From 0e18b5bb33e541c72289f0311f363f00a9e65afd Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 16 Jul 2026 18:44:15 +0300 Subject: [PATCH] feat(mline): render multilines from their MLINESTYLE + complex linetypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multilines were drawn with a fixed ±scale/2 two-line guess in the entity colour, ignoring the MLINESTYLE entirely, so a drawing's custom style (per- element offsets, colours and linetypes) was lost — a red/yellow multiline rendered as a white box. - mline.rs: resolve the MLINESTYLE (by style_handle, then name) and build one parallel line per element from its offset x scale + justification shift. - tessellate.rs: special-case MLINE (like Leader) to emit one WireModel per element in the element's own colour, CPU-dashing every element through apply_along so the parallel lines stay in phase. - linetypes.rs: render a drawing's OWN complex linetypes (embedded text/shape), document definition first and the bundled .lin catalog only as a fallback, so a file-defined text linetype like PLYN shows its text. - Run the dash/glyph layout in a local frame (f64 origin subtracted, re-split to double-single after) so spacing stays precise at UTM coordinates, and pass tracking = 1.0 so embedded text keeps its letter spacing. Cargo.lock pins acadrust to the MLINE->MLINESTYLE handle-link reader fix. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 2 +- src/entities/mline.rs | 196 ++++++++++++++++++----------- src/io/linetypes.rs | 108 +++++++++++++++- src/scene/convert/tess.rs | 4 +- src/scene/convert/tessellate.rs | 212 ++++++++++++++++++++++++++++++++ src/scene/text/complex_lt.rs | 7 +- 6 files changed, 454 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c0ade487..1fa53be9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,7 +73,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.0" -source = "git+https://github.com/OpenAEC-Foundation/acadifc?branch=main#a7f4741eb9dbcc8f44170eca3a5c85143b6cf432" +source = "git+https://github.com/OpenAEC-Foundation/acadifc?branch=main#ecd1ac82985cfef77af54cb37b1b50bcdb29b7c1" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/src/entities/mline.rs b/src/entities/mline.rs index 6f1b3bb63..08a79d93e 100644 --- a/src/entities/mline.rs +++ b/src/entities/mline.rs @@ -7,83 +7,141 @@ use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::model::wire_model::SnapHint; +/// One drawn line of a multiline: the polyline for a single style element (or +/// an end cap), tagged with the element's colour and linetype so the +/// tessellator can colour-bin and dash each one independently. +pub struct MLineLine { + pub points: Vec<[f64; 3]>, + pub color: acadrust::types::Color, + pub linetype: String, +} + +/// Resolve a multiline into its per-element parallel lines in WCS. +/// +/// Geometry comes from the referenced MLINESTYLE (element offsets, the +/// justification shift and the entity scale) rather than a fixed ±scale/2 guess, +/// so a custom style's offsets, colours and linetypes render the way the drawing +/// intends. Falls back to a ±0.5 two-line layout only when no MLINESTYLE can be +/// resolved (e.g. the style object is missing). +pub fn mline_lines(m: &MLine, document: &acadrust::CadDocument) -> Vec { + use acadrust::entities::{MLineFlags, MLineJustification}; + use acadrust::objects::ObjectType; + use acadrust::types::Color; + + if m.vertices.is_empty() { + return Vec::new(); + } + + // MLINESTYLE lookup: prefer the hard-pointer handle, fall back to the name. + let style = m + .style_handle + .and_then(|h| match document.objects.get(&h) { + Some(ObjectType::MLineStyle(s)) => Some(s), + _ => None, + }) + .or_else(|| { + document.objects.values().find_map(|o| match o { + ObjectType::MLineStyle(s) if s.name.eq_ignore_ascii_case(&m.style_name) => Some(s), + _ => None, + }) + }); + + // (offset, colour, linetype) per element. + let elems: Vec<(f64, Color, String)> = match style { + Some(s) if !s.elements.is_empty() => s + .elements + .iter() + .map(|e| (e.offset, e.color, e.linetype.clone())) + .collect(), + _ => vec![ + (0.5, Color::ByLayer, "ByLayer".to_string()), + (-0.5, Color::ByLayer, "ByLayer".to_string()), + ], + }; + + // Justification shifts every element so the picked path runs along the top / + // centre / bottom element of the style. + let mut lo = f64::INFINITY; + let mut hi = f64::NEG_INFINITY; + for (o, _, _) in &elems { + lo = lo.min(*o); + hi = hi.max(*o); + } + let shift = match m.justification { + MLineJustification::Top => -hi, + MLineJustification::Bottom => -lo, + MLineJustification::Zero => 0.0, + }; + + let scale = m.scale_factor; + let closed = m.flags.contains(MLineFlags::CLOSED); + + // Offset a vertex along its miter direction by `d` drawing units. + let off = |vi: usize, d: f64| -> [f64; 3] { + let v = &m.vertices[vi]; + [ + v.position.x + v.miter.x * d, + v.position.y + v.miter.y * d, + v.position.z + v.miter.z * d, + ] + }; + + let mut out: Vec = Vec::with_capacity(elems.len() + 2); + for (offset, color, linetype) in &elems { + let d = (*offset + shift) * scale; + let mut pts: Vec<[f64; 3]> = (0..m.vertices.len()).map(|i| off(i, d)).collect(); + if closed && m.vertices.len() >= 2 { + pts.push(off(0, d)); + } + out.push(MLineLine { + points: pts, + color: *color, + linetype: linetype.clone(), + }); + } + + // End caps: a segment across the full style width at the first / last vertex, + // drawn only when the style requests square caps, the style has width, and + // the multiline is open. + if let Some(s) = style { + let d_lo = (lo + shift) * scale; + let d_hi = (hi + shift) * scale; + if (d_hi - d_lo).abs() > 1e-9 && !closed { + let mut cap = |vi: usize| { + out.push(MLineLine { + points: vec![off(vi, d_lo), off(vi, d_hi)], + color: Color::ByLayer, + linetype: "ByLayer".to_string(), + }); + }; + if s.flags.start_square_cap { + cap(0); + } + if s.flags.end_square_cap { + cap(m.vertices.len() - 1); + } + } + } + + out +} + impl TruckConvertible for MLine { - fn to_truck(&self, _document: &acadrust::CadDocument) -> Option { + fn to_truck(&self, document: &acadrust::CadDocument) -> Option { if self.vertices.is_empty() { return None; } - let n = self.vertices.len(); - let closed = self.flags.contains(acadrust::entities::MLineFlags::CLOSED); - - // Spine: center line connecting all vertex positions. - // Also attempt to draw parallel offset lines (±scale/2 in miter direction) - // when scale_factor is non-zero. - let scale = self.scale_factor; - + // NaN-separated flat list of every element line (single-colour path used + // by pick and the edit commands; the coloured render is built in + // `tessellate`, which special-cases MLINE). + let lines = mline_lines(self, document); let mut pts: Vec<[f64; 3]> = Vec::new(); - - // Center spine. - for v in &self.vertices { - pts.push([v.position.x, v.position.y, v.position.z]); - } - if closed && n >= 2 { - pts.push([ - self.vertices[0].position.x, - self.vertices[0].position.y, - self.vertices[0].position.z, - ]); - } - - // Parallel offset lines — one at +scale/2 and one at -scale/2 - // along each vertex's miter direction. - if scale.abs() > 1e-6 { - let half = scale * 0.5; - for sign in [-1.0_f64, 1.0_f64] { - let offset = half * sign; + for (i, l) in lines.iter().enumerate() { + if i > 0 { pts.push([f64::NAN; 3]); - for v in &self.vertices { - let mx = v.miter.x; - let my = v.miter.y; - let mz = v.miter.z; - pts.push([ - v.position.x + mx * offset, - v.position.y + my * offset, - v.position.z + mz * offset, - ]); - } - if closed && n >= 2 { - let v0 = &self.vertices[0]; - let mx = v0.miter.x; - let my = v0.miter.y; - let mz = v0.miter.z; - pts.push([ - v0.position.x + mx * offset, - v0.position.y + my * offset, - v0.position.z + mz * offset, - ]); - } - } - - // Start and end caps: perpendicular line connecting the two offset lines - // at the first and last vertex of an open MLine. - if !closed { - let cap_v = |v: &acadrust::entities::MLineVertex| { - let mx = v.miter.x; - let my = v.miter.y; - let mz = v.miter.z; - let px = v.position.x; - let py = v.position.y; - let pz = v.position.z; - [ - [f64::NAN; 3], - [px + mx * (-half), py + my * (-half), pz + mz * (-half)], - [px + mx * half, py + my * half, pz + mz * half], - ] - }; - pts.extend_from_slice(&cap_v(&self.vertices[0])); - pts.extend_from_slice(&cap_v(&self.vertices[n - 1])); } + pts.extend_from_slice(&l.points); } let key_verts: Vec<[f64; 3]> = self diff --git a/src/io/linetypes.rs b/src/io/linetypes.rs index 3c67f0f75..b5f004f62 100644 --- a/src/io/linetypes.rs +++ b/src/io/linetypes.rs @@ -8,7 +8,7 @@ use rustc_hash::FxHashMap as HashMap; use std::sync::OnceLock; -use acadrust::tables::linetype::{LineType, LineTypeElement}; +use acadrust::tables::linetype::{LineType, LineTypeComplexContent, LineTypeElement}; use acadrust::{CadDocument, TableEntry}; // ── Complex linetype types ──────────────────────────────────────────────── @@ -69,6 +69,112 @@ pub fn complex_lt(name: &str) -> Option<&'static ComplexLt> { .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 }, + rot_deg: c.rotation as f32, + }); + } + // Embedded SHAPE glyphs need a .shp set we don't ship; skip the + // draw (matches the bundled-catalog behaviour). + LineTypeComplexContent::Shape { .. } => {} + } + } + } + + // 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))?; + if !lt.elements.iter().any(|e| e.complex.is_some()) { + 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"); // ── Pattern art extraction ──────────────────────────────────────────────── diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 1c954e92c..ba8aa117f 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -682,12 +682,12 @@ pub(crate) fn tessellate_entity( // linetype assigned, so we only consult the first wire here — multi-wire // returns come exclusively from MTEXT colour splits which can't trigger // this path. - if let Some(clt) = crate::io::linetypes::complex_lt(lt_name) { + if let Some(clt) = crate::io::linetypes::resolve_complex_lt(document, lt_name) { if let Some(base) = bases.first() { let mut wires = text::complex_lt::apply_along( &base.name, &base.points, - clt, + &clt, (lt_scale * pslt_factor).max(1e-4), entity_color, sel, diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 29c7e22b0..0483970a4 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -68,6 +68,52 @@ fn points_to_ds( (high, low) } +/// Lift a WireModel built in a local frame (a fixed f64 origin subtracted) back +/// to absolute world coordinates, re-splitting every position — polyline points +/// and SDF glyph vertices — into double-single so it stays precise at UTM scale. +/// The MLINE complex-linetype path uses this because `apply_along` lays out its +/// dashes and glyphs in f32, which would otherwise quantise fine spacing far +/// from the origin. +fn shift_wire_to_world(w: &mut WireModel, origin: [f64; 3]) { + if !w.points.is_empty() { + let mut hi = Vec::with_capacity(w.points.len()); + let mut lo = Vec::with_capacity(w.points.len()); + for p in &w.points { + if p[0].is_nan() { + hi.push([f32::NAN; 3]); + lo.push([0.0; 3]); + continue; + } + let (h, l) = split_ds_xyz( + p[0] as f64 + origin[0], + p[1] as f64 + origin[1], + p[2] as f64 + origin[2], + ); + hi.push(h); + lo.push(l); + } + w.points = hi; + w.points_low = lo; + } + for tv in &mut w.text_verts { + let (h, l) = split_ds_xyz( + tv.pos[0] as f64 + tv.pos_low[0] as f64 + origin[0], + tv.pos[1] as f64 + tv.pos_low[1] as f64 + origin[1], + tv.pos[2] as f64 + tv.pos_low[2] as f64 + origin[2], + ); + tv.pos = h; + tv.pos_low = l; + } + if w.aabb != WireModel::UNBOUNDED_AABB { + w.aabb = [ + w.aabb[0] + origin[0] as f32, + w.aabb[1] + origin[1] as f32, + w.aabb[2] + origin[0] as f32, + w.aabb[3] + origin[1] as f32, + ]; + } +} + // ── Public entry points ──────────────────────────────────────────────────── /// Tessellate one entity into a WireModel. @@ -139,6 +185,172 @@ pub fn tessellate( )]; } + // MLINE emits one WireModel per style element so each parallel line keeps + // its own colour and linetype — a red Continuous line under a yellow dashed + // line reads as the two-tone multiline the style defines. Handled here, like + // Leader, because the single-colour truck `Lines` path can't carry + // per-element colour. + if let EntityType::MLine(m) = entity { + let lines = crate::entities::mline::mline_lines(m, document); + if lines.is_empty() { + return vec![]; + } + let lt_scale = + document.header.linetype_scale as f32 * m.common.linetype_scale as f32; + let snap_pts: Vec<(glam::DVec3, SnapHint)> = m + .vertices + .iter() + .map(|v| { + ( + glam::DVec3::new(v.position.x, v.position.y, v.position.z), + SnapHint::Node, + ) + }) + .collect(); + let key_vertices: Vec<[f64; 3]> = m + .vertices + .iter() + .map(|v| [v.position.x, v.position.y, v.position.z]) + .collect(); + + // Local-frame origin (mline start) for the CPU-dashed / glyph-laid + // elements. `apply_along` walks positions in f32, which quantises fine + // spacing — dash gaps AND inter-glyph advance — at UTM coordinates + // (the low half of the double-single is dropped). Subtracting this f64 + // origin first keeps the walk near zero and precise; the result is + // shifted back to absolute double-single afterwards. Mirrors the + // Tolerance frame, which also builds geometry locally and applies its + // f64 origin later. + let origin = [ + m.vertices[0].position.x, + m.vertices[0].position.y, + m.vertices[0].position.z, + ]; + let mut out: Vec = Vec::with_capacity(lines.len()); + let mut snap_attached = false; + for l in lines { + if l.points.is_empty() { + continue; + } + // Element colour: ByLayer / ByBlock inherit the entity's resolved + // colour; an explicit ACI / true-colour is used as-is. + let wcolor = if selected { + WireModel::SELECTED + } else { + match l.color { + AcadColor::ByLayer | AcadColor::ByBlock => entity_color, + other => { + let [r, g, b, _] = + crate::scene::convert::tess_util::aci_to_rgba(&other); + [r, g, b, entity_color[3]] + } + } + }; + let aci = match l.color { + AcadColor::Index(i) => i, + _ => 0, + }; + + // Every dashed element is CPU-dashed by `apply_along` (not the GPU + // pattern) so all parallel lines walk the polyline with the *same* + // arithmetic and stay in phase — otherwise a shader-dashed line and an + // apply_along-dashed sibling drift apart at large (UTM) coordinates and + // one line's dash lands in the other's gap, striking through embedded + // text. Document definition wins over the bundled catalog; a + // continuous element yields `None` and falls to the solid path below. + // Selection forces a plain solid highlight, so skip dashing then. + let clt = if selected { + None + } else { + crate::io::linetypes::document_lt_segments(document, &l.linetype) + .or_else(|| crate::io::linetypes::complex_lt(&l.linetype).cloned()) + }; + + let mut elem_wires: Vec = if let Some(clt) = clt { + // Walk the dash / glyph layout in a local frame so the f32 math + // stays precise, then lift each wire back to world DS. + let local: Vec<[f32; 3]> = l + .points + .iter() + .map(|p| { + [ + (p[0] - origin[0]) as f32, + (p[1] - origin[1]) as f32, + (p[2] - origin[2]) as f32, + ] + }) + .collect(); + let mut w = crate::scene::text::complex_lt::apply_along( + &name, + &local, + &clt, + lt_scale.max(1e-4), + wcolor, + selected, + line_weight_px, + ); + for wm in &mut w { + wm.aci = aci; + shift_wire_to_world(wm, origin); + } + w + } else { + Vec::new() + }; + + // Simple path: the linetype isn't complex, or `apply_along` bailed + // (pattern blow-up guard) and returned nothing — draw the element as a + // dashed / solid polyline so it is never lost. + if elem_wires.is_empty() { + let (pts, pts_low) = points_to_ds(l.points); + let (pattern_length, pattern) = if selected { + (0.0, [0.0; 8]) + } else { + crate::scene::view::render::resolve_pattern( + &document.line_types, + &l.linetype, + lt_scale, + ) + }; + elem_wires.push(WireModel { + text_verts: Vec::new(), + name: name.clone(), + points: pts, + points_low: pts_low, + color: wcolor, + selected, + pattern_length, + pattern, + line_weight_px, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: false, + vp_scissor: None, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + }); + } + + // Snap / key vertices ride the first emitted wire only (they describe + // the whole entity, not one element). + if !snap_attached { + if let Some(w0) = elem_wires.first_mut() { + w0.snap_pts = snap_pts.clone(); + w0.key_vertices = key_vertices.clone(); + snap_attached = true; + } + } + out.append(&mut elem_wires); + } + if out.is_empty() { + return vec![]; + } + return out; + } + // ── Try the truck path first ─────────────────────────────────────────── // Relative-PDSIZE points size their glyph from the current zoom so they // stay a roughly constant on-screen size; otherwise the header-driven path. diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index c0c1c7ef5..f05934486 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -185,9 +185,12 @@ pub fn apply_along( &mut atlas, *tx_scale, fwd_angle, + 1.0, // width_factor + 0.0, // oblique + // tracking = 1.0 applies the font's natural + // letter_spacing; 0.0 zeroes it and cramps the glyphs + // (every other text path passes 1.0). 1.0, - 0.0, - 0.0, style, false, &resolved,