Resolve container content from the template instead of copying it

A scene now stores only the container object; its content is resolved from
the template on every read, with deviations kept as sparse instanceEdits on
the container object. This drops the propagation bookkeeping entirely --
templateSync.ts is gone and the three structural gaps (add, remove, reparent)
cannot diverge any more.

Values stay editable on an instance, structure does not; Unpack writes the
content into the scene, keeping ids. Play mode bakes containers into the
throwaway copy before entities are built. No migration: formatVersion is 2
and the seeders write the new format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 21:09:53 +02:00
1 parent 9d93020bd8
commit ead649eec3
37 files changed
+1878 -1006

No files matched your search

+80 -87
View File
@@ -1,62 +1,40 @@
/**
* "Apply" is the opposite direction of an override: a value changed on a placed object is written
* 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.
* back to the asset, or to the template of the container it belongs to. Reverting drops the
* instance's deviation again, which is all it takes for the value to follow the template – the
* content of a placed container is resolved from it, never copied (see `model/instance.ts`).
*/
import { type TemplateLink, templateLinkOf } from '../model/container';
import {
captureObject,
type InstanceTarget,
instanceTargetOf,
type ObjectMemo,
restoreObject,
} from '../model/instance';
import { type Axis, type Position, type PropertyValue, setBagValue } from '../model/Properties';
import { findObjectHolder, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import type { Project } from '../model/Project';
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';
import { requireAsset, requireHolder, requireObjectRef, requirePropDef } from './require';
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;
const ALL_AXES: readonly Axis[] = ['x', 'y', 'z'];
function requireTarget(project: Project, holderId: string, objectId: string): InstanceTarget {
const target = instanceTargetOf(project, requireHolder(project, holderId), objectId);
if (!target) throw new CommandError('That object is not part of a placed container');
return target;
}
/**
* 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.
*/
function applyToTemplate<T>(
project: Project,
link: TemplateLink,
slots: readonly Slot<T>[],
values: readonly T[],
): SlotMemo<T>[] {
const before = slots.map((slot) => slot.read(link.template));
const memos: SlotMemo<T>[] = slots.map((slot, i) => ({
object: link.template,
slot,
previous: before[i]!,
}));
memos.push(...syncInstances(project, link.template, slots, before, values));
slots.forEach((slot, i) => slot.write(link.template, values[i]!));
return memos;
}
/**
* 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.
* Writes the object's value for `key` into its asset and drops the override. For content of a
* placed container the template's override goes too, so every instance follows the asset again.
*/
export class ApplyOverrideToAssetCommand implements Command {
readonly label: string;
private previousAssetValue: PropertyValue | undefined;
private previousOverride: PropertyValue | undefined;
private templateMemos: SlotMemo<PropertyValue | undefined>[] = [];
private previousTemplateValue: PropertyValue | undefined;
private memo: ObjectMemo | undefined;
private template: InstanceTarget | undefined;
constructor(
private readonly holderId: string,
@@ -67,31 +45,36 @@ export class ApplyOverrideToAssetCommand implements Command {
}
execute(project: Project): void {
const holder = findObjectHolder(project, this.holderId);
const object = requireObject(project, this.holderId, this.objectId);
const asset = requireAsset(project, object.assetId);
const ref = requireObjectRef(project, this.holderId, this.objectId);
const asset = requireAsset(project, ref.object.assetId);
requirePropDef(asset, this.key);
const value = resolveSceneObjectProps(asset, object)[this.key];
const value = resolveSceneObjectProps(asset, ref.object)[this.key];
this.previousAssetValue = asset.props[this.key];
this.previousOverride = object.overrides[this.key];
this.memo = captureObject(ref);
asset.props[this.key] = value!;
delete object.overrides[this.key];
const link = holder && templateLinkOf(project, holder, object);
this.templateMemos = link ? applyToTemplate(project, link, [overrideSlot(this.key)], [undefined]) : [];
ref.setOverride(this.key, undefined);
this.template = ref.target;
if (!this.template) return;
this.previousTemplateValue = this.template.template.overrides[this.key];
delete this.template.template.overrides[this.key];
}
undo(project: Project): void {
const object = requireObject(project, this.holderId, this.objectId);
const asset = requireAsset(project, object.assetId);
restoreSlots(this.templateMemos);
setBagValue(object.overrides, this.key, this.previousOverride);
const asset = requireAsset(
project,
requireObjectRef(project, this.holderId, this.objectId).object.assetId,
);
if (this.template) setBagValue(this.template.template.overrides, this.key, this.previousTemplateValue);
if (this.memo) restoreObject(this.memo);
setBagValue(asset.props, this.key, this.previousAssetValue);
}
}
export class ApplyOverrideToContainerCommand implements Command {
readonly label: string;
private memos: SlotMemo<PropertyValue | undefined>[] = [];
private previous: PropertyValue | undefined;
private memo: ObjectMemo | undefined;
private target: InstanceTarget | undefined;
constructor(
private readonly holderId: string,
@@ -102,19 +85,25 @@ export class ApplyOverrideToContainerCommand implements Command {
}
execute(project: Project): void {
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]]);
const target = requireTarget(project, this.holderId, this.objectId);
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.target = target;
this.memo = captureObject(ref);
this.previous = target.template.overrides[this.key];
setBagValue(target.template.overrides, this.key, ref.object.overrides[this.key]);
// The deviation is gone the moment the template carries it, so the edit must go with it.
ref.setOverride(this.key, target.template.overrides[this.key]);
}
undo(): void {
restoreSlots(this.memos);
if (this.target) setBagValue(this.target.template.overrides, this.key, this.previous);
if (this.memo) restoreObject(this.memo);
}
}
export class RevertOverrideToTemplateCommand implements Command {
readonly label: string;
private previous: PropertyValue | undefined;
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -125,21 +114,23 @@ export class RevertOverrideToTemplateCommand implements Command {
}
execute(project: Project): void {
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]);
const target = requireTarget(project, this.holderId, this.objectId);
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setOverride(this.key, target.template.overrides[this.key]);
}
undo(project: Project): void {
setBagValue(requireObject(project, this.holderId, this.objectId).overrides, this.key, this.previous);
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
/** Writes the object's local position – one axis, or all – into its container template. */
export class ApplyPositionToContainerCommand implements Command {
readonly label: string;
private memos: SlotMemo<number>[] = [];
private previous: Position = { x: 0, y: 0, z: 0 };
private memo: ObjectMemo | undefined;
private target: InstanceTarget | undefined;
constructor(
private readonly holderId: string,
@@ -150,25 +141,25 @@ export class ApplyPositionToContainerCommand implements Command {
}
execute(project: Project): void {
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,
link,
axes.map(positionSlot),
axes.map((a) => object.position[a]),
);
const target = requireTarget(project, this.holderId, this.objectId);
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.target = target;
this.memo = captureObject(ref);
this.previous = { ...target.template.position };
for (const axis of this.axis ? [this.axis] : ALL_AXES)
target.template.position[axis] = ref.object.position[axis];
ref.setPosition(ref.object.position);
}
undo(): void {
restoreSlots(this.memos);
if (this.target) this.target.template.position = { ...this.previous };
if (this.memo) restoreObject(this.memo);
}
}
export class RevertPositionToTemplateCommand implements Command {
readonly label: string;
private previous: Position = { x: 0, y: 0, z: 0 };
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -179,13 +170,15 @@ export class RevertPositionToTemplateCommand implements Command {
}
execute(project: Project): void {
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];
const target = requireTarget(project, this.holderId, this.objectId);
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
const position = { ...ref.object.position };
for (const axis of this.axis ? [this.axis] : ALL_AXES) position[axis] = target.template.position[axis];
ref.setPosition(position);
}
undo(project: Project): void {
requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
+5 -1
View File
@@ -1,4 +1,5 @@
import { newId } from '../ids';
import { isLiveInstance } from '../model/instance';
import type { Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { resolveSceneObjectProps } from '../properties/resolve';
@@ -6,7 +7,7 @@ import { removeById } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
import { requireAsset, requireObject, requireHolder } from './require';
import { requireAsset, requireObject, requireHolder, requireStructural } from './require';
/**
* Creates a collider asset sized like the object (sprites: width × height; anything else: schema
@@ -24,9 +25,12 @@ export class AddColliderCommand implements Command {
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
requireStructural(project, holder, this.targetId);
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');
if (isLiveInstance(project, holder, target))
throw new CommandError('A placed container shows its template; add the collider inside it');
const asset = buildAsset(
'collider',
+60 -33
View File
@@ -3,31 +3,36 @@ import type { ContainerAsset } from '../model/Asset';
import {
findObject,
localPositionFor,
removeSubtrees,
subtreeIds,
subtreeRoots,
treeOrder,
worldPosition,
} from '../model/hierarchy';
import { type InstanceEdit, isLiveInstance, materializeInstance } from '../model/instance';
import type { Position } from '../model/Properties';
import { findObjectHolder, type Project } from '../model/Project';
import { createSceneObject } from '../model/SceneObject';
import { removeById } from '../util/list';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { removeById, type Removed, restoreAt } from '../util/list';
import { buildAsset } from './assetCommands';
import { type Command, CommandError } from './Command';
import { nextAssetName } from './naming';
import { requireObject, requireHolder } from './require';
import { requireObject, requireHolder, requireStructural } from './require';
/**
* Turns the selected objects, with everything parented below them, into a container asset placed
* in their stead. The origin is the centre of the selection's bounding box (z = 0), so the template
* is centred around (0, 0, 0); the objects keep their ids and world positions. The container goes
* below the roots' shared parent, or at the top level when they do not share one.
* is centred around (0, 0, 0). The objects themselves leave the holder – the placed container
* shows them again, resolved from the template. It goes below the roots' shared parent, or at the
* top level when they do not share one.
*/
export class CreateContainerCommand implements Command {
readonly label = 'Create container';
readonly assetId = newId();
readonly objectId = newId();
private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
private removed: Removed<SceneObject>[] = [];
/** Kept across a redo: the ids of the resolved content are built from them. */
private readonly templateIds = new Map<string, string>();
constructor(
private readonly holderId: string,
@@ -36,7 +41,9 @@ export class CreateContainerCommand implements Command {
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
const selected = [...new Set(this.objectIds)].map((id) => requireObject(project, this.holderId, id));
const ids = [...new Set(this.objectIds)];
for (const id of ids) requireStructural(project, holder, id);
const selected = ids.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(holder, o));
@@ -65,45 +72,32 @@ export class CreateContainerCommand implements Command {
nextAssetName(project, 'Container'),
this.assetId,
) as ContainerAsset;
const templateIds = new Map(members.map((o) => [o.id, newId()]));
const templateIds = this.templateIds;
for (const object of members) if (!templateIds.has(object.id)) templateIds.set(object.id, newId());
for (const object of members) {
const isRoot = roots.has(object.id);
const world = worldPosition(holder, object);
asset.objects.push({
...object,
id: templateIds.get(object.id)!,
assetId: object.assetId,
name: object.name,
position: isRoot
? { x: world.x - centre.x, y: world.y - centre.y, z: world.z }
: { ...object.position },
// A view-only offset belongs to the placed object, not to the template (see container.ts).
// A view-only offset belongs to the placed object, not to the template.
editorOffset: { x: 0, y: 0 },
overrides: { ...object.overrides },
parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''),
templateId: '',
instanceEdits: { ...object.instanceEdits },
});
}
this.previous = members.map((o) => ({
id: o.id,
parentId: o.parentId,
position: { ...o.position },
templateId: o.templateId,
}));
const container = createSceneObject(
this.objectId,
this.assetId,
localPositionFor(holder, containerParentId, centre),
containerParentId,
);
for (const object of members) {
if (roots.has(object.id)) {
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)!;
}
this.removed = removeSubtrees(holder, [...roots]);
project.assets.push(asset);
holder.objects.push(container);
}
@@ -111,15 +105,48 @@ export class CreateContainerCommand implements Command {
undo(project: Project): void {
const holder = findObjectHolder(project, this.holderId);
if (holder) {
for (const entry of this.previous) {
const object = findObject(holder, entry.id);
if (!object) continue;
object.parentId = entry.parentId;
object.position = { ...entry.position };
object.templateId = entry.templateId;
}
holder.objects = removeById(holder.objects, this.objectId);
restoreAt(holder.objects, this.removed);
}
project.assets = removeById(project.assets, this.assetId);
}
}
/**
* Writes the content a placed container shows into the holder as ordinary objects. It stops
* following the template from then on – which is what makes its structure editable, the one thing
* an instance does not allow.
*/
export class UnpackContainerCommand implements Command {
readonly label = 'Unpack container';
private createdIds: string[] = [];
private edits: Record<string, InstanceEdit> = {};
constructor(
private readonly holderId: string,
private readonly objectId: string,
) {}
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
requireStructural(project, holder, this.objectId);
const object = requireObject(project, this.holderId, this.objectId);
if (!isLiveInstance(project, holder, object))
throw new CommandError(`${object.name || 'That object'} is not a placed container`);
const created = materializeInstance(project, holder, object);
if (created.length === 0) throw new CommandError('The container is empty, there is nothing to unpack');
this.createdIds = created.map((o) => o.id);
this.edits = object.instanceEdits;
object.instanceEdits = {};
holder.objects.push(...created);
}
undo(project: Project): void {
const holder = findObjectHolder(project, this.holderId);
if (!holder) return;
const ids = new Set(this.createdIds);
holder.objects = holder.objects.filter((o) => !ids.has(o.id));
const object = findObject(holder, this.objectId);
if (object) object.instanceEdits = this.edits;
}
}
+25 -1
View File
@@ -2,7 +2,8 @@
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 { findObject, type ObjectTree } from '../model/hierarchy';
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
import { findAsset, findObjectHolder, findScene, type ObjectHolder, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
import { propDef, type PropertyDef } from '../properties/PropertySchema';
@@ -32,6 +33,29 @@ export function requireObject(project: Project, holderId: string, objectId: stri
return object;
}
/**
* The object a value edit works on, whether it is stored in the holder or resolved from a
* container template – the writes then land in the instance's edits.
*/
export function requireObjectRef(project: Project, holderId: string, objectId: string): ObjectRef {
const ref = objectRef(project, requireHolder(project, holderId), objectId);
if (!ref) throw new CommandError(`Scene object ${objectId} not found`);
return ref;
}
/**
* Rejects an object that only exists because a container template says so. Its structure – what
* exists, what it is parented to – is the template's business, not the instance's.
*/
export function requireStructural(project: Project, holder: ObjectTree, objectId: string): void {
if (!isTemplateContent(holder, objectId)) return;
// Not resolvable at all is a plain "not found", which the caller's own lookup words better.
const object = objectRef(project, holder, objectId)?.object;
if (!object) return;
const name = object.name || 'That object';
throw new CommandError(`${name} comes from a container template; open the container to change it`);
}
export function requireBehavior(project: Project, assetId: string): BehaviorAsset {
const asset = findAsset(project, assetId);
if (!asset || asset.type !== 'behavior') throw new CommandError(`Behavior ${assetId} not found`);
+63 -99
View File
@@ -1,41 +1,31 @@
import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes';
import { containerReaches, instantiateContainer } from '../model/container';
import { containerReaches } from '../model/container';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import {
type Position,
type PropertyBag,
type PropertyValue,
toPosition,
type Vec2,
} from '../model/Properties';
import { captureObject, isLiveInstance, type ObjectMemo, restoreObject } from '../model/instance';
import { type Position, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
import type { Project } from '../model/Project';
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, requireHolder } from './require';
import { SetBagValueCommand } from './SetBagValueCommand';
import {
ALL_AXES,
nameSlot,
overrideSlot,
positionSlot,
restoreSlots,
type SlotMemo,
syncTemplateEdit,
} from './templateSync';
requireAsset,
requireObject,
requireObjectRef,
requirePropDef,
requireHolder,
requireStructural,
} from './require';
/**
* The position is a world position; with a parent it is converted to the parent's local space.
* 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.
* Placing a container adds the container object alone – its content is resolved from the template
* (see `model/instance.ts`), so it is never copied into the scene.
*/
export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
readonly objectId = newId();
private createdIds: string[] = [];
constructor(
private readonly holderId: string,
@@ -48,25 +38,19 @@ export class PlaceAssetCommand implements Command {
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 (
holder.type === 'container' &&
asset.type === 'container' &&
containerReaches(project, asset, holder.id)
)
if (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);
if (this.parentId) {
requireObject(project, this.holderId, this.parentId);
requireStructural(project, holder, 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' && holder.type === 'scene')
created.push(...instantiateContainer(project, asset, this.objectId));
this.createdIds = created.map((o) => o.id);
holder.objects.push(...created);
holder.objects.push(createSceneObject(this.objectId, this.assetId, local, this.parentId));
}
undo(project: Project): void {
const holder = requireHolder(project, this.holderId);
const ids = new Set(this.createdIds);
holder.objects = holder.objects.filter((o) => !ids.has(o.id));
holder.objects = holder.objects.filter((o) => o.id !== this.objectId);
}
}
@@ -83,8 +67,12 @@ export class RemoveSceneObjectsCommand implements Command {
}
execute(project: Project): void {
for (const id of this.objectIds) requireObject(project, this.holderId, id);
this.removed = removeSubtrees(requireHolder(project, this.holderId), this.objectIds);
const holder = requireHolder(project, this.holderId);
for (const id of this.objectIds) {
requireStructural(project, holder, id);
requireObject(project, this.holderId, id);
}
this.removed = removeSubtrees(holder, this.objectIds);
}
undo(project: Project): void {
@@ -104,8 +92,7 @@ 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>[] = [];
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -114,28 +101,20 @@ export class MoveSceneObjectCommand implements Command {
) {}
execute(project: Project): void {
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]),
);
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setPosition(this.position);
}
undo(project: Project): void {
restoreSlots(this.memos);
requireObject(project, this.holderId, this.objectId).position = { ...this.previous };
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
/** Editor-only: undoable like any other edit, but it changes nothing the game can see. */
export class SetEditorOffsetCommand implements Command {
readonly label = 'Editor offset';
private previous: Vec2 = { x: 0, y: 0 };
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -144,20 +123,19 @@ export class SetEditorOffsetCommand implements Command {
) {}
execute(project: Project): void {
const object = requireObject(project, this.holderId, this.objectId);
this.previous = { ...object.editorOffset };
object.editorOffset = { x: this.offset.x, y: this.offset.y };
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setEditorOffset(this.offset);
}
undo(project: Project): void {
requireObject(project, this.holderId, this.objectId).editorOffset = { ...this.previous };
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
export class RenameSceneObjectCommand implements Command {
readonly label = 'Rename object';
private previous = '';
private memos: SlotMemo<string>[] = [];
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
@@ -166,53 +144,36 @@ export class RenameSceneObjectCommand implements Command {
) {}
execute(project: Project): void {
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]);
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setName(this.name);
}
undo(project: Project): void {
restoreSlots(this.memos);
requireObject(project, this.holderId, this.objectId).name = this.previous;
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
private memos: SlotMemo<PropertyValue | undefined>[] = [];
abstract class OverrideCommand implements Command {
private memo: ObjectMemo | undefined;
protected constructor(
private readonly holderId: string,
private readonly objectId: string,
key: string,
label: string,
) {
super(key, label);
protected readonly key: string,
readonly label: string,
) {}
protected abstract nextValue(object: SceneObject, project: Project): PropertyValue | undefined;
execute(project: Project): void {
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setOverride(this.key, this.nextValue(ref.object, project));
}
protected override resolveTarget(project: Project): SceneObject {
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);
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
@@ -226,7 +187,7 @@ export class SetOverrideCommand extends OverrideCommand {
super(holderId, objectId, key, `Override ${key}`);
}
protected override computeNext(object: SceneObject, project: Project): PropertyValue {
protected override nextValue(object: SceneObject, project: Project): PropertyValue {
return coerceValue(requirePropDef(requireAsset(project, object.assetId), this.key), this.value);
}
}
@@ -238,7 +199,7 @@ export class ClearOverrideCommand extends OverrideCommand {
// No schema check, unlike setting one: a key the asset no longer declares is exactly what one
// wants to be able to clear.
protected override computeNext(): undefined {
protected override nextValue(): undefined {
return undefined;
}
}
@@ -262,10 +223,13 @@ export class SetParentCommand implements Command {
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
requireStructural(project, holder, this.objectId);
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.holderId, this.parentId);
requireStructural(project, holder, this.parentId);
if (isLiveInstance(project, holder, requireObject(project, this.holderId, this.parentId)))
throw new CommandError('A placed container shows its template; unpack it to put objects inside');
if (isSelfOrAncestor(holder, this.objectId, this.parentId))
throw new CommandError('An object cannot be parented to one of its own children');
}
-89
View File
@@ -1,89 +0,0 @@
/**
* 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);
}
+16 -13
View File
@@ -1,5 +1,6 @@
import type { PropertyValue } from '../model/Properties';
import { findUsages, type Project } from '../model/Project';
import { overrideSites } from '../model/instance';
import type { Project } from '../model/Project';
import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
import { type Removed, removeFirst, restoreAt } from '../util/list';
import { type Command, CommandError } from './Command';
@@ -39,7 +40,7 @@ export class AddVariableCommand implements Command {
export class RemoveVariableCommand implements Command {
readonly label: string;
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined };
private overrides: { holderId: string; objectId: string; value: PropertyValue }[] = [];
private overrides: { holderId: string; objectId: string; path: string; value: PropertyValue }[] = [];
constructor(
private readonly assetId: string,
@@ -55,11 +56,11 @@ export class RemoveVariableCommand implements Command {
this.removed = { def, value: asset.props[this.key] };
delete asset.props[this.key];
this.overrides = [];
for (const { holder, object } of findUsages(project, this.assetId)) {
const value = object.overrides[this.key];
for (const site of overrideSites(project, (id) => id === this.assetId)) {
const value = site.bag[this.key];
if (value === undefined) continue;
this.overrides.push({ holderId: holder.id, objectId: object.id, value });
delete object.overrides[this.key];
this.overrides.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value });
delete site.bag[this.key];
}
}
@@ -68,10 +69,12 @@ export class RemoveVariableCommand implements Command {
const asset = requireBehavior(project, this.assetId);
restoreAt(asset.variables, [this.removed.def]);
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
const usages = findUsages(project, this.assetId);
const sites = overrideSites(project, (id) => id === this.assetId);
for (const o of this.overrides) {
const object = usages.find((u) => u.holder.id === o.holderId && u.object.id === o.objectId)?.object;
if (object) object.overrides[this.key] = o.value;
const site = sites.find(
(s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path,
);
if (site) site.bag[this.key] = o.value;
}
}
}
@@ -109,10 +112,10 @@ export class RenameVariableCommand implements Command {
asset.props[to] = asset.props[from]!;
delete asset.props[from];
}
for (const { object } of findUsages(project, this.assetId)) {
if (object.overrides[from] === undefined) continue;
object.overrides[to] = object.overrides[from]!;
delete object.overrides[from];
for (const { bag } of overrideSites(project, (id) => id === this.assetId)) {
if (bag[from] === undefined) continue;
bag[to] = bag[from]!;
delete bag[from];
}
// Every node that picks a variable stores it under the param key `variable`.
for (const node of asset.graph.nodes) if (node.params.variable === from) node.params.variable = to;
+3 -3
View File
@@ -41,9 +41,9 @@ export interface ColliderAsset extends AssetBase {
}
/**
* `objects` is a template instantiated below the container whenever it is placed. Template
* positions are local to the container's origin, and every placed copy gets fresh ids and
* remembers the template object it came from (`SceneObject.templateId`).
* `objects` is a template, not content: a placed container shows it without copying it, so an
* edit here reaches every instance (see `model/instance.ts`). Positions are local to the
* container's origin.
*/
export interface ContainerAsset extends AssetBase {
type: 'container';
+12 -8
View File
@@ -1,9 +1,13 @@
import { schemaForAsset } from '../properties/PropertySchema';
import { type Asset, type ContainerAsset, isSceneAsset, type SceneAsset } from './Asset';
import { overrideSites } from './instance';
import type { SceneObject } from './SceneObject';
/** Reserved for format migrations – the loader does not branch on it yet. */
export const PROJECT_FORMAT_VERSION = 1;
/**
* 2 since a placed container stopped copying its template into the scene. Old files are not
* migrated: the loader simply reads no container content from them any more.
*/
export const PROJECT_FORMAT_VERSION = 2;
export interface Project {
formatVersion: number;
@@ -82,7 +86,8 @@ export function findUsages(project: Project, assetId: string): SceneObjectRef[]
export type PropertyUsage =
| { on: 'asset'; asset: Asset; key: string }
| { on: 'object'; holder: ObjectHolder; object: SceneObject; key: string };
/** `path` is empty for a placed object's own override, set for one a container instance keeps. */
| { on: 'object'; holder: ObjectHolder; object: SceneObject; path: string; key: string };
/**
* The other half of "where is this asset used": a texture is never placed, it is worn through a
@@ -102,11 +107,10 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri
refKeys.set(asset.id, keys);
for (const key of keys) if (matches(asset.props[key])) result.push({ on: 'asset', asset, key });
}
for (const holder of objectHolders(project)) {
for (const object of holder.objects) {
for (const key of refKeys.get(object.assetId) ?? [])
if (matches(object.overrides[key])) result.push({ on: 'object', holder, object, key });
}
for (const site of overrideSites(project, () => true)) {
for (const key of refKeys.get(site.assetId) ?? [])
if (matches(site.bag[key]))
result.push({ on: 'object', holder: site.holder, object: site.object, path: site.path, key });
}
return result;
}
+6 -4
View File
@@ -1,3 +1,4 @@
import type { InstanceEdit } from './instance';
import { type Position, type PropertyBag, toPosition, type Vec2 } from './Properties';
/**
@@ -23,10 +24,11 @@ export interface SceneObject {
*/
parentId: string;
/**
* The template object inside the container asset this one was instantiated from, empty for
* everything else – the link that lets template changes reach the instances later on.
* Only on a placed container: what the user changed about its content, keyed by the path of
* template ids leading to the object (see `model/instance.ts`). The content itself is not
* stored – it follows the template.
*/
templateId: string;
instanceEdits: Record<string, InstanceEdit>;
}
export function createSceneObject(
@@ -43,6 +45,6 @@ export function createSceneObject(
editorOffset: { x: 0, y: 0 },
overrides: {},
parentId,
templateId: '',
instanceEdits: {},
};
}
+3 -79
View File
@@ -1,46 +1,10 @@
import { newId } from '../ids';
import type { ContainerAsset } from './Asset';
import { chainOf, type ObjectTree } from './hierarchy';
import { findAsset, objectsWhere, type Project, type SceneObjectRef } from './Project';
import type { SceneObject } from './SceneObject';
export interface TemplateLink {
containerObject: SceneObject;
asset: ContainerAsset;
template: SceneObject;
}
/**
* The nearest container ancestor whose asset holds the template `object` was instantiated from;
* undefined when it has no template id or the link is stale.
*/
export function templateLinkOf(
project: Project,
tree: ObjectTree,
object: SceneObject,
): TemplateLink | undefined {
if (!object.templateId) return undefined;
for (const containerObject of chainOf(tree, object).slice(1)) {
const asset = findAsset(project, containerObject.assetId);
if (asset?.type !== 'container') continue;
const template = asset.objects.find((t) => t.id === object.templateId);
if (template) return { containerObject, asset, template };
}
return undefined;
}
/**
* Every placed copy of a template object. Template objects themselves never carry a template id, so
* one is never mistaken for a copy of itself even though the search covers container assets too.
*/
export function instancesOf(project: Project, templateId: string): SceneObjectRef[] {
return objectsWhere(project, (o) => o.templateId === templateId);
}
import { findAsset, type Project } from './Project';
/**
* 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.
* template can place containers into it, and a cycle would expand forever the moment one of them
* is resolved.
*/
export function containerReaches(project: Project, asset: ContainerAsset, wantedId: string): boolean {
if (asset.id === wantedId) return true;
@@ -57,43 +21,3 @@ export function containerReaches(project: Project, asset: ContainerAsset, wanted
}
return false;
}
const MAX_NESTING = 8;
/**
* One fresh object per template object, parented below `containerObjectId` or below the copy of its
* template parent. A template object that is itself a container is expanded in turn, unless the
* template already spells its content out.
*/
export function instantiateContainer(
project: Project,
asset: ContainerAsset,
containerObjectId: string,
depth = 0,
): SceneObject[] {
const ids = new Map(asset.objects.map((t) => [t.id, newId()]));
const result: SceneObject[] = [];
for (const template of asset.objects) {
const id = ids.get(template.id)!;
const parentId = template.parentId
? (ids.get(template.parentId) ?? containerObjectId)
: containerObjectId;
result.push({
id,
assetId: template.assetId,
name: template.name,
position: { ...template.position },
// The editor offset is a tweak on one placed object, not something copies should inherit.
editorOffset: { x: 0, y: 0 },
overrides: { ...template.overrides },
parentId,
templateId: template.id,
});
const nested = findAsset(project, template.assetId);
const hasExplicitContent = asset.objects.some((o) => o.parentId === template.id);
if (nested?.type === 'container' && !hasExplicitContent && depth < MAX_NESTING) {
result.push(...instantiateContainer(project, nested, id, depth + 1));
}
}
return result;
}
+429
View File
@@ -0,0 +1,429 @@
/**
* A container placed in a scene is not copied into it. The scene stores the container object
* alone; its content is resolved from the template on every read, so a template edit reaches
* every instance at once. What the user changes on one of those resolved objects is kept as a
* sparse `InstanceEdit` on the instance, keyed by the path of template ids leading to it – and
* only where it actually deviates, which is what makes an untouched value keep following.
*
* Ids of resolved content are derived (`<instance id>~<path>`), never stored, and stable as long
* as the template is: the selection, the hierarchy and the viewport address them like any other.
*/
import type { Asset, ContainerAsset } from './Asset';
import type { ObjectTree } from './hierarchy';
import {
type Axis,
type Position,
type PropertyBag,
type PropertyValue,
setBagValue,
type Vec2,
} from './Properties';
import { findAsset, type ObjectHolder, objectHolders, type Project } from './Project';
import type { SceneObject } from './SceneObject';
/** Neither character can occur inside an id (see `newId`), so a derived id parses back uniquely. */
const ID_SEP = '~';
const PATH_SEP = '/';
/** A container inside a container inside … – the same guard `instantiate` needs against cycles. */
const MAX_NESTING = 8;
const AXES: readonly Axis[] = ['x', 'y', 'z'];
/** Sparse deviations of one resolved object; everything absent follows the template. */
export interface InstanceEdit {
name?: string;
position: Partial<Record<Axis, number>>;
editorOffset?: Vec2;
overrides: PropertyBag;
}
export function emptyEdit(): InstanceEdit {
return { position: {}, overrides: {} };
}
export function isEmptyEdit(edit: InstanceEdit): boolean {
return (
edit.name === undefined &&
edit.editorOffset === undefined &&
Object.keys(edit.position).length === 0 &&
Object.keys(edit.overrides).length === 0
);
}
function copyEdit(edit: InstanceEdit): InstanceEdit {
return {
...(edit.name === undefined ? {} : { name: edit.name }),
...(edit.editorOffset === undefined ? {} : { editorOffset: { ...edit.editorOffset } }),
position: { ...edit.position },
overrides: { ...edit.overrides },
};
}
/** The outer edit wins per field and per key; anything it leaves out keeps the inner value. */
function mergeEdits(
inner: InstanceEdit | undefined,
outer: InstanceEdit | undefined,
): InstanceEdit | undefined {
if (!inner) return outer;
if (!outer) return inner;
return {
name: outer.name ?? inner.name,
editorOffset: outer.editorOffset ?? inner.editorOffset,
position: { ...inner.position, ...outer.position },
overrides: { ...inner.overrides, ...outer.overrides },
};
}
export function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined {
const asset: Asset | undefined = findAsset(project, object.assetId);
return asset?.type === 'container' ? asset : undefined;
}
/**
* The placed containers still showing their template, with the asset behind each. Content spelled
* out in the holder means the instance was unpacked (or baked for a play run) and is an ordinary
* parent from then on.
*/
function liveInstances(project: Project, holder: ObjectTree): Map<SceneObject, ContainerAsset> {
const parents = new Set(holder.objects.map((o) => o.parentId));
const live = new Map<SceneObject, ContainerAsset>();
for (const object of holder.objects) {
if (parents.has(object.id)) continue;
const asset = containerAssetOf(project, object);
if (asset) live.set(object, asset);
}
return live;
}
export function isLiveInstance(project: Project, holder: ObjectTree, object: SceneObject): boolean {
return liveInstances(project, holder).has(object);
}
export function instanceChildId(instanceId: string, path: string): string {
return `${instanceId}${ID_SEP}${path}`;
}
/** Splits a derived id back into the instance it belongs to and the template path below it. */
export function splitInstanceId(id: string): { instanceId: string; path: string } | undefined {
const at = id.indexOf(ID_SEP);
if (at < 0) return undefined;
return { instanceId: id.slice(0, at), path: id.slice(at + 1) };
}
/**
* Whether the object is only resolved from a template rather than stored in the holder. The
* question is where it lives, not what its id looks like: unpacking keeps the derived id – so the
* selection and the running game survive it – and the object is an ordinary one from then on.
*/
export function isTemplateContent(holder: ObjectTree, objectId: string): boolean {
return !holder.objects.some((o) => o.id === objectId);
}
function resolveChild(
template: SceneObject,
edit: InstanceEdit | undefined,
id: string,
parentId: string,
): SceneObject {
const position = { ...template.position };
for (const axis of AXES) {
const own = edit?.position[axis];
if (own !== undefined) position[axis] = own;
}
return {
id,
assetId: template.assetId,
name: edit?.name ?? template.name,
position,
editorOffset: edit?.editorOffset ? { ...edit.editorOffset } : { x: 0, y: 0 },
overrides: { ...template.overrides, ...edit?.overrides },
parentId,
instanceEdits: {},
};
}
function expand(
project: Project,
asset: ContainerAsset,
instanceId: string,
prefix: string,
editAt: (path: string) => InstanceEdit | undefined,
rootParentId: string,
depth: number,
out: SceneObject[],
): void {
for (const template of asset.objects) {
const path = prefix ? `${prefix}${PATH_SEP}${template.id}` : template.id;
const id = instanceChildId(instanceId, path);
const parentId = template.parentId
? instanceChildId(instanceId, prefix ? `${prefix}${PATH_SEP}${template.parentId}` : template.parentId)
: rootParentId;
out.push(resolveChild(template, editAt(path), id, parentId));
const nested = containerAssetOf(project, template);
const spelledOut = asset.objects.some((o) => o.parentId === template.id);
if (!nested || spelledOut || depth >= MAX_NESTING) continue;
// The template object's own edits are the base, the outer instance's edits sit on top of them.
// `editAt` always takes the full path; the base is keyed relative to the nested container.
const own = template.instanceEdits;
const below = (full: string) => mergeEdits(own[full.slice(path.length + 1)], editAt(full));
expand(project, nested, instanceId, path, below, id, depth + 1, out);
}
}
/**
* The holder's objects with the content of every live container instance woven in. Stored objects
* are passed through by reference – only instance content is freshly built and must not be written
* to (see `objectRef`). Without a live instance the stored list is returned unchanged.
*/
export function resolveObjects(project: Project, holder: ObjectTree): SceneObject[] {
const live = liveInstances(project, holder);
if (live.size === 0) return holder.objects;
const out: SceneObject[] = [];
for (const object of holder.objects) {
out.push(object);
const asset = live.get(object);
if (asset) expand(project, asset, object.id, '', (p) => object.instanceEdits[p], object.id, 0, out);
}
return out;
}
/** The holder with its resolved objects – a read-only view for anything that walks the tree. */
export function resolvedHolder(project: Project, holder: ObjectHolder): ObjectHolder {
const objects = resolveObjects(project, holder);
return objects === holder.objects ? holder : { ...holder, objects };
}
export interface InstanceTarget {
/** The stored container object the edits live on. */
instance: SceneObject;
path: string;
/** The container asset that declares `template` – the one to open to change it for good. */
asset: ContainerAsset;
template: SceneObject;
}
/** Where a derived id points: the instance holding the edits, and the template object behind it. */
export function instanceTargetOf(
project: Project,
holder: ObjectTree,
objectId: string,
): InstanceTarget | undefined {
const split = splitInstanceId(objectId);
const instance = split && holder.objects.find((o) => o.id === split.instanceId);
if (!split || !instance) return undefined;
let asset = containerAssetOf(project, instance);
let template: SceneObject | undefined;
for (const segment of split.path.split(PATH_SEP)) {
const next = template ? containerAssetOf(project, template) : asset;
if (!next) return undefined;
asset = next;
template = next.objects.find((t) => t.id === segment);
if (!template) return undefined;
}
return asset && template ? { instance, path: split.path, asset, template } : undefined;
}
// --- writing -----------------------------------------------------------------------------------
/**
* Read/write access to a placed object, whether it is stored in the holder or resolved from a
* container template. `object` is the current state either way; the setters keep an instance edit
* to exactly what deviates from the template, so a value put back to the template's follows again.
*/
export interface ObjectRef {
readonly object: SceneObject;
/** The instance edits belong to, undefined for a stored object. */
readonly target: InstanceTarget | undefined;
setName(name: string): void;
setPosition(position: Position): void;
setEditorOffset(offset: Vec2): void;
setOverride(key: string, value: PropertyValue | undefined): void;
}
function editOf(target: InstanceTarget): InstanceEdit {
const existing = target.instance.instanceEdits[target.path];
if (existing) return existing;
const edit = emptyEdit();
target.instance.instanceEdits[target.path] = edit;
return edit;
}
function prune(target: InstanceTarget): void {
const edit = target.instance.instanceEdits[target.path];
if (edit && isEmptyEdit(edit)) delete target.instance.instanceEdits[target.path];
}
function instanceRef(target: InstanceTarget, object: SceneObject): ObjectRef {
const write = (mutate: (edit: InstanceEdit) => void): void => {
mutate(editOf(target));
prune(target);
};
return {
object,
target,
setName: (name) =>
write((edit) => {
if (name === target.template.name) delete edit.name;
else edit.name = name;
}),
setPosition: (position) =>
write((edit) => {
for (const axis of AXES) {
if (position[axis] === target.template.position[axis]) delete edit.position[axis];
else edit.position[axis] = position[axis];
}
}),
setEditorOffset: (offset) =>
write((edit) => {
if (offset.x === 0 && offset.y === 0) delete edit.editorOffset;
else edit.editorOffset = { x: offset.x, y: offset.y };
}),
setOverride: (key, value) =>
write((edit) => {
if (value === target.template.overrides[key]) delete edit.overrides[key];
else setBagValue(edit.overrides, key, value);
}),
};
}
function storedRef(object: SceneObject): ObjectRef {
return {
object,
target: undefined,
setName: (name) => {
object.name = name;
},
setPosition: (position) => {
object.position = { ...position };
},
setEditorOffset: (offset) => {
object.editorOffset = { x: offset.x, y: offset.y };
},
setOverride: (key, value) => setBagValue(object.overrides, key, value),
};
}
export function objectRef(project: Project, holder: ObjectTree, objectId: string): ObjectRef | undefined {
const stored = holder.objects.find((o) => o.id === objectId);
if (stored) return storedRef(stored);
const target = instanceTargetOf(project, holder, objectId);
const object = resolveObjects(project, holder).find((o) => o.id === objectId);
return target && object ? instanceRef(target, object) : undefined;
}
/** Everything a value edit can change, snapshotted so a command can put it back exactly. */
export type ObjectMemo =
| {
kind: 'stored';
object: SceneObject;
name: string;
position: Position;
editorOffset: Vec2;
overrides: PropertyBag;
}
| { kind: 'edit'; instance: SceneObject; path: string; edit: InstanceEdit | undefined };
export function captureObject(ref: ObjectRef): ObjectMemo {
if (ref.target) {
const edit = ref.target.instance.instanceEdits[ref.target.path];
return {
kind: 'edit',
instance: ref.target.instance,
path: ref.target.path,
edit: edit && copyEdit(edit),
};
}
const { object } = ref;
return {
kind: 'stored',
object,
name: object.name,
position: { ...object.position },
editorOffset: { ...object.editorOffset },
overrides: { ...object.overrides },
};
}
export function restoreObject(memo: ObjectMemo): void {
if (memo.kind === 'edit') {
if (memo.edit) memo.instance.instanceEdits[memo.path] = copyEdit(memo.edit);
else delete memo.instance.instanceEdits[memo.path];
return;
}
memo.object.name = memo.name;
memo.object.position = { ...memo.position };
memo.object.editorOffset = { ...memo.editorOffset };
memo.object.overrides = { ...memo.overrides };
}
/**
* One sparse bag of property overrides in the project, with the asset whose schema governs it:
* a placed object's own, or the deviations a container instance keeps for its content. Anything
* rewriting overrides across the project (removing or renaming a behavior variable, counting the
* uses of a texture) has to cover both, or it silently misses everything inside a container.
*/
export interface OverrideSite {
holder: ObjectHolder;
/** The stored object the bag lives on – the placed object itself, or the container instance. */
object: SceneObject;
/** Template path inside `object`; empty when the bag is the object's own overrides. */
path: string;
assetId: string;
bag: PropertyBag;
}
export function overrideSites(project: Project, wanted: (assetId: string) => boolean): OverrideSite[] {
const sites: OverrideSite[] = [];
for (const holder of objectHolders(project)) {
for (const object of holder.objects) {
if (wanted(object.assetId))
sites.push({ holder, object, path: '', assetId: object.assetId, bag: object.overrides });
for (const [path, edit] of Object.entries(object.instanceEdits)) {
const target = instanceTargetOf(project, holder, instanceChildId(object.id, path));
if (target && wanted(target.template.assetId))
sites.push({ holder, object, path, assetId: target.template.assetId, bag: edit.overrides });
}
}
}
return sites;
}
// --- unpacking ---------------------------------------------------------------------------------
/**
* The resolved content of an instance as objects that can be stored. The derived ids are kept:
* they are unique already, and anything pointing at one – the selection, a running game, a check
* script – keeps working across the unpack.
*/
export function materializeInstance(
project: Project,
holder: ObjectTree,
instance: SceneObject,
): SceneObject[] {
return resolveObjects(project, holder)
.filter((o) => splitInstanceId(o.id)?.instanceId === instance.id)
.map((o) => ({
...o,
position: { ...o.position },
editorOffset: { ...o.editorOffset },
overrides: { ...o.overrides },
instanceEdits: {},
}));
}
/**
* Spells out every live instance of the holder. The runtime needs it: entities are built from
* stored objects, and a behavior may destroy or move one of them – which a resolved copy could
* not remember. Returns true when something was written.
*/
export function bakeInstances(project: Project, holder: ObjectTree): boolean {
const parents = new Set(holder.objects.map((o) => o.parentId));
const live = holder.objects.filter((o) => !parents.has(o.id) && containerAssetOf(project, o) !== undefined);
if (live.length === 0) return false;
for (const instance of live) {
holder.objects.push(...materializeInstance(project, holder, instance));
instance.instanceEdits = {};
}
return true;
}
+28 -1
View File
@@ -2,6 +2,7 @@
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
import type { Asset, TextureFrame } from '../model/Asset';
import { isAssetType } from '../model/AssetTypes';
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
import type { SceneObject } from '../model/SceneObject';
@@ -108,7 +109,7 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
overrides: normalizeBag(o.overrides, schema, true),
// `ownerId` is the pre-hierarchy name of the same link.
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
templateId: typeof o.templateId === 'string' ? o.templateId : '',
instanceEdits: normalizeInstanceEdits(o.instanceEdits),
});
}
// Parents must exist in the same scene, differ from the object itself and not form cycles.
@@ -120,6 +121,32 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
return objects;
}
/**
* Deviations of container content, keyed by template path. Values are not coerced against a schema:
* which asset a path leads to depends on container assets that may not be built yet, and resolving
* drops what the schema does not declare anyway.
*/
function normalizeInstanceEdits(raw: unknown): Record<string, InstanceEdit> {
const edits: Record<string, InstanceEdit> = {};
for (const [path, value] of Object.entries(asRecord(raw))) {
if (!path) continue;
const e = asRecord(value);
const edit = emptyEdit();
if (typeof e.name === 'string') edit.name = e.name;
const position = asRecord(e.position);
for (const axis of ['x', 'y', 'z'] as const)
if (typeof position[axis] === 'number' && Number.isFinite(position[axis]))
edit.position[axis] = position[axis];
const offset = asRecord(e.editorOffset);
if (typeof offset.x === 'number' || typeof offset.y === 'number')
edit.editorOffset = { x: toNumber(offset.x), y: toNumber(offset.y) };
for (const [key, v] of Object.entries(asRecord(e.overrides)))
if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') edit.overrides[key] = v;
if (!isEmptyEdit(edit)) edits[path] = edit;
}
return edits;
}
function normalizeVariables(raw: unknown): PropertyDef[] {
const defs: PropertyDef[] = [];
const seen = new Set<string>();
+12
View File
@@ -1,4 +1,6 @@
import { type Application, type Container, Rectangle, Text } from 'pixi.js';
import { resolveObjects } from '../core/model/instance';
import { findObjectHolder } from '../core/model/Project';
import { Button, ui, Widget } from '../ui';
import type { CentreTab, Editor } from './Editor';
import type { EditorState } from './state';
@@ -103,8 +105,18 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const p = popup.getGlobalPosition();
return { x: p.x, y: p.y, w: popup.w, h: popup.h };
};
/**
* A holder's objects the way the editor sees them: the content of placed containers is resolved
* from the template, so it is nowhere in `holder.objects` (see `model/instance.ts`).
*/
const objectsOf = (holderId: string) => {
const holder = findObjectHolder(state.project, holderId);
return holder ? resolveObjects(state.project, holder) : [];
};
(window as unknown as { __gm: unknown }).__gm = {
state,
objectsOf,
sceneObjects: () => (state.activeScene ? objectsOf(state.activeScene.id) : []),
locate,
overlayTexts,
overlayRect,
+151 -82
View File
@@ -6,6 +6,7 @@ import {
RevertPositionToTemplateCommand,
} from '../../core/commands/applyCommands';
import type { Command } from '../../core/commands/Command';
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
@@ -15,8 +16,16 @@ import {
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
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 InstanceTarget,
instanceTargetOf,
isLiveInstance,
isTemplateContent,
objectRef,
type ObjectRef,
resolvedHolder,
} from '../../core/model/instance';
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
@@ -31,7 +40,7 @@ import {
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button, Dropdown, Menu, type MenuItem, TextField } from '../../ui';
import { Button, Dropdown, Label, Menu, type MenuItem, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { aspectActions } from './aspectAction';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
@@ -43,6 +52,17 @@ const OFFSET_AXES = ['x', 'y'] as const;
const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
/** The holder with the content of placed containers resolved in – what the inspector addresses. */
function viewOf(project: Project, holderId: string): ObjectHolder | undefined {
const holder = findObjectHolder(project, holderId);
return holder && resolvedHolder(project, holder);
}
function targetOf(project: Project, holderId: string, objectId: string): InstanceTarget | undefined {
const holder = findObjectHolder(project, holderId);
return holder && instanceTargetOf(project, holder, objectId);
}
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(
project: Project,
@@ -52,11 +72,17 @@ export function objectSignature(
): string {
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const holder = findObjectHolder(project, holderId);
const object = holder && findObject(holder, objectId);
const view = holder && resolvedHolder(project, holder);
const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
if (!object || !asset) return 'missing';
if (!holder || !object || !asset) return 'missing';
const kind = isTemplateContent(holder, objectId)
? 'template'
: isLiveInstance(project, holder, object)
? 'instance'
: '';
return (
`${object.parentId}|${object.templateId}|` +
`${object.parentId}|${kind}|` +
schemaForAsset(asset)
.map((d) => `${d.key}:${d.kind}`)
.join(',')
@@ -65,11 +91,11 @@ export function objectSignature(
/** Several objects: what they are, and the actions that apply to all of them at once. */
export function buildObjectsSection(host: InspectorHost, holderId: string, objectIds: string[]): void {
const holder = findObjectHolder(host.state.project, holderId);
if (!holder) return;
const view = viewOf(host.state.project, holderId);
if (!view) return;
addTitle(host, `${objectIds.length} OBJECTS SELECTED`);
for (const id of objectIds) {
const object = findObject(holder, id);
const object = findObject(view, id);
const asset = object && findAsset(host.state.project, object.assetId);
if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`);
}
@@ -77,6 +103,12 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
addNote(host, 'Dragging one in the viewport moves all of them.', 'small');
addGap(host);
const holder = findObjectHolder(host.state.project, holderId)!;
if (objectIds.some((id) => isTemplateContent(holder, id))) {
addNote(host, 'Part of the selection comes from a container', 'small');
addNote(host, 'template, so grouping and removing are off.', 'small');
return;
}
host.body.add(
new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }),
);
@@ -93,10 +125,12 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
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 (!holder || !object || !asset) return;
const { project } = host.state;
const view = viewOf(project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
if (!view || !object || !asset) return;
const target = targetOf(project, holderId, objectId);
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
host.nameField = new TextField(object.name, {
@@ -117,16 +151,15 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
);
host.body.add(assetRow.row);
host.body.add(parentRow(host, holder, object));
host.body.add(parentRow(host, view, object, target !== undefined));
// Part of a placed container: values compare against the template, not just the asset.
const link = templateLinkOf(host.state.project, holder, object);
if (link) {
// Content of a placed container: values compare against the template, not just the asset.
if (target) {
const linkRow = labelledRow('Part of');
linkRow.row.add(
new Button(link.asset.name, {
new Button(target.asset.name, {
variant: 'ghost',
onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }),
onClick: () => host.state.select({ kind: 'asset', assetId: target.asset.id }),
}),
1,
);
@@ -144,8 +177,8 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
key: axis,
label: axis.toUpperCase(),
axis,
read: (o) => o.position,
write: (o, v) => (o.position = v),
read: (ref) => ref.object.position,
write: (ref, value) => ref.setPosition(value),
command: (v) => new MoveSceneObjectCommand(holderId, objectId, v),
reset: () =>
moved()
@@ -177,12 +210,12 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
key: offsetKey(axis),
label: `Offset ${axis.toUpperCase()}`,
axis,
read: (o) => o.editorOffset,
write: (o, v) => (o.editorOffset = v),
read: (ref) => ref.object.editorOffset,
write: (ref, value) => ref.setEditorOffset(value),
command: (v) => new SetEditorOffsetCommand(holderId, objectId, v),
// No apply: the offset never leaves the placed object.
reset: () => {
const current = currentObject(host.state, holderId, objectId);
const current = currentRef(host.state, holderId, objectId)?.object;
if (!current || current.editorOffset[axis] === 0) return [];
return [
{
@@ -205,16 +238,11 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
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, holderId, objectId, asset, def, values);
markObjectRows(host, object, asset, link);
markObjectRows(host, object, asset, target);
if (asset.type === 'container') {
const count = asset.objects.length;
addTitle(host, 'CONTENT');
addNote(host, count === 0 ? 'The container is empty.' : `Instance with ${count} template object(s).`);
if (count > 0) addNote(host, 'Its content is listed below it in the hierarchy.', 'small');
}
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
if (asset.type !== 'collider') {
if (asset.type !== 'collider' && !target) {
addGap(host);
host.body.add(
new Button('+ Add collider', {
@@ -225,18 +253,53 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
}
}
/**
* What a placed container holds. While it follows its template the content cannot be changed here –
* unpacking writes it into the scene as ordinary objects, which cuts the link for good.
*/
function contentSection(
host: InspectorHost,
holderId: string,
object: SceneObject,
templateCount: number,
): void {
const holder = findObjectHolder(host.state.project, holderId);
const live = Boolean(holder && isLiveInstance(host.state.project, holder, object));
addTitle(host, 'CONTENT');
if (!live) {
addNote(host, 'Unpacked: its content is stored in the scene.');
return;
}
addNote(
host,
templateCount === 0 ? 'The container is empty.' : `Follows a template of ${templateCount} object(s).`,
);
if (templateCount === 0) return;
addNote(host, 'Its content is listed below it in the hierarchy.', 'small');
addGap(host);
host.body.add(
new Button('Unpack', {
variant: 'ghost',
onClick: () => host.state.execute(new UnpackContainerCommand(holderId, object.id)),
}),
);
addNote(host, 'Copies the content into the scene and stops', 'small');
addNote(host, 'following the template.', 'small');
}
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
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 (!holder || !object || !asset) return false;
const { project } = host.state;
const view = viewOf(project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(project, object.assetId);
if (!view || !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, holder, object));
markObjectRows(host, object, asset, targetOf(project, holderId, objectId));
return true;
}
@@ -244,8 +307,8 @@ interface ComponentSpec<V extends object> {
key: string;
label: string;
axis: keyof V & string;
read: (object: SceneObject) => V;
write: (object: SceneObject, value: V) => void;
read: (ref: ObjectRef) => V;
write: (ref: ObjectRef, value: V) => void;
command: (value: V) => Command;
reset?: FieldActions;
apply?: FieldActions;
@@ -268,12 +331,12 @@ function componentRow<V extends object>(
host,
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
previewingHandlers<SceneObject, V>(host, {
target: () => currentObject(host.state, holderId, objectId),
previewingHandlers<ObjectRef, V>(host, {
target: () => currentRef(host.state, holderId, objectId),
// Copied: the snapshot outlives the scrub, the vector in the model does not.
read: (object) => ({ ...spec.read(object) }),
read: (ref) => ({ ...spec.read(ref) }),
write: spec.write,
toModel: (next, object) => withAxis(spec.read(object), next),
toModel: (next, ref) => withAxis(spec.read(ref), next),
commit: spec.command,
}),
{ reset: spec.reset, apply: spec.apply },
@@ -286,10 +349,9 @@ function componentRow<V extends object>(
* 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
* step further up, the asset – so ↺/↑ can become a menu. Dropping the override is what makes
* the object follow the asset again: the template is only read when the container is placed,
* never at draw time.
* Inside a container a value has two bases – the template it follows and, one step further up,
* the asset. Reset always goes to the nearest one, so content of a placed container offers only
* "Reset to container"; "Apply to asset" still reaches past it, dropping the template's override.
*/
function propertyRow(
host: InspectorHost,
@@ -304,11 +366,11 @@ function propertyRow(
host,
def,
values[def.key] ?? def.default,
previewingHandlers<SceneObject>(host, {
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],
previewingHandlers<ObjectRef>(host, {
target: () => currentRef(host.state, holderId, objectId),
read: (ref) => ref.object.overrides[def.key],
write: (ref, value) => ref.setOverride(def.key, value),
remove: (ref) => ref.setOverride(def.key, undefined),
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
}),
{
@@ -342,7 +404,7 @@ function propertyRow(
];
},
apply: () => {
const { template, asset } = bases();
const { template, applyToAsset } = bases();
return [
...(template
? [
@@ -353,7 +415,7 @@ function propertyRow(
},
]
: []),
...(asset
...(applyToAsset
? [
{
label: 'Apply to asset',
@@ -373,22 +435,28 @@ function propertyRow(
/**
* "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.
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner. Content of a
* placed container only shows its parent: where it hangs is the template's business.
*/
function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject) {
function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject, fixed: boolean) {
const { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
const parent = findObject(holder, object.parentId);
const button = new Dropdown(parent ? nameOf(parent) : 'None');
const name = parent ? nameOf(parent) : 'None';
if (fixed) {
row.add(new Label(name, { style: 'muted' }), 1);
return row;
}
const button = new Dropdown(name);
button.onClick = () => {
const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) },
];
for (const { object: candidate, depth } of treeOrder(holder)) {
if (isSelfOrAncestor(holder, object.id, candidate.id)) continue;
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
const label = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({
label: candidate.id === object.parentId ? `● ${name}` : name,
label: candidate.id === object.parentId ? `● ${label}` : label,
onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
});
}
@@ -400,49 +468,51 @@ function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObjec
}
/**
* Bold marks exactly what the ↺/↑ buttons offer: for a container instance a deviation from
* the template (position per axis, overrides), for any other object one from the asset. The
* Bold marks exactly what the ↺/↑ buttons offer: for content of a placed container a deviation
* from the template (position per axis, overrides), for any other object one from the asset. The
* editor offset has one base only – no offset at all.
*/
function markObjectRows(
host: InspectorHost,
object: SceneObject,
asset: Asset,
link: TemplateLink | undefined,
target: InstanceTarget | undefined,
): void {
for (const axis of POSITION_AXES)
host.fields
.get(axis)
?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
?.setModified(Boolean(target) && object.position[axis] !== target!.template.position[axis]);
for (const axis of OFFSET_AXES)
host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
for (const def of schemaForAsset(asset)) {
const deviates = link
? deviatesFromTemplate(object, link.template, def.key)
const deviates = target
? deviatesFromTemplate(object, target.template, def.key)
: deviatesFromAsset(object, asset, def.key);
host.fields.get(def.key)?.setModified(deviates);
}
}
/**
* The two bases a property can be reset to or applied upwards, each only when it actually
* differs: the template of the container the object belongs to, and the asset behind it.
* Evaluated on demand so a menu never acts on a stale snapshot.
* What the ↺/↑ menus may offer, each only when it actually differs. Reset goes to the nearest base
* only: dropping a deviation inside a container lands on the template, never past it. Evaluated on
* demand so a menu never acts on a stale snapshot.
*/
function deviations(
state: InspectorHost['state'],
holderId: string,
objectId: string,
key: string,
): { template: boolean; asset: boolean } {
const holder = findObjectHolder(state.project, holderId);
const object = holder && findObject(holder, objectId);
): { template: boolean; asset: boolean; applyToAsset: boolean } {
const view = viewOf(state.project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(state.project, object.assetId);
if (!holder || !object || !asset) return { template: false, asset: false };
const link = templateLinkOf(state.project, holder, object);
if (!view || !object || !asset) return { template: false, asset: false, applyToAsset: false };
const target = targetOf(state.project, holderId, objectId);
const fromAsset = deviatesFromAsset(object, asset, key);
return {
template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
asset: deviatesFromAsset(object, asset, key),
template: Boolean(target) && deviatesFromTemplate(object, target!.template, key),
asset: !target && fromAsset,
applyToAsset: fromAsset,
};
}
@@ -453,24 +523,23 @@ function positionMoved(
objectId: string,
axis: Axis,
): boolean {
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];
const target = targetOf(state.project, holderId, objectId);
const object = currentRef(state, holderId, objectId)?.object;
return Boolean(target && object) && object!.position[axis] !== target!.template.position[axis];
}
function currentObject(
function currentRef(
state: InspectorHost['state'],
holderId: string,
objectId: string,
): SceneObject | undefined {
): ObjectRef | undefined {
const holder = findObjectHolder(state.project, holderId);
return holder && findObject(holder, objectId);
return holder && objectRef(state.project, holder, objectId);
}
/** The values the object shows right now, asset defaults and overrides merged. */
function currentProps(state: InspectorHost['state'], holderId: string, objectId: string): PropertyBag | null {
const object = currentObject(state, holderId, objectId);
const object = currentRef(state, holderId, objectId)?.object;
const asset = object && findAsset(state.project, object.assetId);
return object && asset ? resolveSceneObjectProps(asset, object) : null;
}
+19 -3
View File
@@ -1,6 +1,8 @@
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes';
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
@@ -79,8 +81,10 @@ export class HierarchyPanel extends Panel {
this.refresh();
}
/** With the content of placed containers resolved in – the rows show it like any other object. */
private holder(): ObjectHolder | undefined {
return this.source();
const holder = this.source();
return holder && resolvedHolder(this.state.project, holder);
}
/** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
@@ -113,6 +117,11 @@ export class HierarchyPanel extends Panel {
const inScene = holder.type === 'scene';
const object = findObject(holder, id);
const type = object && findAsset(state.project, object.assetId)?.type;
// Content of a placed container belongs to its template: values can be changed here, the
// structure cannot (see `model/instance.ts`).
const source = this.source();
const fromTemplate = Boolean(source && isTemplateContent(source, id));
const instance = Boolean(object && source && isLiveInstance(state.project, source, object));
// 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;
@@ -123,16 +132,22 @@ export class HierarchyPanel extends Panel {
},
{
label: 'Add collider',
disabled: !type || type === 'collider' || many,
disabled: !type || type === 'collider' || many || fromTemplate || instance,
onSelect: () => addCollider(state, holder.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
disabled: !source || selected.some((oid) => isTemplateContent(source, oid)),
onSelect: () => createContainer(state, holder.id, selected),
},
{
label: 'Unpack container',
disabled: !instance,
onSelect: () => state.execute(new UnpackContainerCommand(holder.id, id)),
},
{
label: 'Unparent (move to top level)',
disabled: !object?.parentId || many,
disabled: !object?.parentId || many || fromTemplate,
onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')),
},
{ label: 'Expand all', onSelect: () => this.expandAll() },
@@ -142,6 +157,7 @@ export class HierarchyPanel extends Panel {
? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}`
: `Remove from ${inScene ? 'scene' : 'container'}`,
danger: true,
disabled: !source || selected.some((oid) => isTemplateContent(source, oid)),
onSelect: () => removeObjects(state, holder.id, selected),
},
]);
+5 -2
View File
@@ -2,6 +2,7 @@ import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { Asset, SceneAsset } from '../core/model/Asset';
import { findObject } from '../core/model/hierarchy';
import { resolvedHolder } from '../core/model/instance';
import {
createEmptyProject,
findAsset,
@@ -232,7 +233,8 @@ export class EditorState {
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
if (s.kind === 'object') {
const holder = findObjectHolder(this.project, s.holderId);
const alive = holder ? s.objectIds.filter((id) => findObject(holder, id)) : [];
const view = holder && resolvedHolder(this.project, holder);
const alive = view ? s.objectIds.filter((id) => findObject(view, id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
}
if (s.kind === 'node') {
@@ -287,7 +289,8 @@ export class EditorState {
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;
const view = holder && resolvedHolder(this.project, holder);
const assetId = view && findObject(view, s.objectId)?.assetId;
return assetId ? findAsset(this.project, assetId) : undefined;
}
+8 -6
View File
@@ -2,7 +2,7 @@ import { isHandleEnabled, type PointerState, worldPositionOf } from '../core/gra
import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
import { isPlaceableType } from '../core/model/AssetTypes';
import { instantiateContainer } from '../core/model/container';
import { bakeInstances, materializeInstance } from '../core/model/instance';
import { findObject } from '../core/model/hierarchy';
import { descendantsBelow } from '../core/model/walk';
import { findAsset, findScene, type Project } from '../core/model/Project';
@@ -77,6 +77,10 @@ export class World {
syncFromModel(): void {
const scene = findScene(this.project, this.sceneId);
if (!scene) return;
// Entities are built from stored objects, and a behavior may move or destroy one of them –
// which content resolved from a container template could not remember. So it is written out
// once, into the play-mode copy of the model that is thrown away afterwards.
if (bakeInstances(this.project, scene)) this.structureChanged = true;
const background = scene.props.backgroundColor;
this.backgroundColor = typeof background === 'number' ? background : 0x000000;
const keep = new Set<Entity>();
@@ -141,15 +145,13 @@ export class World {
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
scene.objects.push(object);
// A container brings its content along (parented below it, in template order).
const created = [
object,
...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : []),
];
const created = asset.type === 'container' ? materializeInstance(this.project, scene, object) : [];
scene.objects.push(...created);
const entity = this.materialize(object, asset);
entity.parent = parentEntity;
for (const child of created.slice(1)) {
for (const child of created) {
const childAsset = findAsset(this.project, child.assetId);
if (!childAsset || !isPlaceableType(childAsset.type)) continue;
this.materialize(child, childAsset).parent = this.findByObjectId(child.parentId) ?? null;
+17 -12
View File
@@ -8,6 +8,7 @@ import { CompoundCommand } from '../core/commands/CompoundCommand';
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { isSymbolType } from '../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../core/model/hierarchy';
import { objectRef, resolvedHolder } from '../core/model/instance';
import type { Position, Vec2 } from '../core/model/Properties';
import type { ObjectHolder } from '../core/model/Project';
import { footprintOf } from '../core/properties/bounds';
@@ -79,6 +80,12 @@ export class SceneViewport extends WorldViewport {
return this.state.activeScene;
}
/** The holder with the content of placed containers resolved in – what the view shows. */
private resolved(): ObjectHolder | undefined {
const holder = this.holder();
return holder && resolvedHolder(this.state.project, holder);
}
/** World coordinates at the centre of the view. */
worldCenter(): Vec2 {
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
@@ -173,13 +180,11 @@ export class SceneViewport extends WorldViewport {
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(holder, item.objectId);
if (object)
object.position = {
x: Math.round(item.start.x + dx),
y: Math.round(item.start.y + dy),
z: item.start.z,
};
objectRef(this.state.project, holder, item.objectId)?.setPosition({
x: Math.round(item.start.x + dx),
y: Math.round(item.start.y + dy),
z: item.start.z,
});
}
this.state.preview();
} else if (this.marquee) {
@@ -212,10 +217,10 @@ export class SceneViewport extends WorldViewport {
// 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(holder, item.objectId);
if (!object) continue;
const next = { ...object.position };
object.position = { ...item.start };
const ref = objectRef(this.state.project, holder, item.objectId);
if (!ref) continue;
const next = { ...ref.object.position };
ref.setPosition(item.start);
moves.push(new MoveSceneObjectCommand(drag.holderId, item.objectId, next));
}
if (moves.length === 1) this.state.execute(moves[0]!);
@@ -235,7 +240,7 @@ export class SceneViewport extends WorldViewport {
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
if (e.button !== 0) return;
const holder = this.holder();
const holder = this.resolved();
const object = holder && findObject(holder, objectId);
if (!holder || !object) return;
e.stopPropagation();
+6 -3
View File
@@ -1,6 +1,7 @@
import { isHandleEnabled, worldPositionOf } from '../core/graph';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { editorPosition, treeOrder } from '../core/model/hierarchy';
import { resolvedHolder } from '../core/model/instance';
import type { PropertyBag } from '../core/model/Properties';
import { findAsset, type ObjectHolder, type Project } from '../core/model/Project';
import { resolveTexture, type TextureRef } from '../core/model/texture';
@@ -33,7 +34,8 @@ interface DrawList {
/**
* 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.
* positions, with the content of placed containers resolved in. A container has no background of
* its own, so it keeps the fallback.
*/
export function collectHolderItems(
project: Project,
@@ -41,8 +43,9 @@ export function collectHolderItems(
fallbackBackground: number,
): DrawList {
if (!holder) return { items: [], background: fallbackBackground };
const view = resolvedHolder(project, holder);
const items: DrawItem[] = [];
for (const { object } of treeOrder(holder)) {
for (const { object } of treeOrder(view)) {
const asset = findAsset(project, object.assetId);
if (!asset) continue;
const props = resolveSceneObjectProps(asset, object);
@@ -50,7 +53,7 @@ export function collectHolderItems(
id: object.id,
type: asset.type,
name: object.name || asset.name,
...editorPosition(holder, object),
...editorPosition(view, object),
props,
parentId: object.parentId,
enabled: true,