M0: workspace, geometry foundation, sketch constraint solver, kernel boundary

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 17:01:29 +00:00
commit dafce1f4e2
47 files changed
+5624

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
[package]
name = "solvecraft-doc"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "solvecraft-engine"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "solvecraft-geom"
description = "SolveCraft geometry foundation: vectors, planes, frames, meshes and their measures"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+30
View File
@@ -0,0 +1,30 @@
//! Geometry foundation for SolveCraft: 2D/3D vectors, sketch planes, axis-aligned boxes and
//! triangle meshes with their measures (volume, area, centroid). Units are millimetres.
//!
//! This crate has no knowledge of sketches, documents or the B-rep kernel; every other crate
//! builds on it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod mesh;
mod plane;
mod profile;
mod vec;
pub use mesh::{Aabb3, Mesh, MeshMeasure};
pub use plane::Plane;
pub use profile::{Loop2, Region2, Seg2};
pub use vec::{Vec2, Vec3};
/// Geometric tolerance for coincidence tests (mm).
pub const EPS: f64 = 1e-9;
/// Linear tolerance used for "same point" decisions in modelling (mm).
pub const LINEAR_TOL: f64 = 1e-6;
/// `x` if finite, else `fallback`.
pub fn finite_or(x: f64, fallback: f64) -> f64 {
if x.is_finite() { x } else { fallback }
}
#[cfg(test)]
mod tests;
+141
View File
@@ -0,0 +1,141 @@
use serde::{Deserialize, Serialize};
use crate::Vec3;
/// Axis-aligned bounding box.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Aabb3 {
pub min: Vec3,
pub max: Vec3,
}
impl Default for Aabb3 {
fn default() -> Self {
Aabb3::EMPTY
}
}
impl Aabb3 {
pub const EMPTY: Aabb3 = Aabb3 {
min: Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY),
max: Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY),
};
pub fn is_empty(&self) -> bool {
!(self.min.x <= self.max.x && self.min.y <= self.max.y && self.min.z <= self.max.z)
}
pub fn add(&mut self, p: Vec3) {
if p.is_finite() {
self.min = self.min.min(p);
self.max = self.max.max(p);
}
}
pub fn union(&self, o: &Aabb3) -> Aabb3 {
if self.is_empty() {
return *o;
}
if o.is_empty() {
return *self;
}
Aabb3 { min: self.min.min(o.min), max: self.max.max(o.max) }
}
pub fn center(&self) -> Vec3 {
if self.is_empty() { Vec3::ZERO } else { (self.min + self.max) * 0.5 }
}
pub fn size(&self) -> Vec3 {
if self.is_empty() { Vec3::ZERO } else { self.max - self.min }
}
pub fn diagonal(&self) -> f64 {
self.size().len()
}
}
/// An indexed triangle mesh with per-vertex normals, a face id per triangle (the B-rep face it
/// came from) and edge polylines (the B-rep edges, for display and picking).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Mesh {
pub positions: Vec<Vec3>,
pub normals: Vec<Vec3>,
/// Triangles as vertex index triples (counter-clockwise seen from outside).
pub triangles: Vec<[u32; 3]>,
/// B-rep face index per triangle.
pub tri_face: Vec<u32>,
/// B-rep edges as polylines.
pub edges: Vec<Vec<Vec3>>,
}
/// Mass properties of a closed mesh.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MeshMeasure {
pub volume: f64,
pub area: f64,
pub centroid: Vec3,
}
impl Mesh {
pub fn tri(&self, t: &[u32; 3]) -> Option<[Vec3; 3]> {
Some([*self.positions.get(t[0] as usize)?, *self.positions.get(t[1] as usize)?, *self.positions.get(t[2] as usize)?])
}
pub fn bounds(&self) -> Aabb3 {
let mut b = Aabb3::EMPTY;
for p in &self.positions {
b.add(*p);
}
b
}
/// Signed volume (divergence theorem), surface area and volume centroid.
pub fn measure(&self) -> MeshMeasure {
let (mut v6, mut area, mut c) = (0.0, 0.0, Vec3::ZERO);
for t in &self.triangles {
let Some([a, b, cc]) = self.tri(t) else { continue };
let d = a.dot(b.cross(cc));
v6 += d;
c += (a + b + cc) * d;
area += (b - a).cross(cc - a).len() * 0.5;
}
let volume = v6 / 6.0;
let centroid = if v6.abs() > 1e-12 { c / (4.0 * v6) } else { self.bounds().center() };
MeshMeasure { volume, area, centroid }
}
/// Append another mesh (face ids offset by `face_offset`).
pub fn append(&mut self, o: &Mesh, face_offset: u32) {
let base = u32::try_from(self.positions.len()).unwrap_or(u32::MAX);
self.positions.extend_from_slice(&o.positions);
self.normals.extend_from_slice(&o.normals);
self.triangles.extend(o.triangles.iter().map(|t| [t[0].saturating_add(base), t[1].saturating_add(base), t[2].saturating_add(base)]));
self.tri_face.extend(o.tri_face.iter().map(|f| f.saturating_add(face_offset)));
self.edges.extend(o.edges.iter().cloned());
}
/// Closest intersection of a ray with the mesh: (distance along `dir`, triangle index).
pub fn raycast(&self, o: Vec3, dir: Vec3) -> Option<(f64, usize)> {
let mut best: Option<(f64, usize)> = None;
for (i, t) in self.triangles.iter().enumerate() {
let Some([a, b, c]) = self.tri(t) else { continue };
let (e1, e2) = (b - a, c - a);
let p = dir.cross(e2);
let det = e1.dot(p);
if det.abs() < 1e-14 {
continue;
}
let inv = 1.0 / det;
let s = o - a;
let u = s.dot(p) * inv;
if !(0.0..=1.0).contains(&u) {
continue;
}
let q = s.cross(e1);
let v = dir.dot(q) * inv;
if v < 0.0 || u + v > 1.0 {
continue;
}
let t = e2.dot(q) * inv;
if t > 1e-9 && best.is_none_or(|(bt, _)| t < bt) {
best = Some((t, i));
}
}
best
}
}
+83
View File
@@ -0,0 +1,83 @@
use serde::{Deserialize, Serialize};
use crate::{Vec2, Vec3};
/// A sketch plane: an origin and two orthonormal in-plane axes. The normal is `x × y`.
///
/// World point of sketch point `(u, v)` = `origin + u·x + v·y`.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Plane {
pub origin: Vec3,
#[serde(rename = "x_dir")]
pub x: Vec3,
#[serde(rename = "y_dir")]
pub y: Vec3,
}
impl Plane {
/// Top: sketch x = +X, y = +Y, normal +Z.
pub const XY: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Y };
/// Front: sketch x = +X, y = +Z, normal −Y.
pub const XZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z };
/// Right: sketch x = +Y, y = +Z, normal +X.
pub const YZ: Plane = Plane { origin: Vec3::ZERO, x: Vec3::Y, y: Vec3::Z };
/// Plane through `origin` with the given axes, re-orthonormalised. `None` if degenerate.
pub fn new(origin: Vec3, x: Vec3, y: Vec3) -> Option<Plane> {
let x = x.normalized()?;
let n = x.cross(y).normalized()?;
let y = n.cross(x).normalized()?;
origin.is_finite().then_some(Plane { origin, x, y })
}
/// Plane through `origin` with normal `n` (in-plane axes chosen deterministically).
pub fn from_normal(origin: Vec3, n: Vec3) -> Option<Plane> {
let n = n.normalized()?;
// Horizontal planes keep world X as sketch x; others use the horizontal direction Z × n.
let cand = if n.dot(Vec3::Z).abs() > 0.9 { Vec3::X } else { Vec3::Z.cross(n) };
let x = (cand - n * cand.dot(n)).normalized()?;
Plane::new(origin, x, n.cross(x))
}
/// Named origin plane: `XY`, `XZ`, `YZ` (case-insensitive).
pub fn named(name: &str) -> Option<Plane> {
match name.to_ascii_uppercase().as_str() {
"XY" | "TOP" => Some(Plane::XY),
"XZ" | "FRONT" => Some(Plane::XZ),
"YZ" | "RIGHT" => Some(Plane::YZ),
_ => None,
}
}
pub fn normal(&self) -> Vec3 {
self.x.cross(self.y)
}
pub fn to_world(&self, p: Vec2) -> Vec3 {
self.origin + self.x * p.x + self.y * p.y
}
pub fn dir_to_world(&self, d: Vec2) -> Vec3 {
self.x * d.x + self.y * d.y
}
pub fn to_local(&self, p: Vec3) -> Vec2 {
let d = p - self.origin;
Vec2::new(d.dot(self.x), d.dot(self.y))
}
/// Signed distance of `p` above the plane (along the normal).
pub fn height(&self, p: Vec3) -> f64 {
(p - self.origin).dot(self.normal())
}
/// The plane moved along its normal by `d`.
pub fn offset(&self, d: f64) -> Plane {
Plane { origin: self.origin + self.normal() * d, ..*self }
}
/// Intersection of the ray `o + t·dir` with the plane (t may be negative).
pub fn intersect_ray(&self, o: Vec3, dir: Vec3) -> Option<Vec3> {
let n = self.normal();
let den = dir.dot(n);
if den.abs() < 1e-12 {
return None;
}
let t = (self.origin - o).dot(n) / den;
t.is_finite().then(|| o + dir * t)
}
}
+263
View File
@@ -0,0 +1,263 @@
//! Planar profiles: closed loops of lines and circular arcs, and regions (an outer loop minus
//! holes). Sketches produce them; the kernel turns them into faces.
use std::f64::consts::TAU;
use serde::{Deserialize, Serialize};
use crate::Vec2;
/// One boundary segment in sketch coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Seg2 {
Line {
a: Vec2,
b: Vec2,
},
/// Arc from `start` angle sweeping `sweep` radians (positive = counter-clockwise).
Arc {
center: Vec2,
radius: f64,
start: f64,
sweep: f64,
},
}
impl Seg2 {
pub fn start(&self) -> Vec2 {
match *self {
Seg2::Line { a, .. } => a,
Seg2::Arc { center, radius, start, .. } => center + Vec2::from_angle(start) * radius,
}
}
pub fn end(&self) -> Vec2 {
match *self {
Seg2::Line { b, .. } => b,
Seg2::Arc { center, radius, start, sweep } => center + Vec2::from_angle(start + sweep) * radius,
}
}
pub fn point_at(&self, t: f64) -> Vec2 {
match *self {
Seg2::Line { a, b } => a.lerp(b, t),
Seg2::Arc { center, radius, start, sweep } => center + Vec2::from_angle(start + sweep * t) * radius,
}
}
pub fn mid(&self) -> Vec2 {
self.point_at(0.5)
}
/// Unit tangent at the start (direction of travel).
pub fn start_tangent(&self) -> Vec2 {
match *self {
Seg2::Line { a, b } => (b - a).normalized().unwrap_or(Vec2::X),
Seg2::Arc { start, sweep, .. } => Vec2::from_angle(start).perp() * sweep.signum(),
}
}
/// Unit tangent at the end (direction of travel).
pub fn end_tangent(&self) -> Vec2 {
match *self {
Seg2::Line { a, b } => (b - a).normalized().unwrap_or(Vec2::X),
Seg2::Arc { start, sweep, .. } => Vec2::from_angle(start + sweep).perp() * sweep.signum(),
}
}
pub fn reversed(&self) -> Seg2 {
match *self {
Seg2::Line { a, b } => Seg2::Line { a: b, b: a },
Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center, radius, start: start + sweep, sweep: -sweep },
}
}
pub fn length(&self) -> f64 {
match *self {
Seg2::Line { a, b } => a.dist(b),
Seg2::Arc { radius, sweep, .. } => (radius * sweep).abs(),
}
}
/// Contribution to the loop's signed area (shoelace integral ½∮(x dy − y dx)).
pub fn area_term(&self) -> f64 {
match *self {
Seg2::Line { a, b } => 0.5 * a.cross(b),
Seg2::Arc { radius, sweep, .. } => {
let (a, b) = (self.start(), self.end());
// Chord term plus the circular segment between chord and arc.
0.5 * a.cross(b) + 0.5 * radius * radius * (sweep - sweep.sin())
}
}
}
/// Polyline approximation (including both end points) with at most `tol` chord error.
pub fn polyline(&self, tol: f64) -> Vec<Vec2> {
match *self {
Seg2::Line { a, b } => vec![a, b],
Seg2::Arc { radius, sweep, .. } => {
let tol = tol.max(radius.abs() * 1e-6).max(1e-9);
let step = if tol < radius { 2.0 * (1.0 - tol / radius).clamp(-1.0, 1.0).acos() } else { TAU / 8.0 };
let n = ((sweep.abs() / step.max(1e-6)).ceil() as usize).clamp(2, 4096);
(0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect()
}
}
}
}
/// A closed loop of segments, each starting where the previous one ends.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Loop2 {
pub segs: Vec<Seg2>,
}
impl Loop2 {
/// Full circle as two half arcs (counter-clockwise).
pub fn circle(center: Vec2, radius: f64) -> Loop2 {
Loop2 {
segs: vec![
Seg2::Arc { center, radius, start: 0.0, sweep: std::f64::consts::PI },
Seg2::Arc { center, radius, start: std::f64::consts::PI, sweep: std::f64::consts::PI },
],
}
}
pub fn polygon(pts: &[Vec2]) -> Loop2 {
let n = pts.len();
Loop2 { segs: (0..n).filter_map(|i| Some(Seg2::Line { a: *pts.get(i)?, b: *pts.get((i + 1) % n)? })).collect() }
}
/// Signed area: positive when counter-clockwise.
pub fn signed_area(&self) -> f64 {
self.segs.iter().map(Seg2::area_term).sum()
}
pub fn reversed(&self) -> Loop2 {
Loop2 { segs: self.segs.iter().rev().map(Seg2::reversed).collect() }
}
/// Counter-clockwise copy.
pub fn ccw(&self) -> Loop2 {
if self.signed_area() < 0.0 { self.reversed() } else { self.clone() }
}
pub fn polyline(&self, tol: f64) -> Vec<Vec2> {
let mut out = Vec::new();
for s in &self.segs {
let p = s.polyline(tol);
out.extend(p.iter().take(p.len().saturating_sub(1)));
}
out
}
/// Even-odd point containment against the polyline approximation.
pub fn contains(&self, p: Vec2) -> bool {
let poly = self.polyline(1e-3);
let n = poly.len();
let mut inside = false;
for i in 0..n {
let (Some(a), Some(b)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
if (a.y > p.y) != (b.y > p.y) {
let x = a.x + (p.y - a.y) / (b.y - a.y) * (b.x - a.x);
if p.x < x {
inside = !inside;
}
}
}
inside
}
/// Area-weighted centroid (of the polyline approximation).
pub fn centroid(&self) -> Vec2 {
let poly = self.polyline(1e-3);
let n = poly.len();
let (mut a2, mut c) = (0.0, Vec2::ZERO);
for i in 0..n {
let (Some(p), Some(q)) = (poly.get(i), poly.get((i + 1) % n)) else { continue };
let k = p.cross(*q);
a2 += k;
c += (*p + *q) * k;
}
if a2.abs() < 1e-15 { poly.first().copied().unwrap_or_default() } else { c / (3.0 * a2) }
}
/// Closed within `tol`?
pub fn is_closed(&self, tol: f64) -> bool {
let n = self.segs.len();
n > 0
&& (0..n).all(|i| match (self.segs.get(i), self.segs.get((i + 1) % n)) {
(Some(a), Some(b)) => a.end().dist(b.start()) <= tol,
_ => false,
})
}
}
/// A planar region: outer loop (counter-clockwise) minus holes (clockwise).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Region2 {
pub outer: Loop2,
pub holes: Vec<Loop2>,
}
impl Region2 {
pub fn area(&self) -> f64 {
self.outer.signed_area().abs() - self.holes.iter().map(|h| h.signed_area().abs()).sum::<f64>()
}
/// A point strictly inside the region (for picking and labels).
pub fn interior_point(&self) -> Vec2 {
let c = self.centroid();
if self.contains(c) {
return c;
}
// Scan a few horizontal lines for the midpoint of the widest inside span.
let poly = self.outer.polyline(1e-2);
let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY);
for p in &poly {
y0 = y0.min(p.y);
y1 = y1.max(p.y);
}
for k in 1..16 {
let y = y0 + (y1 - y0) * k as f64 / 16.0;
let mut xs: Vec<f64> = Vec::new();
for l in std::iter::once(&self.outer).chain(&self.holes) {
let pl = l.polyline(1e-2);
let n = pl.len();
for i in 0..n {
let (Some(a), Some(b)) = (pl.get(i), pl.get((i + 1) % n)) else { continue };
if (a.y > y) != (b.y > y) {
xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
}
}
}
xs.sort_by(f64::total_cmp);
for w in xs.chunks(2) {
if let [a, b] = w {
let p = Vec2::new((a + b) * 0.5, y);
if self.contains(p) {
return p;
}
}
}
}
c
}
pub fn contains(&self, p: Vec2) -> bool {
self.outer.contains(p) && !self.holes.iter().any(|h| h.contains(p))
}
pub fn centroid(&self) -> Vec2 {
let ao = self.outer.signed_area().abs();
let mut c = self.outer.centroid() * ao;
let mut a = ao;
for h in &self.holes {
let ah = h.signed_area().abs();
c -= h.centroid() * ah;
a -= ah;
}
if a.abs() < 1e-15 { self.outer.centroid() } else { c / a }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn areas() {
let sq = Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)]);
assert!((sq.signed_area() - 12.0).abs() < 1e-12);
assert!((sq.reversed().signed_area() + 12.0).abs() < 1e-12);
let c = Loop2::circle(Vec2::new(5.0, 5.0), 2.0);
assert!((c.signed_area() - std::f64::consts::PI * 4.0).abs() < 1e-9);
assert!(c.is_closed(1e-9));
let r = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![Loop2::circle(Vec2::ZERO, 5.0).reversed()] };
assert!((r.area() - std::f64::consts::PI * 75.0).abs() < 1e-9);
let ip = r.interior_point();
assert!(r.contains(ip), "{ip:?}");
assert!(sq.contains(Vec2::new(1.0, 1.0)) && !sq.contains(Vec2::new(5.0, 1.0)));
assert!(sq.centroid().dist(Vec2::new(2.0, 1.5)) < 1e-9);
}
}
+64
View File
@@ -0,0 +1,64 @@
use crate::*;
fn cube_mesh(s: f64) -> Mesh {
let p = |x: f64, y: f64, z: f64| Vec3::new(x * s, y * s, z * s);
let positions = vec![p(0., 0., 0.), p(1., 0., 0.), p(1., 1., 0.), p(0., 1., 0.), p(0., 0., 1.), p(1., 0., 1.), p(1., 1., 1.), p(0., 1., 1.)];
let triangles =
vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
let n = triangles.len();
Mesh { normals: vec![Vec3::Z; 8], positions, triangles, tri_face: vec![0; n], edges: vec![] }
}
#[test]
fn cube_measure() {
let m = cube_mesh(2.0).measure();
assert!((m.volume - 8.0).abs() < 1e-12);
assert!((m.area - 24.0).abs() < 1e-12);
assert!(m.centroid.dist(Vec3::new(1.0, 1.0, 1.0)) < 1e-12);
}
#[test]
fn raycast_hits_front_face() {
let m = cube_mesh(2.0);
let (t, _) = m.raycast(Vec3::new(1.0, 1.0, 10.0), Vec3::new(0.0, 0.0, -1.0)).unwrap();
assert!((t - 8.0).abs() < 1e-9);
assert!(m.raycast(Vec3::new(5.0, 5.0, 10.0), Vec3::new(0.0, 0.0, -1.0)).is_none());
}
#[test]
fn planes() {
assert_eq!(Plane::XY.normal(), Vec3::Z);
assert_eq!(Plane::XZ.normal(), Vec3::new(0.0, -1.0, 0.0));
assert_eq!(Plane::YZ.normal(), Vec3::X);
let p = Plane::XZ.to_world(Vec2::new(3.0, 4.0));
assert_eq!(p, Vec3::new(3.0, 0.0, 4.0));
assert_eq!(Plane::XZ.to_local(p), Vec2::new(3.0, 4.0));
let hit = Plane::XY.offset(5.0).intersect_ray(Vec3::new(1.0, 2.0, 10.0), Vec3::new(0.0, 0.0, -2.0)).unwrap();
assert!(hit.dist(Vec3::new(1.0, 2.0, 5.0)) < 1e-12);
assert!(Plane::new(Vec3::ZERO, Vec3::X, Vec3::X).is_none());
let f = Plane::from_normal(Vec3::ZERO, Vec3::new(0.0, 0.0, 3.0)).unwrap();
assert!((f.normal() - Vec3::Z).len() < 1e-12);
assert!(Plane::from_normal(Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0)).is_none());
}
#[test]
fn vec_ops_and_serde() {
let a = Vec3::new(1.0, 2.0, 3.0);
assert_eq!(a.cross(Vec3::X), Vec3::new(0.0, 3.0, -2.0));
assert_eq!(Vec2::new(3.0, 4.0).len(), 5.0);
assert!(Vec2::ZERO.normalized().is_none());
assert!(Vec3::new(0.5, 0.0, 0.0).dist_to_segment(Vec3::ZERO, Vec3::X) < 1e-12);
assert_eq!(serde_json::to_string(&a).unwrap(), "[1.0,2.0,3.0]");
let b: Vec2 = serde_json::from_str("[1,2]").unwrap();
assert_eq!(b, Vec2::new(1.0, 2.0));
}
#[test]
fn aabb() {
let mut b = Aabb3::EMPTY;
assert!(b.is_empty());
b.add(Vec3::new(1.0, 2.0, 3.0));
b.add(Vec3::new(f64::NAN, 0.0, 0.0));
b.add(Vec3::new(-1.0, 0.0, 0.0));
assert_eq!(b.size(), Vec3::new(2.0, 2.0, 3.0));
}
+159
View File
@@ -0,0 +1,159 @@
use std::ops::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign};
use serde::{Deserialize, Serialize};
/// 2D vector / point (sketch space).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(from = "[f64; 2]", into = "[f64; 2]")]
pub struct Vec2 {
pub x: f64,
pub y: f64,
}
/// 3D vector / point (model space, Z up).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(from = "[f64; 3]", into = "[f64; 3]")]
pub struct Vec3 {
pub x: f64,
pub y: f64,
pub z: f64,
}
impl From<[f64; 2]> for Vec2 {
fn from(a: [f64; 2]) -> Self {
Vec2::new(a[0], a[1])
}
}
impl From<Vec2> for [f64; 2] {
fn from(v: Vec2) -> Self {
[v.x, v.y]
}
}
impl From<[f64; 3]> for Vec3 {
fn from(a: [f64; 3]) -> Self {
Vec3::new(a[0], a[1], a[2])
}
}
impl From<Vec3> for [f64; 3] {
fn from(v: Vec3) -> Self {
[v.x, v.y, v.z]
}
}
impl Vec2 {
pub const ZERO: Vec2 = Vec2 { x: 0.0, y: 0.0 };
pub const X: Vec2 = Vec2 { x: 1.0, y: 0.0 };
pub const Y: Vec2 = Vec2 { x: 0.0, y: 1.0 };
pub const fn new(x: f64, y: f64) -> Self {
Vec2 { x, y }
}
pub fn dot(self, o: Vec2) -> f64 {
self.x * o.x + self.y * o.y
}
/// z component of the 3D cross product.
pub fn cross(self, o: Vec2) -> f64 {
self.x * o.y - self.y * o.x
}
pub fn len(self) -> f64 {
self.x.hypot(self.y)
}
pub fn len2(self) -> f64 {
self.dot(self)
}
pub fn dist(self, o: Vec2) -> f64 {
(self - o).len()
}
pub fn normalized(self) -> Option<Vec2> {
let l = self.len();
(l > crate::EPS && l.is_finite()).then(|| self / l)
}
/// Rotated +90°.
pub fn perp(self) -> Vec2 {
Vec2::new(-self.y, self.x)
}
pub fn angle(self) -> f64 {
self.y.atan2(self.x)
}
pub fn from_angle(a: f64) -> Vec2 {
Vec2::new(a.cos(), a.sin())
}
pub fn lerp(self, o: Vec2, t: f64) -> Vec2 {
self + (o - self) * t
}
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite()
}
pub fn extend(self, z: f64) -> Vec3 {
Vec3::new(self.x, self.y, z)
}
}
impl Vec3 {
pub const ZERO: Vec3 = Vec3 { x: 0.0, y: 0.0, z: 0.0 };
pub const X: Vec3 = Vec3 { x: 1.0, y: 0.0, z: 0.0 };
pub const Y: Vec3 = Vec3 { x: 0.0, y: 1.0, z: 0.0 };
pub const Z: Vec3 = Vec3 { x: 0.0, y: 0.0, z: 1.0 };
pub const fn new(x: f64, y: f64, z: f64) -> Self {
Vec3 { x, y, z }
}
pub fn dot(self, o: Vec3) -> f64 {
self.x * o.x + self.y * o.y + self.z * o.z
}
pub fn cross(self, o: Vec3) -> Vec3 {
Vec3::new(self.y * o.z - self.z * o.y, self.z * o.x - self.x * o.z, self.x * o.y - self.y * o.x)
}
pub fn len(self) -> f64 {
self.len2().sqrt()
}
pub fn len2(self) -> f64 {
self.dot(self)
}
pub fn dist(self, o: Vec3) -> f64 {
(self - o).len()
}
pub fn normalized(self) -> Option<Vec3> {
let l = self.len();
(l > crate::EPS && l.is_finite()).then(|| self / l)
}
pub fn lerp(self, o: Vec3, t: f64) -> Vec3 {
self + (o - self) * t
}
pub fn is_finite(self) -> bool {
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
}
pub fn min(self, o: Vec3) -> Vec3 {
Vec3::new(self.x.min(o.x), self.y.min(o.y), self.z.min(o.z))
}
pub fn max(self, o: Vec3) -> Vec3 {
Vec3::new(self.x.max(o.x), self.y.max(o.y), self.z.max(o.z))
}
/// Any unit vector perpendicular to `self` (which should be non-zero).
pub fn any_perp(self) -> Vec3 {
let a = if self.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
self.cross(a).normalized().unwrap_or(Vec3::Z)
}
pub fn to_f32(self) -> [f32; 3] {
[self.x as f32, self.y as f32, self.z as f32]
}
/// Distance from this point to the segment `a`–`b`.
pub fn dist_to_segment(self, a: Vec3, b: Vec3) -> f64 {
let ab = b - a;
let l2 = ab.len2();
let t = if l2 > 0.0 { ((self - a).dot(ab) / l2).clamp(0.0, 1.0) } else { 0.0 };
self.dist(a + ab * t)
}
}
macro_rules! ops {
($t:ident, $($f:ident),+) => {
impl Add for $t { type Output = $t; fn add(self, o: $t) -> $t { $t { $($f: self.$f + o.$f),+ } } }
impl Sub for $t { type Output = $t; fn sub(self, o: $t) -> $t { $t { $($f: self.$f - o.$f),+ } } }
impl Mul<f64> for $t { type Output = $t; fn mul(self, k: f64) -> $t { $t { $($f: self.$f * k),+ } } }
impl Div<f64> for $t { type Output = $t; fn div(self, k: f64) -> $t { $t { $($f: self.$f / k),+ } } }
impl Neg for $t { type Output = $t; fn neg(self) -> $t { $t { $($f: -self.$f),+ } } }
impl AddAssign for $t { fn add_assign(&mut self, o: $t) { $(self.$f += o.$f;)+ } }
impl SubAssign for $t { fn sub_assign(&mut self, o: $t) { $(self.$f -= o.$f;)+ } }
};
}
ops!(Vec2, x, y);
ops!(Vec3, x, y, z);
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "solvecraft-io"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "solvecraft-kernel"
description = "SolveCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, tessellation, measures and STEP, behind our own API"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
solvecraft-geom = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
monstertruck-modeling = { workspace = true }
monstertruck-solid = { workspace = true }
monstertruck-meshing = { workspace = true }
monstertruck-io = { workspace = true }
+232
View File
@@ -0,0 +1,232 @@
use monstertruck_meshing::prelude::*;
use monstertruck_modeling as mt;
use serde::Serialize;
use solvecraft_geom::{Mesh, Vec3};
use crate::{KernelError, Result, guard};
pub(crate) type Solid = mt::Solid;
/// A solid body (one or more closed shells). Cheap to clone; never mutated in place.
#[derive(Clone, Debug)]
pub struct Body {
pub(crate) solid: std::sync::Arc<Solid>,
}
/// A B-rep edge as seen by the rest of the application.
#[derive(Clone, Debug, Serialize)]
pub struct EdgeInfo {
pub index: usize,
/// Polyline approximation.
pub points: Vec<Vec3>,
/// Point halfway along the polyline (used to re-find the edge).
pub mid: Vec3,
pub length: f64,
}
/// A B-rep face.
#[derive(Clone, Debug, Serialize)]
pub struct FaceInfo {
pub index: usize,
pub area: f64,
pub centroid: Vec3,
/// Unit normal if the face is planar.
pub plane_normal: Option<Vec3>,
}
pub(crate) fn p3(v: Vec3) -> mt::Point3 {
mt::Point3::new(v.x, v.y, v.z)
}
pub(crate) fn v3(v: Vec3) -> mt::Vector3 {
mt::Vector3::new(v.x, v.y, v.z)
}
pub(crate) fn from_p3(p: mt::Point3) -> Vec3 {
Vec3::new(p.x, p.y, p.z)
}
fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
let len: f64 = pts.windows(2).map(|w| w.first().zip(w.get(1)).map(|(a, b)| a.dist(*b)).unwrap_or(0.0)).sum();
let mut acc = 0.0;
for w in pts.windows(2) {
if let (Some(a), Some(b)) = (w.first(), w.get(1)) {
let l = a.dist(*b);
if acc + l >= len * 0.5 && l > 0.0 {
return (a.lerp(*b, (len * 0.5 - acc) / l), len);
}
acc += l;
}
}
(pts.first().copied().unwrap_or_default(), len)
}
impl Body {
pub(crate) fn new(mut solid: Solid) -> Result<Body> {
if solid.is_empty() {
return Err(KernelError::Failed("empty result".into()));
}
// Orient outward: a closed solid must have positive volume.
let v = guard("orient", || Ok(solid.triangulation(1.0).to_polygon().volume()))?;
if v < 0.0 {
solid.not();
}
Ok(Body { solid: std::sync::Arc::new(solid) })
}
/// A deep copy of the solid that can be mutated without affecting other bodies.
pub(crate) fn deep_copy(&self) -> Solid {
mt::builder::clone(&*self.solid)
}
/// Characteristic size (bounding box diagonal) for tolerances.
pub fn size(&self) -> f64 {
let mut b = solvecraft_geom::Aabb3::EMPTY;
for v in self.solid.vertex_iter() {
b.add(from_p3(v.point()));
}
let d = b.diagonal();
if d.is_finite() && d > 0.0 { d } else { 1.0 }
}
/// Distinct edges in a stable order (the order of `edges()` and `tessellate().edges`).
pub(crate) fn unique_edges(solid: &Solid) -> Vec<mt::Edge> {
let mut seen = std::collections::HashSet::new();
solid.edge_iter().filter(|e| seen.insert(e.id())).collect()
}
/// Triangulate with chord tolerance `tol` (mm). Triangles carry their face index.
pub fn tessellate(&self, tol: f64) -> Result<Mesh> {
let tol = if tol.is_finite() && tol > 0.0 { tol } else { 0.05 };
let tol = tol.max(self.size() * 1e-6);
guard("tessellate", || {
let meshed = self.solid.triangulation(tol);
let mut out = Mesh::default();
for (fi, face) in meshed.face_iter().enumerate() {
let Some(pm) = face.surface() else { continue };
let flip = !face.orientation();
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
let pos = pm.positions();
let nor = pm.normals();
// Expand to per-corner vertices so normals stay per face (crisp edges).
for tri in pm.faces().triangle_iter() {
let mut idx = [0u32; 3];
for (k, v) in tri.iter().enumerate() {
let Some(p) = pos.get(v.pos) else { continue };
let n = v.nor.and_then(|ni| nor.get(ni)).map(|n| Vec3::new(n.x, n.y, n.z)).unwrap_or(Vec3::Z);
let n = if flip { -n } else { n };
out.positions.push(Vec3::new(p.x, p.y, p.z));
out.normals.push(n);
if let Some(slot) = idx.get_mut(k) {
*slot = u32::try_from(out.positions.len() - 1).unwrap_or(0);
}
}
let t = if flip { [idx[0], idx[2], idx[1]] } else { idx };
out.triangles.push(t);
out.tri_face.push(u32::try_from(fi).unwrap_or(u32::MAX));
}
let _ = base;
}
// Fill missing normals from geometry.
for t in &out.triangles {
let (Some(a), Some(b), Some(c)) =
(out.positions.get(t[0] as usize), out.positions.get(t[1] as usize), out.positions.get(t[2] as usize))
else {
continue;
};
let fnrm = (b - a).cross(c - a).normalized().unwrap_or(Vec3::Z);
for k in t {
if let Some(n) = out.normals.get_mut(*k as usize)
&& (n.dot(fnrm) < -0.2 || n.len() < 0.5)
{
*n = fnrm;
}
}
}
let mut seen = std::collections::HashSet::new();
for e in meshed.edge_iter() {
if !seen.insert(e.id()) {
continue;
}
let c = e.oriented_curve();
out.edges.push(c.0.iter().map(|p| Vec3::new(p.x, p.y, p.z)).collect());
}
Ok(out)
})
}
/// Edges with polylines (index = position in [`Body::tessellate`]'s `edges`).
pub fn edges(&self, tol: f64) -> Result<Vec<EdgeInfo>> {
let m = self.tessellate(tol)?;
Ok(m.edges
.into_iter()
.enumerate()
.map(|(index, points)| {
let (mid, length) = polyline_mid(&points);
EdgeInfo { index, points, mid, length }
})
.collect())
}
/// Faces with area and centroid.
pub fn faces(&self, tol: f64) -> Result<Vec<FaceInfo>> {
let m = self.tessellate(tol)?;
let nf = self.solid.face_iter().count();
let mut acc: Vec<(f64, Vec3, Vec3, bool)> = vec![(0.0, Vec3::ZERO, Vec3::ZERO, true); nf];
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let Some([a, b, c]) = m.tri(t) else { continue };
let cr = (b - a).cross(c - a);
let ar = cr.len() * 0.5;
if let Some(slot) = acc.get_mut(*f as usize) {
slot.0 += ar;
slot.1 += (a + b + c) * (ar / 3.0);
if let Some(n) = cr.normalized() {
if slot.2 == Vec3::ZERO {
slot.2 = n;
} else if slot.2.dot(n) < 1.0 - 1e-6 {
slot.3 = false;
}
}
}
}
Ok(acc
.into_iter()
.enumerate()
.map(|(index, (area, c, n, planar))| FaceInfo {
index,
area,
centroid: if area > 0.0 { c / area } else { Vec3::ZERO },
plane_normal: (planar && n != Vec3::ZERO).then_some(n),
})
.collect())
}
/// Index of the edge closest to `p` (by polyline distance).
pub fn nearest_edge(&self, p: Vec3, tol: f64) -> Result<Option<(usize, f64)>> {
let edges = self.edges(tol)?;
let mut best: Option<(usize, f64)> = None;
for e in &edges {
for w in e.points.windows(2) {
if let (Some(a), Some(b)) = (w.first(), w.get(1)) {
let d = p.dist_to_segment(*a, *b);
if best.is_none_or(|(_, bd)| d < bd) {
best = Some((e.index, d));
}
}
}
}
Ok(best)
}
pub fn face_count(&self) -> usize {
self.solid.face_iter().count()
}
pub fn edge_count(&self) -> usize {
Self::unique_edges(&self.solid).len()
}
pub fn vertex_count(&self) -> usize {
let mut seen = std::collections::HashSet::new();
self.solid.vertex_iter().filter(|v| seen.insert(v.id())).count()
}
pub fn shell_count(&self) -> usize {
self.solid.boundaries().len()
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Building bodies: extrude and revolve planar regions, primitives.
use monstertruck_modeling as mt;
use mt::builder;
use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use crate::body::{Body, Solid, p3, v3};
use crate::{KernelError, Result, guard};
const MAX_SEGS: usize = 20_000;
fn finite(x: f64, what: &str) -> Result<f64> {
if x.is_finite() && x.abs() < 1e7 { Ok(x) } else { Err(KernelError::Invalid(format!("{what} = {x}"))) }
}
/// A closed wire in 3D from a sketch loop.
fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
let n = lp.segs.len();
if n == 0 || n > MAX_SEGS {
return Err(KernelError::Invalid(format!("loop with {n} segments")));
}
if !lp.is_closed(1e-6) {
return Err(KernelError::Invalid("profile loop is not closed".into()));
}
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
let mut edges = Vec::with_capacity(n);
for (i, s) in lp.segs.iter().enumerate() {
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue };
let e: mt::Edge = match *s {
Seg2::Line { .. } => builder::line(a, b),
Seg2::Arc { sweep, .. } => {
if sweep.abs() < 1e-9 {
return Err(KernelError::Invalid("zero-length arc".into()));
}
// Split big arcs so every piece is well conditioned.
let mid = plane.to_world(s.mid());
if sweep.abs() > std::f64::consts::PI * 1.5 {
let vm = builder::vertex(p3(mid));
let q1 = plane.to_world(s.point_at(0.25));
let q3 = plane.to_world(s.point_at(0.75));
edges.push(
builder::try_circle_arc(a, &vm, mt::builder::CircularArcConstraint::ThroughPoint(p3(q1)))
.map_err(|e| KernelError::Invalid(e.to_string()))?,
);
builder::try_circle_arc(&vm, b, mt::builder::CircularArcConstraint::ThroughPoint(p3(q3)))
.map_err(|e| KernelError::Invalid(e.to_string()))?
} else {
builder::try_circle_arc(a, b, mt::builder::CircularArcConstraint::ThroughPoint(p3(mid)))
.map_err(|e| KernelError::Invalid(e.to_string()))?
}
}
};
edges.push(e);
}
Ok(edges.into())
}
/// A planar face from a region (outer loop with holes).
pub(crate) fn face(plane: &Plane, r: &Region2) -> Result<mt::Face> {
let mut wires = vec![wire(plane, &r.outer.ccw())?];
for h in &r.holes {
wires.push(wire(plane, &h.ccw().reversed())?);
}
guard("face", || mt::profile::attach_plane_normalized(wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
}
fn region_ok(r: &Region2) -> Result<()> {
if r.area().abs() < 1e-9 {
return Err(KernelError::Invalid("profile has no area".into()));
}
for s in std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs) {
let ok = match *s {
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
Seg2::Arc { center, radius, start, sweep } => {
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
}
};
if !ok {
return Err(KernelError::Invalid("non-finite profile".into()));
}
}
Ok(())
}
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
let (start, end) = (finite(start, "start")?, finite(end, "end")?);
let (lo, hi) = if end >= start { (start, end) } else { (end, start) };
if hi - lo < 1e-6 {
return Err(KernelError::Invalid("extrude distance is zero".into()));
}
if regions.is_empty() {
return Err(KernelError::Invalid("no profile selected".into()));
}
let n = plane.normal();
let mut out = Vec::new();
for r in regions {
region_ok(r)?;
let base = plane.offset(lo);
let f = face(&base, r)?;
let solid: Solid = guard("extrude", || Ok(builder::extrude(&f, v3(n * (hi - lo)))))?;
out.push(Body::new(solid)?);
}
Ok(out)
}
/// Revolve each region about the axis through sketch point `axis_origin` with sketch direction
/// `axis_dir` by `angle` radians (|angle| ≥ 2π = full revolution).
pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result<Vec<Body>> {
let angle = finite(angle, "angle")?;
if angle.abs() < 1e-6 {
return Err(KernelError::Invalid("revolve angle is zero".into()));
}
let d = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?;
if regions.is_empty() {
return Err(KernelError::Invalid("no profile selected".into()));
}
// The profile must lie on one side of the axis.
let o = plane.to_world(axis_origin);
let axis = plane.dir_to_world(d);
let mut out = Vec::new();
for r in regions {
region_ok(r)?;
let (mut neg, mut pos) = (false, false);
for p in r.outer.polyline(1e-3) {
let s = d.cross(p - axis_origin);
neg |= s < -1e-7;
pos |= s > 1e-7;
}
if neg && pos {
return Err(KernelError::Invalid("the profile crosses the revolve axis".into()));
}
let f = face(plane, r)?;
let full = angle.abs() >= std::f64::consts::TAU - 1e-9;
let solid: Solid = guard("revolve", || {
Ok(if full {
builder::revolve(&f, p3(o), v3(axis), builder::SweepAngle::Closed, 4)
} else {
let div = ((angle.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).clamp(1, 8);
builder::revolve(&f, p3(o), v3(axis), builder::SweepAngle::Partial(mt::Rad(angle)), div)
})
})?;
out.push(Body::new(solid)?);
}
Ok(out)
}
/// Axis-aligned box between two corners.
pub fn box_solid(a: Vec3, b: Vec3) -> Result<Body> {
let (lo, hi) = (a.min(b), a.max(b));
let s = hi - lo;
if !(s.x > 1e-6 && s.y > 1e-6 && s.z > 1e-6 && s.is_finite() && s.len() < 1e7) {
return Err(KernelError::Invalid("box needs a positive size in x, y and z".into()));
}
let r = Region2 {
outer: Loop2::polygon(&[Vec2::new(lo.x, lo.y), Vec2::new(hi.x, lo.y), Vec2::new(hi.x, hi.y), Vec2::new(lo.x, hi.y)]),
holes: vec![],
};
let mut v = extrude(&Plane::XY, &[r], lo.z, hi.z)?;
v.pop().ok_or_else(|| KernelError::Failed("box".into()))
}
/// Cylinder with base centre `base`, axis direction `axis`, radius and height.
pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result<Body> {
let plane = Plane::from_normal(base, axis).ok_or_else(|| KernelError::Invalid("cylinder axis".into()))?;
let radius = finite(radius, "radius")?;
if radius <= 1e-6 {
return Err(KernelError::Invalid("radius must be positive".into()));
}
let r = Region2 { outer: Loop2::circle(Vec2::ZERO, radius), holes: vec![] };
let mut v = extrude(&plane, &[r], 0.0, finite(height, "height")?)?;
v.pop().ok_or_else(|| KernelError::Failed("cylinder".into()))
}
/// Sphere: revolve a half disc.
pub fn sphere(center: Vec3, radius: f64) -> Result<Body> {
let radius = finite(radius, "radius")?;
if radius <= 1e-6 {
return Err(KernelError::Invalid("radius must be positive".into()));
}
let plane = Plane { origin: center, ..Plane::XZ };
let half = Loop2 {
segs: vec![
Seg2::Arc { center: Vec2::ZERO, radius, start: -std::f64::consts::FRAC_PI_2, sweep: std::f64::consts::PI },
Seg2::Line { a: Vec2::new(0.0, radius), b: Vec2::new(0.0, -radius) },
],
};
let mut v = revolve(&plane, &[Region2 { outer: half, holes: vec![] }], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?;
v.pop().ok_or_else(|| KernelError::Failed("sphere".into()))
}
/// Torus about the Z axis through `center`.
pub fn torus(center: Vec3, major: f64, minor: f64) -> Result<Body> {
let (major, minor) = (finite(major, "major radius")?, finite(minor, "minor radius")?);
if !(minor > 1e-6 && major > minor) {
return Err(KernelError::Invalid("torus needs major > minor > 0".into()));
}
let plane = Plane { origin: center, ..Plane::XZ };
let r = Region2 { outer: Loop2::circle(Vec2::new(major, 0.0), minor), holes: vec![] };
let mut v = revolve(&plane, &[r], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?;
v.pop().ok_or_else(|| KernelError::Failed("torus".into()))
}
+55
View File
@@ -0,0 +1,55 @@
//! The B-rep kernel boundary.
//!
//! Everything SolveCraft does with solids goes through this crate: building bodies from planar
//! regions (extrude, revolve) and primitives, booleans, fillets and chamfers, tessellation,
//! measurement and STEP export. The implementation uses the `monstertruck` crates (Apache-2.0,
//! a maintained fork of `truck`; see `plan/adr/0001-geometry-kernel.md`), but no monstertruck
//! type appears in the public API, so the kernel can be replaced without touching the rest of
//! the workspace.
//!
//! Every call into the third-party kernel runs under [`guard`]: a panic inside it becomes a
//! [`KernelError::Internal`] instead of taking the application down.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod body;
mod build;
mod measure;
mod ops;
mod step;
mod topo;
pub use body::{Body, EdgeInfo, FaceInfo};
pub use build::{box_solid, cylinder, extrude, revolve, sphere, torus};
pub use measure::{BodyMeasure, measure};
pub use ops::{BoolOp, boolean, chamfer, fillet, transform};
pub use step::step_export;
pub use topo::{TopoCounts, merged_topology};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum KernelError {
#[error("invalid input: {0}")]
Invalid(String),
#[error("the operation failed: {0}")]
Failed(String),
/// The third-party kernel panicked; the model is unchanged.
#[error("kernel internal error: {0}")]
Internal(String),
}
pub type Result<T> = std::result::Result<T, KernelError>;
/// Run third-party kernel code, converting a panic into an error.
pub fn guard<T>(what: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(r) => r,
Err(p) => {
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
log::warn!("kernel panic in {what}: {msg}");
Err(KernelError::Internal(format!("{what}: {msg}")))
}
}
}
#[cfg(test)]
mod tests;
+42
View File
@@ -0,0 +1,42 @@
use serde::Serialize;
use solvecraft_geom::{Aabb3, Vec3};
use crate::Result;
use crate::body::Body;
use crate::topo::{TopoCounts, merged_topology};
/// Measured properties of a body.
#[derive(Clone, Debug, Serialize)]
pub struct BodyMeasure {
pub volume: f64,
pub area: f64,
pub centroid: Vec3,
pub bbox: Aabb3,
/// Raw kernel topology counts (closed curves and surfaces may be split in pieces).
pub faces: usize,
pub edges: usize,
pub vertices: usize,
pub shells: usize,
/// Counts after merging split pieces of the same analytic surface/curve (comparable with
/// systems that use periodic faces and edges).
pub merged: TopoCounts,
}
/// Measure a body from a fine tessellation (chord tolerance 5e-5 of the body size). Curved
/// faces are slightly under-estimated (relative error typically below 2e-4).
pub fn measure(b: &Body) -> Result<BodyMeasure> {
let size = b.size();
let m2 = b.tessellate((size * 5e-5).max(1e-4))?;
let a2 = m2.measure();
Ok(BodyMeasure {
volume: a2.volume,
area: a2.area,
centroid: a2.centroid,
bbox: m2.bounds(),
faces: b.face_count(),
edges: b.edge_count(),
vertices: b.vertex_count(),
shells: b.shell_count(),
merged: merged_topology(b, &m2)?,
})
}
+112
View File
@@ -0,0 +1,112 @@
//! Operations on bodies: booleans, fillets, chamfers, rigid transforms.
use monstertruck_modeling as mt;
use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec3;
use crate::body::{Body, Solid, v3};
use crate::{KernelError, Result, guard};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BoolOp {
Union,
Cut,
Intersect,
}
/// Boolean of two bodies. Retries with a few tolerances; the result may be empty (`Ok(None)`)
/// for a cut that removes everything or an intersection of disjoint bodies.
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
let size = a.size().max(b.size());
let mut last = String::new();
for k in [1e-4, 1e-3, 5e-5, 5e-3] {
let tol = (size * k).max(1e-6);
let r = guard("boolean", || {
let (sa, sb) = (a.deep_copy(), b.deep_copy());
let res = match op {
BoolOp::Union => monstertruck_solid::or(&sa, &sb, tol),
BoolOp::Cut => monstertruck_solid::difference(&sa, &sb, tol),
BoolOp::Intersect => monstertruck_solid::and(&sa, &sb, tol),
};
res.map_err(|e| KernelError::Failed(format!("{e:?}")))
});
match r {
Ok(s) if s.is_empty() => return Ok(None),
Ok(s) => {
let body = Body::new(s)?;
if body.tessellate(size * 1e-2).map(|m| m.measure().volume > 0.0).unwrap_or(false) {
return Ok(Some(body));
}
last = "degenerate result".into();
}
Err(e) => last = e.to_string(),
}
}
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
}
fn blend(body: &Body, edges: &[Vec3], radius: f64, profile: mt::FilletProfile, what: &str) -> Result<Body> {
if !(radius.is_finite() && radius > 1e-6 && radius < 1e6) {
return Err(KernelError::Invalid(format!("{what} size must be positive")));
}
if edges.is_empty() {
return Err(KernelError::Invalid("no edges selected".into()));
}
let tol = (body.size() * 2e-3).max(1e-3);
let mut cur = body.clone();
// One edge at a time: re-find each edge on the current body by its reference point.
for p in edges {
let Some((idx, d)) = cur.nearest_edge(*p, tol)? else { return Err(KernelError::Invalid("body has no edges".into())) };
if d > cur.size() * 0.05 + 1e-3 {
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
}
let mut solid = cur.deep_copy();
let shape = profile.clone();
let res = guard(what, || {
let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?;
let shell_i = solid
.boundaries()
.iter()
.position(|sh| sh.edge_iter().any(|e| e.id() == edge.id()))
.ok_or_else(|| KernelError::Failed("edge not in a shell".into()))?;
let mut shells = solid.boundaries().clone();
let shell = shells.get_mut(shell_i).ok_or_else(|| KernelError::Failed("shell".into()))?;
let opts = mt::FilletOptions { radius: mt::RadiusSpec::Constant(radius), profile: shape, ..Default::default() };
mt::fillet_edges(shell, &[edge], Some(&opts)).map_err(|e| KernelError::Failed(format!("{what}: {e:?}")))?;
solid = Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("{what} left an open shell: {e:?}")))?;
Ok(())
});
res?;
cur = Body::new(solid)?;
}
Ok(cur)
}
/// Constant-radius fillet of the edges nearest to the given points.
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
blend(body, edges, radius, mt::FilletProfile::Round, "fillet")
}
/// Equal-distance chamfer of the edges nearest to the given points.
pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
blend(body, edges, distance, mt::FilletProfile::Chamfer, "chamfer")
}
/// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate.
pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result<Body> {
if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) {
return Err(KernelError::Invalid("non-finite transform".into()));
}
guard("transform", || {
let mut s = body.deep_copy();
if angle.abs() > 1e-12 {
let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("rotation axis".into()))?;
s = mt::builder::rotated(&s, crate::body::p3(origin), v3(ax), mt::Rad(angle));
}
if translate.len() > 0.0 {
s = mt::builder::translated(&s, v3(translate));
}
Body::new(s)
})
}
+18
View File
@@ -0,0 +1,18 @@
use monstertruck_io::step::save;
use crate::body::Body;
use crate::{KernelError, Result, guard};
/// STEP (ISO 10303-21, AP203-style geometry) text for the given bodies.
pub fn step_export(bodies: &[&Body], system: &str) -> Result<String> {
if bodies.is_empty() {
return Err(KernelError::Invalid("nothing to export".into()));
}
guard("step export", || {
let cs: Vec<_> = bodies.iter().map(|b| b.solid.compress()).collect();
let models = save::StepModels::from_iter(cs.iter());
let header = save::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() };
let out = save::CompleteStepDisplay::new(models, header).to_string();
Ok(out)
})
}
+137
View File
@@ -0,0 +1,137 @@
use std::f64::consts::PI;
use solvecraft_geom::{Loop2, Plane, Region2, Vec2, Vec3};
use crate::*;
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
fn rect(w: f64, h: f64) -> Region2 {
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(w, 0.0), Vec2::new(w, h), Vec2::new(0.0, h)]), holes: vec![] }
}
#[test]
fn box_extrude_measures() {
let b = extrude(&Plane::XY, &[rect(40.0, 30.0)], 0.0, 20.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, 24000.0) < 1e-9, "{}", m.volume);
assert!(rel(m.area, 5200.0) < 1e-9);
assert_eq!((m.faces, m.edges, m.vertices), (6, 12, 8));
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (6, 12, 8));
assert!(m.centroid.dist(Vec3::new(20.0, 15.0, 10.0)) < 1e-6);
}
#[test]
fn cylinder_measures_match_analytic() {
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::ZERO, 15.0), holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
let v = PI * 225.0 * 50.0;
let a = 2.0 * PI * 225.0 + 2.0 * PI * 15.0 * 50.0;
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
assert!(rel(m.area, a) < 5e-4, "{} vs {a}", m.area);
assert_eq!((m.merged.faces, m.merged.edges, m.merged.vertices), (3, 2, 2), "{:?}", m.merged);
assert_eq!(m.merged.face_types.get("cylinder"), Some(&1));
}
#[test]
fn extrude_on_other_planes_and_offsets() {
let b = extrude(&Plane::XZ, &[rect(10.0, 5.0)], -2.0, 3.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, 250.0) < 1e-9);
// XZ normal is -Y: the body spans y in [-3, 2].
assert!((m.bbox.min.y + 3.0).abs() < 1e-6 && (m.bbox.max.y - 2.0).abs() < 1e-6, "{:?}", m.bbox);
}
#[test]
fn region_with_hole() {
let r = Region2 { outer: rect(40.0, 30.0).outer, holes: vec![Loop2::circle(Vec2::new(20.0, 15.0), 5.0).reversed()] };
let b = extrude(&Plane::XY, &[r], 0.0, 10.0).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
assert!(rel(m.volume, (1200.0 - PI * 25.0) * 10.0) < 5e-4, "{}", m.volume);
}
#[test]
fn box_fillet_cut_volume() {
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
let f = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0)], 3.0).unwrap();
let mf = measure(&f).unwrap();
let expect_f = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0;
assert!(rel(mf.volume, expect_f) < 1e-4, "{} vs {expect_f}", mf.volume);
assert_eq!(mf.merged.faces, 7);
let hole = cylinder(Vec3::new(20.0, 15.0, -5.0), Vec3::Z, 5.0, 30.0).unwrap();
let cut = boolean(&f, &hole, BoolOp::Cut).unwrap().unwrap();
let mc = measure(&cut).unwrap();
let expect = expect_f - PI * 25.0 * 20.0;
assert!(rel(mc.volume, expect) < 1e-3, "{} vs {expect}", mc.volume);
}
#[test]
fn union_and_intersect() {
let a = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
let b = box_solid(Vec3::new(5.0, 5.0, 5.0), Vec3::new(15.0, 15.0, 15.0)).unwrap();
let u = boolean(&a, &b, BoolOp::Union).unwrap().unwrap();
assert!(rel(measure(&u).unwrap().volume, 2000.0 - 125.0) < 1e-6);
let i = boolean(&a, &b, BoolOp::Intersect).unwrap().unwrap();
assert!(rel(measure(&i).unwrap().volume, 125.0) < 1e-6);
}
#[test]
fn revolve_washer_and_sphere() {
let r =
Region2 { outer: Loop2::polygon(&[Vec2::new(10.0, 0.0), Vec2::new(15.0, 0.0), Vec2::new(15.0, 5.0), Vec2::new(10.0, 5.0)]), holes: vec![] };
let b = revolve(&Plane::XZ, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, 2.0 * PI).unwrap().pop().unwrap();
let m = measure(&b).unwrap();
let v = PI * (225.0 - 100.0) * 5.0;
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
let half = revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::Y, PI).unwrap().pop().unwrap();
assert!(rel(measure(&half).unwrap().volume, v / 2.0) < 5e-4);
let s = sphere(Vec3::new(1.0, 2.0, 3.0), 10.0).unwrap();
let ms = measure(&s).unwrap();
assert!(rel(ms.volume, 4.0 / 3.0 * PI * 1000.0) < 1e-3, "{}", ms.volume);
}
#[test]
fn chamfer_and_step() {
let b = box_solid(Vec3::ZERO, Vec3::new(20.0, 20.0, 20.0)).unwrap();
let c = chamfer(&b, &[Vec3::new(20.0, 10.0, 20.0)], 2.0).unwrap();
assert!(rel(measure(&c).unwrap().volume, 8000.0 - 2.0 * 20.0) < 1e-4);
let s = step_export(&[&c], "SolveCraft").unwrap();
assert!(s.starts_with("ISO-10303-21;"));
assert!(s.contains("MANIFOLD_SOLID_BREP") || s.contains("CLOSED_SHELL"));
}
#[test]
fn hostile_inputs_are_errors() {
assert!(extrude(&Plane::XY, &[rect(1.0, 1.0)], 0.0, 0.0).is_err());
assert!(extrude(&Plane::XY, &[rect(1.0, 1.0)], 0.0, f64::NAN).is_err());
assert!(extrude(&Plane::XY, &[], 0.0, 1.0).is_err());
assert!(extrude(&Plane::XY, &[rect(0.0, 1.0)], 0.0, 1.0).is_err());
assert!(box_solid(Vec3::ZERO, Vec3::new(1.0, 0.0, 1.0)).is_err());
assert!(cylinder(Vec3::ZERO, Vec3::ZERO, 1.0, 1.0).is_err());
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 5.0)], -1.0).is_err());
assert!(fillet(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).is_err());
assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 5.0)], 100.0).is_err());
let r = Region2 { outer: Loop2::polygon(&[Vec2::new(-1.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(1.0, 1.0)]), holes: vec![] };
assert!(revolve(&Plane::XZ, &[r], Vec2::ZERO, Vec2::Y, 1.0).is_err(), "crosses axis");
assert!(transform(&b, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0).is_err());
}
#[test]
#[ignore]
fn debug_convergence() {
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::ZERO, 15.0), holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap();
for t in [0.1, 0.05, 0.02, 0.01, 0.005, 0.002] {
let m = b.tessellate(t).unwrap();
let mm = m.measure();
println!("tol {t}: tris {} vol {} err {:.3e}", m.triangles.len(), mm.volume, mm.volume / (PI * 225.0 * 50.0) - 1.0);
}
let m = b.tessellate(0.05).unwrap();
for f in 0..4u32 {
let ns: Vec<Vec3> =
m.triangles.iter().zip(&m.tri_face).filter(|(_, ff)| **ff == f).flat_map(|(t, _)| t.iter().map(|k| m.normals[*k as usize])).collect();
println!("face {f}: {} normals, first {:?}, max |n.z| {}", ns.len(), ns.first(), ns.iter().map(|n| n.z.abs()).fold(0.0, f64::max));
}
}
+263
View File
@@ -0,0 +1,263 @@
//! Topology counts comparable with kernels that use periodic faces and closed edges.
//!
//! Our kernel splits a full cylinder into two half faces and a circle into two arcs. Faces
//! are classified from their tessellation (plane, cylinder, sphere, other); adjacent faces on
//! the same analytic surface are merged, edges between merged faces disappear, and chains of
//! edges through vertices that only join two pieces of the same edge are counted once.
use std::collections::{BTreeMap, HashMap};
use serde::Serialize;
use solvecraft_geom::{Mesh, Vec3};
use crate::Result;
use crate::body::{Body, from_p3};
#[derive(Clone, Debug, Default, Serialize, PartialEq)]
pub struct TopoCounts {
pub faces: usize,
pub edges: usize,
pub vertices: usize,
pub face_types: BTreeMap<String, usize>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Surf {
Plane { n: Vec3, d: f64 },
Cylinder { axis: Vec3, p: Vec3, r: f64 },
Sphere { c: Vec3, r: f64 },
Other(usize),
}
impl Surf {
fn name(&self) -> &'static str {
match self {
Surf::Plane { .. } => "plane",
Surf::Cylinder { .. } => "cylinder",
Surf::Sphere { .. } => "sphere",
Surf::Other(_) => "other",
}
}
fn same(&self, o: &Surf, tol: f64) -> bool {
match (*self, *o) {
(Surf::Plane { n, d }, Surf::Plane { n: n2, d: d2 }) => n.dot(n2) > 1.0 - 1e-6 && (d - d2).abs() < tol,
(Surf::Cylinder { axis, p, r }, Surf::Cylinder { axis: a2, p: p2, r: r2 }) => {
let off = p2 - p;
axis.dot(a2).abs() > 1.0 - 1e-6 && (r - r2).abs() < tol && (off - axis * off.dot(axis)).len() < tol
}
(Surf::Sphere { c, r }, Surf::Sphere { c: c2, r: r2 }) => c.dist(c2) < tol && (r - r2).abs() < tol,
_ => false,
}
}
}
/// Least-squares circle through 2D points (Kåsa fit): centre and radius.
fn fit_circle(pts: &[(f64, f64)]) -> Option<(f64, f64, f64)> {
let n = pts.len() as f64;
if pts.len() < 3 {
return None;
}
let (mx, my) = pts.iter().fold((0.0, 0.0), |a, p| (a.0 + p.0 / n, a.1 + p.1 / n));
let (mut suu, mut suv, mut svv, mut suuu, mut svvv, mut suvv, mut svuu) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
for (x, y) in pts {
let (u, v) = (x - mx, y - my);
suu += u * u;
suv += u * v;
svv += v * v;
suuu += u * u * u;
svvv += v * v * v;
suvv += u * v * v;
svuu += v * u * u;
}
let det = suu * svv - suv * suv;
if det.abs() < 1e-18 {
return None;
}
let b1 = 0.5 * (suuu + suvv);
let b2 = 0.5 * (svvv + svuu);
let uc = (b1 * svv - b2 * suv) / det;
let vc = (suu * b2 - suv * b1) / det;
let r = (uc * uc + vc * vc + (suu + svv) / n).sqrt();
Some((uc + mx, vc + my, r))
}
fn classify(pts: &[Vec3], nrm: &[Vec3], tol: f64, id: usize) -> Surf {
let Some(&n0) = nrm.first() else { return Surf::Other(id) };
let Some(&p0) = pts.first() else { return Surf::Other(id) };
if nrm.iter().all(|n| n.dot(n0) > 1.0 - 1e-5) {
let n = n0.normalized().unwrap_or(Vec3::Z);
if pts.iter().all(|p| (p.dot(n) - p0.dot(n)).abs() < tol) {
return Surf::Plane { n, d: p0.dot(n) };
}
}
// Cylinder: all normals perpendicular to one axis.
let far = nrm.iter().copied().min_by(|a, b| a.dot(n0).abs().total_cmp(&b.dot(n0).abs())).unwrap_or(n0);
if let Some(axis) = n0.cross(far).normalized()
&& nrm.iter().all(|n| n.dot(axis).abs() < 2e-3)
{
let u = axis.any_perp();
let v = axis.cross(u);
let pl: Vec<(f64, f64)> = pts.iter().map(|p| (p.dot(u), p.dot(v))).collect();
if let Some((cu, cv, r)) = fit_circle(&pl)
&& pl.iter().all(|(x, y)| ((x - cu).hypot(y - cv) - r).abs() < tol.max(r * 2e-3))
{
let axis = if axis.x + axis.y * 1e-3 + axis.z * 1e-6 < 0.0 { -axis } else { axis };
return Surf::Cylinder { axis, p: u * cu + v * cv, r };
}
}
// Sphere: points equidistant from the point where normals meet.
let c_est = {
let mut acc = Vec3::ZERO;
let mut k = 0.0;
for (p, n) in pts.iter().zip(nrm) {
// Assume radius from the first pair.
let r0 = (p0 - *p).len() / ((n0 - *n).len().max(1e-12));
if r0.is_finite() && r0 > 0.0 && (n0 - *n).len() > 0.1 {
acc += *p - *n * r0;
k += 1.0;
}
}
(k > 0.0).then(|| acc / k)
};
if let Some(c) = c_est {
let r = pts.iter().map(|p| p.dist(c)).sum::<f64>() / pts.len().max(1) as f64;
if pts.iter().all(|p| (p.dist(c) - r).abs() < tol.max(r * 2e-3)) {
return Surf::Sphere { c, r };
}
}
Surf::Other(id)
}
fn find(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
break;
}
i = q;
}
i
}
/// Merged face/edge/vertex counts (see module docs). `mesh` must be `b.tessellate(..)`.
pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
let solid = &*b.solid;
let nf = solid.face_iter().count();
let tol = (b.size() * 1e-4).max(1e-6);
let mut pts: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
let mut nrm: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
for &k in t {
if let (Some(p), Some(n), Some(pv), Some(nv)) =
(mesh.positions.get(k as usize), mesh.normals.get(k as usize), pts.get_mut(*f as usize), nrm.get_mut(*f as usize))
{
pv.push(*p);
nv.push(*n);
}
}
}
let surfs: Vec<Surf> = (0..nf)
.map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i))
.collect();
// Edge → faces, edge → vertices.
let mut edge_faces: HashMap<usize, Vec<usize>> = HashMap::new();
let mut edge_verts: HashMap<usize, (usize, usize)> = HashMap::new();
let mut eid: HashMap<String, usize> = HashMap::new();
let mut vid: HashMap<String, usize> = HashMap::new();
let mut vpos: Vec<Vec3> = Vec::new();
for (fi, f) in solid.face_iter().enumerate() {
for e in f.edge_iter() {
let key = format!("{:?}", e.id());
let n = eid.len();
let ei = *eid.entry(key).or_insert(n);
let faces = edge_faces.entry(ei).or_default();
if !faces.contains(&fi) {
faces.push(fi);
}
let mut vix = |v: &monstertruck_modeling::Vertex| {
let k = format!("{:?}", v.id());
let n = vid.len();
let i = *vid.entry(k).or_insert(n);
if i == vpos.len() {
vpos.push(from_p3(v.point()));
}
i
};
let (a, bb) = (vix(e.front()), vix(e.back()));
edge_verts.insert(ei, (a, bb));
}
}
// Merge faces.
let mut fp: Vec<usize> = (0..nf).collect();
for faces in edge_faces.values() {
if let [a, b] = faces[..]
&& let (Some(sa), Some(sb)) = (surfs.get(a), surfs.get(b))
&& sa.same(sb, tol * 10.0)
{
let (ra, rb) = (find(&mut fp, a), find(&mut fp, b));
if let Some(s) = fp.get_mut(ra) {
*s = rb;
}
}
}
let mut groups: BTreeMap<usize, usize> = BTreeMap::new();
let mut face_types: BTreeMap<String, usize> = BTreeMap::new();
for f in 0..nf {
let r = find(&mut fp, f);
if groups.insert(r, f).is_none()
&& let Some(s) = surfs.get(f)
{
*face_types.entry(s.name().into()).or_insert(0) += 1;
}
}
// Remaining edges, keyed by merged face pair.
let mut remaining: Vec<(usize, (usize, usize), (usize, usize))> = Vec::new(); // (edge, verts, face pair)
let mut eids: Vec<&usize> = edge_faces.keys().collect();
eids.sort();
for e in eids {
let faces = edge_faces.get(e).cloned().unwrap_or_default();
let gs: Vec<usize> = faces.iter().map(|f| find(&mut fp, *f)).collect();
let pair = match gs[..] {
[a, b] if a == b => continue,
[a, b] => (a.min(b), a.max(b)),
[a] => (a, a),
_ => (usize::MAX, usize::MAX),
};
if let Some(v) = edge_verts.get(e) {
remaining.push((*e, *v, pair));
}
}
// Vertex degree among remaining edges.
let mut deg: HashMap<usize, Vec<usize>> = HashMap::new();
for (i, (_, (a, b), _)) in remaining.iter().enumerate() {
deg.entry(*a).or_default().push(i);
if b != a {
deg.entry(*b).or_default().push(i);
}
}
let mut ep: Vec<usize> = (0..remaining.len()).collect();
let mut merge_vertex: Vec<usize> = Vec::new();
for (v, es) in &deg {
if let [i, j] = es[..]
&& let (Some(a), Some(b)) = (remaining.get(i), remaining.get(j))
&& a.2 == b.2
{
let (ri, rj) = (find(&mut ep, i), find(&mut ep, j));
if let Some(s) = ep.get_mut(ri) {
*s = rj;
}
merge_vertex.push(*v);
}
}
let mut edge_groups: BTreeMap<usize, bool> = BTreeMap::new(); // root → has a real end vertex
for (i, (_, (a, b), _)) in remaining.iter().enumerate() {
let r = find(&mut ep, i);
let real = !merge_vertex.contains(a) || !merge_vertex.contains(b);
let e = edge_groups.entry(r).or_insert(false);
*e |= real;
}
let real_vertices = deg.keys().filter(|v| !merge_vertex.contains(v)).count();
let closed_loops = edge_groups.values().filter(|r| !**r).count();
let _ = vpos;
Ok(TopoCounts { faces: groups.len(), edges: edge_groups.len(), vertices: real_vertices + closed_loops, face_types })
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "solvecraft-render"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "solvecraft-sketch"
description = "SolveCraft sketches: points, curves, geometric constraints and dimensions, the constraint solver, and profile detection"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
solvecraft-geom = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+21
View File
@@ -0,0 +1,21 @@
//! Sketches: points and curves on a plane, geometric constraints and driving dimensions, a
//! Levenberg–Marquardt constraint solver with degree-of-freedom analysis, and closed-profile
//! (region) detection for features.
//!
//! The solver is our own clean-room implementation (residual functions per constraint, local
//! finite-difference Jacobians, damped Gauss–Newton per connected component, rank analysis by
//! Gaussian elimination). See `plan/architecture.md` §sketch.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod linalg;
mod model;
mod profiles;
mod solver;
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError};
pub use profiles::{Profile, find_profiles};
pub use solver::{SolveReport, SolveStatus, solve};
#[cfg(test)]
mod tests;
+182
View File
@@ -0,0 +1,182 @@
//! Small dense linear algebra for the solver: row-major matrices, Gaussian elimination with
//! partial pivoting, and reduced row echelon form for rank and null-space analysis.
/// Row-major dense matrix.
#[derive(Clone, Debug)]
pub struct Mat {
pub rows: usize,
pub cols: usize,
pub data: Vec<f64>,
}
impl Mat {
pub fn zeros(rows: usize, cols: usize) -> Mat {
Mat { rows, cols, data: vec![0.0; rows.saturating_mul(cols)] }
}
pub fn get(&self, r: usize, c: usize) -> f64 {
self.data.get(r * self.cols + c).copied().unwrap_or(0.0)
}
pub fn set(&mut self, r: usize, c: usize, v: f64) {
if let Some(x) = self.data.get_mut(r * self.cols + c) {
*x = v;
}
}
pub fn add(&mut self, r: usize, c: usize, v: f64) {
if let Some(x) = self.data.get_mut(r * self.cols + c) {
*x += v;
}
}
fn swap_rows(&mut self, a: usize, b: usize) {
if a == b {
return;
}
for c in 0..self.cols {
self.data.swap(a * self.cols + c, b * self.cols + c);
}
}
}
/// Solve `A x = b` (A square). Returns `None` when singular.
pub fn solve(mut a: Mat, mut b: Vec<f64>) -> Option<Vec<f64>> {
let n = a.rows;
if a.cols != n || b.len() != n {
return None;
}
for k in 0..n {
let mut piv = k;
let mut best = a.get(k, k).abs();
for r in k + 1..n {
let v = a.get(r, k).abs();
if v > best {
best = v;
piv = r;
}
}
if !(best > 1e-300) || !best.is_finite() {
return None;
}
a.swap_rows(k, piv);
b.swap(k, piv);
let d = a.get(k, k);
for r in k + 1..n {
let f = a.get(r, k) / d;
if f == 0.0 {
continue;
}
for c in k..n {
let v = a.get(k, c);
a.add(r, c, -f * v);
}
let bk = *b.get(k)?;
*b.get_mut(r)? -= f * bk;
}
}
let mut x = vec![0.0; n];
for k in (0..n).rev() {
let mut s = *b.get(k)?;
for c in k + 1..n {
s -= a.get(k, c) * x.get(c)?;
}
*x.get_mut(k)? = s / a.get(k, k);
}
x.iter().all(|v| v.is_finite()).then_some(x)
}
/// Reduce to row echelon form in place; returns the pivot column of each pivot row.
pub fn rref(a: &mut Mat, tol: f64) -> Vec<usize> {
let mut pivots = Vec::new();
let mut row = 0;
for col in 0..a.cols {
if row >= a.rows {
break;
}
let mut piv = row;
let mut best = a.get(row, col).abs();
for r in row + 1..a.rows {
let v = a.get(r, col).abs();
if v > best {
best = v;
piv = r;
}
}
if !(best > tol) {
continue;
}
a.swap_rows(row, piv);
let d = a.get(row, col);
for c in 0..a.cols {
let v = a.get(row, c) / d;
a.set(row, c, v);
}
for r in 0..a.rows {
if r == row {
continue;
}
let f = a.get(r, col);
if f == 0.0 {
continue;
}
for c in 0..a.cols {
let v = a.get(row, c);
a.add(r, c, -f * v);
}
}
pivots.push(col);
row += 1;
}
pivots
}
/// For each column of `j`: is that variable fixed by the constraints (zero in every null-space
/// vector)? Also returns the rank.
pub fn determined_vars(j: &Mat, tol: f64) -> (Vec<bool>, usize) {
let mut a = j.clone();
let pivots = rref(&mut a, tol);
let rank = pivots.len();
let free: Vec<usize> = (0..a.cols).filter(|c| !pivots.contains(c)).collect();
let mut det = vec![true; a.cols];
for f in &free {
if let Some(d) = det.get_mut(*f) {
*d = false;
}
}
// Null vector for free column f: x_f = 1, x_pivot(r) = -a[r][f].
for (r, pc) in pivots.iter().enumerate() {
if free.iter().any(|f| a.get(r, *f).abs() > tol)
&& let Some(d) = det.get_mut(*pc)
{
*d = false;
}
}
(det, rank)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn solves_and_detects_singular() {
let mut a = Mat::zeros(2, 2);
a.set(0, 0, 2.0);
a.set(0, 1, 1.0);
a.set(1, 0, 1.0);
a.set(1, 1, 3.0);
let x = solve(a, vec![3.0, 5.0]).unwrap();
assert!((x[0] - 0.8).abs() < 1e-12 && (x[1] - 1.4).abs() < 1e-12);
let s = Mat::zeros(2, 2);
assert!(solve(s, vec![1.0, 1.0]).is_none());
}
#[test]
fn rank_and_determined() {
// x0 + x1 = .., x2 = .. ; x0 and x1 not individually determined.
let mut j = Mat::zeros(2, 3);
j.set(0, 0, 1.0);
j.set(0, 1, 1.0);
j.set(1, 2, 1.0);
let (det, rank) = determined_vars(&j, 1e-9);
assert_eq!(rank, 2);
assert_eq!(det, vec![false, false, true]);
}
}
+674
View File
@@ -0,0 +1,674 @@
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Seg2, Vec2};
/// Hard caps that keep hostile input from exhausting memory or time.
pub const MAX_POINTS: usize = 20_000;
pub const MAX_CONSTRAINTS: usize = 40_000;
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SketchError {
#[error("unknown sketch entity `{0}`")]
Unknown(String),
#[error("`{0}` is the wrong kind of entity for this: {1}")]
WrongKind(String, String),
#[error("duplicate id `{0}`")]
Duplicate(String),
#[error("invalid value: {0}")]
Invalid(String),
#[error("sketch is too large")]
TooLarge,
}
type Result<T> = std::result::Result<T, SketchError>;
/// A sketch point. `fixed` points are constants for the solver.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SPoint {
pub id: String,
pub pos: Vec2,
#[serde(default)]
pub fixed: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CurveKind {
/// Segment between two points (indices into `Sketch::points`).
Line { a: usize, b: usize },
/// Full circle: centre point and radius.
Circle { c: usize, r: f64 },
/// Counter-clockwise arc around `c` from `a` to `b` (radius = |a − c|; the solver keeps
/// |b − c| equal to it).
Arc { c: usize, a: usize, b: usize },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Curve {
pub id: String,
#[serde(flatten)]
pub kind: CurveKind,
#[serde(default)]
pub construction: bool,
}
/// Geometric constraints and dimensions. Indices refer to `Sketch::points` (`p`, `q`) or
/// `Sketch::curves` (`l`, `c`, `a`, `b`). Dimension values are millimetres or radians.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ConstraintKind {
Coincident {
p: usize,
q: usize,
},
PointOnCurve {
p: usize,
c: usize,
},
Horizontal {
l: usize,
},
Vertical {
l: usize,
},
HorizontalPoints {
p: usize,
q: usize,
},
VerticalPoints {
p: usize,
q: usize,
},
Parallel {
a: usize,
b: usize,
},
Perpendicular {
a: usize,
b: usize,
},
Collinear {
a: usize,
b: usize,
},
Tangent {
a: usize,
b: usize,
},
Equal {
a: usize,
b: usize,
},
Concentric {
a: usize,
b: usize,
},
Midpoint {
p: usize,
l: usize,
},
Symmetric {
p: usize,
q: usize,
l: usize,
},
Fix {
p: usize,
},
// Dimensions (driving).
Distance {
p: usize,
q: usize,
value: f64,
},
DistanceX {
p: usize,
q: usize,
value: f64,
},
DistanceY {
p: usize,
q: usize,
value: f64,
},
PointLineDistance {
p: usize,
l: usize,
value: f64,
},
Length {
l: usize,
value: f64,
},
Radius {
c: usize,
value: f64,
},
Diameter {
c: usize,
value: f64,
},
/// Angle from line `a` to line `b`, radians, counter-clockwise.
Angle {
a: usize,
b: usize,
value: f64,
},
}
impl ConstraintKind {
pub fn is_dimension(&self) -> bool {
self.value().is_some()
}
pub fn value(&self) -> Option<f64> {
use ConstraintKind::*;
match *self {
Distance { value, .. }
| DistanceX { value, .. }
| DistanceY { value, .. }
| PointLineDistance { value, .. }
| Length { value, .. }
| Radius { value, .. }
| Diameter { value, .. }
| Angle { value, .. } => Some(value),
_ => None,
}
}
pub fn set_value(&mut self, v: f64) {
use ConstraintKind::*;
match self {
Distance { value, .. }
| DistanceX { value, .. }
| DistanceY { value, .. }
| PointLineDistance { value, .. }
| Length { value, .. }
| Radius { value, .. }
| Diameter { value, .. }
| Angle { value, .. } => *value = v,
_ => {}
}
}
pub fn is_angle(&self) -> bool {
matches!(self, ConstraintKind::Angle { .. })
}
/// Short type name (Fusion-like) for listings.
pub fn name(&self) -> &'static str {
use ConstraintKind::*;
match self {
Coincident { .. } | PointOnCurve { .. } => "Coincident",
Horizontal { .. } | HorizontalPoints { .. } => "Horizontal",
Vertical { .. } | VerticalPoints { .. } => "Vertical",
Parallel { .. } => "Parallel",
Perpendicular { .. } => "Perpendicular",
Collinear { .. } => "Collinear",
Tangent { .. } => "Tangent",
Equal { .. } => "Equal",
Concentric { .. } => "Concentric",
Midpoint { .. } => "MidPoint",
Symmetric { .. } => "Symmetry",
Fix { .. } => "Fix",
Distance { .. } | DistanceX { .. } | DistanceY { .. } | PointLineDistance { .. } | Length { .. } => "LinearDimension",
Radius { .. } => "RadialDimension",
Diameter { .. } => "DiameterDimension",
Angle { .. } => "AngularDimension",
}
}
fn indices(&self) -> (Vec<usize>, Vec<usize>) {
use ConstraintKind::*;
match *self {
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => (vec![p, q], vec![]),
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => (vec![p, q], vec![]),
PointOnCurve { p, c } => (vec![p], vec![c]),
Horizontal { l } | Vertical { l } | Length { l, .. } => (vec![], vec![l]),
Radius { c, .. } | Diameter { c, .. } => (vec![], vec![c]),
Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } => {
(vec![], vec![a, b])
}
Angle { a, b, .. } => (vec![], vec![a, b]),
Midpoint { p, l } | PointLineDistance { p, l, .. } => (vec![p], vec![l]),
Symmetric { p, q, l } => (vec![p, q], vec![l]),
Fix { p } => (vec![p], vec![]),
}
}
fn remap(&mut self, pmap: &dyn Fn(usize) -> Option<usize>, cmap: &dyn Fn(usize) -> Option<usize>) -> bool {
use ConstraintKind::*;
let mut ok = true;
let mut fp = |i: &mut usize| match pmap(*i) {
Some(n) => *i = n,
None => ok = false,
};
let mut ok2 = true;
let mut fc = |i: &mut usize| match cmap(*i) {
Some(n) => *i = n,
None => ok2 = false,
};
match self {
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => {
fp(p);
fp(q);
}
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => {
fp(p);
fp(q);
}
PointOnCurve { p, c } => {
fp(p);
fc(c);
}
Horizontal { l } | Vertical { l } | Length { l, .. } => fc(l),
Radius { c, .. } | Diameter { c, .. } => fc(c),
Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Tangent { a, b } | Equal { a, b } | Concentric { a, b } => {
fc(a);
fc(b);
}
Angle { a, b, .. } => {
fc(a);
fc(b);
}
Midpoint { p, l } | PointLineDistance { p, l, .. } => {
fp(p);
fc(l);
}
Symmetric { p, q, l } => {
fp(p);
fp(q);
fc(l);
}
Fix { p } => fp(p),
}
ok && ok2
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Constraint {
pub id: String,
#[serde(flatten)]
pub kind: ConstraintKind,
/// For dimensions: the model parameter that drives the value (e.g. `d1`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub param: Option<String>,
}
/// A 2D sketch. Point 0 is always the fixed sketch origin (id `origin`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Sketch {
pub points: Vec<SPoint>,
pub curves: Vec<Curve>,
pub constraints: Vec<Constraint>,
#[serde(default)]
next_id: u64,
}
impl Default for Sketch {
fn default() -> Self {
Sketch::new()
}
}
impl Sketch {
pub fn new() -> Self {
Sketch { points: vec![SPoint { id: "origin".into(), pos: Vec2::ZERO, fixed: true }], curves: Vec::new(), constraints: Vec::new(), next_id: 1 }
}
fn fresh(&mut self, prefix: &str) -> String {
loop {
let id = format!("{prefix}{}", self.next_id);
self.next_id += 1;
if !self.id_taken(&id) {
return id;
}
}
}
fn id_taken(&self, id: &str) -> bool {
self.points.iter().any(|p| p.id == id) || self.curves.iter().any(|c| c.id == id) || self.constraints.iter().any(|c| c.id == id)
}
pub fn point_index(&self, id: &str) -> Option<usize> {
self.points.iter().position(|p| p.id == id)
}
pub fn curve_index(&self, id: &str) -> Option<usize> {
self.curves.iter().position(|c| c.id == id)
}
pub fn point(&self, i: usize) -> Option<Vec2> {
self.points.get(i).map(|p| p.pos)
}
fn check_pos(p: Vec2) -> Result<Vec2> {
if p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 { Ok(p) } else { Err(SketchError::Invalid(format!("point {:?}", [p.x, p.y]))) }
}
/// Add a free point (or reuse an existing one within `merge_tol` of `p` when `merge_tol > 0`).
pub fn add_point(&mut self, p: Vec2, id: Option<&str>) -> Result<usize> {
let p = Self::check_pos(p)?;
if self.points.len() >= MAX_POINTS {
return Err(SketchError::TooLarge);
}
let id = match id {
Some(i) if self.id_taken(i) => return Err(SketchError::Duplicate(i.into())),
Some("") => return Err(SketchError::Invalid("empty id".into())),
Some(i) => i.to_string(),
None => self.fresh("p"),
};
self.points.push(SPoint { id, pos: p, fixed: false });
Ok(self.points.len() - 1)
}
fn curve_id(&mut self, id: Option<&str>, prefix: &str) -> Result<String> {
match id {
Some(i) if self.id_taken(i) => Err(SketchError::Duplicate(i.into())),
Some("") => Err(SketchError::Invalid("empty id".into())),
Some(i) => Ok(i.into()),
None => Ok(self.fresh(prefix)),
}
}
/// Endpoint ids of a curve's own points: `<curve>.start`, `.end`, `.center`.
fn own_point(&mut self, curve: &str, role: &str, p: Vec2) -> Result<usize> {
let id = format!("{curve}.{role}");
if self.id_taken(&id) {
return self.add_point(p, None);
}
self.add_point(p, Some(&id))
}
/// Line between existing points `a` and `b`.
pub fn add_line_pts(&mut self, a: usize, b: usize, id: Option<&str>) -> Result<usize> {
if a >= self.points.len() || b >= self.points.len() || a == b {
return Err(SketchError::Invalid("line needs two distinct points".into()));
}
let id = self.curve_id(id, "l")?;
self.curves.push(Curve { id, kind: CurveKind::Line { a, b }, construction: false });
Ok(self.curves.len() - 1)
}
/// Line from `p0` to `p1`, reusing given point indices when provided.
pub fn add_line(&mut self, p0: Vec2, p1: Vec2, a: Option<usize>, b: Option<usize>, id: Option<&str>) -> Result<usize> {
Self::check_pos(p0)?;
Self::check_pos(p1)?;
let id = self.curve_id(id, "l")?;
let a = match a {
Some(i) if i < self.points.len() => i,
_ => self.own_point(&id, "start", p0)?,
};
let b = match b {
Some(i) if i < self.points.len() && i != a => i,
_ => self.own_point(&id, "end", p1)?,
};
self.add_line_pts(a, b, Some(&id))
}
pub fn add_circle(&mut self, center: Vec2, r: f64, c: Option<usize>, id: Option<&str>) -> Result<usize> {
if !(r.is_finite() && r > 1e-9 && r < 1e9) {
return Err(SketchError::Invalid(format!("radius {r}")));
}
let id = self.curve_id(id, "c")?;
let c = match c {
Some(i) if i < self.points.len() => i,
_ => self.own_point(&id, "center", center)?,
};
self.curves.push(Curve { id, kind: CurveKind::Circle { c, r }, construction: false });
Ok(self.curves.len() - 1)
}
/// Counter-clockwise arc around `center` from `p0` to `p1` (the end point is projected onto
/// the circle through `p0`).
pub fn add_arc(&mut self, center: Vec2, p0: Vec2, p1: Vec2, pts: [Option<usize>; 3], id: Option<&str>) -> Result<usize> {
let r = center.dist(p0);
if !(r.is_finite() && r > 1e-9 && r < 1e9) {
return Err(SketchError::Invalid("degenerate arc".into()));
}
let p1 = center + (p1 - center).normalized().ok_or_else(|| SketchError::Invalid("degenerate arc".into()))? * r;
let id = self.curve_id(id, "a")?;
let n = self.points.len();
let c = match pts[0] {
Some(i) if i < n => i,
_ => self.own_point(&id, "center", center)?,
};
let a = match pts[1] {
Some(i) if i < n => i,
_ => self.own_point(&id, "start", p0)?,
};
let b = match pts[2] {
Some(i) if i < n && i != a => i,
_ => self.own_point(&id, "end", p1)?,
};
self.curves.push(Curve { id, kind: CurveKind::Arc { c, a, b }, construction: false });
Ok(self.curves.len() - 1)
}
pub fn add_constraint(&mut self, kind: ConstraintKind, param: Option<String>) -> Result<String> {
if self.constraints.len() >= MAX_CONSTRAINTS {
return Err(SketchError::TooLarge);
}
self.validate(&kind)?;
let id = self.fresh("k");
self.constraints.push(Constraint { id: id.clone(), kind, param });
Ok(id)
}
/// Check indices and entity kinds of a constraint.
pub fn validate(&self, k: &ConstraintKind) -> Result<()> {
use ConstraintKind::*;
let (ps, cs) = k.indices();
for p in &ps {
if *p >= self.points.len() {
return Err(SketchError::Unknown(format!("point #{p}")));
}
}
for c in &cs {
if *c >= self.curves.len() {
return Err(SketchError::Unknown(format!("curve #{c}")));
}
}
let is_line = |i: usize| matches!(self.curves.get(i).map(|c| &c.kind), Some(CurveKind::Line { .. }));
let is_round = |i: usize| matches!(self.curves.get(i).map(|c| &c.kind), Some(CurveKind::Circle { .. } | CurveKind::Arc { .. }));
let name = |i: usize| self.curves.get(i).map(|c| c.id.clone()).unwrap_or_default();
let need_line = |i: usize| if is_line(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a line".into())) };
let need_round = |i: usize| if is_round(i) { Ok(()) } else { Err(SketchError::WrongKind(name(i), "needs a circle or arc".into())) };
match *k {
Horizontal { l } | Vertical { l } | Length { l, .. } | Midpoint { l, .. } | Symmetric { l, .. } | PointLineDistance { l, .. } => {
need_line(l)?
}
Parallel { a, b } | Perpendicular { a, b } | Collinear { a, b } | Angle { a, b, .. } => {
need_line(a)?;
need_line(b)?;
}
Radius { c, .. } | Diameter { c, .. } => need_round(c)?,
Concentric { a, b } => {
need_round(a)?;
need_round(b)?;
}
Tangent { a, b } => {
if !(is_round(a) || is_round(b)) {
return Err(SketchError::WrongKind(name(a), "tangent needs a circle or arc".into()));
}
}
Equal { a, b } => {
if is_line(a) != is_line(b) {
return Err(SketchError::WrongKind(name(b), "equal needs two lines or two circles/arcs".into()));
}
}
Coincident { p, q } if p == q => return Err(SketchError::Invalid("a point is always coincident with itself".into())),
_ => {}
}
if let Some(v) = k.value()
&& (!v.is_finite() || v.abs() > 1e9 || (v < 0.0 && !k.is_angle()))
{
return Err(SketchError::Invalid(format!("dimension value {v}")));
}
Ok(())
}
/// Radius of a circle or arc.
pub fn radius(&self, ci: usize) -> Option<f64> {
match self.curves.get(ci)?.kind {
CurveKind::Circle { r, .. } => Some(r),
CurveKind::Arc { c, a, .. } => Some(self.point(c)?.dist(self.point(a)?)),
CurveKind::Line { .. } => None,
}
}
pub fn center(&self, ci: usize) -> Option<Vec2> {
match self.curves.get(ci)?.kind {
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } => self.point(c),
CurveKind::Line { .. } => None,
}
}
/// Boundary segments of a curve (a circle gives two half arcs).
pub fn segs(&self, ci: usize) -> Vec<Seg2> {
let Some(cu) = self.curves.get(ci) else { return Vec::new() };
match cu.kind {
CurveKind::Line { a, b } => match (self.point(a), self.point(b)) {
(Some(a), Some(b)) => vec![Seg2::Line { a, b }],
_ => Vec::new(),
},
CurveKind::Circle { c, r } => match self.point(c) {
Some(c) => solvecraft_geom::Loop2::circle(c, r).segs,
None => Vec::new(),
},
CurveKind::Arc { c, a, b } => match (self.point(c), self.point(a), self.point(b)) {
(Some(c), Some(pa), Some(pb)) => {
let start = (pa - c).angle();
let mut sweep = (pb - c).angle() - start;
while sweep <= 1e-12 {
sweep += std::f64::consts::TAU;
}
vec![Seg2::Arc { center: c, radius: c.dist(pa), start, sweep }]
}
_ => Vec::new(),
},
}
}
/// Remove curves (by index) and then any points no longer used by a curve (except the
/// origin and free points), and constraints that referred to removed entities.
pub fn remove_curves(&mut self, which: &[usize]) {
let mut owned: Vec<usize> = Vec::new();
for (i, c) in self.curves.iter().enumerate() {
if which.contains(&i) {
match c.kind {
CurveKind::Line { a, b } => owned.extend([a, b]),
CurveKind::Circle { c, .. } => owned.push(c),
CurveKind::Arc { c, a, b } => owned.extend([c, a, b]),
}
}
}
self.drop_curves(which);
let mut orphans: Vec<usize> = owned.into_iter().filter(|p| *p != 0 && !self.point_used_by_curve(*p)).collect();
orphans.sort_unstable();
orphans.dedup();
self.remove_points(&orphans);
}
fn point_used_by_curve(&self, p: usize) -> bool {
self.curves.iter().any(|c| match c.kind {
CurveKind::Line { a, b } => a == p || b == p,
CurveKind::Circle { c, .. } => c == p,
CurveKind::Arc { c, a, b } => c == p || a == p || b == p,
})
}
/// Remove curves only (points stay), remapping constraints.
fn drop_curves(&mut self, which: &[usize]) {
let mut cmap = vec![None; self.curves.len()];
let mut n = 0;
for (i, slot) in cmap.iter_mut().enumerate() {
if !which.contains(&i) {
*slot = Some(n);
n += 1;
}
}
let mut idx = 0;
self.curves.retain(|_| {
let k = !which.contains(&idx);
idx += 1;
k
});
let cm = |i: usize| cmap.get(i).copied().flatten();
let pm = |i: usize| Some(i);
self.constraints.retain_mut(|c| c.kind.remap(&pm, &cm));
}
/// Remove points (never the origin) and every curve/constraint using them.
pub fn remove_points(&mut self, which: &[usize]) {
if which.is_empty() {
return;
}
let gone = |p: usize| p != 0 && which.contains(&p);
let dead_curves: Vec<usize> = self
.curves
.iter()
.enumerate()
.filter(|(_, c)| match c.kind {
CurveKind::Line { a, b } => gone(a) || gone(b),
CurveKind::Circle { c, .. } => gone(c),
CurveKind::Arc { c, a, b } => gone(c) || gone(a) || gone(b),
})
.map(|(i, _)| i)
.collect();
self.drop_curves(&dead_curves);
let mut pmap = vec![None; self.points.len()];
let mut n = 0;
for (i, slot) in pmap.iter_mut().enumerate() {
if !gone(i) {
*slot = Some(n);
n += 1;
}
}
let pm = |i: usize| pmap.get(i).copied().flatten();
for c in &mut self.curves {
match &mut c.kind {
CurveKind::Line { a, b } => {
*a = pm(*a).unwrap_or(0);
*b = pm(*b).unwrap_or(0);
}
CurveKind::Circle { c, .. } => *c = pm(*c).unwrap_or(0),
CurveKind::Arc { c, a, b } => {
*c = pm(*c).unwrap_or(0);
*a = pm(*a).unwrap_or(0);
*b = pm(*b).unwrap_or(0);
}
}
}
let cm = |i: usize| Some(i);
self.constraints.retain_mut(|c| c.kind.remap(&pm, &cm));
let mut idx = 0;
self.points.retain(|_| {
let k = !gone(idx);
idx += 1;
k
});
}
/// Resolve a point reference: a point id (`origin`, `l1.end`, `p3`) or `<curve>.start|end|center`.
pub fn resolve_point(&self, r: &str) -> Option<usize> {
if let Some(i) = self.point_index(r) {
return Some(i);
}
let (cid, role) = r.rsplit_once('.')?;
let c = self.curves.get(self.curve_index(cid)?)?;
match (&c.kind, role) {
(CurveKind::Line { a, .. }, "start") | (CurveKind::Arc { a, .. }, "start") => Some(*a),
(CurveKind::Line { b, .. }, "end") | (CurveKind::Arc { b, .. }, "end") => Some(*b),
(CurveKind::Circle { c, .. }, "center") | (CurveKind::Arc { c, .. }, "center") => Some(*c),
_ => None,
}
}
/// Axis-aligned bounds of all geometry (for views).
pub fn bounds(&self) -> Option<(Vec2, Vec2)> {
let mut lo = Vec2::new(f64::INFINITY, f64::INFINITY);
let mut hi = Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
let mut any = false;
for i in 0..self.curves.len() {
for s in self.segs(i) {
for p in s.polyline(0.5) {
lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y));
hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y));
any = true;
}
}
}
any.then_some((lo, hi))
}
}
+273
View File
@@ -0,0 +1,273 @@
//! Closed-profile detection.
//!
//! Non-construction lines and arcs form a planar graph (end points within a small tolerance are
//! merged). Dangling edges are pruned, then faces are traced with the "first clockwise turn"
//! rule, which yields every bounded region counter-clockwise. Circles are loops of their own.
//! A loop that lies inside another loop of a different connected component becomes a hole of
//! the innermost loop containing it, so a circle inside a rectangle gives two profiles: the
//! rectangle with a hole, and the disc.
use std::f64::consts::TAU;
use serde::{Deserialize, Serialize};
use solvecraft_geom::{Loop2, Region2, Seg2, Vec2};
use crate::model::{CurveKind, Sketch};
const MERGE_TOL: f64 = 1e-6;
const MAX_EDGES: usize = 50_000;
/// A closed region usable by features, with the ids of the curves bounding it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Profile {
pub region: Region2,
/// Curve ids of the outer loop.
pub outer_curves: Vec<String>,
/// Curve ids of each hole loop.
pub hole_curves: Vec<Vec<String>>,
pub area: f64,
pub centroid: Vec2,
}
impl Profile {
/// All curve ids bounding the profile.
pub fn curve_ids(&self) -> Vec<String> {
let mut v = self.outer_curves.clone();
for h in &self.hole_curves {
v.extend(h.iter().cloned());
}
v
}
}
struct Edge {
curve: usize,
seg: Seg2,
u: usize,
v: usize,
}
fn find(p: &mut [usize], mut i: usize) -> usize {
while let Some(&q) = p.get(i) {
if q == i {
break;
}
i = q;
}
i
}
/// Find all closed profiles of a sketch.
pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
// Node ids: point indices merged by position.
let n = sk.points.len();
let mut parent: Vec<usize> = (0..n).collect();
let mut order: Vec<usize> = (0..n).collect();
order.sort_by(|a, b| {
let (pa, pb) = (sk.points.get(*a).map(|p| p.pos.x).unwrap_or(0.0), sk.points.get(*b).map(|p| p.pos.x).unwrap_or(0.0));
pa.total_cmp(&pb)
});
for (k, &i) in order.iter().enumerate() {
let Some(pi) = sk.points.get(i).map(|p| p.pos) else { continue };
for &j in order.iter().skip(k + 1) {
let Some(pj) = sk.points.get(j).map(|p| p.pos) else { continue };
if pj.x - pi.x > MERGE_TOL {
break;
}
if pi.dist(pj) <= MERGE_TOL {
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
if ri != rj
&& let Some(s) = parent.get_mut(rj)
{
*s = ri;
}
}
}
}
let mut loops: Vec<(Loop2, Vec<String>, usize)> = Vec::new(); // (ccw loop, curve ids, component)
let mut edges: Vec<Edge> = Vec::new();
for (ci, c) in sk.curves.iter().enumerate() {
if c.construction {
continue;
}
match c.kind {
CurveKind::Circle { .. } => {
let segs = sk.segs(ci);
if !segs.is_empty() {
loops.push((Loop2 { segs }, vec![c.id.clone()], usize::MAX - ci));
}
}
CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } => {
let (u, v) = (find(&mut parent, a), find(&mut parent, b));
if u == v {
continue;
}
if let Some(seg) = sk.segs(ci).into_iter().next()
&& seg.length() > MERGE_TOL
{
edges.push(Edge { curve: ci, seg, u, v });
}
}
}
if edges.len() > MAX_EDGES {
break;
}
}
// Prune dangling edges.
let mut alive = vec![true; edges.len()];
loop {
let mut deg = vec![0usize; n];
for (e, a) in edges.iter().zip(&alive) {
if *a {
if let Some(d) = deg.get_mut(e.u) {
*d += 1;
}
if let Some(d) = deg.get_mut(e.v) {
*d += 1;
}
}
}
let mut changed = false;
for (e, a) in edges.iter().zip(alive.iter_mut()) {
if *a && (deg.get(e.u).copied().unwrap_or(0) < 2 || deg.get(e.v).copied().unwrap_or(0) < 2) {
*a = false;
changed = true;
}
}
if !changed {
break;
}
}
// Components over nodes for nesting.
let mut comp: Vec<usize> = (0..n).collect();
for (e, a) in edges.iter().zip(&alive) {
if *a {
let (ru, rv) = (find(&mut comp, e.u), find(&mut comp, e.v));
if ru != rv
&& let Some(s) = comp.get_mut(rv)
{
*s = ru;
}
}
}
// Half-edges: 2e = forward (u→v), 2e+1 = backward.
let half = |h: usize| -> Option<(Seg2, usize, usize)> {
let e = edges.get(h / 2)?;
Some(if h.is_multiple_of(2) { (e.seg, e.u, e.v) } else { (e.seg.reversed(), e.v, e.u) })
};
let mut outgoing: Vec<Vec<usize>> = vec![Vec::new(); n];
for (ei, a) in alive.iter().enumerate() {
if !*a {
continue;
}
for h in [2 * ei, 2 * ei + 1] {
if let Some((_, from, _)) = half(h)
&& let Some(o) = outgoing.get_mut(from)
{
o.push(h);
}
}
}
let mut visited = vec![false; edges.len() * 2];
for start in 0..edges.len() * 2 {
if !alive.get(start / 2).copied().unwrap_or(false) || visited.get(start).copied().unwrap_or(true) {
continue;
}
let mut segs = Vec::new();
let mut ids = Vec::new();
let mut h = start;
let mut ok = true;
for _ in 0..=edges.len() * 2 {
if let Some(v) = visited.get_mut(h) {
*v = true;
}
let Some((seg, _, to)) = half(h) else {
ok = false;
break;
};
segs.push(seg);
if let Some(e) = edges.get(h / 2)
&& let Some(c) = sk.curves.get(e.curve)
{
ids.push(c.id.clone());
}
let back = (-seg.end_tangent()).angle();
let twin = h ^ 1;
let mut best: Option<(f64, usize)> = None;
for &o in outgoing.get(to).map(Vec::as_slice).unwrap_or(&[]) {
if o == twin {
continue;
}
let Some((os, _, _)) = half(o) else { continue };
let mut d = (back - os.start_tangent().angle()) % TAU;
if d <= 1e-12 {
d += TAU;
}
if best.is_none_or(|(bd, _)| d < bd) {
best = Some((d, o));
}
}
let Some((_, next)) = best else {
ok = false;
break;
};
h = next;
if h == start {
break;
}
if visited.get(h).copied().unwrap_or(true) {
ok = false;
break;
}
}
if !ok || h != start {
continue;
}
let lp = Loop2 { segs };
if lp.signed_area() > 1e-9 {
let c = edges.get(start / 2).map(|e| find(&mut comp, e.u)).unwrap_or(0);
loops.push((lp, ids, c));
}
}
// Nesting: parent = smallest containing loop from another component.
let areas: Vec<f64> = loops.iter().map(|(l, _, _)| l.signed_area().abs()).collect();
let mut parent_of: Vec<Option<usize>> = vec![None; loops.len()];
for (i, (li, _, ci)) in loops.iter().enumerate() {
let probe = li.segs.first().map(Seg2::mid).unwrap_or_default();
let ai = areas.get(i).copied().unwrap_or(0.0);
let mut best: Option<(f64, usize)> = None;
for (j, (lj, _, cj)) in loops.iter().enumerate() {
let aj = areas.get(j).copied().unwrap_or(0.0);
if i == j || ci == cj || aj <= ai {
continue;
}
if lj.contains(probe) && best.is_none_or(|(ba, _)| aj < ba) {
best = Some((aj, j));
}
}
if let Some(p) = parent_of.get_mut(i) {
*p = best.map(|(_, j)| j);
}
}
let mut out: Vec<Profile> = loops
.iter()
.enumerate()
.map(|(i, (l, ids, _))| {
let kids: Vec<usize> = (0..loops.len()).filter(|k| parent_of.get(*k).copied().flatten() == Some(i)).collect();
let holes: Vec<Loop2> = kids.iter().filter_map(|k| loops.get(*k).map(|(h, _, _)| h.ccw().reversed())).collect();
let hole_curves: Vec<Vec<String>> = kids.iter().filter_map(|k| loops.get(*k).map(|(_, ids, _)| ids.clone())).collect();
let region = Region2 { outer: l.ccw(), holes };
let area = region.area();
let centroid = region.centroid();
Profile { region, outer_curves: ids.clone(), hole_curves, area, centroid }
})
.collect();
let first_curve = |p: &Profile| p.outer_curves.iter().filter_map(|id| sk.curve_index(id)).min().unwrap_or(usize::MAX);
out.sort_by(|a, b| first_curve(a).cmp(&first_curve(b)).then(b.area.total_cmp(&a.area)));
out
}
+636
View File
@@ -0,0 +1,636 @@
//! The constraint solver.
//!
//! Unknowns are the coordinates of every non-fixed point and the radius of every circle. Each
//! constraint contributes residuals (zero when satisfied, in millimetres so that all rows have a
//! comparable scale). The system is split into connected components and each is solved with a
//! damped Gauss–Newton (Levenberg–Marquardt) iteration on a dense Jacobian built from local
//! central differences. Small steps from the current positions are preferred, so
//! under-constrained sketches move as little as possible. Afterwards the rank of the Jacobian
//! gives the remaining degrees of freedom and which points are fully determined.
use serde::{Deserialize, Serialize};
use solvecraft_geom::Vec2;
use crate::linalg::{Mat, determined_vars, solve as lin_solve};
use crate::model::{ConstraintKind, CurveKind, Sketch};
const MAX_ITERS: usize = 200;
/// Converged when every residual is below this (mm).
const TOL: f64 = 1e-9;
/// A solution is accepted when every residual is below this (mm).
const ACCEPT: f64 = 1e-6;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SolveStatus {
/// All constraints satisfied.
Solved,
/// The constraints conflict (or the solver could not satisfy them); geometry was left as it
/// was before solving.
Failed,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SolveReport {
pub status: SolveStatus,
/// Remaining degrees of freedom (0 = fully constrained).
pub dof: usize,
pub iterations: usize,
pub max_residual: f64,
/// Constraints still violated after a failed solve.
pub failing: Vec<String>,
/// Per point: fully determined by constraints (fixed points count as determined).
pub point_determined: Vec<bool>,
/// Per curve: all of its defining values determined.
pub curve_determined: Vec<bool>,
}
impl SolveReport {
pub fn ok(&self) -> bool {
self.status == SolveStatus::Solved
}
pub fn fully_constrained(&self) -> bool {
self.dof == 0
}
}
/// Variable layout: point → (x var, y var), circle → radius var.
struct Layout {
pvar: Vec<Option<usize>>,
rvar: Vec<Option<usize>>,
n: usize,
}
fn layout(sk: &Sketch) -> Layout {
let mut fixed: Vec<bool> = sk.points.iter().map(|p| p.fixed).collect();
for c in &sk.constraints {
if let ConstraintKind::Fix { p } = c.kind
&& let Some(f) = fixed.get_mut(p)
{
*f = true;
}
}
let mut n = 0;
let pvar = fixed
.iter()
.map(|f| {
if *f {
None
} else {
n += 2;
Some(n - 2)
}
})
.collect();
let rvar = sk
.curves
.iter()
.map(|c| match c.kind {
CurveKind::Circle { .. } => {
n += 1;
Some(n - 1)
}
_ => None,
})
.collect();
Layout { pvar, rvar, n }
}
/// Read-only view of the sketch with variable values substituted.
struct State<'a> {
sk: &'a Sketch,
lay: &'a Layout,
x: &'a [f64],
}
impl State<'_> {
fn p(&self, i: usize) -> Vec2 {
match self.lay.pvar.get(i).copied().flatten() {
Some(v) => Vec2::new(self.x.get(v).copied().unwrap_or(0.0), self.x.get(v + 1).copied().unwrap_or(0.0)),
None => self.sk.points.get(i).map(|p| p.pos).unwrap_or_default(),
}
}
fn line(&self, l: usize) -> (Vec2, Vec2) {
match self.sk.curves.get(l).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) => (self.p(*a), self.p(*b)),
_ => (Vec2::ZERO, Vec2::X),
}
}
/// Centre and radius of a circle or arc.
fn round(&self, c: usize) -> (Vec2, f64) {
match self.sk.curves.get(c).map(|c| &c.kind) {
Some(CurveKind::Circle { c: ci, r }) => {
let r = match self.lay.rvar.get(c).copied().flatten() {
Some(v) => self.x.get(v).copied().unwrap_or(*r),
None => *r,
};
(self.p(*ci), r)
}
Some(CurveKind::Arc { c: ci, a, .. }) => {
let cc = self.p(*ci);
(cc, cc.dist(self.p(*a)))
}
_ => (Vec2::ZERO, 0.0),
}
}
fn is_line(&self, c: usize) -> bool {
matches!(self.sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. }))
}
}
fn wrap_angle(a: f64) -> f64 {
let t = std::f64::consts::TAU;
let mut a = a % t;
if a > std::f64::consts::PI {
a -= t;
} else if a <= -std::f64::consts::PI {
a += t;
}
a
}
/// Distance from `p` to the infinite line through `a`, `b` (signed by side).
fn line_dist(p: Vec2, a: Vec2, b: Vec2) -> f64 {
let d = b - a;
let l = d.len().max(1e-12);
d.cross(p - a) / l
}
/// Residual rows of one constraint.
fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
use ConstraintKind::*;
match *k {
Coincident { p, q } => {
let d = s.p(p) - s.p(q);
out.extend([d.x, d.y]);
}
PointOnCurve { p, c } => {
let pp = s.p(p);
if s.is_line(c) {
let (a, b) = s.line(c);
out.push(line_dist(pp, a, b));
} else {
let (cc, r) = s.round(c);
out.push(pp.dist(cc) - r);
}
}
Horizontal { l } => {
let (a, b) = s.line(l);
out.push(b.y - a.y);
}
Vertical { l } => {
let (a, b) = s.line(l);
out.push(b.x - a.x);
}
HorizontalPoints { p, q } => out.push(s.p(q).y - s.p(p).y),
VerticalPoints { p, q } => out.push(s.p(q).x - s.p(p).x),
Parallel { a, b } | Perpendicular { a, b } => {
let (a0, a1) = s.line(a);
let (b0, b1) = s.line(b);
let (da, db) = (a1 - a0, b1 - b0);
let scale = (da.len() + db.len()).max(1e-12);
let v = if matches!(k, Parallel { .. }) { da.cross(db) } else { da.dot(db) };
out.push(v / scale);
}
Collinear { a, b } => {
let (a0, a1) = s.line(a);
let (b0, b1) = s.line(b);
out.push(line_dist(b0, a0, a1));
out.push(line_dist(b1, a0, a1));
}
Tangent { a, b } => {
let (la, lb) = (s.is_line(a), s.is_line(b));
if la || lb {
let (l, c) = if la { (a, b) } else { (b, a) };
let (p0, p1) = s.line(l);
let (cc, r) = s.round(c);
out.push(line_dist(cc, p0, p1).abs() - r);
} else {
let (c1, r1) = s.round(a);
let (c2, r2) = s.round(b);
let d = c1.dist(c2);
let ext = d - (r1 + r2);
let int = d - (r1 - r2).abs();
out.push(if ext.abs() <= int.abs() { ext } else { int });
}
}
Equal { a, b } => {
if s.is_line(a) {
let (a0, a1) = s.line(a);
let (b0, b1) = s.line(b);
out.push(a0.dist(a1) - b0.dist(b1));
} else {
out.push(s.round(a).1 - s.round(b).1);
}
}
Concentric { a, b } => {
let d = s.round(a).0 - s.round(b).0;
out.extend([d.x, d.y]);
}
Midpoint { p, l } => {
let (a, b) = s.line(l);
let d = s.p(p) - (a + b) * 0.5;
out.extend([d.x, d.y]);
}
Symmetric { p, q, l } => {
let (a, b) = s.line(l);
let (pp, qq) = (s.p(p), s.p(q));
let dir = b - a;
let len = dir.len().max(1e-12);
out.push(line_dist((pp + qq) * 0.5, a, b));
out.push((qq - pp).dot(dir) / len);
}
Fix { .. } => {}
Distance { p, q, value } => out.push(s.p(p).dist(s.p(q)) - value),
DistanceX { p, q, value } => out.push((s.p(q).x - s.p(p).x).abs() - value),
DistanceY { p, q, value } => out.push((s.p(q).y - s.p(p).y).abs() - value),
PointLineDistance { p, l, value } => {
let (a, b) = s.line(l);
out.push(line_dist(s.p(p), a, b).abs() - value);
}
Length { l, value } => {
let (a, b) = s.line(l);
out.push(a.dist(b) - value);
}
Radius { c, value } => out.push(s.round(c).1 - value),
Diameter { c, value } => out.push(2.0 * s.round(c).1 - value),
Angle { a, b, value } => {
let (a0, a1) = s.line(a);
let (b0, b1) = s.line(b);
let (da, db) = (a1 - a0, b1 - b0);
let ang = da.cross(db).atan2(da.dot(db));
let scale = 0.5 * (da.len() + db.len());
out.push(wrap_angle(ang - value) * scale.max(1e-6));
}
}
}
/// One residual block: implicit arc radius equality or a user constraint.
enum Block<'a> {
Arc { c: usize, a: usize, b: usize },
User(usize, &'a ConstraintKind),
}
fn block_residuals(s: &State, b: &Block, out: &mut Vec<f64>) {
match b {
Block::Arc { c, a, b } => {
let cc = s.p(*c);
out.push(cc.dist(s.p(*b)) - cc.dist(s.p(*a)));
}
Block::User(_, k) => residuals(s, k, out),
}
}
/// Variables a block depends on.
fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec<usize> {
let mut v = Vec::new();
let pt = |v: &mut Vec<usize>, p: usize| {
if let Some(x) = lay.pvar.get(p).copied().flatten() {
v.extend([x, x + 1]);
}
};
let curve = |v: &mut Vec<usize>, c: usize| match sk.curves.get(c).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) => {
pt(v, *a);
pt(v, *b);
}
Some(CurveKind::Circle { c: ci, .. }) => {
pt(v, *ci);
if let Some(r) = lay.rvar.get(c).copied().flatten() {
v.push(r);
}
}
Some(CurveKind::Arc { c, a, b }) => {
pt(v, *c);
pt(v, *a);
pt(v, *b);
}
None => {}
};
use ConstraintKind::*;
match b {
Block::Arc { c, a, b } => {
pt(&mut v, *c);
pt(&mut v, *a);
pt(&mut v, *b);
}
Block::User(_, k) => match **k {
Coincident { p, q } | HorizontalPoints { p, q } | VerticalPoints { p, q } => {
pt(&mut v, p);
pt(&mut v, q);
}
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => {
pt(&mut v, p);
pt(&mut v, q);
}
PointOnCurve { p, c } | Midpoint { p, l: c } | PointLineDistance { p, l: c, .. } => {
pt(&mut v, p);
curve(&mut v, c);
}
Horizontal { l } | Vertical { l } | Length { l, .. } | Radius { c: l, .. } | Diameter { c: l, .. } => curve(&mut v, l),
Parallel { a, b }
| Perpendicular { a, b }
| Collinear { a, b }
| Tangent { a, b }
| Equal { a, b }
| Concentric { a, b }
| Angle { a, b, .. } => {
curve(&mut v, a);
curve(&mut v, b);
}
Symmetric { p, q, l } => {
pt(&mut v, p);
pt(&mut v, q);
curve(&mut v, l);
}
Fix { .. } => {}
},
}
v.sort_unstable();
v.dedup();
v
}
fn find(parent: &mut [usize], mut i: usize) -> usize {
while let Some(&p) = parent.get(i) {
if p == i {
break;
}
let gp = parent.get(p).copied().unwrap_or(p);
if let Some(slot) = parent.get_mut(i) {
*slot = gp;
}
i = p;
}
i
}
/// Residual vector and Jacobian (rows × all n vars) for the given blocks.
fn system(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec<usize>)], x: &mut [f64], with_j: bool) -> (Vec<f64>, Option<Mat>, Vec<usize>) {
let mut r = Vec::new();
let mut owner = Vec::new();
for (bi, (b, _)) in blocks.iter().enumerate() {
let before = r.len();
block_residuals(&State { sk, lay, x }, b, &mut r);
owner.extend(std::iter::repeat_n(bi, r.len() - before));
}
if !with_j {
return (r, None, owner);
}
let mut j = Mat::zeros(r.len(), lay.n);
let mut row = 0;
let mut plus = Vec::new();
let mut minus = Vec::new();
for (b, vars) in blocks {
plus.clear();
block_residuals(&State { sk, lay, x }, b, &mut plus);
let rows = plus.len();
for &v in vars {
let Some(x0) = x.get(v).copied() else { continue };
let h = 1e-7 * x0.abs().max(1.0);
if let Some(xv) = x.get_mut(v) {
*xv = x0 + h;
}
plus.clear();
block_residuals(&State { sk, lay, x }, b, &mut plus);
if let Some(xv) = x.get_mut(v) {
*xv = x0 - h;
}
minus.clear();
block_residuals(&State { sk, lay, x }, b, &mut minus);
if let Some(xv) = x.get_mut(v) {
*xv = x0;
}
for k in 0..rows {
let d = (plus.get(k).copied().unwrap_or(0.0) - minus.get(k).copied().unwrap_or(0.0)) / (2.0 * h);
j.set(row + k, v, d);
}
}
row += rows;
}
(r, Some(j), owner)
}
fn max_abs(v: &[f64]) -> f64 {
v.iter().fold(0.0_f64, |m, x| if x.is_finite() { m.max(x.abs()) } else { f64::INFINITY })
}
fn norm2(v: &[f64]) -> f64 {
v.iter().map(|x| x * x).sum()
}
/// Levenberg–Marquardt on one component (variables `vars`, blocks `blocks`). Returns iterations.
fn lm(sk: &Sketch, lay: &Layout, blocks: &[(Block, Vec<usize>)], vars: &[usize], x: &mut Vec<f64>) -> usize {
let n = vars.len();
let mut lambda = 1e-3;
let (mut r, _, _) = system(sk, lay, blocks, x, false);
let mut f = norm2(&r);
for it in 0..MAX_ITERS {
if max_abs(&r) < TOL {
return it;
}
let (_, Some(j), _) = system(sk, lay, blocks, x, true) else { return it };
// Normal equations restricted to this component's variables.
let mut a = Mat::zeros(n, n);
let mut g = vec![0.0; n];
for row in 0..j.rows {
let ri = r.get(row).copied().unwrap_or(0.0);
let nz: Vec<(usize, f64)> = vars.iter().enumerate().map(|(li, &v)| (li, j.get(row, v))).filter(|(_, d)| *d != 0.0).collect();
for &(li, di) in &nz {
if let Some(gi) = g.get_mut(li) {
*gi += di * ri;
}
for &(lk, dk) in &nz {
a.add(li, lk, di * dk);
}
}
}
let mut improved = false;
for _ in 0..30 {
let mut m = a.clone();
for i in 0..n {
let d = a.get(i, i);
m.add(i, i, lambda * (d + 1e-9) + 1e-12);
}
let rhs: Vec<f64> = g.iter().map(|v| -v).collect();
let Some(step) = lin_solve(m, rhs) else {
lambda *= 10.0;
continue;
};
let mut trial = x.clone();
for (li, &v) in vars.iter().enumerate() {
if let (Some(t), Some(s)) = (trial.get_mut(v), step.get(li)) {
*t += s;
}
}
let (rt, _, _) = system(sk, lay, blocks, &mut trial, false);
let ft = norm2(&rt);
if ft.is_finite() && ft < f {
*x = trial;
r = rt;
f = ft;
lambda = (lambda / 3.0).max(1e-12);
improved = true;
break;
}
lambda *= 4.0;
if lambda > 1e12 {
break;
}
}
if !improved {
return it;
}
}
MAX_ITERS
}
/// Solve the sketch in place. On failure the geometry is restored and the report lists the
/// violated constraints.
pub fn solve(sk: &mut Sketch) -> SolveReport {
let lay = layout(sk);
let mut x = vec![0.0; lay.n];
for (i, p) in sk.points.iter().enumerate() {
if let Some(v) = lay.pvar.get(i).copied().flatten() {
if let Some(s) = x.get_mut(v) {
*s = p.pos.x;
}
if let Some(s) = x.get_mut(v + 1) {
*s = p.pos.y;
}
}
}
for (i, c) in sk.curves.iter().enumerate() {
if let (CurveKind::Circle { r, .. }, Some(v)) = (&c.kind, lay.rvar.get(i).copied().flatten())
&& let Some(s) = x.get_mut(v)
{
*s = *r;
}
}
let x0 = x.clone();
let mut blocks: Vec<(Block, Vec<usize>)> = Vec::new();
for c in &sk.curves {
if let CurveKind::Arc { c, a, b } = c.kind {
let bl = Block::Arc { c, a, b };
let vars = block_vars(sk, &lay, &bl);
blocks.push((bl, vars));
}
}
for (i, c) in sk.constraints.iter().enumerate() {
let bl = Block::User(i, &c.kind);
let vars = block_vars(sk, &lay, &bl);
blocks.push((bl, vars));
}
// Connected components over variables.
let mut parent: Vec<usize> = (0..lay.n).collect();
for (_, vars) in &blocks {
if let Some(&first) = vars.first() {
for &v in vars.iter().skip(1) {
let (ra, rb) = (find(&mut parent, first), find(&mut parent, v));
if let Some(p) = parent.get_mut(ra) {
*p = rb;
}
}
}
}
let mut comps: Vec<(usize, Vec<usize>, Vec<usize>)> = Vec::new(); // (root, vars, block idx)
for v in 0..lay.n {
let r = find(&mut parent, v);
match comps.iter_mut().find(|c| c.0 == r) {
Some(c) => c.1.push(v),
None => comps.push((r, vec![v], Vec::new())),
}
}
let mut constant_blocks = Vec::new();
for (bi, (_, vars)) in blocks.iter().enumerate() {
match vars.first() {
Some(&v) => {
let r = find(&mut parent, v);
if let Some(c) = comps.iter_mut().find(|c| c.0 == r) {
c.2.push(bi);
}
}
None => constant_blocks.push(bi),
}
}
let mut iterations = 0;
// Solve each component; `blocks` is consumed by index so rebuild per component.
let all_blocks = blocks;
for (_, vars, bis) in &comps {
if bis.is_empty() {
continue;
}
let sub: Vec<(Block, Vec<usize>)> = bis
.iter()
.filter_map(|&bi| {
all_blocks.get(bi).map(|(b, v)| {
let b2 = match b {
Block::Arc { c, a, b } => Block::Arc { c: *c, a: *a, b: *b },
Block::User(i, k) => Block::User(*i, k),
};
(b2, v.clone())
})
})
.collect();
iterations += lm(sk, &lay, &sub, vars, &mut x);
}
// Final residuals, Jacobian and analysis over everything.
let (r, j, owner) = system(sk, &lay, &all_blocks, &mut x, true);
let max_residual = max_abs(&r);
let mut failing: Vec<String> = Vec::new();
for (row, v) in r.iter().enumerate() {
if !(v.abs() < ACCEPT)
&& let Some((Block::User(ci, _), _)) = owner.get(row).and_then(|bi| all_blocks.get(*bi))
&& let Some(c) = sk.constraints.get(*ci)
&& !failing.contains(&c.id)
{
failing.push(c.id.clone());
}
}
let _ = constant_blocks;
let status = if max_residual < ACCEPT { SolveStatus::Solved } else { SolveStatus::Failed };
let use_x = if status == SolveStatus::Solved { &x } else { &x0 };
let (det, rank) = match &j {
Some(j) if j.rows > 0 => determined_vars(j, 1e-7),
_ => (vec![false; lay.n], 0),
};
// Write back.
for (i, p) in sk.points.iter_mut().enumerate() {
if let Some(v) = lay.pvar.get(i).copied().flatten() {
p.pos = Vec2::new(use_x.get(v).copied().unwrap_or(p.pos.x), use_x.get(v + 1).copied().unwrap_or(p.pos.y));
}
}
for (i, c) in sk.curves.iter_mut().enumerate() {
if let (CurveKind::Circle { r, .. }, Some(v)) = (&mut c.kind, lay.rvar.get(i).copied().flatten()) {
*r = use_x.get(v).copied().unwrap_or(*r).abs().max(1e-9);
}
}
let vdet = |v: Option<usize>| v.is_none_or(|v| det.get(v).copied().unwrap_or(false));
let pdet = |p: usize| match lay.pvar.get(p).copied().flatten() {
Some(v) => vdet(Some(v)) && vdet(Some(v + 1)),
None => true,
};
let point_determined: Vec<bool> = (0..sk.points.len()).map(pdet).collect();
let curve_determined = sk
.curves
.iter()
.enumerate()
.map(|(i, c)| match c.kind {
CurveKind::Line { a, b } => pdet(a) && pdet(b),
CurveKind::Circle { c, .. } => pdet(c) && vdet(lay.rvar.get(i).copied().flatten()),
CurveKind::Arc { c, a, b } => pdet(c) && pdet(a) && pdet(b),
})
.collect();
SolveReport {
status,
dof: lay.n.saturating_sub(rank),
iterations,
max_residual: if max_residual.is_finite() { max_residual } else { f64::MAX },
failing,
point_determined,
curve_determined,
}
}
+252
View File
@@ -0,0 +1,252 @@
use std::f64::consts::{FRAC_PI_2, PI};
use solvecraft_geom::Vec2;
use crate::*;
fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
/// Rough rectangle from four lines sharing corner points.
fn rect(sk: &mut Sketch, p: [Vec2; 4]) -> [usize; 4] {
let a = sk.add_point(p[0], None).unwrap();
let b = sk.add_point(p[1], None).unwrap();
let c = sk.add_point(p[2], None).unwrap();
let d = sk.add_point(p[3], None).unwrap();
[
sk.add_line_pts(a, b, None).unwrap(),
sk.add_line_pts(b, c, None).unwrap(),
sk.add_line_pts(c, d, None).unwrap(),
sk.add_line_pts(d, a, None).unwrap(),
]
}
fn line_pts(sk: &Sketch, l: usize) -> (Vec2, Vec2) {
match sk.curves[l].kind {
CurveKind::Line { a, b } => (sk.points[a].pos, sk.points[b].pos),
_ => panic!(),
}
}
#[test]
fn fully_constrained_rectangle() {
let mut sk = Sketch::new();
let l = rect(&mut sk, [v(0.3, -0.2), v(38.0, 1.0), v(41.0, 29.0), v(-1.0, 31.0)]);
use ConstraintKind::*;
sk.add_constraint(Horizontal { l: l[0] }, None).unwrap();
sk.add_constraint(Horizontal { l: l[2] }, None).unwrap();
sk.add_constraint(Vertical { l: l[1] }, None).unwrap();
sk.add_constraint(Vertical { l: l[3] }, None).unwrap();
let (a, _) = match sk.curves[l[0]].kind {
CurveKind::Line { a, b } => (a, b),
_ => panic!(),
};
sk.add_constraint(Coincident { p: a, q: 0 }, None).unwrap();
sk.add_constraint(Length { l: l[0], value: 40.0 }, Some("d1".into())).unwrap();
sk.add_constraint(Length { l: l[1], value: 30.0 }, Some("d2".into())).unwrap();
let rep = solve(&mut sk);
assert!(rep.ok(), "{rep:?}");
assert_eq!(rep.dof, 0, "{rep:?}");
assert!(rep.point_determined.iter().all(|d| *d));
let (p0, p1) = line_pts(&sk, l[0]);
assert!(p0.dist(v(0.0, 0.0)) < 1e-7, "{p0:?}");
assert!(p1.dist(v(40.0, 0.0)) < 1e-7, "{p1:?}");
let (_, p2) = line_pts(&sk, l[1]);
assert!(p2.dist(v(40.0, 30.0)) < 1e-7, "{p2:?}");
// Change a dimension and re-solve.
sk.constraints[5].kind.set_value(55.0);
assert!(solve(&mut sk).ok());
let (_, p1) = line_pts(&sk, l[0]);
assert!(p1.dist(v(55.0, 0.0)) < 1e-7);
}
#[test]
fn under_constrained_reports_dof() {
let mut sk = Sketch::new();
let l = sk.add_line(v(0.0, 0.0), v(10.0, 1.0), None, None, None).unwrap();
sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap();
let rep = solve(&mut sk);
assert!(rep.ok());
assert_eq!(rep.dof, 3);
let (a, b) = line_pts(&sk, l);
assert!((a.y - b.y).abs() < 1e-9);
// Minimal movement: the line moved by about half a unit each.
assert!((a.y - 0.5).abs() < 0.05 && (b.y - 0.5).abs() < 0.05, "{a:?} {b:?}");
}
#[test]
fn conflicting_constraints_fail_and_restore() {
let mut sk = Sketch::new();
let l = sk.add_line(v(0.0, 0.0), v(10.0, 0.0), None, None, None).unwrap();
sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap();
sk.add_constraint(ConstraintKind::Vertical { l }, None).unwrap();
sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap();
let before = sk.clone();
let rep = solve(&mut sk);
assert_eq!(rep.status, SolveStatus::Failed);
assert!(!rep.failing.is_empty());
assert_eq!(sk.points, before.points);
}
#[test]
fn circles_tangent_equal_concentric() {
let mut sk = Sketch::new();
let c1 = sk.add_circle(v(0.0, 0.0), 5.0, Some(0), None).unwrap();
let c2 = sk.add_circle(v(12.0, 1.0), 3.0, None, None).unwrap();
use ConstraintKind::*;
sk.add_constraint(Radius { c: c1, value: 10.0 }, None).unwrap();
sk.add_constraint(Equal { a: c1, b: c2 }, None).unwrap();
sk.add_constraint(Tangent { a: c1, b: c2 }, None).unwrap();
let rep = solve(&mut sk);
assert!(rep.ok(), "{rep:?}");
assert!((sk.radius(c2).unwrap() - 10.0).abs() < 1e-7);
assert!((sk.center(c2).unwrap().len() - 20.0).abs() < 1e-7);
let c3 = sk.add_circle(v(3.0, 3.0), 2.0, None, None).unwrap();
sk.add_constraint(Concentric { a: c3, b: c2 }, None).unwrap();
assert!(solve(&mut sk).ok());
assert!(sk.center(c3).unwrap().dist(sk.center(c2).unwrap()) < 1e-7);
}
#[test]
fn line_tangent_to_circle_and_angles() {
let mut sk = Sketch::new();
let c = sk.add_circle(v(0.0, 0.0), 10.0, Some(0), None).unwrap();
use ConstraintKind::*;
sk.add_constraint(Radius { c, value: 10.0 }, None).unwrap();
let l = sk.add_line(v(-20.0, 12.0), v(20.0, 11.0), None, None, None).unwrap();
sk.add_constraint(Tangent { a: l, b: c }, None).unwrap();
sk.add_constraint(Horizontal { l }, None).unwrap();
assert!(solve(&mut sk).ok());
let (a, _) = line_pts(&sk, l);
assert!((a.y.abs() - 10.0).abs() < 1e-7, "{a:?}");
let m = sk.add_line(v(0.0, 0.0), v(10.0, 3.0), Some(0), None, None).unwrap();
sk.add_constraint(Angle { a: l, b: m, value: PI / 6.0 }, None).unwrap();
sk.add_constraint(Length { l: m, value: 8.0 }, None).unwrap();
assert!(solve(&mut sk).ok());
let (p, q) = line_pts(&sk, m);
let (la, lb) = line_pts(&sk, l);
assert!(((q - p).angle() - (lb - la).angle() - PI / 6.0).abs() < 1e-7, "{la:?} {lb:?} {p:?} {q:?}");
let n2 = sk.add_line(v(1.0, 1.0), v(2.0, 5.0), None, None, None).unwrap();
sk.add_constraint(Perpendicular { a: m, b: n2 }, None).unwrap();
sk.add_constraint(Parallel { a: n2, b: n2 }, None).unwrap();
assert!(solve(&mut sk).ok());
let (p2, q2) = line_pts(&sk, n2);
assert!(((q2 - p2).angle() - (PI / 6.0 + FRAC_PI_2)).abs() < 1e-6 || ((q2 - p2).angle() - (PI / 6.0 - FRAC_PI_2)).abs() < 1e-6);
}
#[test]
fn midpoint_symmetry_point_on_curve_arc() {
let mut sk = Sketch::new();
use ConstraintKind::*;
let l = sk.add_line(v(-5.0, 0.0), v(5.0, 0.0), None, None, None).unwrap();
let p = sk.add_point(v(1.0, 1.0), None).unwrap();
sk.add_constraint(Midpoint { p, l }, None).unwrap();
let a = sk.add_arc(v(0.0, 0.0), v(4.0, 0.0), v(0.0, 4.0), [None, None, None], None).unwrap();
sk.add_constraint(Radius { c: a, value: 6.0 }, None).unwrap();
let q = sk.add_point(v(3.0, 3.0), None).unwrap();
sk.add_constraint(PointOnCurve { p: q, c: a }, None).unwrap();
let s1 = sk.add_point(v(-2.0, 1.0), None).unwrap();
let s2 = sk.add_point(v(3.0, -1.5), None).unwrap();
let axis = sk.add_line(v(0.0, -10.0), v(0.0, 10.0), None, None, None).unwrap();
sk.add_constraint(Vertical { l: axis }, None).unwrap();
sk.add_constraint(Symmetric { p: s1, q: s2, l: axis }, None).unwrap();
let rep = solve(&mut sk);
assert!(rep.ok(), "{rep:?}");
let (la, lb) = line_pts(&sk, l);
assert!(sk.points[p].pos.dist((la + lb) * 0.5) < 1e-7);
assert!((sk.radius(a).unwrap() - 6.0).abs() < 1e-7);
let c = sk.center(a).unwrap();
assert!((sk.points[q].pos.dist(c) - 6.0).abs() < 1e-7);
let (ax, _) = line_pts(&sk, axis);
assert!((sk.points[s1].pos.x + sk.points[s2].pos.x - 2.0 * ax.x).abs() < 1e-7);
assert!((sk.points[s1].pos.y - sk.points[s2].pos.y).abs() < 1e-7);
}
#[test]
fn profiles_rectangle_with_hole_and_split() {
let mut sk = Sketch::new();
rect(&mut sk, [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)]);
sk.add_circle(v(20.0, 15.0), 5.0, None, None).unwrap();
let ps = find_profiles(&sk);
assert_eq!(ps.len(), 2, "{ps:?}");
let big = ps.iter().find(|p| p.region.holes.len() == 1).unwrap();
assert!((big.area - (1200.0 - PI * 25.0)).abs() < 1e-6);
let disc = ps.iter().find(|p| p.region.holes.is_empty()).unwrap();
assert!((disc.area - PI * 25.0).abs() < 1e-6);
assert_eq!(big.hole_curves.len(), 1);
// A line across the rectangle splits it into two regions (with the hole in one).
let mut sk2 = Sketch::new();
let l = rect(&mut sk2, [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)]);
let (b0, _) = line_pts(&sk2, l[0]);
let _ = b0;
let pa = sk2.add_point(v(10.0, 0.0), None).unwrap();
let pb = sk2.add_point(v(10.0, 30.0), None).unwrap();
// Re-split bottom and top edges at x = 10 by rebuilding the sketch as 7 lines.
let mut s3 = Sketch::new();
let pts = [v(0.0, 0.0), v(10.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(10.0, 30.0), v(0.0, 30.0)];
let ids: Vec<usize> = pts.iter().map(|p| s3.add_point(*p, None).unwrap()).collect();
for i in 0..6 {
s3.add_line_pts(ids[i], ids[(i + 1) % 6], None).unwrap();
}
s3.add_line_pts(ids[1], ids[4], None).unwrap();
let _ = (pa, pb);
let ps = find_profiles(&s3);
assert_eq!(ps.len(), 2);
let mut areas: Vec<f64> = ps.iter().map(|p| p.area).collect();
areas.sort_by(f64::total_cmp);
assert!((areas[0] - 300.0).abs() < 1e-9 && (areas[1] - 900.0).abs() < 1e-9, "{areas:?}");
}
#[test]
fn profile_with_arcs_and_construction_ignored() {
// Slot: two lines and two half arcs.
let mut sk = Sketch::new();
let p = [v(0.0, 0.0), v(20.0, 0.0), v(20.0, 10.0), v(0.0, 10.0)];
let ids: Vec<usize> = p.iter().map(|q| sk.add_point(*q, None).unwrap()).collect();
sk.add_line_pts(ids[0], ids[1], None).unwrap();
sk.add_arc(v(20.0, 5.0), p[1], p[2], [None, Some(ids[1]), Some(ids[2])], None).unwrap();
sk.add_line_pts(ids[2], ids[3], None).unwrap();
sk.add_arc(v(0.0, 5.0), p[3], p[0], [None, Some(ids[3]), Some(ids[0])], None).unwrap();
let c = sk.add_line(v(-5.0, -5.0), v(30.0, 20.0), None, None, None).unwrap();
sk.curves[c].construction = true;
let ps = find_profiles(&sk);
assert_eq!(ps.len(), 1, "{ps:?}");
assert!((ps[0].area - (200.0 + PI * 25.0)).abs() < 1e-6, "{}", ps[0].area);
assert_eq!(ps[0].outer_curves.len(), 4);
}
#[test]
fn removal_and_references() {
let mut sk = Sketch::new();
let l1 = sk.add_line(v(0.0, 0.0), v(1.0, 0.0), None, None, Some("l1")).unwrap();
let e = sk.resolve_point("l1.end").unwrap();
let l2 = sk.add_line(v(1.0, 0.0), v(1.0, 1.0), Some(e), None, Some("l2")).unwrap();
sk.add_constraint(ConstraintKind::Horizontal { l: l1 }, None).unwrap();
sk.add_constraint(ConstraintKind::Vertical { l: l2 }, None).unwrap();
assert!(sk.add_line(v(0.0, 0.0), v(1.0, 0.0), None, None, Some("l1")).is_err());
sk.remove_curves(&[l1]);
assert_eq!(sk.curves.len(), 1);
assert_eq!(sk.constraints.len(), 1);
assert!(sk.resolve_point("l2.start").is_some());
assert!(sk.resolve_point("l1.start").is_none());
assert_eq!(sk.points.len(), 3, "origin + l2's two points");
assert!(solve(&mut sk).ok());
let json = serde_json::to_string(&sk).unwrap();
let back: Sketch = serde_json::from_str(&json).unwrap();
assert_eq!(back, sk);
}
#[test]
fn hostile_values_rejected() {
let mut sk = Sketch::new();
assert!(sk.add_point(v(f64::NAN, 0.0), None).is_err());
assert!(sk.add_circle(v(0.0, 0.0), -1.0, None, None).is_err());
assert!(sk.add_circle(v(0.0, 0.0), f64::INFINITY, None, None).is_err());
assert!(sk.add_constraint(ConstraintKind::Horizontal { l: 99 }, None).is_err());
let c = sk.add_circle(v(0.0, 0.0), 1.0, None, None).unwrap();
assert!(sk.add_constraint(ConstraintKind::Horizontal { l: c }, None).is_err());
assert!(sk.add_constraint(ConstraintKind::Radius { c, value: f64::NAN }, None).is_err());
assert!(sk.add_constraint(ConstraintKind::Radius { c, value: -2.0 }, None).is_err());
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "solvecraft-ui-egui"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@