From 075808e7ee5e5a0f883db9176c859b7ddb474989 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 00:25:39 +0000 Subject: [PATCH] =?UTF-8?q?Pattern=20on=20Path=20(PatternOnPath:=20spacing?= =?UTF-8?q?=20or=20first-to-last=20distance,=20flip,=20identical=20or=20pa?= =?UTF-8?q?th=20orientation;=20instance=20k=20moves=20by=20path(k=C2=B7d)?= =?UTF-8?q?=20=E2=88=92=20path(0)),=20rectangular/circular/path=20patterns?= =?UTF-8?q?=20of=20bodies;=20recipes:=20path=5Fpattern=20(oracle=2053=20pa?= =?UTF-8?q?sses)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- apps/solvecraft-cli/src/recipe.rs | 14 ++++ crates/doc/src/assembly.rs | 2 + crates/doc/src/document.rs | 27 +++++- crates/doc/src/eval.rs | 105 +++++++++++++++++++++++- crates/doc/src/params.rs | 4 + crates/engine/src/cmd/features.rs | 59 +++++++++++-- crates/engine/src/cmd/patterns_tests.rs | 75 +++++++++++++++++ crates/ui-egui/src/dialogs.rs | 4 +- 8 files changed, 278 insertions(+), 12 deletions(-) create mode 100644 crates/engine/src/cmd/patterns_tests.rs diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index d7f01dd5..75eefe34 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -224,6 +224,20 @@ pub fn to_script(recipe: &Value) -> Result { 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 = f .get("sections") diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 841e2bca..9a54872b 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -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); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 2d11f835..639690e0 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, pattern: PatternKind, + /// Copy these bodies (as new bodies) instead of replaying features. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + bodies: Vec, }, /// 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, + 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), _ => {} } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 003b2aa5..fe60b23a 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1125,7 +1125,7 @@ fn mirror_matrix(pl: &Plane) -> Mat { const MAX_INSTANCES: usize = 1000; -fn pattern_transforms(vals: &BTreeMap, p: &crate::PatternKind) -> Result> { +fn pattern_transforms(vals: &BTreeMap, st: &ModelState, p: &crate::PatternKind) -> Result> { let count = |e: &str| -> Result { 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, 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, 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, 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, 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)?; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 8bc13448..58035106 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -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)); diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 3ed8904e..d6ef2cff 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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, Vec)> { + 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 { 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 { 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 { 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 { + 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 { @@ -863,3 +906,7 @@ fn offset_face(s: &mut Session, p: &Value) -> Result { 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; diff --git a/crates/engine/src/cmd/patterns_tests.rs b/crates/engine/src/cmd/patterns_tests.rs new file mode 100644 index 00000000..9fa8e25b --- /dev/null +++ b/crates/engine/src/cmd/patterns_tests.rs @@ -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()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 115fca15..f87e4fdb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1567,7 +1567,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Pattern { features, pattern, .. } => { let ids: Vec = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect(); let bodies: Vec = 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) -> Option return None, }; if let Some(inp) = d.inputs.first_mut() { inp.items = bodies;