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:
bherbruckandClaude Opus 5.5 committed 2026-10-08 05:50:50 +00:00
1 parent 1db841aa76
commit 66df249c0f
10 files changed
+564 -8

No files matched your search

+226 -4
View File
@@ -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));
}
}