Line tool inference like Fusion: parallel or perpendicular to existing lines, on a line's extension and tangent to circles and arcs, shown with a dashed guide and the snap label; placing the point adds the constraint (the existing curve stays, the new line follows)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 08:39:22 +00:00
1 parent d40ef5bbdc
commit 2ac226cf0a
5 files changed
+247

No files matched your search

+226
View File
@@ -0,0 +1,226 @@
//! Drawing-time inference for the Line tool, like Fusion's: while the next point is placed the
//! cursor snaps to directions and places taken from the existing geometry (parallel or
//! perpendicular to a line, on the extension of a line, tangent to a circle or arc). A dashed
//! guide and a label show it; placing the point adds the matching constraint.
use std::cell::RefCell;
use egui::{Shape, Stroke};
use serde_json::json;
use solvecraft_engine::geom::Vec2;
use solvecraft_engine::sketch::{CurveKind, Sketch};
use crate::SolveApp;
use crate::theme::Tokens;
use crate::viewport::Proj;
#[derive(Clone, Debug, PartialEq)]
pub enum Kind {
Parallel,
Perpendicular,
Extension,
Tangent,
}
/// A snap taken from existing geometry: what, to which curve, where the point goes, how far the
/// cursor was moved, and the guide to draw (sketch coordinates).
#[derive(Clone, Debug, PartialEq)]
pub struct Inference {
pub kind: Kind,
pub curve: String,
pub at: Vec2,
pub dist: f64,
pub guide: (Vec2, Vec2),
}
impl Inference {
pub fn label(&self) -> &'static str {
match self.kind {
Kind::Parallel => "//",
Kind::Perpendicular => "\u{22a5}",
Kind::Extension => "Ext",
Kind::Tangent => "T",
}
}
}
thread_local! {
static PENDING: RefCell<Option<Inference>> = const { RefCell::new(None) };
}
/// Forget the current inference (another snap won, or the tool ended).
pub fn clear() {
PENDING.with(|p| *p.borrow_mut() = None);
}
pub fn current() -> Option<Inference> {
PENDING.with(|p| p.borrow().clone())
}
fn keep(i: Option<Inference>) -> Option<Inference> {
PENDING.with(|p| *p.borrow_mut() = i.clone());
i
}
fn line_ends(sk: &Sketch, c: usize) -> Option<(Vec2, Vec2)> {
match sk.curves.get(c)?.kind {
CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)),
_ => None,
}
}
/// Tangent from the previous point `prev` to a circle or arc, when the cursor `lp` is near the
/// tangent point (within `tol`).
pub fn tangent(sk: &Sketch, prev: Vec2, lp: Vec2, tol: f64) -> Option<Inference> {
let mut best: Option<Inference> = None;
for (i, c) in sk.curves.iter().enumerate() {
if c.construction || !matches!(c.kind, CurveKind::Circle { .. } | CurveKind::Arc { .. }) {
continue;
}
let (Some(o), Some(r)) = (sk.center(i), sk.radius(i)) else { continue };
let d = prev.dist(o);
if d <= r + 1e-9 {
continue;
}
// The two tangent points seen from `prev`.
let base = (prev - o).angle();
let half = (r / d).acos();
for s in [-1.0, 1.0] {
let t = o + Vec2::from_angle(base + s * half) * r;
let on_arc = sk.shape(i).is_none_or(|sh| sh.dist(t) < 1e-6);
let e = t.dist(lp);
if on_arc && e < tol && best.as_ref().is_none_or(|b| e < b.dist) {
best = Some(Inference { kind: Kind::Tangent, curve: c.id.clone(), at: t, dist: e, guide: (prev, t) });
}
}
}
keep(best)
}
/// Directions and places from lines: parallel or perpendicular to a line (from `prev`), or on
/// the extension of a line beyond its ends.
pub fn lines(sk: &Sketch, prev: Vec2, lp: Vec2, tol: f64) -> Option<Inference> {
let mut best: Option<Inference> = None;
let mut offer = |i: Inference| {
if best.as_ref().is_none_or(|b| i.dist < b.dist) {
best = Some(i);
}
};
let v = lp - prev;
if v.len() < tol * 2.0 {
return keep(None);
}
for (i, c) in sk.curves.iter().enumerate() {
if c.construction && c.link.is_none() {
continue;
}
let Some((a, b)) = line_ends(sk, i) else { continue };
let Some(dir) = (b - a).normalized() else { continue };
// Axis-aligned directions are the H/V snaps' business.
let axis = dir.x.abs() < 1e-9 || dir.y.abs() < 1e-9;
let touches = a.dist(prev) < 1e-9 || b.dist(prev) < 1e-9;
for (kind, d) in [(Kind::Parallel, dir), (Kind::Perpendicular, dir.perp())] {
if axis || (kind == Kind::Parallel && touches) {
continue;
}
let t = v.dot(d);
let at = prev + d * t;
let e = at.dist(lp);
if e < tol {
offer(Inference {
kind,
curve: c.id.clone(),
at,
dist: e,
guide: (prev - d * (t.signum() * tol * 6.0), at + d * (t.signum() * tol * 6.0)),
});
}
}
// The extension: beyond the ends, on the line's own (infinite) line.
let s = (lp - a).dot(dir);
let len = a.dist(b);
if !(-tol..=len + tol).contains(&s) {
let at = a + dir * s;
let e = at.dist(lp);
if e < tol * 0.8 && !touches {
let from = if s < 0.0 { a } else { b };
offer(Inference { kind: Kind::Extension, curve: c.id.clone(), at, dist: e * 0.9, guide: (from, at) });
}
}
}
keep(best)
}
/// The Line tool placed a segment ending at `end` (the new line `line`): add the constraint of
/// the inference it was snapped by.
pub fn commit(app: &mut SolveApp, line: &str, end: Vec2) {
let Some(i) = current().filter(|i| i.at.dist(end) < 1e-9) else { return };
clear();
let (cmd, params) = match i.kind {
Kind::Parallel => ("ConstraintParallel", json!({"a": i.curve, "b": line})),
Kind::Perpendicular => ("ConstraintPerpendicular", json!({"a": i.curve, "b": line})),
Kind::Tangent => ("ConstraintTangent", json!({"a": i.curve, "b": line})),
Kind::Extension => ("ConstraintCoincident", json!({"a": format!("{line}.end"), "b": i.curve})),
};
// An inference that would over-constrain the sketch is simply not kept.
let _ = app.session.execute(cmd, &params);
}
/// The dashed guide of the current inference (its label is the snap hint) (while the Line tool is active).
pub fn show(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
if app.tool.as_ref().is_none_or(|t| t.cmd != "DrawPolyline") {
return;
}
let Some(i) = current() else { return };
let st = app.session.model.state();
let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return };
let to = |p: Vec2| proj.to_screen(ss.plane.to_world(p));
let tk = Tokens::get();
if let (Some(a), Some(b)) = (to(i.guide.0), to(i.guide.1)) {
painter.add(Shape::dashed_line(&[a, b], Stroke::new(1.0, tk.rubber_band), 5.0, 4.0));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sk() -> Sketch {
let mut s = Sketch::new();
let _ = s.add_line(Vec2::new(0.0, 0.0), Vec2::new(10.0, 10.0), None, None, Some("l1"));
let _ = s.add_circle(Vec2::new(30.0, 0.0), 5.0, None, Some("c1"));
s
}
#[test]
fn parallel_and_perpendicular_from_the_previous_point() {
let s = sk();
let i = lines(&s, Vec2::new(0.0, 20.0), Vec2::new(10.2, 29.9), 0.5).expect("parallel");
assert_eq!(i.kind, Kind::Parallel);
assert!((i.at - Vec2::new(0.0, 20.0)).cross(Vec2::new(1.0, 1.0)).abs() < 1e-9);
let i = lines(&s, Vec2::new(0.0, 20.0), Vec2::new(10.1, 10.0), 0.5).expect("perpendicular");
assert_eq!(i.kind, Kind::Perpendicular);
assert!(lines(&s, Vec2::new(0.0, 20.0), Vec2::new(10.0, 25.0), 0.5).is_none());
}
#[test]
fn extension_beyond_the_end() {
let s = sk();
let i = lines(&s, Vec2::new(30.0, 15.0), Vec2::new(20.1, 19.9), 0.5).expect("extension");
assert_eq!(i.kind, Kind::Extension);
assert!((i.at.x - i.at.y).abs() < 1e-9 && i.at.x > 10.0);
}
#[test]
fn tangent_point_on_a_circle() {
let s = sk();
let prev = Vec2::new(30.0, 20.0);
// Tangent points from (30, 20) to the circle at (30, 0) r 5.
let half = (5.0f64 / 20.0).acos();
let t = Vec2::new(30.0, 0.0) + Vec2::from_angle(std::f64::consts::FRAC_PI_2 + half) * 5.0;
let i = tangent(&s, prev, t + Vec2::new(0.2, 0.1), 0.5).expect("tangent");
assert_eq!(i.kind, Kind::Tangent);
assert!(i.at.dist(t) < 1e-9);
assert!((i.at - Vec2::new(30.0, 0.0)).dot(i.at - prev).abs() < 1e-9);
}
}
+1
View File
@@ -20,6 +20,7 @@ pub mod dialogs_sheet;
pub mod dim_view;
pub mod gpu;
pub mod icons;
pub mod inference;
#[cfg(test)]
mod menu_tests;
pub mod palette;
+18
View File
@@ -75,6 +75,7 @@ pub fn refine_snap(
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);
@@ -110,6 +111,15 @@ pub fn refine_snap(
}
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 == "DrawPolyline");
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() {
@@ -135,6 +145,14 @@ pub fn refine_snap(
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)| {
+1
View File
@@ -190,6 +190,7 @@ pub fn place(app: &mut SolveApp, p: (Vec2, Option<String>)) {
dimension(app, &tool, &curves);
// Chain: the next segment starts at this one's end point.
if let Some(id) = curves.first() {
crate::inference::commit(app, id, b.0);
let last = tool.pts.len() - 1;
tool.pts[last].1 = Some(format!("{id}.end"));
}
+1
View File
@@ -1272,6 +1272,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
overlays(app, &painter, &proj);
crate::dim_view::show(app, ui, &painter, &proj);
crate::sketch3d::show(app, &painter, &proj);
crate::inference::show(app, &painter, &proj);
hover_highlight(app, &painter, &proj);
points_2d(app, &painter, &proj);
if let Some(bx) = app.viewport.boxsel {