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>
112 lines
3.5 KiB
TypeScript
112 lines
3.5 KiB
TypeScript
/**
|
||
* What Ctrl+C takes (#240): a snapshot of placed objects or of a piece of graph, detached from the
|
||
* model so later edits to the originals do not change what is pasted. Ids are the originals'; the
|
||
* paste commands hand out fresh ones.
|
||
*/
|
||
import {
|
||
type AnnotationKind,
|
||
findAnnotation,
|
||
type Graph,
|
||
type GraphAnnotation,
|
||
type GraphEdge,
|
||
type GraphNode,
|
||
} from '../graph/Graph';
|
||
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';
|
||
import type { Position } from './Properties';
|
||
import type { ObjectHolder, Project } from './Project';
|
||
import type { SceneObject } from './SceneObject';
|
||
|
||
/**
|
||
* 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;
|
||
shown: Position;
|
||
parentMatrix: Mat;
|
||
}
|
||
|
||
export interface ObjectClip {
|
||
kind: 'objects';
|
||
/** Parents before children; a root's `parentId` is the one it had where it was copied. */
|
||
objects: SceneObject[];
|
||
roots: ClipRoot[];
|
||
}
|
||
|
||
export interface GraphClip {
|
||
kind: 'graph';
|
||
nodes: GraphNode[];
|
||
/** Only the edges between copied nodes – a link to a node left behind has nowhere to go. */
|
||
edges: GraphEdge[];
|
||
annotations: { kind: AnnotationKind; annotation: GraphAnnotation }[];
|
||
}
|
||
|
||
export type Clip = ObjectClip | GraphClip;
|
||
|
||
/**
|
||
* The selected objects with everything below them. A placed container keeps showing its template,
|
||
* so its resolved content stays behind; content picked out of a placed container is copied as the
|
||
* ordinary objects it is shown as.
|
||
*/
|
||
export function copyObjects(
|
||
project: Project,
|
||
holder: ObjectHolder,
|
||
ids: readonly string[],
|
||
): ObjectClip | null {
|
||
const view = resolvedHolder(project, holder);
|
||
const roots = subtreeRoots(
|
||
view,
|
||
ids.filter((id) => findObject(view, id)),
|
||
);
|
||
if (roots.length === 0) return null;
|
||
const members = subtreeIds(view, roots);
|
||
const instances = new Set(
|
||
view.objects
|
||
.filter(
|
||
(o) => members.has(o.id) && !isTemplateContent(holder, o.id) && isLiveInstance(project, holder, o),
|
||
)
|
||
.map((o) => o.id),
|
||
);
|
||
const shownByInstance = (id: string) =>
|
||
isTemplateContent(holder, id) && instances.has(splitInstanceId(id)?.instanceId ?? '');
|
||
const objects = treeOrder(view)
|
||
.map((entry) => entry.object)
|
||
.filter((o) => members.has(o.id) && !shownByInstance(o.id))
|
||
.map((o) => ({ ...structuredClone(o), editorOffset: { x: 0, y: 0 } }));
|
||
return {
|
||
kind: 'objects',
|
||
objects,
|
||
roots: roots.map((id) => {
|
||
const object = findObject(view, id)!;
|
||
return {
|
||
id,
|
||
shown: positionOf(editorTransform(project, view, object)),
|
||
parentMatrix: parentTransform(project, view, object.parentId).m,
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
export function copyGraph(
|
||
graph: Graph,
|
||
nodeIds: readonly string[],
|
||
annotationIds: readonly string[],
|
||
): GraphClip | null {
|
||
const ids = new Set(nodeIds);
|
||
const nodes = graph.nodes.filter((n) => ids.has(n.id));
|
||
const annotations = annotationIds.flatMap((id) => {
|
||
const found = findAnnotation(graph, id);
|
||
return found ? [found] : [];
|
||
});
|
||
if (nodes.length === 0 && annotations.length === 0) return null;
|
||
return structuredClone({
|
||
kind: 'graph',
|
||
nodes,
|
||
edges: graph.edges.filter((e) => ids.has(e.from.node) && ids.has(e.to.node)),
|
||
annotations,
|
||
});
|
||
}
|