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

+1 -1
View File
@@ -184,7 +184,7 @@ fn inputs_and_expressions_match_the_golden_record() {
#[test]
fn the_record_covers_every_variant() {
let src = include_str!("document.rs");
let src = include_str!("document.rs").replace("\r\n", "\n");
let body = src.split("pub enum FeatureKind {").nth(1).and_then(|s| s.split("\n}\n").next()).unwrap();
let names: Vec<String> = fixtures().into_iter().map(|(_, v)| v["type"].as_str().unwrap().replace('_', "")).collect();
for line in body.lines().filter(|l| l.starts_with(" ") && !l.starts_with(" ")) {
+29
View File
@@ -147,6 +147,9 @@
//! `pattern` and `body`): a perforation field stored on a sheet, for the flat pattern, the DXF
//! and the measure. It is also first saved in 20; 0.10.0 asks for an update instead of
//! meeting a feature it can't read.
//! Associative, editable sketch patterns (`patterns` in a sketch) and their point/radius
//! relations are also first saved in 20. Old sketch copies stay independent: migration cannot
//! infer which curves formed a pattern.
use serde_json::Value;
@@ -476,6 +479,7 @@ pub fn read(s: &str) -> Result<Document> {
if let crate::FeatureKind::Sketch { sketch, .. } = &feature.kind {
sketch.validate_freeform_offsets().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_wires().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_patterns().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
sketch.validate_periodic_splines().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
}
if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind
@@ -523,9 +527,34 @@ fn validate_sketch_sizes(value: &Value) -> Result<()> {
if len("points") > solidcraft_sketch::MAX_POINTS
|| len("curves") > solidcraft_sketch::MAX_CURVES
|| len("constraints") > solidcraft_sketch::MAX_CONSTRAINTS
|| len("patterns") > solidcraft_sketch::MAX_CONSTRAINTS
{
return Err(invalid());
}
if let Some(patterns) = sketch.get("patterns").and_then(Value::as_array) {
let mut points = 0usize;
let mut curves = 0usize;
let mut constraints = 0usize;
for pattern in patterns {
let Some(entities) = pattern.get("entities").and_then(Value::as_array) else { return Err(invalid()) };
let Some(instances) = pattern.get("instances").and_then(Value::as_array) else { return Err(invalid()) };
if entities.len() > solidcraft_sketch::MAX_CURVES + solidcraft_sketch::MAX_POINTS || instances.len() >= 500 {
return Err(invalid());
}
for instance in instances {
let size = |key| instance.get(key).and_then(Value::as_array).map_or(0, Vec::len);
points = points.saturating_add(size("points"));
curves = curves.saturating_add(size("curves"));
constraints = constraints.saturating_add(size("constraints"));
if points > solidcraft_sketch::MAX_POINTS
|| curves > solidcraft_sketch::MAX_CURVES
|| constraints > solidcraft_sketch::MAX_CONSTRAINTS
{
return Err(invalid());
}
}
}
}
}
Ok(())
}
@@ -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());
}
}
+3 -1
View File
@@ -53,7 +53,9 @@ pub fn agent_scope_with(recovery: Option<PathBuf>, allowed: impl IntoIterator<It
pub fn scratch_dir() -> Option<PathBuf> {
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let mut b = std::fs::DirBuilder::new();
let b = std::fs::DirBuilder::new();
#[cfg(unix)]
let mut b = b;
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
let seed = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
+3 -1
View File
@@ -524,7 +524,9 @@ fn tool_calls_stay_inside_the_agents_folders() {
tool(&mut s, "export", json!({"path": at(&root, "part.step")}));
tool(&mut s, "open", json!({"path": at(&root, "part.solidcraft")}));
tool(&mut s, "screenshot", json!({"path": at(&root, "shot.png"), "width": 64, "height": 64}));
let mut escapes = vec![at(&out, "x.step"), at(&root, "../out/x.step")];
let escapes = vec![at(&out, "x.step"), at(&root, "../out/x.step")];
#[cfg(unix)]
let mut escapes = escapes;
#[cfg(unix)]
{
std::os::unix::fs::symlink(&out, root.join("link")).unwrap();
+2
View File
@@ -28,6 +28,7 @@ mod loci;
mod model;
mod offset;
mod offset_topology;
mod pattern;
mod periodic;
mod profiles;
mod shape;
@@ -51,6 +52,7 @@ pub use model::{
};
pub use offset::OffsetChain;
pub use offset_topology::{OffsetCreated, OffsetRole, OffsetRoleFlags, OffsetRoleKind, OffsetTopology};
pub use pattern::{PatternInstance, SketchPattern, SketchPatternKind};
pub use periodic::periodic_beziers;
pub use profiles::{
CurvePick, Profile, distinct_curves, find_drawn_profiles, find_profiles, merge_regions, profile_at_point, profile_by_curves,
+56
View File
@@ -176,6 +176,20 @@ pub struct Curve {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ConstraintKind {
/// A pattern instance point is a rigid transform of its seed. Angles are radians;
/// a circular pattern keeps its centre as a point, rather than a sampled position.
PatternPoint {
p: usize,
q: usize,
center: Option<usize>,
angle: f64,
offset: Vec2,
},
/// The independent radius of a patterned circle or ellipse follows its seed.
PatternRadius {
a: usize,
b: usize,
},
Coincident {
p: usize,
q: usize,
@@ -372,6 +386,7 @@ impl ConstraintKind {
pub fn name(&self) -> &'static str {
use ConstraintKind::*;
match self {
PatternPoint { .. } | PatternRadius { .. } => "Pattern",
Coincident { .. } | PointOnCurve { .. } => "Coincident",
Horizontal { .. } | HorizontalPoints { .. } => "Horizontal",
Vertical { .. } | VerticalPoints { .. } => "Vertical",
@@ -403,6 +418,8 @@ impl ConstraintKind {
pub fn indices(&self) -> (Vec<usize>, Vec<usize>) {
use ConstraintKind::*;
match *self {
PatternPoint { p, q, center, .. } => (vec![p, q].into_iter().chain(center).collect(), vec![]),
PatternRadius { a, b } => (vec![], vec![a, b]),
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]),
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => (vec![p, q], vec![]),
PointOnCurve { p, c } => (vec![p], vec![c]),
@@ -434,6 +451,17 @@ impl ConstraintKind {
None => ok2 = false,
};
match self {
PatternPoint { p, q, center, .. } => {
fp(p);
fp(q);
if let Some(c) = center {
fp(c);
}
}
PatternRadius { a, b } => {
fc(a);
fc(b);
}
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => {
fp(p);
fp(q);
@@ -537,6 +565,9 @@ pub struct Sketch {
pub points: Vec<SPoint>,
pub curves: Vec<Curve>,
pub constraints: Vec<Constraint>,
/// Editable sketch patterns; their relations are ordinary solver constraints.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patterns: Vec<crate::pattern::SketchPattern>,
/// Links to geometry outside the sketch (projections, intersections, includes).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<crate::link::Link>,
@@ -572,6 +603,7 @@ impl Sketch {
points: vec![SPoint { id: "origin".into(), pos: Vec2::ZERO, fixed: true, link: None }],
curves: Vec::new(),
constraints: Vec::new(),
patterns: Vec::new(),
links: Vec::new(),
offsets: Vec::new(),
freeform_offsets: Vec::new(),
@@ -595,6 +627,7 @@ impl Sketch {
.len()
.saturating_add(self.curves.len())
.saturating_add(self.constraints.len())
.saturating_add(self.patterns.len())
.saturating_add(self.links.len())
.saturating_add(self.wires.len())
.saturating_add(
@@ -610,6 +643,7 @@ impl Sketch {
.map(|p| p.id.as_str())
.chain(self.curves.iter().map(|c| c.id.as_str()))
.chain(self.constraints.iter().map(|c| c.id.as_str()))
.chain(self.patterns.iter().map(|p| p.id.as_str()))
.chain(self.links.iter().map(|l| l.id.as_str()))
.chain(self.wires.iter().map(|w| w.id.as_str()))
.collect();
@@ -631,6 +665,7 @@ impl Sketch {
self.points.iter().any(|p| p.id == id)
|| self.curves.iter().any(|c| c.id == id)
|| self.constraints.iter().any(|c| c.id == id)
|| self.patterns.iter().any(|p| p.id == id)
|| self.links.iter().any(|l| l.id == id)
|| self.wires.iter().any(|w| w.id == id)
|| self.offsets.iter().filter_map(|r| r.topology.as_ref()).any(|r| r.id == id || r.roles.iter().any(|role| role.output == id))
@@ -803,6 +838,25 @@ impl Sketch {
let need_line = |i: usize| if is_line(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a line".into())) };
let need_round = |i: usize| if is_round(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a circle or arc".into())) };
match *k {
PatternPoint { p, q, angle, offset, .. } => {
if p == q
|| !angle.is_finite()
|| angle.abs() > std::f64::consts::TAU
|| !offset.x.is_finite()
|| !offset.y.is_finite()
|| offset.x.abs() > MAX_COORD
|| offset.y.abs() > MAX_COORD
{
return Err(SketchError::Invalid("invalid pattern transform".into()));
}
}
PatternRadius { a, b } => {
let independent_radius =
|c: usize| matches!(self.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Ellipse { .. }));
if a == b || !independent_radius(a) || !independent_radius(b) {
return Err(SketchError::Invalid("pattern radius needs two circles or ellipses".into()));
}
}
Horizontal { l }
| Vertical { l }
| Length { l, .. }
@@ -1291,6 +1345,7 @@ impl Sketch {
orphans.dedup();
self.remove_points(&orphans);
self.prune_links();
self.prune_patterns();
}
fn point_used_by_curve(&self, p: usize) -> bool {
@@ -1376,6 +1431,7 @@ impl Sketch {
k
});
self.prune_links();
self.prune_patterns();
}
/// Resolve a point reference: a point id (`origin`, `l1.end`, `p3`) or `<curve>.start|end|center`.
+227
View File
@@ -0,0 +1,227 @@
//! Stored parameters and stable entity ids for editing an existing sketch pattern.
use serde::{Deserialize, Serialize};
use solidcraft_geom::Vec2;
use crate::{CurveKind, Sketch, SketchError};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SketchPatternKind {
Circular { center: String, count: usize, angle: f64 },
Rectangular { dir: Vec2, count: usize, spacing: f64, count2: usize, spacing2: f64 },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PatternInstance {
pub index: [usize; 2],
pub curves: Vec<String>,
/// Seed point id and instance point id, in the order used to create the copy.
pub points: Vec<(String, String)>,
pub constraints: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SketchPattern {
pub id: String,
pub entities: Vec<String>,
pub kind: SketchPatternKind,
pub instances: Vec<PatternInstance>,
}
impl SketchPattern {
pub fn contains(&self, entity: &str) -> bool {
self.entities.iter().any(|id| id == entity)
|| self
.instances
.iter()
.any(|instance| instance.curves.iter().any(|id| id == entity) || instance.points.iter().any(|(_, id)| id == entity))
|| matches!(&self.kind, SketchPatternKind::Circular { center, .. } if center == entity)
}
}
impl Sketch {
/// Start an explicit Move/Scale of seed geometry with its copies already transformed.
/// Interactive drags instead leave the ordinary relations to the held-point solver,
/// which also supports dragging an instance back through its seed.
pub fn follow_pattern_seeds(&mut self) {
let constraint_indices: std::collections::BTreeMap<_, _> = self.constraints.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
for pattern in &self.patterns {
for instance in &pattern.instances {
for cid in &instance.constraints {
let Some(kind) = constraint_indices.get(cid.as_str()).and_then(|i| self.constraints.get(*i)).map(|c| c.kind.clone()) else {
continue;
};
match kind {
crate::ConstraintKind::PatternPoint { p, q, center, angle, offset } => {
let Some(seed) = self.point(p) else { continue };
let c = center.and_then(|c| self.point(c)).unwrap_or(Vec2::ZERO);
let d = seed - c;
let (sin, cos) = angle.sin_cos();
if let Some(copy) = self.points.get_mut(q) {
copy.pos = c + Vec2::new(d.x * cos - d.y * sin, d.x * sin + d.y * cos) + offset;
}
}
crate::ConstraintKind::PatternRadius { a, b } => {
let r = match self.curves.get(a).map(|c| &c.kind) {
Some(CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }) => Some(*r),
_ => None,
};
if let (Some(r), Some(CurveKind::Circle { r: copy, .. } | CurveKind::Ellipse { r: copy, .. })) =
(r, self.curves.get_mut(b).map(|c| &mut c.kind))
{
*copy = r;
}
}
_ => {}
}
}
}
}
self.refresh_pattern_shapes();
}
/// Deleting a seed, instance or relation breaks that pattern's association. Remaining
/// geometry stays in the sketch, without stale metadata that could resurrect a deletion.
pub fn prune_patterns(&mut self) {
if self.patterns.is_empty() {
return;
}
let curves: std::collections::BTreeSet<_> = self.curves.iter().map(|c| c.id.as_str()).collect();
let points: std::collections::BTreeSet<_> = self.points.iter().map(|p| p.id.as_str()).collect();
let constraint_ids: std::collections::BTreeSet<_> = self.constraints.iter().map(|c| c.id.as_str()).collect();
let retired: Vec<_> = self
.patterns
.iter()
.filter(|pattern| {
pattern.entities.iter().any(|id| !curves.contains(id.as_str()) && !points.contains(id.as_str()))
|| matches!(&pattern.kind, SketchPatternKind::Circular { center, .. } if !points.contains(center.as_str()))
|| pattern.instances.iter().any(|instance| {
instance.curves.iter().any(|id| !curves.contains(id.as_str()))
|| instance.points.iter().any(|(p, q)| !points.contains(p.as_str()) || !points.contains(q.as_str()))
|| instance.constraints.iter().any(|id| !constraint_ids.contains(id.as_str()))
})
})
.map(|pattern| pattern.id.clone())
.collect();
let constraints: std::collections::BTreeSet<_> =
self.patterns.iter().filter(|p| retired.contains(&p.id)).flat_map(|p| &p.instances).flat_map(|i| i.constraints.iter().cloned()).collect();
self.patterns.retain(|p| !retired.contains(&p.id));
self.constraints.retain(|c| !constraints.contains(&c.id));
}
/// Conic rho is an authored shape parameter rather than a solver unknown. Like its
/// defining points, the copies follow the seed's shape when that parameter is edited.
pub(crate) fn refresh_pattern_shapes(&mut self) {
if !self.curves.iter().any(|c| matches!(c.kind, CurveKind::Conic { .. } | CurveKind::Arc { .. })) {
return;
}
for pattern in &self.patterns {
let sources: Vec<_> = pattern.entities.iter().filter_map(|id| self.curve_index(id)).collect();
for instance in &pattern.instances {
for (seed, copy) in sources.iter().zip(&instance.curves) {
if let Some(CurveKind::Conic { rho, .. }) = self.curves.get(*seed).map(|c| &c.kind)
&& let Some(copy) = self.curve_index(copy)
{
let rho = *rho;
if let Some(CurveKind::Conic { rho: slot, .. }) = self.curves.get_mut(copy).map(|c| &mut c.kind) {
*slot = rho;
}
}
let Some(source) = self.curves.get(*seed).cloned() else { continue };
if let CurveKind::Arc { c, a, b } = source.kind {
let mapped = |p: usize| {
self.points
.get(p)
.and_then(|p| instance.points.iter().find(|(id, _)| *id == p.id))
.and_then(|(_, id)| self.point_index(id))
};
if let (Some(c), Some(a), Some(b), Some(copy)) = (mapped(c), mapped(a), mapped(b), self.curve_index(copy))
&& let Some(curve) = self.curves.get_mut(copy)
{
curve.kind = CurveKind::Arc { c, a, b };
curve.reversed = source.reversed;
}
}
}
}
}
}
pub fn validate_patterns(&self) -> Result<(), SketchError> {
if self.patterns.len() > crate::MAX_CONSTRAINTS {
return Err(SketchError::TooLarge);
}
let mut ids = std::collections::BTreeSet::new();
for pattern in &self.patterns {
if pattern.id.is_empty()
|| pattern.id.len() > 512
|| !ids.insert(&pattern.id)
|| pattern.entities.is_empty()
|| pattern.entities.len() > crate::MAX_CURVES + crate::MAX_POINTS
|| pattern.instances.len() >= 500
{
return Err(SketchError::Invalid("invalid sketch pattern".into()));
}
let valid_count = |n| (1..=500).contains(&n);
let finite = |v: f64| v.is_finite() && v.abs() <= crate::MAX_COORD;
let valid = match &pattern.kind {
SketchPatternKind::Circular { count, angle, center } => {
valid_count(*count) && angle.is_finite() && angle.abs() > 1e-9 && angle.abs() <= 360.0 && self.resolve_point(center).is_some()
}
SketchPatternKind::Rectangular { count, count2, spacing, spacing2, dir } => {
valid_count(*count)
&& valid_count(*count2)
&& count.checked_mul(*count2).is_some_and(|n| n <= 500)
&& finite(*spacing)
&& finite(*spacing2)
&& finite(dir.x)
&& finite(dir.y)
&& (dir.len() - 1.0).abs() < 1e-6
}
};
if !valid {
return Err(SketchError::Invalid("invalid sketch pattern settings".into()));
}
let (n1, n2) = match pattern.kind {
SketchPatternKind::Circular { count, .. } => (count, 1),
SketchPatternKind::Rectangular { count, count2, .. } => (count, count2),
};
if pattern.instances.len() != n1 * n2 - 1 {
return Err(SketchError::Invalid("incomplete sketch pattern".into()));
}
let mut slots = std::collections::BTreeSet::new();
for instance in &pattern.instances {
let [i, j] = instance.index;
let valid_id = |id: &String| !id.is_empty() && id.len() <= 512;
if i >= n1
|| j >= n2
|| (i == 0 && j == 0)
|| !slots.insert(instance.index)
|| instance.points.len() > crate::MAX_POINTS
|| instance.curves.len() > crate::MAX_CURVES
|| instance.constraints.len() > crate::MAX_CONSTRAINTS
|| instance.curves.iter().any(|id| !valid_id(id) || self.curve_index(id).is_none())
|| instance
.points
.iter()
.any(|(p, q)| !valid_id(p) || !valid_id(q) || self.point_index(p).is_none() || self.point_index(q).is_none())
|| instance.constraints.iter().any(|id| {
!valid_id(id)
|| !self.constraints.iter().any(|c| {
c.id == *id
&& matches!(c.kind, crate::ConstraintKind::PatternPoint { .. } | crate::ConstraintKind::PatternRadius { .. })
})
})
{
return Err(SketchError::Invalid("invalid sketch pattern instance".into()));
}
}
}
for constraint in &self.constraints {
if matches!(constraint.kind, crate::ConstraintKind::PatternPoint { .. } | crate::ConstraintKind::PatternRadius { .. }) {
self.validate(&constraint.kind)?;
}
}
Ok(())
}
}
+12
View File
@@ -400,6 +400,14 @@ fn line_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 {
fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
use ConstraintKind::*;
match *k {
PatternPoint { p, q, center, angle, offset } => {
let c = center.map(|c| s.p(c)).unwrap_or(Vec2::ZERO);
let d = s.p(p) - c;
let (sin, cos) = angle.sin_cos();
let delta = s.p(q) - c - Vec2::new(d.x * cos - d.y * sin, d.x * sin + d.y * cos) - offset;
out.extend([delta.x, delta.y]);
}
PatternRadius { a, b } => out.push(s.radius_of(a) - s.radius_of(b)),
// Offset distances drive derived geometry in refresh_offsets, never source DOFs.
OffsetDistance { .. } => {}
Coincident { p, q } => {
@@ -767,6 +775,8 @@ fn block_refs(sk: &Sketch, b: &Block) -> Vec<Ref> {
Block::Closure { a, b } => vec![P(*a), P(*b)],
Block::Arc { c, a, b } => vec![P(*c), P(*a), P(*b)],
Block::User(_, k) => match **k {
PatternPoint { p, q, center, .. } => vec![P(p), P(q)].into_iter().chain(center.map(P)).collect(),
PatternRadius { a, b } => vec![C(a), C(b)],
Coincident { p, q }
| HorizontalPoints { p, q }
| VerticalPoints { p, q }
@@ -1772,6 +1782,8 @@ fn solve_inner(
old_values: Option<&[Option<f64>]>,
budget: &mut Budget,
) -> SolveReport {
sk.prune_patterns();
sk.refresh_pattern_shapes();
let mut orientation_before = Vec::new();
for constraint in &sk.constraints {
if !constraint.driven
+12
View File
@@ -705,6 +705,13 @@ fn sketch_entity_items(app: &SolveApp) -> Vec<Item> {
return v;
}
let ents: Vec<String> = curves.iter().chain(points.iter()).cloned().collect();
for pattern in &ss.sketch.patterns {
if ents.iter().any(|id| pattern.contains(id))
|| app.session.selection.iter().filter_map(|sel| crate::sketch_modify_ui::point_id(&app.session, sel)).any(|id| pattern.contains(id))
{
v.push(act("ui.editSketchPattern", "Edit Sketch Pattern", "sketch_circ_pattern").with(json!({"pattern":pattern.id})));
}
}
if crate::sketch_text::selected_entity(app).is_some() {
v.push(cmd(app, "sketch.edit_text", "Edit Text"));
}
@@ -791,6 +798,11 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.delete" if item.params.is_some() => crate::delete::request(app, p),
"ui.delete" => delete(app),
"ui.finishSketch" => app.finish_sketch(),
"ui.editSketchPattern" => {
if let Some(id) = p.get("pattern").and_then(Value::as_str) {
crate::sketch_modify_ui::edit_pattern(app, id);
}
}
"ui.hide" => {
for b in bodies {
if !app.session.visibility.hidden_bodies.contains(&b) {
+52
View File
@@ -179,6 +179,58 @@ pub fn candidate(d: &Dialog, session: &solidcraft_engine::Session, hit: &Hit) ->
}
}
/// Reopen the same pattern window with its stored parameters. OK reaches the same engine
/// command with the pattern id, so previews and programmatic edits share the implementation.
pub fn edit_pattern(app: &mut SolveApp, id: &str) -> bool {
use solidcraft_engine::sketch::SketchPatternKind;
let Some(sid) = app.session.active_sketch else { return false };
let state = app.session.world_state();
let Some(sk) = state.sketch(sid).map(|s| &s.sketch) else { return false };
let Some(pattern) = sk.patterns.iter().find(|p| p.id == id) else { return false };
let kind = match &pattern.kind {
SketchPatternKind::Circular { count, angle, .. } => Mod::PatternCirc { count: count.to_string(), angle: format!("{angle} deg") },
SketchPatternKind::Rectangular { count, spacing, count2, spacing2, .. } => Mod::PatternRect {
count: count.to_string(),
spacing: format!("{spacing} mm"),
count2: count2.to_string(),
spacing2: format!("{spacing2} mm"),
},
};
let mut dialog = Dialog::new(Kind::SketchModify(kind.clone()), inputs(&kind));
if let Some(input) = dialog.inputs.first_mut() {
input.items = pattern
.entities
.iter()
.map(|id| if sk.curve_index(id).is_some() { Sel::SketchCurve { id: id.clone() } } else { Sel::SketchPoint { id: id.clone() } })
.collect();
}
match &pattern.kind {
SketchPatternKind::Circular { center, .. } => {
if let Some(input) = dialog.inputs.get_mut(1) {
input.items = vec![Sel::SketchPoint { id: center.clone() }];
}
}
SketchPatternKind::Rectangular { dir, .. } => {
dialog.extra.insert("dir".into(), json!(dir));
}
}
dialog.extra.insert("pattern".into(), json!(id));
app.tool = None;
app.dialog = Some(dialog);
true
}
pub fn double_click_pattern(app: &mut SolveApp, pos: Pos2) -> bool {
let Some(glyph) = crate::sketch_tools::glyph_at(pos) else { return false };
let Some(sid) = app.session.active_sketch else { return false };
let id = app
.session
.world_state()
.sketch(sid)
.and_then(|ss| ss.sketch.patterns.iter().find(|p| p.instances.iter().any(|i| i.constraints.contains(&glyph))).map(|p| p.id.clone()));
id.is_some_and(|id| edit_pattern(app, &id))
}
/// Place a circular pattern centre in empty sketch space without creating a document point.
pub fn pick_position(d: &mut Dialog, session: &solidcraft_engine::Session, point: Vec3) -> bool {
if !matches!(d.kind, Kind::SketchModify(Mod::PatternCirc { .. })) || d.active != 1 {
@@ -16,6 +16,50 @@ use crate::scenario::Harness;
use crate::selection::{AXES, CURVES, POINTS, SelInput};
use crate::sketch_modify_ui::{Mod, commands};
#[test]
fn existing_patterns_reopen_from_menu_and_badge_and_edit_without_changing_surviving_ids() {
for circular in [false, true] {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.line", json!({"points":[[10,0],[20,0]],"ids":["l1"]})).unwrap();
let cmd = if circular { "sketch.pattern.circular" } else { "sketch.pattern.rectangular" };
let p = if circular {
json!({"entities":["l1"],"center":"origin","count":4})
} else {
json!({"entities":["l1"],"dir":[0,1],"count":4,"spacing":12})
};
let out = h.app.run(cmd, p).unwrap();
let before = h.app.session.doc.to_json();
h.app.session.selection = vec![Sel::SketchCurve { id: "l2".into() }];
h.settle();
let menu = crate::context_menu::items(&h.app, &crate::context_menu::Target::Viewport);
let item = menu.iter().find(|item| item.id == "ui.editSketchPattern").expect("a copy offers Edit Sketch Pattern");
crate::context_menu::run_item(&mut h.app, item, egui::pos2(0.0, 0.0));
assert_eq!(h.app.session.doc.to_json(), before, "opening the editor commits nothing");
let d = h.app.dialog.as_ref().unwrap();
assert_eq!(d.extra["pattern"], out["pattern"]);
let cmds = apply_commands(&h.app, d).unwrap();
if !circular {
assert_eq!(cmds[0].1["dir"], json!([0.0, 1.0]));
}
h.app.dialog = None;
h.settle();
let cid = out["constraints"][0].as_str().unwrap();
let at = crate::sketch_tools::glyph_positions().into_iter().find(|(id, _)| id == cid).expect("pattern has a badge").1;
assert!(crate::sketch_modify_ui::double_click_pattern(&mut h.app, at));
let d = h.app.dialog.as_mut().unwrap();
match &mut d.kind {
Kind::SketchModify(Mod::PatternCirc { count, .. }) | Kind::SketchModify(Mod::PatternRect { count, .. }) => *count = "5".into(),
_ => panic!("wrong pattern editor"),
}
let d = h.app.dialog.clone().unwrap();
let cmds = apply_commands(&h.app, &d).unwrap();
let result = h.app.run(&cmds[0].0, cmds[0].1.clone()).unwrap();
assert_eq!(result["curves"][0], "l2");
assert_eq!(result["curves"].as_array().unwrap().len(), 4);
}
}
/// A sketch on XY holding one 20 by 10 rectangle at the origin.
fn rectangle() -> Harness {
let mut h = Harness::new();
+19 -1
View File
@@ -935,6 +935,13 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Po
if c.kind.is_dimension() {
continue;
}
if matches!(
c.kind,
solidcraft_engine::sketch::ConstraintKind::PatternPoint { .. } | solidcraft_engine::sketch::ConstraintKind::PatternRadius { .. }
) && !ss.sketch.patterns.iter().any(|p| p.instances.first().and_then(|i| i.constraints.first()) == Some(&c.id))
{
continue;
}
for (at, dir) in glyph_anchors(&ss.sketch, &c.kind) {
let Some(base) = proj.to_screen(ss.plane.to_world(at)) else { continue };
// The entity's direction on screen, and its side.
@@ -1006,7 +1013,14 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Po
let r = egui::Rect::from_center_size(*sp, egui::vec2(GLYPH, GLYPH));
painter.rect_filled(r, 3.0, if lit { tk.accent_soft } else { tk.glyph_bg });
painter.rect_stroke(r, 3.0, Stroke::new(1.0, if failing || lit { col } else { tk.glyph_edge }), egui::StrokeKind::Inside);
match glyph_icon(c.kind.name()) {
let pattern_icon = ss.sketch.patterns.iter().find(|p| p.instances.first().and_then(|i| i.constraints.first()) == Some(id)).map(|p| {
if matches!(p.kind, solidcraft_engine::sketch::SketchPatternKind::Circular { .. }) {
"sketch_circ_pattern"
} else {
"sketch_rect_pattern"
}
});
match pattern_icon.or_else(|| glyph_icon(c.kind.name())) {
Some(icon) => crate::icons::paint(painter, r.shrink(2.0), icon, col, tk.glyph_bg, col),
None => {
painter.text(*sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(10.5), col);
@@ -1035,6 +1049,8 @@ fn constraint_entities(k: &solidcraft_engine::sketch::ConstraintKind) -> (Vec<us
use solidcraft_engine::sketch::ConstraintKind::*;
match *k {
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![], vec![p, q]),
PatternPoint { p, q, center, .. } => (vec![], vec![p, q].into_iter().chain(center).collect()),
PatternRadius { a, b } => (vec![a, b], vec![]),
Fix { p } => (vec![], vec![p]),
Midpoint { p, l } => (vec![l], vec![p]),
PointOnCurve { p, c } => (vec![c], vec![p]),
@@ -1072,6 +1088,8 @@ fn glyph_anchors(sk: &solidcraft_engine::sketch::Sketch, k: &solidcraft_engine::
let pt = |p: usize| sk.point(p).map(|q| (q, None));
match *k {
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => pt(p).into_iter().collect(),
PatternPoint { p, center, .. } => pt(center.unwrap_or(p)).into_iter().collect(),
PatternRadius { .. } => Vec::new(),
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => {
sk.point(p).zip(sk.point(q)).map(|(a, b)| ((a + b) * 0.5, None)).into_iter().collect()
}
+5 -1
View File
@@ -2214,7 +2214,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
}
// Double-click a dimension: edit its value in place.
let dim_edit = resp.double_clicked() && !held && app.tool.is_none() && app.dialog.is_none() && crate::dim_view::double_click(app, p);
let dim_edit = resp.double_clicked()
&& !held
&& app.tool.is_none()
&& app.dialog.is_none()
&& (crate::sketch_modify_ui::double_click_pattern(app, p) || crate::dim_view::double_click(app, p));
// Double-click a text (an outline or inside a letter; the first click selected it): edit it.
if resp.double_clicked()
&& !held
+1 -1
View File
@@ -73,7 +73,7 @@ is available. Missing or incompatible pictures use the usual thumbnail generatio
The default `cache: false` omits the portable thumbnail too.
The repository's `docs/design-container.md` describes limits and encoding details.
Current designs use format 20, including thin extrudes, sheet metal perforation fields, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
Current designs use format 20, including thin extrudes, sheet metal perforation fields, associative editable sketch patterns, editable associative offset-distance dimensions, the style of sketch text (bold, italic, alignment, spacing and the curve it follows), face anchors on a piece of a face by its own name, sheet metal rims and bend ends, bend edges for Convert to Sheet Metal, the tear corner relief of sheet metal rules, the names of projected edges, physical materials defined in the design, the font of sketch text, sheet metal corner relief shapes, sketch face anchors on cylinder caps, sketch axis
links, persistent
names from exact face history, exact planar shell-face anchors, persistent analytic offset boundary roles and
association profile references, durable sketch face anchors, periodic fit splines and versioned
+11
View File
@@ -246,6 +246,17 @@ Proposed sketch geometry appears **green** before confirmation. Change the input
update the preview, then press **OK**. **Cancel** discards the preview. A circular pattern centre
can be an existing point or a position clicked in empty sketch space.
Sketch patterns keep each instance related to the seed geometry: moving the seed updates the
copies, and a copy cannot deform independently. Circle and ellipse radii follow the seed too.
Choose a circle's centre point to keep a circular pattern centred on that circle. A centre
placed in empty space becomes a sketch point that can be dimensioned or made coincident.
Double-click the pattern badge (at the circular centre or beside the rectangular seed), or
right-click a seed or copy and choose **Edit Sketch Pattern**, to change quantity, spacing,
direction or total angle. Confirmed edits preserve surviving instance ids; Cancel discards
the preview. Pattern settings survive save/reopen and undo/redo. Deleting a seed, copy or
pattern relation breaks that association and leaves the remaining geometry independent.
Patterns saved by older versions remain independent copies; recreate them to add associations.
Offset distances are editable driving dimensions: double-click a dimension or change its model
parameter to resize the offset without moving its source curves. The selected side, distance,
and association survive save/reopen and undo/redo. Symmetric offsets share one driving distance;