Squash of PR #557 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
52015c0b17
commit
9fd1b8988d
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(<.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<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(<.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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue