Multi profile extrusion performance (#2064)

Thanks @Koschtetnix.
This commit is contained in:
Koschtetnix authored and GitHub committed 2026-10-17 18:30:52 -04:00
1 parent 180e62c981
commit 487aa64e10
21 files changed
+1624 -78

No files matched your search

+19
View File
@@ -274,6 +274,25 @@ completed drawings publish atomically. The browser still exports synchronously.
## 0.2.1 critical fixes ## 0.2.1 critical fixes
- Separated multi-profile Join/Cut extrusions into one solid target batch their tools using
conservative construction bounds, avoiding a Boolean rebuild for every profile. Live
extrusion previews build only their tools; confirmation validates the final Boolean.
- Coplanar selected body faces share one linked sketch and one extrusion. Automatic Join/Cut
keeps the evaluator's participant scope, including parts of an empty imported component. Desktop OK/Enter
computes on a scratch session in the background, with atomic acceptance and cancellation.
Planar section stitching projects lines/arcs analytically and removes duplicate endpoints.
- Imported rotated face groups can check all full start-face seams in one guarded
construction. Separated tools may batch a Join when each meets the same single body;
nearby uninvolved bodies retain the original contact decisions. Regression comparisons
cover all four extrusion operations.
- Inward multi-face cuts retain every eligible target and subtract separated tools together.
Safe interior classification handles roundoff at shared imported line/arc endpoints.
Straight extrusion feedback caches existing face/profile triangles and updates the display
directly, including a newly opened STEP's single identity component placement; other
extents, moved or repeated components and curved sketch profiles keep the Engine preview.
- The extrusion fixes retain tracked Boolean history and the current `solidcraft` packages.
Confirmation and tool previews use main's shared background calculation. Main's collapsed
blend-wall refusal and its tests are retained, including smaller valid radii and mirrored parts.
- Axis view and navigation controls stay anchored to the viewport when feature dialogs or - Axis view and navigation controls stay anchored to the viewport when feature dialogs or
the command palette open, at normal and enlarged display scales. Fit still accounts for the command palette open, at normal and enlarged display scales. Fit still accounts for
dialog coverage; covered controls do not receive clicks through dialogs. dialog coverage; covered controls do not receive clicks through dialogs.
+115 -19
View File
@@ -1581,6 +1581,29 @@ fn apply_beside_sheet_cut(st: &mut ModelState, f: &Feature, tools: Vec<Traced>,
apply_traced(st, f, tools, op, &rest) apply_traced(st, f, tools, op, &rest)
} }
/// Only one target meets every separated tool. A tool with no contact must retain
/// Join's first-target fallback; tools touching different targets must retain absorption order.
fn common_join_target(state: &ModelState, indices: &[usize], tools: &[Traced]) -> Result<Option<usize>> {
if indices.len() > 64 {
return Ok(None);
}
let bodies: Vec<_> = tools.iter().map(|tool| tool.body.clone()).collect();
let mut contacts = vec![None; tools.len()];
for &index in indices {
let body = state.bodies.get(index).ok_or_else(|| DocError::Invalid("join target disappeared".into()))?;
for (contact, meets) in contacts.iter_mut().zip(body.body.meets_each(&bodies)?) {
if meets {
if contact.is_some() {
return Ok(None);
}
*contact = Some(index);
}
}
}
let common = contacts.first().copied().flatten();
Ok(common.filter(|index| contacts.iter().all(|contact| *contact == Some(*index))))
}
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> { fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets) apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
} }
@@ -1636,6 +1659,41 @@ fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Ope
} }
} }
} }
// Batching solid targets must preserve Join's absorption order,
// sheet material tracking and the separate bodies of a New Body extrusion.
if tools.len() > 1
&& matches!(f.kind, FeatureKind::Extrude { .. })
&& matches!(op, Operation::Join | Operation::Cut)
&& !target_idx.is_empty()
&& target_idx.iter().all(|i| state.bodies.get(*i).is_some_and(|b| !b.body.is_mesh() && !state.sheets.iter().any(|s| s.body == b.name)))
&& let Ok(Some(compound)) = kernel::compound_disjoint_extrusions(&tools.iter().map(|tool| tool.body.clone()).collect::<Vec<_>>())
{
let target = if target_idx.len() == 1 {
target_idx.first().copied()
} else if op == Operation::Join {
common_join_target(state, &target_idx, &tools).ok().flatten()
} else {
None
};
// The optimization must not reject geometry the original sequential path can
// handle. A failed batch leaves the model untouched and retries the original tools.
let batch_targets = if op == Operation::Cut {
// Subtracting a union equals subtracting its separated tools in order.
// Keep ALL original participants; no sampled contact test excludes a body.
Some(target_idx.iter().filter_map(|index| state.bodies.get(*index).map(|body| body.name.clone())).collect::<Vec<_>>())
} else {
target.and_then(|index| state.bodies.get(index)).map(|body| vec![body.name.clone()])
};
if let Some(names) = batch_targets {
let mut batched = state.clone();
// Preserve the original participants. For Join, the compound's larger
// size must not expand the contact tolerance and absorb a nearby body.
if apply_traced(&mut batched, f, vec![Traced::step(compound, &tools.iter().collect::<Vec<_>>())], op, &names).is_ok() {
*state = batched;
return Ok(());
}
}
}
match op { match op {
Operation::Join => { Operation::Join => {
// Each tool joins every target it meets into one body, kept under the first one's // Each tool joins every target it meets into one body, kept under the first one's
@@ -3219,25 +3277,63 @@ fn feature_tools_with_constructor(
// Each tool's extent along the normal (they differ where one is run on into a face). // Each tool's extent along the normal (they differ where one is run on into a face).
let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len()); let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len());
let plain_targets: Vec<String> = crate::plain(targets); let plain_targets: Vec<String> = crate::plain(targets);
let surroundings = Surroundings::of(st, *operation, &plain_targets); let exact_planar_cut = *operation == Operation::Cut
// Trimming may discard a tool's material, so only bodies this feature && ss.sketch.links.len() > 1
// actually joins can establish that the discarded end is already filled. && ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. }))
let participants = if plain_targets.is_empty() { &f.participants } else { &plain_targets }; && st
let trim_targets: Vec<String> = st .bodies
.bodies .iter()
.iter() .filter(|body| plain_targets.is_empty() || plain_targets.iter().any(|target| target == "*" || target == &body.name))
.filter(|b| { .all(|body| kernel::is_planar(&body.body));
if participants.is_empty() { let mut touching_batch = false;
b.component == f.component || b.feature == f.id // Generated face profiles have full source seams. Check all of them in
} else { // one guarded construction; arbitrary sketch profiles retain their existing path.
participants.iter().any(|t| t == "*" || t == &b.name) if *operation == Operation::Join
} && regions.len() > 1
}) && ss.sketch.links.len() > 1
.map(|b| b.name.clone()) && ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. }))
.collect(); {
let join_surroundings = let exact = regions
if *operation == Operation::Join && !trim_targets.is_empty() { Surroundings::of(st, *operation, &trim_targets) } else { None }; .iter()
for r in &regions { .map(|region| centered_extrude(&ss.plane, region, lo + off, hi + off, Some(&ss)))
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect::<Vec<_>>();
if let Ok(Some(compound)) = kernel::compound_disjoint_extrusions(&exact.iter().map(|tool| tool.body.clone()).collect::<Vec<_>>())
&& st
.bodies
.iter()
.filter(|body| plain_targets.is_empty() || plain_targets.contains(&body.name))
.any(|body| kernel::can_join_all_touching_faces(&body.body, &compound))
{
tools = exact;
touching_batch = true;
}
}
let surroundings = if exact_planar_cut || touching_batch { None } else { Surroundings::of(st, *operation, &plain_targets) };
let join_surroundings = if *operation == Operation::Join && !touching_batch {
// Trimming may discard a tool's material, so only bodies this feature
// actually joins can establish that the discarded end is already filled.
let participants = if plain_targets.is_empty() { &f.participants } else { &plain_targets };
let trim_targets: Vec<String> = st
.bodies
.iter()
.filter(|b| {
if participants.is_empty() {
b.component == f.component || b.feature == f.id
} else {
participants.iter().any(|t| t == "*" || t == &b.name)
}
})
.map(|b| b.name.clone())
.collect();
if trim_targets.is_empty() { None } else { Surroundings::of(st, *operation, &trim_targets) }
} else {
None
};
for r in regions.iter().filter(|_| !touching_batch) {
if *operation == Operation::Join { if *operation == Operation::Join {
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?; let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
if exact.iter().all(|tool| { if exact.iter().all(|tool| {
@@ -584,3 +584,227 @@ fn closed_sweep_redefines_to_a_half_turn_torus_cut() {
assert_eq!(mesh.contains(point), material, "material at {point:?}"); assert_eq!(mesh.contains(point), material, "material at {point:?}");
} }
} }
#[test]
fn many_extrusion_profiles_preserve_material_and_body_identity() {
for operation in ["join", "cut", "new"] {
let mut s = Session::default();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "body_name": "Stock"})).unwrap();
s.execute("sketch.create", &json!({"plane": {"origin": [0, 0, 20], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})).unwrap();
for i in 0..6 {
for j in 0..4 {
let (x, y) = (2 + i * 6, 2 + j * 7);
s.execute("sketch.rectangle.two_point", &json!({"p0": [x, y], "p1": [x + 4, y + 4]})).unwrap();
}
}
s.execute("sketch.finish", &json!({})).unwrap();
let distance = if operation == "cut" { -5 } else { 5 };
let before = s.revision;
s.execute("solid.extrude", &json!({"distance": distance, "operation": operation})).unwrap();
let state = s.model.state();
assert_eq!(state.bodies.len(), if operation == "new" { 25 } else { 1 });
let expected = 24_000.0
+ match operation {
"join" => 1920.0,
"cut" => -1920.0,
_ => 0.0,
};
assert!((volume(&s, "Stock") - expected).abs() < 1e-6);
let stock = state.body("Stock").unwrap();
for (point, present) in [([3.0, 3.0, 22.0], operation == "join"), ([3.0, 3.0, 18.0], operation != "cut"), ([7.0, 3.0, 18.0], true)] {
assert_eq!(stock.mesh().contains(solidcraft_geom::Vec3::from(point)), present, "{operation}: {point:?}");
}
assert!(stock.body.validity().is_empty());
assert!(s.revision > before);
s.execute("edit.undo", &json!({})).unwrap();
assert!((volume(&s, "Stock") - 24_000.0).abs() < 1e-6);
s.execute("edit.redo", &json!({})).unwrap();
assert!((volume(&s, "Stock") - expected).abs() < 1e-6);
}
}
#[test]
fn many_circular_extrusion_profiles_cut_a_curved_target() {
let mut s = Session::default();
s.execute("solid.cylinder", &json!({"radius": 20, "height": 20, "body_name": "Stock"})).unwrap();
s.execute("sketch.create", &json!({"plane": {"origin": [0, 0, -1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}})).unwrap();
for x in [-9, -3, 3, 9] {
for y in [-9, -3, 3, 9] {
s.execute("sketch.circle.center", &json!({"center": [x, y], "radius": 1})).unwrap();
}
}
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"distance": 22, "operation": "cut", "targets": ["Stock"]})).unwrap();
// The rebuilt caps mesh more finely than the original cylinder. Compare with the
// analytic material volume, rather than subtracting two differently faceted meshes.
let expected = (400.0 - 16.0) * std::f64::consts::PI * 20.0;
let actual = volume(&s, "Stock");
assert!((actual - expected).abs() < expected * 1e-5, "{actual} vs {expected}");
let state = s.model.state();
assert_eq!(state.bodies.len(), 1);
let stock = state.body("Stock").unwrap();
assert!(stock.body.validity().is_empty());
let mesh = stock.mesh();
assert!(mesh.contains(solidcraft_geom::Vec3::new(0.0, 0.0, 10.0)));
for x in [-9.0, -3.0, 3.0, 9.0] {
for y in [-9.0, -3.0, 3.0, 9.0] {
assert!(!mesh.contains(solidcraft_geom::Vec3::new(x, y, 10.0)), "hole at {x}, {y}");
}
}
}
#[test]
fn coplanar_body_faces_extrude_together_and_replay_their_links() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":64,"width":64,"height":20,"body_name":"Plate"})).unwrap();
s.execute("sketch.create", &json!({"plane":{"origin":[0,0,20],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
let mut faces = Vec::new();
for i in 0..8 {
for j in 0..8 {
let (x, y) = (2 + i * 7, 2 + j * 7);
s.execute("sketch.rectangle.two_point", &json!({"p0":[x,y],"p1":[x+4,y+4]})).unwrap();
faces.push(json!([x + 2, y + 2, 26]));
}
}
s.execute("sketch.finish", &json!({})).unwrap();
s.execute("solid.extrude", &json!({"distance":6,"operation":"join"})).unwrap();
let (features, undo) = (s.doc.features.len(), s.undo.len());
s.execute("solid.extrude", &json!({"faces":faces,"distance":-7,"operation":"cut"})).unwrap();
assert_eq!(s.doc.features.len(), features + 2, "one linked sketch and one extrusion");
assert_eq!(s.undo.len(), undo + 1);
let actual = volume(&s, "Plate");
let state = s.model.state();
let sketch = state.sketch(4).unwrap();
assert!((actual - 80_896.0).abs() < 1e-5, "volume {actual}; plane {:?}; profiles {}", sketch.plane, sketch.profiles.len());
assert_eq!(s.model.state().bodies.len(), 1);
let document = serde_json::from_value(serde_json::to_value(s.doc.as_ref()).unwrap()).unwrap();
let mut reopened = Session { doc: std::sync::Arc::new(document), ..Session::default() };
reopened.refresh();
assert!((volume(&reopened, "Plate") - 80_896.0).abs() < 1e-5);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc.features.len(), features);
s.execute("edit.redo", &json!({})).unwrap();
assert!((volume(&s, "Plate") - 80_896.0).abs() < 1e-5);
}
#[test]
fn unsupported_face_groups_are_atomic() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
let before = serde_json::to_value(s.doc.as_ref()).unwrap();
let (revision, undo) = (s.revision, s.undo.len());
for faces in [
json!([]),
json!([[5, 5, 10], [5, 5, 0]]),
json!([[5, 5, 10], [5, 0, 5]]),
json!([[5, 5, 10], [1000, 1000, 1000]]),
json!(vec![[5, 5, 10]; 1025]),
] {
assert!(s.execute("solid.extrude", &json!({"faces":faces,"distance":5,"operation":"join"})).is_err());
assert_eq!(serde_json::to_value(s.doc.as_ref()).unwrap(), before);
assert_eq!((s.revision, s.undo.len()), (revision, undo));
}
}
#[test]
fn imported_rotated_face_extrusions_preserve_other_operations_and_bodies() {
use solidcraft_geom::Vec3;
let mut source = Session::default();
source.execute("solid.box", &json!({"corner":[-16,-16,-3],"length":32,"width":32,"height":1,"body_name":"Other"})).unwrap();
source.execute("solid.cylinder", &json!({"radius":14,"height":8,"body_name":"Stock"})).unwrap();
let mut outlines = Vec::new();
let mut centers = Vec::new();
for i in 0..12 {
let angle = i as f64 * std::f64::consts::TAU / 12.;
let (c, s) = (angle.cos(), angle.sin());
let outline: Vec<_> =
[(-2., -2.), (2., -2.), (2., 2.), (-2., 2.)].into_iter().map(|(x, y)| [10.7 * c + x * c - y * s, 10.7 * s + x * s + y * c]).collect();
outlines.push(outline);
centers.push([10.7 * c, 10.7 * s, 12.]);
}
let draw = |session: &mut Session, height: f64| {
session.execute("sketch.create", &json!({"plane":{"origin":[0,0,height],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
for outline in &outlines {
session.execute("sketch.line", &json!({"points":outline,"closed":true,"infer":false})).unwrap();
}
session.execute("sketch.finish", &json!({})).unwrap();
};
draw(&mut source, 8.);
source.execute("solid.extrude", &json!({"distance":4,"operation":"join"})).unwrap();
// An original public-safe STEP example, with rotated full-face seams and a
// curved target. No vendor/user design is part of this regression.
let path = std::env::temp_dir().join(format!("solidcraft-face-extrusion-{}.step", std::process::id()));
source.execute("file.export", &json!({"path":path.to_string_lossy()})).unwrap();
let mut imported = Session::default();
imported.execute("doc.open", &json!({"path":path.to_string_lossy()})).unwrap();
std::fs::remove_file(path).unwrap();
let original = imported.model.state();
let other = original.body("Other").unwrap().body.clone();
for (operation, distance) in [("join", 2.), ("cut", -1.), ("new", 2.), ("intersect", -1.)] {
let mut grouped = imported.scratch();
grouped.execute("solid.extrude", &json!({"faces":centers,"distance":distance,"operation":operation})).unwrap();
let mut reference = imported.scratch();
draw(&mut reference, 12.);
if matches!(operation, "join" | "cut") {
// Individual tools retain the original Boolean and participant path.
for index in 0..12 {
reference
.execute("solid.extrude", &json!({"sketch":"Sketch1","profiles":[index],"distance":distance,"operation":operation}))
.unwrap();
}
} else {
reference.execute("solid.extrude", &json!({"distance":distance,"operation":operation})).unwrap();
}
let actual = grouped.model.state();
let expected = reference.model.state();
assert_eq!(actual.bodies.len(), expected.bodies.len(), "{operation}");
for body in &actual.bodies {
let reference = expected.body(&body.name).unwrap();
// Fixed fine chord tolerance: cap subdivision can change the default
// adaptive mesh's cylinder faceting without changing its analytic surface.
let (a, b) = (body.body.tessellate(1e-5).unwrap().measure().volume, reference.body.tessellate(1e-5).unwrap().measure().volume);
assert!((a - b).abs() < b.max(1.) * 1e-6, "{operation}: {} {a} vs {b}", body.name);
assert!(body.body.validity().is_empty(), "{operation}: {}", body.name);
let (a, b) = (body.mesh(), reference.mesh());
for center in &centers {
for z in [7., 11.5, 13.] {
let point = Vec3::new(center[0], center[1], z);
assert_eq!(a.contains(point), b.contains(point), "{operation}: {} {point:?}", body.name);
}
}
}
if operation != "intersect" {
let unchanged = &actual.body("Other").unwrap().body;
assert_eq!(unchanged.face_count(), other.face_count());
assert_eq!(unchanged.color(), other.color());
assert_eq!(unchanged.tessellate(0.01).unwrap().positions, other.tessellate(0.01).unwrap().positions, "unaffected imported body");
}
}
}
#[test]
fn multi_target_join_keeps_nearby_bodies_and_original_absorption_order() {
for (second_x, expected_count) in [(10.0015, 2), (9., 1)] {
let mut session = Session::default();
session.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
session.execute("solid.box", &json!({"corner":[second_x,8,10],"length":1,"width":1,"height":2,"body_name":"Other"})).unwrap();
session.execute("sketch.create", &json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
for origin in [[0., 0.], [8., 8.]] {
session.execute("sketch.rectangle.two_point", &json!({"p0":origin,"p1":[origin[0]+2.,origin[1]+2.]})).unwrap();
}
session.execute("sketch.finish", &json!({})).unwrap();
let mut reference = session.scratch();
for index in 0..2 {
reference.execute("solid.extrude", &json!({"sketch":"Sketch1","profiles":[index],"distance":2,"operation":"join"})).unwrap();
}
session.execute("solid.extrude", &json!({"distance":2,"operation":"join"})).unwrap();
assert_eq!(session.model.state().bodies.len(), expected_count);
assert_eq!(
session.model.state().bodies.iter().map(|body| body.name.clone()).collect::<Vec<_>>(),
reference.model.state().bodies.iter().map(|body| body.name.clone()).collect::<Vec<_>>()
);
for body in &session.model.state().bodies {
assert!((volume(&session, &body.name) - volume(&reference, &body.name)).abs() < 1e-6);
}
}
}
+78
View File
@@ -84,6 +84,10 @@ type FacePick = (std::sync::Arc<Mesh>, u32, f64, String);
/// equally near (bodies touching flush), the pick is ambiguous: `Err` names those bodies. /// equally near (bodies touching flush), the pick is ambiguous: `Err` names those bodies.
fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> { fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
let st = s.model.state(); let st = s.model.state();
face_at_state(&st, p, only)
}
pub(super) fn face_at_state(st: &solidcraft_doc::ModelState, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
let mut best: Option<FacePick> = None; let mut best: Option<FacePick> = None;
let mut tied: Vec<String> = Vec::new(); let mut tied: Vec<String> = Vec::new();
for b in st.bodies.iter().filter(|b| only.is_none_or(|n| b.name == n)) { for b in st.bodies.iter().filter(|b| only.is_none_or(|n| b.name == n)) {
@@ -127,6 +131,80 @@ fn xy(v: Vec2) -> Value {
json!([v.x, v.y]) json!([v.x, v.y])
} }
/// Coplanar faces share one linked profile sketch and one Extrude feature. Resolve every
/// pick before editing the document, so later picks never search an already modified body.
pub(super) fn sketch_of_faces(s: &mut Session, points: &[Vec3], cmd: &str, face_body: Option<&str>) -> Result<(u64, Vec<Vec2>, String)> {
let first = points.first().copied().ok_or_else(|| bad(cmd, "faces must not be empty"))?;
if points.len() > 1024 {
return Err(bad(cmd, "faces exceed the 1024-face limit"));
}
let state = s.model.state();
let mut picked = Vec::new();
let mut common: Option<(String, solidcraft_geom::Plane)> = None;
for &point in points {
if !point.is_finite() {
return Err(bad(cmd, "faces must be finite points"));
}
let (mesh, index, distance, name) = face_at_in(s, point, face_body)
.map_err(|_| bad(cmd, "ambiguous face; specify face_body"))?
.ok_or_else(|| bad(cmd, "no body face at a selected point"))?;
let body = state.body(&name).ok_or_else(|| bad(cmd, "selected body disappeared"))?;
let plane = body.body.planar_face(index as usize).ok_or_else(|| bad(cmd, "faces must be planar"))?;
let tolerance = (body.body.size() * 1e-9).max(1e-8);
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
return Err(bad(cmd, "no body face at a selected point"));
}
if let Some((previous, base)) = &common {
if *previous != name || base.normal().dot(plane.normal()) < 1.0 - 1e-9 || base.height(plane.origin).abs() > tolerance {
return Err(bad(cmd, "faces must be coplanar, equally oriented faces of one body"));
}
} else {
common = Some((name.clone(), plane));
}
if picked.iter().any(|(face, _)| *face == index as usize) {
continue;
}
let inside = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter(|(_, face)| **face == index)
.filter_map(|(triangle, _)| mesh.tri(triangle))
.max_by(|[a, b, c], [x, y, z]| ((*b - *a).cross(*c - *a).len()).total_cmp(&((*y - *x).cross(*z - *x).len())))
.map(|[a, b, c]| (a + b + c) / 3.0)
.ok_or_else(|| bad(cmd, "degenerate face"))?;
picked.push((index as usize, inside));
}
let (id, first_inside, _) = sketch_of_face(s, first, cmd, face_body)?;
let (body, _) = common.ok_or_else(|| bad(cmd, "no faces selected"))?;
let doc = s.placed_document();
let source = state.body(&body).ok_or_else(|| bad(cmd, "source body disappeared"))?;
let component = doc.model_body_component(source);
let path = if component == s.active_component {
s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(component))
} else {
let placements = super::component::properties::placements(&doc, cmd)?;
if placements.iter().filter(|(owner, _, _)| *owner == component).count() != 1 {
return Err(bad(cmd, "activate the selected occurrence before extruding its faces"));
}
doc.first_path(component)
};
let placement = EmbossFace::component_frame(&doc, component, &path)?;
let placement = doc.model_body_offset(source).map(|offset| mat_mul(&placement, offset)).unwrap_or(placement);
let author_path = s.active_occurrence_path.clone().unwrap_or_else(|| doc.first_path(s.active_component));
let author = EmbossFace::component_frame(&doc, s.active_component, &author_path)?;
let inverse = mat_inverse(&author).ok_or_else(|| bad(cmd, "invalid face profile author frame"))?;
let into_author = mat_mul(&inverse, &placement);
drop(doc);
let indices: Vec<_> = picked.iter().skip(1).map(|(index, _)| *index).collect();
super::sketch_project::project_internal_faces(s, id, &body, &indices)?;
s.refresh();
let plane = s.model.state().sketch(id).map(|sketch| sketch.plane).ok_or_else(|| bad(cmd, "face profile sketch disappeared"))?;
let mut selected = vec![first_inside];
selected.extend(picked.iter().skip(1).map(|(_, point)| plane.to_local(apply_point(&into_author, *point))));
Ok((id, selected, body))
}
/// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns /// Start a sketch on the planar face at `point` holding a copy of the face boundary. Returns
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name. /// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
/// The sketch is left active. /// The sketch is left active.
+52 -14
View File
@@ -18,7 +18,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("extrude") .icon("extrude")
.key("E") .key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"), .params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); faces?: [[x,y,z],…] (coplanar faces of one body); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", revolve) CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE") .at("SOLID", "CREATE")
.icon("revolve") .icon("revolve")
@@ -118,7 +118,7 @@ pub static COMMANDS: &[CommandSpec] = &[
/// Where an extrude starts and which way is positive: a point inside the (first) profile or /// Where an extrude starts and which way is positive: a point inside the (first) profile or
/// face, and the unit normal. /// face, and the unit normal.
fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> { fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
if let Some(fp) = p.get("face").and_then(vec3) { if let Some(fp) = p.get("face").or_else(|| p.get("faces").and_then(Value::as_array).and_then(|faces| faces.first())).and_then(vec3) {
return Some((fp, super::face::face_normal_in(s, fp, str_(p, "face_body"))?)); return Some((fp, super::face::face_normal_in(s, fp, str_(p, "face_body"))?));
} }
let sid = feature_sketch(s, p, "Extrude").ok()?; let sid = feature_sketch(s, p, "Extrude").ok()?;
@@ -167,19 +167,38 @@ const START_PROBE_MM: f64 = 0.1;
/// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far /// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far
/// past a body's far side read as a Join (#1257). /// past a body's far side read as a Join (#1257).
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> { pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
let d = extrude_reach(s, p)?; auto_operation_in(s, p, true)
}
fn auto_operation_in(s: &Session, p: &Value, world_faces: bool) -> Option<&'static str> {
let symmetric = p.get("to").is_none() && str_(p, "direction").is_some_and(|d| d.eq_ignore_ascii_case("symmetric")); let symmetric = p.get("to").is_none() && str_(p, "direction").is_some_and(|d| d.eq_ignore_ascii_case("symmetric"));
let (base, n) = extrude_base(s, p)?; // Imported face groups can belong to a child of an empty active component.
let st = s.model.state(); // Probe in the same coordinate frame as the face picks.
let grouped = p.get("faces").and_then(Value::as_array).and_then(|faces| faces.first()).and_then(vec3);
let st = if grouped.is_some() && world_faces { s.world_state() } else { s.model.state() };
let (base, n) = if let Some(point) = grouped {
let (mesh, index, distance, name) = super::face::face_at_state(&st, point, str_(p, "face_body")).ok().flatten()?;
if distance > (mesh.bounds().diagonal() * 1e-3).max(1e-3) * 10.0 {
return None;
}
let normal = st.body(&name)?.body.planar_face(index as usize)?.normal();
(point, normal)
} else {
extrude_base(s, p)?
};
let d = if grouped.is_some() && p.get("to").is_some() { (p.get("to").and_then(vec3)? - base).dot(n) } else { extrude_reach(s, p)? };
if st.bodies.is_empty() || !d.is_finite() { if st.bodies.is_empty() || !d.is_finite() {
return Some("new"); return Some("new");
} }
// Only the active component's bodies take part (or the ones named as participants). // Match the evaluator's implicit participants: a grouped cut from an empty
// assembly component considers every body; otherwise keep the active scope.
// Explicit participants override either default.
let parts = string_list(p, "participants"); let parts = string_list(p, "participants");
let implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
let bodies: Vec<_> = st let bodies: Vec<_> = st
.bodies .bodies
.iter() .iter()
.filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) }) .filter(|b| if parts.is_empty() { implicit_all || s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
.collect(); .collect();
if bodies.is_empty() { if bodies.is_empty() {
return Some("new"); return Some("new");
@@ -470,7 +489,8 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
// takes part in every body instead, which it changes only where it meets them. // takes part in every body instead, which it changes only where it meets them.
let participants = if participants.is_empty() let participants = if participants.is_empty()
&& !primitive && !primitive
&& matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect)) && (matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
|| p.get("faces").is_some() && matches!(&kind, FeatureKind::Extrude { operation: Operation::Cut | Operation::Intersect, .. }))
&& { && {
let st = s.model.state(); let st = s.model.state();
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component) !st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
@@ -545,13 +565,31 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) ->
fn extrude(s: &mut Session, p: &Value) -> Result<Value> { fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.extrude"; let cmd = "solid.extrude";
let grouped = if let Some(value) = p.get("faces") {
if p.get("face").is_some() || p.get("sketch").is_some() || p.get("profiles").is_some() {
return Err(bad(cmd, "faces cannot be combined with face, sketch or profiles"));
}
let faces = value
.as_array()
.filter(|faces| !faces.is_empty() && faces.len() <= 1024)
.ok_or_else(|| bad(cmd, "faces must contain between 1 and 1024 points"))?;
let points = faces.iter().map(|point| vec3(point).ok_or_else(|| bad(cmd, "faces must be points [x,y,z]"))).collect::<Result<Vec<_>>>()?;
let (id, points, body) = super::face::sketch_of_faces(s, &points, cmd, str_(p, "face_body"))?;
Some((id, Some(ProfileSel::Points { points }), Some(body)))
} else {
None
};
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it. // A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
let (sketch, face_profile, face_body) = match p.get("face").and_then(vec3) { let (sketch, face_profile, face_body) = if let Some(grouped) = grouped {
Some(fp) => { grouped
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?; } else {
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body)) match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
}
None => (feature_sketch(s, p, cmd)?, None, None),
} }
None => (feature_sketch(s, p, cmd)?, None, None),
}; };
let extent = extent_param(s, p, cmd)?; let extent = extent_param(s, p, cmd)?;
let thin = match bool_(p, "thin") { let thin = match bool_(p, "thin") {
@@ -565,7 +603,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
_ => None, _ => None,
}; };
let operation = match str_(p, "operation") { let operation = match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody), Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation_in(s, p, false).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?, _ => operation(p, cmd)?,
}; };
let mut targets = string_list(p, "targets"); let mut targets = string_list(p, "targets");
@@ -2,6 +2,63 @@ use serde_json::{Value, json};
use crate::Session; use crate::Session;
#[test]
fn grouped_face_auto_operation_keeps_implicit_and_explicit_participants() {
let mut s = Session::default();
let part = run(&mut s, "component.create", json!({"name":"Part"}));
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"}));
run(&mut s, "component.activate", json!({"component":0}));
for offset in [0., 30.] {
if offset != 0. {
run(&mut s, "occurrence.move", json!({"occurrence":part["occurrence"],"translate":[offset,0,0]}));
}
// To Object follows the target's side, even when the distance and
// direction fields still describe the opposite extrusion.
for (height, expected) in [(8., "cut"), (12., "join")] {
let params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"face_body":"Stock",
"to":[offset+5.,5,height],"direction":"negative"});
assert_eq!(crate::auto_operation(&s, &params), Some(expected));
}
for distance in [-2., 2.] {
let mut params = json!({"faces":[[offset+2.,2,10],[offset+8.,8,10]],"distance":distance});
let expected = if distance < 0. { "cut" } else { "join" };
assert_eq!(crate::auto_operation(&s, &params), Some(expected));
params["face_body"] = json!("Stock");
assert_eq!(crate::auto_operation(&s, &params), Some(expected));
let mut actual = s.scratch();
if offset != 0. {
run(&mut actual, "component.activate", json!({"component":part["component"]}));
}
params["operation"] = json!("auto");
let made = run(&mut actual, "solid.extrude", params);
let body = actual.model.state().body("Stock").unwrap().body.clone();
let volume = solidcraft_kernel::measure(&body).unwrap().volume;
assert!(
(volume - (1000. + 100. * distance)).abs() < 1e-6,
"offset={offset}, distance={distance}, volume={volume}, feature={:?}",
actual.doc.feature(made["feature"].as_u64().unwrap())
);
}
if offset != 0. {
// Outside the edited component, grouping keeps the original explicit
// face command's coordinate and participant behavior.
let mut grouped = s.scratch();
let mut single = s.scratch();
run(&mut grouped, "solid.extrude", json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2,"operation":"cut"}));
run(&mut single, "solid.extrude", json!({"face":[32,2,10],"face_body":"Stock","distance":-2,"operation":"cut"}));
let volume = |session: &Session| solidcraft_kernel::measure(&session.model.state().body("Stock").unwrap().body).unwrap().volume;
assert!((volume(&grouped) - volume(&single)).abs() < 1e-6);
}
}
run(&mut s, "solid.box", json!({"corner":[100,0,0],"length":10,"width":10,"height":10,"body_name":"Other"}));
let mut params = json!({"faces":[[32,2,10],[38,8,10]],"face_body":"Stock","distance":-2});
assert_eq!(crate::auto_operation(&s, &params), Some("join"), "an existing active-component body keeps its original participant scope");
params["participants"] = json!(["Stock"]);
assert_eq!(crate::auto_operation(&s, &params), Some("cut"));
params["participants"] = json!(["Other"]);
assert_eq!(crate::auto_operation(&s, &params), Some("join"));
}
fn run(s: &mut Session, id: &str, p: Value) -> Value { fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) { match s.execute(id, &p) {
Ok(v) => v, Ok(v) => v,
@@ -28,6 +85,43 @@ fn volume(s: &mut Session) -> f64 {
measure(s)["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) measure(s)["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
} }
#[test]
fn separated_profile_cut_preserves_participants_and_removes_only_consumed_bodies() {
for explicit in [false, true] {
let mut s = Session::default();
for (name, x) in [("A", 0), ("B", 10), ("C", 30)] {
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":10,"corner":[x,0,0],"body_name":name}));
}
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Cutters"}));
for (a, b) in [([-1, -1], [6, 6]), ([12, 2], [14, 4]), ([32, 2], [34, 4])] {
run(&mut s, "sketch.rectangle.two_point", json!({"p0":a,"p1":b}));
}
run(&mut s, "sketch.finish", json!({}));
let before = s.doc.to_json();
let mut params = json!({"sketch":"Cutters","profiles":"all","distance":10,"operation":"cut"});
if explicit {
params["participants"] = json!(["A", "B"]);
}
run(&mut s, "solid.extrude", params);
let check = |session: &Session| {
let state = session.model.state();
assert_eq!(state.bodies.len(), 2);
assert!(state.body("A").is_none());
for (name, expected) in [("B", 210.), ("C", if explicit { 250. } else { 210. })] {
let body = &state.body(name).unwrap().body;
assert!((solidcraft_kernel::measure(body).unwrap().volume - expected).abs() < 1e-6);
assert!(body.validity().is_empty());
}
};
check(&s);
check(&Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
run(&mut s, "edit.redo", json!({}));
check(&s);
}
}
fn rel(a: f64, b: f64) -> f64 { fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12) (a - b).abs() / b.abs().max(1e-12)
} }
+19 -6
View File
@@ -520,21 +520,34 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result<Value
/// Private compatibility projection for generated face profiles. Named legacy links follow /// Private compatibility projection for generated face profiles. Named legacy links follow
/// edits, but cannot provide the construction certificate required by public attachments. /// edits, but cannot provide the construction certificate required by public attachments.
pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> { pub(super) fn project_internal_face(s: &mut Session, id: u64, body: &str, index: usize) -> Result<String> {
project_internal_faces(s, id, body, &[index])?.pop().ok_or_else(|| bad("face profile", "empty face projection"))
}
/// Capture individual replay names together, against the same unchanged source body.
pub(super) fn project_internal_faces(s: &mut Session, id: u64, body: &str, indices: &[usize]) -> Result<Vec<String>> {
if indices.len() > 1024 {
return Err(bad("face profile", "too many faces"));
}
let mut doc = (*s.doc).clone(); let mut doc = (*s.doc).clone();
let raw = s.model.state_before(id); let raw = s.model.state_before(id);
let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0); let component = doc.feature(id).map(|feature| feature.component).unwrap_or(0);
let state = solidcraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component)); let state = solidcraft_doc::state_in_frame_at(&doc, &raw, component, owner_frame(s, &doc, component));
let plane = sketch_plane(&doc, id, &state)?; let plane = sketch_plane(&doc, id, &state)?;
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?; let names = state.body(body).map(solidcraft_doc::naming::face_names).unwrap_or_default();
let face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned());
let mut sk = doc.sketch(id)?.clone(); let mut sk = doc.sketch(id)?.clone();
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?; let mut links = Vec::with_capacity(indices.len());
if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) { for &index in indices {
entry.face_name = face_name; let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
let link = sk.add_link_with_wires(LinkKind::Project, resolved.source, &resolved.geom, &resolved.wires)?;
if let Some(entry) = sk.links.iter_mut().find(|entry| entry.id == link) {
entry.face_name = names.get(index).cloned();
}
links.push(link);
} }
*doc.sketch_mut(id)? = sk; *doc.sketch_mut(id)? = sk;
*s.doc_mut() = doc; *s.doc_mut() = doc;
Ok(link) Ok(links)
} }
/// Project the edges of the face a new sketch was created on (Fusion's default). /// Project the edges of the face a new sketch was created on (Fusion's default).
+3 -1
View File
@@ -46,7 +46,9 @@ use K::*;
/// The parameters each command takes in world coordinates. /// The parameters each command takes in world coordinates.
fn table(id: &str) -> &'static [(&'static str, K)] { fn table(id: &str) -> &'static [(&'static str, K)] {
match id { match id {
"solid.extrude" => &[("face", Pick), ("face_body", Body), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)], "solid.extrude" => {
&[("face", Pick), ("faces", Picks), ("face_body", Body), ("from", Make), ("to", Make), ("targets", Body), ("participants", Body)]
}
"solid.revolve" => &[("face", Pick), ("axis", Axis), ("to", Make), ("targets", Body), ("participants", Body)], "solid.revolve" => &[("face", Pick), ("axis", Axis), ("to", Make), ("targets", Body), ("participants", Body)],
"solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)], "solid.box" | "solid.sphere" => &[("corner", Make), ("center", Make)],
"solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)], "solid.cylinder" | "solid.coil" | "solid.torus" => &[("base", Make), ("center", Make), ("axis", Dir)],
+41 -5
View File
@@ -54,6 +54,24 @@ fn differs(want: &Value, got: &Value) -> Option<String> {
(!out.is_empty()).then(|| out.join("; ")) (!out.is_empty()).then(|| out.join("; "))
} }
/// Platform records only override the seeds explicitly verified on that platform.
fn expected<'a>(file: &'a Value, seed: u64, platform: &str) -> &'a Value {
let key = seed.to_string();
file.get("platforms")
.and_then(|platforms| platforms.get(platform))
.and_then(|record| record.get("seeds"))
.and_then(|seeds| seeds.get(&key))
.unwrap_or(&file["seeds"][key])
}
#[test]
fn platform_records_keep_shared_references_for_every_other_seed_and_platform() {
let file = json!({"seeds":{"1":{"volume":10},"2":{"volume":20}},"platforms":{"windows":{"seeds":{"1":{"volume":11}}}}});
assert_eq!(expected(&file, 1, "windows")["volume"], 11);
assert_eq!(expected(&file, 1, "linux")["volume"], 10);
assert_eq!(expected(&file, 2, "windows")["volume"], 20);
}
fn check(seed: u64) { fn check(seed: u64) {
let got: BTreeMap<u64, Value> = BTreeMap::from([(seed, fuzz::golden(seed, MAX_STEPS))]); let got: BTreeMap<u64, Value> = BTreeMap::from([(seed, fuzz::golden(seed, MAX_STEPS))]);
let _lock = FILE.lock().unwrap_or_else(|e| e.into_inner()); let _lock = FILE.lock().unwrap_or_else(|e| e.into_inner());
@@ -61,7 +79,7 @@ fn check(seed: u64) {
let changed: Vec<(u64, String)> = got let changed: Vec<(u64, String)> = got
.iter() .iter()
.filter_map(|(seed, g)| { .filter_map(|(seed, g)| {
let w = &file["seeds"][seed.to_string()]; let w = expected(&file, *seed, std::env::consts::OS);
if w.is_null() { Some((*seed, "new".to_string())) } else { differs(w, g).map(|d| (*seed, d)) } if w.is_null() { Some((*seed, "new".to_string())) } else { differs(w, g).map(|d| (*seed, d)) }
}) })
.collect(); .collect();
@@ -75,20 +93,34 @@ fn check(seed: u64) {
"What each robustness seed builds (crates/engine/src/tests/robustness_goldens.rs, docs/robustness.md). Record changes with ROBUSTNESS_GOLDENS_BLESS=\"why\"." "What each robustness seed builds (crates/engine/src/tests/robustness_goldens.rs, docs/robustness.md). Record changes with ROBUSTNESS_GOLDENS_BLESS=\"why\"."
); );
} }
let platform = std::env::consts::OS;
let platform_record = file["platforms"][platform]["seeds"].is_object();
for (seed, g) in &got { for (seed, g) in &got {
file["seeds"][seed.to_string()] = g.clone(); if platform_record {
if changed.iter().any(|(changed, _)| changed == seed) {
file["platforms"][platform]["seeds"][seed.to_string()] = g.clone();
}
} else {
file["seeds"][seed.to_string()] = g.clone();
}
} }
let mut notes = file["notes"].as_array().cloned().unwrap_or_default(); let mut notes = file["notes"].as_array().cloned().unwrap_or_default();
let seeds: Vec<u64> = changed.iter().map(|(s, _)| *s).collect(); let seeds: Vec<u64> = changed.iter().map(|(s, _)| *s).collect();
// The parts of one record (the tests run side by side) share one note. // The parts of one record (the tests run side by side) share one note.
match notes.last_mut() { match notes.last_mut() {
Some(last) if last["reason"] == json!(reason) => { Some(last) if last["reason"] == json!(reason) && (!platform_record || last["platform"] == json!(platform)) => {
let mut all: Vec<u64> = last["seeds"].as_array().into_iter().flatten().filter_map(Value::as_u64).chain(seeds).collect(); let mut all: Vec<u64> = last["seeds"].as_array().into_iter().flatten().filter_map(Value::as_u64).chain(seeds).collect();
all.sort_unstable(); all.sort_unstable();
all.dedup(); all.dedup();
last["seeds"] = json!(all); last["seeds"] = json!(all);
} }
_ => notes.push(json!({"reason": reason, "seeds": seeds})), _ => {
let mut note = json!({"reason": reason, "seeds": seeds});
if platform_record {
note["platform"] = json!(platform);
}
notes.push(note);
}
} }
file["notes"] = Value::Array(notes); file["notes"] = Value::Array(notes);
// Seeds in order, one per line, so a change is a small diff. // Seeds in order, one per line, so a change is a small diff.
@@ -100,7 +132,11 @@ fn check(seed: u64) {
s += &format!(" \"{k}\": {}{}\n", v, if i + 1 < n { "," } else { "" }); s += &format!(" \"{k}\": {}{}\n", v, if i + 1 < n { "," } else { "" });
} }
let notes: Vec<String> = file["notes"].as_array().into_iter().flatten().map(|n| format!(" {n}")).collect(); let notes: Vec<String> = file["notes"].as_array().into_iter().flatten().map(|n| format!(" {n}")).collect();
s += &format!(" }},\n \"notes\": [\n{}\n ]\n}}\n", notes.join(",\n")); s += " },\n";
if let Some(platforms) = file.get("platforms") {
s += &format!(" \"platforms\": {platforms},\n");
}
s += &format!(" \"notes\": [\n{}\n ]\n}}\n", notes.join(",\n"));
std::fs::write(path(), s).unwrap_or_else(|e| panic!("{}: {e}", path().display())); std::fs::write(path(), s).unwrap_or_else(|e| panic!("{}: {e}", path().display()));
return; return;
} }
+27
View File
@@ -1494,6 +1494,33 @@ impl Body {
shell_groups::meshes_meet(&a, &b, 2.0 * tol) shell_groups::meshes_meet(&a, &b, 2.0 * tol)
} }
/// The same contact test for several tools, reusing this unchanged body's
/// fine mesh at each required tolerance. No contact threshold is relaxed.
pub fn meets_each(&self, others: &[Body]) -> Result<Vec<bool>> {
if others.len() > 1024 {
return Err(KernelError::Invalid("too many contact tools".into()));
}
self.require_brep("contact test")?;
let size = self.size();
let mut meshes = std::collections::HashMap::new();
let mut contacts = Vec::with_capacity(others.len());
for other in others {
other.require_brep("contact test")?;
let tol = (size.max(other.size()) * 1e-4).max(1e-6);
let key = tol.to_bits();
if !meshes.contains_key(&key) {
// Bound retained meshes even when hostile inputs request many tolerances.
if meshes.len() == 16 {
meshes.clear();
}
meshes.insert(key, self.tessellate(tol)?);
}
let mesh = meshes.get(&key).ok_or_else(|| KernelError::Internal("contact mesh disappeared".into()))?;
contacts.push(shell_groups::meshes_meet(mesh, &other.tessellate(tol)?, 2.0 * tol)?);
}
Ok(contacts)
}
/// A boolean shortcut needs clearance larger than both shell mesh deflections. /// A boolean shortcut needs clearance larger than both shell mesh deflections.
pub(crate) fn separated_lumps(&self) -> Result<Vec<Body>> { pub(crate) fn separated_lumps(&self) -> Result<Vec<Body>> {
self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3)) self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3))
+43 -1
View File
@@ -1193,10 +1193,18 @@ pub(crate) fn imprint_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool)
/// faces): every such pair of faces goes and the shells are stitched along their edges. `None` /// faces): every such pair of faces goes and the shells are stitched along their edges. `None`
/// when no such pair of faces exists. /// when no such pair of faces exists.
pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> { pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
glue_impl(a, b, false)
}
/// Glue every certified start cap, retaining each tool's original contact tolerance.
pub(crate) fn glue_all_caps(a: &Body, b: &Body) -> Option<Result<Body>> {
glue_impl(a, b, true)
}
fn glue_impl(a: &Body, b: &Body, all_caps: bool) -> Option<Result<Body>> {
use mt::{BoundedCurve, ParametricCurve}; use mt::{BoundedCurve, ParametricCurve};
let size = a.size().max(b.size()); let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0; let tol = (size * 1e-7).max(1e-9) * 10.0;
let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
let mid = |e: &mt::Edge| { let mid = |e: &mt::Edge| {
let c = e.curve(); let c = e.curve();
let (t0, t1) = c.range_tuple(); let (t0, t1) = c.range_tuple();
@@ -1205,14 +1213,40 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
// Matching faces: same plane, opposite normals, the same edges. Every pair is glued: a // Matching faces: same plane, opposite normals, the same edges. Every pair is glued: a
// pair left behind would stay inside the result as a zero-thickness slit (issue #616). // pair left behind would stay inside the result as a zero-thickness slit (issue #616).
let (pa, pb) = (planes(a), planes(b)); let (pa, pb) = (planes(a), planes(b));
let mut tool_sizes = HashMap::new();
if all_caps {
for shell in b.solid.boundaries() {
let mut bounds = solidcraft_geom::Aabb3::EMPTY;
for vertex in shell.vertex_iter() {
bounds.add(from_p3(vertex.point()));
}
let size = bounds.diagonal();
if !size.is_finite() {
return None;
}
for face in shell.face_iter() {
tool_sizes.insert(face.id(), size);
}
}
}
let fa_list: Vec<&mt::Face> = a.solid.face_iter().collect(); let fa_list: Vec<&mt::Face> = a.solid.face_iter().collect();
let fb_list: Vec<&mt::Face> = b.solid.face_iter().collect(); let fb_list: Vec<&mt::Face> = b.solid.face_iter().collect();
// B edge → the matching A edge; B vertex → the matching A vertex. // B edge → the matching A edge; B vertex → the matching A vertex.
let mut map: HashMap<mt::EdgeID, mt::Edge> = HashMap::new(); let mut map: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new(); let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
let (mut glued_a, mut glued_b): (Vec<usize>, Vec<usize>) = (Vec::new(), Vec::new()); let (mut glued_a, mut glued_b): (Vec<usize>, Vec<usize>) = (Vec::new(), Vec::new());
if all_caps && pa.len().saturating_mul(pb.len()) > 2_000_000 {
return None;
}
for (ia, x) in &pa { for (ia, x) in &pa {
for (ib, y) in &pb { for (ib, y) in &pb {
if all_caps
&& !matches!(b.face_sources().and_then(|sources| sources.get(*ib)), Some(Some(crate::FaceSource::ExtrudeCap { high: false, .. })))
{
continue;
}
let tol = if all_caps { (a.size().max(*tool_sizes.get(&fb_list.get(*ib)?.id())?) * 1e-7).max(1e-9) * 10.0 } else { tol };
let key = |p: Vec3| ((p.x / tol).round() as i64, (p.y / tol).round() as i64, (p.z / tol).round() as i64);
if glued_a.contains(ia) || glued_b.contains(ib) || x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol { if glued_a.contains(ia) || glued_b.contains(ib) || x.n.dot(y.n) > -1.0 + 1e-9 || (x.d + y.d).abs() > tol {
continue; continue;
} }
@@ -1264,6 +1298,14 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
glued_b.push(*ib); glued_b.push(*ib);
} }
} }
if all_caps
&& b.face_sources()?
.iter()
.enumerate()
.any(|(index, source)| matches!(source, Some(crate::FaceSource::ExtrudeCap { high: false, .. })) && !glued_b.contains(&index))
{
return None;
}
if glued_a.is_empty() { if glued_a.is_empty() {
return None; return None;
} }
+4 -1
View File
@@ -175,7 +175,10 @@ pub use mesh_convert::convert_prismatic_mesh;
pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use native::{decode_native_body, encode_native_body, native_preview_mesh}; pub use native::{decode_native_body, encode_native_body, native_preview_mesh};
pub use offset::with_same_surface; pub use offset::with_same_surface;
pub use ops::{BoolOp, boolean, can_join_touching_faces, half_space, join_apart, split_by_plane, transform, transform_matrix, union_apart}; pub use ops::{
BoolOp, boolean, can_join_all_touching_faces, can_join_touching_faces, compound_disjoint_extrusions, half_space, is_planar, join_apart,
split_by_plane, transform, transform_matrix, union_apart,
};
pub use polybool::{faceted_boolean, planar_boolean}; pub use polybool::{faceted_boolean, planar_boolean};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean}; pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean};
+253 -8
View File
@@ -23,12 +23,167 @@ pub enum BoolOp {
Intersect, Intersect,
} }
/// Exact planar Booleans need no sampled enlargement of their tool footprint.
pub fn is_planar(body: &Body) -> bool {
if body.is_mesh() || body.face_count() > 16_384 {
return false;
}
guard("planar body", || Ok(body.solid.face_iter().all(|face| matches!(face.oriented_surface(), mt::Surface::Plane(_))))).unwrap_or(false)
}
/// Batch separated extrusion tools without repeatedly unioning an ever-growing target.
/// Construction caps provide conservative bounds, including the full circle of each arc;
/// unsupported or touching tools retain the caller's sequential Boolean path.
pub fn compound_disjoint_extrusions(tools: &[Body]) -> Result<Option<Body>> {
if tools.len() < 2 || tools.len() > 1024 {
return Ok(None);
}
guard("compound extrusion tools", || {
let mut bounds = Vec::with_capacity(tools.len());
let mut corners = Vec::with_capacity(tools.len());
let mut axes = Vec::with_capacity(tools.len());
let mut remaining = 16_384usize;
for tool in tools {
let Some(sources) = tool.face_sources() else { return Ok(None) };
if sources.len() != tool.face_count() {
return Ok(None);
}
let mut bb = solidcraft_geom::Aabb3::EMPTY;
let mut points = Some(Vec::new());
let mut directions = Vec::new();
let mut caps = 0;
for source in sources {
let Some(left) = remaining.checked_sub(1) else { return Ok(None) };
remaining = left;
match source {
Some(crate::FaceSource::ExtrudeCap { region, plane, high }) => {
caps += 1;
// The bottom support is outward-facing, but its region retains
// the original sketch coordinates (see build::extrude).
let plane = if *high { *plane } else { solidcraft_geom::Plane { y: -plane.y, ..*plane } };
for segment in &region.outer.segs {
let Some(left) = remaining.checked_sub(1) else { return Ok(None) };
remaining = left;
match *segment {
solidcraft_geom::Seg2::Line { a, b } => {
let (a, b) = (plane.to_world(a), plane.to_world(b));
bb.add(a);
bb.add(b);
if let Some(points) = points.as_mut() {
points.extend([a, b]);
}
if let Some(axis) = (b - a).cross(plane.normal()).normalized() {
directions.push(axis);
}
}
solidcraft_geom::Seg2::Arc { center, radius, .. } => {
points = None;
for x in [-radius, radius] {
for y in [-radius, radius] {
bb.add(plane.to_world(center + solidcraft_geom::Vec2::new(x, y)));
}
}
}
_ => return Ok(None),
}
}
}
Some(crate::FaceSource::ExtrudeSide { .. }) => {}
_ => return Ok(None),
}
}
if caps != 2 || bb.is_empty() || !bb.min.is_finite() || !bb.max.is_finite() {
return Ok(None);
}
bounds.push(bb);
corners.push(points);
axes.push(directions);
}
let mut remaining_separation_work = 16_000_000usize;
for (i, a) in bounds.iter().enumerate() {
for (j, b) in bounds.iter().enumerate().skip(i + 1) {
let gap = (a.diagonal().max(b.diagonal()) * 1e-6).max(1e-7);
let separated = a.max.x + gap < b.min.x
|| b.max.x + gap < a.min.x
|| a.max.y + gap < b.min.y
|| b.max.y + gap < a.min.y
|| a.max.z + gap < b.min.z
|| b.max.z + gap < a.min.z;
if separated {
continue;
}
let (Some(Some(x)), Some(Some(y))) = (corners.get(i), corners.get(j)) else { return Ok(None) };
let Some(origin) = x.first() else { return Ok(None) };
// Every straight extrusion lies inside the hull of its cap corners.
// A separating cap-edge axis therefore proves clearance even when
// rotated tools' world-coordinate boxes overlap. Arcs retain the box test.
let range = |points: &[Vec3], axis: Vec3| {
points.iter().map(|p| (*p - *origin).dot(axis)).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v)))
};
let rounding = origin.x.abs().max(origin.y.abs()).max(origin.z.abs()) * f64::EPSILON * 128.0;
let separated = axes.get(i).into_iter().flatten().chain(axes.get(j).into_iter().flatten()).any(|axis| {
let Some(left) = remaining_separation_work.checked_sub(x.len().saturating_add(y.len())) else { return false };
remaining_separation_work = left;
let (a, b) = (range(x, *axis), range(y, *axis));
a.1 + gap + rounding < b.0 || b.1 + gap + rounding < a.0
});
if !separated {
return Ok(None);
}
}
}
let sources: std::collections::HashMap<_, _> = tools
.iter()
.flat_map(|tool| {
tool.solid
.face_iter()
.zip(tool.face_sources().unwrap_or_default())
.map(|(face, source)| (face.id(), (face.orientation(), source.clone())))
})
.collect();
let operands: Vec<_> = tools.iter().collect();
let shells = tools.iter().flat_map(|tool| tool.solid.boundaries().clone()).collect();
let solid = Solid::try_new(shells).map_err(|error| KernelError::Failed(format!("extrusion compound: {error}")))?;
let faces = tools
.iter()
.enumerate()
.flat_map(|(operand, tool)| (0..tool.face_count()).map(move |face| record::from_face(operand, face, crate::history::Change::Kept)))
.collect();
let mut edges = HashMap::new();
for (operand, tool) in tools.iter().enumerate() {
edges.extend(record::edge_origins(&tool.solid, operand));
}
record::build(solid, &record::counts(&operands), Some(faces), Some(&edges)).map(|mut body| {
body.regularization_protected = tools.iter().any(|tool| tool.regularization_protected);
// Only unchanged face identities carry these transient tool witnesses.
// No whole-compound extrusion/producer certificate is created.
let mapped = body
.solid
.face_iter()
.map(|face| {
sources.get(&face.id()).filter(|(orientation, _)| *orientation == face.orientation()).and_then(|(_, source)| source.clone())
})
.collect();
Some(body.with_face_sources(Some(std::sync::Arc::new(mapped))))
})
})
}
/// Whether a union can sew two matching planar faces without intersecting them. /// Whether a union can sew two matching planar faces without intersecting them.
/// Callers can retain the exact touching tool instead of extending it into material. /// Callers can retain the exact touching tool instead of extending it into material.
pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool { pub fn can_join_touching_faces(a: &Body, b: &Body) -> bool {
a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok()) a.solid.boundaries().len() == 1 && b.solid.boundaries().len() == 1 && crate::coplanar::glue(a, b).is_some_and(|r| r.is_ok())
} }
/// Check all separated extrusion starts in one construction, without rebuilding
/// the unchanged target once for each face. Partial seams keep the ordinary path.
pub fn can_join_all_touching_faces(a: &Body, b: &Body) -> bool {
if a.solid.boundaries().len() != 1 || !(2..=1024).contains(&b.solid.boundaries().len()) || a.face_count().max(b.face_count()) > 16_384 {
return false;
}
guard("check extrusion seams", || Ok(crate::coplanar::glue_all_caps(a, b).is_some_and(|result| result.is_ok()))).unwrap_or(false)
}
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a /// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
/// ray whose direction is derived from point coordinates; moving both solids (and the result /// ray whose direction is derived from point coordinates; moving both solids (and the result
/// back) leaves the geometry unchanged but changes those rays. /// back) leaves the geometry unchanged but changes those rays.
@@ -238,6 +393,97 @@ mod shortcut_tests {
assert!((meeting_size(&thin, &thin) - 1e-5).abs() < 1e-12); assert!((meeting_size(&thin, &thin) - 1e-5).abs() < 1e-12);
} }
#[test]
fn rotated_extrusion_bounds_use_exact_cap_separation_and_keep_sources() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let rectangle = |angle: f64, offset: f64| {
let (c, s) = (angle.cos(), angle.sin());
Region2 {
outer: Loop2::polygon(
&[(-2., -0.5), (2., -0.5), (2., 0.5), (-2., 0.5)].map(|(x, y)| Vec2::new(x * c - (y + offset) * s, x * s + (y + offset) * c)),
),
holes: vec![],
}
};
for offset in [1.2, 1., 0.8] {
let tools = crate::extrude(&Plane::XY, &[rectangle(0.7, 0.), rectangle(0.7, offset)], 0., 2.).unwrap();
let compound = compound_disjoint_extrusions(&tools).unwrap();
assert_eq!(compound.is_some(), offset > 1.);
if let Some(compound) = compound {
let sources = compound.face_sources().unwrap();
assert_eq!(sources.len(), compound.face_count());
assert_eq!(sources.iter().filter(|source| matches!(source, Some(crate::FaceSource::ExtrudeCap { high: false, .. }))).count(), 2);
assert!(compound.validity().is_empty());
}
}
}
#[test]
fn batched_contact_test_matches_individual_tolerances() {
let target = crate::cylinder(Vec3::ZERO, Vec3::Z, 5., 3.).unwrap();
let tools = [
crate::box_solid(Vec3::new(-1., -1., 3.), Vec3::new(1., 1., 5.)).unwrap(),
crate::box_solid(Vec3::new(-1., -1., 3.002), Vec3::new(1., 1., 5.)).unwrap(),
crate::sphere(Vec3::new(20., 0., 1.), 1.).unwrap(),
];
let expected = tools.iter().map(|tool| target.meets(tool).unwrap()).collect::<Vec<_>>();
assert_eq!(target.meets_each(&tools).unwrap(), expected);
assert!(target.meets_each(&[]).unwrap().is_empty());
}
#[test]
fn compound_span_does_not_relax_full_face_seam_tolerance() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let mut target = crate::box_solid(Vec3::new(0., 0., -3.), Vec3::new(100., 2., -2.)).unwrap();
for x in [0., 98.] {
let boss = crate::box_solid(Vec3::new(x, 0., -2.), Vec3::new(x + 2., 2., 0.)).unwrap();
target = boolean(&target, &boss, BoolOp::Union).unwrap().unwrap();
}
let regions: Vec<_> = [0., 98.]
.into_iter()
.map(|x| Region2 {
outer: Loop2::polygon(&[Vec2::new(x, 0.), Vec2::new(x + 2., 0.), Vec2::new(x + 2., 2.), Vec2::new(x, 2.)]),
holes: vec![],
})
.collect();
let tools = crate::extrude(&Plane::XY, &regions, 0.000_105, 200.).unwrap();
assert!(tools.iter().all(|tool| !can_join_touching_faces(&target, tool)));
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
assert!(!can_join_all_touching_faces(&target, &compound));
}
#[test]
fn extrusion_compound_requires_separated_construction_bounds() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let rectangle = |x: f64| Region2 {
outer: Loop2::polygon(&[Vec2::new(x, 0.0), Vec2::new(x + 2.0, 0.0), Vec2::new(x + 2.0, 2.0), Vec2::new(x, 2.0)]),
holes: vec![],
};
let plane = Plane::from_normal(Vec3::new(100.0, -20.0, 8.0), Vec3::new(1.0, 2.0, 3.0)).unwrap();
let tools = crate::extrude(&plane, &[rectangle(0.0), rectangle(20.0)], -1.0, 5.0).unwrap();
let compound = compound_disjoint_extrusions(&tools).unwrap().unwrap();
assert_eq!(compound.shell_count(), 2);
assert_eq!(compound.history().unwrap().operands, 2);
assert!((crate::measure(&compound).unwrap().volume - 48.0).abs() < 1e-8);
assert!(compound.validity().is_empty());
for x in [1.0, 2.0, 2.000_000_1] {
let touching = crate::extrude(&solidcraft_geom::Plane::XY, &[rectangle(0.0), rectangle(x)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&touching).unwrap().is_none());
}
let unsupported = [crate::sphere(Vec3::ZERO, 1.0).unwrap(), crate::sphere(Vec3::new(10.0, 0.0, 0.0), 1.0).unwrap()];
assert!(compound_disjoint_extrusions(&unsupported).unwrap().is_none());
}
#[test]
fn extrusion_compound_includes_arc_extrema_between_seam_vertices() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let circle = |x| Region2 { outer: Loop2::circle(Vec2::new(x, 0.0), 2.0), holes: vec![] };
let separated = crate::extrude(&Plane::XY, &[circle(0.0), circle(8.0)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&separated).unwrap().is_some());
let overlapping = crate::extrude(&Plane::XY, &[circle(0.0), circle(3.0)], 0.0, 5.0).unwrap();
assert!(compound_disjoint_extrusions(&overlapping).unwrap().is_none());
}
#[test] #[test]
fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() { fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() {
let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap(); let direction = Vec3::new(1.0, 2.0, 3.0).normalized().unwrap();
@@ -756,14 +1002,13 @@ fn boolean_whole_with_refinement(a: &Body, b: &Body, op: BoolOp, refine: bool) -
} }
} }
// Bodies touching along one whole face (a part joined with its mirror image). // Bodies touching along one whole face (a part joined with its mirror image).
if op == BoolOp::Union if op == BoolOp::Union && a.solid.boundaries().len() == 1 {
&& a.solid.boundaries().len() == 1 let glued = if b.solid.boundaries().len() == 1 { crate::coplanar::glue(a, b) } else { crate::coplanar::glue_all_caps(a, b) };
&& b.solid.boundaries().len() == 1 if let Some(Ok(body)) = glued {
&& let Some(Ok(body)) = crate::coplanar::glue(a, b) let (ok, _) = check(&body);
{ if ok {
let (ok, _) = check(&body); return Ok(Some(body));
if ok { }
return Ok(Some(body));
} }
} }
// Bores on a common cylinder (a pad's bore around the hole it is joined over, a disc filling // Bores on a common cylinder (a pad's bore around the hole it is joined over, a disc filling
+115 -21
View File
@@ -181,8 +181,8 @@ pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, en
remaining_pairs = remaining_pairs remaining_pairs = remaining_pairs
.checked_sub(1) .checked_sub(1)
.ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?; .ok_or_else(|| KernelError::Invalid("planar extrusion separation exceeds its work budget".into()))?;
let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(t.closest_param(*p)).dist(*p) <= 1e-7) let endpoints_touch = [s.start(), s.end()].iter().any(|p| t.point_at(closest_param(t, *p)).dist(*p) <= 1e-7)
|| [t.start(), t.end()].iter().any(|p| s.point_at(s.closest_param(*p)).dist(*p) <= 1e-7); || [t.start(), t.end()].iter().any(|p| s.point_at(closest_param(s, *p)).dist(*p) <= 1e-7);
if endpoints_touch || !crossings(s, t, 1e-7).is_empty() { if endpoints_touch || !crossings(s, t, 1e-7).is_empty() {
return Err(KernelError::Invalid("planar material pieces touch at a boundary".into())); return Err(KernelError::Invalid("planar material pieces touch at a boundary".into()));
} }
@@ -765,7 +765,7 @@ fn heights_of(b: &Body, plane: &Plane) -> (f64, f64) {
/// Does `s` run along `t` from end to end? Two lines, `s`'s ends on `t`; two arcs of one /// Does `s` run along `t` from end to end? Two lines, `s`'s ends on `t`; two arcs of one
/// circle, `s`'s ends and middle on `t`. /// circle, `s`'s ends and middle on `t`.
fn runs_along(s: &Seg2, t: &Seg2, tol: f64) -> bool { fn runs_along(s: &Seg2, t: &Seg2, tol: f64) -> bool {
let on = |p: Vec2| t.point_at(t.closest_param(p)).dist(p) < tol; let on = |p: Vec2| t.point_at(closest_param(t, p)).dist(p) < tol;
match (*s, *t) { match (*s, *t) {
(Seg2::Line { .. }, Seg2::Line { .. }) => on(s.start()) && on(s.end()), (Seg2::Line { .. }, Seg2::Line { .. }) => on(s.start()) && on(s.end()),
(Seg2::Arc { center: c, radius: r, .. }, Seg2::Arc { center: d, radius: q, .. }) => { (Seg2::Arc { center: c, radius: r, .. }, Seg2::Arc { center: d, radius: q, .. }) => {
@@ -840,7 +840,7 @@ fn crossings(s: &Seg2, t: &Seg2, tol: f64) -> Vec<(f64, f64)> {
} }
let mut out = Vec::new(); let mut out = Vec::new();
for p in pts { for p in pts {
let (u, v) = (s.closest_param(p), t.closest_param(p)); let (u, v) = (closest_param(s, p), closest_param(t, p));
if s.point_at(u).dist(p) < tol && t.point_at(v).dist(p) < tol { if s.point_at(u).dist(p) < tol && t.point_at(v).dist(p) < tol {
out.push((u, v)); out.push((u, v));
} }
@@ -871,7 +871,7 @@ fn inside_loop(lp: &Loop2, p: Vec2) -> bool {
} }
} }
// A point on the loop keeps the half-open answer. // A point on the loop keeps the half-open answer.
if lp.segs.iter().any(|s| s.point_at(s.closest_param(p)).dist(p) < scale * 1e-9) { if lp.segs.iter().any(|s| s.point_at(closest_param(s, p)).dist(p) < scale * 1e-9) {
return inside_loop_ray(lp, p); return inside_loop_ray(lp, p);
} }
let grazes = |q: Vec2| { let grazes = |q: Vec2| {
@@ -991,12 +991,43 @@ fn inside(regions: &[Region2], p: Vec2) -> bool {
regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p))) regions.iter().any(|r| inside_loop(&r.outer, p) && !r.holes.iter().any(|h| inside_loop(h, p)))
} }
/// Call only after proving the point is at least `tol` from the other boundary.
/// This sub-tolerance interior probe cannot cross that boundary, and avoids casting
/// exactly through fitted arc/line endpoints whose heights differ by roundoff.
fn inside_clear(regions: &[Region2], point: Vec2, tol: f64) -> bool {
inside(regions, point + Vec2::new(tol * 0.173, tol * 0.317))
}
/// Analytic projections avoid iterative searches at every planar boundary probe.
/// Other curve types keep the shared solver introduced on main.
fn closest_param(segment: &Seg2, point: Vec2) -> f64 {
match *segment {
Seg2::Line { a, b } => {
let direction = b - a;
let length2 = direction.dot(direction);
if length2 > 1e-300 && length2.is_finite() { ((point - a).dot(direction) / length2).clamp(0.0, 1.0) } else { 0.0 }
}
Seg2::Arc { center, radius, start, sweep } if radius > 0.0 && sweep.abs() > 1e-12 && sweep.abs() <= std::f64::consts::TAU + 1e-9 => {
let offset = point - center;
let delta = ((offset.y.atan2(offset.x) - start) * sweep.signum()).rem_euclid(std::f64::consts::TAU);
if delta <= sweep.abs() {
(delta / sweep.abs()).clamp(0.0, 1.0)
} else if segment.start().dist(point) <= segment.end().dist(point) {
0.0
} else {
1.0
}
}
_ => segment.closest_param(point),
}
}
fn dist_to(regions: &[Region2], p: Vec2) -> f64 { fn dist_to(regions: &[Region2], p: Vec2) -> f64 {
regions regions
.iter() .iter()
.flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) .flat_map(|r| std::iter::once(&r.outer).chain(&r.holes))
.flat_map(|l| l.segs.iter()) .flat_map(|l| l.segs.iter())
.map(|s| s.point_at(s.closest_param(p)).dist(p)) .map(|s| s.point_at(closest_param(s, p)).dist(p))
.fold(f64::INFINITY, f64::min) .fold(f64::INFINITY, f64::min)
} }
@@ -1043,7 +1074,7 @@ fn box_holds(b: Option<SegBox>, p: Vec2) -> bool {
/// Distance from `p` to the nearest of `segs` whose box holds it: infinite when none does (no /// Distance from `p` to the nearest of `segs` whose box holds it: infinite when none does (no
/// segment is within the tolerance of `p` then). /// segment is within the tolerance of `p` then).
fn near_dist(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2) -> f64 { fn near_dist(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2) -> f64 {
segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(s.closest_param(p)).dist(p)).fold(f64::INFINITY, f64::min) segs.iter().zip(boxes).filter(|(_, b)| box_holds(**b, p)).map(|(s, _)| s.point_at(closest_param(s, p)).dist(p)).fold(f64::INFINITY, f64::min)
} }
/// Direction of the first of `segs` through `p` (within `tol`), as it runs. /// Direction of the first of `segs` through `p` (within `tol`), as it runs.
@@ -1052,7 +1083,7 @@ fn near_dir(segs: &[Seg2], boxes: &[Option<SegBox>], p: Vec2, tol: f64) -> Optio
.iter() .iter()
.zip(boxes) .zip(boxes)
.filter(|(_, b)| box_holds(**b, p)) .filter(|(_, b)| box_holds(**b, p))
.map(|(s, _)| (s, s.closest_param(p))) .map(|(s, _)| (s, closest_param(s, p)))
.find(|(s, t)| s.point_at(*t).dist(p) < tol)?; .find(|(s, t)| s.point_at(*t).dist(p) < tol)?;
s.derivative(t).normalized() s.derivative(t).normalized()
} }
@@ -1193,7 +1224,7 @@ fn trimmed(src: &Seg2, t0: f64, t1: f64, a: Vec2, b: Vec2) -> Seg2 {
if hi < 1.0 - 1e-12 { if hi < 1.0 - 1e-12 {
// Splitting a conic renormalises its weights, which moves its parameters: the second // Splitting a conic renormalises its weights, which moves its parameters: the second
// split is found on the piece. // split is found on the piece.
let at = s.closest_param(src.point_at(hi)); let at = closest_param(&s, src.point_at(hi));
if at < 1.0 - 1e-12 { if at < 1.0 - 1e-12 {
s = s.split_at(at).0; s = s.split_at(at).0;
} }
@@ -1304,7 +1335,7 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
} else if p.dist(src.end()) < near { } else if p.dist(src.end()) < near {
1.0 1.0
} else { } else {
src.closest_param(p) closest_param(src, p)
} }
}; };
let (t0, t1) = (at(seg.start()), at(last.end())); let (t0, t1) = (at(seg.start()), at(last.end()));
@@ -1340,12 +1371,12 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec<Seg
(Piece::Exact { src: f, t1, .. }, Piece::Exact { src: g, t0, .. }) if f != g && (inner(t1) || inner(t0)) => { (Piece::Exact { src: f, t1, .. }, Piece::Exact { src: g, t0, .. }) if f != g && (inner(t1) || inner(t0)) => {
src(f).zip(src(g)).and_then(|(x, y)| meet(x, t1, y, t0, near).map(|(a, b)| (Some(a), Some(b), x.point_at(a)))) src(f).zip(src(g)).and_then(|(x, y)| meet(x, t1, y, t0, near).map(|(a, b)| (Some(a), Some(b), x.point_at(a))))
} }
(Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => { (Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => src(f)
src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t1, &c, c.closest_param(joint), near).map(|(a, _)| (Some(a), None, x.point_at(a)))) .zip(carrier(&s))
} .and_then(|(x, c)| meet(x, t1, &c, closest_param(&c, joint), near).map(|(a, _)| (Some(a), None, x.point_at(a)))),
(Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => { (Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => src(f)
src(f).zip(carrier(&s)).and_then(|(x, c)| meet(x, t0, &c, c.closest_param(joint), near).map(|(a, _)| (None, Some(a), x.point_at(a)))) .zip(carrier(&s))
} .and_then(|(x, c)| meet(x, t0, &c, closest_param(&c, joint), near).map(|(a, _)| (None, Some(a), x.point_at(a)))),
_ => None, _ => None,
}; };
// No crossing to refine (or none found): both end where the curve does. // No crossing to refine (or none found): both end where the curve does.
@@ -1449,7 +1480,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
continue; continue;
} }
for p in [t.start(), t.end()] { for p in [t.start(), t.end()] {
let u = s.closest_param(p); let u = closest_param(s, p);
if s.point_at(u).dist(p) < tol { if s.point_at(u).dist(p) < tol {
events += 1; events += 1;
if events > MAX_PLANAR_EVENTS { if events > MAX_PLANAR_EVENTS {
@@ -1466,7 +1497,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
continue; continue;
} }
for p in [s.start(), s.end()] { for p in [s.start(), s.end()] {
let v = t.closest_param(p); let v = closest_param(t, p);
if t.point_at(v).dist(p) < tol { if t.point_at(v).dist(p) < tol {
events += 1; events += 1;
if events > MAX_PLANAR_EVENTS { if events > MAX_PLANAR_EVENTS {
@@ -1522,7 +1553,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
BoolOp::Cut => !same, BoolOp::Cut => !same,
} }
} else { } else {
let ins = inside(&b, m); let ins = inside_clear(&b, m, tol);
match op { match op {
BoolOp::Union | BoolOp::Cut => !ins, BoolOp::Union | BoolOp::Cut => !ins,
BoolOp::Intersect => ins, BoolOp::Intersect => ins,
@@ -1548,7 +1579,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option<Sect
if near_dist(&sa, &box_a, m) < tol { if near_dist(&sa, &box_a, m) < tol {
continue; continue;
} }
let ins = inside(&a, m); let ins = inside_clear(&a, m, tol);
if kept.len() >= MAX_PLANAR_SEGMENTS { if kept.len() >= MAX_PLANAR_SEGMENTS {
return None; return None;
} }
@@ -1712,7 +1743,7 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option<Vec<Seg2>> {
if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol { if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol {
return None; return None;
} }
let t = seg.closest_param(*p); let t = closest_param(seg, *p);
(t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t) (t > 1e-9 && t < 1.0 - 1e-9 && seg.point_at(t).dist(*p) < tol).then_some(t)
}) })
.collect(); .collect();
@@ -1942,6 +1973,13 @@ fn sew_connected_sections(
ends.push(u); ends.push(u);
} }
} }
// Welding above keeps its original encounter order. Side splitting needs
// only the distinct stations, whose resulting parameters are sorted anyway.
// Deduplicate in place, with bounded sorting work and no extra point list.
if pts.len() <= MAX_PLANAR_SEGMENTS * 8 {
pts.sort_unstable_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y)));
pts.dedup_by_key(|p| (p.x.to_bits(), p.y.to_bits()));
}
// A piece of a free-form curve whose ends weld into one point (a font's curve cut a hair from // A piece of a free-form curve whose ends weld into one point (a font's curve cut a hair from
// its end) is no edge and has no wall: the pieces on either side meet there. (Lines and arcs // its end) is no edge and has no wall: the pieces on either side meet there. (Lines and arcs
// are left as they were: such a piece of one fails the sewing, as before.) // are left as they were: such a piece of one fails the sewing, as before.)
@@ -2518,6 +2556,62 @@ pub(crate) fn prism_union_between_caps(a: &Body, b: &Body, op: BoolOp) -> Option
#[cfg(test)] #[cfg(test)]
mod planar_tests { mod planar_tests {
use super::*; use super::*;
#[test]
fn shared_arc_line_endpoint_roundoff_does_not_open_a_union() {
let a = Region2 { outer: Loop2::polygon(&[Vec2::new(-2., 0.), Vec2::new(2., 0.), Vec2::new(2., 2.), Vec2::new(-2., 2.)]), holes: vec![] };
let line = |a: (f64, f64), b: (f64, f64)| Seg2::Line { a: Vec2::new(a.0, a.1), b: Vec2::new(b.0, b.1) };
for epsilon in [-4e-14, 4e-14] {
let b = Region2 {
outer: Loop2 {
segs: vec![
line((1.15, epsilon), (-1.15, -epsilon)),
Seg2::Arc { center: Vec2::new(-1.35, epsilon), radius: 0.2, start: 0., sweep: -std::f64::consts::FRAC_PI_2 },
line((-1.35, -0.2), (-2.4, -0.2)),
line((-2.4, -0.2), (-2.4, -1.)),
line((-2.4, -1.), (2.4, -1.)),
line((2.4, -1.), (2.4, -0.2)),
line((2.4, -0.2), (1.35, -0.2)),
Seg2::Arc {
center: Vec2::new(1.35, -epsilon),
radius: 0.2,
start: -std::f64::consts::FRAC_PI_2,
sweep: -std::f64::consts::FRAC_PI_2,
},
],
},
holes: vec![],
};
let result = planar_regions_boolean(std::slice::from_ref(&a), std::slice::from_ref(&b), BoolOp::Union, 1e-5).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].outer.is_closed(1e-4));
assert!((result[0].area() - a.area() - b.area()).abs() < 1e-6);
}
}
#[test]
fn analytic_projection_keeps_clamped_lines_and_wrapped_arcs() {
let line = Seg2::Line { a: Vec2::new(2., 3.), b: Vec2::new(5., 7.) };
for (point, expected) in [(Vec2::new(-1., -1.), 0.), (Vec2::new(3.5, 5.), 0.5), (Vec2::new(8., 11.), 1.)] {
assert!((closest_param(&line, point) - expected).abs() < 1e-12);
}
for start in [-7., -0.5, 3., 7.] {
for sweep in [-std::f64::consts::TAU, -5., -0.2, 0.2, 5., std::f64::consts::TAU] {
let arc = Seg2::Arc { center: Vec2::new(2., -3.), radius: 4., start, sweep };
for index in 0..128 {
let angle = index as f64 * std::f64::consts::TAU / 128.;
let point = Vec2::new(2., -3.) + Vec2::new(angle.cos(), angle.sin()) * 6.;
let nearest = arc.point_at(closest_param(&arc, point));
// Includes both signs, sweeps over pi, seam crossings and endpoint clamps.
for sample in 0..=128 {
assert!(nearest.dist(point) <= arc.point_at(sample as f64 / 128.).dist(point) + 1e-10);
}
}
for t in [0., 0.25, 0.5, 0.75, 1.] {
let point = arc.point_at(t);
assert!(arc.point_at(closest_param(&arc, point)).dist(point) < 1e-10);
}
}
}
}
fn rectangle(a: Vec2, b: Vec2) -> Region2 { fn rectangle(a: Vec2, b: Vec2) -> Region2 {
Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] } Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] }
} }
+30 -1
View File
@@ -3760,7 +3760,36 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
if !sketch.is_null() { if !sketch.is_null() {
out.push(("solid.extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx})))); out.push(("solid.extrude".to_string(), with(json!({"sketch": sketch, "profiles": idx}))));
} }
// Each planar body face extrudes on its own. // Coplanar faces of one body share one sketch and Boolean. Mixed selections
// retain their original order and explicit face-body scope.
let state = s.world_state();
let mut group: Option<(String, solidcraft_engine::geom::Plane, Vec<Value>)> = None;
let mut coplanar = true;
for pick in sels(d, 0) {
let Sel::Face { body, index, point } = pick else { continue };
let Some(source) = state.body(body) else { return Err("selected body disappeared".into()) };
let Some(plane) = source.body.planar_face(*index) else {
coplanar = false;
break;
};
let tolerance = (source.body.size() * 1e-9).max(1e-8);
if let Some((name, base, points)) = &mut group {
if name != body || base.normal().dot(plane.normal()) <= 1.0 - 1e-9 || base.height(plane.origin).abs() > tolerance {
coplanar = false;
break;
}
points.push(pt(*point));
} else {
group = Some((body.clone(), plane, vec![pt(*point)]));
}
}
if coplanar
&& let Some((body, _, points)) = group
&& points.len() > 1
{
out.push(("solid.extrude".to_string(), with(json!({"faces": points, "face_body": body}))));
return Ok(out);
}
for pick in sels(d, 0) { for pick in sels(d, 0) {
if let Sel::Face { body, point, .. } = pick { if let Sel::Face { body, point, .. } = pick {
out.push(("solid.extrude".to_string(), with(json!({"face": pt(*point), "face_body": body})))); out.push(("solid.extrude".to_string(), with(json!({"face": pt(*point), "face_body": body}))));
+395
View File
@@ -0,0 +1,395 @@
//! Straight extrusion feedback from existing display triangles, without a scratch design.
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::Value;
use solidcraft_engine::{
Sel,
doc::Document,
geom::{Aabb3, Seg2, Vec3},
};
use crate::{
SolveApp,
gpu::GpuScene,
preview::{Built, Colors},
};
#[derive(Default)]
pub(crate) struct StraightPreview {
cached: Option<Cached>,
}
struct Cached {
document: Arc<Document>,
revision: u64,
inputs: Vec<Sel>,
surfaces: Vec<Surface>,
bounds: Aabb3,
}
struct Surface {
normal: Vec3,
triangles: Vec<[Vec3; 3]>,
edges: Vec<[Vec3; 2]>,
}
impl StraightPreview {
pub(crate) fn clear(&mut self) {
self.cached = None;
}
pub(crate) fn compute(&mut self, app: &SolveApp, commands: &[(String, Value)], colors: Colors) -> Option<Result<Built, String>> {
let [(id, params)] = commands else { return None };
let params = match id.as_str() {
"solid.extrude" => params,
"timeline.redefine" if params.get("command").and_then(Value::as_str) == Some("solid.extrude") => params.get("params")?,
_ => return None,
};
// A newly opened STEP is now a component. Its single identity placement
// has the same authoring coordinates; other placements keep Engine preview.
if app.session.active_component != 0 || app.session.active_occurrence_path.is_some() {
let path = app.session.active_occurrence_path.as_ref()?;
let placed = app.session.occurrence_placement(path).ok()?;
if path.len() != 1
|| placed.component != app.session.active_component
|| placed.world != solidcraft_engine::doc::IDENTITY
|| app.session.doc.occurrences.iter().filter(|o| o.component == placed.component).count() != 1
{
return None;
}
}
if app.session.doc.units != "mm"
|| ["distance2", "start_offset", "from", "to", "to_offset", "thin"].iter().any(|key| params.get(*key).is_some())
|| params.get("through_all").and_then(Value::as_bool) == Some(true)
{
return None;
}
let direction = params.get("direction").and_then(Value::as_str).unwrap_or("positive");
if !matches!(direction, "positive" | "negative") {
return None;
}
if let Some(taper) = params.get("taper") {
let expression = taper.as_str().map(str::to_owned).unwrap_or_else(|| taper.to_string());
if app.session.doc.eval(&expression, solidcraft_engine::doc::expr::Kind::Angle).ok()?.abs() > 1e-12 {
return None;
}
}
let distance = params.get("distance")?;
let expression = distance.as_str().map(str::to_owned).unwrap_or_else(|| distance.to_string());
let mut distance = app.session.doc.eval(&expression, solidcraft_engine::doc::expr::Kind::Length).ok()?;
if direction == "negative" {
distance = -distance;
}
if !distance.is_finite() || distance.abs() < 1e-9 || distance.abs() > 1e6 {
return None;
}
let operation = params.get("operation").and_then(Value::as_str).unwrap_or("new");
if !matches!(operation, "new" | "join" | "cut" | "intersect") {
return None;
}
let inputs = &app.dialog.as_ref()?.inputs.first()?.items;
if inputs.is_empty() || inputs.len() > 1024 {
return None;
}
if self
.cached
.as_ref()
.is_none_or(|cache| cache.revision != app.session.revision || !Arc::ptr_eq(&cache.document, &app.session.doc) || cache.inputs != *inputs)
{
self.cached = prepare(app, inputs);
}
let cache = self.cached.as_ref()?;
let mut scene = GpuScene::default();
let mut bounds = cache.bounds;
let color = if operation == "cut" {
colors.removed
} else if operation == "intersect" {
[colors.added[0], colors.added[1], colors.added[2], colors.removed[3]]
} else {
colors.added
};
let triangle = |scene: &mut GpuScene, points: [Vec3; 3], normal: Vec3| {
for point in points {
if matches!(operation, "cut" | "intersect") {
scene.xray_tri(point.to_f32(), normal.to_f32(), color);
} else {
scene.tri(point.to_f32(), normal.to_f32(), color);
}
}
};
for surface in &cache.surfaces {
let shift = surface.normal * distance;
let (low, high) = if distance > 0. { (Vec3::ZERO, shift) } else { (shift, Vec3::ZERO) };
for &[a, b, c] in &surface.triangles {
triangle(&mut scene, [a + low, c + low, b + low], -surface.normal);
triangle(&mut scene, [a + high, b + high, c + high], surface.normal);
for point in [a + low, b + low, c + low, a + high, b + high, c + high] {
bounds.add(point);
}
}
let edge_color = if operation == "cut" { [color[0], color[1], color[2], 255] } else { colors.edge };
for &[a, b] in &surface.edges {
let normal = (b - a).cross(surface.normal).normalized()?;
triangle(&mut scene, [a + low, b + low, b + high], normal);
triangle(&mut scene, [a + low, b + high, a + high], normal);
for (a, b) in [(a + low, b + low), (a + high, b + high), (a + low, a + high)] {
scene.line(a.to_f32(), b.to_f32(), edge_color, 1.3, operation != "join");
}
}
}
Some(Ok(Built {
scene,
sketches: Vec::new(),
applied: None,
stopped: None,
tapered: Vec::new(),
replaced: Vec::new(),
error: crate::preview::extrusion_target_error(&app.session, commands, &app.session.model.state()),
bounds,
}))
}
}
fn prepare(app: &SolveApp, inputs: &[Sel]) -> Option<Cached> {
let state = app.session.world_state();
let mut surfaces = Vec::new();
let mut budget = 65_536usize;
if inputs.iter().all(|input| matches!(input, Sel::Profile { .. })) {
let Sel::Profile { sketch: owner, .. } = inputs.first()? else { return None };
if inputs.iter().any(|input| !matches!(input,Sel::Profile {sketch,..} if sketch==owner)) {
return None;
}
for input in inputs {
let Sel::Profile { sketch, index } = input else { return None };
let sketch = state.sketch(*sketch)?;
let region = &sketch.profiles.get(*index)?.region;
let loops = std::iter::once(&region.outer).chain(&region.holes);
if loops.clone().flat_map(|loop_| &loop_.segs).any(|segment| !matches!(segment, Seg2::Line { .. })) {
return None;
}
let triangles = crate::sketch_display::fill_triangles(region, &mut budget)
.into_iter()
.map(|triangle| triangle.map(|point| sketch.plane.to_world(point)))
.collect::<Vec<_>>();
if triangles.is_empty() {
return None;
}
let edges = std::iter::once((&region.outer, true))
.chain(region.holes.iter().map(|hole| (hole, false)))
.flat_map(|(loop_, outer)| {
let reverse = (loop_.signed_area() > 0.) != outer;
loop_.segs.iter().map(move |segment| {
let pair = [sketch.plane.to_world(segment.start()), sketch.plane.to_world(segment.end())];
if reverse { [pair[1], pair[0]] } else { pair }
})
})
.collect();
surfaces.push(Surface { normal: sketch.plane.normal(), triangles, edges });
}
} else if inputs.iter().all(|input| matches!(input, Sel::Face { .. })) {
// One pass over each selected body's triangles, rather than one full scan per pick.
for body in &state.bodies {
let mut selected = HashMap::new();
for input in inputs {
if let Sel::Face { body: name, index, point } = input
&& name == &body.name
{
let face = u32::try_from(*index).ok()?;
if selected.contains_key(&face) {
return None;
}
let normal = body.body.planar_face(*index)?.normal();
selected.insert(face, (surfaces.len(), *point));
surfaces.push(Surface { normal, triangles: Vec::new(), edges: Vec::new() });
}
}
if selected.is_empty() {
continue;
}
let mesh = body.mesh();
for (triangle, face) in mesh.triangles.iter().zip(&mesh.tri_face) {
if let Some((index, _)) = selected.get(face) {
budget = budget.checked_sub(1)?;
let mut points = mesh.tri(triangle)?;
let surface = surfaces.get_mut(*index)?;
if (points[1] - points[0]).cross(points[2] - points[0]).dot(surface.normal) < 0. {
points.swap(1, 2);
}
surface.triangles.push(points);
}
}
// The command resolves picks by their point. A stale display face index
// must fall back rather than previewing a different face.
let tolerance = (mesh.bounds().diagonal() * 1e-8).max(1e-8);
for (index, point) in selected.values() {
if !surfaces.get(*index)?.triangles.iter().any(|triangle| crate::dialogs::point_tri_dist(*point, *triangle) < tolerance) {
return None;
}
}
}
if surfaces.len() != inputs.len() {
return None;
}
for surface in &mut surfaces {
let key = |point: Vec3| [point.x, point.y, point.z].map(|coordinate| if coordinate == 0. { 0 } else { coordinate.to_bits() });
let mut edges = HashMap::new();
for &[a, b, c] in &surface.triangles {
for (a, b) in [(a, b), (b, c), (c, a)] {
let (ka, kb) = (key(a), key(b));
let entry = edges.entry(if ka < kb { (ka, kb) } else { (kb, ka) });
match entry {
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert(Some([a, b]));
}
std::collections::hash_map::Entry::Occupied(mut entry) => {
entry.get().as_ref()?;
entry.insert(None);
}
}
}
}
surface.edges = edges.into_values().flatten().collect();
if surface.triangles.is_empty() || surface.edges.is_empty() {
return None;
}
}
} else {
return None;
}
let bounds = state.bodies.iter().fold(Aabb3::EMPTY, |bounds, body| bounds.union(&body.mesh().bounds()));
Some(Cached { document: app.session.doc.clone(), revision: app.session.revision, inputs: inputs.to_vec(), surfaces, bounds })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{dialogs::Kind, scenario::Harness};
use serde_json::json;
fn mesh_volume(scene: &GpuScene) -> f64 {
scene
.tris
.as_chunks::<{ crate::gpu::TRI_SIZE * 3 }>()
.0
.iter()
.map(|triangle| {
let points: Vec<_> = triangle
.as_chunks::<{ crate::gpu::TRI_SIZE }>()
.0
.iter()
.map(|vertex| {
let numbers: Vec<_> =
vertex.get(..12).unwrap().as_chunks::<4>().0.iter().map(|bytes| f32::from_le_bytes(*bytes) as f64).collect();
Vec3::new(numbers[0], numbers[1], numbers[2])
})
.collect();
points[0].dot(points[1].cross(points[2])) / 6.
})
.sum()
}
#[test]
fn face_mesh_feedback_reuses_cap_and_handles_both_distance_signs() {
let mut harness = Harness::new();
harness.app.run("solid.box", json!({"length":4,"width":3,"height":5,"body_name":"Stock"})).unwrap();
let state = harness.app.session.world_state();
let face = state
.body("Stock")
.unwrap()
.body
.faces(0.001)
.unwrap()
.into_iter()
.find(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99))
.unwrap();
harness.app.session.selection = vec![Sel::Face { body: "Stock".into(), index: face.index, point: face.centroid }];
harness.app.start("solid.extrude");
let original = harness.app.session.doc.clone();
let undo = harness.app.session.undo.len();
let mut preview = StraightPreview::default();
let colors = Colors::from_tokens(&crate::theme::Tokens::get());
let mut pointer = None;
for distance in [2.0_f64, -2., 3.] {
if let Kind::Extrude { distance: value, operation, auto_op, .. } = &mut harness.app.dialog.as_mut().unwrap().kind {
*value = distance.to_string();
*operation = 0;
*auto_op = false;
}
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
let scene = preview.compute(&harness.app, &commands, colors).unwrap().unwrap();
assert!((mesh_volume(&scene.scene) - 12. * distance.abs()).abs() < 1e-5);
let current = preview.cached.as_ref().unwrap().surfaces[0].triangles.as_ptr();
if let Some(pointer) = pointer {
assert_eq!(pointer, current);
} else {
pointer = Some(current);
}
}
assert!(Arc::ptr_eq(&original, &harness.app.session.doc));
assert_eq!(harness.app.session.undo.len(), undo);
harness.app.run("solid.box", json!({"length":1,"width":1,"height":1,"corner":[30,0,0]})).unwrap();
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
preview.compute(&harness.app, &commands, colors).unwrap().unwrap();
assert!(Arc::ptr_eq(&preview.cached.as_ref().unwrap().document, &harness.app.session.doc));
assert!(!Arc::ptr_eq(&original, &preview.cached.as_ref().unwrap().document));
if let Sel::Face { point, .. } = &mut harness.app.dialog.as_mut().unwrap().inputs[0].items[0] {
*point = Vec3::new(100., 100., 100.);
}
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
assert!(preview.compute(&harness.app, &commands, colors).is_none());
let mut commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
commands[0].1["through_all"] = json!(true);
assert!(preview.compute(&harness.app, &commands, colors).is_none());
commands[0].1 = json!({"distance":2,"taper":"3deg"});
assert!(preview.compute(&harness.app, &commands, colors).is_none());
}
#[test]
fn identity_component_feedback_skips_moved_and_repeated_placements() {
let mut h = Harness::new();
let made = h.app.run("component.create", json!({"name":"Imported part"})).unwrap();
h.app.run("solid.box", json!({"length":4,"width":3,"height":5,"body_name":"Stock"})).unwrap();
let world = h.app.session.world_state();
let face =
world.body("Stock").unwrap().body.faces(0.001).unwrap().into_iter().find(|face| face.plane_normal.is_some_and(|n| n.z > 0.99)).unwrap();
h.app.session.selection = vec![Sel::Face { body: "Stock".into(), index: face.index, point: face.centroid }];
h.app.start("solid.extrude");
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
let colors = Colors::from_tokens(&crate::theme::Tokens::get());
let mut preview = StraightPreview::default();
assert!(preview.compute(&h.app, &commands, colors).unwrap().is_ok());
h.app.run("occurrence.move", json!({"occurrence":made["occurrence"],"translate":[20,0,0]})).unwrap();
assert!(preview.compute(&h.app, &commands, colors).is_none());
h.app.run("occurrence.move", json!({"occurrence":made["occurrence"],"translate":[-20,0,0]})).unwrap();
h.app.run("occurrence.copy", json!({"component":made["component"],"translate":[30,0,0]})).unwrap();
assert!(preview.compute(&h.app, &commands, colors).is_none());
}
#[test]
fn profile_feedback_keeps_holes_and_skips_unsupported_curves() {
let mut harness = Harness::new();
harness.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
for (a, b) in [([0., 0.], [10., 10.]), ([3., 3.], [7., 7.])] {
harness.app.run("sketch.rectangle.two_point", json!({"p0":a,"p1":b})).unwrap();
}
harness.app.run("sketch.finish", json!({})).unwrap();
let sketch = harness.app.session.model.state().sketches[0].clone();
let index = sketch.profiles.iter().position(|profile| !profile.region.holes.is_empty()).unwrap();
harness.app.session.selection = vec![Sel::Profile { sketch: sketch.feature, index }];
harness.app.start("solid.extrude");
if let Kind::Extrude { distance, operation, auto_op, .. } = &mut harness.app.dialog.as_mut().unwrap().kind {
*distance = "2".into();
*operation = 0;
*auto_op = false;
}
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
let scene = StraightPreview::default().compute(&harness.app, &commands, Colors::from_tokens(&crate::theme::Tokens::get())).unwrap().unwrap();
assert!((mesh_volume(&scene.scene) - 168.).abs() < 1e-5);
harness.app.dialog = None;
harness.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
harness.app.run("sketch.circle.center", json!({"center":[20,0],"diameter":4})).unwrap();
harness.app.run("sketch.finish", json!({})).unwrap();
let feature = harness.app.session.model.state().sketches.last().unwrap().feature;
harness.app.session.selection = vec![Sel::Profile { sketch: feature, index: 0 }];
harness.app.start("solid.extrude");
let commands = crate::dialogs::apply_commands(&harness.app, harness.app.dialog.as_ref().unwrap()).unwrap();
assert!(StraightPreview::default().compute(&harness.app, &commands, Colors::from_tokens(&crate::theme::Tokens::get())).is_none());
}
}
+1
View File
@@ -98,6 +98,7 @@ mod apply_off_thread_tests;
mod expression_complete; mod expression_complete;
#[cfg(test)] #[cfg(test)]
mod extrude_edit_tests; mod extrude_edit_tests;
mod extrude_preview;
mod file_load; mod file_load;
mod file_picker; mod file_picker;
#[cfg(test)] #[cfg(test)]
+45
View File
@@ -6,6 +6,51 @@ use solidcraft_engine::doc::expr::Kind as ValueKind;
use solidcraft_engine::geom::Vec3; use solidcraft_engine::geom::Vec3;
use solidcraft_engine::{Sel, Session}; use solidcraft_engine::{Sel, Session};
#[test]
fn confirming_coplanar_body_faces_uses_one_atomic_extrusion() {
let mut h = crate::scenario::Harness::new();
let parent = h.app.run("component.create", json!({"name":"Imported assembly"})).unwrap()["component"].clone();
h.app.run("component.create", json!({"name":"Part"})).unwrap();
h.app.run("solid.box", json!({"length":30,"width":20,"height":20,"body_name":"Plate"})).unwrap();
h.app.run("sketch.create", json!({"plane":{"origin":[0,0,20],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap();
for x in [2, 10] {
h.app.run("sketch.rectangle.two_point", json!({"p0":[x,2],"p1":[x+4,6]})).unwrap();
}
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("solid.extrude", json!({"distance":6,"operation":"join"})).unwrap();
h.app.run("component.activate", json!({"component":parent})).unwrap();
let world = h.app.session.world_state();
let body = world.body("Plate").unwrap();
h.app.session.selection = body
.body
.faces(0.01)
.unwrap()
.into_iter()
.filter(|face| face.plane_normal.is_some_and(|normal| normal.z > 0.99) && (face.centroid.z - 26.0).abs() < 1e-8)
.map(|face| Sel::Face { body: "Plate".into(), index: face.index, point: face.centroid })
.collect();
assert_eq!(h.app.session.selection.len(), 2);
h.app.start("solid.extrude");
if let Kind::Extrude { distance, .. } = &mut h.app.dialog.as_mut().unwrap().kind {
*distance = "-7".into();
}
h.frames(3);
let commands = apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
assert_eq!(commands.len(), 1);
assert_eq!(commands.first().unwrap().1["faces"].as_array().unwrap().len(), 2);
assert_eq!(commands.first().unwrap().1["face_body"], "Plate");
assert_eq!(commands.first().unwrap().1["operation"], "cut");
assert!(h.app.preview.error.is_none(), "{:?}", h.app.preview.error);
let undo = h.app.session.undo.len();
h.frames(3);
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "busy={}, dialog={:?}", h.app.applying.is_some(), h.app.dialog);
assert_eq!(h.app.session.doc.features.len(), 5);
assert_eq!(h.app.session.undo.len(), undo + 1);
let volume = solidcraft_engine::doc::kernel::measure(&h.app.session.model.state().body("Plate").unwrap().body).unwrap().volume;
assert!((volume - 11_968.0).abs() < 1e-5, "{volume}");
}
use crate::dialogs::{Dialog, Kind, apply_commands}; use crate::dialogs::{Dialog, Kind, apply_commands};
use crate::{Services, SolveApp}; use crate::{Services, SolveApp};
+55
View File
@@ -98,10 +98,12 @@ pub struct PreviewState {
pub stopped: Option<serde_json::Value>, pub stopped: Option<serde_json::Value>,
/// The radius splines the previewed fillet tapered its edges with. /// The radius splines the previewed fillet tapered its edges with.
pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>, pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>,
straight: crate::extrude_preview::StraightPreview,
} }
impl PreviewState { impl PreviewState {
fn clear(&mut self) { fn clear(&mut self) {
self.straight.clear();
self.running = None; self.running = None;
self.running_tool = false; self.running_tool = false;
self.running_stream_key = None; self.running_stream_key = None;
@@ -293,6 +295,14 @@ pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
if pv.running.is_none() && pv.shown_key != Some(key) { if pv.running.is_none() && pv.shown_key != Some(key) {
pv.started = crate::now_ms(); pv.started = crate::now_ms();
pv.running_stream_key = stream_key; pv.running_stream_key = stream_key;
let mut straight = std::mem::take(&mut app.preview.straight);
let job = straight.compute(app, &cmds, colors);
app.preview.straight = straight;
if let Some(job) = job {
app.preview.apply(key, job);
app.preview.busy = false;
return;
}
let scratch = app.session.scratch(); let scratch = app.session.scratch();
spawn(&mut app.preview, key, scratch, cmds, colors, tool); spawn(&mut app.preview, key, scratch, cmds, colors, tool);
} }
@@ -394,6 +404,51 @@ fn compute_tool(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
Ok(b) Ok(b)
} }
fn extrusion_params<'a>(id: &str, params: &'a Value) -> Option<&'a Value> {
match id {
"solid.extrude" => Some(params),
"timeline.redefine" if params.get("command").and_then(Value::as_str) == Some("solid.extrude") => params.get("params"),
_ => None,
}
}
/// Check references without intersecting the tool with the target's geometry.
pub(crate) fn extrusion_target_error(s: &Session, cmds: &[(String, Value)], before: &ModelState) -> Option<String> {
for (id, params) in cmds {
let params = extrusion_params(id, params)?;
let op = params.get("operation").and_then(Value::as_str).unwrap_or("new");
if op == "new" {
continue;
}
let targets = params
.get("targets")
.and_then(Value::as_array)
.filter(|targets| !targets.is_empty())
.or_else(|| params.get("participants").and_then(Value::as_array));
let mut found = false;
if let Some(targets) = targets.filter(|targets| !targets.is_empty()) {
for target in targets.iter().filter_map(Value::as_str) {
if target == "*" {
found |= !before.bodies.is_empty();
} else if before.body(target).is_some() {
found = true;
} else {
return Some(format!("unknown body `{target}`"));
}
}
} else {
found = params.get("faces").and_then(|_| params.get("face_body")).and_then(Value::as_str).map_or_else(
|| before.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component),
|name| s.world_state().body(name).is_some(),
);
}
if !found && matches!(op, "cut" | "intersect") {
return Some("there is no body to cut or intersect".into());
}
}
None
}
fn state_bounds(st: &ModelState) -> Aabb3 { fn state_bounds(st: &ModelState) -> Aabb3 {
st.bodies.iter().fold(Aabb3::EMPTY, |b, x| b.union(&x.mesh().bounds())) st.bodies.iter().fold(Aabb3::EMPTY, |b, x| b.union(&x.mesh().bounds()))
} }
+7
View File
@@ -46,6 +46,13 @@ worse, so a kernel change shows every seed it moves. Pull requests that touch `c
run every 8th seed; pushes to `main` and the nightly run them all. Each seed is its own test run every 8th seed; pushes to `main` and the nightly run them all. Each seed is its own test
(`seed_00` … `seed_95`), so the CI shards and their cores share them out. (`seed_00` … `seed_95`), so the CI shards and their cores share them out.
Explicit `platforms.<os>.seeds` records override only the listed seeds on that
operating system; all other seeds keep the shared records and strict tolerances.
The first four Windows records were independently reproduced on unmodified main
`1e984c8e`; seed 94 was independently reproduced on unmodified main `c5992386`
after the extrusion rebase. Both match the extrusion branch. Recording on a platform
with such entries updates its changed seeds and preserves shared references.
To record an intended change, give the reason: To record an intended change, give the reason:
``` ```
+4 -1
View File
@@ -98,6 +98,7 @@
"94": {"area":3480.70333,"bodies":1,"edges":61,"error":null,"faces":26,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856}, "94": {"area":3480.70333,"bodies":1,"edges":61,"error":null,"faces":26,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.446856},
"95": {"area":2375.340299,"bodies":1,"edges":33,"error":null,"faces":12,"outcome":"ok","rejected":2,"shells":1,"steps":["revolved section","hole","cut cylinder","cut sphere","circular pattern","cut sphere"],"unsupported":1,"volume":6704.220611} "95": {"area":2375.340299,"bodies":1,"edges":33,"error":null,"faces":12,"outcome":"ok","rejected":2,"shells":1,"steps":["revolved section","hole","cut cylinder","cut sphere","circular pattern","cut sphere"],"unsupported":1,"volume":6704.220611}
}, },
"platforms": {"windows":{"seeds":{"1":{"area":2642.450837,"bodies":1,"edges":32,"error":null,"faces":13,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut cylinder","join cylinder"],"unsupported":0,"volume":6991.569339},"23":{"area":1795.696636,"bodies":1,"edges":15,"error":null,"faces":7,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere"],"unsupported":0,"volume":3198.694618},"34":{"area":1086.303608,"bodies":1,"edges":30,"error":null,"faces":14,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut sphere","hole"],"unsupported":0,"volume":2134.3653},"67":{"area":7943.46222,"bodies":2,"edges":86,"error":null,"faces":36,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","cut cylinder","hole","join cylinder","join box","hole","chamfer","rectangular pattern"],"unsupported":2,"volume":27607.962186},"94":{"area":3470.837042,"bodies":1,"edges":63,"error":null,"faces":27,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","cut cylinder","fillet","hole","rectangular pattern","cut box","cut box"],"unsupported":0,"volume":5456.438772}}}},
"notes": [ "notes": [
{"reason":"First record: what each seed builds on main at 1a0100cd","seeds":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95]}, {"reason":"First record: what each seed builds on main at 1a0100cd","seeds":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95]},
{"reason":"seed 0 meshes differently since display tessellation plans (#805), which landed alongside these goldens (#826)","seeds":[0]}, {"reason":"seed 0 meshes differently since display tessellation plans (#805), which landed alongside these goldens (#826)","seeds":[0]},
@@ -135,6 +136,8 @@
{"reason":"After integrating #1718, replay the regenerated revolved-section recipes on the combined branch: seed 13 has 40 faces/101 edges (one face/two edges fewer), seed 94 has 27 faces/63 edges (one face/two edges more). Both complete fixed command sequences pass validity and volume-direction checks after every step, and two independent golden runs per seed agree exactly. Steps, outcomes, body and shell counts are unchanged; area and volume match within existing 1e-6 relative tolerances (seed 13 volume differs by 0.000002 mm3). Only topology counts are updated. The finer unshifted conical-cut route and its geometry guards remain in place.","seeds":[13,94]}, {"reason":"After integrating #1718, replay the regenerated revolved-section recipes on the combined branch: seed 13 has 40 faces/101 edges (one face/two edges fewer), seed 94 has 27 faces/63 edges (one face/two edges more). Both complete fixed command sequences pass validity and volume-direction checks after every step, and two independent golden runs per seed agree exactly. Steps, outcomes, body and shell counts are unchanged; area and volume match within existing 1e-6 relative tolerances (seed 13 volume differs by 0.000002 mm3). Only topology counts are updated. The finer unshifted conical-cut route and its geometry guards remain in place.","seeds":[13,94]},
{"reason":"#1670 and #1753 (mesh export: twisted ruled faces divided to the chord tolerance, and the faces of an edge sharing its points exactly) change the meshes the kernel's validity and volume checks measure: seed 55's body (a filleted box cut by spheres and patterned) now passes the validity check it failed, and its volume moves by 1.3e-5 relative. Pull requests ran only every 8th seed, so main's full run found it. Recorded from main's CI output (Linux).","seeds":[55]}, {"reason":"#1670 and #1753 (mesh export: twisted ruled faces divided to the chord tolerance, and the faces of an edge sharing its points exactly) change the meshes the kernel's validity and volume checks measure: seed 55's body (a filleted box cut by spheres and patterned) now passes the validity check it failed, and its volume moves by 1.3e-5 relative. Pull requests ran only every 8th seed, so main's full run found it. Recorded from main's CI output (Linux).","seeds":[55]},
{"reason":"#853 recorded seeds 13, 71 and 94 as its own branch built them (13: 40 faces/101 edges, 71: 19/50, 94: 27/63), but with main's kernel they build as before (41/103, 20/52, 26/61), so every full run on main failed on them after #853 landed. This puts back the counts main builds; #853's conical-cut refinement (seed 20) is unchanged. Steps, outcomes, volumes and areas are the same.","seeds":[13,71,94]}, {"reason":"#853 recorded seeds 13, 71 and 94 as its own branch built them (13: 40 faces/101 edges, 71: 19/50, 94: 27/63), but with main's kernel they build as before (41/103, 20/52, 26/61), so every full run on main failed on them after #853 landed. This puts back the counts main builds; #853's conical-cut refinement (seed 20) is unchanged. Steps, outcomes, volumes and areas are the same.","seeds":[13,71,94]},
{"reason":"#1853 (a part revolve of a profile with an edge on the axis is swept a third of a turn at most per face, so the boolean can intersect its faces): seeds 28 and 95 start with a rectangle on the axis turned half a turn, now two quarter turns. Seed 28 (joined by a cylinder) has more faces for the same shape (5902.1214 -> 5902.1117 mm3, the new faces' meshes). Seed 95's hole into it failed with 'boolean Cut: no result' on the half-turn faces and now builds, so the generator's later picks move. Values from Linux CI.","seeds":[28,95]} {"reason":"#1853 (a part revolve of a profile with an edge on the axis is swept a third of a turn at most per face, so the boolean can intersect its faces): seeds 28 and 95 start with a rectangle on the axis turned half a turn, now two quarter turns. Seed 28 (joined by a cylinder) has more faces for the same shape (5902.1214 -> 5902.1117 mm3, the new faces' meshes). Seed 95's hole into it failed with 'boolean Cut: no result' on the half-turn faces and now builds, so the generator's later picks move. Values from Linux CI.","seeds":[28,95]},
{"reason":"Independently reproduced on unmodified main 1e984c8e on Windows; the extrusion branch produces the same values. Shared references and tolerances remain unchanged.","platform":"windows","seeds":[1,23,34,67]},
{"reason":"Seed 94 independently reproduced on unmodified main c5992386 on Windows after the extrusion rebase: the same steps, outcome and geometry values as the rebased branch. Shared references and tolerances remain unchanged.","platform":"windows","seeds":[94]}
] ]
} }