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

+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) => {}