M0: truck-based kernel with local fillets and chamfers, parametric document timeline
The kernel moved from monstertruck to truck: truck's booleans handle blind holes, pockets and bosses that monstertruck's did not (see the ADR). Fillets and chamfers on straight convex edges between planar faces are our own exact local B-rep operation. The document holds parameters with unit-aware expressions and a timeline that re-evaluates incrementally. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
dafce1f4e2
commit
a97af44979
18 files changed
+2740
-625
No files matched your search
Generated
+336
-505
File diff suppressed because it is too large.
Load diff
+5
-4
@@ -28,10 +28,11 @@ 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"
|
||||
truck-modeling = "0.6"
|
||||
truck-shapeops = { version = "0.4", default-features = false }
|
||||
truck-meshalgo = { version = "0.4", default-features = false, features = ["tessellation", "analyzers"] }
|
||||
truck-stepio = { version = "0.3", default-features = false }
|
||||
truck-topology = "0.6"
|
||||
|
||||
# images
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
[package]
|
||||
name = "solvecraft-doc"
|
||||
description = "SolveCraft document model: parameters and expressions, sketches and features on a parametric timeline, incremental evaluation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
solvecraft-geom = { workspace = true }
|
||||
solvecraft-sketch = { workspace = true }
|
||||
solvecraft-kernel = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -0,0 +1,583 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::{Plane, Vec2, Vec3};
|
||||
use solvecraft_sketch::Sketch;
|
||||
|
||||
use crate::expr::{self, Kind};
|
||||
use crate::{DocError, Result};
|
||||
|
||||
pub const MAX_FEATURES: usize = 10_000;
|
||||
pub const MAX_PARAMS: usize = 10_000;
|
||||
|
||||
/// A named parameter. User parameters are created by the user; model parameters (`d1`, `d2`…)
|
||||
/// are created by dimensions and features.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Param {
|
||||
pub name: String,
|
||||
pub expr: String,
|
||||
/// `mm`, `deg` or empty (unit-less).
|
||||
pub unit: String,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub comment: String,
|
||||
/// Created by a dimension or feature (not by the user).
|
||||
#[serde(default)]
|
||||
pub model: bool,
|
||||
}
|
||||
|
||||
impl Param {
|
||||
pub fn kind(&self) -> Kind {
|
||||
Kind::from_unit(&self.unit)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a sketch lies.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PlaneRef {
|
||||
/// `XY`, `XZ` or `YZ`.
|
||||
Origin { name: String },
|
||||
/// An explicit plane (e.g. picked from a planar face).
|
||||
Custom { plane: Plane },
|
||||
/// Another plane offset along its normal by an expression.
|
||||
Offset { base: Box<PlaneRef>, distance: String },
|
||||
}
|
||||
|
||||
/// Which closed profiles of a sketch a feature uses.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ProfileSel {
|
||||
/// Every profile.
|
||||
All,
|
||||
/// Profiles by index in the solved sketch's profile list.
|
||||
Indices { indices: Vec<usize> },
|
||||
/// Profiles whose outer loop is made of exactly these curves (order-insensitive); stable
|
||||
/// across parameter changes.
|
||||
Curves { loops: Vec<Vec<String>> },
|
||||
/// The profile containing each sketch point.
|
||||
Points { points: Vec<Vec2> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Operation {
|
||||
#[default]
|
||||
NewBody,
|
||||
Join,
|
||||
Cut,
|
||||
Intersect,
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
pub fn parse(s: &str) -> Option<Operation> {
|
||||
Some(match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() {
|
||||
"new" | "newbody" | "newcomponent" => Operation::NewBody,
|
||||
"join" | "union" | "add" => Operation::Join,
|
||||
"cut" | "subtract" | "difference" => Operation::Cut,
|
||||
"intersect" | "intersection" => Operation::Intersect,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Direction {
|
||||
/// Along the sketch normal.
|
||||
#[default]
|
||||
Positive,
|
||||
Negative,
|
||||
/// Both sides; `distance` per side.
|
||||
Symmetric,
|
||||
}
|
||||
|
||||
/// Extent of an extrude.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Extent {
|
||||
pub distance: String,
|
||||
#[serde(default)]
|
||||
pub direction: Direction,
|
||||
/// Optional second side distance (two-sided extrude), along the negative normal.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub distance2: Option<String>,
|
||||
/// Start offset from the sketch plane (expression).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_offset: Option<String>,
|
||||
}
|
||||
|
||||
/// Revolve axis.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AxisRef {
|
||||
/// A line of the profile's sketch.
|
||||
SketchLine { curve: String },
|
||||
/// Sketch x or y axis through the sketch origin (`x` / `y`).
|
||||
SketchAxis { axis: String },
|
||||
/// World X / Y / Z axis through the origin.
|
||||
World { axis: String },
|
||||
}
|
||||
|
||||
/// Feature definitions.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum FeatureKind {
|
||||
Sketch {
|
||||
plane: PlaneRef,
|
||||
sketch: Sketch,
|
||||
},
|
||||
Extrude {
|
||||
/// Feature id of the sketch.
|
||||
sketch: u64,
|
||||
profiles: ProfileSel,
|
||||
extent: Extent,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
/// Target body names for join/cut/intersect (empty = all bodies).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
Revolve {
|
||||
sketch: u64,
|
||||
profiles: ProfileSel,
|
||||
axis: AxisRef,
|
||||
angle: String,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
Fillet {
|
||||
/// Reference points on the edges to round (each re-finds the nearest edge).
|
||||
edges: Vec<Vec3>,
|
||||
radius: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
},
|
||||
Chamfer {
|
||||
edges: Vec<Vec3>,
|
||||
distance: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
},
|
||||
Box {
|
||||
corner: Vec3,
|
||||
length: String,
|
||||
width: String,
|
||||
height: String,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
},
|
||||
Cylinder {
|
||||
base: Vec3,
|
||||
#[serde(default = "z_axis")]
|
||||
axis: Vec3,
|
||||
radius: String,
|
||||
height: String,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
},
|
||||
Sphere {
|
||||
center: Vec3,
|
||||
radius: String,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
},
|
||||
Torus {
|
||||
center: Vec3,
|
||||
major: String,
|
||||
minor: String,
|
||||
#[serde(default)]
|
||||
operation: Operation,
|
||||
},
|
||||
Combine {
|
||||
target: String,
|
||||
tools: Vec<String>,
|
||||
operation: Operation,
|
||||
#[serde(default)]
|
||||
keep_tools: bool,
|
||||
},
|
||||
Move {
|
||||
bodies: Vec<String>,
|
||||
translate: [String; 3],
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
rotate_axis: Option<Vec3>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
angle: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn z_axis() -> Vec3 {
|
||||
Vec3::Z
|
||||
}
|
||||
|
||||
impl FeatureKind {
|
||||
/// Fusion-like type name (timeline tooltip, `inspect`).
|
||||
pub fn type_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::Sketch { .. } => "Sketch",
|
||||
FeatureKind::Extrude { .. } => "ExtrudeFeature",
|
||||
FeatureKind::Revolve { .. } => "RevolveFeature",
|
||||
FeatureKind::Fillet { .. } => "FilletFeature",
|
||||
FeatureKind::Chamfer { .. } => "ChamferFeature",
|
||||
FeatureKind::Box { .. } => "BoxFeature",
|
||||
FeatureKind::Cylinder { .. } => "CylinderFeature",
|
||||
FeatureKind::Sphere { .. } => "SphereFeature",
|
||||
FeatureKind::Torus { .. } => "TorusFeature",
|
||||
FeatureKind::Combine { .. } => "CombineFeature",
|
||||
FeatureKind::Move { .. } => "MoveFeature",
|
||||
}
|
||||
}
|
||||
/// Default name prefix (`Extrude` → `Extrude1`).
|
||||
pub fn base_name(&self) -> &'static str {
|
||||
match self {
|
||||
FeatureKind::Sketch { .. } => "Sketch",
|
||||
FeatureKind::Extrude { .. } => "Extrude",
|
||||
FeatureKind::Revolve { .. } => "Revolve",
|
||||
FeatureKind::Fillet { .. } => "Fillet",
|
||||
FeatureKind::Chamfer { .. } => "Chamfer",
|
||||
FeatureKind::Box { .. } => "Box",
|
||||
FeatureKind::Cylinder { .. } => "Cylinder",
|
||||
FeatureKind::Sphere { .. } => "Sphere",
|
||||
FeatureKind::Torus { .. } => "Torus",
|
||||
FeatureKind::Combine { .. } => "Combine",
|
||||
FeatureKind::Move { .. } => "Move",
|
||||
}
|
||||
}
|
||||
/// Every expression the feature uses.
|
||||
pub fn expressions(&self) -> Vec<&str> {
|
||||
let mut v: Vec<&str> = Vec::new();
|
||||
match self {
|
||||
FeatureKind::Sketch { plane, .. } => {
|
||||
let mut p = plane;
|
||||
while let PlaneRef::Offset { base, distance } = p {
|
||||
v.push(distance);
|
||||
p = base;
|
||||
}
|
||||
}
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
v.push(&extent.distance);
|
||||
if let Some(d) = &extent.distance2 {
|
||||
v.push(d);
|
||||
}
|
||||
if let Some(d) = &extent.start_offset {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, .. } => v.push(angle),
|
||||
FeatureKind::Fillet { radius, .. } => v.push(radius),
|
||||
FeatureKind::Chamfer { distance, .. } => v.push(distance),
|
||||
FeatureKind::Box { length, width, height, .. } => v.extend([length.as_str(), width, height]),
|
||||
FeatureKind::Cylinder { radius, height, .. } => v.extend([radius.as_str(), height]),
|
||||
FeatureKind::Sphere { radius, .. } => v.push(radius),
|
||||
FeatureKind::Torus { major, minor, .. } => v.extend([major.as_str(), minor]),
|
||||
FeatureKind::Combine { .. } => {}
|
||||
FeatureKind::Move { translate, angle, .. } => {
|
||||
v.extend(translate.iter().map(String::as_str));
|
||||
if let Some(a) = angle {
|
||||
v.push(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Feature {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub suppressed: bool,
|
||||
/// Names for the bodies this feature creates (in order); missing ones get `BodyN`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub body_names: Vec<String>,
|
||||
#[serde(flatten)]
|
||||
pub kind: FeatureKind,
|
||||
}
|
||||
|
||||
/// The document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
#[serde(default = "default_format")]
|
||||
pub format: String,
|
||||
pub name: String,
|
||||
#[serde(default = "default_units")]
|
||||
pub units: String,
|
||||
pub params: Vec<Param>,
|
||||
pub features: Vec<Feature>,
|
||||
/// Timeline marker: features at or after this index are rolled back (not evaluated).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub marker: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub next_id: u64,
|
||||
}
|
||||
|
||||
fn default_format() -> String {
|
||||
"solvecraft/1".into()
|
||||
}
|
||||
fn default_units() -> String {
|
||||
"mm".into()
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
fn default() -> Self {
|
||||
Document::new("Untitled")
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn new(name: &str) -> Self {
|
||||
Document {
|
||||
format: default_format(),
|
||||
name: name.into(),
|
||||
units: default_units(),
|
||||
params: Vec::new(),
|
||||
features: Vec::new(),
|
||||
marker: None,
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_json(s: &str) -> Result<Document> {
|
||||
let d: Document = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("document: {e}")))?;
|
||||
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
|
||||
return Err(DocError::Invalid("document too large".into()));
|
||||
}
|
||||
Ok(d)
|
||||
}
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string_pretty(self).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn feature(&self, id: u64) -> Option<&Feature> {
|
||||
self.features.iter().find(|f| f.id == id)
|
||||
}
|
||||
pub fn feature_mut(&mut self, id: u64) -> Option<&mut Feature> {
|
||||
self.features.iter_mut().find(|f| f.id == id)
|
||||
}
|
||||
pub fn feature_index(&self, id: u64) -> Option<usize> {
|
||||
self.features.iter().position(|f| f.id == id)
|
||||
}
|
||||
/// Find a feature by id (number) or name.
|
||||
pub fn find_feature(&self, r: &str) -> Option<&Feature> {
|
||||
if let Ok(id) = r.parse::<u64>()
|
||||
&& let Some(f) = self.feature(id)
|
||||
{
|
||||
return Some(f);
|
||||
}
|
||||
self.features.iter().find(|f| f.name.eq_ignore_ascii_case(r))
|
||||
}
|
||||
|
||||
fn unique_name(&self, base: &str) -> String {
|
||||
let mut n = 1;
|
||||
loop {
|
||||
let name = format!("{base}{n}");
|
||||
if !self.features.iter().any(|f| f.name == name) {
|
||||
return name;
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a feature at the marker (or the end) and return its id.
|
||||
pub fn add_feature(&mut self, kind: FeatureKind, name: Option<&str>) -> Result<u64> {
|
||||
if self.features.len() >= MAX_FEATURES {
|
||||
return Err(DocError::Invalid("too many features".into()));
|
||||
}
|
||||
let id = self.next_id.max(1);
|
||||
self.next_id = id + 1;
|
||||
let name = match name {
|
||||
Some(n) if !n.trim().is_empty() => n.trim().to_string(),
|
||||
_ => self.unique_name(kind.base_name()),
|
||||
};
|
||||
let f = Feature { id, name, suppressed: false, body_names: Vec::new(), kind };
|
||||
match self.marker {
|
||||
Some(m) if m < self.features.len() => {
|
||||
self.features.insert(m, f);
|
||||
self.marker = Some(m + 1);
|
||||
}
|
||||
_ => self.features.push(f),
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Delete a feature (and, for a sketch, the features that use it).
|
||||
pub fn delete_feature(&mut self, id: u64) -> Result<Vec<u64>> {
|
||||
let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?;
|
||||
let mut gone = vec![id];
|
||||
for f in &self.features {
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.features.retain(|f| !gone.contains(&f.id));
|
||||
if let Some(m) = self.marker
|
||||
&& m > idx
|
||||
{
|
||||
self.marker = Some(m.saturating_sub(gone.len()).max(idx));
|
||||
}
|
||||
// Drop model parameters nobody references any more.
|
||||
self.prune_model_params();
|
||||
Ok(gone)
|
||||
}
|
||||
|
||||
/// Remove model parameters not referenced by any dimension, feature or other parameter.
|
||||
pub fn prune_model_params(&mut self) {
|
||||
let used = self.referenced_names();
|
||||
self.params.retain(|p| !p.model || used.contains(&p.name));
|
||||
}
|
||||
|
||||
fn referenced_names(&self) -> Vec<String> {
|
||||
let mut used: Vec<String> = Vec::new();
|
||||
for f in &self.features {
|
||||
for e in f.kind.expressions() {
|
||||
used.extend(expr::references(e));
|
||||
}
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
used.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
}
|
||||
}
|
||||
for p in &self.params {
|
||||
used.extend(expr::references(&p.expr));
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
pub fn param(&self, name: &str) -> Option<&Param> {
|
||||
self.params.iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
fn valid_name(name: &str) -> bool {
|
||||
let mut cs = name.chars();
|
||||
matches!(cs.next(), Some(c) if c.is_alphabetic() || c == '_')
|
||||
&& cs.all(|c| c.is_alphanumeric() || c == '_')
|
||||
&& !matches!(name, "mm" | "cm" | "m" | "um" | "in" | "ft" | "deg" | "rad" | "pi" | "PI")
|
||||
&& name.len() <= 64
|
||||
}
|
||||
|
||||
/// Add or change a parameter. Fails if the result would not evaluate.
|
||||
pub fn set_param(&mut self, name: &str, expr_s: &str, unit: Option<&str>, comment: Option<&str>) -> Result<()> {
|
||||
if !Self::valid_name(name) {
|
||||
return Err(DocError::Invalid(format!("`{name}` is not a valid parameter name")));
|
||||
}
|
||||
if expr_s.len() > 4096 {
|
||||
return Err(DocError::Expr("expression too long".into()));
|
||||
}
|
||||
let mut next = self.clone();
|
||||
match next.params.iter_mut().find(|p| p.name == name) {
|
||||
Some(p) => {
|
||||
p.expr = expr_s.to_string();
|
||||
if let Some(u) = unit {
|
||||
p.unit = u.to_string();
|
||||
}
|
||||
if let Some(c) = comment {
|
||||
p.comment = c.to_string();
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if next.params.len() >= MAX_PARAMS {
|
||||
return Err(DocError::Invalid("too many parameters".into()));
|
||||
}
|
||||
next.params.push(Param {
|
||||
name: name.into(),
|
||||
expr: expr_s.into(),
|
||||
unit: unit.unwrap_or("mm").into(),
|
||||
comment: comment.unwrap_or_default().into(),
|
||||
model: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
let (_, errors) = next.param_values();
|
||||
if let Some(e) = errors.get(name) {
|
||||
return Err(DocError::Expr(e.clone()));
|
||||
}
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the next free model parameter `dN` with the given expression and unit.
|
||||
pub fn new_model_param(&mut self, expr_s: &str, unit: &str) -> String {
|
||||
let mut n = 1;
|
||||
let name = loop {
|
||||
let c = format!("d{n}");
|
||||
if self.param(&c).is_none() {
|
||||
break c;
|
||||
}
|
||||
n += 1;
|
||||
};
|
||||
self.params.push(Param { name: name.clone(), expr: expr_s.into(), unit: unit.into(), comment: String::new(), model: true });
|
||||
name
|
||||
}
|
||||
|
||||
/// Remove a user parameter (fails while something references it).
|
||||
pub fn remove_param(&mut self, name: &str) -> Result<()> {
|
||||
if self.param(name).is_none() {
|
||||
return Err(DocError::Unknown(format!("parameter `{name}`")));
|
||||
}
|
||||
let mut probe = self.clone();
|
||||
probe.params.retain(|p| p.name != name);
|
||||
if probe.referenced_names().iter().any(|n| n == name) {
|
||||
return Err(DocError::Invalid(format!("`{name}` is still used")));
|
||||
}
|
||||
*self = probe;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Evaluate all parameters: values (mm / rad / unit-less) and errors by name.
|
||||
pub fn param_values(&self) -> (std::collections::BTreeMap<String, expr::Value>, std::collections::BTreeMap<String, String>) {
|
||||
let list: Vec<(String, String, Kind)> = self.params.iter().map(|p| (p.name.clone(), p.expr.clone(), p.kind())).collect();
|
||||
expr::eval_params(&list)
|
||||
}
|
||||
|
||||
/// Evaluate an expression against the document's parameters.
|
||||
pub fn eval(&self, e: &str, kind: Kind) -> Result<f64> {
|
||||
let (vals, _) = self.param_values();
|
||||
Self::eval_in(&vals, e, kind)
|
||||
}
|
||||
|
||||
pub fn eval_in(vals: &std::collections::BTreeMap<String, expr::Value>, e: &str, kind: Kind) -> Result<f64> {
|
||||
expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?.to_kind(kind)
|
||||
}
|
||||
|
||||
/// Resolve a plane reference.
|
||||
pub fn resolve_plane(&self, vals: &std::collections::BTreeMap<String, expr::Value>, p: &PlaneRef, depth: usize) -> Result<Plane> {
|
||||
if depth > 32 {
|
||||
return Err(DocError::Invalid("plane offsets nested too deeply".into()));
|
||||
}
|
||||
match p {
|
||||
PlaneRef::Origin { name } => Plane::named(name).ok_or_else(|| DocError::Unknown(format!("plane `{name}`"))),
|
||||
PlaneRef::Custom { plane } => Plane::new(plane.origin, plane.x, plane.y).ok_or_else(|| DocError::Invalid("degenerate plane".into())),
|
||||
PlaneRef::Offset { base, distance } => {
|
||||
let b = self.resolve_plane(vals, base, depth + 1)?;
|
||||
Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable access to a sketch feature's sketch.
|
||||
pub fn sketch_mut(&mut self, id: u64) -> Result<&mut Sketch> {
|
||||
match self.feature_mut(id).map(|f| &mut f.kind) {
|
||||
Some(FeatureKind::Sketch { sketch, .. }) => Ok(sketch),
|
||||
Some(_) => Err(DocError::Invalid(format!("feature {id} is not a sketch"))),
|
||||
None => Err(DocError::Unknown(format!("sketch {id}"))),
|
||||
}
|
||||
}
|
||||
pub fn sketch(&self, id: u64) -> Result<&Sketch> {
|
||||
match self.feature(id).map(|f| &f.kind) {
|
||||
Some(FeatureKind::Sketch { sketch, .. }) => Ok(sketch),
|
||||
Some(_) => Err(DocError::Invalid(format!("feature {id} is not a sketch"))),
|
||||
None => Err(DocError::Unknown(format!("sketch {id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply parameter values to every dimension in a sketch (in place).
|
||||
pub fn apply_dimension_values(&self, vals: &std::collections::BTreeMap<String, expr::Value>, sk: &mut Sketch) -> Result<()> {
|
||||
for c in &mut sk.constraints {
|
||||
if let Some(p) = &c.param {
|
||||
let kind = if c.kind.is_angle() { Kind::Angle } else { Kind::Length };
|
||||
let v = vals.get(p).copied().ok_or_else(|| DocError::Unknown(format!("parameter `{p}`")))?.to_kind(kind)?;
|
||||
c.kind.set_value(v);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
//! Timeline evaluation.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use serde::Serialize;
|
||||
use solvecraft_geom::{Mesh, Plane, Region2, Vec2, Vec3};
|
||||
use solvecraft_kernel::{self as kernel, Body, BoolOp};
|
||||
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve};
|
||||
|
||||
use crate::expr::{self, Kind, Value};
|
||||
use crate::{AxisRef, DocError, Document, Feature, FeatureKind, Operation, ProfileSel, Result};
|
||||
|
||||
/// A body in the evaluated model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModelBody {
|
||||
pub name: String,
|
||||
pub body: Body,
|
||||
/// Feature that created it.
|
||||
pub feature: u64,
|
||||
mesh: OnceLock<Arc<Mesh>>,
|
||||
}
|
||||
|
||||
impl ModelBody {
|
||||
pub fn new(name: String, body: Body, feature: u64) -> Self {
|
||||
ModelBody { name, body, feature, mesh: OnceLock::new() }
|
||||
}
|
||||
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
|
||||
pub fn mesh(&self) -> Arc<Mesh> {
|
||||
self.mesh
|
||||
.get_or_init(|| {
|
||||
let tol = (self.body.size() * 1e-3).max(1e-3);
|
||||
Arc::new(self.body.tessellate(tol).unwrap_or_default())
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A solved sketch in the evaluated model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SolvedSketch {
|
||||
pub feature: u64,
|
||||
pub name: String,
|
||||
pub plane: Plane,
|
||||
pub sketch: solvecraft_sketch::Sketch,
|
||||
pub report: SolveReport,
|
||||
pub profiles: Vec<Profile>,
|
||||
}
|
||||
|
||||
/// Model after some prefix of the timeline.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ModelState {
|
||||
pub bodies: Vec<ModelBody>,
|
||||
pub sketches: Vec<SolvedSketch>,
|
||||
pub body_counter: usize,
|
||||
}
|
||||
|
||||
impl ModelState {
|
||||
pub fn body(&self, name: &str) -> Option<&ModelBody> {
|
||||
self.bodies.iter().find(|b| b.name == name)
|
||||
}
|
||||
pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> {
|
||||
self.sketches.iter().find(|s| s.feature == feature)
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluation result of one feature.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct FeatureResult {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub error: Option<String>,
|
||||
pub warning: Option<String>,
|
||||
pub ms: f64,
|
||||
pub skipped: bool,
|
||||
#[serde(skip)]
|
||||
fingerprint: u64,
|
||||
#[serde(skip)]
|
||||
pub state: Arc<ModelState>,
|
||||
}
|
||||
|
||||
/// Incremental evaluator: holds the state after every feature.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Model {
|
||||
pub results: Vec<FeatureResult>,
|
||||
pub param_errors: BTreeMap<String, String>,
|
||||
empty: Arc<ModelState>,
|
||||
/// Features recomputed by the last `evaluate`.
|
||||
pub last_recomputed: usize,
|
||||
}
|
||||
|
||||
fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_back: bool) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
prev.hash(&mut h);
|
||||
rolled_back.hash(&mut h);
|
||||
serde_json::to_string(f).unwrap_or_default().hash(&mut h);
|
||||
let mut names: Vec<String> = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect();
|
||||
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
||||
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
||||
}
|
||||
names.sort();
|
||||
names.dedup();
|
||||
for n in names {
|
||||
n.hash(&mut h);
|
||||
match vals.get(&n) {
|
||||
Some(v) => {
|
||||
v.v.to_bits().hash(&mut h);
|
||||
v.len.hash(&mut h);
|
||||
v.ang.hash(&mut h);
|
||||
}
|
||||
None => 0u8.hash(&mut h),
|
||||
}
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn now() -> std::time::Instant {
|
||||
std::time::Instant::now()
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn new() -> Self {
|
||||
Model::default()
|
||||
}
|
||||
|
||||
/// The final model state.
|
||||
pub fn state(&self) -> Arc<ModelState> {
|
||||
self.results.iter().rev().find(|r| !r.skipped).map(|r| r.state.clone()).unwrap_or_else(|| self.empty.clone())
|
||||
}
|
||||
|
||||
/// State just before the feature with this id (for editing a sketch in context).
|
||||
pub fn state_before(&self, id: u64) -> Arc<ModelState> {
|
||||
let Some(i) = self.results.iter().position(|r| r.id == id) else { return self.state() };
|
||||
self.results.get(..i).and_then(|s| s.iter().rev().find(|r| !r.skipped)).map(|r| r.state.clone()).unwrap_or_else(|| self.empty.clone())
|
||||
}
|
||||
|
||||
pub fn result(&self, id: u64) -> Option<&FeatureResult> {
|
||||
self.results.iter().find(|r| r.id == id)
|
||||
}
|
||||
|
||||
/// Re-evaluate the document, reusing results up to the first changed feature.
|
||||
pub fn evaluate(&mut self, doc: &Document) {
|
||||
let (vals, perr) = doc.param_values();
|
||||
self.param_errors = perr;
|
||||
let mut prev_fp = 0u64;
|
||||
let mut state = self.empty.clone();
|
||||
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
|
||||
let mut reuse = true;
|
||||
let mut recomputed = 0;
|
||||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||||
for (i, f) in doc.features.iter().enumerate() {
|
||||
let rolled_back = i >= marker;
|
||||
let fp = fingerprint(prev_fp, f, &vals, rolled_back);
|
||||
prev_fp = fp;
|
||||
if reuse
|
||||
&& let Some(old) = self.results.get(i)
|
||||
&& old.id == f.id
|
||||
&& old.fingerprint == fp
|
||||
{
|
||||
if !old.skipped {
|
||||
state = old.state.clone();
|
||||
}
|
||||
out.push(old.clone());
|
||||
continue;
|
||||
}
|
||||
reuse = false;
|
||||
if f.suppressed || rolled_back {
|
||||
out.push(FeatureResult {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
error: None,
|
||||
warning: None,
|
||||
ms: 0.0,
|
||||
skipped: true,
|
||||
fingerprint: fp,
|
||||
state: state.clone(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
recomputed += 1;
|
||||
let t0 = now();
|
||||
let mut next = (*state).clone();
|
||||
let mut warning = None;
|
||||
let r = kernel::guard(&f.name, || {
|
||||
eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string()))
|
||||
});
|
||||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||||
let error = match r {
|
||||
Ok(()) => {
|
||||
state = Arc::new(next);
|
||||
None
|
||||
}
|
||||
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
|
||||
};
|
||||
out.push(FeatureResult { id: f.id, name: f.name.clone(), error, warning, ms, skipped: false, fingerprint: fp, state: state.clone() });
|
||||
}
|
||||
self.results = out;
|
||||
self.last_recomputed = recomputed;
|
||||
}
|
||||
}
|
||||
|
||||
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
|
||||
Document::eval_in(vals, e, k)
|
||||
}
|
||||
|
||||
fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
|
||||
let ps = &ss.profiles;
|
||||
if ps.is_empty() {
|
||||
return Err(DocError::Invalid(format!("{} has no closed profiles", ss.name)));
|
||||
}
|
||||
let out: Vec<Region2> = match sel {
|
||||
ProfileSel::All => ps.iter().map(|p| p.region.clone()).collect(),
|
||||
ProfileSel::Indices { indices } => {
|
||||
let mut v = Vec::new();
|
||||
for i in indices {
|
||||
v.push(ps.get(*i).ok_or_else(|| DocError::Unknown(format!("profile {i} of {}", ss.name)))?.region.clone());
|
||||
}
|
||||
v
|
||||
}
|
||||
ProfileSel::Curves { loops } => {
|
||||
let mut v = Vec::new();
|
||||
for l in loops {
|
||||
let mut want: Vec<&String> = l.iter().collect();
|
||||
want.sort();
|
||||
let found = ps.iter().find(|p| {
|
||||
let mut have: Vec<&String> = p.outer_curves.iter().collect();
|
||||
have.sort();
|
||||
have == want
|
||||
});
|
||||
v.push(found.ok_or_else(|| DocError::Unknown(format!("profile bounded by {l:?} in {}", ss.name)))?.region.clone());
|
||||
}
|
||||
v
|
||||
}
|
||||
ProfileSel::Points { points } => {
|
||||
let mut v = Vec::new();
|
||||
for p in points {
|
||||
// Innermost profile containing the point.
|
||||
let found = ps.iter().filter(|q| q.region.contains(*p)).min_by(|a, b| a.area.total_cmp(&b.area));
|
||||
v.push(found.ok_or_else(|| DocError::Unknown(format!("profile at {:?} in {}", [p.x, p.y], ss.name)))?.region.clone());
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
if out.is_empty() {
|
||||
return Err(DocError::Invalid("no profile selected".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
|
||||
if let Some(n) = f.body_names.get(k)
|
||||
&& !n.is_empty()
|
||||
&& state.body(n).is_none()
|
||||
{
|
||||
return n.clone();
|
||||
}
|
||||
loop {
|
||||
state.body_counter += 1;
|
||||
let n = format!("Body{}", state.body_counter);
|
||||
if state.body(&n).is_none() {
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add tool bodies to the model according to the operation.
|
||||
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
|
||||
let target_idx: Vec<usize> = if targets.is_empty() {
|
||||
(0..state.bodies.len()).collect()
|
||||
} else {
|
||||
let mut v = Vec::new();
|
||||
for t in targets {
|
||||
v.push(state.bodies.iter().position(|b| &b.name == t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?);
|
||||
}
|
||||
v
|
||||
};
|
||||
if op == Operation::NewBody || target_idx.is_empty() {
|
||||
if op == Operation::Cut || op == Operation::Intersect {
|
||||
return Err(DocError::Invalid("there is no body to cut or intersect".into()));
|
||||
}
|
||||
for (k, b) in tools.into_iter().enumerate() {
|
||||
let name = new_name(state, f, k);
|
||||
state.bodies.push(ModelBody::new(name, b, f.id));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match op {
|
||||
Operation::Join => {
|
||||
let Some(&first) = target_idx.first() else { return Ok(()) };
|
||||
let Some(mut acc) = state.bodies.get(first).map(|b| b.body.clone()) else { return Ok(()) };
|
||||
for t in &tools {
|
||||
acc = kernel::boolean(&acc, t, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()))?;
|
||||
}
|
||||
if let Some(mb) = state.bodies.get_mut(first) {
|
||||
*mb = ModelBody::new(mb.name.clone(), acc, mb.feature);
|
||||
}
|
||||
}
|
||||
Operation::Cut | Operation::Intersect => {
|
||||
let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect };
|
||||
let mut remove = Vec::new();
|
||||
for &ti in &target_idx {
|
||||
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.body.clone())) else { continue };
|
||||
for t in &tools {
|
||||
acc = match acc {
|
||||
Some(a) => kernel::boolean(&a, t, bop)?,
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
match acc {
|
||||
Some(b) => {
|
||||
if let Some(mb) = state.bodies.get_mut(ti) {
|
||||
*mb = ModelBody::new(mb.name.clone(), b, mb.feature);
|
||||
}
|
||||
}
|
||||
None => remove.push(ti),
|
||||
}
|
||||
}
|
||||
let mut i = 0;
|
||||
state.bodies.retain(|_| {
|
||||
let keep = !remove.contains(&i);
|
||||
i += 1;
|
||||
keep
|
||||
});
|
||||
}
|
||||
Operation::NewBody => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sample points inside a region (sketch coordinates).
|
||||
fn region_samples(r: &Region2) -> Vec<Vec2> {
|
||||
let mut out = vec![r.interior_point()];
|
||||
let pts = r.outer.polyline(1e-2);
|
||||
let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY));
|
||||
for p in &pts {
|
||||
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));
|
||||
}
|
||||
for i in 0..5 {
|
||||
for j in 0..5 {
|
||||
let p = Vec2::new(lo.x + (hi.x - lo.x) * (0.1 + 0.2 * i as f64), lo.y + (hi.y - lo.y) * (0.1 + 0.2 * j as f64));
|
||||
if r.contains(p) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Booleans struggle with coincident faces (a cut starting on the face it cuts, a boss
|
||||
/// starting on the face it joins). Extending the tool past such an end changes nothing when the
|
||||
/// space beyond is empty (cut) or already material (join), so do exactly that.
|
||||
fn extend_for_coplanar(st: &ModelState, plane: &Plane, r: &Region2, lo: f64, hi: f64, op: Operation, targets: &[String]) -> (f64, f64) {
|
||||
if !matches!(op, Operation::Cut | Operation::Join) || st.bodies.is_empty() {
|
||||
return (lo, hi);
|
||||
}
|
||||
let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect();
|
||||
if bodies.is_empty() {
|
||||
return (lo, hi);
|
||||
}
|
||||
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
|
||||
let delta = (size * 0.01).max(1e-3).min((hi - lo).abs().max(1e-3));
|
||||
let meshes: Vec<Arc<Mesh>> = bodies.iter().map(|b| b.mesh()).collect();
|
||||
let samples = region_samples(r);
|
||||
let n = plane.normal();
|
||||
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
|
||||
let ok_beyond = |h: f64, dir: f64| {
|
||||
samples.iter().all(|s| {
|
||||
let base = plane.to_world(*s);
|
||||
[0.5, 1.0].iter().all(|k| {
|
||||
let p = base + n * (h + dir * delta * k);
|
||||
let inside = inside_any(p);
|
||||
if op == Operation::Cut { !inside } else { inside }
|
||||
})
|
||||
})
|
||||
};
|
||||
let lo2 = if ok_beyond(lo, -1.0) { lo - delta } else { lo };
|
||||
let hi2 = if ok_beyond(hi, 1.0) { hi + delta } else { hi };
|
||||
(lo2, hi2)
|
||||
}
|
||||
|
||||
/// The body a fillet/chamfer applies to: by name, or the one closest to the first edge point.
|
||||
fn blend_target(state: &ModelState, body: &Option<String>, edges: &[Vec3]) -> Result<usize> {
|
||||
if let Some(n) = body {
|
||||
return state.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")));
|
||||
}
|
||||
let p = edges.first().ok_or_else(|| DocError::Invalid("no edges selected".into()))?;
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, b) in state.bodies.iter().enumerate() {
|
||||
let tol = (b.body.size() * 2e-3).max(1e-3);
|
||||
if let Ok(Some((_, d))) = b.body.nearest_edge(*p, tol)
|
||||
&& best.is_none_or(|(_, bd)| d < bd)
|
||||
{
|
||||
best = Some((i, d));
|
||||
}
|
||||
}
|
||||
best.map(|(i, _)| i).ok_or_else(|| DocError::Invalid("there is no body to modify".into()))
|
||||
}
|
||||
|
||||
fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> {
|
||||
match axis {
|
||||
AxisRef::SketchLine { curve } => {
|
||||
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("sketch line `{curve}`")))?;
|
||||
match ss.sketch.curves.get(ci).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) => {
|
||||
let (pa, pb) = (ss.sketch.point(*a).unwrap_or_default(), ss.sketch.point(*b).unwrap_or_default());
|
||||
Ok((pa, pb - pa))
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("`{curve}` is not a line"))),
|
||||
}
|
||||
}
|
||||
AxisRef::SketchAxis { axis } => match axis.to_ascii_lowercase().as_str() {
|
||||
"x" => Ok((Vec2::ZERO, Vec2::X)),
|
||||
"y" => Ok((Vec2::ZERO, Vec2::Y)),
|
||||
other => Err(DocError::Unknown(format!("sketch axis `{other}`"))),
|
||||
},
|
||||
AxisRef::World { axis } => {
|
||||
let d = match axis.to_ascii_uppercase().as_str() {
|
||||
"X" => Vec3::X,
|
||||
"Y" => Vec3::Y,
|
||||
"Z" => Vec3::Z,
|
||||
other => return Err(DocError::Unknown(format!("axis `{other}`"))),
|
||||
};
|
||||
let pl = &ss.plane;
|
||||
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(Vec3::ZERO).abs() > 1e-9 {
|
||||
return Err(DocError::Invalid(format!("the {axis} axis does not lie in the sketch plane")));
|
||||
}
|
||||
Ok((pl.to_local(Vec3::ZERO), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Sketch { plane, sketch } => {
|
||||
let plane = doc.resolve_plane(vals, plane, 0)?;
|
||||
let mut sk = sketch.clone();
|
||||
doc.apply_dimension_values(vals, &mut sk)?;
|
||||
let report = solve(&mut sk);
|
||||
if !report.ok() {
|
||||
*warning = Some(format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
||||
}
|
||||
let profiles = find_profiles(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
||||
let regions = select_profiles(&ss, profiles)?;
|
||||
let d = val(vals, &extent.distance, Kind::Length)?;
|
||||
let off = match &extent.start_offset {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let (a, b) = match (&extent.distance2, extent.direction) {
|
||||
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
|
||||
(None, crate::Direction::Positive) => (0.0, d),
|
||||
(None, crate::Direction::Negative) => (-d, 0.0),
|
||||
(None, crate::Direction::Symmetric) => (-d, d),
|
||||
};
|
||||
// A negative distance flips the side.
|
||||
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
|
||||
let mut tools = Vec::new();
|
||||
for r in ®ions {
|
||||
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
|
||||
tools.extend(kernel::extrude(&ss.plane, std::slice::from_ref(r), l2, h2)?);
|
||||
}
|
||||
apply_op(st, f, tools, *operation, targets)
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
||||
let regions = select_profiles(&ss, profiles)?;
|
||||
let (o, d) = revolve_axis(&ss, axis)?;
|
||||
let ang = val(vals, angle, Kind::Angle)?;
|
||||
let tools = kernel::revolve(&ss.plane, ®ions, o, d, ang)?;
|
||||
apply_op(st, f, tools, *operation, targets)
|
||||
}
|
||||
FeatureKind::Fillet { edges, radius, body } | FeatureKind::Chamfer { edges, distance: radius, body } => {
|
||||
let r = val(vals, radius, Kind::Length)?;
|
||||
let ti = blend_target(st, body, edges)?;
|
||||
let Some(mb) = st.bodies.get(ti) else { return Err(DocError::Invalid("body".into())) };
|
||||
let nb =
|
||||
if matches!(f.kind, FeatureKind::Fillet { .. }) { kernel::fillet(&mb.body, edges, r)? } else { kernel::chamfer(&mb.body, edges, r)? };
|
||||
let (name, feat) = (mb.name.clone(), mb.feature);
|
||||
if let Some(slot) = st.bodies.get_mut(ti) {
|
||||
*slot = ModelBody::new(name, nb, feat);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Box { corner, length, width, height, operation } => {
|
||||
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
|
||||
let b = kernel::box_solid(*corner, *corner + s)?;
|
||||
apply_op(st, f, vec![b], *operation, &[])
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
||||
let b = kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
|
||||
apply_op(st, f, vec![b], *operation, &[])
|
||||
}
|
||||
FeatureKind::Sphere { center, radius, operation } => {
|
||||
let b = kernel::sphere(*center, val(vals, radius, Kind::Length)?)?;
|
||||
apply_op(st, f, vec![b], *operation, &[])
|
||||
}
|
||||
FeatureKind::Torus { center, major, minor, operation } => {
|
||||
let b = kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?;
|
||||
apply_op(st, f, vec![b], *operation, &[])
|
||||
}
|
||||
FeatureKind::Combine { target, tools, operation, keep_tools } => {
|
||||
if tools.iter().any(|t| t == target) {
|
||||
return Err(DocError::Invalid("a body cannot be combined with itself".into()));
|
||||
}
|
||||
let mut tb = Vec::new();
|
||||
for t in tools {
|
||||
tb.push(st.body(t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?.body.clone());
|
||||
}
|
||||
if st.body(target).is_none() {
|
||||
return Err(DocError::Unknown(format!("body `{target}`")));
|
||||
}
|
||||
let op = if *operation == Operation::NewBody { Operation::Join } else { *operation };
|
||||
apply_op(st, f, tb, op, std::slice::from_ref(target))?;
|
||||
if !keep_tools {
|
||||
st.bodies.retain(|b| !tools.contains(&b.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
|
||||
let t =
|
||||
Vec3::new(val(vals, &translate[0], Kind::Length)?, val(vals, &translate[1], Kind::Length)?, val(vals, &translate[2], Kind::Length)?);
|
||||
let ang = match angle {
|
||||
Some(a) => val(vals, a, Kind::Angle)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let axis = rotate_axis.unwrap_or(Vec3::Z);
|
||||
for n in bodies {
|
||||
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
if let Some(mb) = st.bodies.get_mut(i) {
|
||||
let nb = kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?;
|
||||
*mb = ModelBody::new(mb.name.clone(), nb, mb.feature);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
//! Parameter expressions: `2 * width + 5 mm`, `angle / 2`, `sqrt(a^2 + b^2)`.
|
||||
//!
|
||||
//! Values carry a simple dimension (powers of length and angle). Lengths are millimetres and
|
||||
//! angles radians internally. A unit-less number added to a dimensioned value takes that value's
|
||||
//! unit (`width + 5` adds 5 mm); a unit-less final result takes the expected unit's default
|
||||
//! (mm for lengths, degrees for angles). Trigonometric functions take an angle; a unit-less
|
||||
//! argument is read as degrees.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::DocError;
|
||||
|
||||
const MAX_LEN: usize = 4096;
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// What an expression must evaluate to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Kind {
|
||||
Length,
|
||||
Angle,
|
||||
Unitless,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
pub fn from_unit(u: &str) -> Kind {
|
||||
match u.trim() {
|
||||
"deg" | "rad" | "°" => Kind::Angle,
|
||||
"" | "none" | "unitless" => Kind::Unitless,
|
||||
_ => Kind::Length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimensioned value.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Value {
|
||||
pub v: f64,
|
||||
pub len: i8,
|
||||
pub ang: i8,
|
||||
}
|
||||
|
||||
impl Value {
|
||||
pub fn num(v: f64) -> Value {
|
||||
Value { v, len: 0, ang: 0 }
|
||||
}
|
||||
pub fn length(mm: f64) -> Value {
|
||||
Value { v: mm, len: 1, ang: 0 }
|
||||
}
|
||||
pub fn angle(rad: f64) -> Value {
|
||||
Value { v: rad, len: 0, ang: 1 }
|
||||
}
|
||||
fn unitless(&self) -> bool {
|
||||
self.len == 0 && self.ang == 0
|
||||
}
|
||||
/// Convert to the expected kind (mm or radians).
|
||||
pub fn to_kind(self, k: Kind) -> Result<f64, DocError> {
|
||||
let out = match k {
|
||||
Kind::Length if self.len == 1 && self.ang == 0 => self.v,
|
||||
Kind::Length if self.unitless() => self.v,
|
||||
Kind::Angle if self.ang == 1 && self.len == 0 => self.v,
|
||||
Kind::Angle if self.unitless() => self.v.to_radians(),
|
||||
Kind::Unitless if self.unitless() => self.v,
|
||||
_ => return Err(DocError::Expr(format!("wrong units for a {k:?} value"))),
|
||||
};
|
||||
if out.is_finite() { Ok(out) } else { Err(DocError::Expr("result is not a finite number".into())) }
|
||||
}
|
||||
}
|
||||
|
||||
fn unit(name: &str) -> Option<Value> {
|
||||
Some(match name {
|
||||
"mm" => Value::length(1.0),
|
||||
"cm" => Value::length(10.0),
|
||||
"m" => Value::length(1000.0),
|
||||
"um" => Value::length(0.001),
|
||||
"in" => Value::length(25.4),
|
||||
"ft" => Value::length(304.8),
|
||||
"deg" => Value::angle(std::f64::consts::PI / 180.0),
|
||||
"rad" => Value::angle(1.0),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Tok {
|
||||
Num(f64),
|
||||
Id(String),
|
||||
Op(char),
|
||||
}
|
||||
|
||||
fn lex(s: &str) -> Result<Vec<Tok>, DocError> {
|
||||
if s.len() > MAX_LEN {
|
||||
return Err(DocError::Expr("expression too long".into()));
|
||||
}
|
||||
let cs: Vec<char> = s.chars().collect();
|
||||
let mut i = 0;
|
||||
let mut out = Vec::new();
|
||||
while let Some(&c) = cs.get(i) {
|
||||
if c.is_whitespace() {
|
||||
i += 1;
|
||||
} else if c.is_ascii_digit() || c == '.' {
|
||||
let st = i;
|
||||
while cs.get(i).is_some_and(|c| c.is_ascii_digit() || *c == '.') {
|
||||
i += 1;
|
||||
}
|
||||
// Exponent: 1e-3
|
||||
if cs.get(i).is_some_and(|c| *c == 'e' || *c == 'E') && cs.get(i + 1).is_some_and(|c| c.is_ascii_digit() || *c == '-' || *c == '+') {
|
||||
i += 2;
|
||||
while cs.get(i).is_some_and(char::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let t: String = cs.get(st..i).map(|s| s.iter().collect()).unwrap_or_default();
|
||||
out.push(Tok::Num(t.parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?));
|
||||
} else if c.is_alphabetic() || c == '_' {
|
||||
let st = i;
|
||||
while cs.get(i).is_some_and(|c| c.is_alphanumeric() || *c == '_') {
|
||||
i += 1;
|
||||
}
|
||||
out.push(Tok::Id(cs.get(st..i).map(|s| s.iter().collect()).unwrap_or_default()));
|
||||
} else if c == '°' {
|
||||
out.push(Tok::Id("deg".into()));
|
||||
i += 1;
|
||||
} else if "+-*/^(),".contains(c) {
|
||||
out.push(Tok::Op(c));
|
||||
i += 1;
|
||||
} else {
|
||||
return Err(DocError::Expr(format!("unexpected `{c}`")));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Identifiers an expression refers to (excluding units and functions).
|
||||
pub fn references(s: &str) -> Vec<String> {
|
||||
let Ok(toks) = lex(s) else { return Vec::new() };
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
if let Tok::Id(id) = t {
|
||||
let is_call = matches!(toks.get(i + 1), Some(Tok::Op('(')));
|
||||
if !is_call && unit(id).is_none() && id != "pi" && id != "PI" && !out.contains(id) {
|
||||
out.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
toks: Vec<Tok>,
|
||||
pos: usize,
|
||||
lookup: &'a dyn Fn(&str) -> Result<Value, DocError>,
|
||||
depth: usize,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn peek(&self) -> Option<&Tok> {
|
||||
self.toks.get(self.pos)
|
||||
}
|
||||
fn next(&mut self) -> Option<Tok> {
|
||||
let t = self.toks.get(self.pos).cloned();
|
||||
self.pos += 1;
|
||||
t
|
||||
}
|
||||
fn enter(&mut self) -> Result<(), DocError> {
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH { Err(DocError::Expr("expression nested too deeply".into())) } else { Ok(()) }
|
||||
}
|
||||
fn expr(&mut self) -> Result<Value, DocError> {
|
||||
self.enter()?;
|
||||
let mut a = self.term()?;
|
||||
while let Some(Tok::Op(c @ ('+' | '-'))) = self.peek().cloned() {
|
||||
self.pos += 1;
|
||||
let b = self.term()?;
|
||||
a = add(a, b, c == '-')?;
|
||||
}
|
||||
self.depth -= 1;
|
||||
Ok(a)
|
||||
}
|
||||
fn term(&mut self) -> Result<Value, DocError> {
|
||||
let mut a = self.unary()?;
|
||||
loop {
|
||||
match self.peek().cloned() {
|
||||
Some(Tok::Op('*')) => {
|
||||
self.pos += 1;
|
||||
let b = self.unary()?;
|
||||
a = mul(a, b, false);
|
||||
}
|
||||
Some(Tok::Op('/')) => {
|
||||
self.pos += 1;
|
||||
let b = self.unary()?;
|
||||
a = mul(a, b, true);
|
||||
}
|
||||
// Juxtaposition: `10 mm`, `2 width`.
|
||||
Some(Tok::Id(_) | Tok::Num(_)) | Some(Tok::Op('(')) => {
|
||||
let b = self.unary()?;
|
||||
a = mul(a, b, false);
|
||||
}
|
||||
_ => return Ok(a),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn unary(&mut self) -> Result<Value, DocError> {
|
||||
match self.peek() {
|
||||
Some(Tok::Op('-')) => {
|
||||
self.pos += 1;
|
||||
self.enter()?;
|
||||
let v = self.unary()?;
|
||||
self.depth -= 1;
|
||||
Ok(Value { v: -v.v, ..v })
|
||||
}
|
||||
Some(Tok::Op('+')) => {
|
||||
self.pos += 1;
|
||||
self.unary()
|
||||
}
|
||||
_ => self.power(),
|
||||
}
|
||||
}
|
||||
fn power(&mut self) -> Result<Value, DocError> {
|
||||
let base = self.atom()?;
|
||||
if let Some(Tok::Op('^')) = self.peek() {
|
||||
self.pos += 1;
|
||||
self.enter()?;
|
||||
let e = self.unary()?;
|
||||
self.depth -= 1;
|
||||
if !e.unitless() {
|
||||
return Err(DocError::Expr("exponent must be unit-less".into()));
|
||||
}
|
||||
let n = e.v;
|
||||
let k = if (n - n.round()).abs() < 1e-12 && n.abs() < 16.0 { n.round() as i8 } else { 0 };
|
||||
if !base.unitless() && k == 0 {
|
||||
return Err(DocError::Expr("dimensioned values need a small integer exponent".into()));
|
||||
}
|
||||
return Ok(Value { v: base.v.powf(n), len: base.len.saturating_mul(k), ang: base.ang.saturating_mul(k) });
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
fn atom(&mut self) -> Result<Value, DocError> {
|
||||
match self.next() {
|
||||
Some(Tok::Num(v)) => Ok(Value::num(v)),
|
||||
Some(Tok::Op('(')) => {
|
||||
let v = self.expr()?;
|
||||
match self.next() {
|
||||
Some(Tok::Op(')')) => Ok(v),
|
||||
_ => Err(DocError::Expr("missing `)`".into())),
|
||||
}
|
||||
}
|
||||
Some(Tok::Id(id)) => {
|
||||
if let Some(Tok::Op('(')) = self.peek() {
|
||||
self.pos += 1;
|
||||
let mut args = Vec::new();
|
||||
if !matches!(self.peek(), Some(Tok::Op(')'))) {
|
||||
loop {
|
||||
args.push(self.expr()?);
|
||||
match self.next() {
|
||||
Some(Tok::Op(',')) => continue,
|
||||
Some(Tok::Op(')')) => break,
|
||||
_ => return Err(DocError::Expr("bad function arguments".into())),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.pos += 1;
|
||||
}
|
||||
return call(&id, &args);
|
||||
}
|
||||
if id == "pi" || id == "PI" {
|
||||
return Ok(Value::num(std::f64::consts::PI));
|
||||
}
|
||||
if let Some(u) = unit(&id) {
|
||||
return Ok(u);
|
||||
}
|
||||
(self.lookup)(&id)
|
||||
}
|
||||
Some(Tok::Op(c)) => Err(DocError::Expr(format!("unexpected `{c}`"))),
|
||||
None => Err(DocError::Expr("unexpected end of expression".into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add(a: Value, b: Value, sub: bool) -> Result<Value, DocError> {
|
||||
let (len, ang) = if a.unitless() {
|
||||
(b.len, b.ang)
|
||||
} else if b.unitless() || (a.len == b.len && a.ang == b.ang) {
|
||||
(a.len, a.ang)
|
||||
} else {
|
||||
return Err(DocError::Expr("cannot add values with different units".into()));
|
||||
};
|
||||
// A unit-less operand next to an angle is in degrees.
|
||||
let conv = |x: Value| if x.unitless() && ang == 1 && len == 0 { x.v.to_radians() } else { x.v };
|
||||
let (x, y) = (conv(a), conv(b));
|
||||
Ok(Value { v: if sub { x - y } else { x + y }, len, ang })
|
||||
}
|
||||
|
||||
fn mul(a: Value, b: Value, div: bool) -> Value {
|
||||
if div {
|
||||
Value { v: a.v / b.v, len: a.len.saturating_sub(b.len), ang: a.ang.saturating_sub(b.ang) }
|
||||
} else {
|
||||
Value { v: a.v * b.v, len: a.len.saturating_add(b.len), ang: a.ang.saturating_add(b.ang) }
|
||||
}
|
||||
}
|
||||
|
||||
fn call(f: &str, args: &[Value]) -> Result<Value, DocError> {
|
||||
let one = || match args {
|
||||
[a] => Ok(*a),
|
||||
_ => Err(DocError::Expr(format!("`{f}` takes one argument"))),
|
||||
};
|
||||
let rad = |a: Value| {
|
||||
if a.ang == 1 && a.len == 0 {
|
||||
Ok(a.v)
|
||||
} else if a.unitless() {
|
||||
Ok(a.v.to_radians())
|
||||
} else {
|
||||
Err(DocError::Expr(format!("`{f}` needs an angle")))
|
||||
}
|
||||
};
|
||||
let num = |a: Value| if a.unitless() { Ok(a.v) } else { Err(DocError::Expr(format!("`{f}` needs a unit-less value"))) };
|
||||
Ok(match f {
|
||||
"sin" => Value::num(rad(one()?)?.sin()),
|
||||
"cos" => Value::num(rad(one()?)?.cos()),
|
||||
"tan" => Value::num(rad(one()?)?.tan()),
|
||||
"asin" => Value::angle(num(one()?)?.asin()),
|
||||
"acos" => Value::angle(num(one()?)?.acos()),
|
||||
"atan" => Value::angle(num(one()?)?.atan()),
|
||||
"sqrt" => {
|
||||
let a = one()?;
|
||||
if a.len % 2 != 0 || a.ang % 2 != 0 {
|
||||
return Err(DocError::Expr("sqrt of an odd power of a unit".into()));
|
||||
}
|
||||
Value { v: a.v.sqrt(), len: a.len / 2, ang: a.ang / 2 }
|
||||
}
|
||||
"abs" => {
|
||||
let a = one()?;
|
||||
Value { v: a.v.abs(), ..a }
|
||||
}
|
||||
"floor" | "ceil" | "round" => {
|
||||
let a = one()?;
|
||||
let v = match f {
|
||||
"floor" => a.v.floor(),
|
||||
"ceil" => a.v.ceil(),
|
||||
_ => a.v.round(),
|
||||
};
|
||||
Value { v, ..a }
|
||||
}
|
||||
"min" | "max" => {
|
||||
let Some(first) = args.first().copied() else { return Err(DocError::Expr(format!("`{f}` needs arguments"))) };
|
||||
let mut acc = first;
|
||||
for a in args.iter().skip(1) {
|
||||
let d = add(*a, Value { v: 0.0, ..acc }, false)?;
|
||||
if (f == "min" && d.v < acc.v) || (f == "max" && d.v > acc.v) {
|
||||
acc = d;
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
_ => return Err(DocError::Expr(format!("unknown function `{f}`"))),
|
||||
})
|
||||
}
|
||||
|
||||
/// Evaluate an expression; `lookup` resolves parameter names.
|
||||
pub fn eval_with(s: &str, lookup: &dyn Fn(&str) -> Result<Value, DocError>) -> Result<Value, DocError> {
|
||||
let toks = lex(s)?;
|
||||
if toks.is_empty() {
|
||||
return Err(DocError::Expr("empty expression".into()));
|
||||
}
|
||||
let mut p = Parser { toks, pos: 0, lookup, depth: 0 };
|
||||
let v = p.expr()?;
|
||||
if p.pos < p.toks.len() {
|
||||
return Err(DocError::Expr(format!("unexpected input in `{s}`")));
|
||||
}
|
||||
if !v.v.is_finite() {
|
||||
return Err(DocError::Expr(format!("`{s}` is not a finite number")));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Evaluate all parameters (name → (expression, kind)) in dependency order.
|
||||
pub fn eval_params(params: &[(String, String, Kind)]) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
|
||||
let mut done: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut errors: BTreeMap<String, String> = BTreeMap::new();
|
||||
fn visit(
|
||||
name: &str,
|
||||
params: &[(String, String, Kind)],
|
||||
done: &mut BTreeMap<String, Value>,
|
||||
errors: &mut BTreeMap<String, String>,
|
||||
stack: &mut Vec<String>,
|
||||
) -> Result<Value, DocError> {
|
||||
if let Some(v) = done.get(name) {
|
||||
return Ok(*v);
|
||||
}
|
||||
if let Some(e) = errors.get(name) {
|
||||
return Err(DocError::Expr(e.clone()));
|
||||
}
|
||||
let Some((_, expr, kind)) = params.iter().find(|(n, _, _)| n == name) else {
|
||||
return Err(DocError::Expr(format!("unknown parameter `{name}`")));
|
||||
};
|
||||
if stack.iter().any(|s| s == name) || stack.len() > 256 {
|
||||
return Err(DocError::Expr(format!("circular reference through `{name}`")));
|
||||
}
|
||||
stack.push(name.to_string());
|
||||
let r = {
|
||||
let deps = references(expr);
|
||||
let mut vals: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut err = None;
|
||||
for d in deps {
|
||||
match visit(&d, params, done, errors, stack) {
|
||||
Ok(v) => {
|
||||
vals.insert(d, v);
|
||||
}
|
||||
Err(e) => {
|
||||
err = Some(e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
match err {
|
||||
Some(e) => Err(e),
|
||||
None => eval_with(expr, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")))).and_then(|v| {
|
||||
let x = v.to_kind(*kind)?;
|
||||
Ok(match kind {
|
||||
Kind::Length => Value::length(x),
|
||||
Kind::Angle => Value::angle(x),
|
||||
Kind::Unitless => Value::num(x),
|
||||
})
|
||||
}),
|
||||
}
|
||||
};
|
||||
stack.pop();
|
||||
match &r {
|
||||
Ok(v) => {
|
||||
done.insert(name.to_string(), *v);
|
||||
}
|
||||
Err(e) => {
|
||||
errors.insert(name.to_string(), e.to_string());
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
for (n, _, _) in params {
|
||||
let mut stack = Vec::new();
|
||||
let _ = visit(n, params, &mut done, &mut errors, &mut stack);
|
||||
}
|
||||
(done, errors)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ev(s: &str, k: Kind) -> Result<f64, DocError> {
|
||||
let look = |n: &str| match n {
|
||||
"width" => Ok(Value::length(40.0)),
|
||||
"ang" => Ok(Value::angle(std::f64::consts::FRAC_PI_2)),
|
||||
"count" => Ok(Value::num(3.0)),
|
||||
_ => Err(DocError::Expr(format!("unknown {n}"))),
|
||||
};
|
||||
eval_with(s, &look)?.to_kind(k)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_units() {
|
||||
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
|
||||
assert!((ev("90 deg", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
|
||||
assert_eq!(ev("10 mm", Kind::Length).unwrap(), 10.0);
|
||||
assert_eq!(ev("1 in", Kind::Length).unwrap(), 25.4);
|
||||
assert_eq!(ev("2 cm + 5", Kind::Length).unwrap(), 25.0);
|
||||
assert_eq!(ev("width / 2 + 1", Kind::Length).unwrap(), 21.0);
|
||||
assert_eq!(ev("width * count", Kind::Length).unwrap(), 120.0);
|
||||
assert_eq!(ev("-(2+3)*2^2", Kind::Unitless).unwrap(), -20.0);
|
||||
assert!((ev("45", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
|
||||
assert!((ev("ang / 2", Kind::Angle).unwrap() - std::f64::consts::FRAC_PI_4).abs() < 1e-12);
|
||||
assert!((ev("ang + 90", Kind::Angle).unwrap() - std::f64::consts::PI).abs() < 1e-12);
|
||||
assert!((ev("sqrt((3 mm) ^ 2 + (4 mm) ^ 2)", Kind::Length).unwrap() - 5.0).abs() < 1e-12);
|
||||
assert!((ev("sin(30)", Kind::Unitless).unwrap() - 0.5).abs() < 1e-12);
|
||||
assert!((ev("2*pi", Kind::Unitless).unwrap() - std::f64::consts::TAU).abs() < 1e-12);
|
||||
assert_eq!(ev("max(1 mm, 3, 2)", Kind::Length).unwrap(), 3.0);
|
||||
assert_eq!(ev("1e-3 m", Kind::Length).unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
assert!(ev("width + ang", Kind::Length).is_err());
|
||||
assert!(ev("width * width", Kind::Length).is_err());
|
||||
assert!(ev("nope", Kind::Length).is_err());
|
||||
assert!(ev("(1", Kind::Length).is_err());
|
||||
assert!(ev("1 / 0", Kind::Length).is_err());
|
||||
assert!(ev("", Kind::Length).is_err());
|
||||
assert!(ev("1 $ 2", Kind::Length).is_err());
|
||||
let deep = "(".repeat(500) + "1" + &")".repeat(500);
|
||||
assert!(ev(&deep, Kind::Length).is_err());
|
||||
assert!(ev(&"-".repeat(500), Kind::Length).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_in_order_with_cycles() {
|
||||
let p = vec![
|
||||
("b".to_string(), "a * 2".to_string(), Kind::Length),
|
||||
("a".to_string(), "10 mm".to_string(), Kind::Length),
|
||||
("x".to_string(), "y".to_string(), Kind::Length),
|
||||
("y".to_string(), "x + 1".to_string(), Kind::Length),
|
||||
];
|
||||
let (v, e) = eval_params(&p);
|
||||
assert_eq!(v.get("b").unwrap().v, 20.0);
|
||||
assert!(e.contains_key("x") && e.contains_key("y"));
|
||||
assert_eq!(references("2*width + sin(ang) + 3 mm"), vec!["width".to_string(), "ang".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,34 @@
|
||||
//! The SolveCraft document: user and model parameters, and a parametric timeline of features
|
||||
//! (sketches, extrudes, revolves, fillets, primitives, combines…). Evaluating the timeline with
|
||||
//! the kernel produces the model (bodies and solved sketches). Evaluation is incremental: a
|
||||
//! feature is recomputed only when it, a parameter it uses, or an earlier feature changed.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod document;
|
||||
mod eval;
|
||||
pub mod expr;
|
||||
|
||||
pub use document::*;
|
||||
pub use eval::{FeatureResult, Model, ModelBody, ModelState, SolvedSketch};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
pub use solvecraft_sketch as sketch;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum DocError {
|
||||
#[error("expression: {0}")]
|
||||
Expr(String),
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
#[error("unknown {0}")]
|
||||
Unknown(String),
|
||||
#[error(transparent)]
|
||||
Sketch(#[from] solvecraft_sketch::SketchError),
|
||||
#[error(transparent)]
|
||||
Kernel(#[from] solvecraft_kernel::KernelError),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, DocError>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,183 @@
|
||||
use solvecraft_geom::{Vec2, Vec3};
|
||||
use solvecraft_kernel::measure;
|
||||
use solvecraft_sketch::{ConstraintKind, Sketch};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
/// 40 x 30 rectangle with its width and height driven by `width` / d2.
|
||||
fn plate_doc() -> (Document, u64) {
|
||||
let mut doc = Document::new("plate");
|
||||
doc.set_param("width", "40 mm", Some("mm"), None).unwrap();
|
||||
let mut sk = Sketch::new();
|
||||
let pts = [Vec2::new(0.0, 0.0), Vec2::new(38.0, 0.5), Vec2::new(39.0, 31.0), Vec2::new(0.5, 29.0)];
|
||||
let ids: Vec<usize> = pts.iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
|
||||
let l: Vec<usize> = (0..4).map(|i| sk.add_line_pts(ids[i], ids[(i + 1) % 4], None).unwrap()).collect();
|
||||
use ConstraintKind::*;
|
||||
sk.add_constraint(Coincident { p: ids[0], q: 0 }, None).unwrap();
|
||||
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 d1 = doc.new_model_param("width", "mm");
|
||||
sk.add_constraint(Length { l: l[0], value: 40.0 }, Some(d1)).unwrap();
|
||||
let d2 = doc.new_model_param("30 mm", "mm");
|
||||
sk.add_constraint(Length { l: l[1], value: 30.0 }, Some(d2)).unwrap();
|
||||
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: s,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent { distance: "20".into(), direction: Direction::Positive, distance2: None, start_offset: None },
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
(doc, s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parametric_plate_reevaluates() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
|
||||
let st = m.state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
assert_eq!(st.bodies[0].name, "Body1");
|
||||
assert!(rel(measure(&st.bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
|
||||
assert!(st.sketches[0].report.fully_constrained());
|
||||
|
||||
// No change: nothing recomputed.
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 0);
|
||||
|
||||
doc.set_param("width", "50 mm", None, None).unwrap();
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 2);
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 30000.0) < 1e-9);
|
||||
|
||||
// Changing only the extrude distance re-evaluates only the extrude.
|
||||
if let Some(FeatureKind::Extrude { extent, .. }) = doc.features.get_mut(1).map(|f| &mut f.kind) {
|
||||
extent.distance = "width / 5".into();
|
||||
}
|
||||
m.evaluate(&doc);
|
||||
assert_eq!(m.last_recomputed, 1);
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 50.0 * 30.0 * 10.0) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_fillet_cut_timeline() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(0.0, 0.0, 10.0)], radius: "3".into(), body: None }, None).unwrap();
|
||||
let mut sk = Sketch::new();
|
||||
sk.add_circle(Vec2::new(20.0, 15.0), 5.0, None, None).unwrap();
|
||||
let s2 = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
sketch: s2,
|
||||
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
|
||||
extent: Extent { distance: "20".into(), direction: Direction::Positive, distance2: None, start_offset: None },
|
||||
operation: Operation::Cut,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results.iter().map(|r| &r.error).collect::<Vec<_>>());
|
||||
let st = m.state();
|
||||
let v = measure(&st.bodies[0].body).unwrap().volume;
|
||||
let expect = 24000.0 - (9.0 - std::f64::consts::PI * 9.0 / 4.0) * 20.0 - std::f64::consts::PI * 25.0 * 20.0;
|
||||
assert!(rel(v, expect) < 1e-3, "{v} vs {expect}");
|
||||
// Roll back to before the fillet.
|
||||
doc.marker = Some(2);
|
||||
m.evaluate(&doc);
|
||||
assert!(rel(measure(&m.state().bodies[0].body).unwrap().volume, 24000.0) < 1e-9);
|
||||
assert!(m.results[3].skipped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_are_reported_not_fatal() {
|
||||
let (mut doc, s) = plate_doc();
|
||||
doc.add_feature(
|
||||
FeatureKind::Revolve {
|
||||
sketch: s,
|
||||
profiles: ProfileSel::All,
|
||||
axis: AxisRef::SketchAxis { axis: "y".into() },
|
||||
angle: "90 deg".into(),
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(FeatureKind::Fillet { edges: vec![Vec3::new(500.0, 0.0, 0.0)], radius: "1".into(), body: None }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "nope".into(), width: "1".into(), height: "1".into(), operation: Operation::NewBody },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results[2].error.is_none(), "{:?}", m.results[2]);
|
||||
assert!(m.results[3].error.is_some());
|
||||
assert!(m.results[4].error.as_deref().unwrap_or("").contains("nope"));
|
||||
assert_eq!(m.state().bodies.len(), 2);
|
||||
let quarter = measure(&m.state().bodies[1].body).unwrap().volume;
|
||||
// Revolving the 40x30 rectangle (x in [0,40]) 90° about sketch y: π/4 · 40² · 30.
|
||||
assert!(rel(quarter, std::f64::consts::FRAC_PI_4 * 1600.0 * 30.0) < 1e-3, "{quarter}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_and_serialization() {
|
||||
let (mut doc, _) = plate_doc();
|
||||
assert!(doc.set_param("bad name", "1", None, None).is_err());
|
||||
assert!(doc.set_param("mm", "1", None, None).is_err());
|
||||
assert!(doc.set_param("loop", "loop + 1", None, None).is_err());
|
||||
assert!(doc.remove_param("width").is_err(), "used by d1");
|
||||
doc.set_param("spare", "3", None, None).unwrap();
|
||||
doc.remove_param("spare").unwrap();
|
||||
let json = doc.to_json();
|
||||
let back = Document::from_json(&json).unwrap();
|
||||
assert_eq!(back, doc);
|
||||
assert!(Document::from_json("{").is_err());
|
||||
let gone = doc.delete_feature(doc.features[0].id).unwrap();
|
||||
assert_eq!(gone.len(), 2, "the extrude goes with its sketch");
|
||||
assert!(doc.param("d1").is_none(), "unused model parameters are pruned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitives_and_combine() {
|
||||
let mut doc = Document::new("p");
|
||||
doc.add_feature(
|
||||
FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "10".into(), height: "10".into(), operation: Operation::NewBody },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Cylinder {
|
||||
base: Vec3::new(5.0, 5.0, 0.0),
|
||||
axis: Vec3::Z,
|
||||
radius: "2".into(),
|
||||
height: "20".into(),
|
||||
operation: Operation::NewBody,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(FeatureKind::Combine { target: "Body1".into(), tools: vec!["Body2".into()], operation: Operation::Cut, keep_tools: false }, None)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
|
||||
let st = m.state();
|
||||
assert_eq!(st.bodies.len(), 1);
|
||||
assert!(rel(measure(&st.bodies[0].body).unwrap().volume, 1000.0 - std::f64::consts::PI * 40.0) < 1e-3);
|
||||
}
|
||||
@@ -109,6 +109,41 @@ impl Mesh {
|
||||
self.edges.extend(o.edges.iter().cloned());
|
||||
}
|
||||
|
||||
/// Number of triangles a ray crosses (for inside/outside tests on closed meshes).
|
||||
pub fn ray_crossings(&self, o: Vec3, dir: Vec3) -> usize {
|
||||
let mut n = 0;
|
||||
for t in &self.triangles {
|
||||
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;
|
||||
}
|
||||
if e2.dot(q) * inv > 0.0 {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Point inside a closed mesh (majority vote of three skewed rays).
|
||||
pub fn contains(&self, p: Vec3) -> bool {
|
||||
let dirs = [Vec3::new(0.5773, 0.5774, 0.5776), Vec3::new(-0.6123, 0.3141, 0.7254), Vec3::new(0.2718, -0.8414, 0.4673)];
|
||||
dirs.iter().filter(|d| self.ray_crossings(p, **d) % 2 == 1).count() >= 2
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "solvecraft-kernel"
|
||||
description = "SolveCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, tessellation, measures and STEP, behind our own API"
|
||||
description = "SolveCraft B-rep kernel boundary: solids, extrude/revolve/booleans/fillets, tessellation, measures and STEP, behind our own API (implemented with truck)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -13,7 +13,8 @@ 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 }
|
||||
truck-modeling = { workspace = true }
|
||||
truck-shapeops = { workspace = true }
|
||||
truck-meshalgo = { workspace = true }
|
||||
truck-stepio = { workspace = true }
|
||||
truck-topology = { workspace = true }
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Edge fillets and chamfers as a local B-rep operation.
|
||||
//!
|
||||
//! Supported: a straight, convex edge between two planar faces whose two end vertices each
|
||||
//! touch exactly one more planar face perpendicular to the edge (the common case for edges of
|
||||
//! extruded and box-like parts). The two side faces are trimmed back, the end faces get an arc
|
||||
//! (fillet) or a line (chamfer) at the corner, and a cylindrical (or planar) face is inserted.
|
||||
//! The result is exact. Edges meeting at an already blended corner, curved edges and other
|
||||
//! configurations are reported as not supported yet.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling::{self as mt, builder};
|
||||
|
||||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Shape {
|
||||
Round,
|
||||
Flat,
|
||||
}
|
||||
|
||||
fn unsupported(msg: &str) -> KernelError {
|
||||
KernelError::Failed(format!("not supported yet: {msg}"))
|
||||
}
|
||||
|
||||
fn vtx(v: &mt::Vertex) -> Vec3 {
|
||||
from_p3(v.point())
|
||||
}
|
||||
|
||||
/// Outward unit normal of a planar face (from its oriented surface), if planar.
|
||||
fn plane_normal(f: &mt::Face) -> Option<Vec3> {
|
||||
match f.oriented_surface() {
|
||||
mt::Surface::Plane(p) => {
|
||||
let n = p.normal();
|
||||
Vec3::new(n.x, n.y, n.z).normalized()
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace edges in a face's oriented boundary (by id, keeping orientation), and insert extra
|
||||
/// edges where consecutive edges no longer connect.
|
||||
fn rebuild_face(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, connectors: &[mt::Edge]) -> Result<mt::Face> {
|
||||
let mut wires = Vec::new();
|
||||
for w in f.boundaries() {
|
||||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
let ne = match subst.get(&e.id()) {
|
||||
Some(n) => {
|
||||
// `n` was built in the orientation of the stored edge; match this use.
|
||||
let stored_forward = e.front() == e.absolute_front();
|
||||
if stored_forward { n.clone() } else { n.inverse() }
|
||||
}
|
||||
None => e.clone(),
|
||||
};
|
||||
edges.push(ne);
|
||||
}
|
||||
// Close gaps with connector edges.
|
||||
let n = edges.len();
|
||||
let mut out: Vec<mt::Edge> = Vec::new();
|
||||
for i in 0..n {
|
||||
let (Some(cur), Some(next)) = (edges.get(i), edges.get((i + 1) % n)) else { continue };
|
||||
out.push(cur.clone());
|
||||
if cur.back() != next.front() {
|
||||
let c = connectors
|
||||
.iter()
|
||||
.find_map(|c| {
|
||||
if c.front() == cur.back() && c.back() == next.front() {
|
||||
Some(c.clone())
|
||||
} else if c.back() == cur.back() && c.front() == next.front() {
|
||||
Some(c.inverse())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| KernelError::Failed("blend: could not close a face boundary".into()))?;
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
wires.push(mt::Wire::from(out));
|
||||
}
|
||||
let surface = f.oriented_surface();
|
||||
mt::Face::try_new(wires, surface).map_err(|e| KernelError::Failed(format!("blend: {e}")))
|
||||
}
|
||||
|
||||
fn blend_one(solid: &Solid, edge: &mt::Edge, size: f64, r: f64, shape: Shape) -> Result<Solid> {
|
||||
if !matches!(edge.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("only straight edges can be blended"));
|
||||
}
|
||||
let shells = solid.boundaries();
|
||||
let (si, shell) = shells
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(_, sh)| sh.edge_iter().any(|e| e.id() == edge.id()))
|
||||
.ok_or_else(|| KernelError::Failed("edge not found".into()))?;
|
||||
let faces: Vec<&mt::Face> = shell.face_iter().collect();
|
||||
let on_edge: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.edge_iter().any(|e| e.id() == edge.id()))).collect();
|
||||
let [i1, i2] = on_edge[..] else { return Err(unsupported("the edge is not between two faces")) };
|
||||
let (Some(f1), Some(f2)) = (faces.get(i1), faces.get(i2)) else { return Err(KernelError::Failed("faces".into())) };
|
||||
let (Some(n1), Some(n2)) = (plane_normal(f1), plane_normal(f2)) else { return Err(unsupported("both faces next to the edge must be planar")) };
|
||||
let (v0, v1) = (edge.absolute_front().clone(), edge.absolute_back().clone());
|
||||
let (p0, p1) = (vtx(&v0), vtx(&v1));
|
||||
let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
|
||||
// In-face directions perpendicular to the edge, pointing away from it.
|
||||
let mut t1 = n1.cross(d);
|
||||
if t1.dot(n2) > 0.0 {
|
||||
t1 = -t1;
|
||||
}
|
||||
let mut t2 = n2.cross(d);
|
||||
if t2.dot(n1) > 0.0 {
|
||||
t2 = -t2;
|
||||
}
|
||||
// Convex edge: the faces' outward normals point away from each other's interior.
|
||||
let cos_phi = t1.dot(t2).clamp(-1.0, 1.0);
|
||||
let phi = cos_phi.acos();
|
||||
if !(phi > 1e-3 && phi < std::f64::consts::PI - 1e-3) || n1.dot(t2) > 1e-9 || n2.dot(t1) > 1e-9 {
|
||||
return Err(unsupported("only convex edges can be blended"));
|
||||
}
|
||||
blend_geometry(solid, si, &faces, (i1, i2), edge, (v0, v1), (p0, p1, d), (n1, t1, t2, phi), size, r, shape)
|
||||
}
|
||||
|
||||
/// Distance along each face from the edge to the blend boundary.
|
||||
fn setback(r: f64, phi: f64, shape: Shape) -> f64 {
|
||||
match shape {
|
||||
Shape::Round => r / (phi / 2.0).tan(),
|
||||
Shape::Flat => r,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn blend_geometry(
|
||||
solid: &Solid,
|
||||
si: usize,
|
||||
faces: &[&mt::Face],
|
||||
(i1, i2): (usize, usize),
|
||||
edge: &mt::Edge,
|
||||
(v0, v1): (mt::Vertex, mt::Vertex),
|
||||
(p0, p1, d): (Vec3, Vec3, Vec3),
|
||||
(n1, t1, t2, phi): (Vec3, Vec3, Vec3, f64),
|
||||
size: f64,
|
||||
r: f64,
|
||||
shape: Shape,
|
||||
) -> Result<Solid> {
|
||||
let s = setback(r, phi, shape);
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
// End faces and the side edges at each end.
|
||||
struct End {
|
||||
v: mt::Vertex,
|
||||
g: usize,
|
||||
e1: mt::Edge,
|
||||
e2: mt::Edge,
|
||||
}
|
||||
let mut ends: Vec<End> = Vec::new();
|
||||
for (v, p) in [(&v0, p0), (&v1, p1)] {
|
||||
let at_v: Vec<usize> = (0..faces.len()).filter(|i| faces.get(*i).is_some_and(|f| f.vertex_iter().any(|x| &x == v))).collect();
|
||||
let others: Vec<usize> = at_v.iter().copied().filter(|i| *i != i1 && *i != i2).collect();
|
||||
let [g] = others[..] else { return Err(unsupported("each end of the edge must touch exactly one more face")) };
|
||||
let gf = faces.get(g).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
let ng = plane_normal(gf).ok_or_else(|| unsupported("the faces at the edge ends must be planar"))?;
|
||||
if ng.dot(d).abs() < 1.0 - 1e-9 {
|
||||
return Err(unsupported("the faces at the edge ends must be perpendicular to the edge"));
|
||||
}
|
||||
// Edges at v shared by g with f1 and with f2.
|
||||
let shared = |fi: usize| -> Result<mt::Edge> {
|
||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
f.edge_iter()
|
||||
.find(|e| e.id() != edge.id() && (e.front() == v || e.back() == v) && gf.edge_iter().any(|x| x.id() == e.id()))
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))
|
||||
};
|
||||
let (e1, e2) = (shared(i1)?, shared(i2)?);
|
||||
for (e, t) in [(&e1, t1), (&e2, t2)] {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("the edges at the corners must be straight"));
|
||||
}
|
||||
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
|
||||
let along = (other - p).dot(t);
|
||||
if (other - p - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
ends.push(End { v: v.clone(), g, e1, e2 });
|
||||
}
|
||||
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
if end0.g == end1.g {
|
||||
return Err(unsupported("both ends touch the same face"));
|
||||
}
|
||||
let a0 = builder::vertex(p3(p0 + t1 * s));
|
||||
let b0 = builder::vertex(p3(p0 + t2 * s));
|
||||
let a1 = builder::vertex(p3(p1 + t1 * s));
|
||||
let b1 = builder::vertex(p3(p1 + t2 * s));
|
||||
// Corner curves on the end faces (A → B) and the blend surface.
|
||||
let bisector = (t1 + t2).normalized().ok_or_else(|| KernelError::Failed("bisector".into()))?;
|
||||
let corner = |a: &mt::Vertex, b: &mt::Vertex, p: Vec3| -> mt::Edge {
|
||||
match shape {
|
||||
Shape::Round => {
|
||||
// Arc centre is r inside both faces; its mid point is on the bisector, toward the edge.
|
||||
let c = p + t1 * s - n1 * r;
|
||||
let mid = c - bisector * r;
|
||||
builder::circle_arc(a, b, p3(mid))
|
||||
}
|
||||
Shape::Flat => builder::line(a, b),
|
||||
}
|
||||
};
|
||||
let c0 = corner(&a0, &b0, p0);
|
||||
let c1 = corner(&a1, &b1, p1);
|
||||
let la = builder::line(&a0, &a1);
|
||||
let lb = builder::line(&b0, &b1);
|
||||
|
||||
// Substitutions: side edges at the ends now stop at A/B; the blended edge moves to A or B.
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
|
||||
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
|
||||
let (f, bk) = (e.absolute_front(), e.absolute_back());
|
||||
let ne = if f == &end.v { builder::line(nv, bk) } else { builder::line(f, nv) };
|
||||
subst.insert(e.id(), ne);
|
||||
}
|
||||
}
|
||||
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
let nf = if i == i1 {
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), la.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if i == i2 {
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), lb.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if i == end0.g {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&c0))?
|
||||
} else if i == end1.g {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&c1))?
|
||||
} else {
|
||||
(*f).clone()
|
||||
};
|
||||
new_faces.push(nf);
|
||||
}
|
||||
// The blend face uses each new edge opposite to its neighbour.
|
||||
let f1_forward = faces.get(i1).is_some_and(|f| f.boundaries().iter().any(|w| w.edge_iter().any(|e| e.id() == edge.id() && e.front() == &v0)));
|
||||
let wire: mt::Wire = if f1_forward {
|
||||
vec![c0.clone(), lb.clone(), c1.inverse(), la.inverse()].into()
|
||||
} else {
|
||||
vec![la.clone(), c1.clone(), lb.inverse(), c0.inverse()].into()
|
||||
};
|
||||
let blend_face = match shape {
|
||||
Shape::Flat => builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("chamfer face: {e}")))?,
|
||||
Shape::Round => {
|
||||
let swept: mt::Face = builder::tsweep(&c0, v3(p1 - p0));
|
||||
let mut surf = swept.oriented_surface();
|
||||
// Outward normal at the arc middle points away from the arc centre.
|
||||
let c = p0 + t1 * s - n1 * r;
|
||||
let mid = c - bisector * r;
|
||||
use mt::{ParametricSurface3D, SearchParameter};
|
||||
if let Some((u, v)) = surf.search_parameter(p3(mid), None, 100) {
|
||||
let nn = surf.normal(u, v);
|
||||
if Vec3::new(nn.x, nn.y, nn.z).dot(bisector) > 0.0 {
|
||||
surf = mt::Invertible::inverse(&surf);
|
||||
}
|
||||
}
|
||||
mt::Face::try_new(vec![wire], surf).map_err(|e| KernelError::Failed(format!("fillet face: {e}")))?
|
||||
}
|
||||
};
|
||||
new_faces.push(blend_face);
|
||||
let mut shells = solid.boundaries().clone();
|
||||
let new_shell: mt::Shell = new_faces.into();
|
||||
if let Some(sh) = shells.get_mut(si) {
|
||||
*sh = new_shell;
|
||||
}
|
||||
Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("blend produced an invalid solid: {e}")))
|
||||
}
|
||||
|
||||
fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Result<Body> {
|
||||
if !(r.is_finite() && r > 1e-6 && r < 1e6) {
|
||||
return Err(KernelError::Invalid(format!("{what} size must be positive")));
|
||||
}
|
||||
if edges.is_empty() {
|
||||
return Err(KernelError::Invalid("no edges selected".into()));
|
||||
}
|
||||
if edges.len() > 1000 {
|
||||
return Err(KernelError::Invalid("too many edges".into()));
|
||||
}
|
||||
let mut cur = body.clone();
|
||||
for p in edges {
|
||||
let size = cur.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
let Some((idx, dist)) = cur.nearest_edge(*p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) };
|
||||
if dist > size * 0.05 + 1e-3 {
|
||||
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
|
||||
}
|
||||
let solid = cur.deep_copy();
|
||||
let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?;
|
||||
let out = guard(what, || blend_one(&solid, &edge, size, r, shape))?;
|
||||
cur = Body::new(out)?;
|
||||
}
|
||||
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, Shape::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, Shape::Flat, "chamfer")
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use monstertruck_meshing::prelude::*;
|
||||
use monstertruck_modeling as mt;
|
||||
use serde::Serialize;
|
||||
use solvecraft_geom::{Mesh, Vec3};
|
||||
use truck_meshalgo::prelude::*;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
@@ -61,7 +61,7 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
|
||||
|
||||
impl Body {
|
||||
pub(crate) fn new(mut solid: Solid) -> Result<Body> {
|
||||
if solid.is_empty() {
|
||||
if solid.boundaries().is_empty() {
|
||||
return Err(KernelError::Failed("empty result".into()));
|
||||
}
|
||||
// Orient outward: a closed solid must have positive volume.
|
||||
@@ -216,6 +216,11 @@ impl Body {
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
/// Is the point inside the solid?
|
||||
pub fn contains(&self, p: Vec3) -> bool {
|
||||
self.tessellate(self.size() * 2e-3).map(|m| m.contains(p)).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn face_count(&self) -> usize {
|
||||
self.solid.face_iter().count()
|
||||
}
|
||||
|
||||
+26
-24
@@ -1,8 +1,8 @@
|
||||
//! 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 truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, Solid, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
@@ -33,20 +33,12 @@ fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||||
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()))?
|
||||
let vm = builder::vertex(p3(plane.to_world(s.mid())));
|
||||
edges.push(builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25)))));
|
||||
builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75))))
|
||||
} else {
|
||||
builder::try_circle_arc(a, b, mt::builder::CircularArcConstraint::ThroughPoint(p3(mid)))
|
||||
.map_err(|e| KernelError::Invalid(e.to_string()))?
|
||||
builder::circle_arc(a, b, p3(plane.to_world(s.mid())))
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -61,7 +53,7 @@ pub(crate) fn face(plane: &Plane, r: &Region2) -> Result<mt::Face> {
|
||||
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}"))))
|
||||
guard("face", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
|
||||
}
|
||||
|
||||
fn region_ok(r: &Region2) -> Result<()> {
|
||||
@@ -98,7 +90,7 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
|
||||
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)))))?;
|
||||
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
|
||||
out.push(Body::new(solid)?);
|
||||
}
|
||||
Ok(out)
|
||||
@@ -130,16 +122,26 @@ pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir:
|
||||
if neg && pos {
|
||||
return Err(KernelError::Invalid("the profile crosses the revolve axis".into()));
|
||||
}
|
||||
// Points on the axis (within solver tolerance) go exactly onto it, so the sweep does not
|
||||
// create hair-thin faces.
|
||||
let snap = |p: Vec2| {
|
||||
let along = (p - axis_origin).dot(d);
|
||||
let q = axis_origin + d * along;
|
||||
if p.dist(q) < 1e-7 { q } else { p }
|
||||
};
|
||||
let snap_loop = |l: &Loop2| Loop2 {
|
||||
segs: l
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) },
|
||||
arc => arc,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let r = &Region2 { outer: snap_loop(&r.outer), holes: r.holes.iter().map(snap_loop).collect() };
|
||||
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)
|
||||
})
|
||||
})?;
|
||||
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
|
||||
out.push(Body::new(solid)?);
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! measurement and STEP export. The implementation uses the `truck` crates (Apache-2.0; see
|
||||
//! `plan/adr/0001-geometry-kernel.md`) plus our own local operations (edge fillets and
|
||||
//! chamfers in `blend.rs`), but no truck 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 blend;
|
||||
mod body;
|
||||
mod build;
|
||||
mod measure;
|
||||
@@ -19,10 +20,11 @@ mod ops;
|
||||
mod step;
|
||||
mod topo;
|
||||
|
||||
pub use blend::{chamfer, fillet};
|
||||
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 ops::{BoolOp, boolean, transform};
|
||||
pub use step::step_export;
|
||||
pub use topo::{TopoCounts, merged_topology};
|
||||
|
||||
|
||||
+63
-72
@@ -1,10 +1,10 @@
|
||||
//! Operations on bodies: booleans, fillets, chamfers, rigid transforms.
|
||||
//! Operations on bodies: booleans and rigid transforms.
|
||||
|
||||
use monstertruck_modeling as mt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solvecraft_geom::Vec3;
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, Solid, v3};
|
||||
use crate::body::{Body, p3, v3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -15,84 +15,75 @@ pub enum BoolOp {
|
||||
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.
|
||||
/// Shifts applied to both operands on retries. The boolean classifies some faces by casting a
|
||||
/// ray whose direction is derived from point coordinates; moving both solids (and the result
|
||||
/// back) leaves the geometry unchanged but changes those rays.
|
||||
const JITTER: [[f64; 3]; 4] = [[0.0, 0.0, 0.0], [0.1373, 0.2719, 0.3911], [-0.5127, 0.0833, 0.2291], [0.6911, -0.3337, -0.6127]];
|
||||
|
||||
fn volume(b: &Body) -> f64 {
|
||||
b.tessellate(b.size() * 2e-3).map(|m| m.measure().volume).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes
|
||||
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
|
||||
/// and retried with shifted copies and other tolerances when they fail or look wrong.
|
||||
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
let size = a.size().max(b.size());
|
||||
let (va, vb) = (volume(a), volume(b));
|
||||
let slack = 2e-3 * (va + vb) + 1e-9;
|
||||
let plausible = |v: f64| match op {
|
||||
BoolOp::Union => v >= va.max(vb) - slack && v <= va + vb + slack,
|
||||
BoolOp::Cut => v >= va - vb - slack && v <= va + slack,
|
||||
BoolOp::Intersect => v <= va.min(vb) + slack,
|
||||
};
|
||||
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));
|
||||
let mut empty_votes = 0;
|
||||
for (attempt, j) in JITTER.iter().enumerate() {
|
||||
let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01);
|
||||
for k in [5e-4, 2e-3] {
|
||||
let tol = (size * k).max(1e-5);
|
||||
let r = guard("boolean", || {
|
||||
let (mut sa, mut sb) = (a.deep_copy(), b.deep_copy());
|
||||
if attempt > 0 {
|
||||
sa = mt::builder::translated(&sa, shift);
|
||||
sb = mt::builder::translated(&sb, shift);
|
||||
}
|
||||
last = "degenerate result".into();
|
||||
let res = match op {
|
||||
BoolOp::Union => truck_shapeops::or(&sa, &sb, tol),
|
||||
BoolOp::Cut => {
|
||||
sb.not();
|
||||
truck_shapeops::and(&sa, &sb, tol)
|
||||
}
|
||||
BoolOp::Intersect => truck_shapeops::and(&sa, &sb, tol),
|
||||
};
|
||||
let s = res.ok_or_else(|| KernelError::Failed("no result".into()))?;
|
||||
Ok(if attempt > 0 { mt::builder::translated(&s, -shift) } else { s })
|
||||
});
|
||||
match r {
|
||||
Ok(s) if s.boundaries().is_empty() || s.face_iter().next().is_none() => {
|
||||
empty_votes += 1;
|
||||
last = "empty result".into();
|
||||
}
|
||||
Ok(s) => match Body::new(s) {
|
||||
Ok(body) => {
|
||||
let v = volume(&body);
|
||||
if v > 0.0 && plausible(v) {
|
||||
return Ok(Some(body));
|
||||
}
|
||||
last = format!("implausible result volume {v:.4}");
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
},
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
if empty_votes >= 2 && op != BoolOp::Union {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
@@ -102,7 +93,7 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
|
||||
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));
|
||||
s = mt::builder::rotated(&s, p3(origin), v3(ax), mt::Rad(angle));
|
||||
}
|
||||
if translate.len() > 0.0 {
|
||||
s = mt::builder::translated(&s, v3(translate));
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
use monstertruck_io::step::save;
|
||||
use truck_stepio::out;
|
||||
|
||||
use crate::body::Body;
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
/// STEP (ISO 10303-21, AP203-style geometry) text for the given bodies.
|
||||
/// STEP (ISO 10303-21) 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)
|
||||
let models: out::StepModels<_, _, _> = cs.iter().collect();
|
||||
let header = out::StepHeaderDescriptor { organization_system: system.to_owned(), ..Default::default() };
|
||||
Ok(out::CompleteStepDisplay::new(models, header).to_string())
|
||||
})
|
||||
}
|
||||
@@ -119,6 +119,32 @@ fn hostile_inputs_are_errors() {
|
||||
assert!(transform(&b, Vec3::new(f64::NAN, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillets_on_several_edges_and_angles() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
// Two opposite vertical edges, then a horizontal top edge between them is not touching either.
|
||||
let f = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(40.0, 30.0, 10.0)], 4.0).unwrap();
|
||||
let corner = 16.0 - PI * 4.0;
|
||||
assert!(rel(measure(&f).unwrap().volume, 24000.0 - 2.0 * corner * 20.0) < 2e-4);
|
||||
let top = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0)], 2.0).unwrap();
|
||||
assert!(rel(measure(&top).unwrap().volume, 24000.0 - (4.0 - PI) * 40.0) < 2e-4);
|
||||
// Triangular prism: 60° edges.
|
||||
let tri = Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(15.0, 25.980762113533)]), holes: vec![] };
|
||||
let p = extrude(&Plane::XY, &[tri], 0.0, 10.0).unwrap().pop().unwrap();
|
||||
let r = 2.0;
|
||||
let fp = fillet(&p, &[Vec3::new(0.0, 0.0, 5.0)], r).unwrap();
|
||||
// Removed area for interior angle φ: r²(cot(φ/2)) − r²(π − φ)/2.
|
||||
let phi = PI / 3.0;
|
||||
let removed = r * r / (phi / 2.0).tan() - r * r * (PI - phi) / 2.0;
|
||||
let v0 = measure(&p).unwrap().volume;
|
||||
assert!(rel(measure(&fp).unwrap().volume, v0 - removed * 10.0) < 2e-4, "{} vs {}", measure(&fp).unwrap().volume, v0 - removed * 10.0);
|
||||
// Adjacent edges at a blended corner are not supported yet (an error, not a crash).
|
||||
assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(0.0, 15.0, 20.0)], 2.0).is_err());
|
||||
// Concave edge: not supported.
|
||||
let u = boolean(&b, &box_solid(Vec3::new(10.0, 10.0, 15.0), Vec3::new(20.0, 20.0, 30.0)).unwrap(), BoolOp::Union).unwrap().unwrap();
|
||||
assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_convergence() {
|
||||
@@ -135,3 +161,58 @@ fn debug_convergence() {
|
||||
println!("face {f}: {} normals, first {:?}, max |n.z| {}", ns.len(), ns.first(), ns.iter().map(|n| n.z.abs()).fold(0.0, f64::max));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_coplanar() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
for (z0, z1) in [(0.0, 20.0), (-5.0, 20.0), (0.0, 25.0), (-5.0, 25.0), (5.0, 20.0), (5.0, 25.0)] {
|
||||
let c = cylinder(Vec3::new(20.0, 15.0, z0), Vec3::Z, 5.0, z1 - z0).unwrap();
|
||||
let r = boolean(&b, &c, BoolOp::Cut);
|
||||
println!("cut z {z0}..{z1}: {:?}", r.map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
let r = boolean(&b, &c, BoolOp::Union);
|
||||
println!("union z {z0}..{z1}: {:?}", r.map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
}
|
||||
let r =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]), holes: vec![] };
|
||||
for ang in [PI / 2.0, PI, 1.5 * PI, 2.0 * PI] {
|
||||
let q = revolve(&Plane::XY, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, ang).unwrap().pop().unwrap();
|
||||
let m = measure(&q).unwrap();
|
||||
println!("revolve {ang}: vol {} expect {} faces {}", m.volume, ang / 2.0 * 1600.0 * 30.0, m.faces);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_blind() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
let t = box_solid(Vec3::new(10.0, 10.0, 5.0), Vec3::new(20.0, 20.0, 25.0)).unwrap();
|
||||
println!("box pocket: {:?}", boolean(&b, &t, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
let t = box_solid(Vec3::new(10.0, 10.0, 5.0), Vec3::new(20.0, 20.0, 15.0)).unwrap();
|
||||
println!("box void: {:?}", boolean(&b, &t, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
for r in [5.0, 4.3] {
|
||||
for z in [5.0, 7.3, 13.1] {
|
||||
let c = cylinder(Vec3::new(20.3, 15.1, z), Vec3::Z, r, 30.0).unwrap();
|
||||
println!("cyl r{r} z{z}: {:?}", boolean(&b, &c, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
}
|
||||
}
|
||||
let c = cylinder(Vec3::new(20.0, 15.0, 25.0), -Vec3::Z, 5.0, 20.0).unwrap();
|
||||
println!("cyl down: {:?}", boolean(&b, &c, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
let s = sphere(Vec3::new(20.0, 15.0, 20.0), 5.0).unwrap();
|
||||
println!("sphere: {:?}", boolean(&b, &s, BoolOp::Cut).map(|o| o.map(|b| measure(&b).unwrap().volume)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_revolve_doc_case() {
|
||||
let r =
|
||||
Region2 { outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(40.0, 0.0), Vec2::new(40.0, 30.0), Vec2::new(0.0, 30.0)]), holes: vec![] };
|
||||
for ang in [PI / 2.0, -PI / 2.0] {
|
||||
let q = revolve(&Plane::XY, std::slice::from_ref(&r), Vec2::ZERO, Vec2::Y, ang).unwrap().pop().unwrap();
|
||||
let m = measure(&q).unwrap();
|
||||
println!("ang {ang}: vol {} bbox {:?}", m.volume, m.bbox);
|
||||
}
|
||||
let r2 = Region2 { outer: r.outer.reversed(), holes: vec![] };
|
||||
let q = revolve(&Plane::XY, std::slice::from_ref(&r2), Vec2::ZERO, Vec2::Y, PI / 2.0).unwrap().pop().unwrap();
|
||||
println!("reversed: {}", measure(&q).unwrap().volume);
|
||||
}
|
||||
@@ -174,7 +174,7 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
if !faces.contains(&fi) {
|
||||
faces.push(fi);
|
||||
}
|
||||
let mut vix = |v: &monstertruck_modeling::Vertex| {
|
||||
let mut vix = |v: &truck_modeling::Vertex| {
|
||||
let k = format!("{:?}", v.id());
|
||||
let n = vid.len();
|
||||
let i = *vid.entry(k).or_insert(n);
|
||||
|
||||
Reference in new issue
Block a user