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 <noreply@anthropic.com>
This commit is contained in:
2ez4gcx
2026-10-31 10:32:49 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f18cdcbcac
commit 81e93f2caf
7 changed files with 332 additions and 9 deletions
+85
View File
@@ -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<Attrib>| {
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<Vec2> {
// A broken table has a grip for each part.
+1 -1
View File
@@ -61,7 +61,7 @@ pub fn specs() -> Vec<CommandSpec> {
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"])
+25 -2
View File
@@ -212,12 +212,35 @@ pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result<Value> {
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<Handle> {
/// `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<Handle> {
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");
}
+3 -3
View File
@@ -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)
+33 -3
View File
@@ -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<Vec<Handle>> {
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<Vec<Handle>> {
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<Vec<Handle>> {
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<Value> {
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::<Vec<_>>() }))
}
+1
View File
@@ -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;
+184
View File
@@ -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:?}");
}