graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: native variable-point Curve node with a viewer state for point editing
The Curve node becomes a native type (like line/scatter): a Catmull-Rom
strip through a variable-length "Points" param (x y z; …), sampled
"Segments" times per span, evaluated in Rust — no kernel, so the
template-merge rule that refreshes subnet Code can never revert a
custom point list. Registered in both eval walks and
is_geometry_node_type; points are absolute world coordinates.
The viewer state (src/curve_tool.rs, "Edit Points" in the node context
menu) projects the control points through the cached scene mvp as
viewport handles: left-press grabs and drags a point on the camera-
facing plane at its own depth, left-press on empty space appends a
point at the curve's depth and drags it, right-press on a handle (or
Delete/Backspace on the selection) removes it, Escape exits. Edits
write the param through the SetParam resync sequence, so the scene,
params pane, and spreadsheet follow live. The tool binds the node by
id and drops out lazily if the node disappears.
AddNode now regenerates instance ids like paste does — a verbatim
template clone shared the template's ids across every instance, which
breaks anything keyed by id (eval cycle guard, sim caches, the tool's
binding).
Verified headless (parse/sampling/eval unit tests, a full tool state-
machine test against an identity mvp) and interactively in a --scale 2
shadow session: menu entry, handle rendering, drag/add/delete via
injected pointer, Escape exit, params updating over MCP.
Co-Authored-By: Claude Fable 5 <[email protected]>
nodes/curve.json | 118 ++-------------------
src/app.rs | 92 ++++++++++++++--
src/curve_tool.rs | 311 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/geometry.rs | 89 ++++++++++++++++
src/kernel_cpu.rs | 22 ----
src/main.rs | 216 ++++++++++++++++++++++++++-----------
src/render.rs | 37 +++++++
src/window.rs | 3 +
8 files changed, 688 insertions(+), 200 deletions(-)
diff --git a/nodes/curve.json b/nodes/curve.json
index 60a469e..18ca6d0 100644
--- a/nodes/curve.json
+++ b/nodes/curve.json
@@ -1,111 +1,11 @@
{
- "name": "Curve",
- "type": "node",
- "inputs": 0,
- "outputs": 1,
- "params": [
- {
- "name": "Point 1 X",
- "default": "-0.75",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 1 Y",
- "default": "0.05",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 1 Z",
- "default": "0.0",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 2 X",
- "default": "-0.25",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 2 Y",
- "default": "1.05",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 2 Z",
- "default": "0.0",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 3 X",
- "default": "0.25",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 3 Y",
- "default": "0.05",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 3 Z",
- "default": "0.0",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 4 X",
- "default": "0.75",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 4 Y",
- "default": "1.05",
- "type": "slider:-2:2"
- },
- {
- "name": "Point 4 Z",
- "default": "0.0",
- "type": "slider:-2:2"
- },
- {
- "name": "Segments",
- "default": "24",
- "type": "spinbox",
- "min": 1,
- "max": 256,
- "step": 1
- },
- {
- "name": "Thickness",
- "default": "0.02",
- "type": "slider"
- }
- ],
- "children": [
- {
- "name": "opencl1",
- "type": "opencl",
- "params": [
- {
- "name": "Code",
- "default": "void add_seg(float x0, float y0, float z0, float x1, float y1, float z1, float thickness, __global float* out_pos, __global float* out_col, int* count, int max_vertices) {\n float dx = x1 - x0;\n float dy = y1 - y0;\n float dz = z1 - z0;\n float len = sqrt(dx*dx + dy*dy + dz*dz);\n if (len < 0.00001f) { return; }\n dx /= len;\n dy /= len;\n dz /= len;\n float upx = 1.0f;\n float upy = 0.0f;\n float upz = 0.0f;\n if (fabs(dx) > 0.9f) {\n upx = 0.0f;\n upy = 1.0f;\n }\n float ux = dy * upz - dz * upy;\n float uy = dz * upx - dx * upz;\n float uz = dx * upy - dy * upx;\n float ulen = sqrt(ux*ux + uy*uy + uz*uz);\n if (ulen < 0.00001f) { return; }\n ux /= ulen;\n uy /= ulen;\n uz /= ulen;\n float vx = dy * uz - dz * uy;\n float vy = dz * ux - dx * uz;\n float vz = dx * uy - dy * ux;\n float h = thickness * 0.5f;\n float su[4] = {-1.0f, 1.0f, 1.0f, -1.0f};\n float sv[4] = {-1.0f, -1.0f, 1.0f, 1.0f};\n float cx[8];\n float cy[8];\n float cz[8];\n for (int i = 0; i < 4; i++) {\n float ox = h * (su[i] * ux + sv[i] * vx);\n float oy = h * (su[i] * uy + sv[i] * vy);\n float oz = h * (su[i] * uz + sv[i] * vz);\n cx[i] = x0 + ox;\n cy[i] = y0 + oy;\n cz[i] = z0 + oz;\n cx[i + 4] = x1 + ox;\n cy[i + 4] = y1 + oy;\n cz[i + 4] = z1 + oz;\n }\n int tri[36] = {\n 0, 2, 1, 0, 3, 2,\n 4, 5, 6, 4, 6, 7,\n 0, 1, 5, 0, 5, 4,\n 1, 2, 6, 1, 6, 5,\n 2, 3, 7, 2, 7, 6,\n 3, 0, 4, 3, 4, 7\n };\n float r = 0.5f + dx * 0.5f;\n float g = 0.5f + dy * 0.5f;\n float b = 0.5f + dz * 0.5f;\n int start = *count;\n for (int i = 0; i < 36; i++) {\n int idx = start + i;\n if (idx < max_vertices) {\n int c = tri[i];\n out_pos[idx * 3 + 0] = cx[c];\n out_pos[idx * 3 + 1] = cy[c];\n out_pos[idx * 3 + 2] = cz[c];\n out_col[idx * 3 + 0] = r;\n out_col[idx * 3 + 1] = g;\n out_col[idx * 3 + 2] = b;\n }\n }\n *count = start + 36;\n}\n\n__kernel void process(__global const float* in_pos, __global const float* in_col, int in_count, __global float* out_pos, __global float* out_col, __global int* out_count, int max_vertices) {\n int id = get_global_id(0);\n if (id == 0) {\n float p0x = chf(\"Point 1 X\", -0.75f);\n float p0y = chf(\"Point 1 Y\", 0.05f);\n float p0z = chf(\"Point 1 Z\", 0.0f);\n float p1x = chf(\"Point 2 X\", -0.25f);\n float p1y = chf(\"Point 2 Y\", 1.05f);\n float p1z = chf(\"Point 2 Z\", 0.0f);\n float p2x = chf(\"Point 3 X\", 0.25f);\n float p2y = chf(\"Point 3 Y\", 0.05f);\n float p2z = chf(\"Point 3 Z\", 0.0f);\n float p3x = chf(\"Point 4 X\", 0.75f);\n float p3y = chf(\"Point 4 Y\", 1.05f);\n float p3z = chf(\"Point 4 Z\", 0.0f);\n int segments = chi(\"Segments\", 24);\n if (segments < 1) { segments = 1; }\n if (segments > 256) { segments = 256; }\n float thickness = chf(\"Thickness\", 0.02f);\n int count = 0;\n float prev_x = p0x;\n float prev_y = p0y;\n float prev_z = p0z;\n for (int i = 0; i < segments; i++) {\n float t = (float)(i + 1) / (float)segments;\n float s = 1.0f - t;\n float b0 = s * s * s;\n float b1 = 3.0f * s * s * t;\n float b2 = 3.0f * s * t * t;\n float b3 = t * t * t;\n float x = b0 * p0x + b1 * p1x + b2 * p2x + b3 * p3x;\n float y = b0 * p0y + b1 * p1y + b2 * p2y + b3 * p3y;\n float z = b0 * p0z + b1 * p1z + b2 * p2z + b3 * p3z;\n add_seg(prev_x, prev_y, prev_z, x, y, z, thickness, out_pos, out_col, &count, max_vertices);\n prev_x = x;\n prev_y = y;\n prev_z = z;\n }\n *out_count = count;\n }\n}"
- }
- ],
- "position": [
- 4.0,
- 2.0
- ]
- },
- {
- "name": "output1",
- "type": "output",
- "params": [
- {
- "name": "Input",
- "default": "opencl1"
- }
- ],
- "position": [
- 4.0,
- 3.0
- ]
- }
- ]
+ "name": "Curve",
+ "type": "curve",
+ "inputs": 0,
+ "outputs": 1,
+ "params": [
+ { "name": "Points", "default": "-0.75 0.05 0; -0.25 1.05 0; 0.25 0.05 0; 0.75 1.05 0", "type": "text" },
+ { "name": "Segments", "default": "8", "type": "spinbox", "min": 1, "max": 64, "step": 1 },
+ { "name": "Thickness", "default": "0.02", "type": "slider" }
+ ]
}
diff --git a/src/app.rs b/src/app.rs
index 2738a42..4e2306c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -150,6 +150,17 @@ impl FsNode {
}
}
+/// Fresh ids for a node and its whole subtree — required whenever an
+/// existing tree is cloned into the graph (paste, template instantiation),
+/// because everything keyed by id (the eval cycle guard, sim caches, the
+/// curve viewer state's node binding) assumes ids are unique.
+pub(crate) fn regenerate_node_ids(n: &mut FsNode) {
+ n.id = generate_node_id();
+ for child in &mut n.children {
+ regenerate_node_ids(child);
+ }
+}
+
fn default_node_type() -> String { "node".to_string() }
fn default_node_geometry_visible() -> bool { true }
fn default_node_position() -> (f32, f32) { (0.0, 0.0) }
@@ -217,6 +228,8 @@ pub enum NodeMenuAction {
Enter,
/// Flip the node's geometry visibility (utility nodes excluded).
ToggleGeometry,
+ /// Enter/exit the curve viewer state (curve nodes only).
+ EditCurve,
/// Remove the node.
Delete,
}
@@ -1192,6 +1205,8 @@ pub struct State {
/// LOGICAL px, cached at staging so the 2D pass can project 3D overlays.
pub last_scene_mvp: Option<Mat4>,
pub last_scene_view_rect: (f32, f32, f32, f32),
+ /// The active curve viewer state (viewport point editing), if any.
+ pub curve_tool: Option<crate::curve_tool::CurveTool>,
pub last_viewport_rt_mode: bool,
/// Sphere-geometry cache for the path tracer (a copy of the last
/// `rebuild_scene_geometry` output, so entering RT mode never re-runs
@@ -2687,14 +2702,25 @@ impl State {
/// items are contextual: Enter (dive into the subnet) for enterable nodes,
/// Show/Hide Geometry for non-utility nodes, and Delete always.
fn open_node_context_menu(&mut self, slot: usize) {
- let (is_utility, geom_visible, enterable) = {
+ let (is_utility, geom_visible, enterable, curve_editing) = {
let dir = self.current_dir();
let Some(node) = dir.children.get(slot) else { return };
let enterable = node.is_enterable();
+ // None: not a curve; Some(bool): a curve, editing or not.
+ let curve_editing = node
+ .node_type
+ .eq_ignore_ascii_case("curve")
+ .then(|| {
+ self.curve_tool
+ .as_ref()
+ .map(|t| t.node_id == node.id)
+ .unwrap_or(false)
+ });
(
matches!(node.node_type.as_str(), "utility" | "session" | "meta"),
node.geometry_visible,
enterable,
+ curve_editing,
)
};
let deletable = {
@@ -2714,6 +2740,10 @@ impl State {
options.push(if geom_visible { "Hide Geometry" } else { "Show Geometry" }.to_string());
actions.push(NodeMenuAction::ToggleGeometry);
}
+ if let Some(editing) = curve_editing {
+ options.push(if editing { "Stop Editing Points" } else { "Edit Points" }.to_string());
+ actions.push(NodeMenuAction::EditCurve);
+ }
if deletable {
options.push("Delete".to_string());
actions.push(NodeMenuAction::Delete);
@@ -2927,6 +2957,9 @@ impl State {
let mut redraw = false;
let _ = self.apply_action(McpAction::ToggleGeometry { slot }, &mut redraw);
}
+ NodeMenuAction::EditCurve => {
+ self.toggle_curve_tool(slot);
+ }
NodeMenuAction::Delete => {
self.delete_node(slot);
}
@@ -3753,6 +3786,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
pick_cache: None,
last_scene_mvp: None,
last_scene_view_rect: (0.0, 0.0, 0.0, 0.0),
+ curve_tool: None,
last_viewport_rt_mode: false,
rt_sphere_verts: Vec::new(),
rt_geometry_version: 0,
@@ -5034,6 +5068,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
changed = true;
}
+ // An in-flight curve-tool grab eats motion ahead of every
+ // other drag: the grabbed control point tracks the cursor.
+ if self.curve_tool_drag_motion() {
+ return true;
+ }
+
// An armed corner-dot press becomes a layout drag once it
// moves; stubbed (collapsed/detached) panes stay click-only.
if let Some((idx, px, py)) = self.corner_press {
@@ -5460,6 +5500,26 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
}
}
+ // The curve viewer state takes the viewport press
+ // ahead of the context menu and the click cascade:
+ // left grabs or adds a control point, right on a
+ // handle deletes it (right elsewhere still opens the
+ // viewport menu below).
+ if self.curve_tool.is_some()
+ && self.cursor_in_viewport()
+ && !in_circle_network_pane
+ {
+ if *button == MouseButton::Left {
+ if self.curve_tool_press() {
+ return true;
+ }
+ } else if *button == MouseButton::Right
+ && self.curve_tool_delete_at_cursor()
+ {
+ return true;
+ }
+ }
+
if *button == MouseButton::Right {
self.close_node_menu();
self.close_viewport_menu();
@@ -5684,6 +5744,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
self.sync_pane_focus();
}
ElementState::Released => {
+ if self.curve_tool_release() {
+ changed = true;
+ }
if let Some(drag) = self.app_drag.take() {
// App-mode drag teardown. The DragEnd send is kept from the old
// shared teardown for faithfulness — the pane widget never began
@@ -6011,9 +6074,28 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
}
return true;
}
+ // The curve viewer state exits on Escape, ahead of
+ // connection-cancel — leaving point-edit mode is the
+ // more immediate "get me out" while it is active.
+ if self.curve_tool.is_some() {
+ self.curve_tool = None;
+ return true;
+ }
self.graph_mut().cancel_connecting();
return true;
}
+ // Delete/Backspace removes the curve tool's selected control
+ // point. Ahead of the network pane's node-delete, which only
+ // runs when no tool selection consumed the key; after the
+ // param pane's shot above, so a focused text field keeps
+ // Backspace for its caret.
+ if event.state == ElementState::Pressed
+ && (event.logical_key == Key::Named(NamedKey::Delete)
+ || event.logical_key == Key::Named(NamedKey::Backspace))
+ && self.curve_tool_delete_selected()
+ {
+ return true;
+ }
let mut changed = false;
if event.state == ElementState::Pressed {
let is_plain_key = !self.modifiers.control_key() && !self.modifiers.alt_key() && !self.modifiers.super_key();
@@ -6248,13 +6330,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
"v" | "V" => {
if let Some(ref clipboard_node) = self.node_clipboard {
let mut node = clipboard_node.clone();
- fn regenerate_ids(n: &mut FsNode) {
- n.id = generate_node_id();
- for child in &mut n.children {
- regenerate_ids(child);
- }
- }
- regenerate_ids(&mut node);
+ regenerate_node_ids(&mut node);
let start_x = self.grid_cursor_col as f32;
let start_y = self.grid_cursor_row as f32;
let (nx, ny) = self.find_empty_cell(start_x, start_y, None);
diff --git a/src/curve_tool.rs b/src/curve_tool.rs
new file mode 100644
index 0000000..3f3e19b
--- /dev/null
+++ b/src/curve_tool.rs
@@ -0,0 +1,311 @@
+//! The curve viewer state: an interactive viewport tool for the native
+//! `curve` node, entered from the node's context menu ("Edit Points").
+//!
+//! While active, the node's control points draw as screen-space handles
+//! projected through the raster scene's cached mvp — the same path the meta
+//! Point Numbers overlay rides — and the pointer edits them in place:
+//!
+//! - **left press on a handle** grabs it; dragging moves the point on the
+//! camera-facing plane at its own depth (the grab keeps the point's NDC z,
+//! so orbiting between edits never makes a drag jump);
+//! - **left press on empty viewport space** appends a new point there, at the
+//! depth of the last control point, and immediately drags it;
+//! - **right press on a handle** deletes that point; Delete/Backspace deletes
+//! the selected (last-clicked) one;
+//! - **Escape** exits the state.
+//!
+//! Edits write the node's "Points" param through the same resync sequence as
+//! `McpAction::SetParam`, so the params pane, spreadsheet, and scene all
+//! follow live. The tool holds the node's *id*, not its slot: renames and
+//! graph edits don't detach it, and if the node disappears (deleted, project
+//! reloaded) every handler resolves nothing and the tool drops out lazily.
+
+use crate::app::{FsNode, State};
+use crate::geometry::{format_curve_points, node_param_str, parse_curve_points};
+use glam::{Mat4, Vec3, Vec4};
+
+/// How close (logical px) a press must land to a projected handle to grab it.
+pub const HANDLE_HIT_RADIUS: f32 = 10.0;
+
+pub struct CurveTool {
+ /// Id of the curve node being edited.
+ pub node_id: String,
+ /// The last-clicked control point — the Delete target.
+ pub selected: Option<usize>,
+ /// An in-flight drag, if a press grabbed (or just added) a handle.
+ pub drag: Option<CurveDrag>,
+}
+
+#[derive(Clone, Copy)]
+pub struct CurveDrag {
+ pub point: usize,
+ /// NDC depth captured at grab time; motion unprojects onto this plane.
+ pub ndc_z: f32,
+}
+
+/// World → (screen x, screen y, ndc z) through the cached scene mvp.
+pub fn project_point(mvp: &Mat4, view: (f32, f32, f32, f32), p: Vec3) -> Option<(f32, f32, f32)> {
+ let (vx, vy, vw, vh) = view;
+ let clip = *mvp * Vec4::new(p.x, p.y, p.z, 1.0);
+ if clip.w <= 0.0 {
+ return None;
+ }
+ let ndc = clip / clip.w;
+ Some((
+ vx + (ndc.x * 0.5 + 0.5) * vw,
+ vy + (0.5 - ndc.y * 0.5) * vh,
+ ndc.z,
+ ))
+}
+
+/// (screen x, screen y, ndc z) → world through the inverse of the cached mvp.
+pub fn unproject_point(
+ mvp: &Mat4,
+ view: (f32, f32, f32, f32),
+ sx: f32,
+ sy: f32,
+ ndc_z: f32,
+) -> Option<Vec3> {
+ let (vx, vy, vw, vh) = view;
+ if vw <= 0.0 || vh <= 0.0 {
+ return None;
+ }
+ let inv = mvp.inverse();
+ if !inv.is_finite() {
+ return None;
+ }
+ let ndc_x = ((sx - vx) / vw - 0.5) * 2.0;
+ let ndc_y = (0.5 - (sy - vy) / vh) * 2.0;
+ let world = inv * Vec4::new(ndc_x, ndc_y, ndc_z, 1.0);
+ if world.w.abs() < 1e-6 {
+ return None;
+ }
+ let world = world / world.w;
+ if !world.is_finite() {
+ return None;
+ }
+ Some(Vec3::new(world.x, world.y, world.z))
+}
+
+pub fn find_node_by_id<'a>(root: &'a FsNode, id: &str) -> Option<&'a FsNode> {
+ if root.id == id {
+ return Some(root);
+ }
+ root.children.iter().find_map(|c| find_node_by_id(c, id))
+}
+
+pub fn find_node_by_id_mut<'a>(root: &'a mut FsNode, id: &str) -> Option<&'a mut FsNode> {
+ if root.id == id {
+ return Some(root);
+ }
+ root.children
+ .iter_mut()
+ .find_map(|c| find_node_by_id_mut(c, id))
+}
+
+impl State {
+ /// Enter/exit the curve viewer state for the node in `slot` of the
+ /// current directory. A different curve node retargets the tool.
+ pub(crate) fn toggle_curve_tool(&mut self, slot: usize) {
+ let Some(node) = self.current_dir().children.get(slot) else {
+ return;
+ };
+ if !node.node_type.eq_ignore_ascii_case("curve") {
+ return;
+ }
+ let id = node.id.clone();
+ if self.curve_tool.as_ref().map(|t| t.node_id == id).unwrap_or(false) {
+ self.curve_tool = None;
+ } else {
+ self.curve_tool = Some(CurveTool { node_id: id, selected: None, drag: None });
+ }
+ }
+
+ /// The edited node's control points, or None if the node is gone (or is
+ /// no longer a curve — a project reload can put anything at an old id).
+ fn curve_node_points(&self, node_id: &str) -> Option<Vec<Vec3>> {
+ let node = find_node_by_id(&self.fs_root, node_id)?;
+ if !node.node_type.eq_ignore_ascii_case("curve") {
+ return None;
+ }
+ Some(parse_curve_points(&node_param_str(node, "Points", "")))
+ }
+
+ /// Write the points back and run the same resync sequence as SetParam,
+ /// so the scene, spreadsheet, and params pane all follow the edit.
+ fn set_curve_points(&mut self, node_id: &str, pts: &[Vec3]) {
+ let formatted = format_curve_points(pts);
+ let Some(node) = find_node_by_id_mut(&mut self.fs_root, node_id) else {
+ return;
+ };
+ if let Some(p) = node.params.iter_mut().find(|p| p.name == "Points") {
+ p.default = formatted;
+ } else {
+ return;
+ }
+ self.sync_nodes();
+ self.rebuild_scene_geometry();
+ self.sync_parameters_pane();
+ }
+
+ /// The active tool's handles as (point index, screen x, screen y, ndc z).
+ /// Empty when the tool is off, the node is gone, or no scene mvp has been
+ /// cached yet (a frame before the first scene staging).
+ pub(crate) fn curve_tool_handles(&self) -> Vec<(usize, f32, f32, f32)> {
+ let Some(tool) = &self.curve_tool else {
+ return Vec::new();
+ };
+ let Some(mvp) = self.last_scene_mvp else {
+ return Vec::new();
+ };
+ let Some(pts) = self.curve_node_points(&tool.node_id) else {
+ return Vec::new();
+ };
+ pts.iter()
+ .enumerate()
+ .filter_map(|(i, p)| {
+ project_point(&mvp, self.last_scene_view_rect, *p).map(|(sx, sy, z)| (i, sx, sy, z))
+ })
+ .collect()
+ }
+
+ /// The handle under the cursor, nearest first.
+ fn curve_tool_handle_at_cursor(&self) -> Option<(usize, f32)> {
+ let (cx, cy) = (self.cursor_x, self.cursor_y);
+ self.curve_tool_handles()
+ .iter()
+ .map(|(i, sx, sy, z)| (*i, ((sx - cx).powi(2) + (sy - cy).powi(2)).sqrt(), *z))
+ .filter(|(_, d, _)| *d <= HANDLE_HIT_RADIUS)
+ .min_by(|a, b| a.1.total_cmp(&b.1))
+ .map(|(i, _, z)| (i, z))
+ }
+
+ /// Left press in the viewport while the tool is active: grab the handle
+ /// under the cursor, or append a new point at the cursor (at the last
+ /// point's depth) and start dragging it. Returns false — letting the
+ /// press fall through — only when the edited node no longer exists.
+ pub(crate) fn curve_tool_press(&mut self) -> bool {
+ let Some(tool) = &self.curve_tool else {
+ return false;
+ };
+ let node_id = tool.node_id.clone();
+ let Some(mut pts) = self.curve_node_points(&node_id) else {
+ self.curve_tool = None;
+ return false;
+ };
+ if let Some((idx, ndc_z)) = self.curve_tool_handle_at_cursor() {
+ let tool = self.curve_tool.as_mut().expect("checked above");
+ tool.selected = Some(idx);
+ tool.drag = Some(CurveDrag { point: idx, ndc_z });
+ return true;
+ }
+ // Empty space: add a point. Depth comes from the last control point
+ // (or the world origin for an empty curve) so the new point lands in
+ // the plane the user is already working in.
+ let Some(mvp) = self.last_scene_mvp else {
+ return true; // tool consumes viewport presses even pre-staging
+ };
+ let view = self.last_scene_view_rect;
+ let ndc_z = pts
+ .last()
+ .and_then(|p| project_point(&mvp, view, *p))
+ .map(|(_, _, z)| z)
+ .or_else(|| project_point(&mvp, view, Vec3::ZERO).map(|(_, _, z)| z));
+ let Some(ndc_z) = ndc_z else {
+ return true;
+ };
+ let Some(world) = unproject_point(&mvp, view, self.cursor_x, self.cursor_y, ndc_z) else {
+ return true;
+ };
+ pts.push(world);
+ let idx = pts.len() - 1;
+ self.set_curve_points(&node_id, &pts);
+ if let Some(tool) = self.curve_tool.as_mut() {
+ tool.selected = Some(idx);
+ tool.drag = Some(CurveDrag { point: idx, ndc_z });
+ }
+ true
+ }
+
+ /// Pointer motion during a grab: the point tracks the cursor on the
+ /// camera-facing plane at its grab depth.
+ pub(crate) fn curve_tool_drag_motion(&mut self) -> bool {
+ let Some(drag) = self.curve_tool.as_ref().and_then(|t| t.drag) else {
+ return false;
+ };
+ let Some(mvp) = self.last_scene_mvp else {
+ return false;
+ };
+ let node_id = self.curve_tool.as_ref().expect("drag implies tool").node_id.clone();
+ let Some(mut pts) = self.curve_node_points(&node_id) else {
+ self.curve_tool = None;
+ return false;
+ };
+ if drag.point >= pts.len() {
+ return false;
+ }
+ let Some(world) =
+ unproject_point(&mvp, self.last_scene_view_rect, self.cursor_x, self.cursor_y, drag.ndc_z)
+ else {
+ return false;
+ };
+ pts[drag.point] = world;
+ self.set_curve_points(&node_id, &pts);
+ true
+ }
+
+ /// Button release: end any in-flight grab.
+ pub(crate) fn curve_tool_release(&mut self) -> bool {
+ match self.curve_tool.as_mut() {
+ Some(tool) if tool.drag.is_some() => {
+ tool.drag = None;
+ true
+ }
+ _ => false,
+ }
+ }
+
+ /// Right press while the tool is active: delete the handle under the
+ /// cursor. Consumes only on a hit — otherwise the press falls through to
+ /// the viewport context menu.
+ pub(crate) fn curve_tool_delete_at_cursor(&mut self) -> bool {
+ let Some((idx, _)) = self.curve_tool_handle_at_cursor() else {
+ return false;
+ };
+ self.curve_tool_delete_point(idx)
+ }
+
+ /// Delete/Backspace: remove the selected control point, if any.
+ pub(crate) fn curve_tool_delete_selected(&mut self) -> bool {
+ let Some(idx) = self.curve_tool.as_ref().and_then(|t| t.selected) else {
+ return false;
+ };
+ self.curve_tool_delete_point(idx)
+ }
+
+ fn curve_tool_delete_point(&mut self, idx: usize) -> bool {
+ let Some(tool) = &self.curve_tool else {
+ return false;
+ };
+ let node_id = tool.node_id.clone();
+ let Some(mut pts) = self.curve_node_points(&node_id) else {
+ self.curve_tool = None;
+ return false;
+ };
+ if idx >= pts.len() {
+ return false;
+ }
+ pts.remove(idx);
+ self.set_curve_points(&node_id, &pts);
+ if let Some(tool) = self.curve_tool.as_mut() {
+ tool.drag = None;
+ // Keep a neighbor selected so repeated Delete walks the curve.
+ tool.selected = if pts.is_empty() {
+ None
+ } else {
+ Some(idx.min(pts.len() - 1))
+ };
+ }
+ true
+ }
+}
diff --git a/src/geometry.rs b/src/geometry.rs
index 34c9c11..98838e9 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -279,6 +279,86 @@ pub fn line_vertices(start: Vec3, end: Vec3, thickness: f32) -> Geometry {
Geometry { vertices }
}
+/// Parse a curve node's "Points" param: control points as `x y z` triples
+/// separated by `;`. Commas are accepted alongside whitespace inside a
+/// triple; chunks that don't yield exactly three numbers are skipped, so a
+/// half-typed point in the params pane degrades to "not there yet" instead
+/// of corrupting its neighbors.
+pub fn parse_curve_points(s: &str) -> Vec<Vec3> {
+ s.split(';')
+ .filter_map(|chunk| {
+ let n: Vec<f32> = chunk
+ .split(|c: char| c.is_whitespace() || c == ',')
+ .filter(|t| !t.is_empty())
+ .map(|t| t.parse::<f32>())
+ .collect::<Result<_, _>>()
+ .ok()?;
+ if n.len() == 3 && n.iter().all(|v| v.is_finite()) {
+ Some(Vec3::new(n[0], n[1], n[2]))
+ } else {
+ None
+ }
+ })
+ .collect()
+}
+
+/// The inverse of [`parse_curve_points`] — what the curve viewer state
+/// writes back into the "Points" param.
+pub fn format_curve_points(pts: &[Vec3]) -> String {
+ pts.iter()
+ .map(|p| format!("{} {} {}", p.x, p.y, p.z))
+ .collect::<Vec<_>>()
+ .join("; ")
+}
+
+/// Uniform Catmull-Rom through the control points, `segs` samples per span,
+/// endpoints clamped (the first/last point doubles as its own neighbor). The
+/// result includes the first control point and passes through every control
+/// point at span boundaries. Fewer than two points sample as themselves.
+pub fn sample_catmull_rom(pts: &[Vec3], segs: usize) -> Vec<Vec3> {
+ if pts.len() < 2 {
+ return pts.to_vec();
+ }
+ let segs = segs.max(1);
+ let mut out = Vec::with_capacity((pts.len() - 1) * segs + 1);
+ out.push(pts[0]);
+ for i in 0..pts.len() - 1 {
+ let p0 = if i == 0 { pts[0] } else { pts[i - 1] };
+ let p1 = pts[i];
+ let p2 = pts[i + 1];
+ let p3 = if i + 2 < pts.len() { pts[i + 2] } else { pts[pts.len() - 1] };
+ for s in 1..=segs {
+ let t = s as f32 / segs as f32;
+ let t2 = t * t;
+ let t3 = t2 * t;
+ out.push(
+ 0.5 * ((2.0 * p1)
+ + (-p0 + p2) * t
+ + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
+ + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3),
+ );
+ }
+ }
+ out
+}
+
+/// The native `curve` node: a Catmull-Rom strip through the "Points" param,
+/// each sampled span an oriented box via [`line_vertices`]. Points are
+/// absolute world coordinates — deliberately not offset by the grid index
+/// the other primitives use, because the curve viewer state edits them in
+/// world space.
+pub fn curve_geometry(node: &FsNode) -> Geometry {
+ let pts = parse_curve_points(&node_param_str(node, "Points", ""));
+ let segs = node_param_f32(node, "Segments", 8.0).max(1.0) as usize;
+ let thickness = node_param_f32(node, "Thickness", 0.02).max(0.001);
+ let samples = sample_catmull_rom(&pts, segs);
+ let mut geom = Geometry::new();
+ for w in samples.windows(2) {
+ geom.merge(line_vertices(w[0], w[1], thickness));
+ }
+ geom
+}
+
pub fn node_param_f32(node: &FsNode, name: &str, fallback: f32) -> f32 {
node.params.iter()
.find(|p| p.name.eq_ignore_ascii_case(name))
@@ -463,6 +543,8 @@ pub fn generate_single_node_geometry_with_errors(
let thickness = node_param_f32(target, "Thickness", 0.02);
let end = start + Vec3::new(0.0, length, 0.0);
Some(line_vertices(start, end, thickness))
+ } else if target.node_type.eq_ignore_ascii_case("curve") {
+ Some(curve_geometry(target))
} else if target.node_type.eq_ignore_ascii_case("add") {
let idx = find_sphere_index(root, target)?;
let center = Vec3::new((idx % 4) as f32 * 1.25 - 1.875, 0.55, -((idx / 4) as f32) * 1.25);
@@ -1914,6 +1996,7 @@ pub fn is_geometry_node_type(node_type: &str) -> bool {
let nt = node_type.to_lowercase();
nt == "sphere"
|| nt == "line"
+ || nt == "curve"
|| nt == "add"
|| nt == "transform"
|| nt == "opencl"
@@ -1995,6 +2078,12 @@ pub fn network_sphere_vertices_with_errors(
let end = start + Vec3::new(0.0, length, 0.0);
out.merge(line_vertices(start, end, thickness));
}
+ } else if node.node_type.eq_ignore_ascii_case("curve") {
+ // Absolute world coordinates: no grid-index placement, and
+ // `count` untouched so find_sphere_index stays aligned.
+ if is_visible {
+ out.merge(curve_geometry(node));
+ }
} else if node.node_type.eq_ignore_ascii_case("add") {
let idx = *count;
*count += 1;
diff --git a/src/kernel_cpu.rs b/src/kernel_cpu.rs
index 64642b8..a4f3bb7 100644
--- a/src/kernel_cpu.rs
+++ b/src/kernel_cpu.rs
@@ -1644,27 +1644,6 @@ mod template_tests {
}
}
- #[test]
- fn cpu_runs_the_curve_template() {
- let (code, params) = template_kernel("Curve");
- let mut g = Geometry::new();
- run_kernel_cpu(&code, &mut g, ¶ms).expect("curve kernel");
- // 24 segments, each a 36-vertex box: the default Bézier degenerates
- // nowhere, so every segment emits.
- assert_eq!(g.vertices.len(), 864);
- for v in &g.vertices {
- assert!(v.pos.iter().all(|c| c.is_finite()), "curve produced non-finite positions");
- }
- // The strip starts at Point 1 and ends at Point 4 (within a half
- // thickness of the sampled centerline).
- let near = |v: &GVertex, p: [f32; 3]| {
- let d = (0..3).map(|k| (v.pos[k] - p[k]).powi(2)).sum::<f32>().sqrt();
- d < 0.1
- };
- assert!(g.vertices.iter().any(|v| near(v, [-0.75, 0.05, 0.0])), "curve does not reach Point 1");
- assert!(g.vertices.iter().any(|v| near(v, [0.75, 1.05, 0.0])), "curve does not reach Point 4");
- }
-
/// The reference test proper: byte-level agreement with OpenCL on every
/// shipped kernel. Skips silently where no platform exists — the absolute
/// tests above still cover the CPU side there.
@@ -1675,7 +1654,6 @@ mod template_tests {
("Plane", Geometry::new()),
("Box", Geometry::new()),
("Extrude", triangle()),
- ("Curve", Geometry::new()),
] {
let (code, params) = template_kernel(name);
diff --git a/src/main.rs b/src/main.rs
index 0dd80ee..0ae1d8a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,6 +1,7 @@
pub mod app;
pub mod application;
+pub mod curve_tool;
// Root-level aliases some modules import via `crate::` paths.
#[allow(unused_imports)]
@@ -1218,93 +1219,186 @@ mod tests {
assert!((max_dist_2 - 1.0).abs() < 0.01, "Expected radius around 1.0, got {}", max_dist_2);
}
- /// The Curve template: a subnet (opencl -> output) whose kernel samples a
- /// cubic Bézier through the four control-point params and emits each of
- /// the "Segments" spans as a 36-vertex oriented box strip.
+ /// The native curve node: a Catmull-Rom strip through the "Points"
+ /// param, each sampled span an oriented 36-vertex box.
#[test]
- fn test_curve_subnet_geometry_generation() {
+ fn test_curve_native_geometry_generation() {
let templates_root = crate::app::load_fs_tree();
let curve_template = templates_root
.children
.iter()
.find(|t| t.name == "Curve")
.expect("Curve template should be loaded");
+ assert_eq!(curve_template.node_type, "curve");
+ assert!(curve_template.children.is_empty(), "native curve has no subnet children");
- assert_eq!(curve_template.children.len(), 2);
- let opencl1 = curve_template.children.iter().find(|c| c.name == "opencl1").unwrap();
- assert_eq!(opencl1.node_type, "opencl");
- let output1 = curve_template.children.iter().find(|c| c.name == "output1").unwrap();
- assert_eq!(output1.node_type, "output");
-
- let mut curve_instance = curve_template.clone();
- curve_instance.id = "curve_inst".to_string();
- for child in &mut curve_instance.children {
- child.id = format!("{}_{}", curve_instance.id, child.name);
- }
-
- let root = FsNode {
+ let make_root = |instance: FsNode| FsNode {
id: "root".to_string(),
name: "root".to_string(),
node_type: "node".to_string(),
- children: vec![curve_instance],
+ children: vec![instance],
params: vec![],
geometry_visible: true,
position: (0.0, 0.0),
inputs: 0,
outputs: 0,
};
+ let eval = |root: &FsNode| {
+ let mut visited = Vec::new();
+ crate::geometry::generate_single_node_geometry(root, &root.children[0], &mut visited)
+ .expect("Geometry generation failed")
+ };
- let mut visited = Vec::new();
- let mut ocl_err = None;
- let geom = crate::geometry::generate_single_node_geometry_with_errors(
- &root,
- &root.children[0],
- &mut visited,
- &mut ocl_err,
- &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
- ).expect("Geometry generation failed");
-
- assert!(ocl_err.is_none(), "OpenCL compilation error: {:?}", ocl_err);
- // 24 default segments x 36 vertices per segment box.
+ // Default: 4 points, 8 segments per span → 3*8 spans × 36 vertices.
+ let mut instance = curve_template.clone();
+ instance.id = "curve_inst".to_string();
+ let geom = eval(&make_root(instance.clone()));
assert_eq!(geom.vertices.len(), 864);
for v in &geom.vertices {
assert!(v.pos.iter().all(|c| c.is_finite()), "curve produced non-finite positions");
}
+ // The strip reaches both endpoint control points.
+ let near = |g: &crate::geometry::Geometry, p: [f32; 3]| {
+ g.vertices.iter().any(|v| {
+ (0..3).map(|k| (v.pos[k] - p[k]).powi(2)).sum::<f32>().sqrt() < 0.1
+ })
+ };
+ assert!(near(&geom, [-0.75, 0.05, 0.0]), "curve does not reach its first point");
+ assert!(near(&geom, [0.75, 1.05, 0.0]), "curve does not reach its last point");
+
+ // Two points: one span, 8 boxes. The scene walk agrees with the
+ // single-node path.
+ instance.params.iter_mut().find(|p| p.name == "Points").unwrap().default =
+ "0 0 0; 1 0 0".to_string();
+ let root = make_root(instance.clone());
+ assert_eq!(eval(&root).vertices.len(), 288);
+ assert_eq!(
+ crate::geometry::network_sphere_vertices(&root).vertices.len(),
+ 288,
+ "scene walk and single-node eval disagree"
+ );
- // Halving Segments halves the strip.
- let mut curve_instance_2 = curve_template.clone();
- curve_instance_2.id = "curve_inst_2".to_string();
- for child in &mut curve_instance_2.children {
- child.id = format!("{}_{}", curve_instance_2.id, child.name);
- }
- if let Some(seg_param) = curve_instance_2.params.iter_mut().find(|p| p.name == "Segments") {
- seg_param.default = "12".to_string();
- }
+ // No parseable points: empty geometry, not a panic.
+ instance.params.iter_mut().find(|p| p.name == "Points").unwrap().default =
+ "not points".to_string();
+ assert_eq!(eval(&make_root(instance)).vertices.len(), 0);
+ }
- let root_2 = FsNode {
- id: "root".to_string(),
- name: "root".to_string(),
- node_type: "node".to_string(),
- children: vec![curve_instance_2],
- params: vec![],
- geometry_visible: true,
- position: (0.0, 0.0),
- inputs: 0,
- outputs: 0,
- };
+ /// Points round-trip through the "Points" param format; malformed
+ /// chunks are skipped rather than corrupting neighbors; the sampled
+ /// Catmull-Rom passes through every control point at span boundaries.
+ #[test]
+ fn test_curve_points_parse_and_sampling() {
+ use crate::geometry::{format_curve_points, parse_curve_points, sample_catmull_rom};
- let mut visited_2 = Vec::new();
- let mut ocl_err_2 = None;
- let geom_2 = crate::geometry::generate_single_node_geometry_with_errors(
- &root_2,
- &root_2.children[0],
- &mut visited_2,
- &mut ocl_err_2,
- &mut crate::geometry::EvalSim::new(0, 0, &mut crate::geometry::SimCache::default()),
- ).expect("Geometry generation failed");
+ let pts = vec![
+ Vec3::new(-0.75, 0.05, 0.0),
+ Vec3::new(0.25, 1.5, -0.5),
+ Vec3::new(2.0, -1.0, 3.25),
+ ];
+ assert_eq!(parse_curve_points(&format_curve_points(&pts)), pts);
+
+ // Commas allowed, garbage and half-typed triples skipped.
+ let parsed = parse_curve_points("1, 2, 3; nope; 4 5; ; 6 7 8");
+ assert_eq!(parsed, vec![Vec3::new(1.0, 2.0, 3.0), Vec3::new(6.0, 7.0, 8.0)]);
+
+ let segs = 4;
+ let samples = sample_catmull_rom(&pts, segs);
+ assert_eq!(samples.len(), (pts.len() - 1) * segs + 1);
+ for (i, p) in pts.iter().enumerate() {
+ let s = samples[i * segs];
+ assert!((s - *p).length() < 1e-5, "sample {} misses control point {}", i * segs, i);
+ }
+ // Degenerate inputs sample as themselves.
+ assert_eq!(sample_catmull_rom(&[], segs).len(), 0);
+ assert_eq!(sample_catmull_rom(&pts[..1], segs), pts[..1].to_vec());
+ }
- assert!(ocl_err_2.is_none(), "OpenCL compilation error: {:?}", ocl_err_2);
- assert_eq!(geom_2.vertices.len(), 432);
+ /// The curve viewer state end-to-end, headless: grab-and-drag moves a
+ /// control point on its own depth plane, a press on empty space appends
+ /// a point there, right-press and Delete remove points — all through
+ /// the real press/motion/release handlers, against an identity mvp
+ /// (world x/y map linearly onto a 100×100 pane).
+ #[test]
+ fn test_curve_tool_add_move_delete() {
+ let mut state = State::new(false);
+ let mut redraw = false;
+ state
+ .apply_action(
+ McpAction::AddNode { template_name: "Curve".to_string(), name: None, x: 0.0, y: 0.0 },
+ &mut redraw,
+ )
+ .expect("add curve node");
+ let slot = state.current_dir().children.len() - 1;
+ assert_eq!(state.current_dir().children[slot].node_type, "curve");
+
+ // A second instance gets its own id (AddNode regenerates like
+ // paste) — the tool binds by id, so shared ids would edit the
+ // wrong node.
+ state
+ .apply_action(
+ McpAction::AddNode { template_name: "Curve".to_string(), name: None, x: 2.0, y: 0.0 },
+ &mut redraw,
+ )
+ .expect("add second curve node");
+ let slot2 = state.current_dir().children.len() - 1;
+ assert_ne!(
+ state.current_dir().children[slot].id,
+ state.current_dir().children[slot2].id,
+ "template instances must not share ids"
+ );
+
+ state.toggle_curve_tool(slot);
+ assert!(state.curve_tool.is_some());
+
+ state.last_scene_mvp = Some(Mat4::IDENTITY);
+ state.last_scene_view_rect = (0.0, 0.0, 100.0, 100.0);
+ let sx = |x: f32| 50.0 + x * 50.0;
+ let sy = |y: f32| 50.0 - y * 50.0;
+ let points_of = |state: &State, slot: usize| {
+ crate::geometry::parse_curve_points(&crate::geometry::node_param_str(
+ &state.current_dir().children[slot],
+ "Points",
+ "",
+ ))
+ };
+ let default_first = points_of(&state, slot)[0];
+
+ // Grab the first point and drag it to the pane center → (0, 0, z).
+ state.cursor_x = sx(default_first.x);
+ state.cursor_y = sy(default_first.y);
+ assert!(state.curve_tool_press(), "press on a handle must grab");
+ state.cursor_x = 50.0;
+ state.cursor_y = 50.0;
+ assert!(state.curve_tool_drag_motion());
+ assert!(state.curve_tool_release());
+ let pts = points_of(&state, slot);
+ assert!(pts[0].length() < 1e-4, "dragged point should sit at the origin, got {:?}", pts[0]);
+ // The other curve is untouched.
+ assert_eq!(points_of(&state, slot2)[0], default_first);
+
+ // Press on empty space appends a point there (at the last point's
+ // depth — z=0 here) and immediately drags it.
+ state.cursor_x = 90.0;
+ state.cursor_y = 90.0;
+ assert!(state.curve_tool_press());
+ let pts = points_of(&state, slot);
+ assert_eq!(pts.len(), 5);
+ assert!((pts[4] - Vec3::new(0.8, -0.8, 0.0)).length() < 1e-4, "added at {:?}", pts[4]);
+ assert!(state.curve_tool_release());
+
+ // Delete the (selected) new point, then right-press-delete the one
+ // parked at the pane center.
+ assert!(state.curve_tool_delete_selected());
+ assert_eq!(points_of(&state, slot).len(), 4);
+ state.cursor_x = 50.0;
+ state.cursor_y = 50.0;
+ assert!(state.curve_tool_delete_at_cursor());
+ assert_eq!(points_of(&state, slot).len(), 3);
+
+ // Toggling on the same node exits the state.
+ state.toggle_curve_tool(slot);
+ assert!(state.curve_tool.is_none());
}
/// The Extrude template: a subnet (input -> opencl -> output) whose kernel
diff --git a/src/render.rs b/src/render.rs
index 6092208..2e4b743 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -171,6 +171,7 @@ impl State {
self.append_context_border(&mut pc);
self.append_frame_text(&mut pc);
self.append_meta_point_numbers(&mut pc);
+ self.append_curve_tool_overlay(&mut pc);
self.append_popovers(&mut pc);
self.append_dock_drag_overlay(&mut pc);
self.append_plate_corners(&mut pc);
@@ -853,6 +854,42 @@ impl State {
});
}
+ /// The curve viewer state's handles: each control point projected
+ /// through the cached scene mvp (like the point numbers above), drawn as
+ /// a ringed dot with its index, the control cage as faint segments
+ /// between them. Selected point draws larger and brighter.
+ fn append_curve_tool_overlay(&self, pc: &mut PaintCtx) {
+ let Some(tool) = &self.curve_tool else { return };
+ if !self.show_viewport {
+ return;
+ }
+ let handles = self.curve_tool_handles();
+ let (vx, vy, vw, vh) = self.last_scene_view_rect;
+ if vw <= 0.0 || vh <= 0.0 {
+ return;
+ }
+ pc.clip(rect(vx, vy, vw, vh), |pc| {
+ for pair in handles.windows(2) {
+ let (_, x0, y0, _) = pair[0];
+ let (_, x1, y1, _) = pair[1];
+ pc.vector(x0, y0, x1, y1, 1.0, [1.0, 1.0, 1.0, 0.25], cce_ui::scene::paint::Cap::Round);
+ }
+ for (i, sx, sy, _z) in &handles {
+ let selected = tool.selected == Some(*i);
+ let r = if selected { 6.0 } else { 4.5 };
+ // Dark ring behind for contrast against any scene.
+ pc.circle(*sx, *sy, r + 1.5, [0.0, 0.0, 0.0, 0.6]);
+ let col = if selected {
+ [1.0, 0.92, 0.55, 1.0]
+ } else {
+ [1.0, 0.78, 0.20, 1.0]
+ };
+ pc.circle(*sx, *sy, r, col);
+ pc.text((i + 1).to_string(), sx + 8.0, sy - 6.0, 10.0, [0xff, 0xe6, 0xa0]);
+ }
+ });
+ }
+
pub(crate) fn rebuild_scene_geometry(&mut self) {
let mut ocl_error = None;
// The sim cache lives on State so playing forward steps each simnet once
diff --git a/src/window.rs b/src/window.rs
index ad01778..dd5b277 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -794,6 +794,9 @@ impl State {
});
if let Some(idx) = template_idx {
let mut node = state.node_templates[idx].node.clone();
+ // Fresh ids, like paste: a verbatim clone shares the
+ // template's ids across every instance.
+ crate::app::regenerate_node_ids(&mut node);
let mut allowed = true;
let is_in_utility = state.in_settings_dir();
if is_in_utility {