Files
vectorcraft/crates/ui-egui/src/dialogs/perspective_grid.rs
bflatasticandClaude Opus 5.5 cf7a43d786 i18n: dialogs show user, file and system names as they are
Lists and headings that mix our labels with names no longer send the
names through the catalog, so a preset, printer or plug-in called
"Regular" or "Black" keeps its name in every language.

- dialogs::shown_names / mixed_dropdown: translate only a list's built-in
  entries and show the rest with widgets::dropdown_names (same indices).
- Artboard lists (Save for Office, Save for Web, save options) and
  plug-in choices: dropdown_names.
- Mixed lists translate only their built-in entries: Recolor's Limit to
  Library, Save for Web, Print, Save PDF and Export for Screens presets,
  printers (Default Printer, PDF File), colour profiles and flattener
  presets (profile_pick, flatten::preset_dropdown), perspective presets,
  New Document's preset cards and Size, and the shortcut set.
- Plug-in dialogs and plug-in effects are headed by the plug-in's name as
  it is; plug-in parameter labels are not translated.
- The confirm dialog shows its message and detail as given, and the
  shared frame no longer translates headings again: each heading is
  translated by its spec. File > Revert translates its template before
  filling in the document's name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 08:36:44 +07:00

346 lines
15 KiB
Rust

