Transform: scale, rotation and mirror as properties, world transform as a matrix chain, oriented colliders (#76)
game-maker / suite (push) Successful in 9m5s
game-maker / suite (push) Successful in 9m5s
Four new rows on sprite, text, collider and container (block TRANSFORM): scale, rotation in degrees clockwise, mirrorX, mirrorY. As properties they get override/reset/apply, loading of old projects, write-back after play and the Properties node for free; camera and behavior have none. The hierarchy composes T*R*S per level (composeAlongChain replaces sumAlongChain), so children rotate and scale with their parent. Reparenting and grouping keep the look (carriedTransform). Drawing goes through placeNode, the gizmo is a polygon, cull and objectRect use the AABB, drags are computed in the parent's system. Colliders are oriented boxes: SAT for box/box, box-circle in the box frame; exitDistance replaces axisSeparation/axisCorrection, moveAndSlide moves along the object's own axes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
f85606427b
commit
016e83c9b5
33 files changed
+1346
-274
No files matched your search
@@ -1,18 +1,18 @@
|
||||
import { newId } from '../ids';
|
||||
import type { ContainerAsset } from '../model/Asset';
|
||||
import {
|
||||
findObject,
|
||||
localPositionFor,
|
||||
removeSubtrees,
|
||||
subtreeIds,
|
||||
subtreeRoots,
|
||||
treeOrder,
|
||||
worldPosition,
|
||||
} from '../model/hierarchy';
|
||||
import { findObject, removeSubtrees, subtreeIds, subtreeRoots, treeOrder } from '../model/hierarchy';
|
||||
import { type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance';
|
||||
import { apply, invert, multiply, translation } from '../model/matrix';
|
||||
import type { Position } from '../model/Properties';
|
||||
import { findObjectHolder, type Project } from '../model/Project';
|
||||
import { findAsset, findObjectHolder, type Project } from '../model/Project';
|
||||
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
||||
import {
|
||||
carryTransform,
|
||||
localPositionFor,
|
||||
parentTransform,
|
||||
worldPosition,
|
||||
worldTransform,
|
||||
} from '../properties/transform';
|
||||
import { removeById, type Removed, restoreAt } from '../util/list';
|
||||
import { buildAsset } from './assetCommands';
|
||||
import { type Command, CommandError } from './Command';
|
||||
@@ -42,7 +42,7 @@ export class CreateContainerCommand implements Command {
|
||||
const selected = ids.map((id) => requireStructuralObject(project, holder, id));
|
||||
if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
|
||||
|
||||
const worlds = selected.map((o) => worldPosition(holder, o));
|
||||
const worlds = selected.map((o) => worldPosition(project, holder, o));
|
||||
const centre: Position = {
|
||||
x: Math.round((Math.min(...worlds.map((p) => p.x)) + Math.max(...worlds.map((p) => p.x))) / 2),
|
||||
y: Math.round((Math.min(...worlds.map((p) => p.y)) + Math.max(...worlds.map((p) => p.y))) / 2),
|
||||
@@ -68,31 +68,41 @@ export class CreateContainerCommand implements Command {
|
||||
nextAssetName(project, 'Container'),
|
||||
this.assetId,
|
||||
) as ContainerAsset;
|
||||
const containerLocal = localPositionFor(project, holder, containerParentId, centre);
|
||||
// The template is the container's frame: a root keeps its place and look inside it, which
|
||||
// under a turned or sized parent means transform rows of its own (see `carryTransform`).
|
||||
const containerWorld = multiply(
|
||||
parentTransform(project, holder, containerParentId).m,
|
||||
translation(containerLocal.x, containerLocal.y),
|
||||
);
|
||||
const templateIds = this.templateIds;
|
||||
for (const object of members) if (!templateIds.has(object.id)) templateIds.set(object.id, newId());
|
||||
for (const object of members) {
|
||||
const isRoot = roots.has(object.id);
|
||||
const world = worldPosition(holder, object);
|
||||
asset.objects.push({
|
||||
const world = worldTransform(project, holder, object);
|
||||
const inside = apply(invert(containerWorld), { x: world.m.tx, y: world.m.ty });
|
||||
const template: SceneObject = {
|
||||
...object,
|
||||
id: templateIds.get(object.id)!,
|
||||
position: isRoot
|
||||
? { x: world.x - centre.x, y: world.y - centre.y, z: world.z }
|
||||
: { ...object.position },
|
||||
position: isRoot ? { x: inside.x, y: inside.y, z: world.z } : { ...object.position },
|
||||
// A view-only offset belongs to the placed object, not to the template.
|
||||
editorOffset: { x: 0, y: 0 },
|
||||
overrides: { ...object.overrides },
|
||||
parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''),
|
||||
instanceEdits: { ...object.instanceEdits },
|
||||
});
|
||||
};
|
||||
const objectAsset = findAsset(project, object.assetId);
|
||||
if (isRoot && objectAsset)
|
||||
carryTransform(
|
||||
objectAsset,
|
||||
template,
|
||||
parentTransform(project, holder, object.parentId).m,
|
||||
containerWorld,
|
||||
);
|
||||
asset.objects.push(template);
|
||||
}
|
||||
|
||||
const container = createSceneObject(
|
||||
this.objectId,
|
||||
this.assetId,
|
||||
localPositionFor(holder, containerParentId, centre),
|
||||
containerParentId,
|
||||
);
|
||||
const container = createSceneObject(this.objectId, this.assetId, containerLocal, containerParentId);
|
||||
this.removed = removeSubtrees(holder, [...roots]);
|
||||
project.assets.push(asset);
|
||||
holder.objects.push(container);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { newId } from '../ids';
|
||||
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
|
||||
import { isSelfOrAncestor, removeSubtrees } from '../model/hierarchy';
|
||||
import {
|
||||
captureObject,
|
||||
isLiveInstance,
|
||||
@@ -8,10 +8,23 @@ import {
|
||||
restoreObject,
|
||||
} from '../model/instance';
|
||||
import { placementRejection } from '../model/placement';
|
||||
import { type Position, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
|
||||
import {
|
||||
type Position,
|
||||
type PropertyBag,
|
||||
type PropertyValue,
|
||||
toPosition,
|
||||
type Vec2,
|
||||
} from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
||||
import { coerceValue } from '../properties/PropertySchema';
|
||||
import {
|
||||
carryTransform,
|
||||
localPositionFor,
|
||||
parentTransform,
|
||||
positionOf,
|
||||
worldTransform,
|
||||
} from '../properties/transform';
|
||||
import { type Removed, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import {
|
||||
@@ -75,7 +88,7 @@ export class PlaceAssetCommand implements Command {
|
||||
const rejection = placementRejection(project, holder, asset);
|
||||
if (rejection) throw new CommandError(rejection);
|
||||
if (this.parentId) requireStructuralObject(project, holder, this.parentId);
|
||||
const local = localPositionFor(holder, this.parentId, toPosition(this.position));
|
||||
const local = localPositionFor(project, holder, this.parentId, toPosition(this.position));
|
||||
holder.objects.push(createSceneObject(this.objectId, this.assetId, local, this.parentId));
|
||||
}
|
||||
|
||||
@@ -256,9 +269,10 @@ export class ReorderObjectCommand implements Command {
|
||||
*/
|
||||
export class SetParentCommand implements Command {
|
||||
readonly label = 'Set parent';
|
||||
private previous: { parentId: string; position: Position } = {
|
||||
private previous: { parentId: string; position: Position; overrides: PropertyBag } = {
|
||||
parentId: '',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
overrides: {},
|
||||
};
|
||||
|
||||
constructor(
|
||||
@@ -277,15 +291,29 @@ export class SetParentCommand implements Command {
|
||||
if (isSelfOrAncestor(holder, this.objectId, this.parentId))
|
||||
throw new CommandError('An object cannot be parented to one of its own children');
|
||||
}
|
||||
this.previous = { parentId: object.parentId, position: { ...object.position } };
|
||||
const world = worldPosition(holder, object);
|
||||
this.previous = {
|
||||
parentId: object.parentId,
|
||||
position: { ...object.position },
|
||||
overrides: { ...object.overrides },
|
||||
};
|
||||
// The object stays where it is and how it looks: the new local position and, when the parent
|
||||
// chain's turn or size changed, transform rows that absorb the difference.
|
||||
const world = worldTransform(project, holder, object);
|
||||
const before = parentTransform(project, holder, object.parentId).m;
|
||||
object.parentId = this.parentId;
|
||||
object.position = localPositionFor(holder, this.parentId, world);
|
||||
object.position = localPositionFor(project, holder, this.parentId, positionOf(world));
|
||||
carryTransform(
|
||||
requireAsset(project, object.assetId),
|
||||
object,
|
||||
before,
|
||||
parentTransform(project, holder, this.parentId).m,
|
||||
);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
object.parentId = this.previous.parentId;
|
||||
object.position = { ...this.previous.position };
|
||||
object.overrides = { ...this.previous.overrides };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { sumAlongChain } from '../model/walk';
|
||||
import type { Mat } from '../model/matrix';
|
||||
import type { Position, PropertyBag } from '../model/Properties';
|
||||
import { type ChainTransform, composeAlongChain } from '../model/walk';
|
||||
import { enabledAlongChain } from '../properties/enabled';
|
||||
import { localMatrixOf, positionOf, transformOf } from '../properties/transform';
|
||||
|
||||
/**
|
||||
* Marks a live entity apart from a struct value: both reach a data edge as an object. A symbol
|
||||
@@ -26,14 +28,23 @@ export interface EntityHandle {
|
||||
parent: EntityHandle | null;
|
||||
}
|
||||
|
||||
export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } {
|
||||
return sumAlongChain(
|
||||
/** Position, rotation, scale and mirror of every ancestor applied, nearest last. */
|
||||
export function worldTransformOf(handle: EntityHandle): ChainTransform {
|
||||
return composeAlongChain(
|
||||
handle,
|
||||
(h) => h.parent,
|
||||
(h) => h,
|
||||
(h) => ({ m: localMatrixOf(h, transformOf(h.props)), z: h.z }),
|
||||
);
|
||||
}
|
||||
|
||||
export function worldMatrixOf(handle: EntityHandle): Mat {
|
||||
return worldTransformOf(handle).m;
|
||||
}
|
||||
|
||||
export function worldPositionOf(handle: EntityHandle): Position {
|
||||
return positionOf(worldTransformOf(handle));
|
||||
}
|
||||
|
||||
/** Whether a handle and every ancestor of it are switched on. */
|
||||
export function isHandleEnabled(handle: EntityHandle): boolean {
|
||||
return enabledAlongChain(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type Removed, removeWhere } from '../util/list';
|
||||
import type { Position } from './Properties';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
import { descendantsBelow, parentChain, sumAlongChain } from './walk';
|
||||
import { descendantsBelow, parentChain } from './walk';
|
||||
|
||||
/**
|
||||
* `objects` stays a flat list in creation order; the tree is implied by `SceneObject.parentId`.
|
||||
@@ -78,37 +77,6 @@ export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[]
|
||||
return ids.filter((id) => !ancestorIdsOf(tree, id).some((ancestorId) => set.has(ancestorId)));
|
||||
}
|
||||
|
||||
function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOffset: boolean): Position {
|
||||
return sumAlongChain(
|
||||
object,
|
||||
(o) => parentOf(tree, o),
|
||||
(o) =>
|
||||
withEditorOffset
|
||||
? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z }
|
||||
: o.position,
|
||||
);
|
||||
}
|
||||
|
||||
export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
|
||||
return positionAlongChain(tree, object, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* World position plus the editor offsets along the chain. A view aid only – the game, collisions
|
||||
* and the graph all use `worldPosition`.
|
||||
*/
|
||||
export function editorPosition(tree: ObjectTree, object: SceneObject): Position {
|
||||
return positionAlongChain(tree, object, true);
|
||||
}
|
||||
|
||||
/** The local position that puts an object at `world` under the given parent ('' = top level). */
|
||||
export function localPositionFor(tree: ObjectTree, parentId: string, world: Position): Position {
|
||||
const parent = parentId ? findObject(tree, parentId) : undefined;
|
||||
if (!parent) return { ...world };
|
||||
const origin = worldPosition(tree, parent);
|
||||
return { x: world.x - origin.x, y: world.y - origin.y, z: world.z - origin.z };
|
||||
}
|
||||
|
||||
export interface TreeEntry {
|
||||
object: SceneObject;
|
||||
depth: number;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { Vec2 } from './Properties';
|
||||
|
||||
/**
|
||||
* A 2D affine transform in Pixi's layout: x' = a·x + c·y + tx, y' = b·x + d·y + ty. Every transform
|
||||
* in the hierarchy is a similarity (uniform scale, rotation, an optional mirror), so their products
|
||||
* are too – which is what lets `decompose` recover rotation and scale from one.
|
||||
*/
|
||||
export interface Mat {
|
||||
a: number;
|
||||
b: number;
|
||||
c: number;
|
||||
d: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
}
|
||||
|
||||
export const IDENTITY: Mat = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
|
||||
|
||||
export function translation(x: number, y: number): Mat {
|
||||
return { a: 1, b: 0, c: 0, d: 1, tx: x, ty: y };
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate ∘ rotate ∘ scale: a point is scaled (a mirror flips the axis first), then turned, then
|
||||
* moved. Rotation in degrees, positive clockwise on screen because y grows downwards.
|
||||
*/
|
||||
export function transformMatrix(
|
||||
x: number,
|
||||
y: number,
|
||||
rotation: number,
|
||||
scale: number,
|
||||
mirrorX: boolean,
|
||||
mirrorY: boolean,
|
||||
): Mat {
|
||||
const r = (rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(r);
|
||||
const sin = Math.sin(r);
|
||||
const sx = mirrorX ? -scale : scale;
|
||||
const sy = mirrorY ? -scale : scale;
|
||||
return { a: cos * sx, b: sin * sx, c: -sin * sy, d: cos * sy, tx: x, ty: y };
|
||||
}
|
||||
|
||||
/** `outer ∘ inner`: apply `inner` first. */
|
||||
export function multiply(outer: Mat, inner: Mat): Mat {
|
||||
return {
|
||||
a: outer.a * inner.a + outer.c * inner.b,
|
||||
b: outer.b * inner.a + outer.d * inner.b,
|
||||
c: outer.a * inner.c + outer.c * inner.d,
|
||||
d: outer.b * inner.c + outer.d * inner.d,
|
||||
tx: outer.a * inner.tx + outer.c * inner.ty + outer.tx,
|
||||
ty: outer.b * inner.tx + outer.d * inner.ty + outer.ty,
|
||||
};
|
||||
}
|
||||
|
||||
export function apply(m: Mat, p: Vec2): Vec2 {
|
||||
return { x: m.a * p.x + m.c * p.y + m.tx, y: m.b * p.x + m.d * p.y + m.ty };
|
||||
}
|
||||
|
||||
/** The linear part only – for directions and deltas, which no translation touches. */
|
||||
export function applyVector(m: Mat, v: Vec2): Vec2 {
|
||||
return { x: m.a * v.x + m.c * v.y, y: m.b * v.x + m.d * v.y };
|
||||
}
|
||||
|
||||
export function linearOf(m: Mat): Mat {
|
||||
return { ...m, tx: 0, ty: 0 };
|
||||
}
|
||||
|
||||
export function determinant(m: Mat): number {
|
||||
return m.a * m.d - m.b * m.c;
|
||||
}
|
||||
|
||||
/** A transform with scale 0 has no inverse; its translation is undone and the rest left alone. */
|
||||
export function invert(m: Mat): Mat {
|
||||
const det = determinant(m);
|
||||
if (Math.abs(det) < 1e-12) return translation(-m.tx, -m.ty);
|
||||
const a = m.d / det;
|
||||
const b = -m.b / det;
|
||||
const c = -m.c / det;
|
||||
const d = m.a / det;
|
||||
return { a, b, c, d, tx: -(a * m.tx + c * m.ty), ty: -(b * m.tx + d * m.ty) };
|
||||
}
|
||||
|
||||
/** The uniform scale factor of a similarity. */
|
||||
export function scaleOf(m: Mat): number {
|
||||
return Math.hypot(m.a, m.b);
|
||||
}
|
||||
|
||||
/** The angle the x axis was turned by, in radians. */
|
||||
export function angleOf(m: Mat): number {
|
||||
return Math.atan2(m.b, m.a);
|
||||
}
|
||||
|
||||
export function isMirrored(m: Mat): boolean {
|
||||
return determinant(m) < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotation (degrees), scale and mirror that rebuild the linear part of a similarity. A mirror
|
||||
* always comes out as `mirrorX`: mirroring y is mirroring x turned by a half circle.
|
||||
*/
|
||||
export function decompose(m: Mat): { rotation: number; scale: number; mirrorX: boolean } {
|
||||
const mirrorX = isMirrored(m);
|
||||
const a = mirrorX ? -m.a : m.a;
|
||||
const b = mirrorX ? -m.b : m.b;
|
||||
return { rotation: (Math.atan2(b, a) * 180) / Math.PI, scale: scaleOf(m), mirrorX };
|
||||
}
|
||||
|
||||
export function sameLinear(p: Mat, q: Mat, epsilon = 1e-9): boolean {
|
||||
return (
|
||||
Math.abs(p.a - q.a) < epsilon &&
|
||||
Math.abs(p.b - q.b) < epsilon &&
|
||||
Math.abs(p.c - q.c) < epsilon &&
|
||||
Math.abs(p.d - q.d) < epsilon
|
||||
);
|
||||
}
|
||||
|
||||
/** The four corners of a `w`×`h` box centred at (`cx`, `cy`) in local units, taken through `m`. */
|
||||
export function boxCorners(m: Mat, w: number, h: number, cx = 0, cy = 0): Vec2[] {
|
||||
return [
|
||||
apply(m, { x: cx - w / 2, y: cy - h / 2 }),
|
||||
apply(m, { x: cx + w / 2, y: cy - h / 2 }),
|
||||
apply(m, { x: cx + w / 2, y: cy + h / 2 }),
|
||||
apply(m, { x: cx - w / 2, y: cy + h / 2 }),
|
||||
];
|
||||
}
|
||||
|
||||
export interface Extent {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export function extentOfPoints(points: readonly Vec2[]): Extent {
|
||||
const extent = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity };
|
||||
for (const p of points) {
|
||||
extent.left = Math.min(extent.left, p.x);
|
||||
extent.top = Math.min(extent.top, p.y);
|
||||
extent.right = Math.max(extent.right, p.x);
|
||||
extent.bottom = Math.max(extent.bottom, p.y);
|
||||
}
|
||||
return extent;
|
||||
}
|
||||
+17
-11
@@ -1,4 +1,4 @@
|
||||
import type { Position } from './Properties';
|
||||
import { IDENTITY, type Mat, multiply } from './matrix';
|
||||
|
||||
/**
|
||||
* `start` and every ancestor above it, nearest first. A revisited item ends the walk: a cyclic
|
||||
@@ -23,20 +23,26 @@ export function hasParentCycle<T>(start: T, parentOf: (item: T) => T | null | un
|
||||
return Boolean(parentOf(last));
|
||||
}
|
||||
|
||||
/** Summing local positions gives the world position only because the hierarchy has no rotation or scale. */
|
||||
export function sumAlongChain<T>(
|
||||
/** A world transform: the matrix places, turns and sizes; `z` is draw order and simply adds up. */
|
||||
export interface ChainTransform {
|
||||
m: Mat;
|
||||
z: number;
|
||||
}
|
||||
|
||||
/** Each item's local transform applied inside its parent's, root outermost. */
|
||||
export function composeAlongChain<T>(
|
||||
start: T,
|
||||
parentOf: (item: T) => T | null | undefined,
|
||||
positionOf: (item: T) => Position,
|
||||
): Position {
|
||||
const total: Position = { x: 0, y: 0, z: 0 };
|
||||
localOf: (item: T) => ChainTransform,
|
||||
): ChainTransform {
|
||||
let m = IDENTITY;
|
||||
let z = 0;
|
||||
for (const item of parentChain(start, parentOf)) {
|
||||
const position = positionOf(item);
|
||||
total.x += position.x;
|
||||
total.y += position.y;
|
||||
total.z += position.z;
|
||||
const local = localOf(item);
|
||||
m = multiply(local.m, m);
|
||||
z += local.z;
|
||||
}
|
||||
return total;
|
||||
return { m, z };
|
||||
}
|
||||
|
||||
/** Everything below `rootKey`, depth-first, parents before children. */
|
||||
|
||||
@@ -96,8 +96,33 @@ export const COMMON_PROPS: PropertySchema = [
|
||||
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
|
||||
];
|
||||
|
||||
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
|
||||
/**
|
||||
* How a drawn or grouping object is placed inside its parent, on top of its position. They cascade
|
||||
* like a position does (see `properties/transform.ts`), so a child turns and grows with its parent.
|
||||
*/
|
||||
export const TRANSFORM_PROPS: PropertySchema = [
|
||||
{ key: 'scale', label: 'Scale', kind: 'number', default: 1, min: 0, step: 0.1 },
|
||||
{ key: 'rotation', label: 'Rotation', kind: 'number', default: 0, step: 1 },
|
||||
{ key: 'mirrorX', label: 'Mirror X', kind: 'boolean', default: false },
|
||||
{ key: 'mirrorY', label: 'Mirror Y', kind: 'boolean', default: false },
|
||||
];
|
||||
|
||||
/** Cameras and behaviors are placed by the chain but have no look of their own to turn or size. */
|
||||
const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set(['sprite', 'text', 'collider', 'container']);
|
||||
|
||||
export function hasTransformProps(type: AssetType): boolean {
|
||||
return TRANSFORM_TYPES.has(type);
|
||||
}
|
||||
|
||||
const TRANSFORM_KEYS = new Set(TRANSFORM_PROPS.map((d) => d.key));
|
||||
|
||||
export function isTransformProp(key: string): boolean {
|
||||
return TRANSFORM_KEYS.has(key);
|
||||
}
|
||||
|
||||
const COMMON_KEYS = new Set([...COMMON_PROPS, ...TRANSFORM_PROPS].map((d) => d.key));
|
||||
|
||||
/** The switch and the transform rows: reserved names, shown apart from the type's own properties. */
|
||||
export function isCommonProp(key: string): boolean {
|
||||
return COMMON_KEYS.has(key);
|
||||
}
|
||||
@@ -203,7 +228,9 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
|
||||
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
|
||||
type,
|
||||
isPlaceableType(type) ? [...COMMON_PROPS, ...ASSET_SCHEMAS[type]] : ASSET_SCHEMAS[type],
|
||||
isPlaceableType(type)
|
||||
? [...COMMON_PROPS, ...(hasTransformProps(type) ? TRANSFORM_PROPS : []), ...ASSET_SCHEMAS[type]]
|
||||
: ASSET_SCHEMAS[type],
|
||||
]),
|
||||
) as Record<AssetType, PropertySchema>;
|
||||
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
/**
|
||||
* Footprints in world units, shared by the viewport, by pointer hit tests in the running game and by
|
||||
* "Add collider", so all three agree on how big a thing is.
|
||||
* "Add collider", so all three agree on how big a thing is – and, through the object's transform, on
|
||||
* where its corners are.
|
||||
*/
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { type AssetType, isSymbolType } from '../model/AssetTypes';
|
||||
import {
|
||||
apply,
|
||||
boxCorners,
|
||||
type Extent,
|
||||
extentOfPoints,
|
||||
invert,
|
||||
type Mat,
|
||||
scaleOf,
|
||||
translation,
|
||||
} from '../model/matrix';
|
||||
import { type PropertyBag, textOf, type Vec2 } from '../model/Properties';
|
||||
import { cameraFrame } from './camera';
|
||||
import { colliderSize } from './collider';
|
||||
@@ -53,9 +64,34 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h
|
||||
}
|
||||
}
|
||||
|
||||
export function footprintContains(type: AssetType, props: PropertyBag, centre: Vec2, point: Vec2): boolean {
|
||||
/**
|
||||
* How an item of this type is drawn: with its whole transform, or – for the icons of behaviors,
|
||||
* containers and the camera frame – upright and unscaled at its position, since an icon that turns
|
||||
* with its parent would only be harder to read. Colliders are symbols too but follow their body.
|
||||
*/
|
||||
export function placementOf(type: AssetType, m: Mat): Mat {
|
||||
return isSymbolType(type) && type !== 'collider' ? translation(m.tx, m.ty) : m;
|
||||
}
|
||||
|
||||
function anchorOffset(type: AssetType, props: PropertyBag, w: number): number {
|
||||
return type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
|
||||
}
|
||||
|
||||
/** The footprint's corners in world units, grown by `pad` world units on every side. */
|
||||
export function footprintCorners(type: AssetType, props: PropertyBag, m: Mat, pad = 0): Vec2[] {
|
||||
const placed = placementOf(type, m);
|
||||
const { w, h } = footprintOf(type, props);
|
||||
const offset = type === 'text' ? (0.5 - textAnchor(props)) * w : 0;
|
||||
const x = centre.x + offset;
|
||||
return Math.abs(point.x - x) <= w / 2 && Math.abs(point.y - centre.y) <= h / 2;
|
||||
const local = pad / (scaleOf(placed) || 1);
|
||||
return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0);
|
||||
}
|
||||
|
||||
/** The world rectangle around the drawn footprint – what culling, framing and labels need. */
|
||||
export function footprintExtent(type: AssetType, props: PropertyBag, m: Mat): Extent {
|
||||
return extentOfPoints(footprintCorners(type, props, m));
|
||||
}
|
||||
|
||||
export function footprintContains(type: AssetType, props: PropertyBag, m: Mat, point: Vec2): boolean {
|
||||
const { w, h } = footprintOf(type, props);
|
||||
const local = apply(invert(placementOf(type, m)), point);
|
||||
return Math.abs(local.x - anchorOffset(type, props, w)) <= w / 2 && Math.abs(local.y) <= h / 2;
|
||||
}
|
||||
+126
-51
@@ -1,14 +1,18 @@
|
||||
/**
|
||||
* Collider geometry in world units, shared by the editor and the runtime so both agree on what
|
||||
* "overlaps" means.
|
||||
* "overlaps" means. A box is oriented: it turns and grows with the body it sits on.
|
||||
*/
|
||||
import { angleOf, type Mat, scaleOf } from '../model/matrix';
|
||||
import type { PropertyBag, Vec2 } from '../model/Properties';
|
||||
import { defaultsFor } from './PropertySchema';
|
||||
|
||||
const DEFAULTS = defaultsFor('collider');
|
||||
|
||||
/** Overlaps this shallow count as touching, so two rotated boxes at rest do not jitter. */
|
||||
const EPSILON = 1e-9;
|
||||
|
||||
export type ColliderShape =
|
||||
| { kind: 'box'; x: number; y: number; w: number; h: number }
|
||||
| { kind: 'box'; x: number; y: number; w: number; h: number; angle: number }
|
||||
| { kind: 'circle'; x: number; y: number; r: number };
|
||||
|
||||
type Box = Extract<ColliderShape, { kind: 'box' }>;
|
||||
@@ -17,20 +21,23 @@ type Circle = Extract<ColliderShape, { kind: 'circle' }>;
|
||||
/** A movement axis in the plane (`z` is draw order, not geometry). */
|
||||
export type PlaneAxis = 'x' | 'y';
|
||||
|
||||
export function colliderShape(props: PropertyBag, centre: Vec2): ColliderShape {
|
||||
/** The shape as it sits in the world: sized by the transform's scale, a box turned by its angle. */
|
||||
export function colliderShape(props: PropertyBag, m: Mat): ColliderShape {
|
||||
const scale = scaleOf(m);
|
||||
if (props.shape === 'circle')
|
||||
return {
|
||||
kind: 'circle',
|
||||
x: centre.x,
|
||||
y: centre.y,
|
||||
r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)),
|
||||
x: m.tx,
|
||||
y: m.ty,
|
||||
r: Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * scale,
|
||||
};
|
||||
return {
|
||||
kind: 'box',
|
||||
x: centre.x,
|
||||
y: centre.y,
|
||||
w: Math.max(0, Number(props.width ?? DEFAULTS.width)),
|
||||
h: Math.max(0, Number(props.height ?? DEFAULTS.height)),
|
||||
x: m.tx,
|
||||
y: m.ty,
|
||||
w: Math.max(0, Number(props.width ?? DEFAULTS.width)) * scale,
|
||||
h: Math.max(0, Number(props.height ?? DEFAULTS.height)) * scale,
|
||||
angle: angleOf(m),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,75 +46,143 @@ export function isTrigger(props: PropertyBag): boolean {
|
||||
return Boolean(props.isTrigger ?? DEFAULTS.isTrigger);
|
||||
}
|
||||
|
||||
/** The size in the collider's own units, before any transform. */
|
||||
export function colliderSize(props: PropertyBag): { w: number; h: number } {
|
||||
const shape = colliderShape(props, { x: 0, y: 0 });
|
||||
return shape.kind === 'circle' ? { w: shape.r * 2, h: shape.r * 2 } : { w: shape.w, h: shape.h };
|
||||
if (props.shape === 'circle') {
|
||||
const d = Math.max(0, Number(props.radius ?? DEFAULTS.radius)) * 2;
|
||||
return { w: d, h: d };
|
||||
}
|
||||
return {
|
||||
w: Math.max(0, Number(props.width ?? DEFAULTS.width)),
|
||||
h: Math.max(0, Number(props.height ?? DEFAULTS.height)),
|
||||
};
|
||||
}
|
||||
|
||||
function axesOf(box: Box): [Vec2, Vec2] {
|
||||
const cos = Math.cos(box.angle);
|
||||
const sin = Math.sin(box.angle);
|
||||
return [
|
||||
{ x: cos, y: sin },
|
||||
{ x: -sin, y: cos },
|
||||
];
|
||||
}
|
||||
|
||||
function dot(a: Vec2, b: Vec2): number {
|
||||
return a.x * b.x + a.y * b.y;
|
||||
}
|
||||
|
||||
/** A world point in the box's own frame: origin at its centre, axes along its sides. */
|
||||
function toBoxFrame(box: Box, p: Vec2): Vec2 {
|
||||
const [ux, uy] = axesOf(box);
|
||||
const d = { x: p.x - box.x, y: p.y - box.y };
|
||||
return { x: dot(d, ux), y: dot(d, uy) };
|
||||
}
|
||||
|
||||
/** Half of the box's projection onto the unit direction `n`. */
|
||||
function reach(box: Box, n: Vec2): number {
|
||||
const [ux, uy] = axesOf(box);
|
||||
return (box.w / 2) * Math.abs(dot(ux, n)) + (box.h / 2) * Math.abs(dot(uy, n));
|
||||
}
|
||||
|
||||
/** How wide the shape is along the unit direction `n` – a turned box measures its own slant. */
|
||||
export function shapeExtent(shape: ColliderShape, n: Vec2): number {
|
||||
return shape.kind === 'circle' ? shape.r * 2 : reach(shape, n) * 2;
|
||||
}
|
||||
|
||||
export function shapeContains(shape: ColliderShape, point: Vec2): boolean {
|
||||
if (shape.kind === 'circle')
|
||||
return (point.x - shape.x) ** 2 + (point.y - shape.y) ** 2 <= shape.r * shape.r;
|
||||
return Math.abs(point.x - shape.x) * 2 <= shape.w && Math.abs(point.y - shape.y) * 2 <= shape.h;
|
||||
const local = toBoxFrame(shape, point);
|
||||
return Math.abs(local.x) * 2 <= shape.w && Math.abs(local.y) * 2 <= shape.h;
|
||||
}
|
||||
|
||||
/** Distance from a point to an axis-aligned box centred at the origin; 0 inside. */
|
||||
function distanceToBox(p: Vec2, hw: number, hh: number): number {
|
||||
const dx = Math.max(Math.abs(p.x) - hw, 0);
|
||||
const dy = Math.max(Math.abs(p.y) - hh, 0);
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
/** Shapes that merely touch along an edge do not count as overlapping. */
|
||||
export function shapesOverlap(a: ColliderShape, b: ColliderShape): boolean {
|
||||
if (a.kind === 'box' && b.kind === 'box') {
|
||||
return Math.abs(a.x - b.x) * 2 < a.w + b.w && Math.abs(a.y - b.y) * 2 < a.h + b.h;
|
||||
// Separating axes: the sides of both boxes. Apart along any one of them is apart.
|
||||
const centres = { x: a.x - b.x, y: a.y - b.y };
|
||||
for (const n of [...axesOf(a), ...axesOf(b)])
|
||||
if (reach(a, n) + reach(b, n) - Math.abs(dot(centres, n)) <= EPSILON) return false;
|
||||
return true;
|
||||
}
|
||||
if (a.kind === 'circle' && b.kind === 'circle') {
|
||||
const r = a.r + b.r;
|
||||
return (a.x - b.x) ** 2 + (a.y - b.y) ** 2 < r * r;
|
||||
}
|
||||
const { box, circle } = boxAndCircle(a, b);
|
||||
// Closest point of the box to the circle centre, then a plain distance check.
|
||||
const cx = Math.max(box.x - box.w / 2, Math.min(circle.x, box.x + box.w / 2));
|
||||
const cy = Math.max(box.y - box.h / 2, Math.min(circle.y, box.y + box.h / 2));
|
||||
return (circle.x - cx) ** 2 + (circle.y - cy) ** 2 < circle.r * circle.r;
|
||||
return distanceToBox(toBoxFrame(box, circle), box.w / 2, box.h / 2) < circle.r - EPSILON;
|
||||
}
|
||||
|
||||
/**
|
||||
* The centre distance along `axis` at which the shapes exactly touch, given their current offset on
|
||||
* the other axis; 0 when they do not overlap on that other axis and so cannot collide along `axis`.
|
||||
* How far `mover` has to travel along `direction` until it just clears `obstacle`; 0 when they are
|
||||
* apart already. This is what stops a moving object at a wall: it is pushed back against its
|
||||
* travel by exactly this much.
|
||||
*/
|
||||
export function axisSeparation(a: ColliderShape, b: ColliderShape, axis: PlaneAxis): number {
|
||||
const other: PlaneAxis = axis === 'x' ? 'y' : 'x';
|
||||
const offset = Math.abs(a[other] - b[other]);
|
||||
if (a.kind === 'box' && b.kind === 'box') {
|
||||
return offset * 2 < extent(a, other) + extent(b, other) ? (extent(a, axis) + extent(b, axis)) / 2 : 0;
|
||||
export function exitDistance(mover: ColliderShape, obstacle: ColliderShape, direction: Vec2): number {
|
||||
const length = Math.hypot(direction.x, direction.y);
|
||||
if (length === 0 || !shapesOverlap(mover, obstacle)) return 0;
|
||||
const d = { x: direction.x / length, y: direction.y / length };
|
||||
if (mover.kind === 'box' && obstacle.kind === 'box') return boxesExit(mover, obstacle, d);
|
||||
if (mover.kind === 'circle' && obstacle.kind === 'circle') {
|
||||
const sep = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
|
||||
const r = mover.r + obstacle.r;
|
||||
const along = dot(sep, d);
|
||||
return -along + Math.sqrt(Math.max(0, along * along - dot(sep, sep) + r * r));
|
||||
}
|
||||
if (a.kind === 'circle' && b.kind === 'circle') {
|
||||
const r = a.r + b.r;
|
||||
return offset < r ? Math.sqrt(r * r - offset * offset) : 0;
|
||||
}
|
||||
const { box, circle } = boxAndCircle(a, b);
|
||||
const halfOther = extent(box, other) / 2;
|
||||
const halfAxis = extent(box, axis) / 2;
|
||||
// Circle centre within the box's span on the other axis: the flat side is hit.
|
||||
if (offset <= halfOther) return halfAxis + circle.r;
|
||||
// Otherwise the corner is the closest point: a circle-vs-point solution.
|
||||
const corner = offset - halfOther;
|
||||
return corner < circle.r ? halfAxis + Math.sqrt(circle.r * circle.r - corner * corner) : 0;
|
||||
const { box, circle } = boxAndCircle(mover, obstacle);
|
||||
// Seen from the box: the circle's centre travels along `d` (or against it when the box moves).
|
||||
const [ux, uy] = axesOf(box);
|
||||
const sign = mover.kind === 'circle' ? 1 : -1;
|
||||
const v = { x: sign * dot(d, ux), y: sign * dot(d, uy) };
|
||||
const start = toBoxFrame(box, circle);
|
||||
return circleExit(start, v, box.w / 2, box.h / 2, circle.r);
|
||||
}
|
||||
|
||||
/**
|
||||
* How far `mover` has to be pushed back against its travel direction `dir` (±1) so it just touches
|
||||
* `obstacle`. Only valid when `mover` did not already overlap before its move.
|
||||
* The travel along `d` at which the first separating axis opens – for two boxes that is the exact
|
||||
* moment they stop overlapping. Along an axis the boxes may be moving apart or, when the mover is
|
||||
* already past the obstacle's centre, deeper in and out the far side.
|
||||
*/
|
||||
export function axisCorrection(
|
||||
mover: ColliderShape,
|
||||
obstacle: ColliderShape,
|
||||
axis: PlaneAxis,
|
||||
dir: 1 | -1,
|
||||
): number {
|
||||
if (!shapesOverlap(mover, obstacle)) return 0;
|
||||
return Math.max(0, dir * (mover[axis] - obstacle[axis]) + axisSeparation(mover, obstacle, axis));
|
||||
function boxesExit(mover: Box, obstacle: Box, d: Vec2): number {
|
||||
const centres = { x: mover.x - obstacle.x, y: mover.y - obstacle.y };
|
||||
let best = Infinity;
|
||||
for (const n of [...axesOf(mover), ...axesOf(obstacle)]) {
|
||||
const speed = dot(d, n);
|
||||
if (Math.abs(speed) < 1e-12) continue;
|
||||
const span = reach(mover, n) + reach(obstacle, n);
|
||||
const offset = dot(centres, n);
|
||||
const apart = offset === 0 || Math.sign(speed) === Math.sign(offset);
|
||||
const t = (apart ? span - Math.abs(offset) : span + Math.abs(offset)) / Math.abs(speed);
|
||||
if (t < best) best = t;
|
||||
}
|
||||
return Number.isFinite(best) ? best : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* A circle's centre starting at `start` (inside the box grown by `r`) moving along `v` in the box's
|
||||
* frame: the travel at which it is `r` away from the box. The distance to a box is convex along a
|
||||
* line, so the crossing is found by bisection – exact for sides and corners alike.
|
||||
*/
|
||||
function circleExit(start: Vec2, v: Vec2, hw: number, hh: number, r: number): number {
|
||||
const outside = (t: number) => distanceToBox({ x: start.x + v.x * t, y: start.y + v.y * t }, hw, hh) - r;
|
||||
let hi = 1;
|
||||
for (let i = 0; i < 64 && outside(hi) <= 0; i++) hi *= 2;
|
||||
let lo = 0;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (outside(mid) <= 0) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return hi;
|
||||
}
|
||||
|
||||
function boxAndCircle(a: ColliderShape, b: ColliderShape): { box: Box; circle: Circle } {
|
||||
return a.kind === 'box' ? { box: a, circle: b as Circle } : { box: b as Box, circle: a };
|
||||
}
|
||||
|
||||
function extent(box: Box, axis: PlaneAxis): number {
|
||||
return axis === 'x' ? box.w : box.h;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in new issue
Block a user