mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-03 00:42:46 -06:00
498 lines
20 KiB
Rust
498 lines
20 KiB
Rust
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<EntityKind> {
|
|
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<Vec2> {
|
|
if pts.len() <= max {
|
|
return pts.to_vec();
|
|
}
|
|
let step = pts.len() as f64 / max as f64;
|
|
let mut v: Vec<Vec2> = (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<Vec2> {
|
|
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<Vec<PolyVertex>> {
|
|
let segs: Vec<Segment> = 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<Vec2> = 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<PolyVertex> = 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<Vec2> {
|
|
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<Value> {
|
|
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<Handle> {
|
|
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<f64>,
|
|
distance_first: Option<Vec2>,
|
|
through: bool,
|
|
picked: Option<Handle>,
|
|
ask: Option<OffsetAsk>,
|
|
/// 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<cadcraft_doc::Drawing>, Option<Handle>)>,
|
|
}
|
|
|
|
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<Step> {
|
|
// 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<Step> {
|
|
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 => {
|
|
// <next object>: 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<EntityKind> {
|
|
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()
|
|
}
|
|
}
|