Files
vectorcraft/crates/ui-egui/src/dialogs/revolve.rs
T
Ayberk A. 15cef94ba6 3D and Materials: Inflate takes Twist, Taper and Cap
The public 3D and Materials panel options list Depth, Twist, Taper and Cap
for every 3D type; Inflate had only Depth.

- Twist (0° to 360°) and Taper (1–100 %) turn and scale the back about the
  shape's centre, as Extrude & Bevel's do: a puffed back is turned and
  lowered in proportion, and the sides follow in slices.
- Cap: Solid closes the back; Hollow leaves it off, so the inflated front is
  a shell open behind. The documentation doesn't say what Cap means for
  Inflate; this reading is stated in docs/three-d.md.
- The dialog's Object tab shows them in the documented order (Depth, Twist,
  Taper, Cap), then Volume and Both sides.

At 0 % volume a twisted, tapered Inflate has the extrusion's outline from
every side (tested).

A twisted, tapered, hollow Inflate draws on the canvas exactly as it
exports, and expands to faces with their own ids (tested). ROADMAP and the
parity pages list the new options; the 3D score stays at ~25 %.
2026-10-13 05:22:00 +07:00

400 lines
18 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());
}
});
});
}
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 current = if edge == "right" { tl!("Right") } else { tl!("Left") };
let width = VALUE_W + rail_width(ui) + 6.0;
if let Some(k) = widgets::dropdown(ui, "revolve-edge", current, &[tl!("Left"), tl!("Right")], width) {
d.fields.insert("edge".into(), json!(if k == 0 { "left" } else { "right" }));
}
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.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, "perspective", tl!("Perspective"), (0.0, 100.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 = [
(json!("none"), tl!("None")),
(json!("classic"), tl!("Classic")),
(json!("rounded"), c3("Rounded")),
(json!("cove"), tl!("Cove")),
(json!("step"), tl!("Step")),
(json!("ogee"), tl!("Ogee")),
];
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, "perspective", tl!("Perspective"), (0.0, 100.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| {
number(ui, d, "lightAzimuth", tl!("Direction"), (-360.0, 360.0), "°", 1, None);
number(ui, d, "lightElevation", tl!("Elevation"), (-90.0, 90.0), "°", 1, None);
number(ui, d, "lightIntensity", tl!("Intensity"), (0.0, 100.0), "%", 1, None);
number(ui, d, "ambient", tl!("Ambient"), (0.0, 100.0), "%", 1, None);
});
}
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 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(if decimals == 0 { 1.0 } else { 0.25 })
.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;
let slider =
widgets::color_slider(ui, ("revolve-slider", key), ((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;