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:
bherbruckandClaude Opus 5.5 committed 2026-10-07 23:00:57 +00:00
1 parent 61ed21847a
commit b3ca8911eb
13 files changed
+641 -88

No files matched your search

+91 -1
View File
@@ -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
View File
@@ -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)?;
+1 -1
View File
@@ -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;