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

commit2c137ebc97d887ffd73142c9835d6bc443235263
parent123e08abb2
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 16:09
feat: a node can be bypassed

Bypassed, a node stays in the graph and does nothing: what reads it gets
its Input, and a generator gives nothing. The b key on the network's
selection, the node's menu and MCP's toggle_bypass all set the one flag,
which is saved only when set. A bypassed node wears amber.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md       |  45 ++++++++++++
 src/api.rs      |   9 +++
 src/app.rs      |  57 ++++++++++++++++
 src/command.rs  |   4 ++
 src/geometry.rs |  49 +++++++++++++
 src/main.rs     | 208 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/page.rs     |   7 ++
 src/project.rs  |   1 +
 src/render.rs   |  33 ++++++++-
 src/shortcut.rs |   1 +
 src/window.rs   |  10 +++
 11 files changed, 421 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 98623ea..9d49028 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -829,6 +829,51 @@ stands BEFORE the name changes since a path is names; sibling wires whose
 value is the old name follow, as the load-time sanitizer rewrites them;
 and the active camera. A same-named node elsewhere is not this one.
 
+### Bypass
+
+A node can be BYPASSED (since 2026-09-29): it stays in the graph, wired as
+it was, and does nothing. `FsNode::bypassed`, and `geometry::is_bypassed` is
+the one reading of it.
+
+- **What reads a bypassed node gets what the node reads**: its `Input`,
+  untouched. A node with no input — a generator — gives nothing, which is
+  what a generator switched off should give. A bypassed subnet or simnet
+  passes its Input and its children are not run.
+- **It is decided ahead of the node's own parameters.** The check sits at
+  the top of `generate_single_node_geometry_with_errors`, before
+  `resolve_param_refs`, so an expression that would fail on the node is
+  not evaluated and not reported: bypassing is how a broken node is taken
+  out of a chain while it is fixed.
+- **In three places, because there are three dispatches**: that function,
+  the scene walk's `visit` (which hands nodes to their resolvers itself —
+  a shown, bypassed node draws what it passes, is still counted for the
+  nodes placed by index, and is not gone into), and `page::resolve_page`
+  for the 2D context.
+- **`input`, `output` and `camera` ignore it.** The first two are a
+  subnet's plumbing, and a bypassed `input` would be a chain that reads
+  nothing.
+- **The flag is written only when it is set** (`skip_serializing_if`), so
+  a file that never bypassed anything is byte for byte the file it was and
+  `sim_solve_key`, a hash of the simnet's JSON, restarts a simulation when
+  a node in its chain is bypassed and not otherwise.
+
+`State::set_bypassed(slots, bool)` is the one writer, and three things
+call it: the **`bypass_node`** command (`b`, the network's, acting on the
+selection, the whole of which follows the FIRST node's flag as `e` does
+for the geometry flag); the node's right-click menu (**Bypass** / **Stop
+Bypassing**); and MCP's `toggle_bypass`. The id is not `toggle_bypass`
+because the `toggle_*` family is the settings' switches, which
+`dialog_toggle_rows_cover_every_toggle_command` holds to a table this has
+no place in.
+
+A bypassed node wears amber (`render::BYPASS_TINT`): its roll tinted
+through the bevel's own tint channel, and a flat bar down its left side,
+drawn after the bodies with the geometry toggles. The bar is what still
+says so while the node is selected and its roll is the selection's colour.
+The node is found by `GraphController::node_at` at the body's centre, so
+the second network editor marks its own level's. Nothing in cce-ui
+changed.
+
 ### 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/api.rs b/src/api.rs
index 646fc57..740f400 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -90,6 +90,15 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
                 "required": ["path"],
             }),
         ),
