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 <noreply@anthropic.com>
This commit is contained in:
1 parent
c36a6f9f90
commit
1744fbb262
18 files changed
+1244
-73
No files matched your search
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user