UI: seeds as ticks along their edges, Mesh › Seeds, and seeding the selection

A seed draws a tick at each node it puts on its edge, so a bias shows as ticks
crowding toward an end. The browser lists the seeds under Mesh; a click
selects the seeded edge. The Seed Edges form takes the selected edges with
Use selection. The toolbar entry is ready (its icon renamed with it).

Part of #230.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 20:58:17 -04:00
1 parent e8cada94d0
commit 8df4ea040c
9 files changed
+134 -4

No files matched your search

+1 -1
View File
@@ -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 |
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<title>mesh.size.seed</title>
<title>mesh.seed.edge</title>
<g id="Layer_1" data-name="Layer 1">
<path d="M3 19 C7 5 17 5 21 19" fill="none" stroke="#dde2ea" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.7 19 C1.7 18.282 2.282 17.7 3 17.7 C3.718 17.7 4.3 18.282 4.3 19 C4.3 19.718 3.718 20.3 3 20.3 C2.282 20.3 1.7 19.718 1.7 19 Z" fill="#4f9ce8"/>
File renamed without changes.
+48
View File
@@ -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<T: serde::Serialize>(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<Act> = Vec::new();
@@ -485,6 +499,40 @@ fn rows(app: &mut ModelApp, ui: &mut egui::Ui, acts: &mut Vec<Act>) {
&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] = [
+1
View File
@@ -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 {
+4
View File
@@ -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, &params) else { return Err(format!("{command} takes no selection")) };
let filled = match target {
selection::Target::Members => selection::with_members(&params, &self.pick.selection),
selection::Target::Edges => {
selection::with_edges(&params, &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())?;
+55
View File
@@ -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<f64> = 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<f64> = 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();
+24 -2
View File
@@ -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<GeometryRef> {
pub fn edge(&self, edge: u32) -> Option<GeometryRef> {
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<Target> {
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<Value> {
let edges: Vec<Value> = 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)
}