fix(linetype): catalog complex linetypes keep their shapes and text

The loader added every catalog linetype to the drawing with its [shape]
and ["text"] elements stripped, and the renderer reads the drawing's
own definition first — so BATTING, ZIGZAG, PLANT, POINT1/2, DRAINAGE,
FENCELINE, TRACKS, GAS_LINE, HOT_WATER_SUPPLY drew as plain dashes (a
near-solid line), and a saved file carried no shapes either.

- populate_document_from_source now stores each shape / text on the
  element it follows, with a shape-file STYLE (empty name, one per file)
  and the named text style; values keep their written decimals.
- One table maps shape names to numbers per shape file: the standard
  ltypeshp 130–134, the bundled catalog's own beyond them. The renderer
  resolves stored numbers through it instead of a hard-coded list that
  ignored which file a number belonged to.
- A drawing's copy stripped to exactly the catalog's dashes (as this
  loader saved them) gets its shapes and text back on open.
- Opening completes the linetypes before the geometry is prepared, so
  the first view already draws them.

Fixes #665

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-09-30 14:38:53 +03:00
1 parent 6dbdeb62e8
commit 593f7ca3e9
2 files changed
+157 -17

No files matched your search

+153 -17
View File
@@ -157,18 +157,11 @@ pub fn document_lt_segments(document: &CadDocument, name: &str) -> Option<Comple
let resolved = (!font.is_empty())
.then(|| crate::io::resolve_image_file(&font, base))
.flatten();
// The standard ltypeshp.shx numbers map to the bundled
// 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 = match *shape_number {
130 => "TRACK1",
131 => "ZIG",
132 => "BOX",
133 => "CIRC1",
134 => "BAT",
_ => "",
};
// 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;
}
@@ -268,20 +261,163 @@ pub fn populate_document(doc: &mut CadDocument) {
populate_document_from_source(doc, LIN_SOURCE);
}
/// Add all simple linetypes parsed from a caller-supplied `.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) {
if !doc.line_types.contains(&lt.name) {
lt.set_handle(doc.allocate_handle());
doc.line_types.add(lt).ok();
added += 1;
let segments = complex.get(&lt.name.to_ascii_uppercase());
if let Some(existing) = doc.line_types.get(&lt.name) {
let stripped = segments.is_some()
&& !existing.is_complex()
&& existing.elements.len() == lt.elements.len()
&& existing
.elements
.iter()
.zip(&lt.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, &lt.elements)) {
if let Some(existing) = doc.line_types.get_mut(&lt.name) {
existing.elements = elements;
}
}
}
continue;
}
if let Some(elements) = segments.and_then(|s| complex_elements(doc, s, &lt.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)
}
/// 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<Vec<LineTypeElement>> {
use codec::tables::linetype::LineTypeComplexData;
let mut out: Vec<LineTypeElement> = Vec::new();
let mut lengths = plain.iter();
for segment in &lt.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 ────────────────────────────────────────────────────────────────
+4
View File
@@ -355,6 +355,10 @@ async fn open_path_with_phase_attempt(
recoverable: true,
});
}
// Before the geometry is prepared below: completing the
// drawing's catalog linetypes (and repairing stripped ones)
// after it would leave the first view drawn without them.
crate::io::linetypes::populate_document(&mut doc);
progress2.set(crate::app::OPEN_PHASE_XREF, 6000, 0, 1);
let t_xref = Instant::now();
let (xref_infos, xref_dropped) = if let Some(base_dir) = path2.parent() {