Files
cadcraft/crates/engine/src/cmd/modify2.rs
T

2284 lines
92 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Modify menu depth (M2): PEDIT, LENGTHEN, ALIGN, BLEND, REVERSE, SPLINEDIT, NCOPY, MOCORO, CHSPACE,
//! FLATTEN, plus the fillet/trim/extend extensions used by modify.rs (arcs, polylines,
//! ellipses and splines).
use cadcraft_doc::{Common, Entity, EntityKind, Handle, LwPolyline, MAX_BLOCK_DEPTH, Space};
use cadcraft_geom::{
Arc, Ellipse, Line, Mat3, PolyVertex, Polyline, Segment, Spline, TAU, Vec2, ccw_sweep, intersect_ext, line_circle, line_line_infinite, norm_angle,
};
use serde_json::{Value, json};
use super::curves::{self, Chain, MAX_GEN, TanObj};
use super::helpers::*;
use super::machines::{SelOutcome, SelectPhase, number};
use super::*;
use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step};
mod curve_edits;
mod fillet_pline;
mod mocoro;
pub(crate) use curve_edits::{closed_kind, join_close, join_curves};
pub(crate) use fillet_pline::{ChamferDistances, fillet_pline};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("pedit", "Polyline", run_pedit)
.menu(&["Modify", "Object", "Polyline"])
.alias(&["pe"])
.params(
"{handle | handles, option: close|open|join|width|fit|spline|decurve|reverse|ltypegen|vertex, width?, handles2? (join), fuzz?, \
action?: move|insert|straighten|break|width|regen, index?, index2? (straighten, break), to?, startWidth?, endWidth?, \
tangents?: [{index, angle (deg) | direction}] (fit), convert?: bool}",
)
.interactive(|_| Ok(Box::new(PeditM::default()))),
CommandSpec::new("splinedit", "Spline", run_splinedit)
.menu(&["Modify", "Object", "Spline"])
.alias(&["spe"])
.params(
"{handle, option: close|open|join|reverse|refit|purge|polyline|move|fitmove|fitadd|fitdelete|kink|tangents|tolerance|vertexadd|vertexmove|vertexdelete|weight|elevate, \
handles2? (join), fit?, index?, to?, before?: bool (fitadd), at? (kink, vertexadd), start?, end? (tangent directions; null = default), tolerance?, weight?, \
order? (elevate), precision?}",
)
.interactive(|_| Ok(Box::new(SplineditM::default()))),
CommandSpec::new("lengthen", "Lengthen", run_lengthen)
.menu(&["Modify", "Lengthen"])
.alias(&["len"])
.params("{handle, pick?: [x,y] (end), delta? | deltaAngle? (deg) | percent? | total? | totalAngle? (deg) | to?: [x,y]} ({handle} alone measures)")
.interactive(|_| Ok(Box::new(LengthenM::default()))),
CommandSpec::new("align", "Align", run_align)
.menu(&["Modify", "3D Operations", "Align"])
.alias(&["al"])
.params("{handles?, s1, d1, s2?, d2?, scale?: bool}")
.interactive(|_| Ok(Box::new(AlignM::default()))),
CommandSpec::new("blend", "Blend", run_blend)
.menu(&["Modify", "Blend"])
.alias(&["blendcurves"])
.params("{h1, p1, h2, p2, continuity?: tangent|smooth}")
.interactive(|_| Ok(Box::new(BlendM::default()))),
CommandSpec::new("reverse", "Reverse", run_reverse)
.params("{handles?} (lines, polylines, 3D polylines, splines)")
.interactive(|_| Ok(Box::new(SelOp::new(SelKind::Reverse)))),
CommandSpec::new("ncopy", "Copy Nested Objects", run_ncopy)
.menu(&["Modify", "Copy Nested Objects"])
.params("{handle (block reference), pick?: [x,y] (nearest nested object; default all), delta? | from,to}")
.interactive(|_| Ok(Box::new(NcopyM::default()))),
CommandSpec::new("mocoro", "Move/Copy/Rotate", mocoro::run_mocoro)
.menu(&["Tools", "Express Tools", "Move/Copy/Rotate"])
.params("{handles?, base: [x,y], ops: [{move: [x,y]} | {copy: [x,y]} | {rotate: degrees} | {scale: factor} | {base: [x,y]}]} → {handles (copies)}")
.interactive(|_| Ok(Box::new(mocoro::MocoroM::default()))),
CommandSpec::new("chspace", "Change Space", run_chspace)
.menu(&["Modify", "Change Space"])
.params("{handles?, to?: \"model\" | layout name, viewport?: handle}")
.interactive(|_| Ok(Box::new(SelOp::new(SelKind::Chspace)))),
CommandSpec::new("flatten", "Flatten Objects", run_flatten)
.menu(&["Modify", "Flatten Objects"])
.params("{handles?} (z = 0, 3D polylines become polylines)")
.interactive(|_| Ok(Box::new(SelOp::new(SelKind::Flatten)))),
CommandSpec::new("arraypath", "Path Array", run_arraypath)
.menu(&["Modify", "Array", "Path Array"])
.params("{handles?, path (handle), count? (default 6) | spacing?, align?: bool (default true)}")
.interactive(|_| Ok(Box::new(PathArrayM::default()))),
CommandSpec::new("textedit", "Edit Text...", run_textedit)
.menu(&["Modify", "Object", "Edit Text..."])
.alias(&["ed", "ddedit"])
.params("{handle, text} (text, mtext, attribute definitions, dimension text override)")
.interactive(|_| Ok(Box::new(TextEditM::default()))),
]
}
fn other(m: impl Into<String>) -> EngineError {
EngineError::Other(m.into())
}
fn set_kind(s: &mut Session, h: Handle, k: EntityKind) -> Result<()> {
s.doc_mut()?.modify_entity(h, |e| e.kind = k)?;
Ok(())
}
fn push_entity(s: &mut Session, space: &Space, common: Common, kind: EntityKind) -> Result<Handle> {
let d = s.doc_mut()?;
d.ensure_layer(&common.layer);
let h = d.new_handle();
let st = d.space_mut(space).ok_or_else(|| other("no such space"))?;
st.push(Entity { handle: h, common, kind });
Ok(h)
}
// =====================================================================================
// PEDIT
// =====================================================================================
/// The polyline form of a line, arc or polyline.
pub(crate) fn as_lwpoly(k: &EntityKind) -> Option<LwPolyline> {
match k {
EntityKind::LwPolyline(p) => Some(p.clone()),
EntityKind::Line(_) | EntityKind::Arc(_) => {
let c = Chain::of(k)?;
Some(LwPolyline { vertices: c.vertices(), closed: false, const_width: 0.0, elevation: 0.0, plinegen: false })
}
_ => None,
}
}
/// Fit curve: a tangent-continuous pair of arcs (biarc) per segment through every vertex,
/// following the tangent directions given per vertex (Edit vertex Tangent).
pub(crate) fn fit_vertices(vs: &[PolyVertex], closed: bool, tans: &[Option<Vec2>]) -> Vec<PolyVertex> {
let pts: Vec<Vec2> = vs.iter().map(|v| v.p).collect();
let n = pts.len();
if n < 3 {
return vs.iter().map(|v| PolyVertex { bulge: 0.0, ..*v }).collect();
}
let at = |i: usize| pts.get(i).copied().unwrap_or_default();
let tangent = |i: usize| -> Vec2 {
let (prev, next) = if closed {
(at((i + n - 1) % n), at((i + 1) % n))
} else if i == 0 {
(at(0), at(1))
} else if i + 1 == n {
(at(n - 2), at(n - 1))
} else {
(at(i - 1), at(i + 1))
};
let t = (next - prev).normalized();
if t == Vec2::ZERO { Vec2::X } else { t }
};
// Open ends: mirror the neighbouring tangent across the chord for a natural end.
let tan_at = |i: usize| -> Vec2 {
if let Some(Some(t)) = tans.get(i) {
return *t;
}
if closed || (i != 0 && i + 1 != n) {
return tangent(i);
}
let (a, b, tb) = if i == 0 { (at(0), at(1), tangent(1)) } else { (at(n - 1), at(n - 2), -tangent(n - 2)) };
let c = (b - a).normalized();
let m = c * (2.0 * tb.dot(c)) - tb;
let m = if i == 0 { m } else { -m };
if m.len() < 1e-12 { c } else { m.normalized() }
};
let count = if closed { n } else { n - 1 };
let mut out = Vec::with_capacity(count * 2 + 1);
for i in 0..count {
let p0 = at(i);
let p1 = at((i + 1) % n);
let t0 = tan_at(i);
let t1 = tan_at((i + 1) % n);
let v = p1 - p0;
let tsum = t0 + t1;
let denom = 2.0 * (1.0 - t0.dot(t1));
let d = if denom.abs() < 1e-12 {
let vt = v.dot(t1);
if vt.abs() < 1e-12 { v.len() / 2.0 } else { v.len2() / (4.0 * vt) }
} else {
let vt = v.dot(tsum);
(-vt + (vt * vt + denom * v.len2()).max(0.0).sqrt()) / denom
};
let pm = (p0 + t0 * d + p1 - t1 * d) / 2.0;
let b1 = curves::bulge_from_tangent(p0, pm, t0);
let b2 = -curves::bulge_from_tangent(p1, pm, -t1);
let w = vs.get(i).copied().unwrap_or_default();
out.push(PolyVertex { p: p0, bulge: if b1.is_finite() { b1 } else { 0.0 }, start_width: w.start_width, end_width: w.end_width });
out.push(PolyVertex { p: pm, bulge: if b2.is_finite() { b2 } else { 0.0 }, start_width: w.end_width, end_width: w.end_width });
}
if !closed {
out.push(PolyVertex::new(at(n - 1)));
}
out
}
/// Spline curve: a cubic B-spline with the vertices as its frame (control points).
pub(crate) fn spline_vertices(vs: &[PolyVertex], closed: bool, segs: usize) -> Vec<PolyVertex> {
let pts: Vec<Vec2> = vs.iter().map(|v| v.p).collect();
let n = pts.len();
if n < 3 {
return vs.to_vec();
}
let segs = segs.clamp(1, 64).min((MAX_GEN / n).max(1));
let mut out: Vec<Vec2> = Vec::new();
if closed {
// Uniform periodic cubic B-spline.
let at = |i: usize| pts.get(i % n).copied().unwrap_or_default();
for i in 0..n {
let (p0, p1, p2, p3) = (at(i + n - 1), at(i), at(i + 1), at(i + 2));
for k in 0..segs {
let t = k as f64 / segs as f64;
let (t2, t3) = (t * t, t * t * t);
let b0 = (1.0 - t).powi(3) / 6.0;
let b1 = (3.0 * t3 - 6.0 * t2 + 4.0) / 6.0;
let b2 = (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0) / 6.0;
let b3 = t3 / 6.0;
out.push(p0 * b0 + p1 * b1 + p2 * b2 + p3 * b3);
}
}
} else {
let sp = Spline::from_control(pts.clone(), 3);
let (lo, hi) = sp.domain();
let m = (segs * (n - 1)).min(MAX_GEN);
for k in 0..=m {
out.push(sp.eval(lo + (hi - lo) * k as f64 / m as f64));
}
}
out.into_iter().map(PolyVertex::new).collect()
}
/// Join connected lines/arcs/open polylines onto the ends of polyline `h` (kept in place).
pub(crate) fn pedit_join(s: &mut Session, h: Handle, others: &[Handle], fuzz: f64) -> Result<usize> {
let e = curves::entity(s, h)?;
let mut base = as_lwpoly(&e.kind).ok_or_else(|| other("Select a polyline."))?;
if base.closed {
return Err(other("Cannot join to a closed polyline."));
}
let mut chain = Chain::of(&EntityKind::LwPolyline(base.clone())).unwrap_or_default();
let tol = fuzz.max(1e-6);
let mut pool: Vec<(Handle, Chain)> = Vec::new();
for o in others {
if *o == h {
continue;
}
let Ok(oe) = curves::entity(s, *o) else { continue };
let ok = matches!(&oe.kind, EntityKind::Line(_) | EntityKind::Arc(_)) || matches!(&oe.kind, EntityKind::LwPolyline(p) if !p.closed);
if ok && let Some(c) = Chain::of(&oe.kind) {
pool.push((*o, c));
}
}
let mut joined = Vec::new();
while let (Some(st), Some(en)) = (chain.start(), chain.end()) {
let Some(i) = pool
.iter()
.position(|(_, c)| c.start().zip(c.end()).is_some_and(|(a, b)| a.near(en, tol) || b.near(en, tol) || a.near(st, tol) || b.near(st, tol)))
else {
break;
};
let (oh, c) = pool.remove(i);
let (Some(a), Some(b)) = (c.start(), c.end()) else { continue };
if a.near(en, tol) {
chain.segs.extend(c.segs);
} else if b.near(en, tol) {
chain.segs.extend(c.reversed().segs);
} else if b.near(st, tol) {
let mut v = c.segs;
v.extend(chain.segs);
chain.segs = v;
} else {
let mut v = c.reversed().segs;
v.extend(chain.segs);
chain.segs = v;
}
joined.push(oh);
}
if joined.is_empty() {
return Ok(0);
}
chain.closed = chain.start().zip(chain.end()).is_some_and(|(a, b)| a.near(b, tol)) && chain.segs.len() > 1;
base.vertices = chain.vertices();
base.closed = chain.closed;
let d = s.doc_mut()?;
d.modify_entity(h, |e| e.kind = EntityKind::LwPolyline(base))?;
for o in &joined {
d.remove_entity(*o);
}
Ok(joined.len())
}
fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value> {
let e = curves::entity(s, h)?;
if curves::is_locked(s, h) {
return Err(other("The object is on a locked layer."));
}
let mut pl = match &e.kind {
EntityKind::LwPolyline(pl) => pl.clone(),
EntityKind::Line(_) | EntityKind::Arc(_) if bool_or(p, "convert", true) => as_lwpoly(&e.kind).ok_or_else(|| other("Cannot convert."))?,
_ => return Err(other("Object selected is not a polyline.")),
};
match opt {
"close" | "c" => pl.closed = pl.vertices.len() > 1,
"open" | "o" => pl.closed = false,
"width" | "w" => {
let w = f64_req("pedit", p, "width")?;
if w < 0.0 {
return Err(bad("pedit", "width must be non-negative"));
}
pl.const_width = w;
for v in &mut pl.vertices {
v.start_width = 0.0;
v.end_width = 0.0;
}
}
"fit" | "f" => pl.vertices = fit_vertices(&pl.vertices, pl.closed, &pedit_vertex::tangent_param(p, pl.vertices.len())?),
"spline" | "s" => {
let segs = s.doc()?.header.i64("SPLINESEGS", 8).clamp(1, 64) as usize;
pl.vertices = spline_vertices(&pl.vertices, pl.closed, segs);
}
"decurve" | "d" => pl.vertices.iter_mut().for_each(|v| v.bulge = 0.0),
"reverse" | "r" => pl.vertices = curves::reverse_vertices(&pl.vertices, pl.closed),
"ltypegen" | "l" => pl.plinegen = bool_or(p, "on", !pl.plinegen),
"join" | "j" => {
set_kind(s, h, EntityKind::LwPolyline(pl))?;
let others: Vec<Handle> = p
.get("handles2")
.or_else(|| p.get("join"))
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(curves::handle_value).collect())
.unwrap_or_default();
let n = pedit_join(s, h, &others, f64_or(p, "fuzz", 0.0))?;
return Ok(json!({ "handle": h.hex(), "joined": n }));
}
"vertex" | "e" => {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("pedit", "`index` is required"))? as usize;
let n = pl.vertices.len();
if i >= n {
return Err(bad("pedit", "vertex index out of range"));
}
match str_param(p, "action").unwrap_or("move") {
"move" => {
let to = point_req("pedit", p, "to")?;
if let Some(v) = pl.vertices.get_mut(i) {
v.p = to;
}
}
"insert" => {
let to = point_req("pedit", p, "to")?;
if n >= MAX_GEN {
return Err(bad("pedit", "too many vertices"));
}
if let Some(v) = pl.vertices.get_mut(i) {
v.bulge = 0.0;
}
pl.vertices.insert(i + 1, PolyVertex::new(to));
}
"straighten" => {
let j = p.get("index2").and_then(Value::as_u64).map_or(i, |j| j as usize);
pedit_vertex::straighten(&mut pl, i, j)?;
}
"break" => {
let j = p.get("index2").and_then(Value::as_u64).map_or(i, |j| j as usize);
let hs = pedit_vertex::break_poly(s, h, &pl, i, j)?;
return Ok(json!({ "handle": h.hex(), "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }));
}
// The display is always current; tangents only exist within an interactive PEDIT.
"regen" => {}
"width" => {
let sw = f64_or(p, "startWidth", 0.0).max(0.0);
let ew = f64_or(p, "endWidth", sw).max(0.0);
if let Some(v) = pl.vertices.get_mut(i) {
v.start_width = sw;
v.end_width = ew;
}
}
_ => return Err(bad("pedit", "action must be move, insert, straighten, break, width or regen")),
}
}
_ => return Err(bad("pedit", "unknown option")),
}
set_kind(s, h, EntityKind::LwPolyline(pl))?;
Ok(json!({ "handle": h.hex() }))
}
fn run_pedit(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
if hs.is_empty() {
return Err(bad("pedit", "`handle` is required"));
}
let opt = str_param(p, "option").ok_or_else(|| bad("pedit", "`option` is required"))?.to_ascii_lowercase();
let mut out = Vec::new();
for h in hs {
out.push(pedit_apply(s, h, &opt, p)?);
}
Ok(if out.len() == 1 { out.pop().unwrap_or(Value::Null) } else { json!({ "results": out }) })
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum PPhase {
#[default]
Select,
Convert,
Main,
Width,
Join,
Vertex,
VMove,
VInsert,
VWidthStart,
VWidthEnd,
/// The second marker of Break / Straighten.
VMark,
VTangent,
}
mod pedit_vertex;
#[derive(Default)]
struct PeditM {
phase: PPhase,
h: Option<Handle>,
history: Vec<EntityKind>,
original: Option<Vec<PolyVertex>>,
join: SelectPhase,
vertex: usize,
vwidth: f64,
/// Break / Straighten: the second marked vertex, and which of the two runs.
mark2: usize,
breaking: bool,
/// Edit vertex Tangent directions by vertex position, used by Fit.
tangents: Vec<(Vec2, Vec2)>,
/// Pieces split off by Break, with the history length after it (erased again by Undo).
split_off: Vec<(usize, Vec<Handle>)>,
}
impl PeditM {
fn poly(&self, s: &Session) -> Option<LwPolyline> {
let e = s.doc().ok()?.entity(self.h?)?.clone();
match &e.kind {
EntityKind::LwPolyline(p) => Some(p.clone()),
_ => None,
}
}
fn snapshot(&mut self, s: &Session) {
if let Some(h) = self.h
&& let Ok(e) = curves::entity(s, h)
{
self.history.push(e.kind);
}
}
fn apply(&mut self, s: &mut Session, opt: &str, p: Value) -> Result<()> {
let h = self.h.ok_or_else(|| other("No polyline."))?;
self.snapshot(s);
if let Err(e) = pedit_apply(s, h, opt, &p) {
self.history.pop();
return Err(e);
}
Ok(())
}
}
impl Interactive for PeditM {
fn name(&self) -> &'static str {
"PEDIT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let sel = s.selection();
if let [h] = sel.as_slice() {
let k = curves::entity(s, *h)?.kind;
match k {
EntityKind::LwPolyline(_) => {
self.h = Some(*h);
self.original = self.poly(s).map(|p| p.vertices);
self.phase = PPhase::Main;
}
EntityKind::Line(_) | EntityKind::Arc(_) => {
self.h = Some(*h);
self.phase = PPhase::Convert;
}
_ => {}
}
}
Ok(Step::Continue)
}
fn prompt(&self, s: &Session) -> Prompt {
match self.phase {
PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]),
PPhase::Convert => {
Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y")
}
PPhase::Main => {
let closed = self.poly(s).is_some_and(|p| p.closed);
let first = if closed { "Open" } else { "Close" };
Prompt::new("Enter an option", curves::KW).kw(&[
first,
"Join",
"Width",
"Edit vertex",
"Fit",
"Spline",
"Decurve",
"Ltype gen",
"Reverse",
"Undo",
])
}
PPhase::Width => Prompt::new("Specify new width for all segments", Accept::NUMBER),
PPhase::Join => self.join.prompt(),
PPhase::Vertex => Prompt::new("Enter a vertex editing option", curves::KW)
.kw(&["Next", "Previous", "Break", "Insert", "Move", "Regen", "Straighten", "Tangent", "Width", "eXit"])
.default("N"),
PPhase::VMove => Prompt::new("Specify new location for marked vertex", Accept::POINT),
PPhase::VInsert => Prompt::new("Specify location for new vertex", Accept::POINT),
PPhase::VWidthStart => Prompt::new("Specify starting width for next segment", Accept::NUMBER),
PPhase::VWidthEnd => Prompt::new("Specify ending width for next segment", Accept::NUMBER).default(format!("{:.4}", self.vwidth)),
PPhase::VMark => Prompt::new("Enter an option", curves::KW).kw(&["Next", "Previous", "Go", "eXit"]).default("N"),
PPhase::VTangent => Prompt::new("Specify direction of vertex tangent", Accept::POINT_OR_NUMBER)
.base_opt(self.poly(s).and_then(|p| p.vertices.get(self.vertex).map(|v| v.p))),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let kw = |i: &Input| match i {
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()),
_ => None,
};
match self.phase {
PPhase::Select => match i {
Input::Point(p) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
let k = curves::entity(s, h)?.kind;
self.h = Some(h);
match k {
EntityKind::LwPolyline(pl) => {
self.original = Some(pl.vertices);
self.phase = PPhase::Main;
}
EntityKind::Line(_) | EntityKind::Arc(_) => self.phase = PPhase::Convert,
_ => {
self.h = None;
return Err(other("Object selected is not a polyline."));
}
}
s.set_selection(vec![h]);
Ok(Step::Continue)
}
Input::Keyword(_) => Err(other("Select one polyline (Multiple is available through the JSON form).")),
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
},
PPhase::Convert => {
let no = kw(&i).is_some_and(|k| k.starts_with('n'));
if no {
return Ok(Step::Done);
}
let h = self.h.ok_or_else(|| other("No object."))?;
let k = curves::entity(s, h)?.kind;
let pl = as_lwpoly(&k).ok_or_else(|| other("Cannot convert."))?;
self.original = Some(pl.vertices.clone());
set_kind(s, h, EntityKind::LwPolyline(pl))?;
self.phase = PPhase::Main;
Ok(Step::Continue)
}
PPhase::Main => {
let Some(k) = kw(&i) else {
if i == Input::Enter {
s.set_selection(Vec::new());
return Ok(Step::Done);
}
return Ok(Step::Continue);
};
match k.as_str() {
"open" | "o" => self.apply(s, "open", json!({}))?,
"close" | "c" => self.apply(s, "close", json!({}))?,
"join" | "j" => {
self.join = SelectPhase::default();
self.phase = PPhase::Join;
}
"width" | "w" => self.phase = PPhase::Width,
"edit vertex" | "e" => {
self.vertex = 0;
self.phase = PPhase::Vertex;
}
"fit" | "f" => self.apply(s, "fit", json!({ "tangents": self.tangents_json(s) }))?,
"spline" | "s" => self.apply(s, "spline", json!({}))?,
"decurve" | "d" => {
// Within the session, Decurve restores the frame before Fit/Spline.
let h = self.h.ok_or_else(|| other("No polyline."))?;
self.snapshot(s);
match (self.poly(s), self.original.clone()) {
(Some(mut pl), Some(orig)) => {
pl.vertices = orig.into_iter().map(|v| PolyVertex { bulge: 0.0, ..v }).collect();
set_kind(s, h, EntityKind::LwPolyline(pl))?;
}
_ => {
self.history.pop();
self.apply(s, "decurve", json!({}))?;
}
}
}
"ltype gen" | "l" => self.apply(s, "ltypegen", json!({}))?,
"reverse" | "r" => self.apply(s, "reverse", json!({}))?,
"undo" | "u" => {
self.undo_split(s)?;
if let (Some(h), Some(k)) = (self.h, self.history.pop()) {
set_kind(s, h, k)?;
}
}
_ => return Err(other("Invalid option keyword.")),
}
Ok(Step::Continue)
}
PPhase::Width => {
if let Input::Text(t) = &i {
let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?;
self.apply(s, "width", json!({ "width": w }))?;
}
self.phase = PPhase::Main;
Ok(Step::Continue)
}
PPhase::Join => match self.join.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => {
self.phase = PPhase::Main;
Ok(Step::Continue)
}
SelOutcome::Done(hs) => {
let h = self.h.ok_or_else(|| other("No polyline."))?;
self.snapshot(s);
let n = pedit_join(s, h, &hs, 0.0)?;
s.echo(format!("{n} segments added to polyline"));
s.set_selection(vec![h]);
self.phase = PPhase::Main;
Ok(Step::Continue)
}
},
PPhase::Vertex => {
let n = self.poly(s).map(|p| p.vertices.len()).unwrap_or(0).max(1);
let k = kw(&i).unwrap_or_else(|| if i == Input::Enter { "n".into() } else { String::new() });
match k.as_str() {
"next" | "n" => self.vertex = (self.vertex + 1) % n,
"previous" | "p" => self.vertex = (self.vertex + n - 1) % n,
"move" | "m" => self.phase = PPhase::VMove,
"insert" | "i" => self.phase = PPhase::VInsert,
"straighten" | "s" | "break" | "b" => {
self.breaking = k.starts_with('b');
self.mark2 = self.vertex;
self.phase = PPhase::VMark;
}
"tangent" | "t" => self.phase = PPhase::VTangent,
// The display is always current.
"regen" | "r" => {}
"width" | "w" => self.phase = PPhase::VWidthStart,
"exit" | "x" => self.phase = PPhase::Main,
"" => {}
_ => return Err(other("Invalid option keyword.")),
}
Ok(Step::Continue)
}
PPhase::VMove | PPhase::VInsert => {
if let Input::Point(p) = i {
let action = if self.phase == PPhase::VMove { "move" } else { "insert" };
self.apply(s, "vertex", json!({ "action": action, "index": self.vertex, "to": [p.x, p.y] }))?;
if action == "insert" {
self.vertex += 1;
}
}
self.phase = PPhase::Vertex;
Ok(Step::Continue)
}
PPhase::VWidthStart => {
if let Input::Text(t) = &i {
self.vwidth = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?;
self.phase = PPhase::VWidthEnd;
} else {
self.phase = PPhase::Vertex;
}
Ok(Step::Continue)
}
PPhase::VMark => {
let k = kw(&i).unwrap_or_else(|| if i == Input::Enter { "n".into() } else { String::new() });
self.mark_input(s, &k)?;
Ok(Step::Continue)
}
PPhase::VTangent => {
self.tangent_input(s, &i)?;
Ok(Step::Continue)
}
PPhase::VWidthEnd => {
let ew = match &i {
Input::Text(t) => number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?,
_ => self.vwidth,
};
self.apply(s, "vertex", json!({ "action": "width", "index": self.vertex, "startWidth": self.vwidth, "endWidth": ew }))?;
self.phase = PPhase::Vertex;
Ok(Step::Continue)
}
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if !matches!(
self.phase,
PPhase::Vertex | PPhase::VMove | PPhase::VInsert | PPhase::VWidthStart | PPhase::VWidthEnd | PPhase::VMark | PPhase::VTangent
) {
return Vec::new();
}
let Some(v) = self.poly(s).and_then(|p| p.vertices.get(self.vertex).copied()) else { return Vec::new() };
let k = s.pixel_size() * 8.0;
let mut out = vec![line(v.p - Vec2::new(k, k), v.p + Vec2::new(k, k)), line(v.p - Vec2::new(k, -k), v.p + Vec2::new(k, -k))];
if matches!(self.phase, PPhase::VMove | PPhase::VInsert | PPhase::VTangent) {
out.push(line(v.p, c));
}
if let (PPhase::VMark, Some(m)) = (self.phase, self.poly(s).and_then(|p| p.vertices.get(self.mark2).copied())) {
out.extend([line(m.p - Vec2::new(k, k), m.p + Vec2::new(k, k)), line(m.p - Vec2::new(k, -k), m.p + Vec2::new(k, -k))]);
}
out
}
}
// =====================================================================================
// SPLINEDIT
// =====================================================================================
mod splinedit;
use splinedit::{SplineditM, run_splinedit};
// =====================================================================================
// LENGTHEN
// =====================================================================================
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum LenMode {
Delta(f64),
DeltaAngle(f64),
Percent(f64),
Total(f64),
TotalAngle(f64),
To(Vec2),
}
/// Current length (and included angle for arcs) of a curve.
fn measure_len(k: &EntityKind) -> Option<(f64, Option<f64>)> {
match k {
EntityKind::Arc(a) => {
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
Some((g.len(), Some(g.sweep())))
}
EntityKind::Ellipse(e) => Some((ellipse_of(e).length(), None)),
EntityKind::Spline(sp) if sp.is_valid() => Some((sp.length(), None)),
_ => Chain::of(k).map(|c| (c.len(), None)),
}
}
/// Lengthen a line, arc, open polyline, elliptical arc or open spline at the end nearest `pick`.
pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -> Result<EntityKind> {
let too_short = || other("Lengthening would make the object zero length or negative.");
match k {
EntityKind::Line(l) => {
let (a, b) = (l.a.xy(), l.b.xy());
let at_b = pick.is_none_or(|p| p.dist(b) <= p.dist(a));
let (fixed, end) = if at_b { (a, b) } else { (b, a) };
let len = fixed.dist(end);
let dir = (end - fixed).normalized();
if dir == Vec2::ZERO {
return Err(other("Zero-length line."));
}
let nl = match mode {
LenMode::Delta(d) => len + d,
LenMode::Percent(p) => len * p / 100.0,
LenMode::Total(t) => t,
LenMode::To(q) => (q - fixed).dot(dir),
LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")),
};
if !(nl > 1e-12 && nl.is_finite()) {
return Err(too_short());
}
let ne = fixed + dir * nl;
let mut out = l.clone();
if at_b {
out.b = ne.to3(l.b.z);
} else {
out.a = ne.to3(l.a.z);
}
Ok(EntityKind::Line(out))
}
EntityKind::Arc(ar) => {
let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end);
let at_end = pick.is_none_or(|p| p.dist(g.end_point()) <= p.dist(g.start_point()));
let sw = g.sweep();
let r = g.radius.max(1e-300);
let ns = match mode {
LenMode::Delta(d) => sw + d / r,
LenMode::DeltaAngle(a) => sw + a,
LenMode::Percent(p) => sw * p / 100.0,
LenMode::Total(t) => t / r,
LenMode::TotalAngle(a) => a,
LenMode::To(q) => {
let a = g.center.angle_to(q);
if at_end { ccw_sweep(g.start, a) } else { ccw_sweep(a, g.end) }
}
};
if !(ns > 1e-12 && ns < TAU - 1e-9) {
return Err(other("The new arc would be zero length or a full circle."));
}
let na = if at_end { Arc::new(g.center, g.radius, g.start, g.start + ns) } else { Arc::new(g.center, g.radius, g.end - ns, g.end) };
Ok(EntityKind::Arc(cadcraft_doc::Arc { radius: na.radius, start: na.start, end: na.end, ..ar.clone() }))
}
EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => {
let total = Polyline { vertices: p.vertices.clone(), closed: false }.len();
let mut vs = p.vertices.clone();
let n = vs.len();
let (first, last) = (vs.first().map(|v| v.p).unwrap_or_default(), vs.last().map(|v| v.p).unwrap_or_default());
let at_end = pick.is_none_or(|q| q.dist(last) <= q.dist(first));
if !at_end {
vs = curves::reverse_vertices(&vs, false);
}
let seg_start = vs.get(n - 2).copied().unwrap_or_default();
if seg_start.bulge.abs() > 1e-12 {
return Err(other("The end segment is an arc; explode the polyline or use an open line end."));
}
let end = vs.get(n - 1).map(|v| v.p).unwrap_or_default();
let seg_len = seg_start.p.dist(end);
let dir = (end - seg_start.p).normalized();
if dir == Vec2::ZERO {
return Err(other("Zero-length end segment."));
}
let delta = match mode {
LenMode::Delta(d) => d,
LenMode::Percent(pc) => total * pc / 100.0 - total,
LenMode::Total(t) => t - total,
LenMode::To(q) => (q - seg_start.p).dot(dir) - seg_len,
LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")),
};
let nl = seg_len + delta;
if !(nl > 1e-12 && nl.is_finite()) {
return Err(too_short());
}
if let Some(v) = vs.get_mut(n - 1) {
v.p = seg_start.p + dir * nl;
}
if !at_end {
vs = curves::reverse_vertices(&vs, false);
}
Ok(EntityKind::LwPolyline(LwPolyline { vertices: vs, ..p.clone() }))
}
EntityKind::Ellipse(e) => curve_edits::lengthen_ellipse(e, pick, mode),
EntityKind::Spline(sp) => curve_edits::lengthen_spline(sp, pick, mode),
_ => Err(other("Select a line, arc, open polyline, elliptical arc or open spline.")),
}
}
fn len_mode(p: &Value) -> Option<LenMode> {
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
if let Some(v) = f("delta") {
Some(LenMode::Delta(v))
} else if let Some(v) = f("deltaAngle") {
Some(LenMode::DeltaAngle(v.to_radians()))
} else if let Some(v) = f("percent") {
Some(LenMode::Percent(v))
} else if let Some(v) = f("total") {
Some(LenMode::Total(v))
} else if let Some(v) = f("totalAngle") {
Some(LenMode::TotalAngle(v.to_radians()))
} else {
point_param(p, "to").map(LenMode::To)
}
}
fn run_lengthen(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("lengthen", "`handle` is required"))?;
let e = curves::entity(s, h)?;
let Some(mode) = len_mode(p) else {
let (l, a) = measure_len(&e.kind).ok_or_else(|| other("Cannot measure that object."))?;
return Ok(json!({ "length": l, "angle": a.map(f64::to_degrees) }));
};
if curves::is_locked(s, h) {
return Err(other("The object is on a locked layer."));
}
let k = lengthen_kind(&e.kind, point_param(p, "pick"), mode)?;
let l = measure_len(&k).map(|x| x.0).unwrap_or(0.0);
set_kind(s, h, k)?;
Ok(json!({ "handle": h.hex(), "length": l }))
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum LPhase {
#[default]
Main,
Delta,
DeltaAngle,
Percent,
Total,
TotalAngle,
Change,
DynEnd(Handle, Vec2),
}
#[derive(Default)]
struct LengthenM {
phase: LPhase,
mode: Option<LenMode>,
dynamic: bool,
history: Vec<(Handle, EntityKind)>,
}
impl Interactive for LengthenM {
fn name(&self) -> &'static str {
"LENGTHEN"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.set_selection(Vec::new());
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match self.phase {
LPhase::Main => {
Prompt::new("Select an object to measure or", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic")
}
LPhase::Delta => Prompt::new("Enter delta length or", Accept::NUMBER).kw(&["Angle"]).default("0.0000"),
LPhase::DeltaAngle => Prompt::new("Enter delta angle", Accept::NUMBER).default("0"),
LPhase::Percent => Prompt::new("Enter percentage length", Accept::NUMBER).default("100.0000"),
LPhase::Total => Prompt::new("Specify total length or", Accept::NUMBER).kw(&["Angle"]).default("1.0000"),
LPhase::TotalAngle => Prompt::new("Specify total angle", Accept::NUMBER).default("57"),
LPhase::Change => Prompt::new("Select an object to change or", Accept::POINT).kw(&["Undo"]),
LPhase::DynEnd(..) => Prompt::new("Specify new end point", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
let num = |i: &Input| -> Option<f64> { if let Input::Text(t) = i { number(t) } else { None } };
let au = s.angle_settings();
let ang = |i: &Input| -> Option<f64> { if let Input::Text(t) = i { au.amount(t) } else { None } };
match (self.phase, &i) {
(LPhase::Main, Input::Point(p)) => {
let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?;
let k = curves::entity(s, h)?.kind;
match measure_len(&k) {
Some((l, Some(a))) => s.echo(format!("Current length: {l:.4}, included angle: {:.0}", a.to_degrees())),
Some((l, None)) => s.echo(format!("Current length: {l:.4}")),
None => s.echo("Cannot measure that object."),
}
Ok(Step::Continue)
}
(LPhase::Main, Input::Keyword(k)) => {
self.phase = match k.as_str() {
"DElta" => LPhase::Delta,
"Percent" => LPhase::Percent,
"Total" => LPhase::Total,
_ => {
self.dynamic = true;
LPhase::Change
}
};
Ok(Step::Continue)
}
(LPhase::Main, Input::Enter) => {
self.dynamic = true;
self.phase = LPhase::Change;
Ok(Step::Continue)
}
(LPhase::Delta | LPhase::Total, Input::Keyword(_)) => {
self.phase = if self.phase == LPhase::Delta { LPhase::DeltaAngle } else { LPhase::TotalAngle };
Ok(Step::Continue)
}
(LPhase::Delta, _) => {
self.mode = Some(LenMode::Delta(num(&i).ok_or_else(|| other("Requires a distance."))?));
self.phase = LPhase::Change;
Ok(Step::Continue)
}
(LPhase::DeltaAngle, _) => {
self.mode = Some(LenMode::DeltaAngle(ang(&i).ok_or_else(|| other("Requires an angle."))?));
self.phase = LPhase::Change;
Ok(Step::Continue)
}
(LPhase::Percent, _) => {
let v = num(&i).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive percentage."))?;
self.mode = Some(LenMode::Percent(v));
self.phase = LPhase::Change;
Ok(Step::Continue)
}
(LPhase::Total, _) => {
let v = num(&i).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive length."))?;
self.mode = Some(LenMode::Total(v));
self.phase = LPhase::Change;
Ok(Step::Continue)
}
(LPhase::TotalAngle, _) => {
self.mode = Some(LenMode::TotalAngle(ang(&i).ok_or_else(|| other("Requires an angle."))?));
self.phase = LPhase::Change;
Ok(Step::Continue)
}
(LPhase::Change, Input::Point(p)) => {
let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?;
if self.dynamic {
self.phase = LPhase::DynEnd(h, *p);
return Ok(Step::Continue);
}
let mode = self.mode.ok_or_else(|| other("No lengthen mode."))?;
let k = curves::entity(s, h)?.kind;
let nk = lengthen_kind(&k, Some(*p), mode)?;
set_kind(s, h, nk)?;
self.history.push((h, k));
Ok(Step::Continue)
}
(LPhase::DynEnd(h, pick), Input::Point(p)) => {
let k = curves::entity(s, h)?.kind;
let nk = lengthen_kind(&k, Some(pick), LenMode::To(*p))?;
set_kind(s, h, nk)?;
self.history.push((h, k));
self.phase = LPhase::Change;
Ok(Step::Continue)
}
(LPhase::Change, Input::Keyword(_)) => {
if let Some((h, k)) = self.history.pop() {
set_kind(s, h, k)?;
}
Ok(Step::Continue)
}
(_, Input::Enter) => Ok(Step::Done),
_ => Err(other("Invalid input.")),
}
}
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
if let LPhase::DynEnd(h, pick) = self.phase
&& let Ok(e) = curves::entity(s, h)
{
return lengthen_kind(&e.kind, Some(pick), LenMode::To(c)).map(|k| vec![k]).unwrap_or_default();
}
Vec::new()
}
}
// =====================================================================================
// ALIGN
// =====================================================================================
pub(crate) fn align_matrix(s1: Vec2, d1: Vec2, pair2: Option<(Vec2, Vec2)>, scale: bool) -> Mat3 {
let mut m = Mat3::translate(d1 - s1);
if let Some((s2, d2)) = pair2 {
let vs = s2 - s1;
let vd = d2 - d1;
if vs.len() > 1e-12 && vd.len() > 1e-12 {
m = Mat3::rotate_about(d1, vd.angle() - vs.angle()).then_before(m);
if scale {
m = Mat3::scale_about(d1, vd.len() / vs.len()).then_before(m);
}
}
}
m
}
fn run_align(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let s1 = point_req("align", p, "s1")?;
let d1 = point_req("align", p, "d1")?;
let pair2 = point_param(p, "s2").zip(point_param(p, "d2"));
let m = align_matrix(s1, d1, pair2, bool_or(p, "scale", false));
let r = super::modify::transform_entities(s, &hs, &m, false)?;
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
#[derive(Default)]
struct AlignM {
sel: SelectPhase,
objs: Vec<Handle>,
pts: Vec<Vec2>,
ask_scale: bool,
}
impl AlignM {
fn finish(&self, s: &mut Session, scale: bool) -> Result<Step> {
let (Some(s1), Some(d1)) = (self.pts.first().copied(), self.pts.get(1).copied()) else { return Ok(Step::Cancel) };
let pair2 = self.pts.get(2).copied().zip(self.pts.get(3).copied());
let m = align_matrix(s1, d1, pair2, scale);
super::modify::transform_entities(s, &self.objs, &m, false)?;
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
impl Interactive for AlignM {
fn name(&self) -> &'static str {
"ALIGN"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
self.objs = self.sel.picked.clone();
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if !self.sel.done {
return self.sel.prompt();
}
if self.ask_scale {
return Prompt::new("Scale objects based on alignment points?", curves::KW).kw(&["Yes", "No"]).default("N");
}
match self.pts.len() {
0 => Prompt::new("Specify first source point", Accept::POINT),
1 => Prompt::new("Specify first destination point", Accept::POINT).base_opt(self.pts.first().copied()),
2 => Prompt::new("Specify second source point", Accept::POINT),
3 => Prompt::new("Specify second destination point", Accept::POINT).base_opt(self.pts.get(2).copied()),
_ => Prompt::new("Specify third source point or <continue>", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.sel.done {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Cancel),
SelOutcome::Done(hs) => {
self.objs = hs;
Ok(Step::Continue)
}
};
}
if self.ask_scale {
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('y'));
return self.finish(s, yes);
}
match i {
Input::Point(p) => {
if self.pts.len() < 4 {
self.pts.push(p);
} else {
s.echo("Third pair ignored (2D align).");
self.ask_scale = true;
}
Ok(Step::Continue)
}
Input::Enter => match self.pts.len() {
n if n >= 4 => {
self.ask_scale = true;
Ok(Step::Continue)
}
2 | 3 => {
self.pts.truncate(2);
self.finish(s, false)
}
_ => Ok(Step::Cancel),
},
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
match self.pts.as_slice() {
[a] => vec![line(*a, c)],
[_, _, a] => vec![line(*a, c)],
_ => Vec::new(),
}
}
}
// =====================================================================================
// BLEND
// =====================================================================================
/// Endpoint of a curve nearest `pick` and the outward unit tangent there.
fn curve_end(k: &EntityKind, pick: Vec2) -> Option<(Vec2, Vec2)> {
let c = Chain::of(k)?;
if c.closed {
return None;
}
let (a, b) = (c.start()?, c.end()?);
if pick.dist(b) <= pick.dist(a) {
let t = c.segs.last()?.tangent(1.0);
Some((b, t))
} else {
let t = c.segs.first()?.tangent(0.0);
Some((a, -t))
}
}
pub(crate) fn blend_spline(e1: (Vec2, Vec2), e2: (Vec2, Vec2), smooth: bool) -> Option<Spline> {
let (p1, t1) = e1;
let (p2, t2) = e2;
let d = p1.dist(p2);
if d < 1e-12 {
return None;
}
let ctrl = if smooth {
let k = d / 5.0;
vec![p1, p1 + t1 * k, p1 + t1 * (2.0 * k), p2 + t2 * (2.0 * k), p2 + t2 * k, p2]
} else {
let k = d / 3.0;
vec![p1, p1 + t1 * k, p2 + t2 * k, p2]
};
let deg = ctrl.len() - 1;
Some(Spline::from_control(ctrl, deg))
}
fn blend(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, smooth: bool) -> Result<Handle> {
let k1 = curves::entity(s, h1)?.kind;
let k2 = curves::entity(s, h2)?.kind;
let e1 = curve_end(&k1, p1).ok_or_else(|| other("Select an open line, arc, polyline, ellipse arc or spline."))?;
let e2 = curve_end(&k2, p2).ok_or_else(|| other("Select an open line, arc, polyline, ellipse arc or spline."))?;
let sp = blend_spline(e1, e2, smooth).ok_or_else(|| other("The ends coincide."))?;
s.add_entity(EntityKind::Spline(sp))
}
fn run_blend(s: &mut Session, p: &Value) -> Result<Value> {
let h1 = curves::handle_param(p, "h1").ok_or_else(|| bad("blend", "`h1` is required"))?;
let h2 = curves::handle_param(p, "h2").ok_or_else(|| bad("blend", "`h2` is required"))?;
let p1 = point_req("blend", p, "p1")?;
let p2 = point_req("blend", p, "p2")?;
let smooth = str_param(p, "continuity").is_some_and(|c| c.eq_ignore_ascii_case("smooth"));
let h = blend(s, h1, p1, h2, p2, smooth)?;
Ok(json!({ "handle": h.hex() }))
}
#[derive(Default)]
struct BlendM {
first: Option<(Handle, Vec2)>,
smooth: bool,
asking: bool,
}
impl Interactive for BlendM {
fn name(&self) -> &'static str {
"BLEND"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.set_selection(Vec::new());
s.echo(format!("Continuity = {}", if self.smooth { "Smooth" } else { "Tangent" }));
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.asking {
return Prompt::new("Enter continuity", curves::KW).kw(&["Tangent", "Smooth"]).default("Tangent");
}
match self.first {
None => Prompt::new("Select first object or", Accept::POINT).kw(&["CONtinuity"]),
Some(_) => Prompt::new("Select second point", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.asking {
if let Input::Keyword(k) | Input::Text(k) = &i {
self.smooth = k.to_ascii_lowercase().starts_with('s');
}
self.asking = false;
return Ok(Step::Continue);
}
match i {
Input::Keyword(_) => {
self.asking = true;
Ok(Step::Continue)
}
Input::Point(p) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
match self.first {
None => {
let k = curves::entity(s, h)?.kind;
curve_end(&k, p).ok_or_else(|| other("Select an open curve."))?;
self.first = Some((h, p));
Ok(Step::Continue)
}
Some((h1, p1)) => {
blend(s, h1, p1, h, p, self.smooth)?;
Ok(Step::Done)
}
}
}
Input::Enter => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
}
// =====================================================================================
// REVERSE / CHSPACE / FLATTEN (select, then act)
// =====================================================================================
pub(crate) fn reverse_kind(k: &EntityKind) -> Option<EntityKind> {
Some(match k {
EntityKind::Line(l) => EntityKind::Line(cadcraft_doc::Line { a: l.b, b: l.a }),
EntityKind::LwPolyline(p) => EntityKind::LwPolyline(LwPolyline { vertices: curves::reverse_vertices(&p.vertices, p.closed), ..p.clone() }),
EntityKind::Polyline3d(p) => {
EntityKind::Polyline3d(cadcraft_doc::Polyline3d { points: p.points.iter().rev().copied().collect(), closed: p.closed })
}
EntityKind::Spline(sp) => EntityKind::Spline(curves::reverse_spline(sp)),
_ => return None,
})
}
fn reverse(s: &mut Session, hs: &[Handle]) -> Result<usize> {
let mut n = 0;
for h in hs {
if curves::is_locked(s, *h) {
continue;
}
let Ok(e) = curves::entity(s, *h) else { continue };
if let Some(k) = reverse_kind(&e.kind) {
set_kind(s, *h, k)?;
n += 1;
}
}
Ok(n)
}
fn run_reverse(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let n = reverse(s, &hs)?;
Ok(json!({ "reversed": n }))
}
/// Zero every z coordinate; 3D polylines become 2D polylines.
pub(crate) fn flatten_kind(k: &mut EntityKind) {
let z = |p: &mut cadcraft_geom::Vec3| p.z = 0.0;
match k {
EntityKind::Line(l) => {
z(&mut l.a);
z(&mut l.b);
}
EntityKind::Point(p) => z(&mut p.p),
EntityKind::Circle(c) => z(&mut c.center),
EntityKind::Arc(a) => z(&mut a.center),
EntityKind::Ellipse(e) => {
z(&mut e.center);
z(&mut e.major);
}
EntityKind::Helix(h) => *k = EntityKind::Spline(h.spline()),
EntityKind::LwPolyline(p) => p.elevation = 0.0,
EntityKind::Polyline3d(p) => {
*k = lwpoly(p.points.iter().map(|q| PolyVertex::new(q.xy())).collect(), p.closed);
}
EntityKind::Ray(r) | EntityKind::XLine(r) => {
z(&mut r.base);
r.dir.z = 0.0;
r.dir = r.dir.normalized();
}
EntityKind::Text(t) => {
z(&mut t.insert);
t.align_pt.iter_mut().for_each(z);
}
EntityKind::AttDef(a) => {
z(&mut a.text.insert);
a.text.align_pt.iter_mut().for_each(z);
if let Some(ml) = &mut a.multiline {
z(&mut ml.mtext.insert);
}
}
EntityKind::MText(t) => z(&mut t.insert),
EntityKind::Insert(i) => {
z(&mut i.insert);
for a in &mut i.attribs {
z(&mut a.text.insert);
a.text.align_pt.iter_mut().for_each(z);
if let Some(ml) = &mut a.multiline {
z(&mut ml.mtext.insert);
}
}
}
EntityKind::Dimension(d) => {
for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] {
z(p);
}
d.block = None;
}
EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z),
EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| z(&mut v.p)),
EntityKind::MLeader(m) => {
m.leaders.iter_mut().for_each(|l| l.iter_mut().for_each(z));
z(&mut m.landing);
m.block.iter_mut().for_each(|b| z(&mut b.insert));
}
EntityKind::Hatch(h) => h.elevation = 0.0,
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(z),
EntityKind::Region(_) => {}
EntityKind::Image(i) => {
z(&mut i.insert);
i.u.z = 0.0;
i.v.z = 0.0;
}
EntityKind::Table(t) => z(&mut t.insert),
EntityKind::Tolerance(t) => {
z(&mut t.insert);
t.dir.z = 0.0;
}
EntityKind::Spline(_) | EntityKind::Viewport(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {}
}
}
fn flatten(s: &mut Session, hs: &[Handle]) -> Result<usize> {
let mut n = 0;
for h in hs {
if curves::is_locked(s, *h) {
continue;
}
if s.doc()?.entity(*h).is_none() {
continue;
}
s.doc_mut()?.modify_entity(*h, |e| {
flatten_kind(&mut e.kind);
e.common.thickness = 0.0;
})?;
n += 1;
}
Ok(n)
}
fn run_flatten(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let n = flatten(s, &hs)?;
Ok(json!({ "flattened": n }))
}
/// Model↔paper transform through a viewport: paper = center + (model − viewCenter) / scale.
fn viewport_of(s: &Session, layout: &str, vp: Option<Handle>) -> Option<cadcraft_doc::Viewport> {
let d = s.doc().ok()?;
if let Some(h) = vp
&& let Some(e) = d.entity(h)
&& let EntityKind::Viewport(v) = &e.kind
{
return Some(v.clone());
}
let st = d.space(&Space::Paper(layout.to_string()))?;
st.iter().find_map(|e| match &e.kind {
EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 && v.id != 1 => Some(v.clone()),
_ => None,
})
}
fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option<Handle>) -> Result<(usize, Space, Space)> {
let from = s.space();
let (target, layout) = match (&from, to) {
(Space::Model, t) => {
let name = match t {
Some(n) if !n.eq_ignore_ascii_case("model") => n.to_string(),
_ => {
let d = s.doc()?;
let mut ls: Vec<_> = d.layouts.iter().collect();
ls.sort_by_key(|l| l.tab_order);
ls.first().map(|l| l.name.clone()).ok_or_else(|| other("No layout to move objects to."))?
}
};
if s.doc()?.layout(&name).is_none() {
return Err(other(format!("Layout \"{name}\" not found.")));
}
let real = s.doc()?.layout(&name).map(|l| l.name.clone()).unwrap_or(name);
(Space::Paper(real.clone()), real)
}
(Space::Paper(n), _) => (Space::Model, n.clone()),
};
let m = match viewport_of(s, &layout, vp) {
Some(v) => {
let k = v.view_height / v.height;
let to_paper = Mat3::translate(v.center.xy()).then_before(Mat3::scale(1.0 / k, 1.0 / k)).then_before(Mat3::translate(-v.view_center));
if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper }
}
None => Mat3::IDENTITY,
};
let mut n = 0;
for h in hs {
if curves::is_locked(s, *h) {
continue;
}
let d = s.doc_mut()?;
if d.space_of(*h).as_ref() != Some(&from) {
continue;
}
let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue };
if matches!(e.kind, EntityKind::Viewport(_)) {
continue;
}
let mut e = e;
e.kind.transform(&m);
d.remove_entity(*h);
if let Some(st) = d.space_mut(&target) {
st.push(e);
n += 1;
}
}
s.set_selection(Vec::new());
Ok((n, from, target))
}
fn run_chspace(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let (n, _, target) = chspace(s, &hs, str_param(p, "to"), curves::handle_param(p, "viewport"))?;
Ok(json!({ "moved": n, "to": target.layout().unwrap_or("model") }))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SelKind {
Reverse,
Chspace,
Flatten,
}
struct SelOp {
kind: SelKind,
sel: SelectPhase,
}
impl SelOp {
fn new(kind: SelKind) -> Self {
SelOp { kind, sel: SelectPhase::default() }
}
fn act(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
match self.kind {
SelKind::Reverse => {
let n = reverse(s, hs)?;
s.echo(format!("{n} object(s) direction has been reversed."));
}
SelKind::Flatten => {
let n = flatten(s, hs)?;
s.echo(format!("{n} object(s) flattened."));
}
SelKind::Chspace => {
let (n, from, to) = chspace(s, hs, None, None)?;
let nm = |sp: &Space| if *sp == Space::Model { "MODEL" } else { "PAPER" };
s.echo(format!("{n} object(s) changed from {} space to {} space.", nm(&from), nm(&to)));
}
}
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
impl Interactive for SelOp {
fn name(&self) -> &'static str {
match self.kind {
SelKind::Reverse => "REVERSE",
SelKind::Chspace => "CHSPACE",
SelKind::Flatten => "FLATTEN",
}
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
let hs = self.sel.picked.clone();
return self.act(s, &hs);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
self.sel.prompt()
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Cancel),
SelOutcome::Done(hs) => self.act(s, &hs),
}
}
}
// =====================================================================================
// NCOPY
// =====================================================================================
/// The leaf objects of a block reference in world coordinates (nested references expanded).
fn nested_leaves(d: &cadcraft_doc::Drawing, ins: &cadcraft_doc::Insert, m: &Mat3, depth: usize, out: &mut Vec<(Common, EntityKind)>) {
if depth >= MAX_BLOCK_DEPTH || out.len() >= MAX_GEN {
return;
}
let Some(b) = d.block(&ins.block) else { return };
let mm = m.then_before(ins.transform(b.base.xy()));
for e in b.entities.iter() {
match &e.kind {
EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, out),
EntityKind::AttDef(_) => {}
k => {
let mut k = k.clone();
k.transform(&mm);
out.push((e.common.clone(), k));
}
}
}
}
fn leaves_of(s: &Session, h: Handle) -> Result<Vec<(Common, EntityKind)>> {
let e = curves::entity(s, h)?;
let EntityKind::Insert(ins) = &e.kind else { return Err(other("Select a block reference.")) };
let mut out = Vec::new();
nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, &mut out);
Ok(out)
}
fn nearest_leaf(leaves: Vec<(Common, EntityKind)>, p: Vec2) -> Option<(Common, EntityKind)> {
leaves.into_iter().min_by(|a, b| curves::kind_distance(&a.1, p).total_cmp(&curves::kind_distance(&b.1, p)))
}
fn place_copies(s: &mut Session, items: &[(Common, EntityKind)], delta: Vec2) -> Result<Vec<Handle>> {
let space = s.space();
let m = Mat3::translate(delta);
let mut out = Vec::new();
for (c, k) in items {
let mut k = k.clone();
k.transform(&m);
out.push(push_entity(s, &space, c.clone(), k)?);
}
Ok(out)
}
fn run_ncopy(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("ncopy", "`handle` (block reference) is required"))?;
let leaves = leaves_of(s, h)?;
let items: Vec<(Common, EntityKind)> = match point_param(p, "pick") {
Some(q) => nearest_leaf(leaves, q).into_iter().collect(),
None => leaves,
};
if items.is_empty() {
return Err(other("The block reference has no nested objects."));
}
let delta = match (point_param(p, "delta"), point_param(p, "from"), point_param(p, "to")) {
(Some(d), _, _) => d,
(None, Some(a), Some(b)) => b - a,
_ => Vec2::ZERO,
};
let hs = place_copies(s, &items, delta)?;
Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
#[derive(Default)]
struct NcopyM {
items: Vec<(Common, EntityKind)>,
selecting_done: bool,
base: Option<Vec2>,
}
impl Interactive for NcopyM {
fn name(&self) -> &'static str {
"NCOPY"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
s.set_selection(Vec::new());
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
match (self.selecting_done, self.base) {
(false, _) => Prompt::new("Select nested objects to copy or", Accept::POINT).kw(&["Settings"]),
(true, None) => Prompt::new("Specify base point or", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"),
(true, Some(b)) => Prompt::new("Specify second point or <use first point as displacement>", Accept::POINT).base(b),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.selecting_done {
return match i {
Input::Point(p) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
let leaf = nearest_leaf(leaves_of(s, h)?, p).ok_or_else(|| other("Nothing nested there."))?;
s.echo(format!("Nested object: {}", leaf.1.type_name()));
if self.items.len() < MAX_GEN {
self.items.push(leaf);
}
Ok(Step::Continue)
}
Input::Keyword(_) => {
s.echo("Insert mode: Copy (nested objects are copied as independent objects).");
Ok(Step::Continue)
}
Input::Enter => {
if self.items.is_empty() {
return Ok(Step::Cancel);
}
self.selecting_done = true;
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
};
}
match (self.base, i) {
(None, Input::Point(p)) => {
self.base = Some(p);
Ok(Step::Continue)
}
(None, Input::Enter | Input::Keyword(_)) => {
place_copies(s, &self.items, Vec2::ZERO)?;
Ok(Step::Done)
}
(Some(b), Input::Point(p)) => {
place_copies(s, &self.items, p - b)?;
Ok(Step::Done)
}
(Some(b), Input::Enter) => {
place_copies(s, &self.items, b)?;
Ok(Step::Done)
}
_ => Ok(Step::Continue),
}
}
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
let Some(b) = self.base else { return Vec::new() };
let m = Mat3::translate(c - b);
self.items
.iter()
.take(500)
.map(|(_, k)| {
let mut k = k.clone();
k.transform(&m);
k
})
.collect()
}
}
// =====================================================================================
// FILLET extensions (arcs, circles, polylines)
// =====================================================================================
/// How a fillet operand is trimmed: the new kind and the arrival direction at the tangent point.
fn trim_for_fillet(k: &EntityKind, pick: Vec2, t: Vec2) -> Option<(Option<EntityKind>, Option<Vec2>)> {
match k {
EntityKind::Line(l) => {
let (a, b) = (l.a.xy(), l.b.xy());
let keep = if (a - t).dot(pick - t) >= (b - t).dot(pick - t) { a } else { b };
let dir = (t - keep).normalized();
if dir == Vec2::ZERO {
return Some((None, None));
}
Some((Some(if keep == a { line(a, t) } else { line(t, b) }), Some(dir)))
}
EntityKind::Arc(ar) => {
let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end);
let ta = g.center.angle_to(t);
let ccw_tan = (t - g.center).perp().normalized();
let ap = g.center.angle_to(pick);
let keep_start_side = if g.contains_angle(ta) {
// Keep the side of the tangent point that holds the pick.
cadcraft_geom::angle_in_sweep(ap, g.start, ta)
} else {
// Extend the nearer end.
ccw_sweep(g.end, ta) <= ccw_sweep(ta, g.start)
};
if keep_start_side {
let na = Arc::new(g.center, g.radius, g.start, ta);
Some((Some(arc(&na)), Some(ccw_tan)))
} else {
let na = Arc::new(g.center, g.radius, ta, g.end);
Some((Some(arc(&na)), Some(-ccw_tan)))
}
}
EntityKind::Circle(_) => Some((None, None)),
_ => None,
}
}
/// Fillet two curves (lines, arcs, circles; elliptical arcs and splines in `curve_edits`) with
/// radius `r`. Returns the new arc, if any.
pub(crate) fn fillet_curves(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64) -> Result<Option<Handle>> {
if curves::is_locked(s, h1) || curves::is_locked(s, h2) {
return Err(other("The object is on a locked layer."));
}
let e1 = curves::entity(s, h1)?;
let e2 = curves::entity(s, h2)?;
if [&e1, &e2].iter().any(|e| matches!(e.kind, EntityKind::Ellipse(_) | EntityKind::Spline(_))) {
return curve_edits::fillet_curvy(s, h1, p1, h2, p2, r, &e1, &e2);
}
let unsupported = || other("Fillet works on lines, arcs, circles, elliptical arcs and splines (and polylines with the Polyline option).");
let o1 = TanObj::from_kind(&e1.kind, p1).filter(|_| matches!(e1.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_)));
let o2 = TanObj::from_kind(&e2.kind, p2).filter(|_| matches!(e2.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_)));
let (Some(o1), Some(o2)) = (o1, o2) else { return Err(unsupported()) };
if h1 == h2 {
return Err(other("Cannot fillet an object to itself."));
}
let (t1, t2, fc) = if r <= 1e-12 {
// Sharp corner: the intersection nearest the picks.
let hits: Vec<Vec2> = match (o1, o2) {
(TanObj::Line(a), TanObj::Line(b)) => line_line_infinite(a.a, a.b, b.a, b.b).map(|x| vec![x.0]).unwrap_or_default(),
(TanObj::Line(l), TanObj::Circle(c)) | (TanObj::Circle(c), TanObj::Line(l)) => line_circle(&l, &c).into_iter().map(|x| x.0).collect(),
(TanObj::Circle(a), TanObj::Circle(b)) => cadcraft_geom::circle_circle(&a, &b),
};
let x = hits
.into_iter()
.min_by(|a, b| (a.dist(p1) + a.dist(p2)).total_cmp(&(b.dist(p1) + b.dist(p2))))
.ok_or_else(|| other("The objects do not intersect."))?;
(x, x, None)
} else {
let c = curves::ttr(o1, p1, o2, p2, r).ok_or_else(|| other("Radius is too large."))?;
(o1.touch(&c), o2.touch(&c), Some(c))
};
let (n1, d1) = trim_for_fillet(&e1.kind, p1, t1).ok_or_else(unsupported)?;
let (n2, d2) = trim_for_fillet(&e2.kind, p2, t2).ok_or_else(unsupported)?;
let new_arc = fc.map(|c| {
let a1 = c.center.angle_to(t1);
let a2 = c.center.angle_to(t2);
let ccw1 = (t1 - c.center).perp().normalized();
let ccw2 = (t2 - c.center).perp().normalized();
let ccw = match (d1, d2) {
(Some(d), _) => ccw1.dot(d) > 0.0,
(None, Some(d)) => ccw2.dot(-d) > 0.0,
_ => ccw_sweep(a1, a2) <= std::f64::consts::PI,
};
if ccw { Arc::new(c.center, c.radius, a1, a2) } else { Arc::new(c.center, c.radius, a2, a1) }
});
if let Some(k) = n1 {
set_kind(s, h1, k)?;
}
if let Some(k) = n2 {
set_kind(s, h2, k)?;
}
match new_arc {
Some(a) => {
let space = s.doc()?.space_of(h1).unwrap_or_else(|| s.space());
Ok(Some(push_entity(s, &space, e1.common.clone(), arc(&a))?))
}
None => Ok(None),
}
}
/// Fillet every corner between two straight segments of a polyline. Returns the count.
pub(crate) fn fillet_polyline(s: &mut Session, h: Handle, r: f64) -> Result<usize> {
if curves::is_locked(s, h) {
return Err(other("The object is on a locked layer."));
}
let e = curves::entity(s, h)?;
let EntityKind::LwPolyline(pl) = &e.kind else { return Err(other("Select a 2D polyline.")) };
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();
// Distance cut back from each corner (0 = not filleted).
let mut cut = vec![0.0f64; n];
let mut turn = vec![0.0f64; n];
for (i, c) in cut.iter_mut().enumerate() {
if !pl.closed && (i == 0 || i + 1 == n) {
continue;
}
let prev = at(i + n - 1);
let cur = at(i);
let next = 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 {
continue;
}
let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0;
if half < 1e-9 || (std::f64::consts::FRAC_PI_2 - half).abs() < 1e-9 {
continue;
}
*c = if r <= 1e-12 { 0.0 } else { r / half.tan() };
if let Some(t) = turn.get_mut(i) {
*t = (-u1).cross(u2).atan2((-u1).dot(u2));
}
}
// Drop fillets 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 (ci, cj) = (cut.get(i).copied().unwrap_or(0.0), cut.get(j).copied().unwrap_or(0.0));
if ci + cj > seg + 1e-9 {
if let Some(x) = cut.get_mut(i) {
*x = 0.0;
}
if let Some(x) = cut.get_mut(j) {
*x = 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 c = cut.get(i).copied().unwrap_or(0.0);
if c <= 1e-12 {
out.push(v);
continue;
}
let prev = at(i + n - 1).p;
let next = at(i + 1).p;
let t1 = v.p + (prev - v.p).normalized() * c;
let t2 = v.p + (next - v.p).normalized() * c;
let b = cadcraft_geom::arc_to_bulge(turn.get(i).copied().unwrap_or(0.0));
out.push(PolyVertex { p: t1, bulge: b, ..v });
out.push(PolyVertex { p: t2, bulge: 0.0, ..v });
count += 1;
}
set_kind(s, h, EntityKind::LwPolyline(LwPolyline { vertices: out, ..pl.clone() }))?;
Ok(count)
}
// =====================================================================================
// TRIM / EXTEND for ellipses and splines
// =====================================================================================
fn ellipse_of(e: &cadcraft_doc::Ellipse) -> Ellipse {
Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end }
}
fn ellipse_kind(e: &Ellipse) -> EntityKind {
EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(e.center), major: v3(e.major), ratio: e.ratio, start: e.start, end: e.end })
}
/// Intersections of a sampled curve `eval(t)` (parameters `ts`) with the cutting segments,
/// as curve parameters refined by bisection on the exact curve.
fn hits_on(eval: &dyn Fn(f64) -> Vec2, ts: &[f64], cut: &[Segment]) -> Vec<f64> {
let mut out = Vec::new();
for w in ts.windows(2) {
let (Some(t0), Some(t1)) = (w.first().copied(), w.get(1).copied()) else { continue };
let (a, b) = (eval(t0), eval(t1));
let sg = Segment::Line(Line::new(a, b));
for c in cut {
for x in intersect_ext(&sg, c, false) {
let f = Line::new(a, b).param_of(x).clamp(0.0, 1.0);
out.push(refine_hit(eval, c, t0, t1, t0 + (t1 - t0) * f));
}
}
}
out
}
/// Signed side of `p` relative to a cutting segment (line side, or inside/outside an arc's circle).
fn side_of(c: &Segment, p: Vec2) -> f64 {
match c {
Segment::Line(l) => l.side(p) / l.len().max(1e-300),
Segment::Arc { arc, .. } => p.dist(arc.center) - arc.radius,
}
}
fn refine_hit(eval: &dyn Fn(f64) -> Vec2, c: &Segment, t0: f64, t1: f64, guess: f64) -> f64 {
let (mut a, mut b) = (t0, t1);
let (mut fa, fb) = (side_of(c, eval(a)), side_of(c, eval(b)));
if !(fa.is_finite() && fb.is_finite()) || fa * fb > 0.0 {
return guess;
}
for _ in 0..60 {
let m = (a + b) / 2.0;
let fm = side_of(c, eval(m));
if fa * fm <= 0.0 {
b = m;
} else {
a = m;
fa = fm;
}
}
(a + b) / 2.0
}
fn ellipse_samples(ge: &Ellipse) -> Vec<f64> {
let n = 720usize;
let sw = ge.sweep();
(0..=n).map(|i| ge.start + sw * i as f64 / n as f64).collect()
}
/// Trim objects that modify.rs does not handle itself (ellipses and splines).
pub(crate) fn trim_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result<Vec<EntityKind>> {
let none = || other("Object does not intersect a cutting edge.");
match k {
EntityKind::Ellipse(e) => {
let ge = ellipse_of(e);
let full = Ellipse { start: 0.0, end: TAU, ..ge };
let eval = |t: f64| full.at_param(t);
let params: Vec<f64> = hits_on(&eval, &ellipse_samples(&ge), cut).into_iter().map(norm_angle).collect();
if ge.is_full() {
let mut ps = params;
ps.sort_by(f64::total_cmp);
ps.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
if ps.len() < 2 {
return Err(other("Ellipse must intersect cutting edges at two or more points."));
}
let tp = full.param_of(pick);
let n = ps.len();
for i in 0..n {
let (Some(a0), Some(a1)) = (ps.get(i).copied(), ps.get((i + 1) % n).copied()) else { continue };
if cadcraft_geom::angle_in_sweep(tp, a0, a1) {
return Ok(vec![ellipse_kind(&Ellipse { start: a1, end: a0, ..ge })]);
}
}
return Err(none());
}
let sweep = ge.sweep();
let rel = |t: f64| norm_angle(t - ge.start);
let mut ts: Vec<f64> = params.into_iter().map(rel).filter(|t| *t > 1e-9 && *t < sweep - 1e-9).collect();
ts.sort_by(f64::total_cmp);
let tp = rel(full.param_of(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(none());
}
let mut v = Vec::new();
if lo > 0.0 {
v.push(ellipse_kind(&Ellipse { end: norm_angle(ge.start + lo), ..ge }));
}
if hi < sweep {
v.push(ellipse_kind(&Ellipse { start: norm_angle(ge.start + hi), ..ge }));
}
Ok(v)
}
EntityKind::Spline(sp) => {
if !sp.is_valid() {
return Err(other("Invalid spline."));
}
let (lo_d, hi_d) = sp.domain();
let n = 512;
let params: Vec<f64> = (0..=n).map(|i| lo_d + (hi_d - lo_d) * i as f64 / n as f64).collect();
let eval = |t: f64| sp.eval(t);
let mut ts: Vec<f64> =
hits_on(&eval, &params, cut).into_iter().filter(|t| *t > lo_d + 1e-9 * (hi_d - lo_d) && *t < hi_d - 1e-9 * (hi_d - lo_d)).collect();
ts.sort_by(f64::total_cmp);
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
let tp = curves::spline_param_of(sp, pick);
if sp.closed {
return curve_edits::trim_closed_spline(sp, &ts, tp);
}
let lo = ts.iter().copied().filter(|t| *t < tp).fold(lo_d, f64::max);
let hi = ts.iter().copied().filter(|t| *t > tp).fold(hi_d, f64::min);
if lo == lo_d && hi == hi_d {
return Err(none());
}
let mut v = Vec::new();
if lo > lo_d
&& let Some(a) = curves::sub_spline(sp, lo_d, lo)
{
v.push(EntityKind::Spline(a));
}
if hi < hi_d
&& let Some(b) = curves::sub_spline(sp, hi, hi_d)
{
v.push(EntityKind::Spline(b));
}
Ok(v)
}
_ => Err(other("Cannot trim that object.")),
}
}
/// Extend objects that modify.rs does not handle itself (elliptical arcs and open splines).
pub(crate) fn extend_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result<EntityKind> {
match k {
EntityKind::Ellipse(e) => {
let ge = ellipse_of(e);
if ge.is_full() {
return Err(other("Cannot extend a closed ellipse."));
}
let full = Ellipse { start: 0.0, end: TAU, ..ge };
let from_end = pick.dist(ge.at_param(ge.end)) < pick.dist(ge.at_param(ge.start));
let eval = |t: f64| full.at_param(t);
let hits: Vec<f64> = hits_on(&eval, &ellipse_samples(&full), cut)
.into_iter()
.map(norm_angle)
.filter(|t| !cadcraft_geom::angle_in_sweep(*t, ge.start, ge.end))
.collect();
let t = if from_end {
hits.iter().copied().min_by(|x, y| ccw_sweep(ge.end, *x).total_cmp(&ccw_sweep(ge.end, *y)))
} else {
hits.iter().copied().min_by(|x, y| ccw_sweep(*x, ge.start).total_cmp(&ccw_sweep(*y, ge.start)))
};
let t = t.ok_or_else(|| other("Object does not intersect an edge."))?;
Ok(ellipse_kind(&if from_end { Ellipse { end: t, ..ge } } else { Ellipse { start: t, ..ge } }))
}
EntityKind::Spline(sp) => curve_edits::extend_spline(sp, cut, pick),
_ => Err(other("Cannot extend that object.")),
}
}
// =====================================================================================
// ARRAYPATH (non-associative copies along a curve)
// =====================================================================================
pub(crate) fn arraypath(
s: &mut Session,
hs: &[Handle],
path: Handle,
count: Option<usize>,
spacing: Option<f64>,
align: bool,
) -> Result<Vec<Handle>> {
let pk = curves::entity(s, path)?.kind;
let c = Chain::of(&pk).ok_or_else(|| other("Select a line, arc, circle, polyline, ellipse or spline as the path."))?;
let l = c.len();
if l <= 1e-12 {
return Err(other("The path has no length."));
}
let objs: Vec<Handle> = hs.iter().copied().filter(|h| *h != path).collect();
if objs.is_empty() {
return Err(other("Select objects to array."));
}
let (n, step) = match (count, spacing) {
(_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize).saturating_add(1), sp),
(Some(n), _) => {
let n = n.max(1);
(n, if c.closed { l / n as f64 } else { l / (n.max(2) - 1) as f64 })
}
_ => (6, if c.closed { l / 6.0 } else { l / 5.0 }),
};
super::array::check_size(s, n as u64, objs.len())?;
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
let mut out = Vec::new();
for i in 1..n {
let Some((p, t)) = c.at_length(step * i as f64) else { continue };
let mut m = Mat3::translate(p - p0);
if align {
m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m);
}
out.extend(super::modify::transform_entities(s, &objs, &m, true)?);
}
Ok(out)
}
fn run_arraypath(s: &mut Session, p: &Value) -> Result<Value> {
let hs = targets(s, p)?;
let path = curves::handle_param(p, "path").ok_or_else(|| bad("arraypath", "`path` (handle) is required"))?;
let count = p.get("count").and_then(Value::as_u64).map(|n| usize::try_from(n).unwrap_or(usize::MAX));
let spacing = p.get("spacing").and_then(Value::as_f64);
let r = arraypath(s, &hs, path, count, spacing, bool_or(p, "align", true))?;
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
}
#[derive(Default)]
pub(super) struct PathArrayM {
sel: SelectPhase,
objs: Vec<Handle>,
path: Option<Handle>,
}
impl PathArrayM {
/// Start at the path prompt with the objects already selected (ARRAY > PAth).
pub(super) fn with_objects(objs: Vec<Handle>) -> Self {
PathArrayM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, path: None }
}
}
impl Interactive for PathArrayM {
fn name(&self) -> &'static str {
"ARRAYPATH"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
self.sel = SelectPhase::begin(s);
if self.sel.done {
self.objs = self.sel.picked.clone();
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if !self.sel.done {
return self.sel.prompt();
}
match self.path {
None => Prompt::new("Select path curve", Accept::POINT),
Some(_) => Prompt::new("Enter number of items along path", Accept::NUMBER).default("6"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if !self.sel.done {
return match self.sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Cancel),
SelOutcome::Done(hs) => {
self.objs = hs;
Ok(Step::Continue)
}
};
}
match (self.path, i) {
(None, Input::Point(p)) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
Chain::of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a curve as the path."))?;
self.path = Some(h);
Ok(Step::Continue)
}
(Some(path), inp @ (Input::Text(_) | Input::Enter)) => {
let n = match &inp {
Input::Text(t) => number(t).filter(|n| *n >= 1.0).ok_or_else(|| other("Requires a count of 1 or more."))? as usize,
_ => 6,
};
arraypath(s, &self.objs, path, Some(n), None, true)?;
s.set_selection(Vec::new());
s.echo(format!("Type = Path Associative = No ({n} items)"));
Ok(Step::Done)
}
(None, Input::Enter) => Ok(Step::Cancel),
_ => Ok(Step::Continue),
}
}
}
// =====================================================================================
// TEXTEDIT
// =====================================================================================
fn text_of(k: &EntityKind) -> Option<String> {
match k {
EntityKind::Text(t) => Some(t.value.clone()),
EntityKind::AttDef(a) => Some(a.text.value.clone()),
EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")),
EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }),
_ => None,
}
}
pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> {
if curves::is_locked(s, h) {
return Err(other("The object is on a locked layer."));
}
let mut k = curves::entity(s, h)?.kind;
match &mut k {
EntityKind::Text(t) => t.value = text.to_string(),
EntityKind::AttDef(a) => a.text.value = text.to_string(),
EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"),
EntityKind::Dimension(d) => {
d.text = if text == "<>" { String::new() } else { text.to_string() };
d.block = None;
}
_ => return Err(other("Select a text, mtext, attribute definition or dimension.")),
}
set_kind(s, h, k)
}
fn run_textedit(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("textedit", "`handle` is required"))?;
let text = str_param(p, "text").ok_or_else(|| bad("textedit", "`text` is required"))?;
if text.len() > 1_000_000 {
return Err(bad("textedit", "text is too long"));
}
set_text(s, h, text)?;
Ok(json!({ "handle": h.hex() }))
}
#[derive(Default)]
struct TextEditM {
h: Option<(Handle, String)>,
/// Edits made in this run, newest last: each object as it was before (the Undo option).
edits: Vec<(Handle, EntityKind)>,
/// Single mode: end after one edit (the Mode option; Multiple by default).
single: bool,
/// Asking for the edit mode.
mode: bool,
}
impl Interactive for TextEditM {
fn name(&self) -> &'static str {
"TEXTEDIT"
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.mode {
let current = if self.single { "Single" } else { "Multiple" };
return Prompt::new("Enter a text edit mode option", curves::KW).kw(&["Single", "Multiple"]).default(current);
}
match &self.h {
None => Prompt::new("Select an annotation object", Accept::POINT).kw(&["Undo", "Mode"]),
Some((_, old)) => Prompt::new("Enter new text", Accept::TEXT).default(old.chars().take(40).collect::<String>()),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.mode {
match i {
Input::Keyword(k) if k == "Single" => self.single = true,
Input::Keyword(k) if k == "Multiple" => self.single = false,
Input::Enter => {}
_ => return Err(other("Enter Single or Multiple.")),
}
self.mode = false;
return Ok(Step::Continue);
}
match (&self.h, i) {
(None, Input::Point(p)) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;
self.h = Some((h, old));
Ok(Step::Continue)
}
(Some((h, _)), Input::Text(t) | Input::Keyword(t)) => {
let h = *h;
let before = curves::entity(s, h)?.kind;
set_text(s, h, &t)?;
self.edits.push((h, before));
self.h = None;
Ok(if self.single { Step::Done } else { Step::Continue })
}
(Some(_), Input::Enter) => {
self.h = None;
Ok(if self.single { Step::Done } else { Step::Continue })
}
(None, Input::Enter) => Ok(Step::Done),
(None, Input::Keyword(k)) if k == "Undo" => {
match self.edits.pop() {
Some((h, before)) => set_kind(s, h, before)?,
None => s.echo("Everything has been undone"),
}
Ok(Step::Continue)
}
(None, Input::Keyword(k)) if k == "Mode" => {
self.mode = true;
Ok(Step::Continue)
}
_ => Ok(Step::Continue),
}
}
}
#[cfg(test)]
#[path = "modify2_tests.rs"]
mod tests;