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:
Karl Von Richter authored and GitHub committed 2026-10-14 14:09:37 -04:00
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),
+11 -49
View File
@@ -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,
+190
View File
@@ -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, &params, 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(&params, "count", cmd)?;
let (kind, center, slots) = if circular {
let total = if params.get("angle").is_some() { num(&params, "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(&params, "spacing").ok_or_else(|| bad(cmd, "`spacing` must be finite"))?;
let count2 = if params.get("count2").is_some() { count(&params, "count2", cmd)? } else { 1 };
let spacing2 = if params.get("spacing2").is_some() {
num(&params, "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", &params).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());
}
}