Sketch: associative, editable sketch patterns (#1103)
Rectangular and circular sketch patterns stay linked to their source: editing the pattern's count, spacing or the source geometry updates the copies, and the copies keep their point and radius relations. Patterns are saved under format 20; older designs keep their independent copies.
This commit is contained in:
1 parent
4cfa9c5d34
commit
9a4e8fbd19
20 files changed
+888
-55
No files matched your search
@@ -431,6 +431,8 @@ fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option<Vec2> {
|
||||
let pt = |p: usize| sk.point(p);
|
||||
match *k {
|
||||
OffsetDistance { ref output, .. } => sk.offset_dimension_points(output).map(|(a, b)| (a + b) * 0.5),
|
||||
PatternPoint { q, center, .. } => pt(center.unwrap_or(q)),
|
||||
PatternRadius { b, .. } => mid(b),
|
||||
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } => pt(p),
|
||||
PointOnCurve { p, .. } => pt(p),
|
||||
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
|
||||
|
||||
@@ -13,6 +13,8 @@ use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec2_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[path = "sketch_pattern.rs"]
|
||||
mod pattern;
|
||||
#[path = "sketch_support.rs"]
|
||||
mod support;
|
||||
|
||||
@@ -86,12 +88,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "CREATE")
|
||||
.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)", created_doc!())),
|
||||
.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!())),
|
||||
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("sketch_rect_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], dir?: [x,y] (default x), count, spacing; count2?, spacing2? (perpendicular direction)", created_doc!())),
|
||||
.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!())),
|
||||
];
|
||||
|
||||
const TOL: f64 = 1e-7;
|
||||
@@ -1495,6 +1497,9 @@ fn copy_through(
|
||||
keep: &dyn Fn(Vec2) -> bool,
|
||||
scale_r: f64,
|
||||
) -> Result<(Vec<usize>, Vec<(usize, usize)>)> {
|
||||
if sk.curves.len().saturating_add(cs.len()) > solidcraft_sketch::MAX_CURVES {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
let pts = involved_points(sk, cs, ps);
|
||||
let mut map: Vec<(usize, usize)> = Vec::new();
|
||||
for q in &pts {
|
||||
@@ -1575,7 +1580,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn count(p: &Value, k: &str, cmd: &str) -> Result<usize> {
|
||||
let n = num(p, k).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
|
||||
if !(1.0..=MAX_COPIES as f64).contains(&n) {
|
||||
if !(1.0..=MAX_COPIES as f64).contains(&n) || n.fract() != 0.0 {
|
||||
return Err(bad(cmd, format!("`{k}` must be 1…{MAX_COPIES}")));
|
||||
}
|
||||
Ok(n as usize)
|
||||
@@ -1598,55 +1603,11 @@ fn rotate(c: Vec2, ang: f64) -> impl Fn(Vec2) -> Vec2 {
|
||||
}
|
||||
|
||||
fn pattern_circular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.pattern.circular";
|
||||
let n = count(p, "count", cmd)?;
|
||||
let total = num(p, "angle").unwrap_or(360.0);
|
||||
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
|
||||
return Err(bad(cmd, "`angle` must be in (0, 360]"));
|
||||
}
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (cs, ps) = entities(sk, p, cmd)?;
|
||||
let c = point_or_ref(sk, p, "center", cmd)?;
|
||||
let full = (total.abs() - 360.0).abs() < 1e-9;
|
||||
let step = if full { total / n as f64 } else { total / (n.max(2) - 1) as f64 };
|
||||
let mut made = Vec::new();
|
||||
for k in 1..n {
|
||||
let f = rotate(c, (step * k as f64).to_radians());
|
||||
let (m, _) = copy_through(sk, &cs, &ps, &f, false, &|_| false, 1.0)?;
|
||||
made.extend(m);
|
||||
}
|
||||
Ok(ids_of(sk, &made))
|
||||
})?;
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
pattern::run(s, p, true)
|
||||
}
|
||||
|
||||
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.pattern.rectangular";
|
||||
let n1 = count(p, "count", cmd)?;
|
||||
let s1 = num(p, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be a number"))?;
|
||||
let n2 = if p.get("count2").is_some() { count(p, "count2", cmd)? } else { 1 };
|
||||
let s2 = num(p, "spacing2").unwrap_or(s1);
|
||||
if n1 * n2 > MAX_COPIES {
|
||||
return Err(bad(cmd, "too many copies"));
|
||||
}
|
||||
let d1 = p.get("dir").and_then(vec2).and_then(Vec2::normalized).unwrap_or(Vec2::X);
|
||||
let d2 = d1.perp();
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (cs, ps) = entities(sk, p, cmd)?;
|
||||
let mut made = Vec::new();
|
||||
for i in 0..n1 {
|
||||
for j in 0..n2 {
|
||||
if i == 0 && j == 0 {
|
||||
continue;
|
||||
}
|
||||
let off = d1 * (s1 * i as f64) + d2 * (s2 * j as f64);
|
||||
let (m, _) = copy_through(sk, &cs, &ps, &move |q| q + off, false, &|_| false, 1.0)?;
|
||||
made.extend(m);
|
||||
}
|
||||
}
|
||||
Ok(ids_of(sk, &made))
|
||||
})?;
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
pattern::run(s, p, false)
|
||||
}
|
||||
|
||||
/// Move the points of the entities (in place) through `f`; circle radii scale by `k`.
|
||||
@@ -1665,6 +1626,7 @@ fn transform_in_place(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Ve
|
||||
*r *= k;
|
||||
}
|
||||
}
|
||||
sk.follow_pattern_seeds();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ use solidcraft_sketch::{ConstraintKind, CurveKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[path = "sketch_pattern_tests.rs"]
|
||||
mod pattern_tests;
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Associative sketch patterns. Editing retains ids at surviving instance indices.
|
||||
|
||||
use solidcraft_sketch::{PatternInstance, SketchPattern, SketchPatternKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
let cmd = if circular { "sketch.pattern.circular" } else { "sketch.pattern.rectangular" };
|
||||
let (out, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
if sk.patterns.len() >= solidcraft_sketch::MAX_CONSTRAINTS {
|
||||
return Err(bad(cmd, "too many sketch patterns"));
|
||||
}
|
||||
let old_index = str_(p, "pattern").and_then(|id| sk.patterns.iter().position(|x| x.id == id));
|
||||
let old = if let Some(id) = str_(p, "pattern") {
|
||||
let i = sk.patterns.iter().position(|x| x.id == id).ok_or_else(|| bad(cmd, "unknown sketch pattern"))?;
|
||||
Some(sk.patterns.remove(i))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut params = p.clone();
|
||||
if let Some(old) = &old {
|
||||
if circular != matches!(old.kind, SketchPatternKind::Circular { .. }) {
|
||||
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})
|
||||
}
|
||||
};
|
||||
if let (Some(params), Some(defaults)) = (params.as_object_mut(), defaults.as_object()) {
|
||||
for (key, value) in defaults {
|
||||
params.entry(key.clone()).or_insert_with(|| value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let (mut cs, mut ps) = entities(sk, ¶ms, cmd)?;
|
||||
cs.sort_unstable();
|
||||
cs.dedup();
|
||||
ps.sort_unstable();
|
||||
ps.dedup();
|
||||
let entity_ids: Vec<String> = cs
|
||||
.iter()
|
||||
.filter_map(|i| sk.curves.get(*i).map(|c| c.id.clone()))
|
||||
.chain(ps.iter().filter_map(|i| sk.points.get(*i).map(|p| p.id.clone())))
|
||||
.collect();
|
||||
if let Some(old) = &old {
|
||||
if entity_ids != old.entities {
|
||||
return Err(bad(cmd, "the seed entities of an existing pattern cannot be changed"));
|
||||
}
|
||||
if old.instances.len() >= MAX_COPIES {
|
||||
return Err(bad(cmd, "too many stored pattern instances"));
|
||||
}
|
||||
if old.instances.iter().any(|instance| {
|
||||
instance.curves.iter().any(|id| sk.curve_index(id).is_none())
|
||||
|| instance.points.iter().any(|(p, q)| sk.resolve_point(p).is_none() || sk.resolve_point(q).is_none())
|
||||
|| instance.constraints.iter().any(|id| !sk.constraints.iter().any(|c| c.id == *id))
|
||||
}) {
|
||||
return Err(bad(cmd, "the pattern has deleted geometry or relations; undo the deletion before editing it"));
|
||||
}
|
||||
}
|
||||
let n = count(¶ms, "count", cmd)?;
|
||||
let (kind, center, slots) = if circular {
|
||||
let total = if params.get("angle").is_some() { num(¶ms, "angle").ok_or_else(|| bad(cmd, "`angle` must be finite"))? } else { 360.0 };
|
||||
if !(total.abs() > 1e-9 && total.abs() <= 360.0) {
|
||||
return Err(bad(cmd, "`angle` must be nonzero and at most 360 degrees"));
|
||||
}
|
||||
let center = match params.get("center") {
|
||||
Some(Value::String(id)) => sk.resolve_point(id).ok_or_else(|| bad(cmd, "unknown pattern center point"))?,
|
||||
Some(value) => {
|
||||
let pos = vec2(value).ok_or_else(|| bad(cmd, "`center` must be [x,y] or a point ref"))?;
|
||||
// A centre placed in empty space is a real point that can subsequently
|
||||
// be made coincident with a circle centre or dimensioned.
|
||||
sk.add_point(pos, None)?
|
||||
}
|
||||
None => return Err(bad(cmd, "missing `center`")),
|
||||
};
|
||||
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 };
|
||||
(
|
||||
SketchPatternKind::Circular { center: id, count: n, angle: total },
|
||||
Some(center),
|
||||
(1..n).map(|i| ([i, 0], (step * i as f64).to_radians(), Vec2::ZERO)).collect::<Vec<_>>(),
|
||||
)
|
||||
} else {
|
||||
let spacing = num(¶ms, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be finite"))?;
|
||||
let count2 = if params.get("count2").is_some() { count(¶ms, "count2", cmd)? } else { 1 };
|
||||
let spacing2 = if params.get("spacing2").is_some() {
|
||||
num(¶ms, "spacing2").ok_or_else(|| bad(cmd, "`spacing2` must be finite"))?
|
||||
} else {
|
||||
spacing
|
||||
};
|
||||
if n * count2 > MAX_COPIES {
|
||||
return Err(bad(cmd, "too many copies"));
|
||||
}
|
||||
let dir = match params.get("dir") {
|
||||
Some(value) => vec2(value).and_then(Vec2::normalized).ok_or_else(|| bad(cmd, "`dir` must be a finite nonzero vector"))?,
|
||||
None => Vec2::X,
|
||||
};
|
||||
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)))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(SketchPatternKind::Rectangular { dir, count: n, spacing, count2, spacing2 }, None, slots)
|
||||
};
|
||||
let id = match &old {
|
||||
Some(old) => old.id.clone(),
|
||||
None => sk.fresh("pat")?,
|
||||
};
|
||||
let center_id = center.and_then(|i| sk.points.get(i)).map(|p| p.id.clone());
|
||||
let mut existing = old.map(|old| old.instances).unwrap_or_default();
|
||||
// Remove retired instances first: all following indices are resolved by stable id.
|
||||
for instance in existing.iter().filter(|instance| !slots.iter().any(|(index, _, _)| *index == instance.index)) {
|
||||
let curves: Vec<_> = instance.curves.iter().filter_map(|id| sk.curve_index(id)).collect();
|
||||
sk.constraints.retain(|c| !instance.constraints.contains(&c.id));
|
||||
sk.remove_curves(&curves);
|
||||
let points: Vec<_> = instance.points.iter().filter_map(|(_, id)| sk.resolve_point(id)).collect();
|
||||
sk.remove_points(&points);
|
||||
}
|
||||
let mut instances = Vec::new();
|
||||
let center = center_id.as_ref().and_then(|id| sk.resolve_point(id));
|
||||
if center_id.is_some() && center.is_none() {
|
||||
return Err(bad(cmd, "the center belongs to an instance removed by this edit"));
|
||||
}
|
||||
let mut curves = Vec::new();
|
||||
let mut constraints = Vec::new();
|
||||
for (index, angle, offset) in slots {
|
||||
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
|
||||
{
|
||||
*slot = center;
|
||||
*a = angle;
|
||||
*off = offset;
|
||||
}
|
||||
}
|
||||
for (seed, copy) in &instance.points {
|
||||
let pos = sk.resolve_point(seed).and_then(|i| sk.point(i)).ok_or_else(|| bad(cmd, "missing seed point"))?;
|
||||
if let Some(point) = sk.resolve_point(copy).and_then(|i| sk.points.get_mut(i)) {
|
||||
point.pos = transform(pos);
|
||||
}
|
||||
}
|
||||
instance
|
||||
} else {
|
||||
let cs: Vec<_> = entity_ids.iter().filter_map(|id| sk.curve_index(id)).collect();
|
||||
let ps: Vec<_> = entity_ids.iter().filter(|id| sk.curve_index(id).is_none()).filter_map(|id| sk.resolve_point(id)).collect();
|
||||
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 })?);
|
||||
}
|
||||
for (a, b) in cs.iter().zip(&made) {
|
||||
if matches!(sk.curves.get(*a).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. })) {
|
||||
cons.push(add_c(sk, ConstraintKind::PatternRadius { a: *a, b: *b })?);
|
||||
}
|
||||
}
|
||||
PatternInstance {
|
||||
index,
|
||||
curves: ids_of(sk, &made),
|
||||
points: map.iter().filter_map(|(p, q)| Some((sk.points.get(*p)?.id.clone(), sk.points.get(*q)?.id.clone()))).collect(),
|
||||
constraints: cons,
|
||||
}
|
||||
};
|
||||
curves.extend(instance.curves.iter().cloned());
|
||||
constraints.extend(instance.constraints.iter().cloned());
|
||||
instances.push(instance);
|
||||
}
|
||||
let center_id = center.and_then(|i| sk.points.get(i)).map(|p| p.id.clone());
|
||||
let pattern = SketchPattern { id: id.clone(), entities: entity_ids, kind, instances };
|
||||
if let Some(i) = old_index {
|
||||
sk.patterns.insert(i.min(sk.patterns.len()), pattern);
|
||||
} else {
|
||||
sk.patterns.push(pattern);
|
||||
}
|
||||
Ok(json!({"pattern":id,"center":center_id,"curves":curves,"constraints":constraints}))
|
||||
})?;
|
||||
let mut out = out;
|
||||
if let Some(out) = out.as_object_mut() {
|
||||
out.insert("sketch".into(), info);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use super::*;
|
||||
|
||||
fn point(sk: &solidcraft_sketch::Sketch, id: &str) -> Vec2 {
|
||||
sk.point(sk.resolve_point(id).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn check_relations(s: &Session) {
|
||||
let sk = sketch(s);
|
||||
for (i, constraint) in sk.constraints.iter().enumerate() {
|
||||
assert!(solidcraft_sketch::constraint_residual(&sk, i).is_some_and(|r| r < 1e-6), "{} {:?}", constraint.id, constraint.kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_pattern_keeps_seed_copy_and_center_related_during_drags() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]}));
|
||||
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["l1"],"center":"origin","count":4}));
|
||||
assert_eq!(out["sketch"]["dof"], 4);
|
||||
assert_eq!(ids(&out["created"]["constraints"]).len(), 6);
|
||||
run(&mut s, "sketch.drag", json!({"entity":"l2.start","from":[0,10],"to":[5,13]}));
|
||||
check_relations(&s);
|
||||
let sk = sketch(&s);
|
||||
assert!(point(&sk, "l1.start").dist(Vec2::new(13.0, -5.0)) < 1e-6);
|
||||
assert!(point(&sk, "l3.start").dist(Vec2::new(-13.0, 5.0)) < 1e-6);
|
||||
run(&mut s, "sketch.drag", json!({"entity":"l1.end","from":[20,0],"to":[24,3]}));
|
||||
check_relations(&s);
|
||||
assert!(point(&sketch(&s), "l2.end").dist(Vec2::new(-3.0, 24.0)) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_seed_prevents_independent_copy_motion() {
|
||||
for (cmd, params) in [
|
||||
("sketch.pattern.circular", json!({"entities":["l1"],"center":"origin","count":4})),
|
||||
("sketch.pattern.rectangular", json!({"entities":["l1"],"count":4,"spacing":10})),
|
||||
] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities":["l1"]}));
|
||||
let out = run(&mut s, cmd, params);
|
||||
assert_eq!(out["sketch"]["dof"], 0);
|
||||
let before = point(&sketch(&s), "l2.start");
|
||||
run(&mut s, "sketch.drag", json!({"entity":"l2.start","from":before,"to":[7,17]}));
|
||||
assert!(point(&sketch(&s), "l2.start").dist(before) < 1e-6);
|
||||
check_relations(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_center_reference_follows_a_moved_circle_and_empty_space_center_is_constrainable() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[3,4],"radius":2,"id":"c1"}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[13,4],[23,4]],"ids":["l1"]}));
|
||||
run(&mut s, "sketch.pattern.circular", json!({"entities":["l1"],"center":"c1.center","count":3,"angle":-180}));
|
||||
run(&mut s, "sketch.drag", json!({"entity":"c1.center","from":[3,4],"to":[8,9]}));
|
||||
check_relations(&s);
|
||||
let center = point(&sketch(&s), "c1.center");
|
||||
assert!(center.dist(Vec2::new(8.0, 9.0)) < 1e-6);
|
||||
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["l1"],"center":[8,9],"count":2}));
|
||||
let center_id = out["center"].as_str().unwrap();
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a":center_id,"b":"c1.center"}));
|
||||
check_relations(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangular_pattern_preserves_signed_two_axis_offsets_and_circle_radii() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.circle.center", json!({"center":[10,5],"radius":2,"id":"c1"}));
|
||||
let out = run(&mut s, "sketch.pattern.rectangular", json!({"entities":["c1"],"dir":[0,1],"count":3,"spacing":-8,"count2":2,"spacing2":6}));
|
||||
assert_eq!(out["sketch"]["dof"], 3);
|
||||
run(&mut s, "sketch.drag", json!({"entity":"c1.center","from":[10,5],"to":[12,7]}));
|
||||
run(&mut s, "sketch.drag", json!({"entity":"c1","from":[14,7],"to":[16,7]}));
|
||||
check_relations(&s);
|
||||
let sk = sketch(&s);
|
||||
for id in ids(&out["curves"]) {
|
||||
assert!(close(sk.radius(sk.curve_index(&id).unwrap()).unwrap(), 4.0));
|
||||
}
|
||||
assert!(point(&sk, "c2.center").dist(Vec2::new(6.0, 7.0)) < 1e-6);
|
||||
assert!(point(&sk, "c6.center").dist(Vec2::new(6.0, -9.0)) < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_edits_retain_surviving_ids_and_work_after_undo_and_reopen() {
|
||||
for circular in [false, true] {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]}));
|
||||
let cmd = if circular { "sketch.pattern.circular" } else { "sketch.pattern.rectangular" };
|
||||
let params =
|
||||
if circular { json!({"entities":["l1"],"center":"origin","count":4}) } else { json!({"entities":["l1"],"count":4,"spacing":10}) };
|
||||
let original = run(&mut s, cmd, params);
|
||||
let pattern = original["pattern"].as_str().unwrap();
|
||||
let before = s.doc.to_json();
|
||||
let shrunk = run(&mut s, cmd, json!({"pattern":pattern,"count":2}));
|
||||
assert_eq!(ids(&shrunk["curves"]), vec!["l2"]);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
let mut reopened =
|
||||
Session { doc: solidcraft_doc::format::read(&s.doc.to_json()).unwrap().into(), active_sketch: s.active_sketch, ..Default::default() };
|
||||
reopened.refresh();
|
||||
let grown = run(&mut reopened, cmd, json!({"pattern":pattern,"count":5,"spacing":12,"angle":-180}));
|
||||
assert_eq!(ids(&grown["curves"])[0], "l2");
|
||||
assert_eq!(ids(&grown["curves"]).len(), 4);
|
||||
assert!(!ids(&grown["curves"]).contains(&"l3".to_string()));
|
||||
check_relations(&reopened);
|
||||
let sk = sketch(&reopened);
|
||||
let expected = if circular { Vec2::new(10.0 / 2.0_f64.sqrt(), -10.0 / 2.0_f64.sqrt()) } else { Vec2::new(22.0, 0.0) };
|
||||
assert!(point(&sk, "l2.start").dist(expected) < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_pattern_edits_roll_back_and_deleted_seed_does_not_panic() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]}));
|
||||
let out = run(&mut s, "sketch.pattern.rectangular", json!({"entities":["l1","l1"],"count":3,"spacing":10}));
|
||||
assert_eq!(ids(&out["curves"]).len(), 2);
|
||||
let before = s.doc.to_json();
|
||||
for params in [json!({"count":2.5}), json!({"count":501}), json!({"count2":500}), json!({"dir":[0,0]}), json!({"spacing":1e308})] {
|
||||
let mut params = params;
|
||||
params["pattern"] = out["pattern"].clone();
|
||||
assert!(s.execute("sketch.pattern.rectangular", ¶ms).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
run(&mut s, "sketch.delete", json!({"entities":["l1"]}));
|
||||
assert!(s.execute("sketch.pattern.rectangular", &json!({"pattern":out["pattern"],"count":4})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_seed_move_and_scale_preserve_shape_and_shared_points() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0],[20,10]],"ids":["l1","l2"]}));
|
||||
let out = run(&mut s, "sketch.pattern.circular", json!({"entities":["l1","l2"],"center":"origin","count":3}));
|
||||
let sk = sketch(&s);
|
||||
assert_eq!(sk.resolve_point("l3.end"), sk.resolve_point("l4.start"));
|
||||
assert_eq!(ids(&out["created"]["constraints"]).len(), 6);
|
||||
run(&mut s, "sketch.move", json!({"entities":["l1","l2"],"translate":[5,3]}));
|
||||
assert!(point(&sketch(&s), "l1.start").dist(Vec2::new(15.0, 3.0)) < 1e-6);
|
||||
check_relations(&s);
|
||||
run(&mut s, "sketch.scale", json!({"entities":["l1","l2"],"base":"origin","factor":2}));
|
||||
assert!(point(&sketch(&s), "l1.start").dist(Vec2::new(30.0, 6.0)) < 1e-6);
|
||||
check_relations(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_saved_pattern_settings_and_relations_are_refused() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]}));
|
||||
run(&mut s, "sketch.pattern.circular", json!({"entities":["l1"],"center":"origin","count":3}));
|
||||
let saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
for key in ["count", "index", "angle", "relation", "center"] {
|
||||
let mut value = saved.clone();
|
||||
let sk = &mut value["features"][0]["sketch"];
|
||||
match key {
|
||||
"count" => sk["patterns"][0]["kind"]["count"] = json!(usize::MAX),
|
||||
"index" => sk["patterns"][0]["instances"][0]["index"] = json!([usize::MAX, 0]),
|
||||
"angle" => sk["patterns"][0]["kind"]["angle"] = json!(1e308),
|
||||
"center" => sk["patterns"][0]["kind"]["center"] = json!("missing"),
|
||||
_ => sk["constraints"][0]["offset"] = json!([1e308, 0]),
|
||||
}
|
||||
assert!(solidcraft_doc::format::read(&value.to_string()).is_err(), "{key}");
|
||||
}
|
||||
let mut legacy = saved;
|
||||
legacy["format"] = json!("solidcraft/19");
|
||||
let sk = legacy["features"][0]["sketch"].as_object_mut().unwrap();
|
||||
sk.remove("patterns");
|
||||
sk.insert("constraints".into(), json!([]));
|
||||
let read = solidcraft_doc::format::read(&legacy.to_string()).unwrap();
|
||||
assert!(read.sketch(s.active_sketch.unwrap()).unwrap().patterns.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patterned_arc_ellipse_spline_and_conic_follow_seed_geometry_exactly() {
|
||||
for (cmd, params) in [
|
||||
("sketch.arc.center_point", json!({"center":[0,0],"start":[10,0],"end":[0,10],"connect":false})),
|
||||
("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":4})),
|
||||
("sketch.spline.fit_point", json!({"points":[[0,0],[6,8],[14,3],[20,0]],"connect":false})),
|
||||
("sketch.conic", json!({"start":[0,0],"end":[20,0],"apex":[10,20],"rho":0.65,"connect":false})),
|
||||
] {
|
||||
let mut s = new_sketch();
|
||||
let seed = ids(&run(&mut s, cmd, params)["curves"])[0].clone();
|
||||
let out = run(&mut s, "sketch.pattern.rectangular", json!({"entities":[seed],"count":3,"spacing":40}));
|
||||
run(&mut s, "sketch.scale", json!({"entities":[seed],"base":"origin","factor":1.5}));
|
||||
let sk = sketch(&s);
|
||||
if matches!(sk.curves[sk.curve_index(&seed).unwrap()].kind, CurveKind::Conic { .. }) {
|
||||
let from = sk.exact_segs(sk.curve_index(&seed).unwrap()).unwrap()[0].point_at(0.5);
|
||||
run(&mut s, "sketch.drag", json!({"entity":seed,"from":from,"to":[from.x,from.y+2.0]}));
|
||||
}
|
||||
check_relations(&s);
|
||||
let sk = sketch(&s);
|
||||
let ci = sk.curve_index(&seed).unwrap();
|
||||
let original = sk.exact_segs(ci).unwrap_or_else(|| sk.segs(ci));
|
||||
for (i, id) in ids(&out["curves"]).iter().enumerate() {
|
||||
let ci = sk.curve_index(id).unwrap();
|
||||
let copy = sk.exact_segs(ci).unwrap_or_else(|| sk.segs(ci));
|
||||
assert_eq!(copy.len(), original.len());
|
||||
for (a, b) in original.iter().zip(copy) {
|
||||
for t in [0.0, 0.3, 0.7, 1.0] {
|
||||
assert!(b.point_at(t).dist(a.point_at(t) + Vec2::new(40.0 * (i + 1) as f64, 0.0)) < 1e-6, "{cmd}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(solidcraft_doc::format::read(&s.doc.to_json()).is_ok());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user