Sketch Offset: an Instances count stacks offsets one behind another (#9827) (#9828)

* claim: #9827

* Sketch Offset: an Instances count stacks offsets one behind another (#9827)

Offset made one offset at a time, so a set of evenly spaced outlines meant
running it again on the curves it had just made, once per ring: a separate undo
step and a separate driving dimension each time.

`sketch.offset` now takes `instances`, a whole number or a unitless expression
from 1 to 100 (1, today's behaviour, by default). The per-group work the command
did inline becomes `offset_rung`, and `offset_ladder` runs it once per instance,
each rung offsetting the one before it the same distance again, so instance n
stands n distances from the curves picked. Every rung of a side is driven by
that side's own dimension, so editing it moves the whole set and keeps the
spacing even, and each side of a Two Sides or Symmetric offset grows its own
ladder. A rung the shape has no room for refuses the whole command and names
which instance it was.

A later rung reads its side from the geometry, through the rung's own
normal-distance annotation, rather than from the distance's sign: 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 circle is the exception
and keeps the sign, because it offsets concentrically with no chain to walk, and
a step past a ring smaller than the distance lands beyond the centre, where the
circle's "outside the current radius" side test reads as the outward side: an
inward ladder turned round and grew again (1, 4, 1, 4 mm) instead of running out
of room.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Sketch Offset: the Instances row in the offset window (#9827)

The offset window gets an Instances row under Distance: a whole number with step
arrows, 1 by default, so the count can be walked up a ring at a time and the
preview follows each step. The count is not kept in the design, as Mirror's and
Scale's values are not, so the window sends the number it evaluates to rather
than the text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UI kit: a count's step arrows are not text (#9827)

The arrows stand inside the editor, which asks for the text cursor across its
whole rect, so hovering one showed an I-beam and the arrows read as part of the
number. They take the ordinary cursor back after it, and the pointer is read
from the arrow's rect rather than from the hover the editor underneath competes
for, which keeps the hover highlight in step with it.

The pattern Quantity rows share this field and had the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Sketch Offset: Instances label in the complete catalogs and the other shipped languages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sketch Offset Instances: translations for the complete catalogs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
This commit is contained in:
N3MI-DG
2026-11-01 18:02:32 +00:00
committed by GitHub
parent c454c05f88
commit 2014e60ae6
8 changed files with 434 additions and 52 deletions
@@ -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": "實例數"
}
}
@@ -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 |
+155 -45
View File
@@ -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<usize> {
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<usize> {
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<Vec2>,
@@ -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<String>,
options: OffsetOptions,
names: &[String],
) -> Result<(Vec<Side>, Vec<String>)> {
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::<Vec<_>>())
.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<Vec2> {
// 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<String>, 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<String>,
options: OffsetOptions,
instances: usize,
names: &[String],
) -> Result<(Vec<(usize, Side)>, Vec<String>)> {
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<String>, 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<Value> {
let cmd = "sketch.offset";
let ids = string_list(cmd, p, "curves")?;
@@ -2550,6 +2692,7 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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::<Vec<_>>())
.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(&param) } else { &param };
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<Value> {
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(&param).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<Value> = 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);
@@ -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<_>>(), 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", &params).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<f64> {
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();
+1 -1
View File
@@ -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"]);
+27 -5
View File
@@ -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<SelInput>)> {
"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<u64, String> {
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<u64, String> {
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<String
let (dx, dy) = (value(app, x, ValueKind::Length, "X")?, value(app, y, ValueKind::Length, "Y")?);
("sketch.transform", json!({"entities": entities, "factor": f, "angle": turn, "center": middle, "translate": [dx, dy]}))
}
Mod::Offset { distance, distance2, mode, chain, flip } => {
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));
+8 -1
View File
@@ -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 };
+33
View File
@@ -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::<f64>().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<egui::Color32>);