Feat/Sketch: associative pattern tools with independent directions and Flip/Center controls (#1601)
Thanks @Karl-VonRichter.
This commit is contained in:
1 parent
44968dbfe6
commit
371578a06f
36 files changed
+1394
-87
No files matched your search
@@ -80,20 +80,20 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool; picking a text outline or linked point transforms its whole text source once, and copies remain editable text", created_doc!())),
|
||||
CommandSpec::new("sketch.mirror", "Mirror", mirror)
|
||||
.at("SKETCH", "CREATE")
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_mirror")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [curve/point ids], line: mirror line id (symmetry constraints are added)", created_doc!())),
|
||||
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
|
||||
.at("SKETCH", "CREATE")
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_circ_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360). Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500, angle?: total deg (default 360); alignment?: left|center|right (default right); reversed?: bool (default false). Quantity includes the original. Center balances copies around it; reversed flips the direction and extra side for even totals. Partial arcs span angle with angle/(count-1) spacing; full turns use angle/count. Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
.at("SKETCH", "CREATE")
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_rect_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction). Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
.params(concat!("entities: [ids], count, spacing; count2?, spacing2?; direction?, direction2?: sketch line ids (live references); dir?, dir2?: [x,y] vectors (defaults: sketch x, perpendicular to direction 1); alignment?, alignment2?: left|center|right (default right). Signed spacings reverse each direction. reversed?, reversed2?: bool (default false); reverses placement and the extra side of even centered totals. Quantities include the original. Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
];
|
||||
|
||||
const TOL: f64 = 1e-7;
|
||||
@@ -1700,6 +1700,9 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (o, n) in &map {
|
||||
if o != n {
|
||||
cons.push(add_c(sk, ConstraintKind::Symmetric { p: *o, q: *n, l })?);
|
||||
} else if !sk.curves.get(l).is_some_and(|curve| curve.kind.point_ids().contains(o)) {
|
||||
// A shared endpoint must stay on the mirror line as the source changes.
|
||||
cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: *o, c: l })?);
|
||||
}
|
||||
}
|
||||
for (o, n) in cs.iter().zip(&made) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Associative sketch patterns. Editing retains ids at surviving instance indices.
|
||||
|
||||
use solidcraft_sketch::{PatternInstance, SketchPattern, SketchPatternKind};
|
||||
use solidcraft_sketch::{PatternAlignment, PatternInstance, SketchPattern, SketchPatternKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -23,9 +23,24 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
return Err(bad(cmd, "use the existing pattern's type"));
|
||||
}
|
||||
let defaults = match &old.kind {
|
||||
SketchPatternKind::Circular { center, count, angle } => json!({"entities":old.entities,"center":center,"count":count,"angle":angle}),
|
||||
SketchPatternKind::Rectangular { dir, count, spacing, count2, spacing2 } => {
|
||||
json!({"entities":old.entities,"dir":dir,"count":count,"spacing":spacing,"count2":count2,"spacing2":spacing2})
|
||||
SketchPatternKind::Circular { center, count, angle, alignment, reversed } => {
|
||||
json!({"entities":old.entities,"center":center,"count":count,"angle":angle,"alignment":alignment,"reversed":reversed})
|
||||
}
|
||||
SketchPatternKind::Rectangular {
|
||||
dir,
|
||||
dir2,
|
||||
direction,
|
||||
direction2,
|
||||
count,
|
||||
spacing,
|
||||
count2,
|
||||
spacing2,
|
||||
alignment,
|
||||
alignment2,
|
||||
reversed,
|
||||
reversed2,
|
||||
} => {
|
||||
json!({"entities":old.entities,"dir":dir,"dir2":dir2,"direction":direction,"direction2":direction2,"count":count,"spacing":spacing,"count2":count2,"spacing2":spacing2,"alignment":alignment,"alignment2":alignment2,"reversed":reversed,"reversed2":reversed2})
|
||||
}
|
||||
};
|
||||
if let (Some(params), Some(defaults)) = (params.as_object_mut(), defaults.as_object()) {
|
||||
@@ -78,10 +93,12 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
let id = sk.points.get(center).ok_or_else(|| bad(cmd, "missing center point"))?.id.clone();
|
||||
let full = (total.abs() - 360.0).abs() < 1e-9;
|
||||
let step = total / if full { n as f64 } else { (n.max(2) - 1) as f64 };
|
||||
let alignment = alignment_param(¶ms, "alignment", cmd)?;
|
||||
let reversed = reversed_param(¶ms, "reversed", cmd)?;
|
||||
(
|
||||
SketchPatternKind::Circular { center: id, count: n, angle: total },
|
||||
SketchPatternKind::Circular { center: id, count: n, angle: total, alignment, reversed },
|
||||
Some(center),
|
||||
(1..n).map(|i| ([i, 0], (step * i as f64).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
|
||||
(1..n).map(|i| ([i, 0], (step * alignment.multiplier(i, reversed)).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
|
||||
)
|
||||
} else {
|
||||
let spacing = num(¶ms, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be finite"))?;
|
||||
@@ -98,14 +115,49 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
Some(value) => vec2(value).and_then(Vec2::normalized).ok_or_else(|| bad(cmd, "`dir` must be a finite nonzero vector"))?,
|
||||
None => Vec2::X,
|
||||
};
|
||||
let dir2 = match params.get("dir2") {
|
||||
Some(value) if !value.is_null() => {
|
||||
Some(vec2(value).and_then(Vec2::normalized).ok_or_else(|| bad(cmd, "`dir2` must be a finite nonzero vector"))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let direction = direction_ref(sk, ¶ms, "direction", cmd)?;
|
||||
let direction2 = direction_ref(sk, ¶ms, "direction2", cmd)?;
|
||||
let alignment = alignment_param(¶ms, "alignment", cmd)?;
|
||||
let alignment2 = alignment_param(¶ms, "alignment2", cmd)?;
|
||||
let reversed = reversed_param(¶ms, "reversed", cmd)?;
|
||||
let reversed2 = reversed_param(¶ms, "reversed2", cmd)?;
|
||||
let first = line_direction(sk, direction.as_deref()).unwrap_or(dir);
|
||||
let second = line_direction(sk, direction2.as_deref()).or(dir2).unwrap_or_else(|| first.perp());
|
||||
let slots = (0..n)
|
||||
.flat_map(|i| {
|
||||
(0..count2).filter_map(move |j| {
|
||||
(i != 0 || j != 0).then_some(([i, j], 0.0, dir * (spacing * i as f64) + dir.perp() * (spacing2 * j as f64)))
|
||||
(i != 0 || j != 0).then_some((
|
||||
[i, j],
|
||||
0.0,
|
||||
first * (spacing * alignment.multiplier(i, reversed)) + second * (spacing2 * alignment2.multiplier(j, reversed2)),
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(SketchPatternKind::Rectangular { dir, count: n, spacing, count2, spacing2 }, None, slots)
|
||||
(
|
||||
SketchPatternKind::Rectangular {
|
||||
dir,
|
||||
dir2,
|
||||
direction,
|
||||
direction2,
|
||||
count: n,
|
||||
spacing,
|
||||
count2,
|
||||
spacing2,
|
||||
alignment,
|
||||
alignment2,
|
||||
reversed,
|
||||
reversed2,
|
||||
},
|
||||
None,
|
||||
slots,
|
||||
)
|
||||
};
|
||||
let id = match &old {
|
||||
Some(old) => old.id.clone(),
|
||||
@@ -126,20 +178,28 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
if center_id.is_some() && center.is_none() {
|
||||
return Err(bad(cmd, "the center belongs to an instance removed by this edit"));
|
||||
}
|
||||
if !circular {
|
||||
// A direction can have belonged to an instance retired above. Resolve it again
|
||||
// before creating relations so an edit never silently substitutes a vector.
|
||||
direction_ref(sk, ¶ms, "direction", cmd)?;
|
||||
direction_ref(sk, ¶ms, "direction2", cmd)?;
|
||||
}
|
||||
let mut curves = Vec::new();
|
||||
let mut constraints = Vec::new();
|
||||
for (index, angle, offset) in slots {
|
||||
let (base_offset, directions) = linear_displacement(sk, &kind, index, offset);
|
||||
let c = center.and_then(|i| sk.point(i)).unwrap_or(Vec2::ZERO);
|
||||
let transform = |p| rotate(c, angle)(p) + offset;
|
||||
let instance = if let Some(i) = existing.iter().position(|instance| instance.index == index) {
|
||||
let instance = existing.remove(i);
|
||||
for cid in &instance.constraints {
|
||||
if let Some(constraint) = sk.constraints.iter_mut().find(|constraint| constraint.id == *cid)
|
||||
&& let ConstraintKind::PatternPoint { center: slot, angle: a, offset: off, .. } = &mut constraint.kind
|
||||
&& let ConstraintKind::PatternPoint { center: slot, angle: a, offset: off, directions: dirs, .. } = &mut constraint.kind
|
||||
{
|
||||
*slot = center;
|
||||
*a = angle;
|
||||
*off = offset;
|
||||
*off = base_offset;
|
||||
*dirs = directions;
|
||||
}
|
||||
}
|
||||
for (seed, copy) in &instance.points {
|
||||
@@ -155,7 +215,7 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
let (made, map) = copy_through(sk, &cs, &ps, &transform, false, &|_| false, 1.0)?;
|
||||
let mut cons = Vec::new();
|
||||
for (p, q) in &map {
|
||||
cons.push(add_c(sk, ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset })?);
|
||||
cons.push(add_c(sk, ConstraintKind::PatternPoint { p: *p, q: *q, center, angle, offset: base_offset, directions })?);
|
||||
}
|
||||
for (a, b) in cs.iter().zip(&made) {
|
||||
if matches!(sk.curves.get(*a).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
|
||||
@@ -188,3 +248,53 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn direction_ref(sk: &Sketch, p: &Value, key: &str, cmd: &str) -> Result<Option<String>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(id)) if line_direction(sk, Some(id)).is_some() => Ok(Some(id.clone())),
|
||||
_ => Err(bad(cmd, format!("`{key}` must reference a nondegenerate sketch line"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn alignment_param(p: &Value, key: &str, cmd: &str) -> Result<PatternAlignment> {
|
||||
p.get(key).map_or(Ok(PatternAlignment::default()), |value| {
|
||||
serde_json::from_value(value.clone()).map_err(|_| bad(cmd, format!("`{key}` must be left, center or right")))
|
||||
})
|
||||
}
|
||||
|
||||
fn reversed_param(p: &Value, key: &str, cmd: &str) -> Result<bool> {
|
||||
p.get(key).map_or(Ok(false), |value| serde_json::from_value(value.clone()).map_err(|_| bad(cmd, format!("`{key}` must be true or false"))))
|
||||
}
|
||||
|
||||
fn line_direction(sk: &Sketch, id: Option<&str>) -> Option<Vec2> {
|
||||
let Shape::Line { a, b } = sk.shape(sk.curve_index(id?)?)? else { return None };
|
||||
(b - a).normalized()
|
||||
}
|
||||
|
||||
/// Keep sampled vector directions compatible with older designs, while picked lines are
|
||||
/// solver references. The default second direction follows the first line's perpendicular.
|
||||
fn linear_displacement(sk: &Sketch, kind: &SketchPatternKind, [i, j]: [usize; 2], fallback: Vec2) -> (Vec2, [Option<(usize, Vec2)>; 2]) {
|
||||
let SketchPatternKind::Rectangular { dir, dir2, direction, direction2, spacing, spacing2, alignment, alignment2, reversed, reversed2, .. } = kind
|
||||
else {
|
||||
return (fallback, [None; 2]);
|
||||
};
|
||||
let mut offset = Vec2::ZERO;
|
||||
let mut directions = [None; 2];
|
||||
let d1 = *spacing * alignment.multiplier(i, *reversed);
|
||||
let d2 = *spacing2 * alignment2.multiplier(j, *reversed2);
|
||||
if let Some(l) = direction.as_ref().and_then(|id| sk.curve_index(id)) {
|
||||
directions[0] = Some((l, Vec2::new(d1, if dir2.is_none() && direction2.is_none() { d2 } else { 0.0 })));
|
||||
} else {
|
||||
offset += *dir * d1;
|
||||
if dir2.is_none() && direction2.is_none() {
|
||||
offset += dir.perp() * d2;
|
||||
}
|
||||
}
|
||||
if let Some(l) = direction2.as_ref().and_then(|id| sk.curve_index(id)) {
|
||||
directions[1] = Some((l, Vec2::new(d2, 0.0)));
|
||||
} else if let Some(dir2) = dir2 {
|
||||
offset += *dir2 * d2;
|
||||
}
|
||||
(offset, directions)
|
||||
}
|
||||
@@ -4,6 +4,329 @@ fn point(sk: &solidcraft_sketch::Sketch, id: &str) -> Vec2 {
|
||||
sk.point(sk.resolve_point(id).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_centered_six_holes_span_ninety_degrees_and_flip_about_the_original() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":1,"id":"seed"}));
|
||||
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["seed"],"center":[0,0],"count":6,"angle":90,"alignment":"center"}));
|
||||
let copies = ids(&out["curves"]);
|
||||
let check = |s: &Session, angles: &[f64], seed: Vec2, center: Vec2| {
|
||||
assert!(point(&sketch(s), "seed.center").dist(seed) < 1e-6);
|
||||
for (id, angle) in copies.iter().zip(angles) {
|
||||
let (sin, cos) = angle.to_radians().sin_cos();
|
||||
let d = seed - center;
|
||||
let expected = center + Vec2::new(d.x * cos - d.y * sin, d.x * sin + d.y * cos);
|
||||
assert!(point(&sketch(s), &format!("{id}.center")).dist(expected) < 1e-6, "angle {angle}");
|
||||
}
|
||||
check_relations(s);
|
||||
};
|
||||
check(&s, &[18., -18., 36., -36., 54.], Vec2::new(20., 0.), Vec2::ZERO);
|
||||
let before = s.doc.to_json();
|
||||
let flipped = run(&mut s, "sketch.pattern.circular", json!({"pattern":out["pattern"],"reversed":true}));
|
||||
assert_eq!(ids(&flipped["curves"]), copies);
|
||||
check(&s, &[-18., 18., -36., 36., -54.], Vec2::new(20., 0.), Vec2::ZERO);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let mut s = Session { doc: solidcraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
|
||||
s.refresh();
|
||||
// Omitting placement settings must retain them after reopening.
|
||||
run(&mut s, "sketch.pattern.circular", json!({"pattern":out["pattern"],"angle":90}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":[out["center"]]}));
|
||||
run(&mut s, "sketch.drag", json!({"entity":"seed.center","from":[20,0],"to":[25,2]}));
|
||||
check(&s, &[-18., 18., -36., 36., -54.], Vec2::new(25., 2.), Vec2::ZERO);
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":[out["center"]],"fixed":false}));
|
||||
run(&mut s, "sketch.drag", json!({"entity":out["center"],"from":[0,0],"to":[2,3]}));
|
||||
check(&s, &[-18., 18., -36., 36., -54.], Vec2::new(25., 2.), Vec2::new(2., 3.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_alignment_handles_odd_arcs_one_sided_rotation_and_full_turns() {
|
||||
for (count, angle, alignment, reversed, expected) in [
|
||||
(5, 90., "center", false, vec![22.5_f64, -22.5, 45., -45.]),
|
||||
(3, 90., "right", false, vec![45., 90.]),
|
||||
(3, 90., "right", true, vec![-45., -90.]),
|
||||
(3, 90., "left", false, vec![-45., -90.]),
|
||||
(3, -90., "center", true, vec![45., -45.]),
|
||||
(6, 360., "center", false, vec![60., -60., 120., -120., 180.]),
|
||||
(6, 360., "center", true, vec![-60., 60., -120., 120., -180.]),
|
||||
] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":1,"id":"seed"}));
|
||||
let out = run(
|
||||
&mut s,
|
||||
"sketch.pattern.circular",
|
||||
json!({"entities":["seed"],"center":[0,0],"count":count,"angle":angle,"alignment":alignment,"reversed":reversed}),
|
||||
);
|
||||
let mut positions = vec![Vec2::new(20., 0.)];
|
||||
for (id, a) in ids(&out["curves"]).iter().zip(expected) {
|
||||
let pos = point(&sketch(&s), &format!("{id}.center"));
|
||||
let (sin, cos) = a.to_radians().sin_cos();
|
||||
assert!(pos.dist(Vec2::new(20. * cos, 20. * sin)) < 1e-6);
|
||||
assert!(positions.iter().all(|p| pos.dist(*p) > 1.));
|
||||
positions.push(pos);
|
||||
}
|
||||
assert_eq!(positions.len(), count);
|
||||
check_relations(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_invalid_alignment_and_flip_roll_back_and_old_saved_defaults_still_load() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[20,0],"radius":1,"id":"seed"}));
|
||||
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["seed"],"center":[0,0],"count":3,"angle":90}));
|
||||
let before = s.doc.to_json();
|
||||
for extra in [json!({"reversed":"true"}), json!({"reversed":null}), json!({"alignment":"unknown"}), json!({"alignment":null})] {
|
||||
let mut params = json!({"pattern":out["pattern"]});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
assert!(s.execute("sketch.pattern.circular", ¶ms).is_err(), "{extra}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
let mut saved: Value = serde_json::from_str(&before).unwrap();
|
||||
let kind = saved["features"][0]["sketch"]["patterns"][0]["kind"].as_object_mut().unwrap();
|
||||
kind.remove("alignment");
|
||||
kind.remove("reversed");
|
||||
let mut reopened =
|
||||
Session { doc: solidcraft_doc::format::read(&saved.to_string()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
|
||||
reopened.refresh();
|
||||
run(&mut reopened, "sketch.pattern.circular", json!({"pattern":out["pattern"],"count":3}));
|
||||
assert!(point(&sketch(&reopened), &format!("{}.center", out["curves"][1].as_str().unwrap())).dist(Vec2::new(0., 20.)) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_two_direction_grid_keeps_original_fixed_and_follows_live_guides_after_reopen() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":2,"id":"seed"}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,-10],[10,-10]],"ids":["guide1"]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,-30],[6,-22]],"ids":["guide2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide1","guide2"]}));
|
||||
let out = run(
|
||||
&mut s,
|
||||
"sketch.pattern.rectangular",
|
||||
json!({
|
||||
"entities":["seed"],"direction":"guide1","direction2":"guide2",
|
||||
"count":3,"spacing":20,"count2":3,"spacing2":10,"alignment":"center","alignment2":"center"
|
||||
}),
|
||||
);
|
||||
let expected = [(11., 13.), (-1., -3.), (25., 5.), (31., 13.), (19., -3.), (-15., 5.), (-9., 13.), (-21., -3.)];
|
||||
for (curve, (x, y)) in out["curves"].as_array().unwrap().iter().zip(expected) {
|
||||
assert!(point(&sketch(&s), &format!("{}.center", curve.as_str().unwrap())).dist(Vec2::new(x, y)) < 1e-6);
|
||||
}
|
||||
assert!(point(&sketch(&s), "seed.center").dist(Vec2::new(5., 5.)) < 1e-6);
|
||||
run(&mut s, "sketch.drag", json!({"entity":"seed.center","from":[5,5],"to":[7,8]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide2"],"fixed":false}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide2.start"]}));
|
||||
run(&mut s, "sketch.drag", json!({"entity":"guide2.end","from":[6,-22],"to":[8,-24]}));
|
||||
check_relations(&s);
|
||||
let far = out["curves"][7].as_str().unwrap();
|
||||
assert!(point(&sketch(&s), &format!("{far}.center")).dist(Vec2::new(-21., 2.)) < 1e-6);
|
||||
let saved = s.doc.to_json();
|
||||
let mut s = Session { doc: solidcraft_doc::format::read(&saved).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
|
||||
s.refresh();
|
||||
let before = s.doc.to_json();
|
||||
let edited = run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"alignment":"left","alignment2":"right","count":4}));
|
||||
assert_eq!(edited["curves"][7], far);
|
||||
assert!(point(&sketch(&s), &format!("{far}.center")).dist(Vec2::new(-17., 20.)) < 1e-6);
|
||||
check_relations(&s);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_alignments_are_independent_and_invalid_alignment_values_roll_back() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":2,"id":"seed"}));
|
||||
let out = run(
|
||||
&mut s,
|
||||
"sketch.pattern.rectangular",
|
||||
json!({
|
||||
"entities":["seed"],"count":3,"spacing":20,"count2":2,"spacing2":10,"alignment":"left","alignment2":"right"
|
||||
}),
|
||||
);
|
||||
let far = out["curves"][4].as_str().unwrap();
|
||||
assert!(point(&sketch(&s), &format!("{far}.center")).dist(Vec2::new(-35., 15.)) < 1e-6);
|
||||
run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"alignment":"right","alignment2":"left"}));
|
||||
assert!(point(&sketch(&s), &format!("{far}.center")).dist(Vec2::new(45., -5.)) < 1e-6);
|
||||
let before = s.doc.to_json();
|
||||
for extra in [json!({"reversed":"true"}), json!({"reversed2":null}), json!({"alignment":"unknown"}), json!({"alignment2":null})] {
|
||||
let mut params = json!({"pattern":out["pattern"]});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
assert!(s.execute("sketch.pattern.rectangular", ¶ms).is_err(), "{extra}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
let mut broken: Value = serde_json::from_str(&before).unwrap();
|
||||
broken["features"][0]["sketch"]["patterns"][0]["kind"]["reversed2"] = json!("true");
|
||||
assert!(solidcraft_doc::format::read(&broken.to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn even_centered_totals_flip_the_extra_side_independently_and_remain_associative() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":2,"id":"seed"}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,-10],[10,-10]],"ids":["guide1"]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,-30],[6,-22]],"ids":["guide2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide1","guide2"]}));
|
||||
let out = run(
|
||||
&mut s,
|
||||
"sketch.pattern.rectangular",
|
||||
json!({
|
||||
"entities":["seed"],"direction":"guide1","direction2":"guide2",
|
||||
"count":4,"spacing":20,"count2":2,"spacing2":10,"alignment":"center","alignment2":"center"
|
||||
}),
|
||||
);
|
||||
let copy_ids = ids(&out["curves"]);
|
||||
let expected = [(11., 13.), (25., 5.), (31., 13.), (-15., 5.), (-9., 13.), (45., 5.), (51., 13.)];
|
||||
for (id, (x, y)) in copy_ids.iter().zip(expected) {
|
||||
assert!(point(&sketch(&s), &format!("{id}.center")).dist(Vec2::new(x, y)) < 1e-6);
|
||||
}
|
||||
let before = s.doc.to_json();
|
||||
let flipped = run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"reversed":true}));
|
||||
assert_eq!(ids(&flipped["curves"]), copy_ids);
|
||||
assert!(point(&sketch(&s), &format!("{}.center", copy_ids[6])).dist(Vec2::new(-29., 13.)) < 1e-6);
|
||||
run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"reversed2":true}));
|
||||
assert!(point(&sketch(&s), &format!("{}.center", copy_ids[6])).dist(Vec2::new(-41., -3.)) < 1e-6);
|
||||
assert!(point(&sketch(&s), "seed.center").dist(Vec2::new(5., 5.)) < 1e-6);
|
||||
check_relations(&s);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let mut s = Session { doc: solidcraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
|
||||
s.refresh();
|
||||
run(&mut s, "sketch.drag", json!({"entity":"seed.center","from":[5,5],"to":[7,8]}));
|
||||
assert!(point(&sketch(&s), &format!("{}.center", copy_ids[6])).dist(Vec2::new(-39., 0.)) < 1e-6);
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide2"],"fixed":false}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["guide2.start"]}));
|
||||
run(&mut s, "sketch.drag", json!({"entity":"guide2.end","from":[6,-22],"to":[8,-24]}));
|
||||
assert!(point(&sketch(&s), &format!("{}.center", copy_ids[6])).dist(Vec2::new(-41., 2.)) < 1e-6);
|
||||
let edited = run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"spacing":10}));
|
||||
assert_eq!(ids(&edited["curves"]), copy_ids);
|
||||
assert!(point(&sketch(&s), &format!("{}.center", copy_ids[6])).dist(Vec2::new(-21., 2.)) < 1e-6);
|
||||
check_relations(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_picked_pattern_lines_remain_live_through_drag_reopen_and_edit() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,5],[15,5]],"ids":["seed"]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,-20],[10,-20]],"ids":["dir1"]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,-40],[6,-32]],"ids":["dir2"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed","dir1.start","dir2.start"]}));
|
||||
let out = run(
|
||||
&mut s,
|
||||
"sketch.pattern.rectangular",
|
||||
json!({"entities":["seed"],"direction":"dir1","direction2":"dir2","count":3,"spacing":20,"count2":2,"spacing2":10}),
|
||||
);
|
||||
let copy = out["curves"][0].as_str().unwrap();
|
||||
let far = out["curves"][4].as_str().unwrap();
|
||||
assert!(point(&sketch(&s), &format!("{copy}.start")).dist(Vec2::new(16.0, 13.0)) < 1e-6);
|
||||
assert!(point(&sketch(&s), &format!("{far}.start")).dist(Vec2::new(56.0, 13.0)) < 1e-6);
|
||||
run(&mut s, "sketch.drag", json!({"entity":"dir2.end","from":[6,-32],"to":[8,-34]}));
|
||||
check_relations(&s);
|
||||
assert!(point(&sketch(&s), &format!("{copy}.start")).dist(Vec2::new(18.0, 11.0)) < 1e-6);
|
||||
let saved = s.doc.to_json();
|
||||
let mut s = Session { doc: solidcraft_doc::format::read(&saved).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
|
||||
s.refresh();
|
||||
let edited = run(&mut s, "sketch.pattern.rectangular", json!({"pattern":out["pattern"],"count":4,"spacing2":-10}));
|
||||
assert_eq!(edited["curves"][0], copy);
|
||||
assert!(point(&sketch(&s), &format!("{copy}.start")).dist(Vec2::new(2.0, -1.0)) < 1e-6);
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["dir2"]}));
|
||||
run(&mut s, "sketch.drag", json!({"entity":"dir1.end","from":[10,-20],"to":[0,-10]}));
|
||||
check_relations(&s);
|
||||
assert!(point(&sketch(&s), &format!("{far}.start")).dist(Vec2::new(2.0, 39.0)) < 1e-6);
|
||||
let before = s.doc.to_json();
|
||||
run(&mut s, "sketch.delete", json!({"entities":["dir2"]}));
|
||||
assert!(sketch(&s).patterns.is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_and_both_patterns_keep_selected_line_chains_driven_by_the_source() {
|
||||
for mode in [0, 1, 2] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,0],[15,0],[15,5]],"ids":["l1","l2"]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,-20],[0,20]],"ids":["axis"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["axis"]}));
|
||||
let (cmd, params) = match mode {
|
||||
0 => ("sketch.mirror", json!({"entities":["l1","l2"],"line":"axis"})),
|
||||
1 => (
|
||||
"sketch.pattern.rectangular",
|
||||
json!({"entities":["l1","l2"],"dir":[1,0],"dir2":[0.6,0.8],"count":2,"spacing":30,"count2":2,"spacing2":10}),
|
||||
),
|
||||
_ => ("sketch.pattern.circular", json!({"entities":["l1","l2"],"center":"origin","count":4})),
|
||||
};
|
||||
let out = run(&mut s, cmd, params);
|
||||
run(&mut s, "sketch.drag", json!({"entity":"l1.end","from":[15,0],"to":[18,2]}));
|
||||
check_relations(&s);
|
||||
let sk = sketch(&s);
|
||||
let original = point(&sk, "l1.end");
|
||||
assert!(original.dist(Vec2::new(18.0, 2.0)) < 1e-6);
|
||||
let expected = match mode {
|
||||
0 => Vec2::new(-original.x, original.y),
|
||||
1 => original + Vec2::new(6.0, 8.0),
|
||||
_ => Vec2::new(-original.y, original.x),
|
||||
};
|
||||
let a = out["curves"][0].as_str().unwrap();
|
||||
let b = out["curves"][1].as_str().unwrap();
|
||||
// Reflection reverses each curve's traversal; the shared corner is still one point.
|
||||
let (end, start) = if mode == 0 { ("start", "end") } else { ("end", "start") };
|
||||
assert!(point(&sk, &format!("{a}.{end}")).dist(expected) < 1e-6, "{cmd}");
|
||||
assert_eq!(sk.resolve_point(&format!("{a}.{end}")), sk.resolve_point(&format!("{b}.{start}")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_two_direction_patterns_roll_back_and_saved_references_are_validated() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["seed"]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":2,"id":"circle"}));
|
||||
let before = s.doc.to_json();
|
||||
for extra in [
|
||||
json!({"dir2":[0,0]}),
|
||||
json!({"dir2":[1e308,1e308]}),
|
||||
json!({"direction2":"missing"}),
|
||||
json!({"direction":"circle"}),
|
||||
json!({"direction2":42}),
|
||||
] {
|
||||
let mut params = json!({"entities":["seed"],"count":2,"spacing":10,"count2":2});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
assert!(s.execute("sketch.pattern.rectangular", ¶ms).is_err(), "{extra}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
run(&mut s, "sketch.pattern.rectangular", json!({"entities":["seed"],"direction":"seed","count":2,"spacing":10}));
|
||||
let saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
for invalid in [json!([[usize::MAX, [10, 0]], null]), json!([[0, [1e308, 0]], null])] {
|
||||
let mut broken = saved.clone();
|
||||
let constraint =
|
||||
broken["features"][0]["sketch"]["constraints"].as_array_mut().unwrap().iter_mut().find(|c| c["type"] == "pattern_point").unwrap();
|
||||
constraint["directions"] = invalid;
|
||||
assert!(solidcraft_doc::format::read(&broken.to_string()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirrored_chain_endpoints_on_the_axis_remain_on_it_during_source_edits() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,5],[10,5],[10,15],[0,15]],"ids":["a","b","c"]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,-10],[0,30]],"ids":["axis"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["axis"]}));
|
||||
let out = run(&mut s, "sketch.mirror", json!({"entities":["a","b","c"],"line":"axis"}));
|
||||
let copy = out["curves"][0].as_str().unwrap();
|
||||
assert_eq!(sketch(&s).resolve_point("a.start"), sketch(&s).resolve_point(&format!("{copy}.end")));
|
||||
run(&mut s, "sketch.drag", json!({"entity":"a.start","from":[0,5],"to":[4,8]}));
|
||||
assert!(point(&sketch(&s), "a.start").x.abs() < 1e-6);
|
||||
run(&mut s, "sketch.drag", json!({"entity":"b.start","from":[10,5],"to":[13,8]}));
|
||||
let original = point(&sketch(&s), "b.start");
|
||||
assert!(point(&sketch(&s), &format!("{copy}.start")).dist(Vec2::new(-original.x, original.y)) < 1e-6);
|
||||
check_relations(&s);
|
||||
}
|
||||
|
||||
fn check_relations(s: &Session) {
|
||||
let sk = sketch(s);
|
||||
for (i, constraint) in sk.constraints.iter().enumerate() {
|
||||
@@ -11,6 +334,38 @@ fn check_relations(s: &Session) {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_second_direction_follows_the_first_line_and_legacy_vectors_keep_their_grid() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":2,"id":"seed"}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[0,-10],[10,-10]],"ids":["guide"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["seed","guide.start"]}));
|
||||
let out =
|
||||
run(&mut s, "sketch.pattern.rectangular", json!({"entities":["seed"],"direction":"guide","count":2,"spacing":10,"count2":2,"spacing2":20}));
|
||||
run(&mut s, "sketch.drag", json!({"entity":"guide.end","from":[10,-10],"to":[0,0]}));
|
||||
let far = out["curves"][2].as_str().unwrap();
|
||||
assert!(point(&sketch(&s), &format!("{far}.center")).dist(Vec2::new(-15.0, 15.0)) < 1e-6);
|
||||
check_relations(&s);
|
||||
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[5,5],"radius":2,"id":"seed"}));
|
||||
run(&mut s, "sketch.pattern.rectangular", json!({"entities":["seed"],"dir":[0,1],"count":2,"spacing":10,"count2":2,"spacing2":20}));
|
||||
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
saved["format"] = json!("solidcraft/21");
|
||||
let sk = &mut saved["features"][0]["sketch"];
|
||||
for c in sk["constraints"].as_array_mut().unwrap() {
|
||||
c.as_object_mut().unwrap().remove("directions");
|
||||
}
|
||||
let settings = sk["patterns"][0]["kind"].as_object_mut().unwrap();
|
||||
for key in ["dir2", "direction", "direction2", "alignment", "alignment2", "reversed", "reversed2"] {
|
||||
settings.remove(key);
|
||||
}
|
||||
let doc = solidcraft_doc::format::read(&saved.to_string()).unwrap();
|
||||
let sk = doc.sketch(s.active_sketch.unwrap()).unwrap();
|
||||
assert!(point(sk, "c3.center").dist(Vec2::new(-15.0, 15.0)) < 1e-6);
|
||||
assert_eq!(doc.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_pattern_keeps_seed_copy_and_center_related_during_drags() {
|
||||
let mut s = new_sketch();
|
||||
|
||||
Reference in new issue
Block a user