From 9dfd7cad6007654a124153c45e623c415125b671 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 20:19:56 +0000 Subject: [PATCH] Timeline editing: double-click or Edit Feature reopens the feature's dialog filled in (model rolled back to it, references highlighted) and rebuilds it in place; history marker drag; drag to reorder with dependency checks; context menu (edit, edit profile sketch, rename, suppress, roll marker here, find in browser, delete with dependents); hover links timeline and geometry; fillet edges and hole positions re-resolve after upstream edits (warnings when moved) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- ROADMAP.md | 4 +- crates/doc/src/eval.rs | 71 +++++- crates/engine/src/cmd/edit.rs | 104 ++++++++- crates/engine/src/tests.rs | 45 ++++ crates/ui-egui/src/control.rs | 12 +- crates/ui-egui/src/dialogs.rs | 413 +++++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 25 ++ crates/ui-egui/src/timeline.rs | 229 +++++++++++++++--- crates/ui-egui/src/viewport.rs | 19 ++ docs/control-protocol.md | 1 + 10 files changed, 876 insertions(+), 47 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 04c4822c..a68596ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat | Where are we? | **M0 done; M1/M2 in progress.** Sketch with solver, extrude (taper, two-sided, through all), revolve, fillet/chamfer, holes, patterns, mirror, shell, draft, loft, sweep, split, construction planes, parameters that rebuild the timeline, STEP/STL export, desktop app with Fusion-style selection (hover/selected highlights, origin planes, click-to-pick sketch planes, multi-select, window/crossing box selection, animated view cube), CLI, MCP server, oracle harness. | | Command parity (SOLID + SKETCH toolbar) | **57 / 291 (20%)** — [docs/parity.md](docs/parity.md) | | Fusion oracle | **23 / 29 parts match** Fusion's measurements — [docs/oracle.md](docs/oracle.md) | -| Tests | 84 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) | +| Tests | 85 (solver, profiles, kernel booleans/blends/measures, expressions, timeline, file formats, camera, engine end-to-end, hostile-input fuzz over every command) | | Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering — green | | Weighted parity estimate | **≈ 5%** of Fusion's Design workspace by importance (sketch + basic solids are the core, but surfaces, assemblies, sheet metal, CAM, drawings are untouched) | | Time to a useful alpha (M0–M6) | ≈ **150 agent hours** remain | @@ -25,7 +25,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat | M3 | General fillets and chamfers (curved edges, chains, vertex blends) | planned | 30 | | M4 | Files: STEP import, mesh import, DXF sketches, 3MF | planned | 16 | | M5 | Persistent naming of faces and edges | planned | 20 | -| M6 | UI depth: selection feedback and origin (done), timeline editing (edit feature, rollback marker, reorder, suppress, re-resolved references), live previews, measure tool, section view, drag-solve | in progress | 24 | +| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), live previews, measure tool, section view, drag-solve | in progress | 16 | | M7 | Sweep, loft, rib, web, emboss, thread, coil, pipe | planned | 24 | | M8 | Surface workspace | planned | 30 | | M9 | Assemblies and joints | planned | 40 | diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 3df68cf4..a5d5eefa 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -492,6 +492,68 @@ fn body_at(st: &ModelState, body: &Option, points: &[Vec3]) -> Result Vec3 { + let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max); + let eps = size * 1e-6; + let mut best: Option = None; + for b in &st.bodies { + let m = b.mesh(); + // Already on the surface: the drill enters right here. + if let Some((t, _)) = m.raycast(p - dir * eps, dir) + && t < eps * 4.0 + { + return p; + } + let q = if m.contains(p) { m.raycast(p, -dir).map(|(t, _)| p - dir * t) } else { m.raycast(p, dir).map(|(t, _)| p + dir * t) }; + if let Some(q) = q + && best.is_none_or(|bq| q.dist(p) < bq.dist(p)) + { + best = Some(q); + } + } + best.unwrap_or(p) +} + +/// Edge reference points re-found on the body: points on an edge stay; a point that moved off +/// (an upstream edit) goes to the nearest edge with a warning; points with no edge near are +/// dropped with a warning. +fn resolve_edges(b: &Body, pts: &[Vec3], warning: &mut Option) -> Result> { + let size = b.size(); + let edges = b.edges((size * 1e-3).max(1e-3))?; + let tight = (size * 2e-3).max(1e-3); + let (mut out, mut moved, mut lost) = (Vec::new(), 0, 0); + for p in pts { + let best = edges + .iter() + .map(|e| (e.points.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min), e.mid)) + .min_by(|a, b| a.0.total_cmp(&b.0)); + match best { + Some((d, _)) if d <= tight => out.push(*p), + Some((d, mid)) if d <= size * 0.25 => { + out.push(mid); + moved += 1; + } + _ => lost += 1, + } + } + if out.is_empty() { + return Err(DocError::Invalid("none of the selected edges exist any more".into())); + } + if moved + lost > 0 { + let mut w = Vec::new(); + if moved > 0 { + w.push(format!("{moved} edge reference(s) re-found on the nearest edge")); + } + if lost > 0 { + w.push(format!("{lost} edge reference(s) no longer found and skipped")); + } + *warning = Some(w.join("; ")); + } + Ok(out) +} + /// The body a fillet/chamfer applies to: by name, or the one closest to the first edge point. fn blend_target(state: &ModelState, body: &Option, edges: &[Vec3]) -> Result { if let Some(n) = body { @@ -647,6 +709,7 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - } FeatureKind::Hole { position, direction, diameter, depth, hole } => { let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; + let position = &hole_on_surface(st, *position, dir); let r = val(vals, diameter, Kind::Length)? / 2.0; if !(r > 1e-6) { return Err(DocError::Invalid("hole diameter must be positive".into())); @@ -973,8 +1036,12 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let r = val(vals, radius, Kind::Length)?; let ti = blend_target(st, body, edges)?; let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) }; - let nb = - if matches!(f.kind, FeatureKind::Fillet { .. }) { kernel::fillet(&mb.body, edges, r)? } else { kernel::chamfer(&mb.body, edges, r)? }; + let edges = resolve_edges(&mb.body, edges, warning)?; + let nb = if matches!(f.kind, FeatureKind::Fillet { .. }) { + kernel::fillet(&mb.body, &edges, r)? + } else { + kernel::chamfer(&mb.body, &edges, r)? + }; let (name, feat) = (mb.name.clone(), mb.feature); if let Some(slot) = st.bodies.get_mut(ti) { *slot = ModelBody::new(name, nb, feat); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 0e4d72c3..098c9521 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -22,6 +22,15 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("features: [id or name] (a sketch takes its dependent features with it)"), CommandSpec::new("FusionRenameTimelineEntryCommand", "Rename", rename).params("feature: id|name, name"), CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"), + CommandSpec::new("timeline.rollTo", "Roll History Marker", rollback) + .params("position: number of features to keep active (omit = end) | feature: id|name (the marker goes right after it)"), + CommandSpec::new("timeline.reorder", "Reorder Feature", reorder) + .params("feature: id|name, position: its new index; refused when a feature that worked would fail"), + CommandSpec::new("timeline.redefine", "Edit Feature", redefine) + .params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place)"), + CommandSpec::new("timeline.dependents", "Feature Dependents", dependents) + .noundo() + .params("feature: id|name → features deleted with it, and features that would fail without it"), CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"), CommandSpec::new("timeline.edit", "Edit Feature", edit_feature) .params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"), @@ -141,7 +150,13 @@ fn rename(s: &mut Session, p: &Value) -> Result { fn rollback(s: &mut Session, p: &Value) -> Result { let n = s.doc.features.len(); - let pos = num(p, "position").map(|x| x.max(0.0) as usize); + let pos = match p.get("feature") { + Some(f) => { + let id = feature_id(s, Some(f), "timeline.rollTo")?; + s.doc.feature_index(id).map(|i| i + 1) + } + None => num(p, "position").map(|x| x.max(0.0) as usize), + }; s.doc_mut().marker = match pos { Some(k) if k < n => Some(k), _ => None, @@ -231,3 +246,90 @@ fn select_clear(s: &mut Session, _p: &Value) -> Result { s.revision += 1; Ok(json!({"selected": 0})) } + +/// Features that worked in `before` and fail in `after`: (name, error). +fn newly_broken(before: &solvecraft_doc::Model, after: &solvecraft_doc::Model) -> Vec<(String, String)> { + after + .results + .iter() + .filter_map(|r| { + let e = r.error.clone()?; + let was_ok = before.result(r.id).is_some_and(|b| b.error.is_none() && !b.skipped); + was_ok.then(|| (r.name.clone(), e)) + }) + .collect() +} + +fn reorder(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.reorder"; + let id = feature_id(s, p.get("feature"), cmd)?; + let n = s.doc.features.len(); + let to = num(p, "position").map(|x| x.max(0.0) as usize).ok_or_else(|| bad(cmd, "`position` must be a number"))?.min(n.saturating_sub(1)); + let from = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?; + if from == to { + return Ok(json!({"feature": id, "position": to})); + } + let mut next = (*s.doc).clone(); + let f = next.features.remove(from); + let name = f.name.clone(); + next.features.insert(to, f); + // Everything that evaluated before must still evaluate. + let mut after = solvecraft_doc::Model::new(); + after.evaluate(&next); + let broken = newly_broken(&s.model, &after); + if !broken.is_empty() { + let list: Vec = broken.iter().map(|(n, e)| format!("{n} ({e})")).collect(); + return Err(bad(cmd, format!("moving {name} there breaks {}", list.join("; ")))); + } + *s.doc_mut() = next; + Ok(json!({"feature": id, "position": to})) +} + +fn redefine(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.redefine"; + let id = feature_id(s, p.get("feature"), cmd)?; + let cid = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name the feature's command"))?; + let spec = super::find_command(cid).ok_or_else(|| bad(cmd, format!("no command `{cid}`")))?; + if cid.starts_with("timeline.") || cid.starts_with("select.") || cid.starts_with("Sketch") { + return Err(bad(cmd, format!("`{cid}` does not make a feature"))); + } + let params = p.get("params").cloned().unwrap_or_else(|| json!({})); + let idx = s.doc.feature_index(id).ok_or_else(|| bad(cmd, "feature"))?; + let old = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?; + let marker = s.doc.marker; + // Build the new definition where the feature sits, against the model before it. + s.doc_mut().marker = Some(idx); + s.refresh(); + let r = (spec.run)(s, ¶ms); + let made = r.as_ref().ok().and_then(|v| v.get("feature")).and_then(Value::as_u64); + let new = made.and_then(|nid| s.doc.feature(nid).cloned()); + if let Some(nid) = made { + s.doc_mut().features.retain(|f| f.id != nid); + } + s.doc_mut().marker = marker; + r?; + let new = new.ok_or_else(|| bad(cmd, format!("`{cid}` did not make a feature")))?; + if std::mem::discriminant(&new.kind) != std::mem::discriminant(&old.kind) { + return Err(bad(cmd, format!("{} is not a {} feature", old.name, cid))); + } + if let Some(slot) = s.doc_mut().feature_mut(id) { + slot.kind = new.kind; + } + s.refresh(); + if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { + return Err(EngineError::Other(e)); + } + Ok(json!({"feature": id, "recomputed": s.model.last_recomputed})) +} + +fn dependents(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.dependents"; + let id = feature_id(s, p.get("feature"), cmd)?; + let mut next = (*s.doc).clone(); + let gone = next.delete_feature(id)?; + let mut after = solvecraft_doc::Model::new(); + after.evaluate(&next); + let names = |ids: &[u64]| -> Vec { ids.iter().filter(|x| **x != id).filter_map(|x| s.doc.feature(*x).map(|f| f.name.clone())).collect() }; + let broken: Vec = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect(); + Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken})) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e6685ac..b2f6ca08 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -336,3 +336,48 @@ fn sample_edges_chain_and_faces() { // Every edge bounds a face, and the top face has the outline and the hole rims. assert!(m.edge_faces.iter().all(|f| !f.is_empty())); } + +#[test] +fn timeline_edits_reresolve_references() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20, "name": "Base"})); + run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[20, 0, 20]], "radius": 3, "name": "Round"})); + run(&mut s, "FusionHoleCommand", json!({"position": [20, 15, 20], "diameter": 6, "name": "Bore"})); + let fillet = |l: f64| (9.0 - 9.0 * PI / 4.0) * l; + let expect = |l: f64, h: f64| l * 30.0 * h - fillet(l) - PI * 9.0 * h; + assert!(rel(volume(&mut s), expect(40.0, 20.0)) < 1e-3); + // Taller box: the fillet's edge point is now 5 mm below the edge (re-found, with a warning) + // and the hole moves up with the top face. + run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"height": "25"}})); + assert!(rel(volume(&mut s), expect(40.0, 25.0)) < 1e-3, "{}", volume(&mut s)); + let round = s.doc.find_feature("Round").map(|f| f.id).unwrap(); + assert!(s.model.result(round).and_then(|r| r.warning.clone()).is_some_and(|w| w.contains("re-found"))); + // Longer box: the point still lies on the (longer) edge. + run(&mut s, "timeline.edit", json!({"feature": "Base", "set": {"length": "60"}})); + assert!(rel(volume(&mut s), expect(60.0, 25.0)) < 1e-3, "{}", volume(&mut s)); + // Reorder: the fillet can't go before the box; the hole can go before the fillet. + let e = s.execute("timeline.reorder", &json!({"feature": "Round", "position": 0})).unwrap_err().to_string(); + assert!(e.contains("breaks"), "{e}"); + run(&mut s, "timeline.reorder", json!({"feature": "Bore", "position": 1})); + let names: Vec<&str> = s.doc.features.iter().map(|f| f.name.as_str()).collect(); + assert_eq!(names, ["Base", "Bore", "Round"]); + assert!(rel(volume(&mut s), expect(60.0, 25.0)) < 1e-3); + // Redefine the fillet in place with a new radius; it keeps its name and position. + run( + &mut s, + "timeline.redefine", + json!({"feature": "Round", "command": "FusionFilletEdgesCommand", "params": {"edges": [[30, 0, 25]], "radius": 4}}), + ); + let f4 = (16.0 - 16.0 * PI / 4.0) * 60.0; + assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0 - f4 - PI * 9.0 * 25.0) < 1e-3); + assert_eq!(s.doc.features.iter().map(|f| f.name.as_str()).collect::>(), ["Base", "Bore", "Round"]); + // Dependents and roll back. + let d = run(&mut s, "timeline.dependents", json!({"feature": "Base"})); + assert_eq!(d["would_fail"].as_array().map(|a| a.len()), Some(2), "{d}"); + run(&mut s, "timeline.rollTo", json!({"feature": "Base"})); + assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0) < 1e-6); + run(&mut s, "timeline.rollTo", json!({})); + run(&mut s, "UndoCommand", json!({})); + run(&mut s, "UndoCommand", json!({})); + assert!(rel(volume(&mut s), 60.0 * 30.0 * 25.0 - f4 - PI * 9.0 * 25.0) < 1e-3); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7d87df5b..460e0f0e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -7,7 +7,7 @@ //! - `document.inspect {measure?}`: the design (parameters, timeline, bodies, sketches) //! - `ui.inspect`: UI state, viewport rect, camera, tool and dialog //! - `ui.drag {x0, y0, x1, y1, button?, shift?, ctrl?, steps?}` (box selection, navigation); -//! `ui.selection` (selection, dialog inputs, hover) +//! `ui.selection` (selection, dialog inputs, hover); `ui.editFeature {feature}` (edit dialog) //! - `ui.set {...UiState fields}`; `ui.view {view: front|back|top|bottom|left|right|iso|home|fit, animate?: bool}` (snaps unless animate) //! - `ui.start {command}`: like clicking the toolbar button (starts tools/dialogs) //! - `ui.click {x, y, button?, shift?}`, `ui.move {x, y}`, `ui.scroll {x, y, delta}`: real @@ -173,6 +173,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> app.synthetic.push(egui::Event::PointerButton { pos: z, button, pressed: false, modifiers }); ok(Value::Null) } + "ui.editFeature" => { + let key = match p.get("feature") { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return err("`feature` must be an id or name"), + }; + let Some(id) = app.session.doc.find_feature(&key).map(|f| f.id) else { return err(format!("no feature `{key}`")) }; + app.edit_feature(id); + ok(json!({"dialog": app.dialog.as_ref().map(|d| format!("{d:?}")), "marker": app.session.doc.marker})) + } "ui.selection" => ok(json!({ "selection": app.session.selection, "dialog": app.dialog.as_ref().map(|d| d.inputs.iter().map(|i| json!({"label": i.label, "items": i.items})).collect::>()), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9bd62060..e488aa9a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ use egui::{Color32, RichText, vec2}; use serde_json::{Value, json}; use solvecraft_engine::Sel; use solvecraft_engine::Session; +use solvecraft_engine::doc::{AxisRef, Direction, FeatureKind, HoleKind, Operation, PlaneRef, ProfileSel}; use solvecraft_engine::geom::Vec3; use crate::SolveApp; @@ -51,6 +52,10 @@ pub enum Kind { diameter: String, depth: String, kind: usize, + cb_diameter: String, + cb_depth: String, + cs_diameter: String, + cs_angle: String, }, Primitive { cmd: &'static str, @@ -69,6 +74,16 @@ pub enum Kind { name: String, expr: String, }, + Rename { + feature: u64, + name: String, + }, + /// Delete a feature that others depend on. + ConfirmDelete { + feature: u64, + with: Vec, + fail: Vec, + }, } #[derive(Clone, Debug)] @@ -78,6 +93,10 @@ pub struct Dialog { pub inputs: Vec, pub active: usize, pub error: Option, + /// Editing an existing feature (its id, and the timeline marker to restore afterwards). + pub editing: Option<(u64, Option)>, + /// Parameters the dialog doesn't show but the command needs (kept when editing). + pub extra: serde_json::Map, } /// Can a selection go into an input that accepts `a`? (Planar-ness is checked when picking.) @@ -96,7 +115,7 @@ fn fits(a: Accept, s: &Sel) -> bool { impl Dialog { fn new(kind: Kind, inputs: Vec) -> Dialog { - Dialog { kind, inputs, active: 0, error: None } + Dialog { kind, inputs, active: 0, error: None, editing: None, extra: serde_json::Map::new() } } pub fn for_command(app: &SolveApp, id: &str) -> Option { @@ -126,9 +145,7 @@ impl Dialog { "MirrorCommand" => { Dialog::new(Kind::Mirror, vec![SelInput::new("Bodies", BODIES, true), SelInput::new("Mirror plane", PLANES | PLANAR_FACES, false)]) } - "FusionHoleCommand" => { - Dialog::new(Kind::Hole { diameter: "5 mm".into(), depth: String::new(), kind: 0 }, vec![SelInput::new("Position", FACES, true)]) - } + "FusionHoleCommand" => Dialog::new(hole_defaults(), vec![SelInput::new("Position", FACES, true)]), "PrimitiveBox" => Dialog::new( Kind::Primitive { cmd: "PrimitiveBox", @@ -172,6 +189,14 @@ impl Dialog { Some(d) } + pub fn rename(feature: u64, name: &str) -> Dialog { + Dialog::new(Kind::Rename { feature, name: name.to_string() }, vec![]) + } + + pub fn confirm_delete(feature: u64, with: Vec, fail: Vec) -> Dialog { + Dialog::new(Kind::ConfirmDelete { feature, with, fail }, vec![]) + } + pub fn edit_param(s: &Session, name: &str) -> Dialog { Dialog::new(Kind::EditParam { name: name.into(), expr: s.doc.param(name).map(|p| p.expr.clone()).unwrap_or_default() }, vec![]) } @@ -256,7 +281,6 @@ impl Dialog { /// Profiles of the sketch a feature would use (active, else the last), when there is just one. fn default_profiles(s: &Session) -> Vec { - use solvecraft_engine::doc::FeatureKind; let st = s.model.state(); let sid = s.active_sketch.or_else(|| s.doc.features.iter().rev().find(|f| matches!(f.kind, FeatureKind::Sketch { .. })).map(|f| f.id)); match sid.and_then(|id| st.sketch(id).map(|ss| (id, ss.profiles.len()))) { @@ -331,6 +355,8 @@ fn title(k: &Kind) -> &'static str { Kind::Combine { .. } => "COMBINE", Kind::Params { .. } => "PARAMETERS", Kind::EditParam { .. } => "EDIT DIMENSION", + Kind::Rename { .. } => "RENAME", + Kind::ConfirmDelete { .. } => "DELETE FEATURE", } } @@ -396,7 +422,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut ok = false; let mut cancel = false; let wide = matches!(d.kind, Kind::Params { .. }); - egui::Window::new(RichText::new(title(&d.kind)).strong().size(13.0)) + let heading = if d.editing.is_some() { format!("EDIT {}", title(&d.kind)) } else { title(&d.kind).to_string() }; + egui::Window::new(RichText::new(heading).strong().size(13.0)) .id(egui::Id::new("sc_dialog")) .default_pos(if wide { egui::pos2(pos.x - 260.0, pos.y - 60.0) } else { pos }) .resizable(false) @@ -446,7 +473,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); } Kind::Mirror => {} - Kind::Hole { diameter, depth, kind } => { + Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } => { ui.label("Type"); combo(ui, "hole_kind", &HOLE_LABELS, kind); ui.end_row(); @@ -456,6 +483,22 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.label("Depth"); ui.add(egui::TextEdit::singleline(depth).hint_text("through all")); ui.end_row(); + if *kind == 1 { + ui.label("Counterbore Ø"); + ui.text_edit_singleline(cb_diameter); + ui.end_row(); + ui.label("Counterbore depth"); + ui.text_edit_singleline(cb_depth); + ui.end_row(); + } + if *kind == 2 { + ui.label("Countersink Ø"); + ui.text_edit_singleline(cs_diameter); + ui.end_row(); + ui.label("Countersink angle"); + ui.text_edit_singleline(cs_angle); + ui.end_row(); + } } Kind::Primitive { fields, operation, .. } => { for (k, v) in fields.iter_mut() { @@ -487,6 +530,27 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { r.request_focus(); ui.end_row(); } + Kind::Rename { name, .. } => { + ui.label("Name"); + let r = ui.text_edit_singleline(name); + if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + ok = true; + } + r.request_focus(); + ui.end_row(); + } + Kind::ConfirmDelete { with, fail, .. } => { + if !with.is_empty() { + ui.label("Also deletes"); + ui.label(with.join(", ")); + ui.end_row(); + } + if !fail.is_empty() { + ui.label("Will fail"); + ui.label(RichText::new(fail.join(", ")).color(t.warning)); + ui.end_row(); + } + } } if let Some(e) = &d.error { ui.label(""); @@ -497,7 +561,15 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.add_space(6.0); ui.horizontal(|ui| { if !matches!(d.kind, Kind::Sketch | Kind::Params { .. }) - && ui.add(egui::Button::new(RichText::new("OK").color(Color32::WHITE)).fill(t.accent).min_size(vec2(70.0, 24.0))).clicked() + && ui + .add( + egui::Button::new( + RichText::new(if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" }).color(Color32::WHITE), + ) + .fill(t.accent) + .min_size(vec2(70.0, 24.0)), + ) + .clicked() { ok = true; } @@ -522,6 +594,21 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } if keep && !cancel { app.dialog = Some(d); + } else if let Some((_, marker)) = d.editing { + // Editing done: put the timeline marker back where it was. + let _ = app.run("timeline.rollTo", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); + } +} + +fn hole_defaults() -> Kind { + Kind::Hole { + diameter: "5 mm".into(), + depth: String::new(), + kind: 0, + cb_diameter: "9 mm".into(), + cb_depth: "3 mm".into(), + cs_diameter: "10 mm".into(), + cs_angle: "90 deg".into(), } } @@ -602,6 +689,19 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] { } fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { + let cmds = dialog_commands(app, d)?; + if let Some((id, _)) = d.editing { + let (cmd, params) = cmds.into_iter().next().ok_or("nothing to apply")?; + return app.run("timeline.redefine", json!({"feature": id, "command": cmd, "params": params})).map(drop); + } + for (cmd, params) in cmds { + app.run(&cmd, params)?; + } + Ok(()) +} + +/// The commands (id, parameters) a dialog's OK runs. +fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, String> { let s = &app.session; let need = |i: usize, what: &str| -> Result<(), String> { if sels(d, i).is_empty() { Err(format!("select {what} first")) } else { Ok(()) } }; let profiles = || -> (Value, Value) { @@ -698,18 +798,30 @@ fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { let plane = plane_value(s, sels(d, 1).first()).ok_or("the mirror plane must be a plane or a planar face")?; ("MirrorCommand", json!({"features": features, "plane": plane})) } - Kind::Hole { diameter, depth, kind } => { + Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } => { need(0, "a face position")?; let ty = HOLE_TYPES.get(*kind).copied().unwrap_or("simple"); // One hole per picked position. + let mut out = Vec::new(); for p in face_points(0) { let mut params = json!({"position": p, "diameter": diameter, "type": ty}); if !depth.trim().is_empty() { params["depth"] = json!(depth); } - app.run("FusionHoleCommand", params)?; + match *kind { + 1 => { + params["cb_diameter"] = json!(cb_diameter); + params["cb_depth"] = json!(cb_depth); + } + 2 => { + params["cs_diameter"] = json!(cs_diameter); + params["cs_angle"] = json!(cs_angle); + } + _ => {} + } + out.push(("FusionHoleCommand".to_string(), params)); } - return Ok(()); + return Ok(out); } Kind::Primitive { cmd, fields, operation } => { let mut p = serde_json::Map::new(); @@ -730,7 +842,282 @@ fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { ) } Kind::EditParam { name, expr } => ("ChangeParameterCommand", json!({"name": name, "expression": expr})), - Kind::Sketch | Kind::Params { .. } => return Ok(()), + Kind::Rename { feature, name } => ("FusionRenameTimelineEntryCommand", json!({"feature": feature, "name": name})), + Kind::ConfirmDelete { feature, .. } => ("FusionDeleteCommand", json!({ "features": [feature.to_string()] })), + Kind::Sketch | Kind::Params { .. } => return Ok(Vec::new()), }; - app.run(cmd, params).map(drop) + let mut params = params; + if let Value::Object(m) = &mut params { + for (k, v) in &d.extra { + m.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + Ok(vec![(cmd.to_string(), params)]) +} + +/// Which profiles of a sketch a profile selection means. +fn profile_indices(ss: &solvecraft_engine::doc::SolvedSketch, sel: &ProfileSel) -> Vec { + let ps = &ss.profiles; + match sel { + ProfileSel::All => (0..ps.len()).collect(), + ProfileSel::Indices { indices } => indices.iter().copied().filter(|i| *i < ps.len()).collect(), + ProfileSel::Curves { loops } => loops + .iter() + .filter_map(|l| { + let mut want: Vec<&String> = l.iter().collect(); + want.sort(); + ps.iter().position(|p| { + let mut have: Vec<&String> = p.outer_curves.iter().collect(); + have.sort(); + have == want + }) + }) + .collect(), + ProfileSel::Points { points } => points.iter().filter_map(|q| ps.iter().position(|p| p.region.contains(*q))).collect(), + } +} + +/// The edge of a visible body through (or nearest) a point. +fn edge_sel(s: &Session, p: Vec3) -> Option { + let st = s.model.state(); + let mut best: Option<(f64, Sel)> = None; + for b in &st.bodies { + 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); + if best.as_ref().is_none_or(|(bd, _)| d < *bd) { + best = Some((d, Sel::Edge { body: b.name.clone(), index: i, point: p })); + } + } + } + best.map(|x| x.1) +} + +/// The body face containing a point (nearest triangle). +fn face_sel(s: &Session, p: Vec3) -> Option { + let st = s.model.state(); + let mut best: Option<(f64, Sel)> = None; + for b in &st.bodies { + 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 }; + let d = point_triangle_dist(p, a, bb, c); + if best.as_ref().is_none_or(|(bd, _)| d < *bd) { + best = Some((d, Sel::Face { body: b.name.clone(), index: *f as usize, point: p })); + } + } + } + best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1) +} + +fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { + let n = (b - a).cross(c - a); + let Some(nn) = n.normalized() else { return f64::INFINITY }; + let h = (p - a).dot(nn); + let q = p - nn * h; + let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12); + if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) } +} + +fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { + match pl { + PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }), + PlaneRef::Custom { plane } => face_sel(s, plane.origin), + _ => None, + } +} + +fn op_index(o: &Operation) -> usize { + match o { + Operation::NewBody => 0, + Operation::Join => 1, + Operation::Cut => 2, + Operation::Intersect => 3, + } +} + +fn pt3(v: Vec3) -> Value { + json!([v.x, v.y, v.z]) +} + +/// 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) -> Option { + let s = &app.session; + let f = s.doc.feature(id)?.clone(); + let st = s.model.state(); + let start = |cmd: &str| Dialog::for_command(app, cmd); + let mut d = match &f.kind { + FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => { + let mut d = start("Extrude")?; + d.kind = Kind::Extrude { + distance: extent.distance.clone(), + direction: match extent.direction { + Direction::Positive => 0, + Direction::Negative => 1, + Direction::Symmetric => 2, + }, + operation: op_index(operation), + }; + let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); + if let Some(inp) = d.inputs.first_mut() { + inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); + } + for (k, v) in [ + ("distance2", extent.distance2.as_ref().map(|x| json!(x))), + ("start_offset", extent.start_offset.as_ref().map(|x| json!(x))), + ("taper", extent.taper.as_ref().map(|x| json!(x))), + ("through_all", extent.through_all.then_some(json!(true))), + ("targets", (!targets.is_empty()).then(|| json!(targets))), + ] { + if let Some(v) = v { + d.extra.insert(k.into(), v); + } + } + d + } + FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets } => { + let mut d = start("Revolve")?; + d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation) }; + let items = st.sketch(*sketch).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); + if let Some(inp) = d.inputs.get_mut(0) { + inp.items = items.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect(); + } + let ax = match axis { + AxisRef::World { axis } => Some(Sel::Axis { name: axis.to_ascii_uppercase() }), + AxisRef::SketchLine { curve } => Some(Sel::SketchCurve { id: curve.clone() }), + _ => None, + }; + if let Some(inp) = d.inputs.get_mut(1) { + inp.items = ax.into_iter().collect(); + } + if !targets.is_empty() { + d.extra.insert("targets".into(), json!(targets)); + } + d + } + FeatureKind::Fillet { edges, radius, .. } | FeatureKind::Chamfer { edges, distance: radius, .. } => { + let chamfer = matches!(f.kind, FeatureKind::Chamfer { .. }); + let mut d = start(if chamfer { "FusionChamferCommand" } else { "FusionFilletEdgesCommand" })?; + d.kind = Kind::Fillet { radius: radius.clone(), chamfer, chain: false }; + if let Some(inp) = d.inputs.first_mut() { + inp.items = edges.iter().filter_map(|p| edge_sel(s, *p)).collect(); + } + d + } + FeatureKind::Shell { faces, thickness, .. } => { + let mut d = start("FusionShellBodyCommand")?; + d.kind = Kind::Shell { thickness: thickness.clone() }; + if let Some(inp) = d.inputs.first_mut() { + inp.items = faces.iter().filter_map(|p| face_sel(s, *p)).collect(); + } + d + } + FeatureKind::Draft { faces, angle, neutral, pull, .. } => { + let mut d = start("FusionDraftCommand")?; + d.kind = Kind::Draft { angle: angle.clone() }; + if let Some(inp) = d.inputs.get_mut(0) { + inp.items = faces.iter().filter_map(|p| face_sel(s, *p)).collect(); + } + if let Some(inp) = d.inputs.get_mut(1) { + inp.items = plane_sel(s, neutral).into_iter().collect(); + } + d.extra.insert("pull".into(), pt3(*pull)); + d + } + FeatureKind::Hole { position, direction, diameter, depth, hole } => { + let mut d = start("FusionHoleCommand")?; + let mut k = hole_defaults(); + if let Kind::Hole { diameter: dia, depth: dep, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } = &mut k { + *dia = diameter.clone(); + *dep = depth.clone().unwrap_or_default(); + match hole { + HoleKind::Counterbore { cb_diameter: a, cb_depth: b } => { + *kind = 1; + *cb_diameter = a.clone(); + *cb_depth = b.clone(); + } + HoleKind::Countersink { cs_diameter: a, cs_angle: b } => { + *kind = 2; + *cs_diameter = a.clone(); + *cs_angle = b.clone(); + } + HoleKind::Drilled { tip_angle } => { + d.extra.insert("tip_angle".into(), json!(tip_angle)); + } + HoleKind::Simple => {} + } + } + d.kind = k; + if let Some(inp) = d.inputs.first_mut() { + inp.items = vec![face_sel(s, *position).unwrap_or(Sel::Face { body: String::new(), index: 0, point: *position })]; + } + d.extra.insert("direction".into(), pt3(*direction)); + d + } + FeatureKind::Box { corner, length, width, height, operation } => { + let mut d = start("PrimitiveBox")?; + d.kind = Kind::Primitive { + cmd: "PrimitiveBox", + fields: vec![("length", length.clone()), ("width", width.clone()), ("height", height.clone())], + operation: op_index(operation), + }; + d.extra.insert("corner".into(), pt3(*corner)); + d + } + FeatureKind::Cylinder { base, axis, radius, height, operation } => { + let mut d = start("PrimitiveCylinder")?; + d.kind = Kind::Primitive { + cmd: "PrimitiveCylinder", + fields: vec![("radius", radius.clone()), ("height", height.clone())], + operation: op_index(operation), + }; + d.extra.insert("base".into(), pt3(*base)); + d.extra.insert("axis".into(), pt3(*axis)); + d + } + FeatureKind::Sphere { center, radius, operation } => { + let mut d = start("PrimitiveSphere")?; + d.kind = Kind::Primitive { cmd: "PrimitiveSphere", fields: vec![("radius", radius.clone())], operation: op_index(operation) }; + d.extra.insert("center".into(), pt3(*center)); + d + } + FeatureKind::Torus { center, major, minor, operation } => { + let mut d = start("PrimitiveTorus")?; + d.kind = Kind::Primitive { + cmd: "PrimitiveTorus", + fields: vec![("major", major.clone()), ("minor", minor.clone())], + operation: op_index(operation), + }; + d.extra.insert("center".into(), pt3(*center)); + d + } + FeatureKind::Combine { target, tools, operation, keep_tools } => { + let mut d = start("FusionCombineCommand")?; + d.kind = Kind::Combine { operation: op_index(operation).max(1), keep_tools: *keep_tools }; + if let Some(inp) = d.inputs.get_mut(0) { + inp.items = vec![Sel::Body { name: target.clone() }]; + } + if let Some(inp) = d.inputs.get_mut(1) { + inp.items = tools.iter().map(|n| Sel::Body { name: n.clone() }).collect(); + } + d + } + FeatureKind::Mirror { features, plane } => { + let mut d = start("MirrorCommand")?; + let ids: Vec = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect(); + if let Some(inp) = d.inputs.get_mut(0) { + inp.items = st.bodies.iter().filter(|b| ids.contains(&b.feature)).map(|b| Sel::Body { name: b.name.clone() }).collect(); + } + if let Some(inp) = d.inputs.get_mut(1) { + inp.items = plane_sel(s, plane).into_iter().collect(); + } + d + } + _ => return None, + }; + d.editing = Some((id, marker)); + d.active = 0; + d.error = None; + Some(d) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e892fbcc..0a05dc3b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -191,6 +191,31 @@ impl SolveApp { let _ = self.run(id, json!({})); } + /// Edit a feature the way Fusion does: sketches open in sketch mode; other features roll the + /// timeline back to just before themselves and reopen their dialog, filled in. + pub fn edit_feature(&mut self, id: u64) { + use solvecraft_engine::doc::FeatureKind; + let Some(f) = self.session.doc.feature(id) else { return }; + if matches!(f.kind, FeatureKind::Sketch { .. }) { + let _ = self.run("SketchActivate", json!({ "sketch": id })); + return; + } + let Some(idx) = self.session.doc.feature_index(id) else { return }; + let marker = self.session.doc.marker; + self.tool = None; + if self.run("timeline.rollTo", json!({ "position": idx })).is_err() { + return; + } + match dialogs::for_feature(self, id, marker) { + Some(d) => self.dialog = Some(d), + None => { + let _ = self.run("timeline.rollTo", marker.map(|m| json!({ "position": m })).unwrap_or_else(|| json!({}))); + self.palette.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}"); + self.ui.palette_open = true; + } + } + } + /// Everything shown as selected: the selection plus the open dialog's inputs. pub fn highlighted(&self) -> Vec { let mut v = self.session.selection.clone(); diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 9da7774a..6c26922c 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -1,6 +1,6 @@ //! The timeline (bottom): features in order, the rollback marker, errors and warnings. -use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2}; +use egui::{Color32, Rect, Sense, Stroke, pos2, vec2}; use serde_json::json; use solvecraft_engine::doc::FeatureKind; @@ -33,6 +33,39 @@ fn icon_of(k: &FeatureKind) -> &'static str { } } +/// What a drag in the timeline is moving. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Drag { + Marker, + Feature(u64), +} + +/// Slot index (0..=n) nearest to screen x, given the left edges of the feature buttons. +fn slot_at(xs: &[f32], end: f32, x: f32) -> usize { + let mut best = (0usize, f32::INFINITY); + for (i, sx) in xs.iter().chain(std::iter::once(&end)).enumerate() { + let d = (sx - 2.0 - x).abs(); + if d < best.1 { + best = (i, d); + } + } + best.0 +} + +/// Ask before deleting a feature others depend on; delete right away otherwise. +fn delete_feature(app: &mut SolveApp, id: u64) { + let deps = app.session.execute("timeline.dependents", &json!({ "feature": id })).unwrap_or_default(); + let list = |k: &str| -> Vec { + deps.get(k).and_then(|v| v.as_array()).into_iter().flatten().filter_map(|x| x.as_str().map(String::from)).collect() + }; + let (with, fail) = (list("deleted_with_it"), list("would_fail")); + if with.is_empty() && fail.is_empty() { + let _ = app.run("FusionDeleteCommand", json!({ "features": [id.to_string()] })); + } else { + app.dialog = Some(crate::dialogs::Dialog::confirm_delete(id, with, fail)); + } +} + pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); egui::Panel::bottom("sc_timeline").exact_size(40.0).frame(egui::Frame::NONE.fill(t.timeline)).show(ui, |ui| { @@ -43,51 +76,92 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { let marker = app.session.doc.marker.unwrap_or(n); // Playback controls. let mut x = r.left() + 10.0; - let controls = [("⏮", Some(0usize)), ("◀", Some(marker.saturating_sub(1))), ("▶", Some((marker + 1).min(n))), ("⏭", None)]; - for (label, target) in controls { + let controls = [("first", Some(0usize)), ("back", Some(marker.saturating_sub(1))), ("forward", Some((marker + 1).min(n))), ("last", None)]; + for (what, target) in controls { let br = Rect::from_center_size(pos2(x + 11.0, r.center().y), vec2(22.0, 22.0)); - let resp = ui.interact(br, ui.id().with(("tl", label)), Sense::click()); + let resp = ui.interact(br, ui.id().with(("tl", what)), Sense::click()); if resp.hovered() { p.rect_filled(br, 3.0, t.hover); } - p.text(br.center(), Align2::CENTER_CENTER, label, FontId::proportional(13.0), t.icon); - if resp.clicked() { + transport_glyph(&p, br.center(), what, t.icon); + if resp + .on_hover_text(match what { + "first" => "Roll back to the start", + "back" => "Step back one feature", + "forward" => "Step forward one feature", + _ => "Play to the end", + }) + .clicked() + { let pos = match target { Some(k) if k < n => json!({"position": k}), _ => json!({}), }; - let _ = app.run("timeline.rollback", pos); + let _ = app.run("timeline.rollTo", pos); } x += 24.0; } x += 14.0; - let feats: Vec<_> = - app.session.doc.features.iter().map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name())).collect(); - let mut edit: Option = None; - for (i, (id, name, icon, suppressed, ty)) in feats.iter().enumerate() { - if i == marker { - p.rect_filled(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(4.0, r.height() - 10.0)), 1.0, Color32::from_rgb(70, 76, 88)); - x += 8.0; + let feats: Vec<_> = app + .session + .doc + .features + .iter() + .map(|f| (f.id, f.name.clone(), icon_of(&f.kind), f.suppressed, f.kind.type_name(), f.kind.clone())) + .collect(); + // The feature whose body is under the cursor in the viewport. + let st = app.session.model.state(); + let from_view = match &app.viewport.hover { + Some(crate::viewport::Hit::Face { body, .. } | crate::viewport::Hit::Edge { body, .. } | crate::viewport::Hit::Vertex { body, .. }) => { + st.body(body).map(|b| b.feature) } + _ => None, + }; + let drag_id = ui.id().with("tl_drag"); + let mut drag: Option = ui.data(|d| d.get_temp(drag_id)); + let pointer = ui.input(|i| i.pointer.hover_pos()); + let mut xs: Vec = Vec::new(); + let mut hovered: Option = None; + let mut edit: Option = None; + let mut marker_rect: Option = None; + for (i, (id, name, icon, suppressed, ty, kind)) in feats.iter().enumerate() { + if i == marker { + marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0))); + x += 10.0; + } + xs.push(x); let br = Rect::from_min_size(pos2(x, r.top() + 6.0), vec2(28.0, 28.0)); - let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click()); + let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag()); let res = app.session.model.result(*id); let err = res.and_then(|r| r.error.clone()); let warn = res.and_then(|r| r.warning.clone()); let rolled = i >= marker; let sel = app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Feature { id: f } if f == id)); + if resp.hovered() { + hovered = Some(*id); + } let bg = if err.is_some() { Color32::from_rgb(250, 220, 220) } else if sel { t.accent_soft - } else if resp.hovered() { + } else if resp.hovered() || from_view == Some(*id) { t.hover } else { Color32::WHITE }; - p.rect(br, 3.0, bg, Stroke::new(1.0, if err.is_some() { t.error } else { t.border }), egui::StrokeKind::Inside); + let edge = if err.is_some() { + t.error + } else if from_view == Some(*id) { + t.accent + } else { + t.border + }; + p.rect(br, 3.0, bg, Stroke::new(1.0, edge), egui::StrokeKind::Inside); let ink = if rolled || *suppressed { t.border } else { t.icon }; icons::paint(&p, br.shrink(4.0), icon, ink, if rolled { t.panel_header } else { t.icon_fill }, if rolled { t.border } else { t.accent }); + if *suppressed { + p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim)); + } if warn.is_some() && err.is_none() { icons::paint(&p, Rect::from_min_size(br.right_top() - vec2(10.0, 0.0), vec2(10.0, 10.0)), "warning", t.icon, t.warning, t.warning); } @@ -101,6 +175,10 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { if let Some(r) = res { tip += &format!("\n{:.1} ms", r.ms); } + tip += "\nDouble-click to edit · drag to reorder · right-click for more"; + if resp.drag_started() { + drag = Some(Drag::Feature(*id)); + } let resp = resp.on_hover_text(tip); if resp.clicked() { let _ = app.run("select.set", json!({"items": [{"type": "feature", "id": id}]})); @@ -108,32 +186,127 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { if resp.double_clicked() { edit = Some(*id); } + let sketch_of = match kind { + FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } | FeatureKind::Sweep { sketch, .. } => Some(*sketch), + _ => None, + }; resp.context_menu(|ui| { - if ui.button(if *suppressed { "Unsuppress" } else { "Suppress" }).clicked() { + if ui.button("Edit Feature").clicked() { + edit = Some(*id); + ui.close(); + } + if let Some(sk) = sketch_of + && ui.button("Edit Profile Sketch").clicked() + { + let _ = app.run("SketchActivate", json!({ "sketch": sk })); + ui.close(); + } + if ui.button("Rename").clicked() { + app.dialog = Some(crate::dialogs::Dialog::rename(*id, name)); + ui.close(); + } + if ui.button(if *suppressed { "Unsuppress Features" } else { "Suppress Features" }).clicked() { let _ = app.run("timeline.suppress", json!({"feature": id})); ui.close(); } - if ui.button("Roll back to here").clicked() { - let _ = app.run("timeline.rollback", json!({"position": i})); + if ui.button("Roll History Marker Here").clicked() { + let _ = app.run("timeline.rollTo", json!({ "feature": id })); ui.close(); } + if ui.button("Find in Browser").clicked() { + find_in_browser(app, *id); + ui.close(); + } + ui.separator(); if ui.button("Delete").clicked() { - let _ = app.run("FusionDeleteCommand", json!({"features": [id.to_string()]})); + delete_feature(app, *id); ui.close(); } }); x += 32.0; } + let end = x; if marker >= feats.len() { - p.rect_filled(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(4.0, r.height() - 10.0)), 1.0, Color32::from_rgb(70, 76, 88)); + marker_rect = Some(Rect::from_min_size(pos2(x, r.top() + 5.0), vec2(6.0, r.height() - 10.0))); } - if let Some(id) = edit { - if matches!(app.session.doc.feature(id).map(|f| &f.kind), Some(FeatureKind::Sketch { .. })) { - let _ = app.run("SketchActivate", json!({"sketch": id})); - } else { - app.palette.text = format!("timeline.edit {{\"feature\": {id}, \"set\": {{}}}}"); - app.ui.palette_open = true; + // The history marker: drag it to roll the model back or forward. + if let Some(mr) = marker_rect { + let resp = ui.interact(mr.expand2(vec2(3.0, 0.0)), ui.id().with("tl_marker"), Sense::drag()); + if resp.drag_started() { + drag = Some(Drag::Marker); } + let col = if resp.hovered() || drag == Some(Drag::Marker) { t.accent } else { Color32::from_rgb(70, 76, 88) }; + p.rect_filled(mr, 1.0, col); + resp.on_hover_text("History marker: drag to roll the model back; new features go here"); + } + // Drag feedback, and the drop. + if let (Some(dg), Some(pp)) = (drag, pointer) { + let slot = slot_at(&xs, end, pp.x); + let sx = xs.get(slot).copied().unwrap_or(end) - 4.0; + p.line_segment( + [pos2(sx, r.top() + 3.0), pos2(sx, r.bottom() - 3.0)], + Stroke::new(2.0, if dg == Drag::Marker { t.accent } else { t.sketch_accent }), + ); + } + let released = ui.input(|i| i.pointer.any_released()); + // (Taking the drag ends it even when the pointer has left the window.) + if released + && let Some(dg) = drag.take() + && let Some(pp) = pointer + { + let slot = slot_at(&xs, end, pp.x); + match dg { + Drag::Marker => { + let _ = app.run("timeline.rollTo", if slot >= n { json!({}) } else { json!({ "position": slot }) }); + } + Drag::Feature(id) => { + let from = app.session.doc.feature_index(id).unwrap_or(0); + // Dropping after itself means the slot shifts by one. + let to = if slot > from { slot - 1 } else { slot }; + if to != from { + let _ = app.run("timeline.reorder", json!({ "feature": id, "position": to })); + } + } + } + } + ui.data_mut(|d| match drag { + Some(dg) => { + d.insert_temp(drag_id, dg); + } + None => d.remove::(drag_id), + }); + app.viewport.hover_feature = hovered; + if let Some(id) = edit { + app.edit_feature(id); } }); } + +/// Select what a feature made, so the browser shows it. +fn find_in_browser(app: &mut SolveApp, id: u64) { + let st = app.session.model.state(); + let bodies: Vec = st.bodies.iter().filter(|b| b.feature == id).map(|b| json!({"type": "body", "name": b.name})).collect(); + let items = if bodies.is_empty() { vec![json!({"type": "feature", "id": id})] } else { bodies }; + let _ = app.run("select.set", json!({ "items": items })); +} + +/// Transport buttons drawn as shapes (the UI font has no media glyphs). +fn transport_glyph(p: &egui::Painter, c: egui::Pos2, what: &str, col: Color32) { + let tri = |dir: f32, dx: f32| { + let pts = vec![pos2(c.x + dx - 4.0 * dir, c.y - 5.0), pos2(c.x + dx + 4.0 * dir, c.y), pos2(c.x + dx - 4.0 * dir, c.y + 5.0)]; + p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE)); + }; + let bar = |dx: f32| p.line_segment([pos2(c.x + dx, c.y - 5.0), pos2(c.x + dx, c.y + 5.0)], Stroke::new(1.6, col)); + match what { + "first" => { + bar(-5.0); + tri(-1.0, 1.0); + } + "back" => tri(-1.0, 0.0), + "forward" => tri(1.0, 0.0), + _ => { + tri(1.0, -1.0); + bar(5.0); + } + } +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 6ecaa554..710b96a6 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -37,6 +37,8 @@ pub struct ViewportState { pub mouse: Option, /// Box selection being dragged. pub boxsel: Option, + /// Timeline item under the cursor: its bodies are highlighted. + pub hover_feature: Option, pub build_ms: f64, } @@ -498,6 +500,7 @@ fn highlight_key(app: &SolveApp) -> u64 { app.session.revision.hash(&mut h); serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h); format!("{:?}", app.viewport.hover).hash(&mut h); + app.viewport.hover_feature.hash(&mut h); app.origin_visible().hash(&mut h); app.ui.hidden_origin.hash(&mut h); app.ui.hidden_bodies.hash(&mut h); @@ -597,6 +600,22 @@ fn build_highlight(app: &SolveApp) -> GpuScene { _ => {} } } + // Bodies made by the timeline item under the cursor. + if let Some(fid) = app.viewport.hover_feature { + let k = t.hover_face_lift; + let c = colors::BODY; + for b in st.bodies.iter().filter(|b| b.feature == fid && !app.ui.hidden_bodies.contains(&b.name)) { + let m = b.mesh(); + for f in 0..b.body.face_count() { + face_tris(&mut sc, &m, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true); + } + for (ei, e) in m.edges.iter().enumerate() { + if !m.seams.get(ei).copied().unwrap_or(false) { + edge_lines(&mut sc, e, t.hover_edge, t.hover_edge_halo, 1.5); + } + } + } + } // Hover (pre-highlight). match hover { Some(Hit::Face { body, index, .. }) if !sel.iter().any(|x| matches!(x, Sel::Face { body: b, index: i, .. } if b == body && i == index)) => { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4a6194ac..96aa9245 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -29,6 +29,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.move` | `x`, `y` | | | `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing) | | `ui.selection` | | the selection, the open dialog's inputs and the hovered item | +| `ui.editFeature` | `feature` (id or name) | what double-clicking a timeline item does: rolls back to it and opens its dialog filled in (sketches open in sketch mode) | | `ui.scroll` | `x`, `y`, `delta?` | wheel zoom at the cursor | | `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | | | `ui.text` | `text` | typed text |