diff --git a/README.md b/README.md index c8bfecb2..5c17601a 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1590 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1591 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/pasteCommands.ts b/src/core/commands/pasteCommands.ts index cd2da701..06b96471 100644 --- a/src/core/commands/pasteCommands.ts +++ b/src/core/commands/pasteCommands.ts @@ -11,7 +11,7 @@ import { placementRejection } from '../model/placement'; import type { Vec2 } from '../model/Properties'; import { findAsset, type Project } from '../model/Project'; import type { SceneObject } from '../model/SceneObject'; -import { carryTransform, localPositionFor, parentTransform } from '../properties/transform'; +import { carryTransform, editorLocalPositionFor, parentTransform } from '../properties/transform'; import { type Command, CommandError } from './Command'; import { requireGraph, requireHolder, requireNodeDef, requireStructuralObject } from './require'; @@ -22,8 +22,8 @@ function freshIds(ids: readonly string[]): Map { /** * Puts copied objects into a scene or container (#240). Each root goes under the parent `parents` - * names for it ('' or missing = top level), shifted by `shift` in world units, and keeps its look - * the way a re-parent does. + * names for it ('' or missing = top level), shifted by `shift` from where the editor showed it, and + * keeps its look the way a re-parent does. */ export class PasteObjectsCommand implements Command { readonly label: string; @@ -69,10 +69,10 @@ export class PasteObjectsCommand implements Command { copy.parentId = parentId; // The copy stays in its group when it lands at the top level of a holder that has the group. if (parentId || !findGroup(holder, copy.groupId ?? '')) delete copy.groupId; - copy.position = localPositionFor(project, holder, parentId, { - x: root.world.x + this.shift.x, - y: root.world.y + this.shift.y, - z: root.world.z, + copy.position = editorLocalPositionFor(project, holder, parentId, { + x: root.shown.x + this.shift.x, + y: root.shown.y + this.shift.y, + z: root.shown.z, }); const after = parentTransform(project, holder, parentId).m; carryTransform(findAsset(project, object.assetId)!, copy, root.parentMatrix, after); diff --git a/src/core/model/clipboard.ts b/src/core/model/clipboard.ts index 912c3d45..66536a7e 100644 --- a/src/core/model/clipboard.ts +++ b/src/core/model/clipboard.ts @@ -11,7 +11,7 @@ import { type GraphEdge, type GraphNode, } from '../graph/Graph'; -import { parentTransform, positionOf, worldTransform } from '../properties/transform'; +import { editorTransform, parentTransform, positionOf } from '../properties/transform'; import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy'; import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance'; import type { Mat } from './matrix'; @@ -19,10 +19,13 @@ import type { Position } from './Properties'; import type { ObjectHolder, Project } from './Project'; import type { SceneObject } from './SceneObject'; -/** A copied subtree's top: where it stood in the world and the frame its own rows were meant in. */ +/** + * A copied subtree's top: where the editor showed it, editor offsets included (#263), and the + * frame its own rows were meant in. + */ export interface ClipRoot { id: string; - world: Position; + shown: Position; parentMatrix: Mat; } @@ -80,7 +83,7 @@ export function copyObjects( const object = findObject(view, id)!; return { id, - world: positionOf(worldTransform(project, view, object)), + shown: positionOf(editorTransform(project, view, object)), parentMatrix: parentTransform(project, view, object.parentId).m, }; }), diff --git a/src/core/properties/transform.ts b/src/core/properties/transform.ts index ee1a6cc5..469bdc10 100644 --- a/src/core/properties/transform.ts +++ b/src/core/properties/transform.ts @@ -99,10 +99,19 @@ export function worldPosition(project: Project, tree: ObjectTree, object: SceneO return positionOf(worldTransform(project, tree, object)); } +function parentChain(project: Project, tree: ObjectTree, parentId: string, withEditorOffset: boolean) { + const parent = parentId ? findObject(tree, parentId) : undefined; + return parent ? chain(project, tree, parent, withEditorOffset) : { m: IDENTITY, z: 0 }; +} + /** The world transform of a parent ('' = top level, the identity). */ export function parentTransform(project: Project, tree: ObjectTree, parentId: string): ChainTransform { - const parent = parentId ? findObject(tree, parentId) : undefined; - return parent ? worldTransform(project, tree, parent) : { m: IDENTITY, z: 0 }; + return parentChain(project, tree, parentId, false); +} + +function localIn(parent: ChainTransform, at: Position): Position { + const local = apply(invert(parent.m), at); + return { x: local.x, y: local.y, z: at.z - parent.z }; } /** The local position that puts an object at `world` under the given parent ('' = top level). */ @@ -112,9 +121,20 @@ export function localPositionFor( parentId: string, world: Position, ): Position { - const parent = parentTransform(project, tree, parentId); - const local = apply(invert(parent.m), world); - return { x: local.x, y: local.y, z: world.z - parent.z }; + return localIn(parentTransform(project, tree, parentId), world); +} + +/** + * The local position that shows an object without an editor offset of its own at `shown` in the + * editor: the offsets of the parents above it count, as they do on screen (#263). + */ +export function editorLocalPositionFor( + project: Project, + tree: ObjectTree, + parentId: string, + shown: Position, +): Position { + return localIn(parentChain(project, tree, parentId, true), shown); } /** diff --git a/src/editor/clipboard.ts b/src/editor/clipboard.ts index a67e4778..d57927b9 100644 --- a/src/editor/clipboard.ts +++ b/src/editor/clipboard.ts @@ -123,8 +123,8 @@ function pasteParents( /** * Pastes the copied objects into `holderId`, into or beside `picked` – by default the one selected - * object. With `pointer` (world units) the copy is centred on it, otherwise it lands a step beside - * the original, a step further for every earlier paste still there. + * object. With `pointer` (world units as the view shows them) the copy is centred on it, otherwise + * it lands a step beside the original, a step further for every earlier paste still there. */ export function pasteObjects( state: EditorState, @@ -141,7 +141,7 @@ export function pasteObjects( pointer && centredOn( pointer, - clip.roots.map((r) => ({ x: r.world.x, y: r.world.y, w: 0, h: 0 })), + clip.roots.map((r) => ({ x: r.shown.x, y: r.shown.y, w: 0, h: 0 })), ); const shift = centred ? { x: Math.round(centred.x), y: Math.round(centred.y) } diff --git a/tests/editor/clipboard.test.ts b/tests/editor/clipboard.test.ts index cb13df6c..61c4393a 100644 --- a/tests/editor/clipboard.test.ts +++ b/tests/editor/clipboard.test.ts @@ -4,14 +4,18 @@ import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import type { Command } from '../../src/core/commands/Command'; import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; -import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { + PlaceAssetCommand, + SetEditorOffsetCommand, + SetOverrideCommand, +} from '../../src/core/commands/sceneObjectCommands'; import { BEHAVIOR_PARAM, BEHAVIOR_START, RUN_BEHAVIOR, STATE } from '../../src/core/graph'; import type { BehaviorAsset, ContainerAsset, StateMachineAsset } from '../../src/core/model/Asset'; import { findObject } from '../../src/core/model/hierarchy'; import { instanceChildId, resolvedHolder } from '../../src/core/model/instance'; import type { Vec2 } from '../../src/core/model/Properties'; import { createEmptyProject, findAsset, findScene } from '../../src/core/model/Project'; -import { worldPosition } from '../../src/core/properties/transform'; +import { editorTransform, positionOf, worldPosition } from '../../src/core/properties/transform'; import { copySelection, pasteGraph, pasteObjects } from '../../src/editor/clipboard'; import { EditorState, nodeSelection, objectSelection } from '../../src/editor/state'; @@ -34,11 +38,12 @@ function scene() { const level = () => findScene(state.project, sceneId)!; const view = () => resolvedHolder(state.project, level()); const world = (id: string) => worldPosition(state.project, view(), findObject(view(), id)!); + const shown = (id: string) => positionOf(editorTransform(state.project, view(), findObject(view(), id)!)); const copy = (ids: string[]) => { state.select(objectSelection(sceneId, ids)); expect(copySelection(state)).toBe(true); }; - return { state, sceneId, spriteId, place, a, c, level, view, world, copy }; + return { state, sceneId, spriteId, place, a, c, level, view, world, shown, copy }; } describe('copy and paste of scene objects', () => { @@ -84,6 +89,37 @@ describe('copy and paste of scene objects', () => { expect(pasted.overrides.rotation).toBeCloseTo(-90); }); + it('pastes under the pointer as the view shows it, the parents’ editor offsets included (#263)', () => { + const f = scene(); + run(f.state, new SetEditorOffsetCommand(f.sceneId, f.a, { x: 40, y: -30 })); + const b = f.place({ x: 300, y: 100 }); + f.copy([b]); + f.state.select(objectSelection(f.sceneId, [f.a])); + expect(pasteObjects(f.state, f.sceneId, { x: 500, y: 500 })).toBe(true); + const pasted = f.state.selectedObjectIds[0]!; + expect(findObject(f.level(), pasted)!.parentId).toBe(f.a); + expect(f.shown(pasted)).toMatchObject({ x: 500, y: 500 }); + expect(f.world(pasted)).toMatchObject({ x: 460, y: 530 }); + }); + + it('puts a copy a step beside where the view shows the original, its own offset included (#263)', () => { + const f = scene(); + run(f.state, new SetEditorOffsetCommand(f.sceneId, f.a, { x: 40, y: -30 })); + run(f.state, new SetEditorOffsetCommand(f.sceneId, f.c, { x: 0, y: 25 })); + f.copy([f.c]); + expect(pasteObjects(f.state, f.sceneId, null)).toBe(true); + const pasted = findObject(f.level(), f.state.selectedObjectIds[0]!)!; + expect(pasted.parentId).toBe(f.a); + expect(pasted.editorOffset).toEqual({ x: 0, y: 0 }); + expect(f.shown(pasted.id)).toMatchObject({ x: 250, y: 195 }); + + const b = f.place({ x: 300, y: 100 }); + f.copy([b]); + f.state.select(objectSelection(f.sceneId, [f.a])); + expect(pasteObjects(f.state, f.sceneId, null)).toBe(true); + expect(f.shown(f.state.selectedObjectIds[0]!)).toMatchObject({ x: 400, y: 200 }); + }); + it('puts the copy beside a selected original or earlier paste, a step further each time', () => { const f = scene(); f.copy([f.c]); diff --git a/tools/e2e/51-clipboard.mjs b/tools/e2e/51-clipboard.mjs index 9af24caf..5bdc7b9a 100644 --- a/tools/e2e/51-clipboard.mjs +++ b/tools/e2e/51-clipboard.mjs @@ -105,6 +105,24 @@ export async function clipboard(t) { JSON.stringify({ target, rows, inside }), ); await undo(); + + // 51f. The red crate drawn aside by its editor offset (#263): Ctrl+V with it selected puts the + // copy inside it and still right under the pointer, where the view shows the copy. + await t.mutate(` + __gm.state.activeScene.objects.find((o) => o.id === ${JSON.stringify(RED)}).editorOffset = { x: 60, y: -40 }`); + await select({ kind: 'object', holderId: 'scene1', objectId: RED, objectIds: [RED] }); + const offsetSpot = await json('__gm.emptySpot()'); + await moveTo(offsetSpot); + await press('v', { ctrl: true }); + const underRedId = (await selected()).objectId; + const underRed = (await stored()).find((o) => o.id === underRedId); + const shownAt = await json(`__gm.objectScreen(${JSON.stringify(underRedId)})`); + check( + 'Ctrl+V into a parent with an editor offset still lands under the pointer', + underRed?.assetId === 'crate' && underRed.parentId === RED && near(shownAt, offsetSpot), + JSON.stringify({ offsetSpot, shownAt, underRed: underRed && underRed.position }), + ); + await undo(); check('the scene is back as it was', (await stored()).length === before.length); // 51d. A node of the Mover behavior, copied and pasted under the pointer in its own graph.