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

commitb88f847b5574c798010dc6473914ed1b47ac4472
parent558cf33618
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 12:29
feat: a paste on a wire is spliced into its chain

A pasted node, or a pasted chain with one head and one tail, on a free
cursor cell a wire runs through goes into that wire. Pasted nodes whose
names are taken take the next free one, the wires inside the paste
following, so a pasted chain reads itself and not the original.

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

 CLAUDE.md   | 11 +++++++++++
 src/app.rs  | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 src/main.rs | 45 +++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 116 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index c60bcf2..6cc5b4e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1076,6 +1076,17 @@ 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.
 
+**A paste splices the same way** (`paste_nodes`, the same day): one node,
+or a pasted set that is ONE chain — one head whose Input reads no other
+pasted node, one tail no other pasted node reads — goes into the wire
+whole (`splice_chain_into_wire`); any other shape is pasted beside it.
+That needed a fix it could not work without: a paste KEPT ITS NAMES, so a
+pasted `transform1` stood beside the original and a wire naming it found
+the original. A pasted node whose name is taken now takes the next free
+one, and the wires between pasted nodes follow; a wire to a node that was
+not copied still names that node.
+`a_paste_on_a_wire_is_spliced_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 e8ec547..b1d51c4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -788,12 +788,18 @@ pub(crate) fn splice_out(dir: &mut FsNode, slot: usize) {
 /// 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 {
+    splice_chain_into_wire(dir, mid_id, mid_id, src_name, dest_id)
+}
+
+/// [`splice_into_wire`] for a chain: its `head` takes the wire's upstream,
+/// and the downstream node reads its `tail`. A pasted chain goes in whole.
+pub(crate) fn splice_chain_into_wire(dir: &mut FsNode, head_id: &str, tail_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) {
+    let Some(tail_name) = dir.children.iter().find(|c| c.id == tail_id).map(|c| c.name.clone()) else { return false };
+    if !has_input(head_id) || !has_input(dest_id) {
         return false;
     }
-    for (id, wire) in [(mid_id, src_name), (dest_id, mid_name)] {
+    for (id, wire) in [(head_id, src_name), (dest_id, tail_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);
         }
@@ -8928,6 +8934,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
     /// A node whose cell is taken steps aside to the nearest free one, which
     /// is the one case where the shape gives: a paste that silently sat two
     /// nodes on one crossing would be worse than a paste that is a cell out.
+    ///
+    /// A pasted node whose name is taken takes the next free one
+    /// (`transform1` beside a `transform1` becomes `transform2`), and the
+    /// wires between pasted nodes follow, so a pasted chain reads itself
+    /// and not the chain it was copied from. A wire to a node that was not
+    /// copied still names that node.
+    ///
+    /// Pasted on a free cursor cell a wire runs through, the paste is
+    /// spliced into that wire, as Add Node there would be: one node, or a
+    /// chain with one head and one tail, goes in whole.
     pub(crate) fn paste_nodes(&mut self) -> bool {
         if self.node_clipboard.is_empty() {
             return false;
@@ -8936,7 +8952,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             (x.min(n.position.0), y.min(n.position.1))
         });
         let (cx, cy) = (self.grid_cursor_col as f32, self.grid_cursor_row as f32);
+        // Asked before the paste, whose nodes' own wires would touch the cell.
+        let free = !self.current_dir().children.iter().any(|c| c.position == (cx, cy));
+        let wire = if free { self.graph().input_wire_through_cell(cx, cy) } else { None };
         let mut last = (cx, cy);
+        let mut renamed: Vec<(String, String)> = Vec::new();
+        let mut pasted: Vec<String> = Vec::new();
         for source in self.node_clipboard.clone() {
             let mut node = source;
             regenerate_node_ids(&mut node);
@@ -8946,9 +8967,27 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             // Added = hidden, same as AddNode: a paste of a displayed node
             // must not become a second visible sibling.
             node.geometry_visible = false;
+            let old = node.name.clone();
+            if self.current_dir().children.iter().any(|c| c.name == old) {
+                node.name = self.get_lowest_unused_name(old.trim_end_matches(|c: char| c.is_ascii_digit()));
+            }
+            renamed.push((old, node.name.clone()));
+            pasted.push(node.id.clone());
             self.current_dir_mut().children.push(node);
             last = (nx, ny);
         }
+        let dir = self.current_dir_mut();
+        for node in dir.children.iter_mut().filter(|c| pasted.contains(&c.id)) {
+            for p in node.params.iter_mut().filter(|p| p.kind() == ParamKind::Node && !p.is_expr()) {
+                let wired = p.text().trim().to_string();
+                if let Some((_, new)) = renamed.iter().find(|(old, _)| *old == wired) {
+                    p.set_text(new.clone());
+                }
+            }
+        }
+        if let Some((src_id, dest_id)) = wire {
+            self.splice_paste(&pasted, &src_id, &dest_id);
+        }
         // The cursor lands on the last node pasted, collapsed — the pasted
         // nodes are new, and the region that selected the originals means
         // nothing about them.
@@ -8965,6 +9004,24 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         true
     }
 
+    /// Splice the nodes just pasted (`ids`) into the wire from `src_id` to
+    /// `dest_id`: as one chain, its head the pasted node whose Input reads
+    /// no other pasted node, its tail the one no other pasted node reads.
+    /// A paste that is not one such chain is left beside the wire.
+    fn splice_paste(&mut self, ids: &[String], src_id: &str, dest_id: &str) {
+        let dir = self.current_dir();
+        let nodes: Vec<&FsNode> = dir.children.iter().filter(|c| ids.contains(&c.id)).collect();
+        let input = |n: &FsNode| crate::geometry::node_param_node(n, "Input");
+        let heads: Vec<&FsNode> = nodes.iter().copied().filter(|n| !input(n).is_some_and(|i| nodes.iter().any(|m| m.name == i))).collect();
+        let tails: Vec<&FsNode> = nodes.iter().copied().filter(|n| !nodes.iter().any(|m| input(m).as_deref() == Some(n.name.as_str()))).collect();
+        let ([head], [tail], Some(src)) = (heads.as_slice(), tails.as_slice(), dir.children.iter().find(|c| c.id == src_id)) else { return };
+        let (head_id, tail_id, src_name) = (head.id.clone(), tail.id.clone(), src.name.clone());
+        if splice_chain_into_wire(self.current_dir_mut(), &head_id, &tail_id, src_name.clone(), dest_id) {
+            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!("Pasted between {src_name} and {dest}."));
+        }
+    }
+
     /// Move the SELECTED nodes one cell, and the cursor with them — so a run
     /// of alt+h drags them across the sheet rather than leaving them behind on
     /// the first press.
diff --git a/src/main.rs b/src/main.rs
index 647bb1d..f064c20 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15241,6 +15241,51 @@ mod tests {
         assert_eq!(pasted, vec![(20.0, 20.0), (22.0, 21.0)]);
     }
 
+    /// Pasting on a wire splices the paste in — one node, or a copied chain
+    /// whole — and a pasted node whose name is taken takes the next free
+    /// one, the wires inside the paste following it.
+    #[test]
+    fn a_paste_on_a_wire_is_spliced_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![]),
+            ref_node("p", "p1", "transform", vec![("Input", "node", "")], vec![]),
+            ref_node("q", "q1", "transform", vec![("Input", "node", "p1")], vec![]),
+        ];
+        for (i, pos) in [(2.0, 1.0), (2.0, 5.0), (8.0, 1.0), (8.0, 2.0)].into_iter().enumerate() {
+            state.current_dir_mut().children[i].position = pos;
+        }
+        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")
+        };
+
+        // One node, onto the wire a -> c.
+        state.node_clipboard = vec![state.current_dir().children[2].clone()];
+        state.grid_cursor_col = 2;
+        state.grid_cursor_row = 2;
+        assert!(state.paste_nodes());
+        assert_eq!(input_of(&state, "p2").as_deref(), Some("a"), "renamed past p1, and reading the upstream");
+        assert_eq!(input_of(&state, "c").as_deref(), Some("p2"));
+
+        // The chain p1 -> q1, onto the wire p2 -> c: in whole, wired inside
+        // to its own copies and not to the originals.
+        state.node_clipboard = state.current_dir().children[2..4].to_vec();
+        state.grid_cursor_col = 2;
+        state.grid_cursor_row = 3;
+        state.sync_nodes();
+        assert!(state.paste_nodes());
+        assert_eq!(input_of(&state, "p3").as_deref(), Some("p2"), "the head reads the wire's upstream");
+        assert_eq!(input_of(&state, "q2").as_deref(), Some("p3"), "the copy reads the copy");
+        assert_eq!(input_of(&state, "c").as_deref(), Some("q2"), "the downstream reads the tail");
+        assert_eq!(input_of(&state, "q1").as_deref(), Some("p1"), "the original is untouched");
+    }
+
     /// A press the graph itself took does not arm the expansion drag. The
     /// case that bites is a PORT: it starts a connection and consumes the
     /// press without selecting anything, so the empty-grid arm would read it