refactor(engine): move cmd/modify.rs items to cmd/modify/

This commit is contained in:
aiongg
2026-10-26 19:18:38 +00:00
parent 31a04e3dbe
commit c5e7db78e4
9 changed files with 3007 additions and 2972 deletions
File diff suppressed because it is too large Load Diff
+277
View File
@@ -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<Vec<Handle>> {
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<EntityKind> = 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<Segment> = 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<Value> {
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::<Vec<_>>() }))
}
pub(super) fn run_breakat(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>() }))
}
/// Join lines/arcs/polylines that share endpoints into one polyline (or merge collinear lines).
pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
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<Segment> = Vec::new();
// Source entity of each segment in `segs`, so only entities that end up in the chain are removed.
let mut owners: Vec<Handle> = 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<PolyVertex> = 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<Value> {
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<Step> {
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),
}
}
}
+207
View File
@@ -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<Vec<Entity>> {
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<Entity> {
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<Item = Segment>) -> Vec<Entity> {
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<Entity> {
// 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<Vec2>| {
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<Entity> = 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<Vec<Entity>> {
// 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<Vec<Handle>> {
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<Vec<Entity>>,
) -> Result<Vec<Handle>> {
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<Value> {
let hs = targets(s, p)?;
let r = explode(s, &hs)?;
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
+571
View File
@@ -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<Line> {
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<Option<Handle>> {
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<Handle> {
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<Value> {
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<Value> {
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(&deg) => {
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<FilletAsk>,
/// Multiple: keep asking for pairs until Enter.
multiple: bool,
/// Undo: the drawing before each fillet/chamfer made in this command.
undo: Vec<std::sync::Arc<cadcraft_doc::Drawing>>,
}
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<cadcraft_doc::Drawing>) -> 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<ChamferCut> {
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<Step> {
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<Step> {
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<Step> {
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<Option<i64>> {
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<ChamferCut>, trim: bool) -> Result<Option<Handle>> {
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<usize> {
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<PolyVertex> = 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)
}
+497
View File
@@ -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<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()
}
}
+593
View File
@@ -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<Handle>,
pts: Vec<Vec2>,
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<f64>,
/// 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<Vec2>,
/// SCALE Reference: the reference length, once known.
ref_len: Option<f64>,
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<Handle>,
}
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<Step> {
// 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<Step> {
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<Step> {
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<Step> {
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<Step> {
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<EntityKind> {
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<EntityKind> = 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<Step> {
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<f64>) -> Option<f64> {
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<f64>) -> Result<f64> {
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<Step> {
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 <use first point as displacement>", 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<Step> {
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<EntityKind> {
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<EntityKind> = 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<crate::prompt::Drag> {
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<Step> {
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<Step> {
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<EntityKind> {
if self.displacement {
return Vec::new();
}
self.inner.preview(s, c)
}
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
if self.displacement {
return None;
}
self.inner.drag(s, c)
}
}
+393
View File
@@ -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<Vec2>),
}
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<Handle>) {
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<Value> {
let d = delta_of("stretch", p)?;
let space = s.space();
let listed = |key: &str, least: usize| -> Result<Option<Vec<Vec2>>> {
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<Handle>,
/// Objects selected without a window: they move whole.
whole: Vec<Handle>,
/// The selection windows and polygons: vertices inside any of them move.
windows: Vec<StretchArea>,
removing: bool,
/// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection.
mode: Option<SelectMode>,
/// First corner of the window being drawn.
corner: Option<Vec2>,
base: Option<Vec2>,
}
impl StretchM {
fn add(&mut self, s: &mut Session, hs: Vec<Handle>, 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<Step> {
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<Step> {
let whole: Vec<Handle> = 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<Step> {
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 <use first point as displacement>", Accept::POINT).base_opt(self.base),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
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<EntityKind> {
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<EntityKind> = 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<EntityKind> = 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(),
}
}
}
+106
View File
@@ -0,0 +1,106 @@
use super::*;
// ---------------- core operations ----------------
pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Vec<Handle>> {
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<usize> {
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<Value> {
let hs = targets(s, p)?;
let n = erase(s, &hs)?;
Ok(json!({ "erased": n }))
}
pub(super) fn delta_of(cmd: &str, p: &Value) -> Result<Vec2> {
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<Value> {
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<Value> {
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<Value> {
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::<Vec<_>>() }))
}
pub(super) fn run_scale(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>() }))
}
pub(super) fn run_mirror(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>() }))
}
+343
View File
@@ -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<Vec<Segment>> {
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<Vec<Segment>> {
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<Segment> {
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<Vec<Handle>> {
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<EntityKind> = match &e.kind {
EntityKind::Line(l) => {
let ln = Line::new(l.a.xy(), l.b.xy());
let mut ts: Vec<f64> = 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<f64> =
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<f64> = 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<PolyVertex> {
let mut out: Vec<PolyVertex> = 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<Vec<EntityKind>> {
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<f64> = 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<PolyVertex>| 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<Segment> = 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<f64> =
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(())
}