diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 2777e0fe..67702f62 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -18,6 +18,7 @@ Generated-in-code assets are original and have no file to list: | File | Author | Source | Licence | |---|---|---|---| | docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 | +| crates/sketch/fonts/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/fonts/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 | ## Code adapted from sibling projects diff --git a/Cargo.lock b/Cargo.lock index 7273afc6..1d81a203 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3235,6 +3235,7 @@ dependencies = [ "serde_json", "solvecraft-geom", "thiserror 2.0.21", + "ttf-parser", ] [[package]] @@ -3616,6 +3617,12 @@ dependencies = [ "truck-geotrait", ] +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "type-map" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 826e49fb..55ee44eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,9 @@ truck-topology = "0.6" zip = { version = "3", default-features = false, features = ["deflate"] } quick-xml = "0.38" +# fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust) +ttf-parser = { version = "0.25", default-features = false, features = ["std"] } + # images image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/doc/src/project.rs b/crates/doc/src/project.rs index 4a6521e0..34255563 100644 --- a/crates/doc/src/project.rs +++ b/crates/doc/src/project.rs @@ -136,6 +136,7 @@ pub fn resolve(doc: &Document, vals: &BTreeMap, st: &ModelState, _ => vec![LinkGeom::Point(o)], } } + LinkSource::Text { text, at, height, angle } => solvecraft_sketch::text_geometry(text, *at, *height, *angle)?, LinkSource::Plane { name } => { let pr = if Plane::named(name).is_some() { PlaneRef::Origin { name: name.clone() } } else { PlaneRef::Construction { name: name.clone() } }; diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index e56313a3..ae670c4d 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; use solvecraft_geom::Vec2; -use solvecraft_sketch::{ConstraintKind, CurveKind, Sketch}; +use solvecraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch}; use super::sketch::{add_c, circumcircle, edit, ids_of, mark_construction, req_parg, result, slot}; use super::{CommandSpec, in_sketch}; @@ -70,8 +70,75 @@ pub static COMMANDS: &[CommandSpec] = .icon("conic") .enabled(in_sketch) .params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"), + CommandSpec::new("MTextCmd", "Text", text) + .at("SKETCH", "CREATE") + .icon("text") + .enabled(in_sketch) + .params("text: string (\\n breaks lines), at: baseline start [x,y], height?: capital height mm (default 5), angle?: deg"), + CommandSpec::new("sketch.edit_text", "Edit Text", edit_text) + .enabled(in_sketch) + .params("link: the text's link id (or entity: one of its curves), text?, at?, height?, angle?: deg"), ]; +fn text_source(p: &Value, cmd: &str, old: Option<(String, Vec2, f64, f64)>) -> Result { + let (t0, at0, h0, a0) = old.unwrap_or((String::new(), Vec2::ZERO, 5.0, 0.0)); + let text = str_(p, "text").map(str::to_string).unwrap_or(t0); + if text.trim().is_empty() { + return Err(bad(cmd, "`text` must not be empty")); + } + if text.chars().count() > solvecraft_sketch::MAX_TEXT_CHARS { + return Err(bad(cmd, "text too long")); + } + let at = p.get("at").and_then(crate::params::vec2).unwrap_or(at0); + let height = num(p, "height").unwrap_or(h0); + if !(height > 1e-6 && height < 1e5) { + return Err(bad(cmd, "`height` must be positive")); + } + let angle = num(p, "angle").map(f64::to_radians).unwrap_or(a0); + Ok(LinkSource::Text { text, at, height, angle }) +} + +fn text(s: &mut Session, p: &Value) -> Result { + let cmd = "MTextCmd"; + if p.get("at").is_none() { + return Err(bad(cmd, "`at` must be the baseline start [x, y]")); + } + 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(); + let l = super::sketch_project::add_link(s, &doc, id, &mut sk, LinkKind::Text, src)?; + let curves = ids_of(&sk, &sk.link_curves(&l)); + *doc.sketch_mut(id)? = sk; + *s.doc_mut() = doc; + Ok(json!({"link": l, "curves": curves})) +} + +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 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 })) => (text, at, height, angle), + _ => return Err(bad(cmd, format!("`{l}` is not text"))), + }; + let src = text_source(p, cmd, Some(old))?; + let LinkSource::Text { text, at, height, angle } = &src else { return Err(bad(cmd, "text")) }; + let geom = solvecraft_sketch::text_geometry(text, *at, *height, *angle)?; + sk.set_link_source(&l, src.clone()); + sk.update_link(&l, &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})) +} + fn ellipse(s: &mut Session, p: &Value) -> Result { let cmd = "CircleElipse"; let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?); diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 66e06c88..6fc9777d 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -260,3 +260,27 @@ fn ellipse_splines_conic() { let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [eid, sid], "line": m})); assert_eq!(ids(&r["curves"]).len(), 2); } + +#[test] +fn text_makes_profiles_that_extrude_and_edit() { + let mut s = new_sketch(); + let r = run(&mut s, "MTextCmd", json!({"text": "SO", "at": [0, 0], "height": 10})); + let link = r["link"].as_str().unwrap().to_string(); + let a = profiles(&s); + // S (one region) + O (ring = outer with a hole, plus the counter as its own region). + assert!(a.len() >= 2, "{a:?}"); + let si = run(&mut s, "sketch.inspect", json!({})); + assert_eq!(si["dof"], 0); + // Edit the text: the outlines change, the link stays. + let r2 = run(&mut s, "sketch.edit_text", json!({"link": link, "text": "I", "height": 20})); + assert_eq!(r2["link"], json!(link)); + let a = profiles(&s); + assert_eq!(a.len(), 1, "{a:?}"); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"distance": 2})); + let m = run(&mut s, "MeasureCommand", json!({})); + let v = m["total"]["volume_mm3"].as_f64().unwrap(); + // An "I" 20 mm tall is a bar a couple of mm wide. + assert!(v > 40.0 && v < 200.0, "{v}"); + assert!(s.execute("MTextCmd", &json!({"text": "", "at": [0, 0]})).is_err()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c9e423d8..d7bfc7bf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -281,6 +281,7 @@ fn hostile_params_never_panic() { "minor_radius", "apex", "rho", + "text", ]; let mut internal = Vec::new(); for spec in command_specs() { diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 1197afad..f26ab462 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -69,7 +69,7 @@ pub fn grid_step(half_height: f64) -> (f64, f64) { pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec, Rgb, bool)> { let mut out = Vec::new(); for (i, c) in sk.curves.iter().enumerate() { - let col = if c.link.is_some() { + let col = if c.link.is_some() && !sk.is_text_curve(i) { if sk.is_lost_curve(i) { colors::SKETCH_LOST } else { colors::SKETCH_PROJECTED } } else if c.construction { colors::SKETCH_CONSTRUCTION diff --git a/crates/sketch/Cargo.toml b/crates/sketch/Cargo.toml index 782c883b..f8fa7300 100644 --- a/crates/sketch/Cargo.toml +++ b/crates/sketch/Cargo.toml @@ -12,6 +12,7 @@ workspace = true solvecraft-geom = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } +ttf-parser = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/sketch/fonts/LiberationSans-Regular.ttf b/crates/sketch/fonts/LiberationSans-Regular.ttf new file mode 100644 index 00000000..328ef8be Binary files /dev/null and b/crates/sketch/fonts/LiberationSans-Regular.ttf differ diff --git a/crates/sketch/fonts/OFL.txt b/crates/sketch/fonts/OFL.txt new file mode 100644 index 00000000..5c03520c --- /dev/null +++ b/crates/sketch/fonts/OFL.txt @@ -0,0 +1,97 @@ +Liberation Sans (crates/sketch/fonts/LiberationSans-Regular.ttf) + +Digitized data copyright (c) 2010 Google Corporation with Reserved Font Arimo, Tinos and Cousine. +Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +PREAMBLE The goals of the Open Font License (OFL) are to stimulate +worldwide development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to provide +a free and open framework in which fonts may be shared and improved in +partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. +The fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + + + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. +This may include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components +as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting ? in part or in whole ? +any of the components of the Original Version, by changing formats or +by porting the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer +or other person who contributed to the Font Software. + + +PERMISSION & CONDITIONS + +Permission is hereby granted, free of charge, to any person obtaining a +copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components,in + Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the + corresponding Copyright Holder. This restriction only applies to the + primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + +5) The Font Software, modified or unmodified, in part or in whole, must + be distributed entirely under this license, and must not be distributed + under any other license. The requirement for fonts to remain under + this license does not apply to any document created using the Font + Software. + + + +TERMINATION +This license becomes null and void if any of the above conditions are not met. + + + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER +DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/sketch/src/curves.rs b/crates/sketch/src/curves.rs index 9db6a6a5..cd4da17b 100644 --- a/crates/sketch/src/curves.rs +++ b/crates/sketch/src/curves.rs @@ -34,7 +34,10 @@ pub fn conic_point(a: Vec2, apex: Vec2, b: Vec2, rho: f64, t: f64) -> Vec2 { } pub fn conic_polyline(a: Vec2, apex: Vec2, b: Vec2, rho: f64) -> Vec { - let n = SPAN_SAMPLES * 2; + // Samples by how far the curve bows from its chord (small text curves need few). + let chord = b - a; + let bow = chord.cross(apex - a).abs() / chord.len().max(1e-12); + let n = ((bow / 2e-3).sqrt().ceil() as usize).clamp(2, SPAN_SAMPLES * 2); (0..=n).map(|i| conic_point(a, apex, b, rho, i as f64 / n as f64)).collect() } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index e2d1f261..0b72601b 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -15,6 +15,7 @@ mod model; mod profiles; mod shape; mod solver; +mod text; pub use curves::{MAX_SPLINE_POINTS, conic_point, ellipse_point, spline_end_tangent, spline_polyline}; pub use link::{Link, LinkGeom, LinkKind, LinkSource, MAX_LINK_ENTITIES}; @@ -22,6 +23,7 @@ pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, Sk pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use shape::{Shape, intersections}; pub use solver::{SolveReport, SolveStatus, solve}; +pub use text::{MAX_TEXT_CHARS, text_geometry}; #[cfg(test)] mod tests; diff --git a/crates/sketch/src/link.rs b/crates/sketch/src/link.rs index 2397f147..42901718 100644 --- a/crates/sketch/src/link.rs +++ b/crates/sketch/src/link.rs @@ -27,6 +27,8 @@ pub enum LinkKind { Include, /// Section curves of a body or face with the sketch plane. Intersect, + /// Text outlines (drawn like normal geometry; edit the text to change them). + Text, } /// What a link refers to. Model geometry is referenced by a point on it plus the body name and @@ -53,6 +55,14 @@ pub enum LinkSource { /// An origin plane (`XY`, `XZ`, `YZ`) or a construction plane (by name): its trace on the /// sketch plane. Plane { name: String }, + /// Text: baseline start `at` (sketch coordinates), capital height and angle (radians). + Text { + text: String, + at: Vec2, + height: f64, + #[serde(default)] + angle: f64, + }, } impl LinkSource { @@ -68,6 +78,7 @@ impl LinkSource { LinkSource::Origin => "origin".into(), LinkSource::Axis { name } => format!("{name} axis"), LinkSource::Plane { name } => format!("{name} plane"), + LinkSource::Text { text, .. } => format!("text \"{}\"", text.chars().take(20).collect::()), } } } @@ -96,6 +107,13 @@ pub enum LinkGeom { a: Vec2, b: Vec2, }, + /// Conic from `a` to `b` with control `apex` (rho 0.5: a quadratic Bézier). + Conic { + a: Vec2, + apex: Vec2, + b: Vec2, + rho: f64, + }, } impl LinkGeom { @@ -106,6 +124,7 @@ impl LinkGeom { LinkGeom::Line(a, b) => ok(a) && ok(b) && a.dist(*b) > MERGE_TOL, LinkGeom::Circle(c, r) => ok(c) && r.is_finite() && *r > 1e-9 && *r < 1e9, LinkGeom::Arc { c, a, b } => ok(c) && ok(a) && ok(b) && c.dist(*a) > 1e-9 && a.dist(*b) > MERGE_TOL, + LinkGeom::Conic { a, apex, b, rho } => ok(a) && ok(apex) && ok(b) && a.dist(*b) > MERGE_TOL && *rho > 1e-6 && *rho < 1.0 - 1e-6, } } } @@ -123,6 +142,7 @@ enum LCurve { Line(usize, usize), Circle(usize, f64), Arc(usize, usize, usize), + Conic(usize, usize, usize, f64), } fn layout(geom: &[LinkGeom]) -> Result { @@ -163,6 +183,12 @@ fn layout(geom: &[LinkGeom]) -> Result { l.curves.push(LCurve::Arc(c, a, b)); } } + LinkGeom::Conic { a, apex, b, rho } => { + let (a, b, x) = (pt(&mut l.pts, a), pt(&mut l.pts, b), pt(&mut l.pts, apex)); + if a != b && x != a && x != b { + l.curves.push(LCurve::Conic(a, b, x, rho)); + } + } } } // Lone points that a curve also uses are not lone. @@ -171,6 +197,7 @@ fn layout(geom: &[LinkGeom]) -> Result { LCurve::Line(a, b) => a == i || b == i, LCurve::Circle(c, _) => c == i, LCurve::Arc(c, a, b) => c == i || a == i || b == i, + LCurve::Conic(a, b, x, _) => a == i || b == i || x == i, }) }; let curves = l.curves.clone(); @@ -179,7 +206,13 @@ fn layout(geom: &[LinkGeom]) -> Result { } fn same_shape(a: &LCurve, b: &LCurve) -> bool { - matches!((a, b), (LCurve::Line(..), LCurve::Line(..)) | (LCurve::Circle(..), LCurve::Circle(..)) | (LCurve::Arc(..), LCurve::Arc(..))) + matches!( + (a, b), + (LCurve::Line(..), LCurve::Line(..)) + | (LCurve::Circle(..), LCurve::Circle(..)) + | (LCurve::Arc(..), LCurve::Arc(..)) + | (LCurve::Conic(..), LCurve::Conic(..)) + ) } impl Sketch { @@ -202,6 +235,11 @@ impl Sketch { self.curves.get(ci).is_some_and(|c| c.link.is_some()) } + /// Is this curve text outline (linked, but drawn like normal geometry)? + pub fn is_text_curve(&self, ci: usize) -> bool { + self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.kind == LinkKind::Text) + } + /// Is the curve's link lost (its reference no longer found)? pub fn is_lost_curve(&self, ci: usize) -> bool { self.curves.get(ci).and_then(|c| c.link.as_deref()).and_then(|l| self.link(l)).is_some_and(|l| l.lost) @@ -245,6 +283,7 @@ impl Sketch { LCurve::Line(a, b) => (CurveKind::Line { a: point(self, a)?, b: point(self, b)? }, "l"), LCurve::Circle(c, r) => (CurveKind::Circle { c: point(self, c)?, r }, "c"), LCurve::Arc(c, a, b) => (CurveKind::Arc { c: point(self, c)?, a: point(self, a)?, b: point(self, b)? }, "a"), + LCurve::Conic(a, b, x, rho) => (CurveKind::Conic { a: point(self, a)?, b: point(self, b)?, apex: point(self, x)?, rho }, "k"), }; let cid = self.fresh(prefix); self.curves.push(Curve { id: cid, kind, construction: false, reversed: false, link: Some(id.to_string()) }); @@ -274,6 +313,7 @@ impl Sketch { Some(CurveKind::Line { a, b }) => Some(LCurve::Line(local(*a)?, local(*b)?)), Some(CurveKind::Circle { c, r }) => Some(LCurve::Circle(local(*c)?, *r)), Some(CurveKind::Arc { c, a, b }) => Some(LCurve::Arc(local(*c)?, local(*a)?, local(*b)?)), + Some(CurveKind::Conic { a, b, apex, rho }) => Some(LCurve::Conic(local(*a)?, local(*b)?, local(*apex)?, *rho)), _ => None, }) .collect(); @@ -297,6 +337,11 @@ impl Sketch { visit(a); visit(b); } + LCurve::Conic(a, b, x, _) => { + visit(a); + visit(b); + visit(x); + } } } for i in &lay.lone { @@ -310,6 +355,7 @@ impl Sketch { LCurve::Line(a, b) => LCurve::Line(renum(a), renum(b)), LCurve::Circle(c, r) => LCurve::Circle(renum(c), r), LCurve::Arc(c, a, b) => LCurve::Arc(renum(c), renum(a), renum(b)), + LCurve::Conic(a, b, x, r) => LCurve::Conic(renum(a), renum(b), renum(x), r), }) .collect(); let same = match &cur { @@ -322,6 +368,7 @@ impl Sketch { (LCurve::Line(a0, a1), LCurve::Line(b0, b1)) => a0 == b0 && a1 == b1, (LCurve::Circle(a0, _), LCurve::Circle(b0, _)) => a0 == b0, (LCurve::Arc(a0, a1, a2), LCurve::Arc(b0, b1, b2)) => a0 == b0 && a1 == b1 && a2 == b2, + (LCurve::Conic(a0, a1, a2, _), LCurve::Conic(b0, b1, b2, _)) => a0 == b0 && a1 == b1 && a2 == b2, _ => false, } }) @@ -342,6 +389,9 @@ impl Sketch { if let (LCurve::Circle(_, r), Some(Curve { kind: CurveKind::Circle { r: slot, .. }, .. })) = (c, self.curves.get_mut(*ci)) { *slot = *r; } + if let (LCurve::Conic(.., r), Some(Curve { kind: CurveKind::Conic { rho, .. }, .. })) = (c, self.curves.get_mut(*ci)) { + *rho = *r; + } } return Ok(false); } diff --git a/crates/sketch/src/text.rs b/crates/sketch/src/text.rs new file mode 100644 index 00000000..f0f378b0 --- /dev/null +++ b/crates/sketch/src/text.rs @@ -0,0 +1,129 @@ +//! Sketch text: glyph outlines of an embedded open font (Liberation Sans, SIL OFL 1.1; see +//! `fonts/OFL.txt` and ATTRIBUTION.md) as lines and conics (TrueType quadratic Béziers are +//! conics with rho = 0.5), so text closes into profiles that can be extruded. + +use solvecraft_geom::Vec2; + +use crate::link::LinkGeom; +use crate::model::{Result, SketchError}; + +static FONT: &[u8] = include_bytes!("../fonts/LiberationSans-Regular.ttf"); + +/// Most characters in one text. +pub const MAX_TEXT_CHARS: usize = 1000; + +struct Outline { + out: Vec, + start: Vec2, + cur: Vec2, + map: Box Vec2>, +} + +impl Outline { + fn line(&mut self, to: Vec2) { + if self.cur.dist(to) > 1e-9 { + self.out.push(LinkGeom::Line(self.cur, to)); + } + self.cur = to; + } + fn quad(&mut self, apex: Vec2, to: Vec2) { + let (a, b) = (self.cur, to); + let chord = b - a; + let off = chord.cross(apex - a).abs() / chord.len().max(1e-12); + if off < 1e-6 || a.dist(b) < 1e-9 { + self.line(to); + return; + } + self.out.push(LinkGeom::Conic { a, apex, b, rho: 0.5 }); + self.cur = to; + } +} + +impl ttf_parser::OutlineBuilder for Outline { + fn move_to(&mut self, x: f32, y: f32) { + let p = (self.map)(x, y); + self.start = p; + self.cur = p; + } + fn line_to(&mut self, x: f32, y: f32) { + let p = (self.map)(x, y); + self.line(p); + } + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + let (c, p) = ((self.map)(x1, y1), (self.map)(x, y)); + self.quad(c, p); + } + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + // A cubic as two quadratics (split at the middle). + let (p0, c1, c2, p3) = (self.cur, (self.map)(x1, y1), (self.map)(x2, y2), (self.map)(x, y)); + let mid = (p0 + c1 * 3.0 + c2 * 3.0 + p3) / 8.0; + let q1 = p0 + (c1 - p0) * 0.75; + let q2 = p3 + (c2 - p3) * 0.75; + self.quad(q1, mid); + self.quad(q2, p3); + } + fn close(&mut self) { + let s = self.start; + self.line(s); + } +} + +/// Outline geometry of `text` with its baseline starting at `at`, capital height `height` +/// (mm), rotated by `angle` (radians) about `at`. Lines break at `\n`. +pub fn text_geometry(text: &str, at: Vec2, height: f64, angle: f64) -> Result> { + if text.chars().count() > MAX_TEXT_CHARS { + return Err(SketchError::TooLarge); + } + if !(height.is_finite() && height > 1e-6 && height < 1e6) || !angle.is_finite() || !at.is_finite() { + return Err(SketchError::Invalid("text height must be positive".into())); + } + let face = ttf_parser::Face::parse(FONT, 0).map_err(|e| SketchError::Invalid(format!("font: {e}")))?; + let cap = face.capital_height().map(f64::from).filter(|c| *c > 0.0).unwrap_or(f64::from(face.units_per_em()) * 0.7); + let k = height / cap; + let line_gap = f64::from(face.ascender() - face.descender() + face.line_gap()) * k; + let (s, c) = angle.sin_cos(); + let mut out = Vec::new(); + for (row, line) in text.split('\n').enumerate() { + let mut pen = 0.0f64; + let base_y = -(row as f64) * line_gap; + for ch in line.chars() { + let Some(gid) = face.glyph_index(ch).or_else(|| face.glyph_index('?')) else { continue }; + let x0 = pen; + let map = Box::new(move |x: f32, y: f32| { + let (lx, ly) = (x0 + f64::from(x) * k, base_y + f64::from(y) * k); + at + Vec2::new(lx * c - ly * s, lx * s + ly * c) + }); + let mut ob = Outline { out: Vec::new(), start: at, cur: at, map }; + let _ = face.outline_glyph(gid, &mut ob); + out.extend(ob.out); + pen += f64::from(face.glyph_hor_advance(gid).unwrap_or(0)) * k; + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn letters_have_closed_outlines_at_the_cap_height() { + let g = text_geometry("HO", Vec2::new(5.0, 0.0), 10.0, 0.0).unwrap(); + assert!(!g.is_empty()); + let ys: Vec = g + .iter() + .flat_map(|x| match *x { + LinkGeom::Line(a, b) => vec![a.y, b.y], + LinkGeom::Conic { a, b, .. } => vec![a.y, b.y], + _ => vec![], + }) + .collect(); + let top = ys.iter().cloned().fold(f64::MIN, f64::max); + assert!((top - 10.0).abs() < 0.3, "{top}"); + assert!(g.iter().any(|x| matches!(x, LinkGeom::Conic { .. })), "O has curves"); + assert!(text_geometry("x", Vec2::ZERO, -1.0, 0.0).is_err()); + assert!(text_geometry(&"x".repeat(2000), Vec2::ZERO, 1.0, 0.0).is_err()); + // Empty text makes nothing (not an error here; the command rejects it). + assert!(text_geometry("", Vec2::ZERO, 1.0, 0.0).unwrap().is_empty()); + } +} diff --git a/docs/parity.md b/docs/parity.md index adac3979..60522dc7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. -**SOLID + SKETCH: 89 / 291 commands live (31%).** All Design tabs: 182 / 1175 (15%). Registered commands: 115. +**SOLID + SKETCH: 90 / 291 commands live (31%).** All Design tabs: 183 / 1175 (16%). Registered commands: 117. | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| @@ -15,7 +15,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool | SOLID | INSERT | 1 | 11 | 9% | | SOLID | SELECT | 0 | 15 | 0% | | SOLID | POSITION | 0 | 2 | 0% | -| SKETCH | CREATE | 34 | 41 | 83% | +| SKETCH | CREATE | 35 | 41 | 85% | | SKETCH | MODIFY | 11 | 12 | 92% | | SKETCH | CONSTRAINTS | 11 | 15 | 73% | | SKETCH | CONFIGURE | 0 | 3 | 0% | @@ -158,6 +158,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SKETCH › CREATE › Control Point Spline (`DrawCVMSpline5D`) - SKETCH › CREATE › Conic Curve (`ConicCurveCmd`) - SKETCH › CREATE › Point (`DrawPoint`) +- SKETCH › CREATE › Text (`MTextCmd`) - SKETCH › CREATE › Mirror (`MirrorSketchCommand`) - SKETCH › CREATE › Circular Pattern (`CircularSketchPatternCommand`) - SKETCH › CREATE › Rectangular Pattern (`RectangularSketchPatternCommand`) @@ -330,7 +331,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › POSITION › Capture Position (`SnapshotCmd`) - SOLID › POSITION › Revert Position (`AsBuiltPositionsCmd`) - SKETCH › CREATE › SketchConstrainer (`SketchConstrainer`) -- SKETCH › CREATE › Text (`MTextCmd`) - SKETCH › CREATE › Fit Curves to Mesh Section (`FitCurvesToSectionCommand`) - SKETCH › CREATE › Spun Profile (`SpunProfileCmd`) - SKETCH › CREATE › Project To Surface (`ProjectToSurface`)