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

commitd068b95ebb649ac418e59bf56dad77654f9d1b00
parentfca706e795
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 12:22
feat: deleting a node joins the nodes it was wired between

Every sibling wire that named the deleted node takes the name of what the
node read through its Input, so deleting B from A -> B -> C leaves A -> C.
Undone with the deletion.

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

 CLAUDE.md   | 15 +++++++++++++++
 src/app.rs  | 36 ++++++++++++++++++++++++++++++++++++
 src/main.rs | 40 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 91 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 5089732..a48976a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1046,6 +1046,21 @@ page resolver — with each node of a sheet, grid, border and export chain
 bypassed in turn — and `bypass_is_one_flag_however_it_is_asked_for` the
 three ways of asking.
 
+### Deleting a node splices it out
+
+`State::delete_node` rewires around the node before it goes (since
+2026-10-01, `app::splice_out`): every sibling wire that named it — an
+`Input` or a second operand, any plain `node` parameter — takes the name
+the deleted node's own `Input` carried, so deleting B from A → B → C
+leaves A → C. Every way of deleting goes through it (Delete over a
+selection, Cut, the node menu, MCP); a selection is deleted highest slot
+first, one splice at a time, so a run of chained nodes leaves its ends
+joined. Nothing is rewired when the node's Input is empty (a generator),
+an expression or hidden — those wires are left naming it, as before —
+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.
+
 ### 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 514ee70..f921f3d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -752,6 +752,35 @@ pub fn node_wires_at(root: &FsNode, node: &FsNode, frame: i32) -> Vec<(String, S
     }
 }
 
+/// Rewire around the child at `slot` of `dir`, ahead of its removal: every
+/// sibling's wire that names it — `Input` or a second operand, any shown,
+/// plain `node` parameter — takes the name the child's own `Input` wire
+/// carries. Nothing changes when that wire is empty, an expression, hidden
+/// or names the child itself, and a sibling is never wired to itself.
+pub(crate) fn splice_out(dir: &mut FsNode, slot: usize) {
+    let Some(gone) = dir.children.get(slot) else { return };
+    let name = gone.name.clone();
+    let Some(upstream) = gone
+        .params
+        .iter()
+        .find(|p| p.name == "Input" && p.kind() == ParamKind::Node && !p.is_expr() && param_visible(&gone.params, &p.show_when))
+        .map(|p| p.text().trim().to_string())
+        .filter(|u| !u.is_empty() && *u != name)
+    else {
+        return;
+    };
+    for (i, sibling) in dir.children.iter_mut().enumerate() {
+        if i == slot || sibling.name == upstream {
+            continue;
+        }
+        for p in sibling.params.iter_mut() {
+            if p.kind() == ParamKind::Node && !p.is_expr() && p.text().trim() == name {
+                p.set_text(upstream.clone());
+            }
+        }
+    }
+}
+
 /// [`node_wires_at`] with nothing evaluated: an expression wire names
 /// nothing.
 pub fn node_wires(node: &FsNode) -> Vec<(String, String)> {
@@ -6669,9 +6698,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         (x, y)
     }
 
+    /// Delete the node at `slot` of the current level, splicing it out of
+    /// its chain: every sibling wire that named it is rewired to what it
+    /// read through its `Input`, so deleting B from A → B → C leaves A → C.
+    /// A node with no Input (a generator) leaves those wires as they were.
+    /// The rewiring is part of the same undo step as the deletion, since a
+    /// structure step holds the wires of every node it touches.
     pub fn delete_node(&mut self, slot: usize) -> bool {
         let len = self.current_dir().children.len();
         if slot < len {
+            splice_out(self.current_dir_mut(), slot);
             self.current_dir_mut().children.remove(slot);
             if let Some(sel_idx) = self.graph().selected_node() {
                 if sel_idx == slot {
diff --git a/src/main.rs b/src/main.rs
index 1ed5604..313aa28 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8842,6 +8842,46 @@ mod tests {
     /// The shell end to end, through the resolver the viewport calls.
     // ----- Parameter references and the Switch node (src/geometry.rs) -----
 
+    /// Deleting a node wired between two splices it out: what read it reads
+    /// what it read, through every wire — Input and second operand alike —
+    /// and the rewiring is undone with the deletion.
+    #[test]
+    fn deleting_a_wired_node_connects_its_neighbours() {
+        let mut state = State::new(false);
+        let wires = |state: &State, name: &str| -> Vec<(String, String)> {
+            let n = state.current_dir().children.iter().find(|c| c.name == name).expect(name);
+            crate::app::node_wires(n)
+        };
+        state.current_dir_mut().children = vec![
+            ref_node("a", "a", "sphere", vec![("Radius", "float", "1")], vec![]),
+            ref_node("b", "b", "transform", vec![("Input", "node", "a")], vec![]),
+            ref_node("c", "c", "transform", vec![("Input", "node", "b")], vec![]),
+            ref_node("d", "d", "boolean", vec![("Input", "node", "c"), ("With", "node", "b")], vec![]),
+        ];
+        state.sync_nodes();
+        state.record_structure_changes();
+
+        assert!(state.delete_node(1));
+        state.record_structure_changes();
+        assert_eq!(wires(&state, "c"), vec![("Input".to_string(), "a".to_string())], "A -> C");
+        assert_eq!(
+            wires(&state, "d"),
+            vec![("Input".to_string(), "c".to_string()), ("With".to_string(), "a".to_string())],
+            "a second operand follows too"
+        );
+
+        // A generator has nothing to splice: what read it is left as it was.
+        assert!(state.delete_node(0));
+        assert_eq!(wires(&state, "c"), vec![("Input".to_string(), "a".to_string())]);
+
+        // Undo puts the node back and the wires with it.
+        state.record_structure_changes();
+        assert!(state.run_command("undo"));
+        assert!(state.run_command("undo"));
+        assert_eq!(wires(&state, "c"), vec![("Input".to_string(), "b".to_string())]);
+        assert_eq!(wires(&state, "d")[1], ("With".to_string(), "b".to_string()));
+    }
+
     fn ref_node(id: &str, name: &str, node_type: &str, params: Vec<(&str, &str, &str)>, children: Vec<FsNode>) -> FsNode {
         FsNode {
             id: id.into(),