From 4c3a5d9853408c796dcf9dea877ff517926a7a21 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 10:39:15 +0000 Subject: [PATCH] Joints mate face to face; Drive Joints takes the keyboard; Appearance on the engine's list Joint dialog: picked origins mate as in Fusion, B's z opposite A's (two top faces put B upside down on A, not inside it; a pin goes into its hole); the dialog's Flip turns B the other way (sent as the command's flip, which turns B over from coinciding frames). Edit Joint shows the same. Found by solvecraft-sketchux's scenarios. Drive Joints: the first value box takes the keyboard with its text selected when the dialog opens, so typing 90 and Enter drives the joint (found by solvecraft-sketchux). Appearance: "In This Design" from appearance.list (in_design) when the engine has it; faces are sent as face targets once the engine knows them. Tests: face snaps mate face to face and Flip undoes it; typing into Drive Joints drives. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/ui-egui/src/dialogs_appearance.rs | 22 +++++-- crates/ui-egui/src/dialogs_assembly.rs | 68 ++++++++++++++++---- crates/ui-egui/src/dialogs_assembly_tests.rs | 59 +++++++++++++++++ 3 files changed, 130 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/dialogs_appearance.rs b/crates/ui-egui/src/dialogs_appearance.rs index 1a85a7fe..1ec7726c 100644 --- a/crates/ui-egui/src/dialogs_appearance.rs +++ b/crates/ui-egui/src/dialogs_appearance.rs @@ -131,8 +131,8 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str } if !faces.is_empty() { p["faces"] = json!(faces); - // Until faces have their own looks: their bodies. - if bodies.is_empty() { + // An engine without face looks: their bodies. + if bodies.is_empty() && solvecraft_engine::find_command("appearance.list").is_none() { let mut owners: Vec = targets.iter().filter_map(|x| if let Sel::Face { body, .. } = x { Some(body.clone()) } else { None }).collect(); owners.dedup(); @@ -164,8 +164,18 @@ fn drop_target(app: &SolveApp, faces: bool) -> Vec { } } -/// The looks bodies have in this design (colour and how many bodies wear it). -fn in_design(app: &SolveApp) -> Vec<([u8; 3], usize)> { +/// The looks in this design (colour and how many items wear it): the engine's list when it has +/// one, else the body colours. +fn in_design(app: &mut SolveApp) -> Vec<([u8; 3], usize)> { + if let Ok(v) = app.session.execute("appearance.list", &json!({})) + && let Some(list) = v.get("in_design").and_then(Value::as_array) + { + let count = |x: &Value, k: &str| x.get(k).and_then(Value::as_array).map_or(0, Vec::len); + return list + .iter() + .filter_map(|x| Some((parse_color(x.get("color")?)?, count(x, "bodies") + count(x, "faces") + count(x, "components")))) + .collect(); + } let mut out: Vec<([u8; 3], usize)> = Vec::new(); for c in app.session.doc.appearances.values() { match out.iter_mut().find(|(x, _)| x == c) { @@ -270,7 +280,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel let items: Vec<(usize, &([u8; 3], usize))> = used.iter().enumerate().collect(); swatch_rows(ui, &items, |ui, (i, (c, n))| { let look = Look { name: hex(*c), category: "In This Design".into(), color: *c, opacity: 1.0 }; - if swatch(ui, egui::Id::new(("ap_used", *i)), *c, 1.0, k.color == *c, &format!("{} · {n} bodies", hex(*c)), look).clicked() { + if swatch(ui, egui::Id::new(("ap_used", *i)), *c, 1.0, k.color == *c, &format!("{} · {n} item(s)", hex(*c)), look).clicked() { k.color = *c; k.look = None; } @@ -382,7 +392,7 @@ mod tests { a.run(&id, p).unwrap(); } assert_eq!(a.session.doc.appearances.get("Body2"), Some(&[196, 40, 36])); - assert_eq!(in_design(&a), vec![([196, 40, 36], 2)]); + assert_eq!(in_design(&mut a), vec![([196, 40, 36], 2)]); } /// A swatch dropped on a face in face mode targets the face; otherwise its body. diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index a93f5a02..eb54fa29 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -30,13 +30,35 @@ const MOVES: [&str; 6] = ["JointAssembleCmdNew", "JointAsBuiltCmd", "FusionMoveJ #[derive(Clone, Debug)] pub enum Asm { - NewComponent { name: String, activate: bool }, + NewComponent { + name: String, + activate: bool, + }, Joint(Box), - JointOrigin { name: String, hover: Hover }, + JointOrigin { + name: String, + hover: Hover, + }, RigidGroup, - Drive { joint: usize, values: Vec }, - MotionLink { a: usize, b: usize, ia: usize, ib: usize, ratio: String, offset: String }, - Interference { result: Option, overlaps: Vec, of: Vec }, + /// `focus`: the first value takes the keyboard (its text selected) on the next frame. + Drive { + joint: usize, + values: Vec, + focus: bool, + }, + MotionLink { + a: usize, + b: usize, + ia: usize, + ib: usize, + ratio: String, + offset: String, + }, + Interference { + result: Option, + overlaps: Vec, + of: Vec, + }, } /// The snap under the cursor, remembered for the selection it was found for. @@ -49,6 +71,7 @@ pub struct JointForm { /// Editing this joint (its origins stay; type, alignment and limits change). pub editing: Option, pub kind: usize, + /// Flip B relative to the mate (faces apart instead of together). pub flip: bool, pub angle: String, pub offset: [String; 3], @@ -130,7 +153,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { "RigidGroupCmd" => (Asm::RigidGroup, vec![SelInput::new("Components", BODIES, true)]), "FusionMoveJointsCommand" => { let joint = movable(s).first().copied().unwrap_or(0); - (Asm::Drive { joint, values: current_values(s, joint) }, vec![]) + (Asm::Drive { joint, values: current_values(s, joint), focus: true }, vec![]) } "FusionMotionRelationshipCommand" => { let b = usize::from(movable(s).len() > 1); @@ -173,7 +196,7 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option { let mut f = JointForm::new(false); f.editing = Some(id); f.kind = TYPES.iter().position(|t| JointKind::parse(t) == Some(j.kind)).unwrap_or(0); - f.flip = j.flip; + f.flip = !j.flip; f.angle = num(j.angle.to_degrees(), "deg"); f.offset[2] = num(j.offset, "mm"); f.fit_limits(); @@ -189,7 +212,7 @@ pub fn edit_joint(app: &SolveApp, id: u64) -> Option { /// The Drive Joints dialog on a joint (by id). pub fn drive_joint(app: &SolveApp, id: u64) -> Option { let joint = app.session.doc.assembly.joints.iter().position(|j| j.id == id)?; - Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint) }), vec![])) + Some(Dialog::new(Kind::Assembly(Asm::Drive { joint, values: current_values(&app.session, joint), focus: true }), vec![])) } // ---- snaps ---- @@ -418,7 +441,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se ui.end_row(); } Asm::RigidGroup => {} - Asm::Drive { joint, values } => { + Asm::Drive { joint, values, focus } => { let s = &app.session; let list = movable(s); if list.is_empty() { @@ -445,7 +468,23 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se let unit_kind = if angle { ValueKind::Angle } else { ValueKind::Length }; let name = j.kind.dofs().get(i).copied().unwrap_or("value"); row_label(ui, &capital(name)); - enter |= field(ui, v); + if i == 0 { + // Typing replaces the value at once, as in the other dialogs' value boxes. + let id = egui::Id::new("sc_drive_value"); + let r = ui.add(egui::TextEdit::singleline(v).id(id)); + crate::params_dialog::complete(ui, &r, v); + if std::mem::take(focus) { + r.request_focus(); + let n = v.chars().count(); + let mut st = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); + st.cursor + .set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), id); + } + enter |= r.lost_focus() && ui.input(|x| x.key_pressed(egui::Key::Enter)); + } else { + enter |= field(ui, v); + } ui.end_row(); // A slider over the limits (or a turn, or ±100 mm). let (lo, hi) = match j.limits.get(i).copied().flatten() { @@ -618,7 +657,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result = f.limits.iter().take(jk.dofs().len()).map(|(on, lo, hi)| if *on { json!([lo, hi]) } else { Value::Null }).collect(); if let Some(id) = f.editing { - let mut out = cmd("joint.edit", json!({"joint": id, "type": kind, "flip": f.flip, "offset": f.offset[2], "angle": f.angle})); + let mut out = cmd("joint.edit", json!({"joint": id, "type": kind, "flip": !f.flip, "offset": f.offset[2], "angle": f.angle})); for (i, l) in limits.iter().enumerate() { let p = match l.as_array() { Some(a) => json!({"joint": id, "index": i, "min": a.first(), "max": a.get(1)}), @@ -645,7 +684,10 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result 1e-12 || y.abs() > 1e-12 { shifted(&a, x, y) } else { a.param }; - let p = json!({"type": kind, "a": a_param, "b": b.param, "flip": f.flip, "offset": f.offset[2], "angle": f.angle, "limits": limits}); + // Picked origins mate as in Fusion: B's z opposite A's (faces meet face to face, a pin + // goes into its hole); the dialog's Flip turns B the other way. The command's `flip` + // turns B over from frames that coincide. + let p = json!({"type": kind, "a": a_param, "b": b.param, "flip": !f.flip, "offset": f.offset[2], "angle": f.angle, "limits": limits}); cmd("JointAssembleCmdNew", p) } Asm::JointOrigin { name, .. } => { @@ -662,7 +704,7 @@ pub fn commands(app: &SolveApp, k: &Asm, inputs: &[SelInput]) -> Result { + Asm::Drive { joint, values, .. } => { let j = s.doc.assembly.joints.get(*joint).ok_or("no joint that moves yet")?; cmd("FusionMoveJointsCommand", json!({"joint": j.id, "values": values})) } diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 451492d1..9dbd7d42 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -203,3 +203,62 @@ fn ok_click_survives_a_busy_preview() { assert_eq!(app.session.doc.assembly.joints.len(), 1, "the click applied the joint"); assert!(app.dialog.is_none()); } + +/// Two top faces mate face to face as in Fusion: B ends up upside down on A, not inside it; +/// Flip turns it back over (into A). +#[test] +fn face_snaps_mate_face_to_face() { + let mut app = two_boxes(); + app.start("JointAssembleCmdNew"); + let mut d = app.dialog.clone().unwrap(); + d.pick(&app.session, face_sel(&app.session, Vec3::new(10.0, 10.0, 10.0)).unwrap()); + d.pick(&app.session, face_sel(&app.session, Vec3::new(40.0, 10.0, 10.0)).unwrap()); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["flip"], true, "the mate turns B over"); + for (id, p) in c { + app.run(&id, p).unwrap(); + } + let st = app.session.world_state(); + let b = st.body("Body2").unwrap().mesh().bounds(); + assert!((b.min.z - 10.0).abs() < 1e-6 && (b.max.z - 20.0).abs() < 1e-6, "{b:?}"); + // Edit Joint shows the mate unflipped; Flip puts B back into A's volume. + let id = app.session.doc.assembly.joints[0].id; + let mut e = edit_joint(&app, id).unwrap(); + if let Kind::Assembly(Asm::Joint(f)) = &mut e.kind { + assert!(!f.flip); + f.flip = true; + } + for (id, p) in apply_commands(&app, &e).unwrap() { + app.run(&id, p).unwrap(); + } + let b = app.session.world_state().body("Body2").unwrap().mesh().bounds(); + assert!((b.max.z - 10.0).abs() < 1e-6, "{b:?}"); +} + +/// Drive Joints takes the keyboard: typing a value and Enter drives the joint. +#[test] +fn drive_value_takes_the_keyboard() { + let mut app = two_boxes(); + let (a, b) = (occ(&app, "A"), occ(&app, "B")); + app.run( + "JointAssembleCmdNew", + json!({"type": "revolute", "a": {"occurrence": a, "face": [10, 10, 10]}, "b": {"occurrence": b, "face": [40, 10, 0]}}), + ) + .unwrap(); + app.start("FusionMoveJointsCommand"); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let frame = |app: &mut SolveApp, events: Vec| { + let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + ctx.run_ui(input, |ui| crate::dialogs::show(app, ui.ctx())) + }; + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text("90".into())]); + let key = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + frame(&mut app, vec![key(true), key(false)]); + frame(&mut app, vec![]); + let j = &app.session.doc.assembly.joints[0]; + assert!((j.values[0] - 90f64.to_radians()).abs() < 1e-9, "{:?}", j.values); + assert!(app.dialog.is_none()); +}