forked from 1b-agent/opencadstudio-upstream
MEASUREGEOM gains a Quick option. As the cursor moves, the nearest drawn geometry along the working plane's X and Y lines through it is measured live: cyan measurement lines and a Horizontal/Vertical readout by the cursor. A click prints the current readout and keeps measuring; Enter ends the command. The rays go through the scene's interaction index, so large drawings stay responsive. A structure-pick command whose miss message is empty now gets the click as a plain point instead of an error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
953 lines
34 KiB
Rust
953 lines
34 KiB
Rust
// MEASUREGEOM command (alias MEA) — multi-mode geometry measurement.
|
||
//
|
||
// First step prompts for a mode keyword:
|
||
// Quick / Distance / Radius / Angle / ARea
|
||
// then collects the geometry the mode needs and prints a one-line readout
|
||
// via `CmdResult::Measurement`, ending the command.
|
||
//
|
||
// DISTANCE — two points → distance, delta X/Y/Z, angle in the XY plane.
|
||
// AREA — points until Enter → area (f64 shoelace relative to the first
|
||
// vertex, mirroring inquiry/area.rs for precision) + perimeter.
|
||
// ANGLE — three points (vertex + two ray endpoints) → angle in degrees.
|
||
// RADIUS — pick a Circle or Arc → radius + diameter.
|
||
// QUICK — no picks: as the cursor moves, the nearest geometry along the
|
||
// working plane's X and Y axes is measured live; a click
|
||
// reports the current readout and Enter ends the command.
|
||
//
|
||
// All arithmetic is kept in f64 (picked points stay full precision; downcasting
|
||
// to f32 loses several hundredths of a unit at survey-scale coordinates).
|
||
|
||
use crate::t;
|
||
use codec::{EntityType, Handle};
|
||
use glam::DVec3;
|
||
|
||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||
use crate::scene::model::wire_model::WireModel;
|
||
|
||
// ── Ribbon definition ─────────────────────────────────────────────────────
|
||
|
||
#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line
|
||
pub fn tool() -> ToolDef {
|
||
ToolDef {
|
||
id: "MEASUREGEOM",
|
||
label: "Measure Geometry",
|
||
icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")),
|
||
event: ModuleEvent::Command("MEASUREGEOM".to_string()),
|
||
}
|
||
}
|
||
|
||
// ── Mode ───────────────────────────────────────────────────────────────────
|
||
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
enum Mode {
|
||
/// Awaiting the mode keyword.
|
||
Choose,
|
||
Quick,
|
||
Distance,
|
||
Area,
|
||
Angle,
|
||
Radius,
|
||
AngleLines,
|
||
}
|
||
|
||
// ── Command implementation ──────────────────────────────────────────────────
|
||
|
||
pub const RADIUS_ICON: IconKind = IconKind::Svg(include_bytes!(
|
||
"../../../../assets/icons/measure-radius.svg"
|
||
));
|
||
pub const ANGLE_ICON: IconKind =
|
||
IconKind::Svg(include_bytes!("../../../../assets/icons/measure-angle.svg"));
|
||
|
||
pub struct MeasureGeomCommand {
|
||
name: &'static str,
|
||
hovered: Option<(Handle, EntityType)>,
|
||
first_line: Option<(Handle, DVec3)>,
|
||
mode: Mode,
|
||
/// Picked points for the active point-pick mode.
|
||
points: Vec<DVec3>,
|
||
/// The picked entity for RADIUS, injected before `on_entity_pick`.
|
||
picked: Option<EntityType>,
|
||
plane: WorkingPlane,
|
||
/// QUICK readout for the last hovered cursor position.
|
||
quick: std::cell::RefCell<Option<String>>,
|
||
}
|
||
|
||
impl MeasureGeomCommand {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
name: "MEASUREGEOM",
|
||
hovered: None,
|
||
first_line: None,
|
||
mode: Mode::Choose,
|
||
points: vec![],
|
||
picked: None,
|
||
plane: WorkingPlane::default(),
|
||
quick: std::cell::RefCell::new(None),
|
||
}
|
||
}
|
||
|
||
pub fn radius() -> Self {
|
||
Self {
|
||
name: "MEASURERADIUS",
|
||
mode: Mode::Radius,
|
||
..Self::new()
|
||
}
|
||
}
|
||
|
||
pub fn angle() -> Self {
|
||
Self {
|
||
name: "MEASUREANGLE",
|
||
mode: Mode::Angle,
|
||
..Self::new()
|
||
}
|
||
}
|
||
|
||
fn radius_msg(radius: f64) -> String {
|
||
t!(
|
||
"Radius = %{radius}, Diameter = %{diameter}",
|
||
radius = crate::entities::common::format_length(radius),
|
||
diameter = crate::entities::common::format_length(radius * 2.0)
|
||
)
|
||
.into_owned()
|
||
}
|
||
|
||
// Normalize only after rescaling, retaining f64 precision without an
|
||
// arbitrary drawing-unit tolerance or overflowing on long vectors.
|
||
fn direction(vector: DVec3) -> Option<DVec3> {
|
||
if !vector.is_finite() {
|
||
return None;
|
||
}
|
||
let scale = vector.abs().max_element();
|
||
if scale == 0.0 {
|
||
return None;
|
||
}
|
||
let vector = vector / scale;
|
||
Some(vector / vector.length())
|
||
}
|
||
|
||
fn angle_degrees(a: DVec3, b: DVec3) -> Option<f64> {
|
||
let a = Self::direction(a)?;
|
||
let b = Self::direction(b)?;
|
||
Some(a.cross(b).length().atan2(a.dot(b)).to_degrees())
|
||
}
|
||
|
||
fn line_direction(entity: &EntityType) -> Option<DVec3> {
|
||
let EntityType::Line(line) = entity else {
|
||
return None;
|
||
};
|
||
Self::direction(
|
||
DVec3::new(line.end.x, line.end.y, line.end.z)
|
||
- DVec3::new(line.start.x, line.start.y, line.start.z),
|
||
)
|
||
}
|
||
|
||
// Unoriented lines: the smaller angle, independent of endpoint order.
|
||
fn line_angle(a: DVec3, b: DVec3) -> Option<f64> {
|
||
Self::angle_degrees(a, b).map(|angle| angle.min(180.0 - angle))
|
||
}
|
||
|
||
fn angle_value_msg(angle: f64) -> String {
|
||
let aprec = crate::entities::common::unit_context().auprec.max(0) as usize;
|
||
let angle = crate::entities::common::without_negative_zero(format!("{angle:.aprec$}"));
|
||
t!("Angle = %{angle}°", angle = angle).into_owned()
|
||
}
|
||
|
||
/// Build a cyan preview wire connecting the picked points and the cursor.
|
||
fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel {
|
||
WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.to_string(),
|
||
points: pts,
|
||
points_low: Vec::new(),
|
||
color: WireModel::CYAN,
|
||
selected: false,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: 1.0,
|
||
snap_pts: vec![],
|
||
tangent_geoms: vec![],
|
||
aci: 0,
|
||
key_vertices: vec![],
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
/// Extract (radius) from a Circle or Arc; `None` for anything else.
|
||
fn radius_of(entity: &EntityType) -> Option<f64> {
|
||
let radius = match entity {
|
||
EntityType::Circle(circle) => circle.radius,
|
||
EntityType::Arc(arc) => arc.radius,
|
||
_ => return None,
|
||
};
|
||
(radius.is_finite() && radius > 0.0 && (radius * 2.0).is_finite()).then_some(radius)
|
||
}
|
||
|
||
/// DISTANCE readout for the two collected points.
|
||
fn distance_msg(plane: WorkingPlane, p1: DVec3, p2: DVec3) -> String {
|
||
let p1 = plane.to_local(p1);
|
||
let p2 = plane.to_local(p2);
|
||
let delta = p2 - p1;
|
||
let dist = delta.length();
|
||
let dx = delta.x;
|
||
let dy = delta.y;
|
||
let dz = delta.z;
|
||
let angle_xy = dy.atan2(dx).to_degrees();
|
||
let aprec = crate::entities::common::unit_context().auprec.max(0) as usize;
|
||
let dist_s = crate::entities::common::format_length(dist);
|
||
let angle_xy_s =
|
||
crate::entities::common::without_negative_zero(format!("{angle_xy:.aprec$}"));
|
||
let dx_s = crate::entities::common::format_length(dx);
|
||
let dy_s = crate::entities::common::format_length(dy);
|
||
let dz_s = crate::entities::common::format_length(dz);
|
||
t!(
|
||
"Distance = %{dist}, Angle in XY Plane = %{angle_xy}°\n Delta X = %{dx}, Delta Y = %{dy}, Delta Z = %{dz}",
|
||
dist = dist_s,
|
||
angle_xy = angle_xy_s,
|
||
dx = dx_s,
|
||
dy = dy_s,
|
||
dz = dz_s,
|
||
)
|
||
.into_owned()
|
||
}
|
||
|
||
/// AREA readout: shoelace area (f64, relative to first vertex) + perimeter.
|
||
fn area_msg(plane: WorkingPlane, points: &[DVec3]) -> String {
|
||
let points = points
|
||
.iter()
|
||
.map(|point| plane.to_local(*point))
|
||
.collect::<Vec<_>>();
|
||
let n = points.len();
|
||
let origin = points[0];
|
||
let mut area_sum = 0.0f64;
|
||
let mut perimeter = 0.0f64;
|
||
for idx in 0..n {
|
||
let a = points[idx] - origin;
|
||
let b = points[(idx + 1) % n] - origin;
|
||
area_sum += a.x * b.y - b.x * a.y;
|
||
perimeter += (points[(idx + 1) % n] - points[idx]).length();
|
||
}
|
||
let area = (area_sum * 0.5).abs();
|
||
let area_s = crate::entities::common::format_area(area);
|
||
let perimeter_s = crate::entities::common::format_length(perimeter);
|
||
t!(
|
||
"Area = %{area}, Perimeter = %{perimeter}",
|
||
area = area_s,
|
||
perimeter = perimeter_s
|
||
)
|
||
.into_owned()
|
||
}
|
||
|
||
fn angle_msg(vertex: DVec3, a: DVec3, b: DVec3) -> Option<String> {
|
||
Self::angle_degrees(a - vertex, b - vertex).map(Self::angle_value_msg)
|
||
}
|
||
|
||
/// Signed offset along local `axis` (0 = X, 1 = Y) from `c` to where the
|
||
/// segment `a`–`b` crosses the line through `c` along that axis.
|
||
fn crossing(a: DVec3, b: DVec3, c: DVec3, axis: usize) -> Option<f64> {
|
||
let other = 1 - axis;
|
||
let (da, db) = (a[other] - c[other], b[other] - c[other]);
|
||
if da * db > 0.0 || da == db {
|
||
return None;
|
||
}
|
||
let t = da / (da - db);
|
||
Some(a[axis] + t * (b[axis] - a[axis]) - c[axis])
|
||
}
|
||
|
||
/// Nearest crossings of the drawn geometry with the working plane's X and
|
||
/// Y lines through `cursor`, as local offsets `[-X, +X, -Y, +Y]`.
|
||
fn quick_hits(
|
||
scene: &crate::scene::Scene,
|
||
plane: WorkingPlane,
|
||
cursor: DVec3,
|
||
) -> [Option<f64>; 4] {
|
||
let c = plane.to_local(cursor);
|
||
// Long enough to leave the drawing from anywhere the cursor can be.
|
||
let reach = scene
|
||
.model_space_extents()
|
||
.map(|(lo, hi)| {
|
||
let (lo, hi) = (lo.as_dvec3(), hi.as_dvec3());
|
||
(hi - lo).length() + (cursor - (lo + hi) * 0.5).length()
|
||
})
|
||
.unwrap_or(0.0)
|
||
+ 1.0;
|
||
let mut hits = [None; 4];
|
||
for (axis, direction) in [plane.x, plane.y].into_iter().enumerate() {
|
||
let (a, b) = (cursor - direction * reach, cursor + direction * reach);
|
||
let pad = reach * 1e-9;
|
||
let candidates = scene.interaction_candidates_xy([
|
||
a.x.min(b.x) - pad,
|
||
a.y.min(b.y) - pad,
|
||
a.x.max(b.x) + pad,
|
||
a.y.max(b.y) + pad,
|
||
]);
|
||
for wire in candidates.iter() {
|
||
if !wire.display_visible
|
||
|| wire.snap_only
|
||
|| wire.point_marker.is_some()
|
||
|| !wire.text_verts.is_empty()
|
||
{
|
||
continue;
|
||
}
|
||
let mut previous: Option<DVec3> = None;
|
||
for (index, point) in wire.points.iter().enumerate() {
|
||
if point[0].is_nan() || point[1].is_nan() {
|
||
previous = None;
|
||
continue;
|
||
}
|
||
let local = plane.to_local(wire.point_world(index, 0.0));
|
||
if let Some(offset) = previous
|
||
.and_then(|previous| Self::crossing(previous, local, c, axis))
|
||
.filter(|offset| offset.abs() > pad)
|
||
{
|
||
let slot = &mut hits[axis * 2 + usize::from(offset > 0.0)];
|
||
if slot.is_none_or(|best: f64| offset.abs() < best.abs()) {
|
||
*slot = Some(offset);
|
||
}
|
||
}
|
||
previous = Some(local);
|
||
}
|
||
}
|
||
}
|
||
hits
|
||
}
|
||
|
||
/// QUICK measurement lines (world) and readout for crossings `hits`.
|
||
fn quick_result(
|
||
plane: WorkingPlane,
|
||
cursor: DVec3,
|
||
hits: [Option<f64>; 4],
|
||
) -> (Vec<WireModel>, Option<String>) {
|
||
let c = plane.to_local(cursor);
|
||
let mut wires = Vec::new();
|
||
let mut lines = Vec::new();
|
||
for axis in 0..2 {
|
||
let (low, high) = (hits[axis * 2], hits[axis * 2 + 1]);
|
||
if low.is_none() && high.is_none() {
|
||
continue;
|
||
}
|
||
let (from, to) = (low.unwrap_or(0.0), high.unwrap_or(0.0));
|
||
let mut start = c;
|
||
let mut end = c;
|
||
start[axis] += from;
|
||
end[axis] += to;
|
||
let f = |p: DVec3| {
|
||
let p = plane.to_world(p);
|
||
[p.x as f32, p.y as f32, p.z as f32]
|
||
};
|
||
wires.push(Self::preview_wire(
|
||
"measuregeom_quick",
|
||
vec![f(start), f(end)],
|
||
));
|
||
let value = crate::entities::common::format_length(to - from);
|
||
lines.push(if axis == 0 {
|
||
crate::tr!("measurement", "quick-horizontal", value = value)
|
||
} else {
|
||
crate::tr!("measurement", "quick-vertical", value = value)
|
||
});
|
||
}
|
||
let readout = (!lines.is_empty()).then(|| lines.join("\n"));
|
||
(wires, readout)
|
||
}
|
||
}
|
||
|
||
impl CadCommand for MeasureGeomCommand {
|
||
fn name(&self) -> &'static str {
|
||
self.name
|
||
}
|
||
|
||
fn prompt(&self) -> String {
|
||
match self.mode {
|
||
Mode::Choose => {
|
||
t!("MEASUREGEOM Enter an option [Quick/Distance/Radius/Angle/ARea]:").into_owned()
|
||
}
|
||
Mode::Quick => return crate::tr!("measurement", "quick-prompt", command = self.name),
|
||
Mode::Distance => {
|
||
if self.points.is_empty() {
|
||
t!("MEASUREGEOM Specify first point:").into_owned()
|
||
} else {
|
||
t!("MEASUREGEOM Specify second point:").into_owned()
|
||
}
|
||
}
|
||
Mode::Area => {
|
||
if self.points.is_empty() {
|
||
t!("MEASUREGEOM Specify first corner point (Enter to cancel):").into_owned()
|
||
} else {
|
||
let n = self.points.len();
|
||
t!(
|
||
"MEASUREGEOM Specify next point (%{n} picked, Enter to calculate):",
|
||
n = n
|
||
)
|
||
.into_owned()
|
||
}
|
||
}
|
||
Mode::Angle => match self.points.len() {
|
||
0 => return crate::tr!("measurement", "angle-start", command = self.name),
|
||
1 => t!("MEASUREGEOM Specify first ray point:").into_owned(),
|
||
_ => t!("MEASUREGEOM Specify second ray point:").into_owned(),
|
||
},
|
||
Mode::Radius => t!("MEASUREGEOM Select arc or circle:").into_owned(),
|
||
Mode::AngleLines => {
|
||
return if self.first_line.is_none() {
|
||
crate::tr!("measurement", "first-line", command = self.name)
|
||
} else {
|
||
crate::tr!("measurement", "second-line", command = self.name)
|
||
}
|
||
}
|
||
}
|
||
.replace("MEASUREGEOM", self.name)
|
||
}
|
||
|
||
fn set_working_plane(&mut self, plane: WorkingPlane) {
|
||
self.plane = plane;
|
||
}
|
||
|
||
fn wants_text_input(&self) -> bool {
|
||
self.mode == Mode::Choose || self.point_step_accepts_keywords()
|
||
}
|
||
|
||
fn point_step_accepts_keywords(&self) -> bool {
|
||
(self.mode == Mode::Angle && self.points.is_empty())
|
||
|| (self.mode == Mode::AngleLines && self.first_line.is_none())
|
||
}
|
||
|
||
fn has_measurement_readout(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
fn measurement_readout(&self, cursor: DVec3) -> Option<String> {
|
||
match self.mode {
|
||
Mode::Quick => self.quick.borrow().clone(),
|
||
Mode::Distance if !self.points.is_empty() && cursor.is_finite() => Some(crate::tr!(
|
||
"measurement",
|
||
"distance",
|
||
value = crate::entities::common::format_length((cursor - self.points[0]).length())
|
||
)),
|
||
Mode::Area if self.points.len() >= 2 && cursor.is_finite() => {
|
||
let mut points = self.points.clone();
|
||
points.push(cursor);
|
||
Some(Self::area_msg(self.plane, &points))
|
||
}
|
||
Mode::Angle if self.points.len() >= 2 => {
|
||
Self::angle_msg(self.points[0], self.points[1], cursor)
|
||
}
|
||
Mode::Radius => self
|
||
.hovered
|
||
.as_ref()
|
||
.and_then(|(_, e)| Self::radius_of(e))
|
||
.map(Self::radius_msg),
|
||
Mode::AngleLines => {
|
||
let (first_handle, first) = self.first_line?;
|
||
let (handle, entity) = self.hovered.as_ref()?;
|
||
if first_handle == *handle {
|
||
return None;
|
||
}
|
||
Self::line_angle(first, Self::line_direction(entity)?).map(Self::angle_value_msg)
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||
use crate::command::CmdOption;
|
||
if self.mode != Mode::Choose {
|
||
return match self.mode {
|
||
Mode::Angle if self.points.is_empty() => vec![CmdOption {
|
||
label: crate::tr!("measurement", "lines"),
|
||
keyword: "LINES".into(),
|
||
}],
|
||
Mode::AngleLines if self.first_line.is_none() => vec![CmdOption {
|
||
label: crate::tr!("measurement", "points"),
|
||
keyword: "POINTS".into(),
|
||
}],
|
||
_ => Vec::new(),
|
||
};
|
||
}
|
||
vec![
|
||
CmdOption {
|
||
label: crate::tr!("measurement", "quick"),
|
||
keyword: "Q".into(),
|
||
},
|
||
CmdOption::new("Distance", "D"),
|
||
CmdOption::new("Radius", "R"),
|
||
CmdOption::new("Angle", "A"),
|
||
CmdOption::new("ARea", "AR"),
|
||
]
|
||
}
|
||
|
||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||
let input = text.trim().to_ascii_uppercase();
|
||
if self.mode == Mode::Angle
|
||
&& self.points.is_empty()
|
||
&& matches!(input.as_str(), "L" | "LINES")
|
||
{
|
||
self.mode = Mode::AngleLines;
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
if self.mode == Mode::AngleLines
|
||
&& self.first_line.is_none()
|
||
&& matches!(input.as_str(), "P" | "POINTS")
|
||
{
|
||
self.mode = Mode::Angle;
|
||
self.hovered = None;
|
||
return Some(CmdResult::NeedPoint);
|
||
}
|
||
if self.mode != Mode::Choose {
|
||
return None;
|
||
}
|
||
let t = text.trim().to_uppercase();
|
||
self.mode = match t.as_str() {
|
||
"Q" | "QUICK" => Mode::Quick,
|
||
"D" | "DISTANCE" => Mode::Distance,
|
||
"R" | "RADIUS" => Mode::Radius,
|
||
"A" | "ANGLE" => Mode::Angle,
|
||
"AR" | "AREA" => Mode::Area,
|
||
_ => return Some(CmdResult::NeedPoint), // re-prompt on unknown keyword
|
||
};
|
||
Some(CmdResult::NeedPoint)
|
||
}
|
||
|
||
fn needs_entity_pick(&self) -> bool {
|
||
matches!(self.mode, Mode::Radius | Mode::AngleLines)
|
||
}
|
||
|
||
// QUICK reads the scene under the cursor; it never resolves an object, so
|
||
// every click lands in `on_point`.
|
||
fn needs_structure_point_pick(&self) -> bool {
|
||
self.mode == Mode::Quick
|
||
}
|
||
|
||
fn object_pick_hover_previews(
|
||
&self,
|
||
scene: &crate::scene::Scene,
|
||
cursor: DVec3,
|
||
) -> Vec<WireModel> {
|
||
if self.mode != Mode::Quick || !cursor.is_finite() {
|
||
return Vec::new();
|
||
}
|
||
let hits = Self::quick_hits(scene, self.plane, cursor);
|
||
let (wires, readout) = Self::quick_result(self.plane, cursor, hits);
|
||
*self.quick.borrow_mut() = readout;
|
||
wires
|
||
}
|
||
|
||
fn object_pick_miss_message(&self) -> &'static str {
|
||
""
|
||
}
|
||
|
||
fn inject_before_entity_pick(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
fn inject_picked_entity(&mut self, entity: EntityType) {
|
||
self.picked = Some(entity);
|
||
}
|
||
|
||
fn wants_hover_entity(&self, handle: Handle) -> bool {
|
||
self.needs_entity_pick()
|
||
&& !handle.is_null()
|
||
&& self.hovered.as_ref().map(|(h, _)| *h) != Some(handle)
|
||
}
|
||
|
||
fn inject_hover_entity(&mut self, handle: Handle, entity: EntityType) {
|
||
self.hovered = Some((handle, entity));
|
||
}
|
||
|
||
fn on_hover_entity(&mut self, handle: Handle, _point: DVec3) -> Vec<WireModel> {
|
||
if handle.is_null() || self.hovered.as_ref().map(|(h, _)| *h) != Some(handle) {
|
||
self.hovered = None;
|
||
}
|
||
Vec::new()
|
||
}
|
||
|
||
fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult {
|
||
// Never reuse an entity injected for a previous pick.
|
||
let picked = self.picked.take();
|
||
if handle.is_null() {
|
||
return CmdResult::NeedPoint;
|
||
}
|
||
match self.mode {
|
||
Mode::Radius => match picked.as_ref().and_then(Self::radius_of) {
|
||
Some(radius) => CmdResult::Measurement(Self::radius_msg(radius)),
|
||
None => CmdResult::ReportError(crate::tr!("measurement", "invalid-radius")),
|
||
},
|
||
Mode::AngleLines => {
|
||
let Some(direction) = picked.as_ref().and_then(Self::line_direction) else {
|
||
return CmdResult::ReportError(crate::tr!("measurement", "invalid-line"));
|
||
};
|
||
if let Some((first_handle, first)) = self.first_line {
|
||
if first_handle == handle {
|
||
return CmdResult::ReportError(crate::tr!("measurement", "different-line"));
|
||
}
|
||
match Self::line_angle(first, direction) {
|
||
Some(angle) => CmdResult::Measurement(Self::angle_value_msg(angle)),
|
||
None => CmdResult::ReportError(crate::tr!("measurement", "invalid-line")),
|
||
}
|
||
} else {
|
||
self.first_line = Some((handle, direction));
|
||
CmdResult::NeedPoint
|
||
}
|
||
}
|
||
_ => CmdResult::NeedPoint,
|
||
}
|
||
}
|
||
|
||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||
if self.mode == Mode::Quick {
|
||
return match self.quick.borrow().clone() {
|
||
Some(text) => CmdResult::ReportMeasurement(text),
|
||
None => CmdResult::NeedPoint,
|
||
};
|
||
}
|
||
if matches!(self.mode, Mode::Distance | Mode::Angle | Mode::Area) && !pt.is_finite() {
|
||
return CmdResult::ReportError(crate::tr!("measurement", "invalid-point"));
|
||
}
|
||
if self.mode == Mode::Angle
|
||
&& !self.points.is_empty()
|
||
&& Self::direction(pt - self.points[0]).is_none()
|
||
{
|
||
return CmdResult::ReportError(crate::tr!("measurement", "degenerate-ray"));
|
||
}
|
||
match self.mode {
|
||
Mode::Distance => {
|
||
self.points.push(pt);
|
||
if self.points.len() == 2 {
|
||
CmdResult::Measurement(Self::distance_msg(
|
||
self.plane,
|
||
self.points[0],
|
||
self.points[1],
|
||
))
|
||
} else {
|
||
CmdResult::NeedPoint
|
||
}
|
||
}
|
||
Mode::Angle => {
|
||
self.points.push(pt);
|
||
if self.points.len() == 3 {
|
||
match Self::angle_msg(self.points[0], self.points[1], self.points[2]) {
|
||
Some(text) => CmdResult::Measurement(text),
|
||
None => {
|
||
self.points.pop();
|
||
CmdResult::ReportError(crate::tr!("measurement", "degenerate-ray"))
|
||
}
|
||
}
|
||
} else {
|
||
CmdResult::NeedPoint
|
||
}
|
||
}
|
||
Mode::Area => {
|
||
self.points.push(pt);
|
||
CmdResult::NeedPoint
|
||
}
|
||
// Choose / Radius do not take point picks.
|
||
_ => CmdResult::NeedPoint,
|
||
}
|
||
}
|
||
|
||
fn on_enter(&mut self) -> CmdResult {
|
||
match self.mode {
|
||
Mode::Area => {
|
||
if self.points.len() < 3 {
|
||
CmdResult::Cancel
|
||
} else {
|
||
CmdResult::Measurement(Self::area_msg(self.plane, &self.points))
|
||
}
|
||
}
|
||
_ => CmdResult::Cancel,
|
||
}
|
||
}
|
||
|
||
fn on_escape(&mut self) -> CmdResult {
|
||
CmdResult::Cancel
|
||
}
|
||
|
||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||
let f = |p: DVec3| [p.x as f32, p.y as f32, p.z as f32];
|
||
match self.mode {
|
||
Mode::Distance | Mode::Angle => {
|
||
if self.points.is_empty() {
|
||
return None;
|
||
}
|
||
let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| f(*p)).collect();
|
||
if self.mode == Mode::Angle && self.points.len() >= 2 {
|
||
pts = vec![f(self.points[1]), f(self.points[0])];
|
||
}
|
||
pts.push(f(pt));
|
||
Some(Self::preview_wire("measuregeom_preview", pts))
|
||
}
|
||
Mode::Area => {
|
||
if self.points.is_empty() {
|
||
return None;
|
||
}
|
||
let mut pts: Vec<[f32; 3]> = self.points.iter().map(|p| f(*p)).collect();
|
||
pts.push(f(pt));
|
||
pts.push(f(self.points[0]));
|
||
Some(Self::preview_wire("measuregeom_preview", pts))
|
||
}
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Autocomplete registry ─────────────────────────────────
|
||
inventory::submit!(crate::command::CommandRegistration {
|
||
names: &["MEASUREGEOM", "MEA", "MEASURERADIUS", "MEASUREANGLE"]
|
||
}); // MeasureGeomCommand
|
||
|
||
#[cfg(test)]
|
||
mod measurement_tests {
|
||
use super::*;
|
||
use crate::entities::common::{set_unit_context, unit_context};
|
||
use codec::{
|
||
entities::{Arc, Circle, Line},
|
||
types::Vector3,
|
||
};
|
||
|
||
fn units() {
|
||
let mut context = unit_context();
|
||
context.lunits = 2;
|
||
context.luprec = 4;
|
||
context.auprec = 2;
|
||
set_unit_context(context);
|
||
}
|
||
fn result(result: CmdResult) -> String {
|
||
let CmdResult::Measurement(text) = result else {
|
||
panic!("expected measurement");
|
||
};
|
||
text
|
||
}
|
||
fn pick(command: &mut MeasureGeomCommand, entity: EntityType, id: u64) -> CmdResult {
|
||
command.inject_picked_entity(entity);
|
||
command.on_entity_pick(Handle::new(id), DVec3::ZERO)
|
||
}
|
||
fn line(end: Vector3) -> EntityType {
|
||
EntityType::Line(Line::from_points(Vector3::ZERO, end))
|
||
}
|
||
|
||
#[test]
|
||
fn radius_circle_and_arc_respect_length_precision() {
|
||
units();
|
||
for entity in [
|
||
EntityType::Circle(Circle::from_coords(500000.0, 100000.0, 0.0, 2.34567)),
|
||
EntityType::Arc(Arc::from_center_radius_angles(
|
||
Vector3::ZERO,
|
||
2.34567,
|
||
0.0,
|
||
std::f64::consts::FRAC_PI_2,
|
||
)),
|
||
] {
|
||
let text = result(pick(&mut MeasureGeomCommand::radius(), entity, 1));
|
||
assert!(text.contains("2.3457"), "{text}");
|
||
assert!(text.contains("4.6913"), "{text}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn radius_rejects_invalid_entities_and_stale_injection() {
|
||
let mut command = MeasureGeomCommand::radius();
|
||
assert!(matches!(
|
||
pick(&mut command, line(Vector3::UNIT_X), 1),
|
||
CmdResult::ReportError(_)
|
||
));
|
||
for radius in [0.0, -1.0, f64::NAN, f64::INFINITY] {
|
||
assert!(matches!(
|
||
pick(
|
||
&mut command,
|
||
EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, radius)),
|
||
1
|
||
),
|
||
CmdResult::ReportError(_)
|
||
));
|
||
}
|
||
command.inject_picked_entity(EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0)));
|
||
assert!(matches!(
|
||
command.on_entity_pick(Handle::NULL, DVec3::ZERO),
|
||
CmdResult::NeedPoint
|
||
));
|
||
assert!(matches!(
|
||
command.on_entity_pick(Handle::new(2), DVec3::ZERO),
|
||
CmdResult::ReportError(_)
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn known_angles_extreme_scales_and_degenerate_rays() {
|
||
for degrees in [0.0_f64, 30.0, 45.0, 90.0, 135.0, 180.0] {
|
||
let radians = degrees.to_radians();
|
||
let ray = DVec3::new(radians.cos(), radians.sin(), 0.0);
|
||
for scale in [1e-200, 1.0, 1e200] {
|
||
let actual =
|
||
MeasureGeomCommand::angle_degrees(DVec3::X * scale, ray * scale).unwrap();
|
||
assert!((actual - degrees).abs() < 1e-10);
|
||
}
|
||
}
|
||
assert_eq!(
|
||
MeasureGeomCommand::angle_degrees(DVec3::X, DVec3::Z),
|
||
Some(90.0)
|
||
);
|
||
assert_eq!(
|
||
MeasureGeomCommand::angle_degrees(DVec3::ZERO, DVec3::X),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
MeasureGeomCommand::angle_degrees(DVec3::NAN, DVec3::X),
|
||
None
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_angle_points_do_not_advance_and_survey_precision_is_retained() {
|
||
units();
|
||
let mut command = MeasureGeomCommand::angle();
|
||
let vertex = DVec3::new(500000.001, 600000.002, 100.0);
|
||
assert!(matches!(
|
||
command.on_point(DVec3::NAN),
|
||
CmdResult::ReportError(_)
|
||
));
|
||
assert!(command.points.is_empty());
|
||
command.on_point(vertex);
|
||
assert!(matches!(
|
||
command.on_point(vertex),
|
||
CmdResult::ReportError(_)
|
||
));
|
||
assert_eq!(command.points.len(), 1);
|
||
command.on_point(vertex + DVec3::X * 0.001);
|
||
assert!(matches!(
|
||
command.on_point(vertex),
|
||
CmdResult::ReportError(_)
|
||
));
|
||
assert_eq!(command.points.len(), 2);
|
||
assert!(result(command.on_point(vertex + DVec3::Y * 0.001)).contains("90.00°"));
|
||
}
|
||
|
||
#[test]
|
||
fn angle_preview_two_rays_and_live_value() {
|
||
units();
|
||
let mut command = MeasureGeomCommand::angle();
|
||
command.on_point(DVec3::ZERO);
|
||
assert!(command.measurement_readout(DVec3::Y).is_none());
|
||
command.on_point(DVec3::X);
|
||
assert!(command
|
||
.measurement_readout(DVec3::Y)
|
||
.unwrap()
|
||
.contains("90.00°"));
|
||
assert!(command
|
||
.measurement_readout(DVec3::X + DVec3::Y)
|
||
.unwrap()
|
||
.contains("45.00°"));
|
||
assert_eq!(
|
||
command.on_mouse_move(DVec3::Y).unwrap().points,
|
||
vec![[1.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn two_lines_smaller_angle_and_invalid_selections() {
|
||
units();
|
||
let mut command = MeasureGeomCommand::angle();
|
||
command.on_text_input("LINES");
|
||
assert!(command.needs_entity_pick());
|
||
assert!(matches!(
|
||
pick(&mut command, line(Vector3::ZERO), 1),
|
||
CmdResult::ReportError(_)
|
||
));
|
||
assert!(matches!(
|
||
pick(&mut command, line(Vector3::UNIT_X), 1),
|
||
CmdResult::NeedPoint
|
||
));
|
||
assert!(matches!(
|
||
pick(&mut command, line(Vector3::UNIT_X), 1),
|
||
CmdResult::ReportError(_)
|
||
));
|
||
assert!(matches!(
|
||
pick(
|
||
&mut command,
|
||
EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 1.0)),
|
||
2
|
||
),
|
||
CmdResult::ReportError(_)
|
||
));
|
||
assert!(
|
||
result(pick(&mut command, line(Vector3::new(-1.0, -1.0, 0.0)), 2)).contains("45.00°")
|
||
);
|
||
assert_eq!(
|
||
MeasureGeomCommand::line_angle(DVec3::X, -DVec3::X),
|
||
Some(0.0)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn hover_clears_on_miss_without_completing_measurement() {
|
||
units();
|
||
let mut command = MeasureGeomCommand::radius();
|
||
let handle = Handle::new(5);
|
||
assert!(command.wants_hover_entity(handle));
|
||
command.inject_hover_entity(
|
||
handle,
|
||
EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 2.0)),
|
||
);
|
||
command.on_hover_entity(handle, DVec3::ZERO);
|
||
assert!(command
|
||
.measurement_readout(DVec3::ZERO)
|
||
.unwrap()
|
||
.contains("2.0000"));
|
||
assert!(!command.wants_hover_entity(handle));
|
||
assert!(command.picked.is_none());
|
||
command.on_hover_entity(Handle::NULL, DVec3::ZERO);
|
||
assert!(command.measurement_readout(DVec3::ZERO).is_none());
|
||
command.inject_hover_entity(handle, line(Vector3::UNIT_X));
|
||
assert!(command.measurement_readout(DVec3::ZERO).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn measuregeom_all_four_modes_remain_available() {
|
||
units();
|
||
assert_eq!(MeasureGeomCommand::new().options().len(), 5);
|
||
let mut command = MeasureGeomCommand::new();
|
||
command.on_text_input("D");
|
||
command.on_point(DVec3::ZERO);
|
||
assert!(result(command.on_point(DVec3::new(3.0, 4.0, 0.0))).contains("5.0000"));
|
||
let mut command = MeasureGeomCommand::new();
|
||
command.on_text_input("AR");
|
||
for p in [DVec3::ZERO, DVec3::X * 4.0, DVec3::Y * 3.0] {
|
||
command.on_point(p);
|
||
}
|
||
let text = result(command.on_enter());
|
||
assert!(text.contains("6.0000") && text.contains("12.0000"));
|
||
let mut command = MeasureGeomCommand::new();
|
||
command.on_text_input("A");
|
||
command.on_point(DVec3::ZERO);
|
||
command.on_point(DVec3::X);
|
||
assert!(result(command.on_point(DVec3::Y)).contains("90.00°"));
|
||
let mut command = MeasureGeomCommand::new();
|
||
command.on_text_input("R");
|
||
assert!(result(pick(
|
||
&mut command,
|
||
EntityType::Circle(Circle::from_coords(0.0, 0.0, 0.0, 3.0)),
|
||
1
|
||
))
|
||
.contains("3.0000"));
|
||
}
|
||
}
|