From 6dda34e8379e6f08817bbf95e188a44e265d3e80 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Tue, 13 Oct 2026 19:53:25 -0400 Subject: [PATCH] simplify: joining in modelcraft-mesh::join, shared label helper, run_script returns step outputs Weld and face-to-face check move from the mesh.block command to modelcraft_mesh::join (bounding-box-limited weld, grid-binned hanging-node test), for node merging and imports to reuse. mesh.block and mesh.tet.generate share fresh_labels. The block map evaluates each edge curve once per lattice parameter; vector helpers come from vec3; tet10 and hex20 mid-side nodes follow ElementKind::edges. Session::run_script returns each step's output, which the oracle reads instead of its own loop. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --- crates/engine/src/cmd/block.rs | 232 +++++---------------------------- crates/engine/src/cmd/mesh.rs | 17 +-- crates/engine/src/cmd/mod.rs | 13 ++ crates/engine/src/cmd/solve.rs | 19 ++- crates/engine/src/lib.rs | 9 +- crates/mesh/src/block.rs | 146 ++++++++++----------- crates/mesh/src/join.rs | 195 +++++++++++++++++++++++++++ crates/mesh/src/lib.rs | 1 + crates/testkit/src/corpus.rs | 2 +- crates/testkit/src/oracle.rs | 92 +++++-------- crates/ui-egui/src/lib.rs | 2 +- 11 files changed, 369 insertions(+), 359 deletions(-) create mode 100644 crates/mesh/src/join.rs diff --git a/crates/engine/src/cmd/block.rs b/crates/engine/src/cmd/block.rs index 00aeacc..f8892af 100644 --- a/crates/engine/src/cmd/block.rs +++ b/crates/engine/src/cmd/block.rs @@ -4,11 +4,13 @@ use std::collections::{BTreeMap, BTreeSet}; -use modelcraft_mesh::block::{Block, BlockMesh, Curve, block}; -use modelcraft_model::{ElementKind, Entity, GeometryRef, MAX_LABEL, Model}; +use modelcraft_mesh::block::{Block, Curve, block}; +use modelcraft_mesh::join::{check_conforming, weld}; +use modelcraft_model::{ElementKind, Entity, GeometryRef, Model}; use serde::Deserialize; use serde_json::{Value, json}; +use super::fresh_labels; use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; pub const BLOCK: CommandSpec = CommandSpec { @@ -97,16 +99,15 @@ fn block_mesh(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Params(format!("edge {:?} gives neither a curve nor a name", e.corners))); } } - let faces: Vec<(usize, String)> = p.faces.iter().map(|(k, n)| Ok((numbered(k, 6, "face")?, n.clone()))).collect::>()?; - let vertices: Vec<(usize, String)> = p.vertices.iter().map(|(k, n)| Ok((numbered(k, 8, "vertex")?, n.clone()))).collect::>()?; // Check every name before meshing. let body = GeometryRef::body(p.body.clone())?; - let face_refs: Vec<(usize, GeometryRef)> = - faces.iter().map(|(k, n)| Ok((*k, GeometryRef::face(p.body.clone(), n.clone())?))).collect::>()?; + let named = |entries: &BTreeMap, n: usize, what: &str, entity: fn(String) -> Entity| -> Result> { + entries.iter().map(|(k, name)| Ok((numbered(k, n, what)?, GeometryRef::new(p.body.clone(), entity(name.clone()))?))).collect() + }; + let face_refs = named(&p.faces, 6, "face", Entity::Face)?; + let vertex_refs = named(&p.vertices, 8, "vertex", Entity::Vertex)?; let edge_refs: Vec<(usize, GeometryRef)> = - edge_names.iter().map(|(k, n)| Ok((*k, GeometryRef::new(p.body.clone(), Entity::Edge(n.clone()))?))).collect::>()?; - let vertex_refs: Vec<(usize, GeometryRef)> = - vertices.iter().map(|(k, n)| Ok((*k, GeometryRef::new(p.body.clone(), Entity::Vertex(n.clone()))?))).collect::>()?; + edge_names.into_iter().map(|(k, n)| Ok((k, GeometryRef::new(p.body.clone(), Entity::Edge(n))?))).collect::>()?; let mesh = block(&Block { kind, corners: p.corners, divisions: p.divisions, bias: p.bias, curves })?; let diagonal = { @@ -118,62 +119,49 @@ fn block_mesh(s: &mut Session, p: &Value) -> Result { if !(merge.is_finite() && merge >= 0.0 && merge <= 1e-2 * diagonal) { return Err(EngineError::Params(format!("`merge` must be from 0 to 1/100 of the block's diagonal ({diagonal})"))); } - - let next = |last: Option| last.map_or(Some(1), |l| l.checked_add(1)); - let first_node = next(s.model.nodes().keys().next_back().copied()); - let first_element = next(s.model.elements().keys().next_back().copied()); - let fits = |first: Option, count: usize| first.filter(|&f| u64::from(f) + count as u64 <= u64::from(MAX_LABEL) + 1); - let (Some(first_node), Some(first_element)) = (fits(first_node, mesh.points.len()), fits(first_element, mesh.elements.len())) else { - return Err(EngineError::Params(format!("the block needs labels beyond {MAX_LABEL}"))); - }; + let (first_node, first_element) = fresh_labels(&s.model, mesh.points.len(), mesh.elements.len())?; // Each block point: an existing node within `merge`, or a new label. - let existing = joined(&s.model, &mesh.points, merge)?; + let welded = weld(&s.model, &mesh.points, merge)?; let mut labels = Vec::with_capacity(mesh.points.len()); let mut added = 0u32; - for (i, xyz) in mesh.points.iter().enumerate() { - match existing.get(i).copied().flatten() { - Some(label) => labels.push(label), + for (xyz, existing) in mesh.points.iter().zip(&welded) { + let label = match existing { + Some(label) => *label, None => { - let label = first_node + added; - s.model.set_node(label, *xyz)?; - labels.push(label); added += 1; + s.model.set_node(first_node + added - 1, *xyz)?; + first_node + added - 1 } - } - } - let merged: BTreeSet = existing.iter().flatten().copied().collect(); - let elements: Vec> = mesh.elements.iter().map(|e| e.iter().map(|&i| labels.get(i as usize).copied().unwrap_or(0)).collect()).collect(); - if !merged.is_empty() { - check_conforming(&s.model, kind, &mesh, &elements, &merged, merge)?; + }; + labels.push(label); } + let joined: BTreeSet = welded.iter().flatten().copied().collect(); + let label_of = |i: u32| labels.get(i as usize).copied().ok_or_else(|| EngineError::Params(format!("block point {i} has no node"))); + let elements: Vec<(ElementKind, Vec)> = + mesh.elements.iter().map(|e| Ok((kind, e.iter().map(|&i| label_of(i)).collect::>()?))).collect::>()?; + check_conforming(&s.model, &elements, &joined, merge)?; let body_tag = s.model.geometry_tag(&body)?; - for (i, nodes) in elements.into_iter().enumerate() { - let label = first_element + i as u32; + for (label, (_, nodes)) in (first_element..).zip(elements) { s.model.set_element(label, kind, nodes)?; s.model.tag_element(label, body_tag)?; } - for (k, r) in &face_refs { - let t = s.model.geometry_tag(r)?; - for &(e, f, bf) in &mesh.faces { - if usize::from(bf) == *k { - s.model.tag_element_face(first_element + e, f, t)?; - } - } - } - for (k, r) in &edge_refs { - let t = s.model.geometry_tag(r)?; - for &(e, edge, be) in &mesh.edges { - if usize::from(be) == *k { - s.model.tag_element_edge(first_element + e, edge, t)?; + for (refs, pieces, tag) in [ + (&face_refs, &mesh.faces, Model::tag_element_face as fn(&mut Model, u32, u8, u32) -> modelcraft_model::Result<()>), + (&edge_refs, &mesh.edges, Model::tag_element_edge), + ] { + for (k, r) in refs { + let t = s.model.geometry_tag(r)?; + for &(e, local, _) in pieces.iter().filter(|piece| usize::from(piece.2) == *k) { + tag(&mut s.model, first_element + e, local, t)?; } } } for (k, r) in &vertex_refs { let t = s.model.geometry_tag(r)?; - let node = mesh.corners.get(*k).and_then(|&i| labels.get(i as usize)).copied().unwrap_or(0); - s.model.tag_node(node, t)?; + let corner = mesh.corners.get(*k).copied().unwrap_or(u32::MAX); + s.model.tag_node(label_of(corner)?, t)?; } Ok(json!({ "body": p.body, @@ -182,158 +170,10 @@ fn block_mesh(s: &mut Session, p: &Value) -> Result { "first_element": first_element, "nodes": added, "first_node": first_node, - "joined": merged.len(), + "joined": joined.len(), })) } -/// For each block point, the existing node within `merge` of it (the nearest; the lowest label -/// on a tie). Two points may not join the same node. -fn joined(model: &Model, points: &[[f64; 3]], merge: f64) -> Result>> { - if merge == 0.0 || model.nodes().is_empty() { - return Ok(vec![None; points.len()]); - } - let cell = |x: f64| (x / merge).floor() as i64; - let mut grid: BTreeMap<[i64; 3], Vec<(u32, [f64; 3])>> = BTreeMap::new(); - for (&label, node) in model.nodes() { - grid.entry(node.xyz.map(cell)).or_default().push((label, node.xyz)); - } - let mut out = Vec::with_capacity(points.len()); - let mut taken: BTreeMap = BTreeMap::new(); - for (i, p) in points.iter().enumerate() { - let c = p.map(cell); - let mut best: Option<(f64, u32)> = None; - for dx in -1..=1 { - for dy in -1..=1 { - for dz in -1..=1 { - for &(label, xyz) in grid.get(&[c[0] + dx, c[1] + dy, c[2] + dz]).into_iter().flatten() { - let d = ((xyz[0] - p[0]).powi(2) + (xyz[1] - p[1]).powi(2) + (xyz[2] - p[2]).powi(2)).sqrt(); - if d <= merge && best.is_none_or(|(bd, bl)| d < bd || (d == bd && label < bl)) { - best = Some((d, label)); - } - } - } - } - } - if let Some((_, label)) = best - && let Some(other) = taken.insert(label, i) - { - return Err(EngineError::Params(format!( - "block points {other} and {i} both lie within `merge` of node {label}: lower `merge` or refine the block" - ))); - } - out.push(best.map(|(_, l)| l)); - } - Ok(out) -} - -/// Faces as sorted node lists. -fn key(nodes: impl Iterator) -> Vec { - let mut k: Vec = nodes.collect(); - k.sort_unstable(); - k -} - -/// Where the block joins existing nodes it must meet the mesh face to face: an element face -/// (of the block, or of the mesh) whose corners all landed on shared nodes must be a face of -/// the other side too, with the same nodes (no mixed linear and quadratic sides); and no node on -/// the block's boundary may lie on an edge of the mesh between two of its nodes, nor the other -/// way round (no hanging nodes where the divisions differ). Edges are tested as straight -/// segments, so a mismatch along a curved shared edge is caught only through its faces. -fn check_conforming(model: &Model, kind: ElementKind, mesh: &BlockMesh, elements: &[Vec], merged: &BTreeSet, merge: f64) -> Result<()> { - let refuse = |what: String| { - Err(EngineError::Params(format!("{what}: the block must meet the mesh face to face, with the same divisions and element kind"))) - }; - let corners_of = |k: ElementKind, nodes: &[u32], local: &[usize]| -> Vec { - key(local.iter().filter(|&&i| i < k.corner_count()).filter_map(|&i| nodes.get(i).copied())) - }; - // The block's boundary faces, by sorted corners, with their full node lists. - let mut new_faces: BTreeMap, Vec> = BTreeMap::new(); - for &(e, f, _) in &mesh.faces { - let (Some(nodes), Some(local)) = (elements.get(e as usize), usize::from(f).checked_sub(1).and_then(|i| kind.faces().get(i))) else { - continue; - }; - new_faces.insert(corners_of(kind, nodes, local), key(local.iter().filter_map(|&i| nodes.get(i).copied()))); - } - let mut old_faces: BTreeMap, Vec> = BTreeMap::new(); - let mut old_edges: BTreeSet<[u32; 2]> = BTreeSet::new(); - for e in model.elements().values().filter(|e| e.nodes.iter().any(|n| merged.contains(n))) { - for local in e.kind.faces() { - old_faces.insert(corners_of(e.kind, &e.nodes, local), key(local.iter().filter_map(|&i| e.nodes.get(i).copied()))); - } - } - for (corners, nodes) in &new_faces { - if corners.iter().all(|n| merged.contains(n)) && old_faces.get(corners) != Some(nodes) { - return refuse(format!("the block face on nodes {corners:?} lies on existing nodes but is no element face there")); - } - } - for (corners, nodes) in &old_faces { - if corners.iter().all(|n| merged.contains(n)) && new_faces.get(corners) != Some(nodes) { - return refuse(format!("the element face on nodes {corners:?} lies on the block but no block element shares it")); - } - } - // Hanging nodes: boundary nodes of either side on the other side's element edges. - let xyz = |label: u32| model.node(label).map(|n| n.xyz); - let block_points: BTreeMap = mesh - .elements - .iter() - .zip(elements) - .flat_map(|(pts, labels)| pts.iter().zip(labels).filter_map(|(&p, &l)| Some((l, *mesh.points.get(p as usize)?)))) - .collect(); - let mut new_edges: BTreeSet<[u32; 2]> = BTreeSet::new(); - for &(e, f, _) in &mesh.faces { - let (Some(nodes), Some(local)) = (elements.get(e as usize), usize::from(f).checked_sub(1).and_then(|i| kind.faces().get(i))) else { - continue; - }; - let cs: Vec = local.iter().filter(|&&i| i < kind.corner_count()).filter_map(|&i| nodes.get(i).copied()).collect(); - for (i, &a) in cs.iter().enumerate() { - let b = cs.get((i + 1) % cs.len()).copied().unwrap_or(a); - new_edges.insert(if a < b { [a, b] } else { [b, a] }); - } - } - for e in model.elements().values().filter(|e| e.nodes.iter().any(|n| merged.contains(n))) { - for local in e.kind.edges() { - if let (Some(&a), Some(&b)) = (local.first().and_then(|&i| e.nodes.get(i)), local.get(1).and_then(|&i| e.nodes.get(i))) { - old_edges.insert(if a < b { [a, b] } else { [b, a] }); - } - } - } - let new_boundary: BTreeSet = new_faces.values().flatten().copied().filter(|n| !merged.contains(n)).collect(); - let on_segment = |p: [f64; 3], a: [f64; 3], b: [f64; 3]| { - let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; - let ap = [p[0] - a[0], p[1] - a[1], p[2] - a[2]]; - let len2 = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2]; - if !(len2 > 0.0) { - return false; - } - let t = (ap[0] * ab[0] + ap[1] * ab[1] + ap[2] * ab[2]) / len2; - let d = [ap[0] - t * ab[0], ap[1] - t * ab[1], ap[2] - t * ab[2]]; - let tol = merge / len2.sqrt(); - t > tol && t < 1.0 - tol && (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() <= merge - }; - for &n in &new_boundary { - let Some(&p) = block_points.get(&n) else { continue }; - for [a, b] in &old_edges { - if let (Some(xa), Some(xb)) = (xyz(*a), xyz(*b)) - && on_segment(p, xa, xb) - { - return refuse(format!("a block node at {p:?} lies on the edge between nodes {a} and {b}")); - } - } - } - let old_nodes: BTreeSet = old_faces.values().flatten().copied().filter(|n| !merged.contains(n)).collect(); - for n in old_nodes { - let Some(p) = xyz(n) else { continue }; - for [a, b] in &new_edges { - if let (Some(&xa), Some(&xb)) = (block_points.get(a), block_points.get(b)) - && on_segment(p, xa, xb) - { - return refuse(format!("node {n} at {p:?} lies on a block edge")); - } - } - } - Ok(()) -} - #[cfg(test)] mod tests { use serde_json::json; diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index 1e41f33..7cc78d0 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -6,7 +6,7 @@ use modelcraft_mesh::cdt::{CdtOptions, tetrahedralize}; use modelcraft_mesh::delaunay::Triangulation; use modelcraft_mesh::quality::{self, QualityReport}; use modelcraft_mesh::surface::{BoundarySurface, Defect, FeatureKind, SurfaceOptions}; -use modelcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS, MAX_LABEL}; +use modelcraft_model::{ElementKind, GeometryRef, MAX_ELEMENTS}; use serde_json::{Value, json}; use super::{label, labels, listed, optional_number, points, xyz}; @@ -88,17 +88,6 @@ pub const TET_GENERATE: CommandSpec = CommandSpec { run: tet_generate, }; -/// The first node and element labels of a mesh of `nodes` nodes and `elements` elements added -/// after the model's largest labels, or an error when the labels would pass [`MAX_LABEL`]. -fn next_labels(s: &Session, nodes: usize, elements: usize) -> Result<(u32, u32)> { - let next = |last: Option| last.map_or(Some(1), |l| l.checked_add(1)); - let fits = |first: Option, count: usize| first.filter(|&f| u64::from(f) + count as u64 - 1 <= u64::from(MAX_LABEL)); - match (fits(next(s.model.nodes().keys().next_back().copied()), nodes), fits(next(s.model.elements().keys().next_back().copied()), elements)) { - (Some(n), Some(e)) => Ok((n, e)), - _ => Err(EngineError::Params(format!("the mesh needs labels beyond {MAX_LABEL}"))), - } -} - /// The most Steiner points and tetrahedra `mesh.tet.generate` may be asked for. const TET_MAX_STEINER: u64 = 10_000_000; const TET_MAX_TETS: u64 = 50_000_000; @@ -138,7 +127,7 @@ fn tet_generate(s: &mut Session, p: &Value) -> Result { let cdt = tetrahedralize(&surface, &cdt_options)?; let gate = cdt.gate(surface.volume())?.into_result()?; - let (first_node, first_element) = next_labels(s, cdt.points.len(), cdt.tets.len())?; + let (first_node, first_element) = super::fresh_labels(&s.model, cdt.points.len(), cdt.tets.len())?; for (i, xyz) in cdt.points.iter().enumerate() { s.model.set_node(first_node + i as u32, *xyz)?; } @@ -274,7 +263,7 @@ fn planar(s: &mut Session, p: &Value) -> Result { }; let m = mesh_region(&loops, kind, size, &opts)?; - let (first_node, first_element) = next_labels(s, m.nodes.len(), m.elements.len())?; + let (first_node, first_element) = super::fresh_labels(&s.model, m.nodes.len(), m.elements.len())?; for (i, xy) in m.nodes.iter().enumerate() { s.model.set_node(first_node + i as u32, [xy[0], xy[1], 0.0])?; } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a378b63..1ef8a70 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -109,6 +109,19 @@ pub(crate) fn label(p: &Value, key: &str) -> Result { .ok_or_else(|| EngineError::Params(format!("`{key}` must be a positive whole number"))) } +/// The first node and element labels after the model's largest, for `nodes` new nodes and +/// `elements` new elements, all within [`MAX_LABEL`]. +pub(crate) fn fresh_labels(model: &modelcraft_model::Model, nodes: usize, elements: usize) -> Result<(u32, u32)> { + let first = |last: Option<&u32>, count: usize| { + let first = last.map_or(Some(1), |l| l.checked_add(1))?; + (u64::from(first) + count as u64 <= u64::from(MAX_LABEL) + 1).then_some(first) + }; + match (first(model.nodes().keys().next_back(), nodes), first(model.elements().keys().next_back(), elements)) { + (Some(n), Some(e)) => Ok((n, e)), + _ => Err(EngineError::Params(format!("the mesh needs labels beyond {MAX_LABEL}"))), + } +} + /// A required `[x, y, z]` parameter. pub(crate) fn xyz(p: &Value, key: &str) -> Result<[f64; 3]> { p.get(key).and_then(xyz_value).ok_or_else(|| EngineError::Params(format!("`{key}` must be [x, y, z]"))) diff --git a/crates/engine/src/cmd/solve.rs b/crates/engine/src/cmd/solve.rs index efc998c..bc542e5 100644 --- a/crates/engine/src/cmd/solve.rs +++ b/crates/engine/src/cmd/solve.rs @@ -353,7 +353,7 @@ fn probe(s: &mut Session, p: &Value) -> Result { (None, Some(_)) => { let at = points(p, "at", MAX_PROBE)?; let found = nearest_nodes(&s.model, result.nodes(), &at)?; - (found.iter().map(|(l, _)| *l).collect(), Some(found)) + (found.iter().map(|(l, _, _)| *l).collect(), Some(found)) } (None, None) if matches!(result.as_ref(), StepResult::Explicit(_)) => (Vec::new(), None), _ => return Err(EngineError::Params("give either `nodes` or `at`".into())), @@ -367,9 +367,9 @@ fn probe(s: &mut Session, p: &Value) -> Result { StepResult::Fluid(r) => probe_fluid(r, p)?, }; if let (Some(found), Some(entries)) = (located, out.get_mut("nodes").and_then(Value::as_array_mut)) { - for (entry, (label, distance)) in entries.iter_mut().zip(found) { + for (entry, (_, xyz, distance)) in entries.iter_mut().zip(found) { if let Some(obj) = entry.as_object_mut() { - obj.insert("xyz".into(), json!(s.model.node(label).map(|n| n.xyz))); + obj.insert("xyz".into(), json!(xyz)); obj.insert("distance".into(), json!(distance)); } } @@ -377,16 +377,15 @@ fn probe(s: &mut Session, p: &Value) -> Result { Ok(out) } -/// The node with a result nearest to each point (lowest label on a tie), and its distance. -fn nearest_nodes(model: &modelcraft_model::Model, with_result: &[u32], at: &[[f64; 3]]) -> Result> { +/// The node with a result nearest to each point (lowest label on a tie), where it is and how far. +fn nearest_nodes(model: &modelcraft_model::Model, with_result: &[u32], at: &[[f64; 3]]) -> Result> { + let candidates: Vec<(u32, [f64; 3])> = with_result.iter().filter_map(|&l| Some((l, model.node(l)?.xyz))).collect(); at.iter() .map(|p| { - with_result + candidates .iter() - .filter_map(|&l| { - model.node(l).map(|n| (l, ((n.xyz[0] - p[0]).powi(2) + (n.xyz[1] - p[1]).powi(2) + (n.xyz[2] - p[2]).powi(2)).sqrt())) - }) - .fold(None, |best: Option<(u32, f64)>, (l, d)| if best.is_none_or(|(_, bd)| d < bd) { Some((l, d)) } else { best }) + .map(|&(l, x)| (l, x, ((x[0] - p[0]).powi(2) + (x[1] - p[1]).powi(2) + (x[2] - p[2]).powi(2)).sqrt())) + .min_by(|a, b| a.2.total_cmp(&b.2)) .ok_or_else(|| EngineError::Params("the result has no nodes to probe".into())) }) .collect() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9773773..934ffd1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -206,14 +206,17 @@ impl Session { } /// Run a script: a JSON array of `{"command": id, "params": {...}}`. - pub fn run_script(&mut self, script: &Value) -> Result<()> { + /// Returns each step's output, in order. Other fields of a step (such as an `id` naming it) + /// are left to the caller. + pub fn run_script(&mut self, script: &Value) -> Result> { let steps = script.as_array().ok_or_else(|| EngineError::Params("a script is a JSON array of steps".into()))?; + let mut outputs = Vec::with_capacity(steps.len()); for (i, step) in steps.iter().enumerate() { let id = step.get("command").and_then(Value::as_str).ok_or_else(|| EngineError::Params(format!("step {}: no `command`", i + 1)))?; let params = step.get("params").cloned().unwrap_or(Value::Object(Default::default())); - self.execute(id, ¶ms).map_err(|e| EngineError::Params(format!("step {} ({id}): {e}", i + 1)))?; + outputs.push(self.execute(id, ¶ms).map_err(|e| EngineError::Params(format!("step {} ({id}): {e}", i + 1)))?); } - Ok(()) + Ok(outputs) } /// Identifies the current state (see [`Session::is_dirty`]). diff --git a/crates/mesh/src/block.rs b/crates/mesh/src/block.rs index 7c2c4e0..0c9c712 100644 --- a/crates/mesh/src/block.rs +++ b/crates/mesh/src/block.rs @@ -19,10 +19,9 @@ use modelcraft_model::{ElementKind, MAX_COORD}; use crate::quality::{Validity, element_validity}; +use crate::vec3::{P, cross, dot, norm, sub}; use crate::{Error, Result}; -type P = [f64; 3]; - /// Most elements one block makes. pub const MAX_BLOCK_ELEMENTS: usize = 4_000_000; @@ -88,21 +87,9 @@ impl Block { } } -fn sub(a: P, b: P) -> P { - [a[0] - b[0], a[1] - b[1], a[2] - b[2]] -} fn add_scaled(a: P, s: f64, b: P) -> P { [a[0] + s * b[0], a[1] + s * b[1], a[2] + s * b[2]] } -fn dot(a: P, b: P) -> f64 { - a[0] * b[0] + a[1] * b[1] + a[2] * b[2] -} -fn cross(a: P, b: P) -> P { - [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] -} -fn norm(a: P) -> f64 { - dot(a, a).sqrt() -} /// An edge curve resolved against its end points: `point(s)` for `s` in `[0, 1]`. #[derive(Debug, Clone, Copy)] @@ -196,28 +183,38 @@ fn parameters(n: usize, bias: f64, order: usize) -> Vec { out } -/// The map from the unit cube onto the block. -struct Map { +/// The map from the unit cube onto the block, on the lattice of `params`: each edge curve is +/// evaluated once per lattice parameter along its direction. +struct Map<'a> { corners: [P; 8], - edges: [Path; 12], + params: &'a [Vec; 3], + /// `edges[k][i]`: edge `k` at the `i`-th parameter of its direction (`k / 4`). + edges: [Vec

