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