Merge branch 'bherbruck:main' into main
This commit is contained in:
48 files changed
+3593
-724
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();
|
||||
|
||||
@@ -97,11 +97,19 @@ impl DimensionDraft {
|
||||
|
||||
pub fn cancel() {
|
||||
EDIT.with(|edit| *edit.borrow_mut() = None);
|
||||
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
|
||||
}
|
||||
|
||||
/// Infer the measured value through the same command as scripts, without creating a real
|
||||
/// parameter or an undo entry. Only accepting the editor executes the command on the design.
|
||||
pub fn begin_dimension(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(), String> {
|
||||
let (param, expr, draft) = dimension_draft(app, entities, at)?;
|
||||
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) }));
|
||||
PLACEMENT.with(|preview| *preview.borrow_mut() = None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dimension_draft(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Result<(String, String, DimensionDraft), String> {
|
||||
let sketch = app.session.active_sketch.ok_or("Edit a sketch to add a dimension")?;
|
||||
if entities.is_empty() || entities.len() > 2 || !at.x.is_finite() || !at.y.is_finite() {
|
||||
return Err("Pick one edge or two endpoints, then place the dimension".into());
|
||||
@@ -126,8 +134,51 @@ pub fn begin_dimension(app: &SolveApp, entities: Vec<String>, at: Vec2) -> Resul
|
||||
if !draft.preview.sketch(sketch).is_some_and(|solved| solved.report.ok()) {
|
||||
return Err("That dimension conflicts with the sketch constraints".into());
|
||||
}
|
||||
EDIT.with(|edit| *edit.borrow_mut() = Some(Edit { param, expr, at: None, fresh: FRESH, wait: 10, draft: Some(draft) }));
|
||||
Ok(())
|
||||
Ok((param, expr, draft))
|
||||
}
|
||||
|
||||
/// Cache inference at a stationary pointer, including incomplete/unsupported picks.
|
||||
#[derive(Clone)]
|
||||
struct Placement {
|
||||
baseline: Arc<solvecraft_engine::doc::Document>,
|
||||
sketch: Option<u64>,
|
||||
component: u64,
|
||||
path: Option<Vec<u64>>,
|
||||
pending: std::collections::BTreeMap<u64, solvecraft_engine::doc::Mat>,
|
||||
entities: Vec<String>,
|
||||
at: Vec2,
|
||||
draft: Option<(String, DimensionDraft)>,
|
||||
}
|
||||
|
||||
fn placement_preview(app: &SolveApp) -> Option<(String, DimensionDraft)> {
|
||||
let tool = app.tool.as_ref().filter(|t| t.kind == crate::tools::Kind::Dimension && !t.picks.is_empty())?;
|
||||
let at = tool.hover.as_ref()?.0;
|
||||
PLACEMENT.with(|preview| {
|
||||
let mut preview = preview.borrow_mut();
|
||||
let current = preview.as_ref().is_some_and(|p| {
|
||||
Arc::ptr_eq(&p.baseline, &app.session.doc)
|
||||
&& p.sketch == app.session.active_sketch
|
||||
&& p.component == app.session.active_component
|
||||
&& p.path == app.session.active_occurrence_path
|
||||
&& p.pending == app.session.pending_moves
|
||||
&& p.entities == tool.picks
|
||||
&& p.at == at
|
||||
});
|
||||
if !current {
|
||||
let draft = dimension_draft(app, tool.picks.clone(), at).ok().map(|(param, _, draft)| (param, draft));
|
||||
*preview = Some(Placement {
|
||||
baseline: app.session.doc.clone(),
|
||||
sketch: app.session.active_sketch,
|
||||
component: app.session.active_component,
|
||||
path: app.session.active_occurrence_path.clone(),
|
||||
pending: app.session.pending_moves.clone(),
|
||||
entities: tool.picks.clone(),
|
||||
at,
|
||||
draft,
|
||||
});
|
||||
}
|
||||
preview.as_ref().and_then(|p| p.draft.clone())
|
||||
})
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -135,6 +186,7 @@ thread_local! {
|
||||
/// A text being dragged: dimension id, undo depth at the start, grab offset (sketch).
|
||||
static DRAG: RefCell<Option<(String, usize, Vec2)>> = const { RefCell::new(None) };
|
||||
static EDIT: RefCell<Option<Edit>> = const { RefCell::new(None) };
|
||||
static PLACEMENT: RefCell<Option<Placement>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Frames the inline editor holds on to the keyboard after opening.
|
||||
@@ -258,7 +310,7 @@ pub fn label(k: &ConstraintKind, expr: Option<&str>, driven: bool) -> String {
|
||||
/// Draw the active sketch's dimensions and remember where they are for picking.
|
||||
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
let mut drawn = Vec::new();
|
||||
let draft = EDIT.with(|edit| {
|
||||
let editor_draft = EDIT.with(|edit| {
|
||||
let mut edit = edit.borrow_mut();
|
||||
if edit.as_ref().and_then(|e| e.draft.as_ref()).is_some_and(|d| !d.valid(app)) {
|
||||
*edit = None;
|
||||
@@ -282,6 +334,8 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
None
|
||||
}
|
||||
});
|
||||
let placing = !editing();
|
||||
let draft = editor_draft.or_else(|| placing.then(|| placement_preview(app)).flatten());
|
||||
let st = app.session.world_state();
|
||||
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else {
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
@@ -372,13 +426,13 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
.collect();
|
||||
painter.add(Shape::line(pts, stroke));
|
||||
}
|
||||
if is_draft {
|
||||
if is_draft && !placing {
|
||||
EDIT.with(|edit| {
|
||||
if let Some(e) = edit.borrow_mut().as_mut() {
|
||||
e.at = Some(tc);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
} else if !is_draft {
|
||||
drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text });
|
||||
}
|
||||
}
|
||||
@@ -502,6 +556,71 @@ pub fn drawn() -> Vec<(String, Pos2)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn placement_previews_radial_and_angular_dimensions_and_invalidates_on_document_change() {
|
||||
cancel();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center":[30,30],"radius":5})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[20,10]],"infer":false,"connect":false})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[10,20]],"infer":false,"connect":false})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
for (picks, at, angular) in [(vec![[35, 30]], [40, 40], false), (vec![[15, 10], [10, 15]], [18, 18], true)] {
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
for pick in picks {
|
||||
h.step(&json!({"click":{"sketch":pick}})).unwrap();
|
||||
}
|
||||
h.step(&json!({"move":{"sketch":at}})).unwrap();
|
||||
let (param, draft) = placement_preview(&h.app).unwrap();
|
||||
let constraint =
|
||||
draft.preview.sketch(draft.sketch).unwrap().sketch.constraints.iter().find(|c| c.param.as_deref() == Some(¶m)).unwrap();
|
||||
assert_eq!(constraint.kind.is_angle(), angular);
|
||||
assert!(!editing());
|
||||
let (_, cached) = placement_preview(&h.app).unwrap();
|
||||
assert!(Arc::ptr_eq(&draft.preview_doc, &cached.preview_doc), "stationary pointer reuses inference");
|
||||
h.app.run("sketch.point", json!({"point":[50,50]})).unwrap();
|
||||
let (_, refreshed) = placement_preview(&h.app).unwrap();
|
||||
assert!(!Arc::ptr_eq(&draft.preview_doc, &refreshed.preview_doc));
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
}
|
||||
cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placement_follows_real_pointer_without_editing_or_mutating_design() {
|
||||
cancel();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[10,10],[30,10]],"infer":false})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
h.step(&json!({"click":{"sketch":[20,10]}})).unwrap();
|
||||
for at in [[20., 20.], [20., -5.]] {
|
||||
h.step(&json!({"move":{"sketch":at}})).unwrap();
|
||||
h.settle();
|
||||
let (param, draft) = placement_preview(&h.app).expect("a line has a dimension preview");
|
||||
assert!((draft.at - Vec2::new(at[0], at[1])).len() < 1e-5);
|
||||
let sk = &draft.preview.sketch(draft.sketch).unwrap().sketch;
|
||||
let c = sk.constraints.iter().find(|c| c.param.as_deref() == Some(¶m)).unwrap();
|
||||
assert!(matches!(c.kind, ConstraintKind::Length {value,..} if (value-20.).abs()<1e-6));
|
||||
assert!(!editing());
|
||||
assert!(drawn().is_empty(), "preview must not become a selectable committed dimension");
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
h.step(&json!({"click":{"sketch":[20,-5]}})).unwrap();
|
||||
assert!(editing());
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(placement_preview(&h.app).is_none());
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_show_the_value_or_fx() {
|
||||
let k = ConstraintKind::Length { l: 0, value: 25.0 };
|
||||
|
||||
@@ -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)]
|
||||
@@ -545,6 +547,13 @@ impl SolveApp {
|
||||
self.last_command = Some((spec.id.to_string(), spec.label.to_string()));
|
||||
}
|
||||
if let Some(mut t) = tools::Tool::for_command(id) {
|
||||
if let Some(params) = tools::constraint_preselection(id, &self.session.selection) {
|
||||
if self.run(id, params).is_ok() {
|
||||
let _ = self.session.execute("select.clear", &json!({}));
|
||||
self.set_status("Constraint applied", false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Curve-pair constraints can consume an exact preselection. Leave ambiguous
|
||||
// selections to the ordinary pick tool; never silently constrain a subset.
|
||||
if matches!(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -323,6 +323,41 @@ fn run_shape(app: &mut SolveApp, t: &Tool) -> Result<Vec<String>, String> {
|
||||
Ok(result["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect())
|
||||
}
|
||||
|
||||
/// Only consume a complete, unambiguous selection; command validation owns geometry.
|
||||
pub(crate) fn constraint_preselection(command: &str, selection: &[solvecraft_engine::Sel]) -> Option<Value> {
|
||||
use solvecraft_engine::Sel::{SketchCurve, SketchPoint};
|
||||
match (command, selection) {
|
||||
("sketch.constraint.coincident", [a, b]) => {
|
||||
let entity = |s: &solvecraft_engine::Sel| match s {
|
||||
SketchPoint { id } | SketchCurve { id } => Some(id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let (a, b) = (entity(a)?, entity(b)?);
|
||||
(a != b).then(|| json!({"a":a,"b":b}))
|
||||
}
|
||||
(
|
||||
"sketch.constraint.midpoint",
|
||||
[SketchPoint { id: point }, SketchCurve { id: line }] | [SketchCurve { id: line }, SketchPoint { id: point }],
|
||||
) => Some(json!({"point":point,"line":line})),
|
||||
("sketch.constraint.symmetry", [a, b, c]) => {
|
||||
let mut points = Vec::new();
|
||||
let mut lines = Vec::new();
|
||||
for entity in [a, b, c] {
|
||||
match entity {
|
||||
SketchPoint { id } => points.push(id),
|
||||
SketchCurve { id } => lines.push(id),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
match (points.as_slice(), lines.as_slice()) {
|
||||
([a, b], [line]) if a != b => Some(json!({"a":a,"b":b,"line":line})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_pick(app: &mut SolveApp, t: &Tool) {
|
||||
let k = &t.picks;
|
||||
let params = match t.cmd {
|
||||
|
||||
@@ -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