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

commit1a6f081f4e1bb4472df1e79d338d3eadd450ba90
parente6a48e5d80
authorLucas Galante <[email protected]>
date2026-09-24 09:34
fix: a merged-in parameter lands at the template's position, not the end

The Sphere's Method heads its template, but an instance saved before it
existed gained it below Color: `merge_params` pushed every missing
parameter to the end. It now inserts after the last template parameter
the instance already has, so an old save shows the same pane as a fresh
node.

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

 CLAUDE.md   |  7 +++++--
 src/app.rs  | 14 ++++++++++++--
 src/main.rs | 17 ++++++++++++-----
 3 files changed, 29 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index fd8526c..61e6674 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1416,8 +1416,11 @@ Missing referenced templates panic at load.
 
 Saved instances are self-contained copies, but the loader merges template
 evolution into them (`merge_template_defs` in `src/app.rs`, run on every
-project deserialization including thumbnails): missing params are appended,
-existing ones keep their value but take the template's UI metadata, and subnet
+project deserialization including thumbnails): missing params are inserted
+where the template puts them (after the last template param the instance
+already has — so the Sphere's Method lands above Radius in an old save, not
+below Color), existing ones keep their value but take the template's UI
+metadata, and subnet
 templates (Sphere/Plane/Extrude) refresh their children's `Code` outright —
 **the template owns the surface and implementation, the instance owns its
 values.** A kernel hand-edited inside a template instance reverts on load;
diff --git a/src/app.rs b/src/app.rs
index 08823a4..c801a6d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -764,8 +764,17 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
         }
     }
     fn merge_params(node: &mut FsNode, template: &FsNode) {
+        // A missing parameter goes where the TEMPLATE puts it — after the
+        // last template parameter the instance already has — not at the
+        // end. The pane's order is the template's statement of what matters
+        // first (the Sphere's Method sits above the Radius it governs), and
+        // an old save that appended every later control below Color was
+        // showing a different node from a fresh one.
+        let mut cursor = 0;
         for tp in &template.params {
-            if let Some(ip) = node.params.iter_mut().find(|p| p.name == tp.name) {
+            if let Some(i) = node.params.iter().position(|p| p.name == tp.name) {
+                cursor = i + 1;
+                let ip = &mut node.params[i];
                 ip.param_type = tp.param_type.clone();
                 ip.label = tp.label.clone();
                 ip.options = tp.options.clone();
@@ -779,7 +788,8 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
                 // irrelevant rows would not hide them there.
                 ip.show_when = tp.show_when.clone();
             } else {
-                node.params.push(tp.clone());
+                node.params.insert(cursor, tp.clone());
+                cursor += 1;
             }
         }
     }
diff --git a/src/main.rs b/src/main.rs
index 7f5b611..d44e200 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2875,13 +2875,13 @@ mod tests {
         };
         crate::app::merge_template_defs(&mut root, &templates);
 
-        // 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.
+        // Sphere: new params inserted where the template puts them — Method
+        // ABOVE the Radius the instance already had, the rest after it —
+        // 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", "Method", "Rows", "Columns", "Frequency", "Resolution", "Center X", "Center Y", "Center Z", "Color"]);
-        assert_eq!(s.params[0].default, "0.70", "instance value survives");
+        assert_eq!(names, ["Method", "Radius", "Rows", "Columns", "Frequency", "Resolution", "Center X", "Center Y", "Center Z", "Color"]);
+        assert_eq!(s.params.iter().find(|p| p.name == "Radius").unwrap().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;
         assert!(code.contains("chi(\"Rows\""), "kernel refreshed from template");
@@ -3003,6 +3003,13 @@ mod tests {
         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");
+        assert_eq!(sphere_t.params[0].name, "Method", "the method heads the pane, above the radius it governs");
+        // And it heads the pane of a sphere SAVED before it existed too: the
+        // bundled project's sphere1 gains it through the loader's merge, at
+        // the template's position rather than below Color.
+        let state = State::new(false);
+        let saved = state.current_dir().children.iter().find(|c| c.name == "sphere1").expect("the bundled sphere1");
+        assert_eq!(saved.params[0].name, "Method", "merged order: {:?}", saved.params.iter().map(|p| &p.name).collect::<Vec<_>>());
         let build = |params: &[(&str, &str)]| {
             let mut inst = sphere_t.clone();
             inst.id = "s".to_string();