Files
OneCAD/crates/ui-egui/src/inference.rs
T
bherbruckandClaude Opus 5.5 9ba72e118b Sketch: accept redundant but consistent constraints (#687) (#699)
A clover of same-size rectangles drawn around a shared corner was refused as over-constrained.

Constraints that remove no degree of freedom but still hold (zero residual) are now kept and shown dimmed as redundant. Conflicting ones are still refused. A redundant driving dimension is refused with "already determined by ...". Typed sizes become driven (reference) dimensions. Drawing no longer adds Horizontal/Vertical to a side that is already implied through two coincident corners. The solver reports redundant constraints using a Jacobian row-dependence test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:12:55 -04:00

229 lines
8.4 KiB
Rust

//! 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 solidcraft_engine::geom::Vec2;
use solidcraft_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, mut params) = match i.kind {
Kind::Parallel => ("sketch.constraint.parallel", json!({"a": i.curve, "b": line})),
Kind::Perpendicular => ("sketch.constraint.perpendicular", json!({"a": i.curve, "b": line})),
Kind::Tangent => ("sketch.constraint.tangent", json!({"a": i.curve, "b": line})),
Kind::Extension => ("sketch.constraint.coincident", json!({"a": format!("{line}.end"), "b": i.curve})),
};
// An inference only adds what the sketch does not already have: one that is already implied
// (redundant) or would conflict is simply not kept.
params["if_redundant"] = json!("refuse");
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 != "sketch.line") {
return;
}
let Some(i) = current() else { return };
let st = app.session.world_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);
}
}