diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b09c2af9..6c150300 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -331,6 +331,38 @@ fn revolve_and_primitives() { assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4); } +/// A spherical shell (a half ring between two arcs, both radial lines on the axis) revolved on a +/// sketch plane that does not pass through the origin: the body is the shell, as it is on XY. +/// Before, built in place, it measured a third too large (truck tessellates the degenerate faces +/// on the axis reliably only with the axis through the origin). +#[test] +fn revolve_a_spherical_shell_off_the_origin() { + for (plane, z) in [(json!("XY"), 0.0), (json!({"origin": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}), 10.0)] { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": plane, "name": "Ring"})); + run(&mut s, "sketch.line", json!({"points": [[-40, 0], [40, 0]]})); + run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [40, 0], "end": [-40, 0]})); + run(&mut s, "sketch.arc.center_point", json!({"center": [0, 0], "start": [18.5, 0], "end": [-18.5, 0]})); + run(&mut s, "sketch.finish", json!({})); + // The half ring: the larger of the two regions above the axis. + let si = run(&mut s, "sketch.inspect", json!({"sketch": "Ring"})); + let ring = si["profiles"] + .as_array() + .unwrap() + .iter() + .max_by(|a, b| a["area_mm2"].as_f64().unwrap().total_cmp(&b["area_mm2"].as_f64().unwrap())) + .unwrap()["index"] + .clone(); + run(&mut s, "solid.revolve", json!({"sketch": "Ring", "profiles": [ring], "axis": "l1", "body_name": "Shell"})); + let m = run(&mut s, "inspect.measure", json!({"bodies": ["Shell"]})); + let want = 4.0 / 3.0 * PI * (40.0f64.powi(3) - 18.5f64.powi(3)); + let v = m["total"]["volume_mm3"].as_f64().unwrap(); + assert!(rel(v, want) < 5e-4, "plane at z {z}: {v} vs {want}"); + let b = &m["bodies"][0]["bbox"]; + assert!((b["min"][2].as_f64().unwrap() - (z - 40.0)).abs() < 1e-6 && (b["max"][2].as_f64().unwrap() - (z + 40.0)).abs() < 1e-6, "{b}"); + } +} + #[test] fn sketch_tools_and_constraints() { let mut s = Session::default(); diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index ade9ef80..8d0ca544 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -384,9 +384,25 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: out.push(b); continue; } - let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?; + // Edges on the axis (a spherical shell's two radial lines, say) sweep into degenerate faces + // there, which truck only tessellates reliably with the axis through the world origin. Off + // it, the body measured too large and booleans with it failed or removed the wrong side. + // Build such a profile there and move it into place, as `revolve_touching_axis` does. + let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9; + let axis_edges = r.outer.segs.iter().filter(|s| matches!(**s, Seg2::Line { a, b } if on_axis(a) && on_axis(b))).count(); + let shift = if axis_edges > 0 { o } else { Vec3::ZERO }; + let (profile, f) = if shift.len() > 0.0 { + let local = Plane { origin: plane.origin - shift, ..*plane }; + let profile = region_wires(&local, &given.oriented())?; + let f = attach_profile(&wires_of(&profile))?; + (profile, f) + } else { + (profile, f) + }; + let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o - shift), v3(axis), mt::Rad(angle))))?; let closed = angle.abs() >= std::f64::consts::TAU; let history = guard("revolve history", || Ok(record::swept_face(&solid, &f, &seeds(&profile), closed)))?; + let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid }; out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?); } Ok(out)