Feat/Sketch: associative pattern tools with independent directions and Flip/Center controls (#1601)

Thanks @Karl-VonRichter.
This commit is contained in:
Karl Von Richter authored and GitHub committed 2026-10-16 00:32:32 -04:00
1 parent 44968dbfe6
commit 371578a06f
36 files changed
+1394 -87

No files matched your search

+1 -1
View File
@@ -52,7 +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 pattern::{PatternAlignment, 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,
+17 -3
View File
@@ -189,6 +189,9 @@ pub enum ConstraintKind {
center: Option<usize>,
angle: f64,
offset: Vec2,
/// Displacements along and perpendicular to live sketch lines (at most two).
#[serde(default)]
directions: [Option<(usize, Vec2)>; 2],
},
/// The independent radius of a patterned circle or ellipse follows its seed.
PatternRadius {
@@ -423,7 +426,9 @@ 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![]),
PatternPoint { p, q, center, directions, .. } => {
(vec![p, q].into_iter().chain(center).collect(), directions.into_iter().flatten().map(|(l, _)| l).collect())
}
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![]),
@@ -458,12 +463,15 @@ impl ConstraintKind {
None => ok2 = false,
};
match self {
PatternPoint { p, q, center, .. } => {
PatternPoint { p, q, center, directions, .. } => {
fp(p);
fp(q);
if let Some(c) = center {
fp(c);
}
for (l, _) in directions.iter_mut().flatten() {
fc(l);
}
}
PatternRadius { a, b } => {
fc(a);
@@ -848,7 +856,13 @@ 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, .. } => {
PatternPoint { p, q, angle, offset, directions, .. } => {
for (l, distance) in directions.into_iter().flatten() {
need_line(l)?;
if !distance.x.is_finite() || !distance.y.is_finite() || distance.x.abs() > MAX_COORD || distance.y.abs() > MAX_COORD {
return Err(SketchError::Invalid("invalid pattern direction displacement".into()));
}
}
if p == q
|| !angle.is_finite()
|| angle.abs() > std::f64::consts::TAU
+71 -5
View File
@@ -5,11 +5,62 @@ use solidcraft_geom::Vec2;
use crate::{CurveKind, Sketch, SketchError};
/// Placement relative to the original, along a pattern direction's oriented line.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PatternAlignment {
Left,
Center,
#[default]
Right,
}
impl PatternAlignment {
/// Slot zero is always the original. Center alternates sides to retain stable copy ids.
pub fn multiplier(self, index: usize, reversed: bool) -> f64 {
let value = match self {
Self::Left => -(index as f64),
Self::Right => index as f64,
Self::Center if index.is_multiple_of(2) => -((index / 2) as f64),
Self::Center => index.div_ceil(2) as f64,
};
if reversed { -value } else { value }
}
}
#[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 },
Circular {
center: String,
count: usize,
angle: f64,
#[serde(default)]
alignment: PatternAlignment,
#[serde(default)]
reversed: bool,
},
Rectangular {
dir: Vec2,
#[serde(default)]
dir2: Option<Vec2>,
#[serde(default)]
direction: Option<String>,
#[serde(default)]
direction2: Option<String>,
count: usize,
spacing: f64,
count2: usize,
spacing2: f64,
#[serde(default)]
alignment: PatternAlignment,
#[serde(default)]
alignment2: PatternAlignment,
#[serde(default)]
reversed: bool,
#[serde(default)]
reversed2: bool,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -37,6 +88,8 @@ impl SketchPattern {
.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)
|| matches!(&self.kind, SketchPatternKind::Rectangular { direction, direction2, .. }
if direction.as_deref() == Some(entity) || direction2.as_deref() == Some(entity))
}
}
@@ -53,7 +106,14 @@ impl Sketch {
continue;
};
match kind {
crate::ConstraintKind::PatternPoint { p, q, center, angle, offset } => {
crate::ConstraintKind::PatternPoint { p, q, center, angle, mut offset, directions } => {
for (l, distance) in directions.into_iter().flatten() {
if let Some(crate::Shape::Line { a, b }) = self.shape(l)
&& let Some(u) = (b - a).normalized()
{
offset += u * distance.x + u.perp() * distance.y;
}
}
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;
@@ -95,6 +155,8 @@ impl Sketch {
.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()))
|| matches!(&pattern.kind, SketchPatternKind::Rectangular { direction, direction2, .. }
if direction.iter().chain(direction2).any(|id| !curves.contains(id.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()))
@@ -165,10 +227,10 @@ impl Sketch {
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 } => {
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 } => {
SketchPatternKind::Rectangular { count, count2, spacing, spacing2, dir, dir2, direction, direction2, .. } => {
valid_count(*count)
&& valid_count(*count2)
&& count.checked_mul(*count2).is_some_and(|n| n <= 500)
@@ -177,6 +239,10 @@ impl Sketch {
&& finite(dir.x)
&& finite(dir.y)
&& (dir.len() - 1.0).abs() < 1e-6
&& dir2.is_none_or(|d| finite(d.x) && finite(d.y) && (d.len() - 1.0).abs() < 1e-6)
&& direction.iter().chain(direction2).all(|id| {
self.curve_index(id).is_some_and(|l| matches!(self.curves.get(l).map(|c| &c.kind), Some(CurveKind::Line { .. })))
})
}
};
if !valid {
+9 -2
View File
@@ -400,7 +400,12 @@ 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 } => {
PatternPoint { p, q, center, angle, mut offset, directions } => {
for (l, distance) in directions.into_iter().flatten() {
let (a, b) = s.line(l);
let u = (b - a).normalized().unwrap_or(Vec2::ZERO);
offset += u * distance.x + u.perp() * distance.y;
}
let c = center.map(|c| s.p(c)).unwrap_or(Vec2::ZERO);
let d = s.p(p) - c;
let (sin, cos) = angle.sin_cos();
@@ -775,7 +780,9 @@ 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(),
PatternPoint { p, q, center, directions, .. } => {
vec![P(p), P(q)].into_iter().chain(center.map(P)).chain(directions.into_iter().flatten().map(|(l, _)| L(l))).collect()
}
PatternRadius { a, b } => vec![C(a), C(b)],
Coincident { p, q }
| HorizontalPoints { p, q }