3D and Materials: a mixed checkbox shows a dash and keeps each effect's value

With several objects selected, Shading, Revolve's Cap, Keep visible surfaces
only when expanding and the other checkboxes read a setting the effects hold
with different values as off. A mixed Shading also turned off the Lighting
tab's controls.

- A mixed checkbox shows a dash with its reset dot filled; a click turns it on
  for every selected effect, and other changes leave it as each effect has it.
- A mixed Shading leaves the light settings available.
- A mixed menu choice shows its default; a mixed light color fills its reset dot.
- Add Light is unavailable while a setting of a single light is mixed.
This commit is contained in:
dotCooCoo
2026-11-08 12:59:34 +07:00
committed by bflatastic
parent ba25415792
commit 67339a68c7
6 changed files with 115 additions and 31 deletions
+37 -24
View File
@@ -98,14 +98,13 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog)
});
number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit));
ui.horizontal(|ui| {
let mut cap = d.bool("cap");
let cap = flag(d, "cap", true);
let tip = tl!("Close the circular ends of open profiles. Partial sweeps keep their cut sides open.");
if widgets::check_tip(ui, c3("Cap"), cap, true, tip) {
cap = !cap;
d.fields.insert("cap".into(), json!(cap));
if widgets::check3_tip(ui, c3("Cap"), cap, true, tip) {
d.fields.insert("cap".into(), json!(cap != Some(true)));
}
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, "cap", !cap, "true") {
if reset_dot(ui, "cap", cap != Some(true), "true") {
d.fields.insert("cap".into(), json!(true));
}
});
@@ -123,17 +122,17 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog)
}
ui.add_space(6.0);
ui.horizontal(|ui| {
let visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
let visible = flag(d, "expandVisibleOnly", true);
let tip = tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.");
let toggled = ui.allocate_ui_with_layout(
vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
widgets::check_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip)
widgets::check3_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip)
},
).inner;
if toggled {
d.fields.insert("expandVisibleOnly".into(), json!(!visible));
d.fields.insert("expandVisibleOnly".into(), json!(visible != Some(true)));
}
if reset_dot(ui, "expandVisibleOnly", !visible, "true") {
if reset_dot(ui, "expandVisibleOnly", visible != Some(true), "true") {
d.fields.insert("expandVisibleOnly".into(), json!(true));
}
});
@@ -253,7 +252,9 @@ fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) {
fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) {
let id = d.str("__effect");
let default = vectorcraft_effects::default_params(&id).and_then(|p| p.get(key).cloned()).unwrap_or(Value::Null);
let value = d.fields.get(key).cloned().unwrap_or_else(|| default.clone());
// A mixed setting (null) shows its default, with its reset dot filled.
let mixed = d.fields.get(key).is_some_and(Value::is_null);
let value = d.fields.get(key).filter(|v| !v.is_null()).cloned().unwrap_or_else(|| default.clone());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
label(ui, name);
@@ -266,39 +267,48 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(
d.fields.insert(key.into(), v.clone());
}
let default_label = options.iter().find(|(v, _)| *v == default).map_or("", |(_, l)| *l);
if reset_dot(ui, key, value != default, default_label) {
if reset_dot(ui, key, mixed || value != default, default_label) {
d.fields.insert(key.into(), default.clone());
}
});
}
/// A checkbox for `key`, with its reset dot.
/// The checkbox setting `key`: `None` when it is null (the 3D and Materials panel's selected
/// effects hold it with different values), else its value, or `default` when it has none.
fn flag(d: &Dialog, key: &str, default: bool) -> Option<bool> {
match d.fields.get(key) {
Some(Value::Null) => None,
value => Some(value.and_then(Value::as_bool).unwrap_or(default)),
}
}
/// A checkbox for `key`, with its reset dot. A mixed setting shows a dash, and a click turns it on.
fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) {
ui.horizontal(|ui| {
let on = d.bool(key);
if widgets::check(ui, name, on, ui.is_enabled()) {
d.fields.insert(key.into(), json!(!on));
let on = flag(d, key, default);
if widgets::check3(ui, name, on, ui.is_enabled()) {
d.fields.insert(key.into(), json!(on != Some(true)));
}
ui.add_space((ui.available_width() - 24.0).max(0.0));
let mixed = d.fields.get(key).is_some_and(Value::is_null);
if reset_dot(ui, key, mixed || on != default, if default { "true" } else { "false" }) {
if reset_dot(ui, key, on != Some(default), if default { "true" } else { "false" }) {
d.fields.insert(key.into(), json!(default));
}
});
}
fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
let shade = flag(d, "shade", true);
ui.horizontal(|ui| {
let shade = d.bool("shade");
if widgets::check(ui, tl!("Shading"), shade, true) {
d.fields.insert("shade".into(), json!(!shade));
if widgets::check3(ui, tl!("Shading"), shade, true) {
d.fields.insert("shade".into(), json!(shade != Some(true)));
}
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, "shade", !shade, "true") {
if reset_dot(ui, "shade", shade != Some(true), "true") {
d.fields.insert("shade".into(), json!(true));
}
});
ui.add_enabled_ui(d.bool("shade"), |ui| {
// A mixed Shading leaves the light settings on, for the effects that shade.
ui.add_enabled_ui(flag(d, "shade", true) != Some(false), |ui| {
lighting_presets(ui, d);
light_list(ui, d);
let mut selected = selected_light(d);
@@ -419,8 +429,10 @@ fn light_list(ui: &mut egui::Ui, d: &mut Dialog) {
super::revolve_gizmo::clear(ui.ctx());
}
});
// Add Light is off while a setting of the single light is mixed (null).
let mixed = explicit_lights(d).is_none() && LIGHT_KEYS.iter().any(|(_, key)| d.fields.get(*key).is_some_and(Value::is_null));
ui.horizontal(|ui| {
if ui.add_enabled(list.len() < vectorcraft_three_d::MAX_LIGHTS, egui::Button::new(tl!("Add Light"))).clicked() {
if ui.add_enabled(list.len() < vectorcraft_three_d::MAX_LIGHTS && !mixed, egui::Button::new(tl!("Add Light"))).clicked() {
let mut added = selected_light(d);
let defaults = vectorcraft_effects::default_params(&d.str("__effect")).unwrap_or(json!({}));
for (_, key) in LIGHT_KEYS {
@@ -464,7 +476,8 @@ fn light_color(ui: &mut egui::Ui, d: &mut Dialog) {
}
ui.label(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]));
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, "lightColor", rgb != [255; 3], "#FFFFFF") {
let mixed = d.fields.get("lightColor").is_some_and(Value::is_null);
if reset_dot(ui, "lightColor", mixed || rgb != [255; 3], "#FFFFFF") {
d.fields.insert("lightColor".into(), json!("#ffffff"));
}
});
@@ -337,6 +337,70 @@ fn a_drag_ends_when_the_panel_is_no_longer_drawn() {
assert_eq!(effect(&app).1["depth"], 90.0);
}
/// The panel's controls, drawn once with the accessibility tree on.
fn controls(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<egui::accesskit::Node> {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui));
out.textures_delta.clear();
out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default()
}
fn named<'a>(nodes: &'a [egui::accesskit::Node], label: &str) -> &'a egui::accesskit::Node {
nodes.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no {label:?}"))
}
/// A checkbox setting the selected effects hold with different values shows a dash, leaves the
/// controls it guards available, and another change leaves it as each effect has it.
#[test]
fn a_mixed_checkbox_shows_a_dash_and_another_change_keeps_each_effects_value() {
use crate::panels::tests_appearance::{click, frame_events, text_rect};
let mut app = app();
let a = app.session.active().unwrap().selection.objects[0].0;
set_type(&mut app, "extrude").unwrap();
let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"shade": false}})).unwrap();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx.enable_accesskit();
let texts = frame_events(&ctx, &mut app, vec![], show);
click(&ctx, &mut app, text_rect(&texts, "Lighting").center(), show);
let nodes = controls(&mut app, &ctx);
assert_eq!(named(&nodes, "Shading").toggled(), Some(egui::accesskit::Toggled::Mixed), "a mixed Shading shows a dash");
let behind = named(&nodes, "Light behind the object");
assert!(!behind.is_disabled(), "a mixed Shading leaves the light settings available");
assert!(!named(&nodes, "Add Light").is_disabled());
let at = behind.bounds().map(|r| egui::pos2(((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)).unwrap();
let history = undo_len(&app);
click(&ctx, &mut app, at, show);
assert_eq!(undo_len(&app), history + 1);
let params = |id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params.clone();
let merged = |id: u64| vectorcraft_effects::merged_params("threeD.extrude", &params(id));
assert_eq!((merged(a)["shade"].clone(), merged(b)["shade"].clone()), (json!(true), json!(false)));
assert_eq!((merged(a)["lightBehind"].clone(), merged(b)["lightBehind"].clone()), (json!(true), json!(true)));
// A single light whose settings differ: Add Light is off.
app.run("select.set", json!({"ids": [b]})).unwrap();
app.run("effect.setParams", json!({"index": 0, "item": null, "params": {"lightIntensity": 40.0}})).unwrap();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
assert!(named(&controls(&mut app, &ctx), "Add Light").is_disabled());
}
/// Revolve's Cap checkbox shows a dash when the selected effects disagree on it.
#[test]
fn a_mixed_revolve_cap_shows_a_dash() {
let mut app = app();
let a = app.session.active().unwrap().selection.objects[0].0;
set_type(&mut app, "revolve").unwrap();
let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.run("effect.apply", json!({"effect": "threeD.revolve", "item": null, "params": {"cap": false}})).unwrap();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
ctx.enable_accesskit();
let history = undo_len(&app);
assert_eq!(named(&controls(&mut app, &ctx), "Cap").toggled(), Some(egui::accesskit::Toggled::Mixed));
assert_eq!(undo_len(&app), history);
}
#[test]
fn changed_settings_lists_only_new_values() {
let before = json!({"depth": 50.0, "cap": true, "bevel": "none"});
+1 -1
View File
@@ -246,7 +246,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
/// Sets to null each setting in `fields` (the first selected object's 3D effect) that the 3D effect
/// of another selected object has with another value. The controls then show it at its default
/// with its reset dot filled, and its reset dot, a menu choice, a checkbox click, or a number other
/// (a checkbox shows a dash) with its reset dot filled, and its reset dot, a menu choice, a checkbox click, or a number other
/// than the default shown is a change sent to every effect. The light list (`lights`, where null
/// is the single light) is left as it is.
pub(crate) fn mark_mixed(app: &VectorcraftApp, fields: &mut Map<String, Value>) {
+1 -1
View File
@@ -70,7 +70,7 @@ pub use text::{
exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets,
text_presets_named,
};
pub use toggles::{check, check_box, check_name, check_tip, check3, radio, radio_button};
pub use toggles::{check, check_box, check_name, check_tip, check3, check3_tip, radio, radio_button};
/// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also
/// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do
+6 -1
View File
@@ -26,7 +26,12 @@ pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool {
/// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled.
pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool {
let (clicked, resp) = check_row(ui, tl!(label), Some(value), enabled);
check3_tip(ui, label, Some(value), enabled, tip)
}
/// [`check3`] with tooltip `tip` on the box and its label. Returns true when clicked.
pub fn check3_tip(ui: &mut Ui, label: &str, value: Option<bool>, enabled: bool, tip: &str) -> bool {
let (clicked, resp) = check_row(ui, tl!(label), value, enabled);
resp.on_hover_text(tip);
clicked
}
+6 -4
View File
@@ -33,11 +33,13 @@ pages describe it.
and ranges, showing the 3D effect of the first selected object (its own effects first, then those of its fills
and strokes). A changed setting goes to the 3D effect of each selected object whose effect takes that setting;
other effects and objects without a 3D effect do not change. A setting the selected 3D effects hold with
different values shows its default with its reset dot filled. Its reset dot, a choice from its menu or a click
on its checkbox sets it on all of them; a typed or dragged number sets it when it differs from the default
shown.
different values shows its default with its reset dot filled; a checkbox shows a dash instead, and a mixed
Shading leaves the light settings available. Its reset dot, a choice from its menu or a click on its checkbox
(which turns a mixed one on) sets it on all of them; a typed or dragged number sets it when it differs from the
default shown. Other changes leave it as each effect has it.
The list of lights shows the first object's lights, and an edit to it (Add Light, Delete Light, or a setting
of one light in a list) reaches only the objects whose list of lights matches the first object's.
of one light in a list) reaches only the objects whose list of lights matches the first object's. Add Light is
unavailable while a setting of a single light differs between the selected effects.
- Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step
when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing.
- The Materials tab, shadows and ray-traced rendering are not built (see below). The rotation and light rings on