Insert Part dialog (SOLID > INSERT > Insert Fastener)

The standard parts library by type (with its ISO standard), size and length, the size's table
values and a picture of the part; picking a hole's wall or rim seats it there with a rigid joint
(the live preview shows it seated), without a pick it goes at the origin. Opens on
FusionFastenersCommand (and parts.insert); OK runs FusionFastenersCommand. Its own icon.
Insert's world picks (`at`, `point`, `direction`) go through the component frames like other
picks. Tests for the library parsing and the insert parameters; checked in the app: an M3 socket
head cap screw seated in a drilled hole with its joint.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 18:28:08 +00:00
1 parent 4eff2146d2
commit 0a6f6c4422
6 files changed
+280 -2

No files matched your search

+1
View File
@@ -64,6 +64,7 @@ fn table(id: &str) -> &'static [(&'static str, K)] {
"FusionCurvatureCombAnalysisCommand" | "FusionMinimumRadiusAnalysisCommand" | "FusionIsoCurveAnalysisCommand" => {
&[("edges", Picks), ("faces", Picks)]
}
"parts.insert" | "FusionFastenersCommand" => &[("at", Pick), ("point", Make), ("direction", Dir)],
"AppearanceCommand" => &[("faces", Picks), ("bodies", Body), ("body", Body)],
"FusionMoveCommand" => &[("bodies", Body), ("translate", Dir), ("axis", Dir), ("origin", Make)],
"FusionCombineCommand" => &[("target", Body), ("tools", Body)],
+8 -1
View File
@@ -215,6 +215,8 @@ pub enum Kind {
Appearance(crate::dialogs_appearance::Ap),
/// Contact sets, motion studies, exploded views, configurations (`dialogs_motion`).
Motion(crate::dialogs_motion::Mo),
/// Insert Part from the standard parts library (`dialogs_parts`).
Part(crate::dialogs_parts::Pt),
}
/// The rest of the Hole dialog: placement, extents, tap type and drill point.
@@ -413,7 +415,8 @@ impl Dialog {
.or_else(|| crate::dialogs_sheet::start(app, id))
.or_else(|| crate::dialogs_plastic::start(app, id))
.or_else(|| crate::dialogs_appearance::start(app, id))
.or_else(|| crate::dialogs_motion::start(app, id))?;
.or_else(|| crate::dialogs_motion::start(app, id))
.or_else(|| crate::dialogs_parts::start(app, id))?;
Dialog::new(kind, inputs)
}
};
@@ -514,6 +517,7 @@ impl Dialog {
Kind::Assembly(k) => return k.previews(),
Kind::Sheet(k) => return k.previews(),
Kind::Plastic(k) => return k.previews(),
Kind::Part(_) => return true,
_ => {}
}
matches!(
@@ -773,6 +777,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Plastic(k) => k.title(),
Kind::Appearance(_) => "APPEARANCE",
Kind::Motion(k) => k.title(),
Kind::Part(_) => "INSERT PART",
}
}
@@ -1493,6 +1498,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs),
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
Kind::Part(k) => enter |= crate::dialogs_parts::rows(app, ui, k, &d.inputs),
Kind::ConfirmDelete { with, fail, .. } => {
if !with.is_empty() {
row_label(ui, "Also deletes");
@@ -2127,6 +2133,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra),
Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)),
Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs),
Kind::Part(k) => return crate::dialogs_parts::commands(app, k, &d.inputs),
};
let mut params = params;
if let Value::Object(m) = &mut params {
+10 -1
View File
@@ -26,7 +26,16 @@ const TYPE_LABELS: [&str; 7] = ["Rigid", "Revolute", "Slider", "Cylindrical", "P
/// What a joint origin input accepts (snaps on faces, edges and vertices).
const SNAPS: u16 = FACES | EDGES | VERTICES;
/// Commands whose effect is where occurrences sit (previewed on the placed model).
const MOVES: [&str; 6] = ["JointAssembleCmdNew", "JointAsBuiltCmd", "FusionMoveJointsCommand", "joint.edit", "joint.limits", "RigidGroupCmd"];
const MOVES: [&str; 8] = [
"JointAssembleCmdNew",
"JointAsBuiltCmd",
"FusionMoveJointsCommand",
"joint.edit",
"joint.limits",
"RigidGroupCmd",
"parts.insert",
"FusionFastenersCommand",
];
#[derive(Clone, Debug)]
pub enum Asm {
+251
View File
@@ -0,0 +1,251 @@
//! Insert Part: the standard parts library (screws, bolts, nuts, washers, set screws, pins,
//! bearings) by family, size and length, with the size's table values and a picture of the part.
//! Picking a hole (its wall or rim) seats the part in it with a rigid joint; without one the part
//! goes at the origin. The live preview shows it placed.
use egui::{RichText, vec2};
use serde_json::{Value, json};
use solvecraft_engine::{Sel, Session};
use crate::SolveApp;
use crate::dialogs::{Kind, combo, row_label};
use crate::selection::{EDGES, FACES, SelInput};
use crate::theme::Tokens;
/// One family of the library.
#[derive(Clone, Debug, PartialEq)]
pub struct Family {
pub id: String,
pub name: String,
pub standard: String,
/// Size names and their table rows.
pub sizes: Vec<(String, Value)>,
pub lengths: Option<Vec<f64>>,
}
/// The library as `parts.library` gives it.
pub fn families(v: &Value) -> Vec<Family> {
v.get("families")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|f| {
let id = f.get("family")?.as_str()?.to_string();
let sizes = f
.get("sizes")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|s| Some((s.get("size")?.as_str().map(str::to_string).or_else(|| s.get("size").map(Value::to_string))?, s.clone())))
.collect();
Some(Family {
name: f.get("name").and_then(Value::as_str).unwrap_or(&id).to_string(),
standard: f.get("standard").and_then(Value::as_str).unwrap_or("").to_string(),
sizes,
lengths: f.get("lengths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_f64).collect()),
id,
})
})
.collect()
}
#[derive(Clone, Debug)]
pub struct Pt {
pub family: usize,
pub size: usize,
/// Index into the family's lengths (none: the default length).
pub length: Option<usize>,
pub name: String,
/// The (family, size, length) the picture shows (the texture is kept in egui's memory).
pub picture: Option<(usize, usize, Option<usize>)>,
/// The library, read once.
pub lib: Option<Vec<Family>>,
}
/// The dialog for Insert Part.
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
matches!(id, "FusionFastenersCommand" | "parts.insert").then(|| {
(
Kind::Part(Pt { family: 0, size: 0, length: None, name: String::new(), picture: None, lib: None }),
vec![SelInput::new("Hole (optional)", FACES | EDGES, false)],
)
})
}
fn library(app: &mut SolveApp) -> Vec<Family> {
app.session.execute("parts.library", &json!({})).map(|v| families(&v)).unwrap_or_default()
}
/// The insert command's parameters for the picks.
pub fn insert_params(lib: &[Family], k: &Pt, hole: Option<&Sel>) -> Result<Value, String> {
let f = lib.get(k.family).ok_or("the parts library is empty")?;
let (size, _) = f.sizes.get(k.size).ok_or("pick a size")?;
let mut p = json!({"family": f.id, "size": size});
if let Some(l) = k.length.and_then(|i| f.lengths.as_ref().and_then(|ls| ls.get(i))) {
p["length"] = json!(l);
}
match hole {
Some(Sel::Face { point, .. } | Sel::Edge { point, .. }) => p["at"] = json!([point.x, point.y, point.z]),
_ => {
p["point"] = json!([0.0, 0.0, 0.0]);
p["direction"] = json!([0.0, 0.0, 1.0]);
}
}
if !k.name.trim().is_empty() {
p["name"] = json!(k.name.trim());
}
Ok(p)
}
/// A picture of the part alone.
fn picture(p: &Value) -> Option<egui::ColorImage> {
let mut s = Session::default();
let mut q = p.clone();
if let Value::Object(m) = &mut q {
m.remove("at");
m.insert("point".into(), json!([0.0, 0.0, 0.0]));
m.insert("direction".into(), json!([0.0, 0.0, 1.0]));
}
s.execute("parts.insert", &q).ok()?;
crate::home::thumbnail(&s)
}
/// The dialog's rows.
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Pt, inputs: &[SelInput]) -> bool {
let t = Tokens::get();
let lib = match &k.lib {
Some(l) => l.clone(),
None => {
let l = library(app);
k.lib = Some(l.clone());
l
}
};
if lib.is_empty() {
row_label(ui, "");
ui.label(RichText::new("the parts library is not available").color(t.text_dim));
ui.end_row();
return false;
}
let names: Vec<String> =
lib.iter().map(|f| if f.standard.is_empty() { f.name.clone() } else { format!("{} ({})", f.name, f.standard) }).collect();
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
row_label(ui, "Type");
let before = k.family;
combo(ui, "pt_family", &refs, &mut k.family);
ui.end_row();
if k.family != before {
k.size = 0;
k.length = None;
}
let Some(f) = lib.get(k.family) else { return false };
let sizes: Vec<&str> = f.sizes.iter().map(|s| s.0.as_str()).collect();
row_label(ui, "Size");
combo(ui, "pt_size", &sizes, &mut k.size);
ui.end_row();
if let Some(ls) = &f.lengths {
let mut labels = vec!["Default".to_string()];
labels.extend(ls.iter().map(|l| format!("{l} mm")));
let refs: Vec<&str> = labels.iter().map(String::as_str).collect();
let mut i = k.length.map_or(0, |x| x + 1);
row_label(ui, "Length");
combo(ui, "pt_len", &refs, &mut i);
k.length = i.checked_sub(1);
ui.end_row();
}
row_label(ui, "Name");
ui.add(egui::TextEdit::singleline(&mut k.name).hint_text("automatic"));
ui.end_row();
// The part, and its table values.
let key = (k.family, k.size, k.length);
let tex_id = egui::Id::new("sc_part_picture");
if k.picture != Some(key) {
let tex = insert_params(&lib, k, None)
.ok()
.and_then(|p| picture(&p))
.map(|img| ui.ctx().load_texture("sc_part_picture", img, egui::TextureOptions::LINEAR));
ui.data_mut(|d| match tex {
Some(t) => {
d.insert_temp(tex_id, t);
}
None => d.remove::<egui::TextureHandle>(tex_id),
});
k.picture = Some(key);
}
row_label(ui, "");
match ui.data(|d| d.get_temp::<egui::TextureHandle>(tex_id)) {
Some(tx) => {
ui.add(egui::Image::new(&tx).fit_to_exact_size(vec2(180.0, 112.0)).corner_radius(4.0));
}
None => {
ui.label(RichText::new("no picture").color(t.text_dim));
}
}
ui.end_row();
if let Some((_, row)) = f.sizes.get(k.size)
&& let Some(o) = row.as_object()
{
for (key, v) in o.iter().filter(|(key, _)| key.as_str() != "size") {
row_label(ui, &key.replace('_', " "));
ui.label(RichText::new(v.as_f64().map(|x| format!("{x} mm")).unwrap_or_else(|| v.to_string())).color(t.text_dim));
ui.end_row();
}
}
if inputs.first().is_some_and(|i| i.items.is_empty()) {
row_label(ui, "");
ui.label(RichText::new("pick a hole to seat it, or OK places it at the origin").color(t.text_dim));
ui.end_row();
}
false
}
/// The commands OK runs.
pub fn commands(app: &SolveApp, k: &Pt, inputs: &[SelInput]) -> Result<Vec<(String, Value)>, String> {
let lib = match &k.lib {
Some(l) => l.clone(),
None => app.session.scratch().execute("parts.library", &json!({})).map(|v| families(&v)).unwrap_or_default(),
};
let p = insert_params(&lib, k, inputs.first().and_then(|i| i.items.first()))?;
// The toolbar's command when the engine has it (same parameters).
let id = if solvecraft_engine::find_command("FusionFastenersCommand").is_some() { "FusionFastenersCommand" } else { "parts.insert" };
Ok(vec![(id.into(), p)])
}
#[cfg(test)]
mod tests {
use super::*;
use solvecraft_engine::geom::Vec3;
fn lib() -> Vec<Family> {
families(&json!({"families": [
{"family": "socket_head_cap_screw", "name": "Socket Head Cap Screw", "standard": "ISO 4762",
"sizes": [{"size": "M5", "head_diameter": 8.5}, {"size": "M6", "head_diameter": 10}], "lengths": [10, 16, 20]},
{"family": "bearing", "name": "Deep Groove Ball Bearing", "standard": "", "sizes": [{"size": "608", "bore": 8}], "lengths": null},
]}))
}
#[test]
fn library_rows_parse() {
let l = lib();
assert_eq!(l.len(), 2);
assert_eq!(l[0].sizes[1].0, "M6");
assert_eq!(l[0].lengths.as_deref(), Some(&[10.0, 16.0, 20.0][..]));
assert!(l[1].lengths.is_none());
}
/// A hole pick seats the part (`at`); without one it goes to the origin along Z.
#[test]
fn insert_params_for_picks() {
let l = lib();
let k = Pt { family: 0, size: 1, length: Some(2), name: String::new(), picture: None, lib: None };
let hole = Sel::Face { body: "Body1".into(), index: 3, point: Vec3::new(5.0, 0.0, 10.0) };
let p = insert_params(&l, &k, Some(&hole)).unwrap();
assert_eq!(p, json!({"family": "socket_head_cap_screw", "size": "M6", "length": 20.0, "at": [5.0, 0.0, 10.0]}));
let k = Pt { family: 1, size: 0, length: None, name: "Front bearing".into(), picture: None, lib: None };
let p = insert_params(&l, &k, None).unwrap();
assert_eq!(p["point"], json!([0.0, 0.0, 0.0]));
assert_eq!(p["name"], "Front bearing");
assert!(p.get("length").is_none());
assert!(insert_params(&[], &k, None).is_err());
}
}
+9
View File
@@ -856,6 +856,15 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
pen.arc(16.0, 11.5, 4.5, -1.57, 1.57, a);
pen.line(&[(16.0, 7.0), (8.0, 7.0)], a);
}
// Fastener: a socket head screw seen from the side.
"fastener" => {
pen.poly(&[(6.0, 3.0), (18.0, 3.0), (18.0, 9.0), (6.0, 9.0)], pen.fill, ink);
pen.line(&[(10.0, 3.0), (10.0, 6.0), (14.0, 6.0), (14.0, 3.0)], a);
pen.poly(&[(9.0, 9.0), (15.0, 9.0), (15.0, 21.0), (9.0, 21.0)], pen.fill.gamma_multiply(0.85), ink);
for y in [12.0, 15.0, 18.0] {
pen.line(&[(9.0, y), (15.0, y + 1.5)], ink);
}
}
"fold" => {
pen.line(&[(2.0, 18.0), (12.0, 18.0)], ink);
pen.line(&[(12.0, 18.0), (19.0, 6.0)], a);
+1
View File
@@ -19,6 +19,7 @@ pub mod dialogs_appearance;
pub mod dialogs_assembly;
pub mod dialogs_motion;
pub mod dialogs_move;
pub mod dialogs_parts;
pub mod dialogs_plastic;
pub mod dialogs_sheet;
pub mod dim_view;