diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 09d5e585..8542351e 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1063,10 +1063,12 @@ pub fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result = match sel { // "All" means what was drawn: projected reference geometry (a face's auto-projected - // edges) only counts when the drawn curves alone make no profile. + // edges) only counts when the drawn curves alone make no profile. Text is drawn, and + // its letters keep their counters as holes (the counters are not filled in). ProfileSel::All => { let drawn = solidcraft_sketch::find_drawn_profiles(&ss.sketch); - if drawn.is_empty() { ps.iter().map(|p| p.region.clone()).collect() } else { drawn.into_iter().map(|p| p.region).collect() } + let chosen = if drawn.is_empty() { ps.clone() } else { drawn }; + solidcraft_sketch::without_text_counters(&ss.sketch, chosen).into_iter().map(|p| p.region).collect() } ProfileSel::Indices { indices } => { let mut v = Vec::new(); diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 35f48539..1fef2b5d 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -429,6 +429,72 @@ fn text_makes_profiles_that_extrude_and_edit() { assert!(s.execute("sketch.text", &json!({"text": "", "at": [0, 0]})).is_err()); } +/// Volumes (mm³) of the bodies in the design, smallest first. +fn body_volumes(s: &mut Session) -> Vec { + let m = run(s, "inspect.measure", json!({})); + let mut v: Vec = m["bodies"].as_array().unwrap().iter().map(|b| b["volume_mm3"].as_f64().unwrap()).collect(); + v.sort_by(f64::total_cmp); + v +} + +#[test] +fn text_extrudes_with_its_counters_and_beside_other_curves() { + // Letter areas in mm² at a capital height of 10 mm, summed from the Liberation Sans contours (shoelace + // formula, quadratic pieces flattened finely), each outer contour minus its counters: + // A 39.2159 - 9.0647 B 68.9283 - 12.9336 - 16.6277 8 56.8394 - 10.6451 - 13.7347 I 13.5557 + // Extruded 2 mm, so the volumes are twice that. Faceted walls: within 0.02 mm³. + let near = |got: &[f64], want: &[f64]| { + assert_eq!(got.len(), want.len(), "{got:?} against {want:?}"); + assert!(got.iter().zip(want).all(|(g, w)| (g - w).abs() < 0.02), "{got:?} against {want:?}"); + }; + // Letters with counters, alone: a letter is one body with its counters as holes. (A counter used to be + // extruded as a body of its own, or the extrude failed with "profile has no area".) + for (text, want) in + [("AB", vec![2.0 * (39.2159 - 9.0647), 2.0 * (68.9283 - 12.9336 - 16.6277)]), ("8", vec![2.0 * (56.8394 - 10.6451 - 13.7347)])] + { + let mut s = new_sketch(); + run(&mut s, "sketch.text", json!({"text": text, "at": [0, 0], "height": 10})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 2})); + near(&body_volumes(&mut s), &want); + } + // Text beside a drawn rectangle is extruded with it (it used to be left out): the 30 x 20 x 2 plate and an "I". + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]})); + run(&mut s, "sketch.text", json!({"text": "I", "at": [40, 0], "height": 10})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 2})); + near(&body_volumes(&mut s), &[2.0 * 13.5557, 1200.0]); + // A counter can still be picked on its own, by a point inside it: the inner contour of an O, 47.4584 mm². + let mut s = new_sketch(); + run(&mut s, "sketch.text", json!({"text": "O", "at": [0, 0], "height": 10})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 2, "profiles": [{"point": [5.65, 5.0]}]})); + near(&body_volumes(&mut s), &[2.0 * 47.4584]); +} + +#[test] +fn text_embossed_or_debossed_leaves_its_counters_alone() { + // A 40 x 30 x 5 plate (6000 mm³) with 10 mm tall text raised from, or cut 1 mm into, its top. Only the letters + // change the volume, by their area in mm² (see above) x 1 mm; the counters of the O and of A and B stay as + // they are. (Emboss and deboss used to fail with "boolean Cut: no result" or "boolean Union: no result", + // because each counter was a tool body of its own, filling the hole of its letter.) + let (o, ab) = (83.3133 - 47.4584, (39.2159 - 9.0647) + (68.9283 - 12.9336 - 16.6277)); + for (text, mode, change) in [("O", "deboss", -o), ("O", "emboss", o), ("AB", "deboss", -ab)] { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5, "body_name": "Plate"})); + run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Lid"})); + run(&mut s, "sketch.create", json!({"plane": "Lid", "name": "Text"})); + run(&mut s, "sketch.text", json!({"text": text, "at": [10, 10], "height": 10})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.emboss", json!({"sketch": "Text", "depth": 1, "mode": mode})); + let v = body_volumes(&mut s); + assert!(v.len() == 1 && (v[0] - (6000.0 + change)).abs() < 0.02, "{text} {mode}: {v:?} against {}", 6000.0 + change); + } +} + #[test] fn blend_curve_joins_tangentially() { let mut s = new_sketch(); diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 05a9f42c..1e5fba89 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -47,7 +47,10 @@ pub use model::{Constraint, ConstraintKind, Curve, CurveKind, MAX_CONSTRAINTS, M pub use offset::OffsetChain; pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology}; pub use periodic::periodic_beziers; -pub use profiles::{CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves}; +pub use profiles::{ + CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves, + without_text_counters, +}; pub use shape::{Shape, intersections}; pub use solver::{ Analysis, DrivingValues, Hold, SolveReport, SolveStatus, measure_dimension, solve, solve_after_value_edit, solve_holding, solve_with, diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index f2b0d8aa..42aedbbe 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -413,22 +413,80 @@ fn curvature(s: &Seg2) -> f64 { } } -/// Find all closed profiles of a sketch. Curves are split where other curves end on them or -/// cross them, like a drawing would be read. -/// Profiles of the drawn geometry only (linked reference curves left out). +/// Profiles of the drawn geometry only: linked reference curves (projections, intersections) +/// are left out; text outlines count as drawn. pub fn find_drawn_profiles(sk: &Sketch) -> Vec { - if sk.links.is_empty() { + if sk.links.iter().all(|l| l.kind == crate::LinkKind::Text) { return find_profiles(sk); } let mut d = sk.clone(); - for c in &mut d.curves { - if c.link.is_some() { + for (ci, c) in d.curves.iter_mut().enumerate() { + if c.link.is_some() && !sk.is_text_curve(ci) { c.construction = true; } } find_profiles(&d) } +/// The profiles without text counters: a profile bounded by text outlines alone that lies inside +/// an odd number of other such profiles is the counter of a letter (the hole of A, B, 8...), +/// which the letter's own profile already has as a hole. What "all profiles" means for text: +/// the letters, not the letters with their counters filled in. +pub fn without_text_counters(sk: &Sketch, profiles: Vec) -> Vec { + let index: std::collections::HashMap<&str, usize> = sk.curves.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect(); + let text_only: Vec = profiles + .iter() + .map(|p| !p.outer_curves.is_empty() && p.outer_curves.iter().all(|id| index.get(id.as_str()).is_some_and(|ci| sk.is_text_curve(*ci)))) + .collect(); + if !text_only.iter().any(|t| *t) { + return profiles; + } + // Outer polylines and their boxes, once. + let polys: Vec> = profiles.iter().map(|p| p.region.outer.polyline(1e-3)).collect(); + let boxes: Vec<(Vec2, Vec2)> = polys + .iter() + .map(|pl| { + pl.iter().fold((Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)), |(lo, hi), p| { + (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y))) + }) + }) + .collect(); + let areas: Vec = profiles.iter().map(|p| p.region.outer.signed_area().abs()).collect(); + let inside = |pl: &[Vec2], p: Vec2| { + let n = pl.len(); + let mut odd = false; + for i in 0..n { + let (Some(a), Some(b)) = (pl.get(i), pl.get((i + 1) % n)) else { continue }; + if (a.y > p.y) != (b.y > p.y) && p.x < a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x) { + odd = !odd; + } + } + odd + }; + let keep: Vec = (0..profiles.len()) + .map(|i| { + if !text_only.get(i).copied().unwrap_or(false) { + return true; + } + let Some(probe) = profiles.get(i).and_then(|p| p.region.outer.segs.first()).map(Seg2::mid) else { return true }; + let ai = areas.get(i).copied().unwrap_or(0.0); + let depth = (0..profiles.len()) + .filter(|&j| { + j != i + && text_only.get(j).copied().unwrap_or(false) + && areas.get(j).copied().unwrap_or(0.0) > ai + && boxes.get(j).is_some_and(|(lo, hi)| probe.x >= lo.x && probe.x <= hi.x && probe.y >= lo.y && probe.y <= hi.y) + && polys.get(j).is_some_and(|pl| inside(pl, probe)) + }) + .count(); + depth % 2 == 0 + }) + .collect(); + profiles.into_iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| p).collect() +} + +/// Find all closed profiles of a sketch. Curves are split where other curves end on them or +/// cross them, like a drawing would be read. pub fn find_profiles(sk: &Sketch) -> Vec { find_profiles_bounded(sk, MAX_EDGES) } @@ -981,7 +1039,10 @@ fn same_seg(a: &Seg2, b: &Seg2, tol: f64) -> bool { && a.end().dist(b.end()) <= tol && a.mid().dist(b.mid()) <= tol } - _ => false, + // Free-form segments (text outlines, ellipses, splines): the same curve when they run + // through the same points. + (Seg2::Line { .. } | Seg2::Arc { .. }, _) | (_, Seg2::Line { .. } | Seg2::Arc { .. }) => false, + _ => [0.0, 0.25, 0.5, 0.75, 1.0].iter().all(|t| a.point_at(*t).dist(b.point_at(*t)) <= tol), } } @@ -1090,8 +1151,12 @@ pub fn merge_regions(regions: &[Region2]) -> Vec { None => break, } } + // A loop that encloses nothing (pieces running back over themselves) is no boundary. if !lp.is_empty() && lp.first().zip(lp.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= tol) { - loops.push(Loop2 { segs: rejoined(lp, &rejoin) }); + let lp = Loop2 { segs: rejoined(lp, &rejoin) }; + if lp.signed_area().abs() > 1e-9 { + loops.push(lp); + } } } let outers: Vec<&Loop2> = loops.iter().filter(|l| l.signed_area() > 0.0).collect(); diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index daf4070a..5fa4f885 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -339,6 +339,88 @@ fn merging_adjacent_regions() { assert!(sides.contains(&4) && sides.contains(&3), "{sides:?}"); } +/// A sketch holding `text` the way `sketch.text` makes it: one text link of the built-in font's +/// outlines. +fn text_sketch(text: &str, at: Vec2, height: f64) -> Sketch { + let mut sk = Sketch::new(); + let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0 }; + sk.add_link(LinkKind::Text, source, &text_geometry(text, at, height, 0.0).unwrap()).unwrap(); + sk +} + +fn sorted_areas(ps: &[Profile]) -> Vec { + let mut a: Vec = ps.iter().map(|p| p.area).collect(); + a.sort_by(f64::total_cmp); + a +} + +fn assert_areas(got: &[f64], mut want: Vec) { + want.sort_by(f64::total_cmp); + assert_eq!(got.len(), want.len(), "{got:?} against {want:?}"); + for (g, w) in got.iter().zip(&want) { + assert!((g - w).abs() < 2e-3, "{got:?} against {want:?}"); + } +} + +#[test] +fn text_counters_are_holes_of_their_letters_not_profiles_of_their_own() { + // Areas in mm² at a capital height of 10 mm, summed from the Liberation Sans contours (shoelace + // formula, quadratic pieces flattened finely): a glyph's outer contour, then its counters. + // O: 83.3133 - 47.4584 A: 39.2159 - 9.0647 B: 68.9283 - 12.9336 - 16.6277 + // 8: 56.8394 - 10.6451 - 13.7347 + let (o, o_counter) = (83.3133 - 47.4584, 47.4584); + let (a, a_counter) = (39.2159 - 9.0647, 9.0647); + let (b, b_counters) = (68.9283 - 12.9336 - 16.6277, [12.9336, 16.6277]); + let (eight, eight_counters) = (56.8394 - 10.6451 - 13.7347, [10.6451, 13.7347]); + let check = |text: &str, letters: Vec, counters: Vec| { + let sk = text_sketch(text, Vec2::ZERO, 10.0); + let all = find_drawn_profiles(&sk); + // Every counter is a closed region of its own, next to its letter with the hole in it... + assert_areas(&sorted_areas(&all), letters.iter().chain(&counters).copied().collect()); + // ...and "all profiles" of text means the letters. + assert_areas(&sorted_areas(&without_text_counters(&sk, all)), letters); + }; + check("O", vec![o], vec![o_counter]); + check("8", vec![eight], eight_counters.to_vec()); + check("AB", vec![a, b], [vec![a_counter], b_counters.to_vec()].concat()); + // A letter without a counter is untouched. + check("I", vec![13.5557], vec![]); +} + +#[test] +fn text_beside_other_curves_is_drawn_geometry() { + // A 30 x 20 rectangle with an "I" (a 10 mm tall bar, 13.5557 mm² in Liberation Sans) beside it. + let mut sk = text_sketch("I", v(40.0, 0.0), 10.0); + for (p, q) in [((0.0, 0.0), (30.0, 0.0)), ((30.0, 0.0), (30.0, 20.0)), ((30.0, 20.0), (0.0, 20.0)), ((0.0, 20.0), (0.0, 0.0))] { + sk.add_line(v(p.0, p.1), v(q.0, q.1), None, None, None).unwrap(); + } + assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 600.0]); + // Projected reference geometry still doesn't count as drawn. + let square = |s: f64| { + vec![ + LinkGeom::Line(v(100.0, 0.0), v(100.0 + s, 0.0)), + LinkGeom::Line(v(100.0 + s, 0.0), v(100.0 + s, s)), + LinkGeom::Line(v(100.0 + s, s), v(100.0, s)), + LinkGeom::Line(v(100.0, s), v(100.0, 0.0)), + ] + }; + sk.add_link(LinkKind::Project, LinkSource::Origin, &square(5.0)).unwrap(); + assert_eq!(find_profiles(&sk).len(), 3); + assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 600.0]); +} + +#[test] +fn a_letter_merged_with_its_counter_is_the_filled_letter() { + // The ring of an O and its counter share the counter's two conics; merged, they cancel along it. + let sk = text_sketch("O", Vec2::ZERO, 10.0); + let regions: Vec<_> = find_profiles(&sk).into_iter().map(|p| p.region).collect(); + assert_eq!(regions.len(), 2); + let merged = merge_regions(®ions); + assert_eq!(merged.len(), 1, "{} regions", merged.len()); + assert!(merged[0].holes.is_empty(), "{} holes", merged[0].holes.len()); + assert!((merged[0].area() - 83.3133).abs() < 2e-3, "{}", merged[0].area()); +} + #[test] fn links_update_in_place_rebuild_and_break() { use crate::{LinkGeom, LinkKind, LinkSource};