slicerToggle.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. /**
  2. * Evaluates OrcaSlicer's `toggle_print_fff_options` enable/disable rules so our
  3. * process-settings panel greys out the same fields the real slicer does.
  4. *
  5. * The vendored `process-toggle-rules.json` carries the rules verbatim from the
  6. * C++ source: each rule is a list of option keys plus an `enable_if` expression
  7. * written in C++, referencing named locals that are themselves C++ expressions.
  8. * Rather than hand-translate a subset (which is what upstream's own evaluator
  9. * does — 11 of 68 locals, the rest silently enabled), this interprets the
  10. * expressions directly and resolves locals recursively, so a local defined in
  11. * terms of three other locals costs nothing extra to support.
  12. *
  13. * The cardinal rule is **fail open**: anything we cannot decide with certainty
  14. * leaves the field enabled. A wrongly-greyed control hides a setting the user
  15. * needs and looks like a bug; a wrongly-enabled one merely lets them set
  16. * something the slicer will ignore, which is the pre-existing behaviour of every
  17. * other settings surface in Bambuddy. Every `undefined` return below is that
  18. * rule being applied, not an oversight.
  19. *
  20. * Deliberately not `eval` / `new Function`: the expressions are vendored data
  21. * rather than user input, but the frontend runs under a CSP without
  22. * `unsafe-eval` and a 120-line recursive-descent parser is easier to test than
  23. * a regex pipeline that rewrites C++ into JavaScript.
  24. */
  25. import type { ProcessSchema, SettingValue } from '../types/slicerSettings';
  26. /**
  27. * A read of an enum-typed option, carrying the key so a comparison against a
  28. * C++ enumerator can be checked against that option's declared values.
  29. */
  30. interface EnumRead {
  31. enumKey: string;
  32. value: string | undefined;
  33. }
  34. /** A resolved expression value. `undefined` means "could not determine". */
  35. type Value = boolean | number | string | EnumRead | undefined;
  36. const isEnumRead = (v: Value): v is EnumRead => typeof v === 'object' && v !== null && 'enumKey' in v;
  37. /** A bare C++ enumerator (`ipGyroid`, `IroningType::NoIroning`) seen in an expression. */
  38. const ENUM_SYMBOL = 'enum:';
  39. // --- Config access ---------------------------------------------------------
  40. export interface ConfigReader {
  41. /** Raw value for a key: the user's override if set, else the schema default. */
  42. get(key: string): Value;
  43. has(key: string): boolean;
  44. }
  45. /** Numeric view of a value: "20%" -> 20, [500] -> 500, "0.42" -> 0.42. */
  46. function asNumber(v: Value): number | undefined {
  47. if (typeof v === 'number') return v;
  48. if (typeof v === 'boolean') return v ? 1 : 0;
  49. if (typeof v !== 'string') return undefined;
  50. const n = Number.parseFloat(v);
  51. return Number.isFinite(n) ? n : undefined;
  52. }
  53. function asBoolean(v: Value): boolean | undefined {
  54. if (typeof v === 'boolean') return v;
  55. if (typeof v === 'number') return v !== 0;
  56. if (v === '1' || v === 'true') return true;
  57. if (v === '0' || v === 'false') return false;
  58. return undefined;
  59. }
  60. /**
  61. * Reads settings with schema defaults behind them. Vector options (`coFloats`
  62. * and friends) are per-extruder; every condition in the rule set tests the
  63. * first entry, which is what `opt_float_nullable(key, variant_index)` reads for
  64. * the active variant.
  65. */
  66. export function makeConfigReader(settings: Record<string, SettingValue>, schema: ProcessSchema): ConfigReader {
  67. const read = (key: string): Value => {
  68. let v: unknown = settings[key];
  69. if (v === undefined || v === '') v = schema[key]?.default;
  70. if (Array.isArray(v)) v = v[0];
  71. if (typeof v === 'boolean' || typeof v === 'number' || typeof v === 'string') return v;
  72. return undefined;
  73. };
  74. return { get: read, has: (key) => key in schema };
  75. }
  76. // --- Tokenizer -------------------------------------------------------------
  77. type Token = { kind: 'num'; value: number } | { kind: 'str'; value: string } | { kind: 'id'; value: string } | { kind: 'op'; value: string };
  78. // Longest-first: `->` must be tried before `-`, `<=` before `<`.
  79. const OPERATORS = ['->', '||', '&&', '==', '!=', '<=', '>=', '(', ')', ',', '<', '>', '!'];
  80. function tokenize(src: string): Token[] | undefined {
  81. const tokens: Token[] = [];
  82. let i = 0;
  83. while (i < src.length) {
  84. const c = src[i];
  85. if (c === ' ' || c === '\t' || c === '\n') {
  86. i += 1;
  87. continue;
  88. }
  89. if (c === '"') {
  90. const end = src.indexOf('"', i + 1);
  91. if (end < 0) return undefined;
  92. tokens.push({ kind: 'str', value: src.slice(i + 1, end) });
  93. i = end + 1;
  94. continue;
  95. }
  96. // C++ float literals carry an `f` suffix (`0.3f`) that the extractor left
  97. // intact in a few min/max bounds and defaults.
  98. const num = /^\d+(\.\d*)?f?/.exec(src.slice(i));
  99. if (num && /^[\d]/.test(c)) {
  100. tokens.push({ kind: 'num', value: Number.parseFloat(num[0]) });
  101. i += num[0].length;
  102. continue;
  103. }
  104. const op = OPERATORS.find((o) => src.startsWith(o, i));
  105. if (op) {
  106. tokens.push({ kind: 'op', value: op });
  107. i += op.length;
  108. continue;
  109. }
  110. // Identifiers, including the `->`, `::`, `<>` decorations of the C++
  111. // accessor forms; the parser strips those apart below.
  112. const id = /^[A-Za-z_][A-Za-z0-9_]*(::[A-Za-z_][A-Za-z0-9_]*)*/.exec(src.slice(i));
  113. if (id) {
  114. tokens.push({ kind: 'id', value: id[0] });
  115. i += id[0].length;
  116. continue;
  117. }
  118. return undefined; // Unknown character — fail open.
  119. }
  120. return tokens;
  121. }
  122. // --- Parser / evaluator ----------------------------------------------------
  123. /** Accessor names that read a config key named by their first string argument. */
  124. const ACCESSORS = new Set([
  125. 'opt_bool',
  126. 'opt_int',
  127. 'opt_float',
  128. 'opt_float_nullable',
  129. 'opt_int_nullable',
  130. 'opt_bool_nullable',
  131. 'opt_enum',
  132. 'opt_string',
  133. 'option',
  134. 'has',
  135. ]);
  136. class Evaluator {
  137. private tokens: Token[] = [];
  138. private pos = 0;
  139. private readonly cfg: ConfigReader;
  140. private readonly locals: Record<string, string>;
  141. private readonly schema: ProcessSchema;
  142. /** Locals currently being resolved — guards the (unlikely) cyclic definition. */
  143. private readonly resolving: Set<string>;
  144. private readonly memo: Map<string, Value>;
  145. constructor(cfg: ConfigReader, locals: Record<string, string>, schema: ProcessSchema, resolving: Set<string>, memo: Map<string, Value>) {
  146. this.cfg = cfg;
  147. this.locals = locals;
  148. this.schema = schema;
  149. this.resolving = resolving;
  150. this.memo = memo;
  151. }
  152. evaluate(expr: string): Value {
  153. const tokens = tokenize(expr);
  154. if (!tokens || tokens.length === 0) return undefined;
  155. this.tokens = tokens;
  156. this.pos = 0;
  157. const value = this.parseOr();
  158. // Trailing tokens mean we misread the grammar; don't trust a partial parse.
  159. if (this.pos !== this.tokens.length) return undefined;
  160. return value;
  161. }
  162. private peek(): Token | undefined {
  163. return this.tokens[this.pos];
  164. }
  165. private eatOp(op: string): boolean {
  166. const t = this.peek();
  167. if (t && t.kind === 'op' && t.value === op) {
  168. this.pos += 1;
  169. return true;
  170. }
  171. return false;
  172. }
  173. private parseOr(): Value {
  174. let left = this.parseAnd();
  175. while (this.eatOp('||')) {
  176. const right = this.parseAnd();
  177. const l = asBoolean(left);
  178. const r = asBoolean(right);
  179. // Short-circuit truth survives an unknown operand: `true || ???` is true.
  180. if (l === true || r === true) left = true;
  181. else if (l === undefined || r === undefined) left = undefined;
  182. else left = l || r;
  183. }
  184. return left;
  185. }
  186. private parseAnd(): Value {
  187. let left = this.parseComparison();
  188. while (this.eatOp('&&')) {
  189. const right = this.parseComparison();
  190. const l = asBoolean(left);
  191. const r = asBoolean(right);
  192. if (l === false || r === false) left = false;
  193. else if (l === undefined || r === undefined) left = undefined;
  194. else left = l && r;
  195. }
  196. return left;
  197. }
  198. private parseComparison(): Value {
  199. const left = this.parseUnary();
  200. for (const op of ['==', '!=', '<=', '>=', '<', '>']) {
  201. if (this.eatOp(op)) {
  202. const right = this.parseUnary();
  203. return compare(left, right, op, this.schema);
  204. }
  205. }
  206. return left;
  207. }
  208. private parseUnary(): Value {
  209. if (this.eatOp('!')) {
  210. const v = asBoolean(this.parseUnary());
  211. return v === undefined ? undefined : !v;
  212. }
  213. return this.parsePrimary();
  214. }
  215. private parsePrimary(): Value {
  216. const t = this.peek();
  217. if (!t) return undefined;
  218. if (t.kind === 'num') {
  219. this.pos += 1;
  220. return t.value;
  221. }
  222. if (t.kind === 'str') {
  223. this.pos += 1;
  224. return t.value;
  225. }
  226. if (t.kind === 'op' && t.value === '(') {
  227. this.pos += 1;
  228. const v = this.parseOr();
  229. if (!this.eatOp(')')) return undefined;
  230. return v;
  231. }
  232. if (t.kind !== 'id') return undefined;
  233. this.pos += 1;
  234. if (t.value === 'true') return true;
  235. if (t.value === 'false') return false;
  236. // `config->opt_bool("key")`, `config->option<ConfigOptionFloat>("key")->value`
  237. if (t.value === 'config') return this.parseConfigAccess();
  238. // A bare identifier is either a named local or a C++ enum symbol.
  239. const local = this.locals[t.value];
  240. if (local !== undefined) return this.resolveLocal(t.value, local);
  241. // Not a local, so it is a C++ enumerator; `compare` decides whether it can
  242. // be matched against the other side's declared enum values.
  243. return `${ENUM_SYMBOL}${t.value}`;
  244. }
  245. /** Consumes the `->accessor<T>("key")` tail after a `config` identifier. */
  246. private parseConfigAccess(): Value {
  247. if (!this.eatOp('->')) return undefined;
  248. const name = this.peek();
  249. if (!name || name.kind !== 'id' || !ACCESSORS.has(name.value)) return undefined;
  250. this.pos += 1;
  251. // Optional `<ConfigOptionFloat>` / `<InfillPattern>` template argument.
  252. if (this.eatOp('<')) {
  253. let depth = 1;
  254. while (depth > 0) {
  255. const tok = this.peek();
  256. if (!tok) return undefined;
  257. this.pos += 1;
  258. if (tok.kind === 'op' && tok.value === '<') depth += 1;
  259. if (tok.kind === 'op' && tok.value === '>') depth -= 1;
  260. }
  261. }
  262. if (!this.eatOp('(')) return undefined;
  263. const arg = this.peek();
  264. if (!arg || arg.kind !== 'str') return undefined;
  265. this.pos += 1;
  266. const key = arg.value;
  267. // Skip any further arguments (`, variant_index`, `, 0`).
  268. while (this.eatOp(',')) {
  269. let depth = 0;
  270. for (;;) {
  271. const tok = this.peek();
  272. if (!tok) return undefined;
  273. if (tok.kind === 'op' && tok.value === '(') depth += 1;
  274. if (tok.kind === 'op' && tok.value === ')') {
  275. if (depth === 0) break;
  276. depth -= 1;
  277. }
  278. if (tok.kind === 'op' && tok.value === ',' && depth === 0) break;
  279. this.pos += 1;
  280. }
  281. }
  282. if (!this.eatOp(')')) return undefined;
  283. // `config->option<T>("key")->value` — consume the trailing member access.
  284. if (this.eatOp('->')) {
  285. const member = this.peek();
  286. if (!member || member.kind !== 'id') return undefined;
  287. this.pos += 1;
  288. }
  289. if (name.value === 'has') return this.cfg.has(key);
  290. const raw = this.cfg.get(key);
  291. // Tag reads of enum options so a comparison against a C++ enumerator can
  292. // validate its transliteration against this option's declared values.
  293. if (this.schema[key]?.enum_values) {
  294. return { enumKey: key, value: typeof raw === 'string' ? raw : undefined };
  295. }
  296. return raw;
  297. }
  298. private resolveLocal(name: string, source: string): Value {
  299. const cached = this.memo.get(name);
  300. if (cached !== undefined || this.memo.has(name)) return cached;
  301. if (this.resolving.has(name)) return undefined;
  302. this.resolving.add(name);
  303. const nested = new Evaluator(this.cfg, this.locals, this.schema, this.resolving, this.memo);
  304. const value = nested.evaluate(source);
  305. this.resolving.delete(name);
  306. this.memo.set(name, value);
  307. return value;
  308. }
  309. }
  310. /**
  311. * Compares two resolved values.
  312. *
  313. * The interesting case is an enum option tested against a C++ enumerator —
  314. * `config->opt_enum<IroningType>("ironing_type") != IroningType::NoIroning`.
  315. * OrcaSlicer's enumerator spellings and its serialised config values are
  316. * related but not identical (`btNoBrim` -> `no_brim`, `NoIroning` ->
  317. * `no ironing`), so we generate the plausible spellings and only trust the
  318. * result when exactly one of them is a value the option actually declares.
  319. * A transliteration that matches nothing yields `undefined`, not a confident
  320. * `false` that would grey out a field for the wrong reason.
  321. */
  322. function compare(left: Value, right: Value, op: string, schema: ProcessSchema): Value {
  323. const symbolSide = typeof left === 'string' && left.startsWith(ENUM_SYMBOL) ? left : typeof right === 'string' && right.startsWith(ENUM_SYMBOL) ? right : undefined;
  324. if (symbolSide !== undefined) {
  325. if (op !== '==' && op !== '!=') return undefined;
  326. const other = symbolSide === left ? right : left;
  327. if (!isEnumRead(other)) return undefined;
  328. const declared = schema[other.enumKey]?.enum_values;
  329. if (!declared || other.value === undefined) return undefined;
  330. const matches = enumCandidates(symbolSide.slice(ENUM_SYMBOL.length)).filter((c) => declared.includes(c));
  331. if (matches.length !== 1) return undefined;
  332. const equal = matches[0] === other.value;
  333. return op === '==' ? equal : !equal;
  334. }
  335. // An enum read compared against anything else is only meaningful by value.
  336. const l0 = isEnumRead(left) ? left.value : left;
  337. const r0 = isEnumRead(right) ? right.value : right;
  338. if (op === '==' || op === '!=') {
  339. if (l0 === undefined || r0 === undefined) return undefined;
  340. const equal = typeof l0 === 'string' || typeof r0 === 'string' ? String(l0) === String(r0) : asNumber(l0) === asNumber(r0);
  341. return op === '==' ? equal : !equal;
  342. }
  343. const l = asNumber(l0);
  344. const r = asNumber(r0);
  345. if (l === undefined || r === undefined) return undefined;
  346. if (op === '<') return l < r;
  347. if (op === '<=') return l <= r;
  348. if (op === '>') return l > r;
  349. if (op === '>=') return l >= r;
  350. return undefined;
  351. }
  352. /**
  353. * Plausible config spellings for a C++ enumerator.
  354. *
  355. * `IroningType::NoIroning` -> ["no_ironing", "no ironing", "noironing"]
  356. * `btNoBrim` -> ["no_brim", "no brim", "nobrim"]
  357. */
  358. function enumCandidates(symbol: string): string[] {
  359. const bare = symbol.includes('::') ? symbol.slice(symbol.lastIndexOf('::') + 2) : symbol;
  360. // Enumerators are either bare PascalCase or PascalCase behind a lowercase
  361. // type tag (ip*, bt*, sms*); try both readings.
  362. const cores = [bare, /^[a-z]+([A-Z].*)$/.exec(bare)?.[1]].filter((c): c is string => Boolean(c));
  363. const out = new Set<string>();
  364. for (const core of cores) {
  365. const snake = core.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
  366. out.add(snake);
  367. out.add(snake.replace(/_/g, ' '));
  368. out.add(snake.replace(/_/g, ''));
  369. }
  370. return [...out];
  371. }
  372. // --- Public API ------------------------------------------------------------
  373. export interface ToggleRules {
  374. locals: Record<string, string>;
  375. rules: Array<{ fields: string[]; enable_if: string }>;
  376. }
  377. /**
  378. * Returns the set of option keys the current settings disable.
  379. *
  380. * Only rules that evaluate to a definite `false` contribute; unknown and true
  381. * both leave the field enabled.
  382. */
  383. export function disabledKeys(settings: Record<string, SettingValue>, schema: ProcessSchema, toggles: ToggleRules): Set<string> {
  384. const cfg = makeConfigReader(settings, schema);
  385. const memo = new Map<string, Value>();
  386. const disabled = new Set<string>();
  387. for (const rule of toggles.rules) {
  388. // The C++ helper takes `(expr, variant_index)`; only the first part is the
  389. // condition, the rest selects which extruder variant to read.
  390. const condition = splitCondition(rule.enable_if);
  391. if (!condition) continue;
  392. const evaluator = new Evaluator(cfg, toggles.locals, schema, new Set(), memo);
  393. if (asBoolean(evaluator.evaluate(condition)) === false) {
  394. for (const field of rule.fields) disabled.add(field);
  395. }
  396. }
  397. return disabled;
  398. }
  399. /**
  400. * Takes the condition off an `enable_if` payload, dropping a trailing
  401. * `variant_index` argument. Only parentheses count towards nesting: every
  402. * argument-bearing call in the rule set is parenthesised, while `<` and `>`
  403. * appear far more often as comparisons than as template brackets.
  404. */
  405. function splitCondition(expr: string): string | undefined {
  406. let depth = 0;
  407. for (let i = 0; i < expr.length; i += 1) {
  408. const c = expr[i];
  409. if (c === '(') depth += 1;
  410. else if (c === ')') depth -= 1;
  411. else if (c === ',' && depth === 0) return expr.slice(0, i).trim() || undefined;
  412. }
  413. return expr.trim() || undefined;
  414. }