/** * Evaluates OrcaSlicer's `toggle_print_fff_options` enable/disable rules so our * process-settings panel greys out the same fields the real slicer does. * * The vendored `process-toggle-rules.json` carries the rules verbatim from the * C++ source: each rule is a list of option keys plus an `enable_if` expression * written in C++, referencing named locals that are themselves C++ expressions. * Rather than hand-translate a subset (which is what upstream's own evaluator * does — 11 of 68 locals, the rest silently enabled), this interprets the * expressions directly and resolves locals recursively, so a local defined in * terms of three other locals costs nothing extra to support. * * The cardinal rule is **fail open**: anything we cannot decide with certainty * leaves the field enabled. A wrongly-greyed control hides a setting the user * needs and looks like a bug; a wrongly-enabled one merely lets them set * something the slicer will ignore, which is the pre-existing behaviour of every * other settings surface in Bambuddy. Every `undefined` return below is that * rule being applied, not an oversight. * * Deliberately not `eval` / `new Function`: the expressions are vendored data * rather than user input, but the frontend runs under a CSP without * `unsafe-eval` and a 120-line recursive-descent parser is easier to test than * a regex pipeline that rewrites C++ into JavaScript. */ import type { ProcessSchema, SettingValue } from '../types/slicerSettings'; /** * A read of an enum-typed option, carrying the key so a comparison against a * C++ enumerator can be checked against that option's declared values. */ interface EnumRead { enumKey: string; value: string | undefined; } /** A resolved expression value. `undefined` means "could not determine". */ type Value = boolean | number | string | EnumRead | undefined; const isEnumRead = (v: Value): v is EnumRead => typeof v === 'object' && v !== null && 'enumKey' in v; /** A bare C++ enumerator (`ipGyroid`, `IroningType::NoIroning`) seen in an expression. */ const ENUM_SYMBOL = 'enum:'; // --- Config access --------------------------------------------------------- export interface ConfigReader { /** Raw value for a key: the user's override if set, else the schema default. */ get(key: string): Value; has(key: string): boolean; } /** Numeric view of a value: "20%" -> 20, [500] -> 500, "0.42" -> 0.42. */ function asNumber(v: Value): number | undefined { if (typeof v === 'number') return v; if (typeof v === 'boolean') return v ? 1 : 0; if (typeof v !== 'string') return undefined; const n = Number.parseFloat(v); return Number.isFinite(n) ? n : undefined; } function asBoolean(v: Value): boolean | undefined { if (typeof v === 'boolean') return v; if (typeof v === 'number') return v !== 0; if (v === '1' || v === 'true') return true; if (v === '0' || v === 'false') return false; return undefined; } /** * Reads settings with schema defaults behind them. Vector options (`coFloats` * and friends) are per-extruder; every condition in the rule set tests the * first entry, which is what `opt_float_nullable(key, variant_index)` reads for * the active variant. */ export function makeConfigReader(settings: Record, schema: ProcessSchema): ConfigReader { const read = (key: string): Value => { let v: unknown = settings[key]; if (v === undefined || v === '') v = schema[key]?.default; if (Array.isArray(v)) v = v[0]; if (typeof v === 'boolean' || typeof v === 'number' || typeof v === 'string') return v; return undefined; }; return { get: read, has: (key) => key in schema }; } // --- Tokenizer ------------------------------------------------------------- type Token = { kind: 'num'; value: number } | { kind: 'str'; value: string } | { kind: 'id'; value: string } | { kind: 'op'; value: string }; // Longest-first: `->` must be tried before `-`, `<=` before `<`. const OPERATORS = ['->', '||', '&&', '==', '!=', '<=', '>=', '(', ')', ',', '<', '>', '!']; function tokenize(src: string): Token[] | undefined { const tokens: Token[] = []; let i = 0; while (i < src.length) { const c = src[i]; if (c === ' ' || c === '\t' || c === '\n') { i += 1; continue; } if (c === '"') { const end = src.indexOf('"', i + 1); if (end < 0) return undefined; tokens.push({ kind: 'str', value: src.slice(i + 1, end) }); i = end + 1; continue; } // C++ float literals carry an `f` suffix (`0.3f`) that the extractor left // intact in a few min/max bounds and defaults. const num = /^\d+(\.\d*)?f?/.exec(src.slice(i)); if (num && /^[\d]/.test(c)) { tokens.push({ kind: 'num', value: Number.parseFloat(num[0]) }); i += num[0].length; continue; } const op = OPERATORS.find((o) => src.startsWith(o, i)); if (op) { tokens.push({ kind: 'op', value: op }); i += op.length; continue; } // Identifiers, including the `->`, `::`, `<>` decorations of the C++ // accessor forms; the parser strips those apart below. const id = /^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)*/.exec(src.slice(i)); if (id) { tokens.push({ kind: 'id', value: id[0] }); i += id[0].length; continue; } return undefined; // Unknown character — fail open. } return tokens; } // --- Parser / evaluator ---------------------------------------------------- /** Accessor names that read a config key named by their first string argument. */ const ACCESSORS = new Set([ 'opt_bool', 'opt_int', 'opt_float', 'opt_float_nullable', 'opt_int_nullable', 'opt_bool_nullable', 'opt_enum', 'opt_string', 'option', 'has', ]); class Evaluator { private tokens: Token[] = []; private pos = 0; private readonly cfg: ConfigReader; private readonly locals: Record; private readonly schema: ProcessSchema; /** Locals currently being resolved — guards the (unlikely) cyclic definition. */ private readonly resolving: Set; private readonly memo: Map; constructor(cfg: ConfigReader, locals: Record, schema: ProcessSchema, resolving: Set, memo: Map) { this.cfg = cfg; this.locals = locals; this.schema = schema; this.resolving = resolving; this.memo = memo; } evaluate(expr: string): Value { const tokens = tokenize(expr); if (!tokens || tokens.length === 0) return undefined; this.tokens = tokens; this.pos = 0; const value = this.parseOr(); // Trailing tokens mean we misread the grammar; don't trust a partial parse. if (this.pos !== this.tokens.length) return undefined; return value; } private peek(): Token | undefined { return this.tokens[this.pos]; } private eatOp(op: string): boolean { const t = this.peek(); if (t && t.kind === 'op' && t.value === op) { this.pos += 1; return true; } return false; } private parseOr(): Value { let left = this.parseAnd(); while (this.eatOp('||')) { const right = this.parseAnd(); const l = asBoolean(left); const r = asBoolean(right); // Short-circuit truth survives an unknown operand: `true || ???` is true. if (l === true || r === true) left = true; else if (l === undefined || r === undefined) left = undefined; else left = l || r; } return left; } private parseAnd(): Value { let left = this.parseComparison(); while (this.eatOp('&&')) { const right = this.parseComparison(); const l = asBoolean(left); const r = asBoolean(right); if (l === false || r === false) left = false; else if (l === undefined || r === undefined) left = undefined; else left = l && r; } return left; } private parseComparison(): Value { const left = this.parseUnary(); for (const op of ['==', '!=', '<=', '>=', '<', '>']) { if (this.eatOp(op)) { const right = this.parseUnary(); return compare(left, right, op, this.schema); } } return left; } private parseUnary(): Value { if (this.eatOp('!')) { const v = asBoolean(this.parseUnary()); return v === undefined ? undefined : !v; } return this.parsePrimary(); } private parsePrimary(): Value { const t = this.peek(); if (!t) return undefined; if (t.kind === 'num') { this.pos += 1; return t.value; } if (t.kind === 'str') { this.pos += 1; return t.value; } if (t.kind === 'op' && t.value === '(') { this.pos += 1; const v = this.parseOr(); if (!this.eatOp(')')) return undefined; return v; } if (t.kind !== 'id') return undefined; this.pos += 1; if (t.value === 'true') return true; if (t.value === 'false') return false; // `config->opt_bool("key")`, `config->option("key")->value` if (t.value === 'config') return this.parseConfigAccess(); // A bare identifier is either a named local or a C++ enum symbol. const local = this.locals[t.value]; if (local !== undefined) return this.resolveLocal(t.value, local); // Not a local, so it is a C++ enumerator; `compare` decides whether it can // be matched against the other side's declared enum values. return `${ENUM_SYMBOL}${t.value}`; } /** Consumes the `->accessor("key")` tail after a `config` identifier. */ private parseConfigAccess(): Value { if (!this.eatOp('->')) return undefined; const name = this.peek(); if (!name || name.kind !== 'id' || !ACCESSORS.has(name.value)) return undefined; this.pos += 1; // Optional `` / `` template argument. if (this.eatOp('<')) { let depth = 1; while (depth > 0) { const tok = this.peek(); if (!tok) return undefined; this.pos += 1; if (tok.kind === 'op' && tok.value === '<') depth += 1; if (tok.kind === 'op' && tok.value === '>') depth -= 1; } } if (!this.eatOp('(')) return undefined; const arg = this.peek(); if (!arg || arg.kind !== 'str') return undefined; this.pos += 1; const key = arg.value; // Skip any further arguments (`, variant_index`, `, 0`). while (this.eatOp(',')) { let depth = 0; for (;;) { const tok = this.peek(); if (!tok) return undefined; if (tok.kind === 'op' && tok.value === '(') depth += 1; if (tok.kind === 'op' && tok.value === ')') { if (depth === 0) break; depth -= 1; } if (tok.kind === 'op' && tok.value === ',' && depth === 0) break; this.pos += 1; } } if (!this.eatOp(')')) return undefined; // `config->option("key")->value` — consume the trailing member access. if (this.eatOp('->')) { const member = this.peek(); if (!member || member.kind !== 'id') return undefined; this.pos += 1; } if (name.value === 'has') return this.cfg.has(key); const raw = this.cfg.get(key); // Tag reads of enum options so a comparison against a C++ enumerator can // validate its transliteration against this option's declared values. if (this.schema[key]?.enum_values) { return { enumKey: key, value: typeof raw === 'string' ? raw : undefined }; } return raw; } private resolveLocal(name: string, source: string): Value { const cached = this.memo.get(name); if (cached !== undefined || this.memo.has(name)) return cached; if (this.resolving.has(name)) return undefined; this.resolving.add(name); const nested = new Evaluator(this.cfg, this.locals, this.schema, this.resolving, this.memo); const value = nested.evaluate(source); this.resolving.delete(name); this.memo.set(name, value); return value; } } /** * Compares two resolved values. * * The interesting case is an enum option tested against a C++ enumerator — * `config->opt_enum("ironing_type") != IroningType::NoIroning`. * OrcaSlicer's enumerator spellings and its serialised config values are * related but not identical (`btNoBrim` -> `no_brim`, `NoIroning` -> * `no ironing`), so we generate the plausible spellings and only trust the * result when exactly one of them is a value the option actually declares. * A transliteration that matches nothing yields `undefined`, not a confident * `false` that would grey out a field for the wrong reason. */ function compare(left: Value, right: Value, op: string, schema: ProcessSchema): Value { const symbolSide = typeof left === 'string' && left.startsWith(ENUM_SYMBOL) ? left : typeof right === 'string' && right.startsWith(ENUM_SYMBOL) ? right : undefined; if (symbolSide !== undefined) { if (op !== '==' && op !== '!=') return undefined; const other = symbolSide === left ? right : left; if (!isEnumRead(other)) return undefined; const declared = schema[other.enumKey]?.enum_values; if (!declared || other.value === undefined) return undefined; const matches = enumCandidates(symbolSide.slice(ENUM_SYMBOL.length)).filter((c) => declared.includes(c)); if (matches.length !== 1) return undefined; const equal = matches[0] === other.value; return op === '==' ? equal : !equal; } // An enum read compared against anything else is only meaningful by value. const l0 = isEnumRead(left) ? left.value : left; const r0 = isEnumRead(right) ? right.value : right; if (op === '==' || op === '!=') { if (l0 === undefined || r0 === undefined) return undefined; const equal = typeof l0 === 'string' || typeof r0 === 'string' ? String(l0) === String(r0) : asNumber(l0) === asNumber(r0); return op === '==' ? equal : !equal; } const l = asNumber(l0); const r = asNumber(r0); if (l === undefined || r === undefined) return undefined; if (op === '<') return l < r; if (op === '<=') return l <= r; if (op === '>') return l > r; if (op === '>=') return l >= r; return undefined; } /** * Plausible config spellings for a C++ enumerator. * * `IroningType::NoIroning` -> ["no_ironing", "no ironing", "noironing"] * `btNoBrim` -> ["no_brim", "no brim", "nobrim"] */ function enumCandidates(symbol: string): string[] { const bare = symbol.includes('::') ? symbol.slice(symbol.lastIndexOf('::') + 2) : symbol; // Enumerators are either bare PascalCase or PascalCase behind a lowercase // type tag (ip*, bt*, sms*); try both readings. const cores = [bare, /^[a-z]+([A-Z].*)$/.exec(bare)?.[1]].filter((c): c is string => Boolean(c)); const out = new Set(); for (const core of cores) { const snake = core.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); out.add(snake); out.add(snake.replace(/_/g, ' ')); out.add(snake.replace(/_/g, '')); } return [...out]; } // --- Public API ------------------------------------------------------------ export interface ToggleRules { locals: Record; rules: Array<{ fields: string[]; enable_if: string }>; } /** * Returns the set of option keys the current settings disable. * * Only rules that evaluate to a definite `false` contribute; unknown and true * both leave the field enabled. */ export function disabledKeys(settings: Record, schema: ProcessSchema, toggles: ToggleRules): Set { const cfg = makeConfigReader(settings, schema); const memo = new Map(); const disabled = new Set(); for (const rule of toggles.rules) { // The C++ helper takes `(expr, variant_index)`; only the first part is the // condition, the rest selects which extruder variant to read. const condition = splitCondition(rule.enable_if); if (!condition) continue; const evaluator = new Evaluator(cfg, toggles.locals, schema, new Set(), memo); if (asBoolean(evaluator.evaluate(condition)) === false) { for (const field of rule.fields) disabled.add(field); } } return disabled; } /** * Takes the condition off an `enable_if` payload, dropping a trailing * `variant_index` argument. Only parentheses count towards nesting: every * argument-bearing call in the rule set is parenthesised, while `<` and `>` * appear far more often as comparisons than as template brackets. */ function splitCondition(expr: string): string | undefined { let depth = 0; for (let i = 0; i < expr.length; i += 1) { const c = expr[i]; if (c === '(') depth += 1; else if (c === ')') depth -= 1; else if (c === ',' && depth === 0) return expr.slice(0, i).trim() || undefined; } return expr.trim() || undefined; }