diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index d972caed..ed7b8abd 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("measure") .key("I") .noundo() - .params("bodies?: [names] (default all)"), + .params("bodies?: [names] (default all); or items: [1-2 selections] → length/area/volume, minimum distance (from, to) and angle"), CommandSpec::new("document.inspect", "Inspect Design", inspect).noundo().params("measure?: bool (include body measurements)"), CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"), CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"), @@ -46,6 +46,9 @@ pub fn measure_json(b: &solvecraft_doc::ModelBody) -> Value { } fn measure(s: &mut Session, p: &Value) -> Result { + if let Some(items) = p.get("items") { + return super::measure_sel::measure_items(s, items); + } let want = string_list(p, "bodies"); let st = s.world_state(); let mut bodies: Vec = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect(); diff --git a/crates/engine/src/cmd/measure_sel.rs b/crates/engine/src/cmd/measure_sel.rs new file mode 100644 index 00000000..c4c9e5a4 --- /dev/null +++ b/crates/engine/src/cmd/measure_sel.rs @@ -0,0 +1,177 @@ +//! Measuring picked geometry (`MeasureCommand` with `items`): one item gives its length, area, +//! volume or position; two give the minimum distance between them (with the two closest +//! points) and, where both have a direction (planar faces, straight edges), the angle. + +use serde_json::{Value, json}; +use solvecraft_geom::{Mesh, Vec3}; + +use crate::params::bad; +use crate::{Result, Sel, Session}; + +/// Geometry of a picked item, as points or triangles. +enum Geo { + Points(Vec), + Tris(Vec<[Vec3; 3]>), +} + +/// Caps that keep a measurement interactive on big meshes. +const MAX_POINTS: usize = 4000; +const MAX_TRIS: usize = 20000; + +fn closest_on_triangle(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> Vec3 { + let n = (b - a).cross(c - a); + if let Some(nn) = n.normalized() { + let q = p - nn * (p - a).dot(nn); + if [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12) { + return q; + } + } + let on_seg = |u: Vec3, v: Vec3| { + let d = v - u; + let l2 = d.dot(d); + let t = if l2 > 0.0 { ((p - u).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + u + d * t + }; + [on_seg(a, b), on_seg(b, c), on_seg(c, a)].into_iter().min_by(|x, y| x.dist(p).total_cmp(&y.dist(p))).unwrap_or(a) +} + +fn polyline_len(pts: &[Vec3]) -> f64 { + pts.windows(2).map(|w| w[0].dist(w[1])).sum() +} + +/// A polyline with extra points so no gap is longer than 1/64 of it. +fn densify(pts: &[Vec3]) -> Vec { + let step = (polyline_len(pts) / 64.0).max(1e-9); + let mut out = Vec::new(); + for w in pts.windows(2) { + let n = ((w[0].dist(w[1]) / step).ceil() as usize).clamp(1, 64); + for k in 0..n { + out.push(w[0].lerp(w[1], k as f64 / n as f64)); + } + } + out.extend(pts.last().copied()); + out +} + +/// The mesh edge a selection means: by index if it still passes through the point, otherwise +/// the nearest. +fn edge_points(m: &Mesh, index: usize, point: Vec3) -> Option> { + let near = |e: &Vec| e.windows(2).map(|w| point.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); + let tol = m.bounds().diagonal().max(1.0) * 1e-6; + if let Some(e) = m.edges.get(index).filter(|e| near(e) < tol) { + return Some(e.clone()); + } + m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).cloned() +} + +fn face_tris(m: &Mesh, f: usize) -> Vec<[Vec3; 3]> { + m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf as usize == f).filter_map(|(t, _)| m.tri(t)).collect() +} + +/// Geometry, direction (planar face normal, straight edge direction) and a description. +fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option, Value)> { + let st = s.world_state(); + Some(match sel { + Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})), + Sel::Edge { body, index, point } => { + let m = st.body(body)?.mesh(); + let pts = edge_points(&m, *index, *point)?; + let (a, b) = (*pts.first()?, *pts.last()?); + let straight = (b - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(b)))); + let len = polyline_len(&pts); + (Geo::Points(densify(&pts)), straight, json!({"type": "edge", "length_mm": len})) + } + Sel::Face { body, index, .. } => { + let m = st.body(body)?.mesh(); + let tris = face_tris(&m, *index); + let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum(); + let normals: Vec = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect(); + let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6)); + (Geo::Tris(tris), planar, json!({"type": "face", "area_mm2": area, "planar": planar.is_some()})) + } + Sel::Body { name } => { + let m = st.body(name)?.mesh(); + let tris: Vec<[Vec3; 3]> = m.triangles.iter().filter_map(|t| m.tri(t)).collect(); + let v = m.measure().volume; + (Geo::Tris(tris), None, json!({"type": "body", "name": name, "volume_mm3": v})) + } + _ => return None, + }) +} + +fn sample(v: &[T], max: usize) -> Vec { + let step = v.len().div_ceil(max).max(1); + v.iter().step_by(step).copied().collect() +} + +/// Closest pair of points between two pieces of geometry. +fn closest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> { + let pts_to_tris = |pts: &[Vec3], tris: &[[Vec3; 3]]| -> Option<(Vec3, Vec3)> { + let pts = sample(pts, MAX_POINTS); + let tris = sample(tris, MAX_TRIS); + let mut best: Option<(f64, Vec3, Vec3)> = None; + for p in &pts { + for [x, y, z] in &tris { + let q = closest_on_triangle(*p, *x, *y, *z); + let d = p.dist(q); + if best.is_none_or(|(bd, _, _)| d < bd) { + best = Some((d, *p, q)); + } + } + } + best.map(|(_, p, q)| (p, q)) + }; + let verts = |tris: &[[Vec3; 3]]| -> Vec { tris.iter().flat_map(|t| t.iter().copied()).collect() }; + match (a, b) { + (Geo::Points(p), Geo::Points(q)) => { + let (p, q) = (sample(p, MAX_POINTS), sample(q, MAX_POINTS)); + let mut best: Option<(f64, Vec3, Vec3)> = None; + for x in &p { + for y in &q { + let d = x.dist(*y); + if best.is_none_or(|(bd, _, _)| d < bd) { + best = Some((d, *x, *y)); + } + } + } + best.map(|(_, x, y)| (x, y)) + } + (Geo::Points(p), Geo::Tris(t)) => pts_to_tris(p, t), + (Geo::Tris(t), Geo::Points(p)) => pts_to_tris(p, t).map(|(x, y)| (y, x)), + (Geo::Tris(t1), Geo::Tris(t2)) => { + let ab = pts_to_tris(&verts(t1), t2); + let ba = pts_to_tris(&verts(t2), t1).map(|(x, y)| (y, x)); + [ab, ba].into_iter().flatten().min_by(|x, y| x.0.dist(x.1).total_cmp(&y.0.dist(y.1))) + } + } +} + +/// `MeasureCommand` with `items` (one or two selections). +pub(crate) fn measure_items(s: &Session, items: &Value) -> Result { + let cmd = "MeasureCommand"; + let list = items.as_array().ok_or_else(|| bad(cmd, "`items` must be a list of selections"))?; + if list.is_empty() || list.len() > 2 { + return Err(bad(cmd, "measure one or two items")); + } + let sels: Vec = list.iter().map(|v| serde_json::from_value::(v.clone()).map_err(|e| bad(cmd, e.to_string()))).collect::>()?; + let mut described = Vec::new(); + for x in &sels { + described.push(describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces or bodies)"))?); + } + let mut out = json!({"items": described.iter().map(|d| d.2.clone()).collect::>()}); + if let [(ga, da, _), (gb, db, _)] = described.as_slice() { + if let Some((p, q)) = closest(ga, gb) { + out["distance_mm"] = json!(p.dist(q)); + out["from"] = json!(p); + out["to"] = json!(q); + out["delta"] = json!(q - p); + } + if let (Some(a), Some(b)) = (da, db) { + let both_faces = matches!(sels.as_slice(), [Sel::Face { .. }, Sel::Face { .. }]); + let c = a.dot(*b).clamp(-1.0, 1.0); + let c = if both_faces { c } else { c.abs() }; + out["angle_deg"] = json!(c.acos().to_degrees()); + } + } + Ok(out) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a93d8be3..54b97cd0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -9,6 +9,8 @@ mod features; pub use features::auto_operation; mod file; mod inspect; +mod measure_sel; +pub(crate) use measure_sel::measure_items; mod sketch; mod sketch_constraints; mod sketch_create; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9d847056..f349dee4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -342,6 +342,13 @@ impl Session { Ok(self.model.state()) } + /// Measure one or two selections (see `MeasureCommand` with `items`); errors come back as + /// `{"error": …}`. + pub fn measure_items(&self, items: &[Sel]) -> Value { + let v = serde_json::to_value(items).unwrap_or(Value::Null); + cmd::measure_items(self, &v).unwrap_or_else(|e| serde_json::json!({ "error": e.to_string() })) + } + /// Run a script: a JSON array of `{"command": id, "params": {...}}` (or an object with a /// `commands` array). Stops at the first error. Returns each command's result. pub fn run_script(&mut self, script: &Value) -> Result> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3e192729..4b0cc50a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -582,6 +582,40 @@ fn extrude_auto_operation() { assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0 - 100.0 * 8.0) < 1e-6); } +/// Measuring picked geometry: distance between faces, angle, edge length. +#[test] +fn measure_items() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20})); + let st = s.model.state(); + let m = st.body("Body1").unwrap().mesh(); + // Find the faces by their centroid: top (z = 20), bottom (z = 0), front (y = 0). + let face_at = |z: Option, y: Option| -> usize { + (0..6) + .find(|f| { + let tris: Vec<_> = m.triangles.iter().zip(&m.tri_face).filter(|(_, tf)| **tf as usize == *f).filter_map(|(t, _)| m.tri(t)).collect(); + tris.iter().all(|t| t.iter().all(|p| z.is_none_or(|z| (p.z - z).abs() < 1e-9) && y.is_none_or(|y| (p.y - y).abs() < 1e-9))) + }) + .unwrap() + }; + let (ft, fb, ff) = (face_at(Some(20.0), None), face_at(Some(0.0), None), face_at(None, Some(0.0))); + let face = |i: usize, p: [f64; 3]| json!({"type": "face", "body": "Body1", "index": i, "point": p}); + let r = run(&mut s, "MeasureCommand", json!({"items": [face(ft, [20.0, 15.0, 20.0]), face(fb, [20.0, 15.0, 0.0])]})); + assert!((r["distance_mm"].as_f64().unwrap() - 20.0).abs() < 1e-9, "{r}"); + assert!((r["angle_deg"].as_f64().unwrap() - 180.0).abs() < 1e-6, "{r}"); + let r = run(&mut s, "MeasureCommand", json!({"items": [face(ft, [20.0, 15.0, 20.0]), face(ff, [20.0, 0.0, 10.0])]})); + assert!(r["distance_mm"].as_f64().unwrap() < 1e-9); + assert!((r["angle_deg"].as_f64().unwrap() - 90.0).abs() < 1e-6); + let r = run(&mut s, "MeasureCommand", json!({"items": [face(ft, [20.0, 15.0, 20.0])]})); + assert!((r["items"][0]["area_mm2"].as_f64().unwrap() - 1200.0).abs() < 1e-6, "{r}"); + let v = json!({"type": "vertex", "body": "Body1", "point": [0, 0, 0]}); + let w = json!({"type": "vertex", "body": "Body1", "point": [40, 30, 20]}); + let r = run(&mut s, "MeasureCommand", json!({"items": [v, w]})); + assert!((r["distance_mm"].as_f64().unwrap() - (1600.0f64 + 900.0 + 400.0).sqrt()).abs() < 1e-9); + assert!(s.execute("MeasureCommand", &json!({"items": []})).is_err()); + assert!(s.execute("MeasureCommand", &json!({"items": [{"type": "nonsense"}]})).is_err()); +} + /// Press Pull: positive pulls a face out, negative pushes it in (a cut), edges get a fillet. #[test] fn press_pull() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 59d6be62..a3ee765e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -64,6 +64,11 @@ fn edge_bisector(m: &Mesh, index: usize, p: Vec3) -> Option { /// 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 { + 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 { .. }) { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 87b15d7c..196b82ad 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, + of: Vec, + }, /// 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 = 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, 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 { - [("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 { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 07170c8a..35a41062 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -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);