// Tessellation — convert opencadcodec EntityType to GPU-ready WireModel or MeshModel. // // Flow: // EntityType // ↓ acad_to_render::convert() // RenderEntity { object: RenderObject, snap_pts, tangent_geoms, key_vertices } // ↓ // RenderObject::Lines → WireModel // RenderObject::Dot → WireModel (a dot sized in pixels) // RenderObject::Text → WireModel (glyph strokes) + SDF quads // RenderObject::Text → one WireModel per glyph stroke (elevation from entity Z) // // Entities not handled by acad_to_render (Viewport, Insert, Hatch, Ole2Frame) // are tessellated by the FallbackTess fallback_geometry() path. use crate::entities::leader::LeaderTess; use codec::types::Color as AcadColor; use codec::{CadDocument, EntityType, Handle}; use glam::Vec3; use crate::scene::convert::acad_to_render::{convert, RenderObject}; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; /// Split an f64 offset-relative coordinate into the double-single (high, low) /// f32 pair the renderer consumes. `high + low ≈ value` to ~f64 precision; the /// RTE shader subtracts the eye's own high/low so vertices stay smooth even at /// coordinates where a plain f32 cast would quantize to half a metre. #[inline] fn split_ds(v: f64) -> (f32, f32) { let h = v as f32; let l = (v - h as f64) as f32; (h, l) } #[inline] fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) { let (xh, xl) = split_ds(x); let (yh, yl) = split_ds(y); let (zh, zl) = split_ds(z); ([xh, yh, zh], [xl, yl, zl]) } fn oriented_text_corners( verts: &[crate::scene::pipeline::text_gpu::TextVertex], origin: [f64; 2], rotation: f64, pad: f64, ) -> [[f64; 2]; 4] { let (sin_r, cos_r) = rotation.sin_cos(); let mut bounds = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; for vertex in verts { let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0]; let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1]; let local_x = x * cos_r + y * sin_r; let local_y = -x * sin_r + y * cos_r; bounds[0] = bounds[0].min(local_x); bounds[1] = bounds[1].min(local_y); bounds[2] = bounds[2].max(local_x); bounds[3] = bounds[3].max(local_y); } let [left, bottom, right, top] = [ bounds[0] - pad, bounds[1] - pad, bounds[2] + pad, bounds[3] + pad, ]; let to_world = |x: f64, y: f64| { [ origin[0] + x * cos_r - y * sin_r, origin[1] + x * sin_r + y * cos_r, ] }; [ to_world(left, bottom), to_world(right, bottom), to_world(right, top), to_world(left, top), ] } fn oriented_mtext_corner_groups( verts: &[crate::scene::pipeline::text_gpu::TextVertex], text: &codec::MText, rotation: f64, pad: f64, annotation_scale: f64, ) -> Vec<[[f64; 2]; 4]> { let columns = &text.column_data; let count = crate::entities::text_support::clamp_mtext_column_count(columns.column_count) as usize; if columns.column_type == 0 || count <= 1 || columns.width <= 0.0 { return vec![oriented_text_corners( verts, [text.insertion_point.x, text.insertion_point.y], rotation, pad, )]; } let width = columns.width * annotation_scale; let gutter = columns.gutter.max(0.0) * annotation_scale; let total_width = width * count as f64 + gutter * count.saturating_sub(1) as f64; let anchor = match text.attachment_point { codec::entities::mtext::AttachmentPoint::TopCenter | codec::entities::mtext::AttachmentPoint::MiddleCenter | codec::entities::mtext::AttachmentPoint::BottomCenter => 0.5, codec::entities::mtext::AttachmentPoint::TopRight | codec::entities::mtext::AttachmentPoint::MiddleRight | codec::entities::mtext::AttachmentPoint::BottomRight => 1.0, _ => 0.0, }; let block_left = -anchor * total_width; let origin = [text.insertion_point.x, text.insertion_point.y]; let (sin_r, cos_r) = rotation.sin_cos(); let mut bounds = vec![[f64::MAX, f64::MAX, f64::MIN, f64::MIN]; count]; for vertex in verts { let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0]; let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1]; let local_x = x * cos_r + y * sin_r; let local_y = -x * sin_r + y * cos_r; let stride = width + gutter; let physical = ((local_x - block_left) / stride) .floor() .clamp(0.0, count.saturating_sub(1) as f64) as usize; bounds[physical][0] = bounds[physical][0].min(local_x); bounds[physical][1] = bounds[physical][1].min(local_y); bounds[physical][2] = bounds[physical][2].max(local_x); bounds[physical][3] = bounds[physical][3].max(local_y); } let to_world = |x: f64, y: f64| { [ origin[0] + x * cos_r - y * sin_r, origin[1] + x * sin_r + y * cos_r, ] }; bounds .into_iter() .filter(|bounds| bounds[0] <= bounds[2] && bounds[1] <= bounds[3]) .map(|bounds| { let [left, bottom, right, top] = [ bounds[0] - pad, bounds[1] - pad, bounds[2] + pad, bounds[3] + pad, ]; [ to_world(left, bottom), to_world(right, bottom), to_world(right, top), to_world(left, top), ] }) .collect() } pub(crate) fn explicit_mtext_background(entity: &EntityType) -> Option<[f32; 4]> { let EntityType::MText(text) = entity else { return None; }; if text.background_fill_flags & 0x01 == 0 || text.background_fill_flags & 0x02 != 0 { return None; } text.background_color.rgb().map(|(r, g, b)| { [ r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0, ] }) } pub(crate) fn text_contrast_background( entity: &EntityType, canvas: [f32; 4], ) -> [f32; 4] { explicit_mtext_background(entity).unwrap_or(canvas) } /// Split each absolute f64 source point into double-single (high, low) f32 /// buffers in one pass — the relative-to-eye residual the GPU/CPU reconstruct /// to f64 precision at UTM-scale coordinates. pub(crate) fn points_to_ds( src: impl IntoIterator, ) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { let it = src.into_iter(); let (lo, hi) = it.size_hint(); let cap = hi.unwrap_or(lo); if cap == 0 { return (Vec::new(), Vec::new()); } let mut high = Vec::with_capacity(cap); let mut low = Vec::with_capacity(cap); for [x, y, z] in it { if x.is_nan() { // Wire-model NaN-separator: keep both buffers index-paired. high.push([f32::NAN; 3]); low.push([0.0; 3]); continue; } let (h, l) = split_ds_xyz(x, y, z); high.push(h); low.push(l); } (high, low) } fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> { match entity { EntityType::LwPolyline(p) => { let count = p.vertices.len(); let seg_count = if p.is_closed { count } else { count.saturating_sub(1) }; let c = p.constant_width; (0..seg_count) .map(|i| { let v = &p.vertices[i]; let (sw, ew) = if v.start_width > 1e-9 || v.end_width > 1e-9 { (v.start_width, v.end_width) } else { (c, c) }; (sw as f32, ew as f32) }) .collect() } EntityType::Polyline2D(p) => { let filtered = crate::entities::polyline::drawn_vertices2d(p); let verts: &[codec::entities::Vertex2D] = filtered.as_deref().unwrap_or(&p.vertices); let count = verts.len(); let seg_count = if p.is_closed() { count } else { count.saturating_sub(1) }; let def_start = p.start_width; let def_end = p.end_width; (0..seg_count) .map(|i| { let v = &verts[i]; let (sw, ew) = if v.start_width > 1e-9 || v.end_width > 1e-9 { (v.start_width, v.end_width) } else { (def_start, def_end) }; (sw as f32, ew as f32) }) .collect() } _ => Vec::new(), } } fn split_mixed_polyline( tangent_geoms: &[TangentGeom], key_vertices: &[[f64; 3]], name: &str, color: [f32; 4], selected: bool, pattern_length: f32, pattern: [f32; 8], line_weight_px: f32, snap_pts: Vec<(glam::DVec3, SnapHint)>, point_marker: Option, plinegen: bool, seg_widths: &[(f32, f32)], global_world_width: f32, mut pick_tris: Vec<[f32; 3]>, mut pick_tris_low: Vec<[f32; 3]>, ) -> Vec { let mut out = Vec::new(); // Emit each arc segment as an analytical wire (CircleGpu target) for (i, tg) in tangent_geoms.iter().enumerate() { if let TangentGeom::Arc { center, axis_x, axis_y, radius, start_angle, end_angle, } = *tg { let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { (w0, w1) } else if global_world_width > 1e-9 { (global_world_width, global_world_width) } else { (0.0, 0.0) }; // Check if the CCW start_angle of this TangentGeom::Arc corresponds // to the segment's end vertex (clockwise bulge arc in CAD where // bulge_arc_to_tangent inverted start_angle and end_angle to enforce CCW). let (p_seg_start, p_seg_end) = if i + 1 < key_vertices.len() { (key_vertices[i], key_vertices[i + 1]) } else if !key_vertices.is_empty() { ( key_vertices[i % key_vertices.len()], key_vertices[(i + 1) % key_vertices.len()], ) } else { ([0.0; 3], [0.0; 3]) }; let p_sa = [ center[0] + radius * (start_angle.cos() * axis_x[0] + start_angle.sin() * axis_y[0]), center[1] + radius * (start_angle.cos() * axis_x[1] + start_angle.sin() * axis_y[1]), center[2] + radius * (start_angle.cos() * axis_x[2] + start_angle.sin() * axis_y[2]), ]; let is_reversed = if !key_vertices.is_empty() { let d_start_sq = (p_sa[0] - p_seg_start[0]).powi(2) + (p_sa[1] - p_seg_start[1]).powi(2) + (p_sa[2] - p_seg_start[2]).powi(2); let d_end_sq = (p_sa[0] - p_seg_end[0]).powi(2) + (p_sa[1] - p_seg_end[1]).powi(2) + (p_sa[2] - p_seg_end[2]).powi(2); d_end_sq < d_start_sq } else { false }; let (w_at_sa, w_at_ea) = if is_reversed { (ew, sw) } else { (sw, ew) }; let n = 16; let mut points = Vec::with_capacity(n + 1); let mut points_low = Vec::with_capacity(n + 1); let mut arc_widths = Vec::with_capacity(n + 1); let sweep = if end_angle >= start_angle { end_angle - start_angle } else { end_angle - start_angle + std::f64::consts::TAU }; for s in 0..=n { let frac = s as f64 / n as f64; let ang = start_angle + frac * sweep; let (sin_a, cos_a) = ang.sin_cos(); let x = center[0] + radius * (cos_a * axis_x[0] + sin_a * axis_y[0]); let y = center[1] + radius * (cos_a * axis_x[1] + sin_a * axis_y[1]); let z = center[2] + radius * (cos_a * axis_x[2] + sin_a * axis_y[2]); let (h, l) = split_ds_xyz(x, y, z); points.push(h); points_low.push(l); arc_widths.push(w_at_sa + (w_at_ea - w_at_sa) * frac as f32); } let taper_widths = if (w_at_sa - w_at_ea).abs() > 1e-6 { arc_widths } else { Vec::new() }; let world_width = sw.max(ew); let (arc_pt, arc_ptl) = if out.is_empty() && !pick_tris.is_empty() { (std::mem::take(&mut pick_tris), std::mem::take(&mut pick_tris_low)) } else { (Vec::new(), Vec::new()) }; out.push(WireModel { bg_adapt: None, point_marker: None, taper_widths, pattern_stations: Vec::new(), world_width, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: arc_pt, pick_tris_low: arc_ptl, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.to_string(), points, points_low, color, selected, pattern_length, pattern, line_weight_px, snap_pts: Vec::new(), tangent_geoms: vec![tg.clone()], aci: 0, key_vertices: Vec::new(), aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }); } } // Collect straight lines into a line wire let mut straight_pts: Vec<[f64; 3]> = Vec::new(); let mut straight_widths: Vec = Vec::new(); let mut straight_tangents: Vec = Vec::new(); let mut last_end: Option<[f64; 3]> = None; let mut last_end_w: f32 = 0.0; for (i, tg) in tangent_geoms.iter().enumerate() { if let TangentGeom::Line { p1, p2 } = tg { straight_tangents.push(tg.clone()); let p_start = if i + 1 < key_vertices.len() && (key_vertices[i][0] as f32 - p1[0]).abs() < 1e-3 && (key_vertices[i][1] as f32 - p1[1]).abs() < 1e-3 && (key_vertices[i][2] as f32 - p1[2]).abs() < 1e-3 { key_vertices[i] } else { [p1[0] as f64, p1[1] as f64, p1[2] as f64] }; let p_end = if i + 1 < key_vertices.len() && (key_vertices[i + 1][0] as f32 - p2[0]).abs() < 1e-3 && (key_vertices[i + 1][1] as f32 - p2[1]).abs() < 1e-3 && (key_vertices[i + 1][2] as f32 - p2[2]).abs() < 1e-3 { key_vertices[i + 1] } else { [p2[0] as f64, p2[1] as f64, p2[2] as f64] }; let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { (w0, w1) } else if global_world_width > 1e-9 { (global_world_width, global_world_width) } else { (0.0, 0.0) }; if !plinegen { if !straight_pts.is_empty() { straight_pts.push([f64::NAN; 3]); straight_widths.push(0.0); } straight_pts.push(p_start); straight_widths.push(sw); straight_pts.push(p_end); straight_widths.push(ew); } else { if let Some(prev) = last_end { if (prev[0] - p_start[0]).abs() < 1e-7 && (prev[1] - p_start[1]).abs() < 1e-7 && (prev[2] - p_start[2]).abs() < 1e-7 && (last_end_w - sw).abs() < 1e-6 { straight_pts.push(p_end); straight_widths.push(ew); } else { straight_pts.push([f64::NAN; 3]); straight_widths.push(0.0); straight_pts.push(p_start); straight_widths.push(sw); straight_pts.push(p_end); straight_widths.push(ew); } } else { if !straight_pts.is_empty() { straight_pts.push([f64::NAN; 3]); straight_widths.push(0.0); } straight_pts.push(p_start); straight_widths.push(sw); straight_pts.push(p_end); straight_widths.push(ew); } last_end = Some(p_end); last_end_w = ew; } } else { last_end = None; } } let (line_pts, line_pts_low) = points_to_ds(straight_pts); if !line_pts.is_empty() { let (pt, ptl) = if !pick_tris.is_empty() { (pick_tris, pick_tris_low) } else { (Vec::new(), Vec::new()) }; let line_world_width = straight_widths .iter() .copied() .fold(0.0f32, f32::max); let has_line_taper = straight_widths .first() .map_or(false, |&w0| straight_widths.iter().any(|&w| (w - w0).abs() > 1e-6)); let taper_widths = if has_line_taper || straight_widths.iter().any(|&w| w > 1e-6) { straight_widths } else { Vec::new() }; out.push(WireModel { bg_adapt: None, point_marker, taper_widths, pattern_stations: Vec::new(), world_width: line_world_width, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: pt, pick_tris_low: ptl, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.to_string(), points: line_pts, points_low: line_pts_low, color, selected, pattern_length, pattern, line_weight_px, snap_pts, tangent_geoms: straight_tangents, aci: 0, key_vertices: key_vertices.to_vec(), aabb: WireModel::UNBOUNDED_AABB, plinegen, fill_tris: vec![], fill_tris_low: Vec::new(), }); } else if let Some(first_arc) = out.first_mut() { first_arc.snap_pts = snap_pts; first_arc.key_vertices = key_vertices.to_vec(); if first_arc.pick_tris.is_empty() && !pick_tris.is_empty() { first_arc.pick_tris = pick_tris; first_arc.pick_tris_low = pick_tris_low; } } out } fn point_cloud_wires( document: &CadDocument, handle: Handle, entity: &EntityType, selected: bool, color: [f32; 4], line_weight_px: f32, ) -> Option> { let EntityType::Extended(extended) = entity else { return None; }; let frame_points = crate::entities::extended::point_cloud_frame_lines(extended)?; let rendered = convert(entity, document)?; let RenderObject::Lines(body_points) = rendered.object else { return None; }; // A cloud whose scan file is found draws its points instead of its // extents box; the box stays behind, unseen, to pick the cloud, with its // faces so a click among the points selects it too. let mut shown = true; let (mut pick_tris, mut pick_tris_low) = (Vec::new(), Vec::new()); if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data { // Its box shows too with every point hidden (the reference does). shown = crate::scene::model::point_cloud::shows_no_points(document, data); let c = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]); let mut tris = Vec::with_capacity(36); for [a, b, d, e] in [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 4, 5], [2, 3, 6, 7], [0, 2, 4, 6], [1, 3, 5, 7]] { tris.extend([c[a], c[b], c[e], c[a], c[e], c[d]]); } (pick_tris, pick_tris_low) = points_to_ds(tris); } let (points, points_low) = points_to_ds(body_points); let mut wires = vec![WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: shown, snap_only: false, plot_visible: shown, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris, pick_tris_low, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: handle.value().to_string(), points, points_low, color, selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: rendered.snap_pts, tangent_geoms: rendered.tangent_geoms, aci: 0, key_vertices: rendered.key_vertices, aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: Vec::new(), fill_tris_low: Vec::new(), }]; let mode = crate::scene::frame::mode( document, crate::scene::frame::FrameKind::PointCloudClip, ); if !frame_points.is_empty() { let (points, points_low) = points_to_ds(frame_points); let mut frame = WireModel::solid( handle.value().to_string(), points, color, selected, ); frame.points_low = points_low; frame.line_weight_px = line_weight_px; frame.display_visible = mode != 0; frame.plot_visible = mode == 1; wires.push(frame); } Some(wires) } /// 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. pub(crate) 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. /// For Text/MText entities this produces one WireModel with all glyph strokes /// encoded as NaN-separated segments (wire_gpu skips NaN pairs). /// For Solid3D entities this returns an empty wire; mesh tessellation lives /// in `solid3d_tess` and is uploaded via the mesh pipeline instead. pub fn tessellate( document: &CadDocument, handle: Handle, entity: &EntityType, selected: bool, entity_color: [f32; 4], pattern_length: f32, pattern: [f32; 8], line_weight_px: f32, anno_scale: f32, annotation_scale_handle: Option, world_per_pixel: Option, // Canvas background colour — used for the MTEXT background *mask* fill // (flag 0x02, "use drawing window colour") so the mask erases geometry // behind the text the way a wipeout does. bg_color: [f32; 4], // When true, TEXT/MTEXT run-groups ALSO emit their glyph outline strokes as // polyline points (not just SDF quads). The in-app MTEXT editor preview // draws those strokes on a 2D canvas that can't run the SDF shader; every // other caller passes false and gets the normal SDF-only text. force_text_strokes: bool, ) -> Vec { let color = if selected { WireModel::SELECTED } else { entity_color }; let name = handle.value().to_string(); // Determine the effective annotation scale for this entity. // // Only annotative entities are auto-scaled by the current annotation scale; // everything else is manually pre-scaled (old convention with $DIMSCALE and // oversized text). Annotative-ness is resolved centrally from the entity's // per-object context, legacy XDATA, or annotative style (see // `scene::annotative::is_annotative`) so the bake and the panel agree. let anno_scale = crate::scene::annotative::effective_annotation_scale_for( document, entity, anno_scale, annotation_scale_handle, ); // A HATCH is drawn as a fill by the hatch pipeline and highlighted via a // fill tint when selected (issue #71), so it carries no boundary outline in // the wire set. Skipping it here drops the dense boundary polyline — the // dominant wire-instance cost on hatch-heavy drawings (issue #131). Picking // is unaffected: hatches are caught by their fill area through the existing // `pick::hit_test::click_hit_hatch` path, not this outline. if matches!(entity, EntityType::Hatch(_)) { return vec![]; } // MultiLeader is handled by scene/mod.rs since it emits multiple WireModels // (leader, text, frame, fill) with distinct colors. if let EntityType::Leader(leader) = entity { return vec![leader.tessellate( document, handle, selected, entity_color, line_weight_px, anno_scale, )]; } // 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 the kernel `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, ]; // Centre-line (vertex path) length — the shared "A"-type reference so // every parallel element uses the same end-dash length and thus the same // interior phase (perpendicular dashes line up). f64 deltas so it stays // precise at UTM coordinates. let ref_total: f32 = { let mut acc = 0.0_f64; for w in m.vertices.windows(2) { let dx = w[1].position.x - w[0].position.x; let dy = w[1].position.y - w[0].position.y; let dz = w[1].position.z - w[0].position.z; acc += (dx * dx + dy * dy + dz * dz).sqrt(); } if m.is_closed() && m.vertices.len() > 1 { let first = &m.vertices[0].position; let last = &m.vertices[m.vertices.len() - 1].position; let dx = first.x - last.x; let dy = first.y - last.y; let dz = first.z - last.z; acc += (dx * dx + dy * dy + dz * dz).sqrt(); } acc as f32 }; let mut out: Vec = Vec::with_capacity(lines.len()); if let Some(style) = crate::entities::mline::resolved_mline_style(m, document) { let triangles = crate::entities::mline::mline_fill_triangles_with_style(m, style); if !triangles.is_empty() { let (fill_tris, fill_tris_low) = points_to_ds(triangles); let fill_color = if selected { WireModel::SELECTED } else { match style.fill_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]] } } }; out.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: true, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.clone(), points: Vec::new(), points_low: Vec::new(), color: fill_color, selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: Vec::new(), tangent_geoms: Vec::new(), aci: 0, key_vertices: Vec::new(), aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris, fill_tris_low, }); } } 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 if let Some(doc_seg) = crate::io::linetypes::document_lt_segments(document, &l.linetype) { // In-document linetype: CPU-expand (apply_along) only when it // embeds TEXT glyphs that must be laid out along the curve. Pure // dash / space / dot (and undrawn shape) elements fall through to // the GPU dash shader below — cheaper (one WireModel + pattern // instead of N CPU segments) and now UTM-precise, so they stay in // phase with any glyph-bearing sibling element. if doc_seg .segments .iter() .any(|s| matches!(s, crate::io::linetypes::LtSegment::Text { .. })) { Some(doc_seg) } else { None } } else { // Not in the document: bundled-catalog linetype (may embed // text / shape) → keep the CPU path; `resolve_pattern` below can't // see it, so GPU-dashing would drop the pattern to solid. 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, // Shared "A"-type reference: this text-bearing element aligns // with the GPU-dashed sibling elements (same centre-line // reference) instead of tiling independently from the start. Some(ref_total), ); 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, ) }; // Shared "A"-type: derive the begin/end solid-dash length ONCE // from the multiline centre-line (`ref_total`) so every parallel // element runs the same interior phase and its dashes line up // perpendicular; `align_total` stays each element's own length in // the shader so each still ends on a dash. Dash-first patterns // only (`+dash, -gap, …`); shorter-than-a-period lines fall back // to the per-wire path (solid). let dash_align_end = if pattern_length > 1e-6 && pattern[0] > 0.0 && pattern[1] < 0.0 && ref_total > pattern_length { let a = pattern[0]; let p = pattern_length; let k = ((ref_total - a) / p).round().max(1.0); Some(((ref_total - k * p + a) * 0.5).max(1e-4)) } else { None }; elem_wires.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), // MLINE dashes: A-type aligned, but the end-dash length is // shared across all parallel elements (`dash_align_end`) so // their interiors stay in phase (perpendicular dashes line up) // while each still ends on a dash at its own endpoint. dash_from_start: false, dash_align_end, 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, 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; } if let Some(wires) = point_cloud_wires( document, handle, entity, selected, color, line_weight_px, ) { return wires; } // ── Try the kernel path first ─────────────────────────────────────────── // Zoom-adaptive circles scale their segment count from the current zoom level and radius. // Relative-PDSIZE points size their glyph from the current zoom so they // stay a roughly constant on-screen size; otherwise the header-driven path. let te = crate::entities::circle::relative_render(entity, document, world_per_pixel) .or_else(|| crate::entities::point::relative_render(entity, document, world_per_pixel)) .or_else(|| crate::entities::light::relative_render(entity, document, world_per_pixel)) .or_else(|| match entity { EntityType::Text(text) => Some(crate::entities::text::to_render_at_scale( text, document, anno_scale, )), _ => convert(entity, document), }); if let Some(te) = te { match te.object { // ── Text / MText: pre-tessellated glyph strokes ─────────────── // // Strokes are pre-grouped by world origin (one TextStroke per // line / per run / per fragment), each carrying an optional // colour override produced by MTEXT inline `\C` / `\c`. We bin // groups by override colour and emit one WireModel per bin so a // single MTEXT can hand back N colour-distinct wires when the // value mixes inline colours. RenderObject::Text(stroke_groups) => { let entity_zf = entity_z(entity) as f64; let elev_v = entity_zf; // Scale MTEXT around its attachment point. let ref_origin = match entity { EntityType::MText(m) => [ m.insertion_point.x, m.insertion_point.y, ], _ => stroke_groups .first() .map(|g| g.origin) .unwrap_or([0.0, 0.0]), }; let ref_lx_v = ref_origin[0]; let ref_ly_v = ref_origin[1]; // Selection forces a single uniform colour — never split. let split_by_color = !selected; // Bins: key = (Some(rgb), bold). Bold strokes bin separately so // the editor preview can draw them with a wider pen. struct TextBin { color: Option<[f32; 3]>, bold: bool, pts: Vec<[f32; 3]>, pts_low: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, fill_tris_low: Vec<[f32; 3]>, } let mut bins: Vec = Vec::new(); let mut bin_first: Vec = Vec::new(); let find_or_make = |key: Option<[f32; 3]>, bold: bool, bins: &mut Vec, firsts: &mut Vec| -> usize { if let Some(i) = bins.iter().position(|b| b.color == key && b.bold == bold) { i } else { bins.push(TextBin { color: key, bold, pts: Vec::new(), pts_low: Vec::new(), fill_tris: Vec::new(), fill_tris_low: Vec::new(), }); firsts.push(true); bins.len() - 1 } }; let anno = anno_scale as f64; // Run groups normally render as textured quads. Web runs that // require bidi or joined-script shaping keep their already // shaped vector geometry because the per-glyph SDF path has no // cluster-position data. for group in stroke_groups .iter() .filter(|group| { force_text_strokes || group.run.is_none() || group.run.as_ref().is_some_and(|run| { crate::scene::text::web_font::requires_shaping(&run.text) }) }) { let lx_v = group.origin[0]; let ly_v = group.origin[1]; let slx_v = (lx_v - ref_lx_v) * anno + ref_lx_v; let sly_v = (ly_v - ref_ly_v) * anno + ref_ly_v; let bin_key = if split_by_color { group.color } else { None }; let group_bold = group.run.as_ref().is_some_and(|r| r.bold); let bi = find_or_make(bin_key, group_bold, &mut bins, &mut bin_first); // 1. Process outline strokes for stroke in &group.strokes { if stroke.len() < 2 { continue; } if !bin_first[bi] && !bins[bi].pts.is_empty() { bins[bi].pts.push([f32::NAN, f32::NAN, f32::NAN]); bins[bi].pts_low.push([0.0; 3]); } bin_first[bi] = false; for &[x, y] in stroke { let world = if let Some(plane) = group.plane { let scaled_origin = [ plane.scale_origin[0] + (plane.origin[0] - plane.scale_origin[0]) * anno, plane.scale_origin[1] + (plane.origin[1] - plane.scale_origin[1]) * anno, plane.scale_origin[2] + (plane.origin[2] - plane.scale_origin[2]) * anno, ]; let x = x as f64 * anno; let y = y as f64 * anno; [ scaled_origin[0] + plane.x_axis[0] * x + plane.y_axis[0] * y, scaled_origin[1] + plane.x_axis[1] * x + plane.y_axis[1] * y, scaled_origin[2] + plane.x_axis[2] * x + plane.y_axis[2] * y, ] } else { [x as f64 * anno + slx_v, y as f64 * anno + sly_v, elev_v] }; let (h, l) = split_ds_xyz(world[0], world[1], world[2]); bins[bi].pts.push(h); bins[bi].pts_low.push(l); } } // 2. Process fill triangles for &[x, y] in &group.fill_tris { let world = if let Some(plane) = group.plane { let scaled_origin = [ plane.scale_origin[0] + (plane.origin[0] - plane.scale_origin[0]) * anno, plane.scale_origin[1] + (plane.origin[1] - plane.scale_origin[1]) * anno, plane.scale_origin[2] + (plane.origin[2] - plane.scale_origin[2]) * anno, ]; let x = x as f64 * anno; let y = y as f64 * anno; [ scaled_origin[0] + plane.x_axis[0] * x + plane.y_axis[0] * y, scaled_origin[1] + plane.x_axis[1] * x + plane.y_axis[1] * y, scaled_origin[2] + plane.x_axis[2] * x + plane.y_axis[2] * y, ] } else { [x as f64 * anno + slx_v, y as f64 * anno + sly_v, elev_v] }; let (h, l) = split_ds_xyz(world[0], world[1], world[2]); bins[bi].fill_tris.push(h); bins[bi].fill_tris_low.push(l); } } // ── SDF glyph quads ────────────────────────────────────── // Build each run's glyph quads here and carry them on the wire. // They ride with the wire through the tess memo, hit- // materialisation and (for block content) the block-expand // transform — no separate document-wide collector. let mut sdf_verts: Vec = Vec::new(); { if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() { // Selection tints the whole run; otherwise inline `\C` // colours (bin key) win, falling back to entity colour. for group in &stroke_groups { let Some(run) = &group.run else { continue }; if crate::scene::text::web_font::requires_shaping(&run.text) { continue; } let slx_v = (group.origin[0] - ref_lx_v) * anno + ref_lx_v; let sly_v = (group.origin[1] - ref_ly_v) * anno + ref_ly_v; // Base colour only (inline `\C` wins). Selection / // hover recolouring is done by the text-highlight // overlay, so the base glyphs stay neutral — else a // deselect would leave stale-tinted glyphs until the // next geometry rebuild (the base text buffer only // rebuilds on geometry, not on a pick). let gcolor = group .color .map(|c| [c[0], c[1], c[2], entity_color[3]]) .unwrap_or(entity_color); let gcolor = crate::scene::view::render::adapt_to_bg( gcolor, text_contrast_background(entity, bg_color), ); let quads = crate::scene::text::glyph_quads::layout_glyph_quads( &mut atlas, run.height, run.rotation, run.width_factor, run.oblique, run.tracking, &run.font, run.bold, &run.text, ); if let Some(plane) = group.plane { let scaled_origin = [ plane.scale_origin[0] + (plane.origin[0] - plane.scale_origin[0]) * anno, plane.scale_origin[1] + (plane.origin[1] - plane.scale_origin[1]) * anno, plane.scale_origin[2] + (plane.origin[2] - plane.scale_origin[2]) * anno, ]; crate::scene::pipeline::text_gpu::push_glyph_vertices_on_plane( &mut sdf_verts, &quads, scaled_origin, plane.x_axis, plane.y_axis, anno, gcolor, 0.0, ); } else { crate::scene::pipeline::text_gpu::push_glyph_vertices( &mut sdf_verts, &quads, [slx_v, sly_v, elev_v], anno, gcolor, 0.0, ); } } } } let snap_pts = te.snap_pts; let key_vertices: Vec<[f64; 3]> = te .key_vertices .into_iter() .map(|[x, y, z]| [x, y, z]) .collect(); // Derive the pick box from the rendered glyph quads. let text_aabb = if !sdf_verts.is_empty() { let (mut nx, mut ny, mut xx, mut xy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); for v in &sdf_verts { let x = v.pos[0] as f64 + v.pos_low[0] as f64; let y = v.pos[1] as f64 + v.pos_low[1] as f64; nx = nx.min(x); xx = xx.max(x); ny = ny.min(y); xy = xy.max(y); } [nx as f32, ny as f32, xx as f32, xy as f32] } else { WireModel::UNBOUNDED_AABB }; // Empty input (no glyphs) → emit a single empty wire so the // entity still has a hit-test target via snap_pts. With SDF on // this is the normal path (strokes suppressed) and the wire // also carries the glyph quads built above. if bins.is_empty() { let mut wires: Vec = Vec::new(); // MTEXT background and frame follow the glyph bounds. if text_aabb != WireModel::UNBOUNDED_AABB { if let EntityType::MText(m) = entity { let has_fill = m.background_fill_flags & 0x03 != 0; let has_frame = m.background_fill_flags & 0x10 != 0; if has_fill || has_frame { let text_rotation = stroke_groups .iter() .find_map(|group| { group.run.as_ref().map(|run| run.rotation as f64) }) .unwrap_or(m.rotation); let text_height = m.height * anno; let pad = (m.background_scale - 1.0).max(0.0) * text_height; let corner_groups = oriented_mtext_corner_groups( &sdf_verts, m, text_rotation, pad, anno, ); // Fill / mask — two triangles behind the glyphs. if has_fill { let fill_color = if m.background_fill_flags & 0x02 != 0 { bg_color } else { color_or_inherit(&m.background_color, bg_color) }; let mut ft = Vec::with_capacity(6 * corner_groups.len()); let mut ftl = Vec::with_capacity(6 * corner_groups.len()); for corners in &corner_groups { for &k in &[0usize, 1, 2, 0, 2, 3] { let (h, lo) = split_ds_xyz( corners[k][0], corners[k][1], elev_v, ); ft.push(h); ftl.push(lo); } } wires.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.clone(), points: vec![], points_low: Vec::new(), color: fill_color, selected, aci: 0, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: vec![], tangent_geoms: vec![], key_vertices: vec![], aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: ft, fill_tris_low: ftl, }); } // Text frame — a closed rectangle in the text // colour around the same box. if has_frame { let mut fp = Vec::with_capacity(6 * corner_groups.len()); let mut fpl = Vec::with_capacity(6 * corner_groups.len()); for (group_index, corners) in corner_groups.iter().enumerate() { if group_index > 0 { fp.push([f32::NAN; 3]); fpl.push([0.0; 3]); } for &[x, y] in &[ corners[0], corners[1], corners[2], corners[3], corners[0], ] { let (h, lo) = split_ds_xyz(x, y, elev_v); fp.push(h); fpl.push(lo); } } wires.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.clone(), points: fp, points_low: fpl, color: entity_color, selected, aci: 0, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: vec![], tangent_geoms: vec![], key_vertices: vec![], aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }); } } } } // Debug (env OCS_TEXT_BOX): draw a rectangle around the glyph // bounds as a separate outline wire so the text box is // visible for testing. The empty text wire below is left // untouched (still the SDF + pick target). if !sdf_verts.is_empty() && crate::scene::text::sdf_atlas::text_box_debug() { let [nx, ny, xx, xy] = text_aabb; let (nx, ny, xx, xy) = (nx as f64, ny as f64, xx as f64, xy as f64); let mut pts = Vec::with_capacity(5); let mut low = Vec::with_capacity(5); for (x, y) in [(nx, ny), (xx, ny), (xx, xy), (nx, xy), (nx, ny)] { let (hh, ll) = split_ds_xyz(x, y, elev_v); pts.push(hh); low.push(ll); } wires.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.clone(), points: pts, points_low: low, color: [1.0, 0.0, 1.0, 1.0], selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: Vec::new(), tangent_geoms: Vec::new(), aci: 0, key_vertices: Vec::new(), aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }); } wires.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: sdf_verts, name, points: Vec::new(), points_low: Vec::new(), color, selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts, tangent_geoms: te.tangent_geoms, aci: 0, key_vertices, aabb: text_aabb, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }); return wires; } let mut out: Vec = Vec::new(); let mut is_first = true; for bin in bins { let wire_color = match bin.color { Some([r, g, b]) => [r, g, b, color[3]], None => color, }; // Bold text strokes carry a wider pen so the editor preview // draws them thicker (these stroke wires exist only when // strokes are forced, i.e. the preview; the main render uses // SDF where bold is a wider baked pen). let bin_lw = if bin.bold { line_weight_px.max(1.0) * 2.4 } else { line_weight_px }; if !bin.pts.is_empty() { let (snap, keys, tangents) = if is_first { is_first = false; ( snap_pts.clone(), key_vertices.clone(), te.tangent_geoms.clone(), ) } else { (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.clone(), points: bin.pts, points_low: bin.pts_low, color: wire_color, selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px: bin_lw, snap_pts: snap, tangent_geoms: tangents, aci: 0, key_vertices: keys, aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }); } if !bin.fill_tris.is_empty() { let (snap, keys, tangents) = if is_first { is_first = false; ( snap_pts.clone(), key_vertices.clone(), te.tangent_geoms.clone(), ) } else { (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.clone(), points: Vec::new(), points_low: Vec::new(), color: wire_color, selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: snap, tangent_geoms: tangents, aci: 0, key_vertices: keys, aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: bin.fill_tris, fill_tris_low: bin.fill_tris_low, }); } } // Composite Text objects (e.g. a tolerance frame) keep geometry // in `bins` (run-less groups) and text in `sdf_verts` (run // groups). Emit the glyphs on their own wire carrying the tight // glyph-box AABB so the text draws + picks alongside the box // strokes. TEXT / MTEXT never reach here with SDF on (their bins // are empty → the early-return path above). if !sdf_verts.is_empty() { out.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: sdf_verts, name: name.clone(), points: Vec::new(), points_low: Vec::new(), color, selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: Vec::new(), tangent_geoms: Vec::new(), aci: 0, key_vertices: Vec::new(), aabb: text_aabb, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }); } if out.is_empty() { out.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name, points: Vec::new(), points_low: Vec::new(), color, selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts, tangent_geoms: te.tangent_geoms, aci: 0, key_vertices, aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }); } return out; } // ── Standard topology objects ───────────────────────────────── RenderObject::Dot(position) => { { { // Split into a coarse float and a fine correction, so // a point at survey coordinates keeps its last // millimetres instead of losing them to f32. let [x, y, z] = [ position[0] as f32, position[1] as f32, position[2] as f32, ]; let [xl, yl, zl] = [ (position[0] - x as f64) as f32, (position[1] - y as f64) as f32, (position[2] - z as f64) as f32, ]; // A PDMODE=0 point is a single dot. Size its marker to // ~1 px so it reads as a dot rather than a large // world-space "+" in small drawings — otherwise the // dimension def-points on the Defpoints layer litter // the view with crosses. The tessellation cache keys on // world-per-pixel, so this re-sizes on zoom and stays a // constant on-screen size. (#139) let s = world_per_pixel .map(|w| (w * 0.75).max(1e-6)) .unwrap_or(0.1); let snap_pts = te.snap_pts; let key_vertices: Vec<[f64; 3]> = te .key_vertices .into_iter() .map(|[kx, ky, kz]| [kx, ky, kz]) .collect(); return vec![WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name, points: vec![ [x - s, y, z], [x + s, y, z], [x, y - s, z], [x, y + s, z], ], // All four cross points share the Point's residual // (the cross arms are tiny, < 0.1 m, so the low // component of the centre is also the right one // for the arm tips at f32 precision). points_low: vec![[xl, yl, zl]; 4], color, selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px: 1.0, snap_pts, tangent_geoms: te.tangent_geoms, aci: 0, key_vertices, aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }]; } } } RenderObject::Lines(points) => { // Points are world-space f64 from entity converters (polyline, // leader, mesh, solid2d, etc.). Subtract world_offset in f64 // and split into double-single (high, low) f32 buffers — the // GPU shader pairs them so drawings at large UTM-style // coordinates keep sub-unit precision in the wire model and // don't jitter on camera movement. let (local_pts, local_pts_low) = points_to_ds(points); let snap_pts = te.snap_pts; let key_vertices: Vec<[f64; 3]> = te .key_vertices .into_iter() .map(|[x, y, z]| [x, y, z]) .collect(); let (fill_tris, fill_tris_low) = points_to_ds(te.fill_tris); // Only a real 3-D mesh surface (PolyfaceMesh / PolygonMesh / // the modern subdivision Mesh) fill renders shaded-only with // hidden-surface depth; every other `fill_tris` here (a SOLID's // filled quad — e.g. a `_BoxFilled` arrowhead) is a flat 2-D // overlay visible in every view mode. Leaving `Mesh` out drops // its face fill into the 2-D buffer, so it drew in wireframe too. let fill_is_3d = matches!( entity, EntityType::Face3D(_) | EntityType::PolyfaceMesh(_) | EntityType::PolygonMesh(_) | EntityType::Mesh(_) ) || matches!(entity, EntityType::Solid(solid) if solid.thickness.abs() > 1.0e-10); // Thickness walls ride on the wire that carries their edges, not // on a wire of their own: they are pick geometry for that entity, // and `fill_tris` below deliberately splits off into a fill-only // wire (`is_fill_only`) which has no `points` to hang them from. let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); let mut out = Vec::new(); let mut is_first = true; // A thickened polyline's extrusion — its corner / cap edges // frame the solid tube and read black (like solid-with-edges // outlines), while the tube fill keeps the entity colour. Both // wide polyline kinds (LwPolyline + Polyline2D) extrude tubes. let is_thick_extrusion = matches!( entity, EntityType::LwPolyline(p) if p.thickness.abs() > 1e-10 ) || matches!( entity, EntityType::Polyline2D(p) if p.thickness.abs() > 1e-10 ); let edge_color = if is_thick_extrusion { [0.0, 0.0, 0.0, 1.0] } else { color }; // Basic curves keep their resolved linetype. let (edge_pattern_length, edge_pattern) = if matches!( entity, EntityType::Line(_) | EntityType::Circle(_) | EntityType::Arc(_) | EntityType::Ellipse(_) | EntityType::Spline(_) | EntityType::LwPolyline(_) ) { (pattern_length, pattern) } else { (0.0, [0.0; 8]) }; if !local_pts.is_empty() { let (snap, keys, tangents) = if is_first { is_first = false; ( snap_pts.clone(), key_vertices.clone(), te.tangent_geoms.clone(), ) } else { (Vec::new(), Vec::new(), Vec::new()) }; let point_marker = crate::entities::point::relative_marker_spec(entity, document); let has_arc = te .tangent_geoms .iter() .any(|tg| matches!(tg, TangentGeom::Arc { .. })); let can_split = !is_thick_extrusion && fill_tris.is_empty() && matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); if has_arc && can_split { let seg_widths = polyline_segment_widths(entity); let ww = polyline_band_width(entity, document.header.fill_mode); out.extend(split_mixed_polyline( &te.tangent_geoms, &keys, &name, edge_color, selected, edge_pattern_length, edge_pattern, line_weight_px, snap, point_marker, true, &seg_widths, ww, pick_tris, pick_tris_low, )); } else { out.push(WireModel { bg_adapt: None, point_marker, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: polyline_band_width(entity, document.header.fill_mode), depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d, fill_is_2d_solid: false, render_instance: None, pick_tris, pick_tris_low, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.clone(), points: local_pts, points_low: local_pts_low, color: edge_color, selected, pattern_length: edge_pattern_length, pattern: edge_pattern, line_weight_px, snap_pts: snap, tangent_geoms: tangents, aci: 0, key_vertices: keys, aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }); } } if !fill_tris.is_empty() { let (snap, keys, tangents) = if is_first { ( snap_pts.clone(), key_vertices.clone(), te.tangent_geoms.clone(), ) } else { (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name: name.clone(), points: Vec::new(), points_low: Vec::new(), color, selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: snap, tangent_geoms: tangents, aci: 0, key_vertices: keys, aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris, fill_tris_low, fill_is_3d, fill_is_2d_solid: matches!(entity, EntityType::Solid(_)), render_instance: None, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, }); } if out.is_empty() { out.push(WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name, points: Vec::new(), points_low: Vec::new(), color, selected, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts, tangent_geoms: te.tangent_geoms, aci: 0, key_vertices, aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }); } return out; } RenderObject::BoundaryLines { points, stations, point_segments, station_pieces, source_length, plinegen, } => { let (local_pts, local_pts_low) = points_to_ds(points); let key_vertices = te .key_vertices .into_iter() .map(|[x, y, z]| [x, y, z]) .collect(); let station_data = crate::scene::model::wire_model::encode_pattern_stations( stations, source_length, &point_segments, &station_pieces, ); return vec![WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: station_data, world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name, points: local_pts, points_low: local_pts_low, color, selected, pattern_length, pattern, line_weight_px, snap_pts: te.snap_pts, tangent_geoms: te.tangent_geoms, aci: 0, key_vertices, plinegen, aabb: WireModel::UNBOUNDED_AABB, fill_tris: Vec::new(), fill_tris_low: Vec::new(), }]; } RenderObject::SegmentedLines(points) => { let (local_pts, local_pts_low) = points_to_ds(points); let snap_pts = te.snap_pts; let key_vertices: Vec<[f64; 3]> = te .key_vertices .into_iter() .map(|[x, y, z]| [x, y, z]) .collect(); // A wide polyline with PLINEGEN=0 arrives here: same shader-band // treatment as the Contour arm, restarting the dash per segment. let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); let is_thick_extrusion = matches!( entity, EntityType::LwPolyline(p) if p.thickness.abs() > 1e-10 ) || matches!( entity, EntityType::Polyline2D(p) if p.thickness.abs() > 1e-10 ); let can_split = !is_thick_extrusion && matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); let has_arc = te .tangent_geoms .iter() .any(|tg| matches!(tg, TangentGeom::Arc { .. })); if has_arc && can_split { let edge_color = if is_thick_extrusion { [0.0, 0.0, 0.0, 1.0] } else { color }; let point_marker = crate::entities::point::relative_marker_spec(entity, document); let seg_widths = polyline_segment_widths(entity); let ww = polyline_band_width(entity, document.header.fill_mode); return split_mixed_polyline( &te.tangent_geoms, &key_vertices, &name, edge_color, selected, pattern_length, pattern, line_weight_px, snap_pts, point_marker, false, &seg_widths, ww, pick_tris, pick_tris_low, ); } return vec![WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: polyline_band_width(entity, document.header.fill_mode), depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris, pick_tris_low, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name, points: local_pts, points_low: local_pts_low, color, selected, pattern_length, pattern, line_weight_px, snap_pts, tangent_geoms: te.tangent_geoms, aci: 0, key_vertices, plinegen: false, aabb: WireModel::UNBOUNDED_AABB, fill_tris: vec![], fill_tris_low: Vec::new(), }]; } RenderObject::TaperedLines(points, widths) => { let has_arc = te .tangent_geoms .iter() .any(|tg| matches!(tg, TangentGeom::Arc { .. })); let can_split = matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); let world_width = widths.iter().copied().fold(0.0f32, f32::max); let key_vertices: Vec<[f64; 3]> = te .key_vertices .into_iter() .map(|[x, y, z]| [x, y, z]) .collect(); if has_arc && can_split { let seg_widths = polyline_segment_widths(entity); return split_mixed_polyline( &te.tangent_geoms, &key_vertices, &name, color, selected, pattern_length, pattern, line_weight_px, te.snap_pts, None, true, &seg_widths, world_width, pick_tris, pick_tris_low, ); } // A wide polyline whose width varies: one continuous band wire // carrying a per-point width; the shader interpolates each // segment's two endpoint widths. `world_width` (the widest edge) // stays as the constant fallback for PDF export + a hairline // floor when zoomed out. let (local_pts, local_pts_low) = points_to_ds(points); let snap_pts = te.snap_pts; return vec![WireModel { bg_adapt: None, point_marker: None, taper_widths: widths, pattern_stations: Vec::new(), world_width, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris, pick_tris_low, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name, points: local_pts, points_low: local_pts_low, color, selected, pattern_length, pattern, line_weight_px, snap_pts, tangent_geoms: te.tangent_geoms, aci: 0, key_vertices, plinegen: true, aabb: WireModel::UNBOUNDED_AABB, fill_tris: vec![], fill_tris_low: Vec::new(), }]; } } } // ── Fallback for Viewport / Insert / Hatch / Ole2Frame ──────────────── let (mut points_f64, snap_pts, tangent_geoms, mut key_vertices) = fallback_geometry(entity); let clipped_viewport_polygon = match entity { EntityType::Viewport(viewport) if !viewport.clip_boundary_handle.is_null() => { let polygon = crate::scene::project::clip_boundary_polygon_for_document( document, viewport.clip_boundary_handle, viewport.center.z as f32, ); if polygon.len() >= 3 { let polygon: Vec<[f64; 3]> = polygon .into_iter() .map(|point| { [ point[0] as f64, point[1] as f64, point[2] as f64, ] }) .collect(); points_f64 = polygon.clone(); points_f64.push(polygon[0]); key_vertices = polygon.clone(); Some(polygon) } else { None } } _ => None, }; // `points_f64` are absolute world coords; split into the double-single // high/low pair so the outline reconstructs to f64 precision at UTM scale // (a NaN separator stays NaN in both buffers). let mut points: Vec<[f32; 3]> = Vec::with_capacity(points_f64.len()); let mut points_low: Vec<[f32; 3]> = Vec::with_capacity(points_f64.len()); for [x, y, z] in &points_f64 { if !x.is_finite() || !y.is_finite() { points.push([f32::NAN, f32::NAN, f32::NAN]); points_low.push([0.0; 3]); continue; } let (hx, lx) = split_ds(*x); let (hy, ly) = split_ds(*y); let (hz, lz) = split_ds(*z); points.push([hx, hy, hz]); points_low.push([lx, ly, lz]); } // fallback_geometry still emits offset-relative f32 snap points; widen to // f64 for the WireModel's double-single-era snap buffer. let snap_pts: Vec<(glam::DVec3, SnapHint)> = snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect(); // Paper viewports are pickable inside their frames; the sheet viewport is not. let (pick_tris, pick_tris_low) = match entity { EntityType::Viewport(vp) if !crate::scene::Scene::is_sheet_viewport(document, vp) => { if let Some(polygon) = clipped_viewport_polygon.as_ref() { points_to_ds(crate::entities::mesh::triangulate_planar(polygon)) } else { let (cx, cy, cz) = (vp.center.x, vp.center.y, vp.center.z); let (hw, hh) = (vp.width / 2.0, vp.height / 2.0); points_to_ds(crate::entities::common::quad_pick_tris(&[ [cx - hw, cy - hh, cz], [cx + hw, cy - hh, cz], [cx + hw, cy + hh, cz], [cx - hw, cy + hh, cz], ])) } } _ => (Vec::new(), Vec::new()), }; vec![WireModel { bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), world_width: 0.0, depth_override: None, display_visible: true, snap_only: false, plot_visible: true, fill_is_3d: false, fill_is_2d_solid: false, render_instance: None, pick_tris, pick_tris_low, dash_from_start: false, dash_align_end: None, text_verts: Vec::new(), name, points, points_low, color, selected, aci: 0, pattern_length, pattern, line_weight_px, snap_pts, tangent_geoms, key_vertices, aabb: WireModel::UNBOUNDED_AABB, plinegen: true, fill_tris: vec![], fill_tris_low: Vec::new(), }] } #[derive(Clone)] pub(crate) enum ArrowKind { None, Triangle { size: f32, filled: bool, size_mul: f32 }, Tick { size: f32 }, Open { size: f32, half_angle: f32 }, Dot { size: f32, filled: bool }, Origin { size: f32 }, Box_ { size: f32, filled: bool }, Datum { size: f32, filled: bool }, Custom { size: f32, lines: Vec<[f32; 3]>, fill: Vec<[f32; 3]>, }, } pub(crate) fn arrow_from_block( doc: &CadDocument, handle: codec::types::Handle, dimasz: f32, ) -> ArrowKind { arrow_from_block_with_deferred_hatch(doc, handle, dimasz, false) } pub(crate) fn arrow_from_block_with_deferred_hatch( doc: &CadDocument, handle: codec::types::Handle, dimasz: f32, defer_hatch: bool, ) -> ArrowKind { if handle.is_null() { return arrow_from_block_name(None, dimasz); } let Some(record) = doc.block_records.iter().find(|b| b.handle == handle) else { return arrow_from_block_name(None, dimasz); }; if let Some(arrow) = builtin_arrow_from_block_name(&record.name, dimasz) { return arrow; } custom_arrow_from_block(doc, record, dimasz, defer_hatch) .unwrap_or_else(|| arrow_from_block_name(None, dimasz)) } fn arrow_from_block_name(name: Option<&str>, dimasz: f32) -> ArrowKind { name.and_then(|name| builtin_arrow_from_block_name(name, dimasz)) .unwrap_or(ArrowKind::Triangle { size: dimasz, filled: true, size_mul: 1.0, }) } fn builtin_arrow_from_block_name(name: &str, dimasz: f32) -> Option { // Built-in arrow block names may carry a leading underscore. Normalize it // before matching the canonical names. let n = name .trim() .trim_start_matches('_') .to_ascii_uppercase(); match n.as_str() { "" | "CLOSEDFILLED" => Some(ArrowKind::Triangle { size: dimasz, filled: true, size_mul: 1.0, }), "CLOSED" | "CLOSEDBLANK" => Some(ArrowKind::Triangle { size: dimasz, filled: false, size_mul: 1.0, }), "SMALL" => Some(ArrowKind::Triangle { size: dimasz, filled: true, size_mul: 0.5, }), "OPEN" => Some(ArrowKind::Open { size: dimasz, half_angle: 9.5_f32.to_radians(), }), "OPEN30" => Some(ArrowKind::Open { size: dimasz, half_angle: 15.0_f32.to_radians(), }), "OPEN90" => Some(ArrowKind::Open { size: dimasz, half_angle: 45.0_f32.to_radians(), }), "DOT" => Some(ArrowKind::Dot { size: dimasz, filled: true, }), "DOTSMALL" => Some(ArrowKind::Dot { size: dimasz * 0.5, filled: true, }), "DOTBLANK" => Some(ArrowKind::Dot { size: dimasz, filled: false, }), "DOTSMALLBLANK" => Some(ArrowKind::Dot { size: dimasz * 0.5, filled: false, }), "ORIGIN" | "ORIGIN2" | "ORIGININDICATOR" | "ORIGININDICATOR2" => { Some(ArrowKind::Origin { size: dimasz }) } // `ArrowKind::Tick` draws the stroke `size` to either side of the tip // (total 2·size — its `size` is a half-length, matching DIMTSZ). The // block's stroke runs (-0.5,-0.5)→(0.5,0.5) scaled by DIMASZ, so its // half-length is DIMASZ/√2, not DIMASZ/2 — halving drew every // oblique stroke √2 short of the file's own. (#898) "OBLIQUE" | "ARCHTICK" => Some(ArrowKind::Tick { size: dimasz * std::f32::consts::FRAC_1_SQRT_2, }), "BOXFILLED" => Some(ArrowKind::Box_ { size: dimasz, filled: true, }), "BOXBLANK" | "BOX" => Some(ArrowKind::Box_ { size: dimasz, filled: false, }), "DATUMFILLED" | "DATUMTRIANGLEFILLED" => Some(ArrowKind::Datum { size: dimasz, filled: true, }), "DATUMBLANK" | "DATUMTRIANGLE" => Some(ArrowKind::Datum { size: dimasz, filled: false, }), "NONE" => Some(ArrowKind::None), _ => None, } } fn custom_arrow_from_block( doc: &CadDocument, record: &codec::tables::BlockRecord, dimasz: f32, defer_hatch: bool, ) -> Option { if record.is_layout() || record.is_model_space() || record.is_paper_space() || record.flags.is_xref || record.flags.is_xref_overlay || record.flags.is_external { return None; } let depths = rustc_hash::FxHashMap::default(); let graph = crate::scene::render_graph::RenderSceneGraph::new( doc, None, None, true, &depths, ); let block_use = crate::scene::render_graph::block_use_from_handle( doc, record.handle, crate::scene::render_graph::BlockRole::ArrowHead, codec::types::Vector3::ZERO, )?; let mut lines = Vec::new(); let mut fill = Vec::new(); let mut deferred_hatch = false; graph.walk_block_use( &block_use, record.handle, |_, _| true, |entity, context| { if defer_hatch && matches!(entity, EntityType::Hatch(_)) { deferred_hatch = true; return; } let mut placed = entity.clone(); placed.apply_transform(&context.transform); append_custom_arrow_leaf( doc, &placed, &mut lines, &mut fill, ); }, ); if lines.is_empty() && fill.is_empty() && !deferred_hatch { None } else { Some(ArrowKind::Custom { size: dimasz, lines, fill, }) } } fn append_custom_arrow_leaf( doc: &CadDocument, entity: &EntityType, lines: &mut Vec<[f32; 3]>, fill: &mut Vec<[f32; 3]>, ) { match entity { EntityType::Block(_) | EntityType::BlockEnd(_) | EntityType::AttributeDefinition(_) | EntityType::Dimension(_) | EntityType::Leader(_) | EntityType::MultiLeader(_) | EntityType::Insert(_) => return, EntityType::Hatch(hatch) => { append_custom_hatch_geometry( hatch, codec::types::Vector3::ZERO, lines, fill, ); return; } _ => {} } let wires = tessellate( doc, entity.common().handle, entity, false, [1.0; 4], 0.0, [0.0; 8], 1.0, 1.0, None, None, [0.0, 0.0, 0.0, 1.0], true, ); for wire in wires { // A wide polyline's width rides on the wire as a GPU band; an arrow // keeps only geometry, so turn the band into fill or it draws hairline. // (#1130) if wire.world_width > 0.0 || wire.taper_widths.iter().any(|width| *width > 0.0) { append_custom_band(&wire, fill); } else { append_custom_wire_points( &wire.points, &wire.points_low, codec::types::Vector3::ZERO, lines, ); } append_custom_fill_points( &wire.fill_tris, &wire.fill_tris_low, codec::types::Vector3::ZERO, fill, ); } } fn append_custom_hatch_geometry( hatch: &codec::entities::Hatch, base: codec::types::Vector3, lines: &mut Vec<[f32; 3]>, fill: &mut Vec<[f32; 3]>, ) { use lyon_tessellation::math::point; use lyon_tessellation::path::Path; use lyon_tessellation::{ BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex, VertexBuffers, }; let Some(model) = crate::scene::Scene::hatch_model_from_dxf(hatch, [1.0; 4]) else { return; }; if matches!( &model.pattern, crate::scene::model::hatch_model::HatchPattern::Pattern(_) ) { let z = -base.z as f32; for [start, end] in model.pattern_segments() { if !lines.is_empty() && !lines.last().is_some_and(|point| point[0].is_nan()) { lines.push([f32::NAN; 3]); } lines.push([ (start[0] - base.x) as f32, (start[1] - base.y) as f32, z, ]); lines.push([ (end[0] - base.x) as f32, (end[1] - base.y) as f32, z, ]); } return; } let mut builder = Path::builder(); let mut ring: Vec<[f32; 2]> = Vec::new(); let mut finish_ring = |ring: &mut Vec<[f32; 2]>| { if ring.len() >= 3 { builder.begin(point(ring[0][0], ring[0][1])); for p in &ring[1..] { builder.line_to(point(p[0], p[1])); } builder.end(true); } ring.clear(); }; for &[x, y] in model.boundary.iter() { if x.is_nan() || y.is_nan() { finish_ring(&mut ring); continue; } ring.push([ (model.world_origin[0] + x as f64 - base.x) as f32, (model.world_origin[1] + y as f64 - base.y) as f32, ]); } finish_ring(&mut ring); let mut geometry: VertexBuffers<[f32; 2], u32> = VertexBuffers::new(); let mut tessellator = FillTessellator::new(); if tessellator .tessellate_path( &builder.build(), &FillOptions::default().with_fill_rule(FillRule::EvenOdd), &mut BuffersBuilder::new(&mut geometry, |vertex: FillVertex| { vertex.position().to_array() }), ) .is_err() { return; } let z = -base.z as f32; fill.extend( geometry .indices .iter() .filter_map(|&index| geometry.vertices.get(index as usize)) .map(|&[x, y]| [x, y, z]), ); } /// Triangles covering a wide polyline's band, one quad per segment. // ponytail: no join wedges at corners; add miters if a thick arrow shows notches. fn append_custom_band(wire: &WireModel, fill: &mut Vec<[f32; 3]>) { let at = |index: usize| { let point = wire.points[index]; let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); (!point[0].is_nan()).then(|| { [ point[0] as f64 + low[0] as f64, point[1] as f64 + low[1] as f64, point[2] as f64 + low[2] as f64, ] }) }; let half = |index: usize| { wire.taper_widths.get(index).copied().unwrap_or(wire.world_width).max(0.0) as f64 * 0.5 }; for index in 0..wire.points.len().saturating_sub(1) { let (Some(a), Some(b)) = (at(index), at(index + 1)) else { continue; }; let (dx, dy) = (b[0] - a[0], b[1] - a[1]); let length = dx.hypot(dy); if length <= 0.0 { continue; } let (nx, ny) = (-dy / length, dx / length); let (ha, hb) = (half(index), half(index + 1)); let corner = |p: [f64; 3], h: f64, side: f64| { [(p[0] + nx * h * side) as f32, (p[1] + ny * h * side) as f32, p[2] as f32] }; let quad = [corner(a, ha, 1.0), corner(a, ha, -1.0), corner(b, hb, -1.0), corner(b, hb, 1.0)]; fill.extend([quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]]); } } fn append_custom_wire_points( points: &[[f32; 3]], points_low: &[[f32; 3]], base: codec::types::Vector3, out: &mut Vec<[f32; 3]>, ) { if points.is_empty() { return; } if !out.is_empty() && !out.last().is_some_and(|p| p[0].is_nan()) { out.push([f32::NAN; 3]); } for (index, point) in points.iter().enumerate() { if point[0].is_nan() { if !out.last().is_some_and(|p| p[0].is_nan()) { out.push([f32::NAN; 3]); } continue; } let low = points_low.get(index).copied().unwrap_or([0.0; 3]); out.push([ (point[0] as f64 + low[0] as f64 - base.x) as f32, (point[1] as f64 + low[1] as f64 - base.y) as f32, (point[2] as f64 + low[2] as f64 - base.z) as f32, ]); } } fn append_custom_fill_points( points: &[[f32; 3]], points_low: &[[f32; 3]], base: codec::types::Vector3, out: &mut Vec<[f32; 3]>, ) { for (index, point) in points.iter().enumerate() { let low = points_low.get(index).copied().unwrap_or([0.0; 3]); out.push([ (point[0] as f64 + low[0] as f64 - base.x) as f32, (point[1] as f64 + low[1] as f64 - base.y) as f32, (point[2] as f64 + low[2] as f64 - base.z) as f32, ]); } } pub(crate) struct DimGeom { pub(crate) ext_lines: Vec<[f32; 3]>, pub(crate) dim_lines: Vec<[f32; 3]>, /// Arrowhead / tick outlines of a dimension: drawn solid, never in the /// dimension line's dashes. pub(crate) arrow_lines: Vec<[f32; 3]>, pub(crate) arrow_fill: Vec<[f32; 3]>, } impl DimGeom { pub(crate) fn new() -> Self { Self { ext_lines: Vec::new(), dim_lines: Vec::new(), arrow_lines: Vec::new(), arrow_fill: Vec::new(), } } } /// Convert an opencadcodec `Color` to RGBA, falling back to `inherited` for /// `ByLayer` / `ByBlock` (assumes those are already resolved upstream). pub(crate) fn color_or_inherit(c: &AcadColor, inherited: [f32; 4]) -> [f32; 4] { match c.rgb() { Some(_) => { let [r, g, b, _] = crate::scene::convert::tess_util::aci_to_rgba(c); [r, g, b, inherited[3]] } None => inherited, } } // ── Entity Z helper ─────────────────────────────────────────────────────── /// Extract the Z elevation from a text/mtext entity. pub(crate) fn entity_z(entity: &EntityType) -> f32 { match entity { EntityType::Text(t) => t.insertion_point.z as f32, EntityType::MText(t) => t.insertion_point.z as f32, _ => 0.0, } } /// Shader band width, or zero while FILLMODE draws the kernel boundary. fn polyline_band_width(entity: &EntityType, fill_mode: bool) -> f32 { if !fill_mode { return 0.0; } let w = match entity { EntityType::LwPolyline(p) if p.thickness.abs() <= 1e-10 => { let mut w = p.constant_width; for v in &p.vertices { w = w.max(v.start_width).max(v.end_width); } w } EntityType::Polyline2D(p) if p.thickness.abs() <= 1e-10 => { let mut w = p.start_width.max(p.end_width); for v in &p.vertices { w = w.max(v.start_width).max(v.end_width); } w } _ => 0.0, }; if w > 1e-9 { w as f32 } else { 0.0 } } // ── Fallback geometry (Viewport, Insert, Hatch outline, Ole2Frame) ─────── // // Per-entity blocks have moved to their respective `entities/*.rs` files // (Viewport, Insert, Hatch, Ole2Frame) via the `FallbackTess` trait. This // function stays as the dispatcher used by the main `tessellate()` path. use crate::entities::traits::FallbackTess; use crate::scene::convert::tess_util::FallbackGeometry as Geometry; fn fallback_geometry(entity: &EntityType) -> Geometry { match entity { EntityType::Viewport(vp) => vp.fallback_geometry(), EntityType::Insert(ins) => ins.fallback_geometry(), EntityType::Hatch(h) => h.fallback_geometry(), EntityType::Ole2Frame(ole) => ole.fallback_geometry(), // Modeler solids render as meshes (solid3d_tess). Their wire path // contributes only the pre-computed edge wires (empty for binary SAB) // plus an insertion snap — never the placeholder segment below, which // would otherwise draw a stray 1-unit line at the origin next to the // solid. EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) => { let mut snap = vec![]; if let Some(p) = crate::entities::solid3d::point_of_reference(entity) { snap.push(( Vec3::new((p.x) as f32, (p.y) as f32, (p.z) as f32), SnapHint::Insertion, )); } (vec![], snap, vec![], vec![]) } _ => { let s = 0.5_f64; (vec![[-s, 0.0, 0.0], [s, 0.0, 0.0]], vec![], vec![], vec![]) } } } pub(crate) fn push_tri(out: &mut Vec<[f32; 3]>, a: Vec3, b: Vec3, c: Vec3) { out.push([a.x, a.y, a.z]); out.push([b.x, b.y, b.z]); out.push([c.x, c.y, c.z]); } pub(crate) fn append_arrow(g: &mut DimGeom, tip: Vec3, dir: Vec3, arrow: &ArrowKind) { let dir = normalized_or(dir, Vec3::X); let perp = Vec3::new(-dir.y, dir.x, 0.0); match arrow { ArrowKind::None => {} ArrowKind::Triangle { size, filled, size_mul, } => { let size = *size * *size_mul; let base = tip + dir * size; // ~1:6 length:half-width ratio (≈9.5° half-angle) matches // the standard closed-filled block. let half_w = size / 6.0; let left = base + perp * half_w; let right = base - perp * half_w; add_segment(&mut g.dim_lines, tip, left); add_segment(&mut g.dim_lines, left, right); add_segment(&mut g.dim_lines, right, tip); if *filled { push_tri(&mut g.arrow_fill, tip, left, right); } } ArrowKind::Tick { size } => { // 45° oblique tick crossing the dim line at the tip; `size` is // the half-length used by DIMTSZ. let off = (dir + perp).normalize_or_zero() * *size; add_segment(&mut g.dim_lines, tip - off, tip + off); } ArrowKind::Open { size, half_angle } => { let base = tip + dir * *size; let half_w = *size * half_angle.tan(); let left = base + perp * half_w; let right = base - perp * half_w; add_segment(&mut g.dim_lines, tip, left); add_segment(&mut g.dim_lines, tip, right); } ArrowKind::Dot { size, filled } => { let r = *size * 0.5; const N: usize = 16; let mut ring: Vec = Vec::with_capacity(N + 1); for i in 0..=N { let a = i as f32 * std::f32::consts::TAU / N as f32; ring.push(tip + Vec3::new(a.cos() * r, a.sin() * r, 0.0)); } add_polyline(&mut g.dim_lines, &ring); if *filled { for i in 0..N { push_tri(&mut g.arrow_fill, tip, ring[i], ring[i + 1]); } } } ArrowKind::Origin { size } => { // Small filled dot at the tip with a perpendicular tick crossing // the dim line — matches "_ORIGIN" / "_ORIGIN2" blocks. let r = *size * 0.25; const N: usize = 12; let mut ring: Vec = Vec::with_capacity(N + 1); for i in 0..=N { let a = i as f32 * std::f32::consts::TAU / N as f32; ring.push(tip + Vec3::new(a.cos() * r, a.sin() * r, 0.0)); } add_polyline(&mut g.dim_lines, &ring); for i in 0..N { push_tri(&mut g.arrow_fill, tip, ring[i], ring[i + 1]); } let half = *size * 0.5; add_segment(&mut g.dim_lines, tip - perp * half, tip + perp * half); } ArrowKind::Box_ { size, filled } => { let half = *size * 0.5; let p1 = tip - dir * half - perp * half; let p2 = tip + dir * half - perp * half; let p3 = tip + dir * half + perp * half; let p4 = tip - dir * half + perp * half; add_segment(&mut g.dim_lines, p1, p2); add_segment(&mut g.dim_lines, p2, p3); add_segment(&mut g.dim_lines, p3, p4); add_segment(&mut g.dim_lines, p4, p1); if *filled { push_tri(&mut g.arrow_fill, p1, p2, p3); push_tri(&mut g.arrow_fill, p1, p3, p4); } } ArrowKind::Datum { size, filled } => { // Right-pointing triangle with the base perpendicular to the dim // line at the tip and the apex along +dir. let half = *size * 0.5; let base_a = tip + perp * half; let base_b = tip - perp * half; let apex = tip + dir * *size; add_segment(&mut g.dim_lines, base_a, apex); add_segment(&mut g.dim_lines, apex, base_b); add_segment(&mut g.dim_lines, base_b, base_a); if *filled { push_tri(&mut g.arrow_fill, base_a, apex, base_b); } } ArrowKind::Custom { size, lines, fill } => { let transform = |point: &[f32; 3]| { tip - dir * (point[0] * *size) - perp * (point[1] * *size) + Vec3::Z * (point[2] * *size) }; if !lines.is_empty() && !g.dim_lines.is_empty() && !g.dim_lines.last().is_some_and(|point| point[0].is_nan()) { g.dim_lines.push([f32::NAN; 3]); } for point in lines { if point[0].is_nan() { if !g.dim_lines.last().is_some_and(|point| point[0].is_nan()) { g.dim_lines.push([f32::NAN; 3]); } continue; } let point = transform(point); g.dim_lines.push([point.x, point.y, point.z]); } for triangle in fill.chunks_exact(3) { push_tri( &mut g.arrow_fill, transform(&triangle[0]), transform(&triangle[1]), transform(&triangle[2]), ); } } } } pub(crate) fn add_segment(points: &mut Vec<[f32; 3]>, a: Vec3, b: Vec3) { if !points.is_empty() { points.push([f32::NAN, f32::NAN, f32::NAN]); } points.push([a.x, a.y, a.z]); points.push([b.x, b.y, b.z]); } pub(crate) fn add_polyline(points: &mut Vec<[f32; 3]>, polyline: &[Vec3]) { if polyline.len() < 2 { return; } if !points.is_empty() { points.push([f32::NAN, f32::NAN, f32::NAN]); } points.extend(polyline.iter().map(|p| [p.x, p.y, p.z])); } /// Returns the text position of a dimension in DXF world-space (f64, no offset applied). /// Used when building a synthetic Text entity so tessellate() can apply world_offset itself. /// When the saved `text_middle_point` is zero (no explicit point was written), /// computes a fallback from the dim geometry and applies DIMTAD/DIMGAP. pub(crate) fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 { if v.length_squared() <= 1e-12 { fallback } else { v.normalize() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_split_mixed_polyline_straight_lines_have_taper_widths() { let tg_line = TangentGeom::Line { p1: [0.0, 0.0, 0.0], p2: [10.0, 0.0, 0.0], }; let tg_arc = TangentGeom::Arc { center: [10.0, 5.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 5.0, start_angle: -std::f64::consts::FRAC_PI_2, end_angle: 0.0, }; let tgs = vec![tg_line, tg_arc]; let kv = vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [15.0, 5.0, 0.0]]; let seg_widths = vec![(0.0f32, 10.0f32), (10.0f32, 20.0f32)]; let wires = split_mixed_polyline( &tgs, &kv, "mixed", [1.0, 1.0, 1.0, 1.0], false, 0.0, [0.0; 8], 1.0, Vec::new(), None, true, &seg_widths, 20.0, Vec::new(), Vec::new(), ); // Arc wire + Line wire assert_eq!(wires.len(), 2); let arc_wire = &wires[0]; assert_eq!(arc_wire.taper_widths.first().copied(), Some(10.0)); assert_eq!(arc_wire.taper_widths.last().copied(), Some(20.0)); let line_wire = &wires[1]; assert_eq!(line_wire.taper_widths, vec![0.0, 10.0]); assert_eq!(line_wire.world_width, 10.0); } #[test] fn split_mixed_polyline_keeps_exact_line_intersections() { let tangents = vec![ TangentGeom::Line { p1: [-10.0, 0.0, 0.0], p2: [0.0, 0.0, 0.0], }, TangentGeom::Arc { center: [0.0, 4.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 4.0, start_angle: -std::f64::consts::FRAC_PI_2, end_angle: std::f64::consts::FRAC_PI_2, }, TangentGeom::Line { p1: [0.0, 8.0, 0.0], p2: [10.0, 8.0, 0.0], }, ]; let vertices = vec![ [-10.0, 0.0, 0.0], [0.0, 0.0, 0.0], [0.0, 8.0, 0.0], [10.0, 8.0, 0.0], ]; let wires = split_mixed_polyline( &tangents, &vertices, "mixed", [1.0; 4], false, 0.0, [0.0; 8], 1.0, Vec::new(), None, true, &[(0.0, 0.0); 3], 0.0, Vec::new(), Vec::new(), ); let line_wire = wires .iter() .find(|wire| { wire.tangent_geoms .iter() .all(|geom| matches!(geom, TangentGeom::Line { .. })) }) .expect("straight-segment wire"); let circle = WireModel { tangent_geoms: vec![TangentGeom::PlanarCircle { center: [5.0, 4.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 5.0, }], ..Default::default() }; let mut points = crate::snap::exact_curve_intersections(line_wire, &circle) .expect("analytical intersections"); points.sort_by(|left, right| left.x.total_cmp(&right.x)); assert_eq!(points.len(), 2, "unexpected intersections: {points:?}"); assert!((points[0] - glam::DVec3::new(2.0, 8.0, 0.0)).length() < 1.0e-9); assert!((points[1] - glam::DVec3::new(8.0, 8.0, 0.0)).length() < 1.0e-9); } #[test] fn test_split_mixed_polyline_zero_width_segments_after_tapered_segment() { let tg_line = TangentGeom::Line { p1: [0.0, 0.0, 0.0], p2: [10.0, 0.0, 0.0], }; let tg_arc1 = TangentGeom::Arc { center: [10.0, 5.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 5.0, start_angle: -std::f64::consts::FRAC_PI_2, end_angle: 0.0, }; let tg_arc2 = TangentGeom::Arc { center: [15.0, 10.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 5.0, start_angle: -std::f64::consts::FRAC_PI_2, end_angle: 0.0, }; let tgs = vec![tg_line, tg_arc1, tg_arc2]; let kv = vec![ [0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [15.0, 5.0, 0.0], [20.0, 10.0, 0.0], ]; // Segment 0 has width (2.0, 0.0), segment 1 and 2 have width (0.0, 0.0) let seg_widths = vec![(2.0f32, 0.0f32), (0.0f32, 0.0f32), (0.0f32, 0.0f32)]; let wires = split_mixed_polyline( &tgs, &kv, "taper_then_zero", [1.0, 1.0, 1.0, 1.0], false, 0.0, [0.0; 8], 1.0, Vec::new(), None, true, &seg_widths, 2.0, Vec::new(), Vec::new(), ); // 2 arc wires + 1 line wire = 3 wires assert_eq!(wires.len(), 3); let arc1 = &wires[0]; let arc2 = &wires[1]; let line = &wires[2]; // Arcs must have world_width = 0.0 and empty taper_widths assert_eq!(arc1.world_width, 0.0); assert!(arc1.taper_widths.is_empty()); assert_eq!(arc2.world_width, 0.0); assert!(arc2.taper_widths.is_empty()); let inst1 = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc1, 0.0) .expect("arc1 instance"); assert_eq!(inst1.start_width, 0.0); assert_eq!(inst1.params[3], 0.0); // Straight line must have world_width = 2.0 and taper_widths = [2.0, 0.0] assert_eq!(line.world_width, 2.0); assert_eq!(line.taper_widths, vec![2.0, 0.0]); } #[test] fn test_split_mixed_polyline_reversed_arc_aligns_endpoints() { // Clockwise arc from (10.0, 0.0) to (15.0, 5.0). // bulge_arc_to_tangent inverts angles so start_angle is at (15.0, 5.0) and end_angle at (10.0, 0.0). let tg_arc_reversed = TangentGeom::Arc { center: [10.0, 5.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 5.0, start_angle: 0.0, // at (15.0, 5.0) - physical end! end_angle: -std::f64::consts::FRAC_PI_2, // at (10.0, 0.0) - physical start! }; let tgs = vec![tg_arc_reversed]; let kv = vec![[10.0, 0.0, 0.0], [15.0, 5.0, 0.0]]; let seg_widths = vec![(5.0f32, 25.0f32)]; // sw=5.0 at start, ew=25.0 at end let wires = split_mixed_polyline( &tgs, &kv, "arc_rev", [1.0, 1.0, 1.0, 1.0], false, 0.0, [0.0; 8], 1.0, Vec::new(), None, true, &seg_widths, 25.0, Vec::new(), Vec::new(), ); assert_eq!(wires.len(), 1); let arc_wire = &wires[0]; // In CCW shader angles, start_angle is at (15.0, 5.0) which is physical end (ew = 25.0). // end_angle is at (10.0, 0.0) which is physical start (sw = 5.0). assert_eq!(arc_wire.taper_widths.first().copied(), Some(25.0)); assert_eq!(arc_wire.taper_widths.last().copied(), Some(5.0)); let inst = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc_wire, 0.0) .expect("arc instance extracted"); // start_width is at start_angle (physical end, 25.0) assert_eq!(inst.start_width, 25.0); // params.w (end_width) is at end_angle (physical start, 5.0) assert_eq!(inst.params[3], 5.0); } }