diff --git a/src/editor/AssetTabEditor.ts b/src/editor/AssetTabEditor.ts new file mode 100644 index 00000000..a93f5328 --- /dev/null +++ b/src/editor/AssetTabEditor.ts @@ -0,0 +1,156 @@ +import { Graphics } from 'pixi.js'; +import type { Asset } from '../core/model/Asset'; +import { findAsset } from '../core/model/Project'; +import { + Button, + Dropdown, + HStack, + Label, + Menu, + type MenuItem, + Spacer, + Theme, + VStack, + type Widget, +} from '../ui'; +import type { EditorState } from './state'; + +/** The canvas each tab wraps: the header's Fit button and the tab's own plumbing need this much. */ +export interface TabCanvas { + readonly currentAssetId: string; + show(assetId: string): void; + fit(): void; +} + +export interface AssetTabOptions { + /** Shown left of the picker, and in the empty state as "No ". */ + noun: string; + /** Width reserved for the right-hand readout; a stack measures the label once, while empty. */ + infoWidth: number; +} + +/** + * The Graph, Texture and Container tabs. Each follows the selection but keeps showing its last + * asset when the selection moves elsewhere – clicking a sprite must not empty the tab being + * worked in. "Nothing shown" is `''` throughout. + * + * A subclass finishes construction by calling `mount()`: the header needs the canvas (its Fit + * button) and the canvas needs `state`, so the base cannot build one before the other exists. + */ +export abstract class AssetTabEditor< + A extends Asset, + C extends TabCanvas & Widget = TabCanvas & Widget, +> extends VStack { + protected readonly picker: Dropdown; + protected readonly info: Label; + /** Assigned by `mount`. Public because the check hooks and the play loop reach for it. */ + canvas!: C; + private readonly headerBg = new Graphics(); + + constructor( + protected readonly state: EditorState, + private readonly options: AssetTabOptions, + ) { + super(); + this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() }); + this.info = new Label('', { style: 'small', align: 'right' }); + this.info.minWidth = options.infoWidth; + } + + /** Builds the header around `canvas`, wires the state events and shows the first asset. */ + protected mount(canvas: C, extras: readonly Widget[] = []): void { + this.canvas = canvas; + const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); + header.minHeight = Theme.controlHeight + Theme.padding * 2; + header.addChildAt(this.headerBg, 0); + header.add(new Label(this.options.noun, { style: 'muted' })); + header.add(this.picker); + for (const widget of extras) header.add(widget); + header.add(new Button('Fit', { onClick: () => this.canvas.fit() })); + header.add(new Spacer(), 1); + header.add(this.info); + + this.add(header); + this.add(canvas, 1); + + this.state.on('selection', () => this.followSelection()); + this.state.on('project', () => this.refresh()); + this.state.on('load', () => { + this.canvas.show(''); + this.followSelection(); + }); + this.followSelection(); + } + + /** The asset currently shown, `''` for none – the check scripts read this. */ + get currentAssetId(): string { + return this.canvas.currentAssetId; + } + + /** For the check scripts: the picker's width must not follow the name it happens to show. */ + get pickerWidth(): number { + return this.picker.w; + } + + show(assetId: string): void { + this.canvas.show(assetId); + this.refresh(); + } + + /** The selection's id for this kind of asset, `''` when the selection is about something else. */ + protected abstract selectedId(): string; + + protected abstract isMine(asset: Asset): asset is A; + + /** The right-hand readout; `null` is the empty state. */ + protected abstract infoText(asset: A | null): string; + + /** Enable/disable whatever `extras` the subclass passed to `mount`. */ + protected syncExtras(_asset: A | null): void {} + + protected asset(): A | null { + const asset = findAsset(this.state.project, this.canvas.currentAssetId); + return asset && this.isMine(asset) ? asset : null; + } + + protected candidates(): A[] { + return this.state.project.assets + .filter((a): a is A => this.isMine(a)) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + protected followSelection(): void { + const id = this.selectedId(); + // An empty id merely means "the selection is about something else"; the tab keeps its subject. + if (id) this.canvas.show(id); + this.refresh(); + } + + protected refresh(): void { + // A deleted asset leaves the canvas empty; fall back to whatever is left of this kind. + if (!this.asset()) this.canvas.show(this.candidates()[0]?.id ?? ''); + const asset = this.asset(); + this.picker.setText(asset ? asset.name : `No ${this.options.noun.toLowerCase()}`); + this.info.setText(asset ? this.infoText(asset) : ''); + this.syncExtras(asset); + } + + private openPicker(): void { + const current = this.canvas.currentAssetId; + const items: MenuItem[] = this.candidates().map((asset) => ({ + label: asset.id === current ? `● ${asset.name}` : asset.name, + onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }), + })); + if (items.length === 0) + items.push({ label: `No ${this.options.noun.toLowerCase()}s yet`, onSelect: () => undefined }); + new Menu(items, { searchable: true }).openBelow(this.picker); + } + + override redraw(): void { + super.redraw(); + this.headerBg.clear(); + const h = Theme.controlHeight + Theme.padding * 2; + this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader); + this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border); + } +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 04970229..1db1d939 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -3,8 +3,8 @@ import { findAsset, findObjectHolder } from '../core/model/Project'; import type { ProjectStore } from '../core/store/ProjectStore'; import type { TextureStore } from '../core/store/TextureStore'; import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui'; -import { GameViewport } from '../viewport/GameViewport'; -import { SceneViewport } from '../viewport/SceneViewport'; +import { GameViewport } from './viewport/GameViewport'; +import { SceneViewport } from './viewport/SceneViewport'; import { attachAutosave } from './autosave'; import { ContainerEditor } from './container/ContainerEditor'; import { EditorRun } from './EditorRun'; @@ -207,8 +207,6 @@ export class Editor implements PlayHost { return this.projects.boot(); } - // --- PlayHost -------------------------------------------------------------------------------- - showGame(): void { this.showTab('game'); } @@ -217,8 +215,6 @@ export class Editor implements PlayHost { if (this.tab === 'game') this.showTab('scene'); } - // --- frame ----------------------------------------------------------------------------------- - private tick(dt: number): void { this.measureFps(dt); this.play.tick(dt); diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index a5b1dd5b..3246d31a 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -9,7 +9,7 @@ import { CollisionSystem } from '../runtime/systems/CollisionSystem'; import { Input } from '../runtime/systems/Input'; import { World } from '../runtime/World'; import { ui } from '../ui'; -import type { GameViewport } from '../viewport/GameViewport'; +import type { GameViewport } from './viewport/GameViewport'; import type { GraphEditor } from './graph/GraphEditor'; import type { EditorState } from './state'; diff --git a/src/editor/container/ContainerCanvas.ts b/src/editor/container/ContainerCanvas.ts index 192a43be..5c700d29 100644 --- a/src/editor/container/ContainerCanvas.ts +++ b/src/editor/container/ContainerCanvas.ts @@ -2,7 +2,7 @@ import { Graphics } from 'pixi.js'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { Theme } from '../../ui'; import type { DrawItem } from '../../viewport/items'; -import { SceneViewport } from '../../viewport/SceneViewport'; +import { SceneViewport } from '../viewport/SceneViewport'; import { type View, worldToScreen } from '../../viewport/view'; import type { EditorState } from '../state'; diff --git a/src/editor/container/ContainerEditor.ts b/src/editor/container/ContainerEditor.ts index a5f3c8d7..5985db04 100644 --- a/src/editor/container/ContainerEditor.ts +++ b/src/editor/container/ContainerEditor.ts @@ -1,76 +1,16 @@ -import { Graphics } from 'pixi.js'; -import type { ContainerAsset } from '../../core/model/Asset'; -import { findAsset, listContainers } from '../../core/model/Project'; -import { - activeDrag, - Button, - Dropdown, - HStack, - Label, - Menu, - type MenuItem, - Spacer, - Theme, - VStack, -} from '../../ui'; +import type { Asset, ContainerAsset } from '../../core/model/Asset'; +import { activeDrag } from '../../ui'; +import { AssetTabEditor } from '../AssetTabEditor'; import type { EditorState } from '../state'; import { ContainerCanvas } from './ContainerCanvas'; /** Room kept for the "N objects" readout, wide enough for a three-digit template. */ const INFO_WIDTH = 110; -/** - * The Container tab. It follows the selection, but keeps showing the last container when the - * selection moves elsewhere – clicking a sprite must not empty the tab being worked in. - */ -export class ContainerEditor extends VStack { - private readonly canvas: ContainerCanvas; - private readonly picker: Dropdown; - private readonly info: Label; - private readonly headerBg = new Graphics(); - - constructor(private readonly state: EditorState) { - super(); - const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); - header.minHeight = Theme.controlHeight + Theme.padding * 2; - header.addChildAt(this.headerBg, 0); - - header.add(new Label('Container', { style: 'muted' })); - this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() }); - header.add(this.picker); - header.add(new Button('Fit', { onClick: () => this.canvas.fit() })); - header.add(new Spacer(), 1); - this.info = new Label('', { style: 'small', align: 'right' }); - // Reserved, not measured: a stack sizes the label once, from the empty text it was built with. - this.info.minWidth = INFO_WIDTH; - header.add(this.info); - - this.canvas = new ContainerCanvas(state); - this.add(header); - this.add(this.canvas, 1); - - state.on('selection', () => this.followSelection()); - state.on('project', () => this.refresh()); - state.on('load', () => { - this.canvas.show(''); - this.followSelection(); - }); - this.followSelection(); - } - - /** The container currently shown – the check scripts read this. */ - get currentAssetId(): string { - return this.canvas.currentAssetId; - } - - /** For the check scripts: the picker's width must not follow the name it happens to show. */ - get pickerWidth(): number { - return this.picker.w; - } - - show(assetId: string): void { - this.canvas.show(assetId); - this.refresh(); +export class ContainerEditor extends AssetTabEditor { + constructor(state: EditorState) { + super(state, { noun: 'Container', infoWidth: INFO_WIDTH }); + this.mount(new ContainerCanvas(state)); } /** The pending drop of an asset dragged over the template (test hook). */ @@ -89,48 +29,19 @@ export class ContainerEditor extends VStack { return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h }; } - private followSelection(): void { - const id = this.state.selectedContainerId; + protected override selectedId(): string { // A dragged row selects on release, and whether that lands before or after the drop is up // to Pixi's event order. Following it in between would swap the tab to the very container // being dragged, which would then nest in itself, so the target stays put while a drag runs. - if (id && !activeDrag()) this.canvas.show(id); - this.refresh(); + return activeDrag() ? '' : (this.state.selectedContainerId ?? ''); } - private asset(): ContainerAsset | null { - const asset = findAsset(this.state.project, this.canvas.currentAssetId); - return asset && asset.type === 'container' ? asset : null; + protected override isMine(asset: Asset): asset is ContainerAsset { + return asset.type === 'container'; } - private containers(): ContainerAsset[] { - return listContainers(this.state.project).sort((a, b) => a.name.localeCompare(b.name)); - } - - private openPicker(): void { - const current = this.canvas.currentAssetId; - const items: MenuItem[] = this.containers().map((asset) => ({ - label: asset.id === current ? `● ${asset.name}` : asset.name, - onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }), - })); - if (items.length === 0) items.push({ label: 'No containers yet', onSelect: () => undefined }); - new Menu(items, { searchable: true }).openBelow(this.picker); - } - - private refresh(): void { - // A deleted container leaves the canvas empty; fall back to whatever container is left. - if (!this.asset()) this.canvas.show(this.containers()[0]?.id ?? ''); - const asset = this.asset(); - this.picker.setText(asset ? asset.name : 'No container'); - const count = asset?.objects.length ?? 0; - this.info.setText(asset ? `${count} ${count === 1 ? 'object' : 'objects'}` : ''); - } - - override redraw(): void { - super.redraw(); - this.headerBg.clear(); - const h = Theme.controlHeight + Theme.padding * 2; - this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader); - this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border); + protected override infoText(asset: ContainerAsset): string { + const count = asset.objects.length; + return `${count} ${count === 1 ? 'object' : 'objects'}`; } } diff --git a/src/editor/graph/framing.ts b/src/editor/framing.ts similarity index 69% rename from src/editor/graph/framing.ts rename to src/editor/framing.ts index 1dc08ee4..45edf391 100644 --- a/src/editor/graph/framing.ts +++ b/src/editor/framing.ts @@ -40,9 +40,38 @@ export function boxesInBand(boxes: readonly Box[], band: Band): string[] { .map((b) => b.id); } +export interface Bounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +/** + * The rectangle enclosing every box, or null for none. `seed` starts the sum off – the scene view + * passes the origin, because that is where a container is grabbed when placed and a template drawn + * far to one side would otherwise hide it. + */ +export function boundsOf(boxes: readonly Box[], seed?: Bounds): Bounds | null { + let { minX, minY, maxX, maxY } = seed ?? { + minX: Infinity, + minY: Infinity, + maxX: -Infinity, + maxY: -Infinity, + }; + for (const b of boxes) { + minX = Math.min(minX, b.x); + minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.w); + maxY = Math.max(maxY, b.y + b.h); + } + return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : null; +} + /** * The view that shows every box at once, centred, with `margin` screen pixels to spare. - * Never zooms past 1: a two-node graph blown up to fill the screen helps nobody. + * Never zooms past 1: a two-node graph blown up to fill the screen helps nobody. The scene view + * deliberately does not share that cap, which is why only the bounds sum is common. */ export function frameBoxes( boxes: readonly Box[], @@ -50,17 +79,9 @@ export function frameBoxes( margin: number, limits: { min: number; max: number }, ): Framing { - let minX = Infinity; - let minY = Infinity; - let maxX = -Infinity; - let maxY = -Infinity; - for (const b of boxes) { - minX = Math.min(minX, b.x); - minY = Math.min(minY, b.y); - maxX = Math.max(maxX, b.x + b.w); - maxY = Math.max(maxY, b.y + b.h); - } - if (!Number.isFinite(minX)) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 }; + const bounds = boundsOf(boxes); + if (!bounds) return { zoom: 1, panX: view.w / 2, panY: view.h / 2 }; + const { minX, minY, maxX, maxY } = bounds; const width = Math.max(maxX - minX, 1); const height = Math.max(maxY - minY, 1); const fitted = Math.min((view.w - margin * 2) / width, (view.h - margin * 2) / height, 1); diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index c085b393..cf4609b4 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -40,6 +40,7 @@ import { Menu, type MenuItem, onDragChange, + pressCount, type Size, Theme, textStyle, @@ -47,11 +48,18 @@ import { Widget, zoomFactor, } from '../../ui'; -import { pressCount } from '../../ui/clicks'; import { removeNodes } from '../actions'; import { type EditorState, nodeSelection } from '../state'; -import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from './folding'; -import { type Box, boxesInBand, frameBoxes } from './framing'; +import { + buildFoldMarks, + foldedEdgeShown, + type FoldMarkEnd, + foldMarkAt, + foldMarkKey, + foldMarkLabel, + foldMarkRadius, +} from './folding'; +import { type Box, boxesInBand, frameBoxes } from '../framing'; import { NodePalette } from './NodePalette'; import { type PaletteEntry, paletteEntries } from './paletteEntries'; import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView'; @@ -83,14 +91,6 @@ interface FoldMark { text: string; } -/** A mark while the edges are still adding themselves to it. */ -interface PendingMark extends Omit { - names: string[]; - portX: number; - color: number; - hot: boolean; -} - /** * The node graph of one behavior asset. Which behavior that is comes from the outside (`show`), * so the canvas never has an opinion about the selection – the GraphEditor around it does. @@ -107,7 +107,7 @@ export class GraphCanvas extends Widget { private readonly markLabels: Text[] = []; private markLabelCount = 0; private foldMarks: FoldMark[] = []; - private readonly foldPending = new Map(); + private foldEnds: FoldMarkEnd[] = []; /** Edges the last redraw really stroked as a curve – a hidden one is missing from it. */ private readonly strokedEdges = new Set(); private readonly nodeLayer = new Container(); @@ -116,7 +116,7 @@ export class GraphCanvas extends Widget { private readonly band = new Graphics(); private readonly hint = new Text({ text: '', style: textStyle('muted') }); private readonly views = new Map(); - private assetId: string | null = null; + private assetId = ''; private pan = { x: 0, y: 0 }; private zoom = 1; private panning: { startX: number; startY: number; panX: number; panY: number } | null = null; @@ -180,12 +180,12 @@ export class GraphCanvas extends Widget { state.on('mode', () => this.redraw()); } - get currentAssetId(): string | null { + get currentAssetId(): string { return this.assetId; } /** Switching refits the view, since the next graph is a new subject. */ - show(assetId: string | null): void { + show(assetId: string): void { if (this.assetId === assetId) return; this.assetId = assetId; this.sync(); @@ -211,8 +211,6 @@ export class GraphCanvas extends Widget { return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; } - // --- model binding ---------------------------------------------------------------------- - private behaviors(): BehaviorLookup { return behaviorLookup(this.state.project); } @@ -227,7 +225,7 @@ export class GraphCanvas extends Widget { const asset = this.asset(); if (!asset) { // A behavior that was deleted (or undone away) must not linger as a graph. - this.assetId = null; + this.assetId = ''; for (const view of this.views.values()) view.destroy({ children: true }); this.views.clear(); this.redraw(); @@ -306,8 +304,6 @@ export class GraphCanvas extends Widget { return { x: gx * this.zoom + this.pan.x, y: gy * this.zoom + this.pan.y }; } - // --- pointer ----------------------------------------------------------------------------- - private onCanvasPointerDown(e: FederatedPointerEvent): void { // The middle button pans from anywhere, a node under the pointer included – it is the one // gesture that never means "grab this". Nodes let it through for exactly that reason. @@ -613,8 +609,6 @@ export class GraphCanvas extends Widget { } } - // --- menus ------------------------------------------------------------------------------- - private openPalette(globalX: number, globalY: number, at: { x: number; y: number }): void { new NodePalette(this.entries(), (entry) => this.addNode(entry, at)).open(globalX, globalY); } @@ -750,8 +744,6 @@ export class GraphCanvas extends Widget { ]).open(globalX, globalY); } - // --- drawing ----------------------------------------------------------------------------- - override layout(): void { this.hint.position.set(Theme.padding, this.h - Theme.padding - 16); if (this.needsFit) this.fitView(); @@ -792,7 +784,7 @@ export class GraphCanvas extends Widget { this.foldMarks = []; this.markLabelCount = 0; this.strokedEdges.clear(); - this.foldPending.clear(); + this.foldEnds = []; for (const edge of asset.graph.edges) this.drawEdge(edge, isHot(trace?.edges, edge.id), selectedNodes); // After the curves, so no line drawn later crosses a mark that stands for a hidden one. this.drawFoldMarks(); @@ -871,7 +863,7 @@ export class GraphCanvas extends Widget { this.strokedEdges.add(edge.id); } - /** Adds one end of a hidden link to its port's mark, creating that mark on first sight. */ + /** Records one end of a hidden link; `drawFoldMarks` groups them per port afterwards. */ private addFoldMark( edge: GraphEdge, port: { x: number; y: number }, @@ -881,38 +873,25 @@ export class GraphCanvas extends Widget { hot: boolean, ): void { const [side, other] = isOutput ? [edge.from, edge.to.node] : [edge.to, edge.from.node]; - const key = foldMarkKey(side, isOutput); - let mark = this.foldPending.get(key); - if (!mark) { - mark = { - edgeIds: [], - others: [], - names: [], - isOutput, - x: port.x + (isOutput ? FOLD_STUB : -FOLD_STUB), - y: port.y, - portX: port.x, - color, - hot, - }; - this.foldPending.set(key, mark); - } - mark.edgeIds.push(edge.id); - mark.names.push(otherLabel); - if (!mark.others.includes(other)) mark.others.push(other); - // A pulse anywhere in the bundle colours the whole mark: it is the one thing worth spotting. - if (hot && !mark.hot) { - mark.hot = true; - mark.color = color; - } + this.foldEnds.push({ + edgeId: edge.id, + key: foldMarkKey(side, isOutput), + other, + otherLabel, + isOutput, + portX: port.x, + portY: port.y, + color, + hot, + }); } /** A stub out of the port, a dot, and what waits at the other end – one per port, not per link. */ private drawFoldMarks(): void { - for (const mark of this.foldPending.values()) { + for (const mark of buildFoldMarks(this.foldEnds, FOLD_STUB)) { const { x, y, color, isOutput } = mark; this.edges.moveTo(mark.portX, y).lineTo(x, y).stroke({ width: 2, color, alpha: 0.9 }); - const radius = mark.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS; + const radius = foldMarkRadius(mark.edgeIds.length, FOLD_MARK_RADIUS); this.edges.circle(x, y, radius).fill(color); this.edges.circle(x, y, radius).stroke({ width: 1, color: Theme.colors.node }); const label = this.markLabel(); @@ -941,11 +920,7 @@ export class GraphCanvas extends Widget { } private markAt(g: { x: number; y: number }): FoldMark | null { - for (const m of this.foldMarks) { - const radius = m.edgeIds.length > 1 ? FOLD_MARK_RADIUS + 2 : FOLD_MARK_RADIUS; - if (Math.hypot(g.x - m.x, g.y - m.y) <= radius + 4) return m; - } - return null; + return foldMarkAt(this.foldMarks, g, FOLD_MARK_RADIUS); } private syncGrid(): void { @@ -963,8 +938,6 @@ export class GraphCanvas extends Widget { }); } - // --- test hooks ----------------------------------------------------------------------------- - /** Global screen position of a port (tests drive connections with it). */ portGlobal(nodeId: string, port: string, isOutput: boolean): { x: number; y: number } | null { const view = this.views.get(nodeId); diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 67566e66..271cb777 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -1,115 +1,38 @@ -import { Graphics } from 'pixi.js'; -import type { BehaviorAsset } from '../../core/model/Asset'; -import { findAsset } from '../../core/model/Project'; -import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui'; +import type { Asset, BehaviorAsset } from '../../core/model/Asset'; +import { Button } from '../../ui'; +import { AssetTabEditor } from '../AssetTabEditor'; import type { EditorState } from '../state'; import { GraphCanvas } from './GraphCanvas'; /** Room kept for the "N nodes · M links" readout, wide enough for a three-digit graph. */ const INFO_WIDTH = 150; -/** - * The Graph tab. It follows the selection, but keeps showing the last behavior when the - * selection moves elsewhere – clicking a sprite must not empty the graph being edited. - */ -export class GraphEditor extends VStack { - /** The canvas itself: the live redraw while playing and the check scripts reach for it. */ - readonly canvas: GraphCanvas; - private readonly picker: Dropdown; - private readonly info: Label; +export class GraphEditor extends AssetTabEditor { private readonly addButton: Button; - private readonly headerBg = new Graphics(); - constructor(private readonly state: EditorState) { - super(); - const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); - header.minHeight = Theme.controlHeight + Theme.padding * 2; - header.addChildAt(this.headerBg, 0); - - header.add(new Label('Behavior', { style: 'muted' })); - this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() }); - header.add(this.picker); + constructor(state: EditorState) { + super(state, { noun: 'Behavior', infoWidth: INFO_WIDTH }); this.addButton = new Button('Add node…', { onClick: () => this.canvas.addNodeFrom(this.addButton) }); - header.add(this.addButton); - header.add(new Button('Fit', { onClick: () => this.canvas.fit() })); - header.add(new Spacer(), 1); - this.info = new Label('', { style: 'small', align: 'right' }); - // Reserved, not measured: a stack sizes the label once, from the empty text it was built with. - this.info.minWidth = INFO_WIDTH; - header.add(this.info); - - this.canvas = new GraphCanvas(state); - this.add(header); - this.add(this.canvas, 1); - - state.on('selection', () => this.followSelection()); - state.on('project', () => this.refresh()); + this.mount(new GraphCanvas(state), [this.addButton]); // Adding nodes is an edit, and edits made while playing would be thrown away with the run. state.on('mode', () => this.refresh()); - state.on('load', () => { - this.canvas.show(null); - this.followSelection(); - }); - this.followSelection(); } - /** The behavior currently shown – the check scripts read this. */ - get currentAssetId(): string | null { - return this.canvas.currentAssetId; + protected override selectedId(): string { + return this.state.selectedBehaviorId ?? ''; } - /** For the check scripts: the picker's width must not follow the name it happens to show. */ - get pickerWidth(): number { - return this.picker.w; + protected override isMine(asset: Asset): asset is BehaviorAsset { + return asset.type === 'behavior'; } - private followSelection(): void { - const id = this.state.selectedBehaviorId; - // Null merely means "the selection is about something else"; the tab keeps its subject. - if (id) this.canvas.show(id); - this.refresh(); + protected override infoText(asset: BehaviorAsset): string { + const nodes = asset.graph.nodes.length; + const links = asset.graph.edges.length; + return `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}`; } - private asset(): BehaviorAsset | null { - const id = this.canvas.currentAssetId; - const asset = id ? findAsset(this.state.project, id) : undefined; - return asset && asset.type === 'behavior' ? asset : null; - } - - private behaviors(): BehaviorAsset[] { - return this.state.project.assets - .filter((a): a is BehaviorAsset => a.type === 'behavior') - .sort((a, b) => a.name.localeCompare(b.name)); - } - - private openPicker(): void { - const current = this.canvas.currentAssetId; - const items: MenuItem[] = this.behaviors().map((asset) => ({ - label: asset.id === current ? `● ${asset.name}` : asset.name, - onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }), - })); - if (items.length === 0) items.push({ label: 'No behaviors yet', onSelect: () => undefined }); - new Menu(items, { searchable: true }).openBelow(this.picker); - } - - private refresh(): void { - // A deleted behavior leaves the canvas empty; fall back to whatever behavior is left. - if (!this.asset()) this.canvas.show(this.behaviors()[0]?.id ?? null); - const asset = this.asset(); - this.picker.setText(asset ? asset.name : 'No behavior'); - const nodes = asset?.graph.nodes.length ?? 0; - const links = asset?.graph.edges.length ?? 0; - this.info.setText( - asset ? `${nodes} ${nodes === 1 ? 'node' : 'nodes'} · ${links} ${links === 1 ? 'link' : 'links'}` : '', - ); + protected override syncExtras(asset: BehaviorAsset | null): void { this.addButton.setEnabled(Boolean(asset) && this.state.mode === 'edit'); } - - override redraw(): void { - super.redraw(); - this.headerBg.clear(); - const h = Theme.controlHeight + Theme.padding * 2; - this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader); - this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border); - } } diff --git a/src/editor/graph/folding.ts b/src/editor/graph/folding.ts index dec4f124..1cbcb380 100644 Binary files a/src/editor/graph/folding.ts and b/src/editor/graph/folding.ts differ diff --git a/src/editor/texture/TextureEditor.ts b/src/editor/texture/TextureEditor.ts index c1b11874..78ccc68f 100644 --- a/src/editor/texture/TextureEditor.ts +++ b/src/editor/texture/TextureEditor.ts @@ -1,8 +1,7 @@ -import { Graphics } from 'pixi.js'; import { SliceFramesCommand } from '../../core/commands/frameCommands'; -import type { TextureAsset } from '../../core/model/Asset'; -import { findAsset } from '../../core/model/Project'; -import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui'; +import type { Asset, TextureAsset } from '../../core/model/Asset'; +import { Button } from '../../ui'; +import { AssetTabEditor } from '../AssetTabEditor'; import type { EditorState } from '../state'; import { SliceDialog } from './SliceDialog'; import { TextureCanvas } from './TextureCanvas'; @@ -10,61 +9,13 @@ import { TextureCanvas } from './TextureCanvas'; /** Room kept for the "W × H px · N frames" readout, wide enough for a four-digit sheet. */ const INFO_WIDTH = 190; -/** - * The Texture tab. It follows the selection, but keeps showing the last texture when the - * selection moves elsewhere – clicking a sprite must not empty the tab being worked in. - */ -export class TextureEditor extends VStack { - private readonly canvas: TextureCanvas; - private readonly picker: Dropdown; - private readonly info: Label; +export class TextureEditor extends AssetTabEditor { private readonly sliceButton: Button; - private readonly headerBg = new Graphics(); - constructor(private readonly state: EditorState) { - super(); - const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' }); - header.minHeight = Theme.controlHeight + Theme.padding * 2; - header.addChildAt(this.headerBg, 0); - - header.add(new Label('Texture', { style: 'muted' })); - this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() }); - header.add(this.picker); + constructor(state: EditorState) { + super(state, { noun: 'Texture', infoWidth: INFO_WIDTH }); this.sliceButton = new Button('Slice…', { onClick: () => this.openSlice() }); - header.add(this.sliceButton); - header.add(new Button('Fit', { onClick: () => this.canvas.fit() })); - header.add(new Spacer(), 1); - this.info = new Label('', { style: 'small', align: 'right' }); - // Reserved, not measured: a stack sizes the label once, from the empty text it was built with. - this.info.minWidth = INFO_WIDTH; - header.add(this.info); - - this.canvas = new TextureCanvas(state); - this.add(header); - this.add(this.canvas, 1); - - state.on('selection', () => this.followSelection()); - state.on('project', () => this.refresh()); - state.on('load', () => { - this.canvas.show(''); - this.followSelection(); - }); - this.followSelection(); - } - - /** The texture currently shown – the check scripts read this. */ - get currentAssetId(): string { - return this.canvas.currentAssetId; - } - - /** For the check scripts: the picker's width must not follow the name it happens to show. */ - get pickerWidth(): number { - return this.picker.w; - } - - show(assetId: string): void { - this.canvas.show(assetId); - this.refresh(); + this.mount(new TextureCanvas(state), [this.sliceButton]); } /** Where an image pixel of the shown sheet sits on screen – for the check scripts. */ @@ -72,32 +23,22 @@ export class TextureEditor extends VStack { return this.canvas.imageToScreen(x, y); } - private followSelection(): void { - const id = this.state.selectedTextureId; - // Null merely means "the selection is about something else"; the tab keeps its subject. - if (id) this.canvas.show(id); - else if (!this.asset()) this.canvas.show(this.textures()[0]?.id ?? ''); - this.refresh(); + protected override selectedId(): string { + return this.state.selectedTextureId ?? ''; } - private asset(): TextureAsset | null { - const asset = findAsset(this.state.project, this.canvas.currentAssetId); - return asset && asset.type === 'texture' ? asset : null; + protected override isMine(asset: Asset): asset is TextureAsset { + return asset.type === 'texture'; } - private textures(): TextureAsset[] { - return this.state.project.assets - .filter((a): a is TextureAsset => a.type === 'texture') - .sort((a, b) => a.name.localeCompare(b.name)); + protected override infoText(asset: TextureAsset): string { + const count = asset.frames.length; + return `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}`; } - private openPicker(): void { - const items: MenuItem[] = this.textures().map((asset) => ({ - label: asset.id === this.canvas.currentAssetId ? `● ${asset.name}` : asset.name, - onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }), - })); - if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined }); - new Menu(items, { searchable: true }).openBelow(this.picker); + protected override syncExtras(asset: TextureAsset | null): void { + // Slicing needs a known picture size; without one it could only fail with a notice. + this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0)); } private openSlice(): void { @@ -107,23 +48,4 @@ export class TextureEditor extends VStack { this.state.execute(new SliceFramesCommand(asset.id, options, replace)); }).openCentered(); } - - private refresh(): void { - const asset = this.asset(); - this.picker.setText(asset ? asset.name : 'No texture'); - const count = asset?.frames.length ?? 0; - this.info.setText( - asset ? `${asset.width} × ${asset.height} px · ${count} ${count === 1 ? 'frame' : 'frames'}` : '', - ); - // Slicing needs a known picture size; without one it could only fail with a notice. - this.sliceButton.setEnabled(Boolean(asset && asset.width > 0 && asset.height > 0)); - } - - override redraw(): void { - super.redraw(); - this.headerBg.clear(); - const h = Theme.controlHeight + Theme.padding * 2; - this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader); - this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border); - } } diff --git a/src/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts similarity index 93% rename from src/viewport/GameViewport.ts rename to src/editor/viewport/GameViewport.ts index 5ebac780..8737c35b 100644 --- a/src/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -3,13 +3,13 @@ * playing, the model as a still preview otherwise. */ import { type FederatedPointerEvent, Text } from 'pixi.js'; -import { isSymbolType } from '../core/model/AssetTypes'; -import type { EditorState } from '../editor/state'; -import type { World } from '../runtime/World'; -import { textStyle } from '../ui'; -import { type ActiveCamera, cameraOfItems, cameraOfWorld } from './activeCamera'; -import { collectHolderItems, collectWorldItems } from './items'; -import { cameraView, isInside, screenToWorld, type View } from './view'; +import { isSymbolType } from '../../core/model/AssetTypes'; +import type { EditorState } from '../state'; +import type { World } from '../../runtime/World'; +import { textStyle } from '../../ui'; +import { type ActiveCamera, cameraOfItems, cameraOfWorld } from '../../viewport/activeCamera'; +import { collectHolderItems, collectWorldItems } from '../../viewport/items'; +import { cameraView, isInside, screenToWorld, type View } from '../../viewport/view'; import { type Frame, WorldViewport } from './WorldViewport'; const LETTERBOX_COLOR = 0x000000; diff --git a/src/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts similarity index 92% rename from src/viewport/SceneViewport.ts rename to src/editor/viewport/SceneViewport.ts index a6cc3d6a..915fbcca 100644 --- a/src/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -4,16 +4,17 @@ * work on the same shape of data, so the Container tab subclasses this and swaps `holder()`. */ import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js'; -import { CompoundCommand } from '../core/commands/CompoundCommand'; -import { MoveSceneObjectCommand, PlaceAssetCommand } from '../core/commands/sceneObjectCommands'; -import { isSymbolType } from '../core/model/AssetTypes'; -import { findObject, subtreeRoots } from '../core/model/hierarchy'; -import { objectRef, resolvedHolder } from '../core/model/instance'; -import { placementRejection } from '../core/model/placement'; -import type { Position, Vec2 } from '../core/model/Properties'; -import { findAsset, type ObjectHolder } from '../core/model/Project'; -import { footprintOf } from '../core/properties/bounds'; -import { type EditorState, objectSelection } from '../editor/state'; +import { CompoundCommand } from '../../core/commands/CompoundCommand'; +import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands'; +import { isSymbolType } from '../../core/model/AssetTypes'; +import { findObject, subtreeRoots } from '../../core/model/hierarchy'; +import { objectRef, resolvedHolder } from '../../core/model/instance'; +import { placementRejection } from '../../core/model/placement'; +import type { Position, Vec2 } from '../../core/model/Properties'; +import { findAsset, type ObjectHolder } from '../../core/model/Project'; +import { footprintOf } from '../../core/properties/bounds'; +import { boundsOf } from '../framing'; +import { type EditorState, objectSelection } from '../state'; import { activeDrag, clamp, @@ -24,10 +25,10 @@ import { Theme, textStyle, zoomFactor, -} from '../ui'; -import { collectHolderItems, type DrawItem } from './items'; -import { dashedLine } from './symbols'; -import { freeView, screenToWorld, type View, worldToScreen } from './view'; +} from '../../ui'; +import { collectHolderItems, type DrawItem } from '../../viewport/items'; +import { dashedLine } from '../../viewport/symbols'; +import { freeView, screenToWorld, type View, worldToScreen } from '../../viewport/view'; import { type Frame, WorldViewport } from './WorldViewport'; /** Pixels of travel before a press turns into a drag. */ @@ -122,17 +123,11 @@ export class SceneViewport extends WorldViewport { */ fit(): void { const { items } = this.collectFrame(); - let minX = 0; - let minY = 0; - let maxX = 0; - let maxY = 0; - for (const item of items) { + const boxes = items.map((item) => { const { w, h } = footprintOf(item.type, item.props); - minX = Math.min(minX, item.x - w / 2); - maxX = Math.max(maxX, item.x + w / 2); - minY = Math.min(minY, item.y - h / 2); - maxY = Math.max(maxY, item.y + h / 2); - } + return { id: item.id, x: item.x - w / 2, y: item.y - h / 2, w, h }; + }); + const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!; const width = Math.max(maxX - minX, 1); const height = Math.max(maxY - minY, 1); this.view = { @@ -147,8 +142,6 @@ export class SceneViewport extends WorldViewport { this.redraw(); } - // --- the view ------------------------------------------------------------------------------- - protected override currentView(): View { return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h); } @@ -170,8 +163,6 @@ export class SceneViewport extends WorldViewport { return true; } - // --- pointer -------------------------------------------------------------------------------- - private onPointerDown(e: FederatedPointerEvent): void { if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) { this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y }; @@ -352,8 +343,6 @@ export class SceneViewport extends WorldViewport { this.state.select(objectSelection(holder.id, ids)); } - // --- decorations ---------------------------------------------------------------------------- - protected override beforeItems(): void { this.syncGrid(); this.gizmos.clear(); diff --git a/src/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts similarity index 90% rename from src/viewport/WorldViewport.ts rename to src/editor/viewport/WorldViewport.ts index 781d0299..47b494c9 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -1,14 +1,14 @@ import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js'; -import { isSymbolType } from '../core/model/AssetTypes'; -import { sortByZ, type Vec2 } from '../core/model/Properties'; -import { footprintOf, textAnchor } from '../core/properties/bounds'; -import { rectAround, rectsIntersect } from '../core/properties/camera'; -import type { EditorState } from '../editor/state'; -import { type Size, Theme, Widget } from '../ui'; -import type { DrawItem } from './items'; -import { drawItem } from './symbols'; -import { textureLibrary } from './TextureLibrary'; -import { type View, worldBounds, worldToScreen } from './view'; +import { isSymbolType } from '../../core/model/AssetTypes'; +import { sortByZ, type Vec2 } from '../../core/model/Properties'; +import { footprintOf, textAnchor } from '../../core/properties/bounds'; +import { rectAround, rectsIntersect } from '../../core/properties/camera'; +import type { EditorState } from '../state'; +import { type Size, Theme, Widget } from '../../ui'; +import type { DrawItem } from '../../viewport/items'; +import { drawItem } from '../../viewport/symbols'; +import { textureLibrary } from '../../viewport/TextureLibrary'; +import { type View, worldBounds, worldToScreen } from '../../viewport/view'; export interface Frame { items: DrawItem[]; @@ -52,8 +52,6 @@ export abstract class WorldViewport extends Widget { return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) }; } - // --- what the two views differ in ------------------------------------------------------------ - protected abstract currentView(): View | null; /** Only called when `currentView()` is non-null. */ @@ -81,8 +79,6 @@ export abstract class WorldViewport extends Widget { protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {} - // --- test hooks ------------------------------------------------------------------------------ - debugInfo(): { rendering: boolean; visibleItems: number } { return { rendering: this.currentView() !== null, visibleItems: this.visibleCount }; } @@ -124,8 +120,6 @@ export abstract class WorldViewport extends Widget { }; } - // --- drawing --------------------------------------------------------------------------------- - override redraw(): void { const view = this.currentView(); this.bg.clear().rect(0, 0, this.w, this.h).fill(this.baseColor); diff --git a/tests/core/folded-edge.test.ts b/tests/core/folded-edge.test.ts index 6d6adfa0..dc3990e7 100644 --- a/tests/core/folded-edge.test.ts +++ b/tests/core/folded-edge.test.ts @@ -5,7 +5,15 @@ import { GraphInterpreter } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { findAsset } from '../../src/core/model/Project'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; -import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from '../../src/editor/graph/folding'; +import { + buildFoldMarks, + foldedEdgeShown, + type FoldMarkEnd, + foldMarkAt, + foldMarkKey, + foldMarkLabel, + foldMarkRadius, +} from '../../src/editor/graph/folding'; import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; /** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */ @@ -145,3 +153,67 @@ describe('several hidden links at one port', () => { expect(foldMarkLabel([])).toBe(''); }); }); + +/** The bundling that keeps two hidden links out of the same port from stacking on one pixel. */ +describe('fold mark bundling', () => { + const end = (over: Partial & { edgeId: string; key: string }): FoldMarkEnd => ({ + other: 'far', + otherLabel: 'Far', + isOutput: true, + portX: 100, + portY: 50, + color: 1, + hot: false, + ...over, + }); + + it('gives one port one mark, however many links it hides', () => { + const marks = buildFoldMarks( + [ + end({ edgeId: 'e1', key: 'a', other: 'n1', otherLabel: 'Add' }), + end({ edgeId: 'e2', key: 'a', other: 'n2', otherLabel: 'Compare' }), + end({ edgeId: 'e3', key: 'b', other: 'n3', otherLabel: 'Move' }), + ], + 16, + ); + expect(marks).toHaveLength(2); + expect(marks[0]!.edgeIds).toEqual(['e1', 'e2']); + expect(foldMarkLabel(marks[0]!.names)).toBe('Add +1'); + expect(marks[1]!.edgeIds).toEqual(['e3']); + }); + + it('dedupes the far nodes, so two links to one node select it once', () => { + const marks = buildFoldMarks( + [end({ edgeId: 'e1', key: 'a', other: 'n1' }), end({ edgeId: 'e2', key: 'a', other: 'n1' })], + 16, + ); + expect(marks[0]!.others).toEqual(['n1']); + }); + + it('puts the mark a stub out of its port, on the side the port faces', () => { + const [out] = buildFoldMarks([end({ edgeId: 'e1', key: 'a', isOutput: true })], 16); + const [into] = buildFoldMarks([end({ edgeId: 'e2', key: 'b', isOutput: false })], 16); + expect(out!.x).toBe(116); + expect(into!.x).toBe(84); + }); + + it('lets a pulse anywhere in the bundle colour the whole mark', () => { + const marks = buildFoldMarks( + [ + end({ edgeId: 'e1', key: 'a', color: 1, hot: false }), + end({ edgeId: 'e2', key: 'a', color: 9, hot: true }), + ], + 16, + ); + expect(marks[0]!.hot).toBe(true); + expect(marks[0]!.color).toBe(9); + }); + + it('grows the dot for a bundle and finds it under a click', () => { + expect(foldMarkRadius(1, 5)).toBe(5); + expect(foldMarkRadius(2, 5)).toBe(7); + const marks = [{ edgeIds: ['e1', 'e2'], x: 100, y: 50 }]; + expect(foldMarkAt(marks, { x: 108, y: 50 }, 5)).toBe(marks[0]); + expect(foldMarkAt(marks, { x: 120, y: 50 }, 5)).toBeNull(); + }); +}); diff --git a/tests/editor/graph-framing.test.ts b/tests/editor/graph-framing.test.ts index f30b5edd..10702e1c 100644 --- a/tests/editor/graph-framing.test.ts +++ b/tests/editor/graph-framing.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { type Box, boxesInBand, frameBoxes } from '../../src/editor/graph/framing'; +import { boundsOf, type Box, boxesInBand, frameBoxes } from '../../src/editor/framing'; const LIMITS = { min: 0.25, max: 2.5 }; @@ -83,3 +83,29 @@ describe('fit', () => { expect(f).toEqual({ zoom: 1, panX: 100, panY: 50 }); }); }); + +describe('boundsOf', () => { + it('encloses every box', () => { + const boxes: Box[] = [ + { id: 'a', x: 10, y: 20, w: 100, h: 60 }, + { id: 'b', x: -40, y: 5, w: 20, h: 10 }, + ]; + expect(boundsOf(boxes)).toEqual({ minX: -40, minY: 5, maxX: 110, maxY: 80 }); + }); + + it('reports nothing for no boxes at all', () => { + expect(boundsOf([])).toBeNull(); + }); + + /** The scene view seeds the origin so a template drawn far to one side cannot hide it. */ + it('keeps a seed inside the result even when every box is far away', () => { + const far: Box[] = [{ id: 'a', x: 500, y: 500, w: 10, h: 10 }]; + const origin = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + expect(boundsOf(far, origin)).toEqual({ minX: 0, minY: 0, maxX: 510, maxY: 510 }); + }); + + it('still answers for no boxes when a seed is given', () => { + const origin = { minX: 0, minY: 0, maxX: 0, maxY: 0 }; + expect(boundsOf([], origin)).toEqual(origin); + }); +});