MLINE: multiline entity, MLSTYLE and MLEDIT (part of #510) (#517)

Squash of PR #517 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 11:48:27 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 997aef85d5
commit 289855aa67
20 changed files with 2430 additions and 200 deletions
+14
View File
@@ -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) => {
+428
View File
@@ -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()
}
}
+3
View File
@@ -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)],
+6 -188
View File
@@ -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)
// =====================================================================================
+836
View File
@@ -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, &lt), 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
}
+13 -10
View File
@@ -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]
+1 -1
View File
@@ -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();
+1
View File
@@ -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')
+1
View File
@@ -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);
+1
View File
@@ -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;
+286
View File
@@ -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());
}
+445
View File
@@ -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
}
+2
View File
@@ -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)) {
+15
View File
@@ -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());
+3
View File
@@ -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;
+297
View File
@@ -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"),
+2
View File
@@ -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 &lt {
+73
View File
@@ -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),
}
}
+2
View File
@@ -1069,3 +1069,5 @@
Stretch Vertex Розтягнути вершину
Edit Array Редагувати масив
Close Array Editing Завершити редагування масиву
Multiline... Мультилінія...
Multiline Style... Стиль мультилінії...
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
1069
1070
1071
1072
1073