diff --git a/ROADMAP.md b/ROADMAP.md index 95bbad35..549a21da 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index c0359176..8e0e9912 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -227,6 +227,23 @@ pub enum FeatureKind { depth: Option, #[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, + /// A cosmetic thread in the holes (e.g. "M6x1"). + #[serde(default, skip_serializing_if = "Option::is_none")] + thread: Option, + }, + /// 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, + /// Thread length from the face's end nearest `face`; `None` = full length. + #[serde(default, skip_serializing_if = "Option::is_none")] + length: Option, }, /// 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, +} + +/// 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, + /// 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, #[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, + /// 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, } 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 { + 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 { + 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 { 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); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index d4c6a536..040a4d03 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -48,9 +48,140 @@ pub struct SolvedSketch { pub profiles: Vec, } +/// 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::().ok()?, Some(p.trim().parse::().ok()?)), + None => (body.trim().parse::().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) -> Result { + 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, pub bodies: Vec, pub sketches: Vec, 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)> { + 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, + st: &ModelState, + position: &Vec3, + dir: Vec3, + diameter: &str, + depth: &Option, + hole: &HoleKind, +) -> Result> { + 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 { + 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, f: &Feature, st: &ModelState) -> Result> { match &f.kind { @@ -718,74 +951,14 @@ fn feature_tools(vals: &BTreeMap, 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 { - 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, 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)?; diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6ffee571..3cbfdcb4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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; diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs new file mode 100644 index 00000000..58b837f4 --- /dev/null +++ b/crates/engine/src/cmd/component.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + let st = s.model.state(); + let doc = &s.doc; + let node = |id: u64, name: &str, parent: Option| { + 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})) +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 82c1b21f..330b208f 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 { 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 { fn hole(s: &mut Session, p: &Value) -> Result { 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 { }, 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> { @@ -716,3 +761,25 @@ fn loft(s: &mut Session, p: &Value) -> Result { } add_feature(s, p, FeatureKind::Loft { sections, operation: operation(p, cmd)?, targets: string_list(p, "targets") }) } + +fn thread(s: &mut Session, p: &Value) -> Result { + 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})) +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index 6e2c6621..b299d7ac 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -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 { fn commands(s: &mut Session, _p: &Value) -> Result { 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 { + let st = s.model.state(); + Ok(json!({ "threads": st.threads })) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ab1ea1d1..a3ae23af 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index aa40bf59..662109e9 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -355,6 +355,10 @@ fn create_sketch(s: &mut Session, p: &Value) -> Result { 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). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6cc5f366..456d4da8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d7bfc7bf..3e9f529a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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); +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index be5c16fb..8f1c5926 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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)] diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index 6834bc0e..fba748fa 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -459,3 +459,55 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result { } 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 { + 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 }) +}