+        tool(
+            "toggle_bypass",
+            "Bypass the node at the given slot, or stop bypassing it. A bypassed node stays in the graph and does nothing: what reads it gets the node's own Input.",
+            json!({
+                "type": "object",
+                "properties": { "slot": slot("Child index in the current network level") },
+                "required": ["slot"],
+            }),
+        ),
         tool(
             "toggle_geometry",
             "Toggle geometry visibility for the node at the given slot.",
diff --git a/src/app.rs b/src/app.rs
index 886e035..db7dce8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -149,6 +149,12 @@ pub struct FsNode {
     pub params: Vec<ParamDef>,
     #[serde(default = "default_node_geometry_visible")]
     pub geometry_visible: bool,
+    /// Bypassed: the node is in the graph and does nothing. What reads it
+    /// gets what it reads — its `Input`, untouched — and a node with no
+    /// input gives nothing. Written only when set, so a file that never
+    /// bypassed anything is the file it was.
+    #[serde(default, skip_serializing_if = "is_false")]
+    pub bypassed: bool,
     #[serde(default = "default_node_position")]
     pub position: (f32, f32),
     #[serde(default = "default_node_inputs")]
@@ -213,6 +219,7 @@ pub(crate) struct ParamPaneTarget {
 
 fn default_node_type() -> String { "node".to_string() }
 fn default_node_geometry_visible() -> bool { true }
+fn is_false(flag: &bool) -> bool { !*flag }
 fn default_node_position() -> (f32, f32) { (0.0, 0.0) }
 
 #[derive(Clone, Deserialize, Serialize, Default)]
@@ -363,6 +370,7 @@ pub enum NodeMenuAction {
     Enter,
     /// Flip the node's geometry visibility.
     ToggleGeometry,
+    ToggleBypass,
     /// Enter/exit the curve viewer state (curve nodes only).
     EditCurve,
     /// Remove the node.
@@ -508,6 +516,7 @@ pub enum McpAction {
     Load { path: String },
     Save { path: String },
     ToggleGeometry { slot: usize },
+    ToggleBypass { slot: usize },
     AddNode { template_name: String, name: Option<String>, x: f32, y: f32 },
     DeleteNode { slot: usize },
     RenameNode { slot: usize, new_name: String },
@@ -969,6 +978,7 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
                     fresh.name = c.name.clone();
                     fresh.position = c.position;
                     fresh.geometry_visible = c.geometry_visible;
+                    fresh.bypassed = c.bypassed;
                     for p in &c.params {
                         if let Some(fp) = fresh.params.iter_mut().find(|fp| fp.name == p.name) {
                             fp.set_text(p.text().to_string());
@@ -1153,6 +1163,7 @@ pub fn load_fs_tree() -> FsNode {
                     // draws), or viewing the subnet from outside draws the
                     // chain's intermediate stages on top of its result.
                     resolved_child.geometry_visible = child.geometry_visible;
+                    resolved_child.bypassed = child.bypassed;
                     // Merge parameters
                     for override_p in &child.params {
                         if let Some(base_p) = resolved_child.params.iter_mut().find(|p| p.name == override_p.name) {
@@ -1189,6 +1200,7 @@ pub fn load_fs_tree() -> FsNode {
         children,
         params: vec![],
         geometry_visible: true,
+        bypassed: false,
         position: (0.0, 0.0),
         inputs: 0,
         outputs: 0,
@@ -4386,6 +4398,7 @@ impl State {
     /// items are contextual: Enter (dive into the subnet) for enterable nodes,
     /// Show/Hide Geometry, and Delete.
     fn open_node_context_menu(&mut self, slot: usize) {
+        let bypassed = self.current_dir().children.get(slot).is_some_and(|n| n.bypassed);
         let (is_utility, geom_visible, enterable, curve_editing) = {
             let dir = self.current_dir();
             let Some(node) = dir.children.get(slot) else { return };
@@ -4420,6 +4433,8 @@ impl State {
         if !is_utility {
             options.push(if geom_visible { "Hide Geometry" } else { "Show Geometry" }.to_string());
             actions.push(NodeMenuAction::ToggleGeometry);
+            options.push(if bypassed { "Stop Bypassing" } else { "Bypass" }.to_string());
+            actions.push(NodeMenuAction::ToggleBypass);
         }
         if let Some(editing) = curve_editing {
             // "Handles", not "Points": a soft transform's are a centre and a
@@ -5409,6 +5424,9 @@ impl State {
                 let mut redraw = false;
                 let _ = self.apply_action(McpAction::ToggleGeometry { slot }, &mut redraw);
             }
+            NodeMenuAction::ToggleBypass => {
+                self.set_bypassed(&[slot], !self.current_dir().children.get(slot).is_some_and(|n| n.bypassed));
+            }
             NodeMenuAction::EditCurve => {
                 self.toggle_viewer_state(slot);
             }
@@ -5725,6 +5743,31 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         }
     }
 
+    /// Bypass the nodes at `slots` of the current level, or stop: the one
+    /// way the flag changes, whichever of the key, the node's menu and MCP
+    /// asked. Returns how many nodes it changed.
+    pub fn set_bypassed(&mut self, slots: &[usize], bypassed: bool) -> usize {
+        let mut changed = Vec::new();
+        for &slot in slots {
+            if let Some(node) = self.current_dir_mut().children.get_mut(slot) {
+                if node.bypassed != bypassed {
+                    node.bypassed = bypassed;
+                    changed.push(node.name.clone());
+                }
+            }
+        }
+        if changed.is_empty() {
+            return 0;
+        }
+        self.sync_nodes();
+        self.rebuild_scene_geometry();
+        self.sync_parameters_pane();
+        self.viewport_dirty = true;
+        let what = if bypassed { "Bypassed" } else { "No longer bypassing" };
+        self.update_status_text(&format!("{what} {}.", changed.join(", ")));
+        changed.len()
+    }
+
     /// One level's children as the graph widget's rows.
     fn graph_nodes_of(dir: &FsNode) -> Vec<GraphNode> {
         dir.children
@@ -6070,6 +6113,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 children: vec![],
                 params: vec![],
                 geometry_visible: true,
+                bypassed: false,
                 position: (0.0, 0.0),
                 inputs: 0,
                 outputs: 0,
@@ -7977,6 +8021,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             Action::Deselect => {
                 self.deselect_node();
             }
+            Action::ToggleBypass => {
+                // The network's, like the rest of its bare-letter family:
+                // `b` typed anywhere else is a letter. The whole selection
+                // follows the FIRST node's flag, as `e` has it follow the
+                // first node's geometry flag.
+                if self.focused_pane == LEFT_MENUBAR_IDX {
+                    let selected = self.selected_slots();
+                    if let Some(&first) = selected.first() {
+                        let bypassed = !self.current_dir().children.get(first).is_some_and(|n| n.bypassed);
+                        self.set_bypassed(&selected, bypassed);
+                    }
+                }
+            }
             Action::LayoutNodes => {
                 self.layout_current_level();
             }
diff --git a/src/command.rs b/src/command.rs
index 00a80c7..c46a969 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -168,6 +168,10 @@ pub const COMMANDS: &[Command] = &[
     // like `deselect` above, the row ships unbound rather than duplicating a
     // key the event loop already claims), and it is the first row of the
     // network's right-click menu, which dispatches through this id.
+    // `b`, as `e` is the geometry flag's: the selection's nodes stay in the
+    // graph and do nothing. Not `toggle_`: that family is the settings'
+    // switches, and this acts on what is selected.
+    Command { id: "bypass_node", label: "Bypass Node", context: Context::Network, run: Run::Key(Action::ToggleBypass), default_chord: Some("b") },
     Command { id: "add_node", label: "Add Node", context: Context::Network, run: Run::Menu("Add Node"), default_chord: None },
     Command { id: "zoom_in", label: "Zoom In", context: Context::Network, run: Run::Menu("Zoom In"), default_chord: None },
     Command { id: "zoom_out", label: "Zoom Out", context: Context::Network, run: Run::Menu("Zoom Out"), default_chord: None },
diff --git a/src/geometry.rs b/src/geometry.rs
index 7e1c2ae..2823cc6 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1110,6 +1110,20 @@ pub fn generate_single_node_geometry(root: &FsNode, target: &FsNode, visited: &m
     generate_single_node_geometry_with_errors(root, target, visited, &mut err, &mut sim)
 }
 
+/// Whether a node is bypassed: in the graph, and doing nothing.
+///
+/// What reads a bypassed node gets what the node reads — its `Input`,
+/// untouched, and nothing at all from a node that has none, which is what a
+/// generator switched off gives. It is decided HERE, ahead of every
+/// resolver and ahead of the node's own parameters, so an expression that
+/// fails on a bypassed node is not evaluated and not reported.
+///
+/// `input` and `output` are a subnet's plumbing and a camera is not
+/// geometry: the flag on any of them says nothing.
+pub fn is_bypassed(node: &FsNode) -> bool {
+    node.bypassed && !["input", "output", "camera"].iter().any(|ty| node.node_type.eq_ignore_ascii_case(ty))
+}
+
 pub fn generate_single_node_geometry_with_errors(
     root: &FsNode,
     target: &FsNode,
@@ -1130,6 +1144,12 @@ pub fn generate_single_node_geometry_with_errors(
     // Parameter references resolve here, once, for every resolver below:
     // a child of a composed subnet reads its parent's controls through
     // `ch("Name")` and the resolvers never know.
+    if is_bypassed(target) {
+        let res = param_node(root, target, "Input").and_then(|input| generate_single_node_geometry_with_errors(root, input, visited, ocl_error, sim));
+        visited.pop();
+        return res;
+    }
+
     let resolved = resolve_param_refs(root, target, sim.frame, ocl_error);
     let target = resolved.as_ref().unwrap_or(target);
 
@@ -4974,6 +4994,21 @@ pub fn network_sphere_vertices_with_errors(
         // The walk hands nodes to their resolvers directly, so it resolves
         // references itself — dived into a composed subnet, its children are
         // what is drawn, and their controls live on the subnet.
+        if is_bypassed(node) {
+            // Counted as it would have been, so that what is placed by its
+            // index stays where it was; drawn, if it is shown, as what it
+            // passes through; and not gone into — a bypassed subnet's
+            // children are part of what is switched off.
+            if is_geometry_node_type(&node.node_type) {
+                *count += 1;
+            }
+            if parent_visible && node.geometry_visible {
+                if let Some(geom) = generate_single_node_geometry_with_errors(root, node, &mut Vec::new(), ocl_error, sim) {
+                    out.merge(&geom);
+                }
+            }
+            return;
+        }
         let resolved = resolve_param_refs(root, node, sim.frame, ocl_error);
         let node = resolved.as_ref().unwrap_or(node);
         let is_visible = parent_visible && node.geometry_visible;
@@ -6063,6 +6098,7 @@ mod tests {
                 .map(|(name, default)| crate::app::ParamDef::new(name.to_string(), "text".to_string(), default.to_string()))
                 .collect(),
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 1,
@@ -6096,6 +6132,7 @@ mod tests {
                 crate::app::ParamDef::new("Shape".to_string(), "choice:None,Spiral,Line,Circle,Grid".to_string(), shape.to_string()),
             ],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
         let root = FsNode {
@@ -6107,6 +6144,7 @@ mod tests {
             children: vec![points_node("None")],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
         let geom = network_sphere_vertices(&root);
@@ -6150,6 +6188,7 @@ mod tests {
                 crate::app::ParamDef::new("Radius".to_string(), "slider".to_string(), "0.5".to_string())
             ],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
         let transform1 = FsNode {
@@ -6164,6 +6203,7 @@ mod tests {
                 crate::app::ParamDef::new("Translation".to_string(), "float3".to_string(), "1.00:2.00:3.00".to_string())
             ],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
         let root = FsNode {
@@ -6175,6 +6215,7 @@ mod tests {
             children: vec![sphere.clone(), transform1.clone()],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
 
@@ -6202,6 +6243,7 @@ mod tests {
                 crate::app::ParamDef::new("Translation".to_string(), "float3".to_string(), "-1.00:-1.00:-1.00".to_string())
             ],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
         let root_chained = FsNode {
@@ -6213,6 +6255,7 @@ mod tests {
             children: vec![sphere, transform1, transform2.clone()],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
         let mut visited = Vec::new();
@@ -6237,6 +6280,7 @@ mod tests {
                 crate::app::ParamDef::new("Translation".to_string(), "float3".to_string(), "1.00:1.00:1.00".to_string())
             ],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
         let root_loop = FsNode {
@@ -6248,6 +6292,7 @@ mod tests {
             children: vec![transform_loop.clone()],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
         let mut visited = Vec::new();
@@ -6268,6 +6313,7 @@ mod tests {
                 crate::app::ParamDef::new("Radius".to_string(), "slider".to_string(), "0.5".to_string())
             ],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
 
@@ -6284,6 +6330,7 @@ mod tests {
                 crate::app::ParamDef::new("Radius".to_string(), "slider".to_string(), "0.02".to_string())
             ],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
 
@@ -6296,6 +6343,7 @@ mod tests {
             children: vec![sphere, scatter.clone()],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         };
 
@@ -6614,6 +6662,7 @@ mod simnet_tests {
             children,
             params,
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
         }
     }
diff --git a/src/main.rs b/src/main.rs
index 37db8e7..8a955c8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -216,6 +216,7 @@ mod tests {
             children: vec![],
             params: vec![],
             geometry_visible: false,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 1,
             outputs: 1,
@@ -250,6 +251,7 @@ mod tests {
             children: vec![],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 1,
             outputs: 1,
@@ -261,6 +263,7 @@ mod tests {
             children: vec![child("a"), child("b"), child("c")],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -820,6 +823,7 @@ mod tests {
                 style("Plate Color", "rgba", "#11223344"),
             ],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 1,
             outputs: 1,
@@ -925,6 +929,7 @@ mod tests {
             children: vec![],
             params,
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 1,
             outputs: 1,
@@ -950,6 +955,7 @@ mod tests {
             ],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -998,6 +1004,7 @@ mod tests {
             children: vec![],
             params: vec![],
             geometry_visible: false,
+            bypassed: false,
             position: (2.0, 3.0),
             inputs: 1,
             outputs: 1,
@@ -1014,6 +1021,7 @@ mod tests {
                     children: vec![],
                     params: vec![],
                     geometry_visible: true,
+                    bypassed: false,
                     position: (0.0, 4.0),
                     inputs: 1,
                     outputs: 1,
@@ -1022,6 +1030,7 @@ mod tests {
             ],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -1060,6 +1069,7 @@ mod tests {
             children: vec![],
             params: vec![crate::app::ParamDef::new("Show Grid Guide".to_string(), "toggle".to_string(), "false".to_string())],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 4.0),
             inputs: 1,
             outputs: 1,
@@ -2697,6 +2707,7 @@ mod tests {
                 children: vec![inst],
                 params: vec![],
                 geometry_visible: true,
+                bypassed: false,
                 position: (0.0, 0.0),
                 inputs: 0,
                 outputs: 0,
@@ -2812,6 +2823,7 @@ mod tests {
                 children: vec![inst],
                 params: vec![],
                 geometry_visible: true,
+                bypassed: false,
                 position: (0.0, 0.0),
                 inputs: 0,
                 outputs: 0,
@@ -2876,6 +2888,7 @@ mod tests {
             children: vec![sphere_instance],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -2921,6 +2934,7 @@ mod tests {
             children: vec![instance],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -3363,6 +3377,7 @@ mod tests {
             children: vec![sphere_instance, group_instance],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -3477,6 +3492,7 @@ mod tests {
             children,
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -3744,6 +3760,7 @@ mod tests {
             children: vec![],
             params: vec![],
             geometry_visible: false,
+            bypassed: false,
             position: (0.0, 4.0),
             inputs: 0,
             outputs: 0,
@@ -3762,6 +3779,7 @@ mod tests {
             children: vec![inner],
             params: vec![],
             geometry_visible: false,
+            bypassed: false,
             position: (2.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -3777,6 +3795,7 @@ mod tests {
             children: vec![sphere, camera, session, sub],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -3887,6 +3906,7 @@ mod tests {
                 children: vec![inst],
                 params: vec![],
                 geometry_visible: true,
+                bypassed: false,
                 position: (0.0, 0.0),
                 inputs: 0,
                 outputs: 0,
@@ -4384,6 +4404,7 @@ mod tests {
             children: vec![],
             params: vec![crate::app::ParamDef::new("Code", "code", "OLD KERNEL")],
             geometry_visible: true,
+            bypassed: false,
             position: (4.0, 2.0),
             inputs: 1,
             outputs: 1,
@@ -4399,6 +4420,7 @@ mod tests {
             children: vec![opencl1, output1],
             params: vec![crate::app::ParamDef::new("Radius", "slider", "0.70")],
             geometry_visible: true,
+            bypassed: false,
             position: (3.0, 1.0),
             inputs: 0,
             outputs: 1,
@@ -4419,6 +4441,7 @@ mod tests {
             children: vec![],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -4431,6 +4454,7 @@ mod tests {
             children: vec![old_sphere, old_group, lookalike],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -4504,6 +4528,7 @@ mod tests {
                 children: vec![inst],
                 params: vec![],
                 geometry_visible: true,
+                bypassed: false,
                 position: (0.0, 0.0),
                 inputs: 0,
                 outputs: 0,
@@ -4587,6 +4612,7 @@ mod tests {
                 children: vec![inst],
                 params: vec![],
                 geometry_visible: true,
+                bypassed: false,
                 position: (0.0, 0.0),
                 inputs: 0,
                 outputs: 0,
@@ -4686,6 +4712,7 @@ mod tests {
             ],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -4751,6 +4778,7 @@ mod tests {
                 children: vec![inst],
                 params: vec![],
                 geometry_visible: true,
+                bypassed: false,
                 position: (0.0, 0.0),
                 inputs: 0,
                 outputs: 0,
@@ -4808,6 +4836,7 @@ mod tests {
                     children: vec![],
                     params: vec![],
                     geometry_visible: true,
+                    bypassed: false,
                     position: (5.0, 6.0),
                     inputs: 0,
                     outputs: 1,
@@ -4815,6 +4844,7 @@ mod tests {
             ],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -5846,6 +5876,7 @@ mod tests {
                     .map(|(n, v)| crate::app::ParamDef::new(n.to_string(), "text".to_string(), v.to_string()))
                     .collect(),
                 geometry_visible: true,
+                bypassed: false,
                 position: (0.0, 0.0),
                 inputs: 1,
                 outputs: 1,
@@ -7392,6 +7423,7 @@ mod tests {
                 })
                 .collect(),
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 1,
             outputs: 1,
@@ -8315,6 +8347,7 @@ mod tests {
                     .map(|(n, v)| crate::app::ParamDef::new(n.to_string(), "text".to_string(), v.to_string()))
                     .collect(),
                 geometry_visible: true,
+                bypassed: false,
                 position: (0.0, 0.0),
                 inputs: 1,
                 outputs: 1,
@@ -8837,6 +8870,7 @@ mod tests {
             children,
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
@@ -9747,6 +9781,7 @@ mod tests {
                 .map(|(name, default)| crate::app::ParamDef::new(name, "text", default))
                 .collect(),
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 1,
             outputs: 1,
@@ -9781,6 +9816,7 @@ mod tests {
                 .map(|(name, default)| crate::app::ParamDef::new(*name, "text", *default))
                 .collect(),
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 1,
             outputs: 1,
@@ -10510,6 +10546,178 @@ mod tests {
         assert_eq!(still.positions(), moved.positions());
     }
 
+    /// A bypassed node is in the graph and does nothing: what reads it gets
+    /// what it reads. A generator, which reads nothing, gives nothing; a
+    /// subnet passes its Input and its children are not run; and a node
+    /// whose own parameters would fail is not evaluated to find that out.
+    #[test]
+    fn a_bypassed_node_passes_its_input_through() {
+        let pull = |bypassed: bool| {
+            let mut n = ref_node(
+                "pull",
+                "pull1",
+                "attribute",
+                vec![("Input", "node", "sphere1"), ("Operation", "text", "Modify"), ("Attribute Name", "text", "Pos"), ("Value", "text", "1.00:0.00:0.00"), ("Combine", "text", "Add")],
+                vec![],
+            );
+            n.bypassed = bypassed;
+            n
+        };
+        let sphere = || ref_node("s", "sphere1", "sphere", vec![("Radius", "slider", "0.5"), ("Center X", "slider", "0"), ("Center Y", "slider", "0"), ("Center Z", "slider", "0")], vec![]);
+        let root = |nodes: Vec<FsNode>| ref_node("root", "root", "node", vec![], nodes);
+        let min_x = |d: &Detail| d.positions().iter().map(|p| p[0]).fold(f32::INFINITY, f32::min);
+
+        let (plain, _) = eval_node(&root(vec![sphere()]), "sphere1");
+        let (moved, err) = eval_node(&root(vec![sphere(), pull(false)]), "pull1");
+        assert!(err.is_none() && (min_x(&moved) - min_x(&plain) - 1.0).abs() < 1e-5);
+        let (passed, err) = eval_node(&root(vec![sphere(), pull(true)]), "pull1");
+        assert!(err.is_none());
+        assert_eq!(passed.positions(), plain.positions());
+
+        // What reads a bypassed node reads through it.
+        let mut second = pull(false);
+        (second.id, second.name) = ("pull2".into(), "pull2".into());
+        second.params[0].set_text("pull1");
+        let (after, _) = eval_node(&root(vec![sphere(), pull(true), second]), "pull2");
+        assert!((min_x(&after) - min_x(&plain) - 1.0).abs() < 1e-5, "one pull, not two");
+
+        // A generator reads nothing and gives nothing.
+        let mut off = sphere();
+        off.bypassed = true;
+        let tree = root(vec![off]);
+        let mut cache = crate::geometry::SimCache::default();
+        let mut sim = crate::geometry::EvalSim::new(0, 0, &mut cache);
+        let mut err = None;
+        assert!(crate::geometry::generate_single_node_geometry_with_errors(&tree, &tree.children[0], &mut Vec::new(), &mut err, &mut sim).is_none());
+        assert!(err.is_none());
+
+        // Its own parameters are not evaluated: an expression that names
+        // nothing is an error on the node, and not on a bypassed one.
+        let broken = |bypassed: bool| {
+            let mut n = pull(bypassed);
+            n.params.push(crate::app::ParamDef::new("Strength", "slider", "ch(\"../nothing/Here\")").as_expr());
+            root(vec![sphere(), n])
+        };
+        assert!(eval_node(&broken(false), "pull1").1.is_some());
+        let (passed, err) = eval_node(&broken(true), "pull1");
+        assert!(err.is_none(), "{err:?}");
+        assert_eq!(passed.positions(), plain.positions());
+
+        // A subnet passes its Input, and what is inside it is not run.
+        let mut subnet = ref_node(
+            "sub",
+            "sub1",
+            "node",
+            vec![("Input", "node", "sphere1")],
+            vec![
+                ref_node("in", "input1", "input", vec![], vec![]),
+                { let mut p = pull(false); p.params[0].set_text("input1"); p },
+                ref_node("out", "output1", "output", vec![("Input", "node", "pull1")], vec![]),
+            ],
+        );
+        let (through, _) = eval_node(&root(vec![sphere(), subnet.clone()]), "sub1");
+        assert!((min_x(&through) - min_x(&plain) - 1.0).abs() < 1e-5);
+        subnet.bypassed = true;
+        let (passed, _) = eval_node(&root(vec![sphere(), subnet.clone()]), "sub1");
+        assert_eq!(passed.positions(), plain.positions());
+
+        // The flag says nothing on a subnet's plumbing.
+        subnet.bypassed = false;
+        subnet.children[0].bypassed = true;
+        subnet.children[2].bypassed = true;
+        let (through, _) = eval_node(&root(vec![sphere(), subnet]), "sub1");
+        assert!((min_x(&through) - min_x(&plain) - 1.0).abs() < 1e-5);
+
+        // The scene draws a shown, bypassed node as what it passes, and a
+        // bypassed generator as nothing.
+        let scene = |nodes: Vec<FsNode>| {
+            let tree = root(nodes);
+            let mut cache = crate::geometry::SimCache::default();
+            let mut sim = crate::geometry::EvalSim::new(0, 0, &mut cache);
+            crate::geometry::network_sphere_vertices_with_errors(&tree, &tree, &mut None, &mut sim)
+        };
+        let hidden = |mut n: FsNode| { n.geometry_visible = false; n };
+        assert_eq!(scene(vec![hidden(sphere()), pull(true)]).positions(), plain.positions());
+        assert!((min_x(&scene(vec![hidden(sphere()), pull(false)])) - min_x(&plain) - 1.0).abs() < 1e-5);
+        let mut off = sphere();
+        off.bypassed = true;
+        assert_eq!(scene(vec![off]).num_points(), 0);
+    }
+
+    /// The flag is written only when it is set, so a file that never
+    /// bypassed anything is byte for byte the file it was — and a simnet's
+    /// solve, keyed by its JSON, restarts when a node in its chain is
+    /// bypassed and not otherwise.
+    #[test]
+    fn the_bypass_flag_is_saved_only_when_it_is_set() {
+        let mut node = ref_node("a", "a1", "sphere", vec![("Radius", "slider", "0.5")], vec![]);
+        let plain = serde_json::to_string(&node).unwrap();
+        assert!(!plain.contains("bypassed"), "{plain}");
+        node.bypassed = true;
+        let set = serde_json::to_string(&node).unwrap();
+        assert!(set.contains("\"bypassed\":true"), "{set}");
+        let back: FsNode = serde_json::from_str(&set).unwrap();
+        assert!(back.bypassed);
+        let old: FsNode = serde_json::from_str(&plain).unwrap();
+        assert!(!old.bypassed);
+    }
+
+    /// Bypass from each place that asks for it — the `b` command on the
+    /// network's selection, the node's menu, MCP — and the scene, the
+    /// status line and the node's look follow.
+    #[test]
+    fn bypass_is_one_flag_however_it_is_asked_for() {
+        use crate::app::McpAction;
+        let mut state = State::new(false);
+        let mut redraw = false;
+        while !state.current_path.is_empty() {
+            state.apply_action(McpAction::Up, &mut redraw).unwrap();
+        }
+        state.current_dir_mut().children.clear();
+        state.sync_nodes();
+        state.apply_action(McpAction::AddNode { template_name: "Sphere".into(), name: Some("ball".into()), x: 3.0, y: 3.0 }, &mut redraw).unwrap();
+        state.apply_action(McpAction::AddNode { template_name: "Attribute".into(), name: Some("pull1".into()), x: 3.0, y: 4.0 }, &mut redraw).unwrap();
+        let slot = |state: &State, name: &str| state.current_dir().children.iter().position(|c| c.name == name).unwrap();
+        let (ball, pull) = (slot(&state, "ball"), slot(&state, "pull1"));
+        for (name, value) in [("Input", "ball"), ("Operation", "Modify"), ("Attribute Name", "Pos"), ("Value", "1.00:0.00:0.00"), ("Combine", "Add")] {
+            state.apply_action(McpAction::SetParam { slot: pull, name: name.into(), value: value.into() }, &mut redraw).unwrap();
+        }
+        state.apply_action(McpAction::ToggleGeometry { slot: pull }, &mut redraw).unwrap();
+        let min_x = |state: &State| state.rt_sphere_verts.iter().map(|v| v.position[0]).fold(f32::INFINITY, f32::min);
+        let shown = min_x(&state);
+
+        // MCP.
+        let said = state.apply_action(McpAction::ToggleBypass { slot: pull }, &mut redraw).unwrap();
+        assert_eq!(said, "Bypassed: true");
+        assert!(state.current_dir().children[pull].bypassed);
+        assert!((min_x(&state) - (shown - 1.0)).abs() < 1e-4, "the scene is the sphere where it was: {} from {shown}", min_x(&state));
+        assert_eq!(state.last_status_text, "Bypassed pull1.");
+        assert!(state.apply_action(McpAction::ToggleBypass { slot: 99 }, &mut redraw).is_err());
+
+        // The command, on the selection, and only with the network focused.
+        state.apply_action(McpAction::Select { slot: pull }, &mut redraw).unwrap();
+        state.focused_pane = crate::slots::PARAM_IDX;
+        state.run_command("bypass_node");
+        assert!(state.current_dir().children[pull].bypassed, "a `b` typed elsewhere is a letter");
+        state.focused_pane = LEFT_MENUBAR_IDX;
+        state.run_command("bypass_node");
+        assert!(!state.current_dir().children[pull].bypassed);
+        assert!((min_x(&state) - shown).abs() < 1e-4);
+        assert_eq!(state.last_status_text, "No longer bypassing pull1.");
+        assert_eq!(crate::command::by_id("bypass_node").unwrap().default_chord, Some("b"));
+
+        // The node's menu, which says which way it will go.
+        assert_eq!(state.set_bypassed(&[ball, pull], true), 2);
+        assert_eq!(state.set_bypassed(&[ball, pull], true), 0, "already");
+        assert!(state.rt_sphere_verts.is_empty(), "a bypassed sphere behind a bypassed pull is nothing");
+        assert_eq!(state.set_bypassed(&[ball], false), 1);
+
+        // It is saved with the project and comes back with it.
+        let saved = serde_json::to_string(&state.fs_root).unwrap();
+        let back: FsNode = serde_json::from_str(&saved).unwrap();
+        assert!(back.children[pull].bypassed && !back.children[ball].bypassed);
+    }
+
     /// The Step Limit holds a point's move since the step began to that
     /// many thicknesses, in the direction it was going. It is the Surface
     /// method's, it needs to know where the step began, and only what may
diff --git a/src/page.rs b/src/page.rs
index 17f1737..2cf7329 100644
--- a/src/page.rs
+++ b/src/page.rs
@@ -431,6 +431,13 @@ pub fn resolve_page(root: &FsNode, target: &FsNode, visited: &mut Vec<String>) -
     }
     visited.push(target.id.clone());
 
+    // Bypassed, a page node passes the sheet it was handed, and a sheet that
+    // is bypassed is no sheet.
+    if crate::geometry::is_bypassed(target) {
+        let input = crate::geometry::param_node(root, target, "Input")?;
+        return resolve_page(root, input, visited);
+    }
+
     let kind = target.node_type.to_ascii_lowercase();
     if kind == "page" {
         let preset = node_param_str(target, "Preset", "Letter");
diff --git a/src/project.rs b/src/project.rs
index 34f0a52..ad96dea 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -639,6 +639,7 @@ impl State {
             children: vec![],
             params: vec![],
             geometry_visible: true,
+            bypassed: false,
             position: (0.0, 0.0),
             inputs: 0,
             outputs: 0,
diff --git a/src/render.rs b/src/render.rs
index 98f610a..968945f 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -31,6 +31,10 @@ fn merge_bounds(a: Option<[f32; 4]>, b: Option<[f32; 4]>) -> Option<[f32; 4]> {
     }
 }
 
+/// What a bypassed node wears in the network: Houdini's bypass flag is
+/// this colour, and nothing else in the pane is.
+const BYPASS_TINT: [f32; 3] = [1.0, 0.74, 0.18];
+
 impl State {
     /// Per-corner plate radii for a pane rect: a corner that sits ON a window
     /// corner is this pane's share of the window silhouette — the compositor
@@ -445,7 +449,14 @@ impl State {
                 let g: &dyn cce_ui::widget::GraphController =
                     if second { &*self.slots.content2 } else { self.graph() };
                 let cell_r = g.cell_corner_radius();
-                let mut bodies: Vec<(f32, f32, f32, f32, bool)> = Vec::new();
+                // Which of this pane's nodes are bypassed, by slot: the
+                // widget knows a node's rect and its slot, the tree knows
+                // the flag.
+                let bypassed: Vec<bool> = {
+                    let level = if second { self.dir_at(&self.current_path2.clone()) } else { self.current_dir() };
+                    level.children.iter().map(crate::geometry::is_bypassed).collect()
+                };
+                let mut bodies: Vec<(f32, f32, f32, f32, bool, bool)> = Vec::new();
                 let mut overlays: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
                 let mut seen_node = false;
                 // The grid lines (flat, gap colour at the network opacity)
@@ -469,7 +480,8 @@ impl State {
                                 let (col, row) = self.cell_at(qx + qw * 0.5, qy + qh * 0.5);
                                 self.grid_cursor_covers(col, row)
                             };
-                        bodies.push((qx, qy, qw, qh, in_region || same_rgb(qc, sel) || same_rgb(qc, drag)));
+                        let off = g.node_at(qx + qw * 0.5, qy + qh * 0.5).is_some_and(|slot| bypassed.get(slot).copied().unwrap_or(false));
+                        bodies.push((qx, qy, qw, qh, in_region || same_rgb(qc, sel) || same_rgb(qc, drag), off));
                     } else if seen_node {
                         overlays.push((qx, qy, qw, qh, qc));
                     } else if let Some(corners) = cell {
@@ -495,16 +507,31 @@ impl State {
                         );
                     }
                 }
-                for (qx, qy, qw, qh, highlighted) in bodies {
+                // A bypassed node wears amber: its roll tinted, through the
+                // bevel's own tint channel, and a bar down its left side,
+                // flat and on top with the geometry toggles. The bar is
+                // what still says so while the node is selected and its
+                // roll is the selection's colour.
+                let mut bars: Vec<cce_ui::scene::layout::Rect> = Vec::new();
+                for (qx, qy, qw, qh, highlighted, off) in bodies {
                     if highlighted {
                         pc.bevel_tinted(rect(qx, qy, qw, qh), radii, &node_mat, node_bevel, hl_tint);
+                    } else if off {
+                        pc.bevel_tinted(rect(qx, qy, qw, qh), radii, &node_mat, node_bevel, BYPASS_TINT);
                     } else {
                         pc.bevel(rect(qx, qy, qw, qh), radii, &node_mat, node_bevel);
                     }
+                    if off {
+                        let (inset, wide) = (qh * 0.22, (qw * 0.05).max(2.0));
+                        bars.push(rect(qx + inset, qy + inset, wide, qh - inset * 2.0));
+                    }
                 }
                 for (qx, qy, qw, qh, qc) in overlays {
                     pc.quad(rect(qx, qy, qw, qh), qc);
                 }
+                for bar in bars {
+                    pc.quad(bar, [BYPASS_TINT[0], BYPASS_TINT[1], BYPASS_TINT[2], 0.9]);
+                }
             });
 
             for (cx, cy, cr, cc) in w.extra_circles() {
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 32cf004..26d0ca2 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -3,6 +3,7 @@ use crate::ModifiersState;
 
 #[derive(Debug, Clone, Copy, PartialEq)]
 pub enum Action {
+    ToggleBypass,
     ToggleGrid,
     ToggleSquareViewport,
     ToggleConfigure,
diff --git a/src/window.rs b/src/window.rs
index a31e821..1280583 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -791,6 +791,16 @@ impl State {
                     Ok("Project saved".to_string())
                 }
             }
+            McpAction::ToggleBypass { slot } => {
+                match state.current_dir().children.get(slot).map(|n| !n.bypassed) {
+                    Some(bypassed) => {
+                        state.set_bypassed(&[slot], bypassed);
+                        needs_redraw = true;
+                        Ok(format!("Bypassed: {}", bypassed))
+                    }
+                    None => Err("Slot out of bounds".to_string()),
+                }
+            }
             McpAction::ToggleGeometry { slot } => {
                 let active_nodes = state.current_dir().children.len();
                 if slot < active_nodes {