Consolidate duplicated editor code, trim comments, refresh README
Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus; the port and marker menus were the same list and are now one function. Value specs of the inspector and the list tab share valueSpecs.ts, and the remaining copies (compound commands, type glyphs, typed asset lookup, name rule, play/editor world setup, marquee) are merged. Four copies had already drifted apart; merging them fixes: - "Disconnect N links" in the port menu is one undo step, not N - "+ Add collider" is hidden on a placed container instance, as in the hierarchy menu, instead of always failing - a bool property coerced from the text "false" becomes false - a port command on an unknown node type reports instead of crashing Comment-only edits leave the bundle byte-identical. README corrected in 35 places (step limit, jump-run clock, suite duration, 409 body, ...). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9212e61c4c
commit
8f92a06c84
96 files changed
+999
-1261
No files matched your search
@@ -28,3 +28,8 @@ export class CompoundCommand implements Command {
|
||||
for (let i = this.commands.length - 1; i >= 0; i--) this.commands[i]!.undo(project);
|
||||
}
|
||||
}
|
||||
|
||||
/** A lone command stays itself, so the undo step keeps its own label. */
|
||||
export function oneStep(label: string, commands: readonly Command[]): Command {
|
||||
return commands.length === 1 ? commands[0]! : new CompoundCommand(label, commands);
|
||||
}
|
||||
@@ -20,11 +20,8 @@ import { nextAssetName } from './naming';
|
||||
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 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.
|
||||
* Turns the selected objects and their descendants into a container asset placed in their stead, the
|
||||
* template centred on the selection's bounding box (z = 0).
|
||||
*/
|
||||
export class CreateContainerCommand implements Command {
|
||||
readonly label = 'Create container';
|
||||
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
type GraphNode,
|
||||
isTerminalNode,
|
||||
isValidPortName,
|
||||
requireNodeDef,
|
||||
RUN_BEHAVIOR,
|
||||
} from '../graph';
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { type Removed, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireBehavior } from './require';
|
||||
import { nextFieldName } from './naming';
|
||||
import { requireBehavior, requireNode, requireNodeDef } from './require';
|
||||
|
||||
interface PortSite {
|
||||
graph: Graph;
|
||||
@@ -52,19 +52,17 @@ function edgesOnPort(site: PortSite, key: string): (edge: GraphEdge) => boolean
|
||||
: edge.from.node === site.nodeId && edge.from.port === key;
|
||||
}
|
||||
|
||||
/** The node whose declaration is being edited; a terminal is addressed by id like any other. */
|
||||
function requirePortNode(project: Project, assetId: string, nodeId: string): GraphNode {
|
||||
const node = findNode(requireBehavior(project, assetId).graph, nodeId);
|
||||
if (!node) throw new CommandError(`Node ${nodeId} not found`);
|
||||
return node;
|
||||
}
|
||||
|
||||
function nodeAt(site: PortSite): GraphNode | undefined {
|
||||
return site.graph.nodes.find((n) => n.id === site.nodeId);
|
||||
return findNode(site.graph, site.nodeId);
|
||||
}
|
||||
|
||||
function renameParam(site: PortSite, from: string, to: string): void {
|
||||
const node = nodeAt(site);
|
||||
/** The port's edges follow its new key, and so does an input's typed-in value, which lives under it. */
|
||||
function carryPort(site: PortSite, from: string, to: string): void {
|
||||
for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) {
|
||||
if (site.asInput) edge.to.port = to;
|
||||
else edge.from.port = to;
|
||||
}
|
||||
const node = site.asInput ? nodeAt(site) : undefined;
|
||||
const value = node?.params[from];
|
||||
if (!node || value === undefined) return;
|
||||
node.params[to] = value;
|
||||
@@ -90,7 +88,7 @@ export class AddPortsCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const keys = new Set((node.ports ?? []).map((p) => p.key));
|
||||
for (const port of this.ports) {
|
||||
if (!isValidPortName(port.key)) throw new CommandError(INVALID_NAME);
|
||||
@@ -101,7 +99,7 @@ export class AddPortsCommand implements Command {
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const added = new Set(this.ports.map((p) => p.key));
|
||||
node.ports = (node.ports ?? []).filter((p) => !added.has(p.key));
|
||||
}
|
||||
@@ -129,7 +127,7 @@ export class RemovePortCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const sites = portSites(project, this.assetId, node);
|
||||
const ports = [...(node.ports ?? [])];
|
||||
const removed = removeFirst(ports, (p) => p.key === this.key);
|
||||
@@ -152,7 +150,7 @@ export class RemovePortCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const ports = [...(node.ports ?? [])];
|
||||
restoreAt(ports, [this.removed]);
|
||||
node.ports = ports;
|
||||
@@ -187,22 +185,14 @@ export class RenamePortCommand implements Command {
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
if (from === to) return;
|
||||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const sites = portSites(project, this.assetId, node);
|
||||
if (!isValidPortName(to)) throw new CommandError(INVALID_NAME);
|
||||
const ports = node.ports ?? [];
|
||||
if (!ports.some((p) => p.key === from)) throw new CommandError(`Port "${from}" not found`);
|
||||
if (ports.some((p) => p.key === to)) throw new CommandError(`"${to}" already exists`);
|
||||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||||
for (const site of sites) {
|
||||
for (const edge of site.graph.edges.filter(edgesOnPort(site, from))) {
|
||||
if (site.asInput) edge.to.port = to;
|
||||
else edge.from.port = to;
|
||||
}
|
||||
// An input keeps its typed-in value under the port key, so a rename has to carry it along
|
||||
// or the value silently falls back to the default.
|
||||
if (site.asInput) renameParam(site, from, to);
|
||||
}
|
||||
for (const site of sites) carryPort(site, from, to);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,17 +200,12 @@ export class RenamePortCommand implements Command {
|
||||
function renamePortOn(graph: Graph, node: GraphNode, from: string, to: string): void {
|
||||
const ports = node.ports ?? [];
|
||||
if (from === to || !ports.some((p) => p.key === from)) return;
|
||||
const site: PortSite = {
|
||||
graph,
|
||||
nodeId: node.id,
|
||||
asInput: requireNodeDef(node.type).declaresPorts === 'inputs',
|
||||
};
|
||||
node.ports = ports.map((p) => (p.key === from ? { ...p, key: to } : p));
|
||||
for (const edge of graph.edges.filter(edgesOnPort(site, from))) {
|
||||
if (site.asInput) edge.to.port = to;
|
||||
else edge.from.port = to;
|
||||
}
|
||||
if (site.asInput) renameParam(site, from, to);
|
||||
carryPort(
|
||||
{ graph, nodeId: node.id, asInput: requireNodeDef(node.type).declaresPorts === 'inputs' },
|
||||
from,
|
||||
to,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,7 +227,7 @@ export class SetVariablePortCommand implements Command {
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const node = requirePortNode(project, this.assetId, this.nodeId);
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const ports = node.ports ?? [];
|
||||
const current = ports.find((p) => p.key === this.from);
|
||||
if (!current) throw new CommandError(`Port "${this.from}" not found`);
|
||||
@@ -254,7 +239,7 @@ export class SetVariablePortCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.previous) return;
|
||||
this.swap(project, requirePortNode(project, this.assetId, this.nodeId), this.port.key, this.previous);
|
||||
this.swap(project, requireNode(project, this.assetId, this.nodeId), this.port.key, this.previous);
|
||||
}
|
||||
|
||||
private swap(project: Project, node: GraphNode, from: string, to: DeclaredPort): void {
|
||||
@@ -273,6 +258,5 @@ export function renameVariablePorts(graph: Graph, from: string, to: string): voi
|
||||
|
||||
/** "in1", "in2", … – the first port name the terminal node does not use yet. */
|
||||
export function nextPortName(node: GraphNode, base: string): string {
|
||||
const taken = new Set((node.ports ?? []).map((p) => p.key));
|
||||
for (let i = 1; ; i++) if (!taken.has(`${base}${i}`)) return `${base}${i}`;
|
||||
return nextFieldName(node.ports ?? [], base);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type { Asset, BehaviorAsset } from '../model/Asset';
|
||||
import { findObject, type ObjectTree } from '../model/hierarchy';
|
||||
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
|
||||
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../model/Project';
|
||||
import { findAsset, findAssetOf, findObjectHolder, type ObjectHolder, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { propDef, type PropertyDef } from '../properties/PropertySchema';
|
||||
import { CommandError } from './Command';
|
||||
@@ -51,8 +51,8 @@ export function requireStructural(project: Project, holder: ObjectTree, objectId
|
||||
}
|
||||
|
||||
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`);
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
if (!asset) throw new CommandError(`Behavior ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
|
||||
@@ -214,10 +214,6 @@ export class ClearOverrideCommand extends OverrideCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed
|
||||
* for the new parent.
|
||||
*/
|
||||
/**
|
||||
* Moves an object among its siblings. Sibling order is the order the stored list happens to have
|
||||
* (`treeOrder`), so the move is a splice – and because objects sharing a `z` fall back to that
|
||||
@@ -262,6 +258,10 @@ export class ReorderObjectCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed
|
||||
* for the new parent.
|
||||
*/
|
||||
export class SetParentCommand implements Command {
|
||||
readonly label = 'Set parent';
|
||||
private previous: { parentId: string; position: Position } = {
|
||||
|
||||
@@ -12,7 +12,8 @@ import type { Project } from '../model/Project';
|
||||
import {
|
||||
coerceValue,
|
||||
emptyFieldValue,
|
||||
isCommonProp,
|
||||
isValidFieldName,
|
||||
isValidVariableName,
|
||||
type ListItemKind,
|
||||
type PrimitiveFieldDef,
|
||||
primitiveFieldsOf,
|
||||
@@ -146,16 +147,6 @@ export class RenameVariableCommand implements Command {
|
||||
const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _';
|
||||
const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _';
|
||||
|
||||
/** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */
|
||||
export function isValidVariableName(name: string): boolean {
|
||||
return isValidFieldName(name) && !isCommonProp(name);
|
||||
}
|
||||
|
||||
/** A field lives inside its struct, so the reserved property keys do not apply to it. */
|
||||
export function isValidFieldName(name: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
||||
}
|
||||
|
||||
/** Where declared fields live: on a struct variable, or on the struct items of a list variable. */
|
||||
interface FieldOwner {
|
||||
/** True for a list of structs, where a field may not itself be a list (`ListFieldDef`). */
|
||||
@@ -164,8 +155,12 @@ interface FieldOwner {
|
||||
setFields(next: readonly StructFieldDef[]): void;
|
||||
}
|
||||
|
||||
function variableOf(project: Project, assetId: string, key: string): PropertyDef | undefined {
|
||||
return requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
}
|
||||
|
||||
function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner {
|
||||
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
const def = variableOf(project, assetId, key);
|
||||
if (def?.kind === 'struct')
|
||||
return {
|
||||
inList: false,
|
||||
@@ -182,10 +177,8 @@ function requireFieldOwner(project: Project, assetId: string, key: string): Fiel
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value of one variable that carries a struct: the declared default, the behavior's own, and
|
||||
* each instance override – and for a list of structs, every item of each of those. A field is added
|
||||
* to or taken from all of them at once, so no value keeps a shape its declaration no longer
|
||||
* promises. The `id` is what pairs a site up again when the change is undone.
|
||||
* One struct-carrying value of a variable (default, own, override, or list item); a field change hits
|
||||
* all of them at once so none keeps a stale shape. `id` pairs a site up again on undo.
|
||||
*/
|
||||
interface StructSite {
|
||||
id: string;
|
||||
@@ -194,7 +187,7 @@ interface StructSite {
|
||||
}
|
||||
|
||||
function structSites(project: Project, assetId: string, key: string): StructSite[] {
|
||||
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
const def = variableOf(project, assetId, key);
|
||||
const holders = valueHolders(project, assetId, key).filter(
|
||||
// An override the object does not have stays absent: writing one here would invent a deviation.
|
||||
(h) => !h.optional || (def?.kind === 'list' ? isList(h.read()) : isStruct(h.read())),
|
||||
@@ -440,7 +433,7 @@ function requireListDef(
|
||||
assetId: string,
|
||||
key: string,
|
||||
): Extract<PropertyDef, { kind: 'list' }> {
|
||||
const def = requireBehavior(project, assetId).variables.find((v) => v.key === key);
|
||||
const def = variableOf(project, assetId, key);
|
||||
if (def?.kind !== 'list') throw new CommandError(`Variable "${key}" is not a list`);
|
||||
return def;
|
||||
}
|
||||
@@ -57,7 +57,6 @@ export class GraphInterpreter {
|
||||
return this.graph.nodes.filter((n) => n.type === type);
|
||||
}
|
||||
|
||||
/** Fires every event node of `type` passing `filter`, seeding its outputs with `outputs`. */
|
||||
fire(
|
||||
type: string,
|
||||
ctx: NodeContext,
|
||||
|
||||
@@ -82,10 +82,8 @@ export interface NodeDef {
|
||||
*/
|
||||
derivePorts?(source: PortSource): DerivedShape;
|
||||
/**
|
||||
* Pure data nodes are evaluated on demand when a downstream input needs their value; exec nodes
|
||||
* run when the flow reaches them. Event nodes have no `run` – the interpreter seeds their outputs.
|
||||
* `def` is this definition as resolved for the node at hand, which is the only way a node with
|
||||
* derived ports can know how many it has.
|
||||
* Event nodes have no `run` – the interpreter seeds their outputs. `def` is resolved for the node at
|
||||
* hand, the only way a node with derived ports knows how many it has.
|
||||
*/
|
||||
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag, def: NodeDef): RunResult;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* The auto-repeat of `On key down`, kept apart from the runtime so it can be checked without a
|
||||
* world: given how long the key has been held, how often does the node fire this frame?
|
||||
*
|
||||
* The browser's own key auto-repeat is deliberately not used (`Input.press` drops it): its delay
|
||||
* and rate are an operating-system setting, not something a game can tune.
|
||||
* The auto-repeat of `On key down`, kept apart from the runtime so it can be tested without a world.
|
||||
* The browser's own repeat is dropped (`Input.press`): its delay and rate are an OS setting, not tunable.
|
||||
*/
|
||||
|
||||
/** Smallest repeat gap, mirroring `On timer`; 0 keeps its own meaning of "every frame". */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { textOf } from '../model/Properties';
|
||||
import { isValidFieldName } from '../properties/PropertySchema';
|
||||
import { type DeclaredPort, declaredPortType, type Graph, type GraphNode } from './Graph';
|
||||
import type { BehaviorLookup } from './lookup';
|
||||
import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry';
|
||||
@@ -22,7 +23,7 @@ export function declaresVariablePorts(type: string): boolean {
|
||||
|
||||
/** Identifier-like, and not `exec`: that key belongs to the fixed flow port of every terminal node. */
|
||||
export function isValidPortName(name: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) && name !== 'exec';
|
||||
return isValidFieldName(name) && name !== 'exec';
|
||||
}
|
||||
|
||||
/** The single start (or end) node of a graph; a second one is ignored. */
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
booleanOf,
|
||||
} from '../model/Properties';
|
||||
import { ENTITY_HANDLE, type EntityHandle } from './NodeContext';
|
||||
|
||||
@@ -57,8 +58,7 @@ function asPrimitive(value: Value | undefined): number | string | boolean {
|
||||
}
|
||||
|
||||
export function asBoolean(value: Value | undefined): boolean {
|
||||
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
|
||||
return Boolean(value);
|
||||
return booleanOf(value);
|
||||
}
|
||||
|
||||
/** Entities read as their name, so a handle can be logged or joined into text directly. */
|
||||
|
||||
@@ -23,6 +23,15 @@ export function findAsset(project: Project, assetId: string): Asset | undefined
|
||||
return project.assets.find((a) => a.id === assetId);
|
||||
}
|
||||
|
||||
export function findAssetOf<T extends Asset['type']>(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
type: T,
|
||||
): Extract<Asset, { type: T }> | undefined {
|
||||
const asset = findAsset(project, assetId);
|
||||
return asset?.type === type ? (asset as Extract<Asset, { type: T }>) : undefined;
|
||||
}
|
||||
|
||||
export function findScene(project: Project, sceneId: string): SceneAsset | undefined {
|
||||
const asset = findAsset(project, sceneId);
|
||||
return asset && isSceneAsset(asset) ? asset : undefined;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* A list holds primitives or structs. Depth stops there and it stops in the declaration, not by
|
||||
* convention: a list says what its items are (`ListItemKind`, struct included), a struct field
|
||||
* says what it holds (`FieldKind`, primitives only). So `list → struct → primitive` is the deepest
|
||||
* a stored value goes, which is what keeps the deep compare and the editor finite.
|
||||
*/
|
||||
/** The declarations cap depth: `list → struct → primitive` is the deepest a stored value goes. */
|
||||
export type ListValue = readonly (number | string | boolean | StructValue)[];
|
||||
|
||||
/** What a struct field may hold. A field is never itself a struct – see `ListValue`. */
|
||||
@@ -40,6 +35,19 @@ export function textOf(value: PropertyValue | undefined): string {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** The spelling of a text counts: `Boolean('false')` would be true. */
|
||||
export function booleanOf(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return Boolean(value);
|
||||
const s = value.trim().toLowerCase();
|
||||
return !(s === '' || s === 'false' || s === '0' || s === 'no');
|
||||
}
|
||||
|
||||
/** A number, or a hex text with or without `#`, clamped to 0…0xffffff. */
|
||||
export function colorOf(value: PropertyValue, fallback: number): number {
|
||||
const n = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16);
|
||||
return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : fallback;
|
||||
}
|
||||
|
||||
/** Values are compared by content, because two equal lists (or structs) are never the same object. */
|
||||
export function sameValue(a: PropertyValue | undefined, b: PropertyValue | undefined): boolean {
|
||||
if (isList(a) || isList(b)) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BehaviorLookup } from '../graph/lookup';
|
||||
import { resolveAssetProps } from '../properties/resolve';
|
||||
import { findAsset, type Project } from './Project';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
|
||||
/**
|
||||
* What a `Run behavior` node needs from the project. `defaults` are the asset's variable values;
|
||||
@@ -8,8 +8,8 @@ import { findAsset, type Project } from './Project';
|
||||
*/
|
||||
export function behaviorLookup(project: Project): BehaviorLookup {
|
||||
return (assetId) => {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (!asset || asset.type !== 'behavior') return null;
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
if (!asset) return null;
|
||||
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) };
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* A placed container's content is resolved from its template on every read. Per-instance changes live
|
||||
* in sparse `instanceEdits` keyed by template-id path; derived ids `<instance>~<path>` are never stored.
|
||||
*/
|
||||
import type { Asset, ContainerAsset } from './Asset';
|
||||
import type { ContainerAsset } from './Asset';
|
||||
import type { ObjectTree } from './hierarchy';
|
||||
import {
|
||||
type Axis,
|
||||
@@ -19,7 +13,7 @@ import {
|
||||
setBagValue,
|
||||
type Vec2,
|
||||
} from './Properties';
|
||||
import { findAsset, type ObjectHolder, objectHolders, type Project } from './Project';
|
||||
import { findAssetOf, 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. */
|
||||
@@ -77,8 +71,7 @@ function mergeEdits(
|
||||
}
|
||||
|
||||
export function containerAssetOf(project: Project, object: SceneObject): ContainerAsset | undefined {
|
||||
const asset: Asset | undefined = findAsset(project, object.assetId);
|
||||
return asset?.type === 'container' ? asset : undefined;
|
||||
return findAssetOf(project, object.assetId, 'container');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -203,12 +196,8 @@ export interface InstanceTarget {
|
||||
asset: ContainerAsset;
|
||||
template: SceneObject;
|
||||
/**
|
||||
* What the object would show without this instance's edit: the template with the edits of every
|
||||
* container in between already woven in. Only this is a base a value can fall back to, so it –
|
||||
* not `template` – decides whether an edit deviates. The two differ once containers nest.
|
||||
*
|
||||
* Built fresh on every read, because "apply" writes into `template` and then asks whether the
|
||||
* edit still deviates – a base captured beforehand would answer for the old template.
|
||||
* The template with the edits of every container in between woven in – this, not `template`, decides
|
||||
* whether an edit deviates. Built fresh on every read, since "apply" writes into `template` first.
|
||||
*/
|
||||
readonly base: SceneObject;
|
||||
}
|
||||
@@ -219,8 +208,6 @@ export function instanceTargetOf(
|
||||
holder: ObjectTree,
|
||||
objectId: string,
|
||||
): InstanceTarget | undefined {
|
||||
// Unpacking keeps the derived ids, so the shape of the id alone still looks like content of a
|
||||
// template afterwards. What decides is whether the object is spelled out in the holder.
|
||||
if (!isTemplateContent(holder, objectId)) return undefined;
|
||||
const split = splitInstanceId(objectId);
|
||||
const instance = split && holder.objects.find((o) => o.id === split.instanceId);
|
||||
@@ -388,10 +375,8 @@ export function restoreObject(memo: ObjectMemo): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* One sparse override bag with the asset whose schema governs it: a placed object's own, or a container
|
||||
* instance's edits for its content. Project-wide rewrites must cover both or they miss container content.
|
||||
*/
|
||||
export interface OverrideSite {
|
||||
holder: ObjectHolder;
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type PropertyValue,
|
||||
type StructValue,
|
||||
textOf,
|
||||
booleanOf,
|
||||
colorOf,
|
||||
} from '../model/Properties';
|
||||
|
||||
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
||||
@@ -81,12 +83,7 @@ export interface PrimitiveFieldDef {
|
||||
kind: FieldKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* A list held by a struct field. Its items are primitives, and that asymmetry is the whole point:
|
||||
* a list may hold structs and a struct may hold lists, so only forbidding one of the two here
|
||||
* keeps the storable shapes finite. `list → struct → primitive` and `struct → list → primitive`
|
||||
* are the two deepest, which is exactly what `asStoredValue` lets through.
|
||||
*/
|
||||
/** A list held by a struct field; its items are primitives only, which keeps storable shapes finite. */
|
||||
export interface ListFieldDef {
|
||||
key: string;
|
||||
kind: 'list';
|
||||
@@ -116,6 +113,16 @@ export function isCommonProp(key: string): boolean {
|
||||
return COMMON_KEYS.has(key);
|
||||
}
|
||||
|
||||
/** A field lives inside its struct, so the reserved property keys do not apply to it. */
|
||||
export function isValidFieldName(name: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
||||
}
|
||||
|
||||
/** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */
|
||||
export function isValidVariableName(name: string): boolean {
|
||||
return isValidFieldName(name) && !isCommonProp(name);
|
||||
}
|
||||
|
||||
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
|
||||
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
|
||||
@@ -262,10 +269,6 @@ const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
|
||||
color: 0,
|
||||
};
|
||||
|
||||
export function emptyField(kind: FieldKind): number | string | boolean {
|
||||
return EMPTY_FIELD[kind];
|
||||
}
|
||||
|
||||
export function emptyFieldValue(field: StructFieldDef): FieldValue {
|
||||
return field.kind === 'list' ? [] : EMPTY_FIELD[field.kind];
|
||||
}
|
||||
@@ -283,7 +286,6 @@ export function structOf(fields: readonly StructFieldDef[], from: PropertyValue
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Clamps / coerces a raw value so it satisfies the definition. */
|
||||
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
|
||||
switch (def.kind) {
|
||||
case 'number': {
|
||||
@@ -293,12 +295,10 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
||||
if (def.max !== undefined) n = Math.min(def.max, n);
|
||||
return n;
|
||||
}
|
||||
case 'color': {
|
||||
const n = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16);
|
||||
return Number.isFinite(n) ? Math.max(0, Math.min(0xffffff, Math.floor(n))) : def.default;
|
||||
}
|
||||
case 'color':
|
||||
return colorOf(value, def.default);
|
||||
case 'boolean':
|
||||
return Boolean(value);
|
||||
return booleanOf(value);
|
||||
case 'text':
|
||||
case 'key':
|
||||
case 'asset':
|
||||
@@ -333,16 +333,8 @@ export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): F
|
||||
/** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */
|
||||
export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean {
|
||||
if (items === 'text') return textOf(value);
|
||||
if (items === 'boolean') {
|
||||
if (typeof value === 'boolean') return value;
|
||||
// A list is typed as text, so the spelling has to count: Boolean('false') would be true.
|
||||
const s = textOf(value).trim().toLowerCase();
|
||||
return !(s === '' || s === 'false' || s === '0' || s === 'no');
|
||||
}
|
||||
if (items === 'color') {
|
||||
const c = typeof value === 'number' ? value : parseInt(textOf(value).replace('#', ''), 16);
|
||||
return Number.isFinite(c) ? Math.max(0, Math.min(0xffffff, Math.floor(c))) : 0;
|
||||
}
|
||||
if (items === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
|
||||
if (items === 'color') return colorOf(value, 0);
|
||||
const n = typeof value === 'number' ? value : Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
@@ -50,10 +50,8 @@ export function matchesPixelSize(size: PixelSize, props: PropertyBag): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the sprite already has the picture's proportions. Compared as a cross product with a
|
||||
* relative tolerance, because fitting one side and then asking about the other must not answer
|
||||
* "no" over a rounding error: 3135 of the 40 000 sizes up to 200 × 200 fail an exact test after
|
||||
* their own fit (1 × 11 at height 100 gives 9.090909090909092, whose cross product is 100.000…1).
|
||||
* Cross product with a relative tolerance, because 3135 of the 40 000 sizes up to 200 × 200 fail an
|
||||
* exact test right after their own fit (rounding).
|
||||
*/
|
||||
export function matchesAspect(size: PixelSize, props: PropertyBag): boolean {
|
||||
const w = Number(props.width);
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* The `runInEditor` switch of a behavior (see `ASSET_SCHEMAS.behavior`). A behavior with it on
|
||||
* keeps running while the project is *not* playing, and then it edits the saved model: what it
|
||||
* moves or writes is a real change that is saved and is not on the undo stack. That is what makes
|
||||
* the switch useful (a behavior can build or arrange content) and why it is off by default.
|
||||
*/
|
||||
/** A behavior's `runInEditor` switch: its writes are real, saved and not undoable, hence off by default. */
|
||||
import type { ObjectTree } from '../model/hierarchy';
|
||||
import { resolveObjects } from '../model/instance';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
|
||||
@@ -26,10 +26,6 @@ export function resolveSceneObjectProps(asset: Asset, object: SceneObject): Prop
|
||||
return bag;
|
||||
}
|
||||
|
||||
export function isOverridden(object: SceneObject, key: string): boolean {
|
||||
return object.overrides[key] !== undefined;
|
||||
}
|
||||
|
||||
/** Whether the asset's own value differs from the schema default – the asset-level override. */
|
||||
export function isModified(asset: Asset, key: string): boolean {
|
||||
const def = propDef(asset, key);
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
COMMON_PROPS,
|
||||
FIELD_KINDS,
|
||||
type FieldKind,
|
||||
isCommonProp,
|
||||
isValidFieldName,
|
||||
isValidVariableName,
|
||||
LIST_ITEM_KINDS,
|
||||
type ListItemKind,
|
||||
type PropertyDef,
|
||||
@@ -174,7 +175,7 @@ function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
? (v.kind as VariableKind)
|
||||
: 'number';
|
||||
// Common property keys are reserved – a variable of that name would shadow the switch.
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key) || isCommonProp(key)) continue;
|
||||
if (!isValidVariableName(key) || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const items = (LIST_ITEM_KINDS as readonly string[]).includes(String(v.items))
|
||||
? (v.items as ListItemKind)
|
||||
@@ -193,7 +194,7 @@ function normalizeStructFields(raw: unknown): StructFieldDef[] {
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
const f = asRecord(item);
|
||||
const key = typeof f.key === 'string' ? f.key : '';
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
|
||||
if (!isValidFieldName(key) || seen.has(key)) continue;
|
||||
const field = readField(key, f);
|
||||
if (!field) continue;
|
||||
seen.add(key);
|
||||
|
||||
Reference in new issue
Block a user