diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 28afaec..b4556dd 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -72,7 +72,7 @@ is committed and listed here with its exports. | assets/icons/mesh.region.remesh.svg, assets/icons/mesh.region.remesh.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.region.remesh.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.size.adapt.svg, assets/icons/mesh.size.adapt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.adapt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.size.global.svg, assets/icons/mesh.size.global.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.global.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | -| assets/icons/mesh.size.seed.svg, assets/icons/mesh.size.seed.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.size.seed.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | +| assets/icons/mesh.seed.edge.svg, assets/icons/mesh.seed.edge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.seed.edge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.smooth.svg, assets/icons/mesh.smooth.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.smooth.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.surface.check.svg, assets/icons/mesh.surface.check.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.surface.check.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/mesh.tag.svg, assets/icons/mesh.tag.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/mesh.tag.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | diff --git a/assets/icons/mesh.size.seed.svg b/assets/icons/mesh.seed.edge.svg similarity index 97% rename from assets/icons/mesh.size.seed.svg rename to assets/icons/mesh.seed.edge.svg index 8da5903..9e56ce2 100644 --- a/assets/icons/mesh.size.seed.svg +++ b/assets/icons/mesh.seed.edge.svg @@ -1,6 +1,6 @@ - mesh.size.seed + mesh.seed.edge diff --git a/assets/icons/mesh.size.seed.vectorcraft b/assets/icons/mesh.seed.edge.vectorcraft similarity index 100% rename from assets/icons/mesh.size.seed.vectorcraft rename to assets/icons/mesh.seed.edge.vectorcraft diff --git a/assets/icons/src/mesh.size.seed.json b/assets/icons/src/mesh.seed.edge.json similarity index 100% rename from assets/icons/src/mesh.size.seed.json rename to assets/icons/src/mesh.seed.edge.json diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 05881d0..d1971ae 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -296,6 +296,20 @@ enum Act { ShowResult(String), } +/// A seed in a few words: `8 exact`, `2.5 min, bias 4 end`. +fn seed_note(s: &fieldcraft_model::Seed) -> String { + let what = match s.divisions { + fieldcraft_model::Divisions::Count(n) => format!("{n}"), + fieldcraft_model::Divisions::Size(h) => format!("size {h}"), + }; + let bias = s.bias.map(|b| format!(", bias {} {}", b.ratio, json_name(&b.toward))).unwrap_or_default(); + format!("{what} {}{bias}", s.constraint.name()) +} + +fn json_name(v: &T) -> String { + serde_json::to_value(v).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default() +} + pub fn browser(app: &mut ModelApp, ui: &mut egui::Ui) { let t = Tokens::get(app.ui.dark); let mut acts: Vec = Vec::new(); @@ -485,6 +499,40 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec) { &Row { depth: 2, swatch: Some(t.element(kind)), label: kind.name(), note: Some(format!("{n}")), ..Default::default() }, ); } + // Edge seeds: a click selects the seeded edge in the viewport. + let seeds: Vec<(fieldcraft_model::GeometryRef, String)> = app.session.model.seeds().iter().map(|s| (s.edge.clone(), seed_note(s))).collect(); + if !seeds.is_empty() { + let r = draw_row( + app, + ui, + egui::Id::new("fc_row_seeds"), + &Row { + depth: 2, + fold: Some(open(app, "seeds")), + icon: Some("mesh.seed.edge"), + label: "Seeds", + note: Some(format!("{}", seeds.len())), + ..Default::default() + }, + ); + if r.fold || r.clicked { + acts.push(Act::Fold("seeds".into())); + } + if open(app, "seeds") { + for (i, (edge, note)) in seeds.into_iter().enumerate() { + let label = format!("{} ยท {}", edge.body, edge.entity.name().unwrap_or_default()); + let r = draw_row( + app, + ui, + egui::Id::new(("fc_row_seed", i)), + &Row { depth: 3, label: &label, note: Some(note), ..Default::default() }, + ); + if r.clicked { + app.select([crate::selection::Item::Geometry(edge)], crate::selection::Mode::Replace); + } + } + } + } } // Definitions. let groups: [(&str, &str, &[Def]); 7] = [ diff --git a/crates/ui-egui/src/context.rs b/crates/ui-egui/src/context.rs index 7e9c243..67d540c 100644 --- a/crates/ui-egui/src/context.rs +++ b/crates/ui-egui/src/context.rs @@ -100,6 +100,7 @@ fn form(app: &mut ModelApp, ui: &mut egui::Ui, t: &Tokens) { let tip = match target { selection::Target::Members => "The selection becomes the set's members (geometry by name, the mesh by label)", selection::Target::Set | selection::Target::TargetSet => "Makes a set of the selection (model.set.create) and names it here", + selection::Target::Edges => "The selected design edges (Select: Edges) become the edges seeded", }; use_selection = ui.add_enabled(selected.is_some(), egui::Button::new("Use selection")).on_hover_text(tip).clicked(); match &selected { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8b4d3f5..35c37ba 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -840,6 +840,7 @@ impl ModelApp { let mut symbols = scene::symbols(&mut s, &self.glyphs, |n| drawn.node_position(n), show, self.plot.1.is_some(), &t); if show.geometry && self.plot.1.is_none() { symbols.union(scene::datums(&mut s, &self.session.model, &self.display.1.bounds, &t)); + scene::seeds(&mut s, &self.session.model, &self.pick.names, &self.display.1, &t); } self.scene = (Some(key), s, symbols); } @@ -1050,6 +1051,9 @@ impl ModelApp { let Some(target) = selection::target(command, ¶ms) else { return Err(format!("{command} takes no selection")) }; let filled = match target { selection::Target::Members => selection::with_members(¶ms, &self.pick.selection), + selection::Target::Edges => { + selection::with_edges(¶ms, &self.pick.selection).ok_or("no edge is selected: pick edges with Select: Edges")? + } selection::Target::Set | selection::Target::TargetSet => { let base = params.get("name").and_then(Value::as_str).map(|n| format!("{n}-set")); let set = self.selection_set(base.as_deref())?; diff --git a/crates/ui-egui/src/scene.rs b/crates/ui-egui/src/scene.rs index 20056d8..dd68686 100644 --- a/crates/ui-egui/src/scene.rs +++ b/crates/ui-egui/src/scene.rs @@ -115,6 +115,41 @@ pub fn build(display: &DisplayMesh, style: Style, show: Show, t: &Tokens) -> Sce s } +/// Size of a seed's tick (points). +const SEED_TICK: f64 = 6.0; + +/// Draw the model's edge seeds as ticks at the nodes they put along their edges (the drawn +/// bodies' B-rep edges, [`DisplayMesh::curves`], named by `names`): the seeded count, spaced by +/// the bias, so a grading shows as ticks crowding toward an end. +pub fn seeds(s: &mut Scene, model: &fieldcraft_model::Model, names: &[crate::selection::BodyNames], display: &DisplayMesh, t: &Tokens) { + if model.seeds().is_empty() { + return; + } + let color = rgb(t.warm); + for run in display.curves.chunk_by(|a, b| a.body == b.body && a.edge == b.edge) { + let Some(first) = run.first() else { continue }; + let Some(edge) = names.get(first.body as usize).and_then(|b| b.edge(first.edge)) else { continue }; + let Some(seed) = model.seeds().iter().find(|seed| seed.edge.selects(&edge)) else { continue }; + let lengths: Vec = run.iter().map(|c| c.a.dist(c.b)).collect(); + let total: f64 = lengths.iter().sum(); + if !(total > 0.0 && total.is_finite()) { + continue; + } + // Walk the polyline to each node's arc length. + let (mut k, mut walked) = (0, 0.0); + for f in seed.spacing(seed.count(total)) { + let target = f * total; + while k + 1 < run.len() && walked + lengths.get(k).copied().unwrap_or(0.0) < target { + walked += lengths.get(k).copied().unwrap_or(0.0); + k += 1; + } + let (Some(c), Some(l)) = (run.get(k), lengths.get(k)) else { continue }; + let u = if *l > 0.0 { ((target - walked) / l).clamp(0.0, 1.0) } else { 0.0 }; + s.points.push(ScenePoint { p: c.a + (c.b - c.a) * u, color, size: SEED_TICK }); + } + } +} + /// The radius around the camera target that holds the bounds (for the clipping planes). pub fn radius(b: &Aabb3, cam: &Camera) -> f64 { if b.is_empty() { 1.0 } else { (cam.target.dist(b.center()) + b.diagonal() * 0.5).max(1e-3) } @@ -126,6 +161,26 @@ mod tests { use super::*; + #[test] + fn seeds_draw_ticks_at_their_nodes() { + use fieldcraft_model::{Bias, BiasToward, Divisions, Entity, GeometryRef, Seed, SeedConstraint}; + let mut d = DisplayMesh::default(); + // Two edges of 10 mm; the first in two segments. + d.add_curves(&[vec![[0.0; 3], [4.0, 0.0, 0.0], [10.0, 0.0, 0.0]], vec![[0.0; 3], [0.0, 10.0, 0.0]]]); + let names = [crate::selection::BodyNames { name: "B".into(), faces: vec![], edges: vec!["a|b".into(), "a|c".into()] }]; + let mut m = Model::default(); + let edge = GeometryRef::new("B", Entity::Edge("a|b".into())).unwrap(); + m.set_seed(Seed::new(edge, Divisions::Count(4), Some(Bias { ratio: 8.0, toward: BiasToward::Start }), SeedConstraint::Exact).unwrap()) + .unwrap(); + let mut s = Scene::default(); + seeds(&mut s, &m, &names, &d, &Tokens::dark()); + let xs: Vec = s.points.iter().map(|p| p.p.x).collect(); + let want = [0.0, 10.0 / 15.0, 30.0 / 15.0, 70.0 / 15.0, 10.0]; + assert_eq!(xs.len(), 5); + assert!(xs.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "{xs:?}"); + assert!(s.points.iter().all(|p| p.p.y == 0.0), "only the seeded edge has ticks"); + } + #[test] fn styles_choose_faces_edges_and_nodes() { let mut m = Model::default(); diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs index 354cbec..1ffa9dc 100644 --- a/crates/ui-egui/src/selection.rs +++ b/crates/ui-egui/src/selection.rs @@ -279,7 +279,7 @@ impl BodyNames { } /// The reference to B-rep edge `edge` of this body, when it has a name. - fn edge(&self, edge: u32) -> Option { + pub fn edge(&self, edge: u32) -> Option { GeometryRef::new(self.name.clone(), Entity::Edge(self.edges.get(edge as usize)?.clone())).ok() } } @@ -786,6 +786,8 @@ pub enum Target { /// A set made from the selection, named in `set` (sections, couplings, surface and body /// loads). Set, + /// The selected geometry edges, as `edges: [{body, edge}]` (edge seeds). + Edges, } /// Where command `id`'s form takes the selection, if it does. `params` (the form's current @@ -793,6 +795,7 @@ pub enum Target { pub fn target(id: &str, params: &Value) -> Option { match id { "model.set.create" | "model.set.edit" => Some(Target::Members), + "mesh.seed.edge" | "mesh.seed.clear" => Some(Target::Edges), "model.boundary_condition.create" | "model.boundary_condition.edit" => Some(Target::TargetSet), "model.section.create" | "model.section.edit" | "model.coupling.create" | "model.coupling.edit" => Some(Target::Set), "model.load.create" | "model.load.edit" => { @@ -815,6 +818,25 @@ pub fn with_members(params: &Value, selection: &Selection) -> Value { Value::Object(out) } +/// `params` with the selection's geometry edges as `edges` (replacing any it had); `None` when no +/// edge is selected. +pub fn with_edges(params: &Value, selection: &Selection) -> Option { + let edges: Vec = selection + .items() + .iter() + .filter_map(|i| match i { + Item::Geometry(g) if matches!(g.entity, Entity::Edge(_)) => Some(json!(g)), + _ => None, + }) + .collect(); + if edges.is_empty() { + return None; + } + let mut out = params.as_object().cloned().unwrap_or_default(); + out.insert("edges".into(), Value::Array(edges)); + Some(Value::Object(out)) +} + /// `params` with the set `set` as the target. pub fn with_set(params: &Value, target: Target, set: &str) -> Value { let mut out = params.as_object().cloned().unwrap_or_default(); @@ -825,7 +847,7 @@ pub fn with_set(params: &Value, target: Target, set: &str) -> Value { Target::Set => { out.insert("set".into(), json!(set)); } - Target::Members => {} + Target::Members | Target::Edges => {} } Value::Object(out) }