mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fb2eef92d | |||
| 0ff4438c32 | |||
| 48093730fa | |||
| 237c94854f | |||
| f876e12844 |
Generated
+1
@@ -4289,6 +4289,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"sha2 0.10.9",
|
||||
"solidcraft-doc",
|
||||
"solidcraft-geom",
|
||||
"solidcraft-io",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user