mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-01 06:46:50 -06:00
CHANGE: change objects by a change point or their properties, as AutoCAD does (#2534)
* Add CHANGE, changing objects by a change point or their properties as AutoCAD does A change point moves each line's nearer end to it (level or plumb under ORTHOMODE), passes a circle through it, and places a text, attribute definition or block reference by it when that is the one object selected; otherwise "Change point ignored" and the object's questions follow, in selection order. Properties is CHPROP's loop with Elev, applied as the command ends. Objects on locked layers are left out, through selection helpers now shared with CHPROP. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CHANGE: refuse a JSON call without a readable point or elevation A `point` or `elevation` that can't be read is an error, and so is a call with neither, instead of changing nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * CHANGE: Elev on every kind AutoCAD sets it for; Aligned and Fit text; rotation default Measured in AutoCAD 2024: Elev also sets the z of ellipses, multiline texts, solids and flat 3D polylines (a 3D polyline between heights keeps them), and of hatches, which stop being associative ("Hatch boundary associativity removed."). Aligned text isn't asked its height or rotation and Fit text its rotation, their two points setting them. The rotation default shows as the drawing measures angles (ANGBASE 90 shows a text at 0 as 270.00). Shared z is compared to rounding, and the radius and height prompts say what they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ac886483d1
commit
87f8ce22f6
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
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"])
|
||||
|
||||
@@ -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 <no change>";
|
||||
//! - a text is placed by the point (the alignment point of justified text), or asks "Specify new
|
||||
//! text insertion point <no change>"; 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 <no change>"; 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<ChpropM>),
|
||||
/// The questions left, in order, each about one object.
|
||||
Asking(VecDeque<(Handle, Ask)>),
|
||||
}
|
||||
|
||||
pub(super) struct ChangeM {
|
||||
select: SelectPhase,
|
||||
handles: Vec<Handle>,
|
||||
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<Vec2>) -> Result<Step> {
|
||||
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<Step> {
|
||||
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<Vec2> {
|
||||
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<bool> {
|
||||
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<Item = f64>) -> Option<f64> {
|
||||
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<Option<f64>> {
|
||||
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<Handle>,
|
||||
/// 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<Elevated> {
|
||||
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<Value> {
|
||||
// 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<Handle> = 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<Step> {
|
||||
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<Step> {
|
||||
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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Handle>,
|
||||
/// `None` while objects are selected.
|
||||
ask: Option<Ask>,
|
||||
/// CHANGE's Properties: Elev is on the list.
|
||||
change: bool,
|
||||
/// The elevation typed at Elev, set when the command ends.
|
||||
elevation: Option<f64>,
|
||||
}
|
||||
|
||||
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<Handle>) -> 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<Option<f64>> = 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<Handle> {
|
||||
pub(super) fn locked(s: &Session, hs: &[Handle]) -> HashSet<Handle> {
|
||||
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<SelOutcome> {
|
||||
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<Value> {
|
||||
@@ -242,17 +309,7 @@ impl Interactive for ChpropM {
|
||||
"CHPROP"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
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<Step> {
|
||||
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),
|
||||
|
||||
@@ -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 <no change>"), "{}", 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 <Standard>"), "{}", 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 <varies>"), "{}", 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<String> = 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::<Vec<f64>>(), 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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user