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

src/layout.rs (6K)

  1 //! Auto-layout: arrange a level's nodes from their wiring.
  2 //!
  3 //! The network is already a GRID — every node's position is an integer cell,
  4 //! and the keyboard cursor moves cell by cell — so this is not the usual
  5 //! force-directed sprawl. It is a layered assignment on cells: a node's ROW is
  6 //! how far it is downstream, and its COLUMN is chosen to sit under the node it
  7 //! reads from.
  8 //!
  9 //! **Edges come from the same rule the wires do**: every wire the network
 10 //! draws (`app::node_wires`, the widget's `wire_pairs`) — a node's `Input`,
 11 //! and since 2026-09-30 its second operands too (a Boolean's `With`, a
 12 //! Switch's `Input 2`, a Transfer's `From`). Matching the wires is the point:
 13 //! a layout computed from relationships you cannot see would move nodes for
 14 //! reasons that are not on screen. Every wire pushes a node below what it
 15 //! reads; the `Input` alone decides its column, so a chain stays vertical and
 16 //! a second operand does not drag the node sideways.
 17 //!
 18 //! **Flow is downward**, matching every project in the repo: a Sphere at
 19 //! (4, 2) feeds an output at (4, 3). Row is the LONGEST path from a root, not
 20 //! the shortest, so a node always sits below every one of its inputs rather
 21 //! than beside one of them.
 22 //!
 23 //! Nothing is pinned any more. The settings tree — the root meta node and
 24 //! its four utility subnets — was, because it lived where the user put it and
 25 //! relocating it would have been a surprise every time; it is retired, and
 26 //! `Node::pinned` outlives it for whatever wants it next.
 27 
 28 /// One node's layout input: what it is called, what it reads, where it is now,
 29 /// and whether it may be moved.
 30 pub struct LayoutNode {
 31     pub name: String,
 32     /// The value of its `Input` parameter, if it has one.
 33     pub input: Option<String>,
 34     /// What its other wires read — second operands. They set its row, not
 35     /// its column.
 36     pub reads: Vec<String>,
 37     pub position: (f32, f32),
 38     pub pinned: bool,
 39 }
 40 
 41 /// New positions for the nodes that moved, as (index, (column, row)).
 42 ///
 43 /// Only movers are returned, so a caller can tell whether the layout changed
 44 /// anything and report it — an arrange that silently did nothing looks broken.
 45 pub fn arrange(nodes: &[LayoutNode]) -> Vec<(usize, (f32, f32))> {
 46     let n = nodes.len();
 47     if n == 0 {
 48         return Vec::new();
 49     }
 50 
 51     // Parent index per node, by the wires' own rule.
 52     let parent: Vec<Option<usize>> = nodes
 53         .iter()
 54         .map(|node| {
 55             let want = node.input.as_deref()?.trim();
 56             if want.is_empty() {
 57                 return None;
 58             }
 59             nodes.iter().position(|other| other.name == want)
 60         })
 61         .collect();
 62 
 63     // Every node read, the Input's included: what sets the row.
 64     let find = |want: &str| {
 65         let want = want.trim();
 66         (!want.is_empty()).then(|| nodes.iter().position(|other| other.name == want)).flatten()
 67     };
 68     let reads: Vec<Vec<usize>> = nodes
 69         .iter()
 70         .enumerate()
 71         .map(|(i, node)| parent[i].into_iter().chain(node.reads.iter().filter_map(|r| find(r))).collect())
 72         .collect();
 73 
 74     // Depth by longest path, iteratively. A name-wired graph can contain a
 75     // cycle (A reads B reads A), and the fixed point below simply stops
 76     // improving instead of recursing forever — the cycle's members end up at
 77     // the deepest row any of them could justify, which is as meaningful an
 78     // answer as a cyclic graph has.
 79     let mut depth = vec![0usize; n];
 80     for _ in 0..n {
 81         let mut changed = false;
 82         for i in 0..n {
 83             for &p in &reads[i] {
 84                 if p != i && depth[p] + 1 > depth[i] {
 85                     depth[i] = depth[p] + 1;
 86                     changed = true;
 87                 }
 88             }
 89         }
 90         if !changed {
 91             break;
 92         }
 93     }
 94 
 95     // Cells a pinned node holds; the assignment steps around them.
 96     let mut taken: Vec<(i32, i32)> = nodes
 97         .iter()
 98         .filter(|node| node.pinned)
 99         .map(|node| (node.position.0 as i32, node.position.1 as i32))
100         .collect();
101 
102     let max_depth = (0..n).filter(|&i| !nodes[i].pinned).map(|i| depth[i]).max().unwrap_or(0);
103     let mut column = vec![0i32; n];
104     let mut placed = vec![false; n];
105     let mut out = Vec::new();
106 
107     for row in 0..=max_depth {
108         let mut in_row: Vec<usize> =
109             (0..n).filter(|&i| !nodes[i].pinned && depth[i] == row).collect();
110 
111         // Order within the row by where the node WANTS to be, so the ordering
112         // and the placement agree and the pass does not fight itself. A root's
113         // wish is its current column, which preserves the left-to-right
114         // arrangement the user already made among independent chains.
115         let wish = |i: usize, column: &Vec<i32>, placed: &Vec<bool>| -> i32 {
116             match parent[i] {
117                 Some(p) if placed[p] => column[p],
118                 _ => nodes[i].position.0.round() as i32,
119             }
120         };
121         in_row.sort_by_key(|&i| (wish(i, &column, &placed), nodes[i].name.clone()));
122 
123         for i in in_row {
124             let want = wish(i, &column, &placed);
125             // Nearest free column to the one it wants, searching outward so a
126             // collision nudges a node aside rather than pushing the whole row
127             // to the right. A chain whose parent's column is free stays
128             // perfectly vertical, which is what a chain should look like.
129             // Terminates because `taken` is finite: some column is always free.
130             let col = (0i32..)
131                 .flat_map(|step| {
132                     if step == 0 { vec![want] } else { vec![want + step, want - step] }
133                 })
134                 .find(|c| !taken.contains(&(*c, row as i32)))
135                 .expect("an unbounded column scan always finds a free cell");
136             taken.push((col, row as i32));
137             column[i] = col;
138             placed[i] = true;
139             let new_pos = (col as f32, row as f32);
140             if new_pos != nodes[i].position {
141                 out.push((i, new_pos));
142             }
143         }
144     }
145     out
146 }