graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: every wire is drawn, each into its own port
The network drew only a node's Input, so a Switch's Input 2, a Boolean's
With or a Transfer's From was a connection with no line. Every node
parameter is handed to the graph as a wire now (app::node_wires), the
k-th into port k, with as many ports as wires; a hidden or expression
wire keeps its port and draws none. A connection dropped on a port sets
that wire, and auto-layout puts a node below everything it reads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 26 ++++++++++++-----
src/app.rs | 94 ++++++++++++++++++++++++++++++++++++++---------------------
src/layout.rs | 31 +++++++++++++++-----
src/main.rs | 55 ++++++++++++++++++++++++++++++++++
4 files changed, 157 insertions(+), 49 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 36c1a25..287b105 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1060,8 +1060,18 @@ way — both in `src/geometry.rs`:
Which rows GET a picker is the parameter's kind — `attribute` / `group`,
see "Parameter kinds" — not a table of row names.
- **`switch`** passes one of `Input`, `Input 2` … `Input 4` by `Index`,
- clamped; an empty slot passes nothing. Only `Input` draws a wire, the
- limit every second operand has (Boolean's With, Copy's target).
+ clamped; an empty slot passes nothing. Every one of them draws a wire,
+ into its own port, as every second operand does (Boolean's With, Copy's
+ To, Transfer's From) — since 2026-09-30; until then only `Input` did, so
+ a second operand was a connection with no line. `app::node_wires` is the
+ rule: every `node` parameter, in order, the k-th into port k, handed to
+ the graph typed `node` (cce-ui's `wire_pairs`); a node gets as many input
+ ports as it has wires where its template declared fewer (Relax's Rest,
+ Collision's Collider, the Remesh's From). A wire whose row is hidden, or
+ that is an expression (the Remesh subnet's transfer reads its From
+ through one), keeps its port and draws no line. A connection dropped on
+ port k sets the k-th wire (`State::connect_port`).
+ `every_wire_is_drawn_into_its_own_port` is the test.
### Sphere, Box, Plane and Extrude are native (2026-09-24)
@@ -2542,13 +2552,13 @@ by cell — so this is a layered assignment on cells, not a force-directed
sprawl: a node's ROW is how far downstream it is, its COLUMN is chosen to sit
under what it reads from.
-**Edges come from the same rule the wires do** — a node's `Input` parameter
-naming another node, which is the widget's `wire_pairs` derivation. Matching it
+**Edges come from the same rule the wires do** — every wire the network draws
+(`app::node_wires`), which is the widget's `wire_pairs` derivation. Matching it
is the point: a layout computed from relationships you cannot see would move
-nodes for reasons that are not on screen. It also means a second operand (a
-Boolean's `With`, a Copy's target) does not pull on the layout, because it does
-not draw a wire either. When those become wires they should become edges here
-in the same change.
+nodes for reasons that are not on screen. Since second operands became wires
+(2026-09-30) they are edges too, but only for the ROW (`LayoutNode::reads`): a
+node sits below everything it reads, and under its `Input` alone, so a chain
+stays vertical and a Boolean's `With` does not drag it sideways.
Flow is downward, matching every project in the repo (a Sphere at (4, 2)
feeding an output at (4, 3)). Row is the LONGEST path from a root, not the
diff --git a/src/app.rs b/src/app.rs
index 1715a67..9512654 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -709,6 +709,23 @@ pub fn float3_row(min: f32, max: f32, trackball: bool) -> String {
format!("float3:{}:{}{}", min, max, if trackball { ":trackball" } else { "" })
}
+/// A node's wires as the network draws them: every parameter of the `node`
+/// kind, in order — the k-th is input port k — as (name, the node it names).
+/// A wire whose row is hidden (`show_when`) or that is an expression names
+/// nothing here: it keeps its port and draws no line, since what is not on
+/// screen should not be, and an expression is not a name until evaluated.
+/// Auto-layout reads the same wires.
+pub fn node_wires(node: &FsNode) -> Vec<(String, String)> {
+ node.params
+ .iter()
+ .filter(|p| p.kind() == ParamKind::Node)
+ .map(|p| {
+ let shown = param_visible(&node.params, &p.show_when) && !p.is_expr();
+ (p.name.clone(), if shown { p.text().trim().to_string() } else { String::new() })
+ })
+ .collect()
+}
+
pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
// Rows whose condition does not hold are not shown. Write-back resolves a
// row by its display key rather than by position, so a hidden parameter
@@ -6633,23 +6650,49 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
changed.len()
}
- /// One level's children as the graph widget's rows.
+ /// One level's children as the graph widget's rows. What the widget
+ /// reads of a row's parameters is its WIRES — every `node` parameter,
+ /// typed `node`, the k-th into input port k ([`node_wires`]) — so that
+ /// is what it is handed, and a node has as many input ports as it has
+ /// wires where its template declared fewer (Relax's Rest, Collision's
+ /// Collider, the Remesh's From).
fn graph_nodes_of(dir: &FsNode) -> Vec<GraphNode> {
dir.children
.iter()
- .map(|c| GraphNode {
- id: c.id.clone(),
- name: c.name.clone(),
- position: c.position,
- parameters: param_display(&c.params),
- geom_visible: c.geometry_visible,
- node_type: c.node_type.clone(),
- inputs: c.inputs,
- outputs: c.outputs,
+ .map(|c| {
+ let wires = node_wires(c);
+ GraphNode {
+ id: c.id.clone(),
+ name: c.name.clone(),
+ position: c.position,
+ inputs: c.inputs.max(wires.len()),
+ parameters: wires.into_iter().map(|(name, source)| (name, source, "node".to_string())).collect(),
+ geom_visible: c.geometry_visible,
+ node_type: c.node_type.clone(),
+ outputs: c.outputs,
+ }
})
.collect()
}
+ /// The pointer connected `output` into input port `port` of the node
+ /// `input_id` on the level at `path`: the port's wire is set. False when
+ /// the node or the port is not there.
+ pub(crate) fn connect_port(&mut self, path: &[usize], input_id: &str, output: String, port: usize) -> bool {
+ let dir = self.dir_at_mut(path);
+ let Some(child) = dir.children.iter_mut().find(|c| c.id == input_id) else { return false };
+ // A node with no wire parameter at all (none that says so) takes
+ // it as its Input, as a connection always did.
+ let slot = child.params.iter().enumerate().filter(|(_, p)| p.kind() == ParamKind::Node).map(|(i, _)| i).nth(port)
+ .or_else(|| child.params.iter().position(|p| p.name == "Input"));
+ let Some(slot) = slot else { return false };
+ child.params[slot].set_text(output);
+ self.sync_nodes();
+ self.rebuild_scene_geometry();
+ self.sync_parameters_pane();
+ true
+ }
+
pub fn sync_nodes(&mut self) {
let graph_nodes = Self::graph_nodes_of(self.current_dir());
self.graph_mut().set_nodes(&graph_nodes);
@@ -8534,6 +8577,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
.iter()
.find(|p| p.name.eq_ignore_ascii_case("input"))
.map(|p| p.text().to_string()),
+ // The rest of the wires the network draws.
+ reads: node_wires(c).into_iter().filter(|(name, _)| !name.eq_ignore_ascii_case("input")).map(|(_, src)| src).collect(),
position: c.position,
// Utility trees stay where they were put; see the module doc.
pinned: false,
@@ -10750,17 +10795,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
changed = true;
}
- if let Some((input_node_id, output_node_name)) = self.graph_mut().take_pending_connection() {
- let dir = self.current_dir_mut();
- if let Some(child) = dir.children.iter_mut().find(|c| c.id == input_node_id) {
- if let Some(p) = child.params.iter_mut().find(|p| p.name == "Input") {
- p.set_text(output_node_name);
- self.sync_nodes();
- self.rebuild_scene_geometry();
- self.sync_parameters_pane();
- changed = true;
- }
- }
+ if let Some((input_node_id, output_node_name, port)) = self.graph_mut().take_pending_connection_to_port() {
+ let path = self.current_path.clone();
+ changed |= self.connect_port(&path, &input_node_id, output_node_name, port);
}
// A node dropped onto a wire splices in between its ends:
@@ -10816,20 +10853,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
changed = true;
}
}
- if let Some((input_node_id, output_node_name)) =
- self.slots.content2.take_pending_connection()
+ if let Some((input_node_id, output_node_name, port)) =
+ self.slots.content2.take_pending_connection_to_port()
{
let p2 = self.current_path2.clone();
- let dir = self.dir_at_mut(&p2);
- if let Some(child) = dir.children.iter_mut().find(|c| c.id == input_node_id) {
- if let Some(p) = child.params.iter_mut().find(|p| p.name == "Input") {
- p.set_text(output_node_name);
- self.sync_nodes();
- self.rebuild_scene_geometry();
- self.sync_parameters_pane();
- changed = true;
- }
- }
+ changed |= self.connect_port(&p2, &input_node_id, output_node_name, port);
}
if let Some((mid_id, src_name, dest_id)) =
self.slots.content2.take_pending_splice()
diff --git a/src/layout.rs b/src/layout.rs
index 33cc26a..ccdf47a 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -6,13 +6,14 @@
//! how far it is downstream, and its COLUMN is chosen to sit under the node it
//! reads from.
//!
-//! **Edges come from the same rule the wires do**: a node's `Input` parameter
-//! naming another node. That is the widget's `wire_pairs` derivation, and
-//! matching it is the point — a layout computed from relationships you cannot
-//! see would move nodes for reasons that are not on screen. It also means a
-//! second operand (a Boolean's `With`, a Copy's target) does not pull on the
-//! layout, because it does not draw a wire either. When those become wires,
-//! they should become edges here in the same change.
+//! **Edges come from the same rule the wires do**: every wire the network
+//! draws (`app::node_wires`, the widget's `wire_pairs`) — a node's `Input`,
+//! and since 2026-09-30 its second operands too (a Boolean's `With`, a
+//! Switch's `Input 2`, a Transfer's `From`). Matching the wires is the point:
+//! a layout computed from relationships you cannot see would move nodes for
+//! reasons that are not on screen. Every wire pushes a node below what it
+//! reads; the `Input` alone decides its column, so a chain stays vertical and
+//! a second operand does not drag the node sideways.
//!
//! **Flow is downward**, matching every project in the repo: a Sphere at
//! (4, 2) feeds an output at (4, 3). Row is the LONGEST path from a root, not
@@ -30,6 +31,9 @@ pub struct LayoutNode {
pub name: String,
/// The value of its `Input` parameter, if it has one.
pub input: Option<String>,
+ /// What its other wires read — second operands. They set its row, not
+ /// its column.
+ pub reads: Vec<String>,
pub position: (f32, f32),
pub pinned: bool,
}
@@ -56,6 +60,17 @@ pub fn arrange(nodes: &[LayoutNode]) -> Vec<(usize, (f32, f32))> {
})
.collect();
+ // Every node read, the Input's included: what sets the row.
+ let find = |want: &str| {
+ let want = want.trim();
+ (!want.is_empty()).then(|| nodes.iter().position(|other| other.name == want)).flatten()
+ };
+ let reads: Vec<Vec<usize>> = nodes
+ .iter()
+ .enumerate()
+ .map(|(i, node)| parent[i].into_iter().chain(node.reads.iter().filter_map(|r| find(r))).collect())
+ .collect();
+
// Depth by longest path, iteratively. A name-wired graph can contain a
// cycle (A reads B reads A), and the fixed point below simply stops
// improving instead of recursing forever — the cycle's members end up at
@@ -65,7 +80,7 @@ pub fn arrange(nodes: &[LayoutNode]) -> Vec<(usize, (f32, f32))> {
for _ in 0..n {
let mut changed = false;
for i in 0..n {
- if let Some(p) = parent[i] {
+ for &p in &reads[i] {
if p != i && depth[p] + 1 > depth[i] {
depth[i] = depth[p] + 1;
changed = true;
diff --git a/src/main.rs b/src/main.rs
index 0ab3c29..7eaa9f2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8070,6 +8070,7 @@ mod tests {
let node = |name: &str, input: Option<&str>, pos: (f32, f32)| LayoutNode {
name: name.to_string(),
input: input.map(|s| s.to_string()),
+ reads: Vec::new(),
position: pos,
pinned: false,
};
@@ -8110,6 +8111,59 @@ mod tests {
assert_eq!(at(3).1, 1.0);
}
+ /// Every wire a node has is drawn, into its own port: the Remesh's
+ /// switch reads the loop on its Input and the transfer on its Input 2,
+ /// and both are lines on the network now, where only the Input was. An
+ /// expression wire (the transfer's From) keeps its port and draws none.
+ /// A connection dropped on a port sets THAT wire, and auto-layout puts
+ /// a node below everything it reads.
+ #[test]
+ fn every_wire_is_drawn_into_its_own_port() {
+ use cce_ui::widget::node_wires;
+ let mut state = State::new(false);
+ state.current_path.clear();
+ let mut redraw = false;
+ state.apply_action(McpAction::AddNode { template_name: "Remesh".into(), name: Some("remesh1".into()), x: 3.0, y: 8.0 }, &mut redraw).unwrap();
+ let slot = state.current_dir().children.iter().position(|c| c.name == "remesh1").unwrap();
+ state.apply_action(McpAction::Enter { slot }, &mut redraw).unwrap();
+ state.sync_nodes();
+ let nodes = state.graph().get_nodes();
+ let get = |n: &str| nodes.iter().find(|g| g.name == n).unwrap().clone();
+ let switch = get("transfer_switch1");
+ assert_eq!(node_wires(&switch), ["repeat1", "transfer1", "", ""]);
+ assert_eq!(switch.inputs, 4);
+ let transfer = get("transfer1");
+ assert_eq!(node_wires(&transfer), ["repeat1", ""], "From is an expression: a port, no line");
+
+ // Dropped on the switch's third port: Input 3.
+ let path = state.current_path.clone();
+ let id = switch.id.clone();
+ assert!(state.connect_port(&path, &id, "input1".into(), 2));
+ let sw = state.current_dir().children.iter().find(|c| c.id == id).unwrap();
+ assert_eq!(sw.params.iter().find(|p| p.name == "Input 3").unwrap().text(), "input1");
+ assert_eq!(sw.params.iter().find(|p| p.name == "Input").unwrap().text(), "repeat1", "the Input is untouched");
+
+ // A second operand sets the row, not the column.
+ use crate::layout::{arrange, LayoutNode};
+ let node = |name: &str, input: Option<&str>, reads: &[&str], pos: (f32, f32)| LayoutNode {
+ name: name.into(),
+ input: input.map(String::from),
+ reads: reads.iter().map(|s| s.to_string()).collect(),
+ position: pos,
+ pinned: false,
+ };
+ let nodes = vec![
+ node("a", None, &[], (0.0, 0.0)),
+ node("b", Some("a"), &[], (4.0, 0.0)),
+ node("c", Some("b"), &[], (4.0, 0.0)),
+ node("join", Some("a"), &["c"], (0.0, 0.0)),
+ ];
+ let moved: std::collections::HashMap<usize, (f32, f32)> = arrange(&nodes).into_iter().collect();
+ let at = |i: usize| moved.get(&i).copied().unwrap_or(nodes[i].position);
+ assert_eq!(at(3).1, 3.0, "below c, which it reads through its second wire");
+ assert_eq!(at(3).0, at(0).0, "under a, which its Input reads");
+ }
+
/// The cases that would otherwise hang or overwrite: cycles, self
/// reference, dangling names, and pinned cells.
#[test]
@@ -8118,6 +8172,7 @@ mod tests {
let node = |name: &str, input: Option<&str>, pos: (f32, f32), pinned: bool| LayoutNode {
name: name.to_string(),
input: input.map(|s| s.to_string()),
+ reads: Vec::new(),
position: pos,
pinned,
};