//! Modify commands: ERASE, MOVE, COPY, ROTATE, SCALE, MIRROR, OFFSET, TRIM, EXTEND, FILLET, //! CHAMFER, EXPLODE, STRETCH, BREAK, JOIN (arrays: `array`; draw order: `draworder`). use cadcraft_doc::{Entity, EntityKind, Handle, Prim}; use cadcraft_geom::{ Arc, Circle, Line, Mat3, PolyVertex, Polyline, Segment, TAU, Vec2, angle_in_sweep, ccw_sweep, intersect_ext, line_line_infinite, norm_angle, }; use serde_json::{Value, json}; use super::helpers::*; use super::machines::{SelOutcome, SelectPhase, number}; use super::trimextend::{self, TrimM}; use super::*; use crate::select::{SelectKind, SelectMode}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; pub fn specs() -> Vec { vec![ CommandSpec::new("erase", "Erase", run_erase) .menu(&["Modify", "Erase"]) .alias(&["e", "delete"]) .params("{handles?: [hex]} (default: selection)") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Erase)))), CommandSpec::new("move", "Move", run_move) .menu(&["Modify", "Move"]) .alias(&["m"]) .params("{handles?, from?: [x,y], to?: [x,y] | delta: [dx,dy]}") .interactive(|_| Ok(Box::new(DisplaceM::new(Op::Move)))), CommandSpec::new("copy", "Copy", run_copy) .menu(&["Modify", "Copy"]) .alias(&["co", "cp"]) .params("{handles?, delta: [dx,dy] | from,to, count?: n}") .interactive(|_| Ok(Box::new(DisplaceM::new(Op::Copy)))), CommandSpec::new("rotate", "Rotate", run_rotate) .menu(&["Modify", "Rotate"]) .alias(&["ro"]) .params("{handles?, base: [x,y], angle (degrees), copy?: bool}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Rotate)))), CommandSpec::new("scale", "Scale", run_scale) .menu(&["Modify", "Scale"]) .alias(&["sc"]) .params("{handles?, base: [x,y], factor, copy?: bool}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Scale)))), CommandSpec::new("mirror", "Mirror", run_mirror) .menu(&["Modify", "Mirror"]) .alias(&["mi"]) .params("{handles?, p1, p2, erase?: bool}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Mirror)))), CommandSpec::new("stretch", "Stretch", run_stretch) .menu(&["Modify", "Stretch"]) .alias(&["s"]) .params("{window: [[x,y],[x,y]] | polygon: [[x,y],...] | fence: [[x,y],...], delta: [dx,dy]}") .interactive(|_| Ok(Box::new(StretchM::default()))), CommandSpec::new("offset", "Offset", run_offset) .menu(&["Modify", "Offset"]) .alias(&["o"]) .params("{handle, distance, side: [x,y], erase?: bool, layer?: \"source\"|\"current\"}") .interactive(|s| Ok(Box::new(OffsetM::new(s)))), CommandSpec::new("trim", "Trim", trimextend::run_trim) .menu(&["Modify", "Trim"]) .alias(&["tr"]) .params( "{handle, pick: [x,y] | fence: [[x,y],...] | crossing: [[x,y],[x,y]], edges?: [hex], edgeMode?: \"extend\"|\"none\", mode?: \"quick\"|\"standard\"}", ) .interactive(|_| Ok(Box::new(TrimM::new(false)))), CommandSpec::new("extend", "Extend", trimextend::run_extend) .menu(&["Modify", "Extend"]) .alias(&["ex"]) .params("{handle, pick: [x,y] | fence: [[x,y],...] | crossing: [[x,y],[x,y]], edges?: [hex], edgeMode?: \"extend\"|\"none\"}") .interactive(|_| Ok(Box::new(TrimM::new(true)))), CommandSpec::new("fillet", "Fillet", run_fillet) .menu(&["Modify", "Fillet"]) .alias(&["f"]) .params("{h1, p1, h2, p2, radius?, trim?: bool} (lines, arcs, circles) | {handle, polyline: true, radius?}") .interactive(|s| Ok(Box::new(FilletM::new(s, false)))), CommandSpec::new("chamfer", "Chamfer", run_chamfer) .menu(&["Modify", "Chamfer"]) .alias(&["cha"]) .params("{h1, p1, h2, p2, d1?, d2? | length, angle (degrees), trim?: bool} | {handle, polyline: true, d1?, d2? | length, angle}") .interactive(|s| Ok(Box::new(FilletM::new(s, true)))), CommandSpec::new("explode", "Explode", run_explode) .menu(&["Modify", "Explode"]) .alias(&["x"]) .params("{handles?}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Explode)))), CommandSpec::new("break", "Break", run_break) .menu(&["Modify", "Break"]) .alias(&["br"]) .params("{handle, p1, p2}") .interactive(|_| Ok(Box::new(BreakM::default()))), CommandSpec::new("breakatpoint", "Break At Point", run_breakat).menu(&["Modify", "Break At Point"]).params("{handle, at}"), CommandSpec::new("join", "Join", run_join) .menu(&["Modify", "Join"]) .alias(&["j"]) .params("{handles?, close?: bool (a single arc becomes its circle, an elliptical arc its full ellipse)}") .interactive(|_| Ok(Box::new(SelectThen::new(Op::Join)))), ] } // ---------------- core operations ---------------- pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result> { let d = s.doc_mut()?; let mut out = Vec::new(); for h in hs { let Some(sp) = d.space_of(*h) else { continue }; if copy { let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue }; let nh = d.new_handle(); let mut ne = Entity { handle: nh, ..e }; ne.kind.transform(m); if let Some(st) = d.space_mut(&sp) { st.push(ne); out.push(nh); } } else { let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); if locked { continue; } d.modify_entity(*h, |e| e.kind.transform(m))?; out.push(*h); } } Ok(out) } fn erase(s: &mut Session, hs: &[Handle]) -> Result { let d = s.doc_mut()?; let mut n = 0; for h in hs { let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked); if !locked && d.remove_entity(*h).is_some() { n += 1; } } s.set_selection(Vec::new()); Ok(n) } fn run_erase(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let n = erase(s, &hs)?; Ok(json!({ "erased": n })) } fn delta_of(cmd: &str, p: &Value) -> Result { if let Some(d) = point_param(p, "delta") { return Ok(d); } let a = point_req(cmd, p, "from")?; let b = point_req(cmd, p, "to")?; Ok(b - a) } fn run_move(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let d = delta_of("move", p)?; let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?; Ok(json!({ "moved": moved.len() })) } fn run_copy(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let d = delta_of("copy", p)?; let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 10_000); let mut out = Vec::new(); for k in 1..=n { out.extend(transform_entities(s, &hs, &Mat3::translate(d * k as f64), true)?.into_iter().map(|h| h.hex())); } Ok(json!({ "handles": out })) } fn run_rotate(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let base = point_req("rotate", p, "base")?; let a = f64_req("rotate", p, "angle")?.to_radians(); let r = transform_entities(s, &hs, &Mat3::rotate_about(base, a), bool_or(p, "copy", false))?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } fn run_scale(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let base = point_req("scale", p, "base")?; let f = f64_req("scale", p, "factor")?; if f <= 0.0 { return Err(bad("scale", "factor must be positive")); } let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } fn run_mirror(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let a = point_req("mirror", p, "p1")?; let b = point_req("mirror", p, "p2")?; if a.near(b, 1e-12) { return Err(bad("mirror", "mirror line points coincide")); } let erase_src = bool_or(p, "erase", false); let r = transform_entities(s, &hs, &Mat3::mirror(a, b), !erase_src)?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } /// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first /// point. #[derive(Clone)] enum StretchArea { Box(cadcraft_geom::Bounds2), Polygon(Vec), } impl StretchArea { fn contains(&self, p: Vec2) -> bool { match self { StretchArea::Box(bx) => bx.contains(p), StretchArea::Polygon(poly) => cadcraft_geom::point_in_polygon(poly, p), } } } /// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing /// selection tests the drawn element lines, which sit away from the control vertices by the /// multiline's scale, so a small area around a vertex alone would miss them. fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec) { let Some(store) = d.space(space) else { return }; for e in store.iter() { if let EntityKind::MLine(m) = &e.kind && d.is_visible(e) && !hs.contains(&e.handle) && m.vertices.iter().any(|v| area.contains(v.p.xy())) { hs.push(e.handle); } } } /// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH). fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) { let inside = |p: Vec2| wins.iter().any(|a| a.contains(p)); let mv = |p: &mut cadcraft_geom::Vec3| { if inside(p.xy()) { p.x += d.x; p.y += d.y; } }; match &mut e.kind { EntityKind::Line(l) => { mv(&mut l.a); mv(&mut l.b); } EntityKind::LwPolyline(pl) => { for v in &mut pl.vertices { if inside(v.p) { v.p += d; } } } EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| mv(&mut v.p)), EntityKind::Arc(a) => { // Arcs keep their shape: move when the centre is inside. if inside(a.center.xy()) { a.center.x += d.x; a.center.y += d.y; } } EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(mv), EntityKind::Dimension(dm) => { for p in [&mut dm.p13, &mut dm.p14, &mut dm.defpt, &mut dm.text_mid] { mv(p); } dm.block = None; } k => { let g = k.grips(); if g.first().is_some_and(|p| inside(*p)) { k.transform(&Mat3::translate(d)); } } } } fn run_stretch(s: &mut Session, p: &Value) -> Result { let d = delta_of("stretch", p)?; let space = s.space(); let listed = |key: &str, least: usize| -> Result>> { let Some(pts) = points_param(p, key) else { return Ok(None) }; if pts.len() < least || pts.len() > crate::select::MAX_MODE_POINTS || pts.iter().any(|q| !q.is_finite()) { return Err(bad("stretch", format!("`{key}` needs {least} to {} finite points", crate::select::MAX_MODE_POINTS))); } Ok(Some(pts)) }; // A fence marks no area: the objects it crosses move whole. if let Some(fence) = listed("fence", 2)? { let hs = crate::select::select_fence(s.doc()?, &space, &fence); let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?; return Ok(json!({ "stretched": moved.len() })); } let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? { (crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly)) } else { let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?; let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) }; let bx = cadcraft_geom::Bounds2::new(*a, *b); (crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx)) }; add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs); hs.retain(|h| !super::curves::is_locked(s, *h)); let doc = s.doc_mut()?; let areas = [area]; for h in &hs { doc.modify_entity(*h, |e| stretch_entity(e, &areas, d))?; } Ok(json!({ "stretched": hs.len() })) } #[derive(Clone, Copy, Default, PartialEq)] enum StretchPhase { #[default] Select, Base, Displacement, Second, } /// STRETCH: "Select objects" repeats until Enter. Crossing windows (implied right to left, or after /// `C`), windows (left to right, or after `W`) and polygons (`CP`, `WP`) select objects and mark /// the area whose vertices move; objects picked one at a time (or by a fence, `F`, or /// All/Last/Previous) move whole. Then the base point or Displacement, and the second point. #[derive(Default)] struct StretchM { phase: StretchPhase, objs: Vec, /// Objects selected without a window: they move whole. whole: Vec, /// The selection windows and polygons: vertices inside any of them move. windows: Vec, removing: bool, /// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection. mode: Option, /// First corner of the window being drawn. corner: Option, base: Option, } impl StretchM { fn add(&mut self, s: &mut Session, hs: Vec, whole: bool) { let n = hs.len(); for h in hs { if self.removing { self.objs.retain(|o| *o != h); self.whole.retain(|o| *o != h); } else { if !self.objs.contains(&h) { self.objs.push(h); } if whole && !self.whole.contains(&h) { self.whole.push(h); } } } if n > 0 { s.echo(format!("{n} found, {} total", self.objs.len())); } else { s.echo("0 found"); } s.set_selection(self.objs.clone()); } /// Select with a typed mode that has its points. With too few (a fence of one point, a polygon /// of two) the mode is dropped. fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> { let space = s.space(); let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) }; let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) { (SelectKind::Crossing | SelectKind::CPolygon, _) => true, (SelectKind::Box, Some((a, b))) => b.x < a.x, _ => false, }; let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) { (SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)), (SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => { Some(StretchArea::Box(cadcraft_geom::Bounds2::new(*a, *b))) } // A fence marks no area: what it crosses moves whole. _ => None, }; let whole = area.is_none(); if let (Some(a), true, false) = (&area, crossing, self.removing) { add_mline_vertex_hits(s.doc()?, &space, a, &mut hs); } if let (Some(a), false) = (area, self.removing) { self.windows.push(a); } self.add(s, hs, whole); Ok(()) } fn select(&mut self, s: &mut Session, i: Input) -> Result { if let Some(mut m) = self.mode.take() { match &i { Input::Point(p) => { if m.push(*p) { self.finish_mode(s, m)?; } else { self.mode = Some(m); } return Ok(Step::Continue); } Input::Text(t) | Input::Keyword(t) if matches!(t.trim().to_ascii_lowercase().as_str(), "u" | "undo") => { m.pts.pop(); self.mode = Some(m); return Ok(Step::Continue); } // Enter ends a fence or closes a polygon. Input::Enter => { self.finish_mode(s, m)?; return Ok(Step::Continue); } // Anything else leaves the mode and is read as usual. _ => {} } } match i { Input::Point(p) => match self.corner.take() { Some(c) => { let bx = cadcraft_geom::Bounds2::new(c, p); let space = s.space(); let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x); if !self.removing { if p.x < c.x { add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs); } self.windows.push(StretchArea::Box(bx)); } self.add(s, hs, false); } None => { let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; let space = s.space(); match crate::select::pick(s.doc()?, &space, p, aperture) { Some(h) => self.add(s, vec![h], true), None => self.corner = Some(p), } } }, Input::Pick(hs) => self.add(s, hs, true), Input::Text(t) | Input::Keyword(t) => { self.corner = None; let d = s.doc()?; let store = d.space(&s.space()); let word = t.trim().to_ascii_lowercase(); // `_C`, `_L`: the English name, as scripts write it. let word = word.strip_prefix('_').unwrap_or(&word).to_string(); if let Some(m) = SelectMode::from_keyword(&word).filter(|m| m.kind != SelectKind::Group) { self.mode = Some(m); return Ok(Step::Continue); } let picked = match word.as_str() { "r" | "remove" => { self.removing = true; None } "a" | "add" => { self.removing = false; None } "all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()), "l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()), "p" | "previous" => Some(s.state()?.previous_selection.clone()), _ => { s.echo("*Invalid selection*"); None } }; if let Some(hs) = picked { self.add(s, hs, true); } } Input::Enter if self.corner.is_some() => self.corner = None, Input::Enter if self.objs.is_empty() => return Ok(Step::Done), Input::Enter => { s.remember_selection(&self.objs); self.phase = StretchPhase::Base; } _ => {} } Ok(Step::Continue) } fn apply(&self, s: &mut Session, d: Vec2) -> Result { let whole: Vec = self.objs.iter().filter(|h| self.whole.contains(h)).copied().collect(); transform_entities(s, &whole, &Mat3::translate(d), false)?; let doc = s.doc_mut()?; for h in self.objs.iter().filter(|h| !self.whole.contains(h)) { let locked = doc.entity(*h).and_then(|e| doc.layer(&e.common.layer)).is_some_and(|l| l.locked); if !locked { doc.modify_entity(*h, |e| stretch_entity(e, &self.windows, d))?; } } s.set_selection(Vec::new()); Ok(Step::Done) } } impl Interactive for StretchM { fn name(&self) -> &'static str { "STRETCH" } fn begin(&mut self, s: &mut Session) -> Result { s.set_selection(Vec::new()); s.echo("Select objects to stretch by crossing-window or crossing-polygon..."); Ok(Step::Continue) } fn prompt(&self, _s: &Session) -> Prompt { if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) { return m.prompt(); } match (self.phase, self.corner) { (StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(c), (StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT), (StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"), (StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"), (StretchPhase::Second, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(self.base), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { match (self.phase, i) { (StretchPhase::Select, i) => self.select(s, i), (StretchPhase::Base, Input::Point(p)) => { self.base = Some(p); self.phase = StretchPhase::Second; Ok(Step::Continue) } (StretchPhase::Base, Input::Enter) => { self.phase = StretchPhase::Displacement; Ok(Step::Continue) } (StretchPhase::Base, Input::Keyword(k)) if k == "Displacement" => { self.phase = StretchPhase::Displacement; Ok(Step::Continue) } (StretchPhase::Displacement, Input::Point(p)) => self.apply(s, p), (StretchPhase::Displacement, Input::Enter) => Ok(Step::Done), (StretchPhase::Second, Input::Point(p)) => self.apply(s, p - self.base.unwrap_or(p)), (StretchPhase::Second, Input::Enter) => self.apply(s, self.base.unwrap_or(Vec2::ZERO)), _ => Ok(Step::Continue), } } fn preview(&self, s: &Session, c: Vec2) -> Vec { let rect = |a: Vec2| { let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y)); vec![line(a, b), line(b, c), line(c, d), line(d, a)] }; if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) { let Some(first) = m.pts.first().copied() else { return Vec::new() }; if !matches!(m.kind, SelectKind::Fence | SelectKind::WPolygon | SelectKind::CPolygon) { return rect(first); } // The fence or polygon so far, on to the cursor; a polygon closes back to its start. let mut out: Vec = m.pts.windows(2).filter_map(|w| Some(line(*w.first()?, *w.get(1)?))).collect(); out.extend(m.pts.last().map(|l| line(*l, c))); if m.kind != SelectKind::Fence && m.pts.len() >= 2 { out.push(line(c, first)); } return out; } match (self.phase, self.corner, self.base) { (StretchPhase::Select, Some(a), _) => rect(a), (StretchPhase::Second, _, Some(base)) => { let Ok(doc) = s.doc() else { return Vec::new() }; let m = Mat3::translate(c - base); let mut out: Vec = self .objs .iter() .take(500) .filter_map(|h| doc.entity(*h)) .map(|e| { let mut e = (**e).clone(); if self.whole.contains(&e.handle) { e.kind.transform(&m); } else { stretch_entity(&mut e, &self.windows, c - base); } e.kind }) .collect(); out.push(line(base, c)); out } _ => Vec::new(), } } } /// Whether OFFSET can work on this kind of object at all. fn offsettable(k: &EntityKind) -> bool { matches!( k, EntityKind::Line(_) | EntityKind::Circle(_) | EntityKind::Arc(_) | EntityKind::Ellipse(_) | EntityKind::LwPolyline(_) | EntityKind::Spline(_) | EntityKind::XLine(_) | EntityKind::Ray(_) ) } /// Offset an entity by `dist` towards `side`. Returns the new geometry. pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option { match k { EntityKind::Line(l) => { let ln = Line::new(l.a.xy(), l.b.xy()); let sgn = if ln.side(side) >= 0.0 { 1.0 } else { -1.0 }; let o = ln.offset(dist * sgn); Some(line(o.a, o.b)) } EntityKind::Circle(c) => { let inside = side.dist(c.center.xy()) < c.radius; let r = if inside { c.radius - dist } else { c.radius + dist }; (r > 1e-12).then(|| circle(c.center.xy(), r)) } EntityKind::Arc(a) => { let inside = side.dist(a.center.xy()) < a.radius; let r = if inside { a.radius - dist } else { a.radius + dist }; (r > 1e-12).then(|| arc(&Arc::new(a.center.xy(), r, a.start, a.end))) } EntityKind::Ellipse(e) => { // Approximate with a polyline offset of the tessellation. let ge = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end }; // Inside = on the centre side of the full ellipse, even for an open arc. let inside = { let a = e.major.xy().len(); let b = a * e.ratio.abs(); let u = e.major.xy().normalized(); let d = side - e.center.xy(); let (x, y) = (d.dot(u) * b, d.dot(u.perp()) * a); x * x + y * y < (a * b) * (a * b) }; // offset_points shifts along the left normal, which points into a counter-clockwise ellipse. let mut pts = Vec::new(); ge.tessellate(e.major.xy().len() * 1e-3, &mut pts); let off = offset_points(&pts, if inside { dist } else { -dist }, ge.is_full()); Some(lwpoly(off.into_iter().map(PolyVertex::new).collect(), ge.is_full())) } EntityKind::LwPolyline(p) => { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; // Side: compare with the nearest segment. let segs = pl.segments(); let nearest = segs.iter().min_by(|a, b| a.closest(side).dist(side).total_cmp(&b.closest(side).dist(side)))?; let c = nearest.closest(side); let t = nearest.tangent(0.5); let left = t.cross(side - c) > 0.0; let d = if left { dist } else { -dist }; offset_polyline(&pl, d).map(|vs| { let mut k = lwpoly(vs, p.closed); if let EntityKind::LwPolyline(n) = &mut k { n.const_width = p.const_width; } k }) } EntityKind::Spline(sp) => { let pts = sp.tessellate(1e-3); let n = pts.len(); let mid = pts.get(n / 2).copied()?; let next = pts.get((n / 2 + 1).min(n.saturating_sub(1))).copied()?; let left = (next - mid).cross(side - mid) > 0.0; let off = offset_points(&pts, if left { dist } else { -dist }, sp.closed); // A closed spline stays closed (periodic), smooth across its start point. Some(EntityKind::Spline(cadcraft_geom::Spline::from_fit(&decimate(&off, 40), sp.closed))) } EntityKind::XLine(r) | EntityKind::Ray(r) => { let ln = Line::new(r.base.xy(), r.base.xy() + r.dir.xy()); let sgn = if ln.side(side) >= 0.0 { 1.0 } else { -1.0 }; let o = ln.offset(dist * sgn); let rl = cadcraft_doc::RayLine { base: v3(o.a), dir: r.dir }; Some(if matches!(k, EntityKind::XLine(_)) { EntityKind::XLine(rl) } else { EntityKind::Ray(rl) }) } _ => None, } } fn decimate(pts: &[Vec2], max: usize) -> Vec { if pts.len() <= max { return pts.to_vec(); } let step = pts.len() as f64 / max as f64; let mut v: Vec = (0..max).filter_map(|i| pts.get((i as f64 * step) as usize).copied()).collect(); if let Some(l) = pts.last() { v.push(*l); } v } fn offset_points(pts: &[Vec2], d: f64, closed: bool) -> Vec { let n = pts.len(); let mut out = Vec::with_capacity(n); for i in 0..n { let p = pts.get(i).copied().unwrap_or_default(); let prev = if i > 0 { pts.get(i - 1) } else if closed { pts.get(n.saturating_sub(2)) } else { None }; let next = if i + 1 < n { pts.get(i + 1) } else if closed { pts.get(1) } else { None }; let t = match (prev, next) { (Some(a), Some(b)) => (*b - *a).normalized(), (None, Some(b)) => (*b - p).normalized(), (Some(a), None) => (p - *a).normalized(), _ => Vec2::X, }; out.push(p + t.perp() * d); } out } /// Offset a polyline: offset each segment, then join neighbours at their intersections. pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option> { let segs: Vec = pl.segments().iter().filter_map(|s| s.offset(d)).collect(); if segs.is_empty() { return None; } let n = segs.len(); // Where each offset segment starts after joining it to the previous one. let mut starts: Vec = Vec::with_capacity(n); for i in 0..n { let s = segs.get(i)?; starts.push(if i == 0 && !pl.closed { s.start() } else { let prev = segs.get((i + n - 1) % n)?; join_point(prev, s).unwrap_or(s.start()) }); } let mut verts: Vec = Vec::new(); for i in 0..n { let s = segs.get(i)?; let start = *starts.get(i)?; let end = if i + 1 < n { *starts.get(i + 1)? } else if pl.closed { *starts.first()? } else { s.end() }; let bulge = match s { Segment::Line(_) => 0.0, Segment::Arc { arc, ccw } => { // The joined endpoints may have moved along the circle: take the sweep between them. let sweep = if start.near(end, 1e-9) { arc.sweep() } else if *ccw { ccw_sweep(arc.center.angle_to(start), arc.center.angle_to(end)) } else { ccw_sweep(arc.center.angle_to(end), arc.center.angle_to(start)) }; cadcraft_geom::arc_to_bulge(if *ccw { sweep } else { -sweep }) } }; verts.push(PolyVertex { p: start, bulge, start_width: 0.0, end_width: 0.0 }); } if !pl.closed { verts.push(PolyVertex::new(segs.last()?.end())); } Some(verts) } fn join_point(a: &Segment, b: &Segment) -> Option { if a.end().near(b.start(), 1e-9) { return Some(b.start()); } let hits = intersect_ext(a, b, true); hits.into_iter().min_by(|x, y| x.dist(a.end()).total_cmp(&y.dist(a.end()))) } fn run_offset(s: &mut Session, p: &Value) -> Result { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("offset", "`handle` is required"))?; let dist = f64_req("offset", p, "distance")?; let side = point_req("offset", p, "side")?; let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| bad("offset", "no such object"))?; let k = offset_kind(&e.kind, dist.abs(), side).ok_or_else(|| bad("offset", "cannot offset that object"))?; let current_layer = match str_param(p, "layer").map(str::to_ascii_lowercase).as_deref() { None | Some("source") => false, Some("current") => true, Some(_) => return Err(bad("offset", "`layer` is \"source\" or \"current\"")), }; let nh = add_offset(s, &e, k, current_layer, bool_or(p, "erase", false))?; Ok(json!({ "handle": nh.hex() })) } /// Add the offset copy `k` of `src` (on the current layer instead of the source's when /// `current_layer`), then erase the source when `erase` and its layer is not locked. fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result { let space = s.space(); let d = s.doc_mut()?; let mut common = src.common.clone(); if current_layer { common.layer = d.header.str("CLAYER", "0"); } let nh = d.new_handle(); if let Some(st) = d.space_mut(&space) { st.push(Entity { handle: nh, common, kind: k }); } let locked = d.layer(&src.common.layer).is_some_and(|l| l.locked); if erase && !locked { d.remove_entity(src.handle); } Ok(nh) } // ---------------- trim / extend ---------------- /// The cutting (TRIM) or boundary (EXTEND) edges. #[derive(Clone, Copy, Default)] pub(crate) struct Edges<'a> { /// Only these objects; `None`: every visible object. pub only: Option<&'a [Handle]>, /// Implied edges (EDGEMODE 1): lines and the end segments of open polylines count as running /// on without end, arcs as whole circles. pub implied: bool, } /// All primitives of the cutting edges (excluding `exclude`). fn edge_segments(s: &Session, edges: Edges, exclude: Handle) -> Result> { let d = s.doc()?; let space = s.space(); let store = d.space(&space).ok_or(EngineError::NoDocument)?; let mut out = Vec::new(); for e in store.iter() { if e.handle == exclude || !d.is_visible(e) { continue; } if let Some(es) = edges.only && !es.contains(&e.handle) { continue; } if edges.implied && let Some(segs) = implied_edge(&e.kind) { out.extend(segs); continue; } for p in e.kind.prims() { out.extend(prim_to_segments(&p)); } } Ok(out) } /// `k` as an implied edge (EDGEMODE 1), for lines, arcs and open polylines. fn implied_edge(k: &EntityKind) -> Option> { const FAR: f64 = 1e8; let whole = |a: &Arc| Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*a }, ccw: true }; // Run a segment on past its start and/or its end. let run_on = |sg: Segment, back: bool, fwd: bool| match sg { Segment::Line(l) => { let d = (l.b - l.a).normalized(); Segment::Line(Line::new(if back { l.a - d * FAR } else { l.a }, if fwd { l.b + d * FAR } else { l.b })) } Segment::Arc { arc, .. } => whole(&arc), }; match k { EntityKind::Line(l) => Some(vec![run_on(Segment::Line(Line::new(l.a.xy(), l.b.xy())), true, true)]), EntityKind::Arc(a) => Some(vec![whole(&Arc::new(a.center.xy(), a.radius, a.start, a.end))]), EntityKind::LwPolyline(p) if !p.closed => { let mut segs = Polyline { vertices: p.vertices.clone(), closed: false }.segments(); let last = segs.len().checked_sub(1)?; for (i, sg) in segs.iter_mut().enumerate() { if i == 0 || i == last { *sg = run_on(*sg, i == 0, i == last); } } Some(segs) } _ => None, } } fn prim_to_segments(p: &Prim) -> Vec { match p { Prim::Seg(s) => vec![*s], Prim::Circle(c) => vec![Segment::Arc { arc: Arc { center: c.center, radius: c.radius, start: 0.0, end: TAU - 1e-12 }, ccw: true }], Prim::Infinite { base, dir, .. } => vec![Segment::Line(Line::new(*base - *dir * 1e8, *base + *dir * 1e8))], Prim::Ellipse(e) => { let mut pts = Vec::new(); e.tessellate(e.major.len() * 1e-4, &mut pts); pts.windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect() } Prim::Spline(sp) => sp.tessellate(1e-4).windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect(), Prim::Fill(f) => { let n = f.len(); (0..n).filter_map(|i| Some(Segment::Line(Line::new(*f.get(i)?, *f.get((i + 1) % n)?)))).collect() } Prim::Point(_) => Vec::new(), } } /// Trim `h` at the piece containing `pick`. Returns the handles that replace it. pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result> { if super::curves::is_locked(s, h) { return Err(EngineError::Other("The object is on a locked layer.".into())); } let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; let cut = edge_segments(s, edges, h)?; let pieces: Vec = match &e.kind { EntityKind::Line(l) => { let ln = Line::new(l.a.xy(), l.b.xy()); let mut ts: Vec = cut .iter() .flat_map(|c| intersect_ext(&Segment::Line(ln), c, false)) .map(|x| ln.param_of(x)) .filter(|t| *t > 1e-9 && *t < 1.0 - 1e-9) .collect(); ts.sort_by(f64::total_cmp); let tp = ln.param_of(pick).clamp(0.0, 1.0); let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); let hi = ts.iter().copied().filter(|t| *t > tp).fold(1.0, f64::min); if lo == 0.0 && hi == 1.0 { return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); } let mut v = Vec::new(); if lo > 0.0 { v.push(line(ln.a, ln.at(lo))); } if hi < 1.0 { v.push(line(ln.at(hi), ln.b)); } v } EntityKind::Arc(a) => { let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); let seg = Segment::Arc { arc: ga, ccw: true }; let sweep = ga.sweep(); let param = |p: Vec2| norm_angle(ga.center.angle_to(p) - ga.start); let mut ts: Vec = cut.iter().flat_map(|c| intersect_ext(&seg, c, false)).map(param).filter(|t| *t > 1e-9 && *t < sweep - 1e-9).collect(); ts.sort_by(f64::total_cmp); let tp = param(pick).min(sweep); let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max); let hi = ts.iter().copied().filter(|t| *t > tp).fold(sweep, f64::min); if lo == 0.0 && hi == sweep { return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); } let mut v = Vec::new(); if lo > 0.0 { v.push(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + lo))); } if hi < sweep { v.push(arc(&Arc::new(ga.center, ga.radius, ga.start + hi, ga.end))); } v } EntityKind::Circle(c) => { let full = Segment::Arc { arc: Arc { center: c.center.xy(), radius: c.radius, start: 0.0, end: TAU - 1e-12 }, ccw: true }; let mut angs: Vec = cut.iter().flat_map(|x| intersect_ext(&full, x, false)).map(|p| c.center.xy().angle_to(p)).collect(); angs.sort_by(f64::total_cmp); angs.dedup_by(|a, b| (*a - *b).abs() < 1e-9); if angs.len() < 2 { return Err(EngineError::Other("Circle must intersect cutting edges at two or more points.".into())); } let ap = c.center.xy().angle_to(pick); // The removed arc runs between the two intersections around the pick. let n = angs.len(); let mut keep = None; for i in 0..n { let a0 = angs[i]; let a1 = angs[(i + 1) % n]; if angle_in_sweep(ap, a0, a1) { keep = Some(arc(&Arc::new(c.center.xy(), c.radius, a1, a0))); } } keep.into_iter().collect() } EntityKind::LwPolyline(p) => trim_polyline(p, &cut, pick)?, k => super::modify2::trim_other(k, &cut, pick)?, }; let common = e.common.clone(); let space = s.space(); let d = s.doc_mut()?; d.remove_entity(h); let mut out = Vec::new(); for k in pieces { let nh = d.new_handle(); if let Some(st) = d.space_mut(&space) { st.push(Entity { handle: nh, common: common.clone(), kind: k }); out.push(nh); } } Ok(out) } /// Sub-polyline between length parameters `a` and `b` (open polylines). fn sub_polyline(segs: &[Segment], a: f64, b: f64) -> Vec { let mut out: Vec = Vec::new(); let mut acc = 0.0; for s in segs { let len = s.len(); let s0 = acc; let s1 = acc + len; acc = s1; if s1 <= a + 1e-12 || s0 >= b - 1e-12 || len <= 1e-15 { continue; } let t0 = ((a - s0) / len).clamp(0.0, 1.0); let t1 = ((b - s0) / len).clamp(0.0, 1.0); let p0 = s.at(t0); let bulge = match s { Segment::Line(_) => 0.0, Segment::Arc { arc, ccw } => { let sw = arc.sweep() * (t1 - t0); cadcraft_geom::arc_to_bulge(if *ccw { sw } else { -sw }) } }; if out.last().is_none_or(|l| !l.p.near(p0, 1e-9)) { out.push(PolyVertex { p: p0, bulge, ..Default::default() }); } else if let Some(l) = out.last_mut() { l.bulge = bulge; } out.push(PolyVertex::new(s.at(t1))); } out } fn trim_polyline(p: &cadcraft_doc::LwPolyline, cut: &[Segment], pick: Vec2) -> Result> { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; let segs = pl.segments(); let total: f64 = segs.iter().map(Segment::len).sum(); let mut ts: Vec = Vec::new(); let mut acc = 0.0; let mut tp = 0.0; let mut best = f64::INFINITY; for s in &segs { let len = s.len(); for x in cut.iter().flat_map(|c| intersect_ext(s, c, false)) { ts.push(acc + param_on(s, x) * len); } let c = s.closest(pick); if c.dist(pick) < best { best = c.dist(pick); tp = acc + param_on(s, c) * len; } acc += len; } ts.retain(|t| *t > 1e-9 && *t < total - 1e-9); ts.sort_by(f64::total_cmp); if ts.is_empty() { return Err(EngineError::Other("Object does not intersect a cutting edge.".into())); } let lo = ts.iter().copied().filter(|t| *t < tp).fold(f64::NEG_INFINITY, f64::max); let hi = ts.iter().copied().filter(|t| *t > tp).fold(f64::INFINITY, f64::min); let mk = |vs: Vec| lwpoly(vs, false); let mut out = Vec::new(); if p.closed { // Keep the complementary run from hi around to lo. // The pick lies before the first cut or after the last: the piece wraps past the start // vertex, so the run to keep is the stretch between the first and the last cut. let (lo, hi) = ( if lo.is_finite() { lo } else { ts.last().copied().unwrap_or(0.0) - total }, if hi.is_finite() { hi } else { ts.first().copied().unwrap_or(total) + total }, ); let doubled: Vec = segs.iter().chain(segs.iter()).copied().collect(); let (a, mut b) = if hi > total { (hi - total, lo) } else { (hi, lo + total) }; // A single cut leaves the whole outline, opened there. if b <= a + 1e-9 { b += total; } out.push(mk(sub_polyline(&doubled, a, b))); } else { if lo.is_finite() { out.push(mk(sub_polyline(&segs, 0.0, lo))); } if hi.is_finite() { out.push(mk(sub_polyline(&segs, hi, total))); } } Ok(out.into_iter().filter(|k| matches!(k, EntityKind::LwPolyline(l) if l.vertices.len() >= 2)).collect()) } fn param_on(s: &Segment, p: Vec2) -> f64 { match s { Segment::Line(l) => l.param_of(p).clamp(0.0, 1.0), Segment::Arc { arc, ccw } => { let t = norm_angle(arc.center.angle_to(p) - arc.start) / arc.sweep().max(1e-15); let t = t.clamp(0.0, 1.0); if *ccw { t } else { 1.0 - t } } } } /// Extend the end of `h` nearest `pick` to the nearest boundary edge. pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> { if super::curves::is_locked(s, h) { return Err(EngineError::Other("The object is on a locked layer.".into())); } let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; let cut = edge_segments(s, edges, h)?; let new_kind = match &e.kind { EntityKind::Line(l) => { let (a, b) = (l.a.xy(), l.b.xy()); let from_b = pick.dist(b) < pick.dist(a); let (fixed, end) = if from_b { (a, b) } else { (b, a) }; let dir = (end - fixed).normalized(); let ray = Segment::Line(Line::new(end, end + dir * 1e8)); let best = cut .iter() .flat_map(|c| intersect_ext(&ray, c, false)) .filter(|x| (*x - end).dot(dir) > 1e-9) .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))); let np = best.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; if from_b { line(a, np) } else { line(np, b) } } EntityKind::Arc(a) => { let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); let from_end = pick.dist(ga.end_point()) < pick.dist(ga.start_point()); let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..ga }, ccw: true }; let hits: Vec = cut.iter().flat_map(|c| intersect_ext(&full, c, false)).map(|x| ga.center.angle_to(x)).filter(|t| !ga.contains_angle(*t)).collect(); let pick_ang = if from_end { hits.iter().copied().min_by(|x, y| ccw_sweep(ga.end, *x).total_cmp(&ccw_sweep(ga.end, *y))) } else { hits.iter().copied().min_by(|x, y| ccw_sweep(*x, ga.start).total_cmp(&ccw_sweep(*y, ga.start))) }; let t = pick_ang.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; if from_end { arc(&Arc::new(ga.center, ga.radius, ga.start, t)) } else { arc(&Arc::new(ga.center, ga.radius, t, ga.end)) } } EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => { let mut vs = p.vertices.clone(); let n = vs.len(); let from_end = vs.last().is_some_and(|l| pick.dist(l.p) < vs.first().map(|f| pick.dist(f.p)).unwrap_or(f64::INFINITY)); let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) }; let end = vs.get(i_end).map(|v| v.p).unwrap_or_default(); let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default(); let dir = (end - prev).normalized(); let ray = Segment::Line(Line::new(end, end + dir * 1e8)); let np = cut .iter() .flat_map(|c| intersect_ext(&ray, c, false)) .filter(|x| (*x - end).dot(dir) > 1e-9) .min_by(|x, y| x.dist(end).total_cmp(&y.dist(end))) .ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?; if let Some(v) = vs.get_mut(i_end) { v.p = np; } lwpoly(vs, false) } k => super::modify2::extend_other(k, &cut, pick)?, }; s.doc_mut()?.modify_entity(h, |e| e.kind = new_kind)?; Ok(()) } // ---------------- fillet / chamfer ---------------- /// What CHAMFER takes (as in AutoCAD, not arcs, circles, ellipses or splines). const CHAMFER_LINES_ONLY: &str = "CHAMFER works on lines (and polylines with the Polyline option); it cannot chamfer arcs, circles, ellipses or splines."; fn as_line(e: &Entity) -> Option { match &e.kind { EntityKind::Line(l) => Some(Line::new(l.a.xy(), l.b.xy())), _ => None, } } /// Fillet (or chamfer) two lines. `p1`/`p2` mark the kept side of each line. pub(crate) fn fillet_lines( s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, radius: f64, chamfer: Option<(f64, f64)>, ) -> Result> { if super::curves::is_locked(s, h1) || super::curves::is_locked(s, h2) { return Err(EngineError::Other("The object is on a locked layer.".into())); } let d = s.doc()?; let e1 = d.entity(h1).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; let e2 = d.entity(h2).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; let (Some(l1), Some(l2)) = (as_line(&e1), as_line(&e2)) else { if chamfer.is_none() { return super::modify2::fillet_curves(s, h1, p1, h2, p2, radius); } return Err(EngineError::Other(CHAMFER_LINES_ONLY.into())); }; let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?; // Keep the far end on the picked side of the corner. let keep = |l: &Line, p: Vec2| -> Vec2 { let ta = (l.a - x).dot(p - x); let tb = (l.b - x).dot(p - x); if ta >= tb { l.a } else { l.b } }; let k1 = keep(&l1, p1); let k2 = keep(&l2, p2); let u1 = (k1 - x).normalized(); let u2 = (k2 - x).normalized(); let mut new_arc = None; let (t1, t2) = if let Some((d1, d2)) = chamfer { (x + u1 * d1, x + u2 * d2) } else if radius <= 1e-12 { (x, x) } else { let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0; if half.sin().abs() < 1e-12 { return Err(EngineError::Other("Lines are parallel.".into())); } let dist = radius / half.tan(); let t1 = x + u1 * dist; let t2 = x + u2 * dist; let bis = (u1 + u2).normalized(); let c = x + bis * (radius / half.sin()); let a1 = c.angle_to(t1); let a2 = c.angle_to(t2); let ar = if ccw_sweep(a1, a2) <= std::f64::consts::PI { Arc::new(c, radius, a1, a2) } else { Arc::new(c, radius, a2, a1) }; new_arc = Some(ar); (t1, t2) }; let common = e1.common.clone(); let doc = s.doc_mut()?; doc.modify_entity(h1, |e| e.kind = line(k1, t1))?; doc.modify_entity(h2, |e| e.kind = line(k2, t2))?; let space = s.space(); let doc = s.doc_mut()?; let extra = match (chamfer, new_arc) { (Some(_), _) if !t1.near(t2, 1e-12) => Some(line(t1, t2)), (None, Some(a)) => Some(arc(&a)), _ => None, }; if let Some(k) = extra { let nh = doc.new_handle(); if let Some(st) = doc.space_mut(&space) { st.push(Entity { handle: nh, common, kind: k }); } return Ok(Some(nh)); } Ok(None) } fn h_param(p: &Value, k: &str) -> Option { p.get(k).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) } fn run_fillet(s: &mut Session, p: &Value) -> Result { if bool_or(p, "polyline", false) { let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("fillet", "`handle` (polyline) is required"))?; let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0)); let n = super::modify2::fillet_polyline(s, h, r)?; return Ok(json!({ "filleted": n })); } let h1 = h_param(p, "h1").ok_or_else(|| bad("fillet", "`h1` is required"))?; let h2 = h_param(p, "h2").ok_or_else(|| bad("fillet", "`h2` is required"))?; let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0)); let p1 = point_param(p, "p1").unwrap_or_default(); let p2 = point_param(p, "p2").unwrap_or_default(); let a = fillet_corner(s, h1, p1, h2, p2, r, None, bool_or(p, "trim", true))?; Ok(json!({ "arc": a.map(|h| h.hex()) })) } fn run_chamfer(s: &mut Session, p: &Value) -> Result { let cut = match (p.get("length").and_then(Value::as_f64), p.get("angle").and_then(Value::as_f64)) { (Some(len), Some(deg)) if len.is_finite() && len >= 0.0 && deg.is_finite() && (0.0..180.0).contains(°) => { ChamferCut::Angle(len, deg.to_radians()) } (None, None) => { let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0)); ChamferCut::Dist(d1, f64_or(p, "d2", d1)) } _ => return Err(bad("chamfer", "`length` (>= 0) and `angle` (degrees, 0 to 180) go together")), }; if bool_or(p, "polyline", false) { let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("chamfer", "`handle` (polyline) is required"))?; return Ok(json!({ "chamfered": chamfer_polyline(s, h, cut)? })); } let h1 = h_param(p, "h1").ok_or_else(|| bad("chamfer", "`h1` is required"))?; let h2 = h_param(p, "h2").ok_or_else(|| bad("chamfer", "`h2` is required"))?; let p1 = point_param(p, "p1").unwrap_or_default(); let p2 = point_param(p, "p2").unwrap_or_default(); let a = fillet_corner(s, h1, p1, h2, p2, 0.0, Some(cut), bool_or(p, "trim", true))?; Ok(json!({ "line": a.map(|h| h.hex()) })) } // ---------------- explode / array / order / break / join ---------------- pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option> { // An annotative object explodes into plain objects as it draws at the current annotation scale. if cadcraft_doc::is_annotative(e) { let mut se = match d.anno_draw(e) { cadcraft_doc::AnnoDraw::Scaled(f) => cadcraft_doc::anno_scaled(e, f), _ => e.clone(), }; se.common.anno_scales.clear(); return explode_kind(d, &se); } match &e.kind { EntityKind::LwPolyline(p) => { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; Some( pl.segments() .into_iter() .map(|s| { let k = match s { Segment::Line(l) => line(l.a, l.b), Segment::Arc { arc: a, .. } => arc(&a), }; Entity { handle: Handle(0), common: e.common.clone(), kind: k } }) .collect(), ) } EntityKind::Insert(ins) => { if let Some(items) = super::array::explode_array(d, e) { return Some(items); } let blk = d.block(&ins.block)?; let m = ins.transform(blk.base.xy()); Some( blk.entities .iter() .map(|be| { let mut ne = (**be).clone(); ne.kind.transform(&m); if ne.common.layer == "0" { ne.common.layer = e.common.layer.clone(); } if ne.common.color == cadcraft_color::Color::ByBlock { ne.common.color = e.common.color; } ne }) .collect(), ) } // A helix explodes into its spline (seen from above). EntityKind::Helix(h) => Some(vec![Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Spline(h.spline()) }]), EntityKind::Polyline3d(p) => Some( p.points .windows(2) .filter_map(|w| { let kind = EntityKind::Line(cadcraft_doc::Line { a: *w.first()?, b: *w.get(1)? }); Some(Entity { handle: Handle(0), common: e.common.clone(), kind }) }) .collect(), ), EntityKind::MLine(m) => Some(super::draw2::mline::explode(e, m)), EntityKind::MText(m) => Some( cadcraft_fonts::plain_mtext(&m.contents) .split('\n') .enumerate() .map(|(i, l)| { let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64)); Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Text(cadcraft_doc::Text { insert: v3(at), align_pt: None, height: m.height, value: l.into(), rotation: m.rotation, width_factor: 1.0, oblique: 0.0, style: m.style.clone(), halign: Default::default(), valign: Default::default(), }), } }) .collect(), ), // A region explodes into its boundary's lines and arcs. EntityKind::Region(r) => Some( r.segments() .into_iter() .map(|s| { let k = match s { Segment::Line(l) => line(l.a, l.b), Segment::Arc { arc: a, .. } => arc(&a), }; Entity { handle: Handle(0), common: e.common.clone(), kind: k } }) .collect(), ), EntityKind::Dimension(dm) => { // The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font. let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); let g = cadcraft_render::dimension_in(d, dm); let mut v: Vec = g .lines .iter() .filter_map(|l| { if l.len() == 2 { Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) }) } else { Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false), }) } }) .collect(); for t in &g.fills { if let [a, b, c] = t.as_slice() { v.push(Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }), }); } } if !g.value.is_empty() { v.push(Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::MText(cadcraft_doc::MText { insert: v3(g.text_pos), height: g.text_height, width: 0.0, attach: 5, rotation: g.text_angle, style: style.text_style.clone(), contents: g.mtext_contents(), line_spacing: 1.0, line_spacing_exact: false, columns: None, }), }); } Some(v) } EntityKind::Tolerance(t) => Some(super::tolerance::explode(d, e, t)), _ => None, } } fn explode(s: &mut Session, hs: &[Handle]) -> Result> { explode_with(s, hs, explode_kind) } /// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives /// none for are left alone. EXPLODE, and BURST with its own pieces for block references. pub(crate) fn explode_with( s: &mut Session, hs: &[Handle], pieces: impl Fn(&cadcraft_doc::Drawing, &Entity) -> Option>, ) -> Result> { let space = s.space(); let mut out = Vec::new(); for h in hs { if super::curves::is_locked(s, *h) { continue; } let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue }; let Some(parts) = pieces(s.doc()?, &e) else { continue }; // The pieces of an object are new objects: its xdata goes with it. A block reference's // pieces are copies of the block's objects and keep theirs. let own_pieces = !matches!(e.kind, EntityKind::Insert(_)); let d = s.doc_mut()?; d.remove_entity(*h); for mut p in parts { if own_pieces { p.common.xdata.clear(); } p.handle = d.new_handle(); out.push(p.handle); if let Some(st) = d.space_mut(&space) { st.push(p); } } } s.set_selection(Vec::new()); Ok(out) } fn run_explode(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let r = explode(s, &hs)?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } /// Gives a rebuilt arc piece its source centre Z, or a rebuilt polyline piece its source elevation. fn with_z(mut k: EntityKind, z: f64) -> EntityKind { match &mut k { EntityKind::Arc(a) => a.center.z = z, EntityKind::LwPolyline(p) => p.elevation = z, _ => {} } k } /// Break `h` between two points (or at one point when p1 == p2). pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Result> { if super::curves::is_locked(s, h) { return Err(EngineError::Other("The object is on a locked layer.".into())); } let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?; let pieces: Vec = match &e.kind { EntityKind::Line(l) => { let ln = Line::new(l.a.xy(), l.b.xy()); let (mut t1, mut t2) = (ln.param_of(p1).clamp(0.0, 1.0), ln.param_of(p2).clamp(0.0, 1.0)); if t1 > t2 { std::mem::swap(&mut t1, &mut t2); } let at3 = |t: f64| ln.at(t).to3(l.a.z + (l.b.z - l.a.z) * t); let mut v = Vec::new(); if t1 > 1e-12 { v.push(EntityKind::Line(cadcraft_doc::Line { a: l.a, b: at3(t1) })); } if t2 < 1.0 - 1e-12 { v.push(EntityKind::Line(cadcraft_doc::Line { a: at3(t2), b: l.b })); } v } EntityKind::Circle(c) => { if p1.near(p2, 1e-12) { return Err(EngineError::Other("Arc cannot be full 360 degrees.".into())); } let a1 = c.center.xy().angle_to(p1); let a2 = c.center.xy().angle_to(p2); // AutoCAD removes the CCW portion from the first to the second point. vec![with_z(arc(&Arc::new(c.center.xy(), c.radius, a2, a1)), c.center.z)] } EntityKind::Arc(a) => { let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end); let par = |p: Vec2| norm_angle(ga.center.angle_to(p) - ga.start).min(ga.sweep()); let (mut t1, mut t2) = (par(p1), par(p2)); if t1 > t2 { std::mem::swap(&mut t1, &mut t2); } let mut v = Vec::new(); if t1 > 1e-12 { v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1)), a.center.z)); } if t2 < ga.sweep() - 1e-12 { v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end)), a.center.z)); } v } EntityKind::LwPolyline(p) => { let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed }; let segs = pl.segments(); let total: f64 = segs.iter().map(Segment::len).sum(); let lp = |q: Vec2| { let mut acc = 0.0; let mut best = (f64::INFINITY, 0.0); for s in &segs { let c = s.closest(q); let dd = c.dist(q); if dd < best.0 { best = (dd, acc + param_on(s, c) * s.len()); } acc += s.len(); } best.1 }; let (mut t1, mut t2) = (lp(p1), lp(p2)); if p.closed { if (t1 - t2).abs() < 1e-9 { return Err(EngineError::Other("Cannot break a closed polyline at a single point.".into())); } // Like a circle: the stretch from the first point to the second, along the // vertices, goes; what is left runs from the second point round to the first. let doubled: Vec = segs.iter().chain(segs.iter()).copied().collect(); let end = if t1 > t2 { t1 } else { t1 + total }; vec![with_z(lwpoly(sub_polyline(&doubled, t2, end), false), p.elevation)] } else { if t1 > t2 { std::mem::swap(&mut t1, &mut t2); } let mut v = Vec::new(); if t1 > 1e-12 { v.push(with_z(lwpoly(sub_polyline(&segs, 0.0, t1), false), p.elevation)); } if t2 < total - 1e-12 { v.push(with_z(lwpoly(sub_polyline(&segs, t2, total), false), p.elevation)); } v } } _ => return Err(EngineError::Other("Cannot break that object.".into())), }; let common = e.common.clone(); let space = s.space(); let d = s.doc_mut()?; d.remove_entity(h); let mut out = Vec::new(); for k in pieces { let nh = d.new_handle(); if let Some(st) = d.space_mut(&space) { st.push(Entity { handle: nh, common: common.clone(), kind: k }); out.push(nh); } } Ok(out) } fn run_break(s: &mut Session, p: &Value) -> Result { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("break", "`handle` is required"))?; let p1 = point_req("break", p, "p1")?; let p2 = point_param(p, "p2").unwrap_or(p1); let r = break_entity(s, h, p1, p2)?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } fn run_breakat(s: &mut Session, p: &Value) -> Result { let h = targets(s, p)?.first().copied().ok_or_else(|| bad("breakatpoint", "`handle` is required"))?; let at = point_req("breakatpoint", p, "at")?; let r = break_entity(s, h, at, at)?; Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::>() })) } /// Join lines/arcs/polylines that share endpoints into one polyline (or merge collinear lines). pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result> { if let Some(h) = super::modify2::join_curves(s, hs)? { return Ok(Some(h)); } if hs.iter().any(|h| super::curves::is_locked(s, *h)) { return Err(EngineError::Other("An object is on a locked layer.".into())); } let d = s.doc()?; let mut segs: Vec = Vec::new(); // Source entity of each segment in `segs`, so only entities that end up in the chain are removed. let mut owners: Vec = Vec::new(); let mut common = None; for h in hs { let Some(e) = d.entity(*h) else { continue }; common.get_or_insert_with(|| e.common.clone()); match &e.kind { EntityKind::Line(l) => segs.push(Segment::Line(Line::new(l.a.xy(), l.b.xy()))), EntityKind::Arc(a) => segs.push(Segment::Arc { arc: Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }), EntityKind::LwPolyline(p) if !p.closed => segs.extend(Polyline { vertices: p.vertices.clone(), closed: false }.segments()), _ => {} } owners.resize(segs.len(), *h); } if segs.len() < 2 { return Err(EngineError::Other("Select at least two objects to join.".into())); } // Chain greedily from the first segment. let mut chain = vec![segs.remove(0)]; let mut used = vec![owners.remove(0)]; let tol = 1e-6; loop { let start = chain.first().map(Segment::start).unwrap_or_default(); let end = chain.last().map(Segment::end).unwrap_or_default(); let Some(i) = segs.iter().position(|s| s.start().near(end, tol) || s.end().near(end, tol) || s.start().near(start, tol) || s.end().near(start, tol)) else { break; }; let s0 = segs.remove(i); used.push(owners.remove(i)); if s0.start().near(end, tol) { chain.push(s0); } else if s0.end().near(end, tol) { chain.push(s0.reversed()); } else if s0.end().near(start, tol) { chain.insert(0, s0); } else { chain.insert(0, s0.reversed()); } } if chain.len() < 2 { return Err(EngineError::Other("Objects are not connected.".into())); } let closed = chain.first().zip(chain.last()).is_some_and(|(a, b)| a.start().near(b.end(), tol)); let mut vs: Vec = chain .iter() .map(|s| PolyVertex { p: s.start(), bulge: match s { Segment::Line(_) => 0.0, Segment::Arc { arc, ccw } => cadcraft_geom::arc_to_bulge(if *ccw { arc.sweep() } else { -arc.sweep() }), }, ..Default::default() }) .collect(); if !closed && let Some(l) = chain.last() { vs.push(PolyVertex::new(l.end())); } let space = s.space(); let doc = s.doc_mut()?; for h in hs.iter().filter(|h| used.contains(*h)) { doc.remove_entity(*h); } let nh = doc.new_handle(); if let Some(st) = doc.space_mut(&space) { st.push(Entity { handle: nh, common: common.unwrap_or_default(), kind: lwpoly(vs, closed) }); } s.set_selection(Vec::new()); Ok(Some(nh)) } fn run_join(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; if p.get("close").and_then(Value::as_bool) == Some(true) { let h = super::modify2::join_close(s, &hs)?; return Ok(json!({ "handle": h.hex() })); } let r = join(s, &hs)?; Ok(json!({ "handle": r.map(|h| h.hex()) })) } // ---------------- interactive ---------------- #[derive(Clone, Copy, PartialEq)] enum Op { Erase, Move, Copy, Rotate, Scale, Mirror, Explode, Join, } /// Select objects, then collect the points/values the operation needs. struct SelectThen { op: Op, sel: SelectPhase, objs: Vec, pts: Vec, reference: bool, /// ROTATE Reference: the reference angle (radians), once known. Its first point, and the /// first of the two new-angle points, are kept in `ref_from`. ref_angle: Option, /// ROTATE Reference: the new angle is given by two points (the `Points` option). ref_points: bool, /// SCALE Reference: first point of a reference length given by two points. ref_from: Option, /// SCALE Reference: the reference length, once known. ref_len: Option, copy_mode: bool, ask_erase: bool, /// JOIN of a single arc or elliptical arc: the prompt asks for arcs to join to it, or cLose. join_source: Option, } impl SelectThen { fn new(op: Op) -> Self { SelectThen { op, sel: SelectPhase::default(), objs: Vec::new(), pts: Vec::new(), reference: false, ref_angle: None, ref_points: false, ref_from: None, ref_len: None, copy_mode: false, ask_erase: false, join_source: None, } } fn apply(&mut self, s: &mut Session) -> Result { // Immediate operations once selection is done. match self.op { Op::Erase => { let n = erase(s, &self.objs)?; let _ = n; Ok(Step::Done) } Op::Explode => { explode(s, &self.objs)?; Ok(Step::Done) } Op::Join => { if let [h] = self.objs[..] && s.doc()?.entity(h).is_some_and(|e| super::modify2::closed_kind(&e.kind).is_some()) { self.join_source = Some(h); return Ok(Step::Continue); } self.join_now(s) } _ => Ok(Step::Continue), } } /// JOIN the selected objects and report what they became. fn join_now(&mut self, s: &mut Session) -> Result { match join(s, &self.objs) { Ok(h) => { // Objects outside the joined chain are kept, so count only the ones JOIN consumed // (and the source object an ellipse or spline join keeps). let d = s.doc()?; let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count(); let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) { Some(EntityKind::Spline(_)) => "spline", Some(EntityKind::Ellipse(_)) => "ellipse", _ => "polyline", }; s.echo(format!("{n} objects joined into 1 {what}")) } Err(e) => s.echo(e.to_string()), } Ok(Step::Done) } /// JOIN of a single arc or elliptical arc: more arcs picked join to it on Enter, cLose makes /// it its circle or full ellipse. fn join_source_input(&mut self, s: &mut Session, src: Handle, i: Input) -> Result { match i { Input::Keyword(k) if k == "cLose" => { let ellipse = matches!(s.doc()?.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_))); super::modify2::join_close(s, &[src])?; s.echo(if ellipse { "The elliptical arc is now an ellipse." } else { "The arc is now a circle." }); Ok(Step::Done) } Input::Point(p) => { if let Some(h) = super::curves::pick_at(s, p) && !self.objs.contains(&h) { self.objs.push(h); } Ok(Step::Continue) } Input::Enter => self.join_now(s), _ => Ok(Step::Continue), } } /// SCALE Reference: reference length (typed, or two points), then new length (typed, or a /// point measured from the base point). The scale factor is new length / reference length. fn scale_reference(&mut self, s: &mut Session, i: Input) -> Result { let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) }; match (self.ref_len, self.ref_from, i) { (None, None, Input::Point(p)) => { self.ref_from = Some(p); Ok(Step::Continue) } (None, None, Input::Text(t)) => { self.ref_len = Some(require_length(number(&t))?); Ok(Step::Continue) } (None, None, Input::Enter) => { self.ref_len = Some(1.0); Ok(Step::Continue) } (None, Some(a), Input::Point(p)) => { self.ref_len = Some(require_length(Some(a.dist(p)))?); Ok(Step::Continue) } (Some(r), _, Input::Point(p)) => self.scale_by(s, base, base.dist(p) / r), (Some(r), _, Input::Text(t)) => { let n = require_length(number(&t))?; self.scale_by(s, base, n / r) } (_, _, Input::Enter) => Ok(Step::Done), _ => Ok(Step::Continue), } } /// ROTATE Reference: reference angle (typed, or two points), then the new angle (typed, a /// point measured from the base point, or two points after `Points`). The objects turn by the /// new angle minus the reference angle. fn rotate_reference(&mut self, s: &mut Session, i: Input) -> Result { let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) }; let au = s.angle_settings(); let angle = |t: &str| au.direction(t).filter(|a| a.is_finite()).ok_or_else(|| EngineError::Other("Requires an angle or point.".into())); let two_points = |a: Vec2, p: Vec2| { if a.near(p, 1e-12) { Err(EngineError::Other("The two points must differ.".into())) } else { Ok(a.angle_to(p)) } }; let Some(r) = self.ref_angle else { match (self.ref_from, i) { (None, Input::Point(p)) => self.ref_from = Some(p), (None, Input::Text(t)) => self.ref_angle = Some(angle(&t)?), (None, Input::Enter) => self.ref_angle = Some(0.0), (Some(a), Input::Point(p)) => { self.ref_angle = Some(two_points(a, p)?); self.ref_from = None; } _ => {} } return Ok(Step::Continue); }; let new = match (self.ref_points, self.ref_from, i) { (false, _, Input::Keyword(k)) if k == "Points" => { self.ref_points = true; return Ok(Step::Continue); } (false, _, Input::Point(p)) => base.angle_to(p), (false, _, Input::Text(t)) => angle(&t)?, (false, _, Input::Enter) => 0.0, (true, None, Input::Point(p)) => { self.ref_from = Some(p); return Ok(Step::Continue); } (true, Some(a), Input::Point(p)) => two_points(a, p)?, _ => return Ok(Step::Continue), }; transform_entities(s, &self.objs, &Mat3::rotate_about(base, new - r), self.copy_mode)?; s.set_selection(Vec::new()); Ok(Step::Done) } /// The prompt while ROTATE Reference collects its angles. fn rotate_reference_prompt(&self) -> Prompt { match (self.ref_angle, self.ref_points, self.ref_from) { (None, _, None) => Prompt::new("Specify the reference angle", Accept::POINT_OR_NUMBER).default("0"), (None, _, Some(p)) | (Some(_), true, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p), (Some(_), false, _) => { Prompt::new("Specify the new angle", Accept::POINT_OR_NUMBER).kw(&["Points"]).default("0").base_opt(self.pts.first().copied()) } (Some(_), true, None) => Prompt::new("Specify first point", Accept::POINT), } } /// Rubber band while ROTATE Reference collects its angles: the objects turned to the cursor /// once the reference angle is known, else the line from the first point. fn rotate_reference_preview(&self, s: &Session, c: Vec2) -> Vec { let (Some(base), Ok(d)) = (self.pts.first().copied(), s.doc()) else { return Vec::new() }; match (self.ref_angle, self.ref_points, self.ref_from) { (Some(r), false, _) => { let m = Mat3::rotate_about(base, base.angle_to(c) - r); let mut out: Vec = self .objs .iter() .take(500) .filter_map(|h| d.entity(*h)) .map(|e| { let mut k = e.kind.clone(); k.transform(&m); k }) .collect(); out.push(line(base, c)); out } (_, _, Some(a)) => vec![line(a, c)], _ => Vec::new(), } } fn scale_by(&mut self, s: &mut Session, base: Vec2, f: f64) -> Result { let f = require_length(Some(f))?; // The next SCALE offers this factor. s.last_used.scale_factor = f; transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?; s.set_selection(Vec::new()); Ok(Step::Done) } } /// Objects whose text MIRROR keeps readable instead of mirroring it with the geometry. fn keeps_reading_when_mirrored(k: &EntityKind) -> bool { match k { EntityKind::Insert(i) => !i.attribs.is_empty(), k => matches!( k, EntityKind::Text(_) | EntityKind::AttDef(_) | EntityKind::MText(_) | EntityKind::Dimension(_) | EntityKind::MLeader(_) | EntityKind::Table(_) | EntityKind::Tolerance(_) ), } } /// Text objects transformed for each frame of a MIRROR preview (on the CPU); more are not drawn. const MAX_MIRROR_TEXT_PREVIEW: usize = 1000; /// A finite length greater than zero (rejects 0, negatives, NaN and infinity). fn positive_length(v: Option) -> Option { v.filter(|f| f.is_finite() && *f > 1e-12) } /// [`positive_length`], or the error that makes the prompt ask again. fn require_length(v: Option) -> Result { positive_length(v).ok_or_else(|| EngineError::Other("Requires a positive number.".into())) } impl Interactive for SelectThen { fn name(&self) -> &'static str { match self.op { Op::Erase => "ERASE", Op::Move => "MOVE", Op::Copy => "COPY", Op::Rotate => "ROTATE", Op::Scale => "SCALE", Op::Mirror => "MIRROR", Op::Explode => "EXPLODE", Op::Join => "JOIN", } } fn begin(&mut self, s: &mut Session) -> Result { self.sel = SelectPhase::begin(s); if self.sel.done { self.objs = self.sel.picked.clone(); return self.apply(s); } Ok(Step::Continue) } fn prompt(&self, s: &Session) -> Prompt { if !self.sel.done { return self.sel.prompt(); } if self.ask_erase { return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No"); } if let Some(src) = self.join_source { let ellipse = s.doc().is_ok_and(|d| matches!(d.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_)))); let msg = if ellipse { "Select elliptical arcs to join to source" } else { "Select arcs to join to source" }; return Prompt::new(msg, Accept::POINT).kw(&["cLose"]); } let k = self.pts.len(); let base = self.pts.first().copied(); if self.op == Op::Scale && self.reference { return match (self.ref_len, self.ref_from) { (Some(_), _) => Prompt::new("Specify new length", Accept::POINT_OR_NUMBER).base_opt(base), (None, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p), (None, None) => Prompt::new("Specify reference length", Accept::POINT_OR_NUMBER).default("1"), }; } if self.op == Op::Rotate && self.reference { return self.rotate_reference_prompt(); } match (self.op, k) { (Op::Move | Op::Copy, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]), (Op::Move, _) => Prompt::new("Specify second point or ", Accept::POINT).base_opt(base), (Op::Copy, _) => Prompt::new("Specify second point", Accept::POINT).kw(&["Array", "Exit", "Undo"]).base_opt(base), (Op::Rotate | Op::Scale, 0) => Prompt::new("Specify base point", Accept::POINT), // ROTATE and SCALE offer the angle/factor used last time. (Op::Rotate, _) => { let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0)); // Offered as it would be typed: clockwise is positive when ANGDIR is 1. let a = if s.angle_settings().clockwise { -s.last_used.rotate_angle } else { s.last_used.rotate_angle }; Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER) .kw(&["Copy", "Reference"]) .default(crate::units::format_angle(a, au, ap)) .base_opt(base) } (Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER) .kw(&["Copy", "Reference"]) .default(format!("{:.4}", s.last_used.scale_factor)) .base_opt(base), (Op::Mirror, 0) => Prompt::new("Specify first point of mirror line", Accept::POINT), (Op::Mirror, _) => Prompt::new("Specify second point of mirror line", Accept::POINT).base_opt(base), _ => Prompt::new("", Accept::POINT), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { if !self.sel.done { match self.sel.feed(s, &i)? { SelOutcome::More => return Ok(Step::Continue), SelOutcome::Empty => return Ok(Step::Done), SelOutcome::Done(hs) => { self.objs = hs; return self.apply(s); } } } if let Some(src) = self.join_source { return self.join_source_input(s, src, i); } if self.ask_erase { let yes = matches!(&i, Input::Keyword(k) if k == "Yes") || matches!(&i, Input::Text(t) if t.trim().to_ascii_lowercase().starts_with('y')); let (a, b) = (self.pts[0], self.pts[1]); transform_entities(s, &self.objs, &Mat3::mirror(a, b), !yes)?; s.set_selection(Vec::new()); return Ok(Step::Done); } let k = self.pts.len(); if self.op == Op::Scale && self.reference { return self.scale_reference(s, i); } if self.op == Op::Rotate && self.reference { return self.rotate_reference(s, i); } match (self.op, k, i) { (_, _, Input::Keyword(kw)) if kw == "Copy" => { self.copy_mode = true; s.echo("Rotating/scaling a copy of the selected objects."); Ok(Step::Continue) } (Op::Scale | Op::Rotate, 1, Input::Keyword(kw)) if kw == "Reference" => { self.reference = true; Ok(Step::Continue) } (Op::Copy, _, Input::Keyword(kw)) if kw == "Exit" => { s.set_selection(Vec::new()); Ok(Step::Done) } (_, 0, Input::Point(p)) => { self.pts.push(p); Ok(Step::Continue) } (Op::Move, 1, Input::Point(p)) => { let d = p - self.pts[0]; transform_entities(s, &self.objs, &Mat3::translate(d), false)?; s.set_selection(Vec::new()); Ok(Step::Done) } (Op::Move, 1, Input::Enter) => { let d = self.pts[0]; transform_entities(s, &self.objs, &Mat3::translate(d), false)?; s.set_selection(Vec::new()); Ok(Step::Done) } (Op::Copy, _, Input::Point(p)) => { let d = p - self.pts[0]; transform_entities(s, &self.objs, &Mat3::translate(d), true)?; Ok(Step::Continue) } (Op::Copy, _, Input::Enter) => { s.set_selection(Vec::new()); Ok(Step::Done) } (Op::Rotate, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => { let a = match inp { Input::Point(p) => self.pts[0].angle_to(p), Input::Text(t) => s .angle_settings() .rotation(&t) .filter(|a| a.is_finite()) .ok_or_else(|| EngineError::Other("Requires an angle or point.".into()))?, _ => s.last_used.rotate_angle, }; s.last_used.rotate_angle = a; transform_entities(s, &self.objs, &Mat3::rotate_about(self.pts[0], a), self.copy_mode)?; s.set_selection(Vec::new()); Ok(Step::Done) } (Op::Scale, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => { let f = match inp { Input::Point(p) => self.pts[0].dist(p), Input::Text(t) => { number(&t).filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| EngineError::Other("Requires a positive number.".into()))? } _ => s.last_used.scale_factor, }; if f > 1e-12 && f.is_finite() { s.last_used.scale_factor = f; transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?; } s.set_selection(Vec::new()); Ok(Step::Done) } (Op::Mirror, 1, Input::Point(p)) => { if p.near(self.pts[0], 1e-12) { return Err(EngineError::Other("Mirror line points must differ.".into())); } self.pts.push(p); self.ask_erase = true; Ok(Step::Continue) } (_, _, Input::Enter) => Ok(Step::Done), _ => Ok(Step::Continue), } } fn preview(&self, s: &Session, c: Vec2) -> Vec { if self.op == Op::Rotate && self.reference { return self.rotate_reference_preview(s, c); } if !self.sel.done { return Vec::new(); } let Some(base) = self.pts.first().copied() else { return Vec::new() }; let m = match self.op { Op::Move | Op::Copy => Mat3::translate(c - base), Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), Op::Scale => match self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r))) { Some(f) => Mat3::scale_about(base, f), None if self.ref_len.is_some() => return Vec::new(), None => return self.ref_from.map(|a| vec![line(a, c)]).unwrap_or_default(), }, Op::Mirror if !self.ask_erase => Mat3::mirror(base, c), _ => return Vec::new(), }; let Ok(d) = s.doc() else { return Vec::new() }; let mut out: Vec = self .objs .iter() .take(500) .filter_map(|h| d.entity(*h)) .map(|e| { let mut k = e.kind.clone(); k.transform(&m); k }) .collect(); out.push(line(base, c)); out } fn drag(&self, s: &Session, c: Vec2) -> Option { if (self.op == Op::Rotate && self.reference) || !self.sel.done { return None; } let base = self.pts.first().copied()?; let matrix = match self.op { Op::Move | Op::Copy => Mat3::translate(c - base), Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)), Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)), Op::Scale => Mat3::scale_about(base, self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r)))?), Op::Mirror if !self.ask_erase => Mat3::mirror(base, c), _ => return None, }; let mut extra = vec![line(base, c)]; if self.op != Op::Mirror { return Some(crate::prompt::Drag { handles: self.objs.clone(), matrix, extra, clipboard: false }); } // Mirrored text keeps reading left to right (MIRRTEXT 0), which the matrix alone can't // draw: those objects are transformed one by one, the rest is mirrored on the GPU. let d = s.doc().ok()?; let mut handles = Vec::with_capacity(self.objs.len()); let mut texts = 0; for h in &self.objs { let Some(e) = d.entity(*h) else { continue }; if !keeps_reading_when_mirrored(&e.kind) { handles.push(*h); } else if texts < MAX_MIRROR_TEXT_PREVIEW { texts += 1; let mut k = e.kind.clone(); k.transform(&matrix); extra.push(k); } } Some(crate::prompt::Drag { handles, matrix, extra, clipboard: false }) } } /// An OFFSET option prompt opened from "Specify offset distance". #[derive(Clone, Copy, PartialEq)] enum OffsetAsk { Erase, Layer, } struct OffsetM { dist: Option, distance_first: Option, through: bool, picked: Option, ask: Option, /// Multiple mode: each side point offsets the newest copy again, until Enter. multiple: bool, /// Undo: the drawing before each offset, with the object picked then. undo: Vec<(std::sync::Arc, Option)>, } impl OffsetM { fn new(s: &Session) -> Self { let _ = s; OffsetM { dist: None, distance_first: None, through: false, picked: None, ask: None, multiple: false, undo: Vec::new() } } fn set_distance(&mut self, s: &mut Session, distance: f64) -> Result<()> { if !(distance.is_finite() && distance > 0.0) { return Err(EngineError::Other("Requires a positive distance.".into())); } s.doc_mut()?.header.set_f64("OFFSETDIST", distance); self.dist = Some(distance); self.distance_first = None; Ok(()) } } /// OFFSET's remembered options: erase the source (OFFSETERASE) and put copies on the current /// layer instead of the source's (kept in the drawing header beside it). fn offset_options(s: &Session) -> (bool, bool) { s.doc().map(|d| (d.header.i64("OFFSETERASE", 0) != 0, d.header.i64("OFFSETLAYER", 0) != 0)).unwrap_or((false, false)) } impl Interactive for OffsetM { fn name(&self) -> &'static str { "OFFSET" } /// Starts OFFSET, keeping a single preselected offsettable object (issue #1610) so the prompt /// sequence goes distance then side instead of asking for the object again; any other /// selection is cleared and the object loop asks for it. With PICKFIRST off the selection is /// likewise ignored and cleared. fn begin(&mut self, s: &mut Session) -> Result { // Exactly one offsettable object selected before the command is taken at once, so only the // side is asked for; the object loop stays for the ones after it (issue #1610). A multiple // selection is left for the select prompt, as AutoCAD does. let pre = s.selection(); if s.settings.pickfirst && let [h] = pre[..] && s.doc()?.entity(h).is_some_and(|e| offsettable(&e.kind)) { self.picked = Some(h); s.set_selection(vec![h]); } else { s.set_selection(Vec::new()); } let (erase, current) = offset_options(s); s.echo(format!( "Current settings: Erase source={} Layer={} OFFSETGAPTYPE=0", if erase { "Yes" } else { "No" }, if current { "Current" } else { "Source" } )); Ok(Step::Continue) } fn prompt(&self, s: &Session) -> Prompt { let (erase, current) = offset_options(s); match self.ask { Some(OffsetAsk::Erase) => { return Prompt::new("Erase source object after offsetting?", Accept::TEXT).kw(&["Yes", "No"]).default(if erase { "Yes" } else { "No" }); } Some(OffsetAsk::Layer) => { return Prompt::new("Enter layer option for offset objects", Accept::TEXT).kw(&["Current", "Source"]).default(if current { "Current" } else { "Source" }); } None => {} } let last = s.doc().map(|d| d.header.f64("OFFSETDIST", -1.0)).unwrap_or(-1.0); if self.dist.is_none() && !self.through && let Some(base) = self.distance_first { return Prompt::new("Specify second point", Accept::POINT).base(base); } match (self.dist.is_some() || self.through, self.picked) { (false, _) => Prompt::new("Specify offset distance", Accept::POINT_OR_NUMBER).kw(&["Through", "Erase", "Layer"]).default(if last < 0.0 { "Through".to_string() } else { format!("{last:.4}") }), (true, None) => Prompt::new("Select object to offset", Accept::POINT).kw(&["Exit", "Undo"]).default("Exit"), (true, Some(_)) if self.multiple => { let msg = if self.through { "Specify through point" } else { "Specify point on side to offset" }; Prompt::new(msg, Accept::POINT).kw(&["Exit", "Undo"]).default("next object") } (true, Some(_)) if self.through => Prompt::new("Specify through point", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"), (true, Some(_)) => Prompt::new("Specify point on side to offset", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { if let Some(ask) = self.ask { let word = match &i { Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), Input::Enter => String::new(), _ => return Ok(Step::Continue), }; let (key, yes, no) = match ask { OffsetAsk::Erase => ("OFFSETERASE", "y", "n"), OffsetAsk::Layer => ("OFFSETLAYER", "c", "s"), }; let v = if word.is_empty() { None } else if word.starts_with(yes) { Some(1) } else if word.starts_with(no) { Some(0) } else { return Err(EngineError::Other("Invalid option keyword.".into())); }; if let Some(v) = v { s.doc_mut()?.header.set_i64(key, v); } self.ask = None; return Ok(Step::Continue); } if self.dist.is_none() && !self.through { match i { Input::Text(t) => { let d = number(&t).ok_or_else(|| EngineError::Other("Requires a positive distance.".into()))?; self.set_distance(s, d)?; } Input::Point(p) => { if !p.is_finite() { return Err(EngineError::Other("Invalid point.".into())); } match self.distance_first { Some(base) => self.set_distance(s, base.dist(p))?, None => self.distance_first = Some(p), } } Input::Keyword(k) if k == "Through" => { self.through = true; self.distance_first = None; } Input::Keyword(k) if k == "Erase" => self.ask = Some(OffsetAsk::Erase), Input::Keyword(k) if k == "Layer" => self.ask = Some(OffsetAsk::Layer), Input::Enter => { self.distance_first = None; let last = s.doc()?.header.f64("OFFSETDIST", -1.0); if last > 0.0 { self.dist = Some(last); } else { self.through = true; } } _ => {} } return Ok(Step::Continue); } match (self.picked, i) { (_, Input::Keyword(k)) if k == "Exit" => { s.set_selection(Vec::new()); Ok(Step::Done) } (_, Input::Keyword(k)) if k == "Undo" => { match self.undo.pop() { Some((doc, picked)) => { // Settings changed meanwhile stay. let header = s.doc()?.header.clone(); s.state_mut()?.doc = doc; s.doc_mut()?.header = header; self.picked = if self.multiple { picked } else { None }; if self.picked.is_none() { self.multiple = false; } s.set_selection(self.picked.into_iter().collect()); } None => s.echo("Nothing to undo."), } Ok(Step::Continue) } (Some(_), Input::Keyword(k)) if k == "Multiple" => { self.multiple = true; Ok(Step::Continue) } (Some(_), Input::Enter) if self.multiple => { // : back to selecting. self.multiple = false; self.picked = None; s.set_selection(Vec::new()); Ok(Step::Continue) } (_, Input::Enter) => { s.set_selection(Vec::new()); Ok(Step::Done) } (None, Input::Point(p)) => { let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; let space = s.space(); match crate::select::pick(s.doc()?, &space, p, ap) { Some(h) => { self.picked = Some(h); s.set_selection(vec![h]); } None => s.echo("*Invalid selection*"), } Ok(Step::Continue) } (Some(h), Input::Point(p)) => { let e = s.doc()?.entity(h).map(|e| (**e).clone()); let mut made = None; if let Some(e) = e { let dist = match self.dist { Some(d) => d, None => { let polys = crate::select::hit_polylines(s.doc()?, &e, 1e-4); polys .iter() .flat_map(|pl| pl.windows(2).filter_map(|w| Some(Line::new(*w.first()?, *w.get(1)?).dist(p)))) .fold(f64::INFINITY, f64::min) } }; match offset_kind(&e.kind, dist, p) { Some(k) => { let before = s.state()?.doc.clone(); let (erase, current) = offset_options(s); made = Some(add_offset(s, &e, k, current, erase)?); self.undo.push((before, Some(h))); } None => s.echo("Cannot offset that object."), } } // Multiple mode carries on from the new copy. self.picked = if self.multiple { made.or(self.picked) } else { None }; s.set_selection(self.picked.into_iter().collect()); Ok(Step::Continue) } _ => Ok(Step::Continue), } } fn preview(&self, s: &Session, c: Vec2) -> Vec { if let Some(base) = self.distance_first && c.is_finite() { return vec![line(base, c)]; } let (Some(h), Ok(d)) = (self.picked, s.doc()) else { return Vec::new() }; let Some(e) = d.entity(h) else { return Vec::new() }; let dist = match self.dist { Some(x) => x, None => crate::select::entity_distance(d, e, c, 1e-3), }; offset_kind(&e.kind, dist, c).into_iter().collect() } } /// A FILLET/CHAMFER option prompt opened from the select prompts. #[derive(Clone, Copy, PartialEq)] enum FilletAsk { /// "Enter Trim mode option [Trim/No trim]" (TRIMMODE). Trim, /// CHAMFER "Enter trim method [Distance/Angle]" (CHAMMODE). Method, /// CHAMFER Angle: the length on the first line (CHAMFERC), then the angle (CHAMFERD). Length, Angle, } struct FilletM { chamfer: bool, first: Option<(Handle, Vec2)>, /// Asking for the radius (FILLET) or a chamfer distance: 1 = first, 2 = second. asking: u8, polyline: bool, ask: Option, /// Multiple: keep asking for pairs until Enter. multiple: bool, /// Undo: the drawing before each fillet/chamfer made in this command. undo: Vec>, } impl FilletM { fn new(_s: &Session, chamfer: bool) -> Self { FilletM { chamfer, first: None, asking: 0, polyline: false, ask: None, multiple: false, undo: Vec::new() } } /// One fillet/chamfer done: carry on in Multiple mode, else finish. fn next(&mut self, s: &mut Session, before: std::sync::Arc) -> Step { self.undo.push(before); self.first = None; self.polyline = false; s.set_selection(Vec::new()); if self.multiple { Step::Continue } else { Step::Done } } } /// TRIMMODE: FILLET and CHAMFER trim or extend the selected objects to the new corner (default). fn trim_mode(s: &Session) -> bool { s.doc().map(|d| d.header.i64("TRIMMODE", 1) != 0).unwrap_or(true) } /// The chamfer CHAMFER uses now: two distances, or length and angle when CHAMMODE = 1. fn chamfer_cut(s: &Session) -> Result { let h = &s.doc()?.header; Ok(if h.i64("CHAMMODE", 0) == 1 { ChamferCut::Angle(h.f64("CHAMFERC", 0.0), h.f64("CHAMFERD", 0.0)) } else { ChamferCut::Dist(h.f64("CHAMFERA", 0.0), h.f64("CHAMFERB", 0.0)) }) } impl Interactive for FilletM { fn name(&self) -> &'static str { if self.chamfer { "CHAMFER" } else { "FILLET" } } fn begin(&mut self, s: &mut Session) -> Result { s.set_selection(Vec::new()); let trim = trim_mode(s); let d = s.doc()?; let msg = if self.chamfer { let mode = if trim { "TRIM" } else { "NOTRIM" }; if d.header.i64("CHAMMODE", 0) == 1 { let (au, ap) = (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0)); let a = crate::units::format_angle(d.header.f64("CHAMFERD", 0.0), au, ap); format!("({mode} mode) Current chamfer Length = {:.4}, Angle = {a}", d.header.f64("CHAMFERC", 0.0)) } else { format!("({mode} mode) Current chamfer Dist1 = {:.4}, Dist2 = {:.4}", d.header.f64("CHAMFERA", 0.0), d.header.f64("CHAMFERB", 0.0)) } } else { format!("Current settings: Mode = {}, Radius = {:.4}", if trim { "TRIM" } else { "NOTRIM" }, d.header.f64("FILLETRAD", 0.0)) }; s.echo(msg); Ok(Step::Continue) } fn prompt(&self, s: &Session) -> Prompt { // The radius and distances are remembered in the drawing (FILLETRAD, CHAMFERA/B) and // offered as defaults; the second chamfer distance defaults to the first. if self.asking > 0 { let hdr = |k: &str| s.doc().map(|d| d.header.f64(k, 0.0)).unwrap_or(0.0); let (msg, v) = match (self.chamfer, self.asking) { (false, _) => ("Specify fillet radius", hdr("FILLETRAD")), (true, 1) => ("Specify first chamfer distance", hdr("CHAMFERA")), (true, _) => ("Specify second chamfer distance", hdr("CHAMFERA")), }; return Prompt::new(msg, Accept::NUMBER).default(format!("{v:.4}")); } let hdr_i = |k: &str, dflt: i64| s.doc().map(|d| d.header.i64(k, dflt)).unwrap_or(dflt); match self.ask { Some(FilletAsk::Trim) => { let cur = if trim_mode(s) { "Trim" } else { "No trim" }; return Prompt::new("Enter Trim mode option", Accept::TEXT).kw(&["Trim", "No trim"]).default(cur); } Some(FilletAsk::Method) => { let cur = if hdr_i("CHAMMODE", 0) == 1 { "Angle" } else { "Distance" }; return Prompt::new("Enter trim method", Accept::TEXT).kw(&["Distance", "Angle"]).default(cur); } Some(FilletAsk::Length) => { let v = s.doc().map(|d| d.header.f64("CHAMFERC", 0.0)).unwrap_or(0.0); return Prompt::new("Specify chamfer length on the first line", Accept::NUMBER).default(format!("{v:.4}")); } Some(FilletAsk::Angle) => { let (a, au, ap) = s.doc().map(|d| (d.header.f64("CHAMFERD", 0.0), d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0.0, 0, 0)); return Prompt::new("Specify chamfer angle from the first line", Accept::NUMBER).default(crate::units::format_angle(a, au, ap)); } None => {} } if self.polyline { return Prompt::new("Select 2D polyline", Accept::POINT); } match (self.first, self.chamfer) { (None, false) => Prompt::new("Select first object", Accept::POINT).kw(&["Undo", "Polyline", "Radius", "Trim", "Multiple"]), (None, true) => { Prompt::new("Select first line", Accept::POINT).kw(&["Undo", "Polyline", "Distance", "Angle", "Trim", "mEthod", "Multiple"]) } (Some(_), false) => Prompt::new("Select second object or shift-select to apply corner", Accept::POINT).kw(&["Radius"]), (Some(_), true) => Prompt::new("Select second line or shift-select to apply corner", Accept::POINT).kw(&["Distance", "Angle", "Method"]), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { if self.asking > 0 { // Enter keeps the default. let v = match &i { Input::Text(t) => Some( number(t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?, ), Input::Enter => None, _ => return Ok(Step::Continue), }; let d = s.doc_mut()?; match (self.chamfer, self.asking) { (false, _) => { if let Some(v) = v { d.header.set_f64("FILLETRAD", v); } self.asking = 0; } (true, 1) => { if let Some(v) = v { d.header.set_f64("CHAMFERA", v); } self.asking = 2; } (true, _) => { let v = v.unwrap_or_else(|| d.header.f64("CHAMFERA", 0.0)); d.header.set_f64("CHAMFERB", v); self.asking = 0; } } return Ok(Step::Continue); } if let Some(ask) = self.ask { return self.option(s, ask, i); } match i { Input::Keyword(k) if k == "Radius" || k == "Distance" => { if self.chamfer { s.doc_mut()?.header.set_i64("CHAMMODE", 0); } self.asking = 1; Ok(Step::Continue) } Input::Keyword(k) if k == "Angle" && self.chamfer => { s.doc_mut()?.header.set_i64("CHAMMODE", 1); self.ask = Some(FilletAsk::Length); Ok(Step::Continue) } Input::Keyword(k) if k == "mEthod" || k == "Method" => { self.ask = Some(FilletAsk::Method); Ok(Step::Continue) } Input::Keyword(k) if k == "Trim" => { self.ask = Some(FilletAsk::Trim); Ok(Step::Continue) } Input::Keyword(k) if k == "Multiple" => { self.multiple = true; Ok(Step::Continue) } Input::Keyword(k) if k == "Polyline" => { self.polyline = true; Ok(Step::Continue) } Input::Keyword(k) if k == "Undo" => { match self.undo.pop() { Some(doc) => { // Settings changed meanwhile stay. let header = s.doc()?.header.clone(); s.state_mut()?.doc = doc; s.doc_mut()?.header = header; } None => s.echo("Nothing to undo."), } self.first = None; s.set_selection(Vec::new()); Ok(Step::Continue) } Input::Point(p) => { let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; let space = s.space(); let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else { s.echo("*Invalid selection*"); return Ok(Step::Continue); }; if self.polyline { let before = s.state()?.doc.clone(); if self.chamfer { let n = chamfer_polyline(s, h, chamfer_cut(s)?)?; s.echo(format!("{n} lines were chamfered")); } else { let r = s.doc()?.header.f64("FILLETRAD", 0.0); let n = super::modify2::fillet_polyline(s, h, r)?; s.echo(format!("{n} lines were filleted")); } return Ok(self.next(s, before)); } match self.first { None => { self.first = Some((h, p)); s.set_selection(vec![h]); Ok(Step::Continue) } Some((h1, p1)) => { let r = s.doc()?.header.f64("FILLETRAD", 0.0); let cut = if self.chamfer { Some(chamfer_cut(s)?) } else { None }; let before = s.state()?.doc.clone(); let trim = trim_mode(s); match fillet_corner(s, h1, p1, h, p, r, cut, trim) { Ok(_) => Ok(self.next(s, before)), Err(e) => { s.echo(e.to_string()); self.first = None; s.set_selection(Vec::new()); Ok(Step::Continue) } } } } } Input::Enter => Ok(Step::Done), _ => Ok(Step::Continue), } } } impl FilletM { /// Input at a Trim / Method / Length / Angle option prompt; Enter keeps the current value. fn option(&mut self, s: &mut Session, ask: FilletAsk, i: Input) -> Result { let text = match &i { Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(), Input::Enter => String::new(), _ => return Ok(Step::Continue), }; let invalid = || EngineError::Other("Invalid option keyword.".into()); let flag = |yes: &str, no: &str| -> Result> { if text.is_empty() { Ok(None) } else if text.starts_with(yes) { Ok(Some(1)) } else if text.starts_with(no) { Ok(Some(0)) } else { Err(invalid()) } }; let au = s.angle_settings(); let d = s.doc_mut()?; self.ask = None; match ask { FilletAsk::Trim => { if let Some(v) = flag("t", "n")? { d.header.set_i64("TRIMMODE", v); } } FilletAsk::Method => { if let Some(v) = flag("a", "d")? { d.header.set_i64("CHAMMODE", v); } } FilletAsk::Length => { if !text.is_empty() { let v = number(&text) .filter(|v| v.is_finite() && *v >= 0.0) .ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?; d.header.set_f64("CHAMFERC", v); } self.ask = Some(FilletAsk::Angle); } FilletAsk::Angle => { if !text.is_empty() { let a = au .amount(&text) .filter(|a| a.is_finite() && *a >= 0.0 && *a < std::f64::consts::PI) .ok_or_else(|| EngineError::Other("Requires an angle from 0 to 180 degrees.".into()))?; d.header.set_f64("CHAMFERD", a); } } } Ok(Step::Continue) } } /// How CHAMFER cuts a corner: two distances, or a length on the first line and an angle /// (radians) from it. #[derive(Clone, Copy, Debug)] enum ChamferCut { Dist(f64, f64), Angle(f64, f64), } impl ChamferCut { /// The distances back from the corner along the first and second lines, which leave it /// along the unit directions `u1` and `u2`. fn distances(self, u1: Vec2, u2: Vec2) -> Result<(f64, f64)> { match self { ChamferCut::Dist(a, b) => Ok((a, b)), ChamferCut::Angle(len, ang) => { // Triangle corner–t1–t2: the corner angle and the chamfer angle at t1 give t2 by // the law of sines. let corner = u1.dot(u2).clamp(-1.0, 1.0).acos(); let s = (corner + ang).sin(); let d2 = len * ang.sin() / s; if corner + ang >= std::f64::consts::PI - 1e-9 || s.abs() < 1e-12 || !d2.is_finite() || d2 < 0.0 { return Err(EngineError::Other("The chamfer angle does not fit this corner.".into())); } Ok((len, d2)) } } } } /// FILLET (`cut` None) or CHAMFER two objects, honouring TRIMMODE: with `trim` false the new /// arc or line is added and both objects are left as they were. #[allow(clippy::too_many_arguments)] fn fillet_corner(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64, cut: Option, trim: bool) -> Result> { let dist = cut.map(|c| move |u1: Vec2, u2: Vec2| c.distances(u1, u2)); if let Some(made) = super::modify2::fillet_pline(s, h1, p1, h2, p2, r, dist.as_ref().map(|f| f as super::modify2::ChamferDistances), trim)? { return Ok(made); } let d = s.doc()?; let k1 = d.entity(h1).map(|e| e.kind.clone()); let k2 = d.entity(h2).map(|e| e.kind.clone()); let chamfer = match cut { None => None, Some(c) => { let l = |h| d.entity(h).and_then(|e| as_line(e)).ok_or_else(|| EngineError::Other(CHAMFER_LINES_ONLY.into())); let (l1, l2) = (l(h1)?, l(h2)?); let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?; // The direction along each line towards its picked side. let dir = |l: &Line, p: Vec2| if (l.a - x).dot(p - x) >= (l.b - x).dot(p - x) { (l.a - x).normalized() } else { (l.b - x).normalized() }; Some(c.distances(dir(&l1, p1), dir(&l2, p2))?) } }; let made = fillet_lines(s, h1, p1, h2, p2, r, chamfer)?; if !trim { let doc = s.doc_mut()?; for (h, k) in [(h1, k1), (h2, k2)] { if let Some(k) = k { doc.modify_entity(h, |e| e.kind = k)?; } } } Ok(made) } /// CHAMFER Polyline: cut every corner between two straight segments of a 2D polyline. Returns /// how many corners were chamfered. fn chamfer_polyline(s: &mut Session, h: Handle, cut: ChamferCut) -> Result { let d = s.doc()?; let e = d.entity(h).ok_or_else(|| EngineError::Other("no such object".into()))?; if d.layer(&e.common.layer).is_some_and(|l| l.locked) { return Err(EngineError::Other("The object is on a locked layer.".into())); } let EntityKind::LwPolyline(pl) = &e.kind else { return Err(EngineError::Other("Select a 2D polyline.".into())) }; let vs = &pl.vertices; let n = vs.len(); if n < 3 { return Ok(0); } let at = |i: usize| vs.get(i % n).copied().unwrap_or_default(); // Distances cut back from each corner along the incoming and outgoing segments. let mut cuts = vec![(0.0f64, 0.0f64); n]; for (i, c) in cuts.iter_mut().enumerate() { if !pl.closed && (i == 0 || i + 1 == n) { continue; } let (prev, cur, next) = (at(i + n - 1), at(i), at(i + 1)); if prev.bulge.abs() > 1e-12 || cur.bulge.abs() > 1e-12 { continue; } let u1 = (prev.p - cur.p).normalized(); let u2 = (next.p - cur.p).normalized(); if u1 == Vec2::ZERO || u2 == Vec2::ZERO || u1.dot(u2) < -1.0 + 1e-9 { continue; } if let Ok(ab) = cut.distances(u1, u2) { *c = ab; } } // Drop chamfers that do not fit their segments. for i in 0..n { let j = (i + 1) % n; let seg = at(i).p.dist(at(j).p); let (out_i, in_j) = (cuts.get(i).map_or(0.0, |c| c.1), cuts.get(j).map_or(0.0, |c| c.0)); if out_i + in_j > seg + 1e-9 { for k in [i, j] { if let Some(c) = cuts.get_mut(k) { *c = (0.0, 0.0); } } } } let mut out: Vec = Vec::with_capacity(n * 2); let mut count = 0; for i in 0..n { let v = at(i); let (a, b) = cuts.get(i).copied().unwrap_or_default(); if a <= 1e-12 && b <= 1e-12 { out.push(v); continue; } let t1 = v.p + (at(i + n - 1).p - v.p).normalized() * a; let t2 = v.p + (at(i + 1).p - v.p).normalized() * b; out.push(PolyVertex { p: t1, bulge: 0.0, ..v }); out.push(PolyVertex { p: t2, bulge: 0.0, ..v }); count += 1; } let mut npl = pl.clone(); npl.vertices = out; s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::LwPolyline(npl))?; Ok(count) } #[derive(Default)] struct BreakM { obj: Option<(Handle, Vec2)>, /// The `First point` option: the next point replaces the selection pick as the first break point. first_point: bool, } impl Interactive for BreakM { fn name(&self) -> &'static str { "BREAK" } fn prompt(&self, _s: &Session) -> Prompt { match self.obj { None => Prompt::new("Select object", Accept::POINT), Some(_) if self.first_point => Prompt::new("Specify first break point", Accept::POINT), Some(_) => Prompt::new("Specify second break point", Accept::POINT).kw(&["First point"]), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { match (self.obj, i) { (None, Input::Point(p)) => { let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5; let space = s.space(); match crate::select::pick(s.doc()?, &space, p, ap) { Some(h) => { self.obj = Some((h, p)); s.set_selection(vec![h]); } None => s.echo("*Invalid selection*"), } Ok(Step::Continue) } (Some(_), Input::Keyword(k)) if k == "First point" => { self.first_point = true; Ok(Step::Continue) } (Some((h, _)), Input::Point(p1)) if self.first_point => { self.obj = Some((h, p1)); self.first_point = false; Ok(Step::Continue) } (Some((h, p1)), Input::Point(p2)) => { break_entity(s, h, p1, p2)?; s.set_selection(Vec::new()); Ok(Step::Done) } (_, Input::Enter) => Ok(Step::Done), _ => Ok(Step::Continue), } } } /// MOVE and COPY: [`SelectThen`], plus the Displacement option at the base point prompt /// (the keyword, or Enter): the typed point is the displacement itself. struct DisplaceM { inner: SelectThen, displacement: bool, } impl DisplaceM { fn new(op: Op) -> Self { DisplaceM { inner: SelectThen::new(op), displacement: false } } /// At "Specify base point": objects selected, no point yet. fn at_base(&self) -> bool { self.inner.sel.done && self.inner.pts.is_empty() && !self.displacement } } impl Interactive for DisplaceM { fn name(&self) -> &'static str { self.inner.name() } fn begin(&mut self, s: &mut Session) -> Result { self.inner.begin(s) } fn prompt(&self, s: &Session) -> Prompt { if self.displacement { return Prompt::new("Specify displacement", Accept::POINT).default("0,0"); } if self.at_base() { return Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"); } self.inner.prompt(s) } fn input(&mut self, s: &mut Session, i: Input) -> Result { if self.displacement { let d = match i { Input::Point(p) => p, Input::Enter => Vec2::ZERO, _ => return Ok(Step::Continue), }; if d != Vec2::ZERO { transform_entities(s, &self.inner.objs, &Mat3::translate(d), self.inner.op == Op::Copy)?; } s.set_selection(Vec::new()); return Ok(Step::Done); } if self.at_base() && (matches!(&i, Input::Enter) || matches!(&i, Input::Keyword(k) if k == "Displacement")) { self.displacement = true; return Ok(Step::Continue); } self.inner.input(s, i) } fn preview(&self, s: &Session, c: Vec2) -> Vec { if self.displacement { return Vec::new(); } self.inner.preview(s, c) } fn drag(&self, s: &Session, c: Vec2) -> Option { if self.displacement { return None; } self.inner.drag(s, c) } } pub(crate) fn _unused(_: Circle) {}