1234 lines
54 KiB
Rust
1234 lines
54 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 {r:.3} mm"))),
|
|
};
|
|
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 {
|
|
// 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.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.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.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.
|
|
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 mut sp = base + side * (GLYPH * 0.9);
|
|
for _ in 0..12 {
|
|
if !placed.iter().any(|(_, q, _)| q.distance(sp) < GLYPH + 1.0) {
|
|
break;
|
|
}
|
|
sp += step * (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;
|