Multiselection and container assets: create container from selection, template instances

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 17:40:51 +02:00
1 parent 91ee2feadd
commit 0b78382bcc
26 files changed
+897 -114

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
import type { Project } from '../model/Project';
import type { Command } from './Command';
/**
* Several commands as one undo step (moving a multi-selection, for instance). Executes in
* order; if one of them throws, the ones already done are rolled back before re-throwing,
* so the project is never left half-changed.
*/
export class CompoundCommand implements Command {
constructor(readonly label: string, private readonly commands: readonly Command[]) {}
execute(project: Project): void {
const done: Command[] = [];
try {
for (const command of this.commands) {
command.execute(project);
done.push(command);
}
} catch (error) {
for (const command of done.reverse()) command.undo(project);
throw error;
}
}
undo(project: Project): void {
for (let i = this.commands.length - 1; i >= 0; i--) this.commands[i]!.undo(project);
}
}
+18 -9
View File
@@ -1,8 +1,8 @@
import { createEmptyGraph } from '../graph/Graph';
import type { Asset } from '../model/Asset';
import type { Asset, ContainerAsset, SceneAsset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import type { PropertyValue } from '../model/Properties';
import { findAsset, findScene, listScenes, type Project } from '../model/Project';
import { findAsset, type Project } from '../model/Project';
import { coerceValue, defaultsFor, schemaForAsset } from '../properties/PropertySchema';
import { newId } from '../ids';
import { type Command, CommandError } from './Command';
@@ -21,6 +21,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
case 'collider':
return { ...base, type: 'collider' };
case 'container':
return { ...base, type: 'container', objects: [] };
}
}
@@ -49,12 +51,14 @@ export class CreateAssetCommand implements Command {
/**
* Deletes an asset and, as a cascade, every scene object referencing it – together with the
* objects parented below those (a subtree is removed as a whole).
* objects parented below those (a subtree is removed as a whole). Container templates
* referencing the asset lose those objects the same way.
*/
export class DeleteAssetCommand implements Command {
readonly label: string;
private removed?: { asset: Asset; index: number };
private removedObjects: { sceneId: string; removed: RemovedObject[] }[] = [];
/** Objects removed per scene or container asset (both hold a flat object list). */
private removedObjects: { holderId: string; removed: RemovedObject[] }[] = [];
private previousActiveScene = '';
constructor(private readonly assetId: string) {
@@ -66,10 +70,10 @@ export class DeleteAssetCommand implements Command {
const asset = project.assets[index];
if (!asset) throw new CommandError(`Asset ${this.assetId} not found`);
this.removedObjects = [];
for (const scene of listScenes(project)) {
const roots = scene.objects.filter((o) => o.assetId === this.assetId).map((o) => o.id);
for (const holder of objectHolders(project)) {
const roots = holder.objects.filter((o) => o.assetId === this.assetId).map((o) => o.id);
if (roots.length === 0) continue;
this.removedObjects.push({ sceneId: scene.id, removed: removeSubtrees(scene, roots) });
this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) });
}
this.removed = { asset, index };
project.assets.splice(index, 1);
@@ -84,13 +88,18 @@ export class DeleteAssetCommand implements Command {
if (!this.removed) return;
project.assets.splice(this.removed.index, 0, this.removed.asset);
for (const entry of this.removedObjects) {
const scene = findScene(project, entry.sceneId);
if (scene) restoreObjects(scene, entry.removed);
const holder = objectHolders(project).find((h) => h.id === entry.holderId);
if (holder) restoreObjects(holder, entry.removed);
}
project.activeSceneId = this.previousActiveScene;
}
}
/** Scenes and container assets: everything that holds placed objects. */
function objectHolders(project: Project): (SceneAsset | ContainerAsset)[] {
return project.assets.filter((a): a is SceneAsset | ContainerAsset => a.type === 'scene' || a.type === 'container');
}
export class RenameAssetCommand implements Command {
readonly label = 'Rename asset';
private previousName = '';
+103
View File
@@ -0,0 +1,103 @@
import type { ContainerAsset } from '../model/Asset';
import { descendantsOf, localPositionFor, subtreeRoots, treeOrder, worldPosition } from '../model/hierarchy';
import type { Position } from '../model/Properties';
import { findScene, type Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { newId } from '../ids';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './projectCommands';
/**
* Turns the selected objects (with everything parented below them) into a new container
* asset and replaces them in the scene by one placed container – exactly as if that asset
* had been placed there. The container's origin is the centre of the selection's bounding
* box (x/y of the objects' world positions; z = 0), so the template is centred around
* (0, 0, 0). The objects keep their ids and world positions: the selected roots are simply
* re-parented under the container with recomputed local positions. When all roots share a
* parent the container is placed below that parent, otherwise at the top level.
*/
export class CreateContainerCommand implements Command {
readonly label = 'Create container';
readonly assetId = newId();
readonly objectId = newId();
private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {}
execute(project: Project): void {
const scene = findScene(project, this.sceneId);
if (!scene) throw new CommandError(`Scene ${this.sceneId} not found`);
const selected = [...new Set(this.objectIds)].map((id) => {
const object = scene.objects.find((o) => o.id === id);
if (!object) throw new CommandError(`Scene object ${id} not found`);
return object;
});
if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
// Centre of the selection in world space – the container's origin.
const worlds = selected.map((o) => worldPosition(scene, 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),
z: 0,
};
// The subtrees that move into the container, in tree order (parents first).
const roots = new Set(subtreeRoots(scene, selected.map((o) => o.id)));
const memberIds = new Set<string>();
for (const id of roots) {
memberIds.add(id);
for (const child of descendantsOf(scene, id)) memberIds.add(child.id);
}
const members = treeOrder(scene).map((e) => e.object).filter((o) => memberIds.has(o.id));
const rootParents = new Set([...roots].map((id) => scene.objects.find((o) => o.id === id)!.parentId));
const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
// Template: same structure with private ids, roots relative to the centre.
const asset = buildAsset('container', nextAssetName(project, 'Container'), this.assetId) as ContainerAsset;
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);
asset.objects.push({
id: templateIds.get(object.id)!,
assetId: object.assetId,
name: object.name,
position: isRoot ? { x: world.x - centre.x, y: world.y - centre.y, z: world.z } : { ...object.position },
overrides: { ...object.overrides },
parentId: isRoot ? '' : templateIds.get(object.parentId) ?? '',
templateId: '',
});
}
// Scene: the container takes the roots' place, the members become its instance.
this.previous = members.map((o) => ({ id: o.id, parentId: o.parentId, position: { ...o.position }, templateId: o.templateId }));
const container = createSceneObject(this.objectId, this.assetId, localPositionFor(scene, containerParentId, centre), containerParentId);
for (const object of members) {
if (roots.has(object.id)) {
const world = worldPosition(scene, 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);
}
undo(project: Project): void {
const scene = findScene(project, this.sceneId);
if (scene) {
for (const entry of this.previous) {
const object = scene.objects.find((o) => o.id === entry.id);
if (!object) continue;
object.parentId = entry.parentId;
object.position = { ...entry.position };
object.templateId = entry.templateId;
}
scene.objects = scene.objects.filter((o) => o.id !== this.objectId);
}
project.assets = project.assets.filter((a) => a.id !== this.assetId);
}
}
+37 -12
View File
@@ -1,13 +1,13 @@
import { isPlaceableType } from '../model/AssetTypes';
import { descendantsOf, isSelfOrAncestor, localPositionFor, worldPosition } from '../model/hierarchy';
import { descendantsOf, isSelfOrAncestor, localPositionFor, type ObjectTree, worldPosition } from '../model/hierarchy';
import type { Position, PropertyValue, Vec2 } from '../model/Properties';
import { findScene, findSceneObject, type Project } from '../model/Project';
import type { SceneAsset } from '../model/Asset';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue, schemaForAsset } from '../properties/PropertySchema';
import { newId } from '../ids';
import { type Command, CommandError } from './Command';
import { requireAsset } from './assetCommands';
import { instantiateContainer } from '../model/container';
function requireScene(project: Project, sceneId: string) {
const scene = findScene(project, sceneId);
@@ -28,35 +28,39 @@ export interface RemovedObject {
}
/**
* Removes the given objects and all their descendants from the scene (a subtree goes as a
* whole). Returns what was removed in ascending index order.
* Removes the given objects and all their descendants from the object list (a subtree goes
* as a whole; ids that are descendants of other ids are simply covered). Returns what was
* removed in ascending index order.
*/
export function removeSubtrees(scene: SceneAsset, rootIds: string[]): RemovedObject[] {
export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): RemovedObject[] {
const ids = new Set<string>();
for (const rootId of rootIds) {
ids.add(rootId);
for (const child of descendantsOf(scene, rootId)) ids.add(child.id);
for (const child of descendantsOf(tree, rootId)) ids.add(child.id);
}
const removed: RemovedObject[] = [];
scene.objects.forEach((object, index) => {
tree.objects.forEach((object, index) => {
if (ids.has(object.id)) removed.push({ object, index });
});
scene.objects = scene.objects.filter((o) => !ids.has(o.id));
tree.objects = tree.objects.filter((o) => !ids.has(o.id));
return removed;
}
/** Puts removed objects back at their former indices (ascending order restores them exactly). */
export function restoreObjects(scene: SceneAsset, removed: RemovedObject[]): void {
for (const entry of [...removed].sort((a, b) => a.index - b.index)) scene.objects.splice(entry.index, 0, entry.object);
export function restoreObjects(tree: ObjectTree, removed: RemovedObject[]): void {
for (const entry of [...removed].sort((a, b) => a.index - b.index)) tree.objects.splice(entry.index, 0, entry.object);
}
/**
* Places an asset into a scene, creating a scene object. Scenes cannot be placed. 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`).
*/
export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
readonly objectId = newId();
/** Every object this command added: the placed one plus a container's content. */
private createdIds: string[] = [];
constructor(
private readonly sceneId: string,
@@ -71,12 +75,16 @@ export class PlaceAssetCommand implements Command {
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 world = { x: this.position.x, y: this.position.y, z: 'z' in this.position ? this.position.z : 0 };
scene.objects.push(createSceneObject(this.objectId, this.assetId, localPositionFor(scene, this.parentId, world), this.parentId));
const created = [createSceneObject(this.objectId, this.assetId, localPositionFor(scene, this.parentId, world), this.parentId)];
if (asset.type === 'container') created.push(...instantiateContainer(project, asset, this.objectId));
this.createdIds = created.map((o) => o.id);
scene.objects.push(...created);
}
undo(project: Project): void {
const scene = requireScene(project, this.sceneId);
scene.objects = scene.objects.filter((o) => o.id !== this.objectId);
const ids = new Set(this.createdIds);
scene.objects = scene.objects.filter((o) => !ids.has(o.id));
}
}
@@ -97,6 +105,23 @@ export class RemoveSceneObjectCommand implements Command {
}
}
/** Removes several objects (a multi-selection) with their subtrees as one undo step. */
export class RemoveSceneObjectsCommand implements Command {
readonly label = 'Remove objects';
private removed: RemovedObject[] = [];
constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {}
execute(project: Project): void {
for (const id of this.objectIds) requireObject(project, this.sceneId, id);
this.removed = removeSubtrees(requireScene(project, this.sceneId), this.objectIds);
}
undo(project: Project): void {
restoreObjects(requireScene(project, this.sceneId), this.removed);
}
}
/** Sets the full local position (x, y and draw order z) of a scene object. */
export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object';
+13 -1
View File
@@ -45,7 +45,19 @@ export interface ColliderAsset extends AssetBase {
type: 'collider';
}
export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset | ColliderAsset;
/**
* A container is a reusable group of objects (a "prefab"): its `objects` are a template that
* is instantiated below the container whenever it is placed. Template positions are local
* to the container's origin – the centre of the group when it was created from a selection.
* Template ids are private to the asset; every placed copy gets fresh ids and remembers the
* template object it came from (`SceneObject.templateId`).
*/
export interface ContainerAsset extends AssetBase {
type: 'container';
objects: SceneObject[];
}
export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export function isSceneAsset(asset: Asset): asset is SceneAsset {
return asset.type === 'scene';
+2 -2
View File
@@ -1,5 +1,5 @@
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior', 'collider'] as const;
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior', 'collider', 'container'] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
@@ -19,5 +19,5 @@ export function isAssetType(value: unknown): value is AssetType {
* They exist in the scene for their position, not for their looks.
*/
export function isSymbolType(type: AssetType): boolean {
return type === 'camera' || type === 'behavior' || type === 'collider';
return type === 'camera' || type === 'behavior' || type === 'collider' || type === 'container';
}
+7
View File
@@ -22,6 +22,12 @@ export interface SceneObject {
* acts on.
*/
parentId: string;
/**
* For objects that belong to a placed container: the id of the template object (inside
* the container asset) this one was instantiated from. Empty for everything else. This is
* the link that lets template changes reach the instances later on.
*/
templateId: string;
}
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
@@ -33,5 +39,6 @@ export function createSceneObject(id: string, assetId: string, position: Vec2 |
position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 },
overrides: {},
parentId,
templateId: '',
};
}
+28
View File
@@ -0,0 +1,28 @@
import { newId } from '../ids';
import type { ContainerAsset } from './Asset';
import { findAsset, type Project } from './Project';
import type { SceneObject } from './SceneObject';
/** Nesting depth at which a container inside a container inside … stops being expanded. */
const MAX_NESTING = 8;
/**
* The scene objects a placed container consists of: one fresh object per template object,
* parented below `containerObjectId` (or below the copy of their template parent), each
* remembering its template id. A template object that is itself a container is expanded in
* turn – unless the template already spells its content out (that is what a container built
* from a selection containing another container does).
*/
export function instantiateContainer(project: Project, asset: ContainerAsset, containerObjectId: string, depth = 0): SceneObject[] {
const ids = new Map(asset.objects.map((t) => [t.id, newId()]));
const result: SceneObject[] = [];
for (const template of asset.objects) {
const id = ids.get(template.id)!;
const parentId = template.parentId ? ids.get(template.parentId) ?? containerObjectId : containerObjectId;
result.push({ id, assetId: template.assetId, name: template.name, position: { ...template.position }, overrides: { ...template.overrides }, parentId, templateId: template.id });
const nested = findAsset(project, template.assetId);
const hasExplicitContent = asset.objects.some((o) => o.parentId === template.id);
if (nested?.type === 'container' && !hasExplicitContent && depth < MAX_NESTING) result.push(...instantiateContainer(project, nested, id, depth + 1));
}
return result;
}
+44 -21
View File
@@ -1,4 +1,3 @@
import type { SceneAsset } from './Asset';
import type { Position } from './Properties';
import type { SceneObject } from './SceneObject';
@@ -7,21 +6,27 @@ import type { SceneObject } from './SceneObject';
* creation order); the tree is implied by `SceneObject.parentId`. Positions are local to
* the parent, so the world position is the sum along the chain – there is no rotation or
* scale yet, which keeps this trivially invertible.
*
* The helpers work on anything that holds a flat object list – a scene, or the template
* inside a container asset.
*/
export function findObject(scene: SceneAsset, id: string): SceneObject | undefined {
return scene.objects.find((o) => o.id === id);
export interface ObjectTree {
objects: SceneObject[];
}
export function childrenOf(scene: SceneAsset, parentId: string): SceneObject[] {
return scene.objects.filter((o) => o.parentId === parentId);
export function findObject(tree: ObjectTree, id: string): SceneObject | undefined {
return tree.objects.find((o) => o.id === id);
}
export function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
return tree.objects.filter((o) => o.parentId === parentId);
}
/** All objects below `id`, depth-first, parents before children. */
export function descendantsOf(scene: SceneAsset, id: string): SceneObject[] {
export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
const result: SceneObject[] = [];
const visit = (parentId: string) => {
for (const child of childrenOf(scene, parentId)) {
for (const child of childrenOf(tree, parentId)) {
result.push(child);
visit(child.id);
}
@@ -31,37 +36,55 @@ export function descendantsOf(scene: SceneAsset, id: string): SceneObject[] {
}
/** Whether `ancestorId` is `objectId` itself or one of its ancestors (guards against cycles). */
export function isSelfOrAncestor(scene: SceneAsset, ancestorId: string, objectId: string): boolean {
let current: SceneObject | undefined = findObject(scene, objectId);
export function isSelfOrAncestor(tree: ObjectTree, ancestorId: string, objectId: string): boolean {
let current: SceneObject | undefined = findObject(tree, objectId);
const seen = new Set<string>();
while (current && !seen.has(current.id)) {
if (current.id === ancestorId) return true;
seen.add(current.id);
current = current.parentId ? findObject(scene, current.parentId) : undefined;
current = current.parentId ? findObject(tree, current.parentId) : undefined;
}
return false;
}
/**
* Of the given ids, those that have no ancestor among the ids themselves – the tops of the
* selected subtrees. Whole subtrees are what group operations (move, remove, group) act on.
*/
export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[] {
const set = new Set(ids);
return ids.filter((id) => {
let current = findObject(tree, id);
const seen = new Set<string>();
while (current?.parentId && !seen.has(current.id)) {
seen.add(current.id);
if (set.has(current.parentId)) return false;
current = findObject(tree, current.parentId);
}
return true;
});
}
/** Position of an object in world units (all local positions along the chain summed up). */
export function worldPosition(scene: SceneAsset, object: SceneObject): Position {
export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
const result = { ...object.position };
let parent = object.parentId ? findObject(scene, object.parentId) : undefined;
let parent = object.parentId ? findObject(tree, object.parentId) : undefined;
const seen = new Set<string>([object.id]);
while (parent && !seen.has(parent.id)) {
result.x += parent.position.x;
result.y += parent.position.y;
result.z += parent.position.z;
seen.add(parent.id);
parent = parent.parentId ? findObject(scene, parent.parentId) : undefined;
parent = parent.parentId ? findObject(tree, parent.parentId) : undefined;
}
return result;
}
/** The local position that puts an object at `world` under the given parent ('' = top level). */
export function localPositionFor(scene: SceneAsset, parentId: string, world: Position): Position {
const parent = parentId ? findObject(scene, parentId) : undefined;
export function localPositionFor(tree: ObjectTree, parentId: string, world: Position): Position {
const parent = parentId ? findObject(tree, parentId) : undefined;
if (!parent) return { ...world };
const origin = worldPosition(scene, parent);
const origin = worldPosition(tree, parent);
return { x: world.x - origin.x, y: world.y - origin.y, z: world.z - origin.z };
}
@@ -74,11 +97,11 @@ export interface TreeEntry {
* Objects in tree order: top-level objects in storage order, each followed by its subtree.
* This is the order the hierarchy panel shows and the tie-break order for equal `z`.
*/
export function treeOrder(scene: SceneAsset): TreeEntry[] {
export function treeOrder(tree: ObjectTree): TreeEntry[] {
const result: TreeEntry[] = [];
const ids = new Set(scene.objects.map((o) => o.id));
const ids = new Set(tree.objects.map((o) => o.id));
const visit = (parentId: string, depth: number) => {
for (const object of scene.objects) {
for (const object of tree.objects) {
if (object.parentId !== parentId) continue;
result.push({ object, depth });
visit(object.id, depth + 1);
@@ -86,7 +109,7 @@ export function treeOrder(scene: SceneAsset): TreeEntry[] {
};
visit('', 0);
// Objects whose parent is missing (should not happen after loading) are listed at the top level.
for (const object of scene.objects) {
for (const object of tree.objects) {
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) result.push({ object, depth: 0 });
}
return result;
+2
View File
@@ -47,6 +47,8 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
// Triggers report collisions but never block "Move and slide"; solid colliders do both.
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
],
// A container is pure structure: its content is the template, not a property.
container: [],
};
/** Properties every scene object has regardless of its asset type. */
+2 -1
View File
@@ -40,7 +40,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const type = a.type;
const name = typeof a.name === 'string' ? a.name : type;
const props = normalizeBag(a.props, schema, false);
if (type === 'scene') {
if (type === 'scene' || type === 'container') {
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
} else if (type === 'behavior') {
assets.push({ id, type, name, props, variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph) });
@@ -85,6 +85,7 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
overrides: normalizeBag(o.overrides, schema, true),
// `ownerId` is the pre-hierarchy name of the same link (documents saved before parenting).
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
templateId: typeof o.templateId === 'string' ? o.templateId : '',
});
}
// Parents must exist in the same scene, differ from the object itself and not form cycles.