Files
vectorcraft/crates/ui-egui/src/dialogs/revolve.rs
T
dotCooCoo 22ce516e2d Appearance: the inline effect editor shows 3D, Warp, Rasterize and channel choices as dropdowns
The Appearance panel's inline effect editor still showed eight choice
parameters as text fields holding the stored value: Revolve's edge,
Extrude & Bevel's bevel, every Warp style's orientation, Rasterize's
Color Model, Background and Anti-aliasing, and the channel of Levels
and Curves. Their dialogs built these lists in their own bodies, so
effect::choices did not hold them. Nothing checked a channel's value:
a typo was stored, and Levels or Curves drew it as RGB.

effect::own_choices now holds these lists, each label as its dialog
passes it to its dropdown or radio buttons. The dialogs read the lists
from it (Rasterize's Anti-aliasing from the AntiAlias list it is built
from), and the inline editor shows each one as a dropdown with the
dialog's translated labels; Rasterize's Color Model reads CMYK or RGB
by the document's mode. effect.apply and effect.setParams refuse a
Levels or Curves channel other than rgb, red, green or blue (r, g, b
and any letter case are read too) with "`channel` must be one of rgb,
red, green, blue", and the document and its undo history stay as they
were.

Docs: docs/mcp.md (effect params), docs/target-app-parity.md.
2026-10-18 12:34:36 +07:00

564 lines
26 KiB
Rust

