mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-22 18:22:07 +00:00
Squash of PR #517 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
429 lines
15 KiB
Rust
429 lines
15 KiB
Rust
//! 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<MLineElement>,
|
|
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<Color>,
|
|
}
|
|
|
|
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<MLineCut>,
|
|
}
|
|
|
|
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<MLineVertex>,
|
|
}
|
|
|
|
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<Segment>,
|
|
/// The fill between the outermost elements (even-odd loops); empty without fill.
|
|
pub fill: Vec<Vec<Vec2>>,
|
|
}
|
|
|
|
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<f64> {
|
|
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<Vec2> {
|
|
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<Vec2> {
|
|
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<Vec2> {
|
|
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<usize> = (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::<Vec<Vec2>>();
|
|
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<Segment> {
|
|
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<Item = (f64, f64)>) -> 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<Segment> {
|
|
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()
|
|
}
|
|
}
|