From 289855aa67fcf802558e5c17dd0b11b60d3db54b Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 17:48:27 +0200 Subject: [PATCH] MLINE: multiline entity, MLSTYLE and MLEDIT (part of #510) (#517) Squash of PR #517 by @vangel76. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/entity.rs | 14 + crates/doc/src/entity/mline.rs | 428 +++++++++ crates/doc/src/lib.rs | 3 + crates/engine/src/cmd/draw2.rs | 194 +--- crates/engine/src/cmd/draw2/mline.rs | 836 ++++++++++++++++++ crates/engine/src/cmd/draw2_tests.rs | 23 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/modify.rs | 1 + crates/engine/src/cmd/modify2.rs | 1 + crates/engine/src/grips.rs | 1 + crates/engine/tests/mline.rs | 286 ++++++ crates/io/src/dxf_mline.rs | 445 ++++++++++ crates/io/src/dxf_read.rs | 2 + crates/io/src/dxf_write.rs | 15 + crates/io/src/lib.rs | 3 + crates/io/tests/mline_roundtrip.rs | 297 +++++++ .../io/tests/passthrough_image_roundtrip.rs | 2 +- crates/render/src/lib.rs | 2 + crates/render/src/mline.rs | 73 ++ crates/ui-egui/src/i18n/uk.tsv | 2 + 20 files changed, 2430 insertions(+), 200 deletions(-) create mode 100644 crates/doc/src/entity/mline.rs create mode 100644 crates/engine/src/cmd/draw2/mline.rs create mode 100644 crates/engine/tests/mline.rs create mode 100644 crates/io/src/dxf_mline.rs create mode 100644 crates/io/tests/mline_roundtrip.rs create mode 100644 crates/render/src/mline.rs diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 898e13703..4e4843ab4 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -6,6 +6,9 @@ use cadcraft_geom::{ }; use serde::{Deserialize, Serialize}; +mod mline; +pub use mline::*; + /// A database handle (DXF group 5), unique per drawing. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] pub struct Handle(pub u64); @@ -727,6 +730,7 @@ pub enum EntityKind { AttDef(Attrib), Insert(Insert), Dimension(Dimension), + MLine(MLine), Leader(Leader), MLeader(MLeader), Hatch(Hatch), @@ -778,6 +782,7 @@ impl EntityKind { EntityKind::AttDef(_) => "Attribute Definition", EntityKind::Insert(_) => "Block Reference", EntityKind::Dimension(_) => "Dimension", + EntityKind::MLine(_) => "MLine", EntityKind::Leader(_) => "Leader", EntityKind::MLeader(_) => "Multileader", EntityKind::Hatch(_) => "Hatch", @@ -810,6 +815,7 @@ impl EntityKind { EntityKind::AttDef(_) => "ATTDEF", EntityKind::Insert(_) => "INSERT", EntityKind::Dimension(_) => "DIMENSION", + EntityKind::MLine(_) => "MLINE", EntityKind::Leader(_) => "LEADER", EntityKind::MLeader(_) => "MULTILEADER", EntityKind::Hatch(_) => "HATCH", @@ -886,6 +892,7 @@ impl EntityKind { EntityKind::AttDef(a) => vec![Prim::Point(a.text.insert.xy())], EntityKind::Insert(i) => vec![Prim::Point(i.insert.xy())], EntityKind::Dimension(d) => vec![Prim::Point(d.defpt.xy())], + EntityKind::MLine(m) => m.segments().into_iter().map(Prim::Seg).collect(), EntityKind::MLeader(m) => m .leaders .iter() @@ -1003,6 +1010,12 @@ impl EntityKind { } d.block = None; } + EntityKind::MLine(ml) => { + ml.vertices.iter_mut().for_each(|v| t3(&mut v.p)); + ml.vertices.iter_mut().flat_map(|v| v.cuts.iter_mut()).for_each(|c| (c.start, c.end) = (c.start * s, c.end * s)); + // Mirrored, the elements change sides. + ml.scale *= if mirror { -s } else { s }; + } EntityKind::Leader(l) => l.vertices.iter_mut().for_each(t3), EntityKind::MLeader(ml) => { for l in &mut ml.leaders { @@ -1108,6 +1121,7 @@ impl EntityKind { EntityKind::AttDef(a) => vec![a.text.justify_point()], EntityKind::Insert(i) => std::iter::once(i.insert.xy()).chain(i.attribs.iter().map(|a| a.text.justify_point())).collect(), EntityKind::Dimension(d) => vec![d.p13.xy(), d.p14.xy(), d.defpt.xy(), d.text_mid.xy()], + EntityKind::MLine(m) => m.points(), EntityKind::Leader(l) => l.vertices.iter().map(|v| v.xy()).collect(), EntityKind::MLeader(m) => m.leaders.iter().flat_map(|l| l.iter().map(|v| v.xy())).chain(std::iter::once(m.landing.xy())).collect(), EntityKind::Hatch(h) => { diff --git a/crates/doc/src/entity/mline.rs b/crates/doc/src/entity/mline.rs new file mode 100644 index 000000000..62ab1d8f3 --- /dev/null +++ b/crates/doc/src/entity/mline.rs @@ -0,0 +1,428 @@ +//! Multilines (MLINE) and multiline styles (MLINESTYLE): parallel lines — the style's +//! elements — run at fixed offsets along a path of vertices, with optional caps, joints and a +//! fill between the outermost elements (DXF Reference: MLINE entity, MLINESTYLE object). +//! +//! An [`MLine`] carries a copy of its style ([`MLine::def`]), so its geometry needs nothing +//! else from the drawing: snapping, selection and trimming work from the entity alone. + +use cadcraft_color::Color; +use cadcraft_geom::{Arc as GArc, Line as GLine, PI, Segment, Vec2, Vec3}; +use serde::{Deserialize, Serialize}; + +/// Most elements a multiline style has (as in the style dialog of the reference program). +pub const MAX_MLINE_ELEMENTS: usize = 16; +/// Most vertices of one multiline (hostile files). +pub const MAX_MLINE_VERTICES: usize = 100_000; +/// Most breaks per vertex (all elements together). +pub const MAX_MLINE_CUTS: usize = 1_000; +/// The style every drawing has. +pub const STANDARD_MLINE_STYLE: &str = "STANDARD"; + +/// One line of a multiline style: its offset from the style's zero line (+ = left of the +/// drawing direction), colour and linetype. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MLineElement { + pub offset: f64, + pub color: Color, + pub linetype: String, +} + +impl Default for MLineElement { + fn default() -> Self { + MLineElement { offset: 0.0, color: Color::ByLayer, linetype: "ByLayer".into() } + } +} + +/// The caps at one end of a multiline. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MLineCaps { + /// A line across all elements. + pub line: bool, + /// A half circle joining the outermost elements. + pub outer_arc: bool, + /// Half circles joining pairs of inner elements. + pub inner_arcs: bool, + /// Angle of the end against the drawing direction, radians (π/2 = square). + pub angle: f64, +} + +impl Default for MLineCaps { + fn default() -> Self { + MLineCaps { line: false, outer_arc: false, inner_arcs: false, angle: PI / 2.0 } + } +} + +/// A multiline style (MLSTYLE). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MLineStyle { + pub name: String, + pub description: String, + /// Elements from the largest offset down. + pub elements: Vec, + pub start_caps: MLineCaps, + pub end_caps: MLineCaps, + /// Lines across all elements at every vertex ("display joints"). + pub joints: bool, + /// Fill colour between the outermost elements; `None` = no fill. + pub fill: Option, +} + +impl Default for MLineStyle { + /// The STANDARD style: two lines one unit apart, no caps, no fill. + fn default() -> Self { + MLineStyle { + name: STANDARD_MLINE_STYLE.into(), + description: String::new(), + elements: vec![MLineElement { offset: 0.5, ..Default::default() }, MLineElement { offset: -0.5, ..Default::default() }], + start_caps: MLineCaps::default(), + end_caps: MLineCaps::default(), + joints: false, + fill: None, + } + } +} + +impl MLineStyle { + /// The style with hostile values made safe: 1..=16 finite elements sorted by offset (largest + /// first), cap angles within 10°..170°. + pub fn sanitized(mut self) -> MLineStyle { + self.elements.retain(|e| e.offset.is_finite() && e.offset.abs() < 1e12); + self.elements.truncate(MAX_MLINE_ELEMENTS); + if self.elements.is_empty() { + self.elements.push(MLineElement::default()); + } + self.elements.sort_by(|a, b| b.offset.total_cmp(&a.offset)); + for c in [&mut self.start_caps, &mut self.end_caps] { + c.angle = if c.angle.is_finite() { c.angle.clamp(10f64.to_radians(), 170f64.to_radians()) } else { PI / 2.0 }; + } + self + } + pub fn max_offset(&self) -> f64 { + self.elements.iter().map(|e| e.offset).fold(f64::NEG_INFINITY, f64::max).max(-1e12) + } + pub fn min_offset(&self) -> f64 { + self.elements.iter().map(|e| e.offset).fold(f64::INFINITY, f64::min).min(1e12) + } +} + +/// Which line of the style runs through the picked points. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MLineJust { + /// The element with the largest offset. + #[default] + Top, + /// The style's zero line. + Zero, + /// The element with the smallest offset. + Bottom, +} + +impl MLineJust { + /// DXF group 70 of MLINE (and CMLJUST). + pub fn to_dxf(self) -> i64 { + match self { + MLineJust::Top => 0, + MLineJust::Zero => 1, + MLineJust::Bottom => 2, + } + } + pub fn from_dxf(v: i64) -> MLineJust { + match v { + 1 => MLineJust::Zero, + 2 => MLineJust::Bottom, + _ => MLineJust::Top, + } + } + pub fn name(self) -> &'static str { + match self { + MLineJust::Top => "Top", + MLineJust::Zero => "Zero", + MLineJust::Bottom => "Bottom", + } + } +} + +/// A break in one element along the segment that starts at a vertex: distances from the +/// element's start on that segment (MLEDIT Cut). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct MLineCut { + pub element: usize, + pub start: f64, + pub end: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct MLineVertex { + pub p: Vec3, + /// Breaks in the segment that starts here. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cuts: Vec, +} + +impl MLineVertex { + pub fn new(p: Vec3) -> Self { + MLineVertex { p, cuts: Vec::new() } + } +} + +/// A multiline. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MLine { + /// Style name (the MLINESTYLE it was drawn with). + pub style: String, + /// The style's elements, caps, joints and fill as this multiline uses them. + #[serde(default)] + pub def: MLineStyle, + /// Element offsets are multiplied by this; negative flips the elements to the other side. + #[serde(default = "one")] + pub scale: f64, + #[serde(default)] + pub justification: MLineJust, + #[serde(default)] + pub closed: bool, + #[serde(default)] + pub no_start_caps: bool, + #[serde(default)] + pub no_end_caps: bool, + pub vertices: Vec, +} + +fn one() -> f64 { + 1.0 +} + +/// A multiline's drawn geometry. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MLineGeometry { + /// Element pieces: (element index into `def.elements`, from, to). + pub lines: Vec<(usize, Vec2, Vec2)>, + /// Caps and joints, drawn with the multiline's own properties. + pub caps: Vec, + /// The fill between the outermost elements (even-odd loops); empty without fill. + pub fill: Vec>, +} + +impl MLine { + /// A multiline through `pts` in style `def`. + pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine { + MLine { + style: def.name.clone(), + def: def.clone(), + scale, + justification: just, + closed, + no_start_caps: false, + no_end_caps: false, + vertices: pts.iter().map(|p| MLineVertex::new(p.to3(0.0))).collect(), + } + } + + /// The offset the vertex path sits at, in style units. + pub fn ref_offset(&self) -> f64 { + match self.justification { + MLineJust::Top => self.def.max_offset(), + MLineJust::Zero => 0.0, + MLineJust::Bottom => self.def.min_offset(), + } + } + + /// Each element's distance from the vertex path (+ = left of the drawing direction). + pub fn offsets(&self) -> Vec { + let r = self.ref_offset(); + let s = if self.scale.is_finite() { self.scale } else { 1.0 }; + self.def.elements.iter().map(|e| (e.offset - r) * s).collect() + } + + pub fn points(&self) -> Vec { + self.vertices.iter().map(|v| v.p.xy()).collect() + } + + /// Number of segments (the closing one included). + pub fn segment_count(&self) -> usize { + let n = self.vertices.len(); + if n < 2 { + 0 + } else if self.closed { + n + } else { + n - 1 + } + } + + /// Unit direction of each segment (zero for a degenerate one). + pub fn directions(&self) -> Vec { + let pts = self.points(); + let n = pts.len(); + (0..self.segment_count()) + .map(|i| (pts.get((i + 1) % n.max(1)).copied().unwrap_or_default() - pts.get(i).copied().unwrap_or_default()).normalized()) + .collect() + } + + /// The miter vector at each vertex: the element at offset `d` passes through `v + m * d`. + pub fn miters(&self) -> Vec { + let dirs = self.directions(); + let n = self.vertices.len(); + let nseg = dirs.len(); + // A degenerate segment takes its neighbour's direction. + let dir = |i: usize| -> Vec2 { + (0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X) + }; + let dir_back = |i: usize| -> Vec2 { + (0..nseg) + .map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default()) + .find(|d| *d != Vec2::ZERO) + .unwrap_or(Vec2::X) + }; + let end = |d: Vec2, angle: f64| { + let u = d.rotate(angle); + let s = angle.sin(); + if s.abs() < 1e-6 { d.perp() } else { u / s } + }; + (0..n) + .map(|i| { + if nseg == 0 { + return Vec2::Y; + } + let open_end = !self.closed && (i == 0 || i + 1 == n); + if open_end { + return if i == 0 { end(dir(0), self.def.start_caps.angle) } else { end(dir_back(n - 2), PI - self.def.end_caps.angle) }; + } + let d_in = dir_back((i + nseg - 1) % nseg); + let d_out = dir(i % nseg); + let (n1, n2) = (d_in.perp(), d_out.perp()); + let b = (n1 + n2).normalized(); + if b == Vec2::ZERO { + return n1; + } + // Keep sharp corners finite. + b / b.dot(n1).max(0.05) + }) + .collect() + } + + /// The drawn geometry: element pieces (breaks taken out), caps, joints and fill. + pub fn geometry(&self) -> MLineGeometry { + let mut g = MLineGeometry::default(); + let pts = self.points(); + let n = pts.len(); + let nseg = self.segment_count(); + if nseg == 0 || pts.iter().any(|p| !p.is_finite()) { + return g; + } + let miters = self.miters(); + let offs = self.offsets(); + let at = |k: usize, d: f64| pts.get(k % n).copied().unwrap_or_default() + miters.get(k % n).copied().unwrap_or_default() * d; + for k in 0..nseg { + let cuts = self.vertices.get(k).map(|v| v.cuts.as_slice()).unwrap_or(&[]); + for (e, d) in offs.iter().enumerate() { + let (a, b) = (at(k, *d), at(k + 1, *d)); + let len = a.dist(b); + if len <= 1e-12 { + continue; + } + let dir = (b - a) / len; + for (s, t) in pieces(len, cuts.iter().filter(|c| c.element == e).map(|c| (c.start, c.end))) { + g.lines.push((e, a + dir * s, a + dir * t)); + } + } + } + // Elements by offset, largest (leftmost) first. + let mut order: Vec = (0..offs.len()).collect(); + order.sort_by(|a, b| offs.get(*b).copied().unwrap_or(0.0).total_cmp(&offs.get(*a).copied().unwrap_or(0.0))); + let (Some(&top), Some(&bottom)) = (order.first(), order.last()) else { return g }; + let off = |e: usize| offs.get(e).copied().unwrap_or(0.0); + let across = |k: usize| Segment::Line(GLine::new(at(k, off(top)), at(k, off(bottom)))); + if !self.closed { + for (k, caps, skip, start) in [(0, self.def.start_caps, self.no_start_caps, true), (n - 1, self.def.end_caps, self.no_end_caps, false)] { + if skip { + continue; + } + if caps.line && order.len() > 1 { + g.caps.push(across(k)); + } + let pairs = order.len() / 2; + for i in 0..pairs { + if (i == 0 && !caps.outer_arc) || (i > 0 && !caps.inner_arcs) { + continue; + } + let (Some(&ea), Some(&eb)) = (order.get(i), order.get(order.len() - 1 - i)) else { continue }; + let (a, b) = (at(k, off(ea)), at(k, off(eb))); + if let Some(arc) = half_circle(a, b, start) { + g.caps.push(arc); + } + } + } + } + if self.def.joints && order.len() > 1 { + let inner = if self.closed { 0..n } else { 1..n.saturating_sub(1) }; + for k in inner { + g.caps.push(across(k)); + } + } + if self.def.fill.is_some() { + let side = |e: usize| (0..n).map(|k| at(k, off(e))).collect::>(); + if self.closed { + g.fill = vec![side(top), side(bottom)]; + } else { + let mut poly = side(top); + poly.extend(side(bottom).into_iter().rev()); + g.fill = vec![poly]; + } + } + g + } + + /// Segments of every element piece, cap and joint. + pub fn segments(&self) -> Vec { + let g = self.geometry(); + g.lines.iter().map(|(_, a, b)| Segment::Line(GLine::new(*a, *b))).chain(g.caps).collect() + } +} + +/// The parts of `[0, len]` left after taking out the `gaps` (each `(start, end)`, any order). +fn pieces(len: f64, gaps: impl Iterator) -> Vec<(f64, f64)> { + let mut gaps: Vec<(f64, f64)> = + gaps.filter(|(a, b)| a.is_finite() && b.is_finite()).map(|(a, b)| (a.min(b).max(0.0), a.max(b).min(len))).filter(|(a, b)| b > a).collect(); + gaps.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out = Vec::new(); + let mut from = 0.0; + for (a, b) in gaps { + if a > from + 1e-12 { + out.push((from, a)); + } + from = f64::max(from, b); + } + if len > from + 1e-12 { + out.push((from, len)); + } + out +} + +/// A half circle from `a` to `b` bulging backwards (`start`) or forwards along the multiline. +fn half_circle(a: Vec2, b: Vec2, start: bool) -> Option { + let r = a.dist(b) / 2.0; + if r <= 1e-12 { + return None; + } + let c = a.mid(b); + let (sa, sb) = (c.angle_to(a), c.angle_to(b)); + // From the left element to the right one: counter-clockwise passes behind the start. + Some(if start { Segment::Arc { arc: GArc::new(c, r, sa, sb), ccw: true } } else { Segment::Arc { arc: GArc::new(c, r, sb, sa), ccw: false } }) +} + +impl crate::Drawing { + /// The multiline style named `name` (any case). + pub fn mline_style(&self, name: &str) -> Option<&MLineStyle> { + self.mline_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name.trim())) + } + /// The current multiline style (CMLSTYLE), STANDARD when it is missing. + pub fn current_mline_style(&self) -> MLineStyle { + let name = self.header.str("CMLSTYLE", STANDARD_MLINE_STYLE); + self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default() + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 127df4912..ee7bdca0a 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -63,6 +63,8 @@ pub struct Drawing { pub dim_styles: Vec, pub mleader_styles: Vec, pub table_styles: Vec, + /// Multiline styles (MLSTYLE); STANDARD is always there. + pub mline_styles: Vec, pub blocks: BTreeMap>, pub model: EntityStore, pub layouts: Vec, @@ -123,6 +125,7 @@ impl Drawing { dim_styles: vec![DimStyle::default()], mleader_styles: vec![MLeaderStyle::default()], table_styles: vec![TableStyle::default()], + mline_styles: vec![MLineStyle::default()], blocks: BTreeMap::new(), model: EntityStore::new(), layouts: vec![Layout::new("Layout1", 1), Layout::new("Layout2", 2)], diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 5d03c74dc..97688ffbd 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -12,8 +12,10 @@ use super::machines::number; use super::*; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +pub(crate) mod mline; + pub fn specs() -> Vec { - vec![ + let mut v = vec![ CommandSpec::new("arc.sce", "Start, Center, End", run_arc_variant) .menu(&["Draw", "Arc", "Start, Center, End"]) .params("{start, center, end}") @@ -113,11 +115,6 @@ pub fn specs() -> Vec { .alias(&["3p"]) .params("{points: [[x,y,z?],...], closed?}") .interactive(|_| Ok(Box::new(Poly3dM::default()))), - CommandSpec::new("mline", "Multiline", run_mline) - .menu(&["Draw", "Multiline"]) - .alias(&["ml"]) - .params("{points, scale?, justification?: top|zero|bottom, closed?} (two offset polylines)") - .interactive(|s| Ok(Box::new(MlineM::new(s)))), CommandSpec::new("helix", "Helix", run_helix) .menu(&["Draw", "Helix"]) .params("{center, baseRadius, topRadius?, height?, turns?, ccw?} (3D polyline approximation)") @@ -134,7 +131,9 @@ pub fn specs() -> Vec { .menu(&["Draw", "Center Line"]) .params("{h1, h2} (two lines)") .interactive(|_| Ok(Box::new(CenterM { first: None, mark: false }))), - ] + ]; + v.extend(mline::specs()); + v } fn added(h: Handle) -> Result { @@ -1889,187 +1888,6 @@ impl Interactive for Poly3dM { } } -// ===================================================================================== -// MLINE (two offset polylines) -// ===================================================================================== - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Just { - Top, - Zero, - Bottom, -} - -fn mline_offsets(j: Just, scale: f64) -> [f64; 2] { - match j { - Just::Top => [0.0, -scale], - Just::Zero => [scale / 2.0, -scale / 2.0], - Just::Bottom => [scale, 0.0], - } -} - -fn mline_kinds(pts: &[Vec2], closed: bool, j: Just, scale: f64) -> Vec { - if pts.len() < 2 { - return Vec::new(); - } - let pl = Polyline::from_points(pts, closed); - mline_offsets(j, scale) - .iter() - .filter_map(|d| { - let vs = if d.abs() < 1e-15 { Some(pl.vertices.clone()) } else { super::modify::offset_polyline(&pl, *d) }; - vs.map(|vs| lwpoly(vs, closed)) - }) - .collect() -} - -fn just_of(s: &str) -> Option { - match s.trim().to_ascii_lowercase().as_str() { - "t" | "top" => Some(Just::Top), - "z" | "zero" => Some(Just::Zero), - "b" | "bottom" => Some(Just::Bottom), - _ => None, - } -} - -fn mline_defaults(s: &Session) -> (Just, f64) { - let d = s.doc().ok(); - let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1); - let scale = d.map(|d| d.header.f64("CMLSCALE", if metric { 20.0 } else { 1.0 })).unwrap_or(1.0); - let j = match d.map(|d| d.header.i64("CMLJUST", 0)).unwrap_or(0) { - 1 => Just::Zero, - 2 => Just::Bottom, - _ => Just::Top, - }; - (j, scale) -} - -fn run_mline(s: &mut Session, p: &Value) -> Result { - let pts = points_param(p, "points").ok_or_else(|| bad("mline", "`points` (2 or more) is required"))?; - if pts.len() < 2 || pts.len() > MAX_GEN { - return Err(bad("mline", "need 2 or more points")); - } - let (dj, ds) = mline_defaults(s); - let j = match str_param(p, "justification") { - Some(t) => just_of(t).ok_or_else(|| bad("mline", "justification must be top, zero or bottom"))?, - None => dj, - }; - let scale = f64_or(p, "scale", ds); - let mut hs = Vec::new(); - for k in mline_kinds(&pts, bool_or(p, "closed", false), j, scale) { - hs.push(s.add_entity(k)?.hex()); - } - Ok(json!({ "handles": hs })) -} - -struct MlineM { - pts: Vec, - just: Just, - scale: f64, - asking: u8, // 0 none, 1 justification, 2 scale -} - -impl MlineM { - fn new(s: &Session) -> Self { - let (just, scale) = mline_defaults(s); - MlineM { pts: Vec::new(), just, scale, asking: 0 } - } - fn finish(&self, s: &mut Session, closed: bool) -> Result { - for k in mline_kinds(&self.pts, closed, self.just, self.scale) { - s.add_entity(k)?; - } - Ok(Step::Done) - } -} - -impl Interactive for MlineM { - fn name(&self) -> &'static str { - "MLINE" - } - fn begin(&mut self, s: &mut Session) -> Result { - let j = match self.just { - Just::Top => "Top", - Just::Zero => "Zero", - Just::Bottom => "Bottom", - }; - s.echo(format!("Current settings: Justification = {j}, Scale = {:.2}, Style = STANDARD", self.scale)); - Ok(Step::Continue) - } - fn prompt(&self, _s: &Session) -> Prompt { - match self.asking { - 1 => return Prompt::new("Enter justification type", curves::KW).kw(&["Top", "Zero", "Bottom"]).default("top"), - 2 => return Prompt::new("Enter mline scale", Accept::NUMBER).default(format!("{:.2}", self.scale)), - _ => {} - } - match self.pts.len() { - 0 => Prompt::new("Specify start point", Accept::POINT).kw(&["Justification", "Scale", "STyle"]), - 1 => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()), - 2 => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), - _ => Prompt::new("Specify next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), - } - } - fn input(&mut self, s: &mut Session, i: Input) -> Result { - if self.asking == 1 { - if let Input::Keyword(k) | Input::Text(k) = &i { - self.just = just_of(k).ok_or_else(|| other("Enter Top, Zero or Bottom."))?; - s.doc_mut()?.header.set_i64( - "CMLJUST", - match self.just { - Just::Top => 0, - Just::Zero => 1, - Just::Bottom => 2, - }, - ); - } - self.asking = 0; - return Ok(Step::Continue); - } - if self.asking == 2 { - if let Input::Text(t) = &i { - self.scale = number(t).filter(|v| v.is_finite()).ok_or_else(|| other("Requires a number."))?; - s.doc_mut()?.header.set_f64("CMLSCALE", self.scale); - } - self.asking = 0; - return Ok(Step::Continue); - } - match i { - Input::Keyword(k) => { - match k.as_str() { - "Justification" => self.asking = 1, - "Scale" => self.asking = 2, - "Undo" => { - self.pts.pop(); - } - "Close" => return self.finish(s, true), - _ => s.echo("Only the STANDARD multiline style is available."), - } - Ok(Step::Continue) - } - Input::Point(p) => { - if self.pts.last().is_none_or(|l| !l.near(p, 1e-12)) && self.pts.len() < MAX_GEN { - self.pts.push(p); - } - Ok(Step::Continue) - } - Input::Enter => { - if self.pts.len() >= 2 { - self.finish(s, false) - } else { - Ok(Step::Cancel) - } - } - _ => Ok(Step::Continue), - } - } - fn preview(&self, _s: &Session, c: Vec2) -> Vec { - if self.pts.is_empty() || self.asking != 0 { - return Vec::new(); - } - let mut pts = self.pts.clone(); - pts.push(c); - mline_kinds(&pts, false, self.just, self.scale) - } -} - // ===================================================================================== // HELIX (3D polyline approximation) // ===================================================================================== diff --git a/crates/engine/src/cmd/draw2/mline.rs b/crates/engine/src/cmd/draw2/mline.rs new file mode 100644 index 000000000..75188f4d4 --- /dev/null +++ b/crates/engine/src/cmd/draw2/mline.rs @@ -0,0 +1,836 @@ +//! Multilines: MLINE draws one (Justification, Scale, STyle), MLSTYLE keeps the styles, MLEDIT +//! edits crossings, tees, corners, vertices and breaks. + +use cadcraft_color::Color; +use cadcraft_doc::{ + Entity, EntityKind, Handle, MAX_MLINE_CUTS, MAX_MLINE_ELEMENTS, MLine, MLineCaps, MLineCut, MLineElement, MLineJust, MLineStyle, MLineVertex, + STANDARD_MLINE_STYLE, +}; +use cadcraft_geom::{Line, Vec2, line_line_infinite}; +use serde_json::{Value, json}; + +use super::curves::{self, MAX_GEN}; +use super::machines::number; +use super::*; +use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("mline", "Multiline", run_mline) + .menu(&["Draw", "Multiline"]) + .alias(&["ml"]) + .params("{points, scale?, justification?: top|zero|bottom, style?, closed?} → {handle}") + .interactive(|s| Ok(Box::new(MlineM::new(s)))), + CommandSpec::new("mlstyle", "Multiline Style...", run_mlstyle).menu(&["Format", "Multiline Style..."]).params( + "{} → styles | {name, description?, elements?: [{offset, color?, linetype?}], fill?: color|false, joints?, \ + startCaps?/endCaps?: {line?, outerArc?, innerArcs?, angle? (degrees)}, copyFrom?, current? (default true)} | \ + {delete: name} | {name, rename: newName}", + ), + CommandSpec::new("mledit", "Multiline...", run_mledit) + .menu(&["Modify", "Object", "Multiline..."]) + .alias(&["-mledit"]) + .params( + "{tool: cc|oc|mc|ct|ot|cj, first, p1, second, p2} | {tool: av|dv, handle, point} | \ + {tool: cs|ca|wa, handle, p1, p2} (handles of multilines; points pick segments and sides)", + ) + .interactive(|_| Ok(Box::new(MleditM::default()))), + ] +} + +fn other(m: impl Into) -> EngineError { + EngineError::Other(m.into()) +} + +// ===================================================================================== +// MLINE +// ===================================================================================== + +fn just_of(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "t" | "top" => Some(MLineJust::Top), + "z" | "zero" => Some(MLineJust::Zero), + "b" | "bottom" => Some(MLineJust::Bottom), + _ => None, + } +} + +/// CMLJUST and CMLSCALE (20 in metric drawings when unset). +fn mline_defaults(s: &Session) -> (MLineJust, f64) { + let d = s.doc().ok(); + let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1); + let scale = d.map(|d| d.header.f64("CMLSCALE", if metric { 20.0 } else { 1.0 })).filter(|v| v.is_finite()).unwrap_or(1.0); + (MLineJust::from_dxf(d.map(|d| d.header.i64("CMLJUST", 0)).unwrap_or(0)), scale) +} + +fn style_named(s: &Session, name: &str) -> Result { + s.doc()?.mline_style(name).cloned().ok_or_else(|| other(format!("Cannot find multiline style \"{}\".", name.trim()))) +} + +fn make(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> Option { + (pts.len() >= 2).then(|| EntityKind::MLine(MLine::new(def, pts, closed, just, scale))) +} + +fn run_mline(s: &mut Session, p: &Value) -> Result { + let pts = distinct_points(&points_param(p, "points").ok_or_else(|| bad("mline", "`points` (2 or more) is required"))?); + if pts.len() < 2 || pts.len() > MAX_GEN { + return Err(bad("mline", "need 2 or more distinct points")); + } + let (dj, ds) = mline_defaults(s); + let j = match str_param(p, "justification") { + Some(t) => just_of(t).ok_or_else(|| bad("mline", "justification must be top, zero or bottom"))?, + None => dj, + }; + let scale = f64_or(p, "scale", ds); + if scale.abs() > 1e12 { + return Err(bad("mline", "scale is out of range")); + } + let def = match str_param(p, "style") { + Some(n) => style_named(s, n)?, + None => s.doc()?.current_mline_style(), + }; + let closed = bool_or(p, "closed", false) && pts.len() >= 3; + let k = make(&def, &pts, closed, j, scale).ok_or_else(|| bad("mline", "need 2 or more points"))?; + Ok(json!({ "handle": s.add_entity(k)?.hex() })) +} + +struct MlineM { + pts: Vec, + just: MLineJust, + scale: f64, + style: MLineStyle, + asking: u8, // 0 none, 1 justification, 2 scale, 3 style +} + +impl MlineM { + fn new(s: &Session) -> Self { + let (just, scale) = mline_defaults(s); + let style = s.doc().map(|d| d.current_mline_style()).unwrap_or_default(); + MlineM { pts: Vec::new(), just, scale, style, asking: 0 } + } + fn finish(&self, s: &mut Session, closed: bool) -> Result { + if let Some(k) = make(&self.style, &self.pts, closed && self.pts.len() >= 3, self.just, self.scale) { + s.add_entity(k)?; + } + Ok(Step::Done) + } + fn settings(&self, s: &mut Session) { + s.echo(format!("Current settings: Justification = {}, Scale = {:.2}, Style = {}", self.just.name(), self.scale, self.style.name)); + } +} + +impl Interactive for MlineM { + fn name(&self) -> &'static str { + "MLINE" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.settings(s); + Ok(Step::Continue) + } + fn prompt(&self, _s: &Session) -> Prompt { + match self.asking { + 1 => return Prompt::new("Enter justification type", curves::KW).kw(&["Top", "Zero", "Bottom"]).default(self.just.name().to_lowercase()), + 2 => return Prompt::new("Enter mline scale", Accept::NUMBER).default(format!("{:.2}", self.scale)), + 3 => return Prompt::new("Enter mline style name or [?]", Accept::TEXT).default(self.style.name.clone()), + _ => {} + } + match self.pts.len() { + 0 => Prompt::new("Specify start point", Accept::POINT).kw(&["Justification", "Scale", "STyle"]), + 1 => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + 2 => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()), + _ => Prompt::new("Specify next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + match self.asking { + 1 => { + if let Input::Keyword(k) | Input::Text(k) = &i { + self.just = just_of(k).ok_or_else(|| other("Enter Top, Zero or Bottom."))?; + s.doc_mut()?.header.set_i64("CMLJUST", self.just.to_dxf()); + } + self.asking = 0; + self.settings(s); + return Ok(Step::Continue); + } + 2 => { + if let Input::Text(t) = &i { + self.scale = number(t).filter(|v| v.is_finite() && v.abs() <= 1e12).ok_or_else(|| other("Requires a number."))?; + s.doc_mut()?.header.set_f64("CMLSCALE", self.scale); + } + self.asking = 0; + self.settings(s); + return Ok(Step::Continue); + } + 3 => { + if let Input::Text(t) | Input::Keyword(t) = &i { + let t = t.trim(); + if t == "?" { + let names: Vec = s.doc()?.mline_styles.iter().map(|st| st.name.clone()).collect(); + s.echo(format!("Loaded multiline styles: {}", names.join(", "))); + return Ok(Step::Continue); + } + if !t.is_empty() { + self.style = style_named(s, t)?; + s.doc_mut()?.header.set_str("CMLSTYLE", &self.style.name); + } + } + self.asking = 0; + self.settings(s); + return Ok(Step::Continue); + } + _ => {} + } + match i { + Input::Keyword(k) => { + match k.as_str() { + "Justification" => self.asking = 1, + "Scale" => self.asking = 2, + "STyle" => self.asking = 3, + "Undo" => { + self.pts.pop(); + } + "Close" if self.pts.len() >= 3 => return self.finish(s, true), + _ => {} + } + Ok(Step::Continue) + } + Input::Point(p) => { + if self.pts.last().is_none_or(|l| !l.near(p, 1e-12)) && self.pts.len() < MAX_GEN { + self.pts.push(p); + } + Ok(Step::Continue) + } + Input::Enter => { + if self.pts.len() >= 2 { + self.finish(s, false) + } else { + Ok(Step::Cancel) + } + } + _ => Ok(Step::Continue), + } + } + fn preview(&self, _s: &Session, c: Vec2) -> Vec { + if self.pts.is_empty() || self.asking != 0 { + return Vec::new(); + } + let mut pts = self.pts.clone(); + if pts.last().is_some_and(|l| !l.near(c, 1e-12)) { + pts.push(c); + } + make(&self.style, &pts, false, self.just, self.scale).into_iter().collect() + } +} + +// ===================================================================================== +// MLSTYLE +// ===================================================================================== + +fn mline_in_use(d: &cadcraft_doc::Drawing, name: &str) -> bool { + let uses = |e: &std::sync::Arc| matches!(&e.kind, EntityKind::MLine(m) if m.style.eq_ignore_ascii_case(name)); + d.model.iter().any(uses) || d.layouts.iter().any(|l| l.entities.iter().any(uses)) || d.blocks.values().any(|b| b.entities.iter().any(uses)) +} + +fn caps_param(p: &Value, key: &str, mut c: MLineCaps) -> Result { + let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(c) }; + if !v.is_object() { + return Err(bad("mlstyle", format!("`{key}` must be an object"))); + } + c.line = bool_or(v, "line", c.line); + c.outer_arc = bool_or(v, "outerArc", c.outer_arc); + c.inner_arcs = bool_or(v, "innerArcs", c.inner_arcs); + if let Some(a) = v.get("angle").and_then(Value::as_f64) { + if !(10.0..=170.0).contains(&a) { + return Err(bad("mlstyle", "cap angles must be 10..170 degrees")); + } + c.angle = a.to_radians(); + } + Ok(c) +} + +fn color_value(v: &Value) -> Option { + match v { + Value::String(t) => Color::parse(t), + Value::Number(n) => n.as_u64().and_then(|i| u16::try_from(i).ok()).and_then(|i| Color::parse(&i.to_string())), + _ => None, + } +} + +fn elements_param(p: &Value) -> Result>> { + let Some(v) = p.get("elements").filter(|v| !v.is_null()) else { return Ok(None) }; + let list = v.as_array().ok_or_else(|| bad("mlstyle", "`elements` must be an array"))?; + if list.is_empty() || list.len() > MAX_MLINE_ELEMENTS { + return Err(bad("mlstyle", format!("a style has 1 to {MAX_MLINE_ELEMENTS} elements"))); + } + let mut out = Vec::new(); + for e in list { + let offset = e + .get("offset") + .and_then(Value::as_f64) + .filter(|o| o.is_finite() && o.abs() < 1e12) + .ok_or_else(|| bad("mlstyle", "each element needs a finite `offset`"))?; + let color = match e.get("color") { + None | Some(Value::Null) => Color::ByLayer, + Some(c) => color_value(c).ok_or_else(|| bad("mlstyle", format!("unknown colour {c}")))?, + }; + let linetype = str_param(e, "linetype").map(str::trim).filter(|t| !t.is_empty()).unwrap_or("ByLayer").to_string(); + out.push(MLineElement { offset, color, linetype }); + } + Ok(Some(out)) +} + +fn styles_value(d: &cadcraft_doc::Drawing) -> Value { + json!({ "current": d.header.str("CMLSTYLE", STANDARD_MLINE_STYLE), "styles": serde_json::to_value(&d.mline_styles).unwrap_or(Value::Null) }) +} + +fn run_mlstyle(s: &mut Session, p: &Value) -> Result { + if let Some(name) = str_param(p, "delete").map(str::trim) { + let d = s.doc()?; + let st = d.mline_style(name).ok_or_else(|| other(format!("Cannot find multiline style \"{name}\".")))?.name.clone(); + if st.eq_ignore_ascii_case(STANDARD_MLINE_STYLE) || st.eq_ignore_ascii_case(&d.header.str("CMLSTYLE", STANDARD_MLINE_STYLE)) { + return Err(other(format!("The STANDARD and the current multiline style can't be deleted ({st})."))); + } + if mline_in_use(d, &st) { + return Err(other(format!("Multiline style {st} is in use and can't be deleted."))); + } + let d = s.doc_mut()?; + d.mline_styles.retain(|x| !x.name.eq_ignore_ascii_case(&st)); + return Ok(styles_value(d)); + } + let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) else { return Ok(styles_value(s.doc()?)) }; + if name.chars().count() > 31 || name.chars().any(|c| "<>/\\\":;?*|,=`".contains(c)) { + return Err(bad("mlstyle", "style names have up to 31 characters and none of <>/\\\":;?*|,=`")); + } + if let Some(to) = str_param(p, "rename").map(str::trim) { + if to.is_empty() || to.chars().count() > 31 { + return Err(bad("mlstyle", "`rename` needs a name of up to 31 characters")); + } + let d = s.doc_mut()?; + let from = d.mline_style(name).ok_or_else(|| other(format!("Cannot find multiline style \"{name}\".")))?.name.clone(); + if from.eq_ignore_ascii_case(STANDARD_MLINE_STYLE) { + return Err(other("The STANDARD multiline style can't be renamed.")); + } + if d.mline_style(to).is_some_and(|x| !x.name.eq_ignore_ascii_case(&from)) { + return Err(other(format!("A multiline style named {to} already exists."))); + } + if mline_in_use(d, &from) { + return Err(other(format!("Multiline style {from} is in use and can't be renamed."))); + } + if let Some(st) = d.mline_styles.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&from)) { + st.name = to.to_string(); + } + if d.header.str("CMLSTYLE", STANDARD_MLINE_STYLE).eq_ignore_ascii_case(&from) { + d.header.set_str("CMLSTYLE", to); + } + return Ok(styles_value(d)); + } + let elements = elements_param(p)?; + let d = s.doc()?; + let existing = d.mline_style(name).cloned(); + let base = match (&existing, str_param(p, "copyFrom")) { + (Some(st), _) => st.clone(), + (None, Some(from)) => style_named(s, from)?, + (None, None) => MLineStyle::default(), + }; + let mut st = MLineStyle { name: existing.as_ref().map(|e| e.name.clone()).unwrap_or_else(|| name.to_string()), ..base }; + if let Some(t) = str_param(p, "description") { + st.description = t.chars().take(255).collect(); + } + if let Some(e) = elements { + st.elements = e; + } + match p.get("fill") { + None | Some(Value::Null) => {} + Some(Value::Bool(false)) => st.fill = None, + Some(Value::Bool(true)) => st.fill = Some(st.fill.unwrap_or(Color::ByLayer)), + Some(c) => st.fill = Some(color_value(c).ok_or_else(|| bad("mlstyle", format!("unknown fill colour {c}")))?), + } + st.joints = bool_or(p, "joints", st.joints); + st.start_caps = caps_param(p, "startCaps", st.start_caps)?; + st.end_caps = caps_param(p, "endCaps", st.end_caps)?; + let st = st.sanitized(); + if let Some(old) = &existing { + let look = |x: &MLineStyle| MLineStyle { description: String::new(), ..x.clone() }; + if look(old) != look(&st) && mline_in_use(d, &old.name) { + return Err(other(format!("Multiline style {} is in use: its elements and properties can't be changed.", old.name))); + } + } + let current = bool_or(p, "current", true); + let d = s.doc_mut()?; + match d.mline_styles.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&st.name)) { + Some(x) => *x = st.clone(), + None => d.mline_styles.push(st.clone()), + } + if current { + d.header.set_str("CMLSTYLE", &st.name); + } + Ok(styles_value(d)) +} + +// ===================================================================================== +// Geometry helpers +// ===================================================================================== + +/// The multiline `h`. +fn mline_of(s: &Session, h: Handle) -> Result { + match curves::entity(s, h)?.kind { + EntityKind::MLine(m) => Ok(m), + _ => Err(other("Select a multiline.")), + } +} + +fn store(s: &mut Session, h: Handle, m: MLine) -> Result<()> { + s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::MLine(m))?; + Ok(()) +} + +/// Segment `k` of the vertex path. +fn path_seg(m: &MLine, k: usize) -> Option { + let n = m.vertices.len(); + Some(Line::new(m.vertices.get(k)?.p.xy(), m.vertices.get((k + 1) % n.max(1))?.p.xy())) +} + +/// The segment of the vertex path nearest `p`. +fn nearest_seg(m: &MLine, p: Vec2) -> Option { + (0..m.segment_count()).filter_map(|k| Some((k, path_seg(m, k)?.dist(p)))).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(k, _)| k) +} + +/// Element `e` (offset `d`) on segment `k`: its start and end. +fn element_line(m: &MLine, miters: &[Vec2], k: usize, d: f64) -> Option { + let n = m.vertices.len(); + let j = (k + 1) % n.max(1); + let a = m.vertices.get(k)?.p.xy() + *miters.get(k)? * d; + let b = m.vertices.get(j)?.p.xy() + *miters.get(j)? * d; + Some(Line::new(a, b)) +} + +/// Indices of the outermost elements: (largest offset, smallest offset). +fn outer(offs: &[f64]) -> Option<(usize, usize)> { + let top = offs.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1))?.0; + let bottom = offs.iter().enumerate().min_by(|a, b| a.1.total_cmp(b.1))?.0; + Some((top, bottom)) +} + +/// Distance along `l` (from its start) where it meets the infinite line `o`. +fn meet(l: &Line, o: &Line) -> Option { + let (_, t, _) = line_line_infinite(l.a, l.b, o.a, o.b)?; + let v = t * l.len(); + v.is_finite().then_some(v) +} + +/// Break element `e` of segment `k` between distances `a` and `b`. +fn add_cut(m: &mut MLine, k: usize, e: usize, a: f64, b: f64) { + let (start, end) = (a.min(b), a.max(b)); + if let Some(v) = m.vertices.get_mut(k) + && end > start + && v.cuts.len() < MAX_MLINE_CUTS + { + v.cuts.push(MLineCut { element: e, start, end }); + } +} + +/// Break the elements `which` of `a`'s segment `k` where they cross the outer elements of `b`'s +/// segment `u`. +fn cut_across(a: &mut MLine, k: usize, which: &[usize], b: &MLine, u: usize) { + let (am, ao, bm, bo) = (a.miters(), a.offsets(), b.miters(), b.offsets()); + let Some((top, bottom)) = outer(&bo) else { return }; + let (Some(bt), Some(bb)) = (bo.get(top).and_then(|d| element_line(b, &bm, u, *d)), bo.get(bottom).and_then(|d| element_line(b, &bm, u, *d))) + else { + return; + }; + for &e in which { + let Some(l) = ao.get(e).and_then(|d| element_line(a, &am, k, *d)) else { continue }; + if let (Some(x), Some(y)) = (meet(&l, &bt), meet(&l, &bb)) { + add_cut(a, k, e, x, y); + } + } +} + +/// The multiline cut at distance `at` along segment `k` of its path, keeping the part before +/// (`keep_before`) or after; the new end gets no caps. +fn trim_at(m: &MLine, k: usize, at: f64, keep_before: bool) -> Option { + let seg = path_seg(m, k)?; + let p = seg.a + seg.dir() * at; + if !p.is_finite() { + return None; + } + let mut out = m.clone(); + if m.closed { + // Opened at the cut: from the cut round to it again. + let n = m.vertices.len(); + let mut vs = vec![MLineVertex::new(p.to3(0.0))]; + vs.extend((1..=n).filter_map(|i| m.vertices.get((k + i) % n).cloned())); + if let Some(last) = vs.last_mut() { + last.cuts.clear(); + } + vs.push(MLineVertex::new(p.to3(0.0))); + out.vertices = vs; + out.closed = false; + out.no_start_caps = true; + out.no_end_caps = true; + return Some(out); + } + if keep_before { + let mut vs: Vec = m.vertices.get(..=k)?.to_vec(); + if let Some(last) = vs.last_mut() { + last.cuts.clear(); + } + vs.push(MLineVertex::new(p.to3(0.0))); + out.vertices = vs; + out.no_end_caps = true; + } else { + let mut vs = vec![MLineVertex::new(p.to3(0.0))]; + vs.extend(m.vertices.get(k + 1..)?.iter().cloned()); + out.vertices = vs; + out.no_start_caps = true; + } + (out.vertices.len() >= 2 && out.segment_count() > 0).then_some(out) +} + +// ===================================================================================== +// MLEDIT +// ===================================================================================== + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tool { + ClosedCross, + OpenCross, + MergedCross, + ClosedTee, + OpenTee, + MergedTee, + CornerJoint, + AddVertex, + DeleteVertex, + CutSingle, + CutAll, + WeldAll, +} + +const TOOLS: [(&str, Tool); 12] = [ + ("CC", Tool::ClosedCross), + ("OC", Tool::OpenCross), + ("MC", Tool::MergedCross), + ("CT", Tool::ClosedTee), + ("OT", Tool::OpenTee), + ("MT", Tool::MergedTee), + ("CJ", Tool::CornerJoint), + ("AV", Tool::AddVertex), + ("DV", Tool::DeleteVertex), + ("CS", Tool::CutSingle), + ("CA", Tool::CutAll), + ("WA", Tool::WeldAll), +]; + +fn tool_of(t: &str) -> Option { + TOOLS.iter().find(|(k, _)| k.eq_ignore_ascii_case(t.trim())).map(|(_, t)| *t) +} + +impl Tool { + /// Two multilines (else one multiline and one or two points on it). + fn pair(self) -> bool { + matches!( + self, + Tool::ClosedCross | Tool::OpenCross | Tool::MergedCross | Tool::ClosedTee | Tool::OpenTee | Tool::MergedTee | Tool::CornerJoint + ) + } + fn two_points(self) -> bool { + matches!(self, Tool::CutSingle | Tool::CutAll | Tool::WeldAll) + } +} + +fn run_mledit(s: &mut Session, p: &Value) -> Result { + let tool = str_param(p, "tool").and_then(tool_of).ok_or_else(|| bad("mledit", "`tool` must be one of CC OC MC CT OT MT CJ AV DV CS CA WA"))?; + let h = |k: &str| curves::handle_param(p, k).ok_or_else(|| bad("mledit", format!("`{k}` (multiline handle) is required"))); + if tool.pair() { + mledit_pair(s, tool, h("first")?, point_req("mledit", p, "p1")?, h("second")?, point_req("mledit", p, "p2")?)?; + } else if tool.two_points() { + mledit_cut(s, tool, h("handle")?, point_req("mledit", p, "p1")?, point_req("mledit", p, "p2")?)?; + } else { + mledit_vertex(s, tool, h("handle")?, point_req("mledit", p, "point")?)?; + } + ok() +} + +/// Crosses, tees and the corner joint: `a` (picked at `pa`) against `b` (picked at `pb`). +fn mledit_pair(s: &mut Session, tool: Tool, ha: Handle, pa: Vec2, hb: Handle, pb: Vec2) -> Result<()> { + if ha == hb { + return Err(other("Select two different multilines.")); + } + let (mut a, mut b) = (mline_of(s, ha)?, mline_of(s, hb)?); + let k = nearest_seg(&a, pa).ok_or_else(|| other("The first multiline has no segments."))?; + let u = nearest_seg(&b, pb).ok_or_else(|| other("The second multiline has no segments."))?; + let (ao, bo) = (a.offsets(), b.offsets()); + let all_a: Vec = (0..ao.len()).collect(); + let outer_of = |o: &[f64]| outer(o).map(|(t, b)| if t == b { vec![t] } else { vec![t, b] }).unwrap_or_default(); + match tool { + Tool::ClosedCross => cut_across(&mut a, k, &all_a, &b, u), + Tool::OpenCross => { + let bb = b.clone(); + cut_across(&mut a, k, &all_a, &bb, u); + let aa = a.clone(); + cut_across(&mut b, u, &outer_of(&bo), &aa, k); + } + Tool::MergedCross => { + let bb = b.clone(); + cut_across(&mut a, k, &outer_of(&ao), &bb, u); + let aa = a.clone(); + cut_across(&mut b, u, &outer_of(&bo), &aa, k); + } + Tool::MergedTee => return Err(other("Merged tee is not available yet.")), + Tool::ClosedTee | Tool::OpenTee => { + let path = path_seg(&a, k).ok_or_else(|| other("*Invalid selection*"))?; + let bm = b.miters(); + let (top, bottom) = outer(&bo).ok_or_else(|| other("*Invalid selection*"))?; + let line = |e: usize| bo.get(e).and_then(|d| element_line(&b, &bm, u, *d)); + let (lt, lb) = (line(top).ok_or_else(|| other("*Invalid selection*"))?, line(bottom).ok_or_else(|| other("*Invalid selection*"))?); + let (st, sb) = (meet(&path, <), meet(&path, &lb)); + let (Some(st), Some(sb)) = (st, sb) else { return Err(other("The multilines are parallel.")) }; + let pick = (pa - path.a).dot(path.dir()); + let mid = (st + sb) / 2.0; + let keep_before = pick < mid; + let (at, near_e) = match (keep_before, st < sb) { + (true, true) | (false, false) => (st, top), + _ => (sb, bottom), + }; + let near = if near_e == top { lt } else { lb }; + a = trim_at(&a, k, at, keep_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?; + if tool == Tool::OpenTee { + // Open the near outer element of the second multiline where the first meets it. + let ak = if keep_before { a.vertices.len().saturating_sub(2) } else { 0 }; + let (am, aoff) = (a.miters(), a.offsets()); + if let Some((t2, b2)) = outer(&aoff) { + let al = |e: usize| aoff.get(e).and_then(|d| element_line(&a, &am, ak, *d)); + if let (Some(x), Some(y)) = (al(t2).and_then(|l| meet(&near, &l)), al(b2).and_then(|l| meet(&near, &l))) { + add_cut(&mut b, u, near_e, x, y); + } + } + } + } + Tool::CornerJoint => { + let (la, lb) = + (path_seg(&a, k).ok_or_else(|| other("*Invalid selection*"))?, path_seg(&b, u).ok_or_else(|| other("*Invalid selection*"))?); + let (_, ta, tb) = line_line_infinite(la.a, la.b, lb.a, lb.b).ok_or_else(|| other("The multilines are parallel."))?; + let (xa, xb) = (ta * la.len(), tb * lb.len()); + let a_before = (pa - la.a).dot(la.dir()) < xa; + let b_before = (pb - lb.a).dot(lb.dir()) < xb; + if a.closed || b.closed { + return Err(other("Corner joints need open multilines.")); + } + let na = trim_at(&a, k, xa, a_before).ok_or_else(|| other("Nothing would be left of the first multiline."))?; + let nb = trim_at(&b, u, xb, b_before).ok_or_else(|| other("Nothing would be left of the second multiline."))?; + let same = na.style.eq_ignore_ascii_case(&nb.style) && na.def == nb.def && na.justification == nb.justification && na.scale == nb.scale; + // One multiline round the corner when the two run on from one into the other. + let merged = match (a_before, b_before) { + (true, false) if same => Some((na.clone(), nb.clone())), + (false, true) if same => Some((nb.clone(), na.clone())), + _ => None, + }; + match merged { + Some((first, second)) => { + let mut m = first; + m.no_end_caps = second.no_end_caps; + m.vertices.extend(second.vertices.into_iter().skip(1)); + if m.vertices.len() > MAX_GEN { + return Err(other("Too many vertices.")); + } + store(s, ha, m)?; + s.doc_mut()?.remove_entity(hb); + return Ok(()); + } + None => { + a = na; + b = nb; + } + } + } + _ => {} + } + store(s, ha, a)?; + store(s, hb, b) +} + +/// Add or delete a vertex. +fn mledit_vertex(s: &mut Session, tool: Tool, h: Handle, p: Vec2) -> Result<()> { + let mut m = mline_of(s, h)?; + match tool { + Tool::AddVertex => { + let k = nearest_seg(&m, p).ok_or_else(|| other("*Invalid selection*"))?; + let seg = path_seg(&m, k).ok_or_else(|| other("*Invalid selection*"))?; + let q = seg.closest(p); + if q.near(seg.a, 1e-9) || q.near(seg.b, 1e-9) || m.vertices.len() >= MAX_GEN { + return Err(other("A vertex is already there.")); + } + let at = seg.a.dist(q); + let v = m.vertices.get_mut(k).ok_or_else(|| other("*Invalid selection*"))?; + // Breaks past the new vertex move to its segment. + let (keep, moved): (Vec, Vec) = v.cuts.drain(..).partition(|c| c.start.min(c.end) < at); + v.cuts = keep; + let moved = moved.into_iter().map(|c| MLineCut { start: c.start - at, end: c.end - at, ..c }).collect(); + m.vertices.insert(k + 1, MLineVertex { p: q.to3(m.vertices.get(k).map(|v| v.p.z).unwrap_or(0.0)), cuts: moved }); + } + Tool::DeleteVertex => { + let min = if m.closed { 4 } else { 3 }; + if m.vertices.len() < min { + return Err(other("The multiline has too few vertices to delete one.")); + } + let i = m + .vertices + .iter() + .enumerate() + .min_by(|a, b| a.1.p.xy().dist(p).total_cmp(&b.1.p.xy().dist(p))) + .map(|(i, _)| i) + .ok_or_else(|| other("*Invalid selection*"))?; + m.vertices.remove(i); + let n = m.vertices.len(); + // The segment that now spans the gap loses its breaks. + let prev = if i == 0 { if m.closed { n - 1 } else { 0 } } else { i - 1 }; + if let Some(v) = m.vertices.get_mut(prev) { + v.cuts.clear(); + } + } + _ => {} + } + store(s, h, m) +} + +/// Cut one element (the one nearest `p1`), cut all elements, or weld all, between `p1` and `p2` +/// on the segment picked at `p1`. +fn mledit_cut(s: &mut Session, tool: Tool, h: Handle, p1: Vec2, p2: Vec2) -> Result<()> { + let mut m = mline_of(s, h)?; + let k = nearest_seg(&m, p1).ok_or_else(|| other("*Invalid selection*"))?; + let (miters, offs) = (m.miters(), m.offsets()); + let lines: Vec<(usize, Line)> = offs.iter().enumerate().filter_map(|(e, d)| Some((e, element_line(&m, &miters, k, *d)?))).collect(); + let along = |l: &Line, q: Vec2| (q - l.a).dot(l.dir()).clamp(0.0, l.len()); + match tool { + Tool::CutSingle => { + let (e, l) = lines.iter().min_by(|a, b| a.1.dist(p1).total_cmp(&b.1.dist(p1))).copied().ok_or_else(|| other("*Invalid selection*"))?; + add_cut(&mut m, k, e, along(&l, p1), along(&l, p2)); + } + Tool::CutAll => { + for (e, l) in &lines { + add_cut(&mut m, k, *e, along(l, p1), along(l, p2)); + } + } + Tool::WeldAll => { + let mut cuts = Vec::new(); + for c in m.vertices.get(k).map(|v| v.cuts.clone()).unwrap_or_default() { + let Some((_, l)) = lines.iter().find(|(e, _)| *e == c.element) else { continue }; + let (a, b) = (along(l, p1).min(along(l, p2)), along(l, p1).max(along(l, p2))); + let (cs, ce) = (c.start.min(c.end), c.start.max(c.end)); + // What is left of the break outside the welded stretch. + if cs < a { + cuts.push(MLineCut { start: cs, end: ce.min(a), ..c }); + } + if ce > b { + cuts.push(MLineCut { start: cs.max(b), end: ce, ..c }); + } + } + if let Some(v) = m.vertices.get_mut(k) { + v.cuts = cuts; + } + } + _ => {} + } + store(s, h, m) +} + +#[derive(Default)] +struct MleditM { + tool: Option, + first: Option<(Handle, Vec2)>, +} + +impl Interactive for MleditM { + fn name(&self) -> &'static str { + "MLEDIT" + } + fn prompt(&self, _s: &Session) -> Prompt { + match (self.tool, self.first) { + (None, _) => Prompt::new("Enter mline editing option", curves::KW).kw(&TOOLS.map(|(k, _)| k)), + (Some(t), None) if t.pair() => Prompt::new("Select first mline", Accept::POINT), + (Some(_), None) => Prompt::new("Select mline", Accept::POINT), + (Some(t), Some(_)) if t.pair() => Prompt::new("Select second mline", Accept::POINT).kw(&["Undo"]), + (Some(_), Some((_, p))) => Prompt::new("Select second point", Accept::POINT).kw(&["Undo"]).base(p), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let Some(tool) = self.tool else { + return match &i { + Input::Keyword(k) | Input::Text(k) => { + self.tool = Some(tool_of(k).ok_or_else(|| other("Enter one of CC OC MC CT OT MT CJ AV DV CS CA WA."))?); + Ok(Step::Continue) + } + Input::Enter | Input::Cancel => Ok(Step::Cancel), + _ => Ok(Step::Continue), + }; + }; + match i { + Input::Keyword(k) if k == "Undo" => { + self.first = None; + Ok(Step::Continue) + } + Input::Point(p) => { + let pick = || -> Result { + let h = curves::pick_at(s, p).ok_or_else(|| other("Select a multiline."))?; + mline_of(s, h).map(|_| h) + }; + match self.first { + None if tool == Tool::AddVertex || tool == Tool::DeleteVertex => { + let h = pick()?; + mledit_vertex(s, tool, h, p)?; + Ok(Step::Continue) + } + None => { + self.first = Some((pick()?, p)); + Ok(Step::Continue) + } + Some((h1, p1)) if tool.pair() => { + let h2 = pick()?; + mledit_pair(s, tool, h1, p1, h2, p)?; + self.first = None; + Ok(Step::Continue) + } + Some((h1, p1)) => { + mledit_cut(s, tool, h1, p1, p)?; + self.first = None; + Ok(Step::Continue) + } + } + } + Input::Enter => Ok(Step::Done), + _ => Ok(Step::Continue), + } + } +} + +// ===================================================================================== +// EXPLODE +// ===================================================================================== + +/// The lines and arcs a multiline explodes into: each element piece in its element's colour +/// and linetype, caps and joints in the multiline's. +pub(crate) fn explode(e: &Entity, m: &MLine) -> Vec { + let g = m.geometry(); + let mut out = Vec::new(); + for (i, a, b) in &g.lines { + let mut c = e.common.clone(); + if let Some(el) = m.def.elements.get(*i) { + if !matches!(el.color, Color::ByLayer | Color::ByBlock) { + c.color = el.color; + } + let lt = el.linetype.trim(); + if !lt.is_empty() && !lt.eq_ignore_ascii_case("bylayer") && !lt.eq_ignore_ascii_case("byblock") { + c.linetype = el.linetype.clone(); + } + } + out.push(Entity { handle: Handle(0), common: c, kind: crate::cmd::helpers::line(*a, *b) }); + } + for seg in &g.caps { + let kind = match seg { + cadcraft_geom::Segment::Line(l) => crate::cmd::helpers::line(l.a, l.b), + cadcraft_geom::Segment::Arc { arc, .. } => crate::cmd::helpers::arc(arc), + }; + out.push(Entity { handle: Handle(0), common: e.common.clone(), kind }); + } + out +} diff --git a/crates/engine/src/cmd/draw2_tests.rs b/crates/engine/src/cmd/draw2_tests.rs index 723dd642d..d1cca0b8a 100644 --- a/crates/engine/src/cmd/draw2_tests.rs +++ b/crates/engine/src/cmd/draw2_tests.rs @@ -504,23 +504,26 @@ fn poly3d_json_and_command_line() { #[test] fn mline_json_and_command_line() { + // Element offsets from the vertex path, sorted. + let offs = |k: EntityKind| match k { + EntityKind::MLine(m) => { + let mut o = m.offsets(); + o.sort_by(f64::total_cmp); + o + } + k => panic!("{k:?}"), + }; let mut s = Session::new(); - let r = s.execute("mline", &json!({"points": [[0, 0], [10, 0], [10, 10]], "scale": 2, "justification": "zero"})).unwrap(); - assert_eq!(r["handles"].as_array().unwrap().len(), 2); - let ys: Vec = - s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p.vertices[0].p.y) } else { None }).collect(); - assert!(ys.contains(&1.0) && ys.contains(&-1.0), "{ys:?}"); + s.execute("mline", &json!({"points": [[0, 0], [10, 0], [10, 10]], "scale": 2, "justification": "zero"})).unwrap(); + assert_eq!(offs(last(&s)), vec![-1.0, 1.0]); assert!(s.execute("mline", &json!({"points": [[0, 0], [1, 0]], "justification": "middle"})).is_err()); let mut s = Session::new(); s.cmdline("mline j b s 3 0,0 10,0 10,5").unwrap(); s.cmdline("").unwrap(); assert!(s.running.is_none()); - let ys: Vec = - s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::LwPolyline(p) = &e.kind { Some(p.vertices[0].p.y) } else { None }).collect(); - assert_eq!(ys.len(), 2); - assert!(ys.contains(&0.0) && ys.contains(&3.0), "{ys:?}"); + assert_eq!(offs(last(&s)), vec![0.0, 3.0]); s.cmdline("mline 0,20 5,20 5,25 c").unwrap(); - assert!(matches!(last(&s), EntityKind::LwPolyline(p) if p.closed)); + assert!(matches!(last(&s), EntityKind::MLine(m) if m.closed && m.vertices.len() == 3)); } #[test] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bc340c60d..051b10d17 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -333,7 +333,7 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result { } /// Entities a DXF or DWG file doesn't keep: those of object types CADCraft doesn't model -/// (MLINE, 3DSOLID, MULTILEADER… read from other programs' files and kept in the drawing, but +/// (3DSOLID, MULTILEADER… read from other programs' files and kept in the drawing, but /// not written), counted by DXF type across model space, layouts and block definitions. pub fn not_saved(d: &Drawing) -> std::collections::BTreeMap { let mut out: std::collections::BTreeMap = std::collections::BTreeMap::new(); diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index b460fb7a9..88a2fc7c3 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1191,6 +1191,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option Some(super::draw2::mline::explode(e, m)), EntityKind::MText(m) => Some( cadcraft_fonts::plain_mtext(&m.contents) .split('\n') diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 74f26ae56..47e2f1aab 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -1434,6 +1434,7 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) { d.block = None; } EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z), + EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| z(&mut v.p)), EntityKind::MLeader(m) => { m.leaders.iter_mut().for_each(|l| l.iter_mut().for_each(z)); z(&mut m.landing); diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index 55467522b..3da63db05 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -224,6 +224,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option set_xy(l.vertices.get_mut(index)?, to), + EntityKind::MLine(m) => set_xy(&mut m.vertices.get_mut(index)?.p, to), EntityKind::MLeader(m) => { let mut idx = 0usize; let mut done = false; diff --git a/crates/engine/tests/mline.rs b/crates/engine/tests/mline.rs new file mode 100644 index 000000000..a1b67fb8f --- /dev/null +++ b/crates/engine/tests/mline.rs @@ -0,0 +1,286 @@ +//! Multilines (#510): the MLINE entity with its style's elements, MLSTYLE, MLEDIT, and the +//! edits, grips, snaps, selection and rendering every object gets. + +use cadcraft_doc::{EntityKind, Handle, MLine, Space}; +use cadcraft_engine::Session; +use cadcraft_engine::snap::{self, mode}; +use cadcraft_geom::Vec2; +use serde_json::{Value, json}; + +fn h(v: &Value) -> Handle { + Handle::parse_hex(v["handle"].as_str().unwrap()).unwrap() +} + +fn mline(s: &Session, hd: Handle) -> MLine { + match &s.doc().unwrap().entity(hd).unwrap().kind { + EntityKind::MLine(m) => m.clone(), + k => panic!("not a multiline: {k:?}"), + } +} + +/// Element pieces of a multiline, rounded, sorted: (x1, y1, x2, y2). +fn pieces(m: &MLine) -> Vec<[f64; 4]> { + let r = |v: f64| (v * 1e6).round() / 1e6 + 0.0; + let mut v: Vec<[f64; 4]> = m.geometry().lines.iter().map(|(_, a, b)| [r(a.x), r(a.y), r(b.x), r(b.y)]).collect(); + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v +} + +/// A horizontal multiline (0,0)–(10,0) and a vertical one (5,-5)–(5,5), both centred (Zero) +/// with scale 2: elements at y = ±1 and x = 4 / 6. +fn crossing(s: &mut Session) -> (Handle, Handle) { + let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]], "justification": "zero", "scale": 2})).unwrap()); + let b = h(&s.execute("mline", &json!({"points": [[5, -5], [5, 5]], "justification": "zero", "scale": 2})).unwrap()); + (a, b) +} + +#[test] +fn mline_is_one_object_with_the_standard_style() { + let mut s = Session::new(); + let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0], [10, 5]]})).unwrap()); + assert_eq!(s.doc().unwrap().model.len(), 1); + let m = mline(&s, a); + assert_eq!(m.style, "STANDARD"); + // Top justification: the first element runs through the points, the other one unit to the right. + assert_eq!(pieces(&m), vec![[0.0, -1.0, 11.0, -1.0], [0.0, 0.0, 10.0, 0.0], [10.0, 0.0, 10.0, 5.0], [11.0, -1.0, 11.0, 5.0]]); + assert_eq!(s.doc().unwrap().model.iter().next().unwrap().kind.type_name(), "MLine"); + assert!(s.execute("mline", &json!({"points": [[0, 0]]})).is_err()); + assert!(s.execute("mline", &json!({"points": [[0, 0], [1, 0]], "style": "NOPE"})).is_err()); +} + +#[test] +fn mlstyle_defines_elements_caps_and_fill() { + let mut s = Session::new(); + let r = s + .execute( + "mlstyle", + &json!({"name": "Wall", "elements": [{"offset": 0.5, "color": "red"}, {"offset": 0}, {"offset": -0.5, "linetype": "Hidden"}], + "fill": "yellow", "joints": true, "startCaps": {"line": true}, "endCaps": {"outerArc": true}, "current": false}), + ) + .unwrap(); + assert_eq!(r["current"], "STANDARD"); + assert_eq!(r["styles"].as_array().unwrap().len(), 2); + let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0], [10, 10]], "style": "wall", "justification": "zero"})).unwrap()); + let m = mline(&s, a); + assert_eq!(m.style, "Wall"); + assert_eq!(m.def.elements.len(), 3); + let g = m.geometry(); + assert_eq!(g.lines.len(), 6); + // Start cap line, end half circle, one joint at the corner. + assert_eq!(g.caps.len(), 3); + assert_eq!(g.fill.len(), 1); + + // A style in use keeps its elements; its description may change. + assert!(s.execute("mlstyle", &json!({"name": "Wall", "elements": [{"offset": 1}, {"offset": -1}]})).is_err()); + s.execute("mlstyle", &json!({"name": "Wall", "description": "outer walls", "current": false})).unwrap(); + assert!(s.execute("mlstyle", &json!({"delete": "Wall"})).is_err(), "in use"); + assert!(s.execute("mlstyle", &json!({"delete": "STANDARD"})).is_err()); + assert!(s.execute("mlstyle", &json!({"name": "Bad", "elements": []})).is_err()); + assert!(s.execute("mlstyle", &json!({"name": "Bad", "startCaps": {"angle": 5}})).is_err()); + + // New styles become current; MLINE then draws with them. + s.execute("mlstyle", &json!({"name": "Thin", "copyFrom": "Wall", "fill": false})).unwrap(); + assert_eq!(s.doc().unwrap().header.str("CMLSTYLE", ""), "Thin"); + let b = h(&s.execute("mline", &json!({"points": [[0, 20], [5, 20]]})).unwrap()); + assert_eq!(mline(&s, b).def.elements.len(), 3); + assert!(mline(&s, b).def.fill.is_none()); + s.execute("erase", &json!({"handles": [b.hex()]})).unwrap(); + s.execute("mlstyle", &json!({"name": "Thin", "rename": "Slim"})).unwrap(); + assert_eq!(s.doc().unwrap().header.str("CMLSTYLE", ""), "Slim"); + s.execute("mlstyle", &json!({"name": "STANDARD"})).unwrap(); + s.execute("mlstyle", &json!({"delete": "Slim"})).unwrap(); + assert!(s.doc().unwrap().mline_style("Slim").is_none()); +} + +#[test] +fn mline_command_line_style_option() { + let mut s = Session::new(); + s.execute("mlstyle", &json!({"name": "Triple", "elements": [{"offset": 1}, {"offset": 0}, {"offset": -1}], "current": false})).unwrap(); + s.cmdline("mline st ?").unwrap(); + assert!(s.log.iter().any(|l| l.contains("Triple")), "{:?}", s.log); + for t in ["triple", "j z 0,0 10,0", ""] { + s.cmdline(t).unwrap(); + } + assert!(s.running.is_none()); + let m = match &s.doc().unwrap().model.iter().last().unwrap().kind { + EntityKind::MLine(m) => m.clone(), + k => panic!("{k:?}"), + }; + assert_eq!((m.style.as_str(), m.def.elements.len()), ("Triple", 3)); + assert_eq!(s.doc().unwrap().header.str("CMLSTYLE", ""), "Triple"); + assert!(s.cmdline("mline st nosuch").is_err() || s.log.iter().any(|l| l.contains("Cannot find"))); +} + +#[test] +fn mledit_crosses() { + // Closed cross: the first multiline is broken where the second crosses it. + let mut s = Session::new(); + let (a, b) = crossing(&mut s); + s.execute("mledit", &json!({"tool": "cc", "first": a.hex(), "p1": [2, 0], "second": b.hex(), "p2": [5, 3]})).unwrap(); + assert_eq!(pieces(&mline(&s, a)), vec![[0.0, -1.0, 4.0, -1.0], [0.0, 1.0, 4.0, 1.0], [6.0, -1.0, 10.0, -1.0], [6.0, 1.0, 10.0, 1.0]]); + assert_eq!(pieces(&mline(&s, b)).len(), 2); + + // Open cross: the second one's outer elements open too. + let mut s = Session::new(); + let (a, b) = crossing(&mut s); + s.cmdline("mledit oc 2,1 4,3").unwrap(); + s.cmdline("").unwrap(); + assert_eq!(pieces(&mline(&s, a)).len(), 4); + assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, -1.0], [4.0, 1.0, 4.0, 5.0], [6.0, -5.0, 6.0, -1.0], [6.0, 1.0, 6.0, 5.0]]); + + // Merged cross: the outer elements of both break, inner ones run through. + let mut s = Session::new(); + s.execute("mlstyle", &json!({"name": "C", "elements": [{"offset": 0.5}, {"offset": 0}, {"offset": -0.5}]})).unwrap(); + let (a, b) = crossing(&mut s); + s.execute("mledit", &json!({"tool": "mc", "first": a.hex(), "p1": [2, 0], "second": b.hex(), "p2": [5, 3]})).unwrap(); + assert_eq!(pieces(&mline(&s, a)).len(), 5); + assert_eq!(pieces(&mline(&s, b)).len(), 5); + assert!(pieces(&mline(&s, a)).contains(&[0.0, 0.0, 10.0, 0.0]), "centre line continues"); +} + +#[test] +fn mledit_tees_and_corner() { + // Closed tee: the first multiline ends at the second one's near side. + let mut s = Session::new(); + let (a, b) = crossing(&mut s); + s.execute("mledit", &json!({"tool": "ct", "first": a.hex(), "p1": [1, 0], "second": b.hex(), "p2": [5, 3]})).unwrap(); + let m = mline(&s, a); + assert_eq!(m.points(), vec![Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0)]); + assert!(m.no_end_caps); + assert_eq!(pieces(&mline(&s, b)).len(), 2); + + // Open tee: the second one opens where the first meets it. + let mut s = Session::new(); + let (a, b) = crossing(&mut s); + s.execute("mledit", &json!({"tool": "ot", "first": a.hex(), "p1": [9, 0], "second": b.hex(), "p2": [5, 3]})).unwrap(); + assert_eq!(mline(&s, a).points(), vec![Vec2::new(6.0, 0.0), Vec2::new(10.0, 0.0)]); + assert_eq!(pieces(&mline(&s, b)), vec![[4.0, -5.0, 4.0, 5.0], [6.0, -5.0, 6.0, -1.0], [6.0, 1.0, 6.0, 5.0]]); + assert!(s.execute("mledit", &json!({"tool": "mt", "first": a.hex(), "p1": [9, 0], "second": b.hex(), "p2": [5, 3]})).is_err()); + + // Corner joint: two multilines become one round the corner. + let mut s = Session::new(); + let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + let b = h(&s.execute("mline", &json!({"points": [[12, 2], [12, 10]]})).unwrap()); + s.execute("mledit", &json!({"tool": "cj", "first": a.hex(), "p1": [2, 0], "second": b.hex(), "p2": [12, 8]})).unwrap(); + assert_eq!(s.doc().unwrap().model.len(), 1); + assert_eq!(mline(&s, a).points(), vec![Vec2::new(0.0, 0.0), Vec2::new(12.0, 0.0), Vec2::new(12.0, 10.0)]); +} + +#[test] +fn mledit_vertices_cuts_and_welds() { + let mut s = Session::new(); + let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]], "justification": "zero", "scale": 2})).unwrap()); + s.execute("mledit", &json!({"tool": "av", "handle": a.hex(), "point": [5, 0.3]})).unwrap(); + assert_eq!(mline(&s, a).vertices.len(), 3); + s.execute("mledit", &json!({"tool": "dv", "handle": a.hex(), "point": [5.2, 0]})).unwrap(); + assert_eq!(mline(&s, a).points(), vec![Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0)]); + assert!(s.execute("mledit", &json!({"tool": "dv", "handle": a.hex(), "point": [0, 0]})).is_err(), "two vertices stay"); + + // Cut single: only the element picked. + s.execute("mledit", &json!({"tool": "cs", "handle": a.hex(), "p1": [3, 1.1], "p2": [5, 1]})).unwrap(); + assert_eq!(pieces(&mline(&s, a)), vec![[0.0, -1.0, 10.0, -1.0], [0.0, 1.0, 3.0, 1.0], [5.0, 1.0, 10.0, 1.0]]); + // Cut all, then weld all over both breaks. + s.cmdline("mledit ca 7,1 8,1").unwrap(); + s.cmdline("").unwrap(); + assert_eq!(pieces(&mline(&s, a)).len(), 5); + s.execute("mledit", &json!({"tool": "wa", "handle": a.hex(), "p1": [2, 0], "p2": [9, 0]})).unwrap(); + assert_eq!(pieces(&mline(&s, a)), vec![[0.0, -1.0, 10.0, -1.0], [0.0, 1.0, 10.0, 1.0]]); + // Undo brings the breaks back. + s.execute("u", &json!({})).unwrap(); + assert_eq!(pieces(&mline(&s, a)).len(), 5); +} + +#[test] +fn transforms_grips_explode() { + let mut s = Session::new(); + let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + s.execute("mledit", &json!({"tool": "ca", "handle": a.hex(), "p1": [4, 0], "p2": [6, 0]})).unwrap(); + // Mirrored: the second element moves to the other side. + s.execute("mirror", &json!({"handles": [a.hex()], "p1": [0, 0], "p2": [1, 0], "erase": true})).unwrap(); + let a = s.doc().unwrap().model.iter().last().unwrap().handle; + assert_eq!(pieces(&mline(&s, a)), vec![[0.0, 0.0, 4.0, 0.0], [0.0, 1.0, 4.0, 1.0], [6.0, 0.0, 10.0, 0.0], [6.0, 1.0, 10.0, 1.0]]); + // Scaled: offsets and breaks scale. + s.execute("scale", &json!({"handles": [a.hex()], "base": [0, 0], "factor": 2})).unwrap(); + assert_eq!(pieces(&mline(&s, a)), vec![[0.0, 0.0, 8.0, 0.0], [0.0, 2.0, 8.0, 2.0], [12.0, 0.0, 20.0, 0.0], [12.0, 2.0, 20.0, 2.0]]); + s.execute("move", &json!({"handles": [a.hex()], "delta": [1, 1]})).unwrap(); + s.execute("rotate", &json!({"handles": [a.hex()], "base": [1, 1], "angle": 90})).unwrap(); + let near = |p: &[Vec2], q: &[Vec2]| p.len() == q.len() && p.iter().zip(q).all(|(a, b)| a.near(*b, 1e-9)); + assert!(near(&mline(&s, a).points(), &[Vec2::new(1.0, 1.0), Vec2::new(1.0, 21.0)])); + + // Grips are the vertices. + let k = s.doc().unwrap().entity(a).unwrap().kind.clone(); + assert!(near(&k.grips(), &mline(&s, a).points())); + let moved = cadcraft_engine::grips::stretch_grip(&k, 1, Vec2::new(5.0, 21.0)).unwrap(); + let EntityKind::MLine(m) = moved else { panic!() }; + assert_eq!(m.points()[1], Vec2::new(5.0, 21.0)); + + // Explode: lines in the elements' colours. + let mut s = Session::new(); + s.execute( + "mlstyle", + &json!({"name": "R", "elements": [{"offset": 0.5, "color": "red"}, {"offset": -0.5}], "endCaps": {"line": true, "outerArc": true}}), + ) + .unwrap(); + let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + s.execute("explode", &json!({"handles": [a.hex()]})).unwrap(); + let d = s.doc().unwrap(); + let lines = d.model.iter().filter(|e| matches!(e.kind, EntityKind::Line(_))).count(); + let arcs = d.model.iter().filter(|e| matches!(e.kind, EntityKind::Arc(_))).count(); + assert_eq!((lines, arcs), (3, 1)); + assert!(d.model.iter().any(|e| e.common.color == cadcraft_color::Color::Index(1))); + assert!(!d.model.iter().any(|e| matches!(e.kind, EntityKind::MLine(_)))); +} + +#[test] +fn snaps_selection_extents_and_render() { + let mut s = Session::new(); + s.execute("mlstyle", &json!({"name": "R", "elements": [{"offset": 0.5, "color": "red"}, {"offset": -0.5}], "fill": "blue"})).unwrap(); + let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]], "justification": "zero"})).unwrap()); + let d = s.doc().unwrap(); + // Endpoint of the lower element, and the intersection-free midpoint of the upper one. + let hit = snap::osnap(d, &Space::Model, Vec2::new(10.05, -0.45), 0.3, mode::END, None, false).unwrap(); + assert_eq!(hit.point, Vec2::new(10.0, -0.5)); + let hit = snap::osnap(d, &Space::Model, Vec2::new(5.05, 0.45), 0.3, mode::MID, None, false).unwrap(); + assert_eq!(hit.point, Vec2::new(5.0, 0.5)); + // Picking an element picks the multiline. + assert_eq!(cadcraft_engine::select::pick(d, &Space::Model, Vec2::new(3.0, 0.52), 0.1), Some(a)); + let ext = d.extents(&Space::Model); + assert!(ext.min.near(Vec2::new(0.0, -0.5), 1e-9) && ext.max.near(Vec2::new(10.0, 0.5), 1e-9), "{ext:?}"); + // Drawn: the fill, and each element in its own colour. + let list = cadcraft_render::build(d, &Space::Model, &cadcraft_render::Options::default()); + let red = cadcraft_color::aci_rgb(1); + let blue = cadcraft_color::aci_rgb(5); + assert!(list.prims.iter().any(|p| p.color == red && matches!(p.kind, cadcraft_render::Kind::Polyline))); + assert!(list.prims.iter().any(|p| p.color == blue && matches!(p.kind, cadcraft_render::Kind::Tris))); +} + +#[test] +fn hostile_mline_params_never_panic() { + let mut s = Session::new(); + let a = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + let b = h(&s.execute("mline", &json!({"points": [[0, 0], [10, 0]]})).unwrap()); + let bad = [ + ("mline", json!({"points": [[0, 0], [1e308, 1e308], [-1e308, 0]], "scale": 1e308})), + ("mline", json!({"points": [[0, 0], [0, 0], [0, 0]]})), + ("mline", json!({"points": "x", "closed": true})), + ("mlstyle", json!({"name": "X", "elements": [{"offset": 1e308}, {"offset": "a"}]})), + ("mlstyle", json!({"name": "X", "elements": (0..100).map(|i| json!({"offset": i})).collect::>()})), + ("mlstyle", json!({"name": "", "fill": 99999})), + ("mlstyle", json!({"name": "a String { + s.trim().to_ascii_uppercase() +} + +fn finite(v: f64) -> f64 { + if v.is_finite() { v } else { 0.0 } +} + +#[derive(Default)] +struct RawVertex { + p: Vec3, + dir: Vec3, + miter: Vec3, + /// Per element: its groups 41. + params: Vec>, +} + +/// The MLINE entity's own groups (after the `AcDbMline` marker when present). The style's +/// elements are recovered from the vertex data; [`resolve`] replaces them with the style's. +pub(crate) fn read_entity(tags: &[Tag]) -> MLine { + let start = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbMline").unwrap_or(0); + let tags = tags.get(start..).unwrap_or(&[]); + let mut style = String::new(); + let mut scale = 1.0; + let mut just = MLineJust::Top; + let mut flags = 0; + let mut verts: Vec = Vec::new(); + let mut in_params = false; + for t in tags { + let v = t.f64(); + match t.code { + 2 => style = t.str(), + 40 => scale = finite(v), + 70 => just = MLineJust::from_dxf(t.i64()), + 71 => flags = t.i64(), + 11 => { + in_params = false; + if verts.len() >= MAX_MLINE_VERTICES { + break; + } + verts.push(RawVertex { p: Vec3::new(finite(v), 0.0, 0.0), ..RawVertex::default() }); + } + 21 | 31 | 12 | 22 | 32 | 13 | 23 | 33 => { + let Some(r) = verts.last_mut() else { continue }; + // 1x/2x/3x: x/y/z of the vertex (x1), segment direction (x2) or miter (x3). + let target = match t.code % 10 { + 1 => &mut r.p, + 2 => &mut r.dir, + _ => &mut r.miter, + }; + match t.code / 10 { + 1 => target.x = finite(v), + 2 => target.y = finite(v), + _ => target.z = finite(v), + } + } + 74 => { + if let Some(r) = verts.last_mut() + && r.params.len() < MAX_MLINE_ELEMENTS + { + r.params.push(Vec::new()); + in_params = true; + } else { + in_params = false; + } + } + 41 if in_params => { + if let Some(list) = verts.last_mut().and_then(|r| r.params.last_mut()) + && list.len() < MAX_PARAMS + { + list.push(finite(v)); + } + } + _ => {} + } + } + let nelem = verts.first().map(|r| r.params.len()).unwrap_or(0).clamp(1, MAX_MLINE_ELEMENTS); + // Element offsets from the first vertex: its distance along the miter, measured across the + // first segment. + let first = verts.first(); + let pts: Vec = verts.iter().map(|r| r.p.xy()).collect(); + let dir0 = first.map(|r| r.dir.xy().normalized()).filter(|d| *d != Vec2::ZERO).unwrap_or_else(|| match (pts.first(), pts.get(1)) { + (Some(a), Some(b)) => (*b - *a).normalized(), + _ => Vec2::X, + }); + let miter0 = first.map(|r| r.miter.xy().normalized()).filter(|d| *d != Vec2::ZERO).unwrap_or(dir0.perp()); + let across = miter0.dot(dir0.perp()); + let s = if scale.abs() > 1e-12 { scale } else { 1.0 }; + let elements: Vec = (0..nelem) + .map(|e| { + let d = first.and_then(|r| r.params.get(e)).and_then(|l| l.first()).copied().unwrap_or(0.0) * across; + MLineElement { offset: finite(d / s), ..MLineElement::default() } + }) + .collect(); + let def = MLineStyle { name: if style.is_empty() { STANDARD_MLINE_STYLE.into() } else { style.clone() }, elements, ..MLineStyle::default() }; + let vertices = verts + .iter() + .map(|r| { + let mut cuts = Vec::new(); + for (e, list) in r.params.iter().enumerate() { + cuts.extend(gaps(list).into_iter().map(|(start, end)| MLineCut { element: e, start, end })); + } + cuts.truncate(MAX_MLINE_CUTS); + MLineVertex { p: r.p, cuts } + }) + .collect(); + MLine { + style: def.name.clone(), + def, + scale, + justification: just, + closed: flags & 2 != 0, + no_start_caps: flags & 4 != 0, + no_end_caps: flags & 8 != 0, + vertices, + } +} + +/// End of a break that runs to the end of its segment. +const TO_END: f64 = 1e12; + +/// Breaks from an element's groups 41: the first is its distance along the miter, the second +/// where the element starts (from the miter point), then alternately where it stops and starts +/// again (from its start). +fn gaps(list: &[f64]) -> Vec<(f64, f64)> { + let Some(start) = list.get(1).copied() else { return Vec::new() }; + let mut out = Vec::new(); + if start > 1e-12 { + out.push((0.0, start)); + } + let rest = list.get(2..).unwrap_or(&[]); + for pair in rest.chunks(2) { + match pair { + [a, b] => out.push((start + a, start + b)), + [a] => out.push((start + a, TO_END)), + _ => {} + } + } + out.retain(|(a, b)| b > a); + out +} + +/// An element's groups 41 for one segment: `along` (its distance along the miter), then where +/// it starts and the breaks (`on`: the drawn pieces along the element, `len` its length). +fn params(along: f64, on: &[(f64, f64)], len: f64) -> Vec { + let mut v = vec![along]; + let Some(&(s0, _)) = on.first() else { + v.push(len); + return v; + }; + v.push(s0); + for (i, (s, e)) in on.iter().enumerate() { + if i > 0 { + v.push(s - s0); + } + if (e - len).abs() > 1e-9 || i + 1 < on.len() { + v.push(e - s0); + } + } + v +} + +/// The MLINE groups after the common entity groups. +pub(crate) fn entity_tags(m: &MLine, style_handle: &str) -> Vec { + let mut t = Vec::new(); + let p = |t: &mut Vec, c: i32, v: Vec3| { + t.push(Tag::f(c, v.x)); + t.push(Tag::f(c + 10, v.y)); + t.push(Tag::f(c + 20, v.z)); + }; + t.push(Tag::s(2, m.style.chars().take(32).collect::())); + t.push(Tag::s(340, style_handle)); + t.push(Tag::f(40, finite(m.scale))); + t.push(Tag::i(70, m.justification.to_dxf())); + let flags = + i64::from(!m.vertices.is_empty()) | if m.closed { 2 } else { 0 } | if m.no_start_caps { 4 } else { 0 } | if m.no_end_caps { 8 } else { 0 }; + t.push(Tag::i(71, flags)); + t.push(Tag::i(72, m.vertices.len() as i64)); + t.push(Tag::i(73, m.def.elements.len() as i64)); + p(&mut t, 10, m.vertices.first().map(|v| v.p).unwrap_or_default()); + p(&mut t, 210, Vec3::Z); + let pts = m.points(); + let n = pts.len(); + let dirs = m.directions(); + let miters = m.miters(); + let offs = m.offsets(); + let nseg = m.segment_count(); + for (k, v) in m.vertices.iter().enumerate() { + p(&mut t, 11, v.p); + let dir = dirs.get(k.min(nseg.saturating_sub(1))).copied().unwrap_or(Vec2::X); + p(&mut t, 12, dir.to3(0.0)); + let mv = miters.get(k).copied().unwrap_or(Vec2::Y); + let stretch = mv.len(); + p(&mut t, 13, mv.normalized().to3(0.0)); + let has_seg = k < nseg; + for (e, d) in offs.iter().enumerate() { + let along = d * stretch; + let (a, b) = (pts.get(k).copied().unwrap_or_default() + mv * *d, { + let j = (k + 1) % n.max(1); + pts.get(j).copied().unwrap_or_default() + miters.get(j).copied().unwrap_or_default() * *d + }); + let len = if has_seg { a.dist(b) } else { 0.0 }; + let on = on_pieces(len, v.cuts.iter().filter(|c| c.element == e).map(|c| (c.start, c.end))); + let list = if has_seg { params(along, &on, len) } else { vec![along, 0.0] }; + t.push(Tag::i(74, list.len() as i64)); + t.extend(list.into_iter().map(|x| Tag::f(41, x))); + t.push(Tag::i(75, 0)); + } + } + t +} + +/// The drawn pieces of `[0, len]` with `gaps` taken out. +fn on_pieces(len: f64, gaps: impl Iterator) -> Vec<(f64, f64)> { + let mut gaps: Vec<(f64, f64)> = + gaps.filter(|(a, b)| a.is_finite() && b.is_finite()).map(|(a, b)| (a.min(b).max(0.0), a.max(b).min(len))).filter(|(a, b)| b > a).collect(); + gaps.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut out = Vec::new(); + let mut from = 0.0; + for (a, b) in gaps { + if a > from + 1e-12 { + out.push((from, a)); + } + from = f64::max(from, b); + } + if len > from + 1e-12 { + out.push((from, len)); + } + out +} + +/// Call `f` with each record (`0 TYPE` and the groups after it) of a section, without copying. +fn each_record<'a>(tags: &'a [Tag], mut f: impl FnMut(&str, &'a [Tag])) { + let mut open: Option<(String, usize)> = None; + for (i, t) in tags.iter().enumerate() { + if t.code == 0 { + if let Some((k, s)) = open.take() { + f(&k, tags.get(s..i).unwrap_or(&[])); + } + open = Some((t.str(), i + 1)); + } + } + if let Some((k, s)) = open { + f(&k, tags.get(s..).unwrap_or(&[])); + } +} + +/// A MLINESTYLE object. +fn read_style(tags: &[Tag]) -> Option { + let start = tags.iter().position(|t| t.code == 100 && t.str() == "AcDbMlineStyle").unwrap_or(0); + let body = tags.get(start..).unwrap_or(&[]); + let mut st = MLineStyle { elements: Vec::new(), ..MLineStyle::default() }; + let mut flags = 0; + let mut fill = Color::ByLayer; + for t in body { + match t.code { + 2 => st.name = t.str(), + 3 => st.description = t.str(), + 70 => flags = t.i64(), + 51 => st.start_caps.angle = t.f64().to_radians(), + 52 => st.end_caps.angle = t.f64().to_radians(), + 49 if st.elements.len() < MAX_MLINE_ELEMENTS => st.elements.push(MLineElement { offset: finite(t.f64()), ..MLineElement::default() }), + 62 => { + let c = Color::from_aci(i16::try_from(t.i64()).unwrap_or(256)); + match st.elements.last_mut() { + Some(e) => e.color = c, + None => fill = c, + } + } + 6 => { + if let Some(e) = st.elements.last_mut() { + e.linetype = t.str(); + } + } + _ => {} + } + } + if st.name.trim().is_empty() { + return None; + } + for e in &mut st.elements { + if e.linetype.eq_ignore_ascii_case("bylayer") || e.linetype.is_empty() { + e.linetype = "ByLayer".into(); + } else if e.linetype.eq_ignore_ascii_case("byblock") { + e.linetype = "ByBlock".into(); + } + } + st.fill = (flags & FILL != 0).then_some(fill); + st.joints = flags & JOINTS != 0; + st.start_caps.line = flags & START_LINE != 0; + st.start_caps.inner_arcs = flags & START_INNER != 0; + st.start_caps.outer_arc = flags & START_OUTER != 0; + st.end_caps.line = flags & END_LINE != 0; + st.end_caps.inner_arcs = flags & END_INNER != 0; + st.end_caps.outer_arc = flags & END_OUTER != 0; + Some(st.sanitized()) +} + +fn has_mlines(store: &EntityStore) -> bool { + store.iter().any(|e| matches!(e.kind, EntityKind::MLine(_))) +} + +/// Read the multiline styles, then give every multiline its style's elements, caps and fill. +pub(crate) fn resolve(secs: &[Section], d: &mut Drawing) { + let mut styles: Vec = Vec::new(); + for s in secs.iter().filter(|s| s.name == "OBJECTS") { + each_record(&s.tags, |kind, tags| { + if kind == "MLINESTYLE" + && styles.len() < MAX_STYLES + && let Some(st) = read_style(tags) + { + styles.push(st); + } + }); + } + for st in styles { + match d.mline_styles.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&st.name)) { + Some(x) => *x = st, + None => d.mline_styles.push(st), + } + } + let by_name: HashMap = d.mline_styles.iter().map(|s| (key(&s.name), s.clone())).collect(); + fill(&mut d.model, &by_name); + for l in &mut d.layouts { + fill(&mut l.entities, &by_name); + } + for b in d.blocks.values_mut() { + if has_mlines(&b.entities) { + fill(&mut std::sync::Arc::make_mut(b).entities, &by_name); + } + } +} + +fn fill(store: &mut EntityStore, by_name: &HashMap) { + let hs: Vec = store.iter().filter(|e| matches!(e.kind, EntityKind::MLine(_))).map(|e| e.handle).collect(); + for h in hs { + store.modify(h, |e| { + if let EntityKind::MLine(m) = &mut e.kind + && let Some(st) = by_name.get(&key(&m.style)) + && st.elements.len() == m.def.elements.len() + { + m.def = st.clone(); + } + }); + } +} + +/// The multiline styles to write, with handles allocated before anything is written. +#[derive(Default)] +pub(crate) struct Plan { + /// The `ACAD_MLINESTYLE` dictionary. + pub dict: String, + /// Each style and its MLINESTYLE handle. + styles: Vec<(MLineStyle, String)>, +} + +impl Plan { + /// The MLINESTYLE handle of style `name` (STANDARD's when it is missing). + pub fn handle(&self, name: &str) -> String { + let find = |n: &str| self.styles.iter().find(|(s, _)| s.name.eq_ignore_ascii_case(n.trim())).map(|(_, h)| h.clone()); + find(name).or_else(|| find(STANDARD_MLINE_STYLE)).unwrap_or_else(|| "0".into()) + } +} + +/// The drawing's multiline styles, STANDARD and the style of every multiline in `every`. +pub(crate) fn plan<'a>(d: &Drawing, every: impl Iterator, mut new_handle: impl FnMut() -> String) -> Plan { + let mut list: Vec = d.mline_styles.clone(); + if !list.iter().any(|s| s.name.eq_ignore_ascii_case(STANDARD_MLINE_STYLE)) { + list.insert(0, MLineStyle::default()); + } + for e in every { + if let EntityKind::MLine(m) = &e.kind + && !list.iter().any(|s| s.name.eq_ignore_ascii_case(m.style.trim())) + { + list.push(MLineStyle { name: m.style.trim().to_string(), ..m.def.clone() }); + } + } + let dict = new_handle(); + Plan { dict, styles: list.into_iter().filter(|s| !s.name.trim().is_empty()).map(|s| (s, new_handle())).collect() } +} + +/// The `ACAD_MLINESTYLE` dictionary (owned by the root dictionary `root`) and its styles. +pub(crate) fn objects(plan: &Plan, root: &str) -> Vec { + let mut t = vec![Tag::s(0, "DICTIONARY"), Tag::s(5, plan.dict.clone()), Tag::s(330, root), Tag::s(100, "AcDbDictionary"), Tag::i(281, 1)]; + for (s, h) in &plan.styles { + t.push(Tag::s(3, s.name.trim())); + t.push(Tag::s(350, h.clone())); + } + for (s, h) in &plan.styles { + t.extend([Tag::s(0, "MLINESTYLE"), Tag::s(5, h.clone()), Tag::s(102, "{ACAD_REACTORS"), Tag::s(330, plan.dict.clone()), Tag::s(102, "}")]); + t.extend([Tag::s(330, plan.dict.clone()), Tag::s(100, "AcDbMlineStyle"), Tag::s(2, s.name.trim())]); + let caps = |c: &MLineCaps, line: i64, inner: i64, outer: i64| { + (if c.line { line } else { 0 }) | (if c.inner_arcs { inner } else { 0 }) | (if c.outer_arc { outer } else { 0 }) + }; + let flags = (if s.fill.is_some() { FILL } else { 0 }) + | (if s.joints { JOINTS } else { 0 }) + | caps(&s.start_caps, START_LINE, START_INNER, START_OUTER) + | caps(&s.end_caps, END_LINE, END_INNER, END_OUTER); + t.push(Tag::i(70, flags)); + t.push(Tag::s(3, s.description.clone())); + t.push(Tag::i(62, i64::from(s.fill.unwrap_or(Color::ByLayer).to_aci()))); + t.push(Tag::f(51, finite(s.start_caps.angle.to_degrees()))); + t.push(Tag::f(52, finite(s.end_caps.angle.to_degrees()))); + t.push(Tag::i(71, s.elements.len() as i64)); + for e in &s.elements { + t.push(Tag::f(49, finite(e.offset))); + t.push(Tag::i(62, i64::from(e.color.to_aci()))); + let lt = if e.linetype.trim().is_empty() { "BYLAYER".to_string() } else { e.linetype.to_ascii_uppercase() }; + t.push(Tag::s(6, if lt == "BYLAYER" || lt == "BYBLOCK" { lt } else { e.linetype.clone() })); + } + } + t +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index c618e503b..89b197cb0 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -407,6 +407,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> { let corners = corners.map(|p| ocs(&c, p)); if kind == "SOLID" { EntityKind::Solid(Solid { corners }) } else { EntityKind::Trace(Solid { corners }) } } + "MLINE" => EntityKind::MLine(crate::dxf_mline::read_entity(tags)), "3DFACE" => EntityKind::Face3d(Face3d { corners: [t.p(10), t.p(11), t.p(12), t.p(13)], hidden_edges: t.i(70).unwrap_or(0) as u8 }), "HATCH" => { let mut k = hatch(tags, &t)?; @@ -1597,6 +1598,7 @@ pub fn read(bytes: &[u8]) -> Result { apply_sortents(&mut d, &objs.sortents, &rx.brs, &paper_block_for, &first_layout); objs.apply(&mut d, &rx); crate::dxf_image::resolve(&secs, &mut d); + crate::dxf_mline::resolve(&secs, &mut d); // Keep handles unique against the header's seed. let seed = d.header.str("HANDSEED", ""); if let Some(h) = own_seed.or_else(|| Handle::parse_hex(&seed)) { diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 924991bc1..fa9f3d79d 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -96,6 +96,8 @@ struct OsnapRef { /// Handles and names the entity writer needs from the rest of the file. #[derive(Default)] struct Ctx { + /// Multiline styles: MLINESTYLE handles and their dictionary. + mlines: crate::dxf_mline::Plan, /// First viewport of each layout that has no paper-space viewport (id 1) → the layout's index; /// [`paper_view`] writes that layout's paper-space viewport ahead of it. paper_views: HashMap, @@ -207,6 +209,9 @@ const HEADER_VARS: &[(&str, i32)] = &[ ("ATTMODE", 70), ("TEXTSIZE", 40), ("TRACEWID", 40), + ("CMLSTYLE", 2), + ("CMLJUST", 70), + ("CMLSCALE", 40), ("TEXTSTYLE", 7), ("CLAYER", 8), ("CELTYPE", 6), @@ -479,6 +484,11 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx } } } + EntityKind::MLine(m) => { + w.s(0, "MLINE"); + common(w, e, owner, paper, "AcDbMline"); + w.t.extend(crate::dxf_mline::entity_tags(m, &cx.mlines.handle(&m.style))); + } EntityKind::Polyline3d(p) => { w.s(0, "POLYLINE"); common(w, e, owner, paper, "AcDb3dPolyline"); @@ -1460,6 +1470,7 @@ pub fn tags(d: &Drawing, version: Version) -> Vec { } // Image definitions (IMAGEDEF objects) of raster images anywhere in the drawing. cx.images = crate::dxf_image::plan(every.iter().copied(), || w.h()); + cx.mlines = crate::dxf_mline::plan(d, every.iter().copied(), || w.h()); // DIMASSOC objects of associative dimensions in model and paper space. for st in &all_spaces { for e in st.iter() { @@ -1952,6 +1963,8 @@ pub fn tags(d: &Drawing, version: Version) -> Vec { w.s(350, layout_dict.clone()); w.s(3, "ACAD_MLEADERSTYLE"); w.s(350, mleader_dict.clone()); + w.s(3, "ACAD_MLINESTYLE"); + w.s(350, cx.mlines.dict.clone()); w.s(3, dxf_ext::PLOT_STYLE_NAMES_KEY); w.s(350, pstyle_dict.clone()); w.s(3, "ACAD_TABLESTYLE"); @@ -1988,6 +2001,8 @@ pub fn tags(d: &Drawing, version: Version) -> Vec { for (s, h) in mleader_styles.iter().zip(&mleader_handles) { mleader_style_obj(&mut w, s, h, &mleader_dict, byblock_ltype.as_deref(), cx.style(&s.text_style)); } + // Multiline styles. + w.t.extend(crate::dxf_mline::objects(&cx.mlines, &root_dict)); // Table styles. w.s(0, "DICTIONARY"); w.s(5, table_style_dict.clone()); diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a2a3025bd..7b7abe58d 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -10,6 +10,9 @@ mod dxf_write; pub mod pdf; pub mod svg; +/// Multilines and their styles (MLINE, MLINESTYLE). +mod dxf_mline; + use cadcraft_doc::{Drawing, Space}; use cadcraft_geom::Bounds2; diff --git a/crates/io/tests/mline_roundtrip.rs b/crates/io/tests/mline_roundtrip.rs new file mode 100644 index 000000000..9bd4ce0d7 --- /dev/null +++ b/crates/io/tests/mline_roundtrip.rs @@ -0,0 +1,297 @@ +//! Multilines survive DXF save and reopen (#510): the MLINE entity with its vertices, breaks, +//! closed and cap flags, and its MLINESTYLE (elements, caps, joints, fill) in the +//! `ACAD_MLINESTYLE` dictionary, as the DXF Reference describes. Foreign files read the same way, +//! and hostile ones don't panic. + +use cadcraft_color::Color; +use cadcraft_doc::{Common, Drawing, EntityKind, MLine, MLineCaps, MLineCut, MLineElement, MLineJust, MLineStyle, Space}; +use cadcraft_dxf::{Tag, records, sections}; +use cadcraft_geom::Vec2; + +fn mlines(d: &Drawing) -> Vec { + d.model + .iter() + .filter_map(|e| match &e.kind { + EntityKind::MLine(m) => Some(m.clone()), + _ => None, + }) + .collect() +} + +fn wall() -> MLineStyle { + MLineStyle { + name: "Wall".into(), + description: "outer wall".into(), + elements: vec![ + MLineElement { offset: 1.5, color: Color::Index(1), linetype: "ByLayer".into() }, + MLineElement { offset: 0.0, color: Color::ByBlock, linetype: "Continuous".into() }, + MLineElement { offset: -1.5, color: Color::ByLayer, linetype: "ByLayer".into() }, + ], + start_caps: MLineCaps { line: true, outer_arc: false, inner_arcs: true, angle: 60f64.to_radians() }, + end_caps: MLineCaps { line: false, outer_arc: true, inner_arcs: false, angle: std::f64::consts::FRAC_PI_2 }, + joints: true, + fill: Some(Color::Index(3)), + } +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +#[test] +fn mlines_and_styles_survive_save_and_reopen() { + let mut d = Drawing::new_imperial(); + d.mline_styles.push(wall()); + d.header.set_str("CMLSTYLE", "Wall"); + d.header.set_i64("CMLJUST", 1); + d.header.set_f64("CMLSCALE", 2.5); + let pts = [Vec2::new(0.0, 0.0), Vec2::new(20.0, 0.0), Vec2::new(20.0, 10.0)]; + let mut open = MLine::new(&wall(), &pts, false, MLineJust::Zero, 2.0); + open.vertices[0].cuts.push(MLineCut { element: 0, start: 4.0, end: 6.5 }); + open.vertices[1].cuts.push(MLineCut { element: 2, start: 7.0, end: 1e12 }); + open.no_end_caps = true; + let closed = + MLine::new(&MLineStyle::default(), &[Vec2::new(0.0, 30.0), Vec2::new(10.0, 30.0), Vec2::new(5.0, 40.0)], true, MLineJust::Bottom, -1.0); + d.add(&Space::Model, Common { layer: "Walls".into(), ..Common::default() }, EntityKind::MLine(open.clone())).unwrap(); + d.add(&Space::Model, Common::default(), EntityKind::MLine(closed.clone())).unwrap(); + + let saved = cadcraft_io::write_dxf(&d); + let back = cadcraft_io::read_dxf(saved.as_bytes()).unwrap(); + let [a, b] = mlines(&back).try_into().unwrap(); + // The geometry as drawn is the same, breaks included. + assert_eq!(a.def, wall()); + assert_eq!( + (a.style.as_str(), a.justification, a.scale, a.closed, a.no_start_caps, a.no_end_caps), + ("Wall", MLineJust::Zero, 2.0, false, false, true) + ); + let (ga, go) = (a.geometry(), open.geometry()); + assert_eq!(ga.lines.len(), go.lines.len()); + for ((ea, a1, a2), (eo, o1, o2)) in ga.lines.iter().zip(&go.lines) { + assert_eq!(ea, eo); + assert!(a1.near(*o1, 1e-9) && a2.near(*o2, 1e-9), "{a1:?}-{a2:?} vs {o1:?}-{o2:?}"); + } + assert_eq!((ga.caps.len(), ga.fill.len()), (go.caps.len(), go.fill.len())); + let cut = a.vertices[0].cuts.first().unwrap(); + assert!(cut.element == 0 && near(cut.start, 4.0) && near(cut.end, 6.5), "{cut:?}"); + assert_eq!(b.def, MLineStyle::default()); + assert_eq!((b.closed, b.justification, b.scale, b.points()), (true, MLineJust::Bottom, -1.0, closed.points())); + assert_eq!(back.model.iter().next().unwrap().common.layer, "Walls"); + // The styles and current settings. + assert_eq!(back.mline_style("Wall"), Some(&wall())); + assert!(back.mline_style("STANDARD").is_some()); + assert_eq!(back.header.str("CMLSTYLE", ""), "Wall"); + assert_eq!(back.header.i64("CMLJUST", 0), 1); + assert_eq!(back.header.f64("CMLSCALE", 0.0), 2.5); + + // The file links up as the DXF Reference describes. + let tags = cadcraft_dxf::parse(saved.as_bytes()).unwrap(); + let secs = sections(&tags); + let recs = |name: &str| records(&secs.iter().find(|s| s.name == name).unwrap().tags); + let val = |t: &[Tag], c: i32| t.iter().find(|g| g.code == c).map(Tag::str).unwrap_or_default(); + let ents = recs("ENTITIES"); + let (_, ml) = ents.iter().find(|(k, _)| k == "MLINE").unwrap(); + let objs = recs("OBJECTS"); + let (_, st) = objs.iter().find(|(k, t)| k == "MLINESTYLE" && val(t, 2) == "Wall").unwrap(); + assert_eq!(val(ml, 340), val(st, 5)); + assert_eq!(val(ml, 73), "3"); + // Flags: fill, joints, start line and inner arcs, end outer arc. + assert_eq!(val(st, 70), (1 + 2 + 16 + 32 + 1024).to_string()); + assert!(objs.iter().any(|(k, t)| k == "DICTIONARY" && t.iter().any(|g| g.code == 3 && g.str() == "ACAD_MLINESTYLE"))); + + // Saving again is stable. + let again = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&back).as_bytes()).unwrap(); + assert_eq!(mlines(&again).len(), 2); + assert_eq!(mlines(&again)[0].def, wall()); +} + +/// A multiline as another program writes it: style `Duo` (offsets 0.25 and -0.25, the first red) +/// and a two-segment multiline whose first element is broken from 2 to 3 on the first segment. +fn foreign_dxf() -> String { + let g: &[(i32, &str)] = &[ + (0, "SECTION"), + (2, "ENTITIES"), + (0, "MLINE"), + (5, "40"), + (100, "AcDbEntity"), + (8, "0"), + (100, "AcDbMline"), + (2, "Duo"), + (340, "50"), + (40, "4"), + (70, "1"), + (71, "1"), + (72, "3"), + (73, "2"), + (10, "0"), + (20, "0"), + (30, "0"), + (210, "0"), + (220, "0"), + (230, "1"), + // Vertex 1: segment east, miter north. + (11, "0"), + (21, "0"), + (31, "0"), + (12, "1"), + (22, "0"), + (32, "0"), + (13, "0"), + (23, "1"), + (33, "0"), + (74, "4"), + (41, "1"), + (41, "0"), + (41, "2"), + (41, "3"), + (75, "0"), + (74, "2"), + (41, "-1"), + (41, "0"), + (75, "0"), + // Vertex 2. + (11, "10"), + (21, "0"), + (31, "0"), + (12, "0"), + (22, "1"), + (32, "0"), + (13, "-0.7071067811865475"), + (23, "0.7071067811865475"), + (33, "0"), + (74, "2"), + (41, "1.4142135623730951"), + (41, "0"), + (75, "0"), + (74, "2"), + (41, "-1.4142135623730951"), + (41, "0"), + (75, "0"), + // Vertex 3. + (11, "10"), + (21, "10"), + (31, "0"), + (12, "0"), + (22, "1"), + (32, "0"), + (13, "-1"), + (23, "0"), + (33, "0"), + (74, "2"), + (41, "1"), + (41, "0"), + (75, "0"), + (74, "2"), + (41, "-1"), + (41, "0"), + (75, "0"), + (0, "ENDSEC"), + (0, "SECTION"), + (2, "OBJECTS"), + (0, "DICTIONARY"), + (5, "C"), + (330, "0"), + (100, "AcDbDictionary"), + (3, "ACAD_MLINESTYLE"), + (350, "17"), + (0, "DICTIONARY"), + (5, "17"), + (330, "C"), + (100, "AcDbDictionary"), + (3, "Duo"), + (350, "50"), + (0, "MLINESTYLE"), + (5, "50"), + (330, "17"), + (100, "AcDbMlineStyle"), + (2, "Duo"), + (70, "0"), + (3, ""), + (62, "256"), + (51, "90"), + (52, "90"), + (71, "2"), + (49, "0.25"), + (62, "1"), + (6, "BYLAYER"), + (49, "-0.25"), + (62, "256"), + (6, "BYLAYER"), + (0, "ENDSEC"), + (0, "EOF"), + ]; + g.iter().map(|(c, v)| format!("{c}\n{v}\n")).collect() +} + +#[test] +fn foreign_mline_reads_with_its_style() { + let d = cadcraft_io::read_dxf(foreign_dxf().as_bytes()).unwrap(); + let [m] = mlines(&d).try_into().unwrap(); + assert_eq!(m.style, "Duo"); + assert_eq!(m.def.elements.iter().map(|e| (e.offset, e.color)).collect::>(), vec![(0.25, Color::Index(1)), (-0.25, Color::ByLayer)]); + assert_eq!((m.scale, m.justification), (4.0, MLineJust::Zero)); + let mut offs = m.offsets(); + offs.sort_by(f64::total_cmp); + assert_eq!(offs, vec![-1.0, 1.0]); + // The first element: broken from 2 to 3 along the first segment. + let g = m.geometry(); + let first: Vec<(Vec2, Vec2)> = g.lines.iter().filter(|(e, _, _)| *e == 0).map(|(_, a, b)| (*a, *b)).collect(); + assert_eq!(first.len(), 3, "{first:?}"); + assert!(first[0].0.near(Vec2::new(0.0, 1.0), 1e-9) && first[0].1.near(Vec2::new(2.0, 1.0), 1e-9)); + assert!(first[1].0.near(Vec2::new(3.0, 1.0), 1e-9) && first[1].1.near(Vec2::new(9.0, 1.0), 1e-9)); + assert!(d.mline_style("Duo").is_some()); +} + +#[test] +fn mline_without_its_style_keeps_its_offsets() { + // The style object is missing: the elements come from the vertex data. + let text = foreign_dxf(); + let cut = text.find("0\nSECTION\n2\nOBJECTS").unwrap(); + let text = format!("{}0\nEOF\n", &text[..cut]); + let d = cadcraft_io::read_dxf(text.as_bytes()).unwrap(); + let [m] = mlines(&d).try_into().unwrap(); + let mut offs = m.offsets(); + offs.sort_by(f64::total_cmp); + assert!(near(offs[0], -1.0) && near(offs[1], 1.0), "{offs:?}"); + // And it saves with a style of its own. + let back = cadcraft_io::read_dxf(cadcraft_io::write_dxf(&d).as_bytes()).unwrap(); + assert!(back.mline_style("Duo").is_some()); + assert_eq!(mlines(&back)[0].geometry().lines.len(), m.geometry().lines.len()); +} + +#[test] +fn hostile_mline_does_not_panic() { + let mut huge = String::from("0\nSECTION\n2\nENTITIES\n0\nMLINE\n100\nAcDbMline\n2\nX\n40\nnan\n72\n999999999\n73\n999999\n"); + for i in 0..3000 { + huge.push_str(&format!("11\n{i}\n21\n1e308\n13\nnan\n23\ninf\n74\n99999\n41\n1e308\n41\n-1e308\n41\nnan\n")); + } + for _ in 0..200 { + huge.push_str("74\n5\n41\n0\n41\n1\n41\n2\n"); + } + huge.push_str("0\nENDSEC\n0\nSECTION\n2\nOBJECTS\n0\nMLINESTYLE\n5\n9\n100\nAcDbMlineStyle\n2\nX\n70\n-1\n51\nnan\n71\n-5\n"); + for _ in 0..100 { + huge.push_str("49\nnan\n62\n99999\n6\n\n"); + } + huge.push_str("0\nENDSEC\n0\nEOF\n"); + let d = cadcraft_io::read_dxf(huge.as_bytes()).unwrap(); + for m in mlines(&d) { + assert!(m.def.elements.len() <= cadcraft_doc::MAX_MLINE_ELEMENTS); + let _ = m.geometry(); + } + let _ = cadcraft_io::write_dxf(&d); + let _ = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn mline_survives_dwg_save_and_reopen() { + let mut d = Drawing::new_imperial(); + d.mline_styles.push(wall()); + let mut m = MLine::new(&wall(), &[Vec2::new(0.0, 0.0), Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0)], false, MLineJust::Top, 1.5); + m.vertices[0].cuts.push(MLineCut { element: 1, start: 2.0, end: 3.0 }); + d.add(&Space::Model, Common::default(), EntityKind::MLine(m.clone())).unwrap(); + let dwg = cadcraft_io::write(&d, "x.dwg").unwrap(); + let back = cadcraft_io::read(&dwg, "x.dwg").unwrap(); + let [b] = mlines(&back).try_into().unwrap(); + assert_eq!((b.style.as_str(), b.def.elements.len(), b.justification, b.points()), ("Wall", 3, MLineJust::Top, m.points())); + assert_eq!(b.geometry().lines.len(), m.geometry().lines.len()); +} diff --git a/crates/io/tests/passthrough_image_roundtrip.rs b/crates/io/tests/passthrough_image_roundtrip.rs index 9b26c2b1c..461010f4f 100644 --- a/crates/io/tests/passthrough_image_roundtrip.rs +++ b/crates/io/tests/passthrough_image_roundtrip.rs @@ -7,7 +7,7 @@ use cadcraft_dxf::{Tag, records, sections}; use cadcraft_geom::Vec2; /// A small DXF as another program writes it: a line, an image on layer `Pics` pointing at an -/// IMAGEDEF named `logo`, and an MLINE (a type CADCraft doesn't model). +/// IMAGEDEF named `logo`, and an MLINE without vertices. fn foreign_dxf() -> String { let groups: &[(i32, &str)] = &[ (0, "SECTION"), diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 30d4892ac..8033491b2 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -13,6 +13,7 @@ mod fill; mod hatch; mod linetype; mod linetype_text; +mod mline; pub mod paper; mod plotstyle; mod point; @@ -667,6 +668,7 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { } } EntityKind::Hatch(h) => hatch::draw(b, ctx, e, h, rgb, lw, tol), + EntityKind::MLine(m) => mline::draw(b, ctx, e, m, rgb, lw, tol), EntityKind::LwPolyline(p) if p.const_width > 0.0 || p.vertices.iter().any(|v| v.start_width > 0.0 || v.end_width > 0.0) => { if b.opts.fill && ctx.d.header.i64("FILLMODE", 1) != 0 { match < { diff --git a/crates/render/src/mline.rs b/crates/render/src/mline.rs new file mode 100644 index 000000000..3870e39b2 --- /dev/null +++ b/crates/render/src/mline.rs @@ -0,0 +1,73 @@ +//! Multilines: each element in its style colour and linetype (ByLayer and ByBlock elements +//! follow the multiline's own), caps and joints in the multiline's properties, and the fill. + +use super::{Builder, Ctx, Ink, fill, ink, linetype, resolve_common}; +use cadcraft_color::Color; +use cadcraft_doc::{Entity, MLine}; +use cadcraft_geom::Vec2; + +pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, m: &MLine, rgb: Ink, lw: f32, tol: f64) { + let g = m.geometry(); + if let Some(fc) = m.def.fill + && b.opts.fill + && !g.fill.is_empty() + { + let c = match fc { + Color::ByLayer | Color::ByBlock => rgb, + c => ink(c, Color::Index(7), ctx.block_color), + }; + b.tris(ctx, c, &fill::triangulate_evenodd(&g.fill)); + } + let min = b.opts.min_dash / ctx.xf.scale_factor().max(1e-12); + // One look per element: colour, lineweight, linetype and its scale. + let looks: Vec<_> = m + .def + .elements + .iter() + .map(|el| { + let mut c = e.common.clone(); + if !matches!(el.color, Color::ByLayer | Color::ByBlock) { + c.color = el.color; + } + let lt = el.linetype.trim(); + if !lt.is_empty() && !lt.eq_ignore_ascii_case("bylayer") && !lt.eq_ignore_ascii_case("byblock") { + c.linetype = lt.to_string(); + } + let (rgb, w, lt, scale, _) = resolve_common(ctx, &c, b.plotting); + let lt = match b.styles.and_then(|s| s.linetype(rgb, b.named)) { + Some(over) => over, + None => lt, + }; + (rgb, if b.opts.lineweights { w } else { 0.0 }, lt, scale) + }) + .collect(); + for (i, a, z) in &g.lines { + let Some((rgb, w, lt, scale)) = looks.get(*i) else { continue }; + stroke(b, ctx, *rgb, *w, lt.as_ref(), *scale, min, &[*a, *z]); + } + let own = resolve_common(ctx, &e.common, b.plotting); + let lt = match b.styles.and_then(|s| s.linetype(rgb, b.named)) { + Some(over) => over, + None => own.2, + }; + for s in &g.caps { + let mut pts = Vec::new(); + s.tessellate(tol, &mut pts); + stroke(b, ctx, rgb, lw, lt.as_ref(), own.3, min, &pts); + } +} + +#[allow(clippy::too_many_arguments)] +fn stroke(b: &mut Builder, ctx: &Ctx, rgb: Ink, lw: f32, lt: Option<&cadcraft_doc::Linetype>, scale: f64, min: f64, pts: &[Vec2]) { + match lt { + Some(lt) => { + for dash in linetype::apply(pts, lt, scale, min) { + match dash.as_slice() { + [p] => b.point(ctx, rgb, *p), + _ => b.polyline(ctx, rgb, lw, &dash), + } + } + } + None => b.polyline(ctx, rgb, lw, pts), + } +} diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 69afb6004..de3ffaa62 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1069,3 +1069,5 @@ Stretch Vertex Розтягнути вершину Edit Array Редагувати масив Close Array Editing Завершити редагування масиву + Multiline... Мультилінія... + Multiline Style... Стиль мультилінії...