forked from 1b-agent/fieldcraft-upstream
engine: a mesh made before its seeds changed is out of date; a remesh names a type change
The setup checklist marks the mesh Outdated (a warm ⚠ in the browser, with what changed and where to remesh) when seeds, partitions or datum planes changed after the last mesher ran, and model.check warns (#342). Replacing a mesh asks with the element type change it makes, such as tet10 → tet4 (#343). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
464a4d50c1
commit
3eaca26246
4 files changed
+107
-6
No files matched your search
@@ -55,7 +55,10 @@ fn units(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn check(s: &mut Session, _: &Value) -> Result<Value> {
|
fn check(s: &mut Session, _: &Value) -> Result<Value> {
|
||||||
let diagnostics = s.model.check();
|
let mut diagnostics = s.model.check();
|
||||||
|
if let Some(why) = s.mesh_outdated() {
|
||||||
|
diagnostics.push(fieldcraft_model::Diagnostic { severity: Severity::Warning, subject: "mesh".into(), message: why });
|
||||||
|
}
|
||||||
let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error).count();
|
let errors = diagnostics.iter().filter(|d| d.severity == Severity::Error).count();
|
||||||
let first_error = diagnostics.iter().find(|d| d.severity == Severity::Error).map(ToString::to_string);
|
let first_error = diagnostics.iter().find(|d| d.severity == Severity::Error).map(ToString::to_string);
|
||||||
let progress = crate::progress::to_json(&s.progress_given(first_error));
|
let progress = crate::progress::to_json(&s.progress_given(first_error));
|
||||||
|
|||||||
@@ -102,6 +102,28 @@ fn design_bodies(s: &Session) -> Result<Vec<String>> {
|
|||||||
Ok(design.bodies.iter().map(|b| b.name.clone()).collect())
|
Ok(design.bodies.iter().map(|b| b.name.clone()).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tetrahedra and hexahedra of either order: the kinds an order change swaps.
|
||||||
|
fn family(k: ElementKind) -> Option<u8> {
|
||||||
|
match k {
|
||||||
|
ElementKind::Tet4 | ElementKind::Tet10 => Some(0),
|
||||||
|
ElementKind::Hex8 | ElementKind::Hex20 => Some(1),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The solid element type `command` makes with `params`, when it makes one type.
|
||||||
|
fn made_kind(command: &str, params: &Value) -> Option<ElementKind> {
|
||||||
|
match command {
|
||||||
|
"mesh.tet.generate" => {
|
||||||
|
Some(if params.get("order").and_then(Value::as_str) == Some("quadratic") { ElementKind::Tet10 } else { ElementKind::Tet4 })
|
||||||
|
}
|
||||||
|
"mesh.hex.generate" | "mesh.hex.sweep" | "mesh.hex.mapped" | "mesh.hex.blocks" => {
|
||||||
|
Some(if params.get("order").and_then(Value::as_i64) == Some(2) { ElementKind::Hex20 } else { ElementKind::Hex8 })
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What running a meshing command would replace, for a confirmation before it runs.
|
/// What running a meshing command would replace, for a confirmation before it runs.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct Replacement {
|
pub struct Replacement {
|
||||||
@@ -141,6 +163,14 @@ pub fn replacement(s: &Session, command: &str, params: &Value) -> Option<Replace
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut warnings = Vec::new();
|
let mut warnings = Vec::new();
|
||||||
|
// The element type changing (tet10 → tet4 when the order is left out) is easy to miss.
|
||||||
|
if let Some(new) = made_kind(command, params) {
|
||||||
|
let old: BTreeSet<ElementKind> = removed.elements.iter().filter_map(|l| s.model.element(*l)).map(|e| e.kind).collect();
|
||||||
|
let changed: Vec<&str> = old.iter().filter(|k| **k != new && family(**k) == family(new)).map(|k| k.name()).collect();
|
||||||
|
if !changed.is_empty() {
|
||||||
|
warnings.push(format!("The element type changes: {} → {}.", changed.join(", "), new.name()));
|
||||||
|
}
|
||||||
|
}
|
||||||
let stale = s.model.sets_using(&removed);
|
let stale = s.model.sets_using(&removed);
|
||||||
if !stale.is_empty() {
|
if !stale.is_empty() {
|
||||||
warnings.push(format!("Sets that list its nodes or elements by label ({}) will name labels of the old mesh.", stale.join(", ")));
|
warnings.push(format!("Sets that list its nodes or elements by label ({}) will name labels of the old mesh.", stale.join(", ")));
|
||||||
@@ -194,6 +224,17 @@ mod tests {
|
|||||||
(dir, s)
|
(dir, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Remeshing without the order would turn tet10 into tet4: the question says so (#343).
|
||||||
|
#[test]
|
||||||
|
fn an_element_type_change_is_named() {
|
||||||
|
let (_dir, mut s) = two_boxes();
|
||||||
|
s.execute("mesh.tet.generate", &json!({"body": "Box", "size": 5, "order": "quadratic"})).unwrap();
|
||||||
|
let r = replacement(&s, "mesh.tet.generate", &json!({"body": "Box", "size": 4})).unwrap();
|
||||||
|
assert!(r.warnings.iter().any(|w| w == "The element type changes: tet10 → tet4."), "{r:?}");
|
||||||
|
let same = replacement(&s, "mesh.tet.generate", &json!({"body": "Box", "order": "quadratic"})).unwrap();
|
||||||
|
assert!(same.warnings.iter().all(|w| !w.contains("element type")), "{same:?}");
|
||||||
|
}
|
||||||
|
|
||||||
/// A body's elements, by label.
|
/// A body's elements, by label.
|
||||||
fn elements_of(s: &Session, body: &str) -> BTreeMap<u32, Element> {
|
fn elements_of(s: &Session, body: &str) -> BTreeMap<u32, Element> {
|
||||||
s.model.body_elements(body).into_iter().filter_map(|l| Some((l, s.model.element(l)?.clone()))).collect()
|
s.model.body_elements(body).into_iter().filter_map(|l| Some((l, s.model.element(l)?.clone()))).collect()
|
||||||
|
|||||||
@@ -97,6 +97,9 @@ pub enum Status {
|
|||||||
Blocked,
|
Blocked,
|
||||||
/// Not done, and not needed for this model.
|
/// Not done, and not needed for this model.
|
||||||
Optional,
|
Optional,
|
||||||
|
/// Done, but what it was made from changed since (`detail` says what): a mesh made before
|
||||||
|
/// its seeds or partitions changed.
|
||||||
|
Outdated,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the checklist.
|
/// One row of the checklist.
|
||||||
@@ -148,7 +151,10 @@ impl Session {
|
|||||||
_ if stage == Stage::Solve => first_error.clone(),
|
_ if stage == Stage::Solve => first_error.clone(),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let (status, detail) = if done {
|
let outdated = if done && stage == Stage::Mesh { self.mesh_outdated() } else { None };
|
||||||
|
let (status, detail) = if let Some(why) = outdated {
|
||||||
|
(Status::Outdated, Some(why))
|
||||||
|
} else if done {
|
||||||
(Status::Done, None)
|
(Status::Done, None)
|
||||||
} else if optional {
|
} else if optional {
|
||||||
(Status::Optional, None)
|
(Status::Optional, None)
|
||||||
@@ -167,6 +173,35 @@ impl Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
/// Why the mesh no longer matches what steers it: seeds, partitions or the datums they cut
|
||||||
|
/// by changed after the last mesher ran (the journal says what ran when). `None` when it
|
||||||
|
/// matches, or no mesher made it.
|
||||||
|
pub fn mesh_outdated(&self) -> Option<String> {
|
||||||
|
let mesher = self.journal.iter().rposition(|(c, _)| crate::MESHERS.iter().any(|(m, _)| m == c))?;
|
||||||
|
let (command, _) = self.journal.get(mesher)?;
|
||||||
|
let mut changed = Vec::new();
|
||||||
|
for (c, _) in self.journal.get(mesher + 1..).unwrap_or_default() {
|
||||||
|
let what = match c.as_str() {
|
||||||
|
"mesh.seed.edge" | "mesh.seed.clear" => "Seeds",
|
||||||
|
"geometry.partition" | "geometry.partition.delete" => "Partitions",
|
||||||
|
"geometry.datum.edit" => "Datum planes",
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
if !changed.contains(&what) {
|
||||||
|
changed.push(what);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let place = crate::catalogue::entry_of(command).map_or_else(|| command.clone(), |e| format!("{} › {}", e.tab.label(), e.label));
|
||||||
|
let what = match changed.as_slice() {
|
||||||
|
[] => return None,
|
||||||
|
[one] => one.to_string(),
|
||||||
|
[first @ .., last] => format!("{} and {}", first.join(", "), last.to_lowercase()),
|
||||||
|
};
|
||||||
|
Some(format!("{what} changed since this mesh was made — {place} to remesh"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The checklist as JSON, with the next stage's command first for agents:
|
/// The checklist as JSON, with the next stage's command first for agents:
|
||||||
/// `{"next": {"stage", "command"} | null, "stages": [...]}`.
|
/// `{"next": {"stage", "command"} | null, "stages": [...]}`.
|
||||||
pub fn to_json(stages: &[StageState]) -> Value {
|
pub fn to_json(stages: &[StageState]) -> Value {
|
||||||
@@ -247,6 +282,21 @@ mod tests {
|
|||||||
assert_eq!(status(&s, Stage::Solve), Next, "{:?}", s.progress());
|
assert_eq!(status(&s, Stage::Solve), Next, "{:?}", s.progress());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A seed added after meshing marks the mesh out of date, until a remesh (#342).
|
||||||
|
#[test]
|
||||||
|
fn a_seed_after_the_mesh_marks_it_outdated() {
|
||||||
|
let mut s = Session::default();
|
||||||
|
s.execute("file.sample", &json!({"name": "bracket-hex"})).unwrap();
|
||||||
|
assert_eq!(status(&s, Stage::Mesh), Done);
|
||||||
|
let edge = json!({"body": "Bracket (1) (1)' (1)", "edge": "F17:face:5|F2:side:l1"});
|
||||||
|
s.execute("mesh.seed.edge", &json!({"edges": [edge], "count": 6})).unwrap();
|
||||||
|
assert_eq!(status(&s, Stage::Mesh), Outdated);
|
||||||
|
let why = s.mesh_outdated().unwrap();
|
||||||
|
assert!(why.starts_with("Seeds changed since this mesh was made — Mesh › "), "{why}");
|
||||||
|
let v = s.execute("model.check", &json!({})).unwrap();
|
||||||
|
assert!(v.to_string().contains("Seeds changed since this mesh was made"), "{v}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn model_check_returns_the_checklist() {
|
fn model_check_returns_the_checklist() {
|
||||||
let mut s = Session::default();
|
let mut s = Session::default();
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ fn node_stages(node: &str) -> &'static [Stage] {
|
|||||||
fn stage_tip(s: &StageState) -> Option<String> {
|
fn stage_tip(s: &StageState) -> Option<String> {
|
||||||
match s.status {
|
match s.status {
|
||||||
Status::Next => Some(format!("Next: {} — {}", s.label, s.place)),
|
Status::Next => Some(format!("Next: {} — {}", s.label, s.place)),
|
||||||
Status::Blocked => Some(s.detail.clone().unwrap_or_else(|| s.place.clone())),
|
Status::Blocked | Status::Outdated => Some(s.detail.clone().unwrap_or_else(|| s.place.clone())),
|
||||||
Status::Todo => Some(format!("{} still to do — {}", s.label, s.place)),
|
Status::Todo => Some(format!("{} still to do — {}", s.label, s.place)),
|
||||||
Status::Done | Status::Optional => None,
|
Status::Done | Status::Optional => None,
|
||||||
}
|
}
|
||||||
@@ -75,8 +75,9 @@ fn stage_tip(s: &StageState) -> Option<String> {
|
|||||||
/// else done; `None` for nodes that are no setup stage.
|
/// else done; `None` for nodes that are no setup stage.
|
||||||
fn node_state(stages: &[StageState], node: &str) -> Option<(Status, String)> {
|
fn node_state(stages: &[StageState], node: &str) -> Option<(Status, String)> {
|
||||||
let own: Vec<&StageState> = stages.iter().filter(|s| node_stages(node).contains(&s.stage)).collect();
|
let own: Vec<&StageState> = stages.iter().filter(|s| node_stages(node).contains(&s.stage)).collect();
|
||||||
let status =
|
let status = [Status::Outdated, Status::Next, Status::Blocked, Status::Todo, Status::Done, Status::Optional]
|
||||||
[Status::Next, Status::Blocked, Status::Todo, Status::Done, Status::Optional].into_iter().find(|st| own.iter().any(|s| s.status == *st))?;
|
.into_iter()
|
||||||
|
.find(|st| own.iter().any(|s| s.status == *st))?;
|
||||||
let tip: Vec<String> = own.iter().filter_map(|s| stage_tip(s)).collect();
|
let tip: Vec<String> = own.iter().filter_map(|s| stage_tip(s)).collect();
|
||||||
let tip = if tip.is_empty() { "Done".to_string() } else { tip.join("\n") };
|
let tip = if tip.is_empty() { "Done".to_string() } else { tip.join("\n") };
|
||||||
Some((status, tip))
|
Some((status, tip))
|
||||||
@@ -606,7 +607,7 @@ fn item_row(app: &mut ModelApp, ui: &mut egui::Ui, id: egui::Id, mut row: Row, t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ✓ done (success), → next (accent), ⚠ blocked (muted); nothing for the rest.
|
/// ✓ done (success), → next (accent), ⚠ blocked (muted), ⚠ out of date (warm); nothing for the rest.
|
||||||
fn paint_mark(p: &egui::Painter, r: Rect, status: Status, t: &Tokens) {
|
fn paint_mark(p: &egui::Painter, r: Rect, status: Status, t: &Tokens) {
|
||||||
let at = |x: f32, y: f32| Pos2::new(r.left() + x * r.width(), r.top() + y * r.height());
|
let at = |x: f32, y: f32| Pos2::new(r.left() + x * r.width(), r.top() + y * r.height());
|
||||||
match status {
|
match status {
|
||||||
@@ -627,6 +628,12 @@ fn paint_mark(p: &egui::Painter, r: Rect, status: Status, t: &Tokens) {
|
|||||||
p.line_segment([at(0.5, 0.35), at(0.5, 0.65)], s);
|
p.line_segment([at(0.5, 0.35), at(0.5, 0.65)], s);
|
||||||
p.circle_filled(at(0.5, 0.84), 0.7, t.text_dim);
|
p.circle_filled(at(0.5, 0.84), 0.7, t.text_dim);
|
||||||
}
|
}
|
||||||
|
Status::Outdated => {
|
||||||
|
let s = Stroke::new(1.3, t.warm);
|
||||||
|
p.add(egui::Shape::closed_line(vec![at(0.5, 0.0), at(1.05, 1.0), at(-0.05, 1.0)], s));
|
||||||
|
p.line_segment([at(0.5, 0.35), at(0.5, 0.65)], s);
|
||||||
|
p.circle_filled(at(0.5, 0.84), 0.8, t.warm);
|
||||||
|
}
|
||||||
Status::Todo | Status::Optional => {}
|
Status::Todo | Status::Optional => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user