//! Plastic (enclosure) features: Boss, Lip / Groove, Snap Fit and Rest, and plastic rules. use serde_json::{Value, json}; use solvecraft_doc::FeatureKind; use solvecraft_doc::expr::Kind; use solvecraft_doc::plastic::PlasticRule; use solvecraft_geom::Vec3; use super::CommandSpec; use super::features::{add_feature, check_expr}; use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3}; use crate::{EngineError, 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?", ), CommandSpec::new("FusionRestCommand", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params( "position: [x,y,z] on a face (the rest's centre); direction? (default: out of the face); width (round: the diameter); length? (a rectangle, along `along`); along?: [x,y,z]; \ height; draft? (angle; default: the body's plastic rule); thickness? (wall of a hollow rest); body?", ), CommandSpec::new("FusionManagePlasticRuleCommand", "Manage Plastic Rules", manage_rules).at("PLASTIC", "SETUP").icon("params").params( "name (library or new rule; a new one starts from `from`, default ABS (1.5mm)); from?; material?; thickness?, nominal_radius?, clearance?, knife_edge?, \ reveal_height?, thickness_variation?, draft?, max_thickness?, min_thickness?, min_draft? (expressions; `Thickness` is the rule's); active?: bool (rule for bodies without one); \ delete?: bool (a design rule; a library rule goes back to the library values)", ), CommandSpec::new("FusionAssignPlasticRuleCommand", "Assign Plastic Rule", assign_rule).at("PLASTIC", "SETUP").icon("params").params( "bodies: [names]; rule: name (empty: no rule) — plastic features on these bodies take its draft and clearance as defaults", ), CommandSpec::new("plastic.rules", "List Plastic Rules", list_rules).noundo().params("→ rules with their expressions and values, the active rule and the rules assigned to bodies"), ]; fn rule_json(s: &Session, r: &PlasticRule) -> Value { let (vals, _) = s.doc.param_values(); let values = s.doc.plastic_values(&vals, r).map(|v| { json!({"thickness": v.thickness, "nominal_radius": v.nominal_radius, "clearance": v.clearance, "knife_edge": v.knife_edge, "reveal_height": v.reveal_height, "thickness_variation": v.thickness_variation, "draft_deg": v.draft.to_degrees(), "max_thickness": v.max_thickness, "min_thickness": v.min_thickness, "min_draft_deg": v.min_draft.to_degrees()}) }); let library = PlasticRule::library().iter().any(|l| l.name == r.name); json!({ "name": r.name, "material": r.material, "library": library, "expressions": r.fields().iter().map(|(k, e, _)| (k.to_string(), json!(e))).collect::>(), "values": values.unwrap_or_else(|e| json!({"error": e.to_string()})), }) } fn manage_rules(s: &mut Session, p: &Value) -> Result { let cmd = "FusionManagePlasticRuleCommand"; let name = str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` is required"))?.to_string(); if bool_(p, "delete").unwrap_or(false) { let doc = s.doc_mut(); let before = doc.plastic.rules.len(); doc.plastic.rules.retain(|r| r.name != name); if doc.plastic.rules.len() == before { return Err(bad(cmd, format!("no design rule `{name}`"))); } if doc.plastic_rule(&name).is_none() { if doc.plastic.active.as_deref() == Some(name.as_str()) { doc.plastic.active = None; } doc.plastic.assigned.retain(|_, r| *r != name); } return Ok(json!({"deleted": name})); } let mut rule = match s.doc.plastic_rule(&name) { Some(r) => r, None => { let from = str_(p, "from").unwrap_or("ABS (1.5mm)"); let base = s.doc.plastic_rule(from).ok_or_else(|| bad(cmd, format!("no plastic rule `{from}` to start from")))?; PlasticRule { name: name.clone(), ..base } } }; if let Some(m) = str_(p, "material") { rule.material = m.chars().take(128).collect(); } for (k, _, kind) in PlasticRule::library()[0].fields() { if let Some(e) = expr(p, k) { // `Thickness` is the rule's own: check the others with it standing in. let probe = e.replace("Thickness", "(1 mm)"); check_expr(s, &probe, kind, cmd, k)?; if let Some(slot) = rule.field_mut(k) { *slot = e; } } } let (vals, _) = s.doc.param_values(); s.doc.plastic_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?; let changed = s.doc.plastic_rule(&name).as_ref() != Some(&rule); let doc = s.doc_mut(); if changed { match doc.plastic.rules.iter_mut().find(|r| r.name == name) { Some(r) => *r = rule.clone(), None => { if doc.plastic.rules.len() >= 1000 { return Err(EngineError::Other("too many rules".into())); } doc.plastic.rules.push(rule.clone()); } } } match bool_(p, "active") { Some(true) => doc.plastic.active = Some(name.clone()), Some(false) if doc.plastic.active.as_deref() == Some(name.as_str()) => doc.plastic.active = None, _ => {} } s.refresh(); Ok(json!({"rule": rule_json(s, &rule), "active": s.doc.plastic.active})) } fn assign_rule(s: &mut Session, p: &Value) -> Result { let cmd = "FusionAssignPlasticRuleCommand"; let bodies = string_list(p, "bodies"); if bodies.is_empty() || bodies.len() > 10_000 { return Err(bad(cmd, "`bodies` must list bodies")); } let st = s.model.state(); if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) { return Err(bad(cmd, format!("no body `{b}`"))); } let rule = str_(p, "rule").map(str::trim).unwrap_or_default().to_string(); if !rule.is_empty() && s.doc.plastic_rule(&rule).is_none() { return Err(bad(cmd, format!("no plastic rule `{rule}` (plastic.rules lists them)"))); } let doc = s.doc_mut(); for b in &bodies { if rule.is_empty() { doc.plastic.assigned.remove(b); } else { doc.plastic.assigned.insert(b.clone(), rule.clone()); } } s.refresh(); Ok(json!({"bodies": bodies, "rule": if rule.is_empty() { Value::Null } else { json!(rule) }})) } fn list_rules(s: &mut Session, _p: &Value) -> Result { let rules: Vec = s.doc.plastic_rules().iter().map(|r| rule_json(s, r)).collect(); Ok(json!({"rules": rules, "active": s.doc.plastic.active, "assigned": s.doc.plastic.assigned})) } fn rest(s: &mut Session, p: &Value) -> Result { let cmd = "FusionRestCommand"; let (position, direction) = placed(s, p, cmd)?; let along = match p.get("along") { Some(v) => Some( vec3(v) .and_then(|a| a.normalized()) .filter(|a| a.cross(direction).len() > 1e-6) .ok_or_else(|| bad(cmd, "`along` must be a direction across the face"))?, ), None => None, }; let kind = FeatureKind::Rest { position, direction, along, width: required(s, p, "width", Kind::Length, cmd)?, length: checked(s, p, "length", Kind::Length, cmd)?, height: required(s, p, "height", Kind::Length, cmd)?, draft: checked(s, p, "draft", Kind::Angle, cmd)?, thickness: checked(s, p, "thickness", Kind::Length, cmd)?, body: str_(p, "body").map(str::to_string), }; add_feature(s, p, kind) } 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()); } fn plate() -> (Session, f64) { let mut s = Session::default(); run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 60, "height": 3})); let v0 = volume(&mut s); (s, v0) } #[test] fn rest_pads() { // Rectangle 20 x 10, 4 high: 800. let (mut s, v0) = plate(); run(&mut s, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "along": [1, 0, 0], "height": 4})); assert!(rel(volume(&mut s) - v0, 800.0) < 1e-6, "{}", volume(&mut s) - v0); let m = run(&mut s, "MeasureCommand", json!({})); assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 7.0).abs() < 1e-9); // Round, diameter 10: π·25·4. let (mut r, v0) = plate(); run(&mut r, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "height": 4})); assert!(rel(volume(&mut r) - v0, PI * 25.0 * 4.0) < 1e-3, "{}", volume(&mut r) - v0); // Hollow, wall 1: (20·10 − 18·8)·4 = 224. let (mut h, v0) = plate(); run(&mut h, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "thickness": 1})); assert!(rel(volume(&mut h) - v0, 224.0) < 1e-6, "{}", volume(&mut h) - v0); // Drafted 2°: the section shrinks by 2·s·tan 2° each way at height s. let (mut d, v0) = plate(); run(&mut d, "FusionRestCommand", json!({"position": [30, 30, 3], "width": 10, "length": 20, "height": 4, "draft": "2 deg"})); let a = 2f64.to_radians().tan(); let want = 800.0 - a * 30.0 * 16.0 + 4.0 / 3.0 * a * a * 64.0; assert!(rel(volume(&mut d) - v0, want) < 1e-6, "{} {want}", volume(&mut d) - v0); // A wall thicker than half the width, or a draft that closes the top, is refused. assert!(d.execute("FusionRestCommand", &json!({"position": [10, 10, 3], "width": 4, "height": 4, "thickness": 2})).is_err()); assert!(d.execute("FusionRestCommand", &json!({"position": [10, 10, 3], "width": 4, "height": 40, "draft": 10})).is_err()); } #[test] fn plastic_rules_library_edit_and_assign() { let (mut s, v0) = plate(); let list = run(&mut s, "plastic.rules", json!({})); assert_eq!(list["rules"].as_array().map(Vec::len), Some(6)); assert_eq!(list["rules"][0]["name"], "ABS (1.5mm)"); assert_eq!(list["rules"][0]["values"]["nominal_radius"], 0.75); assert_eq!(list["rules"][3]["values"]["thickness"].as_f64().map(|t| (t - 2.54).abs() < 1e-9), Some(true)); // A new rule from ABS: Thickness-based values follow its thickness. let r = run(&mut s, "FusionManagePlasticRuleCommand", json!({"name": "Thick ABS", "thickness": 3, "draft": "1.5 deg"})); assert_eq!(r["rule"]["values"]["nominal_radius"], 1.5); assert!(s.execute("FusionManagePlasticRuleCommand", &json!({"name": "Bad", "draft": "3 mm"})).is_err()); // Assigned to the plate: a boss without a draft takes the rule's 1.5°. let body = run(&mut s, "MeasureCommand", json!({}))["bodies"][0]["name"].as_str().unwrap_or_default().to_string(); run(&mut s, "FusionAssignPlasticRuleCommand", json!({"bodies": [body], "rule": "Thick ABS"})); run(&mut s, "FusionBossCommand", json!({"position": [30, 30, 3], "diameter": 10, "height": 12})); let rt = 5.0 - 12.0 * 1.5f64.to_radians().tan(); let want = PI * 12.0 / 3.0 * (25.0 + 5.0 * rt + rt * rt); assert!(rel(volume(&mut s) - v0, want) < 1e-3, "{} {want}", volume(&mut s) - v0); // Editing the rule rebuilds the boss. run(&mut s, "FusionManagePlasticRuleCommand", json!({"name": "Thick ABS", "draft": "0 deg"})); assert!(rel(volume(&mut s) - v0, PI * 25.0 * 12.0) < 1e-3, "{}", volume(&mut s) - v0); assert!(s.execute("FusionAssignPlasticRuleCommand", &json!({"bodies": [body], "rule": "Nope"})).is_err()); assert!(s.execute("FusionAssignPlasticRuleCommand", &json!({"bodies": ["Nope"], "rule": "Thick ABS"})).is_err()); } #[test] fn groove_takes_the_rule_clearance() { let mut g = Session::default(); run(&mut g, "PrimitiveBox", json!({"length": 80, "width": 50, "height": 30})); run(&mut g, "FusionShellBodyCommand", json!({"faces": [[40, 25, 30]], "thickness": 2})); let v0 = volume(&mut g); run(&mut g, "FusionManagePlasticRuleCommand", json!({"name": "ABS (1.5mm)", "active": true})); run(&mut g, "FusionLipCommand", json!({"face": [1, 25, 30], "width": 1, "height": 2, "type": "groove", "side": "outside"})); // Width and depth grow by the 0.1 clearance: (80·50 − 77.8·47.8)·2.1. let want = (4000.0 - 77.8 * 47.8) * 2.1; assert!(rel(v0 - volume(&mut g), want) < 1e-6, "{} {want}", v0 - volume(&mut g)); } }