; 12], } -impl Map { - fn point(&self, u: f64, v: f64, w: f64) -> P { - let e = |k: usize, s: f64| self.edges[k].point(s); +impl<'a> Map<'a> { + fn new(corners: [P; 8], paths: &[Path; 12], params: &'a [Vec; 3]) -> Map<'a> { + let edges = std::array::from_fn(|k| params[k / 4].iter().map(|&s| paths[k].point(s)).collect()); + Map { corners, params, edges } + } + + /// The point at lattice coordinates `[i, j, k]`. + fn point(&self, at: [usize; 3]) -> P { + let [u, v, w] = [0, 1, 2].map(|d| self.params[d].get(at[d]).copied().unwrap_or(0.0)); + let e = |k: usize| self.edges[k].get(at[k / 4]).copied().unwrap_or([f64::NAN; 3]); let terms: [(f64, P); 12] = [ - ((1.0 - v) * (1.0 - w), e(0, u)), - (v * (1.0 - w), e(1, u)), - ((1.0 - v) * w, e(2, u)), - (v * w, e(3, u)), - ((1.0 - u) * (1.0 - w), e(4, v)), - (u * (1.0 - w), e(5, v)), - ((1.0 - u) * w, e(6, v)), - (u * w, e(7, v)), - ((1.0 - u) * (1.0 - v), e(8, w)), - (u * (1.0 - v), e(9, w)), - (u * v, e(10, w)), - ((1.0 - u) * v, e(11, w)), + ((1.0 - v) * (1.0 - w), e(0)), + (v * (1.0 - w), e(1)), + ((1.0 - v) * w, e(2)), + (v * w, e(3)), + ((1.0 - u) * (1.0 - w), e(4)), + (u * (1.0 - w), e(5)), + ((1.0 - u) * w, e(6)), + (u * w, e(7)), + ((1.0 - u) * (1.0 - v), e(8)), + (u * (1.0 - v), e(9)), + (u * v, e(10)), + ((1.0 - u) * v, e(11)), ]; let mut x = [0.0; 3]; for (f, p) in terms { @@ -234,22 +231,23 @@ impl Map { /// Lattice offsets of each element of one cell, in units of the lattice (two per cell for /// quadratic kinds), in the model's node order. fn cell_elements(kind: ElementKind) -> Vec> { - let scale = |c: [usize; 3], m: usize| [c[0] * m, c[1] * m, c[2] * m]; - match kind { - ElementKind::Hex8 => vec![CORNERS.to_vec()], - ElementKind::Hex20 => { - let mut nodes: Vec<[usize; 3]> = CORNERS.iter().map(|c| scale(*c, 2)).collect(); - for edge in kind.edges() { - if let (Some(&a), Some(&b)) = (edge.first(), edge.get(1)) - && let (Some(pa), Some(pb)) = (CORNERS.get(a), CORNERS.get(b)) - { - nodes.push([pa[0] + pb[0], pa[1] + pb[1], pa[2] + pb[2]]); - } - } - vec![nodes] + // Corners in units of the lattice, then (quadratic kinds) a mid-side node on each edge in + // the kind's edge order. + let nodes = |corners: &[[usize; 3]]| -> Vec<[usize; 3]> { + if !kind.is_quadratic() { + return corners.to_vec(); } + let mut out: Vec<[usize; 3]> = corners.iter().map(|c| c.map(|x| 2 * x)).collect(); + for edge in kind.edges() { + if let (Some(a), Some(b)) = (edge.first().and_then(|&i| corners.get(i)), edge.get(1).and_then(|&i| corners.get(i))) { + out.push([a[0] + b[0], a[1] + b[1], a[2] + b[2]]); + } + } + out + }; + match kind { + ElementKind::Hex8 | ElementKind::Hex20 => vec![nodes(&CORNERS)], ElementKind::Tet4 | ElementKind::Tet10 => { - let m = if kind == ElementKind::Tet10 { 2 } else { 1 }; [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] .iter() .map(|perm| { @@ -263,13 +261,7 @@ fn cell_elements(kind: ElementKind) -> Vec> { if odd { v.swap(1, 2); } - let mut nodes: Vec<[usize; 3]> = v.iter().map(|c| scale(*c, m)).collect(); - if m == 2 { - for [a, b] in [[0, 1], [1, 2], [2, 0], [0, 3], [1, 3], [2, 3]] { - nodes.push([v[a][0] + v[b][0], v[a][1] + v[b][1], v[a][2] + v[b][2]]); - } - } - nodes + nodes(&v) }) .collect() } @@ -302,26 +294,22 @@ pub fn block(b: &Block) -> Result { Error::Limit(format!("a block makes at most {MAX_BLOCK_ELEMENTS} elements; {:?} divisions of {} are more", b.divisions, kind.name())) })?; - let mut curves = [Curve::Line; 12]; - let mut seen = [false; 12]; + let mut curves: [Option; 12] = [None; 12]; for (pair, curve) in &b.curves { let k = Block::edge_index(*pair) .ok_or_else(|| Error::Invalid(format!("corners {} and {} are not joined by a block edge", pair[0] + 1, pair[1] + 1)))?; - if std::mem::replace(&mut seen[k], true) { + if curves[k].replace(*curve).is_some() { return Err(Error::Invalid(format!("the edge from corner {} to {} is given twice", pair[0] + 1, pair[1] + 1))); } - if let Some(slot) = curves.get_mut(k) { - *slot = *curve; - } } let mut paths = [Path::Line { from: [0.0; 3], to: [0.0; 3] }; 12]; for (k, [from, to]) in EDGES.iter().enumerate() { - paths[k] = Path::new(curves[k], b.corners[*from], b.corners[*to])?; + paths[k] = Path::new(curves[k].unwrap_or(Curve::Line), b.corners[*from], b.corners[*to])?; } - let map = Map { corners: b.corners, edges: paths }; let order = if kind.is_quadratic() { 2 } else { 1 }; let params: [Vec; 3] = [0, 1, 2].map(|d| parameters(b.divisions[d], b.bias[d], order)); + let map = Map::new(b.corners, &paths, ¶ms); let size = [0, 1, 2].map(|d| params[d].len()); let index = |c: [usize; 3]| c[0] + size[0] * (c[1] + size[1] * c[2]); @@ -338,29 +326,28 @@ pub fn block(b: &Block) -> Result { } } } - let total = size.iter().product::(); - let mut number: Vec = vec![u32::MAX; total]; - for e in &lattice { - for c in e { - if let Some(slot) = number.get_mut(index(*c)) { - *slot = 0; - } + let mut used = vec![false; size.iter().product::()]; + for c in lattice.iter().flatten() { + if let Some(slot) = used.get_mut(index(*c)) { + *slot = true; } } + let mut number: Vec> = vec![None; used.len()]; let mut points = Vec::new(); for k in 0..size[2] { for j in 0..size[1] { for i in 0..size[0] { - if let Some(slot) = number.get_mut(index([i, j, k])) - && *slot == 0 + let at = index([i, j, k]); + if used.get(at) == Some(&true) + && let Some(slot) = number.get_mut(at) { - *slot = u32::try_from(points.len()).map_err(|_| Error::Limit("too many points in one block".into()))?; - points.push(map.point(params[0][i], params[1][j], params[2][k])); + *slot = Some(u32::try_from(points.len()).map_err(|_| Error::Limit("too many points in one block".into()))?); + points.push(map.point([i, j, k])); } } } } - let point_of = |c: [usize; 3]| number.get(index(c)).copied().filter(|&n| n != u32::MAX); + let point_of = |c: [usize; 3]| number.get(index(c)).copied().flatten(); let last = [0, 1, 2].map(|d| size[d] - 1); let mut corners = [0u32; 8]; for (slot, c) in corners.iter_mut().zip(CORNERS) { @@ -370,6 +357,11 @@ pub fn block(b: &Block) -> Result { // Block faces in hex8 face order: (direction, at the end?). const FACES: [(usize, bool); 6] = [(2, false), (2, true), (1, false), (0, true), (1, true), (0, false)]; let on_face = |c: &[usize; 3], f: (usize, bool)| c[f.0] == if f.1 { last[f.0] } else { 0 }; + // Each block edge: the direction it runs along and the lattice coordinates fixed on it. + let block_edges: [(usize, [usize; 3]); 12] = std::array::from_fn(|be| { + let [from, _] = EDGES[be]; + (be / 4, [0, 1, 2].map(|d| CORNERS[from][d] * last[d])) + }); let corner_count = kind.corner_count(); let mut elements = Vec::with_capacity(lattice.len()); let mut faces = Vec::new(); @@ -397,11 +389,9 @@ pub fn block(b: &Block) -> Result { } for (k, local) in kind.edges().iter().enumerate() { let (Some(a), Some(c)) = (local.first().and_then(|&i| nodes.get(i)), local.get(1).and_then(|&i| nodes.get(i))) else { continue }; - for (be, [from, to]) in EDGES.iter().enumerate() { - // The block edge is where two of the three lattice coordinates are fixed. - let along = (0..3).find(|&d| CORNERS[*from][d] != CORNERS[*to][d]).unwrap_or(0); - let fixed = |p: &[usize; 3]| (0..3).filter(|&d| d != along).all(|d| p[d] == CORNERS[*from][d] * last[d]); - if fixed(a) && fixed(c) { + for (be, (along, fixed)) in block_edges.iter().enumerate() { + let on = |p: &[usize; 3]| (0..3).filter(|d| d != along).all(|d| p[d] == fixed[d]); + if on(a) && on(c) { edges.push((label, k as u8 + 1, be as u8)); } } diff --git a/crates/mesh/src/join.rs b/crates/mesh/src/join.rs new file mode 100644 index 0000000..c69a897 --- /dev/null +++ b/crates/mesh/src/join.rs @@ -0,0 +1,195 @@ +//! Joining new elements to the mesh already in a model: which new points coincide with existing +//! nodes ([`weld`]), and whether the new elements then meet the mesh face to face +//! ([`check_conforming`]). Mesh generators that add to a model (`mesh.block`) use both; node +//! merging and orphan-mesh import will too. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use modelcraft_model::{ElementKind, Model}; + +use crate::vec3::{P, dot, sub}; +use crate::{Error, Result}; + +/// A uniform grid of cells of side `size` over items with a bounding box. +struct Grid { + size: f64, + cells: HashMap<[i64; 3], Vec>, +} + +impl Grid { + fn new(size: f64) -> Grid { + Grid { size, cells: HashMap::new() } + } + + fn cell(&self, p: P) -> [i64; 3] { + p.map(|x| (x / self.size).floor() as i64) + } + + /// Put `item` in every cell its box `[lo, hi]` overlaps. + fn insert(&mut self, lo: P, hi: P, item: T) { + let (a, b) = (self.cell(lo), self.cell(hi)); + for i in a[0]..=b[0] { + for j in a[1]..=b[1] { + for k in a[2]..=b[2] { + self.cells.entry([i, j, k]).or_default().push(item); + } + } + } + } + + /// The items of the cell holding `p` and of the 26 around it. + fn near(&self, p: P) -> impl Iterator + '_ { + let c = self.cell(p); + (-1..=1_i64).flat_map(move |dx| { + (-1..=1_i64).flat_map(move |dy| { + (-1..=1_i64).flat_map(move |dz| { + let key = [c[0].saturating_add(dx), c[1].saturating_add(dy), c[2].saturating_add(dz)]; + self.cells.get(&key).into_iter().flatten().copied() + }) + }) + }) + } +} + +fn bounds(points: impl Iterator) -> Option<(P, P)> { + points.fold(None, |b, p| match b { + None => Some((p, p)), + Some((lo, hi)) => Some(([0, 1, 2].map(|k| lo[k].min(p[k])), [0, 1, 2].map(|k| hi[k].max(p[k])))), + }) +} + +/// For each point, the model node within `tol` of it: the nearest, the lowest label on a tie. +/// Two points may not weld to the same node. `tol` = 0 welds nothing. +pub fn weld(model: &Model, points: &[P], tol: f64) -> Result>> { + if !(tol.is_finite() && tol >= 0.0) { + return Err(Error::Invalid(format!("the weld distance must be finite and not negative, not {tol}"))); + } + let Some((lo, hi)) = bounds(points.iter().copied()).filter(|_| tol > 0.0) else { return Ok(vec![None; points.len()]) }; + let inside = |p: P| (0..3).all(|k| p[k] >= lo[k] - tol && p[k] <= hi[k] + tol); + let mut grid = Grid::new(tol); + for (&label, node) in model.nodes().iter().filter(|(_, n)| inside(n.xyz)) { + grid.insert(node.xyz, node.xyz, (label, node.xyz)); + } + let mut taken: HashMap = HashMap::new(); + points + .iter() + .enumerate() + .map(|(i, &p)| { + let best = grid + .near(p) + .map(|(label, xyz)| (dot(sub(xyz, p), sub(xyz, p)), label)) + .filter(|(d2, _)| *d2 <= tol * tol) + .min_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))) + .map(|(_, label)| label); + if let Some(label) = best + && let Some(other) = taken.insert(label, i) + { + return Err(Error::Invalid(format!("points {other} and {i} both lie within {tol} of node {label}: lower the distance or refine"))); + } + Ok(best) + }) + .collect() +} + +/// A face or edge by its sorted node labels. +fn key(nodes: impl Iterator) -> Vec { + let mut k: Vec = nodes.collect(); + k.sort_unstable(); + k +} + +/// The faces (by sorted corners, with their sorted nodes) and the corner edges of elements. +struct Skin { + faces: BTreeMap, Vec>, + edges: BTreeSet<[u32; 2]>, +} + +impl Skin { + /// Every face and corner edge of the elements; with `boundary`, only the faces that occur + /// once, and the edges of those. + fn of<'a>(elements: impl Iterator, boundary: bool) -> Skin { + let mut count: BTreeMap, (usize, Vec, Vec)> = BTreeMap::new(); + let mut all_edges = BTreeSet::new(); + for (kind, nodes) in elements { + let corners = kind.corner_count(); + for local in kind.faces() { + let cs: Vec = local.iter().filter(|&&i| i < corners).filter_map(|&i| nodes.get(i).copied()).collect(); + let entry = count.entry(key(cs.iter().copied())).or_insert_with(|| (0, key(local.iter().filter_map(|&i| nodes.get(i).copied())), cs)); + entry.0 += 1; + } + if !boundary { + for local in kind.edges() { + if let (Some(&a), Some(&b)) = (local.first().and_then(|&i| nodes.get(i)), local.get(1).and_then(|&i| nodes.get(i))) { + all_edges.insert([a.min(b), a.max(b)]); + } + } + } + } + let mut faces = BTreeMap::new(); + let mut edges = all_edges; + for (corners, (n, nodes, cycle)) in count { + if boundary && n != 1 { + continue; + } + if boundary { + for (i, &a) in cycle.iter().enumerate() { + let b = cycle.get((i + 1) % cycle.len()).copied().unwrap_or(a); + edges.insert([a.min(b), a.max(b)]); + } + } + faces.insert(corners, nodes); + } + Skin { faces, edges } + } +} + +/// Whether new elements (their nodes already in the model) meet the model's elements face to +/// face where they share the nodes in `joined`: an element face of either side whose corners are +/// all shared must be a face of the other side with the same nodes (no mixed linear and +/// quadratic sides), and no node of the new boundary may lie on an edge of the old elements +/// within `tol`, nor the other way round (no hanging nodes where divisions differ). Edges are +/// tested as straight segments, so along a curved shared edge only the face test applies. +pub fn check_conforming(model: &Model, new: &[(ElementKind, Vec)], joined: &BTreeSet, tol: f64) -> Result<()> { + if joined.is_empty() { + return Ok(()); + } + let refuse = + |what: String| Err(Error::Invalid(format!("{what}: new elements must meet the mesh face to face, with the same divisions and element kind"))); + let new_skin = Skin::of(new.iter().map(|(k, n)| (*k, n.as_slice())), true); + let touching = model.elements().values().filter(|e| e.nodes.iter().any(|n| joined.contains(n))); + let old_skin = Skin::of(touching.map(|e| (e.kind, e.nodes.as_slice())), false); + for (mine, theirs, what) in [(&new_skin, &old_skin, "a new face"), (&old_skin, &new_skin, "an element face")] { + for (corners, nodes) in &mine.faces { + if corners.iter().all(|n| joined.contains(n)) && theirs.faces.get(corners) != Some(nodes) { + return refuse(format!("{what} on nodes {corners:?} lies on shared nodes but the other side has no such face")); + } + } + } + let xyz = |label: u32| model.node(label).map(|n| n.xyz); + for (nodes, edges) in [(&new_skin, &old_skin), (&old_skin, &new_skin)] { + let segments: Vec<([u32; 2], P, P)> = edges.edges.iter().filter_map(|&[a, b]| Some(([a, b], xyz(a)?, xyz(b)?))).collect(); + let Some(longest) = segments.iter().map(|(_, a, b)| dot(sub(*b, *a), sub(*b, *a)).sqrt()).reduce(f64::max).filter(|l| *l > 0.0) else { + continue; + }; + let mut grid = Grid::new(longest + 2.0 * tol); + for (i, (_, a, b)) in segments.iter().enumerate() { + grid.insert([0, 1, 2].map(|k| a[k].min(b[k]) - tol), [0, 1, 2].map(|k| a[k].max(b[k]) + tol), i); + } + let candidates: BTreeSet = nodes.faces.values().flatten().copied().filter(|n| !joined.contains(n)).collect(); + for n in candidates { + let Some(p) = xyz(n) else { continue }; + for i in grid.near(p) { + let Some(&([a, b], xa, xb)) = segments.get(i) else { continue }; + let (ab, ap) = (sub(xb, xa), sub(p, xa)); + let len2 = dot(ab, ab); + let t = dot(ap, ab) / len2; + let d = sub(ap, ab.map(|x| t * x)); + let margin = tol / len2.sqrt(); + if t > margin && t < 1.0 - margin && dot(d, d) <= tol * tol { + return refuse(format!("node {n} at {p:?} lies on the edge between nodes {a} and {b}")); + } + } + } + } + Ok(()) +} diff --git a/crates/mesh/src/lib.rs b/crates/mesh/src/lib.rs index c643786..54ffb2f 100644 --- a/crates/mesh/src/lib.rs +++ b/crates/mesh/src/lib.rs @@ -10,6 +10,7 @@ pub mod block; pub mod cdt; pub mod delaunay; +pub mod join; pub mod planar; pub mod quad; pub mod quality; diff --git a/crates/testkit/src/corpus.rs b/crates/testkit/src/corpus.rs index 304a1bf..427cc2a 100644 --- a/crates/testkit/src/corpus.rs +++ b/crates/testkit/src/corpus.rs @@ -167,7 +167,7 @@ pub fn corpus_dir() -> PathBuf { workspace_root().join("tests").join("corpus") } -fn read(path: &Path) -> Result { +pub(crate) fn read(path: &Path) -> Result { let io = |e: std::io::Error| Error::Io(format!("{}: {e}", path.display())); let len = std::fs::metadata(path).map_err(io)?.len(); if len > MAX_FILE_BYTES { diff --git a/crates/testkit/src/oracle.rs b/crates/testkit/src/oracle.rs index 34d723b..4863e0b 100644 --- a/crates/testkit/src/oracle.rs +++ b/crates/testkit/src/oracle.rs @@ -24,9 +24,6 @@ use std::time::Instant; use modelcraft_engine::Session; use serde_json::{Value, json}; -/// Largest case file read. -const MAX_FILE_BYTES: u64 = 16 << 20; - /// Fixture band: a result may move by this fraction of its size (and a thousandth of the /// reference's absolute tolerance) before CI calls it a change. Results are deterministic on one /// platform; the band only absorbs last-digit differences of the system maths library. @@ -187,11 +184,7 @@ pub fn fixture_band(value: f64, reference: f64, tolerance: Tolerance) -> f64 { } fn read_json(path: &Path) -> Result { - let size = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?.len(); - if size > MAX_FILE_BYTES { - return Err(format!("{}: larger than {MAX_FILE_BYTES} bytes", path.display())); - } - let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + let text = crate::corpus::read(path).map_err(|e| e.to_string())?; serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display())) } @@ -215,19 +208,13 @@ fn read_fixture(path: &Path) -> Result>, String> .map(Some) } -/// The result of measuring one quantity. -#[derive(Debug, Clone)] -pub struct Measured { - pub id: String, - pub value: Result, -} - -/// Run a case's recipe and measure its quantities. A failing command fails every quantity. -pub fn run_case(dir: &Path, expected: &Expected) -> Result, String> { +/// Run a case's recipe and measure its quantities, in order. A failing command fails every +/// quantity. +pub fn run_case(dir: &Path, expected: &Expected) -> Result>, String> { let mut session = Session::default(); let design = dir.join("design.json"); if design.is_file() { - let script = std::fs::read_to_string(&design).map_err(|e| format!("{}: {e}", design.display()))?; + let script = crate::corpus::read(&design).map_err(|e| e.to_string())?; let bytes = modelcraft_geometry::script_design(&script).map_err(|e| format!("design.json: {e}"))?; let opened = modelcraft_geometry::open_design(&bytes).map_err(|e| format!("design.json: {e}"))?; if let Some(e) = opened.errors.first() { @@ -236,17 +223,10 @@ pub fn run_case(dir: &Path, expected: &Expected) -> Result, String session.design = Some(Arc::new(opened)); } let recipe = read_json(&dir.join("recipe.json"))?; - let steps = recipe.as_array().ok_or("recipe.json must be a JSON array of steps")?; - let mut outputs: BTreeMap = BTreeMap::new(); - for (i, step) in steps.iter().enumerate() { - let command = step.get("command").and_then(Value::as_str).ok_or_else(|| format!("recipe step {}: no `command`", i + 1))?; - let params = step.get("params").cloned().unwrap_or_else(|| json!({})); - let out = session.execute(command, ¶ms).map_err(|e| format!("recipe step {} ({command}): {e}", i + 1))?; - if let Some(id) = step.get("id").and_then(Value::as_str) { - outputs.insert(id.to_string(), out); - } - } - Ok(expected.quantities.iter().map(|q| Measured { id: q.id.clone(), value: measure(&mut session, &outputs, &q.measure) }).collect()) + let results = session.run_script(&recipe).map_err(|e| format!("recipe.json: {e}"))?; + let outputs: BTreeMap = + recipe.as_array().into_iter().flatten().zip(results).filter_map(|(step, out)| Some((step.get("id")?.as_str()?.to_string(), out))).collect(); + Ok(expected.quantities.iter().map(|q| measure(&mut session, &outputs, &q.measure)).collect()) } /// Read one number: from a recipe step's output (`"from": id`) or a command run now @@ -368,27 +348,27 @@ pub fn run(root: &Path, options: &Options) -> Result<(), String> { skipped.push(case); continue; } - let measured: Vec = if expected.pending.is_some() { - expected.quantities.iter().map(|q| Measured { id: q.id.clone(), value: Err("pending".into()) }).collect() - } else { - let start = Instant::now(); - let m = run_case(dir, &expected) - .unwrap_or_else(|e| expected.quantities.iter().map(|q| Measured { id: q.id.clone(), value: Err(e.clone()) }).collect()); - eprintln!("oracle: {case} ({:.2} s)", start.elapsed().as_secs_f64()); - m - }; let fixture_path = dir.join("fixture.json"); let fixture = read_fixture(&fixture_path)?; + let mut case_rows: Vec = if expected.pending.is_some() { + let row = + |q: &Quantity| Row { case: case.clone(), quantity: q.clone(), ours: None, verdict: Verdict::Pending, fixture: FixtureCheck::NotKept }; + expected.quantities.iter().map(row).collect() + } else { + let start = Instant::now(); + let measured = run_case(dir, &expected).unwrap_or_else(|e| vec![Err(e); expected.quantities.len()]); + eprintln!("oracle: {case} ({:.2} s)", start.elapsed().as_secs_f64()); + expected + .quantities + .iter() + .zip(&measured) + .map(|(q, m)| { + let (verdict, fixture) = judge(q, m, fixture.as_ref().and_then(|f| f.get(&q.id))); + Row { case: case.clone(), quantity: q.clone(), ours: m.as_ref().ok().copied(), verdict, fixture } + }) + .collect() + }; let bless = options.bless.iter().any(|b| *b == case || b == "all"); - let mut case_rows = Vec::new(); - for (q, m) in expected.quantities.iter().zip(&measured) { - let (verdict, check) = if expected.pending.is_some() { - (Verdict::Pending, FixtureCheck::NotKept) - } else { - judge(q, &m.value, fixture.as_ref().and_then(|f| f.get(&q.id))) - }; - case_rows.push(Row { case: case.clone(), quantity: q.clone(), ours: m.value.as_ref().ok().copied(), verdict, fixture: check }); - } if bless && expected.pending.is_none() { let passing = case_rows.iter().all(|r| matches!(r.verdict, Verdict::Pass | Verdict::Known)); if !passing { @@ -508,14 +488,14 @@ fn error_of(r: &Row) -> String { } } -fn verdict_text(v: &Verdict) -> String { +fn verdict_text(v: &Verdict) -> &'static str { match v { - Verdict::Pass => "pass".into(), - Verdict::Fail => "**FAIL**".into(), - Verdict::Known => "known failure".into(), - Verdict::Fixed => "**passes: remove known_failure**".into(), - Verdict::Error(_) => "**ERROR**".into(), - Verdict::Pending => "pending".into(), + Verdict::Pass => "pass", + Verdict::Fail => "**FAIL**", + Verdict::Known => "known failure", + Verdict::Fixed => "**passes: remove known_failure**", + Verdict::Error(_) => "**ERROR**", + Verdict::Pending => "pending", } } @@ -530,7 +510,7 @@ fn fixture_text(f: &FixtureCheck) -> &'static str { } fn cell(s: &str) -> String { - s.replace('|', "/").replace('\n', " ") + s.replace('|', "\\|").replace('\n', " ") } /// docs/oracle.md. @@ -573,7 +553,7 @@ benchmark, {} experiment; {} pending cases wait for capabilities still being bui ); for (case, _) in &in_kind { for r in rows.iter().filter(|r| &r.case == case) { - let mut result = verdict_text(&r.verdict); + let mut result = verdict_text(&r.verdict).to_string(); if let Some(k) = &r.quantity.known_failure { result = format!("{result}: {k}"); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2f95d7c..0911a9a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -253,7 +253,7 @@ impl ModelApp { /// Run a script (a JSON array of `{command, params}`), stopping at the first error. pub fn run_script(&mut self, script: &Value) -> Result<(), String> { - let r = self.session.run_script(script).map_err(|e| e.to_string()); + let r = self.session.run_script(script).map(|_| ()).map_err(|e| e.to_string()); self.model_changed(); match &r { Ok(()) => self.set_status("Script done", false),