mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-27 04:16:48 -06:00
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
},
|
||||
_ => {}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -970,6 +970,7 @@
|
||||
Viewport Properties Ansichtsfenster-Eigenschaften
|
||||
Viewport {id} Ansichtsfenster {id}
|
||||
Viewports Ansichtsfenster
|
||||
Volume Volumen
|
||||
W W
|
||||
WCS WKS
|
||||
White Weiß
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -964,6 +964,7 @@
|
||||
Viewport Properties Свойства видового экрана
|
||||
Viewport {id} Видовой экран {id}
|
||||
Viewports Видовые экраны
|
||||
Volume Объём
|
||||
W З
|
||||
WCS МСК
|
||||
White Белый
|
||||
|
||||
|
Reference in New Issue
Block a user