Compare commits

...

5 Commits

Author SHA1 Message Date
lillith 3fb2eef92d wip(dxf): certify full round-wall outlines (#5318)
Targeted doc suite 390 pass/0 fail/1 existing ignore; sheet suite 198 pass/1 known trimmed-cap failure/1 existing ignore; view DXF 14 pass/0 fail and 53 original byte pins pass. Full CI and strict Clippy/wasm on this WIP are still pending. This commit preserves a reviewed partial improvement; do not mark PR #6184 ready.
2026-10-27 00:27:04 +01:00
lillith 0ff4438c32 wip(drawings): preserve X1 candidate for focused sheet diagnosis
Current focused validation: geom and IO passed; 53 original export byte pins passed; seven sheet tests failed. View-DXF, Clippy, wasm and full CI have not run. Preserve this unqualified work while the team takes the W38d owner handoff. No golden or assertion changes.
2026-10-26 12:43:08 +01:00
lillith 48093730fa Merge existing X1 claim into frozen Drawings batch 2026-10-26 11:23:36 +01:00
lillith 237c94854f test(dxf): pin original flat-pattern export bytes
Pin 53 original outputs before the shared path conversion: DXF, shape JSON and refusal text. Preserve all existing semantic checks and use native nonignored SHA-256 and length assertions without recording or filesystem fixtures.

Validation: engine format check, ordinary engine library build, and exact permanent byte regression passed. Full repository validation follows the coherent X1 batch.
2026-10-26 11:23:10 +01:00
lillith f876e12844 claim: #5318 2026-10-24 17:40:33 +02:00
12 changed files with 3406 additions and 132 deletions
Generated
+1
View File
@@ -4289,6 +4289,7 @@ dependencies = [
"serde",
"serde_json",
"serde_path_to_error",
"sha2 0.10.9",
"solidcraft-doc",
"solidcraft-geom",
"solidcraft-io",
+93 -1
View File
@@ -121,6 +121,47 @@ pub(crate) fn outline_loop(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u3
Loop2 { segs: outline_segs(pts, wall, eps).into_iter().map(|(seg, _)| seg).collect() }
}
/// The whole outline is one round wall only when every edge was admitted on
/// the same cylindrical support by outline_segs. Recover the directed winding
/// from those ordered points, rather than using a long sum of rounded angles.
/// A monotone polar walk with winding +/-1 rejects retraces and multiple turns.
/// This producer-owned certificate never loosens Loop2::as_circle's rules.
fn certified_round_wall_turn(pts: &[(Vec2, Option<u32>)], center: Vec2) -> Option<f64> {
if pts.len() < 3 || !center.is_finite() {
return None;
}
let mut direction = 0i64;
let mut winding = 0i64;
for i in 0..pts.len() {
let (Some(a), Some(b)) = (pts.get(i), pts.get((i + 1) % pts.len())) else {
return None;
};
let (a, b) = (a.0 - center, b.0 - center);
if !a.is_finite() || !b.is_finite() {
return None;
}
let angle = (b.angle() - a.angle() + PI).rem_euclid(TAU) - PI;
if !angle.is_finite() || angle == 0.0 || angle.abs() >= PI {
return None;
}
let sign = if angle > 0.0 { 1 } else { -1 };
if direction != 0 && direction != sign {
return None;
}
direction = sign;
let cross = a.cross(b);
if !cross.is_finite() {
return None;
}
if a.y <= 0.0 && b.y > 0.0 && cross > 0.0 {
winding = winding.checked_add(1)?;
} else if a.y > 0.0 && b.y <= 0.0 && cross < 0.0 {
winding = winding.checked_sub(1)?;
}
}
(direction != 0 && winding == direction).then_some(TAU * direction as f64)
}
/// [`outline_loop`], each segment with its wall (an arc's: that of its first point).
fn outline_segs(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Option<(Vec2, f64)>, eps: f64) -> Vec<(Seg2, Option<u32>)> {
let m = pts.len();
@@ -150,12 +191,20 @@ fn outline_segs(pts: &[(Vec2, Option<u32>)], wall: &mut impl FnMut(u32) -> Optio
while j < m && same(start + j, (c, r)) {
j += 1;
}
let sweep: f64 = (i..j)
let summed_sweep: f64 = (i..j)
.map(|s| {
let d = (point(start + s + 1) - c).angle() - (point(start + s) - c).angle();
(d + PI).rem_euclid(TAU) - PI
})
.sum();
// When one admitted round-wall run covers every source edge, its ordered
// points may prove one turn even if floating sums miss +/-TAU by a few ULP.
// Partial and uncertified runs keep their originally computed sweep.
let sweep = if i == 0 && j == m && c.is_finite() && r.is_finite() && r > 0.0 {
certified_round_wall_turn(pts, c).unwrap_or(summed_sweep)
} else {
summed_sweep
};
// Arcs of up to half a turn each.
let pieces = ((sweep.abs() / PI - 1e-9).ceil() as usize).clamp(1, 4);
let (a0, step) = ((point(k) - c).angle(), sweep / pieces as f64);
@@ -1042,6 +1091,49 @@ pub fn convert_body(
mod tests {
use super::*;
#[test]
fn sampled_round_wall_turn_is_one_circle_without_widening_recognition() {
let center = Vec2::new(80.0, 30.0);
let radius = 8.0;
let mut ring: Vec<(Vec2, Option<u32>)> = (0..128)
.map(|i| {
let angle = 10.0f64.to_radians() - TAU * i as f64 / 128.0;
(center + Vec2::from_angle(angle) * radius, Some((i / 32) as u32))
})
.collect();
// The seam and any of the four wall-face partitions must not matter.
for shift in [0, 7, 31] {
ring.rotate_left(shift);
let loop2 = outline_loop(&ring, &mut |_id| Some((center, radius)), 1e-9);
assert_eq!(loop2.as_circle(), Some((center, radius)));
}
ring.reverse();
let reversed = outline_loop(&ring, &mut |_id| Some((center, radius)), 1e-9);
assert_eq!(reversed.as_circle(), Some((center, radius)));
// Repeated turns, reversal midway and an untagged chord are not circles.
let twice: Vec<(Vec2, Option<u32>)> = (0..128)
.map(|i| {
let angle = 10.0f64.to_radians() - 2.0 * TAU * i as f64 / 128.0;
(center + Vec2::from_angle(angle) * radius, Some((i / 32) as u32))
})
.collect();
assert!(outline_loop(&twice, &mut |_id| Some((center, radius)), 1e-9).as_circle().is_none());
let backtrack: Vec<(Vec2, Option<u32>)> = (0..128)
.map(|i| {
let step = if i <= 64 { i } else { 128 - i };
let angle = 10.0f64.to_radians() - TAU * step as f64 / 128.0;
(center + Vec2::from_angle(angle) * radius, Some((i / 32) as u32))
})
.collect();
assert!(outline_loop(&backtrack, &mut |_id| Some((center, radius)), 1e-9).as_circle().is_none());
let mut missing_wall = ring;
missing_wall[17].1 = None;
assert!(outline_loop(&missing_wall, &mut |_id| Some((center, radius)), 1e-9).as_circle().is_none());
}
#[test]
fn edges_move_in_by_their_own_amounts_and_step_along_one_line() {
let v = Vec2::new;
+1
View File
@@ -46,6 +46,7 @@ log = { workspace = true }
web-time = { workspace = true }
[dev-dependencies]
sha2 = { workspace = true }
# Model-cache tests also run in a source tarball build (no git commit).
solidcraft-io = { workspace = true, features = ["trust-unknown-source"] }
# Normal CI exercises profiling assertions without enabling the production feature.
+9 -31
View File
@@ -479,23 +479,12 @@ fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result<SheetBody> {
}
}
/// The centre and radius of a loop that is one whole circle (often two semicircles).
fn loop_circle(lp: &solidcraft_geom::Loop2) -> Option<(Vec2, f64)> {
use solidcraft_geom::Seg2;
let Some(Seg2::Arc { center, radius, sweep, .. }) = lp.segs.first() else { return None };
let total: f64 = lp.segs.iter().filter_map(|s| if let Seg2::Arc { sweep, .. } = s { Some(*sweep) } else { None }).sum();
let circular = lp.segs.iter().all(
|s| matches!(s, Seg2::Arc { center: c, radius: r, sweep: a, .. } if c.dist(*center) < 1e-9 && (*r - *radius).abs() < 1e-9 && a.signum() == sweep.signum()),
);
(circular && (total.abs() - std::f64::consts::TAU).abs() < 1e-9).then_some((*center, *radius))
}
/// A loop's exact shape for the flat pattern JSON: a circle, or its lines and arcs (other
/// curves as point lists at the flat pattern's 0.001 mm tolerance).
fn loop_shape(lp: &solidcraft_geom::Loop2) -> Value {
use solidcraft_geom::Seg2;
let pt = |v: Vec2| json!([v.x, v.y]);
if let Some((center, radius)) = loop_circle(lp) {
if let Some((center, radius)) = lp.as_circle() {
return json!({"circle": {"center": pt(center), "radius": radius}});
}
let segments: Vec<Value> = lp
@@ -665,7 +654,7 @@ fn bend_note(b: &solidcraft_doc::sheet::BendInfo, height: f64, display: sheet::A
/// per pocket depth and PERFORATION. Inner contours are written before the outline, so a program
/// that follows the file's order cuts inside-out. Free-form curves as `o.curves` says.
pub fn flat_dxf(sh: &SheetBody, o: &DxfOptions) -> Result<String> {
use solidcraft_io::{DxfEntity, DxfShape};
use solidcraft_io::{DxfEntity, DxfPath, DxfPathStyle, DxfShape, path_shapes};
let regions = sh.flat_regions()?;
let pockets = sh.machining()?;
let letters = sh.letters();
@@ -734,25 +723,14 @@ pub fn flat_dxf(sh: &SheetBody, o: &DxfOptions) -> Result<String> {
}
loops.sort_by_key(|(pass, ..)| *pass);
let mut entities = Vec::new();
for (_, h, layer) in loops {
// A circle is often represented by two semicircles. Keep the whole analytic
// boundary as one CIRCLE rather than approximating outer boundaries with chords.
if let Some((center, radius)) = loop_circle(h) {
entities.push(DxfEntity { layer, shape: DxfShape::Circle { c: center, r: radius } });
continue;
}
let fitted;
let h = match o.curves {
DxfCurves::Arcs if !h.is_line_arc() => {
fitted = h
.fit_arcs(solidcraft_kernel::PLANAR_FIT_TOL)
.ok_or_else(|| EngineError::Other("a flat-pattern curve could not be written as arcs".into()))?;
&fitted
}
_ => h,
for (i, (_, h, layer)) in loops.into_iter().enumerate() {
let style = match o.curves {
DxfCurves::Exact => DxfPathStyle::Exact,
DxfCurves::Arcs => DxfPathStyle::Arcs { tolerance_mm: solidcraft_kernel::PLANAR_FIT_TOL },
};
// A whole ellipse is one ELLIPSE; every other segment is an entity of its own.
entities.extend(solidcraft_io::loop_shapes(h).into_iter().map(|shape| DxfEntity { layer: layer.clone(), shape }));
let shapes = path_shapes(DxfPath::Closed(h), style)
.map_err(|e| EngineError::Other(format!("flat-pattern body {:?}, layer {layer:?}, contour {}: {e}", sh.body, i + 1)))?;
entities.extend(shapes.into_iter().map(|shape| DxfEntity { layer: layer.clone(), shape }));
}
for b in sh.bends() {
if !o.no_bend_lines {
+296 -1
View File
@@ -346,7 +346,10 @@ fn the_flat_pattern_is_on_layers_by_role_and_inner_contours_come_first() {
let regions = s.model.state().sheets[0].flat_regions().unwrap_or_default();
let plate =
regions.iter().max_by(|a, b| a.area().total_cmp(&b.area())).map(|r| r.outer.clone()).unwrap_or(solidcraft_geom::Loop2 { segs: Vec::new() });
assert_eq!(on("OUTLINE").len(), solidcraft_io::loop_shapes(&plate).len());
assert_eq!(
on("OUTLINE").len(),
solidcraft_io::path_shapes(solidcraft_io::DxfPath::Closed(&plate), solidcraft_io::DxfPathStyle::Exact).unwrap().len(),
);
assert!(on("OUTLINE").iter().all(|e| e.0 == "LINE" || e.0 == "ARC"), "{:?}", on("OUTLINE").iter().map(|e| &e.0).collect::<Vec<_>>());
// CUT: what is cut out inside it that is no text: the Ø5 hole as one CIRCLE, the slot's four lines.
let cut = on("CUT");
@@ -614,3 +617,295 @@ fn bend_lines_can_be_left_out_and_everything_put_on_layer_0() {
assert!(s.execute("sheet.export_dxf", &bad).is_err(), "{bad}");
}
}
// Outputs recorded from the pre-conversion exporter at 0312808b92d4523894d2af57c99401c65886e24d.
// A fresh-process comparison matched all 53 payloads on 2026-10-26 before conversion changes.
// These original byte pins are fixed expectations, never regenerated by the test.
#[cfg(not(target_arch = "wasm32"))]
mod byte_regressions {
use super::*;
use sha2::{Digest, Sha256};
type ExpectedOutput = (&'static str, usize, &'static str);
const EXPECTED_OUTPUTS: [ExpectedOutput; 53] = [
("circle-cutout-shapes.json", 48, "c634654c54b427c376b74eb30facd6c6d4cfa41ad4ae5dd51d3b31afc76ed458"),
("circle-two-halves-exact.dxf", 3111, "c98ab7a7e331e312525ad99234bb48d5135e38d36a72f8b4bf2be6d3182dffd7"),
("circle-two-halves-arcs.dxf", 3111, "c98ab7a7e331e312525ad99234bb48d5135e38d36a72f8b4bf2be6d3182dffd7"),
("round-ended-slot-exact.dxf", 3486, "ce06b45cc49ce900d82f9a8b532ccac2751fa039abc1fb40046c4ce13c77deb1"),
("round-ended-slot-arcs.dxf", 3486, "ce06b45cc49ce900d82f9a8b532ccac2751fa039abc1fb40046c4ce13c77deb1"),
("ellipse-exact.dxf", 3199, "6a8b64f71ee0c4b1fd7b9d63ab0709c6c1878a65cf05281220b49730684a4f89"),
("ellipse-arcs.dxf", 14381, "afe1c9fa34d3982108a8d56d2d11af9b14c8788385e04222256f71819686597d"),
("cubic-spline-exact.dxf", 4109, "fedda740d4e0e38c501730385adcd42b635887fef300d6a9a59e48c160b9fa19"),
("cubic-spline-arcs.dxf", 11094, "209e16b90beb40b99f54d29de98affee686ab97d906078976cc55ba1e99fdf77"),
("degree-nine-exact.dxf", 3974, "a32cc2e579b520e1d0d4e47a37bf58e6800da9758b2b4d95b34ec55048cd4705"),
("degree-nine-arcs.dxf", 11885, "42eeadbdc9963a7b9602a47789a02b98951c6c2d0c628198760a4c5eeddd5b1c"),
("text-outlines-exact.dxf", 11267, "23953beaa0a0ab2b9ac6938b9705d8f9b88ad3df02e6972a5013aaaab644611f"),
("text-outlines-arcs.dxf", 23531, "c3adba4518e067d1c78d5e63c6203029cb741630f14447e43ee26f96fb20876c"),
("tray-default.dxf", 24381, "1239b1d78db1f5c3e5a3d6a0cfd43530e51f5548f669d3411b94fd179b1592c6"),
("tray-exact-cut-bends-true-roles.dxf", 25300, "c58cf63afb438c80943541cfaf85b1b40c8cc2a08f7ded78ecd826b8d6159394"),
("tray-exact-cut-bends-true-single.dxf", 24167, "f8ede11b566e9f8bc547fec9e7fda816a8d50b51c2c9e47eaa474ca303e6090f"),
("tray-exact-cut-bends-false-roles.dxf", 24758, "90a3f799c989acc69c5d968486bd20eea4eca33630d1849183c3f32f780c05b4"),
("tray-exact-cut-bends-false-single.dxf", 23651, "cc6498088bd2edcb9ccf115c665dc3b5fb4dc56bf4c7366ac2f1ced1a922cb16"),
("tray-exact-etch-bends-true-roles.dxf", 25300, "df421a23079401e89ba029620e2e377a4a5c4cc1a386391abeb0ad2885814d47"),
("tray-exact-etch-bends-true-single.dxf", 24167, "03606ca21dcc3f8bc3f68f6fc0af8f459162dab6ed6b444f10b67f229876f093"),
("tray-exact-etch-bends-false-roles.dxf", 24758, "0cd8654b1da7505ba13124f9f0a0ecfeb4687a0c0fcaa25dd87edabac1f63c3a"),
("tray-exact-etch-bends-false-single.dxf", 23651, "14a3ea13772c7f64675c6ff0a017df56664cd89a48ecced2c600a07cba95c8d6"),
("tray-arcs-cut-bends-true-roles.dxf", 42484, "e298e9eb113995884a4764210463b86b57ec30080213b182aedcee9e46d1912b"),
("tray-arcs-cut-bends-true-single.dxf", 40982, "2346420e716e8325fd513919f323c1d22fa871694e88b720f034d2018ff18743"),
("tray-arcs-cut-bends-false-roles.dxf", 41938, "6988b20d8030ee46071ad0c8e86f65d54d1be3755b7ead04a2bb8c21021defde"),
("tray-arcs-cut-bends-false-single.dxf", 40462, "4d9b84ba711f7421bcd1c6833d12ad47f3da16ac949f32347b8392459f0495d1"),
("tray-arcs-etch-bends-true-roles.dxf", 42484, "e0eda322c4c75619de535bfd279f4e5616d22f30fdfa7ee0c65b86366ac5b796"),
("tray-arcs-etch-bends-true-single.dxf", 40982, "19dd6d5cdfbf8177f28d74fd29f1760c54b122071dc61401df86f16bfd744fa3"),
("tray-arcs-etch-bends-false-roles.dxf", 41938, "41022f19af172e40307dd37abac43982979bc42992071643dab48de2a59aba01"),
("tray-arcs-etch-bends-false-single.dxf", 40462, "8e6a94cb3a126de3268783e55bde1c7c8c145a202384bbf446ef336f1cdc31e7"),
("rule-bend-down-exact-bends-true-roles.dxf", 4065, "b927b9ffe6e49e6289e719b57a8ad8e204fa44dbeb0a23fcdf65aa5a116dbe87"),
("rule-bend-down-exact-bends-false-single.dxf", 3308, "f97cc60bbe0cab1b75bf7965d750565b2593183e029699915c8ac6bd3ac0b9c0"),
("rule-bend-down-arcs-bends-true-roles.dxf", 4065, "b927b9ffe6e49e6289e719b57a8ad8e204fa44dbeb0a23fcdf65aa5a116dbe87"),
("rule-bend-down-arcs-bends-false-single.dxf", 3308, "f97cc60bbe0cab1b75bf7965d750565b2593183e029699915c8ac6bd3ac0b9c0"),
("rule-included-down-exact-bends-true-roles.dxf", 4066, "3ee6825be836054c2869ba959d611292d34c8b109746ba4b98b82f6cdc064aa2"),
("rule-included-down-exact-bends-false-single.dxf", 3309, "515ab9cace64fe66758cfb601b7d9614f2e62d35d8a1efbeb8ef1d6890130d1d"),
("rule-included-down-arcs-bends-true-roles.dxf", 4066, "3ee6825be836054c2869ba959d611292d34c8b109746ba4b98b82f6cdc064aa2"),
("rule-included-down-arcs-bends-false-single.dxf", 3309, "515ab9cace64fe66758cfb601b7d9614f2e62d35d8a1efbeb8ef1d6890130d1d"),
("pocket-and-engraved-text-exact.dxf", 20843, "df0b996889dc152e66b01b6d56f35337edd109b15d55ab5693ea86c079d14a22"),
("pocket-and-engraved-text-arcs.dxf", 37997, "8d92294fc2ab84d73fbae2df73f5060252db9b54f8c0c040ebf05f6102568add"),
("perforation-exact.dxf", 6480, "703ac2570c91da7d4fa41e889e9f03e48611c9fee91876325be877ab7c2aa24c"),
("perforation-arcs.dxf", 6480, "703ac2570c91da7d4fa41e889e9f03e48611c9fee91876325be877ab7c2aa24c"),
("per-body-0-exact.dxf", 3014, "19bda8ed16b26471a3b417147d7cc4642fda9bdea548f6c61725100944fbdbc6"),
("per-body-1-exact.dxf", 3734, "0c7d54b955202fd01cc4771cacbfe7cdb4fbbbfd6944fbba3fc1f1718ce6f148"),
("per-body-0-arcs.dxf", 3014, "19bda8ed16b26471a3b417147d7cc4642fda9bdea548f6c61725100944fbdbc6"),
("per-body-1-arcs.dxf", 3734, "0c7d54b955202fd01cc4771cacbfe7cdb4fbbbfd6944fbba3fc1f1718ce6f148"),
("writer-bmp-controls.dxf", 2430, "4fd801becee559ff017c0c84d80b881d8da7cc9e960ea76e00ef311035bcfd73"),
("writer-bmp-cap.dxf", 4138, "d17379aa2ad648e6d356df5b58f2222e28472554248a29407cf932507fafdcfd"),
("unicode-early.txt", 159, "90c374179d2388832a9bbcafbbabd4c3887edde4989eedd9cfcbd48b4199b4f1"),
("unicode-first-supplementary.txt", 159, "e14a0d3eb9fabbc361d3804fb00830c013771ad79ba52f24bd12d7ea8a8a02a0"),
("unicode-last-scalar.txt", 166, "f931160242245ee3cdf4f9b551bb20c2a4883d52114208a6b66d83dffca4a484"),
("unicode-after-cap.txt", 161, "6b415a0446cf33531d23a6768790ea562441eb23b70092e77dea4ee99951c617"),
("unicode-far-after-cap.txt", 163, "87709acb08a4e729b4480182419ad843ab2c7887fc73dd23bc86a3e86f35beaa"),
];
struct BytePins {
expected: &'static [ExpectedOutput; 53],
next: usize,
}
impl BytePins {
fn new(expected: &'static [ExpectedOutput; 53]) -> Self {
Self { expected, next: 0 }
}
fn put(&mut self, name: &str, bytes: &[u8]) {
assert!(!bytes.is_empty(), "empty original-output case: {name}");
let &(expected_name, expected_len, expected_sha256) =
self.expected.get(self.next).unwrap_or_else(|| panic!("unexpected extra original-output case: {name}"));
assert_eq!(name, expected_name, "original-output case {} changed order or name", self.next + 1);
assert_eq!(bytes.len(), expected_len, "original byte length changed for {name}");
let actual_sha256 = format!("{:x}", Sha256::digest(bytes));
assert_eq!(actual_sha256, expected_sha256, "original bytes changed for {name}");
self.next += 1;
}
fn dxf(&mut self, name: &str, text: &str) {
assert!(text.contains("AC1015") && text.ends_with(" 0\nEOF\n"), "incomplete DXF: {name}");
self.put(&format!("{name}.dxf"), text.as_bytes());
}
fn finish(self) {
assert_eq!(self.next, self.expected.len(), "every original-output case must be checked");
}
}
fn modes(capture: &mut BytePins, name: &str, s: &mut Session) {
for curves in ["exact", "arcs"] {
let dxf = export(s, json!({"curves": curves}));
let es = entities(&dxf);
assert!(!es.is_empty());
if curves == "arcs" {
assert!(es.iter().all(|e| matches!(e.0.as_str(), "LINE" | "ARC" | "CIRCLE")));
}
capture.dxf(&format!("{name}-{curves}"), &dxf);
}
}
fn g4_plate() -> Session {
let mut s = plate_with(&[]);
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
let mut line = |a: [i32; 2], b: [i32; 2]| run(&mut s, "sketch.line", json!({"points": [a, b]}))["curves"][0].as_str().unwrap().to_string();
let bottom = line([20, 20], [40, 20]);
let top = line([40, 40], [20, 40]);
line([20, 40], [20, 20]);
run(&mut s, "sketch.blend_curve", json!({"a": format!("{bottom}.end"), "b": format!("{top}.start"), "continuity": "g4"}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
let es = entities(&export(&mut s, json!({})));
assert_eq!(es.iter().filter(|e| e.0 == "SPLINE" && values(e, 71) == [9.0]).count(), 1);
s
}
fn rule_plate(display: &str) -> Session {
let mut s = sheet_of_2mm();
run(&mut s, "sheet.manage_rules", json!({"name": "Sheet2", "angle_display": display}));
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "sheet.flange", json!({"sketch": "Base", "rule": "Sheet2"}));
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2]], "height": 20, "angle": "58 deg", "flip": true}));
s
}
#[test]
fn flat_export_preserves_original_bytes() {
let mut capture = BytePins::new(&EXPECTED_OUTPUTS);
let mut circle = plate_with(&[("sketch.circle.center", json!({"center": [25, 25], "radius": 4}))]);
let circular = loops(&circle).into_iter().find(|lp| lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }))).unwrap();
assert_eq!(circular.segs.len(), 2, "the baseline must exercise a circular hole made of two semicircles");
assert!(circular.segs.iter().all(|s| matches!(s, Seg2::Arc { sweep, .. } if (sweep.abs() - PI).abs() < 1e-9)));
assert_eq!(count(&entities(&export(&mut circle, json!({}))), "CIRCLE"), 1);
let flat = run(&mut circle, "sheet.flat_pattern", json!({}));
assert!(flat["cutout_shapes"].as_array().unwrap().iter().any(|v| v.get("circle").is_some()));
capture.put("circle-cutout-shapes.json", &serde_json::to_vec(&flat["cutout_shapes"]).unwrap());
modes(&mut capture, "circle-two-halves", &mut circle);
let mut slot = plate_with(&[("sketch.slot.center_to_center", json!({"p0": [20, 20], "p1": [40, 20], "width": 6}))]);
let es = entities(&export(&mut slot, json!({})));
assert_eq!((count(&es, "LINE"), count(&es, "ARC"), es.len()), (6, 2, 8));
modes(&mut capture, "round-ended-slot", &mut slot);
let mut ellipse = plate_with(&[("sketch.ellipse", json!({"center": [40, 30], "major": [55, 30], "minor_radius": 8}))]);
assert_eq!(count(&entities(&export(&mut ellipse, json!({}))), "ELLIPSE"), 1);
modes(&mut capture, "ellipse", &mut ellipse);
let mut spline = plate_with(&[
("sketch.spline.fit_point", json!({"points": [[20, 15], [40, 45], [65, 40], [85, 20]]})),
("sketch.line", json!({"points": [[85, 20], [20, 15]]})),
]);
assert!(count(&entities(&export(&mut spline, json!({}))), "SPLINE") > 0);
modes(&mut capture, "cubic-spline", &mut spline);
modes(&mut capture, "degree-nine", &mut g4_plate());
let mut text = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]);
assert!(count(&entities(&export(&mut text, json!({}))), "SPLINE") > 0);
modes(&mut capture, "text-outlines", &mut text);
let mut tray = tray_with(&marks());
let default = export(&mut tray, json!({}));
assert_eq!(default, export(&mut tray, json!({"curves": "exact", "text": "cut", "bend_lines": true, "layers": "roles"})));
capture.dxf("tray-default", &default);
for curves in ["exact", "arcs"] {
for text in ["cut", "etch"] {
for bend_lines in [true, false] {
for layers in ["roles", "single"] {
let dxf = export(
&mut tray,
json!({"curves": curves, "text": text, "bend_lines": bend_lines, "layers": layers, "bend_notes": true}),
);
let es = entities(&dxf);
assert_eq!(count(&es, "TEXT"), 4, "notes remain independent of bend lines");
if layers == "single" {
assert!(es.iter().all(|e| layer_of(e) == "0"));
} else {
let bends = es.iter().filter(|e| matches!(layer_of(e).as_str(), "BEND_UP" | "BEND_DOWN")).count();
assert_eq!(bends, if bend_lines { 4 } else { 0 });
assert!(es.iter().any(|e| layer_of(e) == if text == "cut" { "TEXT" } else { "ETCH" }));
let inner = es.iter().rposition(|e| matches!(layer_of(e).as_str(), "CUT" | "TEXT" | "ETCH")).unwrap();
let outline = es.iter().position(|e| layer_of(e) == "OUTLINE").unwrap();
assert!(inner < outline);
}
capture.dxf(&format!("tray-{curves}-{text}-bends-{bend_lines}-{layers}"), &dxf);
}
}
}
}
for display in ["bend", "included"] {
let mut s = rule_plate(display);
let before = export(&mut s, json!({"bend_notes": true}));
run(
&mut s,
"sheet.manage_rules",
json!({"name": "Other", "thickness": "2 mm", "angle_display": if display == "bend" { "included" } else { "bend" }, "active": true}),
);
assert_eq!(export(&mut s, json!({"bend_notes": true})), before, "the named body's rule wins over the active rule");
for curves in ["exact", "arcs"] {
for (bend_lines, layers) in [(true, "roles"), (false, "single")] {
let dxf = export(&mut s, json!({"curves": curves, "bend_notes": true, "bend_lines": bend_lines, "layers": layers}));
let notes: Vec<_> = entities(&dxf).iter().filter(|e| e.0 == "TEXT").map(|e| string(e, 1)).collect();
assert_eq!(notes, [if display == "included" { "B1 DOWN 122%%d R1" } else { "B1 DOWN 58%%d R1" }]);
capture.dxf(&format!("rule-{display}-down-{curves}-bends-{bend_lines}-{layers}"), &dxf);
}
}
}
let mut pocket = plate_with(&[]);
run(&mut pocket, "sketch.create", json!({"plane": "XY", "name": "Marks"}));
run(&mut pocket, "sketch.rectangle.two_point", json!({"p0": [70, 40], "p1": [90, 45]}));
run(&mut pocket, "sketch.text", json!({"text": "AB-12", "at": [20, 28], "height": 10}));
run(&mut pocket, "sketch.finish", json!({}));
run(&mut pocket, "solid.extrude", json!({"sketch": "Marks", "distance": 1, "operation": "cut"}));
let es = entities(&export(&mut pocket, json!({})));
assert_eq!(on_layer(&es, "POCKET_BOTTOM_Z0.000000_TO_1.000000_MM").len(), 4);
assert!(!on_layer(&es, "ETCH").is_empty());
modes(&mut capture, "pocket-and-engraved-text", &mut pocket);
let mut perforated = plate_with(&[]);
run(&mut perforated, "sheet.perforate", json!({"diameter": 2, "pitch": 12, "margin": 6, "pattern": "square"}));
let es = entities(&export(&mut perforated, json!({})));
assert!(es.iter().any(|e| layer_of(e) == "PERFORATION" && e.0 == "CIRCLE"));
modes(&mut capture, "perforation", &mut perforated);
let mut bodies = lid_and_wall();
for curves in ["exact", "arcs"] {
let out = run(&mut bodies, "sheet.export_dxf", json!({"per_body": true, "curves": curves}));
let files = out["files"].as_array().unwrap();
assert_eq!(files.iter().map(|f| f["body"].as_str().unwrap()).collect::<Vec<_>>(), ["Lid", "Side Wall"]);
for (i, file) in files.iter().enumerate() {
let text = file["dxf"].as_str().unwrap();
assert_eq!(text, export(&mut bodies, json!({"body": file["body"], "curves": curves})));
capture.dxf(&format!("per-body-{i}-{curves}"), text);
}
}
// The writer is the reachable API for arbitrary TEXT. Sheet text is outlines and
// bend notes are generated ASCII; no synthetic public command is added to inject text.
use solidcraft_io::{DxfEntity, DxfLayer, DxfShape, IoError, write_dxf_drawing};
let note = |text: String| DxfEntity {
layer: "NOTES".into(),
shape: DxfShape::Text { at: Vec2::new(30.0, 12.0), height: 3.0, angle_deg: 90.0, text },
};
let layers = [DxfLayer::new("NOTES", 8)];
for (name, text) in
[("writer-bmp-controls", "ASCII %%d %%p %%c ° 中 \u{ffff}\n\t\u{7f}".to_string()), ("writer-bmp-cap", "\u{ffff}".repeat(251))]
{
let dxf = write_dxf_drawing(&layers, &[note(text)]).unwrap();
let es = entities(&dxf);
let value = string(es.iter().find(|e| e.0 == "TEXT").unwrap(), 1);
if name == "writer-bmp-cap" {
assert_eq!(value, "\\U+FFFF".repeat(250));
} else {
// This parser trims the three replaced trailing controls; the full byte pin
// below still includes every space in the actual group-code value.
assert_eq!(value, "ASCII %%d %%p %%c \\U+00B0 \\U+4E2D \\U+FFFF");
assert!(dxf.contains("\\U+FFFF \n"));
}
capture.dxf(name, &dxf);
}
for (name, text, code, position) in [
("unicode-early", "中\u{2000b}".to_string(), "U+2000B", 2),
("unicode-first-supplementary", "\u{10000}".to_string(), "U+10000", 1),
("unicode-last-scalar", "\u{10ffff}".to_string(), "U+10FFFF", 1),
("unicode-after-cap", format!("{}\u{2000b}", "中".repeat(250)), "U+2000B", 251),
("unicode-far-after-cap", format!("{}\u{2000b}", "中".repeat(10_000)), "U+2000B", 10_001),
] {
let entities = [DxfEntity { layer: "OUTLINE".into(), shape: DxfShape::Line(Vec2::ZERO, Vec2::X) }, note(text)];
let error = write_dxf_drawing(&layers, &entities).unwrap_err();
assert!(matches!(error, IoError::Invalid(_)));
let message = error.to_string();
assert!(message.contains("DXF TEXT entity 2 on layer \"NOTES\""));
assert!(message.contains(code) && message.contains(&format!("at position {position}")));
assert!(message.len() < 300);
capture.put(&format!("{name}.txt"), message.as_bytes());
}
capture.finish();
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ pub use chain::{Chain, Closed, Ends, Open, Path, Planar, Ring};
pub use grid::Grid;
pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair};
pub use plane::Plane;
pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, bezier, regions_apart};
pub use profile::{EllipseArc, Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, bezier, conic_ellipse, regions_apart};
pub use unit_vec3::UnitVec3;
pub use vec::{Vec2, Vec3};
+620
View File
@@ -492,7 +492,627 @@ pub struct Loop2 {
pub segs: Vec<Seg2>,
}
/// An elliptical arc with a directed, unwrapped parameter interval. Its points are
/// `center + major * cos(t) + major.perp() * ratio * sin(t)`; `end < start` runs clockwise.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EllipseArc {
pub center: Vec2,
pub major: Vec2,
pub ratio: f64,
pub start: f64,
pub end: f64,
}
/// Recognize the ellipse supporting a rational quadratic with endpoint weights 1.
/// Weights close to 1 and degenerate/non-finite extractions return `None`; callers can
/// retain the original rational curve. The axis ratio is never clamped into range.
pub fn conic_ellipse(a: Vec2, apex: Vec2, b: Vec2, w: f64) -> Option<EllipseArc> {
if !(a.is_finite() && apex.is_finite() && b.is_finite() && w > 1e-9 && w < 1.0 - 1e-6) {
return None;
}
let m = (a + b) * 0.5;
let center = m - (apex - m) * (w * w / (1.0 - w * w));
let alpha = w.acos();
let (u, v) = ((m - center) / w, (b - m) / alpha.sin());
if !center.is_finite() || u.cross(v).abs() < 1e-18 {
return None;
}
let (major, minor, ratio, t) = ellipse_axes(u, v)?;
if major.len() <= 1e-12 {
return None;
}
let (start, end) = if major.cross(minor) > 0.0 { (-alpha - t, alpha - t) } else { (t + alpha, t - alpha) };
Some(EllipseArc { center, major, ratio, start, end })
}
fn ellipse_axes(u: Vec2, v: Vec2) -> Option<(Vec2, Vec2, f64, f64)> {
let (uu, uv, vv) = (u.dot(u), u.dot(v), v.dot(v));
let cross = u.cross(v);
if !(u.is_finite() && v.is_finite() && uu.is_finite() && uv.is_finite() && vv.is_finite() && cross.is_finite()) || cross == 0.0 {
return None;
}
let t = 0.5 * (2.0 * uv).atan2(uu - vv);
if !(2.0 * uv).is_finite() || !(uu - vv).is_finite() {
return None;
}
let major = u * t.cos() + v * t.sin();
let minor = v * t.cos() - u * t.sin();
let ratio = minor.len() / major.len();
(major.is_finite() && minor.is_finite() && ratio.is_finite() && ratio > 0.0 && ratio <= 1.0).then_some((major, minor, ratio, t))
}
// Angular recognition uses the rounding cells of the stored parameters, not a spatial
// tolerance. Each +/- propagates operand radii and one rounding of its result. Radius
// additions round outward. This is a finite-precision recognition policy, not permission
// to snap path vertices or to accept an individual sweep outside [-TAU, TAU].
#[derive(Clone, Copy)]
struct AngleResolution {
value: f64,
radius: f64,
}
fn rounding_radius(value: f64) -> f64 {
let spacing = (value.next_up() - value).max(value - value.next_down());
// Half the minimum subnormal cannot be represented; enclose it outward instead.
(spacing * 0.5).max(f64::from_bits(1))
}
// Error-free addition residual for finite operands whose sum does not overflow.
fn sum_roundoff(a: f64, b: f64) -> f64 {
let sum = a + b;
let virtual_b = sum - a;
(a - (sum - virtual_b)) + (b - virtual_b)
}
impl AngleResolution {
fn new(value: f64) -> Self {
Self { value, radius: rounding_radius(value) }
}
fn add(self, rhs: Self) -> Self {
let value = self.value + rhs.value;
let radius = ((self.radius + rhs.radius).next_up() + rounding_radius(value)).next_up();
Self { value, radius }
}
fn sub(self, rhs: Self) -> Self {
self.add(Self { value: -rhs.value, radius: rhs.radius })
}
fn times_integer(self, factor: f64) -> Self {
let value = self.value * factor;
let radius = ((self.radius * factor.abs()).next_up() + rounding_radius(value)).next_up();
Self { value, radius }
}
fn contains_zero(self) -> bool {
self.value.is_finite() && self.radius.is_finite() && self.value.abs() <= self.radius
}
}
// Fixed precision for whole-ellipse reconstruction. The exact expression is enclosed by
// hi + lo +/- radius; no operation is allowed to enlarge the geometric acceptance cell.
// Crate-private visibility lets the existing area tests inspect the arithmetic certificate.
#[derive(Clone, Copy, Debug)]
pub(super) struct EllipseScalar {
pub(super) hi: f64,
pub(super) lo: f64,
pub(super) radius: f64,
}
fn ellipse_two_sum(a: f64, b: f64) -> Option<(f64, f64)> {
let sum = a + b;
let virtual_b = sum - a;
let virtual_a = sum - virtual_b;
let error_b = b - virtual_b;
let error_a = a - virtual_a;
let error = error_a + error_b;
[sum, virtual_b, virtual_a, error_b, error_a, error].into_iter().all(f64::is_finite).then_some((sum, error))
}
// Arguments are nonnegative bounds. Preserve exact zero rather than inventing uncertainty
// for a TwoSum of exact inputs; nonzero products include underflow in their outward bound.
fn ellipse_bound_add(a: f64, b: f64) -> f64 {
if a == 0.0 {
b
} else if b == 0.0 {
a
} else {
(a + b).next_up()
}
}
fn ellipse_bound_mul(a: f64, b: f64) -> f64 {
if a == 0.0 || b == 0.0 { 0.0 } else { (a * b).next_up() }
}
impl EllipseScalar {
const ZERO: Self = Self { hi: 0.0, lo: 0.0, radius: 0.0 };
const ONE: Self = Self { hi: 1.0, lo: 0.0, radius: 0.0 };
fn checked(hi: f64, lo: f64, radius: f64) -> Option<Self> {
(hi.is_finite() && lo.is_finite() && radius.is_finite() && radius >= 0.0).then_some(Self { hi, lo, radius })
}
pub(super) fn exact(value: f64) -> Option<Self> {
Self::checked(value, 0.0, 0.0)
}
pub(super) fn plus(self, rhs: Self) -> Option<Self> {
let (s, e) = ellipse_two_sum(self.hi, rhs.hi)?;
let (t, f) = ellipse_two_sum(self.lo, rhs.lo)?;
let (u, g) = ellipse_two_sum(e, t)?;
let (v, k) = ellipse_two_sum(s, u)?;
let (l, r1) = ellipse_two_sum(k, g)?;
let (l, r2) = ellipse_two_sum(l, f)?;
let (hi, lo) = ellipse_two_sum(v, l)?;
let radius = ellipse_bound_add(ellipse_bound_add(self.radius, rhs.radius), ellipse_bound_add(r1.abs(), r2.abs()));
Self::checked(hi, lo, radius)
}
fn negated(self) -> Self {
Self { hi: -self.hi, lo: -self.lo, radius: self.radius }
}
pub(super) fn minus(self, rhs: Self) -> Option<Self> {
self.plus(rhs.negated())
}
pub(super) fn times(self, rhs: Self) -> Option<Self> {
let mut product = Self::ZERO;
for (a, b) in [(self.hi, rhs.hi), (self.hi, rhs.lo), (self.lo, rhs.hi), (self.lo, rhs.lo)] {
let p = a * b;
let e = a.mul_add(b, -p);
let (hi, lo) = ellipse_two_sum(p, e)?;
// FMA's residual can underflow: enclose its rounding instead of assuming an
// exact TwoProduct, including when the stored residual happens to be zero.
product = product.plus(Self::checked(hi, lo, rounding_radius(e))?)?;
}
let a_bound = ellipse_bound_add(self.hi.abs(), self.lo.abs());
let b_bound = ellipse_bound_add(rhs.hi.abs(), rhs.lo.abs());
let input_error = ellipse_bound_add(
ellipse_bound_add(ellipse_bound_mul(a_bound, rhs.radius), ellipse_bound_mul(b_bound, self.radius)),
ellipse_bound_mul(self.radius, rhs.radius),
);
Self::checked(product.hi, product.lo, ellipse_bound_add(product.radius, input_error))
}
fn lower(self) -> f64 {
((self.hi - self.lo.abs()).next_down() - self.radius).next_down()
}
pub(super) fn magnitude_bound(self) -> f64 {
ellipse_bound_add(ellipse_bound_add(self.hi.abs(), self.lo.abs()), self.radius)
}
pub(super) fn sign(self) -> Option<i8> {
if self.hi == 0.0 && self.lo == 0.0 && self.radius == 0.0 {
Some(0)
} else if self.lower() > 0.0 {
Some(1)
} else if self.negated().lower() > 0.0 {
Some(-1)
} else {
None
}
}
pub(super) fn divided_by(self, rhs: Self) -> Option<Self> {
let denominator = ((rhs.hi.abs() - rhs.lo.abs()).next_down() - rhs.radius).next_down();
if denominator <= 0.0 || !denominator.is_finite() {
return None;
}
let q0 = Self::exact(self.hi / rhs.hi)?;
let residual = self.minus(q0.times(rhs)?)?;
let q1 = (residual.hi + residual.lo) / rhs.hi;
let (hi, lo) = ellipse_two_sum(q0.hi, q1)?;
let quotient = Self::checked(hi, lo, 0.0)?;
// A/B-Q = (A-QB)/B: the predictor gets one correction, then a certificate.
let residual = self.minus(quotient.times(rhs)?)?;
Self::checked(hi, lo, (residual.magnitude_bound() / denominator).next_up())
}
pub(super) fn square_root(self) -> Option<Self> {
if self.lower() <= 0.0 {
return None;
}
let s0 = Self::exact((self.hi + self.lo).sqrt())?;
let denominator = 2.0 * s0.hi;
if denominator <= 0.0 || !denominator.is_finite() {
return None;
}
let residual = self.minus(s0.times(s0)?)?;
let s1 = (residual.hi + residual.lo) / denominator;
let (hi, lo) = ellipse_two_sum(s0.hi, s1)?;
let root = Self::checked(hi, lo, 0.0)?;
let lower = root.lower();
if lower <= 0.0 {
return None;
}
// |sqrt(A)-Q| = |A-Q²|/(sqrt(A)+Q) <= |A-Q²|/lower(Q).
let residual = self.minus(root.times(root)?)?;
Self::checked(hi, lo, (residual.magnitude_bound() / lower).next_up())
}
fn exact_half(self) -> Option<Self> {
let (hi, lo) = (self.hi * 0.5, self.lo * 0.5);
if self.radius != 0.0 || hi * 2.0 != self.hi || lo * 2.0 != self.lo {
return None;
}
let (hi, lo) = ellipse_two_sum(hi, lo)?;
Self::checked(hi, lo, 0.0)
}
pub(super) fn fits(self, radius: f64) -> bool {
radius.is_finite() && radius >= 0.0 && self.magnitude_bound() <= radius
}
pub(super) fn rounds_to(self, value: f64) -> Option<bool> {
let value = Self::exact(value)?;
let lower = value.plus(Self::exact(value.hi.next_down())?)?.exact_half()?;
let upper = value.plus(Self::exact(value.hi.next_up())?)?.exact_half()?;
// Strict inclusion conservatively refuses ties and every uncertain boundary.
Some(self.minus(lower)?.sign() == Some(1) && upper.minus(self)?.sign() == Some(1))
}
}
pub(super) fn ellipse_local_resolution(min: f64, max: f64) -> Option<f64> {
let extent = EllipseScalar::exact(max)?.minus(EllipseScalar::exact(min)?)?.exact_half()?;
let lower = extent.lower();
if lower <= 0.0 || !lower.is_finite() {
return None;
}
let gap = lower.next_up() - lower;
let radius = gap * 0.5;
// Choose the lower binade if the extent straddles a power of two. Underflow must
// not invent a larger cell. Only original vertices contribute to this budget.
(radius > 0.0 && radius.is_finite() && radius * 2.0 == gap).then_some(radius)
}
#[derive(Clone, Copy)]
struct EllipsePoint {
x: EllipseScalar,
y: EllipseScalar,
}
impl EllipsePoint {
fn relative(point: Vec2, origin: Vec2) -> Option<Self> {
Some(Self {
x: EllipseScalar::exact(point.x)?.minus(EllipseScalar::exact(origin.x)?)?,
y: EllipseScalar::exact(point.y)?.minus(EllipseScalar::exact(origin.y)?)?,
})
}
fn plus(self, rhs: Self) -> Option<Self> {
Some(Self { x: self.x.plus(rhs.x)?, y: self.y.plus(rhs.y)? })
}
fn minus(self, rhs: Self) -> Option<Self> {
Some(Self { x: self.x.minus(rhs.x)?, y: self.y.minus(rhs.y)? })
}
fn divided_by(self, rhs: EllipseScalar) -> Option<Self> {
Some(Self { x: self.x.divided_by(rhs)?, y: self.y.divided_by(rhs)? })
}
fn cross(self, rhs: Self) -> Option<EllipseScalar> {
self.x.times(rhs.y)?.minus(self.y.times(rhs.x)?)
}
fn fits(self, radius: Vec2) -> bool {
self.x.fits(radius.x) && self.y.fits(radius.y)
}
}
#[derive(Clone, Copy)]
struct EllipseSupport {
xx: EllipseScalar,
xy: EllipseScalar,
yy: EllipseScalar,
determinant: EllipseScalar,
}
impl EllipseSupport {
fn new(major: Vec2, ratio: f64) -> Option<Self> {
let (x, y, r) = (EllipseScalar::exact(major.x)?, EllipseScalar::exact(major.y)?, EllipseScalar::exact(ratio)?);
let (x2, y2, r2) = (x.times(x)?, y.times(y)?, r.times(r)?);
let xx = x2.plus(r2.times(y2)?)?;
let yy = y2.plus(r2.times(x2)?)?;
let xy = x.times(y)?.times(EllipseScalar::ONE.minus(r2)?)?;
let determinant = xx.times(yy)?.minus(xy.times(xy)?)?;
(xx.sign() == Some(1) && yy.sign() == Some(1) && determinant.sign() == Some(1)).then_some(Self { xx, xy, yy, determinant })
}
fn inner(self, a: EllipsePoint, b: EllipsePoint) -> Option<EllipseScalar> {
let diagonal = self.yy.times(a.x)?.times(b.x)?.plus(self.xx.times(a.y)?.times(b.y)?)?;
let off_diagonal = self.xy.times(a.x.times(b.y)?.plus(a.y.times(b.x)?)?)?;
diagonal.minus(off_diagonal)?.divided_by(self.determinant)
}
fn witness(self, original: EllipsePoint, radius: Vec2) -> Option<EllipsePoint> {
let norm = self.inner(original, original)?.square_root()?;
let witness = original.divided_by(norm)?;
witness.minus(original)?.fits(radius).then_some(witness)
}
}
#[derive(Clone, Copy)]
struct EllipseCandidate {
a: Vec2,
apex: Vec2,
b: Vec2,
w: f64,
chord2: f64,
}
impl EllipseCandidate {
fn new(a: Vec2, apex: Vec2, b: Vec2, w: f64) -> Option<Self> {
if !(a.is_finite() && apex.is_finite() && b.is_finite() && w > 1e-9 && w < 1.0 - 1e-6) {
return None;
}
let canonical = |p: Vec2| Vec2::new(if p.x == 0.0 { 0.0 } else { p.x }, if p.y == 0.0 { 0.0 } else { p.y });
let (mut a, mut b, apex) = (canonical(a), canonical(b), canonical(apex));
if (b.x, b.y) < (a.x, a.y) {
std::mem::swap(&mut a, &mut b);
}
let delta = EllipsePoint::relative(b, a)?;
let d = Vec2::new(delta.x.hi + delta.x.lo, delta.y.hi + delta.y.lo);
let chord2 = d.len2();
// This finite f64 ranking proposes ONE candidate; it is not a certificate of
// the mathematically longest chord. Exact ties use the same canonical key.
(chord2.is_finite() && chord2 > 0.0).then_some(Self { a, apex, b, w, chord2 })
}
fn key(self) -> [f64; 7] {
[self.a.x, self.a.y, self.b.x, self.b.y, self.apex.x, self.apex.y, self.w]
}
fn ellipse(self, radius: Vec2) -> Option<(Vec2, Vec2, f64)> {
let d = EllipsePoint::relative(self.b, self.a)?;
let p = EllipsePoint::relative(self.apex, self.a)?;
// Rounded local values are used only to propose a triple. Certification below
// reconstructs from the original controls with their subtraction residuals.
let d = Vec2::new(d.x.hi + d.x.lo, d.y.hi + d.y.lo);
let p = Vec2::new(p.x.hi + p.x.lo, p.y.hi + p.y.lo);
let m = d * 0.5;
let h = p - m;
let q = 1.0 - self.w * self.w;
let k = self.w * self.w / q;
let local_center = m - h * k;
let (cx, error_x) = ellipse_two_sum(self.a.x, local_center.x)?;
let (cy, error_y) = ellipse_two_sum(self.a.y, local_center.y)?;
if error_x.abs() > radius.x || error_y.abs() > radius.y {
return None;
}
let (along, chord) = (k / q, 0.25 / q);
let xx = h.x * h.x * along + d.x * d.x * chord;
let xy = h.x * h.y * along + d.x * d.y * chord;
let yy = h.y * h.y * along + d.y * d.y * chord;
if ![q, k, along, chord, xx, xy, yy].into_iter().all(f64::is_finite) || q <= 0.0 || xx <= 0.0 || yy <= 0.0 {
return None;
}
let (mut major, ratio) = if xy == 0.0 {
// Preserve coordinate axes exactly, including the isotropic case.
if xx >= yy { (Vec2::new(xx.sqrt(), 0.0), (yy / xx).sqrt()) } else { (Vec2::new(0.0, yy.sqrt()), (xx / yy).sqrt()) }
} else {
let difference = xx - yy;
let discriminant = (difference * difference + 4.0 * xy * xy).sqrt();
let large = (xx + yy + discriminant) * 0.5;
let small = (xx * yy - xy * xy) / large;
if !discriminant.is_finite() || !large.is_finite() || !small.is_finite() || small <= 0.0 || large <= 0.0 {
return None;
}
let axis = if xx >= yy { Vec2::new(large - yy, xy) } else { Vec2::new(xy, large - xx) };
let length = axis.len();
if !length.is_finite() || length <= 0.0 {
return None;
}
(axis * (large.sqrt() / length), (small / large).sqrt())
};
if !major.is_finite() || !ratio.is_finite() || ratio <= 0.0 || ratio > 1.0 {
return None;
}
if major.x < 0.0 || (major.x == 0.0 && major.y < 0.0) {
major = -major;
}
Some((Vec2::new(cx, cy), major, ratio))
}
}
impl Loop2 {
/// Recognize one directed turn of circular arcs on exactly the same center and radius.
/// Joins and the accumulated sweep must agree at the stored angles' floating-point
/// resolution. No trigonometric endpoint or world-space tolerance is used. Ambiguous
/// phases, gaps, mixed directions, repeated turns and non-arc pieces return `None`.
pub fn as_circle(&self) -> Option<(Vec2, f64)> {
let &Seg2::Arc { center, radius, start: first_start, sweep: first_sweep } = self.segs.first()? else { return None };
if !center.is_finite() || !radius.is_finite() || radius <= 0.0 {
return None;
}
let (mut anchor_index, mut anchor) = (0, first_start);
for (index, seg) in self.segs.iter().enumerate() {
let &Seg2::Arc { center: c, radius: r, start, sweep } = seg else { return None };
if c != center
|| r != radius
|| !start.is_finite()
|| !sweep.is_finite()
|| sweep == 0.0
|| sweep.abs() > TAU
|| sweep.is_sign_positive() != first_sweep.is_sign_positive()
|| start + sweep == start
{
return None;
}
if start < anchor {
(anchor_index, anchor) = (index, start);
}
}
// Fix one stored angle instead of independently widening both sides of every
// joint. All starts must fit this same accumulated phase. Choosing the smallest
// start makes the anchor independent of cyclic permutations of the segment list.
let anchor = AngleResolution { value: anchor, radius: 0.0 };
let mut total = AngleResolution { value: 0.0, radius: 0.0 };
for (index, seg) in self.segs.iter().cycle().skip(anchor_index).take(self.segs.len()).enumerate() {
let &Seg2::Arc { start, sweep, .. } = seg else { return None };
let actual = if index == 0 { AngleResolution { value: 0.0, radius: 0.0 } } else { AngleResolution::new(start).sub(anchor) };
let gap = actual.sub(total);
// Stored angles may use different turns. The selected integer must still
// distinguish adjacent turns; its multiplication carries its own roundoff.
let turns = (gap.value / TAU).round();
if !turns.is_finite() || turns + 1.0 == turns || turns - 1.0 == turns {
return None;
}
let residual = gap.sub(AngleResolution::new(TAU).times_integer(turns));
if residual.radius >= sweep.abs() * 0.5 || !residual.contains_zero() {
return None;
}
total = total.add(AngleResolution::new(sweep));
}
let residual = total.sub(AngleResolution::new(TAU.copysign(first_sweep)));
(residual.radius < TAU * 0.5 && residual.contains_zero()).then_some((center, radius))
}
/// Conservatively recognize one turn of an ellipse from rational quadratic pieces.
/// Four quarter pieces retain their common-parallelogram recognition rules. Other
/// partitions require exact stored joins and reconstruct one fixed returned ellipse:
/// shared vertices and tangent controls must fit componentwise cells no larger than
/// half an ULP of the original vertices' local semiextent; required weights must lie
/// strictly inside their stored rounding cells. This is local reconstruction, not
/// exact identity of the original binary rational curves. No controls are changed.
/// General weights outside `1e-9 < w < 1-1e-6`, unresolved arithmetic, gaps, overlaps
/// and mixed/repeated turns return `None`, so callers can preserve the original pieces.
pub fn as_ellipse(&self) -> Option<(Vec2, Vec2, f64)> {
if self.segs.len() == 4 && self.segs.iter().all(|seg| matches!(seg, Seg2::Conic { w, .. } if *w == std::f64::consts::FRAC_1_SQRT_2)) {
// Preserve both acceptance and rejection of this already-supported case.
return self.as_quarter_ellipse();
}
self.as_general_ellipse()
}
fn as_general_ellipse(&self) -> Option<(Vec2, Vec2, f64)> {
let &Seg2::Conic { a: first, apex, b, w } = self.segs.first()? else { return None };
let mut candidate = EllipseCandidate::new(first, apex, b, w)?;
let (mut min, mut max) = (first, first);
for (seg, next) in self.joints() {
let (&Seg2::Conic { a, apex, b, w }, &Seg2::Conic { a: next_a, .. }) = (seg, next) else { return None };
let proposed = EllipseCandidate::new(a, apex, b, w)?;
if b != next_a {
return None;
}
if proposed.chord2 > candidate.chord2 || (proposed.chord2 == candidate.chord2 && proposed.key() < candidate.key()) {
candidate = proposed;
}
min = Vec2::new(min.x.min(a.x), min.y.min(a.y));
max = Vec2::new(max.x.max(a.x), max.y.max(a.y));
}
let radius = Vec2::new(ellipse_local_resolution(min.x, max.x)?, ellipse_local_resolution(min.y, max.y)?);
let (center, major, ratio) = candidate.ellipse(radius)?;
// Freeze the FINAL returned triple before any reconstruction. No candidate
// retry, axis renormalization, per-piece support or adjustable error allowance.
let support = EllipseSupport::new(major, ratio)?;
let origin = candidate.a;
let local_center = EllipsePoint::relative(center, origin)?;
let first_y = EllipsePoint::relative(first, origin)?.minus(local_center)?;
let first_x = support.witness(first_y, radius)?;
let (mut from_y, mut from_x) = (first_y, first_x);
let (mut direction, mut winding) = (None, 0_i32);
for (index, seg) in self.segs.iter().enumerate() {
let &Seg2::Conic { apex, b, w, .. } = seg else { return None };
let (to_y, to_x) = if index == self.segs.len() - 1 {
(first_y, first_x)
} else {
let y = EllipsePoint::relative(b, origin)?.minus(local_center)?;
(y, support.witness(y, radius)?)
};
let apex_y = EllipsePoint::relative(apex, origin)?.minus(local_center)?;
let sign = from_y.cross(to_y)?.sign()?;
// These three signs put the rational numerator in one open cone of width
// < pi and give its angular derivative a constant nonzero sign.
if sign == 0
|| from_y.cross(apex_y)?.sign() != Some(sign)
|| apex_y.cross(to_y)?.sign() != Some(sign)
|| direction.is_some_and(|previous| previous != sign)
{
return None;
}
direction = Some(sign);
let h = support.inner(from_x, to_x)?;
let denominator = EllipseScalar::ONE.plus(h)?;
if denominator.sign() != Some(1) || EllipseScalar::ONE.minus(h)?.sign() != Some(1) {
return None;
}
// The witnesses, required apex and positive minor-arc weight satisfy all
// five homogeneous conic identities on the SAME fixed support by construction.
let required_apex = from_x.plus(to_x)?.divided_by(denominator)?;
let required_weight = denominator.times(EllipseScalar::exact(0.5)?)?.square_root()?;
if !required_apex.minus(apex_y)?.fits(radius) || !required_weight.rounds_to(w)? {
return None;
}
let (from_sign, to_sign) = (from_y.y.sign()?, to_y.y.sign()?);
if (from_sign <= 0 && to_sign > 0 && sign > 0) || (to_sign <= 0 && from_sign > 0 && sign < 0) {
winding = winding.checked_add(i32::from(sign))?;
}
(from_y, from_x) = (to_y, to_x);
}
// Each source conic and its chord stay in the same open cone, so this half-open
// polygon crossing count is also the curve's winding. No angle sum is needed.
(winding == 1 || winding == -1).then_some((center, major, ratio))
}
fn as_quarter_ellipse(&self) -> Option<(Vec2, Vec2, f64)> {
let [q0, q1, q2, q3] = self.segs.as_slice() else { return None };
let (&Seg2::Conic { a: p0, .. }, &Seg2::Conic { a: p1, .. }, &Seg2::Conic { a: p2, .. }, &Seg2::Conic { a: p3, .. }) = (q0, q1, q2, q3)
else {
return None;
};
let center = (p0 + p2) * 0.5;
if !center.is_finite() || center != (p1 + p3) * 0.5 {
return None;
}
let offsets = [p0 - center, p1 - center, p2 - center, p3 - center];
if offsets.iter().any(|d| !d.is_finite()) {
return None;
}
let extent = offsets.iter().fold(Vec2::ZERO, |size, d| Vec2::new(size.x.max(d.x.abs()), size.y.max(d.y.abs())));
// Do not lose a significant LOCAL displacement when averaging large world
// coordinates. TwoSum recovers what the world-coordinate sum discarded; half
// that residue must fit the rounding cell of the local extent in each axis.
for (a, b) in [(p0, p2), (p1, p3)] {
let error = Vec2::new(sum_roundoff(a.x, b.x), sum_roundoff(a.y, b.y)) * 0.5;
if !error.is_finite() || error.x.abs() > rounding_radius(extent.x) || error.y.abs() > rounding_radius(extent.y) {
return None;
}
}
// A rounded world-coordinate sum can hide asymmetric endpoints. A displacement
// from either endpoint must reconstruct BOTH original points. Trying both keeps
// ordinary affine constructions whose inverse subtraction rounds differently.
let opposite = |a: Vec2, b: Vec2| [a - center, center - b].into_iter().find(|&d| center + d == a && center - d == b);
let (u, v) = (opposite(p0, p2)?, opposite(p1, p3)?);
let axes = [u, v, -u, -v];
let mut direction = None;
for ((seg, next), (from, to)) in self.joints().zip(crate::chain::around(&axes)) {
let (&Seg2::Conic { a, apex, b, w }, &Seg2::Conic { a: next_a, .. }) = (seg, next) else { return None };
if !(a.is_finite() && apex.is_finite() && b.is_finite()) || w != std::f64::consts::FRAC_1_SQRT_2 || b != next_a {
return None;
}
// Every endpoint and apex must use the SAME pair of accepted offsets.
// The alternatives only change the association of this affine construction.
if a != center + *from || b != center + *to || (apex != a + *to && apex != b + *from && apex != center + (*from + *to)) {
return None;
}
let cross = from.cross(*to);
if !cross.is_finite() || cross == 0.0 || direction.is_some_and(|positive| positive != cross.is_sign_positive()) {
return None;
}
direction = Some(cross.is_sign_positive());
}
let (mut major, _, ratio, _) = ellipse_axes(u, v)?;
if major.x < 0.0 || (major.x == 0.0 && major.y < 0.0) {
major = -major;
}
Some((center, major, ratio))
}
/// The radius of the largest circle inside the loop, to about `tol` ([`Region2::inradius`]).
pub fn inradius(&self, tol: f64) -> f64 {
max_clearance(&[self.polyline(tol)])
+630
View File
@@ -230,6 +230,636 @@ fn surface_patches_cross_only_hidden_seams() {
assert_eq!(m.surface_patch(0), vec![0]);
}
#[test]
fn profile_circle_recognition_tracks_direction_and_one_turn() {
use std::f64::consts::TAU;
for (center, radius) in [(Vec2::ZERO, 2.0), (Vec2::ZERO, 1e12), (Vec2::new(1e12, -1e12), 1e9)] {
for count in [1, 2, 3, 4] {
for start in [0.0, 0.1, 0.3, 2.7, 100.0, -100.0] {
let sweep = TAU / count as f64;
let circle = Loop2 { segs: (0..count).map(|i| Seg2::Arc { center, radius, start: start + i as f64 * sweep, sweep }).collect() };
for mut path in [circle.clone(), circle.reversed()] {
for _ in 0..count {
assert_eq!(path.as_circle(), Some((center, radius)), "count={count}, start={start}, {path:?}");
path.segs.rotate_left(1);
}
}
}
}
}
let circle = Loop2::circle_from(Vec2::new(1e12, -1e12), 1e9, 0.3);
assert_eq!(circle.as_circle(), Some((Vec2::new(1e12, -1e12), 1e9)));
assert_eq!(circle.reversed().as_circle(), circle.as_circle());
// Each piece may store an equivalent phase from a different turn.
let mut circle = Loop2::circle(Vec2::ZERO, 2.0);
if let Seg2::Arc { start, .. } = &mut circle.segs[1] {
*start = 3.0 * std::f64::consts::PI;
}
for mut path in [circle.clone(), circle.reversed()] {
for _ in 0..2 {
assert_eq!(path.as_circle(), Some((Vec2::ZERO, 2.0)));
path.segs.rotate_left(1);
}
}
}
#[test]
fn profile_circle_recognition_rejects_gaps_retracing_and_invalid_arcs() {
use std::f64::consts::{PI, TAU};
let arc = |start, sweep| Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start, sweep };
for segs in [
vec![],
vec![arc(0.0, PI)],
vec![arc(0.0, PI), arc(0.0, PI)],
vec![arc(0.0, PI), arc(PI + 0.01, PI)],
vec![arc(0.0, PI), arc(PI + 1e-8, PI)],
vec![arc(0.0, PI), arc(PI, -PI)],
vec![arc(0.0, PI), arc(PI, PI), arc(TAU, PI), arc(TAU + PI, PI)],
vec![arc(0.0, TAU), arc(0.0, TAU)],
vec![arc(0.0, PI), Seg2::Line { a: Vec2::new(-2.0, 0.0), b: Vec2::new(2.0, 0.0) }],
] {
let path = Loop2 { segs };
assert_eq!(path.as_circle(), None, "{path:?}");
assert_eq!(path.reversed().as_circle(), None, "{path:?}");
}
for sweep in [0.0, TAU + 1e-13, -TAU - 1e-13, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(Loop2 { segs: vec![arc(0.0, sweep)] }.as_circle(), None);
}
for start in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e20, f64::MAX] {
assert_eq!(Loop2::circle_from(Vec2::ZERO, 2.0, start).as_circle(), None);
}
for radius in [0.0, -1.0, f64::NAN, f64::INFINITY] {
assert_eq!(Loop2::circle(Vec2::ZERO, radius).as_circle(), None);
}
assert_eq!(Loop2::circle(Vec2::new(f64::NAN, 0.0), 2.0).as_circle(), None);
let mut path = Loop2::circle(Vec2::ZERO, 2.0);
path.segs[1] = Seg2::Arc { center: Vec2::ZERO, radius: 2.0_f64.next_up(), start: PI, sweep: PI };
assert_eq!(path.as_circle(), None, "support equality is exact");
path.segs[1] = Seg2::Arc { center: Vec2::new(1e-15, 0.0), radius: 2.0, start: PI, sweep: PI };
assert_eq!(path.as_circle(), None);
// The stored phase resolves an extra 0.125 radians; its rounding cell must not
// close the resulting gap/overlap by independently widening both arc endpoints.
let start = 2.0_f64.powi(49);
let circle = Loop2 { segs: vec![arc(start, PI), arc((start + PI) + 0.125, PI)] };
for mut path in [circle.clone(), circle.reversed()] {
for _ in 0..2 {
assert_eq!(path.as_circle(), None, "{path:?}");
path.segs.rotate_left(1);
}
}
}
fn ellipse_quarters(center: Vec2, u: Vec2, v: Vec2) -> Loop2 {
let axes = [u, v, -u, -v];
Loop2 {
segs: (0..4)
.map(|i| Seg2::Conic {
a: center + axes[i],
apex: (center + axes[i]) + axes[(i + 1) % 4],
b: center + axes[(i + 1) % 4],
w: std::f64::consts::FRAC_1_SQRT_2,
})
.collect(),
}
}
#[test]
fn profile_ellipse_recognition_preserves_quarters_and_orientation() {
for (center, u, v) in
[(Vec2::new(3.0, -2.0), Vec2::new(5.0, 0.0), Vec2::new(0.0, 2.0)), (Vec2::new(17.0, -11.0), Vec2::new(12.0, 9.0), Vec2::new(-4.8, 6.4))]
{
let ellipse = ellipse_quarters(center, u, v);
for mut path in [ellipse.clone(), ellipse.reversed()] {
for _ in 0..4 {
let (c, major, ratio) = path.as_ellipse().expect("four original ellipse quarters");
assert_eq!(c, center);
assert!((major.len() - u.len()).abs() < 1e-12, "{major:?}");
assert!((ratio - v.len() / u.len()).abs() < 1e-12, "{ratio}");
for seg in &path.segs {
for t in [0.0, 0.25, 0.5, 0.75, 1.0] {
let d = seg.point_at(t) - c;
let x = d.dot(major) / major.len2();
let y = d.dot(major.perp()) / (major.len2() * ratio);
assert!((x * x + y * y - 1.0).abs() < 1e-12);
}
}
path.segs.rotate_left(1);
}
}
}
}
#[test]
fn profile_ellipse_recognition_does_not_replace_other_paths() {
let ellipse = ellipse_quarters(Vec2::new(3.0, -2.0), Vec2::new(5.0, 0.0), Vec2::new(0.0, 2.0));
let q = ellipse.segs[0];
assert_eq!(Loop2 { segs: vec![q, q.reversed(), q, q.reversed()] }.as_ellipse(), None);
assert_eq!(Loop2 { segs: vec![q; 4] }.as_ellipse(), None);
assert_eq!(Loop2 { segs: ellipse.segs.repeat(2) }.as_ellipse(), None);
assert_eq!(Loop2 { segs: ellipse.segs[..3].to_vec() }.as_ellipse(), None);
assert_eq!(Loop2::circle(Vec2::ZERO, 2.0).as_ellipse(), None);
assert_eq!(Loop2::default().as_ellipse(), None);
for delta in [Vec2::new(0.001, 0.0), Vec2::new(f64::NAN, 0.0)] {
let mut path = ellipse.clone();
if let Seg2::Conic { a, .. } = &mut path.segs[1] {
*a += delta;
}
assert_eq!(path.as_ellipse(), None);
}
for weight in [0.5, 1.0, 1.1, f64::NAN, f64::INFINITY] {
let mut path = ellipse.clone();
if let Seg2::Conic { w, .. } = &mut path.segs[1] {
*w = weight;
}
assert_eq!(path.as_ellipse(), None);
}
// The world-coordinate midpoint rounds to c, but the x displacements are +4/-3
// or +4/-3.5. Even when one offset reconstructs both rounded endpoints, the local
// asymmetry is significant and must not be replaced by a full ellipse.
let c = 2.0_f64.powi(52);
for left in [3.0, 3.5] {
let points = [Vec2::new(c + 4.0, 0.0), Vec2::new(c, 2.0), Vec2::new(c - left, 0.0), Vec2::new(c, -2.0)];
let apices = [Vec2::new(c + 4.0, 2.0), Vec2::new(c - left, 2.0), Vec2::new(c - left, -2.0), Vec2::new(c + 4.0, -2.0)];
let ellipse = Loop2 {
segs: (0..4).map(|i| Seg2::Conic { a: points[i], apex: apices[i], b: points[(i + 1) % 4], w: std::f64::consts::FRAC_1_SQRT_2 }).collect(),
};
for mut path in [ellipse.clone(), ellipse.reversed()] {
for _ in 0..4 {
assert_eq!(path.as_ellipse(), None, "{path:?}");
path.segs.rotate_left(1);
}
}
}
}
#[test]
fn profile_conic_ellipse_keeps_directed_endpoints_and_unclamped_ratio() {
let ellipse = ellipse_quarters(Vec2::new(3.0, -2.0), Vec2::new(5.0, 0.0), Vec2::new(0.0, 2.0));
for seg in &ellipse.segs {
for (piece, positive) in [(*seg, true), (seg.reversed(), false)] {
let Seg2::Conic { a, apex, b, w } = piece else { panic!("fixture must be a conic") };
let arc = conic_ellipse(a, apex, b, w).expect("elliptical quarter");
assert_eq!(arc.end > arc.start, positive);
for (t, expected) in [(arc.start, a), ((arc.start + arc.end) * 0.5, piece.point_at(0.5)), (arc.end, b)] {
let actual = arc.center + arc.major * t.cos() + arc.major.perp() * (arc.ratio * t.sin());
assert!(actual.dist(expected) < 1e-12, "{piece:?}: {actual:?} != {expected:?}");
}
}
}
let thin = ellipse_quarters(Vec2::ZERO, Vec2::new(1e12, 0.0), Vec2::new(0.0, 0.5));
let (_, _, ratio) = thin.as_ellipse().expect("thin ellipse from its original axes");
assert!((ratio - 5e-13).abs() < 1e-25);
for seg in thin.segs {
let Seg2::Conic { a, apex, b, w } = seg else { panic!("fixture must be a conic") };
if let Some(arc) = conic_ellipse(a, apex, b, w) {
assert!(arc.ratio < 1e-12, "a small ratio must not be clamped");
}
}
for w in [0.2, 0.5, 0.8] {
assert!(conic_ellipse(Vec2::X, Vec2::new(1.0, 1.0), Vec2::Y, w).is_some());
}
for w in [-1.0, 0.0, 1.0 - 1e-7, 1.0, 1.1, f64::NAN, f64::INFINITY] {
assert!(conic_ellipse(Vec2::X, Vec2::new(1.0, 1.0), Vec2::Y, w).is_none());
}
assert!(conic_ellipse(Vec2::ZERO, Vec2::X, Vec2::new(2.0, 0.0), 0.5).is_none());
assert!(conic_ellipse(Vec2::ZERO, Vec2::ZERO, Vec2::ZERO, 0.5).is_none());
assert!(conic_ellipse(Vec2::new(f64::INFINITY, 0.0), Vec2::X, Vec2::Y, 0.5).is_none());
}
#[test]
fn profile_conic_ellipse_recovers_nonquarter_analytic_parameters() {
// For A=(0,-2), P=(4,0), B=(0,2), eliminating the rational parameter gives
// (1-w²)x²/(16w²) + x/2 + y²/4 = 1. Completing its square gives these independent
// centers and principal axes. At w=1/5 the longer axis is Y, not X.
let cases = [
(0.2, Vec2::new(-1.0 / 6.0, 0.0), Vec2::new(0.0, 5.0 / 6.0_f64.sqrt()), 1.0 / 6.0_f64.sqrt(), 2.0 / 3.0),
(0.5, Vec2::new(-4.0 / 3.0, 0.0), Vec2::new(8.0 / 3.0, 0.0), 3.0_f64.sqrt() / 2.0, 4.0 / 3.0),
(0.6, Vec2::new(-9.0 / 4.0, 0.0), Vec2::new(15.0 / 4.0, 0.0), 2.0 / 3.0, 1.5),
(0.8, Vec2::new(-64.0 / 9.0, 0.0), Vec2::new(80.0 / 9.0, 0.0), 3.0 / 8.0, 16.0 / 9.0),
];
let (a, apex, b) = (Vec2::new(0.0, -2.0), Vec2::new(4.0, 0.0), Vec2::new(0.0, 2.0));
for (w, center, major, ratio, middle_x) in cases {
for (start, end, direction) in [(a, b, 1.0), (b, a, -1.0)] {
let arc = conic_ellipse(start, apex, end, w).expect("non-quarter elliptic conic");
assert_profile_ellipse_arc(arc, center, major, ratio, direction * 2.0 * w.acos(), [start, Vec2::new(middle_x, 0.0), end]);
}
}
}
fn assert_profile_ellipse_arc(arc: EllipseArc, center: Vec2, major: Vec2, ratio: f64, sweep: f64, points: [Vec2; 3]) {
assert!(arc.center.dist(center) < 1e-12, "center: {arc:?}, expected {center:?}");
// Changing the major-axis sign and shifting both parameters describes the same arc.
let aligned_major = if arc.major.dot(major) < 0.0 { -arc.major } else { arc.major };
assert!(aligned_major.dist(major) < 1e-12, "major: {arc:?}, expected ±{major:?}");
assert!((arc.ratio - ratio).abs() < 1e-12, "ratio: {arc:?}, expected {ratio}");
assert!((arc.end - arc.start - sweep).abs() < 1e-12, "sweep: {arc:?}, expected {sweep}");
// Only the symmetric rational midpoint corresponds to the angular midpoint. The
// rational parameter is not generally a linear fraction of this angular interval.
for (t, expected) in [arc.start, (arc.start + arc.end) * 0.5, arc.end].into_iter().zip(points) {
let actual = arc.center + arc.major * t.cos() + arc.major.perp() * (arc.ratio * t.sin());
assert!(actual.dist(expected) < 1e-12, "point: {actual:?}, expected {expected:?}, {arc:?}");
}
}
#[test]
fn profile_conic_ellipse_preserves_transformed_partial_arcs() {
// Apply T(x,y)=(7-2y,-5+2x) to the w=3/5 conic above: rotate 90°, scale 2,
// translate. Its independent center is (7,-19/2), semiaxes 15/2 and 5.
// Reflecting Y then reverses the orientation while keeping both axis lengths.
for reflection in [1.0, -1.0] {
let (a, apex, b) = (Vec2::new(11.0, -5.0 * reflection), Vec2::new(7.0, 3.0 * reflection), Vec2::new(3.0, -5.0 * reflection));
for (start, end, direction) in [(a, b, reflection), (b, a, -reflection)] {
let arc = conic_ellipse(start, apex, end, 0.6).expect("transformed non-quarter elliptic conic");
assert_profile_ellipse_arc(
arc,
Vec2::new(7.0, -9.5 * reflection),
Vec2::new(0.0, 7.5),
2.0 / 3.0,
direction * 2.0 * 0.6_f64.acos(),
[start, Vec2::new(7.0, -2.0 * reflection), end],
);
}
}
}
#[test]
fn profile_conic_ellipse_declines_nonelliptic_weights_and_conservative_boundaries() {
let (a, apex, b) = (Vec2::new(0.0, -2.0), Vec2::new(4.0, 0.0), Vec2::new(0.0, 2.0));
for w in [
-f64::MAX,
-1.0,
-0.0,
0.0,
1e-9_f64.next_down(),
1e-9,
1.0 - 1e-6,
1.0 - 1e-7,
1.0_f64.next_down(),
1.0,
1.0_f64.next_up(),
1.5,
f64::MAX,
f64::NAN,
f64::INFINITY,
f64::NEG_INFINITY,
] {
assert!(conic_ellipse(a, apex, b, w).is_none(), "w={w}");
assert!(conic_ellipse(b, apex, a, w).is_none(), "reversed w={w}");
}
// Small positive weights and weights just below 1 still describe ellipses, but the
// inherited extractor guards conservatively retain their original rational curves.
// At w=1 this noncollinear conic is parabolic; above 1 it is hyperbolic.
}
#[test]
fn profile_conic_ellipse_declines_nonfinite_controls_in_every_position() {
let original = [Vec2::new(0.0, -2.0), Vec2::new(4.0, 0.0), Vec2::new(0.0, 2.0)];
for position in 0..3 {
for coordinate in 0..2 {
for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut controls = original;
if coordinate == 0 {
controls[position].x = invalid
} else {
controls[position].y = invalid
}
let [a, apex, b] = controls;
assert!(conic_ellipse(a, apex, b, 0.6).is_none(), "control={position}, coordinate={coordinate}, value={invalid}");
}
}
}
}
#[test]
fn profile_conic_ellipse_declines_degeneracy_and_unrepresentable_extraction() {
for [a, apex, b] in [
[Vec2::new(3.0, -2.0); 3],
[Vec2::ZERO, Vec2::X, Vec2::new(2.0, 0.0)],
[Vec2::new(-3.0, -5.0), Vec2::new(1.0, 3.0), Vec2::new(5.0, 11.0)],
[Vec2::new(0.0, -2.0), Vec2::new(4.0, 0.0), Vec2::new(0.0, -2.0)],
[Vec2::new(0.0, -2.0), Vec2::new(0.0, -2.0), Vec2::new(0.0, 2.0)],
[Vec2::new(0.0, -2.0), Vec2::new(0.0, 2.0), Vec2::new(0.0, 2.0)],
// Finite inputs can still overflow the derived dot products or chord midpoint.
[Vec2::new(f64::MAX, 0.0), Vec2::new(f64::MAX, f64::MAX), Vec2::new(0.0, f64::MAX)],
[Vec2::new(f64::MAX, 0.0), Vec2::new(f64::MAX, 1.0), Vec2::new(f64::MAX, 2.0)],
] {
assert!(conic_ellipse(a, apex, b, 0.6).is_none(), "{a:?}, {apex:?}, {b:?}");
assert!(conic_ellipse(b, apex, a, 0.6).is_none());
}
// A real but very small ellipse is also outside the inherited extraction guard;
// this is a conservative recognition miss, not a claim of geometric degeneracy.
assert!(conic_ellipse(Vec2::new(0.0, -2e-10), Vec2::new(4e-10, 0.0), Vec2::new(0.0, 2e-10), 0.6).is_none());
}
fn ellipse_unequal_pieces(three: bool) -> Loop2 {
// Original integer controls on x²/225+y²/400=1. The three-piece neighboring
// unit-ellipse dot products are -3/5, -7/25, -3/5; their weights are
// sqrt((1+h)/2) = 1/sqrt(5), 3/5, 1/sqrt(5), rounded to nearest binary64.
let pieces = if three {
vec![
(Vec2::new(15.0, 0.0), Vec2::new(15.0, 40.0), Vec2::new(-9.0, 16.0), 0.4472135954999579),
(Vec2::new(-9.0, 16.0), Vec2::new(-25.0, 0.0), Vec2::new(-9.0, -16.0), 0.6),
(Vec2::new(-9.0, -16.0), Vec2::new(15.0, -40.0), Vec2::new(15.0, 0.0), 0.4472135954999579),
]
} else {
vec![
(Vec2::new(9.0, -16.0), Vec2::new(25.0, 0.0), Vec2::new(9.0, 16.0), 0.6),
(Vec2::new(9.0, 16.0), Vec2::new(0.0, 25.0), Vec2::new(-9.0, 16.0), 0.8),
(Vec2::new(-9.0, 16.0), Vec2::new(-25.0, 0.0), Vec2::new(-9.0, -16.0), 0.6),
(Vec2::new(-9.0, -16.0), Vec2::new(0.0, -25.0), Vec2::new(9.0, -16.0), 0.8),
]
};
Loop2 { segs: pieces.into_iter().map(|(a, apex, b, w)| Seg2::Conic { a, apex, b, w }).collect() }
}
fn transform_ellipse_pieces(path: &Loop2, center: Vec2, scale: f64, rotated: bool, reflected: bool) -> Loop2 {
let transform = |p: Vec2| {
let p = if rotated { Vec2::new(-p.y, p.x) } else { p };
let p = if reflected { Vec2::new(p.x, -p.y) } else { p };
center + p * scale
};
Loop2 {
segs: path
.segs
.iter()
.map(|seg| match *seg {
Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: transform(a), apex: transform(apex), b: transform(b), w },
_ => panic!("analytic fixture contains only conics"),
})
.collect(),
}
}
#[test]
fn profile_ellipse_recognizes_unequal_integer_partitions_through_the_real_candidate() {
let far = 2.0_f64.powi(52);
for three in [false, true] {
let original = ellipse_unequal_pieces(three);
for (center, scale) in [(Vec2::ZERO, 1.0), (Vec2::new(7.0, -5.0), 2.0), (Vec2::new(far, far), 1.0)] {
for rotated in [false, true] {
for reflected in [false, true] {
let transformed = transform_ellipse_pieces(&original, center, scale, rotated, reflected);
let major = if rotated { Vec2::new(20.0 * scale, 0.0) } else { Vec2::new(0.0, 20.0 * scale) };
for mut path in [transformed.clone(), transformed.reversed()] {
for _ in 0..path.segs.len() {
assert_eq!(path.as_ellipse(), Some((center, major, 0.75)), "three={three}, {path:?}");
path.segs.rotate_left(1);
}
}
}
}
}
// T(x,y)=(3x-4y,4x+3y) preserves integer controls. Its support is
// [[8425,-2100],[-2100,7200]], with eigenvalues 10000 and 5625.
// This exercises both the non-diagonal candidate and the support cross term.
let transform = |p: Vec2| Vec2::new(3.0 * p.x - 4.0 * p.y, 4.0 * p.x + 3.0 * p.y);
let inclined = Loop2 {
segs: original
.segs
.iter()
.map(|seg| match *seg {
Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: transform(a), apex: transform(apex), b: transform(b), w },
_ => panic!("analytic fixture contains only conics"),
})
.collect(),
};
assert!(inclined.is_closed(0.0));
for mut path in [inclined.clone(), inclined.reversed()] {
for _ in 0..path.segs.len() {
assert_eq!(path.as_ellipse(), Some((Vec2::ZERO, Vec2::new(80.0, -60.0), 0.75)), "three={three}, {path:?}");
path.segs.rotate_left(1);
}
}
}
}
#[test]
fn profile_ellipse_rejects_distinct_supports_even_with_shared_endpoints() {
for three in [false, true] {
let path = ellipse_unequal_pieces(three);
let mut different_apex = path.clone();
if let Seg2::Conic { apex, .. } = &mut different_apex.segs[0] {
apex.x += 1e-8;
}
let mut different_weight = path.clone();
if let Seg2::Conic { w, .. } = &mut different_weight.segs[0] {
*w += 1e-8;
}
let mut different_vertex = path.clone();
if let Seg2::Conic { b, .. } = &mut different_vertex.segs[0] {
b.x += 1e-8;
}
if let Seg2::Conic { a, .. } = &mut different_vertex.segs[1] {
a.x += 1e-8;
}
let mut different_half = path.clone();
if let Seg2::Conic { apex, .. } = &mut different_half.segs[1] {
apex.y += 0.25;
}
for changed in [different_apex, different_weight, different_vertex, different_half] {
assert!(changed.is_closed(0.0));
for mut ordered in [changed.clone(), changed.reversed()] {
for _ in 0..ordered.segs.len() {
assert_eq!(ordered.as_ellipse(), None, "{ordered:?}");
ordered.segs.rotate_left(1);
}
}
}
}
}
#[test]
fn profile_ellipse_rejects_gaps_retracing_and_repeated_unequal_turns() {
let ellipse = ellipse_unequal_pieces(false);
let q = ellipse.segs[0];
let mut one_step_gap = ellipse.clone();
if let Seg2::Conic { a, .. } = &mut one_step_gap.segs[1] {
a.x = a.x.next_up();
}
let mut reordered = ellipse.clone();
reordered.segs.swap(1, 2);
for path in [
one_step_gap,
reordered,
Loop2 { segs: vec![q, q.reversed(), q, q.reversed()] },
Loop2 { segs: vec![q; 4] },
Loop2 { segs: ellipse.segs.repeat(2) },
Loop2 { segs: ellipse.segs[..3].to_vec() },
Loop2 { segs: vec![q, Seg2::Line { a: Vec2::X, b: Vec2::ZERO }] },
] {
assert_eq!(path.as_ellipse(), None, "{path:?}");
assert_eq!(path.reversed().as_ellipse(), None, "{path:?}");
}
}
#[test]
fn profile_ellipse_uses_local_cells_at_large_world_coordinates() {
let c = 2.0_f64.powi(52);
let original = transform_ellipse_pieces(&ellipse_unequal_pieces(false), Vec2::new(c, c), 1.0, false, false);
assert!(original.as_ellipse().is_some(), "large coordinates alone are not a refusal");
let mut changed = original.clone();
if let Seg2::Conic { b, .. } = &mut changed.segs[1] {
b.x += 0.5;
}
if let Seg2::Conic { a, .. } = &mut changed.segs[2] {
a.x += 0.5;
}
assert!(changed.is_closed(0.0));
assert_eq!(changed.as_ellipse(), None, "a resolved local displacement cannot fit a world-coordinate allowance");
// The canonical left chord still has length32. Changing its apex from -25 to -24
// proposes a center with a fractional offset that cannot survive addition to c.
let mut center_loss = original.clone();
if let Seg2::Conic { apex, .. } = &mut center_loss.segs[2] {
apex.x += 1.0;
}
assert_eq!(center_loss.as_ellipse(), None, "final-center loss must fit the local cell");
let mut outlier = original;
if let Seg2::Conic { apex, .. } = &mut outlier.segs[0] {
apex.x = 1e100;
}
assert_eq!(outlier.as_ellipse(), None, "an apex cannot enlarge the vertex budget");
}
#[test]
fn profile_ellipse_declines_hostile_general_partitions() {
let original = ellipse_unequal_pieces(false);
for weight in [0.0, -1.0, 1e-9, 1.0 - 1e-6, 1.0, 1.1, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let mut path = original.clone();
if let Seg2::Conic { w, .. } = &mut path.segs[0] {
*w = weight;
}
assert_eq!(path.as_ellipse(), None, "w={weight}");
}
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
for control in 0..3 {
for coordinate in 0..2 {
let mut path = original.clone();
if let Seg2::Conic { a, apex, b, .. } = &mut path.segs[0] {
let point = match control {
0 => a,
1 => apex,
_ => b,
};
if coordinate == 0 { point.x = value } else { point.y = value }
}
assert_eq!(path.as_ellipse(), None);
}
}
}
for scale in [0.0, 1e-200, 1e200] {
assert_eq!(transform_ellipse_pieces(&original, Vec2::ZERO, scale, false, false).as_ellipse(), None);
}
let mut collinear = original.clone();
for seg in &mut collinear.segs {
if let Seg2::Conic { a, apex, b, .. } = seg {
a.y = 0.0;
apex.y = 0.0;
b.y = 0.0;
}
}
assert_eq!(collinear.as_ellipse(), None);
// Keep exact joins and nonzero chords. The left chord of length 32 supplies
// the known support; the first piece has antipodal endpoints on that support.
let antipodal = Loop2 {
segs: vec![
Seg2::Conic { a: Vec2::new(15.0, 0.0), apex: Vec2::new(0.0, 20.0), b: Vec2::new(-15.0, 0.0), w: 0.6 },
Seg2::Conic { a: Vec2::new(-15.0, 0.0), apex: Vec2::new(-15.0, -10.0), b: Vec2::new(-9.0, -16.0), w: 0.8944271909999159 },
Seg2::Conic { a: Vec2::new(-9.0, -16.0), apex: Vec2::new(-25.0, 0.0), b: Vec2::new(-9.0, 16.0), w: 0.6 },
Seg2::Conic { a: Vec2::new(-9.0, 16.0), apex: Vec2::new(15.0, 40.0), b: Vec2::new(15.0, 0.0), w: 0.4472135954999579 },
],
};
assert!(antipodal.is_closed(0.0));
assert!(antipodal.segs.iter().all(|seg| matches!(*seg, Seg2::Conic { a, b, .. } if a != b)));
assert_eq!(antipodal.as_ellipse(), None);
}
#[test]
fn profile_ellipse_arithmetic_retains_exact_dyadic_residuals() {
use crate::profile::EllipseScalar as Number;
let number = |x| Number::exact(x).expect("finite dyadic fixture");
let large = 2.0_f64.powi(53);
let sum = number(large).plus(number(1.0)).expect("finite sum");
assert_eq!((sum.hi, sum.lo, sum.radius), (large, 1.0, 0.0));
let cancellation = sum.minus(number(large)).expect("exact cancellation");
assert_eq!((cancellation.hi, cancellation.lo, cancellation.radius), (1.0, 0.0, 0.0));
let e = 2.0_f64.powi(-27);
let product = number(1.0 + e).times(number(1.0 - e)).expect("finite product");
// (1+2^-27)(1-2^-27) = 1-2^-54; the low term cannot be discarded.
assert_eq!((product.hi, product.lo), (1.0, -e * e));
assert!(product.radius < 2.0_f64.powi(-100));
let third = 2.0_f64.powi(-108);
let three_terms = number(1.0).plus(number(2.0_f64.powi(-54))).expect("pair").plus(number(third)).expect("three terms");
assert!(three_terms.radius >= third, "a discarded third component remains in the radius");
}
#[test]
fn profile_ellipse_arithmetic_certifies_quotients_and_roots() {
use crate::profile::EllipseScalar as Number;
let number = |x| Number::exact(x).expect("finite fixture");
let dyadic = number(1.0).plus(number(2.0_f64.powi(-53))).expect("exact two-term numerator");
let quotient = dyadic.divided_by(number(2.0)).expect("dyadic quotient");
assert_eq!((quotient.hi, quotient.lo), (0.5, 2.0_f64.powi(-54)));
assert!(quotient.radius < 2.0_f64.powi(-100));
let third = number(1.0).divided_by(number(3.0)).expect("one third");
let correction = third.minus(number(1.0 / 3.0)).expect("retain correction");
// The exact correction is 1/(3*2^54), strictly between these independent dyadics.
assert_eq!(correction.minus(number(2.0_f64.powi(-56))).expect("lower comparison").sign(), Some(1));
assert_eq!(number(2.0_f64.powi(-55)).minus(correction).expect("upper comparison").sign(), Some(1));
assert!(third.radius < 2.0_f64.powi(-100));
let e = 2.0_f64.powi(-27);
let square = number(1.0 + 2.0 * e).plus(number(e * e)).expect("(1+e)^2 exactly");
let root = square.square_root().expect("positive exact-dyadic square");
assert!(root.minus(number(1.0 + e)).expect("compare analytic root").fits(2.0_f64.powi(-100)));
}
#[test]
fn profile_ellipse_arithmetic_refuses_uncertainty_and_preserves_subnormal_bounds() {
use crate::profile::EllipseScalar as Number;
let number = |x| Number::exact(x).expect("finite fixture");
for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert!(Number::exact(invalid).is_none());
}
assert!(number(f64::MAX).plus(number(f64::MAX)).is_none());
assert!(number(f64::MAX).times(number(2.0)).is_none());
assert!(number(1.0).divided_by(number(0.0)).is_none());
assert!(number(-1.0).square_root().is_none());
assert!(number(0.0).square_root().is_none());
let minimum = f64::from_bits(1);
let underflow = number(minimum).times(number(0.5)).expect("half a minimum subnormal is enclosed");
assert_eq!((underflow.hi, underflow.lo), (0.0, 0.0));
assert!(underflow.radius >= minimum);
assert!(!underflow.fits(0.0));
assert_eq!(underflow.sign(), None);
let radius = 2.0_f64.powi(-53);
assert!(number(radius).fits(radius));
assert!(!number(radius.next_up()).fits(radius));
let uncertain = Number { hi: 0.0, lo: 0.0, radius: 2.0 * radius };
assert!(!uncertain.fits(radius), "intersection with the cell does not prove inclusion");
assert!(number(1.0).divided_by(uncertain).is_none());
}
#[test]
fn profile_ellipse_weight_cells_are_asymmetric_and_local_extent_does_not_round_up() {
use crate::profile::{EllipseScalar as Number, ellipse_local_resolution};
let number = |x| Number::exact(x).expect("finite fixture");
let center = number(0.5);
let lower_midpoint = center.minus(number(2.0_f64.powi(-55))).expect("lower midpoint");
let upper_midpoint = center.plus(number(2.0_f64.powi(-54))).expect("upper midpoint");
assert_eq!(lower_midpoint.rounds_to(0.5), Some(false));
assert_eq!(upper_midpoint.rounds_to(0.5), Some(false));
assert_eq!(center.minus(number(2.0_f64.powi(-56))).expect("inside lower cell").rounds_to(0.5), Some(true));
assert_eq!(center.plus(number(2.0_f64.powi(-55))).expect("inside upper cell").rounds_to(0.5), Some(true));
assert_eq!(center.minus(number(3.0 * 2.0_f64.powi(-56))).expect("outside lower cell").rounds_to(0.5), Some(false));
let local = 2.0_f64.powi(-52);
let c = 2.0_f64.powi(52);
assert_eq!(ellipse_local_resolution(-4.0, 4.0), Some(local));
assert_eq!(ellipse_local_resolution(c - 4.0, c + 4.0), Some(local));
assert_eq!(ellipse_local_resolution(-4.0, 4.0_f64.next_down()), Some(local), "rounded extent must not select a larger binade");
assert!(ellipse_local_resolution(0.0, f64::from_bits(1)).is_none());
assert!(ellipse_local_resolution(-f64::MAX, f64::MAX).is_none());
assert!(ellipse_local_resolution(1.0, 1.0).is_none());
}
#[test]
fn cubic_and_conic_segments_measure_exactly() {
use crate::{Loop2, Seg2, Vec2};
+160 -97
View File
@@ -13,10 +13,14 @@
use std::f64::consts::TAU;
use std::fmt::Write as _;
use solidcraft_geom::{Loop2, MAX_BEZIER_DEGREE, Seg2, Vec2};
use solidcraft_geom::Vec2;
use crate::{IoError, Result};
mod path;
pub use path::{DxfPath, DxfPathStyle, DxfVertex, path_shapes};
/// A layer's linetype, defined in the file's LTYPE table so a viewer needs nothing else.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DxfLinetype {
@@ -66,6 +70,12 @@ pub enum DxfShape {
Polyline {
points: Vec<Vec2>,
},
/// Lines and arcs with signed outgoing bulges. At most a half turn per edge.
/// Open polylines have a terminal zero bulge; closed ones do not repeat the first vertex.
BulgedPolyline {
vertices: Vec<DxfVertex>,
closed: bool,
},
Circle {
c: Vec2,
r: f64,
@@ -114,98 +124,12 @@ pub struct DxfEntity {
pub shape: DxfShape,
}
/// The entity of one profile segment, exactly: a line, an arc (a circle when it is a whole
/// turn), an elliptical arc for a conic of weight below 1, a spline for any other conic, and for a
/// Bézier a spline of its own degree over all its control points.
pub fn seg_shape(s: &Seg2) -> DxfShape {
match *s {
Seg2::Line { a, b } => DxfShape::Line(a, b),
Seg2::Arc { center, radius, start, sweep } => {
if (sweep.abs() - TAU).abs() < 1e-9 {
return DxfShape::Circle { c: center, r: radius };
}
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
DxfShape::Arc { c: center, r: radius, a0: a0.to_degrees().rem_euclid(360.0), a1: a1.to_degrees().rem_euclid(360.0) }
}
Seg2::Conic { a, apex, b, w } => match conic_ellipse(a, apex, b, w) {
Some(e) => DxfShape::Ellipse { c: e.c, major: e.major, ratio: e.ratio, t0: wrap(e.t0), t1: wrap(e.t1) },
None => bezier_spline(&[a, apex, b], ((w - 1.0).abs() > 1e-12).then(|| vec![1.0, w, 1.0])),
},
Seg2::Cubic { p0, p1, p2, p3 } => bezier_spline(&[p0, p1, p2, p3], None),
Seg2::Bezier { n, p } => bezier_spline(p.get(..=(n as usize).clamp(1, MAX_BEZIER_DEGREE)).unwrap_or(&p), None),
}
}
/// The entities of a closed loop: one ELLIPSE when it is a whole ellipse of conic arcs, else
/// one entity for each segment.
pub fn loop_shapes(lp: &Loop2) -> Vec<DxfShape> {
let arcs: Option<Vec<EllipseArc>> =
lp.segs.iter().map(|s| if let Seg2::Conic { a, apex, b, w } = *s { conic_ellipse(a, apex, b, w) } else { None }).collect();
if let Some(arcs) = arcs
&& let Some(first) = arcs.first()
&& arcs.iter().all(|e| e.same_ellipse(first))
&& (arcs.iter().map(|e| e.t1 - e.t0).sum::<f64>() - TAU).abs() < 1e-7
{
return vec![DxfShape::Ellipse { c: first.c, major: first.major, ratio: first.ratio, t0: 0.0, t1: TAU }];
}
lp.segs.iter().map(seg_shape).collect()
}
/// A parameter reduced to [0, 2π); one within 1e-12 of a full turn is 0.
fn wrap(t: f64) -> f64 {
let r = t.rem_euclid(TAU);
if r > TAU - 1e-12 { 0.0 } else { r }
}
/// An elliptical arc as DXF describes it (the parameters are not yet reduced to 0…2π).
#[derive(Clone, Copy, Debug)]
struct EllipseArc {
c: Vec2,
major: Vec2,
ratio: f64,
t0: f64,
t1: f64,
}
impl EllipseArc {
fn same_ellipse(&self, o: &EllipseArc) -> bool {
let tol = 1e-7 * self.major.len().max(1.0);
let parallel = self.major.cross(o.major).abs() <= tol * self.major.len();
self.c.dist(o.c) <= tol && parallel && (self.major.len() - o.major.len()).abs() <= tol && (self.ratio - o.ratio).abs() <= 1e-9
}
}
/// The ellipse a rational quadratic Bézier of weight `w` below 1 (end weights 1) lies on. The
/// arc is `c + u·cos θ + v·sin θ` for θ from −α to α, `cos α = w`, with the conjugate
/// semi-diameters `u` (from the centre towards the arc's middle) and `v` (towards its end).
/// With `m` the middle of the chord, `apex − m = u·(1 − w²)/w` and `m − c = w·u`, so
/// `c = m − (apex − m)·w²/(1 − w²)`, and `v = (b − m)/sin α`. The principal axes are where
/// |u cos t + v sin t| is largest and smallest, tan 2t = 2 u·v / (u² − v²).
fn conic_ellipse(a: Vec2, apex: Vec2, b: Vec2, w: f64) -> Option<EllipseArc> {
// Within 1e-6 of 1 the centre is hundreds of metres away: a rational spline is exact too.
if !(w > 1e-9 && w < 1.0 - 1e-6) {
return None;
}
let m = (a + b) * 0.5;
let c = m - (apex - m) * (w * w / (1.0 - w * w));
let alpha = w.acos();
let (u, v) = ((m - c) / w, (b - m) / alpha.sin());
if !(c.is_finite() && u.is_finite() && v.is_finite()) || u.cross(v).abs() < 1e-18 {
return None;
}
let t = 0.5 * (2.0 * u.dot(v)).atan2(u.dot(u) - v.dot(v));
let major = u * t.cos() + v * t.sin();
let minor = v * t.cos() - u * t.sin();
if !(major.len() > 1e-12) {
return None;
}
let ratio = (minor.len() / major.len()).clamp(1e-12, 1.0);
// DXF runs counter-clockwise with the minor axis a quarter turn ahead of the major one. If
// `minor` lies on the other side the arc, which runs a → b with increasing θ, is clockwise.
let (t0, t1) = if major.cross(minor) > 0.0 { (-alpha - t, alpha - t) } else { (t - alpha, t + alpha) };
Some(EllipseArc { c, major, ratio, t0, t1 })
}
/// A Bézier over `pts` (weights `weights`) as a clamped spline of degree `pts.len() − 1`.
fn bezier_spline(pts: &[Vec2], weights: Option<Vec<f64>>) -> DxfShape {
let degree = pts.len().saturating_sub(1).max(1);
@@ -215,10 +139,11 @@ fn bezier_spline(pts: &[Vec2], weights: Option<Vec<f64>>) -> DxfShape {
impl DxfShape {
/// Points on the shape (or its control polygon) for the drawing's extents.
fn sample(&self, out: &mut Vec<Vec2>) {
fn sample(&self, out: &mut Vec<Vec2>) -> Result<()> {
match self {
DxfShape::Line(a, b) => out.extend([*a, *b]),
DxfShape::Polyline { points } => out.extend(points.iter().copied()),
DxfShape::BulgedPolyline { vertices, closed } => sample_bulged(vertices, *closed, out)?,
DxfShape::Circle { c, r } => out.extend([*c - Vec2::new(*r, *r), *c + Vec2::new(*r, *r)]),
DxfShape::Arc { c, r, a0, a1 } => {
let sweep = (a1 - a0).rem_euclid(360.0);
@@ -241,9 +166,60 @@ impl DxfShape {
out.extend([-half, half].map(|s| *at + along * s + up * *height));
}
}
Ok(())
}
}
// Validate before sampling; Out::num's legacy non-finite substitution is never the
// admission policy for a new bulged polyline. All fields here retain f64 round trips.
fn sample_bulged(vertices: &[DxfVertex], closed: bool, out: &mut Vec<Vec2>) -> Result<()> {
if !(2..=path::MAX_PATH_ITEMS).contains(&vertices.len()) {
return Err(IoError::Invalid("bulged polyline requires 2..=200000 vertices".into()));
}
if vertices.iter().any(|v| !v.at.is_finite() || !v.bulge.is_finite() || v.bulge.abs() > 1.0) {
return Err(IoError::Invalid("bulged polyline has a non-finite value or an arc beyond a half turn".into()));
}
if !closed && vertices.last().is_some_and(|v| v.bulge != 0.0) {
return Err(IoError::Invalid("open bulged polyline must end with a zero bulge".into()));
}
for (from, to) in vertices.iter().zip(vertices.iter().skip(1)) {
sample_bulged_edge(*from, to.at, out)?;
}
if closed && let Some((from, to)) = vertices.last().zip(vertices.first()) {
sample_bulged_edge(*from, to.at, out)?;
}
Ok(())
}
fn sample_bulged_edge(from: DxfVertex, to: Vec2, out: &mut Vec<Vec2>) -> Result<()> {
out.try_reserve(6).map_err(|_| IoError::Invalid("cannot allocate bulged polyline bounds".into()))?;
out.extend([from.at, to]);
if from.bulge == 0.0 {
return Ok(());
}
let Some(solidcraft_geom::Seg2::Arc { center, radius, start, sweep }) = path::bulge_arc(from, to) else {
return Err(IoError::Invalid("bulged polyline arc has collapsed or non-finite geometry".into()));
};
// Endpoints alone miss cardinal extrema. Check the directed interval, including its
// phase seam; construct cardinal offsets directly instead of evaluating sin/cos there.
for (angle, offset) in [
(0.0, Vec2::new(radius, 0.0)),
(std::f64::consts::FRAC_PI_2, Vec2::new(0.0, radius)),
(std::f64::consts::PI, Vec2::new(-radius, 0.0)),
(3.0 * std::f64::consts::FRAC_PI_2, Vec2::new(0.0, -radius)),
] {
let delta = if sweep > 0.0 { (angle - start).rem_euclid(TAU) } else { (start - angle).rem_euclid(TAU) };
if delta <= sweep.abs() {
let point = center + offset;
if !point.is_finite() {
return Err(IoError::Invalid("bulged polyline arc bounds are not finite".into()));
}
out.push(point);
}
}
Ok(())
}
/// Group codes and handles, in file order.
struct Out {
s: String,
@@ -261,6 +237,20 @@ impl Out {
let text = if text.ends_with('.') { format!("{text}0") } else { text.to_string() };
self.code(c, if text == "-0.0" { "0.0" } else { text.as_str() });
}
/// New bulged-polyline fields retain their admitted f64 values. Keep the legacy
/// twelve-decimal formatter above unchanged for every pre-existing entity.
fn precise_num(&mut self, c: i32, value: f64) {
if value == 0.0 {
self.code(c, "0.0");
} else {
self.code(c, value);
}
}
fn precise_point(&mut self, c: i32, point: Vec2) {
self.precise_num(c, point.x);
self.precise_num(c + 10, point.y);
self.precise_num(c + 20, 0.0);
}
/// A point in the plane at height 0: codes `c`, `c + 10`, `c + 20`.
fn point(&mut self, c: i32, p: Vec2) {
self.num(c, p.x);
@@ -343,8 +333,8 @@ fn text_value(s: &str) -> Result<String> {
/// character, its position, the entity and its layer; no drawing is returned on failure.
pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<String> {
let mut pts = Vec::new();
for e in entities {
e.shape.sample(&mut pts);
for (index, e) in entities.iter().enumerate() {
e.shape.sample(&mut pts).map_err(|error| IoError::Invalid(format!("DXF entity {} on layer {:?}: {error}", index + 1, e.layer)))?;
}
pts.retain(|p| p.is_finite());
let (lo, hi) = pts
@@ -439,7 +429,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
for (index, e) in entities.iter().enumerate() {
let kind = match &e.shape {
DxfShape::Line(..) => "LINE",
DxfShape::Polyline { .. } => "LWPOLYLINE",
DxfShape::Polyline { .. } | DxfShape::BulgedPolyline { .. } => "LWPOLYLINE",
DxfShape::Circle { .. } => "CIRCLE",
DxfShape::Arc { .. } => "ARC",
DxfShape::Ellipse { .. } => "ELLIPSE",
@@ -463,6 +453,16 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
o.num(20, p.y);
}
}
DxfShape::BulgedPolyline { vertices, closed } => {
o.code(100, "AcDbPolyline");
o.code(90, vertices.len());
o.code(70, 128 + i32::from(*closed));
for vertex in vertices {
o.precise_num(10, vertex.at.x);
o.precise_num(20, vertex.at.y);
o.precise_num(42, vertex.bulge);
}
}
DxfShape::Line(a, b) => {
o.code(100, "AcDbLine");
o.point(10, *a);
@@ -555,9 +555,18 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
head.code(9, "$MEASUREMENT");
head.code(70, 1);
head.code(9, "$EXTMIN");
head.point(10, lo);
let precise_bounds = entities.iter().any(|e| matches!(e.shape, DxfShape::BulgedPolyline { .. }));
if precise_bounds {
head.precise_point(10, lo);
} else {
head.point(10, lo);
}
head.code(9, "$EXTMAX");
head.point(10, hi);
if precise_bounds {
head.precise_point(10, hi);
} else {
head.point(10, hi);
}
head.code(0, "ENDSEC");
Ok(head.s + &o.s)
}
@@ -566,9 +575,22 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> Result<
mod tests {
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, PI};
use solidcraft_geom::{Loop2, Seg2};
use super::*;
use crate::{Geom2, read_dxf};
// Keep the existing ordinary writer regressions on the new public conversion path.
fn seg_shape(seg: &Seg2) -> DxfShape {
let shapes = path_shapes(DxfPath::Open(std::slice::from_ref(seg)), DxfPathStyle::Exact).unwrap();
assert_eq!(shapes.len(), 1);
shapes.into_iter().next().unwrap()
}
fn loop_shapes(lp: &Loop2) -> Vec<DxfShape> {
path_shapes(DxfPath::Closed(lp), DxfPathStyle::Exact).unwrap()
}
/// The conic quarter of the ellipse `c ± u`, `c ± v` from `c + u` to `c + v`.
fn quarter(c: Vec2, u: Vec2, v: Vec2) -> Seg2 {
Seg2::Conic { a: c + u, apex: c + u + v, b: c + v, w: FRAC_1_SQRT_2 }
@@ -706,10 +728,51 @@ mod tests {
assert_eq!((degree, knots.len(), weights), (n, 2 * k, None), "degree {n}");
assert_eq!(ctrl, q[..k], "degree {n}");
}
// A degree no piece can have (hostile data) is held to what the piece holds, never past it.
let shape = |n: u8| seg_shape(&Seg2::Bezier { n, p: q });
assert!(matches!(shape(0), DxfShape::Spline { degree: 1, ref ctrl, .. } if ctrl.len() == 2));
assert!(matches!(shape(u8::MAX), DxfShape::Spline { degree: 9, ref ctrl, .. } if ctrl[..] == q[..]));
// The fallible path API refuses malformed storage before any helper can clamp it.
for n in [0, 1, 2, 3, 10, u8::MAX] {
assert!(path_shapes(DxfPath::Open(&[Seg2::Bezier { n, p: q }]), DxfPathStyle::Exact).is_err());
}
}
#[test]
fn bulged_polyline_fields_and_bounds_keep_small_finite_coordinates() {
let shape = DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::new(1e-14, -0.0), bulge: 0.0 }, DxfVertex { at: Vec2::new(2e-14, 1e-14), bulge: 0.0 }],
closed: false,
};
let text = write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape }]).unwrap();
let e = entities(&text);
assert_eq!(e.len(), 1);
assert_eq!(e[0].0, "LWPOLYLINE");
assert_eq!(values(&e[0], 10), vec![1e-14, 2e-14]);
assert_eq!(values(&e[0], 20), vec![0.0, 1e-14]);
assert!(!values(&e[0], 20)[0].is_sign_negative());
assert_eq!(values(&e[0], 42), vec![0.0, 0.0]);
let pairs = pairs(&text);
let min = pairs.iter().position(|p| *p == (9, "$EXTMIN".into())).unwrap();
let max = pairs.iter().position(|p| *p == (9, "$EXTMAX".into())).unwrap();
assert_eq!(pairs[min + 1].1.parse::<f64>().unwrap(), 1e-14);
assert_eq!(pairs[max + 1].1.parse::<f64>().unwrap(), 2e-14);
}
#[test]
fn bulged_polyline_writer_rejects_invalid_vertices_before_serializing() {
let first = DxfVertex { at: Vec2::ZERO, bulge: 0.0 };
let last = DxfVertex { at: Vec2::X, bulge: 0.0 };
let invalid = [
(vec![], false),
(vec![first], true),
(vec![DxfVertex { at: Vec2::new(f64::NAN, 0.0), ..first }, last], false),
(vec![DxfVertex { bulge: f64::INFINITY, ..first }, last], false),
(vec![DxfVertex { bulge: 1.1, ..first }, last], false),
(vec![first, DxfVertex { bulge: 0.5, ..last }], false),
(vec![DxfVertex { bulge: 0.5, ..first }, first], false),
];
for (vertices, closed) in invalid {
let shape = DxfShape::BulgedPolyline { vertices, closed };
let error = write_dxf_drawing(&[], &[DxfEntity { layer: "CUT".into(), shape }]).unwrap_err().to_string();
assert!(error.contains("entity 1") && error.contains("CUT"), "{error}");
}
}
#[test]
+740
View File
@@ -0,0 +1,740 @@
//! Bounded conversion of borrowed planar paths to DXF geometry.
//!
//! Admission precedes recognition and fitting. A failed conversion returns no partial path;
//! piece errors always refer to the original input, including after arc splitting or fitting.
use std::f64::consts::{PI, TAU};
use solidcraft_geom::{Loop2, MAX_BEZIER_DEGREE, MAX_FIT_PIECES, Seg2, Vec2, conic_ellipse};
use super::{DxfShape, bezier_spline, wrap};
use crate::{IoError, Result};
/// A borrowed sequence of planar pieces, optionally carrying the caller's closed-loop intent.
/// This is not a certificate of continuity or topology. Whole-circle recognition uses geom's
/// bounded angular rounding cells, not bitwise endpoint identity. Polyline joins require
/// equal stored endpoints and otherwise emit separate open runs without filling or snapping.
#[derive(Clone, Copy, Debug)]
pub enum DxfPath<'a> {
Open(&'a [Seg2]),
/// Permit whole-loop recognition and an explicit closing flag when demonstrated.
/// This does not certify or repair the input topology; recognition has the policies above.
Closed(&'a Loop2),
}
/// How a path's curves are represented. Coordinates and tolerances are in millimetres.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DxfPathStyle {
/// Preserve analytic curves and the original polynomial/rational spline controls.
Exact,
/// Collect consecutive lines and circular arcs into polylines with signed bulges.
/// Other curves retain their exact representation and interrupt the polyline run.
/// An arc that cannot preserve its support as a bulge retains its ARC entity instead.
Polyline,
/// Fit free-form pieces with the existing bounded line/arc fitter.
/// Its sampling is not an analytic, uniform error certificate.
Arcs { tolerance_mm: f64 },
}
/// A polyline vertex. `bulge` describes the edge leaving `at`: `tan(sweep / 4)`.
/// The terminal vertex of an open polyline has zero bulge.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DxfVertex {
pub at: Vec2,
pub bulge: f64,
}
// Separate per-path budgets: one entity may contain many vertices. These do not bound an
// entire drawing made from many calls. Check before allocating from or expanding input.
pub(super) const MAX_PATH_ITEMS: usize = 200_000;
const MAX_FITTED_PATH_PIECES: usize = MAX_FIT_PIECES * 8;
#[derive(Clone, Copy)]
struct Piece {
index: usize,
total: usize,
}
impl Piece {
fn invalid(self, reason: impl std::fmt::Display) -> IoError {
IoError::Invalid(format!("DXF path: piece {} of {}: {reason}", self.index + 1, self.total))
}
}
fn invalid(reason: &str) -> IoError {
IoError::Invalid(format!("DXF path: {reason}"))
}
/// Convert a path without mutating it, dropping pieces, or changing their order.
///
/// Invalid options are rejected even for empty paths. Input pieces, output entities and
/// accumulated polyline vertices each have a separate limit of 200,000. If fitting is needed,
/// its 2,048-piece per-input and 16,384-piece per-path limits include retained lines/arcs.
/// Pure line/arc paths bypass the fitting limit. Positive finite conic weights are supported;
/// other rational domains are outside this converter's admission contract.
/// `Closed` permits recognition, not an invented closing edge: only equal stored endpoints
/// share a polyline vertex. Mixed or fragmented runs remain open, in original piece order.
pub fn path_shapes(path: DxfPath<'_>, style: DxfPathStyle) -> Result<Vec<DxfShape>> {
if let DxfPathStyle::Arcs { tolerance_mm } = style
&& !(tolerance_mm.is_finite() && tolerance_mm >= 1e-9)
{
return Err(invalid("arc tolerance must be finite and at least 1e-9 mm"));
}
let (segs, closed) = match path {
DxfPath::Open(segs) => (segs, None),
DxfPath::Closed(lp) => (lp.segs.as_slice(), Some(lp)),
};
if segs.len() > MAX_PATH_ITEMS {
return Err(invalid("input piece budget exceeded"));
}
// Do not call endpoint, recognition or fitting helpers before validating degrees.
for (index, seg) in segs.iter().enumerate() {
admit(seg, Piece { index, total: segs.len() })?;
}
if let Some(lp) = closed {
if let Some((c, r)) = lp.as_circle() {
// Every circular piece was admitted, including its support bounds.
legacy_positive(r, Piece { index: 0, total: segs.len() }, "circle radius")?;
return Ok(vec![DxfShape::Circle { c, r }]);
}
if !matches!(style, DxfPathStyle::Arcs { .. })
&& let Some((c, major, ratio)) = lp.as_ellipse()
{
// Keep the recognizer's validated support. Only its axis sign may follow the
// first conic's historical DXF frame: negating the axis preserves a full ellipse,
// whereas replacing its centre/ratio from another extraction need not do so.
let first = lp.segs.first().and_then(|seg| match *seg {
Seg2::Conic { a, apex, b, w } => conic_ellipse(a, apex, b, w),
_ => None,
});
let mut major = major;
if let Some(first) = first {
let scale = major.x.abs().max(major.y.abs());
let first_scale = first.major.x.abs().max(first.major.y.abs());
if scale > 0.0 && first_scale > 0.0 && (major / scale).dot(first.major / first_scale) < 0.0 {
major = -major;
}
}
if ellipse_representable(c, major, ratio, 0.0, TAU) {
return Ok(vec![DxfShape::Ellipse { c, major, ratio, t0: 0.0, t1: TAU }]);
}
}
}
match style {
DxfPathStyle::Polyline => polylines(segs, closed.is_some()),
DxfPathStyle::Arcs { tolerance_mm } if segs.iter().any(|seg| !seg.is_line_or_arc()) => fitted(segs, tolerance_mm),
DxfPathStyle::Exact | DxfPathStyle::Arcs { .. } => {
let mut shapes = Vec::new();
for (index, seg) in segs.iter().enumerate() {
push_shape(&mut shapes, exact(seg, Piece { index, total: segs.len() })?)?;
}
Ok(shapes)
}
}
}
fn admit(seg: &Seg2, piece: Piece) -> Result<()> {
let finite = match *seg {
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
Seg2::Arc { center, radius, start, sweep } => {
if !(center.is_finite() && radius.is_finite() && start.is_finite() && sweep.is_finite()) {
return Err(piece.invalid("non-finite circular arc"));
}
if radius <= 0.0 {
return Err(piece.invalid("arc radius must be positive"));
}
if sweep == 0.0 {
return Err(piece.invalid("arc sweep must be nonzero"));
}
if sweep.abs() > TAU {
return Err(piece.invalid("arc sweep exceeds one turn"));
}
let end = start + sweep;
if !end.is_finite() || end == start {
return Err(piece.invalid("arc end angle is non-finite or unresolved"));
}
if !(start.to_degrees().is_finite() && end.to_degrees().is_finite()) {
return Err(piece.invalid("arc angles cannot be represented in degrees"));
}
if !circle_bounds_finite(center, radius) {
return Err(piece.invalid("circular support bounds are not finite"));
}
seg.start().is_finite() && seg.end().is_finite()
}
Seg2::Conic { a, apex, b, w } => {
if !(w.is_finite() && w > 0.0) {
return Err(piece.invalid("conic weight must be finite and positive"));
}
a.is_finite() && apex.is_finite() && b.is_finite()
}
Seg2::Cubic { p0, p1, p2, p3 } => [p0, p1, p2, p3].iter().all(|p| p.is_finite()),
Seg2::Bezier { n, ref p } => {
if !(4..=MAX_BEZIER_DEGREE).contains(&usize::from(n)) {
return Err(piece.invalid(format_args!("unsupported Bezier degree {n}")));
}
p.get(..=usize::from(n)).is_some_and(|pts| pts.iter().all(|p| p.is_finite()))
}
};
if !finite {
return Err(piece.invalid("non-finite coordinate"));
}
Ok(())
}
fn circle_bounds_finite(c: Vec2, r: f64) -> bool {
c.is_finite() && r.is_finite() && r > 0.0 && (c - Vec2::new(r, r)).is_finite() && (c + Vec2::new(r, r)).is_finite()
}
// The existing writer uses twelve decimals. Preserve the original rational controls rather
// than clamp a thinner ellipse's ratio to its decimal resolution. This is a representation
// restriction, not a minimum permitted size for source geometry.
fn ellipse_representable(c: Vec2, major: Vec2, ratio: f64, t0: f64, t1: f64) -> bool {
if !(c.is_finite() && major.is_finite() && ratio.is_finite() && (1e-12..=1.0).contains(&ratio) && t0.is_finite() && t1.is_finite()) {
return false;
}
if major == Vec2::ZERO || legacy_point(major) == Some(Vec2::ZERO) || legacy_number(t0) == legacy_number(t1) {
return false;
}
let minor = major.perp() * ratio;
// Bound the writer's actual c + major*cos(t) + minor*sin(t) evaluation, including
// intermediate additions; a finite centre and two finite axes alone are insufficient.
let extent = Vec2::new(major.x.abs() + minor.x.abs(), major.y.abs() + minor.y.abs());
minor.is_finite() && extent.is_finite() && (c - extent).is_finite() && (c + extent).is_finite()
}
fn exact(seg: &Seg2, piece: Piece) -> Result<DxfShape> {
let shape = match *seg {
Seg2::Line { a, b } => {
if a != b && legacy_point(a) == legacy_point(b) {
return Err(piece.invalid("line endpoints collapse at DXF writer precision"));
}
DxfShape::Line(a, b)
}
Seg2::Arc { center, radius, start, sweep } => {
legacy_positive(radius, piece, "arc radius")?;
// Admission already refused >TAU. A shorter arc must not acquire a closing edge.
if sweep.abs() == TAU {
return Ok(DxfShape::Circle { c: center, r: radius });
}
let (a0, a1) = if sweep > 0.0 { (start, start + sweep) } else { (start + sweep, start) };
let (a0, a1) = (a0.to_degrees().rem_euclid(360.0), a1.to_degrees().rem_euclid(360.0));
if legacy_number(a0).map(|a| a.rem_euclid(360.0)) == legacy_number(a1).map(|a| a.rem_euclid(360.0)) {
return Err(piece.invalid("arc endpoints collapse in the DXF angular representation"));
}
DxfShape::Arc { c: center, r: radius, a0, a1 }
}
Seg2::Conic { a, apex, b, w } => {
if let Some(e) = conic_ellipse(a, apex, b, w) {
// Geom preserves direction; DXF ELLIPSE parameters run counter-clockwise.
// Keep the helper's endpoint expressions intact, including its CW ordering.
let (start, end) = if e.end > e.start { (e.start, e.end) } else { (e.end, e.start) };
let (t0, t1) = (wrap(start), wrap(end));
if ellipse_representable(e.center, e.major, e.ratio, t0, t1) {
return Ok(DxfShape::Ellipse { c: e.center, major: e.major, ratio: e.ratio, t0, t1 });
}
}
// Recognition failure is not invalid input. Positive weights keep this spline
// inside the finite control hull even if ellipse extraction would overflow.
legacy_positive(w, piece, "conic weight")?;
bezier_spline(&[a, apex, b], (w != 1.0).then(|| vec![1.0, w, 1.0]))
}
Seg2::Cubic { p0, p1, p2, p3 } => bezier_spline(&[p0, p1, p2, p3], None),
Seg2::Bezier { n, ref p } => {
let pts = p.get(..=usize::from(n)).ok_or_else(|| piece.invalid(format_args!("unsupported Bezier degree {n}")))?;
bezier_spline(pts, None)
}
};
if let DxfShape::Spline { ctrl, .. } = &shape
&& let Some(first) = ctrl.first()
&& ctrl.iter().any(|p| p != first)
&& ctrl.iter().all(|p| legacy_point(*p) == legacy_point(*first))
{
return Err(piece.invalid("spline controls collapse at DXF writer precision"));
}
Ok(shape)
}
// Mirror Out::num's numeric precision for admission only; never rewrite its output or
// substitute rounded input controls. New bulged-polyline fields use round-trip precision.
fn legacy_number(value: f64) -> Option<f64> {
if !value.is_finite() {
return None;
}
format!("{value:.12}").parse::<f64>().ok().filter(|v| v.is_finite())
}
fn legacy_point(point: Vec2) -> Option<Vec2> {
Some(Vec2::new(legacy_number(point.x)?, legacy_number(point.y)?))
}
fn legacy_positive(value: f64, piece: Piece, name: &str) -> Result<()> {
if !legacy_number(value).is_some_and(|v| v > 0.0) {
return Err(piece.invalid(format_args!("{name} is not positive at DXF writer precision")));
}
Ok(())
}
fn fitted(segs: &[Seg2], tolerance: f64) -> Result<Vec<DxfShape>> {
let mut shapes = Vec::new();
let mut count = 0usize;
for (index, seg) in segs.iter().enumerate() {
let piece = Piece { index, total: segs.len() };
if seg.is_line_or_arc() {
count_fit(&mut count, 1, piece)?;
push_shape(&mut shapes, exact(seg, piece)?)?;
continue;
}
let adjusted = seg.fit_arcs(tolerance).ok_or_else(|| piece.invalid("curve could not be fitted within the arc fitting budget"))?;
if adjusted.is_empty() {
return Err(piece.invalid("fitting produced no pieces"));
}
if adjusted.len() > MAX_FIT_PIECES {
return Err(piece.invalid("per-piece arc fitting budget exceeded"));
}
count_fit(&mut count, adjusted.len(), piece)?;
for fitted in adjusted {
if !fitted.is_line_or_arc() {
return Err(piece.invalid("arc fitting produced a non-line/arc piece"));
}
admit(&fitted, piece)?;
push_shape(&mut shapes, exact(&fitted, piece)?)?;
}
}
Ok(shapes)
}
fn count_fit(count: &mut usize, additional: usize, piece: Piece) -> Result<()> {
let total = count.checked_add(additional).ok_or_else(|| piece.invalid("path arc fitting budget exceeded"))?;
if total > MAX_FITTED_PATH_PIECES {
return Err(piece.invalid("path arc fitting budget exceeded"));
}
*count = total;
Ok(())
}
fn push_shape(shapes: &mut Vec<DxfShape>, shape: DxfShape) -> Result<()> {
if shapes.len() >= MAX_PATH_ITEMS {
return Err(invalid("output entity budget exceeded"));
}
shapes.try_reserve(1).map_err(|_| invalid("cannot allocate output entities"))?;
shapes.push(shape);
Ok(())
}
#[derive(Default)]
struct Run {
vertices: Vec<DxfVertex>,
end: Option<Vec2>,
}
struct Polylines {
shapes: Vec<DxfShape>,
run: Run,
vertices: usize,
uninterrupted: bool,
}
impl Polylines {
fn reserve_vertices(&self, additional: usize) -> Result<()> {
if self.vertices.checked_add(additional).is_none_or(|n| n > MAX_PATH_ITEMS) {
return Err(invalid("polyline vertex budget exceeded"));
}
Ok(())
}
fn vertex(&mut self, vertex: DxfVertex) -> Result<()> {
self.reserve_vertices(1)?;
self.run.vertices.try_reserve(1).map_err(|_| invalid("cannot allocate polyline vertices"))?;
self.run.vertices.push(vertex);
self.vertices += 1;
Ok(())
}
fn edge(&mut self, a: Vec2, b: Vec2, bulge: f64, piece: Piece) -> Result<()> {
if self.run.end.is_some_and(|end| end != a) {
// Sharing is exact in stored coordinates (including +/-0). Never replace an
// incoming endpoint with the outgoing one, or bridge them with a straight edge.
self.flush(false)?;
self.uninterrupted = false;
}
if !(a.is_finite() && b.is_finite() && bulge.is_finite()) {
return Err(piece.invalid("non-finite polyline vertex or bulge"));
}
self.vertex(DxfVertex { at: a, bulge })?;
self.run.end = Some(b);
Ok(())
}
fn flush(&mut self, closed: bool) -> Result<()> {
let Some(end) = self.run.end else { return Ok(()) };
if !closed {
self.vertex(DxfVertex { at: end, bulge: 0.0 })?;
}
let vertices = std::mem::take(&mut self.run.vertices);
self.run.end = None;
push_shape(&mut self.shapes, DxfShape::BulgedPolyline { vertices, closed })
}
fn exact(&mut self, shape: DxfShape) -> Result<()> {
self.flush(false)?;
self.uninterrupted = false;
push_shape(&mut self.shapes, shape)
}
}
fn polylines(segs: &[Seg2], closed: bool) -> Result<Vec<DxfShape>> {
let mut out = Polylines { shapes: Vec::new(), run: Run::default(), vertices: 0, uninterrupted: true };
for (index, seg) in segs.iter().enumerate() {
let piece = Piece { index, total: segs.len() };
match *seg {
Seg2::Line { a, b } => out.edge(a, b, 0.0, piece)?,
Seg2::Arc { center, radius, start, sweep } if sweep.abs() < TAU => {
let (a, b) = (seg.start(), seg.end());
if sweep.abs() > PI {
// Split at the actual midpoint angle, keeping both source endpoints.
let mid_angle = start + sweep * 0.5;
if mid_angle == start || mid_angle == start + sweep {
out.exact(exact(seg, piece)?)?;
continue;
}
let mid = center + Vec2::from_angle(mid_angle) * radius;
let Some(bulge) = arc_bulge(sweep * 0.5) else {
out.exact(exact(seg, piece)?)?;
continue;
};
if !can_pack(a, mid, bulge, center, radius, sweep * 0.5) || !can_pack(mid, b, bulge, center, radius, sweep * 0.5) {
out.exact(exact(seg, piece)?)?;
continue;
}
// Both halves must qualify before either is appended.
// A prior disjoint run also needs its terminal zero-bulge vertex.
let terminal = usize::from(out.run.end.is_some_and(|end| end != a));
out.reserve_vertices(2 + terminal)?;
out.edge(a, mid, bulge, piece)?;
out.edge(mid, b, bulge, piece)?;
} else {
if let Some(bulge) = arc_bulge(sweep)
&& can_pack(a, b, bulge, center, radius, sweep)
{
out.edge(a, b, bulge, piece)?;
} else {
out.exact(exact(seg, piece)?)?;
}
}
}
_ => out.exact(exact(seg, piece)?)?,
}
}
// Only a run that covers the entire closed path can receive the closing flag. Its last
// outgoing bulge stays on the final vertex; the first vertex is not appended a second time.
// A single authored zero-length line stays an open two-vertex entity; it cannot form a
// writable one-vertex closed polyline. Exact preserves the original LINE unchanged.
let close =
closed && out.uninterrupted && out.run.vertices.len() >= 2 && out.run.vertices.first().is_some_and(|first| Some(first.at) == out.run.end);
out.flush(close)?;
Ok(out.shapes)
}
fn arc_bulge(sweep: f64) -> Option<f64> {
// A semicircle has bulge exactly +/-1; all other admitted pieces use tan(sweep/4).
let bulge = if sweep.abs() == PI { sweep.signum() } else { (sweep * 0.25).tan() };
(bulge.is_finite() && bulge != 0.0 && bulge.abs() <= 1.0).then_some(bulge)
}
// Packing is optional. Check algebraic support residuals of the values that will actually
// be serialized, normalized around the ORIGINAL centre/radius. A world-coordinate ULP
// allowance would hide local distortion (for example radius .75 at centre (2^52, 2^52)).
// This is an operational acceptance policy at arithmetic roundoff scale, not an identity
// proof or a universal error guarantee for sin/cos/tan. No coordinates or parameters are
// adjusted to pass it. Serialization must round-trip these same f64 vertices and bulges.
fn can_pack(a: Vec2, z: Vec2, bulge: f64, center: Vec2, radius: f64, sweep: f64) -> bool {
if !radius.is_normal() || bulge.signum() != sweep.signum() || sweep.abs() > PI {
return false;
}
let (from, to) = (a - center, z - center);
if [from.x, from.y, to.x, to.y].iter().any(|&value| normal_or_zero(value).is_none()) {
return false;
}
let (u, v) = (from / radius, to / radius);
if [(from.x, u.x), (from.y, u.y), (to.x, v.x), (to.y, v.y)].iter().any(|&(input, output)| input != 0.0 && output == 0.0) {
return false;
}
let Some((ui, vi)) = SupportPoint::normalized(a, center, radius).zip(SupportPoint::normalized(z, center, radius)) else {
return false;
};
support_residuals(u, v, bulge, ui, vi).is_some() && bulge_arc(DxfVertex { at: a, bulge }, z).is_some()
}
// The gamma_n relative-error model applies to normal arithmetic. Conservatively retain
// ARC on subnormal intermediate results instead of adding an unbounded underflow margin.
fn normal_or_zero(value: f64) -> Option<f64> {
(value == 0.0 || value.is_normal()).then_some(value)
}
fn normal_product(a: f64, b: f64) -> Option<f64> {
let product = normal_or_zero(a * b)?;
(product != 0.0 || a == 0.0 || b == 0.0).then_some(product)
}
// These nonnegative quantities are admission limits, so round DOWN: uncertainty in the
// comparison must not enlarge the allowed geometric discrepancy.
fn lower_add(a: f64, b: f64) -> f64 {
if a == 0.0 {
b
} else if b == 0.0 {
a
} else {
(a + b).next_down()
}
}
fn lower_mul(a: f64, b: f64) -> f64 {
if a == 0.0 || b == 0.0 { 0.0 } else { (a * b).next_down() }
}
fn gamma_lower(operations: f64) -> f64 {
let nu = lower_mul(operations, f64::EPSILON * 0.5);
(nu / (1.0 - nu).next_up()).next_down()
}
/// Outward enclosure starting at the actual serialized vertices and original support,
/// including their normalization. This does not enclose a hypothetical exact trigonometric
/// construction of those vertices; their stored f64 values are the operands being checked.
#[derive(Clone, Copy)]
struct SupportInterval {
lo: f64,
hi: f64,
}
impl SupportInterval {
fn exact(value: f64) -> Self {
Self { lo: value, hi: value }
}
fn enclosing(lo: f64, hi: f64) -> Option<Self> {
let (lo, hi) = (lo.next_down(), hi.next_up());
Self::checked(lo, hi)
}
fn checked(lo: f64, hi: f64) -> Option<Self> {
(lo.is_finite() && hi.is_finite() && lo <= hi).then_some(Self { lo, hi })
}
fn is_exact(self, value: f64) -> bool {
self.lo == value && self.hi == value
}
fn negated(self) -> Self {
Self { lo: -self.hi, hi: -self.lo }
}
fn from_residual(value: f64, residual: f64, exact_zero: bool) -> Option<Self> {
if !value.is_finite() || !residual.is_finite() {
return None;
}
if residual == 0.0 && !exact_zero {
// An FMA residual can underflow. Zero alone does not prove exactness.
return Self::enclosing(value, value);
}
let lo = if residual < 0.0 { value.next_down() } else { value };
let hi = if residual > 0.0 { value.next_up() } else { value };
Self::checked(lo, hi)
}
fn sum(a: f64, b: f64) -> Option<Self> {
// TwoSum recovers the exact addition residual in the absence of overflow.
let sum = a + b;
let virtual_b = sum - a;
let virtual_a = sum - virtual_b;
let error_a = a - virtual_a;
let error_b = b - virtual_b;
let residual = error_a + error_b;
if [sum, virtual_b, virtual_a, error_a, error_b, residual].iter().any(|v| !v.is_finite()) {
return Self::enclosing(sum, sum);
}
Self::from_residual(sum, residual, true)
}
fn product(a: f64, b: f64) -> Option<Self> {
let product = a * b;
if !product.is_finite() {
return None;
}
if a == 0.0
|| b == 0.0
|| a.abs() == 1.0
|| b.abs() == 1.0
|| (product.is_normal() && ((power_of_two(a) && b.is_normal()) || (power_of_two(b) && a.is_normal())))
{
return Some(Self::exact(product));
}
Self::from_residual(product, a.mul_add(b, -product), false)
}
fn quotient(a: f64, divisor: f64) -> Option<Self> {
let quotient = a / divisor;
if !quotient.is_finite() {
return None;
}
if a == 0.0 || divisor == 1.0 || a.abs() == divisor || (power_of_two(divisor) && a.is_normal() && quotient.is_normal()) {
return Some(Self::exact(quotient));
}
// A positive divisor preserves the sign of a - quotient*divisor.
Self::from_residual(quotient, (-quotient).mul_add(divisor, a), false)
}
fn add(self, rhs: Self) -> Option<Self> {
if self.is_exact(0.0) {
return Some(rhs);
}
if rhs.is_exact(0.0) {
return Some(self);
}
Self::checked(Self::sum(self.lo, rhs.lo)?.lo, Self::sum(self.hi, rhs.hi)?.hi)
}
fn sub(self, rhs: Self) -> Option<Self> {
self.add(rhs.negated())
}
fn mul(self, rhs: Self) -> Option<Self> {
if self.is_exact(0.0) || rhs.is_exact(0.0) {
return Some(Self::exact(0.0));
}
if self.is_exact(1.0) {
return Some(rhs);
}
if rhs.is_exact(1.0) {
return Some(self);
}
if self.is_exact(-1.0) {
return Some(rhs.negated());
}
if rhs.is_exact(-1.0) {
return Some(self.negated());
}
let products =
[Self::product(self.lo, rhs.lo)?, Self::product(self.lo, rhs.hi)?, Self::product(self.hi, rhs.lo)?, Self::product(self.hi, rhs.hi)?];
let lo = products.iter().map(|p| p.lo).fold(f64::INFINITY, f64::min);
let hi = products.iter().map(|p| p.hi).fold(f64::NEG_INFINITY, f64::max);
Self::checked(lo, hi)
}
fn div_positive(self, divisor: f64) -> Option<Self> {
if !divisor.is_finite() || divisor <= 0.0 {
return None;
}
Self::checked(Self::quotient(self.lo, divisor)?.lo, Self::quotient(self.hi, divisor)?.hi)
}
fn fits(self, bound: f64) -> bool {
bound.is_normal() && bound > 0.0 && self.lo >= -bound && self.hi <= bound
}
}
fn power_of_two(value: f64) -> bool {
// A normal binary64 power of two has no fraction bits, independent of its sign.
value.is_normal() && value.to_bits() & ((1_u64 << 52) - 1) == 0
}
struct SupportPoint {
x: SupportInterval,
y: SupportInterval,
}
impl SupportPoint {
fn normalized(point: Vec2, center: Vec2, radius: f64) -> Option<Self> {
let exact = SupportInterval::exact;
Some(Self { x: exact(point.x).sub(exact(center.x))?.div_positive(radius)?, y: exact(point.y).sub(exact(center.y))?.div_positive(radius)? })
}
}
fn support_residuals(u: Vec2, v: Vec2, b: f64, ui: SupportPoint, vi: SupportPoint) -> Option<()> {
if [u.x, u.y, v.x, v.y, b].iter().any(|&value| normal_or_zero(value).is_none()) {
return None;
}
let sum = u + v;
let d = v - u;
if [sum.x, sum.y, d.x, d.y].iter().any(|&value| normal_or_zero(value).is_none()) {
return None;
}
let square = normal_product(b, b)?;
if square == 0.0 {
return None;
}
let twice = normal_product(2.0, b)?;
let minus = normal_or_zero(1.0 - square)?;
normal_or_zero(normal_product(twice, sum.x)? - normal_product(d.y, minus)?)?;
normal_or_zero(normal_product(twice, sum.y)? + normal_product(d.x, minus)?)?;
let plus = normal_or_zero(1.0 + square)?;
let chord2 = normal_or_zero(normal_product(d.x, d.x)? + normal_product(d.y, d.y)?)?;
if chord2 == 0.0 {
return None;
}
let left = normal_product(chord2, normal_product(plus, plus)?)?;
let right = normal_product(16.0, square)?;
normal_or_zero(left - right)?;
// qC = 2*b*(u+v) + perp(v-u)*(1-b*b); qC/(4*b) is the reconstructed
// centre relative to the original. qR = |v-u|²*(1+b²)² - 16*b²;
// qR/(16*b²) is its squared-radius discrepancy. Bounds use unit-circle scale,
// with a shared infinity norm for centre coordinates, including near-zero ones.
let norm = |p: Vec2| p.x.abs().max(p.y.abs());
let b2_lo = lower_mul(b.abs(), b.abs());
let plus_lo = lower_add(1.0, b2_lo);
let center_scale = lower_add(lower_mul(lower_mul(2.0, b.abs()), lower_add(norm(u), norm(v))), lower_mul(norm(d), plus_lo));
let chord2_lo = lower_add(lower_mul(d.x.abs(), d.x.abs()), lower_mul(d.y.abs(), d.y.abs()));
let radius_scale = lower_add(lower_mul(chord2_lo, lower_mul(plus_lo, plus_lo)), lower_mul(16.0, b2_lo));
// Centre: 8 scalar normalizations, 4 vector +/- components, b² and 2b,
// 1-b², four products and two final +/- = 21 operations.
// Radius: 8 normalizations, 2 chord components, b², 1+b², three operations
// for |d|², a square, a product, scaling by 16 and a subtraction = 19.
// Also cap normalized centre and squared-radius error independently of conditioning:
// |qC|/(4|b|) <= gamma_21 and |qR|/(16b²) <= gamma_19. Small b must not amplify
// an accepted residual into large geometric distortion. Limits round inward.
let center_bound = lower_mul(gamma_lower(21.0), center_scale).min(lower_mul(lower_mul(4.0, b.abs()), gamma_lower(21.0)));
let radius_bound = lower_mul(gamma_lower(19.0), radius_scale).min(lower_mul(lower_mul(16.0, b2_lo), gamma_lower(19.0)));
let exact = SupportInterval::exact;
let ib = exact(b);
let square = ib.mul(ib)?;
let twice = exact(2.0).mul(ib)?;
let minus = exact(1.0).sub(square)?;
let plus = exact(1.0).add(square)?;
let dx = vi.x.sub(ui.x)?;
let dy = vi.y.sub(ui.y)?;
let qx = twice.mul(ui.x.add(vi.x)?)?.sub(dy.mul(minus)?)?;
let qy = twice.mul(ui.y.add(vi.y)?)?.add(dx.mul(minus)?)?;
let qr = dx.mul(dx)?.add(dy.mul(dy)?)?.mul(plus.mul(plus)?)?.sub(exact(16.0).mul(square)?)?;
// Inclusion of the WHOLE enclosure is required; merely containing zero is insufficient.
(qx.fits(center_bound) && qy.fits(center_bound) && qr.fits(radius_bound)).then_some(())
}
/// Reconstruct a non-straight polyline edge with a signed sweep for bounds and validation.
/// Avoid squaring the chord length or first forming 1/bulge: those can overflow even when
/// the resulting radius is finite. A zero chord with a nonzero bulge is not an arc.
pub(super) fn bulge_arc(from: DxfVertex, to: Vec2) -> Option<Seg2> {
let b = from.bulge;
if !(from.at.is_finite() && to.is_finite() && b.is_finite()) || b == 0.0 || b.abs() > 1.0 {
return None;
}
let chord = to - from.at;
let length = chord.len();
if !chord.is_finite() || !length.is_finite() || length == 0.0 {
return None;
}
let scaled = (length * 0.25) / b;
let radius = scaled.abs() * (1.0 + b * b);
let center = from.at * 0.5 + to * 0.5 + (chord / length).perp() * (scaled * (1.0 - b * b));
if !circle_bounds_finite(center, radius) {
return None;
}
let radial = from.at - center;
if !radial.is_finite() || radial == Vec2::ZERO {
return None;
}
let start = radial.angle();
let sweep = 4.0 * b.atan();
(start.is_finite() && sweep.is_finite() && sweep != 0.0).then_some(Seg2::Arc { center, radius, start, sweep })
}
+1 -1
View File
@@ -31,7 +31,7 @@ pub mod urdf;
pub mod vfs;
pub use dxf::{read_dxf, write_dxf};
pub use dxf_out::{DxfEntity, DxfLayer, DxfLinetype, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
pub use dxf_out::{DxfEntity, DxfLayer, DxfLinetype, DxfPath, DxfPathStyle, DxfShape, DxfVertex, path_shapes, write_dxf_drawing};
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, adopt_legacy_dir, legacy_flatpak_path};
pub use safe_write::{backup_path, write_atomic};
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
+854
View File
@@ -0,0 +1,854 @@
//! Public path conversion and DXF readback acceptance, using original analytic fixtures.
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, PI, TAU};
use solidcraft_geom::{Loop2, MAX_BEZIER_CTRL, Seg2, Vec2, bezier};
use solidcraft_io::{DxfEntity, DxfPath, DxfPathStyle, DxfShape, DxfVertex, Geom2, IoError, path_shapes, read_dxf, write_dxf_drawing};
// Accepted fixtures span at most 40 mm. Legacy entities keep twelve decimal places;
// bulged polylines may retain more. These bounds allow roundoff in the independent
// inverse-bulge calculation and reader, not fitting error or an output-format requirement.
const POSITION_EPS: f64 = 1e-8;
const BULGE_EPS: f64 = 1e-11;
fn point_near(actual: Vec2, expected: Vec2) {
assert!(actual.dist(expected) < POSITION_EPS, "actual {actual:?}, expected {expected:?}");
}
fn number_near(actual: f64, expected: f64, tolerance: f64) {
assert!((actual - expected).abs() < tolerance, "actual {actual}, expected {expected}, tolerance {tolerance}");
}
fn styles() -> [DxfPathStyle; 3] {
[DxfPathStyle::Exact, DxfPathStyle::Polyline, DxfPathStyle::Arcs { tolerance_mm: 0.01 }]
}
fn drawing(shapes: &[DxfShape]) -> String {
let entities: Vec<_> = shapes.iter().cloned().map(|shape| DxfEntity { layer: "0".into(), shape }).collect();
write_dxf_drawing(&[], &entities).unwrap()
}
type RawEntity<'a> = (&'a str, Vec<(i32, &'a str)>);
fn raw_entities(text: &str) -> Vec<RawEntity<'_>> {
let lines: Vec<_> = text.lines().collect();
assert_eq!(lines.len() % 2, 0);
let pairs: Vec<_> = lines.chunks_exact(2).map(|p| (p[0].trim().parse::<i32>().unwrap(), p[1].trim())).collect();
let start = pairs.iter().position(|p| *p == (2, "ENTITIES")).unwrap();
let mut entities: Vec<RawEntity<'_>> = Vec::new();
for &(code, value) in &pairs[start + 1..] {
match (code, value) {
(0, "ENDSEC") => return entities,
(0, kind) => entities.push((kind, Vec::new())),
pair => entities.last_mut().unwrap().1.push(pair),
}
}
panic!("missing ENTITIES terminator");
}
fn scalar(entity: &RawEntity<'_>, code: i32) -> f64 {
let values: Vec<_> = entity.1.iter().filter(|p| p.0 == code).map(|p| p.1.parse::<f64>().unwrap()).collect();
assert_eq!(values.len(), 1, "group {code}: {entity:?}");
values[0]
}
// Read group 42 beside its owning vertex, rather than collecting all bulges separately:
// omitting a zero group must not shift the next nonzero bulge to a different edge.
fn raw_polyline(entity: &RawEntity<'_>) -> (Vec<DxfVertex>, bool) {
assert_eq!(entity.0, "LWPOLYLINE");
let mut vertices: Vec<DxfVertex> = Vec::new();
for &(code, value) in &entity.1 {
match code {
10 => vertices.push(DxfVertex { at: Vec2::new(value.parse().unwrap(), f64::NAN), bulge: 0.0 }),
20 => vertices.last_mut().unwrap().at.y = value.parse().unwrap(),
42 => vertices.last_mut().unwrap().bulge = value.parse().unwrap(),
_ => {}
}
}
assert_eq!(scalar(entity, 90), vertices.len() as f64);
assert!(vertices.iter().all(|v| v.at.is_finite() && v.bulge.is_finite()));
let flags = scalar(entity, 70) as u32;
(vertices, flags & 1 != 0)
}
fn one_polyline(shapes: &[DxfShape], closed: bool) -> &[DxfVertex] {
let [DxfShape::BulgedPolyline { vertices, closed: actual }] = shapes else { panic!("{shapes:?}") };
assert_eq!(*actual, closed);
vertices
}
fn assert_serialized_vertices(text: &str, expected: &[DxfVertex], closed: bool) -> Vec<DxfVertex> {
let entities = raw_entities(text);
assert_eq!(entities.len(), 1);
let (vertices, actual_closed) = raw_polyline(&entities[0]);
assert_eq!(actual_closed, closed);
assert_eq!(vertices.len(), expected.len());
for (actual, expected) in vertices.iter().zip(expected) {
point_near(actual.at, expected.at);
number_near(actual.bulge, expected.bulge, BULGE_EPS);
}
vertices
}
// Algebraic inverse from chord and sagitta ratio; deliberately does not use the reader's
// atan/sin/cos construction, the converter, or Seg2::fit_arcs as an oracle.
fn assert_arc_support(a: &DxfVertex, b: &DxfVertex, center: Vec2, radius: f64) {
assert!(a.bulge != 0.0);
let chord = b.at - a.at;
let square = a.bulge * a.bulge;
let recovered_center = (a.at + b.at) * 0.5 + chord.perp() * ((1.0 - square) / (4.0 * a.bulge));
let recovered_radius = chord.len() * (1.0 + square) / (4.0 * a.bulge.abs());
point_near(recovered_center, center);
number_near(recovered_radius, radius, POSITION_EPS);
}
fn assert_readback(text: &str, expected: &[Seg2]) {
let actual = read_dxf(text).unwrap();
assert_eq!(actual.len(), expected.len(), "{actual:?}");
for (actual, expected) in actual.iter().zip(expected) {
match (actual, expected) {
(Geom2::Line(a, b), Seg2::Line { a: ea, b: eb }) => {
point_near(*a, *ea);
point_near(*b, *eb);
}
(Geom2::Arc { c, a, b }, Seg2::Arc { center, radius, sweep, .. }) => {
point_near(*c, *center);
number_near(a.dist(*c), *radius, POSITION_EPS);
number_near(b.dist(*c), *radius, POSITION_EPS);
// Geom2 normalizes clockwise arcs to counter-clockwise endpoint order.
let (ea, eb) = if *sweep > 0.0 { (expected.start(), expected.end()) } else { (expected.end(), expected.start()) };
point_near(*a, ea);
point_near(*b, eb);
}
_ => panic!("read {actual:?}, expected {expected:?}"),
}
}
}
fn assert_arc_style(shapes: &[DxfShape], segment: &Seg2) {
assert!(!shapes.is_empty(), "nonempty curve was discarded");
assert!(shapes.iter().all(|s| matches!(s, DxfShape::Line(..) | DxfShape::Arc { .. } | DxfShape::Circle { .. })));
let ends: Vec<_> = shapes
.iter()
.flat_map(|shape| match shape {
DxfShape::Line(a, b) => vec![*a, *b],
DxfShape::Arc { c, r, a0, a1 } => vec![*c + Vec2::from_angle(a0.to_radians()) * *r, *c + Vec2::from_angle(a1.to_radians()) * *r],
_ => Vec::new(),
})
.collect();
for endpoint in [segment.start(), segment.end()] {
assert!(ends.iter().any(|p| p.dist(endpoint) < POSITION_EPS), "missing endpoint {endpoint:?}: {shapes:?}");
}
}
#[test]
fn primitive_paths_dispatch_by_style_without_losing_arc_direction() {
let line = Seg2::Line { a: Vec2::new(2.0, -3.0), b: Vec2::new(11.0, 4.0) };
for style in styles() {
let out = path_shapes(DxfPath::Open(&[line]), style).unwrap();
match style {
DxfPathStyle::Polyline => {
let vertices = one_polyline(&out, false);
assert_eq!(vertices.len(), 2);
assert!(vertices.iter().all(|v| v.bulge == 0.0));
}
_ => assert_eq!(out, vec![DxfShape::Line(line.start(), line.end())]),
}
assert_readback(&drawing(&out), &[line]);
for sweep in [-FRAC_PI_2, FRAC_PI_2] {
let arc = Seg2::Arc { center: Vec2::new(3.0, -5.0), radius: 7.0, start: 0.3, sweep };
let out = path_shapes(DxfPath::Open(&[arc]), style).unwrap();
match style {
DxfPathStyle::Polyline => {
let vertices = one_polyline(&out, false);
assert_eq!(vertices.len(), 2);
number_near(vertices[0].bulge, sweep.signum() * (2.0_f64.sqrt() - 1.0), BULGE_EPS);
assert_eq!(vertices[1].bulge, 0.0);
}
_ => assert!(matches!(out.as_slice(), [DxfShape::Arc { .. }])),
}
assert_readback(&drawing(&out), &[arc]);
}
}
}
#[test]
fn conic_styles_keep_exact_ellipses_and_rational_splines_unless_fitting_is_requested() {
let (a, apex, b) = (Vec2::new(12.0, 0.0), Vec2::new(12.0, 5.0), Vec2::new(0.0, 5.0));
for w in [FRAC_1_SQRT_2, 1.0, 1.75] {
let conic = Seg2::Conic { a, apex, b, w };
for style in styles() {
let out = path_shapes(DxfPath::Open(&[conic]), style).unwrap();
if matches!(style, DxfPathStyle::Arcs { .. }) {
assert_arc_style(&out, &conic);
} else if w < 1.0 {
assert!(matches!(out.as_slice(), [DxfShape::Ellipse { .. }]));
} else {
let [DxfShape::Spline { degree, ctrl, weights, .. }] = out.as_slice() else { panic!("{out:?}") };
assert_eq!(*degree, 2);
assert_eq!(ctrl, &[a, apex, b]);
let expected_weights = (w != 1.0).then(|| vec![1.0, w, 1.0]);
assert_eq!(weights, &expected_weights);
}
}
}
}
#[test]
fn degrees_one_through_nine_use_their_actual_public_curve_representation() {
for degree in 1..=9 {
let points: Vec<_> = (0..=degree).map(|i| Vec2::new(2.0 * i as f64, 0.08 * (i * (degree - i)) as f64)).collect();
let segment = bezier(&points).unwrap();
for style in styles() {
let out = path_shapes(DxfPath::Open(&[segment]), style).unwrap();
if matches!(style, DxfPathStyle::Arcs { .. }) {
assert_arc_style(&out, &segment);
} else if degree == 1 {
if matches!(style, DxfPathStyle::Polyline) {
assert_eq!(one_polyline(&out, false).len(), 2);
} else {
assert_eq!(out, vec![DxfShape::Line(points[0], points[1])]);
}
} else {
let [DxfShape::Spline { degree: actual, ctrl, knots, .. }] = out.as_slice() else { panic!("{out:?}") };
assert_eq!(usize::from(*actual), degree);
assert_eq!(ctrl, &points);
assert_eq!(knots.len(), 2 * (degree + 1));
assert!(knots[..=degree].iter().all(|k| *k == 0.0));
assert!(knots[degree + 1..].iter().all(|k| *k == 1.0));
}
}
}
}
#[test]
fn whole_circle_and_ellipse_dispatch_through_the_same_public_path_entry() {
let (center, u, v) = (Vec2::new(4.0, -2.0), Vec2::new(12.0, 0.0), Vec2::new(0.0, 5.0));
let quarter = |u, v| Seg2::Conic { a: center + u, apex: center + u + v, b: center + v, w: FRAC_1_SQRT_2 };
let ellipse = Loop2 { segs: vec![quarter(u, v), quarter(v, -u), quarter(-u, -v), quarter(-v, u)] };
for style in styles() {
let circle = Loop2::circle(center, 6.0);
let out = path_shapes(DxfPath::Closed(&circle), style).unwrap();
assert_eq!(out, vec![DxfShape::Circle { c: center, r: 6.0 }]);
for sweep in [-TAU, TAU] {
let arc = Seg2::Arc { center, radius: 6.0, start: 0.4, sweep };
assert_eq!(path_shapes(DxfPath::Open(&[arc]), style).unwrap(), out);
}
let out = path_shapes(DxfPath::Closed(&ellipse), style).unwrap();
if matches!(style, DxfPathStyle::Arcs { .. }) {
assert!(!out.is_empty());
assert!(out.iter().all(|s| matches!(s, DxfShape::Line(..) | DxfShape::Arc { .. } | DxfShape::Circle { .. })));
} else {
assert!(matches!(out.as_slice(), [DxfShape::Ellipse { .. }]));
}
}
}
#[test]
fn half_turn_boundary_splits_only_larger_arcs_and_keeps_signed_bulges() {
let (center, radius, start) = (Vec2::new(12.0, -7.0), 9.0, 0.37);
for magnitude in [PI - 1e-6, PI, PI + 1e-6, 1.5 * PI] {
for sign in [-1.0, 1.0] {
let sweep = sign * magnitude;
let arc = Seg2::Arc { center, radius, start, sweep };
let out = path_shapes(DxfPath::Open(&[arc]), DxfPathStyle::Polyline).unwrap();
let vertices = one_polyline(&out, false);
let count = if magnitude > PI { 2 } else { 1 };
assert_eq!(vertices.len(), count + 1);
let expected: Vec<_> = (0..=count)
.map(|i| {
let angle = start + sweep * i as f64 / count as f64;
DxfVertex {
at: center + Vec2::new(angle.cos(), angle.sin()) * radius,
bulge: if i == count { 0.0 } else { (sweep / (4.0 * count as f64)).tan() },
}
})
.collect();
for (actual, expected) in vertices.iter().zip(&expected) {
point_near(actual.at, expected.at);
number_near(actual.bulge, expected.bulge, BULGE_EPS);
}
let text = drawing(&out);
let raw = assert_serialized_vertices(&text, &expected, false);
for edge in raw.windows(2) {
assert_arc_support(&edge[0], &edge[1], center, radius);
}
let pieces: Vec<_> = (0..count)
.map(|i| Seg2::Arc { center, radius, start: start + sweep * i as f64 / count as f64, sweep: sweep / count as f64 })
.collect();
assert_readback(&text, &pieces);
}
}
}
#[test]
fn open_runs_preserve_zero_bulges_between_arcs_and_have_no_closing_edge() {
let first = Seg2::Arc { center: Vec2::new(0.0, 0.0), radius: 4.0, start: FRAC_PI_2, sweep: -FRAC_PI_2 };
let second = Seg2::Arc { center: Vec2::new(9.0, 0.0), radius: 3.0, start: PI, sweep: FRAC_PI_2 };
let path = [first, Seg2::Line { a: first.end(), b: second.start() }, second];
let q = 2.0_f64.sqrt() - 1.0;
for reverse in [false, true] {
let segments = if reverse { path.iter().rev().map(Seg2::reversed).collect::<Vec<_>>() } else { path.to_vec() };
let out = path_shapes(DxfPath::Open(&segments), DxfPathStyle::Polyline).unwrap();
let vertices = one_polyline(&out, false);
assert_eq!(vertices.len(), 4);
let bulges = [-q, 0.0, q, 0.0];
let expected: Vec<_> =
(0..4).map(|i| DxfVertex { at: if i < 3 { segments[i].start() } else { segments[2].end() }, bulge: bulges[i] }).collect();
let text = drawing(&out);
let raw = assert_serialized_vertices(&text, &expected, false);
for (i, segment) in segments.iter().enumerate() {
if let Seg2::Arc { center, radius, .. } = segment {
assert_arc_support(&raw[i], &raw[i + 1], *center, *radius);
}
}
assert_readback(&text, &segments);
}
}
#[test]
fn a_closed_last_arc_owns_the_final_bulge_and_reversal_moves_it_to_its_new_edge() {
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 4.0, start: 0.0, sweep: FRAC_PI_2 };
let (corner1, corner2) = (Vec2::new(-3.0, 4.0), Vec2::new(-3.0, 0.0));
let loop_ = Loop2 {
segs: vec![Seg2::Line { a: arc.end(), b: corner1 }, Seg2::Line { a: corner1, b: corner2 }, Seg2::Line { a: corner2, b: arc.start() }, arc],
};
for (path, bulges) in [(loop_.clone(), [0.0, 0.0, 0.0, 2.0_f64.sqrt() - 1.0]), (loop_.reversed(), [1.0 - 2.0_f64.sqrt(), 0.0, 0.0, 0.0])] {
let out = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Polyline).unwrap();
let vertices = one_polyline(&out, true);
assert_eq!(vertices.len(), 4, "closed paths must not duplicate the first vertex");
let expected: Vec<_> = path.segs.iter().zip(bulges).map(|(s, bulge)| DxfVertex { at: s.start(), bulge }).collect();
let text = drawing(&out);
let raw = assert_serialized_vertices(&text, &expected, true);
for (i, segment) in path.segs.iter().enumerate() {
if let Seg2::Arc { center, radius, .. } = segment {
assert_arc_support(&raw[i], &raw[(i + 1) % raw.len()], *center, *radius);
}
}
assert_readback(&text, &path.segs);
}
}
#[test]
fn exact_curves_interrupt_closed_polyline_runs_without_inventing_closing_lines() {
let (a, b, c, d, e) = (Vec2::ZERO, Vec2::new(4.0, 0.0), Vec2::new(8.0, 4.0), Vec2::new(8.0, 9.0), Vec2::new(0.0, 12.0));
let lines = [(a, b), (c, d), (e, a)];
let path = Loop2 {
segs: vec![
Seg2::Line { a, b },
Seg2::Cubic { p0: b, p1: Vec2::new(6.0, 0.0), p2: Vec2::new(8.0, 2.0), p3: c },
Seg2::Line { a: c, b: d },
Seg2::Conic { a: d, apex: Vec2::new(8.0, 12.0), b: e, w: FRAC_1_SQRT_2 },
Seg2::Line { a: e, b: a },
],
};
let out = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Polyline).unwrap();
assert_eq!(out.iter().filter(|s| matches!(s, DxfShape::Spline { degree: 3, .. })).count(), 1);
assert_eq!(out.iter().filter(|s| matches!(s, DxfShape::Ellipse { .. })).count(), 1);
let text = drawing(&out);
let entities = raw_entities(&text);
let mut edges = Vec::new();
for entity in entities.iter().filter(|e| e.0 == "LWPOLYLINE") {
let (vertices, closed) = raw_polyline(entity);
assert!(!closed, "a run interrupted by an exact curve is open");
assert!(vertices.iter().all(|v| v.bulge == 0.0));
edges.extend(vertices.windows(2).map(|v| (v[0].at, v[1].at)));
}
// Prefix/suffix runs may be joined across the original seam. Either representation must
// contain exactly the three authored line edges and no chord across a free-form curve.
assert_eq!(edges.len(), lines.len());
for (a, b) in lines {
assert_eq!(edges.iter().filter(|(ea, eb)| ea.dist(a) < POSITION_EPS && eb.dist(b) < POSITION_EPS).count(), 1);
}
let read = read_dxf(&text).unwrap();
assert_eq!(read.iter().filter(|s| matches!(s, Geom2::Line(..))).count(), 3);
}
fn invalid(path: &[Seg2], style: DxfPathStyle) -> String {
match path_shapes(DxfPath::Open(path), style) {
Err(IoError::Invalid(message)) => message,
other => panic!("expected contextual invalid-path error, got {other:?}"),
}
}
#[test]
fn options_are_validated_even_for_empty_and_line_only_paths() {
let line = [Seg2::Line { a: Vec2::ZERO, b: Vec2::X }];
for tolerance_mm in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 0.0, 0.5e-9] {
let style = DxfPathStyle::Arcs { tolerance_mm };
invalid(&[], style);
invalid(&line, style);
}
for style in styles().into_iter().chain([DxfPathStyle::Arcs { tolerance_mm: 1e-9 }]) {
assert!(path_shapes(DxfPath::Open(&[]), style).unwrap().is_empty());
assert!(!path_shapes(DxfPath::Open(&line), style).unwrap().is_empty());
}
}
#[test]
fn invalid_bezier_storage_is_refused_before_helpers_can_clamp_the_degree() {
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
for (i, point) in p.iter_mut().enumerate() {
*point = Vec2::new(i as f64, (i % 3) as f64);
}
for n in [0, 1, 2, 3, 10, u8::MAX] {
for style in styles() {
invalid(&[Seg2::Bezier { n, p }], style);
}
}
}
#[test]
fn piece_errors_keep_original_input_indices_when_earlier_curves_can_expand() {
let predecessors = [
Seg2::Arc { center: Vec2::new(4.0, 2.0), radius: 3.0, start: 0.4, sweep: 1.5 * PI },
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(0.0, 8.0), p2: Vec2::new(7.0, -5.0), p3: Vec2::new(9.0, 1.0) },
];
for predecessor in predecessors {
let mut p = [Vec2::ZERO; MAX_BEZIER_CTRL];
p[0] = predecessor.end();
let path = [Seg2::Line { a: predecessor.start() - Vec2::X, b: predecessor.start() }, predecessor, Seg2::Bezier { n: 10, p }];
for style in styles() {
let message = invalid(&path, style);
assert!(message.contains("piece 3 of 3"), "{message}");
assert!(message.contains("degree 10"), "{message}");
}
}
}
#[test]
fn closed_intent_with_a_gap_preserves_pieces_as_open_runs_without_bridging() {
// Closed is caller intent, not a topology certificate. The explicit gap from (1,0)
// to (2,0) must remain absent from the output, even though the last point is the first.
let path = Loop2 { segs: vec![Seg2::Line { a: Vec2::ZERO, b: Vec2::X }, Seg2::Line { a: Vec2::new(2.0, 0.0), b: Vec2::ZERO }] };
for style in styles() {
let out = path_shapes(DxfPath::Closed(&path), style).unwrap();
let text = drawing(&out);
if matches!(style, DxfPathStyle::Polyline) {
assert_eq!(out.len(), 2);
for (shape, segment) in out.iter().zip(&path.segs) {
let vertices = one_polyline(std::slice::from_ref(shape), false);
assert_eq!(vertices.len(), 2);
point_near(vertices[0].at, segment.start());
point_near(vertices[1].at, segment.end());
assert!(vertices.iter().all(|v| v.bulge == 0.0));
}
for entity in raw_entities(&text) {
assert!(!raw_polyline(&entity).1);
}
} else {
assert_eq!(out, vec![DxfShape::Line(Vec2::ZERO, Vec2::X), DxfShape::Line(Vec2::new(2.0, 0.0), Vec2::ZERO)]);
}
assert_readback(&text, &path.segs);
}
}
#[test]
fn nonfinite_controls_and_invalid_arcs_are_rejected_in_every_style() {
let controls = [
Seg2::Line { a: Vec2::ZERO, b: Vec2::new(f64::NAN, 2.0) },
Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::new(1.0, f64::INFINITY), p2: Vec2::new(2.0, 1.0), p3: Vec2::new(3.0, 0.0) },
Seg2::Conic { a: Vec2::ZERO, apex: Vec2::new(1.0, 2.0), b: Vec2::new(2.0, 0.0), w: f64::NAN },
// Finite inputs may still overflow the derived arc endpoint or bounds.
Seg2::Arc { center: Vec2::new(f64::MAX, 0.0), radius: f64::MAX, start: 0.0, sweep: FRAC_PI_2 },
];
for style in styles() {
for segment in controls {
invalid(&[segment], style);
}
for radius in [0.0, -1.0, f64::INFINITY, f64::NAN] {
invalid(&[Seg2::Arc { center: Vec2::ZERO, radius, start: 0.0, sweep: PI }], style);
}
// A one-ULP overrun is still more than one turn; the old near-TAU shortcut must not
// silently turn it into a circle. Larger positive/negative overruns are also refused.
for sweep in [0.0, f64::NAN, f64::INFINITY, f64::from_bits(TAU.to_bits() + 1), -f64::from_bits(TAU.to_bits() + 1), 1.1 * TAU, -1.1 * TAU] {
invalid(&[Seg2::Arc { center: Vec2::ZERO, radius: 3.0, start: 0.0, sweep }], style);
}
}
}
#[test]
fn fitting_cannot_report_success_after_discarding_a_nonempty_piece() {
// The existing fitter may return Some(empty) for a point-sized cubic. The public path
// converter must refuse that result instead of silently dropping an authored piece.
let at = Vec2::new(2.0, 3.0);
let point_curve = Seg2::Cubic { p0: at, p1: at, p2: at, p3: at };
invalid(&[point_curve], DxfPathStyle::Arcs { tolerance_mm: 0.01 });
}
#[test]
fn bulged_arc_extents_include_cardinal_points_between_its_vertices() {
let arc = Seg2::Arc { center: Vec2::new(12.0, -7.0), radius: 9.0, start: 0.2, sweep: 1.5 * PI };
let out = path_shapes(DxfPath::Open(&[arc]), DxfPathStyle::Polyline).unwrap();
let text = drawing(&out);
let lines: Vec<_> = text.lines().collect();
let pairs: Vec<_> = lines.chunks_exact(2).map(|p| (p[0].trim().parse::<i32>().unwrap(), p[1].trim())).collect();
let bound = |name: &str| {
let start = pairs.iter().position(|p| *p == (9, name)).unwrap();
let fields: Vec<_> = pairs[start + 1..].iter().take_while(|p| p.0 != 9 && p.0 != 0).copied().collect();
let entity = (name, fields);
Vec2::new(scalar(&entity, 10), scalar(&entity, 20))
};
// The arc crosses pi/2, pi and 3pi/2. None of the two-piece polyline's interior
// vertices supplies all three extremes; the rightmost point is its original start.
point_near(bound("$EXTMIN"), Vec2::new(3.0, -16.0));
point_near(bound("$EXTMAX"), Vec2::new(12.0 + 9.0 * 0.2_f64.cos(), 2.0));
}
// Resource-boundary fixtures retain small exact-integer coordinates and do not serialize
// the large paths. Repeated nonzero pieces are intentional: conversion must preserve them,
// not deduplicate geometry to make a path fit a resource budget.
const PATH_ITEM_LIMIT: usize = 200_000;
fn alternating_lines(count: usize) -> Vec<Seg2> {
(0..count).map(|i| if i & 1 == 0 { Seg2::Line { a: Vec2::ZERO, b: Vec2::X } } else { Seg2::Line { a: Vec2::X, b: Vec2::ZERO } }).collect()
}
fn assert_line_entities(shapes: &[DxfShape], segments: &[Seg2]) {
assert_eq!(shapes.len(), segments.len());
for (actual, expected) in shapes.iter().zip(segments) {
let Seg2::Line { a, b } = expected else { panic!("line fixture expected") };
assert_eq!(actual, &DxfShape::Line(*a, *b));
}
}
#[test]
fn input_and_exact_entity_budgets_accept_the_boundary_without_truncation() {
let mut path = alternating_lines(PATH_ITEM_LIMIT);
{
let shapes = path_shapes(DxfPath::Open(&path), DxfPathStyle::Exact).unwrap();
// Exact conversion emits one entity per piece. This reaches the public entity
// boundary as well as the independent input boundary; all geometry must remain.
assert_eq!(shapes.len(), PATH_ITEM_LIMIT);
assert_line_entities(&shapes, &path);
}
path.push(Seg2::Line { a: Vec2::ZERO, b: Vec2::X });
for style in styles() {
let message = invalid(&path, style);
assert!(message.contains("input piece budget"), "{message}");
}
}
#[test]
fn open_and_closed_vertex_budgets_count_the_terminal_vertex_differently() {
let path = Loop2 { segs: alternating_lines(PATH_ITEM_LIMIT) };
{
let shapes = path_shapes(DxfPath::Open(&path.segs[..PATH_ITEM_LIMIT - 1]), DxfPathStyle::Polyline).unwrap();
let vertices = one_polyline(&shapes, false);
assert_eq!(vertices.len(), PATH_ITEM_LIMIT);
for (i, vertex) in vertices.iter().enumerate() {
assert_eq!(vertex.at, if i & 1 == 0 { Vec2::ZERO } else { Vec2::X });
assert_eq!(vertex.bulge, 0.0);
}
}
{
// The same even-length chain ends at its start. Closed is only caller intent;
// these authored overlapping edges still all count, without a duplicate terminal.
let shapes = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Polyline).unwrap();
let vertices = one_polyline(&shapes, true);
assert_eq!(vertices.len(), PATH_ITEM_LIMIT);
for (i, vertex) in vertices.iter().enumerate() {
assert_eq!(vertex.at, if i & 1 == 0 { Vec2::ZERO } else { Vec2::X });
assert_eq!(vertex.bulge, 0.0);
}
}
// Still within the input limit, but its open representation needs 200,001 vertices.
let message = invalid(&path.segs, DxfPathStyle::Polyline);
assert!(message.contains("polyline vertex budget"), "{message}");
}
#[test]
fn vertex_budget_is_accumulated_across_disconnected_runs() {
let line = Seg2::Line { a: Vec2::ZERO, b: Vec2::X };
let mut path = vec![line; PATH_ITEM_LIMIT / 2];
{
let shapes = path_shapes(DxfPath::Open(&path), DxfPathStyle::Polyline).unwrap();
assert_eq!(shapes.len(), PATH_ITEM_LIMIT / 2);
for shape in &shapes {
let vertices = one_polyline(std::slice::from_ref(shape), false);
assert_eq!(vertices.len(), 2);
assert_eq!(vertices[0].at, Vec2::ZERO);
assert_eq!(vertices[1].at, Vec2::X);
assert!(vertices.iter().all(|v| v.bulge == 0.0));
}
}
path.push(line);
// Only 100,001 inputs/entities; allowing each tiny run its own budget would miss this.
let message = invalid(&path, DxfPathStyle::Polyline);
assert!(message.contains("polyline vertex budget"), "{message}");
}
fn assert_budget_run_geometry(shapes: &[DxfShape], segments: &[Seg2]) -> usize {
assert_eq!(shapes.len(), segments.len(), "every fixture piece is a separate run or exact arc");
let mut vertex_count = 0;
for (shape, segment) in shapes.iter().zip(segments) {
match (shape, segment) {
(DxfShape::Arc { c, r, a0, a1 }, Seg2::Arc { center, radius, .. }) => {
// The fixture's 270-degree arcs may legally remain exact ARC entities.
assert_eq!(c, center);
assert_eq!(r, radius);
assert_eq!((*a0, *a1), (0.0, 270.0));
}
(DxfShape::BulgedPolyline { vertices, closed }, Seg2::Arc { center, radius, .. }) => {
assert!(!closed);
assert_eq!(vertices.len(), 3);
point_near(vertices[0].at, segment.start());
point_near(vertices[2].at, segment.end());
assert_eq!(vertices[2].bulge, 0.0);
let mut sweep = 0.0;
for edge in vertices.windows(2) {
assert!(edge[0].bulge > 0.0 && edge[0].bulge <= 1.0);
assert_arc_support(&edge[0], &edge[1], *center, *radius);
sweep += 4.0 * edge[0].bulge.atan();
}
number_near(sweep, 1.5 * PI, BULGE_EPS);
vertex_count += vertices.len();
}
(DxfShape::BulgedPolyline { vertices, closed }, Seg2::Line { a, b }) => {
assert!(!closed);
assert_eq!(vertices.len(), 2);
assert_eq!(vertices[0].at, *a);
assert_eq!(vertices[1].at, *b);
assert!(vertices.iter().all(|v| v.bulge == 0.0));
vertex_count += vertices.len();
}
_ => panic!("fixture geometry lost: {segment:?} became {shape:?}"),
}
}
vertex_count
}
#[test]
fn optional_arc_packing_preserves_support_and_never_exceeds_the_vertex_budget() {
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 4.0, start: 0.0, sweep: 1.5 * PI };
// A packed disconnected 270-degree arc has two curved edges and three vertices.
// 66,666 such runs plus an isolated line need at most 200,000 vertices, including
// when every arc packs. Exact ARC fallback uses no polyline vertices and is permitted.
let arc_count = PATH_ITEM_LIMIT / 3;
let mut path = vec![arc; arc_count];
path.push(Seg2::Line { a: Vec2::X, b: Vec2::new(2.0, 0.0) });
{
let shapes = path_shapes(DxfPath::Open(&path), DxfPathStyle::Polyline).unwrap();
assert!(assert_budget_run_geometry(&shapes, &path) <= PATH_ITEM_LIMIT);
}
path.push(arc);
assert!(path.len() < PATH_ITEM_LIMIT);
// If the expanded representation exceeds the budget, refuse it. If optional exact
// fallbacks keep it within the budget, success must preserve every original support.
// This never calibrates expectations to which arcs the current packer happens to admit.
match path_shapes(DxfPath::Open(&path), DxfPathStyle::Polyline) {
Ok(shapes) => assert!(assert_budget_run_geometry(&shapes, &path) <= PATH_ITEM_LIMIT),
Err(IoError::Invalid(message)) => assert!(message.contains("polyline vertex budget"), "{message}"),
other => panic!("unexpected path failure: {other:?}"),
}
}
#[test]
fn only_mixed_paths_apply_the_aggregate_fitting_budget_to_retained_primitives() {
const FITTING_LIMIT: usize = 16_384;
const PER_CURVE_LIMIT: usize = 2_048;
let style = DxfPathStyle::Arcs { tolerance_mm: 0.01 };
let arc = Seg2::Arc { center: Vec2::new(2.0, 0.0), radius: 1.0, start: 0.0, sweep: FRAC_PI_2 };
{
let mut primitives = alternating_lines(FITTING_LIMIT);
primitives.push(arc);
let shapes = path_shapes(DxfPath::Open(&primitives), style).unwrap();
assert_eq!(shapes.len(), FITTING_LIMIT + 1);
assert_line_entities(&shapes[..FITTING_LIMIT], &primitives[..FITTING_LIMIT]);
assert_arc_style(&shapes[FITTING_LIMIT..], &arc);
}
// A straight cubic has an exact, inexpensive fit. No assertion depends on how many
// pieces the fitter chooses: any admitted fit has 1..=2,048 output pieces.
let curve = Seg2::Cubic { p0: Vec2::ZERO, p1: Vec2::X, p2: Vec2::new(2.0, 0.0), p3: Vec2::new(3.0, 0.0) };
let retained = FITTING_LIMIT - PER_CURVE_LIMIT;
let mut mixed = alternating_lines(retained);
mixed.push(curve);
{
let shapes = path_shapes(DxfPath::Open(&mixed), style).unwrap();
assert!((retained + 1..=FITTING_LIMIT).contains(&shapes.len()));
assert_line_entities(&shapes[..retained], &mixed[..retained]);
assert_arc_style(&shapes[retained..], &curve);
}
assert_eq!(mixed.pop(), Some(curve));
mixed.extend(alternating_lines(PER_CURVE_LIMIT));
mixed.push(curve);
// 16,384 retained primitives already exhaust the mixed-path budget. Every nonempty
// fitted result exceeds it, regardless of the fitter's particular subdivision count.
assert_eq!(mixed.len(), FITTING_LIMIT + 1);
let message = invalid(&mixed, style);
assert!(message.contains("path arc fitting budget"), "{message}");
}
#[test]
fn large_translation_cannot_turn_a_three_quarter_unit_radius_into_a_unit_radius() {
let coordinate = (1_u64 << 52) as f64;
let center = Vec2::new(coordinate, coordinate);
let radius = 0.75;
let arc = Seg2::Arc { center, radius, start: 0.0, sweep: FRAC_PI_2 };
let shapes = path_shapes(DxfPath::Open(&[arc]), DxfPathStyle::Polyline).unwrap();
let text = drawing(&shapes);
let entities = raw_entities(&text);
assert_eq!(entities.len(), 1);
match entities[0].0 {
"ARC" => {
// ARC preserves the center, radius and angle directly, even when its derived
// f64 endpoints round. This exact fallback is allowed when packing loses support.
assert_eq!(scalar(&entities[0], 10), center.x);
assert_eq!(scalar(&entities[0], 20), center.y);
assert_eq!(scalar(&entities[0], 40), radius);
assert_eq!(scalar(&entities[0], 50), 0.0);
assert_eq!(scalar(&entities[0], 51), 90.0);
}
"LWPOLYLINE" => {
let (vertices, closed) = raw_polyline(&entities[0]);
assert!(!closed && vertices.len() >= 2);
assert_eq!(vertices[0].at, arc.start());
assert_eq!(vertices.last().unwrap().at, arc.end());
assert_eq!(vertices.last().unwrap().bulge, 0.0);
let mut sweep = 0.0;
for edge in vertices.windows(2) {
// Translate BEFORE reconstructing. At 2^52, adding a center correction in
// world coordinates can erase it and make a wrong circle appear valid.
let (a, b, bulge) = (edge[0].at - center, edge[1].at - center, edge[0].bulge);
assert!(bulge > 0.0 && bulge <= 1.0);
let chord = b - a;
let square = bulge * bulge;
let recovered_center = (a + b) * 0.5 + chord.perp() * ((1.0 - square) / (4.0 * bulge));
let recovered_radius = chord.len() * (1.0 + square) / (4.0 * bulge);
// Small local arithmetic gets an epsilon-scale allowance, never an ULP
// allowance based on the huge translation (which could hide 0.25 mm loss).
let support_epsilon = 64.0 * f64::EPSILON * radius;
assert!(recovered_center.len() <= support_epsilon, "local center {recovered_center:?}");
assert!((recovered_radius - radius).abs() <= support_epsilon, "radius {recovered_radius}, expected {radius}");
sweep += 4.0 * bulge.atan();
}
number_near(sweep, FRAC_PI_2, 64.0 * f64::EPSILON);
}
kind => panic!("source arc support was lost or replaced: {kind}"),
}
}
fn assert_serialized_arc_span(shapes: &[DxfShape], arc: &Seg2, span_error_degrees: f64) {
let Seg2::Arc { center, radius, sweep, .. } = arc else { panic!("arc fixture expected") };
assert!(matches!(shapes, [DxfShape::Arc { .. }]), "a partial arc must not become a circle or disappear: {shapes:?}");
let text = drawing(shapes);
let entities = raw_entities(&text);
assert_eq!(entities.len(), 1);
assert_eq!(entities[0].0, "ARC");
assert_eq!(scalar(&entities[0], 10), center.x);
assert_eq!(scalar(&entities[0], 20), center.y);
assert_eq!(scalar(&entities[0], 40), *radius);
let (start, end) = (scalar(&entities[0], 50), scalar(&entities[0], 51));
assert!(start.is_finite() && end.is_finite());
// 360 and 0 are the same direction. Comparing just their stored numbers would
// accept a degenerate ARC, even if they were distinct before serialization.
let serialized_sweep = (end - start).rem_euclid(360.0);
assert!(serialized_sweep > 0.0 && serialized_sweep < 360.0, "collapsed serialized ARC: {start} -> {end}");
number_near(serialized_sweep, sweep.abs().to_degrees(), span_error_degrees);
}
fn preserved_arc_or_contextual_refusal(arc: Seg2, style: DxfPathStyle) {
let Seg2::Arc { sweep, .. } = arc else { panic!("arc fixture expected") };
match path_shapes(DxfPath::Open(&[arc]), style) {
// A future precise writer may preserve these spans. Allow the rounding of unit
// conversion/subtraction, not an absolute tolerance that can swallow a tiny arc.
Ok(shapes) => assert_serialized_arc_span(&shapes, &arc, 2.0 * f64::EPSILON * sweep.abs().to_degrees()),
Err(IoError::Invalid(message)) => {
assert!(message.contains("piece 1 of 1"), "{message}");
assert!(message.contains("arc"), "{message}");
}
other => panic!("unexpected arc failure: {other:?}"),
}
}
#[test]
fn serialized_zero_crossing_arcs_keep_their_span_or_refuse_with_piece_context() {
for style in [DxfPathStyle::Exact, DxfPathStyle::Arcs { tolerance_mm: 0.01 }] {
for (start, sweep) in [(-1e-16, 1e-16), (0.0, -1e-16)] {
preserved_arc_or_contextual_refusal(Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start, sweep }, style);
}
// A small but ordinary arc remains supported. Two independently rounded degree
// fields contribute at most 1e-12 degrees; subtraction adds at most a few ULPs.
let writer_error = 1e-12 + 4.0 * f64::EPSILON * 360.0;
for sweep in [-1e-6, 1e-6] {
let arc = Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep };
let shapes = path_shapes(DxfPath::Open(&[arc]), style).unwrap();
assert_serialized_arc_span(&shapes, &arc, writer_error);
}
}
}
#[test]
fn an_arc_one_ulp_short_of_a_turn_cannot_serialize_as_a_circle_or_zero_span() {
let almost_turn = f64::from_bits(TAU.to_bits() - 1);
assert!(almost_turn < TAU);
for style in [DxfPathStyle::Exact, DxfPathStyle::Arcs { tolerance_mm: 0.01 }] {
for sweep in [almost_turn, -almost_turn] {
preserved_arc_or_contextual_refusal(Seg2::Arc { center: Vec2::ZERO, radius: 1.0, start: 0.0, sweep }, style);
}
}
}
#[test]
fn cyclic_ellipse_starts_preserve_the_legacy_first_quarter_parameterization() {
let vertices = [Vec2::new(15.0, 0.0), Vec2::new(0.0, 8.0), Vec2::new(-15.0, 0.0), Vec2::new(0.0, -8.0)];
// The original full-loop writer keeps the first conic's frame instead of canonicalizing
// its major-axis sign. For these four quarters the independent legacy signs are +--+.
// These constants come from the analytic ellipse and legacy frame/decimal conventions;
// no converter or recognizer is used to calculate expected values.
let major_x = [15.0, -15.0, -15.0, 15.0];
let serialized_major_x = ["15.0", "-15.0", "-15.0", "15.0"];
for (first, (expected_major_x, expected_serialized)) in major_x.into_iter().zip(serialized_major_x).enumerate() {
let path = Loop2 {
segs: (0..4)
.map(|offset| {
let (a, b) = (vertices[(first + offset) % 4], vertices[(first + offset + 1) % 4]);
Seg2::Conic { a, apex: a + b, b, w: FRAC_1_SQRT_2 }
})
.collect(),
};
let shapes = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Exact).unwrap();
let [DxfShape::Ellipse { c, major, ratio, t0, t1 }] = shapes.as_slice() else { panic!("{shapes:?}") };
point_near(*c, Vec2::ZERO);
point_near(*major, Vec2::new(expected_major_x, 0.0));
number_near(*ratio, 8.0 / 15.0, 1e-12);
assert_eq!((*t0, *t1), (0.0, TAU));
let text = drawing(&shapes);
let entities = raw_entities(&text);
assert_eq!(entities.len(), 1);
assert_eq!(entities[0].0, "ELLIPSE");
let group = |code| entities[0].1.iter().filter(|(c, _)| *c == code).map(|(_, value)| *value).collect::<Vec<_>>();
assert_eq!(group(11), vec![expected_serialized]);
assert_eq!(group(21), vec!["0.0"]);
assert_eq!(group(40), vec!["0.533333333333"]);
}
}
#[test]
fn a_single_zero_length_line_in_a_closed_path_remains_a_writable_open_polyline() {
let at = Vec2::new(2.0, -3.0);
let path = Loop2 { segs: vec![Seg2::Line { a: at, b: at }] };
let exact = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Exact).unwrap();
assert_eq!(exact, vec![DxfShape::Line(at, at)]);
let exact_text = drawing(&exact);
let exact_entities = raw_entities(&exact_text);
assert_eq!(exact_entities.len(), 1);
assert_eq!(exact_entities[0].0, "LINE");
for (code, expected) in [(10, at.x), (20, at.y), (11, at.x), (21, at.y)] {
assert_eq!(scalar(&exact_entities[0], code), expected);
}
let shapes = path_shapes(DxfPath::Closed(&path), DxfPathStyle::Polyline).unwrap();
let expected = [DxfVertex { at, bulge: 0.0 }; 2];
assert_eq!(one_polyline(&shapes, false), expected.as_slice());
// Inspect the emitted entity: the current reader intentionally skips zero-length
// polyline edges, so it cannot establish that this authored piece was written.
let text = drawing(&shapes);
let entities = raw_entities(&text);
assert_eq!(entities.len(), 1);
let (vertices, closed) = raw_polyline(&entities[0]);
assert_eq!(vertices.as_slice(), expected.as_slice());
assert!(!closed);
assert_eq!(scalar(&entities[0], 70), 128.0);
}