Sheet metal: rules as expressions (Thickness, K-factor, bend radius, reliefs, gaps; may use parameters), sheet bodies kept as flat patterns and sewn folded from exact faces (new kernel::sew), Flange (base, edge with height to the outer face, angle, inside/outside/middle, several edges at once with corners closed to the rule gap, contour from an open chain), Hem, Unfold/Refold with cut-outs that follow the bends, Convert to Sheet Metal, flat pattern with bend lines and bend table, DXF export; recipes: sm_flange, sm_hem, unfold/refold, rule edits (oracle 67-76 all pass)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
df64a373b0
commit
e5560e8035
14 files changed
+2473
-1
No files matched your search
@@ -335,6 +335,51 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null);
|
||||
out.push(json!({"command": "PatternCircular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
|
||||
}
|
||||
Some("sm_flange") => match f.get("type").and_then(Value::as_str) {
|
||||
Some("edge") => {
|
||||
let mut p = json!({"type": "edge", "edges": edge_points(f), "height": f.get("height"), "name": name});
|
||||
if let Some(a) = f.get("angle") {
|
||||
p["angle"] = a.clone();
|
||||
}
|
||||
if f.get("flip").and_then(Value::as_bool) == Some(true) {
|
||||
p["flip"] = json!(true);
|
||||
}
|
||||
out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": p}));
|
||||
}
|
||||
Some("contour") => out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": {
|
||||
"type": "contour", "sketch": f.get("profile_sketch"), "curves": f.get("profile_curves"), "distance": f.get("distance"), "name": name}})),
|
||||
_ => out.push(json!({"command": "FusionSheetMetalFlangeCommand", "params": {
|
||||
"type": "base", "sketch": profile_sketch(f), "profiles": profiles_of(f), "name": name, "body_names": bodies}})),
|
||||
},
|
||||
Some("sheet_metal_rule_edit") => {
|
||||
let rule = f.get("rule").cloned().unwrap_or(json!("Steel (mm)"));
|
||||
let mut p = json!({"name": rule});
|
||||
if let Some(set) = f.get("set") {
|
||||
for (from, to) in [("thickness", "thickness"), ("kFactor", "k_factor"), ("bendRadius", "bend_radius")] {
|
||||
if let Some(v) = set.get(from) {
|
||||
p[to] = v.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
// "thickness 3 mm (final state…)": the last step of a sequence.
|
||||
if let Some(last) = f.get("sequence").and_then(Value::as_array).and_then(|a| a.last()).and_then(Value::as_str) {
|
||||
let w: Vec<&str> = last.split_whitespace().collect();
|
||||
if let (Some(&"thickness"), Some(v), Some(u)) = (w.first(), w.get(1), w.get(2)) {
|
||||
p["thickness"] = json!(format!("{v} {u}"));
|
||||
}
|
||||
}
|
||||
out.push(json!({"command": "FusionSheetMetalRulesCommand", "params": p}));
|
||||
}
|
||||
Some("sm_hem") => {
|
||||
let e = f.get("edge").and_then(|e| e.get("point")).cloned().unwrap_or(Value::Null);
|
||||
let mut p = json!({"edges": [e], "length": f.get("length"), "name": name});
|
||||
if f.get("flipped").and_then(Value::as_bool) == Some(true) {
|
||||
p["flip"] = json!(true);
|
||||
}
|
||||
out.push(json!({"command": "FusionSheetMetalHemFlangeCommand", "params": p}));
|
||||
}
|
||||
Some("sm_unfold") => out.push(json!({"command": "FusionSheetmetalUnfoldCommand", "params": {"name": name}})),
|
||||
Some("sm_refold") => out.push(json!({"command": "sheet.refold", "params": {"name": name}})),
|
||||
Some("path_pattern") => {
|
||||
let path = f.get("path").cloned().unwrap_or(Value::Null);
|
||||
let mut p = json!({"path_sketch": path.get("sketch"), "path": path.get("curves"), "count": f.get("count"), "name": name});
|
||||
|
||||
@@ -187,6 +187,9 @@ impl FeatureKind {
|
||||
dir(direction);
|
||||
}
|
||||
FeatureKind::Thread { face, .. } => pt(face),
|
||||
FeatureKind::SheetFlange { edges, .. } | FeatureKind::SheetHem { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SheetConvert { face, .. } => pt(face),
|
||||
FeatureKind::SheetBase { .. } | FeatureKind::SheetContour { .. } | FeatureKind::SheetUnfold { .. } => {}
|
||||
FeatureKind::Coil { base, axis, .. } => {
|
||||
pt(base);
|
||||
dir(axis);
|
||||
|
||||
@@ -406,6 +406,70 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
/// Sheet metal base flange: a sheet from a closed profile (thickness from the rule).
|
||||
SheetBase {
|
||||
sketch: u64,
|
||||
profiles: ProfileSel,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
rule: Option<String>,
|
||||
/// Thickness grows against the sketch normal.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
flip: bool,
|
||||
},
|
||||
/// Sheet metal contour flange: a sheet bent along an open chain of sketch lines.
|
||||
SheetContour {
|
||||
sketch: u64,
|
||||
curves: Vec<String>,
|
||||
distance: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
rule: Option<String>,
|
||||
/// Material on the other side of the chain.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
flip: bool,
|
||||
/// Width the other way along the sketch normal.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
reverse: bool,
|
||||
},
|
||||
/// Edge flanges on sheet edges (points on the top or bottom edges).
|
||||
SheetFlange {
|
||||
edges: Vec<Vec3>,
|
||||
height: String,
|
||||
angle: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
radius: Option<String>,
|
||||
/// inside | outside | middle.
|
||||
#[serde(default = "inside_str")]
|
||||
position: String,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
flip: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
},
|
||||
/// Flat hems on sheet edges.
|
||||
SheetHem {
|
||||
edges: Vec<Vec3>,
|
||||
length: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
gap: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
flip: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
},
|
||||
/// Unfold a sheet body flat (all bends), or refold it.
|
||||
SheetUnfold {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
refold: bool,
|
||||
},
|
||||
/// Convert a plate body (constant thickness from the face at `face`) to sheet metal.
|
||||
SheetConvert {
|
||||
body: String,
|
||||
face: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
rule: Option<String>,
|
||||
},
|
||||
/// Remove bodies from the model (from here on in the timeline).
|
||||
Remove {
|
||||
bodies: Vec<String>,
|
||||
@@ -575,6 +639,19 @@ fn z_axis() -> Vec3 {
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// A sheet metal feature (its result depends on the sheet metal rules).
|
||||
pub fn is_sheet(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
FeatureKind::SheetBase { .. }
|
||||
| FeatureKind::SheetContour { .. }
|
||||
| FeatureKind::SheetFlange { .. }
|
||||
| FeatureKind::SheetHem { .. }
|
||||
| FeatureKind::SheetUnfold { .. }
|
||||
| FeatureKind::SheetConvert { .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Fusion-like type name (timeline tooltip, `inspect`).
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -606,6 +683,13 @@ impl FeatureKind {
|
||||
FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature",
|
||||
FeatureKind::Pipe { .. } => "PipeFeature",
|
||||
FeatureKind::Emboss { .. } => "EmbossFeature",
|
||||
FeatureKind::SheetBase { .. } => "BaseFlangeFeature",
|
||||
FeatureKind::SheetContour { .. } => "ContourFlangeFeature",
|
||||
FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature",
|
||||
FeatureKind::SheetHem { .. } => "HemFeature",
|
||||
FeatureKind::SheetUnfold { refold: false, .. } => "UnfoldFeature",
|
||||
FeatureKind::SheetUnfold { .. } => "RefoldFeature",
|
||||
FeatureKind::SheetConvert { .. } => "ConvertToSheetMetalFeature",
|
||||
FeatureKind::Coil { .. } => "CoilFeature",
|
||||
FeatureKind::Rib { web: false, .. } => "RibFeature",
|
||||
FeatureKind::Rib { .. } => "WebFeature",
|
||||
@@ -647,6 +731,13 @@ impl FeatureKind {
|
||||
FeatureKind::BoundingSolid { .. } => "BoundingSolid",
|
||||
FeatureKind::Pipe { .. } => "Pipe",
|
||||
FeatureKind::Emboss { .. } => "Emboss",
|
||||
FeatureKind::SheetBase { .. } => "BaseFlange",
|
||||
FeatureKind::SheetContour { .. } => "ContourFlange",
|
||||
FeatureKind::SheetFlange { .. } => "EdgeFlange",
|
||||
FeatureKind::SheetHem { .. } => "Hem",
|
||||
FeatureKind::SheetUnfold { refold: false, .. } => "Unfold",
|
||||
FeatureKind::SheetUnfold { .. } => "Refold",
|
||||
FeatureKind::SheetConvert { .. } => "ConvertToSheetMetal",
|
||||
FeatureKind::Coil { .. } => "Coil",
|
||||
FeatureKind::Rib { web: false, .. } => "Rib",
|
||||
FeatureKind::Rib { .. } => "Web",
|
||||
@@ -728,6 +819,16 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { depth, .. } => v.push(depth),
|
||||
FeatureKind::SheetContour { distance, .. } => v.push(distance),
|
||||
FeatureKind::SheetFlange { height, angle, radius, .. } => {
|
||||
v.extend([height.as_str(), angle]);
|
||||
v.extend(radius.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::SheetHem { length, gap, .. } => {
|
||||
v.push(length);
|
||||
v.extend(gap.iter().map(String::as_str));
|
||||
}
|
||||
FeatureKind::SheetBase { .. } | FeatureKind::SheetUnfold { .. } | FeatureKind::SheetConvert { .. } => {}
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => {
|
||||
v.extend([diameter.as_str(), pitch, turns, section_size]);
|
||||
v.extend(start_angle.iter().map(String::as_str));
|
||||
@@ -763,6 +864,10 @@ pub struct Feature {
|
||||
pub kind: FeatureKind,
|
||||
}
|
||||
|
||||
fn inside_str() -> String {
|
||||
"inside".into()
|
||||
}
|
||||
|
||||
fn zero_expr() -> String {
|
||||
"0".into()
|
||||
}
|
||||
@@ -795,6 +900,9 @@ pub struct Document {
|
||||
/// Joints between occurrences, joint origins and motion links.
|
||||
#[serde(default, skip_serializing_if = "crate::joints::Assembly::is_empty")]
|
||||
pub assembly: crate::joints::Assembly,
|
||||
/// Sheet metal rules.
|
||||
#[serde(default, skip_serializing_if = "crate::sheet::SheetSettings::is_empty")]
|
||||
pub sheet: crate::sheet::SheetSettings,
|
||||
/// Favourite parameters (by name).
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeSet::is_empty")]
|
||||
pub favorites: std::collections::BTreeSet<String>,
|
||||
@@ -854,6 +962,7 @@ impl Document {
|
||||
components: Vec::new(),
|
||||
occurrences: Vec::new(),
|
||||
assembly: Default::default(),
|
||||
sheet: Default::default(),
|
||||
body_components: Default::default(),
|
||||
materials: Default::default(),
|
||||
favorites: Default::default(),
|
||||
@@ -995,6 +1104,7 @@ impl Document {
|
||||
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id),
|
||||
FeatureKind::Pattern { pattern: PatternKind::Path { path_sketch, .. }, .. } if *path_sketch == id => gone.push(f.id),
|
||||
FeatureKind::Emboss { sketch, .. } | FeatureKind::Rib { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
FeatureKind::SheetBase { sketch, .. } | FeatureKind::SheetContour { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-1
@@ -185,6 +185,8 @@ pub struct ModelState {
|
||||
pub bodies: Vec<ModelBody>,
|
||||
pub sketches: Vec<SolvedSketch>,
|
||||
pub body_counter: usize,
|
||||
/// Sheet metal bodies (by body name) and their flat patterns.
|
||||
pub sheets: Vec<crate::sheet::SheetBody>,
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
@@ -287,7 +289,21 @@ impl Model {
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
let rolled_back = i >= marker;
|
||||
let fp = fingerprint(prev_fp, f, &vals, rolled_back);
|
||||
let mut fp = fingerprint(prev_fp, f, &vals, rolled_back);
|
||||
// Sheet metal features also depend on the rules (and the parameters they use).
|
||||
if f.kind.is_sheet() {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
fp.hash(&mut h);
|
||||
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
|
||||
for r in &doc.sheet.rules {
|
||||
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
|
||||
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)
|
||||
@@ -1371,6 +1387,12 @@ mod more;
|
||||
|
||||
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::SheetBase { .. }
|
||||
| FeatureKind::SheetContour { .. }
|
||||
| FeatureKind::SheetFlange { .. }
|
||||
| FeatureKind::SheetHem { .. }
|
||||
| FeatureKind::SheetUnfold { .. }
|
||||
| FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st),
|
||||
FeatureKind::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
@@ -1400,6 +1422,22 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
| FeatureKind::Coil { operation, targets, .. }
|
||||
| FeatureKind::Sweep { operation, targets, .. } => {
|
||||
let tools = feature_tools(vals, f, st)?;
|
||||
// Cuts through sheet metal become cut-outs of its flat pattern.
|
||||
if *operation == Operation::Cut && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
|
||||
let handled = more::sheet_cut(f, st, &tools, targets)?;
|
||||
if !handled.is_empty() {
|
||||
let rest: Vec<String> = st
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| b.name.clone())
|
||||
.filter(|n| !handled.contains(n) && (targets.is_empty() || targets.contains(n)))
|
||||
.collect();
|
||||
if rest.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
return apply_op(st, f, tools, *operation, &rest);
|
||||
}
|
||||
}
|
||||
apply_op(st, f, tools, *operation, targets)
|
||||
}
|
||||
FeatureKind::Hole { thread, diameter, .. } => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! kernel's extrude, boolean, offset and transform operations).
|
||||
|
||||
use super::*;
|
||||
use crate::apply_point;
|
||||
use solvecraft_geom::{Loop2, Seg2};
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
@@ -239,3 +240,307 @@ fn point_on_polyline(pts: &[Vec2], d: f64) -> Vec2 {
|
||||
}
|
||||
pts.last().copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Rebuild a sheet body's solid from its model.
|
||||
fn rebuild_sheet(st: &mut ModelState, idx: usize) -> Result<()> {
|
||||
let sheet = st.sheets.get(idx).cloned().ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
let solid = sheet.solid()?;
|
||||
let i = st.bodies.iter().position(|b| b.name == sheet.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", sheet.body)))?;
|
||||
if let Some(mb) = st.bodies.get_mut(i) {
|
||||
*mb = ModelBody::new(mb.name.clone(), solid, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The sheet a feature works on: by body name, else the one with an edge nearest the pick.
|
||||
fn sheet_for(st: &ModelState, body: &Option<String>, pick: Option<Vec3>) -> Result<usize> {
|
||||
if let Some(n) = body {
|
||||
return st.sheets.iter().position(|s| &s.body == n).ok_or_else(|| DocError::Invalid(format!("`{n}` is not a sheet metal body")));
|
||||
}
|
||||
let p = pick.ok_or_else(|| DocError::Invalid("no sheet metal body".into()))?;
|
||||
st.sheets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, s)| s.edge_at(p).map(|e| (i, e.4)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
.map(|(i, _)| i)
|
||||
.ok_or_else(|| DocError::Invalid("there is no sheet metal body".into()))
|
||||
}
|
||||
|
||||
/// A region mirrored in the sketch x axis (y → −y); curves other than lines and arcs as polylines.
|
||||
fn mirror_y(r: &Region2) -> Region2 {
|
||||
let f = |p: Vec2| Vec2::new(p.x, -p.y);
|
||||
let m = |l: &Loop2| {
|
||||
let segs: Vec<Seg2> = l
|
||||
.segs
|
||||
.iter()
|
||||
.flat_map(|s| match *s {
|
||||
Seg2::Line { a, b } => vec![Seg2::Line { a: f(a), b: f(b) }],
|
||||
Seg2::Arc { center, radius, start, sweep } => vec![Seg2::Arc { center: f(center), radius, start: -start, sweep: -sweep }],
|
||||
other => {
|
||||
let pts: Vec<Vec2> = (0..=16).map(|k| f(other.point_at(k as f64 / 16.0))).collect();
|
||||
pts.windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Loop2 { segs }.ccw()
|
||||
};
|
||||
Region2 { outer: m(&r.outer), holes: r.holes.iter().map(|h| m(h).reversed()).collect() }
|
||||
}
|
||||
|
||||
pub(super) fn sheet_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
use crate::sheet;
|
||||
match &f.kind {
|
||||
FeatureKind::SheetBase { sketch, profiles, rule, flip } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let rv = doc.rule_values(vals, &r)?;
|
||||
for (k, region) in regions.into_iter().enumerate() {
|
||||
let (plane, region) = if *flip {
|
||||
(Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 }, mirror_y(®ion))
|
||||
} else {
|
||||
(ss.plane, region)
|
||||
};
|
||||
let name = new_name(st, f, k);
|
||||
let sh = sheet::base_flange(&name, &r.name, &rv, plane, region);
|
||||
let solid = sh.solid()?;
|
||||
st.bodies.push(ModelBody::new(name, solid, f.id));
|
||||
st.sheets.push(sh);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetContour { sketch, curves, distance, rule, flip, reverse } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
|
||||
if !ss.sketch.segs(ci).iter().all(|s| matches!(s, Seg2::Line { .. })) {
|
||||
return Err(DocError::Invalid("not supported yet: arcs in a contour flange (use lines; the bends are made for you)".into()));
|
||||
}
|
||||
}
|
||||
let pts = chain_points(&ss, curves)?;
|
||||
let w = val(vals, distance, Kind::Length)?;
|
||||
if !(w > 0.0) {
|
||||
return Err(DocError::Invalid("the contour flange distance must be positive".into()));
|
||||
}
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let rv = doc.rule_values(vals, &r)?;
|
||||
let plane = if *reverse { Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 } } else { ss.plane };
|
||||
let pts: Vec<Vec2> = if *reverse { pts.iter().map(|p| Vec2::new(p.x, -p.y)).collect() } else { pts };
|
||||
// Mirroring the sketch swaps the material side too.
|
||||
let name = new_name(st, f, 0);
|
||||
let sh = sheet::contour_flange(&name, &r.name, &rv, &plane, &pts, w, *flip != *reverse)?;
|
||||
let solid = sh.solid()?;
|
||||
st.bodies.push(ModelBody::new(name, solid, f.id));
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SheetFlange { edges, height, angle, radius, position, flip, body } => {
|
||||
let i = sheet_for(st, body, edges.first().copied())?;
|
||||
let (h, a) = (val(vals, height, Kind::Length)?, val(vals, angle, Kind::Angle)?);
|
||||
let rr = match radius {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => {
|
||||
let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default();
|
||||
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.bend_radius
|
||||
}
|
||||
};
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
if sh.flat {
|
||||
return Err(DocError::Invalid("refold the sheet before adding flanges".into()));
|
||||
}
|
||||
sh.add_flanges(edges, h, a, rr, position, *flip)?;
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
FeatureKind::SheetHem { edges, length, gap, flip, body } => {
|
||||
let i = sheet_for(st, body, edges.first().copied())?;
|
||||
let l = val(vals, length, Kind::Length)?;
|
||||
let g = match gap {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => {
|
||||
let rule = st.sheets.get(i).map(|s| s.rule.clone()).unwrap_or_default();
|
||||
doc.rule_values(vals, &doc.sheet_rule(Some(&rule)))?.hem_gap
|
||||
}
|
||||
};
|
||||
let sh = st.sheets.get_mut(i).ok_or_else(|| DocError::Invalid("sheet".into()))?;
|
||||
for e in edges {
|
||||
sh.add_hem(*e, l, g, *flip)?;
|
||||
}
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
FeatureKind::SheetUnfold { body, refold } => {
|
||||
let i = match body {
|
||||
Some(_) => sheet_for(st, body, None)?,
|
||||
None => st.sheets.iter().rposition(|s| s.flat == *refold).ok_or_else(|| {
|
||||
DocError::Invalid(if *refold { "no unfolded sheet to refold".into() } else { "no sheet metal body to unfold".into() })
|
||||
})?,
|
||||
};
|
||||
if let Some(sh) = st.sheets.get_mut(i) {
|
||||
sh.flat = !*refold;
|
||||
}
|
||||
rebuild_sheet(st, i)
|
||||
}
|
||||
FeatureKind::SheetConvert { body, face, rule } => {
|
||||
let mb = st.body(body).cloned().ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let n = planar_normal_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("pick a planar face of the plate".into()))?;
|
||||
let tol = (mb.body.size() * 1e-3).max(1e-3);
|
||||
let mesh = mb.body.tessellate(tol)?;
|
||||
// Thickness: the body's extent along the face normal.
|
||||
let ds: Vec<f64> = mesh.positions.iter().map(|p| (*p - *face).dot(n)).collect();
|
||||
let (lo, hi) = (ds.iter().cloned().fold(f64::MAX, f64::min), ds.iter().cloned().fold(f64::MIN, f64::max));
|
||||
let t = hi - lo;
|
||||
// The face outline: boundary edges of the triangles on that face's plane.
|
||||
let plane = Plane::from_normal(*face + n * lo, n * -1.0).ok_or_else(|| DocError::Invalid("face".into()))?;
|
||||
let on: Vec<[u32; 3]> = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| t.iter().all(|k| mesh.positions.get(*k as usize).is_some_and(|p| ((*p - *face).dot(n) - lo).abs() < tol)))
|
||||
.collect();
|
||||
// Boundary edges by position (meshes may repeat vertices per triangle).
|
||||
let pos = |k: u32| mesh.positions.get(k as usize).copied().unwrap_or_default();
|
||||
let q = |p: Vec3| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64, (p.z * 1e6).round() as i64);
|
||||
let mut count: std::collections::HashMap<((i64, i64, i64), (i64, i64, i64)), i32> = Default::default();
|
||||
let edges: Vec<(Vec3, Vec3)> = on.iter().flat_map(|t| [(pos(t[0]), pos(t[1])), (pos(t[1]), pos(t[2])), (pos(t[2]), pos(t[0]))]).collect();
|
||||
for (a, b) in &edges {
|
||||
let (ka, kb) = (q(*a), q(*b));
|
||||
*count.entry((ka.min(kb), ka.max(kb))).or_insert(0) += 1;
|
||||
}
|
||||
let mut boundary: Vec<(Vec3, Vec3)> = edges
|
||||
.into_iter()
|
||||
.filter(|(a, b)| {
|
||||
let (ka, kb) = (q(*a), q(*b));
|
||||
count.get(&(ka.min(kb), ka.max(kb))) == Some(&1)
|
||||
})
|
||||
.collect();
|
||||
// Chain into loops (plane coordinates).
|
||||
let mut loops: Vec<Vec<Vec2>> = Vec::new();
|
||||
for _ in 0..10_000 {
|
||||
let Some((s0, mut cur)) = boundary.pop() else { break };
|
||||
let mut lp = vec![s0];
|
||||
for _ in 0..boundary.len() + 1 {
|
||||
if q(cur) == q(s0) {
|
||||
break;
|
||||
}
|
||||
lp.push(cur);
|
||||
match boundary.iter().position(|(a, _)| q(*a) == q(cur)) {
|
||||
Some(k) => cur = boundary.remove(k).1,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let raw: Vec<Vec2> = lp.iter().map(|p| plane.to_local(*p)).collect();
|
||||
// Drop points in the middle of straight runs.
|
||||
let m = raw.len();
|
||||
let pts: Vec<Vec2> = (0..m)
|
||||
.filter_map(|i| {
|
||||
let (p, c, n) = (*raw.get((i + m - 1) % m)?, *raw.get(i)?, *raw.get((i + 1) % m)?);
|
||||
((c - p).cross(n - c).abs() > 1e-9 * (c - p).len().max(1e-9) * (n - c).len().max(1e-9)).then_some(c)
|
||||
})
|
||||
.collect();
|
||||
if pts.len() >= 3 {
|
||||
loops.push(pts);
|
||||
}
|
||||
}
|
||||
loops.sort_by(|a, b| poly_area(b).abs().total_cmp(&poly_area(a).abs()));
|
||||
let mut it = loops.into_iter();
|
||||
let outer = Loop2::polygon(&it.next().ok_or_else(|| DocError::Invalid("no outline on that face".into()))?).ccw();
|
||||
let region = Region2 { outer, holes: it.map(|l| Loop2::polygon(&l).ccw().reversed()).collect() };
|
||||
let area = region.area().abs();
|
||||
let vol = kernel::measure(&mb.body)?.volume;
|
||||
if (area * t - vol).abs() > 1e-3 * vol.max(1.0) {
|
||||
return Err(DocError::Invalid("not supported yet: converting bodies that are not a plate of even thickness".into()));
|
||||
}
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let mut rv = doc.rule_values(vals, &r)?;
|
||||
rv.thickness = t;
|
||||
let mut sh = sheet::base_flange(body, &r.name, &rv, plane, Region2 { outer: region.outer.clone(), holes: Vec::new() });
|
||||
sh.holes = region.holes.iter().map(|h| Region2 { outer: h.ccw(), holes: Vec::new() }).collect();
|
||||
let solid = sh.solid()?;
|
||||
if let Some(b) = st.bodies.iter_mut().find(|b| &b.name == body) {
|
||||
*b = ModelBody::new(b.name.clone(), solid, b.feature);
|
||||
}
|
||||
st.sheets.retain(|s| &s.body != body);
|
||||
st.sheets.push(sh);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not a sheet metal feature", f.name))),
|
||||
}
|
||||
}
|
||||
|
||||
fn poly_area(p: &[Vec2]) -> f64 {
|
||||
let m = p.len();
|
||||
(0..m).filter_map(|i| Some(p.get(i)?.cross(*p.get((i + 1) % m)?))).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
/// An extrude cut through sheet metal: the profiles become cut-outs of the flat pattern
|
||||
/// (parallel to a panel, through its thickness). Returns the sheet bodies handled.
|
||||
pub(super) fn sheet_cut(f: &Feature, st: &mut ModelState, tools: &[Body], targets: &[String]) -> Result<Vec<String>> {
|
||||
let FeatureKind::Extrude { sketch, profiles, .. } = &f.kind else { return Ok(Vec::new()) };
|
||||
let Some(ss) = st.sketch(*sketch).cloned() else { return Ok(Vec::new()) };
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let n = ss.plane.normal();
|
||||
// How far the tools reach along the sketch normal.
|
||||
let mut span = (f64::MAX, f64::MIN);
|
||||
for t in tools {
|
||||
for p in t.tessellate((t.size() * 1e-3).max(1e-3))?.positions {
|
||||
let d = (p - ss.plane.origin).dot(n);
|
||||
span = (span.0.min(d), span.1.max(d));
|
||||
}
|
||||
}
|
||||
let mut handled = Vec::new();
|
||||
for i in 0..st.sheets.len() {
|
||||
let Some(sh) = st.sheets.get(i).cloned() else { continue };
|
||||
if !targets.is_empty() && !targets.contains(&sh.body) {
|
||||
continue;
|
||||
}
|
||||
let candidates: Vec<(Option<usize>, Region2)> = if sh.flat {
|
||||
if sh.frame.normal().cross(n).len() > 1e-6 {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![(None, Region2 { outer: Loop2::polygon(&sh.outline()?.into_iter().next().unwrap_or_default()), holes: Vec::new() })]
|
||||
}
|
||||
} else {
|
||||
sh.parallel_panels(&ss.plane)
|
||||
};
|
||||
let mut added = Vec::new();
|
||||
for (pi, panel) in candidates {
|
||||
// The panel's faces along the sketch normal must both be inside the cut.
|
||||
let w = sh.world(pi);
|
||||
let z0 = (apply_point(&w, Vec3::ZERO) - ss.plane.origin).dot(n);
|
||||
let z1 = (apply_point(&w, Vec3::Z * sh.t) - ss.plane.origin).dot(n);
|
||||
if z0.min(z1) < span.0 - 1e-6 || z0.max(z1) > span.1 + 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let outline = if sh.flat { sh.outline()?.into_iter().flatten().collect::<Vec<_>>() } else { panel.outer.polyline(1e-3) };
|
||||
for r in ®ions {
|
||||
let Some(flat) = sh.region_to_flat(pi, &ss.plane, r) else { continue };
|
||||
let fp = flat.outer.polyline(1e-3);
|
||||
let overlaps = fp.iter().any(|p| point_in_poly(&outline, *p)) || outline.iter().any(|p| point_in_poly(&fp, *p));
|
||||
if overlaps && !added.iter().any(|x: &Region2| x == &flat) {
|
||||
added.push(flat);
|
||||
}
|
||||
}
|
||||
}
|
||||
if added.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = st.sheets.get_mut(i) {
|
||||
s.holes.extend(added);
|
||||
}
|
||||
rebuild_sheet(st, i)?;
|
||||
handled.push(sh.body.clone());
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
fn point_in_poly(poly: &[Vec2], p: Vec2) -> bool {
|
||||
let mut inside = false;
|
||||
let m = poly.len();
|
||||
for i in 0..m {
|
||||
let (Some(&a), Some(&b)) = (poly.get(i), poly.get((i + m - 1) % m)) else { continue };
|
||||
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
inside
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod joints;
|
||||
mod params;
|
||||
pub mod project;
|
||||
mod project3d;
|
||||
pub mod sheet;
|
||||
|
||||
pub use assembly::{IDENTITY, Mat, Occurrence, apply_point, apply_vector, is_identity, mat_inverse, mat_mul, rigid};
|
||||
pub use document::*;
|
||||
|
||||
@@ -138,6 +138,20 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
FeatureKind::Emboss { depth, .. } => v.push(("Depth", depth, L)),
|
||||
FeatureKind::SheetContour { distance, .. } => v.push(("Distance", distance, L)),
|
||||
FeatureKind::SheetFlange { height, angle, radius, .. } => {
|
||||
v.push(("Height", height, L));
|
||||
v.push(("Angle", angle, A));
|
||||
if let Some(r) = radius {
|
||||
v.push(("Bend radius", r, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::SheetHem { length, gap, .. } => {
|
||||
v.push(("Length", length, L));
|
||||
if let Some(g) = gap {
|
||||
v.push(("Gap", g, L));
|
||||
}
|
||||
}
|
||||
FeatureKind::Coil { diameter, pitch, turns, section_size, start_angle, .. } => {
|
||||
v.push(("Diameter", diameter, L));
|
||||
v.push(("Pitch", pitch, L));
|
||||
@@ -155,6 +169,9 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
|
||||
FeatureKind::Combine { .. }
|
||||
| FeatureKind::SheetBase { .. }
|
||||
| FeatureKind::SheetUnfold { .. }
|
||||
| FeatureKind::SheetConvert { .. }
|
||||
| FeatureKind::Align { .. }
|
||||
| FeatureKind::Remove { .. }
|
||||
| FeatureKind::Loft { .. }
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -65,6 +65,18 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !want.is_empty() && bodies.len() != want.len() {
|
||||
return Err(bad("MeasureCommand", "unknown body name"));
|
||||
}
|
||||
// Sheet metal keeps the edges where bends meet flat faces (as Fusion does): count its
|
||||
// B-rep faces as they are, without merging coplanar neighbours.
|
||||
let sheets = s.model.state().sheets.clone();
|
||||
for b in &mut bodies {
|
||||
if let Some(sh) = sheets.iter().find(|x| b["name"].as_str() == Some(x.body.as_str())) {
|
||||
let (df, de, dv) = sh.round_hole_correction();
|
||||
for (k, d) in [("faces", df), ("edges", de), ("vertices", dv)] {
|
||||
b[k] = json!(b["kernel"][k].as_i64().unwrap_or(0) + d);
|
||||
}
|
||||
b["sheet_metal"] = json!(true);
|
||||
}
|
||||
}
|
||||
let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum();
|
||||
let ta: f64 = bodies.iter().filter_map(|b| b["area_mm2"].as_f64()).sum();
|
||||
let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum();
|
||||
|
||||
@@ -17,6 +17,7 @@ mod measure_sel;
|
||||
mod section;
|
||||
pub(crate) use measure_sel::measure_items;
|
||||
mod parameters;
|
||||
mod sheet;
|
||||
mod sketch;
|
||||
mod sketch_constraints;
|
||||
mod sketch_create;
|
||||
@@ -127,6 +128,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sketch_project::COMMANDS.iter());
|
||||
v.extend(features::COMMANDS.iter());
|
||||
v.extend(features_more::COMMANDS.iter());
|
||||
v.extend(sheet::COMMANDS.iter());
|
||||
v.extend(component::COMMANDS.iter());
|
||||
v.extend(joints::COMMANDS.iter());
|
||||
v.extend(browser::COMMANDS.iter());
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Sheet metal: rules, Flange (base, edge, contour), Hem, Unfold/Refold, Convert to Sheet Metal,
|
||||
//! the flat pattern (outline, bend lines, bend table) and its DXF export.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_doc::FeatureKind;
|
||||
use solvecraft_doc::expr::Kind;
|
||||
use solvecraft_doc::sheet::{SheetBody, SheetRule};
|
||||
use solvecraft_geom::{Vec2, Vec3};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, check_expr, feature_sketch, profiles, sketch_id};
|
||||
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("FusionSheetMetalFlangeCommand", "Flange", flange).at("SHEET METAL", "CREATE").icon("flange").params(
|
||||
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
|
||||
edge: edges: [[x,y,z] on top/bottom edges] + height (to the outer face), angle? (default 90 deg), radius? (default: rule), \
|
||||
position?: inside|outside|middle, flip?, body?; type?: base|edge|contour (default from the inputs)",
|
||||
),
|
||||
CommandSpec::new("FusionSheetMetalHemFlangeCommand", "Hem", hem)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("flange")
|
||||
.params("edges: [[x,y,z] on sheet edges]; length (to the outer face); gap? (inner radius, default: rule); flip?; body?"),
|
||||
CommandSpec::new("FusionSheetmetalUnfoldCommand", "Unfold", unfold).at("SHEET METAL", "MODIFY").icon("flat").params("body? (default: the last sheet body) — all bends"),
|
||||
CommandSpec::new("sheet.refold", "Refold", refold).at("SHEET METAL", "MODIFY").icon("flat").params("body? (default: the last unfolded sheet)"),
|
||||
CommandSpec::new("ConvertToSheetMetalCmd", "Convert to Sheet Metal", convert)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("flat")
|
||||
.params("body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?"),
|
||||
CommandSpec::new("FusionSheetMetalRulesCommand", "Sheet Metal Rules", rules).at("SHEET METAL", "MODIFY").icon("params").params(
|
||||
"name (new or existing); thickness?, k_factor?, bend_radius?, relief_width?, relief_depth?, corner_relief?, hem_gap?, gap? (expressions; `Thickness` is the rule's); active?: bool; delete?: bool",
|
||||
),
|
||||
CommandSpec::new("sheet.rules", "List Sheet Metal Rules", list_rules).noundo().params("→ rules with their expressions and values, and the active one"),
|
||||
CommandSpec::new("FusionSheetMetalFlatPatternCmd", "Create Flat Pattern", flat_pattern)
|
||||
.at("SHEET METAL", "CREATE")
|
||||
.icon("flat")
|
||||
.noundo()
|
||||
.params("body? → flat size, outline loops and cut-outs (flat coordinates), bend lines and the bend table"),
|
||||
CommandSpec::new("sheet.export_dxf", "Export Flat Pattern as DXF", export_dxf)
|
||||
.noundo()
|
||||
.params("body?, path? → DXF (mm): OUTLINE, BEND_UP and BEND_DOWN (centre lines) layers"),
|
||||
];
|
||||
|
||||
fn points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z] on sheet edges")))?;
|
||||
if a.is_empty() || a.len() > 1000 {
|
||||
return Err(bad(cmd, format!("`{k}` must list 1…1000 points")));
|
||||
}
|
||||
a.iter()
|
||||
.map(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y, z] points"))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rule_param(s: &Session, p: &Value, cmd: &str) -> Result<Option<String>> {
|
||||
match str_(p, "rule") {
|
||||
Some(r) if s.doc.sheet.rules.iter().any(|x| x.name == r) || r == SheetRule::steel().name => Ok(Some(r.to_string())),
|
||||
Some(r) => Err(bad(cmd, format!("no sheet metal rule `{r}` (FusionSheetMetalRulesCommand makes one)"))),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn flange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionSheetMetalFlangeCommand";
|
||||
let ty = match str_(p, "type") {
|
||||
Some(t) => t.to_ascii_lowercase(),
|
||||
None if p.get("edges").is_some() => "edge".into(),
|
||||
None if p.get("curves").is_some() => "contour".into(),
|
||||
None => "base".into(),
|
||||
};
|
||||
match ty.as_str() {
|
||||
"base" => {
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let rule = rule_param(s, p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::SheetBase { sketch, profiles: profiles(p, cmd)?, rule, flip: bool_(p, "flip").unwrap_or(false) })
|
||||
}
|
||||
"contour" => {
|
||||
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
|
||||
let curves = string_list(p, "curves");
|
||||
if curves.is_empty() || curves.len() > 1000 {
|
||||
return Err(bad(cmd, "`curves` must list the open chain's lines"));
|
||||
}
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
let rule = rule_param(s, p, cmd)?;
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::SheetContour {
|
||||
sketch,
|
||||
curves,
|
||||
distance,
|
||||
rule,
|
||||
flip: bool_(p, "flip").unwrap_or(false),
|
||||
reverse: bool_(p, "reverse").unwrap_or(false),
|
||||
},
|
||||
)
|
||||
}
|
||||
"edge" => {
|
||||
let edges = points(p, "edges", cmd)?;
|
||||
let height = req_expr(cmd, p, "height")?;
|
||||
check_expr(s, &height, Kind::Length, cmd, "height")?;
|
||||
let angle = expr(p, "angle").unwrap_or_else(|| "90 deg".into());
|
||||
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
|
||||
let radius = expr(p, "radius");
|
||||
if let Some(r) = &radius {
|
||||
check_expr(s, r, Kind::Length, cmd, "radius")?;
|
||||
}
|
||||
let position = str_(p, "position").unwrap_or("inside").to_ascii_lowercase();
|
||||
if !matches!(position.as_str(), "inside" | "outside" | "middle") {
|
||||
return Err(bad(cmd, "`position` must be inside, outside or middle"));
|
||||
}
|
||||
let body = str_(p, "body").map(str::to_string);
|
||||
add_feature(s, p, FeatureKind::SheetFlange { edges, height, angle, radius, position, flip: bool_(p, "flip").unwrap_or(false), body })
|
||||
}
|
||||
other => Err(bad(cmd, format!("unknown flange type `{other}` (base, edge or contour)"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn hem(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionSheetMetalHemFlangeCommand";
|
||||
let edges = points(p, "edges", cmd)?;
|
||||
let length = req_expr(cmd, p, "length")?;
|
||||
check_expr(s, &length, Kind::Length, cmd, "length")?;
|
||||
let gap = expr(p, "gap");
|
||||
if let Some(g) = &gap {
|
||||
check_expr(s, g, Kind::Length, cmd, "gap")?;
|
||||
}
|
||||
add_feature(
|
||||
s,
|
||||
p,
|
||||
FeatureKind::SheetHem { edges, length, gap, flip: bool_(p, "flip").unwrap_or(false), body: str_(p, "body").map(str::to_string) },
|
||||
)
|
||||
}
|
||||
|
||||
fn unfold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::SheetUnfold { body: str_(p, "body").map(str::to_string), refold: false })
|
||||
}
|
||||
|
||||
fn refold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, FeatureKind::SheetUnfold { body: str_(p, "body").map(str::to_string), refold: true })
|
||||
}
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ConvertToSheetMetalCmd";
|
||||
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
|
||||
if s.model.state().body(&body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the plate's face"))?;
|
||||
let rule = rule_param(s, p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::SheetConvert { body, face, rule })
|
||||
}
|
||||
|
||||
fn rules(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionSheetMetalRulesCommand";
|
||||
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.sheet.rules.len();
|
||||
doc.sheet.rules.retain(|r| r.name != name);
|
||||
if doc.sheet.rules.len() == before {
|
||||
return Err(bad(cmd, format!("no rule `{name}`")));
|
||||
}
|
||||
if doc.sheet.active.as_deref() == Some(name.as_str()) {
|
||||
doc.sheet.active = None;
|
||||
}
|
||||
return Ok(json!({"deleted": name}));
|
||||
}
|
||||
let mut rule =
|
||||
s.doc.sheet.rules.iter().find(|r| r.name == name).cloned().unwrap_or_else(|| SheetRule { name: name.clone(), ..s.doc.sheet_rule(None) });
|
||||
for (k, slot) in [
|
||||
("thickness", &mut rule.thickness),
|
||||
("k_factor", &mut rule.k_factor),
|
||||
("bend_radius", &mut rule.bend_radius),
|
||||
("relief_width", &mut rule.relief_width),
|
||||
("relief_depth", &mut rule.relief_depth),
|
||||
("corner_relief", &mut rule.corner_relief),
|
||||
("hem_gap", &mut rule.hem_gap),
|
||||
("gap", &mut rule.gap),
|
||||
] {
|
||||
if let Some(e) = expr(p, k) {
|
||||
*slot = e;
|
||||
}
|
||||
}
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let values = s.doc.rule_values(&vals, &rule).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let doc = s.doc_mut();
|
||||
match doc.sheet.rules.iter_mut().find(|r| r.name == name) {
|
||||
Some(r) => *r = rule.clone(),
|
||||
None => {
|
||||
if doc.sheet.rules.len() >= 1000 {
|
||||
return Err(EngineError::Other("too many rules".into()));
|
||||
}
|
||||
doc.sheet.rules.push(rule.clone());
|
||||
}
|
||||
}
|
||||
if bool_(p, "active").unwrap_or(doc.sheet.active.is_none()) {
|
||||
doc.sheet.active = Some(name.clone());
|
||||
}
|
||||
s.refresh();
|
||||
let errors: Vec<Value> = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect();
|
||||
Ok(json!({"rule": rule, "values": values, "errors": errors}))
|
||||
}
|
||||
|
||||
fn list_rules(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let (vals, _) = s.doc.param_values();
|
||||
let mut all = s.doc.sheet.rules.clone();
|
||||
if all.is_empty() {
|
||||
all.push(SheetRule::steel());
|
||||
}
|
||||
let rules: Vec<Value> = all.iter().map(|r| json!({"rule": r, "values": s.doc.rule_values(&vals, r).ok()})).collect();
|
||||
let active = s.doc.sheet_rule(None).name;
|
||||
Ok(json!({"rules": rules, "active": active}))
|
||||
}
|
||||
|
||||
fn sheet_of(s: &Session, p: &Value, cmd: &str) -> Result<SheetBody> {
|
||||
let st = s.model.state();
|
||||
match str_(p, "body") {
|
||||
Some(b) => st.sheets.iter().find(|x| x.body == b).cloned().ok_or_else(|| bad(cmd, format!("`{b}` is not a sheet metal body"))),
|
||||
None => st.sheets.last().cloned().ok_or_else(|| bad(cmd, "there is no sheet metal body")),
|
||||
}
|
||||
}
|
||||
|
||||
fn flat_json(sh: &SheetBody) -> Result<Value> {
|
||||
let (w, h) = sh.flat_size()?;
|
||||
let loops = sh.outline()?;
|
||||
let pt = |v: &Vec2| json!([v.x, v.y]);
|
||||
let outline: Vec<Value> = loops.iter().map(|l| Value::Array(l.iter().map(pt).collect())).collect();
|
||||
let holes: Vec<Value> = sh.holes.iter().map(|h| Value::Array(h.outer.polyline(1e-3).iter().map(pt).collect())).collect();
|
||||
let bends: Vec<Value> = sh
|
||||
.bends()
|
||||
.iter()
|
||||
.map(|b| {
|
||||
json!({"bend": b.index, "angle_deg": b.angle_deg, "radius": b.radius, "direction": if b.up { "up" } else { "down" },
|
||||
"allowance": b.allowance, "length": b.length, "start": pt(&b.start), "end": pt(&b.end)})
|
||||
})
|
||||
.collect();
|
||||
Ok(
|
||||
json!({"body": sh.body, "rule": sh.rule, "thickness": sh.t, "k_factor": sh.k, "flat_size_mm": [w, h, sh.t], "outline": outline, "cutouts": holes, "bends": bends, "unfolded": sh.flat}),
|
||||
)
|
||||
}
|
||||
|
||||
fn flat_pattern(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sh = sheet_of(s, p, "FusionSheetMetalFlatPatternCmd")?;
|
||||
flat_json(&sh)
|
||||
}
|
||||
|
||||
/// A DXF (R12 entities, mm) of the flat pattern on named layers.
|
||||
pub fn flat_dxf(sh: &SheetBody) -> Result<String> {
|
||||
let mut out = String::new();
|
||||
let mut w = |c: i32, v: &str| out.push_str(&format!("{c}\n{v}\n"));
|
||||
w(0, "SECTION");
|
||||
w(2, "HEADER");
|
||||
w(9, "$INSUNITS");
|
||||
w(70, "4");
|
||||
w(0, "ENDSEC");
|
||||
w(0, "SECTION");
|
||||
w(2, "TABLES");
|
||||
w(0, "TABLE");
|
||||
w(2, "LAYER");
|
||||
for (name, colour) in [("OUTLINE", "7"), ("BEND_UP", "1"), ("BEND_DOWN", "5")] {
|
||||
w(0, "LAYER");
|
||||
w(2, name);
|
||||
w(70, "0");
|
||||
w(62, colour);
|
||||
w(6, "CONTINUOUS");
|
||||
}
|
||||
w(0, "ENDTAB");
|
||||
w(0, "ENDSEC");
|
||||
w(0, "SECTION");
|
||||
w(2, "ENTITIES");
|
||||
let f = |x: f64| format!("{x:.6}");
|
||||
let line = |w: &mut dyn FnMut(i32, &str), layer: &str, a: Vec2, b: Vec2| {
|
||||
w(0, "LINE");
|
||||
w(8, layer);
|
||||
w(10, &f(a.x));
|
||||
w(20, &f(a.y));
|
||||
w(11, &f(b.x));
|
||||
w(21, &f(b.y));
|
||||
};
|
||||
for l in sh.outline()? {
|
||||
let m = l.len();
|
||||
for i in 0..m {
|
||||
if let (Some(a), Some(b)) = (l.get(i), l.get((i + 1) % m)) {
|
||||
line(&mut w, "OUTLINE", *a, *b);
|
||||
}
|
||||
}
|
||||
}
|
||||
for h in &sh.holes {
|
||||
for seg in &h.outer.segs {
|
||||
match *seg {
|
||||
solvecraft_geom::Seg2::Arc { center, radius, start, sweep } => {
|
||||
let (a0, a1) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) };
|
||||
w(0, "ARC");
|
||||
w(8, "OUTLINE");
|
||||
w(10, &f(center.x));
|
||||
w(20, &f(center.y));
|
||||
w(40, &f(radius));
|
||||
w(50, &f(a0.to_degrees()));
|
||||
w(51, &f(a1.to_degrees()));
|
||||
}
|
||||
other => {
|
||||
let pts: Vec<Vec2> = (0..=8).map(|k| other.point_at(k as f64 / 8.0)).collect();
|
||||
for p in pts.windows(2) {
|
||||
line(&mut w, "OUTLINE", p[0], p[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for b in sh.bends() {
|
||||
line(&mut w, if b.up { "BEND_UP" } else { "BEND_DOWN" }, b.start, b.end);
|
||||
}
|
||||
w(0, "ENDSEC");
|
||||
w(0, "EOF");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sheet.export_dxf";
|
||||
let sh = sheet_of(s, p, cmd)?;
|
||||
let text = flat_dxf(&sh)?;
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "bends": sh.bends().len()}));
|
||||
}
|
||||
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sheet_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,143 @@
|
||||
use std::f64::consts::{FRAC_PI_2, 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)
|
||||
}
|
||||
|
||||
fn flat(s: &mut Session) -> Value {
|
||||
run(s, "FusionSheetMetalFlatPatternCmd", json!({}))
|
||||
}
|
||||
|
||||
/// A w x h base flange on XY.
|
||||
fn base(s: &mut Session, w: f64, h: f64) {
|
||||
run(s, "SketchCreate", json!({"plane": "XY", "name": "Base"}));
|
||||
run(s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [w, h]}));
|
||||
run(s, "SketchStop", json!({}));
|
||||
run(s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Base"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base_and_edge_flange_follow_the_k_factor() {
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
assert!(rel(volume(&mut s), 15000.0) < 1e-9);
|
||||
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
// Oracle 68: flat = 60 − 5 + (π/2)(2.5 + 0.44·2.5) + 20 − 5.
|
||||
let want = 60.0 - 5.0 + FRAC_PI_2 * (2.5 + 0.44 * 2.5) + 15.0;
|
||||
let f = flat(&mut s);
|
||||
assert!((f["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6, "{f}");
|
||||
assert_eq!(f["bends"][0]["direction"], "up");
|
||||
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
|
||||
let m = run(&mut s, "MeasureCommand", json!({}));
|
||||
assert_eq!(m["total"]["faces"], 14, "{m}");
|
||||
// The angle is a parameter: 45° (oracle 70 uses height 15).
|
||||
let names = s.doc.features.last().map(|f| f.param_names.clone()).unwrap_or_default();
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": names[0], "expression": "15"}));
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": names[1], "expression": "45 deg"}));
|
||||
assert!(rel(volume(&mut s), 18450.777) < 1e-4, "{}", volume(&mut s));
|
||||
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - 73.685298).abs() < 1e-5);
|
||||
// DXF: outline and an up bend line.
|
||||
let d = run(&mut s, "sheet.export_dxf", json!({}));
|
||||
let text = d["dxf"].as_str().unwrap_or_default();
|
||||
assert!(text.contains("BEND_UP") && text.contains("OUTLINE"), "{text}");
|
||||
assert!(s.execute("FusionSheetMetalFlangeCommand", &json!({"edges": [[50, 30, 40]], "height": 20})).is_err(), "not on an edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_are_parameters() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "1.5 mm"}));
|
||||
run(&mut s, "FusionSheetMetalRulesCommand", json!({"name": "Custom", "thickness": "Thick", "k_factor": 0.33, "bend_radius": "3 mm"}));
|
||||
base(&mut s, 80.0, 50.0);
|
||||
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 1.5], [40, 0, 1.5]], "height": 20}));
|
||||
// Oracle 71: 50 − 9 + 2·(π/2)(3 + 0.495) + 2·15.5 = 82.980.
|
||||
let want = 50.0 - 2.0 * 4.5 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 1.5) + 2.0 * 15.5;
|
||||
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6);
|
||||
assert!(rel(volume(&mut s), 10053.717) < 1e-4, "{}", volume(&mut s));
|
||||
// Thicker: everything follows.
|
||||
run(&mut s, "ChangeParameterCommand", json!({"name": "Thick", "expression": "2 mm"}));
|
||||
let want = 50.0 - 2.0 * 5.0 + 2.0 * FRAC_PI_2 * (3.0 + 0.33 * 2.0) + 2.0 * 15.0;
|
||||
assert!((flat(&mut s)["flat_size_mm"][1].as_f64().unwrap_or(0.0) - want).abs() < 1e-6);
|
||||
let l = run(&mut s, "sheet.rules", json!({}));
|
||||
assert_eq!(l["active"], "Custom", "{l}");
|
||||
assert!(s.execute("FusionSheetMetalRulesCommand", &json!({"name": "Bad", "k_factor": 3})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_flange_hem_and_tray() {
|
||||
// Oracle 72.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "SketchCreate", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, -1]}, "name": "Contour"}));
|
||||
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [40, 0], [40, -30], [70, -30]], "ids": ["l1", "l2", "l3"]}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"sketch": "Contour", "curves": ["l1", "l2", "l3"], "distance": 50}));
|
||||
assert!(rel(volume(&mut s), 12097.622) < 1e-4, "{}", volume(&mut s));
|
||||
assert!((flat(&mut s)["flat_size_mm"][0].as_f64().unwrap_or(0.0) - 96.309734).abs() < 1e-5);
|
||||
// Oracle 73: flange, then a flat hem of 8.
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
run(&mut s, "FusionSheetMetalHemFlangeCommand", json!({"edges": [[50, 0, 20]], "length": 8}));
|
||||
assert!(rel(volume(&mut s), 21334.723) < 1e-4, "{}", volume(&mut s));
|
||||
let b = flat(&mut s)["bends"].clone();
|
||||
assert!(b.as_array().is_some_and(|a| a.iter().any(|x| (x["angle_deg"].as_f64().unwrap_or(0.0) - 180.0).abs() < 1e-9)), "{b}");
|
||||
// A tray: four flanges at once stop at each other's bends.
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 100.0, 60.0);
|
||||
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 60, 2.5], [100, 30, 2.5], [50, 0, 2.5], [0, 30, 2.5]], "height": 25}));
|
||||
let f = flat(&mut s);
|
||||
// Oracle 69: 100 − 10 + 2·5.655 + 2·20 = 141.310 across.
|
||||
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(0.0) - (90.0 + 2.0 * FRAC_PI_2 * 3.6 + 40.0)).abs() < 1e-6, "{f}");
|
||||
let v = volume(&mut s);
|
||||
// Each flange runs on past its bend toward the corner until the inner corners are the
|
||||
// rule's gap apart (diagonally): 5 − (2.5 + 2.5/√2) = 0.732 at each of 8 ends.
|
||||
let section = PI / 4.0 * (25.0 - 6.25) + 2.5 * 20.0;
|
||||
let ext = 5.0 - (2.5 + 2.5 / std::f64::consts::SQRT_2);
|
||||
let want = 90.0 * 50.0 * 2.5 + section * (2.0 * 90.0 + 2.0 * 50.0) + 8.0 * ext * 2.5 * 20.0;
|
||||
assert!(rel(v, want) < 1e-4 && rel(v, 29666.234) < 1e-4, "{v} {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfold_cut_refold_and_convert() {
|
||||
// Oracle 74: two flanges, unfold, a slot across the bend, refold.
|
||||
let mut s = Session::default();
|
||||
base(&mut s, 80.0, 50.0);
|
||||
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[40, 50, 2.5], [40, 0, 2.5]], "height": 20}));
|
||||
let folded = volume(&mut s);
|
||||
run(&mut s, "FusionSheetmetalUnfoldCommand", json!({}));
|
||||
let m = run(&mut s, "MeasureCommand", json!({}));
|
||||
assert!((m["bodies"][0]["bbox"]["max"][2].as_f64().unwrap_or(0.0) - 2.5).abs() < 1e-6, "{m}");
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Slot"}));
|
||||
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [35, -12], "p1": [45, 10]}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "Extrude", json!({"sketch": "Slot", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
run(&mut s, "sheet.refold", json!({}));
|
||||
let v = volume(&mut s);
|
||||
assert!(v < folded - 300.0, "{v} {folded}");
|
||||
assert!(rel(v, 15800.304) < 1e-4, "{v}");
|
||||
assert_eq!(flat(&mut s)["cutouts"].as_array().map(Vec::len), Some(1));
|
||||
// Convert a plate.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 100, "width": 60, "height": 2.5, "body_name": "Plate"}));
|
||||
run(&mut s, "ConvertToSheetMetalCmd", json!({"body": "Plate", "face": [50, 30, 2.5]}));
|
||||
run(&mut s, "FusionSheetMetalFlangeCommand", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
assert!(rel(volume(&mut s), 18972.622) < 1e-4, "{}", volume(&mut s));
|
||||
let mut t = Session::default();
|
||||
run(&mut t, "PrimitiveCylinder", json!({"radius": 10, "height": 30, "body_name": "Rod"}));
|
||||
assert!(t.execute("ConvertToSheetMetalCmd", &json!({"body": "Rod", "face": [0, 0, 30]})).is_err());
|
||||
}
|
||||
@@ -26,6 +26,7 @@ mod offset;
|
||||
mod ops;
|
||||
mod polybool;
|
||||
mod polyhedron;
|
||||
mod sew;
|
||||
mod step;
|
||||
mod step_in;
|
||||
mod step_out;
|
||||
@@ -40,6 +41,7 @@ pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
|
||||
pub use polybool::planar_boolean;
|
||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
|
||||
pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew};
|
||||
pub use step::step_export;
|
||||
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_validate};
|
||||
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Sewing a solid from face descriptions: each face is its boundary loops (lines and circular
|
||||
//! arcs, counter-clockwise about the outward normal; holes clockwise) and its surface (planar,
|
||||
//! revolved from a line, or extruded from an arc). Vertices and edges at the same places are
|
||||
//! shared, so the faces close into one shell without booleans — for solids whose faces are
|
||||
//! known exactly, such as folded sheet metal.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mt::builder;
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, Solid, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// A boundary edge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum EdgeSpec {
|
||||
Line {
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
},
|
||||
/// A circular arc from `a` to `b` through `mid`.
|
||||
Arc {
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
mid: Vec3,
|
||||
},
|
||||
}
|
||||
|
||||
impl EdgeSpec {
|
||||
fn ends(&self) -> (Vec3, Vec3) {
|
||||
match *self {
|
||||
EdgeSpec::Line { a, b } | EdgeSpec::Arc { a, b, .. } => (a, b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A face's surface.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum SurfSpec {
|
||||
/// The plane of the boundary.
|
||||
Plane,
|
||||
/// The surface the line `a`→`b` sweeps turning `angle` about the axis.
|
||||
Revolved { a: Vec3, b: Vec3, axis_origin: Vec3, axis: Vec3, angle: f64 },
|
||||
/// The surface an arc sweeps moving along `dir`.
|
||||
Extruded { a: Vec3, b: Vec3, mid: Vec3, dir: Vec3 },
|
||||
}
|
||||
|
||||
/// A face: boundary loops, surface, and a point on it with its outward normal (curved faces).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FaceSpec {
|
||||
pub loops: Vec<Vec<EdgeSpec>>,
|
||||
pub surface: SurfSpec,
|
||||
pub probe: Option<(Vec3, Vec3)>,
|
||||
}
|
||||
|
||||
type Key = (i64, i64, i64);
|
||||
|
||||
fn key(p: Vec3) -> Key {
|
||||
let q = |x: f64| (x * 1e6).round() as i64;
|
||||
(q(p.x), q(p.y), q(p.z))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Cache {
|
||||
verts: HashMap<Key, mt::Vertex>,
|
||||
edges: HashMap<(Key, Key, Option<Key>), mt::Edge>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
fn vertex(&mut self, p: Vec3) -> mt::Vertex {
|
||||
self.verts.entry(key(p)).or_insert_with(|| builder::vertex(p3(p))).clone()
|
||||
}
|
||||
fn edge(&mut self, e: &EdgeSpec) -> mt::Edge {
|
||||
let (a, b) = e.ends();
|
||||
let mid = match e {
|
||||
EdgeSpec::Arc { mid, .. } => Some(key(*mid)),
|
||||
EdgeSpec::Line { .. } => None,
|
||||
};
|
||||
let (ka, kb) = (key(a), key(b));
|
||||
if let Some(found) = self.edges.get(&(ka, kb, mid)) {
|
||||
return found.clone();
|
||||
}
|
||||
if let Some(found) = self.edges.get(&(kb, ka, mid)) {
|
||||
return found.inverse();
|
||||
}
|
||||
let (va, vb) = (self.vertex(a), self.vertex(b));
|
||||
let made = match *e {
|
||||
EdgeSpec::Line { .. } => builder::line(&va, &vb),
|
||||
EdgeSpec::Arc { mid, .. } => builder::circle_arc(&va, &vb, p3(mid)),
|
||||
};
|
||||
self.edges.insert((ka, kb, mid), made.clone());
|
||||
made
|
||||
}
|
||||
}
|
||||
|
||||
/// Sew the faces into a closed solid.
|
||||
pub fn sew(faces: &[FaceSpec]) -> Result<Body> {
|
||||
if faces.len() < 4 || faces.len() > 200_000 {
|
||||
return Err(KernelError::Invalid("a solid needs 4…200000 faces".into()));
|
||||
}
|
||||
for f in faces {
|
||||
for l in &f.loops {
|
||||
for e in l {
|
||||
let (a, b) = e.ends();
|
||||
if !a.is_finite() || !b.is_finite() || a.dist(b) < 1e-9 {
|
||||
return Err(KernelError::Invalid("sew: a degenerate edge".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guard("sew", || {
|
||||
let mut cache = Cache::default();
|
||||
let mut out: Vec<mt::Face> = Vec::with_capacity(faces.len());
|
||||
for (fi, f) in faces.iter().enumerate() {
|
||||
let wires: Vec<mt::Wire> = f.loops.iter().map(|l| l.iter().map(|e| cache.edge(e)).collect::<Vec<_>>().into()).collect();
|
||||
let face = match f.surface {
|
||||
SurfSpec::Plane => builder::try_attach_plane(&wires).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))?,
|
||||
SurfSpec::Revolved { a, b, axis_origin, axis, angle } => {
|
||||
let k = axis.normalized().ok_or_else(|| KernelError::Invalid("sew: axis".into()))?;
|
||||
let (va, vb) = (builder::vertex(p3(a)), builder::vertex(p3(b)));
|
||||
let swept: mt::Shell = builder::rsweep(&builder::line(&va, &vb), p3(axis_origin), v3(k), mt::Rad(angle));
|
||||
let tmp = swept.face_iter().next().cloned().ok_or_else(|| KernelError::Failed("sew: revolved surface".into()))?;
|
||||
curved(tmp.oriented_surface(), wires, f.probe, fi)?
|
||||
}
|
||||
SurfSpec::Extruded { a, b, mid, dir } => {
|
||||
let (va, vb) = (builder::vertex(p3(a)), builder::vertex(p3(b)));
|
||||
let tmp: mt::Face = builder::tsweep(&builder::circle_arc(&va, &vb, p3(mid)), v3(dir));
|
||||
curved(tmp.oriented_surface(), wires, f.probe, fi)?
|
||||
}
|
||||
};
|
||||
out.push(face);
|
||||
}
|
||||
let shell: mt::Shell = out.into();
|
||||
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sew: the faces do not close: {e}")))?;
|
||||
Body::new(solid)
|
||||
})
|
||||
}
|
||||
|
||||
/// A curved face on `surf`, turned so its normal at the probe points outward.
|
||||
fn curved(mut surf: mt::Surface, wires: Vec<mt::Wire>, probe: Option<(Vec3, Vec3)>, fi: usize) -> Result<mt::Face> {
|
||||
use mt::{ParametricSurface3D, SearchParameter};
|
||||
if let Some((p, outward)) = probe
|
||||
&& let Some((u, v)) = surf.search_parameter(p3(p), None, 100)
|
||||
{
|
||||
let n = surf.normal(u, v);
|
||||
if Vec3::new(n.x, n.y, n.z).dot(outward) < 0.0 {
|
||||
surf = mt::Invertible::inverse(&surf);
|
||||
}
|
||||
}
|
||||
mt::Face::try_new(wires, surf).map_err(|e| KernelError::Failed(format!("sew: face {fi}: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn quad(a: Vec3, b: Vec3, c: Vec3, d: Vec3) -> FaceSpec {
|
||||
FaceSpec {
|
||||
loops: vec![vec![EdgeSpec::Line { a, b }, EdgeSpec::Line { a: b, b: c }, EdgeSpec::Line { a: c, b: d }, EdgeSpec::Line { a: d, b: a }]],
|
||||
surface: SurfSpec::Plane,
|
||||
probe: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sewn_box() {
|
||||
let p = |x: f64, y: f64, z: f64| Vec3::new(x, y, z);
|
||||
let (a, b, c, d) = (p(0., 0., 0.), p(2., 0., 0.), p(2., 3., 0.), p(0., 3., 0.));
|
||||
let (e, f, g, h) = (p(0., 0., 4.), p(2., 0., 4.), p(2., 3., 4.), p(0., 3., 4.));
|
||||
let faces = vec![quad(a, d, c, b), quad(e, f, g, h), quad(a, b, f, e), quad(b, c, g, f), quad(c, d, h, g), quad(d, a, e, h)];
|
||||
let body = sew(&faces).unwrap();
|
||||
let m = crate::measure(&body).unwrap();
|
||||
assert!((m.volume - 24.0).abs() < 1e-9, "{}", m.volume);
|
||||
assert_eq!(body.face_count(), 6);
|
||||
assert!(sew(&faces[..5]).is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user