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

commit5fe1ec14c8ddf64974290cf6696b8786fea97b9c
parentac109bcc9d
authorLucas Galante <[email protected]>
date2026-09-24 10:15
feat: the wrangle node — a Rhai script run once per element

Phase 7 step 1 of shapeshifter.md: the app's scripting surface for
per-element work, on an engine someone else maintains. src/wrangle.rs
owns the binding to the Detail and nothing else — `@name` sugar
(desugared to an index on an element marker, outside strings and
comments) with attribute creation typed by the first value written,
Class Points / Primitives / Detail over a Group, `@P` / `@Cd` / `@N` /
`@ptnum` intrinsics, neighbours / prims / points off the derived
topology and nearest off the point grid, point / prim / detail access
by index, groups, and deferred addpoint / addprim / removepoint so a
script sees a stable element count. A wrangle with no input still runs,
which in Detail class is how geometry is built from nothing.

ch / chs / chv / chi are resolved BEFORE the run, through the expression
TreeScope, so a parameter that is itself an expression is seen evaluated
— the seam step 2 names — and a channel costs a map lookup per element.

Two budgets (operations per element, wall clock per run); any failure
fails the whole run, named by node and element, and the input passes
through. The element marker is a scope variable, not a constant: Rhai
refuses to assign through an indexer on a constant, and `@P = …` is
exactly that.

Cargo.lock is the crate's standalone lock updated minimally for rhai;
it also gains cce-ui's git source, which the tracked lock predates.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md          |  60 ++++
 Cargo.lock         | 127 ++++++++
 Cargo.toml         |   1 +
 nodes/wrangle.json |  16 +
 shapeshifter.md    |   9 +
 src/geometry.rs    |  60 ++++
 src/main.rs        | 203 ++++++++++++-
 src/wrangle.rs     | 879 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 1354 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 649c52e..30610e2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -708,6 +708,66 @@ resolve the same way, or the nested sphere's kernel node arrived with only
 the params its override named. Depth-bounded, so a template that contained
 itself would fail rather than recurse forever.
 
