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 <Karl-VonRichter@users.noreply.github.com>
This commit is contained in:
Karl Von RichterandKarl-VonRichter authored and GitHub committed 2026-10-15 01:19:55 -04:00
1 parent faeeff1c64
commit 1ec33fcdc7
19 files changed
+323 -18

No files matched your search

+18 -6
View File
@@ -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));
+3
View File
@@ -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);
+8 -1
View File
@@ -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();
+66
View File
@@ -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);
}