diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index e2105d22..721d0f95 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -128,6 +128,12 @@ fn main() -> eframe::Result { for f in &files { app.open_path(f); } + // No design given: the start page (not when driven over the control channel, whose + // clients expect the design view; `ui.home` shows it there). + if files.is_empty() && !sample && control_port.is_none() { + let rev = app.session.revision; + app.home.show(rev); + } Ok(Box::new(App(app))) }), ) diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index ca3a6f42..f3e47cac 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -31,3 +31,125 @@ pub fn script() -> Value { {"command": "Extrude", "params": {"distance": 22, "direction": "negative", "operation": "cut", "name": "Bore"}}, ]}) } + +/// A built-in sample design: its name, a line about what it shows, and the script that builds it. +pub struct Sample { + pub name: &'static str, + pub about: &'static str, + pub script: fn() -> Value, +} + +/// The built-in samples (the start page lists them), each built through commands. +pub fn samples() -> Vec { + vec![ + Sample { name: "Sample Plate", about: "Parametric plate with fillets, holes and a bored boss", script }, + Sample { name: "Sheet Metal Bracket", about: "Base flange with edge flanges and a hem on a 2 mm rule", script: bracket }, + Sample { name: "Hinge Assembly", about: "Two components and a revolute joint, driven to 60°", script: hinge }, + Sample { name: "Plastic Enclosure", about: "Shelled box with a lip, screw bosses, a rest and a snap fit", script: enclosure }, + Sample { name: "Bolted Flange", about: "Hub and flange with a circular pattern driven by a parameter", script: flange }, + Sample { name: "Vent Plate", about: "Slots in a rectangular pattern: rows, columns and pitch are parameters", script: vents }, + ] +} + +fn bracket() -> Value { + json!({"commands": [ + {"command": "FusionSheetMetalRulesCommand", "params": {"name": "Steel 2mm", "thickness": "2 mm", "bend_radius": "Thickness", "active": true}}, + {"command": "SketchCreate", "params": {"plane": "XY", "name": "Base"}}, + {"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [120, 60]}}, + {"command": "SketchStop", "params": {}}, + {"command": "FusionSheetMetalFlangeCommand", "params": {"sketch": "Base"}}, + {"command": "FusionSheetMetalFlangeCommand", "params": {"edges": [[60, 0, 2], [60, 60, 2]], "height": 30}}, + {"command": "FusionSheetMetalFlangeCommand", "params": {"edges": [[120, 30, 2]], "height": 20, "angle": "60 deg"}}, + {"command": "FusionSheetMetalHemFlangeCommand", "params": {"edges": [[0, 30, 2]], "length": 8}}, + ]}) +} + +fn hinge() -> Value { + let mut cmds = Vec::new(); + for name in ["Leaf A", "Leaf B"] { + cmds.extend([ + json!({"command": "component.activate", "params": {"component": "root"}}), + json!({"command": "FusionCreateNewComponentCommand", "params": {"name": name}}), + json!({"command": "PrimitiveBox", "params": {"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]}}), + json!({"command": "PrimitiveCylinder", "params": {"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"}}), + ]); + } + cmds.extend([ + json!({"command": "component.activate", "params": {"component": "root"}}), + json!({"command": "occurrence.ground", "params": {"occurrence": "Leaf A:1", "grounded": true}}), + json!({"command": "JointAssembleCmdNew", "params": {"type": "revolute", "name": "Hinge", + "a": {"occurrence": "Leaf A:1", "circle": [1, 37, 4]}, "b": {"occurrence": "Leaf B:1", "circle": [1, 37, 4]}, + "limits": [["0 deg", "180 deg"]]}}), + json!({"command": "FusionMoveJointsCommand", "params": {"joint": "Hinge", "value": 60}}), + ]); + json!({ "commands": cmds }) +} + +fn enclosure() -> Value { + json!({"commands": [ + {"command": "PrimitiveBox", "params": {"length": 80, "width": 50, "height": 30, "body_name": "Enclosure"}}, + {"command": "FusionShellBodyCommand", "params": {"faces": [[40, 25, 30]], "thickness": 2}}, + {"command": "FusionLipCommand", "params": {"face": [1, 25, 30], "width": 1, "height": 2}}, + {"command": "FusionSnapFitCommand", "params": {"position": [40, 44, 2], "hook": [0, -1, 0], "length": 14, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 2}}, + {"command": "FusionBossCommand", "params": {"position": [10, 10, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}}, + {"command": "FusionBossCommand", "params": {"position": [70, 40, 2], "diameter": 7, "height": 12, "hole_diameter": 2.5}}, + {"command": "FusionRestCommand", "params": {"position": [40, 25, 2], "width": 16, "length": 24, "height": 3}}, + ]}) +} + +fn flange() -> Value { + json!({"commands": [ + {"command": "ChangeParameterCommand", "params": {"name": "bolts", "expression": "6", "unit": "", "comment": "number of bolt holes"}}, + {"command": "ChangeParameterCommand", "params": {"name": "pcd", "expression": "70 mm", "comment": "bolt circle diameter"}}, + {"command": "SketchCreate", "params": {"plane": "XY", "name": "Disc"}}, + {"command": "CircleCenterRadius", "params": {"center": [0, 0], "diameter": 100}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": 10, "body_name": "Flange", "name": "Disc"}}, + {"command": "SketchCreate", "params": {"plane": {"face": [0, 0, 10]}, "name": "Hub"}}, + {"command": "CircleCenterRadius", "params": {"center": [0, 0], "diameter": 44}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": 16, "operation": "join", "name": "Hub"}}, + {"command": "SketchCreate", "params": {"plane": "XY", "name": "Bore"}}, + {"command": "CircleCenterRadius", "params": {"center": [0, 0], "diameter": 24}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": 26, "operation": "cut", "name": "Bore"}}, + {"command": "SketchCreate", "params": {"plane": "XY", "name": "Bolt"}}, + {"command": "CircleCenterRadius", "params": {"center": [35, 0], "diameter": 9}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": 10, "operation": "cut", "name": "BoltHole"}}, + {"command": "PatternCircular", "params": {"features": ["BoltHole"], "axis": "Z", "count": "bolts"}}, + ]}) +} + +fn vents() -> Value { + json!({"commands": [ + {"command": "ChangeParameterCommand", "params": {"name": "cols", "expression": "6", "unit": ""}}, + {"command": "ChangeParameterCommand", "params": {"name": "rows", "expression": "3", "unit": ""}}, + {"command": "ChangeParameterCommand", "params": {"name": "pitch", "expression": "16 mm", "comment": "slot spacing"}}, + {"command": "PrimitiveBox", "params": {"length": 120, "width": 80, "height": 4, "body_name": "Plate"}}, + {"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 2], [120, 0, 2], [120, 80, 2], [0, 80, 2]], "radius": "8 mm"}}, + {"command": "SketchCreate", "params": {"plane": "XY", "name": "Slot"}}, + {"command": "ShapeRectangleTwoPoint", "params": {"p0": [18, 12], "p1": [24, 26]}}, + {"command": "SketchStop", "params": {}}, + {"command": "Extrude", "params": {"distance": 4, "operation": "cut", "name": "Vent"}}, + {"command": "PatternRectangular", "params": {"features": ["Vent"], "dir1": [1, 0, 0], "count1": "cols", "spacing1": "pitch", + "dir2": [0, 1, 0], "count2": "rows", "spacing2": "22 mm"}}, + ]}) +} + +#[cfg(test)] +mod tests { + /// Every built-in sample builds without a failing feature or joint. + #[test] + fn samples_build_cleanly() { + for s in super::samples() { + let mut session = crate::Session::default(); + if let Err(e) = session.run_script(&(s.script)()) { + panic!("{}: {e}", s.name); + } + let failed: Vec = session.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| format!("{}: {e}", r.name))).collect(); + assert!(failed.is_empty(), "{}: {failed:?}", s.name); + assert!(!session.model.state().bodies.is_empty(), "{}", s.name); + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1d2bcac0..731adf5b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -104,7 +104,24 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> "frame_ms": app.frame_ms, "build_ms": app.viewport.build_ms, "preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced}, + "home": app.home.open, + "documents": app.docs.count(), })), + "ui.documents" => ok(crate::documents::control(app, p)), + "ui.home" => { + match p.get("open").and_then(Value::as_bool) { + Some(true) => { + let rev = app.session.revision; + app.home.show(rev); + } + Some(false) => app.home.open = false, + None => {} + } + if let Some(i) = p.get("sample").and_then(Value::as_u64) { + crate::home::open_sample(app, i as usize); + } + ok(json!({"open": app.home.open, "recent": app.home.recent})) + } "ui.set" => { let mut cur = serde_json::to_value(&app.ui).unwrap_or(json!({})); if let (Some(o), Some(src)) = (cur.as_object_mut(), p.as_object()) { diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs new file mode 100644 index 00000000..e00ee11d --- /dev/null +++ b/crates/ui-egui/src/documents.rs @@ -0,0 +1,430 @@ +//! Several open designs, as tabs in the application bar. The active design lives in the app as +//! always (`app.session`, `app.cam`…); the others wait here with their own session (undo, +//! selection), camera and per-design view state, and are swapped in when their tab is picked. +//! Closing a design with unsaved changes asks first. + +use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2}; +use serde_json::{Value, json}; +use solvecraft_engine::Session; +use solvecraft_engine::render::Camera; + +use crate::SolveApp; +use crate::theme::Tokens; + +/// Most designs open at once. +pub const MAX_OPEN: usize = 32; + +/// A design that is not the active one: everything that belongs to it alone. +pub struct DocSlot { + session: Session, + cam: Camera, + pre_sketch_cam: Option, + pre_sketch_marker: Option>, + view: DocView, +} + +/// The per-design part of the UI state (what is hidden, shown or locked). +#[derive(Clone, Debug, Default)] +struct DocView { + hidden_bodies: Vec, + hidden_origin: Vec, + hidden_sketches: Vec, + shown_sketches: Vec, + hidden_profiles: Vec, + shown_dims: Vec, + locked_bodies: Vec, +} + +impl DocSlot { + fn new(session: Session) -> DocSlot { + DocSlot { session, cam: Camera::default(), pre_sketch_cam: None, pre_sketch_marker: None, view: DocView::default() } + } +} + +/// The open designs: one slot per tab; the active tab's slot is empty (its design is in the app). +#[derive(Default)] +pub struct Documents { + slots: Vec>, + pub active: usize, + /// A tab waiting for the answer to "save changes?". + pub closing: Option, +} + +impl Documents { + /// Number of open designs (at least the active one). + pub fn count(&self) -> usize { + self.slots.len().max(1) + } +} + +fn take_view(app: &mut SolveApp) -> DocView { + let u = &mut app.ui; + DocView { + hidden_bodies: std::mem::take(&mut u.hidden_bodies), + hidden_origin: std::mem::take(&mut u.hidden_origin), + hidden_sketches: std::mem::take(&mut u.hidden_sketches), + shown_sketches: std::mem::take(&mut u.shown_sketches), + hidden_profiles: std::mem::take(&mut u.hidden_profiles), + shown_dims: std::mem::take(&mut u.shown_dims), + locked_bodies: std::mem::take(&mut u.locked_bodies), + } +} + +fn put_view(app: &mut SolveApp, v: DocView) { + let u = &mut app.ui; + u.hidden_bodies = v.hidden_bodies; + u.hidden_origin = v.hidden_origin; + u.hidden_sketches = v.hidden_sketches; + u.shown_sketches = v.shown_sketches; + u.hidden_profiles = v.hidden_profiles; + u.shown_dims = v.shown_dims; + u.locked_bodies = v.locked_bodies; +} + +/// Put `slot` into the app and return what was there. +fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { + // Whatever was going on in the design leaves with it. + crate::dialogs::cancel(app); + app.tool = None; + app.params = None; + app.cam_anim = None; + app.preview = crate::preview::PreviewState::default(); + app.viewport.hover = None; + let out_rev = app.session.revision; + let out = DocSlot { + session: std::mem::replace(&mut app.session, slot.session), + cam: std::mem::replace(&mut app.cam, slot.cam), + pre_sketch_cam: std::mem::replace(&mut app.pre_sketch_cam, slot.pre_sketch_cam), + pre_sketch_marker: std::mem::replace(&mut app.pre_sketch_marker, slot.pre_sketch_marker), + view: take_view(app), + }; + put_view(app, slot.view); + // Views cache by revision: the incoming design must not look unchanged. + app.session.revision = app.session.revision.max(out_rev) + 1; + app.ui.tab = if app.session.active_sketch.is_some() { "SKETCH".into() } else { "SOLID".into() }; + out +} + +/// Make tab `i` the active design. +pub fn switch(app: &mut SolveApp, i: usize) { + if i == app.docs.active || i >= app.docs.slots.len() { + return; + } + let Some(incoming) = app.docs.slots.get_mut(i).and_then(Option::take) else { return }; + let out = swap_in(app, incoming); + let a = app.docs.active; + if let Some(s) = app.docs.slots.get_mut(a) { + *s = Some(out); + } + app.docs.active = i; +} + +/// Open `session` in a new tab after the active one and switch to it (`fit`: frame its model). +pub fn open_tab(app: &mut SolveApp, session: Session) -> Result<(), String> { + if app.docs.slots.is_empty() { + app.docs.slots.push(None); + app.docs.active = 0; + } + if app.docs.slots.len() >= MAX_OPEN { + return Err(format!("at most {MAX_OPEN} designs can be open")); + } + let at = app.docs.active + 1; + app.docs.slots.insert(at, Some(DocSlot::new(session))); + switch(app, at); + app.fit_view(); + Ok(()) +} + +/// Is the active design an untouched new one (it can be replaced instead of opening a tab)? +pub fn active_is_blank(app: &SolveApp) -> bool { + let s = &app.session; + s.path.is_none() && s.doc.features.is_empty() && s.doc.params.is_empty() && s.doc.components.is_empty() && !s.is_dirty() +} + +/// Show `session` (a design built or read elsewhere): in place of a blank active design, +/// otherwise in a new tab. +pub fn adopt(app: &mut SolveApp, session: Session) { + if active_is_blank(app) { + let rev = app.session.revision; + crate::dialogs::cancel(app); + app.tool = None; + app.session = session; + app.session.revision = app.session.revision.max(rev) + 1; + app.cam = Camera::default(); + app.fit_view(); + } else if let Err(e) = open_tab(app, session) { + app.set_status(e, true); + } +} + +/// A new, empty design in its own tab. +pub fn new_design(app: &mut SolveApp) { + app.home.open = false; + if active_is_blank(app) { + return; + } + if let Err(e) = open_tab(app, Session::default()) { + app.set_status(e, true); + } +} + +/// Before opening a file: a fresh tab unless the active design is blank. +pub fn prepare_open(app: &mut SolveApp) { + if !active_is_blank(app) + && let Err(e) = open_tab(app, Session::default()) + { + app.set_status(e, true); + } +} + +/// Close tab `i`: asks first when it has unsaved changes (`force`: close anyway). +pub fn close(app: &mut SolveApp, i: usize, force: bool) { + let n = app.docs.count(); + if i >= n { + return; + } + if !force && dirty(app, i) { + app.docs.closing = Some(i); + return; + } + app.docs.closing = None; + if n <= 1 { + // The last design: a blank one takes its place. + let rev = app.session.revision; + crate::dialogs::cancel(app); + app.tool = None; + app.session = Session::default(); + app.session.revision = rev + 1; + app.cam = Camera::default(); + app.fit_view(); + put_view(app, DocView::default()); + app.docs = Documents::default(); + app.home.open = true; + return; + } + if i == app.docs.active { + // Show a neighbour first, then drop this one. + let next = if i + 1 < n { i + 1 } else { i - 1 }; + switch(app, next); + } + app.docs.slots.remove(i); + if app.docs.active > i { + app.docs.active -= 1; + } +} + +fn dirty(app: &SolveApp, i: usize) -> bool { + if i == app.docs.active || app.docs.slots.is_empty() { + return app.session.is_dirty(); + } + app.docs.slots.get(i).and_then(Option::as_ref).is_some_and(|s| s.session.is_dirty()) +} + +/// Tab names (with an unsaved-changes flag), in order. +pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> { + if app.docs.slots.is_empty() { + return vec![(app.session.doc.name.clone(), app.session.is_dirty())]; + } + app.docs + .slots + .iter() + .map(|s| match s { + Some(d) => (d.session.doc.name.clone(), d.session.is_dirty()), + None => (app.session.doc.name.clone(), app.session.is_dirty()), + }) + .collect() +} + +/// The control channel's `ui.documents`: list, `new`, `switch` or `close` (by index; `force` +/// closes without asking). +pub fn control(app: &mut SolveApp, p: &Value) -> Value { + let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize); + match p.get("action").and_then(Value::as_str) { + Some("new") => new_design(app), + Some("switch") => switch(app, i.unwrap_or(app.docs.active)), + Some("close") => close(app, i.unwrap_or(app.docs.active), p.get("force").and_then(Value::as_bool).unwrap_or(false)), + _ => {} + } + let tabs: Vec = tabs_info(app).into_iter().map(|(name, dirty)| json!({"name": name, "dirty": dirty})).collect(); + json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing}) +} + +/// The document tabs in the application bar, from `x0` to `x1`: click to switch, × to close, +/// + 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 info = tabs_info(app); + let n = info.len(); + let avail = (x1 - x0 - 34.0).max(80.0); + let w = (avail / n as f32).clamp(90.0, 220.0); + let mut x = x0; + let mut pick: Option = None; + let mut shut: Option = None; + for (i, (name, dirty)) in info.iter().enumerate() { + 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 resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag()); + // 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 { + if resp.drag_started_by(egui::PointerButton::Primary) { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } else if resp.double_clicked() { + crate::titlebar::toggle_maximize(ui.ctx()); + } + } + let fill = if active { + t.toolbar + } else if resp.hovered() { + Color32::from_white_alpha(28) + } else { + Color32::from_white_alpha(10) + }; + ui.painter().rect_filled(tr, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, fill); + let ink = if active { t.text } else { t.app_bar_text }; + let label = format!("{name}{}", if *dirty { " •" } else { "" }); + let g = ui.painter().layout_no_wrap(label, FontId::proportional(13.0), ink); + let text_w = (tr.width() - 30.0).max(10.0); + let clip = Rect::from_min_size(tr.min, vec2(text_w + 10.0, tr.height())); + ui.painter().with_clip_rect(clip).galley(pos2(tr.left() + 10.0, tr.center().y - g.size().y / 2.0), g, ink); + // × + let xr = Rect::from_center_size(pos2(tr.right() - 13.0, tr.center().y), vec2(16.0, 16.0)); + let xresp = ui.interact(xr, ui.id().with(("doc_close", i)), Sense::click()); + if xresp.hovered() { + ui.painter().rect_filled(xr, 3.0, Color32::from_white_alpha(40)); + } + let c = xr.center(); + let s = Stroke::new(1.3, if active { t.text_dim } else { t.app_bar_text }); + ui.painter().line_segment([c - vec2(3.5, 3.5), c + vec2(3.5, 3.5)], s); + ui.painter().line_segment([c - vec2(3.5, -3.5), c + vec2(3.5, -3.5)], s); + if xresp.on_hover_text("Close").clicked() { + shut = Some(i); + } else if resp.on_hover_text(name.as_str()).clicked() { + pick = Some(i); + } + x += w; + } + // New design. + let pr = Rect::from_center_size(pos2(x + 14.0, r.center().y + 2.0), vec2(22.0, 22.0)); + let presp = ui.interact(pr, ui.id().with("doc_new"), Sense::click()); + if presp.hovered() { + ui.painter().rect_filled(pr, 4.0, Color32::from_white_alpha(30)); + } + let c = pr.center(); + let s = Stroke::new(1.6, t.app_bar_text); + ui.painter().line_segment([c - vec2(6.0, 0.0), c + vec2(6.0, 0.0)], s); + 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); + } + if let Some(i) = shut { + close(app, i, false); + } else if let Some(i) = pick { + switch(app, i); + } +} + +/// The "save changes?" prompt for a tab being closed. +pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) { + let Some(i) = app.docs.closing else { return }; + let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default(); + let mut answer: Option = None; + egui::Window::new("Save changes?") + .id(egui::Id::new("sc_close_prompt")) + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.label(format!("{name} has unsaved changes.")); + ui.add_space(8.0); + ui.horizontal(|ui| { + if ui.button("Save").clicked() { + answer = Some(0); + } + if ui.button("Don't Save").clicked() { + answer = Some(1); + } + if ui.button("Cancel").clicked() { + answer = Some(2); + } + }); + }); + if ctx.input(|x| x.key_pressed(egui::Key::Escape)) { + answer = Some(2); + app.esc_handled = true; + } + match answer { + Some(0) => { + switch(app, i); + crate::toolbar::save(app); + // Saved (not cancelled in the file picker): close it. + if !app.session.is_dirty() { + close(app, app.docs.active, true); + } else { + app.docs.closing = None; + } + } + Some(1) => close(app, i, true), + Some(2) => app.docs.closing = None, + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Services; + + fn app() -> SolveApp { + SolveApp::new(Session::default(), Services::default()) + } + + /// Each tab keeps its own design, undo, selection and camera; closing asks when there are + /// unsaved changes; the last tab closing leaves a blank design. + #[test] + fn tabs_keep_their_own_design() { + let mut a = app(); + a.run("PrimitiveBox", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + a.session.selection = vec![solvecraft_engine::Sel::Body { name: "Body1".into() }]; + a.cam.distance = 123.0; + new_design(&mut a); + assert_eq!(a.docs.count(), 2); + assert_eq!(a.docs.active, 1); + assert!(a.session.doc.features.is_empty() && a.session.selection.is_empty()); + a.run("PrimitiveSphere", json!({"diameter": 8})).unwrap(); + a.run("PrimitiveSphere", json!({"diameter": 4})).unwrap(); + switch(&mut a, 0); + assert_eq!(a.session.doc.features.len(), 1); + assert_eq!(a.session.selection.len(), 1); + assert!((a.cam.distance - 123.0).abs() < 1e-9); + a.run("UndoCommand", json!({})).unwrap(); + assert!(a.session.doc.features.is_empty(), "undo is this design's"); + switch(&mut a, 1); + assert_eq!(a.session.doc.features.len(), 2, "the other design kept its features"); + // Unsaved: closing asks, Don't Save closes. + close(&mut a, 1, false); + assert_eq!(a.docs.closing, Some(1)); + assert_eq!(a.docs.count(), 2); + close(&mut a, 1, true); + assert_eq!(a.docs.count(), 1); + assert_eq!(a.docs.active, 0); + close(&mut a, 0, true); + assert!(active_is_blank(&a) && a.home.open); + } + + /// A design built elsewhere replaces a blank one and gets a tab otherwise. + #[test] + fn adopt_reuses_a_blank_design() { + let mut a = app(); + let mut s = Session::default(); + s.run_script(&solvecraft_engine::sample::script()).unwrap(); + adopt(&mut a, s); + assert_eq!(a.docs.count(), 1); + assert!(!a.session.doc.features.is_empty()); + adopt(&mut a, Session::default()); + assert_eq!(a.docs.count(), 2); + let v = control(&mut a, &json!({"action": "switch", "index": 0})); + assert_eq!(v["active"], 0); + assert_eq!(v["documents"].as_array().map(Vec::len), Some(2)); + } +} diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs new file mode 100644 index 00000000..5fa2768f --- /dev/null +++ b/crates/ui-egui/src/home.rs @@ -0,0 +1,387 @@ +//! 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. + +use std::collections::HashMap; + +use egui::{Color32, CornerRadius, RichText, Sense, Stroke, vec2}; +use solvecraft_engine::Session; +use solvecraft_engine::render::{Camera, StandardView}; + +use crate::SolveApp; +use crate::theme::Tokens; + +/// Recent designs kept. +pub const MAX_RECENT: usize = 12; +const THUMB: [usize; 2] = [240, 150]; + +/// What a background job makes: a key ("sample:N" or a path), the picture, and for samples the +/// built design (opened at once when picked). +type Made = (String, Option, Option); + +#[derive(Default)] +pub struct HomeState { + pub open: bool, + /// Recent design paths, newest first (kept in the preferences). + pub recent: Vec, + /// The session revision when the page opened (a change closes it). + rev: u64, + thumbs: HashMap>, + samples: HashMap, + /// Keys asked for and not made yet. + queued: Vec, + #[cfg(not(target_arch = "wasm32"))] + rx: Option>, + /// A sample picked before its design was ready. + wanted: Option, +} + +impl HomeState { + pub fn show(&mut self, rev: u64) { + self.open = true; + self.rev = rev; + } + + /// Put a design at the top of the recent list. + pub fn add_recent(&mut self, path: &str) { + self.recent.retain(|p| p != path); + self.recent.insert(0, path.to_string()); + self.recent.truncate(MAX_RECENT); + self.thumbs.remove(path); + } +} + +/// A picture of a design: iso view, fitted. +pub fn thumbnail(s: &Session) -> Option { + let mut cam = Camera::default(); + cam.set_view(StandardView::Iso); + let b = solvecraft_engine::view::bounds(s); + if b.is_empty() { + return None; + } + cam.fit(&b); + // Fill the card: the fit leaves room for a whole window's margins. + cam.distance *= 0.7; + let scene = solvecraft_engine::view::scene(s, &cam); + let c = solvecraft_engine::render::render(&scene, &cam, THUMB[0], THUMB[1]); + (c.rgba.len() == c.w * c.h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba)) +} + +/// Build what a key stands for (runs off the UI thread where threads exist). +fn make(key: &str) -> Made { + let mut s = Session::default(); + let built = match key.strip_prefix("sample:").and_then(|i| i.parse::().ok()) { + Some(i) => solvecraft_engine::sample::samples().get(i).is_some_and(|smp| { + let ok = s.run_script(&(smp.script)()).is_ok(); + s.doc_mut().name = smp.name.to_string(); + s.undo.clear(); + s.mark_saved(); + ok + }), + None => s.execute("doc.open", &serde_json::json!({ "path": key })).is_ok(), + }; + if !built { + return (key.to_string(), None, None); + } + let img = thumbnail(&s); + (key.to_string(), img, key.starts_with("sample:").then_some(s)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn start_jobs(h: &mut HomeState) { + if h.rx.is_some() || h.queued.is_empty() { + return; + } + let keys = std::mem::take(&mut h.queued); + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || { + for k in keys { + let made = std::panic::catch_unwind(|| make(&k)).unwrap_or((k, None, None)); + if tx.send(made).is_err() { + return; + } + } + }); + if spawned.is_ok() { + h.rx = Some(rx); + } +} + +/// Take finished pictures and designs (one job per frame on the web, where there are no +/// threads). +fn poll(app: &mut SolveApp, ctx: &egui::Context) { + let mut done: Vec = Vec::new(); + #[cfg(not(target_arch = "wasm32"))] + { + start_jobs(&mut app.home); + if let Some(rx) = &app.home.rx { + loop { + match rx.try_recv() { + Ok(m) => done.push(m), + Err(std::sync::mpsc::TryRecvError::Empty) => break, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + app.home.rx = None; + break; + } + } + } + } + if app.home.rx.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + } + #[cfg(target_arch = "wasm32")] + if !app.home.queued.is_empty() { + let k = app.home.queued.remove(0); + done.push(make(&k)); + ctx.request_repaint(); + } + for (key, img, session) in done { + if session.is_none() && key.starts_with("sample:") && app.home.wanted.is_some_and(|w| key == format!("sample:{w}")) { + app.home.wanted = None; + app.set_status(format!("the sample could not be built ({key})"), true); + } + let tex = img.map(|i| ctx.load_texture(format!("thumb:{key}"), i, egui::TextureOptions::LINEAR)); + app.home.thumbs.insert(key.clone(), tex); + if let (Some(s), Some(i)) = (session, key.strip_prefix("sample:").and_then(|i| i.parse::().ok())) { + app.home.samples.insert(i, s); + } + } + if let Some(i) = app.home.wanted + && app.home.samples.contains_key(&i) + { + app.home.wanted = None; + open_sample(app, i); + } +} + +/// Ask for a key's picture (once: an empty entry marks it as asked for). +fn want(h: &mut HomeState, key: &str) { + if !h.thumbs.contains_key(key) { + h.queued.push(key.to_string()); + h.thumbs.insert(key.to_string(), None); + } +} + +/// Open a built-in sample (a copy of the design made for its picture). +pub fn open_sample(app: &mut SolveApp, i: usize) { + match app.home.samples.get(&i) { + Some(s) => { + let mut copy = s.scratch(); + copy.mark_saved(); + crate::documents::adopt(app, copy); + app.home.open = false; + } + None => { + app.home.wanted = Some(i); + want(&mut app.home, &format!("sample:{i}")); + } + } +} + +fn open_recent(app: &mut SolveApp, path: &str) { + if !std::path::Path::new(path).exists() { + app.home.recent.retain(|p| p != path); + app.set_status(format!("{path} is no longer there"), true); + return; + } + app.home.open = false; + app.open_path(path); +} + +/// 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(); + for i in 0..samples.len() { + want(&mut app.home, &format!("sample:{i}")); + } + let recent = app.home.recent.clone(); + for p in recent.iter().filter(|p| std::path::Path::new(p.as_str()).exists()) { + want(&mut app.home, p); + } + let mut action: Option> = None; + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(36, 24))).show(ui, |ui| { + egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(RichText::new("SolveCraft").size(26.0).strong().color(t.text)); + ui.add_space(12.0); + ui.label(RichText::new("Start").size(16.0).color(t.text_dim)); + }); + ui.add_space(16.0); + ui.horizontal(|ui| { + let big = |ui: &mut egui::Ui, icon: &str, label: &str, primary: bool| -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::click()); + let fill = if primary { + t.accent + } else if resp.hovered() { + t.hover + } else { + t.field + }; + ui.painter().rect(r, 6.0, fill, Stroke::new(1.0, if primary { t.accent } else { t.border }), egui::StrokeKind::Inside); + let ink = if primary { Color32::WHITE } else { t.icon }; + crate::icons::paint( + ui.painter(), + egui::Rect::from_center_size(r.left_center() + vec2(30.0, 0.0), vec2(30.0, 30.0)), + icon, + ink, + t.icon_fill, + ink, + ); + ui.painter().text( + r.left_center() + vec2(56.0, 0.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(15.0), + if primary { Color32::WHITE } else { t.text }, + ); + resp.clicked() + }; + if big(ui, "new", "New Design", true) { + action = Some(Box::new(|app: &mut SolveApp| crate::documents::new_design(app))); + } + ui.add_space(10.0); + if big(ui, "open", "Open…", false) { + action = Some(Box::new(|app: &mut SolveApp| { + if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { + app.home.open = false; + app.open_path(&p); + } + })); + } + }); + ui.add_space(24.0); + section(ui, "Recent"); + if recent.is_empty() { + ui.label(RichText::new("Designs you open or save appear here.").color(t.text_dim)); + } else { + grid(ui, recent.len(), |ui, i| { + 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 = !path.exists(); + let sub = if missing { "missing".to_string() } else { folder }; + if card(ui, app.home.thumbs.get(p.as_str()).and_then(Option::as_ref), &name, &sub, missing).on_hover_text(p.as_str()).clicked() { + let p = p.clone(); + action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p))); + } + }); + } + ui.add_space(24.0); + section(ui, "Samples"); + grid(ui, samples.len(), |ui, i| { + let Some(s) = samples.get(i) else { return }; + let key = format!("sample:{i}"); + let busy = app.home.wanted == Some(i); + let sub = if busy { "opening…" } else { s.about }; + if card(ui, app.home.thumbs.get(&key).and_then(Option::as_ref), s.name, sub, false).on_hover_text(s.about).clicked() { + action = Some(Box::new(move |app: &mut SolveApp| open_sample(app, i))); + } + }); + }); + }); + if let Some(f) = action { + f(app); + } +} + +fn section(ui: &mut egui::Ui, title: &str) { + let t = Tokens::get(); + ui.label(RichText::new(title).size(15.0).strong().color(t.text)); + ui.add_space(8.0); +} + +/// Cards in rows that fill the width. +fn grid(ui: &mut egui::Ui, n: usize, mut each: impl FnMut(&mut egui::Ui, usize)) { + let w = THUMB[0] as f32 + 16.0; + let per_row = ((ui.available_width() + 14.0) / (w + 14.0)).floor().max(1.0) as usize; + for row in 0..n.div_ceil(per_row) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 14.0; + for i in row * per_row..((row + 1) * per_row).min(n) { + each(ui, i); + } + }); + ui.add_space(14.0); + } +} + +/// A design card: its picture (a spinner while it renders), name and a line under it. +fn card(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, 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 (r, resp) = ui.allocate_exact_size(size, Sense::click()); + let hover = resp.hovered(); + ui.painter().rect( + r, + 6.0, + if hover { t.hover } else { t.field }, + Stroke::new(1.0, if hover { t.accent } else { t.border }), + egui::StrokeKind::Inside, + ); + let img = egui::Rect::from_min_size(r.min + vec2(8.0, 8.0), vec2(THUMB[0] as f32, THUMB[1] as f32)); + match tex { + Some(tx) => { + let tint = if dim { Color32::from_white_alpha(90) } else { Color32::WHITE }; + ui.painter().image(tx.id(), img, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint); + } + None => { + ui.painter().rect_filled(img, CornerRadius::same(4), t.panel_header); + if !dim { + egui::Spinner::new().size(18.0).paint_at(ui, egui::Rect::from_center_size(img.center(), vec2(18.0, 18.0))); + } + } + } + let ty = img.bottom() + 8.0; + let clip = egui::Rect::from_min_max(egui::pos2(r.left() + 8.0, ty), egui::pos2(r.right() - 8.0, r.bottom())); + let p = ui.painter().with_clip_rect(clip); + p.text(egui::pos2(r.left() + 10.0, ty), egui::Align2::LEFT_TOP, name, egui::FontId::proportional(14.0), if dim { t.text_dim } else { t.text }); + p.text(egui::pos2(r.left() + 10.0, ty + 20.0), egui::Align2::LEFT_TOP, sub, egui::FontId::proportional(11.5), t.text_dim); + if hover { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + resp +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recent_list_is_newest_first_and_bounded() { + let mut h = HomeState::default(); + for i in 0..20 { + h.add_recent(&format!("/d/{i}.solvecraft")); + } + h.add_recent("/d/5.solvecraft"); + assert_eq!(h.recent.len(), MAX_RECENT); + assert_eq!(h.recent.first().map(String::as_str), Some("/d/5.solvecraft")); + assert_eq!(h.recent.iter().filter(|p| p.as_str() == "/d/5.solvecraft").count(), 1); + } + + /// Samples build in the background and make a picture; picking one opens its design. + #[test] + fn sample_card_makes_a_picture_and_opens() { + let (key, img, session) = make("sample:0"); + assert_eq!(key, "sample:0"); + let img = img.unwrap(); + assert_eq!(img.size, THUMB); + let s = session.unwrap(); + assert_eq!(s.doc.name, "Sample Plate"); + assert!(!s.is_dirty() && s.undo.is_empty()); + let mut app = SolveApp::new(Session::default(), crate::Services::default()); + app.home.samples.insert(0, s); + app.home.show(app.session.revision); + open_sample(&mut app, 0); + assert!(!app.home.open); + assert_eq!(app.session.doc.name, "Sample Plate"); + assert!(!app.session.is_dirty()); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1599edf6..eaed3213 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,7 +18,9 @@ pub mod dialogs_assembly; pub mod dialogs_plastic; pub mod dialogs_sheet; pub mod dim_view; +pub mod documents; pub mod gpu; +pub mod home; pub mod icons; pub mod inference; #[cfg(test)] @@ -164,6 +166,10 @@ pub struct SolveApp { shot_token: u64, styled: bool, fitted: bool, + /// The start page. + pub home: home::HomeState, + /// The other open designs (tabs). + pub docs: documents::Documents, } impl SolveApp { @@ -199,6 +205,8 @@ impl SolveApp { shot_token: 0, styled: false, fitted: false, + home: home::HomeState::default(), + docs: documents::Documents::default(), } } @@ -219,6 +227,7 @@ impl SolveApp { "visual_style": self.ui.visual_style, "browser_collapsed": self.tree.collapsed, "browser_expanded": self.tree.expanded, + "recent": self.home.recent, }) .to_string() } @@ -254,6 +263,11 @@ impl SolveApp { }; self.tree.collapsed = keys("browser_collapsed"); self.tree.expanded = keys("browser_expanded"); + self.home.recent = v + .get("recent") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_str).take(home::MAX_RECENT).map(str::to_string).collect()) + .unwrap_or_default(); } pub fn with_control(mut self, rx: Receiver) -> Self { @@ -271,6 +285,11 @@ impl SolveApp { self.cam = Camera::default(); self.fit_view(); } + if matches!(id, "doc.open" | "SaveDocumentCommand" | "SaveDocumentAsCommand") + && let Some(p) = self.session.path.clone() + { + self.home.add_recent(&p); + } } Err(e) => { self.session.echo(format!("{id}: {e}")); @@ -290,6 +309,7 @@ impl SolveApp { /// that have them, otherwise run it with defaults. pub fn start(&mut self, id: &str) { self.tool = None; + self.home.open = false; if matches!(id, "FusionImportCommandFromToolbar" | "ParaMeshInsertAlignCommand") { if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) { self.insert_path(&p); @@ -455,6 +475,8 @@ impl SolveApp { } pub fn open_path(&mut self, path: &str) { + self.home.open = false; + documents::prepare_open(self); if self.run("doc.open", json!({ "path": path })).is_ok() { self.fit_view(); } @@ -599,6 +621,14 @@ impl SolveApp { pub fn ui(&mut self, ui: &mut egui::Ui) { let t0 = now_ms(); toolbar::app_bar(self, ui); + documents::prompt(self, ui.ctx()); + if self.home.open { + home::show(self, ui); + if self.home.open { + self.frame_ms = now_ms() - t0; + return; + } + } toolbar::toolbar(self, ui); if self.ui.show_timeline { timeline::timeline(self, ui); diff --git a/crates/ui-egui/src/titlebar.rs b/crates/ui-egui/src/titlebar.rs index 8385d45c..a909861f 100644 --- a/crates/ui-egui/src/titlebar.rs +++ b/crates/ui-egui/src/titlebar.rs @@ -44,7 +44,7 @@ fn maximized(ctx: &egui::Context) -> bool { ctx.input(|i| i.viewport().maximized.unwrap_or(false)) } -fn toggle_maximize(ctx: &egui::Context) { +pub fn toggle_maximize(ctx: &egui::Context) { ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx))); } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 69f3e5ac..5b00cb0c 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -78,6 +78,10 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { if click(ui, "redo", "Redo (Ctrl+Y)") { let _ = app.run("RedoCommand", json!({})); } + if click(ui, "home", "Start page") { + let rev = app.session.revision; + app.home.show(rev); + } let theme_tip = if app.ui.dark { "Light theme" } else { "Dark theme" }; if click(ui, if app.ui.dark { "sun" } else { "moon" }, theme_tip) { app.ui.dark = !app.ui.dark; @@ -94,12 +98,9 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) { d.insert_temp(egui::Id::new("sc_file_menu_opened"), pass); }); } - // Document tab. - let name = format!("{}{}", app.session.doc.name, if app.session.is_dirty() { " •" } else { "" }); - let tab = Rect::from_min_size(pos2(r.center().x - 110.0, r.top() + 5.0), vec2(220.0, r.height() - 5.0)); - ui.painter().rect_filled(tab, egui::CornerRadius { nw: 5, ne: 5, sw: 0, se: 0 }, t.toolbar); - ui.painter().text(tab.center(), Align2::CENTER_CENTER, name, FontId::proportional(13.0), t.text); + // Document tabs. let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 }; + crate::documents::tabs(app, ui, r, x + 16.0, r.right() - captions - 60.0); ui.painter().text( pos2(r.right() - 12.0 - captions, r.center().y), Align2::RIGHT_CENTER, @@ -130,8 +131,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { ui.add(egui::Button::new(label).shortcut_text(key).frame(false).min_size(vec2(180.0, 22.0))).clicked() }; if item(ui, "New Design", "Ctrl+N") { - let _ = app.run("NewDocumentCommand", json!({})); - app.fit_view(); + crate::documents::new_design(app); close = true; } if item(ui, "Open…", "Ctrl+O") { @@ -406,10 +406,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { Key::Z => drop(app.run("UndoCommand", json!({}))), Key::Y => drop(app.run("RedoCommand", json!({}))), Key::S => save(app), - Key::N => { - let _ = app.run("NewDocumentCommand", json!({})); - app.fit_view(); - } + Key::N => crate::documents::new_design(app), Key::O => { if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { app.open_path(&p); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9ab6bf22..0935ce7a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -42,6 +42,8 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) | | `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) | | `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists | +| `ui.documents` | `action?`: `new`, `switch`, `close`; `index?`; `force?` (close without asking) | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt | +| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) | | `ui.resize` | `width`, `height` | | | `app.quit` | — | |