//! More sketch creation tools: midpoint line, tangent arc, tangent circles, center-point and //! arc slots. use serde_json::{Value, json}; use solidcraft_geom::Vec2; use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch, TextAlign, TextStyle}; /// The style parameters of `sketch.text` and `sketch.edit_text`. macro_rules! text_style_doc { () => { "bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)" }; } use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot}; use super::{CommandSpec, in_sketch}; use crate::params::{bad, num, req_vec2, str_, string_list}; use crate::{Result, Session}; #[cfg(test)] #[path = "sketch_text_move_tests.rs"] mod text_move_tests; pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.line.midpoint", "Midpoint Line", midpoint_line) .at("SKETCH", "CREATE") .icon("line_mid") .enabled(in_sketch) .params(concat!("mid: [x,y] or point ref, end: [x,y]; construction?", created_doc!())), CommandSpec::new("sketch.arc.tangent", "Tangent Arc", arc_tangent).at("SKETCH", "CREATE").icon("arc_tangent").enabled(in_sketch).params(concat!( "start: end point of a line or arc (\"l1.end\"), end: [x,y]; curve?: the curve to be tangent to (default: the one ending at start)", created_doc!() )), CommandSpec::new("sketch.circle.two_tangent", "2-Tangent Circle", circle_tan_tan) .at("SKETCH", "CREATE") .icon("circle_tt") .enabled(in_sketch) .params(concat!("curves: [two lines/circles/arcs], radius | diameter: expr, near: [x,y] (which of the solutions)", created_doc!())), CommandSpec::new("sketch.circle.three_tangent", "3-Tangent Circle", circle_three_tan) .at("SKETCH", "CREATE") .icon("circle_ttt") .enabled(in_sketch) .params(concat!("curves: [three lines/circles/arcs], near: [x,y] (which of the solutions)", created_doc!())), CommandSpec::new("sketch.slot.center_point", "Center Point Slot", slot_center_point) .at("SKETCH", "CREATE") .icon("slot") .enabled(in_sketch) .params(concat!("center: slot centre, end: one arc centre, width", created_doc!())), CommandSpec::new("sketch.slot.arc_three_point", "Three Point Arc Slot", arc_slot_three) .at("SKETCH", "CREATE") .icon("arc_slot") .enabled(in_sketch) .params(concat!("start, through, end: points on the centre arc, width", created_doc!())), CommandSpec::new("sketch.slot.arc_center", "Center Point Arc Slot", arc_slot_center) .at("SKETCH", "CREATE") .icon("arc_slot") .enabled(in_sketch) .params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width", created_doc!())), CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!( "center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius", created_doc!() )), CommandSpec::new("sketch.spline.fit_point", "Fit Point Spline", spline_fit) .at("SKETCH", "CREATE") .icon("spline") .enabled(in_sketch) .params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())), CommandSpec::new("sketch.spline.control_point", "Control Point Spline", spline_cv3) .at("SKETCH", "CREATE") .icon("spline_cv") .enabled(in_sketch) .params(concat!("points: control points (2…500), cubic", created_doc!())), CommandSpec::new("sketch.spline.control_point_5", "Control Point Spline (Degree 5)", spline_cv5) .at("SKETCH", "CREATE") .icon("spline_cv") .enabled(in_sketch) .params(concat!("points: control points (2…500), degree 5", created_doc!())), CommandSpec::new("sketch.conic", "Conic Curve", conic) .at("SKETCH", "CREATE") .icon("conic") .enabled(in_sketch) .params(concat!("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)", created_doc!())), CommandSpec::new("sketch.blend_curve", "Blend Curve", blend) .at("SKETCH", "MODIFY") .icon("blend") .enabled(in_sketch) .params(concat!("a, b: end points of two curves (\"l1.end\", \"a2.start\"): a smooth (tangent) spline joining them", created_doc!())), CommandSpec::new("sketch.centerline", "Centerline", centerline) .at("SKETCH", "CREATE") .icon("centerline") .enabled(in_sketch) .params("curves: [line ids], value?: bool (default toggles): centerline line type (not in profiles; an axis)"), CommandSpec::new("sketch.text", "Text", text).at("SKETCH", "CREATE").icon("text").enabled(in_sketch).params(concat!( "text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg, font?: an installed font's family or full name, or a .ttf/.otf file path (~ and %VAR% expand; default: the built-in Liberation Sans), ", text_style_doc!(), created_doc!() )), CommandSpec::new("sketch.edit_text", "Edit Text", edit_text) .enabled(in_sketch) .live(edit_text_live) .params(concat!( "link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg, font?: family or file path (empty: the built-in font), ", text_style_doc!(), " (each kept when left out); live?: bool: one frame of an interactive change (the sketch is solved, the features after it stay as they were until `sketch.drag_end`)", created_doc!() )), ]; /// The style a command's parameters give, over `old`. fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result { let mut style = old; for (key, slot) in [("bold", &mut style.bold), ("italic", &mut style.italic), ("reverse", &mut style.reverse)] { match p.get(key) { None | Some(Value::Null) => {} Some(Value::Bool(b)) => *slot = *b, Some(_) => return Err(bad(cmd, format!("`{key}` must be true or false"))), } } match p.get("align") { None | Some(Value::Null) => {} Some(Value::String(a)) => { style.align = match a.trim().to_ascii_lowercase().as_str() { "left" => TextAlign::Left, "center" | "centre" => TextAlign::Center, "right" => TextAlign::Right, _ => return Err(bad(cmd, "`align` must be left, center or right")), } } Some(_) => return Err(bad(cmd, "`align` must be left, center or right")), } if p.get("letter_spacing").is_some_and(|v| !v.is_null()) { style.letter_spacing = num(p, "letter_spacing").filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`letter_spacing` must be a length in mm"))?; } if p.get("line_spacing").is_some_and(|v| !v.is_null()) { style.line_spacing = num(p, "line_spacing").filter(|v| v.is_finite() && *v > 0.0).ok_or_else(|| bad(cmd, "`line_spacing` must be a positive factor"))?; } match p.get("path") { None | Some(Value::Null) => {} Some(Value::String(c)) if c.trim().is_empty() => style.path = None, Some(Value::String(c)) => style.path = Some(c.trim().to_string()), Some(_) => return Err(bad(cmd, "`path` must be a curve id")), } Ok(style) } /// What a text's `at` must be. const AT_MUST: &str = "`at` must be the baseline start [x, y]"; /// The number given for `key`: `None` when it is left out or null, and an error when it is /// anything but a number (not read as left out). fn given_num(p: &Value, cmd: &str, key: &str, what: &str) -> Result> { match p.get(key) { None | Some(Value::Null) => Ok(None), Some(_) => num(p, key).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be {what}"))), } } fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64, Option, TextStyle)>) -> Result { let (t0, at0, h0, a0, f0, s0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0, None, TextStyle::default())); let text = match p.get("text") { None | Some(Value::Null) => t0, Some(Value::String(t)) => t.clone(), Some(_) => return Err(bad(cmd, "`text` must be a string")), }; if text.trim().is_empty() { return Err(bad(cmd, "`text` must not be empty")); } if text.chars().count() > solidcraft_sketch::MAX_TEXT_CHARS { return Err(bad(cmd, "text too long")); } let at = match p.get("at") { None | Some(Value::Null) => at0, Some(v) => crate::params::vec2(v).ok_or_else(|| bad(cmd, AT_MUST))?, }; let height = given_num(p, cmd, "height", "a length in mm")?.unwrap_or(h0); if !(height > 1e-6 && height < 1e5) { return Err(bad(cmd, "`height` must be positive")); } let angle = given_num(p, cmd, "angle", "an angle in degrees")?.map_or(a0, f64::to_radians); let font = match p.get("font") { None | Some(Value::Null) => f0, Some(Value::String(f)) if f.trim().is_empty() => None, Some(Value::String(f)) => Some(f.trim().to_string()), Some(_) => return Err(bad(cmd, "`font` must be a font family name or a font file path")), }; let style = text_style(p, cmd, s0)?; // A font file an agent names must lie in its folders (installed families are found by name). if let Some(path) = font.as_deref().and_then(solidcraft_sketch::font_file_path) { solidcraft_io::vfs::check(&path.to_string_lossy()).map_err(|e| bad(cmd, e.to_string()))?; } // An unknown font, an unreadable file or a style the font lacks is an error now, not a lost link later. solidcraft_sketch::font_data_styled(font.as_deref(), style.bold, style.italic).map_err(|e| bad(cmd, e.to_string()))?; Ok(LinkSource::Text { text, at, height, angle, font, style }) } fn text(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.text"; if p.get("at").is_none_or(Value::is_null) { return Err(bad(cmd, AT_MUST)); } let src = text_source(p, cmd, None)?; let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?; let mut doc = (*s.doc).clone(); let mut sk = doc.sketch(id)?.clone(); // A text needs nothing outside its sketch (a path is one of its curves). let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?; if geom.is_empty() { return Err(bad(cmd, "the text makes no outline")); } let guide = guide(&mut sk, &src); let l = sk.add_link(LinkKind::Text, src, &geom)?; let curves = ids_of(&sk, &sk.link_curves(&l)); *doc.sketch_mut(id)? = sk; *s.doc_mut() = doc; Ok(json!({"link": l, "curves": curves, "guide": guide})) } /// A text's path is its guide: the curve it follows becomes construction. Left as it was, it /// would cut the letters standing on it (and the ones hanging below it) into pieces, each /// profile a piece of a letter and of the curve, and picking the text would pick those pieces. /// Taking the text off the curve leaves the curve as it is. The curve made construction, if any. fn guide(sk: &mut Sketch, src: &LinkSource) -> Option { let LinkSource::Text { style, .. } = src else { return None }; let id = style.path.as_ref()?; let curve = sk.curve_index(id).and_then(|c| sk.curves.get_mut(c))?; if curve.construction { return None; } curve.construction = true; curve.centerline = false; Some(id.clone()) } /// The text source owning an outline or one of its linked points. pub(super) fn text_link<'a>(sk: &'a Sketch, entity: &str) -> Option<&'a str> { let link = sk .curve_index(entity) .and_then(|i| sk.curves.get(i)) .and_then(|c| c.link.as_deref()) .or_else(|| sk.resolve_point(entity).and_then(|i| sk.points.get(i)).and_then(|p| p.link.as_deref()))?; sk.link(link).filter(|source| source.kind == LinkKind::Text).map(|source| source.id.as_str()) } /// Transform a text source as a unit; generated outlines remain linked and editable. pub(super) fn move_text(sk: &mut Sketch, link: &str, translate: Vec2, rotation: f64, center: Vec2, copy: bool) -> Result> { let Some(LinkSource::Text { text, at, height, angle, font, style }) = sk.link(link).map(|l| l.source.clone()) else { return Err(bad("sketch.move", "the selected source is not text")); }; if style.path.is_some() { return Err(bad("sketch.move", "a text on a path follows its curve: move the curve, or take the text off it")); } let q = at - center; let (sin, cos) = rotation.sin_cos(); let at = center + Vec2::new(q.x * cos - q.y * sin, q.x * sin + q.y * cos) + translate; let angle = angle + rotation; let geom = solidcraft_sketch::text_geometry_styled(font.as_deref(), &text, at, height, angle, &style, None)?; let source = LinkSource::Text { text, at, height, angle, font, style }; let link = if copy { sk.add_link(LinkKind::Text, source, &geom)? } else { sk.set_link_source(link, source); if sk.update_link(link, &geom)? { return Err(bad("sketch.move", "moving text must preserve its outline topology; edit the text source first")); } link.to_owned() }; Ok(sk.link_curves(&link)) } fn edit_text(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.edit_text"; let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?; let mut doc = (*s.doc).clone(); let mut sk = doc.sketch(id)?.clone(); let out = apply_text_edit(&mut sk, p)?; *doc.sketch_mut(id)? = sk; *s.doc_mut() = doc; Ok(out) } /// One frame of an interactive resize or rotation: the sketch is solved and shown, the /// features after it keep their last result until `sketch.drag_end` evaluates the model. fn edit_text_live(s: &mut Session, p: &Value) -> Result<(Value, Option)> { let cmd = "sketch.edit_text"; let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?; let (out, report) = super::sketch::edit_solved(s, id, false, |sk, _| Ok((apply_text_edit(sk, p)?, super::sketch::Left::Unsolved)))?; Ok((out, report.map(|report| crate::LiveEdit { sketch: id, report }))) } /// `sketch.edit_text`'s change to its sketch: the text source's new values and its outlines /// regenerated from them. fn apply_text_edit(sk: &mut Sketch, p: &Value) -> Result { let cmd = "sketch.edit_text"; let l = match (str_(p, "link"), str_(p, "entity")) { (Some(l), _) => l.to_string(), (None, Some(e)) => sk.curves.iter().find(|c| c.id == e).and_then(|c| c.link.clone()).ok_or_else(|| bad(cmd, format!("`{e}` is not text")))?, _ => return Err(bad(cmd, "give `link` or `entity`")), }; let old = match sk.link(&l).map(|x| (x.kind, x.source.clone())) { Some((LinkKind::Text, LinkSource::Text { text, at, height, angle, font, style })) => (text, at, height, angle, font, style), _ => return Err(bad(cmd, format!("`{l}` is not text"))), }; let src = text_source(p, cmd, Some(old))?; let geom = sk.text_source_geometry(&src).map_err(|e| bad(cmd, e.to_string()))?; if geom.is_empty() { return Err(bad(cmd, "the text makes no outline")); } let guide = guide(sk, &src); sk.set_link_source(&l, src.clone()); sk.update_link(&l, &geom)?; let curves = ids_of(sk, &sk.link_curves(&l)); Ok(json!({"link": l, "curves": curves, "guide": guide})) } /// The curve ending at point `pi` and the direction leaving it there. fn end_of(sk: &Sketch, pi: usize) -> Option<(usize, Vec2)> { sk.curves.iter().enumerate().filter(|(_, c)| !c.construction).find_map(|(i, _)| leaving_dir(sk, i, pi).map(|d| (i, d))) } fn blend(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.blend_curve"; let (out, info) = edit(s, p, cmd, false, |sk, _| { let pa = req_parg(sk, p, "a", cmd)?.idx().ok_or_else(|| bad(cmd, "`a` must be the end point of a curve"))?; let pb = req_parg(sk, p, "b", cmd)?.idx().ok_or_else(|| bad(cmd, "`b` must be the end point of a curve"))?; let (ca, ta) = end_of(sk, pa).ok_or_else(|| bad(cmd, "no curve ends at `a`"))?; let (cb, tb) = end_of(sk, pb).ok_or_else(|| bad(cmd, "no curve ends at `b`"))?; let (qa, qb) = (sk.point(pa).unwrap_or_default(), sk.point(pb).unwrap_or_default()); let l = qa.dist(qb); if l < 1e-9 || ca == cb && pa == pb { return Err(bad(cmd, "the ends must differ")); } let first_new = sk.points.len(); let c1 = sk.add_point(qa + ta * (l / 3.0), None)?; let c2 = sk.add_point(qb + tb * (l / 3.0), None)?; let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false }, None)?; let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?]; solve_new_only(sk, first_new); Ok((ids_of(sk, &[sp]), cons)) })?; Ok(result(out, info)) } fn centerline(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.centerline"; let ids = string_list(p, "curves"); if ids.is_empty() { return Err(bad(cmd, "`curves` must list lines")); } let want = crate::params::bool_(p, "value"); let (n, info) = edit(s, p, cmd, false, |sk, _| { for id in &ids { let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?; let cu = sk.curves.get_mut(c).ok_or_else(|| bad(cmd, "curve"))?; if !matches!(cu.kind, CurveKind::Line { .. }) { return Err(bad(cmd, format!("`{id}` is not a line"))); } cu.centerline = want.unwrap_or(!cu.centerline); if cu.centerline { cu.construction = false; } } Ok(ids.len()) })?; Ok(json!({"changed": n, "sketch": info})) } fn ellipse(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.ellipse"; let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?); let u = (m - c).normalized().ok_or_else(|| bad(cmd, "the major axis end must differ from the centre"))?; let a = c.dist(m); let r = match (num(p, "minor_radius"), p.get("minor").and_then(crate::params::vec2)) { (Some(r), _) => r, (None, Some(q)) => { // Minor radius so the ellipse passes through q. let d = q - c; let (x, y) = (d.dot(u), d.cross(u).abs()); let k = 1.0 - (x / a).powi(2); if k <= 1e-12 { return Err(bad(cmd, "the minor point must lie within the major axis span")); } y / k.sqrt() } _ => return Err(bad(cmd, "needs `minor` [x,y] or `minor_radius`")), }; if !(r > 1e-9 && r < 1e8) { return Err(bad(cmd, "the minor radius must be positive")); } let (out, info) = edit(s, p, cmd, false, |sk, _| { let ci = sk.add_point(c, None)?; let mi = sk.add_point(m, None)?; let e = sk.add_curve(CurveKind::Ellipse { c: ci, m: mi, r }, None)?; mark_construction(sk, &[e], p); Ok((ids_of(sk, &[e]), Vec::new())) })?; Ok(result(out, info)) } fn point_list(sk: &mut Sketch, p: &Value, cmd: &str) -> Result> { let list = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must be a list"))?; if list.len() < 2 || list.len() > solidcraft_sketch::MAX_SPLINE_POINTS { return Err(bad(cmd, "a spline needs 2 to 500 points")); } let mut out = Vec::new(); for v in list { let a = super::sketch::parg(sk, v, cmd)?; let i = match a.idx() { Some(i) => i, None => sk.add_point(a.pos(), None)?, }; if out.last() == Some(&i) || out.last().and_then(|l| sk.point(*l)).is_some_and(|q| q.dist(a.pos()) < 1e-9) { return Err(bad(cmd, "consecutive points must differ")); } out.push(i); } Ok(out) } fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> Result { let periodic = match p.get("periodic") { None => false, Some(Value::Bool(v)) => *v, _ => return Err(bad(cmd, "periodic must be boolean")), }; if periodic && (control || degree != 3) { return Err(bad(cmd, "periodic mode requires a cubic fit spline")); } let p = &super::sketch_project::snap_points(s, p, cmd)?; let (out, info) = edit(s, p, cmd, false, |sk, _| { let mut pts = point_list(sk, p, cmd)?; if periodic && sk.point(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?) != sk.point(*pts.last().ok_or_else(|| bad(cmd, "missing last point"))?) { pts.push(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?); } let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic }, None)?; mark_construction(sk, &[c], p); Ok((ids_of(sk, &[c]), Vec::new())) })?; Ok(result(out, info)) } fn spline_fit(s: &mut Session, p: &Value) -> Result { spline(s, p, "sketch.spline.fit_point", false, 3) } fn spline_cv3(s: &mut Session, p: &Value) -> Result { spline(s, p, "sketch.spline.control_point", true, 3) } fn spline_cv5(s: &mut Session, p: &Value) -> Result { spline(s, p, "sketch.spline.control_point_5", true, 5) } fn conic(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.conic"; let rho = num(p, "rho").unwrap_or(0.5); if !(rho > 1e-6 && rho < 1.0 - 1e-6) { return Err(bad(cmd, "`rho` must be between 0 and 1")); } let p = &super::sketch_project::snap_points(s, p, cmd)?; let (out, info) = edit(s, p, cmd, false, |sk, _| { let mut idx = Vec::new(); for k in ["start", "end", "apex"] { let a = req_parg(sk, p, k, cmd)?; idx.push(match a.idx() { Some(i) => i, None => sk.add_point(a.pos(), None)?, }); } let (a, b, x) = (idx[0], idx[1], idx[2]); if a == b || a == x || b == x { return Err(bad(cmd, "start, end and apex must differ")); } let c = sk.add_curve(CurveKind::Conic { a, b, apex: x, rho }, None)?; mark_construction(sk, &[c], p); Ok((ids_of(sk, &[c]), Vec::new())) })?; Ok(result(out, info)) } fn midpoint_line(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.line.midpoint"; let end = req_vec2(cmd, p, "end")?; let (out, info) = edit(s, p, cmd, false, |sk, _| { let m = req_parg(sk, p, "mid", cmd)?; let start = m.pos() * 2.0 - end; if start.dist(end) < 1e-9 { return Err(bad(cmd, "the end must differ from the midpoint")); } let l = sk.add_line(start, end, None, None, None)?; let mp = match m.idx() { Some(i) => i, None => sk.add_point(m.pos(), None)?, }; let k = add_c(sk, ConstraintKind::Midpoint { p: mp, l })?; mark_construction(sk, &[l], p); Ok((ids_of(sk, &[l]), vec![k])) })?; Ok(result(out, info)) } /// Direction leaving curve `ci` at its end point `pi` (continuing the curve past that end). fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option { match sk.curves.get(ci)?.kind { CurveKind::Line { a, b } => { let (pa, pb) = (sk.point(a)?, sk.point(b)?); if pi == b { (pb - pa).normalized() } else if pi == a { (pa - pb).normalized() } else { None } } CurveKind::Arc { c, a, b } => { let (pc, q) = (sk.point(c)?, sk.point(pi)?); let ccw = (q - pc).perp().normalized()?; if pi == b { Some(ccw) } else if pi == a { Some(-ccw) } else { None } } CurveKind::Spline { ref pts, control, periodic, .. } => { let q: Vec = pts.iter().filter_map(|i| sk.point(*i)).collect(); if pts.last() == Some(&pi) { solidcraft_sketch::spline_end_tangent_mode(&q, control, periodic, true) } else if pts.first() == Some(&pi) { solidcraft_sketch::spline_end_tangent_mode(&q, control, periodic, false).map(|t| -t) } else { None } } CurveKind::Conic { a, b, apex, .. } => { let x = sk.point(apex)?; if pi == b { (sk.point(b)? - x).normalized() } else if pi == a { (sk.point(a)? - x).normalized() } else { None } } _ => None, } } fn arc_tangent(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.arc.tangent"; let end = req_vec2(cmd, p, "end")?; let (out, info) = edit(s, p, cmd, false, |sk, _| { let st = req_parg(sk, p, "start", cmd)?; let pi = st.idx().ok_or_else(|| bad(cmd, "`start` must be the end point of a line or arc"))?; let ci = match str_(p, "curve") { Some(id) => sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?, None => sk .curves .iter() .enumerate() .find(|(i, c)| !c.construction && leaving_dir(sk, *i, pi).is_some()) .map(|(i, _)| i) .ok_or_else(|| bad(cmd, "no line or arc ends at `start`"))?, }; let t = leaving_dir(sk, ci, pi).ok_or_else(|| bad(cmd, "`start` is not an end of that curve"))?; let s0 = st.pos(); let n = t.perp(); let d = end - s0; let den = 2.0 * n.dot(d); if den.abs() < 1e-9 * d.len().max(1e-9) || d.len() < 1e-9 { return Err(bad(cmd, "the end is straight ahead: draw a line instead")); } let center = s0 + n * (d.len2() / den); let a = if den > 0.0 { sk.add_arc(center, s0, end, [None, Some(pi), None], None)? } else { let a = sk.add_arc(center, end, s0, [None, None, Some(pi)], None)?; if let Some(c) = sk.curves.get_mut(a) { c.reversed = true; } a }; let k = add_c(sk, ConstraintKind::Tangent { a: ci, b: a })?; Ok((ids_of(sk, &[a]), vec![k])) })?; Ok(result(out, info)) } /// Initial radius for a circle near `at` touching the curves: the mean distance to them. fn guess_radius(sk: &Sketch, curves: &[usize], at: Vec2) -> f64 { let ds: Vec = curves.iter().filter_map(|c| sk.shape(*c)).map(|sh| sh.project(at).dist(at)).collect(); let r = ds.iter().sum::() / ds.len().max(1) as f64; if r.is_finite() && r > 1e-6 { r } else { 1.0 } } fn tangent_curves(sk: &Sketch, p: &Value, cmd: &str, n: usize) -> Result> { let ids = string_list(p, "curves"); if ids.len() != n { return Err(bad(cmd, format!("`curves` must list {n} curves"))); } ids.iter().map(|id| sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))).collect() } /// A circle tangent to `curves`, solved from a start near `near`. fn tangent_circle(s: &mut Session, p: &Value, cmd: &str, n: usize, radius: Option) -> Result { let near = req_vec2(cmd, p, "near")?; let (out, info) = edit(s, p, cmd, true, |sk, doc| { let cs = tangent_curves(sk, p, cmd, n)?; let r0 = match &radius { Some(e) => doc.eval(e, solidcraft_doc::expr::Kind::Length).map_err(|e| bad(cmd, format!("radius: {e}")))?, None => guess_radius(sk, &cs, near), }; if !(r0 > 1e-9 && r0 < 1e8) { return Err(bad(cmd, "radius must be positive")); } // Start where the circle already nearly touches: move the centre so the guessed circle // sits between the curves. let first_new = sk.points.len(); let c = sk.add_circle(near, r0, None, None)?; let mut cons = Vec::new(); for k in &cs { cons.push(add_c(sk, ConstraintKind::Tangent { a: *k, b: c })?); } if let Some(e) = &radius { let pname = doc.new_model_param(e, "mm"); let k = sk.add_constraint(ConstraintKind::Radius { c, value: r0 }, Some(pname))?; cons.push(k); } solve_new_only(sk, first_new); Ok((ids_of(sk, &[c]), cons)) })?; if info["solved"] != json!(true) { return Err(bad(cmd, "no tangent circle there")); } Ok(result(out, info)) } fn circle_tan_tan(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.circle.two_tangent"; let r = match (crate::params::expr(p, "radius"), num(p, "diameter")) { (Some(r), _) => r, (None, Some(d)) => format!("{}", d / 2.0), _ => return Err(bad(cmd, "needs `radius` or `diameter`")), }; tangent_circle(s, p, cmd, 2, Some(r)) } fn circle_three_tan(s: &mut Session, p: &Value) -> Result { tangent_circle(s, p, "sketch.circle.three_tangent", 3, None) } fn width(p: &Value, cmd: &str) -> Result { num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive")) } fn slot_center_point(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.slot.center_point"; let (c, e) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "end")?); let w = width(p, cmd)?; slot(s, p, cmd, c * 2.0 - e, e, w) } /// Arc slot around `o` (centre-line radius `r`) from angle `t0` sweeping `sweep` (> 0). fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, sweep: f64, w: f64) -> Result { let h = w / 2.0; if h >= r { return Err(bad(cmd, "the slot is wider than its centre arc's diameter")); } if !(sweep > 1e-6 && sweep < std::f64::consts::TAU - 1e-6) { return Err(bad(cmd, "the centre arc must have a length")); } let t1 = t0 + sweep; let at = |t: f64, rr: f64| o + Vec2::from_angle(t) * rr; let (out, info) = edit(s, p, cmd, false, |sk, _| { let oc = sk.add_point(o, None)?; let ps = sk.add_point(at(t0, r), None)?; let pe = sk.add_point(at(t1, r), None)?; let (os, is) = (sk.add_point(at(t0, r + h), None)?, sk.add_point(at(t0, r - h), None)?); let (oe, ie) = (sk.add_point(at(t1, r + h), None)?, sk.add_point(at(t1, r - h), None)?); let outer = sk.add_arc(o, at(t0, r + h), at(t1, r + h), [Some(oc), Some(os), Some(oe)], None)?; let inner = sk.add_arc(o, at(t0, r - h), at(t1, r - h), [Some(oc), Some(is), Some(ie)], None)?; let cap_e = sk.add_arc(at(t1, r), at(t1, r + h), at(t1, r - h), [Some(pe), Some(oe), Some(ie)], None)?; let cap_s = sk.add_arc(at(t0, r), at(t0, r - h), at(t0, r + h), [Some(ps), Some(is), Some(os)], None)?; let centre = sk.add_arc(o, at(t0, r), at(t1, r), [Some(oc), Some(ps), Some(pe)], None)?; if let Some(c) = sk.curves.get_mut(centre) { c.construction = true; } let mut cons = Vec::new(); for (a, b) in [(outer, cap_e), (inner, cap_e), (outer, cap_s), (inner, cap_s)] { cons.push(add_c(sk, ConstraintKind::Tangent { a, b })?); } cons.push(add_c(sk, ConstraintKind::Equal { a: cap_s, b: cap_e })?); Ok((ids_of(sk, &[outer, cap_e, inner, cap_s]), cons)) })?; Ok(result(out, info)) } fn arc_slot_three(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.slot.arc_three_point"; let (a, t, b) = (req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "through")?, req_vec2(cmd, p, "end")?); let w = width(p, cmd)?; let (o, r) = circumcircle(a, t, b).ok_or_else(|| bad(cmd, "the points are collinear"))?; let ang = |q: Vec2| (q - o).angle(); let span = |from: f64, to: f64| (to - from).rem_euclid(std::f64::consts::TAU); // Counter-clockwise from start must pass the through point; otherwise go from the end. let (t0, sweep) = if span(ang(a), ang(t)) < span(ang(a), ang(b)) { (ang(a), span(ang(a), ang(b))) } else { (ang(b), span(ang(b), ang(a))) }; arc_slot(s, p, cmd, o, r, t0, sweep, w) } fn arc_slot_center(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.slot.arc_center"; let (o, a, b) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "end")?); let w = width(p, cmd)?; let r = o.dist(a); if r < 1e-9 || o.dist(b) < 1e-9 { return Err(bad(cmd, "the points must differ from the centre")); } let t0 = (a - o).angle(); let sweep = ((b - o).angle() - t0).rem_euclid(std::f64::consts::TAU); arc_slot(s, p, cmd, o, r, t0, sweep, w) } /// Solve moving only the points from `first_new` on (the geometry a tool just made), so the /// existing drawing stays put when the new entities alone can satisfy the constraints. Falls /// back to leaving everything to the normal solve. pub(super) fn solve_new_only(sk: &mut Sketch, first_new: usize) { let mut trial = sk.clone(); for (i, p) in trial.points.iter_mut().enumerate() { if i < first_new { p.fixed = true; } } if solidcraft_sketch::solve(&mut trial).ok() { for (p, t) in sk.points.iter_mut().zip(&trial.points).skip(first_new) { p.pos = t.pos; } for (c, t) in sk.curves.iter_mut().zip(&trial.curves) { if let (CurveKind::Circle { r, .. }, CurveKind::Circle { r: tr, .. }) = (&mut c.kind, &t.kind) { *r = *tr; } } } }