git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

src/wrangle.rs (34.9K)

  1 //! The wrangle node: a script run once per element, over the Detail's own
  2 //! surface.
  3 //!
  4 //! Houdini's attribwrangle, on an engine someone else maintains. The script
  5 //! language is Rhai, chosen in `shapeshifter.md` Phase 7 for being pure Rust
  6 //! with no C toolchain, sandboxed behind an operation budget — the step budget
  7 //! the retired `kernel_cpu` interpreter used to count by hand — and compiled
  8 //! once to an AST that is cached by source, as the retired launcher cached
  9 //! kernels. What this module adds is the BINDING: how a script reaches the
 10 //! geometry, and nothing else.
 11 //!
 12 //! The vocabulary, deliberately VEX-shaped so it reads as it does there:
 13 //!
 14 //! - `@P`, `@Cd`, `@N`, `@id`, `@ptnum` / `@primnum`, `@numpt` / `@numprim`,
 15 //!   `@Frame`, and `@name` for any attribute of any type. `@` is sugar —
 16 //!   Rhai has no such token — rewritten by [`desugar`] into an index on the
 17 //!   element (`__at["name"]`), so `@mass += 2.0` and `@P.y = 0.0` are ordinary
 18 //!   Rhai once the script reaches the engine. Naming an attribute creates it,
 19 //!   typed by the value written: a float, an int, a `vec3`, an array of two
 20 //!   or four. A float2 reads as a `vec3` with z = 0 and a float4 as an array.
 21 //! - `ch("path")`, `chs`, `chv`, `chi` — the node's own parameters and, by
 22 //!   Houdini's relative paths, any other node's. The paths are resolved BEFORE
 23 //!   the script runs, through the expression scope, so an expression-valued
 24 //!   parameter is evaluated first and the script sees its value; the cost per
 25 //!   element is a map lookup. A path therefore has to be a string literal.
 26 //! - `neighbours(pt)`, `prims(pt)`, `points(prim)` off the derived topology —
 27 //!   what decision 1 kept out of the kernel language because the interpreter
 28 //!   could not carry it — and `nearest(pos, radius)` off the point grid.
 29 //! - `point(name, i)` / `setpoint`, `prim(name, i)` / `setprim`,
 30 //!   `detail(name)` / `setdetail`, `ingroup(name)` / `setgroup(name, bool)`.
 31 //! - `addpoint(pos)`, `addprim([a, b, c])`, `removepoint(i)` — DEFERRED and
 32 //!   applied after the run, so a script iterating points sees a stable count.
 33 //!   `addpoint` returns the index the point will have.
 34 //! - `vec3(x, y, z)`, `dot`, `cross`, `length`, `normalize`, `distance`,
 35 //!   `lerp`, `fit`, `clamp`, `rand(seed)`, and Rhai's own math.
 36 //!
 37 //! CPU only, and deliberately: an interpreter is an order of magnitude or
 38 //! more below native Rust, which is fine for a wrangle over tens of thousands
 39 //! of elements per edit and wrong for a solver at a million per frame — that
 40 //! is Phase 7's step 4, WGSL compute through the renderer, and not this.
 41 
 42 use crate::detail::{AttribType, AttribValue, Class, Detail};
 43 use crate::spatial::PointGrid;
 44 use glam::Vec3;
 45 use rhai::{Array, Dynamic, Engine, EvalAltResult, ImmutableString, Scope, AST, FLOAT, INT};
 46 use std::cell::{Cell, RefCell};
 47 use std::collections::HashMap;
 48 use std::rc::Rc;
 49 use std::time::{Duration, Instant};
 50 
 51 /// Per-element operation budget. A real script is tens to hundreds of
 52 /// operations; this is a loop that never ends, caught in milliseconds.
 53 const OPS_PER_ELEMENT: u64 = 2_000_000;
 54 /// Wall-clock budget for the whole run, so a script that is merely slow over
 55 /// a large input is an error rather than a frozen UI.
 56 const RUN_BUDGET: Duration = Duration::from_secs(8);
 57 /// Compiled scripts kept by source. Small: a project has a handful of
 58 /// wrangles, and each edit of one is a new key.
 59 const AST_CACHE_CAP: usize = 64;
 60 
 61 /// A channel value resolved before the run: what `ch` / `chs` / `chv` read.
 62 #[derive(Clone, Debug, PartialEq)]
 63 pub struct Chan {
 64     pub num: f64,
 65     pub text: String,
 66 }
 67 
 68 impl Chan {
 69     pub fn new(num: f64, text: impl Into<String>) -> Self {
 70         Chan { num, text: text.into() }
 71     }
 72 
 73     /// `chv`: three `:`-separated components, or the number splatted.
 74     fn vec(&self) -> Vec3 {
 75         let parts: Vec<f32> = self.text.split(':').filter_map(|p| p.trim().parse::<f32>().ok()).collect();
 76         if parts.len() == 3 {
 77             Vec3::new(parts[0], parts[1], parts[2])
 78         } else {
 79             Vec3::splat(self.num as f32)
 80         }
 81     }
 82 }
 83 
 84 /// The Class parameter: which element the script runs once per.
 85 pub fn parse_class(s: &str) -> Class {
 86     let t = s.trim();
 87     if t.eq_ignore_ascii_case("primitives") || t.eq_ignore_ascii_case("prims") || t.eq_ignore_ascii_case("primitive") {
 88         Class::Prim
 89     } else if t.eq_ignore_ascii_case("detail") {
 90         Class::Detail
 91     } else {
 92         Class::Point
 93     }
 94 }
 95 
 96 /// `@name` → `__at["name"]`, outside strings and comments.
 97 ///
 98 /// Rhai has no `@` token, so this is the whole of the sugar: an identifier
 99 /// after `@` becomes an index on the element marker, and everything after it
