Prepare 0.2.0 usability release and refresh desktop demo
This commit is contained in:
1 parent
a094fa46b5
commit
ab05b24dee
54 files changed
+4888
-234
No files matched your search
@@ -312,7 +312,7 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
}
|
||||
}
|
||||
let id = egui::Id::new("sc_canvas_value");
|
||||
egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(label).color(t.text_dim).size(11.5));
|
||||
@@ -579,7 +579,7 @@ fn draw_revolve(
|
||||
}
|
||||
}
|
||||
}
|
||||
egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
crate::frame::area(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
||||
if to_mode {
|
||||
let text = if extent.curved_to {
|
||||
|
||||
@@ -1369,7 +1369,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let area = Rect::from_center_size(c, vec2(2.0 * (RX + 165.0), 2.0 * (RY + 34.0)));
|
||||
inside |= pointer.is_some_and(|p| area.contains(p));
|
||||
let hot = pointer.and_then(|p| sector(p - c));
|
||||
let resp = egui::Area::new(egui::Id::new("sc_marking_menu")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
let resp = crate::frame::area(egui::Id::new("sc_marking_menu")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
|
||||
let painter = ui.painter_at(r.expand(4.0));
|
||||
let font = FontId::proportional(13.0);
|
||||
@@ -1435,15 +1435,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
picked = Some(item.clone());
|
||||
}
|
||||
let key_row = app.menu.key_row;
|
||||
let resp = egui::Area::new(egui::Id::new("sc_context_menu")).fixed_pos(list_at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
menu_frame(ui, |ui| {
|
||||
for (k, item) in list.iter().enumerate() {
|
||||
if let Some(p) = list_row_lit(ui, item, key_row == Some(k)) {
|
||||
picked = Some(p);
|
||||
let resp =
|
||||
crate::frame::area(egui::Id::new("sc_context_menu")).fixed_pos(list_at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
menu_frame(ui, |ui| {
|
||||
for (k, item) in list.iter().enumerate() {
|
||||
if let Some(p) = list_row_lit(ui, item, key_row == Some(k)) {
|
||||
picked = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
inside |= resp.response.contains_pointer() || resp.response.rect.contains(pointer.unwrap_or(Pos2::ZERO));
|
||||
|
||||
let (primary, secondary, esc) = ctx.input(|i| {
|
||||
@@ -1508,6 +1509,7 @@ pub fn list_row_lit(ui: &mut egui::Ui, item: &Item, lit: bool) -> Option<Item> {
|
||||
return None;
|
||||
}
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), if item.enabled { Sense::click() } else { Sense::hover() });
|
||||
crate::scenario::publish_handle(&format!("menu:{}", item.id), r.center());
|
||||
if item.enabled && (resp.hovered() || lit) {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), 4.0, t.hover);
|
||||
}
|
||||
@@ -1536,7 +1538,7 @@ fn show_rename(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(mut r) = app.menu.rename.take() else { return };
|
||||
let t = Tokens::get();
|
||||
let mut done: Option<bool> = None;
|
||||
let resp = egui::Area::new(egui::Id::new("sc_rename")).fixed_pos(r.at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
let resp = crate::frame::area(egui::Id::new("sc_rename")).fixed_pos(r.at).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
menu_frame(ui, |ui| {
|
||||
ui.add_space(2.0);
|
||||
ui.label(egui::RichText::new("Rename").size(11.0).color(t.text_dim));
|
||||
|
||||
@@ -199,7 +199,7 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
||||
app.animate_view(v);
|
||||
return ok(json!({ "animating": true, "to": app.cam_anim.map(|a| a.to) }));
|
||||
}
|
||||
app.cam_anim = None;
|
||||
app.cancel_view_animation();
|
||||
match v {
|
||||
"fit" => app.fit_view(),
|
||||
"home" => {
|
||||
|
||||
@@ -1265,6 +1265,7 @@ fn auto_operation(app: &SolveApp, d: &mut Dialog) {
|
||||
/// Show the active dialog (if any), docked flush to the right edge of the viewport just below
|
||||
/// the axis view, sized to its content.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.viewport.dialog_rect = None;
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
let _language = crate::i18n::scope(&app.preferences.language);
|
||||
auto_operation(app, &mut d);
|
||||
@@ -1294,7 +1295,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
|
||||
let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal };
|
||||
let wkey = egui::Id::new(("sc_dialog_w", wide));
|
||||
crate::frame::docked(
|
||||
let response = crate::frame::docked(
|
||||
RichText::new(heading.clone()).strong().size(13.0),
|
||||
egui::Id::new(("sc_dialog", title(&d.kind))),
|
||||
anchor,
|
||||
@@ -1909,9 +1910,16 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()) {
|
||||
if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()).cloned() {
|
||||
row_label(ui, "");
|
||||
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
|
||||
ui.vertical(|ui| {
|
||||
ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap());
|
||||
let response = ui.button("Create Issue…");
|
||||
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
|
||||
if response.clicked() {
|
||||
crate::issue_report::begin(app, ctx, Some(&d), Some(&e));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
@@ -1959,6 +1967,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
});
|
||||
crate::frame::remember_width(ui, wkey);
|
||||
});
|
||||
app.viewport.dialog_rect = response.map(|r| r.response.rect);
|
||||
// Keyboard: Enter applies (from a value field, or with nothing focused), Esc cancels.
|
||||
let (enter_free, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
|
||||
let nothing_focused = ctx.memory(|m| m.focused().is_none());
|
||||
@@ -1966,10 +1975,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// Tables (configurations, a study's keys) take Enter for their cells.
|
||||
let enter_applies = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. } | Kind::ConfirmDelete { .. })
|
||||
&& !matches!(&d.kind, Kind::Motion(k) if k.wide());
|
||||
if enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) {
|
||||
let report_active = app.issue_report.draft.is_some();
|
||||
if !report_active && enter_applies && (enter || canvas_enter || (enter_free && nothing_focused)) {
|
||||
ok = true;
|
||||
}
|
||||
if esc && !app.esc_handled {
|
||||
if !report_active && esc && !app.esc_handled {
|
||||
app.esc_handled = true;
|
||||
cancel = true;
|
||||
}
|
||||
@@ -2264,6 +2274,11 @@ pub(crate) fn feature_frame(s: &Session, component: u64) -> solvecraft_engine::d
|
||||
if component == s.active_component { s.active_component_frame() } else { s.placed_document().component_transform(component) }
|
||||
}
|
||||
|
||||
/// The feature dialog's current operation name for a reviewed issue draft.
|
||||
pub(crate) fn report_title(d: &Dialog) -> String {
|
||||
title(&d.kind).to_string()
|
||||
}
|
||||
|
||||
/// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same.
|
||||
pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, String> {
|
||||
let cmds = dialog_commands(app, d)?;
|
||||
|
||||
+168
-11
@@ -10,6 +10,7 @@
|
||||
//! `parameters.change`.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{FontId, Pos2, Rect, Shape, Stroke, vec2};
|
||||
use serde_json::json;
|
||||
@@ -44,6 +45,89 @@ struct Edit {
|
||||
fresh: u8,
|
||||
/// Frames left to wait for the dimension to be drawn.
|
||||
wait: u8,
|
||||
draft: Option<DimensionDraft>,
|
||||
}
|
||||
|
||||
/// Creation stays in a scratch session until the user accepts the value. Retaining the
|
||||
/// document also makes the editor's identity check safe across document replacements.
|
||||
#[derive(Clone, Debug)]
|
||||
struct DimensionDraft {
|
||||
baseline: Arc<solvecraft_engine::doc::Document>,
|
||||
sketch: u64,
|
||||
component: u64,
|
||||
path: Option<Vec<u64>>,
|
||||
pending: std::collections::BTreeMap<u64, solvecraft_engine::doc::Mat>,
|
||||
entities: Vec<String>,
|
||||
at: Vec2,
|
||||
checked_expr: String,
|
||||
preview_doc: Arc<solvecraft_engine::doc::Document>,
|
||||
preview: Arc<solvecraft_engine::doc::ModelState>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
impl DimensionDraft {
|
||||
fn valid(&self, app: &SolveApp) -> bool {
|
||||
Arc::ptr_eq(&self.baseline, &app.session.doc)
|
||||
&& app.session.active_sketch == Some(self.sketch)
|
||||
&& app.session.active_component == self.component
|
||||
&& app.session.active_occurrence_path == self.path
|
||||
&& app.session.pending_moves == self.pending
|
||||
}
|
||||
fn params(&self, expr: &str) -> serde_json::Value {
|
||||
json!({"entities": self.entities, "text_at": [self.at.x, self.at.y], "value": expr})
|
||||
}
|
||||
fn evaluate(&self, app: &SolveApp, expr: &str) -> Result<solvecraft_engine::Session, String> {
|
||||
let mut scratch = app.session.scratch();
|
||||
let result = scratch.execute("sketch.dimension", &self.params(expr)).map_err(|e| e.to_string())?;
|
||||
let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("The dimension value is unavailable")?;
|
||||
let state = scratch.world_state();
|
||||
let solved = state.sketch(self.sketch).ok_or("The dimension sketch is unavailable")?;
|
||||
if !solved.report.ok() {
|
||||
return Err("That value conflicts with the sketch constraints".into());
|
||||
}
|
||||
if let Some(kind) = solved.sketch.constraints.iter().find(|c| c.param.as_deref() == Some(param)).map(|c| &c.kind)
|
||||
&& !kind.is_angle()
|
||||
&& kind.value().is_some_and(|value| !value.is_finite() || value <= 0.0)
|
||||
{
|
||||
return Err("Enter a dimension greater than zero".into());
|
||||
}
|
||||
Ok(scratch)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel() {
|
||||
EDIT.with(|edit| *edit.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 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());
|
||||
}
|
||||
let mut scratch = app.session.scratch();
|
||||
let result = scratch.execute("sketch.dimension", &json!({"entities": entities, "text_at": [at.x, at.y]})).map_err(|e| e.to_string())?;
|
||||
let param = result.get("param").and_then(serde_json::Value::as_str).ok_or("This dimension has no editable value")?.to_string();
|
||||
let expr = scratch.doc.param(¶m).ok_or("The dimension value is unavailable")?.expr.clone();
|
||||
let draft = DimensionDraft {
|
||||
baseline: app.session.doc.clone(),
|
||||
sketch,
|
||||
component: app.session.active_component,
|
||||
path: app.session.active_occurrence_path.clone(),
|
||||
pending: app.session.pending_moves.clone(),
|
||||
entities,
|
||||
at,
|
||||
checked_expr: expr.clone(),
|
||||
preview: scratch.world_state(),
|
||||
preview_doc: scratch.doc.clone(),
|
||||
error: None,
|
||||
};
|
||||
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(())
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
@@ -117,7 +201,7 @@ pub fn double_click(app: &mut SolveApp, pos: Pos2) -> bool {
|
||||
return true;
|
||||
};
|
||||
let expr = app.session.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0 }));
|
||||
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param, expr, at: Some(d.text.center()), fresh: FRESH, wait: 0, draft: None }));
|
||||
true
|
||||
}
|
||||
|
||||
@@ -174,6 +258,30 @@ 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 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;
|
||||
}
|
||||
if let Some(e) = edit.as_mut()
|
||||
&& let Some(draft) = e.draft.as_mut()
|
||||
{
|
||||
if e.expr != draft.checked_expr {
|
||||
draft.checked_expr = e.expr.clone();
|
||||
match draft.evaluate(app, &e.expr) {
|
||||
Ok(scratch) => {
|
||||
draft.preview = scratch.world_state();
|
||||
draft.preview_doc = scratch.doc.clone();
|
||||
draft.error = None;
|
||||
}
|
||||
Err(error) => draft.error = Some(error),
|
||||
}
|
||||
}
|
||||
Some((e.param.clone(), draft.clone()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
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());
|
||||
@@ -183,15 +291,28 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
let sk = &ss.sketch;
|
||||
if sk.view.hide_dimensions {
|
||||
DRAWN.with(|d| d.borrow_mut().clear());
|
||||
return;
|
||||
if draft.is_none() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let tk = Tokens::get();
|
||||
let is_sel = |id: &str| app.session.selection.iter().any(|s| matches!(s, Sel::SketchConstraint { id: x } if x == id));
|
||||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)).and_then(hit);
|
||||
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
|
||||
let font = FontId::proportional(12.0);
|
||||
let centres = chain_centres(sk);
|
||||
for c in &sk.constraints {
|
||||
let draft_sketch = draft.as_ref().and_then(|(_, d)| d.preview.sketch(d.sketch));
|
||||
let draft_centres = draft_sketch.map(|solved| chain_centres(&solved.sketch));
|
||||
let mut constraints = sk.constraints.iter().map(|c| (ss, c, false)).collect::<Vec<_>>();
|
||||
if let (Some(source), Some((param, _))) = (draft_sketch, draft.as_ref()) {
|
||||
constraints.extend(source.sketch.constraints.iter().filter(|c| c.param.as_deref() == Some(param.as_str())).map(|c| (source, c, true)));
|
||||
}
|
||||
for (source, c, is_draft) in constraints {
|
||||
if ss.sketch.view.hide_dimensions && !is_draft {
|
||||
continue;
|
||||
}
|
||||
let sk = &source.sketch;
|
||||
let centres = if is_draft { draft_centres.as_ref().unwrap_or(¢res) } else { ¢res };
|
||||
let to = |p: Vec2| proj.to_screen(source.plane.to_world(p));
|
||||
let Some(frame) = dim_frame(sk, &c.kind) else { continue };
|
||||
let anchor = match frame {
|
||||
DimFrame::Linear { p0, p1, .. } => (p0 + p1) * 0.5,
|
||||
@@ -204,8 +325,10 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
if !px.is_finite() {
|
||||
continue;
|
||||
}
|
||||
let expr = c.param.as_ref().and_then(|p| app.session.doc.param(p)).map(|p| p.expr.as_str());
|
||||
let failing = ss.report.failing.contains(&c.id);
|
||||
let display_doc =
|
||||
if is_draft { draft.as_ref().map(|(_, d)| d.preview_doc.as_ref()).unwrap_or(app.session.doc.as_ref()) } else { app.session.doc.as_ref() };
|
||||
let expr = c.param.as_ref().and_then(|p| display_doc.param(p)).map(|p| p.expr.as_str());
|
||||
let failing = source.report.failing.contains(&c.id);
|
||||
let col = if failing {
|
||||
tk.error
|
||||
} else if is_sel(&c.id) || hover.as_deref() == Some(c.id.as_str()) {
|
||||
@@ -249,7 +372,15 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P
|
||||
.collect();
|
||||
painter.add(Shape::line(pts, stroke));
|
||||
}
|
||||
drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text });
|
||||
if is_draft {
|
||||
EDIT.with(|edit| {
|
||||
if let Some(e) = edit.borrow_mut().as_mut() {
|
||||
e.at = Some(tc);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
drawn.push(Drawn { id: c.id.clone(), param: c.param.clone(), text: r, segs, at: lay.text });
|
||||
}
|
||||
}
|
||||
DRAWN.with(|d| *d.borrow_mut() = drawn);
|
||||
edit_box(app, ui.ctx());
|
||||
@@ -274,7 +405,7 @@ fn pos2_mid_top(r: Rect) -> Pos2 {
|
||||
/// its value is selected, so typing replaces it).
|
||||
pub fn edit_param(app: &SolveApp, param: &str) {
|
||||
let expr = app.session.doc.param(param).map(|p| p.expr.clone()).unwrap_or_default();
|
||||
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10 }));
|
||||
EDIT.with(|e| *e.borrow_mut() = Some(Edit { param: param.to_string(), expr, at: None, fresh: FRESH, wait: 10, draft: None }));
|
||||
}
|
||||
|
||||
fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
@@ -292,10 +423,25 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
};
|
||||
let id = egui::Id::new("sc_dim_edit");
|
||||
let mut done: Option<bool> = None;
|
||||
egui::Area::new(egui::Id::new("sc_dim_edit_area")).fixed_pos(at - vec2(45.0, 11.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
let mut anchor = at - vec2(45.0, 11.0);
|
||||
let mut popup_width = 220.0;
|
||||
if let Some(layout) = &app.viewport.layout
|
||||
&& layout.drawable.is_finite()
|
||||
&& layout.drawable.is_positive()
|
||||
{
|
||||
let rect = layout.drawable;
|
||||
popup_width = (rect.width() - 8.0).clamp(24.0, 220.0);
|
||||
anchor.x = anchor.x.clamp(rect.left(), (rect.right() - popup_width - 6.0).max(rect.left()));
|
||||
anchor.y = anchor.y.clamp(rect.top(), (rect.bottom() - 80.0).max(rect.top()));
|
||||
}
|
||||
crate::frame::area(egui::Id::new("sc_dim_edit_area")).fixed_pos(anchor).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(90.0));
|
||||
ui.set_max_width(popup_width);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut e.expr).id(id).desired_width(popup_width.min(90.0)));
|
||||
crate::params_dialog::complete(ui, &r, &mut e.expr);
|
||||
if let Some(error) = e.draft.as_ref().and_then(|d| d.error.as_ref()) {
|
||||
ui.add(egui::Label::new(egui::RichText::new(error).color(Tokens::get().error)).wrap());
|
||||
}
|
||||
if e.fresh == FRESH {
|
||||
// The whole value is selected, so typing replaces it.
|
||||
let n = e.expr.chars().count();
|
||||
@@ -321,9 +467,20 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
e.fresh = e.fresh.saturating_sub(1);
|
||||
match done {
|
||||
Some(true) => {
|
||||
if let Err(err) = app.run("parameters.change", json!({"name": e.param, "expression": e.expr})) {
|
||||
let result = if let Some(draft) = &e.draft {
|
||||
if draft.valid(app) {
|
||||
draft.evaluate(app, &e.expr).and_then(|_| app.run("sketch.dimension", draft.params(&e.expr)))
|
||||
} else {
|
||||
Err("The sketch changed; place the dimension again".into())
|
||||
}
|
||||
} else {
|
||||
app.run("parameters.change", json!({"name": e.param, "expression": e.expr}))
|
||||
};
|
||||
if let Err(err) = result {
|
||||
app.set_status(err, true);
|
||||
EDIT.with(|x| *x.borrow_mut() = Some(Edit { fresh: FRESH, ..e }));
|
||||
} else {
|
||||
app.set_status("Dimension applied", false);
|
||||
}
|
||||
}
|
||||
Some(false) => {}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Dimension creation is an editable draft, accepted by the real focused value field.
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn line(end: [f64; 2]) -> Harness {
|
||||
crate::dim_view::cancel();
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],end]})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
// Leave room around the measured shape while testing the draft itself. Separate Fit
|
||||
// tests exercise measured dock occlusion without changing the picking projection.
|
||||
h.app.cam.distance = 80.0;
|
||||
h.app.cam.target = solvecraft_engine::geom::Vec3::new(end[0] * 0.5, end[1] * 0.5, 0.0);
|
||||
h.settle();
|
||||
h
|
||||
}
|
||||
fn place(h: &mut Harness, picks: &[[f64; 2]], at: [f64; 2]) {
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
for p in picks {
|
||||
h.step(&json!({"click":{"world":[p[0],p[1],0]}})).unwrap();
|
||||
}
|
||||
h.step(&json!({"click":{"world":[at[0],at[1],0]}})).unwrap();
|
||||
assert!(crate::dim_view::editing(), "placement opens an editor");
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")));
|
||||
}
|
||||
fn type_value(h: &mut Harness, text: &str) {
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":text})).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn edge_and_endpoint_creation_commit_once_and_undo_restores_exact_design() {
|
||||
for (end, picks, at) in [
|
||||
([20., 0.], vec![[10., 0.]], [10., 5.]),
|
||||
([20., 0.], vec![[0., 0.], [20., 0.]], [10., 5.]),
|
||||
([0., 20.], vec![[0., 0.], [0., 20.]], [-5., 10.]),
|
||||
([12., 16.], vec![[0., 0.], [12., 16.]], [-5., 12.]),
|
||||
] {
|
||||
let mut h = line(end);
|
||||
let original = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
place(&mut h, &picks, at);
|
||||
assert_eq!(h.app.session.doc.to_json(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
type_value(&mut h, "30 mm");
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let state = h.app.session.world_state();
|
||||
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert!(
|
||||
(sk.point(sk.resolve_point("l1.start").unwrap()).unwrap().dist(sk.point(sk.resolve_point("l1.end").unwrap()).unwrap()) - 30.).abs()
|
||||
< 1e-6
|
||||
);
|
||||
assert!(sk.constraints.iter().any(|c| c.param.is_some()));
|
||||
let accepted = h.app.session.doc.to_json();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), original);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), accepted);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn escape_invalid_correction_and_seed_enter_do_not_create_partial_dimensions() {
|
||||
let mut h = line([20., 0.]);
|
||||
let original = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
place(&mut h, &[[10., 0.]], [10., 5.]);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.doc.to_json(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
place(&mut h, &[[0., 0.], [20., 0.]], [10., 5.]);
|
||||
type_value(&mut h, "unknown_length");
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.doc.to_json(), original);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
type_value(&mut h, "25 mm");
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
h.settle();
|
||||
place(&mut h, &[[10., 0.]], [10., 5.]);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!(!crate::dim_view::editing());
|
||||
}
|
||||
#[test]
|
||||
fn switching_documents_cancels_creation_and_existing_parameter_edit_is_separate() {
|
||||
let mut h = line([20., 0.]);
|
||||
let original = h.app.session.doc.to_json();
|
||||
place(&mut h, &[[10., 0.]], [10., 5.]);
|
||||
h.call("ui.documents", json!({"action":"new"}));
|
||||
assert!(!crate::dim_view::editing());
|
||||
h.call("ui.documents", json!({"action":"switch","index":0}));
|
||||
assert_eq!(h.app.session.doc.to_json(), original);
|
||||
place(&mut h, &[[10., 0.]], [10., 5.]);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
let undo = h.app.session.undo.len();
|
||||
crate::dim_view::edit_param(&h.app, "d1");
|
||||
h.settle();
|
||||
type_value(&mut h, "24 mm");
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(h.app.session.doc.param("d1").unwrap().expr.starts_with("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_side_and_fixed_origin_line_dimensions_preserve_other_constraints() {
|
||||
for edge in [false, true] {
|
||||
let mut h = line([20., 0.]);
|
||||
h.app.run("sketch.constraint.fix", json!({"entity":"l1.start"})).unwrap();
|
||||
h.settle();
|
||||
let original = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let picks = if edge { vec![[10., 0.]] } else { vec![[0., 0.], [20., 0.]] };
|
||||
place(&mut h, &picks, [10., 5.]);
|
||||
type_value(&mut h, "30 mm");
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch;
|
||||
assert!(sk.point(sk.resolve_point("l1.start").unwrap()).unwrap().len() < 1e-8, "fixed origin remains fixed");
|
||||
assert!((sk.point(sk.resolve_point("l1.end").unwrap()).unwrap().x - 30.).abs() < 1e-6);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), original);
|
||||
}
|
||||
for (picks, at) in
|
||||
[(vec![[0., 0.], [0., 10.]], [-5., 5.]), (vec![[0., 5.]], [-5., 5.]), (vec![[20., 0.], [20., 10.]], [25., 5.]), (vec![[20., 5.]], [25., 5.])]
|
||||
{
|
||||
let mut h = Harness::new();
|
||||
crate::dim_view::cancel();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,10]})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 80.;
|
||||
h.app.cam.target = solvecraft_engine::geom::Vec3::new(10., 5., 0.);
|
||||
h.settle();
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
place(&mut h, &picks, at);
|
||||
type_value(&mut h, "12 mm");
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
let state = h.app.session.world_state();
|
||||
let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert!((solved.profiles[0].area - 240.).abs() < 1e-5, "side dimensions keep rectangle width: {}", solved.profiles[0].area);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_second_nested_plane_uses_world_picks_but_one_local_dimension() {
|
||||
use solvecraft_engine::geom::Vec2;
|
||||
crate::dim_view::cancel();
|
||||
let mut h = Harness::new();
|
||||
let parent = h.app.run("component.create", json!({"name":"Assembly"})).unwrap();
|
||||
let parent_component = parent["component"].as_u64().unwrap();
|
||||
let first = parent["occurrence"].as_u64().unwrap();
|
||||
let child = h.app.run("component.create", json!({"name":"Part"})).unwrap();
|
||||
let component = child["component"].as_u64().unwrap();
|
||||
let leaf = child["occurrence"].as_u64().unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
let second = h.app.run("occurrence.copy", json!({"component":parent_component})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"translate":[20,30,40],"axis":[1,0,0],"angle":"35 deg","capture":true})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":component,"occurrence":leaf,"path":[second,leaf]})).unwrap();
|
||||
h.app.run("browser.visibility", json!({"paths":[[first]],"visible":false})).unwrap();
|
||||
let sid = h.app.run("sketch.create", json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap();
|
||||
// The 2D producer uses sketch-local coordinates; pointer picks below use placed world coordinates.
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"translate":[25,32,40],"axis":[1,0,0],"angle":"35 deg","capture":false})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 90.;
|
||||
let plane = h.app.session.world_state().sketch(sid).unwrap().plane;
|
||||
h.app.cam.target = plane.to_world(Vec2::new(10., 0.));
|
||||
h.settle();
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let pending = h.app.session.pending_moves.clone();
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
for p in [Vec2::ZERO, Vec2::new(20., 0.), Vec2::new(10., 5.)] {
|
||||
let w = plane.to_world(p);
|
||||
h.step(&json!({"click":{"world":[w.x,w.y,w.z]}})).unwrap();
|
||||
}
|
||||
assert!(crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
type_value(&mut h, "30 mm");
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert_eq!(h.app.session.active_occurrence_path, Some(vec![second, leaf]));
|
||||
assert_eq!(h.app.session.pending_moves, pending);
|
||||
let state = h.app.session.model.state();
|
||||
let sk = &state.sketch(sid).unwrap().sketch;
|
||||
assert!(
|
||||
(sk.point(sk.resolve_point("l1.start").unwrap()).unwrap().dist(sk.point(sk.resolve_point("l1.end").unwrap()).unwrap()) - 30.).abs() < 1e-6
|
||||
);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.pending_moves, pending);
|
||||
h.app.run("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.pending_moves, pending);
|
||||
assert_eq!(h.app.session.active_occurrence_path, Some(vec![second, leaf]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_zero_negative_failed_open_and_click_away_leave_no_dimension() {
|
||||
let mut h = line([20., 0.]);
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
for invalid in ["0 mm", "-2 mm"] {
|
||||
place(&mut h, &[[10., 0.]], [10., 5.]);
|
||||
type_value(&mut h, invalid);
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
}
|
||||
place(&mut h, &[[10., 0.]], [10., 5.]);
|
||||
assert!(h.app.run("doc.open", json!({"path":"missing-dimension-draft-design.solvecraft"})).is_err());
|
||||
assert!(crate::dim_view::editing(), "failed replacement must preserve the draft");
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
h.frames(8);
|
||||
h.step(&json!({"click":{"world":[12,-10,0]}})).unwrap();
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimension_to_fillet_virtual_sharp_keeps_the_arc_and_fixed_origin() {
|
||||
crate::dim_view::cancel();
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[0,20],[20,20]],"ids":["riser","top"]})).unwrap();
|
||||
h.app.run("sketch.constraint.fix", json!({"entity":"riser.start"})).unwrap();
|
||||
h.app.run("sketch.constraint.horizontal_vertical", json!({"line":"riser","mode":"vertical"})).unwrap();
|
||||
h.app.run("sketch.constraint.horizontal_vertical", json!({"line":"top","mode":"horizontal"})).unwrap();
|
||||
// An existing width requires the fillet's retained virtual sharp. Add an independent
|
||||
// height through the visible corner while preserving that width and the fillet arc.
|
||||
h.app.run("sketch.dimension", json!({"entities":["top"],"value":20})).unwrap();
|
||||
let fillet = h.app.run("sketch.fillet", json!({"point":"riser.end","radius":5})).unwrap();
|
||||
let corner = fillet["virtual_corners"][0].as_str().unwrap().to_string();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 90.;
|
||||
h.app.cam.target = solvecraft_engine::geom::Vec3::new(5., 10., 0.);
|
||||
h.settle();
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
place(&mut h, &[[0., 0.], [0., 20.]], [-5., 10.]);
|
||||
type_value(&mut h, "30 mm");
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let state = h.app.session.world_state();
|
||||
let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert!(solved.report.ok());
|
||||
let sk = &solved.sketch;
|
||||
assert!((sk.point(sk.point_index(&corner).unwrap()).unwrap().y - 30.).abs() < 1e-6);
|
||||
assert!(sk.point(sk.resolve_point("riser.start").unwrap()).unwrap().len() < 1e-8);
|
||||
assert!(sk.curves.iter().any(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Arc { .. })), "the dimension retains the fillet arc");
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_annotation_does_not_move_committed_dimension_hitboxes() {
|
||||
crate::dim_view::cancel();
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,10]})).unwrap();
|
||||
let width = h.app.run("sketch.dimension", json!({"entities":["l3"],"value":20})).unwrap()["param"].as_str().unwrap().to_string();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 80.;
|
||||
h.app.cam.target = solvecraft_engine::geom::Vec3::new(10., 5., 0.);
|
||||
h.settle();
|
||||
let at = crate::dim_view::text_at(&width).unwrap();
|
||||
let drawn = crate::dim_view::drawn();
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
place(&mut h, &[[0., 0.], [0., 10.]], [-5., 5.]);
|
||||
type_value(&mut h, "12 mm");
|
||||
h.settle();
|
||||
assert_eq!(crate::dim_view::text_at(&width), Some(at));
|
||||
assert_eq!(crate::dim_view::drawn(), drawn, "draft annotations must not become committed hit targets");
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(crate::dim_view::drawn(), drawn);
|
||||
}
|
||||
@@ -76,9 +76,10 @@ fn put_view(app: &mut SolveApp, v: DocView) {
|
||||
fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
|
||||
// Whatever was going on in the design leaves with it.
|
||||
crate::dialogs::cancel(app);
|
||||
crate::dim_view::cancel();
|
||||
app.tool = None;
|
||||
app.params = None;
|
||||
app.cam_anim = None;
|
||||
app.cancel_view_animation();
|
||||
app.viewport.orbit_snap = None;
|
||||
app.viewport.orbit_free = None;
|
||||
app.preview = crate::preview::PreviewState::default();
|
||||
@@ -391,20 +392,21 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, !d.get_temp::<bool>(id).unwrap_or(false)));
|
||||
}
|
||||
if ui.ctx().data(|d| d.get_temp::<bool>(id).unwrap_or(false)) {
|
||||
let resp = egui::Area::new(id.with("area")).fixed_pos(pos2(mr.left(), r.bottom())).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
crate::context_menu::menu_frame(ui, |ui| {
|
||||
for (i, (name, dirty)) in info.iter().enumerate() {
|
||||
let item = crate::context_menu::Item::action(
|
||||
"doc",
|
||||
&format!("{name}{}", if *dirty { " •" } else { "" }),
|
||||
if i == app.docs.active { "box" } else { "" },
|
||||
);
|
||||
if crate::context_menu::list_row(ui, &item).is_some() {
|
||||
pick = Some(i);
|
||||
let resp =
|
||||
crate::frame::area(id.with("area")).fixed_pos(pos2(mr.left(), r.bottom())).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
crate::context_menu::menu_frame(ui, |ui| {
|
||||
for (i, (name, dirty)) in info.iter().enumerate() {
|
||||
let item = crate::context_menu::Item::action(
|
||||
"doc",
|
||||
&format!("{name}{}", if *dirty { " •" } else { "" }),
|
||||
if i == app.docs.active { "box" } else { "" },
|
||||
);
|
||||
if crate::context_menu::list_row(ui, &item).is_some() {
|
||||
pick = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
});
|
||||
let pressed_outside = ui.ctx().input(|i| i.pointer.any_pressed()) && !resp.response.contains_pointer() && !mresp.contains_pointer();
|
||||
if pick.is_some() || pressed_outside {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, false));
|
||||
|
||||
@@ -33,6 +33,7 @@ pub fn docked<'a>(title: impl Into<WidgetText>, id: egui::Id, anchor: Pos2, max_
|
||||
Width::Wide => DIALOG_WIDE_MAX_W,
|
||||
};
|
||||
egui::Window::new(title)
|
||||
.enabled(!crate::issue_report::cad_input_blocked())
|
||||
.id(id)
|
||||
.title_bar(false)
|
||||
.pivot(Align2::RIGHT_TOP)
|
||||
@@ -52,7 +53,17 @@ pub fn window<'a>(ctx: &egui::Context, title: impl Into<WidgetText>, width: Widt
|
||||
Width::Wide => WINDOW_WIDE_MAX_W,
|
||||
};
|
||||
let c = ctx.content_rect().center();
|
||||
egui::Window::new(title).collapsible(false).pivot(Align2::CENTER_CENTER).default_pos(c - vec2(0.0, 60.0)).max_width(max)
|
||||
egui::Window::new(title)
|
||||
.enabled(!crate::issue_report::cad_input_blocked())
|
||||
.collapsible(false)
|
||||
.pivot(Align2::CENTER_CENTER)
|
||||
.default_pos(c - vec2(0.0, 60.0))
|
||||
.max_width(max)
|
||||
}
|
||||
|
||||
/// Floating model controls share the report's read-only drawing boundary with windows.
|
||||
pub fn area(id: egui::Id) -> egui::Area {
|
||||
egui::Area::new(id).enabled(!crate::issue_report::cad_input_blocked())
|
||||
}
|
||||
|
||||
/// The width a window's content had last frame (at least `min`), for laying out rows that end at
|
||||
|
||||
@@ -241,6 +241,11 @@ fn snap(v: f64, step: f64, free: bool) -> f64 {
|
||||
if free || step <= 0.0 { v } else { (v / step).round() * step }
|
||||
}
|
||||
|
||||
/// Discard a suspended pointer gesture without reporting a final model change.
|
||||
pub(crate) fn cancel(ctx: &egui::Context, id: egui::Id) {
|
||||
ctx.data_mut(|d| d.remove::<State>(id));
|
||||
}
|
||||
|
||||
/// Draw the triad and run a drag on it. `step` is the distance snap step.
|
||||
pub fn show(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, id: egui::Id, t: &Triad, step: f64) -> Option<Drag> {
|
||||
let tk = Tokens::get();
|
||||
|
||||
+18
-13
@@ -1,6 +1,6 @@
|
||||
//! The Help menu (the ? button on the application bar): Documentation (the online book), About
|
||||
//! SolveCraft (version, commit, build date, licences, attributions, fonts), the keyboard shortcuts
|
||||
//! (read only), and Report an Issue, which copies diagnostic information to the clipboard.
|
||||
//! (read only), and Report an Issue, which opens a local editable report draft.
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
|
||||
@@ -54,8 +54,9 @@ pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
|
||||
app.keymap.read_only = true;
|
||||
}
|
||||
"report" => {
|
||||
ctx.copy_text(diagnostics(app));
|
||||
app.set_status("Diagnostic information copied: paste it into your issue report", false);
|
||||
let dialog = app.dialog.clone();
|
||||
let error = dialog.as_ref().and_then(|d| d.error.clone()).or_else(|| app.preview.error.clone());
|
||||
crate::issue_report::begin(app, ctx, dialog.as_ref(), error.as_deref());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -65,17 +66,21 @@ pub fn run(app: &mut SolveApp, ctx: &egui::Context, item: &str) {
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context, anchor: egui::Pos2) {
|
||||
if app.help.menu {
|
||||
let mut pick: Option<&str> = None;
|
||||
let resp = egui::Area::new(egui::Id::new("sc_help_menu")).fixed_pos(anchor).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
crate::context_menu::menu_frame(ui, |ui| {
|
||||
for (id, label) in
|
||||
[("docs", "Documentation"), ("about", "About SolveCraft"), ("shortcuts", "Keyboard Shortcuts"), ("report", "Report an Issue…")]
|
||||
{
|
||||
if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() {
|
||||
pick = Some(id);
|
||||
let resp =
|
||||
crate::frame::area(egui::Id::new("sc_help_menu")).fixed_pos(anchor).constrain(true).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
crate::context_menu::menu_frame(ui, |ui| {
|
||||
for (id, label) in [
|
||||
("docs", "Documentation"),
|
||||
("about", "About SolveCraft"),
|
||||
("shortcuts", "Keyboard Shortcuts"),
|
||||
("report", "Report an Issue…"),
|
||||
] {
|
||||
if crate::context_menu::list_row(ui, &crate::context_menu::Item::action(id, label, "")).is_some() {
|
||||
pick = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
});
|
||||
if let Some(p) = pick {
|
||||
run(app, ctx, p);
|
||||
} else if ctx.input(|i| i.pointer.any_pressed() || i.key_pressed(egui::Key::Escape)) && !resp.response.contains_pointer() {
|
||||
|
||||
@@ -0,0 +1,992 @@
|
||||
//! Reviewed issue drafts with local evidence; never publishes or uploads a report.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{ColorImage, RichText, TextureHandle, vec2};
|
||||
|
||||
use crate::{SolveApp, dialogs, help};
|
||||
|
||||
const MAX_BODY: usize = 24_000;
|
||||
const MAX_IMAGE_PIXELS: usize = 16_000_000;
|
||||
const ISSUE_URL: &str = "https://github.com/bherbruck/solvecraft/issues/new";
|
||||
|
||||
thread_local! {
|
||||
static CAD_INPUT_BLOCKED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
pub(crate) fn cad_input_blocked() -> bool {
|
||||
CAD_INPUT_BLOCKED.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// Draw CAD behind a report without delivering its input or focus to any model editor.
|
||||
/// Fresh focus history also prevents inline editors from committing on report focus loss.
|
||||
pub(crate) struct CadInput {
|
||||
ctx: egui::Context,
|
||||
input: Option<egui::InputState>,
|
||||
memory: Option<egui::Memory>,
|
||||
previous_blocked: bool,
|
||||
}
|
||||
|
||||
impl CadInput {
|
||||
pub(crate) fn block(app: &SolveApp, ui: &mut egui::Ui, guard: &mut Option<Self>) {
|
||||
if guard.is_none() {
|
||||
*guard = Self::begin(app, ui.ctx());
|
||||
}
|
||||
if guard.is_some() {
|
||||
ui.visuals_mut().disabled_alpha = 1.0;
|
||||
ui.disable();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn begin(app: &SolveApp, ctx: &egui::Context) -> Option<Self> {
|
||||
app.issue_report.draft.as_ref()?;
|
||||
let mut raw = ctx.input(|i| i.raw.clone());
|
||||
raw.events.clear();
|
||||
raw.dropped_files.clear();
|
||||
raw.hovered_files.clear();
|
||||
let neutral = egui::InputState::default().begin_pass(raw.clone(), true, ctx.pixels_per_point(), egui::InputOptions::default());
|
||||
let input = ctx.input_mut(|i| std::mem::replace(i, neutral));
|
||||
let memory = ctx.memory_mut(|m| {
|
||||
let mut saved = std::mem::take(m);
|
||||
let mut neutral = egui::Memory::default();
|
||||
neutral.options = saved.options.clone();
|
||||
Arc::make_mut(&mut neutral.options.dark_style).visuals.disabled_alpha = 1.0;
|
||||
Arc::make_mut(&mut neutral.options.light_style).visuals.disabled_alpha = 1.0;
|
||||
neutral.data = saved.data.clone();
|
||||
neutral.to_global = saved.to_global.clone();
|
||||
*neutral.areas_mut() = saved.areas().clone();
|
||||
neutral.caches = std::mem::take(&mut saved.caches);
|
||||
*m = neutral;
|
||||
saved
|
||||
});
|
||||
let previous_blocked = CAD_INPUT_BLOCKED.with(|blocked| blocked.replace(true));
|
||||
Some(Self { ctx: ctx.clone(), input: Some(input), memory: Some(memory), previous_blocked })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CadInput {
|
||||
fn drop(&mut self) {
|
||||
CAD_INPUT_BLOCKED.with(|blocked| blocked.set(self.previous_blocked));
|
||||
if let Some(input) = self.input.take() {
|
||||
self.ctx.input_mut(|i| *i = input);
|
||||
}
|
||||
if let Some(mut memory) = self.memory.take() {
|
||||
self.ctx.memory_mut(|m| {
|
||||
// Keep current layout/widget state and caches; isolate only input/focus history.
|
||||
memory.data = std::mem::take(&mut m.data);
|
||||
memory.caches = std::mem::take(&mut m.caches);
|
||||
memory.to_global = std::mem::take(&mut m.to_global);
|
||||
*memory.areas_mut() = m.areas().clone();
|
||||
*m = memory;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ReportState {
|
||||
pub draft: Option<Draft>,
|
||||
pending: Option<Pending>,
|
||||
cad_focus: Option<egui::Id>,
|
||||
}
|
||||
|
||||
pub struct Draft {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
image: Option<Arc<ColorImage>>,
|
||||
texture: Option<TextureHandle>,
|
||||
pub note: String,
|
||||
source_signature: u64,
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
token: u64,
|
||||
signature: u64,
|
||||
deadline: f64,
|
||||
window: egui::Rect,
|
||||
pixels_per_point: f32,
|
||||
}
|
||||
|
||||
/// A bounded formatting sink also avoids allocating a full hostile Debug/JSON value first.
|
||||
struct Text {
|
||||
value: String,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Write for Text {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
let room = self.limit.saturating_sub(self.value.len());
|
||||
if s.len() <= room {
|
||||
self.value.push_str(s);
|
||||
return Ok(());
|
||||
}
|
||||
let mut end = room.min(s.len());
|
||||
while !s.is_char_boundary(end) {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
self.value.push_str(s.get(..end).unwrap_or_default());
|
||||
Err(std::fmt::Error)
|
||||
}
|
||||
}
|
||||
|
||||
fn debug(value: &impl std::fmt::Debug, limit: usize) -> String {
|
||||
let mut text = Text { value: String::new(), limit };
|
||||
if write!(&mut text, "{value:#?}").is_err() {
|
||||
text.value.push_str("\n[truncated]");
|
||||
}
|
||||
text.value
|
||||
}
|
||||
|
||||
struct JsonBytes {
|
||||
value: Vec<u8>,
|
||||
limit: usize,
|
||||
}
|
||||
impl std::io::Write for JsonBytes {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
let n = bytes.len().min(self.limit.saturating_sub(self.value.len()));
|
||||
self.value.extend_from_slice(bytes.get(..n).unwrap_or_default());
|
||||
if n != bytes.len() {
|
||||
return Err(std::io::Error::other("report context limit"));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn json_context(value: &impl serde::Serialize, limit: usize) -> String {
|
||||
let mut bytes = JsonBytes { value: Vec::new(), limit };
|
||||
let truncated = serde_json::to_writer_pretty(&mut bytes, value).is_err();
|
||||
let mut value = String::from_utf8_lossy(&bytes.value).into_owned();
|
||||
if truncated {
|
||||
value.push_str("\n[truncated]");
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn truncate(value: &mut String, limit: usize) {
|
||||
if value.len() > limit {
|
||||
let mut end = limit;
|
||||
while !value.is_char_boundary(end) {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
value.truncate(end);
|
||||
}
|
||||
}
|
||||
|
||||
fn limited(value: &str, limit: usize) -> String {
|
||||
let mut end = limit.min(value.len());
|
||||
while !value.is_char_boundary(end) {
|
||||
end = end.saturating_sub(1);
|
||||
}
|
||||
value.get(..end).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
fn block(value: &str) -> String {
|
||||
value.lines().map(|line| format!(" {line}\n")).collect()
|
||||
}
|
||||
|
||||
/// Hash directly through formatting, without constructing an unbounded Debug string.
|
||||
struct Fingerprint(std::collections::hash_map::DefaultHasher);
|
||||
impl std::fmt::Write for Fingerprint {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
self.0.write(s.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn source_signature(app: &SolveApp, dialog: Option<&dialogs::Dialog>) -> u64 {
|
||||
let mut h = Fingerprint(std::collections::hash_map::DefaultHasher::new());
|
||||
app.session.revision.hash(&mut h.0);
|
||||
app.docs.active.hash(&mut h.0);
|
||||
app.docs.count().hash(&mut h.0);
|
||||
app.session.doc.name.hash(&mut h.0);
|
||||
app.preview.key.hash(&mut h.0);
|
||||
app.preview.error.hash(&mut h.0);
|
||||
app.preview.busy.hash(&mut h.0);
|
||||
let _ = write!(&mut h, "{:?}{:?}", dialog, app.session.selection);
|
||||
h.0.finish()
|
||||
}
|
||||
|
||||
fn signature(app: &SolveApp, dialog: Option<&dialogs::Dialog>) -> u64 {
|
||||
let mut h = Fingerprint(std::collections::hash_map::DefaultHasher::new());
|
||||
source_signature(app, dialog).hash(&mut h.0);
|
||||
let _ = write!(&mut h, "{:?}{:?}{:?}", app.cam, app.viewport.rect, app.ui.perspective);
|
||||
h.0.finish()
|
||||
}
|
||||
|
||||
fn reportable(feature: &solvecraft_engine::doc::Feature) -> bool {
|
||||
!matches!(
|
||||
feature.kind,
|
||||
solvecraft_engine::doc::FeatureKind::Sketch { .. }
|
||||
| solvecraft_engine::doc::FeatureKind::Import { .. }
|
||||
| solvecraft_engine::doc::FeatureKind::MeshImport { .. }
|
||||
)
|
||||
}
|
||||
|
||||
fn feature_context(feature: &solvecraft_engine::doc::Feature) -> String {
|
||||
format!("Feature {}: {}\n{}", feature.id, debug(&feature.name, 160), block(&json_context(&feature.kind, 2000)))
|
||||
}
|
||||
|
||||
/// Read-only context excludes whole documents, sketch geometry and imported file contents.
|
||||
pub fn draft_for(app: &SolveApp, dialog: Option<&dialogs::Dialog>, error: Option<&str>) -> Draft {
|
||||
let mut context = String::new();
|
||||
let label = dialog.map(dialogs::report_title).unwrap_or_else(|| "Modeling".into());
|
||||
if let Some(d) = dialog {
|
||||
match dialogs::apply_commands(app, d) {
|
||||
Ok(commands) => context.push_str(&block(&json_context(&commands, 10_000))),
|
||||
Err(e) => {
|
||||
context.push_str(&format!("Could not build command parameters: {}\n", debug(&e, 1000)));
|
||||
context.push_str(&block(&debug(&d.kind, 6000)));
|
||||
context.push_str(&block(&debug(&d.inputs, 3000)));
|
||||
context.push_str(&block(&debug(&d.extra, 1000)));
|
||||
}
|
||||
}
|
||||
}
|
||||
context.push_str("\nSelected items (including occurrence paths):\n");
|
||||
context.push_str(&block(&debug(&app.session.selection.iter().take(32).collect::<Vec<_>>(), 3000)));
|
||||
for item in app.session.selection.iter().take(8) {
|
||||
if let solvecraft_engine::Sel::Feature { id } = item
|
||||
&& let Some(feature) = app.session.doc.feature(*id)
|
||||
&& reportable(feature)
|
||||
{
|
||||
context.push_str(&feature_context(feature));
|
||||
if let Some(e) = app.session.model.result(*id).and_then(|r| r.error.as_deref()) {
|
||||
context.push_str(&format!("Feature error: {}\n", debug(&e, 1000)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if dialog.is_none()
|
||||
&& let Some(feature) = app.session.doc.features.iter().rev().find(|f| reportable(f))
|
||||
{
|
||||
context.push_str("\nLatest modeling operation:\n");
|
||||
context.push_str(&feature_context(feature));
|
||||
if let Some(e) = app.session.model.result(feature.id).and_then(|r| r.error.as_deref()) {
|
||||
context.push_str(&format!("Feature error: {}\n", debug(&e, 1000)));
|
||||
}
|
||||
}
|
||||
let actual = error.map(|e| debug(&e, 4000)).unwrap_or_else(|| "Describe the unexpected result shown in the view.".into());
|
||||
let image_state = if error.is_some() {
|
||||
"A failed preview may display the last valid geometry; that geometry is not evidence that the failed operation succeeded."
|
||||
} else {
|
||||
"Describe what looks wrong in the current result."
|
||||
};
|
||||
let mut body = format!(
|
||||
"## Expected behavior\nDescribe the intended result.\n\n## Actual behavior\n{actual}\n\n{image_state}\n\n## Reproduction\nDescribe the steps or a small public-safe example.\n\n## Settings and selection\n{context}\n## Environment\n{}\nRevision: {}\nCamera: {}\n\n## Visual context\nAttach the reviewed screenshot or a short example showing the problem.\n",
|
||||
block(&debug(&help::diagnostics(app), 2000)),
|
||||
app.session.revision,
|
||||
block(&debug(&app.cam, 1500))
|
||||
);
|
||||
if body.len() > MAX_BODY {
|
||||
truncate(&mut body, MAX_BODY - 32);
|
||||
body.push_str("\n[report context truncated]\n");
|
||||
}
|
||||
let mut title = format!("{label}: {}", if error.is_some() { "operation failed" } else { "unexpected result" });
|
||||
truncate(&mut title, 160);
|
||||
Draft { title, body, image: None, texture: None, note: String::new(), source_signature: source_signature(app, dialog) }
|
||||
}
|
||||
|
||||
fn encode(value: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in value.bytes() {
|
||||
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
|
||||
out.push(char::from(b));
|
||||
} else {
|
||||
let _ = write!(&mut out, "%{b:02X}");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Long drafts stay local and can be copied into the browser editor; never discard them.
|
||||
pub fn draft_url(title: &str, body: &str) -> (String, bool) {
|
||||
let title = limited(title, 160);
|
||||
let base = format!("{ISSUE_URL}?title={}", encode(&title));
|
||||
if body.len() <= MAX_BODY {
|
||||
let url = format!("{base}&body={}", encode(body));
|
||||
if url.len() <= 2000 {
|
||||
return (url, false);
|
||||
}
|
||||
}
|
||||
(base, true)
|
||||
}
|
||||
|
||||
pub fn begin(app: &mut SolveApp, ctx: &egui::Context, dialog: Option<&dialogs::Dialog>, error: Option<&str>) {
|
||||
if app.issue_report.draft.is_none() {
|
||||
app.issue_report.cad_focus = ctx.memory(|m| m.focused());
|
||||
}
|
||||
cancel_capture(app);
|
||||
app.issue_report.draft = Some(draft_for(app, dialog, error));
|
||||
if let Some(draft) = app.issue_report.draft.as_mut() {
|
||||
let rect = ctx.content_rect();
|
||||
let _ = write!(
|
||||
&mut draft.body,
|
||||
"\nWindow: {:.0}×{:.0} logical points; {:.2} physical pixels/point\n",
|
||||
rect.width(),
|
||||
rect.height(),
|
||||
ctx.pixels_per_point()
|
||||
);
|
||||
truncate(&mut draft.body, MAX_BODY);
|
||||
}
|
||||
capture(app, ctx, dialog);
|
||||
}
|
||||
|
||||
fn cancel_capture(app: &mut SolveApp) {
|
||||
if let Some(pending) = app.issue_report.pending.take() {
|
||||
app.queued_shots.retain(|(token, ..)| *token != pending.token);
|
||||
}
|
||||
}
|
||||
|
||||
fn capture(app: &mut SolveApp, ctx: &egui::Context, dialog: Option<&dialogs::Dialog>) {
|
||||
cancel_capture(app);
|
||||
let current_source = source_signature(app, dialog);
|
||||
let Some(draft) = app.issue_report.draft.as_mut() else { return };
|
||||
draft.image = None;
|
||||
draft.texture = None;
|
||||
if draft.source_signature != current_source {
|
||||
draft.note =
|
||||
"Settings or model changed. Close this draft and create a new report to capture matching context; your text is still available to copy."
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
if app.preview.busy || app.queued_shots.len() >= 16 {
|
||||
draft.note = "Screenshot unavailable while the preview is updating. Reopen the report after it settles, or continue with text.".into();
|
||||
return;
|
||||
}
|
||||
let Some(token) = app.shot_token.checked_add(1) else {
|
||||
draft.note = "Screenshot request limit reached; restart the app to capture another frame.".into();
|
||||
return;
|
||||
};
|
||||
app.shot_token = token;
|
||||
draft.note = "Capturing the current app view locally…".into();
|
||||
let now = crate::now_ms();
|
||||
app.issue_report.pending = Some(Pending {
|
||||
token,
|
||||
signature: signature(app, dialog),
|
||||
deadline: now + 8000.0,
|
||||
window: ctx.content_rect(),
|
||||
pixels_per_point: ctx.pixels_per_point(),
|
||||
});
|
||||
app.queued_shots.push((token, now + 150.0, 0));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
pub(crate) fn pending(app: &SolveApp) -> bool {
|
||||
app.issue_report.pending.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let problem = app.issue_report.pending.as_ref().and_then(|p| {
|
||||
if signature(app, app.dialog.as_ref()) != p.signature || p.window != ctx.content_rect() || p.pixels_per_point != ctx.pixels_per_point() {
|
||||
Some("The model, inputs or view changed during capture. Retake the current view.")
|
||||
} else if crate::now_ms() >= p.deadline {
|
||||
Some("No app frame was presented. Restore the window and retake, or continue with text.")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(problem) = problem {
|
||||
cancel_capture(app);
|
||||
if let Some(draft) = app.issue_report.draft.as_mut() {
|
||||
draft.note = problem.into();
|
||||
}
|
||||
}
|
||||
if pending(app) {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn receive(app: &mut SolveApp, token: u64, image: Arc<ColorImage>) {
|
||||
if !app.issue_report.pending.as_ref().is_some_and(|p| p.token == token) {
|
||||
return;
|
||||
}
|
||||
let stale = app.issue_report.pending.as_ref().is_some_and(|p| p.signature != signature(app, app.dialog.as_ref()));
|
||||
app.issue_report.pending = None;
|
||||
if let Some(draft) = app.issue_report.draft.as_mut() {
|
||||
if stale {
|
||||
draft.note = "The model, inputs or view changed during capture. Retake the current view.".into();
|
||||
return;
|
||||
}
|
||||
let pixels = image.width().checked_mul(image.height());
|
||||
if !pixels.is_some_and(|n| n > 0 && n <= MAX_IMAGE_PIXELS && n == image.pixels.len()) {
|
||||
draft.note = "Screenshot dimensions are unsupported. The text draft is still available.".into();
|
||||
} else {
|
||||
draft.image = Some(image);
|
||||
draft.note = "Screenshot captured locally. Review it before attaching it on GitHub.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The report window is hidden only while a frame is captured, so it cannot cover the evidence.
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if pending(app) {
|
||||
return;
|
||||
}
|
||||
let Some(mut draft) = app.issue_report.draft.take() else { return };
|
||||
let mut open = true;
|
||||
let mut retake = false;
|
||||
let mut close = false;
|
||||
let screen = ctx.content_rect();
|
||||
crate::frame::window(ctx, "Create Issue — review draft", crate::frame::Width::Wide)
|
||||
.id(egui::Id::new("sc_issue_report"))
|
||||
.open(&mut open)
|
||||
.resizable(true)
|
||||
.default_width((screen.width() - 32.0).clamp(100.0, 640.0))
|
||||
.max_width((screen.width() - 24.0).max(100.0))
|
||||
.max_height((screen.height() - 32.0).max(100.0))
|
||||
.vscroll(true)
|
||||
.show(ctx, |ui| {
|
||||
ui.label("Review the report and screenshot before opening GitHub.");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut draft.title)
|
||||
.id(egui::Id::new("issue_report_title"))
|
||||
.hint_text("Issue title")
|
||||
.char_limit(160)
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
egui::ScrollArea::vertical().id_salt("issue-report-body").max_height((screen.height() * 0.35).clamp(72.0, 240.0)).show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut draft.body)
|
||||
.id(egui::Id::new("issue_report_body"))
|
||||
.char_limit(MAX_BODY)
|
||||
.desired_rows(5)
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
});
|
||||
truncate(&mut draft.title, 160);
|
||||
truncate(&mut draft.body, MAX_BODY);
|
||||
ui.label(RichText::new("Remove private design details or paths before sharing.").small());
|
||||
ui.label(&draft.note);
|
||||
if let Some(image) = draft.image.as_ref() {
|
||||
let texture =
|
||||
draft.texture.get_or_insert_with(|| ctx.load_texture("issue-report-frame", image.clone(), egui::TextureOptions::LINEAR));
|
||||
ui.add(
|
||||
egui::Image::new(&*texture)
|
||||
.max_size(vec2(ui.available_width(), (screen.height() * 0.25).clamp(60.0, 220.0)))
|
||||
.maintain_aspect_ratio(true),
|
||||
);
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
retake = ui.button("Retake screenshot").clicked();
|
||||
if ui.add_enabled(draft.image.is_some(), egui::Button::new("Copy screenshot")).clicked()
|
||||
&& let Some(image) = draft.image.as_ref()
|
||||
{
|
||||
ctx.copy_image((**image).clone());
|
||||
draft.note = "Paste the image into the GitHub draft, or save the PNG if image copying is unavailable.".into();
|
||||
}
|
||||
if ui.add_enabled(draft.image.is_some() && app.services.pick_save.is_some(), egui::Button::new("Save screenshot…")).clicked()
|
||||
&& let Some(image) = draft.image.as_ref()
|
||||
&& let Some(path) =
|
||||
app.services.pick_save.as_ref().and_then(|pick| pick(&format!("solvecraft-issue-{}.png", app.shot_token), &["png"]))
|
||||
{
|
||||
let result = crate::control::save_screenshot(image, Some(&path));
|
||||
draft.note = if result.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
|
||||
"Screenshot saved. Attach the PNG in the GitHub draft.".into()
|
||||
} else {
|
||||
format!("Could not save screenshot: {}", debug(&result.get("error"), 1000))
|
||||
};
|
||||
}
|
||||
if ui.add_enabled(draft.image.is_some(), egui::Button::new("Remove screenshot")).clicked() {
|
||||
draft.image = None;
|
||||
draft.texture = None;
|
||||
draft.note = "Screenshot removed from this draft.".into();
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui.button("Copy report").clicked() {
|
||||
ctx.copy_text(draft.body.clone());
|
||||
draft.note = "Report copied. Paste it into the GitHub draft if needed.".into();
|
||||
}
|
||||
let (url, copy_needed) = draft_url(&draft.title, &draft.body);
|
||||
if ui.button("Open GitHub draft").clicked() {
|
||||
if copy_needed {
|
||||
ctx.copy_text(draft.body.clone());
|
||||
}
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
draft.note = if copy_needed {
|
||||
"Report copied. Paste into the GitHub draft, then attach the screenshot."
|
||||
} else {
|
||||
"Review the GitHub draft and attach the screenshot. GitHub's Submit new issue button publishes it."
|
||||
}
|
||||
.into();
|
||||
}
|
||||
close = ui.button("Close draft").clicked();
|
||||
});
|
||||
});
|
||||
if open && !close {
|
||||
app.issue_report.draft = Some(draft);
|
||||
if retake {
|
||||
let dialog = app.dialog.clone();
|
||||
capture(app, ctx, dialog.as_ref());
|
||||
}
|
||||
} else {
|
||||
cancel_capture(app);
|
||||
// Pointer release may have happened during review; never finalize it against
|
||||
// the report's last cursor position when CAD interaction resumes.
|
||||
app.viewport.point_drag = None;
|
||||
app.viewport.boxsel = None;
|
||||
app.viewport.axis_drag = None;
|
||||
app.viewport.orbit_free = None;
|
||||
app.viewport.orbit_snap = None;
|
||||
crate::dim_view::drag_end();
|
||||
crate::sketch3d::drag_end();
|
||||
crate::drag_snap::clear();
|
||||
crate::gizmo::cancel(ctx, egui::Id::new("sc_move_triad"));
|
||||
crate::gizmo::cancel(ctx, egui::Id::new("sc_occ_triad"));
|
||||
if let Some(focus) = app.issue_report.cad_focus.take() {
|
||||
ctx.memory_mut(|m| m.request_focus(focus));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::{Sel, Session};
|
||||
|
||||
fn app() -> SolveApp {
|
||||
SolveApp::new(Session::default(), Default::default())
|
||||
}
|
||||
|
||||
fn combine(app: &mut SolveApp) {
|
||||
app.start("solid.combine");
|
||||
let dialog = app.dialog.as_mut().unwrap();
|
||||
dialog.kind = dialogs::Kind::Combine { operation: 2, keep_tools: true };
|
||||
dialog.inputs.get_mut(0).unwrap().items = vec![Sel::Body { name: "Stock".into() }];
|
||||
dialog.inputs.get_mut(1).unwrap().items = vec![Sel::Body { name: "Cutter".into() }];
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_actual_pending_boolean_settings_and_failure_are_read_only() {
|
||||
let mut app = app();
|
||||
combine(&mut app);
|
||||
let before = (app.session.doc.to_json(), app.session.revision, app.session.undo.len(), app.session.redo.len(), app.cam);
|
||||
let draft = draft_for(&app, app.dialog.as_ref(), Some("Boolean tool is outside target"));
|
||||
for expected in
|
||||
["solid.combine", "Stock", "Cutter", "cut", "keep_tools", "true", "Boolean tool is outside target", help::VERSION, "last valid geometry"]
|
||||
{
|
||||
assert!(draft.body.contains(expected), "missing {expected}: {}", draft.body);
|
||||
}
|
||||
assert_eq!(before, (app.session.doc.to_json(), app.session.revision, app.session.undo.len(), app.session.redo.len(), app.cam));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_hostile_context_and_url_are_bounded_without_new_query_parameters() {
|
||||
let mut app = app();
|
||||
combine(&mut app);
|
||||
let hostile = "錯誤 ```\n## fake &labels=secret%20tag 🚀".repeat(40_000);
|
||||
let draft = draft_for(&app, app.dialog.as_ref(), Some(&hostile));
|
||||
assert!(draft.body.len() <= MAX_BODY);
|
||||
assert!(draft.body.contains("[truncated]"));
|
||||
let (url, copy) = draft_url("Cut &labels=secret #? 🚀", "actual\n```\n&assignees=someone");
|
||||
assert!(!copy);
|
||||
assert!(url.starts_with(ISSUE_URL));
|
||||
assert_eq!(url.matches('&').count(), 1);
|
||||
assert!(url.contains("%26labels%3Dsecret") && url.contains("%F0%9F%9A%80"));
|
||||
let (short, copy) = draft_url(&hostile, &draft.body);
|
||||
assert!(copy && short.len() <= 2000 && !short.contains("&body="));
|
||||
assert!(draft.body.contains("Stock"), "fallback must retain the editable full report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_help_supports_successful_geometry_without_mutating_it() {
|
||||
let mut app = app();
|
||||
app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
|
||||
app.run("solid.box", json!({"length":5,"width":5,"height":5,"body_name":"Cutter"})).unwrap();
|
||||
app.run("solid.combine", json!({"target":"Stock","tools":["Cutter"],"operation":"cut","keep_tools":true})).unwrap();
|
||||
assert!(app.session.model.results.iter().all(|r| r.error.is_none()));
|
||||
let before = (app.session.doc.to_json(), app.session.undo.len(), app.session.redo.len(), app.cam);
|
||||
let ctx = egui::Context::default();
|
||||
help::run(&mut app, &ctx, "report");
|
||||
let draft = app.issue_report.draft.as_ref().unwrap();
|
||||
assert!(draft.body.contains("Describe the unexpected result"));
|
||||
assert!(draft.body.contains(help::COMMIT));
|
||||
assert!(draft.body.contains("combine") && draft.body.contains("keep_tools") && draft.body.contains("Cutter"));
|
||||
assert!(pending(&app), "Help must request real evidence, not just copy generic text");
|
||||
assert_eq!(before, (app.session.doc.to_json(), app.session.undo.len(), app.session.redo.len(), app.cam));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_selected_and_recent_sketch_geometry_is_not_exported() {
|
||||
let mut app = app();
|
||||
app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"PublicExample"})).unwrap();
|
||||
let created = app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
let sketch = created["sketch"].as_u64().unwrap();
|
||||
app.run("sketch.point", json!({"point":[123456.789123,987654.321987]})).unwrap();
|
||||
app.run("sketch.finish", json!({})).unwrap();
|
||||
app.session.selection = vec![Sel::Feature { id: sketch }];
|
||||
let stored = app.session.doc.to_json();
|
||||
assert!(stored.contains("123456.789123") && stored.contains("987654.321987"));
|
||||
let draft = draft_for(&app, None, None);
|
||||
assert!(
|
||||
draft.body.contains("\"type\": \"box\"") && draft.body.contains("\"length\""),
|
||||
"keep the relevant last modeling settings: {}",
|
||||
draft.body
|
||||
);
|
||||
assert!(!draft.body.contains("123456.789123") && !draft.body.contains("987654.321987"));
|
||||
assert!(!draft.body.contains("\"points\""), "never serialize the selected or recent sketch point list");
|
||||
assert_eq!(stored, app.session.doc.to_json());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_error_row_click_opens_the_actual_boolean_draft() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
|
||||
h.app.home.open = false;
|
||||
combine(&mut h.app);
|
||||
let error = h.app.run("solid.combine", json!({"target":"Stock","tools":["Cutter"],"operation":"cut","keep_tools":true})).unwrap_err();
|
||||
h.app.dialog.as_mut().unwrap().error = Some(error.clone());
|
||||
h.frames(3);
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len());
|
||||
h.step(&json!({"widget":"Create Issue…"})).unwrap();
|
||||
let draft = h.app.issue_report.draft.as_ref().unwrap();
|
||||
assert!(draft.body.contains("solid.combine") && draft.body.contains("Cutter") && draft.body.contains(&format!("{error:?}")));
|
||||
assert!(h.app.dialog.is_some(), "reporting must not cancel the live feature dialog");
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_long_draft_open_click_copies_the_full_reviewed_text() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
let mut draft = draft_for(&h.app, None, None);
|
||||
draft.body = "Reviewed context 🚀 &labels=private\n".repeat(120);
|
||||
let reviewed = draft.body.clone();
|
||||
h.app.issue_report.draft = Some(draft);
|
||||
h.frames(4);
|
||||
let [x, y] = h.widget("Open GitHub draft").unwrap();
|
||||
assert_eq!(h.call("ui.move", json!({"x":x,"y":y}))["ok"], true);
|
||||
let (request, _reply) = crate::control::ControlRequest::new("ui.click", json!({"x":x,"y":y}));
|
||||
assert!(matches!(crate::control::handle(&mut h.app, &h.ctx, &request), crate::control::Outcome::Done(value) if value["ok"] == true));
|
||||
let mut commands = Vec::new();
|
||||
for _ in 0..12 {
|
||||
h.frame();
|
||||
commands.extend(h.output.commands.clone());
|
||||
}
|
||||
assert!(commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(text) if text == &reviewed)), "commands: {commands:?}");
|
||||
assert!(
|
||||
commands.iter().any(|c| matches!(c, egui::OutputCommand::OpenUrl(url) if url.url.starts_with(ISSUE_URL) && !url.url.contains("&body=")))
|
||||
);
|
||||
assert_eq!(h.app.issue_report.draft.as_ref().unwrap().body, reviewed);
|
||||
assert!(h.app.issue_report.draft.as_ref().unwrap().note.starts_with("Report copied. Paste into the GitHub draft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_editing_enter_and_cad_shortcuts_leave_the_live_boolean_unchanged() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":5,"width":5,"height":5,"body_name":"Cutter"})).unwrap();
|
||||
combine(&mut h.app);
|
||||
h.app.issue_report.draft = Some(draft_for(&h.app, h.app.dialog.as_ref(), None));
|
||||
h.frames(4);
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
|
||||
for field in ["issue_report_title", "issue_report_body"] {
|
||||
let id = egui::Id::new(field);
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.frames(1);
|
||||
h.step(&json!({"text":"edited-report-marker"})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(h.app.dialog.is_some(), "Enter in {field} must not apply the valid Boolean");
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
|
||||
h.ctx.memory_mut(|m| m.surrender_focus(id));
|
||||
}
|
||||
let draft = h.app.issue_report.draft.as_ref().unwrap();
|
||||
assert!(draft.title.contains("edited-report-marker") && draft.body.contains("edited-report-marker"));
|
||||
for key in [json!({"key":"Enter"}), json!({"key":"Escape"}), json!({"key":"Z","cmd":true})] {
|
||||
h.step(&key).unwrap();
|
||||
assert!(h.app.dialog.is_some() && h.app.issue_report.draft.is_some());
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_placed_sketch_text_cannot_steal_focus_apply_or_cancel_behind_draft() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.start("sketch.text");
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"sketch":[0,0]}})).unwrap();
|
||||
h.step(&json!({"text":"Pending CAD text"})).unwrap();
|
||||
assert!(h.widget("Pending CAD text").is_some(), "the real placed Text editor must be open");
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
|
||||
h.app.help.menu = true;
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"menu:report"}})).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.widget("Pending CAD text").is_some(), "capture must keep the underlying Text editor drawn");
|
||||
let token = h.app.issue_report.pending.as_ref().unwrap().token;
|
||||
receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4])));
|
||||
h.frames(3);
|
||||
for field in ["issue_report_title", "issue_report_body"] {
|
||||
let id = egui::Id::new(field);
|
||||
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||
h.frames(2);
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(id), "placed Text must not steal report focus");
|
||||
h.step(&json!({"text":"report-only-marker"})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
|
||||
assert!(h.widget("Pending CAD text").is_some(), "report keys must not apply, edit or cancel the Text editor");
|
||||
}
|
||||
let draft = h.app.issue_report.draft.as_ref().unwrap();
|
||||
assert!(draft.title.contains("report-only-marker") && draft.body.contains("report-only-marker"));
|
||||
h.step(&json!({"widget":"Close draft"})).unwrap();
|
||||
assert!(h.app.issue_report.draft.is_none());
|
||||
h.frames(2);
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
|
||||
assert!(h.widget("Pending CAD text").is_some());
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert_ne!(before.0, h.app.session.doc.to_json(), "closing the report must return control to the preserved CAD text");
|
||||
assert_eq!(h.app.session.undo.len(), before.1 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_boundary_preserves_opacity_widget_state_and_real_focus() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
let focus = egui::Id::new("report-review-focus");
|
||||
let state = egui::Id::new("cad-layout-state");
|
||||
begin(&mut app, &ctx, None, None);
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))),
|
||||
focused: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut output = ctx.run_ui(raw, |ui| {
|
||||
ctx.memory_mut(|m| m.request_focus(focus));
|
||||
let barrier = CadInput::begin(&app, &ctx).unwrap();
|
||||
ui.visuals_mut().disabled_alpha = 1.0;
|
||||
ui.disable();
|
||||
assert_eq!(ui.opacity(), 1.0, "capture must retain normal drawing opacity");
|
||||
assert!(!ui.is_enabled());
|
||||
crate::frame::area(egui::Id::new("cad-boundary-proof")).fade_in(false).show(&ctx, |ui| {
|
||||
assert!(!ui.is_enabled());
|
||||
assert_eq!(ui.opacity(), 1.0);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(state, 42_u32));
|
||||
ui.ctx().memory_mut(|m| m.request_focus(egui::Id::new("underlying-editor")));
|
||||
});
|
||||
drop(barrier);
|
||||
assert_eq!(ctx.memory(|m| m.focused()), Some(focus));
|
||||
assert_eq!(ctx.data(|d| d.get_temp::<u32>(state)), Some(42));
|
||||
assert!(crate::frame::area(egui::Id::new("report-boundary-proof")).is_enabled());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
app.issue_report.draft = None;
|
||||
assert!(!cad_input_blocked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_cached_point_drag_release_does_not_finish_cad_drag() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]],"id":"line"})).unwrap();
|
||||
h.frames(3);
|
||||
let at = h.call("ui.at", json!({"sketch":[0,0]}));
|
||||
let p = egui::pos2(at["result"][0].as_f64().unwrap() as f32, at["result"][1].as_f64().unwrap() as f32);
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(p));
|
||||
h.frame();
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: p,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.frame();
|
||||
let q = p + egui::vec2(40.0, -40.0);
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(q));
|
||||
h.frames(2);
|
||||
let drag = h.app.viewport.point_drag.clone();
|
||||
assert!(drag.is_some(), "real viewport point drag must have started");
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
|
||||
help::run(&mut h.app, &h.ctx, "report");
|
||||
// egui calculates this release before the CAD input barrier begins.
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: q,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.frame();
|
||||
assert_eq!(h.app.viewport.point_drag, drag, "cached release must not clear or constrain the CAD drag");
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
|
||||
let token = h.app.issue_report.pending.as_ref().unwrap().token;
|
||||
receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4])));
|
||||
h.frames(3);
|
||||
h.step(&json!({"widget":"Close draft"})).unwrap();
|
||||
assert!(h.app.viewport.point_drag.is_none());
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_new_dimension_draft_resumes_and_commits_once_after_close() {
|
||||
crate::dim_view::cancel();
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.app.cam.distance = 80.0;
|
||||
h.app.cam.target = solvecraft_engine::geom::Vec3::new(10.0, 0.0, 0.0);
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.dimension"})).unwrap();
|
||||
for point in [[0, 0, 0], [20, 0, 0], [10, 5, 0]] {
|
||||
h.step(&json!({"click":{"world":point}})).unwrap();
|
||||
}
|
||||
h.step(&json!({"text":"30 mm"})).unwrap();
|
||||
assert!(crate::dim_view::editing());
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
|
||||
help::run(&mut h.app, &h.ctx, "report");
|
||||
h.frames(3);
|
||||
let token = h.app.issue_report.pending.as_ref().unwrap().token;
|
||||
receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4])));
|
||||
h.frames(3);
|
||||
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("issue_report_body")));
|
||||
h.step(&json!({"text":"Typing here describes the bug, not a CAD dimension."})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
|
||||
assert!(crate::dim_view::editing() && h.widget("30 mm").is_some());
|
||||
assert!(h.app.issue_report.draft.as_ref().unwrap().body.contains("Typing here describes"));
|
||||
h.step(&json!({"widget":"Close draft"})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), Some(egui::Id::new("sc_dim_edit")));
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.undo.len(), before.1 + 1);
|
||||
assert_eq!(h.app.session.doc.param("d1").unwrap().expr, "30 mm");
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_inline_dimension_retains_expression_and_resumes_after_close() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":10,"id":"circle"})).unwrap();
|
||||
let out = h.app.run("sketch.dimension", json!({"entities":["circle"],"type":"radius","value":10})).unwrap();
|
||||
let param = out["param"].as_str().unwrap().to_owned();
|
||||
h.frames(3);
|
||||
crate::dim_view::edit_param(&h.app, ¶m);
|
||||
h.frames(2);
|
||||
h.step(&json!({"text":"12"})).unwrap();
|
||||
assert!(crate::dim_view::editing() && h.widget("12").is_some());
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
|
||||
help::run(&mut h.app, &h.ctx, "report");
|
||||
h.frames(3);
|
||||
let token = h.app.issue_report.pending.as_ref().unwrap().token;
|
||||
receive(&mut h.app, token, Arc::new(ColorImage::new([2, 2], vec![egui::Color32::GREEN; 4])));
|
||||
h.frames(3);
|
||||
h.ctx.memory_mut(|m| m.request_focus(egui::Id::new("issue_report_body")));
|
||||
h.step(&json!({"text":"report only"})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
|
||||
assert!(crate::dim_view::editing() && h.widget("12").is_some());
|
||||
h.step(&json!({"widget":"Close draft"})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
|
||||
assert!(crate::dim_view::editing() && h.widget("12").is_some());
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
assert!(!crate::dim_view::editing());
|
||||
assert_eq!(h.app.session.doc.param(¶m).unwrap().expr, "12");
|
||||
assert_eq!(h.app.session.undo.len(), before.1 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn issue_report_native_file_drop_cannot_import_behind_review() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.frames(3);
|
||||
let tabs_before = h.app.docs.count();
|
||||
assert!(!h.app.file_load.busy());
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision);
|
||||
help::run(&mut h.app, &h.ctx, "report");
|
||||
let path = h.dir.join("private-design.json");
|
||||
std::fs::write(&path, h.app.session.doc.to_json()).unwrap();
|
||||
#[derive(Debug)]
|
||||
struct File(std::path::PathBuf);
|
||||
impl egui::DroppedFile for File {
|
||||
fn path(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
fn bytes(&self) -> Result<Vec<u8>, String> {
|
||||
std::fs::read(&self.0).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
|
||||
focused: true,
|
||||
dropped_files: vec![Arc::new(File(path))],
|
||||
..Default::default()
|
||||
};
|
||||
let app = &mut h.app;
|
||||
let mut output = h.ctx.run_ui(raw, |ui| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
assert_eq!(before, (h.app.session.doc.to_json(), h.app.session.undo.len(), h.app.session.redo.len(), h.app.session.revision));
|
||||
assert!(h.app.issue_report.draft.is_some());
|
||||
assert!(!h.app.file_load.busy(), "native drop must not queue an asynchronous open behind the draft");
|
||||
assert_eq!(h.app.docs.count(), tabs_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_capture_accepts_only_its_matching_unchanged_frame() {
|
||||
let mut app = app();
|
||||
let ctx = egui::Context::default();
|
||||
begin(&mut app, &ctx, None, None);
|
||||
let token = app.issue_report.pending.as_ref().unwrap().token;
|
||||
let (reply, _receiver) = std::sync::mpsc::channel();
|
||||
app.pending_shots.push((token + 1, None, reply, crate::now_ms() + 8000.0));
|
||||
app.shot_token = token + 1;
|
||||
let image = Arc::new(ColorImage::new([4, 3], vec![egui::Color32::GREEN; 12]));
|
||||
receive(&mut app, token + 1, image.clone());
|
||||
assert!(pending(&app) && app.issue_report.draft.as_ref().unwrap().image.is_none());
|
||||
receive(&mut app, token, image.clone());
|
||||
assert!(!pending(&app));
|
||||
assert_eq!(app.issue_report.draft.as_ref().unwrap().image.as_ref().unwrap().size, [4, 3]);
|
||||
assert_eq!(app.pending_shots.len(), 1, "report cannot consume the control request");
|
||||
begin(&mut app, &ctx, None, None);
|
||||
let token = app.issue_report.pending.as_ref().unwrap().token;
|
||||
app.cam.distance *= 2.0;
|
||||
receive(&mut app, token, image);
|
||||
let draft = app.issue_report.draft.as_ref().unwrap();
|
||||
assert!(draft.image.is_none() && draft.note.contains("changed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_report_capture_rejects_changed_inputs_timeout_and_malformed_images() {
|
||||
let mut app = app();
|
||||
combine(&mut app);
|
||||
let ctx = egui::Context::default();
|
||||
let dialog = app.dialog.clone();
|
||||
begin(&mut app, &ctx, dialog.as_ref(), Some("bad Boolean"));
|
||||
app.dialog.as_mut().unwrap().extra.insert("direction".into(), json!("negative"));
|
||||
tick(&mut app, &ctx);
|
||||
assert!(!pending(&app));
|
||||
assert!(app.issue_report.draft.as_ref().unwrap().note.contains("changed"));
|
||||
let dialog = app.dialog.clone();
|
||||
begin(&mut app, &ctx, dialog.as_ref(), None);
|
||||
app.issue_report.pending.as_mut().unwrap().deadline = -1.0;
|
||||
tick(&mut app, &ctx);
|
||||
assert!(app.issue_report.draft.as_ref().unwrap().note.contains("No app frame"));
|
||||
let dialog = app.dialog.clone();
|
||||
begin(&mut app, &ctx, dialog.as_ref(), None);
|
||||
let token = app.issue_report.pending.as_ref().unwrap().token;
|
||||
receive(&mut app, token, Arc::new(ColorImage::new([0, 0], vec![])));
|
||||
assert!(app.issue_report.draft.as_ref().unwrap().image.is_none());
|
||||
assert!(app.issue_report.draft.as_ref().unwrap().note.contains("dimensions"));
|
||||
}
|
||||
}
|
||||
+82
-13
@@ -25,6 +25,8 @@ pub mod dialogs_plastic;
|
||||
pub mod dialogs_sheet;
|
||||
pub mod dialogs_surface;
|
||||
pub mod dim_view;
|
||||
#[cfg(test)]
|
||||
mod dimension_create_ui_tests;
|
||||
pub mod documents;
|
||||
pub mod drag_snap;
|
||||
pub mod fonts;
|
||||
@@ -38,6 +40,7 @@ pub mod i18n;
|
||||
mod i18n_ui_tests;
|
||||
pub mod icons;
|
||||
pub mod inference;
|
||||
pub mod issue_report;
|
||||
pub mod keymap;
|
||||
#[cfg(test)]
|
||||
mod menu_tests;
|
||||
@@ -98,6 +101,7 @@ pub mod toolbar;
|
||||
pub mod toolbar_custom;
|
||||
pub mod tools;
|
||||
pub mod viewport;
|
||||
mod viewport_layout;
|
||||
pub mod workspace;
|
||||
|
||||
use std::sync::mpsc::Receiver;
|
||||
@@ -209,6 +213,8 @@ pub struct SolveApp {
|
||||
pub keymap: keymap::Keymap,
|
||||
/// The Help menu and the About window.
|
||||
pub help: help::HelpState,
|
||||
/// An editable local issue draft and optional captured app frame.
|
||||
pub issue_report: issue_report::ReportState,
|
||||
/// Toolbar buttons the user pinned, removed or reordered.
|
||||
pub toolbar_custom: toolbar_custom::ToolbarCustom,
|
||||
/// Preferences kept between runs, and their window.
|
||||
@@ -247,6 +253,7 @@ pub struct SolveApp {
|
||||
shot_token: u64,
|
||||
styled: bool,
|
||||
fitted: bool,
|
||||
pending_fit: Option<(Camera, solvecraft_engine::geom::Aabb3, bool)>,
|
||||
/// The start page.
|
||||
pub home: home::HomeState,
|
||||
pub file_load: file_load::FileLoad,
|
||||
@@ -275,6 +282,7 @@ impl SolveApp {
|
||||
sbox: shortcut_box::ShortcutBox::default(),
|
||||
keymap: keymap::Keymap::default(),
|
||||
help: help::HelpState::default(),
|
||||
issue_report: issue_report::ReportState::default(),
|
||||
toolbar_custom: toolbar_custom::ToolbarCustom::default(),
|
||||
preferences: prefs::Prefs::default(),
|
||||
prefs_window: prefs::PrefsWindow::default(),
|
||||
@@ -300,6 +308,7 @@ impl SolveApp {
|
||||
shot_token: 0,
|
||||
styled: false,
|
||||
fitted: false,
|
||||
pending_fit: None,
|
||||
home: home::HomeState::default(),
|
||||
file_load: file_load::FileLoad::default(),
|
||||
docs: documents::Documents::default(),
|
||||
@@ -433,6 +442,7 @@ impl SolveApp {
|
||||
Ok(_) => {
|
||||
self.session.echo(format!("{} done", command_label(id)));
|
||||
if matches!(id, "file.new" | "doc.open" | "doc.recover") {
|
||||
dim_view::cancel();
|
||||
self.menu.props = None;
|
||||
sketch_attachment_ui::clear(self);
|
||||
// The old document is already replaced: discard its editor without
|
||||
@@ -471,10 +481,11 @@ impl SolveApp {
|
||||
/// that have them, otherwise run it with defaults.
|
||||
pub fn start(&mut self, id: &str) {
|
||||
// A component moved without Capture Position: ask first (Capture, Revert or Cancel).
|
||||
if browser::needs_capture(self, id) {
|
||||
if browser::needs_capture(self, id) && !(id == "sketch.dimension" && self.session.active_sketch.is_some()) {
|
||||
self.tree.capture_prompt = Some(id.to_string());
|
||||
return;
|
||||
}
|
||||
dim_view::cancel();
|
||||
self.tool = None;
|
||||
self.home.open = false;
|
||||
if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") {
|
||||
@@ -649,6 +660,7 @@ impl SolveApp {
|
||||
/// Finish the active sketch: stop editing, restore the history marker Edit Sketch moved and
|
||||
/// return to the view from before the sketch.
|
||||
pub fn finish_sketch(&mut self) {
|
||||
dim_view::cancel();
|
||||
self.tool = None;
|
||||
let _ = self.run("sketch.finish", json!({}));
|
||||
if let Some(marker) = self.pre_sketch_marker.take() {
|
||||
@@ -741,6 +753,8 @@ impl SolveApp {
|
||||
self.cam_anim = None;
|
||||
self.viewport.orbit_snap = None;
|
||||
self.viewport.orbit_free = None;
|
||||
let bounds = solvecraft_engine::view::bounds(&self.session);
|
||||
self.pending_fit = Some((self.cam, bounds, false));
|
||||
self.cam = self.fitted(self.cam);
|
||||
}
|
||||
|
||||
@@ -751,13 +765,39 @@ impl SolveApp {
|
||||
cam.target = solvecraft_engine::geom::Vec3::ZERO;
|
||||
cam.distance = 150.0;
|
||||
} else {
|
||||
cam.fit(&b);
|
||||
cam = self.fitted_bounds(cam, &b);
|
||||
}
|
||||
cam
|
||||
}
|
||||
|
||||
fn fitted_bounds(&self, mut cam: Camera, bounds: &solvecraft_engine::geom::Aabb3) -> Camera {
|
||||
if let Some(layout) = &self.viewport.layout {
|
||||
return viewport_layout::fit_bounds(cam, bounds, layout, self.ui.perspective).unwrap_or(cam);
|
||||
}
|
||||
let previous = cam;
|
||||
cam.fit(bounds);
|
||||
if cam.is_valid() { cam } else { previous }
|
||||
}
|
||||
|
||||
fn resolve_fit(&mut self, ctx: &egui::Context) {
|
||||
viewport::refresh_layout(self, ctx.pixels_per_point());
|
||||
if self.viewport.layout.is_some()
|
||||
&& let Some((cam, bounds, animate)) = self.pending_fit.take()
|
||||
{
|
||||
let to = if bounds.is_empty() { self.fitted(cam) } else { self.fitted_bounds(cam, &bounds) };
|
||||
if animate {
|
||||
self.animate_to(to);
|
||||
} else {
|
||||
self.cam = to;
|
||||
self.cam_anim = None;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the camera to `to` over half a second (ease in-out, orientation slerped).
|
||||
pub fn animate_to(&mut self, to: Camera) {
|
||||
self.pending_fit = None;
|
||||
self.viewport.orbit_snap = None;
|
||||
self.viewport.orbit_free = None;
|
||||
if !to.is_valid() {
|
||||
@@ -779,15 +819,23 @@ impl SolveApp {
|
||||
|
||||
/// Animated view changes for the axis view and navigation bar.
|
||||
pub fn animate_view(&mut self, v: &str) {
|
||||
self.pending_fit = None;
|
||||
let mut to = self.cam;
|
||||
match v {
|
||||
// With something selected, Fit frames the selection.
|
||||
"fit" => match viewport::selection_bounds(self) {
|
||||
Some(b) => to.fit(&b),
|
||||
None => to = self.fitted(to),
|
||||
Some(b) => {
|
||||
self.pending_fit = Some((to, b, true));
|
||||
to = self.fitted_bounds(to, &b);
|
||||
}
|
||||
None => {
|
||||
self.pending_fit = Some((to, solvecraft_engine::view::bounds(&self.session), true));
|
||||
to = self.fitted(to);
|
||||
}
|
||||
},
|
||||
"home" => {
|
||||
to.set_view(StandardView::Iso);
|
||||
self.pending_fit = Some((to, solvecraft_engine::view::bounds(&self.session), true));
|
||||
to = self.fitted(to);
|
||||
}
|
||||
v => match StandardView::parse(v) {
|
||||
@@ -795,11 +843,14 @@ impl SolveApp {
|
||||
None => return,
|
||||
},
|
||||
}
|
||||
let request = self.pending_fit.take();
|
||||
self.animate_to(to);
|
||||
self.pending_fit = request;
|
||||
}
|
||||
|
||||
/// Stop any view animation where it is (the user took over the camera).
|
||||
pub fn cancel_view_animation(&mut self) {
|
||||
self.pending_fit = None;
|
||||
self.cam_anim = None;
|
||||
}
|
||||
|
||||
@@ -838,14 +889,19 @@ impl SolveApp {
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
issue_report::tick(self, ctx);
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
toolbar::shortcuts(self, ctx);
|
||||
if self.issue_report.draft.is_none() {
|
||||
toolbar::shortcuts(self, ctx);
|
||||
}
|
||||
recovery_ui::tick(self);
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
let p = f.path().to_string_lossy().to_string();
|
||||
if !p.is_empty() {
|
||||
self.drop_path(&p);
|
||||
if self.issue_report.draft.is_none() {
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
let p = f.path().to_string_lossy().to_string();
|
||||
if !p.is_empty() {
|
||||
self.drop_path(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -881,14 +937,26 @@ impl SolveApp {
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let t0 = now_ms();
|
||||
let _language = crate::i18n::scope(&self.preferences.language);
|
||||
let mut report_input = None;
|
||||
issue_report::CadInput::block(self, ui, &mut report_input);
|
||||
self.cad_ui(ui, &mut report_input);
|
||||
drop(report_input);
|
||||
issue_report::show(self, ui.ctx());
|
||||
if !self.home.open {
|
||||
agent_cursor::paint(self, ui.ctx());
|
||||
}
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
fn cad_ui(&mut self, ui: &mut egui::Ui, report_input: &mut Option<issue_report::CadInput>) {
|
||||
scenario::clear_handles();
|
||||
toolbar::app_bar(self, ui);
|
||||
issue_report::CadInput::block(self, ui, report_input);
|
||||
file_load::show(self, ui.ctx());
|
||||
documents::prompt(self, ui.ctx());
|
||||
if self.home.open {
|
||||
home::show(self, ui);
|
||||
if self.home.open {
|
||||
self.frame_ms = now_ms() - t0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -905,7 +973,9 @@ impl SolveApp {
|
||||
viewport::show(self, ui);
|
||||
});
|
||||
dialogs::show(self, ui.ctx());
|
||||
issue_report::CadInput::block(self, ui, report_input);
|
||||
sketch_palette::show(self, ui.ctx());
|
||||
self.resolve_fit(ui.ctx());
|
||||
params_dialog::show(self, ui.ctx());
|
||||
recovery_ui::show(self, ui.ctx());
|
||||
stand_up::show(self, ui.ctx());
|
||||
@@ -916,8 +986,6 @@ impl SolveApp {
|
||||
}
|
||||
shortcut_box::show(self, ui.ctx());
|
||||
prefs::show(self, ui.ctx());
|
||||
agent_cursor::paint(self, ui.ctx());
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
@@ -963,7 +1031,7 @@ impl SolveApp {
|
||||
}
|
||||
|
||||
fn collect_screenshots(&mut self, ctx: &egui::Context) {
|
||||
if self.pending_shots.is_empty() {
|
||||
if self.pending_shots.is_empty() && !issue_report::pending(self) {
|
||||
return;
|
||||
}
|
||||
let events: Vec<_> = ctx.input(|i| {
|
||||
@@ -980,6 +1048,7 @@ impl SolveApp {
|
||||
.collect()
|
||||
});
|
||||
for (token, image) in events {
|
||||
issue_report::receive(self, token, image.clone());
|
||||
if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) {
|
||||
let (_, path, reply, _) = self.pending_shots.remove(i);
|
||||
let _ = reply.send(control::save_screenshot(&image, path.as_deref()));
|
||||
|
||||
@@ -463,7 +463,7 @@ pub fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) ->
|
||||
}
|
||||
let mut pick: Option<String> = if tab { list.first().map(|s| s.text.clone()) } else { None };
|
||||
let t = Tokens::get();
|
||||
egui::Area::new(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show(
|
||||
crate::frame::area(resp.id.with("completions")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom() + egui::vec2(0.0, 2.0)).show(
|
||||
&ctx,
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
|
||||
@@ -245,7 +245,7 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B
|
||||
}
|
||||
let edge = if op == "cut" { [c.removed[0], c.removed[1], c.removed[2], 255] } else { c.edge };
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
if !m.seams.get(ei).copied().unwrap_or(false) {
|
||||
if m.shaded_edge_visible(ei) {
|
||||
for w in e.windows(2) {
|
||||
sc.line(w[0].to_f32(), w[1].to_f32(), edge, 1.3, op != "join");
|
||||
}
|
||||
@@ -306,7 +306,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
|
||||
}
|
||||
}
|
||||
for (ei, e) in am.edges.iter().enumerate() {
|
||||
if am.seams.get(ei).copied().unwrap_or(false) {
|
||||
if !am.shaded_edge_visible(ei) {
|
||||
continue;
|
||||
}
|
||||
for w in e.windows(2) {
|
||||
|
||||
@@ -158,7 +158,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
at.x.clamp(screen.left() + 4.0, (screen.right() - W - 4.0).max(screen.left())),
|
||||
at.y.clamp(screen.top() + 4.0, (screen.bottom() - h).max(screen.top())),
|
||||
);
|
||||
let resp = egui::Area::new(egui::Id::new("sc_sbox")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
let resp = crate::frame::area(egui::Id::new("sc_sbox")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
crate::context_menu::menu_frame(ui, |ui| {
|
||||
ui.set_width(W - 10.0);
|
||||
// Pinned commands: a mini toolbar.
|
||||
|
||||
@@ -323,7 +323,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
|
||||
s.store(ui.ctx(), id);
|
||||
}
|
||||
let pos: Pos2 = sp + vec2(-34.0, -11.0);
|
||||
egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
crate::frame::area(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let fill = if b.locked { tk.accent_soft } else { tk.panel };
|
||||
egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
|
||||
let r = ui
|
||||
|
||||
@@ -90,6 +90,11 @@ fn pixel_cache_full_view_resize_and_hidpi_invalidate_within_same_grid_bucket() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"corner":[-0.1,-0.1,-0.2],"length":0.2,"width":0.2,"height":0.2,"body_name":"CachedStock"})).unwrap();
|
||||
let sid = circle(&mut h);
|
||||
// This cache test needs a controlled projected radius, independent of floating controls
|
||||
// used by Fit. At this radius, doubling physical scale crosses a subdivision threshold.
|
||||
h.app.cancel_view_animation();
|
||||
h.app.cam.distance = 5.0;
|
||||
h.app.cam.target = Vec3::ZERO;
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let history = (h.app.session.undo.len(), h.app.session.redo.len());
|
||||
let state = h.app.session.world_state();
|
||||
|
||||
@@ -167,6 +167,7 @@ pub fn set_linetype(app: &mut SolveApp, kind: u8) {
|
||||
|
||||
/// Draw the palette (while sketching and no command dialog is open).
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
app.viewport.palette_rect = None;
|
||||
let _language = crate::i18n::scope(&app.preferences.language);
|
||||
if app.dialog.is_some() {
|
||||
return;
|
||||
@@ -204,7 +205,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let mut look = false;
|
||||
let mut finish = false;
|
||||
let has_curves = !selected_curves(app).is_empty();
|
||||
crate::frame::docked(
|
||||
let response = crate::frame::docked(
|
||||
app.tr("ui.heading_sketch_palette"),
|
||||
egui::Id::new("sc_sketch_palette"),
|
||||
anchor,
|
||||
@@ -213,8 +214,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
)
|
||||
.frame(frame)
|
||||
.show(ctx, |ui| {
|
||||
let width = WIDTH.min((vp.width() - 24.0).max(120.0));
|
||||
let width = WIDTH.min((vp.width() * 0.46 - 16.0).max(120.0));
|
||||
ui.set_width(width);
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
ui.horizontal(|ui| {
|
||||
let folded = FOLDED.with(Cell::get);
|
||||
if ui.add(egui::Button::new(RichText::new(if folded { "+" } else { "−" }).size(14.0)).frame(false)).clicked() {
|
||||
@@ -236,7 +238,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let options_height = (bottom - ui.cursor().top() - 68.0).max(24.0);
|
||||
let options =
|
||||
egui::ScrollArea::vertical().id_salt("sc_palette_options").max_height(options_height).auto_shrink([false, true]).show(ui, |ui| {
|
||||
egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(app.tr("ui.linetype"));
|
||||
ui.horizontal(|ui| {
|
||||
// With a selection: convert it; without: the mode new curves are drawn in.
|
||||
@@ -254,21 +256,18 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(app.tr("ui.look_at"));
|
||||
if ui.button(app.tr("ui.look_at")).clicked() {
|
||||
look = true;
|
||||
}
|
||||
ui.end_row();
|
||||
for (k, label) in TOGGLES {
|
||||
// The label is part of the checkbox: clicking it toggles too.
|
||||
let mut on = shown(k);
|
||||
if ui.checkbox(&mut on, app.tr_label(label)).changed() {
|
||||
set = Some((k.to_string(), on));
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
for (k, label) in TOGGLES {
|
||||
// The label is part of the checkbox: clicking it toggles too.
|
||||
let mut on = shown(k);
|
||||
if ui.checkbox(&mut on, app.tr_label(label)).changed() {
|
||||
set = Some((k.to_string(), on));
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::scenario::publish_rect("sketch_palette:options", options.inner_rect);
|
||||
}
|
||||
@@ -284,6 +283,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
app.viewport.palette_rect = response.map(|r| r.response.rect);
|
||||
if let Some(r) = app.viewport.palette_rect {
|
||||
crate::scenario::publish_rect("sketch_palette:outer", r);
|
||||
}
|
||||
if let Some((k, on)) = set {
|
||||
let mut p = serde_json::Map::new();
|
||||
p.insert(k, Value::Bool(on));
|
||||
|
||||
@@ -148,7 +148,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
let mut close = false;
|
||||
let resp = egui::Area::new(egui::Id::new("sc_file_area"))
|
||||
let resp = crate::frame::area(egui::Id::new("sc_file_area"))
|
||||
.fixed_pos(pos2(100.0 + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 }, 34.0))
|
||||
.order(egui::Order::Foreground)
|
||||
.show(ctx, |ui| {
|
||||
@@ -432,7 +432,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let open = ui.ctx().data(|d| d.get_temp::<egui::Id>(egui::Id::new("sc_panel_open"))) == Some(popup_id);
|
||||
if open {
|
||||
let area = egui::Area::new(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let area = crate::frame::area(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
if *panel == "SELECT" {
|
||||
|
||||
@@ -132,6 +132,11 @@ pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
}
|
||||
|
||||
pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
// A click away dismisses the inline editor; it must not also pick the first entity of
|
||||
// the next dimension before that cancellation has been processed.
|
||||
if crate::dim_view::editing() {
|
||||
return;
|
||||
}
|
||||
let Some(mut tool) = app.tool.take() else { return };
|
||||
// In a 3D sketch the drawing tools take points off the plane.
|
||||
if crate::sketch3d::on_click(app, &mut tool, proj, pos) {
|
||||
@@ -199,12 +204,11 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
}
|
||||
} else if !tool.picks.is_empty()
|
||||
&& let Some((at, _)) = sketch_point_at(app, proj, pos)
|
||||
&& let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks, "text_at": [at.x, at.y]}))
|
||||
{
|
||||
if let Some(p) = v["param"].as_str() {
|
||||
crate::dim_view::edit_param(app, p);
|
||||
match crate::dim_view::begin_dimension(app, tool.picks.clone(), at) {
|
||||
Ok(()) => tool.picks.clear(),
|
||||
Err(error) => app.set_status(error, true),
|
||||
}
|
||||
tool.picks.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,6 +337,7 @@ fn run_pick(app: &mut SolveApp, t: &Tool) {
|
||||
|
||||
/// Right click / Esc: end the current chain (line) or cancel the tool.
|
||||
pub fn finish(app: &mut SolveApp) {
|
||||
crate::dim_view::cancel();
|
||||
if crate::sketch3d::finish(app) {
|
||||
return;
|
||||
}
|
||||
|
||||
+196
-28
@@ -33,6 +33,9 @@ pub struct ViewportState {
|
||||
/// The last pick (its key and hits): reused while nothing it depends on changed.
|
||||
last_pick: Option<(u64, Vec<Hit>)>,
|
||||
pub rect: Option<Rect>,
|
||||
pub(crate) layout: Option<crate::viewport_layout::Layout>,
|
||||
pub(crate) palette_rect: Option<Rect>,
|
||||
pub(crate) dialog_rect: Option<Rect>,
|
||||
pub gpu: Option<GpuTarget>,
|
||||
slot: SceneSlot,
|
||||
key: u64,
|
||||
@@ -339,7 +342,7 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
}
|
||||
}
|
||||
for (ei, e) in m.edges.iter().enumerate().filter(|_| edges) {
|
||||
if m.seams.get(ei).copied().unwrap_or(false) {
|
||||
if !m.edge_pickable(ei) || (shaded && !m.shaded_edge_visible(ei)) {
|
||||
continue;
|
||||
}
|
||||
for w in e.windows(2) {
|
||||
@@ -790,7 +793,7 @@ pub fn pick(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec<Hit> {
|
||||
}
|
||||
let m = b.mesh();
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
if m.seams.get(ei).copied().unwrap_or(false) {
|
||||
if !m.edge_pickable(ei) {
|
||||
continue;
|
||||
}
|
||||
for v in [e.first(), e.last()].into_iter().flatten() {
|
||||
@@ -1116,7 +1119,7 @@ fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f3
|
||||
fn hidden_face_outline(sc: &mut GpuScene, m: &solvecraft_engine::geom::Mesh, face: usize, col: Color32) {
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == face));
|
||||
if on && !m.seams.get(ei).copied().unwrap_or(false) {
|
||||
if on && m.edge_pickable(ei) {
|
||||
for p in e.windows(2) {
|
||||
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(col.gamma_multiply(0.45)), 1.4);
|
||||
}
|
||||
@@ -1341,9 +1344,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
}
|
||||
}
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
if !m.seams.get(ei).copied().unwrap_or(false)
|
||||
&& (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize)))))
|
||||
{
|
||||
if m.edge_pickable(ei) && (whole || m.edge_faces.get(ei).is_some_and(|fs| fs.iter().any(|f| faces.contains(&(*f as usize))))) {
|
||||
edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5);
|
||||
}
|
||||
}
|
||||
@@ -1375,7 +1376,7 @@ fn build_highlight(app: &SolveApp) -> GpuScene {
|
||||
}
|
||||
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
|
||||
if let Some(p) = m.edges.get(e)
|
||||
&& !m.seams.get(e).copied().unwrap_or(false)
|
||||
&& m.edge_pickable(e)
|
||||
{
|
||||
edge_lines(&mut sc, p, t.hover_edge, t.hover_edge_halo, 1.5);
|
||||
}
|
||||
@@ -1474,6 +1475,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get();
|
||||
let rect = ui.max_rect();
|
||||
app.viewport.rect = Some(rect);
|
||||
refresh_layout(app, ui.ctx().pixels_per_point());
|
||||
let resp = ui.interact(rect, ui.id().with("viewport"), Sense::click_and_drag());
|
||||
let (hover, scroll, mods, middle, secondary_down, primary_down, delta) = ui.input(|i| {
|
||||
(
|
||||
@@ -1489,7 +1491,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
// The right button belongs to the marking menu while it is open.
|
||||
let secondary_down = secondary_down && app.viewport.context_menu.is_none();
|
||||
// The axis view handles its own clicks; the model underneath must not see them.
|
||||
let axes = axis_rect(rect);
|
||||
let axes = axis_rect(app, rect);
|
||||
// Floating windows and menus own input over the model, including wheel navigation.
|
||||
let inside = resp.contains_pointer() && hover.is_some_and(|p| !axes.contains(p));
|
||||
app.viewport.mouse = hover.filter(|_| inside);
|
||||
@@ -1556,7 +1558,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
if app.viewport.spin {
|
||||
// A click on the navigation bar (its own button) is not a stop.
|
||||
let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0));
|
||||
let bar = app
|
||||
.viewport
|
||||
.layout
|
||||
.as_ref()
|
||||
.map(|l| l.navigation)
|
||||
.unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(320.0, 30.0)));
|
||||
let (pressed, key, dt) = ui.input(|i| {
|
||||
(
|
||||
i.pointer.any_pressed() && inside && !i.pointer.press_origin().is_some_and(|p| bar.contains(p)),
|
||||
@@ -2057,7 +2064,7 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
|
||||
for (ei, e) in m.edges.iter().enumerate() {
|
||||
let rim = m.circular_rim(ei);
|
||||
let takes = if bx.crossing { bx.takes(&to2(e)) } else { rim.iter().all(|&i| m.edges.get(i).is_some_and(|p| bx.takes(&to2(p)))) };
|
||||
if !m.seams.get(ei).copied().unwrap_or(false) && takes {
|
||||
if m.edge_pickable(ei) && takes {
|
||||
out.push(Sel::Edge { body: b.name.clone(), index: ei, point: polyline_mid(e) });
|
||||
}
|
||||
}
|
||||
@@ -2257,22 +2264,39 @@ fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_layout(app: &mut SolveApp, scale: f32) {
|
||||
if let Some(rect) = app.viewport.rect {
|
||||
let docks: Vec<_> = [
|
||||
app.viewport.palette_rect.filter(|_| app.session.active_sketch.is_some() && app.dialog.is_none()),
|
||||
app.viewport.dialog_rect.filter(|_| app.dialog.is_some()),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
app.viewport.layout = Some(crate::viewport_layout::Layout::new(rect, &docks, scale));
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact orientation gizmo, drawn and hit-tested in world coordinates.
|
||||
const AXIS_LENGTH: f64 = 38.0;
|
||||
const AXIS_RADIUS: f32 = 11.0;
|
||||
|
||||
fn axis_center(rect: Rect) -> Pos2 {
|
||||
pos2(rect.right() - 68.0, rect.top() + 68.0)
|
||||
fn axis_center(app: &SolveApp, rect: Rect) -> Pos2 {
|
||||
axis_rect(app, rect).center()
|
||||
}
|
||||
|
||||
fn axis_rect(rect: Rect) -> Rect {
|
||||
Rect::from_center_size(axis_center(rect), vec2(116.0, 116.0))
|
||||
fn axis_rect(app: &SolveApp, rect: Rect) -> Rect {
|
||||
app.viewport
|
||||
.layout
|
||||
.as_ref()
|
||||
.filter(|l| l.viewport == rect)
|
||||
.map(|l| l.axis_view)
|
||||
.unwrap_or_else(|| Rect::from_center_size(pos2(rect.right() - 68.0, rect.top() + 68.0), vec2(116.0, 116.0)))
|
||||
}
|
||||
|
||||
/// Six signed world axes, sorted back to front so overlapping tips have one owner.
|
||||
fn axis_tips(cam: &Camera, rect: Rect) -> Vec<(&'static str, Vec3, Pos2, f64)> {
|
||||
fn axis_tips(cam: &Camera, c: Pos2) -> Vec<(&'static str, Vec3, Pos2, f64)> {
|
||||
let (r, u, b) = cam.basis();
|
||||
let c = axis_center(rect);
|
||||
let mut tips: Vec<_> = [("x", Vec3::X), ("-x", -Vec3::X), ("y", Vec3::Y), ("-y", -Vec3::Y), ("z", Vec3::Z), ("-z", -Vec3::Z)]
|
||||
.into_iter()
|
||||
.map(|(name, d)| (name, d, c + vec2((d.dot(r) * AXIS_LENGTH) as f32, -(d.dot(u) * AXIS_LENGTH) as f32), d.dot(b)))
|
||||
@@ -2386,7 +2410,7 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
|
||||
(p.primary_pressed(), p.primary_down(), p.is_decidedly_dragging(), p.press_origin(), p.interact_pos(), i.modifiers.shift)
|
||||
});
|
||||
if pressed && ui.rect_contains_pointer(rect) {
|
||||
app.viewport.axis_drag = origin.filter(|p| axis_rect(rect).contains(*p));
|
||||
app.viewport.axis_drag = origin.filter(|p| axis_rect(app, rect).contains(*p));
|
||||
if app.viewport.axis_drag.is_some() {
|
||||
app.viewport.orbit_free = None;
|
||||
}
|
||||
@@ -2406,14 +2430,20 @@ fn axis_view_orbit(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) -> bool {
|
||||
}
|
||||
|
||||
fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if !axis_rect(app, rect).is_positive() {
|
||||
app.viewport.axis_drag = None;
|
||||
crate::scenario::publish_count("axis_view_highlights", 0.0);
|
||||
return;
|
||||
}
|
||||
crate::scenario::publish_rect("axis_view:outer", axis_rect(app, rect));
|
||||
let t = Tokens::get();
|
||||
let c = axis_center(rect);
|
||||
let c = axis_center(app, rect);
|
||||
let orbiting = axis_view_orbit(app, ui, rect);
|
||||
let tips = axis_tips(&app.cam, rect);
|
||||
let tips = axis_tips(&app.cam, c);
|
||||
let hover = ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rect));
|
||||
let hovered = if orbiting { None } else { hover.and_then(|p| axis_hit(&tips, p)) };
|
||||
let painter = ui.painter_at(rect);
|
||||
if orbiting || hover.is_some_and(|p| axis_rect(rect).contains(p)) {
|
||||
if orbiting || hover.is_some_and(|p| axis_rect(app, rect).contains(p)) {
|
||||
painter.circle_filled(c, 55.0, t.overlay);
|
||||
}
|
||||
for (i, (name, d, at, depth)) in tips.iter().enumerate() {
|
||||
@@ -2462,7 +2492,9 @@ fn axis_view(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
}
|
||||
let home = Rect::from_center_size(pos2(c.x - 46.0, c.y - 54.0), vec2(18.0, 18.0));
|
||||
let home = Rect::from_center_size(pos2(c.x - 44.0, c.y - 44.0), vec2(18.0, 18.0));
|
||||
crate::scenario::publish_rect("axis_view:home", home);
|
||||
crate::scenario::publish_handle("axis_view:home", home.center());
|
||||
let hr = ui.interact(home, ui.id().with("axis_view_home"), Sense::click());
|
||||
icons::paint(&painter, home, "home", t.icon, if hr.hovered() { t.accent_soft } else { t.overlay }, t.accent);
|
||||
if hr.on_hover_text("Home view").clicked() {
|
||||
@@ -2508,6 +2540,9 @@ pub fn look_at_selection(app: &mut SolveApp) {
|
||||
}
|
||||
|
||||
fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if app.viewport.layout.as_ref().is_some_and(|l| !l.navigation.is_positive()) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get();
|
||||
let items: [(&str, &str); 10] = [
|
||||
("orbit", "Orbit (right drag; hold Shift to snap)"),
|
||||
@@ -2524,11 +2559,21 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let menu_id = ui.id().with("nav_view_menu");
|
||||
let mut menu_open = ui.data(|d| d.get_temp::<bool>(menu_id)).unwrap_or(false);
|
||||
let w = items.len() as f32 * 30.0 + 10.0;
|
||||
let bar = Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0));
|
||||
let bar = app
|
||||
.viewport
|
||||
.layout
|
||||
.as_ref()
|
||||
.map(|l| l.navigation)
|
||||
.unwrap_or_else(|| Rect::from_center_size(pos2(rect.center().x, rect.bottom() - 22.0), vec2(w, 30.0)));
|
||||
crate::scenario::publish_rect("nav:outer", bar);
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect(bar, 6.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let columns = ((bar.width() - 10.0) / 30.0).round().clamp(1.0, 10.0) as usize;
|
||||
for (i, (icon, tip)) in items.iter().enumerate() {
|
||||
let br = Rect::from_min_size(pos2(bar.left() + 5.0 + i as f32 * 30.0, bar.top() + 2.0), vec2(26.0, 26.0));
|
||||
let br = Rect::from_min_size(
|
||||
pos2(bar.left() + 5.0 + (i % columns) as f32 * 30.0, bar.top() + 2.0 + (i / columns) as f32 * 30.0),
|
||||
vec2(26.0, 26.0),
|
||||
);
|
||||
let resp = ui.interact(br, ui.id().with(("nav", *icon)), Sense::click());
|
||||
let active = match *icon {
|
||||
"orbit" => app.viewport.nav == Some(NavMode::Orbit),
|
||||
@@ -2574,7 +2619,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
/// The View settings above the navigation bar's last button: grid, ground shadow and the agent
|
||||
/// cursor (#35). False once it should close (a click outside it).
|
||||
fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool {
|
||||
let area = egui::Area::new(egui::Id::new("sc_view_menu"))
|
||||
let area = crate::frame::area(egui::Id::new("sc_view_menu"))
|
||||
.order(egui::Order::Foreground)
|
||||
.pivot(Align2::RIGHT_BOTTOM)
|
||||
.fixed_pos(button.right_top() - vec2(0.0, 6.0));
|
||||
@@ -2731,12 +2776,14 @@ mod tests {
|
||||
|
||||
fn hover_frame(app: &mut SolveApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||||
let selected = app.highlighted();
|
||||
let _ = ctx.run_ui(
|
||||
let mut output = ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() },
|
||||
|ui| {
|
||||
show(app, ui);
|
||||
},
|
||||
);
|
||||
// This headless frame checks interactions without applying textures to a renderer.
|
||||
output.textures_delta.clear();
|
||||
// Click selection is applied after rendering; hover itself must already be current.
|
||||
if app.viewport.gpu.is_some() && selected == app.highlighted() {
|
||||
assert_eq!(app.viewport.hl_key, highlight_key(app), "GPU highlight must use this frame's hover");
|
||||
@@ -2819,6 +2866,126 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_wall_display_preserves_wireframe_and_edge_selection() {
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
let sketch = s.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap();
|
||||
s.execute("sketch.ellipse", &json!({"center":[0,0],"major":[25,0],"minor_radius":5})).unwrap();
|
||||
let inner =
|
||||
s.execute("sketch.offset", &json!({"curves":["e1"],"distance":-1,"chain":false})).unwrap()["curves"][0].as_str().unwrap().to_string();
|
||||
let outer =
|
||||
s.execute("sketch.offset", &json!({"curves":[inner],"distance":-0.5,"chain":false})).unwrap()["curves"][0].as_str().unwrap().to_string();
|
||||
s.execute(
|
||||
"solid.extrude",
|
||||
&json!({"sketch":sketch,"distance":3,"profiles":{"type":"whole_boundaries","profiles":[{"outer":[outer],"holes":[[inner]]}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
app.ui.ground_shadow = false;
|
||||
let st = app.session.world_state();
|
||||
let b = st.bodies.first().unwrap();
|
||||
let mesh = b.mesh();
|
||||
assert_eq!(mesh.edges.len(), 1536);
|
||||
assert_eq!(mesh.smooth_edges.iter().filter(|f| **f).count(), 512);
|
||||
let smooth = mesh.smooth_edges.iter().position(|f| *f).unwrap();
|
||||
let expected = mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).map(|(_, e)| e.len() - 1).sum::<usize>();
|
||||
let shaded = build_scene(&app);
|
||||
assert_eq!(shaded.lines.len() / crate::gpu::LINE_SIZE, expected);
|
||||
assert!(!shaded.tris.is_empty());
|
||||
app.ui.visual_style = 2;
|
||||
let wire = build_scene(&app);
|
||||
let expected_wire =
|
||||
mesh.edges.iter().enumerate().filter(|(i, _)| !mesh.seams.get(*i).copied().unwrap_or(false)).map(|(_, e)| e.len() - 1).sum::<usize>();
|
||||
assert_eq!(wire.lines.len() / crate::gpu::LINE_SIZE, expected_wire);
|
||||
assert!(expected_wire > expected);
|
||||
assert!(wire.tris.is_empty());
|
||||
app.ui.visual_style = 0;
|
||||
app.session.selection = vec![Sel::Edge { body: b.name.clone(), index: smooth, point: polyline_mid(&mesh.edges[smooth]) }];
|
||||
assert!(!build_highlight(&app).lines.is_empty());
|
||||
let mut without_display_mask = (*mesh).clone();
|
||||
without_display_mask.smooth_edges.clear();
|
||||
assert_eq!(mesh.surface_patch(mesh.edge_faces[smooth][0] as usize), without_display_mask.surface_patch(mesh.edge_faces[smooth][0] as usize));
|
||||
let cam = solvecraft_engine::view::home_camera(&app.session);
|
||||
let cpu = solvecraft_engine::view::scene(&app.session, &cam);
|
||||
let actual_edges = cpu.lines.iter().filter(|line| line.color == solvecraft_engine::view::colors::EDGE).count();
|
||||
assert_eq!(actual_edges, mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).count());
|
||||
if let Ok(directory) = std::env::var("SOLVECRAFT_SMOOTH_SCREENSHOTS") {
|
||||
std::fs::create_dir_all(&directory).unwrap();
|
||||
for (name, old_edges, wireframe) in [("after", false, false), ("before", true, false), ("wireframe", true, true)] {
|
||||
let mut scene = solvecraft_engine::view::scene(&app.session, &cam);
|
||||
scene.lines.retain(|line| line.color == solvecraft_engine::view::colors::EDGE);
|
||||
if old_edges {
|
||||
scene.lines.clear();
|
||||
for (i, edge) in mesh.edges.iter().enumerate() {
|
||||
if mesh.edge_pickable(i) {
|
||||
scene.lines.push(solvecraft_engine::render::SceneLine {
|
||||
points: edge.clone(),
|
||||
color: solvecraft_engine::view::colors::EDGE,
|
||||
width: 1.2,
|
||||
on_top: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if wireframe {
|
||||
scene.meshes.clear();
|
||||
}
|
||||
let png = solvecraft_engine::render::render_png(&scene, &cam, 1200, 800).unwrap();
|
||||
std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn certified_small_crease_overrides_old_seam_fit_for_picking_and_patches() {
|
||||
let mut s = solvecraft_engine::Session::default();
|
||||
let sketch = s.execute("sketch.create", &json!({"plane":"XY"})).unwrap()["sketch"].as_u64().unwrap();
|
||||
let step = std::f64::consts::TAU / 8.;
|
||||
let k = 4. / 3. * (step / 4.).tan() * 10.;
|
||||
for i in 0..8 {
|
||||
let a = i as f64 * step;
|
||||
let b = (i + 1) as f64 * step;
|
||||
let p0 = [10. * a.cos(), 10. * a.sin()];
|
||||
let p3 = if i == 7 { [10., 0.] } else { [10. * b.cos(), 10. * b.sin()] };
|
||||
let mut p1 = [p0[0] - k * a.sin(), p0[1] + k * a.cos()];
|
||||
let mut p2 = [p3[0] + k * b.sin(), p3[1] - k * b.cos()];
|
||||
if i == 0 {
|
||||
p1[0] += k * 1e-6;
|
||||
p2[0] += k * 1e-6 * b.cos();
|
||||
p2[1] += k * 1e-6 * b.sin();
|
||||
}
|
||||
s.execute("sketch.spline.control_point", &json!({"points":[p0,p1,p2,p3]})).unwrap();
|
||||
}
|
||||
s.execute("solid.extrude", &json!({"sketch":sketch,"distance":3})).unwrap();
|
||||
let mut app = SolveApp::new(s, crate::Services::default());
|
||||
let st = app.session.world_state();
|
||||
let body = st.bodies.first().unwrap();
|
||||
let mesh = body.mesh();
|
||||
let crease = mesh
|
||||
.edges
|
||||
.iter()
|
||||
.position(|line| {
|
||||
line.first().zip(line.last()).is_some_and(|(a, b)| {
|
||||
(a.x - 10.).abs() < 1e-8 && a.y.abs() < 1e-8 && (b.x - 10.).abs() < 1e-8 && b.y.abs() < 1e-8 && (a.z - b.z).abs() > 2.9
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
assert!(mesh.seams[crease], "the fixture must exercise the coarse seam false positive");
|
||||
assert!(mesh.sharp_edges[crease]);
|
||||
assert!(mesh.shaded_edge_visible(crease));
|
||||
assert!(mesh.edge_pickable(crease));
|
||||
assert!(mesh.edge_faces[crease].iter().any(|f| mesh.surface_patch(*f as usize).len() == 1));
|
||||
let mid = polyline_mid(&mesh.edges[crease]);
|
||||
app.cam = app.cam.looking_from(Vec3::new(30., 0., 1.5));
|
||||
app.cam.target = Vec3::new(0., 0., 1.5);
|
||||
for perspective in [false, true] {
|
||||
app.ui.perspective = perspective;
|
||||
let proj = projection(&app, Rect::from_min_size(pos2(0., 0.), vec2(800., 600.)));
|
||||
let hits = pick(&app, &proj, proj.to_screen(mid).unwrap());
|
||||
assert!(hits.iter().any(|hit| matches!(hit,Hit::Edge{index,..} if *index==crease)), "{hits:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_items_measure_as_whole_rims_and_surfaces() {
|
||||
let mut app = cylinder_app();
|
||||
@@ -2963,7 +3130,7 @@ mod tests {
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
}
|
||||
let _ = ctx.run_ui(
|
||||
let mut output = ctx.run_ui(
|
||||
egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1100.0, 800.0))),
|
||||
time: Some(time),
|
||||
@@ -2975,6 +3142,7 @@ mod tests {
|
||||
crate::prefs::show(app, ui.ctx());
|
||||
},
|
||||
);
|
||||
output.textures_delta.clear();
|
||||
};
|
||||
// Let egui establish the floating window's size and hit-test widgets.
|
||||
for _ in 0..4 {
|
||||
@@ -3240,7 +3408,7 @@ mod tests {
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
let before = h.app.cam;
|
||||
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":1020,"y1":500,"button":"middle","shift":true,"steps":40}));
|
||||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
|
||||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6, "before={before:?}, after={:?}", h.app.cam);
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
h.call("ui.drag", json!({"x0":800,"y0":500,"x1":850,"y1":520,"button":"middle"}));
|
||||
assert_eq!(h.app.cam.yaw, before.yaw);
|
||||
@@ -3260,7 +3428,7 @@ mod tests {
|
||||
{"call":"ui.view","params":{"view":"front"}}
|
||||
]))
|
||||
.unwrap();
|
||||
let c = axis_center(h.app.viewport.rect.unwrap());
|
||||
let c = axis_center(&h.app, h.app.viewport.rect.unwrap());
|
||||
h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+220.0,"y1":c.y,"shift":true,"steps":40}));
|
||||
assert!((h.app.cam.yaw - std::f64::consts::FRAC_PI_2).abs() < 1e-6);
|
||||
assert!(h.app.viewport.axis_drag.is_none());
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Floating controls reserve drawing space without changing rendering or picking projection.
|
||||
use egui::{Rect, pos2, vec2};
|
||||
use solvecraft_engine::geom::{Aabb3, Vec3};
|
||||
use solvecraft_engine::render::Camera;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Layout {
|
||||
pub viewport: Rect,
|
||||
pub drawable: Rect,
|
||||
pub axis_view: Rect,
|
||||
pub navigation: Rect,
|
||||
}
|
||||
|
||||
/// Enumerate rectangles whose edges meet the viewport or a bounded number of overlays.
|
||||
fn free_rect(view: Rect, obstacles: &[Rect]) -> Rect {
|
||||
let mut xs = vec![view.left(), view.right()];
|
||||
let mut ys = vec![view.top(), view.bottom()];
|
||||
let obstacles: Vec<_> = obstacles.iter().take(8).map(|r| r.intersect(view)).filter(|r| r.is_positive() && r.is_finite()).collect();
|
||||
for r in &obstacles {
|
||||
xs.extend([r.left(), r.right()]);
|
||||
ys.extend([r.top(), r.bottom()]);
|
||||
}
|
||||
xs.sort_by(f32::total_cmp);
|
||||
ys.sort_by(f32::total_cmp);
|
||||
let mut best = Rect::from_min_size(view.min, vec2(0.0, 0.0));
|
||||
for (i, &left) in xs.iter().enumerate() {
|
||||
for &right in xs.iter().skip(i + 1) {
|
||||
for (j, &top) in ys.iter().enumerate() {
|
||||
for &bottom in ys.iter().skip(j + 1) {
|
||||
let r = Rect::from_min_max(pos2(left, top), pos2(right, bottom));
|
||||
if r.area() > best.area().max(0.0) && !obstacles.iter().any(|o| r.intersect(*o).is_positive()) {
|
||||
best = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
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 {
|
||||
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 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 {
|
||||
Rect::from_center_size(pos2(nav_space.center().x, nav_space.bottom() - nav_height * 0.5 - 7.0), vec2(columns * 30.0 + 10.0, nav_height))
|
||||
} else {
|
||||
Rect::NOTHING
|
||||
};
|
||||
if navigation.is_positive() {
|
||||
obstacles.push(navigation);
|
||||
}
|
||||
let drawable = free_rect(viewport, &obstacles).shrink(16.0 / scale);
|
||||
Self { viewport, drawable, axis_view, navigation }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fit_bounds(mut cam: Camera, bounds: &Aabb3, layout: &Layout, perspective: bool) -> Option<Camera> {
|
||||
if bounds.is_empty()
|
||||
|| !bounds.min.is_finite()
|
||||
|| !bounds.max.is_finite()
|
||||
|| !cam.is_valid()
|
||||
|| !layout.viewport.is_positive()
|
||||
|| !layout.drawable.is_positive()
|
||||
|| !layout.drawable.is_finite()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let original_fov = cam.fov;
|
||||
cam.fov = if perspective { 0.6 } else { 0.0 };
|
||||
let (r, u, b) = cam.basis();
|
||||
let centre = bounds.center();
|
||||
let mut hx: f64 = 0.0;
|
||||
let mut hy: f64 = 0.0;
|
||||
let mut depth: f64 = 0.0;
|
||||
let corners: Vec<_> = [bounds.min.x, bounds.max.x]
|
||||
.into_iter()
|
||||
.flat_map(|x| {
|
||||
[bounds.min.y, bounds.max.y].into_iter().flat_map(move |y| [bounds.min.z, bounds.max.z].into_iter().map(move |z| Vec3::new(x, y, z)))
|
||||
})
|
||||
.collect();
|
||||
for p in &corners {
|
||||
let d = *p - centre;
|
||||
hx = hx.max(d.dot(r).abs());
|
||||
hy = hy.max(d.dot(u).abs());
|
||||
depth = depth.max(d.dot(b).abs());
|
||||
}
|
||||
let h = f64::from(layout.viewport.height());
|
||||
let hh = (hx * h / f64::from(layout.drawable.width())).max(hy * h / f64::from(layout.drawable.height())).max(0.5);
|
||||
let offset = layout.drawable.center() - layout.viewport.center();
|
||||
let tangent = if perspective { (0.3_f64).tan() } else { 0.4 };
|
||||
cam.distance = hh / tangent + if perspective { depth } else { 0.0 };
|
||||
// Offset the target so the model projects into the free rectangle in the original viewport.
|
||||
// Perspective needs a bounded correction because corners have different depths.
|
||||
for _ in 0..16 {
|
||||
let k = 2.0 * cam.half_height() / h;
|
||||
cam.target = centre - r * (f64::from(offset.x) * k) + u * (f64::from(offset.y) * k);
|
||||
let radius = (bounds.diagonal() * 0.5 + centre.dist(cam.target)).max(cam.half_height() * 2.0).max(10.0);
|
||||
let fits = corners.iter().all(|p| {
|
||||
cam.to_screen(*p, f64::from(layout.viewport.width()), h, radius).is_some_and(|(x, y, z)| {
|
||||
x.is_finite()
|
||||
&& y.is_finite()
|
||||
&& (0.0..=1.0).contains(&z)
|
||||
&& layout.drawable.contains(pos2(layout.viewport.left() + x as f32, layout.viewport.top() + y as f32))
|
||||
})
|
||||
});
|
||||
if fits {
|
||||
cam.fov = original_fov;
|
||||
return cam.is_valid().then_some(cam);
|
||||
}
|
||||
cam.distance *= 1.15;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "viewport_layout_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,196 @@
|
||||
use super::*;
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::render::StandardView;
|
||||
|
||||
#[test]
|
||||
fn thin_rotated_bounds_fit_using_the_original_full_projection() {
|
||||
for (width, height, scale) in [(1400.0, 900.0, 1.0), (700.0, 450.0, 2.0), (700.0, 360.0, 2.0), (900.0, 720.0, 1.0)] {
|
||||
let vp = Rect::from_min_max(pos2(250.0, 126.0), pos2(width, height - 30.0));
|
||||
let dock = Rect::from_min_max(pos2(width - 205.0, vp.top()), vp.max);
|
||||
let layout = Layout::new(vp, &[dock], scale);
|
||||
assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}");
|
||||
assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}");
|
||||
assert!(!layout.drawable.intersect(dock).is_positive());
|
||||
assert!(!layout.axis_view.intersect(layout.navigation).is_positive());
|
||||
for perspective in [false, true] {
|
||||
for view in [StandardView::Top, StandardView::Iso, StandardView::Right] {
|
||||
let mut cam = Camera::default();
|
||||
cam.set_view(view);
|
||||
let bounds = Aabb3 { min: Vec3::new(-30.0, -1.0, -2.0), max: Vec3::new(70.0, 2.0, 3.0) };
|
||||
let fit = fit_bounds(cam, &bounds, &layout, perspective).unwrap();
|
||||
assert_eq!((cam.yaw, cam.pitch, cam.up), (fit.yaw, fit.pitch, fit.up));
|
||||
assert_eq!(fit_bounds(fit, &bounds, &layout, perspective).unwrap(), fit);
|
||||
let mut effective = fit;
|
||||
effective.fov = if perspective { 0.6 } else { 0.0 };
|
||||
let radius = (bounds.diagonal() * 0.5 + bounds.center().dist(fit.target)).max(effective.half_height() * 2.0).max(10.0);
|
||||
for x in [bounds.min.x, bounds.max.x] {
|
||||
for y in [bounds.min.y, bounds.max.y] {
|
||||
for z in [bounds.min.z, bounds.max.z] {
|
||||
let (sx, sy, depth) =
|
||||
effective.to_screen(Vec3::new(x, y, z), f64::from(vp.width()), f64::from(vp.height()), radius).unwrap();
|
||||
assert!((0.0..=1.0).contains(&depth));
|
||||
assert!(layout.drawable.contains(pos2(vp.left() + sx as f32, vp.top() + sy as f32)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_palette_fit_exposes_endpoints_and_layout_changes_preserve_manual_view() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points":[[0,0],[20,0]]})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.settle();
|
||||
for (scale, width, height) in [(1.0, 1400.0, 900.0), (2.0, 700.0, 450.0), (2.0, 700.0, 360.0), (1.0, 900.0, 720.0)] {
|
||||
h.call("ui.prefs", json!({"ui_scale":scale}));
|
||||
h.call("ui.resize", json!({"width":width,"height":height}));
|
||||
h.settle();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.app.fit_view();
|
||||
h.settle();
|
||||
let layout = h.app.viewport.layout.as_ref().unwrap();
|
||||
let palette = h.app.viewport.palette_rect.unwrap();
|
||||
assert!(layout.drawable.width() >= 80.0 && layout.drawable.height() >= 30.0, "{layout:?}");
|
||||
assert!(layout.axis_view.is_positive() && layout.navigation.is_positive(), "{layout:?}");
|
||||
assert!(palette.width() <= 260.0, "{palette:?}");
|
||||
let projection = crate::viewport::projection(&h.app, layout.viewport);
|
||||
for point in [Vec3::ZERO, Vec3::new(20.0, 0.0, 0.0)] {
|
||||
let p = projection.to_screen(point).unwrap();
|
||||
assert!(layout.drawable.contains(p), "endpoint {p:?} outside {layout:?}");
|
||||
assert!(!palette.contains(p));
|
||||
assert!(!layout.axis_view.contains(p));
|
||||
assert!(!layout.navigation.contains(p));
|
||||
}
|
||||
let fitted = h.app.cam;
|
||||
h.app.fit_view();
|
||||
h.settle();
|
||||
assert_eq!(h.app.cam, fitted);
|
||||
h.app.cancel_view_animation();
|
||||
h.app.cam.pan(13.0, -19.0, 500.0);
|
||||
let manual = h.app.cam;
|
||||
h.call("ui.resize", json!({"width":width+20.0,"height":height}));
|
||||
h.settle();
|
||||
assert_eq!(h.app.cam, manual);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_or_empty_fit_keeps_the_existing_camera_valid() {
|
||||
let cam = Camera::default();
|
||||
let layout = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)), &[], 1.0);
|
||||
assert!(fit_bounds(cam, &Aabb3::EMPTY, &layout, false).is_none());
|
||||
let bad = Aabb3 { min: Vec3::new(f64::NAN, 0.0, 0.0), max: Vec3::new(1.0, 1.0, 1.0) };
|
||||
assert!(fit_bounds(cam, &bad, &layout, true).is_none());
|
||||
let mut app = crate::SolveApp::new(Default::default(), Default::default());
|
||||
let tiny = Layout::new(Rect::from_min_size(pos2(0.0, 0.0), vec2(5.0, 5.0)), &[], 1.0);
|
||||
app.viewport.layout = Some(tiny);
|
||||
let bounds = Aabb3 { min: Vec3::ZERO, max: Vec3::new(20.0, 1.0, 1.0) };
|
||||
assert_eq!(app.fitted_bounds(cam, &bounds), cam);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_home_animation_and_manual_takeover_use_one_layout_solution() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap();
|
||||
h.settle();
|
||||
for view in ["fit", "home"] {
|
||||
h.app.preferences.look_at_anim = false;
|
||||
h.app.animate_view(view);
|
||||
h.settle();
|
||||
let immediate = h.app.cam;
|
||||
h.app.cam.pan(50.0, 10.0, 400.0);
|
||||
h.app.preferences.look_at_anim = true;
|
||||
h.app.animate_view(view);
|
||||
h.settle();
|
||||
assert_eq!(h.app.cam, immediate);
|
||||
}
|
||||
h.app.fit_view();
|
||||
h.app.cancel_view_animation();
|
||||
h.app.cam.pan(7.0, 13.0, 400.0);
|
||||
let takeover = h.app.cam;
|
||||
h.settle();
|
||||
assert_eq!(h.app.cam, takeover);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_axis_view_targets_home_and_drag_work_beside_the_palette_at_hidpi() {
|
||||
for (scale, width, height) in [(1.0, 900.0, 720.0), (2.0, 700.0, 450.0), (2.0, 700.0, 360.0)] {
|
||||
let mut h = Harness::new();
|
||||
h.app.preferences.look_at_anim = false;
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":1})).unwrap();
|
||||
h.call("ui.prefs", json!({"ui_scale":scale}));
|
||||
h.call("ui.resize", json!({"width":width,"height":height}));
|
||||
h.app.fit_view();
|
||||
h.settle();
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
let layout = h.app.viewport.layout.as_ref().unwrap().clone();
|
||||
let palette = h.app.viewport.palette_rect.unwrap();
|
||||
let outer = crate::scenario::rect_of("axis_view:outer").unwrap();
|
||||
let home = crate::scenario::rect_of("axis_view:home").unwrap();
|
||||
assert_eq!(outer, layout.axis_view);
|
||||
assert!(outer.contains_rect(home));
|
||||
assert!(layout.viewport.contains_rect(outer));
|
||||
assert!(!outer.intersect(palette).is_positive());
|
||||
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();
|
||||
assert!(outer.contains_rect(Rect::from_center_size(p, vec2(22.0, 22.0))));
|
||||
}
|
||||
let z = crate::scenario::handle_at("axis_view:z").unwrap();
|
||||
h.call("ui.click", json!({"x":z.x,"y":z.y}));
|
||||
assert!(h.app.cam.back().dist(Vec3::Z) < 1e-5);
|
||||
let z = crate::scenario::handle_at("axis_view:z").unwrap();
|
||||
h.call("ui.click", json!({"x":z.x,"y":z.y}));
|
||||
assert!(h.app.cam.back().dist(-Vec3::Z) < 1e-5);
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
h.app.viewport.nav = Some(crate::viewport::NavMode::Pan);
|
||||
let before = h.app.cam;
|
||||
let c = h.app.viewport.layout.as_ref().unwrap().axis_view.center();
|
||||
h.call("ui.drag", json!({"x0":c.x,"y0":c.y,"x1":c.x+24.0,"y1":c.y+8.0,"steps":8}));
|
||||
assert!((h.app.cam.yaw - before.yaw - 24.0 * 0.008).abs() < 1e-6);
|
||||
assert!((h.app.cam.pitch - before.pitch - 8.0 * 0.008).abs() < 1e-6);
|
||||
assert_eq!(h.app.cam.target, before.target);
|
||||
assert_eq!(h.app.cam.distance, before.distance);
|
||||
assert!(h.app.viewport.axis_drag.is_none());
|
||||
assert!(h.app.viewport.boxsel.is_none());
|
||||
let home = crate::scenario::handle_at("axis_view:home").unwrap();
|
||||
h.call("ui.click", json!({"x":home.x,"y":home.y}));
|
||||
let mut iso = h.app.cam;
|
||||
iso.set_view(StandardView::Iso);
|
||||
assert_eq!((h.app.cam.yaw, h.app.cam.pitch), (iso.yaw, iso.pitch));
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert!(h.app.session.selection.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediate_standard_view_cancels_fit_before_the_measured_frame() {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap();
|
||||
let baseline = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.app.cam.set_view(StandardView::Iso);
|
||||
h.app.fit_view();
|
||||
assert!(h.app.pending_fit.is_some(), "Fit awaits the measured overlay frame");
|
||||
let mut front = h.app.cam;
|
||||
front.set_view(StandardView::Front);
|
||||
h.call("ui.view", json!({"view":"front"}));
|
||||
h.settle();
|
||||
assert!(h.app.pending_fit.is_none());
|
||||
assert_eq!(h.app.cam, front, "the later frame must keep the explicitly requested view");
|
||||
assert_eq!(h.app.session.doc.to_json(), baseline);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
}
|
||||
Reference in new issue
Block a user