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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user