From 5189554a6a5be9232a2e7de7af196c822c35dbc8 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:02:16 -0400 Subject: [PATCH] model: suppress datums and partitions: kept, but left out of the model (#291) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/catalogue.rs | 4 +- crates/engine/src/cmd/datum.rs | 90 ++++++++++++++++++++++++++----- crates/engine/src/cmd/forms.rs | 10 ++++ crates/engine/src/cmd/mirror.rs | 6 +-- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/symmetry.rs | 6 +-- crates/engine/src/progress.rs | 4 +- crates/model/src/datums.rs | 75 ++++++++++++++++++++++++-- crates/model/src/lib.rs | 11 +++- crates/model/src/rename.rs | 5 +- docs/features.md | 4 +- 11 files changed, 183 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 61aebba..f6943f4 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -261,7 +261,7 @@ pub static CATALOGUE: &[Capability] = &[ Geometry, "Prepare", "A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis", - &["geometry.datum.edit", "geometry.datum.delete"], + &["geometry.datum.edit", "geometry.datum.delete", "geometry.datum.suppress"], ), covering( "geometry.datum.axis", @@ -285,7 +285,7 @@ pub static CATALOGUE: &[Capability] = &[ Geometry, "Prepare", "Cut bodies into cells by a datum plane; the two faces of each cut are paired by name", - &["geometry.partition.delete"], + &["geometry.partition.delete", "geometry.partition.suppress"], ), soon("geometry.midsurface", "Midsurface", Geometry, "Prepare", "M8", "Extract midsurfaces of thin parts for shell meshes"), // ---- Mesh ---- diff --git a/crates/engine/src/cmd/datum.rs b/crates/engine/src/cmd/datum.rs index 44c9800..185376c 100644 --- a/crates/engine/src/cmd/datum.rs +++ b/crates/engine/src/cmd/datum.rs @@ -101,6 +101,26 @@ pub const PARTITION: CommandSpec = CommandSpec { run: partition, }; +pub const DATUM_SUPPRESS: CommandSpec = CommandSpec { + id: "geometry.datum.suppress", + label: "Suppress Datum", + params: r#"{"name": "Mid", "suppressed"?: true} — keeps the datum but leaves it out of the model (false brings it back): it cannot be an input to another datum, a partition, a mirror or a symmetry while suppressed, and the partitions that cut by it stop cutting (the design is cut again without them)"#, + form: Some(super::forms::DATUM_SUPPRESS), + kind: CommandKind::Model, + requires: &[Requirement::Datum], + run: datum_suppress, +}; + +pub const PARTITION_SUPPRESS: CommandSpec = CommandSpec { + id: "geometry.partition.suppress", + label: "Suppress Partition", + params: r#"{"name": "Half", "suppressed"?: true} — keeps the partition but leaves it out (false brings it back): the design is cut again without it, so its cells merge back into the bodies they came from"#, + form: Some(super::forms::PARTITION_SUPPRESS), + kind: CommandKind::Model, + requires: &[Requirement::Partition], + run: partition_suppress, +}; + pub const PARTITION_DELETE: CommandSpec = CommandSpec { id: "geometry.partition.delete", label: "Delete Partition", @@ -214,18 +234,20 @@ fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { /// The coordinates of datum point `name`. fn datum_point_at(m: &Model, name: &str) -> Result<[f64; 3]> { - match m.datum(name) { - Some(d) => d.point().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not a point", d.kind()))), - None => Err(EngineError::Params(format!("no datum point `{name}`"))), - } + let d = m.usable_datum(name)?; + d.point().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not a point", d.kind()))) } /// The point and unit direction of datum axis `name`. fn datum_axis_of(m: &Model, name: &str) -> Result<([f64; 3], [f64; 3])> { - match m.datum(name) { - Some(d) => d.axis().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not an axis", d.kind()))), - None => Err(EngineError::Params(format!("no datum axis `{name}`"))), - } + let d = m.usable_datum(name)?; + d.axis().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not an axis", d.kind()))) +} + +/// The point and unit normal of datum plane `name`. +pub(crate) fn datum_plane_of(m: &Model, name: &str) -> Result<([f64; 3], [f64; 3])> { + let d = m.usable_datum(name)?; + d.plane().ok_or_else(|| EngineError::Params(format!("datum `{name}` is a datum {}, not a plane", d.kind()))) } /// A plane through three points; collinear points are refused relative to their spread. @@ -330,10 +352,7 @@ fn point_datum(s: &Session, by: PointBy) -> Result { PointBy::FaceCentre { body, face } => shape(&body)?.face_centre(&face)?, PointBy::AxisPlane { axis, plane } => { let (p, d) = datum_axis_of(&s.model, &axis)?; - let (q, n) = match s.model.datum(&plane) { - Some(x) => x.plane().ok_or_else(|| EngineError::Params(format!("datum `{plane}` is a datum {}, not a plane", x.kind())))?, - None => return Err(EngineError::Params(format!("no datum plane `{plane}`"))), - }; + let (q, n) = datum_plane_of(&s.model, &plane)?; let along = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; let across = along(d, n); // Unit vectors: the sine of the angle between the axis and the plane. @@ -445,6 +464,7 @@ fn cuts(model: &Model) -> Vec { model .partitions() .iter() + .filter(|p| model.partition_cuts(p)) .filter_map(|p| { let (point, normal) = model.datum(&p.datum)?.plane()?; Some(Cut { name: p.name.clone(), point, normal, bodies: p.bodies.clone() }) @@ -470,7 +490,8 @@ fn partition(s: &mut Session, p: &Value) -> Result { let name = super::name(p)?.to_string(); let datum: String = super::req(p, "datum")?; let bodies: Vec = super::opt(p, "bodies")?.unwrap_or_default(); - s.model.add_partition(Partition { name: name.clone(), datum: datum.clone(), bodies })?; + datum_plane_of(&s.model, &datum)?; + s.model.add_partition(Partition { name: name.clone(), datum: datum.clone(), bodies, suppressed: false })?; recut(s)?; let design = s.design.as_deref().ok_or_else(|| EngineError::Params("open a design first".into()))?; let feature = format!("partition {name}"); @@ -499,6 +520,24 @@ fn partition(s: &mut Session, p: &Value) -> Result { })) } +fn datum_suppress(s: &mut Session, p: &Value) -> Result { + let name = super::name(p)?; + let suppressed = super::opt(p, "suppressed")?.unwrap_or(true); + s.model.set_datum_suppressed(name, suppressed)?; + recut(s)?; + let bodies: Vec = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect(); + Ok(json!({"name": name, "suppressed": suppressed, "bodies": bodies})) +} + +fn partition_suppress(s: &mut Session, p: &Value) -> Result { + let name = super::name(p)?; + let suppressed = super::opt(p, "suppressed")?.unwrap_or(true); + s.model.set_partition_suppressed(name, suppressed)?; + recut(s)?; + let bodies: Vec = s.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.clone())).collect(); + Ok(json!({"name": name, "suppressed": suppressed, "bodies": bodies})) +} + fn partition_delete(s: &mut Session, p: &Value) -> Result { let name = super::name(p)?; s.model.remove_partition(name)?; @@ -575,6 +614,31 @@ pub(crate) mod tests { assert!(s.journal.is_empty()); } + /// Suppressing a partition merges its cells back; suppressing its datum does too and stops + /// the datum being an input; unsuppressing cuts again; each is one undo step (#291). + #[test] + fn suppressed_partitions_and_datums_leave_the_design_uncut() { + let (mut s, dir) = plate(); + s.execute("geometry.datum", &json!({"name": "Mid", "by": {"type": "offset", "plane": "yz", "offset": 15}})).unwrap(); + s.execute("geometry.partition", &json!({"name": "Half", "datum": "Mid"})).unwrap(); + let count = |s: &Session| s.design.as_ref().map_or(0, |d| d.bodies.len()); + assert_eq!(count(&s), 2); + let r = s.execute("geometry.partition.suppress", &json!({"name": "Half"})).unwrap(); + assert_eq!((r["suppressed"].as_bool(), count(&s)), (Some(true), 1)); + s.execute("geometry.partition.suppress", &json!({"name": "Half", "suppressed": false})).unwrap(); + assert_eq!(count(&s), 2); + s.execute("geometry.datum.suppress", &json!({"name": "Mid"})).unwrap(); + assert_eq!(count(&s), 1); + let e = s.execute("geometry.partition", &json!({"name": "Again", "datum": "Mid"})).unwrap_err().to_string(); + assert!(e.contains("suppressed"), "{e}"); + let e = s.execute("geometry.datum.point", &json!({"name": "P", "by": {"type": "axis_plane", "axis": "Mid", "plane": "Mid"}})); + assert!(e.is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(count(&s), 2, "undo cuts again"); + assert!(s.execute("geometry.datum.suppress", &json!({"name": "Nope"})).is_err()); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn a_partition_cuts_the_design_and_is_replayed_when_it_opens_again() { let (mut s, dir) = plate(); diff --git a/crates/engine/src/cmd/forms.rs b/crates/engine/src/cmd/forms.rs index 18c8416..1652b21 100644 --- a/crates/engine/src/cmd/forms.rs +++ b/crates/engine/src/cmd/forms.rs @@ -834,6 +834,16 @@ pub const STEP_ACTIVATE: &[Field] = &[ flag("active", "Active").help("On: they act in the step. Off: they are switched off from it on.").def("true"), ]; +pub const DATUM_SUPPRESS: &[Field] = &[ + reference("name", "Datum", C::Datum).help("The datum to keep but leave out of the model.").req(), + flag("suppressed", "Suppressed").help("On: left out (its partitions stop cutting). Off: back in the model.").def("true"), +]; + +pub const PARTITION_SUPPRESS: &[Field] = &[ + reference("name", "Partition", C::Partition).help("The partition to keep but leave out: its cells merge back.").req(), + flag("suppressed", "Suppressed").help("On: the design is cut without it. Off: it cuts again.").def("true"), +]; + pub const RENAME: &[Field] = &[ choice( "kind", diff --git a/crates/engine/src/cmd/mirror.rs b/crates/engine/src/cmd/mirror.rs index 664053d..be109ee 100644 --- a/crates/engine/src/cmd/mirror.rs +++ b/crates/engine/src/cmd/mirror.rs @@ -42,11 +42,7 @@ fn not_a_plane() -> EngineError { /// The plane: a datum plane by name, or a point and a normal. fn plane(s: &Session, p: &Value) -> Result<([f64; 3], [f64; 3])> { match p.get("plane") { - Some(Value::String(name)) => s - .model - .datum(name) - .and_then(|d| d.plane()) - .ok_or_else(|| EngineError::Params(format!("no datum plane `{}`", name.chars().take(64).collect::()))), + Some(Value::String(name)) => super::datum::datum_plane_of(&s.model, name), Some(v @ Value::Object(_)) => match (super::xyz(v, "point"), super::xyz(v, "normal")) { (Ok(point), Ok(normal)) => Ok((point, normal)), _ => Err(not_a_plane()), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4b82655..224ef0e 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -57,6 +57,8 @@ pub static ALL: &[CommandSpec] = &[ view::ISOLATE, view::SHOW_ALL, datum::PARTITION, + datum::DATUM_SUPPRESS, + datum::PARTITION_SUPPRESS, datum::PARTITION_DELETE, mesh::NODE_ADD, mesh::NODE_REMOVE, diff --git a/crates/engine/src/cmd/symmetry.rs b/crates/engine/src/cmd/symmetry.rs index 5694255..3860c9f 100644 --- a/crates/engine/src/cmd/symmetry.rs +++ b/crates/engine/src/cmd/symmetry.rs @@ -29,11 +29,7 @@ const ON_PLANE: f64 = 1e-6; fn symmetry(s: &mut Session, p: &Value) -> Result { let name = super::name(p)?.to_string(); let datum: String = super::req(p, "datum")?; - let (point, normal) = s - .model - .datum(&datum) - .and_then(|d| d.plane()) - .ok_or_else(|| EngineError::Params(format!("no datum plane `{}`", datum.chars().take(64).collect::())))?; + let (point, normal) = super::datum::datum_plane_of(&s.model, &datum)?; let axis = (0..3).find(|&k| (normal[k].abs() - 1.0).abs() < 1e-9).ok_or_else(|| { EngineError::Params(format!("not supported yet: symmetry about a plane not normal to X, Y or Z (datum `{datum}` has normal {normal:?})")) })?; diff --git a/crates/engine/src/progress.rs b/crates/engine/src/progress.rs index ad6790b..cf7ad40 100644 --- a/crates/engine/src/progress.rs +++ b/crates/engine/src/progress.rs @@ -184,8 +184,8 @@ impl Session { 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", + "geometry.partition" | "geometry.partition.delete" | "geometry.partition.suppress" => "Partitions", + "geometry.datum.edit" | "geometry.datum.suppress" => "Datum planes", _ => continue, }; if !changed.contains(&what) { diff --git a/crates/model/src/datums.rs b/crates/model/src/datums.rs index db185b6..b830ff7 100644 --- a/crates/model/src/datums.rs +++ b/crates/model/src/datums.rs @@ -12,7 +12,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use crate::geometry::MAX_GEOMETRY_NAME_LEN; -use crate::{Error, Model, Result, check_name, context, insert_new, not_in_use, remove_existing, replace_existing}; +use crate::{Error, Model, Result, check_name, context, insert_new, not_in_use, remove_existing, replace_existing, require}; /// Most partitions a model holds. pub const MAX_PARTITIONS: usize = 256; @@ -99,6 +99,9 @@ pub struct Partition { /// empty: every body the plane divides. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub bodies: Vec, + /// Kept in the model but left out of it: the design is cut without it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub suppressed: bool, } impl Model { @@ -126,7 +129,46 @@ impl Model { pub fn remove_datum(&mut self, name: &str) -> Result<()> { not_in_use("datum", name, self.datum_users(name))?; - remove_existing(&mut self.datums, "datum", name).map(|_| ()) + remove_existing(&mut self.datums, "datum", name)?; + self.suppressed_datums.remove(name); + Ok(()) + } + + /// Whether datum `name` is suppressed: kept, but not usable as an input and not cutting. + pub fn datum_suppressed(&self, name: &str) -> bool { + self.suppressed_datums.contains(name) + } + + /// Suppress datum `name` (or bring it back). + pub fn set_datum_suppressed(&mut self, name: &str, suppressed: bool) -> Result<()> { + require(&self.datums, "datum", name)?; + if suppressed { + self.suppressed_datums.insert(name.to_string()); + } else { + self.suppressed_datums.remove(name); + } + Ok(()) + } + + /// Datum `name` as an input to another definition: refused while it is suppressed. + pub fn usable_datum(&self, name: &str) -> Result<&Datum> { + let d = self.datums.get(name).ok_or_else(|| Error::NotFound(format!("datum `{name}`")))?; + if self.datum_suppressed(name) { + return Err(Error::Invalid(format!("datum `{name}` is suppressed: unsuppress it to use it"))); + } + Ok(d) + } + + /// Suppress partition `name` (or bring it back): the design is cut without it. + pub fn set_partition_suppressed(&mut self, name: &str, suppressed: bool) -> Result<()> { + let p = self.partitions.iter_mut().find(|p| p.name == name).ok_or_else(|| Error::NotFound(format!("partition `{name}`")))?; + p.suppressed = suppressed; + Ok(()) + } + + /// Whether partition `p` cuts: neither it nor its datum is suppressed. + pub fn partition_cuts(&self, p: &Partition) -> bool { + !p.suppressed && !self.datum_suppressed(&p.datum) } /// The definitions that refer to a datum (the partitions that cut by it), as `kind \`name\`` @@ -187,6 +229,33 @@ mod tests { Datum::Plane { point: [0.0, 0.0, z], normal: [0.0, 0.0, 2.0] } } + /// A suppressed datum is kept but not usable, and stops its partitions cutting; a suppressed + /// partition stops cutting; both are saved (#291). + #[test] + fn suppressed_datums_and_partitions_are_kept_but_left_out() { + let mut m = Model::default(); + m.add_datum("Mid", plane(5.0)).unwrap(); + m.add_datum("Low", plane(1.0)).unwrap(); + let p = |name: &str, datum: &str| Partition { name: name.into(), datum: datum.into(), bodies: vec![], suppressed: false }; + m.add_partition(p("Half", "Mid")).unwrap(); + m.add_partition(p("Base", "Low")).unwrap(); + assert!(m.partitions().iter().all(|p| m.partition_cuts(p))); + m.set_datum_suppressed("Mid", true).unwrap(); + m.set_partition_suppressed("Base", true).unwrap(); + assert!(m.partitions().iter().all(|p| !m.partition_cuts(p))); + assert!(m.usable_datum("Mid").unwrap_err().to_string().contains("suppressed")); + assert!(m.usable_datum("Low").is_ok() && m.datum("Mid").is_some()); + assert!(m.set_datum_suppressed("Nope", true).is_err() && m.set_partition_suppressed("Nope", true).is_err()); + let json = serde_json::to_string(&m).unwrap(); + let back: Model = serde_json::from_str(&json).unwrap(); + assert_eq!(back, m); + assert!(back.datum_suppressed("Mid") && back.partitions().iter().any(|p| p.suppressed)); + m.set_datum_suppressed("Mid", false).unwrap(); + assert!(m.usable_datum("Mid").is_ok()); + let plain = serde_json::to_string(&Model::default()).unwrap(); + assert!(!plain.contains("suppressed"), "nothing suppressed, nothing written"); + } + #[test] fn datums_are_normalised_and_partitions_refer_to_planes() { let mut m = Model::default(); @@ -196,7 +265,7 @@ mod tests { let Some(Datum::Axis { direction, .. }) = m.datum("Axis") else { panic!() }; assert!((direction[0] - 0.5f64.sqrt()).abs() < 1e-15); - let p = |name: &str, datum: &str| Partition { name: name.into(), datum: datum.into(), bodies: vec![] }; + let p = |name: &str, datum: &str| Partition { name: name.into(), datum: datum.into(), bodies: vec![], suppressed: false }; m.add_partition(p("Half", "Mid")).unwrap(); assert!(m.add_partition(p("Half", "Mid")).is_err()); assert!(m.add_partition(p("Bad", "Axis")).is_err()); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 89f38be..6e7204e 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -52,7 +52,7 @@ pub mod steps; pub mod tie; pub mod units; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde::{Deserialize, Serialize}; @@ -152,6 +152,9 @@ pub struct Model { /// In the order they cut. #[serde(skip_serializing_if = "Vec::is_empty")] partitions: Vec, + /// Datums kept but left out of the model ([`Model::datum_suppressed`]). + #[serde(skip_serializing_if = "BTreeSet::is_empty")] + suppressed_datums: BTreeSet, /// Edge seeds, sorted by edge. #[serde(skip_serializing_if = "Vec::is_empty")] seeds: Vec, @@ -200,6 +203,8 @@ struct ModelData { #[serde(default)] partitions: Vec, #[serde(default)] + suppressed_datums: BTreeSet, + #[serde(default)] seeds: Vec, } @@ -275,6 +280,9 @@ impl TryFrom for Model { for (n, v) in d.datums { m.add_datum(&n, v)?; } + for n in d.suppressed_datums { + m.set_datum_suppressed(&n, true)?; + } for p in d.partitions { m.add_partition(p)?; } @@ -408,6 +416,7 @@ impl Model { steps: self.steps.clone(), datums: self.datums.clone(), partitions: self.partitions.clone(), + suppressed_datums: self.suppressed_datums.clone(), seeds: self.seeds.clone(), } } diff --git a/crates/model/src/rename.rs b/crates/model/src/rename.rs index 91af06b..c92a923 100644 --- a/crates/model/src/rename.rs +++ b/crates/model/src/rename.rs @@ -226,6 +226,9 @@ impl Model { for p in &mut self.partitions { swap(&mut p.datum, from, to); } + if self.suppressed_datums.remove(from) { + self.suppressed_datums.insert(to.to_string()); + } } Kind::Section | Kind::Coupling | Kind::Contact | Kind::Tie | Kind::PointMass | Kind::Spring => {} } @@ -294,7 +297,7 @@ mod tests { m.set_load_active("go", "p", true).unwrap(); m.set_load_active("go", "f", true).unwrap(); m.add_datum("d", Datum::Plane { point: [0.0; 3], normal: [0.0, 0.0, 1.0] }).unwrap(); - m.add_partition(Partition { name: "half".into(), datum: "d".into(), bodies: Vec::new() }).unwrap(); + m.add_partition(Partition { name: "half".into(), datum: "d".into(), bodies: Vec::new(), suppressed: false }).unwrap(); m } diff --git a/docs/features.md b/docs/features.md index 9cbf607..1e9925c 100644 --- a/docs/features.md +++ b/docs/features.md @@ -17,10 +17,10 @@ the control channel and AI agents over MCP (docs/mcp.md) run the same commands. | Inspect | Bodies — List the open design's bodies, volumes and named faces, edges and vertices | ready | `geometry.bodies`, `geometry.centre` | | Inspect | Tessellate — Tessellate a body into a face-tagged boundary surface | ready | `geometry.tessellate` | | Inspect | Check Surface — Weld, closed, manifold, orientation and self-intersection checks; feature edges | ready | `mesh.surface.check` | -| Prepare | Datum Plane — A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis | ready | `geometry.datum`, (`geometry.datum.edit`), (`geometry.datum.delete`) | +| Prepare | Datum Plane — A datum plane: principal plane and offset, three points or datum points, point and normal, normal to an axis | ready | `geometry.datum`, (`geometry.datum.edit`), (`geometry.datum.delete`), (`geometry.datum.suppress`) | | Prepare | Datum Axis — A datum axis: principal axis, two points or datum points, point and direction | ready | `geometry.datum.axis`, (`geometry.datum.axis.edit`) | | Prepare | Datum Point — A datum point: coordinates, a mesh vertex, along an edge, a face's centre, an axis through a plane | ready | `geometry.datum.point`, (`geometry.datum.point.edit`) | -| Prepare | Partition — Cut bodies into cells by a datum plane; the two faces of each cut are paired by name | ready | `geometry.partition`, (`geometry.partition.delete`) | +| Prepare | Partition — Cut bodies into cells by a datum plane; the two faces of each cut are paired by name | ready | `geometry.partition`, (`geometry.partition.delete`), (`geometry.partition.suppress`) | | Prepare | Midsurface — Extract midsurfaces of thin parts for shell meshes | coming soon (M8) | | ## Mesh