diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 06149643..2e0125d8 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1942,6 +1942,39 @@ fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: (lo2, hi2) } +/// Whether a tapered tool, run on by `depth` behind the plane it starts in (`dir` the way it +/// extrudes), stays where running on changes nothing: in the targets for a join, out of them for +/// a cut. Its sides keep their slope, so the section there is the profile grown by up to `widen` +/// all round; that grown outline is probed at two depths. +#[allow(clippy::too_many_arguments)] +fn taper_run_on_fits(st: &ModelState, start: &Plane, dir: Vec3, r: &Region2, depth: f64, widen: f64, op: Operation, targets: &[String]) -> bool { + const MAX_SEGMENTS: usize = 2_000; + let meshes: Vec> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).map(|b| b.mesh()).collect(); + if meshes.is_empty() || !depth.is_finite() || !widen.is_finite() { + return false; + } + let want = op == Operation::Join; + // Each loop counterclockwise with the side away from the material: right of the outer loop, + // left of a hole. + let loops: Vec<(solidcraft_geom::Loop2, f64)> = std::iter::once((r.outer.ccw(), 1.0)).chain(r.holes.iter().map(|h| (h.ccw(), -1.0))).collect(); + let count: usize = loops.iter().map(|(l, _)| l.segs.len()).sum(); + if count > MAX_SEGMENTS { + return false; + } + loops.iter().all(|(lp, side)| { + lp.segs.iter().all(|seg| { + [0.0, 0.25, 0.5, 0.75].iter().all(|t| { + let Some(d) = seg.derivative(*t).normalized() else { return true }; + let out = Vec2::new(d.y, -d.x) * *side; + [0.5, 1.0].iter().all(|k| { + let p = start.to_world(seg.point_at(*t) + out * (widen * k)) - dir * (depth * k); + meshes.iter().any(|m| m.contains(p)) == want + }) + }) + }) + }) +} + /// How high (mm) a tapered tool's sides stand straight where it starts on a face it can't run /// on into. const TAPER_FOOT: f64 = 0.01; @@ -2840,16 +2873,40 @@ fn feature_tools_with_constructor( // sides standing on a face are curved where they meet it (along text, say), // and the boolean finds no result there. The sides keep their slope, so // the profile stays the section at the start. + let roles = crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }; + // A start that is a whole face of the target (a foot under a part's base) is sewn on + // along it, as it lies: run on, the sides would leave the face through its edges. + if on_face + && *operation == Operation::Join + && let Ok(exact) = kernel::extrude_tapered_between(&start_plane, r, 0.0, len, 0.0, sign, taper) + && st + .bodies + .iter() + .filter(|t| targets.is_empty() || targets.iter().any(|x| *x == t.name)) + .any(|t| kernel::can_join_touching_faces(&t.body, &exact)) + { + growth = growth.max(len * taper.tan().abs()); + return Ok(roles.tool(exact, None)); + } let (lo, hi) = if sign < 0.0 { (from - len, from) } else { (from, from + len) }; let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo, hi, *operation, &crate::plain(targets)); - let (a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) }; + let (mut a, b) = if sign < 0.0 { (from - h2, from - l2) } else { (l2 - from, h2 - from) }; + // A taper that narrows the tool away from the face widens it as it runs on into it, past + // the profile (where the profile is the whole face, out through the body's sides, #1534): + // then it starts on the face instead. + if a < 0.0 && a * taper.tan() > 0.0 { + let dir = n * sign; + if !taper_run_on_fits(st, &start_plane, dir, r, -a, a * taper.tan(), *operation, &crate::plain(targets)) { + a = 0.0; + } + } // A start that can't run on (the profile hangs over the face's edge): // the sides stand straight for a hair first, so the boolean can push the // face the tool stands on into the body. let foot = if a >= 0.0 && on_face { TAPER_FOOT.min((b - a) * 0.01) } else { 0.0 }; let body = kernel::extrude_tapered_between(&start_plane, r, a, b, foot, sign, taper)?; growth = growth.max(a.abs().max(b.abs()) * taper.tan().abs()); - Ok(crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }.tool(body, None)) + Ok(roles.tool(body, None)) }) .collect::>()?; // Profiles apart by more than the tools grow (the letters of a text): the tools diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index ac076d1e..edcb2a6c 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -782,6 +782,60 @@ fn a_tapered_text_joins_a_block_on_its_face_or_hanging_over_its_edge() { } } +/// A tapered foot extruded 3 mm down from a part's bottom face and joined (#1534). Run on into +/// the part with their slope, sides that narrow the foot away from the face widened it past the +/// face: a box got a lip round its walls (0.16 mm wide, and nothing said), a cylinder's join found +/// no result, and sides that widen the foot met the face's edge there. A foot on the whole face is +/// sewn on along it; one drawn just inside the face's edge starts on it. Either way: the part plus +/// the frustum, and nothing beside the part's walls. +#[test] +fn a_tapered_foot_under_a_part_joins_as_drawn() { + // (round, the foot's half width: the whole face, or just inside its edge) + for (round, half) in [(false, 15.0), (true, 15.0), (false, 14.9)] { + for taper in [-20.0f64, 20.0] { + let t = taper.to_radians().tan(); + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane":"XY"})); + if round { + run(&mut s, "sketch.circle.center", json!({"center":[0,0],"radius":15})); + } else { + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-15,-15],"p1":[15,15]})); + } + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"B"})); + let taper_deg = format!("{taper} deg"); + if half < 15.0 { + run(&mut s, "sketch.create", json!({"plane":"XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-half,-half],"p1":[half,half]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance":3,"direction":"negative","operation":"join","taper":taper_deg})); + } else { + let foot = json!({"face":[5,1,0],"face_body":"B","distance":3,"direction":"positive","operation":"join","taper":taper_deg}); + run(&mut s, "solid.extrude", foot); + } + let (part, frustum) = if round { + let r = 15.0 + 3.0 * t; + (std::f64::consts::PI * 225.0 * 10.0, std::f64::consts::PI * (225.0 + 15.0 * r + r * r)) + } else { + let side = 2.0 * half + 6.0 * t; + (9000.0, 4.0 * half * half + 2.0 * half * side + side * side) + }; + let v = volume(&mut s); + // The cylinder is measured on its mesh; a foot that starts on the face stands straight + // for a hair first. + let tol = if round || half < 15.0 { 2e-4 } else { 1e-9 } * (part + frustum); + assert!((v - (part + frustum)).abs() < tol, "round {round}, half {half}, taper {taper}: {v} for {}", part + frustum); + assert_eq!(s.world_state().bodies.len(), 1); + let state = s.model.state(); + let mesh = state.body("B").unwrap().mesh(); + // Beside the part, above the foot's start: nothing. + let out = |x: f64, y: f64| if round { x.hypot(y) } else { x.abs().max(y.abs()) } > 15.0 + 1e-6; + let beside = mesh.positions.iter().filter(|p| p.z > 1e-6 && out(p.x, p.y)).count(); + assert_eq!(beside, 0, "round {round}, half {half}, taper {taper}: {:?}", mesh.bounds()); + } + } +} + /// A rounded part as drawn in the app: a rectangle and a quarter circle tangent to its top /// line, extruded together. The arc (about (62.17, -21), radius 21) ends square at the front /// face and in a knife-edge tip where it runs into the back face.