Measure tool (I): pick one or two vertices, edges or faces for length/area/position, minimum distance with ΔX/ΔY/ΔZ and a line in the view, and the angle between planar faces or straight edges (MeasureCommand items, engine test); theme switch from the control channel no longer leaves stale scene colours
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
7518c4f4b5
commit
beab69cfb2
8 files changed
+337
-7
No files matched your search
@@ -64,6 +64,11 @@ fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option<Vec3> {
|
||||
/// Draw the manipulator and the value box (called by the viewport after the model is drawn).
|
||||
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
if let Kind::Measure { result: Some(r), .. } = &d.kind {
|
||||
measure_line(painter, proj, r);
|
||||
app.dialog = Some(d);
|
||||
return;
|
||||
}
|
||||
if let Some((base, dir)) = anchor(app, &d)
|
||||
&& let Some(bs) = proj.to_screen(base)
|
||||
{
|
||||
@@ -202,6 +207,26 @@ fn drag_arrow(
|
||||
Some(ts)
|
||||
}
|
||||
|
||||
/// The measured distance: a line between the closest points with its length.
|
||||
fn measure_line(painter: &egui::Painter, proj: &Proj, r: &serde_json::Value) {
|
||||
let t = Tokens::get();
|
||||
let p = |k: &str| -> Option<Vec3> {
|
||||
let a = r.get(k)?.as_array()?;
|
||||
Some(Vec3::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?))
|
||||
};
|
||||
let (Some(a), Some(b), Some(d)) = (p("from"), p("to"), r.get("distance_mm").and_then(|v| v.as_f64())) else { return };
|
||||
let (Some(sa), Some(sb)) = (proj.to_screen(a), proj.to_screen(b)) else { return };
|
||||
painter.line_segment([sa, sb], Stroke::new(2.0, t.manipulator));
|
||||
for s in [sa, sb] {
|
||||
painter.circle(s, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
let mid = sa + (sb - sa) * 0.5 + vec2(10.0, -14.0);
|
||||
let galley = painter.layout_no_wrap(format!("{d:.3} mm"), egui::FontId::proportional(12.5), t.text);
|
||||
let rect = egui::Rect::from_min_size(mid, galley.size() + vec2(10.0, 6.0));
|
||||
painter.rect(rect, 3.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
painter.galley(rect.min + vec2(5.0, 3.0), galley, t.text);
|
||||
}
|
||||
|
||||
/// The revolve axis (a point on it and its unit direction).
|
||||
fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
|
||||
if !matches!(d.kind, Kind::Revolve { .. }) {
|
||||
|
||||
@@ -78,6 +78,12 @@ pub enum Kind {
|
||||
Sweep {
|
||||
operation: usize,
|
||||
},
|
||||
/// Measure one or two picked items (vertices, edges, faces): the result of the last
|
||||
/// measurement and the items it was for.
|
||||
Measure {
|
||||
result: Option<Value>,
|
||||
of: Vec<Sel>,
|
||||
},
|
||||
/// Move bodies by a distance along X, Y and Z.
|
||||
Move {
|
||||
x: String,
|
||||
@@ -191,6 +197,9 @@ impl Dialog {
|
||||
"MirrorCommand" => {
|
||||
Dialog::new(Kind::Mirror, vec![SelInput::new("Bodies", BODIES, true), SelInput::new("Mirror plane", PLANES | PLANAR_FACES, false)])
|
||||
}
|
||||
"MeasureCommand" => {
|
||||
Dialog::new(Kind::Measure { result: None, of: Vec::new() }, vec![SelInput::new("Items", FACES | EDGES | selection::VERTICES, true)])
|
||||
}
|
||||
"PatternRectangular" => Dialog::new(
|
||||
Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
|
||||
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Direction", AXES, false), SelInput::new("Direction 2", AXES, false)],
|
||||
@@ -373,6 +382,11 @@ impl Dialog {
|
||||
let Some(inp) = self.inputs.get_mut(self.active) else { return };
|
||||
inp.toggle(picked);
|
||||
self.focus = true;
|
||||
// A measurement is between two items: a third pick starts over from the last one.
|
||||
if matches!(self.kind, Kind::Measure { .. }) && inp.items.len() > 2 {
|
||||
let keep = inp.items.split_off(inp.items.len() - 1);
|
||||
inp.items = keep;
|
||||
}
|
||||
if !inp.multi && !inp.items.is_empty() && self.active + 1 < self.inputs.len() {
|
||||
self.advance();
|
||||
}
|
||||
@@ -465,6 +479,7 @@ fn title(k: &Kind) -> &'static str {
|
||||
Kind::Shell { .. } => "SHELL",
|
||||
Kind::Draft { .. } => "DRAFT",
|
||||
Kind::Mirror => "MIRROR",
|
||||
Kind::Measure { .. } => "MEASURE",
|
||||
Kind::PatternRect { .. } => "RECTANGULAR PATTERN",
|
||||
Kind::PatternCirc { .. } => "CIRCULAR PATTERN",
|
||||
Kind::Loft { .. } => "LOFT",
|
||||
@@ -550,6 +565,68 @@ const DIALOG_MAX_W: f32 = 380.0;
|
||||
/// Width of value fields and choice boxes in a dialog.
|
||||
const FIELD_W: f32 = 140.0;
|
||||
|
||||
/// Measure again when the picked items changed.
|
||||
fn update_measure(app: &SolveApp, d: &mut Dialog) {
|
||||
let items: Vec<Sel> = sels(d, 0).to_vec();
|
||||
if let Kind::Measure { result, of } = &mut d.kind
|
||||
&& *of != items
|
||||
{
|
||||
*result = if items.is_empty() { None } else { Some(app.session.measure_items(&items)) };
|
||||
*of = items;
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_mm(v: &Value) -> String {
|
||||
v.as_f64().map(|x| format!("{x:.3} mm")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The measurement in the dialog.
|
||||
fn measure_rows(ui: &mut egui::Ui, r: Option<&Value>) {
|
||||
let t = Tokens::get();
|
||||
let Some(r) = r else {
|
||||
ui.label("");
|
||||
ui.label(RichText::new("pick one or two vertices, edges or faces").color(t.text_dim));
|
||||
ui.end_row();
|
||||
return;
|
||||
};
|
||||
if let Some(e) = r.get("error").and_then(Value::as_str) {
|
||||
ui.label("");
|
||||
ui.label(RichText::new(e).color(t.error));
|
||||
ui.end_row();
|
||||
return;
|
||||
}
|
||||
for (i, it) in r["items"].as_array().into_iter().flatten().enumerate() {
|
||||
let what = match it["type"].as_str() {
|
||||
Some("edge") => format!("Length {}", fmt_mm(&it["length_mm"])),
|
||||
Some("face") => format!("Area {:.3} mm²", it["area_mm2"].as_f64().unwrap_or(0.0)),
|
||||
Some("vertex") => {
|
||||
let p = &it["position"];
|
||||
format!("At {:.3}, {:.3}, {:.3}", p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0), p[2].as_f64().unwrap_or(0.0))
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
ui.label(format!("Item {}", i + 1));
|
||||
ui.label(what);
|
||||
ui.end_row();
|
||||
}
|
||||
if r.get("distance_mm").is_some() {
|
||||
ui.label(RichText::new("Distance").strong());
|
||||
ui.label(RichText::new(fmt_mm(&r["distance_mm"])).strong());
|
||||
ui.end_row();
|
||||
let d = &r["delta"];
|
||||
for (k, l) in ["ΔX", "ΔY", "ΔZ"].iter().enumerate() {
|
||||
ui.label(*l);
|
||||
ui.label(format!("{:.3} mm", d[k].as_f64().unwrap_or(0.0).abs()));
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
if let Some(a) = r["angle_deg"].as_f64() {
|
||||
ui.label(RichText::new("Angle").strong());
|
||||
ui.label(RichText::new(format!("{a:.3}°")).strong());
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep an automatic extrude operation in step with the geometry.
|
||||
fn auto_operation(app: &SolveApp, d: &mut Dialog) {
|
||||
if !matches!(d.kind, Kind::Extrude { auto_op: true, .. }) {
|
||||
@@ -572,6 +649,7 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) {
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
auto_operation(app, &mut d);
|
||||
update_measure(app, &mut d);
|
||||
let t = Tokens::get();
|
||||
let vp = app.viewport.rect.unwrap_or_else(|| ctx.content_rect());
|
||||
let anchor = egui::pos2(vp.right(), vp.top() + crate::viewport::VIEW_CUBE_CLEARANCE);
|
||||
@@ -660,6 +738,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
Kind::Mirror => {}
|
||||
Kind::Measure { result, .. } => measure_rows(ui, result.as_ref()),
|
||||
Kind::PatternRect { count, spacing, count2, spacing2 } => {
|
||||
for (l, v) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] {
|
||||
ui.label(l);
|
||||
@@ -779,7 +858,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if !matches!(d.kind, Kind::Sketch | Kind::Params { .. })
|
||||
if !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. })
|
||||
&& ui
|
||||
.add(
|
||||
egui::Button::new(
|
||||
@@ -792,7 +871,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
{
|
||||
ok = true;
|
||||
}
|
||||
let close = if matches!(d.kind, Kind::Params { .. }) { "Close" } else { "Cancel" };
|
||||
let close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
|
||||
if ui.add(egui::Button::new(close).min_size(vec2(70.0, 24.0))).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
@@ -1165,7 +1244,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
Kind::EditParam { name, expr } => ("ChangeParameterCommand", json!({"name": name, "expression": expr})),
|
||||
Kind::Rename { feature, name } => ("FusionRenameTimelineEntryCommand", json!({"feature": feature, "name": name})),
|
||||
Kind::ConfirmDelete { feature, .. } => ("FusionDeleteCommand", json!({ "features": [feature.to_string()] })),
|
||||
Kind::Sketch | Kind::Params { .. } => return Ok(Vec::new()),
|
||||
Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } => return Ok(Vec::new()),
|
||||
};
|
||||
let mut params = params;
|
||||
if let Value::Object(m) = &mut params {
|
||||
@@ -1302,7 +1381,10 @@ fn op_index(o: &Operation) -> usize {
|
||||
|
||||
/// The origin axis a unit direction runs along (either way), if any.
|
||||
fn world_axis(d: Vec3) -> Option<String> {
|
||||
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)].into_iter().find(|(_, a)| d.cross(*a).len() < 1e-9 && d.dot(*a) > 0.0).map(|(n, _)| n.to_string())
|
||||
[("X", Vec3::X), ("Y", Vec3::Y), ("Z", Vec3::Z)]
|
||||
.into_iter()
|
||||
.find(|(_, a)| d.cross(*a).len() < 1e-9 && d.dot(*a) > 0.0)
|
||||
.map(|(n, _)| n.to_string())
|
||||
}
|
||||
|
||||
fn pt3(v: Vec3) -> Value {
|
||||
|
||||
@@ -152,7 +152,7 @@ fn scene_key(app: &SolveApp) -> u64 {
|
||||
app.ui.show_sketches.hash(&mut h);
|
||||
app.ui.hidden_bodies.hash(&mut h);
|
||||
app.preview.replaced.hash(&mut h);
|
||||
app.ui.dark.hash(&mut h);
|
||||
crate::theme::is_dark().hash(&mut h);
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
let (minor, _) = grid_step(app.cam.half_height());
|
||||
minor.to_bits().hash(&mut h);
|
||||
@@ -535,7 +535,7 @@ fn highlight_key(app: &SolveApp) -> u64 {
|
||||
serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h);
|
||||
format!("{:?}", app.viewport.hover).hash(&mut h);
|
||||
app.viewport.hover_feature.hash(&mut h);
|
||||
app.ui.dark.hash(&mut h);
|
||||
crate::theme::is_dark().hash(&mut h);
|
||||
app.preview.replaced.hash(&mut h);
|
||||
app.origin_visible().hash(&mut h);
|
||||
app.ui.hidden_origin.hash(&mut h);
|
||||
|
||||
Reference in new issue
Block a user