* Expose interactive editing and placement of sketch text * Respect document units when editing sketch text * Route Edit Text through the shared feature dialog The sketch text editor is now a Kind::Text dialog opened through Dialog::for_command (frame.rs docking, standard OK/Cancel, Enter/Esc). Its fields are ordinary dialog fields: they take expressions, parameters and units, and bare lengths are in the design's units. Untouched fields keep their exact saved values. The dialog stays open across unrelated design changes and closes only when its text is gone, the sketch ends, or another command starts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: bherbruck <brennen.herbruck@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1261 lines
56 KiB
Rust
1261 lines
56 KiB
Rust
//! 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, Vec3};
|
|
|
|
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) };
|
|
/// Glyphs drawn this frame (constraint id, screen centre) and the selected one.
|
|
static GLYPHS: RefCell<Vec<(String, Pos2)>> = const { RefCell::new(Vec::new()) };
|
|
static PICKED: RefCell<Option<String>> = const { RefCell::new(None) };
|
|
/// The inference the cursor snapped to last (label, sketch point), for the hint.
|
|
static SNAP: RefCell<Option<(&'static str, Vec2)>> = const { RefCell::new(None) };
|
|
static PREV: Cell<Option<Vec2>> = const { Cell::new(None) };
|
|
/// Curvature comb teeth (foot, tip) shown while the comb tool is active.
|
|
static COMBS: RefCell<Vec<Vec<[Vec3; 2]>>> = const { RefCell::new(Vec::new()) };
|
|
/// The image file a Canvas or Decal tool places.
|
|
static IMAGE: RefCell<Option<String>> = const { RefCell::new(None) };
|
|
/// Analysis markers shown while an analysis tool is active: (where, label).
|
|
static MARKS: RefCell<Vec<(Vec3, String)>> = const { RefCell::new(Vec::new()) };
|
|
/// Isocurve analysis polylines (as segments) while the tool is active.
|
|
static ISO: RefCell<Vec<Vec<[Vec3; 2]>>> = const { RefCell::new(Vec::new()) };
|
|
/// Center of Mass has just started: measure everything on the next frame.
|
|
static DEFERRED: Cell<bool> = const { Cell::new(false) };
|
|
}
|
|
|
|
/// Commands registered on one panel that Fusion also lists on another: the analyses (sketch
|
|
/// INSPECT) on SOLID INSPECT, and Interference (SOLID INSPECT) on sketch INSPECT.
|
|
const ALSO_IN: &[(&str, &str, &str)] = &[
|
|
("SOLID", "INSPECT", "inspect.zebra"),
|
|
("SOLID", "INSPECT", "inspect.draft"),
|
|
("SOLID", "INSPECT", "inspect.curvature_map"),
|
|
("SOLID", "INSPECT", "inspect.environment_map"),
|
|
("SOLID", "INSPECT", "inspect.accessibility"),
|
|
("SOLID", "INSPECT", "inspect.minimum_radius"),
|
|
("SOLID", "INSPECT", "inspect.isocurve"),
|
|
("SOLID", "INSPECT", "inspect.curvature_comb"),
|
|
("SOLID", "INSPECT", "inspect.center_of_mass"),
|
|
("SKETCH", "INSPECT", "inspect.interference"),
|
|
];
|
|
|
|
/// A panel's commands plus the ones it shares with another panel (see [`ALSO_IN`]).
|
|
pub fn also_in<'a>(
|
|
tab: &str,
|
|
panel: &str,
|
|
mut cmds: Vec<&'a solvecraft_engine::CommandSpec>,
|
|
specs: &[&'a solvecraft_engine::CommandSpec],
|
|
) -> Vec<&'a solvecraft_engine::CommandSpec> {
|
|
for (t, p, id) in ALSO_IN {
|
|
if *t == tab
|
|
&& *p == panel
|
|
&& !cmds.iter().any(|c| c.id == *id)
|
|
&& let Some(c) = specs.iter().find(|c| c.id == *id)
|
|
{
|
|
cmds.push(c);
|
|
}
|
|
}
|
|
cmds
|
|
}
|
|
|
|
/// Run an analysis and keep its result as a marker.
|
|
fn analysis(app: &mut SolveApp, cmd: &str, p: Value) {
|
|
let Ok(v) = app.run(cmd, p) else { return };
|
|
let mark = match cmd {
|
|
"inspect.center_of_mass" => v3(&v["center"]).map(|c| (c, "Center of mass".to_string())),
|
|
_ => v3(&v["at"]).zip(v["min_radius"].as_f64()).map(|(c, r)| (c, format!("R min {}", crate::prefs::show_mm(app, r, 1)))),
|
|
};
|
|
if let Some(m) = mark {
|
|
MARKS.with(|k| k.borrow_mut().push(m));
|
|
} else if cmd == "inspect.minimum_radius" {
|
|
app.set_status("Minimum radius: it is straight (no curvature)", false);
|
|
}
|
|
}
|
|
|
|
/// Isocurve analysis lines.
|
|
fn iso_lines(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
|
if app.tool.as_ref().map(|t| t.cmd) != Some("inspect.isocurve") {
|
|
ISO.with(|i| i.borrow_mut().clear());
|
|
return;
|
|
}
|
|
let tk = crate::theme::Tokens::get();
|
|
ISO.with(|i| {
|
|
for c in i.borrow().iter() {
|
|
for [a, b] in c {
|
|
if let (Some(p), Some(q)) = (proj.to_screen(*a), proj.to_screen(*b)) {
|
|
painter.line_segment([p, q], Stroke::new(1.2, tk.accent));
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Analysis markers: a target with its label.
|
|
fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
|
if !app.tool.as_ref().is_some_and(|t| matches!(t.cmd, "inspect.minimum_radius" | "inspect.center_of_mass")) {
|
|
MARKS.with(|m| m.borrow_mut().clear());
|
|
return;
|
|
}
|
|
let tk = crate::theme::Tokens::get();
|
|
MARKS.with(|m| {
|
|
for (at, label) in m.borrow().iter() {
|
|
let Some(q) = proj.to_screen(*at) else { continue };
|
|
painter.circle(q, 6.0, tk.panel.gamma_multiply(0.8), Stroke::new(1.6, tk.accent));
|
|
painter.line_segment([q - egui::vec2(10.0, 0.0), q + egui::vec2(10.0, 0.0)], Stroke::new(1.2, tk.accent));
|
|
painter.line_segment([q - egui::vec2(0.0, 10.0), q + egui::vec2(0.0, 10.0)], Stroke::new(1.2, tk.accent));
|
|
let r = painter.text(q + egui::vec2(12.0, -12.0), egui::Align2::LEFT_BOTTOM, label, egui::FontId::proportional(12.0), tk.text);
|
|
painter.rect_filled(r.expand(3.0), 3.0, tk.panel.gamma_multiply(0.85));
|
|
painter.text(q + egui::vec2(12.0, -12.0), egui::Align2::LEFT_BOTTOM, label, egui::FontId::proportional(12.0), tk.text);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Commands that start with a step of their own: Canvas and Decal ask for the image first,
|
|
/// Edit Canvas opens the canvas panel. False when the command should not go on.
|
|
pub fn start_hook(app: &mut SolveApp, id: &str) -> bool {
|
|
if id == "sketch.edit_text"
|
|
&& let Some(dialog) = crate::dialogs::Dialog::for_command(app, id)
|
|
{
|
|
app.dialog = Some(dialog);
|
|
return false;
|
|
}
|
|
// Surface analyses toggle: a second click turns the same one off.
|
|
use solvecraft_engine::SurfaceAnalysis as A;
|
|
let same = match (id, app.session.analysis) {
|
|
("inspect.zebra", Some(A::Zebra { .. })) => Some(true),
|
|
("inspect.draft", Some(A::Draft { .. })) => Some(true),
|
|
("inspect.curvature_map", Some(A::Curvature { .. })) => Some(true),
|
|
("inspect.environment_map", Some(A::Environment)) => Some(true),
|
|
("inspect.accessibility", Some(A::Access { .. })) => Some(true),
|
|
("inspect.zebra" | "inspect.draft" | "inspect.curvature_map" | "inspect.environment_map" | "inspect.accessibility", _) => Some(false),
|
|
_ => None,
|
|
};
|
|
if let Some(on) = same {
|
|
let p = if on { json!({"clear": true}) } else { json!({}) };
|
|
if app.run(id, p).is_ok() {
|
|
app.set_status(if on { "Analysis off" } else { "Analysis on (click it again to turn it off)" }, false);
|
|
}
|
|
return false;
|
|
}
|
|
if id == "inspect.center_of_mass" {
|
|
// The whole model at once; the tool stays for picking single bodies.
|
|
MARKS.with(|m| m.borrow_mut().clear());
|
|
DEFERRED.with(|d| d.set(true));
|
|
return true;
|
|
}
|
|
if id == "canvas.edit" {
|
|
// The browser's canvas panel (one panel for canvases).
|
|
if let Some(first) = app.session.doc.canvases.first().map(|c| c.id) {
|
|
app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(app, first, false));
|
|
}
|
|
return false;
|
|
}
|
|
if !matches!(id, "canvas.insert" | "canvas.decal") {
|
|
return true;
|
|
}
|
|
let path = app.services.pick_open.as_ref().and_then(|f| f());
|
|
let ok = path.is_some();
|
|
IMAGE.with(|i| *i.borrow_mut() = path);
|
|
ok
|
|
}
|
|
|
|
fn v3(v: &Value) -> Option<Vec3> {
|
|
Some(Vec3::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?, v.get(2)?.as_f64()?))
|
|
}
|
|
|
|
/// Curvature combs: teeth and the envelope through their tips.
|
|
fn combs(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
|
if app.tool.as_ref().map(|t| t.cmd) != Some("inspect.curvature_comb") {
|
|
COMBS.with(|c| c.borrow_mut().clear());
|
|
return;
|
|
}
|
|
let tk = crate::theme::Tokens::get();
|
|
COMBS.with(|c| {
|
|
for comb in c.borrow().iter() {
|
|
let mut tips: Vec<Pos2> = Vec::new();
|
|
for [a, b] in comb {
|
|
let (Some(p), Some(q)) = (proj.to_screen(*a), proj.to_screen(*b)) else { continue };
|
|
painter.line_segment([p, q], Stroke::new(1.0, tk.warning.gamma_multiply(0.8)));
|
|
tips.push(q);
|
|
}
|
|
painter.add(egui::Shape::line(tips, Stroke::new(1.4, tk.warning)));
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Snap a cursor point on the sketch plane to what Fusion infers while drawing: a line's
|
|
/// midpoint, or alignment (horizontal/vertical) with the previous point of the tool. None:
|
|
/// nothing near, use the grid.
|
|
pub fn refine_snap(
|
|
app: &SolveApp,
|
|
proj: &Proj,
|
|
sk: &solvecraft_engine::sketch::Sketch,
|
|
plane: &solvecraft_engine::geom::Plane,
|
|
lp: Vec2,
|
|
) -> Option<(Vec2, Option<String>)> {
|
|
crate::inference::clear();
|
|
// Millimetres per pixel here.
|
|
let (a, b) = (proj.to_screen(plane.to_world(lp))?, proj.to_screen(plane.to_world(lp + Vec2::X))?);
|
|
let px = 1.0 / (a.distance(b) as f64).max(1e-9);
|
|
let tol = 7.0 * px;
|
|
// A model vertex under the cursor: drawing on it projects it into the sketch.
|
|
for h in pick(app, proj, a) {
|
|
if let Hit::Vertex { point, .. } = h {
|
|
SNAP.with(|s| *s.borrow_mut() = Some(("Pt", plane.to_local(point))));
|
|
return Some((plane.to_local(point), Some(format!("vertex:{},{},{}", point.x, point.y, point.z))));
|
|
}
|
|
}
|
|
let mut best: Option<(f64, &'static str, Vec2)> = None;
|
|
let mut mid_of: Option<String> = None;
|
|
for (i, c) in sk.curves.iter().enumerate() {
|
|
if let solvecraft_engine::sketch::CurveKind::Line { .. } = c.kind
|
|
&& let Some(solvecraft_engine::sketch::Shape::Line { a, b }) = sk.shape(i)
|
|
{
|
|
let m = (a + b) * 0.5;
|
|
let d = m.dist(lp);
|
|
if d < tol && best.is_none_or(|x| d < x.0) {
|
|
best = Some((d, "Mid", m));
|
|
mid_of = Some(c.id.clone());
|
|
}
|
|
}
|
|
}
|
|
// The tool's previous point (a click takes the tool out of the app while it runs, so the
|
|
// last hover's is kept).
|
|
let prev = match app.tool.as_ref() {
|
|
Some(t) => {
|
|
let p = t.pts.last().map(|p| p.0);
|
|
PREV.with(|c| c.set(p));
|
|
p
|
|
}
|
|
None => PREV.with(Cell::get),
|
|
};
|
|
// Line tool: the tangent point of a circle or arc (before plain "on the curve").
|
|
let line_tool = app.tool.as_ref().is_none_or(|t| t.cmd == "sketch.line");
|
|
if best.is_none()
|
|
&& line_tool
|
|
&& let Some(prev) = prev
|
|
&& let Some(i) = crate::inference::tangent(sk, prev, lp, tol)
|
|
{
|
|
best = Some((i.dist, i.label(), i.at));
|
|
}
|
|
// On a curve (coincident with it).
|
|
if best.is_none() {
|
|
for (i, c) in sk.curves.iter().enumerate() {
|
|
let Some(sh) = sk.shape(i) else { continue };
|
|
let d = sh.dist(lp);
|
|
if d < tol * 0.8 && best.is_none_or(|x| d < x.0) {
|
|
let q = sh.project(lp);
|
|
best = Some((d, "On", q));
|
|
mid_of = Some(format!("{}:{},{}", c.id, q.x, q.y));
|
|
}
|
|
}
|
|
}
|
|
if best.is_none()
|
|
&& let Some(prev) = prev
|
|
{
|
|
let (dx, dy) = ((lp.x - prev.x).abs(), (lp.y - prev.y).abs());
|
|
// The free coordinate still follows the grid.
|
|
let step = solvecraft_engine::view::grid_step(app.cam.half_height()).0 / 10.0;
|
|
let g = |v: f64| (v / step).round() * step;
|
|
if dy < tol && dx > tol {
|
|
best = Some((dy, "H", Vec2::new(g(lp.x), prev.y)));
|
|
} else if dx < tol && dy > tol {
|
|
best = Some((dx, "V", Vec2::new(prev.x, g(lp.y))));
|
|
}
|
|
}
|
|
// Line tool: parallel or perpendicular to a line, or on a line's extension.
|
|
if best.is_none()
|
|
&& line_tool
|
|
&& let Some(prev) = prev
|
|
&& let Some(i) = crate::inference::lines(sk, prev, lp, tol)
|
|
{
|
|
best = Some((i.dist, i.label(), i.at));
|
|
}
|
|
SNAP.with(|s| *s.borrow_mut() = best.map(|(_, l, p)| (l, p)));
|
|
// A midpoint snap is a point argument the commands understand ("mid:<line>").
|
|
best.map(|(_, l, p)| {
|
|
let arg = match l {
|
|
"Mid" => mid_of.map(|m| format!("mid:{m}")),
|
|
"On" => mid_of.map(|m| format!("on:{m}")),
|
|
_ => None,
|
|
};
|
|
(p, arg)
|
|
})
|
|
}
|
|
|
|
/// The constraint glyph under a screen position (its constraint id).
|
|
pub fn glyph_at(pos: Pos2) -> Option<String> {
|
|
GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= GLYPH * 0.6).map(|(id, _)| id.clone()))
|
|
}
|
|
|
|
/// Constraint glyphs drawn last frame: (constraint id, screen centre).
|
|
pub fn glyph_positions() -> Vec<(String, Pos2)> {
|
|
GLYPHS.with(|g| g.borrow().clone())
|
|
}
|
|
|
|
/// A click on a constraint glyph selects it (true: the click was used).
|
|
pub fn click_glyph(app: &mut SolveApp, _proj: &Proj, pos: Pos2) -> bool {
|
|
if app.session.active_sketch.is_none() {
|
|
return false;
|
|
}
|
|
let hit = GLYPHS.with(|g| g.borrow().iter().find(|(_, c)| c.distance(pos) <= GLYPH * 0.6).map(|(id, _)| id.clone()));
|
|
let used = hit.is_some();
|
|
PICKED.with(|p| *p.borrow_mut() = hit.clone());
|
|
if let Some(id) = hit {
|
|
app.set_status(format!("Constraint {id} selected: Delete removes it"), false);
|
|
}
|
|
used
|
|
}
|
|
|
|
/// Delete the selected constraint glyph (true when one was selected).
|
|
pub fn delete_glyph(app: &mut SolveApp) -> bool {
|
|
let Some(id) = PICKED.with(|p| p.borrow_mut().take()) else { return false };
|
|
let _ = app.run("sketch.delete", json!({"entities": [id]}));
|
|
true
|
|
}
|
|
|
|
/// 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 {
|
|
"sketch.project" | "sketch.intersect" | "sketch.include_3d" | "sketch.fit_curves_to_section" | "sketch.isoparametric_curve" => Mode::ModelRef,
|
|
"inspect.curvature_comb" | "canvas.insert" | "canvas.decal" => Mode::ModelRef,
|
|
"inspect.minimum_radius" | "inspect.center_of_mass" | "inspect.isocurve" => Mode::ModelRef,
|
|
"sketch.trim" | "sketch.extend" | "sketch.break" => Mode::CurveAt,
|
|
"sketch.line.midpoint" => Mode::Points(2),
|
|
"sketch.arc.tangent" => Mode::Points(2),
|
|
"sketch.ellipse" | "sketch.conic" | "sketch.slot.center_point" => Mode::Points(3),
|
|
"sketch.slot.arc_three_point" | "sketch.slot.arc_center" => Mode::Points(4),
|
|
"sketch.spline.fit_point" | "sketch.spline.control_point" | "sketch.spline.control_point_5" => Mode::Points(0),
|
|
"sketch.text" => Mode::Points(1),
|
|
"sketch.edit_text" => Mode::Entities(1),
|
|
"sketch.blend_curve" => Mode::Points(2),
|
|
"sketch.offset" => Mode::Entities(1),
|
|
"sketch.fillet" | "sketch.chamfer.equal_distance" | "sketch.chamfer.distance_angle" | "sketch.chamfer.two_distance" => Mode::Entities(1),
|
|
"sketch.constraint.curvature" | "sketch.circle.two_tangent" => Mode::Entities(2),
|
|
"sketch.circle.three_tangent" => Mode::Entities(3),
|
|
"sketch.constrainer" | "sketch.constraint.polygon" => Mode::Entities(0),
|
|
"sketch.centerline" => Mode::Entities(1),
|
|
"sketch.mirror" | "sketch.pattern.circular" | "sketch.pattern.rectangular" => Mode::EntitiesThen,
|
|
"sketch.project_to_surface" | "sketch.intersection_curve" | "sketch.spun_profile" => 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(),
|
|
dim_history: Vec::new(),
|
|
dims_stage: usize::MAX,
|
|
dims_focus: false,
|
|
})
|
|
}
|
|
|
|
pub fn hint(id: &str) -> Option<String> {
|
|
Some(
|
|
match id {
|
|
"sketch.project" => "Project: click edges, faces, vertices, other sketches' curves, axes or planes",
|
|
"sketch.intersect" => "Intersect: click faces or edges to cut with the sketch plane",
|
|
"sketch.include_3d" => "Include 3D Geometry: click edges or vertices",
|
|
"sketch.fit_curves_to_section" => "Fit Curves to Mesh Section: click a body",
|
|
"sketch.isoparametric_curve" => "Isoparametric Curve: click a point on a face (Shift: along)",
|
|
"inspect.curvature_comb" => "Curvature comb: click sketch curves or model edges",
|
|
"inspect.minimum_radius" => "Minimum radius: click sketch curves, edges or faces",
|
|
"inspect.center_of_mass" => "Center of mass of all bodies; click a body for its own",
|
|
"inspect.isocurve" => "Isocurves: click faces",
|
|
"canvas.insert" => "Canvas: click a plane or a planar face where the image's centre goes",
|
|
"canvas.decal" => "Decal: click a planar face where the image's centre goes",
|
|
"sketch.trim" => "Trim: click the piece of a curve to remove",
|
|
"sketch.extend" => "Extend: click a curve near the end to extend",
|
|
"sketch.break" => "Break: click a curve where it should split",
|
|
"sketch.line.midpoint" => "Midpoint line: click the middle, then an end",
|
|
"sketch.arc.tangent" => "Tangent arc: click the end of a line or arc, then the arc's end",
|
|
"sketch.ellipse" => "Ellipse: click the centre, the end of the major axis, then a point on it",
|
|
"sketch.conic" => "Conic: click the start, the end, then the apex",
|
|
"sketch.slot.center_point" => "Slot: click the centre, an arc centre, then the width",
|
|
"sketch.slot.arc_three_point" => "Arc slot: click start, a point on the arc, the end, then the width",
|
|
"sketch.slot.arc_center" => "Arc slot: click the centre, the start, the end, then the width",
|
|
"sketch.spline.fit_point" => "Spline: click fit points; click the start to close smoothly, or right-click to finish",
|
|
"sketch.spline.control_point" | "sketch.spline.control_point_5" => "Control point spline: click control points; right-click to finish",
|
|
"sketch.text" => "Text: click where the text starts",
|
|
"sketch.edit_text" => "Edit Text: click an outline of the text to edit",
|
|
"sketch.blend_curve" => "Blend curve: click the ends of two curves",
|
|
"sketch.offset" => "Offset: click a curve (its chain is offset), then the side and distance",
|
|
"sketch.fillet" => "Fillet: click the corner of two lines",
|
|
"sketch.chamfer.equal_distance" | "sketch.chamfer.distance_angle" | "sketch.chamfer.two_distance" => {
|
|
"Chamfer: click the corner of two lines"
|
|
}
|
|
"sketch.constraint.curvature" => "Curvature: click two curves that share an end",
|
|
"sketch.circle.two_tangent" => "2-tangent circle: click two curves (the circle goes near the second click)",
|
|
"sketch.circle.three_tangent" => "3-tangent circle: click three curves",
|
|
"sketch.constrainer" => "Constrain: click one or two entities; right-click to apply",
|
|
"sketch.constraint.polygon" => "Polygon: click the lines of a closed chain; right-click to apply",
|
|
"sketch.centerline" => "Centerline: click lines",
|
|
"sketch.mirror" => "Mirror: click entities, right-click, then click the mirror line",
|
|
"sketch.pattern.circular" => "Circular pattern: click entities, right-click, then click the centre",
|
|
"sketch.pattern.rectangular" => "Rectangular pattern: click entities, right-click, then click two points (direction and spacing)",
|
|
"sketch.project_to_surface" => "Project to surface: click curves of other sketches, right-click, then click the face",
|
|
"sketch.intersection_curve" => "Intersection curve: click two faces",
|
|
"sketch.spun_profile" => "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,
|
|
})
|
|
}
|
|
|
|
/// Corner tools accept endpoints shared by two editable curves. Pick among those endpoints
|
|
/// directly so the origin, a circle centre or an overlapping loose point cannot hide a corner.
|
|
pub(super) fn corner_point(app: &SolveApp, proj: &Proj, pos: Pos2, cmd: &str) -> Option<(Vec2, String)> {
|
|
let st = app.session.world_state();
|
|
let ss = st.sketch(active_sketch(app)?)?;
|
|
let sk = &ss.sketch;
|
|
sk.points
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(pi, point)| {
|
|
let distance = proj.to_screen(ss.plane.to_world(point.pos))?.distance(pos);
|
|
if distance >= 8.0 {
|
|
return None;
|
|
}
|
|
let curves: Vec<_> = sk
|
|
.curves
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(ci, c)| !sk.is_linked_curve(*ci) && c.kind.ends().is_some_and(|(a, b)| a == pi || b == pi))
|
|
.take(3)
|
|
.collect();
|
|
if curves.len() != 2
|
|
|| curves.iter().any(|(_, c)| {
|
|
!matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. })
|
|
&& (cmd != "sketch.fillet" || !matches!(c.kind, solvecraft_engine::sketch::CurveKind::Arc { .. }))
|
|
})
|
|
{
|
|
return None;
|
|
}
|
|
Some((distance, point.pos, point.id.clone()))
|
|
})
|
|
.min_by(|a, b| a.0.total_cmp(&b.0))
|
|
.map(|(_, at, id)| (at, id))
|
|
}
|
|
|
|
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.world_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.world_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 periodic_start_snap(app: &SolveApp, tool: &Tool, proj: &Proj, pos: Pos2) -> Option<Vec2> {
|
|
if tool.cmd != "sketch.spline.fit_point" || tool.pts.len() < 3 || crate::drag_snap::suppressed() {
|
|
return None;
|
|
}
|
|
let first = tool.pts.first()?.0;
|
|
let mut distinct = Vec::new();
|
|
for p in &tool.pts {
|
|
if !distinct.contains(&p.0) {
|
|
distinct.push(p.0);
|
|
}
|
|
if distinct.len() == 3 {
|
|
break;
|
|
}
|
|
}
|
|
if distinct.len() < 3 {
|
|
return None;
|
|
}
|
|
let state = app.session.world_state();
|
|
let plane = &state.sketch(app.session.active_sketch?)?.plane;
|
|
let screen = proj.to_screen(plane.to_world(first))?;
|
|
(screen.distance(pos) <= 8.0).then_some(first)
|
|
}
|
|
|
|
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 if cmd == "inspect.curvature_comb" => {
|
|
let params = hits.iter().find_map(|h| match h {
|
|
Hit::SketchCurve { sketch, id, .. } => Some(json!({"sketch": sketch, "curves": [id]})),
|
|
Hit::Edge { mid, .. } => Some(json!({"edges": [[mid.x, mid.y, mid.z]]})),
|
|
_ => None,
|
|
});
|
|
if let Some(p) = params
|
|
&& let Ok(v) = app.run(cmd, p)
|
|
{
|
|
for comb in v["combs"].as_array().into_iter().flatten() {
|
|
let teeth: Vec<[Vec3; 2]> =
|
|
comb["teeth"].as_array().into_iter().flatten().filter_map(|t| Some([v3(t.get(0)?)?, v3(t.get(1)?)?])).collect();
|
|
COMBS.with(|c| c.borrow_mut().push(teeth));
|
|
}
|
|
}
|
|
}
|
|
Mode::ModelRef if cmd == "inspect.isocurve" => {
|
|
let face = hits.iter().find_map(|h| if let Hit::Face { point, .. } = h { Some(*point) } else { None });
|
|
if let Some(at) = face
|
|
&& let Ok(v) = app.run(cmd, json!({"faces": [[at.x, at.y, at.z]]}))
|
|
{
|
|
for c in v["curves"].as_array().into_iter().flatten() {
|
|
let pts: Vec<Vec3> = c.as_array().into_iter().flatten().filter_map(v3).collect();
|
|
let teeth: Vec<[Vec3; 2]> = pts.windows(2).map(|w| [w[0], w[1]]).collect();
|
|
ISO.with(|i| i.borrow_mut().push(teeth));
|
|
}
|
|
}
|
|
}
|
|
Mode::ModelRef if matches!(cmd, "inspect.minimum_radius" | "inspect.center_of_mass") => {
|
|
let params = hits.iter().find_map(|h| match (cmd, h) {
|
|
("inspect.minimum_radius", Hit::SketchCurve { sketch, id, .. }) => Some(json!({"sketch": sketch, "curves": [id]})),
|
|
("inspect.minimum_radius", Hit::Edge { mid, .. }) => Some(json!({"edges": [[mid.x, mid.y, mid.z]]})),
|
|
("inspect.minimum_radius", Hit::Face { point, .. }) => Some(json!({"faces": [[point.x, point.y, point.z]]})),
|
|
("inspect.center_of_mass", Hit::Face { body, .. }) => Some(json!({"bodies": [body]})),
|
|
_ => None,
|
|
});
|
|
if let Some(p) = params {
|
|
analysis(app, cmd, p);
|
|
}
|
|
}
|
|
Mode::ModelRef if matches!(cmd, "canvas.insert" | "canvas.decal") => {
|
|
let Some(path) = IMAGE.with(|i| i.borrow().clone()) else { return };
|
|
let decal = cmd == "canvas.decal";
|
|
let target = hits.iter().find_map(|h| match h {
|
|
Hit::Face { point, .. } => Some((json!({"face": [point.x, point.y, point.z]}), *point)),
|
|
Hit::Plane { name, point } if !decal => Some((json!(name), *point)),
|
|
_ => None,
|
|
});
|
|
// Empty space while sketching: the sketch plane.
|
|
let target = target.or_else(|| {
|
|
let st = app.session.world_state();
|
|
let ss = st.sketch(app.session.active_sketch?)?;
|
|
let (o, d) = proj.ray(pos);
|
|
let w = ss.plane.intersect_ray(o, d)?;
|
|
let pl = &ss.plane;
|
|
Some((
|
|
json!({"origin": [pl.origin.x, pl.origin.y, pl.origin.z], "x_dir": [pl.x.x, pl.x.y, pl.x.z], "y_dir": [pl.y.x, pl.y.y, pl.y.z]}),
|
|
w,
|
|
))
|
|
});
|
|
let Some((plane, at)) = target else { return };
|
|
let p = if decal {
|
|
json!({"path": path, "face": plane.get("face").cloned().unwrap_or(Value::Null)})
|
|
} else {
|
|
json!({"path": path, "plane": plane, "at": [at.x, at.y, at.z]})
|
|
};
|
|
if let Ok(v) = app.run(cmd, p) {
|
|
IMAGE.with(|i| *i.borrow_mut() = None);
|
|
app.tool = None;
|
|
if let Some(id) = v["canvas"].as_u64() {
|
|
app.tree.canvas_panel = Some(crate::browser::CanvasPanel::new(app, id, false));
|
|
}
|
|
}
|
|
}
|
|
Mode::ModelRef => {
|
|
let r = hits.iter().find_map(|h| model_ref(app, h));
|
|
let Some(r) = r else { return };
|
|
match cmd {
|
|
"sketch.fit_curves_to_section" => {
|
|
if let Some(b) = r.get("body").and_then(Value::as_str) {
|
|
let _ = app.run(cmd, json!({"body": b}));
|
|
}
|
|
}
|
|
"sketch.isoparametric_curve" => {
|
|
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 => {
|
|
let curve = active_sketch(app).and_then(|active| {
|
|
hits.iter().find_map(|hit| match hit {
|
|
Hit::SketchCurve { sketch, id, .. } if *sketch == active => Some(id.clone()),
|
|
_ => None,
|
|
})
|
|
});
|
|
if let (Some(id), Some((p, _))) = (curve, at) {
|
|
let _ = app.run(cmd, json!({"curve": id, "at": xy(p)}));
|
|
}
|
|
}
|
|
Mode::Points(n) => {
|
|
if periodic_start_snap(app, tool, proj, pos).is_some() {
|
|
// Only this deliberate click selects periodic interpolation. A
|
|
// right-click finish and all previously saved splines stay natural.
|
|
let params = json!({"points":tool.pts.iter().map(arg).collect::<Vec<_>>(),"periodic":true});
|
|
if app.run(cmd, params).is_ok() {
|
|
tool.pts.clear();
|
|
crate::sketch_dims::reset(tool);
|
|
}
|
|
return;
|
|
}
|
|
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 == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") {
|
|
if let Some((_, id)) = corner_point(app, proj, pos, cmd) {
|
|
run_corner(app, cmd, &id);
|
|
}
|
|
return;
|
|
}
|
|
if cmd == "sketch.offset" && !tool.picks.is_empty() {
|
|
// Dense derived fit nodes must not snap a nearby side click back
|
|
// onto the source, reducing a deliberate offset to zero.
|
|
let state = app.session.world_state();
|
|
let side = active_sketch(app).and_then(|sid| state.sketch(sid)).and_then(|ss| {
|
|
let (o, d) = proj.ray(pos);
|
|
ss.plane.intersect_ray(o, d).map(|p| ss.plane.to_local(p))
|
|
});
|
|
if let Some(p) = side {
|
|
offset_side(app, tool, p);
|
|
}
|
|
return;
|
|
}
|
|
if cmd == "sketch.offset" {
|
|
if let Some(sid) = active_sketch(app)
|
|
&& let Some(id) = hits.iter().find_map(|hit| match hit {
|
|
Hit::SketchCurve { sketch, id, .. } if *sketch == sid => Some(id.clone()),
|
|
_ => None,
|
|
})
|
|
{
|
|
tool.picks.push(id);
|
|
}
|
|
return;
|
|
}
|
|
let Some((id, _)) = sketch_entity(app, &hits) else { return };
|
|
if cmd == "sketch.edit_text" {
|
|
crate::sketch_text::open(app, &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 {
|
|
"sketch.mirror" => {
|
|
if let Some((line, false)) = sketch_entity(app, &hits) {
|
|
let _ = app.run(cmd, json!({"entities": ents, "line": line}));
|
|
tool.picks.clear();
|
|
}
|
|
}
|
|
"sketch.pattern.circular" => {
|
|
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 {
|
|
"sketch.intersection_curve" => {
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
"sketch.spun_profile" => {
|
|
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();
|
|
}
|
|
}
|
|
_ => {
|
|
// sketch.project_to_surface: 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()) {
|
|
("sketch.line.midpoint", [m, e]) => json!({"mid": arg(m), "end": xy(e.0)}),
|
|
("sketch.arc.tangent", [s, e]) => json!({"start": arg(s), "end": xy(e.0)}),
|
|
("sketch.ellipse", [c, m, q]) => json!({"center": xy(c.0), "major": xy(m.0), "minor": xy(q.0)}),
|
|
("sketch.conic", [a, b, x]) => json!({"start": arg(a), "end": arg(b), "apex": arg(x)}),
|
|
("sketch.slot.center_point", [c, e, w]) => json!({"center": xy(c.0), "end": xy(e.0), "width": width(c.0, e.0, w.0).max(1e-3)}),
|
|
("sketch.slot.arc_three_point", [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)})
|
|
}
|
|
("sketch.slot.arc_center", [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)})
|
|
}
|
|
("sketch.spline.fit_point" | "sketch.spline.control_point" | "sketch.spline.control_point_5", pts) if pts.len() >= 2 => {
|
|
json!({"points": pts.iter().map(arg).collect::<Vec<_>>()})
|
|
}
|
|
("sketch.blend_curve", [a, b]) => match (&a.1, &b.1) {
|
|
(Some(x), Some(y)) => json!({"a": x, "b": y}),
|
|
_ => return,
|
|
},
|
|
("sketch.text", [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 {
|
|
"sketch.fillet" => json!({"point": point, "radius": size}),
|
|
"sketch.chamfer.distance_angle" => json!({"point": point, "distance": size, "angle": 45}),
|
|
"sketch.chamfer.two_distance" => 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 {
|
|
"sketch.constraint.curvature" => json!({"a": k[0], "b": k[1]}),
|
|
"sketch.circle.two_tangent" => {
|
|
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})
|
|
}
|
|
"sketch.circle.three_tangent" => {
|
|
// 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)})
|
|
}
|
|
"sketch.constrainer" => json!({"entities": k}),
|
|
"sketch.constraint.polygon" => 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 != "sketch.offset" || tool.picks.is_empty() {
|
|
return false;
|
|
}
|
|
let st = app.session.world_state();
|
|
let d = active_sketch(app)
|
|
.and_then(|s| st.sketch(s))
|
|
.and_then(|ss| {
|
|
let curve = ss.sketch.curve_index(&tool.picks[0])?;
|
|
if let Some(shape) = ss.sketch.shape(curve) {
|
|
Some(shape.dist(p))
|
|
} else {
|
|
ss.sketch.exact_segs(curve)?.iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).min_by(f64::total_cmp)
|
|
}
|
|
})
|
|
.unwrap_or(0.0);
|
|
if d > 1e-6 {
|
|
let _ = app.run("sketch.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.world_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);
|
|
}
|
|
if let Some((h, _)) = t.hover.as_ref()
|
|
&& let Some(curve) = crate::tools::preview_curve(t, *h, None)
|
|
{
|
|
crate::tools::paint_preview_curve(&curve, &ss.plane, painter, proj, stroke);
|
|
}
|
|
// Picked curves highlighted.
|
|
let mut display_budget = solvecraft_engine::render::SamplingBudget::new(32_768, 65_536);
|
|
for id in t.picks.iter().filter(|x| *x != "|") {
|
|
if let Some(ci) = ss.sketch.curve_index(id) {
|
|
crate::tools::paint_curve(&ss.sketch, ci, &ss.plane, painter, proj, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)), &mut display_budget);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Constraint glyphs of the active sketch: a small badge per constraint at its anchor
|
|
/// (conflicting ones red).
|
|
/// Glyph size and spacing (screen pixels).
|
|
const GLYPH: f32 = 15.0;
|
|
|
|
fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option<Pos2>) {
|
|
let Some(sid) = active_sketch(app) else { return };
|
|
let st = app.session.world_state();
|
|
let Some(ss) = st.sketch(sid) else { return };
|
|
if ss.sketch.view.hide_constraints {
|
|
GLYPHS.with(|g| g.borrow_mut().clear());
|
|
return;
|
|
}
|
|
let tk = crate::theme::Tokens::get();
|
|
let picked = PICKED.with(|p| p.borrow().clone());
|
|
// Where each glyph goes: beside its entity (to its left, away from the line), every entity
|
|
// of a two-entity constraint getting one; glyphs that would overlap step along. A glyph
|
|
// that would sit on a dimension's value goes to the other side of its line.
|
|
let dim_texts: Vec<egui::Rect> = crate::dim_view::text_rects().into_iter().map(|(_, r)| r.expand(2.0)).collect();
|
|
let on_dim = |p: Pos2| {
|
|
let r = egui::Rect::from_center_size(p, egui::vec2(GLYPH, GLYPH));
|
|
dim_texts.iter().any(|d| d.intersects(r))
|
|
};
|
|
let mut placed: Vec<(String, Pos2, Pos2)> = Vec::new();
|
|
for c in &ss.sketch.constraints {
|
|
if c.kind.is_dimension() {
|
|
continue;
|
|
}
|
|
for (at, dir) in glyph_anchors(&ss.sketch, &c.kind) {
|
|
let Some(base) = proj.to_screen(ss.plane.to_world(at)) else { continue };
|
|
// The entity's direction on screen, and its side.
|
|
let side = match dir.and_then(|d| proj.to_screen(ss.plane.to_world(at + d))) {
|
|
Some(q) => {
|
|
let t = (q - base).normalized();
|
|
if t.length() > 0.5 { egui::vec2(-t.y, t.x) } else { egui::vec2(0.7, -0.7) }
|
|
}
|
|
None => egui::vec2(0.7, -0.7),
|
|
};
|
|
let step = if dir.is_some() { egui::vec2(side.y, -side.x) } else { egui::vec2(1.0, 0.0) };
|
|
let free = |sp: Pos2| !placed.iter().any(|(_, q, _)| q.distance(sp) < GLYPH + 1.0);
|
|
let start = |s: egui::Vec2| base + s * (GLYPH * 0.9);
|
|
let side = if on_dim(start(side)) && !on_dim(start(-side)) { -side } else { side };
|
|
// Step along until clear of other glyphs, preferring a spot clear of dimension
|
|
// values too.
|
|
let settle = |avoid_dims: bool| {
|
|
let mut sp = start(side);
|
|
for _ in 0..12 {
|
|
if free(sp) && !(avoid_dims && on_dim(sp)) {
|
|
return Some(sp);
|
|
}
|
|
sp += step * (GLYPH + 2.0);
|
|
}
|
|
None
|
|
};
|
|
let sp = settle(true).or_else(|| settle(false)).unwrap_or_else(|| start(side) + step * (12.0 * (GLYPH + 2.0)));
|
|
placed.push((c.id.clone(), sp, base));
|
|
}
|
|
}
|
|
let hovered = hover.and_then(|h| placed.iter().find(|(_, q, _)| q.distance(h) <= GLYPH * 0.6).map(|(id, _, _)| id.clone()));
|
|
// The hovered (or selected) constraint's entities lit up.
|
|
if let Some(id) = hovered.as_ref().or(picked.as_ref())
|
|
&& let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id)
|
|
{
|
|
let (cs, ps) = constraint_entities(&c.kind);
|
|
let mut display_budget = solvecraft_engine::render::SamplingBudget::new(32_768, 65_536);
|
|
for ci in cs {
|
|
crate::tools::paint_curve(&ss.sketch, ci, &ss.plane, painter, proj, Stroke::new(3.0, tk.accent.gamma_multiply(0.7)), &mut display_budget);
|
|
}
|
|
for pi in ps {
|
|
if let Some(q) = ss.sketch.point(pi).and_then(|q| proj.to_screen(ss.plane.to_world(q))) {
|
|
painter.circle_filled(q, 4.5, tk.accent);
|
|
}
|
|
}
|
|
}
|
|
let mut drawn: Vec<(String, Pos2)> = Vec::new();
|
|
for (id, sp, base) in &placed {
|
|
let Some(c) = ss.sketch.constraints.iter().find(|c| &c.id == id) else { continue };
|
|
let failing = ss.report.failing.contains(id);
|
|
let selected = picked.as_deref() == Some(id.as_str())
|
|
|| app.session.selection.iter().any(|s| matches!(s, solvecraft_engine::Sel::SketchConstraint { id: x } if x == id));
|
|
let lit = selected || hovered.as_deref() == Some(id.as_str());
|
|
let col = if failing {
|
|
tk.error
|
|
} else if lit {
|
|
tk.accent
|
|
} else {
|
|
tk.glyph_text
|
|
};
|
|
// A faint leader back to the entity when the glyph was pushed away from it.
|
|
if sp.distance(*base) > GLYPH * 1.6 {
|
|
painter.line_segment([*base, *sp], Stroke::new(0.8, tk.text_dim.gamma_multiply(0.6)));
|
|
}
|
|
let r = egui::Rect::from_center_size(*sp, egui::vec2(GLYPH, GLYPH));
|
|
painter.rect_filled(r, 3.0, if lit { tk.accent_soft } else { tk.glyph_bg });
|
|
painter.rect_stroke(r, 3.0, Stroke::new(1.0, if failing || lit { col } else { tk.glyph_edge }), egui::StrokeKind::Inside);
|
|
match glyph_icon(c.kind.name()) {
|
|
Some(icon) => crate::icons::paint(painter, r.shrink(2.0), icon, col, tk.glyph_bg, col),
|
|
None => {
|
|
painter.text(*sp, egui::Align2::CENTER_CENTER, glyph_letter(c.kind.name()), egui::FontId::proportional(10.5), col);
|
|
}
|
|
}
|
|
drawn.push((id.clone(), *sp));
|
|
}
|
|
if let (Some(id), Some(h)) = (hovered, hover)
|
|
&& let Some(c) = ss.sketch.constraints.iter().find(|c| c.id == id)
|
|
{
|
|
let label = format!("{} ({id})", c.kind.name());
|
|
let at = h + egui::vec2(12.0, 12.0);
|
|
let g = painter.layout_no_wrap(label, egui::FontId::proportional(11.0), tk.text);
|
|
painter.rect_filled(egui::Rect::from_min_size(at, g.size()).expand(3.0), 3.0, tk.panel);
|
|
painter.galley(at, g, tk.text);
|
|
}
|
|
GLYPHS.with(|g| *g.borrow_mut() = drawn);
|
|
}
|
|
|
|
/// Toolbar icons used as glyphs (H and V stay letters, which tell them apart).
|
|
fn glyph_icon(name: &str) -> Option<&'static str> {
|
|
Some(match name {
|
|
"Coincident" => "c_coincident",
|
|
"Parallel" => "c_parallel",
|
|
"Perpendicular" => "c_perpendicular",
|
|
"Collinear" => "c_collinear",
|
|
"Tangent" => "c_tangent",
|
|
"Smooth" => "c_smooth",
|
|
"Equal" => "c_equal",
|
|
"Concentric" => "c_concentric",
|
|
"MidPoint" => "c_midpoint",
|
|
"Symmetry" => "c_symmetry",
|
|
"Fix" => "c_fix",
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// The curves and points a constraint ties together.
|
|
fn constraint_entities(k: &solvecraft_engine::sketch::ConstraintKind) -> (Vec<usize>, Vec<usize>) {
|
|
use solvecraft_engine::sketch::ConstraintKind::*;
|
|
match *k {
|
|
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![], vec![p, q]),
|
|
Fix { p } => (vec![], vec![p]),
|
|
Midpoint { p, l } => (vec![l], vec![p]),
|
|
PointOnCurve { p, c } => (vec![c], vec![p]),
|
|
Symmetric { p, q, l } => (vec![l], vec![p, q]),
|
|
Horizontal { l } | Vertical { l } => (vec![l], vec![]),
|
|
Parallel { a, b }
|
|
| Perpendicular { a, b }
|
|
| Collinear { a, b }
|
|
| Equal { a, b }
|
|
| Concentric { a, b }
|
|
| Tangent { a, b }
|
|
| Smooth { a, b } => (vec![a, b], vec![]),
|
|
_ => (vec![], vec![]),
|
|
}
|
|
}
|
|
|
|
fn glyph_letter(name: &str) -> &'static str {
|
|
match name {
|
|
"Horizontal" => "H",
|
|
"Vertical" => "V",
|
|
_ => "?",
|
|
}
|
|
}
|
|
|
|
/// Where a constraint's glyphs go: one per entity (the middle of a curve, with its direction
|
|
/// there; a point, without).
|
|
fn glyph_anchors(sk: &solvecraft_engine::sketch::Sketch, k: &solvecraft_engine::sketch::ConstraintKind) -> Vec<(Vec2, Option<Vec2>)> {
|
|
use solvecraft_engine::sketch::ConstraintKind::*;
|
|
// Half way along the curve, and the direction there.
|
|
let mid = |c: usize| -> Option<(Vec2, Option<Vec2>)> {
|
|
let poly = sk.polyline(c);
|
|
let total: f64 = poly.windows(2).map(|w| w[0].dist(w[1])).sum();
|
|
let mut acc = 0.0;
|
|
for w in poly.windows(2) {
|
|
let l = w[0].dist(w[1]);
|
|
if acc + l >= total * 0.5 && l > 0.0 {
|
|
return Some((w[0].lerp(w[1], (total * 0.5 - acc) / l), (w[1] - w[0]).normalized()));
|
|
}
|
|
acc += l;
|
|
}
|
|
poly.first().map(|p| (*p, None))
|
|
};
|
|
let pt = |p: usize| sk.point(p).map(|q| (q, None));
|
|
match *k {
|
|
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } | PointOnCurve { p, .. } => pt(p).into_iter().collect(),
|
|
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => {
|
|
sk.point(p).zip(sk.point(q)).map(|(a, b)| ((a + b) * 0.5, None)).into_iter().collect()
|
|
}
|
|
Horizontal { l } | Vertical { l } => mid(l).into_iter().collect(),
|
|
Parallel { a, b }
|
|
| Perpendicular { a, b }
|
|
| Collinear { a, b }
|
|
| Equal { a, b }
|
|
| Concentric { a, b }
|
|
| Tangent { a, b }
|
|
| Smooth { a, b } => [mid(a), mid(b)].into_iter().flatten().collect(),
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// The inference label next to the snapped cursor (while a tool is active).
|
|
fn snap_hint(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
|
|
if app.tool.is_none() {
|
|
return;
|
|
}
|
|
let Some((label, p)) = SNAP.with(|s| *s.borrow()) else { return };
|
|
let st = app.session.world_state();
|
|
let Some(ss) = active_sketch(app).and_then(|s| st.sketch(s)) else { return };
|
|
let Some(sp) = proj.to_screen(ss.plane.to_world(p)) else { return };
|
|
let tk = crate::theme::Tokens::get();
|
|
let r = egui::Rect::from_min_size(sp + egui::vec2(12.0, -24.0), egui::vec2(24.0, 14.0));
|
|
painter.rect_filled(r, 3.0, tk.accent_soft);
|
|
painter.text(r.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(10.0), tk.text);
|
|
}
|
|
|
|
/// 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, ui.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(proj.rect)));
|
|
snap_hint(app, painter, proj);
|
|
combs(app, painter, proj);
|
|
if DEFERRED.with(|d| d.replace(false)) {
|
|
analysis(app, "inspect.center_of_mass", json!({}));
|
|
}
|
|
marks(app, painter, proj);
|
|
iso_lines(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;
|
|
crate::frame::window(ctx, "Text", crate::frame::Width::Normal)
|
|
.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("sketch.text", json!({"text": text, "at": xy(at), "height": h}));
|
|
}
|
|
}
|
|
Some(false) => {}
|
|
None => TEXT.with(|t| *t.borrow_mut() = Some((at, text))),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "sketch_corner_ui_tests.rs"]
|
|
mod corner_ui_tests;
|