Files
feuerwerk/src/core/properties/transform.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

168 lines
6.1 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.
/**
* 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;
}