//! OpenCADStudio linetype catalog — loaded from `assets/linetypes/OpenCADStudio.lin`. //! //! Call [`populate_document`] to add all standard linetypes to a new document. //! Linetypes that already exist in the document are skipped. //! //! [`complex_lt`] returns the complex segment catalog for CPU-side rendering. use rustc_hash::FxHashMap as HashMap; use std::sync::OnceLock; use codec::tables::linetype::{LineType, LineTypeComplexContent, LineTypeElement}; use codec::{CadDocument, TableEntry}; // ── Complex linetype types ──────────────────────────────────────────────── /// One element in a complex linetype pattern. #[derive(Clone, Debug)] pub enum LtSegment { /// Draw a dash of this length (world units). Dash(f32), /// Skip a gap of this length (world units). Space(f32), /// Draw a dot (zero-length element, rendered as a tiny mark). Dot, /// Draw a shape at the current pen position. Shape { /// Shape name (`.lin` catalog reference; empty for document shapes). name: String, /// Resolved .SHX shape-file path ("" when none resolved — the LFF /// substitute set is the fallback for catalog names). shx_file: String, /// Shape number inside the file (0 = look up by name). number: u16, /// X offset along the linetype direction (can be negative). x: f32, /// Y offset perpendicular to the linetype direction. y: f32, /// Scale factor applied to the CXF coordinates. scale: f32, /// Optional rotation in degrees (0 = along the line). rot_deg: f32, }, /// Draw a text string at the current pen position. Text { /// The string to render. text: String, /// Text style / font name. style: String, /// X offset along the linetype direction. x: f32, /// Y offset perpendicular to the linetype direction. y: f32, /// Height scale factor. scale: f32, /// Rotation in degrees (0 = along the line). rot_deg: f32, }, } /// A complex linetype — ordered elements for one pattern repeat. #[derive(Clone, Debug)] pub struct ComplexLt { pub segments: Vec, } static COMPLEX_CATALOG: OnceLock> = OnceLock::new(); /// Look up a complex linetype by name (case-insensitive). /// Returns `None` for simple (dash-only) linetypes or unknown names. pub fn complex_lt(name: &str) -> Option<&'static ComplexLt> { COMPLEX_CATALOG .get_or_init(|| parse_complex(lin_source())) .get(&name.to_ascii_uppercase()) } /// Build the full segment list — dashes, spaces, dots **and** embedded /// text / shape — for a linetype defined in the loaded **document**, whether it /// is simple or complex. /// /// Returns `None` when the linetype is missing or continuous (no pattern). Used /// by the MLINE renderer: every element is CPU-dashed by the same walker so the /// parallel lines (and any embedded text) stay in phase with each other. Ordinary /// entities keep the cheaper shader dash for simple linetypes; see /// [`document_complex_lt`] / [`resolve_complex_lt`]. pub fn document_lt_segments(document: &CadDocument, name: &str) -> Option { let lt = document .line_types .iter() .find(|l| l.name.eq_ignore_ascii_case(name))?; let mut segments: Vec = Vec::with_capacity(lt.elements.len() + 1); let mut has_length = false; for e in <.elements { // Emit the dash / space for this element FIRST, then any embedded // text / shape — the pen advances across the element's length before the // glyph is placed, so the text lands at the element's *end* (inside the // gap), matching the `.lin` token order (`dash, -space, ["TEXT"], …`) and // AutoCAD's placement. Putting the text first drops it onto the preceding // dash, so the dash strikes through the glyphs. let len = e.length as f32; if len > 1e-9 { segments.push(LtSegment::Dash(len)); has_length = true; } else if len < -1e-9 { segments.push(LtSegment::Space(-len)); has_length = true; } else if e.complex.is_none() { // A zero-length *non-complex* element is a dot; a zero-length complex // element only contributes its text/shape (pushed below). segments.push(LtSegment::Dot); has_length = true; } if let Some(c) = &e.complex { match &c.content { LineTypeComplexContent::Text { text } => { let style_name = document .text_styles .iter() .find(|s| s.handle == c.style_handle) .map(|s| s.name.as_str()) .unwrap_or(""); let font = crate::entities::text_support::resolve_text_style(style_name, document) .font_name; let scale = c.scale as f32; segments.push(LtSegment::Text { text: text.clone(), style: font, x: c.offset[0] as f32, y: c.offset[1] as f32, scale: if scale.abs() < 1e-6 { 1.0 } else { scale }, // DWG stores the element rotation in RADIANS; the // segment field carries degrees (the `.lin` unit). rot_deg: (c.rotation as f32).to_degrees(), }); } LineTypeComplexContent::Shape { shape_number } => { // Resolve the style's shape file with the shared path // fallbacks (as stored / next to the drawing) so the real // SHX glyph draws; unresolved files simply skip. let font = document .text_styles .iter() .find(|s| s.handle == c.style_handle) .map(|s| s.font_file.trim().to_string()) .unwrap_or_default(); if *shape_number <= 0 { continue; } let base = document .source_path .as_deref() .map(std::path::Path::new) .and_then(|p| p.parent()); let resolved = (!font.is_empty()) .then(|| crate::io::resolve_image_file(&font, base)) .flatten(); // The standard ltypeshp.shx numbers (and the bundled // catalog's own) map to the LFF substitute shapes, so the // glyph still draws when the shape file isn't on disk // (the usual case — traded drawings rarely ship their .shx). let sub_name = bundled_shape_name(&font, *shape_number).unwrap_or(""); if resolved.is_none() && sub_name.is_empty() { continue; } let scale = c.scale as f32; segments.push(LtSegment::Shape { name: sub_name.to_string(), shx_file: resolved.unwrap_or_default(), number: *shape_number as u16, x: c.offset[0] as f32, y: c.offset[1] as f32, scale: if scale.abs() < 1e-6 { 1.0 } else { scale }, rot_deg: (c.rotation as f32).to_degrees(), }); } } } } // No dash/space/dot means "continuous" — let the caller draw a solid line. if !has_length { return None; } Some(ComplexLt { segments }) } /// Complex (embedded text/shape) segments for a **document** linetype, or `None` /// when it is simple (dash-only) — simple dashes are handled by the ordinary /// `resolve_pattern` shader path, so ordinary entities don't pay the CPU-dash /// cost. This is the gate used by [`resolve_complex_lt`]. pub fn document_complex_lt(document: &CadDocument, name: &str) -> Option { let lt = document .line_types .iter() .find(|l| l.name.eq_ignore_ascii_case(name))?; // Treat the linetype as complex only when an element carries *renderable* // embedded content — a non-empty text string, or a shape backed by a shape // file. Some DWGs store dash elements with a placeholder `complex` (shape // #0, null style handle, zero scale) that draws nothing; routing those // through the CPU complex-linetype path needlessly skips the normal dash // shader — and with it the "A"-type endpoint alignment. Fall through to the // ordinary `resolve_pattern` path for them. let has_real_complex = lt.elements.iter().any(|e| { e.complex.as_ref().is_some_and(|cx| match &cx.content { LineTypeComplexContent::Text { text } => !text.trim().is_empty(), LineTypeComplexContent::Shape { .. } => !cx.style_handle.is_null(), }) }); if !has_real_complex { return None; } document_lt_segments(document, name) } /// Resolve a complex linetype honouring "**document first, bundled catalog /// second**": if the drawing defines the linetype, its own definition wins /// (embedded text/shape, or `None` when the drawing's version is simple); only a /// linetype absent from the drawing falls back to the bundled `.lin` catalog. pub fn resolve_complex_lt(document: &CadDocument, name: &str) -> Option { let in_document = document .line_types .iter() .any(|l| l.name.eq_ignore_ascii_case(name)); if in_document { document_complex_lt(document, name) } else { complex_lt(name).cloned() } } const LIN_SOURCE: &str = include_str!("../../assets/linetypes/OpenCADStudio.lin"); /// The bundled linetypes followed by the user's own `.lin` files. fn lin_source() -> &'static str { static SOURCE: OnceLock = OnceLock::new(); SOURCE.get_or_init(|| { format!("{LIN_SOURCE}\n{}", crate::io::patterns::user_definitions("linetypes", "lin")) }) } // ── Pattern art extraction ──────────────────────────────────────────────── /// Extract the ASCII-art portion of a LIN description. /// /// LIN descriptions look like `"Dashed __ __ __ __"` or `"ISO dot . . . ."`. /// This function returns the trailing pattern string (e.g. `"__ __ __ __"`). pub fn extract_pattern(desc: &str) -> String { // Patterns that use dashes start with `__`. if let Some(pos) = desc.find("__") { let start = desc[..pos].rfind(' ').map(|p| p + 1).unwrap_or(0); return desc[start..].trim().to_string(); } // Dot-only patterns: look for `. .` sequence. if let Some(pos) = desc.find(". .") { let start = desc[..pos].rfind(' ').map(|p| p + 1).unwrap_or(0); return desc[start..].trim().to_string(); } // Fallback: return the whole description. desc.trim().to_string() } // ── Public API ──────────────────────────────────────────────────────────── /// Add all standard OpenCADStudio linetypes to `doc`, skipping existing ones. pub fn populate_document(doc: &mut CadDocument) { populate_document_from_source(doc, lin_source()); } /// Add the linetypes of a caller-supplied `.lin` source, embedded shapes and /// text included, so the drawing draws them and a saved file carries them. /// Returns the number of definitions newly registered in the drawing. /// /// A linetype the drawing already has keeps its own definition, with one /// repair: a copy stripped to its dashes (as this loader used to add them, /// and so saved) gets its shapes and text back when its dashes match the /// source's exactly. pub fn populate_document_from_source(doc: &mut CadDocument, source: &str) -> usize { let complex = parse_complex(source); let mut added = 0; for mut lt in parse(source) { let segments = complex.get(<.name.to_ascii_uppercase()); if let Some(existing) = doc.line_types.get(<.name) { let stripped = segments.is_some() && !existing.is_complex() && existing.elements.len() == lt.elements.len() && existing.elements.iter().zip(<.elements).all(|(a, b)| { (a.length - b.length).abs() <= 1e-9 * b.length.abs().max(1.0) }); if stripped { if let Some(elements) = segments.and_then(|s| complex_elements(doc, s, <.elements)) { if let Some(existing) = doc.line_types.get_mut(<.name) { existing.elements = elements; } } } continue; } if let Some(elements) = segments.and_then(|s| complex_elements(doc, s, <.elements)) { lt.elements = elements; } lt.set_handle(doc.allocate_handle()); doc.line_types.add(lt).ok(); added += 1; } added } /// Shape numbers for the shape files the bundled catalog references, /// keyed by file stem and shape name. The `ltypeshp` 130–134 are the /// standard ones; the rest exist only in the bundled LFF substitutes, so /// their numbers are this application's own. const BUNDLED_SHAPES: &[(&str, &str, i16)] = &[ ("ltypeshp", "TRACK1", 130), ("ltypeshp", "ZIG", 131), ("ltypeshp", "BOX", 132), ("ltypeshp", "CIRC1", 133), ("ltypeshp", "BAT", 134), ("ltypeshp", "PT1", 135), ("ltypeshp", "PT2", 136), ("ltypeshp", "PLANT1", 137), ("ltypeshp", "PLANT2", 138), ("ltypeshp", "PLANT3", 139), ("ltypeshp", "PLANT4", 140), ("ltypeshp", "PLANT5", 141), ("ltypeshp", "PLANT6", 142), ("qcadshp", "RIGHT_ARROW", 130), ]; /// A catalog value as the decimal it was written as: 2.54 stays 2.54, not /// the 2.5399999618… an `f32` widens to. fn decimal(value: f32) -> f64 { value.to_string().parse().unwrap_or(value as f64) } fn shape_file_stem(file: &str) -> String { let name = file.rsplit(['/', '\\']).next().unwrap_or(file); name.split('.').next().unwrap_or(name).to_ascii_lowercase() } /// The bundled shape a stored shape number names in `file` (an empty file /// reads as `ltypeshp`). fn bundled_shape_name(file: &str, number: i16) -> Option<&'static str> { let stem = shape_file_stem(file); let stem = if stem.is_empty() { "ltypeshp".to_string() } else { stem }; BUNDLED_SHAPES .iter() .find(|(file, _, n)| *file == stem && *n == number) .map(|(_, name, _)| *name) } /// Whether `file` is a shape file the bundled LFF substitutes stand in for, /// so its absence on disk never needs reporting. pub fn is_bundled_shape_file(file: &str) -> bool { let stem = shape_file_stem(file); BUNDLED_SHAPES.iter().any(|(f, _, _)| *f == stem) } /// The drawing's shape-file STYLE for `file`, added when missing (an empty /// name, as shape files are stored). fn shape_file_style(doc: &mut CadDocument, file: &str) -> codec::Handle { if let Some(style) = doc .text_styles .iter() .find(|s| s.is_shape_file && s.font_file.eq_ignore_ascii_case(file)) { return style.handle; } let mut style = codec::tables::TextStyle::new(""); style.handle = doc.allocate_handle(); style.is_shape_file = true; style.font_file = file.to_string(); let handle = style.handle; doc.text_styles.add_allow_duplicate(style); handle } /// Document elements for catalog segments: each shape or text rides on the /// element before it, as in the `.lin` token order. `None` when a piece /// cannot be stored (an unknown shape or text style), so the caller keeps /// the plain dashes rather than a half-built pattern. /// /// The lengths come from `plain` — the same pattern parsed at full /// precision — rather than the catalog's `f32` segments. fn complex_elements( doc: &mut CadDocument, lt: &ComplexLt, plain: &[LineTypeElement], ) -> Option> { use codec::tables::linetype::LineTypeComplexData; let mut out: Vec = Vec::new(); let mut lengths = plain.iter(); for segment in <.segments { let complex = match segment { LtSegment::Dash(_) | LtSegment::Space(_) | LtSegment::Dot => { out.push(lengths.next()?.clone()); continue; } LtSegment::Shape { name, shx_file, x, y, scale, rot_deg, .. } => { let stem = shape_file_stem(shx_file); let number = BUNDLED_SHAPES .iter() .find(|(file, shape, _)| *file == stem && shape.eq_ignore_ascii_case(name))? .2; LineTypeComplexData { content: LineTypeComplexContent::Shape { shape_number: number, }, style_handle: shape_file_style(doc, shx_file), scale: decimal(*scale), rotation: decimal(*rot_deg).to_radians(), absolute_rotation: false, offset: [decimal(*x), decimal(*y)], } } LtSegment::Text { text, style, x, y, scale, rot_deg, } => LineTypeComplexData { content: LineTypeComplexContent::Text { text: text.clone() }, style_handle: doc .text_styles .iter() .find(|s| !s.is_shape_file && s.name.eq_ignore_ascii_case(style))? .handle, scale: decimal(*scale), rotation: decimal(*rot_deg).to_radians(), absolute_rotation: false, offset: [decimal(*x), decimal(*y)], }, }; out.last_mut()?.complex = Some(complex); } (lengths.next().is_none()).then_some(out) } // ── Parser ──────────────────────────────────────────────────────────────── /// Parse a `.lin` file and return all simple linetypes found. fn parse(src: &str) -> Vec { let mut result = Vec::new(); let mut current: Option<(String, String)> = None; // (name, description) for raw in src.lines() { let line = raw.trim(); // Skip comments and blank lines. if line.is_empty() || line.starts_with(";;") { continue; } if let Some(rest) = line.strip_prefix('*') { // New pattern header: *NAME[,Description]. The description is // optional, and the comma goes with it. let (name, desc) = rest.split_once(',').unwrap_or((rest, "")); current = Some((name.trim().to_string(), desc.trim().to_string())); } else if line.to_ascii_uppercase().starts_with('A') { // Element line: A,v1,v2,... let Some((name, desc)) = current.take() else { continue; }; let after_a = match line[1..].trim_start().strip_prefix(',') { Some(s) => s, None => continue, }; let elements = parse_elements(after_a); let pattern_length: f64 = elements.iter().map(|e| e.length.abs()).sum(); let mut lt = LineType::new(&name); lt.description = desc; lt.pattern_length = pattern_length; for e in elements { lt.add_element(e); } result.push(lt); } } result } /// Parse the comma-separated element values after `A,`. /// Complex elements `[...]` are skipped; only plain numbers are used. fn parse_elements(s: &str) -> Vec { let mut elements = Vec::new(); let mut chars = s.chars().peekable(); let mut token = String::new(); while let Some(&c) = chars.peek() { if c == '[' { // Skip entire complex element including nested brackets. let mut depth = 0usize; for ch in chars.by_ref() { if ch == '[' { depth += 1; } else if ch == ']' { depth -= 1; if depth == 0 { break; } } } // Consume trailing comma if present. if chars.peek() == Some(&',') { chars.next(); } continue; } if c == ',' { chars.next(); push_element(&token, &mut elements); token.clear(); } else { token.push(c); chars.next(); } } push_element(&token, &mut elements); elements } fn push_element(token: &str, out: &mut Vec) { let t = token.trim(); if t.is_empty() { return; } if let Ok(v) = t.parse::() { let elem = if v > 0.0 { LineTypeElement::dash(v) } else if v < 0.0 { LineTypeElement::space(v.abs()) } else { LineTypeElement::dot() }; out.push(elem); } } // ── Complex linetype parser ─────────────────────────────────────────────── /// Parse the LIN source and return a catalog of complex linetypes. /// A linetype is "complex" when its A-line contains at least one `[SHAPE,...]` /// element. Simple (dash-only) linetypes are excluded. fn parse_complex(src: &str) -> HashMap { let mut catalog: HashMap = HashMap::default(); let mut current: Option<(String, String)> = None; // (name, description) for raw in src.lines() { let line = raw.trim(); if line.is_empty() || line.starts_with(";;") { continue; } if let Some(rest) = line.strip_prefix('*') { if let Some((name, desc)) = rest.split_once(',') { current = Some((name.trim().to_string(), desc.trim().to_string())); } } else if line.to_ascii_uppercase().starts_with('A') { let Some((name, _desc)) = current.take() else { continue; }; let after_a = match line[1..].trim_start().strip_prefix(',') { Some(s) => s, None => continue, }; // Only collect linetypes that contain a shape element. if !after_a.contains('[') { continue; } let segments = parse_complex_elements(after_a); if segments.is_empty() { continue; } catalog.insert(name.to_ascii_uppercase(), ComplexLt { segments }); } } catalog } /// Parse complex A-line elements into `LtSegment`s. /// Shape elements: `[SHAPENAME,fontfile,x=v,y=v,s=v,r=v]` /// Text elements: `["TEXT",font,...]` — skipped (returns no segment). fn parse_complex_elements(s: &str) -> Vec { let mut segs = Vec::new(); let mut chars = s.chars().peekable(); let mut token = String::new(); while let Some(&c) = chars.peek() { if c == '[' { // Flush any pending numeric token. push_lt_segment(&token, &mut segs); token.clear(); // Read the entire bracketed element. let mut depth = 0usize; let mut inner = String::new(); for ch in chars.by_ref() { if ch == '[' { depth += 1; if depth > 1 { inner.push(ch); } } else if ch == ']' { depth -= 1; if depth == 0 { break; } inner.push(ch); } else { inner.push(ch); } } // Consume trailing comma. if chars.peek() == Some(&',') { chars.next(); } if let Some(seg) = parse_shape_element(&inner) { segs.push(seg); } } else if c == ',' { chars.next(); push_lt_segment(&token, &mut segs); token.clear(); } else { token.push(c); chars.next(); } } push_lt_segment(&token, &mut segs); segs } /// Parse a bracketed linetype element. /// Handles both shape (`SHAPENAME,...`) and text (`"string",...`) elements. fn parse_shape_element(inner: &str) -> Option { let inner = inner.trim(); if inner.starts_with('"') { return parse_text_element(inner); } let mut parts = inner.split(','); let name = parts.next()?.trim().to_string(); if name.is_empty() { return None; } let mut x = 0.0f32; let mut y = 0.0f32; let mut scale = 1.0f32; let mut rot_deg = 0.0f32; let mut shx_file = String::new(); for part in parts { let p = part.trim(); if let Some(v) = p.strip_prefix("x=").or_else(|| p.strip_prefix("X=")) { x = v.parse().unwrap_or(0.0); } else if let Some(v) = p.strip_prefix("y=").or_else(|| p.strip_prefix("Y=")) { y = v.parse().unwrap_or(0.0); } else if let Some(v) = p.strip_prefix("s=").or_else(|| p.strip_prefix("S=")) { scale = v.parse().unwrap_or(1.0); } else if let Some(v) = p .strip_prefix("r=") .or_else(|| p.strip_prefix("R=")) .or_else(|| p.strip_prefix("u=")) .or_else(|| p.strip_prefix("U=")) { rot_deg = v.parse().unwrap_or(0.0); } else if p.to_ascii_lowercase().ends_with(".shx") { // `[NAME,ltypeshp.shx,…]` — the shape file reference. shx_file = p.to_string(); } } Some(LtSegment::Shape { name, shx_file, number: 0, x, y, scale, rot_deg, }) } /// Parse `"TEXT",STYLE,x=v,y=v,s=v,r=v` into an `LtSegment::Text`. fn parse_text_element(inner: &str) -> Option { // Find the closing quote. let rest = inner.strip_prefix('"')?; let end = rest.find('"')?; let text = rest[..end].to_string(); let after = rest[end + 1..].trim().trim_start_matches(','); let mut parts = after.split(','); let style = parts .next() .map(|s| s.trim().to_string()) .unwrap_or_default(); let style = if style.is_empty() { "Standard".to_string() } else { style }; let mut x = 0.0f32; let mut y = 0.0f32; let mut scale = 1.0f32; let mut rot_deg = 0.0f32; for part in parts { let p = part.trim(); if let Some(v) = p.strip_prefix("x=").or_else(|| p.strip_prefix("X=")) { x = v.parse().unwrap_or(0.0); } else if let Some(v) = p.strip_prefix("y=").or_else(|| p.strip_prefix("Y=")) { y = v.parse().unwrap_or(0.0); } else if let Some(v) = p.strip_prefix("s=").or_else(|| p.strip_prefix("S=")) { scale = v.parse().unwrap_or(1.0); } else if let Some(v) = p .strip_prefix("r=") .or_else(|| p.strip_prefix("R=")) .or_else(|| p.strip_prefix("u=")) .or_else(|| p.strip_prefix("U=")) { rot_deg = v.parse().unwrap_or(0.0); } } Some(LtSegment::Text { text, style, x, y, scale, rot_deg, }) } fn push_lt_segment(token: &str, out: &mut Vec) { let t = token.trim(); if t.is_empty() { return; } if let Ok(v) = t.parse::() { let seg = if v > 0.0 { LtSegment::Dash(v) } else if v < 0.0 { LtSegment::Space(v.abs()) } else { LtSegment::Dot }; out.push(seg); } } #[cfg(test)] mod header_tests { use super::{parse, populate_document_from_source}; use codec::CadDocument; /// A `.lin` header's description is optional, and without one the comma /// goes too: `*PLAIN` followed by its `A,` line is a whole definition. #[test] fn a_linetype_without_a_description_is_loaded() { let mut doc = CadDocument::new(); let added = populate_document_from_source(&mut doc, "*PLAIN\nA,.5,-.25\n"); assert_eq!(added, 1, "the definition was dropped"); assert!(doc.line_types.contains("PLAIN")); let types = parse("*PLAIN\nA,.5,-.25\n"); assert_eq!(types[0].description, ""); let lengths: Vec = types[0].elements.iter().map(|e| e.length).collect(); assert_eq!(lengths, vec![0.5, -0.25]); } /// A header with a description still splits at the first comma. #[test] fn a_description_after_the_comma_is_kept() { let types = parse("*DASHED,Dashed __ __\nA,.5,-.25\n"); assert_eq!(types[0].name, "DASHED"); assert_eq!(types[0].description, "Dashed __ __"); } }