Curvature comb: Comb length and Teeth controls in the tool

The comb tool passed only the pick, so a comb's length and tooth count
could not be changed. While the tool runs, a card at the top left of the
view has Comb length (0.25x to 8x the automatic length, default 1x) and
Teeth (4 to 200, default 40), with plain-word tooltips; moving either
runs the comb again on the curves and edges already shown. New strings
in assets/locales/pending/curvature-comb-options.json.

Test: comb_options_draw_the_combs_shown_again drags both sliders: the
shown comb's teeth grow to 8 times their length, then number 4; the
drags pick nothing, and the card goes with the tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 06:51:09 -04:00
committed by Irakli Kourtis
parent 9142c6c5b5
commit 1af128a69a
3 changed files with 207 additions and 11 deletions
@@ -0,0 +1,44 @@
{
"en": {
"ui.comb_length": "Comb length",
"ui.comb_length_tip": "How long the teeth are: a multiple of the automatic length, which makes the longest tooth a fifth of the size of the curves shown.",
"ui.comb_teeth": "Teeth",
"ui.comb_teeth_tip": "How many teeth each comb has, evenly spaced along the curve. More teeth show more detail of how the curvature changes."
},
"ar": {
"ui.comb_length": "طول المشط",
"ui.comb_length_tip": "طول الأسنان: مضاعف للطول التلقائي الذي يجعل أطول سنّ خُمس حجم المنحنيات المعروضة.",
"ui.comb_teeth": "الأسنان",
"ui.comb_teeth_tip": "عدد أسنان كل مشط، موزّعة بالتساوي على طول المنحنى. كلما زادت الأسنان ظهرت تفاصيل أكثر عن تغيّر الانحناء."
},
"de": {
"ui.comb_length": "Kammlänge",
"ui.comb_length_tip": "Wie lang die Zähne sind: ein Vielfaches der automatischen Länge, bei der der längste Zahn ein Fünftel der Größe der gezeigten Kurven ist.",
"ui.comb_teeth": "Zähne",
"ui.comb_teeth_tip": "Wie viele Zähne jeder Kamm hat, gleichmäßig entlang der Kurve verteilt. Mehr Zähne zeigen genauer, wie sich die Krümmung ändert."
},
"he": {
"ui.comb_length": "אורך המסרק",
"ui.comb_length_tip": "אורך השיניים: כפולה של האורך האוטומטי, שבו השן הארוכה ביותר היא חמישית מגודל העקומות המוצגות.",
"ui.comb_teeth": "שיניים",
"ui.comb_teeth_tip": "כמה שיניים יש לכל מסרק, במרווחים שווים לאורך העקומה. יותר שיניים מראות בפירוט רב יותר איך העקמומיות משתנה."
},
"ja": {
"ui.comb_length": "コームの長さ",
"ui.comb_length_tip": "歯の長さ: 自動の長さの倍率です。自動の長さでは、いちばん長い歯が表示中の曲線の大きさの 5 分の 1 になります。",
"ui.comb_teeth": "歯の数",
"ui.comb_teeth_tip": "各コームの歯の数です。曲線に沿って等間隔に並びます。歯を増やすと、曲率の変わり方がより細かく見えます。"
},
"ko": {
"ui.comb_length": "빗 길이",
"ui.comb_length_tip": "빗살의 길이: 자동 길이의 배수입니다. 자동 길이에서는 가장 긴 빗살이 표시된 곡선 크기의 5분의 1이 됩니다.",
"ui.comb_teeth": "빗살 수",
"ui.comb_teeth_tip": "각 빗의 빗살 수이며, 곡선을 따라 같은 간격으로 놓입니다. 빗살이 많을수록 곡률이 어떻게 바뀌는지 더 자세히 보입니다."
},
"zh": {
"ui.comb_length": "梳长",
"ui.comb_length_tip": "梳齿的长度:自动长度的倍数。自动长度使最长的梳齿为所示曲线尺寸的五分之一。",
"ui.comb_teeth": "梳齿数",
"ui.comb_teeth_tip": "每个梳的梳齿数,沿曲线均匀分布。梳齿越多,越能看清曲率如何变化。"
}
}
+120 -11
View File
@@ -77,8 +77,11 @@ thread_local! {
/// The inference the cursor snapped to last (label, sketch point), for the hint.
static SNAP: RefCell<Option<(&'static str, Vec2)>> = const { RefCell::new(None) };
static PREV: Cell<Option<Vec2>> = const { Cell::new(None) };
/// Curvature comb teeth (foot, tip) shown while the comb tool is active.
static COMBS: RefCell<Vec<Vec<[Vec3; 2]>>> = const { RefCell::new(Vec::new()) };
/// The curvature combs shown while the comb tool is active: each pick (the command's
/// parameters for the curve or edge clicked) and its combs' teeth (foot, tip).
static COMBS: RefCell<Vec<(Value, Vec<Vec<[Vec3; 2]>>)>> = const { RefCell::new(Vec::new()) };
/// The comb tool's options: comb length (a multiplier on the automatic length) and teeth.
static COMB_OPTIONS: Cell<CombOptions> = const { Cell::new(CombOptions::DEFAULT) };
/// The image file a Canvas or Decal tool places.
static IMAGE: RefCell<Option<String>> = const { RefCell::new(None) };
/// Analysis markers shown while an analysis tool is active: (where, label).
@@ -364,6 +367,117 @@ fn v3(v: &Value) -> Option<Vec3> {
Some(Vec3::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?, v.get(2)?.as_f64()?))
}
/// The Curvature Comb tool's options: how long the teeth are, as a multiple of the automatic
/// length (`inspect.curvature_comb`'s `length`), and how many each comb has (`density`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct CombOptions {
pub length: f64,
pub teeth: usize,
}
impl CombOptions {
const DEFAULT: CombOptions = CombOptions { length: 1.0, teeth: 40 };
const LENGTH: std::ops::RangeInclusive<f64> = 0.25..=8.0;
const TEETH: std::ops::RangeInclusive<usize> = 4..=200;
}
/// The comb tool's options now.
pub(crate) fn comb_options() -> CombOptions {
COMB_OPTIONS.with(Cell::get)
}
/// The combs shown, each as its teeth (foot, tip).
#[cfg(test)]
pub(crate) fn shown_combs() -> Vec<Vec<[Vec3; 2]>> {
COMBS.with(|c| c.borrow().iter().flat_map(|(_, combs)| combs.clone()).collect())
}
/// Run the comb command for `pick` with the tool's options: its combs' teeth (foot, tip), or
/// nothing when it fails (the status line says why).
fn comb_teeth(app: &mut SolveApp, pick: &Value) -> Option<Vec<Vec<[Vec3; 2]>>> {
let options = comb_options();
let mut params = pick.clone();
params["density"] = json!(options.teeth);
params["length"] = json!(options.length);
let v = app.run("inspect.curvature_comb", params).ok()?;
Some(
v["combs"]
.as_array()
.into_iter()
.flatten()
.map(|comb| comb["teeth"].as_array().into_iter().flatten().filter_map(|t| Some([v3(t.get(0)?)?, v3(t.get(1)?)?])).collect())
.collect(),
)
}
/// Show the comb of `pick` (the comb command's parameters for a curve or an edge) beside those
/// already shown.
fn add_comb(app: &mut SolveApp, pick: Value) {
if let Some(teeth) = comb_teeth(app, &pick) {
COMBS.with(|c| c.borrow_mut().push((pick, teeth)));
}
}
/// Change the comb tool's options and run the comb again on every curve already shown.
pub(crate) fn set_comb_options(app: &mut SolveApp, options: CombOptions) {
let options = CombOptions {
length: options.length.clamp(*CombOptions::LENGTH.start(), *CombOptions::LENGTH.end()),
teeth: options.teeth.clamp(*CombOptions::TEETH.start(), *CombOptions::TEETH.end()),
};
if COMB_OPTIONS.with(|o| o.replace(options)) == options {
return;
}
let picks: Vec<Value> = COMBS.with(|c| c.borrow().iter().map(|(pick, _)| pick.clone()).collect());
let shown: Vec<(Value, Vec<Vec<[Vec3; 2]>>)> = picks.into_iter().filter_map(|pick| comb_teeth(app, &pick).map(|teeth| (pick, teeth))).collect();
COMBS.with(|c| *c.borrow_mut() = shown);
}
/// The comb tool's options at the top left of the free viewport while it runs (the analysis and
/// continuity legends keep the lower left): Comb length and Teeth. Changing either draws the
/// combs shown again.
fn comb_options_card(app: &mut SolveApp, ctx: &egui::Context, proj: &Proj) {
if app.tool.as_ref().map(|t| t.cmd) != Some("inspect.curvature_comb") {
return;
}
let tk = crate::theme::Tokens::get();
let available = crate::shell::workspace_content(app, crate::shell::content_rect(app, proj.rect));
let width = tk.form_card_width.min(available.width() - 2.0 * tk.space_16).max(0.0);
let mut options = comb_options();
let (length_tip, teeth_tip) = (app.tr("ui.comb_length_tip").to_string(), app.tr("ui.comb_teeth_tip").to_string());
let shown = crate::ui_kit::Container::new(ctx, crate::ui_kit::Surface::Canvas)
.area(egui::Id::new("sc_comb_options"))
.fixed_pos(available.left_top() + egui::vec2(tk.space_16, tk.space_16))
.order(egui::Order::Foreground)
.show(ctx, |ui| {
crate::ui_kit::Container::popup_frame(ui.style()).fill(tk.dialog_body).inner_margin(tk.space_8).show(ui, |ui| {
crate::ui_kit::scope(ui, |ui| {
ui.set_width(width);
let length = crate::ui_kit::row(ui, "ui.comb_length", |ui| {
let format = |v: f64| format!("{v:.2}\u{d7}");
crate::ui_kit::slider(
ui,
&mut options.length,
CombOptions::LENGTH,
crate::ui_kit::Slider { step: Some(0.25), format, ..Default::default() },
)
});
length.on_hover_text(&length_tip);
let teeth = crate::ui_kit::row(ui, "ui.comb_teeth", |ui| {
crate::ui_kit::slider(
ui,
&mut options.teeth,
CombOptions::TEETH,
crate::ui_kit::Slider { integer: true, format: |v| format!("{v:.0}"), ..Default::default() },
)
});
teeth.on_hover_text(&teeth_tip);
});
});
});
crate::scenario::publish_rect("comb_options", shown.response.rect);
set_comb_options(app, options);
}
/// Curvature combs: teeth and the envelope through their tips.
fn combs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
if app.tool.as_ref().map(|t| t.cmd) != Some("inspect.curvature_comb") {
@@ -372,7 +486,7 @@ fn combs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
}
let tk = crate::theme::Tokens::get();
COMBS.with(|c| {
for comb in c.borrow().iter() {
for comb in c.borrow().iter().flat_map(|(_, combs)| combs) {
let mut tips: Vec<Pos2> = Vec::new();
for [a, b] in comb {
let (Some(p), Some(q)) = (proj.to_screen(*a), proj.to_screen(*b)) else { continue };
@@ -837,14 +951,8 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
Hit::Edge { mid, .. } => Some(json!({"edges": [[mid.x, mid.y, mid.z]]})),
_ => None,
});
if let Some(p) = params
&& let Ok(v) = app.run(cmd, p)
{
for comb in v["combs"].as_array().into_iter().flatten() {
let teeth: Vec<[Vec3; 2]> =
comb["teeth"].as_array().into_iter().flatten().filter_map(|t| Some([v3(t.get(0)?)?, v3(t.get(1)?)?])).collect();
COMBS.with(|c| c.borrow_mut().push(teeth));
}
if let Some(p) = params {
add_comb(app, p);
}
}
Mode::ModelRef if cmd == "inspect.isocurve" => {
@@ -1528,6 +1636,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)));
snap_hint(app, painter, proj);
combs(app, painter, proj);
comb_options_card(app, ui.ctx(), proj);
if DEFERRED.with(|d| d.replace(false)) {
analysis(app, "inspect.center_of_mass", json!({}));
}
@@ -607,3 +607,46 @@ fn frame_rate_readout_stays_beside_a_right_pane() {
in_corner(&h);
}
}
/// The Curvature Comb tool's options card (Comb length, Teeth) shows while the tool runs, and
/// moving either slider draws the combs already shown again: their teeth as long as the length
/// says (a multiple of the automatic length), as many as Teeth says. Using the card picks nothing.
#[test]
fn comb_options_draw_the_combs_shown_again() {
use crate::sketch_tools::{comb_options, shown_combs};
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("solid.cylinder", json!({"radius": 10, "height": 20})).unwrap();
h.settle();
h.step(&json!({"start": "inspect.curvature_comb"})).unwrap();
h.frame();
assert!(crate::scenario::rect_of("comb_options").is_some(), "the options card is drawn");
assert_eq!((comb_options().length, comb_options().teeth), (1.0, 40));
h.step(&json!({"click": {"world": [10, 0, 20]}})).unwrap();
let combs = shown_combs();
assert_eq!(combs.len(), 1, "{:?}", h.app.status);
assert_eq!(combs[0].len(), 40);
let tooth = |c: &[[solidcraft_engine::geom::Vec3; 2]]| c[0][0].dist(c[0][1]);
let auto = tooth(&combs[0]);
assert!(auto > 0.0);
let slider = |name: &str| crate::scenario::rect_of(&format!("dialog:slider_track:{name}")).unwrap_or_else(|| panic!("no slider {name}"));
// Comb length to its right end: 8 × the automatic length.
let track = slider("ui.comb_length");
h.step(&json!({"drag": [[track.center().x, track.center().y], [track.right() + 40.0, track.center().y]]})).unwrap();
h.frame();
assert_eq!(comb_options().length, 8.0);
let combs = shown_combs();
assert_eq!(combs.len(), 1, "the card's drag picked nothing");
assert!((tooth(&combs[0]) - 8.0 * auto).abs() < 1e-9 * auto, "{} vs 8 × {auto}", tooth(&combs[0]));
// Teeth to its left end: 4.
let track = slider("ui.comb_teeth");
h.step(&json!({"drag": [[track.center().x, track.center().y], [track.left() - 40.0, track.center().y]]})).unwrap();
h.frame();
assert_eq!(comb_options().teeth, 4);
let combs = shown_combs();
assert_eq!((combs.len(), combs[0].len()), (1, 4));
// The tool ends: the combs and the card go.
h.step(&json!({"key": "Escape"})).unwrap();
h.frames(2);
assert!(shown_combs().is_empty() && crate::scenario::rect_of("comb_options").is_none());
}