mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-31 06:16:51 -06:00
Squash of PR #517 by @vangel76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
997aef85d5
commit
289855aa67
@@ -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) => {
|
||||
|
||||
@@ -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<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()
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,8 @@ pub struct Drawing {
|
||||
pub dim_styles: Vec<DimStyle>,
|
||||
pub mleader_styles: Vec<MLeaderStyle>,
|
||||
pub table_styles: Vec<TableStyle>,
|
||||
/// Multiline styles (MLSTYLE); STANDARD is always there.
|
||||
pub mline_styles: Vec<MLineStyle>,
|
||||
pub blocks: BTreeMap<String, std::sync::Arc<Block>>,
|
||||
pub model: EntityStore,
|
||||
pub layouts: Vec<Layout>,
|
||||
@@ -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)],
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
.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<CommandSpec> {
|
||||
.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<Value> {
|
||||
@@ -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<EntityKind> {
|
||||
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<Just> {
|
||||
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<Value> {
|
||||
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<Vec2>,
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<EntityKind> {
|
||||
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)
|
||||
// =====================================================================================
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<String>) -> EngineError {
|
||||
EngineError::Other(m.into())
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// MLINE
|
||||
// =====================================================================================
|
||||
|
||||
fn just_of(s: &str) -> Option<MLineJust> {
|
||||
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<MLineStyle> {
|
||||
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<EntityKind> {
|
||||
(pts.len() >= 2).then(|| EntityKind::MLine(MLine::new(def, pts, closed, just, scale)))
|
||||
}
|
||||
|
||||
fn run_mline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Vec2>,
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<Step> {
|
||||
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<String> = 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<EntityKind> {
|
||||
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<Entity>| 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<MLineCaps> {
|
||||
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<Color> {
|
||||
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<Option<Vec<MLineElement>>> {
|
||||
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<Value> {
|
||||
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<MLine> {
|
||||
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<Line> {
|
||||
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<usize> {
|
||||
(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<Line> {
|
||||
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<f64> {
|
||||
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<MLine> {
|
||||
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<MLineVertex> = 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<Tool> {
|
||||
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<Value> {
|
||||
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<usize> = (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<MLineCut>, Vec<MLineCut>) = 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<Tool>,
|
||||
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<Step> {
|
||||
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<Handle> {
|
||||
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<Entity> {
|
||||
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
|
||||
}
|
||||
@@ -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<f64> =
|
||||
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<f64> =
|
||||
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]
|
||||
|
||||
@@ -333,7 +333,7 @@ fn run_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// 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<String, usize> {
|
||||
let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
|
||||
|
||||
@@ -1191,6 +1191,7 @@ pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
EntityKind::MLine(m) => Some(super::draw2::mline::explode(e, m)),
|
||||
EntityKind::MText(m) => Some(
|
||||
cadcraft_fonts::plain_mtext(&m.contents)
|
||||
.split('\n')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -224,6 +224,7 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option<EntityK
|
||||
d.block = None;
|
||||
}
|
||||
EntityKind::Leader(l) => 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;
|
||||
|
||||
@@ -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::<Vec<_>>()})),
|
||||
("mlstyle", json!({"name": "", "fill": 99999})),
|
||||
("mlstyle", json!({"name": "a<b"})),
|
||||
("mledit", json!({"tool": "cc", "first": a.hex(), "p1": [0, 0], "second": b.hex(), "p2": [0, 0]})),
|
||||
("mledit", json!({"tool": "cj", "first": a.hex(), "p1": [0, 0], "second": b.hex(), "p2": [1e308, 0]})),
|
||||
("mledit", json!({"tool": "ct", "first": a.hex(), "p1": [0, 0], "second": a.hex(), "p2": [0, 0]})),
|
||||
("mledit", json!({"tool": "cs", "handle": a.hex(), "p1": [1e308, 0], "p2": [-1e308, 0]})),
|
||||
("mledit", json!({"tool": "av", "handle": "zz", "point": [0, 0]})),
|
||||
("mledit", json!({"tool": "nope"})),
|
||||
];
|
||||
for (id, p) in bad {
|
||||
let _ = s.execute(id, &p);
|
||||
}
|
||||
for t in ["mledit", "cc", "0,0", "0,0", "", "mline", "s", "-1e308", "0,0", "1,1", ""] {
|
||||
let _ = s.cmdline(t);
|
||||
}
|
||||
// Everything left draws.
|
||||
let d = s.doc().unwrap();
|
||||
let _ = cadcraft_render::build(d, &Space::Model, &cadcraft_render::Options::default());
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Multilines in DXF (DXF Reference: MLINE entity, MLINESTYLE object). An MLINE holds its
|
||||
//! vertices, each with the direction of the segment that starts there, the miter direction and,
|
||||
//! per style element, the element's distance from the vertex along the miter followed by where
|
||||
//! the element runs and breaks on that segment. It points (group 340) to its MLINESTYLE object,
|
||||
//! which the `ACAD_MLINESTYLE` dictionary names and which holds the elements' offsets, colours
|
||||
//! and linetypes, the caps and the fill.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{
|
||||
Drawing, Entity, EntityKind, EntityStore, MAX_MLINE_CUTS, MAX_MLINE_ELEMENTS, MAX_MLINE_VERTICES, MLine, MLineCaps, MLineCut, MLineElement,
|
||||
MLineJust, MLineStyle, MLineVertex, STANDARD_MLINE_STYLE,
|
||||
};
|
||||
use cadcraft_dxf::{Section, Tag};
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
|
||||
/// Most groups 41 kept per element and vertex (start, then break pairs).
|
||||
const MAX_PARAMS: usize = 2 * MAX_MLINE_CUTS + 2;
|
||||
/// Most multiline styles read from a file.
|
||||
const MAX_STYLES: usize = 10_000;
|
||||
|
||||
/// MLINESTYLE flags (group 70).
|
||||
const FILL: i64 = 1;
|
||||
const JOINTS: i64 = 2;
|
||||
const START_LINE: i64 = 16;
|
||||
const START_INNER: i64 = 32;
|
||||
const START_OUTER: i64 = 64;
|
||||
const END_LINE: i64 = 256;
|
||||
const END_INNER: i64 = 512;
|
||||
const END_OUTER: i64 = 1024;
|
||||
|
||||
fn key(s: &str) -> 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<Vec<f64>>,
|
||||
}
|
||||
|
||||
/// 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<RawVertex> = 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<Vec2> = 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<MLineElement> = (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<f64> {
|
||||
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<Tag> {
|
||||
let mut t = Vec::new();
|
||||
let p = |t: &mut Vec<Tag>, 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::<String>()));
|
||||
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<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
|
||||
}
|
||||
|
||||
/// 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<MLineStyle> {
|
||||
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<MLineStyle> = 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<String, MLineStyle> = 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<String, MLineStyle>) {
|
||||
let hs: Vec<cadcraft_doc::Handle> = 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<Item = &'a Entity>, mut new_handle: impl FnMut() -> String) -> Plan {
|
||||
let mut list: Vec<MLineStyle> = 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<Tag> {
|
||||
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
|
||||
}
|
||||
@@ -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<Drawing> {
|
||||
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)) {
|
||||
|
||||
@@ -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<Handle, usize>,
|
||||
@@ -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<Tag> {
|
||||
}
|
||||
// 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<Tag> {
|
||||
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<Tag> {
|
||||
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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<MLine> {
|
||||
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<_>>(), 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());
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 < {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -1069,3 +1069,5 @@
|
||||
Stretch Vertex Розтягнути вершину
|
||||
Edit Array Редагувати масив
|
||||
Close Array Editing Завершити редагування масиву
|
||||
Multiline... Мультилінія...
|
||||
Multiline Style... Стиль мультилінії...
|
||||
|
||||
|
Reference in New Issue
Block a user