@reactvision/react-viro 2.56.0 → 2.57.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +8 -0
  2. package/android/react_viro/react_viro-release.aar +0 -0
  3. package/android/viro_renderer/viro_renderer-release.aar +0 -0
  4. package/components/AR/ViroARPlane.tsx +1 -4
  5. package/components/AR/ViroARPlaneSelector.tsx +10 -5
  6. package/components/AR/ViroARScene.tsx +19 -4
  7. package/components/Studio/StudioARScene.tsx +243 -48
  8. package/components/Studio/StudioSceneNavigator.tsx +15 -0
  9. package/components/Studio/VRTStudioModule.ts +10 -0
  10. package/components/Studio/domain/StudioSounds.tsx +46 -0
  11. package/components/Studio/domain/apiRequestHelpers.ts +612 -0
  12. package/components/Studio/domain/collisionBindingsRuntime.ts +17 -2
  13. package/components/Studio/domain/defaultApiRequestExecutor.ts +61 -0
  14. package/components/Studio/domain/expressionEvaluator.ts +603 -0
  15. package/components/Studio/domain/sceneNavigationHandler.ts +770 -20
  16. package/components/Studio/domain/soundManager.ts +141 -0
  17. package/components/Studio/domain/utils.ts +50 -0
  18. package/components/Studio/domain/variableStore.ts +74 -0
  19. package/components/Studio/domain/viroNodeFactory.tsx +175 -44
  20. package/components/Studio/domain/visibilityStore.ts +66 -0
  21. package/components/Studio/index.ts +1 -0
  22. package/components/Studio/types.ts +178 -1
  23. package/components/Utilities/VRModuleOpenXR.ts +35 -0
  24. package/components/Utilities/ViroVersion.ts +1 -1
  25. package/components/ViroAnimatedImage.tsx +1 -0
  26. package/components/ViroFlexView.tsx +1 -0
  27. package/components/ViroImage.tsx +1 -0
  28. package/components/ViroObjectDetector.tsx +230 -0
  29. package/components/ViroParticleEmitter.tsx +2 -0
  30. package/components/ViroPolygon.tsx +2 -1
  31. package/components/ViroQuad.tsx +2 -1
  32. package/components/ViroSurface.tsx +1 -0
  33. package/components/ViroVideo.tsx +1 -0
  34. package/components/ViroXRSceneNavigator.tsx +8 -0
  35. package/dist/components/AR/ViroARPlane.d.ts +1 -1
  36. package/dist/components/AR/ViroARPlane.js +1 -5
  37. package/dist/components/AR/ViroARPlaneSelector.d.ts +1 -1
  38. package/dist/components/AR/ViroARPlaneSelector.js +10 -5
  39. package/dist/components/AR/ViroARScene.d.ts +13 -1
  40. package/dist/components/AR/ViroARScene.js +10 -6
  41. package/dist/components/Studio/StudioARScene.d.ts +3 -0
  42. package/dist/components/Studio/StudioARScene.js +142 -15
  43. package/dist/components/Studio/StudioSceneNavigator.js +14 -0
  44. package/dist/components/Studio/VRTStudioModule.d.ts +6 -0
  45. package/dist/components/Studio/VRTStudioModule.js +10 -0
  46. package/dist/components/Studio/domain/StudioSounds.d.ts +11 -0
  47. package/dist/components/Studio/domain/StudioSounds.js +69 -0
  48. package/dist/components/Studio/domain/apiRequestHelpers.d.ts +180 -0
  49. package/dist/components/Studio/domain/apiRequestHelpers.js +496 -0
  50. package/dist/components/Studio/domain/collisionBindingsRuntime.d.ts +3 -1
  51. package/dist/components/Studio/domain/collisionBindingsRuntime.js +4 -3
  52. package/dist/components/Studio/domain/defaultApiRequestExecutor.d.ts +14 -0
  53. package/dist/components/Studio/domain/defaultApiRequestExecutor.js +58 -0
  54. package/dist/components/Studio/domain/expressionEvaluator.d.ts +108 -0
  55. package/dist/components/Studio/domain/expressionEvaluator.js +520 -0
  56. package/dist/components/Studio/domain/sceneNavigationHandler.d.ts +35 -8
  57. package/dist/components/Studio/domain/sceneNavigationHandler.js +564 -22
  58. package/dist/components/Studio/domain/soundManager.d.ts +52 -0
  59. package/dist/components/Studio/domain/soundManager.js +118 -0
  60. package/dist/components/Studio/domain/utils.d.ts +16 -0
  61. package/dist/components/Studio/domain/utils.js +48 -0
  62. package/dist/components/Studio/domain/variableStore.d.ts +21 -0
  63. package/dist/components/Studio/domain/variableStore.js +65 -0
  64. package/dist/components/Studio/domain/viroNodeFactory.d.ts +4 -9
  65. package/dist/components/Studio/domain/viroNodeFactory.js +84 -25
  66. package/dist/components/Studio/domain/visibilityStore.d.ts +33 -0
  67. package/dist/components/Studio/domain/visibilityStore.js +66 -0
  68. package/dist/components/Studio/index.d.ts +1 -1
  69. package/dist/components/Studio/types.d.ts +150 -1
  70. package/dist/components/Utilities/VRModuleOpenXR.d.ts +19 -0
  71. package/dist/components/Utilities/VRModuleOpenXR.js +16 -0
  72. package/dist/components/Utilities/ViroVersion.d.ts +1 -1
  73. package/dist/components/Utilities/ViroVersion.js +1 -1
  74. package/dist/components/ViroAnimatedImage.js +1 -0
  75. package/dist/components/ViroFlexView.js +1 -0
  76. package/dist/components/ViroImage.js +1 -0
  77. package/dist/components/ViroObjectDetector.d.ts +146 -0
  78. package/dist/components/ViroObjectDetector.js +93 -0
  79. package/dist/components/ViroParticleEmitter.js +2 -0
  80. package/dist/components/ViroPolygon.js +1 -0
  81. package/dist/components/ViroQuad.js +1 -0
  82. package/dist/components/ViroSurface.js +1 -0
  83. package/dist/components/ViroVideo.js +1 -0
  84. package/dist/components/ViroXRSceneNavigator.d.ts +8 -0
  85. package/dist/index.d.ts +6 -4
  86. package/dist/index.js +5 -2
  87. package/dist/plugins/withViro.js +2 -15
  88. package/dist/plugins/withViroAndroid.js +18 -5
  89. package/dist/plugins/withViroIos.js +0 -6
  90. package/index.ts +7 -3
  91. package/ios/ViroReact.podspec +25 -6
  92. package/ios/dist/ViroRenderer/ViroKit.framework/ARCoreCoreMLSemanticsResources.bundle/Info.plist +0 -0
  93. package/ios/dist/ViroRenderer/ViroKit.framework/ARCoreResources.bundle/Info.plist +0 -0
  94. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARFrameiOS.h +9 -0
  95. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARScene.h +14 -1
  96. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROARSceneDelegateiOS.h +8 -0
  97. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROGLTFLoader.h +5 -1
  98. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROMonocularDepthEstimator.h +10 -0
  99. package/ios/dist/ViroRenderer/ViroKit.framework/Headers/VROObjectDetector.h +134 -0
  100. package/ios/dist/ViroRenderer/ViroKit.framework/Info.plist +0 -0
  101. package/ios/dist/ViroRenderer/ViroKit.framework/Shaders.dat +1 -1
  102. package/ios/dist/ViroRenderer/ViroKit.framework/ViroKit +0 -0
  103. package/ios/dist/ViroRendererVisionOS/ViroKit.podspec +27 -0
  104. package/ios/dist/include/VRTARScene.h +1 -0
  105. package/ios/dist/lib/libViroReact.a +0 -0
  106. package/package.json +17 -20
