diff --git a/crates/doc/src/config_styles.rs b/crates/doc/src/config_styles.rs index 44c91ccd..1987f8c9 100644 --- a/crates/doc/src/config_styles.rs +++ b/crates/doc/src/config_styles.rs @@ -253,9 +253,20 @@ impl Document { return self.certified_pieces(body, indices, target.origin_feature); } if let Some(super::FaceCertificate::ExtrudeSide { sketch, curve }) = &target.certificate { - let indices: Vec<_> = body.body.face_sources().into_iter().flatten().enumerate().filter_map(|(index, source)| { - matches!(source, Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. }) if *producer == target.origin_feature && current_sketch == sketch && current_curve == curve).then_some(index) - }).collect(); + let side = |index: usize, exact: bool| matches!(body.body.face_sources().and_then(|sources| sources.get(index)), Some(Some(solidcraft_kernel::FaceSource::CertifiedExtrudeSide { producer, sketch: current_sketch, curve: current_curve, .. })) if *producer == target.origin_feature && current_sketch == sketch && (!exact || current_curve == curve)); + let count = body.body.face_sources().map_or(0, |sources| sources.len()); + let mut indices: Vec<_> = (0..count).filter(|index| side(*index, true)).collect(); + // A side drawn in collinear pieces is one face over all of them (#1788): a certificate + // made when the pieces were faces of their own finds it by the piece's name, which + // the joined face answers to. + if indices.is_empty() { + let names = crate::naming::names(body); + indices = (0..count) + .filter(|index| { + side(*index, false) && crate::history_names::matches(&names, *index, &target.name) == Some(crate::history_names::Match::Alias) + }) + .collect(); + } if indices.is_empty() { return Err(DocError::Unknown("constructed side certificate no longer exists".into())); } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 61f8df0c..d945176c 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -3079,7 +3079,8 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc }; // Fit cap surfaces in their own coordinates. Fitting a distant world-space // wire can tilt an otherwise exact planar cap through numeric cancellation. - let bodies = kernel::extrude(&Plane::XY, std::slice::from_ref(region), start, end)?; + // A side drawn in collinear pieces is one face (#1788). + let bodies = kernel::extrude_whole_sides(&Plane::XY, std::slice::from_ref(region), start, end)?; if *plane == Plane::XY { return Ok(bodies.into_iter().map(|body| roles.tool(body, None)).collect()); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fe739c29..fcb1b259 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3693,3 +3693,54 @@ fn pattern_copies_added_after_a_rename_keep_their_numbers() { run(&mut s, "timeline.edit", json!({"feature": "Pat", "set": {"pattern": {"count1": "2"}}})); assert_eq!(names(&s), ["X", "Second"]); } + +/// A sketch side drawn as two collinear lines extrudes into one flat face, not two with an edge +/// between them (#1788); the face is named from the first line and answers to the second. +#[test] +fn a_side_drawn_in_two_collinear_pieces_extrudes_into_one_face() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0], [20, 0], [20, 10], [0, 10], [0, 0]]})); + let pieces: Vec = r["created"]["curves"].as_array().into_iter().flatten().take(2).filter_map(|c| c.as_str().map(String::from)).collect(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5})); + let faces = |s: &mut Session| run(s, "model.faces", json!({"body": "Body1"}))["faces"].as_array().cloned().unwrap_or_default(); + let f = faces(&mut s); + assert_eq!(f.len(), 6, "a box has six faces: {f:?}"); + assert_eq!(run(&mut s, "inspect.measure", json!({}))["bodies"][0]["kernel"]["faces"], 6); + let names: Vec<&str> = f.iter().filter_map(|x| x["name"].as_str()).collect(); + assert!(names.contains(&format!("F2:side:{}", pieces[0]).as_str()), "{names:?}"); + // The second piece's name answers for the same face, so a reference made when the side was + // two faces (an edge of the second piece) still finds its edge. + let st = s.model.state(); + let body = st.body("Body1").unwrap(); + let edge = format!("F2:side:{}|F2:top", pieces[1]); + let (at, _) = solidcraft_doc::naming::find_edge(body, &edge, geom::Vec3::new(15.0, 0.0, 5.0)).unwrap(); + assert!(at.y.abs() < 1e-9 && (at.z - 5.0).abs() < 1e-9, "{edge} at {at:?}"); + // A sketch attaches to the joined side as to any side. + assert_eq!(run(&mut s, "sketch.create", json!({"plane": {"face": [15, 0, 2.5]}}))["attachment"], "exact_face"); + run(&mut s, "sketch.finish", json!({})); +} + +/// A sketch made on the second piece's face while the side was two faces (as in a design saved +/// before #1788) stays attached once the side is straight and extrudes into one face. +#[test] +fn a_sketch_on_one_piece_stays_attached_when_the_side_joins() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + let r = run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, -2], [20, 0], [20, 10], [0, 10], [0, 0]]})); + let first = r["created"]["curves"][0].as_str().unwrap_or_default().to_string(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5})); + let faces = |s: &mut Session| run(s, "model.faces", json!({"body": "Body1"}))["faces"].as_array().map(Vec::len).unwrap_or(0); + assert_eq!(faces(&mut s), 7); + assert_eq!(run(&mut s, "sketch.create", json!({"plane": {"face": [15, -1, 2.5]}}))["attachment"], "exact_face"); + run(&mut s, "sketch.circle.center", json!({"center": [5, 2.5], "radius": 1})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.edit", json!({"sketch": "Sketch1"})); + run(&mut s, "sketch.move_point", json!({"point": format!("{first}.end"), "to": [10, 0]})); + run(&mut s, "sketch.finish", json!({})); + assert_eq!(faces(&mut s), 6, "the side is straight now: one face"); + let sketch = run(&mut s, "timeline.inspect", json!({"feature": "Sketch2"})); + assert!(sketch["error"].is_null() && sketch["warning"].is_null(), "{sketch}"); +} diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index c6f1d12e..34d0ca77 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -190,6 +190,19 @@ pub(crate) fn frame_ok(plane: &Plane) -> bool { /// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`). pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result> { + extrude_with(plane, regions, start, end, false) +} + +/// [`extrude`] with each run of profile lines that continue one another in a straight line (a side +/// drawn in two pieces, a mirrored half meeting its mirror image) swept into one side face over +/// all its pieces, rather than coplanar faces with an edge between them (#1788). The side's +/// history lists every piece under it. For the Extrude feature; other builders (sheet metal's +/// flat pattern, tools) keep a face per segment. +pub fn extrude_whole_sides(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result> { + extrude_with(plane, regions, start, end, true) +} + +fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, whole_sides: bool) -> Result> { if !frame_ok(plane) { return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into())); } @@ -206,7 +219,19 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu for r in regions { region_ok(r)?; let base = plane.offset(lo); - let profile = region_wires(&base, &oriented(r)?)?; + let pieces = oriented(r)?; + let numbered = if whole_sides { pieces.map_loops(|l| l.merged_lines(continues_straight)) } else { pieces.clone() }; + // A joined side's source is its first piece, a segment of the profile as drawn (the side + // lies along it): the sketch curve it is certified and named by, as when it stood alone. + let first_piece = |side: &ProfileEdge| { + pieces + .loops() + .flat_map(|l| l.lp.segs.iter()) + .copied() + .find(|piece| side.numbers.len() > 1 && piece.start() == side.segment.start()) + .unwrap_or(side.segment) + }; + let profile = region_wires(&base, &numbered)?; let wires = wires_of(&profile); let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?; let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?; @@ -263,7 +288,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu _ => None, }; ( - Some(crate::FaceSource::ExtrudeSide { segment: side.segment, plane: basis, coalesced: false }), + Some(crate::FaceSource::ExtrudeSide { segment: first_piece(side), plane: basis, coalesced: false }), Some(record::profile(&side.numbers)), ) } else if Some(*id) == end { @@ -285,6 +310,14 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu Ok(out) } +/// `t` carries on from `s` in the same straight line: the turn at their meeting point is within +/// rounding of none, measured against both lengths. +fn continues_straight(s: &Seg2, t: &Seg2) -> bool { + let (u, v) = (s.end() - s.start(), t.end() - t.start()); + let (lu, lv) = (u.len(), v.len()); + lu > 0.0 && lv > 0.0 && s.end().dist(t.start()) <= 1e-9 * (lu + lv) && u.dot(v) > 0.0 && u.cross(v).abs() <= 1e-9 * lu * lv +} + /// Choose the per-profile axis sign whose positive rotation follows the sketch normal. pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2, axis_dir: Vec2) -> Result { if !axis_origin.is_finite() || !axis_dir.is_finite() || !plane.origin.is_finite() || !plane.x.is_finite() || !plane.y.is_finite() { diff --git a/crates/kernel/src/history_tests.rs b/crates/kernel/src/history_tests.rs index ec563f31..25e2a58f 100644 --- a/crates/kernel/src/history_tests.rs +++ b/crates/kernel/src/history_tests.rs @@ -244,3 +244,36 @@ fn face_sources_follow_kept_split_and_agreeing_merged_faces() { let s = h.face_sources(&[&a]); assert_eq!(s, vec![src(0), src(1), None, src(2), None]); } + +/// A side drawn in two collinear pieces (here the bottom edge, split at x = 10) sweeps into one +/// side face over both pieces, not two coplanar faces with an edge between them (#1788). +#[test] +fn collinear_profile_lines_extrude_into_one_side_face() { + use solidcraft_geom::{Loop2, Plane, Region2, Vec2}; + let pts = [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y)); + let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] }; + // Plain `extrude` keeps a face per segment (sheet metal's flat pattern relies on it). + assert_eq!(crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 0.0, 5.0).unwrap()[0].face_count(), 7); + let body = crate::extrude_whole_sides(&Plane::XY, &[region], 0.0, 5.0).unwrap().pop().unwrap(); + assert_eq!(body.face_count(), 6, "a box has six faces"); + assert!((crate::measure(&body).unwrap().volume - 1000.0).abs() < 1e-9); + let h = body.history().unwrap(); + h.check(&body, &[]).unwrap(); + let mut sides: Vec> = + h.faces.iter().map(|f| f.origins.clone()).filter(|o| matches!(o.first(), Some(FaceOrigin::Profile { .. }))).collect(); + sides.sort(); + assert_eq!( + sides, + vec![ + vec![FaceOrigin::Profile { segment: 0 }, FaceOrigin::Profile { segment: 1 }], + vec![FaceOrigin::Profile { segment: 2 }], + vec![FaceOrigin::Profile { segment: 3 }], + vec![FaceOrigin::Profile { segment: 4 }], + ], + "the bottom side is generated by both its pieces" + ); + // A corner is not a straight run: an L-shaped step keeps its faces. + let step = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (20.0, 5.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y)); + let body = crate::extrude_whole_sides(&Plane::XY, &[Region2 { outer: Loop2::polygon(&step), holes: vec![] }], 0.0, 5.0).unwrap().pop().unwrap(); + assert_eq!(body.face_count(), 8); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index f29b7dd9..cd85def3 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -151,8 +151,8 @@ pub use boundary::{Boundary, boundary_patch}; pub use boundary_network::{BoundaryCurve, CurveSource, MAX_CHAIN_CURVES, MAX_EDGE_PICKS, boundary_join, boundary_surface}; pub use bridge::{Continuity, Frame, blend_between, bridge, chain, chain_closes, frames, frames_across, sample_grid}; pub use build::{ - PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, loft, path_polyline, revolve, - revolve_whole, sphere, sweep, sweep_with, torus, + PathSeg, box_solid, canonical_revolve_axis, cylinder, extrude, extrude_tapered, extrude_tapered_between, extrude_whole_sides, loft, + path_polyline, revolve, revolve_whole, sphere, sweep, sweep_with, torus, }; pub use clearance::{Clearance, Occupancy}; pub use coaxial_overlap::{CoaxialOverlap, coaxial_overlap};