diff --git a/crates/engine/src/cancel_tests.rs b/crates/engine/src/cancel_tests.rs index d9320dd..5e48430 100644 --- a/crates/engine/src/cancel_tests.rs +++ b/crates/engine/src/cancel_tests.rs @@ -18,9 +18,15 @@ const PROMPT: Duration = Duration::from_secs(5); /// (mid-run), or before it starts (`None`): it must fail with `cancelled`, soon after, leaving /// the model and the journal as they were. fn cancel(s: &mut Session, command: &str, params: Value, stage: Option<&str>) { + cancel_when(s, command, params, stage.map(|stage| move |now: &progress::State| now.stage.starts_with(stage) && now.done > 0.0)); +} + +/// [`cancel`] once the reported state passes `when` (before it starts with `None`). +fn cancel_when(s: &mut Session, command: &str, params: Value, when: Option bool>) { let (model, journal) = (s.model.clone(), s.journal.len()); + let design = s.design.as_ref().map(|d| d.bodies.iter().map(|b| b.name.clone()).collect::>()); let p = Progress::new(); - if stage.is_none() { + if when.is_none() { p.cancel(); } let (r, waited) = std::thread::scope(|scope| { @@ -31,18 +37,18 @@ fn cancel(s: &mut Session, command: &str, params: Value, stage: Option<&str>) { scope.spawn(move || progress::scoped(Some(p), || s.execute(command, ¶ms))) }; let mut at = Instant::now(); - if let Some(stage) = stage { + if let Some(when) = &when { let deadline = Instant::now() + Duration::from_secs(600); while !worker.is_finished() && Instant::now() < deadline { let now = p.snapshot(); - if now.stage.starts_with(stage) && now.done > 0.0 { + if when(&now) { at = Instant::now(); p.cancel(); break; } std::thread::sleep(Duration::from_millis(1)); } - assert!(p.is_cancelled(), "{command}: never reached stage `{stage}` ({:?})", p.snapshot()); + assert!(p.is_cancelled(), "{command}: never reached the stage ({:?})", p.snapshot()); } let r = worker.join().unwrap(); (r, at.elapsed()) @@ -53,6 +59,7 @@ fn cancel(s: &mut Session, command: &str, params: Value, stage: Option<&str>) { assert!(waited < PROMPT, "{command}: stopped {waited:?} after the cancel"); assert!(s.model == model, "{command}: the model changed"); assert_eq!(s.journal.len(), journal, "{command}: journalled"); + assert_eq!(s.design.as_ref().map(|d| d.bodies.iter().map(|b| b.name.clone()).collect::>()), design, "{command}: the design changed"); } /// A sample's script run on a fresh session. @@ -128,3 +135,24 @@ fn meshers_stop_and_leave_no_mesh() { cancel(&mut s, "mesh.hex.octree", json!({"body": "Body1", "size": 5.0, "max_depth": 2}), None); assert!(s.model.nodes().is_empty()); } + +/// The partition, the cells mesher and the plan's apply (#545) stop at their stages: between +/// the kernel's cuts, between cells, between the projection's phases. +#[test] +fn partition_cells_and_plan_apply_stop_and_change_nothing() { + let (_dir, mut s) = plate(); + s.execute("geometry.datum", &json!({"name": "X30", "by": {"type": "offset", "plane": "yz", "offset": 30}})).unwrap(); + s.execute("geometry.datum", &json!({"name": "Y5", "by": {"type": "offset", "plane": "zx", "offset": 5}})).unwrap(); + let plan = s.execute("mesh.plan", &json!({"body": "Body1"})).unwrap(); + cancel(&mut s, "mesh.plan.apply", json!({"plan": plan["name"]}), None); + cancel_when(&mut s, "mesh.plan.apply", json!({"plan": plan["name"]}), Some(|now: &progress::State| now.stage.starts_with("Projection: "))); + cancel(&mut s, "geometry.partition", json!({"name": "Half", "datum": "X30"}), None); + s.execute("geometry.partition", &json!({"name": "Half", "datum": "X30"})).unwrap(); + // The second partition cuts again by both: cancelled once the first cut is made. + cancel(&mut s, "geometry.partition", json!({"name": "Strip", "datum": "Y5"}), Some("Partition cuts")); + let cells = json!({"bodies": ["Body1/a", "Body1/b"], "default": "tet", "size": 0.5}); + cancel(&mut s, "mesh.cells", cells.clone(), None); + // Inside a cell's tet mesher: the cancel reaches the command, never a fallback. + cancel(&mut s, "mesh.cells", cells, Some("Refinement round")); + assert!(s.model.nodes().is_empty()); +} diff --git a/crates/engine/src/cmd/cells.rs b/crates/engine/src/cmd/cells.rs index 1ab6f42..c5fd190 100644 --- a/crates/engine/src/cmd/cells.rs +++ b/crates/engine/src/cmd/cells.rs @@ -201,6 +201,8 @@ fn cells(s: &mut Session, p: &Value) -> Result { if members.len() < 2 { continue; } + fieldcraft_model::progress::stage(&format!("Cells: sweeping {source}'s cells together"), None, ""); + fieldcraft_model::progress::check()?; let inputs: Vec = members .iter() .filter_map(|&i| shapes.get(i)) @@ -227,6 +229,9 @@ fn cells(s: &mut Session, p: &Value) -> Result { if joint.get(i).copied().flatten().is_some() { continue; } + fieldcraft_model::progress::stage(&format!("Cells: meshing {}", shape.name), Some(shapes.len() as f64), "cells"); + fieldcraft_model::progress::count(i as f64); + fieldcraft_model::progress::check()?; let method = method_of(&shape.name); let input = || Ok::<_, EngineError>(Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? }); let hex = |m: HexMethod| -> Result<(Input<'static>, HexMesh)> { @@ -246,6 +251,8 @@ fn cells(s: &mut Session, p: &Value) -> Result { let info = json!({"method": mesh.report.method.name()}); (Meshed::Hex(Box::new((b, mesh))), info) } + // A cancelled job stops; any other failure falls back to tet. + Err(e) if e.is_cancelled() => return Err(e), Err(e) => (Meshed::Tet, json!({"method": "tet", "why": e.to_string()})), }, }; @@ -301,6 +308,9 @@ fn cells(s: &mut Session, p: &Value) -> Result { } for (i, mut info) in tet_cells { let Some(shape) = shapes.get(i) else { continue }; + fieldcraft_model::progress::stage(&format!("Cells: meshing {} (tet)", shape.name), Some(shapes.len() as f64), "cells"); + fieldcraft_model::progress::count(i as f64); + fieldcraft_model::progress::check()?; let input = Input { seeds: seeds.clone(), ..super::hex::shape_input(shape, Some(tolerance))? }; let pyramids = if transition == Transition::Tie { Err("`transition` is \"tie\"".to_string()) @@ -348,6 +358,8 @@ fn cells(s: &mut Session, p: &Value) -> Result { .filter(|r| r.get("kind").and_then(Value::as_str).is_some_and(|k| k.starts_with("tet"))) .filter_map(|r| r.get("body").and_then(Value::as_str).map(String::from)) .collect(); + fieldcraft_model::progress::stage("Cells: joining the cells", None, ""); + fieldcraft_model::progress::check()?; interfaces.extend(join(model, &names, &tet, COINCIDENT * diagonal)?); explain_ties(&design, &report, &mut interfaces); let (nodes, elements) = (model.nodes().len(), model.elements().len()); diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index f28387b..b5ad779 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -527,7 +527,10 @@ fn partition(s: &mut Session, p: &Value) -> Result { let cells: Vec = design.bodies.iter().map(|b| b.name.clone()).filter(|b| split.iter().any(|x| b.starts_with(&format!("{x}/")))).collect(); let (faces, bodies): (usize, Vec) = (report.map_or(0, |r| r.faces), design.bodies.iter().map(|b| b.name.clone()).collect()); let mut warnings: Vec = report.map(|r| r.warnings.clone()).unwrap_or_default(); - for cell in &cells { + fieldcraft_model::progress::stage("Partition: checking the cells", Some(cells.len() as f64), "cells"); + for (k, cell) in cells.iter().enumerate() { + fieldcraft_model::progress::count(k as f64); + fieldcraft_model::progress::check()?; if let Some(why) = design.body(cell).and_then(|shape| unmeshable(shape, None).err()) { warnings.push(format!( "partition `{name}`: cell `{cell}` has a boundary no mesher takes ({why}): the plane passes within the kernel's tolerance of a face, edge or vertex it cut (along a face, through the axis of a hole); move the plane off it (by a fraction of the element size)" diff --git a/crates/engine/src/cmd/plan.rs b/crates/engine/src/cmd/plan.rs index 3260fd4..1aa65b4 100644 --- a/crates/engine/src/cmd/plan.rs +++ b/crates/engine/src/cmd/plan.rs @@ -1133,6 +1133,8 @@ fn apply(s: &mut Session, p: &Value) -> Result { // The copies: each meshed by a plan of its own at this plan's size, saved beside it. let mut copies = Vec::with_capacity(plan.copies.len()); for copy in &plan.copies { + fieldcraft_model::progress::stage(&format!("Plan: meshing the copy {copy}"), None, ""); + fieldcraft_model::progress::check()?; let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; if design.source_body(copy).is_none() { return Err(EngineError::Params(format!("mesh plan `{name}`: no copy `{copy}` in the design"))); @@ -1250,7 +1252,13 @@ fn apply_one(s: &mut Session, name: &str, plan: &MeshPlan) -> Result { let body = cells.first().cloned().unwrap_or_else(|| plan.body.clone()); (super::hex::HEX_GENERATE.run)(s, &json!({"body": body, "size": plan.size, "method": "swept", "tolerance": tolerance})) } - .map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the cells {cells:?} (cuts {made:?}) do not mesh all-hex: {e}")))?; + .map_err(|e| { + if e.is_cancelled() { + e + } else { + EngineError::Params(format!("mesh plan `{name}`: the cells {cells:?} (cuts {made:?}) do not mesh all-hex: {e}")) + } + })?; let summary = summary(&mesh, &cells); // What today's meshers cannot express yet: the rings round circles are built by the // projection mesher (#399); until then the frame cuts box the circle and the sweep meshes the @@ -1446,6 +1454,8 @@ fn apply_symmetric(s: &mut Session, name: &str, plan: &MeshPlan, corners: bool) let cells: Vec = design.bodies.iter().filter(|b| design.source_of(&b.name) == Some(plan.body.as_str())).map(|b| b.name.clone()).collect(); let meshed_first = meshed.clone(); for (a, c) in &planes { + fieldcraft_model::progress::stage("Plan: mirroring the cell", None, ""); + fieldcraft_model::progress::check()?; (super::mirror::MIRROR.run)(s, &json!({"plane": {"point": a.map(|x| x * c), "normal": a}, "bodies": meshed}))?; meshed = cells.iter().filter(|n| !s.model.body_elements(n).is_empty()).cloned().collect(); } @@ -1632,6 +1642,7 @@ fn copy_corner( } Ok(Ok(copy)) } + Err(e) if e.is_cancelled() => Err(e), // A corner copy that does not mesh leaves the cell to be meshed whole. Err(e) => { s.restore(before); @@ -1663,9 +1674,13 @@ fn corner_meshes( // planar, the corner taken from that mesh: independent meshes, made side by side. The best // corner wins. let whole = &whole_body; - let runs = fieldcraft_parallel::map(candidates.len() + 1, |k| match k.checked_sub(1).and_then(|i| candidates.get(i)) { - None => super::project::project_only(whole, cell, t0, &opts).map(|m| (m.report.min_scaled_jacobian, None)), - Some(&x1) => corner_of_whole(whole, cell, t0, a, [x1, 2.0 * c - x1], &opts).map(|r| (r.sj, Some(([x1, 2.0 * c - x1], r)))), + // The job's progress on the pool's threads too, so a cancel stops the candidates. + let progress = fieldcraft_model::progress::current(); + let runs = fieldcraft_parallel::map(candidates.len() + 1, |k| { + fieldcraft_model::progress::scoped(progress.clone(), || match k.checked_sub(1).and_then(|i| candidates.get(i)) { + None => super::project::project_only(whole, cell, t0, &opts).map(|m| (m.report.min_scaled_jacobian, None)), + Some(&x1) => corner_of_whole(whole, cell, t0, a, [x1, 2.0 * c - x1], &opts).map(|r| (r.sj, Some(([x1, 2.0 * c - x1], r)))), + }) }); let mut reference: Option = None; let mut best: Option<(f64, [f64; 2], CornerOfWhole)> = None; @@ -1677,6 +1692,7 @@ fn corner_meshes( best = Some((sj, at, corner)); } } + Err(e) if e.is_cancelled() => return Err(e), Err(_) => {} } } @@ -1873,8 +1889,9 @@ fn apply_projection(s: &mut Session, name: &str, plan: &MeshPlan, tolerance: f64 let (fixed, fixed_faces) = cut_face_patches(s, &body, &plan.body, plan.size)?; let field = super::sizes::sizing(s)?.local_field(plan.size)?; let opts = fieldcraft_mesh::project::ProjectOptions { size: plan.size, fixed, field, ..Default::default() }; - let mut mesh = super::project::project_body(s, &plan.body, body, &layout.topology, &opts, merge) - .map_err(|e| EngineError::Params(format!("mesh plan `{name}`: the projected layout does not mesh: {e}")))?; + let mut mesh = super::project::project_body(s, &plan.body, body, &layout.topology, &opts, merge).map_err(|e| { + if e.is_cancelled() { e } else { EngineError::Params(format!("mesh plan `{name}`: the projected layout does not mesh: {e}")) } + })?; if let Some(m) = mesh.as_object_mut() { // The block listing is long; the plan reports the layout instead. m.remove("topology"); @@ -1946,6 +1963,8 @@ fn cut( let cleared = super::datum::recut(s)?; let mut skipped: Vec<(String, String)> = Vec::new(); for _ in 0..=names.len() { + fieldcraft_model::progress::stage("Plan: checking the cells", None, ""); + fieldcraft_model::progress::check()?; let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let live: Vec<&String> = names.iter().filter(|n| !skipped.iter().any(|(k, _)| k == *n)).collect(); let mut failed: Vec<(String, String)> = live