use crate::feature_pipeline::features::common;
use crate::feature_pipeline::sheet_metal::evaluate::{FlatPlacement, WedgeStrip};
use crate::feature_pipeline::sheet_metal::{self, Flat, Hole};
use crate::feature_pipeline::{FeatureContext, FeatureResult, ProfileLoop, SketchProfile};
use crate::{NurbsCurve, Vec3};
const PARALLEL_DOT: f64 = 1.0 - 1e-9;
const MIN_OBLIQUE: f64 = 0.25;
pub fn execute(ctx: &FeatureContext) -> FeatureResult {
match build(ctx) {
Ok(result) => result,
Err(error) => ctx.fail(error),
}
}
fn build(ctx: &FeatureContext) -> Result<FeatureResult, String> {
let Some(body_name) = common::sheet_body_name(ctx) else {
return Ok(FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone()));
};
let Some(handle) = ctx.scene.resolve_solid(&body_name) else {
let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
result.unresolved.push(body_name);
return Ok(result);
};
let Some(mut tree) = sheet_metal::get_tree(handle) else {
return Err("sheet-metal cutout: target is not a sheet-metal body".into());
};
let profile_name = common::normalize_profile_alias(
common::first_reference_name(ctx.param("profile"))
.ok_or("sheet-metal cutout: missing `profile` reference selection")?,
);
let keep_tool = ctx
.param("keepTool")
.and_then(|value| value.as_bool())
.unwrap_or(false);
let profile = match ctx.scene.resolve_profile(&profile_name) {
Some(profile) => profile,
None => {
if let Some(tool_handle) = ctx.scene.resolve_solid(&profile_name) {
if profile_name == body_name {
return Err(format!(
"sheet-metal cutout: the tool solid '{profile_name}' is the sheet itself"
));
}
return solid_tool_cut(ctx, tree, &body_name, &profile_name, tool_handle, keep_tool);
}
if ctx.scene.resolve_face(&profile_name).is_some() {
return Err(format!(
"sheet-metal cutout: profile '{profile_name}' is a resident solid face — \
face cutters are not supported; select a sketch profile or a solid body"
));
}
return Err(format!(
"sheet-metal cutout: profile '{profile_name}' not found (no sketch profile or \
solid body)"
));
}
};
if profile.regions.is_empty() {
return Err(format!(
"sheet-metal cutout: profile '{profile_name}' has no closed regions to cut with"
));
}
let forward = common::optional_number(ctx, "forwardDistance")?
.unwrap_or(0.0)
.max(0.0);
let back = common::optional_number(ctx, "backDistance")?
.unwrap_or(0.0)
.max(0.0);
let placements = sheet_metal::evaluate::folded_flat_placements(&tree)?;
let half_t = tree.thickness * 0.5;
let mut cuts: Vec<(String, Hole)> = Vec::new();
for (region_index, region) in profile.regions.iter().enumerate() {
let outer = region
.first()
.ok_or("sheet-metal cutout: profile region has no outer loop")?;
let sketch_poly = sample_loop_poly(outer, |p| {
let d = p.sub(profile.origin);
[d.dot(profile.x_axis), d.dot(profile.y_axis)]
})?;
let mut landed = false;
for placement in &placements {
let s = profile.z_axis.dot(placement.w);
if s.abs() >= PARALLEL_DOT {
let side = if s >= 0.0 { 1.0 } else { -1.0 };
let (mapped_outer, plane_z) = loop_to_flat(placement, outer)?;
let (z_min, z_max) = depth_span(plane_z, side, forward, back);
let flat_poly = sample_loop_poly(outer, |p| {
let d = p.sub(placement.origin);
[d.dot(placement.u), d.dot(placement.v)]
})?;
for strip in &placement.wedge_strips {
let window = half_t + 2.0 * strip.reach;
if span_overlaps(z_min, z_max, -window, window)
&& polygon_overlaps_strip(&flat_poly, strip)
{
return Err(format!(
"sheet-metal cutout: profile region {region_index} crosses bend \
'{}' at flat '{}' — cutting across a bend arc is not supported; \
keep each cut inside one flat",
strip.bend_id, placement.id
));
}
}
if !span_overlaps(z_min, z_max, -half_t, half_t) {
continue; }
if !polygons_overlap(&flat_poly, &placement.outline) {
continue; }
let islands = region[1..]
.iter()
.map(|island| loop_to_flat(placement, island).map(|(curves, _)| curves))
.collect::<Result<Vec<_>, _>>()?;
cuts.push((
placement.id.clone(),
Hole {
outer: mapped_outer,
islands,
z_min,
z_max,
},
));
landed = true;
} else if oblique_tool_reaches_slab(
profile, &sketch_poly, forward, back, placement, half_t,
) {
if s.abs() < MIN_OBLIQUE {
return Err(format!(
"sheet-metal cutout: profile region {region_index}'s oblique cut grazes \
flat '{}' at {:.1}° from perpendicular — a near-tangent oblique cut is \
unmanufacturable (its footprint spreads past several sheet thicknesses); \
sketch the cut closer to perpendicular to the flat",
placement.id,
s.abs().clamp(0.0, 1.0).acos().to_degrees()
));
}
let hole = oblique_hole(
profile, region, placement, half_t, forward, back, region_index,
)?;
let union_poly = sample_flat_curves(&hole.outer)?;
for strip in &placement.wedge_strips {
if polygon_overlaps_strip(&union_poly, strip) {
return Err(format!(
"sheet-metal cutout: profile region {region_index} crosses bend \
'{}' at flat '{}' — cutting across a bend arc is not supported; \
keep each cut inside one flat",
strip.bend_id, placement.id
));
}
}
if !polygons_overlap(&union_poly, &placement.outline) {
continue; }
cuts.push((placement.id.clone(), hole));
landed = true;
}
}
if !landed {
return Err(format!(
"sheet-metal cutout: profile region {region_index} does not land on any flat \
of '{body_name}' — the loop must overlap a flat and the cut span must reach \
its material (depth 0 = through-all)"
));
}
}
bake_holes(&mut tree, cuts)?;
let added = sheet_metal::register_folded(tree.clone(), &body_name)?;
let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
result.added.push(added);
result.removed.push(body_name);
common::consume_sketch_always(&profile_name, &mut result);
if keep_tool {
let cutters = build_region_cutters(ctx, profile, forward, back, &placements, half_t, &tree)?;
for (name, cutter) in cutters {
result.added.push(common::register_added(cutter, &name));
}
}
Ok(result)
}
fn bake_holes(tree: &mut sheet_metal::SheetTree, cuts: Vec<(String, Hole)>) -> Result<(), String> {
for (flat_id, hole) in cuts {
tree.root.find_by_id_mut(&flat_id)
.ok_or_else(|| format!("sheet-metal cutout: flat `{flat_id}` vanished from the tree"))?
.holes
.push(hole);
}
Ok(())
}
fn depth_span(plane_z: f64, side: f64, forward: f64, back: f64) -> (Option<f64>, Option<f64>) {
let fwd = (forward > 0.0).then(|| plane_z + side * forward);
let bck = (back > 0.0).then(|| plane_z - side * back);
if side >= 0.0 {
(bck, fwd) } else {
(fwd, bck)
}
}
fn span_overlaps(z_min: Option<f64>, z_max: Option<f64>, lo: f64, hi: f64) -> bool {
let eps = 1e-9 * (hi - lo).abs().max(1.0);
let a = z_min.unwrap_or(f64::NEG_INFINITY);
let b = z_max.unwrap_or(f64::INFINITY);
a < hi - eps && b > lo + eps && b > a + eps
}
fn loop_to_flat(
placement: &FlatPlacement,
profile_loop: &ProfileLoop,
) -> Result<(Vec<NurbsCurve>, f64), String> {
let mut curves = Vec::with_capacity(profile_loop.curves.len());
let mut z_lo = f64::INFINITY;
let mut z_hi = f64::NEG_INFINITY;
for curve in &profile_loop.curves {
let mut control_points = Vec::with_capacity(curve.control_points.len());
for cp in &curve.control_points {
let delta = cp.point()?.sub(placement.origin);
let z = delta.dot(placement.w);
z_lo = z_lo.min(z);
z_hi = z_hi.max(z);
control_points.push(crate::Vec4::from_point(
Vec3::new(delta.dot(placement.u), delta.dot(placement.v), 0.0),
cp.w,
));
}
curves.push(NurbsCurve::new(
curve.degree,
curve.knots.clone(),
control_points,
)?);
}
if !(z_hi - z_lo <= 1e-6 * z_hi.abs().max(z_lo.abs()).max(1.0)) {
return Err(format!(
"sheet-metal cutout: profile loop is not planar against flat '{}' (z spread {})",
placement.id,
z_hi - z_lo
));
}
Ok((curves, (z_lo + z_hi) * 0.5))
}
fn sample_loop_poly(
profile_loop: &ProfileLoop,
map: impl Fn(Vec3) -> [f64; 2],
) -> Result<Vec<[f64; 2]>, String> {
let mut poly = Vec::new();
for curve in &profile_loop.curves {
let [t0, t1] = curve.domain()?;
for step in 0..16 {
let t = t0 + (t1 - t0) * (step as f64) / 16.0;
poly.push(map(curve.evaluate(t)?));
}
}
if poly.len() < 3 {
return Err("sheet-metal cutout: degenerate profile loop".into());
}
Ok(poly)
}
fn oblique_tool_reaches_slab(
profile: &SketchProfile,
sketch_poly: &[[f64; 2]],
forward: f64,
back: f64,
placement: &FlatPlacement,
half_t: f64,
) -> bool {
let n = placement.outline.len();
if n < 3 {
return false;
}
let corner = |i: usize, z: f64| {
let p = placement.outline[i % n];
placement
.origin
.add(placement.u.scale(p[0]))
.add(placement.v.scale(p[1]))
.add(placement.w.scale(z))
};
let to_sketch = |p: Vec3| {
let d = p.sub(profile.origin);
([d.dot(profile.x_axis), d.dot(profile.y_axis)], d.dot(profile.z_axis))
};
let mut s_lo = f64::INFINITY;
let mut s_hi = f64::NEG_INFINITY;
for i in 0..n {
for z in [-half_t, half_t] {
let (_, sz) = to_sketch(corner(i, z));
s_lo = s_lo.min(sz);
s_hi = s_hi.max(sz);
}
}
let z_min = (back > 0.0).then_some(-back);
let z_max = (forward > 0.0).then_some(forward);
if !span_overlaps(z_min, z_max, s_lo, s_hi) {
return false;
}
let bottom: Vec<[f64; 2]> = (0..n).map(|i| to_sketch(corner(i, -half_t)).0).collect();
let top: Vec<[f64; 2]> = (0..n).map(|i| to_sketch(corner(i, half_t)).0).collect();
if polygons_overlap(sketch_poly, &bottom) || polygons_overlap(sketch_poly, &top) {
return true;
}
for i in 0..n {
let quad = vec![
to_sketch(corner(i, -half_t)).0,
to_sketch(corner(i + 1, -half_t)).0,
to_sketch(corner(i + 1, half_t)).0,
to_sketch(corner(i, half_t)).0,
];
if polygons_overlap(sketch_poly, &quad) {
return true;
}
}
false
}
fn oblique_hole(
profile: &SketchProfile,
region: &[ProfileLoop],
placement: &FlatPlacement,
half_t: f64,
forward: f64,
back: f64,
region_index: usize,
) -> Result<Hole, String> {
let sw = profile.z_axis.dot(placement.w); let outer = region
.first()
.ok_or("sheet-metal cutout: profile region has no outer loop")?;
let s_min = if back > 0.0 { -back } else { f64::NEG_INFINITY };
let s_max = if forward > 0.0 { forward } else { f64::INFINITY };
let eps = 1e-9 * half_t.max(1.0);
for curve in &outer.curves {
let [t0, t1] = curve.domain()?;
for step in 0..16 {
let t = t0 + (t1 - t0) * (step as f64) / 16.0;
let p = curve.evaluate(t)?;
let d_w = p.sub(placement.origin).dot(placement.w);
let s_top = (half_t - d_w) / sw;
let s_bot = (-half_t - d_w) / sw;
let hits_through = s_top >= s_min - eps
&& s_top <= s_max + eps
&& s_bot >= s_min - eps
&& s_bot <= s_max + eps;
if !hits_through {
return Err(format!(
"sheet-metal cutout: profile region {region_index} on flat '{}': oblique \
cutout must pass through the sheet (a partial-depth oblique cut is not \
supported)",
placement.id
));
}
}
}
let top_outer = project_loop_to_face(profile, outer, placement, half_t)?;
let bot_outer = project_loop_to_face(profile, outer, placement, -half_t)?;
let top_islands = region[1..]
.iter()
.map(|island| project_loop_to_face(profile, island, placement, half_t))
.collect::<Result<Vec<_>, _>>()?;
let bot_islands = region[1..]
.iter()
.map(|island| project_loop_to_face(profile, island, placement, -half_t))
.collect::<Result<Vec<_>, _>>()?;
let top_poly = sample_flat_curves(&top_outer)?;
let bot_poly = sample_flat_curves(&bot_outer)?;
if !polygons_overlap(&top_poly, &bot_poly) {
return Err(format!(
"sheet-metal cutout: profile region {region_index} on flat '{}': cut angle too \
steep for sheet thickness — the tool cannot pass through",
placement.id
));
}
let mut lo = [f64::INFINITY; 2];
let mut hi = [f64::NEG_INFINITY; 2];
for p in top_poly.iter().chain(bot_poly.iter()) {
lo[0] = lo[0].min(p[0]);
lo[1] = lo[1].min(p[1]);
hi[0] = hi[0].max(p[0]);
hi[1] = hi[1].max(p[1]);
}
let height = (hi[0] - lo[0]).hypot(hi[1] - lo[1]).max(1e-3);
let region_a = footprint_prism(&top_outer, &top_islands, height)?;
let region_b = footprint_prism(&bot_outer, &bot_islands, height)?;
let union = common::union_region_solids(vec![region_a, region_b])
.map_err(|e| format!("sheet-metal cutout: oblique footprint union failed: {e}"))?;
if union.shells.len() != 1 {
return Err(format!(
"sheet-metal cutout: profile region {region_index} on flat '{}': cut angle too \
steep for sheet thickness — the tool cannot pass through",
placement.id
));
}
let (outer_curves, islands) = cross_section_loops(&union, height)?;
Ok(Hole {
outer: outer_curves,
islands,
z_min: Some(-half_t),
z_max: Some(half_t),
})
}
fn project_loop_to_face(
profile: &SketchProfile,
profile_loop: &ProfileLoop,
placement: &FlatPlacement,
offset: f64,
) -> Result<Vec<NurbsCurve>, String> {
let sw = profile.z_axis.dot(placement.w); let mut curves = Vec::with_capacity(profile_loop.curves.len());
for curve in &profile_loop.curves {
let mut control_points = Vec::with_capacity(curve.control_points.len());
for cp in &curve.control_points {
let p = cp.point()?;
let s = (offset - p.sub(placement.origin).dot(placement.w)) / sw;
let hit = p.add(profile.z_axis.scale(s));
let d = hit.sub(placement.origin);
control_points.push(crate::Vec4::from_point(
Vec3::new(d.dot(placement.u), d.dot(placement.v), 0.0),
cp.w,
));
}
curves.push(NurbsCurve::new(
curve.degree,
curve.knots.clone(),
control_points,
)?);
}
Ok(curves)
}
fn footprint_prism(
outer: &[NurbsCurve],
islands: &[Vec<NurbsCurve>],
height: f64,
) -> Result<crate::BrepSolid, String> {
let mut solid = extrude_footprint(outer, 0.0, height)?;
for island in islands {
if island.is_empty() {
continue;
}
let island_solid = extrude_footprint(island, -0.5 * height, 2.0 * height)?;
solid = common::subtract_solid(solid, island_solid)?;
}
Ok(solid)
}
fn extrude_footprint(
curves: &[NurbsCurve],
z_start: f64,
z_len: f64,
) -> Result<crate::BrepSolid, String> {
let drop = Vec3::new(0.0, 0.0, z_start);
let mut profile = curves
.iter()
.map(|curve| common::translate_curve(curve, drop))
.collect::<Result<Vec<_>, _>>()?;
if profile.len() == 1 {
let single = profile.pop().expect("one curve");
let domain = single.domain()?;
let (first, second) = single.split((domain[0] + domain[1]) * 0.5)?;
profile.push(first);
profile.push(second);
}
crate::extrude_profile_brep(&profile, Vec3::new(0.0, 0.0, 1.0), z_len).map_err(String::from)
}
fn cross_section_loops(
solid: &crate::BrepSolid,
z_top: f64,
) -> Result<(Vec<NurbsCurve>, Vec<Vec<NurbsCurve>>), String> {
let mut regions = cap_regions(solid, z_top)?;
if regions.len() != 1 {
return Err(format!(
"sheet-metal cutout: oblique footprint union produced {} top-cap faces (expected \
1) — the boolean did not merge the union cross-section",
regions.len()
));
}
Ok(regions.pop().expect("one cap region"))
}
fn cap_regions(
solid: &crate::BrepSolid,
z_cap: f64,
) -> Result<Vec<(Vec<NurbsCurve>, Vec<Vec<NurbsCurve>>)>, String> {
let tol = 1e-6 * z_cap.abs().max(1.0);
let edge_by_id: std::collections::HashMap<u64, &crate::topology::EdgeRecord> =
solid.edges.iter().map(|edge| (edge.id, edge)).collect();
let mut cap_faces = Vec::new();
for shell in &solid.shells {
for face in &shell.faces {
if face.loops.is_empty() {
continue;
}
let mut all_in_plane = true;
'face: for loop_record in &face.loops {
for coedge in &loop_record.coedges {
let Some(edge) = edge_by_id.get(&coedge.edge_id) else {
all_in_plane = false;
break 'face;
};
for cp in &edge.curve.control_points {
if (cp.point()?.z - z_cap).abs() > tol {
all_in_plane = false;
break 'face;
}
}
}
}
if all_in_plane {
cap_faces.push(face);
}
}
}
let mut regions = Vec::with_capacity(cap_faces.len());
for face in cap_faces {
let mut loops: Vec<Vec<NurbsCurve>> = Vec::new();
for loop_record in &face.loops {
let bag = loop_record
.coedges
.iter()
.map(|coedge| {
let edge = edge_by_id
.get(&coedge.edge_id)
.ok_or("sheet-metal cutout: cross-section coedge lost its edge")?;
project_curve_uv(&subcurve(&edge.curve, edge.t0, edge.t1)?)
})
.collect::<Result<Vec<_>, _>>()?;
loops.push(chain_loop(bag)?);
}
let mut best = 0usize;
let mut best_area = -1.0;
for (index, chain) in loops.iter().enumerate() {
let area = loop_area(chain)?.abs();
if area > best_area {
best_area = area;
best = index;
}
}
let outer = loops.remove(best);
regions.push((outer, loops));
}
Ok(regions)
}
fn subcurve(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<NurbsCurve, String> {
let [d0, d1] = curve.domain()?;
let tol = 1e-9 * (d1 - d0).abs().max(1.0);
let (a, b) = if t0 <= t1 { (t0, t1) } else { (t1, t0) };
let a = a.max(d0);
let b = b.min(d1);
let right = if a > d0 + tol { curve.split(a)?.1 } else { curve.clone() };
let [r0, r1] = right.domain()?;
if b > r0 + tol && b < r1 - tol {
Ok(right.split(b)?.0)
} else {
Ok(right)
}
}
fn project_curve_uv(curve: &NurbsCurve) -> Result<NurbsCurve, String> {
let mut control_points = Vec::with_capacity(curve.control_points.len());
for cp in &curve.control_points {
let p = cp.point()?;
control_points.push(crate::Vec4::from_point(Vec3::new(p.x, p.y, 0.0), cp.w));
}
NurbsCurve::new(curve.degree, curve.knots.clone(), control_points)
}
fn chain_loop(mut bag: Vec<NurbsCurve>) -> Result<Vec<NurbsCurve>, String> {
if bag.is_empty() {
return Err("sheet-metal cutout: empty oblique cross-section loop".into());
}
let endpoints = |curve: &NurbsCurve| -> Result<([f64; 2], [f64; 2]), String> {
let [t0, t1] = curve.domain()?;
let a = curve.evaluate(t0)?;
let b = curve.evaluate(t1)?;
Ok(([a.x, a.y], [b.x, b.y]))
};
let close = |a: [f64; 2], b: [f64; 2]| {
let scale = a[0].abs().max(a[1].abs()).max(b[0].abs()).max(b[1].abs()).max(1.0);
(a[0] - b[0]).hypot(a[1] - b[1]) <= 1e-6 * scale
};
let mut chain = vec![bag.remove(0)];
let (_, mut end) = endpoints(&chain[0])?;
while !bag.is_empty() {
let mut pick = None;
for (index, curve) in bag.iter().enumerate() {
let (a, b) = endpoints(curve)?;
if close(a, end) {
pick = Some((index, false, b));
break;
}
if close(b, end) {
pick = Some((index, true, a));
break;
}
}
let (index, reversed, new_end) =
pick.ok_or("sheet-metal cutout: oblique cross-section loop has a gap")?;
let curve = bag.remove(index);
chain.push(if reversed { curve.reversed()? } else { curve });
end = new_end;
}
Ok(chain)
}
fn loop_area(curves: &[NurbsCurve]) -> Result<f64, String> {
let poly = sample_flat_curves(curves)?;
Ok(crate::polygon::signed_area_2d(poly.len(), |i| poly[i]))
}
fn sample_flat_curves(curves: &[NurbsCurve]) -> Result<Vec<[f64; 2]>, String> {
let mut poly = Vec::new();
for curve in curves {
let [t0, t1] = curve.domain()?;
for step in 0..16 {
let t = t0 + (t1 - t0) * (step as f64) / 16.0;
let p = curve.evaluate(t)?;
poly.push([p.x, p.y]);
}
}
if poly.len() < 3 {
return Err("sheet-metal cutout: degenerate oblique footprint loop".into());
}
Ok(poly)
}
fn polygon_overlaps_strip(poly: &[[f64; 2]], strip: &WedgeStrip) -> bool {
let dx = strip.b[0] - strip.a[0];
let dy = strip.b[1] - strip.a[1];
let len = (dx * dx + dy * dy).sqrt();
if len <= 1e-12 || strip.reach <= 0.0 {
return false;
}
let out = [dy / len, -dx / len]; let inset = 1e-7 * strip.reach.max(1.0);
let rect = vec![
[strip.a[0] + out[0] * inset, strip.a[1] + out[1] * inset],
[strip.b[0] + out[0] * inset, strip.b[1] + out[1] * inset],
[strip.b[0] + out[0] * strip.reach, strip.b[1] + out[1] * strip.reach],
[strip.a[0] + out[0] * strip.reach, strip.a[1] + out[1] * strip.reach],
];
polygons_overlap(poly, &rect)
}
fn point_in_polygon(p: [f64; 2], poly: &[[f64; 2]]) -> bool {
crate::polygon::parity_inside(p, poly.len(), |i| poly[i])
}
fn orient(a: [f64; 2], b: [f64; 2], c: [f64; 2]) -> f64 {
(b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
}
fn segments_intersect(p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], p4: [f64; 2]) -> bool {
let len1 = ((p2[0] - p1[0]).powi(2) + (p2[1] - p1[1]).powi(2)).sqrt();
let len2 = ((p4[0] - p3[0]).powi(2) + (p4[1] - p3[1]).powi(2)).sqrt();
let eps = 1e-12 * (len1 * len2).max(1e-12);
let d1 = orient(p3, p4, p1);
let d2 = orient(p3, p4, p2);
let d3 = orient(p1, p2, p3);
let d4 = orient(p1, p2, p4);
if ((d1 > eps && d2 < -eps) || (d1 < -eps && d2 > eps))
&& ((d3 > eps && d4 < -eps) || (d3 < -eps && d4 > eps))
{
return true;
}
let tol = 1e-9 * len1.max(len2).max(1.0);
let on_seg = |a: [f64; 2], b: [f64; 2], p: [f64; 2]| {
p[0] >= a[0].min(b[0]) - tol
&& p[0] <= a[0].max(b[0]) + tol
&& p[1] >= a[1].min(b[1]) - tol
&& p[1] <= a[1].max(b[1]) + tol
};
(d1.abs() <= eps && on_seg(p3, p4, p1))
|| (d2.abs() <= eps && on_seg(p3, p4, p2))
|| (d3.abs() <= eps && on_seg(p1, p2, p3))
|| (d4.abs() <= eps && on_seg(p1, p2, p4))
}
fn polygons_overlap(a: &[[f64; 2]], b: &[[f64; 2]]) -> bool {
if a.len() < 3 || b.len() < 3 {
return false;
}
if a.iter().any(|p| point_in_polygon(*p, b)) || b.iter().any(|p| point_in_polygon(*p, a)) {
return true;
}
for i in 0..a.len() {
let a1 = a[i];
let a2 = a[(i + 1) % a.len()];
for j in 0..b.len() {
if segments_intersect(a1, a2, b[j], b[(j + 1) % b.len()]) {
return true;
}
}
}
false
}
fn solid_tool_cut(
ctx: &FeatureContext,
mut tree: sheet_metal::SheetTree,
body_name: &str,
tool_name: &str,
tool_handle: u32,
keep_tool: bool,
) -> Result<FeatureResult, String> {
let tool = crate::registered_solid_clone(tool_handle)?;
let placements = sheet_metal::evaluate::folded_flat_placements(&tree)?;
let half_t = tree.thickness * 0.5;
let mut cuts: Vec<(String, Hole)> = Vec::new();
for placement in &placements {
for hole in solid_tool_holes(&tool, tool_name, placement, half_t)? {
cuts.push((placement.id.clone(), hole));
}
}
if cuts.is_empty() {
return Err(format!(
"sheet-metal cutout: tool solid '{tool_name}' does not cut any flat of \
'{body_name}' — it must pass through the sheet"
));
}
bake_holes(&mut tree, cuts)?;
let added = sheet_metal::register_folded(tree, body_name)?;
let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
result.added.push(added);
result.removed.push(body_name.to_string());
if !keep_tool {
result.removed.push(tool_name.to_string());
}
Ok(result)
}
fn solid_tool_holes(
tool: &crate::BrepSolid,
tool_name: &str,
placement: &FlatPlacement,
half_t: f64,
) -> Result<Vec<Hole>, String> {
let local = solid_to_flat_frame(tool, placement)?;
let Some((lo, hi)) = control_bounds(&local)? else {
return Ok(Vec::new());
};
let eps = 1e-9 * half_t.max(1.0);
if lo.z >= half_t - eps || hi.z <= -half_t + eps {
return Ok(Vec::new());
}
let reach = placement
.wedge_strips
.iter()
.map(|strip| strip.reach)
.fold(0.0, f64::max);
let mut out_lo = [f64::INFINITY; 2];
let mut out_hi = [f64::NEG_INFINITY; 2];
for p in &placement.outline {
for axis in 0..2 {
out_lo[axis] = out_lo[axis].min(p[axis] - reach);
out_hi[axis] = out_hi[axis].max(p[axis] + reach);
}
}
if hi.x < out_lo[0] || lo.x > out_hi[0] || hi.y < out_lo[1] || lo.y > out_hi[1] {
return Ok(Vec::new());
}
let margin = half_t.max(0.05 * (hi.x - lo.x).hypot(hi.y - lo.y));
let corners = [
Vec3::new(lo.x - margin, lo.y - margin, 0.0),
Vec3::new(hi.x + margin, lo.y - margin, 0.0),
Vec3::new(hi.x + margin, hi.y + margin, 0.0),
Vec3::new(lo.x - margin, hi.y + margin, 0.0),
];
let rectangle = (0..4)
.map(|i| line_curve(corners[i], corners[(i + 1) % 4]))
.collect::<Result<Vec<_>, _>>()?;
let slab = extrude_footprint(&rectangle, -half_t, 2.0 * half_t)?;
let section = common::intersect_solid(local, slab).map_err(|error| {
format!(
"sheet-metal cutout: sectioning tool solid '{tool_name}' at flat '{}' failed: {error}",
placement.id
)
})?;
if section.shells.iter().all(|shell| shell.faces.is_empty()) {
return Ok(Vec::new()); }
let top = cap_regions(§ion, half_t)?;
let bottom = cap_regions(§ion, -half_t)?;
let blind = || {
format!(
"sheet-metal cutout: tool solid '{tool_name}' does not pass through flat '{}' — \
a solid tool must cross both faces of the sheet (a partial-depth cut is not \
supported)",
placement.id
)
};
if top.is_empty() && bottom.is_empty() {
let inside = control_bounds(§ion)?.is_some_and(|(s_lo, s_hi)| {
let hull = vec![
[s_lo.x, s_lo.y],
[s_hi.x, s_lo.y],
[s_hi.x, s_hi.y],
[s_lo.x, s_hi.y],
];
polygons_overlap(&hull, &placement.outline)
});
return if inside { Err(blind()) } else { Ok(Vec::new()) };
}
let top_polys = top
.iter()
.map(|(outer, _)| sample_flat_curves(outer))
.collect::<Result<Vec<_>, _>>()?;
let bottom_polys = bottom
.iter()
.map(|(outer, _)| sample_flat_curves(outer))
.collect::<Result<Vec<_>, _>>()?;
let mut f_lo = [f64::INFINITY; 2];
let mut f_hi = [f64::NEG_INFINITY; 2];
for p in top_polys.iter().chain(bottom_polys.iter()).flatten() {
for axis in 0..2 {
f_lo[axis] = f_lo[axis].min(p[axis]);
f_hi[axis] = f_hi[axis].max(p[axis]);
}
}
let height = (f_hi[0] - f_lo[0]).hypot(f_hi[1] - f_lo[1]).max(1e-3);
let prisms = top
.iter()
.chain(bottom.iter())
.map(|(outer, islands)| footprint_prism(outer, islands, height))
.collect::<Result<Vec<_>, _>>()?;
let union = common::union_region_solids(prisms)
.map_err(|e| format!("sheet-metal cutout: solid-tool section union failed: {e}"))?;
let mut holes = Vec::new();
for (outer, islands) in cap_regions(&union, height)? {
let poly = sample_flat_curves(&outer)?;
let on_flat = polygons_overlap(&poly, &placement.outline);
for strip in &placement.wedge_strips {
if polygon_overlaps_strip(&poly, strip) {
return Err(format!(
"sheet-metal cutout: tool solid '{tool_name}' crosses bend '{}' at flat \
'{}' — cutting across a bend arc is not supported; keep each cut inside \
one flat",
strip.bend_id, placement.id
));
}
}
if !on_flat {
continue; }
let pierces = |polys: &[Vec<[f64; 2]>]| polys.iter().any(|p| polygons_overlap(p, &poly));
if !pierces(&top_polys) || !pierces(&bottom_polys) {
return Err(blind());
}
holes.push(Hole {
outer,
islands,
z_min: Some(-half_t),
z_max: Some(half_t),
});
}
Ok(holes)
}
fn solid_to_flat_frame(
solid: &crate::BrepSolid,
placement: &FlatPlacement,
) -> Result<crate::BrepSolid, String> {
let (o, u, v, w) = (placement.origin, placement.u, placement.v, placement.w);
let transform = crate::AffineTransform::new([
u.x, u.y, u.z, -u.dot(o),
v.x, v.y, v.z, -v.dot(o),
w.x, w.y, w.z, -w.dot(o),
0.0, 0.0, 0.0, 1.0,
])?;
let reflect = transform.determinant3() < 0.0;
crate::transform_brep(solid, transform, reflect)
}
fn control_bounds(solid: &crate::BrepSolid) -> Result<Option<(Vec3, Vec3)>, String> {
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
let mut hi = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
let mut grow = |p: Vec3| {
lo = Vec3::new(lo.x.min(p.x), lo.y.min(p.y), lo.z.min(p.z));
hi = Vec3::new(hi.x.max(p.x), hi.y.max(p.y), hi.z.max(p.z));
};
for vertex in &solid.vertices {
grow(vertex.point);
}
for face in solid.shells.iter().flat_map(|shell| shell.faces.iter()) {
for cp in face.surface.control_points.iter().flatten() {
grow(cp.point()?);
}
}
Ok((lo.x <= hi.x).then_some((lo, hi)))
}
fn line_curve(a: Vec3, b: Vec3) -> Result<NurbsCurve, String> {
NurbsCurve::new(
1,
vec![0.0, 0.0, 1.0, 1.0],
vec![crate::Vec4::from_point(a, 1.0), crate::Vec4::from_point(b, 1.0)],
)
}
fn build_region_cutters(
ctx: &FeatureContext,
profile: &SketchProfile,
forward: f64,
back: f64,
placements: &[FlatPlacement],
half_t: f64,
tree: &sheet_metal::SheetTree,
) -> Result<Vec<(String, crate::BrepSolid)>, String> {
let mut s_lo = f64::INFINITY;
let mut s_hi = f64::NEG_INFINITY;
for placement in placements {
for p in &placement.outline {
for z in [-half_t, half_t] {
let world = placement
.origin
.add(placement.u.scale(p[0]))
.add(placement.v.scale(p[1]))
.add(placement.w.scale(z));
let sz = world.sub(profile.origin).dot(profile.z_axis);
s_lo = s_lo.min(sz);
s_hi = s_hi.max(sz);
}
}
}
let margin = tree.thickness;
let bottom = if back > 0.0 { -back } else { s_lo - margin };
let top = if forward > 0.0 { forward } else { s_hi + margin };
if !(top > bottom + 1e-12) {
return Ok(Vec::new());
}
let mut cutters = Vec::new();
for (region_index, region) in profile.regions.iter().enumerate() {
let outer = region
.first()
.ok_or("sheet-metal cutout: profile region has no outer loop")?;
let name = if profile.regions.len() == 1 {
format!("{}:CUTTER", ctx.id)
} else {
format!("{}:CUTTER:{}", ctx.id, common::hole_key(outer, region_index))
};
let mut cutter = extrude_world_loop(&outer.curves, profile.z_axis, bottom, top, &name)?;
for island in ®ion[1..] {
let island_prism = extrude_world_loop(
&island.curves,
profile.z_axis,
bottom - tree.thickness * 0.5,
top + tree.thickness * 0.5,
&name,
)?;
cutter = common::subtract_solid(cutter, island_prism)?;
}
cutters.push((name, cutter));
}
Ok(cutters)
}
fn extrude_world_loop(
curves: &[NurbsCurve],
direction: Vec3,
bottom: f64,
top: f64,
name: &str,
) -> Result<crate::BrepSolid, String> {
let offset = direction.scale(bottom);
let profile = curves
.iter()
.map(|curve| common::translate_curve(curve, offset))
.collect::<Result<Vec<_>, _>>()?;
let mut solid = crate::extrude_profile_brep(&profile, direction, top - bottom)?;
for face in solid
.shells
.iter_mut()
.flat_map(|shell| shell.faces.iter_mut())
{
if face.name.is_none() {
face.name = Some(name.to_string());
}
}
Ok(solid)
}
pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
probe.solids > 0 || probe.has_profile()
}
pub fn schema() -> serde_json::Value {
serde_json::json!({
"type": "SM.CUTOUT",
"shortName": "SM.CUTOUT",
"longName": "SM Cutout",
"ribbonPath": "Home/Sheet Metal/Cutout",
"commandSize": "Compact",
"displayBuilder": false,
"inputParamsSchema": {
"id": {
"type": "string",
"default_value": null,
"hint": "Unique identifier for the sheet metal cutout"
},
"sheet": {
"type": "reference_selection",
"selectionFilter": [
"SOLID"
],
"multiple": false,
"default_value": null,
"hint": "Target sheet metal solid to cut."
},
"profile": {
"type": "reference_selection",
"selectionFilter": [
"FACE",
"SKETCH",
"SOLID"
],
"multiple": false,
"default_value": null,
"hint": "Sketch profile or solid body to cut with. A sketch is always consumed; each sketch region cuts and inner loops are islands that keep their material. A solid cuts a perpendicular-walled hole through each flat it crosses, sized to its outline on both faces, and is consumed unless Keep Tool is on."
},
"forwardDistance": {
"type": "number",
"default_value": 0,
"min": 0,
"hint": "Blind cut depth forward of the sketch plane (along the sketch normal). 0 = cut through everything on that side. Ignored for a solid tool."
},
"backDistance": {
"type": "number",
"default_value": 0,
"min": 0,
"hint": "Blind cut depth behind the sketch plane (against the sketch normal). 0 = cut through everything on that side. Ignored for a solid tool."
},
"keepTool": {
"type": "boolean",
"default_value": false,
"hint": "Also keep the generated cutting tool solid(s) in the scene ({id}:CUTTER). With a solid tool, keep that solid instead of consuming it."
}
}
})
}