//! Evaluation of Emboss, Rib/Web, Replace Face, Align and Remove (features built from the //! kernel's extrude, boolean, offset and transform operations). use super::*; use crate::EmbossFace; use crate::apply_point; use crate::sheet; use solidcraft_geom::{Loop2, Seg2}; pub(super) fn eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { match &f.kind { FeatureKind::Emboss { sketch, profiles, depth, deboss, targets, face } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?; let regions = select_profiles(ss, profiles)?; let d = val(vals, depth, Kind::Length)?; if !(d > 0.0) { return Err(DocError::Invalid("the emboss depth must be positive".into())); } if let Some(face) = face { if !targets.is_empty() && (targets.len() != 1 || targets.first() != Some(&face.body)) { return Err(DocError::Invalid("cylinder emboss must keep its selected target body".into())); } let i = st.bodies.iter().position(|b| b.name == face.body).ok_or_else(|| DocError::Unknown(format!("emboss body `{}`", face.body)))?; let mb = st.bodies.get(i).cloned().ok_or_else(|| DocError::Invalid("emboss body".into()))?; let body_frame = face.body_frame(doc, &mb)?; let inverse = crate::mat_inverse(&body_frame).ok_or_else(|| DocError::Invalid("invalid cylinder body frame".into()))?; let sketch_owner = doc.feature(ss.feature).map(|f| f.component).unwrap_or(0); let author_frame = EmbossFace::component_frame(doc, f.component, &face.author_path)?; let sketch_frame = face.sketch_frame(doc, f.component, sketch_owner, &mb)?; let sketch_transform = crate::mat_mul(&inverse, &sketch_frame); let plane = Plane::new( crate::apply_point(&sketch_transform, ss.plane.origin), crate::apply_vector(&sketch_transform, ss.plane.x), crate::apply_vector(&sketch_transform, ss.plane.y), ) .ok_or_else(|| DocError::Invalid("invalid cylinder sketch frame".into()))?; let mut at = crate::apply_point(&crate::mat_mul(&inverse, &author_frame), face.at); let name = f .face_names .first() .filter(|name| !name.is_empty()) .ok_or_else(|| DocError::Invalid("selected cylinder face has no persistent identity".into()))?; { let mut scoped = ModelState::default(); scoped.bodies.push(mb.clone()); let (picked, split) = crate::naming::point_on_face(&scoped, name, at) .ok_or_else(|| DocError::Invalid("selected cylinder face no longer exists".into()))?; if split { return Err(DocError::Invalid("selected cylinder face was split".into())); } let (index, _, _) = crate::project::nearest_face(&mb.mesh(), picked) .ok_or_else(|| DocError::Invalid("selected cylinder face cannot be resolved".into()))?; if !crate::naming::face_has_name(&mb, index as usize, name) { return Err(DocError::Invalid("selected cylinder face is ambiguous".into())); } at = kernel::cylinder_emboss_anchor(&mb.body, index as usize, picked)?; } let result = kernel::emboss_cylinder_at(&mb.body, at, &plane, ®ions, if *deboss { -d } else { d })?; if let Some(slot) = st.bodies.get_mut(i) { *slot = ModelBody::new(mb.name, result, mb.feature); } return Ok(()); } let (a, b) = if *deboss { (-d, 0.0) } else { (0.0, d) }; let tools = kernel::extrude(&ss.plane, ®ions, a, b)?; if st.bodies.is_empty() { return Err(DocError::Invalid("there is no body to emboss".into())); } apply_op(st, f, tools, if *deboss { Operation::Cut } else { Operation::Join }, targets) } FeatureKind::Rib { sketch, curves, thickness, depth, flip, web } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone(); let t = val(vals, thickness, Kind::Length)?; if !(t > 0.0) { return Err(DocError::Invalid("the rib thickness must be positive".into())); } let depth = match depth { Some(e) => Some(val(vals, e, Kind::Length)?) .filter(|d| *d > 0.0) .ok_or_else(|| DocError::Invalid("the rib depth must be positive".into())) .map(Some)?, None => None, }; let groups: Vec> = if *web { curves.iter().map(|c| vec![c.clone()]).collect() } else { vec![curves.clone()] }; if groups.is_empty() || curves.is_empty() || curves.len() > 1000 { return Err(DocError::Invalid("a rib needs 1…1000 sketch curves".into())); } let mut tools = Vec::new(); for g in &groups { tools.push(rib_tool(st, &ss, g, t, depth, *flip)?); } apply_op(st, f, tools, Operation::Join, &[]) } FeatureKind::ReplaceFace { faces, target, body } => { let tp = doc.resolve_plane_in(vals, target, 0, Some(st))?; let tn = tp.normal(); let i = body_at(st, body, faces)?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) }; let mut b = mb.body.clone(); for p in faces { let n = planar_normal_at(&b, *p).ok_or_else(|| DocError::Invalid("not supported yet: replacing a face that is not planar".into()))?; if n.dot(tn).abs() < 1.0 - 1e-9 { return Err(DocError::Invalid("not supported yet: a target that is not parallel to the face".into())); } let d = (tp.origin - *p).dot(n); if d.abs() > 1e-9 { b = kernel::offset_faces(&b, &[*p], d)?; } } if let Some(slot) = st.bodies.get_mut(i) { *slot = ModelBody::new(mb.name, b, mb.feature); } Ok(()) } FeatureKind::Align { bodies, from, to, from_normal, to_normal, flip } => { let mut m = translation(Vec3::ZERO); if let (Some(a), Some(b)) = (from_normal.and_then(Vec3::normalized), to_normal.and_then(Vec3::normalized)) { // Turn `a` onto −b (faces meet) or b (flipped), about the `from` point. let want = if *flip { b } else { b * -1.0 }; let axis = a.cross(want); let ang = axis.len().atan2(a.dot(want)); let axis = axis.normalized().unwrap_or_else(|| a.any_perp()); if ang.abs() > 1e-12 { m = rotation(*from, axis, ang); } } let d = *to - *from; m[3][0] += d.x; m[3][1] += d.y; m[3][2] += d.z; if bodies.is_empty() { return Err(DocError::Invalid("no bodies to align".into())); } for n in bodies { let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; if let Some(mb) = st.bodies.get_mut(i) { let nb = kernel::transform_matrix(&mb.body, m)?; *mb = ModelBody::new(mb.name.clone(), nb, mb.feature); } } Ok(()) } FeatureKind::Remove { bodies } => { if bodies.is_empty() { return Err(DocError::Invalid("no bodies to remove".into())); } for n in bodies { if st.body(n).is_none() { return Err(DocError::Unknown(format!("body `{n}`"))); } } st.bodies.retain(|b| !bodies.contains(&b.name)); Ok(()) } _ => Err(DocError::Invalid(format!("{} is not evaluated here", f.name))), } } /// Unit outward normal of the planar face through `p`: of the planar faces whose plane runs /// through it, the one nearest it. A face elsewhere in such a plane (the wall of a notch, the end /// of a rib, in line with a rim) is no pick of `p`, though its plane runs through it as exactly. fn planar_normal_at(b: &Body, p: Vec3) -> Option { let tol = (b.size() * 2e-3).max(1e-3); let faces = b.faces(tol).ok()?; let mesh = b.tessellate(tol).ok()?; let mut near = vec![f64::INFINITY; faces.len()]; for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) { if let (Some([u, v, w]), Some(d)) = (mesh.tri(t), near.get_mut(*f as usize)) { *d = d.min(crate::appearance::closest_on_triangle(p, u, v, w).dist(p)); } } faces .iter() .filter_map(|f| f.plane_normal.map(|n| (n, ((p - f.centroid).dot(n)).abs(), near.get(f.index).copied().unwrap_or(f64::INFINITY)))) .filter(|(_, d, _)| *d < tol * 5.0) .min_by(|a, b| a.2.total_cmp(&b.2).then(a.1.total_cmp(&b.1))) .map(|(n, _, _)| n) } /// The curves of a chain as one polyline in sketch coordinates, in order. fn chain_points(ss: &SolvedSketch, ids: &[String]) -> Result> { let mut segs: Vec = Vec::new(); for id in ids { let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?; segs.extend(ss.sketch.segs(ci)); } let Some(first) = segs.first().copied() else { return Err(DocError::Invalid("no curves".into())) }; // Orient the first segment toward the second, then chain. let mut chain = vec![first]; if let Some(n) = segs.get(1) && (first.start().dist(n.start()) < 1e-6 || first.start().dist(n.end()) < 1e-6) { chain[0] = first.reversed(); } for s in segs.iter().skip(1) { let prev = chain.last().map(Seg2::end).unwrap_or_default(); let s = if s.end().dist(prev) < s.start().dist(prev) { s.reversed() } else { *s }; if s.start().dist(prev) > 1e-5 { return Err(DocError::Invalid("the rib curves are not connected end to end".into())); } chain.push(s); } let mut pts = vec![chain[0].start()]; for s in &chain { let n = if matches!(s, Seg2::Line { .. }) { 1 } else { 24 }; for k in 1..=n { pts.push(s.point_at(k as f64 / n as f64)); } } Ok(pts) } /// A rib wall: the band swept from the curves across the plane by `depth` (or to the body), /// `t` thick about the sketch plane. fn rib_tool(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: Option, flip: bool) -> Result { let pts = chain_points(ss, ids)?; let (Some(a), Some(b)) = (pts.first().copied(), pts.last().copied()) else { return Err(DocError::Invalid("empty rib curve".into())) }; let chord = (b - a).normalized().ok_or_else(|| DocError::Invalid("a closed or zero-length rib curve".into()))?; let mid = pts.get(pts.len() / 2).copied().unwrap_or(a); // Toward the material: the side of the curve where the bodies are (unless flipped). let mut n = chord.perp(); let reach = st.bodies.iter().map(|b| b.mesh().bounds()).fold(0.0f64, |acc, bb| acc.max((bb.max - bb.min).len())); let probe = |side: Vec2| { let mut hits = 0; for k in 1..=20 { let q = ss.plane.to_world(mid + side * (reach * k as f64 / 20.0)); if st.bodies.iter().any(|b| b.mesh().contains(q)) { hits += 1; } } hits }; if probe(n * -1.0) > probe(n) { n = n * -1.0; } if flip { n = n * -1.0; } // From each point of the curves, walk across the plane until the body begins (or `depth`); // walls that reach the body go a little into it so they merge with it. let inside = |q: Vec2| { let w = ss.plane.to_world(q); st.bodies.iter().any(|b| b.mesh().contains(w)) }; let len: f64 = pts.windows(2).map(|w| w[0].dist(w[1])).sum(); let samples = ((len / (t.min(len) * 0.25).max(len / 400.0)).ceil() as usize).clamp(2, 400); let along: Vec = (0..=samples).map(|k| point_on_polyline(&pts, len * k as f64 / samples as f64)).collect(); let limit = depth.unwrap_or(reach * 2.0 + 1.0); let step = (limit.min(reach * 2.0 + 1.0) / 400.0).max(1e-3); let overlap = (len / samples as f64 * 2.0).max(step * 2.0).min(t); let mut far = Vec::with_capacity(along.len()); for q in &along { let mut hit = None; let mut k = 1; while (k as f64) * step <= limit + step && k < 100_000 { if inside(*q + n * (k as f64 * step)) { // Refine between the last outside and the first inside sample. let (mut lo, mut hi) = ((k - 1) as f64 * step, k as f64 * step); for _ in 0..30 { let m = 0.5 * (lo + hi); if inside(*q + n * m) { hi = m } else { lo = m } } hit = Some(hi); break; } k += 1; } let h = match (hit, depth) { (Some(h), Some(d)) if h >= d => d, (Some(h), _) => h + overlap, (None, Some(d)) => d, (None, None) => return Err(DocError::Invalid("the rib curves do not face a body (give a depth)".into())), }; far.push(*q + n * h); } let mut poly = along.clone(); poly.extend(far.into_iter().rev()); let mut lp = Loop2::polygon(&poly); if lp.signed_area() < 0.0 { lp = lp.reversed(); } let region = Region2 { outer: lp, holes: Vec::new() }; let tools = kernel::extrude(&ss.plane, &[region], -t / 2.0, t / 2.0)?; tools.into_iter().next().ok_or_else(|| DocError::Invalid("the rib made no solid".into())) } /// The point `d` along a polyline. fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 { let mut left = d.max(0.0); for w in pts.windows(2) { let l = w[0].dist(w[1]); if left <= l && l > 0.0 { return w[0].lerp(w[1], left / l); } left -= l; } pts.last().copied().unwrap_or_default() } /// Rebuild a sheet body's solid from its model. fn replace_sheet_body(mb: &mut ModelBody, body: Body) { let mut next = ModelBody::new(mb.name.clone(), body, mb.feature); next.component = mb.component; next.transferred = mb.transferred; next.source_name = mb.source_name.clone(); next.source_transform = mb.source_transform; next.imported = mb.imported.clone(); *mb = next; } /// Put a converted sheet in the place of the body it was made from. fn put_sheet(st: &mut ModelState, sh: sheet::SheetBody) -> Result<()> { let solid = sh.solid()?; if let Some(b) = st.bodies.iter_mut().find(|b| b.name == sh.body) { replace_sheet_body(b, solid); } st.sheets.retain(|s| s.body != sh.body); st.sheets.push(sh); Ok(()) } fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> { let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?; let solid = sheet.solid()?; let i = st.bodies.iter().position(|b| b.name == sheet.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", sheet.body)))?; if let Some(mb) = st.bodies.get_mut(i) { replace_sheet_body(mb, solid); } Ok(()) } /// The sheet a feature works on: by body name, else the one with an edge nearest the pick. fn sheet_for(st: &ModelState, body: &Option, pick: Option) -> Result { if let Some(n) = body { return st.sheets.iter().position(|s| &s.body == n).ok_or_else(|| DocError::Invalid(format!("`{n}` is not a sheet metal body"))); } let p = pick.ok_or_else(|| DocError::Invalid("no sheet metal body".into()))?; st.sheets .iter() .enumerate() .filter_map(|(i, s)| s.edge_at(p).map(|e| (i, e.4))) .min_by(|a, b| a.1.total_cmp(&b.1)) .map(|(i, _)| i) .ok_or_else(|| DocError::Invalid("there is no sheet metal body".into())) } /// Whether a point touches sheet `i`'s material: inside it, on one of its faces, or within its /// display tolerance of one (picks land on the display mesh). A sheet with no mesh to test /// against counts as touched. fn on_sheet_material(st: &ModelState, i: usize, p: Vec3) -> bool { let Some(mb) = st.sheets.get(i).and_then(|sh| st.body(&sh.body)) else { return true }; let mesh = mb.mesh(); if mesh.triangles.is_empty() { return true; } // Twice the display mesh's chord tolerance (see `ModelBody::mesh`), and a quarter of the // thickness: on a large thin sheet the first steps from a point on its face clean through // it, so a point on the face would touch nothing (#685). let step = 2.0 * (mb.body.size() * 1e-3).max(1e-3); let thin = st.sheets.get(i).map_or(step, |sh| (sh.t * 0.25).min(step)); let offsets = [-step, -thin, 0.0, thin, step]; offsets.iter().any(|&dx| offsets.iter().any(|&dy| offsets.iter().any(|&dz| mesh.contains(p + Vec3::new(dx, dy, dz))))) } /// A region mirrored in the sketch x axis (y → −y), exactly: free-form curves through their /// control points. fn mirror_y(r: &Region2) -> Region2 { let f = |p: Vec2| Vec2::new(p.x, -p.y); let m = |l: &Loop2| { let segs: Vec = l .segs .iter() .map(|s| match *s { Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep }, other => sheet::map_controls(&other, f), }) .collect(); Loop2 { segs }.ccw() }; Region2 { outer: m(&r.outer), holes: r.holes.iter().map(|h| m(h).reversed()).collect() } } pub(super) fn sheet_eval( doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState, warning: &mut Option, ) -> Result<()> { use crate::sheet; match &f.kind { FeatureKind::SheetBase { sketch, profiles, rule, flip } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone(); let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); let r = doc.sheet_rule(rule.as_deref()); let rv = doc.rule_values(vals, &r)?; for (k, region) in regions.into_iter().enumerate() { let (plane, region) = if *flip { (Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 }, mirror_y(®ion)) } else { (ss.plane, region) }; let name = new_name(st, f, k); let mut sh = sheet::base_flange(&name, &r.name, &rv, plane, region); // The sketch's lines name the base panel's edges. sh.base_curves = ss .sketch .curves .iter() .enumerate() .filter(|(_, c)| !c.construction) .flat_map(|(ci, c)| { ss.sketch.segs(ci).into_iter().filter_map(move |sg| match sg { Seg2::Line { a, b } => Some((c.id.clone(), a, b)), _ => None, }) }) .map(|(id, a, b)| if *flip { (id, Vec2::new(a.x, -a.y), Vec2::new(b.x, -b.y)) } else { (id, a, b) }) .collect(); let solid = sh.solid()?; st.bodies.push(ModelBody::new(name, solid, f.id)); st.sheets.push(sh); } Ok(()) } FeatureKind::SheetContour { sketch, curves, distance, rule, flip, reverse } => { let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone(); for id in curves { let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?; if !ss.sketch.segs(ci).iter().all(|s| matches!(s, Seg2::Line { .. })) { return Err(DocError::Invalid("not supported yet: arcs in a contour flange (use lines; the bends are made for you)".into())); } } let pts = chain_points(&ss, curves)?; let w = val(vals, distance, Kind::Length)?; if !(w > 0.0) { return Err(DocError::Invalid("the contour flange distance must be positive".into())); } let r = doc.sheet_rule(rule.as_deref()); let rv = doc.rule_values(vals, &r)?; let plane = if *reverse { Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 } } else { ss.plane }; let pts: Vec = if *reverse { pts.iter().map(|p| Vec2::new(p.x, -p.y)).collect() } else { pts }; // Mirroring the sketch swaps the material side too. let name = new_name(st, f, 0); let sh = sheet::contour_flange(&name, &r.name, &rv, &plane, &pts, w, *flip != *reverse)?; let solid = sh.solid()?; st.bodies.push(ModelBody::new(name, solid, f.id)); st.sheets.push(sh); Ok(()) } FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => { let i = sheet_for(st, body, edges.first().map(|e| e.point))?; let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?); // Each edge's own height and angle, else the feature's. let mut own = Vec::with_capacity(edges.len()); for e in edges { let height = match &e.height { Some(x) => val(vals, x, Kind::Length)?, None => h, }; let angle = match &e.angle { Some(x) => val(vals, x, Kind::Angle)?, None => a, }; own.push((height, angle)); } let rr = match radius { Some(e) => val(vals, e, Kind::Length)?, None => { let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default(); doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius } }; let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?; if sh.flat { return Err(DocError::Invalid("refold the sheet before adding flanges".into())); } let points: Vec = edges.iter().map(|e| e.point).collect(); let each: Vec = sheet_picks(sh, f, &points, warning) .into_iter() .zip(own) .map(|(pick, (height, angle))| sheet::EdgeFlange { pick, height, angle }) .collect(); let n0 = sh.flanges.len(); let (_, notes) = sh.add_flanges_each(&each, rr, position, *flip)?; if !notes.is_empty() { let notes = notes.join("; "); *warning = Some(match warning.take() { Some(w) => format!("{w}; {notes}"), None => notes, }); } tag_flanges(sh, n0, f.id); rebuild_sheet(st, i) } FeatureKind::SheetPerforate { diameter, pitch, margin, pattern, body } => { // Without a body name the field goes on the newest sheet, as the command's own body lookup does. let i = match body { Some(_) => sheet_for(st, body, None)?, None => st.sheets.len().checked_sub(1).ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))?, }; let d = val(vals, diameter, Kind::Length)?; let pitch = val(vals, pitch, Kind::Length)?; let margin = match margin { Some(e) => val(vals, e, Kind::Length)?, None => d, }; let square = match pattern.as_deref() { None | Some("staggered") => false, Some("square") => true, Some(o) => return Err(DocError::Invalid(format!("perforation pattern {o:?}: use \"staggered\" or \"square\""))), }; if !(d > 0.0 && pitch > d && margin >= 0.0) { return Err(DocError::Invalid(format!( "perforation needs diameter > 0, pitch > diameter and margin >= 0 (diameter {d}, pitch {pitch}, margin {margin})" ))); } let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?; sh.perforations.push(crate::sheet::SheetPerforation { feature: f.id, diameter: d, pitch, margin, square }); Ok(()) } FeatureKind::SheetHem { edges, length, gap, flip, body } => { let i = sheet_for(st, body, edges.first().copied())?; let l = val(vals, length, Kind::Length)?; let g = match gap { Some(e) => val(vals, e, Kind::Length)?, None => { let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default(); doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.hem_gap } }; let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?; let picks = sheet_picks(sh, f, edges, warning); let n0 = sh.flanges.len(); for e in &picks { sh.add_hem(*e, l, g, *flip)?; } tag_flanges(sh, n0, f.id); rebuild_sheet(st, i) } FeatureKind::SheetFold { sketch, curve, a, b, angle, radius, position, flip, fixed, body } => { // The sketch line, where it is now. let (a, b) = match (sketch, curve) { (Some(sk), Some(c)) => { let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk}")))?; let ci = ss.sketch.curve_index(c).ok_or_else(|| DocError::Unknown(format!("curve `{c}`")))?; match ss.sketch.segs(ci).as_slice() { [Seg2::Line { a, b }] => (ss.plane.to_world(*a), ss.plane.to_world(*b)), _ => return Err(DocError::Invalid("the fold line must be a sketch line".into())), } } _ => (*a, *b), }; let i = sheet_for(st, body, Some((a + b) * 0.5))?; let ang = val(vals, angle, Kind::Angle)?; let rr = match radius { Some(e) => val(vals, e, Kind::Length)?, None => { let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default(); doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius } }; // A fixed point that touches no material at all (e.g. far above the sheet) still picks // the side it lies on, so a stored fold keeps recomputing; it says so (#371). if let Some(p) = fixed && !on_sheet_material(st, i, *p) { *warning = Some(format!( "{} at ({:.3}, {:.3}, {:.3}); the side of the fold line it lies on stays", sheet::FOLD_FIXED_OFF_SHEET, p.x, p.y, p.z )); } let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?; if sh.flat { return Err(DocError::Invalid("refold the sheet before folding it".into())); } let n0 = sh.flanges.len(); sh.add_fold(a, b, ang, rr, position, *flip, *fixed, warning)?; tag_flanges(sh, n0, f.id); rebuild_sheet(st, i) } FeatureKind::SheetUnfold { body, refold } => { let i = match body { Some(_) => sheet_for(st, body, None)?, None => st.sheets.iter().rposition(|s| s.flat == *refold).ok_or_else(|| { DocError::Invalid(if *refold { "no unfolded sheet to refold".into() } else { "no sheet metal body to unfold".into() }) })?, }; if let Some(sh) = st.sheets.get_mut(i) { sh.flat = !*refold; } rebuild_sheet(st, i) } // With bend edges: a solid or shelled body whose faces become the sheet. FeatureKind::SheetConvert { body, face, rule, bends } if !bends.is_empty() => { let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; let r = doc.sheet_rule(rule.as_deref()); let rv = doc.rule_values(vals, &r)?; // The bend edges by name first; a name that is gone keeps its point, with a warning. let mut picks = Vec::with_capacity(bends.len()); for (i, p) in bends.iter().enumerate() { let name = f.edge_names.get(i).filter(|n| !n.is_empty()); picks.push(match name.map(|n| (n, crate::naming::locate_edge(&mb, n, *p))) { Some((n, Ok(hit))) => { if let Some(note) = hit.note(n) { append_warning(warning, note); } hit.point } Some((n, Err(_))) => { append_warning(warning, format!("edge `{n}` no longer exists; using the edge at the picked point")); *p } None => *p, }); } let (mut sh, notes) = sheet::convert_body(&mb, &r.name, &rv, *face, &picks)?; for note in notes { append_warning(warning, note); } tag_flanges(&mut sh, 0, f.id); put_sheet(st, sh) } FeatureKind::SheetConvert { body, face, rule, .. } => { let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?; // Thickness: the body's extent along the face normal. let extent = |mesh: &solidcraft_geom::Mesh| { mesh.positions.iter().map(|p| (*p - *face).dot(n)).fold((f64::MAX, f64::MIN), |(lo, hi), d| (lo.min(d), hi.max(d))) }; // The tolerance (the mesh's chords, and how far off the face's plane a point of it may // be) is at most a tenth of the thickness, not only a thousandth of the size. On a long // thin plate that thousandth passes the thickness: the far side's triangles counted as // this face's (no outline was left), and a hole of a smaller radius than that fell out. let mut tol = (mb.body.size() * 1e-3).max(1e-3); let mut mesh = mb.body.tessellate(tol)?; let (mut lo, mut hi) = extent(&mesh); if hi - lo > 0.0 && tol > (hi - lo) * 0.1 { tol = ((hi - lo) * 0.1).max(1e-6); mesh = mb.body.tessellate(tol)?; (lo, hi) = extent(&mesh); } let t = hi - lo; // The face outline: boundary edges of the triangles on that face's plane, where it runs // along a round wall an arc of the wall's circle (not chords: the plate check below // compares its area with the exact volume). let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?; // Recesses in one face (an engraved label) become pockets; the outline is the other face's. let recesses = plate_recesses(&mb.body, &mesh, &plane, *face, n, (lo, hi), tol)?; let mut walls = std::collections::HashMap::new(); let mut wall = |f: u32| *walls.entry(f).or_insert_with(|| sheet::round_wall(&mb.body, &plane, n, f)); let mut loops: Vec = face_outline(&mesh, &plane, *face, n, recesses.level, tol).iter().map(|l| sheet::outline_loop(l, &mut wall, tol * 1e-2)).collect(); loops.sort_by(|a, b| b.signed_area().abs().total_cmp(&a.signed_area().abs())); let mut it = loops.into_iter(); let outer = it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?.ccw(); let region = Region2 { outer, holes: it.map(|l| l.ccw().reversed()).collect() }; let area = region.area().abs(); let vol = kernel::measure(&mb.body)?.volume + recesses.volume; if (area * t - vol).abs() > 1e-3 * vol.max(1.0) { return Err(DocError::Invalid( "not supported yet: converting bodies that are not a plate of even thickness without bend edges (`bends`)".into(), )); } let r = doc.sheet_rule(rule.as_deref()); let mut rv = doc.rule_values(vals, &r)?; rv.thickness = t; let mut sh = sheet::base_flange(body, &r.name, &rv, plane, Region2 { outer: region.outer.clone(), holes: Vec::new() }); sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect(); let mut work = sheet::FlatWork::new(); for (pocket, z0, z1) in recesses.pockets { sh.record_pocket(pocket, z0, z1, &mut work)?; } put_sheet(st, sh) } _ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))), } } fn poly_area(p: &[Vec2]) -> f64 { let m = p.len(); (0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::() / 2.0 } /// Current unmodified extrusion caps, including placement, certify the tool's depth. /// Tessellation can drift in thickness at distant coordinates and cannot certify a slab. fn sheet_tool_span(body: &Body, source: &Plane) -> Option<(f64, f64, f64, Option<(Plane, Region2)>)> { use kernel::FaceSource; #[derive(PartialEq)] enum Signature<'a> { Raw(&'a Region2), Certified(&'a [String], &'a [Vec]), } let sources = body.face_sources()?; if sources.len() != body.face_count() || sources.len() > 16_384 { return None; } let n = source.normal(); let mut family = None; let mut caps = [None, None]; let mut projection_roundoff: f64 = 0.0; let mut footprint = None; for (index, entry) in sources.iter().enumerate() { let (group, cap) = match entry.as_ref()? { FaceSource::ExtrudeSide { .. } => (None, None), FaceSource::ExtrudeCap { high, region, plane } => (None, Some((*high, *plane, Signature::Raw(region)))), FaceSource::CertifiedExtrudeSide { producer, sketch, .. } => (Some((*producer, *sketch)), None), FaceSource::CertifiedExtrudeCap { producer, sketch, high, outer, holes, plane } => { (Some((*producer, *sketch)), Some((*high, *plane, Signature::Certified(outer, holes)))) } _ => return None, }; match family { None => family = Some(group), Some(previous) if previous != group => return None, _ => {} } if let Some((high, plane, signature)) = cap { if high && let Signature::Raw(region) = &signature { footprint = Some((plane, (*region).clone())); } let valid = |p: &Plane| { p.origin.is_finite() && p.x.is_finite() && p.y.is_finite() && (p.x.len2() - 1.0).abs() <= 1e-10 && (p.y.len2() - 1.0).abs() <= 1e-10 && p.x.dot(p.y).abs() <= 1e-10 && p.normal().cross(n).len() <= 1e-10 }; let current = body.planar_face(index)?; if !valid(&plane) || !valid(¤t) { return None; } // Bound floating point projection separately from model geometry tolerance. let roundoff = 128.0 * f64::EPSILON * (plane.origin.len() + current.origin.len() + source.origin.len()).max(1.0); // The cap's current point lies off the recorded plane by the noise of evaluating // its edges, which its fitted tilt shows across the distance between the two points // (8e-12 over 2.4 mm on a 45° flange's drill, #1255; none on axis-aligned caps). let noise = current.normal().cross(n).len() * (current.origin - plane.origin).len(); if roundoff > 1e-7 || (current.origin - plane.origin).dot(n).abs() > roundoff + noise { return None; } projection_roundoff = projection_roundoff.max(roundoff); let distance = (plane.origin - source.origin).dot(n); if !distance.is_finite() || caps[usize::from(high)].is_some() { return None; } caps[usize::from(high)] = Some((distance, signature)); } } let [Some((low, low_signature)), Some((high, high_signature))] = caps else { return None }; (low_signature == high_signature && low != high).then_some((low.min(high), low.max(high), projection_roundoff, footprint)) } /// Exact prismatic machining, retained in sheet material/depth metadata. pub(super) fn sheet_cut( f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String], constructor: Option<&ExtrudeConstructor>, warning: &mut Option, ) -> Result> { let FeatureKind::Extrude { sketch, profiles, operation, extent, .. } = &f.kind else { return Ok(Vec::new()) }; if extent.taper.is_some() || !any_eligible_sheet(f, st, targets) { return Ok(Vec::new()); } let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) }; let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?); // The outlines of the sketch's text: the sheet remembers where its letters went. let text: Vec = ss .sketch .links .iter() .filter(|l| matches!(l.source, solidcraft_sketch::LinkSource::Text { .. })) .flat_map(|l| ss.sketch.link_curves(&l.id)) .flat_map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci))) .collect(); sheet_prism_cut(f, st, &ss.plane, ®ions, *operation, tools, targets, constructor, extent.to.is_some(), &text, warning) } /// Whether a cut of `f` with these targets (else its participants, else its component's /// bodies) machines the sheet `original`. fn sheet_eligible(f: &Feature, st: &ModelState, targets: &[String], original: &crate::sheet::SheetBody) -> bool { let targets = if targets.is_empty() { &f.participants } else { targets }; let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); all || if targets.is_empty() { st.bodies.iter().any(|b| b.name == original.body && (b.component == f.component || b.feature == f.id)) } else { targets.contains(&original.body) } } /// Whether a cut of `f` with these targets machines any sheet metal body. fn any_eligible_sheet(f: &Feature, st: &ModelState, targets: &[String]) -> bool { st.sheets.iter().any(|original| sheet_eligible(f, st, targets, original)) } /// A Box or Cylinder cut (or intersect) whose tool is a straight prism along the normal of /// the sheet it meets: machined like the equivalent extrude of its footprint, so the sheet /// keeps it as a flat cut-out or pocket. A hole's drill (`drill`) may also be square to a /// folded panel of the sheet, a flange's. Returns the sheet bodies it handled; none when the /// primitive is not such a prism (the generic preflight then refuses real 3D machining). pub(super) fn sheet_primitive_cut( f: &Feature, st: &mut ModelState, tool: &Body, prism: PrimitivePrism, operation: Operation, drill: bool, warning: &mut Option, ) -> Result> { if !matches!(operation, Operation::Cut | Operation::Intersect) || !any_eligible_sheet(f, st, &[]) { return Ok(Vec::new()); } // The first sheet the primitive meets decides the machining direction. let mut normal = None; for sh in st.sheets.iter().filter(|sh| sheet_eligible(f, st, &[], sh)) { if let Some(mb) = st.body(&sh.body) && kernel::boolean(&mb.body, tool, kernel::BoolOp::Intersect)?.is_some() { normal = Some(match prism { PrimitivePrism::Cylinder { axis, .. } if drill && !sh.flat && Plane::from_normal(Vec3::ZERO, axis).is_some_and(|p| !sh.parallel_panels(&p).is_empty()) => { axis } _ => sh.frame.normal(), }); break; } } let Some(normal) = normal else { return Ok(Vec::new()) }; let Some((plane, region, interval)) = prism.along(normal) else { return Ok(Vec::new()) }; let tools = kernel::extrude(&plane, std::slice::from_ref(®ion), interval.0, interval.1)?; sheet_prism_cut(f, st, &plane, std::slice::from_ref(®ion), operation, &tools, &[], None, false, &[], warning) } /// A hole's straight drill along a panel of a sheet (into its rim), kept as a bore of each /// panel it meets (see [`crate::sheet::SheetBore`]), so the sheet still unfolds. Returns the /// sheet bodies it machined; None, with the model as it was, when the drill is not a plain /// cylinder or a sheet it meets can't keep it that way (it runs into a bend, or crosses a /// panel at an angle). pub(super) fn sheet_bore_cut(f: &Feature, st: &mut ModelState, prism: PrimitivePrism) -> Result>> { let PrimitivePrism::Cylinder { base, axis, radius, height } = prism else { return Ok(None) }; let mut plans = Vec::new(); for original in &st.sheets { if !sheet_eligible(f, st, &[], original) { continue; } let mut sh = original.clone(); match sh.add_bore(base, axis, radius, height) { Ok(true) => {} Ok(false) => continue, // Refused here, the hole keeps the message of the machining it was first tried as. Err(DocError::Invalid(_)) => return Ok(None), Err(e) => return Err(e), } let body = sh.solid()?; plans.push((sh, body)); } if plans.is_empty() { return Ok(None); } let mut handled = Vec::new(); for (sh, body) in plans { if let Some(mb) = st.bodies.iter_mut().find(|b| b.name == sh.body) { replace_sheet_body(mb, body); } handled.push(sh.body.clone()); if let Some(slot) = st.sheets.iter_mut().find(|s| s.body == sh.body) { *slot = sh; } } Ok(Some(handled)) } /// A primitive tool's exact geometry, for [`sheet_primitive_cut`]. #[derive(Clone, Copy, Debug)] pub(super) enum PrimitivePrism { /// An axis-aligned box between two corners. Box { lo: Vec3, hi: Vec3 }, /// A cylinder: base centre, unit axis, radius and signed height. Cylinder { base: Vec3, axis: Vec3, radius: f64, height: f64 }, } impl PrimitivePrism { /// The sketch-like plane, footprint region and depth interval of this tool as a prism along /// `normal` (either sense), or None when it is not one. fn along(&self, normal: Vec3) -> Option<(Plane, Region2, (f64, f64))> { let n = normal.normalized()?; match *self { PrimitivePrism::Box { lo, hi } => { let (lo, hi) = (lo.min(hi), lo.max(hi)); let s = hi - lo; // Plane axes (x, y) and the depth axis, as a right-handed frame. let (x, y, depth) = if n.cross(Vec3::Z).len() <= 1e-12 { (Vec3::X, Vec3::Y, s.z) } else if n.cross(Vec3::X).len() <= 1e-12 { (Vec3::Y, Vec3::Z, s.x) } else if n.cross(Vec3::Y).len() <= 1e-12 { (Vec3::Z, Vec3::X, s.y) } else { return None; }; let plane = Plane::new(lo, x, y)?; let (w, h) = (s.dot(x), s.dot(y)); let outer = Loop2::polygon(&[Vec2::ZERO, Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)]); Some((plane, Region2 { outer, holes: vec![] }, (0.0, depth))) } PrimitivePrism::Cylinder { base, axis, radius, height } => { let axis = axis.normalized()?; if axis.cross(n).len() > 1e-12 { return None; } let plane = Plane::from_normal(base, axis)?; Some((plane, Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] }, (height.min(0.0), height.max(0.0)))) } } } } /// The most outline segments of a sketch's text a cut tells letters by, and the most text regions /// a sheet keeps (see [`crate::sheet::SheetBody::labels`]). const MAX_TEXT_CURVES: usize = 20_000; const MAX_LABELS: usize = 1000; /// `text` is the outlines of the text in the cut's sketch (none for a primitive's cut). #[allow(clippy::too_many_arguments)] fn sheet_prism_cut( f: &Feature, st: &mut ModelState, plane: &Plane, regions: &[Region2], operation: Operation, tools: &[Body], targets: &[String], constructor: Option<&ExtrudeConstructor>, footprints_bound: bool, text: &[Seg2], warning: &mut Option, ) -> Result> { let n = plane.normal(); let mut work = sheet::FlatWork::new(); let faces = tools.iter().fold(0usize, |count, body| count.saturating_add(body.face_count())); if tools.len() > 1000 || faces > 16_384 { return Err(DocError::Invalid("sheet machining tools exceed the bounded geometry budget".into())); } work.charge(faces.saturating_mul(5))?; let mut span = (f64::MAX, f64::MIN); let mut cap_roundoff: f64 = 0.0; let mut current_footprints = Vec::new(); for tool in tools { let Some((lo, hi, roundoff, footprint)) = sheet_tool_span(tool, plane) else { // Modified tools need a current geometric prism certificate. The generic // sheet preflight permits actual no-change, otherwise refuses stale metadata. return Ok(Vec::new()); }; if span.0 != f64::MAX && ((span.0 - lo).abs() > cap_roundoff + roundoff || (span.1 - hi).abs() > cap_roundoff + roundoff) { // Different profile tools cannot be represented as one filled depth interval. return Ok(Vec::new()); } span = (span.0.min(lo), span.1.max(hi)); cap_roundoff = cap_roundoff.max(roundoff); if let Some(footprint) = footprint { current_footprints.push(footprint); } } // Only untrimmed Distance tools may use their exact authored scalar interval. // The cap certificate still qualifies their current physical slab above. let selected_edges = regions.iter().flat_map(|r| std::iter::once(&r.outer).chain(&r.holes)).map(|l| l.segs.len()).fold(0usize, usize::saturating_add); work.charge(faces.saturating_mul(selected_edges).saturating_mul(16))?; let nominal = constructor.filter(|c| c.plane == *plane).and_then(|c| { let placed_basis = Plane::new(c.plane.origin, c.plane.x, c.plane.y)?; (current_footprints.len() == tools.len() && current_footprints.iter().all(|(plane, r)| plane.x == placed_basis.x && plane.y == placed_basis.y && regions.contains(r))) .then_some(c.interval) }); // The regions of the cut that are letters, with their counters. let letters: Vec<&Region2> = if operation == Operation::Cut && !text.is_empty() && text.len() <= MAX_TEXT_CURVES { regions.iter().filter(|r| sheet::loop_lies_on(&r.outer, text)).collect() } else { Vec::new() }; let mut plans = Vec::new(); let mut notes = Vec::new(); 'sheets: for original in &st.sheets { if !sheet_eligible(f, st, targets, original) { continue; } let Some(mb) = st.body(&original.body) else { continue; }; let mut sh = original.clone(); if operation == Operation::Intersect && (!sh.flat && !sh.flanges.is_empty()) { super::sheet_boolean_unchanged(&mb.body, tools, operation)?; plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); continue; } if !sh.flat && !sh.flanges.is_empty() && !super::sheet_boolean_changes(&mb.body, tools, operation)? { plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); continue; } for bends in sh.unmodeled_solids(plane)? { for tool in tools { if kernel::boolean(&bends, tool, kernel::BoolOp::Intersect)?.is_some() { return Err(DocError::Invalid( "this machining intersects a folded bend or a nonparallel panel; unfold the sheet before machining that material".into(), )); } } } let candidates = if sh.flat { if sh.frame.normal().cross(n).len() > 1e-6 { Vec::new() } else { sh.flat_regions()?.into_iter().map(|r| (None, r)).collect() } } else { sh.material_panels(Some(plane), &mut work)? }; let mut modeled = false; let mut labels = Vec::new(); for (pi, panel) in candidates { let w = if sh.flat { sh.world(None) } else { sh.world(pi) }; let zorigin = (apply_point(&w, Vec3::ZERO) - plane.origin).dot(n); let sign = crate::apply_vector(&w, Vec3::Z).dot(n); if sign.abs() < 1e-6 { continue; } let (a, b) = ((span.0 - zorigin) / sign, (span.1 - zorigin) / sign); let roundoff = (cap_roundoff + 128.0 * f64::EPSILON * (apply_point(&w, Vec3::ZERO).len() + plane.origin.len() + span.0.abs() + span.1.abs() + sh.t).max(1.0)) / sign.abs(); let nominal_depth = if apply_point(&w, Vec3::ZERO) == plane.origin && let Some((lo, hi)) = nominal && (span.0 - lo).abs() <= roundoff && (span.1 - hi).abs() <= roundoff { // Equality of the two frame origins proves zero origin displacement; // use authored distances, never an inferred endpoint coincidence. let normal = crate::apply_vector(&w, Vec3::X).cross(crate::apply_vector(&w, Vec3::Y)); let exact_sign = if normal == n { 1.0 } else if normal == -n { -1.0 } else { sign }; Some((lo / exact_sign, hi / exact_sign)) } else { None }; if nominal_depth.is_none() && roundoff > 1e-7 { super::sheet_boolean_unchanged(&mb.body, tools, operation)?; plans.push((original.body.clone(), Some((original.clone(), mb.body.clone())))); continue 'sheets; } let (a, b) = nominal_depth.unwrap_or((a, b)); let (z0, z1) = (a.min(b).max(0.0), a.max(b).min(sh.t)); // Sub-resolution layers must not be silently erased by endpoint snapping. let depth_resolved = ![z0, sh.t - z1, z1 - z0].into_iter().any(|d| d > 0.0 && d < 1e-6); labels.extend(letters.iter().filter_map(|r| sh.region_to_flat(pi, plane, r).ok())); let mapped = if footprints_bound && current_footprints.len() == tools.len() { current_footprints.iter().map(|(plane, r)| sh.region_to_flat(pi, plane, r)).collect::>>()? } else { regions.iter().map(|r| sh.region_to_flat(pi, plane, r)).collect::>>()? }; if operation == Operation::Intersect { if z1 <= z0 { sh.holes.push(panel); modeled = true; continue; } let tool = sheet::material_union(mapped, &mut work)?; let retained = sheet::material_boolean(std::slice::from_ref(&panel), &tool, kernel::BoolOp::Intersect, &mut work)?; let removed = sheet::material_boolean(std::slice::from_ref(&panel), &retained, kernel::BoolOp::Cut, &mut work)?; if removed.is_empty() && !sh.material_outside_depth(&panel, z0, z1, &mut work)? { modeled = true; continue; } if !retained.is_empty() && !depth_resolved { return Err(DocError::Invalid( "sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(), )); } sh.holes.extend(removed); // Preserve original stock thickness and prior machining. Intersection // removes the complementary layers above/below every surviving region. for region in retained { if z0 > 0.0 { sh.record_pocket(region.clone(), 0.0, z0, &mut work)?; } if z1 < sh.t { sh.record_pocket(region, z1, sh.t, &mut work)?; } } modeled = true; } else if z1 > z0 { // Each tool meets only the panel's cut-outs near it: arranging every one of a // perforation's holes against every new hole would grow with their square (#732). let near = sheet::NearHoles::new(&panel)?; for flat in mapped { let local = near.around(&flat)?; let clipped = sheet::material_boolean(std::slice::from_ref(&local), std::slice::from_ref(&flat), kernel::BoolOp::Intersect, &mut work)?; if !clipped.is_empty() && !depth_resolved { return Err(DocError::Invalid( "sheet machining depth is below the supported 0.000001 mm extrusion resolution; increase the layer depth".into(), )); } if !clipped.is_empty() && z0 <= 1e-7 && z1 >= sh.t - 1e-7 { // Keep a single original tool boundary across structural seams. // Re-clipping notch polygons creates coincident seam endpoints. if !sh.holes.contains(&flat) { sh.holes.push(flat); } modeled = true; } else { for region in clipped { sh.record_pocket(region, z0, z1, &mut work)?; modeled = true; } } } } } if modeled { for flat in labels { if !sh.labels.contains(&flat) && sh.labels.len() < MAX_LABELS { sh.labels.push(flat); } } } if !modeled { super::sheet_boolean_unchanged(&mb.body, tools, operation)?; plans.push((sh.body.clone(), Some((sh, mb.body.clone())))); } else { let material = sh.flat_material()?; if material.is_empty() { // Empty exact material removes geometry and metadata together. Budget or // topology errors propagate; they are never mistaken for an empty blank. plans.push((sh.body.clone(), None)); } else { // Every piece the cut leaves stays; the ones it parts from the base are named. notes.extend(parted_note(&sh, &material, original)); let body = sh.solid()?; plans.push((sh.body.clone(), Some((sh, body)))); } } } let handled = plans.iter().map(|(name, _)| name.clone()).collect(); if !notes.is_empty() { append_warning(warning, notes.join("; ")); } for (name, result) in plans { match result { Some((sh, body)) => { if let Some(mb) = st.bodies.iter_mut().find(|b| b.name == name) { replace_sheet_body(mb, body); } if let Some(target) = st.sheets.iter_mut().find(|s| s.body == name) { *target = sh; } } None => { st.bodies.retain(|b| b.name != name); st.sheets.retain(|s| s.body != name); st.threads.retain(|t| t.body != name); } } } Ok(handled) } /// A cut's note on the pieces it parted from the base of `sh` (see /// [`sheet::SheetBody::parted_from_base`]; `material` is the sheet's flat material after the cut, /// `before` the sheet before it): how many, their area and where they lie in the flat pattern. /// The pieces stay, as every piece a cut leaves does. None when nothing is parted, and when the /// pieces can't be worked out: the note is no reason for a cut to fail. fn parted_note(sh: &sheet::SheetBody, material: &[Region2], before: &sheet::SheetBody) -> Option { let pieces = sh.parted_from_base(material, before).ok().filter(|p| !p.is_empty())?; let area: f64 = pieces.iter().map(Region2::area).sum(); let at: Vec = pieces .iter() .take(4) .map(|r| { let c = r.interior_point(); format!("({:.1}, {:.1})", c.x, c.y) }) .collect(); let at = at.join(", "); let body = &sh.body; Some(match pieces.len() { 1 => format!("1 piece of `{body}` is no longer joined to its base ({area:.2} mm², near {at} in the flat pattern); it is kept"), n => { let more = if n > 4 { format!(" and {} more", n - 4) } else { String::new() }; format!( "{n} pieces of `{body}` are no longer joined to its base ({area:.2} mm² in all, near {at}{more} in the flat pattern); they are kept" ) } }) } /// Outline loops (in `plane` coordinates) of the mesh triangles lying in the plane at distance /// `level` from `at` along `n`: the edges used by one of them only, chained. fn face_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec> { face_outline(mesh, plane, at, n, level, tol).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect() } /// [`face_loops`] with each point's B-rep face across the outline from it to the next point (the /// wall the outline runs along there): the face off the plane that meets both points. fn face_outline(mesh: &solidcraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec)>> { let on_plane = |t: &[u32; 3]| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol)); let on: Vec<[u32; 3]> = mesh.triangles.iter().copied().filter(on_plane).collect(); let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default(); let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64); // The faces off the plane at each point on it, by position to a millionth of a millimetre // either way (a neighbouring face's mesh may round a shared point differently). let mut touch: std::collections::HashMap<(i64, i64, i64), Vec> = Default::default(); for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face).filter(|(t, _)| !on_plane(t)) { for p in t.iter().map(|k| pos(*k)).filter(|p| ((*p - at).dot(n) - level).abs() < tol) { let faces = touch.entry(q(p)).or_default(); if !faces.contains(f) { faces.push(*f); } } } let near = |p: Vec3| { let (x, y, z) = q(p); let cells = (-1..=1).flat_map(|i| (-1..=1).flat_map(move |j| (-1..=1).map(move |k| (x + i, y + j, z + k)))); let mut faces: Vec = cells.filter_map(|c| touch.get(&c)).flatten().copied().collect(); faces.sort_unstable(); faces.dedup(); faces }; let across = |a: Vec3, b: Vec3| { let at_b = near(b); near(a).into_iter().find(|f| at_b.contains(f)) }; chain_loops(mesh, plane, &on, across) } /// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh. fn boundary_loops(mesh: &solidcraft_geom::Mesh, plane: &Plane, on: &[[u32; 3]]) -> Vec> { chain_loops(mesh, plane, on, |_, _| None).into_iter().map(|l| l.into_iter().map(|(p, _)| p).collect()).collect() } /// The loops (in `plane` coordinates) that bound the triangles `on` of the mesh, each point with /// `across(it, next)`: the face the outline runs along from it to the next point. fn chain_loops( mesh: &solidcraft_geom::Mesh, plane: &Plane, on: &[[u32; 3]], across: impl Fn(Vec3, Vec3) -> Option, ) -> Vec)>> { // Boundary edges by position (meshes may repeat vertices per triangle). let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default(); let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64); let key = |a: Vec3, b: Vec3| { let (ka, kb) = (q(a), q(b)); (ka.min(kb), ka.max(kb)) }; let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default(); let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect(); for (a, b) in &edges { *count.entry(key(*a, *b)).or_insert(0) += 1; } let mut boundary: Vec<(Vec3, Vec3)> = edges.into_iter().filter(|(a, b)| count.get(&key(*a, *b)) == Some(&1)).collect(); // Chain into loops (plane coordinates). let mut loops: Vec)>> = Vec::new(); for _ in 0..10_000 { let Some((s0, mut cur)) = boundary.pop() else { break }; let mut lp = vec![(s0, across(s0, cur))]; for _ in 0..boundary.len() + 1 { if q(cur) == q(s0) { break; } match boundary.iter().position(|(a, _)| q(*a) == q(cur)) { Some(k) => { let b = boundary.remove(k).1; lp.push((cur, across(cur, b))); cur = b; } None => { lp.push((cur, None)); break; } } } let raw: Vec<(Vec2, Option)> = lp.iter().map(|(p, f)| (plane.to_local(*p), *f)).collect(); // Drop points in the middle of straight runs. let m = raw.len(); let pts: Vec<(Vec2, Option)> = (0..m) .filter_map(|i| { let (p, c, n) = (raw.get((i + m - 1) % m)?.0, *raw.get(i)?, raw.get((i + 1) % m)?.0); ((c.0 - p).cross(n - c.0).abs() > 1e-9 * (c.0 - p).len().max(1e-9) * (n - c.0).len().max(1e-9)).then_some(c) }) .collect(); if pts.len() >= 3 { loops.push(pts); } } loops } /// What the recesses of a plate (an engraved label, a counterbore) take out of it, for Convert /// to Sheet Metal: each becomes a pocket of the sheet, from its floor to the face it opens on. struct Recesses { /// The level (along the picked face's normal) of the plate's face without recesses, whose /// outline the sheet takes. level: f64, /// Pockets in flat coordinates, with their depth interval in the sheet (0 to its thickness). pockets: Vec<(Region2, f64, f64)>, /// The material they take out. volume: f64, } /// A planar face of the mesh parallel to the plate: its lowest and highest level, whether its /// outward normal is the picked face's (it faces up), its area, a point on it, and its /// triangles' centres and areas. struct LevelFace { lo: f64, hi: f64, up: bool, area: f64, at: Vec3, tris: Vec<(Vec3, f64)>, } /// The recesses of a plate lying between levels `lo` and `hi` along `n` (measured from `at`), /// whose sheet is built in `plane`. A recess's floor is a face parallel to the plate inside its /// thickness, lying in an opening of the face it is cut into, and its walls are square to the /// plate (walls that are not show as material the plate's volume doesn't account for). A body /// that is no plate once its recesses are filled has none, so the plate check refuses it as /// before: a bent part, whose legs' faces lie inside its extent. Recesses are taken in one face /// only, up to half the thickness deep, and no larger than the face they are cut into (a plate /// raised in places is no plate of the raised thickness). fn plate_recesses(body: &Body, mesh: &Mesh, plane: &Plane, at: Vec3, n: Vec3, (lo, hi): (f64, f64), tol: f64) -> Result { let t = hi - lo; let eps = t * 1e-3; let level = |p: Vec3| (p - at).dot(n); let mut faces: BTreeMap> = BTreeMap::new(); for (tri, f) in mesh.triangles.iter().zip(&mesh.tri_face) { let Some([a, b, c]) = mesh.tri(tri) else { continue }; let cr = (b - a).cross(c - a); let len = cr.len(); if !(len > 1e-12) { continue; } let up = cr.dot(n) > 0.0; let entry = faces.entry(*f).or_insert(Some(LevelFace { lo: f64::MAX, hi: f64::MIN, up, area: 0.0, at: a, tris: Vec::new() })); match entry { Some(face) if face.up == up && cr.dot(n).abs() >= len * (1.0 - 1e-6) => { for p in [a, b, c] { face.lo = face.lo.min(level(p)); face.hi = face.hi.max(level(p)); } face.area += len / 2.0; face.tris.push(((a + b + c) / 3.0, len / 2.0)); } _ => *entry = None, } } // The floors (face index, level, the face), and the areas of the plate's two faces (at `lo` // and at `hi`). let mut floors: Vec<(usize, f64, &LevelFace)> = Vec::new(); let (mut bottom, mut top) = (0.0, 0.0); for (f, face) in faces.iter().filter_map(|(f, face)| Some((*f as usize, face.as_ref()?))) { let d = (face.lo + face.hi) / 2.0; if face.hi - face.lo > eps { continue; } else if (d - hi).abs() <= eps && face.up { top += face.area; } else if (d - lo).abs() <= eps && !face.up { bottom += face.area; } else if d > lo + eps && d < hi - eps { floors.push((f, d, face)); } } let none = || Recesses { level: lo, pockets: Vec::new(), volume: 0.0 }; if floors.is_empty() { return Ok(none()); } // A floor is a recess's where (most of) it lies in an opening of the face it is cut into: // inside that face's outline and in a hole of it, not on an island in the hole. (Its // triangles are those at its level within `eps`: `tol` may exceed a recess's depth.) let loops_at = |level: f64| -> Vec { face_loops(mesh, plane, at, n, level, eps).into_iter().map(|l| Loop2::polygon(&l)).collect() }; let (top_loops, bottom_loops) = (loops_at(hi), loops_at(lo)); floors.retain(|(_, _, face)| { let loops = if face.up { &top_loops } else { &bottom_loops }; let open = |p: Vec2| { let k = loops.iter().filter(|l| l.contains(p)).count(); k >= 2 && k.is_multiple_of(2) }; face.tris.iter().filter(|(c, _)| open(plane.to_local(*c))).map(|(_, a)| a).sum::() > face.area / 2.0 }); let Some(&(_, _, first)) = floors.first() else { return Ok(none()) }; // Filled, the recesses leave a plate: its outline as large at `lo` as at `hi` once their // openings count, and its volume with theirs the outline times the thickness. Else (a bent // part) they are none. let depth_in = |d: f64, up: bool| if up { hi - d } else { d - lo }; let opened = |up: bool| floors.iter().filter(|f| f.2.up == up).map(|f| f.2.area).sum::(); let outline = bottom + opened(false); let removed: f64 = floors.iter().map(|f| f.2.area * depth_in(f.1, f.2.up)).sum(); let volume = kernel::measure(body)?.volume; if (outline - top - opened(true)).abs() > 1e-3 * outline || (outline * t - volume - removed).abs() > 1e-3 * volume.max(1.0) { return Ok(none()); } let pt = |p: Vec3| format!("({:.3}, {:.3}, {:.3})", p.x, p.y, p.z); let up = first.up; if let Some(other) = floors.iter().find(|f| f.2.up != up) { return Err(DocError::Invalid(format!( "not supported yet: converting a plate with recesses in both faces (at {} and {}); recesses in one face become pockets", pt(first.at), pt(other.2.at) ))); } let depth_of = |d: f64| depth_in(d, up); if let Some(deep) = floors.iter().find(|f| depth_of(f.1) > t / 2.0 + eps) { return Err(DocError::Invalid(format!( "not supported yet: the recess at {} is {:.3} mm deep, more than half the plate's {:.3} mm; shallower recesses (an engraving, a label) become pockets", pt(deep.2.at), depth_of(deep.1), t ))); } let face_area = if up { top } else { bottom }; if floors.iter().map(|f| f.2.area).sum::() > face_area { return Err(DocError::Invalid(format!( "not supported yet: converting a plate whose recesses are larger than the face they are in (a plate thinner than {t:.3} mm, raised in places)" ))); } // The floors' outlines, from a mesh fine enough to mark them by. let fine = body.tessellate((t * 1e-2).min(tol).max(1e-4))?; floors.sort_by(|a, b| depth_of(a.1).total_cmp(&depth_of(b.1))); // Floors at one depth are one pocket where they meet. let mut groups: Vec<(f64, Vec)> = Vec::new(); for (f, d, ..) in &floors { let on: Vec<[u32; 3]> = fine.triangles.iter().zip(&fine.tri_face).filter(|(_, g)| **g as usize == *f).map(|(tri, _)| *tri).collect(); let mut loops = boundary_loops(&fine, plane, &on); loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs())); let mut it = loops.into_iter(); let Some(outline) = it.next() else { continue }; let region = Region2 { outer: Loop2::polygon(&outline).ccw(), holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() }; match groups.last_mut() { Some((depth, regions)) if depth_of(*d) - *depth <= eps => regions.push(region), _ => groups.push((depth_of(*d), vec![region])), } } // The sheet's z starts from the picked face when its plane faces away from that face. let from_zero = up == (plane.normal().dot(n) < 0.0); let mut work = sheet::FlatWork::new(); let (mut pockets, mut volume) = (Vec::new(), 0.0); for (depth, regions) in groups { let (z0, z1) = if from_zero { (0.0, depth) } else { (t - depth, t) }; for region in sheet::material_union(regions, &mut work)? { volume += region.area().abs() * depth; pockets.push((region, z0, z1)); } } Ok(Recesses { level: if up { lo } else { hi }, pockets, volume }) } /// The body a placed plastic feature joins: by name, else the one under the point. fn body_under(st: &ModelState, body: &Option, p: Vec3) -> Result { let i = body_at(st, body, &[p])?; let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("there is no body".into()))?; let tolerance = (b.body.size() * 1e-5).max(1e-4); if crate::project::nearest_face(&b.mesh(), p).is_none_or(|(_, _, distance)| distance > tolerance) { return Err(DocError::Invalid("the plastic feature's placement is no longer on its body face".into())); } Ok(b.name.clone()) } /// Axes with z along `z` (and x along `x` when given). fn local_frame(z: Vec3, x: Option) -> Result<(Vec3, Vec3, Vec3)> { let z = z.normalized().ok_or_else(|| DocError::Invalid("direction".into()))?; let x = x.and_then(|x| (x - z * x.dot(z)).normalized()).unwrap_or_else(|| z.any_perp()); let y = z.cross(x); Ok((x, y, z)) } /// Offset a closed polygon to its left by `d` (mitred corners). fn offset_left(p: &[Vec2], d: f64) -> Vec { let m = p.len(); let line = |i: usize| -> Option<(Vec2, Vec2)> { let (a, b) = (*p.get(i)?, *p.get((i + 1) % m)?); let t = (b - a).normalized()?; Some((a + Vec2::new(-t.y, t.x) * d, t)) }; (0..m) .filter_map(|i| { let (p0, d0) = line((i + m - 1) % m)?; let (p1, d1) = line(i)?; let den = d0.cross(d1); if den.abs() < 1e-12 { return Some(p1); } Some(p0 + d0 * ((p1 - p0).cross(d1) / den)) }) .collect() } pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { let len = |e: &str| val(vals, e, Kind::Length); // The plastic rule of the body a feature goes on gives its defaults (draft, clearance). let rule = |target: &str| -> Result> { doc.plastic_rule_for(target).map(|r| doc.plastic_values(vals, &r)).transpose() }; // Tools start a little inside the material so they overlap it instead of touching a face. match &f.kind { FeatureKind::Boss { position, direction, diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, body, } => { let target = body_under(st, body, *position)?; let (r0, h) = (len(diameter)? / 2.0, len(height)?); if !(r0 > 0.0 && h > 0.0) { return Err(DocError::Invalid("the boss diameter and height must be positive".into())); } let dr = match draft { Some(e) => val(vals, e, Kind::Angle)?, None => rule(&target)?.map_or(0.0, |r| r.draft), }; let rf = match fillet { Some(e) => len(e)?.max(0.0), None => 0.0, }; let r_top = r0 - h * dr.tan(); if r_top <= 0.0 { return Err(DocError::Invalid("the draft closes the boss before its top".into())); } let delta = (h * 0.05).clamp(1e-3, 0.5); let (x, _, z) = local_frame(*direction, None)?; // The post: a (tapered) cylinder from a little inside the face. let base = Plane::new(*position - z * delta, x, z.cross(x)).ok_or_else(|| DocError::Invalid("boss frame".into()))?; let r_in = r0 + delta * dr.tan(); let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, r_in), holes: Vec::new() }; let post = if dr.abs() > 1e-12 { vec![kernel::extrude_tapered(&base, &disc, h + delta, 1.0, -dr)?] } else { kernel::extrude(&base, &[disc], 0.0, h + delta)? }; let mut tools: Vec = post.into_iter().map(|b| crate::lineage::part_tool(b, "post")).collect(); if let Some(n) = ribs { let n = val(vals, n, Kind::Unitless)?.round(); if !(0.0..=64.0).contains(&n) { return Err(DocError::Invalid("0…64 ribs".into())); } let t = match rib_thickness { Some(e) => len(e)?, None => r0 * 0.3, }; let l = match rib_length { Some(e) => len(e)?, None => r0, }; let off = match rib_offset { Some(e) => len(e)?, None => 0.0, }; let hr = h - off; if n > 0.0 && !(t > 0.0 && l > 0.0 && hr > 0.0) { return Err(DocError::Invalid("ribs need a positive thickness, length and height".into())); } for k in 0..n as usize { let a = std::f64::consts::TAU * k as f64 / n; let (u, v) = (x * a.cos() + z.cross(x) * a.sin(), z.cross(x) * a.cos() - x * a.sin()); let rp = Plane::new(*position, u, v).ok_or_else(|| DocError::Invalid("rib frame".into()))?; let rect = Region2 { outer: Loop2::polygon(&[ Vec2::new(r0 * 0.5, -t / 2.0), Vec2::new(r0 + l, -t / 2.0), Vec2::new(r0 + l, t / 2.0), Vec2::new(r0 * 0.5, t / 2.0), ]), holes: Vec::new(), }; tools.extend(kernel::extrude(&rp, &[rect], -delta, hr)?.into_iter().map(|b| crate::lineage::part_tool(b, &format!("rib{k}")))); } } apply_traced(st, f, tools, Operation::Join, std::slice::from_ref(&target))?; // The root fillet: rounds the circle where the post meets the face. if rf > 1e-9 { let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Invalid("boss body".into()))?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("boss body".into())) }; let mut warn = None; let edges = resolve_edges(&mb.body, &[*position + x * r0], &[], &mut warn)?; let nb = mb.traced().then(kernel::fillet(&mb.body, &edges, rf)?); if let Some(slot) = st.bodies.get_mut(i) { *slot = ModelBody::traced_new(mb.name, nb, mb.feature); } } if let Some(hd) = hole_diameter { let rh = len(hd)? / 2.0; let depth = match hole_depth { Some(e) => len(e)?, None => h, }; if !(rh > 0.0 && rh < r_top && depth > 0.0) { return Err(DocError::Invalid("the boss hole must fit inside the boss".into())); } let hp = Plane::new(*position, x, z.cross(x)).ok_or_else(|| DocError::Invalid("hole frame".into()))?; let tool = kernel::extrude(&hp, &[Region2 { outer: Loop2::circle(Vec2::ZERO, rh), holes: Vec::new() }], h - depth, h + delta)?; let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "hole")).collect(); apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?; } Ok(()) } FeatureKind::Lip { face, width, height, groove, gap, outside, body } => { let target = body_under(st, body, *face)?; let mb = st.body(&target).cloned().ok_or_else(|| DocError::Invalid("body".into()))?; let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick the planar rim face".into()))?; let (w, h) = (len(width)?, len(height)?); let g = match gap { Some(e) => len(e)?, None if *groove => rule(&target)?.map_or(0.0, |r| r.clearance), None => 0.0, }; if !(w > 0.0 && h > 0.0 && g >= 0.0) { return Err(DocError::Invalid("the lip width and height must be positive".into())); } let plane = Plane::from_normal(*face, n).ok_or_else(|| DocError::Invalid("rim plane".into()))?; let tol = (mb.body.size() * 1e-3).max(1e-3); let mesh = mb.body.tessellate(tol)?; let mut loops = face_loops(&mesh, &plane, *face, n, 0.0, tol); loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs())); let ccw = |l: &[Vec2]| if poly_area(l) < 0.0 { l.iter().rev().copied().collect::>() } else { l.to_vec() }; // The inner edge is an opening (the cavity under a rim): not the foot of a boss or a // wall standing on the face, whose side the band would run against. let opening = |l: &[Vec2]| { let eps = (tol * 4.0).max(1e-3); l.iter().zip(l.iter().cycle().skip(1)).filter_map(|(a, b)| Some(((*a + *b) * 0.5, (*b - *a).normalized()?))).take(3).any(|(m, t)| { // Just over the face, inside the loop (to the left of a ccw loop's edges). let q = plane.to_world(m + Vec2::new(-t.y, t.x) * eps) + n * eps; !mesh.contains(q) }) }; let outer = loops.first().map(|l| ccw(l)); let openings: Vec> = loops.iter().skip(1).map(|l| ccw(l)).filter(|l| opening(l)).collect(); // A rim runs round one opening, the cavity; smaller ones (a screw hole) may sit in it. // Openings alike in size (a plate's holes) leave no edge that is the rim's. let alike = match openings.as_slice() { [a, b, ..] => poly_area(b).abs() > 0.5 * poly_area(a).abs(), _ => false, }; let (Some(outer), Some(inner), false) = (outer, openings.first().cloned(), alike) else { let what = match (loops.len(), openings.len()) { (0 | 1, _) => "pick the rim face of a shelled body (a face with an inner and an outer edge)".to_string(), (_, 0) => "pick the rim face of a shelled body: this face's inner edges are where other features stand on it, not an opening" .to_string(), (_, k) => format!("pick the rim face of a shelled body (round one opening): this face has {k} openings alike in size"), }; return Err(DocError::Invalid(what)); }; let bw = if *groove { w + g } else { w }; // A lip: the band raised from the rim's own faces (its walls continue the walls // below, which a join can't do: they would coincide). if !*groove && let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside) && let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target) { *slot = ModelBody::traced_new(target.clone(), Traced::step(b, &[&mb.traced()]), slot.feature); return Ok(()); } // The band along the chosen edge, inside the rim face. A groove's cutter reaches past // the edge into the free side (the cavity, or outside), so no wall coincides. let free = if *groove { bw } else { 0.0 }; let band = if *outside { Region2 { outer: Loop2::polygon(&offset_left(&outer, -free)).ccw(), holes: vec![Loop2::polygon(&offset_left(&outer, bw)).ccw().reversed()], } } else { Region2 { outer: Loop2::polygon(&offset_left(&inner, -bw)).ccw(), holes: vec![Loop2::polygon(&offset_left(&inner, free)).ccw().reversed()], } }; let delta = (h * 0.05).clamp(1e-3, 0.5); if *groove { let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?.into_iter().map(|b| crate::lineage::part_tool(b, "groove")).collect(); apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target)) } else { let tool = kernel::extrude(&plane, &[band], -delta, h)?.into_iter().map(|b| crate::lineage::part_tool(b, "lip")).collect(); apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target)) } } FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => { let target = body_under(st, body, *position)?; let (l, t, w, cd, cl) = (len(length)?, len(thickness)?, len(width)?, len(catch_depth)?, len(catch_length)?); if !(l > 0.0 && t > 0.0 && w > 0.0 && cd > 0.0 && cl > 0.0 && cl < l) { return Err(DocError::Invalid("the snap fit's sizes must be positive and the catch shorter than the arm".into())); } let (x, y, z) = local_frame(*direction, Some(*hook))?; let delta = (l * 0.05).clamp(1e-3, 0.5); // Side profile (x = toward the catch, y = up the arm), extruded across the width. let pts = [Vec2::new(-t, -delta), Vec2::new(0.0, -delta), Vec2::new(0.0, l - cl), Vec2::new(cd, l - cl), Vec2::new(0.0, l), Vec2::new(-t, l)]; let o = *position - y * (w / 2.0); let pl = Plane::new(o, x, z).ok_or_else(|| DocError::Invalid("snap fit frame".into()))?; let mut lp = Loop2::polygon(&pts); if lp.signed_area() < 0.0 { lp = lp.reversed(); } let sgn = if pl.normal().dot(y) >= 0.0 { 1.0 } else { -1.0 }; let (lo, hi) = if sgn > 0.0 { (0.0, w) } else { (-w, 0.0) }; let tool = kernel::extrude(&pl, &[Region2 { outer: lp, holes: Vec::new() }], lo, hi)?; let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "arm")).collect(); apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target)) } FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => { let target = body_under(st, body, *position)?; let (w, h) = (len(width)?, len(height)?); let l = length.as_deref().map(len).transpose()?; let dr = match draft { Some(e) => val(vals, e, Kind::Angle)?, None => rule(&target)?.map_or(0.0, |r| r.draft), }; let t = thickness.as_deref().map(len).transpose()?; if !(w > 0.0 && h > 0.0 && l.is_none_or(|l| l > 0.0) && t.is_none_or(|t| t > 0.0 && 2.0 * t < w.min(l.unwrap_or(w)))) { return Err(DocError::Invalid("the rest's sizes must be positive and its wall thinner than half its width".into())); } if !(dr.abs() < 1.0) { return Err(DocError::Invalid("the rest's draft must be under 57°".into())); } let shrink = h * dr.tan(); let closes = match t { Some(t) => 2.0 * shrink >= t, None => 2.0 * shrink >= w.min(l.unwrap_or(w)), }; if closes { return Err(DocError::Invalid("the draft closes the rest before its top".into())); } let delta = (h * 0.05).clamp(1e-3, 0.5); let (x, y, z) = local_frame(*direction, *along)?; // Sized at the face: the tool starts a little inside, grown by the draft over that. let e = delta * dr.tan(); let shape = |grow: f64| match l { Some(l) => Loop2::polygon(&[ Vec2::new(-l / 2.0 - grow, -w / 2.0 - grow), Vec2::new(l / 2.0 + grow, -w / 2.0 - grow), Vec2::new(l / 2.0 + grow, w / 2.0 + grow), Vec2::new(-l / 2.0 - grow, w / 2.0 + grow), ]), None => Loop2::circle(Vec2::ZERO, w / 2.0 + grow), }; let holes = match t { Some(t) => vec![shape(-t - e).reversed()], None => Vec::new(), }; let region = Region2 { outer: shape(e), holes }; let base = Plane::new(*position - z * delta, x, y).ok_or_else(|| DocError::Invalid("rest frame".into()))?; let tool = if dr.abs() > 1e-12 { vec![kernel::extrude_tapered(&base, ®ion, h + delta, 1.0, -dr)?] } else { kernel::extrude(&base, &[region], 0.0, h + delta)? }; let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "rest")).collect(); apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target)) } _ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))), } } /// Edge picks of a sheet feature, moved onto their named edges (a name that is gone keeps the /// point, with a warning). fn sheet_picks(sh: &crate::sheet::SheetBody, f: &Feature, edges: &[Vec3], warning: &mut Option) -> Vec { let mut gone = Vec::new(); let out = edges .iter() .enumerate() .map(|(i, p)| match f.edge_names.get(i).filter(|n| !n.is_empty()) { Some(n) => sh.edge_by_name(n, *p).unwrap_or_else(|| { gone.push(n.clone()); *p }), None => *p, }) .collect(); if !gone.is_empty() { *warning = Some(format!("sheet edge(s) {} no longer exist; using the edges at the picked points", gone.join(", "))); } out } /// Tag the flanges a feature just added (`F.`). fn tag_flanges(sh: &mut crate::sheet::SheetBody, from: usize, feature: u64) { for (k, fl) in sh.flanges.iter_mut().enumerate().skip(from) { fl.tag = format!("F{feature}.{}", k - from); } }