diff --git a/assets/locales/de.json b/assets/locales/de.json index 5fd806e1..8f3cb0e8 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -906,5 +906,14 @@ "Type": "Typ", "joint.one_component": "Diese Teile sind Körper einer Komponente: {bodies}. Ein Gelenk bewegt eine Komponente gegenüber einer anderen, daher braucht jedes Teil eine eigene Komponente.", "joint.one_body": "Beide Auswahlen liegen auf {body}. Wählen Sie die zweite auf einem anderen Teil.", - "joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist." + "joint.from_bodies_hint": "Jeder ausgewählte Körper kommt in eine eigene neue Komponente und bleibt, wo er ist.", + "Circumference": "Umfang", + "Arc length": "Bogenlänge", + "Slot width": "Langlochbreite", + "Centre to centre": "Mitte zu Mitte", + "Overall length": "Gesamtlänge", + "Min": "Min", + "Max": "Max", + "Length": "Länge", + "Area": "Fläche" } diff --git a/assets/locales/en.json b/assets/locales/en.json index a04d8c93..6cce4388 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -1163,5 +1163,12 @@ "Round": "Round", "joint.one_component": "These parts are bodies of one component: {bodies}. A joint moves one component against another, so each part needs a component of its own.", "joint.one_body": "Both picks are on {body}. Pick the second one on another part.", - "joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is." + "joint.from_bodies_hint": "Each picked body moves into a new component of its own and stays where it is.", + "Circumference": "Circumference", + "Arc length": "Arc length", + "Slot width": "Slot width", + "Centre to centre": "Centre to centre", + "Overall length": "Overall length", + "Min": "Min", + "Max": "Max" } diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 901c485d..5c17756f 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -1163,5 +1163,12 @@ "Round": "丸め", "joint.one_component": "これらの部品は 1 つのコンポーネントのボディです: {bodies}。ジョイントはコンポーネント同士を動かすため、部品ごとに専用のコンポーネントが必要です。", "joint.one_body": "両方の選択が {body} 上にあります。2 つ目は別の部品で選択してください。", - "joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。" + "joint.from_bodies_hint": "選択した各ボディを、位置はそのままで専用の新しいコンポーネントに移動します。", + "Circumference": "円周", + "Arc length": "円弧の長さ", + "Slot width": "長穴の幅", + "Centre to centre": "中心間", + "Overall length": "全長", + "Min": "最小", + "Max": "最大" } diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 15f3f1cc..02c70ea7 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -906,5 +906,14 @@ "Type": "유형", "joint.one_component": "이 부품들은 한 구성 요소의 바디입니다: {bodies}. 관절은 한 구성 요소를 다른 구성 요소에 대해 움직이므로 부품마다 자체 구성 요소가 필요합니다.", "joint.one_body": "두 선택이 모두 {body}에 있습니다. 두 번째는 다른 부품에서 선택하세요.", - "joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다." + "joint.from_bodies_hint": "선택한 각 바디가 제자리에 그대로 있으면서 자체의 새 구성 요소로 옮겨집니다.", + "Circumference": "원주", + "Arc length": "호 길이", + "Slot width": "슬롯 폭", + "Centre to centre": "중심 간", + "Overall length": "전체 길이", + "Min": "최소", + "Max": "최대", + "Length": "길이", + "Area": "면적" } diff --git a/assets/locales/zh.json b/assets/locales/zh.json index bb32bcc7..c52b5231 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -1163,5 +1163,12 @@ "Round": "圆角", "joint.one_component": "这些零件是同一组件的实体:{bodies}。接头使一个组件相对另一个组件运动,因此每个零件都需要自己的组件。", "joint.one_body": "两次选择都在 {body} 上。请在另一个零件上选择第二个。", - "joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。" + "joint.from_bodies_hint": "每个所选实体移入各自的新组件,位置保持不变。", + "Circumference": "周长", + "Arc length": "弧长", + "Slot width": "槽宽", + "Centre to centre": "中心距", + "Overall length": "总长", + "Min": "最小", + "Max": "最大" } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index b2949845..410c6c43 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); or items: [1-2 selections] → length/area/volume, minimum distance (from, to) and angle"), + .params("bodies?: [names] (default all); or items: [1-2 selections] → length/area/volume; diameter, radius, circumference or arc_length/angle_deg of circles, arcs and cylinders; slot sizes; min (distance_mm, from, to), max, center_distance (centre to centre, or from an axis) 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"), diff --git a/crates/engine/src/cmd/measure_sel.rs b/crates/engine/src/cmd/measure_sel.rs index 4ad82745..18da943c 100644 --- a/crates/engine/src/cmd/measure_sel.rs +++ b/crates/engine/src/cmd/measure_sel.rs @@ -1,10 +1,13 @@ //! Measuring picked geometry (`inspect.measure` 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. +//! volume or position, and a round one (circle, arc, cylinder) its diameter and radius, a slot +//! its sizes; two give the minimum and maximum distance between them (with their points), the +//! distance from a round item's centre or axis (see [`super::measure_shape`]) and, where both +//! have a direction (planar faces, straight edges), the angle. use serde_json::{Value, json}; use solidcraft_geom::{Mesh, Vec3}; +use super::measure_shape::{Shape, Slot, add_round, centre}; use crate::params::bad; use crate::{Result, Sel, Session}; @@ -68,13 +71,214 @@ 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)> { +/// A described item: its geometry, direction (planar face normal, straight edge direction), +/// description and exact shape. +struct Item { + geo: Geo, + dir: Option, + json: Value, + shape: Shape, + /// A slot this item is one end of (or the outline of). + slot: Option, +} + +/// The circle through a polyline's points (all on one within `tol`): a fallback for edges the +/// kernel can't name (mesh bodies). +fn fit_circle(pts: &[Vec3], tol: f64) -> Option<(Vec3, Vec3, f64)> { + let n = pts.len().checked_sub(1)?; + let (a, b, c) = (*pts.first()?, *pts.get(n / 3)?, *pts.get(2 * n / 3)?); + let (u, v) = (b - a, c - a); + let w = u.cross(v); + if !(w.len2() > u.len2() * v.len2() * 1e-16) { + return None; + } + let center = a + (v * u.len2() - u * v.len2()).cross(w) * (1.0 / (2.0 * w.len2())); + let axis = w.normalized()?; + let radius = center.dist(a); + let on = pts.iter().all(|p| (p.dist(center) - radius).abs() <= tol && (*p - center).dot(axis).abs() <= tol); + (on && radius.is_finite()).then_some((center, axis, radius)) +} + +/// The round shape of mesh edge `index` of body `b`: from the kernel's exact curve when it +/// is the same edge (its ends on the polyline), else fitted to the polyline's points. +fn edge_round(b: &solidcraft_doc::ModelBody, m: &Mesh, index: usize, whole: bool) -> Option { + let pts = m.edges.get(index)?; + let tol = (m.bounds().diagonal().max(1.0) * 1e-6).max(1e-9); + let exact = solidcraft_kernel::edge_circle(&b.body, index).filter(|c| { + let ends = [pts.first(), pts.last()].into_iter().flatten().all(|p| p.dist(c.start).min(p.dist(c.end)) <= tol * 10.0); + let on = pts.iter().all(|p| (p.dist(c.center) - c.radius).abs() <= tol * 10.0); + ends && on + }); + let (center, axis, radius, angle) = match exact { + Some(c) => (c.center, c.axis, c.radius, if whole { std::f64::consts::TAU } else { c.angle }), + None => { + let all: Vec = + if whole { m.circular_rim(index).iter().filter_map(|&i| m.edges.get(i)).flatten().copied().collect() } else { pts.clone() }; + let (center, axis, radius) = fit_circle(&all, tol * 10.0)?; + let angle = if whole { std::f64::consts::TAU } else { polyline_len(pts) / radius }; + (center, axis, radius, angle) + } + }; + Some(Shape::Round { center, axis, radius, angle, planar: true, lo: 0.0, hi: 0.0 }) +} + +/// Points along a circle or arc (`angle` from `start` about `axis`). +fn arc_points(center: Vec3, axis: Vec3, start: Vec3, angle: f64) -> Vec { + let x = start - center; + let y = axis.cross(x); + let n = 128; + (0..=n) + .map(|i| { + let t = angle * f64::from(i) / f64::from(n); + center + x * t.cos() + y * t.sin() + }) + .collect() +} + +/// The solved sketch holding a picked curve or point: `sketch` when given, else the active +/// sketch, else the last sketch with that id. +fn sketch_of<'a>( + s: &Session, + st: &'a solidcraft_doc::ModelState, + sketch: Option, + has: impl Fn(&solidcraft_sketch::Sketch) -> bool, +) -> Option<&'a solidcraft_doc::SolvedSketch> { + if let Some(id) = sketch { + return st.sketch(id); + } + if let Some(ss) = s.active_sketch.and_then(|id| st.sketch(id)).filter(|ss| has(&ss.sketch)) { + return Some(ss); + } + st.sketches.iter().rev().map(|ss| &**ss).find(|ss| has(&ss.sketch)) +} + +fn sketch_curve(ss: &solidcraft_doc::SolvedSketch, id: &str) -> Option { + use solidcraft_sketch::CurveKind; + let sk = &ss.sketch; + let c = sk.curves.get(sk.curve_index(id)?)?; + let at = |i: usize| sk.points.get(i).map(|p| ss.plane.to_world(p.pos)); + let normal = ss.plane.normal(); + let (shape, pts) = match c.kind { + CurveKind::Circle { c, r } => { + let center = at(c)?; + let shape = Shape::Round { center, axis: normal, radius: r, angle: std::f64::consts::TAU, planar: true, lo: 0.0, hi: 0.0 }; + (shape, arc_points(center, normal, center + ss.plane.x * r, std::f64::consts::TAU)) + } + CurveKind::Arc { c, a, b } => { + let (center, a, b) = (at(c)?, at(a)?, at(b)?); + let radius = center.dist(a); + let (x, y) = ((a - center).normalized()?, normal.cross((a - center).normalized()?)); + let mut angle = (b - center).dot(y).atan2((b - center).dot(x)); + if angle <= 0.0 { + angle += std::f64::consts::TAU; + } + let shape = Shape::Round { center, axis: normal, radius, angle, planar: true, lo: 0.0, hi: 0.0 }; + (shape, arc_points(center, normal, a, angle)) + } + CurveKind::Line { a, b } => { + let (a, b) = (at(a)?, at(b)?); + (Shape::Line { a, b }, densify(&[a, b])) + } + _ => return None, + }; + let mut json = json!({"type": "sketch_curve", "id": id}); + let dir = match shape { + Shape::Line { a, b } => { + json["length_mm"] = json!(a.dist(b)); + (b - a).normalized() + } + _ => { + json["length_mm"] = json!(polyline_len(&pts)); + add_round(&mut json, &shape); + None + } + }; + Some(Item { geo: Geo::Points(pts), dir, json, shape, slot: None }) +} + +/// A profile (closed region of a sketch): its area, and when it is a circle or a slot, its +/// sizes. +fn profile(ss: &solidcraft_doc::SolvedSketch, index: usize) -> Option { + use solidcraft_geom::{Seg2, Vec2}; + let pr = ss.profiles.get(index)?; + let outline: Vec = pr.region.outer.polyline(1e-3).iter().map(|p| ss.plane.to_world(*p)).collect(); + let mut json = json!({"type": "profile", "area_mm2": pr.region.area()}); + let normal = ss.plane.normal(); + // Arcs about one centre merge (a circle is two halves); straight pieces stay as they are. + let mut arcs: Vec<(Vec2, f64, f64)> = Vec::new(); + let mut lines: Vec<(Vec2, Vec2)> = Vec::new(); + let mut other = !pr.region.holes.is_empty(); + for seg in &pr.region.outer.segs { + match *seg { + Seg2::Arc { center, radius, sweep, .. } => { + match arcs.iter_mut().find(|(c, r, _)| c.dist(center) <= 1e-6 * (1.0 + radius) && (r - radius).abs() <= 1e-6 * (1.0 + radius)) { + Some(a) => a.2 += sweep, + None => arcs.push((center, radius, sweep)), + } + } + Seg2::Line { a, b } => lines.push((a, b)), + _ => other = true, + } + } + let mut shape = Shape::Other; + let mut slot = None; + if !other { + if let ([(c, r, sweep)], []) = (arcs.as_slice(), lines.as_slice()) + && (sweep.abs() - std::f64::consts::TAU).abs() < 1e-6 + { + shape = Shape::Round { + center: ss.plane.to_world(*c), + axis: normal, + radius: *r, + angle: std::f64::consts::TAU, + planar: true, + lo: 0.0, + hi: 0.0, + }; + add_round(&mut json, &shape); + } else if let Some((a, b, r)) = super::measure_shape::slot_outline(&arcs, &lines) { + let s = Slot { width: 2.0 * r, centres: (ss.plane.to_world(a), ss.plane.to_world(b)) }; + json["slot"] = s.json(); + slot = Some(s); + } + } + let planar = Some(normal); + let geo = Geo::Points(densify(&outline)); + let shape = if matches!(shape, Shape::Other) { Shape::Plane { point: ss.plane.origin, normal } } else { shape }; + Some(Item { geo, dir: planar, json, shape, slot }) +} + +/// A slot this cylindrical face is one end of, from the body's other faces. +fn face_slot(b: &solidcraft_doc::ModelBody, m: &Mesh, shape: &Shape, pts: &[Vec3]) -> Option { + let faces = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0); + if faces > 4000 { + return None; + } + let mut verts: Vec> = vec![Vec::new(); faces]; + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + if let (Some(v), Some(tri)) = (verts.get_mut(*f as usize), m.tri(t)) { + v.extend(tri); + } + } + let surfaces: Vec<(solidcraft_kernel::FaceSurface, Vec)> = + verts.into_iter().enumerate().filter_map(|(i, v)| Some((solidcraft_kernel::face_surface(&b.body, i)?, v))).collect(); + super::measure_shape::slot_from_faces(shape, pts, &surfaces) +} + +fn describe(s: &Session, sel: &Sel) -> Option { let st = s.world_state(); - Some(match sel { - Sel::Vertex { point, .. } => (Geo::Points(vec![*point]), None, json!({"type": "vertex", "position": point})), + let plain = |geo, dir, json| Some(Item { geo, dir, json, shape: Shape::Other, slot: None }); + match sel { + Sel::Vertex { point, .. } => Some(Item { + geo: Geo::Points(vec![*point]), + dir: None, + json: json!({"type": "vertex", "position": point}), + shape: Shape::Point(*point), + slot: None, + }), Sel::Edge { body, index, point } => { - let m = st.body(body)?.mesh(); + let b = st.body(body)?; + let m = b.mesh(); let index = edge_index(&m, *index, *point)?; let pts = m.edges.get(index)?; let rim = m.circular_rim(index); @@ -82,29 +286,81 @@ fn describe(s: &Session, sel: &Sel) -> Option<(Geo, Option, Value)> { let pieces: Vec<&Vec> = rim.iter().filter_map(|&i| m.edges.get(i)).collect(); let len: f64 = pieces.iter().map(|p| polyline_len(p)).sum(); let points = pieces.iter().flat_map(|p| densify(p)).collect(); - return Some((Geo::Points(points), None, json!({"type": "edge", "length_mm": len}))); + let mut json = json!({"type": "edge", "length_mm": len}); + let shape = edge_round(b, &m, index, true).unwrap_or(Shape::Other); + add_round(&mut json, &shape); + return Some(Item { geo: Geo::Points(points), dir: None, json, shape, slot: None }); } - 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 (a, z) = (*pts.first()?, *pts.last()?); + let straight = (z - a).normalized().filter(|d| pts.iter().all(|p| (*p - a).cross(*d).len() < 1e-6 * (1.0 + a.dist(z)))); let len = polyline_len(pts); - (Geo::Points(densify(pts)), straight, json!({"type": "edge", "length_mm": len})) + let mut json = json!({"type": "edge", "length_mm": len}); + let shape = match straight { + Some(_) => Shape::Line { a, b: z }, + None => edge_round(b, &m, index, false).unwrap_or(Shape::Other), + }; + add_round(&mut json, &shape); + Some(Item { geo: Geo::Points(densify(pts)), dir: straight, json, shape, slot: None }) } Sel::Face { body, index, .. } => { - let m = st.body(body)?.mesh(); - let tris = m.surface_patch(*index).into_iter().flat_map(|f| face_tris(&m, f)).collect::>(); + let b = st.body(body)?; + let m = b.mesh(); + let patch = m.surface_patch(*index); + let tris = patch.iter().flat_map(|f| face_tris(&m, *f)).collect::>(); 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()})) + let mut json = json!({"type": "face", "area_mm2": area, "planar": planar.is_some()}); + let verts: Vec = tris.iter().flat_map(|t| t.iter().copied()).collect(); + let shape = match solidcraft_kernel::face_surface(&b.body, *index) { + Some(solidcraft_kernel::FaceSurface::Cylinder { origin, axis, radius }) => { + super::measure_shape::cylinder(origin, axis, radius, &verts).unwrap_or(Shape::Other) + } + Some(solidcraft_kernel::FaceSurface::Plane { normal, point }) if planar.is_some() => Shape::Plane { point, normal }, + None => match (planar, verts.first()) { + (Some(normal), Some(point)) => Shape::Plane { point: *point, normal }, + _ => Shape::Other, + }, + _ => Shape::Other, + }; + add_round(&mut json, &shape); + let slot = if matches!(shape, Shape::Round { .. }) && !shape.full() { face_slot(b, &m, &shape, &verts) } else { None }; + if let Some(sl) = &slot { + json["slot"] = sl.json(); + } + Some(Item { geo: Geo::Tris(tris), dir: planar, json, shape, slot }) } 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})) + plain(Geo::Tris(tris), None, json!({"type": "body", "name": name, "volume_mm3": v})) } - _ => return None, - }) + Sel::SketchCurve { id } => sketch_curve(sketch_of(s, &st, None, |sk| sk.curve_index(id).is_some())?, id), + Sel::ExternalSketchCurve { sketch, id } => sketch_curve(sketch_of(s, &st, Some(*sketch), |_| true)?, id), + Sel::SketchPoint { id } | Sel::ExternalSketchPoint { id, .. } => { + let given = match sel { + Sel::ExternalSketchPoint { sketch, .. } => Some(*sketch), + _ => None, + }; + // ":" names a point of any sketch. + let (given, id) = match id.split_once(':').and_then(|(k, p)| Some((k.parse::().ok()?, p))) { + Some((k, p)) if given.is_none() => (Some(k), p), + _ => (given, id.as_str()), + }; + let ss = sketch_of(s, &st, given, |sk| sk.point_index(id).is_some())?; + let p = ss.plane.to_world(ss.sketch.points.get(ss.sketch.point_index(id)?)?.pos); + Some(Item { + geo: Geo::Points(vec![p]), + dir: None, + json: json!({"type": "sketch_point", "id": id, "position": p}), + shape: Shape::Point(p), + slot: None, + }) + } + Sel::Profile { sketch, index } => profile(st.sketch(*sketch)?, *index), + _ => None, + } } fn sample(v: &[T], max: usize) -> Vec { @@ -195,22 +451,89 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result { } 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)"))?); + described.push( + describe(s, x).ok_or_else(|| bad(cmd, "an item can't be measured (pick vertices, edges, faces, bodies, sketch curves or profiles)"))?, + ); } - 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)); + let mut out = json!({"items": described.iter().map(|d| d.json.clone()).collect::>()}); + if let [one] = described.as_slice() { + for k in ["diameter", "radius", "circumference", "angle_deg", "arc_length", "center", "slot"] { + if let Some(v) = one.json.get(k) { + out[k] = v.clone(); + } + } + if let Some(sl) = &one.slot { + out["center_from"] = json!(sl.centres.0); + out["center_to"] = json!(sl.centres.1); + } + } + if let [a, b] = described.as_slice() { + let near = closest(&a.geo, &b.geo); + let far = farthest(&a.geo, &b.geo); + let c = centre(&a.shape, &b.shape); + // An exact closest or farthest distance stands only where the mesh agrees with it to + // within its chord error (a bounded face can end before the exact value's point). + let radii = [a.shape, b.shape].iter().map(|x| if let Shape::Round { radius, .. } = x { *radius } else { 0.0 }).sum::(); + let slack = radii * 0.05 + 1e-6; + let mut min = near.map(|(p, q)| p.dist(q)); + if let (Some(exact), Some(m)) = (c.and_then(|c| c.min), min) + && (m - exact).abs() <= slack + { + min = Some(exact); + } + let mut max = far.map(|(p, q)| p.dist(q)); + if let (Some(exact), Some(m)) = (c.and_then(|c| c.max), max) + && (m - exact).abs() <= slack + { + max = Some(exact); + } + if let (Some((p, q)), Some(d)) = (near, min) { + out["distance_mm"] = json!(d); out["from"] = json!(p); out["to"] = json!(q); out["delta"] = json!(q - p); + out["min"] = json!(d); } - if let (Some(a), Some(b)) = (da, db) { + if let (Some((p, q)), Some(d)) = (far, max) { + out["max"] = json!(d); + out["max_from"] = json!(p); + out["max_to"] = json!(q); + out["max_delta"] = json!(q - p); + } + if let Some(c) = c { + out["center_distance"] = json!(c.distance); + out["center_from"] = json!(c.from); + out["center_to"] = json!(c.to); + out["center_delta"] = json!(c.to - c.from); + } + out["measure"] = json!(if c.is_some() { "center" } else { "min" }); + if let (Some(da), Some(db)) = (a.dir, b.dir) { 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()); + let cos = da.dot(db).clamp(-1.0, 1.0); + let cos = if both_faces { cos } else { cos.abs() }; + out["angle_deg"] = json!(cos.acos().to_degrees()); } } Ok(out) } + +/// Farthest pair of points between two pieces of geometry (on their samples and vertices). +fn farthest(a: &Geo, b: &Geo) -> Option<(Vec3, Vec3)> { + let pts = |g: &Geo| -> Vec { + match g { + Geo::Points(p) => sample(p, 2000), + Geo::Tris(t) => sample(&t.iter().flat_map(|t| t.iter().copied()).collect::>(), 2000), + } + }; + let (p, q) = (pts(a), pts(b)); + 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)) +} diff --git a/crates/engine/src/cmd/measure_shape.rs b/crates/engine/src/cmd/measure_shape.rs new file mode 100644 index 00000000..507d57a3 --- /dev/null +++ b/crates/engine/src/cmd/measure_shape.rs @@ -0,0 +1,385 @@ +//! What a picked item is, exactly (#1392): circles, arcs and cylinders (round items with an +//! axis), planes, straight lines and points, from the B-rep's exact curves and surfaces or the +//! sketch's own geometry. Round items report their diameter and radius, and a pair with a +//! round item measures from its centre or axis, with the closest and farthest distances too. + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use std::f64::consts::{PI, TAU}; + +/// The exact shape of a picked item. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Shape { + /// A circle, an arc or a cylinder. `center` lies on the axis: a circle's or arc's centre, + /// or for a cylinder the point of the axis level with the middle of the face. `lo..hi` is + /// the extent along `axis` from `center` (0..0 for a planar circle). `angle` is the span + /// around the axis (2π all the way round). + Round { + center: Vec3, + axis: Vec3, + radius: f64, + angle: f64, + planar: bool, + lo: f64, + hi: f64, + }, + Plane { + point: Vec3, + normal: Vec3, + }, + Line { + a: Vec3, + b: Vec3, + }, + Point(Vec3), + Other, +} + +/// Within this of a whole turn counts as all the way round (a tessellated face's coverage). +const FULL_TURN: f64 = TAU - 1e-3; + +impl Shape { + pub(crate) fn full(&self) -> bool { + matches!(self, Shape::Round { angle, .. } if *angle >= FULL_TURN) + } +} + +/// Diameter, radius and length (circumference or arc length, with the arc's angle) of a round +/// item, added to its description. Ø first, then R, then the length. +pub(crate) fn add_round(v: &mut Value, shape: &Shape) { + let Shape::Round { center, axis, radius, angle, planar, .. } = *shape else { return }; + v["diameter"] = json!(2.0 * radius); + v["radius"] = json!(radius); + if angle >= FULL_TURN { + v["circumference"] = json!(TAU * radius); + } else { + v["angle_deg"] = json!(angle.to_degrees()); + v["arc_length"] = json!(radius * angle); + } + v["center"] = json!(center); + v["axis"] = json!(axis); + v["round"] = json!(if !planar { + "cylinder" + } else if angle >= FULL_TURN { + "circle" + } else { + "arc" + }); +} + +/// Cylinder `axis` through `origin` of radius `radius` over the points of its face: the +/// round shape, centred level with the middle of the face, with its extent and coverage. +pub(crate) fn cylinder(origin: Vec3, axis: Vec3, radius: f64, pts: &[Vec3]) -> Option { + let axis = axis.normalized()?; + if pts.is_empty() || !(radius.is_finite() && radius > 0.0) { + return None; + } + let ts: Vec = pts.iter().map(|p| (*p - origin).dot(axis)).collect(); + let (tmin, tmax) = ts.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), t| (a.min(*t), b.max(*t))); + let mid = (tmin + tmax) * 0.5; + let center = origin + axis * mid; + Some(Shape::Round { center, axis, radius, angle: coverage(center, axis, pts), planar: false, lo: tmin - mid, hi: tmax - mid }) +} + +/// The angle around the axis the points cover: a whole turn less the widest gap between them. +pub(crate) fn coverage(center: Vec3, axis: Vec3, pts: &[Vec3]) -> f64 { + let x = axis.any_perp(); + let y = axis.cross(x); + let mut angles: Vec = pts + .iter() + .filter_map(|p| { + let d = *p - center; + let r = d - axis * d.dot(axis); + (r.len() > 1e-12).then(|| r.dot(y).atan2(r.dot(x))) + }) + .collect(); + if angles.len() < 2 { + return 0.0; + } + angles.sort_by(f64::total_cmp); + let wrap = angles.first().copied().unwrap_or(0.0) + TAU - angles.last().copied().unwrap_or(0.0); + let widest = angles.windows(2).map(|w| w[1] - w[0]).fold(wrap, f64::max); + // Tessellation leaves gaps of a few degrees on a whole cylinder: those still count. + if widest < 0.35 { TAU } else { TAU - widest } +} + +/// The unit direction a point leaves the axis at, towards the middle of the face's points. +fn bulge(center: Vec3, axis: Vec3, pts: &[Vec3]) -> Option { + let sum = pts.iter().fold(Vec3::ZERO, |acc, p| { + let d = *p - center; + acc + (d - axis * d.dot(axis)).normalized().unwrap_or(Vec3::ZERO) + }); + sum.normalized() +} + +/// Closest points of two infinite lines (a point and a unit direction each). +fn line_line(p1: Vec3, d1: Vec3, p2: Vec3, d2: Vec3) -> (Vec3, Vec3) { + let w = p1 - p2; + let b = d1.dot(d2); + let denom = 1.0 - b * b; + if denom < 1e-12 { + return (p1, p2 + d2 * w.dot(d2)); + } + let (d, e) = (d1.dot(w), d2.dot(w)); + let s = (b * e - d) / denom; + let t = (e - b * d) / denom; + (p1 + d1 * s, p2 + d2 * t) +} + +fn parallel(a: Vec3, b: Vec3) -> bool { + a.cross(b).len() < 1e-6 +} + +/// A measurement from a round item's centre or axis. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Centre { + pub distance: f64, + pub from: Vec3, + pub to: Vec3, + /// Closest and farthest distances worked out exactly (`None`: measure them on the mesh). + pub min: Option, + pub max: Option, +} + +/// Distance between two non-negative in-plane circles' rims, closest and farthest, with +/// centres `d` apart. +fn rims(d: f64, ra: f64, rb: f64) -> (f64, f64) { + let near = if d >= ra + rb { + d - ra - rb + } else if d <= (ra - rb).abs() { + (ra - rb).abs() - d + } else { + 0.0 + }; + (near, d + ra + rb) +} + +/// Centre-to-centre (axis to axis, axis to plane, line or point) between two items, when one +/// of them is round. `None` for pairs without a round item and where an axis meets the other +/// item at an angle that gives no single distance (a cylinder crossing a plane). +pub(crate) fn centre(a: &Shape, b: &Shape) -> Option { + match (a, b) { + (Shape::Round { .. }, _) => from_round(a, b), + (_, Shape::Round { .. }) => from_round(b, a).map(|c| Centre { from: c.to, to: c.from, ..c }), + _ => None, + } +} + +fn from_round(a: &Shape, b: &Shape) -> Option { + let Shape::Round { center: ca, axis: na, radius: ra, planar: pa, lo: loa, hi: hia, .. } = *a else { return None }; + let full_a = a.full(); + let c = match *b { + Shape::Round { center: cb, axis: nb, radius: rb, lo: lob, hi: hib, .. } => { + if parallel(na, nb) { + let w = cb - ca; + let along = w.dot(na); + // Drawn level with A's centre: on B's axis, the foot of A's centre. + let foot = cb - na * along; + let d = (w - na * along).len(); + let (mut min, mut max) = (None, None); + if full_a && b.full() { + // B's extent along A's axis. + let s = if na.dot(nb) >= 0.0 { 1.0 } else { -1.0 }; + let (b0, b1) = (along + lob * s, along + hib * s); + let (b0, b1) = (b0.min(b1), b0.max(b1)); + let gap = (loa.max(b0) - hia.min(b1)).max(0.0); + let reach = (hia - b0).abs().max((b1 - loa).abs()); + let (near, far) = rims(d, ra, rb); + min = Some(near.hypot(gap)); + max = Some(far.hypot(reach)); + } + Centre { distance: d, from: ca, to: foot, min, max } + } else { + let (p, q) = line_line(ca, na, cb, nb); + Centre { distance: p.dist(q), from: p, to: q, min: None, max: None } + } + } + Shape::Plane { point, normal } => { + let n = normal.normalized()?; + let h = (ca - point).dot(n); + if na.dot(n).abs() < 1e-6 { + // Axis along the plane: axis to plane, and the rim's nearest and farthest. + let d = h.abs(); + let (min, max) = if full_a { (Some((d - ra).max(0.0)), Some(d + ra)) } else { (None, None) }; + Centre { distance: d, from: ca, to: ca - n * h, min: if d >= ra { min } else { None }, max } + } else if pa { + // A circle's centre to the plane. + Centre { distance: h.abs(), from: ca, to: ca - n * h, min: None, max: None } + } else { + return None; + } + } + Shape::Line { a: la, b: lb } => { + let dir = (lb - la).normalized()?; + let (p, q) = line_line(ca, na, la, dir); + let d = p.dist(q); + let across = dir.dot(na).abs() < 1e-6; + let min = (full_a && across && d >= ra).then_some(d - ra); + Centre { distance: d, from: p, to: q, min, max: None } + } + Shape::Point(p) => { + if pa { + let d = p.dist(ca); + let in_plane = (p - ca).dot(na).abs() < 1e-6 * (1.0 + d); + let (min, max) = if full_a && in_plane { (Some((d - ra).abs()), Some(d + ra)) } else { (None, None) }; + Centre { distance: d, from: ca, to: p, min, max } + } else { + let w = p - ca; + let foot = ca + na * w.dot(na); + Centre { distance: p.dist(foot), from: foot, to: p, min: None, max: None } + } + } + Shape::Other => return None, + }; + (c.distance.is_finite() && c.from.is_finite() && c.to.is_finite()).then_some(c) +} + +/// A slot's sizes: width (the ends' diameter), the distance between the ends' centres and the +/// overall length, with the two centres. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Slot { + pub width: f64, + pub centres: (Vec3, Vec3), +} + +impl Slot { + pub(crate) fn json(&self) -> Value { + let c2c = self.centres.0.dist(self.centres.1); + json!({"width": self.width, "center_distance": c2c, "overall_length": c2c + self.width, "centers": [self.centres.0, self.centres.1]}) + } +} + +/// A slot's outline in its plane: two half-circle ends of one radius joined by two straight +/// sides. `arcs` are (centre, radius, swept angle) with pieces of one end already merged, +/// `lines` the straight pieces (2D, in the sketch plane). +pub(crate) fn slot_outline( + arcs: &[(solidcraft_geom::Vec2, f64, f64)], + lines: &[(solidcraft_geom::Vec2, solidcraft_geom::Vec2)], +) -> Option<(solidcraft_geom::Vec2, solidcraft_geom::Vec2, f64)> { + let [(c1, r1, s1), (c2, r2, s2)] = arcs else { return None }; + let tol = 1e-6 * (1.0 + r1.abs() + c1.dist(*c2)); + if (r1 - r2).abs() > tol || (s1.abs() - PI).abs() > 1e-4 || (s2.abs() - PI).abs() > 1e-4 || c1.dist(*c2) <= tol || lines.is_empty() { + return None; + } + let along = (*c2 - *c1).normalized()?; + // Every straight piece runs along the slot, `r` to one side of the centre line. + let side = |p: solidcraft_geom::Vec2| (p - *c1).cross(along); + let ok = lines.iter().all(|(a, b)| { + let d = *b - *a; + d.cross(along).abs() <= tol * (1.0 + d.len()) && (side(*a).abs() - r1).abs() <= tol && (side(*b).abs() - r1).abs() <= tol + }); + let both = lines.iter().any(|(a, _)| side(*a) > 0.0) && lines.iter().any(|(a, _)| side(*a) < 0.0); + (ok && both).then_some((*c1, *c2, *r1)) +} + +/// A slot cut or boss on a solid, found from one of its half-cylinder ends: another half +/// cylinder of the same radius on a parallel axis, bulging the other way, and two planes along +/// the slot `r` either side of its centre line. `faces` gives each face's surface and points. +pub(crate) fn slot_from_faces(picked: &Shape, picked_pts: &[Vec3], faces: &[(solidcraft_kernel::FaceSurface, Vec)]) -> Option { + use solidcraft_kernel::FaceSurface; + let Shape::Round { center, axis, radius, angle, planar: false, .. } = *picked else { return None }; + if (angle - PI).abs() > 0.3 { + return None; + } + let tol = 1e-6 * (1.0 + radius) * 100.0; + let out_a = bulge(center, axis, picked_pts)?; + for (s, pts) in faces { + let FaceSurface::Cylinder { origin, axis: ax, radius: r } = *s else { continue }; + if (r - radius).abs() > tol || !parallel(ax, axis) { + continue; + } + let Some(Shape::Round { center: cb, angle: ab, .. }) = cylinder(origin, ax, r, pts) else { continue }; + let w = cb - center; + let off = w - axis * w.dot(axis); + let Some(along) = off.normalized() else { continue }; + if (ab - PI).abs() > 0.3 || off.len() <= tol { + continue; + } + let Some(out_b) = bulge(cb, axis, pts) else { continue }; + // Each end bulges away from the other. + if out_a.dot(along) > -0.5 || out_b.dot(along) < 0.5 { + continue; + } + let across = axis.cross(along); + let sides = faces.iter().filter(|(fs, _)| match *fs { + FaceSurface::Plane { normal, point } => { + parallel(normal, across) && ((point - center).dot(across).abs() - radius).abs() <= tol.max(radius * 1e-4) + } + _ => false, + }); + let (mut pos, mut neg) = (false, false); + for (fs, _) in sides { + if let FaceSurface::Plane { point, .. } = fs { + if (*point - center).dot(across) > 0.0 { pos = true } else { neg = true } + } + } + if pos && neg { + // Both centres level with the picked end's middle. + return Some(Slot { width: 2.0 * radius, centres: (center, center + off) }); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use solidcraft_geom::Vec2; + + fn circle(c: Vec3, r: f64) -> Shape { + Shape::Round { center: c, axis: Vec3::Z, radius: r, angle: TAU, planar: true, lo: 0.0, hi: 0.0 } + } + + #[test] + fn two_circles_measure_centre_to_centre_with_rims() { + let c = centre(&circle(Vec3::ZERO, 3.0), &circle(Vec3::new(30.0, 40.0, 0.0), 2.0)).unwrap(); + assert!((c.distance - 50.0).abs() < 1e-12); + assert!((c.min.unwrap() - 45.0).abs() < 1e-12); + assert!((c.max.unwrap() - 55.0).abs() < 1e-12); + } + + #[test] + fn a_cylinder_to_a_side_plane_measures_from_its_axis() { + let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: Vec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 }; + let plane = Shape::Plane { point: Vec3::ZERO, normal: Vec3::new(-1.0, 0.0, 0.0) }; + let c = centre(&cyl, &plane).unwrap(); + assert!((c.distance - 10.0).abs() < 1e-12); + assert!((c.min.unwrap() - 7.5).abs() < 1e-12 && (c.max.unwrap() - 12.5).abs() < 1e-12); + // Swapped, the line runs the other way. + let d = centre(&plane, &cyl).unwrap(); + assert_eq!((d.from, d.to), (c.to, c.from)); + // A cylinder crossing a plane has no single axis distance. + assert!(centre(&cyl, &Shape::Plane { point: Vec3::ZERO, normal: Vec3::Z }).is_none()); + } + + #[test] + fn an_axis_to_a_crossing_edge_is_the_distance_between_the_lines() { + let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: Vec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 }; + let edge = Shape::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(40.0, 0.0, 10.0) }; + let c = centre(&cyl, &edge).unwrap(); + assert!((c.distance - 15.0).abs() < 1e-12); + assert!((c.min.unwrap() - 12.5).abs() < 1e-12); + assert!(c.from.dist(Vec3::new(10.0, 15.0, 10.0)) < 1e-12 && c.to.dist(Vec3::new(10.0, 0.0, 10.0)) < 1e-12); + } + + #[test] + fn coverage_of_a_half_and_a_whole_ring() { + let ring = |n: usize, span: f64| -> Vec { + (0..=n).map(|i| Vec3::new((span * i as f64 / n as f64).cos(), (span * i as f64 / n as f64).sin(), 0.0)).collect() + }; + assert!((coverage(Vec3::ZERO, Vec3::Z, &ring(32, PI)) - PI).abs() < 1e-9); + assert_eq!(coverage(Vec3::ZERO, Vec3::Z, &ring(32, TAU)), TAU); + } + + #[test] + fn a_slot_outline_and_not_a_slot() { + let arcs = [(Vec2::new(0.0, 0.0), 3.0, PI), (Vec2::new(20.0, 0.0), 3.0, PI)]; + let lines = [(Vec2::new(0.0, -3.0), Vec2::new(20.0, -3.0)), (Vec2::new(20.0, 3.0), Vec2::new(0.0, 3.0))]; + let (a, b, r) = slot_outline(&arcs, &lines).unwrap(); + assert_eq!((a.dist(b), r), (20.0, 3.0)); + let skew = [(Vec2::new(0.0, -3.0), Vec2::new(20.0, -2.0)), (Vec2::new(20.0, 3.0), Vec2::new(0.0, 3.0))]; + assert!(slot_outline(&arcs, &skew).is_none()); + assert!(slot_outline(&[(Vec2::new(0.0, 0.0), 3.0, PI), (Vec2::new(20.0, 0.0), 4.0, PI)], &lines).is_none()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 07f2c088..c4d2d0c2 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -42,6 +42,7 @@ mod libraries; mod material_properties; mod materials; mod measure_sel; +mod measure_shape; mod mesh; mod metadata; pub(crate) mod motion; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 32b2a266..59a0a7ce 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -941,6 +941,8 @@ mod hole_matrix_tests; #[cfg(test)] mod large_assembly_tests; #[cfg(test)] +mod measure_centre_tests; +#[cfg(test)] mod naming_history_tests; #[cfg(test)] mod pattern_suppress_tests; diff --git a/crates/engine/src/measure_centre_tests.rs b/crates/engine/src/measure_centre_tests.rs new file mode 100644 index 00000000..74791889 --- /dev/null +++ b/crates/engine/src/measure_centre_tests.rs @@ -0,0 +1,218 @@ +//! Measure on holes, circles, arcs and slots (#1392): centre-to-centre and axis-to-edge +//! distances with the closest and farthest listed too, diameters and radii, slot sizes. Every +//! expected value is worked out by hand from the part's dimensions. + +use std::f64::consts::{PI, TAU}; + +use serde_json::{Value, json}; +use solidcraft_geom::Vec3; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn close(v: &Value, want: f64, tol: f64) -> bool { + v.as_f64().is_some_and(|x| (x - want).abs() <= tol) +} + +/// A 60 × 40 × 10 plate with a Ø6 hole at (15, 20) and a Ø8 hole at (45, 20). +fn plate(s: &mut Session) { + run(s, "solid.box", json!({"length": 60, "width": 40, "height": 10})); + run(s, "solid.cylinder", json!({"radius": 3, "height": 12, "base": [15, 20, -1], "operation": "cut"})); + run(s, "solid.cylinder", json!({"radius": 4, "height": 12, "base": [45, 20, -1], "operation": "cut"})); +} + +fn body(s: &Session) -> String { + s.model.state().bodies.first().unwrap().name.clone() +} + +/// The cylindrical face of radius `r`, as a pick. +fn hole(s: &Session, r: f64) -> Value { + let st = s.model.state(); + let b = st.bodies.first().unwrap(); + let m = b.mesh(); + let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap(); + let f = (0..n) + .find(|&f| matches!(solidcraft_kernel::face_surface(&b.body, f), Some(solidcraft_kernel::FaceSurface::Cylinder { radius, .. }) if (radius - r).abs() < 1e-6)) + .unwrap_or_else(|| panic!("no hole of radius {r}")); + let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0]; + json!({"type": "face", "body": b.name, "index": f, "point": p}) +} + +/// The planar face all of whose points satisfy `on`, as a pick. +fn flat(s: &Session, on: impl Fn(Vec3) -> bool) -> Value { + let st = s.model.state(); + let b = st.bodies.first().unwrap(); + let m = b.mesh(); + let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap(); + let f = (0..n) + .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.is_empty() && tris.iter().all(|t| t.iter().all(|p| on(*p))) + }) + .unwrap(); + let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == f).and_then(|(t, _)| m.tri(t)).unwrap()[0]; + json!({"type": "face", "body": b.name, "index": f, "point": p}) +} + +/// The mesh edge all of whose points satisfy `on`, as a pick. +fn edge(s: &Session, on: impl Fn(Vec3) -> bool) -> Value { + let st = s.model.state(); + let b = st.bodies.first().unwrap(); + let m = b.mesh(); + let i = m.edges.iter().position(|e| e.iter().all(|p| on(*p))).expect("edge"); + let e = &m.edges[i]; + json!({"type": "edge", "body": b.name, "index": i, "point": e[e.len() / 2]}) +} + +fn measure(s: &mut Session, items: Vec) -> Value { + run(s, "inspect.measure", json!({ "items": items })) +} + +#[test] +fn two_holes_measure_centre_to_centre_with_min_and_max() { + let mut s = Session::default(); + plate(&mut s); + let (a, b) = (hole(&s, 3.0), hole(&s, 4.0)); + let r = measure(&mut s, vec![a.clone(), b.clone()]); + assert!(close(&r["center_distance"], 30.0, 1e-9), "{r}"); + assert!(close(&r["min"], 30.0 - 3.0 - 4.0, 1e-9), "{r}"); + assert!(close(&r["max"], (37.0f64).hypot(10.0), 1e-9), "{r}"); + assert_eq!(r["measure"], "center"); + // Backward compatible: the closest distance is still `distance_mm`, with its points. + assert!(close(&r["distance_mm"], 23.0, 1e-9) && r["from"].is_array() && r["to"].is_array(), "{r}"); + // The centre line runs between the two axes, square to them. + let (f, t) = (&r["center_from"], &r["center_to"]); + assert!(close(&f[0], 15.0, 1e-9) && close(&f[1], 20.0, 1e-9) && close(&t[0], 45.0, 1e-9) && close(&t[1], 20.0, 1e-9), "{r}"); + assert!(close(&f[2], t[2].as_f64().unwrap(), 1e-9), "{r}"); + // Each hole: Ø, R and circumference. + assert!(close(&r["items"][0]["diameter"], 6.0, 1e-9) && close(&r["items"][0]["radius"], 3.0, 1e-9), "{r}"); + assert!(close(&r["items"][1]["circumference"], 8.0 * PI, 1e-9), "{r}"); + // The two rims on the top face: the same centres, the rims' gap and span. + let rim = |c: f64, rr: f64| move |p: Vec3| (p.z - 10.0).abs() < 1e-6 && ((p - Vec3::new(c, 20.0, 10.0)).len() - rr).abs() < 1e-6; + let (ea, eb) = (edge(&s, rim(15.0, 3.0)), edge(&s, rim(45.0, 4.0))); + let r = measure(&mut s, vec![ea.clone(), eb]); + assert!(close(&r["center_distance"], 30.0, 1e-9) && close(&r["min"], 23.0, 1e-9) && close(&r["max"], 37.0, 1e-9), "{r}"); + // One rim: Ø, R and circumference first-class. + let r = measure(&mut s, vec![ea]); + assert!(close(&r["diameter"], 6.0, 1e-9) && close(&r["radius"], 3.0, 1e-9) && close(&r["circumference"], TAU * 3.0, 1e-9), "{r}"); + assert_eq!(r["items"][0]["round"], "circle", "{r}"); +} + +#[test] +fn a_hole_to_the_plate_edge_and_side_measures_from_its_axis() { + let mut s = Session::default(); + plate(&mut s); + let a = hole(&s, 3.0); + // The top front edge (y = 0, z = 10). + let e = edge(&s, |p| p.y.abs() < 1e-9 && (p.z - 10.0).abs() < 1e-9); + let r = measure(&mut s, vec![a.clone(), e]); + assert!(close(&r["center_distance"], 20.0, 1e-9), "{r}"); + assert!(close(&r["min"], 17.0, 1e-9), "{r}"); + assert!(r["max"].as_f64().unwrap() > 20.0, "{r}"); + // The front face (y = 0): axis to plane, the rim's nearest and farthest. + let f = flat(&s, |p| p.y.abs() < 1e-9); + let r = measure(&mut s, vec![f, a.clone()]); + assert!(close(&r["center_distance"], 20.0, 1e-9) && close(&r["min"], 17.0, 1e-9), "{r}"); + // The face reaches past the hole: its far corners are farther than the rim's far side. + assert!(r["max"].as_f64().unwrap() > 23.0, "{r}"); + // Picked plane first: the centre line still starts at the plane. + assert!(close(&r["center_from"][1], 0.0, 1e-9) && close(&r["center_to"][1], 20.0, 1e-9), "{r}"); + // Two planes keep today's measure: no centre. + let top = flat(&s, |p| (p.z - 10.0).abs() < 1e-9); + let bottom = flat(&s, |p| p.z.abs() < 1e-9); + let r = measure(&mut s, vec![top, bottom]); + assert!(r.get("center_distance").is_none() && close(&r["distance_mm"], 10.0, 1e-9) && r["measure"] == "min", "{r}"); +} + +#[test] +fn sketch_circle_arc_and_slot() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + let c = run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 5}))["curves"][0].as_str().unwrap().to_string(); + let a = + run(&mut s, "sketch.arc.center_point", json!({"center": [30, 0], "start": [34, 0], "sweep": 90}))["curves"][0].as_str().unwrap().to_string(); + let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": c})]); + assert!(close(&r["diameter"], 10.0, 1e-9) && close(&r["radius"], 5.0, 1e-9) && close(&r["circumference"], 10.0 * PI, 1e-9), "{r}"); + let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": a})]); + assert!(close(&r["radius"], 4.0, 1e-9) && close(&r["diameter"], 8.0, 1e-9), "{r}"); + assert!(close(&r["angle_deg"], 90.0, 1e-9) && close(&r["arc_length"], 2.0 * PI, 1e-9), "{r}"); + assert_eq!(r["items"][0]["round"], "arc"); + // Circle to arc: centre to centre. + let r = measure(&mut s, vec![json!({"type": "sketch_curve", "id": c}), json!({"type": "sketch_curve", "id": a})]); + assert!(close(&r["center_distance"], 30.0, 1e-9), "{r}"); + run(&mut s, "sketch.slot.center_to_center", json!({"p0": [0, 40], "p1": [20, 40], "width": 6})); + let sid = s.active_sketch.unwrap(); + run(&mut s, "sketch.finish", json!({})); + let st = s.model.state(); + let ss = st.sketch(sid).unwrap(); + let slot = + ss.profiles.iter().position(|p| p.region.outer.segs.len() >= 4 && (p.area - (20.0 * 6.0 + PI * 9.0)).abs() < 1e-6).expect("slot profile"); + let circle = ss.profiles.iter().position(|p| (p.area - PI * 25.0).abs() < 1e-6).expect("circle profile"); + let r = measure(&mut s, vec![json!({"type": "profile", "sketch": sid, "index": slot})]); + let sl = &r["slot"]; + assert!(close(&sl["width"], 6.0, 1e-9) && close(&sl["center_distance"], 20.0, 1e-9) && close(&sl["overall_length"], 26.0, 1e-9), "{r}"); + let r = measure(&mut s, vec![json!({"type": "profile", "sketch": sid, "index": circle})]); + assert!(close(&r["diameter"], 10.0, 1e-9), "{r}"); +} + +#[test] +fn a_slot_cut_in_a_plate_reports_its_sizes_from_an_end() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 10})); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.slot.center_to_center", json!({"p0": [20, 20], "p1": [40, 20], "width": 8})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 10, "operation": "cut", "profiles": "all"})); + let st = s.model.state(); + let b = st.bodies.first().unwrap(); + let m = b.mesh(); + let n = m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap(); + let end = (0..n) + .find(|&f| matches!(solidcraft_kernel::face_surface(&b.body, f), Some(solidcraft_kernel::FaceSurface::Cylinder { radius, .. }) if (radius - 4.0).abs() < 1e-6)) + .expect("slot end"); + let p = m.triangles.iter().zip(&m.tri_face).find(|(_, tf)| **tf as usize == end).and_then(|(t, _)| m.tri(t)).unwrap()[0]; + let name = body(&s); + let r = measure(&mut s, vec![json!({"type": "face", "body": name, "index": end, "point": p})]); + let sl = &r["slot"]; + assert!(close(&sl["width"], 8.0, 1e-9) && close(&sl["center_distance"], 20.0, 1e-9) && close(&sl["overall_length"], 28.0, 1e-9), "{r}"); + // The end is a half cylinder: R 4, half a turn. + assert!(close(&r["radius"], 4.0, 1e-9) && close(&r["angle_deg"], 180.0, 1.0), "{r}"); +} + +#[test] +fn a_fillet_edge_is_an_arc() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20})); + run(&mut s, "solid.fillet", json!({"edges": [[40, 30, 10]], "radius": 5})); + // The fillet's quarter circle on the top face: centre (35, 25, 20). + let e = edge(&s, |p| (p.z - 20.0).abs() < 1e-9 && ((p - Vec3::new(35.0, 25.0, 20.0)).len() - 5.0).abs() < 1e-6); + let r = measure(&mut s, vec![e]); + assert!(close(&r["radius"], 5.0, 1e-9) && close(&r["diameter"], 10.0, 1e-9), "{r}"); + assert!(close(&r["angle_deg"], 90.0, 1e-6) && close(&r["arc_length"], 2.5 * PI, 1e-6), "{r}"); + assert!(close(&r["center"][0], 35.0, 1e-9) && close(&r["center"][1], 25.0, 1e-9), "{r}"); +} + +/// A plate with holes through STEP: the holes come back as cylinders, and measure the same. +#[test] +fn step_round_trip_of_a_plate_with_holes() { + let dir = std::env::temp_dir().join(format!("solidcraft-measure-1392-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Plate.step"); + let mut s = Session::default(); + plate(&mut s); + run(&mut s, "file.export", json!({"path": path.to_string_lossy()})); + let mut t = Session::default(); + run(&mut t, "doc.open", json!({"path": path.to_string_lossy()})); + let (a, b) = (hole(&t, 3.0), hole(&t, 4.0)); + let r = measure(&mut t, vec![a, b]); + assert!(close(&r["center_distance"], 30.0, 1e-6), "{r}"); + assert!(close(&r["min"], 23.0, 1e-6), "{r}"); + assert!(close(&r["items"][0]["diameter"], 6.0, 1e-6) && close(&r["items"][1]["diameter"], 8.0, 1e-6), "{r}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 0eaf370c..d99415e9 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -143,7 +143,7 @@ pub use step::step_export; pub use step_in::{ImportedBody, StepImport, StepNode, step_cache_alive, step_import, step_import_shared, step_orientation_errors, step_validate}; pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products}; pub use surface_extend::{ExtendLimit, ExtendMode, ExtendOptions, extend_surface}; -pub use surface_kind::{FaceSurface, face_surface}; +pub use surface_kind::{EdgeCircle, FaceSurface, edge_circle, face_surface}; pub use surface_offset::offset_surface; pub use surface_ruled::{Ruled, RuledCorners, RuledMode, ruled_surface}; pub use surface_untrim::{UntrimHoles, untrim}; diff --git a/crates/kernel/src/surface_kind.rs b/crates/kernel/src/surface_kind.rs index 9ef1f2da..2982fb59 100644 --- a/crates/kernel/src/surface_kind.rs +++ b/crates/kernel/src/surface_kind.rs @@ -3,7 +3,10 @@ use solidcraft_geom::Vec3; +use truck_modeling as mt; + use crate::Body; +use crate::body::from_p3; use crate::offset::{Surf, surf_of}; /// A face's analytic surface. Axes are unit vectors. @@ -51,6 +54,85 @@ pub fn face_surface(b: &Body, face: usize) -> Option { }) } +/// The circle (or arc of one) an edge's curve lies on, from its exact curve. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct EdgeCircle { + pub center: Vec3, + /// Unit normal of the circle's plane, oriented so the edge runs counter-clockwise about it. + pub axis: Vec3, + pub radius: f64, + /// The edge's ends (the same point for a closed circle). + pub start: Vec3, + pub end: Vec3, + /// The angle the edge spans (radians, 2π for a whole circle). + pub angle: f64, +} + +/// The circle edge `edge` (in the order of [`Body::edges`] and the tessellated mesh's edges) +/// lies on, if its curve is a circle or an arc of one. `None` for other curves, mesh bodies +/// and an index out of range. +pub fn edge_circle(b: &Body, edge: usize) -> Option { + use mt::{BoundedCurve, ParametricCurve}; + if b.mesh.is_some() { + return None; + } + let e = Body::unique_edges(&b.solid).into_iter().nth(edge)?; + let tol = (b.size() * 1e-7).max(1e-9) * 100.0; + crate::guard("edge circle", || { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + let at = |f: f64| from_p3(c.subs(t0 + (t1 - t0) * f)); + let samples: Vec = (0..=24).map(|i| at(f64::from(i) / 24.0)).collect(); + Ok(circle_through(&samples, tol)) + }) + .ok() + .flatten() +} + +/// The circle through evenly spaced samples of a curve (first to last), if they all lie on one. +fn circle_through(s: &[Vec3], tol: f64) -> Option { + let (start, end) = (*s.first()?, *s.last()?); + let closed = start.dist(end) <= tol; + // Three well-spread samples: a quarter, half and three quarters for a closed curve. + let n = s.len().checked_sub(1)?; + let (a, b, c) = if closed { (*s.get(n / 4)?, *s.get(n / 2)?, *s.get(3 * n / 4)?) } else { (start, *s.get(n / 2)?, end) }; + let (u, v) = (b - a, c - a); + let w = u.cross(v); + if !(w.len2() > u.len2() * v.len2() * 1e-16) { + return None; + } + let center = a + (v * u.len2() - u * v.len2()).cross(w) * (1.0 / (2.0 * w.len2())); + let axis = w.normalized()?; + let radius = center.dist(a); + if !(radius.is_finite() && radius > tol) || s.iter().any(|p| (p.dist(center) - radius).abs() > tol || (*p - center).dot(axis).abs() > tol) { + return None; + } + let x = (start - center).normalized()?; + let y = axis.cross(x); + let turn = |p: Vec3| { + let d = p - center; + let t = d.dot(y).atan2(d.dot(x)); + if t < 0.0 { t + std::f64::consts::TAU } else { t } + }; + // Accumulate the turn sample to sample so the span is right whichever way the curve runs. + let mut angle = 0.0; + let mut last = 0.0; + for p in s.iter().skip(1) { + let t = turn(*p); + let mut d = t - last; + if d < -std::f64::consts::PI { + d += std::f64::consts::TAU; + } else if d > std::f64::consts::PI { + d -= std::f64::consts::TAU; + } + angle += d; + last = t; + } + let (axis, angle) = if angle < 0.0 { (axis * -1.0, -angle) } else { (axis, angle) }; + let angle = if closed { std::f64::consts::TAU } else { angle.min(std::f64::consts::TAU) }; + Some(EdgeCircle { center, axis, radius, start, end, angle }) +} + impl FaceSurface { /// The point of the whole surface (not only of its face) nearest `p`. `None` where every /// point of a ring is as near (on a cylinder's or cone's axis, at a sphere's or torus's diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 5ed9cf94..cddc5059 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1373,3 +1373,21 @@ fn tool_annotations_tell_the_whole_truth() { } assert!(tools.iter().all(|t| t["annotations"]["readOnlyHint"].is_boolean()), "every tool is annotated"); } + +/// `measure` with `items` (#1392): a sketch circle's diameter and radius, and two circles' +/// centre-to-centre with the closest and farthest distances. +#[test] +fn measure_items_gives_diameters_and_centre_distances() { + let mut s = server(); + tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}})); + let a = tool(&mut s, "execute", json!({"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 3}})); + let b = tool(&mut s, "execute", json!({"command": "sketch.circle.center", "params": {"center": [30, 40], "radius": 2}})); + let id = |r: &Value| r["curves"][0].as_str().or(r["result"]["curves"][0].as_str()).unwrap().to_string(); + let (a, b) = (id(&a), id(&b)); + let one = tool(&mut s, "measure", json!({"items": [{"type": "sketch_curve", "id": a}]})); + assert_eq!((one["diameter"].as_f64(), one["radius"].as_f64()), (Some(6.0), Some(3.0)), "{one}"); + let two = tool(&mut s, "measure", json!({"items": [{"type": "sketch_curve", "id": a}, {"type": "sketch_curve", "id": b}]})); + assert!((two["center_distance"].as_f64().unwrap() - 50.0).abs() < 1e-9, "{two}"); + assert!((two["min"].as_f64().unwrap() - 45.0).abs() < 1e-9 && (two["max"].as_f64().unwrap() - 55.0).abs() < 1e-9, "{two}"); + assert!(two["distance_mm"].is_number() && two["from"].is_array(), "{two}"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 13737beb..42be3d77 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -135,8 +135,17 @@ pub fn tool_definitions() -> Vec { "measure", "Measure bodies", "Volume (mm³), surface area (mm²), centre of mass, bounding box and face/edge/vertex counts of one body or all \ - bodies, with totals.", - obj(json!({"body": string("Body name (default: all bodies)")}), &[]), + bodies, with totals. With `items` (one or two selections, as `execute` takes them: faces, edges, vertices, \ + sketch curves, profiles): one item's length, area or position, a circle's, arc's or cylinder's `diameter` and \ + `radius` (with `circumference`, or an arc's `arc_length` and `angle_deg`), a slot's `slot` sizes; two items' \ + `min` and `max` distance and, where one is round, `center_distance` (centre to centre, or from its axis).", + obj( + json!({ + "body": string("Body name (default: all bodies)"), + "items": {"type": "array", "items": {"type": "object"}, "description": "One or two selections to measure instead of bodies, e.g. {\"type\": \"face\", \"body\": \"Body1\", \"index\": 3, \"point\": [x,y,z]}"}, + }), + &[], + ), Effect::ReadOnly, ), tool( @@ -388,6 +397,9 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result { + if let Some(items) = a.get("items") { + return exec(b, "inspect.measure", json!({ "items": items })); + } let p = match str_arg(a, "body") { Some(body) => json!({"bodies": [body]}), None => json!({}), diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2cc38d3f..7b04073e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -249,8 +249,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj app.dialog = Some(d); return; } - if let Kind::Measure { result: Some(r), .. } = &d.kind { - measure_line(app, painter, proj, r); + if let Kind::Measure { result: Some(r), show, .. } = &d.kind { + measure_line(app, painter, proj, r, *show); app.dialog = Some(d); return; } @@ -758,14 +758,11 @@ fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Pain true } -/// The measured distance: a line between the closest points with its length. -fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value) { +/// The measured distance: a line between the ends of the shown measure (the centres for +/// centre to centre, the closest or farthest points) with its length. +fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value, show: Option) { 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((_, d, a, b)) = crate::dialogs::shown_distance(r, show) 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] { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ef774b66..de221c75 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -193,6 +193,9 @@ pub enum Kind { Measure { result: Option, of: Vec, + /// The measure chosen in the panel (centre, min or max); `None` follows the result's + /// own default (centre where a round item gives one). + show: Option, }, /// Move bodies by a distance along X, Y and Z. /// Move/Copy (see `dialogs_move`): the move type, X/Y/Z distances (Point to Position: the @@ -528,9 +531,10 @@ impl Dialog { "inspect.section" => { Dialog::new(Kind::Section { offset: "0 mm".into(), flip: false }, vec![SelInput::new("Plane", PLANES | PLANAR_FACES, false)]) } - "inspect.measure" => { - Dialog::new(Kind::Measure { result: None, of: Vec::new() }, vec![SelInput::new("Items", FACES | EDGES | selection::VERTICES, true)]) - } + "inspect.measure" => Dialog::new( + Kind::Measure { result: None, of: Vec::new(), show: None }, + vec![SelInput::new("Items", FACES | EDGES | selection::VERTICES | selection::CURVES | PROFILES, true)], + ), "solid.pattern.rectangular" => Dialog::new( Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() }, vec![ @@ -1745,7 +1749,7 @@ pub(crate) fn fill_cells(st: &solidcraft_engine::doc::ModelState, tools: &[Strin /// 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 + if let Kind::Measure { result, of, .. } = &mut d.kind && *of != items { *result = if items.is_empty() { None } else { Some(app.session.measure_items(&items)) }; @@ -1757,8 +1761,39 @@ fn fmt_mm(app: &SolveApp, v: &Value) -> String { v.as_f64().map(|x| crate::prefs::show_mm(app, x, 1)).unwrap_or_default() } +/// Which distance of a pair the Measure panel shows (and the canvas draws). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Shown { + /// Centre to centre, or from a round item's axis. + Centre, + /// Closest points. + Min, + /// Farthest points. + Max, +} + +fn vec3_of(v: &Value) -> Option { + let a = v.as_array()?; + Some(Vec3::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?)) +} + +/// The distance shown for a measured pair: which one, its value and its two ends. The chosen +/// one when the result has it, otherwise the result's default (centre, else the closest). +pub(crate) fn shown_distance(r: &Value, show: Option) -> Option<(Shown, f64, Vec3, Vec3)> { + let get = |which: Shown| -> Option<(Shown, f64, Vec3, Vec3)> { + let (d, from, to) = match which { + Shown::Centre => ("center_distance", "center_from", "center_to"), + Shown::Min => ("distance_mm", "from", "to"), + Shown::Max => ("max", "max_from", "max_to"), + }; + Some((which, r.get(d)?.as_f64()?, vec3_of(r.get(from)?)?, vec3_of(r.get(to)?)?)) + }; + let default = if r.get("measure").and_then(Value::as_str) == Some("center") { Shown::Centre } else { Shown::Min }; + show.and_then(get).or_else(|| get(default)).or_else(|| get(Shown::Min)) +} + /// The measurement in the dialog. -fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>) { +fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>, show: &mut Option) { let t = Tokens::get(); let Some(r) = r else { ui.label(""); @@ -1772,33 +1807,74 @@ fn measure_rows(app: &SolveApp, ui: &mut egui::Ui, r: Option<&Value>) { ui.end_row(); return; } + let mm = |v: &Value| fmt_mm(app, v); 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(app, &it["length_mm"])), - Some("face") => format!("Area {}", crate::prefs::show_mm(app, it["area_mm2"].as_f64().unwrap_or(0.0), 2)), - Some("vertex") => { - let p = &it["position"]; - format!("At {}, {}, {}", fmt_mm(app, &p[0]), fmt_mm(app, &p[1]), fmt_mm(app, &p[2])) + // Round items: Ø first, then R, then the length (circumference, or the arc's length + // and angle). + let mut lines: Vec = Vec::new(); + if it.get("diameter").is_some() { + lines.push(format!("Ø {}", mm(&it["diameter"]))); + lines.push(format!("R {}", mm(&it["radius"]))); + if it.get("circumference").is_some() { + lines.push(format!("{} {}", tr("Circumference"), mm(&it["circumference"]))); + } else if let Some(a) = it["angle_deg"].as_f64() { + lines.push(format!("{} {}", tr("Arc length"), mm(&it["arc_length"]))); + lines.push(format!("{} {a:.2}°", crate::i18n::tr_label("Angle"))); } - _ => 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(tr("Distance")).strong()); - ui.label(RichText::new(fmt_mm(app, &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(crate::prefs::show_mm(app, d[k].as_f64().unwrap_or(0.0).abs(), 1)); + } + if let Some(sl) = it.get("slot") { + lines.push(format!("{} {}", tr("Slot width"), mm(&sl["width"]))); + lines.push(format!("{} {}", tr("Centre to centre"), mm(&sl["center_distance"]))); + lines.push(format!("{} {}", tr("Overall length"), mm(&sl["overall_length"]))); + } + let round = it.get("diameter").is_some(); + match it["type"].as_str() { + Some("edge" | "sketch_curve") if !round => lines.push(format!("{} {}", tr("Length"), mm(&it["length_mm"]))), + Some("face" | "profile") => { + lines.push(format!("{} {}", tr("Area"), crate::prefs::show_mm(app, it["area_mm2"].as_f64().unwrap_or(0.0), 2))) + } + Some("vertex" | "sketch_point") => { + let p = &it["position"]; + lines.push(format!("At {}, {}, {}", mm(&p[0]), mm(&p[1]), mm(&p[2]))); + } + _ => {} + } + for (k, line) in lines.into_iter().enumerate() { + ui.label(if k == 0 { format!("Item {}", i + 1) } else { String::new() }); + ui.label(line); ui.end_row(); } } - if let Some(a) = r["angle_deg"].as_f64() { - ui.label(RichText::new(tr("Angle")).strong()); + if let Some((now, d, a, b)) = shown_distance(r, *show) { + if r.get("center_distance").is_some() { + // Centre, min and max all listed; the chosen one is drawn and broken into ΔX/Y/Z. + for (which, label, key) in + [(Shown::Centre, "Centre to centre", "center_distance"), (Shown::Min, "Min", "distance_mm"), (Shown::Max, "Max", "max")] + { + let Some(v) = r.get(key).filter(|v| v.is_number()) else { continue }; + if ui.radio(now == which, tr(label)).clicked() { + *show = Some(which); + } + let text = RichText::new(mm(v)); + ui.label(if now == which { text.strong() } else { text }); + ui.end_row(); + } + } else { + ui.label(RichText::new(crate::i18n::tr_label("Distance")).strong()); + ui.label(RichText::new(crate::prefs::show_mm(app, d, 1)).strong()); + ui.end_row(); + } + let delta = b - a; + for (l, v) in [("ΔX", delta.x), ("ΔY", delta.y), ("ΔZ", delta.z)] { + ui.label(l); + ui.label(crate::prefs::show_mm(app, v.abs(), 1)); + ui.end_row(); + } + } + if let Some(a) = r["angle_deg"].as_f64() + && r["items"].as_array().is_some_and(|v| v.len() == 2) + { + ui.label(RichText::new(crate::i18n::tr_label("Angle")).strong()); ui.label(RichText::new(format!("{a:.3}°")).strong()); ui.end_row(); } @@ -2373,7 +2449,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { d.extra.insert("combine".into(), json!(op == 1)); ui.end_row(); } - Kind::Measure { result, .. } => measure_rows(app, ui, result.as_ref()), + Kind::Measure { result, show, .. } => measure_rows(app, ui, result.as_ref(), show), Kind::OffsetPlane { offset } => { row_label(ui, "Distance"); enter |= field(ui, offset); diff --git a/docs/mcp.md b/docs/mcp.md index 7ef017db..4a2ba252 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -146,7 +146,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t | `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. | | `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`) and every result before it. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. | | `inspect_design` | `measure?` (default true), `sketches?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible; `removed_later: true` while a sketch is being edited on a rolled-back timeline and a later feature deletes the body, which is then not shown), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). | -| `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. | +| `measure` | `body?` or `items?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: see below. | | `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). | | `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. | | `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: CPU render fitted to the model, or from the named view or camera. Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. | @@ -157,6 +157,24 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t | `open` | `path` | Open a `.solidcraft` design, a STEP file (`.step`/`.stp`) as a new design holding its bodies (an Import base feature), or a 3MF/STL file as mesh bodies. | | `save` | `path?` | Save the design (to its current file without `path`). | +`measure` with `items` reports each item under `items` (`length_mm`, `area_mm2` or `position`). +A circle, arc, hole or other cylindrical face also gives `diameter`, `radius`, `center` and +`axis`, with `circumference` when it goes all the way round, or `arc_length` and `angle_deg` +for an arc; `round` says which (`circle`, `arc`, `cylinder`). A slot (a sketch profile of two +half circles and two lines, or one half-cylinder end of a slot in a solid) gives `slot`: +`width`, `center_distance` and `overall_length`. With one item these fields are repeated at +the top level. With two items: + +- `min` (also `distance_mm`, with `from`, `to` and `delta`): the closest distance; +- `max` (with `max_from`, `max_to`): the farthest distance; +- `center_distance` (with `center_from`, `center_to`): when an item is round, from its centre + or axis: axis to axis for parallel axes, the closest distance between the axes otherwise, + and axis to plane, line or point; +- `measure`: the distance the Measure panel shows first (`center` where there is one, else `min`); +- `angle_deg` between two planar faces or straight edges. + +Earlier fields are unchanged, so older scripts keep working. + To change a feature after it is made, read its definition with `execute` `timeline.inspect` (`{feature}`: id or name) and change fields with `timeline.edit` (`{feature, set}`; e.g. `{"extent": {"distance": "30"}}`). The fields per feature type are listed in the book's