diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index ca7defed8..a362a3502 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -15,6 +15,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, point_param, points_param, size_p use crate::{Accept, EngineError, Input, Interactive, Picker, Prompt, Result, Session, Step}; mod assoc; +pub(crate) use assoc::detach; mod edit; mod pick; use super::has_doc; diff --git a/crates/engine/src/cmd/hatch/assoc.rs b/crates/engine/src/cmd/hatch/assoc.rs index b653aa451..e67782938 100644 --- a/crates/engine/src/cmd/hatch/assoc.rs +++ b/crates/engine/src/cmd/hatch/assoc.rs @@ -121,7 +121,7 @@ pub(super) fn same_shape(a: &[PolyVertex], b: &[PolyVertex]) -> bool { ba.min.dist(bb.min) <= eps && ba.max.dist(bb.max) <= eps && (shoelace(&pa).abs() - shoelace(&pb).abs()).abs() <= eps * (1.0 + size) } -pub(super) fn detach(h: &mut Hatch) { +pub(crate) fn detach(h: &mut Hatch) { h.associative = false; h.loops.iter_mut().for_each(|l| l.sources.clear()); } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 45199b12c..8afaddb3d 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -1,6 +1,7 @@ //! Properties: per-object property edits, current properties, CHPROP, MATCHPROP, linetypes, units, //! RENAME. +mod change; mod chprop; mod dash_units; mod matchprop; @@ -24,6 +25,10 @@ pub fn specs() -> Vec { CommandSpec::new("chprop", "Change Properties", chprop::run_chprop) .params("as properties.set, leaving out objects on locked layers → {changed, locked} (the command line asks Color, LAyer, LType, ltScale, LWeight, Thickness and TRansparency in turn)") .interactive(|_| Ok(Box::new(chprop::ChpropM::new()))), + CommandSpec::new("change", "Change", change::run_change) + .alias(&["-ch"]) + .params("{handles?, point?: [x, y], elevation?} → {changed, ignored, differingZ, detachedHatches, locked}: the point moves the nearer end of each line to it (level or plumb with the other end under ORTHOMODE) and, when it is the one object, passes a circle through it or places a text, attribute definition or block reference at it (`ignored` otherwise); `elevation` sets the z of objects whose points share one (`differingZ` otherwise); objects on locked layers are left out (the command line asks Specify change point or [Properties], then about each circle, text, attribute definition and block reference in turn)") + .interactive(|_| Ok(Box::new(change::ChangeM::new()))), CommandSpec::new("matchprop", "Match Properties", matchprop::run_matchprop) .menu(&["Modify", "Match Properties"]) .alias(&["ma", "painter"]) diff --git a/crates/engine/src/cmd/props/change.rs b/crates/engine/src/cmd/props/change.rs new file mode 100644 index 000000000..64ff4b1ac --- /dev/null +++ b/crates/engine/src/cmd/props/change.rs @@ -0,0 +1,544 @@ +//! CHANGE: change objects by a change point, or change their properties, as AutoCAD 2024 does. +//! +//! "Specify change point or [Properties]" follows "Select objects", which leaves objects on locked +//! layers out as CHPROP does. Properties is CHPROP's loop with Elev added: the elevation is set +//! when the command ends, and objects whose points differ in z keep theirs ("Cannot change +//! elevation of objects with differing Z coordinates."). A change point, or Enter, goes through the +//! objects in the order they were selected. Measured from AutoLISP's `command` (accoreconsole, +//! every object read back with `entget`): +//! - each line moves its end nearer the point to it: (0,0)–(50,0) with (60,10) gives +//! (0,0)–(60,10), with (−5,5) gives (−5,5)–(50,0). Under ORTHOMODE the moved end stays level or +//! plumb with the other one ((60,0)). Enter leaves lines alone; +//! - a circle passes through the point (centre (100,0) and (115,0): radius 15), or asks "Specify +//! new circle radius "; +//! - a text is placed by the point (the alignment point of justified text), or asks "Specify new +//! text insertion point "; then "Enter new text style", "Specify new height" (not +//! with a style of fixed height, nor for Aligned text), "Specify new rotation angle" (not for +//! Aligned or Fit text, whose two points set it) and "Enter new text". An +//! attribute definition asks "Enter new tag", "Enter new prompt" and "Enter new default value" in +//! place of the text. A style that does not exist is refused: `Style "5" not defined - use STYLE +//! command.`; +//! - a block reference is placed by the point, its attributes moving and turning with it, or asks +//! "Specify new block insertion point "; then "Specify new block rotation angle"; +//! - the point places a circle, text or block reference only when it is the one object selected: +//! with others, "Change point ignored" comes before that object's questions, which are asked as +//! for Enter; +//! - other objects (arcs, polylines…) are left alone, and with nothing to change it says "No +//! changeable object selected". +//! +//! Elev sets the z of lines, 3D polylines and solids whose points share one, and of points, +//! circles, arcs, ellipses, polylines, hatches (no longer associative: "Hatch boundary +//! associativity removed."), texts, multiline texts and block references. + +use std::collections::{HashSet, VecDeque}; + +use cadcraft_doc::{Attrib, EntityKind, HAlign, Handle, Text}; +use cadcraft_geom::{Mat3, Vec2, Vec3}; +use serde_json::{Value, json}; + +use super::super::machines::{SelOutcome, SelectPhase, number}; +use super::super::{bad, f64_req, point_req, targets}; +use super::chprop::{ChpropM, begin_unlocked, feed_unlocked, locked}; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +/// A question CHANGE asks about one object. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Ask { + /// Not a question: "Change point ignored" as the object comes up. + Ignored, + Radius, + TextPoint, + Style, + Height, + Rotation, + Value, + Tag, + AttPrompt, + Default, + BlockPoint, + BlockRotation, +} + +enum Stage { + Select, + Point, + Properties(Box), + /// The questions left, in order, each about one object. + Asking(VecDeque<(Handle, Ask)>), +} + +pub(super) struct ChangeM { + select: SelectPhase, + handles: Vec, + stage: Stage, +} + +impl ChangeM { + pub(super) fn new() -> Self { + ChangeM { select: SelectPhase::default(), handles: Vec::new(), stage: Stage::Select } + } + + /// Apply the change point `p` (`None`: Enter) and line up the questions it leaves. + fn change_point(&mut self, s: &mut Session, p: Option) -> Result { + let alone = self.handles.len() == 1; + let ortho = s.drafting_settings().orthomode; + let mut asks = VecDeque::new(); + let mut changeable = false; + for &h in &self.handles { + let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue }; + if matches!(kind, EntityKind::Line(_)) { + if let Some(p) = p { + move_end(s, h, p, ortho)?; + changeable = true; + } + continue; + } + let Some(questions) = questions(&kind) else { continue }; + changeable = true; + let placed = p.filter(|_| alone); + if p.is_some() && !alone { + asks.push_back((h, Ask::Ignored)); + } + if let Some(q) = placed { + place(s, h, q)?; + } + // The point answers the first question: the radius or the insertion point. + asks.extend(questions.iter().skip(usize::from(placed.is_some())).map(|a| (h, *a))); + } + if !changeable { + s.echo("No changeable object selected"); + return Ok(Step::Done); + } + self.stage = Stage::Asking(asks); + self.advance(s) + } + + /// Pass over what needs no answer: say "Change point ignored", and skip what [`skipped`] + /// says isn't asked. Done when nothing is left to ask. + fn advance(&mut self, s: &mut Session) -> Result { + let Stage::Asking(asks) = &mut self.stage else { return Ok(Step::Continue) }; + while let Some(&(h, ask)) = asks.front() { + match ask { + Ask::Ignored => s.echo("Change point ignored"), + a if skipped(s, h, a) => {} + _ => return Ok(Step::Continue), + } + asks.pop_front(); + } + s.set_selection(Vec::new()); + Ok(Step::Done) + } +} + +/// The questions about an object of `kind`, the first answered by the change point when it is +/// the one object; `None` when the change point leaves it alone. +fn questions(kind: &EntityKind) -> Option<&'static [Ask]> { + Some(match kind { + EntityKind::Circle(_) => &[Ask::Radius], + EntityKind::Text(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Value], + EntityKind::AttDef(_) => &[Ask::TextPoint, Ask::Style, Ask::Height, Ask::Rotation, Ask::Tag, Ask::AttPrompt, Ask::Default], + EntityKind::Insert(_) => &[Ask::BlockPoint, Ask::BlockRotation], + _ => return None, + }) +} + +fn text_of(kind: &EntityKind) -> Option<&Text> { + match kind { + EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t), + _ => None, + } +} + +fn text_of_mut(kind: &mut EntityKind) -> Option<&mut Text> { + match kind { + EntityKind::Text(t) | EntityKind::AttDef(Attrib { text: t, .. }) => Some(t), + _ => None, + } +} + +/// Where a circle, text or block reference is measured from: its centre, the point it is placed by +/// (the alignment point of justified text) or its insertion point. +fn anchor(kind: &EntityKind) -> Option { + match kind { + EntityKind::Circle(c) => Some(c.center.xy()), + EntityKind::Insert(i) => Some(i.insert.xy()), + k => text_of(k).map(Text::justify_point), + } +} + +/// Whether CHANGE passes over `ask` for text `h`: the height of a text whose style has a fixed +/// one or that is Aligned, and the rotation of Aligned or Fit text (their two points set it). +fn skipped(s: &Session, h: Handle, ask: Ask) -> bool { + let Ok(d) = s.doc() else { return false }; + let Some(t) = d.entity(h).and_then(|e| text_of(&e.kind)) else { return false }; + match ask { + Ask::Height => t.halign == HAlign::Aligned || d.text_style(&t.style).is_some_and(|st| st.height > 0.0), + Ask::Rotation => matches!(t.halign, HAlign::Aligned | HAlign::Fit), + _ => false, + } +} + +/// Move the end of line `h` nearer `p` to it; under ortho, level or plumb with the other end. +fn move_end(s: &mut Session, h: Handle, p: Vec2, ortho: bool) -> Result<()> { + let settings = s.drafting_settings(); + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Line(l) = &mut e.kind { + let (end, other) = if l.a.xy().dist(p) <= l.b.xy().dist(p) { (&mut l.a, l.b) } else { (&mut l.b, l.a) }; + let q = if ortho { crate::snap::iso::constrain(&settings, other.xy(), p) } else { p }; + end.x = q.x; + end.y = q.y; + } + })?; + Ok(()) +} + +/// Place circle, text or block reference `h` by `q`: a circle passes through it, a text or block +/// reference moves there (with its attributes). +fn place(s: &mut Session, h: Handle, q: Vec2) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Circle(c) = &mut e.kind { + let r = c.center.xy().dist(q); + if r > 0.0 && r.is_finite() { + c.radius = r; + } + } else if let Some(at) = anchor(&e.kind) { + e.kind.transform(&Mat3::translate(q - at)); + } + })?; + Ok(()) +} + +/// Set a circle's radius or a text's height. +fn set_length(s: &mut Session, h: Handle, v: f64) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| match &mut e.kind { + EntityKind::Circle(c) => c.radius = v, + k => { + if let Some(t) = text_of_mut(k) { + t.height = v; + } + } + })?; + Ok(()) +} + +/// Set a text's rotation, or turn a block reference (and its attributes) to `angle` about its +/// insertion point. +fn set_rotation(s: &mut Session, h: Handle, angle: f64) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| { + if let EntityKind::Insert(i) = &e.kind { + let m = Mat3::rotate_about(i.insert.xy(), angle - i.rotation); + e.kind.transform(&m); + } else if let Some(t) = text_of_mut(&mut e.kind) { + t.rotation = cadcraft_geom::norm_angle(angle); + } + })?; + Ok(()) +} + +/// Take an answer to `ask` about `h`; `false` when it was refused (the question comes again). +fn answer(s: &mut Session, h: Handle, ask: Ask, i: Input) -> Result { + let at = s.doc()?.entity(h).and_then(|e| anchor(&e.kind)).unwrap_or(Vec2::ZERO); + match (ask, i.clone()) { + // Enter keeps what there is. + (_, Input::Enter) => {} + (Ask::TextPoint | Ask::BlockPoint, Input::Point(q)) => place(s, h, q)?, + (Ask::TextPoint | Ask::BlockPoint, _) => return Err(EngineError::Other("Point or option keyword required.".into())), + (Ask::Radius | Ask::Height, Input::Point(_) | Input::Text(_)) => { + let v = match &i { + Input::Point(q) => q.dist(at), + Input::Text(t) => number(t).ok_or_else(|| EngineError::Other("Requires numeric distance or second point.".into()))?, + _ => 0.0, + }; + if !(v > 0.0 && v.is_finite()) { + return Err(EngineError::Other("Value must be positive and nonzero.".into())); + } + set_length(s, h, v)?; + } + (Ask::Rotation | Ask::BlockRotation, Input::Point(q)) => set_rotation(s, h, at.angle_to(q))?, + (Ask::Rotation | Ask::BlockRotation, Input::Text(t)) => { + let a = s.angle_settings().direction(&t).ok_or_else(|| EngineError::Other("Requires an angle.".into()))?; + set_rotation(s, h, a)?; + } + (Ask::Style, Input::Text(t) | Input::Keyword(t)) => { + let name = t.trim(); + let Some(style) = s.doc()?.text_style(name).map(|st| st.name.clone()) else { + s.echo(format!("Style \"{name}\" not defined - use STYLE command.")); + return Ok(false); + }; + s.doc_mut()?.modify_entity(h, |e| { + if let Some(t) = text_of_mut(&mut e.kind) { + t.style = style; + } + })?; + } + (Ask::Value | Ask::Tag | Ask::AttPrompt | Ask::Default, Input::Text(t) | Input::Keyword(t)) => { + s.doc_mut()?.modify_entity(h, |e| match (&mut e.kind, ask) { + (EntityKind::AttDef(a), Ask::Tag) => a.tag = t, + (EntityKind::AttDef(a), Ask::AttPrompt) => a.prompt = t, + (k, _) => { + if let Some(x) = text_of_mut(k) { + x.value = t; + } + } + })?; + } + _ => return Ok(false), + } + Ok(true) +} + +/// The question `ask` about `h`. +fn ask_prompt(s: &Session, h: Handle, ask: Ask) -> Prompt { + let d = s.doc().ok(); + let kind = d.and_then(|d| d.entity(h)).map(|e| &e.kind); + let at = kind.and_then(anchor); + let text = kind.and_then(text_of); + // As the drawing shows directions: from ANGBASE in the ANGDIR sense (ANGBASE 90° shows a text + // at 0° as 270.00). + let deg = |a: f64| format!("{:.2}", s.angle_settings().shown(a).to_degrees()); + match ask { + Ask::Radius => Prompt::new("Specify new circle radius", Accept::POINT_OR_NUMBER).default("no change").base_opt(at).distance(), + Ask::TextPoint => Prompt::new("Specify new text insertion point", Accept::POINT).default("no change"), + Ask::BlockPoint => Prompt::new("Specify new block insertion point", Accept::POINT).default("no change"), + Ask::Style => Prompt::new("Enter new text style", Accept::TEXT).default(text.map(|t| t.style.clone()).unwrap_or_default()), + Ask::Height => Prompt::new("Specify new height", Accept::POINT_OR_NUMBER) + .default(format!("{:.4}", text.map_or(0.0, |t| t.height))) + .base_opt(at) + .distance(), + Ask::Rotation => { + Prompt::new("Specify new rotation angle", Accept::POINT_OR_NUMBER).default(deg(text.map_or(0.0, |t| t.rotation))).base_opt(at) + } + Ask::BlockRotation => { + let r = match kind { + Some(EntityKind::Insert(i)) => i.rotation, + _ => 0.0, + }; + Prompt::new("Specify new block rotation angle", Accept::POINT_OR_NUMBER).default(deg(r)).base_opt(at) + } + Ask::Value => Prompt::new("Enter new text", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()), + Ask::Default => Prompt::new("Enter new default value", Accept::TEXT).default(text.map(|t| t.value.clone()).unwrap_or_default()), + Ask::Tag | Ask::AttPrompt => { + let (msg, v) = match (kind, ask) { + (Some(EntityKind::AttDef(a)), Ask::Tag) => ("Enter new tag", a.tag.clone()), + (Some(EntityKind::AttDef(a)), _) => ("Enter new prompt", a.prompt.clone()), + (_, Ask::Tag) => ("Enter new tag", String::new()), + _ => ("Enter new prompt", String::new()), + }; + Prompt::new(msg, Accept::TEXT).default(v) + } + Ask::Ignored => Prompt::new("", Accept::TEXT), + } +} + +/// The one z of `zs`, to rounding; `None` when they differ. +fn shared_z(zs: impl IntoIterator) -> Option { + let mut zs = zs.into_iter(); + let first = zs.next().unwrap_or(0.0); + zs.all(|z| (z - first).abs() <= 1e-9 * (1.0 + first.abs())).then_some(first) +} + +/// The elevation of an object Elev sets: `Some(None)` when its points differ in z (a line between +/// two heights), `None` for the kinds Elev leaves alone. +pub(super) fn elevation_of(kind: &EntityKind) -> Option> { + match kind { + EntityKind::Line(l) => Some(shared_z([l.a.z, l.b.z])), + EntityKind::Polyline3d(p) => Some(shared_z(p.points.iter().map(|q| q.z))), + EntityKind::Solid(so) | EntityKind::Trace(so) => Some(shared_z(so.corners.iter().map(|c| c.z))), + EntityKind::Point(p) => Some(Some(p.p.z)), + EntityKind::Circle(c) => Some(Some(c.center.z)), + EntityKind::Arc(a) => Some(Some(a.center.z)), + EntityKind::Ellipse(e) => Some(Some(e.center.z)), + EntityKind::LwPolyline(p) => Some(Some(p.elevation)), + EntityKind::Hatch(h) => Some(Some(h.elevation)), + EntityKind::MText(t) => Some(Some(t.insert.z)), + EntityKind::Insert(i) => Some(Some(i.insert.z)), + k => text_of(k).map(|t| Some(t.insert.z)), + } +} + +/// Move `text` up by `dz`. +fn raise_text(text: &mut Text, dz: f64) { + for p in std::iter::once(&mut text.insert).chain(text.align_pt.as_mut()) { + p.z += dz; + } +} + +fn set_elevation(kind: &mut EntityKind, z: f64) { + let set = |p: &mut Vec3| p.z = z; + match kind { + EntityKind::Line(l) => { + set(&mut l.a); + set(&mut l.b); + } + EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(set), + EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(set), + EntityKind::Point(p) => set(&mut p.p), + EntityKind::Circle(c) => set(&mut c.center), + EntityKind::Arc(a) => set(&mut a.center), + EntityKind::Ellipse(e) => set(&mut e.center), + EntityKind::LwPolyline(p) => p.elevation = z, + EntityKind::Hatch(h) => { + h.elevation = z; + crate::cmd::hatch::detach(h); + } + EntityKind::MText(t) => set(&mut t.insert), + // A block reference's attributes go up with it. + EntityKind::Insert(i) => { + let dz = z - i.insert.z; + set(&mut i.insert); + for a in &mut i.attribs { + raise_text(&mut a.text, dz); + if let Some(ml) = &mut a.multiline { + ml.mtext.insert.z += dz; + } + } + } + k => { + if let Some(t) = text_of_mut(k) { + let dz = z - t.insert.z; + raise_text(t, dz); + } + } + } +} + +/// What [`elevate`] did. +pub(super) struct Elevated { + /// The objects whose elevation was set. + pub(super) set: Vec, + /// Objects whose points differ in z, which keep theirs. + pub(super) differing: usize, + /// Associative hatches, which no longer are. + pub(super) detached: usize, +} + +/// Set the elevation of the objects among `hs` whose points share one. +pub(super) fn elevate(s: &mut Session, hs: &[Handle], z: f64) -> Result { + let d = s.doc_mut()?; + let mut r = Elevated { set: Vec::new(), differing: 0, detached: 0 }; + for &h in hs { + let Some(e) = d.entity(h) else { continue }; + match elevation_of(&e.kind) { + Some(Some(_)) => { + r.detached += usize::from(matches!(&e.kind, EntityKind::Hatch(hatch) if hatch.associative)); + d.modify_entity(h, |e| set_elevation(&mut e.kind, z))?; + r.set.push(h); + } + Some(None) => r.differing += 1, + None => {} + } + } + Ok(r) +} + +/// Properties ▸ Elev as the command ends: set the elevation, and say what was left alone or +/// changed besides. +pub(super) fn elevate_and_say(s: &mut Session, hs: &[Handle], z: f64) -> Result<()> { + let r = elevate(s, hs, z)?; + if r.differing > 0 { + s.echo("Cannot change elevation of objects with differing Z coordinates."); + } + if r.detached > 0 { + s.echo("Hatch boundary associativity removed."); + } + Ok(()) +} + +/// CHANGE's JSON form: `point` changes the objects as the change point does (`ignored`: circles, +/// texts and block references it leaves alone because others were selected too), `elevation` as +/// Properties ▸ Elev does (`differingZ`: objects whose points differ in z; `detachedHatches`: +/// associative hatches that no longer are); objects on locked layers are left out (`locked`). +pub(super) fn run_change(s: &mut Session, p: &Value) -> Result { + // A point or an elevation that can't be read is refused, and so is a call with neither. + let point = p.get("point").map(|_| point_req("change", p, "point")).transpose()?; + let elevation = p.get("elevation").map(|_| f64_req("change", p, "elevation")).transpose()?; + if point.is_none() && elevation.is_none() { + return Err(bad("change", "`point` or `elevation` is required")); + } + let hs = targets(s, p)?; + let locked = locked(s, &hs); + let free: Vec = hs.into_iter().filter(|h| !locked.contains(h)).collect(); + let (mut changed, mut ignored, mut differing, mut detached) = (HashSet::new(), 0, 0, 0); + if let Some(q) = point { + let ortho = s.drafting_settings().orthomode; + for &h in &free { + let Some(kind) = s.doc()?.entity(h).map(|e| e.kind.clone()) else { continue }; + if matches!(kind, EntityKind::Line(_)) { + move_end(s, h, q, ortho)?; + changed.insert(h); + } else if questions(&kind).is_some() { + if free.len() == 1 { + place(s, h, q)?; + changed.insert(h); + } else { + ignored += 1; + } + } + } + } + if let Some(z) = elevation { + let r = elevate(s, &free, z)?; + changed.extend(r.set); + (differing, detached) = (r.differing, r.detached); + } + Ok(json!({ "changed": changed.len(), "ignored": ignored, "differingZ": differing, "detachedHatches": detached, "locked": locked.len() })) +} + +impl Interactive for ChangeM { + fn name(&self) -> &'static str { + "CHANGE" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.select = begin_unlocked(s); + if self.select.done { + self.handles = self.select.picked.clone(); + self.stage = Stage::Point; + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + match &self.stage { + Stage::Select => self.select.prompt(), + Stage::Point => Prompt::new("Specify change point", Accept::POINT).kw(&["Properties"]), + Stage::Properties(m) => m.prompt(s), + Stage::Asking(asks) => asks.front().map_or_else(|| Prompt::new("", Accept::TEXT), |&(h, ask)| ask_prompt(s, h, ask)), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match &mut self.stage { + Stage::Select => { + return Ok(match feed_unlocked(&mut self.select, s, i)? { + SelOutcome::More => Step::Continue, + SelOutcome::Done(hs) => { + self.handles = hs; + self.stage = Stage::Point; + Step::Continue + } + SelOutcome::Empty => { + s.set_selection(Vec::new()); + Step::Done + } + }); + } + Stage::Properties(m) => return m.input(s, i), + Stage::Asking(asks) => { + let Some(&(h, ask)) = asks.front() else { return Ok(Step::Done) }; + if !answer(s, h, ask, i)? { + return Ok(Step::Continue); + } + asks.pop_front(); + return self.advance(s); + } + Stage::Point => {} + } + match i { + Input::Keyword(k) if k == "Properties" => { + self.stage = Stage::Properties(Box::new(ChpropM::change_properties(self.handles.clone()))); + Ok(Step::Continue) + } + Input::Point(p) => self.change_point(s, Some(p)), + Input::Enter => self.change_point(s, None), + _ => Err(EngineError::Other("Point or option keyword required.".into())), + } + } +} diff --git a/crates/engine/src/cmd/props/chprop.rs b/crates/engine/src/cmd/props/chprop.rs index 0949abee9..fac500ecd 100644 --- a/crates/engine/src/cmd/props/chprop.rs +++ b/crates/engine/src/cmd/props/chprop.rs @@ -28,6 +28,9 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; const PROPERTIES: [&str; 7] = ["Color", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]; +/// CHANGE's Properties: CHPROP's list with Elev. +const CHANGE_PROPERTIES: [&str; 8] = ["Color", "Elev", "LAyer", "LType", "ltScale", "LWeight", "Thickness", "TRansparency"]; + /// What CHPROP asks for. #[derive(Clone, Copy, Debug, PartialEq)] enum Ask { @@ -40,6 +43,7 @@ enum Ask { Lineweight, Thickness, Transparency, + Elevation, } pub(super) struct ChpropM { @@ -47,11 +51,35 @@ pub(super) struct ChpropM { handles: Vec, /// `None` while objects are selected. ask: Option, + /// CHANGE's Properties: Elev is on the list. + change: bool, + /// The elevation typed at Elev, set when the command ends. + elevation: Option, } impl ChpropM { pub(super) fn new() -> Self { - ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None } + ChpropM { select: SelectPhase::default(), handles: Vec::new(), ask: None, change: false, elevation: None } + } + + /// CHANGE's Properties option on `handles`. + pub(super) fn change_properties(handles: Vec) -> Self { + ChpropM { select: SelectPhase::default(), handles, ask: Some(Ask::Property), change: true, elevation: None } + } + + fn keywords(&self) -> &'static [&'static str] { + if self.change { &CHANGE_PROPERTIES } else { &PROPERTIES } + } + + /// The selection's common elevation, or "varies" (as a line between two heights does alone). + fn common_elevation(&self, s: &Session) -> String { + let Ok(d) = s.doc() else { return String::new() }; + let zs: Vec> = self.handles.iter().filter_map(|h| d.entity(*h)).filter_map(|e| super::change::elevation_of(&e.kind)).collect(); + match zs.first() { + None => format!("{:.4}", 0.0), + Some(Some(z)) if zs.iter().all(|o| *o == Some(*z)) => format!("{z:.4}"), + Some(_) => "varies".into(), + } } /// The selection's common value of a property, or "varies". @@ -151,6 +179,13 @@ impl ChpropM { return Ok(false); } } + Ask::Elevation => match number(word).filter(|v| v.is_finite()) { + Some(z) => self.elevation = Some(z), + None => { + s.echo("Requires a number."); + return Ok(false); + } + }, Ask::Property => {} } Ok(true) @@ -158,7 +193,7 @@ impl ChpropM { } /// The objects among `hs` on locked layers. -fn locked(s: &Session, hs: &[Handle]) -> HashSet { +pub(super) fn locked(s: &Session, hs: &[Handle]) -> HashSet { hs.iter().copied().filter(|h| is_locked(s, *h)).collect() } @@ -170,6 +205,38 @@ fn say_locked(s: &mut Session, n: usize) { } } +/// Start selecting objects, leaving those on locked layers out of the pickfirst selection (and +/// saying how many); `done` when some of it is left. +pub(super) fn begin_unlocked(s: &mut Session) -> SelectPhase { + let mut select = SelectPhase::begin(s); + if select.done { + let locked = locked(s, &select.picked); + if !locked.is_empty() { + select.picked.retain(|h| !locked.contains(h)); + say_locked(s, locked.len()); + s.set_selection(select.picked.clone()); + // Nothing left of the pickfirst selection: ask for objects instead. + select.done = !select.picked.is_empty(); + } + } + select +} + +/// Feed `i` to `select`, leaving picked objects on locked layers out and saying how many after +/// what was found. +pub(super) fn feed_unlocked(select: &mut SelectPhase, s: &mut Session, i: Input) -> Result { + let (i, n) = match i { + Input::Pick(hs) if !select.removing => { + let locked = locked(s, &hs); + (Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len()) + } + i => (i, 0), + }; + let outcome = select.feed(s, &i)?; + say_locked(s, n); + Ok(outcome) +} + /// CHPROP's JSON form: `properties.set` on the objects that are not on locked layers, which the /// command leaves out too; "locked" says how many were. pub(super) fn run_chprop(s: &mut Session, p: &Value) -> Result { @@ -242,17 +309,7 @@ impl Interactive for ChpropM { "CHPROP" } fn begin(&mut self, s: &mut Session) -> Result { - self.select = SelectPhase::begin(s); - if self.select.done { - let locked = locked(s, &self.select.picked); - if !locked.is_empty() { - self.select.picked.retain(|h| !locked.contains(h)); - say_locked(s, locked.len()); - s.set_selection(self.select.picked.clone()); - // Nothing left of the pickfirst selection: ask for objects instead. - self.select.done = !self.select.picked.is_empty(); - } - } + self.select = begin_unlocked(s); if self.select.done { self.handles = self.select.picked.clone(); self.ask = Some(Ask::Property); @@ -262,7 +319,7 @@ impl Interactive for ChpropM { fn prompt(&self, s: &Session) -> Prompt { let Some(ask) = self.ask else { return self.select.prompt() }; match ask { - Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(&PROPERTIES), + Ask::Property => Prompt::new("Enter property to change", Accept::TEXT).kw(self.keywords()), Ask::Color => { Prompt::new("New color", Accept::TEXT).kw(&["Truecolor"]).default(self.current(s, |e| e.common.color.name().to_ascii_uppercase())) } @@ -275,21 +332,13 @@ impl Interactive for ChpropM { Ask::Lineweight => Prompt::new("Enter new lineweight", Accept::TEXT).default(self.current(s, |e| lineweight_name(e.common.lineweight))), Ask::Thickness => Prompt::new("Specify new thickness", Accept::NUMBER).default(self.current(s, |e| format!("{:.4}", e.common.thickness))), Ask::Transparency => Prompt::new("Enter transparency value", KW).default(self.current(s, |e| e.common.transparency.name())), + Ask::Elevation => Prompt::new("Specify new elevation", Accept::NUMBER).default(self.common_elevation(s)), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { let Some(ask) = self.ask else { // Objects on locked layers are left out, and said after what was found. - let (i, n) = match i { - Input::Pick(hs) if !self.select.removing => { - let locked = locked(s, &hs); - (Input::Pick(hs.into_iter().filter(|h| !locked.contains(h)).collect()), locked.len()) - } - i => (i, 0), - }; - let outcome = self.select.feed(s, &i)?; - say_locked(s, n); - return Ok(match outcome { + return Ok(match feed_unlocked(&mut self.select, s, i)? { SelOutcome::More => Step::Continue, SelOutcome::Done(hs) => { self.handles = hs; @@ -304,6 +353,10 @@ impl Interactive for ChpropM { }; match (ask, i) { (Ask::Property, Input::Enter) => { + // CHANGE sets the elevation as it ends. + if let Some(z) = self.elevation { + super::change::elevate_and_say(s, &self.handles, z)?; + } s.set_selection(Vec::new()); return Ok(Step::Done); } @@ -315,12 +368,13 @@ impl Interactive for ChpropM { "ltScale" => Ask::LtScale, "LWeight" => Ask::Lineweight, "Thickness" => Ask::Thickness, + "Elev" => Ask::Elevation, _ => Ask::Transparency, }); } (Ask::Property, Input::Text(t)) => { let t = t.trim().trim_start_matches('_').to_ascii_lowercase(); - let n = PROPERTIES.iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count(); + let n = self.keywords().iter().filter(|k| k.to_ascii_lowercase().starts_with(&t)).count(); s.echo(if n > 1 { "Ambiguous response, please clarify..." } else { "Invalid option keyword." }); } (Ask::Color, Input::Keyword(k)) if k == "Truecolor" => self.ask = Some(Ask::TrueColor), diff --git a/crates/engine/tests/it/change_command.rs b/crates/engine/tests/it/change_command.rs new file mode 100644 index 000000000..f497112c5 --- /dev/null +++ b/crates/engine/tests/it/change_command.rs @@ -0,0 +1,321 @@ +//! CHANGE as AutoCAD 2024 runs it from AutoLISP's `command` (accoreconsole, every object read back +//! with `entget`). + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{EntityCodec, set_entity_codec}; +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::doc::color::Color; +use serde_json::json; + +/// The measured drawing, its objects named in LISP: lines `a` (0,0)–(50,0) and `b` (0,20)–(50,30) +/// on layer L1, circle `c` (centre (100,0), radius 10), text `t` "ABC" at (0,50) 2.5 high, circle +/// `e` ((200,0), 3), arc `r` ((150,0), 10, 0°–90°) and polyline `p` (0,100)–(50,100)–(50,120); then +/// `code`. +fn run(code: &str) -> Session { + // `entmake` and `entget` go through the DXF codec, as in the app. + set_entity_codec(EntityCodec { groups: cadcraft_io::entity_groups, entity: cadcraft_io::entity_from_groups }); + let mut s = Session::new(); + s.doc_mut().unwrap().ensure_layer("L1"); + for (v, made) in [ + ("a", "(0 . \"LINE\") (10 0.0 0.0 0.0) (11 50.0 0.0 0.0)"), + ("b", "(0 . \"LINE\") (8 . \"L1\") (10 0.0 20.0 0.0) (11 50.0 30.0 0.0)"), + ("c", "(0 . \"CIRCLE\") (10 100.0 0.0 0.0) (40 . 10.0)"), + ("t", "(0 . \"TEXT\") (10 0.0 50.0 0.0) (40 . 2.5) (1 . \"ABC\")"), + ("e", "(0 . \"CIRCLE\") (10 200.0 0.0 0.0) (40 . 3.0)"), + ("r", "(0 . \"ARC\") (10 150.0 0.0 0.0) (40 . 10.0) (50 . 0.0) (51 . 1.5707963267948966)"), + ("p", "(0 . \"LWPOLYLINE\") (90 . 3) (70 . 0) (10 0.0 100.0) (10 50.0 100.0) (10 50.0 120.0)"), + ] { + s.cmdline(&format!("(setq {v} (progn (entmake '({made})) (entlast)))")).unwrap(); + } + s.log.clear(); + s.cmdline(&format!("(progn {code})")).unwrap(); + s +} + +/// The `i`th object drawn. +fn kind(s: &Session, i: usize) -> EntityKind { + s.doc().unwrap().model.iter().nth(i).unwrap().kind.clone() +} + +fn line(s: &Session, i: usize) -> [[f64; 3]; 2] { + let EntityKind::Line(l) = kind(s, i) else { panic!("not a line") }; + [[l.a.x, l.a.y, l.a.z], [l.b.x, l.b.y, l.b.z]] +} + +fn radius(s: &Session, i: usize) -> f64 { + let EntityKind::Circle(c) = kind(s, i) else { panic!("not a circle") }; + c.radius +} + +fn text(s: &Session, i: usize) -> cadcraft_engine::doc::Text { + match kind(s, i) { + EntityKind::Text(t) => t, + EntityKind::AttDef(a) => a.text, + _ => panic!("not a text"), + } +} + +fn said(s: &Session, line: &str) -> usize { + s.log.iter().filter(|l| l.as_str() == line).count() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +#[test] +fn the_change_point_moves_the_nearer_end_of_each_line() { + let s = run("(command \"_.CHANGE\" a \"\" '(60.0 10.0))"); + assert_eq!(line(&s, 0), [[0.0, 0.0, 0.0], [60.0, 10.0, 0.0]]); + let s = run("(command \"_.CHANGE\" a \"\" '(-5.0 5.0))"); + assert_eq!(line(&s, 0), [[-5.0, 5.0, 0.0], [50.0, 0.0, 0.0]]); + let s = run("(command \"_.CHANGE\" a b \"\" '(60.0 10.0))"); + assert_eq!((line(&s, 0)[1], line(&s, 1)), ([60.0, 10.0, 0.0], [[0.0, 20.0, 0.0], [60.0, 10.0, 0.0]])); + // Under ortho the moved end stays level or plumb with the other one. + let s = run("(setvar \"ORTHOMODE\" 1) (command \"_.CHANGE\" a b \"\" '(60.0 10.0))"); + assert_eq!((line(&s, 0)[1], line(&s, 1)[1]), ([60.0, 0.0, 0.0], [60.0, 20.0, 0.0])); + // Arcs and polylines are left alone: with a line the point still moves it, without one there + // is nothing to change. Enter leaves lines alone too. + let s = run("(command \"_.CHANGE\" a r p \"\" '(60.0 10.0))"); + assert_eq!(line(&s, 0)[1], [60.0, 10.0, 0.0]); + assert_eq!(said(&s, "No changeable object selected"), 0, "{:?}", s.log); + for code in ["(command \"_.CHANGE\" r \"\" '(160.0 10.0))", "(command \"_.CHANGE\" p \"\" '(60.0 130.0))", "(command \"_.CHANGE\" a \"\" \"\")"] { + let s = run(code); + assert_eq!(said(&s, "No changeable object selected"), 1, "{code}: {:?}", s.log); + assert!(s.running.is_none(), "{code}"); + assert_eq!(line(&s, 0)[1], [50.0, 0.0, 0.0], "{code}"); + } +} + +#[test] +fn a_circle_passes_through_the_point_or_takes_a_radius() { + let s = run("(command \"_.CHANGE\" c \"\" '(115.0 0.0))"); + assert!(near(radius(&s, 2), 15.0)); + let s = run("(command \"_.CHANGE\" c \"\" \"\" 20.0)"); + assert!(near(radius(&s, 2), 20.0)); + // With other objects the point is ignored, said as each circle comes up. + let s = run("(command \"_.CHANGE\" c e \"\" '(110.0 10.0) 8.0)"); + assert_eq!((radius(&s, 2), radius(&s, 4)), (8.0, 3.0)); + assert_eq!(said(&s, "Change point ignored"), 2, "{:?}", s.log); + assert!(s.prompt_text().contains("Specify new circle radius "), "{}", s.prompt_text()); + let s = run("(command \"_.CHANGE\" a c \"\" '(60.0 10.0) 12.0)"); + assert_eq!((line(&s, 0)[1], radius(&s, 2)), ([60.0, 10.0, 0.0], 12.0)); + assert_eq!(said(&s, "Change point ignored"), 1, "{:?}", s.log); +} + +#[test] +fn a_text_is_placed_by_the_point_then_asked_the_rest() { + let s = run("(command \"_.CHANGE\" t \"\" '(10.0 60.0) \"\" 4.0 \"\" \"\")"); + let t = text(&s, 3); + assert_eq!((t.insert.x, t.insert.y, t.height, t.value.as_str()), (10.0, 60.0, 4.0, "ABC")); + // Enter asks for the insertion point first. + let s = run("(command \"_.CHANGE\" t \"\" \"\" \"\" \"\" 5.0 30 \"XYZ\")"); + let t = text(&s, 3); + assert_eq!((t.insert.x, t.insert.y, t.height, t.value.as_str()), (0.0, 50.0, 5.0, "XYZ")); + assert!(near(t.rotation.to_degrees(), 30.0)); + // A style that does not exist is refused, and asked for again. + let s = run("(command \"_.CHANGE\" t \"\" \"\" \"\" \"5\")"); + assert_eq!(said(&s, "Style \"5\" not defined - use STYLE command."), 1, "{:?}", s.log); + assert!(s.prompt_text().contains("Enter new text style "), "{}", s.prompt_text()); + // Justified text is placed by its alignment point, with the point or after Enter. + for (justify, code) in [("(72 . 1) (73 . 2)", "'(10.0 60.0) \"\" \"\" \"\" \"\""), ("(72 . 2)", "\"\" '(10.0 60.0) \"\" \"\" \"\" \"\"")] { + let s = run(&format!( + "(entmake '((0 . \"TEXT\") (10 0.0 50.0 0.0) (40 . 2.5) (1 . \"ABC\") {justify} (11 0.0 50.0 0.0))) (command \"_.CHANGE\" (entlast) \"\" {code})" + )); + let at = text(&s, 7).align_pt.unwrap(); + assert_eq!((at.x, at.y), (10.0, 60.0), "{justify}"); + } +} + +#[test] +fn a_style_of_fixed_height_is_not_asked_for_a_height() { + let mut s = run(""); + s.execute("style", &json!({ "name": "FIXH", "height": 5.0 })).unwrap(); + s.cmdline("(entmake '((0 . \"TEXT\") (10 0.0 80.0 0.0) (40 . 5.0) (1 . \"FIX\") (7 . \"FIXH\")))").unwrap(); + s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" 30 \"Q\")").unwrap(); + let t = text(&s, 7); + assert_eq!((t.height, t.value.as_str()), (5.0, "Q")); + assert!(near(t.rotation.to_degrees(), 30.0)); + assert!(s.running.is_none()); +} + +#[test] +fn objects_are_asked_about_in_the_order_selected() { + let s = run( + "(setq d (progn (entmake '((0 . \"TEXT\") (10 0.0 70.0 0.0) (40 . 2.5) (1 . \"DEF\"))) (entlast))) (command \"_.CHANGE\" t d \"\" \"\" \"\" \"\" \"\" \"\" \"X1\" \"\" \"\" \"\" \"\" \"X2\")", + ); + assert_eq!((text(&s, 3).value.as_str(), text(&s, 7).value.as_str()), ("X1", "X2")); + let s = run("(command \"_.CHANGE\" t c \"\" \"\" \"\" \"\" \"\" \"\" \"TT\" 7.0)"); + assert_eq!((text(&s, 3).value.as_str(), radius(&s, 2)), ("TT", 7.0)); + let s = run("(command \"_.CHANGE\" c t \"\" \"\" 7.0 \"\" \"\" \"\" \"\" \"TT\")"); + assert_eq!((text(&s, 3).value.as_str(), radius(&s, 2)), ("TT", 7.0)); +} + +/// Block K: a circle and attribute TAG1 (default V) 5 above its base point, inserted at (200,0) +/// with the default value. +const BLOCK: &str = "(entmake '((0 . \"BLOCK\") (2 . \"K\") (70 . 2) (10 0.0 0.0 0.0))) (entmake '((0 . \"CIRCLE\") (10 0.0 0.0 0.0) (40 . 3.0))) (entmake '((0 . \"ATTDEF\") (10 0.0 5.0 0.0) (40 . 2.5) (1 . \"V\") (3 . \"P\") (2 . \"TAG1\") (70 . 0))) (entmake '((0 . \"ENDBLK\"))) (command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"\")"; + +#[test] +fn a_block_reference_is_placed_and_turned_with_its_attributes() { + let s = run(&format!("{BLOCK} (command \"_.CHANGE\" (entlast) \"\" '(250.0 50.0) 90)")); + let EntityKind::Insert(i) = kind(&s, 7) else { panic!("not a block reference") }; + assert_eq!((i.insert.x, i.insert.y), (250.0, 50.0)); + assert!(near(i.rotation.to_degrees(), 90.0)); + let a = &i.attribs.first().expect("the attribute").text; + assert!(near(a.insert.x, 245.0) && near(a.insert.y, 50.0), "{:?}", a.insert); + assert!(near(a.rotation.to_degrees(), 90.0)); + let s = run(&format!("{BLOCK} (command \"_.CHANGE\" (entlast) \"\" \"\" '(220.0 30.0) 90)")); + let EntityKind::Insert(i) = kind(&s, 7) else { panic!("not a block reference") }; + assert_eq!((i.insert.x, i.insert.y), (220.0, 30.0)); + assert!(near(i.rotation.to_degrees(), 90.0)); +} + +#[test] +fn an_attribute_definition_is_asked_its_tag_prompt_and_default() { + let s = run( + "(entmake '((0 . \"ATTDEF\") (10 300.0 0.0 0.0) (40 . 2.5) (1 . \"V\") (3 . \"P\") (2 . \"TAG1\") (70 . 0))) (command \"_.CHANGE\" (entlast) \"\" '(310.0 10.0) \"\" \"\" \"\" \"T2\" \"P2\" \"V2\")", + ); + let EntityKind::AttDef(a) = kind(&s, 7) else { panic!("not an attribute definition") }; + assert_eq!((a.text.insert.x, a.text.insert.y), (310.0, 10.0)); + assert_eq!((a.tag.as_str(), a.prompt.as_str(), a.text.value.as_str()), ("T2", "P2", "V2")); +} + +#[test] +fn objects_on_locked_layers_are_left_out() { + let s = run("(command \"_.-LAYER\" \"_LO\" \"L1\" \"\") (command \"_.CHANGE\" a b \"\" '(60.0 10.0))"); + assert_eq!((line(&s, 0)[1], line(&s, 1)[1]), ([60.0, 10.0, 0.0], [50.0, 30.0, 0.0])); + assert_eq!(said(&s, "1 was on a locked layer."), 1, "{:?}", s.log); +} + +#[test] +fn properties_add_elev_which_is_set_as_the_command_ends() { + let s = run("(command \"_.CHANGE\" a \"\" \"_P\" \"_C\" 2 \"_E\" 10.0 \"\")"); + let d = s.doc().unwrap(); + assert_eq!(d.model.iter().next().unwrap().common.color, Color::Index(2)); + assert_eq!(line(&s, 0), [[0.0, 0.0, 10.0], [50.0, 0.0, 10.0]]); + let s = run("(command \"_.CHANGE\" a c \"\" \"_P\" \"_LA\" \"L1\" \"_T\" 3.0 \"\")"); + for e in s.doc().unwrap().model.iter().take(3).step_by(2) { + assert_eq!((e.common.layer.as_str(), e.common.thickness), ("L1", 3.0)); + } + let s = run("(command \"_.CHANGE\" c t r p \"\" \"_P\" \"_E\" 5.0 \"\")"); + let EntityKind::Circle(c) = kind(&s, 2) else { panic!() }; + let EntityKind::Arc(r) = kind(&s, 5) else { panic!() }; + let EntityKind::LwPolyline(p) = kind(&s, 6) else { panic!() }; + assert_eq!((c.center.z, text(&s, 3).insert.z, r.center.z, p.elevation), (5.0, 5.0, 5.0, 5.0)); + // A line between two heights keeps them, said as the command ends; the elevation asked shows + // it varies. + let mut s = run( + "(setq l (progn (entmake '((0 . \"LINE\") (10 0.0 200.0 0.0) (11 50.0 200.0 10.0))) (entlast))) (command \"_.CHANGE\" l \"\" \"_P\" \"_E\")", + ); + assert!(s.prompt_text().contains("Specify new elevation "), "{}", s.prompt_text()); + s.cmdline("(command 5.0 \"\")").unwrap(); + assert_eq!(line(&s, 7), [[0.0, 200.0, 0.0], [50.0, 200.0, 10.0]]); + assert_eq!(said(&s, "Cannot change elevation of objects with differing Z coordinates."), 1, "{:?}", s.log); +} + +#[test] +fn change_by_json() { + let mut s = run(""); + let h: Vec = s.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect(); + let r = s.execute("change", &json!({ "handles": [h[0], h[2]], "point": [60, 10] })).unwrap(); + assert_eq!((r["changed"].as_u64(), r["ignored"].as_u64()), (Some(1), Some(1)), "{r}"); + assert_eq!((line(&s, 0)[1], radius(&s, 2)), ([60.0, 10.0, 0.0], 10.0)); + s.execute("change", &json!({ "handles": [h[2]], "point": [115, 0] })).unwrap(); + assert!(near(radius(&s, 2), 15.0)); + s.execute("layer.set", &json!({ "name": "L1", "locked": true })).unwrap(); + let r = s.execute("change", &json!({ "handles": [h[0], h[1]], "elevation": 4.0 })).unwrap(); + assert_eq!((r["changed"].as_u64(), r["locked"].as_u64()), (Some(1), Some(1)), "{r}"); + assert_eq!((line(&s, 0)[0][2], line(&s, 1)[0][2]), (4.0, 0.0)); + // Neither a point nor an elevation, or one that can't be read, is refused. + for bad in [json!({ "handles": [h[0]] }), json!({ "handles": [h[0]], "point": "nowhere" }), json!({ "handles": [h[0]], "elevation": "high" })] { + assert!(s.execute("change", &bad).is_err(), "{bad}"); + } + assert_eq!(line(&s, 0), [[0.0, 0.0, 4.0], [60.0, 10.0, 4.0]]); +} + +/// Elev on the other kinds AutoCAD 2024 sets it for, each alone: the z of a point, a block +/// reference with its attributes, a multiline text, an ellipse, a solid and a flat 3D polyline; +/// a 3D polyline between heights keeps them, and a hatch loses its associativity. +#[test] +fn elev_sets_the_z_of_every_kind_with_one() { + let elev = |made: &str| { + let mut s = run(made); + s.log.clear(); + s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"_P\" \"_E\" 4.0 \"\")").unwrap(); + let k = s.doc().unwrap().model.iter().last().unwrap().kind.clone(); + (k, s) + }; + let (k, _) = elev("(entmake '((0 . \"POINT\") (10 5.0 5.0 0.0)))"); + assert!(matches!(k, EntityKind::Point(p) if p.p.z == 4.0)); + let (k, _) = elev("(entmake '((0 . \"MTEXT\") (10 0.0 150.0 0.0) (40 . 2.5) (1 . \"AB\")))"); + assert!(matches!(k, EntityKind::MText(t) if t.insert.z == 4.0)); + let (k, _) = elev("(entmake '((0 . \"ELLIPSE\") (10 50.0 150.0 0.0) (11 20.0 0.0 0.0) (40 . 0.5) (41 . 0.0) (42 . 6.283185307179586)))"); + assert!(matches!(k, EntityKind::Ellipse(e) if e.center.z == 4.0)); + let (k, _) = elev("(entmake '((0 . \"SOLID\") (10 0.0 170.0 0.0) (11 10.0 170.0 0.0) (12 0.0 180.0 0.0) (13 10.0 180.0 0.0)))"); + assert!(matches!(k, EntityKind::Solid(so) if so.corners.iter().all(|c| c.z == 4.0))); + // 3D polylines (made with their z, which a point given to a command doesn't carry). + let poly3d = |pts: serde_json::Value| { + let mut s = run(""); + s.execute("3dpoly", &json!({ "points": pts })).unwrap(); + s.log.clear(); + s.cmdline("(command \"_.CHANGE\" (entlast) \"\" \"_P\" \"_E\" 4.0 \"\")").unwrap(); + let EntityKind::Polyline3d(p) = s.doc().unwrap().model.iter().last().unwrap().kind.clone() else { panic!("not a 3D polyline") }; + (p.points.iter().map(|q| q.z).collect::>(), s) + }; + let (zs, _) = poly3d(json!([[0, 220, 2], [10, 220, 2], [20, 230, 2]])); + assert_eq!(zs, [4.0, 4.0, 4.0]); + let (zs, s) = poly3d(json!([[0, 200, 0], [10, 200, 5], [20, 210, 0]])); + assert_eq!(zs, [0.0, 5.0, 0.0]); + assert_eq!(said(&s, "Cannot change elevation of objects with differing Z coordinates."), 1, "{:?}", s.log); + let (k, _) = elev(&format!("{BLOCK} (command \"_.-INSERT\" \"K\" '(200.0 0.0) \"\" \"\" 0 \"\")")); + let EntityKind::Insert(i) = k else { panic!("not a block reference") }; + assert_eq!((i.insert.z, i.attribs[0].text.insert.z), (4.0, 4.0)); + // A hatch: its elevation, and no longer associative. + let mut s = run("(entmake '((0 . \"LWPOLYLINE\") (90 . 4) (70 . 1) (10 100.0 150.0) (10 120.0 150.0) (10 120.0 170.0) (10 100.0 170.0)))"); + let poly = s.doc().unwrap().model.iter().last().unwrap().handle.hex(); + s.execute("hatch", &json!({ "handles": [poly], "associative": true })).unwrap(); + // The hatch is drawn behind its boundary, so it isn't the last object in the list. + let hatch = |s: &Session| { + let d = s.doc().unwrap(); + d.model.iter().find(|e| matches!(e.kind, EntityKind::Hatch(_))).map(|e| (e.handle.hex(), e.kind.clone())).expect("a hatch") + }; + let (hex, k) = hatch(&s); + assert!(matches!(&k, EntityKind::Hatch(h) if h.associative)); + s.log.clear(); + s.cmdline(&format!("(command \"_.CHANGE\" (handent \"{hex}\") \"\" \"_P\" \"_E\" 4.0 \"\")")).unwrap(); + let (_, EntityKind::Hatch(h)) = hatch(&s) else { panic!("not a hatch") }; + assert_eq!((h.elevation, h.associative), (4.0, false)); + assert_eq!(said(&s, "Hatch boundary associativity removed."), 1, "{:?}", s.log); +} + +#[test] +fn the_rotation_asked_shows_as_the_drawing_measures_angles() { + // ANGBASE 90°: a text at 0° shows as 270.00. + let s = run("(setvar \"ANGBASE\" (/ pi 2)) (command \"_.CHANGE\" t \"\" \"\" \"\" \"\" \"\")"); + assert!(s.prompt_text().contains("Specify new rotation angle <270.00>"), "{}", s.prompt_text()); +} + +#[test] +fn aligned_and_fit_texts_are_set_by_their_two_points() { + // Aligned: neither height nor rotation is asked; Fit: no rotation. + let s = run( + "(entmake '((0 . \"TEXT\") (10 0.0 300.0 0.0) (11 40.0 300.0 0.0) (40 . 2.5) (1 . \"AL\") (72 . 3))) (command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" \"NEW\")", + ); + let t = text(&s, 7); + assert_eq!((t.height, t.rotation, t.value.as_str()), (2.5, 0.0, "NEW")); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let s = run( + "(entmake '((0 . \"TEXT\") (10 0.0 320.0 0.0) (11 40.0 320.0 0.0) (40 . 2.5) (1 . \"FI\") (72 . 5))) (command \"_.CHANGE\" (entlast) \"\" \"\" \"\" \"\" 5.0 \"FN\")", + ); + let t = text(&s, 7); + assert_eq!((t.height, t.rotation, t.value.as_str()), (5.0, 0.0, "FN")); + assert!(s.running.is_none(), "{}", s.prompt_text()); +} + +#[test] +fn the_json_point_follows_ortho() { + let mut s = run("(setvar \"ORTHOMODE\" 1)"); + let a = s.doc().unwrap().model.iter().next().unwrap().handle.hex(); + s.execute("change", &json!({ "handles": [a], "point": [60, 10] })).unwrap(); + assert_eq!(line(&s, 0)[1], [60.0, 0.0, 0.0]); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 2e60c9df1..9b0907e24 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -19,6 +19,7 @@ mod break_closed_polyline; mod break_join_locked_layers; mod break_keeps_z; mod burst; +mod change_command; mod chprop_command; mod compare_export; mod constraint_geometry;