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

+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;