From 66df249c0f5a1376090ad8470b5010d6f5840eab Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 05:30:48 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- ROADMAP.md | 2 +- crates/doc/src/assembly.rs | 7 + crates/doc/src/document.rs | 38 +++++ crates/doc/src/eval.rs | 18 ++- crates/doc/src/eval_more.rs | 58 +++++++- crates/doc/src/lib.rs | 1 + crates/doc/src/params.rs | 9 ++ crates/doc/src/plastic.rs | 192 ++++++++++++++++++++++++++ crates/engine/src/cmd/plastic.rs | 230 ++++++++++++++++++++++++++++++- xtask/src/main.rs | 17 +++ 10 files changed, 564 insertions(+), 8 deletions(-) create mode 100644 crates/doc/src/plastic.rs diff --git a/ROADMAP.md b/ROADMAP.md index a2a1514a..b7008d07 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -49,7 +49,7 @@ headline counts in-scope tabs only and lists the deferred ones separately. | M7 | Sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces), thread (cosmetic), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Left: emboss on curved faces, thicken (needs surfaces), split face, modelled threads | in progress | 12 | | M8 | Surface workspace | deferred | — | | M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, contact sets later; interference, per-component physical properties; STEP with assembly structure. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches | in progress | 32 | -| M10 | Sheet metal: flange, contour flange, bend, unfold/refold, flat pattern + DXF, rules (thickness, K-factor, bend radius); then the PLASTIC enclosure subset (Boss, Rib, Web, Lip/Groove, Snap Fit) | planned | 36 | +| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done (oracle 67-76). PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, no Fusion oracle (licence), tested against hand-computed geometry | in progress | 36 | | M11 | 2D drawings | deferred | — | | M12 | Release: installers, signing, docs, performance | planned | 16 | diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index df6d4cb5..3bff7e1e 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -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); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index c3bc460a..8b222c1b 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -533,6 +533,30 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] body: Option, }, + /// 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, + /// 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, + height: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + draft: Option, + /// Wall thickness of a hollow rest (none: solid). + #[serde(default, skip_serializing_if = "Option::is_none")] + thickness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + }, /// Remove bodies from the model (from here on in the timeline). Remove { bodies: Vec, @@ -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, @@ -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(), diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index f30ff789..741cabbf 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -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, 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 { .. } diff --git a/crates/doc/src/eval_more.rs b/crates/doc/src/eval_more.rs index 86fca12c..6a551611 100644 --- a/crates/doc/src/eval_more.rs +++ b/crates/doc/src/eval_more.rs @@ -587,8 +587,12 @@ fn offset_left(p: &[Vec2], d: f64) -> Vec { .collect() } -pub(super) fn plastic_eval(vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { +pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap, 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> { + 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, 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, 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, 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, ®ion, 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))), } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a0838870..05f4a2a3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index b7d8a0ce..5f5a8a6e 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -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)); diff --git a/crates/doc/src/plastic.rs b/crates/doc/src/plastic.rs new file mode 100644 index 00000000..2e765b81 --- /dev/null +++ b/crates/doc/src/plastic.rs @@ -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 { + 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, + /// Applies to bodies with no rule of their own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, + /// Body name → rule name. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub assigned: BTreeMap, +} + +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 { + 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 { + let mut out: Vec = PlasticRule::library().into_iter().map(|l| self.plastic_rule(&l.name).unwrap_or(l)).collect(); + let own: Vec = 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 { + 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, r: &PlasticRule) -> Result { + 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 { + 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); + } +} diff --git a/crates/engine/src/cmd/plastic.rs b/crates/engine/src/cmd/plastic.rs index 28aa1e12..75eefdfd 100644 --- a/crates/engine/src/cmd/plastic.rs +++ b/crates/engine/src/cmd/plastic.rs @@ -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::>(), + "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) => { @@ -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)); + } } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 68f761d5..added81c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -114,6 +114,18 @@ fn cmd_layers() -> Result<(), String> { } } +/// Features with no Fusion part to compare with, and the tests that check them analytically. +const NO_ORACLE: [(&str, &str); 5] = [ + ("Boss (draft, root fillet, hole, ribs)", "post, hole and rib volumes (`boss_with_hole_fillet_and_ribs`)"), + ("Lip / Groove", "band offsets and groove clearance on a shelled box (`lip_and_groove_on_a_shelled_box`, `groove_takes_the_rule_clearance`)"), + ("Snap Fit", "arm and catch volume, hook direction (`snap_fit_arm_and_catch`)"), + ("Rest", "rectangle, round, hollow and drafted pads (`rest_pads`)"), + ( + "Plastic Rules (manage, assign)", + "library values, Thickness-based values, rule draft as the boss default (`plastic_rules_library_edit_and_assign`)", + ), +]; + /// Run the CLI's oracle replay over every case directory and write docs/oracle.md. fn cmd_oracle() -> Result<(), String> { let dir = root().join("plan/fusion/oracle"); @@ -165,6 +177,11 @@ fn cmd_oracle() -> Result<(), String> { } md += &format!("| {} | {status} | {} |\n", r["case"].as_str().unwrap_or(""), notes.join("; ")); } + md += "\n## No oracle\n\n| Feature | Status | Tested by |\n|---|---|---|\n"; + for (feature, test) in NO_ORACLE { + md += &format!("| {feature} | no oracle (licence) | {test} |\n"); + } + md += "\nFusion builds these only with its Design Extension, which the oracle account lacks, so there is no Fusion part to compare with. They follow Autodesk's public descriptions of the features and are checked against hand-computed geometry; the plastic rule library values were read from Fusion's API.\n"; std::fs::write(root().join("docs/oracle.md"), md).map_err(|e| format!("docs/oracle.md: {e}"))?; println!("oracle: {pass}/{} cases pass ({skipped} not yet supported) → docs/oracle.md", list.len()); Ok(())