@@ -0,0 +1,61 @@
1
+ import { StudioApiRequestExecutor, StudioApiRequestOutcome } from "../types";
2
+ import { VRTStudioModule } from "../VRTStudioModule";
3
+
4
+ /**
5
+ * Built-in API_REQUEST transport for production Viro apps: POSTs
6
+ * {function_id, variables} to the scene-api-request egress proxy through the
7
+ * native module, authenticated with the app's RVApiKey (same credential used
8
+ * to fetch scenes). Hosts with their own transport (e.g. StudioGo's JWT
9
+ * controller) override it via runtimeCtx.
10
+ *
11
+ * Always resolves with an outcome — transport-level problems (native module
12
+ * missing, RVApiKey unset, proxy auth/limit errors, malformed envelope)
13
+ * become a NETWORK_ERROR failure so the scene routes to the failure arm
14
+ * instead of crashing.
15
+ */
16
+ export const defaultApiRequestExecutor: StudioApiRequestExecutor = async (
17
+ functionId,
18
+ variables,
19
+ ) => {
20
+ const result = await VRTStudioModule.rvStudioApiRequest(
21
+ JSON.stringify({ function_id: functionId, variables }),
22
+ );
23
+
24
+ if (!result.success || typeof result.data !== "string") {
25
+ return failureOutcome(extractErrorMessage(result.error));
26
+ }
27
+
28
+ try {
29
+ const envelope = JSON.parse(result.data) as Partial<StudioApiRequestOutcome>;
30
+ return {
31
+ ok: envelope.ok === true,
32
+ status: typeof envelope.status === "number" ? envelope.status : null,
33
+ body: envelope.body,
34
+ error_code: envelope.error_code ?? null,
35
+ error_message: envelope.error_message ?? null,
36
+ };
37
+ } catch {
38
+ return failureOutcome("Malformed scene-api-request response");
39
+ }
40
+ };
41
+
42
+ function failureOutcome(message: string): StudioApiRequestOutcome {
43
+ return {
44
+ ok: false,
45
+ status: null,
46
+ error_code: "NETWORK_ERROR",
47
+ error_message: message,
48
+ };
49
+ }
50
+
51
+ /** Proxy auth/contract errors arrive as {"error":{"code","message"}} JSON. */
52
+ function extractErrorMessage(error: string | undefined): string {
53
+ if (!error) return "Request failed";
54
+ try {
55
+ const parsed = JSON.parse(error) as { error?: { message?: string } };
56
+ if (typeof parsed.error?.message === "string") return parsed.error.message;
57
+ } catch {
58
+ // not JSON — use the raw transport error
59
+ }
60
+ return error;
61
+ }
@@ -0,0 +1,603 @@
1
+ /**
2
+ * Studio expression evaluator: parses and evaluates the literal-or-expression
3
+ * values written by SET_VARIABLE steps (and, later, reused by Branch
4
+ * conditions via compare()). Hand-rolled tokenizer + recursive descent over a
5
+ * fixed grammar — never eval()/Function().
6
+ *
7
+ * Dual-maintained: this file exists in the Viro repo
8
+ * (components/Studio/domain/) and the ReactVisionStudio monorepo
9
+ * (packages/common/scene-assets/). Keep the two copies identical.
10
+ *
11
+ * Grammar (precedence low → high):
12
+ * or := and ( "||" and )* Boolean operands
13
+ * and := equality ( "&&" equality )* Boolean operands
14
+ * equality := relational ( ("=="|"!=") relational )* same-type operands
15
+ * relational := additive ( ("<"|"<="|">"|">=") additive )* Number only
16
+ * additive := multiplicative ( ("+"|"-") multiplicative )*
17
+ * "+" adds Numbers, or concatenates if either side is a String
18
+ * multiplicative := unary ( ("*"|"/") unary )* Number only
19
+ * unary := ("!"|"-") unary | primary
20
+ * primary := NUMBER | STRING | "true" | "false" | IDENT | "(" or ")"
21
+ */
22
+
23
+ export type StudioVariableType = "BOOLEAN" | "NUMBER" | "STRING";
24
+ export type StudioVariableValue = boolean | number | string;
25
+
26
+ export const MAX_EXPRESSION_LENGTH = 500;
27
+ export const VARIABLE_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
28
+
29
+ type BinaryOp =
30
+ | "||"
31
+ | "&&"
32
+ | "=="
33
+ | "!="
34
+ | "<"
35
+ | "<="
36
+ | ">"
37
+ | ">="
38
+ | "+"
39
+ | "-"
40
+ | "*"
41
+ | "/";
42
+ type UnaryOp = "!" | "-";
43
+
44
+ export type ExpressionNode =
45
+ | { kind: "literal"; value: StudioVariableValue }
46
+ | { kind: "variable"; name: string }
47
+ | { kind: "unary"; op: UnaryOp; operand: ExpressionNode }
48
+ | { kind: "binary"; op: BinaryOp; left: ExpressionNode; right: ExpressionNode };
49
+
50
+ export type ParseResult =
51
+ | { ok: true; ast: ExpressionNode; refs: string[] }
52
+ | { ok: false; error: string };
53
+
54
+ export type TypeCheckResult =
55
+ | { ok: true; type: StudioVariableType }
56
+ | { ok: false; error: string };
57
+
58
+ export type EvaluateResult =
59
+ | { ok: true; value: StudioVariableValue }
60
+ | { ok: false; error: string };
61
+
62
+ class ExpressionError extends Error {}
63
+
64
+ // ─── Tokenizer ────────────────────────────────────────────────────────────────
65
+
66
+ type Token =
67
+ | { type: "number"; value: number; start: number; end: number }
68
+ | { type: "string"; value: string; start: number; end: number }
69
+ | { type: "boolean"; value: boolean; start: number; end: number }
70
+ | { type: "ident"; name: string; start: number; end: number }
71
+ | { type: "op"; op: string; start: number; end: number }
72
+ | { type: "eof"; start: number; end: number };
73
+
74
+ const IDENT_START = /[A-Za-z_]/;
75
+ const IDENT_PART = /[A-Za-z0-9_]/;
76
+ const DIGIT = /[0-9]/;
77
+
78
+ function tokenize(src: string): Token[] {
79
+ const tokens: Token[] = [];
80
+ let i = 0;
81
+ while (i < src.length) {
82
+ const ch = src[i];
83
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
84
+ i++;
85
+ continue;
86
+ }
87
+ const start = i;
88
+ if (DIGIT.test(ch)) {
89
+ let j = i + 1;
90
+ while (j < src.length && DIGIT.test(src[j])) j++;
91
+ if (src[j] === "." && DIGIT.test(src[j + 1] ?? "")) {
92
+ j++;
93
+ while (j < src.length && DIGIT.test(src[j])) j++;
94
+ }
95
+ tokens.push({ type: "number", value: parseFloat(src.slice(i, j)), start, end: j });
96
+ i = j;
97
+ continue;
98
+ }
99
+ if (ch === '"' || ch === "'") {
100
+ const quote = ch;
101
+ let j = i + 1;
102
+ let text = "";
103
+ let closed = false;
104
+ while (j < src.length) {
105
+ const c = src[j];
106
+ if (c === "\\") {
107
+ const next = src[j + 1];
108
+ if (next === undefined) break;
109
+ text += next === "n" ? "\n" : next === "t" ? "\t" : next;
110
+ j += 2;
111
+ continue;
112
+ }
113
+ if (c === quote) {
114
+ closed = true;
115
+ j++;
116
+ break;
117
+ }
118
+ text += c;
119
+ j++;
120
+ }
121
+ if (!closed) throw new ExpressionError(`Unterminated string at position ${start}`);
122
+ tokens.push({ type: "string", value: text, start, end: j });
123
+ i = j;
124
+ continue;
125
+ }
126
+ if (IDENT_START.test(ch)) {
127
+ let j = i + 1;
128
+ while (j < src.length && IDENT_PART.test(src[j])) j++;
129
+ const word = src.slice(i, j);
130
+ if (word === "true" || word === "false") {
131
+ tokens.push({ type: "boolean", value: word === "true", start, end: j });
132
+ } else {
133
+ tokens.push({ type: "ident", name: word, start, end: j });
134
+ }
135
+ i = j;
136
+ continue;
137
+ }
138
+ const two = src.slice(i, i + 2);
139
+ if (two === "||" || two === "&&" || two === "==" || two === "!=" || two === "<=" || two === ">=") {
140
+ tokens.push({ type: "op", op: two, start, end: i + 2 });
141
+ i += 2;
142
+ continue;
143
+ }
144
+ if ("+-*/<>!()".includes(ch)) {
145
+ tokens.push({ type: "op", op: ch, start, end: i + 1 });
146
+ i++;
147
+ continue;
148
+ }
149
+ throw new ExpressionError(`Unexpected character "${ch}" at position ${i}`);
150
+ }
151
+ tokens.push({ type: "eof", start: src.length, end: src.length });
152
+ return tokens;
153
+ }
154
+
155
+ // ─── Parser ───────────────────────────────────────────────────────────────────
156
+
157
+ class Parser {
158
+ private pos = 0;
159
+
160
+ constructor(private tokens: Token[]) {}
161
+
162
+ private peek(): Token {
163
+ return this.tokens[this.pos];
164
+ }
165
+
166
+ private matchOp(...ops: string[]): string | null {
167
+ const t = this.peek();
168
+ if (t.type === "op" && ops.includes(t.op)) {
169
+ this.pos++;
170
+ return t.op;
171
+ }
172
+ return null;
173
+ }
174
+
175
+ parse(): ExpressionNode {
176
+ const node = this.parseOr();
177
+ const t = this.peek();
178
+ if (t.type !== "eof") {
179
+ throw new ExpressionError(`Unexpected token at position ${t.start}`);
180
+ }
181
+ return node;
182
+ }
183
+
184
+ private parseBinaryLevel(ops: BinaryOp[], next: () => ExpressionNode): ExpressionNode {
185
+ let left = next();
186
+ for (;;) {
187
+ const op = this.matchOp(...ops);
188
+ if (op === null) break;
189
+ left = { kind: "binary", op: op as BinaryOp, left, right: next() };
190
+ }
191
+ return left;
192
+ }
193
+
194
+ private parseOr(): ExpressionNode {
195
+ return this.parseBinaryLevel(["||"], () => this.parseAnd());
196
+ }
197
+
198
+ private parseAnd(): ExpressionNode {
199
+ return this.parseBinaryLevel(["&&"], () => this.parseEquality());
200
+ }
201
+
202
+ private parseEquality(): ExpressionNode {
203
+ return this.parseBinaryLevel(["==", "!="], () => this.parseRelational());
204
+ }
205
+
206
+ private parseRelational(): ExpressionNode {
207
+ return this.parseBinaryLevel(["<", "<=", ">", ">="], () => this.parseAdditive());
208
+ }
209
+
210
+ private parseAdditive(): ExpressionNode {
211
+ return this.parseBinaryLevel(["+", "-"], () => this.parseMultiplicative());
212
+ }
213
+
214
+ private parseMultiplicative(): ExpressionNode {
215
+ return this.parseBinaryLevel(["*", "/"], () => this.parseUnary());
216
+ }
217
+
218
+ private parseUnary(): ExpressionNode {
219
+ const op = this.matchOp("!", "-");
220
+ if (op !== null) {
221
+ return { kind: "unary", op: op as UnaryOp, operand: this.parseUnary() };
222
+ }
223
+ return this.parsePrimary();
224
+ }
225
+
226
+ private parsePrimary(): ExpressionNode {
227
+ const t = this.peek();
228
+ if (t.type === "number" || t.type === "string" || t.type === "boolean") {
229
+ this.pos++;
230
+ return { kind: "literal", value: t.value };
231
+ }
232
+ if (t.type === "ident") {
233
+ this.pos++;
234
+ return { kind: "variable", name: t.name };
235
+ }
236
+ if (t.type === "op" && t.op === "(") {
237
+ this.pos++;
238
+ const node = this.parseOr();
239
+ if (this.matchOp(")") === null) {
240
+ throw new ExpressionError(`Missing ")" at position ${this.peek().start}`);
241
+ }
242
+ return node;
243
+ }
244
+ throw new ExpressionError(`Unexpected token at position ${t.start}`);
245
+ }
246
+ }
247
+
248
+ function collectRefs(node: ExpressionNode, acc: Set<string>): void {
249
+ if (node.kind === "variable") {
250
+ acc.add(node.name);
251
+ } else if (node.kind === "unary") {
252
+ collectRefs(node.operand, acc);
253
+ } else if (node.kind === "binary") {
254
+ collectRefs(node.left, acc);
255
+ collectRefs(node.right, acc);
256
+ }
257
+ }
258
+
259
+ export function parseExpression(src: string): ParseResult {
260
+ if (typeof src !== "string" || src.trim().length === 0) {
261
+ return { ok: false, error: "Expression is empty" };
262
+ }
263
+ if (src.length > MAX_EXPRESSION_LENGTH) {
264
+ return { ok: false, error: `Expression exceeds ${MAX_EXPRESSION_LENGTH} characters` };
265
+ }
266
+ try {
267
+ const ast = new Parser(tokenize(src)).parse();
268
+ const refs = new Set<string>();
269
+ collectRefs(ast, refs);
270
+ const refList: string[] = [];
271
+ refs.forEach((name) => refList.push(name));
272
+ return { ok: true, ast, refs: refList };
273
+ } catch (e) {
274
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
275
+ }
276
+ }
277
+
278
+ // ─── Type checking (author-time) ─────────────────────────────────────────────
279
+
280
+ function inferType(
281
+ node: ExpressionNode,
282
+ declared: Record<string, StudioVariableType>,
283
+ ): StudioVariableType {
284
+ switch (node.kind) {
285
+ case "literal":
286
+ if (typeof node.value === "boolean") return "BOOLEAN";
287
+ if (typeof node.value === "number") return "NUMBER";
288
+ return "STRING";
289
+ case "variable": {
290
+ const t = declared[node.name];
291
+ if (t === undefined) throw new ExpressionError(`Unknown variable "${node.name}"`);
292
+ return t;
293
+ }
294
+ case "unary": {
295
+ const t = inferType(node.operand, declared);
296
+ if (node.op === "!") {
297
+ if (t !== "BOOLEAN") throw new ExpressionError(`"!" needs a Boolean operand`);
298
+ return "BOOLEAN";
299
+ }
300
+ if (t !== "NUMBER") throw new ExpressionError(`"-" needs a Number operand`);
301
+ return "NUMBER";
302
+ }
303
+ case "binary": {
304
+ const l = inferType(node.left, declared);
305
+ const r = inferType(node.right, declared);
306
+ switch (node.op) {
307
+ case "||":
308
+ case "&&":
309
+ if (l !== "BOOLEAN" || r !== "BOOLEAN") {
310
+ throw new ExpressionError(`"${node.op}" needs Boolean operands`);
311
+ }
312
+ return "BOOLEAN";
313
+ case "==":
314
+ case "!=":
315
+ if (l !== r) {
316
+ throw new ExpressionError(`"${node.op}" needs operands of the same type`);
317
+ }
318
+ return "BOOLEAN";
319
+ case "<":
320
+ case "<=":
321
+ case ">":
322
+ case ">=":
323
+ if (l !== "NUMBER" || r !== "NUMBER") {
324
+ throw new ExpressionError(`"${node.op}" needs Number operands`);
325
+ }
326
+ return "BOOLEAN";
327
+ case "+":
328
+ if (l === "STRING" || r === "STRING") return "STRING";
329
+ if (l === "NUMBER" && r === "NUMBER") return "NUMBER";
330
+ throw new ExpressionError(`"+" needs Numbers, or at least one String`);
331
+ case "-":
332
+ case "*":
333
+ case "/":
334
+ if (l !== "NUMBER" || r !== "NUMBER") {
335
+ throw new ExpressionError(`"${node.op}" needs Number operands`);
336
+ }
337
+ return "NUMBER";
338
+ }
339
+ }
340
+ }
341
+ }
342
+
343
+ export function checkTypes(
344
+ node: ExpressionNode,
345
+ declared: Record<string, StudioVariableType>,
346
+ ): TypeCheckResult {
347
+ try {
348
+ return { ok: true, type: inferType(node, declared) };
349
+ } catch (e) {
350
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
351
+ }
352
+ }
353
+
354
+ /** Parse + type-check an expression against a target variable's type in one call. */
355
+ export function validateExpressionForTarget(
356
+ src: string,
357
+ targetType: StudioVariableType,
358
+ declared: Record<string, StudioVariableType>,
359
+ ): { ok: true } | { ok: false; error: string } {
360
+ const parsed = parseExpression(src);
361
+ if (!parsed.ok) return parsed;
362
+ const typed = checkTypes(parsed.ast, declared);
363
+ if (!typed.ok) return typed;
364
+ if (typed.type !== targetType) {
365
+ return {
366
+ ok: false,
367
+ error: `Expression result is ${typed.type}, expected ${targetType}`,
368
+ };
369
+ }
370
+ return { ok: true };
371
+ }
372
+
373
+ // ─── Evaluation (runtime) ─────────────────────────────────────────────────────
374
+
375
+ export type ComparisonOp = "==" | "!=" | "<" | "<=" | ">" | ">=";
376
+
377
+ /** Typed comparison shared with Branch conditions. Throws on operand mismatch. */
378
+ export function compare(
379
+ op: ComparisonOp,
380
+ left: StudioVariableValue,
381
+ right: StudioVariableValue,
382
+ ): boolean {
383
+ if (op === "==" || op === "!=") {
384
+ if (typeof left !== typeof right) {
385
+ throw new ExpressionError(`"${op}" needs operands of the same type`);
386
+ }
387
+ return op === "==" ? left === right : left !== right;
388
+ }
389
+ if (typeof left !== "number" || typeof right !== "number") {
390
+ throw new ExpressionError(`"${op}" needs Number operands`);
391
+ }
392
+ switch (op) {
393
+ case "<":
394
+ return left < right;
395
+ case "<=":
396
+ return left <= right;
397
+ case ">":
398
+ return left > right;
399
+ default:
400
+ return left >= right;
401
+ }
402
+ }
403
+
404
+ export type BranchComparison =
405
+ | "EQUALS"
406
+ | "NOT_EQUALS"
407
+ | "GREATER_THAN"
408
+ | "LESS_THAN"
409
+ | "GREATER_OR_EQUAL"
410
+ | "LESS_OR_EQUAL"
411
+ | "LIKE"
412
+ | "ILIKE";
413
+
414
+ const BRANCH_COMPARISON_OPS: Partial<Record<BranchComparison, ComparisonOp>> = {
415
+ EQUALS: "==",
416
+ NOT_EQUALS: "!=",
417
+ GREATER_THAN: ">",
418
+ LESS_THAN: "<",
419
+ GREATER_OR_EQUAL: ">=",
420
+ LESS_OR_EQUAL: "<=",
421
+ };
422
+
423
+ /** SQL LIKE semantics: % = any sequence, _ = any single char; no escape syntax. */
424
+ function likeMatch(text: string, pattern: string, caseInsensitive: boolean): boolean {
425
+ let regex = "";
426
+ for (const ch of pattern) {
427
+ if (ch === "%") regex += "[\\s\\S]*";
428
+ else if (ch === "_") regex += "[\\s\\S]";
429
+ else regex += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
430
+ }
431
+ return new RegExp(`^${regex}$`, caseInsensitive ? "i" : "").test(text);
432
+ }
433
+
434
+ /** Structural input so this file stays standalone (no scene-type imports). */
435
+ export type BranchConditionInput = {
436
+ comparison: BranchComparison;
437
+ variable_name: string;
438
+ compare_literal: StudioVariableValue | null;
439
+ compare_variable_name: string | null;
440
+ };
441
+
442
+ export type BranchConditionResult =
443
+ | { ok: true; value: boolean }
444
+ | { ok: false; error: string };
445
+
446
+ /**
447
+ * Evaluates a structured Branch condition against the runtime store.
448
+ * Never throws; callers warn + skip both arms on { ok: false }.
449
+ */
450
+ export function evaluateBranchCondition(
451
+ cond: BranchConditionInput,
452
+ get: (name: string) => StudioVariableValue | undefined,
453
+ ): BranchConditionResult {
454
+ const left = get(cond.variable_name);
455
+ if (left === undefined) {
456
+ return { ok: false, error: `Variable "${cond.variable_name}" is not defined` };
457
+ }
458
+ let right: StudioVariableValue | undefined;
459
+ if (cond.compare_variable_name !== null) {
460
+ right = get(cond.compare_variable_name);
461
+ if (right === undefined) {
462
+ return { ok: false, error: `Variable "${cond.compare_variable_name}" is not defined` };
463
+ }
464
+ } else if (cond.compare_literal !== null) {
465
+ right = cond.compare_literal;
466
+ } else {
467
+ return { ok: false, error: "Condition has no operand" };
468
+ }
469
+ if (cond.comparison === "LIKE" || cond.comparison === "ILIKE") {
470
+ if (typeof left !== "string" || typeof right !== "string") {
471
+ return { ok: false, error: `"${cond.comparison}" needs String operands` };
472
+ }
473
+ return { ok: true, value: likeMatch(left, right, cond.comparison === "ILIKE") };
474
+ }
475
+ try {
476
+ return { ok: true, value: compare(BRANCH_COMPARISON_OPS[cond.comparison]!, left, right) };
477
+ } catch (e) {
478
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
479
+ }
480
+ }
481
+
482
+ function guardFinite(n: number): number {
483
+ if (!Number.isFinite(n)) throw new ExpressionError("Result is not a finite number");
484
+ return n;
485
+ }
486
+
487
+ function stringify(v: StudioVariableValue): string {
488
+ return typeof v === "string" ? v : String(v);
489
+ }
490
+
491
+ function evalNode(
492
+ node: ExpressionNode,
493
+ get: (name: string) => StudioVariableValue | undefined,
494
+ ): StudioVariableValue {
495
+ switch (node.kind) {
496
+ case "literal":
497
+ return node.value;
498
+ case "variable": {
499
+ const v = get(node.name);
500
+ if (v === undefined) throw new ExpressionError(`Variable "${node.name}" is not set`);
501
+ return v;
502
+ }
503
+ case "unary": {
504
+ const v = evalNode(node.operand, get);
505
+ if (node.op === "!") {
506
+ if (typeof v !== "boolean") throw new ExpressionError(`"!" needs a Boolean operand`);
507
+ return !v;
508
+ }
509
+ if (typeof v !== "number") throw new ExpressionError(`"-" needs a Number operand`);
510
+ return guardFinite(-v);
511
+ }
512
+ case "binary": {
513
+ if (node.op === "||" || node.op === "&&") {
514
+ const l = evalNode(node.left, get);
515
+ if (typeof l !== "boolean") throw new ExpressionError(`"${node.op}" needs Boolean operands`);
516
+ if (node.op === "||" && l) return true;
517
+ if (node.op === "&&" && !l) return false;
518
+ const r = evalNode(node.right, get);
519
+ if (typeof r !== "boolean") throw new ExpressionError(`"${node.op}" needs Boolean operands`);
520
+ return r;
521
+ }
522
+ const l = evalNode(node.left, get);
523
+ const r = evalNode(node.right, get);
524
+ switch (node.op) {
525
+ case "==":
526
+ case "!=":
527
+ case "<":
528
+ case "<=":
529
+ case ">":
530
+ case ">=":
531
+ return compare(node.op, l, r);
532
+ case "+":
533
+ if (typeof l === "string" || typeof r === "string") return stringify(l) + stringify(r);
534
+ if (typeof l === "number" && typeof r === "number") return guardFinite(l + r);
535
+ throw new ExpressionError(`"+" needs Numbers, or at least one String`);
536
+ case "-":
537
+ case "*":
538
+ case "/": {
539
+ if (typeof l !== "number" || typeof r !== "number") {
540
+ throw new ExpressionError(`"${node.op}" needs Number operands`);
541
+ }
542
+ const out = node.op === "-" ? l - r : node.op === "*" ? l * r : l / r;
543
+ return guardFinite(out);
544
+ }
545
+ }
546
+ }
547
+ }
548
+ }
549
+
550
+ export function evaluate(
551
+ node: ExpressionNode,
552
+ get: (name: string) => StudioVariableValue | undefined,
553
+ ): EvaluateResult {
554
+ try {
555
+ return { ok: true, value: evalNode(node, get) };
556
+ } catch (e) {
557
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
558
+ }
559
+ }
560
+
561
+ // ─── Editor helpers ───────────────────────────────────────────────────────────
562
+
563
+ export function valueMatchesType(
564
+ value: StudioVariableValue,
565
+ type: StudioVariableType,
566
+ ): boolean {
567
+ return (
568
+ (type === "BOOLEAN" && typeof value === "boolean") ||
569
+ (type === "NUMBER" && typeof value === "number") ||
570
+ (type === "STRING" && typeof value === "string")
571
+ );
572
+ }
573
+
574
+ /** Serialize a literal value into expression syntax (the editor's literal mode). */
575
+ export function formatLiteral(value: StudioVariableValue): string {
576
+ if (typeof value === "string") {
577
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
578
+ }
579
+ return String(value);
580
+ }
581
+
582
+ /**
583
+ * Rewrite every reference to oldName in an expression's source, preserving all
584
+ * other formatting (token-span splicing). Returns the source unchanged if it
585
+ * does not tokenize. Powers variable rename in the editor.
586
+ */
587
+ export function renameIdentifier(src: string, oldName: string, newName: string): string {
588
+ let tokens: Token[];
589
+ try {
590
+ tokens = tokenize(src);
591
+ } catch {
592
+ return src;
593
+ }
594
+ let out = "";
595
+ let cursor = 0;
596
+ for (const t of tokens) {
597
+ if (t.type === "ident" && t.name === oldName) {
598
+ out += src.slice(cursor, t.start) + newName;
599
+ cursor = t.end;
600
+ }
601
+ }
602
+ return out + src.slice(cursor);
603
+ }