Merge branch 'bherbruck:main' into main

This commit is contained in:
NAX LAB authored and GitHub committed 2026-10-11 15:41:26 -04:00
commit 35555c0b9f
48 files changed
+3593 -724

No files matched your search

File diff suppressed because it is too large. Load diff
+33 -4
View File
@@ -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();
+124 -5
View File
@@ -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(&param)).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(&param)).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 };
+45
View File
@@ -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() {
+9
View File
@@ -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);
+35
View File
@@ -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 {
+12 -6
View File
@@ -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);
}
+89 -1
View File
@@ -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:?}");
}
}
}
}
}