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>
168 lines
6.1 KiB
TypeScript
168 lines
6.1 KiB
TypeScript
/**
|
||
* Where an object is in the world: its position, rotation, scale and mirror applied inside its
|
||
* parent's, all the way up (`composeAlongChain`). The model side reads the transform rows off the
|
||
* asset and the object's overrides; the running world reads them off the entity's props.
|
||
*/
|
||
import type { Asset } from '../model/Asset';
|
||
import { findObject, type ObjectTree } from '../model/hierarchy';
|
||
import {
|
||
apply,
|
||
decompose,
|
||
determinant,
|
||
IDENTITY,
|
||
invert,
|
||
linearOf,
|
||
type Mat,
|
||
multiply,
|
||
sameLinear,
|
||
scaleOf,
|
||
angleOf,
|
||
transformMatrix,
|
||
} from '../model/matrix';
|
||
import { findAsset, type Project } from '../model/Project';
|
||
import type { Position, PropertyBag, Vec2 } from '../model/Properties';
|
||
import type { SceneObject } from '../model/SceneObject';
|
||
import { type ChainTransform, composeAlongChain } from '../model/walk';
|
||
import { hasTransformProps, TRANSFORM_PROPS } from './PropertySchema';
|
||
|
||
export interface LocalTransform {
|
||
rotation: number;
|
||
scale: number;
|
||
mirrorX: boolean;
|
||
mirrorY: boolean;
|
||
}
|
||
|
||
const NEUTRAL: LocalTransform = { rotation: 0, scale: 1, mirrorX: false, mirrorY: false };
|
||
|
||
export function transformOf(props: PropertyBag): LocalTransform {
|
||
return {
|
||
rotation: finiteOr(props.rotation, 0),
|
||
scale: finiteOr(props.scale, 1),
|
||
mirrorX: props.mirrorX === true,
|
||
mirrorY: props.mirrorY === true,
|
||
};
|
||
}
|
||
|
||
function finiteOr(value: unknown, fallback: number): number {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : fallback;
|
||
}
|
||
|
||
/** The object's own rows without resolving the whole bag: an override, else the asset's value. */
|
||
export function transformOfObject(asset: Asset, object: SceneObject): LocalTransform {
|
||
if (!hasTransformProps(asset.type)) return NEUTRAL;
|
||
const bag: PropertyBag = {};
|
||
for (const { key } of TRANSFORM_PROPS) {
|
||
const value = object.overrides[key] ?? asset.props[key];
|
||
if (value !== undefined) bag[key] = value;
|
||
}
|
||
return transformOf(bag);
|
||
}
|
||
|
||
export function localMatrixOf(position: Vec2, t: LocalTransform): Mat {
|
||
return transformMatrix(position.x, position.y, t.rotation, t.scale, t.mirrorX, t.mirrorY);
|
||
}
|
||
|
||
function localOf(project: Project, object: SceneObject, withEditorOffset: boolean): ChainTransform {
|
||
const asset = findAsset(project, object.assetId);
|
||
const t = asset ? transformOfObject(asset, object) : NEUTRAL;
|
||
const offset = withEditorOffset ? object.editorOffset : { x: 0, y: 0 };
|
||
const at = { x: object.position.x + offset.x, y: object.position.y + offset.y };
|
||
return { m: localMatrixOf(at, t), z: object.position.z };
|
||
}
|
||
|
||
function chain(project: Project, tree: ObjectTree, object: SceneObject, withEditorOffset: boolean) {
|
||
return composeAlongChain(
|
||
object,
|
||
(o) => (o.parentId ? findObject(tree, o.parentId) : undefined),
|
||
(o) => localOf(project, o, withEditorOffset),
|
||
);
|
||
}
|
||
|
||
export function worldTransform(project: Project, tree: ObjectTree, object: SceneObject): ChainTransform {
|
||
return chain(project, tree, object, false);
|
||
}
|
||
|
||
/**
|
||
* The world transform plus the editor offsets along the chain. A view aid only – the game,
|
||
* collisions and the graph all use `worldTransform`.
|
||
*/
|
||
export function editorTransform(project: Project, tree: ObjectTree, object: SceneObject): ChainTransform {
|
||
return chain(project, tree, object, true);
|
||
}
|
||
|
||
export function positionOf({ m, z }: ChainTransform): Position {
|
||
return { x: m.tx, y: m.ty, z };
|
||
}
|
||
|
||
export function worldPosition(project: Project, tree: ObjectTree, object: SceneObject): Position {
|
||
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 {
|
||
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). */
|
||
export function localPositionFor(
|
||
project: Project,
|
||
tree: ObjectTree,
|
||
parentId: string,
|
||
world: Position,
|
||
): Position {
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* The rows that keep an object looking the same after the linear part above it changed from
|
||
* `before` to `after` (re-parenting, grouping). Nothing changes when nothing has to: an object
|
||
* moved between two upright parents keeps its own rows, mirror flags included.
|
||
*/
|
||
export function carriedTransform(own: LocalTransform, before: Mat, after: Mat): LocalTransform {
|
||
const carry = multiply(invert(linearOf(after)), linearOf(before));
|
||
if (sameLinear(carry, IDENTITY)) return own;
|
||
if (determinant(carry) > 0)
|
||
return {
|
||
...own,
|
||
rotation: own.rotation + (angleOf(carry) * 180) / Math.PI,
|
||
scale: own.scale * scaleOf(carry),
|
||
};
|
||
return { ...decompose(multiply(carry, localMatrixOf({ x: 0, y: 0 }, own))), mirrorY: false };
|
||
}
|
||
|
||
/** Writes the carried rows as overrides of a stored object; a type without the rows is left alone. */
|
||
export function carryTransform(asset: Asset, object: SceneObject, before: Mat, after: Mat): void {
|
||
if (!hasTransformProps(asset.type)) return;
|
||
const own = transformOfObject(asset, object);
|
||
const carried = carriedTransform(own, before, after);
|
||
if (carried === own) return;
|
||
object.overrides.scale = carried.scale;
|
||
object.overrides.rotation = carried.rotation;
|
||
object.overrides.mirrorX = carried.mirrorX;
|
||
object.overrides.mirrorY = carried.mirrorY;
|
||
}
|