graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the Sphere has a Method dropdown — UV, Icosphere, Cube
One kernel builds all three: a single loop over triangle indices, each
decoded by method into three corners on the unit sphere, then one shared
block that turns it outward, places, shades and writes it. Icosphere
splits the icosahedron's 20 faces into Frequency^2 triangles by integer
barycentric weights summed in one fixed expression, so shared-edge corners
weld (10f^2 + 2 points); Cube pushes a Resolution grid on each face out
through the spherified-cube map (6r^2 + 2 points). UV is unchanged.
A choice now reaches a kernel as its option INDEX (`param_number`, shared
with the parameter-reference path). The kernel path parsed the option's
text, so every dropdown read as 0 from inside a kernel.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 24 +++++++++++++
nodes/sphere.json | 31 ++++++++++++++--
src/geometry.rs | 85 +++++++++++++++++++++++--------------------
src/main.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 201 insertions(+), 44 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index bb95584..fd8526c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -585,6 +585,30 @@ way — all in `src/geometry.rs`:
clamped; an empty slot passes nothing. Only `Input` draws a wire, the
limit every second operand has (Boolean's With, Copy's target).
+### The Sphere's construction methods
+
+`nodes/sphere.json` carries a **Method** dropdown — `UV`, `Icosphere`,
+`Cube` — and one kernel that builds all three: a single loop over triangle
+INDICES, each decoded by method into three corners on the unit sphere, then
+one shared block that turns it outward, places it, shades it and writes it.
+UV is Rows x Columns, the sphere this node always built, corner for corner
+(its rows skip the outward turn so the pole slivers keep their order).
+Icosphere splits each of the icosahedron's 20 faces into Frequency^2
+triangles by integer barycentric weights summed in ONE fixed expression, so
+a corner on an edge two faces share lands on the same bits from either side
+and the 1e-4 weld joins them — the welded count is `10f^2 + 2`, which
+`sphere_method_builds_a_uv_ico_or_cube_sphere` asserts along with
+closedness. Cube lays a Resolution x Resolution grid on each face and pushes
+it out through the spherified-cube map rather than a bare normalize, which
+crowds the corners; `6r^2 + 2` points. Rows/Columns, Frequency and
+Resolution each show only under their method (`show_when`).
+
+**A choice reaches a kernel as its option INDEX** (`geometry::param_number`,
+the one conversion behind both `chi("Method")` in a kernel and a
+`chi("Method")` parameter reference). The kernel path used to parse the
+option's TEXT, so every dropdown read as 0 from inside a kernel — a
+dropdown on a kernel node was simply not possible before this.
+
### The Embryo node is a template of nodes
`nodes/embryo.json` is hou-control's `developer_embryo`, the Developer
diff --git a/nodes/sphere.json b/nodes/sphere.json
index e79e4b4..0191467 100644
--- a/nodes/sphere.json
+++ b/nodes/sphere.json
@@ -4,6 +4,11 @@
"inputs": 0,
"outputs": 1,
"params": [
+ {
+ "name": "Method",
+ "default": "UV",
+ "type": "choice:UV,Icosphere,Cube"
+ },
{
"name": "Radius",
"default": "0.5",
@@ -15,7 +20,8 @@
"type": "spinbox",
"min": 2,
"max": 128,
- "step": 1
+ "step": 1,
+ "show_when": "Method == UV"
},
{
"name": "Columns",
@@ -23,7 +29,26 @@
"type": "spinbox",
"min": 3,
"max": 128,
- "step": 1
+ "step": 1,
+ "show_when": "Method == UV"
+ },
+ {
+ "name": "Frequency",
+ "default": "4",
+ "type": "spinbox",
+ "min": 1,
+ "max": 16,
+ "step": 1,
+ "show_when": "Method == Icosphere"
+ },
+ {
+ "name": "Resolution",
+ "default": "8",
+ "type": "spinbox",
+ "min": 1,
+ "max": 64,
+ "step": 1,
+ "show_when": "Method == Cube"
},
{
"name": "Center X",
@@ -53,7 +78,7 @@
"params": [
{
"name": "Code",
- "default": "__kernel void process(__global const float* in_pos, __global const float* in_col, int in_count, __global float* out_pos, __global float* out_col, __global int* out_count, int max_vertices) {\n int id = get_global_id(0);\n if (id == 0) {\n float radius = chf(\"Radius\", 0.5f);\n float center_x = chf(\"Center X\", 0.0f);\n float center_y = chf(\"Center Y\", 0.55f);\n float center_z = chf(\"Center Z\", 0.0f);\n int lat_steps = chi(\"Rows\", 16);\n if (lat_steps < 2) { lat_steps = 2; }\n if (lat_steps > 128) { lat_steps = 128; }\n int lon_steps = chi(\"Columns\", 24);\n if (lon_steps < 3) { lon_steps = 3; }\n if (lon_steps > 128) { lon_steps = 128; }\n int colored = chb(\"Color\", true) > 0.5f ? 1 : 0;\n int count = 0;\n for (int lat = 0; lat < lat_steps; lat++) {\n float theta0 = 3.14159265f * (float)lat / (float)lat_steps;\n float theta1 = 3.14159265f * (float)(lat + 1) / (float)lat_steps;\n for (int lon = 0; lon < lon_steps; lon++) {\n float phi0 = 6.2831853f * (float)lon / (float)lon_steps;\n float phi1 = 6.2831853f * (float)(lon + 1) / (float)lon_steps;\n float x00 = radius * sin(theta0) * cos(phi0);\n float y00 = radius * cos(theta0);\n float z00 = radius * sin(theta0) * sin(phi0);\n float x10 = radius * sin(theta1) * cos(phi0);\n float y10 = radius * cos(theta1);\n float z10 = radius * sin(theta1) * sin(phi0);\n float x11 = radius * sin(theta1) * cos(phi1);\n float y11 = radius * cos(theta1);\n float z11 = radius * sin(theta1) * sin(phi1);\n float x01 = radius * sin(theta0) * cos(phi1);\n float y01 = radius * cos(theta0);\n float z01 = radius * sin(theta0) * sin(phi1);\n float px[6] = {x00, x11, x10, x00, x01, x11};\n float py[6] = {y00, y11, y10, y00, y01, y11};\n float pz[6] = {z00, z11, z10, z00, z01, z11};\n for (int v = 0; v < 6; v++) {\n int idx = count++;\n if (idx < max_vertices) {\n out_pos[idx * 3 + 0] = center_x + px[v];\n out_pos[idx * 3 + 1] = center_y + py[v];\n out_pos[idx * 3 + 2] = center_z + pz[v];\n float nx = px[v];\n float ny = py[v];\n float nz = pz[v];\n float len = sqrt(nx*nx + ny*ny + nz*nz);\n if (len > 0.0f) {\n nx /= len;\n ny /= len;\n nz /= len;\n }\n if (colored) {\n out_col[idx * 3 + 0] = 0.5f + nx * 0.5f;\n out_col[idx * 3 + 1] = 0.5f + ny * 0.5f;\n out_col[idx * 3 + 2] = 0.5f + nz * 0.5f;\n } else {\n out_col[idx * 3 + 0] = 0.8f;\n out_col[idx * 3 + 1] = 0.8f;\n out_col[idx * 3 + 2] = 0.8f;\n }\n }\n }\n }\n }\n *out_count = count;\n }\n}"
+ "default": "__kernel void process(__global const float* in_pos, __global const float* in_col, int in_count, __global float* out_pos, __global float* out_col, __global int* out_count, int max_vertices) {\n int id = get_global_id(0);\n if (id == 0) {\n int method = chi(\"Method\", 0);\n float radius = chf(\"Radius\", 0.5f);\n float center_x = chf(\"Center X\", 0.0f);\n float center_y = chf(\"Center Y\", 0.55f);\n float center_z = chf(\"Center Z\", 0.0f);\n int lat_steps = chi(\"Rows\", 16);\n if (lat_steps < 2) { lat_steps = 2; }\n if (lat_steps > 128) { lat_steps = 128; }\n int lon_steps = chi(\"Columns\", 24);\n if (lon_steps < 3) { lon_steps = 3; }\n if (lon_steps > 128) { lon_steps = 128; }\n int freq = chi(\"Frequency\", 4);\n if (freq < 1) { freq = 1; }\n if (freq > 16) { freq = 16; }\n int res = chi(\"Resolution\", 8);\n if (res < 1) { res = 1; }\n if (res > 64) { res = 64; }\n int colored = chb(\"Color\", true) > 0.5f ? 1 : 0;\n /* The icosahedron the icosphere subdivides: 12 corners on three\n orthogonal golden rectangles, 20 faces. Orientation does not\n matter here; every triangle is turned outward below. */\n float t = 1.6180340f;\n float ivx[12] = {-1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, t, t, -t, -t};\n float ivy[12] = {t, t, -t, -t, -1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f};\n float ivz[12] = {0.0f, 0.0f, 0.0f, 0.0f, t, t, -t, -t, -1.0f, 1.0f, -1.0f, 1.0f};\n int ifa[20] = {0, 0, 0, 0, 0, 1, 5, 11, 10, 7, 3, 3, 3, 3, 3, 4, 2, 6, 8, 9};\n int ifb[20] = {11, 5, 1, 7, 10, 5, 11, 10, 7, 1, 9, 4, 2, 6, 8, 9, 4, 2, 6, 8};\n int ifc[20] = {5, 1, 7, 10, 11, 9, 4, 2, 6, 8, 4, 2, 6, 8, 9, 5, 11, 10, 7, 1};\n /* One loop over triangles, whatever builds them: triangle t is\n decoded by method into three corners on the unit sphere, and one\n block below places, orients, shades and writes it. */\n int total = lat_steps * lon_steps * 2;\n if (method == 1) { total = 20 * freq * freq; }\n if (method == 2) { total = 6 * res * res * 2; }\n int count = 0;\n for (int tri = 0; tri < total; tri++) {\n float tx[3] = {0.0f, 0.0f, 0.0f};\n float ty[3] = {0.0f, 0.0f, 0.0f};\n float tz[3] = {0.0f, 0.0f, 0.0f};\n if (method == 1) {\n /* Icosphere: each face split into freq*freq triangles by\n barycentric weights, the corners pushed onto the sphere.\n Row i of a face holds freq-i upright triangles and\n freq-i-1 inverted ones, interleaved. The weights are\n integers summed in one fixed expression, so a corner on\n an edge two faces share lands on the same bits from\n either side and the weld joins them. */\n int face = tri / (freq * freq);\n int k = tri - face * freq * freq;\n int i = 0;\n int row_len = 2 * freq - 1;\n while (k >= row_len) {\n k -= row_len;\n i++;\n row_len = 2 * (freq - i) - 1;\n }\n int j = k / 2;\n int upright = (k - j * 2) == 0 ? 1 : 0;\n int wa[3];\n int wb[3];\n int wc[3];\n if (upright) {\n wb[0] = i; wc[0] = j;\n wb[1] = i + 1; wc[1] = j;\n wb[2] = i; wc[2] = j + 1;\n } else {\n wb[0] = i + 1; wc[0] = j;\n wb[1] = i + 1; wc[1] = j + 1;\n wb[2] = i; wc[2] = j + 1;\n }\n int a = ifa[face];\n int b = ifb[face];\n int c = ifc[face];\n for (int v = 0; v < 3; v++) {\n wa[v] = freq - wb[v] - wc[v];\n float x = ((float)wa[v] * ivx[a] + (float)wb[v] * ivx[b] + (float)wc[v] * ivx[c]) / (float)freq;\n float y = ((float)wa[v] * ivy[a] + (float)wb[v] * ivy[b] + (float)wc[v] * ivy[c]) / (float)freq;\n float z = ((float)wa[v] * ivz[a] + (float)wb[v] * ivz[b] + (float)wc[v] * ivz[c]) / (float)freq;\n float len = sqrt(x * x + y * y + z * z);\n tx[v] = x / len;\n ty[v] = y / len;\n tz[v] = z / len;\n }\n } else if (method == 2) {\n /* Cube sphere: a res*res grid on each face of the cube\n spanning -1..1, every point pushed onto the sphere by the\n spherified-cube map (not a bare normalize, which crowds\n the corners and stretches the face centres). */\n int per_face = res * res * 2;\n int face = tri / per_face;\n int q = (tri - face * per_face) / 2;\n int side = tri - face * per_face - q * 2;\n int i = q / res;\n int j = q - i * res;\n float u0 = -1.0f + 2.0f * (float)i / (float)res;\n float u1 = -1.0f + 2.0f * (float)(i + 1) / (float)res;\n float v0 = -1.0f + 2.0f * (float)j / (float)res;\n float v1 = -1.0f + 2.0f * (float)(j + 1) / (float)res;\n float qu[4] = {u0, u1, u1, u0};\n float qv[4] = {v0, v0, v1, v1};\n for (int v = 0; v < 3; v++) {\n int corner = side == 0 ? v : (v == 0 ? 0 : v + 1);\n float u = qu[corner];\n float w = qv[corner];\n float x = 0.0f;\n float y = 0.0f;\n float z = 0.0f;\n int axis = face / 2;\n float sign = (face - axis * 2) == 0 ? 1.0f : -1.0f;\n if (axis == 0) { x = sign; y = u; z = w; }\n if (axis == 1) { y = sign; z = u; x = w; }\n if (axis == 2) { z = sign; x = u; y = w; }\n float x2 = x * x;\n float y2 = y * y;\n float z2 = z * z;\n tx[v] = x * sqrt(1.0f - y2 * 0.5f - z2 * 0.5f + y2 * z2 / 3.0f);\n ty[v] = y * sqrt(1.0f - z2 * 0.5f - x2 * 0.5f + z2 * x2 / 3.0f);\n tz[v] = z * sqrt(1.0f - x2 * 0.5f - y2 * 0.5f + x2 * y2 / 3.0f);\n }\n } else {\n /* UV: rows of latitude, columns of longitude, two triangles\n a cell \u2014 the sphere this node has always built, corner\n for corner. */\n int cell = tri / 2;\n int side = tri - cell * 2;\n int lat = cell / lon_steps;\n int lon = cell - lat * lon_steps;\n float theta0 = 3.14159265f * (float)lat / (float)lat_steps;\n float theta1 = 3.14159265f * (float)(lat + 1) / (float)lat_steps;\n float phi0 = 6.2831853f * (float)lon / (float)lon_steps;\n float phi1 = 6.2831853f * (float)(lon + 1) / (float)lon_steps;\n float x00 = sin(theta0) * cos(phi0);\n float y00 = cos(theta0);\n float z00 = sin(theta0) * sin(phi0);\n float x10 = sin(theta1) * cos(phi0);\n float y10 = cos(theta1);\n float z10 = sin(theta1) * sin(phi0);\n float x11 = sin(theta1) * cos(phi1);\n float y11 = cos(theta1);\n float z11 = sin(theta1) * sin(phi1);\n float x01 = sin(theta0) * cos(phi1);\n float y01 = cos(theta0);\n float z01 = sin(theta0) * sin(phi1);\n if (side == 0) {\n tx[0] = x00; ty[0] = y00; tz[0] = z00;\n tx[1] = x11; ty[1] = y11; tz[1] = z11;\n tx[2] = x10; ty[2] = y10; tz[2] = z10;\n } else {\n tx[0] = x00; ty[0] = y00; tz[0] = z00;\n tx[1] = x01; ty[1] = y01; tz[1] = z01;\n tx[2] = x11; ty[2] = y11; tz[2] = z11;\n }\n }\n /* Turn the triangle outward: the sphere is convex about the\n origin, so a face normal pointing away from its own centroid\n is inside out, whatever table it came from. The UV rows are\n already outward and skip this, so their pole slivers (whose\n normal is rounding noise) keep the order they always had. */\n if (method != 0) {\n float ex = tx[1] - tx[0];\n float ey = ty[1] - ty[0];\n float ez = tz[1] - tz[0];\n float fx = tx[2] - tx[0];\n float fy = ty[2] - ty[0];\n float fz = tz[2] - tz[0];\n float nx = ey * fz - ez * fy;\n float ny = ez * fx - ex * fz;\n float nz = ex * fy - ey * fx;\n float d = nx * (tx[0] + tx[1] + tx[2]) + ny * (ty[0] + ty[1] + ty[2]) + nz * (tz[0] + tz[1] + tz[2]);\n if (d < 0.0f) {\n float sx = tx[1]; tx[1] = tx[2]; tx[2] = sx;\n float sy = ty[1]; ty[1] = ty[2]; ty[2] = sy;\n float sz = tz[1]; tz[1] = tz[2]; tz[2] = sz;\n }\n }\n for (int v = 0; v < 3; v++) {\n int idx = count++;\n if (idx < max_vertices) {\n out_pos[idx * 3 + 0] = center_x + radius * tx[v];\n out_pos[idx * 3 + 1] = center_y + radius * ty[v];\n out_pos[idx * 3 + 2] = center_z + radius * tz[v];\n float nx = tx[v];\n float ny = ty[v];\n float nz = tz[v];\n float len = sqrt(nx*nx + ny*ny + nz*nz);\n if (len > 0.0f) {\n nx /= len;\n ny /= len;\n nz /= len;\n }\n if (colored) {\n out_col[idx * 3 + 0] = 0.5f + nx * 0.5f;\n out_col[idx * 3 + 1] = 0.5f + ny * 0.5f;\n out_col[idx * 3 + 2] = 0.5f + nz * 0.5f;\n } else {\n out_col[idx * 3 + 0] = 0.8f;\n out_col[idx * 3 + 1] = 0.8f;\n out_col[idx * 3 + 2] = 0.8f;\n }\n }\n }\n }\n *out_count = count;\n }\n}"
}
],
"position": [
diff --git a/src/geometry.rs b/src/geometry.rs
index 471533e..20e4ec8 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -655,13 +655,17 @@ pub fn has_param_refs(node: &FsNode) -> bool {
node.params.iter().any(|p| parse_param_ref(&p.default).is_some())
}
-/// A parameter's value converted for a reference of `kind`.
-fn convert_ref_value(kind: RefKind, p: &ParamDef) -> String {
+/// A parameter's value as a NUMBER — what a kernel's `chf` / `chi` / `chb`
+/// and a numeric reference both read. A toggle is 0 or 1; a choice is its
+/// option INDEX, the position in the template's list, the way an ordinal
+/// menu evaluates in Houdini. The index is what lets a subnet's dropdown
+/// drive a child switch's Index or a kernel's `chi("Method")`: the option
+/// text parses as nothing, and until 2026-09-24 the kernel path parsed it
+/// anyway, so every choice read as 0 from inside a kernel.
+pub fn param_number(p: &ParamDef) -> f32 {
let raw = p.default.trim();
let is_choice = p.param_type == "choice" || p.param_type.starts_with("choice:");
- // The option index is what a choice IS to a number: the position in the
- // template's list, the way an ordinal menu evaluates in Houdini.
- let choice_index = || -> Option<usize> {
+ if is_choice {
let options: Vec<String> = if !p.options.is_empty() {
p.options.clone()
} else {
@@ -670,19 +674,27 @@ fn convert_ref_value(kind: RefKind, p: &ParamDef) -> String {
.map(|o| o.split(',').map(|x| x.trim().to_string()).collect())
.unwrap_or_default()
};
- options.iter().position(|o| o.eq_ignore_ascii_case(raw))
- };
- let as_number = || -> f32 {
- if is_choice {
- choice_index().map_or(0.0, |i| i as f32)
- } else if raw.eq_ignore_ascii_case("true") {
- 1.0
- } else if raw.eq_ignore_ascii_case("false") {
- 0.0
- } else {
- raw.parse::<f32>().unwrap_or(0.0)
- }
- };
+ options.iter().position(|o| o.eq_ignore_ascii_case(raw)).map_or(0.0, |i| i as f32)
+ } else {
+ number_of_str(raw)
+ }
+}
+
+/// A bare value's number: `true` / `false` as 1 / 0, else parsed, else 0.
+fn number_of_str(raw: &str) -> f32 {
+ if raw.eq_ignore_ascii_case("true") {
+ 1.0
+ } else if raw.eq_ignore_ascii_case("false") {
+ 0.0
+ } else {
+ raw.parse::<f32>().unwrap_or(0.0)
+ }
+}
+
+/// A parameter's value converted for a reference of `kind`.
+fn convert_ref_value(kind: RefKind, p: &ParamDef) -> String {
+ let raw = p.default.trim();
+ let as_number = || param_number(p);
match kind {
RefKind::Str => raw.to_string(),
RefKind::Float => {
@@ -4708,20 +4720,24 @@ pub fn resolve_opencl_geometry_with_errors(
if !code.is_empty() {
let parsed_params = parse_dynamic_params(&code);
let mut flat_values = Vec::new();
+ // The kernel's own parameter, else the enclosing subnet's — the
+ // parent's value RESOLVED: a Sphere instance whose Radius is
+ // ch("Radius") hands its kernel the subnet's number, not the
+ // reference string (which parses as nothing and left the kernel at
+ // its default). The DEFINITION is looked up rather than the value
+ // string, because a choice is worth its option index and only the
+ // definition knows the options.
+ let resolved_parent = find_parent_node(root, &target.id)
+ .map(|parent| resolve_param_refs(root, parent, ocl_error).unwrap_or_else(|| parent.clone()));
+ let find_def = |name: &str| -> Option<&ParamDef> {
+ target.params.iter().find(|d| d.name.eq_ignore_ascii_case(name)).or_else(|| {
+ resolved_parent.as_ref().and_then(|parent| parent.params.iter().find(|d| d.name.eq_ignore_ascii_case(name)))
+ })
+ };
for p in &parsed_params {
- let mut val_str = node_param_str(target, &p.name, &p.default);
- if !target.params.iter().any(|p_def| p_def.name.eq_ignore_ascii_case(&p.name)) {
- if let Some(parent) = find_parent_node(root, &target.id) {
- // The parent's value RESOLVED: a Sphere instance whose
- // Radius is ch("Radius") hands its kernel the subnet's
- // number, not the reference string (which parses as
- // nothing and left the kernel at its default).
- let resolved_parent = resolve_param_refs(root, parent, ocl_error);
- let parent = resolved_parent.as_ref().unwrap_or(parent);
- val_str = node_param_str(parent, &p.name, &val_str);
- }
- }
+ let def = find_def(&p.name);
if p.param_type == "float3" {
+ let val_str = def.map_or(p.default.as_str(), |d| d.default.as_str());
let parts: Vec<&str> = val_str.split(':').collect();
let (x, y, z) = if parts.len() >= 3 {
(parts[0].parse::<f32>().unwrap_or(0.0), parts[1].parse::<f32>().unwrap_or(0.0), parts[2].parse::<f32>().unwrap_or(0.0))
@@ -4732,14 +4748,7 @@ pub fn resolve_opencl_geometry_with_errors(
flat_values.push(y);
flat_values.push(z);
} else {
- let val = if val_str.eq_ignore_ascii_case("true") {
- 1.0
- } else if val_str.eq_ignore_ascii_case("false") {
- 0.0
- } else {
- val_str.parse::<f32>().unwrap_or(0.0)
- };
- flat_values.push(val);
+ flat_values.push(def.map_or_else(|| number_of_str(&p.default), param_number));
}
}
diff --git a/src/main.rs b/src/main.rs
index b7f89da..7f5b611 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2875,11 +2875,12 @@ mod tests {
};
crate::app::merge_template_defs(&mut root, &templates);
- // Sphere: new params appended with template defaults, value kept,
- // kernel refreshed.
+ // Sphere: new params inserted where the template puts them (Method
+ // ahead of the rows it governs, Frequency and Resolution after
+ // them) with template defaults, value kept, kernel refreshed.
let s = &root.children[0];
let names: Vec<&str> = s.params.iter().map(|p| p.name.as_str()).collect();
- assert_eq!(names, ["Radius", "Rows", "Columns", "Center X", "Center Y", "Center Z", "Color"]);
+ assert_eq!(names, ["Radius", "Method", "Rows", "Columns", "Frequency", "Resolution", "Center X", "Center Y", "Center Z", "Color"]);
assert_eq!(s.params[0].default, "0.70", "instance value survives");
let code = &s.children.iter().find(|c| c.name == "opencl1").unwrap()
.params.iter().find(|p| p.name == "Code").unwrap().default;
@@ -2985,6 +2986,104 @@ mod tests {
);
}
+ /// The Sphere's Method dropdown picks the construction: UV (Rows x
+ /// Columns, the sphere this node always built), Icosphere (an
+ /// icosahedron's 20 faces each split into Frequency^2 triangles) and
+ /// Cube (a Resolution x Resolution grid on each face of a cube, pushed
+ /// onto the sphere). The welded point counts are the closed forms —
+ /// 10f^2 + 2 and 6r^2 + 2 — which hold only if every corner two faces
+ /// share lands on the same point, and closedness says the winding came
+ /// out consistent after the outward turn. Method reaches the kernel as
+ /// an option INDEX: the choice's text used to parse as 0, so a kernel
+ /// could not read a dropdown at all.
+ #[test]
+ fn sphere_method_builds_a_uv_ico_or_cube_sphere() {
+ let templates_root = crate::app::load_fs_tree();
+ let sphere_t = templates_root.children.iter().find(|t| t.name == "Sphere").unwrap();
+ let method = sphere_t.params.iter().find(|p| p.name == "Method").expect("a Method dropdown");
+ assert_eq!(method.param_type, "choice:UV,Icosphere,Cube");
+ assert_eq!(method.default, "UV", "the default stays the sphere every saved project was built with");
+ let build = |params: &[(&str, &str)]| {
+ let mut inst = sphere_t.clone();
+ inst.id = "s".to_string();
+ inst.name = "sphere1".to_string();
+ for child in &mut inst.children {
+ child.id = format!("{}_{}", inst.id, child.name);
+ }
+ for (pname, val) in params {
+ inst.params.iter_mut().find(|p| p.name == *pname).unwrap().default = val.to_string();
+ }
+ let root = FsNode {
+ id: "root".to_string(),
+ name: "root".to_string(),
+ node_type: "node".to_string(),
+ children: vec![inst],
+ params: vec![],
+ geometry_visible: true,
+ position: (0.0, 0.0),
+ inputs: 0,
+ outputs: 0,
+ };
+ let mut visited = Vec::new();
+ let mut err = None;
+ let mut cache = crate::geometry::SimCache::default();
+ let geom = crate::geometry::generate_single_node_geometry_with_errors(
+ &root,
+ &root.children[0],
+ &mut visited,
+ &mut err,
+ &mut crate::geometry::EvalSim::new(0, 0, &mut cache),
+ ).expect("sphere generation failed");
+ assert!(err.is_none(), "{err:?}");
+ geom
+ };
+ let on_sphere = |geom: &crate::detail::Detail, radius: f32| {
+ for pos in geom.positions() {
+ let r = ((pos[0]).powi(2) + (pos[1] - 0.55).powi(2) + (pos[2]).powi(2)).sqrt();
+ assert!((r - radius).abs() < 1e-3, "point {pos:?} is {r} from the centre, not {radius}");
+ }
+ };
+
+ let uv = build(&[("Method", "UV")]);
+ assert_eq!(uv.num_points(), crate::geometry::sphere_point_len(16, 24));
+
+ for (freq, expect) in [("1", 12), ("2", 42), ("4", 162), ("7", 492)] {
+ let ico = build(&[("Method", "Icosphere"), ("Frequency", freq)]);
+ assert_eq!(ico.num_points(), expect, "icosphere at frequency {freq}");
+ assert_eq!(ico.num_prims(), 20 * freq.parse::<usize>().unwrap().pow(2));
+ assert!(ico.is_closed(), "icosphere at frequency {freq} is not closed");
+ on_sphere(&ico, 0.5);
+ }
+
+ for (res, expect) in [("1", 8), ("3", 56), ("8", 386)] {
+ let cube = build(&[("Method", "Cube"), ("Resolution", res), ("Radius", "0.8")]);
+ assert_eq!(cube.num_points(), expect, "cube sphere at resolution {res}");
+ assert_eq!(cube.num_prims(), 6 * res.parse::<usize>().unwrap().pow(2) * 2);
+ assert!(cube.is_closed(), "cube sphere at resolution {res} is not closed");
+ on_sphere(&cube, 0.8);
+ }
+
+ // The out-of-range guards: a frequency of 0 builds the icosahedron.
+ assert_eq!(build(&[("Method", "Icosphere"), ("Frequency", "0")]).num_points(), 12);
+ }
+
+ /// `param_number` is what a kernel's `chi()` reads: a choice is its
+ /// option index, a toggle 0 or 1, a number itself, and text 0.
+ #[test]
+ fn a_choice_reads_as_its_option_index_from_a_kernel() {
+ use crate::geometry::param_number;
+ let p = |ty: &str, val: &str| crate::app::ParamDef {
+ name: "X".into(), label: String::new(), param_type: ty.into(), default: val.into(),
+ options: vec![], min: None, max: None, step: None, show_when: String::new(),
+ };
+ assert_eq!(param_number(&p("choice:UV,Icosphere,Cube", "Cube")), 2.0);
+ assert_eq!(param_number(&p("choice:UV,Icosphere,Cube", "icosphere")), 1.0, "case-insensitive, like the reference path");
+ assert_eq!(param_number(&p("choice:UV,Icosphere,Cube", "Nope")), 0.0, "an unknown option is the first");
+ assert_eq!(param_number(&p("toggle", "true")), 1.0);
+ assert_eq!(param_number(&p("slider", "0.25")), 0.25);
+ assert_eq!(param_number(&p("string", "hello")), 0.0);
+ }
+
/// A Scatter consumed downstream must still evaluate: the dispatch pushes
/// the target id before dispatching, so a resolver-local visited guard
/// sees it and refuses every dispatched call — scatter geometry silently