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

commit44f331160bd2ee390685bfddd198f77c323c1213
parent066dcce5d0
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-26 19:40
feat: a selected pull draws arrows of what it moves

While the params pane shows an Attribute node writing Pos, the viewport
draws amber arrows from each moved point's input position to its output
one, measured rather than read off Value, so every Combine and an
expression show what actually happened. At most twelve points get one,
chosen by farthest-point sampling so they spread over the pulled region.
Inside a simnet the node is measured with the current frame's step
feedback, so the arrows start where the points are this frame, not at the
seed; the shared sim cache makes that a cache hit during playback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       |  21 +++++
 src/app.rs      |  93 ++++++++++++++++++++++
 src/geometry.rs | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/render.rs   |   3 +
 4 files changed, 355 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index a639381..1835937 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1504,6 +1504,27 @@ is taken in MESH space, the inverse of view × model. Leaving see-through
 clears the key and re-uploads nothing: sorted order is still a valid
 opaque mesh.
 
+### Pull arrows
+
+While the params pane shows an Attribute node that writes `Pos`
+(`geometry::moves_points` — the simnet's `pull1` is one), the viewport draws
+amber arrows from where points were to where the node puts them. They are
+MEASURED, output `P` minus input `P` (`point_displacements`), not read off
+Value, so Set, Multiply and an expression all show what actually happened,
+and the arrowed points are exactly the ones that moved. An arrow's full length,
+head included, is the true displacement.
+
+At most `State::PULL_ARROWS_MAX` (12) points get one, picked by farthest-point
+sampling (`spread_sample`) so they cover the region the pull covers rather
+than bunching wherever the point numbering runs locally. A node inside a
+simnet is measured as this frame's step saw it, with the step's feedback
+pushed, the same rule the dived-in scene walk draws by. Without it the
+`input` node reads the seed, and the arrows would sit where the points
+started, not where they are. `sync_pull_arrows` borrows the shared sim cache for
+this, so during playback the feedback is a cache hit rather than a re-solve
+from the seed every frame; it runs from both `sync_nodes` and the end of
+`rebuild_scene_geometry`, keyed by (node id, params, geometry version).
+
 ### Dragging the scene orbits the camera
 
 `State::orbit_camera_by` turns the camera by a drag delta, armed by a left
diff --git a/src/app.rs b/src/app.rs
index 9d1e3b5..5d8a048 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1808,6 +1808,9 @@ pub struct SceneMeshes {
     pub overlay_points: cce_ui::vk::MeshId,
     /// The Show Point Normals overlay (LINE_LIST whiskers).
     pub overlay_normals: cce_ui::vk::MeshId,
+    /// Pull arrows (LINE_LIST): while a point-moving Attribute node is
+    /// selected, how far and which way it moves a spread of the points.
+    pub pull_arrows: cce_ui::vk::MeshId,
 }
 
 /// A left-press on the detached circular window's chrome that becomes an
@@ -2244,6 +2247,15 @@ pub struct State {
     pub group_points_dirty: bool,
     pub group_point_vertex_count: u32,
     pub last_group_points_key: Option<(String, Vec<(String, String)>, u64)>,
+    /// Pull arrows: while the params pane shows an Attribute node that moves
+    /// points (`geometry::moves_points`), an arrow from where each of a
+    /// spread of its points was to where the node puts it — staged by
+    /// `sync_pull_arrows`, flushed to `meshes.pull_arrows`. Keyed like the
+    /// group markers, by (node id, params, geometry version).
+    pub pull_arrow_verts: Vec<Vertex3D>,
+    pub pull_arrows_dirty: bool,
+    pub pull_arrow_count: u32,
+    pub last_pull_arrows_key: Option<(String, Vec<(String, String)>, u64)>,
     /// The selected Group's member positions, kept from the evaluation so
     /// the markers can be re-SIZED without re-evaluating the node — a point
     /// size or marker scale change (the viewport menu's slider, per motion
@@ -5383,6 +5395,65 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             // Marker Scale): re-size without re-evaluating.
             self.rebuild_group_marker_verts();
         }
+        self.sync_pull_arrows();
+    }
+
+    /// How many points of a pull get an arrow. A dozen show the direction
+    /// and the reach of the pull across the region it covers; one per point
+    /// buries the mesh under them.
+    pub const PULL_ARROWS_MAX: usize = 12;
+
+    /// Stage the pull arrows for the node the params pane shows, when it is
+    /// an Attribute node that moves points; clear them otherwise. Evaluates
+    /// only when the key moves — a different node, an edited parameter, or
+    /// a new geometry version (which every scene rebuild, and so every frame
+    /// of a playing simnet, bumps).
+    ///
+    /// The shared sim cache is borrowed for the evaluation rather than a
+    /// throwaway one: the scene rebuild has just solved this frame, so the
+    /// step feedback a node inside a simnet needs is a cache hit instead of
+    /// a solve from the seed on every frame of playback.
+    pub(crate) fn sync_pull_arrows(&mut self) {
+        let node = if self.is_detached_network {
+            None
+        } else {
+            self.param_editor_selected()
+                .and_then(|slot| self.param_editor_dir().children.get(slot))
+                .filter(|n| crate::geometry::moves_points(n))
+                .cloned()
+        };
+        let key = node.as_ref().map(|n| {
+            (
+                n.id.clone(),
+                n.params.iter().map(|p| (p.name.clone(), p.default.clone())).collect::<Vec<_>>(),
+                self.rt_geometry_version,
+            )
+        });
+        if key == self.last_pull_arrows_key {
+            return;
+        }
+        self.last_pull_arrows_key = key;
+        self.pull_arrow_verts = match node {
+            None => Vec::new(),
+            Some(node) => {
+                let (frame, start) = (self.sim_frame(), self.sim_start_frame());
+                let mut cache = std::mem::take(&mut self.sim_cache);
+                let moved = {
+                    let mut sim = crate::geometry::EvalSim::new(frame, start, &mut cache);
+                    crate::geometry::point_displacements(&self.fs_root, &node, &mut sim)
+                };
+                self.sim_cache = cache;
+                let bases: Vec<glam::Vec3> = moved.iter().map(|(a, _)| *a).collect();
+                let shown: Vec<(glam::Vec3, glam::Vec3)> = crate::geometry::spread_sample(&bases, Self::PULL_ARROWS_MAX)
+                    .into_iter()
+                    .map(|i| moved[i])
+                    .collect();
+                // The group markers' warm accent: both are "what the
+                // selected node does", and they read as one feature.
+                crate::geometry::arrow_vertices(&shown, cce_ui::colors::to_linear_rgb([1.0, 0.78, 0.20]))
+            }
+        };
+        self.pull_arrows_dirty = true;
     }
 
     /// The Selected-Group markers' radius: Point Size times Group Marker
