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