//! The Appearance dialog: library swatches (named looks), the looks in this design, a colour //! picker with opacity, and the objects to apply to (bodies, or faces). A swatch dragged onto a //! body or face in the viewport, or onto a body's row in the Browser, applies at once; Apply //! (and OK) gives the picked objects the current look. use egui::{Color32, RichText, Sense, Stroke, vec2}; use serde_json::{Value, json}; use solidcraft_engine::Sel; use crate::SolveApp; use crate::dialogs::{Kind, combo, row_label}; use crate::selection::{BODIES, FACES, SelInput}; use crate::theme::Tokens; use crate::viewport::Hit; /// A named look: colour and opacity. #[derive(Clone, Debug, PartialEq)] pub struct Look { pub name: String, pub category: String, pub color: [u8; 3], pub opacity: f64, } /// Built-in looks, used until the engine offers a library (`appearance.library`). fn builtin() -> Vec { 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), l("Metal", "Stainless Steel", [182, 186, 190], 1.0), l("Metal", "Brass", [196, 160, 80], 1.0), l("Metal", "Copper", [184, 115, 72], 1.0), l("Metal", "Chrome", [222, 226, 232], 1.0), l("Metal", "Anodized Black", [42, 44, 48], 1.0), l("Plastic", "ABS - White", [236, 236, 232], 1.0), l("Plastic", "ABS - Black", [34, 34, 36], 1.0), l("Plastic", "ABS - Grey", [128, 130, 134], 1.0), l("Plastic", "Polycarbonate - Clear", [210, 226, 240], 0.35), l("Plastic", "Nylon - Natural", [228, 222, 204], 1.0), l("Plastic", "Rubber - Black", [26, 26, 26], 1.0), l("Paint", "Paint - Red", [196, 40, 36], 1.0), l("Paint", "Paint - Orange", [232, 120, 30], 1.0), l("Paint", "Paint - Yellow", [236, 196, 40], 1.0), l("Paint", "Paint - Green", [52, 140, 72], 1.0), l("Paint", "Paint - Blue", [36, 92, 180], 1.0), l("Paint", "Paint - Purple", [112, 64, 156], 1.0), l("Wood", "Oak", [190, 150, 100], 1.0), l("Wood", "Walnut", [110, 76, 50], 1.0), l("Glass", "Glass - Clear", [220, 236, 244], 0.25), l("Glass", "Glass - Smoked", [90, 96, 104], 0.5), ] } /// The library: the engine's when it has one, else the built-in looks. pub fn library(app: &mut SolveApp) -> Vec { let from_engine = app.session.execute("appearance.library", &json!({})).ok().and_then(|v| { let list = v.get("appearances").or(v.get("library")).unwrap_or(&v).as_array()?.clone(); let looks: Vec = list .iter() .filter_map(|x| { Some(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), }) }) .collect(); (!looks.is_empty()).then_some(looks) }); from_engine.unwrap_or_else(builtin) } /// "#rrggbb" or [r, g, b]. pub fn parse_color(v: &Value) -> Option<[u8; 3]> { if let Some(s) = v.as_str() { let h = s.trim().trim_start_matches('#'); if h.len() != 6 { return None; } let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()); return Some([byte(0)?, byte(2)?, byte(4)?]); } let a = v.as_array()?; let c = |i: usize| a.get(i).and_then(Value::as_u64).filter(|x| *x <= 255).map(|x| x as u8); Some([c(0)?, c(1)?, c(2)?]) } pub fn hex(c: [u8; 3]) -> String { format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) } #[derive(Clone, Debug)] pub struct Ap { /// Apply to faces instead of bodies. pub faces: bool, pub color: [u8; 3], pub opacity: f64, /// The look picked last (its name), if the colour came from one. pub look: Option, /// 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, pub remove_override: bool, /// The last successful immediate Apply/drop, so OK does not duplicate its undo step. pub applied: Option, /// What the last drop or Apply did. pub note: Option, } /// The dialog for the Appearance command. pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { (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 body_look = |body: &solidcraft_engine::doc::ModelBody| { app.session.doc.model_body_look(body).or_else(|| { (body.body.paint().is_some() || body.imported.is_some() || body.body.color().is_some()).then(|| { let color = app.session.doc.body_color(body).map(|rgb| rgb.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)).unwrap_or([196, 199, 204]); solidcraft_engine::doc::appearance::Look::custom(color, app.session.doc.model_body_opacity(body)) }) }) }; let existing = app.session.selection.iter().find_map(|sel| match sel { Sel::Body { name } => state.body(name).and_then(body_look), 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(|| body_look(b))), _ => None, }); ( 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: f64, look: Option<&str>) -> Option { let bodies: Vec = targets.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect(); let faces: Vec = targets .iter() .filter_map(|x| if let Sel::Face { body, point, .. } = x { Some(json!({"body": body, "point": [point.x, point.y, point.z]})) } else { None }) .collect(); if bodies.is_empty() && faces.is_empty() { return None; } let mut p = json!({"color": hex(color)}); if !bodies.is_empty() { p["bodies"] = json!(bodies); } if !faces.is_empty() { p["faces"] = json!(faces); // An engine without face looks: their bodies. if bodies.is_empty() && solidcraft_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(); p["bodies"] = json!(owners); } } p["opacity"] = json!(opacity); if let Some(n) = look { p["appearance"] = json!(n); } Some(p) } /// 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(&[]); 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 { 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 { 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, /// or the bodies of the Browser row under it. fn drop_target(app: &SolveApp, faces: bool) -> Vec { match &app.viewport.hover { Some(Hit::Face { body, index, point }) if faces => vec![Sel::Face { body: body.clone(), index: *index, point: *point }], Some(Hit::Face { body, .. } | Hit::Edge { body, .. } | Hit::Vertex { body, .. }) => vec![Sel::Body { name: body.clone() }], _ => app.viewport.hover_bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect(), } } /// 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<(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(( 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<(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((look, 1)), } } out } 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: f64, picked: bool, tip: &str, payload: Look) -> egui::Response { let t = Tokens::get(); let r = ui .dnd_drag_source(id, payload, |ui| { let (rect, _) = ui.allocate_exact_size(vec2(30.0, 30.0), Sense::hover()); // A checker behind see-through looks. 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, t.hover); } } ui.painter().rect( rect, 5.0, c32(color, opacity), Stroke::new(if picked { 2.0 } else { 1.0 }, if picked { t.accent } else { t.border }), egui::StrokeKind::Inside, ); }) .response; let r = ui.interact(r.rect, id.with("click"), Sense::click()).on_hover_text(tip); if r.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } r } /// Swatches six to a row. fn swatch_rows(ui: &mut egui::Ui, items: &[T], mut each: impl FnMut(&mut egui::Ui, &T)) { ui.vertical(|ui| { ui.spacing_mut().item_spacing = vec2(4.0, 4.0); for row in items.chunks(6) { ui.horizontal(|ui| { for x in row { each(ui, x); } }); } }); } /// The dialog's rows (after the Objects input); applies drops and Apply at once. pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [SelInput]) -> bool { let t = Tokens::get(); // Apply To: bodies or faces (the input follows). row_label(ui, "Apply To"); if let Some(component) = &k.component { ui.label(format!("Component: {component}")); ui.end_row(); row_label(ui, ""); if ui.button(crate::i18n::tr_label("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. row_label(ui, "Colour"); ui.horizontal(|ui| { let mut rgb = k.color; 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; // Decimal formatting must not round a saved look merely by drawing its editor. if ui.add(egui::Slider::new(&mut k.opacity, 0.0..=1.0).max_decimals_opt(None).custom_formatter(|value, _| format!("{value:.2}"))).changed() { k.remove_override = false; } ui.end_row(); row_label(ui, ""); let picked: Vec = inputs.first().map(|i| i.items.clone()).unwrap_or_default(); ui.horizontal(|ui| { let has_target = !picked.is_empty() || k.component.is_some(); if ui.add_enabled(has_target, egui::Button::new(crate::i18n::tr_label("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(crate::i18n::tr_label("Remove override"))) .on_hover_text(crate::i18n::tr_label("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<_> = 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(); } // The library, by category. let lib = library(app); let mut cats: Vec = vec!["All".into()]; for l in &lib { if !cats.contains(&l.category) { cats.push(l.category.clone()); } } let refs: Vec<&str> = cats.iter().map(String::as_str).collect(); 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(crate::i18n::tr_label("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 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(crate::i18n::tr_label("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()) .clicked() { k.color = l.color; k.opacity = l.opacity; k.look = Some(l.name.clone()); k.remove_override = false; } }); ui.end_row(); // A swatch dropped on the model or the Browser. let released = ui.input(|i| i.pointer.any_released()); if released && let Some(look) = egui::DragAndDrop::take_payload::(ui.ctx()) { let targets = drop_target(app, k.faces); k.color = look.color; k.opacity = look.opacity; 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.clone()) { Ok(_) => { k.applied = Some(p); format!("{} on {}", look.name, target_names(&targets)) } Err(e) => e, }); } } if let Some(n) = &k.note { row_label(ui, ""); ui.label(RichText::new(n.as_str()).color(t.text_dim)); ui.end_row(); } false } fn target_names(targets: &[Sel]) -> String { targets .iter() .map(|x| match x { Sel::Body { name } => name.clone(), Sel::Face { body, .. } => format!("a face of {body}"), _ => String::new(), }) .collect::>() .join(", ") } #[cfg(test)] mod tests { use solidcraft_engine::Session; use solidcraft_engine::geom::Vec3; use super::*; use crate::Services; use crate::dialogs::apply_commands; fn app() -> SolveApp { let mut s = Session::default(); s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10})).unwrap(); s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0]})).unwrap(); SolveApp::new(s, Services::default()) } #[test] fn colours_parse_both_ways() { assert_eq!(parse_color(&json!("#0a1B2c")), Some([10, 27, 44])); assert_eq!(parse_color(&json!([1, 2, 3])), Some([1, 2, 3])); assert_eq!(parse_color(&json!([1, 2, 300])), None); assert_eq!(parse_color(&json!("#12345")), None); assert_eq!(hex([10, 27, 44]), "#0a1b2c"); } /// Pre-selected bodies are the objects; OK gives them the picked look. #[test] fn ok_applies_the_look_to_the_objects() { let mut a = app(); a.session.selection = vec![Sel::Body { name: "Body1".into() }, Sel::Body { name: "Body2".into() }]; a.start("appearance.assign"); let mut d = a.dialog.clone().unwrap(); assert_eq!(d.inputs[0].items.len(), 2); if let Kind::Appearance(k) = &mut d.kind { k.color = [196, 40, 36]; k.look = Some("Paint - Red".into()); } let c = apply_commands(&a, &d).unwrap(); assert_eq!(c[0].0, "appearance.assign"); assert_eq!(c[0].1["color"], "#c42824"); assert_eq!(c[0].1["bodies"], json!(["Body1", "Body2"])); for (id, p) in c { a.run(&id, p).unwrap(); } assert_eq!(a.session.doc.appearances.bodies.get("Body2").map(|l| l.color), Some([196, 40, 36])); 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. #[test] fn drops_land_on_what_is_under_the_cursor() { let mut a = app(); let p = Vec3::new(5.0, 5.0, 10.0); a.viewport.hover = Some(Hit::Face { body: "Body1".into(), index: 0, point: p }); assert_eq!(drop_target(&a, false), vec![Sel::Body { name: "Body1".into() }]); assert_eq!(drop_target(&a, true), vec![Sel::Face { body: "Body1".into(), index: 0, point: p }]); a.viewport.hover = None; a.viewport.hover_bodies = vec!["Body2".into()]; assert_eq!(drop_target(&a, false), vec![Sel::Body { name: "Body2".into() }]); let face = apply_params(&[Sel::Face { body: "Body1".into(), index: 0, point: p }], [1, 2, 3], 0.5, None).unwrap(); assert_eq!(face["faces"][0]["body"], "Body1"); 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 header_chevron_folds_and_restores_appearance_inputs() { let mut h = crate::scenario::Harness::new(); h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); h.app.session.selection = vec![Sel::Body { name: "Body1".into() }]; h.app.start("appearance.assign"); h.frames(3); let before = h.app.dialog.clone().unwrap(); h.step(&json!({"widget":"»"})).unwrap(); assert!(h.app.dialog.as_ref().unwrap().collapsed); assert!(h.widget("Appearance name").is_none()); h.step(&json!({"widget":"«"})).unwrap(); let after = h.app.dialog.as_ref().unwrap(); assert!(!after.collapsed); assert_eq!(format!("{:?}", after.inputs), format!("{:?}", before.inputs)); let (Kind::Appearance(before), Kind::Appearance(after)) = (&before.kind, &after.kind) else { panic!("appearance dialog") }; assert_eq!(before.color, after.color); assert_eq!(before.opacity, after.opacity); assert_eq!(before.search, after.search); } #[test] fn real_frames_and_unchanged_apply_keep_precise_opacity() { let mut h = crate::scenario::Harness::new(); h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":"#abcdef","opacity":0.123456})).unwrap(); for cancel in [true, false] { h.app.session.selection = vec![Sel::Body { name: "Body1".into() }]; h.app.start("appearance.assign"); h.frames(3); let Kind::Appearance(appearance) = &h.app.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") }; assert_eq!(appearance.opacity, 0.123456); h.step(&json!({"key":if cancel {"Escape"} else {"Enter"}})).unwrap(); assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog); assert_eq!(h.app.session.doc.appearances.bodies["Body1"].opacity, 0.123456); } } #[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"); } }