Curvature comb: every comb shown at one scale, kept by the command

inspect.curvature_comb keeps what it shows in the session (Session::comb):
add: true combs the new picks together with those shown, so every comb
shares one scale and tooth lengths compare across curves (a curvature jump
where two edges meet shows); with nothing picked it draws those shown again
with new options; clear: true ends it. Each sketch curve keeps its own
sketch. The tool runs it once per pick and per Comb length / Teeth change,
and the viewport draws the session's combs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 07:14:01 -04:00
committed by Irakli Kourtis
parent 9851718ea3
commit 0b7468729f
4 changed files with 216 additions and 82 deletions
+123 -32
View File
@@ -6,7 +6,7 @@ use solidcraft_geom::{Aabb3, Seg2, Vec2, Vec3};
use super::CommandSpec;
use crate::params::{bad, bool_, num, string_list, vec3};
use crate::{EngineError, Result, Session};
use crate::{CombPick, CombView, EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("inspect.curvature_comb", "Curvature Comb Analysis", curvature_comb)
@@ -19,10 +19,13 @@ pub static COMMANDS: &[CommandSpec] = &[
combs along the face's iso grid); density?: teeth per curve, 2…2000 (default 40; on an edge or an iso curve exactly \
that many, evenly spaced along its parameter); scale?: comb length per unit curvature (default auto: the longest \
tooth a fifth of the combs' size); length?: a multiplier on the automatic scale, 0.01…100 (default 1; ignored with \
`scale`). The curvature is exact: from each sketch curve, edge curve (line, circle, ellipse, B-spline, NURBS, \
`scale`); add?: true keeps the combs shown and adds these, every comb again at one scale (with nothing picked it \
draws those shown again with this call's options, or turns the analysis on with none); clear?: true turns the \
analysis off. Every comb of a call shares one scale, so tooth lengths compare across curves; the viewport draws \
them. The curvature is exact: from each sketch curve, edge curve (line, circle, ellipse, B-spline, NURBS, \
intersection curve) or face surface's own derivatives, never from the display mesh; a face's teeth are the \
surface's normal curvature along each iso curve, along the surface normal. → combs: [{curve, teeth: [[foot, tip]], \
envelope: [tip], max_curvature, min_radius}], scale",
envelope: [tip], max_curvature, min_radius}] (those shown, with `add`), scale",
),
CommandSpec::new("inspect.minimum_radius", "Minimum Radius Analysis", minimum_radius)
.at("SKETCH", "INSPECT")
@@ -100,18 +103,62 @@ fn edge_teeth(st: &solidcraft_doc::ModelState, at: Vec3, density: usize) -> Resu
}
}
/// Most curves Curvature Comb Analysis shows at once.
const MAX_COMB_PICKS: usize = 1000;
/// The combs of one pick, each as (id, teeth (foot, curvature, unit normal)): one for a curve or
/// an edge, one per iso curve for a face.
fn pick_teeth(s: &Session, pick: &CombPick, density: usize) -> Result<Vec<(String, Vec<(Vec3, f64, Vec3)>)>> {
let cmd = "inspect.curvature_comb";
match pick {
CombPick::Curve { sketch, curve } => {
let st = s.model.state();
let ss = st.sketch(*sketch).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
let ci = ss.sketch.curve_index(curve).ok_or_else(|| bad(cmd, format!("unknown curve `{curve}`")))?;
let segs = ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci));
let teeth = teeth_2d(&segs, density).into_iter().map(|(q, k, n)| (ss.plane.to_world(q), k, ss.plane.dir_to_world(n))).collect();
Ok(vec![(curve.clone(), teeth)])
}
CombPick::Edge { at } => {
let teeth = edge_teeth(&s.model.state(), *at, density)?.ok_or_else(|| bad(cmd, "no edge there"))?;
Ok(vec![(format!("edge@{},{},{}", at.x, at.y, at.z), teeth)])
}
CombPick::Face { at, count } => {
let world = s.world_state()?;
let (body, face) = iso_face_at(s, &world, *at).ok_or_else(|| bad(cmd, "no face there"))?;
let mut out = Vec::new();
for (k, line) in body.iso_lines(face, *count)?.iter().enumerate() {
out.push((format!("face@{},{},{}#{k}", at.x, at.y, at.z), teeth_exact(&crate::kernel::iso_comb(&body.body, face, line, density)?)));
}
Ok(out)
}
}
}
/// `inspect.curvature_comb`: the combs of the picked curves, edges and faces (with `add`, those
/// already shown too), all at one scale, kept so the viewport draws them. View state only.
fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "inspect.curvature_comb";
if bool_(p, "clear").unwrap_or(false) {
s.comb = None;
s.revision += 1;
return Ok(json!({"analysis": "curvature_comb", "cleared": true}));
}
let add = bool_(p, "add").unwrap_or(false);
let shown = if add { s.comb.clone() } else { None };
let density = num(p, "density")
.filter(|d| d.is_finite())
.map(|d| d.clamp(crate::kernel::MIN_COMB_TEETH as f64, crate::kernel::MAX_COMB_TEETH as f64) as usize)
.unwrap_or(40);
.unwrap_or(shown.as_ref().map_or(40, |v| v.density));
let length = match p.get("length") {
None | Some(Value::Null) => 1.0,
None | Some(Value::Null) => shown.as_ref().map_or(1.0, |v| v.length),
Some(v) => v.as_f64().filter(|x| (0.01..=100.0).contains(x)).ok_or_else(|| bad(cmd, "`length` must be a multiplier 0.01…100"))?,
};
let st = s.model.state();
let mut combs: Vec<(String, Vec<(Vec3, f64, Vec3)>)> = Vec::new();
let fixed_scale = match p.get("scale") {
None | Some(Value::Null) => shown.as_ref().and_then(|v| v.fixed_scale),
Some(_) => num(p, "scale").filter(|x| x.is_finite() && *x > 0.0),
};
let mut picks: Vec<CombPick> = Vec::new();
let curves = string_list(p, "curves");
if !curves.is_empty() {
let id = match p.get("sketch") {
@@ -120,19 +167,11 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
_ => s.active_sketch,
}
.ok_or_else(|| bad(cmd, "no such sketch (and none is active)"))?;
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
for c in curves.iter().take(1000) {
let ci = ss.sketch.curve_index(c).ok_or_else(|| bad(cmd, format!("unknown curve `{c}`")))?;
let segs = ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci));
let teeth = teeth_2d(&segs, density).into_iter().map(|(q, k, n)| (ss.plane.to_world(q), k, ss.plane.dir_to_world(n))).collect();
combs.push((c.clone(), teeth));
}
picks.extend(curves.iter().take(MAX_COMB_PICKS).map(|c| CombPick::Curve { sketch: id, curve: c.clone() }));
}
if let Some(edges) = p.get("edges").and_then(Value::as_array) {
for e in edges.iter().take(1000) {
let at = vec3(e).ok_or_else(|| bad(cmd, "`edges` must be points [x,y,z]"))?;
let teeth = edge_teeth(&st, at, density)?.ok_or_else(|| bad(cmd, "no edge there"))?;
combs.push((format!("edge@{},{},{}", at.x, at.y, at.z), teeth));
for e in edges.iter().take(MAX_COMB_PICKS) {
picks.push(CombPick::Edge { at: vec3(e).ok_or_else(|| bad(cmd, "`edges` must be points [x,y,z]"))? });
}
}
if let Some(faces) = p.get("faces") {
@@ -143,39 +182,57 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
num(p, "count").filter(|c| c.is_finite()).map(|c| c.clamp(1.0, 50.0) as usize).ok_or_else(|| bad(cmd, "`count` must be a number"))?
}
};
let world = s.world_state()?;
for v in faces.iter().take(100) {
let at = vec3(v).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?;
let (body, face) = iso_face_at(s, &world, at).ok_or_else(|| bad(cmd, "no face there"))?;
for (k, line) in body.iso_lines(face, count)?.iter().enumerate() {
let teeth = teeth_exact(&crate::kernel::iso_comb(&body.body, face, line, density)?);
combs.push((format!("face@{},{},{}#{k}", at.x, at.y, at.z), teeth));
}
picks.push(CombPick::Face { at: vec3(v).ok_or_else(|| bad(cmd, "`faces` must list points [x,y,z]"))?, count });
}
}
if combs.is_empty() {
if picks.is_empty() && !add {
return Err(bad(cmd, "give `curves`, `edges` or `faces`"));
}
let shown_picks = shown.map(|v| v.picks).unwrap_or_default();
if shown_picks.len() + picks.len() > MAX_COMB_PICKS {
return Err(bad(cmd, format!("at most {MAX_COMB_PICKS} curves can be combed at once")));
}
// The curves shown are combed again with these options; one gone with a model change since
// (its sketch, edge or face no longer there) is dropped. A new pick must be there.
let mut kept: Vec<CombPick> = Vec::new();
let mut combs: Vec<(String, Vec<(Vec3, f64, Vec3)>)> = Vec::new();
for pick in shown_picks {
if let Ok(teeth) = pick_teeth(s, &pick, density) {
combs.extend(teeth);
kept.push(pick);
}
}
for pick in picks {
combs.extend(pick_teeth(s, &pick, density)?);
kept.push(pick);
}
// One scale for all combs: the longest tooth is a fifth of the largest curve's size.
let kmax = combs.iter().flat_map(|c| c.1.iter().map(|t| t.1.abs())).fold(0.0_f64, f64::max);
let bounds: Aabb3 = combs.iter().flat_map(|c| c.1.iter().map(|t| t.0)).collect();
let diag = bounds.diagonal();
let auto = if kmax > 1e-12 && !bounds.is_empty() && diag.is_finite() { 0.2 * diag / kmax } else { 1.0 };
let scale = num(p, "scale").filter(|x| x.is_finite() && *x > 0.0).unwrap_or(auto * length);
let scale = fixed_scale.unwrap_or(auto * length);
let mut drawn = Vec::with_capacity(combs.len());
let out: Vec<Value> = combs
.iter()
.map(|(id, teeth)| {
let lines: Vec<[[f64; 3]; 2]> = teeth.iter().map(|(q, k, n)| [(*q).into(), (*q - *n * (k * scale)).into()]).collect();
let lines: Vec<[Vec3; 2]> = teeth.iter().map(|(q, k, n)| [*q, *q - *n * (k * scale)]).collect();
let kmax_c = teeth.iter().map(|t| t.1.abs()).fold(0.0_f64, f64::max);
json!({
let reply: Vec<[[f64; 3]; 2]> = lines.iter().map(|[a, b]| [(*a).into(), (*b).into()]).collect();
let value = json!({
"curve": id,
"teeth": lines,
"envelope": lines.iter().map(|l| l[1]).collect::<Vec<_>>(),
"envelope": reply.iter().map(|l| l[1]).collect::<Vec<_>>(),
"teeth": reply,
"max_curvature": kmax_c,
"min_radius": if kmax_c > 1e-12 { Value::from(1.0 / kmax_c) } else { Value::Null },
})
});
drawn.push(lines);
value
})
.collect();
s.comb = Some(CombView { picks: kept, density, length, fixed_scale, scale, teeth: drawn });
s.revision += 1;
Ok(json!({"combs": out, "scale": scale}))
}
@@ -475,6 +532,40 @@ mod tests {
assert!(s.execute("inspect.curvature_comb", &json!({"faces": [[58, 0, 5]], "count": "x"})).is_err());
}
/// The combs shown are kept in the session: `add` adds to them and draws them all again at
/// one scale (an R5 circle's teeth twice an R10 circle's), with nothing picked it takes new
/// options, a sketch curve keeps its own sketch, and `clear` turns the analysis off.
#[test]
fn added_combs_share_one_scale_and_clear_turns_them_off() {
use solidcraft_geom::Vec3;
let mut s = Session::default();
s.execute("solid.cylinder", &json!({"radius": 5, "height": 10})).unwrap();
s.execute("solid.cylinder", &json!({"base": [40, 0, 0], "radius": 10, "height": 10})).unwrap();
let one = s.execute("inspect.curvature_comb", &json!({"edges": [[0, 5, 10]], "density": 8})).unwrap();
let both = s.execute("inspect.curvature_comb", &json!({"edges": [[40, 10, 10]], "add": true})).unwrap();
let shown = s.comb.as_ref().unwrap();
assert_eq!((shown.picks.len(), shown.teeth.len(), shown.density), (2, 2, 8), "the first call's density is kept");
assert_eq!(both["combs"].as_array().unwrap().len(), 2);
let tooth = |c: &[[Vec3; 2]]| c[0][0].dist(c[0][1]);
assert!((tooth(&shown.teeth[0]) / tooth(&shown.teeth[1]) - 2.0).abs() < 1e-9);
assert_ne!(one["scale"], both["scale"], "the scale is the pair's, not the first comb's");
let longer = s.execute("inspect.curvature_comb", &json!({"add": true, "length": 2})).unwrap();
assert!((longer["scale"].as_f64().unwrap() - 2.0 * both["scale"].as_f64().unwrap()).abs() < 1e-9);
assert_eq!(s.comb.as_ref().unwrap().picks.len(), 2);
// A sketch curve keeps its sketch after another becomes active.
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
let c = s.execute("sketch.circle.center", &json!({"center": [0, 30], "radius": 5})).unwrap()["curves"][0].clone();
s.execute("inspect.curvature_comb", &json!({"curves": [c], "add": true})).unwrap();
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("sketch.create", &json!({"plane": "XZ"})).unwrap();
let r = s.execute("inspect.curvature_comb", &json!({"add": true})).unwrap();
assert_eq!(r["combs"].as_array().unwrap().len(), 3, "{r}");
s.execute("inspect.curvature_comb", &json!({"clear": true})).unwrap();
assert!(s.comb.is_none());
let on = s.execute("inspect.curvature_comb", &json!({"add": true})).unwrap();
assert!(on["combs"].as_array().unwrap().is_empty() && s.comb.as_ref().is_some_and(|v| v.picks.is_empty()));
}
/// Each tooth of a comb reply as (foot, curvature): the tooth's length over the scale.
fn tooth_curvatures(r: &Value, comb: usize) -> Vec<([f64; 3], f64)> {
let scale = r["scale"].as_f64().unwrap();
+34
View File
@@ -439,6 +439,9 @@ pub struct Session {
/// The faces Isocurve Analysis shows the isoparametric curves of, for the viewport to draw
/// ([`Session::isocurve_lines`]). View state: not part of the design and not an undo step.
pub isocurves: Vec<IsoPick>,
/// The curvature combs `inspect.curvature_comb` shows, for the viewport to draw; `None` when
/// the analysis is off. View state: not part of the design and not an undo step.
pub comb: Option<CombView>,
/// What is hidden or shown in the view (`browser.visibility`): saved with the design and an
/// undo step of its own ([`Snapshot::visibility`]). A design brings its saved visibility when
/// it opens (`Session::new`).
@@ -538,6 +541,35 @@ pub struct IsoPick {
pub count: usize,
}
/// What Curvature Comb Analysis shows: the picks, the options every comb is drawn with, and
/// the teeth drawn, all at one scale so their lengths compare. View state only.
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
pub struct CombView {
pub picks: Vec<CombPick>,
/// Teeth per curve.
pub density: usize,
/// The multiplier on the automatic scale.
pub length: f64,
/// A fixed comb length per unit curvature, in place of the automatic one.
pub fixed_scale: Option<f64>,
/// The comb length per unit curvature the teeth are drawn at, shared by every comb.
pub scale: f64,
/// Each comb's teeth as (foot, tip), in world coordinates.
pub teeth: Vec<Vec<[Vec3; 2]>>,
}
/// A curve Curvature Comb Analysis shows, as it was picked (found again whenever it is drawn).
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CombPick {
/// A sketch curve.
Curve { sketch: u64, curve: String },
/// The body edge whose exact curve passes nearest `at`.
Edge { at: Vec3 },
/// The face at `at`, combed along `count` iso curves each way.
Face { at: Vec3, count: usize },
}
impl SurfaceAnalysis {
/// How a picture shades the faces for this analysis.
pub fn mode(self) -> render::AnalysisMode {
@@ -633,6 +665,7 @@ impl Session {
analysis: None,
continuity: Vec::new(),
isocurves: Vec::new(),
comb: None,
checkpoints: Vec::new(),
next_checkpoint: 1,
replacements: 0,
@@ -1443,6 +1476,7 @@ impl Session {
analysis: None,
continuity: Vec::new(),
isocurves: Vec::new(),
comb: None,
checkpoints: Vec::new(),
next_checkpoint: 1,
replacements: 0,
+28 -46
View File
@@ -77,9 +77,6 @@ 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) };
/// 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.
@@ -388,37 +385,23 @@ pub(crate) fn comb_options() -> CombOptions {
/// 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())
pub(crate) fn shown_combs(app: &SolveApp) -> Vec<Vec<[Vec3; 2]>> {
app.session.comb.as_ref().map(|v| v.teeth.clone()).unwrap_or_default()
}
/// 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]>>> {
/// Comb `picks` (the comb command's parameters for curves or edges; none: draw those shown
/// again) beside the combs already shown, every comb at one scale with the tool's options. A
/// failure leaves those shown as they were (the status line says why).
fn comb(app: &mut SolveApp, picks: Value) {
let options = comb_options();
let mut params = pick.clone();
let mut params = picks;
params["add"] = json!(true);
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(),
)
let _ = app.run("inspect.curvature_comb", params);
}
/// 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.
/// Change the comb tool's options and draw the combs shown again with them.
pub(crate) fn set_comb_options(app: &mut SolveApp, options: CombOptions) {
let options = CombOptions {
length: options.length.clamp(*CombOptions::LENGTH.start(), *CombOptions::LENGTH.end()),
@@ -427,9 +410,9 @@ pub(crate) fn set_comb_options(app: &mut SolveApp, options: CombOptions) {
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);
if app.session.comb.is_some() {
comb(app, json!({}));
}
}
/// The comb tool's options at the top left of the free viewport while it runs (the analysis and
@@ -478,24 +461,19 @@ fn comb_options_card(app: &mut SolveApp, ctx: &egui::Context, proj: &Proj) {
set_comb_options(app, options);
}
/// Curvature combs: teeth and the envelope through their tips.
/// Curvature combs (`Session::comb`): 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") {
COMBS.with(|c| c.borrow_mut().clear());
return;
}
let Some(shown) = &app.session.comb else { return };
let tk = crate::theme::Tokens::get();
COMBS.with(|c| {
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 };
painter.line_segment([p, q], Stroke::new(1.0, tk.comb_teeth));
tips.push(q);
}
painter.add(egui::Shape::line(tips, Stroke::new(1.4, tk.comb_envelope)));
for comb in &shown.teeth {
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 };
painter.line_segment([p, q], Stroke::new(1.0, tk.comb_teeth));
tips.push(q);
}
});
painter.add(egui::Shape::line(tips, Stroke::new(1.4, tk.comb_envelope)));
}
}
/// Snap a cursor point on the sketch plane to what is inferred while drawing: a crossing of
@@ -952,7 +930,7 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
_ => None,
});
if let Some(p) = params {
add_comb(app, p);
comb(app, p);
}
}
Mode::ModelRef if cmd == "inspect.isocurve" => {
@@ -1635,6 +1613,10 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
SHIFT.with(|c| c.set(iso.contains(ModAction::IsoCurveAlternate)));
glyphs(app, painter, proj, ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)));
snap_hint(app, painter, proj);
// The combs go with the comb tool.
if app.session.comb.is_some() && app.tool.as_ref().map(|t| t.cmd) != Some("inspect.curvature_comb") {
let _ = app.run("inspect.curvature_comb", json!({"clear": true}));
}
combs(app, painter, proj);
comb_options_card(app, ui.ctx(), proj);
if DEFERRED.with(|d| d.replace(false)) {
+31 -4
View File
@@ -623,7 +623,7 @@ fn comb_options_draw_the_combs_shown_again() {
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();
let combs = shown_combs(&h.app);
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]);
@@ -635,7 +635,7 @@ fn comb_options_draw_the_combs_shown_again() {
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();
let combs = shown_combs(&h.app);
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.
@@ -643,10 +643,37 @@ fn comb_options_draw_the_combs_shown_again() {
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();
let combs = shown_combs(&h.app);
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());
assert!(shown_combs(&h.app).is_empty() && crate::scenario::rect_of("comb_options").is_none());
}
/// Every comb shown shares one scale: the tool combs each new pick together with those already
/// shown, so tooth lengths compare across curves. An R5 edge's teeth are twice an R10 edge's,
/// after either pick and after the options change.
#[test]
fn combs_shown_together_share_one_scale() {
use crate::sketch_tools::{CombOptions, set_comb_options, shown_combs};
let mut h = Harness::new();
h.app.home.open = false;
h.app.run("solid.cylinder", json!({"radius": 5, "height": 20})).unwrap();
h.app.run("solid.cylinder", json!({"base": [30, 0, 0], "radius": 10, "height": 20})).unwrap();
h.settle();
h.step(&json!({"start": "inspect.curvature_comb"})).unwrap();
h.frame();
h.step(&json!({"click": {"world": [0, 5, 20]}})).unwrap();
h.step(&json!({"click": {"world": [30, 10, 20]}})).unwrap();
let ratio = |h: &Harness| {
let combs = shown_combs(&h.app);
assert_eq!(combs.len(), 2, "{:?}", h.app.status);
let tooth = |c: &[[solidcraft_engine::geom::Vec3; 2]]| c[0][0].dist(c[0][1]);
tooth(&combs[0]) / tooth(&combs[1])
};
assert!((ratio(&h) - 2.0).abs() < 1e-9, "{}", ratio(&h));
set_comb_options(&mut h.app, CombOptions { length: 3.0, teeth: 12 });
assert!((ratio(&h) - 2.0).abs() < 1e-9, "{}", ratio(&h));
assert_eq!(shown_combs(&h.app)[1].len(), 12);
}