From 765e6606e2173956d6e92b3659146da86367a723 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 16 Jul 2026 22:58:29 +0300 Subject: [PATCH] feat(dgn-ls): render DET pipe as dashed double walls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DGN line-style linetypes (empty standard LTYPE) drew only end symbols on a single centre line. Render the host polyline as two parallel walls offset by ±(symbol radius = block extent / scale) so each wall sits tangent to the end circles, and dash them with the line style's stroke pattern. The stroke dash lengths live in the component leaf (byte-aligned big-endian f64 after the shared class GUID). `wall_dashes` reads the native dash of the root compound's dash stroke ([5.25, 7.125] for DET); `stroke_dashes` decodes the leaf. The native lengths scale to drawing units by f = radius / symbol-scale (0.1875) — the same factor that maps the compound's native offset (2.0) onto the measured wall offset (0.375), which also lands the dash at ~1.0 drawing unit. Walls dash through the normal WireModel pattern (shader rebuilds each segment relative-to-eye, so it stays precise at UTM coordinates). Co-Authored-By: Claude Opus 4.8 --- src/scene/convert/dgn_linestyle.rs | 168 +++++++++++++++++++++++++++++ src/scene/convert/tess.rs | 66 ++++++++++++ 2 files changed, 234 insertions(+) diff --git a/src/scene/convert/dgn_linestyle.rs b/src/scene/convert/dgn_linestyle.rs index d5babf64d..d77b16648 100644 --- a/src/scene/convert/dgn_linestyle.rs +++ b/src/scene/convert/dgn_linestyle.rs @@ -73,6 +73,174 @@ fn walk(doc: &CadDocument, h: Handle, out: &mut Vec, seen: &mut HashS } } +/// Native dash lengths in a stroke component's leaf. The DGN line-style leaf is +/// byte-aligned big-endian f64 (MicroStation origin, not DWG bit-codes); the +/// stroke's dash/gap values sit in an 8-byte-aligned run after the shared +/// 16-byte class GUID (whose fixed tail is `ae da 14`). Values outside a sane +/// size band are skipped (denormals / stream-boundary bytes). Empty when the +/// object carries no raw snapshot or no plausible lengths. +fn stroke_dashes(doc: &CadDocument, h: Handle) -> Vec { + use acadrust::objects::ObjectType; + let Some(ObjectType::Unknown { + raw_dwg_data: Some(d), + .. + }) = doc.objects.get(&h) + else { + return Vec::new(); + }; + let Some(gi) = d.windows(3).position(|w| w == [0xae, 0xda, 0x14]) else { + return Vec::new(); + }; + // GUID tail (3) + version/type bytes (3) → the first length field. + let start = gi + 6; + let mut out = Vec::new(); + let mut i = start; + while i + 8 <= d.len() { + let v = f64::from_be_bytes(d[i..i + 8].try_into().unwrap()); + if v.is_finite() && v.abs() >= 0.01 && v.abs() < 1.0e4 { + out.push(v); + } + i += 8; + } + out +} + +/// Native dash pattern of a DGN line style's pipe walls: the dash lengths of the +/// first stroke that is a **direct** child of the root compound and carries at +/// least two values (a dash + a gap). The base stroke a point component sits on +/// (a single long length) is intentionally skipped — it is the solid placement +/// guide, not the visible dash. Empty for a solid style. +pub fn wall_dashes(doc: &CadDocument, lt_name: &str) -> Vec { + let Some(def) = doc + .dgn_ls_definitions + .values() + .find(|d| d.name.eq_ignore_ascii_case(lt_name)) + else { + return Vec::new(); + }; + let Some(root) = doc.dgn_ls_components.get(&def.root_component) else { + return Vec::new(); + }; + for r in &root.refs { + if doc.dgn_ls_components.get(r).map(|c| c.component_type) + == Some(DgnLsComponentType::Stroke) + { + let dashes = stroke_dashes(doc, *r); + if dashes.len() >= 2 { + return dashes; + } + } + } + Vec::new() +} + +/// Rendered half-width of a symbol block: its geometry's largest extent from +/// the block base point, divided by the symbol scale (the same divisor +/// [`place_block_wires`] draws it at). For a pipe end-circle this is the circle +/// radius, which is also the offset of the two pipe walls (they sit tangent to +/// the end circles) — so it doubles as the rail offset for the double line. +pub fn symbol_radius(doc: &CadDocument, block: Handle, scale: f64) -> f64 { + let Some(br) = doc.block_records.iter().find(|b| b.handle == block) else { + return 0.0; + }; + let bx = br.base_point.x; + let by = br.base_point.y; + let d = |x: f64, y: f64| ((x - bx).powi(2) + (y - by).powi(2)).sqrt(); + let mut r = 0.0_f64; + for eh in &br.entity_handles { + let Some(e) = doc.get_entity(*eh) else { + continue; + }; + let ext = match e { + EntityType::Ellipse(el) => { + let a = (el.major_axis.x.powi(2) + el.major_axis.y.powi(2)).sqrt(); + d(el.center.x, el.center.y) + a + } + EntityType::Circle(c) => d(c.center.x, c.center.y) + c.radius, + EntityType::Arc(a) => d(a.center.x, a.center.y) + a.radius, + EntityType::Line(l) => d(l.start.x, l.start.y).max(d(l.end.x, l.end.y)), + _ => 0.0, + }; + r = r.max(ext); + } + let s = if scale.abs() > 1e-9 { scale } else { 1.0 }; + r / s +} + +/// Per-vertex left-normal offset of an XY polyline by `d` (signed). Uses the +/// averaged adjacent-segment normal at interior vertices — good for the gentle +/// bends of a pipe run; sharp corners are not mitred. +fn offset_xy(pts: &[[f64; 3]], d: f64) -> Vec<[f64; 2]> { + let n = pts.len(); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let (ax, ay) = if i == 0 { + (pts[1][0] - pts[0][0], pts[1][1] - pts[0][1]) + } else { + (pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]) + }; + let (bx, by) = if i + 1 < n { + (pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) + } else { + (ax, ay) + }; + let na = (ax * ax + ay * ay).sqrt().max(1e-12); + let nb = (bx * bx + by * by).sqrt().max(1e-12); + let tx = ax / na + bx / nb; + let ty = ay / na + by / nb; + let tn = (tx * tx + ty * ty).sqrt().max(1e-12); + // Left normal of the averaged tangent. + out.push([pts[i][0] - d * (ty / tn), pts[i][1] + d * (tx / tn)]); + } + out +} + +/// Clone the host polyline with every vertex offset perpendicular by `d` — one +/// wall of the pipe. Returns `None` for entity kinds without an XY vertex list. +pub fn offset_host_entity(e: &EntityType, d: f64) -> Option { + let mut clone = e.clone(); + match &mut clone { + EntityType::LwPolyline(p) => { + if p.vertices.len() < 2 { + return None; + } + let pts: Vec<[f64; 3]> = p + .vertices + .iter() + .map(|v| [v.location.x, v.location.y, 0.0]) + .collect(); + for (v, o) in p.vertices.iter_mut().zip(offset_xy(&pts, d)) { + v.location.x = o[0]; + v.location.y = o[1]; + } + } + EntityType::Polyline2D(p) => { + if p.vertices.len() < 2 { + return None; + } + let pts: Vec<[f64; 3]> = p + .vertices + .iter() + .map(|v| [v.location.x, v.location.y, 0.0]) + .collect(); + for (v, o) in p.vertices.iter_mut().zip(offset_xy(&pts, d)) { + v.location.x = o[0]; + v.location.y = o[1]; + } + } + EntityType::Line(l) => { + let pts = [[l.start.x, l.start.y, 0.0], [l.end.x, l.end.y, 0.0]]; + let o = offset_xy(&pts, d); + l.start.x = o[0][0]; + l.start.y = o[0][1]; + l.end.x = o[1][0]; + l.end.y = o[1][1]; + } + _ => return None, + } + Some(clone) +} + /// Host entity's polyline vertices in WCS f64 (consecutive duplicates dropped). pub fn polyline_points(e: &EntityType) -> Vec<[f64; 3]> { let mut v: Vec<[f64; 3]> = match e { diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 9b27fab17..bef941704 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -710,6 +710,72 @@ pub(crate) fn tessellate_entity( if !dgn_syms.is_empty() { let verts = convert::dgn_linestyle::polyline_points(e); if verts.len() >= 2 { + // The pipe body is drawn as two parallel walls, not a single centre + // line: offset the host polyline by ±(symbol radius) so each wall + // sits tangent to the end circles, and replace the centre line with + // them. The radius is the rendered symbol extent (block / scale). + let radius = dgn_syms + .iter() + .map(|s| convert::dgn_linestyle::symbol_radius(document, s.block, s.scale)) + .fold(0.0_f64, f64::max); + if radius > 1e-6 { + // The walls carry the line style's dash pattern. Its native + // lengths scale to drawing units by f = radius / symbol-scale + // (the same factor that turns the compound's native offset into + // the measured wall offset). Sign-alternate: dash, gap, dash… + let scale = dgn_syms + .iter() + .map(|s| s.scale) + .find(|s| *s > 1e-9) + .unwrap_or(1.0); + let f = radius / scale; + let native = convert::dgn_linestyle::wall_dashes(document, lt_name); + let (wall_pat, wall_pat_len) = if f > 1e-9 && native.len() >= 2 { + let mut pat = [0.0_f32; 8]; + let mut plen = 0.0_f32; + for (i, &d) in native.iter().take(8).enumerate() { + let v = (d * f) as f32; + pat[i] = if i % 2 == 0 { v } else { -v }; + plen += v; + } + (pat, plen) + } else { + ([0.0_f32; 8], 0.0) + }; + let mut rails = Vec::new(); + for sgn in [1.0_f64, -1.0] { + if let Some(off) = + convert::dgn_linestyle::offset_host_entity(e, sgn * radius) + { + let mut w = convert::tessellate::tessellate( + document, + h, + &off, + sel, + entity_color, + pattern_length, + pattern, + line_weight_px, + anno_scale, + world_per_pixel, + bg_color, + false, + ); + for x in &mut w { + x.aci = aci; + x.aabb = aabb; + if wall_pat_len > 1e-6 { + x.pattern = wall_pat; + x.pattern_length = wall_pat_len; + } + } + rails.append(&mut w); + } + } + if !rails.is_empty() { + bases = rails; + } + } let last = *verts.last().unwrap(); for (i, sym) in dgn_syms.iter().enumerate() { let at = if i == 0 { verts[0] } else { last };