//! View → Perspective Grid → Define Grid…: the grid's type, units and scale, gridline spacing,
//! viewing angle and distance, horizon height and third vanishing point, and the planes' gridline
//! colours and opacity, prefilled from the grid (`perspective.grid.get`). OK runs
//! `perspective.grid.define`: one undo step, nothing when nothing changed.
//!
//! The Preset menu loads a preset's fields (it reads [Custom] once they differ); Save Preset…
//! asks for a name and saves the fields as a preset (`perspective.presets.save`), then comes back.
//! The same dialog is View → Perspective Grid → Save Grid as Preset… and the preset editor of
//! Edit → Perspective Grid Presets (New…, Edit…), whose OK returns to the presets.
//!
//! Fields: those of `perspective.grid.define` (lengths are real-world lengths in `units`; `name`
//! the preset), plus `__mode` (`save`, `edit` or none for Define Grid), `__from` (`define`: Save
//! Preset… returns to Define Grid) and `__original` (the saved preset an edit changes).
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_tools::distort::perspective::define::is_builtin;
use vectorcraft_tools::distort::perspective::{GridDefinition, Rgb};
use super::{DialogSpec, form};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, widgets};
/// The dialog kind of Define Perspective Grid.
pub const KIND: &str = "perspectiveGrid";
/// The label column's width.
const LABEL: f32 = 140.0;
/// A value field's width.
const FIELD: f32 = 120.0;
/// The unit the lengths were last shown in.
const SHOWN: &str = "__units";
/// What the dialog is: Define Grid (none), `save` (Save Grid as Preset) or `edit` (the preset
/// editor).
const MODE: &str = "__mode";
const FROM: &str = "__from";
const ORIGINAL: &str = "__original";
/// The Preset menu's entry for fields no preset has.
const CUSTOM: &str = "[Custom]";
/// The lengths among the fields: real-world lengths in `units`.
const LENGTHS: [&str; 3] = ["gridline", "distance", "horizonHeight"];
/// The scale choices: (label, [artboard, real world]).
const SCALES: [(&str, [f64; 2]); 9] = [
("1:1", [1.0, 1.0]),
("1:2", [1.0, 2.0]),
("1:4", [1.0, 4.0]),
("1:8", [1.0, 8.0]),
("1:12", [1.0, 12.0]),
("1:24", [1.0, 24.0]),
("1:48", [1.0, 48.0]),
("1:96", [1.0, 96.0]),
("1:100", [1.0, 100.0]),
];
const TYPES: [&str; 3] = ["One Point Perspective", "Two Point Perspective", "Three Point Perspective"];
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |d| {
match d.str(MODE).as_str() {
"save" => tl!("Save Grid as Preset"),
"edit" => tl!("Perspective Grid Preset"),
_ => tl!("Define Perspective Grid"),
}
.into()
},
body,
confirm,
min_width: 420.0,
max_width: Some(460.0),
..DialogSpec::FORM
};
/// Open the dialog on `def`'s fields as `mode` (`""`, `save` or `edit`).
fn open_on(app: &mut VectorcraftApp, mut def: Value, mode: &str) {
def[SHOWN] = def["units"].clone();
def[MODE] = json!(mode);
app.ui.dialog = Some(Dialog::new(KIND, def));
}
/// The active document's grid as Define Grid fields.
fn grid_fields(app: &mut VectorcraftApp) -> Result<Value, String> {
Ok(app.session.execute("perspective.grid.get", &json!({})).map_err(|e| e.to_string())?["define"].take())
}
/// Open Define Grid on the active document's grid.
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
let fields = grid_fields(app)?;
open_on(app, fields, "");
Ok(Value::Null)
}
/// View → Perspective Grid → Save Grid as Preset…: the grid's fields under a new preset name.
pub fn open_save(app: &mut VectorcraftApp) -> Result<Value, String> {
let mut fields = grid_fields(app)?;
fields["name"] = json!(app.session.new_perspective_preset_name());
open_on(app, fields, "save");
Ok(Value::Null)
}
/// The preset editor: the saved preset `original` (Edit…), or a new preset starting from `base`
/// (New…). Its OK returns to Edit → Perspective Grid Presets.
pub fn open_edit(app: &mut VectorcraftApp, original: Option<&str>, base: &str) -> Result<(), String> {
let name = original.unwrap_or(base);
let mut def = json!(app.session.perspective_preset(name).ok_or_else(|| format!("no perspective grid preset named `{name}`"))?);
match original {
Some(n) => def[ORIGINAL] = json!(n),
None => def["name"] = json!(app.session.new_perspective_preset_name()),
}
open_on(app, def, "edit");
Ok(())
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut params = form::params(d);
match d.str(MODE).as_str() {
"save" | "edit" => {
if let Some(original) = d.fields.get(ORIGINAL).and_then(Value::as_str) {
params["newName"] = params["name"].take();
params["name"] = json!(original);
}
let r = app.run("perspective.presets.save", params)?;
let name = r["name"].as_str().unwrap_or_default().to_string();
if d.str(MODE) == "edit" {
super::perspective_presets::open(app, Some(&name));
} else if d.str(FROM) == "define" {
// Back to Define Grid, on the preset just saved.
if let Some(back) = app.ui.dialog.as_mut() {
back.fields.remove(MODE);
back.fields.remove(FROM);
back.fields.insert("name".into(), json!(name));
}
} else {
app.ui.dialog = None;
}
Ok(r)
}
_ => {
let r = app.run("perspective.grid.define", params);
if r.is_ok() {
app.ui.dialog = None;
}
r
}
}
}
/// The unit the lengths are in.
fn unit(d: &Dialog) -> Unit {
Unit::named(&d.str("units")).unwrap_or_default()
}
fn kind(d: &Dialog) -> u8 {
d.fields.get("kind").and_then(Value::as_u64).map_or(2, |k| k.clamp(1, 3) as u8)
}
fn scale(d: &Dialog) -> [f64; 2] {
let v = d.fields.get("scale").and_then(Value::as_array);
let at = |i: usize| v.and_then(|a| a.get(i)).and_then(Value::as_f64).unwrap_or(1.0);
[at(0), at(1)]
}
/// A length field (`key`, in `unit`) `width` wide. Returns true when it changed.
fn length(ui: &mut egui::Ui, d: &mut Dialog, key: &str, unit: Unit, width: f32) -> bool {
let pt = d.fields.get(key).and_then(Value::as_f64).map(|v| unit.to_pt(v));
let Some(v) = widgets::num_field(ui, ("persp-len", key), pt, unit, width) else { return false };
d.fields.insert(key.into(), json!(unit.from_pt(v)));
true
}
/// One of the third vanishing point's coordinates (`i`: 0 = x, 1 = y).
fn third_vp(ui: &mut egui::Ui, d: &mut Dialog, i: usize, unit: Unit, enabled: bool) {
let mut v = d.fields.get("thirdVp").and_then(Value::as_array).cloned().unwrap_or_else(|| vec![json!(0), json!(0)]);
v.resize(2, json!(0));
let pt = v.get(i).and_then(Value::as_f64).map(|x| unit.to_pt(x));
ui.label(if i == 0 { "X:" } else { "Y:" });
if let Some(x) = ui.add_enabled_ui(enabled, |ui| widgets::num_field(ui, ("persp-vp3", i), pt, unit, 84.0)).inner {
v[i] = json!(unit.from_pt(x));
d.fields.insert("thirdVp".into(), Value::Array(v));
}
}
/// A plane's gridline colour: a chip and its hex.
fn color(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let t = Tokens::get(ui.ctx());
let cur = Rgb::parse(&d.str(key));
widgets::label_row(ui, label, LABEL, |ui| {
let (chip, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
if let Some(Rgb([r, g, b])) = cur {
ui.painter().rect_filled(chip, 2.0, egui::Color32::from_rgb(r, g, b));
}
ui.painter().rect_stroke(chip, 2.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
let hex = cur.map(|c| c.hex()).unwrap_or_default();
if let Some(c) = widgets::hex_field(ui, ("persp-color", key), hex.trim_start_matches('#')).as_deref().and_then(Rgb::parse) {
d.fields.insert(key.into(), json!(c.hex()));
}
});
}
/// The Name row: in Define Grid the Preset menu (choosing a preset loads its fields) and Save
/// Preset…, else the preset's name.
fn name_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
if !d.str(MODE).is_empty() {
widgets::label_row(ui, tl!("Name:"), LABEL, |ui| {
form::text(ui, d, "name", 200.0);
});
return;
}
let presets = app.session.perspective_presets();
let name = d.str("name");
// The fields no longer the named preset's: [Custom].
let fields: Option<GridDefinition> = serde_json::from_value(form::params(d)).ok();
let current = presets.iter().find(|p| p.name == name).filter(|p| fields.as_ref().is_some_and(|f| f.same(p)));
if current.is_none() && !name.is_empty() {
d.fields.insert("name".into(), json!(""));
}
let shown = current.map_or(CUSTOM, |p| p.name.as_str());
let names: Vec<&str> = std::iter::once(CUSTOM).chain(presets.iter().map(|p| p.name.as_str())).collect();
// [Custom] and the built-in presets are ours (translated); the saved ones are names.
let builtin = |k: usize| k == 0 || names.get(k).is_some_and(|n| is_builtin(n));
widgets::label_row(ui, tl!("Preset:"), LABEL, |ui| {
let picked = super::mixed_dropdown(ui, "persp-preset", shown, &names, 200.0, builtin);
if let Some(p) = picked.and_then(|i| i.checked_sub(1)).and_then(|i| presets.get(i)) {
let mut fields = json!(p);
fields[SHOWN] = fields["units"].clone();
if let Some(o) = fields.as_object() {
d.fields.extend(o.clone());
}
}
if widgets::flat_button(ui, tl!("Save Preset…"), 96.0).on_hover_text(tl!("Save these settings as a preset")).clicked() {
d.fields.insert(MODE.into(), json!("save"));
d.fields.insert(FROM.into(), json!("define"));
d.fields.insert("name".into(), json!(app.session.new_perspective_preset_name()));
}
});
ui.add_space(8.0);
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let t = Tokens::get(ui.ctx());
name_row(app, ui, d);
widgets::subheader(ui, tl!("Perspective Grid Settings"));
ui.add_space(6.0);
let k = kind(d);
widgets::label_row(ui, tl!("Type:"), LABEL, |ui| {
if let Some(i) = widgets::dropdown(ui, "persp-type", TYPES[usize::from(k - 1)], &TYPES, 200.0) {
d.fields.insert("kind".into(), json!(i + 1));
}
});
let units: Vec<(&str, &str)> = Unit::ALL.iter().filter(|u| **u != Unit::FeetInches).map(|u| (u.key(), u.label())).collect();
form::choice(ui, d, "units", tl!("Units:"), (LABEL, 200.0), &units);
// A new unit (the menu or `ui.dialog.set`) keeps the lengths: they are converted.
let unit = unit(d);
let before = Unit::named(&d.str(SHOWN)).unwrap_or(unit);
d.fields.insert(SHOWN.into(), json!(unit.key()));
if unit != before {
let f = before.points() / unit.points();
for key in LENGTHS {
if let Some(v) = d.fields.get(key).and_then(Value::as_f64) {
d.fields.insert(key.into(), json!(v * f));
}
}
if let Some(v) = d.fields.get("thirdVp").and_then(Value::as_array) {
let v: Vec<f64> = v.iter().map(|x| x.as_f64().unwrap_or(0.0) * f).collect();
d.fields.insert("thirdVp".into(), json!(v));
}
}
let sc = scale(d);
let named = SCALES.iter().find(|(_, s)| *s == sc).map(|(l, _)| *l);
let mut labels: Vec<&str> = SCALES.iter().map(|(l, _)| *l).collect();
labels.push("Custom");
widgets::label_row(ui, tl!("Scale:"), LABEL, |ui| {
if let Some(i) = widgets::dropdown(ui, "persp-scale", named.unwrap_or("Custom"), &labels, 200.0) {
// Custom starts from the scale shown.
let s = SCALES.get(i).map_or(sc, |(_, s)| *s);
d.fields.insert("scale".into(), json!(s));
}
});
if named.is_none() {
widgets::label_row(ui, "", LABEL, |ui| {
for (i, label) in [tl!("Artboard:"), tl!("Real World:")].into_iter().enumerate() {
ui.label(egui::RichText::new(label).color(t.text_dim));
if let Some(v) = widgets::plain_field(ui, ("persp-scale", i), sc[i], "", 3, 56.0).filter(|v| *v > 0.0 && v.is_finite()) {
let mut s = sc;
s[i] = v;
d.fields.insert("scale".into(), json!(s));
}
}
});
}
widgets::label_row(ui, tl!("Gridline every:"), LABEL, |ui| {
length(ui, d, "gridline", unit, FIELD);
});
if sc[0] != sc[1] {
let real = unit.number(unit.to_pt(sc[1] / sc[0]));
let note = crate::i18n::fmt(
tl!("1 {unit} on the artboard stands for {real} {unit} in the scene."),
&[("unit", unit.suffix()), ("real", &real.to_string())],
);
widgets::label_row(ui, "", LABEL, |ui| {
ui.label(egui::RichText::new(note).size(11.5).color(t.text_dim));
});
}
ui.add_space(4.0);
widgets::label_row(ui, tl!("Viewing Angle:"), LABEL, |ui| {
let a = d.f64("angle", 45.0);
if let Some(v) = ui.add_enabled_ui(k != 1, |ui| widgets::plain_field(ui, "persp-angle", a, "°", 2, FIELD)).inner {
d.fields.insert("angle".into(), json!(v));
}
});
widgets::label_row(ui, tl!("Viewing Distance:"), LABEL, |ui| {
length(ui, d, "distance", unit, FIELD);
});
widgets::label_row(ui, tl!("Horizon Height:"), LABEL, |ui| {
length(ui, d, "horizonHeight", unit, FIELD);
});
widgets::label_row(ui, tl!("Third Vanishing Point:"), LABEL, |ui| {
third_vp(ui, d, 0, unit, k == 3);
third_vp(ui, d, 1, unit, k == 3);
});
ui.add_space(12.0);
widgets::subheader(ui, tl!("Grid Color & Opacity"));
ui.add_space(6.0);
color(ui, d, "leftColor", tl!("Left Grid:"));
color(ui, d, "rightColor", tl!("Right Grid:"));
color(ui, d, "groundColor", tl!("Horizontal Grid:"));
// Opacity: a slider and its field, in the label column the rows above use.
widgets::label_row(ui, tl!("Opacity:"), LABEL, |ui| {
let v = d.f64("opacity", 50.0).clamp(0.0, 100.0);
let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8);
let mut new =
widgets::color_slider(ui, "persp-opacity", (v / 100.0) as f32, form::SLIDER_WIDTH, &track).0.map(|x| (f64::from(x) * 100.0).round());
if let Some(x) = widgets::plain_field(ui, "persp-opacity-field", v, "%", 0, 52.0) {
new = Some(x.round().clamp(0.0, 100.0));
}
if let Some(n) = new.filter(|n| *n != v) {
d.fields.insert("opacity".into(), json!(n));
}
});
false
}