fix: restore shell and cut wall topology in 0.2.1
This commit is contained in:
1 parent
1aedbf6631
commit
ef8f6ca6f9
31 files changed
+2976
-678
No files matched your search
+557
-549
File diff suppressed because it is too large.
Load diff
@@ -1023,6 +1023,29 @@ mod tests {
|
||||
assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm"));
|
||||
}
|
||||
|
||||
fn click_rule_button(h: &mut crate::scenario::Harness, name: &str) {
|
||||
// The table header also contains this name. Its TextRun may be emitted
|
||||
// after the popup, so select the actual choice rather than matching text.
|
||||
let bounds = h
|
||||
.output
|
||||
.accesskit_update
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|(_, n)| {
|
||||
(n.role() == egui::accesskit::Role::Button && !n.is_disabled() && n.label() == Some(name)).then(|| n.bounds()).flatten()
|
||||
})
|
||||
.expect("rule button");
|
||||
h.step(&json!({"click": [(bounds.x0 + bounds.x1) / 2.0, (bounds.y0 + bounds.y1) / 2.0]})).unwrap();
|
||||
}
|
||||
|
||||
fn select_rule_parameter(h: &mut crate::scenario::Harness, name: &str) {
|
||||
click_rule_button(h, name);
|
||||
assert!(h.widget("Choose…").is_none(), "parameter choice should close and update the dropdown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_compound_condition_and_rule_order_are_editable_in_dialog() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
@@ -1032,9 +1055,11 @@ mod tests {
|
||||
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
|
||||
{"start":"config.table"},{"widget":"Add or edit rule"},
|
||||
{"widget":"Always"},{"widget":"All"},{"widget":"Always"},{"widget":"Parameter"},
|
||||
{"widget":"Choose…"},{"widget":"length"},{"widget":"Save Rule"}
|
||||
{"widget":"Choose…"}
|
||||
]))
|
||||
.unwrap();
|
||||
select_rule_parameter(&mut h, "length");
|
||||
h.step(&json!({"widget":"Save Rule"})).unwrap();
|
||||
assert!(
|
||||
matches!(&h.app.session.doc.configs.rules[0].condition,solvecraft_engine::doc::config::RuleCondition::All{conditions} if matches!(&conditions[0],solvecraft_engine::doc::config::RuleCondition::Parameter{..}))
|
||||
);
|
||||
@@ -1044,7 +1069,8 @@ mod tests {
|
||||
])).unwrap();
|
||||
assert!(h.widget("length = 20 mm").is_some());
|
||||
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
|
||||
h.run(&json!([{"widget":"↑"},{"widget":"Preview Row"}])).unwrap();
|
||||
click_rule_button(&mut h, "↑");
|
||||
h.step(&json!({"widget":"Preview Row"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.configs.rules[0].id, "last");
|
||||
assert!(h.widget("length = 10 mm").is_some());
|
||||
assert_eq!(h.app.session.doc.param("length").unwrap().expr, "40 mm");
|
||||
@@ -1060,8 +1086,11 @@ mod tests {
|
||||
{"call":"engine.execute","params":{"command":"config.configure","params":{"params":["length"]}}},
|
||||
{"start":"config.table"},
|
||||
{"widget":"Add or edit rule"},
|
||||
{"widget":"Choose…"},
|
||||
{"widget":"length"},
|
||||
{"widget":"Choose…"}
|
||||
]))
|
||||
.unwrap();
|
||||
select_rule_parameter(&mut h, "length");
|
||||
h.run(&json!([
|
||||
{"widget":"Save Rule"}
|
||||
]))
|
||||
.unwrap();
|
||||
|
||||
@@ -89,6 +89,51 @@ pub fn right_aligned<R>(ui: &mut Ui, total: f32, width: f32, add: impl FnOnce(&m
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn feature_forms_use_available_height_and_keep_actions_visible() {
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
for (width, height, scale) in [(1400.0, 900.0, 1.0), (1400.0, 900.0, 2.0)] {
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.ui_scale = scale;
|
||||
h.call("ui.resize", json!({"width":width / scale,"height":height / scale}));
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Base"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[10,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.step(&json!({"start":"solid.extrude"})).unwrap();
|
||||
h.frames(10);
|
||||
let dialog = h.app.viewport.dialog_rect.unwrap();
|
||||
let viewport = h.app.viewport.rect.unwrap();
|
||||
assert!(dialog.height() > 230.0, "scale={scale}: {dialog:?}");
|
||||
assert!(viewport.expand(1.0).contains_rect(dialog), "scale={scale}: {dialog:?} outside {viewport:?}");
|
||||
assert!(h.widget("Distance").is_some(), "distance must be visible without scrolling");
|
||||
if scale == 1.0 {
|
||||
assert!(h.widget("Operation").is_some(), "operation must be visible without scrolling");
|
||||
}
|
||||
assert!(dialog.contains(crate::scenario::handle_at("dialog:OK").unwrap()));
|
||||
assert!(h.widget("Cancel").is_some(), "cancel must be visible without scrolling");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
h.step(&json!({"start":"solid.shell"})).unwrap();
|
||||
h.frames(8);
|
||||
let short = h.app.viewport.dialog_rect.unwrap();
|
||||
assert!(short.height() < dialog.height(), "short forms should shrink: {short:?}, {dialog:?}");
|
||||
assert!(h.widget("Cancel").is_some());
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
h.step(&json!({"start":"solid.hole"})).unwrap();
|
||||
if let Some(crate::dialogs::Dialog { kind: crate::dialogs::Kind::Hole { kind, opts, .. }, .. }) = h.app.dialog.as_mut() {
|
||||
*kind = 1;
|
||||
opts.tapped = true;
|
||||
}
|
||||
h.frames(8);
|
||||
let long = h.app.viewport.dialog_rect.unwrap();
|
||||
assert!(viewport.expand(1.0).contains_rect(long), "long form clipped: {long:?}");
|
||||
assert!(h.widget("Cancel").is_some(), "long-form actions must stay visible");
|
||||
assert!(long.contains(crate::scenario::handle_at("dialog:OK").unwrap()));
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
}
|
||||
}
|
||||
/// Windows are made in this file only, so none can skip the width limits.
|
||||
#[test]
|
||||
fn windows_come_from_the_frame_helper() {
|
||||
|
||||
@@ -78,6 +78,8 @@ mod revolve_edit_tests;
|
||||
mod revolve_manipulator_tests;
|
||||
pub mod scenario;
|
||||
pub mod selection;
|
||||
#[cfg(test)]
|
||||
mod shell_face_sketch_ui_tests;
|
||||
pub mod shortcut_box;
|
||||
pub mod sketch3d;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::{
|
||||
Sel,
|
||||
doc::{FaceAttachmentStatus, FeatureKind, PlaneRef},
|
||||
};
|
||||
|
||||
fn shell() -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":40,"width":30,"height":20,"body_name":"Case"})).unwrap();
|
||||
h.app.run("solid.shell", json!({"faces":[[20,15,20]],"thickness":2})).unwrap();
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h
|
||||
}
|
||||
|
||||
fn assert_attached(h: &Harness) {
|
||||
let id = h.app.session.active_sketch.expect("a combined certified wall remains a sketch target");
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(id).unwrap().kind else {
|
||||
panic!("wall sketch must have a durable attachment");
|
||||
};
|
||||
assert_eq!(anchor.target.body, "Case");
|
||||
assert_eq!(serde_json::to_value(&anchor.target.certificate).unwrap()["kind"], "shell_planar");
|
||||
assert!(matches!(h.app.session.doc.sketch_face_attachment(id, &h.app.session.model), FaceAttachmentStatus::Healthy { .. }));
|
||||
let world = h.app.session.world_state();
|
||||
let solved = world.sketch(id).unwrap();
|
||||
assert!(solved.plane.origin.y.abs() < 1e-8);
|
||||
assert!(solved.plane.normal().y.abs() > 0.999);
|
||||
assert!(h.app.session.model.result(id).unwrap().error.is_none());
|
||||
}
|
||||
|
||||
fn selected_wall(h: &Harness) -> usize {
|
||||
assert_eq!(h.app.session.selection.len(), 1, "the exterior wall is one physical face, not a grouped lower strip");
|
||||
let Sel::Face { body, index, .. } = &h.app.session.selection[0] else {
|
||||
panic!("actual wall click must select a face");
|
||||
};
|
||||
assert_eq!(body, "Case");
|
||||
*index
|
||||
}
|
||||
|
||||
fn assert_one_physical_wall(h: &mut Harness) -> usize {
|
||||
h.step(&json!({"click":{"world":[20,0,1]}})).unwrap();
|
||||
let lower = selected_wall(h);
|
||||
h.call("ui.click", json!({"x":300,"y":200}));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
h.step(&json!({"click":{"world":[20,0,10]}})).unwrap();
|
||||
assert_eq!(selected_wall(h), lower, "below and above the floor height must hit the same BRep face index");
|
||||
lower
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_shell_wall_patch_still_creates_a_certified_sketch() {
|
||||
let mut h = shell();
|
||||
assert_one_physical_wall(&mut h);
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.settle();
|
||||
assert_attached(&h);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_create_sketch_accepts_an_actual_shell_wall_patch_click() {
|
||||
let mut h = shell();
|
||||
assert_one_physical_wall(&mut h);
|
||||
h.call("ui.click", json!({"x":300,"y":200}));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
h.step(&json!({"click":{"world":[20,0,1]}})).unwrap();
|
||||
h.settle();
|
||||
assert_attached(&h);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_shell_corner_is_one_physical_face_above_and_below_the_floor() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":80,"width":60,"height":30,"corner":[-40,-30,0],"body_name":"Case"})).unwrap();
|
||||
h.app.run("solid.fillet", json!({"edges":[[40,30,15],[-40,30,15],[-40,-30,15],[40,-30,15]],"radius":6})).unwrap();
|
||||
h.app.run("solid.shell", json!({"faces":[[0,0,30]],"thickness":2})).unwrap();
|
||||
h.call("ui.view", json!({"view":"iso"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
let state = h.app.session.world_state();
|
||||
assert_eq!(state.body("Case").unwrap().body.face_count(), 19, "no artificial lower corner faces remain in the BRep");
|
||||
let document = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let redo = h.app.session.redo.len();
|
||||
h.step(&json!({"click":{"world":[38.242640687,-28.242640687,1]}})).unwrap();
|
||||
let lower = selected_wall(&h);
|
||||
h.call("ui.click", json!({"x":300,"y":200}));
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
h.step(&json!({"click":{"world":[38.242640687,-28.242640687,15]}})).unwrap();
|
||||
assert_eq!(selected_wall(&h), lower, "lower and upper corner clicks hit the same actual face, not two grouped pieces");
|
||||
assert_eq!(h.app.session.doc.to_json(), document);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
}
|
||||
@@ -142,14 +142,16 @@ fn unsupported_modified_face_offers_explicit_fixed_plane_without_live_links() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Modified"})).unwrap();
|
||||
h.app.run("solid.fillet", json!({"edges":[[0,0,5]],"radius":1})).unwrap();
|
||||
// An intentional split gives the +z source two distinct planar pieces. A
|
||||
// strict attachment cannot claim the original whole-face certificate.
|
||||
h.app.run("solid.split_face", json!({"body":"Modified","faces":[[5,5,10]],"plane":{"origin":[5,0,0],"normal":[1,0,0]}})).unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
h.step(&json!({"click":{"world":[2.5,5,10]}})).unwrap();
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
assert!(h.app.dialog.as_ref().unwrap().error.is_some());
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
|
||||
@@ -42,16 +42,14 @@ impl Layout {
|
||||
pub fn new(viewport: Rect, docks: &[Rect], scale: f32) -> Self {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let mut obstacles: Vec<_> = docks.iter().take(6).copied().filter(|r| r.is_finite()).map(|r| r.expand(4.0)).collect();
|
||||
let available = free_rect(viewport, &obstacles);
|
||||
let axis_view = if available.width() >= 116.0 && available.height() >= 116.0 {
|
||||
// Navigation is anchored to the viewport, never to transient dialog space.
|
||||
let available = viewport;
|
||||
let mut axis_view = if available.width() >= 116.0 && available.height() >= 116.0 {
|
||||
Rect::from_min_size(pos2(available.right() - 116.0, available.top()), vec2(116.0, 116.0))
|
||||
} else {
|
||||
Rect::NOTHING
|
||||
};
|
||||
if axis_view.is_positive() {
|
||||
obstacles.push(axis_view);
|
||||
}
|
||||
let nav_space = free_rect(viewport, &obstacles);
|
||||
let nav_space = viewport;
|
||||
let columns = ((nav_space.width() - 10.0) / 30.0).floor().clamp(1.0, 10.0);
|
||||
let nav_height = (10.0 / columns).ceil() * 30.0;
|
||||
let navigation = if nav_space.width() >= columns * 30.0 + 10.0 && nav_space.height() >= nav_height + 7.0 {
|
||||
@@ -59,6 +57,14 @@ impl Layout {
|
||||
} else {
|
||||
Rect::NOTHING
|
||||
};
|
||||
// On tiny viewports keep the bottom controls usable rather than overlapping them.
|
||||
// This decision depends only on viewport size, never on an overlay.
|
||||
if axis_view.intersect(navigation).is_positive() {
|
||||
axis_view = Rect::NOTHING;
|
||||
}
|
||||
if axis_view.is_positive() {
|
||||
obstacles.push(axis_view);
|
||||
}
|
||||
if navigation.is_positive() {
|
||||
obstacles.push(navigation);
|
||||
}
|
||||
|
||||
@@ -142,7 +142,19 @@ fn measured_axis_view_targets_home_and_drag_work_beside_the_palette_at_hidpi() {
|
||||
assert_eq!(outer, layout.axis_view);
|
||||
assert!(outer.contains_rect(home));
|
||||
assert!(layout.viewport.contains_rect(outer));
|
||||
assert!(!outer.intersect(palette).is_positive());
|
||||
assert_eq!(outer.right_top(), layout.viewport.right_top());
|
||||
if outer.intersect(palette).is_positive() {
|
||||
// Short palettes may cover the fixed anchor; covered controls never steal input.
|
||||
let covered = outer.intersect(palette).center();
|
||||
let camera = h.app.cam;
|
||||
h.call("ui.click", json!({"x":covered.x,"y":covered.y}));
|
||||
assert_eq!(h.app.cam, camera);
|
||||
assert!(h.app.viewport.axis_drag.is_none());
|
||||
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), outer);
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
continue;
|
||||
}
|
||||
assert!(!layout.navigation.intersect(outer).is_positive());
|
||||
for name in ["x", "-x", "y", "-y", "z", "-z"] {
|
||||
let p = crate::scenario::handle_at(&format!("axis_view:{name}")).unwrap();
|
||||
@@ -194,3 +206,79 @@ fn immediate_standard_view_cancels_fit_before_the_measured_frame() {
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_anchors_ignore_positioned_resized_and_covering_dialogs() {
|
||||
for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0)] {
|
||||
let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0));
|
||||
let baseline = Layout::new(vp, &[], scale);
|
||||
assert!(baseline.axis_view.is_positive() && baseline.navigation.is_positive());
|
||||
assert_eq!(baseline.axis_view.right_top(), vp.right_top());
|
||||
assert_eq!(baseline.navigation.center().x, vp.center().x);
|
||||
assert_eq!(baseline.navigation.bottom(), vp.bottom() - 7.0);
|
||||
let cases = [
|
||||
vec![Rect::from_min_max(pos2(width - 220.0, vp.top() + 120.0), vp.max)],
|
||||
vec![Rect::from_min_size(vp.min, vec2(210.0, 170.0))],
|
||||
vec![Rect::from_center_size(vp.center(), vec2(250.0, 180.0)), Rect::from_min_size(vp.min, vec2(70.0, 120.0))],
|
||||
vec![vp],
|
||||
];
|
||||
for docks in cases {
|
||||
let measured = Layout::new(vp, &docks, scale);
|
||||
assert_eq!(measured.axis_view, baseline.axis_view, "{docks:?}");
|
||||
assert_eq!(measured.navigation, baseline.navigation, "{docks:?}");
|
||||
assert!(vp.contains_rect(measured.axis_view));
|
||||
assert!(vp.contains_rect(measured.navigation));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_different_command_dialogs_keeps_published_navigation_positions() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
h.settle();
|
||||
let axis = crate::scenario::rect_of("axis_view:outer").unwrap();
|
||||
let nav = crate::scenario::rect_of("nav:outer").unwrap();
|
||||
let camera = h.app.cam;
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
for command in ["solid.extrude", "solid.fillet", "inspect.measure"] {
|
||||
h.app.start(command);
|
||||
h.settle();
|
||||
assert!(h.app.dialog.is_some(), "{command}");
|
||||
assert!(h.app.viewport.dialog_rect.is_some(), "dialog must be measured");
|
||||
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis, "{command}");
|
||||
assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav, "{command}");
|
||||
assert_eq!(h.app.cam, camera);
|
||||
let pan = pos2(nav.left() + 5.0 + 2.0 * 30.0 + 13.0, nav.top() + 15.0);
|
||||
assert!(!h.app.viewport.dialog_rect.unwrap().contains(pan));
|
||||
h.app.viewport.nav = None;
|
||||
h.call("ui.click", json!({"x":pan.x,"y":pan.y}));
|
||||
assert_eq!(h.app.viewport.nav, Some(crate::viewport::NavMode::Pan), "fixed navigation stays usable beside {command}");
|
||||
h.app.dialog = None;
|
||||
h.settle();
|
||||
assert_eq!(crate::scenario::rect_of("axis_view:outer").unwrap(), axis);
|
||||
assert_eq!(crate::scenario::rect_of("nav:outer").unwrap(), nav);
|
||||
}
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_viewports_never_overlap_navigation_and_axis_view() {
|
||||
for width in [39.0, 40.0, 90.0, 120.0, 160.0, 310.0] {
|
||||
for height in [30.0, 80.0, 130.0, 160.0, 240.0] {
|
||||
let vp = Rect::from_min_size(pos2(20.0, 30.0), vec2(width, height));
|
||||
let clear = Layout::new(vp, &[], 1.0);
|
||||
let covered = Layout::new(vp, &[vp], 1.0);
|
||||
assert_eq!(clear.axis_view, covered.axis_view);
|
||||
assert_eq!(clear.navigation, covered.navigation);
|
||||
assert!(!clear.axis_view.intersect(clear.navigation).is_positive());
|
||||
for control in [clear.axis_view, clear.navigation] {
|
||||
if control.is_positive() {
|
||||
assert!(vp.contains_rect(control), "{clear:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user