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

+7 -7
View File
@@ -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);
+7 -4
View File
@@ -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,
};
}),
+25 -5
View File
@@ -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);
}
/**
+3 -3
View File
@@ -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) }