From f7c26d8dfecf6866bdf0d661944e81a0c863d8b1 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 13:04:37 +0000 Subject: [PATCH] =?UTF-8?q?Standard=20parts=20library=20(parts.library,=20?= =?UTF-8?q?parts.insert,=20parts.resize):=20ISO=204762=20socket=20head=20c?= =?UTF-8?q?ap=20screws,=20ISO=204014/4017=20hex=20bolts,=20ISO=204032=20nu?= =?UTF-8?q?ts,=20ISO=207089=20washers,=20ISO=204026=20set=20screws,=20ISO?= =?UTF-8?q?=208734=20dowel=20pins=20and=206000/6200/600-series=20bearing?= =?UTF-8?q?=20envelopes,=20M3=E2=80=93M20,=20built=20in=20code=20from=20th?= =?UTF-8?q?e=20standards'=20dimensions=20as=20their=20own=20components=20w?= =?UTF-8?q?ith=20cosmetic=20threads;=20inserted=20at=20a=20hole's=20rim=20?= =?UTF-8?q?they=20get=20a=20rigid=20joint=20that=20puts=20them=20on=20the?= =?UTF-8?q?=20hole's=20axis=20seated=20on=20its=20face;=20resize=20rebuild?= =?UTF-8?q?s=20in=20place.=20Circle=20joint=20origins=20are=20named=20afte?= =?UTF-8?q?r=20the=20cylindrical=20wall?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/doc/src/document.rs | 13 + crates/engine/src/cmd/joints.rs | 19 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/parts.rs | 515 ++++++++++++++++++++++++++++++++ 4 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/cmd/parts.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index f7e581b1..5884930d 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -1065,6 +1065,15 @@ fn is_root(c: &u64) -> bool { *c == 0 } +/// A standard part (fastener, pin, bearing) inserted as a component. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StandardPart { + pub family: String, + pub size: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub length: Option, +} + /// The document. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Document { @@ -1095,6 +1104,9 @@ pub struct Document { /// Plastic rules. #[serde(default, skip_serializing_if = "crate::plastic::PlasticSettings::is_empty")] pub plastic: crate::plastic::PlasticSettings, + /// Components that are standard parts (by component id): what to rebuild them from. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub parts: std::collections::BTreeMap, /// Configurations (rows of parameter values and suppressions). #[serde(default, skip_serializing_if = "crate::config::ConfigTable::is_empty")] pub configs: crate::config::ConfigTable, @@ -1182,6 +1194,7 @@ impl Document { sheet: Default::default(), plastic: Default::default(), configs: Default::default(), + parts: Default::default(), body_components: Default::default(), materials: Default::default(), appearances: Default::default(), diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 34c35427..722f2319 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -81,7 +81,7 @@ pub(crate) fn occurrence_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result } /// The occurrence whose bodies are nearest a world point (0 = root bodies). -fn occurrence_at(s: &Session, p: Vec3) -> Option { +pub(crate) fn occurrence_at(s: &Session, p: Vec3) -> Option { let st = s.model.state(); let mut best: Option<(f64, u64)> = None; for b in &st.bodies { @@ -133,7 +133,13 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result { let pick = local(world); - let face = solvecraft_doc::naming::face_names_at(&s.model.state(), &[pick]).into_iter().next().filter(|n| !n.is_empty()); + // A circle's name is its cylindrical wall's (a rim pick also touches the end face). + let named_at = if what == "circle" { + s.model.state().bodies.iter().filter_map(|b| cylinder_wall_point(&b.body, pick)).next().unwrap_or(pick) + } else { + pick + }; + let face = solvecraft_doc::naming::face_names_at(&s.model.state(), &[named_at]).into_iter().next().filter(|n| !n.is_empty()); if what == "face" { Snap::FaceCenter { pick, face } } else { Snap::CircleCenter { pick, face } } } "point" => Snap::Point { point: local(world), z: v.get("z").and_then(vec3).map(local_dir) }, @@ -551,3 +557,12 @@ fn interference(s: &mut Session, p: &Value) -> Result { #[cfg(test)] #[path = "joints_tests.rs"] mod tests; + +/// A point in the middle of the cylindrical face at `pick` (on its wall, halfway along). +fn cylinder_wall_point(b: &solvecraft_doc::kernel::Body, pick: Vec3) -> Option { + let c = solvecraft_doc::kernel::cylinder_face_at(b, pick)?; + let v = pick - c.axis_point; + let along = v.dot(c.axis); + let radial = (v - c.axis * along).normalized()?; + Some(c.axis_point + c.axis * ((c.start + c.end) / 2.0) + radial * c.radius) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ed353149..fc7339c2 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -17,6 +17,7 @@ mod inspect; pub(crate) mod joints; mod measure_sel; pub(crate) mod motion; +mod parts; mod section; pub(crate) use measure_sel::measure_items; mod parameters; @@ -146,6 +147,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(joints::COMMANDS.iter()); v.extend(motion::COMMANDS.iter()); v.extend(config::COMMANDS.iter()); + v.extend(parts::COMMANDS.iter()); v.extend(browser::COMMANDS.iter()); v.extend(delete::COMMANDS.iter()); v.extend(edit::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs new file mode 100644 index 00000000..1e366544 --- /dev/null +++ b/crates/engine/src/cmd/parts.rs @@ -0,0 +1,515 @@ +//! Standard parts: ISO metric fasteners, dowel pins and ball bearings, generated in code from +//! the dimensions in the public standards tables (no vendor geometry). Each part is inserted as +//! its own component, built by ordinary features (so it stays parametric and editable), with a +//! cosmetic thread where it has one; at a hole it gets a rigid joint that puts its axis on the +//! hole's and seats it on the face. +//! +//! Part frame: the seating plane is z = 0 (the underside of a head, the bottom of a nut or +//! washer, the top of a set screw or pin); the shank runs down −z. + +use serde_json::{Value, json}; +use solvecraft_geom::Vec3; + +use super::CommandSpec; +use crate::params::{bad, num, str_, vec3}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("parts.library", "Standard Parts", library) + .noundo() + .params("family? → families (name, standard, sizes, lengths) and, with family, each size's dimensions (mm)"), + CommandSpec::new("parts.insert", "Insert Part", insert).at("SOLID", "INSERT").icon("import").params( + "family (socket_head_cap_screw | hex_bolt | hex_nut | washer | set_screw | dowel_pin | bearing); size (\"M6\", or \"608\" for bearings); \ + length? (mm; screws, bolts, set screws, pins; default a standard length near 2.5 d); \ + at?: [x,y,z] on a hole's rim or wall (the part seats on the hole's face with a rigid joint) | point?: [x,y,z] with direction?: [x,y,z] (no joint); name?", + ), + CommandSpec::new("parts.resize", "Change Part Size", resize) + .params("component: id|name of an inserted part; size?; length? — rebuilds it (joints keep it seated)"), +]; + +/// A family: (id, name, standard). +const FAMILIES: [(&str, &str, &str); 7] = [ + ("socket_head_cap_screw", "Socket head cap screw", "ISO 4762"), + ("hex_bolt", "Hex head bolt / screw", "ISO 4014 / ISO 4017"), + ("hex_nut", "Hex nut", "ISO 4032"), + ("washer", "Plain washer", "ISO 7089"), + ("set_screw", "Hex socket set screw, flat point", "ISO 4026"), + ("dowel_pin", "Parallel pin (dowel)", "ISO 8734"), + ("bearing", "Deep groove ball bearing (envelope)", "ISO 15 (6000/6200/600 series)"), +]; + +/// ISO metric coarse: (size, d, pitch). +const METRIC: [(&str, f64, f64); 9] = [ + ("M3", 3.0, 0.5), + ("M4", 4.0, 0.7), + ("M5", 5.0, 0.8), + ("M6", 6.0, 1.0), + ("M8", 8.0, 1.25), + ("M10", 10.0, 1.5), + ("M12", 12.0, 1.75), + ("M16", 16.0, 2.0), + ("M20", 20.0, 2.5), +]; + +/// ISO 4762: (size, head diameter dk, head height k, socket s, socket depth t). +const SHCS: [(&str, f64, f64, f64, f64); 9] = [ + ("M3", 5.5, 3.0, 2.5, 1.3), + ("M4", 7.0, 4.0, 3.0, 2.0), + ("M5", 8.5, 5.0, 4.0, 2.5), + ("M6", 10.0, 6.0, 5.0, 3.0), + ("M8", 13.0, 8.0, 6.0, 4.0), + ("M10", 16.0, 10.0, 8.0, 5.0), + ("M12", 18.0, 12.0, 10.0, 6.0), + ("M16", 24.0, 16.0, 14.0, 8.0), + ("M20", 30.0, 20.0, 17.0, 10.0), +]; + +/// ISO 4014/4017 hex head: (size, across flats s, head height k). +const HEX_BOLT: [(&str, f64, f64); 9] = [ + ("M3", 5.5, 2.0), + ("M4", 7.0, 2.8), + ("M5", 8.0, 3.5), + ("M6", 10.0, 4.0), + ("M8", 13.0, 5.3), + ("M10", 16.0, 6.4), + ("M12", 18.0, 7.5), + ("M16", 24.0, 10.0), + ("M20", 30.0, 12.5), +]; + +/// ISO 4032: (size, across flats s, height m). +const HEX_NUT: [(&str, f64, f64); 9] = [ + ("M3", 5.5, 2.4), + ("M4", 7.0, 3.2), + ("M5", 8.0, 4.7), + ("M6", 10.0, 5.2), + ("M8", 13.0, 6.8), + ("M10", 16.0, 8.4), + ("M12", 18.0, 10.8), + ("M16", 24.0, 14.8), + ("M20", 30.0, 18.0), +]; + +/// ISO 7089: (size, inner d1, outer d2, thickness h). +const WASHER: [(&str, f64, f64, f64); 9] = [ + ("M3", 3.2, 7.0, 0.5), + ("M4", 4.3, 9.0, 0.8), + ("M5", 5.3, 10.0, 1.0), + ("M6", 6.4, 12.0, 1.6), + ("M8", 8.4, 16.0, 1.6), + ("M10", 10.5, 20.0, 2.0), + ("M12", 13.0, 24.0, 2.5), + ("M16", 17.0, 30.0, 3.0), + ("M20", 21.0, 37.0, 3.0), +]; + +/// ISO 4026: (size, socket s, socket depth t). +const SET_SCREW: [(&str, f64, f64); 7] = + [("M3", 1.5, 1.2), ("M4", 2.0, 1.5), ("M5", 2.5, 2.0), ("M6", 3.0, 2.0), ("M8", 4.0, 3.0), ("M10", 5.0, 4.0), ("M12", 6.0, 4.8)]; + +/// ISO 8734 diameters. +const DOWEL: [f64; 9] = [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 16.0]; + +/// Bearings: (designation, bore, outside diameter, width). +const BEARINGS: [(&str, f64, f64, f64); 13] = [ + ("625", 5.0, 16.0, 5.0), + ("626", 6.0, 19.0, 6.0), + ("608", 8.0, 22.0, 7.0), + ("6000", 10.0, 26.0, 8.0), + ("6001", 12.0, 28.0, 8.0), + ("6002", 15.0, 32.0, 9.0), + ("6003", 17.0, 35.0, 10.0), + ("6004", 20.0, 42.0, 12.0), + ("6200", 10.0, 30.0, 9.0), + ("6201", 12.0, 32.0, 10.0), + ("6202", 15.0, 35.0, 11.0), + ("6203", 17.0, 40.0, 12.0), + ("6204", 20.0, 47.0, 14.0), +]; + +/// Standard lengths for screws, bolts and pins (mm). +const LENGTHS: [f64; 22] = + [4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 16.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 55.0, 60.0, 65.0, 70.0, 80.0, 90.0, 100.0, 120.0]; + +fn metric(size: &str) -> Option<(f64, f64)> { + METRIC.iter().find(|(s, _, _)| s.eq_ignore_ascii_case(size)).map(|(_, d, p)| (*d, *p)) +} + +fn default_length(d: f64) -> f64 { + LENGTHS.iter().copied().find(|l| *l >= 2.5 * d).unwrap_or(100.0) +} + +/// One part's shape, in its own frame. +#[derive(Clone, Debug, PartialEq)] +enum Shape { + /// A head (round or hex), a shank down −z, an optional hex socket in the head's top. + Screw { d: f64, pitch: f64, len: f64, head: Head, socket: Option<(f64, f64)> }, + /// A hex prism with a through hole (nut), z 0…m. + Nut { d: f64, pitch: f64, s: f64, m: f64 }, + /// A ring (washer, bearing envelope), z 0…h. + Ring { d_in: f64, d_out: f64, h: f64 }, + /// A cylinder down −z with a socket in its top (set screw), or plain (pin). + Rod { d: f64, pitch: Option, len: f64, socket: Option<(f64, f64)> }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Head { + Round { dk: f64, k: f64 }, + Hex { s: f64, k: f64 }, +} + +fn shape(family: &str, size: &str, length: Option) -> std::result::Result { + let need = |what: &str| format!("no {what} size `{size}` (parts.library lists them)"); + let len = |d: f64| length.unwrap_or_else(|| default_length(d)); + Ok(match family { + "socket_head_cap_screw" => { + let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?; + let (_, dk, k, s, t) = SHCS.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4762"))?; + Shape::Screw { d, pitch, len: len(d), head: Head::Round { dk, k }, socket: Some((s, t)) } + } + "hex_bolt" => { + let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?; + let (_, s, k) = HEX_BOLT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4014"))?; + Shape::Screw { d, pitch, len: len(d), head: Head::Hex { s, k }, socket: None } + } + "hex_nut" => { + let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?; + let (_, s, m) = HEX_NUT.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4032"))?; + Shape::Nut { d, pitch, s, m } + } + "washer" => { + let (_, d1, d2, h) = WASHER.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 7089"))?; + Shape::Ring { d_in: d1, d_out: d2, h } + } + "set_screw" => { + let (d, pitch) = metric(size).ok_or_else(|| need("metric"))?; + let (_, s, t) = SET_SCREW.iter().find(|r| r.0.eq_ignore_ascii_case(size)).copied().ok_or_else(|| need("ISO 4026"))?; + Shape::Rod { + d, + pitch: Some(pitch), + len: length.unwrap_or_else(|| LENGTHS.iter().copied().find(|l| *l >= d).unwrap_or(d)), + socket: Some((s, t)), + } + } + "dowel_pin" => { + let d: f64 = size.trim_start_matches(['d', 'D', 'Ø']).trim().parse().map_err(|_| need("dowel pin"))?; + if !DOWEL.contains(&d) { + return Err(need("ISO 8734")); + } + Shape::Rod { d, pitch: None, len: len(d * 1.6), socket: None } + } + "bearing" => { + let (_, bore, od, w) = BEARINGS.iter().find(|r| r.0 == size.trim()).copied().ok_or_else(|| need("bearing"))?; + Shape::Ring { d_in: bore, d_out: od, h: w } + } + o => return Err(format!("unknown part family `{o}` (parts.library lists them)")), + }) +} + +fn run(s: &mut Session, id: &str, p: Value) -> Result { + s.execute(id, &p) +} + +/// A hex prism (across flats `af`) from z0 to z1, joined or cut. +fn hex(s: &mut Session, af: f64, z0: f64, z1: f64, op: &str) -> Result<()> { + run(s, "SketchCreate", json!({"plane": "XY", "offset": z0}))?; + run(s, "ShapePolygonCircumscribed", json!({"center": [0, 0], "radius": af / 2.0, "sides": 6}))?; + run(s, "SketchStop", json!({}))?; + let (dist, dir) = if z1 >= z0 { (z1 - z0, "positive") } else { (z0 - z1, "negative") }; + run(s, "Extrude", json!({"distance": dist, "direction": dir, "operation": op}))?; + Ok(()) +} + +fn cyl(s: &mut Session, d: f64, z0: f64, z1: f64, op: &str) -> Result<()> { + run(s, "PrimitiveCylinder", json!({"diameter": d, "height": z1 - z0, "base": [0, 0, z0], "axis": [0, 0, 1], "operation": op}))?; + Ok(()) +} + +/// A solid of revolution about z from a closed (r, z) outline, as one body or joined. +fn revolve(s: &mut Session, pts: &[(f64, f64)], op: &str) -> Result<()> { + run(s, "SketchCreate", json!({"plane": "XZ"}))?; + let points: Vec<[f64; 2]> = pts.iter().map(|(r, z)| [*r, *z]).collect(); + run(s, "DrawPolyline", json!({"points": points, "closed": true}))?; + run(s, "SketchStop", json!({}))?; + run(s, "Revolve", json!({"axis": "y", "operation": op}))?; + Ok(()) +} + +fn thread(s: &mut Session, d: f64, pitch: f64, at: Vec3) { + let _ = run(s, "FusionThreadCommand", json!({"face": at, "designation": format!("M{}x{}", d, pitch)})); + let _ = d; +} + +/// Build a part in the active component. +fn build(s: &mut Session, sh: &Shape) -> Result<()> { + match *sh { + Shape::Screw { d, pitch, len, head, socket } => { + let k = match head { + // One revolved outline: shank and round head. + Head::Round { dk, k } => { + revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (dk / 2.0, 0.0), (dk / 2.0, k), (0.0, k)], "new")?; + k + } + // A hex head joined over a shank that runs up into it. + Head::Hex { s: af, k } => { + hex(s, af, 0.0, k, "new")?; + revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, k / 2.0), (0.0, k / 2.0)], "join")?; + k + } + }; + if let Some((af, t)) = socket { + hex(s, af, k, k - t, "cut")?; + } + thread(s, d, pitch, Vec3::new(d / 2.0, 0.0, -len / 2.0)); + } + Shape::Nut { d, pitch, s: af, m } => { + hex(s, af, 0.0, m, "new")?; + cyl(s, d, -1.0, m + 1.0, "cut")?; + thread(s, d, pitch, Vec3::new(d / 2.0, 0.0, m / 2.0)); + } + Shape::Ring { d_in, d_out, h } => { + revolve(s, &[(d_in / 2.0, 0.0), (d_out / 2.0, 0.0), (d_out / 2.0, h), (d_in / 2.0, h)], "new")?; + } + Shape::Rod { d, pitch, len, socket } => { + revolve(s, &[(0.0, -len), (d / 2.0, -len), (d / 2.0, 0.0), (0.0, 0.0)], "new")?; + if let Some((af, t)) = socket { + hex(s, af, 0.0, -t, "cut")?; + } + if let Some(p) = pitch { + thread(s, d, p, Vec3::new(d / 2.0, 0.0, -len / 2.0)); + } + } + } + Ok(()) +} + +fn library(_s: &mut Session, p: &Value) -> Result { + let fam = |id: &str| -> Value { + let sizes: Vec = match id { + "socket_head_cap_screw" => SHCS.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "head_diameter": r.1, "head_height": r.2, "socket": r.3, "socket_depth": r.4})).collect(), + "hex_bolt" => HEX_BOLT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "head_height": r.2})).collect(), + "hex_nut" => HEX_NUT.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "across_flats": r.1, "height": r.2})).collect(), + "washer" => WASHER.iter().map(|r| json!({"size": r.0, "inner": r.1, "outer": r.2, "thickness": r.3})).collect(), + "set_screw" => SET_SCREW.iter().map(|r| json!({"size": r.0, "d": metric(r.0).map(|m| m.0), "pitch": metric(r.0).map(|m| m.1), "socket": r.1, "socket_depth": r.2})).collect(), + "dowel_pin" => DOWEL.iter().map(|d| json!({"size": format!("{d}"), "d": d})).collect(), + "bearing" => BEARINGS.iter().map(|r| json!({"size": r.0, "bore": r.1, "outer": r.2, "width": r.3})).collect(), + _ => Vec::new(), + }; + let (_, name, standard) = FAMILIES.iter().find(|f| f.0 == id).copied().unwrap_or(("", "", "")); + let lengths = matches!(id, "socket_head_cap_screw" | "hex_bolt" | "set_screw" | "dowel_pin").then_some(LENGTHS.to_vec()); + json!({"family": id, "name": name, "standard": standard, "sizes": sizes, "lengths": lengths}) + }; + match str_(p, "family") { + Some(f) if FAMILIES.iter().any(|x| x.0 == f) => Ok(fam(f)), + Some(f) => Err(bad("parts.library", format!("unknown part family `{f}`"))), + None => Ok(json!({"families": FAMILIES.iter().map(|f| fam(f.0)).collect::>()})), + } +} + +fn part_name(family: &str, size: &str, sh: &Shape) -> String { + match sh { + Shape::Screw { len, .. } | Shape::Rod { len, .. } => format!("{} {size}x{len}", label(family)), + _ => format!("{} {size}", label(family)), + } +} + +fn label(family: &str) -> &'static str { + match family { + "socket_head_cap_screw" => "SHCS", + "hex_bolt" => "Hex Bolt", + "hex_nut" => "Hex Nut", + "washer" => "Washer", + "set_screw" => "Set Screw", + "dowel_pin" => "Dowel Pin", + _ => "Bearing", + } +} + +fn length_param(p: &Value, cmd: &str) -> Result> { + match p.get("length") { + None | Some(Value::Null) => Ok(None), + Some(_) => { + let l = num(p, "length").filter(|l| l.is_finite() && *l > 0.0 && *l <= 2000.0).ok_or_else(|| bad(cmd, "`length` must be 0…2000 mm"))?; + Ok(Some(l)) + } + } +} + +fn insert(s: &mut Session, p: &Value) -> Result { + let cmd = "parts.insert"; + let family = str_(p, "family").ok_or_else(|| bad(cmd, "`family` is required (parts.library)"))?.to_string(); + let size = str_(p, "size").ok_or_else(|| bad(cmd, "`size` is required"))?.to_string(); + let sh = shape(&family, &size, length_param(p, cmd)?).map_err(|e| bad(cmd, e))?; + // A pick on a hole's rim also touches its end face: step onto the wall just inside the rim. + let at = p.get("at").and_then(vec3).map(|a| hole_wall(s, a).unwrap_or(a)); + // The occurrence the hole belongs to, found before the part exists. + let host = match at { + Some(a) => Some(super::joints::occurrence_at(s, a).ok_or_else(|| bad(cmd, "no body at `at` (pick a hole's rim or wall)"))?), + None => None, + }; + let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| part_name(&family, &size, &sh)); + let active = s.active_component; + let c = run(s, "FusionCreateNewComponentCommand", json!({"name": name}))?; + let (comp, occ) = (c["component"].as_u64().unwrap_or(0), c["occurrence"].as_u64().unwrap_or(0)); + build(s, &sh)?; + s.doc_mut().parts.insert(comp, solvecraft_doc::StandardPart { family: family.clone(), size: size.clone(), length: shape_len(&sh) }); + run(s, "component.activate", json!({"component": active}))?; + let mut out = json!({"component": comp, "occurrence": occ, "name": name}); + if let (Some(a), Some(h)) = (at, host) { + // Seat it: its axis on the hole's, its seating plane on the hole's face, the shank going + // into the hole (the joint mates the part's −z with the face's outward z). + let j = run( + s, + "JointAssembleCmdNew", + json!({"type": "rigid", "a": {"occurrence": h, "circle": a}, "b": {"occurrence": occ, "point": [0, 0, 0], "z": [0, 0, -1]}, "name": format!("{name} seat")}), + )?; + out["joint"] = j["joint"].clone(); + } else if let Some(pt) = p.get("point").and_then(vec3) { + // Placed at a point, its +z along `direction`. + let dir = p.get("direction").and_then(vec3).and_then(|d| d.normalized()).unwrap_or(Vec3::Z); + let m = solvecraft_doc::rigid( + Vec3::ZERO, + Vec3::ZERO, + Vec3::Z.cross(dir).normalized().unwrap_or(Vec3::X), + Vec3::Z.dot(dir).clamp(-1.0, 1.0).acos(), + ); + let mut m = m; + m[3] = [pt.x, pt.y, pt.z, 1.0]; + if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) { + o.transform = m; + } + } + Ok(out) +} + +/// A point on the cylindrical wall of the hole at `a`, a little in from the picked rim. +fn hole_wall(s: &Session, a: Vec3) -> Option { + let st = s.model.state(); + let dirs = [Vec3::Z * -1.0, Vec3::Z, Vec3::X, Vec3::X * -1.0, Vec3::Y, Vec3::Y * -1.0]; + for step in [0.2, 0.5, 1.0, 2.0] { + for b in &st.bodies { + for d in dirs { + let q = a + d * step; + if let Some(c) = solvecraft_doc::kernel::cylinder_face_at(&b.body, q) { + let v = q - c.axis_point; + let along = v.dot(c.axis); + let on_wall = ((v - c.axis * along).len() - c.radius).abs() < 1e-3 && along > c.start && along < c.end; + if on_wall { + return Some(q); + } + } + } + } + } + None +} + +fn shape_len(sh: &Shape) -> Option { + match sh { + Shape::Screw { len, .. } | Shape::Rod { len, .. } => Some(*len), + _ => None, + } +} + +fn resize(s: &mut Session, p: &Value) -> Result { + let cmd = "parts.resize"; + let key = match p.get("component") { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return Err(bad(cmd, "`component` must be an id or a name")), + }; + let comp = s.doc.find_component(&key).ok_or_else(|| bad(cmd, format!("no component `{key}`")))?; + let part = s.doc.parts.get(&comp).cloned().ok_or_else(|| bad(cmd, "that component is not a standard part"))?; + let size = str_(p, "size").map(str::to_string).unwrap_or(part.size.clone()); + let length = length_param(p, cmd)?.or(if size == part.size { part.length } else { None }); + let sh = shape(&part.family, &size, length).map_err(|e| bad(cmd, e))?; + // Rebuild: the component's features go, the new shape is built in their place. + let ids: Vec = s.doc.features.iter().filter(|f| f.component == comp).map(|f| f.id).collect(); + for id in ids.iter().rev() { + let _ = s.doc_mut().delete_feature(*id); + } + let active = s.active_component; + run(s, "component.activate", json!({"component": comp}))?; + build(s, &sh)?; + let name = part_name(&part.family, &size, &sh); + s.doc_mut().parts.insert(comp, solvecraft_doc::StandardPart { family: part.family.clone(), size: size.clone(), length: shape_len(&sh) }); + if let Some(c) = s.doc_mut().components.iter_mut().find(|c| c.id == comp) { + c.name = name.clone(); + } + run(s, "component.activate", json!({"component": active}))?; + s.refresh(); + if let Some(e) = s.model.results.iter().find_map(|r| r.error.clone()) { + return Err(EngineError::Other(e)); + } + Ok(json!({"component": comp, "name": name, "size": size, "length": shape_len(&sh)})) +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::Session; + + fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } + } + + fn bbox(s: &Session, prefix: &str) -> (solvecraft_geom::Vec3, solvecraft_geom::Vec3) { + let st = s.world_state(); + let comp = s.doc.components.iter().find(|c| c.name.starts_with(prefix)).map(|c| c.id).unwrap_or(0); + let (mut lo, mut hi) = (solvecraft_geom::Vec3::new(f64::MAX, f64::MAX, f64::MAX), solvecraft_geom::Vec3::new(f64::MIN, f64::MIN, f64::MIN)); + for b in st.bodies.iter().filter(|b| s.doc.body_component(&b.name, b.feature) == comp) { + let m = b.mesh().bounds(); + lo = solvecraft_geom::Vec3::new(lo.x.min(m.min.x), lo.y.min(m.min.y), lo.z.min(m.min.z)); + hi = solvecraft_geom::Vec3::new(hi.x.max(m.max.x), hi.y.max(m.max.y), hi.z.max(m.max.z)); + } + (lo, hi) + } + + #[test] + fn dimensions_follow_the_standard_tables() { + let l = run(&mut Session::default(), "parts.library", json!({"family": "socket_head_cap_screw"})); + let m6 = l["sizes"].as_array().and_then(|a| a.iter().find(|x| x["size"] == "M6")).cloned().unwrap_or_default(); + assert_eq!((m6["head_diameter"].as_f64(), m6["head_height"].as_f64(), m6["socket"].as_f64()), (Some(10.0), Some(6.0), Some(5.0))); + // An M6 x 20 cap screw: head Ø10 x 6, shank 20 long. + let mut s = Session::default(); + run(&mut s, "parts.insert", json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20})); + let (lo, hi) = bbox(&s, "SHCS M6x20"); + assert!((hi.x - lo.x - 10.0).abs() < 1e-6 && (lo.z + 20.0).abs() < 1e-6 && (hi.z - 6.0).abs() < 1e-6, "{lo:?} {hi:?}"); + assert!(s.model.state().threads.iter().any(|t| t.designation.starts_with("M6")), "{:?}", s.model.state().threads); + // An M8 nut: 13 across flats (14.43 across corners), 6.8 high. + let mut n = Session::default(); + run(&mut n, "parts.insert", json!({"family": "hex_nut", "size": "M8"})); + let (lo, hi) = bbox(&n, "Hex Nut M8"); + let (w, h) = ((hi.x - lo.x).max(hi.y - lo.y), hi.z - lo.z); + assert!((w - 13.0 / (std::f64::consts::PI / 6.0).cos()).abs() < 1e-6 && (h - 6.8).abs() < 1e-6, "{w} {h}"); + // A 608 bearing: 8 x 22 x 7. + let mut b = Session::default(); + run(&mut b, "parts.insert", json!({"family": "bearing", "size": "608"})); + let (lo, hi) = bbox(&b, "Bearing 608"); + assert!((hi.x - lo.x - 22.0).abs() < 1e-6 && (hi.z - lo.z - 7.0).abs() < 1e-6); + assert!(b.execute("parts.insert", &json!({"family": "hex_nut", "size": "M7"})).is_err()); + } + + #[test] + fn a_screw_seats_in_a_hole_and_resizes() { + let mut s = Session::default(); + run(&mut s, "PrimitiveBox", json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"})); + run(&mut s, "FusionHoleCommand", json!({"position": [20, 15, 10], "diameter": 6.4})); + // Pick the hole's rim at the top face. + let r = run(&mut s, "parts.insert", json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20, "at": [23.2, 15, 10]})); + assert!(r["joint"].is_number(), "{r}"); + let (lo, hi) = bbox(&s, "SHCS M6x20"); + // The head sits flush on the top face, the shank down the hole's axis. + assert!((hi.z - 16.0).abs() < 1e-6 && (lo.z + 10.0).abs() < 1e-6, "{lo:?} {hi:?}"); + assert!(((lo.x + hi.x) / 2.0 - 20.0).abs() < 1e-6 && ((lo.y + hi.y) / 2.0 - 15.0).abs() < 1e-6); + // M8 x 25: rebuilt in place, still seated. + let comp = r["component"].as_u64().unwrap_or(0); + run(&mut s, "parts.resize", json!({"component": comp, "size": "M8", "length": 25})); + let (lo, hi) = bbox(&s, "SHCS M8x25"); + assert!((hi.z - 18.0).abs() < 1e-6 && (lo.z + 15.0).abs() < 1e-6, "{lo:?} {hi:?}"); + } +}