Plastic: Rest (round or rectangular pad, solid or hollow wall, draft) and Plastic Rules (the six-rule library, design rules from expressions using Thickness, active rule, rules assigned to bodies; a body's rule gives Boss and Rest their draft and grooves their clearance), plastic.rules; docs/oracle.md lists the plastic features as no oracle (licence) with the analytic tests that cover them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
1db841aa76
commit
66df249c0f
10 files changed
+564
-8
No files matched your search
@@ -1,14 +1,15 @@
|
||||
//! Plastic (enclosure) features: Boss, Lip / Groove and Snap Fit.
|
||||
//! Plastic (enclosure) features: Boss, Lip / Groove, Snap Fit and Rest, and plastic rules.
|
||||
|
||||
use serde_json::Value;
|
||||
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_, vec3};
|
||||
use crate::{Result, Session};
|
||||
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(
|
||||
@@ -21,8 +22,156 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
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::<serde_json::Map<_, _>>(),
|
||||
"values": values.unwrap_or_else(|e| json!({"error": e.to_string()})),
|
||||
})
|
||||
}
|
||||
|
||||
fn manage_rules(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let rules: Vec<Value> = 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<Value> {
|
||||
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<Option<String>> {
|
||||
match expr(p, k) {
|
||||
Some(e) => {
|
||||
@@ -202,4 +351,77 @@ mod tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user