// Explode tool — ribbon definition + command implementation. // // Command: EXPLODE (X) // EXPLODE: Breaks compound objects into their constituent simple entities. // // Supported: // LwPolyline → Lines (straight segments) + Arcs (bulge segments) // Polyline2D → Lines + Arcs // Polyline3D → Lines // Polyline → Lines // Insert → constituent entities (via acadrust explode_from_document) // MLine → Lines (spine + offset lines per miter direction) // Dimension → Lines (extension + dimension + arrows) + Text // // Unsupported entity types are skipped silently. use super::geom::normalize_angle as norm_angle; use acadrust::entities::EntityCommon; use acadrust::entities::{ Arc as ArcEnt, Block, BlockEnd, Circle as CircleEnt, Dimension, Line as LineEnt, LwPolyline, MLine, }; use acadrust::entities::{Polyline, Polyline2D}; use acadrust::tables::BlockRecord; use acadrust::types::Vector3; use acadrust::{CadDocument, EntityType, Handle}; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::entities::curve::lwpolyline_world_xy; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use glam::DVec3; use crate::t; // ── Ribbon definition ────────────────────────────────────────────────────── pub fn tool() -> ToolDef { ToolDef { id: "EXPLODE", label: "Explode", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/explode.svg")), event: ModuleEvent::Command("EXPLODE".to_string()), } } // ── Geometry helpers ──────────────────────────────────────────────────────── /// Explode just the polyline family (LwPolyline / Polyline / Polyline2D / /// Polyline3D) into Line + Arc segments. No document needed — used where a /// polyline must be treated as its constituent edges (e.g. TRIM boundaries). /// Returns empty for any other entity type. pub fn explode_polyline_segments(entity: &EntityType) -> Vec { match entity { EntityType::LwPolyline(p) => explode_lwpolyline(p), EntityType::Polyline2D(p) => explode_polyline2d(p), EntityType::Polyline(p) => explode_polyline(p), EntityType::Polyline3D(p) => explode_polyline3d(p), _ => vec![], } } /// Decompose an entity into its constituent simple entities. /// Returns an empty vec if the entity cannot be exploded. pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec { match entity { EntityType::Line(line) => { let Some(association) = acadrust::entities::CenterMarkAssociation::read( &line.common.extended_data, ) else { return vec![]; }; crate::scene::centermark::mark_segments(&association) .into_iter() .map(|segment| { let mut common = line.common.clone(); common.handle = Handle::NULL; acadrust::entities::CenterMarkAssociation::remove(&mut common.extended_data); EntityType::Line(LineEnt { common, start: Vector3::new(segment[0].x, segment[0].y, segment[0].z), end: Vector3::new(segment[1].x, segment[1].y, segment[1].z), ..LineEnt::new() }) }) .collect() } EntityType::LwPolyline(p) => explode_lwpolyline(p), EntityType::Polyline2D(p) => explode_polyline2d(p), EntityType::Polyline(p) => explode_polyline(p), EntityType::Polyline3D(p) => explode_polyline3d(p), EntityType::Insert(ins) => ins .explode_from_document(document) .into_iter() .map(normalize_insert_entity) .collect(), EntityType::MLine(ml) => explode_mline(ml), EntityType::Dimension(dim) => explode_dimension(dim, document), _ => vec![], } } fn explode_polyline(p: &Polyline) -> Vec { let n = p.vertices.len(); if n < 2 { return vec![]; } let closed = p.flags.is_closed(); let n_segs = if closed { n } else { n - 1 }; let mut result = Vec::new(); for i in 0..n_segs { let v0 = &p.vertices[i]; let v1 = &p.vertices[(i + 1) % n]; let mut common = p.common.clone(); common.handle = Handle::NULL; result.push(EntityType::Line(LineEnt { common, start: v0.location.clone(), end: v1.location.clone(), ..LineEnt::new() })); } result } fn explode_polyline3d(p: &acadrust::entities::Polyline3D) -> Vec { let n = p.vertices.len(); if n < 2 { return vec![]; } let closed = p.is_closed(); let n_segs = if closed { n } else { n - 1 }; let mut result = Vec::new(); for i in 0..n_segs { let v0 = &p.vertices[i]; let v1 = &p.vertices[(i + 1) % n]; let mut common = p.common.clone(); common.handle = Handle::NULL; result.push(EntityType::Line(LineEnt { common, start: v0.position.clone(), end: v1.position.clone(), ..LineEnt::new() })); } result } fn explode_polyline2d(p: &Polyline2D) -> Vec { let n = p.vertices.len(); if n < 2 { return vec![]; } let closed = p.is_closed(); let n_segs = if closed { n } else { n - 1 }; let elevation = p.elevation; let normal = DVec3::new(p.normal.x, p.normal.y, p.normal.z) .try_normalize() .unwrap_or(DVec3::Z); let normal = Vector3::new(normal.x, normal.y, normal.z); let plane = crate::entities::curve::ocs_plane(normal.clone(), elevation); let mut result = Vec::new(); for i in 0..n_segs { let v0 = &p.vertices[i]; let v1 = &p.vertices[(i + 1) % n]; let p0 = [v0.location.x, v0.location.y]; let p1 = [v1.location.x, v1.location.y]; if v0.bulge.abs() < 1e-10 { let mut common = p.common.clone(); common.handle = Handle::NULL; let start = plane.point_at(p0); let end = plane.point_at(p1); result.push(EntityType::Line(LineEnt { common, start: Vector3::new(start[0], start[1], start[2]), end: Vector3::new(end[0], end[1], end[2]), thickness: p.thickness, normal: normal.clone(), ..LineEnt::new() })); } else if let Some(arc) = bulge_to_arc( p0, p1, v0.bulge, elevation, &p.common, p.thickness, normal.clone(), ) { result.push(arc); } } result } pub fn normalize_insert_entity(mut entity: EntityType) -> EntityType { match &mut entity { EntityType::Ellipse(ell) => { let major_len = ell.major_axis_length(); let full_span = { let mut span = ell.end_parameter - ell.start_parameter; if span < 0.0 { span += std::f64::consts::TAU; } (span - std::f64::consts::TAU).abs() < 1e-6 }; if (ell.minor_axis_ratio - 1.0).abs() < 1e-6 && full_span { let mut circle = CircleEnt::new(); circle.common = ell.common.clone(); circle.center = ell.center; circle.radius = major_len; circle.normal = ell.normal; entity = EntityType::Circle(circle); } } _ => {} } entity.common_mut().handle = Handle::NULL; entity.common_mut().owner_handle = Handle::NULL; entity } pub fn normalize_entity_for_block(entity: EntityType) -> EntityType { entity } fn explode_lwpolyline(p: &LwPolyline) -> Vec { let Some(p) = lwpolyline_world_xy(p) else { return vec![]; }; let n = p.vertices.len(); if n < 2 { return vec![]; } let elevation = p.elevation; let n_segs = if p.is_closed { n } else { n - 1 }; let mut result = Vec::new(); for i in 0..n_segs { let v0 = &p.vertices[i]; let v1 = &p.vertices[(i + 1) % n]; let p0 = [v0.location.x, v0.location.y]; let p1 = [v1.location.x, v1.location.y]; if v0.bulge.abs() < 1e-10 { // Straight segment → Line let mut common = p.common.clone(); common.handle = Handle::NULL; let line = LineEnt { common, start: Vector3::new(p0[0], p0[1], elevation), end: Vector3::new(p1[0], p1[1], elevation), thickness: p.thickness, normal: p.normal.clone(), ..LineEnt::new() }; result.push(EntityType::Line(line)); } else { // Arc segment from bulge if let Some(arc) = bulge_to_arc( p0, p1, v0.bulge, elevation, &p.common, p.thickness, p.normal.clone(), ) { result.push(arc); } } } result } /// Convert a polyline bulge segment to an Arc entity. /// Arc angles are measured from the +X axis. fn bulge_to_arc( p0: [f64; 2], p1: [f64; 2], bulge: f64, elevation: f64, common_src: &EntityCommon, thickness: f64, normal: Vector3, ) -> Option { let ba = crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge)?; // acadrust Arc is always CCW from start_angle to end_angle. Negative // bulge means the polyline goes p0→p1 the CW way around the centre, // which is the same circular arc traversed p1→p0 the CCW way — so // swap endpoints when bulge < 0. let (start_angle, end_angle) = if bulge > 0.0 { (norm_angle(ba.start_angle), norm_angle(ba.end_angle)) } else { (norm_angle(ba.end_angle), norm_angle(ba.start_angle)) }; let mut common = common_src.clone(); common.handle = Handle::NULL; let arc = ArcEnt { common, center: Vector3::new(ba.center[0], ba.center[1], elevation), radius: ba.radius, start_angle, end_angle, thickness, normal, ..ArcEnt::new() }; Some(EntityType::Arc(arc)) } fn explode_mline(ml: &MLine) -> Vec { let n = ml.vertices.len(); if n < 2 { return vec![]; } let closed = ml.flags.contains(acadrust::entities::MLineFlags::CLOSED); let scale = ml.scale_factor; let n_segs = if closed { n } else { n - 1 }; let mut result = Vec::new(); // Helper: build a Line from two Vector3 positions. let make_line = |common: &acadrust::entities::EntityCommon, s: &acadrust::types::Vector3, e: &acadrust::types::Vector3| -> EntityType { let mut c = common.clone(); c.handle = Handle::NULL; EntityType::Line(LineEnt { common: c, start: s.clone(), end: e.clone(), ..LineEnt::new() }) }; // For each segment, emit the center-spine line and the two ±scale/2 offset lines. for i in 0..n_segs { let v0 = &ml.vertices[i]; let v1 = &ml.vertices[(i + 1) % n]; // Spine line result.push(make_line(&ml.common, &v0.position, &v1.position)); if scale.abs() > 1e-9 { let half = scale * 0.5; for &sign in &[-1.0_f64, 1.0_f64] { let off = half * sign; // Use miter direction at each vertex to offset the endpoints. let s = Vector3::new( v0.position.x + v0.miter.x * off, v0.position.y + v0.miter.y * off, v0.position.z + v0.miter.z * off, ); let e = Vector3::new( v1.position.x + v1.miter.x * off, v1.position.y + v1.miter.y * off, v1.position.z + v1.miter.z * off, ); result.push(make_line(&ml.common, &s, &e)); } } } result } // ── Dimension explode ────────────────────────────────────────────────────── /// Convert a Dimension entity into Lines (geometry) + Text (label). /// A NULL-handle line segment for a baked dimension block. fn dim_seg(a: Vector3, b: Vector3, common: &acadrust::entities::EntityCommon) -> EntityType { let mut c = common.clone(); c.handle = Handle::NULL; EntityType::Line(LineEnt { common: c, start: a, end: b, ..LineEnt::new() }) } fn dim_geom_entities( geometry: &crate::scene::convert::tessellate::DimGeom, ext_common: &acadrust::entities::EntityCommon, dim_common: &acadrust::entities::EntityCommon, ) -> Vec { let mut entities = Vec::new(); let point = |value: [f32; 3]| { Vector3::new(value[0] as f64, value[1] as f64, value[2] as f64) }; for (points, common) in [ (geometry.ext_lines.as_slice(), ext_common), (geometry.dim_lines.as_slice(), dim_common), ] { for run in points.split(|point| point[0].is_nan()) { for pair in run.windows(2) { entities.push(dim_seg(point(pair[0]), point(pair[1]), common)); } } } for triangle in geometry.arrow_fill.chunks_exact(3) { let mut solid = acadrust::entities::Solid::triangle( point(triangle[0]), point(triangle[1]), point(triangle[2]), ); solid.common = dim_common.clone(); solid.common.handle = Handle::NULL; entities.push(EntityType::Solid(solid)); } entities } /// A dimension-line terminator at `tip`, body extending back along the unit /// vector `(dx,dy)` (toward the dim line). When DIMTSZ>0 it's an oblique 45° /// tick; otherwise a closed *filled* arrowhead (DXF SOLID) of length DIMASZ — /// matching the style so a baked block keeps the original arrow type rather /// than a generic open stroke. fn dim_terminator( tip: Vector3, dx: f64, dy: f64, arrow: &crate::scene::convert::tessellate::ArrowKind, common: &acadrust::entities::EntityCommon, ) -> Vec { use crate::scene::convert::tessellate::ArrowKind as A; let (dx, dy) = norm2(dx, dy, 1.0, 0.0); let (px, py) = (-dy, dx); // Point at `along` (down the dim line from the tip) and `perp` (sideways). let pt = |along: f64, perp: f64| { Vector3::new(tip.x + dx * along + px * perp, tip.y + dy * along + py * perp, tip.z) }; let mut out: Vec = Vec::new(); let tri = |a: Vector3, b: Vector3, c: Vector3, out: &mut Vec| { let mut s = acadrust::entities::Solid::triangle(a, b, c); s.common = common.clone(); s.common.handle = Handle::NULL; out.push(EntityType::Solid(s)); }; match arrow { A::None => {} A::Triangle { size, filled, size_mul } => { let size = (*size * *size_mul) as f64; let hw = size / 6.0; let (l, r) = (pt(size, hw), pt(size, -hw)); if *filled { tri(tip, l, r, &mut out); } else { out.push(dim_seg(tip, l, common)); out.push(dim_seg(l, r, common)); out.push(dim_seg(r, tip, common)); } } A::Tick { size } => { let s = *size as f64; let (ox, oy) = (dx + px, dy + py); let m = (ox * ox + oy * oy).sqrt().max(1e-9); let (ox, oy) = (ox / m * s, oy / m * s); out.push(dim_seg( Vector3::new(tip.x - ox, tip.y - oy, tip.z), Vector3::new(tip.x + ox, tip.y + oy, tip.z), common, )); } A::Open { size, half_angle } => { let size = *size as f64; let hw = size * (*half_angle as f64).tan(); out.push(dim_seg(tip, pt(size, hw), common)); out.push(dim_seg(tip, pt(size, -hw), common)); } A::Dot { size, filled } => { let r = *size as f64 * 0.5; terminator_circle(tip, r, *filled, common, &mut out); } A::Origin { size } => { terminator_circle(tip, *size as f64 * 0.25, true, common, &mut out); let half = *size as f64 * 0.5; out.push(dim_seg(pt(0.0, -half), pt(0.0, half), common)); } A::Box_ { size, filled } => { let h = *size as f64 * 0.5; let (p1, p2, p3, p4) = (pt(-h, -h), pt(h, -h), pt(h, h), pt(-h, h)); out.push(dim_seg(p1, p2, common)); out.push(dim_seg(p2, p3, common)); out.push(dim_seg(p3, p4, common)); out.push(dim_seg(p4, p1, common)); if *filled { tri(p1, p2, p3, &mut out); tri(p1, p3, p4, &mut out); } } A::Datum { size, filled } => { let half = *size as f64 * 0.5; let (ba, bb, apex) = (pt(0.0, half), pt(0.0, -half), pt(*size as f64, 0.0)); out.push(dim_seg(ba, apex, common)); out.push(dim_seg(apex, bb, common)); out.push(dim_seg(bb, ba, common)); if *filled { tri(ba, apex, bb, &mut out); } } A::Custom { size, lines, fill } => { let custom_pt = |point: &[f32; 3]| { let mut placed = pt( -(point[0] as f64) * *size as f64, -(point[1] as f64) * *size as f64, ); placed.z += point[2] as f64 * *size as f64; placed }; let mut previous = None; for point in lines { if point[0].is_nan() { previous = None; continue; } let current = custom_pt(point); if let Some(previous) = previous { out.push(dim_seg(previous, current, common)); } previous = Some(current); } for triangle in fill.chunks_exact(3) { tri( custom_pt(&triangle[0]), custom_pt(&triangle[1]), custom_pt(&triangle[2]), &mut out, ); } } } out } /// A dot terminator: a 16-gon ring outline, optionally filled with a triangle fan. fn terminator_circle( center: Vector3, r: f64, filled: bool, common: &acadrust::entities::EntityCommon, out: &mut Vec, ) { const N: usize = 16; let ring: Vec = (0..=N) .map(|i| { let a = i as f64 * std::f64::consts::TAU / N as f64; Vector3::new(center.x + a.cos() * r, center.y + a.sin() * r, center.z) }) .collect(); for w in ring.windows(2) { out.push(dim_seg(w[0], w[1], common)); } if filled { for i in 0..N { let mut s = acadrust::entities::Solid::triangle(center, ring[i], ring[i + 1]); s.common = common.clone(); s.common.handle = Handle::NULL; out.push(EntityType::Solid(s)); } } } /// Center-mark cross at `center`, arm length |DIMCEN|. Empty when DIMCEN == 0. fn dim_center_mark( center: Vector3, dimcen: f64, radius: f64, common: &acadrust::entities::EntityCommon, ) -> Vec { let mag = dimcen.abs(); if mag < 1e-9 { return Vec::new(); } let v = |x: f64, y: f64| Vector3::new(x, y, center.z); // Central "+" mark. let mut out = vec![ dim_seg(v(center.x - mag, center.y), v(center.x + mag, center.y), common), dim_seg(v(center.x, center.y - mag), v(center.x, center.y + mag), common), ]; // Negative DIMCEN adds centre LINES: four radial strokes spanning the edge. if dimcen < 0.0 && radius > mag + 1e-6 { let inner = (radius - mag).max(0.0); let outer = radius + mag; out.push(dim_seg(v(center.x + inner, center.y), v(center.x + outer, center.y), common)); out.push(dim_seg(v(center.x - inner, center.y), v(center.x - outer, center.y), common)); out.push(dim_seg(v(center.x, center.y + inner), v(center.x, center.y + outer), common)); out.push(dim_seg(v(center.x, center.y - inner), v(center.x, center.y - outer), common)); } out } /// Arc from angle `a1` to `a2` (radians, swept the short way) at `radius` about /// `center`, approximated by straight chords (world f64). fn dim_arc_segs( center: Vector3, radius: f64, a1: f64, a2: f64, common: &acadrust::entities::EntityCommon, ) -> Vec { use std::f64::consts::PI; let mut sweep = a2 - a1; while sweep > PI { sweep -= 2.0 * PI; } // Half-open at -PI so an exact 180° span sweeps the same way as the // terminator code and the live render (CCW), not the opposite semicircle. while sweep <= -PI { sweep += 2.0 * PI; } dim_arc_segs_with_sweep(center, radius, a1, sweep, common) } fn dim_arc_segs_with_sweep( center: Vector3, radius: f64, start: f64, sweep: f64, common: &acadrust::entities::EntityCommon, ) -> Vec { use std::f64::consts::PI; let steps = 12usize.max((sweep.abs() / (PI / 36.0)).ceil() as usize); let pt = |a: f64| Vector3::new(center.x + radius * a.cos(), center.y + radius * a.sin(), center.z); let mut out = Vec::new(); let mut prev = pt(start); for i in 1..=steps { let cur = pt(start + sweep * (i as f64 / steps as f64)); out.push(dim_seg(prev, cur, common)); prev = cur; } out } /// Resolved, DIMSCALE-applied dimension metrics for baking. struct DimMetrics { dimasz: f64, dimcen: f64, dimexo: f64, dimexe: f64, dimtsz: f64, dimdle: f64, dimfxl: f64, dimfxlon: bool, dimse1: bool, dimse2: bool, dimsd1: bool, dimsd2: bool, dimsoxd: bool, dimclrd: i16, dimclre: i16, dimclrt: i16, dimlwd: i16, dimlwe: i16, dimltype: Handle, dimltex1: Handle, arrow1: crate::scene::convert::tessellate::ArrowKind, arrow2: crate::scene::convert::tessellate::ArrowKind, } /// Resolve style metrics used by saved dimension geometry. fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics { let name = dim.base().style_name.as_str(); let effective_style = doc .dim_styles .iter() .find(|style| { style.name.eq_ignore_ascii_case(name) || (name.trim().is_empty() && style.name.eq_ignore_ascii_case("Standard")) }) .map(|style| crate::entities::dimension::resolved_dimension_style(style, dim, doc)); let style = effective_style.as_ref(); let scale = style .map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }) .unwrap_or(1.0); use crate::scene::convert::tessellate::{arrow_from_block, ArrowKind}; let dimasz = style.map(|s| s.dimasz * scale).unwrap_or(0.18 * scale).max(1e-6); let dimtsz = style.map(|s| s.dimtsz * scale).unwrap_or(0.0); let asz = dimasz as f32; let (arrow1, arrow2) = if dimtsz > 1e-9 { let t = ArrowKind::Tick { size: dimtsz as f32 }; (t.clone(), t) } else if let Some(s) = style { if matches!(dim, Dimension::Radius(_) | Dimension::LargeRadial(_)) { let arrow = arrow_from_block(doc, s.dimldrblk, asz); (arrow.clone(), arrow) } else if matches!(dim, Dimension::Diameter(_)) { use crate::entities::dim_override as ov; let data = &dim.base().common.extended_data; let first = ov::handle(data, ov::DIMBLK1) .unwrap_or(if s.dimsah { s.dimblk1 } else { s.dimblk }); let second = ov::handle(data, ov::DIMBLK2) .unwrap_or(if s.dimsah { s.dimblk2 } else { s.dimblk }); (arrow_from_block(doc, first, asz), arrow_from_block(doc, second, asz)) } else if s.dimsah { (arrow_from_block(doc, s.dimblk1, asz), arrow_from_block(doc, s.dimblk2, asz)) } else { let a = arrow_from_block(doc, s.dimblk, asz); (a.clone(), a) } } else { let a = ArrowKind::Triangle { size: asz, filled: true, size_mul: 1.0 }; (a.clone(), a) }; DimMetrics { dimasz, dimcen: style.map(|s| s.dimcen * scale).unwrap_or(0.09 * scale), dimexo: style.map(|s| s.dimexo * scale).unwrap_or(0.0), dimexe: style.map(|s| s.dimexe * scale).unwrap_or(0.0), dimtsz, dimdle: style.map(|s| s.dimdle * scale).unwrap_or(0.0), dimfxl: style.map(|s| s.dimfxl * scale).unwrap_or(1.0), dimfxlon: style.map(|s| s.dimfxlon).unwrap_or(false), dimse1: style.map(|s| s.dimse1).unwrap_or(false), dimse2: style.map(|s| s.dimse2).unwrap_or(false), dimsd1: style.map(|s| s.dimsd1).unwrap_or(false), dimsd2: style.map(|s| s.dimsd2).unwrap_or(false), dimsoxd: style.map(|s| s.dimsoxd).unwrap_or(false), dimclrd: style.map(|s| s.dimclrd).unwrap_or(0), dimclre: style.map(|s| s.dimclre).unwrap_or(0), dimclrt: style.map(|s| s.dimclrt).unwrap_or(0), dimlwd: style.map(|s| s.dimlwd).unwrap_or(-2), dimlwe: style.map(|s| s.dimlwe).unwrap_or(-2), dimltype: style.map(|s| s.dimltex_handle).unwrap_or(Handle::NULL), dimltex1: style.map(|s| s.dimltex1_handle).unwrap_or(Handle::NULL), arrow1, arrow2, } } /// A baked sub-entity common with a per-element dimension colour / lineweight. /// A DIMCLR* of 0 (ByBlock) or 256 (ByLayer) keeps the dimension's own colour /// so the block inherits it; a specific ACI overrides. DIMLW* < 0 (ByBlock / /// ByLayer) keeps the dimension's lineweight. fn dim_common(base: &acadrust::entities::EntityCommon, clr: i16, lw: i16) -> acadrust::entities::EntityCommon { let mut c = base.clone(); c.handle = Handle::NULL; if clr != 0 && clr != 256 { c.color = acadrust::types::Color::from_index(clr); c.color_name = None; c.color_book_handle = None; } if lw >= 0 { c.line_weight = acadrust::types::LineWeight::from_value(lw); } c } fn with_dim_linetype( mut common: acadrust::entities::EntityCommon, doc: &CadDocument, handle: Handle, ) -> acadrust::entities::EntityCommon { common.linetype = doc .line_types .iter() .find(|line_type| line_type.handle == handle) .map(|line_type| line_type.name.clone()) .unwrap_or_else(|| "Continuous".to_string()); common.linetype_handle = (!handle.is_null()).then_some(handle); common } /// Baked geometry for an angular dimension, matching the live render exactly: /// an extension line from each measured ray point flush out to the dimension /// arc, the swept arc, and a terminator at each arc end. (The live angular path /// applies neither DIMEXO/DIMEXE nor DIMSE suppression, so the bake mustn't /// either, or the dim would shift on reload. Adding those to both is tracked as /// a separate live-render enhancement.) #181 / DIM-022. fn angular_block_segs( vertex: Vector3, p1: Vector3, p2: Vector3, arc_loc: Vector3, met: &DimMetrics, ext_c: &acadrust::entities::EntityCommon, dim_c: &acadrust::entities::EntityCommon, explicit_sweep: Option<(f64, f64)>, ) -> Vec { use std::f64::consts::PI; let measured_a1 = (p1.y - vertex.y).atan2(p1.x - vertex.x); let measured_a2 = (p2.y - vertex.y).atan2(p2.x - vertex.x); let (a1, a2) = explicit_sweep.unwrap_or((measured_a1, measured_a2)); let radius = ((arc_loc.x - vertex.x).powi(2) + (arc_loc.y - vertex.y).powi(2)).sqrt(); let mut out = Vec::new(); if radius < 1e-9 { out.push(dim_seg(p1, vertex, ext_c)); out.push(dim_seg(p2, vertex, ext_c)); return out; } // Extension lines run flush from each measured ray point out to the arc. let e1 = Vector3::new(vertex.x + a1.cos() * radius, vertex.y + a1.sin() * radius, vertex.z); let e2 = Vector3::new(vertex.x + a2.cos() * radius, vertex.y + a2.sin() * radius, vertex.z); out.push(dim_seg(p1, e1, ext_c)); out.push(dim_seg(p2, e2, ext_c)); let mut sweep = a2 - a1; if explicit_sweep.is_some() { while sweep <= 0.0 { sweep += 2.0 * PI; } out.extend(dim_arc_segs_with_sweep( vertex, radius, a1, sweep, dim_c, )); } else { out.extend(dim_arc_segs(vertex, radius, a1, a2, dim_c)); } // Terminators tangent to the arc at each end, pointing along the sweep. if explicit_sweep.is_none() { while sweep > PI { sweep -= 2.0 * PI; } while sweep <= -PI { sweep += 2.0 * PI; } } let sgn = if sweep >= 0.0 { 1.0 } else { -1.0 }; out.extend(dim_terminator(e1, -a1.sin() * sgn, a1.cos() * sgn, &met.arrow1, dim_c)); out.extend(dim_terminator(e2, a2.sin() * sgn, -a2.cos() * sgn, &met.arrow2, dim_c)); out } /// The text anchor for a radial leader: the saved text middle point when set, /// else the midpoint of `a` and `b` — mirroring the live `dimension_text_position`. fn dim_text_anchor( base: &acadrust::entities::dimension::DimensionBase, a: Vector3, b: Vector3, ) -> Vector3 { let p = base.text_middle_point; if p.x * p.x + p.y * p.y + p.z * p.z > 1e-8 { p } else { Vector3::new((a.x + b.x) * 0.5, (a.y + b.y) * 0.5, (a.z + b.z) * 0.5) } } /// The (start, end) of a linear/aligned extension line. `origin` is the /// measured point, `land` its landing on the dim line, `sign` the perpendicular /// direction, `(edx,edy)` the (possibly oblique) extension direction. Normally /// the line runs from `origin + DIMEXO` to `land + DIMEXE`; with DIMFXLON it is /// a fixed length DIMFXL back from the dim line instead. DIM-FXL. fn ext_endpoints( origin: Vector3, land: Vector3, sign: f64, edx: f64, edy: f64, met: &DimMetrics, ) -> (Vector3, Vector3) { let end = Vector3::new( land.x + edx * (sign * met.dimexe), land.y + edy * (sign * met.dimexe), land.z, ); let start = if met.dimfxlon { Vector3::new( land.x - edx * (sign * met.dimfxl), land.y - edy * (sign * met.dimfxl), land.z, ) } else { Vector3::new( origin.x + edx * (sign * met.dimexo), origin.y + edy * (sign * met.dimexo), origin.z, ) }; (start, end) } /// Normalise `(dx,dy)`, falling back to `(fx,fy)` when it is ~zero length. fn norm2(dx: f64, dy: f64, fx: f64, fy: f64) -> (f64, f64) { let m = (dx * dx + dy * dy).sqrt(); if m > 1e-9 { (dx / m, dy / m) } else { (fx, fy) } } fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { if let Dimension::Arc(arc) = dim { if let Some((local_arc, normal)) = crate::entities::dimension::arc_dimension_in_ocs(arc) { let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes(( normal.x, normal.y, normal.z, )); let plane = WorkingPlane::new( DVec3::ZERO, DVec3::new(x_axis.0, x_axis.1, x_axis.2), DVec3::new(y_axis.0, y_axis.1, y_axis.2), ); return explode_dimension(&Dimension::Arc(local_arc), doc) .into_iter() .map(|entity| plane.place_entity(entity)) .collect(); } } if let Dimension::LargeRadial(radial) = dim { if let Some((local_radial, normal)) = crate::entities::dimension::large_radial_dimension_in_ocs(radial) { let (x_axis, y_axis) = crate::scene::view::transform::ocs_axes(( normal.x, normal.y, normal.z, )); let plane = WorkingPlane::new( DVec3::ZERO, DVec3::new(x_axis.0, x_axis.1, x_axis.2), DVec3::new(y_axis.0, y_axis.1, y_axis.2), ); return explode_dimension(&Dimension::LargeRadial(local_radial), doc) .into_iter() .map(|entity| plane.place_entity(entity)) .collect(); } } let base = dim.base(); let met = dim_metrics(dim, doc); // Per-element commons so a baked block keeps the style's colours/lineweights: // extension lines (DIMCLRE/DIMLWE), dimension line + arrows + centre mark // (DIMCLRD/DIMLWD), text (DIMCLRT). let ext_c = dim_common(&base.common, met.dimclre, met.dimlwe); let dim_c = dim_common(&base.common, met.dimclrd, met.dimlwd); let mut result: Vec = Vec::new(); // Helper: make a line segment with a given common. let make_seg = |a: &Vector3, b: &Vector3, common: &EntityCommon| -> EntityType { let mut c = common.clone(); c.handle = Handle::NULL; EntityType::Line(LineEnt { common: c, start: a.clone(), end: b.clone(), ..LineEnt::new() }) }; let v3 = |x: f64, y: f64, z: f64| Vector3::new(x, y, z); // Dimension line for a linear/aligned dim, between d1 and d2 with the unit // direction (ux,uy): DIMDLE overshoot when ticks are used, omitted when both // halves are suppressed. When the arrows don't fit between the extension // lines (gap < 2·DIMASZ, arrows only) they flip to the outside with short // stubs unless DIMSOXD — matching the live fit logic. DIM-ARROWS-OUTSIDE. let push_dim_line = |result: &mut Vec, d1: Vector3, d2: Vector3, ux: f64, uy: f64| { let ticks = met.dimtsz > 1e-9; if !(met.dimsd1 && met.dimsd2) { let dle = if ticks { met.dimdle } else { 0.0 }; let a = v3(d1.x - ux * dle, d1.y - uy * dle, d1.z); let b = v3(d2.x + ux * dle, d2.y + uy * dle, d2.z); result.push(make_seg(&a, &b, &dim_c)); } let gap = ((d2.x - d1.x).powi(2) + (d2.y - d1.y).powi(2)).sqrt(); let outside = !ticks && met.dimasz > 1e-6 && gap < 2.0 * met.dimasz; if outside { // Arrow bodies extend outward (tips still at the origins). result.extend(dim_terminator(d1, -ux, -uy, &met.arrow1, &dim_c)); result.extend(dim_terminator(d2, ux, uy, &met.arrow2, &dim_c)); if !met.dimsoxd { let stub = 2.0 * met.dimasz; result.push(make_seg(&v3(d1.x - ux * stub, d1.y - uy * stub, d1.z), &d1, &dim_c)); result.push(make_seg(&d2, &v3(d2.x + ux * stub, d2.y + uy * stub, d2.z), &dim_c)); } } else { result.extend(dim_terminator(d1, ux, uy, &met.arrow1, &dim_c)); result.extend(dim_terminator(d2, -ux, -uy, &met.arrow2, &dim_c)); } }; match dim { Dimension::Aligned(d) => { let fx = d.first_point.x; let fy = d.first_point.y; let sx = d.second_point.x; let sy = d.second_point.y; let axis_angle = (sy - fy).atan2(sx - fx); let perp_x = -(axis_angle.sin()); let perp_y = axis_angle.cos(); let offset = (d.definition_point.x - fx) * perp_x + (d.definition_point.y - fy) * perp_y; let d1 = v3(fx + perp_x * offset, fy + perp_y * offset, d.first_point.z); let d2 = v3(sx + perp_x * offset, sy + perp_y * offset, d.second_point.z); let s = if offset >= 0.0 { 1.0 } else { -1.0 }; // DIMEDIT "Oblique": extension lines slant by ext_line_rotation (the // dim line itself stays on the unrotated perp). DIM-EXT-OBLIQUE. let (es, ec) = d.ext_line_rotation.sin_cos(); let edx = perp_x * ec - perp_y * es; let edy = perp_x * es + perp_y * ec; let (e1a, e1b) = ext_endpoints(d.first_point, d1, s, edx, edy, &met); let (e2a, e2b) = ext_endpoints(d.second_point, d2, s, edx, edy, &met); if !met.dimse1 { result.push(make_seg(&e1a, &e1b, &ext_c)); } if !met.dimse2 { result.push(make_seg(&e2a, &e2b, &ext_c)); } let ml = ((d2.x - d1.x).powi(2) + (d2.y - d1.y).powi(2)).sqrt().max(1e-12); let (ux, uy) = ((d2.x - d1.x) / ml, (d2.y - d1.y) / ml); push_dim_line(&mut result, d1, d2, ux, uy); } Dimension::Linear(d) => { // `rotation` is the dimension-line angle, already in radians. let angle = d.rotation; let perp_x = -(angle.sin()); let perp_y = angle.cos(); let fx = d.first_point.x; let fy = d.first_point.y; let sx = d.second_point.x; let sy = d.second_point.y; // Project each extension origin onto the dim line independently — a // shared offset tilts the line over non-level origins (#181). let dperp = d.definition_point.x * perp_x + d.definition_point.y * perp_y; let off1 = dperp - (fx * perp_x + fy * perp_y); let off2 = dperp - (sx * perp_x + sy * perp_y); let d1 = v3(fx + perp_x * off1, fy + perp_y * off1, d.first_point.z); let d2 = v3(sx + perp_x * off2, sy + perp_y * off2, d.second_point.z); // Extension lines: DIMEXO start gap from the point, DIMEXE overshoot // past the dim line, in the perp direction toward it. #181 / DIM-023. let s1 = if off1 >= 0.0 { 1.0 } else { -1.0 }; let s2 = if off2 >= 0.0 { 1.0 } else { -1.0 }; // DIMEDIT "Oblique": extension lines slant by ext_line_rotation. let (es, ec) = d.ext_line_rotation.sin_cos(); let edx = perp_x * ec - perp_y * es; let edy = perp_x * es + perp_y * ec; let (e1a, e1b) = ext_endpoints(d.first_point, d1, s1, edx, edy, &met); let (e2a, e2b) = ext_endpoints(d.second_point, d2, s2, edx, edy, &met); if !met.dimse1 { result.push(make_seg(&e1a, &e1b, &ext_c)); } if !met.dimse2 { result.push(make_seg(&e2a, &e2b, &ext_c)); } let ml = ((d2.x - d1.x).powi(2) + (d2.y - d1.y).powi(2)).sqrt().max(1e-12); let (ux, uy) = ((d2.x - d1.x) / ml, (d2.y - d1.y) / ml); push_dim_line(&mut result, d1, d2, ux, uy); } Dimension::Radius(d) => { let (center, point) = (d.angle_vertex, d.definition_point); result.push(make_seg(¢er, &point, &dim_c)); let len = ((center.x - point.x).powi(2) + (center.y - point.y).powi(2)) .sqrt() .max(1e-12); result.extend(dim_terminator( point, (center.x - point.x) / len, (center.y - point.y) / len, &met.arrow1, &dim_c, )); // Leader from the arrow tip toward the text — to the text anchor when // leader_length is 0, else that far along the text direction (matches // the live radius render). DIM-RAD-LEADER. let anchor = dim_text_anchor(base, center, point); let ld = norm2(anchor.x - point.x, anchor.y - point.y, 1.0, 0.0); let leader = if d.leader_length.abs() > 1e-9 { v3(point.x + ld.0 * d.leader_length, point.y + ld.1 * d.leader_length, point.z) } else { anchor }; result.push(make_seg(&point, &leader, &dim_c)); result.extend(dim_center_mark(center, met.dimcen, len, &dim_c)); } Dimension::Diameter(d) => { let (edge, far) = (d.angle_vertex, d.definition_point); let center = v3( (edge.x + far.x) * 0.5, (edge.y + far.y) * 0.5, (edge.z + far.z) * 0.5, ); let len = ((edge.x - far.x).powi(2) + (edge.y - far.y).powi(2)) .sqrt() .max(1e-12); let (ux, uy) = ((edge.x - far.x) / len, (edge.y - far.y) / len); let ticks = met.dimtsz > 1e-9; let extension = if ticks { met.dimdle } else { 0.0 }; let edge_outer = v3( edge.x + ux * extension, edge.y + uy * extension, edge.z, ); let far_outer = v3( far.x - ux * extension, far.y - uy * extension, far.z, ); if !met.dimsd1 { result.push(make_seg(&edge_outer, ¢er, &dim_c)); } if !met.dimsd2 { result.push(make_seg(¢er, &far_outer, &dim_c)); } let outside = !ticks && met.dimasz > 1e-6 && len < 2.0 * met.dimasz; if outside { result.extend(dim_terminator(edge, ux, uy, &met.arrow1, &dim_c)); result.extend(dim_terminator(far, -ux, -uy, &met.arrow2, &dim_c)); if !met.dimsoxd { let stub = 2.0 * met.dimasz; if !met.dimsd1 { result.push(make_seg( &edge, &v3(edge.x + ux * stub, edge.y + uy * stub, edge.z), &dim_c, )); } if !met.dimsd2 { result.push(make_seg( &far, &v3(far.x - ux * stub, far.y - uy * stub, far.z), &dim_c, )); } } } else { result.extend(dim_terminator(edge, -ux, -uy, &met.arrow1, &dim_c)); result.extend(dim_terminator(far, ux, uy, &met.arrow2, &dim_c)); } let anchor = dim_text_anchor(base, center, edge); let distance_squared = |first: Vector3, second: Vector3| { (first.x - second.x).powi(2) + (first.y - second.y).powi(2) + (first.z - second.z).powi(2) }; let (leader_tip, suppressed, fallback) = if distance_squared(anchor, edge) <= distance_squared(anchor, far) { (edge, met.dimsd1, (ux, uy)) } else { (far, met.dimsd2, (-ux, -uy)) }; if !suppressed && d.leader_length.abs() > 1e-9 { let ld = norm2( anchor.x - leader_tip.x, anchor.y - leader_tip.y, fallback.0, fallback.1, ); result.push(make_seg( &leader_tip, &v3( leader_tip.x + ld.0 * d.leader_length.abs(), leader_tip.y + ld.1 * d.leader_length.abs(), leader_tip.z, ), &dim_c, )); } if !met.dimse1 { if let Some(points) = crate::scene::dimension_assoc::radial_extension_points( doc, base.common.handle, met.dimexo, met.dimexe, ) { for pair in points.windows(2) { result.push(make_seg(&pair[0], &pair[1], &ext_c)); } } } result.extend(dim_center_mark(center, met.dimcen, len * 0.5, &dim_c)); } Dimension::Angular2Ln(d) => { result.extend(angular_block_segs( d.angle_vertex, d.first_point, d.second_point, d.dimension_arc, &met, &ext_c, &dim_c, None, )); } Dimension::Angular3Pt(d) => { result.extend(angular_block_segs( d.angle_vertex, d.first_point, d.second_point, d.definition_point, &met, &ext_c, &dim_c, None, )); } Dimension::Ordinate(d) => { if !met.dimse1 { let fixed_length = met.dimfxlon.then_some(met.dimfxl); let points = d.leader_polyline(met.dimasz * 2.0, met.dimexo, fixed_length); for pair in points.windows(2) { if (pair[1] - pair[0]).length() > 1e-12 { result.push(make_seg(&pair[0], &pair[1], &ext_c)); } } } } Dimension::Arc(d) => { let explicit_sweep = crate::entities::dimension::arc_dimension_angles(d) .map(|(start, end)| (start as f64, end as f64)); result.extend(angular_block_segs( d.center_point, d.first_extension_point, d.second_extension_point, d.definition_point, &met, &ext_c, &dim_c, explicit_sweep, )); if d.has_leader { result.push(make_seg( &d.first_leader_point, &d.second_leader_point, &dim_c, )); } } Dimension::LargeRadial(_) => { if let Some(geometry) = crate::entities::dimension::baked_large_radial_geometry(dim, doc) { let radial_ext_c = with_dim_linetype(ext_c.clone(), doc, met.dimltex1); let radial_dim_c = with_dim_linetype(dim_c.clone(), doc, met.dimltype); result.extend(dim_geom_entities(&geometry, &radial_ext_c, &radial_dim_c)); } } } let symbol_points = crate::entities::dimension::baked_arc_length_symbol_points(dim, doc, 1.0); if symbol_points.len() > 1 { let text_c = dim_common(&base.common, met.dimclrt, -2); for pair in symbol_points.windows(2) { result.push(make_seg(&pair[0], &pair[1], &text_c)); } } // Measurement text is the live render's own Text/MText entity (value, // position, height, rotation, alignment, text style, MText handling all // shared), so the baked block matches the on-screen dimension and the label // doesn't shift when the file is saved and reopened. `None` = suppressed. if let Some(mut tent) = crate::entities::dimension::baked_dimension_text_entity(dim, doc, 1.0) { // Apply the text colour / lineweight (DIMCLRT) onto the entity. let tc = dim_common(&base.common, met.dimclrt, -2); tent.common_mut().color = tc.color; tent.common_mut().color_name = tc.color_name; tent.common_mut().color_book_handle = tc.color_book_handle; tent.common_mut().line_weight = tc.line_weight; result.push(tent); } result } // ── Dimension block baking (DWG/DXF interop) ──────────────────────────────── /// Mirror each dimension's authoritative geometric definition point into /// `base.definition_point`, which is the field the DWG/DXF writer emits as /// group 10. Edits (grips, properties, transforms) update the per-type struct /// field but not `base`, so without this the saved group 10 goes stale and the /// dimension's line / leader / origin jumps on reload (#181). Angular-2-line /// keeps a distinct base point (the second line's point) and is left alone. fn sync_dimension_base_points_and_collect_pending(doc: &mut CadDocument) -> Vec { let existing_blocks: rustc_hash::FxHashSet = doc .block_records .iter() .map(|record| record.name.to_ascii_lowercase()) .collect(); let dimensions: Vec<(Handle, Option, bool)> = doc .entities() .filter_map(|entity| { let EntityType::Dimension(dimension) = entity else { return None; }; let definition_point = match dimension { Dimension::Linear(value) => Some(value.definition_point), Dimension::Aligned(value) => Some(value.definition_point), Dimension::Radius(value) => Some(value.definition_point), Dimension::Diameter(value) => Some(value.definition_point), Dimension::Ordinate(value) => Some(value.definition_point), Dimension::Angular3Pt(value) => Some(value.definition_point), Dimension::Arc(value) => Some(value.definition_point), Dimension::LargeRadial(value) => Some(value.definition_point), Dimension::Angular2Ln(_) => None, }; let base = dimension.base(); let needs_sync = definition_point.filter(|point| *point != base.definition_point); let block_missing = base.block_name.trim().is_empty() || !existing_blocks.contains(&base.block_name.to_ascii_lowercase()); Some((base.common.handle, needs_sync, block_missing)) }) .collect(); let mut pending = Vec::new(); for (handle, definition_point, block_missing) in dimensions { if let Some(point) = definition_point { if let Some(EntityType::Dimension(dimension)) = doc.get_entity_mut(handle) { dimension.base_mut().definition_point = point; } } if block_missing { pending.push(handle); } } pending } /// Smallest free `*D` anonymous block name in `doc`. fn next_dimension_block_name(doc: &CadDocument, next: &mut u64) -> String { loop { let n = *next; *next = (*next).saturating_add(1); let cand = format!("*D{n}"); if doc.block_records.get(&cand).is_none() { return cand; } } } /// Build missing anonymous dimension geometry blocks before writing. pub fn bake_dimension_blocks(doc: &mut CadDocument) { // Keep group-10 in step and find missing blocks in one entity pass. Existing // `*D` blocks are the save cache: only invalidated/new dimensions enter the // relatively expensive geometry generation below. let pending = sync_dimension_base_points_and_collect_pending(doc); let dimensions: Vec<(Handle, Dimension)> = pending .into_iter() .filter_map(|handle| match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => Some((handle, d.clone())), _ => None, }) .collect(); use crate::par::prelude::*; let doc_ref: &CadDocument = doc; let generated: Vec<(Handle, Vec)> = dimensions .par_iter() .map(|(handle, dim)| (*handle, explode_dimension(dim, doc_ref))) .collect(); let mut next_block_number = 0u64; for (handle, subs) in generated { if subs.is_empty() { continue; } let name = next_dimension_block_name(doc, &mut next_block_number); // Reserve three consecutive handles for the record / block / endblk. // Adding the block + endblk (which carry explicit handles) advances the // document's handle counter past them, so the NULL-handle sub-entities // added afterwards get fresh handles without colliding. let next = doc.next_handle(); let br_handle = Handle::new(next); let block_handle = Handle::new(next + 1); let end_handle = Handle::new(next + 2); let mut br = BlockRecord::new(&name); br.handle = br_handle; br.block_entity_handle = block_handle; br.block_end_handle = end_handle; br.flags.anonymous = true; if doc.block_records.add(br).is_err() { continue; } let mut block = Block::new(&name, Vector3::new(0.0, 0.0, 0.0)); block.common.handle = block_handle; block.common.owner_handle = br_handle; let _ = doc.add_entity(EntityType::Block(block)); let mut block_end = BlockEnd::new(); block_end.common.handle = end_handle; block_end.common.owner_handle = br_handle; let _ = doc.add_entity(EntityType::BlockEnd(block_end)); for mut sub in subs { sub.common_mut().handle = Handle::NULL; sub.common_mut().owner_handle = br_handle; let _ = doc.add_entity(sub); } if let Some(EntityType::Dimension(d)) = doc.get_entity_mut(handle) { d.base_mut().block_name = name; // Baked geometry uses absolute coordinates. d.base_mut().insertion_point = Vector3::new(0.0, 0.0, 0.0); } } } /// Drop baked dimension geometry so the next save regenerates it. pub fn invalidate_dim_block(doc: &mut CadDocument, handle: Handle) { let bn = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().block_name.clone(), _ => return, }; if bn.trim().is_empty() { return; } // Dimension graphics live in an anonymous "*D…" block, unique to the one // dimension, so it is safe to delete along with its sub-entities. Guard on // the anonymous prefix so a hand-referenced named block is never removed. if bn.starts_with("*D") || bn.starts_with("*d") { if let Some(rec) = doc.block_records.remove(&bn) { let mut owned = rec.entity_handles.clone(); owned.push(rec.block_entity_handle); owned.push(rec.block_end_handle); for h in owned { if !h.is_null() { doc.remove_entity(h); } } } } if let Some(EntityType::Dimension(d)) = doc.get_entity_mut(handle) { d.base_mut().block_name.clear(); } } // ── Command stub (kept for future interactive selection mode) ─────────────── pub struct ExplodeCommand; impl ExplodeCommand { #[allow(dead_code)] pub fn new() -> Self { Self } } impl CadCommand for ExplodeCommand { fn name(&self) -> &'static str { "EXPLODE" } fn prompt(&self) -> String { t!("EXPLODE Select objects to explode:").into_owned() } fn on_point(&mut self, _pt: DVec3) -> CmdResult { CmdResult::Cancel } fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } } #[cfg(test)] mod tests { use super::*; use acadrust::entities::DimensionLinear; /// A dimension created without a geometry block gets a real `*D` block on /// bake, its `block_name` resolves to that block, and a second bake is a /// no-op (already has a valid block). #[test] fn bakes_block_for_blockless_dimension_and_is_idempotent() { let mut doc = CadDocument::new(); let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); d.definition_point = Vector3::new(0.0, 5.0, 0.0); d.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0); // block_name is left empty — exactly what OCS-created dimensions carry. let handle = doc .add_entity(EntityType::Dimension(Dimension::Linear(d))) .unwrap(); bake_dimension_blocks(&mut doc); let block_name = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().block_name.clone(), _ => panic!("dimension missing"), }; assert!(!block_name.trim().is_empty(), "block_name should be set"); assert!( doc.block_records.get(&block_name).is_some(), "baked block must exist in the block table" ); // Second pass must not create another block for the same dimension. let before = doc.block_records.len(); bake_dimension_blocks(&mut doc); assert_eq!( doc.block_records.len(), before, "a dimension that already owns a block must not be re-baked" ); } /// Baking a blockless dimension resets its group-12 insertion point to the /// origin. OCS's commands seed it with the text anchor; left non-zero, a /// reader that positions the absolute-WCS block by that point draws the /// dimension shifted. Regression test for #181. #[test] fn bake_zeroes_insertion_point() { let mut doc = CadDocument::new(); let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); d.definition_point = Vector3::new(0.0, 5.0, 0.0); d.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0); // Exactly what OCS-created dimensions carry: insertion == text anchor. d.base.insertion_point = d.base.text_middle_point; let handle = doc .add_entity(EntityType::Dimension(Dimension::Linear(d))) .unwrap(); bake_dimension_blocks(&mut doc); let ins = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().insertion_point, _ => panic!("dimension missing"), }; assert_eq!( ins, Vector3::new(0.0, 0.0, 0.0), "baked dimension must carry a zero insertion point (#181)" ); } /// Invalidating a baked dimension removes its `*D` block record AND the /// sub-entities it owned (no orphans), and clears `block_name` so the next /// save re-bakes a fresh block. Guards the stale-export fix for #181. #[test] fn invalidate_removes_block_and_leaves_no_orphan() { let mut doc = CadDocument::new(); let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); d.definition_point = Vector3::new(0.0, 5.0, 0.0); d.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0); let handle = doc .add_entity(EntityType::Dimension(Dimension::Linear(d))) .unwrap(); bake_dimension_blocks(&mut doc); let block_name = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().block_name.clone(), _ => panic!("dimension missing"), }; assert!(!block_name.trim().is_empty()); let owned: Vec = doc .block_records .get(&block_name) .unwrap() .entity_handles .clone(); assert!(!owned.is_empty(), "baked block should own sub-entities"); invalidate_dim_block(&mut doc, handle); // block_name cleared → the dimension is pending again. match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => { assert!(d.base().block_name.trim().is_empty(), "block_name must be cleared"); } _ => panic!("dimension missing"), } // Block record gone. assert!( doc.block_records.get(&block_name).is_none(), "stale block record must be removed" ); // No orphaned sub-entities left behind. for h in owned { assert!( doc.get_entity(h).is_none(), "sub-entity {h:?} of the dropped block leaked" ); } // A fresh bake re-creates a block, so the round-trip stays valid. bake_dimension_blocks(&mut doc); let rebaked = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().block_name.clone(), _ => panic!("dimension missing"), }; assert!(!rebaked.trim().is_empty(), "re-bake must assign a new block"); } // Collect the line segments baked into the dimension's `*D` block. fn baked_segments(doc: &CadDocument, block_name: &str) -> Vec<(Vector3, Vector3)> { let rec = doc.block_records.get(block_name).expect("block record"); doc.entities() .filter_map(|e| match e { EntityType::Line(l) if l.common.owner_handle == rec.handle => { Some((l.start, l.end)) } _ => None, }) .collect() } // A horizontal (rotation = 0) linear dimension whose two measured points sit // at *different* heights must still bake a level dimension line — both // extension origins project onto the same line. Regression test for #181, // where a shared offset tilted the dimension line. #[test] fn linear_dim_line_stays_level_over_sloped_points() { let mut doc = CadDocument::new(); let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 5.0, 0.0)); d.rotation = 0.0; d.definition_point = Vector3::new(0.0, 8.0, 0.0); let handle = doc .add_entity(EntityType::Dimension(Dimension::Linear(d))) .unwrap(); bake_dimension_blocks(&mut doc); let name = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().block_name.clone(), _ => panic!("dimension missing"), }; // The dimension line is the segment spanning both x extents; it must be // horizontal at the definition-point level (y = 8). let dim_line = baked_segments(&doc, &name) .into_iter() .find(|(a, b)| (a.x - 0.0).abs() < 1e-6 && (b.x - 10.0).abs() < 1e-6) .expect("dimension line segment"); assert!( (dim_line.0.y - 8.0).abs() < 1e-6 && (dim_line.1.y - 8.0).abs() < 1e-6, "dimension line must be level at y=8, got {:?}", dim_line ); } // A linear dimension bakes closed FILLED arrowheads (SOLID entities), not // generic open strokes — so a saved+reloaded dim keeps the style's arrow // type. (Default style: DIMTSZ=0 -> arrows, two of them.) #[test] fn linear_dim_bakes_filled_arrowheads() { let mut doc = CadDocument::new(); let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); d.definition_point = Vector3::new(0.0, 5.0, 0.0); let handle = doc .add_entity(EntityType::Dimension(Dimension::Linear(d))) .unwrap(); bake_dimension_blocks(&mut doc); let name = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().block_name.clone(), _ => panic!("dimension missing"), }; let rec = doc.block_records.get(&name).expect("block"); let solids = doc .entities() .filter(|e| matches!(e, EntityType::Solid(s) if s.common.owner_handle == rec.handle)) .count(); assert_eq!(solids, 2, "expected 2 filled (SOLID) arrowheads, got {solids}"); } // An angular dimension must bake its swept ARC (not just two rays), else a // saved+reloaded angular dim collapses to a V. The block should carry the // two extension lines plus many arc chords. #[test] fn angular_dim_bakes_an_arc() { use acadrust::entities::DimensionAngular3Pt; let mut doc = CadDocument::new(); let mut d = DimensionAngular3Pt::new( Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0), Vector3::new(0.0, 10.0, 0.0), ); d.definition_point = Vector3::new(5.0, 5.0, 0.0); // arc location let handle = doc .add_entity(EntityType::Dimension(Dimension::Angular3Pt(d))) .unwrap(); bake_dimension_blocks(&mut doc); let name = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().block_name.clone(), _ => panic!("dimension missing"), }; let rec = doc.block_records.get(&name).expect("block"); let lines = doc .entities() .filter(|e| matches!(e, EntityType::Line(l) if l.common.owner_handle == rec.handle)) .count(); assert!(lines > 5, "angular bake must include arc chords, got {lines} lines"); } // Diameter endpoints stay equidistant from the circle center. #[test] fn diameter_dim_bakes_through_center() { use acadrust::entities::DimensionDiameter; let mut doc = CadDocument::new(); let center = Vector3::new(3.0, 4.0, 0.0); let edge = Vector3::new(8.0, 4.0, 0.0); // radius 5 along +x let far = Vector3::new(-2.0, 4.0, 0.0); let mut d = DimensionDiameter::new(edge, far); d.base.text_middle_point = Vector3::new(3.0, 9.0, 0.0); let handle = doc .add_entity(EntityType::Dimension(Dimension::Diameter(d))) .unwrap(); bake_dimension_blocks(&mut doc); let name = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().block_name.clone(), _ => panic!("dimension missing"), }; // Each suppressible half must join an endpoint to the circle centre. let segments = baked_segments(&doc, &name); let connects = |expected: Vector3| { segments.iter().any(|(a, b)| { let near = |p: &Vector3, q: &Vector3| { (p.x - q.x).abs() < 1e-6 && (p.y - q.y).abs() < 1e-6 && (p.z - q.z).abs() < 1e-6 }; (near(a, &expected) && near(b, ¢er)) || (near(b, &expected) && near(a, ¢er)) }) }; assert!(connects(edge), "near diameter half missing"); assert!(connects(far), "far diameter half missing"); } // A rotated linear dimension uses `rotation` directly (radians). Before the // fix `to_radians()` shrank a 90° dim to ~1.57°, baking a nearly-horizontal // line instead of a vertical one. #[test] fn rotated_linear_dim_bakes_at_its_angle() { let mut doc = CadDocument::new(); let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(0.0, 10.0, 0.0)); d.rotation = std::f64::consts::FRAC_PI_2; // 90°, vertical dimension line d.definition_point = Vector3::new(8.0, 0.0, 0.0); let handle = doc .add_entity(EntityType::Dimension(Dimension::Linear(d))) .unwrap(); bake_dimension_blocks(&mut doc); let name = match doc.get_entity(handle) { Some(EntityType::Dimension(d)) => d.base().block_name.clone(), _ => panic!("dimension missing"), }; // Dimension line spans both y extents and must be vertical at x = 8. let dim_line = baked_segments(&doc, &name) .into_iter() .find(|(a, b)| (a.y - 0.0).abs() < 1e-6 && (b.y - 10.0).abs() < 1e-6) .expect("dimension line segment"); assert!( (dim_line.0.x - 8.0).abs() < 1e-6 && (dim_line.1.x - 8.0).abs() < 1e-6, "dimension line must be vertical at x=8, got {:?}", dim_line ); } // EXPLODE replaces the polyline with fresh Line/Arc entities; each piece // must carry the source thickness instead of resetting to 0 (#916). #[test] fn explode_keeps_lwpolyline_thickness() { use acadrust::entities::LwVertex; use acadrust::types::Vector2; let mut pl = LwPolyline::new(); // One straight span + one bulged span, so the result holds a Line and // an Arc and both paths through the rebuild are covered. let mut v1 = LwVertex::new(Vector2::new(0.0, 0.0)); v1.bulge = 1.0; // semicircle to the next vertex pl.vertices = vec![ v1, LwVertex::new(Vector2::new(10.0, 0.0)), LwVertex::new(Vector2::new(20.0, 0.0)), ]; pl.thickness = 4.0; let pieces = explode_polyline_segments(&EntityType::LwPolyline(pl)); assert!(pieces.len() >= 2, "expected a line and an arc piece"); for piece in &pieces { match piece { EntityType::Line(l) => assert!( (l.thickness - 4.0).abs() < 1e-12, "exploded line must keep thickness, got {}", l.thickness ), EntityType::Arc(a) => assert!( (a.thickness - 4.0).abs() < 1e-12, "exploded arc must keep thickness, got {}", a.thickness ), other => panic!("unexpected piece type: {other:?}"), } } } #[test] fn explode_keeps_polyline2d_extrusion() { use acadrust::entities::Vertex2D; let mut pl = Polyline2D::new(); pl.vertices = vec![ Vertex2D::new(Vector3::new(0.0, 0.0, 0.0)), Vertex2D::new(Vector3::new(10.0, 0.0, 0.0)), ]; pl.elevation = 2.0; pl.thickness = -4.0; pl.normal = Vector3::new(1.0, 0.0, 0.0); let pieces = explode_polyline_segments(&EntityType::Polyline2D(pl)); let Some(EntityType::Line(line)) = pieces.first() else { panic!("expected one line"); }; assert_eq!(pieces.len(), 1); assert_eq!(line.normal, Vector3::new(1.0, 0.0, 0.0)); assert!((line.thickness + 4.0).abs() < 1e-12); assert!((line.start.x - 2.0).abs() < 1e-12); assert!((line.end.x - 2.0).abs() < 1e-12); } }