Extrude: a symmetric distance is the total thickness (#1260)
A symmetric solid extrude of 10 mm is now 10 mm thick, 5 mm each side of the sketch plane, and switching from One Side to Symmetric keeps the thickness entered. Under format 21 the extent records its convention; designs saved without it keep the per-side reading, so their geometry and parameters don't change. Fixes #1236. Co-authored-by: Karl-VonRichter <[email protected]>
This commit is contained in:
1 parent
faeeff1c64
commit
1ec33fcdc7
19 files changed
+323
-18
No files matched your search
@@ -92,7 +92,7 @@ fn extent(f: &Value, p: &mut Value) {
|
||||
"symmetric" => {
|
||||
let full = e.get("is_full_length").and_then(Value::as_bool).unwrap_or(false);
|
||||
p["direction"] = json!("symmetric");
|
||||
p["distance"] = if full { json!(format!("({}) / 2", expr_text(&dist))) } else { dist };
|
||||
p["distance"] = if full { dist } else { json!(format!("({}) * 2", expr_text(&dist))) };
|
||||
}
|
||||
"two_sides" => {
|
||||
p["distance"] = e.get("side1").cloned().unwrap_or(Value::Null);
|
||||
|
||||
@@ -134,7 +134,7 @@ pub enum Direction {
|
||||
#[default]
|
||||
Positive,
|
||||
Negative,
|
||||
/// Both sides; `distance` per side.
|
||||
/// Both sides; the extent convention determines whether `distance` is total or per side.
|
||||
Symmetric,
|
||||
}
|
||||
|
||||
@@ -186,12 +186,33 @@ pub enum RevolveConvention {
|
||||
SketchNormal,
|
||||
}
|
||||
|
||||
/// Symmetric distance semantics. Missing markers preserve existing designs' thickness.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExtrudeConvention {
|
||||
#[default]
|
||||
LegacyPerSide,
|
||||
TotalDistance,
|
||||
}
|
||||
|
||||
impl ExtrudeConvention {
|
||||
/// Distance from the mid-plane to either cap for a distance-limited extent.
|
||||
pub fn symmetric_half_distance(self, distance: f64) -> f64 {
|
||||
match self {
|
||||
Self::LegacyPerSide => distance,
|
||||
Self::TotalDistance => distance * 0.5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extent of an extrude.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)]
|
||||
pub struct Extent {
|
||||
#[walk(length = "Distance")]
|
||||
pub distance: String,
|
||||
#[serde(default)]
|
||||
pub convention: ExtrudeConvention,
|
||||
#[serde(default)]
|
||||
pub direction: Direction,
|
||||
/// Optional second side distance (two-sided extrude), along the negative normal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
|
||||
@@ -3291,7 +3291,11 @@ pub(crate) fn extent_sides(vals: &BTreeMap<String, Value>, extent: &crate::Exten
|
||||
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
|
||||
(None, crate::Direction::Positive) => (0.0, d),
|
||||
(None, crate::Direction::Negative) => (-d, 0.0),
|
||||
(None, crate::Direction::Symmetric) => (-d, d),
|
||||
(None, crate::Direction::Symmetric) => {
|
||||
// Through All is a reach computed from target bodies, not an entered total.
|
||||
let half = if extent.through_all { d } else { extent.convention.symmetric_half_distance(d) };
|
||||
(-half, half)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,11 @@
|
||||
//! neighbouring edges as they are (`neighbours: false`). An Extend without them builds the
|
||||
//! planar extension it always did and saves as before; 0.11.0 asks for an update instead of
|
||||
//! dropping them and extending a plane by the distance.
|
||||
//! Under the same version, an extrude's extent records how a symmetric `distance` is read
|
||||
//! (`convention`: `total_distance`, split evenly around the start plane, which new solid
|
||||
//! extrudes save; or `legacy_per_side`). An extent without it keeps the per-side reading
|
||||
//! designs always had, so their thickness and parameters don't change; 0.11.0 asks for an
|
||||
//! update instead of doubling a total-distance extrude.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -1131,6 +1136,7 @@ mod tests {
|
||||
sketch,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "10".into(),
|
||||
direction: Direction::Negative,
|
||||
distance2: None,
|
||||
|
||||
@@ -427,6 +427,7 @@ mod tests {
|
||||
sketch: if missing_sketch { 999_999 } else { id },
|
||||
profiles: ProfileSel::OffsetBoundaries { associations: vec!["deleted-o1".into()] },
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
|
||||
@@ -312,6 +312,7 @@ mod tests {
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
@@ -438,6 +439,7 @@ mod tests {
|
||||
sketch: feature,
|
||||
profiles: selection.clone(),
|
||||
extent: crate::Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "2".into(),
|
||||
direction: crate::Direction::Positive,
|
||||
distance2: None,
|
||||
|
||||
@@ -33,6 +33,7 @@ fn plate_doc() -> (Document, u64) {
|
||||
sketch: s,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "20".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
@@ -125,6 +126,7 @@ fn box_fillet_cut_timeline() {
|
||||
sketch: s2,
|
||||
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "20".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
|
||||
@@ -53,6 +53,7 @@ impl Walk for crate::document::ThinSide {}
|
||||
impl Walk for crate::document::ExtendMode {}
|
||||
impl Walk for crate::document::RuledMode {}
|
||||
impl Walk for crate::document::RuledCorners {}
|
||||
impl Walk for crate::document::ExtrudeConvention {}
|
||||
|
||||
/// Containers walk what they hold, in order.
|
||||
macro_rules! walk_each {
|
||||
|
||||
@@ -626,6 +626,13 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
merge(&mut v, set, 0);
|
||||
let mut nf: Feature = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?;
|
||||
nf.id = f.id;
|
||||
if let (FeatureKind::Extrude { extent: old, .. }, FeatureKind::Extrude { extent: new, .. }) = (&f.kind, &mut nf.kind)
|
||||
&& old.direction != solidcraft_doc::Direction::Symmetric
|
||||
&& new.direction == solidcraft_doc::Direction::Symmetric
|
||||
&& set.get("extent").and_then(|e| e.get("convention")).is_none()
|
||||
{
|
||||
new.convention = solidcraft_doc::ExtrudeConvention::TotalDistance;
|
||||
}
|
||||
if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) {
|
||||
return Err(bad(cmd, "the feature type cannot change"));
|
||||
}
|
||||
@@ -862,6 +869,17 @@ fn redefine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut params = p.get("params").cloned().unwrap_or_else(|| json!({}));
|
||||
let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?;
|
||||
if let FeatureKind::Extrude { extent, .. } | FeatureKind::SurfaceExtrude { extent, .. } = &old.kind
|
||||
&& matches!(cid, "solid.extrude" | "surface.extrude")
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
{
|
||||
let convention = if cid == "surface.extrude" || extent.direction == solidcraft_doc::Direction::Symmetric {
|
||||
extent.convention
|
||||
} else {
|
||||
solidcraft_doc::ExtrudeConvention::TotalDistance
|
||||
};
|
||||
parameters.entry("convention").or_insert_with(|| json!(convention));
|
||||
}
|
||||
if let FeatureKind::Revolve { convention, .. } = &old.kind
|
||||
&& cid == "solid.revolve"
|
||||
&& let Some(parameters) = params.as_object_mut()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use crate::Session;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{Document, ExtrudeConvention, FeatureKind};
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, params: Value) {
|
||||
s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
}
|
||||
|
||||
fn fixture(settings: Value) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY","name":"Square"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let mut p = json!({"sketch":"Square","distance":"10 mm","direction":"symmetric","operation":"new","name":"Depth","body_name":"Block"});
|
||||
p.as_object_mut().unwrap().extend(settings.as_object().unwrap().clone());
|
||||
run(&mut s, "solid.extrude", p);
|
||||
s
|
||||
}
|
||||
|
||||
fn check(s: &Session, lo: f64, hi: f64) {
|
||||
assert!(s.model.results.iter().all(|r| r.error.is_none()));
|
||||
let st = s.model.state();
|
||||
let body = st.body("Block").unwrap();
|
||||
let bounds = body.mesh().bounds();
|
||||
assert!((bounds.min.z - lo).abs() < 1e-7 && (bounds.max.z - hi).abs() < 1e-7, "{bounds:?}");
|
||||
let m = solidcraft_kernel::measure(&body.body).unwrap();
|
||||
assert!((m.volume - 100.0 * (hi - lo)).abs() < 1e-7, "{m:?}");
|
||||
assert!((m.centroid.z - (lo + hi) * 0.5).abs() < 1e-7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_total_distance_is_centered_and_follows_parameters() {
|
||||
for distance in ["10 mm", "-10 mm"] {
|
||||
let mut s = fixture(json!({"distance":distance,"from":[0,0,2],"start_offset":"3 mm"}));
|
||||
check(&s, 0.0, 10.0);
|
||||
let FeatureKind::Extrude { extent, .. } = &s.doc.find_feature("Depth").unwrap().kind else { panic!("extrude") };
|
||||
assert_eq!(extent.convention, ExtrudeConvention::TotalDistance);
|
||||
let name = s.doc.find_feature("Depth").unwrap().param_names[0].clone();
|
||||
run(&mut s, "parameters.change", json!({"name":name,"expression":"20 mm"}));
|
||||
check(&s, -5.0, 15.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s, 0.0, 10.0);
|
||||
check(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()), 0.0, 10.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_switch_from_one_side_keeps_total_thickness() {
|
||||
let mut s = fixture(json!({"direction":"positive","convention":"legacy_per_side"}));
|
||||
check(&s, 0.0, 10.0);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Depth","set":{"extent":{"direction":"symmetric"}}}));
|
||||
check(&s, -5.0, 5.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
check(&s, 0.0, 10.0);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature":"Depth","command":"solid.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Block"}}),
|
||||
);
|
||||
check(&s, -5.0, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_legacy_missing_marker_preserves_geometry_and_edits() {
|
||||
let s = fixture(json!({"convention":"legacy_per_side"}));
|
||||
let mut saved: Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
saved["format"] = json!("solidcraft/21");
|
||||
for f in saved["features"].as_array_mut().unwrap() {
|
||||
if f["type"] == "extrude" {
|
||||
f["extent"].as_object_mut().unwrap().remove("convention");
|
||||
}
|
||||
}
|
||||
let mut s = Session::new(Document::from_json(&saved.to_string()).unwrap());
|
||||
check(&s, -10.0, 10.0);
|
||||
run(&mut s, "timeline.edit", json!({"feature":"Depth","set":{"extent":{"distance":"12 mm"}}}));
|
||||
check(&s, -12.0, 12.0);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature":"Depth","command":"solid.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Block"}}),
|
||||
);
|
||||
check(&s, -10.0, 10.0);
|
||||
check(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()), -10.0, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_through_all_keeps_reach_and_two_sides_keep_independent_distances() {
|
||||
let mut s = fixture(json!({"distance2":"3 mm"}));
|
||||
check(&s, -3.0, 10.0);
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,100],"body_name":"FarAbove"}));
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,-110],"body_name":"FarBelow"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch":"Square","direction":"symmetric","through_all":true,"operation":"cut","targets":["FarAbove","FarBelow"]}),
|
||||
);
|
||||
assert!(s.model.state().body("FarAbove").is_none());
|
||||
assert!(s.model.state().body("FarBelow").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_invalid_convention_is_atomic() {
|
||||
let mut s = fixture(json!({}));
|
||||
let before = s.doc.to_json();
|
||||
for convention in [json!("unknown"), json!(7), Value::Null] {
|
||||
assert!(s.execute("solid.extrude", &json!({"sketch":"Square","distance":10,"direction":"symmetric","convention":convention})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_extrude_thin_wall_uses_total_distance_and_to_object_remains_one_sided() {
|
||||
let s = fixture(json!({"thin":true,"thickness":"2 mm"}));
|
||||
let st = s.model.state();
|
||||
let body = st.body("Block").unwrap();
|
||||
let b = body.mesh().bounds();
|
||||
assert!((b.min.z + 5.0).abs() < 1e-7 && (b.max.z - 5.0).abs() < 1e-7);
|
||||
// A centred 2 mm wall around a 10 mm square: (12² - 8²) × 10.
|
||||
assert!((solidcraft_kernel::measure(&body.body).unwrap().volume - 800.0).abs() < 1e-7);
|
||||
check(&fixture(json!({"to":[0,0,10]})), 0.0, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_extrude_shared_extent_retains_its_existing_per_side_semantics() {
|
||||
let mut s = fixture(json!({}));
|
||||
run(&mut s, "surface.extrude", json!({"sketch":"Square","distance":"10 mm","direction":"symmetric","name":"Sheet","body_name":"Sheet"}));
|
||||
let check_sheet = |s: &Session| {
|
||||
let st = s.model.state();
|
||||
let bounds = st.body("Sheet").unwrap().mesh().bounds();
|
||||
assert!((bounds.min.z + 10.0).abs() < 1e-7 && (bounds.max.z - 10.0).abs() < 1e-7);
|
||||
};
|
||||
check_sheet(&s);
|
||||
run(
|
||||
&mut s,
|
||||
"timeline.redefine",
|
||||
json!({"feature":"Sheet","command":"surface.extrude","params":{"sketch":"Square","distance":"10 mm","direction":"symmetric","body_name":"Sheet"}}),
|
||||
);
|
||||
check_sheet(&s);
|
||||
check_sheet(&Session::new(Document::from_json(&s.doc.to_json()).unwrap()));
|
||||
}
|
||||
@@ -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?; 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"),
|
||||
.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 (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); 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")
|
||||
@@ -632,8 +632,15 @@ pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent>
|
||||
if let Some(d) = &start_offset {
|
||||
check_expr(s, d, Kind::Length, cmd, "start_offset")?;
|
||||
}
|
||||
let convention = match p.get("convention") {
|
||||
// Surface Extrude shares Extent but retains its existing per-side distance semantics.
|
||||
None if cmd == "surface.extrude" => solidcraft_doc::ExtrudeConvention::LegacyPerSide,
|
||||
None => solidcraft_doc::ExtrudeConvention::TotalDistance,
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be total_distance or legacy_per_side"))?,
|
||||
};
|
||||
Ok(Extent {
|
||||
distance,
|
||||
convention,
|
||||
direction,
|
||||
distance2,
|
||||
start_offset,
|
||||
@@ -1780,3 +1787,7 @@ mod move_in_sketch_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "thin_extrude_tests.rs"]
|
||||
mod thin_extrude_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "extrude_convention_tests.rs"]
|
||||
mod extrude_convention_tests;
|
||||
@@ -367,7 +367,8 @@ fn sawtooth_bar(teeth: usize, pattern: bool) -> (Session, Option<u64>) {
|
||||
run(&mut s, "sketch.line", json!({"points": pts, "closed": true}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Tooth", "distance": 8, "direction": "symmetric", "operation": "cut", "name": "Tooth cut"}));
|
||||
// Total thickness 16 mm keeps the sawtooth cut spanning 8 mm on each side of YZ.
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Tooth", "distance": 16, "direction": "symmetric", "operation": "cut", "name": "Tooth cut"}));
|
||||
let id = pattern.then(|| {
|
||||
let r = run(&mut s, "solid.pattern.rectangular", json!({"features": ["Tooth cut"], "dir1": [0, 1, 0], "count1": teeth, "spacing1": 4}));
|
||||
r["feature"].as_u64().unwrap()
|
||||
|
||||
@@ -18,7 +18,7 @@ fn format20_scalar_looks_upgrade_and_format21_setups_keep_their_meaning() {
|
||||
legacy["format"] = json!("solidcraft/20");
|
||||
legacy.as_object_mut().unwrap().remove("render");
|
||||
let upgraded = crate::doc::Document::from_json(&legacy.to_string()).unwrap();
|
||||
assert_eq!(upgraded.format, "solidcraft/21");
|
||||
assert_eq!(upgraded.format, crate::doc::format::format_string(crate::doc::format::FORMAT));
|
||||
assert_eq!(serde_json::to_value(&upgraded.appearances).unwrap(), before);
|
||||
assert!(upgraded.render.setups.is_empty());
|
||||
s.execute("render.settings.set", &json!({"name":"Studio","set":{"width":320,"height":240}})).unwrap();
|
||||
@@ -29,7 +29,7 @@ fn format20_scalar_looks_upgrade_and_format21_setups_keep_their_meaning() {
|
||||
assert_eq!(serde_json::to_value(&d.appearances).unwrap(), before);
|
||||
assert!(d.appearances.bodies["Specimen"].pbr.is_none());
|
||||
assert_eq!(d.render.setups["Studio"].width, 320);
|
||||
for version in [22, 23] {
|
||||
for version in [crate::doc::format::FORMAT + 1, crate::doc::format::FORMAT + 2] {
|
||||
saved["format"] = json!(format!("solidcraft/{version}"));
|
||||
assert!(crate::doc::Document::from_json(&saved.to_string()).is_err());
|
||||
}
|
||||
@@ -52,7 +52,7 @@ fn opaque_response_preserves_geometry_mass_undo_and_cold_reopening() {
|
||||
assert!(after.meshes[0].surface.is_some());
|
||||
assert_eq!(after.required_features(), vec![crate::render::RenderFeature::Pbr]);
|
||||
let saved = s.doc.to_json();
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&saved).unwrap()["format"], "solidcraft/21");
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&saved).unwrap()["format"], crate::doc::format::format_string(crate::doc::format::FORMAT));
|
||||
let look = &s.doc.appearances.bodies["Specimen"];
|
||||
assert_eq!(crate::doc::config::appearance_cell(&serde_json::to_string(look).unwrap()).unwrap().as_ref(), Some(look));
|
||||
let list = s.execute("appearance.list", &json!({})).unwrap();
|
||||
|
||||
@@ -46,7 +46,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.params(concat!(
|
||||
"curves: [ids], distance: number in mm or length expression (creates an editable dimension; positive = left of the chain, or toward `side`), side?: [x,y], chain?: bool (default true: the connected chain); ",
|
||||
"direction?: one_side|symmetric (default one_side) or symmetric?: bool (the same distance each way, as for `solid.extrude`; no `side`, whose job it takes over), ",
|
||||
"direction?: one_side|symmetric (default one_side) or symmetric?: bool (the same distance each way; no `side`, whose job it takes over), ",
|
||||
"distance2?: positive length expression (Two Sides: an independent distance the other way, as for `solid.extrude`). Each side is its own offset association, listed in `sides`; flip?: bool reverses the primary side; `param`/`param2` name the driving dimensions",
|
||||
created_doc!()
|
||||
)),
|
||||
@@ -992,7 +992,7 @@ struct Side {
|
||||
}
|
||||
|
||||
/// The signed distances an offset runs at. One side is the distance as given. `symmetric` is the
|
||||
/// same distance each way, as `direction: symmetric` means for `solid.extrude`. `distance2` is an
|
||||
/// same distance each way. `distance2` is an
|
||||
/// independent distance the other way, as `distance2` means for `solid.extrude`. The primary side
|
||||
/// comes first, so a one-sided caller reads the same first entry it always did.
|
||||
fn offset_runs(d: f64, symmetric: bool, distance2: Option<f64>) -> Vec<f64> {
|
||||
@@ -1259,7 +1259,7 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d0 = if bool_(p, "flip").unwrap_or(false) { -value } else { value };
|
||||
let side = p.get("side").and_then(vec2);
|
||||
let use_chain = bool_(p, "chain").unwrap_or(true);
|
||||
// Both sides at once. The words keep the meaning `solid.extrude` gives them: `symmetric` is
|
||||
// Both sides at once: `symmetric` is
|
||||
// one distance applied each way, `distance2` is an independent distance the other way.
|
||||
let symmetric = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("positive") | Some("one_side") => bool_(p, "symmetric").unwrap_or(false),
|
||||
|
||||
@@ -338,6 +338,16 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
_ => 1.0,
|
||||
};
|
||||
let symmetric = to_span.is_none() && matches!(d.kind, Kind::Extrude { direction: 2, .. });
|
||||
let symmetric_scale = if symmetric {
|
||||
d.extra
|
||||
.get("convention")
|
||||
.cloned()
|
||||
.and_then(|v| serde_json::from_value::<solidcraft_engine::doc::ExtrudeConvention>(v).ok())
|
||||
.unwrap_or(solidcraft_engine::doc::ExtrudeConvention::TotalDistance)
|
||||
.symmetric_half_distance(1.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// Extrudes and moves go either way; radii and thicknesses stay positive.
|
||||
let signed = matches!(
|
||||
d.kind,
|
||||
@@ -416,17 +426,19 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
|
||||
let n = n * sign;
|
||||
let baseline = to_span.map(f64::abs).unwrap_or(0.0);
|
||||
let mut shown_value = format_len(l + baseline);
|
||||
// Through All already supplies a reach per side; dragging switches to an entered distance.
|
||||
let half = if all_span.is_some() { l } else { l * symmetric_scale };
|
||||
let mut shown_value = format_len(half + baseline);
|
||||
let original_shown = shown_value.clone();
|
||||
let dragged = if to_span.is_some() { &mut shown_value } else { &mut *value };
|
||||
if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, l + baseline, signed, egui::Id::new("sc_manipulator"), half_height, dragged) {
|
||||
if to_span.is_some()
|
||||
let dragged = if to_span.is_some() || symmetric_scale != 1.0 { &mut shown_value } else { &mut *value };
|
||||
if let Some(ts) = drag_arrow(ui, painter, proj, base, bs, n, half + baseline, signed, egui::Id::new("sc_manipulator"), half_height, dragged) {
|
||||
if (to_span.is_some() || symmetric_scale != 1.0)
|
||||
&& shown_value != original_shown
|
||||
&& let Ok(v) = app.session.doc.eval(&shown_value, ValueKind::Length)
|
||||
{
|
||||
*value = format_len(v - baseline);
|
||||
*value = format_len((v - baseline) / symmetric_scale);
|
||||
}
|
||||
if symmetric && let Some(bt) = proj.to_screen(base - n * l) {
|
||||
if symmetric && let Some(bt) = proj.to_screen(base - n * half) {
|
||||
arrow(painter, bs, bt, t.manipulator);
|
||||
}
|
||||
painter.circle(bs, 3.5, t.manipulator, Stroke::new(1.0, Color32::WHITE));
|
||||
|
||||
@@ -2087,6 +2087,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
combo(ui, "ex_dir", &["One Side", "Two Sides", "Symmetric"], &mut pos);
|
||||
if pos != order.iter().position(|o| o == direction).unwrap_or(0) {
|
||||
*direction = order.get(pos).copied().unwrap_or(0);
|
||||
// Selecting Symmetric applies the current total-distance semantics.
|
||||
d.extra.insert("convention".into(), json!(solidcraft_engine::doc::ExtrudeConvention::TotalDistance));
|
||||
// An explicit direction ends the automatic choices (#1257).
|
||||
smart.manual_dir = true;
|
||||
*auto_op = false;
|
||||
@@ -4194,6 +4196,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
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()),
|
||||
};
|
||||
d.extra.insert("convention".into(), json!(extent.convention));
|
||||
// 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).
|
||||
let frame = feature_frame(s, f.component);
|
||||
|
||||
@@ -603,7 +603,14 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
|
||||
Direction::Symmetric => 1,
|
||||
Direction::Negative => 2,
|
||||
};
|
||||
(Surf::Extrude { distance: extent.distance.clone(), direction }, vec![sels])
|
||||
let k = Surf::Extrude { distance: extent.distance.clone(), direction };
|
||||
let mut ins = inputs(&k);
|
||||
if let Some(input) = ins.first_mut() {
|
||||
input.items = sels;
|
||||
}
|
||||
let mut extra = Map::new();
|
||||
extra.insert("convention".into(), json!(extent.convention));
|
||||
return Some((Kind::Surface(k), ins, extra));
|
||||
}
|
||||
FeatureKind::Patch { sketch, edges, .. } => {
|
||||
let mut sels: Vec<Sel> = edges.iter().filter_map(edge).collect();
|
||||
|
||||
@@ -46,6 +46,7 @@ fn unchanged_actual_editor_preserves_all_extents_and_parameter_identities() {
|
||||
json!({"direction":"negative","distance":"10"}),
|
||||
json!({"direction":"negative","distance":"-10 mm"}),
|
||||
json!({"direction":"symmetric","distance":"5 mm"}),
|
||||
json!({"direction":"symmetric","distance":"5 mm","convention":"legacy_per_side"}),
|
||||
json!({"distance":"10 mm","distance2":"3 mm"}),
|
||||
json!({"from":[0,0,5],"start_offset":"0 mm"}),
|
||||
json!({"to":[0,0,10],"to_offset":"3 mm"}),
|
||||
@@ -295,3 +296,68 @@ fn dragging_to_update_value_reselects_whole_value_box_text() {
|
||||
assert_eq!(distance, "15 mm");
|
||||
assert_eq!(selection(&h), (0, 5), "the drag grew the text length; the selection must cover all of it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_total_distance_preview_handle_drag_apply_and_reopen_agree() {
|
||||
for (convention, cap, total_after_drag) in [("total_distance", 50.0, 120.0), ("legacy_per_side", 100.0, 60.0)] {
|
||||
let mut h = make(json!({"direction":"symmetric","distance":"100 mm","convention":convention}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.frames(4);
|
||||
open(&mut h);
|
||||
let expected_tip = h.at(&json!({"world":[5,5,cap]})).unwrap();
|
||||
let handle = h.at(&json!({"handle":"arrow"})).unwrap();
|
||||
assert!((handle[0] - expected_tip[0]).hypot(handle[1] - expected_tip[1]) < 1.0, "{convention}: {handle:?} expected {expected_tip:?}");
|
||||
h.step(&json!({"drag":[{"handle":"arrow"},{"world":[5,5,60]}]})).unwrap();
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
let crate::dialogs::Kind::Extrude { distance, .. } = &d.kind else { panic!("extrude") };
|
||||
assert_eq!(h.app.session.doc.eval(distance, solidcraft_engine::doc::expr::Kind::Length).unwrap(), total_after_drag);
|
||||
let commands = crate::dialogs::apply_commands(&h.app, d).unwrap();
|
||||
let preview = h.app.session.preview(&commands).unwrap();
|
||||
let preview_bounds = preview.after.body("Main").unwrap().mesh().bounds();
|
||||
assert!((preview_bounds.min.z + 60.0).abs() < 1e-7 && (preview_bounds.max.z - 60.0).abs() < 1e-7);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), preview_bounds);
|
||||
let path = h.dir.join("symmetric.solidcraft");
|
||||
h.app.run("file.save_as", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), preview_bounds);
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), preview_bounds);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_one_side_to_symmetric_in_actual_editor_keeps_total_thickness() {
|
||||
for convention in ["total_distance", "legacy_per_side"] {
|
||||
let mut h = make(json!({"convention":convention}));
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"One Side"})).unwrap();
|
||||
h.step(&json!({"widget":"Symmetric"})).unwrap();
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview(&commands).unwrap();
|
||||
let bounds = preview.after.body("Main").unwrap().mesh().bounds();
|
||||
assert!((bounds.min.z + 5.0).abs() < 1e-7 && (bounds.max.z - 5.0).abs() < 1e-7);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), bounds);
|
||||
assert_eq!(extent(&h).convention, solidcraft_engine::doc::ExtrudeConvention::TotalDistance);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symmetric_through_all_handle_drag_switches_to_total_distance() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":100,"corner":[0,0,-50],"body_name":"Reference"})).unwrap();
|
||||
let mut h = make_in(h, json!({"direction":"symmetric","through_all":true}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.frames(4);
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":"arrow"},{"world":[5,5,60]}]})).unwrap();
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
let crate::dialogs::Kind::Extrude { distance, all, .. } = &d.kind else { panic!("extrude") };
|
||||
assert!(!all);
|
||||
assert_eq!(h.app.session.doc.eval(distance, solidcraft_engine::doc::expr::Kind::Length).unwrap(), 120.0);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
assert!((bounds.min.z + 60.0).abs() < 1e-7 && (bounds.max.z - 60.0).abs() < 1e-7);
|
||||
}
|
||||
@@ -55,6 +55,13 @@ hole identities also makes it lost. Older curve-span selections retain their exa
|
||||
multisets. If an older feature has already lost its profile after subdivision changed, edit
|
||||
the feature and reselect the intended current region to capture the new identity.
|
||||
|
||||
New symmetric solid extrusions treat the entered distance as the total thickness and split it evenly
|
||||
around the start plane: 10 mm spans -5 to +5 mm on an XY sketch. Switching from One Side to
|
||||
Symmetric keeps the entered total thickness. Through All still reaches targets in both
|
||||
directions, and Two Sides retains its independent distances. Symmetric extrusions saved before
|
||||
this change keep their original per-side distances when reopened or edited. Selecting another direction
|
||||
in the feature editor uses the current total-distance convention.
|
||||
|
||||
New symmetric revolves split the entered angle evenly around the sketch profile, regardless
|
||||
of which way the selected axis points. Two Sides measures the second angle backward and the
|
||||
first angle forward along the sketch-normal sweep direction. Profiles on opposite sides of
|
||||
|
||||
Reference in new issue
Block a user