Add a Container tab and propagate template edits
The Container tab reuses SceneViewport against any object holder, so a container template is edited exactly like a scene. Right-click no longer changes the selection, which previously made nesting impossible: opening the menu on a container loaded it, and "Place in container" then targeted itself. Template edits (move, rename, override) now reach already-placed instances per slot, through a shared templateSync used by both the apply path and the plain object commands. Copies the user has touched keep their own value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6bedc948be
commit
9d93020bd8
35 files changed
+1452
-363
No files matched your search
@@ -3,47 +3,30 @@
|
||||
* back to the asset, or to the template of the container it is part of. Prefab semantics for the
|
||||
* other copies: those still at the template's value follow, those modified on their own do not.
|
||||
*/
|
||||
import { instancesOf, type TemplateLink, templateLinkOf } from '../model/container';
|
||||
import { type TemplateLink, templateLinkOf } from '../model/container';
|
||||
import { type Axis, type Position, type PropertyValue, setBagValue } from '../model/Properties';
|
||||
import { findScene, type Project } from '../model/Project';
|
||||
import { findObjectHolder, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset, requireObject, requirePropDef } from './require';
|
||||
import {
|
||||
ALL_AXES,
|
||||
overrideSlot,
|
||||
positionSlot,
|
||||
restoreSlots,
|
||||
type Slot,
|
||||
type SlotMemo,
|
||||
syncInstances,
|
||||
} from './templateSync';
|
||||
|
||||
const ALL_AXES: readonly Axis[] = ['x', 'y', 'z'];
|
||||
|
||||
function requireLink(project: Project, sceneId: string, object: SceneObject): TemplateLink {
|
||||
const scene = findScene(project, sceneId);
|
||||
const link = scene && templateLinkOf(project, scene, object);
|
||||
function requireLink(project: Project, holderId: string, object: SceneObject): TemplateLink {
|
||||
const holder = findObjectHolder(project, holderId);
|
||||
const link = holder && templateLinkOf(project, holder, object);
|
||||
if (!link) throw new CommandError(`${object.name || object.id} is not part of a placed container`);
|
||||
return link;
|
||||
}
|
||||
|
||||
/** One value that is applied on its own: an override key, or a single axis of the position. */
|
||||
interface Slot<T> {
|
||||
read(object: SceneObject): T;
|
||||
write(object: SceneObject, value: T): void;
|
||||
}
|
||||
|
||||
const overrideSlot = (key: string): Slot<PropertyValue | undefined> => ({
|
||||
read: (object) => object.overrides[key],
|
||||
write: (object, value) => setBagValue(object.overrides, key, value),
|
||||
});
|
||||
|
||||
const positionSlot = (axis: Axis): Slot<number> => ({
|
||||
read: (object) => object.position[axis],
|
||||
write: (object, value) => {
|
||||
object.position[axis] = value;
|
||||
},
|
||||
});
|
||||
|
||||
interface SlotMemo<T> {
|
||||
object: SceneObject;
|
||||
slot: Slot<T>;
|
||||
previous: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes `values` into the template's slots and carries along every instance that still agreed with
|
||||
* it. Returns the memos in application order; `restoreSlots` undoes them.
|
||||
@@ -60,21 +43,11 @@ function applyToTemplate<T>(
|
||||
slot,
|
||||
previous: before[i]!,
|
||||
}));
|
||||
for (const { object } of instancesOf(project, link.template.id)) {
|
||||
slots.forEach((slot, i) => {
|
||||
if (slot.read(object) !== before[i]) return;
|
||||
memos.push({ object, slot, previous: slot.read(object) });
|
||||
slot.write(object, values[i]!);
|
||||
});
|
||||
}
|
||||
memos.push(...syncInstances(project, link.template, slots, before, values));
|
||||
slots.forEach((slot, i) => slot.write(link.template, values[i]!));
|
||||
return memos;
|
||||
}
|
||||
|
||||
function restoreSlots<T>(memos: readonly SlotMemo<T>[]): void {
|
||||
for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the object's value for `key` into its asset and drops the override. For a container
|
||||
* instance the template's override is dropped too, along with the instances that shared it.
|
||||
@@ -86,7 +59,7 @@ export class ApplyOverrideToAssetCommand implements Command {
|
||||
private templateMemos: SlotMemo<PropertyValue | undefined>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly key: string,
|
||||
) {
|
||||
@@ -94,8 +67,8 @@ export class ApplyOverrideToAssetCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const scene = findScene(project, this.sceneId);
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const holder = findObjectHolder(project, this.holderId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const asset = requireAsset(project, object.assetId);
|
||||
requirePropDef(asset, this.key);
|
||||
const value = resolveSceneObjectProps(asset, object)[this.key];
|
||||
@@ -103,12 +76,12 @@ export class ApplyOverrideToAssetCommand implements Command {
|
||||
this.previousOverride = object.overrides[this.key];
|
||||
asset.props[this.key] = value!;
|
||||
delete object.overrides[this.key];
|
||||
const link = scene && templateLinkOf(project, scene, object);
|
||||
const link = holder && templateLinkOf(project, holder, object);
|
||||
this.templateMemos = link ? applyToTemplate(project, link, [overrideSlot(this.key)], [undefined]) : [];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const asset = requireAsset(project, object.assetId);
|
||||
restoreSlots(this.templateMemos);
|
||||
setBagValue(object.overrides, this.key, this.previousOverride);
|
||||
@@ -121,7 +94,7 @@ export class ApplyOverrideToContainerCommand implements Command {
|
||||
private memos: SlotMemo<PropertyValue | undefined>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly key: string,
|
||||
) {
|
||||
@@ -129,8 +102,8 @@ export class ApplyOverrideToContainerCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const link = requireLink(project, this.holderId, object);
|
||||
this.memos = applyToTemplate(project, link, [overrideSlot(this.key)], [object.overrides[this.key]]);
|
||||
}
|
||||
|
||||
@@ -144,7 +117,7 @@ export class RevertOverrideToTemplateCommand implements Command {
|
||||
private previous: PropertyValue | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly key: string,
|
||||
) {
|
||||
@@ -152,14 +125,14 @@ export class RevertOverrideToTemplateCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const link = requireLink(project, this.holderId, object);
|
||||
this.previous = object.overrides[this.key];
|
||||
setBagValue(object.overrides, this.key, link.template.overrides[this.key]);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous);
|
||||
setBagValue(requireObject(project, this.holderId, this.objectId).overrides, this.key, this.previous);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +142,7 @@ export class ApplyPositionToContainerCommand implements Command {
|
||||
private memos: SlotMemo<number>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly axis?: Axis,
|
||||
) {
|
||||
@@ -177,8 +150,8 @@ export class ApplyPositionToContainerCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const link = requireLink(project, this.holderId, object);
|
||||
const axes = this.axis ? [this.axis] : ALL_AXES;
|
||||
this.memos = applyToTemplate(
|
||||
project,
|
||||
@@ -198,7 +171,7 @@ export class RevertPositionToTemplateCommand implements Command {
|
||||
private previous: Position = { x: 0, y: 0, z: 0 };
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly axis?: Axis,
|
||||
) {
|
||||
@@ -206,13 +179,13 @@ export class RevertPositionToTemplateCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const link = requireLink(project, this.holderId, object);
|
||||
this.previous = { ...object.position };
|
||||
for (const a of this.axis ? [this.axis] : ALL_AXES) object.position[a] = link.template.position[a];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireObject(project, this.sceneId, this.objectId).position = { ...this.previous };
|
||||
requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { removeById } from '../util/list';
|
||||
import { buildAsset } from './assetCommands';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { nextAssetName } from './naming';
|
||||
import { requireAsset, requireObject, requireScene } from './require';
|
||||
import { requireAsset, requireObject, requireHolder } from './require';
|
||||
|
||||
/**
|
||||
* Creates a collider asset sized like the object (sprites: width × height; anything else: schema
|
||||
@@ -18,13 +18,13 @@ export class AddColliderCommand implements Command {
|
||||
readonly objectId = newId();
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly targetId: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
const target = requireObject(project, this.sceneId, this.targetId);
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
const target = requireObject(project, this.holderId, this.targetId);
|
||||
const targetAsset = requireAsset(project, target.assetId);
|
||||
if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider');
|
||||
|
||||
@@ -39,12 +39,12 @@ export class AddColliderCommand implements Command {
|
||||
asset.props.height = Number(props.height);
|
||||
}
|
||||
project.assets.push(asset);
|
||||
scene.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
|
||||
holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
scene.objects = removeById(scene.objects, this.objectId);
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
holder.objects = removeById(holder.objects, this.objectId);
|
||||
project.assets = removeById(project.assets, this.assetId);
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
worldPosition,
|
||||
} from '../model/hierarchy';
|
||||
import type { Position } from '../model/Properties';
|
||||
import { findScene, type Project } from '../model/Project';
|
||||
import { findObjectHolder, type Project } from '../model/Project';
|
||||
import { createSceneObject } from '../model/SceneObject';
|
||||
import { removeById } from '../util/list';
|
||||
import { buildAsset } from './assetCommands';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { nextAssetName } from './naming';
|
||||
import { requireObject, requireScene } from './require';
|
||||
import { requireObject, requireHolder } from './require';
|
||||
|
||||
/**
|
||||
* Turns the selected objects, with everything parented below them, into a container asset placed
|
||||
@@ -30,16 +30,16 @@ export class CreateContainerCommand implements Command {
|
||||
private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectIds: readonly string[],
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.sceneId, id));
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.holderId, id));
|
||||
if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
|
||||
|
||||
const worlds = selected.map((o) => worldPosition(scene, o));
|
||||
const worlds = selected.map((o) => worldPosition(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),
|
||||
@@ -49,15 +49,15 @@ export class CreateContainerCommand implements Command {
|
||||
// Members are collected in tree order, so a parent is always built before its children.
|
||||
const roots = new Set(
|
||||
subtreeRoots(
|
||||
scene,
|
||||
holder,
|
||||
selected.map((o) => o.id),
|
||||
),
|
||||
);
|
||||
const memberIds = subtreeIds(scene, roots);
|
||||
const members = treeOrder(scene)
|
||||
const memberIds = subtreeIds(holder, roots);
|
||||
const members = treeOrder(holder)
|
||||
.map((e) => e.object)
|
||||
.filter((o) => memberIds.has(o.id));
|
||||
const rootParents = new Set([...roots].map((id) => findObject(scene, id)!.parentId));
|
||||
const rootParents = new Set([...roots].map((id) => findObject(holder, id)!.parentId));
|
||||
const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
|
||||
|
||||
const asset = buildAsset(
|
||||
@@ -68,7 +68,7 @@ export class CreateContainerCommand implements Command {
|
||||
const templateIds = new Map(members.map((o) => [o.id, newId()]));
|
||||
for (const object of members) {
|
||||
const isRoot = roots.has(object.id);
|
||||
const world = worldPosition(scene, object);
|
||||
const world = worldPosition(holder, object);
|
||||
asset.objects.push({
|
||||
id: templateIds.get(object.id)!,
|
||||
assetId: object.assetId,
|
||||
@@ -93,32 +93,32 @@ export class CreateContainerCommand implements Command {
|
||||
const container = createSceneObject(
|
||||
this.objectId,
|
||||
this.assetId,
|
||||
localPositionFor(scene, containerParentId, centre),
|
||||
localPositionFor(holder, containerParentId, centre),
|
||||
containerParentId,
|
||||
);
|
||||
for (const object of members) {
|
||||
if (roots.has(object.id)) {
|
||||
const world = worldPosition(scene, object);
|
||||
const world = worldPosition(holder, object);
|
||||
object.parentId = this.objectId;
|
||||
object.position = { x: world.x - centre.x, y: world.y - centre.y, z: world.z };
|
||||
}
|
||||
object.templateId = templateIds.get(object.id)!;
|
||||
}
|
||||
project.assets.push(asset);
|
||||
scene.objects.push(container);
|
||||
holder.objects.push(container);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const scene = findScene(project, this.sceneId);
|
||||
if (scene) {
|
||||
const holder = findObjectHolder(project, this.holderId);
|
||||
if (holder) {
|
||||
for (const entry of this.previous) {
|
||||
const object = findObject(scene, entry.id);
|
||||
const object = findObject(holder, entry.id);
|
||||
if (!object) continue;
|
||||
object.parentId = entry.parentId;
|
||||
object.position = { ...entry.position };
|
||||
object.templateId = entry.templateId;
|
||||
}
|
||||
scene.objects = removeById(scene.objects, this.objectId);
|
||||
holder.objects = removeById(holder.objects, this.objectId);
|
||||
}
|
||||
project.assets = removeById(project.assets, this.assetId);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { findNode, type GraphNode } from '../graph/Graph';
|
||||
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type { Asset, BehaviorAsset, SceneAsset } from '../model/Asset';
|
||||
import { findObject } from '../model/hierarchy';
|
||||
import { findAsset, findScene, type Project } from '../model/Project';
|
||||
import { findAsset, findObjectHolder, findScene, type ObjectHolder, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { propDef, type PropertyDef } from '../properties/PropertySchema';
|
||||
import { CommandError } from './Command';
|
||||
@@ -20,8 +20,14 @@ export function requireScene(project: Project, sceneId: string): SceneAsset {
|
||||
return scene;
|
||||
}
|
||||
|
||||
export function requireObject(project: Project, sceneId: string, objectId: string): SceneObject {
|
||||
const object = findObject(requireScene(project, sceneId), objectId);
|
||||
export function requireHolder(project: Project, holderId: string): ObjectHolder {
|
||||
const holder = findObjectHolder(project, holderId);
|
||||
if (!holder) throw new CommandError(`Scene or container ${holderId} not found`);
|
||||
return holder;
|
||||
}
|
||||
|
||||
export function requireObject(project: Project, holderId: string, objectId: string): SceneObject {
|
||||
const object = findObject(requireHolder(project, holderId), objectId);
|
||||
if (!object) throw new CommandError(`Scene object ${objectId} not found`);
|
||||
return object;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { newId } from '../ids';
|
||||
import { isPlaceableType } from '../model/AssetTypes';
|
||||
import { instantiateContainer } from '../model/container';
|
||||
import { containerReaches, instantiateContainer } from '../model/container';
|
||||
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
|
||||
import {
|
||||
type Position,
|
||||
@@ -14,12 +14,23 @@ import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
||||
import { coerceValue } from '../properties/PropertySchema';
|
||||
import { type Removed, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset, requireObject, requirePropDef, requireScene } from './require';
|
||||
import { requireAsset, requireObject, requirePropDef, requireHolder } from './require';
|
||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
import {
|
||||
ALL_AXES,
|
||||
nameSlot,
|
||||
overrideSlot,
|
||||
positionSlot,
|
||||
restoreSlots,
|
||||
type SlotMemo,
|
||||
syncTemplateEdit,
|
||||
} from './templateSync';
|
||||
|
||||
/**
|
||||
* The position is a world position; with a parent it is converted to the parent's local space.
|
||||
* Placing a container also creates its content (see `instantiateContainer`).
|
||||
* Placing a container into a scene also creates its content (see `instantiateContainer`); into
|
||||
* a container template it does not, because the template spells out one object per placed copy
|
||||
* and `instantiateContainer` expands the nested one when the copy is made.
|
||||
*/
|
||||
export class PlaceAssetCommand implements Command {
|
||||
readonly label = 'Place asset';
|
||||
@@ -27,28 +38,35 @@ export class PlaceAssetCommand implements Command {
|
||||
private createdIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly assetId: string,
|
||||
private readonly position: Vec2 | Position,
|
||||
private readonly parentId = '',
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
const asset = requireAsset(project, this.assetId);
|
||||
if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene');
|
||||
if (this.parentId) requireObject(project, this.sceneId, this.parentId);
|
||||
const local = localPositionFor(scene, this.parentId, toPosition(this.position));
|
||||
if (
|
||||
holder.type === 'container' &&
|
||||
asset.type === 'container' &&
|
||||
containerReaches(project, asset, holder.id)
|
||||
)
|
||||
throw new CommandError(`${asset.name} cannot contain ${holder.name}: that nests forever`);
|
||||
if (this.parentId) requireObject(project, this.holderId, this.parentId);
|
||||
const local = localPositionFor(holder, this.parentId, toPosition(this.position));
|
||||
const created = [createSceneObject(this.objectId, this.assetId, local, this.parentId)];
|
||||
if (asset.type === 'container') created.push(...instantiateContainer(project, asset, this.objectId));
|
||||
if (asset.type === 'container' && holder.type === 'scene')
|
||||
created.push(...instantiateContainer(project, asset, this.objectId));
|
||||
this.createdIds = created.map((o) => o.id);
|
||||
scene.objects.push(...created);
|
||||
holder.objects.push(...created);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
const ids = new Set(this.createdIds);
|
||||
scene.objects = scene.objects.filter((o) => !ids.has(o.id));
|
||||
holder.objects = holder.objects.filter((o) => !ids.has(o.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,25 +76,25 @@ export class RemoveSceneObjectsCommand implements Command {
|
||||
private removed: Removed<SceneObject>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectIds: readonly string[],
|
||||
) {
|
||||
this.label = objectIds.length === 1 ? 'Remove object' : 'Remove objects';
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
for (const id of this.objectIds) requireObject(project, this.sceneId, id);
|
||||
this.removed = removeSubtrees(requireScene(project, this.sceneId), this.objectIds);
|
||||
for (const id of this.objectIds) requireObject(project, this.holderId, id);
|
||||
this.removed = removeSubtrees(requireHolder(project, this.holderId), this.objectIds);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
restoreAt(requireScene(project, this.sceneId).objects, this.removed);
|
||||
restoreAt(requireHolder(project, this.holderId).objects, this.removed);
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
|
||||
constructor(sceneId: string, objectId: string) {
|
||||
super(sceneId, [objectId]);
|
||||
constructor(holderId: string, objectId: string) {
|
||||
super(holderId, [objectId]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,21 +105,30 @@ export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
|
||||
export class MoveSceneObjectCommand implements Command {
|
||||
readonly label = 'Move object';
|
||||
private previous: Position = { x: 0, y: 0, z: 0 };
|
||||
private memos: SlotMemo<number>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly position: Position,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
this.previous = { ...object.position };
|
||||
object.position = { ...this.position };
|
||||
this.memos = syncTemplateEdit(
|
||||
project,
|
||||
this.holderId,
|
||||
object,
|
||||
ALL_AXES.map(positionSlot),
|
||||
ALL_AXES.map((axis) => this.previous[axis]),
|
||||
);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireObject(project, this.sceneId, this.objectId).position = { ...this.previous };
|
||||
restoreSlots(this.memos);
|
||||
requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,46 +138,51 @@ export class SetEditorOffsetCommand implements Command {
|
||||
private previous: Vec2 = { x: 0, y: 0 };
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly offset: Vec2,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
this.previous = { ...object.editorOffset };
|
||||
object.editorOffset = { x: this.offset.x, y: this.offset.y };
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireObject(project, this.sceneId, this.objectId).editorOffset = { ...this.previous };
|
||||
requireObject(project, this.holderId, this.objectId).editorOffset = { ...this.previous };
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameSceneObjectCommand implements Command {
|
||||
readonly label = 'Rename object';
|
||||
private previous = '';
|
||||
private memos: SlotMemo<string>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly name: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
this.previous = object.name;
|
||||
object.name = this.name;
|
||||
this.memos = syncTemplateEdit(project, this.holderId, object, [nameSlot], [this.previous]);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireObject(project, this.sceneId, this.objectId).name = this.previous;
|
||||
restoreSlots(this.memos);
|
||||
requireObject(project, this.holderId, this.objectId).name = this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
|
||||
private memos: SlotMemo<PropertyValue | undefined>[] = [];
|
||||
|
||||
protected constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
key: string,
|
||||
label: string,
|
||||
@@ -159,22 +191,39 @@ abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
|
||||
}
|
||||
|
||||
protected override resolveTarget(project: Project): SceneObject {
|
||||
return requireObject(project, this.sceneId, this.objectId);
|
||||
return requireObject(project, this.holderId, this.objectId);
|
||||
}
|
||||
|
||||
protected override bagOf(object: SceneObject): PropertyBag {
|
||||
return object.overrides;
|
||||
}
|
||||
|
||||
override execute(project: Project): void {
|
||||
const before = this.resolveTarget(project).overrides[this.key];
|
||||
super.execute(project);
|
||||
this.memos = syncTemplateEdit(
|
||||
project,
|
||||
this.holderId,
|
||||
this.resolveTarget(project),
|
||||
[overrideSlot(this.key)],
|
||||
[before],
|
||||
);
|
||||
}
|
||||
|
||||
override undo(project: Project): void {
|
||||
restoreSlots(this.memos);
|
||||
super.undo(project);
|
||||
}
|
||||
}
|
||||
|
||||
export class SetOverrideCommand extends OverrideCommand {
|
||||
constructor(
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
key: string,
|
||||
private readonly value: PropertyValue,
|
||||
) {
|
||||
super(sceneId, objectId, key, `Override ${key}`);
|
||||
super(holderId, objectId, key, `Override ${key}`);
|
||||
}
|
||||
|
||||
protected override computeNext(object: SceneObject, project: Project): PropertyValue {
|
||||
@@ -183,8 +232,8 @@ export class SetOverrideCommand extends OverrideCommand {
|
||||
}
|
||||
|
||||
export class ClearOverrideCommand extends OverrideCommand {
|
||||
constructor(sceneId: string, objectId: string, key: string) {
|
||||
super(sceneId, objectId, key, `Reset ${key}`);
|
||||
constructor(holderId: string, objectId: string, key: string) {
|
||||
super(holderId, objectId, key, `Reset ${key}`);
|
||||
}
|
||||
|
||||
// No schema check, unlike setting one: a key the asset no longer declares is exactly what one
|
||||
@@ -206,28 +255,28 @@ export class SetParentCommand implements Command {
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly parentId: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
if (this.parentId) {
|
||||
if (this.parentId === this.objectId) throw new CommandError('An object cannot be its own parent');
|
||||
requireObject(project, this.sceneId, this.parentId);
|
||||
if (isSelfOrAncestor(scene, this.objectId, this.parentId))
|
||||
requireObject(project, this.holderId, this.parentId);
|
||||
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(scene, object);
|
||||
const world = worldPosition(holder, object);
|
||||
object.parentId = this.parentId;
|
||||
object.position = localPositionFor(scene, this.parentId, world);
|
||||
object.position = localPositionFor(holder, this.parentId, world);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
object.parentId = this.previous.parentId;
|
||||
object.position = { ...this.previous.position };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Prefab semantics for container templates: a placed copy that still holds the template's old value
|
||||
* follows a change made to the template, a copy modified on its own keeps what it has. Used by both
|
||||
* directions – an edit in the container editor (see the object commands) and "apply" from a placed
|
||||
* object upwards (see `applyCommands`).
|
||||
*/
|
||||
import { instancesOf } from '../model/container';
|
||||
import { type Axis, type PropertyValue, setBagValue } from '../model/Properties';
|
||||
import { findObjectHolder, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
|
||||
export const ALL_AXES: readonly Axis[] = ['x', 'y', 'z'];
|
||||
|
||||
/** One value that follows on its own: an override key, a single axis of the position, the name. */
|
||||
export interface Slot<T> {
|
||||
read(object: SceneObject): T;
|
||||
write(object: SceneObject, value: T): void;
|
||||
}
|
||||
|
||||
export interface SlotMemo<T> {
|
||||
object: SceneObject;
|
||||
slot: Slot<T>;
|
||||
previous: T;
|
||||
}
|
||||
|
||||
export const overrideSlot = (key: string): Slot<PropertyValue | undefined> => ({
|
||||
read: (object) => object.overrides[key],
|
||||
write: (object, value) => setBagValue(object.overrides, key, value),
|
||||
});
|
||||
|
||||
export const positionSlot = (axis: Axis): Slot<number> => ({
|
||||
read: (object) => object.position[axis],
|
||||
write: (object, value) => {
|
||||
object.position[axis] = value;
|
||||
},
|
||||
});
|
||||
|
||||
export const nameSlot: Slot<string> = {
|
||||
read: (object) => object.name,
|
||||
write: (object, value) => {
|
||||
object.name = value;
|
||||
},
|
||||
};
|
||||
|
||||
/** Writes `after` into every copy of `template` whose slot still reads `before`. */
|
||||
export function syncInstances<T>(
|
||||
project: Project,
|
||||
template: SceneObject,
|
||||
slots: readonly Slot<T>[],
|
||||
before: readonly T[],
|
||||
after: readonly T[],
|
||||
): SlotMemo<T>[] {
|
||||
const memos: SlotMemo<T>[] = [];
|
||||
for (const { object } of instancesOf(project, template.id)) {
|
||||
slots.forEach((slot, i) => {
|
||||
const current = slot.read(object);
|
||||
if (current !== before[i]) return;
|
||||
memos.push({ object, slot, previous: current });
|
||||
slot.write(object, after[i]!);
|
||||
});
|
||||
}
|
||||
return memos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries an edit of a container template object to the placed copies; nothing to do when the
|
||||
* holder is a scene, where an edit is meant for that one object. `before` has to be read off the
|
||||
* object before it is written, the new values are read back from it here.
|
||||
*/
|
||||
export function syncTemplateEdit<T>(
|
||||
project: Project,
|
||||
holderId: string,
|
||||
object: SceneObject,
|
||||
slots: readonly Slot<T>[],
|
||||
before: readonly T[],
|
||||
): SlotMemo<T>[] {
|
||||
if (findObjectHolder(project, holderId)?.type !== 'container') return [];
|
||||
return syncInstances(
|
||||
project,
|
||||
object,
|
||||
slots,
|
||||
before,
|
||||
slots.map((slot) => slot.read(object)),
|
||||
);
|
||||
}
|
||||
|
||||
export function restoreSlots<T>(memos: readonly SlotMemo<T>[]): void {
|
||||
for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
|
||||
}
|
||||
@@ -37,8 +37,26 @@ export function listScenes(project: Project): SceneAsset[] {
|
||||
*/
|
||||
export type ObjectHolder = SceneAsset | ContainerAsset;
|
||||
|
||||
export function isObjectHolder(asset: Asset): asset is ObjectHolder {
|
||||
return asset.type === 'scene' || asset.type === 'container';
|
||||
}
|
||||
|
||||
export function objectHolders(project: Project): ObjectHolder[] {
|
||||
return project.assets.filter((a): a is ObjectHolder => a.type === 'scene' || a.type === 'container');
|
||||
return project.assets.filter(isObjectHolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scene or container asset an object belongs to. Editing a container's template is the same
|
||||
* work as editing a scene, so the object commands resolve their target through this rather than
|
||||
* through `findScene`.
|
||||
*/
|
||||
export function findObjectHolder(project: Project, holderId: string): ObjectHolder | undefined {
|
||||
const asset = findAsset(project, holderId);
|
||||
return asset && isObjectHolder(asset) ? asset : undefined;
|
||||
}
|
||||
|
||||
export function listContainers(project: Project): ContainerAsset[] {
|
||||
return project.assets.filter((a): a is ContainerAsset => a.type === 'container');
|
||||
}
|
||||
|
||||
export interface SceneObjectRef {
|
||||
|
||||
@@ -37,6 +37,27 @@ export function instancesOf(project: Project, templateId: string): SceneObjectRe
|
||||
return objectsWhere(project, (o) => o.templateId === templateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `wantedId` is that container or sits somewhere in its content. Editing a container's
|
||||
* template can place containers into it, and a cycle would expand into thousands of objects the
|
||||
* moment one of them is placed.
|
||||
*/
|
||||
export function containerReaches(project: Project, asset: ContainerAsset, wantedId: string): boolean {
|
||||
if (asset.id === wantedId) return true;
|
||||
const seen = new Set([asset.id]);
|
||||
const queue = [asset];
|
||||
for (const current of queue) {
|
||||
for (const template of current.objects) {
|
||||
const nested = findAsset(project, template.assetId);
|
||||
if (nested?.type !== 'container' || seen.has(nested.id)) continue;
|
||||
if (nested.id === wantedId) return true;
|
||||
seen.add(nested.id);
|
||||
queue.push(nested);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const MAX_NESTING = 8;
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user