100 /// — `.x`, `+=`, `[0]` — is Rhai's own syntax on the value that comes back.
101 /// String literals (double-quoted, backtick, and char literals) and both
102 /// comment forms are copied through untouched, so `"@"` in a message stays a
103 /// character.
104 pub fn desugar(code: &str) -> String {
105     let b = code.as_bytes();
106     let mut out = String::with_capacity(code.len() + 32);
107     let mut i = 0;
108     while i < b.len() {
109         let c = b[i];
110         // Comments.
111         if c == b'/' && i + 1 < b.len() && b[i + 1] == b'/' {
112             let end = code[i..].find('\n').map_or(b.len(), |n| i + n);
113             out.push_str(&code[i..end]);
114             i = end;
115             continue;
116         }
117         if c == b'/' && i + 1 < b.len() && b[i + 1] == b'*' {
118             let end = code[i + 2..].find("*/").map_or(b.len(), |n| i + 2 + n + 2);
119             out.push_str(&code[i..end]);
120             i = end;
121             continue;
122         }
123         // String and char literals: copy to the matching close, honouring
124         // backslash escapes.
125         if c == b'"' || c == b'`' || c == b'\'' {
126             let quote = c;
127             let mut j = i + 1;
128             while j < b.len() {
129                 if b[j] == b'\\' && quote != b'`' {
130                     j += 2;
131                     continue;
132                 }
133                 if b[j] == quote {
134                     j += 1;
135                     break;
136                 }
137                 j += 1;
138             }
139             let j = j.min(b.len());
140             out.push_str(&code[i..j]);
141             i = j;
142             continue;
143         }
144         if c == b'@' && i + 1 < b.len() && (b[i + 1].is_ascii_alphabetic() || b[i + 1] == b'_') {
145             let mut j = i + 1;
146             while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_') {
147                 j += 1;
148             }
149             out.push_str("__at[\"");
150             out.push_str(&code[i + 1..j]);
151             out.push_str("\"]");
152             i = j;
153             continue;
154         }
155         out.push(c as char);
156         i += 1;
157     }
158     out
159 }
160 
161 /// The channel paths a script names as string literals — `ch("../Radius")`,
162 /// `chs`, `chv`, `chi`, `chf`, `chb` — so the caller can resolve them before
163 /// the run. A path built at runtime is not found here and errors when read.
164 pub fn channel_refs(code: &str) -> Vec<String> {
165     let src = strip_comments(code);
166     let b = src.as_bytes();
167     let mut out: Vec<String> = Vec::new();
168     for call in ["ch(", "chs(", "chv(", "chi(", "chf(", "chb("] {
169         let mut from = 0;
170         while let Some(pos) = src[from..].find(call) {
171             let at = from + pos;
172             from = at + call.len();
173             // A whole identifier: `search(` contains `ch(` and must not count.
174             if at > 0 && (b[at - 1].is_ascii_alphanumeric() || b[at - 1] == b'_') {
175                 continue;
176             }
177             let rest = src[from..].trim_start();
178             let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else { continue };
179             let inner = &rest[1..];
180             let Some(end) = inner.find(quote) else { continue };
181             let path = inner[..end].to_string();
182             if !path.is_empty() && !out.contains(&path) {
183                 out.push(path);
184             }
185         }
186     }
187     out
188 }
189 
190 fn strip_comments(code: &str) -> String {
191     let mut out = String::with_capacity(code.len());
192     let mut rest = code;
193     while !rest.is_empty() {
194         if let Some(stripped) = rest.strip_prefix("//") {
195             let end = stripped.find('\n').unwrap_or(stripped.len());
196             rest = &stripped[end..];
197         } else if let Some(stripped) = rest.strip_prefix("/*") {
198             let end = stripped.find("*/").map_or(stripped.len(), |n| n + 2);
199             rest = &stripped[end..];
200         } else {
201             let mut chars = rest.chars();
202             out.push(chars.next().unwrap());
203             rest = chars.as_str();
204         }
205     }
206     out
207 }
208 
209 // ------------------------------------------------------------ the binding
210 
211 /// The marker the desugared `@` indexes: `__at["P"]`. Carries nothing; the
212 /// indexers reach the shared context through their captured handle.
213 #[derive(Clone, Copy)]
214 struct El;
215 
216 /// What the script runs against: the geometry, which element it is on, and
217 /// the edits it has asked for that apply after the run.
218 struct Ctx {
219     d: Detail,
220     class: Class,
221     i: usize,
222     frame: i32,
223     normals: Option<Vec<Vec3>>,
224     grid: Option<(f32, PointGrid)>,
225     adds: Vec<Vec3>,
226     prim_adds: Vec<Vec<u32>>,
227     removes: Vec<usize>,
228     chans: HashMap<String, Result<Chan, String>>,
229 }
230 
231 type Shared = Rc<RefCell<Ctx>>;
232 type RhaiResult<T> = Result<T, Box<EvalAltResult>>;
233 
234 fn rt<T>(msg: impl Into<String>) -> RhaiResult<T> {
235     Err(msg.into().into())
236 }
237 
238 fn class_name(c: Class) -> &'static str {
239     match c {
240         Class::Point => "point",
241         Class::Vertex => "vertex",
242         Class::Prim => "primitive",
243         Class::Detail => "detail",
244     }
245 }
246 
247 /// A number out of anything numeric the script can hold.
248 fn num(v: &Dynamic) -> Result<f64, String> {
249     if v.is_float() {
250         Ok(v.as_float().unwrap_or(0.0))
251     } else if v.is_int() {
252         Ok(v.as_int().unwrap_or(0) as f64)
253     } else if v.is_bool() {
254         Ok(if v.as_bool().unwrap_or(false) { 1.0 } else { 0.0 })
255     } else {
256         Err(format!("expected a number, got {}", v.type_name()))
257     }
258 }
259 
260 fn to_vec3(v: &Dynamic) -> Result<Vec3, String> {
261     if let Some(x) = v.clone().try_cast::<Vec3>() {
262         return Ok(x);
263     }
264     if v.is_array() {
265         let a = v.clone().into_array().unwrap_or_default();
266         if a.len() >= 3 {
267             return Ok(Vec3::new(num(&a[0])? as f32, num(&a[1])? as f32, num(&a[2])? as f32));
268         }
269         if a.len() == 2 {
270             return Ok(Vec3::new(num(&a[0])? as f32, num(&a[1])? as f32, 0.0));
271         }
272         return Err(format!("expected a vec3, got an array of {}", a.len()));
273     }
274     Ok(Vec3::splat(num(v)? as f32))
275 }
276 
277 fn to_dyn(v: AttribValue) -> Dynamic {
278     match v {
279         AttribValue::Float(f) => Dynamic::from_float(f as FLOAT),
280         AttribValue::Int(i) => Dynamic::from_int(i as INT),
281         AttribValue::Float3(a) => Dynamic::from(Vec3::from(a)),
282         AttribValue::Float2(a) => Dynamic::from(Vec3::new(a[0], a[1], 0.0)),
283         AttribValue::Float4(a) => Dynamic::from_array(a.iter().map(|&f| Dynamic::from_float(f as FLOAT)).collect()),
284     }
285 }
286 
287 /// A script value converted to an attribute's type — the type of the
288 /// attribute being written, so a whole number into a float attribute is a
289 /// float and a float into an int attribute is truncated, as the kernel
290 /// vocabulary does.
291 fn to_attr(v: &Dynamic, ty: AttribType) -> Result<AttribValue, String> {
292     Ok(match ty {
293         AttribType::Float => AttribValue::Float(to_scalar(v)? as f32),
294         AttribType::Int => AttribValue::Int(to_scalar(v)?.trunc() as i32),
295         AttribType::Float3 => AttribValue::Float3(to_vec3(v)?.to_array()),
296         AttribType::Float2 => {
297             let a = to_vec3(v)?;
298             AttribValue::Float2([a.x, a.y])
299         }
300         AttribType::Float4 => {
301             if v.is_array() {
302                 let a = v.clone().into_array().unwrap_or_default();
303                 if a.len() != 4 {
304                     return Err(format!("expected four components, got {}", a.len()));
305                 }
306                 AttribValue::Float4([num(&a[0])? as f32, num(&a[1])? as f32, num(&a[2])? as f32, num(&a[3])? as f32])
307             } else if let Some(x) = v.clone().try_cast::<Vec3>() {
308                 AttribValue::Float4([x.x, x.y, x.z, 1.0])
309             } else {
310                 AttribValue::Float4([num(v)? as f32; 4])
311             }
312         }
313     })
314 }
315 
316 /// A scalar out of a number or a vector's first component.
317 fn to_scalar(v: &Dynamic) -> Result<f64, String> {
318     if let Some(x) = v.clone().try_cast::<Vec3>() {
319         return Ok(x.x as f64);
320     }
321     num(v)
322 }
323 
324 /// The attribute type a fresh attribute takes from the first value written.
325 fn infer_type(v: &Dynamic) -> Result<AttribType, String> {
326     if v.is_float() {
327         Ok(AttribType::Float)
328     } else if v.is_int() || v.is_bool() {
329         Ok(AttribType::Int)
330     } else if v.clone().try_cast::<Vec3>().is_some() {
331         Ok(AttribType::Float3)
332     } else if v.is_array() {
333         match v.clone().into_array().unwrap_or_default().len() {
334             2 => Ok(AttribType::Float2),
335             3 => Ok(AttribType::Float3),
336             4 => Ok(AttribType::Float4),
337             n => Err(format!("an array of {n} is not an attribute type (2, 3 or 4 components)")),
338         }
339     } else {
340         Err(format!("a {} cannot be stored as an attribute", v.type_name()))
341     }
342 }
343 
344 fn zero_of(ty: AttribType) -> AttribValue {
345     match ty {
346         AttribType::Float => AttribValue::Float(0.0),
347         AttribType::Int => AttribValue::Int(0),
348         AttribType::Float2 => AttribValue::Float2([0.0; 2]),
349         AttribType::Float3 => AttribValue::Float3([0.0; 3]),
350         AttribType::Float4 => AttribValue::Float4([0.0; 4]),
351     }
352 }
353 
354 impl Ctx {
355     fn check(&self, class: Class, i: usize) -> Result<(), String> {
356         let n = match class {
357             Class::Point => self.d.num_points(),
358             Class::Prim => self.d.num_prims(),
359             Class::Vertex => self.d.num_verts(),
360             Class::Detail => 1,
361         };
362         if i >= n {
363             return Err(format!("{} {i} is out of range ({n} {}s)", class_name(class), class_name(class)));
364         }
365         Ok(())
366     }
367 
368     /// One element's attribute, or one of the intrinsics that read like one.
369     fn read(&mut self, class: Class, i: usize, name: &str) -> Result<Dynamic, String> {
370         match name {
371             "ptnum" | "primnum" | "elemnum" => return Ok(Dynamic::from_int(i as INT)),
372             "numpt" => return Ok(Dynamic::from_int(self.d.num_points() as INT)),
373             "numprim" => return Ok(Dynamic::from_int(self.d.num_prims() as INT)),
374             "Frame" => return Ok(Dynamic::from_int(self.frame as INT)),
375             _ => {}
376         }
377         self.check(class, i)?;
378         match (class, name) {
379             (Class::Point, "P") => Ok(Dynamic::from(self.d.pos(i))),
380             (Class::Prim, "P") => {
381                 let pts = self.d.prim_points(i);
382                 let sum: Vec3 = pts.iter().map(|&p| self.d.pos(p as usize)).sum();
383                 Ok(Dynamic::from(if pts.is_empty() { Vec3::ZERO } else { sum / pts.len() as f32 }))
384             }
385             (Class::Point, "Cd") => Ok(Dynamic::from(Vec3::from(self.d.color(i)))),
386             (Class::Point, "id") => Ok(Dynamic::from_int(self.d.id(i).unwrap_or(0) as INT)),
387             (Class::Point, "N") if !self.d.points().has("N") => {
388                 if self.normals.is_none() {
389                     self.normals = Some(crate::geometry::point_normals(&self.d));
390                 }
391                 Ok(Dynamic::from(self.normals.as_ref().unwrap()[i]))
392             }
393             _ => match self.d.store(class).value(name, i) {
394                 Some(v) => Ok(to_dyn(v)),
395                 None => Ok(Dynamic::from_float(0.0)),
396             },
397         }
398     }
399 
400     fn write(&mut self, class: Class, i: usize, name: &str, v: &Dynamic) -> Result<(), String> {
401         match name {
402             "ptnum" | "primnum" | "elemnum" | "numpt" | "numprim" | "Frame" | "id" => {
403                 return Err(format!("@{name} is read-only"));
404             }
405             _ => {}
406         }
407         self.check(class, i)?;
408         match (class, name) {
409             (Class::Point, "P") => {
410                 self.d.set_pos(i, to_vec3(v)?);
411                 return Ok(());
412             }
413             (Class::Point, "Cd") => {
414                 self.d.set_color(i, to_vec3(v)?.to_array());
415                 return Ok(());
416             }
417             (_, "P") => return Err(format!("@P is read-only on a {}", class_name(class))),
418             _ => {}
419         }
420         let store = self.d.store_mut(class);
421         let ty = match store.get(name) {
422             Some(data) => data.ty(),
423             None => {
424                 let ty = infer_type(v)?;
425                 store.create(name, zero_of(ty));
426                 ty
427             }
428         };
429         store.set_value(name, i, to_attr(v, ty)?)
430     }
431 }
432 
433 fn wrap<T>(r: Result<T, String>) -> RhaiResult<T> {
434     r.map_err(|e| e.into())
435 }
436 
437 fn index_arg(v: &Dynamic) -> Result<usize, String> {
438     let n = num(v)?;
439     if n < 0.0 {
440         return Err(format!("index {n} is negative"));
441     }
442     Ok(n as usize)
443 }
444 
445 /// A deterministic unit float from a seed: splitmix64 over the seed's bits,
446 /// so `rand(@ptnum)` and `rand(@P.x * 7.3)` both give a stable draw.
447 fn rand_unit(seed: &Dynamic) -> f64 {
448     let bits: u64 = if seed.is_float() {
449         seed.as_float().unwrap_or(0.0).to_bits()
450     } else {
451         seed.as_int().unwrap_or(0) as u64
452     };
453     let mut z = bits.wrapping_add(0x9E37_79B9_7F4A_7C15);
454     z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
455     z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
456     z ^= z >> 31;
457     (z >> 11) as f64 / (1u64 << 53) as f64
458 }
459 
460 fn build_engine(ctx: &Shared) -> Engine {
461     let mut engine = Engine::new();
462     engine.set_max_operations(OPS_PER_ELEMENT);
463 
464     // Numbers mix. Rhai keeps ints and floats apart by default, and a script
465     // that writes `@P.y * 2` should not have to know which it wrote.
466     engine
467         .register_fn("+", |a: INT, b: FLOAT| a as FLOAT + b)
468         .register_fn("+", |a: FLOAT, b: INT| a + b as FLOAT)
469         .register_fn("-", |a: INT, b: FLOAT| a as FLOAT - b)
470         .register_fn("-", |a: FLOAT, b: INT| a - b as FLOAT)
471         .register_fn("*", |a: INT, b: FLOAT| a as FLOAT * b)
472         .register_fn("*", |a: FLOAT, b: INT| a * b as FLOAT)
473         .register_fn("/", |a: INT, b: FLOAT| a as FLOAT / b)
474         .register_fn("/", |a: FLOAT, b: INT| a / b as FLOAT)
475         .register_fn("%", |a: INT, b: FLOAT| (a as FLOAT) % b)
476         .register_fn("%", |a: FLOAT, b: INT| a % b as FLOAT)
477         .register_fn("**", |a: INT, b: FLOAT| (a as FLOAT).powf(b))
478         .register_fn("**", |a: FLOAT, b: INT| a.powf(b as FLOAT))
479         .register_fn("<", |a: INT, b: FLOAT| (a as FLOAT) < b)
480         .register_fn("<", |a: FLOAT, b: INT| a < b as FLOAT)
481         .register_fn(">", |a: INT, b: FLOAT| (a as FLOAT) > b)
482         .register_fn(">", |a: FLOAT, b: INT| a > b as FLOAT)
483         .register_fn("<=", |a: INT, b: FLOAT| (a as FLOAT) <= b)
484         .register_fn("<=", |a: FLOAT, b: INT| a <= b as FLOAT)
485         .register_fn(">=", |a: INT, b: FLOAT| (a as FLOAT) >= b)
486         .register_fn(">=", |a: FLOAT, b: INT| a >= b as FLOAT)
487         .register_fn("==", |a: INT, b: FLOAT| (a as FLOAT) == b)
488         .register_fn("==", |a: FLOAT, b: INT| a == b as FLOAT)
489         .register_fn("!=", |a: INT, b: FLOAT| (a as FLOAT) != b)
490         .register_fn("!=", |a: FLOAT, b: INT| a != b as FLOAT);
491 
492     // vec3: glam's, with components, arithmetic and the usual functions.
493     engine
494         .register_type_with_name::<Vec3>("vec3")
495         .register_get_set("x", |v: &mut Vec3| v.x as FLOAT, |v: &mut Vec3, x: FLOAT| v.x = x as f32)
496         .register_get_set("y", |v: &mut Vec3| v.y as FLOAT, |v: &mut Vec3, y: FLOAT| v.y = y as f32)
497         .register_get_set("z", |v: &mut Vec3| v.z as FLOAT, |v: &mut Vec3, z: FLOAT| v.z = z as f32)
498         .register_set("x", |v: &mut Vec3, x: INT| v.x = x as f32)
499         .register_set("y", |v: &mut Vec3, y: INT| v.y = y as f32)
500         .register_set("z", |v: &mut Vec3, z: INT| v.z = z as f32)
501         .register_indexer_get(|v: &mut Vec3, i: INT| -> RhaiResult<FLOAT> {
502             match i {
503                 0 => Ok(v.x as FLOAT),
504                 1 => Ok(v.y as FLOAT),
505                 2 => Ok(v.z as FLOAT),
506                 _ => rt(format!("vec3 index {i} out of range")),
507             }
508         })
509         .register_indexer_set(|v: &mut Vec3, i: INT, x: Dynamic| -> RhaiResult<()> {
510             let x = wrap(num(&x))? as f32;
511             match i {
512                 0 => v.x = x,
513                 1 => v.y = x,
514                 2 => v.z = x,
515                 _ => return rt(format!("vec3 index {i} out of range")),
516             }
517             Ok(())
518         })
519         .register_fn("vec3", |x: Dynamic, y: Dynamic, z: Dynamic| -> RhaiResult<Vec3> {
520             Ok(Vec3::new(wrap(num(&x))? as f32, wrap(num(&y))? as f32, wrap(num(&z))? as f32))
521         })
522         .register_fn("vec3", |x: Dynamic| -> RhaiResult<Vec3> { wrap(to_vec3(&x)) })
523         .register_fn("+", |a: Vec3, b: Vec3| a + b)
524         .register_fn("-", |a: Vec3, b: Vec3| a - b)
525         .register_fn("-", |a: Vec3| -a)
526         .register_fn("*", |a: Vec3, b: Vec3| a * b)
527         .register_fn("*", |a: Vec3, b: FLOAT| a * b as f32)
528         .register_fn("*", |a: FLOAT, b: Vec3| b * a as f32)
529         .register_fn("*", |a: Vec3, b: INT| a * b as f32)
530         .register_fn("*", |a: INT, b: Vec3| b * a as f32)
531         .register_fn("/", |a: Vec3, b: Vec3| a / b)
532         .register_fn("/", |a: Vec3, b: FLOAT| a / b as f32)
533         .register_fn("/", |a: Vec3, b: INT| a / b as f32)
534         .register_fn("==", |a: Vec3, b: Vec3| a == b)
535         .register_fn("!=", |a: Vec3, b: Vec3| a != b)
536         .register_fn("to_string", |v: Vec3| format!("{}:{}:{}", v.x, v.y, v.z))
537         .register_fn("to_debug", |v: Vec3| format!("vec3({}, {}, {})", v.x, v.y, v.z))
538         .register_fn("dot", |a: Vec3, b: Vec3| a.dot(b) as FLOAT)
539         .register_fn("cross", |a: Vec3, b: Vec3| a.cross(b))
540         .register_fn("length", |a: Vec3| a.length() as FLOAT)
541         .register_fn("length2", |a: Vec3| a.length_squared() as FLOAT)
542         .register_fn("normalize", |a: Vec3| a.normalize_or_zero())
543         .register_fn("distance", |a: Vec3, b: Vec3| a.distance(b) as FLOAT)
544         .register_fn("abs", |a: Vec3| a.abs())
545         .register_fn("min", |a: Vec3, b: Vec3| a.min(b))
546         .register_fn("max", |a: Vec3, b: Vec3| a.max(b))
547         .register_fn("lerp", |a: Vec3, b: Vec3, t: Dynamic| -> RhaiResult<Vec3> { Ok(a.lerp(b, wrap(num(&t))? as f32)) })
548         .register_fn("lerp", |a: FLOAT, b: FLOAT, t: FLOAT| a + (b - a) * t)
549         .register_fn("clamp", |v: Vec3, lo: Vec3, hi: Vec3| v.clamp(lo, hi))
550         .register_fn("clamp", |v: Dynamic, lo: Dynamic, hi: Dynamic| -> RhaiResult<FLOAT> {
551             let (v, lo, hi) = (wrap(num(&v))?, wrap(num(&lo))?, wrap(num(&hi))?);
552             Ok(v.max(lo).min(hi))
553         })
554         .register_fn("fit", |v: Dynamic, a: Dynamic, b: Dynamic, c: Dynamic, d: Dynamic| -> RhaiResult<FLOAT> {
555             let (v, a, b, c, d) = (wrap(num(&v))?, wrap(num(&a))?, wrap(num(&b))?, wrap(num(&c))?, wrap(num(&d))?);
556             let t = if (b - a).abs() < 1e-12 { 0.0 } else { ((v - a) / (b - a)).clamp(0.0, 1.0) };
557             Ok(c + (d - c) * t)
558         })
559         .register_fn("rand", |seed: Dynamic| rand_unit(&seed));
560 
561     // The element: `@name` desugars to `__at["name"]`.
562     engine.register_type_with_name::<El>("element");
563     let c = ctx.clone();
564     engine.register_indexer_get(move |_: &mut El, name: ImmutableString| -> RhaiResult<Dynamic> {
565         let mut c = c.borrow_mut();
566         let (class, i) = (c.class, c.i);
567         wrap(c.read(class, i, &name))
568     });
569     let c = ctx.clone();
570     engine.register_indexer_set(move |_: &mut El, name: ImmutableString, v: Dynamic| -> RhaiResult<()> {
571         let mut c = c.borrow_mut();
572         let (class, i) = (c.class, c.i);
573         wrap(c.write(class, i, &name, &v))
574     });
575 
576     // Other elements, by index.
577     let c = ctx.clone();
578     engine.register_fn("point", move |name: ImmutableString, i: Dynamic| -> RhaiResult<Dynamic> {
579         let i = wrap(index_arg(&i))?;
580         wrap(c.borrow_mut().read(Class::Point, i, &name))
581     });
582     let c = ctx.clone();
583     engine.register_fn("setpoint", move |name: ImmutableString, i: Dynamic, v: Dynamic| -> RhaiResult<()> {
584         let i = wrap(index_arg(&i))?;
585         wrap(c.borrow_mut().write(Class::Point, i, &name, &v))
586     });
587     let c = ctx.clone();
588     engine.register_fn("prim", move |name: ImmutableString, i: Dynamic| -> RhaiResult<Dynamic> {
589         let i = wrap(index_arg(&i))?;
590         wrap(c.borrow_mut().read(Class::Prim, i, &name))
591     });
592     let c = ctx.clone();
593     engine.register_fn("setprim", move |name: ImmutableString, i: Dynamic, v: Dynamic| -> RhaiResult<()> {
594         let i = wrap(index_arg(&i))?;
595         wrap(c.borrow_mut().write(Class::Prim, i, &name, &v))
596     });
597     let c = ctx.clone();
598     engine.register_fn("detail", move |name: ImmutableString| -> RhaiResult<Dynamic> {
599         wrap(c.borrow_mut().read(Class::Detail, 0, &name))
600     });
601     let c = ctx.clone();
602     engine.register_fn("setdetail", move |name: ImmutableString, v: Dynamic| -> RhaiResult<()> {
603         wrap(c.borrow_mut().write(Class::Detail, 0, &name, &v))
604     });
605     let c = ctx.clone();
606     engine.register_fn("npoints", move || c.borrow().d.num_points() as INT);
607     let c = ctx.clone();
608     engine.register_fn("nprims", move || c.borrow().d.num_prims() as INT);
609 
610     // Topology.
611     let c = ctx.clone();
612     engine.register_fn("neighbours", move |i: Dynamic| -> RhaiResult<Array> {
613         let i = wrap(index_arg(&i))?;
614         let c = c.borrow();
615         wrap(c.check(Class::Point, i))?;
616         Ok(c.d.point_neighbours(i).iter().map(|&n| Dynamic::from_int(n as INT)).collect())
617     });
618     let c = ctx.clone();
619     engine.register_fn("neighbors", move |i: Dynamic| -> RhaiResult<Array> {
620         let i = wrap(index_arg(&i))?;
621         let c = c.borrow();
622         wrap(c.check(Class::Point, i))?;
623         Ok(c.d.point_neighbours(i).iter().map(|&n| Dynamic::from_int(n as INT)).collect())
624     });
625     let c = ctx.clone();
626     engine.register_fn("prims", move |i: Dynamic| -> RhaiResult<Array> {
627         let i = wrap(index_arg(&i))?;
628         let c = c.borrow();
629         wrap(c.check(Class::Point, i))?;
630         Ok(c.d.point_prims(i).iter().map(|&n| Dynamic::from_int(n as INT)).collect())
631     });
632     let c = ctx.clone();
633     engine.register_fn("points", move |i: Dynamic| -> RhaiResult<Array> {
634         let i = wrap(index_arg(&i))?;
635         let c = c.borrow();
636         wrap(c.check(Class::Prim, i))?;
637         Ok(c.d.prim_points(i).iter().map(|&n| Dynamic::from_int(n as INT)).collect())
638     });
639     let c = ctx.clone();
640     engine.register_fn("nearest", move |pos: Dynamic, radius: Dynamic| -> RhaiResult<Array> {
641         let pos = wrap(to_vec3(&pos))?;
642         let radius = wrap(num(&radius))? as f32;
643         if radius <= 0.0 {
644             return Ok(Array::new());
645         }
646         let mut c = c.borrow_mut();
647         // The grid is built from the positions as they stand at the first
648         // call and keyed by radius; a script that moves points and asks
649         // again reads the earlier layout, which is what a per-element pass
650         // should see anyway.
651         if c.grid.as_ref().map_or(true, |(r, _)| (*r - radius).abs() > 1e-6) {
652             let pts: Vec<Vec3> = (0..c.d.num_points()).map(|p| c.d.pos(p)).collect();
653             c.grid = Some((radius, PointGrid::build(&pts, radius)));
654         }
655         let mut out = Vec::new();
656         c.grid.as_ref().unwrap().1.within(pos, radius, &mut out);
657         out.sort_by(|&a, &b| {
658             let da = c.d.pos(a as usize).distance_squared(pos);
659             let db = c.d.pos(b as usize).distance_squared(pos);
660             da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
661         });
662         Ok(out.into_iter().map(|n| Dynamic::from_int(n as INT)).collect())
663     });
664 
665     // Groups.
666     let c = ctx.clone();
667     engine.register_fn("ingroup", move |name: ImmutableString| -> bool {
668         let c = c.borrow();
669         c.d.store(c.class).in_group(&name, c.i)
670     });
671     let c = ctx.clone();
672     engine.register_fn("ingroup", move |name: ImmutableString, i: Dynamic| -> RhaiResult<bool> {
673         let i = wrap(index_arg(&i))?;
674         let c = c.borrow();
675         Ok(c.d.store(c.class).in_group(&name, i))
676     });
677     let c = ctx.clone();
678     engine.register_fn("setgroup", move |name: ImmutableString, on: Dynamic| -> RhaiResult<()> {
679         let on = wrap(num(&on))? != 0.0;
680         let mut c = c.borrow_mut();
681         let (class, i) = (c.class, c.i);
682         let store = c.d.store_mut(class);
683         if on {
684             if !store.has_group(&name) {
685                 store.create_group(&name);
686             }
687             store.add_to_group(&name, i);
688         } else if store.has_group(&name) {
689             // No remove-one on the store: rebuild the membership without i.
690             let members: Vec<u32> = store.group_members(&name).into_iter().filter(|&m| m as usize != i).collect();
691             store.remove_group(&name);
692             store.create_group(&name);
693             for m in members {
694                 store.add_to_group(&name, m as usize);
695             }
696         }
697         Ok(())
698     });
699 
700     // Deferred structural edits.
701     let c = ctx.clone();
702     engine.register_fn("addpoint", move |pos: Dynamic| -> RhaiResult<INT> {
703         let pos = wrap(to_vec3(&pos))?;
704         let mut c = c.borrow_mut();
705         let idx = c.d.num_points() + c.adds.len();
706         c.adds.push(pos);
707         Ok(idx as INT)
708     });
709     let c = ctx.clone();
710     engine.register_fn("addprim", move |pts: Array| -> RhaiResult<INT> {
711         let mut c = c.borrow_mut();
712         let total = c.d.num_points() + c.adds.len();
713         let mut idx = Vec::with_capacity(pts.len());
714         for p in &pts {
715             let p = wrap(index_arg(p))?;
716             if p >= total {
717                 return rt(format!("addprim: point {p} does not exist ({total} points)"));
718             }
719             idx.push(p as u32);
720         }
721         if idx.len() < 2 {
722             return rt("addprim: a primitive needs at least two points");
723         }
724         let n = c.d.num_prims() + c.prim_adds.len();
725         c.prim_adds.push(idx);
726         Ok(n as INT)
727     });
728     let c = ctx.clone();
729     engine.register_fn("removepoint", move |i: Dynamic| -> RhaiResult<()> {
730         let i = wrap(index_arg(&i))?;
731         let mut c = c.borrow_mut();
732         wrap(c.check(Class::Point, i))?;
733         c.removes.push(i);
734         Ok(())
735     });
736 
737     // Channels, resolved before the run.
738     fn chan(c: &Shared, path: &str) -> RhaiResult<Chan> {
739         match c.borrow().chans.get(path) {
740             Some(Ok(ch)) => Ok(ch.clone()),
741             Some(Err(e)) => rt(e.clone()),
742             None => rt(format!("ch(\"{path}\"): a channel path must be a string literal, so it can be resolved before the script runs")),
743         }
744     }
745     let c = ctx.clone();
746     engine.register_fn("ch", move |path: ImmutableString| -> RhaiResult<FLOAT> { Ok(chan(&c, &path)?.num) });
747     let c = ctx.clone();
748     engine.register_fn("chf", move |path: ImmutableString| -> RhaiResult<FLOAT> { Ok(chan(&c, &path)?.num) });
749     let c = ctx.clone();
750     engine.register_fn("chi", move |path: ImmutableString| -> RhaiResult<INT> { Ok(chan(&c, &path)?.num.trunc() as INT) });
751     let c = ctx.clone();
752     engine.register_fn("chb", move |path: ImmutableString| -> RhaiResult<bool> { Ok(chan(&c, &path)?.num != 0.0) });
753     let c = ctx.clone();
754     engine.register_fn("chs", move |path: ImmutableString| -> RhaiResult<ImmutableString> { Ok(chan(&c, &path)?.text.into()) });
755     let c = ctx.clone();
756     engine.register_fn("chv", move |path: ImmutableString| -> RhaiResult<Vec3> { Ok(chan(&c, &path)?.vec()) });
757 
758     engine
759 }
760 
761 thread_local! {
762     static AST_CACHE: RefCell<HashMap<String, Rc<AST>>> = RefCell::new(HashMap::new());
763 }
764 
765 fn compile(engine: &Engine, src: &str) -> Result<Rc<AST>, String> {
766     if let Some(ast) = AST_CACHE.with(|c| c.borrow().get(src).cloned()) {
767         return Ok(ast);
768     }
769     let ast = Rc::new(engine.compile(src).map_err(|e| format!("syntax: {e}"))?);
770     AST_CACHE.with(|c| {
771         let mut c = c.borrow_mut();
772         if c.len() >= AST_CACHE_CAP {
773             c.clear();
774         }
775         c.insert(src.to_string(), ast.clone());
776     });
777     Ok(ast)
778 }
779 
780 /// Run `code` once per element of `class` in `input` (over `group`, if
781 /// named), with the channel values the caller resolved. On any error — a
782 /// syntax error, a runtime error on some element, the budget — the whole run
783 /// fails and the caller keeps its input: a half-wrangled geometry is not a
784 /// result.
785 pub fn run_wrangle(
786     input: Detail,
787     code: &str,
788     class: Class,
789     group: &str,
790     frame: i32,
791     chans: HashMap<String, Result<Chan, String>>,
792 ) -> Result<Detail, String> {
793     let class = match class {
794         Class::Vertex => Class::Point,
795         c => c,
796     };
797     let ctx: Shared = Rc::new(RefCell::new(Ctx {
798         d: input,
799         class,
800         i: 0,
801         frame,
802         normals: None,
803         grid: None,
804         adds: Vec::new(),
805         prim_adds: Vec::new(),
806         removes: Vec::new(),
807         chans,
808     }));
809     let mut engine = build_engine(&ctx);
810 
811     // The wall-clock budget, checked every so often rather than per operation.
812     let started = Instant::now();
813     let ticks = Cell::new(0u32);
814     engine.on_progress(move |_| {
815         ticks.set(ticks.get().wrapping_add(1));
816         if ticks.get() % 4096 == 0 && started.elapsed() > RUN_BUDGET {
817             Some(Dynamic::from(format!("the script ran for more than {} s and was stopped", RUN_BUDGET.as_secs())))
818         } else {
819             None
820         }
821     });
822 
823     let src = desugar(code);
824     let ast = compile(&engine, &src)?;
825 
826     let group = group.trim();
827     let elements: Vec<usize> = {
828         let c = ctx.borrow();
829         let n = match class {
830             Class::Point => c.d.num_points(),
831             Class::Prim => c.d.num_prims(),
832             _ => 1,
833         };
834         (0..n).filter(|&i| class == Class::Detail || group.is_empty() || c.d.store(class).in_group(group, i)).collect()
835     };
836 
837     let mut scope = Scope::new();
838     // A variable, not a constant: Rhai refuses to assign through an indexer
839     // on a constant, and `@P = ...` is exactly that.
840     scope.push("__at", El);
841     let base = scope.len();
842     for i in elements {
843         ctx.borrow_mut().i = i;
844         scope.rewind(base);
845         if let Err(e) = engine.run_ast_with_scope(&mut scope, &ast) {
846             let e = e.to_string();
847             let e = e.strip_prefix("Runtime error: ").unwrap_or(&e).to_string();
848             return Err(match class {
849                 Class::Detail => e,
850                 _ => format!("{} {i}: {e}", class_name(class)),
851             });
852         }
853     }
854 
855     drop(scope);
856     drop(engine);
857     let mut c = ctx.borrow_mut();
858     let mut d = std::mem::replace(&mut c.d, Detail::new());
859     let adds = std::mem::take(&mut c.adds);
860     let prim_adds = std::mem::take(&mut c.prim_adds);
861     let removes = std::mem::take(&mut c.removes);
862     drop(c);
863 
864     for p in adds {
865         d.add_point(p);
866     }
867     for pts in prim_adds {
868         d.add_prim(&pts);
869     }
870     if !removes.is_empty() {
871         let mut keep = vec![true; d.num_points()];
872         for r in removes {
873             if r < keep.len() {
874                 keep[r] = false;
875             }
876         }
877         d.keep_points(&keep);
878     }
879     Ok(d)
880 }