@@ -5839,6 +5910,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             group_points_dirty: false,
             group_point_vertex_count: 0,
             last_group_points_key: None,
+            pull_arrow_verts: Vec::new(),
+            pull_arrows_dirty: false,
+            pull_arrow_count: 0,
+            last_pull_arrows_key: None,
             group_members: Vec::new(),
             last_group_marker_size: 0.0,
             overlay_marker_verts: Vec::new(),
@@ -9740,6 +9815,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             self.overlay_normal_count = self.overlay_normal_verts.len() as u32;
             self.viewport_dirty = true;
         }
+
+        // Pull arrows, staged by sync_pull_arrows.
+        if self.pull_arrows_dirty {
+            self.pull_arrows_dirty = false;
+            renderer.update_mesh(meshes.pull_arrows, bytemuck::cast_slice(&self.pull_arrow_verts));
+            self.pull_arrow_count = self.pull_arrow_verts.len() as u32;
+            self.viewport_dirty = true;
+        }
     }
 
     /// One-time renderer setup (engine `renderer_init` hook): the persistent
@@ -9794,7 +9877,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             group_points: renderer.create_mesh(&[]),
             overlay_points: renderer.create_mesh(&[]),
             overlay_normals: renderer.create_mesh(&[]),
+            // Seeded with what is staged: a replacement renderer gets the
+            // arrows back without waiting for the selection to change.
+            pull_arrows: renderer.create_mesh(bytemuck::cast_slice(&self.pull_arrow_verts)),
         });
