diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 253afc0..bb6a87b 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -66,6 +66,7 @@ is committed and listed here with its exports. | assets/icons/geometry.partition.svg, assets/icons/geometry.partition.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.partition.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/geometry.faces.merge.svg, assets/icons/geometry.faces.merge.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.faces.merge.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/geometry.face.absorb.svg, assets/icons/geometry.face.absorb.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.face.absorb.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | +| assets/icons/geometry.stitch.svg, assets/icons/geometry.stitch.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.stitch.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/geometry.tessellate.svg, assets/icons/geometry.tessellate.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/geometry.tessellate.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/interaction.bolt.svg, assets/icons/interaction.bolt.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.bolt.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | | assets/icons/interaction.connector.svg, assets/icons/interaction.connector.vectorcraft | FieldCraft contributors | Original toolbar icon drawn with VectorCraft from `assets/icons/src/interaction.connector.json` (`cargo xtask icons`) | MIT OR Apache-2.0 | diff --git a/assets/icons/geometry.stitch.svg b/assets/icons/geometry.stitch.svg new file mode 100644 index 0000000..0e38eda --- /dev/null +++ b/assets/icons/geometry.stitch.svg @@ -0,0 +1,11 @@ + + + geometry.stitch + + + + + + + + diff --git a/assets/icons/geometry.stitch.vectorcraft b/assets/icons/geometry.stitch.vectorcraft new file mode 100644 index 0000000..7b6fcd9 Binary files /dev/null and b/assets/icons/geometry.stitch.vectorcraft differ diff --git a/assets/icons/src/geometry.stitch.json b/assets/icons/src/geometry.stitch.json new file mode 100644 index 0000000..0ef8d7a --- /dev/null +++ b/assets/icons/src/geometry.stitch.json @@ -0,0 +1 @@ +{"shapes": [{"d": "M3 6 L10.5 6 L10.5 18 L3 18 Z", "fill": "soft", "stroke": "line"}, {"d": "M13.5 6 L21 6 L21 18 L13.5 18 Z", "fill": "soft", "stroke": "line"}, {"line": [9, 8.5, 15, 8.5], "stroke": "warm", "width": 1.75}, {"line": [9, 12, 15, 12], "stroke": "warm", "width": 1.75}, {"line": [9, 15.5, 15, 15.5], "stroke": "warm", "width": 1.75}]} diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 1f4e857..8e4deca 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -309,6 +309,14 @@ pub static CATALOGUE: &[Capability] = &[ "Prepare", "Absorb tiny and sliver faces into a neighbour for meshing, one picked or every one Check Geometry found", ), + covering( + "geometry.stitch", + "Stitch", + Geometry, + "Prepare", + "Close an imported surface's gaps under a tolerance: open points welded, T-junctions split", + &["geometry.stitch.delete"], + ), soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"), // ---- Mesh ---- soon("mesh.size.global", "Global Size", Mesh, "Size", "M2", "One element size for the whole part"), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index db5da46..d6e24f2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -231,7 +231,12 @@ impl Session { } }); if let Some(d) = &design { - warnings.extend(d.errors.iter().filter(|e| e.feature.starts_with("partition ")).map(|e| format!("{}: {}", e.feature, e.message))); + warnings.extend( + d.errors + .iter() + .filter(|e| e.feature.starts_with("partition ") || e.feature.starts_with("merge ")) + .map(|e| format!("{}: {}", e.feature, e.message)), + ); } sources.design = Some(source); } @@ -241,7 +246,7 @@ impl Session { surfaces.extend(opened.into_iter().flatten().map(Arc::new)); sources.meshes.push(source); } - let session = Session { + let mut session = Session { model: file.model, design, surfaces, @@ -252,6 +257,8 @@ impl Session { view: file.view, ..Session::default() }; + let (_, failed) = super::stitch::restitch(&mut session); + warnings.extend(failed.into_iter().map(|(n, why)| format!("stitch `{n}`: {why}"))); (session, warnings) } diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 0049cad..142f1ae 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -1723,6 +1723,14 @@ pub const FACE_ABSORB: &[Field] = &[ text("into", "Into").help("The neighbour face that absorbs it, by persistent name.").hint("the neighbour sharing the longest edges"), flag("from_check", "From check").help("Absorb every small and sliver face the last Check Geometry found, each into its best neighbour."), ]; +pub const STITCH: &[Field] = &[ + NAME, + text("surface", "Surface").help("The imported surface to stitch, by name.").req(), + positive("tolerance", "Tolerance", D::Length) + .help("Gaps narrower than this close: open points this close become one, and a point this close to another open edge splits it.") + .req(), +]; +pub const STITCH_DELETE: &[Field] = existing!("Stitch", C::Stitch); pub const FACES_MERGE_DELETE: &[Field] = existing!("Face merge", C::FaceMerge); pub const MESH_PLAN: &[Field] = &[ reference("body", "Body", C::Body).help("The body of the design to plan (as opened, not a partition cell).").req(), diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index 2f6ae52..dff911b 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -520,7 +520,17 @@ fn import_mesh(s: &mut Session, p: &Value) -> Result { .collect(); s.surfaces.extend(surfaces.into_iter().map(Arc::new)); s.sources.meshes.push(source); - Ok(json!({"path": path, "surfaces": added})) + // The model's stitches of surfaces by these names apply to them too. + let (_, failed) = super::stitch::restitch(s); + let mut reply = json!({"path": path, "surfaces": added}); + let warnings: Vec = + failed.into_iter().filter(|(_, why)| !why.starts_with("no surface")).map(|(n, why)| format!("stitch `{n}`: {why}")).collect(); + if !warnings.is_empty() + && let Some(o) = reply.as_object_mut() + { + o.insert("warnings".into(), json!(warnings)); + } + Ok(reply) } #[cfg(test)] @@ -660,7 +670,7 @@ pub(crate) mod tests { } } - fn package(model: &str) -> Vec { + pub(crate) fn package(model: &str) -> Vec { let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new())); zip.start_file("3D/3dmodel.model", zip::write::SimpleFileOptions::default()).unwrap(); zip.write_all(model.as_bytes()).unwrap(); diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index 5e29ee5..659434e 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -1075,7 +1075,7 @@ mod tests { SurfaceMesh { positions: p.clone(), triangles: t.clone(), tri_face: vec![0, 1, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() }; let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() }; for mesh in [tet, open] { - s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 })); + s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2, original: None })); } let r = s.execute("mesh.surface.check", &json!({"surface": 0, "feature_angle": 180})).unwrap(); assert_eq!((r["valid"].clone(), r["feature_edges"].clone()), (json!(true), json!({"face_boundary": 3, "sharp": 0}))); @@ -1308,7 +1308,7 @@ mod tests { let open = SurfaceMesh { positions: p, triangles: t[..3].to_vec(), tri_face: vec![0, 1, 1], weld_distance: 0.0, ..SurfaceMesh::default() }; let mut s = Session::default(); for mesh in [tet, open] { - s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2 })); + s.surfaces.push(Arc::new(TaggedSurface { name: "Tet".into(), mesh, faces: 2, original: None })); } let r = s.execute("mesh.tet.generate", &json!({"surface": 0, "quality": false})).unwrap(); assert_eq!((r["elements"].clone(), r["nodes"].clone(), r["boundary_faces"].clone()), (json!(1), json!(4), json!(4))); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1ca1cb8..f85c119 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -31,6 +31,7 @@ pub mod replace; mod results; pub(crate) mod seeds; pub(crate) mod solve; +pub(crate) mod stitch; mod symmetry; mod tags; pub mod update; @@ -74,6 +75,8 @@ pub static ALL: &[CommandSpec] = &[ virtual_faces::FACES_MERGE, virtual_faces::FACES_MERGE_DELETE, virtual_faces::FACE_ABSORB, + stitch::STITCH, + stitch::STITCH_DELETE, mesh::NODE_ADD, mesh::NODE_REMOVE, mesh::ELEMENT_ADD, diff --git a/crates/engine/src/cmd/stitch.rs b/crates/engine/src/cmd/stitch.rs new file mode 100644 index 0000000..6925035 --- /dev/null +++ b/crates/engine/src/cmd/stitch.rs @@ -0,0 +1,186 @@ +//! Stitching imported surfaces (`geometry.stitch`): their gaps under a tolerance closed for +//! meshing ([`fieldcraft_geometry::stitch`]). +//! +//! A stitch is part of the model (`fieldcraft_model::virtual_faces::Stitch`), naming the surface +//! by name. The surfaces keep what was read ([`TaggedSurface::original`]); [`restitch`] stitches +//! each again from it by the model's stitches, in order, whenever a surface is imported or the +//! model is opened, and after every stitch or delete, so the meshers always take the stitched +//! surface. Undo restores the surfaces with the model. + +use std::sync::Arc; + +use fieldcraft_geometry::tagged_mesh::TaggedSurface; +use fieldcraft_model::Stitch; +use serde_json::{Value, json}; + +use crate::requirement::Requirement; +use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; + +pub const STITCH: CommandSpec = CommandSpec { + id: "geometry.stitch", + label: "Stitch Surface", + params: r#"{"name": "Gaps", "surface": "Mesh1" (an imported surface, by name), "tolerance": 0.05 (mm)} — closes the gaps of an imported surface under the tolerance, for meshing: points on open edges closer than it become one point (triangles that collapse go), and a point on an open edge within it of the inside of another open edge splits that edge's triangle (a T-junction). Edges that are not open are never touched, so a closed surface is unchanged; triangles keep their face tags. Kept in the model and applied again, in order, whenever the surface is imported or the model opened (the file is not changed). Replies the open edges before and after (`open_before`, `open_after`: 0 means closed), the points `merged`, the triangles `split` and `removed`; the surface loses its mesh (`replaced`). A surface the session lacks is an error. One undo step"#, + form: Some(super::forms::STITCH), + kind: CommandKind::Model, + requires: &[Requirement::Geometry], + run: stitch, +}; + +pub const STITCH_DELETE: CommandSpec = CommandSpec { + id: "geometry.stitch.delete", + label: "Delete Stitch", + params: r#"{"name": "Gaps"} — removes a stitch: the surface is stitched again by the ones left (as read when none); it loses its mesh (`replaced`)"#, + form: Some(super::forms::STITCH_DELETE), + kind: CommandKind::Model, + requires: &[Requirement::Stitch], + run: delete, +}; + +/// The name a surface goes by (and its mesh is tagged with): its own, or `Surface`. +pub(crate) fn surface_name(index: usize, t: &TaggedSurface) -> String { + if t.name.trim().is_empty() { format!("Surface{index}") } else { t.name.clone() } +} + +/// Stitch the session's surfaces again from what was read, by the model's stitches. Replies each +/// stitch's report by name, and the stitches whose surface is not there or that failed (with why). +pub(crate) fn restitch(s: &mut Session) -> (Vec<(String, Value)>, Vec<(String, String)>) { + let mut reports = Vec::new(); + let mut failed = Vec::new(); + let stitches = s.model.stitches().to_vec(); + for st in stitches.iter().filter(|st| !s.surfaces.iter().enumerate().any(|(i, t)| surface_name(i, t) == st.surface)) { + failed.push((st.name.clone(), format!("no surface `{}`", st.surface))); + } + for (i, slot) in s.surfaces.iter_mut().enumerate() { + let name = surface_name(i, slot); + let mine: Vec<&Stitch> = stitches.iter().filter(|st| st.surface == name).collect(); + if mine.is_empty() && slot.original.is_none() { + continue; + } + let raw = slot.original.clone().unwrap_or_else(|| Arc::new(slot.mesh.clone())); + let mut mesh = raw.as_ref().clone(); + for st in mine { + match fieldcraft_geometry::stitch::stitch(&mesh, st.tolerance) { + Ok((m, r)) => { + mesh = m; + let report = + json!({"open_before": r.open_before, "open_after": r.open_after, "merged": r.merged, "split": r.split, "removed": r.removed}); + reports.push((st.name.clone(), report)); + } + Err(e) => failed.push((st.name.clone(), e.to_string())), + } + } + let original = (!s.model.stitches().iter().all(|st| st.surface != name)).then_some(raw); + *slot = Arc::new(TaggedSurface { name: slot.name.clone(), mesh, faces: slot.faces, original }); + } + (reports, failed) +} + +fn stitch(s: &mut Session, p: &Value) -> Result { + let name = super::name(p)?.to_string(); + let surface: String = super::req(p, "surface")?; + let tolerance: f64 = super::req(p, "tolerance")?; + s.model.add_stitch(Stitch { name: name.clone(), surface: surface.clone(), tolerance })?; + let (reports, failed) = restitch(s); + if let Some((_, why)) = failed.iter().find(|(n, _)| *n == name) { + return Err(EngineError::Params(format!("stitch `{name}`: {why}"))); + } + let mut reply = reports.into_iter().find(|(n, _)| *n == name).map(|(_, r)| r).unwrap_or_else(|| json!({})); + if let Some(o) = reply.as_object_mut() { + o.insert("name".into(), json!(name)); + o.insert("surface".into(), json!(surface)); + } + let cleared = super::replace::clear(&mut s.model, [surface.as_str()]); + Ok(cleared.report_if_any(s, reply)) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + let name = super::name(p)?; + let removed = s.model.remove_stitch(name)?; + restitch(s); + let cleared = super::replace::clear(&mut s.model, [removed.surface.as_str()]); + Ok(cleared.report_if_any(s, json!({"deleted": name, "surface": removed.surface}))) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + /// A unit cube in a 3MF whose top face is raised 0.01 mm: open along the top's rim. + fn gapped_cube(dir: &std::path::Path) -> String { + let m = fieldcraft_geometry::stitch::test_cube(0.01, false); + let vertices: String = m.positions.iter().map(|p| format!(r#""#, p[0], p[1], p[2])).collect(); + let triangles: String = m + .triangles + .iter() + .zip(&m.tri_face) + .map(|(t, f)| format!(r#""#, t[0], t[1], t[2])) + .collect(); + let model = format!( + r#"{vertices}{triangles}"#, + fieldcraft_geometry::tagged_mesh::FACE_TAGS_NS + ); + let path = dir.join("cube.3mf"); + std::fs::write(&path, super::super::geometry::tests::package(&model)).unwrap(); + path.to_str().unwrap().to_string() + } + + /// The gapped cube does not mesh; stitched under a tolerance over the gap it does; the stitch + /// is saved and applied again when the model is opened; undo and delete bring the gap back. + #[test] + fn a_stitched_surface_meshes_and_the_stitch_is_replayed() { + let dir = std::env::temp_dir().join(format!("fieldcraft-stitch-{}-{:?}", std::process::id(), std::thread::current().id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::default(); + s.execute("geometry.import.mesh", &json!({"path": gapped_cube(&dir)})).unwrap(); + let tet = json!({"surface": 0, "size": 0.25}); + assert!(s.execute("mesh.tet.generate", &tet).is_err(), "open: no volume to fill"); + assert!( + s.execute("geometry.stitch", &json!({"name": "Small", "surface": "Cube", "tolerance": 0.001})).unwrap()["open_after"].as_u64() > Some(0) + ); + let r = s.execute("geometry.stitch", &json!({"name": "Gaps", "surface": "Cube", "tolerance": 0.05})).unwrap(); + assert_eq!((r["open_after"].as_u64(), r["merged"].as_u64()), (Some(0), Some(4)), "{r}"); + s.execute("mesh.tet.generate", &tet).unwrap(); + assert!(!s.model.elements().is_empty()); + let file = dir.join("stitched.fieldcraft"); + s.execute("file.save", &json!({"path": file.to_str().unwrap()})).unwrap(); + let mut t = Session::default(); + t.execute("file.open", &json!({"path": file.to_str().unwrap()})).unwrap(); + assert_eq!(t.surfaces[0].mesh, s.surfaces[0].mesh); + assert!(t.surfaces[0].original.is_some()); + // Undo the mesh and the stitch: the gap is back. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.execute("mesh.tet.generate", &tet).is_err()); + s.execute("edit.redo", &json!({})).unwrap(); + s.execute("mesh.tet.generate", &tet).unwrap(); + let r = s.execute("geometry.stitch.delete", &json!({"name": "Gaps"})).unwrap(); + assert!(r["replaced"].is_array(), "{r}"); + assert!(s.execute("mesh.tet.generate", &tet).is_err()); + std::fs::remove_dir_all(dir).ok(); + } + + #[test] + fn bad_stitches_change_nothing() { + let dir = std::env::temp_dir().join(format!("fieldcraft-stitch-bad-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::default(); + s.execute("geometry.import.mesh", &json!({"path": gapped_cube(&dir)})).unwrap(); + let journal = s.journal.len(); + for p in [ + json!({}), + json!({"name": "A", "surface": "Cube"}), + json!({"name": "A", "surface": "Nope", "tolerance": 0.1}), + json!({"name": "A", "surface": "Cube", "tolerance": 0}), + json!({"name": "A", "surface": "Cube", "tolerance": "x"}), + json!({"name": "1A", "surface": "Cube", "tolerance": 0.1}), + ] { + assert!(s.execute("geometry.stitch", &p).is_err(), "{p}"); + } + assert!(s.execute("geometry.stitch.delete", &json!({"name": "A"})).is_err()); + assert_eq!(s.journal.len(), journal); + assert!(s.model.stitches().is_empty() && s.surfaces[0].original.is_none()); + std::fs::remove_dir_all(dir).ok(); + } +} diff --git a/crates/engine/src/requirement.rs b/crates/engine/src/requirement.rs index 09c248a..ae01b69 100644 --- a/crates/engine/src/requirement.rs +++ b/crates/engine/src/requirement.rs @@ -34,6 +34,8 @@ pub enum Requirement { Partition, /// A face merge. FaceMerge, + /// A stitch. + Stitch, /// An edge seed. Seed, /// At least one mesh node. @@ -82,6 +84,7 @@ impl Requirement { Requirement::DatumPoint => m.datums().values().any(|d| d.point().is_some()), Requirement::Partition => !m.partitions().is_empty(), Requirement::FaceMerge => !m.face_merges().is_empty(), + Requirement::Stitch => !m.stitches().is_empty(), Requirement::Seed => !m.seeds().is_empty(), Requirement::Nodes => !m.nodes().is_empty(), Requirement::Mesh => has_elements, @@ -123,6 +126,7 @@ impl Requirement { Requirement::DatumPoint => "a datum point", Requirement::Partition => "a partition", Requirement::FaceMerge => "a face merge", + Requirement::Stitch => "a stitch", Requirement::Seed => "an edge seed", Requirement::Nodes => "mesh nodes", Requirement::Mesh | Requirement::Analysable => "a mesh", @@ -154,6 +158,7 @@ impl Requirement { Requirement::DatumPoint => "geometry.datum.point", Requirement::Partition => "geometry.partition", Requirement::FaceMerge => "geometry.faces.merge", + Requirement::Stitch => "geometry.stitch", Requirement::Seed => "mesh.seed.edge", Requirement::Nodes => "mesh.node.add", Requirement::Mesh | Requirement::Analysable => "mesh.tet.generate", @@ -212,6 +217,7 @@ impl crate::schema::Collection { C::DatumPoint => Requirement::DatumPoint, C::Partition => Requirement::Partition, C::FaceMerge => Requirement::FaceMerge, + C::Stitch => Requirement::Stitch, } } } @@ -353,6 +359,7 @@ mod tests { C::DatumPoint, C::Partition, C::FaceMerge, + C::Stitch, ]; let empty = Session::default(); for c in all { diff --git a/crates/engine/src/schema.rs b/crates/engine/src/schema.rs index 939406d..adb292b 100644 --- a/crates/engine/src/schema.rs +++ b/crates/engine/src/schema.rs @@ -219,6 +219,8 @@ pub enum Collection { Partition, /// A face merge (`geometry.faces.merge`). FaceMerge, + /// A stitch (`geometry.stitch`). + Stitch, } impl Collection { @@ -245,6 +247,7 @@ impl Collection { Collection::DatumPoint => "datum point", Collection::Partition => "partition", Collection::FaceMerge => "face merge", + Collection::Stitch => "stitch", } } } @@ -275,6 +278,7 @@ impl Session { Collection::DatumPoint => Box::new(m.datums().iter().filter(|(_, d)| d.point().is_some()).map(|(n, _)| n.as_str())), Collection::Partition => Box::new(m.partitions().iter().map(|p| p.name.as_str())), Collection::FaceMerge => Box::new(m.face_merges().iter().map(|f| f.name.as_str())), + Collection::Stitch => Box::new(m.stitches().iter().map(|f| f.name.as_str())), }; names.take(MAX_NAMES).map(str::to_string).collect() } diff --git a/crates/geometry/src/tagged_mesh.rs b/crates/geometry/src/tagged_mesh.rs index c347655..604f8e7 100644 --- a/crates/geometry/src/tagged_mesh.rs +++ b/crates/geometry/src/tagged_mesh.rs @@ -46,6 +46,9 @@ pub struct TaggedSurface { pub mesh: SurfaceMesh, /// Number of faces: `ft:faces` when given, else the largest tag + 1 (1 when untagged). pub faces: u32, + /// The surface as read, when `mesh` is it stitched ([`crate::stitch`]): what stitching + /// starts from again. + pub original: Option>, } fn invalid(msg: impl std::fmt::Display) -> Error { @@ -345,6 +348,7 @@ fn surface(mut o: Object, k: usize, id: u32, xf: &Xf, unit: f64) -> Result