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

+7
View File
@@ -194,6 +194,13 @@ impl FeatureKind {
dir(direction);
}
FeatureKind::Lip { face, .. } => pt(face),
FeatureKind::Rest { position, direction, along, .. } => {
pt(position);
dir(direction);
if let Some(a) = along {
dir(a);
}
}
FeatureKind::SnapFit { position, direction, hook, .. } => {
pt(position);
dir(direction);
+38
View File
@@ -533,6 +533,30 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Plastic rest: a raised pad on a face (round, or a rectangle), solid or a hollow wall,
/// with draft.
Rest {
position: Vec3,
/// Out of the face (the pad grows along it).
#[serde(default = "z_axis")]
direction: Vec3,
/// The rectangle's length runs along this (default: any direction in the face).
#[serde(default, skip_serializing_if = "Option::is_none")]
along: Option<Vec3>,
/// Diameter of a round rest, or the rectangle's width.
width: String,
/// The rectangle's length (none: round).
#[serde(default, skip_serializing_if = "Option::is_none")]
length: Option<String>,
height: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
draft: Option<String>,
/// Wall thickness of a hollow rest (none: solid).
#[serde(default, skip_serializing_if = "Option::is_none")]
thickness: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// Remove bodies from the model (from here on in the timeline).
Remove {
bodies: Vec<String>,
@@ -703,6 +727,10 @@ fn z_axis() -> Vec3 {
impl FeatureKind {
/// A sheet metal feature (its result depends on the sheet metal rules).
pub fn is_plastic(&self) -> bool {
matches!(self, FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. })
}
pub fn is_sheet(&self) -> bool {
matches!(
self,
@@ -750,6 +778,7 @@ impl FeatureKind {
FeatureKind::Lip { groove: false, .. } => "LipFeature",
FeatureKind::Lip { .. } => "GrooveFeature",
FeatureKind::SnapFit { .. } => "SnapFitFeature",
FeatureKind::Rest { .. } => "RestFeature",
FeatureKind::SheetBase { .. } => "BaseFlangeFeature",
FeatureKind::SheetContour { .. } => "ContourFlangeFeature",
FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature",
@@ -802,6 +831,7 @@ impl FeatureKind {
FeatureKind::Lip { groove: false, .. } => "Lip",
FeatureKind::Lip { .. } => "Groove",
FeatureKind::SnapFit { .. } => "SnapFit",
FeatureKind::Rest { .. } => "Rest",
FeatureKind::SheetBase { .. } => "BaseFlange",
FeatureKind::SheetContour { .. } => "ContourFlange",
FeatureKind::SheetFlange { .. } => "EdgeFlange",
@@ -903,6 +933,10 @@ impl FeatureKind {
FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => {
v.extend([length.as_str(), thickness, width, catch_depth, catch_length]);
}
FeatureKind::Rest { width, length, height, draft, thickness, .. } => {
v.extend([width.as_str(), height]);
v.extend([length, draft, thickness].into_iter().flatten().map(String::as_str));
}
FeatureKind::SheetContour { distance, .. } => v.push(distance),
FeatureKind::SheetFlange { height, angle, radius, .. } => {
v.extend([height.as_str(), angle]);
@@ -987,6 +1021,9 @@ pub struct Document {
/// Sheet metal rules.
#[serde(default, skip_serializing_if = "crate::sheet::SheetSettings::is_empty")]
pub sheet: crate::sheet::SheetSettings,
/// Plastic rules.
#[serde(default, skip_serializing_if = "crate::plastic::PlasticSettings::is_empty")]
pub plastic: crate::plastic::PlasticSettings,
/// Favourite parameters (by name).
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
pub favorites: std::collections::BTreeSet<String>,
@@ -1050,6 +1087,7 @@ impl Document {
occurrences: Vec::new(),
assembly: Default::default(),
sheet: Default::default(),
plastic: Default::default(),
body_components: Default::default(),
materials: Default::default(),
favorites: Default::default(),
+17 -1
View File
@@ -304,6 +304,20 @@ impl Model {
}
fp = h.finish();
}
// Plastic features take defaults from the plastic rules.
if f.kind.is_plastic() && !doc.plastic.is_empty() {
let mut h = std::collections::hash_map::DefaultHasher::new();
fp.hash(&mut h);
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
for r in &doc.plastic.rules {
for (_, e, _) in r.fields() {
for n in expr::references(e) {
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
}
}
}
fp = h.finish();
}
prev_fp = fp;
if reuse
&& let Some(old) = self.results.get(i)
@@ -1393,7 +1407,9 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, 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::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. } => {
more::plastic_eval(doc, vals, f, st)
}
FeatureKind::Emboss { .. }
| FeatureKind::Rib { .. }
| FeatureKind::ReplaceFace { .. }
+56 -2
View File
@@ -587,8 +587,12 @@ fn offset_left(p: &[Vec2], d: f64) -> Vec<Vec2> {
.collect()
}
pub(super) fn plastic_eval(vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, 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<Option<crate::plastic::PlasticValues>> {
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 {
@@ -613,7 +617,7 @@ pub(super) fn plastic_eval(vals: &BTreeMap<String, Value>, f: &Feature, st: &mut
}
let dr = match draft {
Some(e) => val(vals, e, Kind::Angle)?,
None => 0.0,
None => rule(&target)?.map_or(0.0, |r| r.draft),
};
let rf = match fillet {
Some(e) => len(e)?.max(0.0),
@@ -705,6 +709,7 @@ pub(super) fn plastic_eval(vals: &BTreeMap<String, Value>, f: &Feature, st: &mut
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) {
@@ -757,6 +762,55 @@ pub(super) fn plastic_eval(vals: &BTreeMap<String, Value>, f: &Feature, st: &mut
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))
}
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, &region, h + delta, 1.0, -dr)?]
} else {
kernel::extrude(&base, &[region], 0.0, h + delta)?
};
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
}
_ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))),
}
}
+1
View File
@@ -13,6 +13,7 @@ mod eval;
pub mod expr;
pub mod joints;
mod params;
pub mod plastic;
pub mod project;
mod project3d;
pub mod sheet;
+9
View File
@@ -163,6 +163,15 @@ impl FeatureKind {
v.push(("Gap", g, L));
}
}
FeatureKind::Rest { width, length, height, draft, thickness, .. } => {
v.push(("Width", width, L));
v.push(("Height", height, L));
for (l, o, k) in [("Length", length, L), ("Draft angle", draft, A), ("Wall thickness", thickness, L)] {
if let Some(e) = o {
v.push((l, e, k));
}
}
}
FeatureKind::SnapFit { length, thickness, width, catch_depth, catch_length, .. } => {
v.push(("Length", length, L));
v.push(("Thickness", thickness, L));
+192
View File
@@ -0,0 +1,192 @@
//! Plastic rules: wall thickness, draft, radii and clearances for molded parts, as expressions
//! (`Thickness` inside them is the rule's thickness). A body assigned a rule (or every body,
//! when a rule is active) takes its draft and clearance as the defaults of plastic features.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::expr::{self, Kind, Value};
use crate::{DocError, Document, Result};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlasticRule {
pub name: String,
#[serde(default)]
pub material: String,
pub thickness: String,
pub nominal_radius: String,
pub clearance: String,
pub knife_edge: String,
pub reveal_height: String,
pub thickness_variation: String,
pub draft: String,
pub max_thickness: String,
pub min_thickness: String,
pub min_draft: String,
}
impl PlasticRule {
fn new(name: &str, material: &str, inch: bool, thickness: &str, max: &str, min: &str) -> PlasticRule {
let (clearance, knife, reveal) = if inch { ("0.004 in", "0.04 in", "0.02 in") } else { ("0.1 mm", "1 mm", "0.5 mm") };
PlasticRule {
name: name.into(),
material: material.into(),
thickness: thickness.into(),
nominal_radius: "0.5 * Thickness".into(),
clearance: clearance.into(),
knife_edge: knife.into(),
reveal_height: reveal.into(),
thickness_variation: "0.15 * Thickness".into(),
draft: "2 deg".into(),
max_thickness: max.into(),
min_thickness: min.into(),
min_draft: "0.5 deg".into(),
}
}
/// The rule library (the first is the default).
pub fn library() -> Vec<PlasticRule> {
vec![
PlasticRule::new("ABS (1.5mm)", "ABS Plastic", false, "1.5 mm", "3.5 mm", "1.2 mm"),
PlasticRule::new("Nylon (PA6) (2.5mm)", "Nylon 6", false, "2.5 mm", "3 mm", "0.8 mm"),
PlasticRule::new("Polypropylene (PP) (3 mm)", "Polypropylene", false, "3 mm", "3.8 mm", "0.8 mm"),
PlasticRule::new("ABS (0.1 in)", "ABS Plastic", true, "0.1 in", "3.5 mm", "1.2 mm"),
PlasticRule::new("Nylon (PA6) (0.1 in)", "Nylon 6", true, "0.1 in", "3 mm", "0.8 mm"),
PlasticRule::new("Polypropylene (PP) (0.1 in)", "Polypropylene", true, "0.1 in", "3.8 mm", "0.8 mm"),
]
}
/// The expressions, by field name (as commands and the dialog name them).
pub fn fields(&self) -> [(&'static str, &String, Kind); 10] {
[
("thickness", &self.thickness, Kind::Length),
("nominal_radius", &self.nominal_radius, Kind::Length),
("clearance", &self.clearance, Kind::Length),
("knife_edge", &self.knife_edge, Kind::Length),
("reveal_height", &self.reveal_height, Kind::Length),
("thickness_variation", &self.thickness_variation, Kind::Length),
("draft", &self.draft, Kind::Angle),
("max_thickness", &self.max_thickness, Kind::Length),
("min_thickness", &self.min_thickness, Kind::Length),
("min_draft", &self.min_draft, Kind::Angle),
]
}
pub fn field_mut(&mut self, name: &str) -> Option<&mut String> {
Some(match name {
"thickness" => &mut self.thickness,
"nominal_radius" => &mut self.nominal_radius,
"clearance" => &mut self.clearance,
"knife_edge" => &mut self.knife_edge,
"reveal_height" => &mut self.reveal_height,
"thickness_variation" => &mut self.thickness_variation,
"draft" => &mut self.draft,
"max_thickness" => &mut self.max_thickness,
"min_thickness" => &mut self.min_thickness,
"min_draft" => &mut self.min_draft,
_ => return None,
})
}
}
/// The design's own plastic rules, the active one and the rules assigned to bodies.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PlasticSettings {
/// Rules made or edited in this design (a library rule edited here is copied in).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<PlasticRule>,
/// Applies to bodies with no rule of their own.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<String>,
/// Body name → rule name.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub assigned: BTreeMap<String, String>,
}
impl PlasticSettings {
pub fn is_empty(&self) -> bool {
self.rules.is_empty() && self.active.is_none() && self.assigned.is_empty()
}
}
/// A rule's values (mm, radians).
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct PlasticValues {
pub thickness: f64,
pub nominal_radius: f64,
pub clearance: f64,
pub knife_edge: f64,
pub reveal_height: f64,
pub thickness_variation: f64,
pub draft: f64,
pub max_thickness: f64,
pub min_thickness: f64,
pub min_draft: f64,
}
impl Document {
/// A rule by name: the design's own first, then the library.
pub fn plastic_rule(&self, name: &str) -> Option<PlasticRule> {
self.plastic.rules.iter().find(|r| r.name == name).cloned().or_else(|| PlasticRule::library().into_iter().find(|r| r.name == name))
}
/// Every rule: the library (as edited in this design) and the design's own.
pub fn plastic_rules(&self) -> Vec<PlasticRule> {
let mut out: Vec<PlasticRule> = PlasticRule::library().into_iter().map(|l| self.plastic_rule(&l.name).unwrap_or(l)).collect();
let own: Vec<PlasticRule> = self.plastic.rules.iter().filter(|r| !out.iter().any(|o| o.name == r.name)).cloned().collect();
out.extend(own);
out
}
/// The rule that applies to a body: its own, else the active one.
pub fn plastic_rule_for(&self, body: &str) -> Option<PlasticRule> {
self.plastic.assigned.get(body).or(self.plastic.active.as_ref()).and_then(|n| self.plastic_rule(n))
}
pub fn plastic_values(&self, vals: &BTreeMap<String, Value>, r: &PlasticRule) -> Result<PlasticValues> {
let look = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")));
let t = expr::eval_with(&r.thickness, &look)?.to_kind(Kind::Length)?;
if !(t > 0.0 && t < 1e4) {
return Err(DocError::Invalid("the plastic thickness must be positive".into()));
}
let with_t = |e: &str, k: Kind| -> Result<f64> {
let f = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(Value::length(t)) } else { look(n) };
let v = expr::eval_with(e, &f)?.to_kind(k)?;
if !v.is_finite() || v < 0.0 {
return Err(DocError::Invalid(format!("plastic rule `{}`: `{e}` must not be negative", r.name)));
}
Ok(v)
};
Ok(PlasticValues {
thickness: t,
nominal_radius: with_t(&r.nominal_radius, Kind::Length)?,
clearance: with_t(&r.clearance, Kind::Length)?,
knife_edge: with_t(&r.knife_edge, Kind::Length)?,
reveal_height: with_t(&r.reveal_height, Kind::Length)?,
thickness_variation: with_t(&r.thickness_variation, Kind::Length)?,
draft: with_t(&r.draft, Kind::Angle)?,
max_thickness: with_t(&r.max_thickness, Kind::Length)?,
min_thickness: with_t(&r.min_thickness, Kind::Length)?,
min_draft: with_t(&r.min_draft, Kind::Angle)?,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn library_values() {
let d = Document::default();
let vals = BTreeMap::new();
let abs = d.plastic_values(&vals, &PlasticRule::library()[0]).unwrap();
assert_eq!((abs.thickness, abs.nominal_radius, abs.clearance), (1.5, 0.75, 0.1));
assert!((abs.thickness_variation - 0.225).abs() < 1e-12);
assert!((abs.draft - 2f64.to_radians()).abs() < 1e-12);
let pp_in = d.plastic_values(&vals, &d.plastic_rule("Polypropylene (PP) (0.1 in)").unwrap()).unwrap();
assert!((pp_in.thickness - 2.54).abs() < 1e-9 && (pp_in.nominal_radius - 1.27).abs() < 1e-9 && (pp_in.clearance - 0.1016).abs() < 1e-9);
assert_eq!(d.plastic_rules().len(), 6);
}
}