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

+2
View File
@@ -0,0 +1,2 @@
[alias]
xtask = "run --package xtask --"
+7
View File
@@ -0,0 +1,7 @@
/target
/plan/
/dist/
*~
.DS_Store
*.swp
/.claude/worktrees/
Generated
+1388
View File
File diff suppressed because it is too large. Load diff
+66
View File
@@ -0,0 +1,66 @@
[workspace]
resolver = "3"
members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
authors = ["SolveCraft contributors"]
repository = "https://github.com/storytold/solvecraft"
[workspace.dependencies]
# internal
solvecraft-geom = { path = "crates/geom" }
solvecraft-sketch = { path = "crates/sketch" }
solvecraft-kernel = { path = "crates/kernel" }
solvecraft-render = { path = "crates/render" }
solvecraft-doc = { path = "crates/doc" }
solvecraft-io = { path = "crates/io" }
solvecraft-engine = { path = "crates/engine" }
solvecraft-ui-egui = { path = "crates/ui-egui" }
# foundation
thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# geometry kernel (Apache-2.0; see plan/adr/0001-geometry-kernel.md). Only crates/kernel uses it.
monstertruck-modeling = { version = "0.4.1", features = ["fillet"] }
monstertruck-solid = "0.4.1"
monstertruck-meshing = "0.4.1"
monstertruck-io = "0.4.1"
# images
image = { version = "0.25", default-features = false, features = ["png"] }
# ui
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui-wgpu = "0.36"
rfd = "0.17"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
dbg_macro = "warn"
todo = "warn"
too_many_arguments = "allow"
type_complexity = "allow"
# `!(x > tol)` is our idiom for "not a usable number" (NaN-safe).
neg_cmp_op_on_partial_ord = "allow"
[profile.dev.package."*"]
opt-level = 2
[profile.dev]
opt-level = 1
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
+177
View File
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 SolveCraft contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
[package]
name = "solvecraft-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
+1
View File
@@ -0,0 +1 @@
fn main() {}
+5
View File
@@ -0,0 +1,5 @@
[package]
name = "solvecraft"
version.workspace = true
edition.workspace = true
license.workspace = true
+1
View File
@@ -0,0 +1 @@
fn main() {}
+4
View File
@@ -0,0 +1,4 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true
+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 @@
+2
View File
@@ -0,0 +1,2 @@
max_width = 150
use_small_heuristics = "Max"
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "xtask"
version.workspace = true
edition.workspace = true
license.workspace = true
publish = false
[dependencies]
serde_json = { workspace = true }
+1
View File
@@ -0,0 +1 @@
fn main() {}