Import base feature (FeatureKind::Import, timeline type BaseFeature) holding the STEP text and the file's product tree; bodies named from the file. doc.open accepts .step/.stp (any case) as a new design; Insert STEP (FusionImportCommandFromToolbar) adds one to the current design; File menu, SOLID > INSERT, drag-and-drop (insert into a non-empty design, else open), 'solvecraft part.step', MCP open. Features (fillet, hole, sketch-on-face cut, shell) work on imported bodies. Imports are cached so a file is read once per command. Measure tightens its chord tolerance for small radii. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
1129 lines
49 KiB
Rust
1129 lines
49 KiB
Rust
//! Timeline evaluation.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::hash::{Hash, Hasher};
|
|
use std::sync::{Arc, OnceLock};
|
|
|
|
use serde::Serialize;
|
|
use solvecraft_geom::{Mesh, Plane, Region2, Vec2, Vec3};
|
|
use solvecraft_kernel::{self as kernel, Body, BoolOp};
|
|
use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve};
|
|
|
|
use crate::expr::{self, Kind, Value};
|
|
use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operation, ProfileSel, Result};
|
|
|
|
/// A body in the evaluated model.
|
|
#[derive(Clone, Debug)]
|
|
pub struct ModelBody {
|
|
pub name: String,
|
|
pub body: Body,
|
|
/// Feature that created it.
|
|
pub feature: u64,
|
|
mesh: OnceLock<Arc<Mesh>>,
|
|
}
|
|
|
|
impl ModelBody {
|
|
pub fn new(name: String, body: Body, feature: u64) -> Self {
|
|
ModelBody { name, body, feature, mesh: OnceLock::new() }
|
|
}
|
|
/// Display mesh (chord tolerance 1/1000 of the body size), computed once.
|
|
pub fn mesh(&self) -> Arc<Mesh> {
|
|
self.mesh
|
|
.get_or_init(|| {
|
|
let tol = (self.body.size() * 1e-3).max(1e-3);
|
|
Arc::new(self.body.display_mesh(tol).unwrap_or_default())
|
|
})
|
|
.clone()
|
|
}
|
|
}
|
|
|
|
/// A solved sketch in the evaluated model.
|
|
#[derive(Clone, Debug)]
|
|
pub struct SolvedSketch {
|
|
pub feature: u64,
|
|
pub name: String,
|
|
pub plane: Plane,
|
|
pub sketch: solvecraft_sketch::Sketch,
|
|
pub report: SolveReport,
|
|
pub profiles: Vec<Profile>,
|
|
}
|
|
|
|
/// Model after some prefix of the timeline.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct ModelState {
|
|
pub bodies: Vec<ModelBody>,
|
|
pub sketches: Vec<SolvedSketch>,
|
|
pub body_counter: usize,
|
|
}
|
|
|
|
impl ModelState {
|
|
pub fn body(&self, name: &str) -> Option<&ModelBody> {
|
|
self.bodies.iter().find(|b| b.name == name)
|
|
}
|
|
pub fn sketch(&self, feature: u64) -> Option<&SolvedSketch> {
|
|
self.sketches.iter().find(|s| s.feature == feature)
|
|
}
|
|
}
|
|
|
|
/// Evaluation result of one feature.
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct FeatureResult {
|
|
pub id: u64,
|
|
pub name: String,
|
|
pub error: Option<String>,
|
|
pub warning: Option<String>,
|
|
pub ms: f64,
|
|
pub skipped: bool,
|
|
#[serde(skip)]
|
|
fingerprint: u64,
|
|
#[serde(skip)]
|
|
pub state: Arc<ModelState>,
|
|
}
|
|
|
|
/// Incremental evaluator: holds the state after every feature.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Model {
|
|
pub results: Vec<FeatureResult>,
|
|
pub param_errors: BTreeMap<String, String>,
|
|
empty: Arc<ModelState>,
|
|
/// Features recomputed by the last `evaluate`.
|
|
pub last_recomputed: usize,
|
|
}
|
|
|
|
fn fingerprint(prev: u64, f: &Feature, vals: &BTreeMap<String, Value>, rolled_back: bool) -> u64 {
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
prev.hash(&mut h);
|
|
rolled_back.hash(&mut h);
|
|
serde_json::to_string(f).unwrap_or_default().hash(&mut h);
|
|
let mut names: Vec<String> = f.kind.expressions().iter().flat_map(|e| expr::references(e)).collect();
|
|
if let FeatureKind::Sketch { sketch, .. } = &f.kind {
|
|
names.extend(sketch.constraints.iter().filter_map(|c| c.param.clone()));
|
|
}
|
|
names.sort();
|
|
names.dedup();
|
|
for n in names {
|
|
n.hash(&mut h);
|
|
match vals.get(&n) {
|
|
Some(v) => {
|
|
v.v.to_bits().hash(&mut h);
|
|
v.len.hash(&mut h);
|
|
v.ang.hash(&mut h);
|
|
}
|
|
None => 0u8.hash(&mut h),
|
|
}
|
|
}
|
|
h.finish()
|
|
}
|
|
|
|
fn now() -> 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)
|
|
}
|
|
|
|
/// Re-evaluate the document, reusing results up to the first changed feature.
|
|
pub fn evaluate(&mut self, doc: &Document) {
|
|
let (vals, perr) = doc.param_values();
|
|
self.param_errors = perr;
|
|
let mut prev_fp = 0u64;
|
|
let mut state = self.empty.clone();
|
|
let mut out: Vec<FeatureResult> = Vec::with_capacity(doc.features.len());
|
|
let mut reuse = true;
|
|
let mut recomputed = 0;
|
|
let marker = doc.marker.unwrap_or(usize::MAX);
|
|
for (i, f) in doc.features.iter().enumerate() {
|
|
let rolled_back = i >= marker;
|
|
let fp = fingerprint(prev_fp, f, &vals, rolled_back);
|
|
prev_fp = fp;
|
|
if reuse
|
|
&& let Some(old) = self.results.get(i)
|
|
&& old.id == f.id
|
|
&& old.fingerprint == fp
|
|
{
|
|
if !old.skipped {
|
|
state = old.state.clone();
|
|
}
|
|
out.push(old.clone());
|
|
continue;
|
|
}
|
|
reuse = false;
|
|
if f.suppressed || rolled_back {
|
|
out.push(FeatureResult {
|
|
id: f.id,
|
|
name: f.name.clone(),
|
|
error: None,
|
|
warning: None,
|
|
ms: 0.0,
|
|
skipped: true,
|
|
fingerprint: fp,
|
|
state: state.clone(),
|
|
});
|
|
continue;
|
|
}
|
|
recomputed += 1;
|
|
let t0 = now();
|
|
let mut next = (*state).clone();
|
|
let mut warning = None;
|
|
let r = kernel::guard(&f.name, || {
|
|
eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string()))
|
|
});
|
|
let ms = t0.elapsed().as_secs_f64() * 1000.0;
|
|
let error = match r {
|
|
Ok(()) => {
|
|
state = Arc::new(next);
|
|
None
|
|
}
|
|
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
|
|
};
|
|
out.push(FeatureResult { id: f.id, name: f.name.clone(), error, warning, ms, skipped: false, fingerprint: fp, state: state.clone() });
|
|
}
|
|
self.results = out;
|
|
self.last_recomputed = recomputed;
|
|
}
|
|
}
|
|
|
|
fn val(vals: &BTreeMap<String, Value>, e: &str, k: Kind) -> Result<f64> {
|
|
Document::eval_in(vals, e, k)
|
|
}
|
|
|
|
fn select_profiles(ss: &SolvedSketch, sel: &ProfileSel) -> Result<Vec<Region2>> {
|
|
let ps = &ss.profiles;
|
|
if ps.is_empty() {
|
|
return Err(DocError::Invalid(format!("{} has no closed profiles", ss.name)));
|
|
}
|
|
let out: Vec<Region2> = match sel {
|
|
ProfileSel::All => ps.iter().map(|p| p.region.clone()).collect(),
|
|
ProfileSel::Indices { indices } => {
|
|
let mut v = Vec::new();
|
|
for i in indices {
|
|
v.push(ps.get(*i).ok_or_else(|| DocError::Unknown(format!("profile {i} of {}", ss.name)))?.region.clone());
|
|
}
|
|
v
|
|
}
|
|
ProfileSel::Curves { loops } => {
|
|
let mut v = Vec::new();
|
|
for l in loops {
|
|
let mut want: Vec<&String> = l.iter().collect();
|
|
want.sort();
|
|
let found = ps.iter().find(|p| {
|
|
let mut have: Vec<&String> = p.outer_curves.iter().collect();
|
|
have.sort();
|
|
have == want
|
|
});
|
|
v.push(found.ok_or_else(|| DocError::Unknown(format!("profile bounded by {l:?} in {}", ss.name)))?.region.clone());
|
|
}
|
|
v
|
|
}
|
|
ProfileSel::Points { points } => {
|
|
let mut v = Vec::new();
|
|
for p in points {
|
|
// Innermost profile containing the point.
|
|
let found = ps.iter().filter(|q| q.region.contains(*p)).min_by(|a, b| a.area.total_cmp(&b.area));
|
|
v.push(found.ok_or_else(|| DocError::Unknown(format!("profile at {:?} in {}", [p.x, p.y], ss.name)))?.region.clone());
|
|
}
|
|
v
|
|
}
|
|
};
|
|
if out.is_empty() {
|
|
return Err(DocError::Invalid("no profile selected".into()));
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
fn new_name(state: &mut ModelState, f: &Feature, k: usize) -> String {
|
|
if let Some(n) = f.body_names.get(k)
|
|
&& !n.is_empty()
|
|
&& state.body(n).is_none()
|
|
{
|
|
return n.clone();
|
|
}
|
|
loop {
|
|
state.body_counter += 1;
|
|
let n = format!("Body{}", state.body_counter);
|
|
if state.body(&n).is_none() {
|
|
return n;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Add tool bodies to the model according to the operation.
|
|
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation, targets: &[String]) -> Result<()> {
|
|
let target_idx: Vec<usize> = if targets.is_empty() {
|
|
(0..state.bodies.len()).collect()
|
|
} else {
|
|
let mut v = Vec::new();
|
|
for t in targets {
|
|
v.push(state.bodies.iter().position(|b| &b.name == t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?);
|
|
}
|
|
v
|
|
};
|
|
if op == Operation::NewBody || target_idx.is_empty() {
|
|
if op == Operation::Cut || op == Operation::Intersect {
|
|
return Err(DocError::Invalid("there is no body to cut or intersect".into()));
|
|
}
|
|
for (k, b) in tools.into_iter().enumerate() {
|
|
let name = new_name(state, f, k);
|
|
state.bodies.push(ModelBody::new(name, b, f.id));
|
|
}
|
|
return Ok(());
|
|
}
|
|
match op {
|
|
Operation::Join => {
|
|
let Some(&first) = target_idx.first() else { return Ok(()) };
|
|
let Some(mut acc) = state.bodies.get(first).map(|b| b.body.clone()) else { return Ok(()) };
|
|
for t in &tools {
|
|
acc = kernel::boolean(&acc, t, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()))?;
|
|
}
|
|
if let Some(mb) = state.bodies.get_mut(first) {
|
|
*mb = ModelBody::new(mb.name.clone(), acc, mb.feature);
|
|
}
|
|
}
|
|
Operation::Cut | Operation::Intersect => {
|
|
let bop = if op == Operation::Cut { BoolOp::Cut } else { BoolOp::Intersect };
|
|
let mut remove = Vec::new();
|
|
for &ti in &target_idx {
|
|
let Some(mut acc) = state.bodies.get(ti).map(|b| Some(b.body.clone())) else { continue };
|
|
for t in &tools {
|
|
acc = match acc {
|
|
Some(a) => kernel::boolean(&a, t, bop)?,
|
|
None => None,
|
|
};
|
|
}
|
|
match acc {
|
|
Some(b) => {
|
|
if let Some(mb) = state.bodies.get_mut(ti) {
|
|
*mb = ModelBody::new(mb.name.clone(), b, mb.feature);
|
|
}
|
|
}
|
|
None => remove.push(ti),
|
|
}
|
|
}
|
|
let mut i = 0;
|
|
state.bodies.retain(|_| {
|
|
let keep = !remove.contains(&i);
|
|
i += 1;
|
|
keep
|
|
});
|
|
}
|
|
Operation::NewBody => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Distance that reaches through every target body from the sketch plane (either side).
|
|
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 solvecraft_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: solvecraft_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)
|
|
}
|
|
|
|
/// Edge reference points re-found on the body: points on an edge stay; a point that moved off
|
|
/// (an upstream edit) 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], 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 (mut out, mut moved, mut lost) = (Vec::new(), 0, 0);
|
|
for p in pts {
|
|
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(|| DocError::Invalid("there is no body to modify".into()))
|
|
}
|
|
|
|
fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> {
|
|
match axis {
|
|
AxisRef::SketchLine { curve } => {
|
|
let ci = ss.sketch.curve_index(curve).ok_or_else(|| DocError::Unknown(format!("sketch line `{curve}`")))?;
|
|
match ss.sketch.curves.get(ci).map(|c| &c.kind) {
|
|
Some(CurveKind::Line { a, b }) => {
|
|
let (pa, pb) = (ss.sketch.point(*a).unwrap_or_default(), ss.sketch.point(*b).unwrap_or_default());
|
|
Ok((pa, pb - pa))
|
|
}
|
|
_ => Err(DocError::Invalid(format!("`{curve}` is not a line"))),
|
|
}
|
|
}
|
|
AxisRef::SketchAxis { axis } => match axis.to_ascii_lowercase().as_str() {
|
|
"x" => Ok((Vec2::ZERO, Vec2::X)),
|
|
"y" => Ok((Vec2::ZERO, Vec2::Y)),
|
|
other => Err(DocError::Unknown(format!("sketch axis `{other}`"))),
|
|
},
|
|
AxisRef::World { axis } => {
|
|
let d = match axis.to_ascii_uppercase().as_str() {
|
|
"X" => Vec3::X,
|
|
"Y" => Vec3::Y,
|
|
"Z" => Vec3::Z,
|
|
other => return Err(DocError::Unknown(format!("axis `{other}`"))),
|
|
};
|
|
let pl = &ss.plane;
|
|
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(Vec3::ZERO).abs() > 1e-9 {
|
|
return Err(DocError::Invalid(format!("the {axis} axis does not lie in the sketch plane")));
|
|
}
|
|
Ok((pl.to_local(Vec3::ZERO), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
|
|
}
|
|
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))))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
|
|
fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
|
|
match &f.kind {
|
|
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
|
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
|
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
|
let d = if extent.through_all { through_all_distance(st, &ss.plane, targets)? } else { 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.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
|
|
let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) };
|
|
return Ok(regions
|
|
.iter()
|
|
.map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper))
|
|
.collect::<std::result::Result<Vec<_>, _>>()?);
|
|
}
|
|
let off = match &extent.start_offset {
|
|
Some(e) => val(vals, e, Kind::Length)?,
|
|
None => 0.0,
|
|
};
|
|
let (a, b) = match (&extent.distance2, extent.direction) {
|
|
(Some(d2), _) => (-val(vals, d2, Kind::Length)?, d),
|
|
(None, crate::Direction::Positive) => (0.0, d),
|
|
(None, crate::Direction::Negative) => (-d, 0.0),
|
|
(None, crate::Direction::Symmetric) => (-d, d),
|
|
};
|
|
// A negative distance flips the side.
|
|
let (lo, hi) = if a <= b { (a, b) } else { (b, a) };
|
|
let mut tools = Vec::new();
|
|
for r in ®ions {
|
|
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
|
|
let n = ss.plane.normal();
|
|
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 r = grow_profile_for_coplanar(st, r, *operation, targets, &probe);
|
|
tools.extend(kernel::extrude(&ss.plane, std::slice::from_ref(&r), l2, h2)?);
|
|
}
|
|
Ok(tools)
|
|
}
|
|
FeatureKind::Revolve { sketch, profiles, axis, angle, .. } => {
|
|
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
|
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
|
let (o, d) = revolve_axis(&ss, axis)?;
|
|
let ang = val(vals, angle, Kind::Angle)?;
|
|
let (operation, targets) = feature_op(f);
|
|
let axis_w = ss.plane.dir_to_world(d);
|
|
let origin_w = ss.plane.to_world(o);
|
|
let probe = |p: Vec2| {
|
|
let q = ss.plane.to_world(p);
|
|
(1..4)
|
|
.map(|k| {
|
|
let a = ang * k as f64 / 4.0;
|
|
let v = q - origin_w;
|
|
origin_w + v * a.cos() + axis_w.cross(v) * a.sin() + axis_w * (axis_w.dot(v) * (1.0 - a.cos()))
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
let regions: Vec<Region2> = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect();
|
|
Ok(kernel::revolve(&ss.plane, ®ions, o, d, ang)?)
|
|
}
|
|
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 = solvecraft_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()));
|
|
}
|
|
Ok(vec![kernel::loft(&secs)?])
|
|
}
|
|
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 regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
|
|
let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default();
|
|
let segs = path_segments(ps, path, start)?;
|
|
regions.iter().map(|r| kernel::sweep(&ss.plane, r, &segs).map_err(DocError::from)).collect()
|
|
}
|
|
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)?);
|
|
Ok(vec![kernel::box_solid(*corner, *corner + s)?])
|
|
}
|
|
FeatureKind::Cylinder { base, axis, radius, height, .. } => {
|
|
Ok(vec![kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?])
|
|
}
|
|
FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(*center, val(vals, radius, Kind::Length)?)?]),
|
|
FeatureKind::Torus { center, major, minor, .. } => {
|
|
Ok(vec![kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?])
|
|
}
|
|
FeatureKind::Hole { position, direction, diameter, depth, hole } => {
|
|
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
|
let position = &hole_on_surface(st, *position, dir);
|
|
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: solvecraft_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;
|
|
if depth.is_some() && half > 1e-3 && half < std::f64::consts::FRAC_PI_2 {
|
|
// 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: solvecraft_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)
|
|
}
|
|
_ => 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`.
|
|
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()));
|
|
}
|
|
let mut segs: Vec<solvecraft_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();
|
|
}
|
|
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()
|
|
.map(|s| match *s {
|
|
solvecraft_geom::Seg2::Line { a, b } => kernel::PathSeg::Line { a: w(a), b: w(b) },
|
|
solvecraft_geom::Seg2::Arc { center, sweep, .. } => {
|
|
kernel::PathSeg::Arc { a: w(s.start()), center: w(center), axis: n * sweep.signum(), angle: sweep.abs() }
|
|
}
|
|
})
|
|
.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::Loft { operation, targets, .. } | FeatureKind::Sweep { 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;
|
|
|
|
fn pattern_transforms(vals: &BTreeMap<String, Value>, 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::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)
|
|
}
|
|
|
|
/// 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]) -> Result<()> {
|
|
if features.is_empty() {
|
|
return Err(DocError::Invalid("no features selected".into()));
|
|
}
|
|
for name in features {
|
|
let src = doc.find_feature(name).ok_or_else(|| DocError::Unknown(format!("feature `{name}`")))?;
|
|
let tools = feature_tools(vals, src, st)?;
|
|
let (op, targets) = feature_op(src);
|
|
let mut copies = Vec::new();
|
|
for m in mats {
|
|
for t in &tools {
|
|
copies.push(kernel::transform_matrix(t, *m)?);
|
|
}
|
|
}
|
|
// New bodies from a pattern join their source when they touch it (as one feature would).
|
|
apply_op(st, f, copies, op, &targets)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
|
match &f.kind {
|
|
FeatureKind::Sketch { plane, sketch } => {
|
|
let plane = doc.resolve_plane(vals, plane, 0)?;
|
|
let mut sk = sketch.clone();
|
|
doc.apply_dimension_values(vals, &mut sk)?;
|
|
let report = solve(&mut sk);
|
|
if !report.ok() {
|
|
*warning = Some(format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
|
}
|
|
let profiles = find_profiles(&sk);
|
|
st.sketches.retain(|s| s.feature != f.id);
|
|
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
|
|
Ok(())
|
|
}
|
|
FeatureKind::Extrude { operation, targets, .. }
|
|
| FeatureKind::Revolve { operation, targets, .. }
|
|
| FeatureKind::Loft { operation, targets, .. }
|
|
| FeatureKind::Sweep { operation, targets, .. } => {
|
|
let tools = feature_tools(vals, f, st)?;
|
|
apply_op(st, f, tools, *operation, targets)
|
|
}
|
|
FeatureKind::Hole { .. } => {
|
|
let tools = feature_tools(vals, f, st)?;
|
|
apply_op(st, f, tools, Operation::Cut, &[])
|
|
}
|
|
FeatureKind::Shell { faces, thickness, body } => {
|
|
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 nb = kernel::shell(&mb.body, faces, t)?;
|
|
if let Some(slot) = st.bodies.get_mut(i) {
|
|
*slot = ModelBody::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(vals, neutral, 0)?;
|
|
let i = body_at(st, body, faces)?;
|
|
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
|
let nb = kernel::draft(&mb.body, faces, &pl, *pull, a)?;
|
|
if let Some(slot) = st.bodies.get_mut(i) {
|
|
*slot = ModelBody::new(mb.name, nb, mb.feature);
|
|
}
|
|
Ok(())
|
|
}
|
|
FeatureKind::ConstructionPlane { plane } => {
|
|
doc.resolve_plane(vals, plane, 0)?;
|
|
Ok(())
|
|
}
|
|
FeatureKind::Split { body, plane } => {
|
|
let pl = doc.resolve_plane(vals, plane, 0)?;
|
|
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 = kernel::split_by_plane(&mb.body, &pl)?;
|
|
if parts.len() < 2 {
|
|
return Err(DocError::Invalid("the plane does not split the body".into()));
|
|
}
|
|
let mut it = parts.into_iter();
|
|
if let (Some(first), Some(slot)) = (it.next(), st.bodies.get_mut(i)) {
|
|
*slot = ModelBody::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::new(name, b, f.id));
|
|
}
|
|
Ok(())
|
|
}
|
|
FeatureKind::Pattern { features, pattern } => {
|
|
let mats = pattern_transforms(vals, pattern)?;
|
|
replay(doc, vals, f, st, features, &mats)
|
|
}
|
|
FeatureKind::Mirror { features, plane } => {
|
|
let pl = doc.resolve_plane(vals, plane, 0)?;
|
|
replay(doc, vals, f, st, features, &[mirror_matrix(&pl)])
|
|
}
|
|
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 = resolve_edges(&mb.body, edges, warning)?;
|
|
let nb = if matches!(f.kind, FeatureKind::Fillet { .. }) {
|
|
kernel::fillet(&mb.body, &edges, r)?
|
|
} else {
|
|
kernel::chamfer(&mb.body, &edges, r)?
|
|
};
|
|
let (name, feat) = (mb.name.clone(), mb.feature);
|
|
if let Some(slot) = st.bodies.get_mut(ti) {
|
|
*slot = ModelBody::new(name, nb, feat);
|
|
}
|
|
Ok(())
|
|
}
|
|
FeatureKind::Box { corner, length, width, height, operation } => {
|
|
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
|
|
let b = kernel::box_solid(*corner, *corner + s)?;
|
|
apply_op(st, f, vec![b], *operation, &[])
|
|
}
|
|
FeatureKind::Cylinder { base, axis, radius, height, operation } => {
|
|
let b = kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?;
|
|
apply_op(st, f, vec![b], *operation, &[])
|
|
}
|
|
FeatureKind::Sphere { center, radius, operation } => {
|
|
let b = kernel::sphere(*center, val(vals, radius, Kind::Length)?)?;
|
|
apply_op(st, f, vec![b], *operation, &[])
|
|
}
|
|
FeatureKind::Torus { center, major, minor, operation } => {
|
|
let b = kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?;
|
|
apply_op(st, f, vec![b], *operation, &[])
|
|
}
|
|
FeatureKind::Combine { target, tools, operation, keep_tools } => {
|
|
if tools.iter().any(|t| t == target) {
|
|
return Err(DocError::Invalid("a body cannot be combined with itself".into()));
|
|
}
|
|
let mut tb = Vec::new();
|
|
for t in tools {
|
|
tb.push(st.body(t).ok_or_else(|| DocError::Unknown(format!("body `{t}`")))?.body.clone());
|
|
}
|
|
if st.body(target).is_none() {
|
|
return Err(DocError::Unknown(format!("body `{target}`")));
|
|
}
|
|
let op = if *operation == Operation::NewBody { Operation::Join } else { *operation };
|
|
apply_op(st, f, tb, op, std::slice::from_ref(target))?;
|
|
if !keep_tools {
|
|
st.bodies.retain(|b| !tools.contains(&b.name));
|
|
}
|
|
Ok(())
|
|
}
|
|
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
|
|
let t =
|
|
Vec3::new(val(vals, &translate[0], Kind::Length)?, val(vals, &translate[1], Kind::Length)?, val(vals, &translate[2], Kind::Length)?);
|
|
let ang = match angle {
|
|
Some(a) => val(vals, a, Kind::Angle)?,
|
|
None => 0.0,
|
|
};
|
|
let axis = rotate_axis.unwrap_or(Vec3::Z);
|
|
for n in bodies {
|
|
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
|
if let Some(mb) = st.bodies.get_mut(i) {
|
|
let nb = kernel::transform(&mb.body, t, Vec3::ZERO, axis, ang)?;
|
|
*mb = ModelBody::new(mb.name.clone(), nb, mb.feature);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
FeatureKind::Import { step, .. } => {
|
|
let imp = kernel::step_import_shared(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);
|
|
st.bodies.push(ModelBody::new(name, b.body.clone(), 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(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `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())
|
|
}
|