+        self.pull_arrow_count = self.pull_arrow_verts.len() as u32;
         // Scene geometry built during `State::new` (before the renderer
         // existed) uploads on the first frame's flush.
         self.spheres_dirty = !self.rt_sphere_verts.is_empty();
@@ -10031,6 +10118,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         if self.overlay_normal_count > 0 {
                             draws.push(SceneDraw { mesh: meshes.overlay_normals, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false, see_through: false });
                         }
+                        // Pull arrows: selection feedback, like the group
+                        // markers — full opacity, a little heavier than the
+                        // whiskers so they read over a wireframe.
+                        if self.pull_arrow_count > 0 {
+                            draws.push(SceneDraw { mesh: meshes.pull_arrows, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 2.0, wire_base_width: 0.0, prelit: false, see_through: false });
+                        }
                     }
                     renderer.stage_scene((sx, sy, cw, ch), draws);
                     }
diff --git a/src/geometry.rs b/src/geometry.rs
index cef7ca4..20476c7 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -3357,6 +3357,123 @@ pub fn vis_marker_vertices(geom: &Detail, linearize: impl Fn([f32; 3]) -> [f32;
     out
 }
 
+/// Whether `node` is an Attribute node that moves points — one writing the
+/// built-in `Pos`. Those are the nodes whose effect the viewport draws as
+/// pull arrows while one is selected.
+pub fn moves_points(node: &FsNode) -> bool {
+    node.node_type.eq_ignore_ascii_case("attribute")
+        && node_param_str(node, "Attribute Name", "").trim().eq_ignore_ascii_case("Pos")
+}
+
+/// Where `target` moves each point it moves, as `(before, after)` positions:
+/// its input's `P` against its own. Measured rather than read off Value, so
+/// Set and Multiply — whose vector differs point to point — and an
+/// expression-driven Value all come out as what actually happened, and the
+/// affected points are exactly the ones that moved.
+///
+/// A node inside a simnet is evaluated as the current frame's step saw it,
+/// with the feedback stack holding the state that step consumed — the same
+/// rule the dived-in scene walk draws by — so the arrows start where the
+/// points were this frame, not at the seed. Point counts that differ (the
+/// node was rewired onto something that adds or removes points) give nothing,
+/// since the indices no longer pair up.
+pub fn point_displacements(root: &FsNode, target: &FsNode, sim: &mut EvalSim) -> Vec<(Vec3, Vec3)> {
+    let input_name = node_param_str(target, "Input", "");
+    if input_name.trim().is_empty() {
+        return Vec::new();
+    }
+    let Some(input_node) = find_input_node(root, target, &input_name) else {
+        return Vec::new();
+    };
+    let mut ocl_error = None;
+    let simnet = find_parent_node(root, &target.id).filter(|p| p.node_type.eq_ignore_ascii_case("simnet"));
+    let mut pushed = false;
+    if let Some(simnet) = simnet {
+        let mut visited = Vec::new();
+        match simnet_step_feedback(root, simnet, &mut visited, &mut ocl_error, sim) {
+            Some(fed) => {
+                sim.feedback.push((simnet.id.clone(), fed));
+                pushed = true;
+            }
+            None => return Vec::new(),
+        }
+    }
+    let before = generate_single_node_geometry_with_errors(root, input_node, &mut Vec::new(), &mut ocl_error, sim);
+    let after = generate_single_node_geometry_with_errors(root, target, &mut Vec::new(), &mut ocl_error, sim);
+    if pushed {
+        sim.feedback.pop();
+    }
+    let (Some(before), Some(after)) = (before, after) else { return Vec::new() };
+    if before.num_points() != after.num_points() {
+        return Vec::new();
+    }
+    (0..after.num_points())
+        .map(|p| (before.pos(p), after.pos(p)))
+        .filter(|(a, b)| (*b - *a).length_squared() > 1e-12)
+        .collect()
+}
+
+/// Up to `k` of `points`, spread out: farthest-point sampling, starting from
+/// the first point and repeatedly taking the one farthest from everything
+/// taken so far. A pull on a thousand points reads from a dozen arrows as
+/// well as from a thousand, and a thousand are a hedgehog that hides the
+/// mesh; sampling by index instead would bunch wherever the numbering runs
+/// locally, which on a scattered or remeshed surface is anywhere. Returned in
+/// ascending index order; all of them when there are no more than `k`.
+pub fn spread_sample(points: &[Vec3], k: usize) -> Vec<usize> {
+    if points.len() <= k {
+        return (0..points.len()).collect();
+    }
+    let mut chosen = Vec::with_capacity(k);
+    let mut dist = vec![f32::INFINITY; points.len()];
+    let mut next = 0;
+    while chosen.len() < k {
+        chosen.push(next);
+        let at = points[next];
+        let mut far = (0, -1.0_f32);
+        for (i, d) in dist.iter_mut().enumerate() {
+            *d = d.min(points[i].distance_squared(at));
+            if *d > far.1 {
+                far = (i, *d);
+            }
+        }
+        if far.1 <= 0.0 {
+            break; // every remaining point coincides with a chosen one
+        }
+        next = far.0;
+    }
+    chosen.sort_unstable();
+    chosen
+}
+
+/// Arrows as LINE_LIST pairs: a shaft from each `from` to its `to`, and a
+/// head of four strokes flaring back from the tip. The head is a fixed
+/// fraction of the shaft, so an arrow's whole length is the true
+/// displacement — the one number it exists to show.
+pub fn arrow_vertices(pairs: &[(Vec3, Vec3)], color: [f32; 3]) -> Vec<Vertex3D> {
+    let mut out = Vec::with_capacity(pairs.len() * 10);
+    for &(from, to) in pairs {
+        let d = to - from;
+        let len = d.length();
+        if len < 1e-6 {
+            continue;
+        }
+        let dir = d / len;
+        let (u, v) = dir.any_orthonormal_pair();
+        let head = len * 0.25;
+        let back = to - dir * head;
+        let mut seg = |a: Vec3, b: Vec3| {
+            out.push(Vertex3D { position: a.to_array(), color });
+            out.push(Vertex3D { position: b.to_array(), color });
+        };
+        seg(from, to);
+        for side in [u, -u, v, -v] {
+            seg(to, back + side * head * 0.4);
+        }
+    }
+    out
+}
+
 /// The Visualize node: make a simulation's state visible.
 ///
 /// Two readings, chosen by Mode. **Ramp** maps a scalar attribute through a
@@ -8482,4 +8599,125 @@ mod simnet_tests {
         assert!(max_response > 0.01,
             "no neighbor responded to the pull (relax did nothing), max {max_response}");
     }
+
+    /// The pull arrows measure what the selected node DID: one pair per
+    /// point it moved, from its input position to its output one, and none
+    /// for the points it left alone.
+    #[test]
+    fn pull_arrows_measure_what_the_node_moves() {
+        let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+        let group = node(
+            "id-group",
+            "Group 1",
+            "group",
+            vec![
+                param("Input", "Sphere 1"),
+                param("Group Name", "pull"),
+                param("Mode", "Random"),
+                param("Count", "30"),
+                param("Seed", "3"),
+                param("Highlight", "false"),
+            ],
+            vec![],
+        );
+        let pull = node(
+            "id-pull",
+            "Pull 1",
+            "attribute",
+            vec![
+                param("Input", "Group 1"),
+                param("Operation", "Modify"),
+                param("Attribute Name", "Pos"),
+                param("Value", "0.00:0.06:0.00"),
+                param("Combine", "Add"),
+                param("Group", "pull"),
+            ],
+            vec![],
+        );
+        let root = node("id-root", "root", "node", vec![], vec![sphere, group, pull]);
+        let pull = &root.children[2];
+        assert!(moves_points(pull));
+        assert!(!moves_points(&root.children[1]), "a Group node moves nothing");
+
+        let base = eval(&root, "Group 1");
+        let mut cache = SimCache::default();
+        let mut sim = EvalSim::new(1, 1, &mut cache);
+        let moved = point_displacements(&root, pull, &mut sim);
+        assert_eq!(moved.len(), base.points().group_members("pull").len(), "one pair per pulled point");
+        for (a, b) in &moved {
+            assert!((*b - *a - Vec3::new(0.0, 0.06, 0.0)).length() < 1e-5, "vector {:?}", *b - *a);
+            assert!(
+                base.points().group_members("pull").iter().any(|&p| base.pos(p as usize).distance(*a) < 1e-6),
+                "an arrow starts at a point outside the group: {a:?}"
+            );
+        }
+
+        // Twelve of the thirty, distinct.
+        let bases: Vec<Vec3> = moved.iter().map(|(a, _)| *a).collect();
+        let picked = spread_sample(&bases, 12);
+        assert_eq!(picked.len(), 12);
+        assert!(picked.windows(2).all(|w| w[0] < w[1]), "ascending and distinct: {picked:?}");
+        // Five strokes per arrow: the shaft and a four-stroke head.
+        let shown: Vec<(Vec3, Vec3)> = picked.iter().map(|&i| moved[i]).collect();
+        assert_eq!(arrow_vertices(&shown, [1.0; 3]).len(), 12 * 10);
+    }
+
+    /// Farthest-point sampling takes the extremes before anything between
+    /// them, and hands every point back when there are no more than asked.
+    #[test]
+    fn spread_sample_spreads_out() {
+        let line: Vec<Vec3> = (0..=100).map(|i| Vec3::new(i as f32, 0.0, 0.0)).collect();
+        let picked = spread_sample(&line, 3);
+        assert_eq!(picked, vec![0, 50, 100], "the two ends, then the middle");
+        assert_eq!(spread_sample(&line[..5], 12), vec![0, 1, 2, 3, 4]);
+        // Coincident points: no more picks than distinct positions.
+        let same = vec![Vec3::ONE; 20];
+        assert_eq!(spread_sample(&same, 12), vec![0]);
+    }
+
+    /// Inside a simnet the arrows start where the points were THIS frame —
+    /// the state the frame's step consumed, which is the previous frame's
+    /// solve — not at the seed, which is what evaluating the node on its own
+    /// would give (the `input` node reads the seed with no feedback pushed).
+    #[test]
+    fn pull_arrows_inside_a_simnet_start_from_this_frames_state() {
+        let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+        let inner_input = node("id-in", "input1", "input", vec![], vec![]);
+        let pull = node(
+            "id-pull",
+            "pull1",
+            "attribute",
+            vec![
+                param("Input", "input1"),
+                param("Operation", "Modify"),
+                param("Attribute Name", "Pos"),
+                param("Value", "1.00:0.00:0.00"),
+                param("Combine", "Add"),
+                param("Group", ""),
+            ],
+            vec![],
+        );
+        let inner_output = node("id-out", "output1", "output", vec![param("Input", "pull1")], vec![]);
+        let sim_node = node(
+            "id-sim",
+            "Simnet 1",
+            "simnet",
+            vec![param("Input", "Sphere 1")],
+            vec![inner_input, pull, inner_output],
+        );
+        let root = node("id-root", "root", "node", vec![], vec![sphere, sim_node]);
+        let pull = &root.children[1].children[1];
+
+        let prev = solve_at(&root, 3);
+        let mut cache = SimCache::default();
+        let mut sim = EvalSim::new(4, 1, &mut cache);
+        let moved = point_displacements(&root, pull, &mut sim);
+        assert_eq!(moved.len(), prev.num_points());
+        let lo = moved.iter().map(|(a, _)| a.x).fold(f32::INFINITY, f32::min);
+        assert!((lo - min_x(&prev)).abs() < 1e-4, "arrows start at {lo}, frame 3 solved to {}", min_x(&prev));
+        assert!(min_x(&prev) > min_x(&solve_at(&root, 1)) + 1.0, "the fixture must have stepped, or this proves nothing");
+        for (a, b) in &moved {
+            assert!((*b - *a - Vec3::X).length() < 1e-5);
+        }
+    }
 }
diff --git a/src/render.rs b/src/render.rs
index b4fbb38..d893653 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1196,6 +1196,9 @@ impl State {
             cce_ui::colors::to_linear_rgb,
         ));
         self.overlay_dirty = true;
+        // The pull arrows measure the selected node against this new
+        // geometry version; a playing simnet reaches here every frame.
+        self.sync_pull_arrows();
 
         // Last, not first: the page's status line would otherwise be
         // overwritten by the geometry pass's own, and a level showing a page