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
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user