Support files: user .lin linetypes and .pat hatch patterns (part of #552) (#557)

Squash of PR #557 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-11 19:12:17 +02:00 committed by GitHub
parent 52015c0b17
commit 9fd1b8988d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1221 additions and 8 deletions

View File

@ -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<Linetype> {

View File

@ -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<PatternLine>,
}
/// What a file held: the definitions that could be read, and why anything else was skipped.
#[derive(Clone, Debug, PartialEq)]
pub struct Parsed<T> {
pub items: Vec<T>,
pub warnings: Vec<String>,
}
impl<T> Default for Parsed<T> {
fn default() -> Self {
Parsed { items: Vec::new(), warnings: Vec::new() }
}
}
impl<T> Parsed<T> {
fn warn(&mut self, line: usize, msg: impl Into<String>) {
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<f64> {
t.trim().parse::<f64>().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<Item = (usize, &str)> {
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<Linetype> {
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<Vec<&str>, 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<Linetype>) -> std::result::Result<Vec<DashElement>, 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<DashElement> = 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<Option<DashElement>, 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<f64> {
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::<f64>().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<UserPattern> {
let mut out: Parsed<UserPattern> = 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<UserPattern>| {
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<PatternLine, String> {
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() })
}

View File

@ -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::<Vec<_>>(), [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::<Vec<_>>(), [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::<Vec<_>>(), ["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::<Vec<_>>(), ["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);
}

View File

@ -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<Vec<HatchLoop>>)
}
fn run_hatch(s: &mut Session, p: &Value) -> Result<Value> {
patfile::load_param(p)?;
make(s, p, false)
}
fn run_gradient(s: &mut Session, p: &Value) -> Result<Value> {
@ -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::<Vec<_>>().join(", ")
));
s.echo(format!("Unknown pattern {up}. Available: {}", patfile::names().join(", ")));
}
}
HatchAsk::Scale => {

View File

@ -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<UserPattern>> {
static USER: OnceLock<Mutex<Vec<UserPattern>>> = 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<UserPattern> {
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<PatternLine> {
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<String> {
let mut v: Vec<String> = 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<String>, Vec<String>) {
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<String> {
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<Value> {
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<Value> = 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::<Vec<_>>(),
"user": user,
"added": added,
"warnings": warnings,
}))
}

View File

@ -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<Option<(String, String)>> {
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<String> {
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<String> {
Err(super::bad(cmd, "paths are not available on the web; pass the file's contents"))
}

View File

@ -23,7 +23,12 @@ pub fn specs() -> Vec<CommandSpec> {
.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<Value> {
}
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<Value> {
ok()
}
mod linfile;
fn run_linetype(s: &mut Session, p: &Value) -> Result<Value> {
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);

View File

@ -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<String>,
reloaded: Vec<String>,
/// Already in the drawing and not reloaded.
kept: Vec<String>,
warnings: Vec<String>,
}
/// 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<Linetype> {
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(&lt.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 &lt.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(&lt.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<Value> {
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<Linetype>,
}
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<String> =
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(&lt.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<Step> {
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<Step> {
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)
}
}

View File

@ -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<Vec<f64>> {
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<Hatch> {
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());
}

View File

@ -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(&lt.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(&lt.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:?}");
}
}

View File

@ -372,6 +372,7 @@
Group Selection Вибір груп
Hatch Штрихування
Hatch Edit Редагувати штрихування
Hatch Patterns Зразки штрихування
Hatch... Штрихування...
Height Висота
Height: {height} Висота: {height}

Can't render this file because it has a wrong number of fields in line 8.

View File

@ -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(&current);
if ui.add_enabled(!is_current, egui::Button::new("Current")).on_hover_text("Draw new objects with the selected linetype").clicked() {