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:
adminandClaude Opus 5.5 committed 2026-09-29 22:36:30 +02:00
1 parent 9cff5282b6
commit 6d4247ebf8
7 files changed
+100 -23

No files matched your search

+39 -3
View File
@@ -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]);