Prepare 0.2.0 usability release and refresh desktop demo

This commit is contained in:
bherbruck committed 2026-10-11 10:55:55 -04:00
1 parent a094fa46b5
commit ab05b24dee
54 files changed
+4888 -234

No files matched your search

+2 -2
View File
@@ -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 {
+11 -9
View File
@@ -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));
+1 -1
View File
@@ -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" => {
+20 -5
View File
@@ -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
View File
@@ -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(&param).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(&param).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(&centres) } else { &centres };
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);
}
+16 -14
View File
@@ -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));
+12 -1
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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() {
+992
View File
@@ -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, &param);
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(&param).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
View File
@@ -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()));
+1 -1
View File
@@ -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| {
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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();
+16 -13
View File
@@ -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));
+2 -2
View File
@@ -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" {
+9 -4
View File
@@ -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
View File
@@ -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());
+130
View File
@@ -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;
+196
View File
@@ -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);
}