diff --git a/ROADMAP.md b/ROADMAP.md index 22ff428c..58799605 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -274,6 +274,25 @@ completed drawings publish atomically. The browser still exports synchronously. ## 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 the command palette open, at normal and enlarged display scales. Fit still accounts for dialog coverage; covered controls do not receive clicks through dialogs. diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 98754470..13101e0b 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1581,6 +1581,29 @@ fn apply_beside_sheet_cut(st: &mut ModelState, f: &Feature, tools: Vec, 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> { + 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, op: Operation, targets: &[String]) -> Result<()> { 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, 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::>()) + { + 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::>()) + } 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::>())], op, &names).is_ok() { + *state = batched; + return Ok(()); + } + } + } match op { Operation::Join => { // 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). let mut extents: Vec<(f64, f64)> = Vec::with_capacity(regions.len()); let plain_targets: Vec = crate::plain(targets); - let surroundings = Surroundings::of(st, *operation, &plain_targets); - // 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 = 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(); - let join_surroundings = - if *operation == Operation::Join && !trim_targets.is_empty() { Surroundings::of(st, *operation, &trim_targets) } else { None }; - for r in ®ions { + let exact_planar_cut = *operation == Operation::Cut + && ss.sketch.links.len() > 1 + && ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. })) + && st + .bodies + .iter() + .filter(|body| plain_targets.is_empty() || plain_targets.iter().any(|target| target == "*" || target == &body.name)) + .all(|body| kernel::is_planar(&body.body)); + let mut touching_batch = false; + // Generated face profiles have full source seams. Check all of them in + // one guarded construction; arbitrary sketch profiles retain their existing path. + if *operation == Operation::Join + && regions.len() > 1 + && ss.sketch.links.len() > 1 + && ss.sketch.links.iter().all(|link| matches!(&link.source, solidcraft_sketch::LinkSource::Face { .. })) + { + let exact = regions + .iter() + .map(|region| centered_extrude(&ss.plane, region, lo + off, hi + off, Some(&ss))) + .collect::>>()? + .into_iter() + .flatten() + .collect::>(); + if let Ok(Some(compound)) = kernel::compound_disjoint_extrusions(&exact.iter().map(|tool| tool.body.clone()).collect::>()) + && 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 = 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 { let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?; if exact.iter().all(|tool| { diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 6b7d8151..3867827f 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -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 ¢ers { + 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::>(), + reference.model.state().bodies.iter().map(|body| body.name.clone()).collect::>() + ); + for body in &session.model.state().bodies { + assert!((volume(&session, &body.name) - volume(&reference, &body.name)).abs() < 1e-6); + } + } +} diff --git a/crates/engine/src/cmd/face.rs b/crates/engine/src/cmd/face.rs index b7351d5b..0eb29b27 100644 --- a/crates/engine/src/cmd/face.rs +++ b/crates/engine/src/cmd/face.rs @@ -84,6 +84,10 @@ type FacePick = (std::sync::Arc, 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, Vec> { 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, Vec> { let mut best: Option = None; let mut tied: Vec = 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, 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. diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 20becc82..63e4cfae 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 { 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::>>()?; + 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 { _ => 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"); diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 5db4403e..94a6421e 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -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, ¶ms), 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, ¶ms), Some(expected)); + params["face_body"] = json!("Stock"); + assert_eq!(crate::auto_operation(&s, ¶ms), 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, ¶ms), Some("join"), "an existing active-component body keeps its original participant scope"); + params["participants"] = json!(["Stock"]); + assert_eq!(crate::auto_operation(&s, ¶ms), Some("cut")); + params["participants"] = json!(["Other"]); + assert_eq!(crate::auto_operation(&s, ¶ms), 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) } diff --git a/crates/engine/src/cmd/sketch_project.rs b/crates/engine/src/cmd/sketch_project.rs index 7d4649c7..54b13d31 100644 --- a/crates/engine/src/cmd/sketch_project.rs +++ b/crates/engine/src/cmd/sketch_project.rs @@ -520,21 +520,34 @@ pub(super) fn snap_points(s: &mut Session, p: &Value, cmd: &str) -> Result Result { + 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> { + 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). diff --git a/crates/engine/src/frames.rs b/crates/engine/src/frames.rs index 122e1626..9d83ff9f 100644 --- a/crates/engine/src/frames.rs +++ b/crates/engine/src/frames.rs @@ -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)], diff --git a/crates/engine/src/tests/robustness_goldens.rs b/crates/engine/src/tests/robustness_goldens.rs index 05922229..feef21db 100644 --- a/crates/engine/src/tests/robustness_goldens.rs +++ b/crates/engine/src/tests/robustness_goldens.rs @@ -54,6 +54,24 @@ fn differs(want: &Value, got: &Value) -> Option { (!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 = 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 = 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 = 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 = 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; } diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index add84c75..e2ac5f5c 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -1494,6 +1494,33 @@ impl Body { 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> { + 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. pub(crate) fn separated_lumps(&self) -> Result> { self.lumps_with_clearance(4.0 * (self.size() * 1e-3).max(1e-3)) diff --git a/crates/kernel/src/coplanar.rs b/crates/kernel/src/coplanar.rs index 530cc4a4..801b8a37 100644 --- a/crates/kernel/src/coplanar.rs +++ b/crates/kernel/src/coplanar.rs @@ -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` /// when no such pair of faces exists. pub(crate) fn glue(a: &Body, b: &Body) -> Option> { + 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> { + glue_impl(a, b, true) +} + +fn glue_impl(a: &Body, b: &Body, all_caps: bool) -> Option> { use mt::{BoundedCurve, ParametricCurve}; let size = a.size().max(b.size()); 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 c = e.curve(); let (t0, t1) = c.range_tuple(); @@ -1205,14 +1213,40 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option> { // 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). 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 fb_list: Vec<&mt::Face> = b.solid.face_iter().collect(); // B edge → the matching A edge; B vertex → the matching A vertex. let mut map: HashMap = HashMap::new(); let mut vmap: HashMap = HashMap::new(); let (mut glued_a, mut glued_b): (Vec, Vec) = (Vec::new(), Vec::new()); + if all_caps && pa.len().saturating_mul(pb.len()) > 2_000_000 { + return None; + } for (ia, x) in &pa { 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 { continue; } @@ -1264,6 +1298,14 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option> { 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() { return None; } diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index a2b84cd6..35eb423e 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -175,7 +175,10 @@ pub use mesh_convert::convert_prismatic_mesh; pub use meshbody::{MAX_TRIANGLES, mesh_body}; pub use native::{decode_native_body, encode_native_body, native_preview_mesh}; 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 polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell}; pub use prism::{PLANAR_FIT_TOL, extrude_planar_regions, planar_regions_boolean}; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 3c8ebdc8..08512db6 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -23,12 +23,167 @@ pub enum BoolOp { 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> { + 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 ®ion.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. /// Callers can retain the exact touching tool instead of extending it into material. 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()) } +/// 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 /// ray whose direction is derived from point coordinates; moving both solids (and the result /// 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); } + #[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::>(); + 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, ®ions, 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] fn curved_near_contact_never_uses_a_shell_only_boolean_candidate() { 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). - if op == BoolOp::Union - && a.solid.boundaries().len() == 1 - && b.solid.boundaries().len() == 1 - && let Some(Ok(body)) = crate::coplanar::glue(a, b) - { - let (ok, _) = check(&body); - if ok { - return Ok(Some(body)); + if op == BoolOp::Union && 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) }; + if let Some(Ok(body)) = glued { + let (ok, _) = check(&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 diff --git a/crates/kernel/src/prism.rs b/crates/kernel/src/prism.rs index 41f989b7..c9a9ddc4 100644 --- a/crates/kernel/src/prism.rs +++ b/crates/kernel/src/prism.rs @@ -181,8 +181,8 @@ pub fn extrude_planar_regions(plane: &Plane, regions: &[Region2], start: f64, en remaining_pairs = remaining_pairs .checked_sub(1) .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) - || [t.start(), t.end()].iter().any(|p| s.point_at(s.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(closest_param(s, *p)).dist(*p) <= 1e-7); if endpoints_touch || !crossings(s, t, 1e-7).is_empty() { 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 /// circle, `s`'s ends and middle on `t`. 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) { (Seg2::Line { .. }, Seg2::Line { .. }) => on(s.start()) && on(s.end()), (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(); 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 { 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. - 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); } 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))) } +/// 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 { regions .iter() .flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)) .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) } @@ -1043,7 +1074,7 @@ fn box_holds(b: Option, p: Vec2) -> bool { /// 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). fn near_dist(segs: &[Seg2], boxes: &[Option], 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. @@ -1052,7 +1083,7 @@ fn near_dir(segs: &[Seg2], boxes: &[Option], p: Vec2, tol: f64) -> Optio .iter() .zip(boxes) .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)?; 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 { // Splitting a conic renormalises its weights, which moves its parameters: the second // 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 { s = s.split_at(at).0; } @@ -1304,7 +1335,7 @@ fn exact_loop(lp: &Loop2, tags: &[Tag], sources: &[Seg2], near: f64) -> (Vec (Vec { 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) => { - 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)))) - } - (Piece::Plain(s, _), Piece::Exact { src: f, t0, .. }) if inner(t0) => { - 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)))) - } + (Piece::Exact { src: f, t1, .. }, Piece::Plain(s, _)) if inner(t1) => src(f) + .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) => src(f) + .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, }; // 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 MAX_PLANAR_EVENTS { @@ -1466,7 +1497,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option MAX_PLANAR_EVENTS { @@ -1522,7 +1553,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option !same, } } else { - let ins = inside(&b, m); + let ins = inside_clear(&b, m, tol); match op { BoolOp::Union | BoolOp::Cut => !ins, BoolOp::Intersect => ins, @@ -1548,7 +1579,7 @@ fn boolean2d_arcs(a: &Section, b: &Section, op: BoolOp, tol: f64) -> Option= MAX_PLANAR_SEGMENTS { return None; } @@ -1712,7 +1743,7 @@ fn split_seg(seg: &Seg2, pts: &[Vec2], tol: f64) -> Option> { if p.dist(seg.start()) < tol || p.dist(seg.end()) < tol { 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) }) .collect(); @@ -1942,6 +1973,13 @@ fn sew_connected_sections( 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 // 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.) @@ -2518,6 +2556,62 @@ pub(crate) fn prism_union_between_caps(a: &Body, b: &Body, op: BoolOp) -> Option #[cfg(test)] mod planar_tests { 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 { Region2 { outer: Loop2::polygon(&[a, Vec2::new(b.x, a.y), b, Vec2::new(a.x, b.y)]), holes: vec![] } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0a255a2e..2dffaafd 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3760,7 +3760,36 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S if !sketch.is_null() { 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)> = 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) { if let Sel::Face { body, point, .. } = pick { out.push(("solid.extrude".to_string(), with(json!({"face": pt(*point), "face_body": body})))); diff --git a/crates/ui-egui/src/extrude_preview.rs b/crates/ui-egui/src/extrude_preview.rs new file mode 100644 index 00000000..76274935 --- /dev/null +++ b/crates/ui-egui/src/extrude_preview.rs @@ -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, +} + +struct Cached { + document: Arc, + revision: u64, + inputs: Vec, + surfaces: Vec, + 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> { + 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 { + 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(®ion.outer).chain(®ion.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::>(); + if triangles.is_empty() { + return None; + } + let edges = std::iter::once((®ion.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()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2a40f6e0..638583fa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -98,6 +98,7 @@ mod apply_off_thread_tests; mod expression_complete; #[cfg(test)] mod extrude_edit_tests; +mod extrude_preview; mod file_load; mod file_picker; #[cfg(test)] diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index c621c594..c3233dda 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -6,6 +6,51 @@ use solidcraft_engine::doc::expr::Kind as ValueKind; use solidcraft_engine::geom::Vec3; 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::{Services, SolveApp}; diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs index 41a2ab13..07098fa2 100644 --- a/crates/ui-egui/src/preview.rs +++ b/crates/ui-egui/src/preview.rs @@ -98,10 +98,12 @@ pub struct PreviewState { pub stopped: Option, /// The radius splines the previewed fillet tapered its edges with. pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>, + straight: crate::extrude_preview::StraightPreview, } impl PreviewState { fn clear(&mut self) { + self.straight.clear(); self.running = None; self.running_tool = false; 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) { pv.started = crate::now_ms(); 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(); 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) } +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 { + 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 { st.bodies.iter().fold(Aabb3::EMPTY, |b, x| b.union(&x.mesh().bounds())) } diff --git a/docs/robustness.md b/docs/robustness.md index db5f866d..dfa772fc 100644 --- a/docs/robustness.md +++ b/docs/robustness.md @@ -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 (`seed_00` … `seed_95`), so the CI shards and their cores share them out. +Explicit `platforms..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: ``` diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index 2418a1cb..2eb4bf04 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -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}, "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": [ {"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]}, @@ -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":"#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":"#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]} ] }