//! Opening SolidCraft designs (`.solidcraft`): read the design, rebuild its parametric timeline //! with SolidCraft, and hand back its bodies with persistent face names. //! //! The design is rebuilt from its authoritative inputs (sketches, features, parameters); cached //! display meshes in the file are never used. [`open_design`] takes the file's bytes (reading //! files is `fieldcraft_io::fs`'s job, which in the browser holds the page's files). use solidcraft_doc::{Document, Model, naming}; use solidcraft_kernel::{KernelError, guard}; use crate::{Face, Result, Shape}; /// Largest design file opened (SolidCraft's own limit). pub const MAX_DESIGN_BYTES: usize = solidcraft_io::MAX_DESIGN_BYTES; /// A rebuilt design. #[derive(Debug, Clone)] pub struct Design { pub name: String, /// The bodies at the end of the timeline, in the design's order, placed in the world: bodies of /// components moved by their occurrences, one body per placement. pub bodies: Vec, /// Features that failed to rebuild. The design still opens with the bodies the rest made, /// as it does in SolidCraft. pub errors: Vec, /// The partitions applied ([`Design::partitioned`]), in order; empty for the design as opened. pub cuts: Vec, /// The bodies as opened, before any partition: what [`Design::partitioned`] starts from. pub(crate) source: std::sync::Arc>, /// The bodies as opened with the face merges on them ([`Design::merged`]): what /// [`Design::source_body`] and [`Design::features`] describe. pub(crate) opened: std::sync::Arc>, /// The timeline the design was rebuilt from ([`Design::features`]). pub(crate) history: Option>, /// The defeatures applied ([`Design::defeatured`]) and the design as opened before them. pub(crate) defeat: Option>, } /// A feature of the design that failed to rebuild. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FeatureError { pub feature: String, pub message: String, /// The bodies that may differ from the design as SolidCraft builds it: those the feature /// names, or every body when it names none of them. pub bodies: Vec, } impl FeatureError { /// One line for the person opening the design, naming the bodies it may change. pub fn warning(&self) -> String { format!("{} failed to build, so {} may differ from the design: {}", self.feature, quoted(&self.bodies), self.message) } } fn quoted(bodies: &[String]) -> String { match bodies { [] => "the design".into(), [b] => format!("body \"{b}\""), _ => format!("bodies {}", bodies.iter().map(|b| format!("\"{b}\"")).collect::>().join(", ")), } } /// The bodies of `bodies` that `feature` names anywhere in its saved form (targets, tools, /// selections); all of them when it names none. fn bodies_of(feature: Option<&solidcraft_doc::Feature>, bodies: &[Shape]) -> Vec { fn strings<'a>(v: &'a serde_json::Value, out: &mut Vec<&'a str>) { match v { serde_json::Value::String(s) => out.push(s), serde_json::Value::Array(a) => a.iter().for_each(|v| strings(v, out)), serde_json::Value::Object(o) => o.values().for_each(|v| strings(v, out)), _ => {} } } let value = feature.and_then(|f| serde_json::to_value(f).ok()).unwrap_or_default(); let mut named = Vec::new(); strings(&value, &mut named); let hit: Vec = bodies.iter().filter(|b| named.contains(&b.name.as_str())).map(|b| b.name.clone()).collect(); if hit.is_empty() { bodies.iter().map(|b| b.name.clone()).collect() } else { hit } } impl Design { pub fn body(&self, name: &str) -> Option<&Shape> { self.bodies.iter().find(|b| b.name == name) } /// The body `name` of the design as opened (before any partition), with its merged faces. pub fn source_body(&self, name: &str) -> Option<&Shape> { self.opened.iter().find(|b| b.name == name) } /// The body of the design as opened that `name` is, or that partitions cut it from /// (`Base` for `Base/a/b1`). pub fn source_of(&self, name: &str) -> Option<&str> { self.source .iter() .map(|b| b.name.as_str()) .filter(|s| name == *s || name.strip_prefix(s).is_some_and(|rest| rest.starts_with('/'))) .max_by_key(|s| s.len()) } } /// Open a design from its file bytes (a `.solidcraft` container or a JSON design) and rebuild it. pub fn open_design(bytes: &[u8]) -> Result { let doc = guard("read design", || solidcraft_io::read_design(bytes).map_err(|e| KernelError::Invalid(format!("not a readable design: {e}"))))?; rebuild(&doc) } /// Rebuild an in-memory document (the design half of [`open_design`]). pub(crate) fn rebuild(doc: &Document) -> Result { let model = guard("rebuild design", || { let mut m = Model::new(); m.evaluate(doc); Ok(m) })?; let history = crate::features::History { doc: doc.clone(), state: model.state(), errors: model.results.iter().filter(|r| !r.skipped).filter_map(|r| Some((r.id, r.error.clone()?))).collect(), }; // Placed in the world: a component's bodies through every occurrence of it (an instance is a // body of its own, named after its occurrence path), as SolidCraft displays and exports them. let state = guard("place components", || Ok(solidcraft_doc::world_state(doc, &model.state())))?; let mut bodies = Vec::with_capacity(state.bodies.len()); for b in &state.bodies { let faces = guard("name faces", || { let names = naming::face_names(b); Ok(names .iter() .enumerate() .map(|(i, name)| Face { name: name.clone(), exact: naming::face_name_exact(b, i), members: Vec::new() }) .collect()) })?; bodies.push(Shape { name: b.name.clone(), body: b.body.clone(), faces, face_map: Vec::new(), cuts: Vec::new() }); } // A feature that failed leaves a different solid than SolidCraft's: each one names the bodies // it would have changed, so opening never hands back a silently different solid. let errors: Vec = model .results .iter() .filter(|r| !r.skipped) .filter_map(|r| { let message = r.error.clone()?; let bodies = bodies_of(doc.features.iter().find(|f| f.id == r.id), &bodies); Some(FeatureError { feature: r.name.clone(), message, bodies }) }) .collect(); // The errors name the bodies as SolidCraft does; they follow the pieces' new names. let renamed = number_split_bodies(&mut bodies); let errors = errors .into_iter() .map(|mut e| { for b in &mut e.bodies { if let Some((_, new)) = renamed.iter().find(|(old, _)| old == b) { *b = new.clone(); } } e }) .collect(); Ok(Design { name: doc.name.clone(), source: std::sync::Arc::new(bodies.clone()), opened: std::sync::Arc::new(bodies.clone()), bodies, errors, cuts: Vec::new(), history: Some(std::sync::Arc::new(history)), defeat: None, }) } /// A body name without the suffixes SolidCraft gives the pieces of a split body: ` (1)` for the /// new piece and `'` for a piece kept under its old name, any number of times /// (`Bracket (1) (1)' (1)` → `Bracket`). pub(crate) fn split_base(name: &str) -> &str { let mut s = name; loop { let t = s.trim_end_matches('\''); let t = match t.strip_suffix(')').and_then(|r| r.rsplit_once(" (")) { Some((head, n)) if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) => head, _ => t, }; if t.len() == s.len() || t.is_empty() { return s; } s = t; } } /// Name the pieces of a body split in the design `.1`, `.2`, … by position, instead /// of the suffixes the splits stacked up (`Bracket (1) (1)' (1)`), which say nothing about where /// a piece is. Pieces are ordered by centroid, x first, then y, then z, on a grid of a millionth /// of their spread, so the names follow the geometry, not the order of the design's timeline. A /// group is left as it is when one of the new names is a body's name already. The pieces keep /// the group's places in the list of bodies, in the order of their numbers. Returns each /// renamed body's (old, new) name. fn number_split_bodies(bodies: &mut [Shape]) -> Vec<(String, String)> { let mut renamed = Vec::new(); let mut groups: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for (i, b) in bodies.iter().enumerate() { groups.entry(split_base(&b.name).to_string()).or_default().push(i); } for (base, mut members) in groups { if members.len() < 2 || members.iter().all(|&i| bodies.get(i).is_none_or(|b| b.name == base)) { continue; } let names: Vec = (1..=members.len()).map(|k| format!("{base}.{k}")).collect(); if bodies.iter().any(|b| names.contains(&b.name)) { continue; } let centroid = |i: usize| bodies.get(i).and_then(|b| b.measure().ok()).map_or([0.0; 3], |m| m.centroid); let centres: Vec<[f64; 3]> = members.iter().map(|&i| centroid(i)).collect(); let spread = (0..3) .map(|k| { let (lo, hi) = centres.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), c| (lo.min(c[k]), hi.max(c[k]))); hi - lo }) .fold(0.0f64, f64::max); let grid = if spread.is_finite() && spread > 0.0 { spread * 1e-6 } else { 1.0 }; let key = |j: usize| centres.get(j).map_or([0; 3], |c| c.map(|v| (v / grid).round() as i64)); let mut order: Vec = (0..members.len()).collect(); order.sort_by_key(|&j| (key(j), j)); // The group keeps its places in the list, now in the order of the numbers. let ordered: Vec = order.into_iter().filter_map(|j| members.get(j).and_then(|&i| bodies.get(i)).cloned()).collect(); members.sort_unstable(); for ((slot, mut shape), name) in members.into_iter().zip(ordered).zip(names) { renamed.push((std::mem::replace(&mut shape.name, name.clone()), name)); if let Some(b) = bodies.get_mut(slot) { *b = shape; } } } renamed } #[cfg(test)] mod tests { use super::*; #[test] fn split_suffixes_are_stripped() { assert_eq!(split_base("Bracket (1) (1)' (1)"), "Bracket"); assert_eq!(split_base("Bracket (1)'''"), "Bracket"); assert_eq!(split_base("Bracket"), "Bracket"); assert_eq!(split_base("Part (a)"), "Part (a)"); assert_eq!(split_base("(1)"), "(1)"); assert_eq!(split_base("'''"), "'''"); } #[cfg(feature = "scripts")] /// The corpus bracket, split 29 times in the design, opens as `Bracket.1` … `Bracket.30`, /// numbered by position. #[test] fn pieces_of_a_split_body_are_numbered_by_position() { let script = include_str!("../../../tests/corpus/filleted-bracket-blocks/recipe.json"); let d = open_design(&crate::script_design(script).unwrap()).unwrap(); let names: Vec<&str> = d.bodies.iter().map(|b| b.name.as_str()).collect(); let expected: Vec = (1..=30).map(|k| format!("Bracket.{k}")).collect(); assert_eq!(names, expected.iter().map(String::as_str).collect::>(), "listed in the order of their numbers"); let x = |n: &str| d.body(n).unwrap().measure().unwrap().centroid[0]; assert!(x("Bracket.1") <= x("Bracket.30")); } use crate::sample::{self, Plate}; fn face_areas(shape: &Shape) -> Vec { let mesh = shape.tessellate(0.01).unwrap(); let mut areas = vec![0.0; shape.faces().len()]; for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) { let [a, b, c] = t.map(|i| { let [x, y, z] = mesh.positions[i as usize]; solidcraft_geom::Vec3 { x, y, z } }); areas[*f as usize] += 0.5 * (b - a).cross(c - a).len(); } areas } #[test] fn opens_rebuilds_and_tessellates_a_design() { let plate = Plate::default(); let design = open_design(&sample::plate_design(&plate).unwrap()).unwrap(); assert!(design.errors.is_empty(), "{:?}", design.errors); assert_eq!(design.bodies.len(), 1); let body = &design.bodies[0]; let m = body.measure().unwrap(); let r = plate.hole_diameter / 2.0; let expect = (plate.width * plate.depth - std::f64::consts::PI * r * r) * plate.thickness; assert!((m.volume - expect).abs() < 1e-3 * expect, "{} vs {expect}", m.volume); // Four sides, top, bottom and the hole (a cylinder is two half faces). assert_eq!(m.faces, 8); assert_eq!(body.faces().len(), 8); let mesh = body.tessellate(0.01).unwrap(); let mut tags = mesh.tri_face.clone(); tags.sort_unstable(); tags.dedup(); assert_eq!(tags.len(), 8); assert!(tags.iter().all(|&t| (t as usize) < body.faces().len())); // Every face has its own name. let mut names: Vec<&str> = body.faces().iter().map(|f| f.name.as_str()).collect(); names.sort_unstable(); names.dedup(); assert_eq!(names.len(), 8, "{names:?}"); } #[test] fn face_names_survive_a_parameter_change() { let narrow = Plate::default(); let wide = Plate { width: 70.0, thickness: 15.0, ..Plate::default() }; let a = open_design(&sample::plate_design(&narrow).unwrap()).unwrap(); let b = open_design(&sample::plate_design(&wide).unwrap()).unwrap(); let (a, b) = (&a.bodies[0], &b.bodies[0]); let (area_a, area_b) = (face_areas(a), face_areas(b)); let pi = std::f64::consts::PI; // Each half of the hole. let hole = |p: &Plate| pi * p.hole_diameter * p.thickness / 2.0; let cap = |p: &Plate| p.width * p.depth - pi * p.hole_diameter * p.hole_diameter / 4.0; for face in a.faces() { let i = a.face_index(&face.name).unwrap() as usize; let j = b.face_index(&face.name).unwrap_or_else(|| panic!("{} is gone after the edit", face.name)) as usize; // The same face by name: the hole stays the hole, a cap stays a cap, and a side keeps // its own dimension (depth sides grow with the thickness only, width sides with both). let (x, y) = (area_a[i], area_b[j]); let close = |v: f64, e: f64| (v - e).abs() < 0.02 * e; let kind = if close(x, hole(&narrow)) { assert!(close(y, hole(&wide)), "{}: {y}", face.name); "hole" } else if close(x, cap(&narrow)) { assert!(close(y, cap(&wide)), "{}: {y}", face.name); "cap" } else if close(x, narrow.width * narrow.thickness) { assert!(close(y, wide.width * wide.thickness), "{}: {y}", face.name); "width side" } else { assert!(close(x, narrow.depth * narrow.thickness), "{}: {x}", face.name); assert!(close(y, wide.depth * wide.thickness), "{}: {y}", face.name); "depth side" }; assert!(face.exact, "{} ({kind}) is named from geometry", face.name); } } #[test] fn hostile_bytes_are_an_error() { for bytes in [&b""[..], b"PK\x03\x04", b"{", b"{}", b"\xff\xfe\x00", b"[1,2,3]", br#"{"format":"solidcraft/9999","name":"x"}"#] { assert!(open_design(bytes).is_err(), "{bytes:?}"); } assert!(open_design(&vec![b' '; MAX_DESIGN_BYTES + 1]).is_err()); } }