Pattern on Path (PatternOnPath: spacing or first-to-last distance, flip, identical or path orientation; instance k moves by path(k·d) − path(0)), rectangular/circular/path patterns of bodies; recipes: path_pattern (oracle 53 passes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 00:42:26 +00:00
1 parent 02de01f87c
commit 075808e7ee
8 files changed
+278 -12

No files matched your search

+14
View File
@@ -224,6 +224,20 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null);
out.push(json!({"command": "PatternCircular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
}
Some("path_pattern") => {
let path = f.get("path").cloned().unwrap_or(Value::Null);
let mut p = json!({"path_sketch": path.get("sketch"), "path": path.get("curves"), "count": f.get("count"), "name": name});
match f.get("spacing") {
Some(sp) => p["spacing"] = sp.clone(),
None => p["distance"] = f.get("distance").cloned().unwrap_or(Value::Null),
}
if f.get("pattern_of").and_then(Value::as_str) == Some("bodies") {
p["bodies"] = f.get("bodies").cloned().unwrap_or(Value::Null);
} else {
p["features"] = feature_refs(f.get("features"));
}
out.push(json!({"command": "PatternOnPath", "params": p}));
}
Some("loft") => {
let sections: Vec<Value> = f
.get("sections")
+2
View File
@@ -180,6 +180,8 @@ impl FeatureKind {
pt(origin);
dir(axis);
}
// Follows its sketch.
FeatureKind::Pattern { pattern: PatternKind::Path { .. }, .. } => {}
FeatureKind::Hole { position, direction, .. } => {
pt(position);
dir(direction);
+26 -1
View File
@@ -206,10 +206,13 @@ pub enum FeatureKind {
#[serde(default)]
keep_tools: bool,
},
/// Copies of other features' results (rectangular or circular).
/// Copies of other features' results, or of bodies (rectangular, circular or along a path).
Pattern {
features: Vec<String>,
pattern: PatternKind,
/// Copy these bodies (as new bodies) instead of replaying features.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
bodies: Vec<String>,
},
/// Mirror copies of other features' results in a plane.
Mirror {
@@ -437,6 +440,24 @@ pub enum PatternKind {
/// Total angle (360 deg = evenly around).
angle: String,
},
/// Copies along a chain of sketch curves: instance k moves by path(k·spacing) − path(0),
/// measured along the path from its start.
Path {
path_sketch: u64,
path: Vec<String>,
count: String,
/// Distance along the path between instances, or the whole extent when `extent`.
spacing: String,
/// `spacing` is the distance from the first to the last instance.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
extent: bool,
/// Run from the path's other end.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
flip: bool,
/// Turn copies with the path's direction (else they keep their orientation).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
orient: bool,
},
}
fn plane_exprs<'a>(p: &'a PlaneRef, v: &mut Vec<&'a str>) {
@@ -475,6 +496,7 @@ impl FeatureKind {
FeatureKind::Torus { .. } => "TorusFeature",
FeatureKind::Combine { .. } => "CombineFeature",
FeatureKind::Pattern { pattern: PatternKind::Rectangular { .. }, .. } => "RectangularPatternFeature",
FeatureKind::Pattern { pattern: PatternKind::Path { .. }, .. } => "PathPatternFeature",
FeatureKind::Pattern { .. } => "CircularPatternFeature",
FeatureKind::Mirror { .. } => "MirrorFeature",
FeatureKind::ConstructionPlane { .. } => "ConstructionPlane",
@@ -508,6 +530,7 @@ impl FeatureKind {
FeatureKind::Torus { .. } => "Torus",
FeatureKind::Combine { .. } => "Combine",
FeatureKind::Pattern { pattern: PatternKind::Rectangular { .. }, .. } => "RectangularPattern",
FeatureKind::Pattern { pattern: PatternKind::Path { .. }, .. } => "PathPattern",
FeatureKind::Pattern { .. } => "CircularPattern",
FeatureKind::Mirror { .. } => "Mirror",
FeatureKind::ConstructionPlane { .. } => "Plane",
@@ -561,6 +584,7 @@ impl FeatureKind {
v.extend(spacing2.iter().map(String::as_str));
}
PatternKind::Circular { count, angle, .. } => v.extend([count.as_str(), angle]),
PatternKind::Path { count, spacing, .. } => v.extend([count.as_str(), spacing]),
},
FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v),
FeatureKind::Shell { thickness, .. } => v.push(thickness),
@@ -826,6 +850,7 @@ impl Document {
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id),
FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id),
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id),
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
_ => {}
}
}
+101 -4
View File
@@ -1125,7 +1125,7 @@ fn mirror_matrix(pl: &Plane) -> Mat {
const MAX_INSTANCES: usize = 1000;
fn pattern_transforms(vals: &BTreeMap<String, Value>, p: &crate::PatternKind) -> Result<Vec<Mat>> {
fn pattern_transforms(vals: &BTreeMap<String, Value>, st: &ModelState, p: &crate::PatternKind) -> Result<Vec<Mat>> {
let count = |e: &str| -> Result<usize> {
let n = val(vals, e, Kind::Unitless)?.round();
if !(1.0..=MAX_INSTANCES as f64).contains(&n) {
@@ -1156,6 +1156,37 @@ fn pattern_transforms(vals: &BTreeMap<String, Value>, p: &crate::PatternKind) ->
}
}
}
crate::PatternKind::Path { path_sketch, path, count: c, spacing, extent, flip, orient } => {
let n = count(c)?;
let d = val(vals, spacing, Kind::Length)?;
let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("path sketch {path_sketch}")))?;
let mut segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
if *flip {
segs = segs.iter().rev().map(reverse_seg).collect();
}
let total: f64 = segs.iter().map(seg_len).sum();
let step = if *extent { if n > 1 { d / (n - 1) as f64 } else { 0.0 } } else { d };
if (step * (n - 1) as f64).abs() > total * (1.0 + 1e-5) + 1e-6 {
return Err(DocError::Invalid(format!("the instances run past the end of the path ({total:.3} mm long)")));
}
let normal = ps.plane.normal();
let (p0, t0) = path_at(&segs, 0.0);
for i in 1..n {
let (pk, tk) = path_at(&segs, step * i as f64);
let mut m = if *orient {
// Signed turn of the tangent about the sketch normal.
let ang = t0.cross(tk).dot(normal).atan2(t0.dot(tk));
rotation(p0, normal, ang)
} else {
translation(Vec3::ZERO)
};
let dv = pk - p0;
m[3][0] += dv.x;
m[3][1] += dv.y;
m[3][2] += dv.z;
out.push(m);
}
}
crate::PatternKind::Circular { origin, axis, count: c, angle } => {
let n = count(c)?;
let total = val(vals, angle, Kind::Angle)?;
@@ -1175,6 +1206,60 @@ fn pattern_transforms(vals: &BTreeMap<String, Value>, p: &crate::PatternKind) ->
Ok(out)
}
fn seg_len(s: &kernel::PathSeg) -> f64 {
match *s {
kernel::PathSeg::Line { a, b } => (b - a).len(),
kernel::PathSeg::Arc { a, center, angle, .. } => (a - center).len() * angle.abs(),
}
}
/// Rotate `v` about the unit axis `k` by `ang` (Rodrigues).
fn rotate_vec(v: Vec3, k: Vec3, ang: f64) -> Vec3 {
v * ang.cos() + k.cross(v) * ang.sin() + k * (k.dot(v) * (1.0 - ang.cos()))
}
/// Point and unit tangent `t` mm along a segment.
fn seg_at(s: &kernel::PathSeg, t: f64) -> (Vec3, Vec3) {
match *s {
kernel::PathSeg::Line { a, b } => {
let d = (b - a).normalized().unwrap_or(Vec3::X);
(a + d * t, d)
}
kernel::PathSeg::Arc { a, center, axis, angle } => {
let k = axis.normalized().unwrap_or(Vec3::Z);
let r = (a - center).len().max(1e-12);
// Sweeps turn right-handed about `axis` (angles are positive).
let s = if angle < 0.0 { -1.0 } else { 1.0 };
let v = rotate_vec(a - center, k, s * t / r);
(center + v, k.cross(v).normalized().unwrap_or(Vec3::X) * s)
}
}
}
fn reverse_seg(s: &kernel::PathSeg) -> kernel::PathSeg {
match *s {
kernel::PathSeg::Line { a, b } => kernel::PathSeg::Line { a: b, b: a },
kernel::PathSeg::Arc { a, center, axis, angle } => {
let k = axis.normalized().unwrap_or(Vec3::Z);
let end = center + rotate_vec(a - center, k, angle);
kernel::PathSeg::Arc { a: end, center, axis: k * -1.0, angle }
}
}
}
/// Point and tangent at arc length `d` along a chain (clamped to its ends).
fn path_at(segs: &[kernel::PathSeg], d: f64) -> (Vec3, Vec3) {
let mut left = d.max(0.0);
for (i, s) in segs.iter().enumerate() {
let l = seg_len(s);
if left <= l + 1e-9 || i + 1 == segs.len() {
return seg_at(s, left.min(l));
}
left -= l;
}
(Vec3::ZERO, Vec3::X)
}
/// Apply transformed copies of the tools of `features` (patterns, mirrors).
fn replay(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, features: &[String], mats: &[Mat]) -> Result<()> {
if features.is_empty() {
@@ -1317,9 +1402,21 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Pattern { features, pattern } => {
let mats = pattern_transforms(vals, pattern)?;
replay(doc, vals, f, st, features, &mats)
FeatureKind::Pattern { features, pattern, bodies } => {
let mats = pattern_transforms(vals, st, pattern)?;
if bodies.is_empty() {
return replay(doc, vals, f, st, features, &mats);
}
// Bodies: each copy is a new body named after its source.
for n in bodies {
let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
for m in &mats {
let copy = kernel::transform_matrix(&mb.body, *m)?;
let name = unique_body_name(st, &mb.name);
st.bodies.push(ModelBody::new(name, copy, f.id));
}
}
Ok(())
}
FeatureKind::Mirror { features, plane, bodies, combine } => {
let pl = doc.resolve_plane(vals, plane, 0)?;
+4
View File
@@ -75,6 +75,10 @@ impl FeatureKind {
v.push(("Spacing 2", sp, L));
}
}
PatternKind::Path { count, spacing, .. } => {
v.push(("Quantity", count, U));
v.push(("Distance", spacing, L));
}
PatternKind::Circular { count, angle, .. } => {
v.push(("Quantity", count, U));
v.push(("Total angle", angle, A));
+53 -6
View File
@@ -37,11 +37,15 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("PatternRectangular", "Rectangular Pattern", pattern_rect)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?"),
.params("features: [names] | bodies: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?"),
CommandSpec::new("PatternCircular", "Circular Pattern", pattern_circ)
.at("SOLID", "CREATE")
.icon("pattern_circ")
.params("features: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"),
.params("features: [names] | bodies: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"),
CommandSpec::new("PatternOnPath", "Pattern on Path", pattern_path)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
CommandSpec::new("MirrorCommand", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
CommandSpec::new("FusionHoleCommand", "Hole", hole)
.at("SOLID", "CREATE")
@@ -518,9 +522,22 @@ fn axis_line(p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
}
}
/// What a pattern copies: features (replayed), or bodies (`bodies: [names]`).
fn pattern_sources(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<String>, Vec<String>)> {
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Ok((source_features(s, p, cmd)?, Vec::new()));
}
let st = s.model.state();
if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) {
return Err(bad(cmd, format!("no body `{b}`")));
}
Ok((Vec::new(), bodies))
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PatternRectangular";
let features = source_features(s, p, cmd)?;
let (features, bodies) = pattern_sources(s, p, cmd)?;
let dir1 = p.get("dir1").and_then(vec3).ok_or_else(|| bad(cmd, "`dir1` must be [x, y, z]"))?;
let count1 = req_expr(cmd, p, "count1")?;
let spacing1 = req_expr(cmd, p, "spacing1")?;
@@ -535,19 +552,45 @@ fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
check_expr(s, c, Kind::Length, cmd, "spacing2")?;
}
let pattern = solvecraft_doc::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 };
add_feature(s, p, FeatureKind::Pattern { features, pattern })
add_feature(s, p, FeatureKind::Pattern { features, pattern, bodies })
}
fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PatternCircular";
let features = source_features(s, p, cmd)?;
let (features, bodies) = pattern_sources(s, p, cmd)?;
let (origin, axis) = axis_line(p, cmd)?;
let count = req_expr(cmd, p, "count")?;
check_expr(s, &count, Kind::Unitless, cmd, "count")?;
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let pattern = solvecraft_doc::PatternKind::Circular { origin, axis, count, angle };
add_feature(s, p, FeatureKind::Pattern { features, pattern })
add_feature(s, p, FeatureKind::Pattern { features, pattern, bodies })
}
fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PatternOnPath";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch").or_else(|| p.get("sketch")), cmd, "path_sketch")?;
let path = string_list(p, "path");
if path.is_empty() || path.len() > 1000 {
return Err(bad(cmd, "`path` must list 1…1000 curve ids"));
}
let count = req_expr(cmd, p, "count")?;
check_expr(s, &count, Kind::Unitless, cmd, "count")?;
let (spacing, extent) = match (expr(p, "spacing"), expr(p, "distance")) {
(Some(x), _) => (x, false),
(None, Some(x)) => (x, true),
(None, None) => return Err(bad(cmd, "give `spacing` (between instances) or `distance` (first to last)")),
};
check_expr(s, &spacing, Kind::Length, cmd, "spacing")?;
let orient = match str_(p, "orientation") {
None | Some("identical") => false,
Some("path" | "path_direction" | "along_path") => true,
Some(o) => return Err(bad(cmd, format!("unknown orientation `{o}` (identical or path)"))),
};
let flip = bool_(p, "flip").unwrap_or(false);
let pattern = solvecraft_doc::PatternKind::Path { path_sketch, path, count, spacing, extent, flip, orient };
add_feature(s, p, FeatureKind::Pattern { features, pattern, bodies })
}
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
@@ -863,3 +906,7 @@ fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
add_feature(s, p, FeatureKind::OffsetFace { faces, distance, body: str_(p, "body").map(str::to_string) })
}
#[cfg(test)]
#[path = "patterns_tests.rs"]
mod patterns_tests;
+75
View File
@@ -0,0 +1,75 @@
use std::f64::consts::PI;
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &mut Session) -> f64 {
run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
/// A 100 x 50 x 5 plate with a hole at the start of a straight path (10,10) → (90,10).
fn plate_with_hole() -> Session {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 50, "height": 5, "corner": [0, 0, 0]}));
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "DrawPolyline", json!({"points": [[10, 10], [90, 10]], "ids": ["l1"]}));
run(&mut s, "CircleCenterRadius", json!({"center": [10, 10], "radius": 2, "id": "c1"}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "Extrude", json!({"sketch": "Path", "through_all": true, "operation": "cut", "name": "Hole1"}));
s
}
const PLATE: f64 = 100.0 * 50.0 * 5.0;
const HOLE: f64 = PI * 4.0 * 5.0;
#[test]
fn pattern_on_path_spacing_extent_and_flip() {
let mut s = plate_with_hole();
assert!(rel(volume(&mut s), PLATE - HOLE) < 1e-4);
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 5, "spacing": 20}));
assert!(rel(volume(&mut s), PLATE - 5.0 * HOLE) < 1e-4);
// The count is a parameter like any other input.
let n = s.doc.features.last().and_then(|f| f.param_names.first().cloned()).unwrap_or_default();
run(&mut s, "ChangeParameterCommand", json!({"name": n, "expression": "3"}));
assert!(rel(volume(&mut s), PLATE - 3.0 * HOLE) < 1e-4);
// Instances past the end of the path are an error.
run(&mut s, "ChangeParameterCommand", json!({"name": n, "expression": "6"}));
let err = s.model.results.last().and_then(|r| r.error.clone()).unwrap_or_default();
assert!(err.contains("past the end of the path"), "{err}");
// Distance = first to last instance.
let mut s = plate_with_hole();
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 3, "distance": 80}));
assert!(rel(volume(&mut s), PLATE - 3.0 * HOLE) < 1e-4);
// Flipped, the path runs from (90,10) back toward (10,10).
let mut s = plate_with_hole();
run(&mut s, "PatternOnPath", json!({"features": ["Hole1"], "path_sketch": "Path", "path": ["l1"], "count": 2, "spacing": 30, "flip": true}));
// The copy moves by path(30) − path(0) = (60,10) − (90,10) = −30: off the plate side → only 1 hole.
assert!(rel(volume(&mut s), PLATE - HOLE) < 1e-4);
}
#[test]
fn patterns_of_bodies() {
let mut s = Session::default();
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cube"}));
run(&mut s, "PatternCircular", json!({"bodies": ["Cube"], "axis": "Z", "count": 4}));
let m = run(&mut s, "MeasureCommand", json!({}));
assert_eq!(m["body_count"], 4, "{m}");
assert!(rel(volume(&mut s), 4000.0) < 1e-4);
run(&mut s, "PatternRectangular", json!({"bodies": ["Cube"], "dir1": [0, 0, 1], "count1": 3, "spacing1": 20}));
assert!(rel(volume(&mut s), 6000.0) < 1e-4);
assert!(s.execute("PatternRectangular", &json!({"bodies": ["Nope"], "dir1": [1, 0, 0], "count1": 2, "spacing1": 5})).is_err());
}
+3 -1
View File
@@ -1567,7 +1567,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
FeatureKind::Pattern { features, pattern } => {
FeatureKind::Pattern { features, pattern, .. } => {
let ids: Vec<u64> = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect();
let bodies: Vec<Sel> = st.bodies.iter().filter(|b| ids.contains(&b.feature)).map(|b| Sel::Body { name: b.name.clone() }).collect();
let mut d = match pattern {
@@ -1610,6 +1610,8 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
}
d
}
// No dialog for a path pattern yet: edit it with timeline.redefine.
PatternKind::Path { .. } => return None,
};
if let Some(inp) = d.inputs.first_mut() {
inp.items = bodies;