solid.move takes copy: true to keep the source bodies and transform a new copy of each, with a Create Copy box in the Move dialog. Designs without it save and load exactly as before.
4186 lines
199 KiB
Rust
4186 lines
199 KiB
Rust
//! Timeline evaluation.
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::hash::{Hash, Hasher};
|
||
use std::sync::{Arc, OnceLock};
|
||
|
||
use serde::Serialize;
|
||
use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3};
|
||
use solidcraft_kernel::{self as kernel, Body, BoolOp};
|
||
use solidcraft_sketch::{CurveKind, Profile, SolveReport, find_profiles};
|
||
|
||
use crate::expr::{self, Kind, Value};
|
||
use crate::lineage::Traced;
|
||
use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operation, PlaneRef, ProfileSel, Result};
|
||
|
||
/// A body in the evaluated model.
|
||
#[derive(Clone, Debug)]
|
||
pub struct ModelBody {
|
||
pub name: String,
|
||
/// Definition identity for a computed placement; never parsed from its display name.
|
||
pub source_name: Option<String>,
|
||
/// Definition geometry coordinates to this computed body's frame.
|
||
pub source_transform: crate::Mat,
|
||
pub body: Body,
|
||
/// Feature that created it.
|
||
pub feature: u64,
|
||
// Timeline snapshots are cloned before meshes are requested. Share the cell itself so
|
||
// meshing any snapshot also warms every unchanged copy of this body.
|
||
mesh: Arc<OnceLock<Arc<Mesh>>>,
|
||
/// Identity of this body's content for the evaluation cache: the key of the evaluation
|
||
/// that produced it (kept while later features pass it through unchanged).
|
||
key: u64,
|
||
/// Persistent face names (worked out on first use).
|
||
pub(crate) names: crate::naming::NameCell,
|
||
/// How the feature being evaluated made or changed it (read when its names are set up).
|
||
pub(crate) made: crate::lineage::Made,
|
||
/// The component the body belongs to (set after each feature).
|
||
pub component: u64,
|
||
/// A timeline transfer has converted this body into its evaluated owner's frame.
|
||
pub transferred: bool,
|
||
/// The look it was imported with, carried through later features.
|
||
pub imported: Option<Arc<crate::appearance::ImportedLook>>,
|
||
}
|
||
|
||
impl ModelBody {
|
||
pub fn new(name: String, body: Body, feature: u64) -> Self {
|
||
ModelBody {
|
||
name,
|
||
source_name: None,
|
||
source_transform: crate::IDENTITY,
|
||
body,
|
||
feature,
|
||
mesh: Arc::new(OnceLock::new()),
|
||
key: 0,
|
||
names: Default::default(),
|
||
made: Default::default(),
|
||
component: 0,
|
||
transferred: false,
|
||
imported: None,
|
||
}
|
||
}
|
||
pub fn definition_name(&self) -> &str {
|
||
self.source_name.as_deref().unwrap_or(&self.name)
|
||
}
|
||
/// Display mesh, computed once: chord tolerance 1/1000 of the body size, and no facet of a
|
||
/// curved face or edge turning more than [`kernel::DISPLAY_ANGLE`], so that a bend or a
|
||
/// hole that is small beside the body is still drawn round.
|
||
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.display_mesh_angular(tol, kernel::DISPLAY_ANGLE).unwrap_or_default())
|
||
})
|
||
.clone()
|
||
}
|
||
|
||
/// Already computed display geometry; saving must not trigger cold tessellation.
|
||
pub fn cached_mesh(&self) -> Option<Arc<Mesh>> {
|
||
self.mesh.get().cloned()
|
||
}
|
||
}
|
||
|
||
/// A solved sketch in the evaluated model.
|
||
#[derive(Clone, Debug)]
|
||
pub struct SolvedSketch {
|
||
pub feature: u64,
|
||
pub name: String,
|
||
pub plane: Plane,
|
||
pub sketch: solidcraft_sketch::Sketch,
|
||
pub report: SolveReport,
|
||
pub profiles: Vec<Profile>,
|
||
}
|
||
|
||
/// A cosmetic thread: on which cylinder, its size, and the threaded part of the axis.
|
||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||
pub struct ThreadInfo {
|
||
pub feature: u64,
|
||
pub body: String,
|
||
pub designation: String,
|
||
pub major_diameter: f64,
|
||
pub pitch: f64,
|
||
pub internal: bool,
|
||
pub axis_point: Vec3,
|
||
pub axis: Vec3,
|
||
/// Threaded part along the axis from `axis_point`.
|
||
pub start: f64,
|
||
pub end: f64,
|
||
}
|
||
|
||
/// ISO metric coarse threads: (nominal diameter, pitch).
|
||
const ISO_COARSE: [(f64, f64); 22] = [
|
||
(1.6, 0.35),
|
||
(2.0, 0.4),
|
||
(2.5, 0.45),
|
||
(3.0, 0.5),
|
||
(4.0, 0.7),
|
||
(5.0, 0.8),
|
||
(6.0, 1.0),
|
||
(8.0, 1.25),
|
||
(10.0, 1.5),
|
||
(12.0, 1.75),
|
||
(14.0, 2.0),
|
||
(16.0, 2.0),
|
||
(18.0, 2.5),
|
||
(20.0, 2.5),
|
||
(22.0, 2.5),
|
||
(24.0, 3.0),
|
||
(27.0, 3.0),
|
||
(30.0, 3.5),
|
||
(36.0, 4.0),
|
||
(42.0, 4.5),
|
||
(48.0, 5.0),
|
||
(56.0, 5.5),
|
||
];
|
||
|
||
/// "M8" or "M8x1" → (nominal diameter, pitch).
|
||
pub fn parse_metric_thread(s: &str) -> Option<(f64, f64)> {
|
||
let t = s.trim().to_ascii_uppercase();
|
||
let body = t.strip_prefix('M')?;
|
||
let (d, p) = match body.split_once(['X', '×']) {
|
||
Some((d, p)) => (d.trim().parse::<f64>().ok()?, Some(p.trim().parse::<f64>().ok()?)),
|
||
None => (body.trim().parse::<f64>().ok()?, None),
|
||
};
|
||
let pitch = match p {
|
||
Some(p) => p,
|
||
None => ISO_COARSE.iter().find(|(n, _)| (n - d).abs() < 1e-9)?.1,
|
||
};
|
||
(d > 0.0 && pitch > 0.0 && pitch < d).then_some((d, pitch))
|
||
}
|
||
|
||
/// The metric thread a cylinder of this diameter takes: a shaft is the major diameter, a hole
|
||
/// the tap drill (major − pitch).
|
||
fn thread_for(diameter: f64, internal: bool) -> Option<(f64, f64)> {
|
||
ISO_COARSE
|
||
.iter()
|
||
.map(|&(n, p)| (n, p, if internal { n - p } else { n }))
|
||
.min_by(|a, b| (a.2 - diameter).abs().total_cmp(&(b.2 - diameter).abs()))
|
||
.filter(|(_, _, want)| (want - diameter).abs() <= 0.15 * diameter)
|
||
.map(|(n, p, _)| (n, p))
|
||
}
|
||
|
||
fn format_thread(d: f64, p: f64) -> String {
|
||
let coarse = ISO_COARSE.iter().any(|(n, q)| (n - d).abs() < 1e-9 && (q - p).abs() < 1e-9);
|
||
let num = |x: f64| if (x - x.round()).abs() < 1e-9 { format!("{}", x.round()) } else { format!("{x}") };
|
||
if coarse { format!("M{}", num(d)) } else { format!("M{}x{}", num(d), num(p)) }
|
||
}
|
||
|
||
/// A cosmetic thread on the cylindrical face at `p`.
|
||
fn thread_on(st: &ModelState, feature: u64, p: Vec3, designation: Option<&str>, length: Option<f64>) -> Result<ThreadInfo> {
|
||
let (body, cyl) = st
|
||
.bodies
|
||
.iter()
|
||
.filter_map(|b| kernel::cylinder_face_at(&b.body, p).map(|c| (b.name.clone(), c)))
|
||
.min_by(|a, b| {
|
||
let dist = |c: &kernel::CylinderFace| ((p - c.axis_point - c.axis * (p - c.axis_point).dot(c.axis)).len() - c.radius).abs();
|
||
dist(&a.1).total_cmp(&dist(&b.1))
|
||
})
|
||
.ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?;
|
||
let dia = cyl.radius * 2.0;
|
||
let (major, pitch) = match designation {
|
||
Some(s) => parse_metric_thread(s).ok_or_else(|| DocError::Invalid(format!("unknown thread `{s}` (ISO metric, like M8 or M8x1)")))?,
|
||
None => thread_for(dia, cyl.internal).ok_or_else(|| DocError::Invalid(format!("no metric thread fits a {dia:.2} mm cylinder")))?,
|
||
};
|
||
let fits = if cyl.internal {
|
||
(major - pitch * 1.0825 - dia).abs() < 0.2 * major || (major - pitch - dia).abs() < 0.15 * major
|
||
} else {
|
||
(major - dia).abs() < 0.15 * major
|
||
};
|
||
if !fits {
|
||
return Err(DocError::Invalid(format!(
|
||
"an {} thread does not fit a {dia:.2} mm {}",
|
||
format_thread(major, pitch),
|
||
if cyl.internal { "hole" } else { "shaft" }
|
||
)));
|
||
}
|
||
// From the end of the face nearest the point.
|
||
let s = (p - cyl.axis_point).dot(cyl.axis);
|
||
let (mut a, mut b) = (cyl.start, cyl.end);
|
||
if let Some(l) = length {
|
||
if !(l > 0.0) {
|
||
return Err(DocError::Invalid("thread length must be positive".into()));
|
||
}
|
||
if (s - a).abs() <= (b - s).abs() {
|
||
b = (a + l).min(b);
|
||
} else {
|
||
a = (b - l).max(a);
|
||
}
|
||
}
|
||
Ok(ThreadInfo {
|
||
feature,
|
||
body,
|
||
designation: format_thread(major, pitch),
|
||
major_diameter: major,
|
||
pitch,
|
||
internal: cyl.internal,
|
||
axis_point: cyl.axis_point,
|
||
axis: cyl.axis,
|
||
start: a,
|
||
end: b,
|
||
})
|
||
}
|
||
|
||
/// The helical space between the teeth of a modelled ISO metric thread (ISO 68-1 basic
|
||
/// profile, 60° flanks): cut from the shaft (external) or the hole wall (internal), running from
|
||
/// `start` to `end` along the axis. `radius` is the face's own radius; `open` says which ends
|
||
/// of the threaded length are free ends of the face.
|
||
fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<solidcraft_geom::Mesh> {
|
||
let (p, d) = (t.pitch, t.major_diameter);
|
||
let h = p * 3f64.sqrt() / 2.0;
|
||
let slope = (std::f64::consts::PI / 6.0).tan();
|
||
let len = t.end - t.start;
|
||
if !(p > 1e-3 && len > 1e-6) {
|
||
return Err(DocError::Invalid("thread too short".into()));
|
||
}
|
||
if len / p > 190.0 {
|
||
return Err(DocError::Invalid(format!("a modelled thread is limited to 190 turns ({:.0} mm at this pitch); use a cosmetic one", 190.0 * p)));
|
||
}
|
||
// (radius, half width of the groove) at its two ends: a trapezoid in the axial plane.
|
||
let (r0, w0, r1, w1) = if t.internal {
|
||
// From inside the hole wall out to the major diameter, where the bolt's crest sits
|
||
// (the root flat is P/8 wide).
|
||
let r_in = radius.min(d / 2.0 - 5.0 * h / 8.0) - 0.1 * p;
|
||
(r_in, (p / 8.0 + 2.0 * (d / 2.0 - r_in) * slope) / 2.0, d / 2.0, p / 16.0)
|
||
} else {
|
||
// From the root (5H/8 under the major diameter, flat P/4 wide) out past the surface.
|
||
let r_root = d / 2.0 - 5.0 * h / 8.0;
|
||
let r_out = radius.max(d / 2.0) + 0.05 * p;
|
||
(r_root, p / 8.0, r_out, (p / 4.0 + 2.0 * (r_out - r_root) * slope) / 2.0)
|
||
};
|
||
if w0.max(w1) * 2.0 >= p * 0.98 {
|
||
return Err(DocError::Invalid("the face is too far from the thread's diameter".into()));
|
||
}
|
||
let k = t.axis;
|
||
let u = k.cross(if k.x.abs() < 0.9 { Vec3::new(1.0, 0.0, 0.0) } else { Vec3::new(0.0, 1.0, 0.0) });
|
||
let u = u.normalized().ok_or_else(|| DocError::Invalid("thread axis".into()))?;
|
||
// Past a free end of the face the groove runs out into the air; elsewhere it stops square
|
||
// at the threaded length's end.
|
||
let (s0, s1) = (if open.0 { t.start - p } else { t.start }, if open.1 { t.end + p } else { t.end });
|
||
let base = t.axis_point + k * s0;
|
||
let plane = Plane { origin: base, x: u, y: k };
|
||
let section = [Vec2::new(r0, -w0), Vec2::new(r1, -w1), Vec2::new(r1, w1), Vec2::new(r0, w0)];
|
||
// Sections 7.5° apart: the chords sag 0.2% of the radius.
|
||
Ok(kernel::sweep_helix_mesh(&plane, §ion, base, k, p, (s1 - s0) / p, 48)?)
|
||
}
|
||
|
||
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
|
||
/// large for the geometry around them is reduced to the largest that works for all of them, which
|
||
/// is returned.
|
||
fn fillet_fitted(
|
||
body: &kernel::Body,
|
||
edges: &[Vec3],
|
||
r: f64,
|
||
fit: crate::FilletFit,
|
||
) -> std::result::Result<(kernel::Body, Option<f64>), kernel::KernelError> {
|
||
match kernel::fillet(body, edges, r) {
|
||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max(body, edges, r) {
|
||
Some((used, b)) => Ok((b, Some(used))),
|
||
None => Err(kernel::KernelError::TooLarge(why)),
|
||
},
|
||
other => other.map(|b| (b, None)),
|
||
}
|
||
}
|
||
|
||
/// Add `note` to a feature's warning.
|
||
fn append_warning(warning: &mut Option<String>, note: String) {
|
||
*warning = Some(match warning.take() {
|
||
Some(w) => format!("{w}; {note}"),
|
||
None => note,
|
||
});
|
||
}
|
||
|
||
/// A length in mm, without trailing zeros.
|
||
fn show_mm(v: f64) -> String {
|
||
let s = format!("{v:.6}");
|
||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||
}
|
||
|
||
impl ModelState {
|
||
/// The radius fillet `feature` used when it reduced a radius too large for its edges.
|
||
pub fn fitted_radius(&self, feature: u64) -> Option<f64> {
|
||
self.fitted.get(&feature).copied()
|
||
}
|
||
}
|
||
|
||
/// Model after some prefix of the timeline.
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct ModelState {
|
||
/// Cosmetic threads (from Thread features and threaded holes).
|
||
pub threads: Vec<ThreadInfo>,
|
||
pub bodies: Vec<ModelBody>,
|
||
/// Shared between states: a feature that does not change a sketch keeps it as it was.
|
||
pub sketches: Vec<Arc<SolvedSketch>>,
|
||
pub body_counter: usize,
|
||
/// Sheet metal bodies (by body name) and their flat patterns.
|
||
pub sheets: Vec<crate::sheet::SheetBody>,
|
||
/// Construction planes, axes and points as evaluated (by feature).
|
||
pub construct: Vec<crate::construct::Construct>,
|
||
/// Evaluation-cache keys of the sketches (by sketch feature id).
|
||
sketch_keys: BTreeMap<u64, u64>,
|
||
/// The edges each fillet or chamfer blended, as found in this evaluation (by feature id):
|
||
/// a pattern of the feature repeats these, not the points picked when it was made, which a
|
||
/// parameter change may have moved off the edges.
|
||
blend_edges: BTreeMap<u64, Vec<Vec3>>,
|
||
/// The radius a fillet used when it reduced a radius too large for its edges (by feature id,
|
||
/// see `FilletFit::Reduce`).
|
||
fitted: BTreeMap<u64, f64>,
|
||
}
|
||
|
||
impl ModelState {
|
||
/// A digest of everything later features can read: bodies (by content identity), sketches,
|
||
/// the body counter, threads and sheet bodies.
|
||
fn digest(&self) -> u64 {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
for b in &self.bodies {
|
||
b.name.hash(&mut h);
|
||
b.key.hash(&mut h);
|
||
b.feature.hash(&mut h);
|
||
b.component.hash(&mut h);
|
||
b.transferred.hash(&mut h);
|
||
}
|
||
for s in &self.sketches {
|
||
s.feature.hash(&mut h);
|
||
self.sketch_keys.get(&s.feature).hash(&mut h);
|
||
}
|
||
self.body_counter.hash(&mut h);
|
||
for (feature, points) in &self.blend_edges {
|
||
feature.hash(&mut h);
|
||
for p in points {
|
||
[p.x, p.y, p.z].map(f64::to_bits).hash(&mut h);
|
||
}
|
||
}
|
||
for (feature, r) in &self.fitted {
|
||
feature.hash(&mut h);
|
||
r.to_bits().hash(&mut h);
|
||
}
|
||
serde_json::to_string(&self.threads).unwrap_or_default().hash(&mut h);
|
||
serde_json::to_string(&self.sheets).unwrap_or_default().hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
/// After a feature evaluated to this state from `before` with cache key `key`: bodies it
|
||
/// made or changed take that key, sketches it made a digest of their solved geometry (see
|
||
/// [`quantize`]); the rest keep theirs.
|
||
fn stamp(&mut self, before: &Arc<ModelState>, f: &Feature, key: u64, mats: Vec<Mat>) {
|
||
let feature = f.id;
|
||
let depth = before.bodies.iter().filter_map(|b| b.names.origin.as_ref().map(|o| o.depth)).max().unwrap_or(0) + 1;
|
||
let origin = Arc::new(crate::naming::NamingOrigin { feature: f.clone(), before: before.clone(), depth, mats });
|
||
// Pieces a cut left are named after their faces, not the kernel's shell order (unless
|
||
// the feature was saved when they were named in that order).
|
||
if !f.shell_piece_order {
|
||
crate::lineage::order_lumps(&mut self.bodies, &origin);
|
||
}
|
||
for b in &mut self.bodies {
|
||
if !matches!(f.kind, FeatureKind::BodyTransfer { .. })
|
||
&& let Some(old) = before.bodies.iter().find(|old| old.name == b.name && old.transferred)
|
||
{
|
||
b.transferred = true;
|
||
b.component = old.component;
|
||
b.source_transform = old.source_transform;
|
||
}
|
||
let kept = before.bodies.iter().find(|x| x.name == b.name && x.body.same(&b.body));
|
||
match kept {
|
||
Some(x) => {
|
||
b.key = x.key;
|
||
b.names = x.names.clone();
|
||
}
|
||
None => {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
(key, &b.name).hash(&mut h);
|
||
b.key = h.finish();
|
||
if !matches!(f.kind, FeatureKind::BodyTransfer { .. }) {
|
||
b.names = crate::naming::NameCell::traced(Some(origin.clone()), b.lineage());
|
||
}
|
||
// Faces split on purpose stay split through later features (for export).
|
||
if b.body.split_lines().is_none()
|
||
&& let Some(lines) = before.bodies.iter().find(|x| x.name == b.name).and_then(|x| x.body.split_lines())
|
||
{
|
||
b.body = b.body.clone().with_split_lines(Some(lines.clone()));
|
||
}
|
||
// An imported look outlives the operations on the body.
|
||
if b.body.paint().is_none()
|
||
&& b.body.color().is_none()
|
||
&& let Some(old) = before.bodies.iter().find(|x| x.name == b.name)
|
||
{
|
||
b.imported = crate::appearance::ImportedLook::of(old);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Later features read a sketch's solved geometry: they are keyed by it (to the
|
||
// quantum), so a value set back that solves to the same geometry reuses their results.
|
||
if let Some(s) = self.sketches.iter().find(|s| s.feature == feature) {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
serde_json::to_string(&s.plane).unwrap_or_default().hash(&mut h);
|
||
let mut sketch = s.sketch.clone();
|
||
quantize(&mut sketch);
|
||
serde_json::to_string(&sketch).unwrap_or_default().hash(&mut h);
|
||
s.report.ok().hash(&mut h);
|
||
self.sketch_keys.insert(feature, h.finish());
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Evaluation results by cache key, shared by a model and its copies (previews, scratch
|
||
/// sessions), so a result computed once is reused wherever the same inputs come back.
|
||
#[derive(Debug, Default)]
|
||
pub struct EvalCache {
|
||
map: std::collections::HashMap<u64, CachedEval>,
|
||
order: std::collections::VecDeque<u64>,
|
||
pub hits: usize,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct CachedEval {
|
||
error: Option<String>,
|
||
warning: Option<String>,
|
||
boolean_diagnostics: kernel::BooleanDiagnostics,
|
||
ms: f64,
|
||
state: Arc<ModelState>,
|
||
}
|
||
|
||
/// Results kept (oldest dropped first).
|
||
const CACHE_ENTRIES: usize = 4096;
|
||
|
||
impl EvalCache {
|
||
fn get(&mut self, key: u64) -> Option<CachedEval> {
|
||
let r = self.map.get(&key).cloned();
|
||
if r.is_some() {
|
||
self.hits += 1;
|
||
}
|
||
r
|
||
}
|
||
fn put(&mut self, key: u64, e: CachedEval) {
|
||
if self.map.insert(key, e).is_none() {
|
||
self.order.push_back(key);
|
||
}
|
||
while self.order.len() > CACHE_ENTRIES {
|
||
if let Some(k) = self.order.pop_front() {
|
||
self.map.remove(&k);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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).map(|s| &**s)
|
||
}
|
||
}
|
||
|
||
/// 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 boolean_diagnostics: kernel::BooleanDiagnostics,
|
||
pub ms: f64,
|
||
pub skipped: bool,
|
||
/// Not evaluated on the document's current inputs: a feature at or after a sketch shown live
|
||
/// ([`Model::show_live_sketch`]). It keeps its last result until the next `evaluate`, which
|
||
/// never reuses it.
|
||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||
pub stale: 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,
|
||
/// Features whose result the last `evaluate` took from the cache instead of recomputing: the
|
||
/// same feature on the same inputs was evaluated before (an edit set back, undo, redo, a
|
||
/// configuration row). Their geometry did change since the previous evaluation.
|
||
pub last_restored: usize,
|
||
/// Results by inputs, shared with this model's copies.
|
||
pub cache: Arc<std::sync::Mutex<EvalCache>>,
|
||
/// Evaluate everything from scratch (no reuse; tests compare the two).
|
||
pub no_cache: bool,
|
||
}
|
||
|
||
/// Parameters a feature uses directly (inputs and sketch dimensions), sorted.
|
||
fn param_names(f: &Feature) -> Vec<String> {
|
||
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();
|
||
names
|
||
}
|
||
|
||
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);
|
||
vals.get(DESIGN_LEN).map(|v| v.v.to_bits()).hash(&mut h);
|
||
feature_json(f).hash(&mut h);
|
||
for n in param_names(f) {
|
||
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()
|
||
}
|
||
|
||
/// Positions and radii are fingerprinted to this quantum (mm): far below anything a design
|
||
/// can show, far above the solver's rounding, so a sketch solved again to the same geometry
|
||
/// reads as the same input.
|
||
const QUANTUM: f64 = 1e-6;
|
||
|
||
/// Put what a solve writes into a sketch on the [`QUANTUM`] grid: positions, radii and the
|
||
/// measured values of driven dimensions.
|
||
fn quantize(sk: &mut solidcraft_sketch::Sketch) {
|
||
let q = |v: f64| (v / QUANTUM).round() * QUANTUM;
|
||
for p in &mut sk.points {
|
||
p.pos = Vec2::new(q(p.pos.x), q(p.pos.y));
|
||
}
|
||
for c in &mut sk.curves {
|
||
if let CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. } = &mut c.kind {
|
||
*r = q(*r);
|
||
}
|
||
}
|
||
for c in sk.constraints.iter_mut().filter(|c| c.driven) {
|
||
if let Some(v) = c.kind.value() {
|
||
c.kind.set_value(q(v));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A feature as fingerprinted: its definition, a sketch's stored solve quantized.
|
||
fn feature_json(f: &Feature) -> String {
|
||
if let FeatureKind::Sketch { .. } = &f.kind {
|
||
let mut g = f.clone();
|
||
if let FeatureKind::Sketch { sketch, .. } = &mut g.kind {
|
||
quantize(sketch);
|
||
}
|
||
return serde_json::to_string(&g).unwrap_or_default();
|
||
}
|
||
serde_json::to_string(f).unwrap_or_default()
|
||
}
|
||
|
||
/// Store in `stored` the solution `solved` holds for it: the positions, the circle and ellipse
|
||
/// radii, the arcs' end order and orientation (a signed sweep can swap them) and the
|
||
/// constraints' values. Nothing else: what evaluation derives for its own copy (an offset's
|
||
/// upgraded topology, refreshed links) stays out of the stored sketch. A `solved` that does not
|
||
/// match `stored` entity for entity is not adopted.
|
||
fn adopt_solution(stored: &mut solidcraft_sketch::Sketch, solved: &solidcraft_sketch::Sketch) {
|
||
if stored.points.len() != solved.points.len()
|
||
|| stored.curves.len() != solved.curves.len()
|
||
|| stored.constraints.len() != solved.constraints.len()
|
||
|| stored.constraints.iter().zip(&solved.constraints).any(|(a, b)| a.id != b.id)
|
||
{
|
||
return;
|
||
}
|
||
for (p, q) in stored.points.iter_mut().zip(&solved.points) {
|
||
p.pos = q.pos;
|
||
}
|
||
for (c, d) in stored.curves.iter_mut().zip(&solved.curves) {
|
||
match (&mut c.kind, &d.kind) {
|
||
(CurveKind::Circle { r, .. }, CurveKind::Circle { r: s, .. }) | (CurveKind::Ellipse { r, .. }, CurveKind::Ellipse { r: s, .. }) => {
|
||
*r = *s
|
||
}
|
||
(CurveKind::Arc { a, b, .. }, CurveKind::Arc { a: sa, b: sb, .. }) => {
|
||
(*a, *b) = (*sa, *sb);
|
||
c.reversed = d.reversed;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
for (c, d) in stored.constraints.iter_mut().zip(&solved.constraints) {
|
||
if let Some(v) = d.kind.value() {
|
||
c.kind.set_value(v);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The values features are evaluated with: the parameters, and the design's length unit.
|
||
fn evaluation_values(doc: &Document) -> (BTreeMap<String, Value>, BTreeMap<String, String>) {
|
||
let (mut vals, perr) = doc.param_values();
|
||
if let Some((Kind::Length, scale)) = expr::unit_info(doc.units.trim())
|
||
&& scale != 1.0
|
||
{
|
||
vals.insert(DESIGN_LEN.into(), Value { v: scale, len: 0, ang: 0 });
|
||
}
|
||
(vals, perr)
|
||
}
|
||
|
||
/// A feature's fingerprint in the chain after `prev`: its own inputs, where it is placed, and
|
||
/// the sheet metal or plastic rules it takes defaults from.
|
||
fn chain_fingerprint(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, prev: u64, rolled_back: bool) -> u64 {
|
||
let mut fp = fingerprint(prev, f, vals, rolled_back) ^ placement_digest(doc, f);
|
||
// Sheet metal features also depend on the rules (and the parameters they use).
|
||
if f.kind.is_sheet() {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
fp.hash(&mut h);
|
||
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
|
||
for r in &doc.sheet.rules {
|
||
for e in [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap] {
|
||
for n in expr::references(e) {
|
||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||
}
|
||
}
|
||
}
|
||
fp = h.finish();
|
||
}
|
||
// Plastic features take defaults from the plastic rules.
|
||
if f.kind.is_plastic() && !doc.plastic.is_empty() {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
fp.hash(&mut h);
|
||
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
|
||
for r in &doc.plastic.rules {
|
||
for (_, e, _) in r.fields() {
|
||
for n in expr::references(e) {
|
||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||
}
|
||
}
|
||
}
|
||
fp = h.finish();
|
||
}
|
||
fp
|
||
}
|
||
|
||
/// Document-level inputs any feature may read: construction planes (resolved by name), sheet
|
||
/// metal and plastic rules (and the parameters they use), components and body placement.
|
||
fn context_digest(doc: &Document, vals: &BTreeMap<String, Value>) -> u64 {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
for f in doc.features.iter().filter(|f| {
|
||
matches!(f.kind, FeatureKind::ConstructionPlane { .. } | FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })
|
||
}) {
|
||
fingerprint(0, f, vals, false).hash(&mut h);
|
||
}
|
||
doc.units.hash(&mut h);
|
||
serde_json::to_string(&doc.sheet).unwrap_or_default().hash(&mut h);
|
||
serde_json::to_string(&doc.plastic).unwrap_or_default().hash(&mut h);
|
||
serde_json::to_string(&doc.components).unwrap_or_default().hash(&mut h);
|
||
serde_json::to_string(&doc.body_components).unwrap_or_default().hash(&mut h);
|
||
for e in doc
|
||
.sheet
|
||
.rules
|
||
.iter()
|
||
.flat_map(|r| [&r.thickness, &r.k_factor, &r.bend_radius, &r.relief_width, &r.relief_depth, &r.corner_relief, &r.hem_gap, &r.gap])
|
||
{
|
||
for n in expr::references(e) {
|
||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||
}
|
||
}
|
||
for r in &doc.plastic.rules {
|
||
for (_, e, _) in r.fields() {
|
||
for n in expr::references(e) {
|
||
vals.get(&n).map(|v| v.v.to_bits()).hash(&mut h);
|
||
}
|
||
}
|
||
}
|
||
h.finish()
|
||
}
|
||
|
||
/// The cache key of a feature evaluation: its own inputs, the document context and the state it
|
||
/// starts from. Features that replay other features' definitions (patterns and mirrors of
|
||
/// features) also take the chain fingerprint, which covers every earlier definition.
|
||
fn cache_key(f: &Feature, chain_fp: u64, local: u64, ctx: u64, state: &ModelState) -> u64 {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
local.hash(&mut h);
|
||
ctx.hash(&mut h);
|
||
state.digest().hash(&mut h);
|
||
f.id.hash(&mut h);
|
||
let replays = match &f.kind {
|
||
FeatureKind::Pattern { features, .. } | FeatureKind::Mirror { features, .. } => !features.is_empty(),
|
||
_ => false,
|
||
};
|
||
if replays {
|
||
chain_fp.hash(&mut h);
|
||
}
|
||
h.finish()
|
||
}
|
||
|
||
/// Where components are placed, for a sketch that sees other components' geometry (on a face,
|
||
/// or with projections): it is resolved in its own component's view of them.
|
||
fn placement_digest(doc: &Document, f: &Feature) -> u64 {
|
||
let path = match &f.kind {
|
||
FeatureKind::Sweep { path_sketch, .. }
|
||
| FeatureKind::Pipe { path_sketch, .. }
|
||
| FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch),
|
||
_ => None,
|
||
};
|
||
if let Some(path) = path
|
||
&& doc.sketch(path).is_ok_and(|sketch| !sketch.wires.is_empty())
|
||
{
|
||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||
let owner = doc.feature(path).map(|feature| feature.component).unwrap_or(0);
|
||
for frame in [doc.component_transform(f.component), doc.component_transform(owner)] {
|
||
frame.iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||
}
|
||
if let FeatureKind::Sweep { sketch, .. } = &f.kind {
|
||
let owner = doc.feature(*sketch).map(|feature| feature.component).unwrap_or(0);
|
||
doc.component_transform(owner).iter().flatten().for_each(|value| value.to_bits().hash(&mut hash));
|
||
}
|
||
return hash.finish();
|
||
}
|
||
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return 0 };
|
||
let anchor = crate::face_anchor::plane_face_anchor(plane);
|
||
if (doc.occurrences.is_empty() && (anchor.is_none() || doc.body_offsets.is_empty()))
|
||
|| (!matches!(plane, PlaneRef::Face { .. }) && anchor.is_none() && sketch.links.is_empty())
|
||
{
|
||
return 0;
|
||
}
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
for o in &doc.occurrences {
|
||
o.id.hash(&mut h);
|
||
o.transform.iter().flatten().for_each(|x| x.to_bits().hash(&mut h));
|
||
}
|
||
serde_json::to_string(&doc.body_offsets).unwrap_or_default().hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
fn now() -> web_time::Instant {
|
||
web_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)
|
||
}
|
||
|
||
/// Show a sketch's new solution before the model is evaluated again (the frames of an
|
||
/// interactive drag): `sketch`, already solved with `report`, on the plane it was last
|
||
/// evaluated on. The sketch's own result and the final state show it, and the sketch and
|
||
/// every feature after it are marked stale, keeping their last geometry. Nothing else is
|
||
/// recomputed; the next [`Model::evaluate`] recomputes from the sketch on. Returns `false`,
|
||
/// changing nothing, when the sketch has no evaluated result or a feature before it is
|
||
/// already stale (only a full evaluation brings that up to date).
|
||
pub fn show_live_sketch(&mut self, feature: u64, sketch: solidcraft_sketch::Sketch, report: SolveReport) -> bool {
|
||
let Some(i) = self.results.iter().position(|r| r.id == feature) else { return false };
|
||
if self.results.iter().take(i).any(|r| r.stale) {
|
||
return false;
|
||
}
|
||
let Some(last) = self.results.get(i).filter(|r| !r.skipped && r.error.is_none()).and_then(|r| r.state.sketch(feature)) else {
|
||
return false;
|
||
};
|
||
let solved = Arc::new(SolvedSketch { feature, name: last.name.clone(), plane: last.plane, profiles: find_profiles(&sketch), sketch, report });
|
||
let shown = self.results.iter().rposition(|r| !r.skipped);
|
||
for (k, r) in self.results.iter_mut().enumerate().skip(i) {
|
||
r.stale = true;
|
||
// The sketch's own state and the final one show it: only that entry changes (the
|
||
// states are copied on the first frame; later frames own them and swap in place).
|
||
if (k == i || Some(k) == shown)
|
||
&& let Some(s) = Arc::make_mut(&mut r.state).sketches.iter_mut().find(|s| s.feature == feature)
|
||
{
|
||
*s = solved.clone();
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Some results are stale ([`Model::show_live_sketch`]): the model needs an `evaluate`.
|
||
pub fn is_stale(&self) -> bool {
|
||
self.results.iter().any(|r| r.stale)
|
||
}
|
||
|
||
/// Store in `doc` the sketches whose last evaluation solved them for new driving values
|
||
/// (see `Document::solve_sketch`), so the stored sketch is the solved state the next value
|
||
/// edit continues from (edits depend on their history, as a user editing dimensions one
|
||
/// after another expects) and later evaluations repeat no continuation. Their results
|
||
/// stand for the updated document: the solved sketch is what evaluating it gives.
|
||
/// Returns how many sketches were stored.
|
||
pub fn adopt_solved_sketches(&mut self, doc: &mut Document) -> usize {
|
||
let solved: Vec<SolvedSketch> = self.edited_sketches(doc).cloned().collect();
|
||
let Some(first) = solved.iter().filter_map(|s| doc.features.iter().position(|f| f.id == s.feature)).min() else { return 0 };
|
||
for s in &solved {
|
||
if let Ok(stored) = doc.sketch_mut(s.feature) {
|
||
adopt_solution(stored, &s.sketch);
|
||
}
|
||
}
|
||
// The fingerprints from the first stored sketch on.
|
||
let vals = evaluation_values(doc).0;
|
||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||
let mut prev_fp = first.checked_sub(1).and_then(|i| self.results.get(i)).map_or(0, |r| r.fingerprint);
|
||
for (i, f) in doc.features.iter().enumerate().skip(first) {
|
||
prev_fp = chain_fingerprint(doc, &vals, f, prev_fp, i >= marker);
|
||
if let Some(r) = self.results.get_mut(i).filter(|r| r.id == f.id) {
|
||
r.fingerprint = prev_fp;
|
||
}
|
||
}
|
||
solved.len()
|
||
}
|
||
|
||
/// The sketches the last evaluation solved for driving values other than the ones `doc`
|
||
/// stores them with (a value edit since they were stored).
|
||
pub fn edited_sketches<'a>(&'a self, doc: &'a Document) -> impl Iterator<Item = &'a SolvedSketch> + 'a {
|
||
self.results.iter().filter(|r| !r.skipped && r.error.is_none()).filter_map(|r| r.state.sketch(r.id)).filter(|s| {
|
||
s.report.keeps_orientation() && doc.sketch(s.feature).is_ok_and(|stored| stored.driving_values() != s.sketch.driving_values())
|
||
})
|
||
}
|
||
|
||
/// Re-evaluate the document, reusing results up to the first changed feature.
|
||
pub fn evaluate(&mut self, doc: &Document) {
|
||
let (vals, perr) = evaluation_values(doc);
|
||
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 = !self.no_cache;
|
||
let mut recomputed = 0;
|
||
let mut restored = 0;
|
||
let marker = doc.marker.unwrap_or(usize::MAX);
|
||
let ctx = context_digest(doc, &vals);
|
||
for (i, f) in doc.features.iter().enumerate() {
|
||
let rolled_back = i >= marker;
|
||
let fp = chain_fingerprint(doc, &vals, f, prev_fp, rolled_back);
|
||
prev_fp = fp;
|
||
if reuse
|
||
&& let Some(old) = self.results.get(i)
|
||
&& old.id == f.id
|
||
&& old.fingerprint == fp
|
||
&& !old.stale
|
||
{
|
||
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,
|
||
boolean_diagnostics: Default::default(),
|
||
ms: 0.0,
|
||
skipped: true,
|
||
stale: false,
|
||
fingerprint: fp,
|
||
state: state.clone(),
|
||
});
|
||
continue;
|
||
}
|
||
// The same feature on the same inputs: its earlier result.
|
||
let key = cache_key(f, fp, fingerprint(0, f, &vals, false) ^ placement_digest(doc, f), ctx, &state);
|
||
if !self.no_cache
|
||
&& let Some(c) = self.cache.lock().ok().and_then(|mut m| m.get(key))
|
||
{
|
||
restored += 1;
|
||
if c.error.is_none() {
|
||
state = c.state.clone();
|
||
}
|
||
out.push(FeatureResult {
|
||
id: f.id,
|
||
name: f.name.clone(),
|
||
error: c.error,
|
||
warning: c.warning,
|
||
boolean_diagnostics: c.boolean_diagnostics,
|
||
ms: c.ms,
|
||
skipped: false,
|
||
stale: false,
|
||
fingerprint: fp,
|
||
state: state.clone(),
|
||
});
|
||
continue;
|
||
}
|
||
recomputed += 1;
|
||
let t0 = now();
|
||
let mut next = (*state).clone();
|
||
for b in &mut next.bodies {
|
||
b.made = crate::lineage::Made::Before;
|
||
}
|
||
let mut warning = None;
|
||
// Face references by name: move each face point onto its named face.
|
||
let renamed;
|
||
// Cylindrical emboss resolves its named face in the selected body's frame
|
||
// and certifies an analytic anchor; mesh projection here would undo that.
|
||
let explicit_emboss = matches!(&f.kind, FeatureKind::Emboss { face: Some(_), .. });
|
||
let f = if !explicit_emboss && f.face_names.iter().any(|n| !n.is_empty()) {
|
||
let mut g = f.clone();
|
||
let mut notes = Vec::new();
|
||
// A hole's position is a place on its face, not a pick of the face: it follows
|
||
// the face along the hole's axis and never slides sideways to the face's nearest
|
||
// point when the face no longer reaches it.
|
||
let hole_axis = match &f.kind {
|
||
FeatureKind::Hole { direction, points: None, .. } => direction.normalized(),
|
||
_ => None,
|
||
};
|
||
for (k, (p, n)) in g.kind.face_points_mut().into_iter().zip(&f.face_names).enumerate() {
|
||
if n.is_empty() {
|
||
continue;
|
||
}
|
||
match crate::naming::locate_face(&state, n, *p) {
|
||
Some(hit) => {
|
||
if let Some(note) = hit.note(n) {
|
||
notes.push(note);
|
||
}
|
||
match (k, hole_axis) {
|
||
(0, Some(axis)) => match hit.along(&state, *p, axis) {
|
||
Some(q) => *p = q,
|
||
// The place still lies on the face (the axis may run along it).
|
||
None if hit.on_point => {}
|
||
None => notes.push(format!(
|
||
"the hole's position [{}, {}, {}] is no longer on face `{n}`: it stays where it was placed",
|
||
p.x, p.y, p.z
|
||
)),
|
||
},
|
||
_ => *p = hit.point,
|
||
}
|
||
}
|
||
None => notes.push(format!("face `{n}` no longer exists; using the face at the picked point")),
|
||
}
|
||
}
|
||
if !notes.is_empty() {
|
||
warning = Some(notes.join("; "));
|
||
}
|
||
renamed = g;
|
||
&renamed
|
||
} else {
|
||
f
|
||
};
|
||
// A parameter with an error fails the features using it, with that error.
|
||
let bad_param = param_names(f).into_iter().find_map(|n| self.param_errors.get(&n).map(|e| format!("parameter `{n}`: {e}")));
|
||
let (r, mut boolean_diagnostics) = kernel::capture_boolean_diagnostics(|| match bad_param {
|
||
Some(e) => Err(kernel::KernelError::Failed(e)),
|
||
None => kernel::guard(&f.name, || {
|
||
eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string()))
|
||
}),
|
||
});
|
||
if r.is_ok() {
|
||
if let Some(note) = boolean_diagnostics.warning() {
|
||
append_warning(&mut warning, note);
|
||
}
|
||
} else {
|
||
boolean_diagnostics = Default::default();
|
||
}
|
||
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
||
let error = match r {
|
||
Ok(()) => {
|
||
// Copies are named after their sources through the instance transforms.
|
||
let mats = match &f.kind {
|
||
FeatureKind::Pattern { pattern, .. } => pattern_transforms(doc, &vals, &state, f.component, pattern).unwrap_or_default(),
|
||
FeatureKind::Mirror { plane, .. } => {
|
||
doc.resolve_plane_in(&vals, plane, 0, Some(&state)).map(|pl| vec![mirror_matrix(&pl)]).unwrap_or_default()
|
||
}
|
||
_ => Vec::new(),
|
||
};
|
||
next.stamp(&state, f, key, mats);
|
||
for b in &mut next.bodies {
|
||
if !b.transferred {
|
||
b.component = doc.legacy_body_component(&b.name, b.feature);
|
||
}
|
||
}
|
||
state = Arc::new(next);
|
||
None
|
||
}
|
||
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
|
||
};
|
||
if !self.no_cache
|
||
&& let Ok(mut m) = self.cache.lock()
|
||
{
|
||
m.put(
|
||
key,
|
||
CachedEval {
|
||
error: error.clone(),
|
||
warning: warning.clone(),
|
||
boolean_diagnostics: boolean_diagnostics.clone(),
|
||
ms,
|
||
state: state.clone(),
|
||
},
|
||
);
|
||
}
|
||
out.push(FeatureResult {
|
||
id: f.id,
|
||
name: f.name.clone(),
|
||
error,
|
||
warning,
|
||
boolean_diagnostics,
|
||
ms,
|
||
skipped: false,
|
||
stale: false,
|
||
fingerprint: fp,
|
||
state: state.clone(),
|
||
});
|
||
}
|
||
self.results = out;
|
||
self.last_recomputed = recomputed;
|
||
self.last_restored = restored;
|
||
}
|
||
}
|
||
|
||
/// Key of the design's length unit (mm per unit) among the values (no parameter can be named
|
||
/// so).
|
||
pub(crate) const DESIGN_LEN: &str = "\u{1}design_len";
|
||
|
||
/// An expression's value. A length that comes out unit-less without being a bare number (a
|
||
/// unit-less parameter used as a length) is in the design's units.
|
||
/// A point given as length expressions.
|
||
fn point(vals: &BTreeMap<String, Value>, p: &crate::PointExpr) -> Result<Vec3> {
|
||
let [x, y, z] = p;
|
||
let v = Vec3::new(val(vals, x, Kind::Length)?, val(vals, y, Kind::Length)?, val(vals, z, Kind::Length)?);
|
||
if [v.x, v.y, v.z].iter().all(|c| c.is_finite() && c.abs() < 1e9) { Ok(v) } else { Err(DocError::Invalid("the position is out of range".into())) }
|
||
}
|
||
|
||
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
|
||
let scale = vals.get(DESIGN_LEN).map(|x| x.v).unwrap_or(1.0);
|
||
if k == Kind::Length && scale != 1.0 && !expr::is_literal(e) {
|
||
let v = expr::eval_with(e, &|n| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`"))))?;
|
||
return v.to_kind_in(k, expr::Defaults { len: scale, ..expr::Defaults::default() });
|
||
}
|
||
Document::eval_in(vals, e, k)
|
||
}
|
||
|
||
pub 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 {
|
||
// "All" means what was drawn: projected reference geometry (a face's auto-projected
|
||
// edges) only counts when the drawn curves alone make no profile. Text is drawn, and
|
||
// its letters keep their counters as holes (the counters are not filled in).
|
||
ProfileSel::All => {
|
||
let drawn = solidcraft_sketch::find_drawn_profiles(&ss.sketch);
|
||
let chosen = if drawn.is_empty() { ps.clone() } else { drawn };
|
||
solidcraft_sketch::without_text_counters(&ss.sketch, chosen).into_iter().map(|p| p.region).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 { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
|
||
crate::profile_selection_indices(ss, sel)?
|
||
.into_iter()
|
||
.map(|index| {
|
||
ps.get(index)
|
||
.map(|profile| profile.region.clone())
|
||
.ok_or_else(|| DocError::Invalid("profile index changed during resolution".into()))
|
||
})
|
||
.collect::<Result<Vec<_>>>()?
|
||
}
|
||
ProfileSel::Points { points } => {
|
||
let mut v = Vec::new();
|
||
for p in points {
|
||
// Innermost profile containing the point.
|
||
let found = solidcraft_sketch::profile_at_point(ps, *p).and_then(|i| ps.get(i));
|
||
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)
|
||
}
|
||
|
||
/// The error for a profile picked by curves that no longer names exactly one profile.
|
||
pub fn curve_pick_error(e: solidcraft_sketch::CurvePick, ids: &[String], sketch: &str) -> DocError {
|
||
match e {
|
||
solidcraft_sketch::CurvePick::Missing => DocError::Invalid(format!(
|
||
"no profile of {sketch} is bounded by exactly the curves {ids:?} (a curve was deleted, or another curve now cuts the region)"
|
||
)),
|
||
solidcraft_sketch::CurvePick::Ambiguous(n) => DocError::Invalid(format!(
|
||
"{n} profiles of {sketch} are bounded by exactly the curves {ids:?}; add a curve that tells them apart, or pick by index or point"
|
||
)),
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
// Bodies past the stored names (a profile added after the feature was made) are numbered
|
||
// after the first name: `Ribs (4)` (#373). Features saved before format 14 keep `BodyN`,
|
||
// which later features of those designs may name.
|
||
if !f.shell_piece_order
|
||
&& k >= f.body_names.len()
|
||
&& let Some(base) = f.body_names.first().map(|n| n.trim()).filter(|n| !n.is_empty())
|
||
{
|
||
let n = format!("{base} ({})", k.saturating_add(1));
|
||
return if state.body(&n).is_none() { n } else { unique_body_name(state, base) };
|
||
}
|
||
loop {
|
||
state.body_counter += 1;
|
||
let n = format!("Body{}", state.body_counter);
|
||
if state.body(&n).is_none() {
|
||
return n;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn copy_body_name(state: &ModelState, f: &Feature, k: usize, source: &str) -> String {
|
||
let base = f.body_names.get(k).filter(|name| !name.trim().is_empty()).map(String::as_str).unwrap_or(source);
|
||
unique_body_name(state, base)
|
||
}
|
||
|
||
/// Add tool bodies to the model according to the operation.
|
||
fn sheet_boolean_unchanged(body: &Body, tools: &[Body], op: Operation) -> Result<()> {
|
||
if sheet_boolean_changes(body, tools, op)? {
|
||
return Err(DocError::Invalid("this sheet machining cannot be preserved in its flat material: use a parallel, untapered extrude pocket/cut, or unfold before intersecting; unsupported 3D sheet machining is refused to prevent material restoration".into()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn sheet_boolean_changes(body: &Body, tools: &[Body], op: Operation) -> Result<bool> {
|
||
let united;
|
||
let tools: &[Body] = if op == Operation::Intersect {
|
||
united = intersection_tool_union(tools.iter().cloned().map(Traced::unnamed).collect())?.into_iter().map(|t| t.body).collect::<Vec<_>>();
|
||
&united
|
||
} else {
|
||
tools
|
||
};
|
||
for tool in tools {
|
||
let test = if op == Operation::Cut { BoolOp::Intersect } else { BoolOp::Cut };
|
||
if kernel::boolean(body, tool, test)?.is_some() {
|
||
return Ok(true);
|
||
}
|
||
}
|
||
Ok(false)
|
||
}
|
||
|
||
/// Selected profiles form one machining tool, including disconnected tool material.
|
||
/// Balanced unions avoid repeatedly rebuilding a growing aggregate boundary.
|
||
fn intersection_tool_union(tools: Vec<Traced>) -> Result<Vec<Traced>> {
|
||
if tools.len() > 1000 || tools.iter().fold(0usize, |n, t| n.saturating_add(t.body.face_count())) > 16_384 {
|
||
return Err(DocError::Invalid("intersection tools exceed the bounded geometry budget".into()));
|
||
}
|
||
let mut groups = tools;
|
||
while groups.len() > 1 {
|
||
let mut next = Vec::new();
|
||
let mut iter = groups.into_iter();
|
||
while let Some(a) = iter.next() {
|
||
match iter.next() {
|
||
Some(b) => {
|
||
let u = kernel::boolean(&a.body, &b.body, BoolOp::Union)?
|
||
.ok_or_else(|| DocError::Invalid("intersection tool union is empty".into()))?;
|
||
next.push(Traced::step(u, &[&a, &b]));
|
||
}
|
||
None => next.push(a),
|
||
}
|
||
}
|
||
groups = next;
|
||
}
|
||
Ok(groups)
|
||
}
|
||
|
||
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
|
||
apply_traced(state, f, tools.into_iter().map(Traced::unnamed).collect(), op, targets)
|
||
}
|
||
|
||
/// Many cut tools clear of each other (a pattern's copies, the circles of a vent field in one
|
||
/// sketch) as one tool of many pieces, so the target is cut once instead of rebuilding an ever
|
||
/// more complex body for every tool (#261, #368). Tools that may meet are left as they are.
|
||
fn clear_tools_as_one(tools: Vec<Traced>) -> Vec<Traced> {
|
||
if tools.len() < 8 {
|
||
return tools;
|
||
}
|
||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||
match kernel::union_apart(&bodies) {
|
||
// Each piece's faces keep the names of the tool it is.
|
||
Ok(Some(one)) => vec![Traced::step(one, &tools.iter().collect::<Vec<_>>())],
|
||
_ => tools,
|
||
}
|
||
}
|
||
|
||
/// [`apply_op`] with tools whose lineage is known: the bodies it makes or changes keep theirs.
|
||
fn apply_traced(state: &mut ModelState, f: &Feature, tools: Vec<Traced>, op: Operation, targets: &[String]) -> Result<()> {
|
||
let tools = if op == Operation::Intersect { intersection_tool_union(tools)? } else { tools };
|
||
// Participants: the feature's own targets, else its explicit participants, else the bodies
|
||
// of its component (and what it made itself): other components are left alone.
|
||
let targets: &[String] = if targets.is_empty() { &f.participants } else { targets };
|
||
let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*");
|
||
let target_idx: Vec<usize> = if all {
|
||
(0..state.bodies.len()).collect()
|
||
} else if targets.is_empty() {
|
||
(0..state.bodies.len()).filter(|i| state.bodies.get(*i).is_some_and(|b| b.component == f.component || b.feature == f.id)).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, t) in tools.into_iter().enumerate() {
|
||
let name = new_name(state, f, k);
|
||
state.bodies.push(ModelBody::traced_new(name, t, f.id));
|
||
}
|
||
return Ok(());
|
||
}
|
||
if matches!(op, Operation::Cut | Operation::Intersect) {
|
||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||
for &i in &target_idx {
|
||
if let Some(mb) = state.bodies.get(i).filter(|b| state.sheets.iter().any(|s| s.body == b.name)) {
|
||
sheet_boolean_unchanged(&mb.body, &bodies, op)?;
|
||
}
|
||
}
|
||
}
|
||
match op {
|
||
Operation::Join => {
|
||
// Each tool joins every target it meets into one body, kept under the first one's
|
||
// name; a tool that meets none joins the first target as a separate lump. A sheet
|
||
// metal body keeps its own identity: only the first body met may be one.
|
||
let mut live: Vec<String> = target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
|
||
// Each union keeps the lineage of what it joins, so the joined body's faces keep
|
||
// their names (the tool's new faces are named by their roles).
|
||
let union = |a: &Traced, b: &Traced| -> Result<Traced> {
|
||
let u = kernel::boolean(&a.body, &b.body, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()))?;
|
||
Ok(Traced::step(u, &[a, b]))
|
||
};
|
||
for tool in &tools {
|
||
let mut met: Vec<String> = Vec::new();
|
||
for name in &live {
|
||
let Some(mb) = state.body(name) else { continue };
|
||
let sheet = state.sheets.iter().any(|s| &s.body == name);
|
||
if mb.body.is_mesh() || (!met.is_empty() && sheet) {
|
||
continue;
|
||
}
|
||
if mb.body.meets(&tool.body)? {
|
||
met.push(name.clone());
|
||
}
|
||
}
|
||
let Some(keep) = met.first().or(live.first()).cloned() else { return Ok(()) };
|
||
let Some(base) = state.body(&keep).map(ModelBody::traced) else { continue };
|
||
let mut acc = union(&base, tool)?;
|
||
for name in met.iter().skip(1) {
|
||
if let Some(b) = state.body(name) {
|
||
acc = union(&acc, &b.traced())?;
|
||
}
|
||
}
|
||
let absorbed: std::collections::BTreeSet<String> = met.into_iter().skip(1).collect();
|
||
if let Some(mb) = state.bodies.iter_mut().find(|b| b.name == keep) {
|
||
*mb = ModelBody::traced_new(mb.name.clone(), acc, mb.feature);
|
||
}
|
||
state.bodies.retain(|b| !absorbed.contains(&b.name));
|
||
for t in state.threads.iter_mut().filter(|t| absorbed.contains(&t.body)) {
|
||
t.body = keep.clone();
|
||
}
|
||
live.retain(|n| !absorbed.contains(n));
|
||
}
|
||
}
|
||
Operation::Cut | Operation::Intersect => {
|
||
let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect };
|
||
let tools = if op == Operation::Cut { clear_tools_as_one(tools) } else { tools };
|
||
let affected: std::collections::BTreeSet<String> =
|
||
target_idx.iter().filter_map(|i| state.bodies.get(*i).map(|b| b.name.clone())).collect();
|
||
let mut remove = Vec::new();
|
||
for &ti in &target_idx {
|
||
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.traced())) else { continue };
|
||
for t in &tools {
|
||
acc = match acc {
|
||
Some(a) => kernel::boolean(&a.body, &t.body, bop)?.map(|r| Traced::step(r, &[&a, t])),
|
||
None => None,
|
||
};
|
||
}
|
||
match acc {
|
||
Some(b) => {
|
||
if let Some(mb) = state.bodies.get_mut(ti) {
|
||
*mb = ModelBody::traced_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
|
||
});
|
||
let live: std::collections::BTreeSet<&str> = state.bodies.iter().map(|b| b.name.as_str()).collect();
|
||
state.threads.retain(|t| live.contains(t.body.as_str()));
|
||
// A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"),
|
||
// numbered on past names already taken (a body cut in pieces before).
|
||
let mut taken: std::collections::BTreeSet<String> = state.bodies.iter().map(|b| b.name.clone()).collect();
|
||
let mut split: Vec<ModelBody> = Vec::new();
|
||
for mb in std::mem::take(&mut state.bodies) {
|
||
if !affected.contains(&mb.name) || state.sheets.iter().any(|s| s.body == mb.name) {
|
||
split.push(mb);
|
||
continue;
|
||
}
|
||
let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]);
|
||
if pieces.len() < 2 {
|
||
split.push(mb);
|
||
continue;
|
||
}
|
||
// Each piece's faces are named after the whole's, worked out once for the whole
|
||
// (so pieces of one face in different bodies are told apart).
|
||
let lineages = crate::lineage::Lineage::lumps(&mb.body, mb.lineage().as_deref(), &pieces);
|
||
for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() {
|
||
let name = if k == 0 {
|
||
mb.name.clone()
|
||
} else {
|
||
(k..=k + taken.len()).map(|j| format!("{} ({j})", mb.name)).find(|n| !taken.contains(n)).unwrap_or_default()
|
||
};
|
||
taken.insert(name.clone());
|
||
split.push(ModelBody::new(name, p, mb.feature).with_lineage(lineage));
|
||
}
|
||
}
|
||
state.bodies = split;
|
||
}
|
||
Operation::NewBody => {}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Distance that reaches through every target body from the sketch plane (either side).
|
||
pub fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result<f64> {
|
||
let mut far: f64 = 0.0;
|
||
let mut any = false;
|
||
for b in st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)) {
|
||
let bb = b.mesh().bounds();
|
||
if bb.is_empty() {
|
||
continue;
|
||
}
|
||
any = true;
|
||
for i in 0..8 {
|
||
let p = Vec3::new(
|
||
if i & 1 == 0 { bb.min.x } else { bb.max.x },
|
||
if i & 2 == 0 { bb.min.y } else { bb.max.y },
|
||
if i & 4 == 0 { bb.min.z } else { bb.max.z },
|
||
);
|
||
far = far.max(plane.height(p).abs());
|
||
}
|
||
}
|
||
if !any {
|
||
return Err(DocError::Invalid("through all needs a body to cut".into()));
|
||
}
|
||
Ok(far * 1.02 + 1.0)
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
|
||
/// Grow profile edges that lie on a face of a target body away from the profile, by a small
|
||
/// amount, when the space they grow into is already material (join) or empty (cut). `probe`
|
||
/// maps a sketch point to the world points where the tool would be (along the extrude, around
|
||
/// the revolve). Only loops of straight edges are adjusted.
|
||
fn grow_profile_for_coplanar(st: &ModelState, r: &Region2, op: Operation, targets: &[String], probe: &dyn Fn(Vec2) -> Vec<Vec3>) -> Region2 {
|
||
if !matches!(op, Operation::Cut | Operation::Join) {
|
||
return r.clone();
|
||
}
|
||
let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect();
|
||
if bodies.is_empty() {
|
||
return r.clone();
|
||
}
|
||
let meshes: Vec<Arc<Mesh>> = bodies.iter().map(|b| b.mesh()).collect();
|
||
let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max);
|
||
let delta = (size * 0.005).max(1e-3);
|
||
let lp = r.outer.ccw();
|
||
let lines: Option<Vec<(Vec2, Vec2)>> =
|
||
lp.segs.iter().map(|s| if let solidcraft_geom::Seg2::Line { a, b } = *s { Some((a, b)) } else { None }).collect();
|
||
let Some(lines) = lines else { return r.clone() };
|
||
let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p));
|
||
let mut grow = vec![0.0; lines.len()];
|
||
let mut any = false;
|
||
for (i, (a, b)) in lines.iter().enumerate() {
|
||
let Some(dir) = (*b - *a).normalized() else { continue };
|
||
let out = Vec2::new(dir.y, -dir.x);
|
||
// Is the edge on a body face? Material on exactly one side of it.
|
||
let mids = [a.lerp(*b, 0.25), a.lerp(*b, 0.5), a.lerp(*b, 0.75)];
|
||
let side = |k: f64| mids.iter().flat_map(|m| probe(*m + out * (delta * k))).map(inside_any).collect::<Vec<bool>>();
|
||
let (outer, inner) = (side(0.5), side(-0.5));
|
||
let want_outer = op == Operation::Join;
|
||
let on_face = outer.iter().all(|x| *x == want_outer) && inner.iter().any(|x| *x != want_outer);
|
||
let room = side(1.0).iter().all(|x| *x == want_outer);
|
||
if on_face && room {
|
||
if let Some(g) = grow.get_mut(i) {
|
||
*g = delta;
|
||
}
|
||
any = true;
|
||
}
|
||
}
|
||
if !any {
|
||
return r.clone();
|
||
}
|
||
// Offset the chosen lines and re-intersect neighbours.
|
||
let n = lines.len();
|
||
let shifted: Vec<(Vec2, Vec2)> = lines
|
||
.iter()
|
||
.zip(&grow)
|
||
.map(|((a, b), g)| {
|
||
let dir = (*b - *a).normalized().unwrap_or(Vec2::X);
|
||
let out = Vec2::new(dir.y, -dir.x);
|
||
(*a + out * *g, dir)
|
||
})
|
||
.collect();
|
||
let mut pts = Vec::with_capacity(n);
|
||
for i in 0..n {
|
||
let (Some(&(p0, d0)), Some(&(p1, d1))) = (shifted.get((i + n - 1) % n), shifted.get(i)) else { return r.clone() };
|
||
let den = d0.cross(d1);
|
||
if den.abs() < 1e-12 {
|
||
pts.push(p1);
|
||
continue;
|
||
}
|
||
let t = (p1 - p0).cross(d1) / den;
|
||
pts.push(p0 + d0 * t);
|
||
}
|
||
Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: r.holes.clone() }
|
||
}
|
||
|
||
/// 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 face-based feature applies to: by name, or the one whose surface is nearest the
|
||
/// first point.
|
||
fn body_at(st: &ModelState, body: &Option<String>, points: &[Vec3]) -> Result<usize> {
|
||
if let Some(n) = body {
|
||
return st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")));
|
||
}
|
||
let p = points.first().ok_or_else(|| DocError::Invalid("no faces selected".into()))?;
|
||
st.bodies
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, b)| {
|
||
let bb = b.mesh().bounds();
|
||
let q = Vec3::new(p.x.clamp(bb.min.x, bb.max.x), p.y.clamp(bb.min.y, bb.max.y), p.z.clamp(bb.min.z, bb.max.z));
|
||
(i, q.dist(*p))
|
||
})
|
||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||
.map(|(i, _)| i)
|
||
.ok_or_else(|| DocError::Invalid("there is no body".into()))
|
||
}
|
||
|
||
/// A hole stays on its face when the model changes: a position now inside a body moves back
|
||
/// against the drilling direction to the surface, one now above it moves down onto it.
|
||
fn hole_on_surface(st: &ModelState, p: Vec3, dir: Vec3) -> Vec3 {
|
||
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
|
||
let eps = size * 1e-6;
|
||
let mut best: Option<Vec3> = None;
|
||
for b in &st.bodies {
|
||
let m = b.mesh();
|
||
// Already on the surface: the drill enters right here.
|
||
if let Some((t, _)) = m.raycast(p - dir * eps, dir)
|
||
&& t < eps * 4.0
|
||
{
|
||
return p;
|
||
}
|
||
let q = if m.contains(p) { m.raycast(p, -dir).map(|(t, _)| p - dir * t) } else { m.raycast(p, dir).map(|(t, _)| p + dir * t) };
|
||
if let Some(q) = q
|
||
&& best.is_none_or(|bq| q.dist(p) < bq.dist(p))
|
||
{
|
||
best = Some(q);
|
||
}
|
||
}
|
||
best.unwrap_or(p)
|
||
}
|
||
|
||
/// Where a picked edge sat in its body: its direction (zero for curved edges) and the body's
|
||
/// bounding box at the time.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, serde::Deserialize)]
|
||
pub struct EdgeRef {
|
||
pub dir: Vec3,
|
||
pub min: Vec3,
|
||
pub max: Vec3,
|
||
}
|
||
|
||
fn edge_dir(e: &kernel::EdgeInfo) -> Vec3 {
|
||
let (Some(a), Some(b)) = (e.points.first(), e.points.last()) else { return Vec3::ZERO };
|
||
let straight = e.points.iter().all(|p| p.dist_to_segment(*a, *b) < 1e-6 * (1.0 + e.length));
|
||
if straight && a.dist(*b) > 1e-9 { (*b - *a).normalized().unwrap_or(Vec3::ZERO) } else { Vec3::ZERO }
|
||
}
|
||
|
||
/// References for edges picked at `pts` on the body a blend would go on (the state before it).
|
||
pub fn edge_refs(st: &ModelState, body: &Option<String>, pts: &[Vec3]) -> Vec<EdgeRef> {
|
||
let Ok(ti) = blend_target(st, body, pts) else { return Vec::new() };
|
||
let Some(mb) = st.bodies.get(ti) else { return Vec::new() };
|
||
let size = mb.body.size();
|
||
let Ok(edges) = mb.body.edges((size * 1e-3).max(1e-3)) else { return Vec::new() };
|
||
let bb = mb.mesh().bounds();
|
||
pts.iter()
|
||
.map(|p| {
|
||
let e = edges.iter().min_by(|x, y| {
|
||
let d = |e: &kernel::EdgeInfo| e.points.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||
d(x).total_cmp(&d(y))
|
||
});
|
||
EdgeRef { dir: e.map(edge_dir).unwrap_or(Vec3::ZERO), min: bb.min, max: bb.max }
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Persistent names of the edges picked at `pts` on the body a blend would go on.
|
||
pub fn edge_names_for(st: &ModelState, body: &Option<String>, pts: &[Vec3]) -> Vec<String> {
|
||
match blend_target(st, body, pts).ok().and_then(|i| st.bodies.get(i)) {
|
||
Some(b) => crate::naming::edge_names_at(b, pts),
|
||
None => Vec::new(),
|
||
}
|
||
}
|
||
|
||
/// Edge reference points re-found on the body: points on an edge stay; a point that moved off
|
||
/// (an upstream edit) is carried with the body (its place in the body's bounding box then and
|
||
/// now) to an edge running the same way; failing that it goes to the nearest edge with a
|
||
/// warning; points with no edge near are dropped with a warning.
|
||
fn resolve_edges(b: &Body, pts: &[Vec3], refs: &[EdgeRef], warning: &mut Option<String>) -> Result<Vec<Vec3>> {
|
||
let size = b.size();
|
||
let edges = b.edges((size * 1e-3).max(1e-3))?;
|
||
let tight = (size * 2e-3).max(1e-3);
|
||
let dist = |p: Vec3, e: &kernel::EdgeInfo| e.points.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min);
|
||
let now = b.tessellate((size * 1e-2).max(1e-2)).map(|m| m.bounds()).ok();
|
||
let (mut out, mut moved, mut lost) = (Vec::new(), 0, 0);
|
||
for (i, p) in pts.iter().enumerate() {
|
||
let on = edges.iter().any(|e| dist(*p, e) <= tight);
|
||
if !on && let (Some(r), Some(nb)) = (refs.get(i), now.as_ref()) {
|
||
// The same place in the body as it is now.
|
||
let map = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| {
|
||
if (a1 - a0).abs() > 1e-9 { b0 + (v - a0) * (b1 - b0) / (a1 - a0) } else { v + (b0 - a0) }
|
||
};
|
||
let q = Vec3::new(
|
||
map(p.x, r.min.x, r.max.x, nb.min.x, nb.max.x),
|
||
map(p.y, r.min.y, r.max.y, nb.min.y, nb.max.y),
|
||
map(p.z, r.min.z, r.max.z, nb.min.z, nb.max.z),
|
||
);
|
||
let fits = |e: &kernel::EdgeInfo| {
|
||
let d = edge_dir(e);
|
||
dist(q, e) <= tight && (r.dir.len() < 0.5 || d.cross(r.dir).len() < 1e-3)
|
||
};
|
||
if edges.iter().any(fits) {
|
||
out.push(q);
|
||
continue;
|
||
}
|
||
}
|
||
let best = edges
|
||
.iter()
|
||
.map(|e| (e.points.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min), e.mid))
|
||
.min_by(|a, b| a.0.total_cmp(&b.0));
|
||
match best {
|
||
Some((d, _)) if d <= tight => out.push(*p),
|
||
Some((d, mid)) if d <= size * 0.25 => {
|
||
out.push(mid);
|
||
moved += 1;
|
||
}
|
||
_ => lost += 1,
|
||
}
|
||
}
|
||
if out.is_empty() {
|
||
return Err(DocError::Invalid("none of the selected edges exist any more".into()));
|
||
}
|
||
if moved + lost > 0 {
|
||
let mut w = Vec::new();
|
||
if moved > 0 {
|
||
w.push(format!("{moved} edge reference(s) re-found on the nearest edge"));
|
||
}
|
||
if lost > 0 {
|
||
w.push(format!("{lost} edge reference(s) no longer found and skipped"));
|
||
}
|
||
*warning = Some(w.join("; "));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// 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(|| {
|
||
if state.bodies.iter().any(|b| b.body.is_mesh()) {
|
||
DocError::Invalid("there is no solid body to modify (mesh bodies have no edges)".into())
|
||
} else {
|
||
DocError::Invalid("there is no body to modify".into())
|
||
}
|
||
})
|
||
}
|
||
|
||
pub(crate) 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))))
|
||
}
|
||
AxisRef::Line { origin, dir } => {
|
||
let pl = &ss.plane;
|
||
let d = dir.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
|
||
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(*origin).abs() > 1e-6 {
|
||
return Err(DocError::Invalid("the revolve axis does not lie in the sketch plane".into()));
|
||
}
|
||
Ok((pl.to_local(*origin), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Where a hole feature drills (each on the surface) and in which direction: at its sketch
|
||
/// points perpendicular to the sketch (into the material), else at its position.
|
||
fn hole_spots(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
|
||
let FeatureKind::Hole { position, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
|
||
let (spots, dir) = match points {
|
||
Some(sp) => {
|
||
let ss = st.sketch(sp.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sp.sketch)))?;
|
||
let mut v = Vec::new();
|
||
for id in &sp.ids {
|
||
let p = ss.sketch.points.iter().find(|p| &p.id == id).ok_or_else(|| DocError::Unknown(format!("sketch point `{id}`")))?;
|
||
v.push(ss.plane.to_world(p.pos));
|
||
}
|
||
(v, -ss.plane.normal())
|
||
}
|
||
None => (vec![*position], direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?),
|
||
};
|
||
if spots.is_empty() || spots.len() > 10_000 {
|
||
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
|
||
}
|
||
Ok((spots.into_iter().map(|p| hole_on_surface(st, p, dir)).collect(), dir))
|
||
}
|
||
|
||
/// A hole's depth: down to the To Object (where the hole's axis meets the face there, else the
|
||
/// point's depth along the axis), else as given.
|
||
fn hole_depth_to(st: &ModelState, at: &Vec3, dir: Vec3, to: &Option<Vec3>, depth: &Option<String>) -> Result<Option<String>> {
|
||
let Some(t) = to else { return Ok(depth.clone()) };
|
||
let mut d = (*t - *at).dot(dir);
|
||
// The face at the To point, met along the axis.
|
||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
|
||
let Some(fi) = crate::appearance::face_index_at(b, *t) else { continue };
|
||
let m = b.mesh();
|
||
if let Some((h, tri)) = m.raycast(*at + dir * 1e-6, dir)
|
||
&& m.tri_face.get(tri).is_some_and(|x| *x as usize == fi)
|
||
{
|
||
d = h + 1e-6;
|
||
}
|
||
break;
|
||
}
|
||
if !(d > 1e-6) {
|
||
return Err(DocError::Invalid("the hole's To object is not ahead of the hole".into()));
|
||
}
|
||
Ok(Some(format!("{d} mm")))
|
||
}
|
||
|
||
/// A hole's spots as given (not yet moved onto the surface) and its direction.
|
||
fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
|
||
let FeatureKind::Hole { position, direction, points, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
|
||
match points {
|
||
Some(sp) => {
|
||
let ss = st.sketch(sp.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", sp.sketch)))?;
|
||
let v: Vec<Vec3> =
|
||
sp.ids.iter().filter_map(|id| ss.sketch.points.iter().find(|p| &p.id == id)).map(|p| ss.plane.to_world(p.pos)).take(10_000).collect();
|
||
Ok((v, -ss.plane.normal()))
|
||
}
|
||
None => Ok((vec![*position], direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?)),
|
||
}
|
||
}
|
||
|
||
/// The cutting tools of one hole at `position`, drilling along `dir`.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn hole_tools(
|
||
vals: &BTreeMap<String, Value>,
|
||
st: &ModelState,
|
||
position: &Vec3,
|
||
dir: Vec3,
|
||
diameter: &str,
|
||
depth: &Option<String>,
|
||
hole: &HoleKind,
|
||
) -> Result<Vec<Body>> {
|
||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||
if !(r > 1e-6) {
|
||
return Err(DocError::Invalid("hole diameter must be positive".into()));
|
||
}
|
||
let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
|
||
let top = (size * 0.01).max(0.1);
|
||
let bottom = match depth {
|
||
Some(d) => -val(vals, d, Kind::Length)?,
|
||
None => -(size * 2.0 + 1.0),
|
||
};
|
||
if bottom >= 0.0 {
|
||
return Err(DocError::Invalid("hole depth must be positive".into()));
|
||
}
|
||
// Tools are cylinders and cones made by (tapered) extrudes along the axis, cut one
|
||
// after another, widest first.
|
||
let plane = Plane::from_normal(*position, -dir).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
|
||
// Each tool's circle seam at its own angle, so seams don't line up between tools.
|
||
let seam = std::cell::Cell::new(0.0);
|
||
let disc = |rad: f64| {
|
||
seam.set(seam.get() + 0.613);
|
||
Region2 { outer: solidcraft_geom::Loop2::circle_from(Vec2::ZERO, rad, seam.get()), holes: vec![] }
|
||
};
|
||
let cyl = |rad: f64, lo: f64, hi: f64| -> Result<Body> {
|
||
kernel::extrude(&plane, &[disc(rad)], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into()))
|
||
};
|
||
let mut tools = Vec::new();
|
||
match hole {
|
||
HoleKind::Simple => tools.push(cyl(r, bottom, top)?),
|
||
HoleKind::Drilled { tip_angle } => {
|
||
let half = val(vals, tip_angle, Kind::Angle)? / 2.0;
|
||
// A hole exactly as deep as the material: the point is in the air past the exit
|
||
// face, which would lie on the drill point's shoulder (a through hole, as drilled).
|
||
let at = |h: f64| *position + dir * (-h);
|
||
let eps = size * 1e-6;
|
||
let solid_at = |h: f64| st.bodies.iter().any(|b| b.body.contains(at(h)));
|
||
let exits_at_shoulder = solid_at(bottom + eps) && !solid_at(bottom - eps);
|
||
if depth.is_some() && half > 1e-3 && half < std::f64::consts::FRAC_PI_2 && !exits_at_shoulder {
|
||
// One revolved tool (cylinder with a drill point).
|
||
let tip = r / half.tan();
|
||
let up = -dir;
|
||
let side = up.any_perp();
|
||
let rp = Plane::new(*position, side, up).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
|
||
let pts = [Vec2::new(0.0, top), Vec2::new(r, top), Vec2::new(r, bottom), Vec2::new(0.0, bottom - tip)];
|
||
let region = Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: vec![] };
|
||
tools.extend(kernel::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?);
|
||
} else {
|
||
tools.push(cyl(r, bottom, top)?);
|
||
}
|
||
}
|
||
HoleKind::Counterbore { cb_diameter, cb_depth } => {
|
||
let (rc, dc) = (val(vals, cb_diameter, Kind::Length)? / 2.0, val(vals, cb_depth, Kind::Length)?);
|
||
if !(rc > r && dc > 0.0 && -dc > bottom) {
|
||
return Err(DocError::Invalid("the counterbore must be wider than the hole and shallower than it".into()));
|
||
}
|
||
// Wide and shallow first: the narrow hole then crosses the pocket floor.
|
||
tools.push(cyl(rc, -dc, top)?);
|
||
tools.push(cyl(r, bottom, top)?);
|
||
}
|
||
HoleKind::Countersink { cs_diameter, cs_angle } => {
|
||
let (rc, half) = (val(vals, cs_diameter, Kind::Length)? / 2.0, val(vals, cs_angle, Kind::Angle)? / 2.0);
|
||
if !(rc > r && half > 1e-3 && half < std::f64::consts::FRAC_PI_2) {
|
||
return Err(DocError::Invalid("the countersink must be wider than the hole".into()));
|
||
}
|
||
// Cone from above the face to just inside the hole radius, then the hole.
|
||
let r_top = rc + top * half.tan();
|
||
let r_end = r * 0.9;
|
||
let len = (r_top - r_end) / half.tan();
|
||
let top_plane = plane.offset(top);
|
||
tools.push(kernel::extrude_tapered(&top_plane, &disc(r_top), len, -1.0, -half)?);
|
||
tools.push(cyl(r, bottom, top)?);
|
||
}
|
||
}
|
||
Ok(tools)
|
||
}
|
||
|
||
/// The angular interval used by both the solid and its interactive extent handles.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct RevolveExtent {
|
||
pub start: f64,
|
||
pub sweep: f64,
|
||
pub rotation_axis: Vec3,
|
||
pub display_start: f64,
|
||
/// A curved target trims the swept solid and has no single angular boundary.
|
||
pub curved_to: bool,
|
||
}
|
||
|
||
pub fn revolve_extent(
|
||
st: &ModelState,
|
||
plane: &Plane,
|
||
regions: &[Region2],
|
||
(origin, axis): (Vec3, Vec3),
|
||
(angle, angle2): (f64, Option<f64>),
|
||
symmetric: bool,
|
||
to: Option<Vec3>,
|
||
) -> Result<RevolveExtent> {
|
||
revolve_extent_with_convention(st, plane, regions, (origin, axis), (angle, angle2), symmetric, to, crate::RevolveConvention::LegacyAxisStart)
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
pub fn revolve_extent_with_convention(
|
||
st: &ModelState,
|
||
plane: &Plane,
|
||
regions: &[Region2],
|
||
(origin, axis): (Vec3, Vec3),
|
||
(angle, angle2): (f64, Option<f64>),
|
||
symmetric: bool,
|
||
to: Option<Vec3>,
|
||
convention: crate::RevolveConvention,
|
||
) -> Result<RevolveExtent> {
|
||
let (start, sweep) = match (to, angle2, symmetric) {
|
||
(Some(point), _, _) => {
|
||
let points: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||
let centre = if convention == crate::RevolveConvention::SketchNormal {
|
||
regions.first().map(|r| plane.to_world(r.centroid())).ok_or_else(|| DocError::Invalid("no profile selected".into()))?
|
||
} else {
|
||
plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64))
|
||
};
|
||
let projection_axis = if convention == crate::RevolveConvention::SketchNormal {
|
||
axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?
|
||
} else {
|
||
axis
|
||
};
|
||
let flat = |point: Vec3| {
|
||
let v = point - origin;
|
||
v - projection_axis * v.dot(projection_axis)
|
||
};
|
||
let (u, v) = (flat(centre), flat(point));
|
||
let angle_axis = if convention == crate::RevolveConvention::SketchNormal {
|
||
if v.len() < 1e-9 {
|
||
return Err(DocError::Invalid("the revolve target lies on the axis; pick a point away from the axis".into()));
|
||
}
|
||
kernel::canonical_revolve_axis(
|
||
plane,
|
||
regions.first().ok_or_else(|| DocError::Invalid("no profile selected".into()))?,
|
||
plane.to_local(origin),
|
||
Vec2::new(projection_axis.dot(plane.x), projection_axis.dot(plane.y)),
|
||
)?
|
||
} else {
|
||
-axis
|
||
};
|
||
let sweep = angle_axis.dot(u.cross(v)).atan2(u.dot(v));
|
||
(0.0, if sweep <= 1e-9 { sweep + std::f64::consts::TAU } else { sweep })
|
||
}
|
||
(None, Some(other), _) => (-other, angle + other),
|
||
(None, None, true) => (-angle / 2.0, angle),
|
||
(None, None, false) => (0.0, angle),
|
||
};
|
||
if !(sweep.is_finite() && start.is_finite() && sweep.abs() > 1e-9 && sweep.abs() <= std::f64::consts::TAU + 1e-9) {
|
||
return Err(DocError::Invalid("the revolve angle must be more than 0 and at most 360°".into()));
|
||
}
|
||
let points: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||
let centre = plane.to_world(points.iter().fold(Vec2::default(), |sum, point| sum + *point) * (1.0 / points.len().max(1) as f64));
|
||
let unit_axis = axis.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
|
||
let radial_centre = regions.first().map(|region| plane.to_world(region.centroid())).unwrap_or(centre);
|
||
let radial = radial_centre - origin - unit_axis * (radial_centre - origin).dot(unit_axis);
|
||
let rotation_axis = match (convention, regions.first()) {
|
||
(crate::RevolveConvention::SketchNormal, Some(region)) => {
|
||
kernel::canonical_revolve_axis(plane, region, plane.to_local(origin), Vec2::new(unit_axis.dot(plane.x), unit_axis.dot(plane.y)))?
|
||
}
|
||
_ => {
|
||
if unit_axis.cross(radial).dot(plane.normal()) < 0.0 {
|
||
-unit_axis
|
||
} else {
|
||
unit_axis
|
||
}
|
||
}
|
||
};
|
||
let display_start = match convention {
|
||
crate::RevolveConvention::LegacyAxisStart => start * -unit_axis.dot(rotation_axis),
|
||
crate::RevolveConvention::SketchNormal => start,
|
||
};
|
||
Ok(RevolveExtent { start, sweep, rotation_axis, display_start, curved_to: to.is_some_and(|point| curved_target_any(st, point).is_some()) })
|
||
}
|
||
|
||
/// The exact input frame and scalar interval of a current, untrimmed constructor.
|
||
struct ExtrudeConstructor {
|
||
plane: Plane,
|
||
interval: (f64, f64),
|
||
}
|
||
|
||
/// Construct planar caps locally before placing the completed tool.
|
||
fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketch: Option<&SolvedSketch>) -> Result<Vec<Traced>> {
|
||
if !plane.origin.is_finite()
|
||
|| !plane.x.is_finite()
|
||
|| !plane.y.is_finite()
|
||
|| (plane.x.len2() - 1.0).abs() > 1e-8
|
||
|| (plane.y.len2() - 1.0).abs() > 1e-8
|
||
|| plane.x.dot(plane.y).abs() > 1e-8
|
||
{
|
||
return Err(DocError::Invalid("extrude frame is not finite and orthonormal".into()));
|
||
}
|
||
// The faces' roles: the sketch curve under each side, the caps by where they lie.
|
||
let (lo, hi) = if start <= end { (start, end) } else { (end, start) };
|
||
let roles = crate::lineage::ProfileRoles {
|
||
sketch,
|
||
region: Some(region),
|
||
caps: [Some(crate::lineage::extrude_cap_role(lo)), Some(crate::lineage::extrude_cap_role(hi))],
|
||
};
|
||
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
|
||
// wire can tilt an otherwise exact planar cap through numeric cancellation.
|
||
let bodies = kernel::extrude(&Plane::XY, std::slice::from_ref(region), start, end)?;
|
||
if *plane == Plane::XY {
|
||
return Ok(bodies.into_iter().map(|body| roles.tool(body, None)).collect());
|
||
}
|
||
let n = plane.normal();
|
||
let m = [
|
||
[plane.x.x, plane.x.y, plane.x.z, 0.0],
|
||
[plane.y.x, plane.y.y, plane.y.z, 0.0],
|
||
[n.x, n.y, n.z, 0.0],
|
||
[plane.origin.x, plane.origin.y, plane.origin.z, 1.0],
|
||
];
|
||
bodies
|
||
.into_iter()
|
||
.map(|body| {
|
||
let moved = kernel::transform_matrix(&body, m)?;
|
||
Ok(roles.tool(moved, Some(&body)))
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
|
||
fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
|
||
Ok(feature_tools_with_constructor(doc, vals, f, st, &mut None)?.into_iter().map(|t| t.body).collect())
|
||
}
|
||
|
||
fn feature_tools_with_constructor(
|
||
doc: &Document,
|
||
vals: &BTreeMap<String, Value>,
|
||
f: &Feature,
|
||
st: &ModelState,
|
||
constructor: &mut Option<ExtrudeConstructor>,
|
||
) -> Result<Vec<Traced>> {
|
||
let unnamed = |bodies: Vec<Body>| -> Vec<Traced> { bodies.into_iter().map(Traced::unnamed).collect() };
|
||
match &f.kind {
|
||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
||
let ss = sketch_in_feature_frame(
|
||
doc,
|
||
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||
f.component,
|
||
)?;
|
||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||
// From / To Object: where the face or point lies along the normal, measured over the
|
||
// middle of the profiles.
|
||
let n = ss.plane.normal();
|
||
let centre = {
|
||
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||
let c = pts.iter().fold(Vec2::default(), |a, p| a + *p) * (1.0 / pts.len().max(1) as f64);
|
||
ss.plane.to_world(c)
|
||
};
|
||
let from = match extent.from {
|
||
Some(p) => object_height(st, &ss.plane, centre, p),
|
||
None => 0.0,
|
||
};
|
||
let d = match extent.to {
|
||
Some(p) => {
|
||
let off = match &extent.to_offset {
|
||
Some(e) => val(vals, e, Kind::Length)?,
|
||
None => 0.0,
|
||
};
|
||
let h = object_height(st, &ss.plane, centre, p);
|
||
let start = from + extent.start_offset.as_deref().map(|e| val(vals, e, Kind::Length)).transpose()?.unwrap_or(0.0);
|
||
// Past the face by the offset, in the direction travelled.
|
||
let to = h + if h >= start { off } else { -off };
|
||
if (to - start).abs() < 1e-9 {
|
||
return Err(DocError::Invalid("the To object is where the extrude starts".into()));
|
||
}
|
||
to - start
|
||
}
|
||
None if extent.through_all => through_all_distance(st, &ss.plane, targets)?,
|
||
None => val(vals, &extent.distance, Kind::Length)?,
|
||
};
|
||
if let Some(tp) = &extent.taper {
|
||
let taper = val(vals, tp, Kind::Angle)?;
|
||
if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric {
|
||
return Err(DocError::Invalid("not supported yet: taper with two-sided, symmetric or offset extents".into()));
|
||
}
|
||
let sign = if extent.to.is_none() && extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
|
||
let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) };
|
||
let start_plane = ss.plane.offset(from);
|
||
let caps = [
|
||
Some(crate::lineage::extrude_cap_role(from)),
|
||
Some(crate::lineage::extrude_cap_role(if sign < 0.0 { from - len } else { from + len })),
|
||
];
|
||
return regions
|
||
.iter()
|
||
.map(|r| {
|
||
let body = kernel::extrude_tapered(&start_plane, r, len, sign, taper)?;
|
||
Ok(crate::lineage::ProfileRoles { sketch: Some(&ss), region: Some(r), caps: caps.clone() }.tool(body, None))
|
||
})
|
||
.collect();
|
||
}
|
||
let off = from
|
||
+ match &extent.start_offset {
|
||
Some(e) => val(vals, e, Kind::Length)?,
|
||
None => 0.0,
|
||
};
|
||
// To Object gives a signed distance from the start: one side only.
|
||
let direction = if extent.to.is_some() { crate::Direction::Positive } else { extent.direction };
|
||
let (a, b) = match (&extent.distance2, 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 (mut lo, mut hi) = if a <= b { (a, b) } else { (b, a) };
|
||
// To a curved (or slanted) face: run right through the target body, then keep what
|
||
// lies before its surface (the tool less the body, the piece at the start).
|
||
let curved = extent.to.and_then(|p| curved_target(st, n, p));
|
||
if let Some(bi) = curved
|
||
&& let Some(tb) = st.bodies.get(bi)
|
||
{
|
||
let m = tb.mesh();
|
||
let hs: Vec<f64> = m.positions.iter().map(|q| (*q - ss.plane.origin).dot(n) - off).collect();
|
||
let margin = tb.body.size() * 0.05 + 1.0;
|
||
if hi > 0.0 {
|
||
hi = hs.iter().cloned().fold(hi, f64::max) + margin;
|
||
} else {
|
||
lo = hs.iter().cloned().fold(lo, f64::min) - margin;
|
||
}
|
||
}
|
||
let mut tools = Vec::new();
|
||
let mut untrimmed = extent.to.is_none() && extent.from.is_none() && !extent.through_all;
|
||
for r in ®ions {
|
||
if *operation == Operation::Join {
|
||
let exact = centered_extrude(&ss.plane, r, lo + off, hi + off, Some(&ss))?;
|
||
if exact.iter().all(|tool| {
|
||
st.bodies
|
||
.iter()
|
||
.filter(|b| targets.is_empty() || targets.contains(&b.name))
|
||
.any(|b| kernel::can_join_touching_faces(&b.body, &tool.body))
|
||
}) {
|
||
// Preserve a whole touching face: extending into a chamfer replaces
|
||
// the sewable seam with a coincident curved intersection.
|
||
tools.extend(exact);
|
||
continue;
|
||
}
|
||
}
|
||
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
|
||
let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::<Vec<_>>();
|
||
let original = r;
|
||
let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
|
||
untrimmed &= l2 == lo + off && h2 == hi + off && &r == original;
|
||
tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?);
|
||
}
|
||
if let Some(tb) = curved.and_then(|bi| st.bodies.get(bi)) {
|
||
let start = if hi > 0.0 { lo + off } else { hi + off };
|
||
tools = trim_to_body(tools, tb, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
|
||
}
|
||
if untrimmed {
|
||
*constructor = Some(ExtrudeConstructor { plane: ss.plane, interval: (lo + off, hi + off) });
|
||
}
|
||
// A Join tool's faces outlive the boolean where they stay on the result, so they are
|
||
// certified as a new body's are (see `Body::with_preserved_union_planar_sources`).
|
||
if matches!(operation, Operation::NewBody | Operation::Join) && extent.taper.is_none() {
|
||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &bodies, 10_000_000);
|
||
let source_curves = cap_bindings.as_ref().map(|_| crate::config::ExactSketchCurves::new(&ss));
|
||
tools = tools
|
||
.into_iter()
|
||
.enumerate()
|
||
.map(|(body_index, Traced { body, lineage })| {
|
||
let curves: Vec<_> = body
|
||
.face_sources()
|
||
.into_iter()
|
||
.flatten()
|
||
.map(|source| match source {
|
||
Some(kernel::FaceSource::ExtrudeSide { segment, .. }) if cap_bindings.is_some() => {
|
||
source_curves.as_ref().and_then(|curves| curves.find(segment))
|
||
}
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let mut caps =
|
||
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
|
||
// A cap moved off its designed height (into a Join target, or past a
|
||
// curved To face) is not the feature's start or end face.
|
||
let nominal = |plane: &solidcraft_geom::Plane| {
|
||
let height = (plane.origin - ss.plane.origin).dot(n);
|
||
let tolerance = 1e-9 * (1.0 + height.abs() + (lo + off).abs() + (hi + off).abs());
|
||
(height - (lo + off)).abs() <= tolerance || (height - (hi + off)).abs() <= tolerance
|
||
};
|
||
for (cap, source) in caps.iter_mut().zip(body.face_sources().into_iter().flatten()) {
|
||
if matches!(source, Some(kernel::FaceSource::ExtrudeCap { plane, .. }) if !nominal(plane)) {
|
||
*cap = None;
|
||
}
|
||
}
|
||
// Certificates set face sources only: the faces, and their lineage, stay.
|
||
let body = body.with_extrude_certificates(f.id, *sketch, &curves)?.with_extrude_cap_certificates(f.id, *sketch, &caps)?;
|
||
Ok(Traced { body, lineage })
|
||
})
|
||
.collect::<Result<Vec<_>>>()?;
|
||
}
|
||
Ok(tools)
|
||
}
|
||
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => {
|
||
let ss = sketch_in_feature_frame(
|
||
doc,
|
||
st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?,
|
||
f.component,
|
||
)?;
|
||
let regions = solidcraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||
let (o, d) = revolve_axis(&ss, axis)?;
|
||
let angles = if to.is_some() {
|
||
(0.0, None)
|
||
} else {
|
||
(val(vals, angle, Kind::Angle)?, angle2.as_ref().map(|a| val(vals, a, Kind::Angle)).transpose()?)
|
||
};
|
||
let extent = revolve_extent_with_convention(
|
||
st,
|
||
&ss.plane,
|
||
®ions,
|
||
(ss.plane.to_world(o), ss.plane.dir_to_world(d)),
|
||
angles,
|
||
*symmetric,
|
||
*to,
|
||
*convention,
|
||
)?;
|
||
let (start, ang) = (extent.start, extent.sweep);
|
||
let region_extents = regions
|
||
.iter()
|
||
.map(|r| {
|
||
if *convention == crate::RevolveConvention::SketchNormal && to.is_some() {
|
||
revolve_extent_with_convention(
|
||
st,
|
||
&ss.plane,
|
||
std::slice::from_ref(r),
|
||
(ss.plane.to_world(o), ss.plane.dir_to_world(d)),
|
||
angles,
|
||
*symmetric,
|
||
*to,
|
||
*convention,
|
||
)
|
||
} else {
|
||
Ok(extent)
|
||
}
|
||
})
|
||
.collect::<Result<Vec<_>>>()?;
|
||
let (operation, targets) = feature_op(f);
|
||
let axis_w = ss.plane.dir_to_world(d);
|
||
let origin_w = ss.plane.to_world(o);
|
||
let regions: Vec<Region2> = regions
|
||
.iter()
|
||
.zip(®ion_extents)
|
||
.map(|(r, region_extent)| {
|
||
let rotation_axis = match convention {
|
||
crate::RevolveConvention::LegacyAxisStart => axis_w,
|
||
crate::RevolveConvention::SketchNormal => kernel::canonical_revolve_axis(&ss.plane, r, o, d)?,
|
||
};
|
||
let probe = |p: Vec2| {
|
||
let q = ss.plane.to_world(p);
|
||
(1..4)
|
||
.map(|k| {
|
||
let a = region_extent.sweep * k as f64 / 4.0
|
||
+ if *convention == crate::RevolveConvention::SketchNormal { region_extent.start } else { 0.0 };
|
||
let v = q - origin_w;
|
||
origin_w + v * a.cos() + rotation_axis.cross(v) * a.sin() + rotation_axis * (rotation_axis.dot(v) * (1.0 - a.cos()))
|
||
})
|
||
.collect::<Vec<_>>()
|
||
};
|
||
Ok(grow_profile_for_coplanar(st, r, operation, &targets, &probe))
|
||
})
|
||
.collect::<Result<Vec<_>>>()?;
|
||
// To a curved face: turn past the whole target body, then cut back to its surface
|
||
// (the piece that starts at the profile).
|
||
let curved = to.and_then(|p| curved_target_any(st, p));
|
||
let curved_body = curved.and_then(|bi| st.bodies.get(bi));
|
||
// Each region's body: its sides named by the sketch curves, its caps start and end.
|
||
let tool = |r: &Region2, body: Body, built: Option<&Body>| crate::lineage::ProfileRoles::swept(Some(&ss), Some(r)).tool(body, built);
|
||
let build_curved = |regions: &[Region2], angle_axis: Vec3, tb: &ModelBody| -> Result<Vec<Traced>> {
|
||
let pts: Vec<Vec2> = regions.iter().flat_map(|r| r.outer.polyline(1e-2)).collect();
|
||
let c = ss.plane.to_world(pts.iter().fold(Vec2::default(), |a, q| a + *q) * (1.0 / pts.len().max(1) as f64));
|
||
let flat = |q: Vec3| {
|
||
let v = q - origin_w;
|
||
v - axis_w * v.dot(axis_w)
|
||
};
|
||
let u0 = flat(c);
|
||
let angle_of = |q: Vec3| {
|
||
let v = flat(q);
|
||
let a = angle_axis.dot(u0.cross(v)).atan2(u0.dot(v));
|
||
if a < 0.0 { a + std::f64::consts::TAU } else { a }
|
||
};
|
||
let far = tb.mesh().positions.iter().map(|q| angle_of(*q)).fold(0.0, f64::max);
|
||
let sweep = (far + 0.05).min(std::f64::consts::TAU - 1e-3);
|
||
let bodies = kernel::revolve(&ss.plane, regions, o, d, sweep)?;
|
||
let tools = regions.iter().zip(bodies).map(|(r, body)| tool(r, body, None)).collect();
|
||
let n = ss.plane.normal();
|
||
let tol_side = |q: Vec3| if flat(q).dot(u0) > 0.0 { ((q - ss.plane.origin).dot(n)).abs() } else { f64::MAX };
|
||
trim_to_body(tools, tb, tol_side)
|
||
};
|
||
if *convention == crate::RevolveConvention::LegacyAxisStart
|
||
&& let Some(tb) = curved_body
|
||
{
|
||
return build_curved(®ions, -axis_w, tb);
|
||
}
|
||
if *convention == crate::RevolveConvention::SketchNormal {
|
||
let mut out = Vec::new();
|
||
for (region, region_extent) in regions.iter().zip(®ion_extents) {
|
||
let rotation_axis = kernel::canonical_revolve_axis(&ss.plane, region, o, d)?;
|
||
if let Some(tb) = curved_body {
|
||
out.extend(build_curved(std::slice::from_ref(region), rotation_axis, tb)?);
|
||
continue;
|
||
}
|
||
let bodies = kernel::revolve(&ss.plane, std::slice::from_ref(region), o, d, region_extent.sweep)?;
|
||
let m = crate::rigid(Vec3::ZERO, origin_w, rotation_axis, region_extent.start);
|
||
for body in bodies {
|
||
out.push(if region_extent.start.abs() < 1e-12 {
|
||
tool(region, body, None)
|
||
} else {
|
||
tool(region, kernel::transform_matrix(&body, m)?, Some(&body))
|
||
});
|
||
}
|
||
}
|
||
return Ok(out);
|
||
}
|
||
let bodies = kernel::revolve(&ss.plane, ®ions, o, d, ang)?;
|
||
if start.abs() < 1e-12 {
|
||
return Ok(regions.iter().zip(bodies).map(|(r, body)| tool(r, body, None)).collect());
|
||
}
|
||
// Turned back to start before the profile.
|
||
let m = crate::rigid(Vec3::ZERO, origin_w, axis_w, -start);
|
||
regions.iter().zip(bodies).map(|(r, body)| Ok(tool(r, kernel::transform_matrix(&body, m)?, Some(&body)))).collect()
|
||
}
|
||
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.point.is_some()) => {
|
||
// A profile lofted to a sketch point (an apex).
|
||
let [a, b] = §ions[..] else { return Err(DocError::Invalid("not supported yet: a point section in a loft of more than two".into())) };
|
||
let (prof, pt) = if a.point.is_some() { (b, a) } else { (a, b) };
|
||
let ss = st.sketch(prof.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", prof.sketch)))?;
|
||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &prof.profiles)?);
|
||
let [r] = ®ions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
|
||
let ps = st.sketch(pt.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {}", pt.sketch)))?;
|
||
let id = pt.point.as_deref().unwrap_or_default();
|
||
let q = ps.sketch.points.iter().find(|p| p.id == id).ok_or_else(|| DocError::Unknown(format!("point `{id}` in {}", ps.name)))?;
|
||
let body = kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?;
|
||
Ok(vec![crate::lineage::ProfileRoles::swept(None, None).tool(body, None)])
|
||
}
|
||
FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => {
|
||
let base = &point(vals, base)?;
|
||
let k = axis.normalized().ok_or_else(|| DocError::Invalid("coil axis".into()))?;
|
||
let (d, p, n, s) = (
|
||
val(vals, diameter, Kind::Length)?,
|
||
val(vals, pitch, Kind::Length)?,
|
||
val(vals, turns, Kind::Unitless)?,
|
||
val(vals, section_size, Kind::Length)?,
|
||
);
|
||
if !(d > 0.0 && s > 0.0 && n > 0.0) {
|
||
return Err(DocError::Invalid("the coil diameter, section size and revolutions must be positive".into()));
|
||
}
|
||
let r = d / 2.0 + f64::from(*position) * s / 2.0;
|
||
if r <= s / 2.0 {
|
||
return Err(DocError::Invalid("the section reaches the coil's axis".into()));
|
||
}
|
||
let a0 = match start_angle {
|
||
Some(e) => val(vals, e, Kind::Angle)?,
|
||
None => 0.0,
|
||
};
|
||
// Radial direction at the start; the section lies in the plane of the axis.
|
||
let x0 = if k.cross(Vec3::X).len() > 1e-6 { (Vec3::X - k * k.dot(Vec3::X)).normalized() } else { Some(k.any_perp()) };
|
||
let x0 = x0.ok_or_else(|| DocError::Invalid("coil axis".into()))?;
|
||
let x = x0 * a0.cos() + k.cross(x0) * a0.sin();
|
||
let plane = Plane { origin: *base, x, y: k };
|
||
let c = Vec2::new(r, 0.0);
|
||
let outer = match section {
|
||
crate::CoilSection::Circular => solidcraft_geom::Loop2::circle(c, s / 2.0),
|
||
crate::CoilSection::Square => {
|
||
let h = s / 2.0;
|
||
solidcraft_geom::Loop2::polygon(&[c + Vec2::new(-h, -h), c + Vec2::new(h, -h), c + Vec2::new(h, h), c + Vec2::new(-h, h)])
|
||
}
|
||
};
|
||
let region = Region2 { outer, holes: Vec::new() };
|
||
let axis = if *clockwise { k * -1.0 } else { k };
|
||
// A left-handed coil turns about −axis and climbs along +axis: mirror the pitch.
|
||
let pitch = if *clockwise { -p } else { p };
|
||
Ok(unnamed(vec![kernel::sweep_helix(&plane, ®ion, *base, axis, pitch, n)?]))
|
||
}
|
||
FeatureKind::Loft { sections, .. } => {
|
||
let mut secs = Vec::new();
|
||
for sec in sections {
|
||
let ss = st
|
||
.sketch(sec.sketch)
|
||
.ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?;
|
||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
|
||
let [r] = ®ions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
|
||
secs.push((ss.plane, r.outer.clone()));
|
||
}
|
||
// Its caps are the first and last sections; which section's curve a side follows is
|
||
// not recorded, so the sides are named from geometry.
|
||
let body = kernel::loft(&secs)?;
|
||
Ok(vec![crate::lineage::ProfileRoles::swept(None, None).tool(body, None)])
|
||
}
|
||
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
|
||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||
let ps =
|
||
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||
let profile;
|
||
let ss = if !ps.sketch.wires.is_empty() {
|
||
profile = profile_in_frame(doc, ss, f.component)?;
|
||
&profile
|
||
} else {
|
||
ss
|
||
};
|
||
let mapped = path_in_frame(doc, ps, f.component)?;
|
||
let ps = mapped.as_ref();
|
||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
|
||
let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default();
|
||
let tool = |r: &Region2, body: Body| crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None);
|
||
// A path through 3D sketch curves follows them as points.
|
||
if let Some(pts) = path_points(ps, path, start)? {
|
||
return regions.iter().map(|r| Ok(tool(r, kernel::sweep_path(&ss.plane, r, &pts)?))).collect();
|
||
}
|
||
let segs = path_segments(ps, path, start)?;
|
||
regions.iter().map(|r| Ok(tool(r, kernel::sweep(&ss.plane, r, &segs)?))).collect()
|
||
}
|
||
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
|
||
let ps =
|
||
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
|
||
let mapped = path_in_frame(doc, ps, f.component)?;
|
||
let ps = mapped.as_ref();
|
||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||
if !(r > 1e-6) {
|
||
return Err(DocError::Invalid("pipe diameter must be positive".into()));
|
||
}
|
||
if let Some(pts) = path_points(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))? {
|
||
let (Some(a), Some(b)) = (pts.first().copied(), pts.get(1).copied()) else { return Err(DocError::Invalid("empty path".into())) };
|
||
let t = (b - a).normalized().ok_or_else(|| DocError::Invalid("path direction".into()))?;
|
||
let plane = Plane::from_normal(a, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?;
|
||
return Ok(unnamed(vec![kernel::sweep_path(&plane, &pipe_region(vals, r, wall)?, &pts)?]));
|
||
}
|
||
let segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
|
||
let first = segs.first().ok_or_else(|| DocError::Invalid("empty path".into()))?;
|
||
let (start, tangent) = match *first {
|
||
kernel::PathSeg::Line { a, b } => (a, b - a),
|
||
kernel::PathSeg::Arc { a, center, axis, .. } => (a, axis.cross(a - center)),
|
||
};
|
||
let t = tangent.normalized().ok_or_else(|| DocError::Invalid("path direction".into()))?;
|
||
let plane = Plane::from_normal(start, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?;
|
||
Ok(unnamed(vec![kernel::sweep(&plane, &pipe_region(vals, r, wall)?, &segs)?]))
|
||
}
|
||
FeatureKind::Box { corner, length, width, height, .. } => {
|
||
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
|
||
let corner = point(vals, corner)?;
|
||
Ok(vec![crate::lineage::box_tool(kernel::box_solid(corner, corner + s)?)])
|
||
}
|
||
FeatureKind::Cylinder { base, axis, radius, height, .. } => Ok(vec![crate::lineage::cylinder_tool(kernel::cylinder(
|
||
point(vals, base)?,
|
||
*axis,
|
||
val(vals, radius, Kind::Length)?,
|
||
val(vals, height, Kind::Length)?,
|
||
)?)]),
|
||
FeatureKind::Sphere { center, radius, .. } => Ok(unnamed(vec![kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?])),
|
||
FeatureKind::Torus { center, major, minor, .. } => {
|
||
Ok(unnamed(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]))
|
||
}
|
||
FeatureKind::Hole { diameter, depth, hole, to, .. } => {
|
||
let (spots, dir) = hole_spots(st, f)?;
|
||
if spots.is_empty() || spots.len() > 10_000 {
|
||
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
|
||
}
|
||
let mut tools = Vec::new();
|
||
for at in spots {
|
||
let depth = hole_depth_to(st, &at, dir, to, depth)?;
|
||
tools.extend(hole_tools(vals, st, &at, dir, diameter, &depth, hole)?);
|
||
}
|
||
Ok(unnamed(tools))
|
||
}
|
||
_ => Err(DocError::Invalid(format!("{} cannot be patterned or mirrored yet", f.name))),
|
||
}
|
||
}
|
||
|
||
/// A chain of sketch curves as 3D path segments, starting at the end nearest `start`.
|
||
/// A pipe's section: a circle of radius `r`, hollow when it has a wall.
|
||
fn pipe_region(vals: &BTreeMap<String, Value>, r: f64, wall: &Option<String>) -> Result<Region2> {
|
||
let holes = match wall {
|
||
Some(w) => {
|
||
let wt = val(vals, w, Kind::Length)?;
|
||
if !(wt > 0.0 && wt < r) {
|
||
return Err(DocError::Invalid("the wall must be thinner than the radius".into()));
|
||
}
|
||
vec![solidcraft_geom::Loop2::circle(Vec2::ZERO, r - wt).reversed()]
|
||
}
|
||
None => Vec::new(),
|
||
};
|
||
Ok(Region2 { outer: solidcraft_geom::Loop2::circle(Vec2::ZERO, r), holes })
|
||
}
|
||
|
||
fn map_wire(wire: &mut solidcraft_sketch::Wire, matrix: &Mat) {
|
||
for point in wire.pts.iter_mut().chain(&mut wire.fit) {
|
||
*point = crate::apply_point(matrix, *point);
|
||
}
|
||
}
|
||
|
||
/// A feature's sketch as its component sees it: a sketch of another component that is placed
|
||
/// differently is moved through both placements (as `state_in_frame` does for sketches on faces),
|
||
/// so the tool is built where the sketch is shown. The sketch itself when they share a frame.
|
||
fn sketch_in_feature_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result<SolvedSketch> {
|
||
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
|
||
if owner == consumer || doc.occurrences.is_empty() {
|
||
return Ok(sketch.clone());
|
||
}
|
||
profile_in_frame(doc, sketch, consumer)
|
||
}
|
||
|
||
fn profile_in_frame(doc: &Document, sketch: &SolvedSketch, consumer: u64) -> Result<SolvedSketch> {
|
||
let owner = doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
|
||
let inverse =
|
||
crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("profile consumer frame is invalid".into()))?;
|
||
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
|
||
let mut mapped = sketch.clone();
|
||
mapped.plane = Plane::new(
|
||
crate::apply_point(&relative, sketch.plane.origin),
|
||
crate::apply_vector(&relative, sketch.plane.x),
|
||
crate::apply_vector(&relative, sketch.plane.y),
|
||
)
|
||
.ok_or_else(|| DocError::Invalid("profile sketch frame is invalid".into()))?;
|
||
Ok(mapped)
|
||
}
|
||
|
||
/// Stored wire coordinates are converted once into the feature consumer frame.
|
||
fn path_in_frame<'a>(doc: &Document, sketch: &'a SolvedSketch, consumer: u64) -> Result<std::borrow::Cow<'a, SolvedSketch>> {
|
||
use std::borrow::Cow;
|
||
if sketch.sketch.wires.is_empty() {
|
||
return Ok(Cow::Borrowed(sketch));
|
||
}
|
||
let owner = doc.feature(sketch.feature).map(|f| f.component).unwrap_or(0);
|
||
let inverse =
|
||
crate::mat_inverse(&doc.component_transform(consumer)).ok_or_else(|| DocError::Invalid("path consumer frame is not invertible".into()))?;
|
||
let relative = crate::mat_mul(&inverse, &doc.component_transform(owner));
|
||
let mut mapped = sketch.clone();
|
||
mapped.plane = Plane::new(
|
||
crate::apply_point(&relative, sketch.plane.origin),
|
||
crate::apply_vector(&relative, sketch.plane.x),
|
||
crate::apply_vector(&relative, sketch.plane.y),
|
||
)
|
||
.ok_or_else(|| DocError::Invalid("path sketch frame is invalid".into()))?;
|
||
for wire in &mut mapped.sketch.wires {
|
||
let matrix = if wire.space == solidcraft_sketch::WireSpace::Component { &relative } else { &inverse };
|
||
map_wire(wire, matrix);
|
||
}
|
||
Ok(Cow::Owned(mapped))
|
||
}
|
||
|
||
/// A path that uses 3D sketch curves (wires), as one chain of world points from the end nearest
|
||
/// `start` (no start: the first curve runs toward the second). `None` when the path is all
|
||
/// planar curves.
|
||
fn path_points(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Option<Vec<Vec3>>> {
|
||
if !ids.iter().any(|id| ps.sketch.wire_index(id).is_some()) {
|
||
return Ok(None);
|
||
}
|
||
if ids.len() > 1000 {
|
||
return Err(DocError::Invalid("the path needs 1…1000 curves".into()));
|
||
}
|
||
let mut pieces: Vec<Vec<Vec3>> = Vec::with_capacity(ids.len());
|
||
for id in ids {
|
||
if let Some(wi) = ps.sketch.wire_index(id) {
|
||
pieces.push(ps.sketch.wires.get(wi).map(|w| w.pts.clone()).unwrap_or_default());
|
||
} else {
|
||
let ci = ps.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("path curve `{id}`")))?;
|
||
let mut pts: Vec<Vec3> = Vec::new();
|
||
for s in ps.sketch.segs(ci) {
|
||
for q in s.polyline(1e-2) {
|
||
let w = ps.plane.to_world(q);
|
||
if pts.last().is_none_or(|l| l.dist(w) > 1e-9) {
|
||
pts.push(w);
|
||
}
|
||
}
|
||
}
|
||
pieces.push(pts);
|
||
}
|
||
}
|
||
// Pieces meet within the sketch's tolerance (projected curves are only that close).
|
||
let tol = 1e-3;
|
||
let ends = |p: &Vec<Vec3>| (p.first().copied().unwrap_or_default(), p.last().copied().unwrap_or_default());
|
||
if let Some(f) = pieces.first().cloned() {
|
||
let (a, b) = ends(&f);
|
||
let flip = if start.is_finite() {
|
||
b.dist(start) < a.dist(start)
|
||
} else {
|
||
pieces.get(1).is_some_and(|n| {
|
||
let (na, nb) = ends(n);
|
||
na.dist(a).min(nb.dist(a)) < na.dist(b).min(nb.dist(b))
|
||
})
|
||
};
|
||
if flip && let Some(p0) = pieces.first_mut() {
|
||
p0.reverse();
|
||
}
|
||
}
|
||
let mut chain: Vec<Vec3> = Vec::new();
|
||
for mut p in pieces {
|
||
if let Some(last) = chain.last().copied() {
|
||
let (a, b) = ends(&p);
|
||
if b.dist(last) < a.dist(last) {
|
||
p.reverse();
|
||
}
|
||
if p.first().is_some_and(|a| a.dist(last) > tol) {
|
||
return Err(DocError::Invalid("the path curves are not connected end to end".into()));
|
||
}
|
||
chain.extend(p.into_iter().skip(1));
|
||
} else {
|
||
chain = p;
|
||
}
|
||
}
|
||
Ok(Some(chain))
|
||
}
|
||
|
||
fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<kernel::PathSeg>> {
|
||
if ids.is_empty() || ids.len() > 1000 {
|
||
return Err(DocError::Invalid("the path needs 1…1000 curves".into()));
|
||
}
|
||
// A path through 3D sketch curves is followed as a 3D polyline.
|
||
if let Some(pts) = path_points(ps, ids, start)? {
|
||
return Ok(pts.windows(2).map(|w| kernel::PathSeg::Line { a: w[0], b: w[1] }).collect());
|
||
}
|
||
let mut segs: Vec<solidcraft_geom::Seg2> = Vec::new();
|
||
for id in ids {
|
||
let ci = ps.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("path curve `{id}`")))?;
|
||
segs.extend(ps.sketch.segs(ci));
|
||
}
|
||
// Orient the chain: the first segment starts at the end nearest `start`, each next one
|
||
// starts where the previous ended.
|
||
let w = |p: Vec2| ps.plane.to_world(p);
|
||
if let (Some(f), true) = (segs.first().copied(), !segs.is_empty())
|
||
&& w(f.end()).dist(start) < w(f.start()).dist(start)
|
||
&& (segs.len() == 1 || segs.get(1).is_some_and(|n| n.start().dist(f.start()) < 1e-6 || n.end().dist(f.start()) < 1e-6))
|
||
{
|
||
segs[0] = f.reversed();
|
||
}
|
||
// No start given (a pipe): the first curve runs toward the second.
|
||
if !start.is_finite()
|
||
&& let (Some(f), Some(n)) = (segs.first().copied(), segs.get(1).copied())
|
||
&& (n.start().dist(f.start()) < 1e-6 || n.end().dist(f.start()) < 1e-6)
|
||
{
|
||
segs[0] = f.reversed();
|
||
}
|
||
for i in 1..segs.len() {
|
||
let prev_end = segs[i - 1].end();
|
||
if segs[i].end().dist(prev_end) < segs[i].start().dist(prev_end) {
|
||
segs[i] = segs[i].reversed();
|
||
}
|
||
if segs[i].start().dist(prev_end) > 1e-5 {
|
||
return Err(DocError::Invalid("the path curves are not connected end to end".into()));
|
||
}
|
||
}
|
||
let n = ps.plane.normal();
|
||
Ok(segs
|
||
.iter()
|
||
.flat_map(|s| match *s {
|
||
solidcraft_geom::Seg2::Line { a, b } => vec![kernel::PathSeg::Line { a: w(a), b: w(b) }],
|
||
solidcraft_geom::Seg2::Arc { center, sweep, .. } => {
|
||
vec![kernel::PathSeg::Arc { a: w(s.start()), center: w(center), axis: n * sweep.signum(), angle: sweep.abs() }]
|
||
}
|
||
// Free-form path curves are followed as polylines.
|
||
solidcraft_geom::Seg2::Cubic { .. } | solidcraft_geom::Seg2::Conic { .. } | solidcraft_geom::Seg2::Bezier { .. } => {
|
||
s.polyline(1e-2).windows(2).map(|q| kernel::PathSeg::Line { a: w(q[0]), b: w(q[1]) }).collect()
|
||
}
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// Operation and targets of a feature that makes tools.
|
||
fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
|
||
match &f.kind {
|
||
FeatureKind::Extrude { operation, targets, .. } | FeatureKind::Revolve { operation, targets, .. } => (*operation, targets.clone()),
|
||
FeatureKind::Box { operation, .. }
|
||
| FeatureKind::Cylinder { operation, .. }
|
||
| FeatureKind::Sphere { operation, .. }
|
||
| FeatureKind::Torus { operation, .. } => (*operation, Vec::new()),
|
||
FeatureKind::Hole { .. } => (Operation::Cut, Vec::new()),
|
||
FeatureKind::Pipe { operation, targets, .. } => (*operation, targets.clone()),
|
||
FeatureKind::Loft { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => (*operation, targets.clone()),
|
||
FeatureKind::Coil { operation, targets, .. } => (*operation, targets.clone()),
|
||
_ => (Operation::NewBody, Vec::new()),
|
||
}
|
||
}
|
||
|
||
type Mat = [[f64; 4]; 4];
|
||
|
||
fn translation(t: Vec3) -> Mat {
|
||
[[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [t.x, t.y, t.z, 1.0]]
|
||
}
|
||
|
||
/// Rotation by `ang` about the line through `o` with direction `a` (column-major).
|
||
fn rotation(o: Vec3, a: Vec3, ang: f64) -> Mat {
|
||
let a = a.normalized().unwrap_or(Vec3::Z);
|
||
let (c, s) = (ang.cos(), ang.sin());
|
||
let t = 1.0 - c;
|
||
let r = [
|
||
[t * a.x * a.x + c, t * a.x * a.y + s * a.z, t * a.x * a.z - s * a.y],
|
||
[t * a.x * a.y - s * a.z, t * a.y * a.y + c, t * a.y * a.z + s * a.x],
|
||
[t * a.x * a.z + s * a.y, t * a.y * a.z - s * a.x, t * a.z * a.z + c],
|
||
];
|
||
// p' = R (p - o) + o
|
||
let ro = Vec3::new(
|
||
r[0][0] * o.x + r[1][0] * o.y + r[2][0] * o.z,
|
||
r[0][1] * o.x + r[1][1] * o.y + r[2][1] * o.z,
|
||
r[0][2] * o.x + r[1][2] * o.y + r[2][2] * o.z,
|
||
);
|
||
let tr = o - ro;
|
||
[[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]]
|
||
}
|
||
|
||
/// Reflection in a plane (column-major).
|
||
fn mirror_matrix(pl: &Plane) -> Mat {
|
||
let n = pl.normal();
|
||
let d = pl.origin.dot(n);
|
||
let m = |i: usize, j: usize| {
|
||
let (ni, nj) = ([n.x, n.y, n.z][i], [n.x, n.y, n.z][j]);
|
||
(if i == j { 1.0 } else { 0.0 }) - 2.0 * ni * nj
|
||
};
|
||
// p' = p - 2 (p·n - d) n
|
||
[
|
||
[m(0, 0), m(0, 1), m(0, 2), 0.0],
|
||
[m(1, 0), m(1, 1), m(1, 2), 0.0],
|
||
[m(2, 0), m(2, 1), m(2, 2), 0.0],
|
||
[2.0 * d * n.x, 2.0 * d * n.y, 2.0 * d * n.z, 1.0],
|
||
]
|
||
}
|
||
|
||
const MAX_INSTANCES: usize = 1000;
|
||
|
||
/// The placements (column-major matrices) of a pattern's copies, the original excluded.
|
||
pub fn pattern_matrices(doc: &Document, st: &ModelState, p: &crate::PatternKind) -> Result<Vec<[[f64; 4]; 4]>> {
|
||
let (vals, _) = doc.param_values();
|
||
pattern_transforms(doc, &vals, st, 0, p)
|
||
}
|
||
|
||
fn pattern_transforms(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, component: u64, p: &crate::PatternKind) -> Result<Vec<Mat>> {
|
||
let count = |e: &str| -> Result<usize> {
|
||
let n = val(vals, e, Kind::Unitless)?.round();
|
||
if !(1.0..=MAX_INSTANCES as f64).contains(&n) {
|
||
return Err(DocError::Invalid(format!("pattern count must be 1…{MAX_INSTANCES}")));
|
||
}
|
||
Ok(n as usize)
|
||
};
|
||
let mut out = Vec::new();
|
||
match p {
|
||
crate::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 } => {
|
||
let (n1, s1) = (count(count1)?, val(vals, spacing1, Kind::Length)?);
|
||
let d1 = dir1.normalized().ok_or_else(|| DocError::Invalid("pattern direction".into()))?;
|
||
let (n2, s2, d2) = match (dir2, count2, spacing2) {
|
||
(Some(d), Some(c), Some(sp)) => {
|
||
(count(c)?, val(vals, sp, Kind::Length)?, d.normalized().ok_or_else(|| DocError::Invalid("pattern direction".into()))?)
|
||
}
|
||
_ => (1, 0.0, Vec3::Y),
|
||
};
|
||
if n1 * n2 > MAX_INSTANCES {
|
||
return Err(DocError::Invalid("too many pattern instances".into()));
|
||
}
|
||
for i in 0..n1 {
|
||
for j in 0..n2 {
|
||
if i == 0 && j == 0 {
|
||
continue;
|
||
}
|
||
out.push(translation(d1 * (s1 * i as f64) + d2 * (s2 * j as f64)));
|
||
}
|
||
}
|
||
}
|
||
crate::PatternKind::Path { path_sketch, path, count: c, spacing, extent, flip, orient } => {
|
||
let n = count(c)?;
|
||
let d = val(vals, spacing, Kind::Length)?;
|
||
let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("path sketch {path_sketch}")))?;
|
||
let mapped = path_in_frame(doc, ps, component)?;
|
||
let ps = mapped.as_ref();
|
||
let mut segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
|
||
if *flip {
|
||
segs = segs.iter().rev().map(reverse_seg).collect();
|
||
}
|
||
let total: f64 = segs.iter().map(seg_len).sum();
|
||
let step = if *extent { if n > 1 { d / (n - 1) as f64 } else { 0.0 } } else { d };
|
||
if (step * (n - 1) as f64).abs() > total * (1.0 + 1e-5) + 1e-6 {
|
||
return Err(DocError::Invalid(format!("the instances run past the end of the path ({total:.3} mm long)")));
|
||
}
|
||
let normal = ps.plane.normal();
|
||
let (p0, t0) = path_at(&segs, 0.0);
|
||
for i in 1..n {
|
||
let (pk, tk) = path_at(&segs, step * i as f64);
|
||
let mut m = if *orient {
|
||
// Signed turn of the tangent about the sketch normal.
|
||
let ang = t0.cross(tk).dot(normal).atan2(t0.dot(tk));
|
||
rotation(p0, normal, ang)
|
||
} else {
|
||
translation(Vec3::ZERO)
|
||
};
|
||
let dv = pk - p0;
|
||
m[3][0] += dv.x;
|
||
m[3][1] += dv.y;
|
||
m[3][2] += dv.z;
|
||
out.push(m);
|
||
}
|
||
}
|
||
crate::PatternKind::Circular { origin, axis, count: c, angle } => {
|
||
let n = count(c)?;
|
||
let total = val(vals, angle, Kind::Angle)?;
|
||
let full = (total.abs() - std::f64::consts::TAU).abs() < 1e-9;
|
||
let step = if full {
|
||
total / n as f64
|
||
} else if n > 1 {
|
||
total / (n - 1) as f64
|
||
} else {
|
||
0.0
|
||
};
|
||
for i in 1..n {
|
||
out.push(rotation(*origin, *axis, step * i as f64));
|
||
}
|
||
}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
fn seg_len(s: &kernel::PathSeg) -> f64 {
|
||
match *s {
|
||
kernel::PathSeg::Line { a, b } => (b - a).len(),
|
||
kernel::PathSeg::Arc { a, center, angle, .. } => (a - center).len() * angle.abs(),
|
||
}
|
||
}
|
||
|
||
/// Rotate `v` about the unit axis `k` by `ang` (Rodrigues).
|
||
fn rotate_vec(v: Vec3, k: Vec3, ang: f64) -> Vec3 {
|
||
v * ang.cos() + k.cross(v) * ang.sin() + k * (k.dot(v) * (1.0 - ang.cos()))
|
||
}
|
||
|
||
/// Point and unit tangent `t` mm along a segment.
|
||
fn seg_at(s: &kernel::PathSeg, t: f64) -> (Vec3, Vec3) {
|
||
match *s {
|
||
kernel::PathSeg::Line { a, b } => {
|
||
let d = (b - a).normalized().unwrap_or(Vec3::X);
|
||
(a + d * t, d)
|
||
}
|
||
kernel::PathSeg::Arc { a, center, axis, angle } => {
|
||
let k = axis.normalized().unwrap_or(Vec3::Z);
|
||
let r = (a - center).len().max(1e-12);
|
||
// Sweeps turn right-handed about `axis` (angles are positive).
|
||
let s = if angle < 0.0 { -1.0 } else { 1.0 };
|
||
let v = rotate_vec(a - center, k, s * t / r);
|
||
(center + v, k.cross(v).normalized().unwrap_or(Vec3::X) * s)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn reverse_seg(s: &kernel::PathSeg) -> kernel::PathSeg {
|
||
match *s {
|
||
kernel::PathSeg::Line { a, b } => kernel::PathSeg::Line { a: b, b: a },
|
||
kernel::PathSeg::Arc { a, center, axis, angle } => {
|
||
let k = axis.normalized().unwrap_or(Vec3::Z);
|
||
let end = center + rotate_vec(a - center, k, angle);
|
||
kernel::PathSeg::Arc { a: end, center, axis: k * -1.0, angle }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Point and tangent at arc length `d` along a chain (clamped to its ends).
|
||
fn path_at(segs: &[kernel::PathSeg], d: f64) -> (Vec3, Vec3) {
|
||
let mut left = d.max(0.0);
|
||
for (i, s) in segs.iter().enumerate() {
|
||
let l = seg_len(s);
|
||
if left <= l + 1e-9 || i + 1 == segs.len() {
|
||
return seg_at(s, left.min(l));
|
||
}
|
||
left -= l;
|
||
}
|
||
(Vec3::ZERO, Vec3::X)
|
||
}
|
||
|
||
/// Apply transformed copies of the tools of `features` (patterns, mirrors).
|
||
fn replay(
|
||
doc: &Document,
|
||
vals: &BTreeMap<String, Value>,
|
||
f: &Feature,
|
||
st: &mut ModelState,
|
||
features: &[String],
|
||
mats: &[Mat],
|
||
warning: &mut Option<String>,
|
||
) -> Result<()> {
|
||
if features.is_empty() {
|
||
return Err(DocError::Invalid("no features selected".into()));
|
||
}
|
||
// A suppressed feature has nothing to copy: its copies go with it.
|
||
let suppressed: Vec<&String> = features.iter().filter(|n| doc.find_feature(n).is_some_and(|x| x.suppressed)).collect();
|
||
if !suppressed.is_empty() {
|
||
let list = suppressed.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", ");
|
||
*warning = Some(if suppressed.len() == features.len() {
|
||
format!("nothing to copy: {list} suppressed")
|
||
} else {
|
||
format!("{list} suppressed and not copied")
|
||
});
|
||
}
|
||
for name in features.iter().filter(|n| !suppressed.contains(n)) {
|
||
// A sketch and its extrude often share a name; the feature that makes geometry wins.
|
||
let src = doc
|
||
.features
|
||
.iter()
|
||
.find(|x| &x.name == name && !matches!(x.kind, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. }))
|
||
.or_else(|| doc.find_feature(name))
|
||
.ok_or_else(|| DocError::Unknown(format!("feature `{name}`")))?;
|
||
// A fillet or chamfer repeats at the copied edges.
|
||
if let FeatureKind::Fillet { edges, radius, .. } | FeatureKind::Chamfer { edges, distance: radius, .. } = &src.kind {
|
||
let r = val(vals, radius, Kind::Length)?;
|
||
// The edges the feature blended in this evaluation (found by name), falling back to
|
||
// its picked points.
|
||
let found = st.blend_edges.get(&src.id).cloned().unwrap_or_else(|| edges.clone());
|
||
for m in mats {
|
||
let moved: Vec<Vec3> = found.iter().map(|p| crate::apply_point(m, *p)).collect();
|
||
let ti = blend_target(st, &None, &moved)?;
|
||
let Some(mb) = st.bodies.get(ti).cloned() else { continue };
|
||
let nb = match &src.kind {
|
||
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
|
||
// A copy is congruent to the original: the radius it was reduced to first.
|
||
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id).copied() {
|
||
Some(used) => kernel::fillet(&mb.body, &moved, used).or_else(|_| fillet_fitted(&mb.body, &moved, r, *fit).map(|x| x.0))?,
|
||
None => fillet_fitted(&mb.body, &moved, r, *fit)?.0,
|
||
},
|
||
_ => kernel::fillet(&mb.body, &moved, r)?,
|
||
};
|
||
let nb = Traced::step(nb, &[&mb.traced()]);
|
||
if let Some(slot) = st.bodies.get_mut(ti) {
|
||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
let (op, targets) = feature_op(src);
|
||
let mut copies = Vec::new();
|
||
if let FeatureKind::Hole { diameter, depth, hole, to, .. } = &src.kind {
|
||
// A hole is drilled again at each copy of its spot (found on the surface there), so
|
||
// a blind hole's depth counts from the face at the copy, not from the bottom of the
|
||
// original hole under the original spot.
|
||
let (spots, dir) = hole_spots_raw(st, src)?;
|
||
for m in mats {
|
||
let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||
for p in &spots {
|
||
let at = hole_on_surface(st, crate::apply_point(m, *p), d);
|
||
let to = to.map(|t| crate::apply_point(m, t));
|
||
let depth = hole_depth_to(st, &at, d, &to, depth)?;
|
||
copies.extend(hole_tools(vals, st, &at, d, diameter, &depth, hole)?.into_iter().map(Traced::unnamed));
|
||
}
|
||
}
|
||
} else {
|
||
// Each copy's faces are named as the source feature's, with the instance.
|
||
let tools = feature_tools_with_constructor(doc, vals, src, st, &mut None)?;
|
||
for (k, m) in mats.iter().enumerate() {
|
||
for t in &tools {
|
||
copies.push(t.copied(kernel::transform_matrix(&t.body, *m)?, src.id, k + 1));
|
||
}
|
||
}
|
||
}
|
||
// New bodies from a pattern join their source when they touch it (as one feature would).
|
||
if f.body_names.is_empty() {
|
||
apply_traced(st, f, copies, op, &targets)?;
|
||
} else {
|
||
let start = st.bodies.iter().filter(|b| b.feature == f.id).count();
|
||
let mut named = f.clone();
|
||
named.body_names = f.body_names.iter().skip(start).cloned().collect();
|
||
apply_traced(st, &named, copies, op, &targets)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[path = "eval_more.rs"]
|
||
mod more;
|
||
#[path = "eval_surface.rs"]
|
||
mod surface;
|
||
|
||
/// Height above `plane` (along its normal) of the object at `p` as seen from `centre`: the face
|
||
/// there if it is planar and parallel (its plane), else where a ray from `centre` along the
|
||
/// normal meets the body there, else the point itself.
|
||
pub fn object_height(st: &ModelState, plane: &Plane, centre: Vec3, p: Vec3) -> f64 {
|
||
let n = plane.normal();
|
||
let h = |q: Vec3| (q - plane.origin).dot(n);
|
||
let Some((bi, fi)) = face_at(st, p) else { return h(p) };
|
||
let Some(b) = st.bodies.get(bi) else { return h(p) };
|
||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||
let Ok(faces) = b.body.faces(tol) else { return h(p) };
|
||
if let Some(f) = faces.iter().find(|f| f.index == fi)
|
||
&& f.plane_normal.is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6)
|
||
{
|
||
return h(f.centroid);
|
||
}
|
||
// A curved or slanted face: where the profile's middle meets it.
|
||
let m = b.mesh();
|
||
for dir in [n, n * -1.0] {
|
||
if let Some((t, tri)) = m.raycast(centre, dir)
|
||
&& m.tri_face.get(tri).is_some_and(|x| *x as usize == fi)
|
||
{
|
||
return h(centre + dir * t);
|
||
}
|
||
}
|
||
h(p)
|
||
}
|
||
|
||
/// The face a To Object pick is on: the nearest face over every solid body, `(body index,
|
||
/// face index)`, within that body's pick tolerance. Not the first body with any face within
|
||
/// its tolerance: that is 5 % of the body's size, so a large body beside a small target (a
|
||
/// plate under a post, a frame around a pin) would otherwise take a pick made on the target.
|
||
fn face_at(st: &ModelState, p: Vec3) -> Option<(usize, usize)> {
|
||
st.bodies
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, b)| !b.body.is_mesh())
|
||
.filter_map(|(bi, b)| {
|
||
let (d, fi) = crate::appearance::nearest_face(b, p)?;
|
||
(d <= crate::appearance::pick_tolerance(b)).then_some((d, bi, fi))
|
||
})
|
||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||
.map(|(_, bi, fi)| (bi, fi))
|
||
}
|
||
|
||
/// The body at `p` when the face there is not a plane square to `n` (To Object must then
|
||
/// follow the face's shape).
|
||
fn curved_target(st: &ModelState, n: Vec3, p: Vec3) -> Option<usize> {
|
||
let (bi, fi) = face_at(st, p)?;
|
||
let b = st.bodies.get(bi)?;
|
||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||
let planar =
|
||
b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).and_then(|f| f.plane_normal).is_some_and(|fnrm| fnrm.cross(n).len() < 1e-6);
|
||
(!planar).then_some(bi)
|
||
}
|
||
|
||
/// The body at `p` when the face there is not planar.
|
||
fn curved_target_any(st: &ModelState, p: Vec3) -> Option<usize> {
|
||
let (bi, fi) = face_at(st, p)?;
|
||
let b = st.bodies.get(bi)?;
|
||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||
let planar = b.body.faces(tol).ok()?.into_iter().find(|f| f.index == fi).is_some_and(|f| f.plane_normal.is_some());
|
||
(!planar).then_some(bi)
|
||
}
|
||
|
||
/// Tools run through `target`, cut back to its surface: each tool less the body, keeping the
|
||
/// piece(s) that touch the start (`dist_to_start` 0 there).
|
||
fn trim_to_body(tools: Vec<Traced>, target: &ModelBody, dist_to_start: impl Fn(Vec3) -> f64) -> Result<Vec<Traced>> {
|
||
let mut out = Vec::new();
|
||
let target_traced = target.traced();
|
||
for t in tools {
|
||
let Some(cut) = kernel::boolean(&t.body, &target.body, BoolOp::Cut)? else { continue };
|
||
let cut = Traced::step(cut, &[&t, &target_traced]);
|
||
let lumps = cut.body.lumps()?;
|
||
let lineages = crate::lineage::Lineage::lumps(&cut.body, cut.lineage.as_ref(), &lumps);
|
||
for (lump, lineage) in lumps.into_iter().zip(lineages) {
|
||
let tol = (lump.size() * 1e-4).max(1e-6);
|
||
let touches =
|
||
lump.tessellate((lump.size() * 1e-2).max(1e-2)).map(|m| m.positions.iter().any(|q| dist_to_start(*q) < tol)).unwrap_or(false);
|
||
if touches {
|
||
out.push(Traced { body: lump, lineage });
|
||
}
|
||
}
|
||
}
|
||
if out.is_empty() {
|
||
return Err(DocError::Invalid("nothing lies between the profile and the To object".into()));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// One point on each face of a body (a triangle centre, so it lies on the face).
|
||
fn face_samples(b: &ModelBody) -> Vec<Vec3> {
|
||
let m = b.mesh();
|
||
let mut out: Vec<Option<Vec3>> = Vec::new();
|
||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||
let f = *f as usize;
|
||
if out.len() <= f {
|
||
out.resize(f + 1, None);
|
||
}
|
||
if let (Some(slot @ None), Some([a, bb, c])) = (out.get_mut(f), m.tri(t)) {
|
||
*slot = Some((a + bb + c) * (1.0 / 3.0));
|
||
}
|
||
}
|
||
out.into_iter().flatten().collect()
|
||
}
|
||
|
||
/// The picked faces and every face joined to them smoothly (across tangent edges), as points.
|
||
fn smooth_chain(b: &ModelBody, picks: &[Vec3]) -> Vec<Vec3> {
|
||
let m = b.mesh();
|
||
let samples = face_samples(b);
|
||
let face_of = |p: Vec3| crate::appearance::face_index_at(b, p);
|
||
// The surface normal of face f at its mesh vertex nearest q (vertex normals are the
|
||
// surface's, so a tangent edge reads the same from both sides).
|
||
let normal_near = |f: u32, q: Vec3| -> Option<Vec3> {
|
||
m.triangles
|
||
.iter()
|
||
.zip(&m.tri_face)
|
||
.filter(|(_, tf)| **tf == f)
|
||
.flat_map(|(t, _)| t.iter().copied())
|
||
.filter_map(|i| Some((m.positions.get(i as usize)?.dist(q), *m.normals.get(i as usize)?)))
|
||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||
.and_then(|(_, n)| n.normalized())
|
||
};
|
||
let mut chosen: Vec<u32> = picks.iter().filter_map(|p| face_of(*p).map(|f| f as u32)).collect();
|
||
let mut k = 0;
|
||
while k < chosen.len() && chosen.len() < 10_000 {
|
||
let f = chosen[k];
|
||
k += 1;
|
||
for (e, fs) in m.edges.iter().zip(&m.edge_faces) {
|
||
let (Some(&a), Some(&bb)) = (fs.first(), fs.get(1)) else { continue };
|
||
let other = if a == f {
|
||
bb
|
||
} else if bb == f {
|
||
a
|
||
} else {
|
||
continue;
|
||
};
|
||
if chosen.contains(&other) {
|
||
continue;
|
||
}
|
||
let Some(mid) = e.get(e.len() / 2).copied() else { continue };
|
||
if let (Some(n1), Some(n2)) = (normal_near(f, mid), normal_near(other, mid))
|
||
&& n1.dot(n2) > 2f64.to_radians().cos()
|
||
{
|
||
chosen.push(other);
|
||
}
|
||
}
|
||
}
|
||
let mut out = picks.to_vec();
|
||
for f in chosen {
|
||
if let Some(p) = samples.iter().find(|p| face_of(**p) == Some(f as usize)) {
|
||
out.push(*p);
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// `plane` with the sheet face it names (see `sheet::plane_sheet_face`) replaced by that face's current
|
||
/// plane in the frame of component `comp`, or an error when the face is gone: a lost
|
||
/// attachment is reported, never moved to the nearest face.
|
||
pub(crate) fn sheet_face_plane_in_frame(doc: &Document, st: &ModelState, comp: u64, plane: &PlaneRef) -> Result<PlaneRef> {
|
||
Ok(match plane {
|
||
PlaneRef::Face { name: Some(name), anchor: None, .. } if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||
let lost = || DocError::Invalid(format!("lost face attachment: the sheet face `{name}` no longer exists; reattach the sketch to a face"));
|
||
let (body, face) = crate::sheet::sheet_face_plane(st, name).ok_or_else(lost)?;
|
||
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
|
||
return Ok(PlaneRef::Custom { plane: face });
|
||
}
|
||
// The body's own coordinates into the component's frame, as `state_in_frame` places its bodies.
|
||
let source = st.body(&body).ok_or_else(lost)?;
|
||
let inverse = crate::mat_inverse(&doc.component_transform(comp)).ok_or_else(lost)?;
|
||
let m = crate::mat_mul(&inverse, &doc.component_transform(doc.model_body_component(source)));
|
||
let m = doc.model_body_offset(source).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
|
||
let placed = Plane::new(crate::apply_point(&m, face.origin), crate::apply_vector(&m, face.x), crate::apply_vector(&m, face.y));
|
||
PlaneRef::Custom { plane: placed.ok_or_else(lost)? }
|
||
}
|
||
PlaneRef::Offset { base, distance } => {
|
||
PlaneRef::Offset { base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?), distance: distance.clone() }
|
||
}
|
||
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => PlaneRef::AtAngle {
|
||
base: Box::new(sheet_face_plane_in_frame(doc, st, comp, base)?),
|
||
axis_origin: *axis_origin,
|
||
axis_dir: *axis_dir,
|
||
angle: angle.clone(),
|
||
},
|
||
other => other.clone(),
|
||
})
|
||
}
|
||
|
||
/// The plane of the body face a sketch sits on, found again: among planar faces with the
|
||
/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it.
|
||
fn face_plane(st: &ModelState, picked: &Plane, at: Vec3, name: Option<&str>) -> (Plane, Option<String>) {
|
||
let n = picked.normal();
|
||
// By name first: the face (or a piece of it) with that name, facing the same way.
|
||
if let Some(name) = name {
|
||
// (distance from `at`, plane offset along n, what to say about how it was found)
|
||
let mut found: Option<(f64, f64, Option<String>)> = None;
|
||
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
|
||
let named = crate::naming::faces_with_stem(b, name);
|
||
if named.is_empty() {
|
||
continue;
|
||
}
|
||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||
let Ok(faces) = b.body.faces(tol) else { continue };
|
||
for f in faces {
|
||
let hit = named.contains(&f.index);
|
||
if let (true, Some(fnrm)) = (hit, f.plane_normal)
|
||
&& fnrm.dot(n) > 1.0 - 1e-6
|
||
{
|
||
let d = f.centroid.dist(at);
|
||
if found.as_ref().is_none_or(|x| d < x.0) {
|
||
// A name saved before format 14 is found as it was then, without a note.
|
||
let note = if crate::naming::legacy_name(name).is_some() {
|
||
None
|
||
} else if !crate::naming::face_has_name(b, f.index, name) {
|
||
Some(format!("face `{name}` was split; using the piece nearest the picked point"))
|
||
} else if !crate::naming::face_name_exact(b, f.index) && (f.centroid - at).dot(n).abs() > tol {
|
||
// Named from geometry and moved off the picked plane: geometry alone decided.
|
||
Some(format!("face `{name}` was found by geometry, not exact history; check it"))
|
||
} else {
|
||
None
|
||
};
|
||
found = Some((d, f.centroid.dot(n), note));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some((_, off, note)) = found {
|
||
return (picked.offset(off - picked.origin.dot(n)), note);
|
||
}
|
||
let (pl, w) = face_plane(st, picked, at, None);
|
||
let note = format!(
|
||
"the face `{name}` the sketch was on no longer exists; {}",
|
||
if w.is_some() { "the sketch stays where it was" } else { "using the face at the picked point" }
|
||
);
|
||
return (pl, Some(note));
|
||
}
|
||
let mut best: Option<(f64, f64)> = None; // (distance from `at`, plane offset along n)
|
||
for b in &st.bodies {
|
||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||
let (Ok(faces), Ok(mesh)) = (b.body.faces(tol), b.body.tessellate(tol)) else { continue };
|
||
for f in faces {
|
||
let Some(fnrm) = f.plane_normal else { continue };
|
||
if fnrm.dot(n) < 1.0 - 1e-6 {
|
||
continue;
|
||
}
|
||
let d = mesh
|
||
.triangles
|
||
.iter()
|
||
.zip(&mesh.tri_face)
|
||
.filter(|(_, tf)| **tf as usize == f.index)
|
||
.filter_map(|(t, _)| mesh.tri(t))
|
||
.map(|[a, bb, c]| crate::project::closest_on_triangle(at, a, bb, c).dist(at))
|
||
.fold(f64::INFINITY, f64::min);
|
||
if best.is_none_or(|x| d < x.0) {
|
||
best = Some((d, f.centroid.dot(n)));
|
||
}
|
||
}
|
||
}
|
||
match best {
|
||
Some((_, off)) => {
|
||
let shift = off - picked.origin.dot(n);
|
||
(picked.offset(shift), None)
|
||
}
|
||
None => (*picked, Some("the face the sketch was on is gone; the sketch stays where it was".into())),
|
||
}
|
||
}
|
||
|
||
/// A chamfer, equal or by its second distance or angle.
|
||
fn chamfer_with(
|
||
vals: &BTreeMap<String, Value>,
|
||
b: &kernel::Body,
|
||
edges: &[Vec3],
|
||
d: f64,
|
||
distance2: &Option<String>,
|
||
angle: &Option<String>,
|
||
flip: bool,
|
||
) -> Result<kernel::Body> {
|
||
let side = match (distance2, angle) {
|
||
(Some(e), _) => Some(kernel::ChamferSide::Distance(val(vals, e, Kind::Length)?)),
|
||
(None, Some(e)) => Some(kernel::ChamferSide::Angle(val(vals, e, Kind::Angle)?)),
|
||
(None, None) => None,
|
||
};
|
||
Ok(match side {
|
||
Some(s) => kernel::chamfer_sides(b, edges, d, s, flip)?,
|
||
None => kernel::chamfer(b, edges, d)?,
|
||
})
|
||
}
|
||
|
||
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||
match &f.kind {
|
||
FeatureKind::BodyTransfer { bodies, from, to, transform } => crate::body_transfer::evaluate(doc, st, bodies, *from, *to, *transform),
|
||
FeatureKind::SheetBase { .. }
|
||
| FeatureKind::SheetContour { .. }
|
||
| FeatureKind::SheetFlange { .. }
|
||
| FeatureKind::SheetHem { .. }
|
||
| FeatureKind::SheetFold { .. }
|
||
| FeatureKind::SheetUnfold { .. }
|
||
| FeatureKind::SheetConvert { .. } => more::sheet_eval(doc, vals, f, st, warning),
|
||
FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. } => {
|
||
more::plastic_eval(doc, vals, f, st)
|
||
}
|
||
FeatureKind::Patch { .. }
|
||
| FeatureKind::Stitch { .. }
|
||
| FeatureKind::Thicken { .. }
|
||
| FeatureKind::SurfaceTrim { .. }
|
||
| FeatureKind::SurfaceExtend { .. } => surface::eval(doc, vals, f, st),
|
||
FeatureKind::Emboss { .. }
|
||
| FeatureKind::Rib { .. }
|
||
| FeatureKind::ReplaceFace { .. }
|
||
| FeatureKind::Align { .. }
|
||
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st),
|
||
FeatureKind::Sketch { plane, sketch } => {
|
||
let anchor = crate::face_anchor::plane_face_anchor(plane);
|
||
let fst = match anchor {
|
||
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
|
||
None => state_in_frame(doc, st, f.component),
|
||
};
|
||
let (plane, face_warning) = match (anchor, plane) {
|
||
(Some(_), plane) => (doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?, None),
|
||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
|
||
}
|
||
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref()),
|
||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||
};
|
||
let mut sk = sketch.clone();
|
||
let mut warns = match anchor {
|
||
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor),
|
||
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk),
|
||
};
|
||
drop(fst);
|
||
sk.refresh_offsets()?;
|
||
if let Some(w) = face_warning {
|
||
warns.insert(0, w);
|
||
}
|
||
// The stored sketch is solved for its stored values.
|
||
let stored = sk.driving_values();
|
||
let report = doc.solve_sketch(vals, &mut sk, &stored)?;
|
||
if !report.keeps_orientation() {
|
||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||
}
|
||
if !warns.is_empty() {
|
||
*warning = Some(warns.join("; "));
|
||
}
|
||
sk.validate_periodic_splines()?;
|
||
let profiles = find_profiles(&sk);
|
||
st.sketches.retain(|s| s.feature != f.id);
|
||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }));
|
||
Ok(())
|
||
}
|
||
FeatureKind::Extrude { operation, targets, .. }
|
||
| FeatureKind::Pipe { operation, targets, .. }
|
||
| FeatureKind::Revolve { operation, targets, .. }
|
||
| FeatureKind::Loft { operation, targets, .. }
|
||
| FeatureKind::Coil { operation, targets, .. }
|
||
| FeatureKind::Sweep { operation, targets, .. } => {
|
||
let mut constructor = None;
|
||
let tools = feature_tools_with_constructor(doc, vals, f, st, &mut constructor)?;
|
||
// Cuts through sheet metal become cut-outs of its flat pattern.
|
||
if matches!(operation, Operation::Cut | Operation::Intersect) && matches!(f.kind, FeatureKind::Extrude { .. }) && !st.sheets.is_empty() {
|
||
let bodies: Vec<Body> = tools.iter().map(|t| t.body.clone()).collect();
|
||
let handled = more::sheet_cut(f, st, &bodies, targets, constructor.as_ref())?;
|
||
if !handled.is_empty() {
|
||
let effective = if targets.is_empty() { &f.participants } else { targets };
|
||
let all = effective.len() == 1 && effective.first().is_some_and(|t| t == "*");
|
||
let rest: Vec<String> = st
|
||
.bodies
|
||
.iter()
|
||
.filter(|b| {
|
||
!handled.contains(&b.name)
|
||
&& (all
|
||
|| if effective.is_empty() {
|
||
b.component == f.component || b.feature == f.id
|
||
} else {
|
||
effective.contains(&b.name)
|
||
})
|
||
})
|
||
.map(|b| b.name.clone())
|
||
.collect();
|
||
if rest.is_empty() {
|
||
return Ok(());
|
||
}
|
||
return apply_traced(st, f, tools, *operation, &rest);
|
||
}
|
||
}
|
||
apply_traced(st, f, tools, *operation, targets)
|
||
}
|
||
FeatureKind::Hole { thread, diameter, .. } => {
|
||
let (spots, dir) = hole_spots(st, f)?;
|
||
let tools = feature_tools(doc, vals, f, st)?;
|
||
apply_op(st, f, tools, Operation::Cut, &[])?;
|
||
if let Some(d) = thread {
|
||
// A point on each hole's wall, a little below the entry.
|
||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||
for p in spots {
|
||
let wall = p + dir.any_perp() * r + dir * (r * 0.5);
|
||
let t = thread_on(st, f.id, wall, Some(d), None)?;
|
||
st.threads.push(t);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::Thread { face, designation, length, modeled } => {
|
||
let l = match length {
|
||
Some(e) => Some(val(vals, e, Kind::Length)?),
|
||
None => None,
|
||
};
|
||
let t = thread_on(st, f.id, *face, designation.as_deref(), l)?;
|
||
if !*modeled {
|
||
st.threads.push(t);
|
||
return Ok(());
|
||
}
|
||
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
|
||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||
let cyl = kernel::cylinder_face_at(&mb.body, *face).ok_or_else(|| DocError::Invalid("no cylindrical face there to thread".into()))?;
|
||
let tol = t.pitch * 1e-3;
|
||
let groove = thread_groove(&t, cyl.radius, ((t.start - cyl.start).abs() < tol, (t.end - cyl.end).abs() < tol))?;
|
||
// The B-rep boolean can't take helical walls in reasonable time: the body is cut
|
||
// faceted (curved faces within a two-hundredth of the pitch).
|
||
let nb = kernel::faceted_boolean(&mb.body, &groove, BoolOp::Cut, t.pitch * 0.005)?
|
||
.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?;
|
||
// The groove's faces are its triangles, with no role to name them by.
|
||
let groove = crate::lineage::Lineage::unnamed_faces(groove.triangles.len());
|
||
let lineage = mb.traced().lineage.and_then(|l| crate::lineage::Lineage::step(&nb, &[&l, &groove]));
|
||
let nb = Traced { body: nb, lineage };
|
||
if let Some(slot) = st.bodies.get_mut(i) {
|
||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::Shell { faces, thickness, body, direction, tangent_chain } => {
|
||
let t = val(vals, thickness, Kind::Length)?;
|
||
let i = body_at(st, body, faces)?;
|
||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||
let open = if *tangent_chain { smooth_chain(&mb, faces) } else { faces.clone() };
|
||
let input = mb.traced();
|
||
// The shell, and the operands of the operation that made it.
|
||
let (nb, operands) = match direction.as_str() {
|
||
"" | "inside" => (kernel::shell(&mb.body, &open, t)?, vec![input]),
|
||
"outside" | "both" => {
|
||
// Walls grown out of the faces (or half out, half in): an offset body less a
|
||
// cavity, the open faces pushed out of the way.
|
||
if !(t > 1e-6) {
|
||
return Err(DocError::Invalid("shell thickness must be positive".into()));
|
||
}
|
||
let (out, inn) = if direction == "outside" { (t, 0.0) } else { (t / 2.0, t / 2.0) };
|
||
let samples = face_samples(&mb);
|
||
let opened =
|
||
|p: &Vec3| open.iter().any(|q| crate::appearance::face_index_at(&mb, *q) == crate::appearance::face_index_at(&mb, *p));
|
||
let walls: Vec<Vec3> = samples.iter().filter(|p| !opened(p)).copied().collect();
|
||
let outer = Traced::step(kernel::offset_faces(&mb.body, &walls, out)?, &[&input]);
|
||
// The cavity's faces are the shell's inner walls, named after the faces they offset.
|
||
let inner = mb.before_as(crate::lineage::Operand::Inner(mb.name.clone()));
|
||
let cavity = if inn > 0.0 { Traced::step(kernel::offset_faces(&mb.body, &walls, -inn)?, &[&inner]) } else { inner };
|
||
let cavity = Traced::step(kernel::offset_faces(&cavity.body, &open, 2.0 * t + 1.0)?, &[&cavity]);
|
||
let cut = kernel::boolean(&outer.body, &cavity.body, BoolOp::Cut)?
|
||
.ok_or_else(|| DocError::Invalid("the shell removed everything".into()))?;
|
||
(cut, vec![outer, cavity])
|
||
}
|
||
o => return Err(DocError::Invalid(format!("unknown shell direction `{o}` (inside, outside or both)"))),
|
||
};
|
||
// Certifying may join faces; their history stays over the same operands.
|
||
let nb = Traced::step(nb.with_shell_certificate(f.id)?, &operands.iter().collect::<Vec<_>>());
|
||
if let Some(slot) = st.bodies.get_mut(i) {
|
||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::Draft { faces, angle, neutral, pull, body } => {
|
||
let a = val(vals, angle, Kind::Angle)?;
|
||
let pl = doc.resolve_plane_in(vals, neutral, 0, Some(st))?;
|
||
let i = body_at(st, body, faces)?;
|
||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||
let nb = Traced::step(kernel::draft(&mb.body, faces, &pl, *pull, a)?, &[&mb.traced()]);
|
||
if let Some(slot) = st.bodies.get_mut(i) {
|
||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::ConstructionPlane { plane } => {
|
||
// On a body face: follow the face as it is now, as a sketch on that face does.
|
||
let pl = match crate::face_anchor::plane_face_anchor(plane) {
|
||
Some(anchor) => {
|
||
let fst = crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?;
|
||
doc.resolve_attached_plane(vals, plane, f.component, st, &fst)?
|
||
}
|
||
None => doc.resolve_plane_in(vals, plane, 0, Some(st))?,
|
||
};
|
||
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Plane(pl) });
|
||
Ok(())
|
||
}
|
||
FeatureKind::ConstructionAxis { def } => {
|
||
let (origin, dir) = crate::construct::resolve_axis(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
|
||
st.construct.push(crate::construct::Construct {
|
||
feature: f.id,
|
||
name: f.name.clone(),
|
||
geom: crate::construct::ConstructGeom::Axis { origin, dir },
|
||
});
|
||
Ok(())
|
||
}
|
||
FeatureKind::ConstructionPoint { def } => {
|
||
let p = crate::construct::resolve_point(&crate::construct::Ctx { doc, vals, st, depth: 0 }, def)?;
|
||
st.construct.push(crate::construct::Construct { feature: f.id, name: f.name.clone(), geom: crate::construct::ConstructGeom::Point(p) });
|
||
Ok(())
|
||
}
|
||
FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => {
|
||
if tools.is_empty() || tools.len() > 64 || cells.is_empty() || cells.len() > 1000 {
|
||
return Err(DocError::Invalid("Boundary Fill needs 1…64 tool bodies and 1…1000 cells".into()));
|
||
}
|
||
let tb: Vec<ModelBody> =
|
||
tools.iter().map(|t| st.body(t).cloned().ok_or_else(|| DocError::Unknown(format!("body `{t}`")))).collect::<Result<_>>()?;
|
||
let mut made = Vec::new();
|
||
for p in cells {
|
||
// The cell: inside the tools that hold the point, outside the others.
|
||
let inside: Vec<bool> = tb.iter().map(|b| b.mesh().contains(*p)).collect();
|
||
let mut acc: Option<Traced> = None;
|
||
for (b, _) in tb.iter().zip(&inside).filter(|(_, i)| **i) {
|
||
acc = match acc {
|
||
None => Some(b.traced()),
|
||
Some(a) => kernel::boolean(&a.body, &b.body, BoolOp::Intersect)?.map(|r| Traced::step(r, &[&a, &b.traced()])),
|
||
};
|
||
}
|
||
let Some(mut cell) = acc else {
|
||
return Err(DocError::Invalid(format!("the point {p:?} is outside every tool: no enclosed cell there")));
|
||
};
|
||
for (b, _) in tb.iter().zip(&inside).filter(|(_, i)| !**i) {
|
||
let cut = kernel::boolean(&cell.body, &b.body, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("a cell came out empty".into()))?;
|
||
cell = Traced::step(cut, &[&cell, &b.traced()]);
|
||
}
|
||
made.push(cell);
|
||
}
|
||
if *remove_tools {
|
||
st.bodies.retain(|b| !tools.contains(&b.name));
|
||
}
|
||
apply_traced(st, f, made, *operation, &[])
|
||
}
|
||
FeatureKind::SplitFace { faces, body, plane, tool, sketch } => {
|
||
let i = body_at(st, body, faces)?;
|
||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||
let mut picked: Vec<usize> = Vec::new();
|
||
for p in faces {
|
||
let fi = crate::appearance::face_index_at(&mb, *p)
|
||
.ok_or_else(|| DocError::Invalid(format!("Split Face: no face of {} at {p:?}", mb.name)))?;
|
||
if !picked.contains(&fi) {
|
||
picked.push(fi);
|
||
}
|
||
}
|
||
let nb = match (tool, sketch) {
|
||
(Some(t), _) => {
|
||
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
|
||
let face = crate::appearance::face_index_at(tb, t.point)
|
||
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
|
||
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Face { body: &tb.body, face })?
|
||
}
|
||
(None, Some(sid)) => {
|
||
let ss = st.sketch(*sid).ok_or_else(|| DocError::Unknown(format!("sketch {sid}")))?;
|
||
// Each curve's exact segments (lines and arcs split by their own plane and cylinder).
|
||
let curves: Vec<Vec<solidcraft_geom::Seg2>> = (0..ss.sketch.curves.len())
|
||
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
|
||
.map(|ci| ss.sketch.segs(ci))
|
||
.filter(|segs| !segs.is_empty())
|
||
.collect();
|
||
if curves.is_empty() {
|
||
return Err(DocError::Invalid(format!("sketch `{}` has no curves to split with", ss.name)));
|
||
}
|
||
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Curves { plane: ss.plane, curves })?
|
||
}
|
||
(None, None) => {
|
||
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
|
||
kernel::split_faces(&mb.body, &picked, &kernel::SplitTool::Plane(pl))?
|
||
}
|
||
};
|
||
let nb = Traced::step(nb, &[&mb.traced()]);
|
||
if let Some(slot) = st.bodies.get_mut(i) {
|
||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::Split { body, plane, tool } => {
|
||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Unknown(format!("body `{body}`"))) };
|
||
let parts = match tool {
|
||
// A face of a body (its surface extended).
|
||
Some(t) => {
|
||
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
|
||
let face = crate::appearance::face_index_at(tb, t.point)
|
||
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
|
||
kernel::split_body(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face })?
|
||
}
|
||
None => {
|
||
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
|
||
kernel::split_by_plane(&mb.body, &pl)?
|
||
}
|
||
};
|
||
if parts.len() < 2 {
|
||
return Err(DocError::Invalid("the tool does not split the body".into()));
|
||
}
|
||
let input = mb.traced();
|
||
let mut it = parts.into_iter().map(|part| Traced::step(part, &[&input]));
|
||
if let (Some(first), Some(slot)) = (it.next(), st.bodies.get_mut(i)) {
|
||
*slot = ModelBody::traced_new(mb.name.clone(), first, mb.feature);
|
||
}
|
||
for (k, b) in it.enumerate() {
|
||
let mut name = format!("{} ({})", mb.name, k + 1);
|
||
while st.body(&name).is_some() {
|
||
name.push('\'');
|
||
}
|
||
st.bodies.push(ModelBody::traced_new(name, b, f.id));
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::Pattern { features, pattern, bodies } => {
|
||
let mats = pattern_transforms(doc, vals, st, f.component, pattern)?;
|
||
if bodies.is_empty() {
|
||
return replay(doc, vals, f, st, features, &mats, warning);
|
||
}
|
||
// Bodies: each copy is a new body named after its source.
|
||
let mut output = 0;
|
||
for n in bodies {
|
||
let mb = st.body(n).cloned().ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||
for (k, m) in mats.iter().enumerate() {
|
||
let copy = moved_copy(&mb, kernel::transform_matrix(&mb.body, *m)?, k + 1);
|
||
let name = copy_body_name(st, f, output, &mb.name);
|
||
st.bodies.push(ModelBody::traced_new(name, copy, f.id));
|
||
output += 1;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::Mirror { features, plane, bodies, combine } => {
|
||
let pl = doc.resolve_plane_in(vals, plane, 0, Some(st))?;
|
||
if bodies.is_empty() {
|
||
return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)], warning);
|
||
}
|
||
let m = mirror_matrix(&pl);
|
||
let mut output = 0;
|
||
for n in bodies {
|
||
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||
let Some(mb) = st.bodies.get(i).cloned() else { continue };
|
||
let mirrored = moved_copy(&mb, kernel::transform_matrix(&mb.body, m)?, 1);
|
||
if *combine {
|
||
let joined = kernel::boolean(&mb.body, &mirrored.body, BoolOp::Union)?
|
||
.ok_or_else(|| DocError::Invalid("the mirror join produced nothing".into()))?;
|
||
let joined = Traced::step(joined, &[&mb.traced(), &mirrored]);
|
||
let pieces = joined.body.lumps()?;
|
||
let lineages = crate::lineage::Lineage::lumps(&joined.body, joined.lineage.as_ref(), &pieces);
|
||
for (k, (p, lineage)) in pieces.into_iter().zip(lineages).enumerate() {
|
||
if k == 0 {
|
||
if let Some(slot) = st.bodies.get_mut(i) {
|
||
*slot = ModelBody::new(mb.name.clone(), p, mb.feature).with_lineage(lineage);
|
||
}
|
||
} else {
|
||
let name = copy_body_name(st, f, output, &mb.name);
|
||
st.bodies.push(ModelBody::new(name, p, f.id).with_lineage(lineage));
|
||
output += 1;
|
||
}
|
||
}
|
||
} else {
|
||
let name = copy_body_name(st, f, output, &mb.name);
|
||
st.bodies.push(ModelBody::traced_new(name, mirrored, f.id));
|
||
output += 1;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
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 edges = if f.edge_names.iter().any(|n| !n.is_empty()) {
|
||
// By name first; a name that is gone falls back to the point, with a warning.
|
||
let (mut out, mut notes) = (Vec::new(), Vec::new());
|
||
for (i, p) in edges.iter().enumerate() {
|
||
match f.edge_names.get(i).filter(|n| !n.is_empty()).map(|n| (n, crate::naming::locate_edge(mb, n, *p))) {
|
||
Some((n, Ok(hit))) => {
|
||
if let Some(note) = hit.note(n) {
|
||
notes.push(note);
|
||
}
|
||
out.push(hit.point);
|
||
}
|
||
Some((_, Err(_))) | None => {
|
||
let mut w = None;
|
||
let refs: Vec<EdgeRef> = f.edge_refs.get(i).cloned().into_iter().collect();
|
||
if let Ok(q) = resolve_edges(&mb.body, &[*p], &refs, &mut w) {
|
||
out.extend(q);
|
||
notes.push(match f.edge_names.get(i) {
|
||
Some(n) if !n.is_empty() => format!("edge `{n}` no longer exists; using the edge at the picked point"),
|
||
_ => "an edge reference without a name was found by its point".into(),
|
||
});
|
||
} else {
|
||
notes.push(format!(
|
||
"edge `{}` no longer exists and was skipped",
|
||
f.edge_names.get(i).map(String::as_str).unwrap_or("?")
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if out.is_empty() {
|
||
return Err(DocError::Invalid("none of the selected edges exist any more".into()));
|
||
}
|
||
if !notes.is_empty() {
|
||
*warning = Some(notes.join("; "));
|
||
}
|
||
out
|
||
} else {
|
||
resolve_edges(&mb.body, edges, &f.edge_refs, warning)?
|
||
};
|
||
st.blend_edges.insert(f.id, edges.clone());
|
||
let input = mb.traced();
|
||
// The blended body, and the lineage of what the last blend started from.
|
||
let (nb, from) = match &f.kind {
|
||
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => (kernel::fillet_chord(&mb.body, &edges, r)?, input),
|
||
FeatureKind::Fillet { style: crate::FilletStyle::Variable { radius2, start, mid, smooth }, .. } => {
|
||
let r2 = val(vals, radius2, Kind::Length)?;
|
||
let mut pts = vec![(0.0, r), (1.0, r2)];
|
||
for (t, e) in mid {
|
||
pts.push((*t, val(vals, e, Kind::Length)?));
|
||
}
|
||
// One edge at a time: each blend's lineage over the one before.
|
||
let mut b = input;
|
||
let mut from = None;
|
||
for p in &edges {
|
||
let next = Traced::step(kernel::fillet_variable_points(&b.body, *p, *start, &pts, *smooth)?, &[&b]);
|
||
from = Some(std::mem::replace(&mut b, next));
|
||
}
|
||
match from {
|
||
Some(prev) => (b.body, prev),
|
||
None => return Err(DocError::Invalid("no edges to fillet".into())),
|
||
}
|
||
}
|
||
FeatureKind::Fillet { fit, .. } => {
|
||
let (b, used) = fillet_fitted(&mb.body, &edges, r, *fit)?;
|
||
if let Some(used) = used {
|
||
st.fitted.insert(f.id, used);
|
||
append_warning(warning, format!("the radius is reduced to {} mm, the largest that fits these edges", show_mm(used)));
|
||
}
|
||
(b, input)
|
||
}
|
||
FeatureKind::Chamfer { distance2, angle, flip, .. } => (chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?, input),
|
||
_ => return Err(DocError::Invalid("blend".into())),
|
||
};
|
||
// Keeping the input's planar certificates may join faces; their history stays over
|
||
// the same operand.
|
||
let nb = Traced::step(nb.with_preserved_planar_sources(&mb.body)?, &[&from]);
|
||
let (name, feat) = (mb.name.clone(), mb.feature);
|
||
if let Some(slot) = st.bodies.get_mut(ti) {
|
||
*slot = ModelBody::traced_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 corner = point(vals, corner)?;
|
||
let b = kernel::box_solid(corner, corner + s)?;
|
||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_box_certificate(f.id)? } else { b };
|
||
apply_traced(st, f, vec![crate::lineage::box_tool(b)], *operation, &[])
|
||
}
|
||
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
||
let b = kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
|
||
let b = if matches!(operation, Operation::NewBody | Operation::Join) { b.with_cylinder_certificate(f.id)? } else { b };
|
||
apply_traced(st, f, vec![crate::lineage::cylinder_tool(b)], *operation, &[])
|
||
}
|
||
FeatureKind::Sphere { center, radius, operation } => {
|
||
let b = kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?;
|
||
apply_op(st, f, vec![b], *operation, &[])
|
||
}
|
||
FeatureKind::Torus { center, major, minor, operation } => {
|
||
let b = kernel::torus(point(vals, 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}`")))?.traced());
|
||
}
|
||
if st.body(target).is_none() {
|
||
return Err(DocError::Unknown(format!("body `{target}`")));
|
||
}
|
||
let op = if *operation == Operation::NewBody { Operation::Join } else { *operation };
|
||
apply_traced(st, f, tb, op, std::slice::from_ref(target))?;
|
||
if !keep_tools {
|
||
st.bodies.retain(|b| !tools.contains(&b.name));
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::Scale { bodies, origin, factor, factors } => {
|
||
let k = match factors {
|
||
Some(fs) => [val(vals, &fs[0], Kind::Unitless)?, val(vals, &fs[1], Kind::Unitless)?, val(vals, &fs[2], Kind::Unitless)?],
|
||
None => {
|
||
let x = val(vals, factor, Kind::Unitless)?;
|
||
[x, x, x]
|
||
}
|
||
};
|
||
if k.iter().any(|x| !(x.is_finite() && *x > 1e-9 && *x < 1e6)) {
|
||
return Err(DocError::Invalid("scale factors must be positive".into()));
|
||
}
|
||
let o = *origin;
|
||
// Column-major: scale about the origin.
|
||
let m = [
|
||
[k[0], 0.0, 0.0, 0.0],
|
||
[0.0, k[1], 0.0, 0.0],
|
||
[0.0, 0.0, k[2], 0.0],
|
||
[o.x * (1.0 - k[0]), o.y * (1.0 - k[1]), o.z * (1.0 - k[2]), 1.0],
|
||
];
|
||
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 = Traced::step(kernel::transform_matrix(&mb.body, m)?, &[&mb.traced()]);
|
||
*mb = ModelBody::traced_new(mb.name.clone(), nb, mb.feature);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::OffsetFace { faces, distance, body } => {
|
||
let d = val(vals, distance, Kind::Length)?;
|
||
let i = body_at(st, body, faces)?;
|
||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||
let nb = Traced::step(kernel::offset_faces(&mb.body, faces, d)?, &[&mb.traced()]);
|
||
if let Some(slot) = st.bodies.get_mut(i) {
|
||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::DeleteFace { faces, body } => {
|
||
let i = body_at(st, body, faces)?;
|
||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||
let mut picked: Vec<usize> = Vec::new();
|
||
for p in faces {
|
||
let fi = crate::appearance::face_index_at(&mb, *p)
|
||
.ok_or_else(|| DocError::Invalid(format!("Delete Face: no face of {} at {p:?}", mb.name)))?;
|
||
if !picked.contains(&fi) {
|
||
picked.push(fi);
|
||
}
|
||
}
|
||
let all = kernel::with_same_surface(&mb.body, &picked);
|
||
let nb = Traced::step(kernel::delete_faces(&mb.body, &all)?, &[&mb.traced()]);
|
||
if let Some(slot) = st.bodies.get_mut(i) {
|
||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::BoundingSolid { bodies, margin } => {
|
||
let m = val(vals, margin, Kind::Length)?;
|
||
let mut bb = solidcraft_geom::Aabb3::EMPTY;
|
||
for b in st.bodies.iter().filter(|b| bodies.is_empty() || bodies.contains(&b.name)) {
|
||
bb = bb.union(&b.mesh().bounds());
|
||
}
|
||
if bb.is_empty() {
|
||
return Err(DocError::Invalid("no bodies to bound".into()));
|
||
}
|
||
let pad = Vec3::new(m, m, m);
|
||
let b = crate::lineage::numbered_tool(kernel::box_solid(bb.min - pad, bb.max + pad)?);
|
||
let name = unique_body_name(st, f.body_names.first().map(String::as_str).unwrap_or("Bounding"));
|
||
st.bodies.push(ModelBody::traced_new(name, b, f.id));
|
||
Ok(())
|
||
}
|
||
FeatureKind::Move { bodies, translate, rotate_axis, angle, copy } => {
|
||
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);
|
||
// With `copy` set, the source body is kept in place and the transform lands on a new
|
||
// body pushed beside it instead of replacing it.
|
||
let mut output = 0;
|
||
for n in bodies {
|
||
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||
let Some(mb) = st.bodies.get(i).cloned() else { continue };
|
||
let nb = kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?;
|
||
if *copy {
|
||
let traced = moved_copy(&mb, nb, 1);
|
||
let name = copy_body_name(st, f, output, &mb.name);
|
||
st.bodies.push(ModelBody::traced_new(name, traced, f.id));
|
||
output += 1;
|
||
} else if let Some(slot) = st.bodies.get_mut(i) {
|
||
*slot = ModelBody::traced_new(mb.name.clone(), Traced::step(nb, &[&mb.traced()]), mb.feature);
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::MeshConvert { body, tolerance } => {
|
||
let tolerance = val(vals, tolerance, Kind::Length)?;
|
||
let index = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||
let source = st.bodies.get(index).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||
if doc.model_body_component(source) != f.component {
|
||
return Err(DocError::Invalid("activate the mesh body's component before converting it".into()));
|
||
}
|
||
let converted = kernel::convert_prismatic_mesh(&source.body, tolerance)?;
|
||
let converted = converted.with_color(source.body.color());
|
||
let result = ModelBody::new(source.name.clone(), converted, f.id);
|
||
let target = st.bodies.get_mut(index).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||
*target = result;
|
||
Ok(())
|
||
}
|
||
FeatureKind::MeshImport { meshes, .. } => {
|
||
for (k, (name, b)) in mesh_bodies(meshes)?.into_iter().enumerate() {
|
||
let name = copy_body_name(st, f, k, &name);
|
||
st.bodies.push(ModelBody::new(name, b, f.id));
|
||
}
|
||
Ok(())
|
||
}
|
||
FeatureKind::Import { step, .. } => {
|
||
let imp = kernel::step_import_shared(step)?;
|
||
let digest = crate::config::payload_digest(step);
|
||
for (k, b) in imp.bodies.iter().enumerate() {
|
||
let base = f.body_names.get(k).filter(|n| !n.trim().is_empty()).unwrap_or(&b.name);
|
||
let name = unique_body_name(st, base);
|
||
let b = crate::lineage::numbered_tool(b.body.clone().with_import_certificate(&digest, f.id)?);
|
||
st.bodies.push(ModelBody::traced_new(name, b, f.id));
|
||
}
|
||
if !imp.warnings.is_empty() {
|
||
let n = imp.warnings.len();
|
||
let shown: Vec<&str> = imp.warnings.iter().take(3).map(String::as_str).collect();
|
||
let more = if n > 3 { format!(" (+{} more)", n - 3) } else { String::new() };
|
||
*warning = Some(format!("{}{more}", shown.join("; ")));
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `copy`, a moved copy of `source` (instance `instance` of a pattern or mirror): its faces are
|
||
/// named after the source's.
|
||
fn moved_copy(source: &ModelBody, copy: Body, instance: usize) -> Traced {
|
||
Traced::step(copy, &[&source.before_as(crate::lineage::Operand::Copy { body: source.name.clone(), instance })])
|
||
}
|
||
|
||
/// Mesh bodies of a MeshImport feature.
|
||
fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
|
||
let mut out = Vec::with_capacity(meshes.len());
|
||
for m in meshes {
|
||
let positions: Vec<Vec3> = m.positions.as_chunks::<3>().0.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect();
|
||
let triangles: Vec<[u32; 3]> = m.triangles.as_chunks::<3>().0.to_vec();
|
||
let b = kernel::mesh_body(&positions, &triangles).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
|
||
out.push((m.name.clone(), b.with_color(m.color)));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// `base`, or `base (2)`, `base (3)`… if a body already has that name.
|
||
fn unique_body_name(st: &ModelState, base: &str) -> String {
|
||
let base = if base.trim().is_empty() { "Body" } else { base.trim() };
|
||
if st.body(base).is_none() {
|
||
return base.to_string();
|
||
}
|
||
(2..).map(|i| format!("{base} ({i})")).find(|n| st.body(n).is_none()).unwrap_or_else(|| base.to_string())
|
||
}
|
||
|
||
/// The model as a component sees it: bodies and sketches of components placed differently from
|
||
/// it are moved into its frame (through both placements), so a sketch on another component's
|
||
/// face finds the face where it is shown and projects its edges there. The model itself when
|
||
/// every placement agrees (the usual case).
|
||
pub fn state_in_frame<'a>(doc: &Document, st: &'a ModelState, comp: u64) -> std::borrow::Cow<'a, ModelState> {
|
||
state_in_frame_at(doc, st, comp, doc.component_transform(comp))
|
||
}
|
||
|
||
/// As `state_in_frame`, using the exact active placement for its shared source geometry.
|
||
pub fn state_in_frame_at<'a>(doc: &Document, st: &'a ModelState, comp: u64, frame: Mat) -> std::borrow::Cow<'a, ModelState> {
|
||
use std::borrow::Cow;
|
||
if doc.occurrences.is_empty() && doc.body_offsets.is_empty() {
|
||
return Cow::Borrowed(st);
|
||
}
|
||
let Some(inv) = crate::mat_inverse(&frame) else { return Cow::Borrowed(st) };
|
||
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||
let rel = |c: u64, body: Option<&str>| {
|
||
let m = crate::mat_mul(&inv, &if c == comp { frame } else { doc.component_transform(c) });
|
||
match body.and_then(|b| doc.body_offsets.get(b)) {
|
||
Some(o) => crate::mat_mul(&m, o),
|
||
None => m,
|
||
}
|
||
};
|
||
let body_m: Vec<Mat> = st
|
||
.bodies
|
||
.iter()
|
||
.map(|b| {
|
||
let m = rel(doc.model_body_component(b), None);
|
||
doc.model_body_offset(b).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m)
|
||
})
|
||
.collect();
|
||
let sketch_m: Vec<Mat> = st.sketches.iter().map(|s| rel(comp_of_feature(s.feature), None)).collect();
|
||
let world_wires_need_mapping = !crate::is_identity(&inv)
|
||
&& st.sketches.iter().any(|sketch| sketch.sketch.wires.iter().any(|wire| wire.space == solidcraft_sketch::WireSpace::World));
|
||
if body_m.iter().chain(&sketch_m).all(crate::is_identity) && !world_wires_need_mapping {
|
||
return Cow::Borrowed(st);
|
||
}
|
||
let mut out = st.clone();
|
||
for (b, m) in out.bodies.iter_mut().zip(&body_m) {
|
||
if !crate::is_identity(m)
|
||
&& let Ok(t) = kernel::transform_matrix(&b.body, *m)
|
||
{
|
||
// Moving a body keeps its faces (and their order): it keeps their names.
|
||
let names = crate::naming::NameCell::same(b);
|
||
let mut nb = ModelBody::new(b.name.clone(), t, b.feature);
|
||
nb.component = b.component;
|
||
nb.transferred = b.transferred;
|
||
nb.source_name = b.source_name.clone();
|
||
nb.source_transform = crate::mat_mul(m, &b.source_transform);
|
||
nb.imported = b.imported.clone();
|
||
nb.names = names;
|
||
*b = nb;
|
||
}
|
||
}
|
||
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
|
||
// Only a sketch the frame moves is copied.
|
||
let maps_wire = |w: &solidcraft_sketch::Wire| !crate::is_identity(if w.space == solidcraft_sketch::WireSpace::Component { m } else { &inv });
|
||
if crate::is_identity(m) && !s.sketch.wires.iter().any(maps_wire) {
|
||
continue;
|
||
}
|
||
let s = Arc::make_mut(s);
|
||
if !crate::is_identity(m)
|
||
&& let Some(np) = Plane::new(crate::apply_point(m, s.plane.origin), crate::apply_vector(m, s.plane.x), crate::apply_vector(m, s.plane.y))
|
||
{
|
||
s.plane = np;
|
||
}
|
||
for wire in &mut s.sketch.wires {
|
||
let matrix = if wire.space == solidcraft_sketch::WireSpace::Component { m } else { &inv };
|
||
map_wire(wire, matrix);
|
||
}
|
||
}
|
||
Cow::Owned(out)
|
||
}
|
||
|
||
/// The model placed in the world: each component's bodies, sketches and threads through every
|
||
/// occurrence of it (bodies of the first placement keep their names; further instances get the
|
||
/// occurrence path in brackets). Without components this is the model itself.
|
||
pub fn world_state(doc: &Document, st: &ModelState) -> ModelState {
|
||
if doc.occurrences.is_empty() {
|
||
return st.clone();
|
||
}
|
||
let comp_of_feature = |id: u64| doc.feature(id).map(|f| f.component).unwrap_or(0);
|
||
let first_path = |comp: u64| -> Vec<u64> {
|
||
let mut path = Vec::new();
|
||
let mut c = comp;
|
||
for _ in 0..1000 {
|
||
if c == 0 {
|
||
break;
|
||
}
|
||
let Some(o) = doc.occurrence_of(c) else { break };
|
||
path.insert(0, o.id);
|
||
c = o.parent;
|
||
}
|
||
path
|
||
};
|
||
let mut out = ModelState { body_counter: st.body_counter, ..Default::default() };
|
||
for (comp, path, m) in doc.placements() {
|
||
let primary = path == first_path(comp);
|
||
let ident = crate::is_identity(&m);
|
||
for b in st.bodies.iter().filter(|b| doc.model_body_component(b) == comp) {
|
||
let name = doc.world_body_name(st, &b.name, comp, &path);
|
||
// A body moved in from another component keeps its place (its offset).
|
||
let bm = doc.model_body_offset(b).map(|o| crate::mat_mul(&m, o)).unwrap_or(m);
|
||
// Identical geometry also shares its display mesh cell with the definition.
|
||
// A changed placement keeps a distinct mesh, at that placement's coordinates.
|
||
let mut placed = if crate::is_identity(&bm) {
|
||
let mut placed = b.clone();
|
||
placed.name = name;
|
||
placed
|
||
} else {
|
||
let body = kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone());
|
||
ModelBody::new(name, body, b.feature)
|
||
};
|
||
// World states keep their previous display-only evaluation-key semantics.
|
||
placed.key = 0;
|
||
placed.source_name = Some(b.definition_name().to_string());
|
||
placed.source_transform = crate::mat_mul(&bm, &b.source_transform);
|
||
placed.component = comp;
|
||
placed.transferred = b.transferred;
|
||
placed.imported = b.imported.clone();
|
||
// Placement preserves topology: face ownership remains that of the
|
||
// source model, including modifiers on component instances.
|
||
placed.names = crate::naming::NameCell::same(b);
|
||
out.bodies.push(placed);
|
||
}
|
||
if primary {
|
||
for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) {
|
||
if ident {
|
||
// In place: shared as it is.
|
||
out.sketches.push(s.clone());
|
||
continue;
|
||
}
|
||
let mut s2 = SolvedSketch::clone(s);
|
||
if !ident {
|
||
let p = &s.plane;
|
||
if let Some(np) = Plane::new(crate::apply_point(&m, p.origin), crate::apply_vector(&m, p.x), crate::apply_vector(&m, p.y)) {
|
||
s2.plane = np;
|
||
}
|
||
}
|
||
for wire in &mut s2.sketch.wires {
|
||
if wire.space == solidcraft_sketch::WireSpace::Component {
|
||
map_wire(wire, &m);
|
||
}
|
||
}
|
||
out.sketches.push(Arc::new(s2));
|
||
}
|
||
for t in st
|
||
.threads
|
||
.iter()
|
||
.filter(|t| st.body(&t.body).map(|body| doc.model_body_component(body)).unwrap_or_else(|| comp_of_feature(t.feature)) == comp)
|
||
{
|
||
let mut t2 = t.clone();
|
||
let frame = st.body(&t.body).and_then(|body| doc.model_body_offset(body)).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
|
||
t2.axis_point = crate::apply_point(&frame, t.axis_point);
|
||
t2.axis = crate::apply_vector(&frame, t.axis);
|
||
out.threads.push(t2);
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod world_placement_cache_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn placed_real_feature_retains_names_from_its_original_feature_context() {
|
||
let mut doc = Document::new("authored names");
|
||
let component = doc.add_component(Some("Part"), 0).unwrap();
|
||
let feature = doc
|
||
.add_feature(
|
||
FeatureKind::Box {
|
||
corner: crate::point_expr(Vec3::ZERO),
|
||
length: "2 mm".into(),
|
||
width: "3 mm".into(),
|
||
height: "4 mm".into(),
|
||
operation: crate::Operation::NewBody,
|
||
},
|
||
Some("AuthoredBox"),
|
||
)
|
||
.unwrap();
|
||
doc.feature_mut(feature).unwrap().component = component;
|
||
let matrix = crate::rigid(Vec3::new(15., -2., 8.), Vec3::ZERO, Vec3::X, 0.7);
|
||
doc.occurrences.iter_mut().find(|o| o.component == component).unwrap().transform = matrix;
|
||
let mut model = Model::default();
|
||
model.evaluate(&doc);
|
||
let state = model.state();
|
||
let source = &state.bodies[0];
|
||
assert!(source.names.origin.is_some());
|
||
assert!(source.mesh.get().is_none());
|
||
let world = world_state(&doc, &state);
|
||
let names = crate::naming::face_names(&world.bodies[0]);
|
||
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(source)));
|
||
assert!(names.iter().all(|name| name.starts_with(&format!("F{feature}:"))));
|
||
let local = source.mesh();
|
||
let placed = world.bodies[0].mesh();
|
||
assert_eq!(local.tri_face, placed.tri_face);
|
||
let mapped: Vec<_> = local.positions.iter().map(|p| crate::apply_point(&matrix, *p)).collect();
|
||
assert!(mapped.iter().all(|p| placed.positions.iter().any(|q| p.dist(*q) < 1e-8)));
|
||
assert!(placed.positions.iter().all(|p| mapped.iter().any(|q| p.dist(*q) < 1e-8)));
|
||
}
|
||
|
||
#[test]
|
||
fn identity_shares_mesh_but_rotated_instances_keep_distinct_physical_coordinates_and_source_names() {
|
||
let mut doc = Document::new("placements");
|
||
let component = doc.add_component(Some("Part"), 0).unwrap();
|
||
doc.body_components.insert("Block".into(), component);
|
||
let matrix = crate::rigid(Vec3::new(10., 20., 0.), Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2);
|
||
let second = doc.add_occurrence(component, 0, matrix).unwrap();
|
||
let mut state = ModelState::default();
|
||
state.bodies.push(ModelBody::new("Block".into(), kernel::box_solid(Vec3::ZERO, Vec3::new(2., 3., 4.)).unwrap(), 1));
|
||
state.bodies[0].key = 1234;
|
||
let source = &state.bodies[0];
|
||
let world = world_state(&doc, &state);
|
||
assert_eq!(world.bodies.len(), 2);
|
||
let first = world.body("Block").unwrap();
|
||
let second_name = doc.world_body_name(&state, "Block", component, &[second]);
|
||
let placed = world.body(&second_name).unwrap();
|
||
assert!(Arc::ptr_eq(&source.mesh, &first.mesh), "unchanged geometry must retain the shared display mesh cell");
|
||
assert_eq!(first.key, 0);
|
||
assert_eq!(placed.key, 0);
|
||
assert!(!Arc::ptr_eq(&source.mesh, &placed.mesh), "a transformed mesh cannot be reused without mapping coordinates");
|
||
assert_eq!(first.source_name.as_deref(), Some("Block"));
|
||
assert_eq!(placed.source_name.as_deref(), Some("Block"));
|
||
assert_eq!(placed.source_transform, matrix);
|
||
assert_eq!(doc.model_body_component(placed), component);
|
||
let bounds = placed.mesh().bounds();
|
||
assert!(bounds.min.dist(Vec3::new(7., 20., 0.)) < 1e-9);
|
||
assert!(bounds.max.dist(Vec3::new(10., 22., 4.)) < 1e-9);
|
||
let names = crate::naming::face_names(source);
|
||
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(first)));
|
||
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(placed)));
|
||
let original_mesh = source.mesh();
|
||
assert!(Arc::ptr_eq(&original_mesh, &first.mesh()));
|
||
let again = world_state(&doc, &state);
|
||
assert!(Arc::ptr_eq(&original_mesh, &again.body("Block").unwrap().mesh()));
|
||
assert!(Arc::ptr_eq(&names, &crate::naming::face_names(again.body(&second_name).unwrap())));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod chain_tests {
|
||
use super::*;
|
||
|
||
/// The top face of a box with one rounded top edge chains to the round and on to the side
|
||
/// it rolls into; the other sides meet at sharp edges and stay out.
|
||
#[test]
|
||
fn tangent_chain_follows_smooth_edges() {
|
||
let mut doc = Document::new("T");
|
||
doc.add_feature(
|
||
FeatureKind::Box {
|
||
corner: crate::point_expr(Vec3::ZERO),
|
||
length: "40".into(),
|
||
width: "30".into(),
|
||
height: "20".into(),
|
||
operation: Operation::NewBody,
|
||
},
|
||
None,
|
||
)
|
||
.unwrap();
|
||
doc.add_feature(
|
||
FeatureKind::Fillet {
|
||
edges: vec![Vec3::new(20.0, 0.0, 20.0)],
|
||
radius: "4".into(),
|
||
body: None,
|
||
style: crate::FilletStyle::Constant,
|
||
fit: Default::default(),
|
||
},
|
||
None,
|
||
)
|
||
.unwrap();
|
||
let mut m = Model::new();
|
||
m.evaluate(&doc);
|
||
let st = m.state();
|
||
let b = &st.bodies[0];
|
||
let pts = smooth_chain(b, &[Vec3::new(20.0, 15.0, 20.0)]);
|
||
let faces: std::collections::BTreeSet<usize> = pts.iter().filter_map(|p| crate::appearance::face_index_at(b, *p)).collect();
|
||
assert_eq!(faces.len(), 3, "{faces:?}");
|
||
}
|
||
}
|