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

commit2e185b895fd0c10584079cd7a09225e9880358ac
parentd068b95ebb
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 12:26
feat: a node added on a wire is wired into its chain

Add Node on a free grid-cursor cell a wire runs through splices the new
node into that wire, through the hit test a dropped node's splice uses.
The drop's rewiring is one helper now, shared by both editors and the add.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md     | 15 +++++++++++++
 src/app.rs    | 70 +++++++++++++++++++----------------------------------------
 src/dialog.rs | 31 ++++++++++++++++++++++++--
 src/main.rs   | 45 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 111 insertions(+), 50 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a48976a..c60bcf2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1061,6 +1061,21 @@ and a node is never wired to itself. The rewiring is in the deletion's
 undo step, since a structure step holds the wires of every node it
 touches. `deleting_a_wired_node_connects_its_neighbours` is the test.
 
+### Adding a node on a wire splices it in
+
+Add Node (the dialog's AddNode pick, from Tab or the network menu) on a
+FREE grid-cursor cell that a wire into an Input runs through wires the new
+node into that chain (since 2026-10-01): A → C becomes A → new → C. Which
+wire is cce-ui's `GraphController::input_wire_through_cell`, the hit test a
+node dragged onto a wire uses, asked about the body the new node will have
+— so adding and dropping agree about what is on a wire, in every wire
+style. It is asked BEFORE the add, since the new node's own wires would
+touch the cell after. The rewiring is `app::splice_into_wire`, the one the
+drag drop runs (both editors): both Inputs or neither, so a node with no
+Input — a generator — is added beside the wire and cuts nothing. MCP's
+`add_node` places at the coordinates it is given and does not splice.
+`a_node_added_on_a_wire_is_wired_into_its_chain` is the test.
+
 ### Sibling-first inputs and the Switch node
 
 Two pieces added on 2026-09-21 so a node can be BUILT FROM other nodes
diff --git a/src/app.rs b/src/app.rs
index f921f3d..e8ec547 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -781,6 +781,26 @@ pub(crate) fn splice_out(dir: &mut FsNode, slot: usize) {
     }
 }
 
