diff --git a/apps/solidcraft-cli/tests/action_matrix.rs b/apps/solidcraft-cli/tests/action_matrix.rs index 7800f26c..ab3bcb4d 100644 --- a/apps/solidcraft-cli/tests/action_matrix.rs +++ b/apps/solidcraft-cli/tests/action_matrix.rs @@ -1246,3 +1246,30 @@ fn a_plane_on_a_patterned_counterbore_after_a_thickness_change() { ]); reproduces(None, Value::Array(c)); } + +/// #707: the same design builds the same body every time, in one process too. Faces were put in +/// the hash order of their addresses in a few places (truck's connected components, the planar +/// boolean's rebuild, healing's merges), so a later boolean on the ribbed lid passed or failed +/// from run to run. +#[test] +fn the_ribbed_lid_builds_the_same_body_every_time() { + let path = root().join("examples/regressions/lid_ribs_flush_with_window.json"); + let fingerprint = || { + let s = load(&path).unwrap(); + let st = s.model.state(); + st.bodies + .iter() + .map(|b| { + let mesh = b.body.tessellate(0.1).unwrap(); + let bits = mesh.positions.iter().fold(0u64, |acc, p| { + acc.rotate_left(7).wrapping_add(p.x.to_bits() ^ p.y.to_bits().rotate_left(21) ^ p.z.to_bits().rotate_left(42)) + }); + format!("{} {} {bits:x}", b.name, b.body.face_count()) + }) + .collect::>() + }; + let first = fingerprint(); + for _ in 0..5 { + assert_eq!(fingerprint(), first); + } +} diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs index 64815de0..f943bd26 100644 --- a/crates/kernel/src/coplanar.rs +++ b/crates/kernel/src/coplanar.rs @@ -712,7 +712,7 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option> { // Bodies of several pieces stay in several pieces (each face's provenance follows it). let prov: HashMap = faces.iter().map(|f| f.id()).zip(from).collect(); let shell: mt::Shell = faces.into(); - let solid = Solid::try_new(shell.connected_components()).map_err(|e| fail(&e.to_string()))?; + let solid = Solid::try_new(crate::polybool::components_in_order(&shell)).map_err(|e| fail(&e.to_string()))?; let from: Vec = solid.face_iter().filter_map(|f| prov.get(&f.id()).cloned()).collect(); // Each edge's origins in the solid's edge order. let origins = crate::body::edge_order(solid.face_iter()).0.iter().map(|e| origins.get(&e.id()).cloned()).collect(); diff --git a/crates/kernel/src/heal.rs b/crates/kernel/src/heal.rs index 6ae0cf63..20a93e58 100644 --- a/crates/kernel/src/heal.rs +++ b/crates/kernel/src/heal.rs @@ -120,10 +120,12 @@ fn merge_coplanar(shell: &mt::Shell, tol: f64, keep: &[Vec]) -> Option<(mt && na.dot(*nb) > 1.0 - 1e-9 && (da - db).abs() < tol { + // The lesser root wins, so each group's root is its first face whatever order the + // edges (in hash order of their addresses) are met in (#707). let (ra, rb) = (find(&mut parent, a), find(&mut parent, b)); if ra != rb { - if let Some(x) = parent.get_mut(ra) { - *x = rb; + if let Some(x) = parent.get_mut(ra.max(rb)) { + *x = ra.min(rb); } merged_any = true; } @@ -232,11 +234,13 @@ fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option { let (pa, pm, pb) = (from_p3(other(a).point()), from_p3(mid.point()), from_p3(other(b).point())); let (Some(u), Some(w)) = ((pm - pa).normalized(), (pb - pm).normalized()) else { continue }; if u.dot(w) > 1.0 - 1e-10 { + // The lesser root wins: each run's root is its first edge whatever order the + // vertices are met in (#707). let (ra, rb) = (find(&mut parent, ia), find(&mut parent, ib)); if ra != rb - && let Some(x) = parent.get_mut(ra) + && let Some(x) = parent.get_mut(ra.max(rb)) { - *x = rb; + *x = ra.min(rb); any = true; } } @@ -244,7 +248,9 @@ fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option { if !any { return None; } - let mut runs: HashMap> = HashMap::new(); + // Ordered by run (and each run's ends by first use below): which end a joined edge starts + // from must not follow hash order, or the same heal gives a different body from run to run. + let mut runs: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for i in 0..edges.len() { let r = find(&mut parent, i); runs.entry(r).or_default().push(i); @@ -253,14 +259,17 @@ fn merge_collinear(shell: &mt::Shell, trace: &mut Trace) -> Option { let mut joined: Vec<(mt::Edge, HashMap)> = Vec::new(); let mut run_of: HashMap = HashMap::new(); for members in runs.values().filter(|m| m.len() > 1) { - let mut ends: HashMap = HashMap::new(); + let mut ends: Vec<(mt::VertexID, usize, mt::Vertex)> = Vec::new(); for i in members { let Some(e) = edges.get(*i) else { continue }; for v in [e.front(), e.back()] { - ends.entry(v.id()).or_insert((0, v.clone())).0 += 1; + match ends.iter_mut().find(|(id, ..)| *id == v.id()) { + Some(end) => end.1 += 1, + None => ends.push((v.id(), 1, v.clone())), + } } } - let mut ends = ends.into_values().filter(|(n, _)| *n == 1).map(|(_, v)| v); + let mut ends = ends.into_iter().filter(|(_, n, _)| *n == 1).map(|(.., v)| v); let (Some(first), Some(last), None) = (ends.next(), ends.next(), ends.next()) else { continue }; // Walk the run from one end, collecting its vertices in order. let mut order = vec![first.id()]; diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index b6ae89f2..582b4eba 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -3,7 +3,7 @@ //! as a wall flush with the side of its base. The polygons are rebuilt into a B-rep: coplanar //! pieces are merged into faces, T-junctions are split so neighbouring faces share edges. -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use solidcraft_geom::{Mesh, Vec3}; use truck_modeling::{self as mt, builder}; @@ -415,7 +415,7 @@ fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option = HashMap::new(); + let mut curved: BTreeMap = BTreeMap::new(); for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) { if infos.get(*fi as usize)?.plane_normal.is_some() { continue; @@ -589,13 +589,13 @@ struct Sources { /// faces (lesser face first). struct OperandEdges { ends: Vec<(Vec3, Vec3)>, - between: HashMap<(usize, usize), Vec>, + between: BTreeMap<(usize, usize), Vec>, } impl OperandEdges { fn of(b: &Body) -> OperandEdges { let (edges, bounds) = edge_order(b.solid.face_iter()); - let mut between: HashMap<(usize, usize), Vec> = HashMap::new(); + let mut between: BTreeMap<(usize, usize), Vec> = BTreeMap::new(); for (i, fs) in bounds.iter().enumerate() { if let [f, g] = fs.as_slice() { between.entry((*f.min(g), *f.max(g))).or_default().push(i); @@ -699,6 +699,25 @@ fn csg(pa: Vec, pb: Vec, op: BoolOp, eps: f64) -> Result> Ok(polys) } +/// `shell`'s connected components, each face in the shell's order and the components by their +/// first face. truck's own come out in the hash order of face addresses, so the same rebuild +/// gave a body with its faces in a different order from run to run (#707). +pub(crate) fn components_in_order(shell: &mt::Shell) -> Vec { + let pos: HashMap = shell.face_iter().enumerate().map(|(i, f)| (f.id(), i)).collect(); + let key = |f: &mt::Face| pos.get(&f.id()).copied().unwrap_or(usize::MAX); + let mut comps: Vec> = shell + .connected_components() + .into_iter() + .map(|c| { + let mut faces: Vec = c.into_iter().collect(); + faces.sort_by_key(|f| key(f)); + faces + }) + .collect(); + comps.sort_by_key(|c| c.first().map(key).unwrap_or(usize::MAX)); + comps.into_iter().map(mt::Shell::from).collect() +} + /// Position key for vertex merging. fn key(p: Vec3, q: f64) -> (i64, i64, i64) { ((p.x / q).round() as i64, (p.y / q).round() as i64, (p.z / q).round() as i64) @@ -711,7 +730,7 @@ fn key(p: Vec3, q: f64) -> (i64, i64, i64) { fn rebuild(polys: Vec, size: f64, sources: &Sources) -> Result { let q = size * 1e-7; // Global vertex table. - let mut index: HashMap<(i64, i64, i64), usize> = HashMap::new(); + let mut index: BTreeMap<(i64, i64, i64), usize> = BTreeMap::new(); let mut verts: Vec = Vec::new(); // Points within `q` share a vertex, also across the rounding grid's cell boundaries. let mut id = |p: Vec3, verts: &mut Vec| -> usize { @@ -755,15 +774,15 @@ fn rebuild(polys: Vec, size: f64, sources: &Sources) -> Result { let mean = edge_len.iter().sum::() / edge_len.len().max(1) as f64; let cell = mean.max(size * 1e-4).max(1e-9); let ckey = |p: Vec3| ((p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64); - let mut grid: HashMap<(i64, i64, i64), Vec> = HashMap::new(); + let mut grid: BTreeMap<(i64, i64, i64), Vec> = BTreeMap::new(); for (k, v) in verts.iter().enumerate() { grid.entry(ckey(*v)).or_default().push(k); } let mut directed: Vec<(usize, usize, usize)> = Vec::new(); // (plane group, from, to) // The polygons (by loop) each directed edge comes from. - let mut owners: HashMap<(usize, usize, usize), Vec> = HashMap::new(); + let mut owners: BTreeMap<(usize, usize, usize), Vec> = BTreeMap::new(); let mut groups: Vec = Vec::new(); - let mut group_of: HashMap<(i64, i64, i64, i64), usize> = HashMap::new(); + let mut group_of: BTreeMap<(i64, i64, i64, i64), usize> = BTreeMap::new(); for (li, (pl, l)) in loops.iter().enumerate() { let gk = ((pl.n.x * 1e8).round() as i64, (pl.n.y * 1e8).round() as i64, (pl.n.z * 1e8).round() as i64, (pl.w / (tol * 10.0)).round() as i64); let g = *group_of.entry(gk).or_insert_with(|| { @@ -814,7 +833,7 @@ fn rebuild(polys: Vec, size: f64, sources: &Sources) -> Result { } } // Interior edges cancel against their reverse within the same plane group. - let mut count: HashMap<(usize, usize, usize), i64> = HashMap::new(); + let mut count: BTreeMap<(usize, usize, usize), i64> = BTreeMap::new(); for (g, a, b) in &directed { *count.entry((*g, *a, *b)).or_insert(0) += 1; } @@ -838,12 +857,12 @@ fn rebuild(polys: Vec, size: f64, sources: &Sources) -> Result { } } let roots: Vec = (0..loops.len()).map(|li| find(&mut parent, li)).collect(); - let mut joined: HashMap> = HashMap::new(); + let mut joined: BTreeMap> = BTreeMap::new(); for (root, src) in roots.iter().zip(&srcs) { joined.entry(*root).or_default().insert(*src); } // What lies on each side of each boundary edge (from, to): the faces of its polygons. - let mut side: HashMap<(usize, usize), BTreeSet> = HashMap::new(); + let mut side: BTreeMap<(usize, usize), BTreeSet> = BTreeMap::new(); for (g, a, b) in &boundary { let set = side.entry((*a, *b)).or_default(); for li in owners.get(&(*g, *a, *b)).into_iter().flatten() { @@ -899,7 +918,7 @@ fn rebuild(polys: Vec, size: f64, sources: &Sources) -> Result { let mut orig: Vec = (0..verts.len()).collect(); split_pinched_vertices(&mut all_loops, &mut verts, &mut orig); let tv: Vec = verts.iter().map(|v| builder::vertex(p3(*v))).collect(); - let mut edges: HashMap<(usize, usize), mt::Edge> = HashMap::new(); + let mut edges: BTreeMap<(usize, usize), mt::Edge> = BTreeMap::new(); let mut faces: Vec = Vec::new(); let mut face_from: HashMap>> = HashMap::new(); for (g, plane) in groups.iter().enumerate() { @@ -990,7 +1009,7 @@ fn rebuild(polys: Vec, size: f64, sources: &Sources) -> Result { } } let shell: mt::Shell = faces.into(); - let parts = shell.connected_components(); + let parts = components_in_order(&shell); let solid = Solid::try_new(parts).map_err(|e| KernelError::Failed(format!("planar boolean: {e}")))?; let regions = solid.face_iter().map(|f| face_from.get(&f.id()).cloned().flatten().map(|o| (o, false))).collect::>>(); let tol = size * 1e-6; @@ -1018,7 +1037,7 @@ fn rebuild(polys: Vec, size: f64, sources: &Sources) -> Result { /// manifold one. fn split_pinched_vertices(loops: &mut [Vec>], verts: &mut Vec, orig: &mut Vec) { // Every corner: (group, loop, position), by vertex. - let mut corners: HashMap> = HashMap::new(); + let mut corners: BTreeMap> = BTreeMap::new(); for (g, ls) in loops.iter().enumerate() { for (li, l) in ls.iter().enumerate() { for (i, v) in l.iter().enumerate() { @@ -1055,7 +1074,7 @@ fn split_pinched_vertices(loops: &mut [Vec>], verts: &mut Vec, } } } - let mut fan_id: HashMap = HashMap::new(); + let mut fan_id: BTreeMap = BTreeMap::new(); for (k, c) in cs.iter().enumerate() { let root = find(&mut parent, k); let next = fan_id.len(); diff --git a/tests/action_matrix/baseline.json b/tests/action_matrix/baseline.json index d0262de0..93b1fc1b 100644 --- a/tests/action_matrix/baseline.json +++ b/tests/action_matrix/baseline.json @@ -200,7 +200,7 @@ "Lid/F10:+x|F10:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:+x|F10:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:+x|F4:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Lid/F10:+y": "face.angle_plane !face.draft_neutral !face.extrude face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", + "Lid/F10:+y": "face.angle_plane !face.draft_neutral !face.extrude !face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", "Lid/F10:+y|F10:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:+y|F1:+z#[F10:+y,F11:-y,F4:side:l6,F5:-x]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:+y|F4:side:l6#[F10:+y,F11:-y]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -209,7 +209,7 @@ "Lid/F10:+z": "face.angle_plane face.draft_neutral !face.extrude face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", "Lid/F10:+z|F10:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:+z|F5:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Lid/F10:-y": "face.angle_plane !face.draft_neutral !face.extrude face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", + "Lid/F10:-y": "face.angle_plane !face.draft_neutral !face.extrude !face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", "Lid/F10:-y|F1:+z#[F10:-y,F4:side:l5,F4:side:l6,F4:side:l8,F5:+x,F5:+y,F5:-x,F8:-y]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:-y|F4:side:l6#[F10:-y,F4:side:l5]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:-y|F4:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -219,7 +219,7 @@ "Lid/F11:+x|F11:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F11:+x|F11:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F11:+x|F4:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Lid/F11:+y": "face.angle_plane !face.draft_neutral !face.extrude face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", + "Lid/F11:+y": "face.angle_plane !face.draft_neutral !face.extrude !face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", "Lid/F11:+y|F11:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F11:+y|F1:+z#[F11:+y,F1:+x,F1:+y,F1:-x,F1:-y,F2:blend:F1:+x|F1:+y,F2:blend:F1:+x|F1:-y,F2:blend:F1:+y|F1:-x,F2:blend:F1:-x|F1:-y,F4:side:l1,F4:side:l2,F4:side:l3,F4:side:l4,F4:side:l6,F4:side:l7,F4:side:l8,F5:+x,F5:-x,F5:-y,F6:+x,F6:+y,F6:-x,F6:-y,F7:+x,F7:-x,F9:+y]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F11:+y|F4:side:l6#[F11:+y,F4:side:l7]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -228,7 +228,7 @@ "Lid/F11:+z": "face.angle_plane face.draft_neutral !face.extrude face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", "Lid/F11:+z|F11:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F11:+z|F5:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Lid/F11:-y": "face.angle_plane !face.draft_neutral !face.extrude face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", + "Lid/F11:-y": "face.angle_plane !face.draft_neutral !face.extrude !face.hole face.midplane !face.offset_plane !face.press_pull face.shell !face.sketch", "Lid/F11:-y|F1:+z#[F10:+y,F11:-y,F4:side:l6,F5:-x]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F11:-y|F4:side:l6#[F10:+y,F11:-y]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F11:-y|F4:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -2921,7 +2921,7 @@ "Case/F4:inner:F2:start#[F11:side:bs_hole]": "face.extrude face.hole face.midplane face.midplane+edit face.offset_plane face.offset_plane+edit face.press_pull face.shell face.sketch face.sketch+edit", "Case/F4:inner:F2:start#[F12:face:30,F12:face:31]": "face.extrude !face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Case/F4:inner:F2:start#[F12:face:34,F12:face:35]": "face.extrude !face.hole face.midplane face.offset_plane face.offset_plane+edit face.press_pull face.shell face.sketch", - "Case/F4:inner:F2:start#[F12:face:38,F12:face:39]": "face.extrude face.hole !face.hole+edit face.midplane !face.midplane+edit face.offset_plane face.offset_plane+edit face.press_pull face.shell face.sketch", + "Case/F4:inner:F2:start#[F12:face:38,F12:face:39]": "face.extrude !face.hole !face.hole+edit face.midplane !face.midplane+edit face.offset_plane face.offset_plane+edit face.press_pull face.shell face.sketch", "Case/F4:inner:F2:start#[F6:side:so_hole@F7.1]": "face.extrude !face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Case/F4:inner:F2:start#[F6:side:so_hole@F7.1]|F6:side:so_hole@F7.1#1": "edge.chamfer edge.fillet edge.normal_plane edge.project", "Case/F4:inner:F2:start#[F6:side:so_hole@F7.1]|F6:side:so_hole@F7.1#2": "edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -9709,7 +9709,7 @@ "Lid/F10:+x|F10:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:+x|F10:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:+x|F4:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Lid/F10:+y": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", + "Lid/F10:+y": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Lid/F10:+y|F10:+z": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:+y|F1:+z#[F4:side:l5,F4:side:l6,F4:side:l7,F4:side:l8,F5:+x,F5:+y,F5:-x,F5:-y]#[F10:+y,F11:-y,F4:side:l6,F5:-x]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:+y|F4:side:l6#[F10:+y,F4:side:l7]#[F10:+y,F11:-y]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -9718,7 +9718,7 @@ "Lid/F10:+z": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Lid/F10:+z|F10:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:+z|F5:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Lid/F10:-y": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", + "Lid/F10:-y": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Lid/F10:-y|F1:+z#[F4:side:l5,F4:side:l6,F4:side:l7,F4:side:l8,F5:+x,F5:+y,F5:-x,F5:-y]#[F10:-y,F4:side:l5,F4:side:l6,F4:side:l8,F5:+x,F5:+y,F5:-x,F8:-y]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:-y|F4:side:l6#[F10:-y,F4:side:l5]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F10:-y|F4:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", @@ -9737,7 +9737,7 @@ "Lid/F11:+z": "face.angle_plane face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Lid/F11:+z|F11:-y": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F11:+z|F5:-x": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", - "Lid/F11:-y": "face.angle_plane !face.draft_neutral face.extrude face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", + "Lid/F11:-y": "face.angle_plane !face.draft_neutral face.extrude !face.hole face.midplane face.offset_plane face.press_pull face.shell face.sketch", "Lid/F11:-y|F1:+z#[F4:side:l5,F4:side:l6,F4:side:l7,F4:side:l8,F5:+x,F5:+y,F5:-x,F5:-y]#[F10:+y,F11:-y,F4:side:l6,F5:-x]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F11:-y|F4:side:l6#[F10:+y,F4:side:l7]#[F10:+y,F11:-y]": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", "Lid/F11:-y|F4:top": "edge.axis edge.chamfer edge.fillet edge.normal_plane edge.project", diff --git a/vendor/truck-shapeops/src/transversal/faces_classification/mod.rs b/vendor/truck-shapeops/src/transversal/faces_classification/mod.rs index fddc201f..f0bc1fd0 100644 --- a/vendor/truck-shapeops/src/transversal/faces_classification/mod.rs +++ b/vendor/truck-shapeops/src/transversal/faces_classification/mod.rs @@ -40,8 +40,16 @@ impl FacesClassification { let [and, or, unknown] = self.and_or_unknown(); let and_boundary = and.extract_boundaries(); let or_boundary = or.extract_boundaries(); + // SolidCraft: `connected_components` walks a map keyed by face pointers, so each + // component's face order (and with it which boundary edge comes first) followed the + // heap addresses: the same Boolean could classify a component differently from run to + // run. Put each component's faces back in the shell's order first. + let position: HashMap<_, usize> = unknown.face_iter().enumerate().map(|(i, face)| (face.id(), i)).collect(); let components = unknown.connected_components(); for comp in components { + let mut faces: Vec<_> = comp.face_iter().cloned().collect(); + faces.sort_by_key(|face| position.get(&face.id()).copied().unwrap_or(usize::MAX)); + let comp: Shell = faces.into_iter().collect(); let boundary = comp.extract_boundaries(); let Some(first) = boundary.first().and_then(|w| w.edge_iter().next()).map(|e| e.id()) else { continue }; let set = if and_boundary.iter().flatten().any(|edge| edge.id() == first) {