Files
feuerwerk/src/core/model/clipboard.ts
T
adminandClaude Opus 5.5 6d4247ebf8 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>
2026-09-29 22:36:30 +02:00

112 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
});
}