From f8f0504b9a858a8d264c51d3ba233bfc092116ee Mon Sep 17 00:00:00 2001 From: lforgiarini <97414997+lforgiarini@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:15:53 +0100 Subject: [PATCH] perf(ui): Properties palette keeps the selection's shared values per revision and selection Working out every shared value on each frame made the palette slow with thousands of objects selected. The values (and the selected handles' hex ids) are kept in ui.data until the drawing revision, the selection or the units change. Entity helpers no longer clone whole hatches, texts or polylines for one field. Tests: a value typed in the palette reaches every selected object; the shown values follow revision and selection. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/palettes.rs | 426 ++++++++++++++++++++++----------- 1 file changed, 286 insertions(+), 140 deletions(-) diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 8ab4508b1..e050a3e2b 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -708,6 +708,60 @@ fn properties_header(app: &mut CadApp, ui: &mut egui::Ui) { /// What the palette shows for a property that differs between the selected objects. const VARIES: &str = "*VARIES*"; +/// What the palette works out from the selection, for one drawing revision and one selection. +#[derive(Default)] +struct SelectionValues { + key: u64, + ids: std::sync::Arc>, + values: std::collections::HashMap<&'static str, Box>, +} + +/// The shared handle to a [`SelectionValues`], kept in `ui.data` between frames. +#[derive(Clone, Default)] +struct SelectionMemo(std::sync::Arc>); + +impl SelectionMemo { + /// The value kept under `key`, worked out by `f` the first time. + fn get(&self, key: &'static str, f: impl FnOnce() -> T) -> T { + let mut m = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(v) = m.values.get(key).and_then(|v| v.downcast_ref::()) { + return v.clone(); + } + let v = f(); + m.values.insert(key, Box::new(v.clone())); + v + } + + /// The selected handles as hex strings, as `properties.set` takes them. + fn ids(&self) -> std::sync::Arc> { + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).ids.clone() + } +} + +/// The memo for the current drawing revision and selection; `extra` holds whatever else the shown +/// values depend on (units, angle settings). +fn selection_memo(ui: &egui::Ui, app: &CadApp, sel: &[cadcraft_engine::doc::Handle], extra: impl std::hash::Hash) -> SelectionMemo { + use std::hash::{Hash, Hasher}; + let fresh = || { + let ids = sel.iter().map(|h| h.hex()).collect(); + SelectionMemo(std::sync::Arc::new(std::sync::Mutex::new(SelectionValues { ids: std::sync::Arc::new(ids), ..Default::default() }))) + }; + let Ok(st) = app.session.state() else { return fresh() }; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + (st.uid, st.revision, extra, sel).hash(&mut hasher); + let key = hasher.finish(); + let id = egui::Id::unique("properties-selection-memo"); + if let Some(memo) = ui.data(|d| d.get_temp::(id)) + && memo.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).key == key + { + return memo; + } + let memo = fresh(); + memo.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).key = key; + ui.data_mut(|d| d.insert_temp(id, memo.clone())); + memo +} + /// Properties palette: the selection's common properties, or the current defaults when nothing is selected; formats lengths per drawing units and dispatches setter commands. pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let t = Tokens::get(); @@ -873,33 +927,40 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { // Selected objects: common + geometry for a single object. let first = sel.first().and_then(|h| d.entity(*h)).map(|e| (**e).clone()); let Some(e) = first else { return }; - let same = |f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String { - let vals: Vec = sel.iter().filter_map(|h| d.entity(*h)).map(|e| f(e)).collect(); - match vals.first() { - Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(), - _ => VARIES.into(), - } + // What the palette works out from the selection is kept while the drawing and the selection + // stay as they are: with thousands of objects selected, redoing it every frame is slow. + let memo = + selection_memo(ui, app, &sel, (lu, lp, app.session.angle_settings().shown(0.0).to_bits(), app.session.angle_settings().shown(1.0).to_bits())); + let same = |key: &'static str, f: &dyn Fn(&cadcraft_doc::Entity) -> String| -> String { + memo.get(key, || { + let vals: Vec = sel.iter().filter_map(|h| d.entity(*h)).map(|e| f(e)).collect(); + match vals.first() { + Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(), + _ => VARIES.into(), + } + }) }; - let selected_color = Some(e.common.color).filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| e.common.color == *c)); + let selected_color = memo.get("color", || Some(e.common.color).filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| e.common.color == *c))); let color = selected_color.map_or_else(|| VARIES.into(), Color::name); let effective_color = |e: &cadcraft_doc::Entity| { let layer = d.layer(&e.common.layer).map_or(Color::WHITE, |l| l.color); e.common.color.resolve(layer, Color::WHITE) }; - let resolved_color = sel - .first() - .and_then(|h| d.entity(*h)) - .map(|e| effective_color(e)) - .filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| effective_color(e) == *c)) - .unwrap_or(Rgb(255, 255, 255)); - let layer = same(&|e| e.common.layer.clone()); - let linetype = same(&|e| e.common.linetype.clone()); - let lts = same(&|e| format!("{:.4}", e.common.ltscale)); - let lw = same(&|e| e.common.lineweight.name()); - let tr = same(&|e| e.common.transparency.name()); + let resolved_color = memo.get("resolved color", || { + sel.first() + .and_then(|h| d.entity(*h)) + .map(|e| effective_color(e)) + .filter(|c| sel.iter().filter_map(|h| d.entity(*h)).all(|e| effective_color(e) == *c)) + .unwrap_or(Rgb(255, 255, 255)) + }); + let layer = same("layer", &|e| e.common.layer.clone()); + let linetype = same("linetype", &|e| e.common.linetype.clone()); + let lts = same("ltscale", &|e| format!("{:.4}", e.common.ltscale)); + let lw = same("lineweight", &|e| e.common.lineweight.name()); + let tr = same("transparency", &|e| e.common.transparency.name()); let layers: Vec = d.layers.iter().map(|l| l.name.clone()).collect(); let linetypes: Vec = d.linetypes.iter().map(|l| l.name.clone()).collect(); - let ids: Vec = sel.iter().map(|h| h.hex()).collect(); + let ids = &*memo.ids(); let mut set: Option = None; let mut custom_set: Option = None; let group = |ui: &mut egui::Ui, title: &str| { @@ -912,7 +973,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let width = (ui.available_width() - 10.0).max(0.0); if let Some(c) = crate::layers::color_button_resolved( ui, - egui::Id::unique(("properties_color", &ids)), + egui::Id::unique(("properties_color", ids)), selected_color.unwrap_or(Color::ByLayer), resolved_color, &color, @@ -948,7 +1009,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); }); prop_row(ui, "Linetype scale", |ui| { - if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", &ids)), lts.clone()), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("lts", ids)), lts.clone()), &mut bad) { set = Some(json!({ "handles": ids, "ltscale": f })); } }); @@ -978,7 +1039,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } ui.separator(); // A fixed percentage, committed when the drag or edit ends. - let tid = egui::Id::unique(("prop_tr", &ids)); + let tid = egui::Id::unique(("prop_tr", ids)); let cur = tr.parse::().ok(); let mut pct = ui.data_mut(|d| d.get_temp::(tid)).or(cur).unwrap_or(0); ui.horizontal(|ui| { @@ -1011,22 +1072,22 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { match &e.kind { EntityKind::Line(l) => { prop_row(ui, "Start X", |ui| { - if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", &ids)), len(l.a.x)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sx", ids)), len(l.a.x)), &mut bad) { set = Some(json!({ "handles": ids, "start": [f, l.a.y] })); } }); prop_row(ui, "Start Y", |ui| { - if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", &ids)), len(l.a.y)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("sy", ids)), len(l.a.y)), &mut bad) { set = Some(json!({ "handles": ids, "start": [l.a.x, f] })); } }); prop_row(ui, "End X", |ui| { - if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", &ids)), len(l.b.x)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ex", ids)), len(l.b.x)), &mut bad) { set = Some(json!({ "handles": ids, "end": [f, l.b.y] })); } }); prop_row(ui, "End Y", |ui| { - if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", &ids)), len(l.b.y)), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with(("ey", ids)), len(l.b.y)), &mut bad) { set = Some(json!({ "handles": ids, "end": [l.b.x, f] })); } }); @@ -1034,16 +1095,16 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { prop_row(ui, "Delta X", |ui| read_only(ui, &len(dlt.x))); prop_row(ui, "Delta Y", |ui| read_only(ui, &len(dlt.y))); // Length and angle keep the start point and move the end point. - num(ui, "Length", "length", len(dlt.len()), &mut set, &mut bad, &ids); - num(ui, "Angle", "angle", real(dlt.angle().to_degrees()), &mut set, &mut bad, &ids); + num(ui, "Length", "length", len(dlt.len()), &mut set, &mut bad, ids); + num(ui, "Angle", "angle", real(dlt.angle().to_degrees()), &mut set, &mut bad, ids); } EntityKind::Circle(c) => { - num(ui, "Center X", "cx", len(c.center.x), &mut set, &mut bad, &ids); - num(ui, "Center Y", "cy", len(c.center.y), &mut set, &mut bad, &ids); - num(ui, "Radius", "radius", len(c.radius), &mut set, &mut bad, &ids); - num(ui, "Diameter", "diameter", len(c.radius * 2.0), &mut set, &mut bad, &ids); - num(ui, "Circumference", "circumference", len(c.radius * std::f64::consts::TAU), &mut set, &mut bad, &ids); - num(ui, "Area", "area", real(c.radius * c.radius * std::f64::consts::PI), &mut set, &mut bad, &ids); + num(ui, "Center X", "cx", len(c.center.x), &mut set, &mut bad, ids); + num(ui, "Center Y", "cy", len(c.center.y), &mut set, &mut bad, ids); + num(ui, "Radius", "radius", len(c.radius), &mut set, &mut bad, ids); + num(ui, "Diameter", "diameter", len(c.radius * 2.0), &mut set, &mut bad, ids); + num(ui, "Circumference", "circumference", len(c.radius * std::f64::consts::TAU), &mut set, &mut bad, ids); + num(ui, "Area", "area", real(c.radius * c.radius * std::f64::consts::PI), &mut set, &mut bad, ids); if let Some(v) = &mut set && let Some(o) = v.as_object_mut() { @@ -1056,31 +1117,31 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } } EntityKind::Arc(a) => { - num(ui, "Radius", "radius", len(a.radius), &mut set, &mut bad, &ids); - num(ui, "Start angle", "startAngle", real(a.start.to_degrees()), &mut set, &mut bad, &ids); - num(ui, "End angle", "endAngle", real(a.end.to_degrees()), &mut set, &mut bad, &ids); + num(ui, "Radius", "radius", len(a.radius), &mut set, &mut bad, ids); + num(ui, "Start angle", "startAngle", real(a.start.to_degrees()), &mut set, &mut bad, ids); + num(ui, "End angle", "endAngle", real(a.end.to_degrees()), &mut set, &mut bad, ids); } EntityKind::Text(tx) => { prop_row(ui, "Contents", |ui| { - if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", &ids)), tx.value.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", ids)), tx.value.clone()) { set = Some(json!({ "handles": ids, "text": s })); } }); - num(ui, "Height", "height", len(tx.height), &mut set, &mut bad, &ids); - num(ui, "Rotation", "rotation", real(angles.shown(tx.rotation).to_degrees()), &mut set, &mut bad, &ids); - num(ui, "Width factor", "widthFactor", real(tx.width_factor), &mut set, &mut bad, &ids); + num(ui, "Height", "height", len(tx.height), &mut set, &mut bad, ids); + num(ui, "Rotation", "rotation", real(angles.shown(tx.rotation).to_degrees()), &mut set, &mut bad, ids); + num(ui, "Width factor", "widthFactor", real(tx.width_factor), &mut set, &mut bad, ids); } EntityKind::MText(tx) => { prop_row(ui, "Contents", |ui| { - if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", &ids)), tx.contents.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", ids)), tx.contents.clone()) { set = Some(json!({ "handles": ids, "text": s })); } }); - num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, &ids); - num(ui, "Defined width", "width", len(tx.width), &mut set, &mut bad, &ids); + num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, ids); + num(ui, "Defined width", "width", len(tx.width), &mut set, &mut bad, ids); } EntityKind::LwPolyline(pl) => { - num(ui, "Global width", "width", len(pl.const_width), &mut set, &mut bad, &ids); + num(ui, "Global width", "width", len(pl.const_width), &mut set, &mut bad, ids); let g = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed }; prop_row(ui, "Length", |ui| read_only(ui, &len(g.len()))); if pl.closed { @@ -1103,12 +1164,12 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } }); }); - num(ui, "Scale", "scale", real(hh.scale), &mut set, &mut bad, &ids); - num(ui, "Angle", "angle", real(hh.angle.to_degrees()), &mut set, &mut bad, &ids); + num(ui, "Scale", "scale", real(hh.scale), &mut set, &mut bad, ids); + num(ui, "Angle", "angle", real(hh.angle.to_degrees()), &mut set, &mut bad, ids); } EntityKind::Dimension(dm) => { prop_row(ui, "Text override", |ui| { - if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", &ids)), dm.text.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", ids)), dm.text.clone()) { set = Some(json!({ "handles": ids, "textOverride": s })); } }); @@ -1120,9 +1181,9 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { // The style's values with this dimension's own overrides; editing one overrides // it for the selected dimensions, as AutoCAD's Properties palette does. let st = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); - num(ui, "Text height", "textHeight", len(st.text_height), &mut set, &mut bad, &ids); - num(ui, "Arrow size", "arrowSize", len(st.arrow_size), &mut set, &mut bad, &ids); - num(ui, "Dim scale overall", "dimScale", real(st.scale), &mut set, &mut bad, &ids); + num(ui, "Text height", "textHeight", len(st.text_height), &mut set, &mut bad, ids); + num(ui, "Arrow size", "arrowSize", len(st.arrow_size), &mut set, &mut bad, ids); + num(ui, "Dim scale overall", "dimScale", real(st.scale), &mut set, &mut bad, ids); prop_row(ui, "Precision", |ui| { let cur = st.decimals.to_string(); if let Some(n) = pick_menu(ui, &cur, (0..=8).map(|n: u8| n.to_string())).and_then(|n| n.parse::().ok()) { @@ -1139,8 +1200,8 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { prop_row(ui, "Position X", |ui| read_only(ui, &len(i.insert.x))); prop_row(ui, "Position Y", |ui| read_only(ui, &len(i.insert.y))); prop_row(ui, "Position Z", |ui| read_only(ui, &len(i.insert.z))); - num(ui, "Rotation", "rotation", real(angles.shown(i.rotation).to_degrees()), &mut set, &mut bad, &ids); - num(ui, "Scale", "scale", real(i.scale.x), &mut set, &mut bad, &ids); + num(ui, "Rotation", "rotation", real(angles.shown(i.rotation).to_degrees()), &mut set, &mut bad, ids); + num(ui, "Scale", "scale", real(i.scale.x), &mut set, &mut bad, ids); if let Some((_, props)) = custom.filter(|(_, p)| !p.is_empty()) { group(ui, "Custom"); for p in &props { @@ -1173,44 +1234,46 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { // Several dimensions: every property of one dimension, `*VARIES*` where they differ; an // edit applies to all of them. group(ui, "Geometry"); - let shared = |f: &dyn Fn(&cadcraft_doc::Dimension, &cadcraft_doc::DimStyle) -> String| -> String { - let vals: Vec = dims - .iter() - .map(|dm| { - let st = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); - f(dm, &st) - }) - .collect(); - match vals.first() { - Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(), - _ => VARIES.into(), - } + let shared = |key: &'static str, f: &dyn Fn(&cadcraft_doc::Dimension, &cadcraft_doc::DimStyle) -> String| -> String { + memo.get(key, || { + let vals: Vec = dims + .iter() + .map(|dm| { + let st = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides); + f(dm, &st) + }) + .collect(); + match vals.first() { + Some(v0) if vals.iter().all(|v| v == v0) => v0.clone(), + _ => VARIES.into(), + } + }) }; - let text = shared(&|dm, _| dm.text.clone()); + let text = shared("dim.text", &|dm, _| dm.text.clone()); prop_row(ui, "Text override", |ui| { - if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", &ids)), text.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("dimtxt", ids)), text.clone()) { set = Some(json!({ "handles": ids, "textOverride": s })); } }); - let style = shared(&|dm, _| dm.style.clone()); + let style = shared("dim.style", &|dm, _| dm.style.clone()); prop_row(ui, "Dim style", |ui| { if let Some(n) = pick_menu(ui, &style, d.dim_styles.iter().map(|st| st.name.clone())) { set = Some(json!({ "handles": ids, "dimStyle": n })); } }); let numbers = [ - ("Text height", "textHeight", shared(&|_, st| len(st.text_height))), - ("Arrow size", "arrowSize", shared(&|_, st| len(st.arrow_size))), - ("Dim scale overall", "dimScale", shared(&|_, st| real(st.scale))), + ("Text height", "textHeight", shared("dim.text_height", &|_, st| len(st.text_height))), + ("Arrow size", "arrowSize", shared("dim.arrow_size", &|_, st| len(st.arrow_size))), + ("Dim scale overall", "dimScale", shared("dim.scale", &|_, st| real(st.scale))), ]; for (label, key, shown) in numbers { prop_row(ui, label, |ui| { - if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, &ids)), shown), &mut bad) { + if let Some(f) = number(edit_field(ui, ui.scope_id().with((key, ids)), shown), &mut bad) { set = Some(json!({ "handles": ids, key: f })); } }); } - let precision = shared(&|_, st| st.decimals.to_string()); + let precision = shared("dim.precision", &|_, st| st.decimals.to_string()); prop_row(ui, "Precision", |ui| { if let Some(n) = pick_menu(ui, &precision, (0..=8).map(|n: u8| n.to_string())).and_then(|n| n.parse::().ok()) { set = Some(json!({ "handles": ids, "precision": n })); @@ -1221,90 +1284,111 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { // Edits apply to all of them; positions are shown but stay read-only, since typing one // would move every object to the same place. group(ui, "Geometry"); - let shared = |f: &dyn Fn(&EntityKind) -> Option| -> String { - let vals: Vec> = sel.iter().filter_map(|h| d.entity(*h)).map(|x| f(&x.kind)).collect(); - match vals.first() { - Some(Some(v0)) if vals.iter().all(|v| v.as_ref() == Some(v0)) => v0.clone(), - _ => VARIES.into(), - } + let shared = |key: &'static str, f: &dyn Fn(&EntityKind) -> Option| -> String { + memo.get(key, || { + let vals: Vec> = sel.iter().filter_map(|h| d.entity(*h)).map(|x| f(&x.kind)).collect(); + match vals.first() { + Some(Some(v0)) if vals.iter().all(|v| v.as_ref() == Some(v0)) => v0.clone(), + _ => VARIES.into(), + } + }) }; let deg = |v: f64| real(angles.shown(v).to_degrees()); match &e.kind { EntityKind::Line(_) => { let line = |k: &EntityKind| if let EntityKind::Line(l) = k { Some(l.clone()) } else { None }; for (label, value) in [ - ("Start X", shared(&|k| line(k).map(|l| len(l.a.x)))), - ("Start Y", shared(&|k| line(k).map(|l| len(l.a.y)))), - ("End X", shared(&|k| line(k).map(|l| len(l.b.x)))), - ("End Y", shared(&|k| line(k).map(|l| len(l.b.y)))), - ("Delta X", shared(&|k| line(k).map(|l| len(l.b.x - l.a.x)))), - ("Delta Y", shared(&|k| line(k).map(|l| len(l.b.y - l.a.y)))), + ("Start X", shared("line.ax", &|k| line(k).map(|l| len(l.a.x)))), + ("Start Y", shared("line.ay", &|k| line(k).map(|l| len(l.a.y)))), + ("End X", shared("line.bx", &|k| line(k).map(|l| len(l.b.x)))), + ("End Y", shared("line.by", &|k| line(k).map(|l| len(l.b.y)))), + ("Delta X", shared("line.dx", &|k| line(k).map(|l| len(l.b.x - l.a.x)))), + ("Delta Y", shared("line.dy", &|k| line(k).map(|l| len(l.b.y - l.a.y)))), ] { prop_row(ui, label, |ui| read_only(ui, &value)); } - num(ui, "Length", "length", shared(&|k| line(k).map(|l| len((l.b.xy() - l.a.xy()).len()))), &mut set, &mut bad, &ids); - num(ui, "Angle", "angle", shared(&|k| line(k).map(|l| real((l.b.xy() - l.a.xy()).angle().to_degrees()))), &mut set, &mut bad, &ids); + num(ui, "Length", "length", shared("line.length", &|k| line(k).map(|l| len((l.b.xy() - l.a.xy()).len()))), &mut set, &mut bad, ids); + num( + ui, + "Angle", + "angle", + shared("line.angle", &|k| line(k).map(|l| real((l.b.xy() - l.a.xy()).angle().to_degrees()))), + &mut set, + &mut bad, + ids, + ); } EntityKind::Circle(_) => { let circle = |k: &EntityKind| if let EntityKind::Circle(c) = k { Some(c.clone()) } else { None }; - prop_row(ui, "Center X", |ui| read_only(ui, &shared(&|k| circle(k).map(|c| len(c.center.x))))); - prop_row(ui, "Center Y", |ui| read_only(ui, &shared(&|k| circle(k).map(|c| len(c.center.y))))); - num(ui, "Radius", "radius", shared(&|k| circle(k).map(|c| len(c.radius))), &mut set, &mut bad, &ids); - num(ui, "Diameter", "diameter", shared(&|k| circle(k).map(|c| len(c.radius * 2.0))), &mut set, &mut bad, &ids); + prop_row(ui, "Center X", |ui| read_only(ui, &shared("circle.cx", &|k| circle(k).map(|c| len(c.center.x))))); + prop_row(ui, "Center Y", |ui| read_only(ui, &shared("circle.cy", &|k| circle(k).map(|c| len(c.center.y))))); + num(ui, "Radius", "radius", shared("circle.radius", &|k| circle(k).map(|c| len(c.radius))), &mut set, &mut bad, ids); + num(ui, "Diameter", "diameter", shared("circle.diameter", &|k| circle(k).map(|c| len(c.radius * 2.0))), &mut set, &mut bad, ids); num( ui, "Circumference", "circumference", - shared(&|k| circle(k).map(|c| len(c.radius * std::f64::consts::TAU))), + shared("circle.circumference", &|k| circle(k).map(|c| len(c.radius * std::f64::consts::TAU))), &mut set, &mut bad, - &ids, + ids, + ); + num( + ui, + "Area", + "area", + shared("circle.area", &|k| circle(k).map(|c| real(c.radius * c.radius * std::f64::consts::PI))), + &mut set, + &mut bad, + ids, ); - num(ui, "Area", "area", shared(&|k| circle(k).map(|c| real(c.radius * c.radius * std::f64::consts::PI))), &mut set, &mut bad, &ids); } EntityKind::Arc(_) => { let arc = |k: &EntityKind| if let EntityKind::Arc(a) = k { Some(a.clone()) } else { None }; - num(ui, "Radius", "radius", shared(&|k| arc(k).map(|a| len(a.radius))), &mut set, &mut bad, &ids); - num(ui, "Start angle", "startAngle", shared(&|k| arc(k).map(|a| real(a.start.to_degrees()))), &mut set, &mut bad, &ids); - num(ui, "End angle", "endAngle", shared(&|k| arc(k).map(|a| real(a.end.to_degrees()))), &mut set, &mut bad, &ids); + num(ui, "Radius", "radius", shared("arc.radius", &|k| arc(k).map(|a| len(a.radius))), &mut set, &mut bad, ids); + num(ui, "Start angle", "startAngle", shared("arc.start", &|k| arc(k).map(|a| real(a.start.to_degrees()))), &mut set, &mut bad, ids); + num(ui, "End angle", "endAngle", shared("arc.end", &|k| arc(k).map(|a| real(a.end.to_degrees()))), &mut set, &mut bad, ids); } EntityKind::Text(_) => { - let text = |k: &EntityKind| if let EntityKind::Text(t) = k { Some(t.clone()) } else { None }; - let contents = shared(&|k| text(k).map(|t| t.value)); + let contents = shared("text.value", &|k| if let EntityKind::Text(t) = k { Some(t.value.clone()) } else { None }); prop_row(ui, "Contents", |ui| { - if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", &ids)), contents.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("txt", ids)), contents.clone()) { set = Some(json!({ "handles": ids, "text": s })); } }); - num(ui, "Height", "height", shared(&|k| text(k).map(|t| len(t.height))), &mut set, &mut bad, &ids); - num(ui, "Rotation", "rotation", shared(&|k| text(k).map(|t| deg(t.rotation))), &mut set, &mut bad, &ids); - num(ui, "Width factor", "widthFactor", shared(&|k| text(k).map(|t| real(t.width_factor))), &mut set, &mut bad, &ids); + let height = shared("text.height", &|k| if let EntityKind::Text(t) = k { Some(len(t.height)) } else { None }); + num(ui, "Height", "height", height, &mut set, &mut bad, ids); + let rotation = shared("text.rotation", &|k| if let EntityKind::Text(t) = k { Some(deg(t.rotation)) } else { None }); + num(ui, "Rotation", "rotation", rotation, &mut set, &mut bad, ids); + let width_factor = shared("text.width_factor", &|k| if let EntityKind::Text(t) = k { Some(real(t.width_factor)) } else { None }); + num(ui, "Width factor", "widthFactor", width_factor, &mut set, &mut bad, ids); } EntityKind::MText(_) => { - let mtext = |k: &EntityKind| if let EntityKind::MText(t) = k { Some(t.clone()) } else { None }; - let contents = shared(&|k| mtext(k).map(|t| t.contents)); + let contents = shared("mtext.contents", &|k| if let EntityKind::MText(t) = k { Some(t.contents.clone()) } else { None }); prop_row(ui, "Contents", |ui| { - if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", &ids)), contents.clone()) { + if let Some(s) = edit_field(ui, ui.scope_id().with(("mtxt", ids)), contents.clone()) { set = Some(json!({ "handles": ids, "text": s })); } }); - num(ui, "Text height", "height", shared(&|k| mtext(k).map(|t| len(t.height))), &mut set, &mut bad, &ids); - num(ui, "Defined width", "width", shared(&|k| mtext(k).map(|t| len(t.width))), &mut set, &mut bad, &ids); + let height = shared("mtext.height", &|k| if let EntityKind::MText(t) = k { Some(len(t.height)) } else { None }); + num(ui, "Text height", "height", height, &mut set, &mut bad, ids); + let width = shared("mtext.width", &|k| if let EntityKind::MText(t) = k { Some(len(t.width)) } else { None }); + num(ui, "Defined width", "width", width, &mut set, &mut bad, ids); } EntityKind::LwPolyline(_) => { - let poly = |k: &EntityKind| { - if let EntityKind::LwPolyline(p) = k { - Some((p.const_width, p.closed, cadcraft_geom::Polyline { vertices: p.vertices.clone(), closed: p.closed })) - } else { - None - } - }; - num(ui, "Global width", "width", shared(&|k| poly(k).map(|(w, ..)| len(w))), &mut set, &mut bad, &ids); - prop_row(ui, "Length", |ui| read_only(ui, &shared(&|k| poly(k).map(|(.., g)| len(g.len()))))); - prop_row(ui, "Area", |ui| { - read_only(ui, &shared(&|k| poly(k).map(|(_, closed, g)| if closed { real(g.area().abs()) } else { String::new() }))) + // Only Length and Area need the vertices; the other rows read one field. + let geom = |p: &cadcraft_doc::LwPolyline| cadcraft_geom::Polyline { vertices: p.vertices.clone(), closed: p.closed }; + let width = shared("poly.width", &|k| if let EntityKind::LwPolyline(p) = k { Some(len(p.const_width)) } else { None }); + num(ui, "Global width", "width", width, &mut set, &mut bad, ids); + let length = shared("poly.length", &|k| if let EntityKind::LwPolyline(p) = k { Some(len(geom(p).len())) } else { None }); + prop_row(ui, "Length", |ui| read_only(ui, &length)); + let area = shared("poly.area", &|k| { + if let EntityKind::LwPolyline(p) = k { Some(if p.closed { real(geom(p).area().abs()) } else { String::new() }) } else { None } + }); + prop_row(ui, "Area", |ui| read_only(ui, &area)); + let closed = shared("poly.closed", &|k| { + if let EntityKind::LwPolyline(p) = k { Some(crate::i18n::t(if p.closed { "Yes" } else { "No" }).to_string()) } else { None } }); - let closed = shared(&|k| poly(k).map(|(_, c, _)| crate::i18n::t(if c { "Yes" } else { "No" }).to_string())); prop_row(ui, "Closed", |ui| { if let Some(n) = pick_menu(ui, &closed, ["Yes", "No"].map(|s| crate::i18n::t(s).to_string())) { set = Some(json!({ "handles": ids, "closed": n == crate::i18n::t("Yes") })); @@ -1312,8 +1396,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); } EntityKind::Hatch(_) => { - let hatch = |k: &EntityKind| if let EntityKind::Hatch(h) = k { Some(h.clone()) } else { None }; - let pattern = shared(&|k| hatch(k).map(|h| h.pattern)); + let pattern = shared("hatch.pattern", &|k| if let EntityKind::Hatch(h) = k { Some(h.pattern.clone()) } else { None }); prop_row(ui, "Pattern", |ui| { ui.menu_button(pattern.clone(), |ui| { if let Some(name) = patterns::menu(ui, &pattern) { @@ -1322,34 +1405,42 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { } }); }); - num(ui, "Scale", "scale", shared(&|k| hatch(k).map(|h| real(h.scale))), &mut set, &mut bad, &ids); - num(ui, "Angle", "angle", shared(&|k| hatch(k).map(|h| real(h.angle.to_degrees()))), &mut set, &mut bad, &ids); + let scale = shared("hatch.scale", &|k| if let EntityKind::Hatch(h) = k { Some(real(h.scale)) } else { None }); + num(ui, "Scale", "scale", scale, &mut set, &mut bad, ids); + let angle = shared("hatch.angle", &|k| if let EntityKind::Hatch(h) = k { Some(real(h.angle.to_degrees())) } else { None }); + num(ui, "Angle", "angle", angle, &mut set, &mut bad, ids); } EntityKind::Insert(_) => { - let insert = |k: &EntityKind| if let EntityKind::Insert(i) = k { Some(i.clone()) } else { None }; // A dynamic block reference shows its dynamic block's name, as with one reference; // its anonymous blocks (`*U12`) differ from one reference to the next. - let names: Vec = sel - .iter() - .filter_map(|h| match d.entity(*h).map(|x| &x.kind) { - Some(EntityKind::Insert(i)) => Some(custom_properties(ui, app, *h).map_or_else(|| i.block.clone(), |(n, _)| n)), - _ => None, - }) - .collect(); - let name = match names.first() { - Some(n0) if names.iter().all(|n| n == n0) => n0.clone(), - _ => VARIES.to_string(), + let name = memo.get("insert.name", || { + let names: Vec = sel + .iter() + .filter_map(|h| match d.entity(*h).map(|x| &x.kind) { + Some(EntityKind::Insert(i)) => Some(custom_properties(ui, app, *h).map_or_else(|| i.block.clone(), |(n, _)| n)), + _ => None, + }) + .collect(); + match names.first() { + Some(n0) if names.iter().all(|n| n == n0) => n0.clone(), + _ => VARIES.to_string(), + } + }); + let position = |key: &'static str, f: fn(&cadcraft_doc::Insert) -> f64| { + shared(key, &|k| if let EntityKind::Insert(i) = k { Some(len(f(i))) } else { None }) }; for (label, value) in [ ("Name", name), - ("Position X", shared(&|k| insert(k).map(|i| len(i.insert.x)))), - ("Position Y", shared(&|k| insert(k).map(|i| len(i.insert.y)))), - ("Position Z", shared(&|k| insert(k).map(|i| len(i.insert.z)))), + ("Position X", position("insert.x", |i| i.insert.x)), + ("Position Y", position("insert.y", |i| i.insert.y)), + ("Position Z", position("insert.z", |i| i.insert.z)), ] { prop_row(ui, label, |ui| read_only(ui, &value)); } - num(ui, "Rotation", "rotation", shared(&|k| insert(k).map(|i| deg(i.rotation))), &mut set, &mut bad, &ids); - num(ui, "Scale", "scale", shared(&|k| insert(k).map(|i| real(i.scale.x))), &mut set, &mut bad, &ids); + let rotation = shared("insert.rotation", &|k| if let EntityKind::Insert(i) = k { Some(deg(i.rotation)) } else { None }); + num(ui, "Rotation", "rotation", rotation, &mut set, &mut bad, ids); + let scale = shared("insert.scale", &|k| if let EntityKind::Insert(i) = k { Some(real(i.scale.x)) } else { None }); + num(ui, "Scale", "scale", scale, &mut set, &mut bad, ids); } _ => {} } @@ -1376,6 +1467,7 @@ mod tests { use crate::Services; use cadcraft_engine::Session; use egui::{Color32, Shape}; + use egui_kittest::kittest::Queryable; fn contains_swatch(shape: &Shape, color: Color32) -> bool { match shape { @@ -1494,4 +1586,58 @@ mod tests { assert!(texts.iter().any(|t| t == VARIES), "{command}: differing values show {VARIES}; painted: {texts:?}"); } } + + /// The radius of every selected circle. + fn selected_radii(app: &CadApp) -> Vec { + let d = app.session.doc().unwrap(); + app.session + .selection() + .iter() + .filter_map(|h| d.entity(*h)) + .filter_map(|e| if let EntityKind::Circle(c) = &e.kind { Some(c.radius) } else { None }) + .collect() + } + + /// A value typed into the palette goes to every selected object, not just the first. + #[test] + fn typing_a_shared_value_changes_every_selected_object() { + let mut app = CadApp::new(Session::default(), Services::default()); + let handles: Vec = + [2, 5, 9].map(|r| app.run("circle", json!({ "center": [0, 0], "radius": r })).unwrap()["handle"].as_str().unwrap().to_string()).to_vec(); + app.run("select", json!({ "handles": handles })).unwrap(); + let mut h = + egui_kittest::Harness::builder().with_size(vec2(400.0, 1200.0)).build_ui_state(|ui, app: &mut CadApp| properties_section(app, ui), app); + h.run_steps(3); + // The first `*VARIES*` field is the Radius: its row comes first. + let radius = h.get_all_by_value(VARIES).next().unwrap(); + radius.focus(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(2); + h.get_all_by_value(VARIES).next().unwrap().type_text("7"); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(selected_radii(h.state()), vec![7.0, 7.0, 7.0], "all three circles take the typed radius"); + } + + /// The values the palette works out are kept for a drawing revision and a selection, and + /// worked out again when either changes. + #[test] + fn shared_values_follow_the_drawing_and_the_selection() { + let mut app = CadApp::new(Session::default(), Services::default()); + let handles: Vec = + [2, 5].map(|r| app.run("circle", json!({ "center": [0, 0], "radius": r })).unwrap()["handle"].as_str().unwrap().to_string()).to_vec(); + app.run("select", json!({ "handles": handles.clone() })).unwrap(); + let mut h = + egui_kittest::Harness::builder().with_size(vec2(400.0, 1200.0)).build_ui_state(|ui, app: &mut CadApp| properties_section(app, ui), app); + h.run_steps(3); + assert!(h.query_all_by_value(VARIES).count() > 0, "two different radii show {VARIES}"); + h.state_mut().run("properties.set", json!({ "handles": handles, "radius": 4.0 })).unwrap(); + h.run_steps(3); + assert!(h.query_all_by_value(VARIES).count() == 0, "after both radii are 4 nothing varies"); + h.state_mut().run("select", json!({ "handles": [handles[0].clone()] })).unwrap(); + h.run_steps(3); + assert!(h.query_all_by_value("4.0000").count() > 0, "one circle alone shows its radius"); + } }