graphic design tool
git clone https://git.lucas.co/cce-designer.git
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