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

@@ -584,3 +584,227 @@ fn closed_sweep_redefines_to_a_half_turn_torus_cut() {
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.
fn face_at_in(s: &Session, p: Vec3, only: Option<&str>) -> std::result::Result<Option<FacePick>, Vec<String>> {
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 tied: Vec<String> = Vec::new();
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])
}
/// 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
/// the sketch id, a point inside the face (sketch coordinates), and the picked body's name.
/// The sketch is left active.
+52 -14
View File
@@ -18,7 +18,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "CREATE")
.icon("extrude")
.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)
.at("SOLID", "CREATE")
.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
/// face, and the unit normal.
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"))?));
}
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
/// past a body's far side read as a Join (#1257).
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 (base, n) = extrude_base(s, p)?;
let st = s.model.state();
// Imported face groups can belong to a child of an empty active component.
// 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() {
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 implicit_all = grouped.is_some() && !st.bodies.iter().any(|body| s.doc.model_body_component(body) == s.active_component);
let bodies: Vec<_> = st
.bodies
.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();
if bodies.is_empty() {
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.
let participants = if participants.is_empty()
&& !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();
!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> {
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.
let (sketch, face_profile, face_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))
let (sketch, face_profile, face_body) = if let Some(grouped) = grouped {
grouped
} else {
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 thin = match bool_(p, "thin") {
@@ -565,7 +603,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
_ => None,
};
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)?,
};
let mut targets = string_list(p, "targets");
@@ -2,6 +2,63 @@ use serde_json::{Value, json};
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 {
match s.execute(id, &p) {
Ok(v) => v,
@@ -28,6 +85,43 @@ fn volume(s: &mut Session) -> f64 {
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 {
(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
/// 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> {
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 raw = s.model.state_before(id);
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 plane = sketch_plane(&doc, id, &state)?;
let resolved = solidcraft_doc::project::resolve_face_indices(&state, &plane, LinkKind::Project, body, &[index])?;
let face_name = state.body(body).and_then(|body| solidcraft_doc::naming::face_names(body).get(index).cloned());
let names = state.body(body).map(solidcraft_doc::naming::face_names).unwrap_or_default();
let mut sk = doc.sketch(id)?.clone();
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 = face_name;
let mut links = Vec::with_capacity(indices.len());
for &index in indices {
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;
*s.doc_mut() = doc;
Ok(link)
Ok(links)
}
/// 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.
fn table(id: &str) -> &'static [(&'static str, K)] {
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.box" | "solid.sphere" => &[("corner", Make), ("center", Make)],
"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("; "))
}
/// 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) {
let got: BTreeMap<u64, Value> = BTreeMap::from([(seed, fuzz::golden(seed, MAX_STEPS))]);
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
.iter()
.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)) }
})
.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\"."
);
}
let platform = std::env::consts::OS;
let platform_record = file["platforms"][platform]["seeds"].is_object();
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 seeds: Vec<u64> = changed.iter().map(|(s, _)| *s).collect();
// The parts of one record (the tests run side by side) share one note.
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();
all.sort_unstable();
all.dedup();
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);
// 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 { "" });
}
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()));
return;
}