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

+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