Holes at sketch points (one feature, many holes, following the points) and threaded holes; cosmetic threads (FusionThreadCommand, ISO metric, model.threads); components groundwork: the document is a component tree (New Component, Create Components from Bodies, active component, component.list)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
61ed21847a
commit
b3ca8911eb
13 files changed
+641
-88
No files matched your search
@@ -227,6 +227,23 @@ pub enum FeatureKind {
|
||||
depth: Option<String>,
|
||||
#[serde(default)]
|
||||
hole: HoleKind,
|
||||
/// Drill at these sketch points instead (one hole each, perpendicular to the sketch);
|
||||
/// the holes follow the points when the sketch changes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
points: Option<SketchPoints>,
|
||||
/// A cosmetic thread in the holes (e.g. "M6x1").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
thread: Option<String>,
|
||||
},
|
||||
/// A cosmetic thread on a cylindrical face (no geometry change; drawn and exported as an
|
||||
/// annotation). `designation` is an ISO metric size such as "M8" or "M8x1".
|
||||
Thread {
|
||||
face: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
designation: Option<String>,
|
||||
/// Thread length from the face's end nearest `face`; `None` = full length.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
length: Option<String>,
|
||||
},
|
||||
/// Ruled loft through profiles of several sketches, in order.
|
||||
Loft {
|
||||
@@ -312,6 +329,24 @@ pub struct MeshData {
|
||||
pub color: Option<[f32; 3]>,
|
||||
}
|
||||
|
||||
/// Points of a sketch, by id.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SketchPoints {
|
||||
pub sketch: u64,
|
||||
pub ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// A component: a node of the design tree that owns sketches, features and bodies. The root
|
||||
/// (id 0) is the design itself and is not stored.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Component {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
/// Parent component (0 = the root).
|
||||
#[serde(default)]
|
||||
pub parent: u64,
|
||||
}
|
||||
|
||||
/// One loft section: profiles of a sketch.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LoftSection {
|
||||
@@ -403,6 +438,7 @@ impl FeatureKind {
|
||||
FeatureKind::Mirror { .. } => "MirrorFeature",
|
||||
FeatureKind::ConstructionPlane { .. } => "ConstructionPlane",
|
||||
FeatureKind::Hole { .. } => "HoleFeature",
|
||||
FeatureKind::Thread { .. } => "ThreadFeature",
|
||||
FeatureKind::Loft { .. } => "LoftFeature",
|
||||
FeatureKind::Sweep { .. } => "SweepFeature",
|
||||
FeatureKind::Shell { .. } => "ShellFeature",
|
||||
@@ -431,6 +467,7 @@ impl FeatureKind {
|
||||
FeatureKind::Mirror { .. } => "Mirror",
|
||||
FeatureKind::ConstructionPlane { .. } => "Plane",
|
||||
FeatureKind::Hole { .. } => "Hole",
|
||||
FeatureKind::Thread { .. } => "Thread",
|
||||
FeatureKind::Loft { .. } => "Loft",
|
||||
FeatureKind::Sweep { .. } => "Sweep",
|
||||
FeatureKind::Shell { .. } => "Shell",
|
||||
@@ -493,6 +530,7 @@ impl FeatureKind {
|
||||
HoleKind::Countersink { cs_diameter, cs_angle } => v.extend([cs_diameter.as_str(), cs_angle]),
|
||||
}
|
||||
}
|
||||
FeatureKind::Thread { length, .. } => v.extend(length.iter().map(String::as_str)),
|
||||
FeatureKind::Move { translate, angle, .. } => {
|
||||
v.extend(translate.iter().map(String::as_str));
|
||||
if let Some(a) = angle {
|
||||
@@ -514,10 +552,17 @@ pub struct Feature {
|
||||
/// Names for the bodies this feature creates (in order); missing ones get `BodyN`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub body_names: Vec<String>,
|
||||
/// The component the feature (and what it makes) belongs to; 0 = the root.
|
||||
#[serde(default, skip_serializing_if = "is_root")]
|
||||
pub component: u64,
|
||||
#[serde(flatten)]
|
||||
pub kind: FeatureKind,
|
||||
}
|
||||
|
||||
fn is_root(c: &u64) -> bool {
|
||||
*c == 0
|
||||
}
|
||||
|
||||
/// The document.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Document {
|
||||
@@ -533,6 +578,12 @@ pub struct Document {
|
||||
pub marker: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub next_id: u64,
|
||||
/// Components below the root, as a tree (by parent).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub components: Vec<Component>,
|
||||
/// Bodies moved into another component than their feature's (by body name).
|
||||
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
|
||||
pub body_components: std::collections::BTreeMap<String, u64>,
|
||||
}
|
||||
|
||||
fn default_format() -> String {
|
||||
@@ -558,9 +609,48 @@ impl Document {
|
||||
features: Vec::new(),
|
||||
marker: None,
|
||||
next_id: 1,
|
||||
components: Vec::new(),
|
||||
body_components: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The component a body belongs to: where it was moved, else its feature's.
|
||||
pub fn body_component(&self, body: &str, feature: u64) -> u64 {
|
||||
self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Add a component under `parent` and return its id.
|
||||
pub fn add_component(&mut self, name: Option<&str>, parent: u64) -> Result<u64> {
|
||||
if parent != 0 && !self.components.iter().any(|c| c.id == parent) {
|
||||
return Err(DocError::Unknown(format!("component {parent}")));
|
||||
}
|
||||
if self.components.len() >= 10_000 {
|
||||
return Err(DocError::Invalid("too many components".into()));
|
||||
}
|
||||
let id = self.next_id.max(1);
|
||||
self.next_id = id + 1;
|
||||
let name = match name.map(str::trim) {
|
||||
Some(n) if !n.is_empty() => n.to_string(),
|
||||
_ => {
|
||||
let mut k = self.components.len() + 1;
|
||||
while self.components.iter().any(|c| c.name == format!("Component{k}")) {
|
||||
k += 1;
|
||||
}
|
||||
format!("Component{k}")
|
||||
}
|
||||
};
|
||||
self.components.push(Component { id, name, parent });
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// A component by id or name (0 / "root" is the design itself).
|
||||
pub fn find_component(&self, key: &str) -> Option<u64> {
|
||||
if key == "0" || key.eq_ignore_ascii_case("root") || key == self.name {
|
||||
return Some(0);
|
||||
}
|
||||
self.components.iter().find(|c| c.id.to_string() == key || c.name == key).map(|c| c.id)
|
||||
}
|
||||
|
||||
pub fn from_json(s: &str) -> Result<Document> {
|
||||
let d: Document = serde_json::from_str(s).map_err(|e| DocError::Invalid(format!("document: {e}")))?;
|
||||
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
|
||||
@@ -613,7 +703,7 @@ impl Document {
|
||||
Some(n) if !n.trim().is_empty() => n.trim().to_string(),
|
||||
_ => self.unique_name(kind.base_name()),
|
||||
};
|
||||
let f = Feature { id, name, suppressed: false, body_names: Vec::new(), kind };
|
||||
let f = Feature { id, name, suppressed: false, body_names: Vec::new(), component: 0, kind };
|
||||
match self.marker {
|
||||
Some(m) if m < self.features.len() => {
|
||||
self.features.insert(m, f);
|
||||
|
||||
+261
-68
@@ -48,9 +48,140 @@ pub struct SolvedSketch {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -624,6 +755,108 @@ fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// 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;
|
||||
if depth.is_some() && half > 1e-3 && half < std::f64::consts::FRAC_PI_2 {
|
||||
// One revolved tool (cylinder with a drill point).
|
||||
let tip = r / half.tan();
|
||||
let up = -dir;
|
||||
let side = up.any_perp();
|
||||
let rp = Plane::new(*position, side, up).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
|
||||
let pts = [Vec2::new(0.0, top), Vec2::new(r, top), Vec2::new(r, bottom), Vec2::new(0.0, bottom - tip)];
|
||||
let region = Region2 { outer: solvecraft_geom::Loop2::polygon(&pts), holes: vec![] };
|
||||
tools.extend(kernel::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?);
|
||||
} else {
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
}
|
||||
}
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth } => {
|
||||
let (rc, dc) = (val(vals, cb_diameter, Kind::Length)? / 2.0, val(vals, cb_depth, Kind::Length)?);
|
||||
if !(rc > r && dc > 0.0 && -dc > bottom) {
|
||||
return Err(DocError::Invalid("the counterbore must be wider than the hole and shallower than it".into()));
|
||||
}
|
||||
// Wide and shallow first: the narrow hole then crosses the pocket floor.
|
||||
tools.push(cyl(rc, -dc, top)?);
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
}
|
||||
HoleKind::Countersink { cs_diameter, cs_angle } => {
|
||||
let (rc, half) = (val(vals, cs_diameter, Kind::Length)? / 2.0, val(vals, cs_angle, Kind::Angle)? / 2.0);
|
||||
if !(rc > r && half > 1e-3 && half < std::f64::consts::FRAC_PI_2) {
|
||||
return Err(DocError::Invalid("the countersink must be wider than the hole".into()));
|
||||
}
|
||||
// Cone from above the face to just inside the hole radius, then the hole.
|
||||
let r_top = rc + top * half.tan();
|
||||
let r_end = r * 0.9;
|
||||
let len = (r_top - r_end) / half.tan();
|
||||
let top_plane = plane.offset(top);
|
||||
tools.push(kernel::extrude_tapered(&top_plane, &disc(r_top), len, -1.0, -half)?);
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
}
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
|
||||
fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
|
||||
match &f.kind {
|
||||
@@ -718,74 +951,14 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
|
||||
FeatureKind::Torus { center, major, minor, .. } => {
|
||||
Ok(vec![kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?])
|
||||
}
|
||||
FeatureKind::Hole { position, direction, diameter, depth, hole } => {
|
||||
let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
let position = &hole_on_surface(st, *position, dir);
|
||||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||||
if !(r > 1e-6) {
|
||||
return Err(DocError::Invalid("hole diameter must be positive".into()));
|
||||
FeatureKind::Hole { diameter, depth, hole, .. } => {
|
||||
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 size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max);
|
||||
let top = (size * 0.01).max(0.1);
|
||||
let bottom = match depth {
|
||||
Some(d) => -val(vals, d, Kind::Length)?,
|
||||
None => -(size * 2.0 + 1.0),
|
||||
};
|
||||
if bottom >= 0.0 {
|
||||
return Err(DocError::Invalid("hole depth must be positive".into()));
|
||||
}
|
||||
// Tools are cylinders and cones made by (tapered) extrudes along the axis, cut one
|
||||
// after another, widest first.
|
||||
let plane = Plane::from_normal(*position, -dir).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
|
||||
// Each tool's circle seam at its own angle, so seams don't line up between tools.
|
||||
let seam = std::cell::Cell::new(0.0);
|
||||
let disc = |rad: f64| {
|
||||
seam.set(seam.get() + 0.613);
|
||||
Region2 { outer: solvecraft_geom::Loop2::circle_from(Vec2::ZERO, rad, seam.get()), holes: vec![] }
|
||||
};
|
||||
let cyl = |rad: f64, lo: f64, hi: f64| -> Result<Body> {
|
||||
kernel::extrude(&plane, &[disc(rad)], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into()))
|
||||
};
|
||||
let mut tools = Vec::new();
|
||||
match hole {
|
||||
HoleKind::Simple => tools.push(cyl(r, bottom, top)?),
|
||||
HoleKind::Drilled { tip_angle } => {
|
||||
let half = val(vals, tip_angle, Kind::Angle)? / 2.0;
|
||||
if depth.is_some() && half > 1e-3 && half < std::f64::consts::FRAC_PI_2 {
|
||||
// One revolved tool (cylinder with a drill point).
|
||||
let tip = r / half.tan();
|
||||
let up = -dir;
|
||||
let side = up.any_perp();
|
||||
let rp = Plane::new(*position, side, up).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
|
||||
let pts = [Vec2::new(0.0, top), Vec2::new(r, top), Vec2::new(r, bottom), Vec2::new(0.0, bottom - tip)];
|
||||
let region = Region2 { outer: solvecraft_geom::Loop2::polygon(&pts), holes: vec![] };
|
||||
tools.extend(kernel::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?);
|
||||
} else {
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
}
|
||||
}
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth } => {
|
||||
let (rc, dc) = (val(vals, cb_diameter, Kind::Length)? / 2.0, val(vals, cb_depth, Kind::Length)?);
|
||||
if !(rc > r && dc > 0.0 && -dc > bottom) {
|
||||
return Err(DocError::Invalid("the counterbore must be wider than the hole and shallower than it".into()));
|
||||
}
|
||||
// Wide and shallow first: the narrow hole then crosses the pocket floor.
|
||||
tools.push(cyl(rc, -dc, top)?);
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
}
|
||||
HoleKind::Countersink { cs_diameter, cs_angle } => {
|
||||
let (rc, half) = (val(vals, cs_diameter, Kind::Length)? / 2.0, val(vals, cs_angle, Kind::Angle)? / 2.0);
|
||||
if !(rc > r && half > 1e-3 && half < std::f64::consts::FRAC_PI_2) {
|
||||
return Err(DocError::Invalid("the countersink must be wider than the hole".into()));
|
||||
}
|
||||
// Cone from above the face to just inside the hole radius, then the hole.
|
||||
let r_top = rc + top * half.tan();
|
||||
let r_end = r * 0.9;
|
||||
let len = (r_top - r_end) / half.tan();
|
||||
let top_plane = plane.offset(top);
|
||||
tools.push(kernel::extrude_tapered(&top_plane, &disc(r_top), len, -1.0, -half)?);
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
}
|
||||
for at in spots {
|
||||
tools.extend(hole_tools(vals, st, &at, dir, diameter, depth, hole)?);
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
@@ -989,9 +1162,29 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let tools = feature_tools(vals, f, st)?;
|
||||
apply_op(st, f, tools, *operation, targets)
|
||||
}
|
||||
FeatureKind::Hole { .. } => {
|
||||
FeatureKind::Hole { thread, diameter, .. } => {
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
let tools = feature_tools(vals, f, st)?;
|
||||
apply_op(st, f, tools, Operation::Cut, &[])
|
||||
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 } => {
|
||||
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)?;
|
||||
st.threads.push(t);
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Shell { faces, thickness, body } => {
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
|
||||
@@ -11,7 +11,7 @@ pub mod expr;
|
||||
pub mod project;
|
||||
|
||||
pub use document::*;
|
||||
pub use eval::{FeatureResult, Model, ModelBody, ModelState, SolvedSketch};
|
||||
pub use eval::{FeatureResult, Model, ModelBody, ModelState, SolvedSketch, ThreadInfo, parse_metric_thread};
|
||||
pub use solvecraft_kernel as kernel;
|
||||
pub use solvecraft_sketch as sketch;
|
||||
|
||||
|
||||
Reference in new issue
Block a user