From 4adfd69d6ad301344ce132a18c17b08a674fade1 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 04:36:18 +0000 Subject: [PATCH] Plastic: Boss (draft, root fillet, hole, ribs), Lip and Groove along a shelled body's rim (inside or outside edge, groove clearance), Snap Fit (cantilever arm with a catch) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/assembly.rs | 10 + crates/doc/src/document.rs | 84 ++++++++ crates/doc/src/eval.rs | 1 + crates/doc/src/eval_more.rs | 316 ++++++++++++++++++++++++++----- crates/doc/src/params.rs | 32 ++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/plastic.rs | 205 ++++++++++++++++++++ 7 files changed, 600 insertions(+), 50 deletions(-) create mode 100644 crates/engine/src/cmd/plastic.rs diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 4863db69..df6d4cb5 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -189,6 +189,16 @@ impl FeatureKind { FeatureKind::Thread { face, .. } => pt(face), FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt), FeatureKind::SheetConvert { face, .. } => pt(face), + FeatureKind::Boss { position, direction, .. } => { + pt(position); + dir(direction); + } + FeatureKind::Lip { face, .. } => pt(face), + FeatureKind::SnapFit { position, direction, hook, .. } => { + pt(position); + dir(direction); + dir(hook); + } FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {} FeatureKind::Coil { base, axis, .. } => { pt(base); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 90fa1533..3d2db965 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -470,6 +470,69 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] rule: Option, }, + /// Plastic boss: a round post on a face (draft, root fillet, hole, ribs). + Boss { + position: Vec3, + /// Out of the face (the boss grows along it). + #[serde(default = "z_axis")] + direction: Vec3, + diameter: String, + height: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + hole_diameter: Option, + /// Hole depth from the top (default: down to the face). + #[serde(default, skip_serializing_if = "Option::is_none")] + hole_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + draft: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fillet: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ribs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + rib_thickness: Option, + /// Rib reach out from the boss wall. + #[serde(default, skip_serializing_if = "Option::is_none")] + rib_length: Option, + /// Rib top below the boss top. + #[serde(default, skip_serializing_if = "Option::is_none")] + rib_offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, + /// Plastic lip (raised band) or groove (recess) along the rim face picked at `face`. + Lip { + face: Vec3, + width: String, + height: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + groove: bool, + /// Clearance added to a groove's width (and depth). + #[serde(default, skip_serializing_if = "Option::is_none")] + gap: Option, + /// Along the rim's outer edge instead of its inner one. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + outside: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, + /// Plastic snap fit: a cantilever arm on a face with a hook (catch) at its tip. + SnapFit { + position: Vec3, + /// The arm grows along this (out of the face). + #[serde(default = "z_axis")] + direction: Vec3, + /// The catch sticks out this way. + hook: Vec3, + length: String, + thickness: String, + width: String, + catch_depth: String, + /// The catch's height along the arm. + catch_length: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, /// Remove bodies from the model (from here on in the timeline). Remove { bodies: Vec, @@ -683,6 +746,10 @@ impl FeatureKind { FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature", FeatureKind::Pipe { .. } => "PipeFeature", FeatureKind::Emboss { .. } => "EmbossFeature", + FeatureKind::Boss { .. } => "BossFeature", + FeatureKind::Lip { groove: false, .. } => "LipFeature", + FeatureKind::Lip { .. } => "GrooveFeature", + FeatureKind::SnapFit { .. } => "SnapFitFeature", FeatureKind::SheetBase { .. } => "BaseFlangeFeature", FeatureKind::SheetContour { .. } => "ContourFlangeFeature", FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature", @@ -731,6 +798,10 @@ impl FeatureKind { FeatureKind::BoundingSolid { .. } => "BoundingSolid", FeatureKind::Pipe { .. } => "Pipe", FeatureKind::Emboss { .. } => "Emboss", + FeatureKind::Boss { .. } => "Boss", + FeatureKind::Lip { groove: false, .. } => "Lip", + FeatureKind::Lip { .. } => "Groove", + FeatureKind::SnapFit { .. } => "SnapFit", FeatureKind::SheetBase { .. } => "BaseFlange", FeatureKind::SheetContour { .. } => "ContourFlange", FeatureKind::SheetFlange { .. } => "EdgeFlange", @@ -819,6 +890,19 @@ impl FeatureKind { } } FeatureKind::Emboss { depth, .. } => v.push(depth), + FeatureKind::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, .. } => { + v.extend([diameter.as_str(), height]); + for o in [hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset].into_iter().flatten() { + v.push(o); + } + } + FeatureKind::Lip { width, height, gap, .. } => { + v.extend([width.as_str(), height]); + v.extend(gap.iter().map(String::as_str)); + } + FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => { + v.extend([length.as_str(), thickness, width, catch_depth, catch_length]); + } FeatureKind::SheetContour { distance, .. } => v.push(distance), FeatureKind::SheetFlange { height, angle, radius, .. } => { v.extend([height.as_str(), angle]); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 2b6253b0..f30ff789 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1393,6 +1393,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: | FeatureKind::SheetHem { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st), + FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } => more::plastic_eval(vals, f, st), FeatureKind::Emboss { .. } | FeatureKind::Rib { .. } | FeatureKind::ReplaceFace { .. } diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 84e71446..86fca12c 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -391,56 +391,7 @@ pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap, f: &Fea let t = hi - lo; // The face outline: boundary edges of the triangles on that face's plane. let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?; - let on: Vec<[u32; 3]> = mesh - .triangles - .iter() - .copied() - .filter(|t| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - *face).dot(n) - lo).abs() < tol))) - .collect(); - // 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 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 { - let (ka, kb) = (q(*a), q(*b)); - *count.entry((ka.min(kb), ka.max(kb))).or_insert(0) += 1; - } - let mut boundary: Vec<(Vec3, Vec3)> = edges - .into_iter() - .filter(|(a, b)| { - let (ka, kb) = (q(*a), q(*b)); - count.get(&(ka.min(kb), ka.max(kb))) == 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]; - for _ in 0..boundary.len() + 1 { - if q(cur) == q(s0) { - break; - } - lp.push(cur); - match boundary.iter().position(|(a, _)| q(*a) == q(cur)) { - Some(k) => cur = boundary.remove(k).1, - None => break, - } - } - let raw: Vec = lp.iter().map(|p| plane.to_local(*p)).collect(); - // Drop points in the middle of straight runs. - let m = raw.len(); - let pts: Vec = (0..m) - .filter_map(|i| { - let (p, c, n) = (*raw.get((i + m - 1) % m)?, *raw.get(i)?, *raw.get((i + 1) % m)?); - ((c - p).cross(n - c).abs() > 1e-9 * (c - p).len().max(1e-9) * (n - c).len().max(1e-9)).then_some(c) - }) - .collect(); - if pts.len() >= 3 { - loops.push(pts); - } - } + let mut loops = face_loops(&mesh, &plane, *face, n, lo, tol); loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs())); let mut it = loops.into_iter(); let outer = Loop2::polygon(&it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?).ccw(); @@ -544,3 +495,268 @@ fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool { } inside } + +/// 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: &solvecraft_geom::Mesh, plane: &Plane, at: Vec3, n: Vec3, level: f64, tol: f64) -> Vec> { + let on: Vec<[u32; 3]> = mesh + .triangles + .iter() + .copied() + .filter(|t| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - at).dot(n) - level).abs() < tol))) + .collect(); + // 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 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 { + let (ka, kb) = (q(*a), q(*b)); + *count.entry((ka.min(kb), ka.max(kb))).or_insert(0) += 1; + } + let mut boundary: Vec<(Vec3, Vec3)> = edges + .into_iter() + .filter(|(a, b)| { + let (ka, kb) = (q(*a), q(*b)); + count.get(&(ka.min(kb), ka.max(kb))) == 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]; + for _ in 0..boundary.len() + 1 { + if q(cur) == q(s0) { + break; + } + lp.push(cur); + match boundary.iter().position(|(a, _)| q(*a) == q(cur)) { + Some(k) => cur = boundary.remove(k).1, + None => break, + } + } + let raw: Vec = lp.iter().map(|p| plane.to_local(*p)).collect(); + // Drop points in the middle of straight runs. + let m = raw.len(); + let pts: Vec = (0..m) + .filter_map(|i| { + let (p, c, n) = (*raw.get((i + m - 1) % m)?, *raw.get(i)?, *raw.get((i + 1) % m)?); + ((c - p).cross(n - c).abs() > 1e-9 * (c - p).len().max(1e-9) * (n - c).len().max(1e-9)).then_some(c) + }) + .collect(); + if pts.len() >= 3 { + loops.push(pts); + } + } + loops +} + +/// 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])?; + st.bodies.get(i).map(|b| b.name.clone()).ok_or_else(|| DocError::Invalid("there is no body".into())) +} + +/// 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(vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { + let len = |e: &str| val(vals, e, Kind::Length); + // 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 => 0.0, + }; + 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 mut tools = 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)? + }; + 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)?); + } + } + apply_op(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 = kernel::fillet(&mb.body, &edges, rf)?; + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::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)?; + apply_op(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 => 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() }; + let (Some(outer), Some(inner)) = (loops.first().map(|l| ccw(l)), loops.get(1).map(|l| ccw(l))) else { + return Err(DocError::Invalid("pick the rim face of a shelled body (a face with an inner and an outer edge)".into())); + }; + let bw = if *groove { w + g } else { w }; + // The band along the chosen edge, inside the rim face. + let band = if *outside { + Region2 { outer: Loop2::polygon(&outer).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(&inner).ccw().reversed()] } + }; + let delta = (h * 0.05).clamp(1e-3, 0.5); + if *groove { + let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?; + apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target)) + } else { + let tool = kernel::extrude(&plane, &[band], -delta, h)?; + apply_op(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)?; + apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target)) + } + _ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))), + } +} diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 046ec099..b7d8a0ce 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -138,6 +138,38 @@ impl FeatureKind { } } FeatureKind::Emboss { depth, .. } => v.push(("Depth", depth, L)), + FeatureKind::Boss { diameter, height, hole_diameter, hole_depth, draft, fillet, ribs, rib_thickness, rib_length, rib_offset, .. } => { + v.push(("Diameter", diameter, L)); + v.push(("Height", height, L)); + for (l, o, k) in [ + ("Hole diameter", hole_diameter, L), + ("Hole depth", hole_depth, L), + ("Draft angle", draft, A), + ("Root fillet", fillet, L), + ("Ribs", ribs, U), + ("Rib thickness", rib_thickness, L), + ("Rib length", rib_length, L), + ("Rib offset", rib_offset, L), + ] { + if let Some(e) = o { + v.push((l, e, k)); + } + } + } + FeatureKind::Lip { width, height, gap, .. } => { + v.push(("Width", width, L)); + v.push(("Height", height, L)); + if let Some(g) = gap { + v.push(("Gap", g, L)); + } + } + FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => { + v.push(("Length", length, L)); + v.push(("Thickness", thickness, L)); + v.push(("Width", width, L)); + v.push(("Catch depth", catch_depth, L)); + v.push(("Catch length", catch_length, L)); + } FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)), FeatureKind::SheetFlange { height, angle, radius, .. } => { v.push(("Height", height, L)); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 0485bd24..04cd3302 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ mod measure_sel; mod section; pub(crate) use measure_sel::measure_items; mod parameters; +mod plastic; mod sheet; mod sketch; mod sketch_constraints; @@ -131,6 +132,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(features::COMMANDS.iter()); v.extend(features_more::COMMANDS.iter()); v.extend(sheet::COMMANDS.iter()); + v.extend(plastic::COMMANDS.iter()); v.extend(component::COMMANDS.iter()); v.extend(joints::COMMANDS.iter()); v.extend(browser::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/plastic.rs b/crates/engine/src/cmd/plastic.rs new file mode 100644 index 00000000..28aa1e12 --- /dev/null +++ b/crates/engine/src/cmd/plastic.rs @@ -0,0 +1,205 @@ +//! Plastic (enclosure) features: Boss, Lip / Groove and Snap Fit. + +use serde_json::Value; +use solvecraft_doc::FeatureKind; +use solvecraft_doc::expr::Kind; +use solvecraft_geom::Vec3; + +use super::CommandSpec; +use super::features::{add_feature, check_expr}; +use crate::params::{bad, bool_, expr, req_expr, str_, vec3}; +use crate::{Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("FusionBossCommand", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params( + "position: [x,y,z] on a face; direction? (default: out of the face); diameter, height; hole_diameter?, hole_depth? (default: to the face); \ + draft? (angle), fillet? (root radius); ribs? (count), rib_thickness?, rib_length? (out from the wall), rib_offset? (below the top); body?", + ), + CommandSpec::new("FusionLipCommand", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params( + "face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?", + ), + CommandSpec::new("FusionSnapFitCommand", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap").params( + "position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; body?", + ), +]; + +fn checked(s: &Session, p: &Value, k: &str, kind: Kind, cmd: &str) -> Result> { + match expr(p, k) { + Some(e) => { + check_expr(s, &e, kind, cmd, k)?; + Ok(Some(e)) + } + None => Ok(None), + } +} + +fn required(s: &Session, p: &Value, k: &str, kind: Kind, cmd: &str) -> Result { + let e = req_expr(cmd, p, k)?; + check_expr(s, &e, kind, cmd, k)?; + Ok(e) +} + +/// A point on a face and the direction out of it. +fn placed(s: &Session, p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> { + let at = p.get("position").and_then(vec3).ok_or_else(|| bad(cmd, "`position` must be a point [x, y, z] on a face"))?; + let dir = match p.get("direction") { + Some(v) => vec3(v).and_then(|d| d.normalized()).ok_or_else(|| bad(cmd, "`direction` must be a non-zero [x, y, z]"))?, + None => super::face::face_normal(s, at).ok_or_else(|| bad(cmd, "no planar face there (give `direction`)"))?, + }; + Ok((at, dir)) +} + +fn boss(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionBossCommand"; + let (position, direction) = placed(s, p, cmd)?; + let kind = FeatureKind::Boss { + position, + direction, + diameter: required(s, p, "diameter", Kind::Length, cmd)?, + height: required(s, p, "height", Kind::Length, cmd)?, + hole_diameter: checked(s, p, "hole_diameter", Kind::Length, cmd)?, + hole_depth: checked(s, p, "hole_depth", Kind::Length, cmd)?, + draft: checked(s, p, "draft", Kind::Angle, cmd)?, + fillet: checked(s, p, "fillet", Kind::Length, cmd)?, + ribs: checked(s, p, "ribs", Kind::Unitless, cmd)?, + rib_thickness: checked(s, p, "rib_thickness", Kind::Length, cmd)?, + rib_length: checked(s, p, "rib_length", Kind::Length, cmd)?, + rib_offset: checked(s, p, "rib_offset", Kind::Length, cmd)?, + body: str_(p, "body").map(str::to_string), + }; + add_feature(s, p, kind) +} + +fn lip(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionLipCommand"; + let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the rim face"))?; + let groove = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() { + None | Some("lip") => false, + Some("groove") => true, + Some(o) => return Err(bad(cmd, format!("unknown type `{o}` (lip or groove)"))), + }; + let outside = match str_(p, "side").map(str::to_ascii_lowercase).as_deref() { + None | Some("inside") => false, + Some("outside") => true, + Some(o) => return Err(bad(cmd, format!("unknown side `{o}` (inside or outside)"))), + }; + let kind = FeatureKind::Lip { + face, + width: required(s, p, "width", Kind::Length, cmd)?, + height: required(s, p, "height", Kind::Length, cmd)?, + groove, + gap: checked(s, p, "gap", Kind::Length, cmd)?, + outside: outside || bool_(p, "outside").unwrap_or(false), + body: str_(p, "body").map(str::to_string), + }; + add_feature(s, p, kind) +} + +fn snap_fit(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionSnapFitCommand"; + let (position, direction) = placed(s, p, cmd)?; + let hook = p.get("hook").and_then(vec3).and_then(|h| h.normalized()).ok_or_else(|| bad(cmd, "`hook` must be the catch's direction [x, y, z]"))?; + if hook.cross(direction).len() < 1e-6 { + return Err(bad(cmd, "`hook` must point across the arm")); + } + let kind = FeatureKind::SnapFit { + position, + direction, + hook, + length: required(s, p, "length", Kind::Length, cmd)?, + thickness: required(s, p, "thickness", Kind::Length, cmd)?, + width: required(s, p, "width", Kind::Length, cmd)?, + catch_depth: required(s, p, "catch_depth", Kind::Length, cmd)?, + catch_length: required(s, p, "catch_length", Kind::Length, cmd)?, + body: str_(p, "body").map(str::to_string), + }; + add_feature(s, p, kind) +} + +#[cfg(test)] +mod tests { + use std::f64::consts::PI; + + use serde_json::{Value, json}; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } + } + + fn volume(s: &mut Session) -> f64 { + run(s, "MeasureCommand", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN) + } + + fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) + } + + #[test] + fn boss_with_hole_fillet_and_ribs() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3, "body_name": "Plate"})); + run(&mut s, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 4})); + let want = 10800.0 + PI * 25.0 * 12.0 - PI * 4.0 * 12.0; + assert!(rel(volume(&mut s), want) < 1e-3, "{} {want}", volume(&mut s)); + assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 1); + // Ribs add t·l·h each. + let mut t = Session::default(); + run(&mut t, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3})); + run( + &mut t, + "FusionBossCommand", + json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "ribs": 4, "rib_thickness": 1.5, "rib_length": 6, "rib_offset": 2}), + ); + let ribs = 4.0 * 1.5 * 6.0 * 10.0; + // Each rib also fills a sliver between its flat inner end and the round wall. + let v = volume(&mut t) - 10800.0 - PI * 25.0 * 12.0; + assert!(v > ribs && v < ribs * 1.05, "{v} {ribs}"); + let mut u = Session::default(); + run(&mut u, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3})); + run(&mut u, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "fillet": 2, "draft": 1})); + assert!(volume(&mut u) > 10800.0 + PI * 25.0 * 10.0); + assert!(u.execute("FusionBossCommand", &json!({"position": [30, 30, 3], "diameter": 10, "height": 12, "hole_diameter": 20})).is_err()); + } + + #[test] + fn lip_and_groove_on_a_shelled_box() { + let shelled = || { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 80, "width": 50, "height": 30})); + run(&mut s, "FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2})); + s + }; + let mut s = shelled(); + let v0 = volume(&mut s); + run(&mut s, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2})); + // Inside band 1 wide along the inner edge (76 x 46): (76+2)(46+2) − 76·46 = 248, 2 high. + assert!(rel(volume(&mut s) - v0, 248.0 * 2.0) < 1e-6, "{}", volume(&mut s) - v0); + let mut g = shelled(); + run(&mut g, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"})); + // Outside band along the outer edge (80 x 50): 80·50 − 78·48 = 256, 2 deep. + assert!(rel(v0 - volume(&mut g), 256.0 * 2.0) < 1e-6, "{}", v0 - volume(&mut g)); + assert!(g.execute("FusionLipCommand", &json!({"face": [40, 25, 0], "width": 1, "height": 2})).is_err(), "a face without an inner edge"); + } + + #[test] + fn snap_fit_arm_and_catch() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 40, "height": 4})); + let v0 = volume(&mut s); + run( + &mut s, + "FusionSnapFitCommand", + json!({"position": [20, 20, 4], "hook": [1, 0, 0], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3}), + ); + let want = 1.5 * 15.0 * 6.0 + 0.5 * 1.0 * 3.0 * 6.0; + assert!(rel(volume(&mut s) - v0, want) < 1e-6, "{}", volume(&mut s) - v0); + let m = run(&mut s, "MeasureCommand", json!({})); + assert!((m["bodies"][0]["bbox"]["max"][0].as_f64().unwrap_or(0.0) - 40.0).abs() < 1e-9); + assert!(s.execute("FusionSnapFitCommand", &json!({"position": [5, 5, 4], "direction": [0, 0, 1], "hook": [0, 0, 1], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})).is_err()); + } +}