Refactoring round 7: split BehaviorSystem, fewer copies (#85)
Recurring cleanup round over the code since round 6 (056a622), 105
commits. Behaviour is unchanged. Duplicated code in src went from 29
clones with 229 lines (0.32 %) to 14 clones with 109 lines (0.15 %),
measured with jscpd and the same settings as round 6.
- BehaviorSystem.ts shrinks from 941 to 589 lines. Collecting the
pulses (keys, timers, collisions, animations) moved to
behaviorPulses.ts. The NodeContext the nodes receive moved to
behaviorContext.ts. Imports from outside stay the same, TimeScaled
is still exported from BehaviorSystem.ts.
- PointKindButton and drawPointGrip replace the "Point:" button with
its menu and the point and handle drawing that the curve and the
vector art tab each had on their own.
- lerpVec, cubicAt and splitCubic in src/core/util/vec.ts replace four
copies of the cubic curve and two de Casteljau splits.
- mapNested in Properties.ts is the one walk over lists and structs
that liveObjects and objectRefs did separately. withFieldRenamed
and withoutField replace three copies of renaming a struct field
and two of removing one.
- jsonBody and staleRevision build the JSON body and turn a 409 into
StaleRevisionError for all stores. The auth store turns a 401 into
its error in one place.
- Smaller helpers: structAssetFrom for the two "Make struct type"
commands, shownObject in the inspector, selectedItem for the size
gizmo and the polygon corners, solidTiles in the tilemap collision,
requireVariablesAsset and requireBehaviorVariable.
- Left as is on purpose: the plain forwarding from the collision and
physics systems to Solids, the pointer wiring of the canvases and
the identical last lines of the two make commands.
- Removed unused code: tilemapTileIds and tilemapsUsing were never
called since #128 and #131. EditorState.runtimeProject became unused
with GameRun in #266. The assetId getter of the make commands was
read only by a test.
- 60 values used only in their own file lost their export. The shader
and particle node catalogues stay exported as in round 6.
- Five comments that told history now describe the current state. The
doc comment of BehaviorSystem sits on the class again.
- README lists the struct, clip area and curve assets and mentions the
game link from #266.
Findings:
- Deleting a tile that is painted on a tilemap leaves its cells in
place, and they draw nothing. The delete dialog does not mention the
tilemaps. That was probably the job of the unused tilemapsUsing.
Left as is, because fixing it changes behaviour.
- The solid filter for tile surfaces was untested. A bent filter
stayed green. A new test in tests/core/surface.test.ts now fails
against it.
Tests:
- ./check.sh: 195 files, 2815 tests, exit 0 (one test removed with
tilemapsUsing, one added for tile surfaces).
- Full e2e run: ALL PASSED, 1580 checks.
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks, all passed.
The example project files were restored byte for byte afterwards.
- Counter-checks, 12, all fail as expected: 10 in unit tests (among
them mapNested, splitCubic, cubicAt, staleRevision, the stay pulse,
the late timer and the time scale), 2 in the browser (Point button
always enabled fails 47, selectedItem without a hit fails 29).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dded62b9f9
commit
bc688f3efc
93 files changed
+914
-902
No files matched your search
@@ -27,7 +27,7 @@ import { dialogButtons } from './FormDialog';
|
||||
|
||||
/** The dialog keeps this size whatever the list turns out to be, so it does not jump on opening. */
|
||||
export const PICKER_WIDTH = 560;
|
||||
export const PICKER_HEIGHT = 340;
|
||||
const PICKER_HEIGHT = 340;
|
||||
|
||||
/** One offered file, with the asset that already wears it (if any). */
|
||||
export type PickerItem<S extends StoredFile = StoredFile> = S & { assetName: string | null };
|
||||
@@ -46,7 +46,7 @@ export function withAssetNames<S extends StoredFile>(
|
||||
}
|
||||
|
||||
/** The line under the list for a picked file: its upload name and size, and who wears it. */
|
||||
export function describeStored(item: PickerItem): string {
|
||||
function describeStored(item: PickerItem): string {
|
||||
const size = item.name ? `${item.name} – ${formatBytes(item.bytes)}` : formatBytes(item.bytes);
|
||||
return item.assetName ? `${size} – already in the project as "${item.assetName}"` : size;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Dropdown, Menu } from '../ui';
|
||||
import { POINT_KINDS, type PointKind } from './polygonEdit';
|
||||
|
||||
/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */
|
||||
export const POINT_BUTTON_WIDTH = 150;
|
||||
|
||||
const POINT_KIND_LABELS: Record<PointKind, string> = {
|
||||
straight: 'Straight',
|
||||
corner: 'Corner',
|
||||
smooth: 'Smooth',
|
||||
symmetric: 'Symmetric',
|
||||
};
|
||||
|
||||
/** What the button edits: the point last picked on a canvas, null while none is. */
|
||||
export interface PointKindTarget {
|
||||
readonly pickedKind: PointKind | null;
|
||||
setPickedKind(kind: PointKind): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's point type (#195), in the Vector art and the Curve tab: the four types a point can
|
||||
* be, the one it is now ticked.
|
||||
*/
|
||||
export class PointKindButton extends Dropdown {
|
||||
constructor(private readonly target: () => PointKindTarget) {
|
||||
super(pointLabel(null), { width: POINT_BUTTON_WIDTH });
|
||||
this.onClick = () => this.openMenu();
|
||||
}
|
||||
|
||||
/** Follows the picked point: its type, or disabled while none is picked. */
|
||||
sync(): void {
|
||||
const kind = this.target().pickedKind;
|
||||
this.setText(pointLabel(kind));
|
||||
this.setEnabled(kind !== null);
|
||||
}
|
||||
|
||||
private openMenu(): void {
|
||||
const items = POINT_KINDS.map((kind) => ({
|
||||
label: POINT_KIND_LABELS[kind],
|
||||
checked: () => this.target().pickedKind === kind,
|
||||
onSelect: () => this.target().setPickedKind(kind),
|
||||
}));
|
||||
new Menu(items).openBelow(this);
|
||||
}
|
||||
}
|
||||
|
||||
function pointLabel(kind: PointKind | null): string {
|
||||
return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const RADIUS = 6;
|
||||
* Inset of icon and name inside a card. Half the theme padding was too tight once the cards got a
|
||||
* border of their own, so the row keeps its own value instead of `Theme.padding`.
|
||||
*/
|
||||
export const TAB_PADDING = 6;
|
||||
const TAB_PADDING = 6;
|
||||
|
||||
/**
|
||||
* One card of the tab row. It is drawn as a real index card rather than as a toggled button: a
|
||||
|
||||
@@ -22,7 +22,7 @@ export const RULER_HEIGHT = 22;
|
||||
export const ROW_HEIGHT = 26;
|
||||
export const MIN_CELL_WIDTH = 4;
|
||||
export const MAX_CELL_WIDTH = 48;
|
||||
export const DEFAULT_CELL_WIDTH = 16;
|
||||
const DEFAULT_CELL_WIDTH = 16;
|
||||
/** Side of a key's diamond, in pixels – constant, so it stays grabbable when the cells are narrow. */
|
||||
export const KEY_SIZE = 10;
|
||||
/** Every `n`-th frame gets a number on the ruler; chosen so the numbers never overlap. */
|
||||
|
||||
@@ -147,7 +147,7 @@ export function assetNameForFile(fileName: string, fallback: string): string {
|
||||
}
|
||||
|
||||
/** Opens the browser's file dialog; resolves empty when the dialog was cancelled. */
|
||||
export function pickFiles(accept: string): Promise<File[]> {
|
||||
function pickFiles(accept: string): Promise<File[]> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
|
||||
@@ -101,7 +101,7 @@ function pickedObject(state: EditorState, holderId: string): string {
|
||||
* is an original or an earlier paste; otherwise under the parent it had, if this holder has it,
|
||||
* else on top.
|
||||
*/
|
||||
export function pasteParents(
|
||||
function pasteParents(
|
||||
project: Project,
|
||||
holder: ObjectHolder,
|
||||
clip: ObjectClip,
|
||||
@@ -156,7 +156,7 @@ export function pasteObjects(
|
||||
}
|
||||
|
||||
/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */
|
||||
export function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean {
|
||||
function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean {
|
||||
return paletteEntries(project, asset).some(
|
||||
(entry) =>
|
||||
entry.def.type === node.type &&
|
||||
@@ -165,7 +165,7 @@ export function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode):
|
||||
}
|
||||
|
||||
/** The part of a graph copy `asset` can take, and the nodes it cannot. */
|
||||
export function fittingPart(
|
||||
function fittingPart(
|
||||
project: Project,
|
||||
asset: GraphAsset,
|
||||
clip: GraphClip,
|
||||
|
||||
@@ -14,8 +14,8 @@ import type { CurveAsset, VectorPoint, VectorRing } from '../../core/model/Asset
|
||||
import { curveExtent, curveValue, sanitizedCurve } from '../../core/model/curve';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { handleAt } from '../../core/model/vectorart';
|
||||
import { cancelFrameWork, onFrame, pressCount, Theme, textStyle } from '../../ui';
|
||||
import { DRAG_THRESHOLD, drawPointGrip, POINT_SIZE } from '../pointGrips';
|
||||
import {
|
||||
type PointKind,
|
||||
pointKind,
|
||||
@@ -45,10 +45,6 @@ import {
|
||||
withCurvePointSet,
|
||||
} from './curveView';
|
||||
|
||||
/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */
|
||||
const POINT_SIZE = 8;
|
||||
const HANDLE_RADIUS = 4;
|
||||
const DRAG_THRESHOLD = 3;
|
||||
/** Steps the drawn line is walked in; a bounce needs this many to keep its corners. */
|
||||
const LINE_STEPS = 240;
|
||||
/** Where the preview's second dot runs, in units of `t` right of the graph. */
|
||||
@@ -468,28 +464,8 @@ export class CurveCanvas extends SheetCanvas<CurveAsset> {
|
||||
|
||||
/** Every point with its handles, in widget pixels so they keep their size. */
|
||||
private drawPoints(points: readonly VectorPoint[]): void {
|
||||
const half = POINT_SIZE / 2;
|
||||
for (const [index, point] of points.entries()) {
|
||||
const at = this.local(point.x, point.y);
|
||||
for (const side of ['in', 'out'] as const) {
|
||||
const handle = handleAt(point, side);
|
||||
if (!handle) continue;
|
||||
const tip = this.local(handle.x, handle.y);
|
||||
this.overlay
|
||||
.moveTo(at.x, at.y)
|
||||
.lineTo(tip.x, tip.y)
|
||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
this.overlay
|
||||
.circle(tip.x, tip.y, HANDLE_RADIUS)
|
||||
.fill(Theme.colors.viewport)
|
||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
}
|
||||
const isPicked = index === this.picked;
|
||||
this.overlay
|
||||
.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE)
|
||||
.fill(isPicked ? Theme.colors.accent : Theme.colors.accentText)
|
||||
.stroke({ width: 1, color: isPicked ? Theme.colors.accentText : Theme.colors.accent });
|
||||
}
|
||||
for (const [index, point] of points.entries())
|
||||
drawPointGrip(this.overlay, point, index === this.picked, (t, v) => this.local(t, v));
|
||||
}
|
||||
|
||||
/** The playhead's knob: under the graph and its `t` numbers, below whatever the curve reaches. */
|
||||
|
||||
@@ -3,26 +3,13 @@ import { curvePresets } from '../../core/model/curvePresets';
|
||||
import { Button, Dropdown, Label, Menu, type MenuEntry, NumberField } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
import { POINT_KINDS, type PointKind } from '../polygonEdit';
|
||||
import { PointKindButton } from '../PointKindButton';
|
||||
import type { EditorState } from '../state';
|
||||
import { CurveCanvas } from './CurveCanvas';
|
||||
|
||||
const INFO_WIDTH = 90;
|
||||
/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */
|
||||
const POINT_WIDTH = 150;
|
||||
const PLACE_WIDTH = 64;
|
||||
|
||||
const POINT_KIND_LABELS: Record<PointKind, string> = {
|
||||
straight: 'Straight',
|
||||
corner: 'Corner',
|
||||
smooth: 'Smooth',
|
||||
symmetric: 'Symmetric',
|
||||
};
|
||||
|
||||
function pointLabel(kind: PointKind | null): string {
|
||||
return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Curve tab (#247): the canvas edits the curve, the header starts it over from a built-in ease,
|
||||
* sets the picked point's type as in the Vector art tab and its place by number, snaps, and runs the
|
||||
@@ -31,7 +18,7 @@ function pointLabel(kind: PointKind | null): string {
|
||||
export class CurveEditor extends AssetTabEditor<CurveAsset, CurveCanvas> {
|
||||
/** Public for the check scripts. */
|
||||
readonly presetButton = new Dropdown('Preset');
|
||||
readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH });
|
||||
readonly pointButton = new PointKindButton(() => this.canvas);
|
||||
readonly tField: NumberField;
|
||||
readonly vField: NumberField;
|
||||
readonly snap = new SnapToggle(() => this.canvas);
|
||||
@@ -40,7 +27,6 @@ export class CurveEditor extends AssetTabEditor<CurveAsset, CurveCanvas> {
|
||||
constructor(state: EditorState) {
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.presetButton.onClick = () => this.openPresetMenu();
|
||||
this.pointButton.onClick = () => this.openPointMenu();
|
||||
this.tField = this.placeField((t, commit) => this.movePicked(t, null, commit));
|
||||
this.vField = this.placeField((v, commit) => this.movePicked(null, v, commit));
|
||||
this.previewButton = new Button('Preview', {
|
||||
@@ -109,19 +95,8 @@ export class CurveEditor extends AssetTabEditor<CurveAsset, CurveCanvas> {
|
||||
new Menu(entries, { searchable: true }).openBelow(this.presetButton);
|
||||
}
|
||||
|
||||
private openPointMenu(): void {
|
||||
const items = POINT_KINDS.map((kind) => ({
|
||||
label: POINT_KIND_LABELS[kind],
|
||||
checked: () => this.canvas.pickedKind === kind,
|
||||
onSelect: () => this.canvas.setPickedKind(kind),
|
||||
}));
|
||||
new Menu(items).openBelow(this.pointButton);
|
||||
}
|
||||
|
||||
private syncPoint(): void {
|
||||
const kind = this.canvas.pickedKind;
|
||||
this.pointButton.setText(pointLabel(kind));
|
||||
this.pointButton.setEnabled(kind !== null);
|
||||
this.pointButton.sync();
|
||||
const place = this.canvas.pickedPlace;
|
||||
this.tField.setNumber(place?.x ?? 0);
|
||||
this.vField.setNumber(place?.y ?? 0);
|
||||
|
||||
@@ -25,7 +25,7 @@ const SEEK_STEPS = 1000;
|
||||
const FIT_MARGIN = 0.12;
|
||||
|
||||
/** One pass of the preview: the dot runs `PREVIEW_RUN` seconds, then rests at the end. */
|
||||
export const PREVIEW_RUN = 1;
|
||||
const PREVIEW_RUN = 1;
|
||||
const PREVIEW_REST = 0.5;
|
||||
|
||||
export function curveToImage(points: readonly VectorPoint[]): VectorRing {
|
||||
|
||||
@@ -67,7 +67,7 @@ class FontList extends RowList<FontItem, FontRow> {
|
||||
}
|
||||
}
|
||||
|
||||
export class FontPicker extends FilePicker<FontItem> {
|
||||
class FontPicker extends FilePicker<FontItem> {
|
||||
declare protected readonly list: FontList;
|
||||
|
||||
constructor(private readonly deps: FontImportDeps) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAnd
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
|
||||
export const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2';
|
||||
const FONT_ACCEPT = '.ttf,.otf,.woff,.woff2';
|
||||
|
||||
const FONT_EXTENSION = /\.(ttf|otf|woff|woff2)$/i;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface Rect {
|
||||
|
||||
/** Length of an arrowhead along the line, and half its width across. */
|
||||
export const ARROW = 10;
|
||||
export const ARROW_HALF_WIDTH = 4.5;
|
||||
const ARROW_HALF_WIDTH = 4.5;
|
||||
/** Air between a node's border and the end of the arrow touching it. */
|
||||
export const BORDER_GAP = 7;
|
||||
/** How far apart two transitions between the same pair of states are pushed. */
|
||||
|
||||
@@ -315,7 +315,7 @@ export interface Field {
|
||||
* The row alone, or – for a field with something below it – the row with that under it, `depth`
|
||||
* indent steps in.
|
||||
*/
|
||||
export function withBelow(row: Widget, field: Field, depth: number): Widget {
|
||||
function withBelow(row: Widget, field: Field, depth: number): Widget {
|
||||
if (!field.below) return row;
|
||||
const stack = new VStack({ gap: Theme.gap });
|
||||
stack.add(row);
|
||||
@@ -362,7 +362,7 @@ export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef
|
||||
}
|
||||
|
||||
/** The handlers of a struct field that is a struct again: its own fields answer one step deeper. */
|
||||
export function nestedStructHandlers(handlers: FieldHandlers, key: string): FieldHandlers {
|
||||
function nestedStructHandlers(handlers: FieldHandlers, key: string): FieldHandlers {
|
||||
const per = handlers.structFieldHandlers;
|
||||
return per ? { ...READ_ONLY, structFieldHandlers: (path, field) => per([key, ...path], field) } : READ_ONLY;
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
type ObjectRef,
|
||||
resolvedHolder,
|
||||
} from '../../core/model/instance';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import type { Axis, PropertyBag } from '../../core/model/Properties';
|
||||
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
|
||||
import type { SceneObject } from '../../core/model/SceneObject';
|
||||
import {
|
||||
deviatesFromAsset,
|
||||
deviatesFromTemplate,
|
||||
@@ -28,6 +30,18 @@ export function viewOf(project: Project, holderId: string): ObjectHolder | undef
|
||||
return holder && resolvedHolder(project, holder);
|
||||
}
|
||||
|
||||
/** The object as the inspector addresses it, with its holder and the asset it was placed from. */
|
||||
export function shownObject(
|
||||
project: Project,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
): { view: ObjectHolder; object: SceneObject; asset: Asset } | null {
|
||||
const view = viewOf(project, holderId);
|
||||
const object = view && findObject(view, objectId);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
return view && object && asset ? { view, object, asset } : null;
|
||||
}
|
||||
|
||||
export function targetOf(project: Project, holderId: string, objectId: string): InstanceTarget | undefined {
|
||||
const holder = findObjectHolder(project, holderId);
|
||||
return holder && instanceTargetOf(project, holder, objectId);
|
||||
@@ -56,10 +70,9 @@ export function deviations(
|
||||
objectId: string,
|
||||
key: string,
|
||||
): { template: boolean; asset: boolean; applyToAsset: boolean } {
|
||||
const view = viewOf(state.project, holderId);
|
||||
const object = view && findObject(view, objectId);
|
||||
const asset = object && findAsset(state.project, object.assetId);
|
||||
if (!view || !object || !asset) return { template: false, asset: false, applyToAsset: false };
|
||||
const shown = shownObject(state.project, holderId, objectId);
|
||||
if (!shown) return { template: false, asset: false, applyToAsset: false };
|
||||
const { object, asset } = shown;
|
||||
const target = targetOf(state.project, holderId, objectId);
|
||||
const fromAsset = deviatesFromAsset(state.project, object, asset, key);
|
||||
return {
|
||||
|
||||
@@ -63,7 +63,15 @@ import { parentRow } from './parentRow';
|
||||
import { listSource, previewingHandlers, structFields } from './previewHandlers';
|
||||
import { overrideValue, tilemapSizeValue } from './valueSpecs';
|
||||
import { buildVariable } from './variableRows';
|
||||
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
|
||||
import {
|
||||
currentProps,
|
||||
currentRef,
|
||||
deviations,
|
||||
positionMoved,
|
||||
shownObject,
|
||||
targetOf,
|
||||
viewOf,
|
||||
} from './objectQueries';
|
||||
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
|
||||
|
||||
/**
|
||||
@@ -140,10 +148,9 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
|
||||
|
||||
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
|
||||
const { project } = host.state;
|
||||
const view = viewOf(project, holderId);
|
||||
const object = view && findObject(view, objectId);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
if (!view || !object || !asset) return;
|
||||
const shown = shownObject(project, holderId, objectId);
|
||||
if (!shown) return;
|
||||
const { view, object, asset } = shown;
|
||||
const target = targetOf(project, holderId, objectId);
|
||||
|
||||
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
|
||||
@@ -230,10 +237,9 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
||||
*/
|
||||
export function buildOwnRows(host: InspectorHost, holderId: string, objectId: string, inTab = false): void {
|
||||
const { project } = host.state;
|
||||
const view = viewOf(project, holderId);
|
||||
const object = view && findObject(view, objectId);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
if (!object || !asset) return;
|
||||
const shown = shownObject(project, holderId, objectId);
|
||||
if (!shown) return;
|
||||
const { object, asset } = shown;
|
||||
const values = resolveSceneObjectProps(project, asset, object);
|
||||
const own = objectRows(project, asset, object).filter((def) => isRowShown(def, values));
|
||||
host.objectChoices = () => pickableObjects(host.state.project, holderId, objectId);
|
||||
@@ -339,10 +345,9 @@ function contentSection(
|
||||
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
|
||||
export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
|
||||
const { project } = host.state;
|
||||
const view = viewOf(project, holderId);
|
||||
const object = view && findObject(view, objectId);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
if (!view || !object || !asset) return false;
|
||||
const shown = shownObject(project, holderId, objectId);
|
||||
if (!shown) return false;
|
||||
const { object, asset } = shown;
|
||||
host.nameField?.setValue(object.name);
|
||||
// A rename of the asset changes no structure, so it arrives here rather than as a rebuild.
|
||||
host.fields.get(ASSET_ROW)?.setValue(asset.name);
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import {
|
||||
type FieldValue,
|
||||
isList,
|
||||
isStruct,
|
||||
itemTypeOf,
|
||||
@@ -34,7 +33,9 @@ import {
|
||||
type StructValue,
|
||||
textOf,
|
||||
untag,
|
||||
withFieldRenamed,
|
||||
withItemType,
|
||||
withoutField,
|
||||
} from '../../core/model/Properties';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import { structAssetOf, typedItem, typeFields } from '../../core/model/structTypes';
|
||||
@@ -463,7 +464,7 @@ export class InlineList extends VStack {
|
||||
const name = locked
|
||||
? new Label(field.key, { style: 'muted', width: nameWidth(depth), padding: Theme.fieldPadding })
|
||||
: nameField(field.key, depth, (to) => {
|
||||
if (to && to !== field.key) this.replaceAt(access, path, renamedField(now(), field.key, to));
|
||||
if (to && to !== field.key) this.replaceAt(access, path, withFieldRenamed(now(), field.key, to));
|
||||
});
|
||||
line.add(fieldMark);
|
||||
line.add(name);
|
||||
@@ -607,18 +608,6 @@ function addFieldMenu(add: (kind: NestedKind) => void): Dropdown {
|
||||
return button;
|
||||
}
|
||||
|
||||
const withoutField = (bundle: PropertyValue, key: string): ListItem => {
|
||||
const next = { ...structItemOf(bundle) };
|
||||
delete next[key];
|
||||
return next;
|
||||
};
|
||||
|
||||
const renamedField = (bundle: PropertyValue, from: string, to: string): ListItem => {
|
||||
const next: Record<string, FieldValue> = {};
|
||||
for (const [key, value] of Object.entries(structItemOf(bundle))) next[key === from ? to : key] = value;
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* An item's kind, and for a struct its type and the shape of every field below it – what its rows
|
||||
* are built from.
|
||||
|
||||
@@ -49,7 +49,7 @@ export function groupMenuItems(
|
||||
* Asks for the name of a new group – or, from a dropdown, for an existing group to put the objects
|
||||
* into. A group all of them are in already is not offered.
|
||||
*/
|
||||
export function groupObjects(state: EditorState, source: ObjectHolder, ids: readonly string[]): void {
|
||||
function groupObjects(state: EditorState, source: ObjectHolder, ids: readonly string[]): void {
|
||||
const title = ids.length > 1 ? `Group ${ids.length} objects` : 'Group the object';
|
||||
const groups = (source.groups ?? []).filter((g) => {
|
||||
const members = groupMemberIds(source, g.id);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { dialogSlot, FormDialog } from '../FormDialog';
|
||||
const FIELD_WIDTH = 220;
|
||||
|
||||
/** The dropdown entry that makes a group instead of joining one. */
|
||||
export const NEW_GROUP = 'New group';
|
||||
const NEW_GROUP = 'New group';
|
||||
|
||||
const slot = dialogSlot<GroupNameDialog>();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The small symbols in inspector rows and lists (#214): kind marks, row actions, transition arrows.
|
||||
* They used to be characters, which the bundled Latin font does not have.
|
||||
* Drawn, not typed: the bundled Latin font has none of these characters.
|
||||
*/
|
||||
import type { Kind } from '../../core/properties/kinds';
|
||||
import apply from './icons/apply.svg?raw';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* The top bar's icons (#183). It carries no captions any more: menu, undo and redo on the left,
|
||||
* play and pause in the middle, so each button is its picture and nothing else.
|
||||
* The top bar's icons (#183). It carries no captions: menu, undo and redo on the left, play and
|
||||
* pause in the middle, so each button is its picture and nothing else.
|
||||
*/
|
||||
import type { VisibilityPart } from '../../core/model/sceneVisibility';
|
||||
import eye from './icons/eye.svg?raw';
|
||||
|
||||
@@ -57,9 +57,10 @@ class PluginRow extends HStack {
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugins (#114, #119), in the user management tab since #179: every file in `data/plugins/` with a switch. Switching one on loads
|
||||
* and activates it there and then, switching it off takes its tabs and listeners away again – the
|
||||
* switch is part of the open project, so it travels with it and undo takes it back.
|
||||
* The plugins (#114, #119) in the user management tab (#179): every file in `data/plugins/` with a
|
||||
* switch. Switching one on loads and activates it there and then, switching it off takes its tabs
|
||||
* and listeners away again – the switch is part of the open project, so it travels with it and undo
|
||||
* takes it back.
|
||||
*
|
||||
* A plugin that is off is never loaded, which is why its row has no name until it has run once:
|
||||
* reading a name out of it would mean running it.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Graphics } from 'pixi.js';
|
||||
import type { VectorPoint } from '../core/model/Asset';
|
||||
import type { Vec2 } from '../core/model/Properties';
|
||||
import { handleAt } from '../core/model/vectorart';
|
||||
import { Theme } from '../ui';
|
||||
|
||||
/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */
|
||||
export const POINT_SIZE = 8;
|
||||
/** Radius of a Bezier handle's knob, in screen pixels. */
|
||||
export const HANDLE_RADIUS = 4;
|
||||
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
|
||||
export const DRAG_THRESHOLD = 3;
|
||||
|
||||
/**
|
||||
* One point of a Vector art ring or a curve with its handles, in widget pixels so they keep their
|
||||
* size; `toLocal` says where a place in the picture is drawn.
|
||||
*/
|
||||
export function drawPointGrip(
|
||||
g: Graphics,
|
||||
point: VectorPoint,
|
||||
picked: boolean,
|
||||
toLocal: (x: number, y: number) => Vec2,
|
||||
): void {
|
||||
const at = toLocal(point.x, point.y);
|
||||
for (const side of ['in', 'out'] as const) {
|
||||
const handle = handleAt(point, side);
|
||||
if (!handle) continue;
|
||||
const tip = toLocal(handle.x, handle.y);
|
||||
g.moveTo(at.x, at.y).lineTo(tip.x, tip.y).stroke({ width: 1, color: Theme.colors.marquee });
|
||||
g.circle(tip.x, tip.y, HANDLE_RADIUS)
|
||||
.fill(Theme.colors.viewport)
|
||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
}
|
||||
const half = POINT_SIZE / 2;
|
||||
g.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE)
|
||||
.fill(picked ? Theme.colors.accent : Theme.colors.accentText)
|
||||
.stroke({ width: 1, color: picked ? Theme.colors.accentText : Theme.colors.accent });
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
import type { VectorPoint, VectorPointMode, VectorRing } from '../core/model/Asset';
|
||||
import type { Vec2 } from '../core/model/Properties';
|
||||
import { closestOnSegment } from '../core/util/vec';
|
||||
import { closestOnSegment, cubicAt, splitCubic } from '../core/util/vec';
|
||||
// Where a handle sits in canvas pixels; it is stored as an offset, and the model says so once.
|
||||
import { cloneVectorPoint, cloneVectorRing, handleAt } from '../core/model/vectorart';
|
||||
|
||||
@@ -162,11 +162,7 @@ export function nearestOnSegment(
|
||||
export function pointOnSegment(from: VectorPoint, to: VectorPoint, t: number): Vec2 {
|
||||
const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y };
|
||||
const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y };
|
||||
const u = 1 - t;
|
||||
return {
|
||||
x: u * u * u * from.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * to.x,
|
||||
y: u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y,
|
||||
};
|
||||
return cubicAt(from, c1, c2, to, t);
|
||||
}
|
||||
|
||||
/** A ring walked as a polyline – for hit-testing its inside and for drawing it in the editor. */
|
||||
@@ -318,20 +314,16 @@ export function withPointOnSegment(ring: VectorRing, index: number, t: number):
|
||||
if (from.out || to.in) {
|
||||
const c1 = handleAt(from, 'out') ?? { x: from.x, y: from.y };
|
||||
const c2 = handleAt(to, 'in') ?? { x: to.x, y: to.y };
|
||||
const a = lerp(from, c1, t);
|
||||
const b = lerp(c1, c2, t);
|
||||
const c = lerp(c2, to, t);
|
||||
const d = lerp(a, b, t);
|
||||
const e = lerp(b, c, t);
|
||||
const { left, right } = splitCubic([from, c1, c2, to], t);
|
||||
const next = points[(index + 1) % ring.points.length]!;
|
||||
setHandle(points[index]!, 'out', a);
|
||||
setHandle(next, 'in', c);
|
||||
setHandle(points[index]!, 'out', left[1]!);
|
||||
setHandle(next, 'in', right[2]!);
|
||||
// The split keeps the directions but shortens one handle of each end: no longer symmetric,
|
||||
// still smooth. The new point sits on a curve, so its handles are in line – smooth as well.
|
||||
for (const end of [points[index]!, next]) if (end.mode === 'symmetric') end.mode = 'smooth';
|
||||
inserted = { x: at.x, y: at.y, mode: 'smooth' };
|
||||
setHandle(inserted, 'in', d);
|
||||
setHandle(inserted, 'out', e);
|
||||
setHandle(inserted, 'in', left[2]!);
|
||||
setHandle(inserted, 'out', right[1]!);
|
||||
if (!inserted.in && !inserted.out) delete inserted.mode;
|
||||
}
|
||||
points.splice(index + 1, 0, inserted);
|
||||
@@ -372,7 +364,3 @@ function setHandle(point: VectorPoint, side: 'in' | 'out', at: Vec2): void {
|
||||
if (Math.hypot(offset.x, offset.y) < 1e-6) delete point[side];
|
||||
else point[side] = offset;
|
||||
}
|
||||
|
||||
function lerp(a: Vec2, b: Vec2, t: number): Vec2 {
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||
}
|
||||
@@ -81,7 +81,7 @@ class SoundList extends RowList<SoundItem, SoundRow> {
|
||||
}
|
||||
}
|
||||
|
||||
export class SoundPicker extends FilePicker<SoundItem> {
|
||||
class SoundPicker extends FilePicker<SoundItem> {
|
||||
declare protected readonly list: SoundList;
|
||||
|
||||
constructor(private readonly deps: SoundImportDeps) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAnd
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/** What the file dialog offers and a drop accepts; the server rejects everything else anyway. */
|
||||
export const SOUND_ACCEPT = '.mp3,.ogg,.wav,.m4a';
|
||||
const SOUND_ACCEPT = '.mp3,.ogg,.wav,.m4a';
|
||||
|
||||
const SOUND_EXTENSION = /\.(mp3|ogg|wav|m4a)$/i;
|
||||
|
||||
|
||||
@@ -308,11 +308,6 @@ export class EditorState {
|
||||
return findScene(this.project, this.project.activeSceneId);
|
||||
}
|
||||
|
||||
/** The play-mode copy while a run is on, else null – what a scene swap rebuilds its world from. */
|
||||
get runtimeProject(): Project | null {
|
||||
return this.mode === 'play' ? this.project : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which scene Play builds its world from. Two answers rather than one, because the project has
|
||||
* two: where the game begins, and what you are working on this minute. The switch says which
|
||||
|
||||
@@ -97,7 +97,7 @@ class ImageGrid extends PickerList<ImageItem, Tile> {
|
||||
}
|
||||
}
|
||||
|
||||
export class ImagePicker extends FilePicker<ImageItem> {
|
||||
class ImagePicker extends FilePicker<ImageItem> {
|
||||
declare protected readonly list: ImageGrid;
|
||||
|
||||
constructor(private readonly deps: TextureImportDeps) {
|
||||
|
||||
@@ -152,7 +152,7 @@ export class TextureCanvas extends SheetCanvas<TextureAsset> {
|
||||
super();
|
||||
this.perFrame = new FramePreview(state, 'texture-drag');
|
||||
this.marks.addChild(this.sheet, this.overlay);
|
||||
// Plates under the outlines, as before the split: a corner handle must not sink under a name.
|
||||
// Plates under the outlines: a corner handle must not sink under a name.
|
||||
this.content.addChild(this.image, this.plates, this.marks, this.labelLayer);
|
||||
|
||||
state.on('project', () => this.redraw());
|
||||
|
||||
@@ -34,7 +34,7 @@ export function fitView(imageWidth: number, imageHeight: number, w: number, h: n
|
||||
};
|
||||
}
|
||||
|
||||
export function clampZoom(zoom: number): number {
|
||||
function clampZoom(zoom: number): number {
|
||||
return Math.max(MIN_SHEET_ZOOM, Math.min(MAX_SHEET_ZOOM, zoom));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type ImportKind, type ImportOutcome, importFiles, importStored, pickAnd
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/** What the picker offers and a drop accepts; the server rejects everything else anyway. */
|
||||
export const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
||||
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif';
|
||||
|
||||
/** Longest side a stored texture may have: a 4000×3000 photo costs 48 MB of video memory. */
|
||||
export const MAX_TEXTURE_SIDE = 2048;
|
||||
@@ -51,7 +51,7 @@ export interface PreparedImage {
|
||||
}
|
||||
|
||||
/** The bytes to upload: the file itself when it already fits, otherwise a downscaled WebP. */
|
||||
export async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
async function prepareImage(file: File): Promise<PreparedImage> {
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
|
||||
@@ -316,7 +316,7 @@ export class TileCanvas extends Widget {
|
||||
const regrown = this.board.w !== this.fittedFor.board.w || this.board.h !== this.fittedFor.board.h;
|
||||
if (this.followsFit && regrown) this.needsFit = true;
|
||||
if (this.needsFit && this.w > 0 && this.h > 0 && this.tiles.length > 0) {
|
||||
// The largest step the board fits at, as in the scene view – no longer a free factor.
|
||||
// The largest step the board fits at, as in the scene view.
|
||||
const { w, h } = this.board;
|
||||
const fitted = Math.min((this.w - FIT_MARGIN * 2) / w, (this.h - FIT_MARGIN * 2) / h);
|
||||
const zoom = zoomStepBelow(fitted * this.ratio()) / this.ratio();
|
||||
|
||||
@@ -28,7 +28,7 @@ import type {
|
||||
} from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { cloneVectorRing, findShape, handleAt, vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { cloneVectorRing, findShape, vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { paintMoved, paintWithHandle } from '../../core/model/vectorPaint';
|
||||
import { vectorArtDrawings } from '../../core/model/vectorPath';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
withoutPoint,
|
||||
withSmoothToggled,
|
||||
} from '../polygonEdit';
|
||||
import { DRAG_THRESHOLD, drawPointGrip, HANDLE_RADIUS, POINT_SIZE } from '../pointGrips';
|
||||
import { type EditorState, shapeSelection } from '../state';
|
||||
import { drawChecker } from '../texture/checker';
|
||||
import { SheetCanvas } from '../texture/SheetCanvas';
|
||||
@@ -63,12 +64,6 @@ export type VectorTool = 'select' | 'pen' | 'paint';
|
||||
/** Whose paint the paint tool shows: the fill's or the outline's. */
|
||||
export type PaintTarget = 'fill' | 'stroke';
|
||||
|
||||
/** Side of a point handle in screen pixels – constant, so a point stays grabbable at any zoom. */
|
||||
const POINT_SIZE = 8;
|
||||
/** Radius of a Bezier handle's knob, in screen pixels. */
|
||||
const HANDLE_RADIUS = 4;
|
||||
/** Pixels the pointer must travel before a press becomes a drag rather than a click. */
|
||||
const DRAG_THRESHOLD = 3;
|
||||
/** The grid points snap to while snapping is on, in canvas pixels. */
|
||||
const SNAP_STEP = 8;
|
||||
|
||||
@@ -771,33 +766,14 @@ export class VectorArtCanvas extends SheetCanvas<VectorArtAsset> {
|
||||
/** The selected shape's points and handles, in widget pixels so they keep their size. */
|
||||
private drawShape(shape: VectorShape | undefined): void {
|
||||
if (!shape) return;
|
||||
const half = POINT_SIZE / 2;
|
||||
const picked = this.picked?.shapeId === shape.id ? this.picked : null;
|
||||
const toLocal = (x: number, y: number) => imageToLocal(this.view, x, y);
|
||||
for (const [ringIndex, ring] of shape.rings.entries()) {
|
||||
const screen = ringOutline(ring).map((p) => imageToLocal(this.view, p.x, p.y));
|
||||
const screen = ringOutline(ring).map((p) => toLocal(p.x, p.y));
|
||||
if (screen.length >= 2)
|
||||
this.overlay.poly(screen, ring.closed).stroke({ width: 1, color: Theme.colors.accent });
|
||||
for (const [index, point] of ring.points.entries()) {
|
||||
const at = imageToLocal(this.view, point.x, point.y);
|
||||
const isPicked = picked?.ring === ringIndex && picked.index === index;
|
||||
for (const side of ['in', 'out'] as const) {
|
||||
const handle = handleAt(point, side);
|
||||
if (!handle) continue;
|
||||
const tip = imageToLocal(this.view, handle.x, handle.y);
|
||||
this.overlay
|
||||
.moveTo(at.x, at.y)
|
||||
.lineTo(tip.x, tip.y)
|
||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
this.overlay
|
||||
.circle(tip.x, tip.y, HANDLE_RADIUS)
|
||||
.fill(Theme.colors.viewport)
|
||||
.stroke({ width: 1, color: Theme.colors.marquee });
|
||||
}
|
||||
this.overlay
|
||||
.rect(at.x - half, at.y - half, POINT_SIZE, POINT_SIZE)
|
||||
.fill(isPicked ? Theme.colors.accent : Theme.colors.accentText)
|
||||
.stroke({ width: 1, color: isPicked ? Theme.colors.accentText : Theme.colors.accent });
|
||||
}
|
||||
for (const [index, point] of ring.points.entries())
|
||||
drawPointGrip(this.overlay, point, picked?.ring === ringIndex && picked.index === index, toLocal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,31 +3,18 @@ import { vectorCanvasSize } from '../../core/model/vectorart';
|
||||
import { Button, Dropdown, Menu, type Stack } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { SnapToggle } from '../graph/SnapToggle';
|
||||
import { POINT_KINDS, type PointKind } from '../polygonEdit';
|
||||
import { POINT_BUTTON_WIDTH, PointKindButton } from '../PointKindButton';
|
||||
import type { EditorState } from '../state';
|
||||
import { type PaintTarget, VectorArtCanvas, type VectorTool } from './VectorArtCanvas';
|
||||
|
||||
/** Room kept for the "256 × 256 px · 3 shapes" readout. */
|
||||
const INFO_WIDTH = 190;
|
||||
/** Fixed, so the header does not shift when "Point: Symmetric" becomes "Point: Corner". */
|
||||
const POINT_WIDTH = 150;
|
||||
|
||||
const POINT_KIND_LABELS: Record<PointKind, string> = {
|
||||
straight: 'Straight',
|
||||
corner: 'Corner',
|
||||
smooth: 'Smooth',
|
||||
symmetric: 'Symmetric',
|
||||
};
|
||||
|
||||
function pointLabel(kind: PointKind | null): string {
|
||||
return `Point: ${kind ? POINT_KIND_LABELS[kind] : '–'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Paint tool's menu takes the point type's place in the header (#186) – a point is not picked
|
||||
* with the Paint tool, and the header has no room for both next to the size readout.
|
||||
*/
|
||||
const PAINT_TARGET_WIDTH = POINT_WIDTH;
|
||||
const PAINT_TARGET_WIDTH = POINT_BUTTON_WIDTH;
|
||||
|
||||
const PAINT_TARGET_LABELS: Record<PaintTarget, string> = { fill: 'Fill', stroke: 'Outline' };
|
||||
|
||||
@@ -47,17 +34,16 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
readonly paintButton: Button;
|
||||
readonly paintTargetButton = new Dropdown(paintTargetLabel('fill'), { width: PAINT_TARGET_WIDTH });
|
||||
readonly snap = new SnapToggle(() => this.canvas);
|
||||
readonly pointButton = new Dropdown(pointLabel(null), { width: POINT_WIDTH });
|
||||
readonly pointButton = new PointKindButton(() => this.canvas);
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { infoWidth: INFO_WIDTH });
|
||||
this.selectButton = new Button('Select', { toggle: true, onClick: () => this.setTool('select') });
|
||||
this.penButton = new Button('Pen', { toggle: true, onClick: () => this.setTool('pen') });
|
||||
this.paintButton = new Button('Paint', { toggle: true, onClick: () => this.setTool('paint') });
|
||||
this.pointButton.onClick = () => this.openPointMenu();
|
||||
this.paintTargetButton.onClick = () => this.openPaintTargetMenu();
|
||||
const canvas = new VectorArtCanvas(state);
|
||||
canvas.onPointPicked = () => this.syncPoint();
|
||||
canvas.onPointPicked = () => this.pointButton.sync();
|
||||
this.mount(canvas, [
|
||||
this.selectButton,
|
||||
this.penButton,
|
||||
@@ -69,16 +55,6 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
this.setTool('select');
|
||||
}
|
||||
|
||||
/** The four types a point can be (#195); the one it is now is ticked. */
|
||||
private openPointMenu(): void {
|
||||
const items = POINT_KINDS.map((kind) => ({
|
||||
label: POINT_KIND_LABELS[kind],
|
||||
checked: () => this.canvas.pickedKind === kind,
|
||||
onSelect: () => this.canvas.setPickedKind(kind),
|
||||
}));
|
||||
new Menu(items).openBelow(this.pointButton);
|
||||
}
|
||||
|
||||
/** Whose handles the paint tool shows (#186): the fill's or the outline's. */
|
||||
private openPaintTargetMenu(): void {
|
||||
const items = (['fill', 'stroke'] as const).map((target) => ({
|
||||
@@ -94,12 +70,6 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
this.paintTargetButton.setText(paintTargetLabel(target));
|
||||
}
|
||||
|
||||
private syncPoint(): void {
|
||||
const kind = this.canvas.pickedKind;
|
||||
this.pointButton.setText(pointLabel(kind));
|
||||
this.pointButton.setEnabled(kind !== null);
|
||||
}
|
||||
|
||||
setTool(tool: VectorTool): void {
|
||||
this.canvas.setTool(tool);
|
||||
this.selectButton.setToggled(tool === 'select');
|
||||
@@ -136,6 +106,6 @@ export class VectorArtEditor extends AssetTabEditor<VectorArtAsset, VectorArtCan
|
||||
this.paintButton.setEnabled(asset !== null);
|
||||
this.paintTargetButton.setEnabled(asset !== null);
|
||||
this.snap.setEnabled(asset !== null);
|
||||
this.syncPoint();
|
||||
this.pointButton.sync();
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export class GamePane extends VStack {
|
||||
}
|
||||
|
||||
/** Auto and fit also say what they came to, once there is a camera to size. */
|
||||
export function scaleLabel(mode: GameScale, factor: number | null): string {
|
||||
function scaleLabel(mode: GameScale, factor: number | null): string {
|
||||
if (typeof mode === 'number') return percent(mode);
|
||||
const name = mode === 'auto' ? 'Auto' : 'Fit';
|
||||
return factor === null ? name : `${name} · ${percent(factor)}`;
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { DrawItem } from '../../viewport/items';
|
||||
import { type View, worldToScreen } from '../../viewport/view';
|
||||
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
|
||||
import { FramePreview } from '../framePreview';
|
||||
import type { SceneViewHost } from './sceneViewHost';
|
||||
import { type SceneViewHost, selectedItem } from './sceneViewHost';
|
||||
|
||||
/** Screen pixels around a polygon's corner or edge that grab it. */
|
||||
const CORNER_GRIP = 7;
|
||||
@@ -53,16 +53,9 @@ export class PolygonCornerEditor {
|
||||
return this.corner !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The polygon collider whose corners can be edited: the primary selection, if it is one and
|
||||
* belongs to the view's holder – a placed container's content is edited in its template.
|
||||
*/
|
||||
/** The polygon collider whose corners can be edited: the selected item, if it is one. */
|
||||
private editedPolygon(): DrawItem | null {
|
||||
const state = this.host.state;
|
||||
const s = state.selection;
|
||||
const holder = this.host.holder();
|
||||
if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null;
|
||||
const item = this.host.items().find((i) => i.id === s.objectId);
|
||||
const item = selectedItem(this.host);
|
||||
return item?.type === 'collider' && item.props.shape === 'polygon' ? item : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { objectRef } from '../../core/model/instance';
|
||||
import type { ObjectHolder } from '../../core/model/Project';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import type { DrawItem } from '../../viewport/items';
|
||||
@@ -25,3 +26,15 @@ export interface SceneViewHost {
|
||||
scale(): number;
|
||||
redraw(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the view drew for the primary selection, if that is an object of the view's holder – a
|
||||
* placed container's content is edited in its template.
|
||||
*/
|
||||
export function selectedItem(host: SceneViewHost): DrawItem | null {
|
||||
const { state } = host;
|
||||
const s = state.selection;
|
||||
const holder = host.holder();
|
||||
if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null;
|
||||
return host.items().find((i) => i.id === s.objectId) ?? null;
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
swept,
|
||||
turned,
|
||||
} from '../sizeGizmo';
|
||||
import type { SceneViewHost } from './sceneViewHost';
|
||||
import { type SceneViewHost, selectedItem } from './sceneViewHost';
|
||||
|
||||
/** Screen pixels around a handle that grab it. */
|
||||
const GRIP = 7;
|
||||
@@ -117,11 +117,7 @@ export class SizeGizmoEditor {
|
||||
|
||||
/** The primary selection when it is an object with a turn and a scale this view can edit. */
|
||||
private edited(): DrawItem | null {
|
||||
const state = this.host.state;
|
||||
const s = state.selection;
|
||||
const holder = this.host.holder();
|
||||
if (s?.kind !== 'object' || !holder || !objectRef(state.project, holder, s.objectId)) return null;
|
||||
const item = this.host.items().find((i) => i.id === s.objectId);
|
||||
const item = selectedItem(this.host);
|
||||
return item && hasTransformProps(item.type) ? item : null;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user