Measure: centre-to-centre for holes, diameters for circles and arcs, slot sizes (#1392) (#1419)

Measure now reads the picked item's exact shape (cylindrical faces, circular and arc edges,
sketch circles, arcs and lines, profiles, planes, points):

- A circle, hole or arc shows Ø, R and its length (circumference, or arc length and angle).
- A slot (a sketch profile, or a half-cylinder end of a slot in a solid) shows its width,
  centre-to-centre length and overall length.
- Two items where one is round measure from the centre or axis by default (axis to axis,
  axis to plane, edge or point), with the closest and farthest distances listed too; the
  panel switches between centre, min and max, and the canvas line follows.
- inspect.measure and MCP `measure` (new `items` argument) add diameter, radius,
  center_distance, min and max; existing fields are unchanged.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 10:27:13 -04:00
1 parent c36a6f9f90
commit 1744fbb262
18 files changed
+1244 -73

No files matched your search

+10 -1
View File
@@ -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"
}
+8 -1
View File
@@ -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"
}
+8 -1
View File
@@ -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": "最大"
}
+10 -1
View File
@@ -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": "면적"
}
+8 -1
View File
@@ -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": "最大"
}
+1 -1
View File
@@ -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"),
+349 -26
View File
@@ -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<Vec3>, Value)> {
/// A described item: its geometry, direction (planar face normal, straight edge direction),
/// description and exact shape.
struct Item {
geo: Geo,
dir: Option<Vec3>,
json: Value,
shape: Shape,
/// A slot this item is one end of (or the outline of).
slot: Option<Slot>,
}
/// 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<Shape> {
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<Vec3> =
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<Vec3> {
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<u64>,
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<Item> {
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<Item> {
use solidcraft_geom::{Seg2, Vec2};
let pr = ss.profiles.get(index)?;
let outline: Vec<Vec3> = 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<Slot> {
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<Vec3>> = 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<Vec3>)> =
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<Item> {
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<Vec3>, Value)> {
let pieces: Vec<&Vec<Vec3>> = 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::<Vec<_>>();
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::<Vec<_>>();
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
let normals: Vec<Vec3> = 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<Vec3> = 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,
};
// "<sketch id>:<point id>" names a point of any sketch.
let (given, id) = match id.split_once(':').and_then(|(k, p)| Some((k.parse::<u64>().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<T: Copy>(v: &[T], max: usize) -> Vec<T> {
@@ -195,22 +451,89 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
}
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::<Vec<_>>()});
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::<Vec<_>>()});
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::<f64>();
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<Vec3> {
match g {
Geo::Points(p) => sample(p, 2000),
Geo::Tris(t) => sample(&t.iter().flat_map(|t| t.iter().copied()).collect::<Vec<_>>(), 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))
}
+385
View File
@@ -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<Shape> {
let axis = axis.normalized()?;
if pts.is_empty() || !(radius.is_finite() && radius > 0.0) {
return None;
}
let ts: Vec<f64> = 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<f64> = 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<Vec3> {
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<f64>,
pub max: Option<f64>,
}
/// 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<Centre> {
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<Centre> {
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<Vec3>)]) -> Option<Slot> {
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<Vec3> {
(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());
}
}
+1
View File
@@ -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;
+2
View File
@@ -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;
+218
View File
@@ -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>) -> 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);
}
+1 -1
View File
@@ -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};
+82
View File
@@ -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<FaceSurface> {
})
}
/// 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<EdgeCircle> {
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<Vec3> = (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<EdgeCircle> {
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
+18
View File
@@ -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}");
}
+14 -2
View File
@@ -135,8 +135,17 @@ pub fn tool_definitions() -> Vec<Value> {
"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<String, Value>) -> Result<V
Ok(d)
}
"measure" => {
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!({}),
+6 -9
View File
@@ -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<crate::dialogs::Shown>) {
let t = Tokens::get();
let p = |k: &str| -> Option<Vec3> {
let a = r.get(k)?.as_array()?;
Some(Vec3::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?, a.get(2)?.as_f64()?))
};
let (Some(a), Some(b), Some(d)) = (p("from"), p("to"), r.get("distance_mm").and_then(|v| v.as_f64())) else { return };
let Some((_, 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] {
+104 -28
View File
@@ -193,6 +193,9 @@ pub enum Kind {
Measure {
result: Option<Value>,
of: Vec<Sel>,
/// 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<Shown>,
},
/// 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<Sel> = 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<Vec3> {
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<Shown>) -> 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<Shown>) {
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<String> = 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);
+19 -1
View File
@@ -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