mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-07 12:31:56 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8e7a8690a | |||
| 6b39ee3686 | |||
| a7f3c78b07 | |||
| 471e17f50e | |||
| 1891344e07 | |||
| 1e176d8503 | |||
| fac0043543 | |||
| 119c40c814 |
@@ -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),
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+2597
-4
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
//! 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());
|
||||
let mut full_bytes = MAX_DRAWING_BYTES;
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut full_bytes, &mut 0).is_err());
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut 0, &mut (MAX_WORK - 1)).is_err());
|
||||
let mut invalid_bytes = usize::MAX;
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut invalid_bytes, &mut 0).is_err());
|
||||
let mut invalid_work = usize::MAX;
|
||||
assert!(read_text_entity(&entity, 1.0, 1, 1, &mut 0, &mut invalid_work).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());
|
||||
}
|
||||
}
|
||||
@@ -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(), "中𠀋");
|
||||
}
|
||||
}
|
||||
+1490
-33
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,902 @@
|
||||
//! Owned layers and entity metadata for partially built DXF files.
|
||||
//!
|
||||
//! Generic geometry is carried unchanged and is not validated or serialized by
|
||||
//! insertion. Concrete DxfShape methods provide conservative bounds and positive
|
||||
//! transforms in declared supported subsets; text bounds need a layout provider.
|
||||
//! Legacy drawing commands keep their existing APIs until their adapters are qualified.
|
||||
//!
|
||||
//! Layer declarations currently admit ASCII only. They trim ASCII whitespace,
|
||||
//! replace restricted characters, and refuse normalized/case collisions.
|
||||
//! Names admit at most 4096 raw bytes and 255 normalized characters; files hold
|
||||
//! at most 1024 layers and one million entities. These are resource limits,
|
||||
//! not a promise of complete DXF import support. Geometry owns its own budget.
|
||||
//! Standard physical lineweights use the 24 public DXF codes, with exact
|
||||
//! midpoint ties choosing the heavier weight. Absent and symbolic entity
|
||||
//! weights stay distinct. Layer zero is initialized by new() or with_layer_zero()
|
||||
//! and stays immutable after construction. Unicode names remain unsupported.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use super::DxfLinetype;
|
||||
use crate::{IoError, Result};
|
||||
|
||||
mod bulges;
|
||||
mod geometry;
|
||||
pub use geometry::DxfGeometryBounds;
|
||||
|
||||
const STANDARD_WEIGHTS: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
const MAX_NAME_CHARS: usize = 255;
|
||||
// A resource bound on the RAW input, independent of the format-name length
|
||||
// after trimming. It does not change the normalized format-name bound.
|
||||
const MAX_RAW_NAME_BYTES: usize = 4096;
|
||||
const MAX_LAYERS: usize = 1024;
|
||||
const MAX_ENTITIES: usize = 1_000_000;
|
||||
static NEXT_OWNER: AtomicUsize = AtomicUsize::new(1);
|
||||
|
||||
fn invalid(message: &str) -> IoError {
|
||||
IoError::Invalid(message.into())
|
||||
}
|
||||
|
||||
/// A standard physical weight, in hundredths of a millimetre.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct StandardWeight(u16);
|
||||
|
||||
impl StandardWeight {
|
||||
pub fn from_code(code: u16) -> Result<Self> {
|
||||
if STANDARD_WEIGHTS.contains(&code) { Ok(Self(code)) } else { Err(invalid("not a standard DXF lineweight")) }
|
||||
}
|
||||
|
||||
/// Nearest rational standard weight; exact midpoint ties choose the heavier.
|
||||
/// Existing drawing writers are unchanged until their adapters use this model.
|
||||
pub fn from_mm(mm: f64) -> Result<Self> {
|
||||
if !mm.is_finite() || !(0.0..=2.11).contains(&mm) {
|
||||
return Err(invalid("DXF lineweight must be finite and between 0 and 2.11 mm"));
|
||||
}
|
||||
for pair in STANDARD_WEIGHTS.windows(2) {
|
||||
let [low, high] = pair else { return Err(invalid("invalid standard lineweight table")) };
|
||||
let midpoint_sum = f64::from(*low + *high);
|
||||
// Compare exact 200*mm to the integer midpoint_sum. When its
|
||||
// rounded product equals that integer, FMA preserves the sign of
|
||||
// the residual. At these nonzero integer midpoints the residual
|
||||
// is far above subnormal underflow. No tolerance or mm clamping.
|
||||
let scaled = mm * 200.0;
|
||||
if scaled < midpoint_sum || (scaled == midpoint_sum && mm.mul_add(200.0, -scaled) < 0.0) {
|
||||
return Ok(Self(*low));
|
||||
}
|
||||
}
|
||||
Ok(Self(211))
|
||||
}
|
||||
|
||||
pub fn code(self) -> u16 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbols are not negative physical weights. None on Entity means the group
|
||||
/// was omitted; Some(ByLayer) is an explicitly authored -1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum EntityWeight {
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Default,
|
||||
Explicit(StandardWeight),
|
||||
}
|
||||
|
||||
impl EntityWeight {
|
||||
pub fn group370(self) -> i16 {
|
||||
match self {
|
||||
Self::ByLayer => -1,
|
||||
Self::ByBlock => -2,
|
||||
Self::Default => -3,
|
||||
Self::Explicit(value) => value.code() as i16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// These are in-memory ownership handles, NOT serialized DXF group-5 handles.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LayerId {
|
||||
owner: usize,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Layer {
|
||||
original: String,
|
||||
name: String,
|
||||
color: i16,
|
||||
linetype: DxfLinetype,
|
||||
/// None preserves legacy omission of group370; its semantic default is
|
||||
/// unresolved here, never silently replaced with a numeric weight.
|
||||
weight: Option<StandardWeight>,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
pub fn color(&self) -> i16 {
|
||||
self.color
|
||||
}
|
||||
pub fn linetype(&self) -> DxfLinetype {
|
||||
self.linetype
|
||||
}
|
||||
pub fn weight(&self) -> Option<StandardWeight> {
|
||||
self.weight
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Entity<G> {
|
||||
layer: LayerId,
|
||||
weight: Option<EntityWeight>,
|
||||
geometry: G,
|
||||
}
|
||||
|
||||
impl<G> Entity<G> {
|
||||
pub fn layer(&self) -> LayerId {
|
||||
self.layer
|
||||
}
|
||||
pub fn weight(&self) -> Option<EntityWeight> {
|
||||
self.weight
|
||||
}
|
||||
pub fn geometry(&self) -> &G {
|
||||
&self.geometry
|
||||
}
|
||||
}
|
||||
|
||||
/// No Clone: cloning must not accidentally duplicate ownership. A future copy
|
||||
/// operation must mint a new owner and remap its handles explicitly.
|
||||
#[derive(Debug)]
|
||||
pub struct DxfFile<G> {
|
||||
owner: usize,
|
||||
layers: Vec<Layer>,
|
||||
entities: Vec<Entity<G>>,
|
||||
max_layers: usize,
|
||||
max_entities: usize,
|
||||
}
|
||||
|
||||
fn owner_from(counter: &AtomicUsize) -> Result<usize> {
|
||||
counter.try_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1)).map_err(|_| invalid("DXF file ownership identities exhausted"))
|
||||
}
|
||||
|
||||
fn owned(text: &str) -> Result<String> {
|
||||
let mut out = String::new();
|
||||
out.try_reserve(text.len()).map_err(|_| invalid("DXF name allocation failed"))?;
|
||||
out.push_str(text);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn ascii_name_space(c: char) -> bool {
|
||||
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
|
||||
}
|
||||
|
||||
fn name_of(input: &str) -> Result<String> {
|
||||
if input.len() > MAX_RAW_NAME_BYTES {
|
||||
return Err(invalid("DXF raw layer-name input exceeds resource byte limit"));
|
||||
}
|
||||
let input = input.trim_matches(ascii_name_space);
|
||||
// Inspect at most one scalar beyond the bound before any allocation.
|
||||
// At most255 Unicode scalars imply at most1020 UTF-8 bytes. The length
|
||||
// rule alone does not establish Unicode symbol-equivalence semantics.
|
||||
if input.chars().take(MAX_NAME_CHARS + 1).count() > MAX_NAME_CHARS {
|
||||
return Err(invalid("DXF layer name exceeds 255 Unicode scalar values"));
|
||||
}
|
||||
if input.is_empty() {
|
||||
return owned("0");
|
||||
}
|
||||
let mut name = String::new();
|
||||
name.try_reserve(input.len()).map_err(|_| invalid("DXF name allocation failed"))?;
|
||||
for c in input.chars() {
|
||||
let forbidden = c.is_control() || matches!(c, '<' | '>' | '/' | '\\' | '"' | '\'' | ':' | ';' | '?' | '*' | '|' | '=');
|
||||
name.push(if forbidden { '_' } else { c });
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
impl<G> DxfFile<G> {
|
||||
pub fn new() -> Result<Self> {
|
||||
Self::with_limits(MAX_LAYERS, MAX_ENTITIES)
|
||||
}
|
||||
|
||||
/// Construct a new file with the imported style of its first layer, `0`.
|
||||
/// This is initialization, not a setter: the style is immutable after construction.
|
||||
/// Colors use the same indexed range as [`Self::declare_layer`]; a missing
|
||||
/// physical weight remains absent rather than becoming an explicit zero.
|
||||
/// Every successful construction mints fresh file-scoped ownership handles.
|
||||
pub fn with_layer_zero(color: i16, linetype: DxfLinetype, weight: Option<StandardWeight>) -> Result<Self> {
|
||||
Self::with_layer_zero_limits(color, linetype, weight, MAX_LAYERS, MAX_ENTITIES)
|
||||
}
|
||||
|
||||
fn with_limits(max_layers: usize, max_entities: usize) -> Result<Self> {
|
||||
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> {
|
||||
if !(1..=255).contains(&color) {
|
||||
return Err(invalid("DXF layer color must be an index from 1 to 255"));
|
||||
}
|
||||
if max_layers == 0 {
|
||||
return Err(invalid("DXF file requires layer 0"));
|
||||
}
|
||||
let owner = owner_from(&NEXT_OWNER)?;
|
||||
let mut layers = Vec::new();
|
||||
layers.try_reserve(1).map_err(|_| invalid("DXF layer allocation failed"))?;
|
||||
layers.push(Layer { original: owned("0")?, name: owned("0")?, color, linetype, weight });
|
||||
Ok(Self { owner, layers, entities: Vec::new(), max_layers: max_layers.min(MAX_LAYERS), max_entities: max_entities.min(MAX_ENTITIES) })
|
||||
}
|
||||
|
||||
pub fn default_layer(&self) -> LayerId {
|
||||
LayerId { owner: self.owner, index: 0 }
|
||||
}
|
||||
pub fn layers(&self) -> &[Layer] {
|
||||
&self.layers
|
||||
}
|
||||
pub fn entities(&self) -> &[Entity<G>] {
|
||||
&self.entities
|
||||
}
|
||||
|
||||
pub fn layer(&self, id: LayerId) -> Result<&Layer> {
|
||||
if id.owner != self.owner {
|
||||
return Err(invalid("layer belongs to a different DXF file"));
|
||||
}
|
||||
self.layers.get(id.index).ok_or_else(|| invalid("unknown DXF layer handle"))
|
||||
}
|
||||
|
||||
pub fn declare_layer(&mut self, original: &str, color: i16, linetype: DxfLinetype, weight: Option<StandardWeight>) -> Result<LayerId> {
|
||||
if original.len() > MAX_RAW_NAME_BYTES {
|
||||
return Err(invalid("DXF raw layer-name input exceeds resource byte limit"));
|
||||
}
|
||||
// Provisional ASCII-only container: do not claim general Unicode
|
||||
// collision safety from eq_ignore_ascii_case. The character-bound
|
||||
// helper is Unicode-aware, but a reader adapter needs an agreed
|
||||
// canonical symbol comparison before it may admit Unicode layers.
|
||||
if !original.is_ascii() {
|
||||
return Err(invalid("not supported yet: non-ASCII DXF layer names"));
|
||||
}
|
||||
let name = name_of(original)?;
|
||||
if !(1..=255).contains(&color) {
|
||||
return Err(invalid("DXF layer color must be an index from 1 to 255"));
|
||||
}
|
||||
if let Some((index, existing)) = self.layers.iter().enumerate().find(|(_, layer)| layer.name.eq_ignore_ascii_case(&name)) {
|
||||
// Blank and whitespace-surrounded0 explicitly alias built-in0.
|
||||
// Other distinct raw declarations that normalize to one symbol
|
||||
// refuse rather than adding a second or silently merging styles.
|
||||
let same_input = original == existing.original || name == "0";
|
||||
if same_input && existing.color == color && existing.linetype == linetype && existing.weight == weight {
|
||||
return Ok(LayerId { owner: self.owner, index });
|
||||
}
|
||||
return Err(invalid("DXF layer declaration collides with an existing symbol"));
|
||||
}
|
||||
if self.layers.len() >= self.max_layers {
|
||||
return Err(invalid("DXF layer count limit exceeded"));
|
||||
}
|
||||
let original = owned(original)?;
|
||||
self.layers.try_reserve(1).map_err(|_| invalid("DXF layer allocation failed"))?;
|
||||
let index = self.layers.len();
|
||||
self.layers.push(Layer { original, name, color, linetype, weight });
|
||||
Ok(LayerId { owner: self.owner, index })
|
||||
}
|
||||
|
||||
/// G is carried unchanged. A future adapter validates geometric values.
|
||||
pub fn push(&mut self, layer: LayerId, geometry: G, weight: Option<EntityWeight>) -> Result<usize> {
|
||||
self.layer(layer)?;
|
||||
if self.entities.len() >= self.max_entities {
|
||||
return Err(invalid("DXF entity count limit exceeded"));
|
||||
}
|
||||
self.entities.try_reserve(1).map_err(|_| invalid("DXF entity allocation failed"))?;
|
||||
let index = self.entities.len();
|
||||
self.entities.push(Entity { layer, weight, geometry });
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
/// ByBlock and Default deliberately remain symbolic, rather than inventing
|
||||
/// a parent block or the consuming application's default numeric thickness.
|
||||
pub fn effective_weight(&self, index: usize) -> Result<EntityWeight> {
|
||||
let entity = self.entities.get(index).ok_or_else(|| invalid("unknown DXF entity index"))?;
|
||||
match entity.weight {
|
||||
None | Some(EntityWeight::ByLayer) => Ok(self.layer(entity.layer)?.weight.map_or(EntityWeight::Default, EntityWeight::Explicit)),
|
||||
Some(weight) => Ok(weight),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_linetype(&self, index: usize) -> Result<DxfLinetype> {
|
||||
let entity = self.entities.get(index).ok_or_else(|| invalid("unknown DXF entity index"))?;
|
||||
Ok(self.layer(entity.layer)?.linetype)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layers_own_ordered_geometry_without_inventing_a_weight() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let cut = file.declare_layer("CUT", 1, DxfLinetype::Continuous, None).unwrap();
|
||||
let hidden = file.declare_layer("HIDDEN", 8, DxfLinetype::Hidden, None).unwrap();
|
||||
file.push(cut, ((0, 0), (10, 5)), None).unwrap();
|
||||
file.push(hidden, ((3, 4), (5, 4)), None).unwrap();
|
||||
assert_eq!(file.layers().iter().map(Layer::name).collect::<Vec<_>>(), ["0", "CUT", "HIDDEN"]);
|
||||
assert_eq!(file.entities()[0].geometry(), &((0, 0), (10, 5)));
|
||||
assert_eq!(file.entities()[0].layer(), cut);
|
||||
assert_eq!(file.entities()[0].weight(), None);
|
||||
assert_eq!(file.effective_linetype(1).unwrap(), DxfLinetype::Hidden);
|
||||
assert_eq!(file.effective_weight(0).unwrap(), EntityWeight::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_from_another_file_never_select_its_same_numbered_layer() {
|
||||
let first = DxfFile::<()>::new().unwrap();
|
||||
let mut second = DxfFile::new().unwrap();
|
||||
assert!(second.push(first.default_layer(), (), None).is_err());
|
||||
assert!(second.entities().is_empty());
|
||||
second.push(second.default_layer(), (), None).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_cleaned_once_and_collisions_refuse_without_mutation() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
let id = file.declare_layer("CUT\nA", 3, DxfLinetype::Continuous, None).unwrap();
|
||||
assert_eq!(file.layer(id).unwrap().name(), "CUT_A");
|
||||
assert_eq!(file.declare_layer("CUT\nA", 3, DxfLinetype::Continuous, None).unwrap(), id);
|
||||
for name in ["CUT?A", "cut_a", "CUT_A"] {
|
||||
assert!(file.declare_layer(name, 3, DxfLinetype::Continuous, None).is_err());
|
||||
}
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
assert_eq!(file.declare_layer("", 7, DxfLinetype::Continuous, None).unwrap(), file.default_layer());
|
||||
assert!(file.declare_layer(&"x".repeat(256), 7, DxfLinetype::Continuous, None).is_err());
|
||||
assert!(file.declare_layer("bad color", 256, DxfLinetype::Continuous, None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_cannot_be_redeclared_with_different_style() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
file.declare_layer("ETCH", 4, DxfLinetype::Dashed, None).unwrap();
|
||||
assert!(file.declare_layer("ETCH", 4, DxfLinetype::Continuous, None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
assert_eq!(file.layers()[1].color(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_length_counts_scalars_with_a_bounded_utf8_allocation() {
|
||||
assert_eq!(name_of(&"漢".repeat(86)).unwrap().chars().count(), 86);
|
||||
assert_eq!(name_of(&"漢".repeat(255)).unwrap().chars().count(), 255);
|
||||
assert_eq!(name_of(&"😀".repeat(255)).unwrap().len(), 1020);
|
||||
assert!(name_of(&"漢".repeat(256)).is_err());
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
file.declare_layer(&"A".repeat(255), 7, DxfLinetype::Continuous, None).unwrap();
|
||||
assert!(file.declare_layer(&"A".repeat(256), 7, DxfLinetype::Continuous, None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_symbol_equivalence_is_refused_instead_of_assumed() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
for name in ["É", "é", "漢", "Aé", "E\u{301}"] {
|
||||
let error = file.declare_layer(name, 7, DxfLinetype::Continuous, None).unwrap_err();
|
||||
assert!(error.to_string().contains("not supported yet: non-ASCII"));
|
||||
}
|
||||
assert_eq!(file.layers().len(), 1);
|
||||
assert!(file.entities().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_a_different_layer_zero_style_remains_explicitly_pending() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
assert!(file.declare_layer("0", 3, DxfLinetype::Dashed, None).is_err());
|
||||
let zero = file.layer(file.default_layer()).unwrap();
|
||||
assert_eq!(zero.color(), 7);
|
||||
assert_eq!(zero.linetype(), DxfLinetype::Continuous);
|
||||
assert_eq!(zero.weight(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trimming_precedes_ascii_symbol_collision_detection() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
file.declare_layer("CUT", 7, DxfLinetype::Continuous, None).unwrap();
|
||||
for (name, expected) in [(" CUT ", "CUT"), ("\tCUT\r\n", "CUT"), ("\x0bCUT\x0c", "CUT"), (" cut ", "cut")] {
|
||||
assert_eq!(name_of(name).unwrap(), expected);
|
||||
assert!(file.declare_layer(name, 7, DxfLinetype::Continuous, None).is_err());
|
||||
}
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blanks_and_trimmed_layer_zero_use_the_existing_zero_handle() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
for name in ["", " \t\r\n\x0b\x0c ", " 0 ", "\t0\r\n"] {
|
||||
assert_eq!(file.declare_layer(name, 7, DxfLinetype::Continuous, None).unwrap(), file.default_layer());
|
||||
}
|
||||
assert_eq!(file.layers().len(), 1);
|
||||
assert!(file.declare_layer(" 0 ", 3, DxfLinetype::Continuous, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_255_character_limit_is_independent_of_raw_padding() {
|
||||
let mut file = DxfFile::<()>::new().unwrap();
|
||||
let name = "A".repeat(255);
|
||||
let padded = format!(" \t{name}\r\n ");
|
||||
let id = file.declare_layer(&padded, 7, DxfLinetype::Continuous, None).unwrap();
|
||||
assert_eq!(file.layer(id).unwrap().name(), name);
|
||||
assert!(name_of(&format!(" {} ", "A".repeat(256))).is_err());
|
||||
assert_eq!(name_of(&format!(" {} ", "漢".repeat(255))).unwrap().chars().count(), 255);
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_resource_limit_is_separate_from_normalized_format_length() {
|
||||
let boundary = format!("{}{}", " ".repeat(3841), "A".repeat(255));
|
||||
assert_eq!(boundary.len(), 4096);
|
||||
assert_eq!(name_of(&boundary).unwrap().len(), 255);
|
||||
assert!(name_of(&format!(" {boundary}")).unwrap_err().to_string().contains("resource byte limit"));
|
||||
assert!(name_of(&" ".repeat(4097)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_weights_keep_standard_values_and_nearest_boundaries() {
|
||||
let table = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
for code in table {
|
||||
let w = StandardWeight::from_code(code).unwrap();
|
||||
assert_eq!(w.code(), code);
|
||||
assert_eq!(StandardWeight::from_mm(f64::from(code) / 100.0).unwrap(), w);
|
||||
}
|
||||
// 0.375 is the EXACT binary midpoint between 0.35 and 0.40.
|
||||
assert_eq!(StandardWeight::from_mm(0.375_f64.next_down()).unwrap().code(), 35);
|
||||
assert_eq!(StandardWeight::from_mm(0.375).unwrap().code(), 40);
|
||||
assert_eq!(StandardWeight::from_mm(0.375_f64.next_up()).unwrap().code(), 40);
|
||||
// Stored 0.025 is slightly ABOVE the rational first midpoint.
|
||||
assert_eq!(StandardWeight::from_mm(0.025_f64.next_down()).unwrap().code(), 0);
|
||||
assert_eq!(StandardWeight::from_mm(0.025).unwrap().code(), 5);
|
||||
assert_eq!(StandardWeight::from_mm(0.01).unwrap().code(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_physical_weights_are_refused_not_clamped_or_symbols() {
|
||||
for value in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 2.11_f64.next_up()] {
|
||||
assert!(StandardWeight::from_mm(value).is_err());
|
||||
}
|
||||
assert!(StandardWeight::from_code(6).is_err());
|
||||
assert_eq!(StandardWeight::from_mm(-0.0).unwrap().code(), 0);
|
||||
assert_eq!(StandardWeight::from_mm(2.11).unwrap().code(), 211);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_and_symbolic_entity_weights_remain_distinct() {
|
||||
let mut file = DxfFile::new().unwrap();
|
||||
let fifty = StandardWeight::from_code(50).unwrap();
|
||||
let cut = file.declare_layer("CUT", 1, DxfLinetype::Continuous, Some(fifty)).unwrap();
|
||||
for weight in [
|
||||
None,
|
||||
Some(EntityWeight::ByLayer),
|
||||
Some(EntityWeight::ByBlock),
|
||||
Some(EntityWeight::Default),
|
||||
Some(EntityWeight::Explicit(StandardWeight::from_code(0).unwrap())),
|
||||
] {
|
||||
file.push(cut, (), weight).unwrap();
|
||||
}
|
||||
assert_eq!(file.entities()[0].weight(), None);
|
||||
assert_eq!(file.entities()[1].weight(), Some(EntityWeight::ByLayer));
|
||||
assert_eq!(file.effective_weight(0).unwrap(), EntityWeight::Explicit(fifty));
|
||||
assert_eq!(file.effective_weight(1).unwrap(), EntityWeight::Explicit(fifty));
|
||||
assert_eq!(file.effective_weight(2).unwrap(), EntityWeight::ByBlock);
|
||||
assert_eq!(file.effective_weight(3).unwrap(), EntityWeight::Default);
|
||||
assert_eq!(file.effective_weight(4).unwrap(), EntityWeight::Explicit(StandardWeight::from_code(0).unwrap()));
|
||||
assert_eq!(EntityWeight::ByLayer.group370(), -1);
|
||||
assert_eq!(EntityWeight::ByBlock.group370(), -2);
|
||||
assert_eq!(EntityWeight::Default.group370(), -3);
|
||||
assert_eq!(EntityWeight::Explicit(fifty).group370(), 50);
|
||||
assert!(file.effective_weight(5).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cumulative_limits_refuse_the_next_insert_without_a_partial_entity() {
|
||||
let mut file = DxfFile::with_limits(2, 1).unwrap();
|
||||
file.declare_layer("A", 7, DxfLinetype::Continuous, None).unwrap();
|
||||
assert!(file.declare_layer("B", 7, DxfLinetype::Continuous, None).is_err());
|
||||
file.push(file.default_layer(), (), None).unwrap();
|
||||
assert!(file.push(file.default_layer(), (), None).is_err());
|
||||
assert_eq!(file.layers().len(), 2);
|
||||
assert_eq!(file.entities().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ownership_counter_never_wraps_or_reuses_an_identity() {
|
||||
let counter = AtomicUsize::new(usize::MAX - 1);
|
||||
assert_eq!(owner_from(&counter).unwrap(), usize::MAX - 1);
|
||||
assert!(owner_from(&counter).is_err());
|
||||
assert!(owner_from(&counter).is_err());
|
||||
}
|
||||
|
||||
// Round trips exercise the real writer and layered reader, not group-code test parsers.
|
||||
use crate::DxfShape;
|
||||
use solidcraft_geom::Vec2;
|
||||
|
||||
fn read_written_file(file: &DxfFile<DxfShape>) -> DxfFile<DxfShape> {
|
||||
crate::read_dxf_file(&crate::write_dxf_file(file).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_reader_keep_mixed_layer_declarations_entity_order_and_file_scoped_handles() {
|
||||
let mut source = DxfFile::new().unwrap();
|
||||
let cut = source.declare_layer("CUT", 1, DxfLinetype::Continuous, Some(StandardWeight::from_code(50).unwrap())).unwrap();
|
||||
let hidden = source.declare_layer("HIDDEN", 8, DxfLinetype::Hidden, Some(StandardWeight::from_code(0).unwrap())).unwrap();
|
||||
source.declare_layer("UNUSED", 3, DxfLinetype::Dashed, None).unwrap();
|
||||
source.push(cut, DxfShape::Line(Vec2::new(-2.0, 1.5), Vec2::new(3.0, 1.5)), None).unwrap();
|
||||
source.push(source.default_layer(), DxfShape::Circle { c: Vec2::new(4.0, -1.0), r: 0.5 }, Some(EntityWeight::ByBlock)).unwrap();
|
||||
source.push(hidden, DxfShape::Arc { c: Vec2::new(-3.0, 2.0), r: 2.0, a0: 350.0, a1: 10.0 }, Some(EntityWeight::ByLayer)).unwrap();
|
||||
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 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);
|
||||
assert_eq!(recovered.entities()[0].geometry(), &DxfShape::Line(Vec2::new(-2.0, 1.5), Vec2::new(3.0, 1.5)));
|
||||
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)]
|
||||
);
|
||||
// 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());
|
||||
assert!(source.layer(recovered.default_layer()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_reader_keep_omission_symbols_and_all_physical_weights_in_each_linetype() {
|
||||
let mut weights = vec![None, Some(EntityWeight::ByLayer), Some(EntityWeight::ByBlock), Some(EntityWeight::Default)];
|
||||
// Independent explicit public code list: not the model's internal lookup table.
|
||||
for code in [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211] {
|
||||
weights.push(Some(EntityWeight::Explicit(StandardWeight::from_code(code).unwrap())));
|
||||
}
|
||||
let styles = [
|
||||
("MEASURED", 1, DxfLinetype::Continuous, Some(50)),
|
||||
("OMITTED", 3, DxfLinetype::Dashed, None),
|
||||
("ZERO_WEIGHT", 8, DxfLinetype::Hidden, Some(0)),
|
||||
];
|
||||
let mut source = DxfFile::new().unwrap();
|
||||
for (name, color, linetype, weight) in styles {
|
||||
let layer = source.declare_layer(name, color, linetype, weight.map(|code| StandardWeight::from_code(code).unwrap())).unwrap();
|
||||
for weight in &weights {
|
||||
source.push(layer, DxfShape::Line(Vec2::new(0.5, 0.75), Vec2::new(1.25, -0.5)), *weight).unwrap();
|
||||
}
|
||||
}
|
||||
let recovered = read_written_file(&source);
|
||||
assert_eq!(recovered.layers().len(), 4);
|
||||
assert_eq!(recovered.entities().len(), 84);
|
||||
for (style_index, (name, color, linetype, layer_weight)) in styles.iter().enumerate() {
|
||||
for (weight_index, expected_weight) in weights.iter().enumerate() {
|
||||
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!(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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_reader_keep_exact_supported_curves_on_all_three_layer_styles() {
|
||||
for (name, linetype) in [("SOLID", DxfLinetype::Continuous), ("DASH", DxfLinetype::Dashed), ("HIDE", DxfLinetype::Hidden)] {
|
||||
let mut source = DxfFile::new().unwrap();
|
||||
let layer = source.declare_layer(name, 5, linetype, None).unwrap();
|
||||
for shape in [
|
||||
DxfShape::Line(Vec2::new(-1.25, 2.5), Vec2::new(3.75, -4.0)),
|
||||
DxfShape::Circle { c: Vec2::new(8.0, -2.0), r: 1.25 },
|
||||
DxfShape::Arc { c: Vec2::new(2.0, 3.0), r: 0.75, a0: 45.0, a1: 135.0 },
|
||||
// Open straight chain, with an intentional repeated vertex retained.
|
||||
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)] },
|
||||
] {
|
||||
source.push(layer, shape, Some(EntityWeight::ByLayer)).unwrap();
|
||||
}
|
||||
let recovered = read_written_file(&source);
|
||||
assert_eq!(recovered.entities().len(), 4);
|
||||
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)] }
|
||||
);
|
||||
for entity in recovered.entities() {
|
||||
let layer = recovered.layer(entity.layer()).unwrap();
|
||||
assert_eq!((layer.name(), layer.linetype()), (name, linetype));
|
||||
assert_eq!(entity.weight(), Some(EntityWeight::ByLayer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writer_reader_keep_degree_nine_nonunit_knots_controls_and_optional_rational_weights() {
|
||||
for linetype in [DxfLinetype::Continuous, DxfLinetype::Dashed, DxfLinetype::Hidden] {
|
||||
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();
|
||||
}
|
||||
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!(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),
|
||||
1 => assert_eq!(weights.as_deref(), Some([1.0; 10].as_slice())),
|
||||
2 => assert_eq!(weights.as_deref(), Some([0.5, 1.0, 2.0, 4.0, 0.25, 0.5, 1.0, 2.0, 4.0, 1.0].as_slice())),
|
||||
_ => panic!("unexpected extra spline"),
|
||||
}
|
||||
let layer = recovered.layer(entity.layer()).unwrap();
|
||||
assert_eq!((layer.name(), layer.linetype(), layer.weight().map(StandardWeight::code)), ("CURVE", linetype, Some(35)));
|
||||
assert_eq!(entity.weight(), Some(EntityWeight::Explicit(StandardWeight::from_code(0).unwrap())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
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!(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));
|
||||
}
|
||||
|
||||
// DEPENDENT CASE ONLY: apply after W1's real DxfShape::Point(Vec2) and writer
|
||||
// support plus Kernel's real POINT read_dxf_file branch are integrated.
|
||||
// No fake variant/API, ignore attribute or active-source compile claim.
|
||||
#[test]
|
||||
fn writer_reader_keep_point_geometry_layer_and_stored_weight() {
|
||||
let mut source = DxfFile::new().unwrap();
|
||||
let points = source.declare_layer("DATUM", 4, DxfLinetype::Hidden, Some(StandardWeight::from_code(50).unwrap())).unwrap();
|
||||
source.push(points, DxfShape::Point(Vec2::new(-0.5, 1.25)), Some(EntityWeight::ByBlock)).unwrap();
|
||||
source.push(source.default_layer(), DxfShape::Point(Vec2::new(8.0, -2.0)), None).unwrap();
|
||||
let recovered = read_written_file(&source);
|
||||
assert_eq!(recovered.entities().len(), 2);
|
||||
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!(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);
|
||||
assert!(recovered.layer(points).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_zero_default_constructor_keeps_new_metadata_and_writer_bytes() {
|
||||
let mut original = DxfFile::new().unwrap();
|
||||
let mut imported = DxfFile::with_layer_zero(7, DxfLinetype::Continuous, None).unwrap();
|
||||
for file in [&original, &imported] {
|
||||
assert_eq!(file.layers().len(), 1);
|
||||
assert!(file.entities().is_empty());
|
||||
let zero = file.layer(file.default_layer()).unwrap();
|
||||
assert_eq!((zero.name(), zero.color(), zero.linetype(), zero.weight()), ("0", 7, DxfLinetype::Continuous, None));
|
||||
}
|
||||
assert_eq!(crate::write_dxf_file(&original).unwrap(), crate::write_dxf_file(&imported).unwrap());
|
||||
original.push(original.default_layer(), DxfShape::Line(Vec2::new(-1.0, 0.5), Vec2::new(2.0, 0.5)), None).unwrap();
|
||||
imported.push(imported.default_layer(), DxfShape::Line(Vec2::new(-1.0, 0.5), Vec2::new(2.0, 0.5)), None).unwrap();
|
||||
assert_eq!(original.effective_weight(0).unwrap(), EntityWeight::Default);
|
||||
assert_eq!(imported.effective_weight(0).unwrap(), EntityWeight::Default);
|
||||
assert_eq!(crate::write_dxf_file(&original).unwrap(), crate::write_dxf_file(&imported).unwrap());
|
||||
assert!(original.layer(imported.default_layer()).is_err());
|
||||
assert!(imported.layer(original.default_layer()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_layer_zero_is_first_and_immutable_through_every_zero_alias() {
|
||||
let zero_weight = Some(StandardWeight::from_code(0).unwrap());
|
||||
let mut file = DxfFile::with_layer_zero(3, DxfLinetype::Hidden, zero_weight).unwrap();
|
||||
let zero = file.default_layer();
|
||||
assert!(file.entities().is_empty());
|
||||
let cut = file.declare_layer("CUT", 1, DxfLinetype::Dashed, None).unwrap();
|
||||
file.push(zero, (), None).unwrap();
|
||||
for alias in ["0", " 0 ", "\t0\r\n", "", " \t\r\n\x0b\x0c "] {
|
||||
assert_eq!(file.declare_layer(alias, 3, DxfLinetype::Hidden, zero_weight).unwrap(), zero);
|
||||
assert!(file.declare_layer(alias, 7, DxfLinetype::Hidden, zero_weight).is_err());
|
||||
assert!(file.declare_layer(alias, 3, DxfLinetype::Continuous, zero_weight).is_err());
|
||||
assert!(file.declare_layer(alias, 3, DxfLinetype::Hidden, None).is_err());
|
||||
}
|
||||
assert_eq!(file.layers().iter().map(Layer::name).collect::<Vec<_>>(), ["0", "CUT"]);
|
||||
let layer = file.layer(zero).unwrap();
|
||||
assert_eq!((layer.color(), layer.linetype(), layer.weight().map(StandardWeight::code)), (3, DxfLinetype::Hidden, Some(0)));
|
||||
assert_eq!(file.layer(cut).unwrap().name(), "CUT");
|
||||
assert_eq!(file.entities().len(), 1);
|
||||
assert_eq!(file.entities()[0].layer(), zero);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_zero_inheritance_keeps_omission_symbols_and_physical_zero_distinct() {
|
||||
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())),
|
||||
] {
|
||||
file.push(file.default_layer(), (), weight).unwrap();
|
||||
}
|
||||
let inherited = match code {
|
||||
None => EntityWeight::Default,
|
||||
Some(0) => EntityWeight::Explicit(StandardWeight::from_code(0).unwrap()),
|
||||
Some(50) => EntityWeight::Explicit(StandardWeight::from_code(50).unwrap()),
|
||||
_ => panic!("unexpected fixture weight"),
|
||||
};
|
||||
assert_eq!(file.layer(file.default_layer()).unwrap().weight().map(StandardWeight::code), code);
|
||||
assert_eq!(file.entities()[0].weight(), None);
|
||||
assert_eq!(file.entities()[1].weight(), Some(EntityWeight::ByLayer));
|
||||
assert_eq!(file.effective_weight(0).unwrap(), inherited);
|
||||
assert_eq!(file.effective_weight(1).unwrap(), inherited);
|
||||
assert_eq!(file.effective_weight(2).unwrap(), EntityWeight::ByBlock);
|
||||
assert_eq!(file.effective_weight(3).unwrap(), EntityWeight::Default);
|
||||
assert_eq!(file.effective_weight(4).unwrap(), EntityWeight::Explicit(StandardWeight::from_code(13).unwrap()));
|
||||
assert_eq!(file.effective_linetype(0).unwrap(), DxfLinetype::Dashed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_layer_zero_uses_validated_colors_and_standard_weight_types() {
|
||||
for color in [i16::MIN, -1, 0, 256, i16::MAX] {
|
||||
let error = DxfFile::<()>::with_layer_zero(color, DxfLinetype::Continuous, None).unwrap_err();
|
||||
assert!(error.to_string().contains("color must be an index from 1 to 255"));
|
||||
}
|
||||
for color in [1, 255] {
|
||||
let file = DxfFile::<()>::with_layer_zero(color, DxfLinetype::Hidden, Some(StandardWeight::from_code(211).unwrap())).unwrap();
|
||||
let zero = file.layer(file.default_layer()).unwrap();
|
||||
assert_eq!((zero.color(), zero.linetype(), zero.weight().map(StandardWeight::code)), (color, DxfLinetype::Hidden, Some(211)));
|
||||
}
|
||||
assert!(StandardWeight::from_code(6).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_zero_handles_are_fresh_and_foreign_insertion_is_atomic() {
|
||||
let first = DxfFile::<()>::with_layer_zero(1, DxfLinetype::Dashed, None).unwrap();
|
||||
let mut second = DxfFile::with_layer_zero(1, DxfLinetype::Dashed, None).unwrap();
|
||||
let mut ordinary = DxfFile::new().unwrap();
|
||||
assert!(second.push(first.default_layer(), (), None).is_err());
|
||||
assert!(ordinary.push(second.default_layer(), (), None).is_err());
|
||||
assert!(second.push(ordinary.default_layer(), (), None).is_err());
|
||||
assert!(second.entities().is_empty());
|
||||
assert!(ordinary.entities().is_empty());
|
||||
second.push(second.default_layer(), (), None).unwrap();
|
||||
assert_eq!(second.entities().len(), 1);
|
||||
assert_eq!(second.layers().len(), 1);
|
||||
assert_eq!(second.layer(second.entities()[0].layer()).unwrap().name(), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_zero_constructor_keeps_fallible_resource_limits() {
|
||||
assert!(DxfFile::<()>::with_layer_zero_limits(1, DxfLinetype::Dashed, None, 0, 1).is_err());
|
||||
let mut empty = DxfFile::with_layer_zero_limits(1, DxfLinetype::Dashed, None, 1, 0).unwrap();
|
||||
assert!(empty.push(empty.default_layer(), (), None).is_err());
|
||||
assert!(empty.entities().is_empty());
|
||||
let mut file = DxfFile::with_layer_zero_limits(2, DxfLinetype::Hidden, None, 2, 1).unwrap();
|
||||
file.declare_layer("CUT", 1, DxfLinetype::Continuous, None).unwrap();
|
||||
assert!(file.declare_layer("EXCESS", 1, DxfLinetype::Continuous, None).is_err());
|
||||
file.push(file.default_layer(), (), None).unwrap();
|
||||
assert!(file.push(file.default_layer(), (), None).is_err());
|
||||
assert_eq!((file.layers().len(), file.entities().len()), (2, 1));
|
||||
let bounded = DxfFile::<()>::with_layer_zero_limits(7, DxfLinetype::Continuous, None, usize::MAX, usize::MAX).unwrap();
|
||||
assert_eq!((bounded.max_layers, bounded.max_entities), (1024, 1_000_000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! 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 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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 })
|
||||
}
|
||||
@@ -32,8 +32,12 @@ mod threemf;
|
||||
pub mod urdf;
|
||||
pub mod vfs;
|
||||
|
||||
pub use dxf::{read_dxf, write_dxf};
|
||||
pub use dxf_out::{DxfEntity, DxfLayer, DxfLinetype, DxfShape, loop_shapes, seg_shape, write_dxf_drawing};
|
||||
pub use dxf::{read_dxf, read_dxf_file, write_dxf};
|
||||
pub use dxf_out::{
|
||||
DxfEntity, DxfEntityWeight, DxfFile, DxfFileEntity, DxfFileLayer, DxfGeometryBounds, DxfLayer, DxfLayerId, DxfLinetype, DxfShape,
|
||||
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};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
pub use sketch2d::{Geom2, MAX_DRAWING_BYTES};
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
Reference in New Issue
Block a user