Paste where the view shows it, editor offsets included (#263)
Paste mixed two frames. The pointer comes from the Scene View, which draws every object moved by the editor offsets of it and its parents. Paste turned that point into a local position through the parents' plain world transform, so the copy landed beside the pointer by the offset of the parent it went into. Without a pointer, an original with an offset of its own had its copy placed beside its game position, which the view does not show. - A copied root now remembers where the view showed it (`ClipRoot.shown`, from `editorTransform`) instead of its world position. - New `editorLocalPositionFor` in `properties/transform.ts` turns a shown point into a local position through the parent chain with the editor offsets, as the view draws it. Paste uses it. - The copy itself still gets no editor offset of its own, as since #240. In the game it stands where it was drawn, minus the offsets of its parents. - Re-parenting under a parent with an offset still keeps the game position, so the object jumps in the view by that offset. That is a different rule from paste and is left as it is. Tests: - Unit (clipboard, groups, references, transform): 50 passed, 2 new. - e2e 51 on melektaus-atx: ALL PASSED, 12 checks, 1 new (51f). - Gate on melektaus-atx: 195 files, 2829 tests, CHECK EXIT 0. - Counter-checks: without the paste fix both new unit tests fail (copy at 540/470 instead of 500/500) and 51f fails by exactly the 60/-40 offset. With the root remembering its world position again, the own-offset unit test fails. - No full run. The e2e total of 1591 is 1590 plus 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9cff5282b6
commit
6d4247ebf8
7 files changed
+100
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<string, string> {
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user