diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index fce9ac5d4..dd75da685 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -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 { + 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 { - 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 { - 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 { + 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 { + let tri = |a: Vec3, b: Vec3, d: Vec3| -> Option { + 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 { 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) diff --git a/crates/engine/tests/inquiry_z.rs b/crates/engine/tests/inquiry_z.rs new file mode 100644 index 000000000..17f44ac03 --- /dev/null +++ b/crates/engine/tests/inquiry_z.rs @@ -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::>()); +} + +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}"); +}