mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
The three 3D types besides Revolve, as live effects in the three-d crate, drawn as shaded vector faces by the canvas, SVG/PDF export and Expand Appearance alike, from the public 3D and Materials documentation: - Extrude & Bevel: depth (0-2000 pt), twist, taper, solid or hollow caps, and bevels in six original cross-sections (classic, rounded, cove, step, ogee) with width, height, repeat, spacing, inside and both sides. - Inflate: the shape as a membrane under pressure (a Poisson solve held at zero along the outline and holes), so it rises vertically from the edge and rounds without creases; volume, depth and both sides. Steep cells near the outline are refined without cracks. - Rotate: every fill and stroke turned in 3D and shaded as one plane. All three share Revolve's view and lighting parameters, the rotation and light gizmos (turning about the art's centre) and a dialog with Object, Rotation and Lighting tabs, with Position presets (Front ... Isometric Bottom, our own values). An object takes one 3D effect. Inflate cuts the grid cells that straddle a hole's edge with pathops, hence the new pathops -> three-d layer order. Docs: docs/three-d.md, mcp.md, and the 3D rows of the parity docs.
396 lines
18 KiB
Rust
396 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);
|
|
let height = (screen.height() - 240.0).clamp(120.0, 340.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, "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"), (-360.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!("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", tl!("Spacing"), (0.0, 90.0), "%", 1, None);
|
|
toggle(ui, d, "bevelInside", c3("Inside"), false);
|
|
toggle(ui, d, "bevelBothSides", tl!("Both sides"), false);
|
|
});
|
|
}
|
|
|
|
/// Position 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!("Position"));
|
|
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;
|