mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
View analyses: one toggle group, its state in the session
Zebra, Draft, Curvature Map, Environment Map, Accessibility, Continuity, Isocurve and Curvature Comb are one group: turning one on turns the one on before off (Session::analyses_off), and the session says which is on (Session::active_analysis). The new inspect.analysis names it and off: true turns it off (typed contract and reply). The toolbar shows the active one's button pressed, its tool running or not. Curvature Comb's button toggles: on, the tool combs the edges clicked; again, off and the combs go; Esc or a right click in the tool turns it off; opening another dialog leaves it on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"en": {
|
||||
"cmd.inspect_analysis": "View Analysis"
|
||||
},
|
||||
"ar": {
|
||||
"cmd.inspect_analysis": "تحليل العرض"
|
||||
},
|
||||
"he": {
|
||||
"cmd.inspect_analysis": "ניתוח תצוגה"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.inspect_analysis": "表示解析"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.inspect_analysis": "视图分析"
|
||||
}
|
||||
}
|
||||
@@ -231,8 +231,8 @@ fn curvature_comb(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
value
|
||||
})
|
||||
.collect();
|
||||
s.analyses_off(Some("inspect.curvature_comb"));
|
||||
s.comb = Some(CombView { picks: kept, density, length, fixed_scale, scale, teeth: drawn });
|
||||
s.revision += 1;
|
||||
Ok(json!({"combs": out, "scale": scale}))
|
||||
}
|
||||
|
||||
@@ -426,7 +426,7 @@ fn isocurves(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.isocurves.clear();
|
||||
}
|
||||
s.isocurves.extend(picks.into_iter().map(|(_, k)| k));
|
||||
s.revision += 1;
|
||||
s.analyses_off(Some("inspect.isocurve"));
|
||||
Ok(json!({"curves": curves, "faces": shown}))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Surface analyses: zebra stripes, draft angle and curvature map shading of the model's faces.
|
||||
//! View state only: no geometry changes and no undo step.
|
||||
//! View state only: no geometry changes and no undo step. The view analyses (these shadings,
|
||||
//! continuity seams, isocurves and curvature combs) are one group: one at a time is on, and
|
||||
//! `inspect.analysis` says which or turns it off.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_geom::Vec3;
|
||||
@@ -38,6 +40,18 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("curvature_map")
|
||||
.noundo()
|
||||
.params("radius?: mm shown red (default: a tenth of the model's size); clear?: true turns the analysis off"),
|
||||
CommandSpec::new("inspect.analysis", "View Analysis", crate::checked!("inspect.analysis", analysis))
|
||||
.icon("zebra")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&ANALYSIS_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::surface_analysis::AnalysisState>)
|
||||
.params(
|
||||
"off?: true turns the view analysis on now off. → active: the command id of the view analysis on (inspect.zebra, \
|
||||
inspect.draft, inspect.curvature_map, inspect.environment_map, inspect.accessibility, inspect.continuity, \
|
||||
inspect.isocurve or inspect.curvature_comb), or null. One is on at a time: turning one on turns the one on before \
|
||||
off. Read-only without `off`",
|
||||
),
|
||||
CommandSpec::new("inspect.continuity", "Continuity Analysis", continuity).at("SURFACE", "INSPECT").icon("continuity").noundo().params(
|
||||
"body?: name (default: the visible bodies); edges?: [x,y,z] points or {\"index\": i} (default: every edge of the body; \
|
||||
a pick must match one body); samples?: 3…257 stations along each edge, ends included (default 33); \
|
||||
@@ -52,10 +66,75 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
];
|
||||
|
||||
const ANALYSIS_CONTRACT: crate::schema::TypedContract<crate::schema::surface_analysis::AnalysisParams> = crate::schema::TypedContract::new();
|
||||
|
||||
/// The view analyses, by the command that turns each on: the shadings, the continuity seams, the
|
||||
/// isocurves and the curvature combs. One at a time is on.
|
||||
impl Session {
|
||||
/// The view analysis on now, by its command id, or `None`.
|
||||
pub fn active_analysis(&self) -> Option<&'static str> {
|
||||
if let Some(a) = self.analysis {
|
||||
Some(a.command())
|
||||
} else if self.comb.is_some() {
|
||||
Some("inspect.curvature_comb")
|
||||
} else if !self.isocurves.is_empty() {
|
||||
Some("inspect.isocurve")
|
||||
} else if !self.continuity.is_empty() {
|
||||
Some("inspect.continuity")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn every view analysis off but the one command `keep` turns on (`None`: all of them).
|
||||
pub fn analyses_off(&mut self, keep: Option<&str>) {
|
||||
if self.analysis.is_some_and(|a| Some(a.command()) != keep) {
|
||||
self.analysis = None;
|
||||
}
|
||||
if keep != Some("inspect.curvature_comb") {
|
||||
self.comb = None;
|
||||
}
|
||||
if keep != Some("inspect.isocurve") {
|
||||
self.isocurves.clear();
|
||||
}
|
||||
if keep != Some("inspect.continuity") {
|
||||
self.continuity.clear();
|
||||
}
|
||||
self.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
impl SurfaceAnalysis {
|
||||
/// The command that turns this analysis on.
|
||||
pub fn command(self) -> &'static str {
|
||||
match self {
|
||||
SurfaceAnalysis::Zebra { .. } => "inspect.zebra",
|
||||
SurfaceAnalysis::Draft { .. } => "inspect.draft",
|
||||
SurfaceAnalysis::Curvature { .. } => "inspect.curvature_map",
|
||||
SurfaceAnalysis::Environment => "inspect.environment_map",
|
||||
SurfaceAnalysis::Access { .. } => "inspect.accessibility",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `inspect.analysis`: which view analysis is on, and `off` turns it off.
|
||||
fn analysis(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let off = match p.get("off") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad("inspect.analysis", "`off` must be true or false")),
|
||||
};
|
||||
if off {
|
||||
s.analyses_off(None);
|
||||
}
|
||||
Ok(json!({"active": s.active_analysis()}))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value, a: SurfaceAnalysis) -> Result<Value> {
|
||||
if bool_(p, "clear").unwrap_or(false) {
|
||||
s.analysis = None;
|
||||
} else {
|
||||
s.analyses_off(Some(a.command()));
|
||||
s.analysis = Some(a);
|
||||
}
|
||||
s.revision += 1;
|
||||
@@ -213,6 +292,9 @@ fn continuity(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
s.continuity = seams;
|
||||
}
|
||||
if !s.continuity.is_empty() {
|
||||
s.analyses_off(Some("inspect.continuity"));
|
||||
}
|
||||
s.revision += 1;
|
||||
Ok(json!({
|
||||
"analysis": "continuity",
|
||||
|
||||
@@ -58,6 +58,7 @@ pub mod browser;
|
||||
pub mod diagnostics;
|
||||
pub mod features;
|
||||
pub mod inspect;
|
||||
pub mod surface_analysis;
|
||||
|
||||
pub use check::{Contract, Issue, ParamCheck, TypedContract, input_schema_for, output_schema_for, run_checked, run_checked_as};
|
||||
pub use types::*;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Contracts and result types for the commands in `cmd/surface_analysis.rs`.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// `true`, `false` or null, with the handler's message for anything else.
|
||||
fn flag<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
Value::Bool(b) => Ok(Some(b)),
|
||||
_ => Err(serde::de::Error::custom("must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Say which view analysis is on (zebra, draft, curvature map, environment map, accessibility,
|
||||
/// continuity, isocurves or curvature combs: one at a time), or turn it off.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AnalysisParams {
|
||||
/// Turn the view analysis on now off.
|
||||
#[serde(default, deserialize_with = "flag")]
|
||||
pub off: Option<bool>,
|
||||
}
|
||||
|
||||
/// What `inspect.analysis` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AnalysisState {
|
||||
/// The command id of the view analysis on (after `off`), or null when none is.
|
||||
pub active: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "surface_analysis_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,45 @@
|
||||
use serde_json::json;
|
||||
|
||||
use super::AnalysisParams;
|
||||
use crate::Session;
|
||||
use crate::schema::input_schema_for;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<AnalysisParams>(), &["off"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[("inspect.analysis", json!({"off": "yes"})), ("inspect.analysis", json!({"off": 1}))]);
|
||||
}
|
||||
|
||||
/// The view analyses are one group: turning one on turns the one on before off, and
|
||||
/// `inspect.analysis` names the one on and turns it off.
|
||||
#[test]
|
||||
fn one_view_analysis_is_on_at_a_time() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.cylinder", &json!({"radius": 5, "height": 10})).unwrap();
|
||||
let active = |s: &mut Session| s.execute("inspect.analysis", &json!({})).unwrap()["active"].clone();
|
||||
assert_eq!(active(&mut s), json!(null));
|
||||
s.execute("inspect.zebra", &json!({})).unwrap();
|
||||
assert_eq!(active(&mut s), json!("inspect.zebra"));
|
||||
s.execute("inspect.curvature_comb", &json!({"edges": [[0, 5, 10]]})).unwrap();
|
||||
assert_eq!(active(&mut s), json!("inspect.curvature_comb"));
|
||||
assert!(s.analysis.is_none(), "the zebra stripes went off");
|
||||
s.execute("inspect.isocurve", &json!({"faces": [[0, 5, 5]]})).unwrap();
|
||||
assert_eq!(active(&mut s), json!("inspect.isocurve"));
|
||||
assert!(s.comb.is_none(), "the combs went off");
|
||||
s.execute("inspect.continuity", &json!({})).unwrap();
|
||||
assert_eq!(active(&mut s), json!("inspect.continuity"));
|
||||
assert!(s.isocurves.is_empty(), "the isocurves went off");
|
||||
s.execute("inspect.draft", &json!({})).unwrap();
|
||||
assert_eq!(active(&mut s), json!("inspect.draft"));
|
||||
assert!(s.continuity.is_empty(), "the seams went off");
|
||||
// Turning on the comb with nothing picked yet.
|
||||
s.execute("inspect.curvature_comb", &json!({"add": true})).unwrap();
|
||||
assert_eq!(active(&mut s), json!("inspect.curvature_comb"));
|
||||
assert_eq!(s.execute("inspect.analysis", &json!({"off": true})).unwrap()["active"], json!(null));
|
||||
assert!(s.comb.is_none() && s.analysis.is_none());
|
||||
}
|
||||
@@ -325,6 +325,18 @@ pub fn start_hook(app: &mut SolveApp, id: &str) -> bool {
|
||||
app.set_status(format!("Continuity: {} (click the button again to turn it off)", counts.join(", ")), false);
|
||||
return true;
|
||||
}
|
||||
if id == "inspect.curvature_comb" {
|
||||
// With combs shown the button turns the analysis off; otherwise it turns it on (any other
|
||||
// view analysis off) and the tool combs the curves clicked.
|
||||
if app.session.comb.is_some() {
|
||||
if app.run(id, json!({"clear": true})).is_ok() {
|
||||
app.set_status("Analysis off", false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
comb(app, json!({}));
|
||||
return app.session.comb.is_some();
|
||||
}
|
||||
if id == "inspect.isocurve" && !app.session.isocurves.is_empty() {
|
||||
// With curves shown, the button turns the analysis off; otherwise the tool picks faces.
|
||||
if app.run(id, json!({"clear": true})).is_ok() {
|
||||
@@ -1203,6 +1215,11 @@ fn run_entities(app: &mut SolveApp, t: &Tool) {
|
||||
/// Right-click with an extra tool: finish a chain or move to the next stage. Returns false
|
||||
/// when the tool should be dropped.
|
||||
pub fn finish(app: &mut SolveApp, tool: &mut Tool) -> bool {
|
||||
// Esc or a right click ends the comb tool and turns its analysis off.
|
||||
if tool.cmd == "inspect.curvature_comb" {
|
||||
let _ = app.run(tool.cmd, json!({"clear": true}));
|
||||
return false;
|
||||
}
|
||||
let Some(m) = mode(tool.cmd) else { return false };
|
||||
match m {
|
||||
Mode::Points(0) if tool.pts.len() >= 2 => {
|
||||
@@ -1613,10 +1630,6 @@ 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)) {
|
||||
|
||||
@@ -208,6 +208,23 @@ fn help_x(report: Rect) -> f32 {
|
||||
report.left() - 18.0
|
||||
}
|
||||
|
||||
/// The command whose tool or dialog is open right now, shown pressed in the toolbar.
|
||||
fn running_command(app: &SolveApp) -> Option<String> {
|
||||
match (&app.tool, &app.dialog) {
|
||||
(Some(tool), _) => Some(tool.cmd.to_string()),
|
||||
// Create Sketch never lands in last_command (it is not repeatable).
|
||||
(None, Some(d)) if matches!(d.kind, crate::dialogs::Kind::Sketch) => Some("sketch.create".to_string()),
|
||||
(None, Some(_)) => app.last_command.as_ref().map(|(id, _)| id.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the toolbar shows command `id` pressed: its tool or dialog is open, or it turned on
|
||||
/// the view analysis on now (one at a time, `Session::active_analysis`), its tool running or not.
|
||||
pub(crate) fn shows_pressed(app: &SolveApp, id: &str) -> bool {
|
||||
running_command(app).as_deref() == Some(id) || app.session.active_analysis() == Some(id)
|
||||
}
|
||||
|
||||
/// The Report a bug button: labelled when the layout asks for it and there is room for its text
|
||||
/// (`label_width`, measured in the current language), otherwise just the icon. An icon-only
|
||||
/// layout gives it a square the size of Settings and Help.
|
||||
@@ -1245,14 +1262,6 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ribbon_tabs(app, ui, Rect::from_min_max(pos2(ribbon_left, r.top()), pos2(r.right(), r.top() + TAB_ROW_HEIGHT)));
|
||||
// Panels.
|
||||
let tab = app.ui.tab.clone();
|
||||
// The command whose tool or dialog is open right now, shown pressed in the toolbar.
|
||||
let running: Option<String> = match (&app.tool, &app.dialog) {
|
||||
(Some(tool), _) => Some(tool.cmd.to_string()),
|
||||
// Create Sketch never lands in last_command (it is not repeatable).
|
||||
(None, Some(d)) if matches!(d.kind, crate::dialogs::Kind::Sketch) => Some("sketch.create".to_string()),
|
||||
(None, Some(_)) => app.last_command.as_ref().map(|(id, _)| id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let mut px = ribbon_left;
|
||||
let top = r.top() + TAB_ROW_HEIGHT;
|
||||
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
|
||||
@@ -1432,7 +1441,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let accent = crate::theme::TabAccent::for_tab(&t, &tab);
|
||||
let family_open = family.is_some_and(|f| egui::Popup::is_id_open(ui.ctx(), ui.id().with(("family", f.label)).with("popup")));
|
||||
if !finish && (resp.is_pointer_button_down_on() || running.as_deref() == Some(selected.id) || family_open) {
|
||||
if !finish && (resp.is_pointer_button_down_on() || shows_pressed(app, selected.id) || family_open) {
|
||||
painter.rect(whole, t.radius_sm, accent.soft, Stroke::new(1.0, accent.color), egui::StrokeKind::Inside);
|
||||
} else if resp.hovered() && info.enabled {
|
||||
painter.rect_filled(whole, t.radius_sm, t.hover);
|
||||
@@ -1499,7 +1508,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
let info = crate::render_workspace::interactive_command_info(app, c);
|
||||
let resp = ui.interact(cell, ui.id().with(("cmd", c.id)), Sense::click_and_drag());
|
||||
let active = running.as_deref() == Some(c.id);
|
||||
let active = shows_pressed(app, c.id);
|
||||
let accent = crate::theme::TabAccent::for_tab(&t, &tab);
|
||||
painter.rect(
|
||||
cell,
|
||||
|
||||
@@ -649,6 +649,7 @@ fn comb_options_draw_the_combs_shown_again() {
|
||||
h.step(&json!({"key": "Escape"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(shown_combs(&h.app).is_empty() && crate::scenario::rect_of("comb_options").is_none());
|
||||
assert!(!crate::toolbar::shows_pressed(&h.app, "inspect.curvature_comb"), "Esc turned the analysis off");
|
||||
}
|
||||
|
||||
/// Every comb shown shares one scale: the tool combs each new pick together with those already
|
||||
@@ -677,3 +678,54 @@ fn combs_shown_together_share_one_scale() {
|
||||
assert!((ratio(&h) - 2.0).abs() < 1e-9, "{}", ratio(&h));
|
||||
assert_eq!(shown_combs(&h.app)[1].len(), 12);
|
||||
}
|
||||
|
||||
/// The view analysis buttons are one toggle group, reflecting the session's one analysis on:
|
||||
/// Curvature Comb on shows it pressed (through another dialog too); its button again turns it off
|
||||
/// and clears the combs; Zebra on turns the comb off, and Zebra again leaves none on.
|
||||
#[test]
|
||||
fn view_analysis_buttons_are_one_toggle_group() {
|
||||
use crate::sketch_tools::shown_combs;
|
||||
use crate::toolbar::shows_pressed;
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("solid.cylinder", json!({"radius": 10, "height": 20})).unwrap();
|
||||
h.settle();
|
||||
let (comb, zebra) = ("inspect.curvature_comb", "inspect.zebra");
|
||||
let active = |h: &mut Harness| h.app.run("inspect.analysis", json!({})).unwrap()["active"].clone();
|
||||
// Comb on, then off with its button again.
|
||||
h.step(&json!({"start": comb})).unwrap();
|
||||
h.frame();
|
||||
assert!(shows_pressed(&h.app, comb) && !shows_pressed(&h.app, zebra));
|
||||
assert_eq!(active(&mut h), json!(comb));
|
||||
h.step(&json!({"click": {"world": [0, 10, 20]}})).unwrap();
|
||||
assert_eq!(shown_combs(&h.app).len(), 1, "{:?}", h.app.status);
|
||||
h.step(&json!({"start": comb})).unwrap();
|
||||
h.frame();
|
||||
assert!(shown_combs(&h.app).is_empty() && h.app.tool.is_none() && !shows_pressed(&h.app, comb));
|
||||
assert_eq!(active(&mut h), json!(null));
|
||||
// Comb on again; it stays on, pressed, while another dialog is open.
|
||||
h.step(&json!({"start": comb})).unwrap();
|
||||
h.step(&json!({"click": {"world": [0, 10, 20]}})).unwrap();
|
||||
h.step(&json!({"start": "solid.fillet"})).unwrap();
|
||||
h.frame();
|
||||
assert!(h.app.dialog.is_some() && h.app.tool.is_none());
|
||||
assert!(shows_pressed(&h.app, comb) && shown_combs(&h.app).len() == 1);
|
||||
h.step(&json!({"key": "Escape"})).unwrap();
|
||||
h.frame();
|
||||
// Zebra on: the comb goes off and its combs go.
|
||||
h.step(&json!({"start": zebra})).unwrap();
|
||||
h.frame();
|
||||
assert!(shows_pressed(&h.app, zebra) && !shows_pressed(&h.app, comb));
|
||||
assert!(shown_combs(&h.app).is_empty());
|
||||
assert_eq!(active(&mut h), json!(zebra));
|
||||
// Comb on: Zebra goes off.
|
||||
h.step(&json!({"start": comb})).unwrap();
|
||||
h.frame();
|
||||
assert!(shows_pressed(&h.app, comb) && !shows_pressed(&h.app, zebra) && h.app.session.analysis.is_none());
|
||||
// Zebra on, then off: none is on.
|
||||
h.step(&json!({"start": zebra})).unwrap();
|
||||
h.step(&json!({"start": zebra})).unwrap();
|
||||
h.frame();
|
||||
assert!(!shows_pressed(&h.app, zebra) && !shows_pressed(&h.app, comb));
|
||||
assert_eq!(active(&mut h), json!(null));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user