M0: workspace, geometry foundation, sketch constraint solver, kernel boundary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
dafce1f4e2
47 files changed
+5624
No files matched your search
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
xtask = "run --package xtask --"
|
||||
@@ -0,0 +1,7 @@
|
||||
/target
|
||||
/plan/
|
||||
/dist/
|
||||
*~
|
||||
.DS_Store
|
||||
*.swp
|
||||
/.claude/worktrees/
|
||||
Generated
+1388
File diff suppressed because it is too large.
Load diff
+66
@@ -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
@@ -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
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
[package]
|
||||
name = "solvecraft-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
@@ -0,0 +1,5 @@
|
||||
[package]
|
||||
name = "solvecraft"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "solvecraft-doc"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "solvecraft-engine"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "solvecraft-io"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)?,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 ° {
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "solvecraft-render"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "solvecraft-ui-egui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
max_width = 150
|
||||
use_small_heuristics = "Max"
|
||||
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "xtask"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
Reference in new issue
Block a user