3D sketch: with 3D Sketch on, the Line, Spline and Point tools take points off the sketch plane, snapping to model vertices, edges and faces and to drawn 3D points (segments that stay on the plane are ordinary sketch lines); drawn 3D curves are wires with fit points (sketch.line3d, sketch.spline3d: centripetal Catmull-Rom, sketch.point3d), a clicked 3D point gets a move triad (drag along X/Y/Z or parallel to the sketch plane: sketch.move3d, joined ends move together), Delete removes it; sweep and pipe paths may mix 3D curves with sketch curves; control: ui.triad, ui.worldToScreen
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
d2068e07a3
commit
d40ef5bbdc
14 files changed
+755
-10
No files matched your search
@@ -1103,6 +1103,10 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
|
||||
if ids.is_empty() || ids.len() > 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<solvecraft_geom::Seg2> = 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<Vec<k
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A chain of sketch curves and 3D sketch curves (wires) as line segments in world space,
|
||||
/// joined end to end, starting at the end nearest `start` (no start: the first curve runs toward
|
||||
/// the second).
|
||||
fn path_segments_3d(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<kernel::PathSeg>> {
|
||||
let mut pieces: Vec<Vec<Vec3>> = Vec::new();
|
||||
for id in ids {
|
||||
let pts: Vec<Vec3> = if let Some(ci) = ps.sketch.curve_index(id) {
|
||||
let mut v: Vec<Vec3> = 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<Vec3>| (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<kernel::PathSeg> = 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::<Vec<_>>())
|
||||
.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<String>) {
|
||||
match &f.kind {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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<Vec<Vec3>> {
|
||||
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<Value> {
|
||||
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::<Result<Vec<String>>>()
|
||||
})?;
|
||||
Ok(json!({"curves": ids, "sketch": info}))
|
||||
}
|
||||
|
||||
fn spline3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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}))
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -18,6 +18,53 @@ pub struct Wire {
|
||||
pub pts: Vec<Vec3>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
/// 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<Vec3>,
|
||||
}
|
||||
|
||||
/// 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<Vec3> {
|
||||
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<Vec<Vec3>> {
|
||||
@@ -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<String> {
|
||||
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<usize> {
|
||||
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));
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
))
|
||||
}
|
||||
"ui.worldToScreen" => {
|
||||
let Some(rect) = app.viewport.rect else { return err("no viewport") };
|
||||
let proj = crate::viewport::projection(app, rect);
|
||||
let pts: Vec<Value> = 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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec3>)> = const { RefCell::new((None, Vec::new())) };
|
||||
/// The drawn point with the triad: wire id and fit index.
|
||||
static TRIAD: RefCell<Option<(String, usize)>> = const { RefCell::new(None) };
|
||||
/// A triad drag: axis (None: in the sketch plane), undo depth, grab offset.
|
||||
static DRAG: RefCell<Option<(Option<Vec3>, usize, Vec3)>> = const { RefCell::new(None) };
|
||||
/// Where the cursor would put a point (for the rubber band), and its snap label.
|
||||
static HOVER: RefCell<Option<(Vec3, &'static str)>> = 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<Vec2> {
|
||||
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<Vec3> {
|
||||
CHAIN.with(|c| c.borrow().1.clone())
|
||||
}
|
||||
|
||||
fn set_chain(cmd: Option<&'static str>, pts: Vec<Vec3>) {
|
||||
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<Value, String>) {
|
||||
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<Value> = 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<Value> = 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<Pos2> = 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<Vec3> = pts.clone();
|
||||
if let Some((h, _)) = hover {
|
||||
w.push(h);
|
||||
}
|
||||
let poly: Vec<Pos2> = 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<egui::Vec2> {
|
||||
(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<Vec3>, 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<Vec3>, p: Vec3) -> Option<Vec3> {
|
||||
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()
|
||||
}
|
||||
@@ -100,6 +100,7 @@ fn xy(p: &(Vec2, Option<String>)) -> 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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user