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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-08 10:57:10 +00:00
1 parent b2d611bafe
commit 4c3a5d9853
3 files changed
+130 -19

No files matched your search

+16 -6
View File
@@ -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<String> =
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<Sel> {
}
}
/// 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.
+55 -13
View File
@@ -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<JointForm>),
JointOrigin { name: String, hover: Hover },
JointOrigin {
name: String,
hover: Hover,
},
RigidGroup,
Drive { joint: usize, values: Vec<String> },
MotionLink { a: usize, b: usize, ia: usize, ib: usize, ratio: String, offset: String },
Interference { result: Option<Value>, overlaps: Vec<Mesh>, of: Vec<Sel> },
/// `focus`: the first value takes the keyboard (its text selected) on the next frame.
Drive {
joint: usize,
values: Vec<String>,
focus: bool,
},
MotionLink {
a: usize,
b: usize,
ia: usize,
ib: usize,
ratio: String,
offset: String,
},
Interference {
result: Option<Value>,
overlaps: Vec<Mesh>,
of: Vec<Sel>,
},
}
/// 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<u64>,
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<SelInput>)> {
"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<Dialog> {
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<Dialog> {
/// The Drive Joints dialog on a joint (by id).
pub fn drive_joint(app: &SolveApp, id: u64) -> Option<Dialog> {
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<Vec<(Str
let limits: Vec<Value> =
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<Vec<(Str
}
let (x, y) = (eval(&f.offset[0], ValueKind::Length)?, eval(&f.offset[1], ValueKind::Length)?);
let a_param = if x.abs() > 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<Vec<(Str
}
cmd("RigidGroupCmd", json!({ "occurrences": occs }))
}
Asm::Drive { joint, values } => {
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}))
}
@@ -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<egui::Event>| {
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());
}