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)?;