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 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
97e73b0935
commit
9dfd7cad60
10 files changed
+876
-47
No files matched your search
+2
-2
@@ -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 |
|
||||
|
||||
+69
-2
@@ -492,6 +492,68 @@ fn body_at(st: &ModelState, body: &Option<String>, points: &[Vec3]) -> Result<us
|
||||
.ok_or_else(|| DocError::Invalid("there is no body".into()))
|
||||
}
|
||||
|
||||
/// A hole stays on its face when the model changes: a position now inside a body moves back
|
||||
/// against the drilling direction to the surface, one now above it moves down onto it.
|
||||
fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3) -> 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<Vec3> = 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<String>) -> Result<Vec<Vec3>> {
|
||||
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<String>, edges: &[Vec3]) -> Result<usize> {
|
||||
if let Some(n) = body {
|
||||
@@ -647,6 +709,7 @@ fn feature_tools(vals: &BTreeMap<String, Value>, 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<String, Value>, 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);
|
||||
|
||||
@@ -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<Value> {
|
||||
|
||||
fn rollback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<String> = 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<Value> {
|
||||
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<Value> {
|
||||
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<String> { ids.iter().filter(|x| **x != id).filter_map(|x| s.doc.feature(*x).map(|f| f.name.clone())).collect() };
|
||||
let broken: Vec<String> = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect();
|
||||
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
|
||||
}
|
||||
@@ -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::<Vec<_>>(), ["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);
|
||||
}
|
||||
@@ -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::<Vec<_>>()),
|
||||
|
||||
+400
-13
@@ -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<String>,
|
||||
fail: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -78,6 +93,10 @@ pub struct Dialog {
|
||||
pub inputs: Vec<SelInput>,
|
||||
pub active: usize,
|
||||
pub error: Option<String>,
|
||||
/// Editing an existing feature (its id, and the timeline marker to restore afterwards).
|
||||
pub editing: Option<(u64, Option<usize>)>,
|
||||
/// Parameters the dialog doesn't show but the command needs (kept when editing).
|
||||
pub extra: serde_json::Map<String, Value>,
|
||||
}
|
||||
|
||||
/// 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<SelInput>) -> 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<Dialog> {
|
||||
@@ -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<String>, fail: Vec<String>) -> 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<Sel> {
|
||||
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<Vec<(String, Value)>, 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<usize> {
|
||||
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<Sel> {
|
||||
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<Sel> {
|
||||
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<Sel> {
|
||||
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<usize>) -> Option<Dialog> {
|
||||
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<u64> = 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)
|
||||
}
|
||||
@@ -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<solvecraft_engine::Sel> {
|
||||
let mut v = self.session.selection.clone();
|
||||
|
||||
+201
-28
@@ -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<String> {
|
||||
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<u64> = 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<Drag> = ui.data(|d| d.get_temp(drag_id));
|
||||
let pointer = ui.input(|i| i.pointer.hover_pos());
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut hovered: Option<u64> = None;
|
||||
let mut edit: Option<u64> = None;
|
||||
let mut marker_rect: Option<Rect> = 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>(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<serde_json::Value> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ pub struct ViewportState {
|
||||
pub mouse: Option<Pos2>,
|
||||
/// Box selection being dragged.
|
||||
pub boxsel: Option<BoxSel>,
|
||||
/// Timeline item under the cursor: its bodies are highlighted.
|
||||
pub hover_feature: Option<u64>,
|
||||
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)) => {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user