graphic design tool
git clone https://git.lucas.co/cce-designer.git
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)
+}