From ebc67fa81dccb0d89c157babb27e7d5c3a5c1177 Mon Sep 17 00:00:00 2001 From: 2ez4gcx <108074836+2ez4gcx@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:31:22 +0700 Subject: [PATCH] Dynamic blocks: stretch the points inside the frame, as AutoCAD does (#2627) * Dynamic blocks: stretch the points inside the frame, as AutoCAD does A dynamic block made by a third-party tool came out distorted when a distance changed: its stretch action has distance multiplier 0 with angle offset 1, and stored vertex lists that don't match its objects. apply turned the 0 into 1 but kept the angle (a 57 degree slant), and moved exactly the listed points. AutoCAD 2024, on a copy of that drawing, moved the block straight along the parameter, and every point of its lines and polylines exactly when it was inside the frame, whatever the lists said. So: - a zero multiplier moves as 1, without its angle offset; - a line's or lightweight polyline's moving points are those inside the frame where the dynamic block has them; a selected one wholly outside the frame moves whole, as in AutoCAD's own stretched copies; - a frame of fewer than 2 or more than 1,000 points keeps the stored lists, so a huge frame can't make every point test every edge. Against AutoCAD's own anonymous copies in two library drawings (556 references set back to their block's distances), 249 come back exactly on main and 258 with this change; none stops matching. Co-Authored-By: Claude Opus 5.5 * Dynamic blocks: a point on a polygon frame's edge is inside it in_frame counted a point on a two-corner frame's edge as inside, but the even-odd test of a polygon frame put one on its right edge outside. A line whose end lay there, with its other end outside, then had no point inside the frame and moved whole instead of its end alone. A polygon frame now counts a point on an edge (within 1e-9, relative to the edge's length) as inside too, as the two-corner frame does. Co-Authored-By: Claude Opus 5.5 * Dynamic blocks: frame edge tolerance by the edge's length; tilted polylines keep their lists A point counted as on a polygon frame's edge within 1e-9 * (1 + the edge's length), so never less than 1e-9 drawing units: in a tiny frame, a point visibly outside was taken as inside. The tolerance is now a billionth of the edge's length. A lightweight polyline's vertices are in its own plane. The reader flips a mirrored one (extrusion along -z) into the block's plane, but leaves a tilted one's in its OCS, which the frame (block coordinates) can't be tested against. A tilted polyline keeps the stored vertex list, as before the frame test. Co-Authored-By: Claude Opus 5.5 * dynblock: only an exactly untilted polyline is tested against the frame; a tilted one keeps its stored list Co-Authored-By: Claude Opus 5.5 * fmt Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/doc/src/dynblock.rs | 4 +- crates/doc/src/dynblock/apply.rs | 38 ++++++- crates/io/tests/it/dynblock_read.rs | 154 ++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs index ef0a88be2..392861388 100644 --- a/crates/doc/src/dynblock.rs +++ b/crates/doc/src/dynblock.rs @@ -194,7 +194,9 @@ pub struct Action { #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] pub enum ActionKind { - /// Moves the vertices inside `frame` of the objects listed (all of an object not listed). + /// Moves the points of its objects inside `frame`: a line's or polyline's points inside it + /// (the whole of one outside it), any other object whole. `vertices` are the point indices the + /// file lists for each object, used only when the frame can't be tested (see `apply`). Stretch { frame: Vec, #[serde(serialize_with = "hex_vertices")] diff --git a/crates/doc/src/dynblock/apply.rs b/crates/doc/src/dynblock/apply.rs index ed5281bf6..d5651faac 100644 --- a/crates/doc/src/dynblock/apply.rs +++ b/crates/doc/src/dynblock/apply.rs @@ -403,7 +403,20 @@ fn move_entity(e: &mut Entity, vertices: Option<&[i32]>, v: Vec2) { } } -/// Whether point `p` lies inside a stretch frame (two corners, or a polygon). +/// Most points of a stretch frame whose objects' points are tested against it one by one. A frame +/// is two corners or a short polygon; testing every point of every object against every edge of a +/// longer one would grow with their product, so the action's own vertex lists are used instead. +const MAX_FRAME_POINTS: usize = 1000; + +/// The indices of `points` inside stretch frame `frame`, or `None` when none is: an object the +/// action selects that lies outside its frame moves whole. +fn inside(frame: &[Vec2], points: impl Iterator) -> Option> { + let idx: Vec = points.enumerate().filter(|(_, p)| in_frame(frame, *p)).filter_map(|(i, _)| i32::try_from(i).ok()).collect(); + (!idx.is_empty()).then_some(idx) +} + +/// Whether point `p` lies inside a stretch frame (two corners, or a polygon). A point on its edge is +/// inside, for a polygon as for two corners: within a billionth of the edge's length of it. fn in_frame(frame: &[Vec2], p: Vec2) -> bool { match frame { [a, b] => p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y), @@ -412,6 +425,10 @@ fn in_frame(frame: &[Vec2], p: Vec2) -> bool { let mut j = f.len() - 1; for i in 0..f.len() { let (Some(pi), Some(pj)) = (f.get(i), f.get(j)) else { break }; + let edge = cadcraft_geom::Line { a: *pj, b: *pi }; + if edge.dist(p) <= 1e-9 * pi.dist(*pj) { + return true; + } if (pi.y > p.y) != (pj.y > p.y) && p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x { inside = !inside; } @@ -464,7 +481,10 @@ fn apply( _ => 0.0, }) }; - let mult = if mult == 0.0 { 1.0 } else { mult }; + // A zero multiplier would move nothing. AutoCAD 2024 moves such an action by the + // parameter's change and leaves its angle offset out (measured on a stretch action written + // with multiplier 0, angle offset 1 and group 280 = 1). + let (mult, angle) = if mult == 0.0 { (1.0, 0.0) } else { (mult, angle) }; let v = Vec2::new(comp(0), comp(1)).rotate(angle) * mult; if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) { continue; @@ -473,6 +493,8 @@ fn apply( // this action's selection, so a selection that repeats a handle does not re-scan the // vertex list and re-clone its index vector per occurrence (CWE-400). let vtx_by_handle: HashMap> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect(); + // Whether the frame (two corners or a polygon) is tested point by point. + let framed = (2..=MAX_FRAME_POINTS).contains(&frame.len()); let mut handled: HashSet = HashSet::new(); for h in &a.selection { if !handled.insert(*h) { @@ -502,7 +524,17 @@ fn apply( continue; } let Some(c) = copy_of(h) else { continue }; - let idx = vtx_by_handle.get(h).map(|i| (*i).clone()); + // The points of a line or polyline that move are those inside the frame where the + // dynamic block has them, all of them when none is, as AutoCAD 2024 moves them. It + // doesn't go by the vertex lists the action stores, which a block from another program + // can get wrong. A polyline's vertices are in its own plane: in the block's when its + // extrusion is along z (a mirrored one is read flipped into it), so a tilted one keeps + // the stored list. + let idx = match def.entities.get(*h).map(|e| (&e.kind, e.common.extrusion)) { + Some((EntityKind::Line(l), _)) if framed => inside(frame, [l.a.xy(), l.b.xy()].into_iter()), + Some((EntityKind::LwPolyline(pl), n)) if framed && n.x == 0.0 && n.y == 0.0 => inside(frame, pl.vertices.iter().map(|v| v.p)), + _ => vtx_by_handle.get(h).map(|i| (*i).clone()), + }; moves.entry(c).or_default().push((idx, v)); } } diff --git a/crates/io/tests/it/dynblock_read.rs b/crates/io/tests/it/dynblock_read.rs index d265f9e70..bd9f2d1e1 100644 --- a/crates/io/tests/it/dynblock_read.rs +++ b/crates/io/tests/it/dynblock_read.rs @@ -406,6 +406,160 @@ fn parameters_in_a_stretch_selection_are_carried_along() { assert_eq!(tags[grip + 1..grip + 3], [(300, "GRIPLOC".to_string()), (11, "-15".to_string())]); } +/// A reference to Door at the origin with Door's own values, in a drawing read from `src`. +fn door_reference(src: &str) -> (cadcraft_doc::Drawing, Handle) { + let mut d = read_dxf(dxf(src).as_bytes()).unwrap(); + let ins = cadcraft_doc::Insert { + block: "Door".into(), + insert: cadcraft_doc::geom::Vec3::new(0.0, 0.0, 0.0), + scale: cadcraft_doc::geom::Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + let r = d.add(&cadcraft_doc::Space::Model, cadcraft_doc::Common::default(), cadcraft_doc::EntityKind::Insert(ins)).unwrap(); + (d, r) +} + +/// The points of the lines and polylines of reference `r`'s block, in its order. +fn shapes(d: &cadcraft_doc::Drawing, r: Handle) -> Vec> { + let b = d.block(&block_of(d, &r.hex())).unwrap(); + b.entities + .iter() + .filter_map(|e| match &e.kind { + cadcraft_doc::EntityKind::Line(l) => Some(vec![(l.a.x, l.a.y), (l.b.x, l.b.y)]), + cadcraft_doc::EntityKind::LwPolyline(p) => Some(p.vertices.iter().map(|v| (v.p.x, v.p.y)).collect()), + _ => None, + }) + .collect() +} + +#[test] +fn a_stretch_moves_the_points_inside_its_frame_not_the_listed_ones() { + // The stretch (frame (34,-2)-(38,2)) also acts on line 32, which is all outside the frame, and + // on a polyline whose last two vertices are inside it; and the vertex lists it stores are + // wrong for all three. AutoCAD 2024 moves the points inside the frame, measured on a dynamic + // block from another program whose lists didn't match its objects: every point of its lines and + // polylines moved exactly when it was inside the frame, whatever the lists said. A selected + // line or polyline all outside the frame moves whole, as in AutoCAD's own stretched copies. + let polyline = "0 LWPOLYLINE\n5 33\n330 1D\n100 AcDbEntity\n8 0\n100 AcDbPolyline\n90 3\n70 0\n10 20\n20 1\n10 36\n20 1\n10 36\n20 -1\n"; + let src = fixture() + .replace("71 1\n330 30\n1010 40", "71 3\n330 30\n330 32\n330 33\n1010 40") + .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 3\n331 30\n74 1\n94 0\n331 32\n74 1\n94 1\n331 33\n74 1\n94 0\n75 0") + .replace("21 25\n31 0\n0 ENDBLK\n", &format!("21 25\n31 0\n{polyline}0 ENDBLK\n")); + let (mut d, r) = door_reference(&src); + let ActionKind::Stretch { vertices, .. } = &dynamic_block(&d, "Door").unwrap().actions[0].kind else { panic!("a stretch action") }; + assert_eq!(vertices, &[(h("30"), vec![0]), (h("32"), vec![1]), (h("33"), vec![0])]); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + let want: Vec> = vec![ + vec![(0.0, 0.0), (30.0, 0.0)], + vec![(0.0, 0.0), (0.0, 36.0)], + vec![(-6.0, 0.0), (19.0, 25.0)], + vec![(20.0, 1.0), (30.0, 1.0), (30.0, -1.0)], + ]; + assert_eq!(shapes(&d, r), want); +} + +#[test] +fn a_zero_distance_multiplier_moves_as_one_without_its_angle_offset() { + // A stretch written with multiplier 0 and angle offset 1 (radian), and group 280 = 1. AutoCAD + // 2024 moved such an action by the parameter's change, straight along it. + let src = fixture().replace("140 1\n141 0\n280 0", "140 0\n141 1\n280 1"); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + let ActionKind::Stretch { multiplier, angle_offset, .. } = &dynamic_block(&d, "Door").unwrap().actions[0].kind else { + panic!("a stretch action") + }; + assert_eq!((*multiplier, *angle_offset), (0.0, 1.0)); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(0.0, 0.0), (30.0, 0.0)]); +} + +#[test] +fn a_point_on_the_edge_of_a_polygon_frame_is_inside_it() { + // Line 30 ends at (36, 0), and its start is outside every frame here. On the edge of the frame + // (34,-2)-(36,2) given as two corners, the end is inside and moves alone. The same frame as a + // polygon, and triangles whose slanted edge passes through (36, 0), exactly or up to rounding, + // take it the same way. + let frames = [ + "72 2\n1011 34\n1021 -2\n1011 36\n1021 2\n", + "72 4\n1011 34\n1021 -2\n1011 36\n1021 -2\n1011 36\n1021 2\n1011 34\n1021 2\n", + "72 3\n1011 34\n1021 -2\n1011 38\n1021 2\n1011 34\n1021 2\n", + "72 3\n1011 35.7\n1021 -0.3\n1011 36.7\n1021 0.7\n1011 30\n1021 10\n", + ]; + for frame in frames { + let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(0.0, 0.0), (30.0, 0.0)], "{frame}"); + } +} + +#[test] +fn a_point_just_outside_a_tiny_frame_is_outside_it() { + // A frame 1e-8 across whose right edge is 5e-10 short of line 30's end (36, 0): the end is + // outside, so the line, with no point inside, moves whole. An edge's tolerance is a billionth of + // its length, not of a drawing unit. + let frame = "72 4\n1011 35.99999999\n1021 -1e-8\n1011 35.9999999995\n1021 -1e-8\n1011 35.9999999995\n1021 1e-8\n1011 35.99999999\n1021 1e-8\n"; + let src = fixture().replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", frame); + assert_ne!(src, fixture()); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], [(-6.0, 0.0), (30.0, 0.0)]); +} + +#[test] +fn a_tilted_polyline_keeps_the_stored_vertex_list() { + // Polyline 34 lies in a plane tilted about the y axis (extrusion (1,0,0)): its vertices are in + // that plane's coordinates, not the block's, so the frame isn't tested against them and the + // stored list (vertex 0) is used, as before. Polyline 35 is mirrored (extrusion (0,0,-1)): it + // is read flipped into the block's plane, (-20, 1) as (20, 1), so the frame is tested. + let polyline = |h: &str, [x0, x1]: [i32; 2], [ex, ey, ez]: [i32; 3]| { + format!( + "0 LWPOLYLINE\n5 {h}\n330 1D\n100 AcDbEntity\n8 0\n100 AcDbPolyline\n90 2\n70 0\n10 {x0}\n20 1\n10 {x1}\n20 1\n210 {ex}\n220 {ey}\n230 {ez}\n" + ) + }; + let src = fixture() + .replace("71 1\n330 30\n1010 40", "71 3\n330 30\n330 34\n330 35\n1010 40") + .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 3\n331 30\n74 1\n94 1\n331 34\n74 1\n94 0\n331 35\n74 1\n94 0\n75 0") + .replace( + "21 25\n31 0\n0 ENDBLK\n", + &format!("21 25\n31 0\n{}{}0 ENDBLK\n", polyline("34", [20, 36], [1, 0, 0]), polyline("35", [-20, -36], [0, 0, -1])), + ); + let (mut d, r) = door_reference(&src); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + let s = shapes(&d, r); + assert_eq!(s[3], [(14.0, 1.0), (36.0, 1.0)]); + assert_eq!(s[4], [(20.0, 1.0), (30.0, 1.0)]); +} + +#[test] +fn a_frame_with_too_many_points_to_test_one_by_one_keeps_the_stored_lists() { + // The same frame as a polygon of `n` points (its four corners, the last repeated). Up to 1000 + // points each point of the objects is tested against it; past that the stored list (here the + // line's start, outside the frame) is used, so a huge frame doesn't make every point test every + // edge. + let frame = |n: usize| { + let mut f = format!("72 {n}\n1011 34\n1021 -2\n1011 38\n1021 -2\n1011 38\n1021 2\n"); + f.push_str(&"1011 34\n1021 2\n".repeat(n - 3)); + f + }; + for (n, want) in [(1000, [(0.0, 0.0), (30.0, 0.0)]), (1001, [(-6.0, 0.0), (36.0, 0.0)])] { + let src = fixture() + .replace("72 2\n1011 34\n1021 -2\n1011 38\n1021 2\n", &frame(n)) + .replace("73 1\n331 30\n74 1\n94 1\n75 0", "73 1\n331 30\n74 1\n94 0\n75 0"); + let (mut d, r) = door_reference(&src); + let ActionKind::Stretch { frame, .. } = &dynamic_block(&d, "Door").unwrap().actions[0].kind else { panic!("a stretch action") }; + assert_eq!(frame.len(), n); + cadcraft_doc::dynblock::set_property(&mut d, r, "Width", "30").unwrap(); + assert_eq!(shapes(&d, r)[0], want, "{n} frame points"); + } +} + /// The object entry `key` of dictionary `dict` refers to. fn dict_entry(d: &cadcraft_doc::Drawing, dict: Handle, key: &str) -> Option { let o = d.foreign.objects.iter().find(|o| o.handle == dict)?;