graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: Pull Arrow Scale lengthens the pull arrows, default 1
A display-only multiple of the displacement the pull arrows show: a
dialog row and a viewport-menu slider (0.25-10x) under Group Marker
Scale, persisted in the render block and so in the project's display
snapshot too. The sampled pairs are kept unscaled and each arrow is
stretched from its fixed base, so a slider drag re-evaluates nothing.
Absent in older settings: 1, the true vector.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 11 ++++++---
src/app.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++-------
src/dialog.rs | 8 +++++++
src/main.rs | 44 ++++++++++++++++++++++++++++++++++-
4 files changed, 124 insertions(+), 12 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 1835937..951c186 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1408,8 +1408,8 @@ menubar addressed through `GUIDES_MENU` / `GUIDE_*` so no item slid onto
another's action, while old files carrying `show_cube_enabled` still load),
the
scene furniture that is not the geometry); wireframe (switch,
-thickness, opacity); points (Show Points, Point Size, and Group Marker Scale, which
-multiplies it); overlays (Show Point Markers and its size, Show Point
+thickness, opacity); points (Show Points, Point Size, Group Marker Scale, which
+multiplies it, and Pull Arrow Scale); overlays (Show Point Markers and its size, Show Point
Numbers, Show Point Normals — the annotations over the scene's points);
surface (shading, opacity, Show Occluded).
`the_viewport_menu_groups_its_display_rows` holds the
@@ -1512,7 +1512,12 @@ 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.
+head included, is the displacement times **Pull Arrow Scale**
+(`State::pull_arrow_scale`, default 1 — the true vector; a dialog row and a
+viewport-menu slider, 0.25–10x, persisted in the render block beside Group
+Marker Scale). The scale is display only: the sampled pairs are kept
+unscaled on `pull_arrow_pairs`, and `rebuild_pull_arrow_verts` stretches
+each arrow from its fixed base, so a slider drag re-evaluates nothing.
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
diff --git a/src/app.rs b/src/app.rs
index 5d8a048..9b41ffe 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -489,6 +489,9 @@ pub enum ViewportMenuAction {
/// Group Marker Scale, the Selected-Group markers' radius as a multiple
/// of Point Size: the palette row's 0.5–4.
GroupMarkerScaleSlider,
+ /// Pull Arrow Scale, the pull arrows' length as a multiple of the true
+ /// displacement: the palette row's 0.25–10.
+ PullArrowScaleSlider,
/// A "-" row: engraved, inert.
Separator,
}
@@ -1229,6 +1232,10 @@ pub struct RenderSettings {
/// they draw on the same vertices as the Render points, so the ratio is
/// what keeps both legible. Hard-coded at 1.25 until 2026-09-24.
pub group_marker_scale: f32,
+ /// The pull arrows' length as a multiple of the displacement they show.
+ /// 1 draws the true vector; a longer arrow is legible when the pull is
+ /// small beside the model. Display only — the pull itself is untouched.
+ pub pull_arrow_scale: f32,
/// Smooth (vertex-normal) shading of the scene fill, where off is the
/// faceted look the raster pass has always had. Absent in older files:
/// flat.
@@ -1268,6 +1275,8 @@ struct StoredRenderSettings {
point_color: [f32; 3],
#[serde(default = "default_group_marker_scale")]
group_marker_scale: f32,
+ #[serde(default = "default_pull_arrow_scale")]
+ pull_arrow_scale: f32,
#[serde(default)]
smooth_shading: bool,
#[serde(default)]
@@ -1293,6 +1302,7 @@ impl From<StoredRenderSettings> for RenderSettings {
point_size: s.point_size,
point_color: s.point_color,
group_marker_scale: s.group_marker_scale,
+ pull_arrow_scale: s.pull_arrow_scale,
smooth_shading: s.smooth_shading,
show_occluded: s.show_occluded,
}
@@ -1303,6 +1313,10 @@ fn default_group_marker_scale() -> f32 {
1.25
}
+fn default_pull_arrow_scale() -> f32 {
+ 1.0
+}
+
fn default_wire_color() -> [f32; 3] {
[0.0, 0.0, 0.0]
}
@@ -1336,6 +1350,7 @@ impl Default for RenderSettings {
point_size: default_point_size(),
point_color: default_point_color(),
group_marker_scale: default_group_marker_scale(),
+ pull_arrow_scale: default_pull_arrow_scale(),
smooth_shading: false,
show_occluded: false,
}
@@ -2212,6 +2227,10 @@ pub struct State {
/// Selected-Group marker radius as a multiple of `point_size` (a
/// setting row of the dialog; persisted in the render block).
pub group_marker_scale: f32,
+ /// Pull arrow length as a multiple of the true displacement (a setting
+ /// row of the dialog and a viewport-menu slider; persisted in the render
+ /// block).
+ pub pull_arrow_scale: f32,
/// Smooth shading of the scene fill (`toggle_smooth_shading`). While on,
/// `scene_smooth_verts` holds the lit fill and the draw is `prelit`.
pub smooth_shading: bool,
@@ -2253,6 +2272,10 @@ pub struct State {
/// `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>,
+ /// The sampled `(before, after)` pairs the arrows were built from, kept
+ /// so Pull Arrow Scale can re-draw them without re-evaluating the node
+ /// (`rebuild_pull_arrow_verts`).
+ pub pull_arrow_pairs: Vec<(glam::Vec3, glam::Vec3)>,
pub pull_arrows_dirty: bool,
pub pull_arrow_count: u32,
pub last_pull_arrows_key: Option<(String, Vec<(String, String)>, u64)>,
@@ -2485,6 +2508,7 @@ impl State {
point_size: self.point_size,
point_color: self.point_color,
group_marker_scale: self.group_marker_scale,
+ pull_arrow_scale: self.pull_arrow_scale,
smooth_shading: self.smooth_shading,
show_occluded: self.show_occluded,
},
@@ -2556,6 +2580,8 @@ impl State {
self.point_size = r.point_size;
self.point_color = r.point_color;
self.group_marker_scale = r.group_marker_scale;
+ self.pull_arrow_scale = r.pull_arrow_scale;
+ self.rebuild_pull_arrow_verts();
self.smooth_shading = r.smooth_shading;
self.show_occluded = r.show_occluded;
@@ -4629,6 +4655,16 @@ impl State {
decimals: 2,
suffix: "x",
},
+ // A multiple of the true displacement, read as the group
+ // markers' scale is.
+ ViewportMenuAction::PullArrowScaleSlider => MenuSlider {
+ value: self.pull_arrow_scale.clamp(0.25, 10.0),
+ min: 0.25,
+ max: 10.0,
+ step: 0.25,
+ decimals: 2,
+ suffix: "x",
+ },
_ => return None,
})
}
@@ -4658,6 +4694,10 @@ impl State {
self.group_marker_scale = v.clamp(0.5, 4.0);
self.rebuild_group_marker_verts();
}
+ ViewportMenuAction::PullArrowScaleSlider => {
+ self.pull_arrow_scale = v.clamp(0.25, 10.0);
+ self.rebuild_pull_arrow_verts();
+ }
_ => return,
}
self.viewport_dirty = true;
@@ -4722,11 +4762,13 @@ impl State {
row(&mut options, &mut actions, "Wire Thickness".into(), ViewportMenuAction::WireThicknessSlider);
row(&mut options, &mut actions, "Wire Opacity".into(), ViewportMenuAction::WireOpacitySlider);
- // Points: the Render points, and the group markers sized off them.
+ // Points: the Render points, the group markers sized off them, and
+ // the pull arrows' length, the other selection feedback beside them.
row(&mut options, &mut actions, "-".into(), sep);
row(&mut options, &mut actions, format!("{} {}", mark(self.render_points), label("toggle_render_points", "Show Points")), ViewportMenuAction::Command("toggle_render_points"));
row(&mut options, &mut actions, "Point Size".into(), ViewportMenuAction::PointSizeSlider);
row(&mut options, &mut actions, "Group Marker Scale".into(), ViewportMenuAction::GroupMarkerScaleSlider);
+ row(&mut options, &mut actions, "Pull Arrow Scale".into(), ViewportMenuAction::PullArrowScaleSlider);
// Overlays: the three annotations over the scene's points.
row(&mut options, &mut actions, "-".into(), sep);
@@ -4796,7 +4838,8 @@ impl State {
| ViewportMenuAction::WireOpacitySlider
| ViewportMenuAction::PointSizeSlider
| ViewportMenuAction::PointMarkerSizeSlider
- | ViewportMenuAction::GroupMarkerScaleSlider => {}
+ | ViewportMenuAction::GroupMarkerScaleSlider
+ | ViewportMenuAction::PullArrowScaleSlider => {}
ViewportMenuAction::Separator => {}
}
}
@@ -5433,7 +5476,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
return;
}
self.last_pull_arrows_key = key;
- self.pull_arrow_verts = match node {
+ self.pull_arrow_pairs = match node {
None => Vec::new(),
Some(node) => {
let (frame, start) = (self.sim_frame(), self.sim_start_frame());
@@ -5444,15 +5487,27 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
};
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)
+ 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]))
+ .collect()
}
};
+ self.rebuild_pull_arrow_verts();
+ }
+
+ /// Build the pull arrows from the kept pairs at the current Pull Arrow
+ /// Scale — the cheap half, with no evaluation, so the menu slider can run
+ /// it on every motion of a drag. Each arrow keeps its base on the point's
+ /// position before the pull and stretches along the pull by the scale.
+ pub(crate) fn rebuild_pull_arrow_verts(&mut self) {
+ let scale = self.pull_arrow_scale;
+ let shown: Vec<(glam::Vec3, glam::Vec3)> =
+ self.pull_arrow_pairs.iter().map(|&(a, b)| (a, a + (b - a) * scale)).collect();
+ // The group markers' warm accent: both are "what the selected node
+ // does", and they read as one feature.
+ self.pull_arrow_verts =
+ crate::geometry::arrow_vertices(&shown, cce_ui::colors::to_linear_rgb([1.0, 0.78, 0.20]));
self.pull_arrows_dirty = true;
}
@@ -5897,6 +5952,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
point_size: settings.render.point_size,
point_color: settings.render.point_color,
group_marker_scale: settings.render.group_marker_scale,
+ pull_arrow_scale: settings.render.pull_arrow_scale,
+ pull_arrow_pairs: Vec::new(),
smooth_shading: settings.render.smooth_shading,
show_occluded: settings.render.show_occluded,
sorted_fill_key: None,
diff --git a/src/dialog.rs b/src/dialog.rs
index 16e8a5f..6fbc50a 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1317,6 +1317,9 @@ pub const SETTINGS: &[Setting] = &[
// how much larger they are — 1.25 was the hard-coded ratio until
// 2026-09-24.
Setting::field("Group Marker Scale", "group_marker_scale", Ctl::Slider { min: 0.5, max: 4.0, dec: 2 }),
+ // The pull arrows' length over the true displacement; 1 is the vector
+ // as it is.
+ Setting::field("Pull Arrow Scale", "pull_arrow_scale", Ctl::Slider { min: 0.25, max: 10.0, dec: 2 }),
Setting::field("Point Marker Size", "point_marker_size", Ctl::Spin { min: 5.0, max: 100.0, unit: 1000.0 }),
Setting::field("Point Marker Color", "point_marker_color", Ctl::Color),
Setting::field("Grid Color", "grid_color", Ctl::Color),
@@ -1780,6 +1783,7 @@ impl State {
"wire_opacity" => self.wire_opacity,
"point_size" => self.point_size,
"group_marker_scale" => self.group_marker_scale,
+ "pull_arrow_scale" => self.pull_arrow_scale,
_ => 0.0,
}
}
@@ -1848,6 +1852,10 @@ impl State {
"wire_opacity" => self.wire_opacity = v,
"point_size" => self.point_size = v,
"group_marker_scale" => self.group_marker_scale = v,
+ "pull_arrow_scale" => {
+ self.pull_arrow_scale = v;
+ self.rebuild_pull_arrow_verts();
+ }
_ => {}
}
}
diff --git a/src/main.rs b/src/main.rs
index 45a754b..fe87a1c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2000,7 +2000,7 @@ mod tests {
assert_eq!(groups[2], vec![A::Command("toggle_wireframe"), A::WireThicknessSlider, A::WireOpacitySlider]);
assert_eq!(
groups[3],
- vec![A::Command("toggle_render_points"), A::PointSizeSlider, A::GroupMarkerScaleSlider]
+ vec![A::Command("toggle_render_points"), A::PointSizeSlider, A::GroupMarkerScaleSlider, A::PullArrowScaleSlider]
);
assert_eq!(
groups[4],
@@ -2334,6 +2334,46 @@ mod tests {
assert!(state.overlay_marker_points.is_empty());
}
+ /// Pull Arrow Scale stretches the pull arrows along the pull from their
+ /// kept pairs, base fixed — 1 by default, the true vector — and is a
+ /// viewport-menu slider under Group Marker Scale.
+ #[test]
+ fn pull_arrow_scale_stretches_the_arrows_from_their_base() {
+ use crate::app::ViewportMenuAction as A;
+ use crate::window::WindowEvent;
+ use cce_ui::widget::{context_menu, MouseScrollDelta};
+ assert_eq!(crate::app::DesignSettings::default().render.pull_arrow_scale, 1.0);
+ assert_eq!(crate::app::DesignSettings::from_kdl_str("").render.pull_arrow_scale, 1.0, "absent means 1");
+
+ let mut state = State::new(false);
+ state.pull_arrow_scale = 1.0;
+ let (a, b) = (glam::Vec3::new(0.0, 1.0, 0.0), glam::Vec3::new(0.0, 1.06, 0.0));
+ state.pull_arrow_pairs = vec![(a, b)];
+ state.rebuild_pull_arrow_verts();
+ // The shaft is the first stroke: from the base to the tip.
+ let shaft = |state: &State| (state.pull_arrow_verts[0].position, state.pull_arrow_verts[1].position);
+ assert_eq!(shaft(&state).0, a.to_array(), "the base stays on the point");
+ assert!((shaft(&state).1[1] - 1.06).abs() < 1e-6, "scale 1 is the true vector");
+
+ state.cursor_x = 300.0;
+ state.cursor_y = 200.0;
+ state.open_viewport_context_menu();
+ let i = state.viewport_menu_actions.iter().position(|a| *a == A::PullArrowScaleSlider).expect("a Pull Arrow Scale row");
+ let sl = context_menu::slider(i).expect("a slider");
+ assert_eq!((sl.min, sl.max, sl.step, sl.suffix), (0.25, 10.0, 0.25, "x"));
+ assert_eq!(sl.readout(), "1.00x");
+
+ state.cursor_x = context_menu::x() + 20.0;
+ state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+ state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 15.0) });
+ let k = state.pull_arrow_scale;
+ assert!(k > 1.0, "the wheel raised the scale: {k}");
+ assert_eq!(shaft(&state).0, a.to_array());
+ assert!((shaft(&state).1[1] - (1.0 + 0.06 * k)).abs() < 1e-5, "the tip stretched to {:?} at {k}x", shaft(&state).1);
+ assert_eq!(state.pull_arrow_pairs, vec![(a, b)], "the kept pairs are the measurement, never scaled");
+ context_menu::hide();
+ }
+
/// Group Marker Scale is a viewport-menu slider over the palette row's
/// 0.5–4, re-sizing the Selected-Group markers from their kept members
/// at Point Size x the scale — no re-evaluation of the Group node.
@@ -5732,6 +5772,7 @@ mod tests {
a.point_size = 0.05;
a.point_color = [0.0, 1.0, 0.0];
a.group_marker_scale = 2.5;
+ a.pull_arrow_scale = 4.0;
a.smooth_shading = true;
a.show_occluded = true;
a.save_settings();
@@ -5769,6 +5810,7 @@ mod tests {
assert!(back.render.render_points);
assert!(close(back.render.point_size, 0.05));
assert!(close(back.render.group_marker_scale, 2.5));
+ assert!(close(back.render.pull_arrow_scale, 4.0));
assert!(back.render.smooth_shading);
assert!(back.render.show_occluded);
}