Fix modelling regressions and reconcile WIP UI work

This commit is contained in:
bherbruck committed 2026-10-10 15:37:00 -04:00
1 parent 49bd89de13
commit d6cdec40da
67 files changed
+1793 -260

No files matched your search

+25 -25
View File
@@ -232,41 +232,41 @@ const INDENT: f32 = 14.0;
/// One row's look.
#[derive(Default)]
struct Row<'a> {
depth: usize,
pub(crate) struct Row<'a> {
pub(crate) depth: usize,
/// Some(open) for rows that fold.
fold: Option<bool>,
pub(crate) fold: Option<bool>,
/// Some(visible) for rows with an eye.
eye: Option<bool>,
icon: &'a str,
label: &'a str,
selected: bool,
dim: bool,
color: Option<Color32>,
pub(crate) eye: Option<bool>,
pub(crate) icon: &'a str,
pub(crate) label: &'a str,
pub(crate) selected: bool,
pub(crate) dim: bool,
pub(crate) color: Option<Color32>,
/// Component rows: is it the active one?
radio: Option<bool>,
pub(crate) radio: Option<bool>,
/// Trailing badges: "lock", "pin".
badges: &'a [&'a str],
pub(crate) badges: &'a [&'a str],
}
/// What happened on a row.
#[derive(Default)]
struct RowResp {
rect: Option<Rect>,
clicked: bool,
double: bool,
secondary: Option<Pos2>,
fold: bool,
eye: bool,
radio: bool,
hovered: bool,
pub(crate) struct RowResp {
pub(crate) rect: Option<Rect>,
pub(crate) clicked: bool,
pub(crate) double: bool,
pub(crate) secondary: Option<Pos2>,
pub(crate) fold: bool,
pub(crate) eye: bool,
pub(crate) radio: bool,
pub(crate) hovered: bool,
/// The row's response (drag and drop).
resp: Option<egui::Response>,
pub(crate) resp: Option<egui::Response>,
/// The press began on the fold arrow, eye or radio (not a drag of the row).
on_control: bool,
pub(crate) on_control: bool,
}
fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp {
let t = Tokens::get();
// Rows scrolled out of view only take their space: no widgets, no painting.
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H));
@@ -449,11 +449,11 @@ fn sel_of_key(k: &str) -> Option<Sel> {
None
}
fn is_open(app: &SolveApp, key: &str, default: bool) -> bool {
pub(crate) fn is_open(app: &SolveApp, key: &str, default: bool) -> bool {
if default { !app.tree.collapsed.contains(key) } else { app.tree.expanded.contains(key) }
}
fn toggle(app: &mut SolveApp, key: &str, default: bool) {
pub(crate) fn toggle(app: &mut SolveApp, key: &str, default: bool) {
if default {
if !app.tree.collapsed.remove(key) {
app.tree.collapsed.insert(key.to_string());
+13 -5
View File
@@ -493,7 +493,10 @@ fn joint_items(app: &SolveApp, id: u64) -> Vec<Item> {
let rigid = j.kind == solvecraft_engine::doc::joints::JointKind::Rigid;
vec![
act("ui.editJoint", "Edit Joint", "joint").with(json!({ "joint": id })),
act("ui.editJoint", "Edit Joint Limits", "").with(json!({ "joint": id })).on(!rigid),
act("ui.driveJoint", "Drive Joint", "").with(json!({ "joint": id })).on(!rigid),
act("ui.animateJoint", "Animate Joint", "").with(json!({ "joint": id })).on(!rigid && !j.suppressed),
act("ui.findJoint", "Find in Window", "").with(json!({ "joint": id })),
cmd(app, "joint.edit", if j.suppressed { "Unsuppress" } else { "Suppress" }).with(json!({ "joint": id, "suppressed": !j.suppressed })),
Item::sep(),
cmd(app, "joint.delete", "Delete").key("Del").with(json!({ "joint": id })),
@@ -792,12 +795,17 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
"ui.canvasVisible" => drop(app.run("canvas.edit", p)),
"ui.toolbarPin" | "ui.toolbarRemove" | "ui.toolbarReset" | "ui.shortcutPin" => crate::toolbar_custom::run(app, &item.id, &p),
"ui.workspace" => {}
"ui.editJoint" | "ui.driveJoint" => {
"ui.findJoint" => {
if let Some(id) = id_of(&p, "joint") {
let d = if item.id == "ui.editJoint" {
crate::dialogs_assembly::edit_joint(app, id)
} else {
crate::dialogs_assembly::drive_joint(app, id)
crate::dialogs_assembly::find_joint(app, id);
}
}
"ui.editJoint" | "ui.driveJoint" | "ui.animateJoint" => {
if let Some(id) = id_of(&p, "joint") {
let d = match item.id.as_str() {
"ui.editJoint" => crate::dialogs_assembly::edit_joint(app, id),
"ui.animateJoint" => crate::dialogs_assembly::animate_joint(app, id),
_ => crate::dialogs_assembly::drive_joint(app, id),
};
if let Some(d) = d {
app.tool = None;
+45 -3
View File
@@ -2691,11 +2691,50 @@ pub(crate) fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &P
}
}
thread_local! {
/// A dialog is being filled from a feature: its points are in component frames.
static REFILL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// While filling a dialog from a feature (dropped at the end).
struct Refill;
impl Refill {
fn start() -> Refill {
REFILL.with(|r| r.set(true));
Refill
}
}
impl Drop for Refill {
fn drop(&mut self) {
REFILL.with(|r| r.set(false));
}
}
/// A point stored in a feature, as it shows on world body `body`: features keep their picks in
/// the frame of the body they were on, so each body's placement takes the point to the world
/// (a moved component, a pasted instance). Other points are the world already.
fn stored_on(frames: &Option<Vec<(String, solvecraft_engine::doc::Mat)>>, body: &str, p: Vec3) -> Vec3 {
frames.as_ref().and_then(|f| f.iter().find(|(n, _)| n == body)).map(|(_, m)| solvecraft_engine::doc::apply_point(m, p)).unwrap_or(p)
}
/// Body placements while filling a dialog from a feature (when anything is placed).
fn stored_frames(s: &Session) -> Option<Vec<(String, solvecraft_engine::doc::Mat)>> {
if !REFILL.with(std::cell::Cell::get) || !solvecraft_engine::frames::placed(s) {
return None;
}
let st = s.model.state();
Some(s.doc.body_frames(&st).into_iter().map(|(w, _, _, m)| (w, m)).collect())
}
/// The edge of a visible body through (or nearest) a point.
pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
pub(crate) fn edge_sel(s: &Session, p0: Vec3) -> Option<Sel> {
let st = s.world_state();
let frames = stored_frames(s);
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let p = stored_on(&frames, &b.name, p0);
let m = b.mesh();
for (i, e) in m.edges.iter().enumerate() {
let d = e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
@@ -2708,10 +2747,12 @@ pub(crate) fn edge_sel(s: &Session, p: Vec3) -> Option<Sel> {
}
/// The body face containing a point (nearest triangle).
pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option<Sel> {
let st = s.world_state();
let frames = stored_frames(s);
let mut best: Option<(f64, Sel)> = None;
for b in &st.bodies {
let p = stored_on(&frames, &b.name, p0);
let m = b.mesh();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some([a, bb, c]) = m.tri(t) else { continue };
@@ -2721,7 +2762,7 @@ pub(crate) fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
}
}
}
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1)
best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p0.len()).map(|x| x.1)
}
pub(crate) fn plane_sel(s: &Session, pl: &PlaneRef) -> Option<Sel> {
@@ -2756,6 +2797,7 @@ fn pt3(v: Vec3) -> Value {
/// A dialog that edits an existing feature, filled from it. The timeline is rolled back to just
/// before the feature, so its references show on the geometry they refer to.
pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dialog> {
let _refill = Refill::start();
let s = &app.session;
let f = s.doc.feature(id)?.clone();
let st = s.world_state();
+165 -60
View File
@@ -209,6 +209,38 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
}
/// The Drive Joints dialog on a joint (by id).
/// Edit Joint with its motion playing (the browser's Animate Joint).
pub fn animate_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
let mut d = edit_joint(app, id)?;
if let Kind::Assembly(Asm::Joint(f)) = &mut d.kind {
f.animate = true;
}
Some(d)
}
/// Frame the two components a joint connects (the browser's Find in Window).
pub fn find_joint(app: &mut SolveApp, id: u64) {
let s = &app.session;
let Some(j) = s.doc.assembly.joints.iter().find(|j| j.id == id) else { return };
let occs = [j.a.occurrence, j.b.occurrence];
let model = s.model.state();
let world = s.world_state();
let mut bb = Aabb3::default();
for (name, _, path, _) in s.doc.body_frames(&model) {
// The root (occurrence 0) holds the bodies outside every occurrence.
let on = occs.iter().any(|o| if *o == 0 { path.is_empty() } else { path.contains(o) });
if on && let Some(b) = world.body(&name) {
bb = bb.union(&b.mesh().bounds());
}
}
if bb.is_empty() {
return;
}
let mut cam = app.cam;
cam.fit(&bb);
app.animate_to(cam);
}
pub fn drive_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
let joint = app.session.doc.assembly.joints.iter().position(|j| j.id == id)?;
Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint), focus: true }), vec![]))
@@ -494,12 +526,25 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
let cur = s.doc.eval(v, unit_kind).ok().filter(|x| x.is_finite()).unwrap_or(0.0);
let (mut shown, scale) = if angle { (cur.to_degrees(), 1f64.to_degrees()) } else { (cur, 1.0) };
row_label(ui, "");
ui.spacing_mut().slider_width = 124.0;
ui.spacing_mut().slider_width = 160.0;
let r = ui.add(egui::Slider::new(&mut shown, lo * scale..=hi * scale).show_value(false));
if r.changed() {
*v = if angle { format!("{shown:.1} deg") } else { format!("{shown:.2} mm") };
}
ui.end_row();
// The slider's range: the joint's limits, or the default span when it has none.
let limited = j.limits.get(i).copied().flatten().is_some();
let fmt = |x: f64| {
let (x, unit) = if angle { (x.to_degrees(), "°") } else { (x, " mm") };
if x.fract().abs() < 1e-6 { format!("{x:.0}{unit}") } else { format!("{x:.1}{unit}") }
};
row_label(ui, "");
ui.label(
RichText::new(format!("{} … {}{}", fmt(lo), fmt(hi), if limited { " (limits)" } else { " (no limits)" }))
.size(11.0)
.color(if limited { t.text } else { t.text_dim }),
);
ui.end_row();
}
}
Asm::MotionLink { a, b, ia, ib, ratio, offset } => {
@@ -826,6 +871,8 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
}
return;
}
// A joint names its two picks; a joint origin has one.
let two = matches!(k, Asm::Joint(_));
let hover = match k {
Asm::Joint(f) => Some(&mut f.hover),
Asm::JointOrigin { hover, .. } => Some(hover),
@@ -840,8 +887,9 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
.filter(|(_, i)| i.accept == SNAPS)
.filter_map(|(n, i)| i.items.first().and_then(|x| snap_of(&app.session, x)).map(|s| (s, if n == 0 { t.accent } else { t.warning })))
.collect();
for (s, c) in &picked {
glyph(painter, proj, s, *c, app.cam.half_height());
for (n, (s, c)) in picked.iter().enumerate() {
let tag = format!("Component {}", n + 1);
glyph(painter, proj, s, *c, app.cam.half_height(), two.then_some(tag.as_str()));
}
// The snap under the cursor (found again only when the hovered item changes).
let accepting = d.inputs.get(d.active).is_some_and(|i| i.accept == SNAPS);
@@ -853,7 +901,7 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
*hover = Some((sel.clone(), snap_of(&app.session, &sel)));
}
if let Some((_, Some(s))) = hover.as_ref() {
glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height());
glyph(painter, proj, s, t.hover_profile_edge, app.cam.half_height(), Some(snap_name(s.kind)));
}
}
None => *hover = None,
@@ -861,36 +909,70 @@ pub fn overlay(app: &SolveApp, painter: &egui::Painter, proj: &Proj, d: &mut Dia
}
/// A snap: its kind's marker at the point and the z axis as an arrow.
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64) {
fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_height: f64, tag: Option<&str>) {
let Some(c) = proj.to_screen(s.at) else { return };
let len = half_height * 2.0 * 0.06;
// A dark halo under every stroke keeps the glyph readable on light and dark faces alike.
let halo = Stroke::new(4.5, Color32::from_black_alpha(150));
let white = Stroke::new(1.5, Color32::WHITE);
if let Some(tip) = proj.to_screen(s.at + s.z * len) {
painter.line_segment([c, tip], Stroke::new(2.0, col));
let d = (tip - c).normalized();
let n = vec2(-d.y, d.x);
painter.add(egui::Shape::convex_polygon(vec![tip, tip - d * 8.0 + n * 4.0, tip - d * 8.0 - n * 4.0], col, Stroke::NONE));
let head = vec![tip + d * 2.0, tip - d * 9.0 + n * 5.0, tip - d * 9.0 - n * 5.0];
painter.line_segment([c, tip], halo);
painter.add(egui::Shape::convex_polygon(head.clone(), Color32::TRANSPARENT, halo));
painter.line_segment([c, tip], Stroke::new(2.0, col));
painter.add(egui::Shape::convex_polygon(head, col, Stroke::NONE));
}
let white = Stroke::new(1.5, Color32::WHITE);
let shape = |fill: Color32, stroke: Stroke, grow: f32| -> egui::Shape {
match s.kind {
SnapKind::FaceCenter => egui::Shape::rect_filled(egui::Rect::from_center_size(c, vec2(12.0 + grow, 12.0 + grow)), 1.5, fill),
SnapKind::CircleCenter => egui::Shape::circle_filled(c, 7.0 + grow / 2.0, fill),
SnapKind::EdgeMid => {
let r = 8.0 + grow / 2.0;
egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r * 0.93, r * 0.7), c + vec2(-r * 0.93, r * 0.7)], fill, stroke)
}
SnapKind::Vertex => {
let r = 7.5 + grow / 2.0;
egui::Shape::convex_polygon(vec![c + vec2(0.0, -r), c + vec2(r, 0.0), c + vec2(0.0, r), c + vec2(-r, 0.0)], fill, stroke)
}
}
};
painter.add(shape(Color32::from_black_alpha(150), Stroke::NONE, 4.0));
painter.add(shape(col, Stroke::NONE, 0.0));
match s.kind {
SnapKind::FaceCenter => {
painter.rect(egui::Rect::from_center_size(c, vec2(10.0, 10.0)), 1.0, col, white, egui::StrokeKind::Middle);
painter.rect_stroke(egui::Rect::from_center_size(c, vec2(12.0, 12.0)), 1.5, white, egui::StrokeKind::Middle);
}
SnapKind::CircleCenter => {
painter.circle(c, 6.0, col, white);
painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(0.0, 4.0), c + vec2(0.0, 4.0)], Stroke::new(1.2, Color32::WHITE));
painter.circle_stroke(c, 7.0, white);
painter.line_segment([c - vec2(4.5, 0.0), c + vec2(4.5, 0.0)], Stroke::new(1.2, Color32::WHITE));
painter.line_segment([c - vec2(0.0, 4.5), c + vec2(0.0, 4.5)], Stroke::new(1.2, Color32::WHITE));
}
SnapKind::EdgeMid => {
painter.add(egui::Shape::convex_polygon(vec![c + vec2(0.0, -7.0), c + vec2(6.5, 5.0), c + vec2(-6.5, 5.0)], col, white));
}
SnapKind::Vertex => {
painter.add(egui::Shape::convex_polygon(
vec![c + vec2(0.0, -6.5), c + vec2(6.5, 0.0), c + vec2(0.0, 6.5), c + vec2(-6.5, 0.0)],
col,
white,
));
SnapKind::EdgeMid | SnapKind::Vertex => {
painter.add(shape(Color32::TRANSPARENT, white, 0.0));
}
}
// What it snaps to (hovered), or which component it is (picked): a small label.
if let Some(tag) = tag {
let t = Tokens::get();
let font = egui::FontId::proportional(11.5);
let g = painter.layout_no_wrap(tag.to_string(), font, t.text);
let at = c + vec2(12.0, 10.0);
let r = egui::Rect::from_min_size(at, g.size()).expand2(vec2(5.0, 2.0));
painter.rect(r, 3.0, t.panel.gamma_multiply(0.92), Stroke::new(1.0, col), egui::StrokeKind::Inside);
painter.galley(at, g, t.text);
}
}
/// What a snap is called (the hover label).
fn snap_name(k: SnapKind) -> &'static str {
match k {
SnapKind::FaceCenter => "Face center",
SnapKind::CircleCenter => "Circle center",
SnapKind::EdgeMid => "Edge midpoint",
SnapKind::Vertex => "Vertex",
}
}
// ---- timeline and browser ----
@@ -963,61 +1045,36 @@ pub fn timeline_joints(app: &mut SolveApp, ui: &mut egui::Ui, p: &egui::Painter,
/// The Joints folder at the end of the Browser: a row per joint (double-click edits, the menu
/// edits, drives, suppresses or deletes).
pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
let t = Tokens::get();
use crate::browser::{Row, draw_row, is_open, toggle};
let joints: Vec<(u64, String, JointKind, bool)> =
app.session.doc.assembly.joints.iter().map(|j| (j.id, j.name.clone(), j.kind, j.suppressed)).collect();
if joints.is_empty() {
return;
}
let fold_id = egui::Id::new("sc_browser_joints_open");
let mut open = ui.data(|d| d.get_temp::<bool>(fold_id)).unwrap_or(true);
let row = |ui: &mut egui::Ui, depth: f32, icon: &str, label: &str, dim: bool, fold: Option<bool>| -> egui::Response {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), egui::Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let x = r.left() + 2.0 + depth * 14.0;
let cy = r.center().y;
if let Some(o) = fold {
let c = egui::pos2(x + 6.0, cy);
let pts = if o {
vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]
} else {
vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)]
};
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
}
let ir = egui::Rect::from_center_size(egui::pos2(x + 26.0, cy), vec2(16.0, 16.0));
crate::icons::paint(ui.painter(), ir, icon, if dim { t.border } else { t.icon }, t.icon_fill, t.accent);
ui.painter().text(
egui::pos2(x + 38.0, cy),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(13.0),
if dim { t.text_dim } else { t.text },
);
resp
};
let depth = depth as f32;
if row(ui, depth, "folder", "Joints", false, Some(open)).clicked() {
open = !open;
ui.data_mut(|d| d.insert_temp(fold_id, open));
// Drawn like every other browser node (its fold and rows publish `fold:Joints`, `row:<name>`).
let key = "c0/joints";
let open = is_open(app, key, true);
let r = draw_row(ui, ui.id().with(key), &Row { depth, fold: Some(open), icon: "folder", label: "Joints", ..Default::default() });
if r.fold || r.clicked {
toggle(app, key, true);
}
if !open {
return;
}
let mut dialog: Option<Dialog> = None;
for (id, name, kind, suppressed) in joints {
let resp = row(ui, depth + 1.0, "joint", &name, suppressed, None).on_hover_text(format!("{kind:?} joint"));
if resp.double_clicked() {
let r =
draw_row(ui, ui.id().with(("joint", id)), &Row { depth: depth + 1, icon: "joint", label: &name, dim: suppressed, ..Default::default() });
if let Some(resp) = &r.resp {
resp.clone().on_hover_text(format!("{kind:?} joint"));
}
if r.double {
dialog = edit_joint(app, id);
}
// The same menus as the rest of the browser.
if resp.secondary_clicked() {
let at = resp.interact_pointer_pos().unwrap_or(resp.rect.left_bottom());
if let Some(at) = r.secondary {
crate::context_menu::open_for(app, at, crate::context_menu::Target::Joint { id });
}
let _ = (kind, suppressed);
}
if let Some(d) = dialog {
app.tool = None;
@@ -1028,3 +1085,51 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
#[cfg(test)]
#[path = "dialogs_assembly_tests.rs"]
mod tests;
thread_local! {
/// Named joint origins in the world, for the design state they were found in.
static ORIGINS: std::cell::RefCell<Option<(String, Vec<(String, solvecraft_engine::doc::Mat)>)>> = const { std::cell::RefCell::new(None) };
}
/// Named joint origins in the world: on their occurrence's component, so they move with it
/// (joint drives, free moves not captured yet, Capture Position).
pub fn origins_world(s: &Session) -> Vec<(String, solvecraft_engine::doc::Mat)> {
if s.doc.assembly.origins.is_empty() {
return Vec::new();
}
let key = format!("{}:{:?}", s.revision, s.pending_moves);
if let Some(v) = ORIGINS.with(|o| o.borrow().as_ref().filter(|(k, _)| *k == key).map(|(_, v)| v.clone())) {
return v;
}
let doc: std::borrow::Cow<solvecraft_engine::doc::Document> = if s.pending_moves.is_empty() {
std::borrow::Cow::Borrowed(&*s.doc)
} else {
let mut d = (*s.doc).clone();
for o in &mut d.occurrences {
if let Some(m) = s.pending_moves.get(&o.id) {
o.transform = *m;
}
}
std::borrow::Cow::Owned(d)
};
let st = s.model.state();
let v: Vec<_> = doc.assembly.origins.iter().filter_map(|o| doc.origin_world(&st, &o.origin).ok().map(|m| (o.name.clone(), m))).collect();
ORIGINS.with(|o| *o.borrow_mut() = Some((key, v.clone())));
v
}
/// Named joint origins in the viewport: a small triad where each sits.
pub fn origins(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
let t = Tokens::get();
let len = app.cam.half_height() * 2.0 * 0.04;
for (_, m) in origins_world(&app.session) {
let at = solvecraft_engine::doc::apply_point(&m, Vec3::ZERO);
let Some(c) = proj.to_screen(at) else { continue };
for (axis, col) in [(Vec3::X, t.axis_x), (Vec3::Y, t.axis_y), (Vec3::Z, t.axis_z)] {
if let Some(tip) = proj.to_screen(at + solvecraft_engine::doc::apply_vector(&m, axis) * len) {
painter.line_segment([c, tip], Stroke::new(2.0, col));
}
}
painter.circle(c, 4.5, t.panel, Stroke::new(1.5, t.text));
}
}
@@ -263,3 +263,41 @@ fn drive_value_takes_the_keyboard() {
assert!((j.values[0] - 90f64.to_radians()).abs() < 1e-9, "{:?}", j.values);
assert!(app.dialog.is_none());
}
/// Named joint origins are drawn where their component is: a drive of the occurrence, or a free
/// move not captured yet, carries them along.
#[test]
fn joint_origins_follow_their_component() {
let mut app = two_boxes();
app.session.execute("joint.origin", &json!({"name": "Top of B", "face": [40, 10, 10]})).unwrap();
let at = |app: &SolveApp| {
let v = origins_world(&app.session);
let (_, m) = v.iter().find(|(n, _)| n == "Top of B").unwrap();
solvecraft_engine::doc::apply_point(m, Vec3::ZERO)
};
assert!(at(&app).dist(Vec3::new(40.0, 10.0, 10.0)) < 1e-6, "{:?}", at(&app));
let b = occ(&app, "B");
app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap();
assert!(at(&app).dist(Vec3::new(40.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app));
// A drag not captured yet.
let mut m = app.session.doc.occurrence_of(app.session.doc.find_component("B").unwrap()).unwrap().transform;
m[3][0] += 5.0;
app.session.pending_moves.insert(b, m);
assert!(at(&app).dist(Vec3::new(45.0, 10.0, 25.0)) < 1e-6, "{:?}", at(&app));
}
/// Edit Feature on a moved component: the dialog shows the feature's picks where the
/// component is (its stored points are in the component's frame).
#[test]
fn edit_feature_on_a_moved_component_refills_in_the_world() {
let mut app = two_boxes();
let b = occ(&app, "B");
app.session.execute("occurrence.move", &json!({"occurrence": b, "translate": [0, 0, 15]})).unwrap();
let r = app.session.execute("solid.fillet", &json!({"edges": [[40, 0, 25]], "radius": 2})).unwrap();
let id = r["feature"].as_u64().unwrap();
let d = crate::dialogs::for_feature(&app, id, None).unwrap();
let items: Vec<Sel> = d.inputs.iter().flat_map(|i| i.items.clone()).collect();
let Some(Sel::Edge { body, point, .. }) = items.first() else { panic!("{items:?}") };
assert_eq!(body, "Body2");
assert!(point.dist(Vec3::new(40.0, 0.0, 25.0)) < 1e-6, "{point:?}");
}
+127 -8
View File
@@ -41,6 +41,10 @@ pub enum Mo {
tracks: Vec<Track>,
step: f64,
playing: bool,
/// Playback starts over at the end (else it stops there).
looping: bool,
/// Playback speed (index into `SPEEDS`).
speed: usize,
/// The step the shown pose is for (and its revision).
shown: Option<(u64, u64)>,
saved: Option<BTreeMap<u64, Mat>>,
@@ -101,6 +105,8 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
tracks: Vec::new(),
step: 0.0,
playing: false,
looping: true,
speed: 2,
shown: None,
saved: None,
error: None,
@@ -116,6 +122,73 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
// ---- pure mappings (tested) ----
/// Playback speeds offered (times `STEPS_PER_S`).
pub const SPEEDS: [f64; 5] = [0.25, 0.5, 1.0, 2.0, 4.0];
/// Steps played per second at 1×.
const STEPS_PER_S: f64 = 24.0;
/// The next playback step: `by` steps on; at the end it starts over (looping) or stops there.
/// Returns the step and whether it still plays.
pub fn advance(step: f64, by: f64, total: u32, looping: bool) -> (f64, bool) {
let last = f64::from(total);
let next = step + by.max(0.0);
if next <= last {
(next, true)
} else if looping && last > 0.0 {
(next % (last + 1.0), true)
} else {
(last, false)
}
}
#[derive(Clone, Copy)]
enum Transport {
Start,
Back,
Play,
Stop,
Forward,
End,
}
/// A media button drawn in code: bars and triangles.
fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response {
let t = Tokens::get();
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 22.0), Sense::click());
let fill = if resp.hovered() { t.hover } else { t.field };
ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
let c = r.center();
let ink = if matches!(what, Transport::Play | Transport::Stop) { t.accent } else { t.icon };
let p = ui.painter();
let tri = |dir: f32, x: f32| {
egui::Shape::convex_polygon(vec![pos2(x - 4.0 * dir, c.y - 5.0), pos2(x + 4.0 * dir, c.y), pos2(x - 4.0 * dir, c.y + 5.0)], ink, Stroke::NONE)
};
let bar = |x: f32| egui::Shape::rect_filled(egui::Rect::from_center_size(pos2(x, c.y), vec2(2.0, 10.0)), 0.0, ink);
match what {
Transport::Play => drop(p.add(tri(1.0, c.x + 1.0))),
Transport::Stop => drop(p.rect_filled(egui::Rect::from_center_size(c, vec2(9.0, 9.0)), 1.0, ink)),
Transport::Back => {
p.add(bar(c.x + 4.0));
p.add(tri(-1.0, c.x - 1.0));
}
Transport::Forward => {
p.add(bar(c.x - 4.0));
p.add(tri(1.0, c.x + 1.0));
}
Transport::Start => {
p.add(bar(c.x - 6.0));
p.add(tri(-1.0, c.x - 0.5));
p.add(tri(-1.0, c.x + 5.0));
}
Transport::End => {
p.add(tri(1.0, c.x - 5.0));
p.add(tri(1.0, c.x + 0.5));
p.add(bar(c.x + 6.0));
}
}
resp.on_hover_text(tip)
}
/// The study command's parameters.
pub fn study_params(s: &Session, name: &str, study: Option<&str>, steps: &str, tracks: &[Track]) -> Result<Value, String> {
let steps: u32 = steps.trim().parse().map_err(|_| "steps must be a whole number".to_string())?;
@@ -321,7 +394,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
ui.end_row();
}
}
Mo::MotionStudy { name, study, steps, tracks, step, playing, shown, saved, error } => {
Mo::MotionStudy { name, study, steps, tracks, step, playing, looping, speed, shown, saved, error } => {
row_label(ui, "Name");
ui.text_edit_singleline(name);
ui.end_row();
@@ -336,19 +409,54 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
ui.end_row();
return enter;
}
// Playback.
row_label(ui, "Step");
// Playback: to start, step back, play/stop, step on, to end; loop and speed.
row_label(ui, "Playback");
ui.horizontal(|ui| {
if ui.button(if *playing { "■" } else { "▶" }).on_hover_text(if *playing { "Stop" } else { "Play" }).clicked() {
let last = f64::from(total);
if transport(ui, Transport::Start, "To the start").clicked() {
*step = 0.0;
*playing = false;
}
if transport(ui, Transport::Back, "Step back").clicked() {
*step = (step.round() - 1.0).max(0.0);
*playing = false;
}
let (what, tip) = if *playing { (Transport::Stop, "Stop") } else { (Transport::Play, "Play") };
if transport(ui, what, tip).clicked() {
// Play from the start again when at the end.
if !*playing && step.round() >= last {
*step = 0.0;
}
*playing = !*playing;
}
ui.spacing_mut().slider_width = 220.0;
ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer());
if transport(ui, Transport::Forward, "Step on").clicked() {
*step = (step.round() + 1.0).min(last);
*playing = false;
}
if transport(ui, Transport::End, "To the end").clicked() {
*step = last;
*playing = false;
}
ui.add_space(6.0);
ui.checkbox(looping, "Loop");
let names: Vec<String> = SPEEDS.iter().map(|x| format!("{x}×")).collect();
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
combo(ui, "ms_speed", &refs, speed);
});
ui.end_row();
row_label(ui, "Step");
ui.horizontal(|ui| {
ui.spacing_mut().slider_width = 260.0;
if ui.add(egui::Slider::new(step, 0.0..=f64::from(total)).integer().show_value(false)).dragged() {
*playing = false;
}
ui.label(RichText::new(format!("{} / {total}", step.round() as u32)).color(t.text_dim));
});
ui.end_row();
if *playing {
let dt = ui.input(|i| i.stable_dt).min(0.1);
*step = (*step + f64::from(dt) * 24.0) % (f64::from(total) + 1.0);
let dt = f64::from(ui.input(|i| i.stable_dt).min(0.1));
let fps = STEPS_PER_S * SPEEDS.get(*speed).copied().unwrap_or(1.0);
(*step, *playing) = advance(*step, dt * fps, total, *looping);
ui.ctx().request_repaint();
}
// Tracks: a row per keyed joint value, its keys on a strip under the steps.
@@ -667,6 +775,15 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result<Vec<(Stri
mod tests {
use super::*;
/// Playback runs on, starts over at the end when looping, else stops on the last step.
#[test]
fn playback_advances_loops_and_stops() {
assert_eq!(advance(3.0, 2.0, 10, true), (5.0, true));
assert_eq!(advance(9.5, 2.0, 10, true), (0.5, true));
assert_eq!(advance(9.5, 2.0, 10, false), (10.0, false));
assert_eq!(advance(4.0, -3.0, 10, false), (4.0, true));
}
/// What a dialog shows goes away when it closes; an exploded view applied with OK stays.
#[test]
fn shown_poses_are_put_back() {
@@ -688,6 +805,8 @@ mod tests {
tracks: vec![],
step: 0.0,
playing: false,
looping: true,
speed: 2,
shown: None,
saved,
error: None,
+22 -2
View File
@@ -98,6 +98,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
/// Make tab `i` the active design.
pub fn switch(app: &mut SolveApp, i: usize) {
app.home.open = false;
if i == app.docs.active || i >= app.docs.slots.len() {
return;
}
@@ -293,6 +294,25 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Value {
/// + for a new design.
pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
let t = Tokens::get();
let home = Rect::from_min_size(pos2(x0, r.top() + 5.0), vec2(66.0, r.height() - 5.0));
let response = ui.interact(home, ui.id().with("home_tab"), Sense::click());
ui.painter().rect_filled(
home,
5.0,
if app.home.open {
t.toolbar
} else if response.hovered() {
t.hover
} else {
Color32::from_white_alpha(10)
},
);
ui.painter().text(home.center(), Align2::CENTER_CENTER, "Home", FontId::proportional(13.0), if app.home.open { t.text } else { t.app_bar_text });
crate::scenario::publish_handle("tab:home", home.center());
if response.clicked() {
app.home.show(app.session.revision);
}
let x0 = x0 + 70.0;
let info = tabs_info(app);
let n = info.len();
let (first, shown, w) = layout(n, app.docs.active, x1 - x0);
@@ -301,8 +321,9 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
let mut shut: Option<usize> = None;
for (i, (name, dirty)) in info.iter().enumerate().skip(first).take(shown) {
let tr = Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(w - 2.0, r.height() - 5.0));
let active = i == app.docs.active || app.docs.slots.is_empty();
let active = !app.home.open && (i == app.docs.active || app.docs.slots.is_empty());
let resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag());
crate::scenario::publish_handle(&format!("tab:design:{i}"), tr.center());
// The tabs are part of the title bar: dragging one moves the window, a double click
// maximizes it.
if app.custom_titlebar || app.integrated_titlebar {
@@ -389,7 +410,6 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
ui.painter().line_segment([c - vec2(0.0, 6.0), c + vec2(0.0, 6.0)], s);
if presp.on_hover_text("New Design (Ctrl+N)").clicked() {
new_design(app);
app.home.show(app.session.revision);
}
if let Some(i) = shut {
close(app, i, false);
+53 -19
View File
@@ -1,6 +1,6 @@
//! The start page: New Design, Open, the recent designs and the built-in samples, each with a
//! picture of the design rendered from it. Shown on launch without a file and from the house
//! button; it closes when a design is opened or started, or the design changes.
//! button or the first document tab; selecting a design returns to its view.
use std::collections::HashMap;
@@ -24,8 +24,7 @@ pub struct HomeState {
pub open: bool,
/// Recent design paths, newest first (kept in the preferences).
pub recent: Vec<String>,
/// The session revision when the page opened (a change closes it).
rev: u64,
pub opened_at: std::collections::BTreeMap<String, u64>,
thumbs: HashMap<String, Option<egui::TextureHandle>>,
samples: HashMap<usize, Session>,
/// Keys asked for and not made yet.
@@ -37,9 +36,8 @@ pub struct HomeState {
}
impl HomeState {
pub fn show(&mut self, rev: u64) {
pub fn show(&mut self, _rev: u64) {
self.open = true;
self.rev = rev;
}
/// Put a design at the top of the recent list.
@@ -47,6 +45,8 @@ impl HomeState {
self.recent.retain(|p| p != path);
self.recent.insert(0, path.to_string());
self.recent.truncate(MAX_RECENT);
self.opened_at.insert(path.to_string(), web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));
self.opened_at.retain(|p, _| self.recent.contains(p));
self.thumbs.remove(path);
}
}
@@ -179,9 +179,8 @@ pub fn open_sample(app: &mut SolveApp, i: usize) {
}
}
fn open_recent(app: &mut SolveApp, path: &str) {
pub fn open_recent(app: &mut SolveApp, path: &str) {
if !solvecraft_engine::io::vfs::exists(path) {
app.home.recent.retain(|p| p != path);
app.set_status(format!("{path} is no longer there"), true);
return;
}
@@ -191,10 +190,6 @@ fn open_recent(app: &mut SolveApp, path: &str) {
/// The page itself (in place of the toolbar, browser and viewport).
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.session.revision != app.home.rev {
app.home.open = false;
return;
}
poll(app, ui.ctx());
let t = Tokens::get();
let samples = solvecraft_engine::sample::samples();
@@ -261,6 +256,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
}
});
ui.add_space(24.0);
ui.horizontal_wrapped(|ui| {
ui.hyperlink_to("Documentation", crate::help::DOCS_URL);
ui.hyperlink_to("Report an Issue", "https://github.com/bherbruck/solvecraft/issues/new");
ui.hyperlink_to("What's new", "https://github.com/bherbruck/solvecraft/releases/latest");
if ui.link("Keyboard Shortcuts").clicked() {
app.keymap.open = true;
app.keymap.read_only = true;
}
if ui.link("Preferences").clicked() {
crate::prefs::open_at(app, 0);
}
});
ui.add_space(16.0);
section(ui, "Recent");
if recent.is_empty() {
ui.label(RichText::new("Designs you open or save appear here.").color(t.text_dim));
@@ -269,20 +277,46 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let Some(p) = recent.get(i) else { return };
let path = std::path::Path::new(p);
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
let folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
let missing = !solvecraft_engine::io::vfs::exists(p);
let sub = if missing {
let mut sub = if missing {
"missing".to_string()
} else if p.starts_with("/opfs/") {
// The web build keeps designs in the browser's own storage.
"in this browser".to_string()
} else {
folder
p.clone()
};
if card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
.on_hover_text(p.as_str())
.clicked()
{
if let Some(time) = app.home.opened_at.get(p) {
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(*time);
let seconds = now.saturating_sub(*time);
let ago = if seconds < 60 {
"just now".to_string()
} else if seconds < 3600 {
format!("{} min ago", seconds / 60)
} else if seconds < 86400 {
format!("{} h ago", seconds / 3600)
} else {
format!("{} days ago", seconds / 86400)
};
sub.push_str(&format!("\nOpened {ago}"));
}
let response = card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
.on_hover_text(p.as_str());
crate::scenario::publish_handle(&format!("home:recent:{i}"), response.rect.center());
response.context_menu(|ui| {
if ui.button("Remove from list").clicked() {
app.home.recent.retain(|x| x != p);
app.home.opened_at.remove(p);
ui.close();
}
if !cfg!(target_arch = "wasm32") && ui.button("Show in folder").clicked() {
if let Some(show) = &app.services.show_in_folder {
show(p);
}
ui.close();
}
});
if response.clicked() {
let p = p.clone();
action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p)));
}
@@ -340,7 +374,7 @@ enum Pic<'a> {
/// A design card: its picture (a spinner while it renders), name and a line under it.
fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response {
let t = Tokens::get();
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 62.0);
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 80.0);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let hover = resp.hovered();
ui.painter().rect(
+23 -1
View File
@@ -155,6 +155,7 @@ impl Default for UiState {
pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
pub show_in_folder: Option<Box<dyn Fn(&str)>>,
/// Where the host reads the anti-aliasing preference at start (a small JSON file).
pub graphics_path: Option<String>,
}
@@ -304,6 +305,7 @@ impl SolveApp {
"browser_collapsed": self.tree.collapsed,
"browser_expanded": self.tree.expanded,
"recent": self.home.recent,
"recent_opened_at": self.home.opened_at,
"shortcut_box": self.sbox.prefs(),
"shortcuts": self.keymap.prefs(),
"toolbar": self.toolbar_custom.prefs(),
@@ -319,6 +321,7 @@ impl SolveApp {
pub fn load_prefs(&mut self, prefs: &str) {
let Ok(v) = serde_json::from_str::<Value>(prefs) else { return };
let flag = |k: &str| v.get(k).and_then(Value::as_bool);
self.home.opened_at = v.get("recent_opened_at").and_then(|x| serde_json::from_value(x.clone()).ok()).unwrap_or_default();
if let Some(d) = flag("dark") {
self.ui.dark = d;
}
@@ -430,6 +433,12 @@ impl SolveApp {
}
self.tool = None;
self.home.open = false;
if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") {
if let Some(path) = self.services.pick_open.as_ref().and_then(|f| f()) {
let _ = self.run(id, json!({"path": path}));
}
return;
}
if matches!(id, "file.insert_step" | "file.insert_mesh") {
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
self.insert_path(&p);
@@ -480,7 +489,20 @@ impl SolveApp {
{
self.last_command = Some((spec.id.to_string(), spec.label.to_string()));
}
if let Some(t) = tools::Tool::for_command(id) {
if let Some(mut t) = tools::Tool::for_command(id) {
if id == "sketch.dimension" {
t.picks = self
.session
.selection
.iter()
.filter_map(|s| match s {
solvecraft_engine::Sel::SketchCurve { id } | solvecraft_engine::Sel::SketchPoint { id } => Some(id.clone()),
_ => None,
})
.take(2)
.collect();
let _ = self.session.execute("select.clear", &json!({}));
}
self.tool = Some(t);
self.set_status(tools::hint(id), false);
return;
+4 -2
View File
@@ -232,8 +232,10 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() {
app.run("edit.undo", json!({})).unwrap();
assert_eq!(app.session.doc.features.len(), n);
// A face its neighbours can't close (a side of the plate): refused, nothing changes.
let b0 = app.session.model.state().bodies[0].name.clone();
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": 0, "point": [0, 0, 0]}]})).unwrap();
let body = &app.session.model.state().bodies[0];
let face = body.body.faces(0.01).unwrap().into_iter().find(|f| f.plane_normal.is_some_and(|n| n.x < -0.99)).unwrap();
let b0 = body.name.clone();
app.run("select.set", json!({"items": [{"type": "face", "body": b0, "index": face.index, "point": face.centroid}]})).unwrap();
crate::delete::delete_selection(&mut app);
assert!(app.status.as_ref().is_some_and(|s| s.0.contains("Delete Face")), "{:?}", app.status);
assert_eq!(app.session.doc.features.len(), n);
+2 -2
View File
@@ -68,7 +68,7 @@ pub fn gpu_images(app: &SolveApp) -> Vec<GpuImage> {
.iter()
.filter(|c| c.visible && c.opacity > 0.0)
.filter_map(|c| {
let plane = doc.resolve_plane(&vals, &c.plane, 0).ok()?;
let plane = doc.canvas_plane(&vals, c).ok()?;
let (_, version, size, rgba) = pixels(c)?;
let corners = c.world_corners(&plane).map(|p| [p.x as f32, p.y as f32, p.z as f32]);
Some(GpuImage { id: c.id, version, size, rgba, corners, opacity: c.opacity.clamp(0.0, 1.0) as f32 })
@@ -105,7 +105,7 @@ pub fn show(app: &SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj)
}
let (vals, _) = doc.param_values();
for c in doc.canvases.iter().filter(|c| c.visible) {
let Ok(plane) = doc.resolve_plane(&vals, &c.plane, 0) else { continue };
let Ok(plane) = doc.canvas_plane(&vals, c) else { continue };
let Some(tex) = texture(ui.ctx(), c) else { continue };
let [bl, br, _, tl] = c.corners();
let (u, v) = (br - bl, tl - bl);
+9
View File
@@ -245,6 +245,15 @@ impl Harness {
}
pub fn call(&mut self, method: &str, params: Value) -> Value {
if method == "ui.resize"
&& let (Some(w), Some(h)) = (params["width"].as_f64(), params["height"].as_f64())
&& w.is_finite()
&& h.is_finite()
&& w > 0.0
&& h > 0.0
{
self.size = egui::vec2(w as f32, h as f32);
}
let (req, _rx) = ControlRequest::new(method, params);
let out = match handle(&mut self.app, &self.ctx, &req) {
Outcome::Done(v) => v,
+53 -1
View File
@@ -54,6 +54,7 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let br = Rect::from_center_size(pos2(x, r.center().y), vec2(26.0, 26.0));
x += 30.0;
let resp = ui.interact(br, ui.id().with(("ab", icon)), Sense::click());
crate::scenario::publish_handle(&format!("appbar:{icon}"), br.center());
if resp.hovered() {
ui.painter().rect_filled(br, 4.0, Color32::from_white_alpha(30));
}
@@ -148,8 +149,11 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
.show(ctx, |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(190.0);
ui.visuals_mut().widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE;
ui.visuals_mut().widgets.hovered.weak_bg_fill = Tokens::get().hover;
let item = |ui: &mut egui::Ui, label: &str, key: &str| {
ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked()
ui.add(egui::Button::new(label).shortcut_text(key).min_size(vec2(180.0, 22.0))).clicked()
};
if item(ui, "New Design", "Ctrl+N") {
crate::documents::new_design(app);
@@ -161,6 +165,24 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
}
close = true;
}
ui.menu_button("Open Recent", |ui| {
for path in app.home.recent.clone() {
let missing = !solvecraft_engine::io::vfs::exists(&path);
let label = format!("{path}{}", if missing { " (missing)" } else { "" });
if ui.button(label).clicked() {
crate::home::open_recent(app, &path);
close = true;
ui.close();
}
}
ui.separator();
if ui.button("Clear Recent").clicked() {
app.home.recent.clear();
app.home.opened_at.clear();
close = true;
ui.close();
}
});
if item(ui, "Insert STEP…", "") {
app.start("file.insert_step");
close = true;
@@ -308,11 +330,16 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
let top = r.top() + 26.0;
let mut drop: Option<(&'static str, &'static str, Option<&'static str>)> = None;
let mut menu: Option<(egui::Pos2, &'static str)> = None;
let mut overflow = Vec::new();
for (panel, cmds, promote) in crate::toolbar_custom::panel_lists(&tab) {
let panel: &&str = &panel;
let shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote);
let n = shown.len().max(1);
let width = (n as f32 * 40.0).max(64.0) + 8.0;
if !overflow.is_empty() || px + width > r.right() - 40.0 {
overflow.push((*panel, cmds));
continue;
}
let enabled_panel = !cmds.is_empty() || *panel == "SELECT";
// Dropped on the panel's free space: the button goes to its end.
let free = Rect::from_min_size(
@@ -433,6 +460,31 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
painter.line_segment([pos2(px, top + 4.0), pos2(px, top + 58.0)], Stroke::new(1.0, t.border));
px += 2.0;
}
if !overflow.is_empty() {
let at = Rect::from_min_size(pos2(px.min(r.right() - 40.0), top + 4.0), vec2(38.0, 52.0));
ui.scope_builder(egui::UiBuilder::new().max_rect(at), |ui| {
let response = ui.menu_button("»", |ui| {
for (panel, cmds) in &overflow {
ui.menu_button(*panel, |ui| {
if *panel == "SELECT" {
selection_filter(app, ui);
}
for c in cmds {
let info = c.info(&app.session);
let response = ui.add_enabled(info.enabled, egui::Button::new(c.label).shortcut_text(c.shortcut.unwrap_or("")));
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), response.rect.center());
if response.clicked() {
app.start(c.id);
ui.close();
}
}
});
}
});
crate::scenario::publish_handle("toolbar:overflow", response.response.rect.center());
response.response.on_hover_text("More toolbar panels");
});
}
painter.line_segment([pos2(r.left(), r.bottom() - 0.5), pos2(r.right(), r.bottom() - 0.5)], Stroke::new(1.0, t.border));
if let Some((id, panel, before)) = drop {
crate::toolbar_custom::reorder(app, &tab, id, panel, before);
+13 -23
View File
@@ -80,7 +80,7 @@ pub fn hint(id: &str) -> String {
"sketch.circle.center" => "Circle: click the centre, then a point on the circle".into(),
"sketch.arc.three_point" => "Arc: click start, end, then a point on the arc".into(),
"sketch.arc.center_point" => "Arc: click centre, start, then end (counter-clockwise)".into(),
"sketch.dimension" => "Dimension: pick a line, circle or arc (or two points/lines)".into(),
"sketch.dimension" => "Dimension: pick one or two entities, then click to place the value".into(),
"sketch.slot.center_to_center" | "sketch.slot.overall" => "Slot: click both ends, then the width".into(),
_ if id.starts_with("sketch.constraint.") => "Constraint: pick the sketch entities".into(),
_ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()),
@@ -142,31 +142,21 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
Kind::Dimension => {
let hits = pick(app, proj, pos);
let hit = hits.iter().find_map(|h| match h {
Hit::SketchPoint { id, .. } => Some((id.clone(), true)),
Hit::SketchCurve { id, .. } => Some((id.clone(), false)),
Hit::SketchPoint { id, .. } | Hit::SketchCurve { id, .. } => Some(id.clone()),
_ => None,
});
if let Some((id, is_point)) = hit {
tool.picks.push(id.clone());
let st = app.session.world_state();
let is_line = app
.session
.active_sketch
.and_then(|s| st.sketch(s))
.and_then(|ss| ss.sketch.curve_index(&id).and_then(|c| ss.sketch.curves.get(c).cloned()))
.map(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. }));
let ready = (tool.picks.len() == 1 && !is_point && is_line != Some(true)) || tool.picks.len() >= 2;
let single_line = tool.picks.len() == 1 && is_line == Some(true);
if ready || single_line {
// A single line gets its length right away; a second pick (line or point) replaces it.
if let Ok(v) = app.run("sketch.dimension", json!({"entities": tool.picks}))
&& let Some(p) = v["param"].as_str()
{
// The value is edited in place, selected so typing replaces it.
crate::dim_view::edit_param(app, p);
}
tool.picks.clear();
if let Some(id) = hit {
if !tool.picks.contains(&id) && tool.picks.len() < 2 {
tool.picks.push(id);
}
} 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);
}
tool.picks.clear();
}
}
}
+1
View File
@@ -1682,6 +1682,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
app.viewport.boxsel = None;
}
crate::ref_images::show(app, ui, &painter, &proj);
crate::dialogs_assembly::origins(app, &painter, &proj);
overlays(app, &painter, &proj);
crate::dim_view::show(app, ui, &painter, &proj);
crate::sketch3d::show(app, &painter, &proj);