ID, DIST and LIST report Z (#1509)

ID always printed Z = 0, DIST's Delta Z and angle from the XY plane were
0, and LIST printed Z = 0 for lines, circles, arcs and polyline vertices,
though the drawing kept every Z: a topographic drawing's levels could not
be read in CADCraft.

Points reach these commands in 2D, so the Z is that of what is drawn at
the point, as AutoCAD reports a snapped point's: the object a pick finds
there (a point, a 3D line or 3D polyline interpolated, a polyline's
elevation, down Z for a mirrored one, a circle, arc, text, block or the
plane of a 3D face), else the current elevation (ELEVATION). A Z given
with a JSON point wins. DIST gives the 3D distance, Delta Z and the angle
from the XY plane; LIST the Z of lines, circles, arcs and text, a line's
3D length and deltas, and a polyline's elevation.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aldozar
2026-10-19 02:33:10 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 64e55f903c
commit 0f833358b9
2 changed files with 218 additions and 22 deletions
+120 -22
View File
@@ -1,7 +1,7 @@
//! Inquiry: DIST, ID, LIST, AREA, drawing inspection for agents.
use cadcraft_doc::{EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2};
use cadcraft_doc::{Entity, EntityKind, Handle};
use cadcraft_geom::{Polyline, Vec2, Vec3};
use serde_json::{Value, json};
use super::*;
@@ -71,28 +71,119 @@ fn fmt_a(s: &Session, v: f64) -> String {
format_angle(v, h.0, h.1)
}
fn dist_text(s: &Session, a: Vec2, b: Vec2) -> String {
/// The angle of `d` up from the XY plane.
fn angle_from_xy(d: Vec3) -> f64 {
d.z.atan2(d.xy().len())
}
fn dist_text(s: &Session, a: Vec3, b: Vec3) -> String {
let d = b - a;
format!(
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = 0\nDelta X = {}, Delta Y = {}, Delta Z = {}",
fmt_d(s, a.dist(b)),
fmt_a(s, d.angle()),
"Distance = {}, Angle in XY Plane = {}, Angle from XY Plane = {}\nDelta X = {}, Delta Y = {}, Delta Z = {}",
fmt_d(s, (b - a).len()),
fmt_a(s, d.xy().angle()),
fmt_a(s, angle_from_xy(d)),
fmt_d(s, d.x),
fmt_d(s, d.y),
fmt_d(s, 0.0)
fmt_d(s, d.z)
)
}
/// A point parameter with its Z: the third number when given, else the Z of what is drawn
/// there ([`z_at`]).
fn point3_req(s: &Session, cmd: &str, p: &Value, key: &str) -> Result<Vec3> {
let q = point_req(cmd, p, key)?;
let z = p.get(key).and_then(|v| v.get(2)).and_then(Value::as_f64).filter(|z| z.is_finite());
Ok(Vec3::new(q.x, q.y, z.unwrap_or_else(|| z_at(s, q))))
}
fn run_dist(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("dist", p, "p1")?;
let b = point_req("dist", p, "p2")?;
Ok(json!({ "distance": a.dist(b), "angle": (b - a).angle().to_degrees(), "dx": b.x - a.x, "dy": b.y - a.y, "message": dist_text(s, a, b) }))
let a = point3_req(s, "dist", p, "p1")?;
let b = point3_req(s, "dist", p, "p2")?;
let d = b - a;
Ok(json!({
"distance": d.len(),
"angle": d.xy().angle().to_degrees(),
"angleFromXY": angle_from_xy(d).to_degrees(),
"dx": d.x,
"dy": d.y,
"dz": d.z,
"message": dist_text(s, a, b),
}))
}
fn id_text(s: &Session, a: Vec3) -> String {
format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, a.z))
}
fn run_id(s: &mut Session, p: &Value) -> Result<Value> {
let a = point_req("id", p, "at")?;
s.last_point = a;
Ok(json!({ "x": a.x, "y": a.y, "message": format!("X = {} Y = {} Z = {}", fmt_d(s, a.x), fmt_d(s, a.y), fmt_d(s, 0.0)) }))
let a = point3_req(s, "id", p, "at")?;
s.last_point = a.xy();
Ok(json!({ "x": a.x, "y": a.y, "z": a.z, "message": id_text(s, a) }))
}
/// The Z of what is drawn at `p`: the object a pick finds there, at that point, or the current
/// elevation (ELEVATION) when there is none. Points come from the canvas in 2D; the object
/// under a snapped point gives its Z back, as AutoCAD's ID and DIST report a snapped point's.
pub(crate) fn z_at(s: &Session, p: Vec2) -> f64 {
let Ok(d) = s.doc() else { return 0.0 };
let elevation = d.header.f64("ELEVATION", 0.0);
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
crate::select::pick(d, &space, p, ap)
.and_then(|h| d.entity(h))
.and_then(|e| entity_z(e, p))
.filter(|z| z.is_finite())
.unwrap_or(if elevation.is_finite() { elevation } else { 0.0 })
}
/// The Z of `e` at the point of it nearest `p` (seen from above), when it has one there.
fn entity_z(e: &Entity, p: Vec2) -> Option<f64> {
let on_segment = |a: Vec3, b: Vec3| {
let d = b.xy() - a.xy();
let l2 = d.dot(d);
let t = if l2 > 0.0 { ((p - a.xy()).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
(a.z + (b.z - a.z) * t, (a.xy() + d * t).dist(p))
};
let along = |pts: &[Vec3], closed: bool| {
let n = pts.len();
let segs = if closed { n } else { n.saturating_sub(1) };
(0..segs)
.filter_map(|i| Some(on_segment(*pts.get(i)?, *pts.get((i + 1) % n)?)))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(z, _)| z)
.or_else(|| pts.first().map(|q| q.z))
};
let n = e.common.extrusion;
let plan = n.x.abs() < 1e-9 && n.y.abs() < 1e-9 && n.z != 0.0;
match &e.kind {
EntityKind::Point(q) => Some(q.p.z),
EntityKind::Line(l) => Some(on_segment(l.a, l.b).0),
EntityKind::Polyline3d(pl) => along(&pl.points, pl.closed),
// Elevation is in the polyline's OCS: down Z for a mirrored one.
EntityKind::LwPolyline(pl) if plan => Some(if n.z < 0.0 { -pl.elevation } else { pl.elevation }),
// Read in world coordinates already.
EntityKind::Circle(c) => Some(c.center.z),
EntityKind::Arc(a) => Some(a.center.z),
EntityKind::Text(t) | EntityKind::AttDef(cadcraft_doc::Attrib { text: t, .. }) => Some(t.insert.z),
EntityKind::MText(t) => Some(t.insert.z),
EntityKind::Insert(i) => Some(i.insert.z),
EntityKind::Face3d(f) => face_z(&f.corners, p).or_else(|| along(&f.corners, true)),
_ => None,
}
}
/// The Z at `p` on a 3D face: on the plane of the triangle of its corners that holds `p`.
fn face_z(c: &[Vec3; 4], p: Vec2) -> Option<f64> {
let tri = |a: Vec3, b: Vec3, d: Vec3| -> Option<f64> {
let n = (b - a).cross(d - a);
if n.z.abs() < 1e-12 * (1.0 + n.len()) {
return None;
}
let inside = cadcraft_geom::point_in_polygon(&[a.xy(), b.xy(), d.xy()], p);
inside.then(|| a.z - (n.x * (p.x - a.x) + n.y * (p.y - a.y)) / n.z)
};
tri(c[0], c[1], c[2]).or_else(|| if c[3] == c[2] { None } else { tri(c[0], c[2], c[3]) })
}
fn list_text(s: &Session, h: Handle) -> String {
@@ -112,23 +203,25 @@ fn list_text(s: &Session, h: Handle) -> String {
"\n from point, X={} Y={} Z={}\n to point, X={} Y={} Z={}",
fmt_d(s, l.a.x),
fmt_d(s, l.a.y),
fmt_d(s, 0.0),
fmt_d(s, l.a.z),
fmt_d(s, l.b.x),
fmt_d(s, l.b.y),
fmt_d(s, 0.0)
fmt_d(s, l.b.z)
));
t.push_str(&format!(
"\n Length = {}, Angle in XY Plane = {}",
fmt_d(s, l.a.xy().dist(l.b.xy())),
fmt_d(s, (l.b - l.a).len()),
fmt_a(s, l.a.xy().angle_to(l.b.xy()))
));
let d = l.b - l.a;
t.push_str(&format!("\n Delta X = {}, Delta Y = {}, Delta Z = {}", fmt_d(s, d.x), fmt_d(s, d.y), fmt_d(s, d.z)));
}
EntityKind::Circle(c) => {
t.push_str(&format!(
"\n center point, X={} Y={} Z={}\n radius {}\n circumference {}\n area {}",
fmt_d(s, c.center.x),
fmt_d(s, c.center.y),
fmt_d(s, 0.0),
fmt_d(s, c.center.z),
fmt_d(s, c.radius),
fmt_d(s, c.radius * cadcraft_geom::TAU),
fmt_d(s, c.radius * c.radius * cadcraft_geom::PI)
@@ -139,7 +232,7 @@ fn list_text(s: &Session, h: Handle) -> String {
"\n center point, X={} Y={} Z={}\n radius {}\n start angle {}\n end angle {}",
fmt_d(s, a.center.x),
fmt_d(s, a.center.y),
fmt_d(s, 0.0),
fmt_d(s, a.center.z),
fmt_d(s, a.radius),
fmt_a(s, a.start),
fmt_a(s, a.end)
@@ -153,16 +246,20 @@ fn list_text(s: &Session, h: Handle) -> String {
} else {
t.push_str(&format!("\n length {}", fmt_d(s, pl.len())));
}
// The elevation, in world Z (down Z for a mirrored polyline).
let z = entity_z(e, Vec2::ZERO).unwrap_or(p.elevation);
t.push_str(&format!("\n elevation {}", fmt_d(s, z)));
for v in &p.vertices {
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, 0.0)));
t.push_str(&format!("\n at point X={} Y={} Z={}", fmt_d(s, v.p.x), fmt_d(s, v.p.y), fmt_d(s, z)));
}
}
EntityKind::Text(tx) => {
t.push_str(&format!(
"\n Style = \"{}\"\n start point, X={} Y={}\n height {}\n text {}\n rotation angle {}",
"\n Style = \"{}\"\n start point, X={} Y={} Z={}\n height {}\n text {}\n rotation angle {}",
tx.style,
fmt_d(s, tx.insert.x),
fmt_d(s, tx.insert.y),
fmt_d(s, tx.insert.z),
fmt_d(s, tx.height),
tx.value,
fmt_a(s, tx.rotation)
@@ -555,7 +652,8 @@ impl Interactive for DistM {
(Some(first), Some(second)) if first.viewport == second.viewport => (first.point, second.point),
_ => (a, b),
};
let t = dist_text(s, a, b);
let (za, zb) = if self.first_source.is_some() || s.picked_viewport.is_some() { (0.0, 0.0) } else { (z_at(s, a), z_at(s, b)) };
let t = dist_text(s, Vec3::new(a.x, a.y, za), Vec3::new(b.x, b.y, zb));
for l in t.lines() {
s.echo(l.to_string());
}
@@ -602,7 +700,7 @@ impl Interactive for IdM {
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Input::Point(p) = i {
let m = format!("X = {} Y = {} Z = {}", fmt_d(s, p.x), fmt_d(s, p.y), fmt_d(s, 0.0));
let m = id_text(s, Vec3::new(p.x, p.y, z_at(s, p)));
s.echo(m);
}
Ok(Step::Done)
+98
View File
@@ -0,0 +1,98 @@
//! ID, DIST and LIST report Z: the Z of what is drawn at a picked point (a survey point, a 3D
//! line, a contour polyline's elevation, a TIN face), and the Z of the objects LIST shows. They
//! all said Z = 0, so a topographic drawing's levels couldn't be read in CADCraft.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Face3d, Handle, Line, LwPolyline, Point};
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
use serde_json::json;
fn add(s: &mut Session, k: EntityKind) -> Handle {
s.add_entity(k).unwrap()
}
fn z(s: &mut Session, at: [f64; 2]) -> f64 {
s.execute("id", &json!({ "at": at })).unwrap()["z"].as_f64().unwrap()
}
fn contour(elevation: f64) -> EntityKind {
EntityKind::LwPolyline(LwPolyline {
vertices: vec![PolyVertex::new(Vec2::new(0.0, 50.0)), PolyVertex::new(Vec2::new(40.0, 55.0)), PolyVertex::new(Vec2::new(80.0, 50.0))],
closed: false,
const_width: 0.0,
elevation,
plinegen: false,
})
}
#[test]
fn id_reports_the_z_of_what_is_at_the_point() {
let mut s = Session::new();
add(&mut s, EntityKind::Point(Point { p: Vec3::new(10.0, 10.0, 1883.546), angle: 0.0 }));
add(&mut s, EntityKind::Line(Line { a: Vec3::new(100.0, 0.0, 10.0), b: Vec3::new(200.0, 0.0, 30.0) }));
add(&mut s, contour(1880.0));
// A sloped TIN face: z = 1000 + x / 10 over (300..400, 0..100).
add(
&mut s,
EntityKind::Face3d(Face3d {
corners: [Vec3::new(300.0, 0.0, 1030.0), Vec3::new(400.0, 0.0, 1040.0), Vec3::new(400.0, 100.0, 1040.0), Vec3::new(300.0, 100.0, 1030.0)],
hidden_edges: 0,
}),
);
s.cmdline("zoom e").ok();
assert!((z(&mut s, [10.0, 10.0]) - 1883.546).abs() < 1e-9, "survey point");
assert!((z(&mut s, [150.0, 0.0]) - 20.0).abs() < 1e-9, "middle of the 3D line");
assert!((z(&mut s, [40.0, 55.0]) - 1880.0).abs() < 1e-9, "contour elevation");
assert!((z(&mut s, [350.0, 0.0]) - 1035.0).abs() < 1e-9, "on the face's edge");
// Nothing there: the current elevation.
assert_eq!(z(&mut s, [-500.0, -500.0]), 0.0);
s.doc_mut().unwrap().header.set_f64("ELEVATION", 7.5);
assert_eq!(z(&mut s, [-500.0, -500.0]), 7.5);
// A Z given with the point wins.
assert_eq!(z_given(&mut s), 3.0);
// At the command line it is in the message.
s.cmdline("id").unwrap();
s.cmdline("10,10").unwrap();
assert!(s.log.iter().any(|l| l.contains("Z = 1883.5460")), "{:?}", s.log.iter().rev().take(3).collect::<Vec<_>>());
}
fn z_given(s: &mut Session) -> f64 {
s.execute("id", &json!({ "at": [10, 10, 3] })).unwrap()["z"].as_f64().unwrap()
}
#[test]
fn a_mirrored_contours_elevation_is_down_z() {
let mut s = Session::new();
let h = add(&mut s, contour(25.0));
s.doc_mut().unwrap().modify_entity(h, |e| e.common.extrusion = Vec3::new(0.0, 0.0, -1.0)).unwrap();
s.cmdline("zoom e").ok();
assert!((z(&mut s, [40.0, 55.0]) + 25.0).abs() < 1e-9);
}
#[test]
fn dist_between_two_levels_gives_the_3d_distance_and_the_rise() {
let mut s = Session::new();
add(&mut s, EntityKind::Point(Point { p: Vec3::new(0.0, 0.0, 100.0), angle: 0.0 }));
add(&mut s, EntityKind::Point(Point { p: Vec3::new(30.0, 40.0, 150.0), angle: 0.0 }));
s.cmdline("zoom e").ok();
let r = s.execute("dist", &json!({ "p1": [0, 0], "p2": [30, 40] })).unwrap();
assert!((r["dz"].as_f64().unwrap() - 50.0).abs() < 1e-9, "{r}");
assert!((r["distance"].as_f64().unwrap() - (50.0f64 * 50.0 * 2.0).sqrt()).abs() < 1e-9, "{r}");
assert!((r["angleFromXY"].as_f64().unwrap() - 45.0).abs() < 1e-9, "{r}");
assert!(r["message"].as_str().unwrap().contains("Delta Z = 50.0000"), "{r}");
// Flat points keep the 2D answer.
let r = s.execute("dist", &json!({ "p1": [500, 500], "p2": [503, 504] })).unwrap();
assert!((r["distance"].as_f64().unwrap() - 5.0).abs() < 1e-9 && r["dz"].as_f64().unwrap() == 0.0, "{r}");
}
#[test]
fn list_shows_the_z_values_objects_have() {
let mut s = Session::new();
let l = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(30.0, 40.0, 60.0) }));
let c = add(&mut s, contour(1880.0));
let text = s.execute("list", &json!({ "handles": [l.hex(), c.hex()] })).unwrap()["message"].as_str().unwrap_or_default().to_string();
let text = if text.is_empty() { s.log.join("\n") } else { text };
assert!(text.contains("Z=10.0000") && text.contains("Z=60.0000"), "{text}");
assert!(text.contains("Delta Z = 50.0000"), "{text}");
assert!(text.contains("elevation 1880.0000") && text.contains("Z=1880.0000"), "{text}");
}