feat: strengthen modeling, configuration, and assembly workflows

Preserve linked sketch identities through deterministic face projection and resizing. Fix origin-corner picking, persistent inline navigation, appearance editing, configuration styles, component properties, contact sweeps, sheet exports, analytic STEP offsets, and bounded rolling-ball fit accuracy.

Validated with full cargo xtask ci, native workflow screenshots, repeated enclosure replays, and analytic geometry regressions.
This commit is contained in:
bherbruck committed 2026-10-11 00:37:26 -04:00
1 parent 22672585c9
commit d71bdb78ec
54 files changed
+4179 -316

No files matched your search

+45 -9
View File
@@ -366,7 +366,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(act("ui.editFeature", "Edit Feature", "").with(json!({ "feature": feature })).on(feature.is_some()));
v.push(act("ui.editSketch", "Edit Profile Sketch", "sketch").with(json!({ "sketch": sketch })).on(sketch.is_some()));
v.push(Item::sep());
v.push(act("ui.appearance", "Appearance", "").key("A").on(false));
v.push(act("ui.appearance", "Appearance", "appearance").key("A"));
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] })));
v.push(Item::sep());
v.push(act("ui.delete", "Delete", "delete").key("Del"));
@@ -437,7 +437,7 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec<Item> {
cmd(app, "component.from_bodies", "Create Components from Bodies").with(json!({ "bodies": names })),
Item::sep(),
act("ui.material", "Physical Material", "").with(json!({ "bodies": names })),
act("ui.appearance", "Appearance", "").key("A").on(false),
act("ui.appearance", "Appearance", "appearance").key("A").with(json!({ "bodies": names })),
act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })),
Item::sep(),
act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })),
@@ -530,8 +530,13 @@ fn component_items(app: &SolveApp, id: u64) -> Vec<Item> {
}
v.push(Item::sep());
v.push(act("ui.material", "Physical Material", "").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(act("ui.appearance", "Appearance", "").key("A").on(false));
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(
act("ui.appearance", "Appearance", "appearance")
.key("A")
.with(if id == 0 { json!({ "bodies": names }) } else { json!({"component": id}) })
.on(id != 0 || !bodies.is_empty()),
);
v.push(act("ui.properties", "Properties", "measure").with(json!({ "component": id })));
v.push(Item::sep());
v.push(act("ui.export", "Export…", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": names })).on(!bodies.is_empty()));
@@ -716,10 +721,20 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
};
start_rename(app, what, at);
}
"ui.properties" => match app.session.execute("inspect.measure", &json!({ "bodies": bodies })) {
Ok(v) => app.menu.props = Some((if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) }, v)),
Err(e) => app.set_status(e.to_string(), true),
},
"ui.properties" => {
let command = if p.get("component").is_some() { "component.properties" } else { "inspect.measure" };
let params = if command == "component.properties" { p.clone() } else { json!({ "bodies": bodies }) };
match app.session.execute(command, &params) {
Ok(v) => {
let title = v["name"]
.as_str()
.map(str::to_string)
.unwrap_or_else(|| if bodies.len() == 1 { bodies.join("") } else { format!("{} bodies", bodies.len()) });
app.menu.props = Some((title, v));
}
Err(e) => app.set_status(e.to_string(), true),
}
}
"ui.export" => {
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"]))
@@ -847,6 +862,26 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
app.edit_feature(f);
}
}
"ui.appearance" => {
if item.params.is_some() {
let items: Vec<Value> = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect();
let _ = app.run("select.set", json!({ "items": items }));
}
app.start("appearance.assign");
if let Some(name) = id_of(&p, "component").and_then(|id| app.session.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()))
&& let Some(d) = app.dialog.as_mut()
&& let crate::dialogs::Kind::Appearance(k) = &mut d.kind
{
if let Some(id) = id_of(&p, "component")
&& let Some(look) = app.session.doc.appearances.components.get(&id)
{
k.color = look.color;
k.opacity = look.opacity;
k.look = Some(look.name.clone());
}
k.component = Some(name);
}
}
"ui.material" => {
let items: Vec<Value> = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect();
let _ = app.run("select.set", json!({ "items": items }));
@@ -1502,7 +1537,8 @@ fn show_props(app: &mut SolveApp, ctx: &egui::Context) {
ui.label("Area");
ui.label(crate::prefs::show_mm(app, f(total, "area_mm2"), 2));
ui.end_row();
if let [b] = bodies.as_slice() {
let geometry = if total.get("center_of_mass").is_some() { Some(total) } else { bodies.first().filter(|_| bodies.len() == 1) };
if let Some(b) = geometry {
if let Some(c) = b["center_of_mass"].as_array() {
let c: Vec<String> = c.iter().map(|x| crate::prefs::show_mm_bare(app, x.as_f64().unwrap_or(f64::NAN))).collect();
ui.label("Center of mass");
+12 -1
View File
@@ -1226,6 +1226,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
.stroke(Stroke::new(1.0, t.dialog_border))
.corner_radius(egui::CornerRadius { nw: 4, sw: 4, ne: 0, se: 0 })
.shadow(egui::Shadow { offset: [-2, 2], blur: 8, spread: 0, color: Color32::from_black_alpha(40) });
// The area includes the border as well as the inner margins; reserve all of it so a
// horizontally scrolling table cannot push a wide dialog two pixels past its limit.
let horizontal_frame = frame.inner_margin.leftf() + frame.inner_margin.rightf() + 2.0 * frame.stroke.width;
// OK is offered once the inputs make a feature.
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. });
let valid = !has_ok || !d.previews() || (apply_commands(app, &d).is_ok() && app.preview.error.is_none());
@@ -1245,7 +1248,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.spacing_mut().text_edit_width = FIELD_W;
// Long texts (errors, notes) wrap instead of widening the dialog.
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
let margin = 2.0 * ui.style().spacing.window_margin.leftf();
let margin = horizontal_frame;
let min_w = if wide { 480.0 } else { DIALOG_MIN_W } - margin;
ui.set_min_width(min_w);
ui.set_max_width(if wide { DIALOG_WIDE_MAX_W } else { DIALOG_MAX_W } - margin);
@@ -1268,7 +1271,15 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
ui.add_space(2.0);
// Configuration tables own their column widths and scrolling; the two-column form
// grid would squeeze their row names and action buttons into a label-sized cell.
if let Kind::Motion(k @ crate::dialogs_motion::Mo::Configurations { .. }) = &mut d.kind {
enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs);
}
egui::Grid::new("sc_dialog_grid").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
if matches!(&d.kind, Kind::Motion(crate::dialogs_motion::Mo::Configurations { .. })) {
return;
}
placement_row(&mut d, ui);
input_rows(&mut d, ui);
match &mut d.kind {
+251 -48
View File
@@ -19,12 +19,12 @@ pub struct Look {
pub name: String,
pub category: String,
pub color: [u8; 3],
pub opacity: f32,
pub opacity: f64,
}
/// Built-in looks, used until the engine offers a library (`appearance.library`).
fn builtin() -> Vec<Look> {
let l = |cat: &str, name: &str, c: [u8; 3], o: f32| Look { name: name.into(), category: cat.into(), color: c, opacity: o };
let l = |cat: &str, name: &str, c: [u8; 3], o: f64| Look { name: name.into(), category: cat.into(), color: c, opacity: o };
vec![
l("Metal", "Aluminum - Satin", [196, 199, 204], 1.0),
l("Metal", "Steel - Brushed", [160, 164, 170], 1.0),
@@ -63,7 +63,7 @@ pub fn library(app: &mut SolveApp) -> Vec<Look> {
name: x.get("name")?.as_str()?.to_string(),
category: x.get("category").and_then(Value::as_str).unwrap_or("Library").to_string(),
color: parse_color(x.get("color")?)?,
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32,
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
})
})
.collect();
@@ -96,27 +96,61 @@ pub struct Ap {
/// Apply to faces instead of bodies.
pub faces: bool,
pub color: [u8; 3],
pub opacity: f32,
pub opacity: f64,
/// The look picked last (its name), if the colour came from one.
pub look: Option<String>,
/// Library category shown (0: all).
pub category: usize,
/// Library names can be searched without changing the selected look.
pub search: String,
/// A Browser component target, inherited by its bodies unless they have overrides.
pub component: Option<String>,
pub remove_override: bool,
/// The last successful immediate Apply/drop, so OK does not duplicate its undo step.
pub applied: Option<Value>,
/// What the last drop or Apply did.
pub note: Option<String>,
}
/// The dialog for the Appearance command.
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
(id == "appearance.assign").then(|| {
let faces = app.session.selection.iter().any(|s| matches!(s, Sel::Face { .. }))
&& !app.session.selection.iter().any(|s| matches!(s, Sel::Body { .. }));
let state = app.session.world_state();
let existing = app.session.selection.iter().find_map(|sel| match sel {
Sel::Body { name } => state.body(name).and_then(|b| app.session.doc.body_look(name, b.feature)),
Sel::Face { body, index, .. } => state.body(body).and_then(|b| {
app.session
.doc
.face_colors(b)
.into_iter()
.find(|(i, _)| i == index)
.map(|(_, look)| look)
.or_else(|| app.session.doc.body_look(body, b.feature))
}),
_ => None,
});
(
Kind::Appearance(Ap { faces: false, color: [196, 199, 204], opacity: 1.0, look: None, category: 0, note: None }),
vec![SelInput::new("Objects", BODIES, true)],
Kind::Appearance(Ap {
faces,
color: existing.as_ref().map_or([196, 199, 204], |l| l.color),
opacity: existing.as_ref().map_or(1.0, |l| l.opacity),
look: existing.map(|l| l.name),
category: 0,
search: String::new(),
component: None,
remove_override: false,
applied: None,
note: None,
}),
vec![SelInput::new("Objects", if faces { FACES } else { BODIES }, true)],
)
})
}
/// The command giving `targets` (bodies or faces) a look.
fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str>) -> Option<Value> {
fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str>) -> Option<Value> {
let bodies: Vec<String> = targets.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
let faces: Vec<Value> = targets
.iter()
@@ -139,9 +173,7 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str
p["bodies"] = json!(owners);
}
}
if opacity < 0.999 {
p["opacity"] = json!((opacity * 1000.0).round() / 1000.0);
}
p["opacity"] = json!(opacity);
if let Some(n) = look {
p["appearance"] = json!(n);
}
@@ -150,8 +182,35 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f32, look: Option<&str
/// The commands OK runs: the current look on the picked objects (nothing when none are picked).
pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> {
// Remove override already ran immediately; OK closes without creating a second undo step.
if k.remove_override {
return Vec::new();
}
let items = inputs.first().map(|i| i.items.as_slice()).unwrap_or(&[]);
apply_params(items, k.color, k.opacity, k.look.as_deref()).map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default()
let p = selected_params(k, items);
if p.as_ref().is_some_and(|p| k.applied.as_ref() == Some(p)) {
return Vec::new();
}
p.map(|p| vec![("appearance.assign".to_string(), p)]).unwrap_or_default()
}
fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
if let Some(component) = &k.component {
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look}))
} else {
apply_params(items, k.color, k.opacity, k.look.as_deref())
}
}
/// Removing an override restores the inherited component or material appearance.
fn clear_params(k: &Ap, items: &[Sel]) -> Option<Value> {
let mut p = selected_params(k, items)?;
if let Some(obj) = p.as_object_mut() {
obj.remove("appearance");
obj.remove("color");
obj.remove("opacity");
}
Some(p)
}
/// What a swatch dropped now would land on: the face or body under the cursor in the viewport,
@@ -166,32 +225,43 @@ fn drop_target(app: &SolveApp, faces: bool) -> Vec<Sel> {
/// 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)> {
fn in_design(app: &mut SolveApp) -> Vec<(Look, 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"))))
.filter_map(|x| {
Some((
Look {
name: x.get("name").and_then(Value::as_str).unwrap_or("Custom").to_string(),
category: "In This Design".into(),
color: parse_color(x.get("color")?)?,
opacity: x.get("opacity").and_then(Value::as_f64).unwrap_or(1.0),
},
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.bodies.values().map(|l| l.color) {
match out.iter_mut().find(|(x, _)| *x == c) {
let mut out: Vec<(Look, usize)> = Vec::new();
for l in app.session.doc.appearances.bodies.values() {
let look = Look { name: l.name.clone(), category: "In This Design".into(), color: l.color, opacity: l.opacity };
match out.iter_mut().find(|(x, _)| *x == look) {
Some(e) => e.1 += 1,
None => out.push((c, 1)),
None => out.push((look, 1)),
}
}
out
}
fn c32(c: [u8; 3], opacity: f32) -> Color32 {
fn c32(c: [u8; 3], opacity: f64) -> Color32 {
Color32::from_rgba_unmultiplied(c[0], c[1], c[2], (opacity.clamp(0.0, 1.0) * 255.0) as u8)
}
/// A swatch: a rounded square of the look; returns its response (click, drag).
fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f32, picked: bool, tip: &str, payload: Look) -> egui::Response {
fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f64, picked: bool, tip: &str, payload: Look) -> egui::Response {
let t = Tokens::get();
let r = ui
.dnd_drag_source(id, payload, |ui| {
@@ -200,7 +270,7 @@ fn swatch(ui: &mut egui::Ui, id: egui::Id, color: [u8; 3], opacity: f32, picked:
if opacity < 0.999 {
let h = rect.width() / 2.0;
for (dx, dy) in [(0.0, 0.0), (h, h)] {
ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + vec2(dx, dy), vec2(h, h)), 0.0, Color32::from_gray(200));
ui.painter().rect_filled(egui::Rect::from_min_size(rect.min + vec2(dx, dy), vec2(h, h)), 0.0, t.hover);
}
}
ui.painter().rect(
@@ -238,13 +308,23 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
let t = Tokens::get();
// Apply To: bodies or faces (the input follows).
row_label(ui, "Apply To");
let mut mode = usize::from(k.faces);
combo(ui, "ap_mode", &["Bodies/Components", "Faces"], &mut mode);
ui.end_row();
if (mode == 1) != k.faces {
k.faces = mode == 1;
if let Some(i) = inputs.first_mut() {
*i = SelInput::new("Objects", if k.faces { FACES } else { BODIES }, true);
if let Some(component) = &k.component {
ui.label(format!("Component: {component}"));
ui.end_row();
row_label(ui, "");
if ui.button("Select bodies or faces instead").clicked() {
k.component = None;
}
ui.end_row();
} else {
let mut mode = usize::from(k.faces);
combo(ui, "ap_mode", &["Bodies", "Faces"], &mut mode);
ui.end_row();
if (mode == 1) != k.faces {
k.faces = mode == 1;
if let Some(i) = inputs.first_mut() {
*i = SelInput::new("Objects", if k.faces { FACES } else { BODIES }, true);
}
}
}
// The current look: colour and opacity.
@@ -254,35 +334,71 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
if ui.color_edit_button_srgb(&mut rgb).changed() {
k.color = rgb;
k.look = None;
k.remove_override = false;
}
ui.label(RichText::new(k.look.clone().unwrap_or_else(|| hex(k.color))).color(t.text_dim));
});
ui.end_row();
row_label(ui, "Opacity");
ui.spacing_mut().slider_width = 110.0;
ui.add(egui::Slider::new(&mut k.opacity, 0.05..=1.0).fixed_decimals(2));
if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).fixed_decimals(2)).changed() {
k.remove_override = false;
}
ui.end_row();
row_label(ui, "");
let picked: Vec<Sel> = inputs.first().map(|i| i.items.clone()).unwrap_or_default();
if ui.add_enabled(!picked.is_empty(), egui::Button::new("Apply to selection")).clicked()
&& let Some(p) = apply_params(&picked, k.color, k.opacity, k.look.as_deref())
{
k.note = Some(match app.run("appearance.assign", p) {
Ok(_) => format!("applied to {} item(s)", picked.len()),
Err(e) => e,
});
}
ui.horizontal(|ui| {
let has_target = !picked.is_empty() || k.component.is_some();
if ui.add_enabled(has_target, egui::Button::new("Apply to selection")).clicked()
&& let Some(p) = selected_params(k, &picked)
{
k.remove_override = false;
k.note = Some(match app.run("appearance.assign", p.clone()) {
Ok(_) => {
k.applied = Some(p);
"Appearance applied".into()
}
Err(e) => e,
});
}
if ui
.add_enabled(has_target, egui::Button::new("Remove override"))
.on_hover_text("Restore the inherited component or physical material appearance")
.clicked()
&& let Some(p) = clear_params(k, &picked)
{
k.note = Some(match app.run("appearance.assign", p) {
Ok(_) => {
k.remove_override = true;
k.applied = None;
"Inherited appearance restored".into()
}
Err(e) => e,
});
}
});
ui.end_row();
// In this design.
let used = in_design(app);
if !used.is_empty() {
row_label(ui, "In This Design");
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} item(s)", hex(*c)), look).clicked() {
k.color = *c;
k.look = None;
let items: Vec<_> = used.iter().enumerate().collect();
swatch_rows(ui, &items, |ui, (i, (look, n))| {
if swatch(
ui,
egui::Id::new(("ap_used", *i)),
look.color,
look.opacity,
k.color == look.color && k.opacity == look.opacity,
&format!("{} · {n} item(s)", look.name),
look.clone(),
)
.clicked()
{
k.color = look.color;
k.opacity = look.opacity;
k.look = Some(look.name.clone());
k.remove_override = false;
}
});
ui.end_row();
@@ -299,9 +415,18 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
row_label(ui, "Library");
combo(ui, "ap_cat", &refs, &mut k.category);
ui.end_row();
row_label(ui, "Search");
let search = ui.add(egui::TextEdit::singleline(&mut k.search).hint_text("Appearance name").desired_width(190.0));
crate::scenario::publish_handle("appearance:search", search.rect.center());
ui.end_row();
row_label(ui, "");
let want = cats.get(k.category).cloned().unwrap_or_default();
let shown: Vec<(usize, &Look)> = lib.iter().enumerate().filter(|(_, l)| k.category == 0 || l.category == want).collect();
let query = k.search.trim().to_lowercase();
let shown: Vec<(usize, &Look)> =
lib.iter().enumerate().filter(|(_, l)| (k.category == 0 || l.category == want) && l.name.to_lowercase().contains(&query)).collect();
if shown.is_empty() {
ui.label(RichText::new("No matching appearances").color(t.text_dim));
}
swatch_rows(ui, &shown, |ui, (i, l)| {
let picked_look = k.look.as_deref() == Some(l.name.as_str());
if swatch(ui, egui::Id::new(("ap_lib", *i)), l.color, l.opacity, picked_look, &format!("{} (drag onto a body or face)", l.name), (*l).clone())
@@ -310,6 +435,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
k.color = l.color;
k.opacity = l.opacity;
k.look = Some(l.name.clone());
k.remove_override = false;
}
});
ui.end_row();
@@ -319,10 +445,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
let targets = drop_target(app, k.faces);
k.color = look.color;
k.opacity = look.opacity;
k.look = (look.category != "In This Design").then(|| look.name.clone());
k.look = Some(look.name.clone());
k.remove_override = false;
if let Some(p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) {
k.note = Some(match app.run("appearance.assign", p) {
Ok(_) => format!("{} on {}", look.name, target_names(&targets)),
k.note = Some(match app.run("appearance.assign", p.clone()) {
Ok(_) => {
k.applied = Some(p);
format!("{} on {}", look.name, target_names(&targets))
}
Err(e) => e,
});
}
@@ -392,7 +522,11 @@ mod tests {
a.run(&id, p).unwrap();
}
assert_eq!(a.session.doc.appearances.bodies.get("Body2").map(|l| l.color), Some([196, 40, 36]));
assert_eq!(in_design(&mut a), vec![([196, 40, 36], 2)]);
let used = in_design(&mut a);
assert_eq!(used.len(), 1);
assert_eq!(used[0].0.color, [196, 40, 36]);
assert_eq!(used[0].0.name, "Paint - Red");
assert_eq!(used[0].1, 2);
}
/// A swatch dropped on a face in face mode targets the face; otherwise its body.
@@ -411,4 +545,73 @@ mod tests {
assert_eq!(face["opacity"], 0.5);
assert!(!library(&mut a).is_empty());
}
#[test]
fn face_preselection_keeps_the_face_and_remove_survives_ok() {
let mut a = app();
let point = Vec3::new(5.0, 5.0, 10.0);
let selected = vec![Sel::Face { body: "Body1".into(), index: 0, point }];
a.session.selection = selected.clone();
a.start("appearance.assign");
let mut d = a.dialog.clone().unwrap();
assert_eq!(d.inputs[0].items, selected);
let Kind::Appearance(k) = &mut d.kind else { panic!("appearance dialog") };
assert!(k.faces);
k.color = [10, 20, 30];
k.opacity = 0.25;
for (id, p) in commands(k, &d.inputs) {
a.run(&id, p).unwrap();
}
assert_eq!(a.session.doc.appearances.faces.len(), 1);
assert!(a.session.doc.appearances.bodies.is_empty());
a.run("appearance.assign", clear_params(k, &d.inputs[0].items).unwrap()).unwrap();
k.remove_override = true;
assert!(commands(k, &d.inputs).is_empty());
assert!(a.session.doc.appearances.faces.is_empty());
a.run("edit.undo", json!({})).unwrap();
assert_eq!(a.session.doc.appearances.faces.len(), 1);
}
#[test]
fn reused_design_look_preserves_transparency_and_name() {
let mut a = app();
a.run("appearance.assign", json!({"bodies": ["Body1"], "color": "#abcdef", "appearance": "Tinted glass", "opacity": 0.25})).unwrap();
let used = in_design(&mut a);
let look = &used[0].0;
assert_eq!(look.opacity, 0.25);
assert_eq!(look.name, "Tinted glass");
a.session.selection = vec![Sel::Body { name: "Body1".into() }];
a.start("appearance.assign");
let Kind::Appearance(k) = &a.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
assert_eq!(k.opacity, 0.25);
assert_eq!(k.look.as_deref(), Some("Tinted glass"));
let p = apply_params(&[Sel::Body { name: "Body2".into() }], look.color, look.opacity, Some(&look.name)).unwrap();
a.run("appearance.assign", p).unwrap();
assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
}
#[test]
fn immediate_apply_then_ok_has_one_undo_and_retains_precise_opacity() {
let mut a = app();
a.run("appearance.assign", json!({"bodies": ["Body1"], "color": "#abcdef", "opacity": 0.123456})).unwrap();
a.session.selection = vec![Sel::Body { name: "Body1".into() }];
a.start("appearance.assign");
let mut d = a.dialog.clone().unwrap();
let Kind::Appearance(k) = &mut d.kind else { panic!("appearance dialog") };
assert_eq!(commands(k, &d.inputs)[0].1["opacity"], json!(0.123456));
k.color = [1, 2, 3];
let p = selected_params(k, &d.inputs[0].items).unwrap();
a.run("appearance.assign", p.clone()).unwrap();
k.applied = Some(p);
assert!(commands(k, &d.inputs).is_empty());
a.run("edit.undo", json!({})).unwrap();
let restored = a.session.doc.appearances.bodies.get("Body1").unwrap();
assert_eq!(restored.color, [171, 205, 239]);
assert_eq!(restored.opacity, 0.123456);
k.opacity = 0.5;
assert_eq!(commands(k, &d.inputs).len(), 1, "changed look still applies on OK");
k.opacity = 0.123456;
d.inputs[0].items = vec![Sel::Body { name: "Body2".into() }];
assert_eq!(commands(k, &d.inputs).len(), 1, "changed target still applies on OK");
}
}
+198 -47
View File
@@ -597,56 +597,115 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
let run = |app: &mut SolveApp, error: &mut Option<String>, id: &str, p: Value| {
*error = app.run(id, p).err();
};
// Header.
ui.label(RichText::new("Configuration").strong());
ui.horizontal(|ui| {
for c in &tb.columns {
let (kind, n) = column_parts(c);
ui.add_sized(vec2(96.0, 18.0), egui::Label::new(RichText::new(n).strong()).truncate())
.on_hover_text(format!("{} {n}", if kind == "suppress" { "Suppress" } else { "Parameter" }));
if ui.small_button("×").on_hover_text("Remove the column").clicked() {
let key = if kind == "suppress" { "feature" } else { "param" };
run(app, error, "config.column", json!({ key: n, "delete": true }));
}
}
});
ui.end_row();
for (name, vals) in &tb.rows {
let active = tb.active.as_deref() == Some(name.as_str());
ui.horizontal(|ui| {
ui.label(if active { RichText::new(name).strong() } else { RichText::new(name) });
if active {
ui.label(RichText::new("Active").color(t.accent));
} else if ui.button("Activate").clicked() {
run(app, error, "config.activate", json!({ "row": name }));
}
});
ui.horizontal(|ui| {
for (c, val) in tb.columns.iter().zip(vals) {
let id = egui::Id::new(("cfg", name, c));
let mut text = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| val.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(96.0));
if r.changed() {
ui.data_mut(|d| d.insert_temp(id, text.clone()));
egui::ScrollArea::horizontal().id_salt("configuration_columns").auto_shrink([false, true]).show(ui, |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
egui::Grid::new("configuration_table").num_columns(2).min_col_width(210.0).spacing(vec2(10.0, 8.0)).show(ui, |ui| {
// Header.
ui.label(RichText::new("Configuration").strong());
ui.horizontal(|ui| {
for c in &tb.columns {
ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
let (kind, n) = column_parts(c);
let label = match kind {
"material" => format!("Material: {n}"),
"appearance" => format!("Appearance: {n}"),
"suppress" => format!("Suppress: {n}"),
_ => n.to_string(),
};
ui.add_sized(vec2(156.0, 22.0), egui::Label::new(RichText::new(label).strong()).truncate()).on_hover_text(c);
if ui.small_button("×").on_hover_text("Remove the column").clicked() {
let key = if kind == "suppress" { "feature" } else { kind };
run(app, error, "config.column", json!({ key: n, "delete": true }));
}
});
}
if r.lost_focus() {
ui.data_mut(|d| d.remove::<String>(id));
if text != *val {
let (_, n) = column_parts(c);
run(app, error, "config.row", json!({"name": name, "values": { n: cell_value(c, &text) }}));
});
ui.end_row();
for (name, vals) in &tb.rows {
let active = tb.active.as_deref() == Some(name.as_str());
ui.horizontal(|ui| {
ui.add_sized(
vec2(100.0, 22.0),
egui::Label::new(if active { RichText::new(name).strong() } else { RichText::new(name) }).truncate(),
)
.on_hover_text(name);
if active {
ui.label(RichText::new("Active").color(t.accent));
if v.get("matches").and_then(Value::as_bool) == Some(false) && ui.button("Apply").clicked() {
run(app, error, "config.activate", json!({ "row": name }));
}
} else if ui.button("Activate").clicked() {
run(app, error, "config.activate", json!({ "row": name }));
}
}
ui.add_space(18.0);
}
if ui.small_button("×").on_hover_text("Delete the row").clicked() {
run(app, error, "config.row", json!({"name": name, "delete": true}));
});
ui.horizontal(|ui| {
for (c, val) in tb.columns.iter().zip(vals) {
ui.allocate_ui_with_layout(vec2(190.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
let (kind, _) = column_parts(c);
let shown = if kind == "appearance" {
solvecraft_engine::doc::config::appearance_cell(val)
.ok()
.flatten()
.filter(|look| solvecraft_engine::doc::appearance::library(&look.name).as_ref() == Some(look))
.map(|look| look.name)
.unwrap_or_else(|| val.clone())
} else {
val.clone()
};
let style = matches!(kind, "material" | "appearance");
if style {
let mut choice = None;
ui.menu_button("Library", |ui| {
if ui.button(if kind == "appearance" { "Follow material" } else { "Default material" }).clicked() {
choice = Some("Default".to_string());
ui.close();
}
if kind == "material" {
for (name, _) in solvecraft_engine::doc::MATERIALS.iter().filter(|(n, _)| *n != "Default") {
if ui.button(*name).clicked() {
choice = Some(name.to_string());
ui.close();
}
}
} else {
for (name, _, _) in solvecraft_engine::doc::appearance::LIBRARY {
if ui.button(name).clicked() {
choice = Some(name.to_string());
ui.close();
}
}
}
});
if let Some(choice) = choice {
run(app, error, "config.row", json!({"name": name, "values": { c: choice }}));
}
}
let id = egui::Id::new(("cfg", name, c));
let mut text = ui.data(|d| d.get_temp::<String>(id)).unwrap_or_else(|| shown.clone());
let r = ui.add(egui::TextEdit::singleline(&mut text).desired_width(if style { 106.0 } else { 164.0 }));
if r.changed() {
ui.data_mut(|d| d.insert_temp(id, text.clone()));
}
if r.lost_focus() {
ui.data_mut(|d| d.remove::<String>(id));
if text != shown {
run(app, error, "config.row", json!({"name": name, "values": { c: cell_value(c, &text) }}));
}
}
});
}
if ui.small_button("×").on_hover_text("Delete the row").clicked() {
run(app, error, "config.row", json!({"name": name, "delete": true}));
}
});
ui.end_row();
}
});
ui.end_row();
}
});
// New row (a copy of the active one) and new column.
ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(130.0));
ui.horizontal(|ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
ui.add(egui::TextEdit::singleline(new_row).hint_text("new configuration").desired_width(160.0));
if ui.button("Add Row").on_hover_text("A new configuration, a copy of the active one").clicked() {
let name = if new_row.trim().is_empty() {
(tb.rows.len() + 1..).map(|k| format!("Configuration{k}")).find(|n| !tb.rows.iter().any(|r| &r.0 == n)).unwrap_or_default()
@@ -672,6 +731,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
options.push((format!("Suppress {}", f.name), json!({ "feature": f.name })));
}
}
for body in &app.session.model.state().bodies {
for kind in ["material", "appearance"] {
if !tb.columns.contains(&format!("{kind}:{}", body.name)) {
options.push((format!("{} ({kind})", body.name), json!({kind: body.name})));
}
}
}
let mut pick: Option<Value> = None;
ui.menu_button("Add Column", |ui| {
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
@@ -688,9 +754,14 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
let (id, p) = if tb.columns.is_empty() {
(
"config.configure",
match p.get("param") {
Some(n) => json!({ "params": [n] }),
None => json!({ "features": [p.get("feature")] }),
if let Some(n) = p.get("param") {
json!({ "params": [n] })
} else if let Some(n) = p.get("feature") {
json!({ "features": [n] })
} else if let Some(n) = p.get("material") {
json!({ "materials": [n] })
} else {
json!({ "appearances": [p.get("appearance")] })
},
)
} else {
@@ -837,6 +908,86 @@ mod tests {
assert_eq!(cell_value("param:width", "90 mm"), json!("90 mm"));
}
#[test]
fn configuration_style_picker_applies_then_inherits_material() {
let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap();
let mut harness = crate::scenario::Harness::new();
for step in steps.as_array().unwrap() {
harness.step(step).unwrap();
match step.get("shot").and_then(Value::as_str) {
Some("configuration_red") => {
let look = harness.app.session.doc.appearances.bodies.get("Block").unwrap();
assert_eq!(look.color, [196, 40, 40]);
assert!(harness.app.session.doc.configuration_matches());
}
Some("configuration_default") => {
assert!(!harness.app.session.doc.appearances.bodies.contains_key("Block"));
let state = harness.app.session.model.state();
let body = state.body("Block").unwrap();
let look = harness.app.session.doc.body_look("Block", body.feature).unwrap();
assert_eq!(look.name, "Steel");
assert_eq!(look.color, [158, 164, 172]);
assert!(harness.app.session.doc.configuration_matches());
}
_ => {}
}
}
}
#[test]
fn configuration_table_actions_stay_on_one_line_at_normal_and_narrow_widths() {
for width in [1400, 900] {
let mut harness = crate::scenario::Harness::new();
harness.call("ui.resize", json!({"width": width, "height": 720}));
harness
.run(&json!([
{"call":"engine.execute","params":{"command":"solid.box","params":{"length":40,"width":20,"height":10,"body_name":"Block"}}},
{"start":"config.table"}
]))
.unwrap();
assert_single_line(&harness, "Configuration");
for name in ["span", "thickness", "depth", "clearance"] {
harness.call("engine.execute", json!({"command":"parameters.change","params":{"name":name,"expression":"10 mm"}}));
}
harness
.run(&json!([
{"call":"engine.execute","params":{"command":"config.configure","params":{
"params":["span","thickness","depth","clearance"],"materials":["Block"],"appearances":["Block"]
}}},
{"call":"engine.execute","params":{"command":"config.row","params":{"name":"Default","values":{"param:span":"20 mm"}}}}
]))
.unwrap();
harness.frames(4);
for label in ["Configuration", "Active", "Apply", "Add Row", "Add Column"] {
assert_single_line(&harness, label);
}
crate::scenario::check(&mut harness, &json!({"windows_fit":{"max_width":560}})).unwrap();
for name in ["span", "thickness", "depth", "clearance"] {
harness.call("engine.execute", json!({"command":"config.column","params":{"param":name,"delete":true}}));
}
harness.call("engine.execute", json!({"command":"config.column","params":{"appearance":"Block","delete":true}}));
assert_single_line(&harness, "Material: Block");
harness.call("engine.execute", json!({"command":"config.column","params":{"material":"Block","delete":true}}));
harness.call("engine.execute", json!({"command":"config.column","params":{"feature":"Box1"}}));
assert_single_line(&harness, "Suppress: Box1");
}
fn assert_single_line(harness: &crate::scenario::Harness, label: &str) {
let tree = harness.output.accesskit_update.as_ref().unwrap();
let bounds = tree
.nodes
.iter()
.filter(|(_, n)| n.label() == Some(label) || n.value() == Some(label))
.filter_map(|(_, n)| n.bounds())
.collect::<Vec<_>>();
assert!(
!bounds.is_empty(),
"missing {label}; labels/values: {:?}",
tree.nodes.iter().filter_map(|(_, n)| n.label().or(n.value())).collect::<Vec<_>>()
);
assert!(bounds.iter().all(|b| b.y1 - b.y0 <= 24.0), "{label} wrapped: {bounds:?}");
}
}
#[test]
fn study_keys_by_joint_id() {
let mut s = Session::default();
+11 -5
View File
@@ -360,7 +360,12 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
}
row_label(ui, "");
if ui.button("Export DXF…").clicked() {
*status = Some(export_dxf(app, v["body"].as_str().unwrap_or("")));
*status = Some(export_flat(app, v["body"].as_str().unwrap_or(""), "dxf"));
}
ui.end_row();
row_label(ui, "");
if ui.button("Export Bend Table…").clicked() {
*status = Some(export_flat(app, v["body"].as_str().unwrap_or(""), "csv"));
}
ui.end_row();
if let Some(s) = status {
@@ -381,12 +386,13 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
enter
}
fn export_dxf(app: &mut SolveApp, body: &str) -> String {
let name = format!("{}-{body}.dxf", app.session.doc.name);
let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &["dxf"])) else {
fn export_flat(app: &mut SolveApp, body: &str, extension: &str) -> String {
let name = format!("{}-{body}.{extension}", app.session.doc.name);
let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[extension])) else {
return "Export cancelled".into();
};
match app.run("sheet.export_dxf", json!({"body": body, "path": path})) {
let command = if extension == "csv" { "sheet.export_bend_table" } else { "sheet.export_dxf" };
match app.run(command, json!({"body": body, "path": path})) {
Ok(_) => {
app.set_status(format!("Exported {path}"), false);
format!("Saved {path}")
+43
View File
@@ -106,3 +106,46 @@ fn hem_and_rules() {
run_all(&mut app, c);
assert!(app.session.doc.sheet.rules.iter().any(|r| r.name == "Alu"));
}
#[test]
fn flat_exports_use_the_save_picker() {
use std::sync::{Arc, Mutex};
let mut app = plate();
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
let picks = Arc::new(Mutex::new(Vec::new()));
let captured = picks.clone();
app.services.pick_save = Some(Box::new(move |name, extensions| {
captured.lock().unwrap().push((name.to_string(), extensions.iter().map(|x| x.to_string()).collect::<Vec<_>>()));
None
}));
assert_eq!(export_flat(&mut app, "Base", "csv"), "Export cancelled");
assert_eq!(export_flat(&mut app, "Base", "dxf"), "Export cancelled");
let picks = picks.lock().unwrap();
assert!(picks[0].0.ends_with("-Base.csv"));
assert_eq!(picks[0].1, vec!["csv"]);
assert!(picks[1].0.ends_with("-Base.dxf"));
assert_eq!(picks[1].1, vec!["dxf"]);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn bend_table_picker_writes_the_current_body() {
let mut app = plate();
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.run("sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20})).unwrap();
let body = app.session.model.state().sheets[0].body.clone();
let path = std::env::temp_dir().join(format!("solvecraft-bend-table-{}.csv", std::process::id()));
let output = path.to_string_lossy().into_owned();
let chosen = output.clone();
app.services.pick_save = Some(Box::new(move |_, extensions| {
assert_eq!(extensions, &["csv"]);
Some(chosen.clone())
}));
let undo = app.session.undo.len();
assert_eq!(export_flat(&mut app, &body, "csv"), format!("Saved {output}"));
let contents = std::fs::read_to_string(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
assert!(contents.contains(",90.000000000,up,2.500000000,"), "{contents}");
assert_eq!(app.session.undo.len(), undo);
}
+45 -1
View File
@@ -45,6 +45,32 @@ fn empty_space_offers_view_items_and_greys_out_undo() {
assert!(!app.ui.show_origin);
}
#[test]
fn appearance_browser_action_targets_the_clicked_body() {
let mut app = sample_app();
let b = body(&app);
app.run("select.clear", json!({})).unwrap();
act(&mut app, &Target::Body { name: b.clone() }, "Appearance");
let dialog = app.dialog.as_ref().unwrap();
assert!(matches!(dialog.kind, crate::dialogs::Kind::Appearance(_)));
assert_eq!(dialog.inputs[0].items, vec![solvecraft_engine::Sel::Body { name: b }]);
}
#[test]
fn appearance_component_action_assigns_an_inherited_look() {
let mut app = sample_app();
let created = app.run("component.create", json!({"name": "Painted part"})).unwrap();
let id = created["component"].as_u64().unwrap();
act(&mut app, &Target::Component { id }, "Appearance");
let d = app.dialog.clone().unwrap();
let commands = crate::dialogs::apply_commands(&app, &d).unwrap();
assert_eq!(commands[0].1["components"], json!(["Painted part"]));
for (id, p) in commands {
app.run(&id, p).unwrap();
}
assert!(app.session.doc.appearances.components.contains_key(&id));
}
#[test]
fn face_menu_hides_and_finds_the_body() {
let mut app = sample_app();
@@ -54,7 +80,7 @@ fn face_menu_hides_and_finds_the_body() {
for l in ["Create Sketch", "Offset Plane", "Press Pull", "Extrude", "Measure", "Find in Browser", "Find in Timeline"] {
assert!(find(&items, l).enabled, "{l}");
}
assert!(!find(&items, "Appearance").enabled);
assert!(find(&items, "Appearance").enabled);
context_menu::run_item(&mut app, &find(&items, "Find in Timeline"), pos2(0.0, 0.0));
assert!(matches!(app.session.selection.as_slice(), [solvecraft_engine::Sel::Feature { .. }]));
context_menu::run_item(&mut app, &find(&items, "Show/Hide"), pos2(0.0, 0.0));
@@ -97,6 +123,24 @@ fn properties_measure_the_body() {
assert!(v["total"]["mass_g"].as_f64().unwrap() > 0.0);
}
#[test]
fn component_properties_measure_one_assembly_placement() {
let mut session = Session::default();
let component = session.execute("component.create", &json!({"name": "Subassembly"})).unwrap()["component"].as_u64().unwrap();
session.execute("component.create", &json!({"name": "Block"})).unwrap();
session.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap();
session.execute("occurrence.copy", &json!({"component": component, "translate": [100, 0, 0]})).unwrap();
let mut app = SolveApp::new(session, Services::default());
act(&mut app, &Target::Component { id: component }, "Properties");
let (title, properties) = app.menu.props.as_ref().unwrap();
assert_eq!(title, "Subassembly");
assert_eq!(properties["body_count"], 1);
assert!((properties["total"]["volume_mm3"].as_f64().unwrap() - 1000.0).abs() < 1e-6);
assert!((properties["total"]["center_of_mass"][0].as_f64().unwrap() - 5.0).abs() < 1e-6);
act(&mut app, &Target::Component { id: 0 }, "Properties");
assert_eq!(app.menu.props.as_ref().unwrap().1["body_count"], 2);
}
#[test]
fn sketch_menu_hides_and_moves_to_another_plane() {
let mut app = sample_app();
@@ -0,0 +1,63 @@
use super::*;
use solvecraft_engine::sketch::CurveKind;
#[test]
fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() {
for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"] {
let mut h = crate::scenario::Harness::new();
h.app.ui.show_origin = true;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]})).unwrap();
h.frames(2);
let sid = h.app.session.active_sketch.unwrap();
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
let cursor = proj.to_screen(Vec3::ZERO).unwrap();
// The ordinary snap still finds the fixed origin; the corner tool must instead
// reach the rectangle's overlapping endpoint.
assert_eq!(sketch_point_at(&h.app, &proj, cursor).unwrap().1.as_deref(), Some("origin"));
h.step(&json!({"start":command})).unwrap();
h.step(&json!({"move":{"world":[0,0,0]}})).unwrap();
let point = h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref().unwrap();
assert_ne!(point, "origin", "the actual shared endpoint must be highlighted");
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
let st = h.app.session.world_state();
let ss = st.sketch(sid).unwrap();
assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
assert!(ss.sketch.points[0].fixed);
assert_eq!(ss.sketch.curves.len(), 5, "{command} must actually modify the geometry; status: {:?}", h.app.status);
if command == "sketch.fillet" {
assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. })));
} else {
assert!(ss.sketch.curves.iter().all(|c| matches!(c.kind, CurveKind::Line { .. })));
}
let expected = if command == "sketch.fillet" { 600.0 - 25.0 * (1.0 - std::f64::consts::PI / 4.0) } else { 587.5 };
assert_eq!(ss.profiles.len(), 1);
assert!((ss.profiles[0].area - expected).abs() < 1e-6, "{command}: {}", ss.profiles[0].area);
h.app.run("edit.undo", json!({})).unwrap();
let st = h.app.session.world_state();
let ss = st.sketch(sid).unwrap();
assert_eq!(ss.sketch.curves.len(), 4);
assert!((ss.profiles[0].area - 600.0).abs() < 1e-6);
}
}
#[test]
fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() {
let mut h = crate::scenario::Harness::new();
h.app.ui.show_origin = true;
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.step(&json!({"start":"sketch.line"})).unwrap();
h.step(&json!({"move":{"world":[0,0,0]}})).unwrap();
assert_eq!(h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref(), Some("origin"));
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
assert_eq!(h.app.tool.as_ref().unwrap().pts[0].1.as_deref(), Some("origin"));
h.app.tool = None;
h.app.run("sketch.line", json!({"points":["origin",[20,0],[20,20],[0,20],"origin"]})).unwrap();
h.step(&json!({"start":"sketch.fillet"})).unwrap();
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
let st = h.app.session.world_state();
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
assert!(ss.sketch.curves.iter().any(|c| matches!(c.kind, CurveKind::Arc { .. })));
assert_eq!(ss.profiles.len(), 1);
}
+56 -3
View File
@@ -240,6 +240,30 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
let px = 2.0 * app.cam.half_height() / f64::from(proj.rect.height().max(1.0));
let lv = live(t, e, px);
let focus = std::mem::take(&mut t.dims_focus);
// Egui processes ordinary Tab navigation before these floating text edits are drawn.
// Remember the box that owned the keyboard and restore a focus within this tool after
// drawing, so the last dimension wraps to the first rather than reaching the panels.
let tab = ui.input(|input| {
input.raw.events.iter().find_map(|event| match event {
egui::Event::Key { key: egui::Key::Tab, pressed: true, modifiers, .. } if !modifiers.ctrl && !modifiers.alt && !modifiers.command => {
Some(modifiers.shift)
}
_ => None,
})
});
let source = tab.and_then(|_| {
ui.ctx().memory(|memory| {
(0..t.dims.len())
.find(|i| memory.had_focus_last_frame(egui::Id::new(("sc_dim", i))))
.or_else(|| (0..t.dims.len()).find(|i| memory.has_focus(egui::Id::new(("sc_dim", i)))))
})
});
if source.is_some() {
// Also cancel navigation on the first focused frame, before the text edit's focus
// filter has been installed (e.g. immediately repeated keyboard input).
ui.ctx().memory_mut(|memory| memory.move_focus(egui::FocusDirection::None));
}
let mut completed = false;
let mut any_focused = false;
for (i, b) in t.dims.iter_mut().enumerate() {
let Some((v, at)) = lv.get(i).copied() else { continue };
@@ -260,9 +284,19 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
egui::Area::new(egui::Id::new(("sc_dim_area", i))).fixed_pos(pos).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
let fill = if b.locked { tk.accent_soft } else { tk.panel };
egui::Frame::popup(ui.style()).fill(fill).inner_margin(egui::Margin::symmetric(3, 1)).show(ui, |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0)).on_hover_text(b.label);
crate::params_dialog::complete(ui, &r, &mut b.value);
if r.changed() {
let r = ui
.add(egui::TextEdit::singleline(&mut b.value).id(id).desired_width(66.0).event_filter(egui::EventFilter {
tab: true,
horizontal_arrows: true,
vertical_arrows: true,
..Default::default()
}))
.on_hover_text(b.label);
// A completion gets first use of forward Tab. Shift+Tab always moves back;
// the box reached by egui's generic traversal must not complete its text.
let changed = (source.is_none() || (source == Some(i) && tab != Some(true))) && crate::params_dialog::complete(ui, &r, &mut b.value);
completed |= changed && source == Some(i);
if r.changed() || changed {
b.locked = true;
}
if focus && i == 0 {
@@ -272,6 +306,21 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
});
});
}
if let Some(source) = source
&& !completed
{
let next = if tab == Some(true) { (source + t.dims.len() - 1) % t.dims.len() } else { (source + 1) % t.dims.len() };
let id = egui::Id::new(("sc_dim", next));
if let Some(dimension) = t.dims.get(next) {
let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(
egui::text::CCursor::new(0),
egui::text::CCursor::new(dimension.value.chars().count()),
)));
state.store(ui.ctx(), id);
}
ui.ctx().memory_mut(|memory| memory.request_focus(id));
}
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
let nothing = ui.ctx().memory(|m| m.focused().is_none());
(enter && (any_focused || nothing)).then_some(e)
@@ -281,3 +330,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option
pub fn dimension_params(entities: &[String], ty: &str, value: &str) -> Value {
json!({"entities": entities, "type": ty, "value": value})
}
#[cfg(test)]
#[path = "sketch_dims_tests.rs"]
mod tests;
+128
View File
@@ -0,0 +1,128 @@
//! Real keyboard input must stay inside the dimensions of the shape being drawn.
use serde_json::json;
use crate::scenario::Harness;
fn drawing(command: &str) -> Harness {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.step(&json!({"start": command})).unwrap();
h.step(&json!({"click": {"world": [0, 0, 0]}})).unwrap();
h.step(&json!({"move": {"world": [20, 10, 0]}})).unwrap();
assert_focus(&h, 0);
h
}
fn assert_focus(h: &Harness, index: usize) {
let expected = egui::Id::new(("sc_dim", index));
assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(expected), "focus must stay in inline dimension {index}");
}
fn tab(h: &mut Harness, backwards: bool) {
h.step(&json!({"key": "Tab", "shift": backwards})).unwrap();
}
fn type_value(h: &mut Harness, text: &str) {
h.step(&json!({"key": "A", "cmd": true})).unwrap();
h.step(&json!({"text": text})).unwrap();
}
#[test]
fn tabs_cycle_rectangle_dimensions_indefinitely_and_enter_commits_values() {
for command in ["sketch.rectangle.two_point", "sketch.rectangle.center"] {
let mut h = drawing(command);
type_value(&mut h, "30");
tab(&mut h, false);
assert_focus(&h, 1);
type_value(&mut h, "12");
for i in 0..8 {
tab(&mut h, false);
assert_focus(&h, i % 2);
}
for i in 0..8 {
tab(&mut h, true);
assert_focus(&h, i % 2);
}
// Revisited locked dimensions are selected, so another value replaces them directly.
h.step(&json!({"text": "14"})).unwrap();
tab(&mut h, true);
assert_focus(&h, 0);
h.step(&json!({"text": "32"})).unwrap();
let dims = &h.app.tool.as_ref().unwrap().dims;
assert_eq!(dims.iter().map(|d| d.value.as_str()).collect::<Vec<_>>(), ["32", "14"]);
assert!(dims.iter().all(|d| d.locked));
h.step(&json!({"key": "Enter"})).unwrap();
let state = h.app.session.world_state();
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(sketch.sketch.curves.iter().filter(|curve| !curve.construction).count(), 4);
assert_eq!(sketch.profiles.len(), 1);
assert!((sketch.profiles[0].area - 448.0).abs() < 1e-6, "{command}: {}", sketch.profiles[0].area);
}
}
#[test]
fn line_dimensions_wrap_both_directions_and_enter_uses_length_and_angle() {
let mut h = drawing("sketch.line");
type_value(&mut h, "10");
tab(&mut h, false);
type_value(&mut h, "45 deg");
for i in 0..6 {
tab(&mut h, false);
assert_focus(&h, i % 2);
}
tab(&mut h, true);
assert_focus(&h, 0);
h.step(&json!({"key": "Enter"})).unwrap();
let state = h.app.session.world_state();
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(sketch.sketch.curves.len(), 1);
let points: Vec<_> = sketch.sketch.points.iter().map(|point| point.pos).collect();
assert!(points.iter().any(|point| (point.x - 10.0 / 2f64.sqrt()).abs() < 1e-6 && (point.y - 10.0 / 2f64.sqrt()).abs() < 1e-6));
}
#[test]
fn single_dimension_keeps_focus_for_tab_and_shift_tab() {
let mut h = drawing("sketch.circle.center");
type_value(&mut h, "8");
for backwards in [false, false, true, true, false] {
tab(&mut h, backwards);
assert_focus(&h, 0);
}
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "8");
h.step(&json!({"key": "Enter"})).unwrap();
let state = h.app.session.world_state();
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(sketch.profiles.len(), 1);
assert!((sketch.profiles[0].area - std::f64::consts::PI * 16.0).abs() < 1e-6);
}
#[test]
fn forward_tab_completes_expression_then_cycles_and_shift_tab_moves_back() {
let mut h = drawing("sketch.rectangle.two_point");
h.app.run("parameters.add", json!({"name": "width", "expression": "24 mm"})).unwrap();
h.frames(2);
type_value(&mut h, "wid");
tab(&mut h, false);
assert_focus(&h, 0);
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "width");
tab(&mut h, false);
assert_focus(&h, 1);
type_value(&mut h, "5");
tab(&mut h, true);
assert_focus(&h, 0);
type_value(&mut h, "wid");
tab(&mut h, true);
assert_focus(&h, 1);
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "wid", "Shift+Tab must navigate rather than accepting a completion");
tab(&mut h, true);
assert_focus(&h, 0);
tab(&mut h, false);
assert_focus(&h, 0);
assert_eq!(h.app.tool.as_ref().unwrap().dims[0].value, "width");
h.step(&json!({"key": "Enter"})).unwrap();
let state = h.app.session.world_state();
let sketch = state.sketch(h.app.session.active_sketch.unwrap()).unwrap();
assert_eq!(sketch.profiles.len(), 1);
assert!((sketch.profiles[0].area - 120.0).abs() < 1e-6);
}
+45 -6
View File
@@ -458,6 +458,41 @@ fn sketch_entity(app: &SolveApp, hits: &[Hit]) -> Option<(String, bool)> {
})
}
/// Corner tools accept endpoints shared by two editable curves. Pick among those endpoints
/// directly so the origin, a circle centre or an overlapping loose point cannot hide a corner.
pub(super) fn corner_point(app: &SolveApp, proj: &Proj, pos: Pos2, cmd: &str) -> Option<(Vec2, String)> {
let st = app.session.world_state();
let ss = st.sketch(active_sketch(app)?)?;
let sk = &ss.sketch;
sk.points
.iter()
.enumerate()
.filter_map(|(pi, point)| {
let distance = proj.to_screen(ss.plane.to_world(point.pos))?.distance(pos);
if distance >= 8.0 {
return None;
}
let curves: Vec<_> = sk
.curves
.iter()
.enumerate()
.filter(|(ci, c)| !sk.is_linked_curve(*ci) && c.kind.ends().is_some_and(|(a, b)| a == pi || b == pi))
.take(3)
.collect();
if curves.len() != 2
|| curves.iter().any(|(_, c)| {
!matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. })
&& (cmd != "sketch.fillet" || !matches!(c.kind, solvecraft_engine::sketch::CurveKind::Arc { .. }))
})
{
return None;
}
Some((distance, point.pos, point.id.clone()))
})
.min_by(|a, b| a.0.total_cmp(&b.0))
.map(|(_, at, id)| (at, id))
}
fn xy(p: Vec2) -> Value {
json!([p.x, p.y])
}
@@ -624,6 +659,12 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
}
}
Mode::Entities(n) => {
if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") {
if let Some((_, id)) = corner_point(app, proj, pos, cmd) {
run_corner(app, cmd, &id);
}
return;
}
if cmd == "sketch.offset" && !tool.picks.is_empty() {
if let Some((p, _)) = at {
offset_side(app, tool, p);
@@ -640,12 +681,6 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
}
return;
}
if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") {
if is_point {
run_corner(app, cmd, &id);
}
return;
}
if cmd == "sketch.centerline" {
let _ = app.run(cmd, json!({"curves": [id]}));
return;
@@ -1132,3 +1167,7 @@ fn text_entry(app: &mut SolveApp, ctx: &egui::Context) {
None => TEXT.with(|t| *t.borrow_mut() = Some((at, text))),
}
}
#[cfg(test)]
#[path = "sketch_corner_ui_tests.rs"]
mod corner_ui_tests;
+5 -1
View File
@@ -100,7 +100,11 @@ fn xy(p: &(Vec2, Option<String>)) -> Value {
pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
crate::sketch3d::on_hover(app, proj, pos);
let h = sketch_point_at(app, proj, pos);
let h = if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) {
crate::sketch_tools::corner_point(app, proj, pos, tool.cmd).map(|(at, id)| (at, Some(id)))
} else {
sketch_point_at(app, proj, pos)
};
if let Some(t) = app.tool.as_mut() {
t.hover = h;
}