+/// Splice the child `mid_id` of `dir` into the wire from `src_name` to
+/// `dest_id`: the middle node takes the wire's upstream as its Input, and
+/// the downstream node re-aims its Input at the middle one. Both rewires or
+/// neither — a splice that only cut the wire would orphan downstream — so
+/// false, and nothing written, when either node has no Input. What a node
+/// dropped onto a wire runs, and Add Node on a cell a wire runs through.
+pub(crate) fn splice_into_wire(dir: &mut FsNode, mid_id: &str, src_name: String, dest_id: &str) -> bool {
+    let has_input = |id: &str| dir.children.iter().any(|c| c.id == id && c.params.iter().any(|p| p.name == "Input"));
+    let Some(mid_name) = dir.children.iter().find(|c| c.id == mid_id).map(|c| c.name.clone()) else { return false };
+    if !has_input(mid_id) || !has_input(dest_id) {
+        return false;
+    }
+    for (id, wire) in [(mid_id, src_name), (dest_id, mid_name)] {
+        if let Some(p) = dir.children.iter_mut().find(|c| c.id == id).and_then(|c| c.params.iter_mut().find(|p| p.name == "Input")) {
+            p.set_text(wire);
+        }
+    }
+    true
+}
+
 /// [`node_wires_at`] with nothing evaluated: an expression wire names
 /// nothing.
 pub fn node_wires(node: &FsNode) -> Vec<(String, String)> {
@@ -10910,30 +10930,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 // at the dragged node. Both rewires or neither — a splice
                 // that only cut the wire would silently orphan downstream.
                 if let Some((mid_id, src_name, dest_id)) = self.graph_mut().take_pending_splice() {
-                    let dir = self.current_dir_mut();
-                    let mid_name = dir
-                        .children
-                        .iter()
-                        .find(|c| c.id == mid_id)
-                        .map(|c| c.name.clone());
-                    let both_rewirable = mid_name.is_some()
-                        && dir.children.iter().any(|c| {
-                            c.id == dest_id && c.params.iter().any(|p| p.name == "Input")
-                        })
-                        && dir.children.iter().any(|c| {
-                            c.id == mid_id && c.params.iter().any(|p| p.name == "Input")
-                        });
-                    if let (Some(mid_name), true) = (mid_name, both_rewirable) {
-                        if let Some(mid) = dir.children.iter_mut().find(|c| c.id == mid_id) {
-                            if let Some(p) = mid.params.iter_mut().find(|p| p.name == "Input") {
-                                p.set_text(src_name);
-                            }
-                        }
-                        if let Some(dest) = dir.children.iter_mut().find(|c| c.id == dest_id) {
-                            if let Some(p) = dest.params.iter_mut().find(|p| p.name == "Input") {
-                                p.set_text(mid_name);
-                            }
-                        }
+                    if splice_into_wire(self.current_dir_mut(), &mid_id, src_name, &dest_id) {
                         self.sync_nodes();
                         self.rebuild_scene_geometry();
                         self.sync_parameters_pane();
@@ -10967,30 +10964,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         self.slots.content2.take_pending_splice()
                     {
                         let p2 = self.current_path2.clone();
-                        let dir = self.dir_at_mut(&p2);
-                        let mid_name = dir
-                            .children
-                            .iter()
-                            .find(|c| c.id == mid_id)
-                            .map(|c| c.name.clone());
-                        let both = mid_name.is_some()
-                            && dir.children.iter().any(|c| {
-                                c.id == dest_id && c.params.iter().any(|p| p.name == "Input")
-                            })
-                            && dir.children.iter().any(|c| {
-                                c.id == mid_id && c.params.iter().any(|p| p.name == "Input")
-                            });
-                        if let (Some(mid_name), true) = (mid_name, both) {
-                            if let Some(mid) = dir.children.iter_mut().find(|c| c.id == mid_id) {
-                                if let Some(p) = mid.params.iter_mut().find(|p| p.name == "Input") {
-                                    p.set_text(src_name);
-                                }
-                            }
-                            if let Some(dest) = dir.children.iter_mut().find(|c| c.id == dest_id) {
-                                if let Some(p) = dest.params.iter_mut().find(|p| p.name == "Input") {
-                                    p.set_text(mid_name);
-                                }
-                            }
+                        if splice_into_wire(self.dir_at_mut(&p2), &mid_id, src_name, &dest_id) {
                             self.sync_nodes();
                             self.rebuild_scene_geometry();
                             self.sync_parameters_pane();
diff --git a/src/dialog.rs b/src/dialog.rs
index be508bd..a755c71 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1513,6 +1513,22 @@ impl State {
         self.open_dialog_anchored(Mode::AddNode, Some((x, y)));
     }
 
+    /// Wire the node just added (the level's last) into the wire from the
+    /// node `src_id` to `dest_id`, and say so. A node with no Input — a
+    /// generator — is left unwired: it cannot sit mid-chain.
+    fn splice_new_node(&mut self, src_id: &str, dest_id: &str) {
+        let dir = self.current_dir();
+        let (Some(new), Some(src)) = (dir.children.last(), dir.children.iter().find(|c| c.id == src_id)) else { return };
+        let (new_id, new_name, src_name) = (new.id.clone(), new.name.clone(), src.name.clone());
+        if crate::app::splice_into_wire(self.current_dir_mut(), &new_id, src_name.clone(), dest_id) {
+            self.sync_nodes();
+            self.rebuild_scene_geometry();
+            self.sync_parameters_pane();
+            let dest = self.current_dir().children.iter().find(|c| c.id == dest_id).map(|c| c.name.clone()).unwrap_or_default();
+            self.update_status_text(&format!("Added {new_name} between {src_name} and {dest}."));
+        }
+    }
+
     /// Open the dialog to rename the node in `slot` of the current level.
     /// The query line is the name: it opens holding the one the node has,
     /// so a rename that changes a letter is a letter typed.
@@ -2452,6 +2468,12 @@ impl State {
             }
             Mode::Groups => {}
             Mode::AddNode => {
+                // A free cursor cell a wire runs through is a place in that
+                // chain: the new node is spliced into the wire, as a node
+                // dropped there would be. Asked before the add, which puts
+                // a node on the cell and its own wires through it.
+                let free = !self.current_dir().children.iter().any(|c| c.position == (gx, gy));
+                let wire = if free { self.graph().input_wire_through_cell(gx, gy) } else { None };
                 let mut redraw = false;
                 let action = crate::app::McpAction::AddNode {
                     template_name: id,
@@ -2459,12 +2481,17 @@ impl State {
                     x: gx,
                     y: gy,
                 };
-                if let Err(e) = self.apply_action(action, &mut redraw) {
+                match self.apply_action(action, &mut redraw) {
+                    Ok(_) => {
+                        if let Some((src_id, dest_id)) = wire {
+                            self.splice_new_node(&src_id, &dest_id);
+                        }
+                    }
                     // The one refusal this can hit is a geometry template in
                     // a utility dir, which `refresh_dialog_rows` already
                     // filters out — but the rule lives in `apply_action`, so
                     // say what it said rather than assume it cannot fire.
-                    self.update_status_text(&e);
+                    Err(e) => self.update_status_text(&e),
                 }
             }
         }
diff --git a/src/main.rs b/src/main.rs
index 313aa28..647bb1d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15526,6 +15526,51 @@ mod tests {
         assert_eq!(added.position, (3.0, 2.0), "placed at the grid cursor");
     }
 
+    /// Adding a node on a free cell a wire runs through wires it into that
+    /// chain: A -> C becomes A -> new -> C. Off the wire, or for a node with
+    /// no Input, nothing is rewired.
+    #[test]
+    fn a_node_added_on_a_wire_is_wired_into_its_chain() {
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.current_dir_mut().children = vec![
+            ref_node("a", "a", "sphere", vec![("Radius", "float", "1")], vec![]),
+            ref_node("c", "c", "transform", vec![("Input", "node", "a")], vec![]),
+        ];
+        state.current_dir_mut().children[0].position = (2.0, 1.0);
+        state.current_dir_mut().children[1].position = (2.0, 3.0);
+        state.sync_nodes();
+        state.rebuild_positions();
+        state.apply_layout();
+        let input_of = |state: &State, name: &str| {
+            let n = state.current_dir().children.iter().find(|c| c.name == name).expect(name);
+            crate::geometry::node_param_node(n, "Input")
+        };
+        let add = |state: &mut State, template: &str, col: i32, row: i32| {
+            state.grid_cursor_col = col;
+            state.grid_cursor_row = row;
+            state.open_node_palette();
+            state.take_dialog_pick(template.to_string());
+            state.current_dir().children.last().unwrap().name.clone()
+        };
+
+        let mid = add(&mut state, "Transform", 2, 2);
+        assert_eq!(input_of(&state, &mid).as_deref(), Some("a"), "the new node reads the wire's upstream");
+        assert_eq!(input_of(&state, "c").as_deref(), Some(mid.as_str()), "and the downstream reads it");
+        assert!(state.last_status_text.contains("between"), "{}", state.last_status_text);
+
+        // Off every wire: added, wired to nothing new.
+        let aside = add(&mut state, "Transform", 6, 2);
+        assert_eq!(input_of(&state, "c").as_deref(), Some(mid.as_str()));
+        assert_ne!(input_of(&state, &aside).as_deref(), Some("a"));
+
+        // A generator on a wire cannot sit mid-chain: the wire is left alone.
+        state.current_dir_mut().children[1].position = (2.0, 5.0);
+        state.sync_nodes();
+        let gen = add(&mut state, "Sphere", 2, 4);
+        assert_eq!(input_of(&state, "c").as_deref(), Some(mid.as_str()), "{gen} did not cut the wire");
+    }
+
     /// The Add Node list offers every template, everywhere.
     ///
     /// It used to hide the geometry ones inside a "utility dir" — the root