From 81e93f2caf0bb7fbd7ae3f00c58e01492213d01b Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Sat, 31 Oct 2026 21:32:49 +0700 Subject: [PATCH] Scale heights with SCALE and keep them with OFFSET (#2558) AutoCAD 2024's SCALE is a uniform 3D scale about the base point: every Z and elevation scales about the base point's Z (the current ELEVATION for a point given without one) and every thickness by the factor. cadcraft scaled X and Y only. The JSON form also takes a Z with the base point. OFFSET's copy now keeps the source's height: a line's end Zs, a circle's or arc's centre Z, a polyline's elevation. A circle's or arc's copy has no thickness and a polyline's keeps it, as AutoCAD makes them. Grip scaling, ALIGN's scale option and MOCORO stay 2D: their base point is often a grip or snapped point whose Z cadcraft doesn't keep. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/entity.rs | 85 +++++++++ crates/engine/src/cmd/modify.rs | 2 +- crates/engine/src/cmd/modify/offset.rs | 27 ++- crates/engine/src/cmd/modify/select_then.rs | 6 +- crates/engine/src/cmd/modify/transform.rs | 36 +++- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/it/scale_offset_z.rs | 184 ++++++++++++++++++++ 7 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 crates/engine/tests/it/scale_offset_z.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index d13b82722..b19d10961 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -465,6 +465,91 @@ impl EntityKind { } } + /// Scale every height of the object by `f` about the height `base_z`: the Z of its points, + /// its elevation. With [`EntityKind::transform`] scaling about the base point's XY this is a + /// uniform 3D scale about the base point, as AutoCAD 2024's SCALE makes it. Sizes `transform` + /// already scales (text and helix heights, an insert's Z scale) are left alone; splines, + /// regions and wipeouts lie in the XY plane. + pub fn scale_heights(&mut self, base_z: f64, f: f64) { + let scale = |h: &mut f64| *h = base_z + (*h - base_z) * f; + let z = |p: &mut Vec3| scale(&mut p.z); + let text_z = |t: &mut Text| { + z(&mut t.insert); + if let Some(a) = &mut t.align_pt { + z(a); + } + }; + let attribs_z = |attribs: &mut Vec| { + for a in attribs { + text_z(&mut a.text); + if let Some(ml) = &mut a.multiline { + z(&mut ml.mtext.insert); + } + } + }; + match self { + EntityKind::Line(l) => { + z(&mut l.a); + z(&mut l.b); + } + EntityKind::Point(p) => z(&mut p.p), + EntityKind::Circle(c) => z(&mut c.center), + EntityKind::Arc(a) => z(&mut a.center), + EntityKind::Ellipse(e) => z(&mut e.center), + EntityKind::LwPolyline(p) => scale(&mut p.elevation), + EntityKind::Polyline3d(p) => p.points.iter_mut().for_each(z), + EntityKind::Helix(h) => z(&mut h.center), + EntityKind::Ray(r) | EntityKind::XLine(r) => z(&mut r.base), + EntityKind::Text(t) => text_z(t), + EntityKind::AttDef(a) => { + text_z(&mut a.text); + if let Some(ml) = &mut a.multiline { + z(&mut ml.mtext.insert); + } + } + EntityKind::MText(t) => z(&mut t.insert), + EntityKind::Insert(i) => { + z(&mut i.insert); + attribs_z(&mut i.attribs); + } + EntityKind::Dimension(d) => { + for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] { + z(p); + } + if let Some(j) = &mut d.marks.jog_line { + z(j); + } + if let Some(j) = &mut d.marks.jogged { + z(&mut j.center); + z(&mut j.jog); + } + d.marks.breaks.iter_mut().for_each(|b| z(&mut b.at)); + d.block = None; + } + EntityKind::MLine(ml) => ml.vertices.iter_mut().for_each(|v| z(&mut v.p)), + EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z), + EntityKind::MLeader(ml) => { + ml.leaders.iter_mut().flatten().for_each(z); + z(&mut ml.landing); + if let Some(t) = &mut ml.text { + z(&mut t.insert); + } + if let Some(b) = &mut ml.block { + z(&mut b.insert); + attribs_z(&mut b.attribs); + } + } + EntityKind::Hatch(h) => scale(&mut h.elevation), + EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z), + EntityKind::Face3d(fc) => fc.corners.iter_mut().for_each(z), + EntityKind::Viewport(v) => z(&mut v.center), + EntityKind::Image(i) => z(&mut i.insert), + EntityKind::Table(t) => z(&mut t.insert), + EntityKind::Tolerance(t) => z(&mut t.insert), + EntityKind::Spline(_) | EntityKind::Region(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {} + } + } + /// Grip points (editable defining points), in display order. pub fn grips(&self) -> Vec { // A broken table has a grip for each part. diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index bbcd25267..22126f900 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { CommandSpec::new("scale", "Scale", run_scale) .menu(&["Modify", "Scale"]) .alias(&["sc"]) - .params("{handles?, base: [x,y], factor, copy?: bool}") + .params("{handles?, base: [x,y] or [x,y,z] (default Z: ELEVATION), factor, copy?: bool}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Scale)))), CommandSpec::new("mirror", "Mirror", run_mirror) .menu(&["Modify", "Mirror"]) diff --git a/crates/engine/src/cmd/modify/offset.rs b/crates/engine/src/cmd/modify/offset.rs index 17cd15b09..74dc0fb79 100644 --- a/crates/engine/src/cmd/modify/offset.rs +++ b/crates/engine/src/cmd/modify/offset.rs @@ -212,12 +212,35 @@ pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result { Ok(json!({ "handle": nh.hex() })) } +/// The copy at the source's height, as AutoCAD 2024's OFFSET makes it: a line's end Zs, an arc's +/// or circle's centre Z, a polyline's elevation (an ellipse's offset, a polyline, at its centre Z). +fn keep_z(src: &EntityKind, k: &mut EntityKind) { + match (src, k) { + (EntityKind::Line(s), EntityKind::Line(l)) => { + l.a.z = s.a.z; + l.b.z = s.b.z; + } + (EntityKind::Circle(s), EntityKind::Circle(c)) => c.center.z = s.center.z, + (EntityKind::Arc(s), EntityKind::Arc(a)) => a.center.z = s.center.z, + (EntityKind::LwPolyline(s), EntityKind::LwPolyline(p)) => p.elevation = s.elevation, + (EntityKind::Ellipse(s), EntityKind::LwPolyline(p)) => p.elevation = s.center.z, + (EntityKind::Ray(s) | EntityKind::XLine(s), EntityKind::Ray(r) | EntityKind::XLine(r)) => r.base.z = s.base.z, + _ => {} + } +} + /// Add the offset copy `k` of `src` (on the current layer instead of the source's when -/// `current_layer`), then erase the source when `erase` and its layer is not locked. -fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result { +/// `current_layer`), then erase the source when `erase` and its layer is not locked. The copy is +/// at the source's height ([`keep_z`]); a circle's or arc's copy has no thickness, a polyline's +/// keeps it, as AutoCAD 2024 makes them. +fn add_offset(s: &mut Session, src: &Entity, mut k: EntityKind, current_layer: bool, erase: bool) -> Result { + keep_z(&src.kind, &mut k); let space = s.space(); let d = s.doc_mut()?; let mut common = src.common.clone(); + if matches!(k, EntityKind::Circle(_) | EntityKind::Arc(_)) { + common.thickness = 0.0; + } if current_layer { common.layer = d.header.str("CLAYER", "0"); } diff --git a/crates/engine/src/cmd/modify/select_then.rs b/crates/engine/src/cmd/modify/select_then.rs index f9a970ebd..009a95281 100644 --- a/crates/engine/src/cmd/modify/select_then.rs +++ b/crates/engine/src/cmd/modify/select_then.rs @@ -1,6 +1,6 @@ use super::break_join::join; use super::explode::explode; -use super::transform::{erase, transform_entities}; +use super::transform::{at_elevation, erase, scale_entities, transform_entities}; use super::{ Accept, EngineError, EntityKind, Handle, Input, Interactive, Mat3, Prompt, Result, SelOutcome, SelectPhase, Session, Step, Vec2, line, number, }; @@ -242,7 +242,7 @@ impl SelectThen { let f = require_length(Some(f))?; // The next SCALE offers this factor. s.last_used.scale_factor = f; - transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?; + scale_entities(s, &self.objs, at_elevation(s, base), f, self.copy_mode)?; s.set_selection(Vec::new()); Ok(Step::Done) } @@ -439,7 +439,7 @@ impl Interactive for SelectThen { }; if f > 1e-12 && f.is_finite() { s.last_used.scale_factor = f; - transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?; + scale_entities(s, &self.objs, at_elevation(s, self.pts[0]), f, self.copy_mode)?; } s.set_selection(Vec::new()); Ok(Step::Done) diff --git a/crates/engine/src/cmd/modify/transform.rs b/crates/engine/src/cmd/modify/transform.rs index 18ff4866c..9adc54ec1 100644 --- a/crates/engine/src/cmd/modify/transform.rs +++ b/crates/engine/src/cmd/modify/transform.rs @@ -1,8 +1,33 @@ +use cadcraft_geom::Vec3; + use super::{Entity, Handle, Mat3, Result, Session, Value, Vec2, bad, bool_or, f64_req, json, point_param, point_req, targets}; // ---------------- core operations ---------------- pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result> { + edit_entities(s, hs, copy, |e| e.kind.transform(m)) +} + +/// Scale `hs` by `f` about `base` in 3D, as AutoCAD 2024's SCALE does: about the base point's XY, +/// and every height of the objects about its Z, their thickness too. +pub(crate) fn scale_entities(s: &mut Session, hs: &[Handle], base: Vec3, f: f64, copy: bool) -> Result> { + let m = Mat3::scale_about(base.xy(), f); + edit_entities(s, hs, copy, |e| { + e.kind.transform(&m); + e.kind.scale_heights(base.z, f); + e.common.thickness *= f; + }) +} + +/// `p` at the current elevation (ELEVATION), the Z AutoCAD gives a point entered without one. +pub(crate) fn at_elevation(s: &Session, p: Vec2) -> Vec3 { + let z = s.doc().map(|d| d.header.f64("ELEVATION", 0.0)).ok().filter(|z| z.is_finite()); + Vec3::new(p.x, p.y, z.unwrap_or(0.0)) +} + +/// Apply `edit` to each of `hs` still in the drawing, or to a copy of each when `copy`; an object +/// on a locked layer is left alone unless it is copied. The handles edited or made. +fn edit_entities(s: &mut Session, hs: &[Handle], copy: bool, edit: impl Fn(&mut Entity)) -> Result> { let d = s.doc_mut()?; let mut out = Vec::new(); for h in hs { @@ -11,7 +36,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue }; let nh = d.new_handle(); let mut ne = Entity { handle: nh, ..e }; - ne.kind.transform(m); + edit(&mut ne); if let Some(st) = d.space_mut(&sp) { st.push(ne); out.push(nh); @@ -21,7 +46,7 @@ pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: if locked { continue; } - d.modify_entity(*h, |e| e.kind.transform(m))?; + d.modify_entity(*h, &edit)?; out.push(*h); } } @@ -92,7 +117,12 @@ pub(super) fn run_scale(s: &mut Session, p: &Value) -> Result { if f <= 0.0 { return Err(bad("scale", "factor must be positive")); } - let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?; + // A Z given with the base point, as AutoCAD takes one; without one, the current elevation. + let base = match p.get("base").and_then(|b| b.get(2)) { + Some(z) => z.as_f64().map(|z| Vec3::new(base.x, base.y, z)).ok_or_else(|| bad("scale", "`base` Z must be a number"))?, + None => at_elevation(s, base), + }; + let r = scale_entities(s, &hs, base, f, bool_or(p, "copy", false))?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index ee06666c5..137e423a7 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -113,6 +113,7 @@ mod refedit; mod region; mod rename_cmdline; mod rotate_reference; +mod scale_offset_z; mod select_keywords; mod select_similar; mod select_underscore_keywords; diff --git a/crates/engine/tests/it/scale_offset_z.rs b/crates/engine/tests/it/scale_offset_z.rs new file mode 100644 index 000000000..a32cea081 --- /dev/null +++ b/crates/engine/tests/it/scale_offset_z.rs @@ -0,0 +1,184 @@ +//! SCALE scales heights too and OFFSET keeps them, as AutoCAD 2024 does (measured with +//! accoreconsole, running these scripts on these drawings): SCALE scales every Z and elevation +//! about the base point's Z (the current elevation for a point given without one) and every +//! thickness by the factor; OFFSET's copy is at the source's height, and a circle's or arc's copy +//! has no thickness while a polyline's keeps it. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{Arc, Circle, Common, EntityKind, Handle, Line, LwPolyline, Point}; +use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3}; +use serde_json::json; + +fn add(s: &mut Session, kind: EntityKind, thickness: f64) -> Handle { + let space = s.space(); + s.doc_mut().unwrap().add(&space, Common { thickness, ..Common::default() }, kind).unwrap() +} + +fn lisp(s: &mut Session, expr: &str) { + s.cmdline(expr).unwrap(); + assert!(s.running.is_none(), "{expr}: {}", s.prompt_text()); +} + +fn get(s: &Session, h: Handle) -> (EntityKind, f64) { + let e = s.doc().unwrap().entity(h).unwrap().clone(); + (e.kind.clone(), e.common.thickness) +} + +fn last(s: &Session) -> (EntityKind, f64) { + let e = s.doc().unwrap().model.iter().last().unwrap().clone(); + (e.kind.clone(), e.common.thickness) +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * (1.0 + b.abs()) +} + +fn near3(a: Vec3, b: Vec3) -> bool { + close(a.x, b.x) && close(a.y, b.y) && close(a.z, b.z) +} + +fn scale(s: &mut Session, h: Handle, base: &str) { + lisp(s, &format!("(command \"_.SCALE\" (handent \"{}\") \"\" '({base}) 1.7)", h.hex())); +} + +#[test] +fn scale_scales_a_lines_z() { + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0); + scale(&mut s, h, "2.0 3.0"); + let EntityKind::Line(l) = get(&s, h).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(-1.4, -2.1, 11.9)) && near3(l.b, Vec3::new(66.6, -2.1, 11.9)), "{l:?}"); + // A sloped line at survey coordinates. + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(612345.678, 2398765.432, 3.0), b: Vec3::new(612375.678, 2398775.432, 8.0) }), 0.0); + scale(&mut s, h, "612347.678 2398768.432"); + let EntityKind::Line(l) = get(&s, h).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(612344.278, 2398763.332, 5.1)) && near3(l.b, Vec3::new(612395.278, 2398780.332, 13.6)), "{l:?}"); +} + +#[test] +fn scale_scales_the_centre_z_and_thickness_of_circles_and_arcs() { + let mut s = Session::new(); + let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(0.5, 0.25, 0.005), radius: 0.01 }), 0.002); + scale(&mut s, c, "0.502 0.253"); + let (EntityKind::Circle(k), t) = get(&s, c) else { panic!() }; + assert!(near3(k.center, Vec3::new(0.4986, 0.2479, 0.0085)) && close(k.radius, 0.017) && close(t, 0.0034), "{k:?} {t}"); + let mut s = Session::new(); + let arc = Arc { center: Vec3::new(0.0, 0.0, 4.5), radius: 10.0, start: 30f64.to_radians(), end: 200f64.to_radians() }; + let a = add(&mut s, EntityKind::Arc(arc), 1.5); + scale(&mut s, a, "2.0 3.0"); + let (EntityKind::Arc(k), t) = get(&s, a) else { panic!() }; + assert!(near3(k.center, Vec3::new(-1.4, -2.1, 7.65)) && close(k.radius, 17.0) && close(t, 2.55), "{k:?} {t}"); +} + +#[test] +fn scale_scales_a_polylines_elevation_and_thickness_and_a_points_z() { + let mut s = Session::new(); + let vs = [(612345.678, 2398765.432, 0.0), (612365.678, 2398765.432, 0.4), (612375.678, 2398780.432, 0.0), (612350.678, 2398785.432, 0.0)]; + let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect(); + let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0); + scale(&mut s, p, "612347.678 2398768.432"); + let (EntityKind::LwPolyline(k), t) = get(&s, p) else { panic!() }; + assert!(close(k.elevation, 21.25) && close(t, 1.7), "{} {t}", k.elevation); + let first = k.vertices.first().unwrap().p; + assert!(close(first.x, 612344.278) && close(first.y, 2398763.332), "{first:?}"); + let pt = add(&mut s, EntityKind::Point(Point { p: Vec3::new(5.0, 5.0, 6.0), angle: 0.0 }), 0.0); + scale(&mut s, pt, "2.0 3.0"); + let EntityKind::Point(k) = get(&s, pt).0 else { panic!() }; + assert!(near3(k.p, Vec3::new(7.1, 6.4, 10.2)), "{k:?}"); +} + +#[test] +fn scale_scales_heights_about_the_current_elevation() { + // ELEVATION 5: the base point, given without a Z, is at Z = 5. + let mut s = Session::new(); + s.doc_mut().unwrap().header.set_f64("ELEVATION", 5.0); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0); + scale(&mut s, h, "2.0 3.0"); + let EntityKind::Line(l) = get(&s, h).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(-1.4, -2.1, 8.4)) && near3(l.b, Vec3::new(66.6, -2.1, 8.4)), "{l:?}"); + let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(0.0, 0.0, 4.5), radius: 10.0 }), 1.5); + scale(&mut s, c, "2.0 3.0"); + let (EntityKind::Circle(k), t) = get(&s, c) else { panic!() }; + assert!(near3(k.center, Vec3::new(-1.4, -2.1, 4.15)) && close(k.radius, 17.0) && close(t, 2.55), "{k:?} {t}"); + let vs = [(0.0, 0.0, 0.0), (20.0, 0.0, 0.4), (30.0, 15.0, 0.0)]; + let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect(); + let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0); + scale(&mut s, p, "2.0 3.0"); + let (EntityKind::LwPolyline(k), t) = get(&s, p) else { panic!() }; + assert!(close(k.elevation, 17.75) && close(t, 1.7), "{} {t}", k.elevation); + let last = k.vertices.last().unwrap().p; + assert!(close(last.x, 49.6) && close(last.y, 23.4), "{last:?}"); +} + +#[test] +fn json_scale_takes_the_base_points_z() { + let line = || EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }); + let z = |s: &Session, h: Handle| { + let EntityKind::Line(l) = get(s, h).0 else { panic!() }; + (l.a.z, l.b.z) + }; + let mut s = Session::new(); + let h = add(&mut s, line(), 0.0); + s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0, 1.0], "factor": 1.7 })).unwrap(); + let (a, b) = z(&s, h); + assert!(close(a, 11.2) && close(b, 11.2), "{a} {b}"); + // Without one, the current elevation (not measured through JSON: the same base point as the + // command line's). + let mut s = Session::new(); + s.doc_mut().unwrap().header.set_f64("ELEVATION", 5.0); + let h = add(&mut s, line(), 0.0); + s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0], "factor": 1.7 })).unwrap(); + let (a, b) = z(&s, h); + assert!(close(a, 8.4) && close(b, 8.4), "{a} {b}"); + // A Z that isn't a number is refused, the drawing left as it was. + assert!(s.execute("scale", &json!({ "handles": [h.hex()], "base": [2.0, 3.0, "1"], "factor": 1.7 })).is_err()); + assert_eq!(z(&s, h), (a, b)); +} + +#[test] +fn move_and_rotate_leave_heights_alone() { + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 2.0); + lisp(&mut s, &format!("(command \"_.ROTATE\" (handent \"{}\") \"\" '(0.0 0.0) 90)", h.hex())); + lisp(&mut s, &format!("(command \"_.MOVE\" (handent \"{}\") \"\" '(0.0 0.0) '(5.0 5.0))", h.hex())); + let (EntityKind::Line(l), t) = get(&s, h) else { panic!() }; + assert!(close(l.a.z, 7.0) && close(l.b.z, 7.0) && close(t, 2.0), "{l:?} {t}"); +} + +#[test] +fn offset_keeps_a_lines_z() { + let mut s = Session::new(); + let h = add(&mut s, EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 7.0), b: Vec3::new(40.0, 0.0, 7.0) }), 0.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(20.0 0.0)) '(10.0 5.0) \"\")", h.hex())); + let EntityKind::Line(l) = last(&s).0 else { panic!() }; + assert!(near3(l.a, Vec3::new(0.0, 3.0, 7.0)) && near3(l.b, Vec3::new(40.0, 3.0, 7.0)), "{l:?}"); +} + +#[test] +fn offset_keeps_the_centre_z_of_circles_and_arcs_but_not_their_thickness() { + let mut s = Session::new(); + let c = add(&mut s, EntityKind::Circle(Circle { center: Vec3::new(612345.678, 2398765.432, 5.0), radius: 10.0 }), 2.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(612355.678 2398765.432)) '(612345.678 2398780.432) \"\")", c.hex())); + let (EntityKind::Circle(k), t) = last(&s) else { panic!() }; + assert!(close(k.center.z, 5.0) && close(k.radius, 13.0) && t == 0.0, "{k:?} {t}"); + let mut s = Session::new(); + let arc = Arc { center: Vec3::new(-35000.0, 81000.0, 225.0), radius: 500.0, start: 30f64.to_radians(), end: 200f64.to_radians() }; + let a = add(&mut s, EntityKind::Arc(arc), 75.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 150.0 (list (handent \"{}\") '(-35000.0 81500.0)) '(-35000.0 81750.0) \"\")", a.hex())); + let (EntityKind::Arc(k), t) = last(&s) else { panic!() }; + assert!(close(k.center.z, 225.0) && close(k.radius, 650.0) && t == 0.0, "{k:?} {t}"); +} + +#[test] +fn offset_keeps_a_polylines_elevation_and_thickness() { + let mut s = Session::new(); + let vs = [(0.0, 0.0, 0.0), (20.0, 0.0, 0.4), (30.0, 15.0, 0.0), (5.0, 20.0, 0.0)]; + let vertices = vs.iter().map(|&(x, y, b)| PolyVertex { bulge: b, ..PolyVertex::new(Vec2::new(x, y)) }).collect(); + let p = add(&mut s, EntityKind::LwPolyline(LwPolyline { vertices, closed: false, const_width: 0.0, elevation: 12.5, plinegen: false }), 1.0); + lisp(&mut s, &format!("(command \"_.OFFSET\" 3.0 (list (handent \"{}\") '(10.0 0.0)) '(12.0 6.0) \"\")", p.hex())); + let (EntityKind::LwPolyline(k), t) = last(&s) else { panic!() }; + assert!(close(k.elevation, 12.5) && close(t, 1.0), "{} {t}", k.elevation); + let first = k.vertices.first().unwrap().p; + assert!(close(first.x, 0.0) && close(first.y, 3.0), "{first:?}"); +}