From 53238fba5f0cee532def71001c4fa6125b487de0 Mon Sep 17 00:00:00 2001 From: William Dunn Date: Tue, 13 Oct 2026 16:29:24 -0400 Subject: [PATCH] Sketch Offset on two sides, or symmetric about the source (#807) Sketch Offset can place the copy on both sides of the source, or symmetric about it. --- crates/engine/src/cmd/sketch_modify.rs | 359 +++++++++++------- crates/engine/src/cmd/sketch_modify_tests.rs | 181 +++++++++ crates/ui-egui/src/canvas.rs | 5 +- crates/ui-egui/src/icons.rs | 17 + crates/ui-egui/src/sketch_modify_ui.rs | 66 +++- .../scenarios/sketch_offset_two_sides.json | 83 ++++ 6 files changed, 567 insertions(+), 144 deletions(-) create mode 100644 crates/ui-egui/tests/scenarios/sketch_offset_two_sides.json diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 08f9aaf2..2f4e67cd 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -42,7 +42,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("offset") .key("O") .enabled(in_sketch) - .params(concat!("curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain)", created_doc!())), + .params(concat!( + "curves: [ids], distance: number (positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain); ", + "direction?: one_side|symmetric (default one_side) or symmetric?: bool (the same distance each way, as for `solid.extrude`; no `side`, whose job it takes over), ", + "distance2?: positive number (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`", + created_doc!() + )), CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset) .enabled(in_sketch) .params("curve: derived offset curve id (retains geometry and makes its points editable)"), @@ -919,6 +924,148 @@ fn piece_dist(p: &Piece, q: Vec2) -> f64 { } } +/// One side of an offset. The curves created at the signed distance, the parallel constraints +/// added with them, and the association when the boundary carries an offset topology. +struct Side { + distance: f64, + curves: Vec, + constraints: Vec, + association: Option, +} + +/// The signed distances an offset runs at. One side is the distance as given. `symmetric` is the +/// same distance each way, as `direction: symmetric` means for `solid.extrude`. `distance2` is an +/// independent distance the other way, as `distance2` means for `solid.extrude`. The primary side +/// comes first, so a one-sided caller reads the same first entry it always did. +fn offset_runs(d: f64, symmetric: bool, distance2: Option) -> Vec { + match (symmetric, distance2) { + (true, _) => vec![d.abs(), -d.abs()], + (_, Some(second)) => vec![d, -second * d.signum()], + _ => vec![d], + } +} + +/// Build one side of a chain offset at the signed distance `d`. `order` and `sources` are the +/// walked chain, computed once before anything is created so a second side walks the same +/// curves rather than a pool the first side has already grown. +fn offset_side(sk: &mut Sketch, cmd: &str, order: &[(usize, bool)], sources: &[(String, bool)], closed: bool, convex: bool, d: f64) -> Result { + let mut made = Vec::new(); + let mut cons = Vec::new(); + if convex { + let created = sk.add_convex_offset(sources.to_vec(), d)?; + let roles = sk + .offsets + .iter() + .filter_map(|offset| offset.topology.as_ref()) + .find(|topology| topology.id == created.association) + .map(|topology| topology.roles.clone()) + .ok_or_else(|| bad(cmd, "new offset association is missing"))?; + for (source, role) in sources.iter().zip(&roles) { + if role.kind == solidcraft_sketch::OffsetRoleKind::Line { + let a = sk.curve_index(&source.0).ok_or_else(|| bad(cmd, "offset source line vanished"))?; + let b = sk.curve_index(&role.output).ok_or_else(|| bad(cmd, "offset output line vanished"))?; + cons.push(add_c(sk, ConstraintKind::Parallel { a, b })?); + } + } + return Ok(Side { distance: d, curves: created.curves, constraints: cons, association: Some(created.association) }); + } + let pieces: Vec = + order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?; + let n = pieces.len(); + let start_of = |p: &Piece| match p { + Piece::Line(a, _) => *a, + Piece::Arc(_, _, _, s, _, _) => *s, + Piece::Free(v) => v.first().copied().unwrap_or_default(), + }; + let end_of = |p: &Piece| match p { + Piece::Line(_, b) => *b, + Piece::Arc(_, _, _, _, e, _) => *e, + Piece::Free(v) => v.last().copied().unwrap_or_default(), + }; + // Junction points between consecutive pieces. + let joint = |x: &Piece, y: &Piece| -> Vec2 { + let (e, s0) = (end_of(x), start_of(y)); + if e.dist(s0) < TOL * 10.0 { + return e; + } + let guess = (e + s0) * 0.5; + if matches!(x, Piece::Free(_)) || matches!(y, Piece::Free(_)) { + return guess; + } + intersections(&piece_shape(x), &piece_shape(y)).into_iter().min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess))).unwrap_or(guess) + }; + let (Some(head), Some(tail)) = (pieces.first(), pieces.last()) else { + return Err(bad(cmd, "no curves")); + }; + let mut joints: Vec = Vec::new(); + joints.push(if closed { joint(tail, head) } else { start_of(head) }); + for k in 0..n.saturating_sub(1) { + let (Some(x), Some(y)) = (pieces.get(k), pieces.get(k + 1)) else { + return Err(bad(cmd, "the offset chain changed while it was built")); + }; + joints.push(joint(x, y)); + } + if !closed { + joints.push(end_of(tail)); + } + // A straight piece whose joints come out reversed (or meet) was overrun by its + // neighbours: the offset is larger than the shape allows there. + for (k, pc) in pieces.iter().enumerate() { + if let Piece::Line(a, b) = pc { + let (ja, jb) = (joints.get(k).copied(), joints.get(if closed { (k + 1) % n } else { k + 1 }).copied()); + if let (Some(ja), Some(jb)) = (ja, jb) + && (jb - ja).dot(*b - *a) <= TOL * a.dist(*b) + { + return Err(bad(cmd, "the offset is larger than the shape allows (the offset curves would cross)")); + } + } + } + let pts: Vec = joints.iter().map(|q| sk.add_point(*q, None)).collect::>()?; + for (k, pc) in pieces.iter().enumerate() { + let (Some(pa), Some(pb)) = (pts.get(k).copied(), pts.get(if closed { (k + 1) % n } else { k + 1 }).copied()) else { + return Err(bad(cmd, "the offset chain changed while it was built")); + }; + let orig = order.get(k).map(|(c, _)| *c).ok_or_else(|| bad(cmd, "the offset chain changed while it was built"))?; + match pc.clone() { + Piece::Free(v) => { + // A fit spline through a few of the samples, ending at the joints. + let step = (v.len() / 24).max(1); + let mut idx = vec![pa]; + for q in v.iter().skip(step).step_by(step) { + if q.dist(*v.last().unwrap_or(q)) > 1e-6 { + idx.push(sk.add_point(*q, None)?); + } + } + idx.push(pb); + idx.dedup(); + if idx.len() >= 2 { + made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false }, None)?); + } + } + Piece::Line(..) => { + let l = sk.add_line_pts(pa, pb, None)?; + cons.push(add_c(sk, ConstraintKind::Parallel { a: orig, b: l })?); + made.push(l); + } + Piece::Arc(ci, cc, _, _, _, ccw) => { + let (qa, qb) = (sk.point(pa).unwrap_or_default(), sk.point(pb).unwrap_or_default()); + let a = if ccw { + sk.add_arc(cc, qa, qb, [Some(ci), Some(pa), Some(pb)], None)? + } else { + sk.add_arc(cc, qb, qa, [Some(ci), Some(pb), Some(pa)], None)? + }; + made.push(a); + } + } + } + let outputs = ids_of(sk, &made); + if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) { + sk.offsets.push(solidcraft_sketch::OffsetChain { sources: sources.to_vec(), outputs: outputs.clone(), distance: d, closed, topology: None }); + sk.refresh_offsets()?; + } + Ok(Side { distance: d, curves: outputs, constraints: cons, association: None }) +} + fn offset(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.offset"; let ids = string_list(p, "curves"); @@ -928,7 +1075,32 @@ fn offset(s: &mut Session, p: &Value) -> Result { let d0 = num(p, "distance").filter(|d| d.abs() > 1e-9).ok_or_else(|| bad(cmd, "`distance` must be a non-zero number"))?; let side = p.get("side").and_then(vec2); let use_chain = bool_(p, "chain").unwrap_or(true); - let (out, info) = edit(s, p, cmd, true, |sk, doc| { + // Both sides at once. The words keep the meaning `solid.extrude` gives them: `symmetric` is + // one distance applied each way, `distance2` is an independent distance the other way. + let symmetric = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() { + None | Some("positive") | Some("one_side") => bool_(p, "symmetric").unwrap_or(false), + Some("symmetric") => true, + Some("negative" | "reverse" | "flip") => { + return Err(bad(cmd, "an offset has no negative direction: give a negative `distance`, or a `side` to offset toward")); + } + Some(other) => return Err(bad(cmd, format!("unknown direction `{other}` (one_side, symmetric)"))), + }; + let distance2 = match p.get("distance2") { + None => None, + Some(_) => Some(num(p, "distance2").filter(|d| *d > 1e-9).ok_or_else(|| { + bad( + cmd, + "`distance2` must be a positive number: the other side's distance (the sign of `distance`, or `side`, picks which side is which)", + ) + })?), + }; + if symmetric && distance2.is_some() { + return Err(bad(cmd, "give `distance2` or `symmetric`, not both")); + } + if symmetric && side.is_some() { + return Err(bad(cmd, "`symmetric` offsets both sides, so `side` has no side left to pick: drop one of them")); + } + let (sides, info) = edit(s, p, cmd, true, |sk, doc| { sk.refresh_offsets()?; let rep = solve_current(sk, doc)?; if !rep.keeps_orientation() { @@ -945,30 +1117,42 @@ fn offset(s: &mut Session, p: &Value) -> Result { "offsets of a boundary with retired corner arcs are not supported yet; break its offset association to use the current fixed topology", )); } - let mut made = Vec::new(); - let mut cons = Vec::new(); - // Circles offset on their own. + let mut sides: Vec = Vec::new(); + // Circles offset on their own, concentrically. Both sides are then a smaller circle and + // a larger one; the one toward the centre still has to have a size. if let Some(CurveKind::Circle { c, r }) = sk.curves.get(first).map(|c| c.kind.clone()) { let cc = sk.point(c).unwrap_or_default(); + let source = sk.curves.get(first).map(|c| c.id.clone()).ok_or_else(|| bad(cmd, "offset source circle vanished"))?; let grow = match side { Some(q) => q.dist(cc) > r, None => d0 < 0.0, }; - let nr = if grow { r + d0.abs() } else { r - d0.abs() }; - if nr <= 1e-9 { - return Err(bad(cmd, "the offset circle would have no size")); + let radii: Vec = match (symmetric, distance2) { + (true, _) => vec![r - d0.abs(), r + d0.abs()], + (_, Some(second)) if grow => vec![r + d0.abs(), r - second], + (_, Some(second)) => vec![r - d0.abs(), r + second], + _ if grow => vec![r + d0.abs()], + _ => vec![r - d0.abs()], + }; + for nr in radii { + if nr <= 1e-9 { + return Err(bad(cmd, "the offset circle would have no size")); + } + let ci = sk.add_circle(cc, nr, Some(c), None)?; + let outputs = ids_of(sk, &[ci]); + sk.offsets.push(solidcraft_sketch::OffsetChain { + sources: vec![(source.clone(), true)], + outputs: outputs.clone(), + distance: r - nr, + closed: true, + topology: None, + }); + sides.push(Side { distance: r - nr, curves: outputs, constraints: Vec::new(), association: None }); } - let ci = sk.add_circle(cc, nr, Some(c), None)?; - made.push(ci); - sk.offsets.push(solidcraft_sketch::OffsetChain { - sources: vec![(ids[0].clone(), true)], - outputs: ids_of(sk, &made), - distance: r - nr, - closed: true, - topology: None, - }); - return Ok((ids_of(sk, &made), cons, None)); + return Ok(sides); } + // The chain is walked once, before anything is created: a second side has to follow the + // same source curves, not a pool the first side has already added its output to. let pool: Vec = if use_chain { (0..sk.curves.len()) .filter(|i| sk.curves.get(*i).is_some_and(|c| c.construction == sk.curves.get(first).is_some_and(|f| f.construction))) @@ -989,6 +1173,7 @@ fn offset(s: &mut Session, p: &Value) -> Result { } None => d0, }; + let runs = offset_runs(d, symmetric, distance2); if order.iter().any(|(i, _)| sk.shape(*i).is_none()) { // A smooth free-form entity is offset from exact pieces. Junctions that // would require trimming/intersecting independent normal offsets are @@ -996,9 +1181,12 @@ fn offset(s: &mut Session, p: &Value) -> Result { if order.len() != 1 { return Err(bad(cmd, "offset smooth free-form curves individually; mixed chain junctions are ambiguous")); } - let (source, forward) = order[0]; - let output = sk.add_freeform_offset(source, forward, d)?; - return Ok((ids_of(sk, &[output]), cons, None)); + let (source, forward) = *order.first().ok_or_else(|| bad(cmd, "no curves"))?; + for dd in runs { + let output = sk.add_freeform_offset(source, forward, dd)?; + sides.push(Side { distance: dd, curves: ids_of(sk, &[output]), constraints: Vec::new(), association: None }); + } + return Ok(sides); } let sources = order .iter() @@ -1006,115 +1194,30 @@ fn offset(s: &mut Session, p: &Value) -> Result { sk.curves.get(*index).map(|curve| (curve.id.clone(), *forward)).ok_or_else(|| bad(cmd, "offset chain source vanished")) }) .collect::>>()?; - if closed && sk.convex_offset_supported(&sources)? { - let created = sk.add_convex_offset(sources.clone(), d)?; - let roles = sk - .offsets - .iter() - .filter_map(|offset| offset.topology.as_ref()) - .find(|topology| topology.id == created.association) - .map(|topology| topology.roles.clone()) - .ok_or_else(|| bad(cmd, "new offset association is missing"))?; - for (source, role) in sources.iter().zip(&roles) { - if role.kind == solidcraft_sketch::OffsetRoleKind::Line { - let a = sk.curve_index(&source.0).ok_or_else(|| bad(cmd, "offset source line vanished"))?; - let b = sk.curve_index(&role.output).ok_or_else(|| bad(cmd, "offset output line vanished"))?; - cons.push(add_c(sk, ConstraintKind::Parallel { a, b })?); - } - } - return Ok((created.curves, cons, Some(created.association))); + let convex = closed && sk.convex_offset_supported(&sources)?; + for dd in runs { + sides.push(offset_side(sk, cmd, &order, &sources, closed, convex, dd)?); } - let pieces: Vec = - order.iter().map(|(c, f)| offset_piece(sk, *c, *f, d)).collect::>().ok_or_else(|| bad(cmd, "the offset collapses an arc"))?; - let n = pieces.len(); - let start_of = |p: &Piece| match p { - Piece::Line(a, _) => *a, - Piece::Arc(_, _, _, s, _, _) => *s, - Piece::Free(v) => v.first().copied().unwrap_or_default(), - }; - let end_of = |p: &Piece| match p { - Piece::Line(_, b) => *b, - Piece::Arc(_, _, _, _, e, _) => *e, - Piece::Free(v) => v.last().copied().unwrap_or_default(), - }; - // Junction points between consecutive pieces. - let joint = |x: &Piece, y: &Piece| -> Vec2 { - let (e, s0) = (end_of(x), start_of(y)); - if e.dist(s0) < TOL * 10.0 { - return e; - } - let guess = (e + s0) * 0.5; - if matches!(x, Piece::Free(_)) || matches!(y, Piece::Free(_)) { - return guess; - } - intersections(&piece_shape(x), &piece_shape(y)).into_iter().min_by(|a, b| a.dist(guess).total_cmp(&b.dist(guess))).unwrap_or(guess) - }; - let mut joints: Vec = Vec::new(); - joints.push(if closed { joint(&pieces[n - 1], &pieces[0]) } else { start_of(&pieces[0]) }); - for k in 0..n.saturating_sub(1) { - joints.push(joint(&pieces[k], &pieces[k + 1])); - } - if !closed { - joints.push(end_of(&pieces[n - 1])); - } - // A straight piece whose joints come out reversed (or meet) was overrun by its - // neighbours: the offset is larger than the shape allows there. - for (k, pc) in pieces.iter().enumerate() { - if let Piece::Line(a, b) = pc { - let (ja, jb) = (joints.get(k).copied(), joints.get(if closed { (k + 1) % n } else { k + 1 }).copied()); - if let (Some(ja), Some(jb)) = (ja, jb) - && (jb - ja).dot(*b - *a) <= TOL * a.dist(*b) - { - return Err(bad(cmd, "the offset is larger than the shape allows (the offset curves would cross)")); - } - } - } - let pts: Vec = joints.iter().map(|q| sk.add_point(*q, None)).collect::>()?; - for (k, pc) in pieces.iter().enumerate() { - let (pa, pb) = (pts[k], if closed { pts[(k + 1) % n] } else { pts[k + 1] }); - let (orig, _) = order[k]; - match pc.clone() { - Piece::Free(v) => { - // A fit spline through a few of the samples, ending at the joints. - let step = (v.len() / 24).max(1); - let mut idx = vec![pa]; - for q in v.iter().skip(step).step_by(step) { - if q.dist(*v.last().unwrap_or(q)) > 1e-6 { - idx.push(sk.add_point(*q, None)?); - } - } - idx.push(pb); - idx.dedup(); - if idx.len() >= 2 { - made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false }, None)?); - } - } - Piece::Line(..) => { - let l = sk.add_line_pts(pa, pb, None)?; - cons.push(add_c(sk, ConstraintKind::Parallel { a: orig, b: l })?); - made.push(l); - } - Piece::Arc(ci, cc, _, _, _, ccw) => { - let (qa, qb) = (sk.point(pa).unwrap_or_default(), sk.point(pb).unwrap_or_default()); - let a = if ccw { - sk.add_arc(cc, qa, qb, [Some(ci), Some(pa), Some(pb)], None)? - } else { - sk.add_arc(cc, qb, qa, [Some(ci), Some(pb), Some(pa)], None)? - }; - made.push(a); - } - } - } - let outputs = ids_of(sk, &made); - if pieces.iter().all(|p| !matches!(p, Piece::Free(_))) { - let sources = order.iter().filter_map(|(i, forward)| sk.curves.get(*i).map(|c| (c.id.clone(), *forward))).collect(); - sk.offsets.push(solidcraft_sketch::OffsetChain { sources, outputs: outputs.clone(), distance: d, closed, topology: None }); - sk.refresh_offsets()?; - } - Ok((outputs, cons, None)) + Ok(sides) })?; - let mut response = json!({"curves":out.0,"constraints":out.1,"sketch":info}); - if let Some(association) = out.2 { + let curves: Vec<&String> = sides.iter().flat_map(|side| side.curves.iter()).collect(); + let constraints: Vec<&String> = sides.iter().flat_map(|side| side.constraints.iter()).collect(); + let each: Vec = sides + .iter() + .map(|side| { + let mut entry = json!({"distance": side.distance, "curves": side.curves}); + if let Some(association) = &side.association { + entry["association"] = json!(association); + } + entry + }) + .collect(); + let mut response = json!({"curves":curves,"constraints":constraints,"sketch":info,"sides":each}); + // One side keeps the single `association` it always returned. Two sides have one each, in + // `sides`, so there is no single association left to name. + if let [only] = sides.as_slice() + && let Some(association) = &only.association + { response["association"] = json!(association); } Ok(response) diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index c6de598a..eecfc65a 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -1018,3 +1018,184 @@ fn periodic_offset_extrusion_survives_post_extrude_span_changes_and_cold_reopen( run(&mut s, "edit.undo", json!({})); } } + +/// The bounding box of a set of sketch curves, from their polylines. +fn bounds(sk: &solidcraft_sketch::Sketch, curves: &[String]) -> (Vec2, Vec2) { + let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY); + let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY); + for id in curves { + let i = sk.curve_index(id).unwrap(); + for p in sk.polyline(i) { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + } + (lo, hi) +} + +fn box_is(sk: &solidcraft_sketch::Sketch, curves: &[String], lo: [f64; 2], hi: [f64; 2]) -> bool { + let (a, b) = bounds(sk, curves); + a.dist(Vec2::new(lo[0], lo[1])) < 1e-6 && b.dist(Vec2::new(hi[0], hi[1])) < 1e-6 +} + +fn side_curves(v: &Value, i: usize) -> Vec { + ids(&v["sides"][i]["curves"]) +} + +#[test] +fn offset_symmetric_puts_the_same_distance_on_both_sides_of_a_chain() { + let mut s = new_sketch(); + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + let l = ids(&r["curves"]); + // A 40 by 30 rectangle offset symmetrically by 5: one chain 5 inside (5,5)-(35,25), a + // 30 by 20 rectangle, and one 5 outside (-5,-5)-(45,35), a 50 by 40 rectangle. + let o = run(&mut s, "sketch.offset", json!({"curves": [l[0]], "distance": 5, "symmetric": true})); + assert_eq!(o["sides"].as_array().map(Vec::len), Some(2), "{o}"); + assert_eq!(ids(&o["curves"]).len(), 8, "{o}"); + let (a, b) = (side_curves(&o, 0), side_curves(&o, 1)); + assert_eq!((a.len(), b.len()), (4, 4), "{o}"); + let sk = sketch(&s); + // The chain's walk direction decides which side is first, so match on the sizes. + let inner = box_is(&sk, &a, [5.0, 5.0], [35.0, 25.0]) && box_is(&sk, &b, [-5.0, -5.0], [45.0, 35.0]); + let outer = box_is(&sk, &b, [5.0, 5.0], [35.0, 25.0]) && box_is(&sk, &a, [-5.0, -5.0], [45.0, 35.0]); + assert!(inner || outer, "{:?} {:?}", bounds(&sk, &a), bounds(&sk, &b)); + // The two distances are equal and opposite. + let d: Vec = o["sides"].as_array().unwrap().iter().map(|x| x["distance"].as_f64().unwrap()).collect(); + assert!(close(d[0], -d[1]) && close(d[0].abs(), 5.0), "{d:?}"); + // Both sides are parallel to their sources, so each one added four constraints. + assert_eq!(ids(&o["constraints"]).len(), 8, "{o}"); +} + +#[test] +fn offset_two_sides_takes_an_independent_distance_the_other_way() { + let mut s = new_sketch(); + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + let l = ids(&r["curves"]); + // `side` sends `distance` 5 inward, so `distance2` 3 goes outward: (5,5)-(35,25), a 30 by + // 20 rectangle, and (-3,-3)-(43,33), a 46 by 36 one. + let o = run(&mut s, "sketch.offset", json!({"curves": [l[0]], "distance": 5, "distance2": 3, "side": [20, 15]})); + assert_eq!(o["sides"].as_array().map(Vec::len), Some(2), "{o}"); + let sk = sketch(&s); + assert!(box_is(&sk, &side_curves(&o, 0), [5.0, 5.0], [35.0, 25.0]), "{:?}", bounds(&sk, &side_curves(&o, 0))); + assert!(box_is(&sk, &side_curves(&o, 1), [-3.0, -3.0], [43.0, 33.0]), "{:?}", bounds(&sk, &side_curves(&o, 1))); +} + +#[test] +fn offset_two_sides_of_one_curve_follows_the_sign_of_the_distance() { + let mut s = new_sketch(); + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + let l = ids(&r["curves"]); + // The bottom edge runs (0,0)-(40,0). Chain off, distance -5 and distance2 3: one line at + // y = -5 and one at y = 3, each 40 long. + let f = run(&mut s, "sketch.offset", json!({"curves": [l[0]], "distance": -5, "distance2": 3, "chain": false})); + assert_eq!(f["sides"].as_array().map(Vec::len), Some(2), "{f}"); + let sk = sketch(&s); + let (one, two) = (bounds(&sk, &side_curves(&f, 0)), bounds(&sk, &side_curves(&f, 1))); + assert!(close(one.0.y, one.1.y) && close(two.0.y, two.1.y), "{one:?} {two:?}"); + assert!(close(one.0.y, -5.0) && close(two.0.y, 3.0), "{one:?} {two:?}"); + assert!(close(one.1.x - one.0.x, 40.0) && close(two.1.x - two.0.x, 40.0), "{one:?} {two:?}"); +} + +#[test] +fn offset_both_sides_of_a_rounded_boundary_keep_separate_associations() { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-38, -33], "p1": [38, 33]})); + run(&mut s, "sketch.fillet", json!({"points": ["l1.start", "l1.end", "l2.end", "l3.end"], "radius": "1 mm"})); + // A 76 by 66 boundary with 1 mm corners, offset symmetrically by 0.2: 75.6 by 65.6 with + // 0.8 mm corners inside, and 76.4 by 66.4 with 1.2 mm corners outside. + let o = run(&mut s, "sketch.offset", json!({"curves": ["l1"], "distance": 0.2, "symmetric": true})); + let sides = o["sides"].as_array().unwrap().clone(); + assert_eq!(sides.len(), 2, "{o}"); + // Each side is its own offset association; a single side still reports the one it always did. + let associations: Vec<&str> = sides.iter().filter_map(|x| x["association"].as_str()).collect(); + assert_eq!(associations.len(), 2, "{o}"); + assert_ne!(associations[0], associations[1], "{o}"); + assert!(o["association"].is_null(), "two sides name no single association: {o}"); + let sk = sketch(&s); + assert_eq!(sk.offsets.len(), 2); + for association in &associations { + assert_eq!(sk.offset_boundary(association).unwrap().len(), 8, "{association}"); + } + let radii = |curves: &[String]| { + let mut r: Vec = curves + .iter() + .filter_map(|id| { + let CurveKind::Arc { c, a, .. } = sk.curves.get(sk.curve_index(id)?)?.kind else { return None }; + Some(sk.point(c)?.dist(sk.point(a)?)) + }) + .collect(); + r.sort_by(f64::total_cmp); + r + }; + let (a, b) = (side_curves(&o, 0), side_curves(&o, 1)); + let (ra, rb) = (radii(&a), radii(&b)); + assert_eq!((ra.len(), rb.len()), (4, 4), "{ra:?} {rb:?}"); + let shrunk = ra.iter().all(|r| close(*r, 0.8)) && rb.iter().all(|r| close(*r, 1.2)); + let grown = rb.iter().all(|r| close(*r, 0.8)) && ra.iter().all(|r| close(*r, 1.2)); + assert!(shrunk || grown, "{ra:?} {rb:?}"); + let small = if shrunk { &a } else { &b }; + let large = if shrunk { &b } else { &a }; + assert!(box_is(&sk, small, [-37.8, -32.8], [37.8, 32.8]), "{:?}", bounds(&sk, small)); + assert!(box_is(&sk, large, [-38.2, -33.2], [38.2, 33.2]), "{:?}", bounds(&sk, large)); +} + +#[test] +fn offset_both_sides_of_a_circle_are_concentric() { + let mut s = new_sketch(); + let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone(); + // Symmetric by 3 on a 10 mm circle: a 7 mm circle and a 13 mm one, both about the centre. + let o = run(&mut s, "sketch.offset", json!({"curves": [c], "distance": 3, "symmetric": true})); + let sk = sketch(&s); + let radius = |id: &str| match sk.curves.get(sk.curve_index(id).unwrap()).unwrap().kind { + CurveKind::Circle { c, r } => (sk.point(c).unwrap(), r), + _ => panic!("not a circle"), + }; + let made = ids(&o["curves"]); + assert_eq!(made.len(), 2, "{o}"); + let (p0, r0) = radius(&made[0]); + let (p1, r1) = radius(&made[1]); + assert!(p0.dist(Vec2::default()) < 1e-9 && p1.dist(Vec2::default()) < 1e-9); + assert!(close(r0, 7.0) && close(r1, 13.0), "{r0} {r1}"); + // A circle whose inward side would be offset away to nothing is refused, both sides with it. + assert!(s.execute("sketch.offset", &json!({"curves": [c], "distance": 10, "symmetric": true})).is_err()); +} + +#[test] +fn offset_refuses_the_combinations_that_would_be_guesswork() { + let mut s = new_sketch(); + let r = run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + let l = ids(&r["curves"]); + let refused = |s: &mut Session, p: Value| s.execute("sketch.offset", &p).unwrap_err().to_string(); + let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "symmetric": true, "side": [20, 15]})); + assert!(e.contains("no side left to pick"), "{e}"); + let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "symmetric": true, "distance2": 3})); + assert!(e.contains("`distance2` or `symmetric`"), "{e}"); + let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "distance2": -3})); + assert!(e.contains("`distance2` must be a positive number"), "{e}"); + let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "distance2": 0})); + assert!(e.contains("`distance2` must be a positive number"), "{e}"); + let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "direction": "negative"})); + assert!(e.contains("no negative direction"), "{e}"); + let e = refused(&mut s, json!({"curves": [l[0]], "distance": 5, "direction": "sideways"})); + assert!(e.contains("unknown direction"), "{e}"); + // An inward side the shape cannot hold refuses the whole command, the outward side with it. + let e = refused(&mut s, json!({"curves": [l[0]], "distance": 20, "symmetric": true})); + assert!(e.contains("larger than the shape allows"), "{e}"); + assert_eq!(sketch(&s).curves.len(), 4, "a refused offset leaves the sketch alone"); + // `direction: symmetric` is the word `solid.extrude` uses, and it means the same here. + let o = run(&mut s, "sketch.offset", json!({"curves": [l[0]], "distance": 5, "direction": "symmetric"})); + assert_eq!(o["sides"].as_array().map(Vec::len), Some(2), "{o}"); +} + +#[test] +fn offset_one_side_still_reports_one_set_of_curves_and_its_association() { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [-38, -33], "p1": [38, 33]})); + run(&mut s, "sketch.fillet", json!({"points": ["l1.start", "l1.end", "l2.end", "l3.end"], "radius": "1 mm"})); + let o = run(&mut s, "sketch.offset", json!({"curves": ["l1"], "distance": 0.2, "side": [0, 0]})); + assert_eq!(o["sides"].as_array().map(Vec::len), Some(1), "{o}"); + let association = o["association"].as_str().unwrap().to_string(); + assert_eq!(o["sides"][0]["association"].as_str(), Some(association.as_str()), "{o}"); + assert_eq!(ids(&o["curves"]), ids(&o["sides"][0]["curves"]), "{o}"); + assert!(close(o["sides"][0]["distance"].as_f64().unwrap().abs(), 0.2), "{o}"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ff9784b2..65941844 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -53,9 +53,10 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { let (mid, tangent) = poly_mid(&pts)?; // An offset's drag arrow: the in-plane direction the distance grows along. The // command offsets a picked curve to its left, and the window's Flip row reverses it. + // Symmetric hides Flip and offsets both ways, so the arrow keeps the left side. let dir = match &d.kind { - Kind::SketchModify(crate::sketch_modify_ui::Mod::Offset { flip, .. }) => { - let n = if *flip { -tangent.perp() } else { tangent.perp() }; + Kind::SketchModify(crate::sketch_modify_ui::Mod::Offset { flip, mode, .. }) => { + let n = if *flip && !crate::sketch_modify_ui::offset_symmetric(*mode) { -tangent.perp() } else { tangent.perp() }; Some(ss.plane.to_world(mid + n) - ss.plane.to_world(mid)) } _ => None, diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 94675236..2ba27f21 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -937,6 +937,23 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.arc(8.0, 15.0, 5.0, std::f32::consts::PI, std::f32::consts::PI * 1.5, a); pen.arc(8.0, 9.0, 5.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, a); } + // Offset mode icons, drawn with the house `dashed` helper. The source is the solid + // line and each result a dashed one. One Side has a single result; Two Sides has one + // each way at two different gaps; Symmetric has one each way at the same gap. + "offset_one_side" => { + pen.line(&[(8.0, 4.0), (8.0, 20.0)], ink); + pen.dashed(&[(18.0, 4.0), (18.0, 20.0)], a); + } + "offset_two_sides" => { + pen.line(&[(13.0, 4.0), (13.0, 20.0)], ink); + pen.dashed(&[(3.0, 4.0), (3.0, 20.0)], a); + pen.dashed(&[(20.0, 4.0), (20.0, 20.0)], a); + } + "offset_symmetric" => { + pen.line(&[(12.0, 4.0), (12.0, 20.0)], ink); + pen.dashed(&[(4.0, 4.0), (4.0, 20.0)], a); + pen.dashed(&[(20.0, 4.0), (20.0, 20.0)], a); + } "trim" => { pen.line(&[(12.0, 3.0), (12.0, 21.0)], ink); pen.line(&[(3.0, 12.0), (12.0, 12.0)], ink); diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index 98e0e5b7..9db4eb96 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -4,7 +4,8 @@ //! reached no tool and no dialog at all, so a press ran `sketch.scale` with no parameters and //! the engine answered "`factor` must be a positive number". Offset joins it here (upstream //! issue #162 item 1: "Offset does not create a constraint, the size of the offset seems -//! random"). Mirror joins them here, which ran as a bare click flow: entities until a +//! random"), and it now offers the same One Side, Two Sides and Symmetric directions +//! `solid.extrude` already names. Mirror joins them here, which ran as a bare click flow: entities until a //! right-click, then one more click for the line, with nothing on screen saying what was //! picked. Move joins them too, which had no toolbar placement, no tool and no dialog: its only //! route into the UI was the sketch context menu, and that went through the same @@ -39,6 +40,21 @@ const CHAMFER_TYPE_ICONS: [&str; 3] = ["sketch_chamfer_equal", "sketch_chamfer_t /// The engine's own cap on the copies one sketch pattern may make. const MAX_COPIES: f64 = 500.0; +/// The offset directions, in the words and the order `solid.extrude` already uses for them. +/// One Side offsets the way the distance points. Two Sides adds an independent distance the +/// other way. Symmetric applies the one distance each way. +pub(crate) const OFFSET_MODES: [&str; 3] = ["One Side", "Two Sides", "Symmetric"]; +const OFFSET_MODE_ICONS: [&str; 3] = ["offset_one_side", "offset_two_sides", "offset_symmetric"]; +/// Index of Two Sides in [`OFFSET_MODES`] (the mode with a second distance). +const TWO_SIDES: usize = 1; +/// Index of Symmetric in [`OFFSET_MODES`]. +const SYMMETRIC: usize = 2; + +/// Whether an offset mode index is Symmetric (which hides Flip and offsets both ways). +pub(crate) fn offset_symmetric(mode: usize) -> bool { + mode == SYMMETRIC +} + /// A sketch modify tool window's values. #[derive(Clone, Debug, PartialEq)] pub enum Mod { @@ -48,8 +64,9 @@ pub enum Mod { Scale { factor: String }, /// `sketch.offset`: the curves to offset, the distance typed and whether the connected /// chain follows. A positive distance is left of the chain, as the command defines it; - /// `flip` reverses whatever sign the field holds. - Offset { distance: String, chain: bool, flip: bool }, + /// `flip` reverses whatever sign the field holds. `mode` indexes [`OFFSET_MODES`], and + /// `distance2` is the other side's distance that Two Sides asks for. + Offset { distance: String, distance2: String, mode: usize, chain: bool, flip: bool }, /// `sketch.mirror`: the entities and the line they reflect in. The command has no number of /// its own, so the window's whole job is to show what is picked and let a pick be changed /// before anything is committed. @@ -126,7 +143,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { }; let k = match id { "sketch.scale" => Mod::Scale { factor: "2".into() }, - "sketch.offset" => Mod::Offset { distance: "2 mm".into(), chain: true, flip: false }, + "sketch.offset" => Mod::Offset { distance: "2 mm".into(), distance2: "2 mm".into(), mode: 0, chain: true, flip: false }, "sketch.mirror" => Mod::Mirror, "sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false }, "sketch.pattern.rectangular" => Mod::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() }, @@ -149,13 +166,24 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool { enter |= field(ui, factor); ui.end_row(); } - Mod::Offset { distance, chain, flip } => { + Mod::Offset { distance, distance2, mode, chain, flip } => { + row_label(ui, "Direction"); + icon_choice(ui, &OFFSET_MODES, &OFFSET_MODE_ICONS, mode); + ui.end_row(); row_label(ui, "Distance"); enter |= field(ui, distance); ui.end_row(); - row_label(ui, "Flip"); - check(ui, flip); - ui.end_row(); + if *mode == TWO_SIDES { + row_label(ui, "Distance 2"); + enter |= field(ui, distance2); + ui.end_row(); + } + // Symmetric offsets both sides, so there is no side left for Flip to swap. + if *mode != SYMMETRIC { + row_label(ui, "Flip"); + check(ui, flip); + ui.end_row(); + } row_label(ui, "Chain"); check(ui, chain); ui.end_row(); @@ -330,11 +358,11 @@ fn value(app: &SolveApp, text: &str, kind: ValueKind, label: &str) -> Result Result { +fn signed_length(app: &SolveApp, label: &str, text: &str, flip: bool) -> Result { let doc = &app.session.doc; - let v = doc.eval(&doc.with_design_unit(text.trim(), ValueKind::Length), ValueKind::Length).map_err(|e| format!("Distance: {e}"))?; + let v = doc.eval(&doc.with_design_unit(text.trim(), ValueKind::Length), ValueKind::Length).map_err(|e| format!("{label}: {e}"))?; if !v.is_finite() || v.abs() <= 1e-9 { - return Err("Distance must be a non-zero number".into()); + return Err(format!("{label} must be a non-zero number")); } Ok(if flip { -v } else { v }) } @@ -430,13 +458,23 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map { + Mod::Offset { distance, distance2, mode, chain, flip } => { let curves = curve_ids(inputs, 0); if curves.is_empty() { return Err("Select the curves to offset first".into()); } - let d = signed_length(app, distance, *flip)?; - ("sketch.offset", json!({"curves": curves, "distance": d, "chain": chain})) + // Symmetric hides Flip, so a flip left over from another mode does not travel with it. + let d = signed_length(app, "Distance", distance, *flip && *mode != SYMMETRIC)?; + let mut params = json!({"curves": curves, "distance": d, "chain": chain}); + if let Value::Object(m) = &mut params { + if *mode == SYMMETRIC { + m.insert("symmetric".into(), json!(true)); + } + if *mode == TWO_SIDES { + m.insert("distance2".into(), json!(signed_length(app, "Distance 2", distance2, false)?.abs())); + } + } + ("sketch.offset", params) } Mod::Mirror => { let entities = entity_ids(inputs, 0); diff --git a/crates/ui-egui/tests/scenarios/sketch_offset_two_sides.json b/crates/ui-egui/tests/scenarios/sketch_offset_two_sides.json new file mode 100644 index 00000000..cdfd2aa4 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_offset_two_sides.json @@ -0,0 +1,83 @@ +[ + {"note": "Offset had one distance and one direction, so there was no way to offset both sides of a curve in one operation. The window gains a Direction row of icon buttons carrying One Side, Two Sides and Symmetric, in the words solid.extrude already uses. Symmetric applies one distance each way. Two Sides adds an independent Distance 2 the other way."}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"expect": {"sketching": true}}, + {"start": "sketch.circle.center"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [10, 0]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 1}, "errors": 0}}, + + {"note": "The window opens on One Side: Flip is offered, Distance 2 is not."}, + {"start": "sketch.offset"}, + {"click": {"sketch": [10, 0]}}, + {"expect": {"dialog": "Offset", "shown_handles": ["dialog:Direction", "dialog:Distance", "dialog:Flip", "dialog:Chain"], "hidden_handles": ["dialog:Distance 2"]}}, + + {"note": "Symmetric offsets both sides, so Flip has no side left to swap and the row goes."}, + {"click": {"handle": "dialog:Direction:Symmetric"}}, + {"expect": {"dialog": "Offset", "shown_handles": ["dialog:Distance", "dialog:Chain"], "hidden_handles": ["dialog:Flip", "dialog:Distance 2"]}}, + + {"note": "A 10 mm circle offset symmetrically by a typed 3 mm becomes a 7 mm circle and a 13 mm one, both about the same centre."}, + {"click": {"handle": "dialog:Distance"}}, + {"key": "A", "cmd": true}, + {"text": "3"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 3}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 1, "radius"], "value": 7}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 2, "radius"], "value": 13}}}, + {"shot": "sketch_offset_symmetric_circle"}, + {"start": "sketch.finish"}, + + {"note": "Two Sides asks for the second distance, and Flip comes back: it decides which side each distance lands on."}, + {"start": "sketch.create"}, + {"click": {"plane": "XZ"}}, + {"start": "sketch.circle.center"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [10, 0]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 1}, "errors": 0}}, + {"start": "sketch.offset"}, + {"click": {"sketch": [10, 0]}}, + {"click": {"handle": "dialog:Direction:Two Sides"}}, + {"expect": {"dialog": "Offset", "shown_handles": ["dialog:Distance", "dialog:Distance 2", "dialog:Flip", "dialog:Chain"]}}, + + {"note": "Distance 3 inward and Distance 2 of 5 outward on the same 10 mm circle: a 7 mm circle and a 15 mm one."}, + {"click": {"handle": "dialog:Distance"}}, + {"key": "A", "cmd": true}, + {"text": "3"}, + {"click": {"handle": "dialog:Distance 2"}}, + {"key": "A", "cmd": true}, + {"text": "5"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 3}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 1, "radius"], "value": 7}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 2, "radius"], "value": 15}}}, + {"start": "sketch.finish"}, + + {"note": "A whole chain offsets both ways at once: one picked edge of a 40 by 30 rectangle, symmetric by 5, gives a 30 by 20 chain inside and a 50 by 40 chain outside, four curves each."}, + {"start": "sketch.create"}, + {"click": {"plane": "YZ"}}, + {"start": "sketch.rectangle.two_point"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [40, 30]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 4}, "errors": 0}}, + {"start": "sketch.offset"}, + {"click": {"sketch": [20, 0]}}, + {"click": {"handle": "dialog:Direction:Symmetric"}}, + {"click": {"handle": "dialog:Distance"}}, + {"key": "A", "cmd": true}, + {"text": "5"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 12}}}, + {"shot": "sketch_offset_symmetric_chain"}, + + {"note": "Escape on a Two Sides window leaves the sketch exactly as it was."}, + {"start": "sketch.offset"}, + {"click": {"sketch": [20, 0]}}, + {"click": {"handle": "dialog:Direction:Two Sides"}}, + {"expect": {"dialog": "Offset"}}, + {"key": "Escape"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 12}}} +]