diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 108fcecb..d58d3994 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1103,6 +1103,10 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result 1000 { return Err(DocError::Invalid("the path needs 1…1000 curves".into())); } + // A path through 3D sketch curves is followed as a 3D polyline. + if ids.iter().any(|id| ps.sketch.curve_index(id).is_none() && ps.sketch.wire_index(id).is_some()) { + return path_segments_3d(ps, ids, start); + } let mut segs: Vec = Vec::new(); for id in ids { let ci = ps.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("path curve `{id}`")))?; @@ -1149,6 +1153,64 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result Result> { + let mut pieces: Vec> = Vec::new(); + for id in ids { + let pts: Vec = if let Some(ci) = ps.sketch.curve_index(id) { + let mut v: Vec = Vec::new(); + for s in ps.sketch.segs(ci) { + for q in s.polyline(1e-2) { + let w = ps.plane.to_world(q); + if v.last().is_none_or(|l| l.dist(w) > 1e-12) { + v.push(w); + } + } + } + v + } else { + let w = ps.sketch.wire_index(id).and_then(|i| ps.sketch.wires.get(i)).ok_or_else(|| DocError::Unknown(format!("path curve `{id}`")))?; + w.pts.clone() + }; + if pts.len() < 2 { + return Err(DocError::Invalid(format!("`{id}` is not a curve"))); + } + pieces.push(pts); + } + let ends = |p: &Vec| (p.first().copied().unwrap_or_default(), p.last().copied().unwrap_or_default()); + let tol = 1e-5; + let flip_first = match (pieces.first().map(ends), pieces.get(1).map(ends)) { + (Some((a, b)), _) if start.is_finite() => b.dist(start) < a.dist(start), + (Some((a, _)), Some((c, d))) => c.dist(a) < tol || d.dist(a) < tol, + _ => false, + }; + if flip_first && let Some(f) = pieces.first_mut() { + f.reverse(); + } + for i in 1..pieces.len() { + let prev_end = pieces.get(i - 1).map(|p| ends(p).1).unwrap_or_default(); + if let Some(p) = pieces.get_mut(i) { + let (a, b) = ends(p); + if b.dist(prev_end) < a.dist(prev_end) { + p.reverse(); + } + if ends(p).0.dist(prev_end) > tol { + return Err(DocError::Invalid("the path curves are not connected end to end".into())); + } + } + } + let segs: Vec = pieces + .iter() + .flat_map(|p| p.windows(2).filter(|w| w[0].dist(w[1]) > 1e-9).map(|w| kernel::PathSeg::Line { a: w[0], b: w[1] }).collect::>()) + .collect(); + if segs.len() > 1000 { + return Err(DocError::Invalid("the path is too detailed (over 1000 segments)".into())); + } + Ok(segs) +} + /// Operation and targets of a feature that makes tools. fn feature_op(f: &Feature) -> (Operation, Vec) { match &f.kind { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ab8e4b68..1897377b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -21,6 +21,7 @@ mod parameters; mod plastic; mod sheet; mod sketch; +mod sketch_3d; mod sketch_canvas; mod sketch_constraints; mod sketch_create; @@ -130,6 +131,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(sketch_import::COMMANDS.iter()); v.extend(sketch_inspect::COMMANDS.iter()); v.extend(sketch_canvas::COMMANDS.iter()); + v.extend(sketch_3d::COMMANDS.iter()); v.extend(sketch_modify::COMMANDS.iter()); v.extend(sketch_project::COMMANDS.iter()); v.extend(features::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/sketch_3d.rs b/crates/engine/src/cmd/sketch_3d.rs new file mode 100644 index 00000000..061ff353 --- /dev/null +++ b/crates/engine/src/cmd/sketch_3d.rs @@ -0,0 +1,90 @@ +//! 3D sketch drawing: with the sketch's 3D Sketch option on (Sketch Palette), lines, splines and +//! points may leave the sketch plane. They are kept as drawn 3D curves (wires with fit points): +//! drawn and snapped to, movable point by point, and usable as sweep and pipe paths. + +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; + +use super::sketch::edit; +use super::{CommandSpec, in_sketch}; +use crate::params::{bad, req_vec3, vec3}; +use crate::{Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("sketch.line3d", "3D Line", line3d) + .enabled(in_sketch) + .params("points: [[x,y,z]…] world points (2 or more): one 3D line per segment, joined end to end (needs 3D Sketch on)"), + CommandSpec::new("sketch.spline3d", "3D Spline", spline3d) + .enabled(in_sketch) + .params("points: [[x,y,z]…] world fit points (2 or more): a smooth 3D curve through them (needs 3D Sketch on)"), + CommandSpec::new("sketch.point3d", "3D Point", point3d).enabled(in_sketch).params("point: [x,y,z] world point (needs 3D Sketch on)"), + CommandSpec::new("sketch.move3d", "Move 3D Point", move3d) + .enabled(in_sketch) + .params("wire: drawn 3D curve or point id, index?: fit point (default 0), to: [x,y,z] | by: [dx,dy,dz]; points joined to it move too"), +]; + +fn points(cmd: &str, p: &Value, min: usize) -> Result> { + let a = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must list [x,y,z] points"))?; + if a.len() < min || a.len() > solvecraft_sketch::MAX_FIT_POINTS { + return Err(bad(cmd, format!("`points` needs {min} to {} points", solvecraft_sketch::MAX_FIT_POINTS))); + } + a.iter().map(|v| vec3(v).ok_or_else(|| bad(cmd, "a point must be [x, y, z]"))).collect() +} + +fn needs_3d(sk: &solvecraft_sketch::Sketch, cmd: &str) -> Result<()> { + if sk.view.three_d { Ok(()) } else { Err(bad(cmd, "turn on 3D Sketch (sketch.options sketch_3d: true) to draw off the sketch plane")) } +} + +fn line3d(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.line3d"; + let pts = points(cmd, p, 2)?; + let (ids, info) = edit(s, p, cmd, false, |sk, _| { + needs_3d(sk, cmd)?; + pts.windows(2).map(|w| sk.add_drawn_wire(w).map_err(Into::into)).collect::>>() + })?; + Ok(json!({"curves": ids, "sketch": info})) +} + +fn spline3d(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.spline3d"; + let pts = points(cmd, p, 2)?; + let (id, info) = edit(s, p, cmd, false, |sk, _| { + needs_3d(sk, cmd)?; + Ok(sk.add_drawn_wire(&pts)?) + })?; + Ok(json!({"curves": [id], "sketch": info})) +} + +fn point3d(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.point3d"; + let at = req_vec3(cmd, p, "point")?; + let (id, info) = edit(s, p, cmd, false, |sk, _| { + needs_3d(sk, cmd)?; + Ok(sk.add_drawn_wire(&[at])?) + })?; + Ok(json!({"point": id, "sketch": info})) +} + +fn move3d(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.move3d"; + let wire = p.get("wire").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`wire` must be a 3D curve or point id"))?.to_string(); + let index = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + let to = p.get("to").and_then(vec3); + let by = p.get("by").and_then(vec3); + let (n, info) = edit(s, p, cmd, false, |sk, _| { + let from = sk + .wire_index(&wire) + .and_then(|w| sk.wires.get(w)) + .filter(|w| !w.fit.is_empty()) + .and_then(|w| w.fit.get(index)) + .copied() + .ok_or_else(|| bad(cmd, format!("`{wire}` has no drawn point {index}")))?; + let to = match (to, by) { + (Some(t), _) => t, + (None, Some(d)) => from + d, + _ => return Err(bad(cmd, "give `to` or `by`")), + }; + Ok(sk.move_drawn_point(&wire, index, to)?) + })?; + Ok(json!({"moved": n, "sketch": info})) +} diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index d837d866..b7e09fa8 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -564,3 +564,58 @@ fn constraints_and_dimensions_can_be_selected_and_deleted_together() { run(&mut s, "selection.delete", json!({"items": items})); assert!(sketch(&s).constraints.is_empty()); } + +// --------------------------------------------------------------------------------------------- +// 3D sketch + +#[test] +fn three_d_curves_need_the_option_and_move_joined() { + let mut s = new_sketch(); + assert!(s.execute("sketch.line3d", &json!({"points": [[0, 0, 0], [0, 0, 10]]})).is_err()); + run(&mut s, "sketch.options", json!({"sketch_3d": true})); + let r = run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 10], [10, 0, 10]]})); + let ids = curve_ids(&r); + assert_eq!(ids.len(), 2); + run(&mut s, "sketch.point3d", json!({"point": [5, 5, 5]})); + let sp = run(&mut s, "sketch.spline3d", json!({"points": [[10, 0, 10], [15, 5, 12], [20, 0, 20]]})); + let sid = curve_ids(&sp)[0].clone(); + let sk = sketch(&s); + let w = &sk.wires[sk.wire_index(&sid).expect("spline")]; + assert!(w.pts.len() > 10); + assert!(w.pts.first().expect("p").dist(solvecraft_geom::Vec3::new(10.0, 0.0, 10.0)) < 1e-12); + assert!(w.pts.last().expect("p").dist(solvecraft_geom::Vec3::new(20.0, 0.0, 20.0)) < 1e-12); + // The shared corner moves for both lines (and the spline that starts there). + let r = run(&mut s, "sketch.move3d", json!({"wire": ids[1], "index": 1, "by": [0, 0, 5]})); + assert_eq!(r["moved"], 2); + let r = run(&mut s, "sketch.move3d", json!({"wire": ids[0], "index": 1, "to": [0, 3, 12]})); + assert_eq!(r["moved"], 2); + let sk = sketch(&s); + let fit = |id: &str| sk.wires[sk.wire_index(id).expect("w")].fit.clone(); + assert_eq!(fit(&ids[0])[1], fit(&ids[1])[0]); + assert!(s.execute("sketch.move3d", &json!({"wire": "nope", "to": [0, 0, 0]})).is_err()); + assert!(s.execute("sketch.line3d", &json!({"points": [[0, 0, 0], [0, 0, 0]]})).is_err()); + assert!(s.execute("sketch.line3d", &json!({"points": [[0, 0, 0], [f64::NAN, 0, 0]]})).is_err()); + // Deleting works like any sketch entity. + run(&mut s, "sketch.delete", json!({"entities": [sid]})); + assert!(sketch(&s).wire_index(&curve_ids(&sp)[0]).is_none()); +} + +#[test] +fn pipe_and_sweep_follow_3d_sketch_curves() { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"})); + run(&mut s, "sketch.options", json!({"sketch_3d": true})); + let r = run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 30], [20, 0, 30]]})); + let mut path = curve_ids(&r); + let sp = run(&mut s, "sketch.spline3d", json!({"points": [[20, 0, 30], [35, 10, 35], [50, 0, 50]]})); + path.extend(curve_ids(&sp)); + run(&mut s, "SketchStop", json!({})); + let p = run(&mut s, "PrimitivePipe", json!({"path_sketch": "Path", "path": path, "diameter": 4})); + let si = run(&mut s, "document.inspect", json!({"measure": true})); + let body = si["bodies"].as_array().and_then(|b| b.first()).cloned().unwrap_or_default(); + // The tube runs from the first line's start to the spline's end (radius 2 around them). + let (lo, hi) = (&body["bbox"]["min"], &body["bbox"]["max"]); + let near = |v: &Value, x: f64| (v.as_f64().unwrap_or(f64::NAN) - x).abs() < 0.5; + assert!(near(&lo[0], -2.0) && near(&lo[2], 0.0) && near(&hi[0], 52.0) && near(&hi[2], 50.0), "{p} {body}"); + assert!(body["volume_mm3"].as_f64().unwrap_or(0.0) > 4.0 * std::f64::consts::PI * 50.0 * 0.95, "{p} {body}"); +} diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 124a8f50..c1415e53 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -95,8 +95,16 @@ pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool } } // 3D curves are in world coordinates already. - for w in sk.wires.iter().filter(|_| !sk.view.hide_projected) { - out.push((w.pts.clone(), colors::SKETCH_PROJECTED, false)); + for w in sk.wires.iter().filter(|w| !w.fit.is_empty() || !sk.view.hide_projected) { + // Curves drawn in a 3D sketch look like the sketch's own; included ones are references. + let col = if w.fit.is_empty() { + colors::SKETCH_PROJECTED + } else if active { + colors::SKETCH + } else { + colors::SKETCH_DONE + }; + out.push((w.pts.clone(), col, false)); } out } diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 02beb522..7e507456 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -29,7 +29,7 @@ pub use profiles::{Profile, find_drawn_profiles, find_profiles, merge_regions}; pub use shape::{Shape, intersections}; pub use solver::{Hold, SolveReport, SolveStatus, solve, solve_holding}; pub use text::{MAX_TEXT_CHARS, text_geometry}; -pub use wire::{MAX_WIRE_POINTS, Wire}; +pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, fit_polyline}; #[cfg(test)] mod tests; diff --git a/crates/sketch/src/wire.rs b/crates/sketch/src/wire.rs index 1298dbd4..62ff4fe7 100644 --- a/crates/sketch/src/wire.rs +++ b/crates/sketch/src/wire.rs @@ -18,6 +18,53 @@ pub struct Wire { pub pts: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub link: Option, + /// Drawn in a 3D sketch: the points the curve was drawn through (one: a point; two: a + /// line; more: a smooth spline). `pts` is made from them. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub fit: Vec, +} + +/// Most fit points of a drawn 3D curve. +pub const MAX_FIT_POINTS: usize = 500; + +/// Samples per span of a drawn 3D spline. +const SPAN_SAMPLES: usize = 24; + +/// The polyline of a drawn 3D curve through `fit`: the point itself, the line, or a centripetal +/// Catmull–Rom spline through the points (smooth, no overshoot loops). +pub fn fit_polyline(fit: &[Vec3]) -> Vec { + if fit.len() <= 2 { + return fit.to_vec(); + } + let n = fit.len(); + let at = |i: isize| -> Vec3 { + match usize::try_from(i) { + Ok(i) if i < n => fit[i], + // Mirrored end tangents. + Ok(_) => fit[n - 1] * 2.0 - fit[n - 2], + Err(_) => fit[0] * 2.0 - fit[1], + } + }; + let mut out = vec![fit[0]]; + for k in 0..n - 1 { + let k = k as isize; + let (p0, p1, p2, p3) = (at(k - 1), at(k), at(k + 1), at(k + 2)); + let tj = |a: Vec3, b: Vec3| a.dist(b).sqrt().max(1e-9); + let t0 = 0.0; + let t1 = t0 + tj(p0, p1); + let t2 = t1 + tj(p1, p2); + let t3 = t2 + tj(p2, p3); + for s in 1..=SPAN_SAMPLES { + let t = t1 + (t2 - t1) * s as f64 / SPAN_SAMPLES as f64; + let a1 = p0 * ((t1 - t) / (t1 - t0)) + p1 * ((t - t0) / (t1 - t0)); + let a2 = p1 * ((t2 - t) / (t2 - t1)) + p2 * ((t - t1) / (t2 - t1)); + let a3 = p2 * ((t3 - t) / (t3 - t2)) + p3 * ((t - t2) / (t3 - t2)); + let b1 = a1 * ((t2 - t) / (t2 - t0)) + a2 * ((t - t0) / (t2 - t0)); + let b2 = a2 * ((t3 - t) / (t3 - t1)) + a3 * ((t - t1) / (t3 - t1)); + out.push(b1 * ((t2 - t) / (t2 - t1)) + b2 * ((t - t1) / (t2 - t1))); + } + } + out } fn clean(w: &[Vec3]) -> Option> { @@ -73,11 +120,50 @@ impl Sketch { Some(o) => o.clone(), None => self.fresh("w"), }; - self.wires.push(Wire { id: wid, pts, link: Some(id.to_string()) }); + self.wires.push(Wire { id: wid, pts, link: Some(id.to_string()), fit: Vec::new() }); } Ok(()) } + /// Add a curve drawn in a 3D sketch through `fit` (see [`Wire::fit`]). + pub fn add_drawn_wire(&mut self, fit: &[Vec3]) -> Result { + if fit.is_empty() || fit.len() > MAX_FIT_POINTS { + return Err(SketchError::Invalid(format!("a 3D curve needs 1 to {MAX_FIT_POINTS} points"))); + } + if fit.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) { + return Err(SketchError::Invalid("3D point out of range".into())); + } + if fit.windows(2).any(|w| w[0].dist(w[1]) < 1e-9) { + return Err(SketchError::Invalid("the points of a 3D curve must differ".into())); + } + let id = self.fresh(if fit.len() == 1 { "q" } else { "w" }); + self.wires.push(Wire { id: id.clone(), pts: fit_polyline(fit), link: None, fit: fit.to_vec() }); + Ok(id) + } + + /// Move a fit point of a drawn 3D curve, and every other drawn point at the same place + /// (curves drawn end to end stay joined). Returns how many points moved. + pub fn move_drawn_point(&mut self, wire: &str, index: usize, to: Vec3) -> Result { + if !(to.is_finite() && to.x.abs() < 1e9 && to.y.abs() < 1e9 && to.z.abs() < 1e9) { + return Err(SketchError::Invalid("3D point out of range".into())); + } + let w = self.wire_index(wire).ok_or_else(|| SketchError::Unknown(wire.into()))?; + let from = self.wires.get(w).and_then(|w| w.fit.get(index)).copied().ok_or_else(|| SketchError::Unknown(format!("{wire} point {index}")))?; + let mut n = 0; + for w in self.wires.iter_mut().filter(|w| !w.fit.is_empty()) { + let mut changed = false; + for p in w.fit.iter_mut().filter(|p| p.dist(from) < 1e-9) { + *p = to; + changed = true; + n += 1; + } + if changed { + w.pts = fit_polyline(&w.fit); + } + } + Ok(n) + } + /// Remove wires by id. pub fn remove_wires(&mut self, ids: &[String]) { self.wires.retain(|w| !ids.contains(&w.id)); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 94e534c7..1d2bcac0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -207,6 +207,30 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> "dimension_selected": crate::dim_view::selected(app), "dimension_editing": crate::dim_view::editing(), })), + "ui.triad" => { + // Screen points of the 3D move triad's handles (x, y, z arrows and the plane square). + let Some(rect) = app.viewport.rect else { return err("no viewport") }; + let proj = crate::viewport::projection(app, rect); + ok(json!( + crate::sketch3d::handle_points(app, &proj).into_iter().map(|(n, p)| json!({"handle": n, "x": p.x, "y": p.y})).collect::>() + )) + } + "ui.worldToScreen" => { + let Some(rect) = app.viewport.rect else { return err("no viewport") }; + let proj = crate::viewport::projection(app, rect); + let pts: Vec = p + .get("points") + .and_then(Value::as_array) + .into_iter() + .flatten() + .take(1000) + .map(|v| { + let c = |i: usize| v.get(i).and_then(Value::as_f64).unwrap_or(0.0); + proj.to_screen(solvecraft_engine::geom::Vec3::new(c(0), c(1), c(2))).map_or(Value::Null, |q| json!([q.x, q.y])) + }) + .collect(); + ok(json!(pts)) + } "ui.sketchToScreen" => { // Screen points of active-sketch coordinates (for driving sketch interaction). let st = app.session.model.state(); diff --git a/crates/ui-egui/src/delete.rs b/crates/ui-egui/src/delete.rs index b22b7125..392c5f00 100644 --- a/crates/ui-egui/src/delete.rs +++ b/crates/ui-egui/src/delete.rs @@ -31,7 +31,7 @@ fn is_empty(p: &Value) -> bool { /// Delete what is selected (the Delete key). A selected sketch dimension or constraint glyph /// goes first, as before. pub fn delete_selection(app: &mut SolveApp) { - if crate::sketch_tools::delete_glyph(app) { + if crate::sketch3d::delete_selected(app) || crate::sketch_tools::delete_glyph(app) { return; } request(app, selection_params(app)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a44a6481..b43e5fb8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,6 +29,7 @@ mod preselect_tests; pub mod preview; pub mod ref_images; pub mod selection; +pub mod sketch3d; pub mod sketch_dims; #[cfg(test)] mod sketch_edit_tests; diff --git a/crates/ui-egui/src/sketch3d.rs b/crates/ui-egui/src/sketch3d.rs new file mode 100644 index 00000000..ec92338f --- /dev/null +++ b/crates/ui-egui/src/sketch3d.rs @@ -0,0 +1,386 @@ +//! 3D sketching in the viewport (the Sketch Palette's 3D Sketch option): the Line, Spline and +//! Point tools take points off the sketch plane by snapping to model vertices, edges and faces +//! and to points already drawn in 3D; segments that stay on the plane are ordinary sketch lines. +//! A drawn 3D point (or curve end) clicked without a tool gets a move triad: drag an arrow to +//! move along X, Y or Z, or the square to move parallel to the sketch plane. Everything runs +//! `sketch.line3d`, `sketch.spline3d`, `sketch.point3d`, `sketch.move3d` and `DrawPolyline`. + +use std::cell::RefCell; + +use egui::{Pos2, Rect, Shape, Stroke, vec2}; +use serde_json::{Value, json}; +use solvecraft_engine::geom::{Vec2, Vec3}; + +use crate::SolveApp; +use crate::theme::Tokens; +use crate::tools::Tool; +use crate::viewport::{Hit, Proj, pick}; + +thread_local! { + /// Points of the curve being drawn in 3D (with the tool they belong to). + static CHAIN: RefCell<(Option<&'static str>, Vec)> = const { RefCell::new((None, Vec::new())) }; + /// The drawn point with the triad: wire id and fit index. + static TRIAD: RefCell> = const { RefCell::new(None) }; + /// A triad drag: axis (None: in the sketch plane), undo depth, grab offset. + static DRAG: RefCell, usize, Vec3)>> = const { RefCell::new(None) }; + /// Where the cursor would put a point (for the rubber band), and its snap label. + static HOVER: RefCell> = const { RefCell::new(None) }; +} + +/// Triad arrow length (screen px). +const ARROW: f32 = 60.0; + +/// Is the sketch being edited a 3D sketch? +pub fn active(app: &SolveApp) -> bool { + let st = app.session.model.state(); + app.session.active_sketch.and_then(|s| st.sketch(s)).is_some_and(|ss| ss.sketch.view.three_d) +} + +fn takes(cmd: &str) -> bool { + matches!(cmd, "DrawPolyline" | "DrawSpline" | "DrawPoint") +} + +/// Points drawn in 3D: (wire id, fit index, point). +fn drawn_points(app: &SolveApp) -> Vec<(String, usize, Vec3)> { + let st = app.session.model.state(); + let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return Vec::new() }; + ss.sketch.wires.iter().flat_map(|w| w.fit.iter().enumerate().map(|(i, p)| (w.id.clone(), i, *p))).collect() +} + +/// Closest point of segment a–b to the ray (o, d). +fn seg_ray_closest(a: Vec3, b: Vec3, o: Vec3, d: Vec3) -> Vec3 { + let u = b - a; + let w0 = a - o; + let (aa, bb, cc) = (u.dot(u), u.dot(d), d.dot(d)); + let (dd, ee) = (u.dot(w0), d.dot(w0)); + let den = aa * cc - bb * bb; + let s = if den.abs() < 1e-12 { 0.0 } else { ((bb * ee - cc * dd) / den).clamp(0.0, 1.0) }; + a + u * s +} + +/// The 3D point under the cursor and what it snapped to: a drawn 3D point, a model vertex, a +/// point on a model edge or face, a sketch point, or the sketch plane. +pub fn point_at(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Vec3, &'static str)> { + for (_, _, p) in drawn_points(app) { + if proj.to_screen(p).is_some_and(|s| s.distance(pos) < 8.0) { + return Some((p, "Pt")); + } + } + let st = app.session.model.state(); + let ss = app.session.active_sketch.and_then(|s| st.sketch(s))?; + let (o, d) = proj.ray(pos); + for h in pick(app, proj, pos) { + match h { + Hit::SketchPoint { at, .. } => return Some((ss.plane.to_world(at), "Pt")), + Hit::Vertex { point, .. } => return Some((point, "Vertex")), + Hit::Edge { body, index, .. } => { + let ws = app.session.world_state(); + let m = ws.body(&body)?.mesh(); + let e = m.edges.get(index)?; + let best = e + .windows(2) + .map(|w| seg_ray_closest(w[0], w[1], o, d)) + .min_by(|p, q| (*p - o).cross(d).len().total_cmp(&(*q - o).cross(d).len()))?; + return Some((best, "Edge")); + } + Hit::Face { point, .. } => return Some((point, "Face")), + _ => {} + } + } + ss.plane.intersect_ray(o, d).map(|w| (w, "Plane")) +} + +/// The sketch-plane coordinates of `p` when it lies on the plane. +fn on_plane(app: &SolveApp, p: Vec3) -> Option { + let st = app.session.model.state(); + let ss = app.session.active_sketch.and_then(|s| st.sketch(s))?; + let q = ss.plane.to_local(p); + (ss.plane.to_world(q).dist(p) < 1e-6).then_some(q) +} + +fn chain() -> Vec { + CHAIN.with(|c| c.borrow().1.clone()) +} + +fn set_chain(cmd: Option<&'static str>, pts: Vec) { + CHAIN.with(|c| *c.borrow_mut() = (cmd, pts)); +} + +/// The pointer moved: remember where a click would put the point. +pub fn on_hover(app: &SolveApp, proj: &Proj, pos: Pos2) { + let h = if app.tool.as_ref().is_some_and(|t| takes(t.cmd)) && active(app) { point_at(app, proj, pos) } else { None }; + HOVER.with(|x| *x.borrow_mut() = h); +} + +/// A click while a drawing tool is active in a 3D sketch (true: handled here). +pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) -> bool { + if !takes(tool.cmd) || !active(app) { + return false; + } + let Some((p, _)) = point_at(app, proj, pos) else { return true }; + if CHAIN.with(|c| c.borrow().0) != Some(tool.cmd) { + set_chain(Some(tool.cmd), Vec::new()); + } + match tool.cmd { + "DrawPoint" => { + let r = match on_plane(app, p) { + Some(q) => app.run("DrawPoint", json!({"point": [q.x, q.y]})), + None => app.run("sketch.point3d", json!({"point": [p.x, p.y, p.z]})), + }; + report(app, r); + } + "DrawPolyline" => { + if let Some(a) = chain().last().copied() { + let r = match (on_plane(app, a), on_plane(app, p)) { + (Some(qa), Some(qb)) => app.run("DrawPolyline", json!({"points": [[qa.x, qa.y], [qb.x, qb.y]], "infer": true})), + _ => app.run("sketch.line3d", json!({"points": [[a.x, a.y, a.z], [p.x, p.y, p.z]]})), + }; + report(app, r); + } + let mut c = chain(); + c.push(p); + set_chain(Some(tool.cmd), c); + } + _ => { + let mut c = chain(); + c.push(p); + set_chain(Some(tool.cmd), c); + } + } + true +} + +fn report(app: &mut SolveApp, r: Result) { + if let Err(e) = r { + app.set_status(e, true); + } +} + +/// Right-click / Esc while drawing in 3D: a spline is made from its points, a line chain ends +/// (true: handled here; the tool stays for the next curve). +pub fn finish(app: &mut SolveApp) -> bool { + let (cmd, pts) = CHAIN.with(|c| c.borrow().clone()); + let Some(cmd) = cmd.filter(|c| app.tool.as_ref().is_some_and(|t| t.cmd == *c)) else { + set_chain(None, Vec::new()); + return false; + }; + if pts.is_empty() { + set_chain(None, Vec::new()); + return false; + } + if cmd == "DrawSpline" && pts.len() >= 2 { + let r = if pts.iter().all(|p| on_plane(app, *p).is_some()) { + let q: Vec = pts.iter().filter_map(|p| on_plane(app, *p)).map(|q| json!([q.x, q.y])).collect(); + app.run("DrawSpline", json!({ "points": q })) + } else { + let q: Vec = pts.iter().map(|p| json!([p.x, p.y, p.z])).collect(); + app.run("sketch.spline3d", json!({ "points": q })) + }; + report(app, r); + } + set_chain(Some(cmd), Vec::new()); + true +} + +/// Rubber band and snap marker of the curve being drawn in 3D (true: drawn here). +pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) -> bool { + if !takes(t.cmd) || !active(app) { + return false; + } + let tk = Tokens::get(); + let stroke = Stroke::new(1.5, tk.rubber_band); + let pts = if CHAIN.with(|c| c.borrow().0) == Some(t.cmd) { chain() } else { Vec::new() }; + let hover = HOVER.with(|h| *h.borrow()); + let mut all: Vec = pts.iter().filter_map(|p| proj.to_screen(*p)).collect(); + if let Some((h, label)) = hover + && let Some(s) = proj.to_screen(h) + { + if t.cmd != "DrawPoint" && !pts.is_empty() { + all.push(s); + } + painter.circle_stroke(s, if label == "Plane" { 3.0 } else { 6.0 }, stroke); + if label != "Plane" { + painter.text(s + vec2(10.0, -14.0), egui::Align2::LEFT_BOTTOM, label, egui::FontId::proportional(10.0), tk.text); + } + painter.text( + s + vec2(10.0, 10.0), + egui::Align2::LEFT_TOP, + format!("{:.2}, {:.2}, {:.2}", h.x, h.y, h.z), + egui::FontId::proportional(11.0), + tk.text_dim, + ); + } + if t.cmd == "DrawSpline" && all.len() >= 2 { + let mut w: Vec = pts.clone(); + if let Some((h, _)) = hover { + w.push(h); + } + let poly: Vec = solvecraft_engine::sketch::fit_polyline(&w).into_iter().filter_map(|p| proj.to_screen(p)).collect(); + painter.add(Shape::line(poly, stroke)); + } else { + painter.add(Shape::line(all, stroke)); + } + true +} + +// --------------------------------------------------------------------------------------------- +// The move triad + +fn triad_point(app: &SolveApp) -> Option<(String, usize, Vec3)> { + let (w, i) = TRIAD.with(|t| t.borrow().clone())?; + drawn_points(app).into_iter().find(|(id, k, _)| *id == w && *k == i) +} + +fn unit(v: egui::Vec2) -> Option { + (v.length() > 1e-3).then(|| v / v.length()) +} + +/// The triad's handles on screen: (axis or None for the plane square, tip, base). +fn handles(app: &SolveApp, proj: &Proj) -> Vec<(Option, Pos2, Pos2)> { + let Some((_, _, p)) = triad_point(app) else { return Vec::new() }; + let Some(base) = proj.to_screen(p) else { return Vec::new() }; + let mut out = Vec::new(); + for axis in [Vec3::X, Vec3::Y, Vec3::Z] { + if let Some(t) = proj.to_screen(p + axis) + && let Some(dir) = unit(t - base) + { + out.push((Some(axis), base + dir * ARROW, base)); + } + } + out.push((None, base + vec2(14.0, -14.0), base)); + out +} + +/// A click without a tool: a drawn 3D point gets the triad (true: handled). +pub fn click(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> bool { + if !active(app) { + TRIAD.with(|t| *t.borrow_mut() = None); + return false; + } + let hit = drawn_points(app).into_iter().find(|(_, _, p)| proj.to_screen(*p).is_some_and(|s| s.distance(pos) < 8.0)); + let on_handle = handles(app, proj).iter().any(|(_, tip, _)| tip.distance(pos) < 9.0); + if on_handle { + return true; + } + TRIAD.with(|t| *t.borrow_mut() = hit.as_ref().map(|(w, i, _)| (w.clone(), *i))); + if let Some((w, _, p)) = hit { + app.set_status( + format!("{w} at {:.2}, {:.2}, {:.2}: drag an arrow (X, Y, Z) or the square (in the sketch plane); Delete removes it", p.x, p.y, p.z), + false, + ); + return true; + } + false +} + +/// The drawn curve or point with the triad, for Delete. +pub fn delete_selected(app: &mut SolveApp) -> bool { + let Some((w, _)) = TRIAD.with(|t| t.borrow_mut().take()) else { return false }; + let r = app.run("sketch.delete", json!({ "entities": [w] })); + report(app, r); + true +} + +/// A drag starting on a triad handle (true: the triad took it). +pub fn drag_start(app: &SolveApp, proj: &Proj, press: Pos2) -> bool { + let Some((axis, _, _)) = handles(app, proj).into_iter().find(|(_, tip, _)| tip.distance(press) < 9.0) else { return false }; + let Some((_, _, p)) = triad_point(app) else { return false }; + let grab = target(app, proj, press, axis, p).map(|g| p - g).unwrap_or(Vec3::ZERO); + DRAG.with(|d| *d.borrow_mut() = Some((axis, app.session.undo.len(), grab))); + true +} + +/// Where the pointer puts the point: along `axis` through `p`, or in the plane through `p` +/// parallel to the sketch plane. +fn target(app: &SolveApp, proj: &Proj, pos: Pos2, axis: Option, p: Vec3) -> Option { + let (o, d) = proj.ray(pos); + match axis { + Some(u) => { + let w0 = p - o; + let (a, b, c) = (u.dot(u), u.dot(d), d.dot(d)); + let den = a * c - b * b; + if den.abs() < 1e-12 { + return None; + } + Some(p + u * ((b * d.dot(w0) - c * u.dot(w0)) / den)) + } + None => { + let st = app.session.model.state(); + let n = app.session.active_sketch.and_then(|s| st.sketch(s))?.plane.normal(); + let den = d.dot(n); + (den.abs() > 1e-12).then(|| o + d * ((p - o).dot(n) / den)) + } + } +} + +pub fn dragging() -> bool { + DRAG.with(|d| d.borrow().is_some()) +} + +pub fn drag_to(app: &mut SolveApp, proj: &Proj, pos: Pos2) { + let Some((axis, depth, grab)) = DRAG.with(|d| *d.borrow()) else { return }; + let Some((w, i, p)) = triad_point(app) else { return }; + let Some(to) = target(app, proj, pos, axis, p).map(|t| t + grab) else { return }; + if app.session.execute("sketch.move3d", &json!({"wire": w, "index": i, "to": [to.x, to.y, to.z]})).is_ok() { + app.session.undo.truncate(depth + 1); + } +} + +pub fn drag_end() { + DRAG.with(|d| *d.borrow_mut() = None); +} + +/// Draw the drawn 3D points and the triad. +pub fn show(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + if app.session.active_sketch.is_none() { + return; + } + let tk = Tokens::get(); + let sel = TRIAD.with(|t| t.borrow().clone()); + for (w, i, p) in drawn_points(app) { + if let Some(s) = proj.to_screen(p) { + let on = sel.as_ref().is_some_and(|(sw, si)| *sw == w && *si == i); + painter.rect_filled(Rect::from_center_size(s, vec2(6.0, 6.0)), 0.0, if on { tk.accent } else { tk.sketch_point_free }); + } + } + if !active(app) { + return; + } + for (axis, tip, base) in handles(app, proj) { + match axis { + Some(a) => { + let col = if a == Vec3::X { + tk.axis_x + } else if a == Vec3::Y { + tk.axis_y + } else { + tk.axis_z + }; + painter.line_segment([base, tip], Stroke::new(2.0, col)); + if let Some(dir) = unit(tip - base) { + let n = vec2(-dir.y, dir.x); + painter.add(Shape::convex_polygon(vec![tip + dir * 8.0, tip + n * 4.0, tip - n * 4.0], col, Stroke::NONE)); + } + } + None => { + painter.rect_stroke(Rect::from_center_size(tip, vec2(10.0, 10.0)), 0.0, Stroke::new(1.5, tk.accent), egui::StrokeKind::Inside); + } + } + } +} + +/// Test hook: screen positions of the triad handles (axis name or "plane", tip). +pub fn handle_points(app: &SolveApp, proj: &Proj) -> Vec<(&'static str, Pos2)> { + handles(app, proj) + .into_iter() + .map(|(a, tip, _)| { + let name = match a { + Some(v) if v == Vec3::X => "x", + Some(v) if v == Vec3::Y => "y", + Some(_) => "z", + None => "plane", + }; + (name, tip) + }) + .collect() +} diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 92324380..b93ddaae 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -100,6 +100,7 @@ fn xy(p: &(Vec2, Option)) -> Value { } pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) { + crate::sketch3d::on_hover(app, proj, pos); let h = sketch_point_at(app, proj, pos); if let Some(t) = app.tool.as_mut() { t.hover = h; @@ -108,6 +109,11 @@ 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 }; + // In a 3D sketch the drawing tools take points off the plane. + if crate::sketch3d::on_click(app, &mut tool, proj, pos) { + app.tool = Some(tool); + return; + } match tool.kind { Kind::Draw(_) => { if let Some(mut p) = sketch_point_at(app, proj, pos) { @@ -252,6 +258,9 @@ fn run_pick(app: &mut SolveApp, t: &Tool) { /// Right click / Esc: end the current chain (line) or cancel the tool. pub fn finish(app: &mut SolveApp) { + if crate::sketch3d::finish(app) { + return; + } if let Some(mut t) = app.tool.take_if(|t| t.kind == Kind::Ext) { if crate::sketch_tools::finish(app, &mut t) { app.tool = Some(t); @@ -270,6 +279,9 @@ pub fn finish(app: &mut SolveApp) { /// Rubber-band preview of the shape being drawn. pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { + if crate::sketch3d::preview(app, t, painter, proj) { + return; + } if t.kind == Kind::Ext { return crate::sketch_tools::preview(app, t, painter, proj); } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 98297df5..fa08898e 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -1138,9 +1138,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let sketching = app.session.active_sketch.is_some(); let on_dim = crate::dim_view::hit(p).filter(|_| sketching && !off("dimensions")); let on_glyph = crate::sketch_tools::glyph_at(p).filter(|_| sketching && !off("constraints")); - match on_dim.or(on_glyph) { - Some(id) => select(app, Some(Sel::SketchConstraint { id }), add), - None => select(app, cand.and_then(|c| c.1), add), + if !crate::sketch3d::click(app, &proj, p) { + match on_dim.or(on_glyph) { + Some(id) => select(app, Some(Sel::SketchConstraint { id }), add), + None => select(app, cand.and_then(|c| c.1), add), + } } } } @@ -1190,13 +1192,20 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let (o, d) = proj.ray(p); ss.plane.intersect_ray(o, d).map(|w| ss.plane.to_local(w)) }; - // A dimension's text follows a drag that starts on it. + // The 3D triad, then a dimension's text, follow a drag that starts on them. if resp.drag_started_by(egui::PointerButton::Primary) && let Some(a) = ui.input(|i| i.pointer.press_origin()) + && !crate::sketch3d::drag_start(app, &proj, a) && let Some(q) = on_plane(app, a) { crate::dim_view::drag_start(app, a, q); } + if crate::sketch3d::dragging() + && (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped()) + && let Some(p) = hover + { + crate::sketch3d::drag_to(app, &proj, p); + } if crate::dim_view::dragging() && (resp.dragged_by(egui::PointerButton::Primary) || resp.drag_stopped()) && let Some(q) = hover.and_then(|p| on_plane(app, p)) @@ -1206,6 +1215,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // A point or a curve of the sketch being edited follows a drag that starts on it. if resp.drag_started_by(egui::PointerButton::Primary) && !crate::dim_view::dragging() + && !crate::sketch3d::dragging() && let Some(a) = ui.input(|i| i.pointer.press_origin()) { let active = app.session.active_sketch; @@ -1232,10 +1242,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if resp.drag_stopped() { app.viewport.point_drag = None; crate::dim_view::drag_end(); + crate::sketch3d::drag_end(); } } // Box selection: a primary drag on the model when no navigation mode is on. - if app.tool.is_none() && app.viewport.nav.is_none() && app.viewport.point_drag.is_none() && !crate::dim_view::dragging() { + if app.tool.is_none() + && app.viewport.nav.is_none() + && app.viewport.point_drag.is_none() + && !crate::dim_view::dragging() + && !crate::sketch3d::dragging() + { let origin = ui.input(|i| i.pointer.press_origin()); if resp.dragged_by(egui::PointerButton::Primary) && let (Some(a), Some(b)) = (origin, hover) @@ -1255,6 +1271,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { crate::ref_images::show(app, ui, &painter, &proj); overlays(app, &painter, &proj); crate::dim_view::show(app, ui, &painter, &proj); + crate::sketch3d::show(app, &painter, &proj); hover_highlight(app, &painter, &proj); points_2d(app, &painter, &proj); if let Some(bx) = app.viewport.boxsel { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 80722f1b..9ab6bf22 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -32,6 +32,8 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does | | `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened | | `ui.selection` | | the selection, the open dialog's inputs, the hovered item, and the active sketch's drawn dimensions (`dimensions`: id and text centre, `dimension_selected`, `dimension_editing`) | +| `ui.worldToScreen` | `points`: `[[x, y, z]…]` world points | their screen points | +| `ui.triad` | | screen points of the 3D sketch move triad's handles (`x`, `y`, `z`, `plane`) | | `ui.sketchToScreen` | `points`: `[[x, y]…]` in active-sketch coordinates | their screen points (null when behind the camera) | | `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 |