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:
rootandClaude Opus 5 committed 2026-09-08 20:19:54 +02:00
1 parent 6bedc948be
commit 9d93020bd8
35 files changed
+1452 -363

No files matched your search

+34 -61
View File
@@ -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 };
}
}
+7 -7
View File
@@ -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);
}
}
+18 -18
View File
@@ -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);
}
+9 -3
View File
@@ -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;
}
+89 -40
View File
@@ -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 };
}
+89
View File
@@ -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);
}
+19 -1
View File
@@ -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 {
+21
View File
@@ -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
View File
@@ -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();
}
+8 -8
View File
@@ -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. */
+65
View File
@@ -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 });
}
}
+124
View File
@@ -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);
}
}
+9 -1
View File
@@ -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(),
+4 -4
View File
@@ -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();
}
}
+73 -73
View File
@@ -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;
}
+25 -12
View File
@@ -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;
}
}
+64 -32
View File
@@ -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 {
+2 -2
View File
@@ -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
View File
@@ -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])
+22 -5
View File
@@ -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 {
+63 -23
View File
@@ -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
View File
@@ -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. */