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
@@ -3,47 +3,30 @@
|
||||
* back to the asset, or to the template of the container it is part of. Prefab semantics for the
|
||||
* other copies: those still at the template's value follow, those modified on their own do not.
|
||||
*/
|
||||
import { instancesOf, type TemplateLink, templateLinkOf } from '../model/container';
|
||||
import { type TemplateLink, templateLinkOf } from '../model/container';
|
||||
import { type Axis, type Position, type PropertyValue, setBagValue } from '../model/Properties';
|
||||
import { findScene, type Project } from '../model/Project';
|
||||
import { findObjectHolder, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset, requireObject, requirePropDef } from './require';
|
||||
import {
|
||||
ALL_AXES,
|
||||
overrideSlot,
|
||||
positionSlot,
|
||||
restoreSlots,
|
||||
type Slot,
|
||||
type SlotMemo,
|
||||
syncInstances,
|
||||
} from './templateSync';
|
||||
|
||||
const ALL_AXES: readonly Axis[] = ['x', 'y', 'z'];
|
||||
|
||||
function requireLink(project: Project, sceneId: string, object: SceneObject): TemplateLink {
|
||||
const scene = findScene(project, sceneId);
|
||||
const link = scene && templateLinkOf(project, scene, object);
|
||||
function requireLink(project: Project, holderId: string, object: SceneObject): TemplateLink {
|
||||
const holder = findObjectHolder(project, holderId);
|
||||
const link = holder && templateLinkOf(project, holder, object);
|
||||
if (!link) throw new CommandError(`${object.name || object.id} is not part of a placed container`);
|
||||
return link;
|
||||
}
|
||||
|
||||
/** One value that is applied on its own: an override key, or a single axis of the position. */
|
||||
interface Slot<T> {
|
||||
read(object: SceneObject): T;
|
||||
write(object: SceneObject, value: T): void;
|
||||
}
|
||||
|
||||
const overrideSlot = (key: string): Slot<PropertyValue | undefined> => ({
|
||||
read: (object) => object.overrides[key],
|
||||
write: (object, value) => setBagValue(object.overrides, key, value),
|
||||
});
|
||||
|
||||
const positionSlot = (axis: Axis): Slot<number> => ({
|
||||
read: (object) => object.position[axis],
|
||||
write: (object, value) => {
|
||||
object.position[axis] = value;
|
||||
},
|
||||
});
|
||||
|
||||
interface SlotMemo<T> {
|
||||
object: SceneObject;
|
||||
slot: Slot<T>;
|
||||
previous: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes `values` into the template's slots and carries along every instance that still agreed with
|
||||
* it. Returns the memos in application order; `restoreSlots` undoes them.
|
||||
@@ -60,21 +43,11 @@ function applyToTemplate<T>(
|
||||
slot,
|
||||
previous: before[i]!,
|
||||
}));
|
||||
for (const { object } of instancesOf(project, link.template.id)) {
|
||||
slots.forEach((slot, i) => {
|
||||
if (slot.read(object) !== before[i]) return;
|
||||
memos.push({ object, slot, previous: slot.read(object) });
|
||||
slot.write(object, values[i]!);
|
||||
});
|
||||
}
|
||||
memos.push(...syncInstances(project, link.template, slots, before, values));
|
||||
slots.forEach((slot, i) => slot.write(link.template, values[i]!));
|
||||
return memos;
|
||||
}
|
||||
|
||||
function restoreSlots<T>(memos: readonly SlotMemo<T>[]): void {
|
||||
for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the object's value for `key` into its asset and drops the override. For a container
|
||||
* instance the template's override is dropped too, along with the instances that shared it.
|
||||
@@ -86,7 +59,7 @@ export class ApplyOverrideToAssetCommand implements Command {
|
||||
private templateMemos: SlotMemo<PropertyValue | undefined>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly key: string,
|
||||
) {
|
||||
@@ -94,8 +67,8 @@ export class ApplyOverrideToAssetCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const scene = findScene(project, this.sceneId);
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const holder = findObjectHolder(project, this.holderId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const asset = requireAsset(project, object.assetId);
|
||||
requirePropDef(asset, this.key);
|
||||
const value = resolveSceneObjectProps(asset, object)[this.key];
|
||||
@@ -103,12 +76,12 @@ export class ApplyOverrideToAssetCommand implements Command {
|
||||
this.previousOverride = object.overrides[this.key];
|
||||
asset.props[this.key] = value!;
|
||||
delete object.overrides[this.key];
|
||||
const link = scene && templateLinkOf(project, scene, object);
|
||||
const link = holder && templateLinkOf(project, holder, object);
|
||||
this.templateMemos = link ? applyToTemplate(project, link, [overrideSlot(this.key)], [undefined]) : [];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const asset = requireAsset(project, object.assetId);
|
||||
restoreSlots(this.templateMemos);
|
||||
setBagValue(object.overrides, this.key, this.previousOverride);
|
||||
@@ -121,7 +94,7 @@ export class ApplyOverrideToContainerCommand implements Command {
|
||||
private memos: SlotMemo<PropertyValue | undefined>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly key: string,
|
||||
) {
|
||||
@@ -129,8 +102,8 @@ export class ApplyOverrideToContainerCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const link = requireLink(project, this.holderId, object);
|
||||
this.memos = applyToTemplate(project, link, [overrideSlot(this.key)], [object.overrides[this.key]]);
|
||||
}
|
||||
|
||||
@@ -144,7 +117,7 @@ export class RevertOverrideToTemplateCommand implements Command {
|
||||
private previous: PropertyValue | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly key: string,
|
||||
) {
|
||||
@@ -152,14 +125,14 @@ export class RevertOverrideToTemplateCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const link = requireLink(project, this.holderId, object);
|
||||
this.previous = object.overrides[this.key];
|
||||
setBagValue(object.overrides, this.key, link.template.overrides[this.key]);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous);
|
||||
setBagValue(requireObject(project, this.holderId, this.objectId).overrides, this.key, this.previous);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +142,7 @@ export class ApplyPositionToContainerCommand implements Command {
|
||||
private memos: SlotMemo<number>[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly axis?: Axis,
|
||||
) {
|
||||
@@ -177,8 +150,8 @@ export class ApplyPositionToContainerCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const link = requireLink(project, this.holderId, object);
|
||||
const axes = this.axis ? [this.axis] : ALL_AXES;
|
||||
this.memos = applyToTemplate(
|
||||
project,
|
||||
@@ -198,7 +171,7 @@ export class RevertPositionToTemplateCommand implements Command {
|
||||
private previous: Position = { x: 0, y: 0, z: 0 };
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly axis?: Axis,
|
||||
) {
|
||||
@@ -206,13 +179,13 @@ export class RevertPositionToTemplateCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
const link = requireLink(project, this.holderId, object);
|
||||
this.previous = { ...object.position };
|
||||
for (const a of this.axis ? [this.axis] : ALL_AXES) object.position[a] = link.template.position[a];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireObject(project, this.sceneId, this.objectId).position = { ...this.previous };
|
||||
requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { removeById } from '../util/list';
|
||||
import { buildAsset } from './assetCommands';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { nextAssetName } from './naming';
|
||||
import { requireAsset, requireObject, requireScene } from './require';
|
||||
import { requireAsset, requireObject, requireHolder } from './require';
|
||||
|
||||
/**
|
||||
* Creates a collider asset sized like the object (sprites: width × height; anything else: schema
|
||||
@@ -18,13 +18,13 @@ export class AddColliderCommand implements Command {
|
||||
readonly objectId = newId();
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly targetId: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
const target = requireObject(project, this.sceneId, this.targetId);
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
const target = requireObject(project, this.holderId, this.targetId);
|
||||
const targetAsset = requireAsset(project, target.assetId);
|
||||
if (targetAsset.type === 'collider') throw new CommandError('A collider cannot have a collider');
|
||||
|
||||
@@ -39,12 +39,12 @@ export class AddColliderCommand implements Command {
|
||||
asset.props.height = Number(props.height);
|
||||
}
|
||||
project.assets.push(asset);
|
||||
scene.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
|
||||
holder.objects.push(createSceneObject(this.objectId, this.assetId, { x: 0, y: 0, z: 0 }, this.targetId));
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
scene.objects = removeById(scene.objects, this.objectId);
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
holder.objects = removeById(holder.objects, this.objectId);
|
||||
project.assets = removeById(project.assets, this.assetId);
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
worldPosition,
|
||||
} from '../model/hierarchy';
|
||||
import type { Position } from '../model/Properties';
|
||||
import { findScene, type Project } from '../model/Project';
|
||||
import { findObjectHolder, type Project } from '../model/Project';
|
||||
import { createSceneObject } from '../model/SceneObject';
|
||||
import { removeById } from '../util/list';
|
||||
import { buildAsset } from './assetCommands';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { nextAssetName } from './naming';
|
||||
import { requireObject, requireScene } from './require';
|
||||
import { requireObject, requireHolder } from './require';
|
||||
|
||||
/**
|
||||
* Turns the selected objects, with everything parented below them, into a container asset placed
|
||||
@@ -30,16 +30,16 @@ export class CreateContainerCommand implements Command {
|
||||
private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly holderId: string,
|
||||
private readonly objectIds: readonly string[],
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.sceneId, id));
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.holderId, id));
|
||||
if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
|
||||
|
||||
const worlds = selected.map((o) => worldPosition(scene, o));
|
||||
const worlds = selected.map((o) => worldPosition(holder, 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),
|
||||
@@ -49,15 +49,15 @@ export class CreateContainerCommand implements Command {
|
||||
// Members are collected in tree order, so a parent is always built before its children.
|
||||
const roots = new Set(
|
||||
subtreeRoots(
|
||||
scene,
|
||||
holder,
|
||||
selected.map((o) => o.id),
|
||||
),
|
||||
);
|
||||
const memberIds = subtreeIds(scene, roots);
|
||||
const members = treeOrder(scene)
|
||||
const memberIds = subtreeIds(holder, roots);
|
||||
const members = treeOrder(holder)
|
||||
.map((e) => e.object)
|
||||
.filter((o) => memberIds.has(o.id));
|
||||
const rootParents = new Set([...roots].map((id) => findObject(scene, id)!.parentId));
|
||||
const rootParents = new Set([...roots].map((id) => findObject(holder, id)!.parentId));
|
||||
const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
|
||||
|
||||
const asset = buildAsset(
|
||||
@@ -68,7 +68,7 @@ export class CreateContainerCommand implements Command {
|
||||
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);
|
||||
const world = worldPosition(holder, object);
|
||||
asset.objects.push({
|
||||
id: templateIds.get(object.id)!,
|
||||
assetId: object.assetId,
|
||||
@@ -93,32 +93,32 @@ export class CreateContainerCommand implements Command {
|
||||
const container = createSceneObject(
|
||||
this.objectId,
|
||||
this.assetId,
|
||||
localPositionFor(scene, containerParentId, centre),
|
||||
localPositionFor(holder, containerParentId, centre),
|
||||
containerParentId,
|
||||
);
|
||||
for (const object of members) {
|
||||
if (roots.has(object.id)) {
|
||||
const world = worldPosition(scene, object);
|
||||
const world = worldPosition(holder, 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);
|
||||
holder.objects.push(container);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const scene = findScene(project, this.sceneId);
|
||||
if (scene) {
|
||||
const holder = findObjectHolder(project, this.holderId);
|
||||
if (holder) {
|
||||
for (const entry of this.previous) {
|
||||
const object = findObject(scene, entry.id);
|
||||
const object = findObject(holder, entry.id);
|
||||
if (!object) continue;
|
||||
object.parentId = entry.parentId;
|
||||
object.position = { ...entry.position };
|
||||
object.templateId = entry.templateId;
|
||||
}
|
||||
scene.objects = removeById(scene.objects, this.objectId);
|
||||
holder.objects = removeById(holder.objects, this.objectId);
|
||||
}
|
||||
project.assets = removeById(project.assets, this.assetId);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { findNode, type GraphNode } from '../graph/Graph';
|
||||
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type { Asset, BehaviorAsset, SceneAsset } from '../model/Asset';
|
||||
import { findObject } from '../model/hierarchy';
|
||||
import { findAsset, findScene, type Project } from '../model/Project';
|
||||
import { findAsset, findObjectHolder, findScene, type ObjectHolder, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { propDef, type PropertyDef } from '../properties/PropertySchema';
|
||||
import { CommandError } from './Command';
|
||||
@@ -20,8 +20,14 @@ export function requireScene(project: Project, sceneId: string): SceneAsset {
|
||||
return scene;
|
||||
}
|
||||
|
||||
export function requireObject(project: Project, sceneId: string, objectId: string): SceneObject {
|
||||
const object = findObject(requireScene(project, sceneId), objectId);
|
||||
export function requireHolder(project: Project, holderId: string): ObjectHolder {
|
||||
const holder = findObjectHolder(project, holderId);
|
||||
if (!holder) throw new CommandError(`Scene or container ${holderId} not found`);
|
||||
return holder;
|
||||
}
|
||||
|
||||
export function requireObject(project: Project, holderId: string, objectId: string): SceneObject {
|
||||
const object = findObject(requireHolder(project, holderId), objectId);
|
||||
if (!object) throw new CommandError(`Scene object ${objectId} not found`);
|
||||
return object;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Prefab semantics for container templates: a placed copy that still holds the template's old value
|
||||
* follows a change made to the template, a copy modified on its own keeps what it has. Used by both
|
||||
* directions – an edit in the container editor (see the object commands) and "apply" from a placed
|
||||
* object upwards (see `applyCommands`).
|
||||
*/
|
||||
import { instancesOf } from '../model/container';
|
||||
import { type Axis, type PropertyValue, setBagValue } from '../model/Properties';
|
||||
import { findObjectHolder, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
|
||||
export const ALL_AXES: readonly Axis[] = ['x', 'y', 'z'];
|
||||
|
||||
/** One value that follows on its own: an override key, a single axis of the position, the name. */
|
||||
export interface Slot<T> {
|
||||
read(object: SceneObject): T;
|
||||
write(object: SceneObject, value: T): void;
|
||||
}
|
||||
|
||||
export interface SlotMemo<T> {
|
||||
object: SceneObject;
|
||||
slot: Slot<T>;
|
||||
previous: T;
|
||||
}
|
||||
|
||||
export const overrideSlot = (key: string): Slot<PropertyValue | undefined> => ({
|
||||
read: (object) => object.overrides[key],
|
||||
write: (object, value) => setBagValue(object.overrides, key, value),
|
||||
});
|
||||
|
||||
export const positionSlot = (axis: Axis): Slot<number> => ({
|
||||
read: (object) => object.position[axis],
|
||||
write: (object, value) => {
|
||||
object.position[axis] = value;
|
||||
},
|
||||
});
|
||||
|
||||
export const nameSlot: Slot<string> = {
|
||||
read: (object) => object.name,
|
||||
write: (object, value) => {
|
||||
object.name = value;
|
||||
},
|
||||
};
|
||||
|
||||
/** Writes `after` into every copy of `template` whose slot still reads `before`. */
|
||||
export function syncInstances<T>(
|
||||
project: Project,
|
||||
template: SceneObject,
|
||||
slots: readonly Slot<T>[],
|
||||
before: readonly T[],
|
||||
after: readonly T[],
|
||||
): SlotMemo<T>[] {
|
||||
const memos: SlotMemo<T>[] = [];
|
||||
for (const { object } of instancesOf(project, template.id)) {
|
||||
slots.forEach((slot, i) => {
|
||||
const current = slot.read(object);
|
||||
if (current !== before[i]) return;
|
||||
memos.push({ object, slot, previous: current });
|
||||
slot.write(object, after[i]!);
|
||||
});
|
||||
}
|
||||
return memos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries an edit of a container template object to the placed copies; nothing to do when the
|
||||
* holder is a scene, where an edit is meant for that one object. `before` has to be read off the
|
||||
* object before it is written, the new values are read back from it here.
|
||||
*/
|
||||
export function syncTemplateEdit<T>(
|
||||
project: Project,
|
||||
holderId: string,
|
||||
object: SceneObject,
|
||||
slots: readonly Slot<T>[],
|
||||
before: readonly T[],
|
||||
): SlotMemo<T>[] {
|
||||
if (findObjectHolder(project, holderId)?.type !== 'container') return [];
|
||||
return syncInstances(
|
||||
project,
|
||||
object,
|
||||
slots,
|
||||
before,
|
||||
slots.map((slot) => slot.read(object)),
|
||||
);
|
||||
}
|
||||
|
||||
export function restoreSlots<T>(memos: readonly SlotMemo<T>[]): void {
|
||||
for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
|
||||
}
|
||||
@@ -37,8 +37,26 @@ export function listScenes(project: Project): SceneAsset[] {
|
||||
*/
|
||||
export type ObjectHolder = SceneAsset | ContainerAsset;
|
||||
|
||||
export function isObjectHolder(asset: Asset): asset is ObjectHolder {
|
||||
return asset.type === 'scene' || asset.type === 'container';
|
||||
}
|
||||
|
||||
export function objectHolders(project: Project): ObjectHolder[] {
|
||||
return project.assets.filter((a): a is ObjectHolder => a.type === 'scene' || a.type === 'container');
|
||||
return project.assets.filter(isObjectHolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scene or container asset an object belongs to. Editing a container's template is the same
|
||||
* work as editing a scene, so the object commands resolve their target through this rather than
|
||||
* through `findScene`.
|
||||
*/
|
||||
export function findObjectHolder(project: Project, holderId: string): ObjectHolder | undefined {
|
||||
const asset = findAsset(project, holderId);
|
||||
return asset && isObjectHolder(asset) ? asset : undefined;
|
||||
}
|
||||
|
||||
export function listContainers(project: Project): ContainerAsset[] {
|
||||
return project.assets.filter((a): a is ContainerAsset => a.type === 'container');
|
||||
}
|
||||
|
||||
export interface SceneObjectRef {
|
||||
|
||||
@@ -37,6 +37,27 @@ export function instancesOf(project: Project, templateId: string): SceneObjectRe
|
||||
return objectsWhere(project, (o) => o.templateId === templateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `wantedId` is that container or sits somewhere in its content. Editing a container's
|
||||
* template can place containers into it, and a cycle would expand into thousands of objects the
|
||||
* moment one of them is placed.
|
||||
*/
|
||||
export function containerReaches(project: Project, asset: ContainerAsset, wantedId: string): boolean {
|
||||
if (asset.id === wantedId) return true;
|
||||
const seen = new Set([asset.id]);
|
||||
const queue = [asset];
|
||||
for (const current of queue) {
|
||||
for (const template of current.objects) {
|
||||
const nested = findAsset(project, template.assetId);
|
||||
if (nested?.type !== 'container' || seen.has(nested.id)) continue;
|
||||
if (nested.id === wantedId) return true;
|
||||
seen.add(nested.id);
|
||||
queue.push(nested);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const MAX_NESTING = 8;
|
||||
|
||||
/**
|
||||
|
||||
+44
-10
@@ -1,13 +1,15 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import type { TextureStore } from '../core/store/TextureStore';
|
||||
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
|
||||
import { GameViewport } from '../viewport/GameViewport';
|
||||
import { SceneViewport } from '../viewport/SceneViewport';
|
||||
import { attachAutosave } from './autosave';
|
||||
import { ContainerEditor } from './container/ContainerEditor';
|
||||
import { GraphEditor } from './graph/GraphEditor';
|
||||
import { Inspector } from './inspector/Inspector';
|
||||
import { AssetPanel } from './panels/AssetPanel';
|
||||
import { AssetPanel, type PlacementTarget } from './panels/AssetPanel';
|
||||
import { HierarchyPanel } from './panels/HierarchyPanel';
|
||||
import { Toolbar } from './panels/Toolbar';
|
||||
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
|
||||
@@ -17,9 +19,9 @@ import type { EditorState } from './state';
|
||||
import { TextureEditor } from './texture/TextureEditor';
|
||||
import { installTextureImport } from './textureImport';
|
||||
|
||||
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture';
|
||||
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture' | 'container';
|
||||
|
||||
const TAB_ORDER = ['scene', 'game', 'graph', 'texture'] as const;
|
||||
const TAB_ORDER = ['scene', 'game', 'graph', 'texture', 'container'] as const;
|
||||
|
||||
const MIN_LEFT_WIDTH = 160;
|
||||
const MAX_LEFT_WIDTH = 500;
|
||||
@@ -38,6 +40,7 @@ export class Editor implements PlayHost {
|
||||
readonly gameView: GameViewport;
|
||||
readonly graphEditor: GraphEditor;
|
||||
readonly textureEditor: TextureEditor;
|
||||
readonly containerEditor: ContainerEditor;
|
||||
readonly hierarchy: HierarchyPanel;
|
||||
tab: CentreTab = 'scene';
|
||||
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
||||
@@ -60,7 +63,7 @@ export class Editor implements PlayHost {
|
||||
constructor(
|
||||
app: Application,
|
||||
root: VStack,
|
||||
state: EditorState,
|
||||
private readonly state: EditorState,
|
||||
store: ProjectStore,
|
||||
textures: TextureStore,
|
||||
) {
|
||||
@@ -68,6 +71,7 @@ export class Editor implements PlayHost {
|
||||
this.gameView = new GameViewport(state);
|
||||
this.graphEditor = new GraphEditor(state);
|
||||
this.textureEditor = new TextureEditor(state);
|
||||
this.containerEditor = new ContainerEditor(state);
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
this.inspector = new Inspector(state);
|
||||
this.play = new PlaySession(state, this);
|
||||
@@ -87,7 +91,7 @@ export class Editor implements PlayHost {
|
||||
|
||||
this.workspace = new HStack();
|
||||
this.leftColumn = new VStack({ gap: 1 });
|
||||
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter(), textures), 1);
|
||||
this.leftColumn.add(new AssetPanel(state, () => this.placementTarget(), textures), 1);
|
||||
installTextureImport(app.canvas, { state, textures });
|
||||
this.leftColumn.add(this.hierarchy, 1);
|
||||
|
||||
@@ -103,6 +107,11 @@ export class Editor implements PlayHost {
|
||||
width: 90,
|
||||
onClick: () => this.showTab('texture'),
|
||||
}),
|
||||
container: new Button('Container', {
|
||||
variant: 'ghost',
|
||||
width: 90,
|
||||
onClick: () => this.showTab('container'),
|
||||
}),
|
||||
};
|
||||
for (const tab of TAB_ORDER) tabs.add(this.tabButtons[tab]);
|
||||
this.centre.add(tabs);
|
||||
@@ -110,9 +119,11 @@ export class Editor implements PlayHost {
|
||||
this.centre.add(this.graphEditor, 1);
|
||||
this.centre.add(this.gameView, 1);
|
||||
this.centre.add(this.textureEditor, 1);
|
||||
this.centre.add(this.containerEditor, 1);
|
||||
this.graphEditor.visible = false;
|
||||
this.gameView.visible = false;
|
||||
this.textureEditor.visible = false;
|
||||
this.containerEditor.visible = false;
|
||||
this.tabButtons.scene.setToggled(true);
|
||||
|
||||
this.leftColumn.minWidth = this.leftWidth;
|
||||
@@ -138,12 +149,18 @@ export class Editor implements PlayHost {
|
||||
attachAutosave(state, store);
|
||||
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
|
||||
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
|
||||
// A node and a frame exist only in their own tab, so selecting one switches to it;
|
||||
// selecting the behavior or texture asset itself does not.
|
||||
// A node, a frame and a template object exist only in their own tab, so selecting one
|
||||
// switches to it; selecting the behavior, texture or container asset itself does not.
|
||||
state.on('selection', () => {
|
||||
const kind = state.selection?.kind;
|
||||
if (kind === 'node' && this.tab !== 'graph') this.showTab('graph');
|
||||
if (kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
|
||||
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
|
||||
if (
|
||||
s?.kind === 'object' &&
|
||||
this.tab !== 'container' &&
|
||||
findAsset(state.project, s.holderId)?.type === 'container'
|
||||
)
|
||||
this.showTab('container');
|
||||
});
|
||||
state.on('project', () => this.play.syncFromModel());
|
||||
}
|
||||
@@ -154,11 +171,28 @@ export class Editor implements PlayHost {
|
||||
this.graphEditor.visible = tab === 'graph';
|
||||
this.gameView.visible = tab === 'game';
|
||||
this.textureEditor.visible = tab === 'texture';
|
||||
this.containerEditor.visible = tab === 'container';
|
||||
// The hierarchy lists what the visible tab edits; every other tab is about the scene.
|
||||
this.hierarchy.showObjectsOf(
|
||||
tab === 'container'
|
||||
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
|
||||
: () => this.state.activeScene,
|
||||
);
|
||||
for (const key of TAB_ORDER) this.tabButtons[key].setToggled(key === tab);
|
||||
this.centre.relayout();
|
||||
ui().focus.validate();
|
||||
}
|
||||
|
||||
/** Where the Assets panel places: into the container being edited, otherwise into the scene. */
|
||||
private placementTarget(): PlacementTarget | undefined {
|
||||
if (this.tab === 'container') {
|
||||
const holder = findObjectHolder(this.state.project, this.containerEditor.currentAssetId);
|
||||
return holder ? { holder, at: this.containerEditor.worldCenter() } : undefined;
|
||||
}
|
||||
const scene = this.state.activeScene;
|
||||
return scene ? { holder: scene, at: this.viewport.worldCenter() } : undefined;
|
||||
}
|
||||
|
||||
boot(): Promise<void> {
|
||||
return this.projects.boot();
|
||||
}
|
||||
|
||||
@@ -12,24 +12,24 @@ import { Dialog } from '../ui';
|
||||
import { type EditorState, objectSelection } from './state';
|
||||
|
||||
/** Groups the objects into a new container asset placed in their stead; selects the container. */
|
||||
export function createContainer(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
|
||||
const command = new CreateContainerCommand(sceneId, objectIds);
|
||||
export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
|
||||
const command = new CreateContainerCommand(holderId, objectIds);
|
||||
if (!state.execute(command)) return false;
|
||||
state.select(objectSelection(sceneId, [command.objectId]));
|
||||
state.select(objectSelection(holderId, [command.objectId]));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Gives the object a collider child sized like it; selects the collider. */
|
||||
export function addCollider(state: EditorState, sceneId: string, objectId: string): boolean {
|
||||
const command = new AddColliderCommand(sceneId, objectId);
|
||||
export function addCollider(state: EditorState, holderId: string, objectId: string): boolean {
|
||||
const command = new AddColliderCommand(holderId, objectId);
|
||||
if (!state.execute(command)) return false;
|
||||
state.select(objectSelection(sceneId, [command.objectId]));
|
||||
state.select(objectSelection(holderId, [command.objectId]));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Removes the objects (with their subtrees) as one undo step. */
|
||||
export function removeObjects(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
|
||||
return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds));
|
||||
export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
|
||||
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
|
||||
}
|
||||
|
||||
/** Asks before deleting an asset, spelling out what goes with it. */
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { Theme } from '../../ui';
|
||||
import type { DrawItem } from '../../viewport/items';
|
||||
import { SceneViewport } from '../../viewport/SceneViewport';
|
||||
import { type View, worldToScreen } from '../../viewport/view';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
/** Arm length of the origin cross, in screen pixels. */
|
||||
const ORIGIN_ARM = 14;
|
||||
|
||||
/**
|
||||
* The editable view of one container's template. Everything about picking, dragging, the marquee
|
||||
* and the labels is the scene view's – the only difference is which asset holds the objects.
|
||||
*/
|
||||
export class ContainerCanvas extends SceneViewport {
|
||||
private assetId = '';
|
||||
private readonly originMark = new Graphics();
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state);
|
||||
this.content.addChild(this.originMark);
|
||||
}
|
||||
|
||||
get currentAssetId(): string {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
protected override holder(): ObjectHolder | undefined {
|
||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||
return asset?.type === 'container' ? asset : undefined;
|
||||
}
|
||||
|
||||
/** A container has no background of its own – painting one would invent a scene it is not in. */
|
||||
protected override paintsBackground(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override beforeItems(): void {
|
||||
super.beforeItems();
|
||||
this.originMark.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin, marked because it carries meaning here: it is the point the container is held by
|
||||
* when placed, so where the content sits relative to it is the thing being authored.
|
||||
*/
|
||||
protected override afterItems(items: DrawItem[], view: View): void {
|
||||
super.afterItems(items, view);
|
||||
const at = worldToScreen(view, 0, 0);
|
||||
this.originMark
|
||||
.moveTo(at.x - ORIGIN_ARM, at.y)
|
||||
.lineTo(at.x + ORIGIN_ARM, at.y)
|
||||
.moveTo(at.x, at.y - ORIGIN_ARM)
|
||||
.lineTo(at.x, at.y + ORIGIN_ARM)
|
||||
.stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.6 });
|
||||
this.originMark.circle(at.x, at.y, 3).stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.6 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { ContainerAsset } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset, listContainers } from '../../core/model/Project';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { ContainerCanvas } from './ContainerCanvas';
|
||||
|
||||
/** Room kept for the "N objects" readout, wide enough for a three-digit template. */
|
||||
const INFO_WIDTH = 110;
|
||||
|
||||
/**
|
||||
* The Container tab. It follows the selection, but keeps showing the last container when the
|
||||
* selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
|
||||
*/
|
||||
export class ContainerEditor extends VStack {
|
||||
private readonly canvas: ContainerCanvas;
|
||||
private readonly picker: Dropdown;
|
||||
private readonly info: Label;
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
|
||||
header.add(new Label('Container', { style: 'muted' }));
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
header.add(this.picker);
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
this.canvas = new ContainerCanvas(state);
|
||||
this.add(header);
|
||||
this.add(this.canvas, 1);
|
||||
|
||||
state.on('selection', () => this.followSelection());
|
||||
state.on('project', () => this.refresh());
|
||||
state.on('load', () => {
|
||||
this.canvas.show('');
|
||||
this.followSelection();
|
||||
});
|
||||
this.followSelection();
|
||||
}
|
||||
|
||||
/** The container currently shown – the check scripts read this. */
|
||||
get currentAssetId(): string {
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
this.canvas.show(assetId);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** Middle of the view – where the Assets panel drops what is placed into this template. */
|
||||
worldCenter(): Vec2 {
|
||||
return this.canvas.worldCenter();
|
||||
}
|
||||
|
||||
/** Where a template object is drawn, in global screen coordinates – for the check scripts. */
|
||||
objectScreen(objectId: string): { x: number; y: number } | null {
|
||||
return this.canvas.objectScreen(objectId);
|
||||
}
|
||||
|
||||
/** The drawing area without the header, so a check can ask whether a point is on screen. */
|
||||
canvasRect(): { x: number; y: number; w: number; h: number } {
|
||||
const p = this.canvas.getGlobalPosition();
|
||||
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const id = this.state.selectedContainerId;
|
||||
// Null merely means "the selection is about something else"; the tab keeps its subject.
|
||||
if (id) this.canvas.show(id);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private asset(): ContainerAsset | null {
|
||||
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
|
||||
return asset && asset.type === 'container' ? asset : null;
|
||||
}
|
||||
|
||||
private containers(): ContainerAsset[] {
|
||||
return listContainers(this.state.project).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const current = this.canvas.currentAssetId;
|
||||
const items: MenuItem[] = this.containers().map((asset) => ({
|
||||
label: asset.id === current ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No containers yet', onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
// A deleted container leaves the canvas empty; fall back to whatever container is left.
|
||||
if (!this.asset()) this.canvas.show(this.containers()[0]?.id ?? '');
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : 'No container');
|
||||
const count = asset?.objects.length ?? 0;
|
||||
this.info.setText(asset ? `${count} ${count === 1 ? 'object' : 'objects'}` : '');
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
this.headerBg.clear();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import type { EditorState } from './state';
|
||||
* visible text and to read editor state.
|
||||
*/
|
||||
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
|
||||
const { viewport, gameView, graphEditor, textureEditor, hierarchy } = editor;
|
||||
const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy } = editor;
|
||||
const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
|
||||
const p = widget.getGlobalPosition();
|
||||
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
|
||||
@@ -135,6 +135,14 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
|
||||
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
|
||||
},
|
||||
container: {
|
||||
current: () => containerEditor.currentAssetId,
|
||||
pickerWidth: () => containerEditor.pickerWidth,
|
||||
center: () => centreOf(containerEditor),
|
||||
rect: () => containerEditor.canvasRect(),
|
||||
/** Where a template object is drawn, so a check can click or sample it. */
|
||||
objectScreen: (id: string) => containerEditor.objectScreen(id),
|
||||
},
|
||||
world: () => editor.worldSnapshot(),
|
||||
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
|
||||
hierarchyOrder: () => hierarchy.rowOrder(),
|
||||
|
||||
@@ -72,8 +72,8 @@ export class Inspector extends Panel {
|
||||
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
|
||||
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
|
||||
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
|
||||
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.sceneId, s.objectIds);
|
||||
else buildObjectSection(this.host, s.sceneId, s.objectId);
|
||||
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
|
||||
else buildObjectSection(this.host, s.holderId, s.objectId);
|
||||
this.scroll.setContent(this.host.body);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export class Inspector extends Panel {
|
||||
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
|
||||
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
|
||||
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
|
||||
return objectSignature(this.state.project, s.sceneId, s.objectIds, s.objectId);
|
||||
return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId);
|
||||
}
|
||||
|
||||
/** Pushes current model values into the widgets (after undo, drag in viewport, …). */
|
||||
@@ -101,7 +101,7 @@ export class Inspector extends Panel {
|
||||
? refreshNodeSection(this.host, s.assetId, s.nodeId)
|
||||
: s.kind === 'frame'
|
||||
? refreshFrameSection(this.host, s.assetId, s.frameId)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
|
||||
if (!ok) this.rebuild();
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
SetOverrideCommand,
|
||||
SetParentCommand,
|
||||
} from '../../core/commands/sceneObjectCommands';
|
||||
import type { Asset, SceneAsset } from '../../core/model/Asset';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { type TemplateLink, templateLinkOf } from '../../core/model/container';
|
||||
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
|
||||
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, findScene, type Project } from '../../core/model/Project';
|
||||
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
|
||||
import type { SceneObject } from '../../core/model/SceneObject';
|
||||
import {
|
||||
COMMON_PROPS,
|
||||
@@ -46,13 +46,13 @@ const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
|
||||
/** Structure-defining facts of an object selection: parent, template link and schema. */
|
||||
export function objectSignature(
|
||||
project: Project,
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectIds: readonly string[],
|
||||
objectId: string,
|
||||
): string {
|
||||
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
|
||||
const scene = findScene(project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
const holder = findObjectHolder(project, holderId);
|
||||
const object = holder && findObject(holder, objectId);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
if (!object || !asset) return 'missing';
|
||||
return (
|
||||
@@ -64,44 +64,44 @@ export function objectSignature(
|
||||
}
|
||||
|
||||
/** Several objects: what they are, and the actions that apply to all of them at once. */
|
||||
export function buildObjectsSection(host: InspectorHost, sceneId: string, objectIds: string[]): void {
|
||||
const scene = findScene(host.state.project, sceneId);
|
||||
if (!scene) return;
|
||||
export function buildObjectsSection(host: InspectorHost, holderId: string, objectIds: string[]): void {
|
||||
const holder = findObjectHolder(host.state.project, holderId);
|
||||
if (!holder) return;
|
||||
addTitle(host, `${objectIds.length} OBJECTS SELECTED`);
|
||||
for (const id of objectIds) {
|
||||
const object = findObject(scene, id);
|
||||
const object = findObject(holder, id);
|
||||
const asset = object && findAsset(host.state.project, object.assetId);
|
||||
if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`);
|
||||
}
|
||||
addGap(host);
|
||||
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
|
||||
addNote(host, 'Dragging one in the scene moves all of them.', 'small');
|
||||
addNote(host, 'Dragging one in the viewport moves all of them.', 'small');
|
||||
addGap(host);
|
||||
host.body.add(
|
||||
new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }),
|
||||
new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }),
|
||||
);
|
||||
addNote(host, 'Groups the selection into a reusable container', 'small');
|
||||
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small');
|
||||
addGap(host);
|
||||
host.body.add(
|
||||
new Button('Remove from scene', {
|
||||
new Button('Remove', {
|
||||
variant: 'danger',
|
||||
onClick: () => removeObjects(host.state, sceneId, objectIds),
|
||||
onClick: () => removeObjects(host.state, holderId, objectIds),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
|
||||
export function buildObjectSection(host: InspectorHost, sceneId: string, objectId: string): void {
|
||||
const scene = findScene(host.state.project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
|
||||
const holder = findObjectHolder(host.state.project, holderId);
|
||||
const object = holder && findObject(holder, objectId);
|
||||
const asset = object && findAsset(host.state.project, object.assetId);
|
||||
if (!scene || !object || !asset) return;
|
||||
if (!holder || !object || !asset) return;
|
||||
|
||||
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
|
||||
host.nameField = new TextField(object.name, {
|
||||
placeholder: asset.name,
|
||||
onChange: (name) => host.state.execute(new RenameSceneObjectCommand(sceneId, objectId, name.trim())),
|
||||
onChange: (name) => host.state.execute(new RenameSceneObjectCommand(holderId, objectId, name.trim())),
|
||||
});
|
||||
const nameRow = labelledRow('Name');
|
||||
nameRow.row.add(host.nameField, 1);
|
||||
@@ -117,10 +117,10 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
);
|
||||
host.body.add(assetRow.row);
|
||||
|
||||
host.body.add(parentRow(host, scene, object));
|
||||
host.body.add(parentRow(host, holder, object));
|
||||
|
||||
// Part of a placed container: values compare against the template, not just the asset.
|
||||
const link = templateLinkOf(host.state.project, scene, object);
|
||||
const link = templateLinkOf(host.state.project, holder, object);
|
||||
if (link) {
|
||||
const linkRow = labelledRow('Part of');
|
||||
linkRow.row.add(
|
||||
@@ -135,25 +135,25 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
|
||||
// The master switch, right where it is looked for: above everything type-specific.
|
||||
for (const def of COMMON_PROPS)
|
||||
propertyRow(host, sceneId, objectId, asset, def, resolveSceneObjectProps(asset, object));
|
||||
propertyRow(host, holderId, objectId, asset, def, resolveSceneObjectProps(asset, object));
|
||||
|
||||
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
|
||||
for (const axis of POSITION_AXES) {
|
||||
const moved = () => positionMoved(host.state, sceneId, objectId, axis);
|
||||
componentRow(host, sceneId, objectId, object.position[axis], {
|
||||
const moved = () => positionMoved(host.state, holderId, objectId, axis);
|
||||
componentRow(host, holderId, objectId, object.position[axis], {
|
||||
key: axis,
|
||||
label: axis.toUpperCase(),
|
||||
axis,
|
||||
read: (o) => o.position,
|
||||
write: (o, v) => (o.position = v),
|
||||
command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v),
|
||||
command: (v) => new MoveSceneObjectCommand(holderId, objectId, v),
|
||||
reset: () =>
|
||||
moved()
|
||||
? [
|
||||
{
|
||||
label: 'Reset to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)),
|
||||
host.state.execute(new RevertPositionToTemplateCommand(holderId, objectId, axis)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -163,33 +163,33 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
{
|
||||
label: 'Apply to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)),
|
||||
host.state.execute(new ApplyPositionToContainerCommand(holderId, objectId, axis)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Where the object is *drawn* in the scene view, nothing else – for pulling stacked objects apart.
|
||||
// Where the object is *drawn* in the viewport, nothing else – for pulling stacked objects apart.
|
||||
addTitle(host, 'EDITOR OFFSET (SCENE VIEW ONLY)');
|
||||
for (const axis of OFFSET_AXES) {
|
||||
componentRow(host, sceneId, objectId, object.editorOffset[axis], {
|
||||
componentRow(host, holderId, objectId, object.editorOffset[axis], {
|
||||
key: offsetKey(axis),
|
||||
label: `Offset ${axis.toUpperCase()}`,
|
||||
axis,
|
||||
read: (o) => o.editorOffset,
|
||||
write: (o, v) => (o.editorOffset = v),
|
||||
command: (v) => new SetEditorOffsetCommand(sceneId, objectId, v),
|
||||
command: (v) => new SetEditorOffsetCommand(holderId, objectId, v),
|
||||
// No apply: the offset never leaves the placed object.
|
||||
reset: () => {
|
||||
const current = currentObject(host.state, sceneId, objectId);
|
||||
const current = currentObject(host.state, holderId, objectId);
|
||||
if (!current || current.editorOffset[axis] === 0) return [];
|
||||
return [
|
||||
{
|
||||
label: 'Reset to 0',
|
||||
onSelect: () =>
|
||||
host.state.execute(
|
||||
new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 }),
|
||||
new SetEditorOffsetCommand(holderId, objectId, { ...current.editorOffset, [axis]: 0 }),
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -204,7 +204,7 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
if (own.length > 0 || asset.type === 'behavior')
|
||||
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
|
||||
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
|
||||
for (const def of own) propertyRow(host, sceneId, objectId, asset, def, values);
|
||||
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values);
|
||||
markObjectRows(host, object, asset, link);
|
||||
|
||||
if (asset.type === 'container') {
|
||||
@@ -219,24 +219,24 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
|
||||
host.body.add(
|
||||
new Button('+ Add collider', {
|
||||
variant: 'ghost',
|
||||
onClick: () => addCollider(host.state, sceneId, objectId),
|
||||
onClick: () => addCollider(host.state, holderId, objectId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
|
||||
export function refreshObjectSection(host: InspectorHost, sceneId: string, objectId: string): boolean {
|
||||
const scene = findScene(host.state.project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
|
||||
const holder = findObjectHolder(host.state.project, holderId);
|
||||
const object = holder && findObject(holder, objectId);
|
||||
const asset = object && findAsset(host.state.project, object.assetId);
|
||||
if (!scene || !object || !asset) return false;
|
||||
if (!holder || !object || !asset) return false;
|
||||
host.nameField?.setValue(object.name);
|
||||
for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]);
|
||||
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]);
|
||||
const values = resolveSceneObjectProps(asset, object);
|
||||
for (const def of schemaForAsset(asset)) host.fields.get(def.key)?.setValue(values[def.key] ?? def.default);
|
||||
markObjectRows(host, object, asset, templateLinkOf(host.state.project, scene, object));
|
||||
markObjectRows(host, object, asset, templateLinkOf(host.state.project, holder, object));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ interface ComponentSpec<V extends object> {
|
||||
*/
|
||||
function componentRow<V extends object>(
|
||||
host: InspectorHost,
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
value: number,
|
||||
spec: ComponentSpec<V>,
|
||||
@@ -269,7 +269,7 @@ function componentRow<V extends object>(
|
||||
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
|
||||
value,
|
||||
previewingHandlers<SceneObject, V>(host, {
|
||||
target: () => currentObject(host.state, sceneId, objectId),
|
||||
target: () => currentObject(host.state, holderId, objectId),
|
||||
// Copied: the snapshot outlives the scrub, the vector in the model does not.
|
||||
read: (object) => ({ ...spec.read(object) }),
|
||||
write: spec.write,
|
||||
@@ -283,7 +283,7 @@ function componentRow<V extends object>(
|
||||
}
|
||||
|
||||
/**
|
||||
* One editable property of a scene object: writes an override, offers ↺ (back to the asset
|
||||
* One editable property of a placed object: writes an override, offers ↺ (back to the asset
|
||||
* or, inside a container, to the template) and ↑ (apply upwards).
|
||||
*
|
||||
* Inside a container a value has two bases – the template it was instantiated from and, one
|
||||
@@ -293,31 +293,31 @@ function componentRow<V extends object>(
|
||||
*/
|
||||
function propertyRow(
|
||||
host: InspectorHost,
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
owner: Asset,
|
||||
def: PropertyDef,
|
||||
values: Record<string, PropertyValue>,
|
||||
): FieldRow {
|
||||
const bases = () => deviations(host.state, sceneId, objectId, def.key);
|
||||
const bases = () => deviations(host.state, holderId, objectId, def.key);
|
||||
const row = createFieldRow(
|
||||
host,
|
||||
def,
|
||||
values[def.key] ?? def.default,
|
||||
previewingHandlers<SceneObject>(host, {
|
||||
target: () => currentObject(host.state, sceneId, objectId),
|
||||
target: () => currentObject(host.state, holderId, objectId),
|
||||
read: (object) => object.overrides[def.key],
|
||||
write: (object, value) => (object.overrides[def.key] = value),
|
||||
remove: (object) => delete object.overrides[def.key],
|
||||
commit: (value) => new SetOverrideCommand(sceneId, objectId, def.key, value),
|
||||
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
|
||||
}),
|
||||
{
|
||||
extra: aspectActions(
|
||||
host,
|
||||
owner,
|
||||
def,
|
||||
() => currentProps(host.state, sceneId, objectId),
|
||||
(value) => host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)),
|
||||
() => currentProps(host.state, holderId, objectId),
|
||||
(value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)),
|
||||
),
|
||||
reset: () => {
|
||||
const { template, asset } = bases();
|
||||
@@ -327,7 +327,7 @@ function propertyRow(
|
||||
{
|
||||
label: 'Reset to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
|
||||
host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -335,7 +335,7 @@ function propertyRow(
|
||||
? [
|
||||
{
|
||||
label: 'Reset to asset',
|
||||
onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
|
||||
onSelect: () => host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -349,7 +349,7 @@ function propertyRow(
|
||||
{
|
||||
label: 'Apply to container',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
|
||||
host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -358,7 +358,7 @@ function propertyRow(
|
||||
{
|
||||
label: 'Apply to asset',
|
||||
onSelect: () =>
|
||||
host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
|
||||
host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -372,27 +372,27 @@ function propertyRow(
|
||||
}
|
||||
|
||||
/**
|
||||
* "Parent: <name> ▾" – picks another object of the same scene, or none. The object's own
|
||||
* "Parent: <name> ▾" – picks another object of the same scene or container, or none. The object's own
|
||||
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner.
|
||||
*/
|
||||
function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) {
|
||||
function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject) {
|
||||
const { row } = labelledRow('Parent');
|
||||
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
|
||||
const parent = findObject(scene, object.parentId);
|
||||
const parent = findObject(holder, object.parentId);
|
||||
const button = new Dropdown(parent ? nameOf(parent) : 'None');
|
||||
button.onClick = () => {
|
||||
const items: MenuItem[] = [
|
||||
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) },
|
||||
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) },
|
||||
];
|
||||
for (const { object: candidate, depth } of treeOrder(scene)) {
|
||||
if (isSelfOrAncestor(scene, object.id, candidate.id)) continue;
|
||||
for (const { object: candidate, depth } of treeOrder(holder)) {
|
||||
if (isSelfOrAncestor(holder, object.id, candidate.id)) continue;
|
||||
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
|
||||
items.push({
|
||||
label: candidate.id === object.parentId ? `● ${name}` : name,
|
||||
onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)),
|
||||
onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
|
||||
});
|
||||
}
|
||||
// Searchable: this lists every object of the scene, which grows with the project.
|
||||
// Searchable: this lists every object of the scene or container, which grows with the project.
|
||||
new Menu(items, { searchable: true }).openBelow(button);
|
||||
};
|
||||
row.add(button, 1);
|
||||
@@ -431,15 +431,15 @@ function markObjectRows(
|
||||
*/
|
||||
function deviations(
|
||||
state: InspectorHost['state'],
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
key: string,
|
||||
): { template: boolean; asset: boolean } {
|
||||
const scene = findScene(state.project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
const holder = findObjectHolder(state.project, holderId);
|
||||
const object = holder && findObject(holder, objectId);
|
||||
const asset = object && findAsset(state.project, object.assetId);
|
||||
if (!scene || !object || !asset) return { template: false, asset: false };
|
||||
const link = templateLinkOf(state.project, scene, object);
|
||||
if (!holder || !object || !asset) return { template: false, asset: false };
|
||||
const link = templateLinkOf(state.project, holder, object);
|
||||
return {
|
||||
template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
|
||||
asset: deviatesFromAsset(object, asset, key),
|
||||
@@ -449,28 +449,28 @@ function deviations(
|
||||
/** Whether the object sits somewhere else on `axis` than its container template says. */
|
||||
function positionMoved(
|
||||
state: InspectorHost['state'],
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
axis: Axis,
|
||||
): boolean {
|
||||
const scene = findScene(state.project, sceneId);
|
||||
const object = scene && findObject(scene, objectId);
|
||||
const link = scene && object && templateLinkOf(state.project, scene, object);
|
||||
const holder = findObjectHolder(state.project, holderId);
|
||||
const object = holder && findObject(holder, objectId);
|
||||
const link = holder && object && templateLinkOf(state.project, holder, object);
|
||||
return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
|
||||
}
|
||||
|
||||
function currentObject(
|
||||
state: InspectorHost['state'],
|
||||
sceneId: string,
|
||||
holderId: string,
|
||||
objectId: string,
|
||||
): SceneObject | undefined {
|
||||
const scene = findScene(state.project, sceneId);
|
||||
return scene && findObject(scene, objectId);
|
||||
const holder = findObjectHolder(state.project, holderId);
|
||||
return holder && findObject(holder, objectId);
|
||||
}
|
||||
|
||||
/** The values the object shows right now, asset defaults and overrides merged. */
|
||||
function currentProps(state: InspectorHost['state'], sceneId: string, objectId: string): PropertyBag | null {
|
||||
const object = currentObject(state, sceneId, objectId);
|
||||
function currentProps(state: InspectorHost['state'], holderId: string, objectId: string): PropertyBag | null {
|
||||
const object = currentObject(state, holderId, objectId);
|
||||
const asset = object && findAsset(state.project, object.assetId);
|
||||
return object && asset ? resolveSceneObjectProps(asset, object) : null;
|
||||
}
|
||||
@@ -3,13 +3,22 @@ import { nextAssetName } from '../../core/commands/naming';
|
||||
import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } from '../../core/model/AssetTypes';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import type { TextureStore } from '../../core/store/TextureStore';
|
||||
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
|
||||
import { confirmDeleteAsset } from '../actions';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
import { pickAndImport } from '../textureImport';
|
||||
|
||||
/**
|
||||
* Where a placed asset lands. The tab in front decides, so it appears where the user is
|
||||
* looking: the Scene tab places into the active scene, the Container tab into the template.
|
||||
*/
|
||||
export interface PlacementTarget {
|
||||
holder: ObjectHolder;
|
||||
at: Vec2;
|
||||
}
|
||||
|
||||
/** Lists all assets of the project; creates, places and deletes them. */
|
||||
export class AssetPanel extends Panel {
|
||||
private readonly list = new ListView();
|
||||
@@ -17,7 +26,7 @@ export class AssetPanel extends Panel {
|
||||
|
||||
constructor(
|
||||
private readonly state: EditorState,
|
||||
private readonly placementPoint: () => Vec2,
|
||||
private readonly target: () => PlacementTarget | undefined,
|
||||
/** Where imported images go. Without it the Texture entry has nothing to upload to. */
|
||||
private readonly textures: TextureStore | null = null,
|
||||
) {
|
||||
@@ -92,18 +101,19 @@ export class AssetPanel extends Panel {
|
||||
}
|
||||
|
||||
private place(assetId: string): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) {
|
||||
this.state.setNotice('Create a scene first, then place assets into it.');
|
||||
const target = this.target();
|
||||
if (!target) {
|
||||
this.state.setNotice('Nothing to place into – create a scene, or open a container first.');
|
||||
return;
|
||||
}
|
||||
const command = new PlaceAssetCommand(scene.id, assetId, this.placementPoint());
|
||||
if (this.state.execute(command)) this.state.select(objectSelection(scene.id, [command.objectId]));
|
||||
const { holder, at } = target;
|
||||
const command = new PlaceAssetCommand(holder.id, assetId, at);
|
||||
if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId]));
|
||||
}
|
||||
|
||||
private openContextMenu(id: string, x: number, y: number): void {
|
||||
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
|
||||
const asset = findAsset(this.state.project, id);
|
||||
if (!asset) return;
|
||||
if (!asset) return undefined;
|
||||
const items: MenuItem[] = [];
|
||||
if (asset.type === 'scene') {
|
||||
items.push({
|
||||
@@ -112,13 +122,16 @@ export class AssetPanel extends Panel {
|
||||
disabled: this.state.project.activeSceneId === id,
|
||||
});
|
||||
} else {
|
||||
const target = this.target();
|
||||
items.push({
|
||||
label: 'Place in scene',
|
||||
label: target?.holder.type === 'container' ? 'Place in container' : 'Place in scene',
|
||||
onSelect: () => this.place(id),
|
||||
disabled: !this.state.activeScene,
|
||||
disabled: !target,
|
||||
});
|
||||
}
|
||||
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
|
||||
new Menu(items).open(x, y);
|
||||
const menu = new Menu(items);
|
||||
menu.open(x, y);
|
||||
return menu;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { hasColorSwatch } from '../../core/model/AssetTypes';
|
||||
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
|
||||
import { addCollider, createContainer, removeObjects } from '../actions';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
|
||||
/**
|
||||
* Objects placed in the active scene as a tree (children indented below their parent).
|
||||
* Objects of the scene or container being edited, as a tree (children indented below their parent).
|
||||
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
|
||||
* another to parent it there; drop it on the empty space below the rows to move it to the
|
||||
* top level. The object keeps its world position either way.
|
||||
@@ -22,24 +22,31 @@ export class HierarchyPanel extends Panel {
|
||||
private readonly scroll = new ScrollContainer();
|
||||
/** Ids of the objects whose children are shown. Anything missing is collapsed. */
|
||||
private readonly expanded = new Set<string>();
|
||||
/**
|
||||
* Which objects are listed. A function, not an id, so a container renamed or swapped under the
|
||||
* panel needs no notification; the Container tab points this at the template it edits.
|
||||
*/
|
||||
private source: () => ObjectHolder | undefined = () => this.state.activeScene;
|
||||
/** Id of the holder the rows were built from, to notice when the source starts naming another. */
|
||||
private shownHolderId = '';
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ title: 'Scene objects', padding: 0, gap: 0 });
|
||||
this.minWidth = 160;
|
||||
this.list.emptyText = 'Place assets here';
|
||||
this.list.onSelect = (id, modifiers) => {
|
||||
const scene = state.activeScene;
|
||||
if (!scene) return;
|
||||
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(scene.id, id);
|
||||
else state.select(id ? objectSelection(scene.id, [id]) : null);
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id);
|
||||
else state.select(id ? objectSelection(holder.id, [id]) : null);
|
||||
};
|
||||
this.list.onDrop = (draggedId, targetId) => {
|
||||
const scene = state.activeScene;
|
||||
const object = scene && findObject(scene, draggedId);
|
||||
if (!scene || !object || object.parentId === (targetId ?? '')) return;
|
||||
const holder = this.holder();
|
||||
const object = holder && findObject(holder, draggedId);
|
||||
if (!holder || !object || object.parentId === (targetId ?? '')) return;
|
||||
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
|
||||
if (targetId) this.expanded.add(targetId);
|
||||
state.execute(new SetParentCommand(scene.id, draggedId, targetId ?? ''));
|
||||
state.execute(new SetParentCommand(holder.id, draggedId, targetId ?? ''));
|
||||
};
|
||||
this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
|
||||
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
|
||||
@@ -53,14 +60,33 @@ export class HierarchyPanel extends Panel {
|
||||
this.expanded.clear();
|
||||
this.refresh();
|
||||
});
|
||||
state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection()));
|
||||
state.on('selection', () => {
|
||||
// The Container tab's picker only changes the selection, which swaps the holder under this
|
||||
// panel; without the id check the title and rows would keep naming the previous one.
|
||||
const swapped = (this.holder()?.id ?? '') !== this.shownHolderId;
|
||||
if (this.revealSelection() || swapped) this.refresh();
|
||||
else this.syncSelection();
|
||||
});
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists another holder's objects – the Container tab hands in its template, the Scene tab the
|
||||
* active scene again.
|
||||
*/
|
||||
showObjectsOf(source: () => ObjectHolder | undefined): void {
|
||||
this.source = source;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private holder(): ObjectHolder | undefined {
|
||||
return this.source();
|
||||
}
|
||||
|
||||
/** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
|
||||
rowOrder(): string[] {
|
||||
const scene = this.state.activeScene;
|
||||
return scene ? visibleTreeOrder(scene, this.expanded).map((e) => e.object.id) : [];
|
||||
const holder = this.holder();
|
||||
return holder ? visibleTreeOrder(holder, this.expanded).map((e) => e.object.id) : [];
|
||||
}
|
||||
|
||||
/** Global centre of a row's chevron, so tests can click the real hit area (test hook). */
|
||||
@@ -69,8 +95,7 @@ export class HierarchyPanel extends Panel {
|
||||
}
|
||||
|
||||
expandAll(): void {
|
||||
const scene = this.state.activeScene;
|
||||
for (const object of scene?.objects ?? []) this.expanded.add(object.id);
|
||||
for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -81,16 +106,17 @@ export class HierarchyPanel extends Panel {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private openContextMenu(id: string, x: number, y: number): void {
|
||||
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
|
||||
const { state } = this;
|
||||
const scene = state.activeScene;
|
||||
if (!scene) return;
|
||||
const object = findObject(scene, id);
|
||||
const holder = this.holder();
|
||||
if (!holder) return undefined;
|
||||
const inScene = holder.type === 'scene';
|
||||
const object = findObject(holder, id);
|
||||
const type = object && findAsset(state.project, object.assetId)?.type;
|
||||
// Inside a multi-selection the menu acts on all selected objects.
|
||||
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
|
||||
const many = selected.length > 1;
|
||||
new Menu([
|
||||
const menu = new Menu([
|
||||
{
|
||||
label: 'Select asset',
|
||||
onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }),
|
||||
@@ -98,25 +124,29 @@ export class HierarchyPanel extends Panel {
|
||||
{
|
||||
label: 'Add collider',
|
||||
disabled: !type || type === 'collider' || many,
|
||||
onSelect: () => addCollider(state, scene.id, id),
|
||||
onSelect: () => addCollider(state, holder.id, id),
|
||||
},
|
||||
{
|
||||
label: many ? `Create container from ${selected.length} objects` : 'Create container',
|
||||
onSelect: () => createContainer(state, scene.id, selected),
|
||||
onSelect: () => createContainer(state, holder.id, selected),
|
||||
},
|
||||
{
|
||||
label: 'Unparent (move to top level)',
|
||||
disabled: !object?.parentId || many,
|
||||
onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')),
|
||||
onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')),
|
||||
},
|
||||
{ label: 'Expand all', onSelect: () => this.expandAll() },
|
||||
{ label: 'Collapse all', onSelect: () => this.collapseAll() },
|
||||
{
|
||||
label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene',
|
||||
label: many
|
||||
? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}`
|
||||
: `Remove from ${inScene ? 'scene' : 'container'}`,
|
||||
danger: true,
|
||||
onSelect: () => removeObjects(state, scene.id, selected),
|
||||
onSelect: () => removeObjects(state, holder.id, selected),
|
||||
},
|
||||
]).open(x, y);
|
||||
]);
|
||||
menu.open(x, y);
|
||||
return menu;
|
||||
}
|
||||
|
||||
private setExpanded(id: string, expand: boolean): void {
|
||||
@@ -127,11 +157,11 @@ export class HierarchyPanel extends Panel {
|
||||
|
||||
/** Unfolds the ancestors of every selected object. Returns whether that changed anything. */
|
||||
private revealSelection(): boolean {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return false;
|
||||
const holder = this.holder();
|
||||
if (!holder) return false;
|
||||
let changed = false;
|
||||
for (const id of this.state.selectedObjectIds) {
|
||||
for (const ancestorId of ancestorIdsOf(scene, id)) {
|
||||
for (const ancestorId of ancestorIdsOf(holder, id)) {
|
||||
if (this.expanded.has(ancestorId)) continue;
|
||||
this.expanded.add(ancestorId);
|
||||
changed = true;
|
||||
@@ -141,10 +171,12 @@ export class HierarchyPanel extends Panel {
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const scene = this.state.activeScene;
|
||||
this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects');
|
||||
const holder = this.holder();
|
||||
this.shownHolderId = holder?.id ?? '';
|
||||
const kind = holder?.type === 'container' ? 'Container' : 'Scene';
|
||||
this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects');
|
||||
this.list.setItems(
|
||||
(scene ? visibleTreeOrder(scene, this.expanded) : []).map(({ object, depth, hasChildren }) => {
|
||||
(holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => {
|
||||
const asset = findAsset(this.state.project, object.assetId);
|
||||
const props = asset ? resolveSceneObjectProps(asset, object) : {};
|
||||
return {
|
||||
|
||||
@@ -32,12 +32,12 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
|
||||
}
|
||||
if (mod && key === 'g') {
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'object') createContainer(state, s.sceneId, s.objectIds);
|
||||
if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds);
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'object') removeObjects(state, s.sceneId, s.objectIds);
|
||||
if (s?.kind === 'object') removeObjects(state, s.holderId, s.objectIds);
|
||||
else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
|
||||
else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
|
||||
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
|
||||
|
||||
+45
-19
@@ -1,17 +1,24 @@
|
||||
import { type Command, CommandError } from '../core/commands/Command';
|
||||
import { CommandStack } from '../core/commands/CommandStack';
|
||||
import type { SceneAsset } from '../core/model/Asset';
|
||||
import type { Asset, SceneAsset } from '../core/model/Asset';
|
||||
import { findObject } from '../core/model/hierarchy';
|
||||
import { createEmptyProject, findAsset, findScene, type Project } from '../core/model/Project';
|
||||
import {
|
||||
createEmptyProject,
|
||||
findAsset,
|
||||
findObjectHolder,
|
||||
findScene,
|
||||
type Project,
|
||||
} from '../core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../core/store/serialize';
|
||||
|
||||
export type Selection =
|
||||
| { kind: 'asset'; assetId: string }
|
||||
/**
|
||||
* One or more objects of a scene. `objectId` is the primary one (the last picked – the
|
||||
* inspector shows it); `objectIds` holds the whole selection, primary included.
|
||||
* One or more objects of a scene or of a container's template (`holderId` names which).
|
||||
* `objectId` is the primary one (the last picked – the inspector shows it); `objectIds`
|
||||
* holds the whole selection, primary included.
|
||||
*/
|
||||
| { kind: 'object'; sceneId: string; objectId: string; objectIds: string[] }
|
||||
| { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
|
||||
/** A node inside a behavior's graph. */
|
||||
| { kind: 'node'; assetId: string; nodeId: string }
|
||||
/** A frame rectangle inside a texture asset (the Texture tab). */
|
||||
@@ -19,11 +26,11 @@ export type Selection =
|
||||
| null;
|
||||
|
||||
/** Builds an object selection; the last id is the primary one unless given explicitly. */
|
||||
export function objectSelection(sceneId: string, objectIds: readonly string[], primary?: string): Selection {
|
||||
export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
|
||||
const ids = [...new Set(objectIds)];
|
||||
if (ids.length === 0) return null;
|
||||
const objectId = primary && ids.includes(primary) ? primary : ids[ids.length - 1]!;
|
||||
return { kind: 'object', sceneId, objectId, objectIds: ids };
|
||||
return { kind: 'object', holderId, objectId, objectIds: ids };
|
||||
}
|
||||
|
||||
export type EditorMode = 'edit' | 'play';
|
||||
@@ -194,23 +201,23 @@ export class EditorState {
|
||||
// --- selection -----------------------------------------------------------------------------
|
||||
|
||||
select(selection: Selection): void {
|
||||
// Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`.
|
||||
// Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (selection?.kind === 'object' && !selection.objectIds)
|
||||
selection = objectSelection(selection.sceneId, [selection.objectId]);
|
||||
selection = objectSelection(selection.holderId, [selection.objectId]);
|
||||
if (sameSelection(this.selection, selection)) return;
|
||||
this.selection = selection;
|
||||
this.emit('selection');
|
||||
}
|
||||
|
||||
/** Ctrl/Shift-click: adds the object to the selection or removes it again. */
|
||||
toggleObject(sceneId: string, objectId: string): void {
|
||||
toggleObject(holderId: string, objectId: string): void {
|
||||
const s = this.selection;
|
||||
const current = s?.kind === 'object' && s.sceneId === sceneId ? s.objectIds : [];
|
||||
const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
|
||||
const next = current.includes(objectId)
|
||||
? current.filter((id) => id !== objectId)
|
||||
: [...current, objectId];
|
||||
this.select(objectSelection(sceneId, next, current.includes(objectId) ? undefined : objectId));
|
||||
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
|
||||
}
|
||||
|
||||
/** Ids of the selected scene objects (empty unless objects are selected). */
|
||||
@@ -224,9 +231,9 @@ export class EditorState {
|
||||
if (!s) return;
|
||||
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
|
||||
if (s.kind === 'object') {
|
||||
const scene = findScene(this.project, s.sceneId);
|
||||
const alive = scene ? s.objectIds.filter((id) => findObject(scene, id)) : [];
|
||||
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.sceneId, alive, s.objectId));
|
||||
const holder = findObjectHolder(this.project, s.holderId);
|
||||
const alive = holder ? s.objectIds.filter((id) => findObject(holder, id)) : [];
|
||||
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
|
||||
}
|
||||
if (s.kind === 'node') {
|
||||
const asset = findAsset(this.project, s.assetId);
|
||||
@@ -259,12 +266,31 @@ export class EditorState {
|
||||
if (!s) return null;
|
||||
if (s.kind === 'node') return s.assetId;
|
||||
if (s.kind === 'frame') return null;
|
||||
const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined;
|
||||
const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId;
|
||||
const asset = assetId ? findAsset(this.project, assetId) : undefined;
|
||||
const asset = this.assetBehind(s);
|
||||
return asset && asset.type === 'behavior' ? asset.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The container the current selection refers to: the asset itself, a placed container, or the
|
||||
* template being edited – selecting one of its objects keeps the Container tab on it.
|
||||
*/
|
||||
get selectedContainerId(): string | null {
|
||||
const s = this.selection;
|
||||
if (!s) return null;
|
||||
if (s.kind === 'object' && findAsset(this.project, s.holderId)?.type === 'container') return s.holderId;
|
||||
const asset = this.assetBehind(s);
|
||||
return asset && asset.type === 'container' ? asset.id : null;
|
||||
}
|
||||
|
||||
/** The asset a selection stands for: itself, or the one a selected object was placed from. */
|
||||
private assetBehind(s: NonNullable<Selection>): Asset | undefined {
|
||||
if (s.kind === 'asset') return findAsset(this.project, s.assetId);
|
||||
if (s.kind !== 'object') return undefined;
|
||||
const holder = findObjectHolder(this.project, s.holderId);
|
||||
const assetId = holder && findObject(holder, s.objectId)?.assetId;
|
||||
return assetId ? findAsset(this.project, assetId) : undefined;
|
||||
}
|
||||
|
||||
// --- status --------------------------------------------------------------------------------
|
||||
|
||||
setSaveStatus(status: SaveStatus): void {
|
||||
@@ -286,7 +312,7 @@ export function sameSelection(a: Selection, b: Selection): boolean {
|
||||
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
|
||||
if (a.kind === 'object' && b.kind === 'object') {
|
||||
return (
|
||||
a.sceneId === b.sceneId &&
|
||||
a.holderId === b.holderId &&
|
||||
a.objectId === b.objectId &&
|
||||
a.objectIds.length === b.objectIds.length &&
|
||||
a.objectIds.every((id, i) => id === b.objectIds[i])
|
||||
|
||||
@@ -3,6 +3,7 @@ import { centreY } from '../math';
|
||||
import { Theme, textStyle } from '../Theme';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { ListRow } from './ListRow';
|
||||
import type { Popup } from './Popup';
|
||||
|
||||
export interface ListItem {
|
||||
id: string;
|
||||
@@ -46,9 +47,12 @@ export class ListView extends Widget {
|
||||
private rows: ListRow[] = [];
|
||||
private selectedId: string | null = null;
|
||||
private highlighted = new Set<string>();
|
||||
/** Row an open context menu acts on – drawn like a selected row, but only until it closes. */
|
||||
private contextId: string | null = null;
|
||||
onSelect?: (id: string | null, modifiers?: PickModifiers) => void;
|
||||
onActivate?: (id: string) => void;
|
||||
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
|
||||
/** Returning the opened menu keeps its row marked for as long as it is up. */
|
||||
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
|
||||
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
|
||||
onDrop?: (draggedId: string, targetId: string | null) => void;
|
||||
/**
|
||||
@@ -94,6 +98,7 @@ export class ListView extends Widget {
|
||||
});
|
||||
this.addChild(this.dropMarker); // keep the marker above the rows
|
||||
if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null;
|
||||
if (this.contextId && !items.some((i) => i.id === this.contextId)) this.contextId = null;
|
||||
this.layout();
|
||||
this.redraw();
|
||||
}
|
||||
@@ -106,6 +111,12 @@ export class ListView extends Widget {
|
||||
if (!silent) this.onSelect?.(id);
|
||||
}
|
||||
|
||||
private setContext(id: string | null): void {
|
||||
if (id === this.contextId) return;
|
||||
this.contextId = id;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Rows drawn as selected in addition to the selected one (the owner's multi-selection). */
|
||||
highlight(ids: Iterable<string>): void {
|
||||
this.highlighted = new Set(ids);
|
||||
@@ -153,7 +164,11 @@ export class ListView extends Widget {
|
||||
|
||||
override redraw(): void {
|
||||
for (const row of this.rows) {
|
||||
row.setSelected(row.item.id === this.selectedId || this.highlighted.has(row.item.id), this.focused);
|
||||
const id = row.item.id;
|
||||
row.setSelected(
|
||||
id === this.selectedId || id === this.contextId || this.highlighted.has(id),
|
||||
this.focused,
|
||||
);
|
||||
}
|
||||
this.emptyLabel.text = this.items.length === 0 ? this.emptyText : '';
|
||||
this.emptyLabel.position.set(Theme.padding, centreY(this.rowHeight, this.emptyLabel.height));
|
||||
@@ -169,9 +184,11 @@ export class ListView extends Widget {
|
||||
if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) {
|
||||
this.onToggle?.(item.id, !item.expanded);
|
||||
} else if (e.button === 2) {
|
||||
// A right-click inside a multi-selection keeps it (the menu acts on all of it).
|
||||
if (!this.highlighted.has(item.id)) this.select(item.id);
|
||||
this.onContextMenu?.(item.id, e.global.x, e.global.y);
|
||||
// Marked, not selected: selecting also loads the row into the editor, which would swap the
|
||||
// very tab out from under a menu opened to place something into it.
|
||||
const menu = this.onContextMenu?.(item.id, e.global.x, e.global.y);
|
||||
this.setContext(menu ? item.id : null);
|
||||
if (menu) menu.onClose = () => this.setContext(null);
|
||||
} else if (modifiers.ctrl || modifiers.shift) {
|
||||
this.onSelect?.(item.id, modifiers);
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The editable view of a scene. It shows `state.project`, which in play mode is the runtime
|
||||
* copy, so live positions are visible and stay editable.
|
||||
* copy, so live positions are visible and stay editable. A container's template is the same
|
||||
* work on the same shape of data, so the Container tab subclasses this and swaps `holder()`.
|
||||
*/
|
||||
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import { CompoundCommand } from '../core/commands/CompoundCommand';
|
||||
@@ -8,10 +9,11 @@ import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { findObject, subtreeRoots } from '../core/model/hierarchy';
|
||||
import type { Position, Vec2 } from '../core/model/Properties';
|
||||
import type { ObjectHolder } from '../core/model/Project';
|
||||
import { footprintOf } from '../core/properties/bounds';
|
||||
import { type EditorState, objectSelection } from '../editor/state';
|
||||
import { clamp, drawGrid, type Size, Theme, textStyle, zoomFactor } from '../ui';
|
||||
import { collectSceneItems, type DrawItem } from './items';
|
||||
import { collectHolderItems, type DrawItem } from './items';
|
||||
import { dashedLine } from './symbols';
|
||||
import { freeView, screenToWorld, type View, worldToScreen } from './view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
@@ -24,9 +26,11 @@ const GRID_STEP = 100;
|
||||
const MIN_GRID_STEP = 12;
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
/** Breathing room left around the content by `fit()`, in screen pixels. */
|
||||
const FIT_MARGIN = 40;
|
||||
|
||||
interface ObjectDrag {
|
||||
sceneId: string;
|
||||
holderId: string;
|
||||
items: { objectId: string; start: Position }[];
|
||||
pointer: Vec2;
|
||||
moved: boolean;
|
||||
@@ -70,11 +74,47 @@ export class SceneViewport extends WorldViewport {
|
||||
});
|
||||
}
|
||||
|
||||
/** What this view shows and edits; the Container tab returns its container asset instead. */
|
||||
protected holder(): ObjectHolder | undefined {
|
||||
return this.state.activeScene;
|
||||
}
|
||||
|
||||
/** World coordinates at the centre of the view. */
|
||||
worldCenter(): Vec2 {
|
||||
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Frames everything the holder contains. The origin is always included: it is where a container
|
||||
* is grabbed when placed, so a template drawn far off to one side would otherwise hide it.
|
||||
*/
|
||||
fit(): void {
|
||||
const { items } = this.collectFrame();
|
||||
let minX = 0;
|
||||
let minY = 0;
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
for (const item of items) {
|
||||
const { w, h } = footprintOf(item.type, item.props);
|
||||
minX = Math.min(minX, item.x - w / 2);
|
||||
maxX = Math.max(maxX, item.x + w / 2);
|
||||
minY = Math.min(minY, item.y - h / 2);
|
||||
maxY = Math.max(maxY, item.y + h / 2);
|
||||
}
|
||||
const width = Math.max(maxX - minX, 1);
|
||||
const height = Math.max(maxY - minY, 1);
|
||||
this.view = {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
zoom: clamp(
|
||||
Math.min((this.w - FIT_MARGIN * 2) / width, (this.h - FIT_MARGIN * 2) / height),
|
||||
MIN_ZOOM,
|
||||
MAX_ZOOM,
|
||||
),
|
||||
};
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
// --- the view -------------------------------------------------------------------------------
|
||||
|
||||
protected override currentView(): View {
|
||||
@@ -82,7 +122,7 @@ export class SceneViewport extends WorldViewport {
|
||||
}
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
return collectSceneItems(this.state, Theme.colors.viewport);
|
||||
return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport);
|
||||
}
|
||||
|
||||
protected override get baseColor(): number {
|
||||
@@ -91,7 +131,7 @@ export class SceneViewport extends WorldViewport {
|
||||
|
||||
/** Without a scene there is nothing to frame: the neutral base colour stands alone. */
|
||||
protected override paintsBackground(): boolean {
|
||||
return Boolean(this.state.activeScene);
|
||||
return Boolean(this.holder());
|
||||
}
|
||||
|
||||
protected override get itemsInteractive(): boolean {
|
||||
@@ -129,11 +169,11 @@ export class SceneViewport extends WorldViewport {
|
||||
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
|
||||
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
this.dragging.moved = true;
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
|
||||
for (const item of this.dragging.items) {
|
||||
const object = findObject(scene, item.objectId);
|
||||
const object = findObject(holder, item.objectId);
|
||||
if (object)
|
||||
object.position = {
|
||||
x: Math.round(item.start.x + dx),
|
||||
@@ -167,16 +207,16 @@ export class SceneViewport extends WorldViewport {
|
||||
const drag = this.dragging;
|
||||
this.dragging = null;
|
||||
if (!drag || !drag.moved) return;
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
// The preview moved the objects already; put them back so the command records the start.
|
||||
const moves: MoveSceneObjectCommand[] = [];
|
||||
for (const item of drag.items) {
|
||||
const object = findObject(scene, item.objectId);
|
||||
const object = findObject(holder, item.objectId);
|
||||
if (!object) continue;
|
||||
const next = { ...object.position };
|
||||
object.position = { ...item.start };
|
||||
moves.push(new MoveSceneObjectCommand(drag.sceneId, item.objectId, next));
|
||||
moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
|
||||
}
|
||||
if (moves.length === 1) this.state.execute(moves[0]!);
|
||||
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
|
||||
@@ -195,30 +235,30 @@ export class SceneViewport extends WorldViewport {
|
||||
|
||||
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
const scene = this.state.activeScene;
|
||||
const object = scene && findObject(scene, objectId);
|
||||
if (!scene || !object) return;
|
||||
const holder = this.holder();
|
||||
const object = holder && findObject(holder, objectId);
|
||||
if (!holder || !object) return;
|
||||
e.stopPropagation();
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
||||
this.state.toggleObject(scene.id, objectId);
|
||||
this.state.toggleObject(holder.id, objectId);
|
||||
return;
|
||||
}
|
||||
// Clicking a selected object keeps the selection so the whole group can be dragged.
|
||||
if (!this.state.selectedObjectIds.includes(objectId))
|
||||
this.state.select(objectSelection(scene.id, [objectId]));
|
||||
this.state.select(objectSelection(holder.id, [objectId]));
|
||||
// Only the tops of the selected subtrees move; descendants follow through the hierarchy.
|
||||
const items = subtreeRoots(scene, this.state.selectedObjectIds)
|
||||
.map((id) => findObject(scene, id))
|
||||
const items = subtreeRoots(holder, this.state.selectedObjectIds)
|
||||
.map((id) => findObject(holder, id))
|
||||
.filter((o): o is NonNullable<typeof o> => Boolean(o))
|
||||
.map((o) => ({ objectId: o.id, start: { ...o.position } }));
|
||||
const local = this.toLocal(e.global);
|
||||
this.dragging = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||||
this.dragging = { holderId: holder.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||||
}
|
||||
|
||||
/** Selects every object whose position – not its bounds – lies inside the band. */
|
||||
private selectInMarquee(marquee: Marquee): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
const view = this.currentView();
|
||||
const a = screenToWorld(view, marquee.start.x, marquee.start.y);
|
||||
const b = screenToWorld(view, marquee.current.x, marquee.current.y);
|
||||
@@ -229,7 +269,7 @@ export class SceneViewport extends WorldViewport {
|
||||
const ids = this.lastItems
|
||||
.filter((i) => i.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY)
|
||||
.map((i) => i.id);
|
||||
this.state.select(objectSelection(scene.id, ids));
|
||||
this.state.select(objectSelection(holder.id, ids));
|
||||
}
|
||||
|
||||
// --- decorations ----------------------------------------------------------------------------
|
||||
|
||||
+16
-11
@@ -2,11 +2,10 @@ import { isHandleEnabled, worldPositionOf } from '../core/graph';
|
||||
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
|
||||
import { editorPosition, treeOrder } from '../core/model/hierarchy';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import { findAsset } from '../core/model/Project';
|
||||
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
|
||||
import { resolveTexture, type TextureRef } from '../core/model/texture';
|
||||
import { enabledAlongChain } from '../core/properties/enabled';
|
||||
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||||
import type { EditorState } from '../editor/state';
|
||||
import type { World } from '../runtime/World';
|
||||
|
||||
/** What the viewport draws: the same shape whether it comes from the model or the world. */
|
||||
@@ -32,24 +31,30 @@ interface DrawList {
|
||||
background: number;
|
||||
}
|
||||
|
||||
/** The model's scene, in tree order – the tie-break for equal `z` – at the editor positions. */
|
||||
export function collectSceneItems(state: EditorState, fallbackBackground: number): DrawList {
|
||||
const scene = state.activeScene;
|
||||
if (!scene) return { items: [], background: fallbackBackground };
|
||||
/**
|
||||
* A scene or a container template, in tree order – the tie-break for equal `z` – at the editor
|
||||
* positions. A container has no background of its own, so it keeps the fallback.
|
||||
*/
|
||||
export function collectHolderItems(
|
||||
project: Project,
|
||||
holder: ObjectHolder | undefined,
|
||||
fallbackBackground: number,
|
||||
): DrawList {
|
||||
if (!holder) return { items: [], background: fallbackBackground };
|
||||
const items: DrawItem[] = [];
|
||||
for (const { object } of treeOrder(scene)) {
|
||||
const asset = findAsset(state.project, object.assetId);
|
||||
for (const { object } of treeOrder(holder)) {
|
||||
const asset = findAsset(project, object.assetId);
|
||||
if (!asset) continue;
|
||||
const props = resolveSceneObjectProps(asset, object);
|
||||
items.push({
|
||||
id: object.id,
|
||||
type: asset.type,
|
||||
name: object.name || asset.name,
|
||||
...editorPosition(scene, object),
|
||||
...editorPosition(holder, object),
|
||||
props,
|
||||
parentId: object.parentId,
|
||||
enabled: true,
|
||||
texture: asset.type === 'sprite' ? resolveTexture(state.project, props) : null,
|
||||
texture: asset.type === 'sprite' ? resolveTexture(project, props) : null,
|
||||
});
|
||||
}
|
||||
const byId = new Map(items.map((i) => [i.id, i]));
|
||||
@@ -59,7 +64,7 @@ export function collectSceneItems(state: EditorState, fallbackBackground: number
|
||||
(i) => i.props,
|
||||
(i) => byId.get(i.parentId) ?? null,
|
||||
);
|
||||
return { items, background: Number(scene.props.backgroundColor ?? fallbackBackground) };
|
||||
return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) };
|
||||
}
|
||||
|
||||
/** The running world: scene content only, since symbols are editor-only. */
|
||||
|
||||
Reference in new issue
Block a user