diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 65646a2..1e88289 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -460,6 +460,26 @@ pub struct MLeader { pub style: String, #[serde(default = "one")] pub arrow_size: f64, + /// Leader lines are smooth curves through their points (leader type Spline). An empty + /// `leaders` is a multileader without a leader line (leader type None). + #[serde(default)] + pub spline: bool, + /// Block content in place of the text: a block reference placed at the landing. + #[serde(default)] + pub block: Option, +} + +impl MLeader { + /// Each leader line as drawn: through its points to the landing, smoothed for spline leaders. + pub fn leader_paths(&self) -> Vec> { + self.leaders + .iter() + .map(|l| { + let pts: Vec = l.iter().map(|v| v.xy()).chain(std::iter::once(self.landing.xy())).collect(); + if self.spline && pts.len() > 2 { GSpline::from_fit_points(&pts).tessellate(1e-3) } else { pts } + }) + .collect() + } } /// A hatch boundary loop: closed polyline with bulges. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -831,6 +851,7 @@ impl EntityKind { .leaders .iter() .flat_map(|l| l.windows(2).filter_map(|w| Some(Prim::Seg(Segment::Line(GLine::new(w.first()?.xy(), w.get(1)?.xy())))))) + .chain(m.block.iter().map(|b| Prim::Point(b.insert.xy()))) .collect(), EntityKind::Image(i) => vec![Prim::Point(i.insert.xy())], EntityKind::Table(t) => vec![Prim::Point(t.insert.xy())], @@ -952,6 +973,14 @@ impl EntityKind { t3(&mut t.insert); t.height *= s; } + if let Some(b) = &mut ml.block { + // Transformed exactly like a block reference. + let mut k = EntityKind::Insert(b.clone()); + k.transform(m); + if let EntityKind::Insert(nb) = k { + *b = nb; + } + } } EntityKind::Hatch(h) => { for l in &mut h.loops { diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index 3ba03e2..d2f05fb 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -151,6 +151,10 @@ fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap { diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index a948ee9..58fd3e3 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { CommandSpec::new("mleader", "Multileader", run_mleader) .menu(&["Dimension", "Multileader"]) .alias(&["mld"]) - .params("{points: [[arrow], ..., [landing]], text}") + .params("{points: [[arrow], ..., [landing]] ([location] with leaderType none), text?, leaderType?: straight|spline|none, block?: name, blockAttach?: center|insertion}") .interactive(|s| Ok(Box::new(MLeaderM::new(s)))), CommandSpec::new("leader", "Leader", run_leader).alias(&["lead"]).params("{points: [[x,y]...], text?}"), CommandSpec::new("dimstyle.update", "Update", run_update) @@ -489,15 +489,20 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option< }), style: st.name.clone(), arrow_size: st.arrow_size * k, + spline: false, + block: None, })) } fn run_mleader(s: &mut Session, p: &Value) -> Result { let pts = points_param(p, "points").ok_or_else(|| bad("mleader", "`points` (arrow … landing) is required"))?; - if pts.len() < 2 { + let content = super::mleader_content::json_content(s, p)?; + // Leader type None places the content at the one point given. + if pts.len() < 2 && !(content.leader == crate::LeaderType::None && pts.len() == 1) { return Err(bad("mleader", "need an arrowhead and a landing point")); } let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or(""), true).ok_or_else(|| bad("mleader", "bad points"))?; + let k = super::mleader_content::apply(s, k, &content)?; Ok(json!({ "handle": s.add_entity(k)?.hex() })) } @@ -1467,6 +1472,8 @@ enum LeaderOpt { MaxPoints, FirstAngle, SecondAngle, + BlockName, + BlockAttach, } /// Upper bound for MLEADER Maxpoints. @@ -1486,6 +1493,11 @@ struct MLeaderM { angles: [f64; 2], landing: bool, content: bool, + leader_type: crate::LeaderType, + /// Block content: the block (`block_name`) instead of text. + block: bool, + block_name: String, + block_center: bool, } impl MLeaderM { @@ -1503,12 +1515,37 @@ impl MLeaderM { angles: o.angles, landing: o.landing, content: o.content, + leader_type: o.leader_type, + // Block content needs a block that still exists. + block: o.block && s.doc().is_ok_and(|d| d.block(&s.last_used.mleader_block).is_some()), + block_name: s.last_used.mleader_block.clone(), + block_center: o.block_center, } } /// Keep the order and Options for the next MLEADER. fn remember(&self, s: &mut Session) { - s.last_used.mleader = - MLeaderOptions { order: self.order, max_points: self.max_points, angles: self.angles, landing: self.landing, content: self.content }; + s.last_used.mleader = MLeaderOptions { + order: self.order, + max_points: self.max_points, + angles: self.angles, + landing: self.landing, + content: self.content, + leader_type: self.leader_type, + block: self.block, + block_center: self.block_center, + }; + s.last_used.mleader_block = self.block_name.clone(); + } + /// The leader type and block content for `mleader_content::apply`. + fn content_spec(&self) -> super::mleader_content::Content { + super::mleader_content::Content { + leader: self.leader_type, + block: (self.content && self.block).then(|| (self.block_name.clone(), self.block_center)), + } + } + /// Leader type None: the one point placed is the content location. + fn no_leader(&self) -> bool { + self.leader_type == crate::LeaderType::None } /// The leader from arrowhead to landing, with `extra` placed next. fn leader(&self, extra: Option) -> Vec { @@ -1531,11 +1568,19 @@ impl MLeaderM { } /// The text is asked now: right after the content location (Content first) or after the leader. fn wants_text(&self) -> bool { - self.content && self.text.is_none() && (self.leader_done || (self.order == LeaderOrder::Content && self.pts.len() == 1)) + self.content + && !self.block + && self.text.is_none() + && (self.leader_done || (self.order == LeaderOrder::Content && self.pts.len() == 1 && !self.no_leader())) } fn finish(&self, s: &mut Session) -> Result { + if self.no_leader() && !self.content { + s.echo("A multileader needs a leader line or content; nothing was drawn."); + return Ok(Step::Cancel); + } let text = self.text.clone().unwrap_or_default(); let k = mleader_kind(s, &self.leader(None), &text, self.landing).ok_or_else(|| EngineError::Other("bad leader".into()))?; + let k = super::mleader_content::apply(s, k, &self.content_spec())?; s.add_entity(k)?; Ok(Step::Done) } @@ -1551,6 +1596,10 @@ impl MLeaderM { LeaderOpt::MaxPoints => Prompt::new("Enter the maximum points for leader line", Accept::NUMBER), LeaderOpt::FirstAngle => Prompt::new("Enter first angle constraint", Accept::NUMBER), LeaderOpt::SecondAngle => Prompt::new("Enter second angle constraint", Accept::NUMBER), + LeaderOpt::BlockName => Prompt::new("Enter block name", kw), + LeaderOpt::BlockAttach => { + Prompt::new("Specify block attachment", kw).kw(&["Center extents", "Insertion point"]).default("Center extents") + } } } /// One answer at the Options prompts; Enter keeps the current value. @@ -1568,11 +1617,11 @@ impl MLeaderM { }, (Menu, Input::Enter) => None, (LeaderType, Input::Keyword(k)) => { - match k.as_str() { - "sPline" => s.echo("Spline leaders are not available yet; the leader stays straight."), - "None" => s.echo("Multileaders without a leader line are not available yet."), - _ => {} - } + self.leader_type = match k.as_str() { + "sPline" => crate::LeaderType::Spline, + "None" => crate::LeaderType::None, + _ => crate::LeaderType::Straight, + }; Some(Menu) } (Landing, Input::Keyword(k)) => { @@ -1580,11 +1629,34 @@ impl MLeaderM { Some(Menu) } (ContentType, Input::Keyword(k)) => { - match k.as_str() { - "Block" => s.echo("Block content is not available yet; the content stays multiline text."), - "None" => self.content = false, - _ => self.content = true, + self.content = k != "None"; + self.block = false; + // Block content asks which block, then how it attaches. + if k == "Block" { Some(BlockName) } else { Some(Menu) } + } + (BlockName, Input::Text(t)) => match super::mleader_content::block_name(s, &t) { + Ok(n) => { + self.block_name = n; + self.block = true; + Some(BlockAttach) } + Err(e) => { + s.echo(e.to_string()); + Some(BlockName) + } + }, + (BlockName, Input::Enter) => { + // Enter keeps the block chosen before, if there is one. + if super::mleader_content::block_name(s, &self.block_name).is_ok() { + self.block = true; + Some(BlockAttach) + } else { + s.echo("Requires the name of a block defined in the drawing."); + Some(BlockName) + } + } + (BlockAttach, Input::Keyword(k)) => { + self.block_center = k == "Center extents"; Some(Menu) } (MaxPoints, Input::Text(t)) => match t.trim().parse::() { @@ -1607,7 +1679,7 @@ impl MLeaderM { Some(o) } }, - (Menu | LeaderType | Landing | ContentType, Input::Text(_)) => { + (Menu | LeaderType | Landing | ContentType | BlockAttach, Input::Text(_)) => { s.echo("Invalid option keyword."); Some(o) } @@ -1628,6 +1700,10 @@ impl Interactive for MLeaderM { if self.wants_text() { return Prompt::new("Enter leader text", Accept::TEXT); } + if self.no_leader() && self.pts.is_empty() { + let what = if self.content && self.block { "Specify location of the block" } else { "Specify location of the text" }; + return Prompt::new(what, Accept::POINT).kw(&["Options"]).default("Options"); + } let last = self.pts.len() + 1 >= self.max_points; match (self.pts.last(), self.order) { (None, LeaderOrder::Arrowhead) => Prompt::new("Specify leader arrowhead location", Accept::POINT) @@ -1658,7 +1734,7 @@ impl Interactive for MLeaderM { Input::Point(p) => { let p = self.constrained(p); self.pts.push(p); - if self.pts.len() >= self.max_points { + if self.pts.len() >= self.max_points || self.no_leader() { self.leader_done = true; } } @@ -1684,7 +1760,10 @@ impl Interactive for MLeaderM { return Vec::new(); } let text = self.text.as_deref().unwrap_or(""); - mleader_kind(s, &self.leader(Some(self.constrained(c))), text, self.landing).into_iter().collect() + mleader_kind(s, &self.leader(Some(self.constrained(c))), text, self.landing) + .and_then(|k| super::mleader_content::apply(s, k, &self.content_spec()).ok()) + .into_iter() + .collect() } } diff --git a/crates/engine/src/cmd/blocks/purge.rs b/crates/engine/src/cmd/blocks/purge.rs index 504e0cd..d70fd18 100644 --- a/crates/engine/src/cmd/blocks/purge.rs +++ b/crates/engine/src/cmd/blocks/purge.rs @@ -113,6 +113,7 @@ fn used_blocks(d: &Drawing) -> HashSet { match &e.kind { EntityKind::Insert(i) => frontier.push(i.block.clone()), EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()), + EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())), _ => {} } } @@ -127,6 +128,7 @@ fn used_blocks(d: &Drawing) -> HashSet { match &e.kind { EntityKind::Insert(i) => frontier.push(i.block.clone()), EntityKind::Dimension(dm) => frontier.extend(dm.block.clone()), + EntityKind::MLeader(m) => frontier.extend(m.block.as_ref().map(|b| b.block.clone())), _ => {} } } diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index eb188bb..6d415ba 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -214,6 +214,7 @@ impl Used { EntityKind::MLeader(m) => { self.mleader_styles.insert(up(&m.style)); self.text_styles.extend(m.text.iter().map(|t| up(&t.style))); + blocks.extend(m.block.as_ref().map(|b| b.block.clone())); } EntityKind::Table(t) => { self.table_styles.insert(up(&t.style)); diff --git a/crates/engine/src/cmd/mleader_content.rs b/crates/engine/src/cmd/mleader_content.rs new file mode 100644 index 0000000..3d96c9b --- /dev/null +++ b/crates/engine/src/cmd/mleader_content.rs @@ -0,0 +1,97 @@ +//! MLEADER's leader types (straight, spline, none) and block content: applied to the multileader +//! `mleader_kind` builds, for the prompt machine and the JSON form. + +use cadcraft_doc::{Entity, EntityKind, Handle, Insert}; +use cadcraft_geom::{Vec2, Vec3}; +use serde_json::Value; + +use super::{bad, str_param}; +use crate::{EngineError, LeaderType, Result, Session}; + +/// How the multileader differs from a straight leader with text. +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) struct Content { + pub leader: LeaderType, + /// Block content: the block name, and whether its extents centre (rather than its insertion + /// point) attaches to the end of the landing. + pub block: Option<(String, bool)>, +} + +/// A block MLEADER can use as content: defined, and not an anonymous (`*`) block. +pub(super) fn block_name(s: &Session, name: &str) -> Result { + let name = name.trim(); + match s.doc()?.block(name) { + Some(b) if !b.name.starts_with('*') => Ok(b.name.clone()), + _ => Err(EngineError::Other(format!("Block \"{name}\" not found."))), + } +} + +/// Apply the leader type and block content to a multileader from `mleader_kind`. +pub(super) fn apply(s: &Session, k: EntityKind, c: &Content) -> Result { + let EntityKind::MLeader(mut m) = k else { return Ok(k) }; + m.spline = c.leader == LeaderType::Spline; + let land = m.landing.xy(); + // The side the content sits on, as `mleader_kind` chose it for the text. + let dir = if m.leaders.first().and_then(|l| l.first()).is_some_and(|a| a.x > land.x) { -1.0 } else { 1.0 }; + if c.leader == LeaderType::None { + // Content only, at the picked location (text from its top left corner). + m.leaders.clear(); + m.dogleg = 0.0; + if let Some(t) = &mut m.text { + t.insert = m.landing; + t.attach = 1; + } + } + if let Some((name, center)) = &c.block { + let anchor = land + Vec2::new(m.dogleg * dir, 0.0); + let mut ins = Insert { + block: name.clone(), + insert: Vec3::new(anchor.x, anchor.y, m.landing.z), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }; + if *center { + // Move the block so the centre of its extents lands on the anchor. + let d = s.doc()?; + let probe = Entity { handle: Handle(0), common: Default::default(), kind: EntityKind::Insert(ins.clone()) }; + let b = cadcraft_doc::entity_bounds(d, &probe, 0); + if !b.is_empty() { + let c = b.center(); + if c.is_finite() { + ins.insert = Vec3::new(ins.insert.x + anchor.x - c.x, ins.insert.y + anchor.y - c.y, ins.insert.z); + } + } + } + m.text = None; + m.block = Some(ins); + } + Ok(EntityKind::MLeader(m)) +} + +/// `leaderType` ("straight" | "spline" | "none"), `block` (a block name) and `blockAttach` +/// ("center" | "insertion") from the JSON form. +pub(super) fn json_content(s: &Session, p: &Value) -> Result { + let leader = match str_param(p, "leaderType").map(str::to_ascii_lowercase).as_deref() { + None | Some("straight") => LeaderType::Straight, + Some("spline") => LeaderType::Spline, + Some("none") => LeaderType::None, + Some(_) => return Err(bad("mleader", "`leaderType` must be \"straight\", \"spline\" or \"none\"")), + }; + let block = match str_param(p, "block") { + None => None, + Some(n) => { + let center = match str_param(p, "blockAttach").map(str::to_ascii_lowercase).as_deref() { + None | Some("center") => true, + Some("insertion") => false, + Some(_) => return Err(bad("mleader", "`blockAttach` must be \"center\" or \"insertion\"")), + }; + Some((block_name(s, n).map_err(|e| bad("mleader", e.to_string()))?, center)) + } + }; + Ok(Content { leader, block }) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3cd305d..0526fdc 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ mod hatch; mod inquiry; mod layer; mod layout; +mod mleader_content; mod modify; mod modify2; mod mtext_opts; diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index ff8fe9a..bb0cd5c 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -1428,6 +1428,7 @@ pub(crate) fn flatten_kind(k: &mut EntityKind) { EntityKind::MLeader(m) => { m.leaders.iter_mut().for_each(|l| l.iter_mut().for_each(z)); z(&mut m.landing); + m.block.iter_mut().for_each(|b| z(&mut b.insert)); } EntityKind::Hatch(h) => h.elevation = 0.0, EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z), diff --git a/crates/engine/src/cmd/props/rename.rs b/crates/engine/src/cmd/props/rename.rs index c7c2cf2..fe2e289 100644 --- a/crates/engine/src/cmd/props/rename.rs +++ b/crates/engine/src/cmd/props/rename.rs @@ -54,11 +54,15 @@ pub(super) fn block(s: &mut Session, from: &str, to: &str) -> Result<()> { let mut b = d.blocks.remove(&key).ok_or_else(|| bad("rename", "no such block"))?; Arc::make_mut(&mut b).name = to.clone(); d.blocks.insert(to.clone(), b); - let uses = |k: &EntityKind| matches!(k, EntityKind::Insert(i) if i.block.eq_ignore_ascii_case(&key)); - let fix = |k: &mut EntityKind| { - if let EntityKind::Insert(i) = k { - i.block = to.clone(); - } + let uses = |k: &EntityKind| match k { + EntityKind::Insert(i) => i.block.eq_ignore_ascii_case(&key), + EntityKind::MLeader(m) => m.block.as_ref().is_some_and(|b| b.block.eq_ignore_ascii_case(&key)), + _ => false, + }; + let fix = |k: &mut EntityKind| match k { + EntityKind::Insert(i) => i.block = to.clone(), + EntityKind::MLeader(m) => m.block.iter_mut().for_each(|b| b.block = to.clone()), + _ => {} }; repoint(d, &uses, &fix)?; if d.header.str("INSNAME", "").eq_ignore_ascii_case(&key) { diff --git a/crates/engine/src/grips.rs b/crates/engine/src/grips.rs index 43e6330..dfc7c2f 100644 --- a/crates/engine/src/grips.rs +++ b/crates/engine/src/grips.rs @@ -238,13 +238,17 @@ pub fn stretch_grip(kind: &EntityKind, index: usize, to: Vec2) -> Option set_xy(s.corners.get_mut(index)?, to), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eb38519..55be3a5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -387,11 +387,20 @@ pub struct LastUsed { pub scale_factor: f64, /// MLEADER placement order and Options. pub mleader: MLeaderOptions, + /// MLEADER's block content name (Options > Content type > Block). + pub mleader_block: String, } impl Default for LastUsed { fn default() -> Self { - LastUsed { hatch_select: false, polygon_circumscribed: false, rotate_angle: 0.0, scale_factor: 1.0, mleader: MLeaderOptions::default() } + LastUsed { + hatch_select: false, + polygon_circumscribed: false, + rotate_angle: 0.0, + scale_factor: 1.0, + mleader: MLeaderOptions::default(), + mleader_block: String::new(), + } } } @@ -416,11 +425,35 @@ pub struct MLeaderOptions { pub landing: bool, /// Ask for multiline text content (Content type None turns it off). pub content: bool, + /// Leader lines: straight, spline, or none (content only). + pub leader_type: LeaderType, + /// The content is a block (`LastUsed::mleader_block`) instead of multiline text. + pub block: bool, + /// The block's extents centre (not its insertion point) sits at the end of the landing. + pub block_center: bool, +} + +/// MLEADER's leader line type (Options > Leader type). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum LeaderType { + #[default] + Straight, + Spline, + None, } impl Default for MLeaderOptions { fn default() -> Self { - MLeaderOptions { order: LeaderOrder::Arrowhead, max_points: 2, angles: [0.0; 2], landing: true, content: true } + MLeaderOptions { + order: LeaderOrder::Arrowhead, + max_points: 2, + angles: [0.0; 2], + landing: true, + content: true, + leader_type: LeaderType::Straight, + block: false, + block_center: true, + } } } diff --git a/crates/engine/tests/annotate_options.rs b/crates/engine/tests/annotate_options.rs index 70256c6..35195f5 100644 --- a/crates/engine/tests/annotate_options.rs +++ b/crates/engine/tests/annotate_options.rs @@ -131,9 +131,10 @@ fn mleader_options_take_effect() { let r = Vec2::new(10.0, 8.0).len(); assert!(near(m.landing.xy(), Vec2::new(r, r) / 2f64.sqrt()), "{:?}", m.landing); - // Unmodelled choices say so and keep the following input. + // Leader type sPline keeps the following input and draws a spline leader. s.script("mleader o l p x 0,0 4,4 hi\n").unwrap(); - assert!(s.log.iter().any(|l| l.contains("not available yet")), "{:?}", s.log); - assert_eq!(last_mleader(&s).text.unwrap().contents, "hi"); + let m = last_mleader(&s); + assert!(m.spline); + assert_eq!(m.text.unwrap().contents, "hi"); assert!(s.running.is_none()); } diff --git a/crates/engine/tests/mleader_content.rs b/crates/engine/tests/mleader_content.rs new file mode 100644 index 0000000..6187504 --- /dev/null +++ b/crates/engine/tests/mleader_content.rs @@ -0,0 +1,123 @@ +//! MLEADER leader types (straight, spline, none) and block content, at the command line and in +//! the JSON form (#408). + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, MLeader}; +use cadcraft_engine::geom::Vec2; +use serde_json::json; + +fn last_mleader(s: &Session) -> MLeader { + match &s.doc().unwrap().model.last().unwrap().kind { + EntityKind::MLeader(m) => m.clone(), + other => panic!("not a multileader: {other:?}"), + } +} + +fn logged(s: &Session, text: &str) -> bool { + s.log.iter().any(|l| l.contains(text)) +} + +/// A session with block "TAG": a 2 x 2 square around its base point (0, 0). +fn with_block() -> Session { + let mut s = Session::new(); + let sq = s.execute("pline", &json!({"vertices": [[-1, -1], [1, -1], [1, 1], [-1, 1]], "closed": true})).unwrap()["handle"].clone(); + s.execute("block", &json!({"name": "TAG", "base": [-1, -1], "handles": [sq], "keep": "delete"})).unwrap(); + s +} + +#[test] +fn spline_leader_curves_through_its_points() { + let mut s = Session::new(); + s.script("mleader o l p m 3 x 0,0 5,5 10,0 note\n").unwrap(); + let m = last_mleader(&s); + assert!(m.spline && m.leaders[0].len() == 2); + let path = &m.leader_paths()[0]; + assert!(path.len() > 3, "tessellated: {path:?}"); + assert!(path.first().unwrap().near(Vec2::ZERO, 1e-9) && path.last().unwrap().near(Vec2::new(10.0, 0.0), 1e-9)); + // The straight leader runs along y = x up to the middle point; the curve rounds the corner above it. + assert!(path.iter().any(|p| p.x > 2.0 && p.x < 4.0 && p.y > p.x + 0.1), "not a smooth curve: {path:?}"); + // Remembered, and Straight switches back. + s.script("mleader 0,0 5,5 10,0 again\n").unwrap(); + assert!(last_mleader(&s).spline); + s.script("mleader o l s x 0,0 5,5 10,0 straight\n").unwrap(); + assert!(!last_mleader(&s).spline); +} + +#[test] +fn no_leader_places_only_the_content() { + let mut s = Session::new(); + s.script("mleader o l n x 3,4 Only text\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let m = last_mleader(&s); + assert!(m.leaders.is_empty() && m.dogleg == 0.0); + let t = m.text.unwrap(); + assert_eq!(t.contents, "Only text"); + assert!(t.insert.xy().near(Vec2::new(3.0, 4.0), 1e-9) && t.attach == 1); + // No leader and no content: nothing to draw. + s.script("mleader o c n x 1,1\n").unwrap(); + assert!(logged(&s, "nothing was drawn")); + assert_eq!(s.doc().unwrap().model.iter().count(), 1); + assert!(s.running.is_none()); +} + +#[test] +fn block_content_inserts_the_block_at_the_landing() { + let mut s = with_block(); + // Unknown names are reported and asked again; Center extents puts the block's centre at the + // end of the landing. + s.script("mleader o c b nosuch\n").unwrap(); + assert!(logged(&s, "Block \"nosuch\" not found.")); + s.script("tag c x 0,0 10,5\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let m = last_mleader(&s); + assert!(m.text.is_none()); + let b = m.block.clone().expect("block content"); + assert_eq!(b.block, "TAG"); + let end = m.landing.xy() + Vec2::new(m.dogleg, 0.0); + // Block base (-1,-1) maps to the insertion point; the square's centre is base + (1, 1). + assert!((b.insert.xy() + Vec2::new(1.0, 1.0)).near(end, 1e-9), "{:?} vs {end:?}", b.insert); + // Insertion point attachment, remembered for the next MLEADER together with the block. + s.script("mleader o c b tag i x 0,0 10,5\n").unwrap(); + let m = last_mleader(&s); + assert!(m.block.unwrap().insert.xy().near(m.landing.xy() + Vec2::new(m.dogleg, 0.0), 1e-9)); + s.script("mleader 20,0 30,5\n").unwrap(); + assert!(last_mleader(&s).block.is_some(), "block content remembered"); + // Block content without a leader line. + s.script("mleader o l n x 50,50\n").unwrap(); + let m = last_mleader(&s); + assert!(m.leaders.is_empty() && m.block.unwrap().insert.xy().near(Vec2::new(50.0, 50.0), 1e-9)); +} + +#[test] +fn block_content_keeps_the_block_alive() { + let mut s = with_block(); + let h = s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "block": "tag", "blockAttach": "insertion"})).unwrap()["handle"].clone(); + // PURGE keeps a block used only as multileader content. + s.execute("purge", &json!({"type": "blocks"})).ok(); + assert!(s.doc().unwrap().block("TAG").is_some(), "purged a block in use"); + // RENAME follows into the multileader. + s.execute("rename", &json!({"table": "block", "from": "TAG", "to": "NOTE"})).unwrap(); + assert_eq!(last_mleader(&s).block.unwrap().block, "NOTE"); + // MOVE carries the block along. + let before = last_mleader(&s).block.unwrap().insert.xy(); + s.execute("move", &json!({"handles": [h], "from": [0, 0], "to": [5, 5]})).unwrap(); + assert!(last_mleader(&s).block.unwrap().insert.xy().near(before + Vec2::new(5.0, 5.0), 1e-9)); +} + +#[test] +fn json_form_takes_leader_type_and_block() { + let mut s = with_block(); + s.execute("mleader", &json!({"points": [[0, 0], [5, 5], [10, 0]], "text": "s", "leaderType": "spline"})).unwrap(); + assert!(last_mleader(&s).spline); + s.execute("mleader", &json!({"points": [[7, 7]], "text": "alone", "leaderType": "none"})).unwrap(); + let m = last_mleader(&s); + assert!(m.leaders.is_empty() && m.text.unwrap().insert.xy().near(Vec2::new(7.0, 7.0), 1e-9)); + for bad in [ + json!({"points": [[0, 0], [5, 5]], "leaderType": "wavy"}), + json!({"points": [[0, 0], [5, 5]], "block": "missing"}), + json!({"points": [[0, 0], [5, 5]], "block": "tag", "blockAttach": "corner"}), + json!({"points": [[0, 0]]}), + ] { + assert!(s.execute("mleader", &bad).is_err(), "{bad}"); + } +} diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index c3c8c94..4095b6a 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -745,7 +745,7 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx let le = Entity { handle: Handle(u64::from_str_radix(&w.h(), 16).unwrap_or(0)), common: e.common.clone(), - kind: EntityKind::Leader(cadcraft_doc::Leader { vertices, arrow: true, spline: false, style: m.style.clone() }), + kind: EntityKind::Leader(cadcraft_doc::Leader { vertices, arrow: true, spline: m.spline, style: m.style.clone() }), }; entity(w, d, &le, owner, paper, cx); } @@ -757,6 +757,15 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx }; entity(w, d, &te, owner, paper, cx); } + // Block content: a plain INSERT of the block. + if let Some(ins) = &m.block { + let ie = Entity { + handle: Handle(u64::from_str_radix(&w.h(), 16).unwrap_or(0)), + common: e.common.clone(), + kind: EntityKind::Insert(ins.clone()), + }; + entity(w, d, &ie, owner, paper, cx); + } } EntityKind::Leader(l) => { w.s(0, "LEADER"); diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 98a18b1..8357a2a 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -1359,6 +1359,8 @@ fn mleader_is_written_as_leader_and_mtext() { text: Some(text), style: "Standard".into(), arrow_size: 2.5, + spline: false, + block: None, }; d.add(&Space::Model, Default::default(), EntityKind::MLeader(m)).unwrap(); let back = roundtrip(&d); diff --git a/crates/io/tests/mleader_content.rs b/crates/io/tests/mleader_content.rs new file mode 100644 index 0000000..302ebc5 --- /dev/null +++ b/crates/io/tests/mleader_content.rs @@ -0,0 +1,38 @@ +//! A multileader's spline leader and block content are written as LEADER (path type spline, +//! group 72) and INSERT (#408); MULTILEADER objects themselves are not written. + +use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Line, MLeader, Space}; +use cadcraft_geom::Vec3; + +#[test] +fn spline_leader_and_block_content_are_written() { + let mut d = Drawing::new_metric(); + let mut blk = Block::new("TAG"); + blk.entities.push(Entity::new(Handle(0x100), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(1.0, 1.0, 0.0) }))); + d.blocks.insert(blk.name.clone(), std::sync::Arc::new(blk)); + let m = MLeader { + leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0), Vec3::new(5.0, 5.0, 0.0)]], + landing: Vec3::new(10.0, 0.0, 0.0), + dogleg: 1.0, + text: None, + style: "Standard".into(), + arrow_size: 1.0, + spline: true, + block: Some(Insert { + block: "TAG".into(), + insert: Vec3::new(11.0, 0.0, 0.0), + scale: Vec3::new(1.0, 1.0, 1.0), + rotation: 0.0, + attribs: Vec::new(), + cols: 1, + rows: 1, + col_spacing: 0.0, + row_spacing: 0.0, + }), + }; + d.add(&Space::Model, Common::default(), EntityKind::MLeader(m)).unwrap(); + let bytes = cadcraft_io::write(&d, "a.dxf").unwrap(); + let back = cadcraft_io::read(&bytes, "a.dxf").unwrap(); + assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Leader(l) if l.spline && l.vertices.len() == 3))); + assert!(back.model.iter().any(|e| matches!(&e.kind, EntityKind::Insert(i) if i.block == "TAG" && i.insert == Vec3::new(11.0, 0.0, 0.0)))); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 30c8cc5..eb9bc52 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -674,8 +674,8 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { } } EntityKind::MLeader(m) => { - for l in &m.leaders { - let pts: Vec = l.iter().map(|v| v.xy()).chain(std::iter::once(m.landing.xy())).collect(); + // Straight or spline leader lines; block content draws like a block reference. + for pts in m.leader_paths() { b.polyline(ctx, rgb, lw, &pts); if let (Some(a), Some(n)) = (pts.first(), pts.get(1)) { b.tris(ctx, rgb, &dim::arrow(*a, (*a - *n).normalized(), m.arrow_size)); @@ -687,6 +687,14 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]); mtext(b, ctx, t, rgb, lw); } + if let Some(ins) = &m.block { + if m.dogleg > 0.0 && !m.leaders.is_empty() { + let land = m.landing.xy(); + let dir = if ins.insert.x >= land.x { 1.0 } else { -1.0 }; + b.polyline(ctx, rgb, lw, &[land, land + Vec2::new(m.dogleg * dir, 0.0)]); + } + insert(b, ctx, e, ins, rgb); + } } EntityKind::Point(p) => { // Relative sizes follow the view: undo the block / viewport scale. diff --git a/docs/gaps.md b/docs/gaps.md index 8e6e133..289ab41 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (object snap tracking, Extension, Parallel and Apparent Intersection landed, #379: top-10 row 3 and U1 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (SPLINE, MTEXT and MLEADER prompt options landed, #408: top-10 row 5, F1 and F10 narrowed) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -17,7 +17,7 @@ do them first. | 2 | **[alpha blocker]** **No real-file test corpus and no check in AutoCAD** | All 36 file tests are synthetic; nobody has verified a CADCraft DWG/DXF opens cleanly in AutoCAD | 10–15 + human | [file formats](file-format-parity.md#tests) | | 3 | **Tracking leftovers** (object snap tracking, Extension, Parallel and Apparent Intersection landed, #379): polar angles relative to the last segment, PolarSnap, temporary tracking point | Small now; ranks lower at the next review | 3–5 | [ui](ui-parity.md#tracking-polar-ortho-grid-and-snap--70) | | 4 | **[alpha blocker]** **DWG always saves as R2000; DXF only R2000 ASCII; no code pages** | Can't exchange at 2018 format; pre-2007 CJK/Cyrillic files are garbled | 10–15 | [file formats](file-format-parity.md) | -| 5 | **[alpha blocker]** **Stubbed prompt options** in SPLINE, MTEXT, MLEADER | Scripts and muscle memory hit "not available yet" in the most-used commands | 15–25 | [features](#features) | +| 5 | **[alpha blocker]** **Stubbed prompt options** left in PEDIT Edit vertex, SPLINEDIT and MTEXT columns | Scripts and muscle memory hit "not available yet" (the most-used commands are done) | 3–6 | [features](#features) | | 6 | **Ellipse and spline geometry is tessellated** (intersections, offsets, closest points; polyline offset ignores bulges) | Coordinates are subtly wrong; offsets of ellipses come out as polylines | 30–50 | [geometry](geometry-parity.md) | | 7 | **[alpha blocker]** **No xrefs, block editor, dynamic blocks or groups** | Most production drawings use them | 80–120 | [features](#features) | | 8 | **No autosave, crash recovery, AUDIT or RECOVER** | Work is lost if anything goes wrong; damaged files can't be repaired | 10–15 | [features](#features) | @@ -28,7 +28,7 @@ do them first. | # | Gap | Evidence | User impact | Est. | |---|---|---|---|---| -| F1 | **[alpha blocker]** Prompt options print "not available yet": SPLINE Method, Knots, Object, Tangency, toLerance; MTEXT Height/Justify/Line spacing/Rotation/Style/Width/Columns; MLEADER options (TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `cmd/draw.rs` (`SplineM`), `modify2.rs:594,783,806` | High: the commands people use most | 15–25 | +| F1 | **[alpha blocker]** Prompt options print "not available yet": PEDIT Edit vertex Break/Regen/Tangent, some SPLINEDIT options; MTEXT Dynamic/Static columns (no column model). SPLINE, MTEXT and MLEADER options done (#408): closed splines keep but ignore a fit tolerance, SPLINE Object fits the polyline's vertices (no spline frame is kept) (TRIM/EXTEND, PLINE arc mode and Length, XLINE done (#397); OFFSET, ROTATE and FILLET options done earlier) | `modify2.rs:594,783,806`, `cmd/mtext_opts.rs` | Medium: the most-used commands are done | 3–6 | | F2 | **[alpha blocker]** No xrefs (XATTACH, XREF palette, REFEDIT, BIND, XCLIP, overlay, paths); DXF xref blocks read with an empty path | `dxf_read.rs:923` | High: multi-file projects | 30–45 | | F3 | **[alpha blocker]** (block editor and REFEDIT; dynamic-block authoring is beta) No block editor (BEDIT) or dynamic blocks (parameters, actions, visibility, lookup); dynamic blocks from other files lose their behaviour | issue #185 offers help | High: standard libraries are dynamic | 50–75 | | F4 | No autosave (SAVETIME), crash recovery files or drawing recovery manager (AUDIT and RECOVER landed with #406; AUDITCTL `.adt` logs and a RECOVER mode in the file readers are not there) | no SAVETIME anywhere | High: lost work | 6–10 | @@ -37,7 +37,7 @@ do them first. | F7 | Dimensions: no DIMSTYLE manager dialog; 43 of ~80 DIMSTYLE variables (DIMTOFL, DIMATFIT, DIMTMOVE, DIMLTYPE, DIMLWD, DIMFXL, DIMTOLJ, DIMALTU …); no smart DIM; no DIMJOGGED, DIMJOGLINE, DIMBREAK, oblique DIMEDIT, inspection; QDIM continuous only (SETVAR DIM* now become style overrides, #129) | `doc/src/tables.rs:223-266`, `annotate.rs:64,364` | High for detailers | 25–35 | | F8 | No TOLERANCE (GD&T feature control frames) | menu "Dimension > Tolerance..." uncovered | Medium (mechanical) | 4–6 | | F9 | MTEXT editor is a dialog box, not in place; no columns, bullets, numbering, tabs, indents; no fields (FIELD, UPDATEFIELD); no spell check (SPELL) | `ui-egui/dialogs.rs:188-240` | Medium-high | 30–45 | -| F10 | MLEADER: options ignored, no block content, no MLEADEREDIT/ALIGN/COLLECT; MLEADERSTYLE has 5 fields | `annotate.rs:1195-1230`, `tables.rs:417` | Medium | 10–15 | +| F10 | MLEADER: no MLEADEREDIT/ALIGN/COLLECT; MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 | | F11 | Tables: no cell styles, formulas, data links, table breaking | `cmd/table.rs` | Medium | 10–15 | | F12 | Annotative scaling: the flag is stored, nothing scales; no scale list, no "Annotative Object Scale" commands | 4 uncovered menu items | Medium-high in layouts | 10–15 | | F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); gradients render as a flat colour; associativity not updated when the boundary moves; origin always 0,0; island style always normal; no gap tolerance, separate hatches, hatch dialog, MPolygon | `doc/src/library.rs`, `render/lib.rs:430-437`, `cmd/hatch.rs:198,201` | Medium | 15–25 | @@ -158,6 +158,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | SPLINE Knots/Tangency/toLerance/Object, MTEXT Height/Justify/Line spacing/Rotation/Style/Width/Columns, MLEADER spline and no-leader types and block content landed (#408): top-10 row 5, F1 and F10 narrowed | | 2026-10-11 | minor | Object snap tracking, Extension, Parallel and true Apparent Intersection landed (#379): top-10 row 3 and U1 narrowed to the leftovers | | 2026-10-10 | minor | Merged main: closed or narrowed rows for the Ukrainian catalog, UNITS dialog, shortcut display, dynamic input pointer boxes, press-drag windows, SETVAR DIM*, WIPEOUT and gradients, fmt | | 2026-10-10 | minor | Alpha blockers marked (rows failing the core-workflow gate) |