feat(dxf): add bounded bulged paths and layered text import (WIP #5319 #5320)

Add the reviewed minimal X1 bulged-path producer, conservative bounds,
ordered layered bulge reader, ELLIPSE/TEXT imports and two original
cross-feature cases. Preserve legacy entry points; retain the two explicit
source-supported fixture migrations, not a blanket assertion relaxation.

Include the three original owner corrections from #8534 to unblock wasm,
without adopting later main or transferring upstream validation.

Normal cargo fmt --all passed. 223 declarations in the nine IO files
(333 including 110 unchanged UI/web declarations) are not test results.
This WIP has not been compiled or run; full CI and caller migration remain.
Earlier-source native passes do not qualify this candidate.

Co-authored-by: Ray <raykholo@users.noreply.github.com>
This commit is contained in:
lillith
2026-10-29 02:39:19 +01:00
parent 471e17f50e
commit a7f3c78b07
12 changed files with 3185 additions and 296 deletions
+6 -2
View File
@@ -40,6 +40,8 @@ pub enum Intent {
Open,
/// Add its bodies to the open design (STEP, 3MF, STL).
Insert,
/// Import it as a theme: it was dropped on Preferences' Import tile (#8226).
Theme,
}
/// A stored design, as the file panel lists it.
@@ -615,13 +617,15 @@ fn choose_file(accept: &str) -> Result<js_sys::Promise, String> {
}
/// Read files dropped on the page.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput) {
/// `theme`: the pointer is over Preferences' Import tile, so the files are themes.
pub fn take_drops(files: &Rc<Files>, raw: &mut egui::RawInput, theme: bool) {
let intent = if theme { Intent::Theme } else { Intent::Insert };
for f in std::mem::take(&mut raw.dropped_files) {
let files = files.clone();
spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(b) => files.arrive(name, b, Intent::Insert),
Ok(b) => files.arrive(name, b, intent),
Err(e) => files.say(format!("{name}: {e}"), true),
}
});
+7 -2
View File
@@ -271,6 +271,10 @@ mod web {
!companion
});
for (name, bytes, intent) in incoming {
if intent == Intent::Theme {
solidcraft_ui_egui::prefs::import_theme_bytes(&mut self.app, bytes);
continue;
}
let design = solidcraft_engine::io::is_design_path(&name);
let path = if design && intent == Intent::Open {
// A design from the computer is kept in the browser (a copy), so Save works.
@@ -287,7 +291,7 @@ mod web {
};
match intent {
Intent::Open => self.app.open_path(&path),
Intent::Insert => self.app.drop_path(&path),
Intent::Insert | Intent::Theme => self.app.drop_path(&path),
}
self.files.panel.set(false);
}
@@ -632,7 +636,8 @@ mod web {
ctx.request_repaint_after(delay);
}
if self.launch.persists() {
files::take_drops(&self.files, raw);
let theme = solidcraft_ui_egui::prefs::theme_drop_target(&self.app, ctx);
files::take_drops(&self.files, raw, theme);
} else {
raw.dropped_files.clear();
}
+1213 -180
View File
File diff suppressed because it is too large Load Diff
+380
View File
@@ -0,0 +1,380 @@
//! Nine-alignment TEXT adapter over the real parsed Ent. No parser or glyph layout.
//! Registration and the production call remain with the parent dxf module owner.
use solidcraft_geom::Vec2;
use super::text_value::decode_text_value;
use super::{Ent, MAX_WORK, layered_array_budget, layered_number, layered_required, layered_text};
use crate::dxf_out::{DxfTextHorizontalAlignment, DxfTextVerticalAlignment};
use crate::{DxfShape, IoError, MAX_DRAWING_BYTES, Result};
const MAX_TEXT_ENCODED_BYTES: usize = 250 * 7;
const MAX_TEXT_DECODED_BYTES: usize = 250 * 3;
fn invalid(reason: &str) -> IoError {
IoError::Invalid(format!("layered DXF TEXT: {reason}"))
}
fn number(entity: &Ent, code: i32) -> Result<Option<f64>> {
let value = layered_number(entity, code)?;
if value.is_some_and(|value| !value.is_finite()) {
return Err(invalid("nonfinite numeric field"));
}
Ok(value)
}
fn required(entity: &Ent, code: i32) -> Result<f64> {
let value = layered_required(entity, code)?;
if !value.is_finite() {
return Err(invalid("nonfinite required field"));
}
Ok(value)
}
/// Read nine-alignment model-space TEXT; return an error for MTEXT and any
/// unrepresented alignment/style/OCS. Input is raw Ent.s, decoded exactly once.
/// Both counters belong to the whole file; callers must not reset them per TEXT.
/// Decoded text is restricted to the current writer's faithful plain BMP subset.
pub(super) fn read_text_entity(
entity: &Ent,
k: f64,
max_encoded_bytes: usize,
max_decoded_bytes: usize,
bytes: &mut usize,
work: &mut usize,
) -> Result<DxfShape> {
if entity.kind != "TEXT" {
return Err(invalid("not supported yet: MTEXT attachment/wrapping or a non-TEXT entity"));
}
if !k.is_finite() || k <= 0.0 {
return Err(invalid("unit scale must be finite and positive"));
}
if max_encoded_bytes > MAX_DRAWING_BYTES || max_decoded_bytes > MAX_DRAWING_BYTES {
return Err(invalid("decoder byte caps exceed the file ceiling"));
}
let remaining_bytes = MAX_DRAWING_BYTES.checked_sub(*bytes).ok_or_else(|| invalid("invalid shared byte counter"))?;
let remaining_work = MAX_WORK.checked_sub(*work).ok_or_else(|| invalid("invalid shared work counter"))?;
// Known envelope fields retain the parent's layer/weight/space semantics.
// Unknown geometry, explicit style7, MTEXT chunks3,
// entity color/visibility/linetype and extension data cannot silently vanish.
for code in entity.f.iter().map(|(code, _)| *code).chain(entity.s.iter().map(|(code, _)| *code)) {
if !matches!(code, 1 | 5 | 8 | 10 | 11 | 20 | 21 | 30 | 31 | 39 | 40 | 41 | 50 | 51 | 67 | 71 | 72 | 73 | 100 | 210 | 220 | 230 | 330 | 370) {
return Err(invalid("not supported yet: unrepresented field or style"));
}
}
for code in [5, 8, 330] {
let _ = layered_text(entity, code)?;
}
// Repeated subclass markers100 are ordinary TEXT framing, not scalar fields.
let _ = super::layered_entity_weight(entity)?;
if number(entity, 67)?.unwrap_or(0.0) != 0.0 {
return Err(invalid("expected model-space TEXT after caller space filtering"));
}
for (code, neutral) in [(30, 0.0), (31, 0.0), (39, 0.0), (41, 1.0), (51, 0.0), (71, 0.0), (210, 0.0), (220, 0.0), (230, 1.0)] {
if number(entity, code)?.is_some_and(|value| value != neutral) {
return Err(invalid("not supported yet: nondefault alignment, width, oblique angle, generation flags or OCS"));
}
}
// Parent lexical preflight must validate72/73 before section converts them
// through f64. These finite local enum checks also protect direct Ent callers.
let horizontal = match number(entity, 72)?.unwrap_or(0.0) {
0.0 => DxfTextHorizontalAlignment::Left,
1.0 => DxfTextHorizontalAlignment::Centre,
2.0 => DxfTextHorizontalAlignment::Right,
_ => return Err(invalid("not supported yet: horizontal TEXT alignment")),
};
let vertical = match number(entity, 73)?.unwrap_or(0.0) {
0.0 => DxfTextVerticalAlignment::Baseline,
2.0 => DxfTextVerticalAlignment::Middle,
3.0 => DxfTextVerticalAlignment::Top,
_ => return Err(invalid("not supported yet: vertical TEXT alignment")),
};
let insertion = Vec2::new(required(entity, 10)?, required(entity, 20)?);
let source = if horizontal == DxfTextHorizontalAlignment::Left && vertical == DxfTextVerticalAlignment::Baseline {
// Optional second point is irrelevant for left/baseline, but malformed
// or duplicate fields still refuse. No silent fallback for other modes.
let _ = number(entity, 11)?;
let _ = number(entity, 21)?;
insertion
} else {
Vec2::new(required(entity, 11)?, required(entity, 21)?)
};
let at = source * k;
if !at.is_finite() || (source.x != 0.0 && at.x == 0.0) || (source.y != 0.0 && at.y == 0.0) {
return Err(invalid("nonfinite or collapsed scaled insertion point"));
}
let raw_height = required(entity, 40)?;
let height = raw_height * k;
if raw_height <= 0.0 || !height.is_finite() || height <= 0.0 {
return Err(invalid("height must remain finite and positive after scaling"));
}
let angle_deg = number(entity, 50)?.unwrap_or(0.0);
if !angle_deg.to_radians().is_finite() {
return Err(invalid("invalid rotation angle"));
}
let value = layered_text(entity, 1)?.ok_or_else(|| invalid("missing raw group1 value"))?;
// Check input before allocation/scanning. Decoded output cannot allocate past
// remaining shared payload/work even when its UTF8 representation expands.
let encoded_limit = max_encoded_bytes.min(MAX_TEXT_ENCODED_BYTES);
if value.len() > encoded_limit || value.len() > remaining_work {
return Err(invalid("encoded byte or shared work budget exceeded"));
}
*work = work.checked_add(value.len()).ok_or_else(|| invalid("work counter overflow"))?;
// Current owned writer accepts at most250 BMP scalars: at most750 UTF8 bytes.
let decoded_limit = max_decoded_bytes.min(remaining_bytes).min(remaining_work - value.len()).min(MAX_TEXT_DECODED_BYTES);
let text = decode_text_value(value, encoded_limit, decoded_limit)?;
if text.chars().count() > 250 || text.chars().any(|ch| u32::from(ch) > 0xffff || ch.is_control() || matches!(ch, '%' | '\\')) {
return Err(invalid("not supported yet: decoded text exceeds the current writer's plain BMP subset"));
}
layered_array_budget::<u8>(text.len(), bytes, work)?;
if horizontal == DxfTextHorizontalAlignment::Centre && vertical == DxfTextVerticalAlignment::Baseline {
Ok(DxfShape::Text { at, height, angle_deg, text })
} else {
Ok(DxfShape::AlignedText { at, height, angle_deg, text, horizontal, vertical })
}
}
#[cfg(test)]
mod tests {
use super::*;
const FIELDS: &str = "10\n99\n20\n88\n11\n1\n21\n2\n40\n3\n72\n1\n1\nA\n";
fn parsed(fields: &str) -> Ent {
let input = format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n{fields}0\nENDSEC\n0\nEOF\n");
super::super::section(&super::super::pairs(&input), 1).unwrap().remove(0)
}
fn basic(value: &str) -> Ent {
parsed(&FIELDS.replacen("1\nA\n", &format!("1\n{value}\n"), 1))
}
fn read(entity: &Ent) -> Result<DxfShape> {
read_text_entity(entity, 1.0, 4096, 4096, &mut 0, &mut 0)
}
#[test]
fn raw_text_uses_real_decoder_and_second_alignment_point_without_trimming() {
assert_eq!(
read(&basic(" %%d %%p %%c \\U+4E2D ")).unwrap(),
DxfShape::Text { at: Vec2::new(1.0, 2.0), height: 3.0, angle_deg: 0.0, text: " ° ± \u{2205} 中 ".into() }
);
for value in ["", " ", "ASCII braces {}"] {
assert!(matches!(read(&basic(value)).unwrap(), DxfShape::Text { text, .. } if text == value));
}
}
#[test]
fn scale_alignment_point_and_height_once_keep_rotation_in_degrees() {
let fields = FIELDS.replacen("40\n3\n", "40\n0.5\n50\n-90\n", 1);
assert_eq!(
read_text_entity(&parsed(&fields), 25.4, 1, 1, &mut 0, &mut 0).unwrap(),
DxfShape::Text { at: Vec2::new(25.4, 50.8), height: 12.7, angle_deg: -90.0, text: "A".into() }
);
}
#[test]
fn neutral_fields_and_repeated_real_subclasses_are_admitted() {
let entity = parsed(&format!(
"{FIELDS}100\nAcDbEntity\n100\nAcDbText\n100\nAcDbText\n30\n0\n31\n0\n39\n0\n41\n1\n50\n0\n51\n0\n71\n0\n73\n0\n210\n0\n220\n0\n230\n1\n"
));
assert!(read(&entity).is_ok());
}
#[test]
fn missing_malformed_and_duplicate_fields_refuse() {
for removed in ["10\n99\n", "20\n88\n", "11\n1\n", "21\n2\n", "40\n3\n", "1\nA\n"] {
assert!(read(&parsed(&FIELDS.replacen(removed, "", 1))).is_err(), "{removed}");
}
for extra in ["10\n99\n", "11\n1\n", "21\n2\n", "40\n3\n", "72\n1\n", "1\nA\n", "50\n0\n50\n0\n", "73\n0\n73\n0\n", "230\n1\n230\n1\n"] {
assert!(read(&parsed(&format!("{FIELDS}{extra}"))).is_err(), "{extra}");
}
for (from, to) in [("11\n1", "11\nNaN"), ("21\n2", "21\nbad"), ("40\n3", "40\ninf"), ("72\n1", "72\nwrong")] {
assert!(read(&parsed(&FIELDS.replacen(from, to, 1))).is_err());
}
let mut entity = basic("A");
entity.f.push((1, 1.0));
assert!(read(&entity).is_err());
}
#[test]
fn unsupported_alignment_style_ocs_and_mtext_remain_explicit_errors() {
for mode in ["-1", "3", "4", "5", "0.5"] {
assert!(read(&parsed(&FIELDS.replacen("72\n1\n", &format!("72\n{mode}\n"), 1))).is_err());
}
for extra in [
"7\nSTANDARD\n",
"7\nCustom\n",
"41\n2\n",
"51\n15\n",
"71\n2\n",
"73\n1\n",
"30\n1\n",
"31\n1\n",
"39\n1\n",
"210\n1\n",
"220\n1\n",
"230\n-1\n",
"3\nextra\n",
"6\nDASHED\n",
"62\n1\n",
"60\n1\n",
"67\n1\n",
"999\ncomment\n",
] {
assert!(read(&parsed(&format!("{FIELDS}{extra}"))).is_err(), "{extra}");
}
let mut entity = basic("A");
entity.kind = "MTEXT".into();
assert!(read(&entity).unwrap_err().to_string().contains("MTEXT attachment/wrapping"));
}
#[test]
fn nonrepresentable_literals_are_refused_after_exactly_one_decode() {
for value in ["%", "%%%", "\\U+0025\\U+0025d", "\\U+005CU+0041", "\\P", "a\tb", "中𠀋", "\\U+D800", "\\U+0000", "%%q"] {
assert!(read(&basic(value)).is_err(), "{value}");
}
assert!(read(&basic(&"中".repeat(250))).is_ok());
assert!(read(&basic(&"A".repeat(251))).is_err());
assert!(read(&basic(&"中".repeat(251))).is_err());
}
#[test]
fn invalid_geometry_scale_and_underflow_refuse() {
for k in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MAX] {
assert!(read_text_entity(&basic("A"), k, 1, 1, &mut 0, &mut 0).is_err());
}
for (code, value) in [(10, f64::NAN), (11, f64::INFINITY), (21, f64::NAN), (40, 0.0), (40, -1.0), (40, f64::NAN), (50, f64::NAN)] {
let mut entity = basic("A");
entity.f.retain(|(field, _)| *field != code);
entity.f.push((code, value));
assert!(read(&entity).is_err());
}
for fields in [FIELDS.replacen("11\n1\n", "11\n1e-300\n", 1), FIELDS.replacen("40\n3\n", "40\n1e-300\n", 1)] {
assert!(read_text_entity(&parsed(&fields), 1e-100, 1, 1, &mut 0, &mut 0).is_err());
}
}
#[test]
fn inclusive_decoder_caps_measure_utf8_bytes() {
let entity = basic("\\U+FFFF");
assert!(read_text_entity(&entity, 1.0, 7, 3, &mut 0, &mut 0).is_ok());
assert!(read_text_entity(&entity, 1.0, 6, 3, &mut 0, &mut 0).is_err());
assert!(read_text_entity(&entity, 1.0, 7, 2, &mut 0, &mut 0).is_err());
assert!(read_text_entity(&basic("中"), 1.0, 2, 3, &mut 0, &mut 0).is_err());
assert!(read_text_entity(&basic(""), 1.0, 0, 0, &mut 0, &mut 0).is_ok());
assert!(read_text_entity(&basic("A"), 1.0, MAX_DRAWING_BYTES + 1, 1, &mut 0, &mut 0).is_err());
assert!(read_text_entity(&basic("A"), 1.0, 1, MAX_DRAWING_BYTES + 1, &mut 0, &mut 0).is_err());
}
#[test]
fn shared_counters_remain_cumulative_and_limit_before_decode_allocation() {
let entity = basic("A");
let mut bytes = MAX_DRAWING_BYTES - 2;
let mut work = MAX_WORK - 4;
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut bytes, &mut work).is_ok());
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut bytes, &mut work).is_ok());
assert_eq!((bytes, work), (MAX_DRAWING_BYTES, MAX_WORK));
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut bytes, &mut work).is_err());
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut MAX_DRAWING_BYTES, &mut 0).is_err());
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut 0, &mut (MAX_WORK - 1)).is_err());
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut usize::MAX, &mut 0).is_err());
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut 0, &mut usize::MAX).is_err());
}
#[test]
fn parent_lexical_preflight_must_precede_float_field_conversion() {
for extra in ["71\n1e-400\n", "73\n0.0\n"] {
let input = format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n{FIELDS}{extra}0\nENDSEC\n0\nEOF\n");
assert!(super::super::layered_array_integer_fields(&super::super::pairs(&input), 1).is_err());
}
let fields = FIELDS.replacen("72\n1\n", "72\n1.0000000000000000001\n", 1);
let input = format!("0\nSECTION\n2\nENTITIES\n0\nTEXT\n{fields}0\nENDSEC\n0\nEOF\n");
assert!(super::super::layered_array_integer_fields(&super::super::pairs(&input), 1).is_err());
}
#[test]
fn nine_literal_mode_pairs_select_the_actual_anchor_and_preserve_legacy_text() {
use DxfTextHorizontalAlignment::{Centre, Left, Right};
use DxfTextVerticalAlignment::{Baseline, Middle, Top};
for (h, v, horizontal, vertical) in [
(0, 0, Left, Baseline),
(1, 0, Centre, Baseline),
(2, 0, Right, Baseline),
(0, 2, Left, Middle),
(1, 2, Centre, Middle),
(2, 2, Right, Middle),
(0, 3, Left, Top),
(1, 3, Centre, Top),
(2, 3, Right, Top),
] {
let fields = FIELDS.replacen("72\n1\n", &format!("72\n{h}\n73\n{v}\n50\n-90\n"), 1);
let shape = read_text_entity(&parsed(&fields), 2.0, 1, 1, &mut 0, &mut 0).unwrap();
let at = if h == 0 && v == 0 { Vec2::new(198.0, 176.0) } else { Vec2::new(2.0, 4.0) };
let expected = if h == 1 && v == 0 {
DxfShape::Text { at, height: 6.0, angle_deg: -90.0, text: "A".into() }
} else {
DxfShape::AlignedText { at, height: 6.0, angle_deg: -90.0, text: "A".into(), horizontal, vertical }
};
assert_eq!(shape, expected, "72={h},73={v}");
}
}
#[test]
fn default_left_baseline_uses_first_point_without_requiring_second_point() {
let fields = FIELDS.replacen("72\n1\n", "", 1).replacen("11\n1\n", "", 1).replacen("21\n2\n", "", 1);
assert_eq!(
read(&parsed(&fields)).unwrap(),
DxfShape::AlignedText {
at: Vec2::new(99.0, 88.0),
height: 3.0,
angle_deg: 0.0,
text: "A".into(),
horizontal: DxfTextHorizontalAlignment::Left,
vertical: DxfTextVerticalAlignment::Baseline,
}
);
// Though irrelevant for placement, a present malformed/duplicate point
// cannot be used to hide corrupted input in the default mode.
for extra in ["11\nbad\n", "21\nNaN\n", "11\n1\n11\n1\n"] {
assert!(read(&parsed(&format!("{fields}{extra}"))).is_err());
}
}
#[test]
fn every_other_mode_requires_second_anchor_and_never_falls_back_to_first() {
for (h, v) in [(1, 0), (2, 0), (0, 2), (1, 2), (2, 2), (0, 3), (1, 3), (2, 3)] {
let fields = FIELDS.replacen("72\n1\n", &format!("72\n{h}\n73\n{v}\n"), 1);
for removed in ["11\n1\n", "21\n2\n"] {
assert!(read(&parsed(&fields.replacen(removed, "", 1))).is_err());
}
}
for invalid in ["73\n-1\n", "73\n1\n", "73\n4\n", "73\n0.5\n", "73\n2\n73\n2\n"] {
assert!(read(&parsed(&format!("{FIELDS}{invalid}"))).is_err());
}
}
#[test]
fn two_hundred_fifty_cif_bmp_chars_need_1750_encoded_and_750_decoded_bytes() {
let encoded = "\\U+4E2D".repeat(250);
let expected = "中".repeat(250);
assert_eq!(encoded.len(), 1750);
assert_eq!(expected.len(), 750);
let entity = basic(&encoded);
let (mut bytes, mut work) = (0, 0);
assert_eq!(
read_text_entity(&entity, 1.0, 1750, 750, &mut bytes, &mut work).unwrap(),
DxfShape::Text { at: Vec2::new(1.0, 2.0), height: 3.0, angle_deg: 0.0, text: expected }
);
assert_eq!((bytes, work), (750, 2500));
let (mut bytes, mut work) = (0, 0);
assert!(read_text_entity(&entity, 1.0, 1749, 750, &mut bytes, &mut work).is_err());
assert_eq!((bytes, work), (0, 0), "encoded admission precedes decoding");
assert!(read_text_entity(&entity, 1.0, 1750, 749, &mut 0, &mut 0).is_err());
assert!(read_text_entity(&entity, 1.0, 1750, 750, &mut (MAX_DRAWING_BYTES - 749), &mut 0).is_err());
assert!(read_text_entity(&entity, 1.0, 1750, 750, &mut 0, &mut (MAX_WORK - 2499)).is_err());
assert!(read(&basic(&"\\U+4E2D".repeat(251))).is_err());
assert!(read(&basic(&"A".repeat(251))).is_err());
}
}
+101
View File
@@ -0,0 +1,101 @@
//! TEXT-only value decoding; no MTEXT helper or registration substitute.
//! Caller supplies raw group1 and encoded/decoded byte caps. Output is not rescanned.
use crate::{IoError, Result};
fn invalid(reason: impl std::fmt::Display) -> IoError {
IoError::Invalid(format!("DXF text value: {reason}"))
}
fn push(out: &mut String, ch: char, max_bytes: usize) -> Result<()> {
let needed = out.len().checked_add(ch.len_utf8()).ok_or_else(|| invalid("decoded size overflow"))?;
if needed > max_bytes {
return Err(invalid("decoded byte budget exceeded"));
}
out.try_reserve(ch.len_utf8()).map_err(|error| invalid(format!("cannot reserve decoded text: {error}")))?;
out.push(ch);
Ok(())
}
fn cif(chars: &mut impl Iterator<Item = char>) -> Result<char> {
if chars.next() != Some('+') {
return Err(invalid("malformed CIF Unicode prefix"));
}
let mut code = 0_u32;
for _ in 0..4 {
let digit = chars.next().and_then(|ch| ch.to_digit(16)).ok_or_else(|| invalid("CIF Unicode escape requires four hexadecimal digits"))?;
code = code * 16 + digit;
}
let ch = char::from_u32(code).ok_or_else(|| invalid("CIF Unicode escape is not a scalar value; surrogate pairs are unsupported"))?;
if ch.is_control() {
return Err(invalid("not supported yet: a control character encoded as CIF"));
}
Ok(ch)
}
fn percent(chars: &mut impl Iterator<Item = char>) -> Result<char> {
match chars.next() {
Some('%') => Ok('%'),
Some('d') => Ok('°'),
Some('p') => Ok('±'),
Some('c') => Ok('\u{2205}'),
_ => Err(invalid("not supported yet: percent formatting code")),
}
}
pub(super) fn decode_text_value(value: &str, max_encoded_bytes: usize, max_decoded_bytes: usize) -> Result<String> {
if value.len() > max_encoded_bytes {
return Err(invalid("encoded byte budget exceeded"));
}
let mut out = String::new();
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
let decoded = match ch {
c if c.is_control() => return Err(invalid("not supported yet: raw text control character")),
'\\' if chars.peek() == Some(&'U') => {
chars.next();
cif(&mut chars)?
}
'%' if chars.peek() == Some(&'%') => {
chars.next();
percent(&mut chars)?
}
c => c,
};
push(&mut out, decoded, max_decoded_bytes)?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::decode_text_value;
#[test]
fn literals_percent_cif_and_decode_once_retain_text_semantics() {
assert_eq!(decode_text_value("%%% %%d %%p %%c \\U+FFFF", 4096, 4096).unwrap(), "% ° ± \u{2205} \u{ffff}");
assert_eq!(decode_text_value(" \\U+0025\\U+0025d \\U+005CU+0041 \\P ", 4096, 4096).unwrap(), " %%d \\U+0041 \\P ");
assert_eq!(decode_text_value("trailing\\", 4096, 4096).unwrap(), "trailing\\");
}
#[test]
fn malformed_styles_surrogates_and_controls_still_refuse() {
for value in [
"\\U+", "\\U+XYZ0", "\\U+123", "\\U0041", "\\U+D800", "\\U+DC00", "%%", "%%q", "%%u", "%%123", "a\rb", "a\nb", "a\tb", "\\U+0000",
"\\U+000A", "\\U+0085",
] {
assert!(decode_text_value(value, 4096, 4096).is_err(), "{value}");
}
}
#[test]
fn encoded_and_decoded_budgets_are_separate_utf8_byte_bounds() {
assert_eq!(decode_text_value("\\U+FFFF", 7, 3).unwrap(), "\u{ffff}");
assert!(decode_text_value("\\U+FFFF", 6, 3).is_err());
assert!(decode_text_value("\\U+FFFF", 7, 2).is_err());
assert_eq!(decode_text_value("", 0, 0).unwrap(), "");
// The general TEXT value decoder still preserves newer Unicode; the
// current writer-compatible entity adapter separately refuses non-BMP.
assert_eq!(decode_text_value("中𠀋", 7, 7).unwrap(), "中𠀋");
}
}
+478 -54
View File
@@ -18,10 +18,12 @@ use solidcraft_geom::{Loop2, MAX_BEZIER_DEGREE, Seg2, Vec2};
use crate::{IoError, Result};
mod file_model;
mod path;
pub use file_model::{
DxfFile, DxfGeometryBounds, Entity as DxfFileEntity, EntityWeight as DxfEntityWeight, Layer as DxfFileLayer, LayerId as DxfLayerId,
StandardWeight as DxfStandardWeight,
};
pub use path::DxfVertex;
/// A layer's linetype, defined in the file's LTYPE table so a viewer needs nothing else.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -67,16 +69,26 @@ impl DxfLayer {
/// The three horizontal TEXT modes in the nine-alignment subset.
/// This deliberately excludes DXF Aligned/Middle/Fit modes72=3/4/5.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DxfTextHorizontalAlignment { Left, Centre, Right }
pub enum DxfTextHorizontalAlignment {
Left,
Centre,
Right,
}
impl DxfTextHorizontalAlignment {
pub fn group72(self) -> i16 {
match self { Self::Left => 0, Self::Centre => 1, Self::Right => 2 }
match self {
Self::Left => 0,
Self::Centre => 1,
Self::Right => 2,
}
}
pub fn from_group72(code: i16) -> Result<Self> {
match code {
0 => Ok(Self::Left), 1 => Ok(Self::Centre), 2 => Ok(Self::Right),
0 => Ok(Self::Left),
1 => Ok(Self::Centre),
2 => Ok(Self::Right),
_ => Err(IoError::Invalid("not supported yet: DXF horizontal TEXT alignment outside left/centre/right".into())),
}
}
@@ -84,16 +96,26 @@ impl DxfTextHorizontalAlignment {
/// Baseline, middle and top only; DXF Bottom73=1 is outside this subset.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DxfTextVerticalAlignment { Baseline, Middle, Top }
pub enum DxfTextVerticalAlignment {
Baseline,
Middle,
Top,
}
impl DxfTextVerticalAlignment {
pub fn group73(self) -> i16 {
match self { Self::Baseline => 0, Self::Middle => 2, Self::Top => 3 }
match self {
Self::Baseline => 0,
Self::Middle => 2,
Self::Top => 3,
}
}
pub fn from_group73(code: i16) -> Result<Self> {
match code {
0 => Ok(Self::Baseline), 2 => Ok(Self::Middle), 3 => Ok(Self::Top),
0 => Ok(Self::Baseline),
2 => Ok(Self::Middle),
3 => Ok(Self::Top),
_ => Err(IoError::Invalid("not supported yet: DXF vertical TEXT alignment outside baseline/middle/top".into())),
}
}
@@ -109,6 +131,13 @@ pub enum DxfShape {
Polyline {
points: Vec<Vec2>,
},
/// X1 lines/arcs with signed outgoing bulges, at most a half turn per edge.
/// Open paths have a terminal zero bulge; closed paths do not repeat their
/// first vertex. Coordinates/bulges keep X1's round-trip numeric spelling.
BulgedPolyline {
vertices: Vec<DxfVertex>,
closed: bool,
},
Circle {
c: Vec2,
r: f64,
@@ -273,6 +302,10 @@ impl DxfShape {
/// Points on the shape (or its control polygon) for the drawing's extents.
fn sample(&self, out: &mut Vec<Vec2>) -> Result<()> {
match self {
DxfShape::BulgedPolyline { vertices, closed } => {
admit_bulged(vertices, *closed)?;
sample_bulged(vertices, *closed, out)?;
}
DxfShape::AlignedText { .. } => return Err(IoError::Invalid("aligned DXF TEXT requires explicit layout bounds".into())),
DxfShape::Point(point) => out.push(*point),
DxfShape::Line(a, b) => out.extend([*a, *b]),
@@ -303,6 +336,86 @@ impl DxfShape {
}
}
// Shared admission for the new owned/copy route. Geometry bounds have a
// separate stricter interval domain and MUST NOT serve as total admission here.
pub(super) fn admit_bulged(vertices: &[DxfVertex], closed: bool) -> 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()));
}
if closed && vertices.first().zip(vertices.last()).is_some_and(|(first, last)| first.at == last.at) {
return Err(IoError::Invalid("closed bulged polyline must not repeat its first vertex".into()));
}
let check = |from: DxfVertex, to: Vec2| -> Result<()> {
if from.bulge != 0.0 && path::bulge_arc(from, to).is_none() {
return Err(IoError::Invalid("bulged polyline arc has collapsed or non-finite geometry".into()));
}
Ok(())
};
for (from, to) in vertices.iter().zip(vertices.iter().skip(1)) {
check(*from, to.at)?;
}
if closed && let Some((last, first)) = vertices.last().zip(vertices.first()) {
check(*last, first.at)?;
}
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(())
}
/// A fallible bounded sink for the owned-file path. Formatting remains exactly that of Out.
struct BoundedDxfString<'a> {
text: &'a mut String,
@@ -400,6 +513,21 @@ impl Out {
self.num(c + 10, p.y);
self.num(c + 20, 0.0);
}
/// X1's spelling for bulged fields and their drawing's header bounds only.
fn precise_num(&mut self, c: i32, value: f64) {
if !value.is_finite() {
self.failure.get_or_insert("DXF bulged polyline contains a non-finite output number");
} else 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);
}
fn handle(&mut self) -> String {
let h = format!("{:X}", self.next);
if self.limit.is_some() {
@@ -546,7 +674,9 @@ pub fn write_dxf_file(file: &DxfFile<DxfShape>) -> Result<String> {
/// this writer cannot certify font metrics. Legacy Text retains its old sampled
/// extents and bytes and does not call this provider. Errors return no file.
pub fn write_dxf_file_with_text_bounds<F>(file: &DxfFile<DxfShape>, text_bounds: F) -> Result<String>
where F: FnMut(&DxfShape) -> Result<DxfGeometryBounds> {
where
F: FnMut(&DxfShape) -> Result<DxfGeometryBounds>,
{
write_dxf_file_limited_with_text_bounds(file, crate::MAX_DRAWING_BYTES, MAX_DXF_FILE_VALUES, text_bounds)
}
@@ -556,7 +686,9 @@ fn write_dxf_file_limited(file: &DxfFile<DxfShape>, bytes: usize, values: usize)
}
fn write_dxf_file_limited_with_text_bounds<F>(file: &DxfFile<DxfShape>, bytes: usize, values: usize, mut text_bounds: F) -> Result<String>
where F: FnMut(&DxfShape) -> Result<DxfGeometryBounds> {
where
F: FnMut(&DxfShape) -> Result<DxfGeometryBounds>,
{
let bytes = bytes.min(crate::MAX_DRAWING_BYTES);
let values = values.min(MAX_DXF_FILE_VALUES);
if file.layers().len() > 1_024 || file.entities().len() > 1_000_000 {
@@ -581,7 +713,8 @@ where F: FnMut(&DxfShape) -> Result<DxfGeometryBounds> {
samples.clear();
samples.try_reserve(count).map_err(|_| IoError::Invalid("DXF extent allocation failed".into()))?;
if matches!(shape, DxfShape::AlignedText { .. }) {
let bounds = text_bounds(shape).and_then(|bounds| DxfGeometryBounds::new(bounds.min, bounds.max))
let bounds = text_bounds(shape)
.and_then(|bounds| DxfGeometryBounds::new(bounds.min, bounds.max))
.map_err(|error| IoError::Invalid(format!("DXF aligned TEXT entity {} on layer {:?}: {error}", index + 1, layer.name())))?;
samples.extend([bounds.min, bounds.max]);
} else {
@@ -606,6 +739,7 @@ where F: FnMut(&DxfShape) -> Result<DxfGeometryBounds> {
fn shape_preflight(shape: &DxfShape) -> Result<()> {
let invalid = || IoError::Invalid("invalid or unsupported DXF geometry".into());
let finite = match shape {
DxfShape::BulgedPolyline { vertices, closed } => return admit_bulged(vertices, *closed),
DxfShape::Point(point) => point.is_finite(),
DxfShape::Line(a, b) => a.is_finite() && b.is_finite(),
DxfShape::Polyline { points } => points.len() >= 2 && points.len() <= MAX_DXF_FILE_VALUES && points.iter().all(|p| p.is_finite()),
@@ -669,6 +803,17 @@ fn shape_preflight(shape: &DxfShape) -> Result<()> {
fn shape_work(shape: &DxfShape) -> Result<usize> {
match shape {
DxfShape::BulgedPolyline { vertices, closed } => {
let count = vertices.len();
if !(2..=path::MAX_PATH_ITEMS).contains(&count) {
return Err(IoError::Invalid("bulged polyline requires 2..=200000 vertices".into()));
}
let edges = if *closed { count } else { count - 1 };
edges
.checked_mul(6)
.and_then(|samples| samples.checked_add(count))
.ok_or_else(|| IoError::Invalid("bulged polyline payload size overflow".into()))
}
DxfShape::Point(_) => Ok(1),
DxfShape::Line(..) | DxfShape::Circle { .. } => Ok(2),
DxfShape::Polyline { points } => Ok(points.len()),
@@ -701,7 +846,11 @@ fn shape_emission_preflight(shape: &DxfShape) -> Result<()> {
let collapse = || IoError::Invalid("DXF geometry loses a required positive value or distinct point/knot span at twelve decimals".into());
let positive = |value| -> Result<()> { if emitted_number(value)? > 0.0 { Ok(()) } else { Err(collapse()) } };
match shape {
DxfShape::Point(point) => { emitted_point(*point)?; }
// Fields use X1's round-trip spelling, not the legacy12decimal rule.
DxfShape::BulgedPolyline { .. } => {}
DxfShape::Point(point) => {
emitted_point(*point)?;
}
DxfShape::Line(a, b) => {
if emitted_point(*a)? == emitted_point(*b)? {
return Err(collapse());
@@ -778,13 +927,41 @@ where
L: Iterator<Item = LayerView<'a>> + Clone + ExactSizeIterator,
E: Iterator<Item = Result<EntityView<'a>>> + Clone,
{
let precise_bounds = entities
.clone()
.try_fold(false, |found, entity| -> Result<bool> { Ok(found || matches!(entity?.shape, DxfShape::BulgedPolyline { .. })) })?;
// Only the new variant opts the legacy wrapper into bounded output. Old
// drawings without a bulged entity retain their original route and bytes.
let limit = if precise_bounds { Some(limit.unwrap_or(crate::MAX_DRAWING_BYTES).min(crate::MAX_DRAWING_BYTES)) } else { limit };
let (lo, hi) = match extents {
Some(extents) => extents,
None => {
// Legacy path retains its existing permissive sampled extents.
let mut pts = Vec::new();
for e in entities.clone() {
e?.shape.sample(&mut pts)?;
if precise_bounds {
// Length-only cumulative admission before scanning/expanding
// any input vertex array. Reserve the proven sample ceiling.
let mut total = 0usize;
for (index, entity) in entities.clone().enumerate() {
let entity = entity?;
let count = shape_work(entity.shape)
.map_err(|error| IoError::Invalid(format!("DXF entity {} on layer {:?}: {error}", index + 1, entity.layer)))?;
total = total
.checked_add(count)
.filter(|count| *count <= MAX_DXF_FILE_VALUES)
.ok_or_else(|| IoError::Invalid("DXF bulged drawing geometry payload exceeds the supported limit".into()))?;
}
pts.try_reserve(total).map_err(|_| IoError::Invalid("DXF bulged drawing extent allocation failed".into()))?;
}
for (index, entity) in entities.clone().enumerate() {
let entity = entity?;
entity.shape.sample(&mut pts).map_err(|error| {
if matches!(entity.shape, DxfShape::BulgedPolyline { .. }) {
IoError::Invalid(format!("DXF entity {} on layer {:?}: {error}", index + 1, entity.layer))
} else {
error
}
})?;
}
pts.retain(|p| p.is_finite());
pts.iter().fold(None, |acc, point| Some(extend_extents(acc, *point))).unwrap_or((Vec2::ZERO, Vec2::ZERO))
@@ -872,6 +1049,7 @@ where
for (index, e) in entities.enumerate() {
let e = e?;
let kind = match e.shape {
DxfShape::BulgedPolyline { .. } => "LWPOLYLINE",
DxfShape::Point(_) => "POINT",
DxfShape::Line(..) => "LINE",
DxfShape::Polyline { .. } => "LWPOLYLINE",
@@ -891,6 +1069,16 @@ where
o.code(370, weight.group370());
}
match e.shape {
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::AlignedText { at, height, angle_deg, text, horizontal, vertical } => {
// The owned route validates payload/geometry and receives real
// bounds before reaching Out. The legacy route refuses this
@@ -898,7 +1086,8 @@ where
o.code(100, "AcDbText");
o.point(10, *at);
o.num(40, *height);
let value = text_value(text).map_err(|error| IoError::Invalid(format!("DXF TEXT entity {} on layer {:?}: {error}", index + 1, e.layer)))?;
let value =
text_value(text).map_err(|error| IoError::Invalid(format!("DXF TEXT entity {} on layer {:?}: {error}", index + 1, e.layer)))?;
o.code(1, value);
o.num(50, *angle_deg);
o.code(72, horizontal.group72());
@@ -1019,9 +1208,17 @@ where
head.code(9, "$MEASUREMENT");
head.code(70, 1);
head.code(9, "$EXTMIN");
head.point(10, lo);
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");
head.check()?;
if let Some(limit) = limit {
@@ -1043,18 +1240,158 @@ mod tests {
use super::*;
use crate::{Geom2, read_dxf};
#[test]
fn bulged_literal_groups_keep_signed_outgoing_values_and_closed_flag() {
for (bulge, spelling) in [(1.0, "1"), (-1.0, "-1")] {
let mut file = DxfFile::new().unwrap();
file.push(
file.default_layer(),
DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::new(-1.0, 0.0), bulge }, DxfVertex { at: Vec2::new(1.0, 0.0), bulge: -0.0 }],
closed: false,
},
Some(DxfEntityWeight::ByLayer),
)
.unwrap();
let output = write_dxf_file(&file).unwrap();
let records = entities(&output);
let record = records.first().unwrap();
assert_eq!(record.0, "LWPOLYLINE");
assert_eq!((field(&record.1, 90), field(&record.1, 70)), ("2", "128"));
for (code, expected) in
[(90, vec!["2"]), (70, vec!["128"]), (10, vec!["-1", "1"]), (20, vec!["0.0", "0.0"]), (42, vec![spelling, "0.0"]), (370, vec!["-1"])]
{
assert_eq!(record.1.iter().filter(|(field, _)| *field == code).map(|(_, value)| value.as_str()).collect::<Vec<_>>(), expected);
}
}
let closed = DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::new(-1.0, 0.0), bulge: 1.0 }, DxfVertex { at: Vec2::new(1.0, 0.0), bulge: 1.0 }],
closed: true,
};
let output = write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape: closed }]).unwrap();
let records = entities(&output);
assert_eq!((field(&records[0].1, 90), field(&records[0].1, 70)), ("2", "129"));
assert_eq!(values(&records[0], 70), [129.0]);
assert_eq!(values(&records[0], 90), [2.0]);
assert_eq!(values(&records[0], 42), [1.0, 1.0]);
let groups = pairs(&output);
let min = groups.iter().position(|(code, value)| *code == 9 && value == "$EXTMIN").unwrap();
let max = groups.iter().position(|(code, value)| *code == 9 && value == "$EXTMAX").unwrap();
// X1 directed-cardinal sampling, not inflated supporting-circle boxes.
assert_eq!(&groups[min + 1..min + 4], &[(10, "-1".into()), (20, "-1".into()), (30, "0.0".into())]);
assert_eq!(&groups[max + 1..max + 4], &[(10, "1".into()), (20, "1".into()), (30, "0.0".into())]);
}
#[test]
fn bulged_zero_line_and_tiny_arcs_do_not_inherit_the_interval_refusal_domain() {
for (vertices, expected_x, expected_bulge) in [
(vec![DxfVertex { at: Vec2::ZERO, bulge: 0.0 }; 2], [0.0, 0.0], [0.0, 0.0]),
(vec![DxfVertex { at: Vec2::ZERO, bulge: 1e-200 }, DxfVertex { at: Vec2::new(1e-200, 0.0), bulge: 0.0 }], [0.0, 1e-200], [1e-200, 0.0]),
] {
let mut file = DxfFile::new().unwrap();
file.push(file.default_layer(), DxfShape::BulgedPolyline { vertices, closed: false }, None).unwrap();
let output = write_dxf_file(&file).unwrap();
assert_eq!(field(&entities(&output)[0].1, 90), "2");
assert_eq!(values(&entities(&output)[0], 10), expected_x);
assert_eq!(values(&entities(&output)[0], 42), expected_bulge);
}
let invalid = DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::ZERO, bulge: 0.0 }, DxfVertex { at: Vec2::X, bulge: 0.0 }, DxfVertex { at: Vec2::ZERO, bulge: 0.0 }],
closed: true,
};
assert!(write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape: invalid }]).is_err());
}
#[test]
fn bulged_writer_precharges_samples_before_hostile_vertex_scans() {
let mut file = DxfFile::new().unwrap();
file.push(
file.default_layer(),
DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::ZERO, bulge: 0.0 }, DxfVertex { at: Vec2::X, bulge: 0.0 }],
closed: false,
},
None,
)
.unwrap();
// n+6*edges =2+6=8. No old twelve-decimal collapse admission applies.
assert!(write_dxf_file_limited(&file, crate::MAX_DRAWING_BYTES, 8).is_ok());
assert!(write_dxf_file_limited(&file, crate::MAX_DRAWING_BYTES, 7).is_err());
let mut hostile = DxfFile::new().unwrap();
hostile
.push(
hostile.default_layer(),
DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::new(f64::NAN, 0.0), bulge: 0.0 }, DxfVertex { at: Vec2::X, bulge: 0.0 }],
closed: false,
},
None,
)
.unwrap();
assert!(write_dxf_file_limited(&hostile, crate::MAX_DRAWING_BYTES, 7).unwrap_err().to_string().contains("geometry payload"));
}
/// 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 }
}
#[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]
fn owned_point_uses_real_wcs_record_and_retains_layer_and_each_optional_weight() {
let mut file = DxfFile::new().unwrap();
let physical = DxfStandardWeight::from_code(50).unwrap();
let layer = file.declare_layer("DATUM", 4, DxfLinetype::Hidden, Some(physical)).unwrap();
let weights = [None, Some(DxfEntityWeight::ByLayer), Some(DxfEntityWeight::ByBlock), Some(DxfEntityWeight::Default), Some(DxfEntityWeight::Explicit(physical))];
for weight in weights { file.push(layer, DxfShape::Point(Vec2::new(1.25, -2.5)), weight).unwrap(); }
let weights = [
None,
Some(DxfEntityWeight::ByLayer),
Some(DxfEntityWeight::ByBlock),
Some(DxfEntityWeight::Default),
Some(DxfEntityWeight::Explicit(physical)),
];
for weight in weights {
file.push(layer, DxfShape::Point(Vec2::new(1.25, -2.5)), weight).unwrap();
}
let dxf = write_dxf_file(&file).unwrap();
let records = entities(&dxf);
assert_eq!(records.len(), 5);
@@ -1104,7 +1441,9 @@ mod tests {
fn finite_point_shares_writer_with_legacy_and_does_not_change_line_record_order() {
let shapes = [DxfShape::Point(Vec2::new(-3.0, 4.0)), DxfShape::Line(Vec2::ZERO, Vec2::X)];
let mut file = DxfFile::new().unwrap();
for shape in &shapes { file.push(file.default_layer(), shape.clone(), None).unwrap(); }
for shape in &shapes {
file.push(file.default_layer(), shape.clone(), None).unwrap();
}
let legacy: Vec<_> = shapes.into_iter().map(|shape| DxfEntity { layer: "0".into(), shape }).collect();
let owned_output = write_dxf_file(&file).unwrap();
assert_eq!(owned_output, write_dxf_drawing(&[], &legacy).unwrap());
@@ -1113,11 +1452,20 @@ mod tests {
#[test]
fn all_nine_text_alignments_emit_spec_codes_and_both_anchor_points() {
for (horizontal, code72) in [(DxfTextHorizontalAlignment::Left, 0), (DxfTextHorizontalAlignment::Centre, 1), (DxfTextHorizontalAlignment::Right, 2)] {
for (vertical, code73) in [(DxfTextVerticalAlignment::Baseline, 0), (DxfTextVerticalAlignment::Middle, 2), (DxfTextVerticalAlignment::Top, 3)] {
for (horizontal, code72) in
[(DxfTextHorizontalAlignment::Left, 0), (DxfTextHorizontalAlignment::Centre, 1), (DxfTextHorizontalAlignment::Right, 2)]
{
for (vertical, code73) in
[(DxfTextVerticalAlignment::Baseline, 0), (DxfTextVerticalAlignment::Middle, 2), (DxfTextVerticalAlignment::Top, 3)]
{
let mut file = DxfFile::new().unwrap();
let layer = file.declare_layer("NOTES", 4, DxfLinetype::Hidden, None).unwrap();
file.push(layer, DxfShape::AlignedText { at: Vec2::new(10.0, 20.0), height: 2.5, angle_deg: 30.0, text: "A".into(), horizontal, vertical }, Some(DxfEntityWeight::ByLayer)).unwrap();
file.push(
layer,
DxfShape::AlignedText { at: Vec2::new(10.0, 20.0), height: 2.5, angle_deg: 30.0, text: "A".into(), horizontal, vertical },
Some(DxfEntityWeight::ByLayer),
)
.unwrap();
let mut calls = 0;
// Explicit fixture from an external synthetic layout provider;
// its bounds are inputs, never inferred by the writer.
@@ -1126,16 +1474,23 @@ mod tests {
assert!(matches!(shape, DxfShape::AlignedText { horizontal: h, vertical: v, .. } if *h == horizontal && *v == vertical));
calls += 1;
Ok(bounds)
}).unwrap();
})
.unwrap();
assert_eq!(calls, 1);
let records = entities(&output);
let record = records.first().unwrap();
assert_eq!(record.0, "TEXT");
assert_eq!(values(record, 72), [f64::from(code72)]);
assert_eq!(values(record, 73), [f64::from(code73)]);
for code in [10, 11] { assert_eq!(values(record, code), [10.0]); }
for code in [20, 21] { assert_eq!(values(record, code), [20.0]); }
for code in [30, 31] { assert_eq!(values(record, code), [0.0]); }
for code in [10, 11] {
assert_eq!(values(record, code), [10.0]);
}
for code in [20, 21] {
assert_eq!(values(record, code), [20.0]);
}
for code in [30, 31] {
assert_eq!(values(record, code), [0.0]);
}
assert_eq!(field(&record.1, 8), "NOTES");
assert_eq!(values(record, 370), [-1.0]);
assert_eq!(values(record, 40), [2.5]);
@@ -1152,9 +1507,15 @@ mod tests {
#[test]
fn alignment_codes_reject_bottom_fit_and_unrepresented_modes() {
for code in [-1, 3, 4, 5, i16::MAX] { assert!(DxfTextHorizontalAlignment::from_group72(code).is_err()); }
for code in [-1, 1, 4, i16::MAX] { assert!(DxfTextVerticalAlignment::from_group73(code).is_err()); }
for (code, expected) in [(0, DxfTextHorizontalAlignment::Left), (1, DxfTextHorizontalAlignment::Centre), (2, DxfTextHorizontalAlignment::Right)] {
for code in [-1, 3, 4, 5, i16::MAX] {
assert!(DxfTextHorizontalAlignment::from_group72(code).is_err());
}
for code in [-1, 1, 4, i16::MAX] {
assert!(DxfTextVerticalAlignment::from_group73(code).is_err());
}
for (code, expected) in
[(0, DxfTextHorizontalAlignment::Left), (1, DxfTextHorizontalAlignment::Centre), (2, DxfTextHorizontalAlignment::Right)]
{
assert_eq!(DxfTextHorizontalAlignment::from_group72(code).unwrap(), expected);
assert_eq!(expected.group72(), code);
}
@@ -1170,9 +1531,19 @@ mod tests {
let legacy = DxfShape::Text { at, height: 2.5, angle_deg: 0.0, text: "AB".into() };
let legacy_output = write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape: legacy }]).unwrap();
let mut file = DxfFile::new().unwrap();
file.push(file.default_layer(), DxfShape::AlignedText {
at, height: 2.5, angle_deg: 0.0, text: "AB".into(), horizontal: DxfTextHorizontalAlignment::Centre, vertical: DxfTextVerticalAlignment::Baseline,
}, None).unwrap();
file.push(
file.default_layer(),
DxfShape::AlignedText {
at,
height: 2.5,
angle_deg: 0.0,
text: "AB".into(),
horizontal: DxfTextHorizontalAlignment::Centre,
vertical: DxfTextVerticalAlignment::Baseline,
},
None,
)
.unwrap();
// Synthetic metrics chosen analytically: centre at5,6; width4,height2.5.
let output = write_dxf_file_with_text_bounds(&file, |_| DxfGeometryBounds::new(Vec2::new(3.0, 6.0), Vec2::new(7.0, 8.5))).unwrap();
assert_eq!(output, legacy_output);
@@ -1184,17 +1555,23 @@ mod tests {
#[test]
fn explicit_alignment_refuses_missing_or_invalid_layout_bounds_without_a_partial_file() {
let shape = DxfShape::AlignedText {
at: Vec2::ZERO, height: 1.0, angle_deg: 0.0, text: "A".into(), horizontal: DxfTextHorizontalAlignment::Left, vertical: DxfTextVerticalAlignment::Top,
at: Vec2::ZERO,
height: 1.0,
angle_deg: 0.0,
text: "A".into(),
horizontal: DxfTextHorizontalAlignment::Left,
vertical: DxfTextVerticalAlignment::Top,
};
let mut file = DxfFile::new().unwrap();
file.push(file.default_layer(), DxfShape::Point(Vec2::X), None).unwrap();
file.push(file.default_layer(), shape.clone(), None).unwrap();
assert!(write_dxf_file(&file).is_err());
assert!(write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape }]).is_err());
for bounds in [
DxfGeometryBounds { min: Vec2::new(f64::NAN, 0.0), max: Vec2::X },
DxfGeometryBounds { min: Vec2::new(1.0, 1.0), max: Vec2::ZERO },
] { assert!(write_dxf_file_with_text_bounds(&file, |_| Ok(bounds)).is_err()); }
for bounds in
[DxfGeometryBounds { min: Vec2::new(f64::NAN, 0.0), max: Vec2::X }, DxfGeometryBounds { min: Vec2::new(1.0, 1.0), max: Vec2::ZERO }]
{
assert!(write_dxf_file_with_text_bounds(&file, |_| Ok(bounds)).is_err());
}
assert!(write_dxf_file_with_text_bounds(&file, |_| Err(IoError::Invalid("layout unavailable".into()))).is_err());
assert_eq!(file.entities().len(), 2);
}
@@ -1203,15 +1580,36 @@ mod tests {
fn aligned_text_hostile_payloads_refuse_before_requesting_metrics() {
for (at, height, angle_deg, text) in [
(Vec2::new(f64::NAN, 0.0), 1.0, 0.0, "A".into()),
(Vec2::ZERO, 0.0, 0.0, "A".into()), (Vec2::ZERO, -1.0, 0.0, "A".into()),
(Vec2::ZERO, f64::INFINITY, 0.0, "A".into()), (Vec2::ZERO, 1.0, f64::NAN, "A".into()),
(Vec2::ZERO, 1e-20, 0.0, "A".into()), (Vec2::ZERO, 1.0, 0.0, "A".repeat(251)),
(Vec2::ZERO, 0.0, 0.0, "A".into()),
(Vec2::ZERO, -1.0, 0.0, "A".into()),
(Vec2::ZERO, f64::INFINITY, 0.0, "A".into()),
(Vec2::ZERO, 1.0, f64::NAN, "A".into()),
(Vec2::ZERO, 1e-20, 0.0, "A".into()),
(Vec2::ZERO, 1.0, 0.0, "A".repeat(251)),
(Vec2::ZERO, 1.0, 0.0, "𠀋".into()),
] {
let mut file = DxfFile::new().unwrap();
file.push(file.default_layer(), DxfShape::AlignedText { at, height, angle_deg, text, horizontal: DxfTextHorizontalAlignment::Right, vertical: DxfTextVerticalAlignment::Middle }, None).unwrap();
file.push(
file.default_layer(),
DxfShape::AlignedText {
at,
height,
angle_deg,
text,
horizontal: DxfTextHorizontalAlignment::Right,
vertical: DxfTextVerticalAlignment::Middle,
},
None,
)
.unwrap();
let mut requested = false;
assert!(write_dxf_file_with_text_bounds(&file, |_| { requested = true; DxfGeometryBounds::new(Vec2::ZERO, Vec2::X) }).is_err());
assert!(
write_dxf_file_with_text_bounds(&file, |_| {
requested = true;
DxfGeometryBounds::new(Vec2::ZERO, Vec2::X)
})
.is_err()
);
assert!(!requested);
}
}
@@ -1219,15 +1617,28 @@ mod tests {
#[test]
fn aligned_text_retains_existing_encoding_and_resource_boundaries() {
let mut file = DxfFile::new().unwrap();
file.push(file.default_layer(), DxfShape::AlignedText {
at: Vec2::ZERO, height: 1.0, angle_deg: 0.0, text: "A%%d\nB°\\{}".into(), horizontal: DxfTextHorizontalAlignment::Left, vertical: DxfTextVerticalAlignment::Baseline,
}, None).unwrap();
file.push(
file.default_layer(),
DxfShape::AlignedText {
at: Vec2::ZERO,
height: 1.0,
angle_deg: 0.0,
text: "A%%d\nB°\\{}".into(),
horizontal: DxfTextHorizontalAlignment::Left,
vertical: DxfTextVerticalAlignment::Baseline,
},
None,
)
.unwrap();
let metrics = |_: &DxfShape| DxfGeometryBounds::new(Vec2::ZERO, Vec2::new(8.0, 1.0));
let output = write_dxf_file_limited_with_text_bounds(&file, crate::MAX_DRAWING_BYTES, 2, metrics).unwrap();
assert_eq!(field(&entities(&output)[0].1, 1), "A%%d B\\U+00B0\\{}");
assert!(write_dxf_file_limited_with_text_bounds(&file, output.len(), 2, metrics).is_ok());
assert!(write_dxf_file_limited_with_text_bounds(&file, output.len() - 1, 2, metrics).is_err());
assert!(write_dxf_file_limited_with_text_bounds(&file, crate::MAX_DRAWING_BYTES, 1, |_| panic!("over-budget text must not request metrics")).is_err());
assert!(
write_dxf_file_limited_with_text_bounds(&file, crate::MAX_DRAWING_BYTES, 1, |_| panic!("over-budget text must not request metrics"))
.is_err()
);
}
/// The ellipse of 50 × 24 round (50, 40): its four quarters, counter-clockwise from (75, 40).
@@ -1944,21 +2355,29 @@ mod tests {
let records = entities(&output);
assert_eq!(records.iter().map(|record| record.0.as_str()).collect::<Vec<_>>(), ["LINE", "POINT"]);
assert_eq!(records.iter().map(|record| field(&record.1, 8)).collect::<Vec<_>>(), ["0", "0"]);
assert_eq!(records.iter().map(|record| record.1.iter().filter(|(code, _)| *code == 370)
.map(|(_, value)| value.as_str()).collect::<Vec<_>>()).collect::<Vec<_>>(), [vec![], vec!["-1"]]);
assert_eq!(
records
.iter()
.map(|record| record.1.iter().filter(|(code, _)| *code == 370).map(|(_, value)| value.as_str()).collect::<Vec<_>>())
.collect::<Vec<_>>(),
[vec![], vec!["-1"]]
);
assert!(records.iter().all(|record| field(&record.1, 6).is_empty() && field(&record.1, 62).is_empty()));
}
#[test]
fn layer_zero_physical_zero_and_omission_keep_distinct_table_and_entity_spellings() {
let zero = DxfStandardWeight::from_code(0).unwrap();
for (layer_weight, layer_raw, inherited) in [
(None, "", DxfEntityWeight::Default),
(Some(zero), "0", DxfEntityWeight::Explicit(zero)),
] {
for (layer_weight, layer_raw, inherited) in [(None, "", DxfEntityWeight::Default), (Some(zero), "0", DxfEntityWeight::Explicit(zero))] {
let mut file = DxfFile::with_layer_zero(5, DxfLinetype::Dashed, layer_weight).unwrap();
let override_weight = DxfStandardWeight::from_code(13).unwrap();
let weights = [None, Some(DxfEntityWeight::ByLayer), Some(DxfEntityWeight::ByBlock), Some(DxfEntityWeight::Default), Some(DxfEntityWeight::Explicit(override_weight))];
let weights = [
None,
Some(DxfEntityWeight::ByLayer),
Some(DxfEntityWeight::ByBlock),
Some(DxfEntityWeight::Default),
Some(DxfEntityWeight::Explicit(override_weight)),
];
for (index, weight) in weights.into_iter().enumerate() {
file.push(file.default_layer(), DxfShape::Point(Vec2::new(index as f64, 0.0)), weight).unwrap();
}
@@ -1983,8 +2402,13 @@ mod tests {
let records = entities(&output);
assert_eq!(records.len(), 5);
assert!(records.iter().all(|record| record.0 == "POINT" && field(&record.1, 8) == "0"));
assert_eq!(records.iter().map(|record| record.1.iter().filter(|(code, _)| *code == 370)
.map(|(_, value)| value.as_str()).collect::<Vec<_>>()).collect::<Vec<_>>(), [vec![], vec!["-1"], vec!["-2"], vec!["-3"], vec!["13"]]);
assert_eq!(
records
.iter()
.map(|record| record.1.iter().filter(|(code, _)| *code == 370).map(|(_, value)| value.as_str()).collect::<Vec<_>>())
.collect::<Vec<_>>(),
[vec![], vec!["-1"], vec!["-2"], vec!["-3"], vec!["13"]]
);
}
}
+127 -27
View File
@@ -20,6 +20,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use super::DxfLinetype;
use crate::{IoError, Result};
mod bulges;
mod geometry;
pub use geometry::DxfGeometryBounds;
@@ -211,7 +212,13 @@ impl<G> DxfFile<G> {
Self::with_layer_zero_limits(7, DxfLinetype::Continuous, None, max_layers, max_entities)
}
fn with_layer_zero_limits(color: i16, linetype: DxfLinetype, weight: Option<StandardWeight>, max_layers: usize, max_entities: usize) -> Result<Self> {
fn with_layer_zero_limits(
color: i16,
linetype: DxfLinetype,
weight: Option<StandardWeight>,
max_layers: usize,
max_entities: usize,
) -> Result<Self> {
if !(1..=255).contains(&color) {
return Err(invalid("DXF layer color must be an index from 1 to 255"));
}
@@ -528,13 +535,20 @@ mod tests {
source.push(cut, DxfShape::Line(Vec2::new(7.0, 0.0), Vec2::new(7.0, 4.0)), Some(EntityWeight::Default)).unwrap();
let recovered = read_written_file(&source);
let layers: Vec<_> = recovered.layers().iter().map(|layer| (layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code))).collect();
assert_eq!(layers, [
("0", 7, DxfLinetype::Continuous, None),
("CUT", 1, DxfLinetype::Continuous, Some(50)),
("HIDDEN", 8, DxfLinetype::Hidden, Some(0)),
("UNUSED", 3, DxfLinetype::Dashed, None),
]);
let layers: Vec<_> = recovered
.layers()
.iter()
.map(|layer| (layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)))
.collect();
assert_eq!(
layers,
[
("0", 7, DxfLinetype::Continuous, None),
("CUT", 1, DxfLinetype::Continuous, Some(50)),
("HIDDEN", 8, DxfLinetype::Hidden, Some(0)),
("UNUSED", 3, DxfLinetype::Dashed, None),
]
);
let names: Vec<_> = recovered.entities().iter().map(|entity| recovered.layer(entity.layer()).unwrap().name()).collect();
assert_eq!(names, ["CUT", "0", "HIDDEN", "CUT"]);
assert_eq!(recovered.entities().len(), 4);
@@ -542,7 +556,10 @@ mod tests {
assert_eq!(recovered.entities()[1].geometry(), &DxfShape::Circle { c: Vec2::new(4.0, -1.0), r: 0.5 });
assert_eq!(recovered.entities()[2].geometry(), &DxfShape::Arc { c: Vec2::new(-3.0, 2.0), r: 2.0, a0: 350.0, a1: 10.0 });
assert_eq!(recovered.entities()[3].geometry(), &DxfShape::Line(Vec2::new(7.0, 0.0), Vec2::new(7.0, 4.0)));
assert_eq!(recovered.entities().iter().map(Entity::weight).collect::<Vec<_>>(), [None, Some(EntityWeight::ByBlock), Some(EntityWeight::ByLayer), Some(EntityWeight::Default)]);
assert_eq!(
recovered.entities().iter().map(Entity::weight).collect::<Vec<_>>(),
[None, Some(EntityWeight::ByBlock), Some(EntityWeight::ByLayer), Some(EntityWeight::Default)]
);
// Imported handles belong to the new file; compare resolved metadata, not owner IDs.
assert!(recovered.layer(cut).is_err());
assert!(recovered.layer(source.default_layer()).is_err());
@@ -576,7 +593,10 @@ mod tests {
let index = style_index * 28 + weight_index;
let entity = &recovered.entities()[index];
let layer = recovered.layer(entity.layer()).unwrap();
assert_eq!((layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)), (*name, *color, *linetype, *layer_weight));
assert_eq!(
(layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)),
(*name, *color, *linetype, *layer_weight)
);
assert_eq!(entity.weight(), *expected_weight, "style={name} weight ordinal={weight_index}");
assert_eq!(entity.geometry(), &DxfShape::Line(Vec2::new(0.5, 0.75), Vec2::new(1.25, -0.5)));
}
@@ -602,7 +622,10 @@ mod tests {
assert_eq!(recovered.entities()[0].geometry(), &DxfShape::Line(Vec2::new(-1.25, 2.5), Vec2::new(3.75, -4.0)));
assert_eq!(recovered.entities()[1].geometry(), &DxfShape::Circle { c: Vec2::new(8.0, -2.0), r: 1.25 });
assert_eq!(recovered.entities()[2].geometry(), &DxfShape::Arc { c: Vec2::new(2.0, 3.0), r: 0.75, a0: 45.0, a1: 135.0 });
assert_eq!(recovered.entities()[3].geometry(), &DxfShape::Polyline { points: vec![Vec2::new(-2.0, 0.0), Vec2::new(0.0, 1.5), Vec2::new(0.0, 1.5), Vec2::new(4.0, -0.5)] });
assert_eq!(
recovered.entities()[3].geometry(),
&DxfShape::Polyline { points: vec![Vec2::new(-2.0, 0.0), Vec2::new(0.0, 1.5), Vec2::new(0.0, 1.5), Vec2::new(4.0, -0.5)] }
);
for entity in recovered.entities() {
let layer = recovered.layer(entity.layer()).unwrap();
assert_eq!((layer.name(), layer.linetype()), (name, linetype));
@@ -617,19 +640,50 @@ mod tests {
let mut source = DxfFile::new().unwrap();
let layer = source.declare_layer("CURVE", 2, linetype, Some(StandardWeight::from_code(35).unwrap())).unwrap();
for weights in [None, Some(vec![1.0; 10]), Some(vec![0.5, 1.0, 2.0, 4.0, 0.25, 0.5, 1.0, 2.0, 4.0, 1.0])] {
source.push(layer, DxfShape::Spline {
degree: 9,
knots: vec![-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0],
ctrl: vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 2.0), Vec2::new(2.0, 4.0), Vec2::new(3.0, 8.0), Vec2::new(4.0, 4.0), Vec2::new(5.0, 2.0), Vec2::new(6.0, 1.0), Vec2::new(7.0, 0.0), Vec2::new(8.0, -1.0), Vec2::new(9.0, -2.0)],
weights,
}, Some(EntityWeight::Explicit(StandardWeight::from_code(0).unwrap()))).unwrap();
source
.push(
layer,
DxfShape::Spline {
degree: 9,
knots: vec![-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0],
ctrl: vec![
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 2.0),
Vec2::new(2.0, 4.0),
Vec2::new(3.0, 8.0),
Vec2::new(4.0, 4.0),
Vec2::new(5.0, 2.0),
Vec2::new(6.0, 1.0),
Vec2::new(7.0, 0.0),
Vec2::new(8.0, -1.0),
Vec2::new(9.0, -2.0),
],
weights,
},
Some(EntityWeight::Explicit(StandardWeight::from_code(0).unwrap())),
)
.unwrap();
}
let recovered = read_written_file(&source);
assert_eq!(recovered.entities().len(), 3);
for (index, entity) in recovered.entities().iter().enumerate() {
let DxfShape::Spline { degree, knots, ctrl, weights } = entity.geometry() else { panic!("expected degree-nine spline") };
assert_eq!(*degree, 9);
assert_eq!(ctrl, &[Vec2::new(0.0, 0.0), Vec2::new(1.0, 2.0), Vec2::new(2.0, 4.0), Vec2::new(3.0, 8.0), Vec2::new(4.0, 4.0), Vec2::new(5.0, 2.0), Vec2::new(6.0, 1.0), Vec2::new(7.0, 0.0), Vec2::new(8.0, -1.0), Vec2::new(9.0, -2.0)]);
assert_eq!(
ctrl,
&[
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 2.0),
Vec2::new(2.0, 4.0),
Vec2::new(3.0, 8.0),
Vec2::new(4.0, 4.0),
Vec2::new(5.0, 2.0),
Vec2::new(6.0, 1.0),
Vec2::new(7.0, 0.0),
Vec2::new(8.0, -1.0),
Vec2::new(9.0, -2.0)
]
);
assert_eq!(knots, &[-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0]);
match index {
0 => assert_eq!(weights, &None),
@@ -647,19 +701,56 @@ mod tests {
#[test]
fn writer_reader_keep_degree_nine_interior_knots_without_normalizing_the_domain() {
let mut source = DxfFile::new().unwrap();
source.push(source.default_layer(), DxfShape::Spline {
degree: 9,
knots: vec![-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -0.5, 2.5, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0],
ctrl: vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 4.0), Vec2::new(3.0, 1.0), Vec2::new(4.0, 0.0), Vec2::new(5.0, -1.0), Vec2::new(6.0, -2.0), Vec2::new(7.0, -1.0), Vec2::new(8.0, 0.0), Vec2::new(9.0, 1.0), Vec2::new(10.0, 2.0), Vec2::new(11.0, 0.0)],
weights: Some(vec![1.0, 0.5, 2.0, 1.0, 0.25, 4.0, 1.0, 2.0, 0.5, 1.0, 4.0, 1.0]),
}, Some(EntityWeight::Default)).unwrap();
source
.push(
source.default_layer(),
DxfShape::Spline {
degree: 9,
knots: vec![
-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -0.5, 2.5, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0,
],
ctrl: vec![
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 1.0),
Vec2::new(2.0, 4.0),
Vec2::new(3.0, 1.0),
Vec2::new(4.0, 0.0),
Vec2::new(5.0, -1.0),
Vec2::new(6.0, -2.0),
Vec2::new(7.0, -1.0),
Vec2::new(8.0, 0.0),
Vec2::new(9.0, 1.0),
Vec2::new(10.0, 2.0),
Vec2::new(11.0, 0.0),
],
weights: Some(vec![1.0, 0.5, 2.0, 1.0, 0.25, 4.0, 1.0, 2.0, 0.5, 1.0, 4.0, 1.0]),
},
Some(EntityWeight::Default),
)
.unwrap();
let recovered = read_written_file(&source);
assert_eq!(recovered.entities().len(), 1);
let entity = &recovered.entities()[0];
let DxfShape::Spline { degree, knots, ctrl, weights } = entity.geometry() else { panic!("expected degree-nine spline") };
assert_eq!(*degree, 9);
assert_eq!(knots, &[-2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -2.0, -0.5, 2.5, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0, 6.0]);
assert_eq!(ctrl, &[Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 4.0), Vec2::new(3.0, 1.0), Vec2::new(4.0, 0.0), Vec2::new(5.0, -1.0), Vec2::new(6.0, -2.0), Vec2::new(7.0, -1.0), Vec2::new(8.0, 0.0), Vec2::new(9.0, 1.0), Vec2::new(10.0, 2.0), Vec2::new(11.0, 0.0)]);
assert_eq!(
ctrl,
&[
Vec2::new(0.0, 0.0),
Vec2::new(1.0, 1.0),
Vec2::new(2.0, 4.0),
Vec2::new(3.0, 1.0),
Vec2::new(4.0, 0.0),
Vec2::new(5.0, -1.0),
Vec2::new(6.0, -2.0),
Vec2::new(7.0, -1.0),
Vec2::new(8.0, 0.0),
Vec2::new(9.0, 1.0),
Vec2::new(10.0, 2.0),
Vec2::new(11.0, 0.0)
]
);
assert_eq!(weights.as_deref(), Some([1.0, 0.5, 2.0, 1.0, 0.25, 4.0, 1.0, 2.0, 0.5, 1.0, 4.0, 1.0].as_slice()));
assert_eq!(recovered.layer(entity.layer()).unwrap().name(), "0");
assert_eq!(entity.weight(), Some(EntityWeight::Default));
@@ -679,7 +770,10 @@ mod tests {
assert_eq!(recovered.entities()[0].geometry(), &DxfShape::Point(Vec2::new(-0.5, 1.25)));
assert_eq!(recovered.entities()[1].geometry(), &DxfShape::Point(Vec2::new(8.0, -2.0)));
let layer = recovered.layer(recovered.entities()[0].layer()).unwrap();
assert_eq!((layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)), ("DATUM", 4, DxfLinetype::Hidden, Some(50)));
assert_eq!(
(layer.name(), layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)),
("DATUM", 4, DxfLinetype::Hidden, Some(50))
);
assert_eq!(recovered.entities()[0].weight(), Some(EntityWeight::ByBlock));
assert_eq!(recovered.layer(recovered.entities()[1].layer()).unwrap().name(), "0");
assert_eq!(recovered.entities()[1].weight(), None);
@@ -733,7 +827,13 @@ mod tests {
for code in [None, Some(0), Some(50)] {
let layer_weight = code.map(|value| StandardWeight::from_code(value).unwrap());
let mut file = DxfFile::with_layer_zero(5, DxfLinetype::Dashed, layer_weight).unwrap();
for weight in [None, Some(EntityWeight::ByLayer), Some(EntityWeight::ByBlock), Some(EntityWeight::Default), Some(EntityWeight::Explicit(StandardWeight::from_code(13).unwrap()))] {
for weight in [
None,
Some(EntityWeight::ByLayer),
Some(EntityWeight::ByBlock),
Some(EntityWeight::Default),
Some(EntityWeight::Explicit(StandardWeight::from_code(13).unwrap())),
] {
file.push(file.default_layer(), (), weight).unwrap();
}
let inherited = match code {
+350
View File
@@ -0,0 +1,350 @@
//! Bounded admission and conservative bounds for the real X1 BulgedPolyline.
//! Bounds contain each whole supporting circle, not only the directed arc.
//! No trig/hypot, sampling, output array, topology repair or physical stroke width.
use solidcraft_geom::Vec2;
use super::super::{DxfVertex, path::MAX_PATH_ITEMS};
use super::DxfGeometryBounds;
use crate::{IoError, Result};
const MAX_WORK: usize = 64 << 20;
const EDGE_WORK: usize = 512;
fn invalid(message: &str) -> IoError {
IoError::Invalid(format!("DXF bulged polyline bounds: {message}"))
}
// Intervals enclose REAL arithmetic on the supplied binary64 endpoints/bulge.
// Nonzero subnormal inputs/intermediates and infinite outward endpoints refuse.
// This deliberately conservative admission is not a tolerance or ULP-accuracy
// promise for any transcendental function or the old reconstructed Arc sampler.
#[derive(Clone, Copy)]
struct Interval {
lo: f64,
hi: f64,
}
impl Interval {
fn checked(lo: f64, hi: f64) -> Result<Self> {
if lo > hi || !lo.is_finite() || !hi.is_finite() || (lo != 0.0 && !lo.is_normal()) || (hi != 0.0 && !hi.is_normal()) {
return Err(invalid("nonfinite or subnormal support interval is not representable"));
}
Ok(Self { lo, hi })
}
fn exact(value: f64) -> Result<Self> {
Self::checked(value, value)
}
fn is(self, value: f64) -> bool {
self.lo == value && self.hi == value
}
fn rounded(value: f64) -> Result<Self> {
if value == 0.0 || !value.is_normal() {
return Err(invalid("support arithmetic overflow or underflow"));
}
Self::checked(value.next_down(), value.next_up())
}
fn neg(self) -> Self {
Self { lo: -self.hi, hi: -self.lo }
}
fn sum(a: f64, b: f64) -> Result<Self> {
if a == 0.0 {
return Self::exact(b);
}
if b == 0.0 {
return Self::exact(a);
}
if a == -b {
return Self::exact(0.0);
}
Self::rounded(a + b)
}
fn product(a: f64, b: f64) -> Result<Self> {
if a == 0.0 || b == 0.0 {
return Self::exact(0.0);
}
if a.abs() == 1.0 {
return Self::exact(if a < 0.0 { -b } else { b });
}
if b.abs() == 1.0 {
return Self::exact(if b < 0.0 { -a } else { a });
}
Self::rounded(a * b)
}
fn quotient(a: f64, b: f64) -> Result<Self> {
if b == 0.0 {
return Err(invalid("support divisor contains zero"));
}
if a == 0.0 {
return Self::exact(0.0);
}
if b.abs() == 1.0 {
return Self::exact(if b < 0.0 { -a } else { a });
}
Self::rounded(a / b)
}
fn hull(self, other: Self) -> Result<Self> {
Self::checked(self.lo.min(other.lo), self.hi.max(other.hi))
}
fn add(self, rhs: Self) -> Result<Self> {
let lo = Self::sum(self.lo, rhs.lo)?.lo;
let hi = Self::sum(self.hi, rhs.hi)?.hi;
Self::checked(lo, hi)
}
fn sub(self, rhs: Self) -> Result<Self> {
self.add(rhs.neg())
}
fn mul(self, rhs: Self) -> Result<Self> {
Self::product(self.lo, rhs.lo)?
.hull(Self::product(self.lo, rhs.hi)?)?
.hull(Self::product(self.hi, rhs.lo)?)?
.hull(Self::product(self.hi, rhs.hi)?)
}
fn div(self, rhs: Self) -> Result<Self> {
if rhs.lo <= 0.0 && rhs.hi >= 0.0 {
return Err(invalid("support divisor contains zero"));
}
Self::quotient(self.lo, rhs.lo)?
.hull(Self::quotient(self.lo, rhs.hi)?)?
.hull(Self::quotient(self.hi, rhs.lo)?)?
.hull(Self::quotient(self.hi, rhs.hi)?)
}
fn abs(self) -> Result<Self> {
let hi = self.lo.abs().max(self.hi.abs());
let lo = if self.lo <= 0.0 && self.hi >= 0.0 { 0.0 } else { self.lo.abs().min(self.hi.abs()) };
Self::checked(lo, hi)
}
}
fn supporting_arc(from: DxfVertex, to: Vec2) -> Result<DxfGeometryBounds> {
let (x0, y0) = (Interval::exact(from.at.x)?, Interval::exact(from.at.y)?);
let (x1, y1) = (Interval::exact(to.x)?, Interval::exact(to.y)?);
let b = Interval::exact(from.bulge)?;
let b2 = b.mul(b)?;
let one = Interval::exact(1.0)?;
let two = Interval::exact(2.0)?;
let four = Interval::exact(4.0)?;
let (dx, dy) = (x1.sub(x0)?, y1.sub(y0)?);
// Centre = midpoint + perp(chord/(4b)) * (1-b²).
// Dividing the chord components first avoids an unbounded standalone1/b.
let divisor = four.mul(b)?;
let (qx, qy) = (dx.div(divisor)?, dy.div(divisor)?);
let factor = one.sub(b2)?;
let cx = x0.div(two)?.add(x1.div(two)?)?.sub(qy.mul(factor)?)?;
let cy = y0.div(two)?.add(y1.div(two)?)?.add(qx.mul(factor)?)?;
// r = ||chord/(4b)||2 * (1+b²) <= its L1 analogue.
// All terms are enclosed before selecting the upper radius, so no assumed
// libm rounding bound or single-final-ULP shortcut is hidden here.
let radius = qx.abs()?.add(qy.abs()?)?.mul(one.add(b2)?)?.hi;
if radius <= 0.0 {
return Err(invalid("support radius has collapsed"));
}
let radius = Interval::exact(radius)?;
let x = cx.sub(radius)?.hull(cx.add(radius)?)?;
let y = cy.sub(radius)?.hull(cy.add(radius)?)?;
DxfGeometryBounds::new(Vec2::new(x.lo, y.lo), Vec2::new(x.hi, y.hi))
}
fn edge(from: DxfVertex, to: Vec2) -> Result<DxfGeometryBounds> {
if from.at == to && from.bulge != 0.0 {
return Err(invalid("zero chord cannot carry a nonzero bulge"));
}
let endpoints = DxfGeometryBounds::new(Vec2::new(from.at.x.min(to.x), from.at.y.min(to.y)), Vec2::new(from.at.x.max(to.x), from.at.y.max(to.y)))?;
if from.bulge == 0.0 {
return Ok(endpoints);
}
let support = supporting_arc(from, to)?;
// Include stored endpoints explicitly even for an extremely wide support
// interval; neither a support reconstruction nor a hull may discard them.
Ok(union(endpoints, support))
}
fn union(a: DxfGeometryBounds, b: DxfGeometryBounds) -> DxfGeometryBounds {
DxfGeometryBounds { min: Vec2::new(a.min.x.min(b.min.x), a.min.y.min(b.min.y)), max: Vec2::new(a.max.x.max(b.max.x), a.max.y.max(b.max.y)) }
}
/// New internal proposal: validate/bound the real X1 vertices, without copying.
/// max_vertices/max_work may tighten hard limits only. Work is len +512*edges,
/// checked BEFORE scanning values. Caller must charge that same deterministic
/// cost cumulatively across the file; passing a fresh per-entity cap is not a
/// whole-file budget. No hidden caller or model/variant adoption is assumed.
pub(super) fn bounds(vertices: &[DxfVertex], closed: bool, max_vertices: usize, max_work: usize) -> Result<DxfGeometryBounds> {
let count = vertices.len();
if !(2..=max_vertices.min(MAX_PATH_ITEMS)).contains(&count) {
return Err(invalid("vertex count exceeds admission or needs at least two vertices"));
}
let edges = if closed { count } else { count - 1 };
let work = edges.checked_mul(EDGE_WORK).and_then(|cost| cost.checked_add(count)).ok_or_else(|| invalid("work count overflow"))?;
if work > max_work.min(MAX_WORK) {
return Err(invalid("work budget exceeded before vertex scan"));
}
if vertices.iter().any(|v| !v.at.is_finite() || !v.bulge.is_finite() || v.bulge.abs() > 1.0) {
return Err(invalid("nonfinite vertex/bulge or more than a half turn"));
}
if !closed && vertices.last().is_some_and(|v| v.bulge != 0.0) {
return Err(invalid("open terminal bulge must be zero"));
}
// Zero-bulge coincident points are valid authored LINE geometry, but must
// not silently admit the duplicated terminal vertex of a closed polyline.
if closed && vertices.first().zip(vertices.last()).is_some_and(|(first, last)| first.at == last.at) {
return Err(invalid("closed polyline must not duplicate its first vertex at the seam"));
}
let mut pairs = vertices.iter().zip(vertices.iter().skip(1));
let (first, second) = pairs.next().ok_or_else(|| invalid("missing first segment"))?;
let mut total = edge(*first, second.at)?;
for (from, to) in pairs {
total = union(total, edge(*from, to.at)?);
}
if closed {
let (last, first) = vertices.last().zip(vertices.first()).ok_or_else(|| invalid("missing closed seam"))?;
total = union(total, edge(*last, first.at)?);
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
fn v(x: f64, y: f64, bulge: f64) -> DxfVertex {
DxfVertex { at: Vec2::new(x, y), bulge }
}
fn read(vertices: &[DxfVertex], closed: bool) -> Result<DxfGeometryBounds> {
bounds(vertices, closed, MAX_PATH_ITEMS, MAX_WORK)
}
fn contains(b: DxfGeometryBounds, x: f64, y: f64) -> bool {
b.min.x <= x && x <= b.max.x && b.min.y <= y && y <= b.max.y
}
#[test]
fn lines_use_exact_endpoint_hull_without_arc_arithmetic() {
let b = read(&[v(-2.0, 3.0, 0.0), v(4.0, -1.0, 0.0), v(0.0, 8.0, 0.0)], false).unwrap();
assert_eq!(b, DxfGeometryBounds { min: Vec2::new(-2.0, -1.0), max: Vec2::new(4.0, 8.0) });
assert!(read(&[v(-f64::MAX, 0.0, 0.0), v(f64::MAX, 0.0, 0.0)], false).is_ok());
}
#[test]
fn signed_semicircles_include_all_four_exact_support_cardinals() {
for bulge in [-1.0, 1.0] {
let b = read(&[v(-1.0, 0.0, bulge), v(1.0, 0.0, 0.0)], false).unwrap();
for (x, y) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)] {
assert!(contains(b, x, y));
}
}
}
#[test]
fn asymmetric_signed_bulges_use_analytic_centre_and_radius_not_writer_output() {
// Chord(0,0)->(4,0), b=1/2: centre(2,3/2), radius5/2.
for (bulge, cy) in [(0.5, 1.5), (-0.5, -1.5)] {
let b = read(&[v(0.0, 0.0, bulge), v(4.0, 0.0, 0.0)], false).unwrap();
for (x, y) in [(-0.5, cy), (4.5, cy), (2.0, cy - 2.5), (2.0, cy + 2.5)] {
assert!(contains(b, x, y));
}
}
// Oblique chord(0,0)->(4,3), b=1/2: centre(7/8,3), r=25/8.
let b = read(&[v(0.0, 0.0, 0.5), v(4.0, 3.0, 0.0)], false).unwrap();
for (x, y) in [(-2.25, 3.0), (4.0, 3.0), (0.875, -0.125), (0.875, 6.125)] {
assert!(contains(b, x, y));
}
}
#[test]
fn closed_last_bulge_is_used_and_duplicate_seam_is_refused() {
let b = read(&[v(-1.0, 0.0, 0.0), v(1.0, 0.0, 1.0)], true).unwrap();
assert!(contains(b, 0.0, 1.0) && contains(b, 0.0, -1.0));
assert!(read(&[v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0)], true).is_err());
assert!(read(&[v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.5)], false).is_err());
}
#[test]
fn nonfinite_or_out_of_domain_values_refuse_without_mutating_inputs() {
for input in [
[v(f64::NAN, 0.0, 0.0), v(1.0, 0.0, 0.0)],
[v(0.0, 0.0, f64::INFINITY), v(1.0, 0.0, 0.0)],
[v(0.0, 0.0, 1.0000000000000002), v(1.0, 0.0, 0.0)],
[v(0.0, 0.0, -1.0000000000000002), v(1.0, 0.0, 0.0)],
[v(0.0, 0.0, 0.0), v(1.0, f64::NEG_INFINITY, 0.0)],
] {
let before = input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits()));
assert!(read(&input, false).is_err());
assert_eq!(input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits())), before);
}
}
#[test]
fn tiny_representable_support_survives_without_squaring_chord() {
let b = read(&[v(0.0, 0.0, 1.0), v(1e-200, 0.0, 0.0)], false).unwrap();
assert!(contains(b, 0.0, 0.0) && contains(b, 1e-200, 0.0));
assert!(b.min.y < 0.0 && b.max.y > 0.0 && b.min.is_finite() && b.max.is_finite());
assert!(read(&[v(0.0, 0.0, 1.0), v(f64::from_bits(1), 0.0, 0.0)], false).is_err());
assert!(read(&[v(0.0, 0.0, 1e-300), v(1.0, 0.0, 0.0)], false).is_err());
}
#[test]
fn overflow_or_outward_infinite_support_is_explicitly_refused() {
assert!(read(&[v(-f64::MAX, 0.0, 1.0), v(f64::MAX, 0.0, 0.0)], false).is_err());
assert!(read(&[v(f64::MAX, 0.0, 0.5), v(f64::MAX, 1e308, 0.0)], false).is_err());
}
#[test]
fn vertex_and_work_admission_precede_payload_scan_and_are_inclusive() {
let input = [v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0)];
assert!(bounds(&input, false, 2, 514).is_ok());
assert!(bounds(&input, false, 1, 514).is_err());
assert!(bounds(&input, false, 2, 513).is_err());
assert!(bounds(&input, true, 2, 1026).is_ok());
assert!(bounds(&input, true, 2, 1025).is_err());
let hostile = [v(f64::NAN, 0.0, 0.0), v(1.0, 0.0, 0.0)];
assert!(bounds(&hostile, false, 2, 513).unwrap_err().to_string().contains("work budget"));
assert!(bounds(&hostile, false, 1, usize::MAX).unwrap_err().to_string().contains("vertex count"));
assert!(bounds(&[], false, usize::MAX, usize::MAX).is_err());
assert!(bounds(&input[..1], false, usize::MAX, usize::MAX).is_err());
}
#[test]
fn authored_zero_length_line_keeps_exact_point_bounds_and_input_bits() {
// Exact migrated former Err fixture, now SUPPORTED to match X1's
// original polylines() zero-length LINE -> open two-vertex entity.
let original = [v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0)];
assert_eq!(read(&original, false).unwrap(), DxfGeometryBounds { min: Vec2::ZERO, max: Vec2::ZERO });
for point in [Vec2::new(3.0, -4.0), Vec2::new(f64::from_bits(1), 0.0), Vec2::new(f64::MAX, -f64::MAX)] {
let input = [DxfVertex { at: point, bulge: 0.0 }, DxfVertex { at: point, bulge: 0.0 }];
let before = input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits()));
assert_eq!(read(&input, false).unwrap(), DxfGeometryBounds { min: point, max: point });
assert_eq!(input.map(|v| (v.at.x.to_bits(), v.at.y.to_bits(), v.bulge.to_bits())), before);
}
let mixed = [v(2.0, 3.0, 0.0), v(2.0, 3.0, 0.0), v(5.0, 7.0, 0.0)];
assert_eq!(read(&mixed, false).unwrap(), DxfGeometryBounds { min: Vec2::new(2.0, 3.0), max: Vec2::new(5.0, 7.0) });
}
#[test]
fn zero_chord_arc_and_explicit_closed_duplicate_seam_still_refuse() {
for bulge in [-1.0, -0.5, 0.5, 1.0] {
assert!(read(&[v(2.0, 3.0, bulge), v(2.0, 3.0, 0.0)], false).is_err());
}
for input in [
vec![v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0)],
vec![v(0.0, 0.0, 0.0), v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0)],
vec![v(0.0, 0.0, 1.0), v(1.0, 0.0, 0.0), v(0.0, 0.0, 0.0)],
] {
assert!(read(&input, true).unwrap_err().to_string().contains("closed polyline must not duplicate"));
}
let nonfinite = [v(f64::INFINITY, 0.0, 0.0), v(f64::INFINITY, 0.0, 0.0)];
assert!(read(&nonfinite, false).is_err());
assert!(bounds(&[v(0.0, 0.0, 0.0), v(0.0, 0.0, 0.0)], false, 2, 513).is_err());
}
}
+447 -23
View File
@@ -12,7 +12,7 @@
use solidcraft_geom::Vec2;
use super::super::DxfShape;
use super::super::{DxfShape, DxfVertex};
use super::DxfFile;
use crate::{IoError, Result};
@@ -189,6 +189,11 @@ where
F: FnMut(&DxfShape) -> Result<DxfGeometryBounds>,
{
match shape {
DxfShape::BulgedPolyline { vertices, closed } => {
// The file-level length-only preflight already charged every
// entity's interval work cumulatively before this value scan.
super::bulges::bounds(vertices, *closed, super::super::path::MAX_PATH_ITEMS, crate::MAX_DRAWING_BYTES)
}
DxfShape::Point(point) => {
let point = finite_point(*point)?;
DxfGeometryBounds::new(point, point)
@@ -230,6 +235,26 @@ fn transformed_point(point: Vec2, scale: f64, translation: Vec2) -> Result<Vec2>
fn transformed_shape(shape: &DxfShape, scale: f64, translation: Vec2) -> Result<DxfShape> {
match shape {
DxfShape::BulgedPolyline { vertices, closed } => {
super::super::admit_bulged(vertices, *closed)?;
let mut copied = Vec::new();
copied.try_reserve_exact(vertices.len()).map_err(|_| invalid("DXF bulged vertex allocation failed"))?;
let mut previous: Option<(Vec2, Vec2)> = None;
for vertex in vertices {
let at = transformed_point(vertex.at, scale, translation)?;
if let Some((old, last)) = previous {
if old != vertex.at && last == at {
return Err(invalid("DXF transform collapses adjacent distinct bulged vertices"));
}
}
copied.push(DxfVertex { at, bulge: vertex.bulge });
previous = Some((vertex.at, at));
}
// This retains raw outgoing signs/zero bits and the original seam
// flag; it does not reconstruct/re-fit arcs or invoke strict bounds.
super::super::admit_bulged(&copied, *closed)?;
Ok(DxfShape::BulgedPolyline { vertices: copied, closed: *closed })
}
DxfShape::Point(point) => Ok(DxfShape::Point(transformed_point(*point, scale, translation)?)),
DxfShape::Line(a, b) => {
let start = transformed_point(*a, scale, translation)?;
@@ -378,6 +403,21 @@ impl DxfFile<DxfShape> {
// string comparison cost in layer declarations, or wall-clock time.
budget.charge(0, 32, copy_limit, work_limit)?;
match entity.geometry() {
DxfShape::BulgedPolyline { vertices, closed } => {
let count = vertices.len();
if !(2..=super::super::path::MAX_PATH_ITEMS).contains(&count) {
return Err(invalid("bulged vertex count exceeds supported range"));
}
let edges = if *closed { count } else { count - 1 };
// Mirrors the exact pinned d8b helper's n+512*edges cost;
// array copy adds8*n. Charge globally BEFORE any values.
let interval_work = edges
.checked_mul(512)
.and_then(|work| work.checked_add(count))
.ok_or_else(|| invalid("DXF bulged interval work count overflow"))?;
budget.array::<DxfVertex>(count, copy_limit, work_limit)?;
budget.charge(0, interval_work, copy_limit, work_limit)?;
}
DxfShape::Text { text, .. } | DxfShape::AlignedText { text, .. } => {
text_bytes = accumulated_text_bytes(text_bytes, text.len(), copy_limit)?;
budget.charge(text.len(), text.len(), copy_limit, work_limit)?;
@@ -424,6 +464,110 @@ mod tests {
use super::super::super::DxfLinetype;
use super::super::super::{DxfTextHorizontalAlignment, DxfTextVerticalAlignment};
#[test]
fn bulged_copy_preserves_vertices_raw_signs_seams_and_custom_zero_metadata() {
let physical = StandardWeight::from_code(50).unwrap();
let mut file = DxfFile::with_layer_zero(3, DxfLinetype::Hidden, Some(physical)).unwrap();
let vertices = vec![
DxfVertex { at: Vec2::new(0.0, 0.0), bulge: 0.5 },
DxfVertex { at: Vec2::new(4.0, 0.0), bulge: -0.5 },
DxfVertex { at: Vec2::new(8.0, 0.0), bulge: -0.0 },
];
file.push(file.default_layer(), DxfShape::BulgedPolyline { vertices: vertices.clone(), closed: false }, Some(EntityWeight::ByLayer)).unwrap();
let copied = file.translated_scaled(Vec2::new(10.0, -5.0), 2.0).unwrap();
let DxfShape::BulgedPolyline { vertices: result, closed } = copied.entities()[0].geometry() else { panic!("expected real bulged shape") };
assert!(!*closed);
assert_eq!(result.iter().map(|v| v.at).collect::<Vec<_>>(), [Vec2::new(10.0, -5.0), Vec2::new(18.0, -5.0), Vec2::new(26.0, -5.0)]);
assert_eq!(result.iter().map(|v| v.bulge.to_bits()).collect::<Vec<_>>(), vertices.iter().map(|v| v.bulge.to_bits()).collect::<Vec<_>>());
assert_eq!(copied.effective_weight(0).unwrap(), EntityWeight::Explicit(physical));
assert_eq!(copied.effective_linetype(0).unwrap(), DxfLinetype::Hidden);
assert!(copied.layer(file.default_layer()).is_err());
assert_eq!(file.entities()[0].geometry(), &DxfShape::BulgedPolyline { vertices, closed: false });
}
#[test]
fn bulged_closed_copy_uses_last_bulge_and_conservative_geometry_bounds() {
let mut file = DxfFile::new().unwrap();
file.push(
file.default_layer(),
DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::new(-1.0, 0.0), bulge: 1.0 }, DxfVertex { at: Vec2::new(1.0, 0.0), bulge: 1.0 }],
closed: true,
},
None,
)
.unwrap();
let copied = file.translated_scaled(Vec2::new(10.0, -5.0), 2.0).unwrap();
let DxfShape::BulgedPolyline { vertices, closed } = copied.entities()[0].geometry() else { panic!("expected real bulged shape") };
assert!(*closed);
assert_eq!(vertices.len(), 2);
assert_eq!((vertices[0].at, vertices[1].at), (Vec2::new(8.0, -5.0), Vec2::new(12.0, -5.0)));
assert_eq!((vertices[0].bulge, vertices[1].bulge), (1.0, 1.0));
let b = copied.extents().unwrap().unwrap();
for point in [Vec2::new(8.0, -5.0), Vec2::new(12.0, -5.0), Vec2::new(10.0, -7.0), Vec2::new(10.0, -3.0)] {
assert!(b.min.x <= point.x && point.x <= b.max.x && b.min.y <= point.y && point.y <= b.max.y);
}
}
#[test]
fn bulged_copy_and_bounds_preflight_cumulative_bytes_and_work_before_values() {
let mut file = DxfFile::new().unwrap();
file.push(
file.default_layer(),
DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::ZERO, bulge: 0.0 }, DxfVertex { at: Vec2::X, bulge: 0.0 }],
closed: false,
},
None,
)
.unwrap();
let bytes = 2 * std::mem::size_of::<DxfVertex>();
// Fixed32 + copy8*n + helper(n+512*edges) =562.
assert!(file.translated_scaled_with_limits(Vec2::ZERO, 1.0, bytes, 562).is_ok());
assert!(file.translated_scaled_with_limits(Vec2::ZERO, 1.0, bytes - 1, 562).is_err());
assert!(file.translated_scaled_with_limits(Vec2::ZERO, 1.0, bytes, 561).is_err());
let mut hostile = DxfFile::new().unwrap();
hostile
.push(
hostile.default_layer(),
DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::new(f64::NAN, 0.0), bulge: 0.0 }, DxfVertex { at: Vec2::X, bulge: 0.0 }],
closed: false,
},
None,
)
.unwrap();
assert!(hostile.translated_scaled_with_limits(Vec2::ZERO, 1.0, bytes, 561).unwrap_err().to_string().contains("work"));
let second = file.entities()[0].geometry().clone();
file.push(file.default_layer(), second, None).unwrap();
assert!(file.translated_scaled_with_limits(Vec2::ZERO, 1.0, 2 * bytes, 1124).is_ok());
assert!(file.translated_scaled_with_limits(Vec2::ZERO, 1.0, 2 * bytes, 1123).is_err());
}
#[test]
fn strict_bulged_bounds_are_not_total_copy_admission_and_collapsed_vertices_refuse() {
let mut file = DxfFile::new().unwrap();
let original = DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::ZERO, bulge: 1e-200 }, DxfVertex { at: Vec2::new(1e-200, 0.0), bulge: 0.0 }],
closed: false,
};
file.push(file.default_layer(), original.clone(), None).unwrap();
assert!(file.extents().is_err()); // b*b underflows under interval policy.
assert_eq!(file.translated_scaled(Vec2::ZERO, 1.0).unwrap().entities()[0].geometry(), &original);
let mut line = DxfFile::new().unwrap();
line.push(
line.default_layer(),
DxfShape::BulgedPolyline {
vertices: vec![DxfVertex { at: Vec2::ZERO, bulge: 0.0 }, DxfVertex { at: Vec2::X, bulge: 0.0 }],
closed: false,
},
None,
)
.unwrap();
assert!(line.translated_scaled(Vec2::new(1e300, 0.0), 1.0).is_err());
assert_eq!(file.entities()[0].geometry(), &original);
}
#[test]
fn identity_and_positive_copy_preserve_custom_layer_zero_and_remap_all_handles() {
let physical_zero = StandardWeight::from_code(0).unwrap();
@@ -432,14 +576,18 @@ mod tests {
let explicit_13 = EntityWeight::Explicit(StandardWeight::from_code(13).unwrap());
let original = [
DxfShape::Line(Vec2::new(1.0, 2.0), Vec2::new(3.0, 4.0)),
DxfShape::Point(Vec2::new(-1.0, 0.0)), DxfShape::Point(Vec2::new(0.0, 1.0)),
DxfShape::Point(Vec2::new(2.0, -1.0)), DxfShape::Point(Vec2::new(4.0, 2.0)),
DxfShape::Point(Vec2::new(-1.0, 0.0)),
DxfShape::Point(Vec2::new(0.0, 1.0)),
DxfShape::Point(Vec2::new(2.0, -1.0)),
DxfShape::Point(Vec2::new(4.0, 2.0)),
DxfShape::Line(Vec2::new(0.0, -2.0), Vec2::new(2.0, -2.0)),
];
let moved = [
DxfShape::Line(Vec2::new(12.0, -1.0), Vec2::new(16.0, 3.0)),
DxfShape::Point(Vec2::new(8.0, -5.0)), DxfShape::Point(Vec2::new(10.0, -3.0)),
DxfShape::Point(Vec2::new(14.0, -7.0)), DxfShape::Point(Vec2::new(18.0, -1.0)),
DxfShape::Point(Vec2::new(8.0, -5.0)),
DxfShape::Point(Vec2::new(10.0, -3.0)),
DxfShape::Point(Vec2::new(14.0, -7.0)),
DxfShape::Point(Vec2::new(18.0, -1.0)),
DxfShape::Line(Vec2::new(10.0, -9.0), Vec2::new(14.0, -9.0)),
];
let weights = [None, Some(EntityWeight::ByLayer), Some(EntityWeight::ByBlock), Some(EntityWeight::Default), Some(explicit_13), None];
@@ -456,7 +604,10 @@ mod tests {
let new_zero = copied.layer(copied.default_layer()).unwrap();
assert_eq!((new_zero.color(), new_zero.linetype(), new_zero.weight()), (3, DxfLinetype::Hidden, zero_weight));
let new_cut = copied.layer(copied.entities()[5].layer()).unwrap();
assert_eq!((new_cut.name(), new_cut.color(), new_cut.linetype(), new_cut.weight()), ("CUT", 5, DxfLinetype::Dashed, Some(cut_weight)));
assert_eq!(
(new_cut.name(), new_cut.color(), new_cut.linetype(), new_cut.weight()),
("CUT", 5, DxfLinetype::Dashed, Some(cut_weight))
);
assert!(copied.layer(zero).is_err() && copied.layer(cut).is_err());
assert!(source.layer(copied.default_layer()).is_err());
assert!(source.layer(copied.entities()[5].layer()).is_err());
@@ -464,7 +615,9 @@ mod tests {
assert_eq!(entity.geometry(), shape);
assert_eq!(entity.weight(), weights[index]);
assert_eq!(copied.effective_linetype(index).unwrap(), if index == 5 { DxfLinetype::Dashed } else { DxfLinetype::Hidden });
if index < 5 { assert_eq!(entity.layer(), copied.default_layer()); }
if index < 5 {
assert_eq!(entity.layer(), copied.default_layer());
}
}
let inherited = zero_weight.map_or(EntityWeight::Default, EntityWeight::Explicit);
assert_eq!(copied.effective_weight(0).unwrap(), inherited);
@@ -491,24 +644,35 @@ mod tests {
let mut file = DxfFile::new().unwrap();
let physical = StandardWeight::from_code(50).unwrap();
let layer = file.declare_layer("NOTES", 4, DxfLinetype::Hidden, Some(physical)).unwrap();
let original = DxfShape::AlignedText { at: Vec2::new(2.0, 3.0), height: 4.0, angle_deg: 90.0, text: "A".into(), horizontal, vertical };
let original =
DxfShape::AlignedText { at: Vec2::new(2.0, 3.0), height: 4.0, angle_deg: 90.0, text: "A".into(), horizontal, vertical };
file.push(layer, original.clone(), Some(EntityWeight::ByLayer)).unwrap();
assert!(file.extents().is_err());
let provided = DxfGeometryBounds::new(Vec2::new(-2.0, 1.0), Vec2::new(3.0, 6.0)).unwrap();
assert_eq!(file.extents_with_text_bounds(|shape| {
assert_eq!(shape, &original);
Ok(provided)
}).unwrap(), Some(provided));
assert_eq!(
file.extents_with_text_bounds(|shape| {
assert_eq!(shape, &original);
Ok(provided)
})
.unwrap(),
Some(provided)
);
let copied = file.translated_scaled(Vec2::new(10.0, -5.0), 2.0).unwrap();
assert_eq!(copied.entities()[0].geometry(), &DxfShape::AlignedText {
at: Vec2::new(14.0, 1.0), height: 8.0, angle_deg: 90.0, text: "A".into(), horizontal, vertical,
});
assert_eq!(
copied.entities()[0].geometry(),
&DxfShape::AlignedText { at: Vec2::new(14.0, 1.0), height: 8.0, angle_deg: 90.0, text: "A".into(), horizontal, vertical }
);
assert!(copied.extents().is_err());
let moved_bounds = DxfGeometryBounds::new(Vec2::new(6.0, -3.0), Vec2::new(16.0, 7.0)).unwrap();
assert_eq!(copied.extents_with_text_bounds(|shape| {
assert_eq!(shape, copied.entities()[0].geometry());
Ok(moved_bounds)
}).unwrap(), Some(moved_bounds));
assert_eq!(
copied
.extents_with_text_bounds(|shape| {
assert_eq!(shape, copied.entities()[0].geometry());
Ok(moved_bounds)
})
.unwrap(),
Some(moved_bounds)
);
assert_eq!(copied.entities()[0].weight(), Some(EntityWeight::ByLayer));
assert_eq!(copied.effective_weight(0).unwrap(), EntityWeight::Explicit(physical));
assert_eq!(copied.effective_linetype(0).unwrap(), DxfLinetype::Hidden);
@@ -521,9 +685,19 @@ mod tests {
fn aligned_payload_participates_in_existing_cumulative_text_budget() {
let mut file = DxfFile::new().unwrap();
file.push(file.default_layer(), DxfShape::Text { at: Vec2::ZERO, height: 1.0, angle_deg: 0.0, text: "é".into() }, None).unwrap();
file.push(file.default_layer(), DxfShape::AlignedText {
at: Vec2::ZERO, height: 1.0, angle_deg: 0.0, text: "abc".into(), horizontal: DxfTextHorizontalAlignment::Left, vertical: DxfTextVerticalAlignment::Top,
}, None).unwrap();
file.push(
file.default_layer(),
DxfShape::AlignedText {
at: Vec2::ZERO,
height: 1.0,
angle_deg: 0.0,
text: "abc".into(),
horizontal: DxfTextHorizontalAlignment::Left,
vertical: DxfTextVerticalAlignment::Top,
},
None,
)
.unwrap();
assert!(file.translated_scaled_with_text_limit(Vec2::ZERO, 1.0, 5).is_ok());
assert!(file.translated_scaled_with_text_limit(Vec2::ZERO, 1.0, 4).is_err());
assert_eq!(file.entities().len(), 2);
@@ -532,7 +706,14 @@ mod tests {
#[test]
fn aligned_transform_and_callback_refusals_preserve_source() {
let mut file = DxfFile::new().unwrap();
let original = DxfShape::AlignedText { at: Vec2::new(f64::MAX, 0.0), height: 1.0, angle_deg: 0.0, text: "A".into(), horizontal: DxfTextHorizontalAlignment::Right, vertical: DxfTextVerticalAlignment::Top };
let original = DxfShape::AlignedText {
at: Vec2::new(f64::MAX, 0.0),
height: 1.0,
angle_deg: 0.0,
text: "A".into(),
horizontal: DxfTextHorizontalAlignment::Right,
vertical: DxfTextVerticalAlignment::Top,
};
file.push(file.default_layer(), original.clone(), Some(EntityWeight::Default)).unwrap();
assert!(file.translated_scaled(Vec2::ZERO, 2.0).is_err());
assert!(file.extents_with_text_bounds(|_| Ok(DxfGeometryBounds { min: Vec2::new(1.0, 1.0), max: Vec2::ZERO })).is_err());
@@ -843,4 +1024,247 @@ mod tests {
.unwrap();
assert!(file.translated_scaled(Vec2::ZERO, 0.5).is_err());
}
#[test]
fn imported_nine_text_modes_keep_custom_zero_when_a_bulged_shape_is_added_and_copied() {
use crate::{
DxfEntityWeight as Weight, DxfLinetype as LineType, DxfShape as Shape, DxfStandardWeight as Standard, DxfTextHorizontalAlignment as H,
DxfTextVerticalAlignment as V, DxfVertex as Vertex, read_dxf_file,
};
use solidcraft_geom::Vec2;
let modes = [
(0, 0, H::Left, V::Baseline),
(1, 0, H::Centre, V::Baseline),
(2, 0, H::Right, V::Baseline),
(0, 2, H::Left, V::Middle),
(1, 2, H::Centre, V::Middle),
(2, 2, H::Right, V::Middle),
(0, 3, H::Left, V::Top),
(1, 3, H::Centre, V::Top),
(2, 3, H::Right, V::Top),
];
// Literal millimetre DXF, with the canonical HIDDEN definition and a
// nondefault layer0. No generated writer output supplies these oracles.
let mut input = concat!(
"0\nSECTION\n2\nHEADER\n9\n$INSUNITS\n70\n4\n0\nENDSEC\n",
"0\nSECTION\n2\nTABLES\n0\nTABLE\n2\nLTYPE\n",
"0\nLTYPE\n2\nHIDDEN\n72\n65\n73\n2\n40\n3\n49\n2\n74\n0\n49\n-1\n74\n0\n0\nENDTAB\n",
"0\nTABLE\n2\nLAYER\n0\nLAYER\n2\n0\n62\n3\n6\nHIDDEN\n370\n50\n0\nENDTAB\n0\nENDSEC\n",
"0\nSECTION\n2\nENTITIES\n",
)
.to_string();
for (h, v, _, _) in modes {
input.push_str(&format!("0\nTEXT\n8\n0\n370\n-1\n10\n99\n20\n88\n11\n2\n21\n3\n40\n4\n50\n90\n72\n{h}\n73\n{v}\n1\n %%d \n"));
}
input.push_str("0\nENDSEC\n0\nEOF\n");
let mut source = read_dxf_file(&input).unwrap();
assert_eq!(source.entities().len(), 9);
let physical = Standard::from_code(50).unwrap();
let old_zero = source.default_layer();
let zero = source.layer(old_zero).unwrap();
assert_eq!((zero.name(), zero.color(), zero.linetype(), zero.weight()), ("0", 3, LineType::Hidden, Some(physical)));
source
.push(
old_zero,
Shape::BulgedPolyline {
vertices: vec![
Vertex { at: Vec2::ZERO, bulge: 0.5 },
Vertex { at: Vec2::new(4.0, 0.0), bulge: -0.5 },
Vertex { at: Vec2::new(8.0, 0.0), bulge: -0.0 },
],
closed: false,
},
Some(Weight::ByBlock),
)
.unwrap();
let original: Vec<_> = source.entities().iter().map(|e| e.geometry().clone()).collect();
let copied = source.translated_scaled(Vec2::new(10.0, -5.0), 2.0).unwrap();
let zero = copied.layer(copied.default_layer()).unwrap();
assert_eq!((zero.name(), zero.color(), zero.linetype(), zero.weight()), ("0", 3, LineType::Hidden, Some(physical)));
assert_eq!(copied.layers().len(), 1);
assert_eq!(copied.entities().len(), 10);
assert!(copied.layer(old_zero).is_err());
assert!(source.layer(copied.default_layer()).is_err());
for (entity, (h, v, horizontal, vertical)) in copied.entities().iter().take(9).zip(modes) {
// Left/baseline selects group10; other modes select group11. The
// reader's centre/baseline canonical Text representation is retained.
let at = if h == 0 && v == 0 { Vec2::new(208.0, 171.0) } else { Vec2::new(14.0, 1.0) };
let expected = if h == 1 && v == 0 {
Shape::Text { at, height: 8.0, angle_deg: 90.0, text: " ° ".into() }
} else {
Shape::AlignedText { at, height: 8.0, angle_deg: 90.0, text: " ° ".into(), horizontal, vertical }
};
assert_eq!(entity.geometry(), &expected);
assert_eq!(entity.layer(), copied.default_layer());
assert_eq!(entity.weight(), Some(Weight::ByLayer));
}
let Shape::BulgedPolyline { vertices, closed } = copied.entities()[9].geometry() else { panic!("expected bulged geometry") };
assert!(!*closed);
assert_eq!(vertices.iter().map(|v| v.at).collect::<Vec<_>>(), [Vec2::new(10.0, -5.0), Vec2::new(18.0, -5.0), Vec2::new(26.0, -5.0)]);
assert_eq!(vertices.iter().map(|v| v.bulge.to_bits()).collect::<Vec<_>>(), [0.5f64.to_bits(), (-0.5f64).to_bits(), (-0.0f64).to_bits()]);
for index in 0..10 {
assert_eq!(copied.effective_linetype(index).unwrap(), LineType::Hidden);
assert_eq!(copied.effective_weight(index).unwrap(), if index == 9 { Weight::ByBlock } else { Weight::Explicit(physical) });
assert_eq!(source.entities()[index].geometry(), &original[index]);
assert_eq!(source.entities()[index].layer(), old_zero);
assert_eq!(source.entities()[index].weight(), Some(if index == 9 { Weight::ByBlock } else { Weight::ByLayer }));
}
}
#[test]
fn copied_mixed_bulges_and_empty_aligned_text_keep_zero_style_and_literal_writer_fields() {
use crate::{
DxfEntityWeight as Weight, DxfFile as File, DxfGeometryBounds as Bounds, DxfLinetype as LineType, DxfShape as Shape,
DxfStandardWeight as Standard, DxfTextHorizontalAlignment as H, DxfTextVerticalAlignment as V, DxfVertex as Vertex, write_dxf_file,
write_dxf_file_with_text_bounds,
};
use solidcraft_geom::Vec2;
fn records<'a>(pairs: &'a [(i32, &'a str)], kind: &str) -> Vec<&'a [(i32, &'a str)]> {
pairs
.iter()
.enumerate()
.filter_map(|(index, (code, value))| {
if *code != 0 || *value != kind {
return None;
}
let tail = &pairs[index + 1..];
let end = tail.iter().position(|(code, _)| *code == 0).unwrap_or(tail.len());
Some(&tail[..end])
})
.collect()
}
fn values<'a>(record: &[(i32, &'a str)], code: i32) -> Vec<&'a str> {
record.iter().filter_map(|(field, value)| (*field == code).then_some(*value)).collect()
}
let modes = [
(H::Left, V::Baseline, "0", "0"),
(H::Centre, V::Baseline, "1", "0"),
(H::Right, V::Baseline, "2", "0"),
(H::Left, V::Middle, "0", "2"),
(H::Centre, V::Middle, "1", "2"),
(H::Right, V::Middle, "2", "2"),
(H::Left, V::Top, "0", "3"),
(H::Centre, V::Top, "1", "3"),
(H::Right, V::Top, "2", "3"),
];
for weight_code in [None, Some(0), Some(50)] {
let weight = weight_code.map(|code| Standard::from_code(code).unwrap());
let mut source = File::with_layer_zero(3, LineType::Hidden, weight).unwrap();
source
.push(
source.default_layer(),
Shape::BulgedPolyline {
vertices: vec![Vertex { at: Vec2::new(-1.0, 0.0), bulge: 1.0 }, Vertex { at: Vec2::new(1.0, 0.0), bulge: 1.0 }],
closed: true,
},
Some(Weight::ByLayer),
)
.unwrap();
for (horizontal, vertical, _, _) in modes {
source
.push(
source.default_layer(),
Shape::AlignedText { at: Vec2::new(2.0, 3.0), height: 4.0, angle_deg: 90.0, text: String::new(), horizontal, vertical },
Some(Weight::ByLayer),
)
.unwrap();
}
let copied = source.translated_scaled(Vec2::new(10.0, -5.0), 2.0).unwrap();
assert!(copied.extents().is_err());
let mut bounds_calls = 0;
let bounds = copied
.extents_with_text_bounds(|shape| {
let Shape::AlignedText { at, text, .. } = shape else { panic!("text bounds called for geometry") };
assert_eq!(*at, Vec2::new(14.0, 1.0));
assert!(text.is_empty());
bounds_calls += 1;
Bounds::new(Vec2::new(14.0, 1.0), Vec2::new(14.0, 1.0))
})
.unwrap()
.unwrap();
assert_eq!(bounds_calls, 9);
// Conservative interval bounds may extend past the circle; the mixed
// union must include both its literal cardinal support and the TEXT.
assert!(bounds.min.x <= 8.0 && bounds.min.y <= -7.0);
assert!(bounds.max.x >= 14.0 && bounds.max.y >= 1.0);
assert!(write_dxf_file(&copied).is_err());
let mut calls = 0;
let output = write_dxf_file_with_text_bounds(&copied, |shape| {
let Shape::AlignedText { at, height, angle_deg, text, horizontal, vertical } = shape else { panic!("provider called for non-text") };
let (h, v, _, _) = modes[calls];
assert_eq!((*at, *height, *angle_deg, *horizontal, *vertical), (Vec2::new(14.0, 1.0), 8.0, 90.0, h, v));
assert!(text.is_empty());
calls += 1;
// Empty text has no glyph ink; this is a fixture-only provider,
// not an invented font bound for nonempty text.
Bounds::new(Vec2::new(14.0, 1.0), Vec2::new(14.0, 1.0))
})
.unwrap();
assert_eq!(calls, 9);
let mut lines = output.lines();
let mut pairs = Vec::new();
while let Some(code) = lines.next() {
pairs.push((code.trim().parse::<i32>().unwrap(), lines.next().unwrap()));
}
let layers = records(&pairs, "LAYER");
assert_eq!(layers.len(), 1);
assert_eq!(values(layers[0], 2), ["0"]);
assert_eq!(values(layers[0], 62), ["3"]);
assert_eq!(values(layers[0], 6), ["HIDDEN"]);
let expected_weight = match weight_code {
None => vec![],
Some(0) => vec!["0"],
Some(50) => vec!["50"],
_ => panic!("unexpected fixture weight"),
};
assert_eq!(values(layers[0], 370), expected_weight);
let bulges = records(&pairs, "LWPOLYLINE");
assert_eq!(bulges.len(), 1);
for (code, expected) in [
(8, vec!["0"]),
(370, vec!["-1"]),
(90, vec!["2"]),
(70, vec!["129"]),
(10, vec!["8", "12"]),
(20, vec!["-5", "-5"]),
(42, vec!["1", "1"]),
] {
assert_eq!(values(bulges[0], code), expected);
}
let texts = records(&pairs, "TEXT");
assert_eq!(texts.len(), 9);
for (text, (_, _, h, v)) in texts.iter().zip(modes) {
for (code, expected) in [
(8, "0"),
(370, "-1"),
(10, "14.0"),
(20, "1.0"),
(11, "14.0"),
(21, "1.0"),
(40, "8.0"),
(50, "90.0"),
(72, h),
(73, v),
(1, ""),
] {
assert_eq!(values(text, code), [expected]);
}
}
for (name, expected) in [("$EXTMIN", [(10, "8"), (20, "-7"), (30, "0.0")]), ("$EXTMAX", [(10, "14"), (20, "1"), (30, "0.0")])] {
let index = pairs.iter().position(|pair| *pair == (9, name)).unwrap();
assert_eq!(&pairs[index + 1..index + 4], &expected);
}
for (index, entity) in copied.entities().iter().enumerate() {
assert_eq!(entity.layer(), copied.default_layer());
assert_eq!(entity.weight(), Some(Weight::ByLayer));
assert_eq!(copied.effective_weight(index).unwrap(), weight.map_or(Weight::Default, Weight::Explicit));
assert_eq!(copied.effective_linetype(index).unwrap(), LineType::Hidden);
}
assert!(copied.layer(source.default_layer()).is_err());
assert_eq!(source.layers().len(), 1);
assert_eq!(source.entities().len(), 10);
}
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Minimal X1 bulged-polyline support; path conversion/fitting is not ported.
use solidcraft_geom::{Seg2, Vec2};
/// 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,
}
pub(super) const MAX_PATH_ITEMS: usize = 200_000;
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()
}
/// Reconstruct a non-straight polyline edge with a signed sweep for bounds and validation.
/// Avoid squaring the chord length or first forming 1/bulge: those can overflow even when
/// the resulting radius is finite. A zero chord with a nonzero bulge is not an arc.
pub(super) fn bulge_arc(from: DxfVertex, to: Vec2) -> Option<Seg2> {
let b = from.bulge;
if !(from.at.is_finite() && to.is_finite() && b.is_finite()) || b == 0.0 || b.abs() > 1.0 {
return None;
}
let chord = to - from.at;
let length = chord.len();
if !chord.is_finite() || !length.is_finite() || length == 0.0 {
return None;
}
let scaled = (length * 0.25) / b;
let radius = scaled.abs() * (1.0 + b * b);
let center = from.at * 0.5 + to * 0.5 + (chord / length).perp() * (scaled * (1.0 - b * b));
if !circle_bounds_finite(center, radius) {
return None;
}
let radial = from.at - center;
if !radial.is_finite() || radial == Vec2::ZERO {
return None;
}
let start = radial.angle();
let sweep = 4.0 * b.atan();
(start.is_finite() && sweep.is_finite() && sweep != 0.0).then_some(Seg2::Arc { center, radius, start, sweep })
}
+1 -1
View File
@@ -35,7 +35,7 @@ pub mod vfs;
pub use dxf::{read_dxf, read_dxf_file, write_dxf};
pub use dxf_out::{
DxfEntity, DxfEntityWeight, DxfFile, DxfFileEntity, DxfFileLayer, DxfGeometryBounds, DxfLayer, DxfLayerId, DxfLinetype, DxfShape,
DxfStandardWeight, DxfTextHorizontalAlignment, DxfTextVerticalAlignment, loop_shapes, seg_shape, write_dxf_drawing, write_dxf_file,
DxfStandardWeight, DxfTextHorizontalAlignment, DxfTextVerticalAlignment, DxfVertex, loop_shapes, seg_shape, write_dxf_drawing, write_dxf_file,
write_dxf_file_with_text_bounds,
};
pub use legacy_dir::{APP_ID, LEGACY_APP_ID, PREVIOUS_APP_IDS, adopt_legacy_dir, legacy_flatpak_paths};
+30 -7
View File
@@ -2728,20 +2728,43 @@ pub fn take_theme_paste(app: &mut SolveApp, ctx: &egui::Context) {
/// Files dropped on the theme Import tile are themes to import, not designs to open (#8226).
pub fn take_theme_drops(app: &mut SolveApp, ctx: &egui::Context) {
let Some(rect) = app.prefs_window.import_tile.filter(|_| app.prefs_window.open) else { return };
if !ctx.input(|i| !i.raw.dropped_files.is_empty() && i.pointer.latest_pos().is_some_and(|p| rect.contains(p))) {
if !theme_drop_target(app, ctx) || !ctx.input(|i| !i.raw.dropped_files.is_empty()) {
return;
}
let files = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in files.iter().take(8) {
let text = f
.bytes()
.and_then(|b| if b.len() > crate::theme_files::MAX_BYTES { Err("the file is too large for a theme".into()) } else { Ok(b) })
.and_then(|b| String::from_utf8(b).map_err(|_| "not a theme file".to_string()));
import_theme_text(app, text);
// The browser reads dropped files only asynchronously: the web shell takes them first and
// hands a theme's bytes to `import_theme_bytes` (#8226), so none reach this on wasm.
#[cfg(not(target_arch = "wasm32"))]
match f.bytes() {
Ok(bytes) => import_theme_bytes(app, bytes),
Err(e) => import_theme_text(app, Err(e.to_string())),
}
#[cfg(target_arch = "wasm32")]
let _ = f;
}
}
/// Whether files dropped now land on the Import tile: the Preferences window is open and the
/// pointer was last seen over the tile.
pub fn theme_drop_target(app: &SolveApp, ctx: &egui::Context) -> bool {
app.prefs_window
.import_tile
.filter(|_| app.prefs_window.open)
.is_some_and(|rect| ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| rect.contains(p)))
}
/// A file dropped on the Import tile, as bytes: imported when it is a theme, refused with the reason
/// otherwise.
pub fn import_theme_bytes(app: &mut SolveApp, bytes: Vec<u8>) {
let text = if bytes.len() > crate::theme_files::MAX_BYTES {
Err("the file is too large for a theme".to_string())
} else {
String::from_utf8(bytes).map_err(|_| "not a theme file".to_string())
};
import_theme_text(app, text);
}
pub fn import_theme(app: &mut SolveApp, v: &serde_json::Value) -> Result<String, String> {
let theme = match v {
serde_json::Value::String(text) => crate::theme_files::parse(text)?,