diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index c9ecca07..fea3c2f3 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -65,6 +65,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("plane") .params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"), CommandSpec::new("FusionSplitBodyCommand", "Split Body", split_body).at("SOLID", "MODIFY").icon("split").params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal}"), + CommandSpec::new("FusionPressPullCommand", "Press Pull", press_pull) + .at("SOLID", "MODIFY") + .icon("presspull") + .key("Q") + .params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"), CommandSpec::new("FusionMoveCommand", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"), ]; @@ -318,6 +323,23 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result> { Ok(out) } +/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an +/// edge is rounded (a fillet). +fn press_pull(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionPressPullCommand"; + let distance = req_expr(cmd, p, "distance")?; + if p.get("edges").is_some() { + return fillet(s, &json!({"edges": p.get("edges"), "radius": distance})); + } + let face = p.get("face").ok_or_else(|| bad(cmd, "needs `face` or `edges`"))?; + let d = s.doc.eval(&distance, Kind::Length).map_err(|e| bad(cmd, format!("distance: {e}")))?; + if d.abs() < 1e-9 { + return Err(bad(cmd, "the distance must not be zero")); + } + let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (format!("-({distance})"), "negative", "cut") }; + extrude(s, &json!({"face": face, "distance": dist, "direction": dir, "operation": op})) +} + fn fillet(s: &mut Session, p: &Value) -> Result { let cmd = "FusionFilletEdgesCommand"; let edges = edge_points(s, p, cmd)?; diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 37f2f9ff..d4f6291b 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -147,6 +147,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("sketch.construction", "Normal/Construction", construction) .at("SKETCH", "CREATE") .icon("construction") + .key("X") .enabled(in_sketch) .params("curves: [ids], value?: bool (default toggles)"), CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 983b0047..5b2e2f36 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -526,6 +526,22 @@ fn preview_of_an_edit() { assert!(Arc::ptr_eq(&doc, &s.doc)); } +/// Press Pull: positive pulls a face out, negative pushes it in (a cut), edges get a fillet. +#[test] +fn press_pull() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20})); + run(&mut s, "FusionPressPullCommand", json!({"face": [20, 15, 20], "distance": 5})); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 25.0) < 1e-6); + run(&mut s, "FusionPressPullCommand", json!({"face": [20, 15, 25], "distance": "-10"})); + assert!(rel(volume(&mut s), 40.0 * 30.0 * 15.0) < 1e-6); + assert!(s.execute("FusionPressPullCommand", &json!({"face": [20, 15, 15], "distance": 0})).is_err()); + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 20})); + run(&mut s, "FusionPressPullCommand", json!({"edges": [[0, 0, 7]], "distance": 3})); + assert!(rel(volume(&mut s), 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0) < 2e-4); +} + /// Extruding a planar body face (press-pull style): the face boundary becomes a sketch profile. #[test] fn extrude_a_body_face() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f1c1c3b8..82758f62 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -26,6 +26,7 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { } Sel::Face { body, index, point } => (*point, crate::dialogs::planar_face(&app.session, body, *index).map(|(_, n)| n)), Sel::Edge { point, .. } | Sel::Vertex { point, .. } => (*point, None), + Sel::Body { name } => (st.body(name)?.mesh().bounds().center(), None), _ => return None, }; // Only extrudes point along the normal. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1c82894..aa9f2201 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -49,6 +49,12 @@ pub enum Kind { angle: String, }, Mirror, + /// Move bodies by a distance along X, Y and Z. + Move { + x: String, + y: String, + z: String, + }, Hole { diameter: String, depth: String, @@ -148,6 +154,9 @@ impl Dialog { "MirrorCommand" => { Dialog::new(Kind::Mirror, vec![SelInput::new("Bodies", BODIES, true), SelInput::new("Mirror plane", PLANES | PLANAR_FACES, false)]) } + "FusionMoveCommand" => { + Dialog::new(Kind::Move { x: "0 mm".into(), y: "0 mm".into(), z: "10 mm".into() }, vec![SelInput::new("Bodies", BODIES, true)]) + } "FusionHoleCommand" => Dialog::new(hole_defaults(), vec![SelInput::new("Position", FACES, true)]), "PrimitiveBox" => Dialog::new( Kind::Primitive { @@ -175,8 +184,21 @@ impl Dialog { "ChangeParameterCommand" => Dialog::new(Kind::Params { new_name: String::new(), new_expr: String::new() }, vec![]), _ => return None, }; - // Pre-selection: what is selected now becomes the input (first input that takes it). + // Pre-selection: what is selected now becomes the input (first input that takes it); a + // face or edge stands for its body where bodies are wanted. for sel in &s.selection { + let as_body = match sel { + Sel::Face { body, .. } | Sel::Edge { body, .. } | Sel::Vertex { body, .. } => Some(Sel::Body { name: body.clone() }), + _ => None, + }; + if let Some(b) = as_body + && let Some(inp) = d.inputs.iter_mut().find(|i| i.accept == BODIES && (i.multi || i.items.is_empty())) + { + if !inp.items.contains(&b) { + inp.items.push(b); + } + continue; + } if let Some(inp) = d.inputs.iter_mut().find(|i| fits(i.accept, sel) && (i.multi || i.items.is_empty())) { inp.items.push(sel.clone()); } @@ -223,6 +245,7 @@ impl Dialog { | Kind::Shell { .. } | Kind::Draft { .. } | Kind::Mirror + | Kind::Move { .. } | Kind::Hole { .. } | Kind::Primitive { .. } | Kind::Combine { .. } @@ -238,6 +261,7 @@ impl Dialog { Kind::Shell { thickness } => ("Thickness", ValueKind::Length, thickness), Kind::Draft { angle } => ("Angle", ValueKind::Angle, angle), Kind::Hole { diameter, .. } => ("Diameter", ValueKind::Length, diameter), + Kind::Move { z, .. } => ("Z", ValueKind::Length, z), _ => return None, }) } @@ -384,6 +408,7 @@ fn title(k: &Kind) -> &'static str { Kind::Shell { .. } => "SHELL", Kind::Draft { .. } => "DRAFT", Kind::Mirror => "MIRROR", + Kind::Move { .. } => "MOVE", Kind::Hole { .. } => "HOLE", Kind::Primitive { cmd, .. } => match *cmd { "PrimitiveBox" => "BOX", @@ -539,6 +564,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { ui.end_row(); } Kind::Mirror => {} + Kind::Move { x, y, z } => { + for (l, v) in [("X distance", x), ("Y distance", y), ("Z distance", z)] { + ui.label(l); + enter |= field(ui, v); + ui.end_row(); + } + } Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle } => { ui.label("Type"); combo(ui, "hole_kind", &HOLE_LABELS, kind); @@ -905,6 +937,10 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S 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::Move { x, y, z } => { + need(0, "bodies")?; + ("FusionMoveCommand", json!({"bodies": body_names(0), "translate": [x, y, z]})) + } 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"); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 07221506..732d4d6b 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -183,6 +183,10 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.poly(&[(3.0, 7.0), (9.0, 10.0), (9.0, 17.0), (3.0, 17.0)], pen.fill, ink); pen.poly(&[(21.0, 7.0), (15.0, 10.0), (15.0, 17.0), (21.0, 17.0)], Color32::TRANSPARENT, a); } + "presspull" => { + pen.poly(&[(3.0, 15.0), (12.0, 11.0), (21.0, 15.0), (12.0, 19.0)], pen.fill, ink); + pen.arrow((12.0, 15.0), (12.0, 3.0), a); + } "move" => { for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] { pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9dfd5f30..73a6dd8f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,6 +17,7 @@ pub mod icons; pub mod palette; pub mod preview; pub mod selection; +pub mod sketch_dims; pub mod theme; pub mod timeline; pub mod toolbar; @@ -185,6 +186,24 @@ impl SolveApp { } return; } + // Press Pull: edges get a fillet, faces and profiles an extrude. + if id == "FusionPressPullCommand" { + let edges = self.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::Edge { .. })); + return self.start(if edges { "FusionFilletEdgesCommand" } else { "Extrude" }); + } + // Construction toggle: selected sketch curves switch at once, otherwise pick them. + if id == "sketch.construction" { + let curves: Vec = self + .session + .selection + .iter() + .filter_map(|s| if let solvecraft_engine::Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }) + .collect(); + if !curves.is_empty() { + let _ = self.run(id, json!({ "curves": curves })); + return; + } + } if let Some(t) = tools::Tool::for_command(id) { self.tool = Some(t); self.set_status(tools::hint(id), false); diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs new file mode 100644 index 00000000..9f920e05 --- /dev/null +++ b/crates/ui-egui/src/sketch_dims.rs @@ -0,0 +1,248 @@ +//! Inline dimensions while drawing in a sketch. Once a shape's first point is down, value +//! boxes (line length and angle, rectangle width and height, circle diameter…) sit next to the +//! segment they measure and follow the cursor. Typing goes straight into the first box and +//! locks that value; Tab moves to the next box; Enter places the shape with the typed values, +//! which become dimension constraints. Values are expressions ("25", "1 in", "d1 * 2"). + +use egui::{Pos2, vec2}; +use serde_json::{Value, json}; +use solvecraft_engine::doc::expr::Kind as ValueKind; +use solvecraft_engine::geom::Vec2; + +use crate::SolveApp; +use crate::theme::Tokens; +use crate::tools::Tool; +use crate::viewport::Proj; + +/// One value box. +#[derive(Clone, Debug)] +pub struct DimBox { + pub label: &'static str, + pub kind: ValueKind, + /// The expression shown (the live value while not locked). + pub value: String, + /// Typed by the user: the value no longer follows the cursor. + pub locked: bool, +} + +impl DimBox { + fn new(label: &'static str, kind: ValueKind) -> DimBox { + DimBox { label, kind, value: String::new(), locked: false } + } +} + +/// The boxes a tool shows after `n` points. +pub fn boxes_for(cmd: &str, n: usize) -> Vec { + use ValueKind::*; + let b = DimBox::new; + match (cmd, n) { + ("DrawPolyline", n) if n >= 1 => vec![b("Length", Length), b("Angle", Angle)], + ("ShapeRectangleTwoPoint" | "ShapeRectangleCenter", 1) => vec![b("Width", Length), b("Height", Length)], + ("CircleCenterRadius", 1) => vec![b("Diameter", Length)], + ("ArcCenterTwoPoint", 1) => vec![b("Radius", Length)], + ("ArcCenterTwoPoint", 2) => vec![b("Sweep", Angle)], + ("ShapePolygonInscribed" | "ShapePolygonCircumscribed", 1) => vec![b("Radius", Length), b("Sides", Unitless)], + ("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 1) => vec![b("Length", Length)], + ("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 2) => vec![b("Width", Length)], + _ => Vec::new(), + } +} + +/// A locked box's value (mm or radians). +fn locked(app: &SolveApp, t: &Tool, label: &str) -> Option { + let b = t.dims.iter().find(|b| b.label == label && b.locked)?; + app.session.doc.eval(&b.value, b.kind).ok().filter(|v| v.is_finite()) +} + +fn dir_or_x(v: Vec2) -> Vec2 { + v.normalized().unwrap_or(Vec2::X) +} + +fn sign(x: f64) -> f64 { + if x < 0.0 { -1.0 } else { 1.0 } +} + +/// The next point the tool would place for the cursor at `h`, honouring locked values. +pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 { + let Some(a) = t.pts.last().map(|p| p.0) else { return h }; + let lk = |l: &str| locked(app, t, l); + match (t.cmd, t.pts.len()) { + ("DrawPolyline", _) | ("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 1) => { + let v = h - a; + let len = lk("Length").unwrap_or(v.len()); + let ang = lk("Angle").unwrap_or_else(|| v.angle()); + a + Vec2::from_angle(ang) * len + } + ("ShapeRectangleTwoPoint", 1) => { + let d = h - a; + Vec2::new(a.x + lk("Width").map_or(d.x, |w| w * sign(d.x)), a.y + lk("Height").map_or(d.y, |hh| hh * sign(d.y))) + } + ("ShapeRectangleCenter", 1) => { + let d = h - a; + Vec2::new(a.x + lk("Width").map_or(d.x, |w| w * 0.5 * sign(d.x)), a.y + lk("Height").map_or(d.y, |hh| hh * 0.5 * sign(d.y))) + } + ("CircleCenterRadius", 1) => a + dir_or_x(h - a) * lk("Diameter").map_or((h - a).len(), |d| d * 0.5), + ("ArcCenterTwoPoint", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()), + ("ArcCenterTwoPoint", 2) => { + let c = t.pts.first().map(|p| p.0).unwrap_or(a); + let r = (a - c).len(); + let a0 = (a - c).angle(); + let sweep = lk("Sweep").unwrap_or_else(|| ((h - c).angle() - a0).rem_euclid(std::f64::consts::TAU)); + c + Vec2::from_angle(a0 + sweep) * r + } + ("ShapePolygonInscribed" | "ShapePolygonCircumscribed", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()), + ("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 2) => match lk("Width") { + Some(w) => { + let c0 = t.pts.first().map(|p| p.0).unwrap_or(a); + let dir = dir_or_x(a - c0); + let n = dir.perp(); + let side = sign(n.dot(h - c0)); + c0 + (a - c0) * 0.5 + n * (w * 0.5 * side) + } + None => h, + }, + _ => h, + } +} + +/// The value each box shows while it follows the cursor, and where it sits (sketch coordinates). +fn live(t: &Tool, e: Vec2) -> Vec<(f64, Vec2)> { + let Some(a) = t.pts.last().map(|p| p.0) else { return Vec::new() }; + let first = t.pts.first().map(|p| p.0).unwrap_or(a); + match (t.cmd, t.pts.len()) { + ("DrawPolyline", _) | ("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 1) => { + let v = e - a; + vec![(v.len(), (a + e) * 0.5), (v.angle(), a + dir_or_x(v) * (v.len() * 0.2).min(12.0))] + } + ("ShapeRectangleTwoPoint", 1) => { + vec![((e.x - a.x).abs(), Vec2::new((a.x + e.x) * 0.5, a.y)), ((e.y - a.y).abs(), Vec2::new(e.x, (a.y + e.y) * 0.5))] + } + ("ShapeRectangleCenter", 1) => { + let d = e - a; + vec![(2.0 * d.x.abs(), Vec2::new(a.x, a.y - d.y)), (2.0 * d.y.abs(), Vec2::new(a.x + d.x, a.y))] + } + ("CircleCenterRadius", 1) => vec![(2.0 * (e - a).len(), (a + e) * 0.5)], + ("ArcCenterTwoPoint", 1) | ("ShapePolygonInscribed" | "ShapePolygonCircumscribed", 1) => { + vec![((e - a).len(), (a + e) * 0.5), (6.0, a + (e - a) * 0.5 + (e - a).perp() * 0.3)] + } + ("ArcCenterTwoPoint", 2) => { + let a0 = (a - first).angle(); + vec![(((e - first).angle() - a0).rem_euclid(std::f64::consts::TAU), e)] + } + ("ShapeSlotCenterToCenter" | "ShapeSlotOverall", 2) => { + let dir = dir_or_x(a - first); + vec![(2.0 * dir.cross(e - first).abs(), e)] + } + _ => Vec::new(), + } +} + +fn fmt(kind: ValueKind, v: f64) -> String { + match kind { + ValueKind::Angle => format!("{:.1} deg", v.to_degrees()), + ValueKind::Unitless => format!("{}", v.round()), + ValueKind::Length => format!("{v:.2}"), + } +} + +/// Make the tool's boxes match its stage (new boxes when a point was placed). +pub fn sync(t: &mut Tool) { + if t.dims_stage != t.pts.len() { + t.dims = boxes_for(t.cmd, t.pts.len()); + t.dims_stage = t.pts.len(); + t.dims_focus = !t.dims.is_empty(); + } +} + +/// The dimension constraints the locked values ask for after the shape made `curves`: +/// (entities, type, value). +pub fn constraints(t: &Tool, curves: &[String]) -> Vec<(Vec, &'static str, String)> { + let mut out = Vec::new(); + for b in t.dims.iter().filter(|b| b.locked) { + let target = match (t.cmd, b.label) { + ("DrawPolyline", "Length") | ("ShapeRectangleTwoPoint" | "ShapeRectangleCenter", "Width") => curves.first().map(|c| (c, "length")), + ("ShapeRectangleTwoPoint" | "ShapeRectangleCenter", "Height") => curves.get(1).map(|c| (c, "length")), + ("CircleCenterRadius", "Diameter") => curves.first().map(|c| (c, "diameter")), + ("ArcCenterTwoPoint", "Radius") => curves.first().map(|c| (c, "radius")), + _ => None, + }; + if let Some((c, ty)) = target { + out.push((vec![c.clone()], ty, b.value.clone())); + } + } + out +} + +/// A locked box value by label (for the polygon side count). +pub fn locked_value(app: &SolveApp, t: &Tool, label: &str) -> Option { + locked(app, t, label) +} + +/// Draw the boxes of the active tool next to what they measure. Enter places the point. +pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, proj: &Proj) { + // Esc in a box ends the shape (the box had the keyboard, so the shortcut didn't see it). + let in_box = + app.tool.as_ref().is_some_and(|t| (0..t.dims.len()).any(|i| ui.ctx().memory(|m| m.had_focus_last_frame(egui::Id::new(("sc_dim", i)))))); + if in_box && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + crate::tools::finish(app); + return; + } + let Some(mut t) = app.tool.take() else { return }; + sync(&mut t); + let place = if t.dims.is_empty() { None } else { boxes(app, ui, proj, &mut t) }; + app.tool = Some(t); + if let Some(p) = place { + crate::tools::place(app, (p, None)); + } +} + +fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option { + let tk = Tokens::get(); + let sid = app.session.active_sketch?; + let st = app.session.model.state(); + let plane = st.sketch(sid)?.plane; + let h = t.hover.as_ref().map(|x| x.0)?; + let e = effective(app, t, h); + let lv = live(t, e); + let focus = std::mem::take(&mut t.dims_focus); + let mut any_focused = false; + for (i, b) in t.dims.iter_mut().enumerate() { + let Some((v, at)) = lv.get(i).copied() else { continue }; + if !b.locked { + b.value = fmt(b.kind, v); + } + let Some(sp) = proj.to_screen(plane.to_world(at)) else { continue }; + let id = egui::Id::new(("sc_dim", i)); + let has_focus = ui.ctx().memory(|m| m.has_focus(id)); + // While a box follows the cursor its whole text is selected, so typing replaces it. + if (has_focus || (focus && i == 0)) && !b.locked { + let mut s = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); + let n = b.value.chars().count(); + s.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + s.store(ui.ctx(), id); + } + let pos: Pos2 = sp + vec2(-34.0, -11.0); + egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + let fill = if b.locked { tk.accent_soft } else { tk.panel }; + egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| { + let r = + ui.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(62.0).font(egui::TextStyle::Small)).on_hover_text(b.label); + if r.changed() { + b.locked = true; + } + if focus && i == 0 { + r.request_focus(); + } + any_focused |= r.has_focus() || r.lost_focus(); + }); + }); + } + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let nothing = ui.ctx().memory(|m| m.focused().is_none()); + (enter && (any_focused || nothing)).then_some(e) +} + +/// JSON for a typed dimension. +pub fn dimension_params(entities: &[String], ty: &str, value: &str) -> Value { + json!({"entities": entities, "type": ty, "value": value}) +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 2823dcd2..596203d7 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -375,7 +375,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { match k { Key::Escape => { if app.tool.is_some() { - app.tool = None; + crate::tools::finish(app); } else if app.dialog.is_some() { crate::dialogs::cancel(app); } else { diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index be52416b..18e38bbd 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -26,6 +26,11 @@ pub struct Tool { pub hover: Option<(Vec2, Option)>, /// Picked entity ids. pub picks: Vec, + /// Inline dimension boxes for the shape being drawn, the point count they were made for, + /// and whether the first box should take the keyboard. + pub dims: Vec, + pub dims_stage: usize, + pub dims_focus: bool, } impl Tool { @@ -61,7 +66,7 @@ impl Tool { "sketch.construction" => ("sketch.construction", Kind::Pick(1)), _ => return None, }; - Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new() }) + Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new(), dims: Vec::new(), dims_stage: usize::MAX, dims_focus: false }) } } @@ -102,24 +107,15 @@ pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) { pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { let Some(mut tool) = app.tool.take() else { return }; match tool.kind { - Kind::Draw(n) => { - if let Some(p) = sketch_point_at(app, proj, pos) { - tool.pts.push(p); - } - if tool.cmd == "DrawPolyline" { - if tool.pts.len() >= 2 { - let (a, b) = (tool.pts[tool.pts.len() - 2].clone(), tool.pts[tool.pts.len() - 1].clone()); - if let Ok(v) = app.run("DrawPolyline", json!({"points": [arg(&a), arg(&b)], "infer": true})) { - // Chain: the next segment starts at this one's end point. - if let Some(id) = v["curves"].get(0).and_then(Value::as_str) { - let last = tool.pts.len() - 1; - tool.pts[last].1 = Some(format!("{id}.end")); - } - } + Kind::Draw(_) => { + if let Some(mut p) = sketch_point_at(app, proj, pos) { + // Typed values win over the cursor. + if tool.dims.iter().any(|b| b.locked) { + p = (crate::sketch_dims::effective(app, &tool, p.0), None); } - } else if n > 0 && tool.pts.len() >= n { - run_shape(app, &tool); - tool.pts.clear(); + app.tool = Some(tool); + place(app, p); + return; } } Kind::Pick(n) => { @@ -168,9 +164,47 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { app.tool = Some(tool); } -fn run_shape(app: &mut SolveApp, t: &Tool) { +/// Put down the next point of the shape being drawn; the last point runs the command (with +/// the typed values as dimension constraints). +pub fn place(app: &mut SolveApp, p: (Vec2, Option)) { + let Some(mut tool) = app.tool.take() else { return }; + let Kind::Draw(n) = tool.kind else { + app.tool = Some(tool); + return; + }; + tool.pts.push(p); + if tool.cmd == "DrawPolyline" { + if tool.pts.len() >= 2 { + let (a, b) = (tool.pts[tool.pts.len() - 2].clone(), tool.pts[tool.pts.len() - 1].clone()); + if let Ok(v) = app.run("DrawPolyline", json!({"points": [arg(&a), arg(&b)], "infer": true})) { + let curves: Vec = v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect(); + dimension(app, &tool, &curves); + // Chain: the next segment starts at this one's end point. + if let Some(id) = curves.first() { + let last = tool.pts.len() - 1; + tool.pts[last].1 = Some(format!("{id}.end")); + } + } + } + } else if n > 0 && tool.pts.len() >= n { + let curves = run_shape(app, &tool); + dimension(app, &tool, &curves); + tool.pts.clear(); + } + app.tool = Some(tool); +} + +/// Typed values become dimension constraints on the new curves. +fn dimension(app: &mut SolveApp, t: &Tool, curves: &[String]) { + for (ents, ty, value) in crate::sketch_dims::constraints(t, curves) { + let _ = app.run("SketchDimension", crate::sketch_dims::dimension_params(&ents, ty, &value)); + } +} + +fn run_shape(app: &mut SolveApp, t: &Tool) -> Vec { let p = &t.pts; let d = |a: usize, b: usize| p[a].0.dist(p[b].0); + let sides = crate::sketch_dims::locked_value(app, t, "Sides").map_or(6, |v| v.round().clamp(3.0, 200.0) as usize); let params = match t.cmd { "ShapeRectangleTwoPoint" => json!({"p0": xy(&p[0]), "p1": xy(&p[1])}), "ShapeRectangleCenter" => json!({"center": xy(&p[0]), "corner": xy(&p[1])}), @@ -182,18 +216,21 @@ fn run_shape(app: &mut SolveApp, t: &Tool) { "ArcCenterTwoPoint" => json!({"center": arg(&p[0]), "start": arg(&p[1]), "end": xy(&p[2])}), "ShapePolygonInscribed" | "ShapePolygonCircumscribed" => { let a = (p[1].0 - p[0].0).angle().to_degrees(); - json!({"center": xy(&p[0]), "radius": d(0, 1), "sides": 6, "angle": a}) + json!({"center": xy(&p[0]), "radius": d(0, 1), "sides": sides, "angle": a}) } - "ShapePolygonEdge" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "sides": 6}), + "ShapePolygonEdge" => json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "sides": sides}), "ShapeSlotCenterToCenter" | "ShapeSlotOverall" => { let dir = (p[1].0 - p[0].0).normalized().unwrap_or(Vec2::X); let w = 2.0 * dir.cross(p[2].0 - p[0].0).abs(); json!({"p0": xy(&p[0]), "p1": xy(&p[1]), "width": w.max(1e-3)}) } "DrawPoint" => json!({"point": xy(&p[0])}), - _ => return, + _ => return Vec::new(), }; - let _ = app.run(t.cmd, params); + app.run(t.cmd, params) + .ok() + .map(|v| v["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect()) + .unwrap_or_default() } fn run_pick(app: &mut SolveApp, t: &Tool) { @@ -242,6 +279,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { ); } let Some((h, _)) = t.hover.clone() else { return }; + let h = crate::sketch_dims::effective(app, t, h); let pts: Vec = t.pts.iter().map(|p| p.0).collect(); let line = |a: Vec2, b: Vec2| { if let (Some(x), Some(y)) = (to(a), to(b)) { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index f439efda..b1a8165f 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -902,6 +902,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::tools::preview(app, tl, &painter, &proj); } crate::canvas::show(app, ui, &painter, &proj); + crate::sketch_dims::show(app, ui, &proj); view_cube(app, ui, rect); nav_bar(app, ui, rect); // Status chip.