306 lines
8.0 KiB
TypeScript
306 lines
8.0 KiB
TypeScript
import { deepClone } from '../util/deep-clone';
|
|
import { isObject } from '../util/merge-deep';
|
|
import {
|
|
MsonComponent,
|
|
MsonData,
|
|
MsonEvaluatedLocals,
|
|
MsonEvaluatedModel,
|
|
MsonExpression,
|
|
MsonLocals,
|
|
MsonSlot,
|
|
MsonVariable,
|
|
} from './mson.type';
|
|
import { ModelStore } from './store';
|
|
|
|
type MsonVariables = Record<string, number>;
|
|
|
|
/**
|
|
* This class is used to evaluate all variables within a MSON model definition.
|
|
*/
|
|
export class MsonEvaluate {
|
|
constructor(protected readonly store: ModelStore) {}
|
|
|
|
/**
|
|
* Evaluate a model, resolving all of its references and parameters.
|
|
* @param model MSON data to evaluate
|
|
* @param useLocals Override locals
|
|
* @returns Evaluated model, with (hopefully) all variables replaced.
|
|
*/
|
|
evaluateModel(
|
|
model: string,
|
|
useLocals?: MsonEvaluatedLocals,
|
|
): MsonEvaluatedModel {
|
|
const modelData = this.store.getModelByName(model);
|
|
if (!modelData) {
|
|
throw new Error(`Model ${model} not found for evaluation`);
|
|
}
|
|
|
|
// Deep copy the model
|
|
const localCopy = deepClone(modelData) as MsonEvaluatedModel;
|
|
localCopy.name = model;
|
|
localCopy._locals = { ...(localCopy.locals || {}) };
|
|
localCopy._data = { ...localCopy.data };
|
|
|
|
// Evaluate parents
|
|
if (modelData.parent) {
|
|
localCopy._parent = this.evaluateModel(modelData.parent);
|
|
|
|
// Overwrite / combine parent locals with current locals
|
|
localCopy._locals = {
|
|
...(localCopy._parent._locals || {}),
|
|
...localCopy._locals,
|
|
};
|
|
|
|
// Overwrite / combine parent data with current data
|
|
localCopy._data = {
|
|
...(localCopy._parent._data || {}),
|
|
...localCopy._data,
|
|
};
|
|
|
|
// Inherit texture parameters
|
|
localCopy.texture = {
|
|
...(localCopy._parent?.texture || {}),
|
|
...(localCopy.texture || {}),
|
|
};
|
|
}
|
|
|
|
// Overwrite / merge locals
|
|
if (useLocals) {
|
|
localCopy._locals = {
|
|
...localCopy._locals,
|
|
...useLocals,
|
|
};
|
|
}
|
|
|
|
// Evaluate locals with globally combined parameters
|
|
this.evaluateLocals(localCopy);
|
|
|
|
// Recursively substitute all locals within the data.
|
|
// Also, fill all slots.
|
|
localCopy._dataEvaluated = this.recursiveSubstitute(
|
|
localCopy._data,
|
|
localCopy._localsEvaluated,
|
|
);
|
|
|
|
return localCopy;
|
|
}
|
|
|
|
/**
|
|
* Evaluate a MSON expression (arithmetic)
|
|
* @param expression Mson Expression definition
|
|
* @param variables Scope variables
|
|
*/
|
|
protected evaluateExpression(
|
|
expression: MsonExpression,
|
|
variables: MsonVariables = {},
|
|
) {
|
|
if (!this.isExpression(expression)) {
|
|
throw new Error('Invalid expression');
|
|
}
|
|
|
|
let [src, oper, trg] = expression;
|
|
|
|
if (this.isExpression(src)) {
|
|
// TODO: resolve as unknown, probably with a tuple of some kind
|
|
src = this.evaluateExpression(
|
|
src as unknown as MsonExpression,
|
|
variables,
|
|
);
|
|
}
|
|
|
|
if (this.isExpression(trg)) {
|
|
// TODO: resolve as unknown, probably with a tuple of some kind
|
|
trg = this.evaluateExpression(
|
|
trg as unknown as MsonExpression,
|
|
variables,
|
|
);
|
|
}
|
|
|
|
src = this.substitute(src, variables);
|
|
trg = this.substitute(trg, variables);
|
|
|
|
// Assertions
|
|
if (isNaN(src)) {
|
|
throw new Error(
|
|
`Expression evaluation [${expression}] failed: ${src} is NaN`,
|
|
);
|
|
}
|
|
|
|
if (isNaN(trg)) {
|
|
throw new Error(
|
|
`Expression evaluation [${expression}] failed: ${trg} is NaN`,
|
|
);
|
|
}
|
|
|
|
if (!['+', '-', '*', '/', '%', '^'].includes(oper)) {
|
|
throw new Error(
|
|
`Expression evaluation [${expression}] failed: ${oper} is not supported`,
|
|
);
|
|
}
|
|
|
|
// Operators
|
|
switch (oper) {
|
|
case '+':
|
|
return src + trg;
|
|
case '-':
|
|
return src - trg;
|
|
case '*':
|
|
return src * trg;
|
|
case '/':
|
|
return src / trg;
|
|
case '%':
|
|
return src % trg;
|
|
case '^':
|
|
return Math.pow(src, trg);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine if input is an expression or not.
|
|
* @param input Questionable input
|
|
* @returns Is expression or not
|
|
*/
|
|
protected isExpression(input: MsonVariable | MsonExpression) {
|
|
return (
|
|
Array.isArray(input) &&
|
|
input.length === 3 &&
|
|
typeof input[1] === 'string' &&
|
|
!input[1].startsWith('#')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Substitute input with variables or return input.
|
|
* @param input Input to substitute
|
|
* @param variables Pool of variables
|
|
* @returns Substituted value
|
|
*/
|
|
protected substitute(input: MsonVariable, variables: MsonVariables = {}) {
|
|
if (typeof input !== 'string') return input;
|
|
const key = input.replace('#', '');
|
|
return variables[key] ?? input;
|
|
}
|
|
|
|
/**
|
|
* Substitute ALL variables recursively.
|
|
* Also fills slots. (TODO: separate)
|
|
* @param input Model data, a component or component metadata
|
|
* @param variables Variables to use
|
|
* @returns Substituted object
|
|
*/
|
|
protected recursiveSubstitute(
|
|
input: MsonData | MsonComponent,
|
|
variables: MsonVariables = {},
|
|
) {
|
|
const keyList = Object.keys(input);
|
|
|
|
// Handle slots, include the child model with current locals already applied.
|
|
const asSlot = input as MsonSlot;
|
|
if (keyList.includes('data') && typeof asSlot.data === 'string') {
|
|
const evaluateChild = this.evaluateModel(
|
|
asSlot.data as string,
|
|
asSlot.locals
|
|
? this.evaluateLocalsObject(asSlot.locals, variables)
|
|
: variables,
|
|
);
|
|
|
|
return {
|
|
type: 'mson:slot',
|
|
texture: evaluateChild.texture,
|
|
implementation: asSlot.implementation,
|
|
data: evaluateChild._dataEvaluated,
|
|
};
|
|
}
|
|
|
|
return keyList.reduce<any>((item, key) => {
|
|
const value = item[key];
|
|
|
|
// Do not substitute locals or generated values here.
|
|
if (key === 'locals' || key.startsWith('_')) return item;
|
|
|
|
if (isObject(value)) {
|
|
item[key] = this.recursiveSubstitute(value, variables);
|
|
}
|
|
|
|
if (typeof value === 'string' && value.startsWith('#')) {
|
|
item[key] = this.substitute(value, variables);
|
|
}
|
|
|
|
if (this.isExpression(value)) {
|
|
item[key] = this.evaluateExpression(value, variables);
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
item[key] = value.map((entry) =>
|
|
isObject(entry)
|
|
? this.recursiveSubstitute(entry, variables)
|
|
: this.substitute(entry, variables),
|
|
);
|
|
}
|
|
|
|
return item;
|
|
}, input);
|
|
}
|
|
|
|
/**
|
|
* Evaluate locals on model
|
|
* @param localCopy Local evaluated model
|
|
*/
|
|
protected evaluateLocals(localCopy: MsonEvaluatedModel) {
|
|
if (!localCopy._locals) {
|
|
if (localCopy._parent?._localsEvaluated) {
|
|
// Copy parent's globals
|
|
localCopy._localsEvaluated = deepClone(
|
|
localCopy._parent._localsEvaluated,
|
|
);
|
|
}
|
|
// Nothing more to evaluate
|
|
return;
|
|
}
|
|
|
|
localCopy._localsEvaluated = this.evaluateLocalsObject(
|
|
localCopy._locals,
|
|
localCopy._parent?._localsEvaluated,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Substitute locals' variables and evaluate expressions.
|
|
* @param locals Locals to evaluate
|
|
* @param parentEvals Parents evaluated locals, falls back to these.
|
|
* @returns Locals with variables substituted.
|
|
*/
|
|
protected evaluateLocalsObject(
|
|
locals: MsonLocals,
|
|
parentEvals: MsonEvaluatedLocals = {},
|
|
) {
|
|
return Object.keys(locals).reduce<MsonEvaluatedLocals>(
|
|
(pool, local) => {
|
|
const currentValue = locals?.[local];
|
|
if (currentValue === undefined) return pool;
|
|
|
|
// Number type just pass on
|
|
if (typeof currentValue === 'number') {
|
|
pool[local] = currentValue;
|
|
}
|
|
|
|
// Substitute strings, those are variables
|
|
if (typeof currentValue === 'string') {
|
|
pool[local] = this.substitute(currentValue, pool);
|
|
}
|
|
|
|
// Evaluate expressions
|
|
if (this.isExpression(currentValue)) {
|
|
pool[local] = this.evaluateExpression(
|
|
currentValue as MsonExpression,
|
|
pool,
|
|
);
|
|
}
|
|
|
|
return pool;
|
|
},
|
|
{ ...parentEvals },
|
|
);
|
|
}
|
|
}
|