# Conflicts:
#	docs/parity-checklist.md
This commit is contained in:
aiongg
2026-10-25 02:22:16 +00:00
6 changed files with 214 additions and 13 deletions
+106 -9
View File
@@ -401,8 +401,17 @@ pub(super) fn area_of(pts: &[Vec2]) -> (f64, f64) {
(a, per)
}
/// `points` as given, `None` when it is absent; a `points` that is not a list of points is an
/// error, never a fall back to the selection.
fn given_points(p: &Value, cmd: &str) -> Result<Option<Vec<Vec2>>> {
match p.get("points") {
None | Some(Value::Null) => Ok(None),
Some(_) => points_param(p, "points").map(Some).ok_or_else(|| bad(cmd, "`points` must be a list of [x, y] points")),
}
}
fn run_area(s: &mut Session, p: &Value) -> Result<Value> {
let (a, per) = if let Some(pts) = points_param(p, "points") {
let (a, per) = if let Some(pts) = given_points(p, "area")? {
area_of(&pts)
} else {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("area", "`points` or `handle` is required"))?;
@@ -415,6 +424,37 @@ fn area_text(s: &Session, a: f64, per: f64) -> String {
format!("Area = {}, Perimeter = {}", fmt_d(s, a), fmt_d(s, per))
}
/// MEASUREGEOM's Volume option: the area of a polygon of three or more points, or of a 2D object
/// as AREA measures it (an open polyline as if closed), times the absolute value of a height.
pub(super) fn run_volume(s: &mut Session, p: &Value) -> Result<Value> {
let a = if let Some(pts) = given_points(p, "volume.measure")? {
if pts.len() < 3 {
return Err(bad("volume.measure", "`points` needs 3 or more [x,y] points"));
}
area_of(&pts).0
} else {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("volume.measure", "`points` or `handle` is required"))?;
object_area(s, h)?.0
};
let height = f64_req("volume.measure", p, "height")?;
let v = a * height.abs();
if !v.is_finite() {
return Err(bad("volume.measure", "the volume is too large"));
}
Ok(json!({ "volume": v, "area": a, "height": height, "message": volume_text(s, v) }))
}
fn volume_text(s: &Session, v: f64) -> String {
format!("Volume = {}", fmt_volume(s, v))
}
/// Format a volume as a number in the drawing's `LUPREC`. Engineering and architectural units
/// show it as a decimal number, not in feet and inches.
fn fmt_volume(s: &Session, v: f64) -> String {
let (u, p) = s.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4));
format_distance(v, if matches!(u, 3 | 4) { 2 } else { u }, p)
}
/// Area and perimeter of a closed object (AREA's Object option).
pub(super) fn object_area(s: &Session, h: Handle) -> Result<(f64, f64)> {
let d = s.doc()?;
@@ -889,9 +929,17 @@ pub(super) struct AreaM {
object: bool,
/// Asking for the length of a segment (the Length option).
length: bool,
/// Measuring volumes (MEASUREGEOM's Volume option): each area is multiplied by the absolute value of a height.
volume: bool,
/// Areas measured in volume mode that still wait for their height, oldest first.
pending: Vec<f64>,
}
impl AreaM {
/// MEASUREGEOM's Volume option: AREA's prompts, then a height for each area.
pub(super) fn volume() -> Self {
AreaM { volume: true, ..AreaM::default() }
}
fn mode_prefix(&self) -> &'static str {
match self.mode {
AreaMode::Single => "",
@@ -899,8 +947,36 @@ impl AreaM {
AreaMode::Subtract => "(SUBTRACT mode) ",
}
}
/// The Add and Subtract keywords: "Add area" and "Subtract area", or "Add volume" and
/// "Subtract volume".
fn mode_keywords(&self) -> (&'static str, &'static str) {
if self.volume { ("Add volume", "Subtract volume") } else { ("Add area", "Subtract area") }
}
/// The running total with `v` added or subtracted; in single mode, the total as it is.
fn total_with(&self, v: f64) -> f64 {
match self.mode {
AreaMode::Single => self.total,
AreaMode::Add => self.total + v,
AreaMode::Subtract => self.total - v,
}
}
/// Report a volume (and the running total in add/subtract mode); ends the command in single mode.
fn volume_measured(&mut self, s: &mut Session, v: f64) -> Step {
s.echo(volume_text(s, v));
if self.mode == AreaMode::Single {
return Step::Done;
}
self.total = self.total_with(v);
s.echo(format!("Total volume = {}", fmt_volume(s, self.total)));
Step::Continue
}
/// Report an area (and the running total in add/subtract mode); ends the command in single mode.
/// In volume mode the area waits for its height instead.
fn measured(&mut self, s: &mut Session, a: f64, per: f64) -> Step {
if self.volume {
self.pending.push(a);
return Step::Continue;
}
s.echo(area_text(s, a, per));
match self.mode {
AreaMode::Single => return Step::Done,
@@ -923,9 +999,12 @@ impl AreaM {
impl Interactive for AreaM {
fn name(&self) -> &'static str {
"AREA"
if self.volume { "MEASUREGEOM" } else { "AREA" }
}
fn prompt(&self, _s: &Session) -> Prompt {
if !self.pending.is_empty() {
return Prompt::new(format!("{}Specify height", self.mode_prefix()), Accept::NUMBER);
}
if self.object {
return Prompt::new(format!("{}Select objects", self.mode_prefix()), Accept::SELECT);
}
@@ -933,17 +1012,31 @@ impl Interactive for AreaM {
return length_prompt();
}
let next = format!("{}Specify next point", self.mode_prefix());
let (add, subtract) = self.mode_keywords();
match (self.pts.len(), self.mode) {
(0, AreaMode::Single) => {
Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area", "Subtract area"]).default("Object")
}
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Subtract area"]),
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", "Add area"]),
(0, AreaMode::Single) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add, subtract]).default("Object"),
(0, AreaMode::Add) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", subtract]),
(0, AreaMode::Subtract) => Prompt::new("Specify first corner point", Accept::POINT).kw(&["Object", add]),
(1, _) => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo"]).base_opt(self.pts.last().copied()),
_ => Prompt::new(next, Accept::POINT).kw(&["Arc", "Length", "Undo", "Total"]).default("Total").base_opt(self.pts.last().copied()),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.pending.is_empty() {
if let Input::Text(t) = i {
match crate::units::parse_distance(&t).filter(|v| v.is_finite()) {
Some(h) => match self.pending.first().map(|a| a * h.abs()) {
Some(v) if v.is_finite() && self.total_with(v).is_finite() => {
self.pending.remove(0);
return Ok(self.volume_measured(s, v));
}
_ => s.echo("The volume is too large."),
},
None => s.echo("Requires a distance."),
}
}
return Ok(Step::Continue);
}
if self.object {
match i {
Input::Pick(hs) => {
@@ -953,6 +1046,10 @@ impl Interactive for AreaM {
if self.measured(s, a, per) == Step::Done {
return Ok(Step::Done);
}
// A single volume takes the first object with an area.
if self.volume && self.mode == AreaMode::Single {
break;
}
}
Err(_) => s.echo("The selected object has no area."),
}
@@ -980,8 +1077,8 @@ impl Interactive for AreaM {
Input::Point(p) => self.pts.push(p),
Input::Keyword(k) => match k.as_str() {
"Object" => self.object = true,
"Add area" => self.mode = AreaMode::Add,
"Subtract area" => self.mode = AreaMode::Subtract,
"Add area" | "Add volume" => self.mode = AreaMode::Add,
"Subtract area" | "Subtract volume" => self.mode = AreaMode::Subtract,
"Undo" => {
self.pts.pop();
}
+10 -3
View File
@@ -27,11 +27,16 @@ pub fn specs() -> Vec<CommandSpec> {
.interactive(|_| Ok(Box::<PickM>::default())),
CommandSpec::new("find", "Find...", run_find).menu(&["Edit", "Find..."]).params("{find, replace?, matchCase?: bool, wholeWord?: bool}"),
CommandSpec::new("measuregeom", "Measure Geometry", run_measuregeom)
.params("{mode: distance|radius|angle|area, ...}")
.params("{mode: distance|radius|angle|area|volume, ...}")
.noundo()
.interactive(|_| Ok(Box::<MeasureM>::default())),
CommandSpec::new("radius.measure", "Radius", run_radius).menu(&["Tools", "Inquiry", "Radius"]).params("{handle}").noundo(),
CommandSpec::new("angle.measure", "Angle", run_angle).menu(&["Tools", "Inquiry", "Angle"]).params("{vertex, p1, p2} | {h1, h2}").noundo(),
CommandSpec::new("volume.measure", "Volume", super::inquiry::run_volume)
.menu(&["Tools", "Inquiry", "Volume"])
.params("{points: [[x,y],...] (3 or more) | handle (a closed 2D object), height} → {volume, area, height}")
.noundo()
.interactive(|_| Ok(Box::new(super::inquiry::AreaM::volume()))),
]
}
@@ -318,7 +323,8 @@ fn run_measuregeom(s: &mut Session, p: &Value) -> Result<Value> {
"radius" | "r" => run_radius(s, p),
"angle" | "a" => run_angle(s, p),
"area" | "ar" => s.execute("area", p),
_ => Err(bad("measuregeom", "mode must be distance, radius, angle or area")),
"volume" | "v" => super::inquiry::run_volume(s, p),
_ => Err(bad("measuregeom", "mode must be distance, radius, angle, area or volume")),
}
}
@@ -339,7 +345,7 @@ impl Interactive for MeasureM {
Some(m) => m.prompt(s),
None if self.radius => Prompt::new("Select arc or circle", Accept::POINT),
None => Prompt::new("Enter an option", Accept { point: false, number: false, text: false, select: false, enter: true })
.kw(&["Distance", "Radius", "ARea"])
.kw(&["Distance", "Radius", "ARea", "Volume"])
.default("Distance"),
}
}
@@ -366,6 +372,7 @@ impl Interactive for MeasureM {
Input::Keyword(k) => match k.as_str() {
"Radius" => self.radius = true,
"ARea" => self.inner = Some(Box::new(super::inquiry::AreaM::default())),
"Volume" => self.inner = Some(Box::new(super::inquiry::AreaM::volume())),
_ => self.inner = Some(Box::new(super::inquiry::DistM::default())),
},
_ => {}
+95 -1
View File
@@ -1,4 +1,5 @@
//! AREA's Object / Add area / Subtract area and DIST's Multiple points, typed as at the command line.
//! AREA's Object / Add area / Subtract area, MEASUREGEOM's Volume and DIST's Multiple points, typed as
//! at the command line.
use cadcraft_engine::Session;
use serde_json::json;
@@ -103,3 +104,96 @@ fn area_of_an_ellipse_reports_its_exact_perimeter() {
let (_, log) = run(&["ellipse c 0,0 10,0 4", "area", "o", "10,0"]);
assert!(has(&log, "Perimeter = 46.0262"), "{log:?}");
}
#[test]
fn measuregeom_volume_multiplies_an_area_by_a_height() {
// Points: a 4 x 3 triangle's area is 6, times a height of 10.
let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "10"]);
assert!(has(&log, "Volume = 60.0000"), "{log:?}");
assert!(s.running.is_none(), "{}", s.prompt_text());
// Object (the default): a 10 x 10 square polyline, height 2.
let (s, log) = run(&["rectang 0,0 10,10", "measuregeom", "v", "", "10,5", "2"]);
assert!(has(&log, "Volume = 200.0000"), "{log:?}");
assert!(s.running.is_none(), "{}", s.prompt_text());
// A height that is not a distance is asked for again.
let (s, log) = run(&["measuregeom", "v", "0,0", "4,0", "4,3", "", "tall"]);
assert!(has(&log, "Requires a distance."), "{log:?}");
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
// The JSON forms, by points and by handle.
let mut s = Session::new();
let r = s.execute("measuregeom", &json!({"mode": "volume", "points": [[0, 0], [4, 0], [4, 3]], "height": 10})).unwrap();
assert_eq!(r["volume"], json!(60.0));
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
let r = s.execute("volume.measure", &json!({"handle": c["handle"], "height": -3})).unwrap();
assert!((r["volume"].as_f64().unwrap() - 3.0 * std::f64::consts::PI).abs() < 1e-12, "{r}");
assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0]], "height": 1})).is_err());
assert!(s.execute("volume.measure", &json!({"points": [[0, 0], [1, 0], [1, 1]]})).is_err());
}
#[test]
fn measure_with_points_that_are_not_points_is_an_error_not_the_selection() {
let mut s = Session::new();
let c = s.execute("circle", &json!({"center": [0, 0], "radius": 1})).unwrap();
s.set_selection(vec![cadcraft_engine::doc::Handle::parse_hex(c["handle"].as_str().unwrap()).unwrap()]);
for bad in [json!([[0, 0], [4, 0], "north"]), json!([[0, 0], [4, 0], [4]]), json!(7)] {
assert!(s.execute("volume.measure", &json!({"points": bad, "height": 10})).is_err(), "{bad}");
assert!(s.execute("area", &json!({"points": bad})).is_err(), "{bad}");
}
// Without `points`, both measure the selected circle.
let r = s.execute("volume.measure", &json!({"height": 1})).unwrap();
assert!((r["volume"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}");
let r = s.execute("area", &json!({"points": null})).unwrap();
assert!((r["area"].as_f64().unwrap() - std::f64::consts::PI).abs() < 1e-12, "{r}");
}
#[test]
fn measuregeom_volume_too_large_to_hold_is_refused() {
// A 2e150 square has an area of 4e300; times 1e10 it is past f64's range.
let p = json!([[0, 0], [2e150, 0], [2e150, 2e150], [0, 2e150]]);
let mut s = Session::new();
assert!(s.execute("volume.measure", &json!({"points": p, "height": 1e10})).is_err());
assert!(s.execute("volume.measure", &json!({"points": p, "height": 1})).is_ok());
// Typed, the height is asked for again.
let (s, log) = run(&["measuregeom", "v", "0,0", "2e150,0", "2e150,2e150", "0,2e150", "", "1e10"]);
assert!(has(&log, "The volume is too large."), "{log:?}");
assert!(!has(&log, "Volume = "), "{log:?}");
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
// Two volumes that each fit can overflow the running total; the second is asked for again.
let sq = ["0,0", "1,0", "1,1", "0,1", ""];
let mut lines = vec!["measuregeom", "v", "a"];
lines.extend(sq);
lines.push("1e308");
lines.extend(sq);
lines.push("1e308");
let (s, log) = run(&lines);
assert!(has(&log, "The volume is too large."), "{log:?}");
assert!(!has(&log, "Total volume = 0"), "{log:?}");
assert!(s.prompt_text().contains("Specify height"), "{}", s.prompt_text());
}
#[test]
fn tools_inquiry_volume_adds_and_subtracts_a_running_total() {
let spec = cadcraft_engine::find_command("volume.measure").expect("no command volume.measure");
assert_eq!(spec.menu, ["Tools", "Inquiry", "Volume"]);
// Add a 10 x 10 square by points at height 3, subtract a 2 x 2 square object at height 5.
let mut s = Session::new();
s.cmdline("rectang 20,0 22,2").unwrap();
s.start("volume.measure").unwrap();
for l in ["a", "0,0", "10,0", "10,10", "0,10", "", "3", "s", "o", "21,0", "5", "", ""] {
s.cmdline(l).unwrap();
}
assert!(has(&s.log, "Total volume = 300.0000"), "{:?}", s.log);
assert!(has(&s.log, "Total volume = 280.0000"), "{:?}", s.log);
assert!(s.running.is_none(), "{}", s.prompt_text());
// Architectural units show the volume as a number, not as a feet-and-inches length.
let (_, log) = run(&["setvar lunits 4", "measuregeom", "v", "0,0", "10,0", "10,10", "0,10", "", "12"]);
assert!(has(&log, "Volume = 1200"), "{log:?}");
assert!(!has(&log, "'-"), "{log:?}");
}
+1
View File
@@ -970,6 +970,7 @@
Viewport Properties Ansichtsfenster-Eigenschaften
Viewport {id} Ansichtsfenster {id}
Viewports Ansichtsfenster
Volume Volumen
W W
WCS WKS
White Weiß
1 # CADCraft German interface catalog — original translations by the CADCraft contributors.
970
971
972
973
974
975
976
+1
View File
@@ -964,6 +964,7 @@
Viewport Properties Propriétés de la fenêtre
Viewport {id} Fenêtre {id}
Viewports Fenêtres
Volume Volume
W O
WCS SCG
White Blanc
1 # CADCraft French interface catalog — original translations by @pechnikov2026-star.
964
965
966
967
968
969
970
+1
View File
@@ -964,6 +964,7 @@
Viewport Properties Свойства видового экрана
Viewport {id} Видовой экран {id}
Viewports Видовые экраны
Volume Объём
W З
WCS МСК
White Белый
1 # CADCraft Russian interface catalog — original translations by @pechnikov2026-star.
964
965
966
967
968
969
970