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:
1 parent
9d93020bd8
commit
ead649eec3
37 files changed
+1878
-1006
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user