From 9b0f028f13e482f827bb36ea1a8774ae37531aef Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 14 Oct 2026 14:23:33 -0400 Subject: [PATCH] STEP assemblies: mesh a repeated part once and place it for its instances (#1152) The reader converts each item once, but every instance made its own display mesh (tessellation, seam and tangent flags), the most expensive step of opening a large assembly. A later instance of an item now shows its first instance's mesh, placed by the difference of the two placements. Only rigid placements share; split lines and any operation drop the link. Measurements and export read each instance's own geometry, so eval output is unchanged. Generated assemblies: mesh phase 2,215 -> 139 ms (901 bodies) and 1,028 -> 85 ms (5,401 bodies), with the same triangle counts. Co-authored-by: Claude Opus 5.5 --- crates/kernel/src/body.rs | 64 ++++++++++++++++++++++++++++++ crates/kernel/src/meshbody.rs | 2 + crates/kernel/src/native.rs | 1 + crates/kernel/src/splitface.rs | 2 + crates/kernel/src/step_in/mod.rs | 14 +++++++ crates/kernel/src/step_in/tests.rs | 25 ++++++++++++ crates/kernel/src/surfaces.rs | 1 + docs/perf.md | 26 ++++++++++++ 8 files changed, 135 insertions(+) diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 0fe66742..775841b0 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -45,6 +45,50 @@ pub struct Body { /// Exact history of the operation that made this body, relative to that operation's /// operands; `None` when the operation does not report one. pub(crate) history: Option>, + /// A placed copy of a solid that another body shows already (an instance of a part a STEP + /// assembly lists many times): its display mesh is that body's, placed by the matrix, so the + /// part is tessellated once rather than once per instance. Dropped by anything that changes + /// what the body shows (its geometry is never changed in place). + pub(crate) display_of: Option, [[f64; 4]; 4])>>, +} + +/// The display mesh of a body that several placed copies show (see `Body::display_of`): the +/// body, and its mesh once made. +#[derive(Debug)] +pub(crate) struct SharedDisplay { + pub(crate) original: Body, + mesh: std::sync::OnceLock>, +} + +impl SharedDisplay { + pub(crate) fn new(original: Body) -> Self { + SharedDisplay { original: Body { display_of: None, ..original }, mesh: std::sync::OnceLock::new() } + } +} + +/// `m` placed by the rigid matrix `t` (by columns, translation in `t[3]`, as the kernel's +/// transforms take it): positions, normals and edge polylines move, everything indexed stays. +/// `None` for anything but a rotation and translation (a reflection would turn the triangles +/// inside out). +fn placed_mesh(m: &Mesh, t: &[[f64; 4]; 4]) -> Option { + let r = |v: Vec3, w: f64| { + Vec3::new( + t[0][0] * v.x + t[1][0] * v.y + t[2][0] * v.z + w * t[3][0], + t[0][1] * v.x + t[1][1] * v.y + t[2][1] * v.z + w * t[3][1], + t[0][2] * v.x + t[1][2] * v.y + t[2][2] * v.z + w * t[3][2], + ) + }; + let det = t[0][0] * (t[1][1] * t[2][2] - t[1][2] * t[2][1]) - t[0][1] * (t[1][0] * t[2][2] - t[1][2] * t[2][0]) + + t[0][2] * (t[1][0] * t[2][1] - t[1][1] * t[2][0]); + if !(det > 0.0 && (det - 1.0).abs() < 1e-6) || t.iter().flatten().any(|x| !x.is_finite()) { + return None; + } + Some(Mesh { + positions: m.positions.iter().map(|p| r(*p, 1.0)).collect(), + normals: m.normals.iter().map(|n| r(*n, 0.0)).collect(), + edges: m.edges.iter().map(|e| e.iter().map(|p| r(*p, 1.0)).collect()).collect(), + ..m.clone() + }) } #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] @@ -538,6 +582,7 @@ impl Body { face_sources: None, regularization_protected: false, history: None, + display_of: None, }) } @@ -567,6 +612,7 @@ impl Body { face_sources: None, regularization_protected: false, history: None, + display_of: None, }) } @@ -869,8 +915,20 @@ impl Body { } /// The same body with the lines its faces were split along on purpose. + /// This body shows `shared`'s mesh placed by `t` (it is that body's solid, placed by `t`). + pub(crate) fn with_display_of(mut self, shared: std::sync::Arc, t: [[f64; 4]; 4]) -> Body { + if self.splits.is_none() && !self.is_mesh() { + self.display_of = Some(std::sync::Arc::new((shared, t))); + } + self + } + pub fn with_split_lines(mut self, s: Option>>>) -> Body { self.splits = s.filter(|l| !l.is_empty()); + // Split lines are drawn: the shared mesh does not have them. + if self.splits.is_some() { + self.display_of = None; + } self.regularization_protected |= self.splits.is_some(); self } @@ -1228,6 +1286,12 @@ impl Body { /// The reference mesher's angular display mesh: what [`Body::display_mesh_angular`] falls /// back to when no plan can be made. pub fn reference_display_mesh_angular(&self, tol: f64, angle: f64) -> Result { + if let Some((shared, t)) = self.display_of.as_deref() + && let Some(placed) = + shared.mesh.get_or_init(|| shared.original.reference_display_mesh_angular(tol, angle).ok()).as_ref().and_then(|m| placed_mesh(m, t)) + { + return Ok(placed); + } if self.is_mesh() { return self.flagged(self.tessellate(tol)?); } diff --git a/crates/kernel/src/meshbody.rs b/crates/kernel/src/meshbody.rs index e08411cf..78b76a2a 100644 --- a/crates/kernel/src/meshbody.rs +++ b/crates/kernel/src/meshbody.rs @@ -112,6 +112,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result { splits: None, face_sources: None, history: None, + display_of: None, regularization_protected: false, }) } @@ -208,6 +209,7 @@ impl Body { splits: self.splits.clone(), face_sources: None, history: None, + display_of: None, regularization_protected: self.regularization_protected, }) } diff --git a/crates/kernel/src/native.rs b/crates/kernel/src/native.rs index 585b924a..9fba0518 100644 --- a/crates/kernel/src/native.rs +++ b/crates/kernel/src/native.rs @@ -93,6 +93,7 @@ pub fn decode_native_body(bytes: &[u8]) -> Result { splits: None, face_sources: None, history: None, + display_of: None, regularization_protected: false, }; metadata::apply(&mut body, saved.metadata)?; diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 41f737f2..b481ffef 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -558,6 +558,7 @@ pub(crate) fn split_edges_at(body: &Body, points: &[Vec3]) -> Result { splits: body.splits.clone(), face_sources: None, history: None, + display_of: None, regularization_protected: true, }; Ok(crate::record::rebuilt_body(split, &[&body.solid], &rec)) @@ -1053,6 +1054,7 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul splits: body.splits.clone(), face_sources: None, history: None, + display_of: None, regularization_protected: true, }; Ok((crate::record::rebuilt_body(split, &[&body.solid], &imp.rec), imp)) diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index 53818e6a..d9d655e0 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -248,6 +248,9 @@ struct Reader<'a> { /// Items the file converts exactly once (see `index`): keeping their solids would hold a /// second copy of the whole import for nothing. once: HashSet, + /// An item placed more than once: its first instance's display, shared by the later ones + /// (with the placement that first instance had), so the part is meshed once. + shown: HashMap, mt::Matrix4)>, used_items: HashSet, /// Items already instanced for the product being read. node_items: HashSet, @@ -280,6 +283,7 @@ impl<'a> Reader<'a> { opacity: HashMap::new(), solids: HashMap::new(), once: HashSet::new(), + shown: HashMap::new(), used_items: HashSet::new(), node_items: HashSet::new(), visits: 0, @@ -680,6 +684,16 @@ impl<'a> Reader<'a> { .map(|face| Some(crate::FaceSource::Imported { item, face: *face, payload_sha256: None, producer: None })) .collect(); let body = body.with_face_sources(Some(std::sync::Arc::new(identities))); + // A later instance of an item shows its first instance's mesh, placed. + let body = match self.shown.get(&item) { + Some((shared, first)) => body.with_display_of(shared.clone(), to_array(&mat_mul(m, &rigid_inverse(first)))), + None => { + if !self.once.contains(&item) { + self.shown.insert(item, (Arc::new(crate::body::SharedDisplay::new(body.clone())), *m)); + } + body + } + }; self.bodies.push(ImportedBody { name, body, diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index 8b6fdf5a..f91b54f9 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -523,6 +523,31 @@ fn hand_written_assembly() { assert!((root.children[1].transform[3][0] - 100.0).abs() < 1e-9); } +/// A part an assembly places twice is meshed once: the second instance shows the first one's +/// display mesh, placed (the same triangles and faces, at its own place), and keeps its own +/// placed geometry for everything else. +#[test] +fn later_instances_show_the_first_instances_mesh_placed() { + let imp = step_import(&two_instance_assembly()).unwrap(); + let (first, second) = (&imp.bodies[0].body, &imp.bodies[1].body); + assert!(first.display_of.is_none() && second.display_of.is_some()); + let a = first.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE).unwrap(); + let b = second.reference_display_mesh_angular(0.01, crate::DISPLAY_ANGLE).unwrap(); + assert_eq!(a.triangles, b.triangles); + assert_eq!(a.tri_face, b.tri_face); + assert_eq!(a.edges.len(), b.edges.len()); + // Turned 90 degrees about Z and moved 100 mm along X. + for (p, q) in a.positions.iter().zip(&b.positions) { + assert!(q.dist(Vec3::new(100.0 - p.y, p.x, p.z)) < 1e-9, "{p:?} -> {q:?}"); + } + let own = measure(second).unwrap().bbox; + let shown = b.bounds(); + assert!(shown.min.dist(own.min) < 1e-6 && shown.max.dist(own.max) < 1e-6, "{shown:?} vs {own:?}"); + // Anything that changes what the body shows drops the shared mesh. + let split = second.clone().with_split_lines(Some(std::sync::Arc::new(vec![vec![Vec3::new(91.0, 0.0, 5.0), Vec3::new(91.0, 10.0, 5.0)]]))); + assert!(split.display_of.is_none()); +} + /// The import keeps a conversion only where the file can ask for the item again. A flat file of /// separate solids converts each item exactly once (no second copy of the whole import is held); /// an assembly that instances one item keeps it, and each instance still gets geometry of its diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index cbe98b4c..0ca9d542 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -41,6 +41,7 @@ fn surface_of(shell: mt::Shell) -> Body { splits: None, face_sources: None, history: None, + display_of: None, regularization_protected: false, } } diff --git a/docs/perf.md b/docs/perf.md index b8348e71..8ee6e0c3 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -1,5 +1,31 @@ # Design loading +## STEP assemblies: a repeated part is meshed once (2026-10-14) + +The reader already converts each B-rep item once and keeps it for the instances that list it +again. Every instance still became a body of its own geometry (it has to: features cut and join +it where it is), and each of those bodies made its own display mesh: tessellation, seam and +tangent flags, the most expensive step of opening a large assembly. A board with 60 of each +resistor meshed the same resistor 60 times. + +A later instance of an item now shows its first instance's display mesh, placed by the +difference of the two placements: the same triangles, faces and edge flags, moved. The mesh is +made once, by whichever instance is drawn first. Only a rotation and translation is shared +this way (anything else meshes itself), and anything that changes what a body shows (split +lines; any operation, which makes a new body) drops the link. Measurements, booleans and export +read the instance's own geometry as before, so `solidcraft-cli eval` of an assembly is unchanged. + +Two generated assemblies (SolidCraft designs with placed copies of components, exported as STEP +AP214 assemblies), `solidcraft-cli bench-open`, release build, shared 16-core Linux box: + +| file | bodies | mesh phase before | after | CPU before | after | peak RSS before | after | +|---|---:|---:|---:|---:|---:|---:|---:| +| 4 parts, 500 instances (bolts with a torus ring, brackets) | 901 | 2,215 ms | 139 ms | 28.4 s | 4.1 s | 918 MB | 749 MB | +| 10 filleted parts with 8 pins, 600 instances | 5,401 | 1,028 ms | 85 ms | 16.7 s | 6.0 s | 1,095 MB | 783 MB | + +Both import to the same triangle counts (4,404,412 and 4,316,412). Each instance's display plan +(the GPU viewport draws bodies from plans) is still made per instance; sharing those the same +way is the next step. ## Designs built by agents: work per command that grew with the design (2026-10-14) A design an agent builds over MCP is made of many small commands: a cylinder per spoke, a box