diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index c2f63a5..7941a68 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -6,6 +6,8 @@ use crate::{DashElement, Drawing, Linetype}; +pub mod files; + /// The standard linetype library, defined in inches. Load it into a drawing with /// [`standard_linetypes_for`], which scales it for metric drawings. pub fn standard_linetypes() -> Vec { diff --git a/crates/doc/src/library/files.rs b/crates/doc/src/library/files.rs new file mode 100644 index 0000000..5e555af --- /dev/null +++ b/crates/doc/src/library/files.rs @@ -0,0 +1,341 @@ +//! Readers for the user's own linetype (`.lin`) and hatch pattern (`.pat`) files. +//! +//! Both follow the publicly documented text formats: a header line `*NAME[,description]`, then +//! the definition (`A,dash,gap,…` for a linetype, `angle, x, y, dx, dy[, dash, …]` lines for +//! a pattern), with `;` starting a comment line. CADCraft ships no such file; these read the +//! files users bring. Everything here treats the text as hostile: lines, definitions, dashes, +//! families and warnings are capped, numbers must be finite and reasonable, and nothing panics. +//! Problems become warnings and the definitions that can be read are kept. + +use super::PatternLine; +use crate::{DashElement, Linetype}; + +/// Longest line read; longer lines are skipped with a warning. +pub const MAX_LINE_CHARS: usize = 4096; +/// Most definitions (linetypes or patterns) read from one file. +pub const MAX_DEFINITIONS: usize = 4096; +/// Most elements in one linetype. +pub const MAX_LINETYPE_ELEMENTS: usize = 256; +/// Most line families in one hatch pattern (what the renderer draws, too). +pub const MAX_PATTERN_FAMILIES: usize = 1024; +/// Most dashes in one pattern line family (as for a pattern definition read from DXF). +pub const MAX_PATTERN_DASHES: usize = 256; +/// Largest magnitude accepted for a length, offset or scale. +pub const MAX_VALUE: f64 = 1.0e6; +/// Longest name or description kept. +const MAX_NAME_CHARS: usize = 255; +/// Most warnings collected; the rest are counted in a last line. +const MAX_WARNINGS: usize = 100; + +/// A hatch pattern read from a user's `.pat` file. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct UserPattern { + pub name: String, + pub description: String, + pub lines: Vec, +} + +/// What a file held: the definitions that could be read, and why anything else was skipped. +#[derive(Clone, Debug, PartialEq)] +pub struct Parsed { + pub items: Vec, + pub warnings: Vec, +} + +impl Default for Parsed { + fn default() -> Self { + Parsed { items: Vec::new(), warnings: Vec::new() } + } +} + +impl Parsed { + fn warn(&mut self, line: usize, msg: impl Into) { + match self.warnings.len() { + n if n < MAX_WARNINGS => self.warnings.push(format!("line {line}: {}", msg.into())), + MAX_WARNINGS => self.warnings.push("more problems not listed".into()), + _ => {} + } + } +} + +/// A header line `*NAME[,description]`: the name and description, or why it can't be used. +fn header(rest: &str) -> std::result::Result<(String, String), String> { + let (name, desc) = rest.split_once(',').unwrap_or((rest, "")); + let name = name.trim(); + if name.is_empty() { + return Err("a definition without a name".into()); + } + if name.chars().count() > MAX_NAME_CHARS || name.chars().any(char::is_control) { + return Err("a definition name that is too long or has control characters".into()); + } + let desc: String = desc.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME_CHARS).collect(); + Ok((name.to_string(), desc)) +} + +/// A finite number within ±[`MAX_VALUE`]. +fn number(t: &str) -> Option { + t.trim().parse::().ok().filter(|v| v.is_finite() && v.abs() <= MAX_VALUE) +} + +/// The file's text lines with 1-based numbers, without a leading byte-order mark. +fn lines(text: &str) -> impl Iterator { + text.trim_start_matches('\u{feff}').lines().enumerate().map(|(i, l)| (i.saturating_add(1), l.trim())) +} + +// ---------- LIN ---------- + +/// Read the linetypes of a `.lin` file. Simple linetypes and complex ones with embedded text +/// (`["TEXT",STYLE,S=…,R=…,A=…,U=…,X=…,Y=…]`) are read; shape elements (`[SHAPE,file.shx,…]`) +/// need SHX shape fonts, so they are left out with a warning and the dashes around them kept. +/// Lengths are in drawing units as written: a user's file is never rescaled. +pub fn parse_lin(text: &str) -> Parsed { + let mut out = Parsed::default(); + // The definition being read: name, description, header line. + let mut pending: Option<(String, String, usize)> = None; + for (no, line) in lines(text) { + if line.is_empty() || line.starts_with(';') { + continue; + } + if line.chars().count() > MAX_LINE_CHARS { + out.warn(no, "line too long; skipped"); + continue; + } + if let Some(rest) = line.strip_prefix('*') { + if let Some((name, _, at)) = pending.take() { + out.warn(at, format!("linetype {name} has no pattern line; skipped")); + } + match header(rest) { + Ok((name, desc)) => pending = Some((name, desc, no)), + Err(e) => out.warn(no, format!("{e}; skipped")), + } + continue; + } + let Some((name, description, _)) = pending.take() else { + out.warn(no, "pattern line without a *NAME line before it; skipped"); + continue; + }; + if out.items.len() >= MAX_DEFINITIONS { + out.warn(no, format!("more than {MAX_DEFINITIONS} linetypes; the rest are skipped")); + break; + } + if out.items.iter().any(|l: &Linetype| l.name.eq_ignore_ascii_case(&name)) { + out.warn(no, format!("linetype {name} is defined twice; the first definition is kept")); + continue; + } + match lin_pattern(line, no, &name, &mut out) { + Ok(pattern) => out.items.push(Linetype { name, description, pattern }), + Err(e) => out.warn(no, format!("linetype {name}: {e}; skipped")), + } + } + if let Some((name, _, at)) = pending { + out.warn(at, format!("linetype {name} has no pattern line; skipped")); + } + out +} + +/// Split a pattern line at commas outside `[…]` and `"…"`. +fn lin_fields(line: &str) -> std::result::Result, String> { + let mut out = Vec::new(); + let (mut depth, mut quoted, mut start) = (0usize, false, 0usize); + for (i, c) in line.char_indices() { + match c { + '"' => quoted = !quoted, + '[' if !quoted => depth = depth.saturating_add(1), + ']' if !quoted => depth = depth.checked_sub(1).ok_or("a ] without its [")?, + ',' if !quoted && depth == 0 => { + out.push(line.get(start..i).unwrap_or("").trim()); + start = i.saturating_add(1); + } + _ => {} + } + if out.len() > MAX_LINETYPE_ELEMENTS.saturating_mul(2) { + return Err(format!("more than {MAX_LINETYPE_ELEMENTS} elements")); + } + } + if quoted || depth != 0 { + return Err("an unclosed [ or \"".into()); + } + out.push(line.get(start..).unwrap_or("").trim()); + Ok(out) +} + +/// The elements of a pattern line `A,dash,gap,[…],…`. +fn lin_pattern(line: &str, no: usize, name: &str, out: &mut Parsed) -> std::result::Result, String> { + let mut fields = lin_fields(line)?; + // The alignment field ("A") comes first; a line of plain numbers is read as well. + if fields.first().is_some_and(|f| f.eq_ignore_ascii_case("a")) { + fields.remove(0); + } else if fields.first().is_some_and(|f| number(f).is_none() && !f.starts_with('[')) { + return Err(format!("unknown alignment `{}` (expected A)", fields.first().copied().unwrap_or(""))); + } + let mut pattern: Vec = Vec::new(); + for f in fields.into_iter().filter(|f| !f.is_empty()) { + if pattern.len() >= MAX_LINETYPE_ELEMENTS { + return Err(format!("more than {MAX_LINETYPE_ELEMENTS} elements")); + } + let Some(inner) = f.strip_prefix('[') else { + let len = number(f).ok_or_else(|| format!("`{f}` is not a dash length"))?; + pattern.push(DashElement::dash(len)); + continue; + }; + let inner = inner.strip_suffix(']').ok_or_else(|| format!("`{f}` is not a complete [ ] element"))?; + match lin_text(inner)? { + Some(text) => { + // The text sits at the end of the element before it (a zero-length one if none). + if pattern.last().is_none_or(|e| e.text.is_some()) { + pattern.push(DashElement::dash(0.0)); + } + if let Some(last) = pattern.last_mut() { + *last = DashElement { length: last.length, ..text }; + } + } + None => { + let shape = inner.split(',').next().unwrap_or("").trim(); + out.warn(no, format!("linetype {name}: shape {shape} needs an SHX shape file, which CADCraft can't read; left out")); + } + } + } + if pattern.is_empty() { + return Err("no dashes".into()); + } + if pattern.iter().all(|e| e.length == 0.0) && pattern.iter().all(|e| e.text.is_none()) { + return Err("every element has zero length".into()); + } + Ok(pattern) +} + +/// A `"TEXT",STYLE,key=value…` element (the inside of `[…]`), or `None` for a shape element. +fn lin_text(inner: &str) -> std::result::Result, String> { + let inner = inner.trim(); + let Some(after) = inner.strip_prefix('"') else { return Ok(None) }; + let (text, rest) = after.split_once('"').ok_or("unclosed text quote")?; + if text.chars().count() > MAX_NAME_CHARS || text.chars().any(char::is_control) { + return Err("embedded text that is too long or has control characters".into()); + } + let mut parts = rest.split(',').map(str::trim); + if !parts.next().unwrap_or("").is_empty() { + return Err("text element: expected a comma after the text".into()); + } + let style = parts.next().unwrap_or("").trim(); + if style.is_empty() || style.contains('=') { + return Err("text element: the text style is missing".into()); + } + let mut e = DashElement { text: Some(text.to_string()), style: Some(style.to_string()), ..DashElement::dash(0.0) }; + for kv in parts.filter(|p| !p.is_empty()) { + let (k, v) = kv.split_once('=').ok_or_else(|| format!("text element: `{kv}` is not key=value"))?; + match k.trim().to_ascii_uppercase().as_str() { + "S" => e.scale = number(v).filter(|s| *s > 0.0).ok_or_else(|| format!("text element: bad scale `{v}`"))?, + "X" => e.offset.x = number(v).ok_or_else(|| format!("text element: bad X offset `{v}`"))?, + "Y" => e.offset.y = number(v).ok_or_else(|| format!("text element: bad Y offset `{v}`"))?, + // Upright text keeps a rotation relative to the line, like R. + k @ ("R" | "A" | "U") => { + e.rotation = angle(v).ok_or_else(|| format!("text element: bad rotation `{v}`"))?; + e.absolute = k == "A"; + } + other => return Err(format!("text element: unknown option `{other}`")), + } + } + Ok(Some(e)) +} + +/// An angle in degrees, or with a `d` (degrees), `r` (radians) or `g` (grads) suffix; radians out. +fn angle(t: &str) -> Option { + let t = t.trim().to_ascii_lowercase(); + let (num, unit) = match t.char_indices().last() { + Some((i, c @ ('d' | 'r' | 'g'))) => (t.get(..i)?, c), + _ => (t.as_str(), 'd'), + }; + let v = num.trim().parse::().ok().filter(|v| v.is_finite() && v.abs() <= 1.0e5)?; + Some(match unit { + 'r' => v, + 'g' => v * std::f64::consts::PI / 200.0, + _ => v.to_radians(), + }) +} + +// ---------- PAT ---------- + +/// Read the hatch patterns of a `.pat` file. Values are used as written (an inch file for inch +/// drawings, a millimetre file for metric ones). +pub fn parse_pat(text: &str) -> Parsed { + let mut out: Parsed = Parsed::default(); + // The pattern being read, its header line, and whether it already failed. + let mut cur: Option<(UserPattern, usize, bool)> = None; + let finish = |cur: Option<(UserPattern, usize, bool)>, out: &mut Parsed| { + let Some((p, at, failed)) = cur else { return }; + if failed { + return; + } + if p.lines.is_empty() { + out.warn(at, format!("pattern {} has no line families; skipped", p.name)); + } else if out.items.len() >= MAX_DEFINITIONS { + out.warn(at, format!("more than {MAX_DEFINITIONS} patterns; {} skipped", p.name)); + } else if out.items.iter().any(|q| q.name.eq_ignore_ascii_case(&p.name)) { + out.warn(at, format!("pattern {} is defined twice; the first definition is kept", p.name)); + } else { + out.items.push(p); + } + }; + for (no, line) in lines(text) { + // A comment runs to the end of the line. + let line = line.split(';').next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + if line.chars().count() > MAX_LINE_CHARS { + out.warn(no, "line too long; skipped"); + continue; + } + if let Some(rest) = line.strip_prefix('*') { + finish(cur.take(), &mut out); + match header(rest) { + Ok((name, description)) => cur = Some((UserPattern { name: name.to_ascii_uppercase(), description, lines: Vec::new() }, no, false)), + Err(e) => out.warn(no, format!("{e}; skipped")), + } + continue; + } + let Some((p, _, failed)) = cur.as_mut() else { + out.warn(no, "line family without a *NAME line before it; skipped"); + continue; + }; + if *failed { + continue; + } + if p.lines.len() >= MAX_PATTERN_FAMILIES { + out.warn(no, format!("pattern {}: more than {MAX_PATTERN_FAMILIES} line families; skipped", p.name)); + *failed = true; + continue; + } + match pat_family(line) { + Ok(l) => p.lines.push(l), + Err(e) => { + out.warn(no, format!("pattern {}: {e}; skipped", p.name)); + *failed = true; + } + } + } + finish(cur, &mut out); + out +} + +/// One line family: `angle, x-origin, y-origin, delta-x, delta-y[, dash-1, dash-2, …]`. +fn pat_family(line: &str) -> std::result::Result { + let mut v = Vec::new(); + for f in line.split(',') { + if v.len() >= MAX_PATTERN_DASHES.saturating_add(5) { + return Err(format!("more than {MAX_PATTERN_DASHES} dashes in a line family")); + } + let f = f.trim(); + if f.is_empty() && v.len() >= 5 { + continue; + } + v.push(number(f).ok_or_else(|| format!("`{f}` is not a number"))?); + } + let (Some(&angle), Some(&x), Some(&y), Some(&dx), Some(&dy)) = (v.first(), v.get(1), v.get(2), v.get(3), v.get(4)) else { + return Err("a line family needs angle, x, y, delta-x and delta-y".into()); + }; + if dy.abs() < 1e-9 { + return Err("a line family with zero spacing (delta-y)".into()); + } + Ok(PatternLine { angle, origin: (x, y), delta: (dx, dy), dashes: v.get(5..).unwrap_or(&[]).to_vec() }) +} diff --git a/crates/doc/tests/lin_pat_parse.rs b/crates/doc/tests/lin_pat_parse.rs new file mode 100644 index 0000000..e0d5619 --- /dev/null +++ b/crates/doc/tests/lin_pat_parse.rs @@ -0,0 +1,181 @@ +//! Reading the user's own `.lin` and `.pat` files (#552). Every file here is a tiny synthetic one +//! written for these tests, with our own names. + +use cadcraft_doc::library::files::{MAX_LINETYPE_ELEMENTS, MAX_PATTERN_DASHES, MAX_PATTERN_FAMILIES, parse_lin, parse_pat}; + +const LIN: &str = "\ +;; my own linetypes +*MYDASH,My dash __ __ __ +A,.5,-.25 + +*MYDOTS +A,0,-.1,0,-.2 +*MYTEXT,Text line --MY--MY-- +A,.5,-.2,[\"MY\",STANDARD,S=.1,R=0.0,X=-0.1,Y=-.05],-.2 +*MYSHAPE,Shape line +A,.25,-.1,[MYBOX,myshapes.shx,S=.1],-.1,1 +"; + +#[test] +fn simple_and_text_linetypes_are_read() { + let r = parse_lin(LIN); + let names: Vec<&str> = r.items.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["MYDASH", "MYDOTS", "MYTEXT", "MYSHAPE"]); + let dash = &r.items[0]; + assert_eq!(dash.description, "My dash __ __ __"); + assert_eq!(dash.pattern.iter().map(|e| e.length).collect::>(), [0.5, -0.25]); + assert_eq!(r.items[1].description, ""); + assert_eq!(r.items[1].pattern.len(), 4); + + // The text belongs to the gap before it: drawn at that gap's end. + let text = &r.items[2].pattern; + assert_eq!(text.len(), 3); + assert_eq!(text[1].length, -0.2); + assert_eq!(text[1].text.as_deref(), Some("MY")); + assert_eq!(text[1].style.as_deref(), Some("STANDARD")); + assert!((text[1].scale - 0.1).abs() < 1e-12); + assert!((text[1].offset.x + 0.1).abs() < 1e-12 && (text[1].offset.y + 0.05).abs() < 1e-12); + assert!(!text[1].absolute); + assert!(text[2].text.is_none()); + + // Shapes need SHX files: left out with a note, the dashes kept. + let shape = &r.items[3].pattern; + assert_eq!(shape.iter().map(|e| e.length).collect::>(), [0.25, -0.1, -0.1, 1.0]); + assert!(shape.iter().all(|e| e.text.is_none() && e.shape.is_none())); + assert!(r.warnings.iter().any(|w| w.contains("MYBOX")), "{:?}", r.warnings); +} + +#[test] +fn text_rotation_units_and_absolute_angles() { + let r = parse_lin("*R1\nA,1,[\"x\",S1,A=90,S=1],-1\n*R2\nA,1,[\"x\",S1,R=1.5r],-1\n*R3\nA,1,[\"a,b]\",S1,U=100g],-1\n"); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let rot = |i: usize| r.items[i].pattern[0].rotation; + assert!((rot(0) - std::f64::consts::FRAC_PI_2).abs() < 1e-12 && r.items[0].pattern[0].absolute); + assert!((rot(1) - 1.5).abs() < 1e-12); + assert!((rot(2) - std::f64::consts::FRAC_PI_2).abs() < 1e-12); + // Commas and brackets inside the quoted text are text. + assert_eq!(r.items[2].pattern[0].text.as_deref(), Some("a,b]")); +} + +#[test] +fn broken_linetypes_are_skipped_with_warnings() { + let text = "\ +*NOPATTERN +*BADNUM +A,.5,abc +*BADALIGN +B,.5,-.5 +*UNCLOSED +A,.5,[\"x\",S1 +* +A,1,-1 +A,1,-1 +*GOOD +A,1,-1 +*GOOD +A,2,-2 +*ZERO +A,0,0 +*NOSTYLE +A,1,[\"x\"],-1 +"; + let r = parse_lin(text); + assert_eq!(r.items.iter().map(|l| l.name.as_str()).collect::>(), ["GOOD"]); + assert_eq!(r.items[0].pattern[0].length, 1.0, "the first definition is kept"); + for what in ["NOPATTERN", "BADNUM", "BADALIGN", "UNCLOSED", "without a name", "without a *NAME", "defined twice", "ZERO", "NOSTYLE"] { + assert!(r.warnings.iter().any(|w| w.contains(what)), "no warning about {what}: {:?}", r.warnings); + } +} + +#[test] +fn hostile_linetype_files_never_panic_and_stay_capped() { + let long_line = format!("*LONG\nA,{}", vec!["1"; 5000].join(",")); + let many = format!("*MANY\nA,{}", vec!["1,-1"; MAX_LINETYPE_ELEMENTS].join(",")); + let defs: String = (0..5000).map(|i| format!("*L{i}\nA,1,-1\n")).collect(); + let huge = "*HUGE\nA,1e300,-1\n*NAN\nA,NaN,-1\n*INF\nA,inf,-1\n"; + for text in [long_line.as_str(), many.as_str(), defs.as_str(), huge, "", "\u{feff}", "]]][[[\"", "*\u{0}\nA,1", "A,1,-1\n*X,", "*X\nA,[[[[\"]]]"] + { + let r = parse_lin(text); + assert!(r.items.len() <= 4096); + assert!(r.items.iter().all(|l| l.pattern.len() <= MAX_LINETYPE_ELEMENTS && l.pattern.iter().all(|e| e.length.is_finite()))); + assert!(r.warnings.len() <= 101); + } + assert!(parse_lin(huge).items.is_empty()); + assert_eq!(parse_lin(&defs).items.len(), 4096); +} + +const PAT: &str = "\ +; my own patterns +*MYGRID, My grid +0, 0,0, 0,.5 +90, 0,0, 0,.5 +*MYBRICK,My brick ; trailing comment +0, 0,0, 0,.25 +90, 0,0, .25,.25, .25,-.25 +"; + +#[test] +fn hatch_patterns_are_read() { + let r = parse_pat(PAT); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(r.items.len(), 2); + let grid = &r.items[0]; + assert_eq!((grid.name.as_str(), grid.description.as_str()), ("MYGRID", "My grid")); + assert_eq!(grid.lines.len(), 2); + assert_eq!(grid.lines[1].angle, 90.0); + assert_eq!(grid.lines[1].delta, (0.0, 0.5)); + let brick = &r.items[1]; + assert_eq!(brick.description, "My brick"); + assert_eq!(brick.lines[1].origin, (0.0, 0.0)); + assert_eq!(brick.lines[1].dashes, [0.25, -0.25]); +} + +#[test] +fn lowercase_pattern_names_are_upper_cased() { + let r = parse_pat("*mypat\n45,0,0,0,.125\n"); + assert_eq!(r.items[0].name, "MYPAT"); +} + +#[test] +fn broken_patterns_are_skipped_with_warnings() { + let text = "\ +0,0,0,0,1 +*EMPTY +*SHORT +0,0,0 +*ZEROGAP +0,0,0,1,0 +*BADNUM +0,0,0,0,x +*OK +0,0,0,0,1 +*OK +45,0,0,0,1 +"; + let r = parse_pat(text); + assert_eq!(r.items.iter().map(|p| p.name.as_str()).collect::>(), ["OK"]); + assert_eq!(r.items[0].lines[0].angle, 0.0, "the first definition is kept"); + for what in ["without a *NAME", "EMPTY", "SHORT", "ZEROGAP", "BADNUM", "defined twice"] { + assert!(r.warnings.iter().any(|w| w.contains(what)), "no warning about {what}: {:?}", r.warnings); + } +} + +#[test] +fn hostile_pattern_files_never_panic_and_stay_capped() { + let families = format!("*FAM\n{}", "0,0,0,0,1\n".repeat(MAX_PATTERN_FAMILIES + 5)); + let dashes = format!("*DASH\n0,0,0,0,1,{}\n", vec!["1"; MAX_PATTERN_DASHES + 1].join(",")); + let ok_dashes = format!("*DASHOK\n0,0,0,0,1,{}\n", vec!["1"; MAX_PATTERN_DASHES].join(",")); + let defs: String = (0..5000).map(|i| format!("*P{i}\n0,0,0,0,1\n")).collect(); + let big = "*BIG\n1e300,0,0,0,1\n*NAN\nNaN,0,0,0,1\n"; + for text in [families.as_str(), dashes.as_str(), ok_dashes.as_str(), defs.as_str(), big, "", "*", "***\n,,,,,", "*X\n,,,,,,,,,,"] { + let r = parse_pat(text); + assert!(r.items.len() <= 4096); + assert!(r.items.iter().all(|p| p.lines.len() <= MAX_PATTERN_FAMILIES && p.lines.iter().all(|l| l.dashes.len() <= MAX_PATTERN_DASHES))); + assert!(r.warnings.len() <= 101); + } + assert!(parse_pat(&families).items.is_empty()); + assert!(parse_pat(&dashes).items.is_empty()); + assert_eq!(parse_pat(&ok_dashes).items.len(), 1); + assert!(parse_pat(big).items.is_empty()); + assert_eq!(parse_pat(&defs).items.len(), 4096); +} diff --git a/crates/engine/src/cmd/hatch.rs b/crates/engine/src/cmd/hatch.rs index eca726e..ab08b81 100644 --- a/crates/engine/src/cmd/hatch.rs +++ b/crates/engine/src/cmd/hatch.rs @@ -1,5 +1,8 @@ //! HATCH, GRADIENT, BOUNDARY, REGION: boundaries from pick points or selected closed objects. +mod patfile; +pub(super) use patfile::{lines_for, run_patterns}; + use cadcraft_color::Color; use cadcraft_doc::{Drawing, Entity, EntityKind, Gradient, Handle, Hatch, HatchLoop, Space, entity_bounds}; use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Vec2, point_in_polygon}; @@ -314,7 +317,7 @@ impl HatchSettings { elevation: 0.0, gradient: self.gradient.clone(), background: None, - pattern_lines: Vec::new(), + pattern_lines: patfile::lines_for(&self.pattern), }) } } @@ -369,6 +372,7 @@ fn add_hatches(s: &mut Session, st: &HatchSettings, groups: Vec>) } fn run_hatch(s: &mut Session, p: &Value) -> Result { + patfile::load_param(p)?; make(s, p, false) } fn run_gradient(s: &mut Session, p: &Value) -> Result { @@ -467,15 +471,16 @@ impl HatchM { fn answer(&mut self, s: &mut Session, a: HatchAsk, t: &str) -> Result<()> { match a { HatchAsk::Pattern => { - let up = t.trim().to_ascii_uppercase(); - if cadcraft_doc::library::pattern(&up).is_some() { + let up = match patfile::typed_file(s, t.trim()) { + Some(n) if n.is_empty() => return Ok(()), + Some(n) => n, + None => t.trim().to_ascii_uppercase(), + }; + if cadcraft_doc::library::pattern(&up).is_some() || patfile::user_pattern(&up).is_some() { self.settings.pattern = up.clone(); s.doc_mut()?.header.set_str("HPNAME", &up); } else { - s.echo(format!( - "Unknown pattern {up}. Available: {}", - cadcraft_doc::library::standard_patterns().iter().map(|p| p.name).collect::>().join(", ") - )); + s.echo(format!("Unknown pattern {up}. Available: {}", patfile::names().join(", "))); } } HatchAsk::Scale => { diff --git a/crates/engine/src/cmd/hatch/patfile.rs b/crates/engine/src/cmd/hatch/patfile.rs new file mode 100644 index 0000000..a1c8168 --- /dev/null +++ b/crates/engine/src/cmd/hatch/patfile.rs @@ -0,0 +1,125 @@ +//! Hatch patterns from the user's own `.pat` files. +//! +//! Loaded patterns join the pattern list for the rest of the session, for every drawing (as a +//! pattern file on the support path is available to all of them). A hatch made with one keeps +//! the pattern's line families (`Hatch::pattern_lines`), so the drawing shows and saves it +//! without the file: DXF writes the definition with the hatch, and reading it back restores it. +//! A file can't replace a built-in pattern (its name keeps meaning the library's definition). + +use std::sync::{Mutex, MutexGuard, OnceLock, PoisonError}; + +use cadcraft_doc::library::files::{UserPattern, parse_pat}; +use cadcraft_doc::library::{PatternLine, pattern, standard_patterns}; +use serde_json::{Value, json}; + +use super::super::helpers::support_text; +use super::*; + +/// Most user patterns held at once. +pub const MAX_USER_PATTERNS: usize = 4096; + +fn store() -> MutexGuard<'static, Vec> { + static USER: OnceLock>> = OnceLock::new(); + USER.get_or_init(|| Mutex::new(Vec::new())).lock().unwrap_or_else(PoisonError::into_inner) +} + +/// The user pattern called `name` (case-insensitive), if a file loaded one. +pub(crate) fn user_pattern(name: &str) -> Option { + store().iter().find(|p| p.name.eq_ignore_ascii_case(name)).cloned() +} + +/// The line families a new hatch of pattern `name` carries: a user pattern's, else none (the +/// library's patterns are drawn and saved from the library). +pub(crate) fn lines_for(name: &str) -> Vec { + if pattern(name).is_some() { + return Vec::new(); + } + user_pattern(name).map(|p| p.lines).unwrap_or_default() +} + +/// Every pattern name offered: the library's, then the loaded user patterns. +pub(crate) fn names() -> Vec { + let mut v: Vec = standard_patterns().iter().map(|p| p.name.to_string()).collect(); + v.extend(store().iter().map(|p| p.name.clone())); + v +} + +/// Add the patterns of a `.pat` file's text (a pattern loaded before under the same name is +/// replaced). Returns the names added and the file's problems. +pub(crate) fn load(text: &str) -> (Vec, Vec) { + let parsed = parse_pat(text); + let mut warnings = parsed.warnings; + let mut added = Vec::new(); + let mut user = store(); + for p in parsed.items { + if pattern(&p.name).is_some() { + warnings.push(format!("{} is a built-in pattern; the built-in definition is kept", p.name)); + continue; + } + let full = user.len() >= MAX_USER_PATTERNS; + match user.iter_mut().find(|q| q.name.eq_ignore_ascii_case(&p.name)) { + Some(q) => *q = p.clone(), + None if full => { + warnings.push(format!("more than {MAX_USER_PATTERNS} user patterns; {} not loaded", p.name)); + continue; + } + None => user.push(p.clone()), + } + added.push(p.name); + } + (added, warnings) +} + +/// Load the pattern file a HATCH call names (`patternFile` path or `patternText` contents). +pub(super) fn load_param(p: &Value) -> Result<()> { + if let Some((text, source)) = support_text("hatch", p, "patternFile", "patternText")? { + let (added, warnings) = load(&text); + if added.is_empty() { + return Err(bad("hatch", format!("no hatch pattern could be read from {source}: {}", warnings.join("; ")))); + } + } + Ok(()) +} + +/// A `.pat` file typed at HATCH's pattern prompt: load it and return the pattern to use (the +/// one named like the file, else its first). `None` when `t` isn't a `.pat` file name. +pub(super) fn typed_file(s: &mut Session, t: &str) -> Option { + if !t.to_ascii_lowercase().ends_with(".pat") { + return None; + } + let text = match support_text("hatch", &json!({ "file": t }), "file", "text") { + Ok(Some((text, _))) => text, + Ok(None) => return None, + Err(e) => { + s.echo(e.to_string()); + return Some(String::new()); + } + }; + let (added, warnings) = load(&text); + for w in warnings { + s.echo(w); + } + s.echo(format!("{} pattern(s) loaded from {t}: {}", added.len(), added.join(", "))); + let file = t.rsplit(['/', '\\']).next().unwrap_or(t); + let stem = file.get(..file.len().saturating_sub(4)).unwrap_or(""); + Some(added.iter().find(|n| n.eq_ignore_ascii_case(stem)).or(added.first()).cloned().unwrap_or_default()) +} + +/// HATCH.PATTERNS `{file?: path, text?: contents, clear?: bool}`: load a `.pat` file into the +/// pattern list, or forget the loaded ones; returns the list. +pub(crate) fn run_patterns(_s: &mut Session, p: &Value) -> Result { + if bool_or(p, "clear", false) { + store().clear(); + } + let (added, warnings) = match support_text("hatch.patterns", p, "file", "text")? { + Some((text, _)) => load(&text), + None => (Vec::new(), Vec::new()), + }; + let user: Vec = store().iter().map(|p| json!({ "name": p.name, "description": p.description, "families": p.lines.len() })).collect(); + Ok(json!({ + "standard": standard_patterns().iter().map(|p| p.name).collect::>(), + "user": user, + "added": added, + "warnings": warnings, + })) +} diff --git a/crates/engine/src/cmd/helpers.rs b/crates/engine/src/cmd/helpers.rs index 42f0a46..54c5ffd 100644 --- a/crates/engine/src/cmd/helpers.rs +++ b/crates/engine/src/cmd/helpers.rs @@ -100,3 +100,43 @@ pub fn rect_with_corners(a: Vec2, b: Vec2, fillet: f64, chamfer: (f64, f64)) -> } rect_vertices(Vec2::new(x0, y0), Vec2::new(x1, y1)) } + +// ---------- the user's support files ---------- + +/// Largest support file (linetype, hatch pattern or alias file) read. +pub const MAX_SUPPORT_FILE_BYTES: u64 = 8 << 20; + +/// The text of a support file the user gives: a path in `{path_key}` (read from disk; the web has +/// no file system) or the contents in `{text_key}`. Returns the text and a name for messages, or +/// `None` when neither is given. Files over [`MAX_SUPPORT_FILE_BYTES`] are refused; bytes that +/// aren't UTF-8 are read as replacement characters. +pub fn support_text(cmd: &str, p: &serde_json::Value, path_key: &str, text_key: &str) -> crate::Result> { + if let Some(text) = p.get(text_key).and_then(serde_json::Value::as_str) { + if text.len() as u64 > MAX_SUPPORT_FILE_BYTES { + return Err(super::bad(cmd, format!("`{text_key}` is larger than {} MB", MAX_SUPPORT_FILE_BYTES >> 20))); + } + return Ok(Some((text.to_string(), "the given text".into()))); + } + match p.get(path_key) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(path)) => Ok(Some((read_support_file(cmd, path)?, path.clone()))), + Some(_) => Err(super::bad(cmd, format!("`{path_key}` must be a file path"))), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn read_support_file(cmd: &str, path: &str) -> crate::Result { + use std::io::Read; + let f = std::fs::File::open(path).map_err(|e| super::bad(cmd, format!("{path}: {e}")))?; + let mut bytes = Vec::new(); + f.take(MAX_SUPPORT_FILE_BYTES + 1).read_to_end(&mut bytes).map_err(|e| super::bad(cmd, format!("{path}: {e}")))?; + if bytes.len() as u64 > MAX_SUPPORT_FILE_BYTES { + return Err(super::bad(cmd, format!("{path}: larger than {} MB", MAX_SUPPORT_FILE_BYTES >> 20))); + } + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +#[cfg(target_arch = "wasm32")] +fn read_support_file(cmd: &str, _path: &str) -> crate::Result { + Err(super::bad(cmd, "paths are not available on the web; pass the file's contents")) +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 48c8f08..1cf8888 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -23,7 +23,12 @@ pub fn specs() -> Vec { .params("{source, targets?: [hex] (default: selection), properties?: [name] | {name: bool} (default: all; names: color, layer, linetype, ltscale, lineweight, transparency, thickness, plotStyle, dimension, text, hatch, polyline, viewport, table, multileader)} → {changed}") .interactive(|_| Ok(Box::new(matchprop::MatchPropM::new()))), CommandSpec::new("color", "Color...", run_color).menu(&["Format", "Color..."]).alias(&["col", "colour"]).params("{color: \"ByLayer\" | \"red\" | 1..255 | \"r,g,b\"}"), - CommandSpec::new("linetype", "Linetype...", run_linetype).menu(&["Format", "Linetype..."]).alias(&["lt", "ltype"]).params("{current?: name, load?: name | \"*\"}"), + CommandSpec::new("linetype", "Linetype...", run_linetype).menu(&["Format", "Linetype..."]).alias(&["lt", "ltype"]).params("{current?: name, load?: name | \"*\" (with a file: comma-separated names, wildcards), file?: path to a .lin file | text?: its contents, reload?: bool}"), + CommandSpec::new("hatch.patterns", "Hatch Patterns", super::hatch::run_patterns) + .params("{file?: path to a .pat file | text?: its contents, clear?: bool} → {standard, user, added, warnings} (HATCH also takes patternFile | patternText)") + .enabled(always) + .noundo(), + CommandSpec::new("-linetype", "Linetype", run_linetype).params("{current?, load?, file? | text?, reload?} (as LINETYPE)").interactive(|_| Ok(Box::new(linfile::DashLinetype::new()))), CommandSpec::new("lweight", "Lineweight...", run_lweight).menu(&["Format", "Lineweight..."]).alias(&["lw", "lineweight"]).params("{lineweight: mm | ByLayer}"), CommandSpec::new("ltscale", "Linetype Scale", run_ltscale).alias(&["lts"]).params("{scale}"), CommandSpec::new("units", "Units...", run_units).menu(&["Format", "Units..."]).alias(&["un"]).params("{lunits?: 1..5, luprec?: 0..8, aunits?: 0..4, auprec?: 0..8, insunits?}"), @@ -338,6 +343,9 @@ fn run_set(s: &mut Session, p: &Value) -> Result { } EntityKind::Hatch(h) => { if let Some(v) = str_param(p, "pattern") { + if !h.pattern.eq_ignore_ascii_case(v) { + h.pattern_lines = super::hatch::lines_for(v); + } h.pattern = v.to_ascii_uppercase(); h.solid = h.pattern == "SOLID"; if !h.solid { @@ -375,7 +383,12 @@ fn run_color(s: &mut Session, p: &Value) -> Result { ok() } +mod linfile; + fn run_linetype(s: &mut Session, p: &Value) -> Result { + if linfile::has_file(p) { + return linfile::run(s, p); + } let d = s.doc_mut()?; if let Some(load) = str_param(p, "load") { let lib = cadcraft_doc::library::standard_linetypes_for(d); diff --git a/crates/engine/src/cmd/props/linfile.rs b/crates/engine/src/cmd/props/linfile.rs new file mode 100644 index 0000000..2290051 --- /dev/null +++ b/crates/engine/src/cmd/props/linfile.rs @@ -0,0 +1,265 @@ +//! Linetypes from the user's own `.lin` files: LINETYPE `{load, file | text}` and the +//! command-line -LINETYPE (`?`, `Load` from the built-in library or a file, `Set`). + +use cadcraft_doc::library::files::parse_lin; +use cadcraft_doc::{Drawing, Linetype}; +use serde_json::{Value, json}; + +use super::super::helpers::support_text; +use super::super::qselect::wildcard; +use super::*; +use crate::{Accept, Input, Interactive, Prompt, Step}; + +/// Names no file may define: they are not linetype definitions. +const RESERVED: &[&str] = &["bylayer", "byblock", "continuous"]; + +/// Whether a LINETYPE call names a file (`file`) or gives its contents (`text`). +pub(super) fn has_file(p: &Value) -> bool { + p.get("file").is_some_and(|v| !v.is_null()) || p.get("text").is_some_and(|v| !v.is_null()) +} + +/// What loading from a file did. +#[derive(Default)] +struct Loaded { + added: Vec, + reloaded: Vec, + /// Already in the drawing and not reloaded. + kept: Vec, + warnings: Vec, +} + +/// Whether `name` is one of the comma-separated names or wildcards in `names`. +fn wanted(names: &str, name: &str) -> bool { + names.split(',').map(str::trim).filter(|n| !n.is_empty()).any(|n| wildcard(n, name)) +} + +/// The linetypes of `text` that `names` asks for, without the reserved names. +fn read(text: &str, names: &str, out: &mut Loaded) -> Vec { + let parsed = parse_lin(text); + out.warnings.extend(parsed.warnings); + let mut keep = Vec::new(); + for lt in parsed.items.into_iter().filter(|l| wanted(names, &l.name)) { + if RESERVED.contains(<.name.to_ascii_lowercase().as_str()) { + out.warnings.push(format!("{} is a reserved name; not loaded", lt.name)); + } else { + keep.push(lt); + } + } + keep +} + +/// Put `lt` into `d`: added, or replacing the definition of the same name when `reload`. +fn put(d: &mut Drawing, lt: Linetype, reload: bool, out: &mut Loaded) { + for e in <.pattern { + if let Some(st) = e.style.as_deref() + && d.text_style(st).is_none() + { + out.warnings.push(format!("{}: text style {st} is not in this drawing; its text uses the default style", lt.name)); + break; + } + } + match d.linetypes.iter_mut().find(|l| l.name.eq_ignore_ascii_case(<.name)) { + Some(old) if reload => { + out.reloaded.push(old.name.clone()); + // The drawing keeps the name as it had it (objects refer to it). + *old = Linetype { name: old.name.clone(), ..lt }; + } + Some(old) => out.kept.push(old.name.clone()), + None => { + out.added.push(lt.name.clone()); + d.linetypes.push(lt); + } + } +} + +/// LINETYPE `{load?: names | "*", file?: path, text?: contents, reload?, current?}`: load the +/// named linetypes (comma-separated, wildcards allowed; all by default) from a `.lin` file. +pub(super) fn run(s: &mut Session, p: &Value) -> Result { + let (text, source) = support_text("linetype", p, "file", "text")?.ok_or_else(|| bad("linetype", "`file` or `text` is required"))?; + let names = str_param(p, "load").unwrap_or("*"); + let mut out = Loaded::default(); + let found = read(&text, names, &mut out); + if found.is_empty() { + return Err(bad("linetype", format!("no linetype matching `{names}` in {source}"))); + } + let reload = bool_or(p, "reload", false); + let d = s.doc_mut()?; + for lt in found { + put(d, lt, reload, &mut out); + } + // Setting the current linetype and the listing are LINETYPE's own. + let mut rest = p.clone(); + if let Some(o) = rest.as_object_mut() { + for k in ["file", "text", "load", "reload"] { + o.remove(k); + } + } + let mut r = super::run_linetype(s, &rest)?; + if let Some(o) = r.as_object_mut() { + o.insert("added".into(), json!(out.added)); + o.insert("reloaded".into(), json!(out.reloaded)); + o.insert("alreadyLoaded".into(), json!(out.kept)); + o.insert("warnings".into(), json!(out.warnings)); + } + Ok(r) +} + +// ---------- -LINETYPE ---------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Ask { + Option, + LoadNames, + LoadFile, + Reload, + Set, +} + +/// -LINETYPE at the command line. +pub(super) struct DashLinetype { + ask: Ask, + names: String, + /// Linetypes from the file that are already loaded, asked about one at a time. + reloads: Vec, +} + +impl DashLinetype { + pub(super) fn new() -> Self { + DashLinetype { ask: Ask::Option, names: String::new(), reloads: Vec::new() } + } + + fn list(s: &mut Session) { + let names: Vec = + s.doc().map(|d| d.linetypes.iter().map(|l| format!("{:<20} {}", l.name, l.description)).collect()).unwrap_or_default(); + s.echo("Loaded linetypes:"); + for n in names { + s.echo(n); + } + } + + /// Load the asked-for names from the built-in library (`path` empty) or a file. + fn load(&mut self, s: &mut Session, path: &str) -> Result<()> { + if path.is_empty() { + for name in self.names.split(',').map(str::trim).filter(|n| !n.is_empty()) { + let before = s.doc()?.linetypes.len(); + match super::run_linetype(s, &json!({ "load": name })) { + Ok(_) if s.doc()?.linetypes.len() > before => s.echo(format!("Linetype(s) {name} loaded.")), + Ok(_) => s.echo(format!("Linetype {name} is already loaded.")), + Err(e) => s.echo(e.to_string()), + } + } + self.ask = Ask::Option; + return Ok(()); + } + let text = match support_text("linetype", &json!({ "file": path }), "file", "text") { + Ok(Some((text, _))) => text, + Ok(None) => String::new(), + Err(e) => { + s.echo(e.to_string()); + self.ask = Ask::LoadFile; + return Ok(()); + } + }; + let mut out = Loaded::default(); + let found = read(&text, &self.names, &mut out); + for w in out.warnings.drain(..) { + s.echo(w); + } + if found.is_empty() { + s.echo(format!("No linetype matching {} in {path}.", self.names)); + } + let d = s.doc_mut()?; + for lt in found { + if d.linetype(<.name).is_some() { + self.reloads.push(lt); + } else { + put(d, lt, false, &mut out); + } + } + for w in out.warnings.drain(..) { + s.echo(w); + } + for n in &out.added { + s.echo(format!("Linetype {n} loaded.")); + } + // `reloads` is asked from its end: reversed, the questions follow the file's order. + self.reloads.reverse(); + self.ask = if self.reloads.is_empty() { Ask::Option } else { Ask::Reload }; + Ok(()) + } +} + +impl Interactive for DashLinetype { + fn name(&self) -> &'static str { + "-LINETYPE" + } + + fn begin(&mut self, s: &mut Session) -> Result { + let cur = s.doc()?.header.str("CELTYPE", "ByLayer"); + s.echo(format!("Current linetype: \"{cur}\"")); + Ok(Step::Continue) + } + + fn prompt(&self, s: &Session) -> Prompt { + match self.ask { + Ask::Option => Prompt::new("Enter an option", Accept::TEXT).kw(&["?", "Load", "Set"]), + Ask::LoadNames => Prompt::new("Enter linetype(s) to load", Accept::TEXT), + Ask::LoadFile => Prompt::new("Enter name of linetype file", Accept::TEXT).default("built-in library"), + Ask::Reload => { + let name = self.reloads.last().map(|l| l.name.as_str()).unwrap_or(""); + Prompt::new(format!("Linetype {name} is already loaded. Reload it?"), Accept::TEXT).kw(&["Yes", "No"]).default("Yes") + } + Ask::Set => { + let cur = s.doc().map(|d| d.header.str("CELTYPE", "ByLayer")).unwrap_or_default(); + Prompt::new("Specify linetype name", Accept::TEXT).kw(&["?"]).default(cur) + } + } + } + + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let t = match &i { + Input::Cancel => return Ok(Step::Cancel), + Input::Text(t) | Input::Keyword(t) => t.trim().to_string(), + Input::Enter => String::new(), + _ => return Ok(Step::Continue), + }; + match self.ask { + Ask::Option => match t.to_ascii_lowercase().as_str() { + "" => return Ok(Step::Done), + "?" => Self::list(s), + "load" | "l" => self.ask = Ask::LoadNames, + "set" | "s" => self.ask = Ask::Set, + _ => s.echo("Invalid option keyword."), + }, + Ask::LoadNames if t.is_empty() => self.ask = Ask::Option, + Ask::LoadNames => { + self.names = t; + self.ask = Ask::LoadFile; + } + Ask::LoadFile => self.load(s, &t)?, + Ask::Reload => { + let yes = !t.to_ascii_lowercase().starts_with('n'); + if let Some(lt) = self.reloads.pop() { + let name = lt.name.clone(); + if yes { + let mut out = Loaded::default(); + put(s.doc_mut()?, lt, true, &mut out); + s.echo(format!("Linetype {name} reloaded.")); + } + } + if self.reloads.is_empty() { + self.ask = Ask::Option; + } + } + Ask::Set if t == "?" => Self::list(s), + Ask::Set if t.is_empty() => self.ask = Ask::Option, + Ask::Set => { + if let Err(e) = super::run_linetype(s, &json!({ "current": t })) { + s.echo(e.to_string()); + } + self.ask = Ask::Option; + } + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/tests/lin_pat_files.rs b/crates/engine/tests/lin_pat_files.rs new file mode 100644 index 0000000..1c57d9f --- /dev/null +++ b/crates/engine/tests/lin_pat_files.rs @@ -0,0 +1,181 @@ +//! Linetypes and hatch patterns from the user's own `.lin` / `.pat` files (#552): LINETYPE's JSON +//! form, -LINETYPE at the command line, HATCH with a pattern file, HATCH.PATTERNS. The files are +//! tiny synthetic ones written here, with our own names. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Hatch}; +use serde_json::json; + +const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n"; + +/// A user file in the test's temp folder; returns its path. +fn file(name: &str, text: &str) -> String { + let dir = std::env::temp_dir().join(format!("cadcraft-lin-pat-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join(name); + std::fs::write(&p, text).unwrap(); + p.to_string_lossy().into_owned() +} + +fn linetype_pattern(s: &Session, name: &str) -> Option> { + s.doc().unwrap().linetype(name).map(|l| l.pattern.iter().map(|e| e.length).collect()) +} + +#[test] +fn linetype_json_loads_named_linetypes_from_file_text() { + let mut s = Session::new(); + let r = s.execute("linetype", &json!({ "load": "MYDASH", "text": LIN, "current": "MYDASH" })).unwrap(); + assert_eq!(r["added"], json!(["MYDASH"])); + assert_eq!(linetype_pattern(&s, "MYDASH"), Some(vec![0.5, -0.25])); + assert!(linetype_pattern(&s, "MYTEXT").is_none(), "only the named linetype is loaded"); + assert_eq!(s.doc().unwrap().header.str("CELTYPE", ""), "MYDASH"); + + // All of them by default; one already loaded is kept unless asked to reload. + let r = s.execute("linetype", &json!({ "text": LIN.replace(".5,-.25", "1,-1") })).unwrap(); + assert_eq!((r["added"].clone(), r["alreadyLoaded"].clone()), (json!(["MYTEXT"]), json!(["MYDASH"]))); + assert_eq!(linetype_pattern(&s, "MYDASH"), Some(vec![0.5, -0.25])); + let text = &s.doc().unwrap().linetype("MYTEXT").unwrap().pattern[1]; + assert_eq!(text.text.as_deref(), Some("MY")); + let r = s.execute("linetype", &json!({ "load": "my*", "text": LIN.replace(".5,-.25", "1,-1"), "reload": true })).unwrap(); + assert_eq!(r["reloaded"], json!(["MYDASH", "MYTEXT"])); + assert_eq!(linetype_pattern(&s, "MYDASH"), Some(vec![1.0, -1.0])); + // Undo takes a file load back like any change. + s.execute("undo", &json!({})).unwrap(); + assert_eq!(linetype_pattern(&s, "MYDASH"), Some(vec![0.5, -0.25])); +} + +#[test] +fn linetype_json_reads_a_lin_file_by_path() { + let path = file("mine.lin", LIN); + let mut s = Session::new(); + s.execute("linetype", &json!({ "load": "MYTEXT", "file": path })).unwrap(); + assert!(linetype_pattern(&s, "MYTEXT").is_some()); + let err = s.execute("linetype", &json!({ "load": "NOPE", "file": path })).unwrap_err().to_string(); + assert!(err.contains("NOPE"), "{err}"); + let err = s.execute("linetype", &json!({ "file": "/no/such/dir/x.lin" })).unwrap_err().to_string(); + assert!(err.contains("x.lin"), "{err}"); +} + +#[test] +fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() { + let path = file("cmdline.lin", LIN); + let mut s = Session::new(); + s.cmdline("-linetype").unwrap(); + for line in ["l", "MYDASH,MYTEXT", path.as_str(), "s", "MYTEXT"] { + s.cmdline(line).unwrap(); + } + assert!(s.running.is_some(), "back at the option prompt"); + s.cmdline("").unwrap(); + assert!(s.running.is_none()); + assert!(linetype_pattern(&s, "MYDASH").is_some() && linetype_pattern(&s, "MYTEXT").is_some()); + assert_eq!(s.doc().unwrap().header.str("CELTYPE", ""), "MYTEXT"); + assert!(s.log.iter().any(|l| l.contains("Linetype MYDASH loaded")), "{:?}", s.log); + + // Already loaded: asked whether to reload, in the file's order. + let changed = file("changed.lin", &LIN.replace(".5,-.25", "2,-2")); + s.cmdline("-linetype").unwrap(); + for line in ["l", "*", changed.as_str()] { + s.cmdline(line).unwrap(); + } + assert!(s.current_prompt().unwrap().message.contains("MYDASH is already loaded"), "{:?}", s.current_prompt()); + s.cmdline("y").unwrap(); + assert!(s.current_prompt().unwrap().message.contains("MYTEXT is already loaded")); + s.cmdline("n").unwrap(); + s.cmdline("").unwrap(); + assert_eq!(linetype_pattern(&s, "MYDASH"), Some(vec![2.0, -2.0])); + + // Enter at the file prompt: the built-in library. + s.cmdline("-linetype").unwrap(); + for line in ["l", "DASHED", "", ""] { + s.cmdline(line).unwrap(); + } + assert!(s.running.is_none()); + assert!(linetype_pattern(&s, "DASHED").is_some()); +} + +const PAT: &str = "*MYGRID,My grid\n0,0,0,0,.5\n90,0,0,0,.5\n*MYSTEPS,My steps\n0,0,0,.25,.25,.25,-.25\n"; + +fn hatches(s: &Session) -> Vec { + s.doc() + .unwrap() + .model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::Hatch(h) => Some(h.clone()), + _ => None, + }) + .collect() +} + +#[test] +fn hatch_uses_a_pattern_from_a_pat_file_and_keeps_its_definition() { + let mut s = Session::new(); + s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + s.execute("hatch", &json!({ "points": [[5, 5]], "pattern": "MYGRID", "patternText": PAT })).unwrap(); + let h = hatches(&s).pop().unwrap(); + assert_eq!(h.pattern, "MYGRID"); + assert_eq!(h.pattern_lines.len(), 2); + assert_eq!(h.pattern_lines[1].angle, 90.0); + + let list = s.execute("hatch.patterns", &json!({})).unwrap(); + let user: Vec<&str> = list["user"].as_array().unwrap().iter().filter_map(|p| p["name"].as_str()).collect(); + assert!(user.contains(&"MYGRID") && user.contains(&"MYSTEPS"), "{list}"); + assert!(list["standard"].as_array().unwrap().iter().any(|n| n == "ANSI31")); + + // Another pattern from the list replaces the definition the hatch carries; a library one clears it. + let hx = s.doc().unwrap().model.iter().find(|e| matches!(e.kind, EntityKind::Hatch(_))).unwrap().handle.hex(); + s.execute("hatchedit", &json!({ "handles": [hx], "pattern": "MYSTEPS" })).unwrap(); + assert_eq!(hatches(&s).pop().unwrap().pattern_lines[0].dashes, [0.25, -0.25]); + s.execute("hatchedit", &json!({ "handles": [hx], "pattern": "ANSI31" })).unwrap(); + assert!(hatches(&s).pop().unwrap().pattern_lines.is_empty()); +} + +#[test] +fn hatch_pattern_prompt_takes_a_pat_file_name() { + let path = file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n"); + let mut s = Session::new(); + s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + s.cmdline("hatch").unwrap(); + s.cmdline("p").unwrap(); + s.cmdline(&path).unwrap(); + // The pattern named like the file is the one used. + s.cmdline("5,5").unwrap(); + s.cmdline("").unwrap(); + let h = hatches(&s).pop().unwrap(); + assert_eq!((h.pattern.as_str(), h.pattern_lines.len()), ("MYSTAIRS", 2)); + assert_eq!(s.doc().unwrap().header.str("HPNAME", ""), "MYSTAIRS"); + + // A loaded pattern is now offered by name, too. + s.cmdline("hatch").unwrap(); + s.cmdline("p").unwrap(); + s.cmdline("myother").unwrap(); + s.cmdline("5,5").unwrap(); + s.cmdline("").unwrap(); + assert_eq!(hatches(&s).iter().filter(|h| h.pattern == "MYOTHER").count(), 1); +} + +#[test] +fn a_pat_file_cannot_replace_a_library_pattern() { + let mut s = Session::new(); + let r = s.execute("hatch.patterns", &json!({ "text": "*ANSI31\n0,0,0,0,9\n*MYKEEP\n0,0,0,0,1\n" })).unwrap(); + assert_eq!(r["added"], json!(["MYKEEP"])); + assert!(r["warnings"].to_string().contains("ANSI31")); +} + +#[test] +fn hostile_support_files_never_panic() { + let mut s = Session::new(); + s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap(); + let huge = "x".repeat(9 << 20); + let garbage = "*\u{0}[\"]]\n,,,,\n*X\nA,[[[\n\u{feff}*Y\n1e999,0,0,0,1\n"; + for text in [huge.as_str(), garbage, "", "*A\n"] { + assert!(s.execute("linetype", &json!({ "text": text })).is_err()); + assert!(s.execute("hatch", &json!({ "points": [[5, 5]], "patternText": text })).is_err()); + let _ = s.execute("hatch.patterns", &json!({ "text": text })); + } + for p in [json!({ "file": 7 }), json!({ "file": [] }), json!({ "text": null, "file": null })] { + let _ = s.execute("linetype", &p); + let _ = s.execute("hatch.patterns", &p); + } + assert!(hatches(&s).is_empty()); +} diff --git a/crates/io/tests/user_lin_pat_roundtrip.rs b/crates/io/tests/user_lin_pat_roundtrip.rs new file mode 100644 index 0000000..6ab6df4 --- /dev/null +++ b/crates/io/tests/user_lin_pat_roundtrip.rs @@ -0,0 +1,50 @@ +//! Linetypes and hatch patterns read from the user's own `.lin` / `.pat` files (#552) are kept by +//! a DXF save and reopen, so a drawing that uses them no longer needs the file. The files are tiny +//! synthetic ones with our own names. + +use cadcraft_doc::geom::{PolyVertex, Vec2}; +use cadcraft_doc::library::files::{parse_lin, parse_pat}; +use cadcraft_doc::{Common, Drawing, EntityKind, Hatch, HatchLoop, Space}; +use cadcraft_io::{read_dxf, write_dxf}; + +#[test] +fn user_linetypes_and_patterns_survive_dxf() { + let lin = parse_lin("*MYDASH,My dash\nA,.5,-.25,0,-.25\n*MYTEXT,My text\nA,.5,-.2,[\"MY\",Standard,S=.1,R=15,X=-.1,Y=-.05],-.2\n"); + let pat = parse_pat("*MYSTEPS,My steps\n0,0,0,.25,.25,.25,-.25\n90,.25,0,.25,.25,.25,-.25\n"); + assert!(lin.warnings.is_empty() && pat.warnings.is_empty()); + let mut d = Drawing::new_imperial(); + d.linetypes.extend(lin.items.clone()); + let steps = pat.items.first().unwrap().clone(); + let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)].into_iter().map(|(x, y)| PolyVertex::new(Vec2::new(x, y))).collect(); + let h = Hatch { + pattern: steps.name.clone(), + solid: false, + loops: vec![HatchLoop { vertices: square, outer: true, sources: Vec::new() }], + scale: 1.0, + angle: 0.0, + associative: false, + style: 0, + elevation: 0.0, + gradient: None, + origin: Vec2::ZERO, + background: None, + pattern_lines: steps.lines.clone(), + }; + d.add(&Space::Model, Common::default(), EntityKind::Hatch(h)).unwrap(); + + let back = read_dxf(write_dxf(&d).as_bytes()).unwrap(); + for lt in &lin.items { + let got = back.linetype(<.name).unwrap(); + assert_eq!(got.description, lt.description); + assert_eq!(got.pattern.len(), lt.pattern.len(), "{}", lt.name); + for (a, b) in got.pattern.iter().zip(<.pattern) { + assert!((a.length - b.length).abs() < 1e-9 && a.text == b.text && (a.rotation - b.rotation).abs() < 1e-9, "{a:?} vs {b:?}"); + } + } + let h = back.model.iter().find_map(|e| if let EntityKind::Hatch(h) = &e.kind { Some(h.clone()) } else { None }).unwrap(); + assert_eq!(h.pattern, "MYSTEPS"); + assert_eq!(h.pattern_lines.len(), 2); + for (a, b) in h.pattern_lines.iter().zip(&steps.lines) { + assert!((a.angle - b.angle).abs() < 1e-9 && a.dashes.len() == b.dashes.len(), "{a:?} vs {b:?}"); + } +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 21e356a..ec9504e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -372,6 +372,7 @@ Group Selection Вибір груп Hatch Штрихування Hatch Edit Редагувати штрихування + Hatch Patterns Зразки штрихування Hatch... Штрихування... Height Висота Height: {height} Висота: {height} diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs index d701326..15d9592 100644 --- a/crates/ui-egui/src/managers.rs +++ b/crates/ui-egui/src/managers.rs @@ -124,6 +124,15 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { action = Some(("linetype", json!({ "load": n }))); } } + // The user's own .lin file (where the host has a file picker). + if let Some(pick) = app.services.pick_open.as_ref() { + ui.separator(); + if ui.selectable_label(false, "From File...").clicked() + && let Some(path) = pick() + { + action = Some(("linetype", json!({ "load": "*", "file": path }))); + } + } }); let is_current = sel.eq_ignore_ascii_case(¤t); if ui.add_enabled(!is_current, egui::Button::new("Current")).on_hover_text("Draw new objects with the selected linetype").clicked() {