UI: interactive tools for the new sketch commands (crates/ui-egui/src/sketch_tools.rs): Project/Intersect/Include and the other model-pick tools, Trim/Extend/Break by clicking the piece, Offset (curve then side), sketch Fillet/Chamfer at a corner (radius dialog), Curvature, tangent circles, SketchConstrainer, Polygon, Mirror and sketch patterns, Project To Surface, Intersection Curve, Spun Profile, point tools for midpoint line, tangent arc, ellipse, conic, splines, arc slots, blend curve, and a text box for Text; constraint glyphs on the active sketch; text outline points hidden. Hooks in tools.rs/viewport.rs/lib.rs are a few lines
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
9330ee5698
commit
cde3af7a3c
4 files changed
+629
-2
No files matched your search
@@ -21,6 +21,7 @@ pub mod selection;
|
||||
pub mod sketch_dims;
|
||||
#[cfg(test)]
|
||||
mod sketch_edit_tests;
|
||||
pub mod sketch_tools;
|
||||
pub mod theme;
|
||||
pub mod timeline;
|
||||
pub mod toolbar;
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
//! More interactive sketch tools: projection and other model-pick tools, curve tools (trim,
|
||||
//! extend, break, offset, fillet, chamfer), multi-pick tools (mirror, patterns, polygon), point
|
||||
//! tools for free-form curves and slots, text placement, and constraint glyphs. Like `tools`,
|
||||
//! these are UI state only: every action runs a command.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use egui::{Pos2, Stroke};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::geom::Vec2;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::tools::{Kind, Tool};
|
||||
use crate::viewport::{Hit, Proj, pick, sketch_point_at};
|
||||
|
||||
thread_local! {
|
||||
/// A placed text waiting for its string (position, text so far).
|
||||
static TEXT: RefCell<Option<(Vec2, String)>> = const { RefCell::new(None) };
|
||||
/// Shift held this frame.
|
||||
static SHIFT: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
/// What the extra tools do with clicks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
/// Each click on model geometry runs the command with that reference.
|
||||
ModelRef,
|
||||
/// Click a curve of the active sketch where it should change.
|
||||
CurveAt,
|
||||
/// Click points; `n` complete the command (0: until right-click).
|
||||
Points(usize),
|
||||
/// Pick sketch entities; `n` run the command (0: until right-click).
|
||||
Entities(usize),
|
||||
/// Pick entities until right-click, then one more pick or point (mirror line, centre).
|
||||
EntitiesThen,
|
||||
/// Model faces / sketch curves in steps (see the command).
|
||||
Steps,
|
||||
}
|
||||
|
||||
fn mode(id: &str) -> Option<Mode> {
|
||||
Some(match id {
|
||||
"ProjectNewCmd" | "IntersectCmd" | "Include3DGeometry" | "FitCurvesToSectionCommand" | "SketchIsoparametricCurve" => Mode::ModelRef,
|
||||
"TrimSketchCmd" | "ExtendSketchCmd" | "BreakSketchCmd" => Mode::CurveAt,
|
||||
"SketchMidpointLine" => Mode::Points(2),
|
||||
"ArcTangent" => Mode::Points(2),
|
||||
"CircleElipse" | "ConicCurveCmd" | "ShapeSlotCenterPoint" => Mode::Points(3),
|
||||
"ShapeArcSlotThreePoint" | "ShapeArcSlotCenterTwoPoint" => Mode::Points(4),
|
||||
"DrawSpline" | "DrawCVMSpline3D" | "DrawCVMSpline5D" => Mode::Points(0),
|
||||
"MTextCmd" => Mode::Points(1),
|
||||
"BlendG1CurveSketchCmd" => Mode::Points(2),
|
||||
"Offset" => Mode::Entities(1),
|
||||
"FilletSketchCmd" | "ChamferSketchEqualDistance" | "ChamferSketchDistanceAngle" | "ChamferSketchDistanceDistance" => Mode::Entities(1),
|
||||
"ConstraintSmooth" | "CircleTanTanRadius" => Mode::Entities(2),
|
||||
"CircleThreeTangent" => Mode::Entities(3),
|
||||
"SketchConstrainer" | "SketchPolygonConstraintCmd" => Mode::Entities(0),
|
||||
"sketch.centerline" => Mode::Entities(1),
|
||||
"MirrorSketchCommand" | "CircularSketchPatternCommand" | "RectangularSketchPatternCommand" => Mode::EntitiesThen,
|
||||
"ProjectToSurface" | "IntersectionCurve" | "SpunProfileCmd" => Mode::Steps,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A tool for one of the extra sketch commands.
|
||||
pub fn tool_for(id: &str) -> Option<Tool> {
|
||||
mode(id)?;
|
||||
let spec = solvecraft_engine::find_command(id)?;
|
||||
Some(Tool {
|
||||
cmd: spec.id,
|
||||
kind: Kind::Ext,
|
||||
pts: Vec::new(),
|
||||
hover: None,
|
||||
picks: Vec::new(),
|
||||
dims: Vec::new(),
|
||||
dims_stage: usize::MAX,
|
||||
dims_focus: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hint(id: &str) -> Option<String> {
|
||||
Some(
|
||||
match id {
|
||||
"ProjectNewCmd" => "Project: click edges, faces, vertices, other sketches' curves, axes or planes",
|
||||
"IntersectCmd" => "Intersect: click faces or edges to cut with the sketch plane",
|
||||
"Include3DGeometry" => "Include 3D Geometry: click edges or vertices",
|
||||
"FitCurvesToSectionCommand" => "Fit Curves to Mesh Section: click a body",
|
||||
"SketchIsoparametricCurve" => "Isoparametric Curve: click a point on a face (Shift: along)",
|
||||
"TrimSketchCmd" => "Trim: click the piece of a curve to remove",
|
||||
"ExtendSketchCmd" => "Extend: click a curve near the end to extend",
|
||||
"BreakSketchCmd" => "Break: click a curve where it should split",
|
||||
"SketchMidpointLine" => "Midpoint line: click the middle, then an end",
|
||||
"ArcTangent" => "Tangent arc: click the end of a line or arc, then the arc's end",
|
||||
"CircleElipse" => "Ellipse: click the centre, the end of the major axis, then a point on it",
|
||||
"ConicCurveCmd" => "Conic: click the start, the end, then the apex",
|
||||
"ShapeSlotCenterPoint" => "Slot: click the centre, an arc centre, then the width",
|
||||
"ShapeArcSlotThreePoint" => "Arc slot: click start, a point on the arc, the end, then the width",
|
||||
"ShapeArcSlotCenterTwoPoint" => "Arc slot: click the centre, the start, the end, then the width",
|
||||
"DrawSpline" => "Spline: click fit points; right-click to finish",
|
||||
"DrawCVMSpline3D" | "DrawCVMSpline5D" => "Control point spline: click control points; right-click to finish",
|
||||
"MTextCmd" => "Text: click where the text starts",
|
||||
"BlendG1CurveSketchCmd" => "Blend curve: click the ends of two curves",
|
||||
"Offset" => "Offset: click a curve (its chain is offset), then the side and distance",
|
||||
"FilletSketchCmd" => "Fillet: click the corner of two lines",
|
||||
"ChamferSketchEqualDistance" | "ChamferSketchDistanceAngle" | "ChamferSketchDistanceDistance" => "Chamfer: click the corner of two lines",
|
||||
"ConstraintSmooth" => "Curvature: click two curves that share an end",
|
||||
"CircleTanTanRadius" => "2-tangent circle: click two curves (the circle goes near the second click)",
|
||||
"CircleThreeTangent" => "3-tangent circle: click three curves",
|
||||
"SketchConstrainer" => "Constrain: click one or two entities; right-click to apply",
|
||||
"SketchPolygonConstraintCmd" => "Polygon: click the lines of a closed chain; right-click to apply",
|
||||
"sketch.centerline" => "Centerline: click lines",
|
||||
"MirrorSketchCommand" => "Mirror: click entities, right-click, then click the mirror line",
|
||||
"CircularSketchPatternCommand" => "Circular pattern: click entities, right-click, then click the centre",
|
||||
"RectangularSketchPatternCommand" => "Rectangular pattern: click entities, right-click, then click two points (direction and spacing)",
|
||||
"ProjectToSurface" => "Project to surface: click curves of other sketches, right-click, then click the face",
|
||||
"IntersectionCurve" => "Intersection curve: click two faces",
|
||||
"SpunProfileCmd" => "Spun profile: click the body, then the axis line",
|
||||
_ => return None,
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn active_sketch(app: &SolveApp) -> Option<u64> {
|
||||
app.session.active_sketch
|
||||
}
|
||||
|
||||
/// A model reference for a hit (for Project and friends).
|
||||
fn model_ref(app: &SolveApp, h: &Hit) -> Option<Value> {
|
||||
let active = active_sketch(app);
|
||||
Some(match h {
|
||||
Hit::Vertex { body, point } => json!({"vertex": [point.x, point.y, point.z], "body": body}),
|
||||
Hit::Edge { body, mid, .. } => json!({"edge": [mid.x, mid.y, mid.z], "body": body}),
|
||||
Hit::Face { body, point, .. } => json!({"face": [point.x, point.y, point.z], "body": body}),
|
||||
Hit::SketchCurve { sketch, id, .. } if Some(*sketch) != active => json!({"sketch": sketch, "curve": id}),
|
||||
Hit::SketchPoint { sketch, id, .. } if Some(*sketch) != active => json!({"sketch": sketch, "point": id}),
|
||||
Hit::Axis { name } => json!({"axis": name}),
|
||||
Hit::Plane { name, .. } => json!({"plane": name}),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The active sketch's curve or point under the cursor.
|
||||
fn sketch_entity(app: &SolveApp, hits: &[Hit]) -> Option<(String, bool)> {
|
||||
let active = active_sketch(app)?;
|
||||
hits.iter().find_map(|h| match h {
|
||||
Hit::SketchPoint { sketch, id, .. } if *sketch == active => Some((id.clone(), true)),
|
||||
Hit::SketchCurve { sketch, id, .. } if *sketch == active => Some((id.clone(), false)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn xy(p: Vec2) -> Value {
|
||||
json!([p.x, p.y])
|
||||
}
|
||||
|
||||
fn arg(p: &(Vec2, Option<String>)) -> Value {
|
||||
match &p.1 {
|
||||
Some(id) => json!(id),
|
||||
None => xy(p.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Length of a curve (for default sizes).
|
||||
fn curve_len(app: &SolveApp, id: &str) -> f64 {
|
||||
let st = app.session.model.state();
|
||||
let Some(ss) = active_sketch(app).and_then(|s| st.sketch(s)) else { return 10.0 };
|
||||
ss.sketch.curve_index(id).map(|c| ss.sketch.polyline(c).windows(2).map(|w| w[0].dist(w[1])).sum::<f64>()).unwrap_or(10.0)
|
||||
}
|
||||
|
||||
/// Lines meeting at a sketch point.
|
||||
fn corner_len(app: &SolveApp, point: &str) -> f64 {
|
||||
let st = app.session.model.state();
|
||||
let Some(ss) = active_sketch(app).and_then(|s| st.sketch(s)) else { return 10.0 };
|
||||
let sk = &ss.sketch;
|
||||
let Some(q) = sk.resolve_point(point) else { return 10.0 };
|
||||
sk.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.kind.uses(q) && matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. }))
|
||||
.map(|(i, _)| sk.polyline(i).windows(2).map(|w| w[0].dist(w[1])).sum::<f64>())
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
.min(1e6)
|
||||
}
|
||||
|
||||
fn round_nice(x: f64) -> f64 {
|
||||
if !(x.is_finite() && x > 0.0) {
|
||||
return 1.0;
|
||||
}
|
||||
let p = 10f64.powf(x.log10().floor());
|
||||
let m = x / p;
|
||||
let n = if m < 1.5 {
|
||||
1.0
|
||||
} else if m < 3.5 {
|
||||
2.0
|
||||
} else if m < 7.5 {
|
||||
5.0
|
||||
} else {
|
||||
10.0
|
||||
};
|
||||
n * p
|
||||
}
|
||||
|
||||
/// A click with an extra tool.
|
||||
pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
let Some(m) = mode(tool.cmd) else { return };
|
||||
let hits = pick(app, proj, pos);
|
||||
let at = sketch_point_at(app, proj, pos);
|
||||
let cmd = tool.cmd;
|
||||
match m {
|
||||
Mode::ModelRef => {
|
||||
let r = hits.iter().find_map(|h| model_ref(app, h));
|
||||
let Some(r) = r else { return };
|
||||
match cmd {
|
||||
"FitCurvesToSectionCommand" => {
|
||||
if let Some(b) = r.get("body").and_then(Value::as_str) {
|
||||
let _ = app.run(cmd, json!({"body": b}));
|
||||
}
|
||||
}
|
||||
"SketchIsoparametricCurve" => {
|
||||
if r.get("face").is_some() {
|
||||
let along = SHIFT.with(Cell::get);
|
||||
let _ = app.run(cmd, json!({"face": r["face"], "body": r["body"], "direction": if along { "v" } else { "u" }}));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = app.run(cmd, json!({"refs": [r]}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Mode::CurveAt => {
|
||||
if let (Some((id, false)), Some((p, _))) = (sketch_entity(app, &hits), at) {
|
||||
let _ = app.run(cmd, json!({"curve": id, "at": xy(p)}));
|
||||
}
|
||||
}
|
||||
Mode::Points(n) => {
|
||||
let Some(p) = at else { return };
|
||||
tool.pts.push(p);
|
||||
if n > 0 && tool.pts.len() >= n {
|
||||
run_points(app, tool);
|
||||
tool.pts.clear();
|
||||
}
|
||||
}
|
||||
Mode::Entities(n) => {
|
||||
if cmd == "Offset" && !tool.picks.is_empty() {
|
||||
if let Some((p, _)) = at {
|
||||
offset_side(app, tool, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((id, is_point)) = sketch_entity(app, &hits) else { return };
|
||||
if cmd == "Offset" {
|
||||
// First the curve, then the side point.
|
||||
if tool.picks.is_empty() && !is_point {
|
||||
tool.picks.push(id);
|
||||
} else if let Some((p, _)) = at {
|
||||
offset_side(app, tool, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if cmd.starts_with("Fillet") || cmd.starts_with("Chamfer") {
|
||||
if is_point {
|
||||
run_corner(app, cmd, &id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if cmd == "sketch.centerline" {
|
||||
let _ = app.run(cmd, json!({"curves": [id]}));
|
||||
return;
|
||||
}
|
||||
if !tool.picks.contains(&id) {
|
||||
tool.picks.push(id);
|
||||
if let Some((p, _)) = at {
|
||||
tool.pts.push((p, None));
|
||||
}
|
||||
}
|
||||
if n > 0 && tool.picks.len() >= n {
|
||||
run_entities(app, tool);
|
||||
tool.picks.clear();
|
||||
tool.pts.clear();
|
||||
}
|
||||
}
|
||||
Mode::EntitiesThen => {
|
||||
// Stage 2 (after a right-click) is marked by a "|" pick.
|
||||
if tool.picks.last().is_some_and(|x| x == "|") {
|
||||
let ents: Vec<String> = tool.picks.iter().filter(|x| *x != "|").cloned().collect();
|
||||
match cmd {
|
||||
"MirrorSketchCommand" => {
|
||||
if let Some((line, false)) = sketch_entity(app, &hits) {
|
||||
let _ = app.run(cmd, json!({"entities": ents, "line": line}));
|
||||
tool.picks.clear();
|
||||
}
|
||||
}
|
||||
"CircularSketchPatternCommand" => {
|
||||
if let Some(p) = at {
|
||||
let _ = app.run(cmd, json!({"entities": ents, "center": arg(&p), "count": 6}));
|
||||
tool.picks.clear();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(p) = at {
|
||||
tool.pts.push(p);
|
||||
if tool.pts.len() >= 2 {
|
||||
let d = tool.pts[1].0 - tool.pts[0].0;
|
||||
if d.len() > 1e-9 {
|
||||
let _ = app.run(cmd, json!({"entities": ents, "dir": xy(d), "count": 3, "spacing": d.len()}));
|
||||
}
|
||||
tool.picks.clear();
|
||||
tool.pts.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some((id, _)) = sketch_entity(app, &hits)
|
||||
&& !tool.picks.contains(&id)
|
||||
{
|
||||
tool.picks.push(id);
|
||||
}
|
||||
}
|
||||
Mode::Steps => match cmd {
|
||||
"IntersectionCurve" => {
|
||||
if let Some(r) = hits.iter().find_map(|h| model_ref(app, h)).filter(|r| r.get("face").is_some()) {
|
||||
tool.picks.push(r.to_string());
|
||||
if tool.picks.len() >= 2 {
|
||||
let a: Value = serde_json::from_str(&tool.picks[0]).unwrap_or(Value::Null);
|
||||
let b: Value = serde_json::from_str(&tool.picks[1]).unwrap_or(Value::Null);
|
||||
let _ = app.run(cmd, json!({"a": a, "b": b}));
|
||||
tool.picks.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
"SpunProfileCmd" => {
|
||||
if tool.picks.is_empty() {
|
||||
if let Some(b) =
|
||||
hits.iter().find_map(|h| model_ref(app, h)).and_then(|r| r.get("body").and_then(Value::as_str).map(str::to_string))
|
||||
{
|
||||
tool.picks.push(b);
|
||||
}
|
||||
} else if let Some((line, false)) = sketch_entity(app, &hits) {
|
||||
let _ = app.run(cmd, json!({"body": tool.picks[0], "axis": line}));
|
||||
tool.picks.clear();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// ProjectToSurface: curves of other sketches, then (after a right-click) a face.
|
||||
let staged = tool.picks.last().is_some_and(|x| x == "|");
|
||||
if staged {
|
||||
if let Some(r) = hits.iter().find_map(|h| model_ref(app, h)).filter(|r| r.get("face").is_some()) {
|
||||
let curves: Vec<Value> = tool.picks.iter().filter(|x| *x != "|").filter_map(|x| serde_json::from_str(x).ok()).collect();
|
||||
let _ = app.run(cmd, json!({"curves": curves, "face": r["face"], "body": r["body"]}));
|
||||
tool.picks.clear();
|
||||
}
|
||||
} else if let Some(r) = hits.iter().find_map(|h| model_ref(app, h)).filter(|r| r.get("curve").is_some()) {
|
||||
tool.picks.push(r.to_string());
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn run_points(app: &mut SolveApp, t: &Tool) {
|
||||
let p = &t.pts;
|
||||
let width = |a: Vec2, b: Vec2, c: Vec2| -> f64 { 2.0 * (b - a).normalized().map(|d| d.cross(c - a).abs()).unwrap_or(1.0) };
|
||||
let params = match (t.cmd, p.as_slice()) {
|
||||
("SketchMidpointLine", [m, e]) => json!({"mid": arg(m), "end": xy(e.0)}),
|
||||
("ArcTangent", [s, e]) => json!({"start": arg(s), "end": xy(e.0)}),
|
||||
("CircleElipse", [c, m, q]) => json!({"center": xy(c.0), "major": xy(m.0), "minor": xy(q.0)}),
|
||||
("ConicCurveCmd", [a, b, x]) => json!({"start": arg(a), "end": arg(b), "apex": arg(x)}),
|
||||
("ShapeSlotCenterPoint", [c, e, w]) => json!({"center": xy(c.0), "end": xy(e.0), "width": width(c.0, e.0, w.0).max(1e-3)}),
|
||||
("ShapeArcSlotThreePoint", [a, th, b, w]) => {
|
||||
json!({"start": xy(a.0), "through": xy(th.0), "end": xy(b.0), "width": (2.0 * w.0.dist(b.0)).max(1e-3)})
|
||||
}
|
||||
("ShapeArcSlotCenterTwoPoint", [c, a, b, w]) => {
|
||||
let r = c.0.dist(a.0);
|
||||
json!({"center": xy(c.0), "start": xy(a.0), "end": xy(b.0), "width": (2.0 * (w.0.dist(c.0) - r).abs()).max(1e-3)})
|
||||
}
|
||||
("DrawSpline" | "DrawCVMSpline3D" | "DrawCVMSpline5D", pts) if pts.len() >= 2 => {
|
||||
json!({"points": pts.iter().map(arg).collect::<Vec<_>>()})
|
||||
}
|
||||
("BlendG1CurveSketchCmd", [a, b]) => match (&a.1, &b.1) {
|
||||
(Some(x), Some(y)) => json!({"a": x, "b": y}),
|
||||
_ => return,
|
||||
},
|
||||
("MTextCmd", [at]) => {
|
||||
let at = at.0;
|
||||
TEXT.with(|t| *t.borrow_mut() = Some((at, String::new())));
|
||||
return;
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
let _ = app.run(t.cmd, params);
|
||||
}
|
||||
|
||||
fn run_corner(app: &mut SolveApp, cmd: &str, point: &str) {
|
||||
let size = round_nice(corner_len(app, point) * 0.2);
|
||||
let params = match cmd {
|
||||
"FilletSketchCmd" => json!({"point": point, "radius": size}),
|
||||
"ChamferSketchDistanceAngle" => json!({"point": point, "distance": size, "angle": 45}),
|
||||
"ChamferSketchDistanceDistance" => json!({"point": point, "distance": size, "distance2": size}),
|
||||
_ => json!({"point": point, "distance": size}),
|
||||
};
|
||||
if let Ok(v) = app.run(cmd, params)
|
||||
&& let Some(p) = v["param"].as_str()
|
||||
{
|
||||
app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, p));
|
||||
}
|
||||
}
|
||||
|
||||
fn run_entities(app: &mut SolveApp, t: &Tool) {
|
||||
let k = &t.picks;
|
||||
let params = match t.cmd {
|
||||
"ConstraintSmooth" => json!({"a": k[0], "b": k[1]}),
|
||||
"CircleTanTanRadius" => {
|
||||
let r = round_nice(curve_len(app, &k[0]).min(curve_len(app, &k[1])) * 0.1);
|
||||
let near = t.pts.last().map(|p| xy(p.0)).unwrap_or(json!([0, 0]));
|
||||
json!({"curves": k, "radius": r, "near": near})
|
||||
}
|
||||
"CircleThreeTangent" => {
|
||||
// Start inside the three picks.
|
||||
let c = t.pts.iter().fold(Vec2::ZERO, |a, p| a + p.0) / t.pts.len().max(1) as f64;
|
||||
json!({"curves": k, "near": xy(c)})
|
||||
}
|
||||
"SketchConstrainer" => json!({"entities": k}),
|
||||
"SketchPolygonConstraintCmd" => json!({"lines": k}),
|
||||
_ => return,
|
||||
};
|
||||
let _ = app.run(t.cmd, params);
|
||||
}
|
||||
|
||||
/// Right-click with an extra tool: finish a chain or move to the next stage. Returns false
|
||||
/// when the tool should be dropped.
|
||||
pub fn finish(app: &mut SolveApp, tool: &mut Tool) -> bool {
|
||||
let Some(m) = mode(tool.cmd) else { return false };
|
||||
match m {
|
||||
Mode::Points(0) if tool.pts.len() >= 2 => {
|
||||
run_points(app, tool);
|
||||
tool.pts.clear();
|
||||
true
|
||||
}
|
||||
Mode::Entities(0) if !tool.picks.is_empty() => {
|
||||
run_entities(app, tool);
|
||||
tool.picks.clear();
|
||||
tool.pts.clear();
|
||||
true
|
||||
}
|
||||
Mode::EntitiesThen | Mode::Steps if !tool.picks.is_empty() && tool.picks.last().is_some_and(|x| x != "|") => {
|
||||
tool.picks.push("|".into());
|
||||
true
|
||||
}
|
||||
_ if !tool.pts.is_empty() || !tool.picks.is_empty() => {
|
||||
tool.pts.clear();
|
||||
tool.picks.clear();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset: the second click sets the side and distance.
|
||||
fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool {
|
||||
if tool.cmd != "Offset" || tool.picks.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let st = app.session.model.state();
|
||||
let d = active_sketch(app)
|
||||
.and_then(|s| st.sketch(s))
|
||||
.and_then(|ss| ss.sketch.curve_index(&tool.picks[0]).and_then(|c| ss.sketch.shape(c)))
|
||||
.map(|sh| sh.dist(p))
|
||||
.unwrap_or(0.0);
|
||||
if d > 1e-6 {
|
||||
let _ = app.run("Offset", json!({"curves": [tool.picks[0]], "distance": d, "side": xy(p)}));
|
||||
}
|
||||
tool.picks.clear();
|
||||
true
|
||||
}
|
||||
|
||||
/// Rubber band for point tools and pick markers.
|
||||
pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) {
|
||||
let Some(sid) = active_sketch(app) else { return };
|
||||
let st = app.session.model.state();
|
||||
let Some(ss) = st.sketch(sid) else { return };
|
||||
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
|
||||
let tk = crate::theme::Tokens::get();
|
||||
let stroke = Stroke::new(1.5, tk.rubber_band);
|
||||
if let Some((hp, snap)) = &t.hover
|
||||
&& let Some(sp) = to(*hp)
|
||||
{
|
||||
painter.circle_stroke(sp, if snap.is_some() { 6.0 } else { 3.0 }, stroke);
|
||||
}
|
||||
let pts: Vec<Pos2> = t.pts.iter().filter_map(|p| to(p.0)).collect();
|
||||
for w in pts.windows(2) {
|
||||
painter.line_segment([w[0], w[1]], Stroke::new(1.0, tk.rubber_band.gamma_multiply(0.6)));
|
||||
}
|
||||
if let (Some(last), Some((h, _))) = (pts.last(), t.hover.as_ref())
|
||||
&& let Some(hs) = to(*h)
|
||||
{
|
||||
painter.line_segment([*last, hs], stroke);
|
||||
}
|
||||
// Picked curves highlighted.
|
||||
for id in t.picks.iter().filter(|x| *x != "|") {
|
||||
if let Some(ci) = ss.sketch.curve_index(id) {
|
||||
let v: Vec<Pos2> = ss.sketch.polyline(ci).iter().filter_map(|q| to(*q)).collect();
|
||||
painter.add(egui::Shape::line(v, Stroke::new(3.0, tk.accent.gamma_multiply(0.7))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Constraint glyphs of the active sketch: a small badge per constraint at its anchor
|
||||
/// (conflicting ones red).
|
||||
fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
||||
let Some(sid) = active_sketch(app) else { return };
|
||||
let st = app.session.model.state();
|
||||
let Some(ss) = st.sketch(sid) else { return };
|
||||
let tk = crate::theme::Tokens::get();
|
||||
let mut placed: Vec<Pos2> = Vec::new();
|
||||
for c in &ss.sketch.constraints {
|
||||
if c.kind.is_dimension() {
|
||||
continue;
|
||||
}
|
||||
let Some(at) = glyph_anchor(&ss.sketch, &c.kind) else { continue };
|
||||
let Some(mut sp) = proj.to_screen(ss.plane.to_world(at)) else { continue };
|
||||
sp += egui::vec2(9.0, -9.0);
|
||||
// Keep glyphs at one spot from overlapping.
|
||||
while placed.iter().any(|q| q.distance(sp) < 12.0) {
|
||||
sp += egui::vec2(13.0, 0.0);
|
||||
}
|
||||
placed.push(sp);
|
||||
let failing = ss.report.failing.contains(&c.id);
|
||||
let col = if failing { tk.error } else { tk.text_dim };
|
||||
let r = egui::Rect::from_center_size(sp, egui::vec2(12.0, 12.0));
|
||||
painter.rect_filled(r, 2.0, tk.panel.gamma_multiply(0.85));
|
||||
painter.rect_stroke(r, 2.0, Stroke::new(0.8, col), egui::StrokeKind::Inside);
|
||||
painter.text(sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(9.0), col);
|
||||
}
|
||||
}
|
||||
|
||||
fn glyph_letter(name: &str) -> &'static str {
|
||||
match name {
|
||||
"Coincident" => "\u{25cf}",
|
||||
"Horizontal" => "H",
|
||||
"Vertical" => "V",
|
||||
"Parallel" => "//",
|
||||
"Perpendicular" => "\u{22a5}",
|
||||
"Collinear" => "\u{2261}",
|
||||
"Tangent" => "T",
|
||||
"Smooth" => "G2",
|
||||
"Equal" => "=",
|
||||
"Concentric" => "\u{25ce}",
|
||||
"MidPoint" => "M",
|
||||
"Symmetry" => "[]",
|
||||
"Fix" => "\u{1f512}",
|
||||
_ => "?",
|
||||
}
|
||||
}
|
||||
|
||||
fn glyph_anchor(sk: &solvecraft_engine::sketch::Sketch, k: &solvecraft_engine::sketch::ConstraintKind) -> Option<Vec2> {
|
||||
use solvecraft_engine::sketch::ConstraintKind::*;
|
||||
let mid = |c: usize| -> Option<Vec2> {
|
||||
let poly = sk.polyline(c);
|
||||
poly.get(poly.len() / 2).copied()
|
||||
};
|
||||
match *k {
|
||||
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => sk.point(p),
|
||||
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((sk.point(p)? + sk.point(q)?) * 0.5),
|
||||
Horizontal { l } | Vertical { l } => mid(l),
|
||||
Parallel { a, .. }
|
||||
| Perpendicular { a, .. }
|
||||
| Collinear { a, .. }
|
||||
| Equal { a, .. }
|
||||
| Concentric { a, .. }
|
||||
| Tangent { a, .. }
|
||||
| Smooth { a, .. } => mid(a),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Per frame, after the viewport is drawn: constraint glyphs and the text-entry box.
|
||||
pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &Proj) {
|
||||
SHIFT.with(|c| c.set(ui.input(|i| i.modifiers.shift)));
|
||||
glyphs(app, painter, proj);
|
||||
text_entry(app, ui.ctx());
|
||||
}
|
||||
|
||||
/// The text-entry box for a placed text.
|
||||
fn text_entry(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some((at, mut text)) = TEXT.with(|t| t.borrow_mut().take()) else { return };
|
||||
let mut done = None;
|
||||
egui::Window::new("Text").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)).show(ctx, |ui| {
|
||||
let r = ui.text_edit_singleline(&mut text);
|
||||
r.request_focus();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("OK").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
done = Some(true);
|
||||
}
|
||||
if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
done = Some(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
match done {
|
||||
Some(true) => {
|
||||
if !text.trim().is_empty() {
|
||||
let h = round_nice(app.cam.half_height() * 0.1);
|
||||
let _ = app.run("MTextCmd", json!({"text": text, "at": xy(at), "height": h}));
|
||||
}
|
||||
}
|
||||
Some(false) => {}
|
||||
None => TEXT.with(|t| *t.borrow_mut() = Some((at, text))),
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ pub enum Kind {
|
||||
/// Pick sketch entities; `n` picks run the command.
|
||||
Pick(usize),
|
||||
Dimension,
|
||||
/// A tool from `sketch_tools` (it handles its own clicks).
|
||||
Ext,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -64,7 +66,7 @@ impl Tool {
|
||||
"ConstraintMidPoint" => ("ConstraintMidPoint", Kind::Pick(2)),
|
||||
"ConstraintSymmetry" => ("ConstraintSymmetry", Kind::Pick(3)),
|
||||
"sketch.construction" => ("sketch.construction", Kind::Pick(1)),
|
||||
_ => return None,
|
||||
_ => return crate::sketch_tools::tool_for(id),
|
||||
};
|
||||
Some(Tool { cmd, kind, pts: Vec::new(), hover: None, picks: Vec::new(), dims: Vec::new(), dims_stage: usize::MAX, dims_focus: false })
|
||||
}
|
||||
@@ -82,7 +84,7 @@ pub fn hint(id: &str) -> String {
|
||||
"SketchDimension" => "Dimension: pick a line, circle or arc (or two points/lines)".into(),
|
||||
"ShapeSlotCenterToCenter" | "ShapeSlotOverall" => "Slot: click both ends, then the width".into(),
|
||||
_ if id.starts_with("Constraint") => "Constraint: pick the sketch entities".into(),
|
||||
_ => "Click in the sketch".into(),
|
||||
_ => crate::sketch_tools::hint(id).unwrap_or_else(|| "Click in the sketch".into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +133,7 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
tool.picks.clear();
|
||||
}
|
||||
}
|
||||
Kind::Ext => crate::sketch_tools::on_click(app, &mut tool, proj, pos),
|
||||
Kind::Dimension => {
|
||||
let hits = pick(app, proj, pos);
|
||||
let hit = hits.iter().find_map(|h| match h {
|
||||
@@ -249,6 +252,12 @@ 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 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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(t) = app.tool.as_mut()
|
||||
&& !t.pts.is_empty()
|
||||
{
|
||||
@@ -261,6 +270,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 t.kind == Kind::Ext {
|
||||
return crate::sketch_tools::preview(app, t, painter, proj);
|
||||
}
|
||||
let Some(sid) = app.session.active_sketch else { return };
|
||||
let st = app.session.model.state();
|
||||
let Some(ss) = st.sketch(sid) else { return };
|
||||
|
||||
@@ -956,6 +956,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_tools::show(app, ui, &painter, &proj);
|
||||
crate::sketch_dims::show(app, ui, &proj);
|
||||
view_cube(app, ui, rect);
|
||||
nav_bar(app, ui, rect);
|
||||
@@ -1133,6 +1134,9 @@ fn overlays(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj
|
||||
let Some(ss) = st.sketch(sid) else { return };
|
||||
let sk = &ss.sketch;
|
||||
for (i, p) in sk.points.iter().enumerate() {
|
||||
if !solvecraft_engine::view::sketch_point_visible(sk, i) {
|
||||
continue;
|
||||
}
|
||||
let Some(sp) = proj.to_screen(ss.plane.to_world(p.pos)) else { continue };
|
||||
let det = ss.report.point_determined.get(i).copied().unwrap_or(false);
|
||||
let c = if det { t.sketch_point } else { t.sketch_point_free };
|
||||
|
||||
Reference in new issue
Block a user