diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index f2116e9d0..bbcd25267 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -16,6 +16,26 @@ use super::{CommandSpec, bad, bool_or, f64_or, f64_req, point_param, point_req, use crate::select::{SelectKind, SelectMode}; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; +mod break_join; +mod explode; +mod fillet; +mod offset; +mod select_then; +mod stretch; +mod transform; +mod trim; + +use break_join::{BreakM, run_break, run_breakat, run_join}; +use explode::run_explode; +pub(crate) use explode::{explode_kind, explode_with}; +use fillet::{FilletM, run_chamfer, run_fillet}; +use offset::{OffsetM, run_offset}; +use select_then::{DisplaceM, Op, SelectThen}; +use stretch::{StretchM, run_stretch}; +pub(crate) use transform::transform_entities; +use transform::{run_copy, run_erase, run_mirror, run_move, run_rotate, run_scale}; +pub(crate) use trim::{Edges, extend, trim}; + pub fn specs() -> Vec { vec![ CommandSpec::new("erase", "Erase", run_erase) @@ -99,2976 +119,4 @@ pub fn specs() -> Vec { ] } -// ---------------- 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 ---------------- - -fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option> { - 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(), - ) -} - -fn explode_mtext(e: &Entity, m: &cadcraft_doc::MText) -> Vec { - 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() -} - -/// The entities of a boundary's lines and arcs, on the object's layer and style. -fn segment_entities(e: &Entity, segs: impl IntoIterator) -> Vec { - segs.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() -} - -fn explode_dimension(d: &cadcraft_doc::Drawing, e: &Entity, dm: &cadcraft_doc::Dimension) -> Vec { - // 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 line_entity = |l: &Vec| { - 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) }) - } - }; - // The text's marks (arc length symbol, DIMINSPECT frame) go after the text background. - let text_marks = &g.text_marks; - let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l); - let mut v: Vec = marks(false).filter_map(&line_entity).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)] }), - }); - } - } - // DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and - // arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3). - if style.text_fill == 2 - && let [p0, p1, p2, p3] = g.text_fill.as_slice() - { - let fill = style.text_fill_color; - let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill }; - v.push(Entity { - handle: Handle(0), - common: cadcraft_doc::Common { color, ..e.common.clone() }, - kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }), - }); - } - v.extend(marks(true).filter_map(&line_entity)); - 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, - }), - }); - } - v -} - -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(segment_entities(e, pl.segments())) - } - EntityKind::Insert(ins) => explode_block_ref(d, e, ins), - // 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(explode_mtext(e, m)), - // A region explodes into its boundary's lines and arcs. - EntityKind::Region(r) => Some(segment_entities(e, r.segments())), - EntityKind::Dimension(dm) => Some(explode_dimension(d, e, dm)), - 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) {} diff --git a/crates/engine/src/cmd/modify/break_join.rs b/crates/engine/src/cmd/modify/break_join.rs new file mode 100644 index 000000000..16892c199 --- /dev/null +++ b/crates/engine/src/cmd/modify/break_join.rs @@ -0,0 +1,277 @@ +use super::trim::{param_on, sub_polyline}; +use super::*; + +/// 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 crate::cmd::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) +} + +pub(super) 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::>() })) +} + +pub(super) 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) = crate::cmd::modify2::join_curves(s, hs)? { + return Ok(Some(h)); + } + if hs.iter().any(|h| crate::cmd::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)) +} + +pub(super) 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 = crate::cmd::modify2::join_close(s, &hs)?; + return Ok(json!({ "handle": h.hex() })); + } + let r = join(s, &hs)?; + Ok(json!({ "handle": r.map(|h| h.hex()) })) +} + +#[derive(Default)] +pub(super) 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), + } + } +} diff --git a/crates/engine/src/cmd/modify/explode.rs b/crates/engine/src/cmd/modify/explode.rs new file mode 100644 index 000000000..3b25ccfee --- /dev/null +++ b/crates/engine/src/cmd/modify/explode.rs @@ -0,0 +1,207 @@ +use super::*; + +// ---------------- explode / array / order / break / join ---------------- + +fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option> { + if let Some(items) = crate::cmd::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(), + ) +} + +fn explode_mtext(e: &Entity, m: &cadcraft_doc::MText) -> Vec { + 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() +} + +/// The entities of a boundary's lines and arcs, on the object's layer and style. +fn segment_entities(e: &Entity, segs: impl IntoIterator) -> Vec { + segs.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() +} + +fn explode_dimension(d: &cadcraft_doc::Drawing, e: &Entity, dm: &cadcraft_doc::Dimension) -> Vec { + // 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 line_entity = |l: &Vec| { + 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) }) + } + }; + // The text's marks (arc length symbol, DIMINSPECT frame) go after the text background. + let text_marks = &g.text_marks; + let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l); + let mut v: Vec = marks(false).filter_map(&line_entity).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)] }), + }); + } + } + // DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and + // arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3). + if style.text_fill == 2 + && let [p0, p1, p2, p3] = g.text_fill.as_slice() + { + let fill = style.text_fill_color; + let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill }; + v.push(Entity { + handle: Handle(0), + common: cadcraft_doc::Common { color, ..e.common.clone() }, + kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }), + }); + } + v.extend(marks(true).filter_map(&line_entity)); + 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, + }), + }); + } + v +} + +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(segment_entities(e, pl.segments())) + } + EntityKind::Insert(ins) => explode_block_ref(d, e, ins), + // 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(crate::cmd::draw2::mline::explode(e, m)), + EntityKind::MText(m) => Some(explode_mtext(e, m)), + // A region explodes into its boundary's lines and arcs. + EntityKind::Region(r) => Some(segment_entities(e, r.segments())), + EntityKind::Dimension(dm) => Some(explode_dimension(d, e, dm)), + EntityKind::Tolerance(t) => Some(crate::cmd::tolerance::explode(d, e, t)), + _ => None, + } +} + +pub(super) 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 crate::cmd::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) +} + +pub(super) 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::>() })) +} diff --git a/crates/engine/src/cmd/modify/fillet.rs b/crates/engine/src/cmd/modify/fillet.rs new file mode 100644 index 000000000..01d701817 --- /dev/null +++ b/crates/engine/src/cmd/modify/fillet.rs @@ -0,0 +1,571 @@ +use super::*; + +// ---------------- 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 crate::cmd::curves::is_locked(s, h1) || crate::cmd::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 crate::cmd::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))) +} + +pub(super) 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 = crate::cmd::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()) })) +} + +pub(super) 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()) })) +} + +/// 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, +} + +pub(super) 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 { + pub(super) 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 = crate::cmd::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) = + crate::cmd::modify2::fillet_pline(s, h1, p1, h2, p2, r, dist.as_ref().map(|f| f as crate::cmd::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) +} diff --git a/crates/engine/src/cmd/modify/offset.rs b/crates/engine/src/cmd/modify/offset.rs new file mode 100644 index 000000000..67bdd7ec7 --- /dev/null +++ b/crates/engine/src/cmd/modify/offset.rs @@ -0,0 +1,497 @@ +use super::*; + +/// 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()))) +} + +pub(super) 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) +} + +/// An OFFSET option prompt opened from "Specify offset distance". +#[derive(Clone, Copy, PartialEq)] +enum OffsetAsk { + Erase, + Layer, +} + +pub(super) 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 { + pub(super) 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() + } +} diff --git a/crates/engine/src/cmd/modify/select_then.rs b/crates/engine/src/cmd/modify/select_then.rs new file mode 100644 index 000000000..75264990c --- /dev/null +++ b/crates/engine/src/cmd/modify/select_then.rs @@ -0,0 +1,593 @@ +use super::break_join::join; +use super::explode::explode; +use super::transform::{erase, transform_entities}; +use super::*; + +// ---------------- interactive ---------------- + +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Op { + Erase, + Move, + Copy, + Rotate, + Scale, + Mirror, + Explode, + Join, +} + +/// Select objects, then collect the points/values the operation needs. +pub(super) 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 { + pub(super) 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| crate::cmd::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(_))); + crate::cmd::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) = crate::cmd::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 }) + } +} + +/// MOVE and COPY: [`SelectThen`], plus the Displacement option at the base point prompt +/// (the keyword, or Enter): the typed point is the displacement itself. +pub(super) struct DisplaceM { + inner: SelectThen, + displacement: bool, +} + +impl DisplaceM { + pub(super) 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) + } +} diff --git a/crates/engine/src/cmd/modify/stretch.rs b/crates/engine/src/cmd/modify/stretch.rs new file mode 100644 index 000000000..5aa91bbef --- /dev/null +++ b/crates/engine/src/cmd/modify/stretch.rs @@ -0,0 +1,393 @@ +use super::transform::{delta_of, transform_entities}; +use super::*; + +/// 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)); + } + } + } +} + +pub(super) 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| !crate::cmd::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)] +pub(super) 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(), + } + } +} diff --git a/crates/engine/src/cmd/modify/transform.rs b/crates/engine/src/cmd/modify/transform.rs new file mode 100644 index 000000000..760e37cbc --- /dev/null +++ b/crates/engine/src/cmd/modify/transform.rs @@ -0,0 +1,106 @@ +use super::*; + +// ---------------- 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) +} + +pub(super) 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) +} + +pub(super) fn run_erase(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + let n = erase(s, &hs)?; + Ok(json!({ "erased": n })) +} + +pub(super) 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) +} + +pub(super) 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() })) +} + +pub(super) 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 })) +} + +pub(super) 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::>() })) +} + +pub(super) 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::>() })) +} + +pub(super) 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::>() })) +} diff --git a/crates/engine/src/cmd/modify/trim.rs b/crates/engine/src/cmd/modify/trim.rs new file mode 100644 index 000000000..4caaadfb6 --- /dev/null +++ b/crates/engine/src/cmd/modify/trim.rs @@ -0,0 +1,343 @@ +use super::*; + +// ---------------- 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 crate::cmd::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 => crate::cmd::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). +pub(super) 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()) +} + +pub(super) 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 crate::cmd::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 => crate::cmd::modify2::extend_other(k, &cut, pick)?, + }; + s.doc_mut()?.modify_entity(h, |e| e.kind = new_kind)?; + Ok(()) +}