/** * 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; } export 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)); } export function editorPosition(project: Project, tree: ObjectTree, object: SceneObject): Position { return positionOf(editorTransform(project, tree, object)); } /** 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 }; } /** 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 { 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 }; } /** * 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; }