Thin Extrude builds walls of a set thickness along sketch curves, open chains and closed loops, with the wall centred, inside, outside or on both sides at two thicknesses. Saved under format 20 (thin on an Extrude); an extrude without thin saves as before. Closes #721.
This commit is contained in:
1 parent
86ea018d05
commit
6df69e9def
38 files changed
+1440
-34
No files matched your search
@@ -118,6 +118,7 @@ fn shell_planar_anchor_rebuild_cold_geometry_and_missing_source_errors() {
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"2"})).unwrap(),
|
||||
@@ -280,6 +281,7 @@ fn saved_former_strip_projection_reports_lost_without_expanding_healthy_material
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
@@ -429,6 +431,7 @@ fn saved_curved_strip_projection_is_lost_after_exact_wall_coalescence() {
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
@@ -553,6 +556,7 @@ fn saved_stepped_tab_cut_piece_projection_is_lost_without_expanding_material() {
|
||||
let material = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent: serde_json::from_value(json!({"distance":"1"})).unwrap(),
|
||||
|
||||
@@ -179,6 +179,51 @@ pub struct Extent {
|
||||
pub from: Option<Vec3>,
|
||||
}
|
||||
|
||||
/// Thin Extrude: a wall of constant thickness along sketch curves instead of the profiles.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ThinWall {
|
||||
/// The sketch curves the wall follows: open chains and closed loops, joined where they
|
||||
/// meet end to end.
|
||||
pub curves: Vec<String>,
|
||||
/// The wall thickness (expression); with `side: two_sides`, the thickness inside.
|
||||
pub thickness: String,
|
||||
#[serde(default, skip_serializing_if = "ThinSide::is_center")]
|
||||
pub side: ThinSide,
|
||||
/// With `side: two_sides`, the thickness outside (expression).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub thickness2: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a thin wall lies against its curves. A closed loop's inside is the area it encloses;
|
||||
/// an open chain's is the side it bends toward (a straight one's, the left of its first
|
||||
/// curve as drawn).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ThinSide {
|
||||
/// Half the thickness either side of the curves.
|
||||
#[default]
|
||||
Center,
|
||||
Inside,
|
||||
Outside,
|
||||
/// `thickness` inside and `thickness2` outside.
|
||||
TwoSides,
|
||||
}
|
||||
|
||||
impl ThinSide {
|
||||
pub fn is_center(&self) -> bool {
|
||||
*self == ThinSide::Center
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<ThinSide> {
|
||||
match s.trim().to_ascii_lowercase().replace([' ', '-'], "_").as_str() {
|
||||
"center" | "centre" | "centered" | "centred" | "symmetric" => Some(ThinSide::Center),
|
||||
"inside" | "side1" | "side_1" => Some(ThinSide::Inside),
|
||||
"outside" | "side2" | "side_2" => Some(ThinSide::Outside),
|
||||
"two_sides" | "both" => Some(ThinSide::TwoSides),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Revolve axis.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
@@ -365,6 +410,9 @@ pub enum FeatureKind {
|
||||
/// Target body names for join/cut/intersect (empty = all bodies).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
/// Thin: a wall along sketch curves instead of the profiles (which are then unused).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
thin: Option<ThinWall>,
|
||||
},
|
||||
Revolve {
|
||||
sketch: u64,
|
||||
@@ -1464,6 +1512,8 @@ impl FeatureKind {
|
||||
/// The feature's profile selections, each with its sketch.
|
||||
pub fn profile_sels_mut(&mut self) -> Vec<(u64, &mut ProfileSel)> {
|
||||
match self {
|
||||
// A thin extrude follows curves; its profiles are not used.
|
||||
FeatureKind::Extrude { thin: Some(_), .. } => Vec::new(),
|
||||
FeatureKind::Extrude { sketch, profiles, .. }
|
||||
| FeatureKind::Revolve { sketch, profiles, .. }
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
@@ -1484,6 +1534,8 @@ impl FeatureKind {
|
||||
/// Profile selections and their source sketches without cloning feature data.
|
||||
pub fn profile_sels(&self) -> Vec<(u64, &ProfileSel)> {
|
||||
match self {
|
||||
// A thin extrude follows curves; its profiles are not used.
|
||||
FeatureKind::Extrude { thin: Some(_), .. } => Vec::new(),
|
||||
FeatureKind::Extrude { sketch, profiles, .. }
|
||||
| FeatureKind::Revolve { sketch, profiles, .. }
|
||||
| FeatureKind::Sweep { sketch, profiles, .. }
|
||||
|
||||
+34
-2
@@ -2551,6 +2551,35 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The regions a thin extrude's wall covers in its sketch.
|
||||
pub(crate) fn thin_regions(vals: &BTreeMap<String, Value>, ss: &SolvedSketch, thin: &crate::ThinWall) -> Result<Vec<Region2>> {
|
||||
if thin.curves.is_empty() || thin.curves.len() > 1000 {
|
||||
return Err(DocError::Invalid("a thin extrude needs 1…1000 sketch curves".into()));
|
||||
}
|
||||
let t = val(vals, &thin.thickness, Kind::Length)?;
|
||||
let (inside, outside) = match thin.side {
|
||||
crate::ThinSide::Center => (t / 2.0, t / 2.0),
|
||||
crate::ThinSide::Inside => (t, 0.0),
|
||||
crate::ThinSide::Outside => (0.0, t),
|
||||
crate::ThinSide::TwoSides => {
|
||||
let e = thin.thickness2.as_deref().ok_or_else(|| DocError::Invalid("a two-sided thin wall needs `thickness2`".into()))?;
|
||||
(t, val(vals, e, Kind::Length)?)
|
||||
}
|
||||
};
|
||||
if !(inside >= 0.0 && outside >= 0.0 && inside + outside > 0.0) {
|
||||
return Err(DocError::Invalid("the wall thickness must be positive".into()));
|
||||
}
|
||||
let curves = thin
|
||||
.curves
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}` of {}", ss.name)))?;
|
||||
Ok(ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| DocError::Invalid(format!("thin wall: {e}")))
|
||||
}
|
||||
|
||||
/// How far apart (mm) profiles must stay for their extruded tools to be cut as one.
|
||||
const APART_GAP: f64 = 1e-3;
|
||||
|
||||
@@ -2564,13 +2593,16 @@ fn feature_tools_with_constructor(
|
||||
) -> Result<Vec<Traced>> {
|
||||
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => {
|
||||
let ss = sketch_in_feature_frame(
|
||||
doc,
|
||||
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||||
f.component,
|
||||
)?;
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let regions = match thin {
|
||||
Some(thin) => thin_regions(vals, &ss, thin)?,
|
||||
None => solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?),
|
||||
};
|
||||
// From / To Object: where the face or point lies along the normal, measured over the
|
||||
// middle of the profiles. The points are in this feature's component frame, so the
|
||||
// model is seen from there: a target in another, moved component where it is shown.
|
||||
|
||||
@@ -446,6 +446,7 @@ mod tests {
|
||||
let producer = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: source,
|
||||
profiles: crate::ProfileSel::Indices { indices: vec![0, 1] },
|
||||
extent,
|
||||
@@ -555,7 +556,14 @@ mod tests {
|
||||
let extent = serde_json::from_value(serde_json::json!({"distance":"2"})).unwrap();
|
||||
let boss = doc
|
||||
.add_feature(
|
||||
FeatureKind::Extrude { sketch: sk, profiles: crate::ProfileSel::All, extent, operation: crate::Operation::NewBody, targets: vec![] },
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: sk,
|
||||
profiles: crate::ProfileSel::All,
|
||||
extent,
|
||||
operation: crate::Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
Some("Boss"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -36,6 +36,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> {
|
||||
("sketch", json!({"type": "sketch", "plane": plane("sketch"), "sketch": sk.clone()})),
|
||||
("sketch_along_path", json!({"type": "sketch", "plane": along("sketch"), "sketch": sk})),
|
||||
("extrude", json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("extrude")})),
|
||||
(
|
||||
"extrude_thin",
|
||||
json!({"type": "extrude", "sketch": 1, "profiles": {"type": "all"}, "extent": extent("thin"),
|
||||
"thin": {"curves": ["c1"], "thickness": "thin.thickness", "side": "two_sides", "thickness2": "thin.thickness2"}}),
|
||||
),
|
||||
(
|
||||
"revolve",
|
||||
json!({"type": "revolve", "sketch": 1, "profiles": {"type": "all"}, "axis": {"type": "world", "axis": "X"}, "angle": "revolve.angle", "angle2": "revolve.angle2"}),
|
||||
@@ -158,7 +163,7 @@ pub(super) fn with_refs(name: &str, mut v: Value) -> Value {
|
||||
let o = [0, 0, 0];
|
||||
let tool = json!({"body": "B", "point": o});
|
||||
let extra = match name {
|
||||
"extrude" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
|
||||
"extrude" | "extrude_thin" | "revolve" | "sweep" | "pipe" | "coil" | "thicken" => json!({"targets": ["B"]}),
|
||||
"fillet_constant" | "fillet_chord" | "fillet_curvature" | "fillet_variable" | "fillet_spline" | "chamfer" | "shell" | "draft"
|
||||
| "offset_face" | "delete_face" | "replace_face" | "sheet_flange" | "sheet_hem" | "sheet_unfold" | "boss" | "lip" | "snap_fit" | "rest"
|
||||
| "surface_offset" => {
|
||||
|
||||
@@ -16,6 +16,16 @@ extrude
|
||||
Taper angle = extrude.taper (Angle)
|
||||
To offset = extrude.to_offset (Length)
|
||||
expressions: extrude.distance, extrude.distance2, extrude.start_offset, extrude.taper, extrude.to_offset
|
||||
extrude_thin
|
||||
inputs:
|
||||
Distance = thin.distance (Length)
|
||||
Distance 2 = thin.distance2 (Length)
|
||||
Start offset = thin.start_offset (Length)
|
||||
Taper angle = thin.taper (Angle)
|
||||
To offset = thin.to_offset (Length)
|
||||
Thickness = thin.thickness (Length)
|
||||
Thickness 2 = thin.thickness2 (Length)
|
||||
expressions: thin.distance, thin.distance2, thin.start_offset, thin.taper, thin.to_offset, thin.thickness, thin.thickness2
|
||||
revolve
|
||||
inputs:
|
||||
Angle = revolve.angle (Angle)
|
||||
|
||||
@@ -7,6 +7,10 @@ extrude
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
remap_bodies: targets[0]
|
||||
extrude_thin
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
remap_bodies: targets[0]
|
||||
revolve
|
||||
sketches: [1]
|
||||
remap_refs: sketch
|
||||
|
||||
@@ -138,13 +138,18 @@
|
||||
//! Under the same version, a fillet's style can be `curvature` (a G2 quintic blend; `radius`
|
||||
//! is the equivalent circular setback). Fillets of the other styles save as before; 0.9.0
|
||||
//! would refuse the unknown style.
|
||||
//! - 20: an Extrude can be thin (`thin`: `curves`, `thickness`, optional `side` and
|
||||
//! `thickness2`): a wall along sketch curves instead of its profiles. Version 19 designs need
|
||||
//! no transformation, and an extrude without `thin` saves as before; the version stamp makes
|
||||
//! 0.10.0 (which reads up to 19) ask for an update instead of ignoring the wall and filling
|
||||
//! the sketch's profiles.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{DocError, Document, Result};
|
||||
|
||||
/// The format this build writes.
|
||||
pub const FORMAT: u32 = 19;
|
||||
pub const FORMAT: u32 = 20;
|
||||
|
||||
pub fn format_string(n: u32) -> String {
|
||||
format!("solidcraft/{n}")
|
||||
@@ -161,7 +166,7 @@ pub fn version_of(s: &str) -> Option<u32> {
|
||||
|
||||
/// JSON-level upgrades: entry `i` turns format `i + 1` into `i + 2`.
|
||||
type JsonStep = fn(&mut Value) -> Result<()>;
|
||||
const JSON_STEPS: [JsonStep; 18] = [
|
||||
const JSON_STEPS: [JsonStep; 19] = [
|
||||
|_| Ok(()),
|
||||
v2_to_v3,
|
||||
v3_to_v4,
|
||||
@@ -180,6 +185,7 @@ const JSON_STEPS: [JsonStep; 18] = [
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
];
|
||||
|
||||
/// Stored face and edge names were worked out from geometry before format 14; they get the
|
||||
@@ -1048,6 +1054,7 @@ mod tests {
|
||||
document
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
|
||||
@@ -423,6 +423,7 @@ mod tests {
|
||||
document
|
||||
.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: if missing_sketch { 999_999 } else { id },
|
||||
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
|
||||
extent: crate::Extent {
|
||||
|
||||
@@ -81,7 +81,28 @@ macro_rules! feature_walk {
|
||||
}
|
||||
}
|
||||
},
|
||||
FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } => {
|
||||
FeatureKind::Extrude { extent, thin, .. } => {
|
||||
f("Distance", & $($m)? extent.distance, L, I);
|
||||
if let Some(d) = & $($m)? extent.distance2 {
|
||||
f("Distance 2", d, L, I);
|
||||
}
|
||||
if let Some(d) = & $($m)? extent.start_offset {
|
||||
f("Start offset", d, L, I);
|
||||
}
|
||||
if let Some(d) = & $($m)? extent.taper {
|
||||
f("Taper angle", d, A, I);
|
||||
}
|
||||
if let Some(d) = & $($m)? extent.to_offset {
|
||||
f("To offset", d, L, I);
|
||||
}
|
||||
if let Some(thin) = thin {
|
||||
f("Thickness", & $($m)? thin.thickness, L, I);
|
||||
if let Some(t) = & $($m)? thin.thickness2 {
|
||||
f("Thickness 2", t, L, I);
|
||||
}
|
||||
}
|
||||
}
|
||||
FeatureKind::SurfaceExtrude { extent, .. } => {
|
||||
f("Distance", & $($m)? extent.distance, L, I);
|
||||
if let Some(d) = & $($m)? extent.distance2 {
|
||||
f("Distance 2", d, L, I);
|
||||
|
||||
@@ -308,6 +308,7 @@ mod tests {
|
||||
document
|
||||
.add_feature(
|
||||
crate::FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
@@ -433,6 +434,7 @@ mod tests {
|
||||
document
|
||||
.add_feature(
|
||||
crate::FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
|
||||
@@ -29,6 +29,7 @@ fn plate_doc() -> (Document, u64) {
|
||||
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: s,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
@@ -120,6 +121,7 @@ fn box_fillet_cut_timeline() {
|
||||
let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: s2,
|
||||
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
|
||||
extent: Extent {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, expr::Kind, point_expr};
|
||||
use solidcraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, PointExpr, ProfileSel, ThinSide, ThinWall, expr::Kind, point_expr};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("extrude")
|
||||
.key("E")
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric; distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body]; name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
|
||||
CommandSpec::new("solid.revolve", "Revolve", revolve)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("revolve")
|
||||
@@ -119,6 +119,11 @@ fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
|
||||
let sid = feature_sketch(s, p, "Extrude").ok()?;
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(sid)?;
|
||||
if bool_(p, "thin").unwrap_or(false) {
|
||||
let thin = thin_param(s, p, sid, "solid.extrude").ok()?;
|
||||
let q = thin_regions(s, ss, &thin).ok()?.first()?.interior_point();
|
||||
return Some((ss.plane.to_world(q), ss.plane.normal()));
|
||||
}
|
||||
let ps = &ss.profiles;
|
||||
let q = match profiles(p, "Extrude").ok()? {
|
||||
ProfileSel::All => ps.first()?.region.interior_point(),
|
||||
@@ -505,10 +510,21 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => (feature_sketch(s, p, cmd)?, None),
|
||||
};
|
||||
let extent = extent_param(s, p, cmd)?;
|
||||
let thin = match bool_(p, "thin") {
|
||||
Some(true) => {
|
||||
if face_profile.is_some() || p.get("profiles").is_some() {
|
||||
return Err(bad(cmd, "a thin extrude follows `curves`; it takes no `profiles` or `face`"));
|
||||
}
|
||||
Some(thin_param(s, p, sketch, cmd)?)
|
||||
}
|
||||
_ if p.get("thin").is_some_and(|v| !v.is_boolean()) => return Err(bad(cmd, "`thin` must be true or false")),
|
||||
_ => None,
|
||||
};
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: match face_profile {
|
||||
Some(f) => f,
|
||||
None if thin.is_some() => ProfileSel::All,
|
||||
None => profiles(p, cmd)?,
|
||||
},
|
||||
extent,
|
||||
@@ -517,10 +533,64 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => operation(p, cmd)?,
|
||||
},
|
||||
targets: string_list(p, "targets"),
|
||||
thin,
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn
|
||||
/// curve when none are given), its thickness and which side of them it lies on.
|
||||
fn thin_param(s: &Session, p: &Value, sketch: u64, cmd: &str) -> Result<ThinWall> {
|
||||
let st = s.model.state();
|
||||
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
|
||||
let mut curves = string_list(p, "curves");
|
||||
if curves.is_empty() {
|
||||
curves = ss.sketch.curves.iter().filter(|c| !c.construction && !c.centerline && c.link.is_none()).map(|c| c.id.clone()).collect();
|
||||
}
|
||||
if curves.is_empty() || curves.len() > 1000 {
|
||||
return Err(bad(cmd, "a thin extrude needs 1…1000 sketch curves (`curves`)"));
|
||||
}
|
||||
if let Some(c) = curves.iter().find(|c| ss.sketch.curve_index(c).is_none()) {
|
||||
return Err(bad(cmd, format!("no curve `{c}` in the sketch")));
|
||||
}
|
||||
let thickness = req_expr(cmd, p, "thickness")?;
|
||||
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
|
||||
let side = match str_(p, "side") {
|
||||
None => ThinSide::Center,
|
||||
Some(o) => ThinSide::parse(o).ok_or_else(|| bad(cmd, format!("unknown side `{o}` (center, inside, outside or two_sides)")))?,
|
||||
};
|
||||
let thickness2 = expr(p, "thickness2");
|
||||
match (&thickness2, side) {
|
||||
(Some(t), ThinSide::TwoSides) => check_expr(s, t, Kind::Length, cmd, "thickness2")?,
|
||||
(None, ThinSide::TwoSides) => return Err(bad(cmd, "`side: two_sides` needs `thickness2` (the thickness outside)")),
|
||||
(Some(_), _) => return Err(bad(cmd, "`thickness2` goes with `side: two_sides`")),
|
||||
(None, _) => {}
|
||||
}
|
||||
let thin = ThinWall { curves, thickness, side, thickness2 };
|
||||
// Refuse a wall that can't be built now, with the reason, rather than a failed feature.
|
||||
thin_regions(s, ss, &thin).map_err(|e| bad(cmd, e))?;
|
||||
Ok(thin)
|
||||
}
|
||||
|
||||
/// The regions of a thin wall in its (solved) sketch, with the current parameter values.
|
||||
fn thin_regions(s: &Session, ss: &solidcraft_doc::SolvedSketch, thin: &ThinWall) -> std::result::Result<Vec<solidcraft_geom::Region2>, String> {
|
||||
let length = |e: &str| s.doc.eval(&s.doc.with_design_unit(e, Kind::Length), Kind::Length).map_err(|e| e.to_string());
|
||||
let t = length(&thin.thickness)?;
|
||||
let (inside, outside) = match thin.side {
|
||||
ThinSide::Center => (t / 2.0, t / 2.0),
|
||||
ThinSide::Inside => (t, 0.0),
|
||||
ThinSide::Outside => (0.0, t),
|
||||
ThinSide::TwoSides => (t, length(thin.thickness2.as_deref().unwrap_or("0"))?),
|
||||
};
|
||||
let curves: Vec<Vec<solidcraft_geom::Seg2>> = thin
|
||||
.curves
|
||||
.iter()
|
||||
.filter_map(|id| ss.sketch.curve_index(id))
|
||||
.map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
|
||||
.collect();
|
||||
solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| format!("thin wall: {e}"))
|
||||
}
|
||||
|
||||
/// An extrude's extent from its parameters: distance (or through all, or to an object), the
|
||||
/// side(s), a second distance, a start offset, a taper, and from an object.
|
||||
pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent> {
|
||||
@@ -1696,3 +1766,7 @@ mod solid_move_copy_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "move_in_sketch_tests.rs"]
|
||||
mod move_in_sketch_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thin_extrude_tests.rs"]
|
||||
mod thin_extrude_tests;
|
||||
@@ -1475,7 +1475,7 @@ fn offset_driver_format18_offsets_keep_fixed_distances_on_upgrade() {
|
||||
let mut json: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
json["format"] = json!("solidcraft/18");
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&json.to_string()).unwrap());
|
||||
assert_eq!(reopened.doc.format, "solidcraft/19");
|
||||
assert_eq!(reopened.doc.format, "solidcraft/20");
|
||||
run(&mut reopened, "sketch.edit", json!({"sketch":1}));
|
||||
let sk = sketch(&reopened);
|
||||
assert!((sk.offsets[0].distance - 2.0).abs() < 1e-8);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Thin Extrude (#721): walls along sketch curves, their volumes worked out by hand (a wall's
|
||||
//! area times its height).
|
||||
|
||||
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, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
fn body_count(s: &mut Session) -> u64 {
|
||||
run(s, "inspect.measure", json!({}))["body_count"].as_u64().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) {
|
||||
assert!((a - b).abs() <= 1e-6 * b.abs().max(1.0), "{a} != {b}");
|
||||
}
|
||||
|
||||
/// A sketch on XY holding a 40 × 20 rectangle from (5, 5); returns its side ids.
|
||||
fn rectangle(s: &mut Session) -> Vec<String> {
|
||||
run(s, "sketch.create", json!({"plane": "XY", "name": "Outline"}));
|
||||
let r = run(s, "sketch.rectangle.two_point", json!({"p0": [5, 5], "p1": [45, 25]}));
|
||||
run(s, "sketch.finish", json!({}));
|
||||
["bottom", "right", "top", "left"].iter().map(|k| r["sides"][k].as_str().unwrap_or_default().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_makes_a_ring_wall_on_either_side() {
|
||||
for (side, extra, area) in [
|
||||
("center", json!({}), 42.0 * 22.0 - 38.0 * 18.0),
|
||||
("inside", json!({}), 40.0 * 20.0 - 36.0 * 16.0),
|
||||
("outside", json!({}), 44.0 * 24.0 - 40.0 * 20.0),
|
||||
("two_sides", json!({"thickness2": 1.5}), 43.0 * 23.0 - 39.0 * 19.0),
|
||||
] {
|
||||
let mut s = Session::default();
|
||||
rectangle(&mut s);
|
||||
let mut p = json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": side, "distance": 10});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
if side == "two_sides" {
|
||||
p["thickness"] = json!(0.5);
|
||||
}
|
||||
run(&mut s, "solid.extrude", p);
|
||||
close(volume(&mut s), area * 10.0);
|
||||
assert_eq!(body_count(&mut s), 1, "{side}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thickness_is_a_parameter_and_the_wall_survives_reopening() {
|
||||
let mut s = Session::default();
|
||||
rectangle(&mut s);
|
||||
let r = run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 10, "name": "Wall"}));
|
||||
assert!(r["created_bodies"].as_array().is_some_and(|b| b.len() == 1));
|
||||
close(volume(&mut s), 240.0 * 10.0);
|
||||
let f = s.doc.features.iter().find(|f| f.name == "Wall").cloned().unwrap();
|
||||
let t = f.param_names.iter().zip(f.kind.inputs()).find(|(_, (label, _, _))| *label == "Thickness").map(|(n, _)| n.clone()).unwrap();
|
||||
run(&mut s, "parameters.change", json!({"name": t, "expression": "4"}));
|
||||
close(volume(&mut s), (44.0 * 24.0 - 36.0 * 16.0) * 10.0);
|
||||
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
|
||||
reopened.refresh();
|
||||
close(volume(&mut reopened), (44.0 * 24.0 - 36.0 * 16.0) * 10.0);
|
||||
// A plain extrude of the same sketch fills the rectangle.
|
||||
let mut s = Session::default();
|
||||
rectangle(&mut s);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "distance": 10}));
|
||||
close(volume(&mut s), 800.0 * 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_curves_make_an_open_wall_that_joins_and_cuts() {
|
||||
// Three sides of the rectangle: a U, inside toward the open side's middle.
|
||||
let mut s = Session::default();
|
||||
let sides = rectangle(&mut s);
|
||||
let u = [&sides[0], &sides[1], &sides[2]];
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "curves": u, "thickness": 2, "side": "inside", "distance": 5}));
|
||||
// 40 × 2 twice and 20 × 2, less the two 2 × 2 corners they share.
|
||||
close(volume(&mut s), (80.0 + 40.0 + 80.0 - 8.0) * 5.0);
|
||||
// Cut a groove 3 deep along the whole outline into a block under it.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "corner": [0, 0, -10], "body_name": "Block"}));
|
||||
rectangle(&mut s);
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 3, "direction": "negative", "operation": "cut"}),
|
||||
);
|
||||
close(volume(&mut s), 15000.0 - 240.0 * 3.0);
|
||||
assert_eq!(body_count(&mut s), 1);
|
||||
// And join a rim on top of it.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 50, "width": 30, "height": 10, "corner": [0, 0, -10], "body_name": "Block"}));
|
||||
rectangle(&mut s);
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Outline", "thin": true, "thickness": 2, "distance": 4, "operation": "auto"}));
|
||||
close(volume(&mut s), 15000.0 + 240.0 * 4.0);
|
||||
assert_eq!(body_count(&mut s), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ring_and_a_cross_of_walls_are_one_body() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Target"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 20}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[-20, 0], [20, 0]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, -20], [0, 20]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Target", "thin": true, "thickness": 1, "distance": 5}));
|
||||
assert_eq!(body_count(&mut s), 1);
|
||||
// The ring, plus the bars inside it less where they cross and where they run into it.
|
||||
let ring = std::f64::consts::PI * (20.5f64.powi(2) - 19.5f64.powi(2));
|
||||
let v = volume(&mut s);
|
||||
assert!(v > (ring + 2.0 * 38.0 - 1.0) * 5.0 && v < (ring + 2.0 * 40.0) * 5.0, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_thin_walls_are_refused_and_change_nothing() {
|
||||
let mut s = Session::default();
|
||||
rectangle(&mut s);
|
||||
let before = s.doc.to_json();
|
||||
for (p, says) in [
|
||||
(json!({"thin": true, "thickness": 2, "distance": 5, "curves": ["nope"]}), "no curve"),
|
||||
(json!({"thin": true, "distance": 5}), "thickness"),
|
||||
(json!({"thin": true, "thickness": 2, "distance": 5, "side": "two_sides"}), "thickness2"),
|
||||
(json!({"thin": true, "thickness": 2, "thickness2": 1, "distance": 5}), "two_sides"),
|
||||
(json!({"thin": true, "thickness": 2, "distance": 5, "side": "sideways"}), "unknown side"),
|
||||
(json!({"thin": true, "thickness": 2, "distance": 5, "profiles": [0]}), "profiles"),
|
||||
(json!({"thin": true, "thickness": 0, "distance": 5}), "thickness"),
|
||||
(json!({"thin": true, "thickness": -1, "distance": 5}), "thickness"),
|
||||
(json!({"thin": "yes", "thickness": 1, "distance": 5}), "`thin`"),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["sketch"] = json!("Outline");
|
||||
let e = s.execute("solid.extrude", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains(says), "{p}: {e}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
// A wall thicker than an arc it follows bends.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 1}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let e = s.execute("solid.extrude", &json!({"sketch": "Small", "thin": true, "thickness": 3, "side": "inside", "distance": 5}));
|
||||
assert!(e.is_err_and(|e| e.to_string().contains("thicker")));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Small", "thin": true, "thickness": 3, "side": "outside", "distance": 5}));
|
||||
// Measured from the tessellation, within its 1e-4.
|
||||
let (v, exact) = (volume(&mut s), std::f64::consts::PI * (16.0 - 1.0) * 5.0);
|
||||
assert!((v - exact).abs() < 1e-4 * exact, "{v} vs {exact}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_thin_params_never_panic() {
|
||||
let keys = ["curves", "thickness", "thickness2", "side", "distance", "taper", "direction", "to", "operation"];
|
||||
for v in crate::tests::hostile_values().into_iter().chain([json!(1e9), json!(1e-9), json!("1e6 m"), json!(["l1", "l1", "l1"])]) {
|
||||
for k in keys {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
|
||||
rectangle(&mut s);
|
||||
let mut p = json!({"sketch": "Outline", "thin": true, "thickness": 2, "side": "two_sides", "thickness2": 1, "distance": 5});
|
||||
p[k] = v.clone();
|
||||
if let Err(crate::EngineError::Internal(id, msg)) = s.execute("solid.extrude", &p) {
|
||||
panic!("{id} {p}: {msg}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,7 +602,7 @@ fn script_runs_commands() {
|
||||
assert!(s.run_script(&json!([{"command": "Nope"}])).is_err());
|
||||
}
|
||||
|
||||
fn hostile_values() -> Vec<Value> {
|
||||
pub(crate) fn hostile_values() -> Vec<Value> {
|
||||
vec![
|
||||
Value::Null,
|
||||
json!(true),
|
||||
|
||||
@@ -33,6 +33,7 @@ mod profiles;
|
||||
mod shape;
|
||||
mod solver;
|
||||
mod text;
|
||||
mod thin;
|
||||
mod union_find;
|
||||
mod wire;
|
||||
|
||||
@@ -61,6 +62,7 @@ pub use solver::{
|
||||
solve_after_edit, solve_after_value_edit, solve_holding, solve_with,
|
||||
};
|
||||
pub use text::{MAX_TEXT_CHARS, TextAlign, TextStyle, text_geometry, text_geometry_in, text_geometry_styled};
|
||||
pub use thin::{MAX_THIN_SEGMENTS, thin_wall_regions};
|
||||
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireSpace, fit_polyline};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -73,6 +75,8 @@ mod continuation_tests;
|
||||
mod font_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod thin_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod virtual_corner_tests;
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Thin walls: the regions a wall of constant thickness covers along sketch curves (Thin
|
||||
//! Extrude). Curves that meet end to end are walked as one chain, open or closed; the wall runs
|
||||
//! `inside` to the left of each chain and `outside` to its right. A closed chain runs
|
||||
//! counter-clockwise, so its inside is the area it encloses; an open chain runs so that it
|
||||
//! bends to the left (its inside is the side it curls toward, as a U's inside), and a straight
|
||||
//! one runs the way its first listed curve was drawn.
|
||||
//!
|
||||
//! The wall is the union of a band along every segment (squared off at its ends) and, where
|
||||
//! segments meet at a corner, a mitred piece that closes the outer side of the corner. The
|
||||
//! bands' outlines (offsets and end lines) and the mitres are laid out as sketch curves, the
|
||||
//! sketch's profile detection splits them where they cross, and the faces inside the wall are
|
||||
//! merged into regions. Walls that cross or meet (a honeycomb, a ring and a cross) come out as
|
||||
//! one region where they touch.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use solidcraft_geom::{Loop2, Region2, Seg2, Vec2};
|
||||
|
||||
use crate::model::{CurveKind, Result, Sketch, SketchError};
|
||||
use crate::profiles::{find_profiles, merge_regions};
|
||||
|
||||
/// Most curve segments one wall is built along.
|
||||
pub const MAX_THIN_SEGMENTS: usize = 2_000;
|
||||
/// End points closer than this are one vertex of the curves (mm).
|
||||
const MERGE_TOL: f64 = 1e-6;
|
||||
/// A mitre longer than this many wall thicknesses is cut off square (a bevel).
|
||||
const MITRE_LIMIT: f64 = 4.0;
|
||||
/// Free-form offsets are approximated by cubics within this distance (mm).
|
||||
const FIT_TOL: f64 = 1e-4;
|
||||
/// Most cubic pieces the offset of one free-form segment is approximated with.
|
||||
const MAX_FIT_PIECES: usize = 256;
|
||||
|
||||
fn bad(message: impl Into<String>) -> SketchError {
|
||||
SketchError::Invalid(message.into())
|
||||
}
|
||||
|
||||
/// The regions of a wall `inside` thick to the left of the curves and `outside` to their
|
||||
/// right (see the module docs for which way the curves run). `curves` holds each selected
|
||||
/// curve's exact segments in its drawn direction, in the order the curves were listed.
|
||||
pub fn thin_wall_regions(curves: &[Vec<Seg2>], inside: f64, outside: f64) -> Result<Vec<Region2>> {
|
||||
if !(inside.is_finite() && outside.is_finite()) || inside < 0.0 || outside < 0.0 {
|
||||
return Err(bad("the wall thickness must be a positive length"));
|
||||
}
|
||||
if inside + outside <= 1e-6 {
|
||||
return Err(bad("the wall thickness must be positive"));
|
||||
}
|
||||
if inside.max(outside) > crate::MAX_COORD {
|
||||
return Err(bad("the wall thickness is too large"));
|
||||
}
|
||||
// Every segment with the index of the curve it belongs to.
|
||||
let mut segs: Vec<(usize, Seg2)> = Vec::new();
|
||||
for (k, curve) in curves.iter().enumerate() {
|
||||
for s in curve {
|
||||
if !seg_is_finite(s) {
|
||||
return Err(bad("a wall curve is not finite"));
|
||||
}
|
||||
if s.length() > 1e-9 {
|
||||
segs.push((k, *s));
|
||||
}
|
||||
if segs.len() > MAX_THIN_SEGMENTS {
|
||||
return Err(SketchError::TooLarge);
|
||||
}
|
||||
}
|
||||
}
|
||||
if segs.is_empty() {
|
||||
return Err(bad("a thin wall needs at least one curve"));
|
||||
}
|
||||
let chains = chains(&segs)?;
|
||||
let (mut bands, mut boundary) = (Vec::new(), Vec::new());
|
||||
// Each vertex's segment ends: (vertex, end point, direction away from it, thickness to the
|
||||
// left of that direction, to its right).
|
||||
let mut ends: Vec<(usize, Vec2, Vec2, f64, f64)> = Vec::new();
|
||||
for chain in &chains {
|
||||
for (k, s) in chain.segs.iter().enumerate() {
|
||||
let (start_v, end_v) = chain.vertices.get(k).copied().ok_or_else(|| bad("wall chain"))?;
|
||||
for side in [inside, -outside] {
|
||||
boundary.extend(offset_seg(s, side)?);
|
||||
}
|
||||
for (p, n) in [(s.start(), s.start_tangent().perp()), (s.end(), s.end_tangent().perp())] {
|
||||
boundary.push(Seg2::Line { a: p + n * inside, b: p - n * outside });
|
||||
}
|
||||
ends.push((start_v, s.start(), s.start_tangent(), inside, outside));
|
||||
ends.push((end_v, s.end(), -s.end_tangent(), outside, inside));
|
||||
bands.push((*s, inside, outside));
|
||||
}
|
||||
}
|
||||
// Close the outer side of every corner with a mitre (or a bevel where the mitre would run
|
||||
// far out): between two neighbouring segment ends around a vertex whose walls leave a gap
|
||||
// of more than half a turn.
|
||||
let mut fills: Vec<Loop2> = Vec::new();
|
||||
ends.sort_by(|a, b| a.0.cmp(&b.0).then(a.2.angle().total_cmp(&b.2.angle())));
|
||||
for group in ends.chunk_by(|a, b| a.0 == b.0) {
|
||||
if group.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for (i, a) in group.iter().enumerate() {
|
||||
let Some(b) = group.get((i + 1) % group.len()) else { continue };
|
||||
let gap = (b.2.angle() - a.2.angle()).rem_euclid(TAU);
|
||||
if gap <= PI + 1e-7 {
|
||||
continue;
|
||||
}
|
||||
let (vertex, ui, uj) = (a.1, a.2, b.2);
|
||||
let (ti, tj) = (a.3, b.4);
|
||||
if ti.max(tj) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let pi = vertex + ui.perp() * ti;
|
||||
let pj = b.1 - uj.perp() * tj;
|
||||
let den = ui.cross(uj);
|
||||
let mitre = (den.abs() > 1e-9)
|
||||
.then(|| {
|
||||
let s = (pj - pi).cross(uj) / den;
|
||||
let r = (pj - pi).cross(ui) / den;
|
||||
let m = pi + ui * s;
|
||||
(s <= 1e-12 && r <= 1e-12 && m.dist(vertex) <= MITRE_LIMIT * ti.max(tj)).then_some(m)
|
||||
})
|
||||
.flatten();
|
||||
let pts = match mitre {
|
||||
Some(m) => vec![vertex, pi, m, pj],
|
||||
None => vec![vertex, pi, pj],
|
||||
};
|
||||
let fill = Loop2::polygon(&pts);
|
||||
if fill.signed_area().abs() < 1e-10 {
|
||||
continue;
|
||||
}
|
||||
for w in pts.windows(2).skip(1) {
|
||||
boundary.push(Seg2::Line { a: w[0], b: w[1] });
|
||||
}
|
||||
fills.push(fill);
|
||||
}
|
||||
}
|
||||
let inside_wall = |q: Vec2| bands.iter().any(|(s, l, r)| band_contains(s, *l, *r, q)) || fills.iter().any(|f| f.contains(q));
|
||||
// Lay the outlines out as a sketch, read its regions, and keep the ones in the wall.
|
||||
let sketch = outline_sketch(&boundary)?;
|
||||
let kept: Vec<Region2> = find_profiles(&sketch).into_iter().map(|p| p.region).filter(|r| inside_wall(r.interior_point())).collect();
|
||||
if kept.is_empty() {
|
||||
return Err(bad("the wall has no area"));
|
||||
}
|
||||
Ok(merge_regions(&kept))
|
||||
}
|
||||
|
||||
fn seg_is_finite(s: &Seg2) -> bool {
|
||||
let radius_ok = match *s {
|
||||
Seg2::Arc { radius, start, sweep, .. } => radius.is_finite() && start.is_finite() && sweep.is_finite(),
|
||||
_ => true,
|
||||
};
|
||||
radius_ok && s.start().is_finite() && s.end().is_finite() && s.mid().is_finite()
|
||||
}
|
||||
|
||||
/// A run of segments end to end, oriented as the wall's sides are measured.
|
||||
struct Chain {
|
||||
segs: Vec<Seg2>,
|
||||
/// The vertex at each segment's start and end.
|
||||
vertices: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// The segments walked into chains: maximal runs through vertices where exactly two segments
|
||||
/// meet, each oriented as the module docs say.
|
||||
fn chains(segs: &[(usize, Seg2)]) -> Result<Vec<Chain>> {
|
||||
let mut verts: Vec<Vec2> = Vec::new();
|
||||
let mut vertex = |p: Vec2| match verts.iter().position(|v| v.dist(p) <= MERGE_TOL) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
verts.push(p);
|
||||
verts.len() - 1
|
||||
}
|
||||
};
|
||||
let ends: Vec<(usize, usize)> = segs.iter().map(|(_, s)| (vertex(s.start()), vertex(s.end()))).collect();
|
||||
// Each vertex's segment ends: (segment, whether it's the segment's start).
|
||||
let mut at: Vec<Vec<(usize, bool)>> = vec![Vec::new(); verts.len()];
|
||||
for (e, (a, b)) in ends.iter().enumerate() {
|
||||
if let Some(v) = at.get_mut(*a) {
|
||||
v.push((e, true));
|
||||
}
|
||||
if let Some(v) = at.get_mut(*b) {
|
||||
v.push((e, false));
|
||||
}
|
||||
}
|
||||
let degree = |v: usize| at.get(v).map_or(0, Vec::len);
|
||||
let mut used = vec![false; segs.len()];
|
||||
// Walk from segment `e` entered at its start (or its end), through vertices of degree 2.
|
||||
let walk = |mut e: usize, mut from_start: bool, used: &mut Vec<bool>| -> Vec<(usize, bool)> {
|
||||
let mut run = Vec::new();
|
||||
for _ in 0..segs.len() {
|
||||
if used.get(e).copied().unwrap_or(true) {
|
||||
break;
|
||||
}
|
||||
if let Some(u) = used.get_mut(e) {
|
||||
*u = true;
|
||||
}
|
||||
run.push((e, !from_start));
|
||||
let Some(&(a, b)) = ends.get(e) else { break };
|
||||
let far = if from_start { b } else { a };
|
||||
if degree(far) != 2 {
|
||||
break;
|
||||
}
|
||||
let next = at.get(far).and_then(|v| v.iter().find(|(e2, s2)| !(*e2 == e && *s2 != from_start)).copied());
|
||||
match next {
|
||||
Some((e2, s2)) => {
|
||||
e = e2;
|
||||
from_start = s2;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
run
|
||||
};
|
||||
let mut runs: Vec<(Vec<(usize, bool)>, bool)> = Vec::new();
|
||||
for v in 0..verts.len() {
|
||||
if degree(v) == 2 {
|
||||
continue;
|
||||
}
|
||||
for &(e, s) in at.get(v).map(Vec::as_slice).unwrap_or_default() {
|
||||
if !used.get(e).copied().unwrap_or(true) {
|
||||
runs.push((walk(e, s, &mut used), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
for e in 0..segs.len() {
|
||||
if !used.get(e).copied().unwrap_or(true) {
|
||||
runs.push((walk(e, true, &mut used), true));
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(runs.len());
|
||||
for (mut run, closed) in runs {
|
||||
let oriented = |run: &[(usize, bool)]| -> Vec<Seg2> {
|
||||
run.iter().filter_map(|(e, rev)| segs.get(*e).map(|(_, s)| if *rev { s.reversed() } else { *s })).collect()
|
||||
};
|
||||
let path = oriented(&run);
|
||||
let (Some(first), Some(last)) = (path.first(), path.last()) else { continue };
|
||||
let length: f64 = path.iter().map(Seg2::length).sum();
|
||||
let mut area: f64 = path.iter().map(Seg2::area_term).sum();
|
||||
if !closed {
|
||||
area += 0.5 * last.end().cross(first.start());
|
||||
}
|
||||
let backwards = if area.abs() > 1e-9 * length * length {
|
||||
area < 0.0
|
||||
} else {
|
||||
// Straight: the way the first listed curve of the chain was drawn.
|
||||
run.iter().min_by_key(|(e, _)| segs.get(*e).map_or(usize::MAX, |(k, _)| *k)).is_some_and(|(_, rev)| *rev)
|
||||
};
|
||||
if backwards {
|
||||
run.reverse();
|
||||
for item in &mut run {
|
||||
item.1 = !item.1;
|
||||
}
|
||||
}
|
||||
let vertices = run
|
||||
.iter()
|
||||
.map(|(e, rev)| ends.get(*e).map(|&(a, b)| if *rev { (b, a) } else { (a, b) }))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.ok_or_else(|| bad("wall chain"))?;
|
||||
out.push(Chain { segs: oriented(&run), vertices });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Whether `q` lies in the band `left` thick to the left of `s` and `right` thick to its right,
|
||||
/// between the lines square to `s` at its ends.
|
||||
fn band_contains(s: &Seg2, left: f64, right: f64, q: Vec2) -> bool {
|
||||
let within = |d: f64| d >= -right - 1e-9 && d <= left + 1e-9;
|
||||
match *s {
|
||||
Seg2::Line { a, b } => {
|
||||
let d = b - a;
|
||||
let (t, n) = ((q - a).dot(d) / d.len2(), d.len());
|
||||
t > 1e-12 && t < 1.0 - 1e-12 && within((q - a).dot(d.perp()) / n)
|
||||
}
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
let angle = (q - center).angle();
|
||||
let along = if sweep > 0.0 { (angle - start).rem_euclid(TAU) } else { (start - angle).rem_euclid(TAU) };
|
||||
let rho = q.dist(center);
|
||||
along > 1e-12 && along < sweep.abs() - 1e-12 && within(if sweep > 0.0 { radius - rho } else { rho - radius })
|
||||
}
|
||||
_ => {
|
||||
let t = s.closest_param(q);
|
||||
if t <= 1e-9 || t >= 1.0 - 1e-9 {
|
||||
return false;
|
||||
}
|
||||
s.derivative(t).normalized().is_some_and(|tangent| within((q - s.point_at(t)).dot(tangent.perp())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` moved `d` to its left (to its right when `d` is negative): exact for lines and arcs,
|
||||
/// cubics within [`FIT_TOL`] for free-form segments.
|
||||
fn offset_seg(s: &Seg2, d: f64) -> Result<Vec<Seg2>> {
|
||||
match *s {
|
||||
Seg2::Line { a, b } => {
|
||||
let n = (b - a).normalized().ok_or_else(|| bad("a wall curve has no length"))?.perp();
|
||||
Ok(vec![Seg2::Line { a: a + n * d, b: b + n * d }])
|
||||
}
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
// The centre is on the left of a counter-clockwise arc.
|
||||
let r = if sweep > 0.0 { radius - d } else { radius + d };
|
||||
if r <= 1e-6 {
|
||||
return Err(bad(format!("the wall is thicker than the radius of an arc it follows ({radius:.3} mm)")));
|
||||
}
|
||||
Ok(vec![Seg2::Arc { center, radius: r, start, sweep }])
|
||||
}
|
||||
_ => offset_freeform(s, d),
|
||||
}
|
||||
}
|
||||
|
||||
/// A free-form segment's offset as Hermite cubics through exact offset points and tangents,
|
||||
/// halved until each is within [`FIT_TOL`] of the exact offset.
|
||||
fn offset_freeform(s: &Seg2, d: f64) -> Result<Vec<Seg2>> {
|
||||
if d == 0.0 && matches!(s, Seg2::Cubic { .. }) {
|
||||
return Ok(vec![*s]);
|
||||
}
|
||||
// The exact offset and its derivative at `t`. Along a curve of signed curvature κ the
|
||||
// offset's derivative is the curve's scaled by 1 − dκ; it turns back where that reaches 0.
|
||||
let at = |t: f64| -> Result<(Vec2, Vec2)> {
|
||||
let d1 = s.derivative(t);
|
||||
let speed = d1.len();
|
||||
let tangent = d1.normalized().ok_or_else(|| bad("a wall curve has a point without a direction"))?;
|
||||
let h = 1e-6;
|
||||
let d2 = (s.derivative((t + h).min(1.0)) - s.derivative((t - h).max(0.0))) / ((t + h).min(1.0) - (t - h).max(0.0));
|
||||
let kappa = d1.cross(d2) / (speed * speed * speed);
|
||||
let scale = 1.0 - d * kappa;
|
||||
if !(scale > 0.05) || !kappa.is_finite() {
|
||||
return Err(bad("the wall is thicker than the radius of a curve it follows"));
|
||||
}
|
||||
let p = s.point_at(t) + tangent.perp() * d;
|
||||
if !p.is_finite() {
|
||||
return Err(bad("a wall offset is not finite"));
|
||||
}
|
||||
Ok((p, d1 * scale))
|
||||
};
|
||||
for k in 0..=64 {
|
||||
at(k as f64 / 64.0)?;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![(0.0f64, 1.0f64)];
|
||||
while let Some((t0, t1)) = stack.pop() {
|
||||
let ((q0, v0), (q1, v1)) = (at(t0)?, at(t1)?);
|
||||
let h = (t1 - t0) / 3.0;
|
||||
let cubic = Seg2::Cubic { p0: q0, p1: q0 + v0 * h, p2: q1 - v1 * h, p3: q1 };
|
||||
let mut error = 0.0f64;
|
||||
for k in 1..8 {
|
||||
let u = k as f64 / 8.0;
|
||||
error = error.max(cubic.point_at(u).dist(at(t0 + (t1 - t0) * u)?.0));
|
||||
}
|
||||
if error <= FIT_TOL || out.len() + stack.len() >= MAX_FIT_PIECES {
|
||||
if error > FIT_TOL * 10.0 {
|
||||
return Err(bad("could not follow a free-form wall curve closely enough"));
|
||||
}
|
||||
out.push((t0, cubic));
|
||||
} else {
|
||||
let mid = 0.5 * (t0 + t1);
|
||||
stack.push((mid, t1));
|
||||
stack.push((t0, mid));
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
Ok(out.into_iter().map(|(_, c)| c).collect())
|
||||
}
|
||||
|
||||
/// The outline segments as the curves of a sketch (duplicate lines once).
|
||||
fn outline_sketch(boundary: &[Seg2]) -> Result<Sketch> {
|
||||
let mut sk = Sketch::new();
|
||||
let mut lines: Vec<(Vec2, Vec2)> = Vec::new();
|
||||
for s in boundary {
|
||||
match *s {
|
||||
Seg2::Line { a, b } => {
|
||||
if a.dist(b) <= 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let same = |(p, q): &(Vec2, Vec2)| (p.dist(a) <= 1e-9 && q.dist(b) <= 1e-9) || (p.dist(b) <= 1e-9 && q.dist(a) <= 1e-9);
|
||||
if lines.iter().any(same) {
|
||||
continue;
|
||||
}
|
||||
lines.push((a, b));
|
||||
sk.add_line(a, b, None, None, None)?;
|
||||
}
|
||||
Seg2::Arc { center, sweep, .. } => {
|
||||
let (p0, p1) = if sweep > 0.0 { (s.start(), s.end()) } else { (s.end(), s.start()) };
|
||||
sk.add_arc(center, p0, p1, [None; 3], None)?;
|
||||
}
|
||||
Seg2::Cubic { p0, p1, p2, p3 } => {
|
||||
let pts = [p0, p1, p2, p3].iter().map(|p| sk.add_point(*p, None)).collect::<Result<Vec<_>>>()?;
|
||||
sk.add_curve(CurveKind::Spline { pts, control: true, degree: 3, periodic: false }, None)?;
|
||||
}
|
||||
_ => return Err(bad("not supported yet: this kind of wall curve")),
|
||||
}
|
||||
}
|
||||
Ok(sk)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Thin walls against areas worked out by hand: a wall along a chain covers its length times
|
||||
//! the thickness, less what corners overlap and plus what mitres add.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use solidcraft_geom::{Seg2, Vec2};
|
||||
|
||||
use crate::thin_wall_regions;
|
||||
|
||||
fn line(a: [f64; 2], b: [f64; 2]) -> Vec<Seg2> {
|
||||
vec![Seg2::Line { a: Vec2::new(a[0], a[1]), b: Vec2::new(b[0], b[1]) }]
|
||||
}
|
||||
|
||||
fn area(curves: &[Vec<Seg2>], inside: f64, outside: f64) -> f64 {
|
||||
let regions = thin_wall_regions(curves, inside, outside).unwrap_or_else(|e| panic!("wall: {e}"));
|
||||
regions.iter().map(|r| r.area()).sum()
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) {
|
||||
assert!((a - b).abs() < 1e-6 * (1.0 + b.abs()), "{a} != {b}");
|
||||
}
|
||||
|
||||
/// A 40 × 20 rectangle, its sides listed in any order and drawn either way.
|
||||
fn rectangle() -> Vec<Vec<Seg2>> {
|
||||
vec![line([0.0, 0.0], [40.0, 0.0]), line([40.0, 20.0], [0.0, 20.0]), line([40.0, 0.0], [40.0, 20.0]), line([0.0, 0.0], [0.0, 20.0])]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_makes_a_ring_on_either_side_or_centred() {
|
||||
let regions = thin_wall_regions(&rectangle(), 1.0, 1.0).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].holes.len(), 1);
|
||||
// The mitred corners keep straight sides: four lines outside, four inside.
|
||||
assert_eq!(regions[0].outer.segs.len(), 4);
|
||||
assert_eq!(regions[0].holes[0].segs.len(), 4);
|
||||
close(area(&rectangle(), 1.0, 1.0), 42.0 * 22.0 - 38.0 * 18.0);
|
||||
close(area(&rectangle(), 2.0, 0.0), 40.0 * 20.0 - 36.0 * 16.0);
|
||||
close(area(&rectangle(), 0.0, 2.0), 44.0 * 24.0 - 40.0 * 20.0);
|
||||
close(area(&rectangle(), 0.5, 1.5), 43.0 * 23.0 - 39.0 * 19.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_corner_has_its_inside_where_it_bends() {
|
||||
// (0,0) → (10,0) → (10,10) turns left: the inside is toward the triangle it closes.
|
||||
let l = vec![line([0.0, 0.0], [10.0, 0.0]), line([10.0, 10.0], [10.0, 0.0])];
|
||||
close(area(&l, 1.0, 1.0), 40.0);
|
||||
close(area(&l, 2.0, 0.0), 20.0 + 20.0 - 4.0);
|
||||
close(area(&l, 0.0, 2.0), 20.0 + 20.0 + 4.0);
|
||||
let inside = thin_wall_regions(&l, 2.0, 0.0).unwrap();
|
||||
assert!(inside[0].contains(Vec2::new(5.0, 1.0)) && !inside[0].contains(Vec2::new(5.0, -1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straight_wall_takes_its_side_from_the_drawn_direction() {
|
||||
let up = thin_wall_regions(&[line([0.0, 0.0], [10.0, 0.0])], 2.0, 0.0).unwrap();
|
||||
assert!(up[0].contains(Vec2::new(5.0, 1.0)));
|
||||
let down = thin_wall_regions(&[line([10.0, 0.0], [0.0, 0.0])], 2.0, 0.0).unwrap();
|
||||
assert!(down[0].contains(Vec2::new(5.0, -1.0)));
|
||||
close(area(&[line([0.0, 0.0], [10.0, 0.0])], 1.5, 0.5), 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circle_makes_an_exact_ring() {
|
||||
let circle = solidcraft_geom::Loop2::circle(Vec2::new(3.0, 4.0), 10.0).segs;
|
||||
let regions = thin_wall_regions(std::slice::from_ref(&circle), 1.0, 1.0).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].holes.len(), 1);
|
||||
assert!(regions[0].outer.segs.iter().chain(®ions[0].holes[0].segs).all(|s| matches!(s, Seg2::Arc { .. })));
|
||||
close(area(std::slice::from_ref(&circle), 1.0, 1.0), PI * (11.0f64.powi(2) - 9.0f64.powi(2)));
|
||||
close(area(std::slice::from_ref(&circle), 3.0, 0.0), PI * (100.0 - 49.0));
|
||||
// A clockwise circle still has its inside inside.
|
||||
let cw: Vec<Seg2> = circle.iter().rev().map(Seg2::reversed).collect();
|
||||
close(area(&[cw], 3.0, 0.0), PI * (100.0 - 49.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walls_that_cross_or_meet_are_one_region() {
|
||||
// Two lines crossing, and four meeting at a point: a plus of four 10 mm arms either way.
|
||||
let crossing = vec![line([-10.0, 0.0], [10.0, 0.0]), line([0.0, -10.0], [0.0, 10.0])];
|
||||
let meeting: Vec<Vec<Seg2>> = [[10.0, 0.0], [0.0, 10.0], [-10.0, 0.0], [0.0, -10.0]].iter().map(|p| line([0.0, 0.0], *p)).collect();
|
||||
for curves in [crossing, meeting] {
|
||||
let regions = thin_wall_regions(&curves, 1.0, 1.0).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
close(regions[0].area(), 4.0 * 10.0 * 2.0 - 4.0);
|
||||
}
|
||||
// A ring with a bar across it.
|
||||
let mut ring = vec![solidcraft_geom::Loop2::circle(Vec2::ZERO, 10.0).segs];
|
||||
ring.push(line([-10.0, 0.0], [10.0, 0.0]));
|
||||
let regions = thin_wall_regions(&ring, 1.0, 1.0).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].holes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_walls_stay_separate() {
|
||||
let regions = thin_wall_regions(&[line([0.0, 0.0], [10.0, 0.0]), line([0.0, 5.0], [10.0, 5.0])], 1.0, 1.0).unwrap();
|
||||
assert_eq!(regions.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spline_wall_is_its_length_times_its_thickness() {
|
||||
// A gentle cubic; its wall's area is its length times the thickness (a centred wall's
|
||||
// sides are as much longer on one side as shorter on the other).
|
||||
let s = Seg2::Cubic { p0: Vec2::new(0.0, 0.0), p1: Vec2::new(10.0, 8.0), p2: Vec2::new(20.0, -8.0), p3: Vec2::new(30.0, 0.0) };
|
||||
// Within the fit's 1e-4 mm along both sides.
|
||||
let a = area(&[vec![s]], 0.5, 0.5);
|
||||
assert!((a - s.length()).abs() < 2.0 * s.length() * 1e-4, "{a} vs {}", s.length());
|
||||
let regions = thin_wall_regions(&[vec![s]], 0.5, 0.5).unwrap();
|
||||
for q in [0.25, 0.5, 0.75] {
|
||||
let p = s.point_at(q);
|
||||
let n = s.derivative(q).normalized().unwrap().perp();
|
||||
assert!(regions[0].contains(p + n * 0.4) && regions[0].contains(p - n * 0.4));
|
||||
assert!(!regions[0].contains(p + n * 0.6) && !regions[0].contains(p - n * 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walls_thicker_than_a_curve_bends_or_without_thickness_are_refused() {
|
||||
let arc = vec![Seg2::Arc { center: Vec2::ZERO, radius: 2.0, start: 0.0, sweep: PI }];
|
||||
assert!(thin_wall_regions(std::slice::from_ref(&arc), 3.0, 0.0).is_err());
|
||||
assert!(thin_wall_regions(&[arc], 0.0, 3.0).is_ok());
|
||||
let l = [line([0.0, 0.0], [10.0, 0.0])];
|
||||
for (a, b) in [(0.0, 0.0), (-1.0, 2.0), (f64::NAN, 1.0), (1.0, f64::INFINITY)] {
|
||||
assert!(thin_wall_regions(&l, a, b).is_err());
|
||||
}
|
||||
assert!(thin_wall_regions(&[], 1.0, 1.0).is_err());
|
||||
assert!(thin_wall_regions(&[line([f64::NAN, 0.0], [1.0, 0.0])], 1.0, 1.0).is_err());
|
||||
}
|
||||
|
||||
/// The edges of an `n` × `n` grid of hexagons of side `a` (pointy top), each edge once.
|
||||
fn honeycomb(n: usize, a: f64) -> Vec<Vec<Seg2>> {
|
||||
let key = |p: Vec2| ((p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
let w = a * 3f64.sqrt();
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let c = Vec2::new(i as f64 * w + if j % 2 == 1 { w / 2.0 } else { 0.0 }, j as f64 * 1.5 * a);
|
||||
let corners: Vec<Vec2> = (0..6).map(|k| c + Vec2::from_angle(PI / 6.0 + k as f64 * PI / 3.0) * a).collect();
|
||||
for k in 0..6 {
|
||||
let (p, q) = (corners[k], corners[(k + 1) % 6]);
|
||||
let edge = if key(p) < key(q) { (key(p), key(q)) } else { (key(q), key(p)) };
|
||||
if seen.insert(edge) {
|
||||
out.push(vec![Seg2::Line { a: p, b: q }]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_honeycomb_is_one_region_with_a_hole_per_cell() {
|
||||
let cells = honeycomb(4, 5.0);
|
||||
let regions = thin_wall_regions(&cells, 0.4, 0.4).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].holes.len(), 16);
|
||||
}
|
||||
@@ -59,6 +59,8 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
let n = if *flip && !crate::sketch_modify_ui::offset_symmetric(*mode) { -tangent.perp() } else { tangent.perp() };
|
||||
Some(ss.plane.to_world(mid + n) - ss.plane.to_world(mid))
|
||||
}
|
||||
// A thin extrude's curves extrude along their sketch's normal.
|
||||
Kind::Extrude { .. } => Some(ss.plane.normal()),
|
||||
_ => None,
|
||||
};
|
||||
(ss.plane.to_world(mid), dir)
|
||||
|
||||
+133
-10
@@ -9,7 +9,7 @@ use serde_json::{Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
use solidcraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel};
|
||||
use solidcraft_engine::doc::{AxisRef, Direction, FeatureKind, FilletStyle, HoleKind, Operation, PatternKind, PlaneRef, ProfileSel, ThinSide};
|
||||
use solidcraft_engine::geom::{Aabb3, Vec3};
|
||||
|
||||
use crate::SolveApp;
|
||||
@@ -22,6 +22,12 @@ pub(crate) const OP_LABELS: [&str; 4] = ["New Body", "Join", "Cut", "Intersect"]
|
||||
const DIRS: [&str; 4] = ["positive", "negative", "symmetric", "positive"];
|
||||
/// The "Two sides" direction (a second distance the other way).
|
||||
pub const TWO_SIDES: usize = 3;
|
||||
/// Thin Extrude wall locations, as `solid.extrude` names them, and their labels.
|
||||
const WALL_SIDES: [&str; 4] = ["center", "inside", "outside", "two_sides"];
|
||||
const WALL_LABELS: [&str; 4] = ["Center", "Inside", "Outside", "Two Sides"];
|
||||
/// What an extrude's first input picks: profiles or planar faces, or curves too when thin.
|
||||
const EXTRUDE_ACCEPT: Accept = PROFILES | PLANAR_FACES;
|
||||
const THIN_ACCEPT: Accept = PROFILES | CURVES;
|
||||
const HOLE_TYPES: [&str; 3] = ["simple", "counterbore", "countersink"];
|
||||
const HOLE_LABELS: [&str; 3] = ["Simple", "Counterbore", "Countersink"];
|
||||
|
||||
@@ -48,6 +54,14 @@ pub enum Kind {
|
||||
start: String,
|
||||
/// Extent type All (through everything) instead of a distance.
|
||||
all: bool,
|
||||
/// Thin Extrude: a wall along the picked curves (or profiles' outlines).
|
||||
thin: bool,
|
||||
/// The wall thickness (inside, with Two Sides).
|
||||
wall: String,
|
||||
/// Wall location: an index into [`WALL_SIDES`].
|
||||
wall_side: usize,
|
||||
/// The thickness outside, with Two Sides.
|
||||
wall2: String,
|
||||
},
|
||||
Revolve {
|
||||
angle: String,
|
||||
@@ -376,8 +390,12 @@ impl Dialog {
|
||||
to_offset: String::new(),
|
||||
start: String::new(),
|
||||
all: false,
|
||||
thin: false,
|
||||
wall: "2 mm".into(),
|
||||
wall_side: 0,
|
||||
wall2: "1 mm".into(),
|
||||
},
|
||||
vec![SelInput::new("Profiles", PROFILES | PLANAR_FACES, true)],
|
||||
vec![SelInput::new("Profiles", EXTRUDE_ACCEPT, true)],
|
||||
),
|
||||
"solid.revolve" => Dialog::new(
|
||||
Kind::Revolve { angle: "360 deg".into(), operation: 0, axis: None },
|
||||
@@ -1913,7 +1931,40 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => {
|
||||
Kind::Extrude {
|
||||
distance,
|
||||
direction,
|
||||
operation,
|
||||
auto_op,
|
||||
distance2,
|
||||
taper,
|
||||
taper_present,
|
||||
to_offset,
|
||||
start,
|
||||
all,
|
||||
thin,
|
||||
wall,
|
||||
wall_side,
|
||||
wall2,
|
||||
} => {
|
||||
row_label(ui, "Thin");
|
||||
if ui.checkbox(thin, tr("Thin Extrude")).changed() {
|
||||
set_thin(&mut d.inputs, *thin);
|
||||
}
|
||||
ui.end_row();
|
||||
if *thin {
|
||||
row_label(ui, "Wall Location");
|
||||
combo(ui, "ex_wall", &WALL_LABELS, wall_side);
|
||||
ui.end_row();
|
||||
row_label(ui, "Wall Thickness");
|
||||
enter |= field(ui, wall);
|
||||
ui.end_row();
|
||||
if *wall_side == 3 {
|
||||
row_label(ui, "Thickness Outside");
|
||||
enter |= field(ui, wall2);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
row_label(ui, "Start");
|
||||
let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) };
|
||||
let mut st = old;
|
||||
@@ -3016,7 +3067,7 @@ pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>
|
||||
let (cmd, mut params) = cmds.into_iter().next().ok_or("nothing to apply")?;
|
||||
if let Some(feature) = app.session.doc.feature(id) {
|
||||
let original_all = match &feature.kind {
|
||||
FeatureKind::Extrude { sketch, profiles: ProfileSel::All, .. }
|
||||
FeatureKind::Extrude { sketch, profiles: ProfileSel::All, thin: None, .. }
|
||||
| FeatureKind::Revolve { sketch, profiles: ProfileSel::All, .. }
|
||||
| FeatureKind::Emboss { sketch, profiles: ProfileSel::All, .. } => Some(*sketch),
|
||||
_ => None,
|
||||
@@ -3093,8 +3144,23 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
sels(d, i).iter().filter_map(|x| if let Sel::Face { point, .. } = x { Some(pt(*point)) } else { None }).collect()
|
||||
};
|
||||
let (cmd, params): (&str, Value) = match &d.kind {
|
||||
Kind::Extrude { distance, direction, operation, distance2, taper, taper_present, to_offset, start, all, .. } => {
|
||||
need(0, "profiles or a planar face")?;
|
||||
Kind::Extrude {
|
||||
distance,
|
||||
direction,
|
||||
operation,
|
||||
distance2,
|
||||
taper,
|
||||
taper_present,
|
||||
to_offset,
|
||||
start,
|
||||
all,
|
||||
thin,
|
||||
wall,
|
||||
wall_side,
|
||||
wall2,
|
||||
..
|
||||
} => {
|
||||
need(0, if *thin { "curves or profiles" } else { "profiles or a planar face" })?;
|
||||
let mut common = json!({"distance": distance, "direction": DIRS.get(*direction).copied().unwrap_or("positive"), "operation": OPS.get(*operation).copied().unwrap_or("new")});
|
||||
let tapered = s.doc.eval(taper, ValueKind::Angle).map(|v| v.abs() > 1e-12).unwrap_or(!taper.trim().is_empty());
|
||||
if *direction == TWO_SIDES {
|
||||
@@ -3127,6 +3193,37 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
p
|
||||
};
|
||||
if *thin {
|
||||
// The picked curves, and the outlines (outer and holes) of picked profiles.
|
||||
let st = s.world_state();
|
||||
let mut curves = curve_ids(d, 0);
|
||||
let mut sketch = None;
|
||||
for x in sels(d, 0) {
|
||||
if let Sel::Profile { sketch: id, index } = x
|
||||
&& let Some(p) = st.sketch(*id).and_then(|ss| ss.profiles.get(*index))
|
||||
{
|
||||
sketch = Some(*id);
|
||||
curves.extend(p.curve_ids());
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
curves.retain(|c| seen.insert(c.clone()));
|
||||
// A picked profile's sketch, or the edited extrude's, when it has every curve.
|
||||
let edited = d.editing.and_then(|(id, _)| s.doc.feature(id)).and_then(|f| match f.kind {
|
||||
FeatureKind::Extrude { sketch, .. } => Some(sketch),
|
||||
_ => None,
|
||||
});
|
||||
let has_all = |id: u64| curves.iter().all(|c| st.sketch(id).is_some_and(|ss| ss.sketch.curve_index(c).is_some()));
|
||||
let sketch = match sketch.or(edited) {
|
||||
Some(id) if has_all(id) => id,
|
||||
_ => curves_sketch(app, &curves, None).ok_or("the curves must be in one sketch")?,
|
||||
};
|
||||
let mut wall_params = json!({"sketch": sketch, "thin": true, "curves": curves, "thickness": wall, "side": WALL_SIDES.get(*wall_side).copied().unwrap_or("center")});
|
||||
if *wall_side == 3 {
|
||||
wall_params["thickness2"] = json!(wall2);
|
||||
}
|
||||
return Ok(vec![("solid.extrude".to_string(), with(wall_params))]);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let (sketch, idx) = profiles();
|
||||
if !sketch.is_null() {
|
||||
@@ -3660,6 +3757,21 @@ fn box_corner(s: &Session, fields: &[(&'static str, String)], p: Vec3, normal: O
|
||||
json!([coord(p.x, size("length"), Vec3::X), coord(p.y, size("width"), Vec3::Y), coord(p.z, size("height"), Vec3::Z)])
|
||||
}
|
||||
|
||||
/// Switch an extrude's first input between profiles and faces, and (thin) profiles and curves;
|
||||
/// picks the other kind doesn't take are dropped.
|
||||
fn set_thin(inputs: &mut [SelInput], thin: bool) {
|
||||
if let Some(input) = inputs.first_mut() {
|
||||
input.accept = if thin { THIN_ACCEPT } else { EXTRUDE_ACCEPT };
|
||||
input.label = if thin { "Profiles/Curves" } else { "Profiles" };
|
||||
input.items.retain(|x| match x {
|
||||
Sel::Profile { .. } => true,
|
||||
Sel::SketchCurve { .. } => thin,
|
||||
Sel::Face { .. } => !thin,
|
||||
_ => false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Sketch curve ids picked in an input.
|
||||
fn curve_ids(d: &Dialog, i: usize) -> Vec<String> {
|
||||
sels(d, i).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect()
|
||||
@@ -3912,7 +4024,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
let st = s.world_state();
|
||||
let start = |cmd: &str| Dialog::for_command(app, cmd);
|
||||
let mut d = match &f.kind {
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets, thin } => {
|
||||
let mut d = start("solid.extrude")?;
|
||||
d.kind = Kind::Extrude {
|
||||
distance: extent.distance.clone(),
|
||||
@@ -3930,6 +4042,10 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
to_offset: extent.to_offset.clone().unwrap_or_default(),
|
||||
start: extent.start_offset.clone().unwrap_or_default(),
|
||||
all: extent.through_all,
|
||||
thin: thin.is_some(),
|
||||
wall: thin.as_ref().map_or_else(|| "2 mm".into(), |t| t.thickness.clone()),
|
||||
wall_side: thin.as_ref().map_or(0, |t| WALL_SIDES.iter().position(|w| ThinSide::parse(w) == Some(t.side)).unwrap_or(0)),
|
||||
wall2: thin.as_ref().and_then(|t| t.thickness2.clone()).unwrap_or_else(|| "1 mm".into()),
|
||||
};
|
||||
// Extent references belong to the extrude's authoring frame, including free
|
||||
// points. They are not body-local picks (a reference may be on another body).
|
||||
@@ -3942,9 +4058,16 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
}
|
||||
}
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
if let Some(thin) = thin {
|
||||
set_thin(&mut d.inputs, true);
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = thin.curves.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect();
|
||||
}
|
||||
} else {
|
||||
let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default();
|
||||
if let Some(inp) = d.inputs.first_mut() {
|
||||
inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
|
||||
}
|
||||
}
|
||||
if !targets.is_empty() {
|
||||
d.extra.insert("targets".into(), json!(targets));
|
||||
|
||||
@@ -125,6 +125,8 @@ mod sketch_text_move;
|
||||
pub mod sketch_tools;
|
||||
pub mod stand_up;
|
||||
pub mod theme;
|
||||
#[cfg(test)]
|
||||
mod thin_extrude_tests;
|
||||
pub mod timeline;
|
||||
pub mod timeline_left;
|
||||
pub mod titlebar;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! The Extrude dialog's Thin Extrude (#721): a picked profile's outline becomes the wall's
|
||||
//! curves, and editing a thin extrude keeps it thin.
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::{FeatureKind, ThinSide};
|
||||
|
||||
fn rectangle(h: &mut Harness) -> u64 {
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,20]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app.session.doc.find_feature("Base").unwrap().id
|
||||
}
|
||||
|
||||
fn volume(h: &Harness) -> f64 {
|
||||
h.app.session.world_state().bodies.iter().map(|b| solidcraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_thin_extrude_of_a_picked_profile_follows_its_outline() {
|
||||
let mut h = Harness::new();
|
||||
let sketch = rectangle(&mut h);
|
||||
h.step(&json!({"call":"ui.start","params":{"command":"solid.extrude"}})).unwrap();
|
||||
h.frames(3);
|
||||
h.app.dialog.as_mut().unwrap().inputs[0].items = vec![Sel::Profile { sketch, index: 0 }];
|
||||
h.step(&json!({"widget":"Thin Extrude"})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Wall Thickness").is_some(), "the wall rows show once Thin is on");
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
let f = h.app.session.doc.features.last().unwrap();
|
||||
let FeatureKind::Extrude { thin: Some(thin), .. } = &f.kind else { panic!("a thin extrude: {:?}", f.kind) };
|
||||
assert_eq!(thin.curves.len(), 4);
|
||||
assert_eq!(thin.side, ThinSide::Center);
|
||||
// A 2 mm wall centred on the 40 × 20 outline, 10 mm high.
|
||||
assert!((volume(&h) - (42.0 * 22.0 - 38.0 * 18.0) * 10.0).abs() < 1e-6, "{}", volume(&h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_thin_extrude_keeps_its_wall() {
|
||||
let mut h = Harness::new();
|
||||
rectangle(&mut h);
|
||||
h.app
|
||||
.run(
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Base","thin":true,"thickness":"3 mm","side":"two_sides","thickness2":"1 mm","distance":"5 mm","name":"Wall"}),
|
||||
)
|
||||
.unwrap();
|
||||
h.frames(4);
|
||||
let before = h.app.session.doc.find_feature("Wall").unwrap().clone();
|
||||
let v = volume(&h);
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Wall"}})).unwrap();
|
||||
h.frames(8);
|
||||
assert!(h.widget("Thickness Outside").is_some());
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.session.log);
|
||||
let after = h.app.session.doc.find_feature("Wall").unwrap();
|
||||
assert_eq!(after.kind, before.kind);
|
||||
assert_eq!(after.param_names, before.param_names);
|
||||
assert!((volume(&h) - v).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thin_rows_are_translated() {
|
||||
for (lang, thin, location, outside) in
|
||||
[("ja", "薄肉押し出し", "壁の位置", "外側の厚さ"), ("de", "Dünnwandig extrudieren", "Wandlage", "Dicke außen")]
|
||||
{
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.language = lang.into();
|
||||
rectangle(&mut h);
|
||||
h.app
|
||||
.run(
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Base","thin":true,"thickness":"3 mm","side":"two_sides","thickness2":"1 mm","distance":"5 mm","name":"Wall"}),
|
||||
)
|
||||
.unwrap();
|
||||
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Wall"}})).unwrap();
|
||||
h.frames(8);
|
||||
for word in [thin, location, outside] {
|
||||
assert!(h.widget(word).is_some(), "{lang}: {word:?} must be on screen");
|
||||
}
|
||||
assert!(h.widget("Wall Location").is_none(), "{lang}: must not fall back to English");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user