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
@@ -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(" ")) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user