diff --git a/assets/locales/pending/sketch-offset-instances.json b/assets/locales/pending/sketch-offset-instances.json new file mode 100644 index 0000000000..717b57add2 --- /dev/null +++ b/assets/locales/pending/sketch-offset-instances.json @@ -0,0 +1,71 @@ +{ + "en": { + "ui.instances": "Instances" + }, + "ar": { + "ui.instances": "النسخ" + }, + "bs": { + "ui.instances": "Instance" + }, + "cs": { + "ui.instances": "Instance" + }, + "da": { + "ui.instances": "Forekomster" + }, + "de": { + "ui.instances": "Instanzen" + }, + "en-GB": { + "ui.instances": "Instances" + }, + "es": { + "ui.instances": "Instancias" + }, + "fi": { + "ui.instances": "Esiintymät" + }, + "fr": { + "ui.instances": "Instances" + }, + "he": { + "ui.instances": "מופעים" + }, + "hr": { + "ui.instances": "Instance" + }, + "it": { + "ui.instances": "Istanze" + }, + "ja": { + "ui.instances": "インスタンス" + }, + "ko": { + "ui.instances": "인스턴스" + }, + "nl": { + "ui.instances": "Instanties" + }, + "pl": { + "ui.instances": "Wystąpienia" + }, + "pt-BR": { + "ui.instances": "Instâncias" + }, + "sr": { + "ui.instances": "Инстанце" + }, + "uk": { + "ui.instances": "Екземпляри" + }, + "vi": { + "ui.instances": "Số bản" + }, + "zh": { + "ui.instances": "实例数" + }, + "zh-TW": { + "ui.instances": "實例數" + } +} diff --git a/attribution/pending/sketch-offset-instances.md b/attribution/pending/sketch-offset-instances.md new file mode 100644 index 0000000000..4e1b0ab7e8 --- /dev/null +++ b/attribution/pending/sketch-offset-instances.md @@ -0,0 +1,2 @@ +| File | Author | Source | Licence | +| `assets/locales/pending/sketch-offset-instances.json` | SolidCraft contributors | Original offset instance-count label and translations | MIT OR Apache-2.0 | diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index 9704723293..c6109d9db1 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -48,7 +48,8 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!( "curves: [ids], distance: number in mm or length expression (creates an editable dimension; positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain, including ends that only coincide). A corner is trimmed back to the crossing when the offset runs past it, and extended until the offset curves intersect when the corner falls short; ", "direction?: one_side|symmetric (default one_side) or symmetric?: bool (the same distance each way; no `side`, whose job it takes over), ", - "distance2?: positive length expression (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`; flip?: bool reverses the primary side; `param`/`param2` name the driving dimensions", + "distance2?: positive length expression (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`; flip?: bool reverses the primary side; `param`/`param2` name the driving dimensions; ", + "instances?: whole number or unitless expression, 1…100 (default 1): each instance after the first offsets the instance before it by the same distance again, so instance n stands n distances from the curves picked. Every instance of a side is driven by that side's own dimension, and each entry of `sides` names its `instance`; an instance the shape cannot take refuses the whole command", created_doc!() )), CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset).icon("sketch_break") @@ -112,6 +113,10 @@ pub static COMMANDS: &[CommandSpec] = &[ const TOL: f64 = 1e-7; const MAX_COPIES: usize = 500; +/// The most offsets `sketch.offset` stacks one behind the other in a single run. Each instance +/// is its own association, so this stays below the sketch's own caps on analytic offset groups +/// and free-form associations (both 128). +const MAX_INSTANCES: usize = 100; fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result { let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a curve id"))?; @@ -2386,6 +2391,24 @@ fn offset_length(s: &Session, p: &Value, key: &str, positive: bool) -> Result<(f Ok((value, expression)) } +/// `instances`: how many offsets stand one behind the other, each at the same distance from the +/// one before it. A number or a unitless expression; one (the offset by itself) by default. +fn offset_instances(s: &Session, p: &Value, cmd: &str) -> Result { + let message = format!("`instances` must be a whole number from 1 to {MAX_INSTANCES}, or an expression for one"); + let count = match p.get("instances") { + None | Some(Value::Null) => return Ok(1), + Some(Value::Number(_)) => num(p, "instances").ok_or_else(|| bad(cmd, message.clone()))?, + Some(_) => { + let expression = expr(p, "instances").ok_or_else(|| bad(cmd, message.clone()))?.into_string(); + s.doc.eval(&expression, solidcraft_doc::expr::Kind::Unitless).map_err(|error| bad(cmd, format!("`instances`: {error}")))? + } + }; + if !count.is_finite() || count.fract() != 0.0 || !(1.0..=MAX_INSTANCES as f64).contains(&count) { + return Err(bad(cmd, message)); + } + Ok(count as usize) +} + #[derive(Clone, Copy)] struct OffsetOptions { side: Option, @@ -2520,6 +2543,125 @@ fn offset_group(sk: &mut Sketch, ids: &[String], eligible: &std::collections::Ha Ok(sides) } +/// One rung of an offset ladder: the offsets of `ids`, with the infinite flag carried over from +/// infinite sources (#1359) and a driving dimension on each side that is linked to its source. +/// `names` is the parameter each side is driven by, in side order, falling back to the first for +/// a side the rung before it had none for. The sources the rung consumed come back with it. +fn offset_rung( + sk: &mut Sketch, + cmd: &str, + ids: &[String], + eligible: &std::collections::HashSet, + options: OffsetOptions, + names: &[String], +) -> Result<(Vec, Vec)> { + let mut group = offset_group(sk, ids, eligible, options)?; + let output = group.first().and_then(|side| side.curves.first()).ok_or_else(|| bad(cmd, "offset produced no curves"))?; + let sources = sk + .offsets + .iter() + .find(|o| o.owns_output(output)) + .map(|o| o.sources.iter().map(|(id, _)| id.clone()).collect::>()) + .or_else(|| sk.freeform_offsets.iter().find(|o| o.output == *output).map(|o| vec![o.source.clone()])) + .or_else(|| group.iter().find_map(|side| side.unlinked_sources.clone())) + .ok_or_else(|| bad(cmd, "offset association is missing"))?; + // An offset of infinite lines is infinite lines too (#1359). + let infinite = sources.iter().all(|id| sk.curve_index(id).and_then(|c| sk.curves.get(c)).is_some_and(|c| c.infinite)); + if infinite { + for id in group.iter().flat_map(|side| side.curves.iter()) { + if let Some(cu) = sk.curve_index(id).and_then(|c| sk.curves.get_mut(c)) + && matches!(cu.kind, CurveKind::Line { .. }) + { + cu.infinite = true; + cu.construction = true; + } + } + } + for (index, side) in group.iter_mut().enumerate() { + // A trimmed offset is not linked to its source: it has no distance to drive. + if side.unlinked_sources.is_some() { + continue; + } + let name = names.get(index).or_else(|| names.first()).ok_or_else(|| bad(cmd, "offset has no driving dimension"))?; + let output = side.curves.first().ok_or_else(|| bad(cmd, "offset produced no curves"))?; + side.constraints.push(sk.add_constraint( + ConstraintKind::OffsetDistance { output: output.clone(), value: side.distance.abs(), reverse: side.distance < 0.0 }, + Some(name.clone()), + )?); + side.param = Some(name.clone()); + } + Ok((group, sources)) +} + +/// Where the next instance of an offset belongs: one more step the way the rung already went. +/// The rung's own normal-distance annotation knows that, since its ends are the middle of its +/// first source curve and the point one signed distance away on the rung itself; the same step +/// again lands one distance past the rung. The side is taken from the geometry this way rather +/// than from the sign again, because an output's stored ends need not run the way the chain +/// through them is walked, and a reversed walk reads the same sign as the other side. A trimmed +/// offset has no annotation and is ordinary geometry: `None` leaves the sign to speak for it. +fn offset_beyond(sk: &Sketch, rung: &[String]) -> Option { + // A circle offsets concentrically: there is no chain to walk and so no walk direction to + // mistake, and its own side is read as "outside the current radius". A step past a ring + // smaller than the distance lands beyond the centre, which reads as outside and would turn + // an inward ladder round into a growing one instead of letting it run out of room. Its + // signed distance already says which way, so leave the side to it. + let index = sk.curve_index(rung.first()?)?; + if matches!(sk.curves.get(index)?.kind, CurveKind::Circle { .. }) { + return None; + } + let (from, to) = sk.offset_dimension_points(rung.first()?)?; + (from.dist(to) > TOL).then(|| to + (to - from)) +} + +/// What one side of a rung leaves for the next: the curves to offset again, its own signed +/// distance, and the dimension its whole ladder is driven by. +fn offset_leaves(side: &Side, fallback: &str) -> (Vec, f64, String) { + (side.curves.clone(), side.distance, side.param.clone().unwrap_or_else(|| fallback.to_string())) +} + +/// Every offset of one connected source group: the first rung at the distances asked for, then +/// one more rung per extra instance, each offsetting the rung before it the same way and the +/// same distance again, so instance n stands n distances from the curves picked. Each side comes +/// back paired with the instance it belongs to, and the first rung's sources come with them, so +/// the caller can take those out of the selection it is still working through. +fn offset_ladder( + sk: &mut Sketch, + cmd: &str, + ids: &[String], + eligible: &std::collections::HashSet, + options: OffsetOptions, + instances: usize, + names: &[String], +) -> Result<(Vec<(usize, Side)>, Vec)> { + let primary = names.first().cloned().ok_or_else(|| bad(cmd, "offset has no driving dimension"))?; + let (first, sources) = offset_rung(sk, cmd, ids, eligible, options, names)?; + let mut rung: Vec<(Vec, f64, String)> = first.iter().map(|side| offset_leaves(side, &primary)).collect(); + let mut sides: Vec<(usize, Side)> = first.into_iter().map(|side| (1, side)).collect(); + for instance in 2..=instances { + let mut next = Vec::new(); + for (curves, distance, name) in &rung { + // One side at a time from here: the side this rung follows is the whole of its + // direction, so `side`, `symmetric` and `distance2` had their say in the first rung. + let beyond = offset_beyond(sk, curves); + let step = OffsetOptions { side: beyond, chain: false, distance: *distance, symmetric: false, distance2: None }; + // Which instance ran out of room is the whole of the answer, so the refusal names it + // in front of the reason, rather than wrapping one "invalid parameters" in another. + let (made, _) = offset_rung(sk, cmd, curves, eligible, step, std::slice::from_ref(name)).map_err(|error| { + let reason = match &error { + crate::EngineError::BadParams { msg, .. } => msg.clone(), + other => other.to_string(), + }; + bad(cmd, format!("instance {instance} of {instances}: {reason}")) + })?; + next.extend(made.iter().map(|side| offset_leaves(side, name))); + sides.extend(made.into_iter().map(|side| (instance, side))); + } + rung = next; + } + Ok((sides, sources)) +} + fn offset(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.offset"; let ids = string_list(cmd, p, "curves")?; @@ -2550,6 +2692,7 @@ fn offset(s: &mut Session, p: &Value) -> Result { 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 instances = offset_instances(s, p, cmd)?; let ((sides, param, param2), info) = edit(s, p, cmd, true, |sk, doc| { sk.refresh_offsets()?; let rep = solve_current(sk, doc)?; @@ -2564,41 +2707,8 @@ fn offset(s: &mut Session, p: &Value) -> Result { let mut pending = ids.clone(); let mut sides = Vec::new(); while !pending.is_empty() { - let mut group = offset_group(sk, &pending, &eligible, OffsetOptions { side, chain: use_chain, distance: d0, symmetric, distance2 })?; - let output = group.first().and_then(|side| side.curves.first()).ok_or_else(|| bad(cmd, "offset produced no curves"))?; - let sources = sk - .offsets - .iter() - .find(|o| o.owns_output(output)) - .map(|o| o.sources.iter().map(|(id, _)| id.clone()).collect::>()) - .or_else(|| sk.freeform_offsets.iter().find(|o| o.output == *output).map(|o| vec![o.source.clone()])) - .or_else(|| group.iter().find_map(|side| side.unlinked_sources.clone())) - .ok_or_else(|| bad(cmd, "offset association is missing"))?; - // An offset of infinite lines is infinite lines too (#1359). - let infinite = sources.iter().all(|id| sk.curve_index(id).and_then(|c| sk.curves.get(c)).is_some_and(|c| c.infinite)); - if infinite { - for id in group.iter().flat_map(|side| side.curves.iter()) { - if let Some(cu) = sk.curve_index(id).and_then(|c| sk.curves.get_mut(c)) - && matches!(cu.kind, CurveKind::Line { .. }) - { - cu.infinite = true; - cu.construction = true; - } - } - } - for (index, side) in group.iter_mut().enumerate() { - // A trimmed offset is not linked to its source: it has no distance to drive. - if side.unlinked_sources.is_some() { - continue; - } - let name = if index == 1 { param2.as_ref().unwrap_or(¶m) } else { ¶m }; - let output = side.curves.first().ok_or_else(|| bad(cmd, "offset produced no curves"))?; - side.constraints.push(sk.add_constraint( - ConstraintKind::OffsetDistance { output: output.clone(), value: side.distance.abs(), reverse: side.distance < 0.0 }, - Some(name.clone()), - )?); - side.param = Some(name.clone()); - } + let options = OffsetOptions { side, chain: use_chain, distance: d0, symmetric, distance2 }; + let (group, sources) = offset_ladder(sk, cmd, &pending, &eligible, options, instances, &made)?; let before = pending.len(); pending.retain(|id| !sources.contains(id)); if pending.len() == before { @@ -2607,31 +2717,31 @@ fn offset(s: &mut Session, p: &Value) -> Result { sides.extend(group); } // Parameters made for distances that only trimmed (unlinked) offsets used drive nothing. - let used = |name: &String| sides.iter().any(|side: &Side| side.param.as_ref() == Some(name)); + let used = |name: &String| sides.iter().any(|(_, side): &(usize, Side)| side.param.as_ref() == Some(name)); let param = used(¶m).then_some(param); let param2 = param2.filter(|name| used(name)); doc.params.retain(|p| !made.contains(&p.name) || param.as_ref() == Some(&p.name) || param2.as_ref() == Some(&p.name)); Ok((sides, param, param2)) })?; - 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 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, "param": side.param}); + .map(|(instance, side)| { + let mut entry = json!({"distance": side.distance, "curves": side.curves, "param": side.param, "instance": instance}); if let Some(association) = &side.association { entry["association"] = json!(association); } entry }) .collect(); - let mut response = json!({"curves":curves,"constraints":constraints,"sketch":info,"sides":each,"param":param}); + let mut response = json!({"curves":curves,"constraints":constraints,"sketch":info,"sides":each,"param":param,"instances":instances}); if let Some(param2) = param2 { response["param2"] = json!(param2); } - // 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() + // One side keeps the single `association` it always returned. Two sides, or an instance + // behind another, 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); diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 32f30349ee..7d5b1b8dae 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -2418,6 +2418,143 @@ fn offset_two_sides_of_one_curve_follows_the_sign_of_the_distance() { assert!(close(one.1.x - one.0.x, 40.0) && close(two.1.x - two.0.x, 40.0), "{one:?} {two:?}"); } +/// Each instance of an outward offset of a 40 by 30 rectangle, as a box one more step out. +fn offset_rings(sk: &solidcraft_sketch::Sketch, o: &Value, out: &[f64]) { + for (index, out) in out.iter().copied().enumerate() { + let curves = side_curves(o, index); + assert_eq!(curves.len(), 4, "{o}"); + assert!(box_is(sk, &curves, [-out, -out], [40.0 + out, 30.0 + out]), "instance {}: {:?}", index + 1, bounds(sk, &curves)); + } +} + +#[test] +fn offset_instances_stack_one_behind_another_at_the_same_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"]); + // Three instances outward by 5 from a 40 by 30 rectangle: rings 5, 10 and 15 out, each the + // offset of the one before it. + let o = run(&mut s, "sketch.offset", json!({"curves": [l[0]], "distance": 5, "side": [20, -10], "instances": 3})); + assert_eq!(o["instances"], 3, "{o}"); + let sides = o["sides"].as_array().unwrap().clone(); + assert_eq!(sides.len(), 3, "{o}"); + assert_eq!(sides.iter().map(|side| side["instance"].as_u64()).collect::>(), vec![Some(1), Some(2), Some(3)], "{o}"); + assert_eq!(ids(&o["curves"]).len(), 12, "{o}"); + offset_rings(&sketch(&s), &o, &[5.0, 10.0, 15.0]); + // One dimension drives the whole ladder, so every ring moves together when it changes. + let param = o["param"].as_str().unwrap().to_string(); + assert!(sides.iter().all(|side| side["param"].as_str() == Some(param.as_str())), "{o}"); + assert_eq!(sketch(&s).constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::OffsetDistance { .. })).count(), 3); + run(&mut s, "parameters.change", json!({"name": param, "expression": "8 mm"})); + offset_rings(&sketch(&s), &o, &[8.0, 16.0, 24.0]); +} + +#[test] +fn offset_instances_go_the_way_the_first_one_went() { + 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"]); + // Inward by 5: rings at (5,5)-(35,25) and (10,10)-(30,20). A later instance reads its side + // from the ring it follows, so it never doubles back over the one before it. + let o = run(&mut s, "sketch.offset", json!({"curves": [l[0]], "distance": 5, "side": [20, 15], "instances": 2})); + 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), [10.0, 10.0], [30.0, 20.0]), "{:?}", bounds(&sk, &side_curves(&o, 1))); +} + +#[test] +fn offset_instances_stop_where_the_shape_runs_out() { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + let before = s.doc.to_json(); + // Inward by 5 twice reaches a 20 by 10 ring; a third would have no height left, so the + // whole command is refused and names the instance that could not be made. + let error = s.execute("sketch.offset", &json!({"curves": ["l1"], "distance": 5, "side": [20, 15], "instances": 3})).unwrap_err().to_string(); + assert!(error.contains("instance 3 of 3"), "{error}"); + assert_eq!(s.doc.to_json(), before, "a refused instance leaves the design alone"); + // Two of them still work on the same shape. + let o = run(&mut s, "sketch.offset", json!({"curves": ["l1"], "distance": 5, "side": [20, 15], "instances": 2})); + assert_eq!(o["sides"].as_array().map(Vec::len), Some(2), "{o}"); +} + +#[test] +fn offset_instances_count_must_be_a_whole_number_within_the_cap() { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + for count in [json!(0), json!(-1), json!(1.5), json!(101), json!("two"), json!(true)] { + let params = json!({"curves": ["l1"], "distance": 1, "side": [20, -10], "instances": count.clone()}); + assert!(s.execute("sketch.offset", ¶ms).is_err(), "instances {count} must be refused"); + } + // No count, and a count of one, are the offset by itself. + for count in [json!(null), json!(1), json!("1")] { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + let o = run(&mut s, "sketch.offset", json!({"curves": ["l1"], "distance": 1, "side": [20, -10], "instances": count})); + assert_eq!(o["instances"], 1, "{o}"); + assert_eq!(o["sides"].as_array().map(Vec::len), Some(1), "{o}"); + assert_eq!(ids(&o["curves"]).len(), 4, "{o}"); + } +} + +#[test] +fn offset_instances_into_a_circle_run_out_at_the_centre() { + let mut s = new_sketch(); + let c = ids(&run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 10}))["curves"])[0].clone(); + let radii = |s: &Session, o: &Value| -> Vec { + let sk = sketch(s); + o["sides"] + .as_array() + .unwrap() + .iter() + .map(|side| match sk.curves.get(sk.curve_index(ids(&side["curves"])[0].as_str()).unwrap()).unwrap().kind { + CurveKind::Circle { r, .. } => r, + ref other => panic!("{other:?}"), + }) + .collect() + }; + // Inward by 3 from a 10 mm circle: rings at 7, 4 and 1 mm. + let o = run(&mut s, "sketch.offset", json!({"curves": [c.clone()], "distance": 3, "side": [0, 0], "instances": 3})); + let got = radii(&s, &o); + assert!(got.iter().zip([7.0, 4.0, 1.0]).all(|(a, b)| close(*a, b)), "{got:?}"); + let before = s.doc.to_json(); + // A fourth has nothing left to take. The ladder has to run out here: a step past a ring + // smaller than the distance lands beyond the centre, and reading that as the outward side + // turned the ladder round and grew it again (1, 4, 1, 4 ...) rather than refusing. + let error = s.execute("sketch.offset", &json!({"curves": [c.clone()], "distance": 3, "side": [0, 0], "instances": 4})).unwrap_err().to_string(); + assert!(error.contains("instance 4 of 4"), "{error}"); + assert!(error.contains("no size"), "{error}"); + assert_eq!(s.doc.to_json(), before, "a refused instance leaves the design alone"); + // Outward is unaffected: every ring is one distance larger than the last. + let out = run(&mut s, "sketch.offset", json!({"curves": [c], "distance": 3, "side": [40, 0], "instances": 3})); + let got = radii(&s, &out); + assert!(got.iter().zip([13.0, 16.0, 19.0]).all(|(a, b)| close(*a, b)), "{got:?}"); +} + +#[test] +fn offset_instances_follow_each_side_of_a_symmetric_offset() { + let mut s = new_sketch(); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})); + // Symmetric by 5, two instances: both ways get a second ring a further 5 out, so the four + // rings are 5 and 10 inside and 5 and 10 outside. + let o = run(&mut s, "sketch.offset", json!({"curves": ["l1"], "distance": 5, "symmetric": true, "instances": 2})); + let sides = o["sides"].as_array().unwrap().clone(); + assert_eq!(sides.len(), 4, "{o}"); + assert_eq!(sides.iter().filter(|side| side["instance"] == 2).count(), 2, "{o}"); + let sk = sketch(&s); + let mut boxes: Vec<[f64; 4]> = sides + .iter() + .map(|side| { + let (lo, hi) = bounds(&sk, &ids(&side["curves"])); + [lo.x, lo.y, hi.x, hi.y] + }) + .collect(); + boxes.sort_by(|a, b| a[0].total_cmp(&b[0])); + let want = [[-10.0, -10.0, 50.0, 40.0], [-5.0, -5.0, 45.0, 35.0], [5.0, 5.0, 35.0, 25.0], [10.0, 10.0, 30.0, 20.0]]; + for (got, want) in boxes.iter().zip(&want) { + assert!(got.iter().zip(want).all(|(a, b)| close(*a, *b)), "{boxes:?}"); + } +} + #[test] fn offset_both_sides_of_a_rounded_boundary_keep_separate_associations() { let mut s = new_sketch(); diff --git a/crates/ui-egui/src/dialogs_sketch_tests.rs b/crates/ui-egui/src/dialogs_sketch_tests.rs index 545b94b2ac..58c5847560 100644 --- a/crates/ui-egui/src/dialogs_sketch_tests.rs +++ b/crates/ui-egui/src/dialogs_sketch_tests.rs @@ -80,7 +80,7 @@ macro_rules! form_case { } form_case!(scale, "sketch.scale", ["Factor"]); -form_case!(offset, "sketch.offset", ["Direction", "Distance"]); +form_case!(offset, "sketch.offset", ["Direction", "Distance", "Instances"]); form_case!(mirror, "sketch.mirror", []); form_case!(move_copy, "sketch.move", ["Position", "Angle"]); form_case!(rectangular_pattern, "sketch.pattern.rectangular", ["Quantity", "Spacing", "Alignment", "Quantity 2", "Spacing 2", "Alignment 2"]); diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index 3317e32720..716d3ed618 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -72,6 +72,9 @@ 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 engine's own cap on the offsets `sketch.offset` stacks one behind the other. +const MAX_OFFSET_INSTANCES: i64 = 100; + /// 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. @@ -102,8 +105,10 @@ pub enum Mod { /// `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. `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 }, + /// `distance2` is the other side's distance that Two Sides asks for. `instances` is how many + /// offsets stand one behind the other, each the same distance again from the one before it: + /// one, the offset by itself, by default. + Offset { distance: String, distance2: String, instances: 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. @@ -227,6 +232,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { "sketch.offset" => Mod::Offset { distance: crate::dialogs::length_default(&app.session, "2"), distance2: crate::dialogs::length_default(&app.session, "2"), + instances: "1".into(), mode: 0, chain: true, flip: false, @@ -526,12 +532,15 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod, doc: &Document) -> bool { let mut enter = false; match k { Mod::Scale { factor } => enter |= number(ui, "Factor", factor), - Mod::Offset { distance, distance2, mode, chain, flip } => { + Mod::Offset { distance, distance2, instances, mode, chain, flip } => { icons(ui, "Direction", &OFFSET_MODES, &OFFSET_MODE_ICONS, mode); enter |= number(ui, "Distance", distance); if *mode == TWO_SIDES { enter |= number(ui, "Distance 2", distance2); } + // Every instance after the first offsets the one before it by Distance again, so the + // arrows walk the offset outwards one ring at a time. + enter |= quantity(ui, doc, "Instances", instances, 1, MAX_OFFSET_INSTANCES); ui_kit::section(ui, "offset_options", "Options", true, |ui| { if *mode != SYMMETRIC { ui_kit::checkbox_grid(ui, &mut [("Flip", flip), ("Chain", chain)], 2); @@ -796,6 +805,15 @@ fn whole(app: &SolveApp, label: &str, text: &str) -> Result { Ok(v.round() as u64) } +/// Validate a typed offset instance count before sending it to the command. +fn offset_instances(app: &SolveApp, text: &str) -> Result { + let v = app.session.doc.eval(text.trim(), ValueKind::Unitless).map_err(|e| format!("Instances: {e}"))?; + if !v.is_finite() || !(1.0..=MAX_OFFSET_INSTANCES as f64).contains(&v) || (v - v.round()).abs() > 1e-9 { + return Err(format!("Instances must be a whole number from 1 to {MAX_OFFSET_INSTANCES}")); + } + Ok(v.round() as u64) +} + /// A typed length as the command wants it, in millimetres. A bare number means the design's /// units, as a typed sketch value does everywhere else. `value` above reads an empty row as /// zero, which a Move window wants and a pattern spacing does not. @@ -903,14 +921,18 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map { + Mod::Offset { distance, distance2, instances, mode, chain, flip } => { let curves = curve_ids(inputs, 0); if curves.is_empty() { return Err("Select the curves to offset first".into()); } // Symmetric hides Flip, so a flip left over from another mode does not travel with it. signed_length(app, "Distance", distance, *flip && *mode != SYMMETRIC)?; - let mut params = json!({"curves": curves, "distance": distance.trim(), "flip": *flip && *mode != SYMMETRIC, "chain": chain}); + // The count is not kept in the design, so the command gets the number it evaluates to + // rather than the text, the way Scale and the pattern spacings send theirs. + let count = offset_instances(app, instances)?; + let mut params = + json!({"curves": curves, "distance": distance.trim(), "flip": *flip && *mode != SYMMETRIC, "chain": chain, "instances": count}); if let Value::Object(m) = &mut params { if *mode == SYMMETRIC { m.insert("symmetric".into(), json!(true)); diff --git a/crates/ui-egui/src/ui_kit/fields.rs b/crates/ui-egui/src/ui_kit/fields.rs index 51e39ae34d..af10715834 100644 --- a/crates/ui-egui/src/ui_kit/fields.rs +++ b/crates/ui-egui/src/ui_kit/fields.rs @@ -53,7 +53,14 @@ pub(crate) fn count_field(ui: &mut Ui, value: &mut String, limits: &Count<'_>) - let next = step_count(value, delta, limits); let arrow = ui.interact(half, ui.id().with(("sc_count_step", &row, name)), egui::Sense::click()); let enabled = ui.is_enabled() && next.is_some(); - if enabled && arrow.hovered() { + // The arrows stand inside the editor, which claims the text cursor across its whole + // rect: they are not text, so each one takes the ordinary cursor back after it. The + // pointer is read from the rect rather than from the hover the editor competes for. + let over = ui.rect_contains_pointer(half); + if over { + ui.ctx().set_cursor_icon(egui::CursorIcon::Default); + } + if enabled && over { painter.rect_filled(half.shrink(1.0), t.control_radius, t.selected_bg); } let ink = if enabled { t.form_ink } else { t.form_dim }; diff --git a/crates/ui-egui/src/ui_kit/fields_tests.rs b/crates/ui-egui/src/ui_kit/fields_tests.rs index c05710673f..0a84b8a16e 100644 --- a/crates/ui-egui/src/ui_kit/fields_tests.rs +++ b/crates/ui-egui/src/ui_kit/fields_tests.rs @@ -313,6 +313,39 @@ fn count_steps_stay_within_limits_and_keep_expressions() { assert_eq!(super::step_count("5", i64::MIN, &wide).as_deref(), Some("-9223372036854775803")); } +/// A count's step arrows stand inside the editor, which asks for the text cursor across its +/// whole rect, so the arrows showed an I-beam and read as part of the text. They are not text: +/// the pointer over one is the ordinary cursor, whether it can step or not, and the number +/// beside them is still typed. +#[test] +fn count_step_arrows_do_not_take_the_text_cursor() { + for value in ["5", "1"] { + let ctx = crate::fonts::test_context(); + let eval = |text: &str| text.trim().parse::().ok(); + let mut count = value.to_string(); + let mut draw = |ui: &mut Ui| { + count_row(ui, "Instances", &mut count, &Count { min: 1, max: 10, eval: &eval }); + }; + frame(&ctx, vec![], &mut draw); + let at = |name: &str| scenario::handle_at(&format!("dialog:Instances {name}")).unwrap_or_else(|| panic!("{name} has a handle")); + let (above, below) = (at("up"), at("down")); + let field = scenario::rect_of("dialog:widget:Instances").expect("the field has a rect"); + // The first frame registers the rects; the second reads the cursor they ask for. At 1 the + // down arrow is dimmed and the up arrow can still step: neither is text either way. + for (pos, which) in [(above, "up"), (below, "down")] { + frame_cursor(&ctx, pos, &mut draw); + assert_eq!(frame_cursor(&ctx, pos, &mut draw), egui::CursorIcon::Default, "{value}: the {which} arrow"); + } + let text = egui::pos2(field.left() + 2.0, field.center().y); + frame_cursor(&ctx, text, &mut draw); + assert_eq!(frame_cursor(&ctx, text, &mut draw), egui::CursorIcon::Text, "{value}: the number is still typed"); + // The enabled arrow still steps when it is clicked. + frame(&ctx, pointer(above, true), &mut draw); + frame(&ctx, pointer(above, false), &mut draw); + assert_eq!(count, if value == "1" { "2" } else { "6" }, "the up arrow steps"); + } +} + #[test] fn selection_field_has_three_distinct_looks_in_every_theme() { type Look = (Vec<(egui::Color32, egui::Color32)>, Option);