+### The wrangle node
+
+`src/wrangle.rs` is a script run once per element, on Rhai — Phase 7 step 1
+of `shapeshifter.md`, and the app's scripting surface for per-element work
+where the `opencl` node used to be the only one. The engine is a dependency;
+what the module owns is the BINDING to the `Detail`, and it is VEX-shaped on
+purpose so `@P.y += sin(@P.x) * 0.1;` reads as it does there.
+
+`@name` is sugar. Rhai has no `@` token, so `desugar` rewrites `@name` into
+an index on an element marker (`__at["name"]`) outside strings and
+comments, and everything after it — `.x`, `+=`, `[0]` — is Rhai's own syntax
+on the value that came back. The indexers reach the geometry through a
+shared context; the marker is a VARIABLE in the scope, not a constant,
+because Rhai refuses to assign through an indexer on a constant and `@P = …`
+is exactly that (the first cut used `push_constant` and every write failed
+with "Cannot assign to indexer of constant"). Naming an attribute creates it,
+typed by the first value written — a float, an int (a bool is an int), a
+`vec3`, an array of two or four — and a write to an existing attribute
+converts to ITS type, so `@mass = 2` into a float attribute is `2.0`. A
+float2 reads back as a `vec3` with z = 0, a float4 as an array. `@P`, `@Cd`,
+`@N` (computed on read when absent), `@id`, `@ptnum` / `@primnum`, `@numpt` /
+`@numprim` and `@Frame` are intrinsics; on the Primitives class `@P` is the
+centroid and read-only, and on Detail `@name` is a detail attribute.
+
+**`ch("path")` is resolved BEFORE the run, not called during it.**
+`channel_refs` scans the script for the paths it names as string literals,
+and the evaluator in `geometry.rs` resolves each through the expression
+`TreeScope` — the one scope, so a parameter that is itself an expression is
+evaluated first and the script sees its value; that is the seam Phase 7's
+step 2 names, and neither language knows the other exists. Two things follow:
+`ch` costs a map lookup per element rather than a tree walk, and a path built
+at runtime is an error that says why. `chs` reads text, `chv` a float3, `chi`
+truncates.
+
+`neighbours(pt)`, `prims(pt)`, `points(prim)` read the derived topology and
+`nearest(pos, r)` the point grid — built once at the first call from the
+positions as they then stand, and keyed by radius. `point(name, i)` /
+`setpoint`, `prim` / `setprim`, `detail` / `setdetail`, `ingroup` /
+`setgroup` reach elements other than the current one. `addpoint`, `addprim`
+and `removepoint` are DEFERRED and applied after the run, so a script
+iterating points sees a stable count; `addpoint` returns the index the point
+will have, which is what makes `addprim([a, b, c])` in Detail class a way to
+build geometry from no input at all — a wrangle with nothing wired still runs.
+
+Ints and floats mix (`@P.y * 2` works), which Rhai does not do on its own;
+the mixed arithmetic and comparison operators are registered by hand, as are
+`vec3`'s. Two budgets: `OPS_PER_ELEMENT` operations per element, which is
+`kernel_cpu`'s step budget as a setting rather than a hand-rolled counter,
+and `RUN_BUDGET` seconds of wall clock for the whole run, checked in
+`on_progress` every few thousand operations. Any failure — syntax, a runtime
+error on an element, a budget — fails the WHOLE run, named by node and
+element (`wrangle1: point 4: …`), and the input passes through untouched: a
+half-wrangled geometry is not a result. Compiled scripts cache by desugared
+source in a thread-local, as `OPENCL_CACHE` keys kernels.
+
+CPU only, deliberately: an interpreter is an order of magnitude or more
+below native Rust, which is fine for tens of thousands of elements per edit
+and wrong for a solver at a million per frame. That is Phase 7's step 4
+(WGSL compute through the renderer), not a reason to grow this.
+
 ### The volume representation
 
 `src/volume.rs` is a dense signed distance field — `Volume { origin, voxel,
diff --git a/Cargo.lock b/Cargo.lock
index 56b1cc2..c8eae52 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,6 +8,20 @@ version = "2.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
 
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "const-random",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
 [[package]]
 name = "aho-corasick"
 version = "1.1.4"
@@ -411,6 +425,7 @@ dependencies = [
  "log",
  "opencl3",
  "png",
+ "rhai",
  "serde",
  "serde_json",
  "smithay-client-toolkit",
@@ -423,6 +438,7 @@ dependencies = [
 [[package]]
 name = "cce-ui"
 version = "0.1.0"
+source = "git+https://github.com/lsgalante/cce-ui.git?rev=2aa605d0cc289fa8b948a542a782388298d03843#2aa605d0cc289fa8b948a542a782388298d03843"
 dependencies = [
  "ash",
  "bitflags 2.13.1",
@@ -436,6 +452,7 @@ dependencies = [
  "libc",
  "log",
  "naga",
+ "png",
  "raw-window-handle",
  "resvg",
  "rfd",
@@ -504,6 +521,26 @@ dependencies = [
  "crossbeam-utils",
 ]
 
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "tiny-keccak",
+]
+
 [[package]]
 name = "cosmic-text"
 version = "0.12.1"
@@ -561,6 +598,12 @@ version = "0.8.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
 
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
 [[package]]
 name = "cursor-icon"
 version = "1.2.0"
@@ -888,6 +931,17 @@ dependencies = [
  "slab",
 ]
 
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
 [[package]]
 name = "getrandom"
 version = "0.3.4"
@@ -1408,6 +1462,9 @@ name = "once_cell"
 version = "1.21.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+dependencies = [
+ "portable-atomic",
+]
 
 [[package]]
 name = "once_cell_polyfill"
@@ -1787,6 +1844,34 @@ dependencies = [
  "bytemuck",
 ]
 
+[[package]]
+name = "rhai"
+version = "1.26.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0334639972c0ea5a3fd366aa36116754a11431b619fec3ed559b3f73bcbcebf5"
+dependencies = [
+ "ahash",
+ "bitflags 2.13.1",
+ "num-traits",
+ "once_cell",
+ "rhai_codegen",
+ "smallvec",
+ "smartstring",
+ "thin-vec",
+ "web-time",
+]
+
+[[package]]
+name = "rhai_codegen"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
 [[package]]
 name = "roxmltree"
 version = "0.19.0"
@@ -2019,6 +2104,17 @@ version = "1.15.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
 
+[[package]]
+name = "smartstring"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
+dependencies = [
+ "autocfg",
+ "static_assertions",
+ "version_check",
+]
+
 [[package]]
 name = "smithay-client-toolkit"
 version = "0.19.2"
@@ -2072,6 +2168,12 @@ version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
 
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
 [[package]]
 name = "strict-num"
 version = "0.1.1"
@@ -2188,6 +2290,12 @@ dependencies = [
  "winapi-util",
 ]
 
+[[package]]
+name = "thin-vec"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748"
+
 [[package]]
 name = "thiserror"
 version = "1.0.69"
@@ -2228,6 +2336,15 @@ dependencies = [
  "syn 3.0.3",
 ]
 
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
 [[package]]
 name = "tiny-skia"
 version = "0.11.4"
@@ -2760,6 +2877,16 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
 [[package]]
 name = "weezl"
 version = "0.1.12"
diff --git a/Cargo.toml b/Cargo.toml
index b0ebdd5..508a1c6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -22,6 +22,7 @@ opencl3 = "0.9"
 log = "0.4"
 env_logger = "0.11"
 png = "0.17"
+rhai = "1"
 
 [[bin]]
 name = "cce-designer"
diff --git a/nodes/wrangle.json b/nodes/wrangle.json
new file mode 100644
index 0000000..ad32935
--- /dev/null
+++ b/nodes/wrangle.json
@@ -0,0 +1,16 @@
+{
+  "name": "Wrangle",
+  "type": "wrangle",
+  "inputs": 1,
+  "outputs": 1,
+  "params": [
+    { "name": "Input", "default": "", "type": "text" },
+    { "name": "Class", "type": "choice:Points,Primitives,Detail", "default": "Points" },
+    { "name": "Group", "default": "", "type": "text" },
+    {
+      "name": "Code",
+      "type": "code",
+      "default": "// Runs once per point. @P is the position, @name any attribute;\n// ch(\"Name\") reads a parameter, neighbours(@ptnum) the topology.\[email protected] += sin(@P.x * 4.0) * 0.15;\n"
+    }
+  ]
+}
diff --git a/shapeshifter.md b/shapeshifter.md
index 1076c4b..d3d4d6a 100644
--- a/shapeshifter.md
+++ b/shapeshifter.md
@@ -595,6 +595,15 @@ that the parallelism a GPU offers belongs somewhere other than the user's
 generator code. Four steps, in dependency order; the first three do not have
 to be undone to reach the fourth.
 
+> **Started (2026-09-24).** The `wrangle` node is in: `src/wrangle.rs` on
+> Rhai 1.26, `nodes/wrangle.json`, Class Points / Primitives / Detail over a
+> Group, the `@name` sugar with typed attribute creation, `ch` / `chs` / `chv`
+> / `chi` resolved through `TreeScope` before the run, topology and nearest,
+> deferred `addpoint` / `addprim` / `removepoint`, both budgets. Nine tests
+> in `main.rs`. Not yet: a code editor worth the name in the params pane
+> (the `code` row is a single-line text box), `@N` write-back feeding the
+> normal overlay, and a vertex class.
+
 **Step 1 — a `wrangle` node on an embedded engine.** Houdini's attribwrangle,
 the thing users actually reach for, on a scripting engine someone else
 maintains. Rhai is the pick: pure Rust with no C toolchain, which every client
diff --git a/src/geometry.rs b/src/geometry.rs
index 12bba38..74c4620 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1165,6 +1165,8 @@ pub fn generate_single_node_geometry_with_errors(
         resolve_mold_shell_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("hull") {
         resolve_hull_geometry_with_errors(root, target, visited, ocl_error, sim)
+    } else if target.node_type.eq_ignore_ascii_case("wrangle") {
+        resolve_wrangle_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("switch") {
         resolve_switch_geometry_with_errors(root, target, visited, ocl_error, sim)
     } else if target.node_type.eq_ignore_ascii_case("boolean") {
@@ -2248,6 +2250,54 @@ pub fn resolve_hull_geometry_with_errors(
     Some(crate::hull::convex_hull(&pts).unwrap_or(input))
 }
 
+/// The Wrangle node: a Rhai script run once per element (`src/wrangle.rs`).
+///
+/// A wrangle with no input still runs — in Detail class, once, which is how
+/// a script builds geometry from nothing with `addpoint` / `addprim`. The
+/// channels the script names as literals are resolved HERE, before the run,
+/// through the expression scope: `ch("../Radius")` on a parameter that is
+/// itself an expression sees the evaluated value, and neither language has
+/// to know the other exists. A failing script reports through the error
+/// slot and the input passes through unchanged.
+pub fn resolve_wrangle_geometry_with_errors(
+    root: &FsNode,
+    target: &FsNode,
+    visited: &mut Vec<String>,
+    ocl_error: &mut Option<String>,
+    sim: &mut EvalSim,
+) -> Option<Detail> {
+    let input_name = node_param_str(target, "Input", "");
+    let input = match find_input_node(root, target, &input_name) {
+        Some(n) => generate_single_node_geometry_with_errors(root, n, visited, ocl_error, sim).unwrap_or_default(),
+        None => Detail::new(),
+    };
+    let code = node_param_str(target, "Code", "");
+    let class = crate::wrangle::parse_class(&node_param_str(target, "Class", "Points"));
+    let group = node_param_str(target, "Group", "");
+
+    let mut chans = std::collections::HashMap::new();
+    {
+        use crate::expr::Scope as _;
+        let mut scope = TreeScope::new(root, target, sim.frame);
+        for path in crate::wrangle::channel_refs(&code) {
+            let r = scope
+                .channel(&path, ChKind::Float)
+                .and_then(|n| scope.channel(&path, ChKind::Str).map(|s| crate::wrangle::Chan::new(n.as_num(), s.as_str())));
+            chans.insert(path, r);
+        }
+    }
+
+    match crate::wrangle::run_wrangle(input.clone(), &code, class, &group, sim.frame, chans) {
+        Ok(d) => Some(d),
+        Err(e) => {
+            if ocl_error.is_none() {
+                *ocl_error = Some(format!("{}: {e}", target.name));
+            }
+            Some(input)
+        }
+    }
+}
+
 /// The Switch node: one of up to four inputs, chosen by Index.
 ///
 /// What a composed subnet puts behind a choice: the Embryo's Source is a
@@ -5544,6 +5594,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
         || nt == "boolean"
         || nt == "mold_shell"
         || nt == "hull"
+        || nt == "wrangle"
         || nt == "switch"
         || nt == "volume"
         || nt == "deform"
@@ -5874,6 +5925,15 @@ pub fn network_sphere_vertices_with_errors(
                     out.merge(&geom);
                 }
             }
+        } else if node.node_type.eq_ignore_ascii_case("wrangle") {
+            let _idx = *count;
+            *count += 1;
+            if is_visible {
+                let mut visited = Vec::new();
+                if let Some(geom) = resolve_wrangle_geometry_with_errors(root, node, &mut visited, ocl_error, sim) {
+                    out.merge(&geom);
+                }
+            }
         } else if node.node_type.eq_ignore_ascii_case("switch") {
             let _idx = *count;
             *count += 1;
diff --git a/src/main.rs b/src/main.rs
index 6189029..85caa19 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,6 +10,7 @@ pub mod export_cli;
 pub mod remesh;
 pub mod spatial;
 pub mod volume;
+pub mod wrangle;
 
 // Root-level aliases some modules import via `crate::` paths.
 #[allow(unused_imports)]
@@ -10455,5 +10456,205 @@ mod tests {
         assert!((state.grid_pitch_x - 140.0).abs() < 0.01, "short labels fit at 100%, got pitch {}", state.grid_pitch_x);
         assert!(label_right(&state, long) <= px + pw - padding + 0.5);
     }
-}
 
+    // ---- the wrangle node (src/wrangle.rs) ----
+
+    fn wrangle_node(id: &str, name: &str, input: &str, class: &str, code: &str) -> FsNode {
+        ref_node(id, name, "wrangle", vec![("Input", "text", input), ("Class", "choice:Points,Primitives,Detail", class), ("Group", "text", ""), ("Code", "code", code)], vec![])
+    }
+
+    /// The input as the wrangle sees it, then the wrangle's own result.
+    fn wrangle_over_sphere(code: &str) -> (Detail, Option<Detail>, Option<String>) {
+        let src = ref_node("s", "src", "sphere", vec![("Radius", "slider", "1.0")], vec![]);
+        let w = wrangle_node("w", "wrangle1", "src", "Points", code);
+        let root = ref_node("root", "root", "node", vec![], vec![src, w]);
+        let before = eval(&root, &root.children[0]).0.unwrap();
+        let (g, err) = eval(&root, &root.children[1]);
+        (before, g, err)
+    }
+
+    /// `@name` is sugar for an index on the element, and only outside
+    /// strings and comments — a message that mentions `@` keeps it.
+    #[test]
+    fn wrangle_desugars_at_names_outside_strings_and_comments() {
+        use crate::wrangle::desugar;
+        assert_eq!(desugar("@P.y += 1.0;"), "__at[\"P\"].y += 1.0;");
+        assert_eq!(desugar("@mass = @mass * 2;"), "__at[\"mass\"] = __at[\"mass\"] * 2;");
+        assert_eq!(desugar("let s = \"at @P\"; // @P here\n@x = 1;"), "let s = \"at @P\"; // @P here\n__at[\"x\"] = 1;");
+        assert_eq!(desugar("/* @a */ @b = '@';"), "/* @a */ __at[\"b\"] = '@';");
+        assert_eq!(desugar("a @ b"), "a @ b", "a bare @ is not sugar");
+        assert_eq!(crate::wrangle::channel_refs("ch(\"../Radius\") + chs('Name') + search(\"x\") // ch(\"no\")"), vec!["../Radius".to_string(), "Name".to_string()]);
+    }
+
+    /// The core contract: positions move, and naming an attribute creates
+    /// it, typed by what was written.
+    #[test]
+    fn wrangle_moves_points_and_creates_typed_attributes() {
+        use crate::detail::AttribType;
+        let (before, g, err) = wrangle_over_sphere(
+            "@P.y += 1.0;\n@mass = 2.5;\n@count = @ptnum;\n@dir = vec3(1, 0, 0) * 2;\n@half = [0.5, 0.25];\n@flag = @ptnum > 3;\n@Cd = vec3(1.0, 0.0, 0.0);",
+        );
+        assert!(err.is_none(), "{err:?}");
+        let g = g.unwrap();
+        assert_eq!(g.num_points(), before.num_points());
+        assert_eq!(g.num_prims(), before.num_prims(), "a per-point script leaves the topology alone");
+        for p in 0..g.num_points() {
+            assert!((g.pos(p).y - (before.pos(p).y + 1.0)).abs() < 1e-5, "point {p} moved up by one");
+        }
+        let pts = g.points();
+        assert_eq!(pts.get("mass").unwrap().ty(), AttribType::Float);
+        assert_eq!(pts.get("count").unwrap().ty(), AttribType::Int, "an int written makes an int attribute");
+        assert_eq!(pts.get("dir").unwrap().ty(), AttribType::Float3);
+        assert_eq!(pts.get("half").unwrap().ty(), AttribType::Float2);
+        assert_eq!(pts.get("flag").unwrap().ty(), AttribType::Int, "a bool is an int");
+        assert_eq!(pts.value("count", 5).unwrap().as_f32(), 5.0);
+        assert_eq!(pts.value("dir", 0).unwrap().as_vec3(), Vec3::new(2.0, 0.0, 0.0));
+        assert_eq!(pts.value("flag", 4).unwrap().as_f32(), 1.0);
+        assert_eq!(g.color(2), [1.0, 0.0, 0.0]);
+    }
+
+    /// Neighbours and other points are reachable, off the derived topology.
+    #[test]
+    fn wrangle_reads_topology_and_other_points() {
+        let (_, g, err) = wrangle_over_sphere(
+            "@valence = neighbours(@ptnum).len();\nlet sum = 0.0;\nfor n in neighbours(@ptnum) { sum += point(\"P\", n).x; }\n@nx = sum;\n@nprims = prims(@ptnum).len();\n@near = nearest(@P, 0.6).len();",
+        );
+        assert!(err.is_none(), "{err:?}");
+        let g = g.unwrap();
+        for p in 0..g.num_points() {
+            let want = g.point_neighbours(p).len() as f32;
+            assert_eq!(g.points().value("valence", p).unwrap().as_f32(), want, "point {p}");
+            let sum: f32 = g.point_neighbours(p).iter().map(|&n| g.pos(n as usize).x).sum();
+            assert!((g.points().value("nx", p).unwrap().as_f32() - sum).abs() < 1e-4);
+            assert_eq!(g.points().value("nprims", p).unwrap().as_f32(), g.point_prims(p).len() as f32);
+            assert!(g.points().value("near", p).unwrap().as_f32() >= 1.0, "a point is within its own radius");
+        }
+    }
+
+    /// The Group parameter narrows the run; everything else is untouched.
+    #[test]
+    fn wrangle_runs_over_a_group_only_and_removes_points() {
+        use crate::wrangle::run_wrangle;
+        let mut d = crate::geometry::sphere_detail(Vec3::ZERO, 1.0, 6, 8);
+        d.points_mut().create_group("top");
+        for p in 0..5 {
+            d.points_mut().add_to_group("top", p);
+        }
+        let before = d.clone();
+        let out = run_wrangle(d.clone(), "@P += vec3(0, 10, 0); @touched = 1;", crate::detail::Class::Point, "top", 1, Default::default()).unwrap();
+        for p in 0..out.num_points() {
+            let moved = (out.pos(p).y - before.pos(p).y - 10.0).abs() < 1e-4;
+            assert_eq!(moved, p < 5, "point {p}");
+            assert_eq!(out.points().value("touched", p).unwrap().as_f32(), if p < 5 { 1.0 } else { 0.0 });
+        }
+        let out = run_wrangle(d, "if @ptnum % 2 == 0 { removepoint(@ptnum); }", crate::detail::Class::Point, "", 1, Default::default()).unwrap();
+        assert_eq!(out.num_points(), before.num_points() / 2, "every even point removed, after the run");
+    }
+
+    /// Detail class runs once, with no input at all, and builds geometry
+    /// through the deferred adds.
+    #[test]
+    fn wrangle_detail_class_builds_geometry_from_nothing() {
+        let w = wrangle_node("w", "wrangle1", "", "Detail", "let a = addpoint(vec3(0, 0, 0));\nlet b = addpoint(vec3(1, 0, 0));\nlet c = addpoint([0, 1, 0]);\naddprim([a, b, c]);\nsetdetail(\"made\", 3);\n@note = 7;");
+        let root = ref_node("root", "root", "node", vec![], vec![w]);
+        let (g, err) = eval(&root, &root.children[0]);
+        assert!(err.is_none(), "{err:?}");
+        let g = g.unwrap();
+        assert_eq!((g.num_points(), g.num_prims()), (3, 1));
+        assert_eq!(g.prim_points(0), &[0, 1, 2]);
+        assert_eq!(g.pos(1), Vec3::new(1.0, 0.0, 0.0));
+        assert_eq!(g.detail().value("made", 0).unwrap().as_f32(), 3.0);
+        assert_eq!(g.detail().value("note", 0).unwrap().as_f32(), 7.0, "@name on the detail class is a detail attribute");
+    }
+
+    /// Primitives class: `@P` is the centroid, read-only; attributes land on
+    /// the primitive store.
+    #[test]
+    fn wrangle_prims_class_writes_prim_attributes_and_reads_centroids() {
+        let src = ref_node("s", "src", "sphere", vec![("Radius", "slider", "1.0")], vec![]);
+        let w = wrangle_node("w", "wrangle1", "src", "Primitives", "@r = length(@P);\n@n = points(@primnum).len();\n@which = @primnum;");
+        let root = ref_node("root", "root", "node", vec![], vec![src, w]);
+        let (g, err) = eval(&root, &root.children[1]);
+        assert!(err.is_none(), "{err:?}");
+        let g = g.unwrap();
+        assert!(g.num_prims() > 0);
+        assert!(g.points().get("r").is_none(), "a prim wrangle writes no point attribute");
+        for pr in 0..g.num_prims() {
+            let pts = g.prim_points(pr);
+            let centroid = pts.iter().map(|&p| g.pos(p as usize)).sum::<Vec3>() / pts.len() as f32;
+            assert!((g.prims().value("r", pr).unwrap().as_f32() - centroid.length()).abs() < 1e-4);
+            assert_eq!(g.prims().value("n", pr).unwrap().as_f32(), pts.len() as f32);
+            assert_eq!(g.prims().value("which", pr).unwrap().as_f32(), pr as f32);
+        }
+        let w2 = wrangle_node("w2", "wrangle2", "src", "Primitives", "@P = vec3(0, 0, 0);");
+        let root2 = ref_node("root", "root", "node", vec![], vec![root.children[0].clone(), w2]);
+        let (_, err) = eval(&root2, &root2.children[1]);
+        assert!(err.as_deref().is_some_and(|e| e.contains("read-only")), "{err:?}");
+    }
+
+    /// Errors name the node and the element, and the input passes through.
+    #[test]
+    fn wrangle_errors_report_the_node_and_pass_the_input_through() {
+        let (before, g, err) = wrangle_over_sphere("@P.y += ;");
+        let err = err.expect("a syntax error is reported");
+        assert!(err.starts_with("wrangle1: syntax"), "{err}");
+        let g = g.unwrap();
+        assert_eq!(g.num_points(), before.num_points());
+        assert_eq!(g.pos(3), before.pos(3), "the input passes through untouched");
+
+        let (_, g, err) = wrangle_over_sphere("if @ptnum == 4 { @x = point(\"P\", 100000); }");
+        let err = err.expect("a runtime error is reported");
+        assert!(err.starts_with("wrangle1: point 4:"), "{err}");
+        assert!(err.contains("out of range"), "{err}");
+        assert!(g.unwrap().points().get("x").is_none(), "nothing of a failed run survives");
+
+        let (_, _, err) = wrangle_over_sphere("loop { }");
+        let err = err.expect("a script that never ends is stopped");
+        assert!(err.contains("operations") || err.contains("stopped"), "{err}");
+    }
+
+    /// `ch()` reaches the node's own parameters and its parent's, evaluated:
+    /// an expression-valued parameter is seen as its value.
+    #[test]
+    fn wrangle_ch_reads_parameters_through_the_expression_scope() {
+        let src = ref_node("s", "src", "sphere", vec![("Radius", "slider", "1.0")], vec![]);
+        let w = ref_node("w", "wrangle1", "wrangle", vec![
+            ("Input", "text", "src"), ("Class", "choice:Points,Primitives,Detail", "Points"), ("Group", "text", ""),
+            ("Amount", "slider", "ch(\"../Lift\") + 1"),
+            ("Label", "text", "hello"),
+            ("Code", "code", "@P = @P * ch(\"Amount\") + vec3(0, ch(\"../Lift\"), 0);\n@n = chi(\"Amount\");\n@s = chs(\"Label\").len();\n@v = chv(\"../Offset\");"),
+        ], vec![]);
+        let root = ref_node("root", "root", "node", vec![("Lift", "slider", "2"), ("Offset", "float3", "1:2:3")], vec![src, w]);
+        let before = eval(&root, &root.children[0]).0.unwrap();
+        let (g, err) = eval(&root, &root.children[1]);
+        assert!(err.is_none(), "{err:?}");
+        let g = g.unwrap();
+        assert_eq!(g.num_points(), before.num_points());
+        let p = 7;
+        let want = before.pos(p) * 3.0 + Vec3::new(0.0, 2.0, 0.0);
+        assert!((g.pos(p) - want).length() < 1e-4, "got {:?}, want {want:?}", g.pos(p));
+        assert_eq!(g.points().value("n", p).unwrap().as_f32(), 3.0);
+        assert_eq!(g.points().value("s", p).unwrap().as_f32(), 5.0);
+        assert_eq!(g.points().value("v", p).unwrap().as_vec3(), Vec3::new(1.0, 2.0, 3.0));
+
+        let bad = wrangle_node("b", "wrangle2", "src", "Points", "@x = ch(\"Nope\");");
+        let root2 = ref_node("root", "root", "node", vec![], vec![root.children[0].clone(), bad]);
+        let (_, err) = eval(&root2, &root2.children[1]);
+        assert!(err.as_deref().is_some_and(|e| e.contains("Nope")), "a channel to nothing is an error: {err:?}");
+    }
+
+    /// The shipped template resolves, and its default script runs.
+    #[test]
+    fn wrangle_template_ships_and_its_default_code_runs() {
+        let templates = crate::app::load_fs_tree();
+        let t = templates.children.iter().find(|n| n.node_type == "wrangle").expect("nodes/wrangle.json loads");
+        assert_eq!(t.name, "Wrangle");
+        let code = t.params.iter().find(|p| p.name == "Code").map(|p| p.default.clone()).unwrap();
+        assert!(t.params.iter().all(|p| !p.expr), "no template parameter reads as an expression — least of all the Code");
+        let (before, g, err) = wrangle_over_sphere(&code);
+        assert!(err.is_none(), "{err:?}");
+        let g = g.unwrap();
+        let moved = (0..g.num_points()).filter(|&p| (g.pos(p).y - before.pos(p).y).abs() > 1e-6).count();
+        assert!(moved > 0, "the default script deforms the input");
+    }
+}
diff --git a/src/wrangle.rs b/src/wrangle.rs
new file mode 100644
index 0000000..7d1e5d5
--- /dev/null
+++ b/src/wrangle.rs
@@ -0,0 +1,879 @@
+//! The wrangle node: a script run once per element, over the Detail's own
+//! surface.
+//!
+//! Houdini's attribwrangle, on an engine someone else maintains. The script
+//! language is Rhai, chosen in `shapeshifter.md` Phase 7 for being pure Rust
+//! with no C toolchain, sandboxed behind an operation budget — the step budget
+//! `kernel_cpu` reimplements by hand — and compiled once to an AST that is
+//! cached by source, as `OPENCL_CACHE` keys kernels. What this module adds is
+//! the BINDING: how a script reaches the geometry, and nothing else.
+//!
+//! The vocabulary, deliberately VEX-shaped so it reads as it does there:
+//!
+//! - `@P`, `@Cd`, `@N`, `@id`, `@ptnum` / `@primnum`, `@numpt` / `@numprim`,
+//!   `@Frame`, and `@name` for any attribute of any type. `@` is sugar —
+//!   Rhai has no such token — rewritten by [`desugar`] into an index on the
+//!   element (`__at["name"]`), so `@mass += 2.0` and `@P.y = 0.0` are ordinary
+//!   Rhai once the script reaches the engine. Naming an attribute creates it,
+//!   typed by the value written: a float, an int, a `vec3`, an array of two
+//!   or four. A float2 reads as a `vec3` with z = 0 and a float4 as an array.
+//! - `ch("path")`, `chs`, `chv`, `chi` — the node's own parameters and, by
+//!   Houdini's relative paths, any other node's. The paths are resolved BEFORE
+//!   the script runs, through the expression scope, so an expression-valued
+//!   parameter is evaluated first and the script sees its value; the cost per
+//!   element is a map lookup. A path therefore has to be a string literal.
+//! - `neighbours(pt)`, `prims(pt)`, `points(prim)` off the derived topology —
+//!   what decision 1 kept out of the kernel language because the interpreter
+//!   could not carry it — and `nearest(pos, radius)` off the point grid.
+//! - `point(name, i)` / `setpoint`, `prim(name, i)` / `setprim`,
+//!   `detail(name)` / `setdetail`, `ingroup(name)` / `setgroup(name, bool)`.
+//! - `addpoint(pos)`, `addprim([a, b, c])`, `removepoint(i)` — DEFERRED and
+//!   applied after the run, so a script iterating points sees a stable count.
+//!   `addpoint` returns the index the point will have.
+//! - `vec3(x, y, z)`, `dot`, `cross`, `length`, `normalize`, `distance`,
+//!   `lerp`, `fit`, `clamp`, `rand(seed)`, and Rhai's own math.
+//!
+//! CPU only, and deliberately: an interpreter is an order of magnitude or
+//! more below native Rust, which is fine for a wrangle over tens of thousands
+//! of elements per edit and wrong for a solver at a million per frame — that
+//! is Phase 7's step 4, WGSL compute through the renderer, and not this.
+
+use crate::detail::{AttribType, AttribValue, Class, Detail};
+use crate::spatial::PointGrid;
+use glam::Vec3;
+use rhai::{Array, Dynamic, Engine, EvalAltResult, ImmutableString, Scope, AST, FLOAT, INT};
+use std::cell::{Cell, RefCell};
+use std::collections::HashMap;
+use std::rc::Rc;
+use std::time::{Duration, Instant};
+
+/// Per-element operation budget. A real script is tens to hundreds of
+/// operations; this is a loop that never ends, caught in milliseconds.
+const OPS_PER_ELEMENT: u64 = 2_000_000;
+/// Wall-clock budget for the whole run, so a script that is merely slow over
+/// a large input is an error rather than a frozen UI.
+const RUN_BUDGET: Duration = Duration::from_secs(8);
+/// Compiled scripts kept by source. Small: a project has a handful of
+/// wrangles, and each edit of one is a new key.
+const AST_CACHE_CAP: usize = 64;
+
+/// A channel value resolved before the run: what `ch` / `chs` / `chv` read.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Chan {
+    pub num: f64,
+    pub text: String,
+}
+
+impl Chan {
+    pub fn new(num: f64, text: impl Into<String>) -> Self {
+        Chan { num, text: text.into() }
+    }
+
+    /// `chv`: three `:`-separated components, or the number splatted.
+    fn vec(&self) -> Vec3 {
+        let parts: Vec<f32> = self.text.split(':').filter_map(|p| p.trim().parse::<f32>().ok()).collect();
+        if parts.len() == 3 {
+            Vec3::new(parts[0], parts[1], parts[2])
+        } else {
+            Vec3::splat(self.num as f32)
+        }
+    }
+}
+
+/// The Class parameter: which element the script runs once per.
+pub fn parse_class(s: &str) -> Class {
+    let t = s.trim();
+    if t.eq_ignore_ascii_case("primitives") || t.eq_ignore_ascii_case("prims") || t.eq_ignore_ascii_case("primitive") {
+        Class::Prim
+    } else if t.eq_ignore_ascii_case("detail") {
+        Class::Detail
+    } else {
+        Class::Point
+    }
+}
+
+/// `@name` → `__at["name"]`, outside strings and comments.
+///
+/// Rhai has no `@` token, so this is the whole of the sugar: an identifier
+/// after `@` becomes an index on the element marker, and everything after it
+/// — `.x`, `+=`, `[0]` — is Rhai's own syntax on the value that comes back.
+/// String literals (double-quoted, backtick, and char literals) and both
+/// comment forms are copied through untouched, so `"@"` in a message stays a
+/// character.
+pub fn desugar(code: &str) -> String {
+    let b = code.as_bytes();
+    let mut out = String::with_capacity(code.len() + 32);
+    let mut i = 0;
+    while i < b.len() {
+        let c = b[i];
+        // Comments.
+        if c == b'/' && i + 1 < b.len() && b[i + 1] == b'/' {
+            let end = code[i..].find('\n').map_or(b.len(), |n| i + n);
+            out.push_str(&code[i..end]);
+            i = end;
+            continue;
+        }
+        if c == b'/' && i + 1 < b.len() && b[i + 1] == b'*' {
+            let end = code[i + 2..].find("*/").map_or(b.len(), |n| i + 2 + n + 2);
+            out.push_str(&code[i..end]);
+            i = end;
+            continue;
+        }
+        // String and char literals: copy to the matching close, honouring
+        // backslash escapes.
+        if c == b'"' || c == b'`' || c == b'\'' {
+            let quote = c;
+            let mut j = i + 1;
+            while j < b.len() {
+                if b[j] == b'\\' && quote != b'`' {
+                    j += 2;
+                    continue;
+                }
+                if b[j] == quote {
+                    j += 1;
+                    break;
+                }
+                j += 1;
+            }
+            let j = j.min(b.len());
+            out.push_str(&code[i..j]);
+            i = j;
+            continue;
+        }
+        if c == b'@' && i + 1 < b.len() && (b[i + 1].is_ascii_alphabetic() || b[i + 1] == b'_') {
+            let mut j = i + 1;
+            while j < b.len() && (b[j].is_ascii_alphanumeric() || b[j] == b'_') {
+                j += 1;
+            }
+            out.push_str("__at[\"");
+            out.push_str(&code[i + 1..j]);
+            out.push_str("\"]");
+            i = j;
+            continue;
+        }
+        out.push(c as char);
+        i += 1;
+    }
+    out
+}
+
+/// The channel paths a script names as string literals — `ch("../Radius")`,
+/// `chs`, `chv`, `chi`, `chf`, `chb` — so the caller can resolve them before
+/// the run. A path built at runtime is not found here and errors when read.
+pub fn channel_refs(code: &str) -> Vec<String> {
+    let src = strip_comments(code);
+    let b = src.as_bytes();
+    let mut out: Vec<String> = Vec::new();
+    for call in ["ch(", "chs(", "chv(", "chi(", "chf(", "chb("] {
+        let mut from = 0;
+        while let Some(pos) = src[from..].find(call) {
+            let at = from + pos;
+            from = at + call.len();
+            // A whole identifier: `search(` contains `ch(` and must not count.
+            if at > 0 && (b[at - 1].is_ascii_alphanumeric() || b[at - 1] == b'_') {
+                continue;
+            }
+            let rest = src[from..].trim_start();
+            let Some(quote) = rest.chars().next().filter(|c| *c == '"' || *c == '\'') else { continue };
+            let inner = &rest[1..];
+            let Some(end) = inner.find(quote) else { continue };
+            let path = inner[..end].to_string();
+            if !path.is_empty() && !out.contains(&path) {
+                out.push(path);
+            }
+        }
+    }
+    out
+}
+
+fn strip_comments(code: &str) -> String {
+    let mut out = String::with_capacity(code.len());
+    let mut rest = code;
+    while !rest.is_empty() {
+        if let Some(stripped) = rest.strip_prefix("//") {
+            let end = stripped.find('\n').unwrap_or(stripped.len());
+            rest = &stripped[end..];
+        } else if let Some(stripped) = rest.strip_prefix("/*") {
+            let end = stripped.find("*/").map_or(stripped.len(), |n| n + 2);
+            rest = &stripped[end..];
+        } else {
+            let mut chars = rest.chars();
+            out.push(chars.next().unwrap());
+            rest = chars.as_str();
+        }
+    }
+    out
+}
+
+// ------------------------------------------------------------ the binding
+
+/// The marker the desugared `@` indexes: `__at["P"]`. Carries nothing; the
+/// indexers reach the shared context through their captured handle.
+#[derive(Clone, Copy)]
+struct El;
+
+/// What the script runs against: the geometry, which element it is on, and
+/// the edits it has asked for that apply after the run.
+struct Ctx {
+    d: Detail,
+    class: Class,
+    i: usize,
+    frame: i32,
+    normals: Option<Vec<Vec3>>,
+    grid: Option<(f32, PointGrid)>,
+    adds: Vec<Vec3>,
+    prim_adds: Vec<Vec<u32>>,
+    removes: Vec<usize>,
+    chans: HashMap<String, Result<Chan, String>>,
+}
+
+type Shared = Rc<RefCell<Ctx>>;
+type RhaiResult<T> = Result<T, Box<EvalAltResult>>;
+
+fn rt<T>(msg: impl Into<String>) -> RhaiResult<T> {
+    Err(msg.into().into())
+}
+
+fn class_name(c: Class) -> &'static str {
+    match c {
+        Class::Point => "point",
+        Class::Vertex => "vertex",
+        Class::Prim => "primitive",
+        Class::Detail => "detail",
+    }
+}
+
+/// A number out of anything numeric the script can hold.
+fn num(v: &Dynamic) -> Result<f64, String> {
+    if v.is_float() {
+        Ok(v.as_float().unwrap_or(0.0))
+    } else if v.is_int() {
+        Ok(v.as_int().unwrap_or(0) as f64)
+    } else if v.is_bool() {
+        Ok(if v.as_bool().unwrap_or(false) { 1.0 } else { 0.0 })
+    } else {
+        Err(format!("expected a number, got {}", v.type_name()))
+    }
+}
+
+fn to_vec3(v: &Dynamic) -> Result<Vec3, String> {
+    if let Some(x) = v.clone().try_cast::<Vec3>() {
+        return Ok(x);
+    }
+    if v.is_array() {
+        let a = v.clone().into_array().unwrap_or_default();
+        if a.len() >= 3 {
+            return Ok(Vec3::new(num(&a[0])? as f32, num(&a[1])? as f32, num(&a[2])? as f32));
+        }
+        if a.len() == 2 {
+            return Ok(Vec3::new(num(&a[0])? as f32, num(&a[1])? as f32, 0.0));
+        }
+        return Err(format!("expected a vec3, got an array of {}", a.len()));
+    }
+    Ok(Vec3::splat(num(v)? as f32))
+}
+
+fn to_dyn(v: AttribValue) -> Dynamic {
+    match v {
+        AttribValue::Float(f) => Dynamic::from_float(f as FLOAT),
+        AttribValue::Int(i) => Dynamic::from_int(i as INT),
+        AttribValue::Float3(a) => Dynamic::from(Vec3::from(a)),
+        AttribValue::Float2(a) => Dynamic::from(Vec3::new(a[0], a[1], 0.0)),
+        AttribValue::Float4(a) => Dynamic::from_array(a.iter().map(|&f| Dynamic::from_float(f as FLOAT)).collect()),
+    }
+}
+
+/// A script value converted to an attribute's type — the type of the
+/// attribute being written, so a whole number into a float attribute is a
+/// float and a float into an int attribute is truncated, as the kernel
+/// vocabulary does.
+fn to_attr(v: &Dynamic, ty: AttribType) -> Result<AttribValue, String> {
+    Ok(match ty {
+        AttribType::Float => AttribValue::Float(to_scalar(v)? as f32),
+        AttribType::Int => AttribValue::Int(to_scalar(v)?.trunc() as i32),
+        AttribType::Float3 => AttribValue::Float3(to_vec3(v)?.to_array()),
+        AttribType::Float2 => {
+            let a = to_vec3(v)?;
+            AttribValue::Float2([a.x, a.y])
+        }
+        AttribType::Float4 => {
+            if v.is_array() {
+                let a = v.clone().into_array().unwrap_or_default();
+                if a.len() != 4 {
+                    return Err(format!("expected four components, got {}", a.len()));
+                }
+                AttribValue::Float4([num(&a[0])? as f32, num(&a[1])? as f32, num(&a[2])? as f32, num(&a[3])? as f32])
+            } else if let Some(x) = v.clone().try_cast::<Vec3>() {
+                AttribValue::Float4([x.x, x.y, x.z, 1.0])
+            } else {
+                AttribValue::Float4([num(v)? as f32; 4])
+            }
+        }
+    })
+}
+
+/// A scalar out of a number or a vector's first component.
+fn to_scalar(v: &Dynamic) -> Result<f64, String> {
+    if let Some(x) = v.clone().try_cast::<Vec3>() {
+        return Ok(x.x as f64);
+    }
+    num(v)
+}
+
+/// The attribute type a fresh attribute takes from the first value written.
+fn infer_type(v: &Dynamic) -> Result<AttribType, String> {
+    if v.is_float() {
+        Ok(AttribType::Float)
+    } else if v.is_int() || v.is_bool() {
+        Ok(AttribType::Int)
+    } else if v.clone().try_cast::<Vec3>().is_some() {
+        Ok(AttribType::Float3)
+    } else if v.is_array() {
+        match v.clone().into_array().unwrap_or_default().len() {
+            2 => Ok(AttribType::Float2),
+            3 => Ok(AttribType::Float3),
+            4 => Ok(AttribType::Float4),
+            n => Err(format!("an array of {n} is not an attribute type (2, 3 or 4 components)")),
+        }
+    } else {
+        Err(format!("a {} cannot be stored as an attribute", v.type_name()))
+    }
+}
+
+fn zero_of(ty: AttribType) -> AttribValue {
+    match ty {
+        AttribType::Float => AttribValue::Float(0.0),
+        AttribType::Int => AttribValue::Int(0),
+        AttribType::Float2 => AttribValue::Float2([0.0; 2]),
+        AttribType::Float3 => AttribValue::Float3([0.0; 3]),
+        AttribType::Float4 => AttribValue::Float4([0.0; 4]),
+    }
+}
+
+impl Ctx {
+    fn check(&self, class: Class, i: usize) -> Result<(), String> {
+        let n = match class {
+            Class::Point => self.d.num_points(),
+            Class::Prim => self.d.num_prims(),
+            Class::Vertex => self.d.num_verts(),
+            Class::Detail => 1,
+        };
+        if i >= n {
+            return Err(format!("{} {i} is out of range ({n} {}s)", class_name(class), class_name(class)));
+        }
+        Ok(())
+    }
+
+    /// One element's attribute, or one of the intrinsics that read like one.
+    fn read(&mut self, class: Class, i: usize, name: &str) -> Result<Dynamic, String> {
+        match name {
+            "ptnum" | "primnum" | "elemnum" => return Ok(Dynamic::from_int(i as INT)),
+            "numpt" => return Ok(Dynamic::from_int(self.d.num_points() as INT)),
+            "numprim" => return Ok(Dynamic::from_int(self.d.num_prims() as INT)),
+            "Frame" => return Ok(Dynamic::from_int(self.frame as INT)),
+            _ => {}
+        }
+        self.check(class, i)?;
+        match (class, name) {
+            (Class::Point, "P") => Ok(Dynamic::from(self.d.pos(i))),
+            (Class::Prim, "P") => {
+                let pts = self.d.prim_points(i);
+                let sum: Vec3 = pts.iter().map(|&p| self.d.pos(p as usize)).sum();
+                Ok(Dynamic::from(if pts.is_empty() { Vec3::ZERO } else { sum / pts.len() as f32 }))
+            }
+            (Class::Point, "Cd") => Ok(Dynamic::from(Vec3::from(self.d.color(i)))),
+            (Class::Point, "id") => Ok(Dynamic::from_int(self.d.id(i).unwrap_or(0) as INT)),
+            (Class::Point, "N") if !self.d.points().has("N") => {
+                if self.normals.is_none() {
+                    self.normals = Some(crate::geometry::point_normals(&self.d));
+                }
+                Ok(Dynamic::from(self.normals.as_ref().unwrap()[i]))
+            }
+            _ => match self.d.store(class).value(name, i) {
+                Some(v) => Ok(to_dyn(v)),
+                None => Ok(Dynamic::from_float(0.0)),
+            },
+        }
+    }
+
+    fn write(&mut self, class: Class, i: usize, name: &str, v: &Dynamic) -> Result<(), String> {
+        match name {
+            "ptnum" | "primnum" | "elemnum" | "numpt" | "numprim" | "Frame" | "id" => {
+                return Err(format!("@{name} is read-only"));
+            }
+            _ => {}
+        }
+        self.check(class, i)?;
+        match (class, name) {
+            (Class::Point, "P") => {
+                self.d.set_pos(i, to_vec3(v)?);
+                return Ok(());
+            }
+            (Class::Point, "Cd") => {
+                self.d.set_color(i, to_vec3(v)?.to_array());
+                return Ok(());
+            }
+            (_, "P") => return Err(format!("@P is read-only on a {}", class_name(class))),
+            _ => {}
+        }
+        let store = self.d.store_mut(class);
+        let ty = match store.get(name) {
+            Some(data) => data.ty(),
+            None => {
+                let ty = infer_type(v)?;
+                store.create(name, zero_of(ty));
+                ty
+            }
+        };
+        store.set_value(name, i, to_attr(v, ty)?)
+    }
+}
+
+fn wrap<T>(r: Result<T, String>) -> RhaiResult<T> {
+    r.map_err(|e| e.into())
+}
+
+fn index_arg(v: &Dynamic) -> Result<usize, String> {
+    let n = num(v)?;
+    if n < 0.0 {
+        return Err(format!("index {n} is negative"));
+    }
+    Ok(n as usize)
+}
+
+/// A deterministic unit float from a seed: splitmix64 over the seed's bits,
+/// so `rand(@ptnum)` and `rand(@P.x * 7.3)` both give a stable draw.
+fn rand_unit(seed: &Dynamic) -> f64 {
+    let bits: u64 = if seed.is_float() {
+        seed.as_float().unwrap_or(0.0).to_bits()
+    } else {
+        seed.as_int().unwrap_or(0) as u64
+    };
+    let mut z = bits.wrapping_add(0x9E37_79B9_7F4A_7C15);
+    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
+    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
+    z ^= z >> 31;
+    (z >> 11) as f64 / (1u64 << 53) as f64
+}
+
+fn build_engine(ctx: &Shared) -> Engine {
+    let mut engine = Engine::new();
+    engine.set_max_operations(OPS_PER_ELEMENT);
+
+    // Numbers mix. Rhai keeps ints and floats apart by default, and a script
+    // that writes `@P.y * 2` should not have to know which it wrote.
+    engine
+        .register_fn("+", |a: INT, b: FLOAT| a as FLOAT + b)
+        .register_fn("+", |a: FLOAT, b: INT| a + b as FLOAT)
+        .register_fn("-", |a: INT, b: FLOAT| a as FLOAT - b)
+        .register_fn("-", |a: FLOAT, b: INT| a - b as FLOAT)
+        .register_fn("*", |a: INT, b: FLOAT| a as FLOAT * b)
+        .register_fn("*", |a: FLOAT, b: INT| a * b as FLOAT)
+        .register_fn("/", |a: INT, b: FLOAT| a as FLOAT / b)
+        .register_fn("/", |a: FLOAT, b: INT| a / b as FLOAT)
+        .register_fn("%", |a: INT, b: FLOAT| (a as FLOAT) % b)
+        .register_fn("%", |a: FLOAT, b: INT| a % b as FLOAT)
+        .register_fn("**", |a: INT, b: FLOAT| (a as FLOAT).powf(b))
+        .register_fn("**", |a: FLOAT, b: INT| a.powf(b as FLOAT))
+        .register_fn("<", |a: INT, b: FLOAT| (a as FLOAT) < b)
+        .register_fn("<", |a: FLOAT, b: INT| a < b as FLOAT)
+        .register_fn(">", |a: INT, b: FLOAT| (a as FLOAT) > b)
+        .register_fn(">", |a: FLOAT, b: INT| a > b as FLOAT)
+        .register_fn("<=", |a: INT, b: FLOAT| (a as FLOAT) <= b)
+        .register_fn("<=", |a: FLOAT, b: INT| a <= b as FLOAT)
+        .register_fn(">=", |a: INT, b: FLOAT| (a as FLOAT) >= b)
+        .register_fn(">=", |a: FLOAT, b: INT| a >= b as FLOAT)
+        .register_fn("==", |a: INT, b: FLOAT| (a as FLOAT) == b)
+        .register_fn("==", |a: FLOAT, b: INT| a == b as FLOAT)
+        .register_fn("!=", |a: INT, b: FLOAT| (a as FLOAT) != b)
+        .register_fn("!=", |a: FLOAT, b: INT| a != b as FLOAT);
+
+    // vec3: glam's, with components, arithmetic and the usual functions.
+    engine
+        .register_type_with_name::<Vec3>("vec3")
+        .register_get_set("x", |v: &mut Vec3| v.x as FLOAT, |v: &mut Vec3, x: FLOAT| v.x = x as f32)
+        .register_get_set("y", |v: &mut Vec3| v.y as FLOAT, |v: &mut Vec3, y: FLOAT| v.y = y as f32)
+        .register_get_set("z", |v: &mut Vec3| v.z as FLOAT, |v: &mut Vec3, z: FLOAT| v.z = z as f32)
+        .register_set("x", |v: &mut Vec3, x: INT| v.x = x as f32)
+        .register_set("y", |v: &mut Vec3, y: INT| v.y = y as f32)
+        .register_set("z", |v: &mut Vec3, z: INT| v.z = z as f32)
+        .register_indexer_get(|v: &mut Vec3, i: INT| -> RhaiResult<FLOAT> {
+            match i {
+                0 => Ok(v.x as FLOAT),
+                1 => Ok(v.y as FLOAT),
+                2 => Ok(v.z as FLOAT),
+                _ => rt(format!("vec3 index {i} out of range")),
+            }
+        })
+        .register_indexer_set(|v: &mut Vec3, i: INT, x: Dynamic| -> RhaiResult<()> {
+            let x = wrap(num(&x))? as f32;
+            match i {
+                0 => v.x = x,
+                1 => v.y = x,
+                2 => v.z = x,
+                _ => return rt(format!("vec3 index {i} out of range")),
+            }
+            Ok(())
+        })
+        .register_fn("vec3", |x: Dynamic, y: Dynamic, z: Dynamic| -> RhaiResult<Vec3> {
+            Ok(Vec3::new(wrap(num(&x))? as f32, wrap(num(&y))? as f32, wrap(num(&z))? as f32))
+        })
+        .register_fn("vec3", |x: Dynamic| -> RhaiResult<Vec3> { wrap(to_vec3(&x)) })
+        .register_fn("+", |a: Vec3, b: Vec3| a + b)
+        .register_fn("-", |a: Vec3, b: Vec3| a - b)
+        .register_fn("-", |a: Vec3| -a)
+        .register_fn("*", |a: Vec3, b: Vec3| a * b)
+        .register_fn("*", |a: Vec3, b: FLOAT| a * b as f32)
+        .register_fn("*", |a: FLOAT, b: Vec3| b * a as f32)
+        .register_fn("*", |a: Vec3, b: INT| a * b as f32)
+        .register_fn("*", |a: INT, b: Vec3| b * a as f32)
+        .register_fn("/", |a: Vec3, b: Vec3| a / b)
+        .register_fn("/", |a: Vec3, b: FLOAT| a / b as f32)
+        .register_fn("/", |a: Vec3, b: INT| a / b as f32)
+        .register_fn("==", |a: Vec3, b: Vec3| a == b)
+        .register_fn("!=", |a: Vec3, b: Vec3| a != b)
+        .register_fn("to_string", |v: Vec3| format!("{}:{}:{}", v.x, v.y, v.z))
+        .register_fn("to_debug", |v: Vec3| format!("vec3({}, {}, {})", v.x, v.y, v.z))
+        .register_fn("dot", |a: Vec3, b: Vec3| a.dot(b) as FLOAT)
+        .register_fn("cross", |a: Vec3, b: Vec3| a.cross(b))
+        .register_fn("length", |a: Vec3| a.length() as FLOAT)
+        .register_fn("length2", |a: Vec3| a.length_squared() as FLOAT)
+        .register_fn("normalize", |a: Vec3| a.normalize_or_zero())
+        .register_fn("distance", |a: Vec3, b: Vec3| a.distance(b) as FLOAT)
+        .register_fn("abs", |a: Vec3| a.abs())
+        .register_fn("min", |a: Vec3, b: Vec3| a.min(b))
+        .register_fn("max", |a: Vec3, b: Vec3| a.max(b))
+        .register_fn("lerp", |a: Vec3, b: Vec3, t: Dynamic| -> RhaiResult<Vec3> { Ok(a.lerp(b, wrap(num(&t))? as f32)) })
+        .register_fn("lerp", |a: FLOAT, b: FLOAT, t: FLOAT| a + (b - a) * t)
+        .register_fn("clamp", |v: Vec3, lo: Vec3, hi: Vec3| v.clamp(lo, hi))
+        .register_fn("clamp", |v: Dynamic, lo: Dynamic, hi: Dynamic| -> RhaiResult<FLOAT> {
+            let (v, lo, hi) = (wrap(num(&v))?, wrap(num(&lo))?, wrap(num(&hi))?);
+            Ok(v.max(lo).min(hi))
+        })
+        .register_fn("fit", |v: Dynamic, a: Dynamic, b: Dynamic, c: Dynamic, d: Dynamic| -> RhaiResult<FLOAT> {
+            let (v, a, b, c, d) = (wrap(num(&v))?, wrap(num(&a))?, wrap(num(&b))?, wrap(num(&c))?, wrap(num(&d))?);
+            let t = if (b - a).abs() < 1e-12 { 0.0 } else { ((v - a) / (b - a)).clamp(0.0, 1.0) };
+            Ok(c + (d - c) * t)
+        })
+        .register_fn("rand", |seed: Dynamic| rand_unit(&seed));
+
+    // The element: `@name` desugars to `__at["name"]`.
+    engine.register_type_with_name::<El>("element");
+    let c = ctx.clone();
+    engine.register_indexer_get(move |_: &mut El, name: ImmutableString| -> RhaiResult<Dynamic> {
+        let mut c = c.borrow_mut();
+        let (class, i) = (c.class, c.i);
+        wrap(c.read(class, i, &name))
+    });
+    let c = ctx.clone();
+    engine.register_indexer_set(move |_: &mut El, name: ImmutableString, v: Dynamic| -> RhaiResult<()> {
+        let mut c = c.borrow_mut();
+        let (class, i) = (c.class, c.i);
+        wrap(c.write(class, i, &name, &v))
+    });
+
+    // Other elements, by index.
+    let c = ctx.clone();
+    engine.register_fn("point", move |name: ImmutableString, i: Dynamic| -> RhaiResult<Dynamic> {
+        let i = wrap(index_arg(&i))?;
+        wrap(c.borrow_mut().read(Class::Point, i, &name))
+    });
+    let c = ctx.clone();
+    engine.register_fn("setpoint", move |name: ImmutableString, i: Dynamic, v: Dynamic| -> RhaiResult<()> {
+        let i = wrap(index_arg(&i))?;
+        wrap(c.borrow_mut().write(Class::Point, i, &name, &v))
+    });
+    let c = ctx.clone();
+    engine.register_fn("prim", move |name: ImmutableString, i: Dynamic| -> RhaiResult<Dynamic> {
+        let i = wrap(index_arg(&i))?;
+        wrap(c.borrow_mut().read(Class::Prim, i, &name))
+    });
+    let c = ctx.clone();
+    engine.register_fn("setprim", move |name: ImmutableString, i: Dynamic, v: Dynamic| -> RhaiResult<()> {
+        let i = wrap(index_arg(&i))?;
+        wrap(c.borrow_mut().write(Class::Prim, i, &name, &v))
+    });
+    let c = ctx.clone();
+    engine.register_fn("detail", move |name: ImmutableString| -> RhaiResult<Dynamic> {
+        wrap(c.borrow_mut().read(Class::Detail, 0, &name))
+    });
+    let c = ctx.clone();
+    engine.register_fn("setdetail", move |name: ImmutableString, v: Dynamic| -> RhaiResult<()> {
+        wrap(c.borrow_mut().write(Class::Detail, 0, &name, &v))
+    });
+    let c = ctx.clone();
+    engine.register_fn("npoints", move || c.borrow().d.num_points() as INT);
+    let c = ctx.clone();
+    engine.register_fn("nprims", move || c.borrow().d.num_prims() as INT);
+
+    // Topology.
+    let c = ctx.clone();
+    engine.register_fn("neighbours", move |i: Dynamic| -> RhaiResult<Array> {
+        let i = wrap(index_arg(&i))?;
+        let c = c.borrow();
+        wrap(c.check(Class::Point, i))?;
+        Ok(c.d.point_neighbours(i).iter().map(|&n| Dynamic::from_int(n as INT)).collect())
+    });
+    let c = ctx.clone();
+    engine.register_fn("neighbors", move |i: Dynamic| -> RhaiResult<Array> {
+        let i = wrap(index_arg(&i))?;
+        let c = c.borrow();
+        wrap(c.check(Class::Point, i))?;
+        Ok(c.d.point_neighbours(i).iter().map(|&n| Dynamic::from_int(n as INT)).collect())
+    });
+    let c = ctx.clone();
+    engine.register_fn("prims", move |i: Dynamic| -> RhaiResult<Array> {
+        let i = wrap(index_arg(&i))?;
+        let c = c.borrow();
+        wrap(c.check(Class::Point, i))?;
+        Ok(c.d.point_prims(i).iter().map(|&n| Dynamic::from_int(n as INT)).collect())
+    });
+    let c = ctx.clone();
+    engine.register_fn("points", move |i: Dynamic| -> RhaiResult<Array> {
+        let i = wrap(index_arg(&i))?;
+        let c = c.borrow();
+        wrap(c.check(Class::Prim, i))?;
+        Ok(c.d.prim_points(i).iter().map(|&n| Dynamic::from_int(n as INT)).collect())
+    });
+    let c = ctx.clone();
+    engine.register_fn("nearest", move |pos: Dynamic, radius: Dynamic| -> RhaiResult<Array> {
+        let pos = wrap(to_vec3(&pos))?;
+        let radius = wrap(num(&radius))? as f32;
+        if radius <= 0.0 {
+            return Ok(Array::new());
+        }
+        let mut c = c.borrow_mut();
+        // The grid is built from the positions as they stand at the first
+        // call and keyed by radius; a script that moves points and asks
+        // again reads the earlier layout, which is what a per-element pass
+        // should see anyway.
+        if c.grid.as_ref().map_or(true, |(r, _)| (*r - radius).abs() > 1e-6) {
+            let pts: Vec<Vec3> = (0..c.d.num_points()).map(|p| c.d.pos(p)).collect();
+            c.grid = Some((radius, PointGrid::build(&pts, radius)));
+        }
+        let mut out = Vec::new();
+        c.grid.as_ref().unwrap().1.within(pos, radius, &mut out);
+        out.sort_by(|&a, &b| {
+            let da = c.d.pos(a as usize).distance_squared(pos);
+            let db = c.d.pos(b as usize).distance_squared(pos);
+            da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
+        });
+        Ok(out.into_iter().map(|n| Dynamic::from_int(n as INT)).collect())
+    });
+
+    // Groups.
+    let c = ctx.clone();
+    engine.register_fn("ingroup", move |name: ImmutableString| -> bool {
+        let c = c.borrow();
+        c.d.store(c.class).in_group(&name, c.i)
+    });
+    let c = ctx.clone();
+    engine.register_fn("ingroup", move |name: ImmutableString, i: Dynamic| -> RhaiResult<bool> {
+        let i = wrap(index_arg(&i))?;
+        let c = c.borrow();
+        Ok(c.d.store(c.class).in_group(&name, i))
+    });
+    let c = ctx.clone();
+    engine.register_fn("setgroup", move |name: ImmutableString, on: Dynamic| -> RhaiResult<()> {
+        let on = wrap(num(&on))? != 0.0;
+        let mut c = c.borrow_mut();
+        let (class, i) = (c.class, c.i);
+        let store = c.d.store_mut(class);
+        if on {
+            if !store.has_group(&name) {
+                store.create_group(&name);
+            }
+            store.add_to_group(&name, i);
+        } else if store.has_group(&name) {
+            // No remove-one on the store: rebuild the membership without i.
+            let members: Vec<u32> = store.group_members(&name).into_iter().filter(|&m| m as usize != i).collect();
+            store.remove_group(&name);
+            store.create_group(&name);
+            for m in members {
+                store.add_to_group(&name, m as usize);
+            }
+        }
+        Ok(())
+    });
+
+    // Deferred structural edits.
+    let c = ctx.clone();
+    engine.register_fn("addpoint", move |pos: Dynamic| -> RhaiResult<INT> {
+        let pos = wrap(to_vec3(&pos))?;
+        let mut c = c.borrow_mut();
+        let idx = c.d.num_points() + c.adds.len();
+        c.adds.push(pos);
+        Ok(idx as INT)
+    });
+    let c = ctx.clone();
+    engine.register_fn("addprim", move |pts: Array| -> RhaiResult<INT> {
+        let mut c = c.borrow_mut();
+        let total = c.d.num_points() + c.adds.len();
+        let mut idx = Vec::with_capacity(pts.len());
+        for p in &pts {
+            let p = wrap(index_arg(p))?;
+            if p >= total {
+                return rt(format!("addprim: point {p} does not exist ({total} points)"));
+            }
+            idx.push(p as u32);
+        }
+        if idx.len() < 2 {
+            return rt("addprim: a primitive needs at least two points");
+        }
+        let n = c.d.num_prims() + c.prim_adds.len();
+        c.prim_adds.push(idx);
+        Ok(n as INT)
+    });
+    let c = ctx.clone();
+    engine.register_fn("removepoint", move |i: Dynamic| -> RhaiResult<()> {
+        let i = wrap(index_arg(&i))?;
+        let mut c = c.borrow_mut();
+        wrap(c.check(Class::Point, i))?;
+        c.removes.push(i);
+        Ok(())
+    });
+
+    // Channels, resolved before the run.
+    fn chan(c: &Shared, path: &str) -> RhaiResult<Chan> {
+        match c.borrow().chans.get(path) {
+            Some(Ok(ch)) => Ok(ch.clone()),
+            Some(Err(e)) => rt(e.clone()),
+            None => rt(format!("ch(\"{path}\"): a channel path must be a string literal, so it can be resolved before the script runs")),
+        }
+    }
+    let c = ctx.clone();
+    engine.register_fn("ch", move |path: ImmutableString| -> RhaiResult<FLOAT> { Ok(chan(&c, &path)?.num) });
+    let c = ctx.clone();
+    engine.register_fn("chf", move |path: ImmutableString| -> RhaiResult<FLOAT> { Ok(chan(&c, &path)?.num) });
+    let c = ctx.clone();
+    engine.register_fn("chi", move |path: ImmutableString| -> RhaiResult<INT> { Ok(chan(&c, &path)?.num.trunc() as INT) });
+    let c = ctx.clone();
+    engine.register_fn("chb", move |path: ImmutableString| -> RhaiResult<bool> { Ok(chan(&c, &path)?.num != 0.0) });
+    let c = ctx.clone();
+    engine.register_fn("chs", move |path: ImmutableString| -> RhaiResult<ImmutableString> { Ok(chan(&c, &path)?.text.into()) });
+    let c = ctx.clone();
+    engine.register_fn("chv", move |path: ImmutableString| -> RhaiResult<Vec3> { Ok(chan(&c, &path)?.vec()) });
+
+    engine
+}
+
+thread_local! {
+    static AST_CACHE: RefCell<HashMap<String, Rc<AST>>> = RefCell::new(HashMap::new());
+}
+
+fn compile(engine: &Engine, src: &str) -> Result<Rc<AST>, String> {
+    if let Some(ast) = AST_CACHE.with(|c| c.borrow().get(src).cloned()) {
+        return Ok(ast);
+    }
+    let ast = Rc::new(engine.compile(src).map_err(|e| format!("syntax: {e}"))?);
+    AST_CACHE.with(|c| {
+        let mut c = c.borrow_mut();
+        if c.len() >= AST_CACHE_CAP {
+            c.clear();
+        }
+        c.insert(src.to_string(), ast.clone());
+    });
+    Ok(ast)
+}
+
+/// Run `code` once per element of `class` in `input` (over `group`, if
+/// named), with the channel values the caller resolved. On any error — a
+/// syntax error, a runtime error on some element, the budget — the whole run
+/// fails and the caller keeps its input: a half-wrangled geometry is not a
+/// result.
+pub fn run_wrangle(
+    input: Detail,
+    code: &str,
+    class: Class,
+    group: &str,
+    frame: i32,
+    chans: HashMap<String, Result<Chan, String>>,
+) -> Result<Detail, String> {
+    let class = match class {
+        Class::Vertex => Class::Point,
+        c => c,
+    };
+    let ctx: Shared = Rc::new(RefCell::new(Ctx {
+        d: input,
+        class,
+        i: 0,
+        frame,
+        normals: None,
+        grid: None,
+        adds: Vec::new(),
+        prim_adds: Vec::new(),
+        removes: Vec::new(),
+        chans,
+    }));
+    let mut engine = build_engine(&ctx);
+
+    // The wall-clock budget, checked every so often rather than per operation.
+    let started = Instant::now();
+    let ticks = Cell::new(0u32);
+    engine.on_progress(move |_| {
+        ticks.set(ticks.get().wrapping_add(1));
+        if ticks.get() % 4096 == 0 && started.elapsed() > RUN_BUDGET {
+            Some(Dynamic::from(format!("the script ran for more than {} s and was stopped", RUN_BUDGET.as_secs())))
+        } else {
+            None
+        }
+    });
+
+    let src = desugar(code);
+    let ast = compile(&engine, &src)?;
+
+    let group = group.trim();
+    let elements: Vec<usize> = {
+        let c = ctx.borrow();
+        let n = match class {
+            Class::Point => c.d.num_points(),
+            Class::Prim => c.d.num_prims(),
+            _ => 1,
+        };
+        (0..n).filter(|&i| class == Class::Detail || group.is_empty() || c.d.store(class).in_group(group, i)).collect()
+    };
+
+    let mut scope = Scope::new();
+    // A variable, not a constant: Rhai refuses to assign through an indexer
+    // on a constant, and `@P = ...` is exactly that.
+    scope.push("__at", El);
+    let base = scope.len();
+    for i in elements {
+        ctx.borrow_mut().i = i;
+        scope.rewind(base);
+        if let Err(e) = engine.run_ast_with_scope(&mut scope, &ast) {
+            let e = e.to_string();
+            let e = e.strip_prefix("Runtime error: ").unwrap_or(&e).to_string();
+            return Err(match class {
+                Class::Detail => e,
+                _ => format!("{} {i}: {e}", class_name(class)),
+            });
+        }
+    }
+
+    drop(scope);
+    drop(engine);
+    let mut c = ctx.borrow_mut();
+    let mut d = std::mem::replace(&mut c.d, Detail::new());
+    let adds = std::mem::take(&mut c.adds);
+    let prim_adds = std::mem::take(&mut c.prim_adds);
+    let removes = std::mem::take(&mut c.removes);
+    drop(c);
+
+    for p in adds {
+        d.add_point(p);
+    }
+    for pts in prim_adds {
+        d.add_prim(&pts);
+    }
+    if !removes.is_empty() {
+        let mut keep = vec![true; d.num_points()];
+        for r in removes {
+            if r < keep.len() {
+                keep[r] = false;
+            }
+        }
+        d.keep_points(&keep);
+    }
+    Ok(d)
+}