Write Wipeout entities to DXF so saving keeps them (#79) (#147)

* Write Wipeout entities to DXF so saving keeps them (#79)

* Wipeout: clip vertices are in image pixel space (origin top-left, y down)

Write and read the boundary with y flipped, so other programs show the same
shape and wipeouts from other programs are no longer mirrored vertically.
Tested with an asymmetric boundary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Wipeout: collapse nested if (clippy)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 13:15:07 -04:00
1 parent f0d6f248f7
commit 1dfa3660a7
3 files changed
+79 -3

No files matched your search

+2 -1
View File
@@ -313,7 +313,8 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
let o = t.p(10);
let u = t.p(11);
let v = t.p(12);
let pts: Vec<Vec2> = t.pts(14).into_iter().map(|q| o.xy() + u.xy() * (q.x + 0.5) + v.xy() * (q.y + 0.5)).collect();
// Clip vertices are in pixel space: origin at the image's top-left corner, y pointing down.
let pts: Vec<Vec2> = t.pts(14).into_iter().map(|q| o.xy() + u.xy() * (q.x + 0.5) + v.xy() * (0.5 - q.y)).collect();
EntityKind::Wipeout(Wipeout { boundary: pts })
}
"IMAGE" => {
+38 -2
View File
@@ -5,7 +5,7 @@ use std::collections::HashMap;
use cadcraft_color::Color;
use cadcraft_doc::*;
use cadcraft_dxf::Tag;
use cadcraft_geom::{Vec2, Vec3};
use cadcraft_geom::{Bounds2, Vec2, Vec3};
use crate::dxf_ext::{self, DimVal, K};
@@ -797,7 +797,40 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
w.xdata(dxf_ext::frozen_xdata(&v.frozen_layers));
}
}
// Not yet written: images, wipeouts, tables, multileaders, unknown objects.
EntityKind::Wipeout(wo) => {
// A 1x1 image spanning the boundary's extents. Clip vertices are in pixel space, whose origin is
// the image's top-left corner with y pointing down: the reader maps each vertex back through
// insert + u * (x + 0.5) + v * (0.5 - y).
// Polygonal clip boundaries are closed, so the first vertex is repeated when needed.
let b = Bounds2::from_points(wo.boundary.iter().copied());
let o = if b.is_empty() { Vec2::ZERO } else { b.min };
let (sx, sy) = (if b.width() > 0.0 { b.width() } else { 1.0 }, if b.height() > 0.0 { b.height() } else { 1.0 });
w.s(0, "WIPEOUT");
common(w, e, owner, paper, "AcDbWipeout");
w.i(90, 0);
w.p(10, Vec3::new(o.x, o.y, 0.0));
w.p(11, Vec3::new(sx, 0.0, 0.0));
w.p(12, Vec3::new(0.0, sy, 0.0));
w.p2(13, Vec2::new(1.0, 1.0));
// Display flags 7 = show image, show unaligned, use clipping boundary; clipping on; default brightness/contrast/fade.
w.i(70, 7);
w.i(280, 1);
w.i(281, 50);
w.i(282, 50);
w.i(283, 0);
w.i(71, 2);
let mut pts = wo.boundary.clone();
if let (Some(first), Some(last)) = (pts.first().copied(), pts.last().copied())
&& first != last
{
pts.push(first);
}
w.i(91, pts.len() as i64);
for q in pts {
w.p2(14, Vec2::new((q.x - o.x) / sx - 0.5, 0.5 - (q.y - o.y) / sy));
}
}
// Not yet written: images, tables, multileaders, unknown objects.
_ => {}
}
}
@@ -1177,6 +1210,9 @@ pub fn write(d: &Drawing) -> String {
if !cx.assoc.is_empty() {
classes.push(("DIMASSOC", "AcDbDimAssoc", 0, false));
}
if every.iter().any(|e| matches!(e.kind, EntityKind::Wipeout(_))) {
classes.push(("WIPEOUT", "AcDbWipeout", 127, true));
}
for (dxf_name, cpp, proxy, is_entity) in classes {
w.s(0, "CLASS");
w.s(1, dxf_name);
+39
View File
@@ -1285,6 +1285,32 @@ fn dimension_text_rotation_roundtrips() {
assert!(rot.is_some_and(|r| (r - dm.text_rotation).abs() < 1e-9), "block text rotation {rot:?}");
}
/// Wipeout clip vertices are in image pixel space (origin top-left, y down): an asymmetric
/// boundary must keep its orientation, both through our reader and in the file itself.
#[test]
fn wipeout_boundary_keeps_its_orientation() {
let mut d = Drawing::new_imperial();
// A triangle with its apex at the top.
let boundary = vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(0.0, 4.0)];
d.add(&Space::Model, Common::default(), EntityKind::Wipeout(Wipeout { boundary: boundary.clone() })).unwrap();
let text = write_dxf(&d);
// The apex (0, 4) is at the top-left corner of the image: pixel (-0.5, -0.5).
let pts: Vec<(f64, f64)> = {
let lines: Vec<&str> = text.lines().map(str::trim).collect();
let mut v = Vec::new();
for i in 0..lines.len().saturating_sub(3) {
if lines[i] == "14" && lines[i + 2] == "24" {
v.push((lines[i + 1].parse().unwrap(), lines[i + 3].parse().unwrap()));
}
}
v
};
assert!(pts.iter().any(|&(x, y)| (x + 0.5).abs() < 1e-9 && (y + 0.5).abs() < 1e-9), "{pts:?}");
let back = roundtrip(&d);
let got = first(&back, |k| if let EntityKind::Wipeout(w) = k { Some(w.boundary.clone()) } else { None });
assert_eq!(&got[..3], &boundary[..]);
}
#[test]
fn arc_length_dimension_keeps_its_kind_and_centre_on_roundtrip() {
let mut d = Drawing::new_metric();
@@ -1475,3 +1501,16 @@ fn long_gradient_name_is_cut_only_in_xdata() {
let back = read_dxf(text.as_bytes()).unwrap();
assert_eq!(hatches(&back).into_iter().next().and_then(|h| h.gradient).unwrap().name, name);
}
#[test]
fn wipeout_survives_dxf_roundtrip() {
let mut d = Drawing::new_imperial();
let boundary = vec![Vec2::new(2.0, 3.0), Vec2::new(12.0, 3.0), Vec2::new(12.0, 13.0), Vec2::new(2.0, 13.0)];
d.add(&Space::Model, Common::default(), EntityKind::Wipeout(Wipeout { boundary: boundary.clone() })).unwrap();
let back = roundtrip(&d);
let got = first(&back, |k| if let EntityKind::Wipeout(w) = k { Some(w.boundary.clone()) } else { None });
// The DXF polygon is closed, so the first vertex is repeated at the end.
assert_eq!(got.len(), 5);
assert_eq!(&got[..4], &boundary[..]);
assert_eq!(got[4], boundary[0]);
}