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
+1
-1
@@ -28,7 +28,7 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
|
||||
| M6 | UI depth: selection feedback and origin (done), timeline editing (done: edit feature, history marker drag, reorder with dependency checks, suppress, rename, delete with dependents, re-resolved references), live previews, measure tool, section view, drag-solve | in progress | 16 |
|
||||
| M7 | Sweep, loft, rib, web, emboss, thread, coil, pipe | planned | 24 |
|
||||
| M8 | Surface workspace | planned | 30 |
|
||||
| M9 | Assemblies and joints | planned | 40 |
|
||||
| M9 | Components and assemblies (Fusion-style): New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, As-built Joint, Rigid Group, contact sets later; interference, per-component physical properties; STEP with assembly structure. Groundwork done: the document is a component tree (features and bodies carry a component, active component, New Component, Create Components from Bodies) | planned | 40 |
|
||||
| M10 | Sheet metal | planned | 30 |
|
||||
| M11 | 2D drawings | planned | 30 |
|
||||
| M12 | Release: installers, signing, docs, performance | planned | 16 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Components: the design as a tree. New sketches and features go into the active component;
|
||||
//! bodies belong to their feature's component unless moved. (Groundwork for assemblies:
|
||||
//! occurrences, joints and per-component physical properties build on this.)
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_, string_list};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("FusionCreateNewComponentCommand", "New Component", new_component)
|
||||
.at("SOLID", "ASSEMBLE")
|
||||
.icon("component")
|
||||
.params("name?, parent?: component id or name (default: the active one); activate?: bool (default true)"),
|
||||
CommandSpec::new("FusionCreateComponentsFromBodiesCommand", "Create Components from Bodies", from_bodies)
|
||||
.icon("component")
|
||||
.params("bodies: [names] → one new component per body, named after it"),
|
||||
CommandSpec::new("component.activate", "Activate Component", activate).noundo().params("component: id or name (0 or \"root\" for the design)"),
|
||||
CommandSpec::new("component.list", "List Components", list).noundo(),
|
||||
];
|
||||
|
||||
fn component_key(v: Option<&Value>) -> Option<String> {
|
||||
match v {
|
||||
Some(Value::Number(n)) => Some(n.to_string()),
|
||||
Some(Value::String(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_component(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionCreateNewComponentCommand";
|
||||
let parent = match component_key(p.get("parent")) {
|
||||
Some(k) => s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))?,
|
||||
None => s.active_component,
|
||||
};
|
||||
let id = s.doc_mut().add_component(str_(p, "name"), parent)?;
|
||||
if p.get("activate").and_then(Value::as_bool).unwrap_or(true) {
|
||||
s.active_component = id;
|
||||
}
|
||||
let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default();
|
||||
Ok(json!({"component": id, "name": name, "parent": parent}))
|
||||
}
|
||||
|
||||
fn from_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionCreateComponentsFromBodiesCommand";
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() || bodies.len() > 1000 {
|
||||
return Err(bad(cmd, "`bodies` must list 1…1000 body names"));
|
||||
}
|
||||
let st = s.model.state();
|
||||
for b in &bodies {
|
||||
if st.body(b).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
}
|
||||
let parent = s.active_component;
|
||||
let mut made = Vec::new();
|
||||
for b in bodies {
|
||||
let id = s.doc_mut().add_component(Some(&b), parent)?;
|
||||
s.doc_mut().body_components.insert(b.clone(), id);
|
||||
made.push(json!({"component": id, "body": b}));
|
||||
}
|
||||
Ok(json!({ "components": made }))
|
||||
}
|
||||
|
||||
fn activate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.activate";
|
||||
let k = component_key(p.get("component")).ok_or_else(|| bad(cmd, "`component` must be an id or name"))?;
|
||||
let id = s.doc.find_component(&k).ok_or_else(|| bad(cmd, format!("no component `{k}`")))?;
|
||||
s.active_component = id;
|
||||
s.revision += 1;
|
||||
Ok(json!({ "active": id }))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
let doc = &s.doc;
|
||||
let node = |id: u64, name: &str, parent: Option<u64>| {
|
||||
let bodies: Vec<&str> = st.bodies.iter().filter(|b| doc.body_component(&b.name, b.feature) == id).map(|b| b.name.as_str()).collect();
|
||||
let features: Vec<&str> = doc.features.iter().filter(|f| f.component == id).map(|f| f.name.as_str()).collect();
|
||||
json!({"id": id, "name": name, "parent": parent, "bodies": bodies, "features": features})
|
||||
};
|
||||
let mut out = vec![node(0, &doc.name, None)];
|
||||
for c in &doc.components {
|
||||
out.push(node(c.id, &c.name, Some(c.parent)));
|
||||
}
|
||||
Ok(json!({"active": s.active_component, "components": out}))
|
||||
}
|
||||
@@ -47,7 +47,11 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("hole")
|
||||
.key("H")
|
||||
.params("position: [x,y,z] on a face; direction?: [x,y,z] (default: into the face); diameter; depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?"),
|
||||
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch); direction?: [x,y,z] (default: into the face); diameter; depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
|
||||
CommandSpec::new("FusionThreadCommand", "Thread", thread)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("thread")
|
||||
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face). Cosmetic: the model is unchanged, model.threads lists it"),
|
||||
CommandSpec::new("FusionShellBodyCommand", "Shell", shell)
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("shell")
|
||||
@@ -217,6 +221,12 @@ fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str) -> Result
|
||||
fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
|
||||
let name = str_(p, "name");
|
||||
let id = s.doc_mut().add_feature(kind, name)?;
|
||||
let comp = s.active_component;
|
||||
if comp != 0
|
||||
&& let Some(f) = s.doc_mut().feature_mut(id)
|
||||
{
|
||||
f.component = comp;
|
||||
}
|
||||
let mut names = string_list(p, "body_names");
|
||||
if let Some(b) = str_(p, "body_name") {
|
||||
names.insert(0, b.to_string());
|
||||
@@ -595,25 +605,60 @@ fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionHoleCommand";
|
||||
let position = p.get("position").and_then(vec3).ok_or_else(|| bad(cmd, "`position` must be [x, y, z]"))?;
|
||||
let direction = match p.get("direction").and_then(vec3) {
|
||||
Some(d) => d.normalized().ok_or_else(|| bad(cmd, "`direction` must be non-zero"))?,
|
||||
None => {
|
||||
// Into the planar face the position lies on.
|
||||
// At sketch points: the holes go perpendicular to the sketch and follow its points.
|
||||
let points = match (p.get("sketch"), p.get("points")) {
|
||||
(Some(_), Some(_)) => {
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let ids = string_list(p, "points");
|
||||
if ids.is_empty() || ids.len() > 10_000 {
|
||||
return Err(bad(cmd, "`points` must list 1…10000 sketch point ids"));
|
||||
}
|
||||
let st = s.model.state();
|
||||
let mut found = None;
|
||||
for b in &st.bodies {
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
for f in b.body.faces(tol).unwrap_or_default() {
|
||||
if let Some(n) = f.plane_normal
|
||||
&& (position - f.centroid).dot(n).abs() < tol * 10.0
|
||||
{
|
||||
found = Some(-n);
|
||||
}
|
||||
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
|
||||
for id in &ids {
|
||||
if !ss.sketch.points.iter().any(|q| &q.id == id) {
|
||||
return Err(bad(cmd, format!("no point `{id}` in the sketch")));
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| bad(cmd, "no planar face at `position`; give `direction`"))?
|
||||
let n = ss.plane.normal();
|
||||
let first =
|
||||
ids.first().and_then(|id| ss.sketch.points.iter().find(|q| &q.id == id)).map(|q| ss.plane.to_world(q.pos)).unwrap_or_default();
|
||||
Some((solvecraft_doc::SketchPoints { sketch, ids }, first, -n))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let thread = str_(p, "thread").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
if let Some(t) = &thread
|
||||
&& solvecraft_doc::parse_metric_thread(t).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("unknown thread `{t}` (ISO metric, like M6 or M6x0.75)")));
|
||||
}
|
||||
let position = match (&points, p.get("position").and_then(vec3)) {
|
||||
(Some((_, first, _)), None) => *first,
|
||||
(_, Some(x)) => x,
|
||||
(None, None) => return Err(bad(cmd, "`position` must be [x, y, z] (or give `sketch` and `points`)")),
|
||||
};
|
||||
let direction = match (p.get("direction").and_then(vec3), &points) {
|
||||
(None, Some((_, _, d))) => *d,
|
||||
(d, _) => match d {
|
||||
Some(d) => d.normalized().ok_or_else(|| bad(cmd, "`direction` must be non-zero"))?,
|
||||
None => {
|
||||
// Into the planar face the position lies on.
|
||||
let st = s.model.state();
|
||||
let mut found = None;
|
||||
for b in &st.bodies {
|
||||
let tol = (b.body.size() * 1e-3).max(1e-3);
|
||||
for f in b.body.faces(tol).unwrap_or_default() {
|
||||
if let Some(n) = f.plane_normal
|
||||
&& (position - f.centroid).dot(n).abs() < tol * 10.0
|
||||
{
|
||||
found = Some(-n);
|
||||
}
|
||||
}
|
||||
}
|
||||
found.ok_or_else(|| bad(cmd, "no planar face at `position`; give `direction`"))?
|
||||
}
|
||||
},
|
||||
};
|
||||
let diameter = req_expr(cmd, p, "diameter")?;
|
||||
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
|
||||
@@ -638,7 +683,7 @@ fn hole(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
other => return Err(bad(cmd, format!("unknown hole type `{other}`"))),
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind })
|
||||
add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind, points: points.map(|x| x.0), thread })
|
||||
}
|
||||
|
||||
fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
|
||||
@@ -716,3 +761,25 @@ fn loft(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Loft { sections, operation: operation(p, cmd)?, targets: string_list(p, "targets") })
|
||||
}
|
||||
|
||||
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionThreadCommand";
|
||||
let face = p
|
||||
.get("face")
|
||||
.and_then(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)))
|
||||
.ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on a cylindrical face"))?;
|
||||
let designation = str_(p, "designation").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
|
||||
if let Some(t) = &designation
|
||||
&& solvecraft_doc::parse_metric_thread(t).is_none()
|
||||
{
|
||||
return Err(bad(cmd, format!("unknown thread `{t}` (ISO metric, like M8 or M8x1)")));
|
||||
}
|
||||
let length = expr(p, "length");
|
||||
if let Some(l) = &length {
|
||||
check_expr(s, l, Kind::Length, cmd, "length")?;
|
||||
}
|
||||
let v = add_feature(s, p, FeatureKind::Thread { face, designation, length })?;
|
||||
let st = s.model.state();
|
||||
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
|
||||
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
|
||||
}
|
||||
@@ -18,6 +18,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"),
|
||||
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
|
||||
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params("body: name"),
|
||||
CommandSpec::new("model.threads", "List Threads", model_threads).noundo(),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
|
||||
];
|
||||
|
||||
@@ -206,3 +207,8 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn commands(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect()))
|
||||
}
|
||||
|
||||
fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.model.state();
|
||||
Ok(json!({ "threads": st.threads }))
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! toolbar placement (`tab`, `panel`) follows Fusion's Design workspace so the registry doubles
|
||||
//! as the parity metric (`cargo xtask parity`).
|
||||
|
||||
mod component;
|
||||
mod edit;
|
||||
mod face;
|
||||
mod features;
|
||||
@@ -112,6 +113,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sketch_modify::COMMANDS.iter());
|
||||
v.extend(sketch_project::COMMANDS.iter());
|
||||
v.extend(features::COMMANDS.iter());
|
||||
v.extend(component::COMMANDS.iter());
|
||||
v.extend(edit::COMMANDS.iter());
|
||||
v.extend(file::COMMANDS.iter());
|
||||
v.extend(inspect::COMMANDS.iter());
|
||||
|
||||
@@ -355,6 +355,10 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.doc.resolve_plane(&vals, &plane, 0)?;
|
||||
let name = str_(p, "name");
|
||||
let id = s.doc_mut().add_feature(FeatureKind::Sketch { plane, sketch: Sketch::new() }, name)?;
|
||||
let comp = s.active_component;
|
||||
if let Some(f) = s.doc_mut().feature_mut(id) {
|
||||
f.component = comp;
|
||||
}
|
||||
s.active_sketch = Some(id);
|
||||
let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
// Sketching on a face projects the face's edges (unless turned off).
|
||||
|
||||
@@ -149,6 +149,8 @@ pub struct Session {
|
||||
pub revision: u64,
|
||||
/// Project a face's edges into a sketch created on it (Fusion's default; `sketch.auto_project`).
|
||||
pub auto_project: bool,
|
||||
/// The component new sketches and features go into (0 = the root).
|
||||
pub active_component: u64,
|
||||
}
|
||||
|
||||
const MAX_UNDO: usize = 200;
|
||||
@@ -177,6 +179,7 @@ impl Session {
|
||||
log: Vec::new(),
|
||||
revision: 1,
|
||||
auto_project: true,
|
||||
active_component: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +271,7 @@ impl Session {
|
||||
log: Vec::new(),
|
||||
revision: self.revision,
|
||||
auto_project: self.auto_project,
|
||||
active_component: self.active_component,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -652,3 +652,48 @@ fn threemf_export_open_insert() {
|
||||
assert_eq!(*s.doc, *before);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holes_at_sketch_points_threads_and_components() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 60, "width": 40, "height": 10, "name": "Plate"}));
|
||||
run(&mut s, "SketchCreate", json!({"plane": {"face": [30, 20, 10]}, "name": "HolePoints"}));
|
||||
let mut ids = Vec::new();
|
||||
for (k, (x, y)) in [(10.0, 10.0), (50.0, 10.0), (50.0, 30.0), (10.0, 30.0)].into_iter().enumerate() {
|
||||
let id = format!("h{k}");
|
||||
run(&mut s, "DrawPoint", json!({ "point": [x, y], "id": id }));
|
||||
ids.push(id);
|
||||
}
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "FusionHoleCommand", json!({"sketch": "HolePoints", "points": ids, "diameter": 5, "thread": "M6", "name": "Holes"}));
|
||||
let v = volume(&mut s);
|
||||
assert!(rel(v, 60.0 * 40.0 * 10.0 - 4.0 * PI * 6.25 * 10.0) < 1e-3, "{v}");
|
||||
let t = run(&mut s, "model.threads", json!({}));
|
||||
let threads = t["threads"].as_array().unwrap();
|
||||
assert_eq!(threads.len(), 4, "{t}");
|
||||
assert_eq!(threads[0]["designation"], "M6");
|
||||
assert_eq!(threads[0]["internal"], true);
|
||||
// A cosmetic thread on a shaft; designation from its size.
|
||||
run(&mut s, "PrimitiveCylinder", json!({"base": [100, 0, 0], "radius": 4, "height": 20, "name": "Shaft", "body_name": "Shaft"}));
|
||||
let th = run(&mut s, "FusionThreadCommand", json!({"face": [104, 0, 10], "length": 12}));
|
||||
assert_eq!(th["thread"]["designation"], "M8", "{th}");
|
||||
assert!((th["thread"]["end"].as_f64().unwrap() - th["thread"]["start"].as_f64().unwrap() - 12.0).abs() < 1e-6, "{th}");
|
||||
assert!(s.execute("FusionThreadCommand", &json!({"face": [104, 0, 10], "designation": "M20"})).is_err());
|
||||
// Components: features made while one is active belong to it.
|
||||
let c = run(&mut s, "FusionCreateNewComponentCommand", json!({"name": "Bracket"}));
|
||||
let cid = c["component"].as_u64().unwrap();
|
||||
run(&mut s, "PrimitiveBox", json!({"corner": [0, 60, 0], "length": 10, "width": 10, "height": 10, "name": "Block"}));
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
let l = run(&mut s, "component.list", json!({}));
|
||||
let comps = l["components"].as_array().unwrap();
|
||||
let bracket = comps.iter().find(|x| x["id"] == cid).unwrap();
|
||||
assert_eq!(bracket["features"], json!(["Block"]), "{l}");
|
||||
assert_eq!(bracket["bodies"].as_array().map(|a| a.len()), Some(1), "{l}");
|
||||
let r = run(&mut s, "FusionCreateComponentsFromBodiesCommand", json!({"bodies": ["Shaft"]}));
|
||||
assert_eq!(r["components"].as_array().map(|a| a.len()), Some(1));
|
||||
// Saved and loaded with the components.
|
||||
let text = s.doc.to_json();
|
||||
let back = solvecraft_doc::Document::from_json(&text).unwrap();
|
||||
assert_eq!(back.components.len(), 2);
|
||||
assert_eq!(back.body_components.len(), 1);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ pub use polybool::planar_boolean;
|
||||
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, shell};
|
||||
pub use step::step_export;
|
||||
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared};
|
||||
pub use topo::{CylinderFace, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
|
||||
@@ -459,3 +459,55 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
}
|
||||
Ok(TopoCounts { faces: groups.len(), edges: edge_groups.len() + apexes, vertices: real_vertices + closed_loops + apexes, face_types })
|
||||
}
|
||||
|
||||
/// A cylindrical face of a body near a point: where its axis is, which way, its radius, the
|
||||
/// extent of the face along the axis (from the axis point), and whether it is a hole (the
|
||||
/// material outside the cylinder).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
|
||||
pub struct CylinderFace {
|
||||
pub axis_point: Vec3,
|
||||
pub axis: Vec3,
|
||||
pub radius: f64,
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
pub internal: bool,
|
||||
}
|
||||
|
||||
pub fn cylinder_face_at(b: &Body, p: Vec3) -> Option<CylinderFace> {
|
||||
let size = b.size();
|
||||
let mesh = b.tessellate((size * 1e-3).max(1e-3)).ok()?;
|
||||
// The face of the triangle nearest the point.
|
||||
let mut best: Option<(f64, u32)> = None;
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
let Some([a, bb, c]) = mesh.tri(t) else { continue };
|
||||
let d = ((a + bb + c) / 3.0).dist(p).min(a.dist(p)).min(bb.dist(p)).min(c.dist(p));
|
||||
if best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, *f));
|
||||
}
|
||||
}
|
||||
let (d, face) = best?;
|
||||
if d > size * 0.05 {
|
||||
return None;
|
||||
}
|
||||
let nf = b.face_count();
|
||||
let tol = (size * 1e-4).max(1e-6);
|
||||
let surfs = classify_faces(&mesh, nf, tol);
|
||||
let Surf::Cylinder { axis, p: ap, r } = *surfs.get(face as usize)? else { return None };
|
||||
let mut lo = f64::INFINITY;
|
||||
let mut hi = f64::NEG_INFINITY;
|
||||
let mut inward = 0.0;
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
if *f != face {
|
||||
continue;
|
||||
}
|
||||
for k in t {
|
||||
let (Some(q), Some(n)) = (mesh.positions.get(*k as usize), mesh.normals.get(*k as usize)) else { continue };
|
||||
let s = (*q - ap).dot(axis);
|
||||
lo = lo.min(s);
|
||||
hi = hi.max(s);
|
||||
let radial = *q - ap - axis * s;
|
||||
inward += n.dot(radial);
|
||||
}
|
||||
}
|
||||
Some(CylinderFace { axis_point: ap, axis, radius: r, start: lo, end: hi, internal: inward < 0.0 })
|
||||
}
|
||||
Reference in new issue
Block a user