Files
solidcraft-upstream/crates/doc/src/eval.rs
T
Iraklis KourtisandClaude Opus 5.5 8a38a7354c fix: join merges every body the tool meets
A Join unioned its tools into the first target only, leaving other target
bodies the tool overlapped or touched as separate, interpenetrating bodies.
Each tool now merges with every target it meets (shared material or face
contact, tested on the operands' boundaries) into one body under the first
one's name; absorbed bodies are removed and their threads follow. A tool that
meets no target still joins the first target as a separate lump. Sheet metal
and mesh bodies are never absorbed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01J53sR6gEaVt56soFDpTck8
2026-10-11 10:41:48 -04:00

3516 lines
162 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, 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,
/// 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(),
component: 0,
transferred: false,
imported: None,
}
}
pub fn definition_name(&self) -> &str {
self.source_name.as_deref().unwrap_or(&self.name)
}
/// 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>,
}
/// 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<solvecraft_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, &section, base, k, p, (s1 - s0) / p, 48)?)
}
/// 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>,
pub sketches: Vec<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>,
}
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);
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, and sketches it made, take that key; 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 });
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::new(Some(origin.clone()));
}
// 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);
}
}
}
}
if self.sketches.iter().any(|s| s.feature == feature) {
self.sketch_keys.insert(feature, key);
}
}
}
/// 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>,
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)
}
}
/// 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,
/// 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);
serde_json::to_string(f).unwrap_or_default().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()
}
/// 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)
}
/// Re-evaluate the document, reusing results up to the first changed feature.
pub fn evaluate(&mut self, doc: &Document) {
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 });
}
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 mut fp = fingerprint(prev_fp, 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();
}
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;
}
// 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,
ms: c.ms,
skipped: false,
fingerprint: fp,
state: state.clone(),
});
continue;
}
recomputed += 1;
let t0 = now();
let mut next = (*state).clone();
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();
for (p, n) in g.kind.face_points_mut().into_iter().zip(&f.face_names) {
if n.is_empty() {
continue;
}
match crate::naming::point_on_face(&state, n, *p) {
Some((q, split)) => {
if split {
notes.push(format!("face `{n}` was split; using the piece nearest the picked point"));
}
*p = q;
}
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 = 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()))
}),
};
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(), ms, state: state.clone() });
}
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;
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.
ProfileSel::All => {
let drawn = solvecraft_sketch::find_drawn_profiles(&ss.sketch);
if drawn.is_empty() { ps.iter().map(|p| p.region.clone()).collect() } else { drawn.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 = solvecraft_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: solvecraft_sketch::CurvePick, ids: &[String], sketch: &str) -> DocError {
match e {
solvecraft_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)"
)),
solvecraft_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();
}
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)?;
&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: &[Body]) -> Result<Vec<Body>> {
if tools.len() > 1000 || tools.iter().fold(0usize, |n, b| n.saturating_add(b.face_count())) > 16_384 {
return Err(DocError::Invalid("intersection tools exceed the bounded geometry budget".into()));
}
let mut groups = tools.to_vec();
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) => {
next.push(kernel::boolean(&a, &b, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("intersection tool union is empty".into()))?)
}
None => next.push(a),
}
}
groups = next;
}
Ok(groups)
}
fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, 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, b) in tools.into_iter().enumerate() {
let name = new_name(state, f, k);
state.bodies.push(ModelBody::new(name, b, f.id));
}
return Ok(());
}
if matches!(op, Operation::Cut | Operation::Intersect) {
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, &tools, 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();
let union = |a: &Body, b: &Body| kernel::boolean(a, b, BoolOp::Union)?.ok_or_else(|| DocError::Invalid("join produced nothing".into()));
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)? {
met.push(name.clone());
}
}
let Some(keep) = met.first().or(live.first()).cloned() else { return Ok(()) };
let Some(base) = state.body(&keep).map(|b| b.body.clone()) 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.body)?;
}
}
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::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 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.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
});
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)").
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;
}
for (k, p) in pieces.into_iter().enumerate() {
let name = if k == 0 { mb.name.clone() } else { format!("{} ({k})", mb.name) };
split.push(ModelBody::new(name, p, mb.feature));
}
}
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 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)
}
/// 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: 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;
// 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: 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)
}
/// 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, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
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()));
}
// 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, regions, start, end)?;
if *plane == Plane::XY {
return Ok(bodies);
}
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| kernel::transform_matrix(&body, m).map_err(DocError::from)).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>> {
feature_tools_with_constructor(doc, vals, f, st, &mut None)
}
fn feature_tools_with_constructor(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
st: &ModelState,
constructor: &mut Option<ExtrudeConstructor>,
) -> 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)?);
// 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);
return Ok(regions
.iter()
.map(|r| kernel::extrude_tapered(&start_plane, r, len, sign, taper))
.collect::<std::result::Result<Vec<_>, _>>()?);
}
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 &regions {
if *operation == Operation::Join {
let exact = centered_extrude(&ss.plane, std::slice::from_ref(r), lo + off, hi + off)?;
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))
}) {
// 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, std::slice::from_ref(&r), l2, h2)?);
}
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.body, |q: Vec3| ((q - ss.plane.origin).dot(n) - start).abs())?;
}
if untrimmed {
*constructor = Some(ExtrudeConstructor { plane: ss.plane, interval: (lo + off, hi + off) });
}
if *operation == Operation::NewBody && extent.taper.is_none() {
let cap_bindings = crate::config::extrude_cap_bindings(&ss, &tools, 10_000_000);
tools = tools
.into_iter()
.enumerate()
.map(|(body_index, body)| {
let curves: Vec<_> = body
.face_sources()
.into_iter()
.flatten()
.map(|source| match source {
Some(kernel::FaceSource::ExtrudeSide { segment, .. }) if cap_bindings.is_some() => {
crate::config::exact_sketch_curve(&ss, segment)
}
_ => None,
})
.collect();
let caps =
cap_bindings.as_ref().and_then(|bindings| bindings.get(body_index)).cloned().unwrap_or_else(|| vec![None; curves.len()]);
body.with_extrude_certificates(f.id, *sketch, &curves)?
.with_extrude_cap_certificates(f.id, *sketch, &caps)
.map_err(DocError::from)
})
.collect::<Result<Vec<_>>>()?;
}
Ok(tools)
}
FeatureKind::Revolve { sketch, profiles, axis, angle, angle2, symmetric, convention, to, .. } => {
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 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,
&regions,
(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(&region_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));
let build_curved = |regions: &[Region2], angle_axis: Vec3, tb: &ModelBody| -> Result<Vec<Body>> {
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 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(bodies, &tb.body, tol_side)
};
if *convention == crate::RevolveConvention::LegacyAxisStart
&& let Some(tb) = curved_body
{
return build_curved(&regions, -axis_w, tb);
}
if *convention == crate::RevolveConvention::SketchNormal {
let mut out = Vec::new();
for (region, region_extent) in regions.iter().zip(&region_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 { body } else { kernel::transform_matrix(&body, m)? });
}
}
return Ok(out);
}
let bodies = kernel::revolve(&ss.plane, &regions, o, d, ang)?;
if start.abs() < 1e-12 {
return Ok(bodies);
}
// Turned back to start before the profile.
let m = crate::rigid(Vec3::ZERO, origin_w, axis_w, -start);
Ok(bodies.iter().map(|b| kernel::transform_matrix(b, m)).collect::<std::result::Result<Vec<_>, _>>()?)
}
FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.point.is_some()) => {
// A profile lofted to a sketch point (an apex).
let [a, b] = &sections[..] 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 = solvecraft_sketch::merge_regions(&select_profiles(ss, &prof.profiles)?);
let [r] = &regions[..] 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)))?;
Ok(vec![kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?])
}
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 => solvecraft_geom::Loop2::circle(c, s / 2.0),
crate::CoilSection::Square => {
let h = s / 2.0;
solvecraft_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(vec![kernel::sweep_helix(&plane, &region, *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 = solvecraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
let [r] = &regions[..] 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 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 = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default();
// A path through 3D sketch curves follows them as points.
if let Some(pts) = path_points(ps, path, start)? {
return regions.iter().map(|r| kernel::sweep_path(&ss.plane, r, &pts).map_err(DocError::from)).collect();
}
let segs = path_segments(ps, path, start)?;
regions.iter().map(|r| kernel::sweep(&ss.plane, r, &segs).map_err(DocError::from)).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(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(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![kernel::box_solid(corner, corner + s)?])
}
FeatureKind::Cylinder { base, axis, radius, height, .. } => {
Ok(vec![kernel::cylinder(point(vals, base)?, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?])
}
FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(point(vals, center)?, val(vals, radius, Kind::Length)?)?]),
FeatureKind::Torus { center, major, minor, .. } => {
Ok(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(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![solvecraft_geom::Loop2::circle(Vec2::ZERO, r - wt).reversed()]
}
None => Vec::new(),
};
Ok(Region2 { outer: solvecraft_geom::Loop2::circle(Vec2::ZERO, r), holes })
}
fn map_wire(wire: &mut solvecraft_sketch::Wire, matrix: &Mat) {
for point in wire.pts.iter_mut().chain(&mut wire.fit) {
*point = crate::apply_point(matrix, *point);
}
}
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 == solvecraft_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<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();
}
// 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 {
solvecraft_geom::Seg2::Line { a, b } => vec![kernel::PathSeg::Line { a: w(a), b: w(b) }],
solvecraft_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.
solvecraft_geom::Seg2::Cubic { .. } | solvecraft_geom::Seg2::Conic { .. } | solvecraft_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)?;
for m in mats {
let moved: Vec<Vec3> = edges.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)?,
_ => kernel::fillet(&mb.body, &moved, r)?,
};
if let Some(slot) = st.bodies.get_mut(ti) {
*slot = ModelBody::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)?);
}
}
} else {
let tools = feature_tools(doc, vals, src, st)?;
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).
if f.body_names.is_empty() {
apply_op(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_op(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);
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
let tol = (b.body.size() * 1e-3).max(1e-3);
let Ok(faces) = b.body.faces(tol) else { continue };
let m = b.mesh();
let on: Vec<usize> = faces.iter().filter(|f| crate::appearance::face_index_at(b, p) == Some(f.index)).map(|f| f.index).collect();
let Some(&fi) = on.first() else { continue };
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.
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);
}
}
return h(p);
}
h(p)
}
/// 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> {
for (bi, b) in st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()) {
let Some(fi) = crate::appearance::face_index_at(b, p) else { continue };
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);
return (!planar).then_some(bi);
}
None
}
/// The body at `p` when the face there is not planar.
fn curved_target_any(st: &ModelState, p: Vec3) -> Option<usize> {
for (bi, b) in st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()) {
let Some(fi) = crate::appearance::face_index_at(b, p) else { continue };
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());
return (!planar).then_some(bi);
}
None
}
/// 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<Body>, target: &Body, dist_to_start: impl Fn(Vec3) -> f64) -> Result<Vec<Body>> {
let mut out = Vec::new();
for t in tools {
let Some(cut) = kernel::boolean(&t, target, BoolOp::Cut)? else { continue };
for lump in cut.lumps()? {
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(lump);
}
}
}
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
}
/// 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 {
let stem = crate::naming::strip_piece(name);
let mut found: Option<(f64, f64)> = None;
for b in st.bodies.iter().filter(|b| !b.body.is_mesh()) {
let names = crate::naming::face_names(b);
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 = names.get(f.index).is_some_and(|x| crate::naming::strip_piece(x) == stem);
if let (true, Some(fnrm)) = (hit, f.plane_normal)
&& fnrm.dot(n) > 1.0 - 1e-6
{
let d = f.centroid.dist(at);
if found.is_none_or(|x| d < x.0) {
found = Some((d, f.centroid.dot(n)));
}
}
}
}
if let Some((_, off)) = found {
return (picked.offset(off - picked.origin.dot(n)), None);
}
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),
(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);
}
doc.apply_dimension_values(vals, &mut sk)?;
let mut report = solve(&mut sk);
if !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty() {
// Source dimensions may have moved curves during the solve.
sk.refresh_offsets()?;
report = solve(&mut sk);
}
if !report.ok() {
warns.insert(0, format!("the sketch constraints conflict ({})", report.failing.join(", ")));
}
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(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 handled = more::sheet_cut(f, st, &tools, 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_op(st, f, tools, *operation, &rest);
}
}
apply_op(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()))?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::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 nb = match direction.as_str() {
"" | "inside" => kernel::shell(&mb.body, &open, t)?,
"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 = kernel::offset_faces(&mb.body, &walls, out)?;
let cavity = if inn > 0.0 { kernel::offset_faces(&mb.body, &walls, -inn)? } else { mb.body.clone() };
let cavity = kernel::offset_faces(&cavity, &open, 2.0 * t + 1.0)?;
kernel::boolean(&outer, &cavity, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("the shell removed everything".into()))?
}
o => return Err(DocError::Invalid(format!("unknown shell direction `{o}` (inside, outside or both)"))),
};
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_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 = 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 } => {
let pl = 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 }, 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 }, 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<Body> = None;
for (b, _) in tb.iter().zip(&inside).filter(|(_, i)| **i) {
acc = match acc {
None => Some(b.body.clone()),
Some(a) => kernel::boolean(&a, &b.body, BoolOp::Intersect)?,
};
}
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) {
cell = kernel::boolean(&cell, &b.body, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("a cell came out empty".into()))?;
}
made.push(cell);
}
if *remove_tools {
st.bodies.retain(|b| !tools.contains(&b.name));
}
apply_op(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<solvecraft_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))?
}
};
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::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 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, 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 m in &mats {
let copy = kernel::transform_matrix(&mb.body, *m)?;
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::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 = kernel::transform_matrix(&mb.body, m)?;
if *combine {
let joined = kernel::boolean(&mb.body, &mirrored, BoolOp::Union)?
.ok_or_else(|| DocError::Invalid("the mirror join produced nothing".into()))?;
for (k, p) in joined.lumps()?.into_iter().enumerate() {
if k == 0 {
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name.clone(), p, mb.feature);
}
} else {
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::new(name, p, f.id));
output += 1;
}
}
} else {
let name = copy_body_name(st, f, output, &mb.name);
st.bodies.push(ModelBody::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::find_edge(mb, n, *p))) {
Some((_, Ok((q, false)))) => out.push(q),
Some((n, Ok((q, true)))) => {
notes.push(format!("edge `{n}` was split; using the piece nearest the picked point"));
out.push(q);
}
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)?
};
let nb = match &f.kind {
FeatureKind::Fillet { style: crate::FilletStyle::Chord, .. } => kernel::fillet_chord(&mb.body, &edges, r)?,
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)?));
}
let mut b = mb.body.clone();
for p in &edges {
b = kernel::fillet_variable_points(&b, *p, *start, &pts, *smooth)?;
}
b
}
FeatureKind::Fillet { .. } => kernel::fillet(&mb.body, &edges, r)?,
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &edges, r, distance2, angle, *flip)?,
_ => return Err(DocError::Invalid("blend".into())),
};
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 corner = point(vals, corner)?;
let b = kernel::box_solid(corner, corner + s)?;
let b = if *operation == Operation::NewBody { b.with_box_certificate(f.id)? } else { b };
apply_op(st, f, vec![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)?)?;
apply_op(st, f, vec![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}`")))?.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::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 = kernel::transform_matrix(&mb.body, m)?;
*mb = ModelBody::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 = kernel::offset_faces(&mb.body, faces, d)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::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 = kernel::delete_faces(&mb.body, &all)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
Ok(())
}
FeatureKind::BoundingSolid { bodies, margin } => {
let m = val(vals, margin, Kind::Length)?;
let mut bb = solvecraft_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 = 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::new(name, b, f.id));
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::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);
st.bodies.push(ModelBody::new(name, b.body.clone().with_import_certificate(&digest, f.id)?, 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(())
}
}
}
/// 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 == solvecraft_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::face_names(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 = crate::naming::NameCell::known(names);
*b = nb;
}
}
for (s, m) in out.sketches.iter_mut().zip(&sketch_m) {
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 == solvecraft_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);
let body =
if crate::is_identity(&bm) { b.body.clone() } else { kernel::transform_matrix(&b.body, bm).unwrap_or_else(|_| b.body.clone()) };
let mut placed = ModelBody::new(name, body, b.feature);
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::known(crate::naming::face_names(b));
out.bodies.push(placed);
}
if primary {
for s in st.sketches.iter().filter(|s| comp_of_feature(s.feature) == comp) {
let mut s2 = s.clone();
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 == solvecraft_sketch::WireSpace::Component {
map_wire(wire, &m);
}
}
out.sketches.push(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 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 },
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:?}");
}
}