//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate.
//! The object and the rotation and light gizmos live on the artboard. All document changes run
//! through the effect dialog's existing preview/commit commands.
use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_effects::{EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, scrub, widgets};
const LABEL_W: f32 = 102.0;
const VALUE_W: f32 = 74.0;
const SIDE_W: f32 = 354.0;
/// Sized to the screen, with scrolling inside the body so OK and Cancel stay reachable.
pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
// Values start in drag mode; clicking or Tab enters their text editor.
ui.data_mut(|m| m.remove::<bool>(widgets::dialog_focus_flag()));
let screen = ui.ctx().content_rect();
let width = (screen.width() - 64.0).clamp(240.0, SIDE_W);
// Tall enough for Extrude & Bevel's Object tab, the longest, where the screen has the room.
let height = (screen.height() - 240.0).clamp(120.0, 450.0);
ui.set_width(width);
egui::ScrollArea::vertical().id_salt("revolve-body").max_height(height).auto_shrink([false, true]).show(ui, |ui| {
controls(app, ui, d);
});
ui.add_space(6.0);
ui.horizontal(|ui| {
let t = Tokens::get(ui.ctx());
ui.allocate_ui_with_layout(vec2((width - 100.0).max(80.0), 32.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
ui.add(egui::Label::new(egui::RichText::new(super::revolve_gizmo::hint(d)).size(11.0).color(t.text_dim)).wrap());
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let id = d.str("__effect");
let defaults = vectorcraft_effects::default_params(&id).unwrap_or(json!({}));
let changed = defaults.as_object().is_some_and(|m| {
m.iter().any(|(k, v)| v.as_f64().map_or_else(|| d.fields.get(k) != Some(v), |default| (d.f64(k, default) - default).abs() > 1e-6))
});
let (r, _) = ui.allocate_exact_size(vec2(88.0, 24.0), Sense::hover());
let response = ui.interact(r, Id::new("revolve-reset-all"), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset all")));
if response.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
ui.painter().text(r.left_center() + vec2(4.0, 0.0), Align2::LEFT_CENTER, tl!("Reset all"), FontId::proportional(11.5), t.text);
dot(ui, r.right_center() - vec2(10.0, 0.0), changed);
let tip =
if id == REVOLVE { tl!("Reset all Revolve settings to their defaults.") } else { tl!("Reset all 3D settings to their defaults.") };
if response.on_hover_text(tip).clicked()
&& let Some(defaults) = defaults.as_object()
{
d.fields.extend(defaults.clone());
d.fields.remove("__light");
super::revolve_gizmo::clear(ui.ctx());
}
});
});
}
fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
ui.spacing_mut().item_spacing.y = 4.0;
let id = d.str("__effect");
if id != REVOLVE {
return solid_controls(app, ui, d, &id);
}
let lighting = super::revolve_gizmo::lighting(d);
if let Some(tab) = widgets::tab_bar(ui, &[tl!("Rotation"), tl!("Lighting")], usize::from(lighting)) {
d.fields.insert("__gizmo".into(), json!(if tab == 1 { "light" } else { "rotation" }));
}
ui.add_space(8.0);
if super::revolve_gizmo::lighting(d) {
lighting_controls(ui, d);
return;
}
let unit = app.session.general_unit();
heading(ui, tl!("Shape"));
number(ui, d, "angle", tl!("Revolve angle"), (0.0, 360.0), "°", 1, None);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
label(ui, tl!("Axis side"));
let edge = d.str("edge");
let sides = super::effect::own_choices(REVOLVE, "edge", false).unwrap_or_default();
let current = sides.iter().find(|(_, v)| *v == edge).or(sides.first()).map_or("", |(l, _)| *l);
let labels: Vec<&str> = sides.iter().map(|(l, _)| *l).collect();
let width = VALUE_W + rail_width(ui) + 6.0;
if let Some((_, v)) = widgets::dropdown(ui, "revolve-edge", current, &labels, width).and_then(|k| sides.get(k)) {
d.fields.insert("edge".into(), json!(v));
}
if reset_dot(ui, "edge", edge != "left", tl!("Left")) {
d.fields.insert("edge".into(), json!("left"));
}
});
number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit));
ui.horizontal(|ui| {
let mut cap = d.bool("cap");
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));
}
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, "cap", !cap, "true") {
d.fields.insert("cap".into(), json!(true));
}
});
ui.add_space(10.0);
heading(ui, tl!("View"));
for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] {
number(ui, d, key, name, (-360.0, 360.0), "°", 1, None);
}
number(ui, d, "perspectiveAngle", tl!("Perspective"), (0.0, 160.0), "°", 1, None);
ui.add_space(6.0);
egui::CollapsingHeader::new(tl!("Advanced")).id_salt("revolve-advanced").show(ui, |ui| {
number(ui, d, "segments", tl!("Segments"), (8.0, 128.0), "", 0, None);
super::form::check(ui, d, "__showAxis", tl!("Show axis"));
ui.add_space(6.0);
ui.horizontal(|ui| {
let visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(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)
},
).inner;
if toggled {
d.fields.insert("expandVisibleOnly".into(), json!(!visible));
}
if reset_dot(ui, "expandVisibleOnly", !visible, "true") {
d.fields.insert("expandVisibleOnly".into(), json!(true));
}
});
});
}
/// `s` as the 3D effects mean it (a solid's cap, not a stroke's; a bevelled edge, not a join).
fn c3(s: &'static str) -> &'static str {
crate::i18n::tr_ctx(crate::i18n::current(), "3d", s)
}
/// Extrude & Bevel and Inflate: Object, Rotation and Lighting tabs (Rotate has no Object tab).
/// The rotation gizmo shows on the first two, the light gizmo on Lighting.
fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
let object = id == EXTRUDE || id == INFLATE;
let mut tabs = vec![];
if object {
tabs.push(tl!("Object"));
}
tabs.extend([tl!("Rotation"), tl!("Lighting")]);
let current = if super::revolve_gizmo::lighting(d) {
tabs.len() - 1
} else if object && d.str("__tab") != "rotation" {
0
} else {
tabs.len() - 2
};
if let Some(tab) = widgets::tab_bar(ui, &tabs, current) {
let light = tab + 1 == tabs.len();
d.fields.insert("__gizmo".into(), json!(if light { "light" } else { "rotation" }));
d.fields.insert("__tab".into(), json!(if object && tab == 0 { "object" } else { "rotation" }));
}
ui.add_space(8.0);
if super::revolve_gizmo::lighting(d) {
lighting_controls(ui, d);
} else if object && d.str("__tab") != "rotation" {
let unit = app.session.general_unit();
if id == EXTRUDE {
extrude_controls(ui, d, unit);
} else {
heading(ui, tl!("Object"));
number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit));
number(ui, d, "twist", tl!("Twist"), (0.0, 360.0), "°", 1, None);
number(ui, d, "taper", tl!("Taper"), (1.0, 100.0), "%", 1, None);
choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]);
number(ui, d, "volume", tl!("Volume"), (0.0, 100.0), "%", 1, None);
toggle(ui, d, "bothSides", tl!("Both sides"), false);
}
} else {
view_controls(ui, d);
}
}
/// Extrude & Bevel's depth, twist, taper, cap and bevel.
fn extrude_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
heading(ui, tl!("Object"));
number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit));
number(ui, d, "twist", tl!("Twist"), (0.0, 360.0), "°", 1, None);
number(ui, d, "taper", tl!("Taper"), (1.0, 100.0), "%", 1, None);
choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]);
ui.add_space(10.0);
heading(ui, c3("Bevel"));
let shapes: Vec<(Value, &str)> =
super::effect::own_choices(EXTRUDE, "bevel", false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect();
choice(ui, d, "bevel", tl!("Bevel Shape"), &shapes);
ui.add_enabled_ui(d.str("bevel") != "none", |ui| {
number(ui, d, "bevelWidth", tl!("Width"), (0.0, 1000.0), "", 1, Some(unit));
number(ui, d, "bevelHeight", tl!("Height"), (0.0, 1000.0), "", 1, Some(unit));
number(ui, d, "bevelRepeat", tl!("Repeat"), (1.0, 32.0), "", 0, None);
number(ui, d, "bevelSpace", c3("Space"), (0.0, 90.0), "%", 1, None);
toggle(ui, d, "bevelInside", tl!("Bevel Inside"), false);
toggle(ui, d, "bevelBothSides", tl!("Bevel both sides"), false);
});
}
/// Rotation presets, rotation and perspective for Extrude & Bevel, Inflate and Rotate.
fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) {
heading(ui, tl!("Rotation"));
let names = [
tl!("Front"),
tl!("Back"),
tl!("Left"),
tl!("Right"),
tl!("Top"),
tl!("Bottom"),
tl!("Off-Axis Front"),
tl!("Off-Axis Back"),
tl!("Off-Axis Left"),
tl!("Off-Axis Right"),
tl!("Off-Axis Top"),
tl!("Off-Axis Bottom"),
tl!("Isometric Left"),
tl!("Isometric Right"),
tl!("Isometric Top"),
tl!("Isometric Bottom"),
];
let angles = [d.f64("rotationX", 0.0), d.f64("rotationY", 0.0), d.f64("rotationZ", 0.0)];
let current = ROTATION_PRESETS.iter().position(|(_, a)| a.iter().zip(angles).all(|(a, b)| (a - b).abs() < 0.005));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
label(ui, tl!("Presets"));
let width = VALUE_W + rail_width(ui) + 6.0;
let shown = current.and_then(|i| names.get(i).copied()).unwrap_or(tl!("Custom"));
if let Some(k) = widgets::dropdown(ui, "three-d-position", shown, &names, width)
&& let Some((_, [x, y, z])) = ROTATION_PRESETS.get(k)
{
d.fields.extend([("rotationX".into(), json!(x)), ("rotationY".into(), json!(y)), ("rotationZ".into(), json!(z))]);
}
});
for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] {
number(ui, d, key, name, (-180.0, 180.0), "°", 2, None);
}
number(ui, d, "perspectiveAngle", tl!("Perspective"), (0.0, 160.0), "°", 1, None);
}
/// A dropdown of `options` (value, label) for `key`, with its reset dot.
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());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
label(ui, name);
let current = options.iter().find(|(v, _)| *v == value).map_or("", |(_, l)| *l);
let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect();
let width = VALUE_W + rail_width(ui) + 6.0;
if let Some(k) = widgets::dropdown(ui, ("three-d-choice", key), current, &labels, width)
&& let Some((v, _)) = options.get(k)
{
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) {
d.fields.insert(key.into(), default.clone());
}
});
}
/// A checkbox for `key`, with its reset dot.
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));
}
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, key, on != default, if default { "true" } else { "false" }) {
d.fields.insert(key.into(), json!(default));
}
});
}
fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
let shade = d.bool("shade");
if widgets::check(ui, tl!("Shading"), shade, true) {
d.fields.insert("shade".into(), json!(!shade));
}
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, "shade", !shade, "true") {
d.fields.insert("shade".into(), json!(true));
}
});
ui.add_enabled_ui(d.bool("shade"), |ui| {
light_list(ui, d);
let mut selected = selected_light(d);
let before = light_record(&selected);
// The documented Light Settings, in their order.
light_color(ui, &mut selected);
number(ui, &mut selected, "lightIntensity", tl!("Intensity"), (0.0, 100.0), "%", 1, None);
number(ui, &mut selected, "lightRotation", tl!("Rotation"), (-180.0, 180.0), "°", 1, None);
number(ui, &mut selected, "lightHeight", tl!("Height"), (0.0, 90.0), "°", 1, None);
number(ui, &mut selected, "lightSoftness", tl!("Softness"), (0.0, 100.0), "%", 1, None);
number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 200.0), "%", 1, None);
toggle(ui, &mut selected, "lightBehind", tl!("Light behind the object"), false);
if light_record(&selected) != before {
store_selected(d, &selected);
}
});
}
const LIGHT_KEYS: [(&str, &str); 6] = [
("color", "lightColor"),
("intensity", "lightIntensity"),
("rotation", "lightRotation"),
("height", "lightHeight"),
("softness", "lightSoftness"),
("behind", "lightBehind"),
];
fn explicit_lights(d: &Dialog) -> Option<&Vec<Value>> {
d.fields.get("lights").and_then(Value::as_array).filter(|v| !v.is_empty() && v.len() <= vectorcraft_three_d::MAX_LIGHTS)
}
fn light_index(d: &Dialog, count: usize) -> usize {
d.fields.get("__light").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).unwrap_or(0).min(count.saturating_sub(1))
}
/// Only the selected light's six settings are overlaid. Selection is UI state and never
/// enters effect parameters, so selecting another light creates no document history.
pub(super) fn selected_light(d: &Dialog) -> Dialog {
let mut selected = d.clone();
selected.fields.remove("lights");
if let Some(list) = explicit_lights(d)
&& let Some(light) = list.get(light_index(d, list.len()))
{
let defaults = vectorcraft_effects::default_params(&d.str("__effect")).unwrap_or(json!({}));
for (key, field) in LIGHT_KEYS {
if let Some(value) = light.get(key).or_else(|| defaults.get(field)) {
selected.fields.insert(field.into(), value.clone());
}
}
}
selected
}
pub(super) fn light_options(d: &Dialog) -> vectorcraft_three_d::Revolve {
vectorcraft_effects::revolve_options(&super::form::params(&selected_light(d)))
}
fn light_record(d: &Dialog) -> Value {
Value::Object(LIGHT_KEYS.iter().filter_map(|(key, field)| d.fields.get(*field).map(|v| ((*key).into(), v.clone()))).collect())
}
fn store_selected(d: &mut Dialog, selected: &Dialog) {
if let Some(list) = explicit_lights(d) {
let index = light_index(d, list.len());
let mut list = list.clone();
if let Some(light) = list.get_mut(index) {
*light = light_record(selected);
}
d.fields.insert("lights".into(), json!(list));
} else {
for (_, key) in LIGHT_KEYS {
if let Some(value) = selected.fields.get(key) {
d.fields.insert(key.into(), value.clone());
}
}
}
}
pub(super) fn set_light_value(d: &mut Dialog, key: &str, value: Value) {
let mut selected = selected_light(d);
selected.fields.insert(key.into(), value);
store_selected(d, &selected);
}
fn light_list(ui: &mut egui::Ui, d: &mut Dialog) {
let mut list = explicit_lights(d).cloned().unwrap_or_else(|| vec![light_record(d)]);
let mut index = light_index(d, list.len());
let names: Vec<_> = (1..=list.len()).map(|i| format!("{} {i}", c3("Light"))).collect();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
label(ui, tl!("Lights"));
let labels: Vec<_> = names.iter().map(String::as_str).collect();
if let Some(next) =
widgets::dropdown(ui, "three-d-lights", names.get(index).map_or("", String::as_str), &labels, VALUE_W + rail_width(ui) + 6.0)
{
index = next;
ui.memory_mut(|m| m.stop_text_input());
super::revolve_gizmo::clear(ui.ctx());
}
});
ui.horizontal(|ui| {
if ui.add_enabled(list.len() < vectorcraft_three_d::MAX_LIGHTS, 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 {
if let Some(value) = defaults.get(key) {
added.fields.insert(key.into(), value.clone());
}
}
added.fields.insert("lightRotation".into(), json!(0.0));
added.fields.insert("lightHeight".into(), json!(90.0));
list.push(light_record(&added));
index = list.len() - 1;
d.fields.insert("lights".into(), json!(list));
super::revolve_gizmo::clear(ui.ctx());
}
if ui.add_enabled(list.len() > 1, egui::Button::new(tl!("Delete Light"))).clicked() && index < list.len() {
list.remove(index);
index = index.min(list.len().saturating_sub(1));
d.fields.insert("lights".into(), json!(list));
super::revolve_gizmo::clear(ui.ctx());
}
});
d.fields.insert("__light".into(), json!(index));
ui.add_space(4.0);
}
fn light_color(ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
label(ui, tl!("Color"));
let color = d
.fields
.get("lightColor")
.and_then(Value::as_str)
.filter(|s| s.len() == 7 && s.starts_with('#'))
.and_then(vectorcraft_color::Color::from_hex)
.unwrap_or(vectorcraft_color::Color::WHITE);
// Light colors are display RGB, independent of the document's working profile.
let mut rgb = color.to_rgb_uncalibrated().map(|c| (c * 255.0).round() as u8);
if ui.color_edit_button_srgb(&mut rgb).on_hover_text(tl!("Color")).changed() {
d.fields.insert("lightColor".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
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") {
d.fields.insert("lightColor".into(), json!("#ffffff"));
}
});
}
fn heading(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).font(crate::theme::semibold(13.0)).color(t.text));
}
fn label(ui: &mut egui::Ui, name: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 24.0), Sense::hover());
ui.put(r, egui::Label::new(egui::RichText::new(name).size(12.5).color(t.text)).halign(egui::Align::Min).truncate()).on_hover_text(name);
scrub::note_label(ui, r);
}
fn rail_width(ui: &egui::Ui) -> f32 {
(ui.available_width() - VALUE_W - 24.0 - 18.0).clamp(40.0, 134.0)
}
/// A value can be typed, dragged, scrubbed by its label, or moved on its slider.
#[allow(clippy::too_many_arguments)]
fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option<Unit>) {
let default = vectorcraft_effects::default_params(&d.str("__effect")).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0);
let from = |n| unit.map_or(n, |u| u.from_pt(n));
let to = |n| unit.map_or(n, |u| u.to_pt(n));
let typed = unit.map(|u| widgets::typed_unit(ui.ctx(), u));
let mut value = from(d.f64(key, default)).clamp(from(range.0), from(range.1));
let suffix = unit.map_or_else(|| suffix.to_string(), |u| format!(" {}", u.suffix()));
let speed = if decimals == 0 { 1.0 } else { 0.25 };
let mut next = None;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
label(ui, name);
let t = Tokens::get(ui.ctx());
let response = ui
.scope(|ui| {
ui.visuals_mut().widgets.inactive.weak_bg_fill = t.input;
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border);
ui.add_sized(
[VALUE_W, 24.0],
egui::DragValue::new(&mut value)
.range(from(range.0)..=from(range.1))
.speed(speed)
.max_decimals(decimals)
.suffix(&suffix)
.update_while_editing(false)
.custom_parser(|s| {
unit.map_or_else(
|| vectorcraft_doc::parse_number(&s.replace(['°', '%'], "")),
|u| typed.unwrap_or(u).parse(s).map(|v| u.from_pt(v)),
)
}),
)
})
.inner;
response.clone().on_hover_text(tl!("Drag values or labels. Double-click a value to type."));
if response.changed() {
next = Some(value);
}
if let Some(n) = scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32) {
next = Some(n);
}
// Lengths' rails cover a useful working range; larger values can still be typed.
let rail_max = match key {
"offset" | "depth" => from(500.0),
"bevelWidth" | "bevelHeight" => from(100.0),
_ => from(range.1),
};
let rail_min = from(range.0);
let rail = rail_width(ui) + VALUE_W;
// The text the DragValue shows: at least the places its speed gives (one for 0.25 at 1x to
// 3x scaling, as egui computes them), at most `decimals`.
let places = ((f64::from(ui.input(|i| i.aim_radius())) / speed).log10().ceil().clamp(0.0, 15.0) as usize).min(decimals);
let number = ui.style().number_formatter.format(value, places..=decimals);
let shown = widgets::SliderValue::text(number.parse().unwrap_or(value), rail_min..=rail_max, format!("{number}{suffix}"));
let slider = widgets::color_slider_valued(
ui,
("revolve-slider", key),
name,
Some(shown),
((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32,
rail,
&|_| t.input_border,
);
if let (Some(n), _) = slider {
next = Some(rail_min + f64::from(n) * (rail_max - rail_min));
}
if reset_dot(ui, key, (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) {
next = Some(from(default));
}
});
if let Some(n) = next.filter(|n| n.is_finite()) {
let factor = 10f64.powi(decimals as i32);
let n = (n * factor).round() / factor;
d.fields.insert(key.into(), json!(to(n.clamp(from(range.0), from(range.1)))));
}
}
fn dot(ui: &egui::Ui, p: Pos2, changed: bool) {
let t = Tokens::get(ui.ctx());
if changed {
ui.painter().circle_filled(p, 3.0, if ui.is_enabled() { t.accent } else { t.text_disabled });
} else {
ui.painter().circle_stroke(p, 3.0, Stroke::new(1.0, t.text_disabled));
}
}
fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool {
let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover());
let response = ui.interact(r, Id::new(("revolve-reset", key)), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset to default")));
if response.hovered() {
ui.painter().rect_filled(r, 3.0, Tokens::get(ui.ctx()).hover);
}
dot(ui, r.center(), changed);
response.on_hover_text(format!("{}: {default}", tl!("Reset to default"))).clicked()
}
#[cfg(test)]
#[path = "tests_revolve.rs"]
mod tests;