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:
1 parent
91ee2feadd
commit
0b78382bcc
26 files changed
+897
-114
No files matched your search
+13
-1
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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: '',
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user