diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 7615bc56..d5622686 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -2130,7 +2130,9 @@ fn dimassoc(d: &mut Drawing, tags: &[Tag]) { let Some(main) = r.s(331).and_then(|h| Handle::parse_hex(&h)) else { continue }; let other = r.s(332).and_then(|h| Handle::parse_hex(&h)); let at = r.p(10).xy(); - if let Some(snap) = infer_snap(d, r.i(72).unwrap_or(0), main, other, at) { + // A polyline's segment (subentity type 73 = 2, edge; 91 its 1-based index). + let segment = r.i(91).filter(|k| *k >= 1 && r.i(73) == Some(2)); + if let Some(snap) = infer_snap(d, r.i(72).unwrap_or(0), main, other, at, segment) { links.push(DimAssoc { point: (*point).to_string(), handle: main, snap }); } } @@ -2143,9 +2145,18 @@ fn dimassoc(d: &mut Drawing, tags: &[Tag]) { } } -/// A CADCraft snap from a DIMASSOC object snap type and point. -fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option, at: Vec2) -> Option { +/// A CADCraft snap from a DIMASSOC object snap type and point, and for a polyline the segment +/// the point is an end of: AutoCAD writes no usable point there (10/20 are 0, #1049), the segment +/// and the osnap (1 its end, 13 its start) name the vertex. +fn infer_snap(d: &Drawing, osnap: i64, main: Handle, other: Option, at: Vec2, segment: Option) -> Option { let e = d.entity(main)?; + if let (EntityKind::LwPolyline(p), Some(k), 1 | 13) = (&e.kind, segment, osnap) { + let n = p.vertices.len(); + let segments = if p.closed { n } else { n.saturating_sub(1) }; + let k = usize::try_from(k).ok().filter(|k| *k <= segments)?; + let index = if osnap == 13 { k - 1 } else { k % n }; + return Some(AssocSnap::Vertex { index }); + } Some(match osnap { 1 | 13 => match &e.kind { EntityKind::Line(l) => { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 3fd43215..d2f8941f 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -94,6 +94,9 @@ struct AssocObj { struct OsnapRef { osnap: i64, main: Handle, + /// The 1-based segment of a polyline the point is an end of (group 91, with subentity type + /// 73 = 2, edge); 0 for an object made of one curve. + segment: i64, param: f64, point: Vec3, other: Option, @@ -1506,9 +1509,43 @@ fn arrow_block_entities(kind: cadcraft_render::Arrowhead) -> Vec { out } +/// Whether a DIMASSOC point reference to `k` names a point of it without naming a segment: +/// true for objects made of one curve. Polylines are referenced by segment ([`polyline_vertex_ref`]); +/// 3D polylines and multilines can't be yet, and references to them are left out, as AutoCAD +/// otherwise resolved them to the first segment and changed the dimension's value (#1049). +fn whole_object_ref(k: &EntityKind) -> bool { + !matches!(k, EntityKind::LwPolyline(_) | EntityKind::Polyline3d(_) | EntityKind::MLine(_)) +} + +/// A polyline vertex as AutoCAD references it (#1049): an end of one of its two segments, +/// which are numbered from 1 (segment k runs from vertex k−1 to vertex k; a closed polyline's +/// closing segment is the last). Returns (osnap, parameter, segment): the end of the incoming +/// segment (osnap 1, parameter 1.0), else the start of the outgoing one (osnap 13, 0.0). Only +/// straight segments are used, whose parameters are 0 and 1; a vertex between two arcs gets no +/// reference. +pub(crate) fn polyline_vertex_ref(p: &LwPolyline, index: usize) -> Option<(i64, f64, i64)> { + let n = p.vertices.len(); + if index >= n || n < 2 { + return None; + } + let segments = if p.closed { n } else { n - 1 }; + // Segment k (1-based) starts at vertex k−1; its bulge is that vertex's. + let straight = |k: usize| k >= 1 && k <= segments && p.vertices.get(k - 1).is_some_and(|v| v.bulge == 0.0); + let incoming = if index == 0 { if p.closed { Some(n) } else { None } } else { Some(index) }; + let outgoing = (index < segments).then_some(index + 1); + // Vertex 0 as the start of segment 1, the others as the end of the segment before, as + // AutoCAD writes them when it can. + let preferred = if index == 0 { + [outgoing.map(|k| (13, 0.0, k)), incoming.map(|k| (1, 1.0, k))] + } else { + [incoming.map(|k| (1, 1.0, k)), outgoing.map(|k| (13, 0.0, k))] + }; + preferred.into_iter().flatten().find(|(_, _, k)| straight(*k)).map(|(o, t, k)| (o, t, i64::try_from(k).unwrap_or(0))) +} + /// Standard DIMASSOC point references of an associative dimension: the extension-line /// origins of linear/aligned dimensions and the feature point of ordinate dimensions, linked -/// to objects that exist. CADCraft's exact links travel in xdata as well. +/// to objects that exist ([`whole_object_ref`]). CADCraft's exact links travel in xdata as well. fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec) { let slots: &[&str] = match dm.kind { DimKind::Linear { .. } | DimKind::Aligned => &["p13", "p14"], @@ -1521,20 +1558,35 @@ fn std_assoc_refs(d: &Drawing, dm: &Dimension) -> (i64, Vec) { let Some(a) = dm.assoc.iter().find(|a| a.point == *name) else { continue }; let Some(ent) = d.entity(a.handle) else { continue }; let point = if *name == "p13" { dm.p13 } else { dm.p14 }; + if let EntityKind::LwPolyline(p) = &ent.kind { + let index = match &a.snap { + AssocSnap::Vertex { index } => Some(*index), + AssocSnap::Start => Some(0), + AssocSnap::End => p.vertices.len().checked_sub(1), + _ => None, + }; + let Some((osnap, param, segment)) = index.and_then(|i| polyline_vertex_ref(p, i)) else { continue }; + flags |= 1 << slot; + refs.push(OsnapRef { osnap, main: a.handle, segment, param, point, other: None }); + continue; + } + if !whole_object_ref(&ent.kind) { + continue; + } let (osnap, param, other) = match &a.snap { AssocSnap::Start | AssocSnap::End | AssocSnap::Vertex { .. } => (1, 0.0, None), AssocSnap::Mid => (2, 0.0, None), AssocSnap::Center => (if matches!(ent.kind, EntityKind::Point(_)) { 4 } else { 3 }, 0.0, None), AssocSnap::OnCircle { angle } => (10, *angle, None), AssocSnap::Intersection { other } => { - if d.entity(*other).is_none() { + if !d.entity(*other).is_some_and(|o| whole_object_ref(&o.kind)) { continue; } (6, 0.0, Some(*other)) } }; flags |= 1 << slot; - refs.push(OsnapRef { osnap, main: a.handle, param, point, other }); + refs.push(OsnapRef { osnap, main: a.handle, segment: 0, param, point, other }); } (flags, refs) } @@ -2450,8 +2502,8 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec { w.s(1, "AcDbOsnapPointRef"); w.i(72, r.osnap); w.s(331, r.main.hex()); - w.i(73, 0); - w.i(91, 0); + w.i(73, if r.segment > 0 { 2 } else { 0 }); + w.i(91, r.segment); w.s(301, ""); w.f(40, r.param); w.p(10, r.point); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 1fa53c6e..20a18fe5 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1086,11 +1086,117 @@ fn dimension_associativity_roundtrips() { assert_eq!(assoc_of(&foreign, hl), lin.assoc); let fa = assoc_of(&foreign, ha); assert_eq!(fa.len(), 2); - assert_eq!(fa[1], ali.assoc[1]); + assert_eq!(fa[1], ali.assoc[1], "the polyline vertex, named by its segment (#1049)"); assert!(matches!(fa[0].snap, AssocSnap::OnCircle { angle } if angle.abs() < 1e-9)); assert!(assoc_of(&foreign, hr).is_empty(), "radial links are CADCraft-only"); } +/// #1049: a polyline vertex is referenced as AutoCAD does: an end of one of its segments, +/// numbered from 1 (subentity type 73 = 2, segment in 91, osnap 1 end / 13 start, parameter 1 / +/// 0), preferring a straight segment. The cases are the ones observed in AutoCAD 2026 (black box) +/// in the PR review. +#[test] +fn polyline_vertex_references_name_the_segment() { + let poly = |pts: &[(f64, f64)], closed: bool, bulges: &[(usize, f64)]| LwPolyline { + vertices: pts + .iter() + .enumerate() + .map(|(i, (x, y))| { + let b = bulges.iter().find(|(j, _)| *j == i).map_or(0.0, |(_, b)| *b); + PolyVertex::with_bulge(Vec2::new(*x, *y), b) + }) + .collect(), + closed, + const_width: 0.0, + elevation: 0.0, + plinegen: false, + }; + let refs = |p: &LwPolyline| (0..p.vertices.len()).map(|i| crate::dxf_write::polyline_vertex_ref(p, i)).collect::>(); + // Open: vertex 0 is the start of segment 1, vertex k the end of segment k. + let open = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 60.0), (40.0, 60.0)], false, &[]); + assert_eq!(refs(&open), [Some((13, 0.0, 1)), Some((1, 1.0, 1)), Some((1, 1.0, 2)), Some((1, 1.0, 3))]); + // Closed (clockwise): the same; the closing segment is the 4th. + let closed = poly(&[(0.0, 0.0), (0.0, 60.0), (100.0, 60.0), (100.0, 0.0)], true, &[]); + assert_eq!(refs(&closed), [Some((13, 0.0, 1)), Some((1, 1.0, 1)), Some((1, 1.0, 2)), Some((1, 1.0, 3))]); + // An arc as segment 3: its end, vertex 3, is the start of the straight segment 4 instead. + let arc = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 60.0), (40.0, 60.0), (0.0, 30.0)], true, &[(2, 0.5)]); + assert_eq!(refs(&arc)[3], Some((13, 0.0, 4))); + assert_eq!(refs(&arc)[2], Some((1, 1.0, 2))); + // Between two arcs there is no straight segment: no reference. + let arcs = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 60.0)], false, &[(0, 0.5), (1, 0.5)]); + assert_eq!(refs(&arcs)[1], None); +} + +/// #1049: dimensions on the vertices of a polyline are written with segment references, and a +/// file with only those (another writer's, whose reference points AutoCAD leaves at 0,0) is read +/// back to the same vertices. +#[test] +fn polyline_vertex_dimensions_round_trip_by_segment() { + let mut d = Drawing::new_metric(); + let c = Common::default; + let pts = [(0.0, 0.0), (160.0, 0.0), (160.0, 210.0), (0.0, 210.0)]; + let pl = d + .add( + &Space::Model, + c(), + EntityKind::LwPolyline(LwPolyline { + vertices: pts.iter().map(|(x, y)| PolyVertex::new(Vec2::new(*x, *y))).collect(), + closed: true, + const_width: 0.0, + elevation: 0.0, + plinegen: false, + }), + ) + .unwrap(); + let mut on_pl = dim(DimKind::Linear { rotation: std::f64::consts::FRAC_PI_2 }, Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 210.0, 0.0)); + on_pl.assoc = vec![ + DimAssoc { point: "p13".into(), handle: pl, snap: AssocSnap::Vertex { index: 0 } }, + DimAssoc { point: "p14".into(), handle: pl, snap: AssocSnap::Vertex { index: 3 } }, + ]; + let hp = d.add(&Space::Model, c(), EntityKind::Dimension(on_pl.clone())).unwrap(); + let text = write_dxf(&d); + let tags = cadcraft_dxf::parse(text.as_bytes()).unwrap(); + // Each reference to the polyline: (72, 73, 91). + let refs: Vec<(i64, i64, i64)> = tags + .iter() + .enumerate() + .filter(|(_, t)| t.code == 331 && t.str() == pl.hex()) + .map(|(i, _)| { + let back = |code: i32| tags[..i].iter().rev().find(|t| t.code == code).and_then(|t| t.str().parse().ok()).unwrap_or(-1); + let fwd = |code: i32| tags[i..].iter().find(|t| t.code == code).and_then(|t| t.str().parse().ok()).unwrap_or(-1); + (back(72), fwd(73), fwd(91)) + }) + .collect(); + assert_eq!(refs, [(13, 2, 1), (1, 2, 3)]); + + // Without CADCraft's xdata and with the reference points at 0,0 (as AutoCAD writes them), + // the segments still give the vertices. + let mut stripped = Vec::new(); + let mut skipping = false; + let mut in_ref = false; + for mut t in tags { + if t.code == 1001 { + skipping = t.str() == "CADCRAFT"; + } else if t.code < 1000 { + skipping = false; + } + if t.code == 1 { + in_ref = t.str() == "AcDbOsnapPointRef"; + } + if in_ref && matches!(t.code, 10 | 20) { + t = cadcraft_dxf::Tag::new(t.code, cadcraft_dxf::Value::Real(0.0)); + } + if !skipping { + stripped.push(t); + } + } + let foreign = read_dxf(cadcraft_dxf::write_ascii(&stripped).as_bytes()).unwrap(); + match &foreign.entity(hp).unwrap().kind { + EntityKind::Dimension(x) => assert_eq!(x.assoc, on_pl.assoc), + _ => panic!(), + } +} + fn parametric_sample(d: &mut Drawing) -> (Vec, Parametric) { let l1 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(4.0, 0.0, 0.0) })).unwrap(); let l2 = d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(4.0, 0.0, 0.0), b: Vec3::new(4.0, 3.0, 0.0) })).unwrap();