mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 02:25:55 +00:00
Sheet metal: personal Sheet Metal Rules, saved in the profile and offered by every design's rule manager (#9346) (#9520)
* Sheet metal: personal rules library in the profile, offered by every design's rule manager Adds personal_sheet_rules.rs and its tests, the personal_sheet_rules preference, and the rule manager wiring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqPNsVN9JQhyMReCXXsRSu * Sheet metal: pending strings for personal rules English and the complete catalogs (ar, he, ja, vi, zh), plus de and ko. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqPNsVN9JQhyMReCXXsRSu * Docs: personal rules in the sheet metal guide Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqPNsVN9JQhyMReCXXsRSu * Docs: glossary entry for personal sheet metal rule Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqPNsVN9JQhyMReCXXsRSu --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"en": {
|
||||
"sheet.personal_rules.confirm_delete": "Confirm delete",
|
||||
"sheet.personal_rules.delete": "Delete",
|
||||
"sheet.personal_rules.delete_prompt": "Delete this personal rule? Rules already in designs are unchanged.",
|
||||
"sheet.personal_rules.full": "The personal library already contains 64 rules.",
|
||||
"sheet.personal_rules.invalid_name": "Enter a short name without line breaks or control characters.",
|
||||
"sheet.personal_rules.invalid_values": "Complete the rule's values: each must work out in this design, with a positive thickness and a K factor from 0 to 1.",
|
||||
"sheet.personal_rules.missing": "The selected personal rule is no longer available.",
|
||||
"sheet.personal_rules.name": "Rule name",
|
||||
"sheet.personal_rules.name_exists": "A personal rule with this name already exists. Use a different name.",
|
||||
"sheet.personal_rules.name_hint": "A unique name",
|
||||
"sheet.personal_rules.overwrite": "Save changes to selected personal rule",
|
||||
"sheet.personal_rules.rename": "Rename",
|
||||
"sheet.personal_rules.rule": "Personal rule",
|
||||
"sheet.personal_rules.save": "Save new",
|
||||
"sheet.personal_rules.scope": "Saved in your profile for all designs.",
|
||||
"sheet.personal_rules.select": "Select a rule",
|
||||
"sheet.personal_rules.title": "Personal rules",
|
||||
"sheet.personal_rules.unsupported": "This rule has values that this version can't use."
|
||||
},
|
||||
"ar": {
|
||||
"sheet.personal_rules.confirm_delete": "تأكيد الحذف",
|
||||
"sheet.personal_rules.delete": "حذف",
|
||||
"sheet.personal_rules.delete_prompt": "هل تحذف هذه القاعدة الشخصية؟ القواعد الموجودة في التصاميم لا تتغير.",
|
||||
"sheet.personal_rules.full": "المكتبة الشخصية فيها 64 قاعدة بالفعل.",
|
||||
"sheet.personal_rules.invalid_name": "أدخل اسمًا قصيرًا بلا فواصل أسطر ولا محارف تحكم.",
|
||||
"sheet.personal_rules.invalid_values": "أكمل قيم القاعدة: يجب أن تُحسب كل قيمة في هذا التصميم، بسماكة موجبة ومعامل K من 0 إلى 1.",
|
||||
"sheet.personal_rules.missing": "القاعدة الشخصية المحددة لم تعد متاحة.",
|
||||
"sheet.personal_rules.name": "اسم القاعدة",
|
||||
"sheet.personal_rules.name_exists": "توجد قاعدة شخصية بهذا الاسم. استخدم اسمًا آخر.",
|
||||
"sheet.personal_rules.name_hint": "اسم فريد",
|
||||
"sheet.personal_rules.overwrite": "حفظ التغييرات في القاعدة الشخصية المحددة",
|
||||
"sheet.personal_rules.rename": "إعادة تسمية",
|
||||
"sheet.personal_rules.rule": "القاعدة الشخصية",
|
||||
"sheet.personal_rules.save": "حفظ كجديدة",
|
||||
"sheet.personal_rules.scope": "محفوظة في ملفك الشخصي لكل التصاميم.",
|
||||
"sheet.personal_rules.select": "اختر قاعدة",
|
||||
"sheet.personal_rules.title": "القواعد الشخصية",
|
||||
"sheet.personal_rules.unsupported": "تحتوي هذه القاعدة على قيم لا يستطيع هذا الإصدار استخدامها."
|
||||
},
|
||||
"de": {
|
||||
"sheet.personal_rules.confirm_delete": "Löschen bestätigen",
|
||||
"sheet.personal_rules.delete": "Löschen",
|
||||
"sheet.personal_rules.delete_prompt": "Möchten Sie diese persönliche Regel wirklich löschen? Regeln, die bereits in Entwürfen sind, bleiben unverändert.",
|
||||
"sheet.personal_rules.full": "Die persönliche Bibliothek enthält bereits 64 Regeln.",
|
||||
"sheet.personal_rules.invalid_name": "Geben Sie einen kurzen Namen ohne Zeilenumbrüche oder Steuerzeichen ein.",
|
||||
"sheet.personal_rules.invalid_values": "Vervollständigen Sie die Werte der Regel: Jeder muss sich in diesem Entwurf berechnen lassen, mit positiver Dicke und einem K-Faktor von 0 bis 1.",
|
||||
"sheet.personal_rules.missing": "Die ausgewählte persönliche Regel ist nicht mehr verfügbar.",
|
||||
"sheet.personal_rules.name": "Regelname",
|
||||
"sheet.personal_rules.name_exists": "Eine persönliche Regel mit diesem Namen existiert bereits. Wählen Sie einen anderen Namen.",
|
||||
"sheet.personal_rules.name_hint": "Eindeutigen Namen eingeben",
|
||||
"sheet.personal_rules.overwrite": "Änderungen in der ausgewählten persönlichen Regel speichern",
|
||||
"sheet.personal_rules.rename": "Umbenennen",
|
||||
"sheet.personal_rules.rule": "Persönliche Regel",
|
||||
"sheet.personal_rules.save": "Als neue Regel speichern",
|
||||
"sheet.personal_rules.scope": "In Ihrem Profil gespeichert und für alle Entwürfe verfügbar.",
|
||||
"sheet.personal_rules.select": "Regel auswählen",
|
||||
"sheet.personal_rules.title": "Persönliche Regeln",
|
||||
"sheet.personal_rules.unsupported": "Diese Regel enthält Werte, die diese Version nicht verwenden kann."
|
||||
},
|
||||
"he": {
|
||||
"sheet.personal_rules.confirm_delete": "אישור מחיקה",
|
||||
"sheet.personal_rules.delete": "מחיקה",
|
||||
"sheet.personal_rules.delete_prompt": "למחוק את הכלל האישי הזה? כללים שכבר נמצאים בתכנים לא ישתנו.",
|
||||
"sheet.personal_rules.full": "הספרייה האישית כבר מכילה 64 כללים.",
|
||||
"sheet.personal_rules.invalid_name": "יש להזין שם קצר ללא מעברי שורה או תווי בקרה.",
|
||||
"sheet.personal_rules.invalid_values": "יש להשלים את ערכי הכלל: כל ערך צריך להיות ניתן לחישוב בתכן זה, עם עובי חיובי ומקדם K בין 0 ל־1.",
|
||||
"sheet.personal_rules.missing": "הכלל האישי שנבחר כבר אינו זמין.",
|
||||
"sheet.personal_rules.name": "שם הכלל",
|
||||
"sheet.personal_rules.name_exists": "כבר קיים כלל אישי בשם הזה. יש להשתמש בשם אחר.",
|
||||
"sheet.personal_rules.name_hint": "שם ייחודי",
|
||||
"sheet.personal_rules.overwrite": "שמירת השינויים בכלל האישי שנבחר",
|
||||
"sheet.personal_rules.rename": "שינוי שם",
|
||||
"sheet.personal_rules.rule": "כלל אישי",
|
||||
"sheet.personal_rules.save": "שמירה כחדש",
|
||||
"sheet.personal_rules.scope": "נשמרים בפרופיל שלך לכל התכנים.",
|
||||
"sheet.personal_rules.select": "בחירת כלל",
|
||||
"sheet.personal_rules.title": "כללים אישיים",
|
||||
"sheet.personal_rules.unsupported": "לכלל הזה יש ערכים שהגרסה הזו אינה יכולה להשתמש בהם."
|
||||
},
|
||||
"ja": {
|
||||
"sheet.personal_rules.confirm_delete": "削除を確認",
|
||||
"sheet.personal_rules.delete": "削除",
|
||||
"sheet.personal_rules.delete_prompt": "この個人用ルールを削除しますか?デザインに既にあるルールは変更されません。",
|
||||
"sheet.personal_rules.full": "個人用ライブラリには既に64個のルールがあります。",
|
||||
"sheet.personal_rules.invalid_name": "改行や制御文字を含まない短い名前を入力してください。",
|
||||
"sheet.personal_rules.invalid_values": "ルールの値をすべて入力してください。各値はこのデザインで計算でき、板厚は正の値、K係数は0〜1である必要があります。",
|
||||
"sheet.personal_rules.missing": "選択した個人用ルールは利用できなくなりました。",
|
||||
"sheet.personal_rules.name": "ルール名",
|
||||
"sheet.personal_rules.name_exists": "同じ名前の個人用ルールが既にあります。別の名前を指定してください。",
|
||||
"sheet.personal_rules.name_hint": "一意の名前",
|
||||
"sheet.personal_rules.overwrite": "選択した個人用ルールの変更を保存",
|
||||
"sheet.personal_rules.rename": "名前を変更",
|
||||
"sheet.personal_rules.rule": "個人用ルール",
|
||||
"sheet.personal_rules.save": "新規保存",
|
||||
"sheet.personal_rules.scope": "すべてのデザインで使えるようにプロファイルに保存します。",
|
||||
"sheet.personal_rules.select": "ルールを選択",
|
||||
"sheet.personal_rules.title": "個人用ルール",
|
||||
"sheet.personal_rules.unsupported": "このルールには、このバージョンで使用できない値が含まれています。"
|
||||
},
|
||||
"ko": {
|
||||
"sheet.personal_rules.confirm_delete": "삭제 확인",
|
||||
"sheet.personal_rules.delete": "삭제",
|
||||
"sheet.personal_rules.delete_prompt": "이 개인 규칙을 삭제할까요? 설계에 이미 있는 규칙은 바뀌지 않습니다.",
|
||||
"sheet.personal_rules.full": "개인 라이브러리에 이미 규칙이 64개 있습니다.",
|
||||
"sheet.personal_rules.invalid_name": "줄 바꿈이나 제어 문자가 없는 짧은 이름을 입력하세요.",
|
||||
"sheet.personal_rules.invalid_values": "규칙 값을 모두 입력하세요. 각 값은 이 설계에서 계산할 수 있어야 하며, 두께는 양수, K 계수는 0에서 1 사이여야 합니다.",
|
||||
"sheet.personal_rules.missing": "선택한 개인 규칙을 더 이상 사용할 수 없습니다.",
|
||||
"sheet.personal_rules.name": "규칙 이름",
|
||||
"sheet.personal_rules.name_exists": "같은 이름의 개인 규칙이 이미 있습니다. 다른 이름을 사용하세요.",
|
||||
"sheet.personal_rules.name_hint": "고유한 이름",
|
||||
"sheet.personal_rules.overwrite": "선택한 개인 규칙에 변경 사항 저장",
|
||||
"sheet.personal_rules.rename": "이름 바꾸기",
|
||||
"sheet.personal_rules.rule": "개인 규칙",
|
||||
"sheet.personal_rules.save": "새로 저장",
|
||||
"sheet.personal_rules.scope": "모든 설계에서 쓸 수 있도록 프로필에 저장됩니다.",
|
||||
"sheet.personal_rules.select": "규칙 선택",
|
||||
"sheet.personal_rules.title": "개인 규칙",
|
||||
"sheet.personal_rules.unsupported": "이 규칙에는 이 버전에서 사용할 수 없는 값이 있습니다."
|
||||
},
|
||||
"vi": {
|
||||
"sheet.personal_rules.confirm_delete": "Xác nhận xóa",
|
||||
"sheet.personal_rules.delete": "Xóa",
|
||||
"sheet.personal_rules.delete_prompt": "Xóa quy tắc cá nhân này? Các quy tắc đã có trong thiết kế sẽ không thay đổi.",
|
||||
"sheet.personal_rules.full": "Thư viện cá nhân đã chứa tối đa 64 quy tắc.",
|
||||
"sheet.personal_rules.invalid_name": "Nhập một tên ngắn gọn, không chứa ký tự xuống dòng hoặc ký tự điều khiển.",
|
||||
"sheet.personal_rules.invalid_values": "Hãy điền đủ các giá trị của quy tắc: mỗi giá trị phải tính được trong thiết kế này, với độ dày dương và hệ số K từ 0 đến 1.",
|
||||
"sheet.personal_rules.missing": "Quy tắc cá nhân đã chọn không còn khả dụng.",
|
||||
"sheet.personal_rules.name": "Tên quy tắc",
|
||||
"sheet.personal_rules.name_exists": "Quy tắc cá nhân với tên này đã tồn tại. Vui lòng sử dụng tên khác.",
|
||||
"sheet.personal_rules.name_hint": "Tên duy nhất",
|
||||
"sheet.personal_rules.overwrite": "Lưu các thay đổi vào quy tắc cá nhân đã chọn",
|
||||
"sheet.personal_rules.rename": "Đổi tên",
|
||||
"sheet.personal_rules.rule": "Quy tắc cá nhân",
|
||||
"sheet.personal_rules.save": "Lưu mới",
|
||||
"sheet.personal_rules.scope": "Được lưu trong hồ sơ cá nhân của bạn cho tất cả thiết kế.",
|
||||
"sheet.personal_rules.select": "Chọn một quy tắc",
|
||||
"sheet.personal_rules.title": "Quy tắc cá nhân",
|
||||
"sheet.personal_rules.unsupported": "Quy tắc này có các giá trị mà phiên bản hiện tại không thể sử dụng."
|
||||
},
|
||||
"zh": {
|
||||
"sheet.personal_rules.confirm_delete": "确认删除",
|
||||
"sheet.personal_rules.delete": "删除",
|
||||
"sheet.personal_rules.delete_prompt": "删除此个人规则?设计中已有的规则不会改变。",
|
||||
"sheet.personal_rules.full": "个人库中已有64条规则。",
|
||||
"sheet.personal_rules.invalid_name": "请输入不含换行符或控制字符的简短名称。",
|
||||
"sheet.personal_rules.invalid_values": "请填写完整的规则值:每个值都必须能在此设计中计算,厚度为正,K 因子在 0 到 1 之间。",
|
||||
"sheet.personal_rules.missing": "所选个人规则已不可用。",
|
||||
"sheet.personal_rules.name": "规则名称",
|
||||
"sheet.personal_rules.name_exists": "同名个人规则已存在,请使用其他名称。",
|
||||
"sheet.personal_rules.name_hint": "唯一名称",
|
||||
"sheet.personal_rules.overwrite": "保存对所选个人规则的修改",
|
||||
"sheet.personal_rules.rename": "重命名",
|
||||
"sheet.personal_rules.rule": "个人规则",
|
||||
"sheet.personal_rules.save": "另存为新规则",
|
||||
"sheet.personal_rules.scope": "保存在您的配置中,适用于所有设计。",
|
||||
"sheet.personal_rules.select": "选择规则",
|
||||
"sheet.personal_rules.title": "个人规则",
|
||||
"sheet.personal_rules.unsupported": "此规则包含当前版本无法使用的值。"
|
||||
}
|
||||
}
|
||||
@@ -3226,6 +3226,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let body = |ui: &mut egui::Ui| {
|
||||
d.sync_fillet_chain(&app.session);
|
||||
crate::hole_presets::rows(ui, &mut d, &mut app.preferences.hole_presets, &app.session.doc, reset_scroll);
|
||||
crate::personal_sheet_rules::rows(ui, &mut d, &mut app.preferences.personal_sheet_rules, &app.session.doc, reset_scroll);
|
||||
let (accepted, fields) = crate::ui_kit::value_fields(ctx, || dense::rows(app, ctx, &mut d, ui));
|
||||
enter |= accepted;
|
||||
value_fields = fields;
|
||||
@@ -3309,7 +3310,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
} else {
|
||||
d.value_focus = ctx.memory(|memory| memory.focused()).filter(|id| value_fields.iter().any(|(field, _)| field == id));
|
||||
}
|
||||
let preset_prompt = crate::hole_presets::name_prompt(ctx, &mut d, &mut app.preferences.hole_presets, &app.session.doc);
|
||||
// Each prompt draws only for its own dialog, so both run (`|`, not `||`).
|
||||
let preset_prompt = crate::hole_presets::name_prompt(ctx, &mut d, &mut app.preferences.hole_presets, &app.session.doc)
|
||||
| crate::personal_sheet_rules::name_prompt(ctx, &mut d, &mut app.preferences.personal_sheet_rules, &app.session.doc);
|
||||
if preset_prompt {
|
||||
enter = false;
|
||||
ok = false;
|
||||
|
||||
@@ -31,7 +31,7 @@ const POSITION_LABELS: [&str; 5] = ["Inside", "Outside", "Middle", "Material Out
|
||||
const HEIGHT_FROM: [&str; 3] = ["outer", "inner", "tangent"];
|
||||
const HEIGHT_FROM_LABELS: [&str; 3] = ["Outer Sharp", "Inner Sharp", "Tangent"];
|
||||
/// Rule fields the manager edits (parameter name, label).
|
||||
const RULE_FIELDS: [(&str, &str); 8] = [
|
||||
pub(crate) const RULE_FIELDS: [(&str, &str); 8] = [
|
||||
("thickness", "Thickness"),
|
||||
("k_factor", "K Factor"),
|
||||
("bend_radius", "Bend Radius"),
|
||||
@@ -42,11 +42,11 @@ const RULE_FIELDS: [(&str, &str); 8] = [
|
||||
("gap", "Flange Gap"),
|
||||
];
|
||||
/// The corner relief shapes the manager offers (the rule's `corner_relief_shape`) and their labels.
|
||||
const RELIEF_SHAPES: [&str; 4] = ["notch", "round", "rectangular", "tear"];
|
||||
pub(crate) const RELIEF_SHAPES: [&str; 4] = ["notch", "round", "rectangular", "tear"];
|
||||
const RELIEF_SHAPE_LABELS: [&str; 4] = ["Notch", "Round", "Rectangular", "Tear"];
|
||||
/// How the manager's rule reads bend angles in the DXF bend notes (the rule's `angle_display`)
|
||||
/// and their labels.
|
||||
const ANGLE_DISPLAYS: [&str; 2] = ["bend", "included"];
|
||||
pub(crate) const ANGLE_DISPLAYS: [&str; 2] = ["bend", "included"];
|
||||
const ANGLE_DISPLAY_LABELS: [&str; 2] = ["Bend", "Included"];
|
||||
/// The corner relief shapes an edge flange offers: the rule's (index 0, nothing of its own), then
|
||||
/// the shapes of `RELIEF_SHAPES` in order.
|
||||
@@ -208,7 +208,13 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
|
||||
/// The design's rules (the default steel rule when it has none).
|
||||
fn rule_list(app: &SolveApp) -> Vec<SheetRule> {
|
||||
let r = &app.session.doc.sheet.rules;
|
||||
design_rules(&app.session.doc)
|
||||
}
|
||||
|
||||
/// The rules the manager lists for this design: its own, or the default steel rule when it has
|
||||
/// none.
|
||||
pub(crate) fn design_rules(doc: &solidcraft_engine::doc::Document) -> Vec<SheetRule> {
|
||||
let r = &doc.sheet.rules;
|
||||
if r.is_empty() { vec![SheetRule::steel()] } else { r.clone() }
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ pub mod op_auto;
|
||||
pub mod open_elsewhere;
|
||||
pub(crate) mod open_meshing;
|
||||
pub mod params_dialog;
|
||||
pub mod personal_sheet_rules;
|
||||
pub(crate) mod pick_index;
|
||||
pub(crate) mod place_gizmo;
|
||||
pub(crate) mod plugins;
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
//! Personal Sheet Metal Rules: rules kept in the user's profile and offered by the Sheet Metal
|
||||
//! Rules manager of every design (#9346). Picking one only fills the open manager; the manager's
|
||||
//! OK adds it to the design, or updates the design's rule of that name, as for any rule. The
|
||||
//! design keeps its own copy, so it builds the same where the profile has no such rule.
|
||||
|
||||
use serde::{Deserialize, Serialize, de};
|
||||
use solidcraft_engine::doc::sheet::{AngleDisplay, ReliefShape, SheetRule};
|
||||
use solidcraft_engine::doc::{DocError, Document, expr};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::dialogs::{Dialog, Kind};
|
||||
use crate::dialogs_sheet::{ANGLE_DISPLAYS, RELIEF_SHAPES, RULE_FIELDS, Sm, design_rules};
|
||||
use crate::i18n::tr;
|
||||
use crate::ui_kit::{self, Text};
|
||||
|
||||
const MAX_RULES: usize = 64;
|
||||
/// The longest rule name `sheet.manage_rules` takes.
|
||||
const MAX_NAME: usize = 128;
|
||||
const MAX_EXPRESSION: usize = 512;
|
||||
|
||||
const INVALID_NAME: &str = "sheet.personal_rules.invalid_name";
|
||||
const INVALID_VALUES: &str = "sheet.personal_rules.invalid_values";
|
||||
|
||||
/// A personal rule: a rule's name and values. Every length carries its unit, so the rule gives
|
||||
/// the same sheet in a millimetre or an inch design; `Thickness` and other expressions are kept
|
||||
/// as written.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct Rule {
|
||||
name: String,
|
||||
thickness: String,
|
||||
k_factor: String,
|
||||
bend_radius: String,
|
||||
relief_width: String,
|
||||
relief_depth: String,
|
||||
corner_relief: String,
|
||||
corner_relief_shape: String,
|
||||
hem_gap: String,
|
||||
gap: String,
|
||||
angle_display: String,
|
||||
/// Values a newer version saved. They survive renaming and are never dropped silently: a rule
|
||||
/// that has them can't be recalled or overwritten here.
|
||||
#[serde(flatten)]
|
||||
unknown: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn valid_text(text: &str, max: usize) -> bool {
|
||||
text.len() <= max && !text.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
fn valid_name(name: &str) -> bool {
|
||||
!name.trim().is_empty() && name == name.trim() && valid_text(name, MAX_NAME)
|
||||
}
|
||||
|
||||
/// The manager's text for a value, by its parameter name (`dialogs_sheet::RULE_FIELDS`).
|
||||
fn field<'a>(values: &'a [String], key: &str) -> &'a str {
|
||||
RULE_FIELDS.iter().position(|(k, _)| *k == key).and_then(|i| values.get(i)).map_or("", |v| v.trim())
|
||||
}
|
||||
|
||||
/// A length as the rule keeps it: a bare number takes the design's unit, and an expression
|
||||
/// without a unit is read in it, as the manager's OK would; anything else is kept as written.
|
||||
fn portable_length(text: &str, unit: &str, lookup: &dyn Fn(&str) -> Result<expr::Value, DocError>) -> Result<String, &'static str> {
|
||||
let text = text.trim();
|
||||
// A definition (`T = 2`) would name a parameter of this design only.
|
||||
if text.is_empty() || !valid_text(text, MAX_EXPRESSION) || expr::definition(text).is_some() {
|
||||
return Err(INVALID_VALUES);
|
||||
}
|
||||
if expr::is_bare_number(text) {
|
||||
return Ok(format!("{text} {unit}"));
|
||||
}
|
||||
let value = expr::eval_with(text, lookup).map_err(|_| INVALID_VALUES)?;
|
||||
Ok(if value.unitless() { format!("({text}) * 1 {unit}") } else { text.into() })
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
/// A value by its parameter name, as `dialogs_sheet::RULE_FIELDS` names them.
|
||||
fn value(&self, key: &str) -> Option<&String> {
|
||||
Some(match key {
|
||||
"thickness" => &self.thickness,
|
||||
"k_factor" => &self.k_factor,
|
||||
"bend_radius" => &self.bend_radius,
|
||||
"relief_width" => &self.relief_width,
|
||||
"relief_depth" => &self.relief_depth,
|
||||
"corner_relief" => &self.corner_relief,
|
||||
"hem_gap" => &self.hem_gap,
|
||||
"gap" => &self.gap,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn expressions(&self) -> [&String; 8] {
|
||||
[&self.thickness, &self.k_factor, &self.bend_radius, &self.relief_width, &self.relief_depth, &self.corner_relief, &self.hem_gap, &self.gap]
|
||||
}
|
||||
|
||||
fn sheet_rule(&self) -> SheetRule {
|
||||
SheetRule {
|
||||
name: self.name.clone(),
|
||||
thickness: self.thickness.clone(),
|
||||
k_factor: self.k_factor.clone(),
|
||||
bend_radius: self.bend_radius.clone(),
|
||||
relief_width: self.relief_width.clone(),
|
||||
relief_depth: self.relief_depth.clone(),
|
||||
corner_relief: self.corner_relief.clone(),
|
||||
corner_relief_shape: self.corner_relief_shape.clone(),
|
||||
hem_gap: self.hem_gap.clone(),
|
||||
gap: self.gap.clone(),
|
||||
angle_display: self.angle_display.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape of a stored row. Values are evaluated only in a design (capture and the manager's
|
||||
/// OK), never when the preferences load.
|
||||
fn validate(&self) -> Result<(), &'static str> {
|
||||
if !valid_name(&self.name) {
|
||||
return Err(INVALID_NAME);
|
||||
}
|
||||
if self.expressions().into_iter().any(|text| text.trim().is_empty() || !valid_text(text, MAX_EXPRESSION))
|
||||
|| ReliefShape::parse(&self.corner_relief_shape).is_none()
|
||||
|| AngleDisplay::parse(&self.angle_display).is_none()
|
||||
|| self.unknown.len() > 8
|
||||
|| self.unknown.iter().any(|(key, value)| {
|
||||
key.is_empty()
|
||||
|| !valid_text(key, 64)
|
||||
|| match value {
|
||||
serde_json::Value::String(text) => !valid_text(text, MAX_EXPRESSION),
|
||||
serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null => false,
|
||||
_ => true,
|
||||
}
|
||||
})
|
||||
{
|
||||
return Err(INVALID_VALUES);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rule the manager shows, under `name`. The design must accept it as the manager's OK
|
||||
/// would: a positive thickness and a K factor from 0 to 1.
|
||||
fn capture(sm: &Sm, name: &str, doc: &Document) -> Result<Self, &'static str> {
|
||||
let Sm::Rules { values, relief, angles, .. } = sm else {
|
||||
return Err(INVALID_VALUES);
|
||||
};
|
||||
let (vals, _) = doc.param_values();
|
||||
let unit = expr::length_unit_or_mm(&doc.units);
|
||||
let lookup = |n: &str| vals.get(n).copied().ok_or_else(|| DocError::Expr(format!("unknown parameter `{n}`")));
|
||||
let thickness = portable_length(field(values, "thickness"), unit, &lookup)?;
|
||||
let t = expr::eval_with(&thickness, &lookup).and_then(|v| v.to_kind(expr::Kind::Length)).map_err(|_| INVALID_VALUES)?;
|
||||
// The other values may use the rule's own thickness, as `rule_values` reads them.
|
||||
let with_t = |n: &str| if n == "Thickness" && !vals.contains_key("Thickness") { Ok(expr::Value::length(t)) } else { lookup(n) };
|
||||
let length = |key: &str| portable_length(field(values, key), unit, &with_t);
|
||||
let k_factor = field(values, "k_factor");
|
||||
if k_factor.is_empty() || !valid_text(k_factor, MAX_EXPRESSION) || expr::definition(k_factor).is_some() {
|
||||
return Err(INVALID_VALUES);
|
||||
}
|
||||
let rule = Self {
|
||||
name: name.trim().into(),
|
||||
thickness,
|
||||
k_factor: k_factor.into(),
|
||||
bend_radius: length("bend_radius")?,
|
||||
relief_width: length("relief_width")?,
|
||||
relief_depth: length("relief_depth")?,
|
||||
corner_relief: length("corner_relief")?,
|
||||
corner_relief_shape: RELIEF_SHAPES.get(*relief).copied().unwrap_or("notch").into(),
|
||||
hem_gap: length("hem_gap")?,
|
||||
gap: length("gap")?,
|
||||
angle_display: ANGLE_DISPLAYS.get(*angles).copied().unwrap_or("bend").into(),
|
||||
unknown: BTreeMap::new(),
|
||||
};
|
||||
rule.validate()?;
|
||||
doc.rule_values(&vals, &rule.sheet_rule()).map_err(|_| INVALID_VALUES)?;
|
||||
Ok(rule)
|
||||
}
|
||||
|
||||
fn same_values(&self, other: &Self) -> bool {
|
||||
let values = |rule: &Self| {
|
||||
let mut rule = rule.clone();
|
||||
rule.name.clear();
|
||||
rule.unknown.clear();
|
||||
rule
|
||||
};
|
||||
values(self) == values(other)
|
||||
}
|
||||
|
||||
/// Fill the manager with this rule: the design's rule of the same name when it has one, else
|
||||
/// a new rule by this name. Nothing changes in the design until the manager's OK.
|
||||
fn recall(&self, sm: &mut Sm, doc: &Document) -> Result<(), &'static str> {
|
||||
self.validate()?;
|
||||
if !self.unknown.is_empty() {
|
||||
return Err("sheet.personal_rules.unsupported");
|
||||
}
|
||||
let Sm::Rules { pick, name, values, relief, angles, active, loaded } = sm else {
|
||||
return Err(INVALID_VALUES);
|
||||
};
|
||||
let rules = design_rules(doc);
|
||||
*pick = rules.iter().position(|r| r.name == self.name).unwrap_or(rules.len());
|
||||
name.clone_from(&self.name);
|
||||
*values = RULE_FIELDS.iter().map(|(key, _)| self.value(key).cloned().unwrap_or_default()).collect();
|
||||
*relief = RELIEF_SHAPES.iter().position(|s| s.eq_ignore_ascii_case(self.corner_relief_shape.trim())).unwrap_or(0);
|
||||
*angles = ANGLE_DISPLAYS.iter().position(|d| d.eq_ignore_ascii_case(self.angle_display.trim())).unwrap_or(0);
|
||||
*active = doc.sheet_rule(None).name == self.name || *pick >= rules.len();
|
||||
// The manager loads a rule's values only when its Rule box changes: these are now its own.
|
||||
*loaded = Some(*pick);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Stored as one preference array. A malformed row cannot discard unrelated preferences or valid
|
||||
/// rows. The rows and every user-controlled string are bounded.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Library(Vec<Rule>);
|
||||
|
||||
impl<'de> Deserialize<'de> for Library {
|
||||
fn deserialize<D: de::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct LibraryVisitor;
|
||||
impl<'de> de::Visitor<'de> for LibraryVisitor {
|
||||
type Value = Library;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("an array of personal sheet metal rules")
|
||||
}
|
||||
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Library, A::Error> {
|
||||
let mut library = Library::default();
|
||||
for _ in 0..MAX_RULES {
|
||||
let Some(row) = seq.next_element::<serde_json::Value>()? else { return Ok(library) };
|
||||
if let Ok(rule) = serde_json::from_value::<Rule>(row)
|
||||
&& rule.validate().is_ok()
|
||||
&& library.find(&rule.name).is_none()
|
||||
{
|
||||
library.0.push(rule);
|
||||
}
|
||||
}
|
||||
while seq.next_element::<de::IgnoredAny>()?.is_some() {}
|
||||
Ok(library)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_seq(LibraryVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
impl Library {
|
||||
/// Names are unique ignoring case.
|
||||
fn find(&self, name: &str) -> Option<usize> {
|
||||
let name = name.to_lowercase();
|
||||
self.0.iter().position(|rule| rule.name.to_lowercase() == name)
|
||||
}
|
||||
|
||||
fn save(&mut self, sm: &Sm, name: &str, doc: &Document) -> Result<(), &'static str> {
|
||||
if self.find(name.trim()).is_some() {
|
||||
return Err("sheet.personal_rules.name_exists");
|
||||
}
|
||||
if self.0.len() >= MAX_RULES {
|
||||
return Err("sheet.personal_rules.full");
|
||||
}
|
||||
self.0.push(Rule::capture(sm, name, doc)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn modified(&self, selected: &str, sm: &Sm, doc: &Document) -> bool {
|
||||
self.find(selected).and_then(|index| self.0.get(index)).is_some_and(|saved| match Rule::capture(sm, &saved.name, doc) {
|
||||
Ok(current) => !current.same_values(saved),
|
||||
Err(_) => true,
|
||||
})
|
||||
}
|
||||
|
||||
fn overwrite(&mut self, selected: &str, sm: &Sm, doc: &Document) -> Result<(), &'static str> {
|
||||
let index = self.find(selected).ok_or("sheet.personal_rules.missing")?;
|
||||
let saved = self.0.get(index).ok_or("sheet.personal_rules.missing")?;
|
||||
if !saved.unknown.is_empty() {
|
||||
return Err("sheet.personal_rules.unsupported");
|
||||
}
|
||||
let candidate = Rule::capture(sm, &saved.name, doc)?;
|
||||
// Capture and its checks finish before the saved rule is replaced.
|
||||
*self.0.get_mut(index).ok_or("sheet.personal_rules.missing")? = candidate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rename(&mut self, old: &str, new: &str) -> Result<(), &'static str> {
|
||||
let new = new.trim();
|
||||
if !valid_name(new) {
|
||||
return Err(INVALID_NAME);
|
||||
}
|
||||
let index = self.find(old).ok_or("sheet.personal_rules.missing")?;
|
||||
if self.find(new).is_some_and(|other| other != index) {
|
||||
return Err("sheet.personal_rules.name_exists");
|
||||
}
|
||||
let rule = self.0.get_mut(index).ok_or("sheet.personal_rules.missing")?;
|
||||
rule.name = new.into();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the rule the manager shows: the design's rule picked, or the new rule's name.
|
||||
fn shown_name(sm: &Sm, doc: &Document) -> String {
|
||||
let Sm::Rules { pick, name, .. } = sm else { return String::new() };
|
||||
design_rules(doc).get(*pick).map_or_else(|| name.trim().to_string(), |rule| rule.name.clone())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct NamePrompt {
|
||||
name: String,
|
||||
renaming: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct PanelState {
|
||||
selected: String,
|
||||
prompt: Option<NamePrompt>,
|
||||
confirming_delete: bool,
|
||||
error: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn panel_id() -> egui::Id {
|
||||
egui::Id::new("personal_sheet_rules_panel")
|
||||
}
|
||||
|
||||
fn rule_manager(d: &mut Dialog) -> Option<&mut Sm> {
|
||||
match &mut d.kind {
|
||||
Kind::Sheet(sm @ Sm::Rules { .. }) => Some(sm),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One section above the Sheet Metal Rules manager's fields. Only the library is kept in the
|
||||
/// preferences; the selection, errors and the naming prompt belong to this dialog.
|
||||
pub(crate) fn rows(ui: &mut egui::Ui, d: &mut Dialog, library: &mut Library, doc: &Document, reset: bool) {
|
||||
let Some(sm) = rule_manager(d) else { return };
|
||||
let id = panel_id();
|
||||
let mut state = if reset { PanelState::default() } else { ui.ctx().data_mut(|data| data.get_temp::<PanelState>(id)).unwrap_or_default() };
|
||||
ui_kit::section(ui, "personal_sheet_rules", "sheet.personal_rules.title", false, |ui| {
|
||||
ui.label(tr("sheet.personal_rules.scope"));
|
||||
crate::dialogs::form_row(ui, "sheet.personal_rules.rule", |ui| {
|
||||
let before = library.find(&state.selected).map_or(0, |index| index + 1);
|
||||
let mut selected = before;
|
||||
let dirty = library.modified(&state.selected, sm, doc);
|
||||
let display = library.find(&state.selected).and_then(|index| library.0.get(index)).map_or_else(
|
||||
|| tr("sheet.personal_rules.select").to_string(),
|
||||
|rule| {
|
||||
if dirty { format!("* {}", rule.name) } else { rule.name.clone() }
|
||||
},
|
||||
);
|
||||
let height = ui_kit::row_height();
|
||||
let width = (ui.available_width() - height - ui.spacing().item_spacing.x).max(1.0);
|
||||
ui.allocate_ui_with_layout(egui::vec2(width, height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
let response = ui_kit::dropdown_menu(ui, "personal_sheet_rule_choice", &display, |ui| {
|
||||
let placeholder = ui_kit::list_picker(ui, "personal_sheet_rules_select", tr("sheet.personal_rules.select"), selected == 0);
|
||||
if placeholder.clicked() {
|
||||
selected = 0;
|
||||
}
|
||||
crate::scenario::publish_handle(
|
||||
&format!("dialog:sheet.personal_rules.rule:{}", tr("sheet.personal_rules.select")),
|
||||
placeholder.rect.center(),
|
||||
);
|
||||
for (index, rule) in library.0.iter().enumerate() {
|
||||
let option = ui_kit::list_picker(ui, &format!("personal_sheet_rule_{index}"), &rule.name, selected == index + 1);
|
||||
if option.clicked() {
|
||||
selected = index + 1;
|
||||
}
|
||||
crate::scenario::publish_handle(&format!("dialog:sheet.personal_rules.rule:{}", rule.name), option.rect.center());
|
||||
}
|
||||
});
|
||||
response.widget_info(|| {
|
||||
let mut info = egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, ui.is_enabled(), tr("sheet.personal_rules.rule"));
|
||||
info.current_text_value = Some(display.clone());
|
||||
info
|
||||
});
|
||||
});
|
||||
if selected != before {
|
||||
let rule = selected.checked_sub(1).and_then(|index| library.0.get(index));
|
||||
state.selected = rule.map_or_else(String::new, |rule| rule.name.clone());
|
||||
state.error = rule.and_then(|rule| rule.recall(sm, doc).err());
|
||||
state.prompt = None;
|
||||
state.confirming_delete = false;
|
||||
}
|
||||
let modified = library.modified(&state.selected, sm, doc);
|
||||
ui.add_enabled_ui(modified, |ui| {
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(height, height), egui::Sense::hover());
|
||||
let save = ui_kit::icon_button_at(ui, rect, "save", tr("sheet.personal_rules.overwrite"));
|
||||
crate::scenario::publish_target("dialog:sheet.personal_rules:overwrite", save.rect);
|
||||
if save.clicked() {
|
||||
state.error = library.overwrite(&state.selected, sm, doc).err();
|
||||
state.confirming_delete = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
let chosen = library.find(&state.selected);
|
||||
// Each button is also a handle (`dialog:sheet.personal_rules:<action>`) for scenarios and the
|
||||
// control channel.
|
||||
let button = |ui: &mut egui::Ui, action: &str, label: &str, enabled: bool| {
|
||||
let response = ui_kit::button(ui, label, ui_kit::Button::Enabled(enabled));
|
||||
crate::scenario::publish_handle(&format!("dialog:sheet.personal_rules:{action}"), response.rect.center());
|
||||
response.clicked()
|
||||
};
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if button(ui, "save", tr("sheet.personal_rules.save"), true) {
|
||||
state.prompt = Some(NamePrompt { name: shown_name(sm, doc), renaming: false });
|
||||
state.error = None;
|
||||
state.confirming_delete = false;
|
||||
}
|
||||
if button(ui, "rename", tr("sheet.personal_rules.rename"), chosen.is_some()) {
|
||||
state.prompt = Some(NamePrompt { name: state.selected.clone(), renaming: true });
|
||||
state.error = None;
|
||||
state.confirming_delete = false;
|
||||
}
|
||||
if button(ui, "delete", tr("sheet.personal_rules.delete"), chosen.is_some()) {
|
||||
state.confirming_delete = true;
|
||||
state.error = None;
|
||||
}
|
||||
});
|
||||
if state.confirming_delete {
|
||||
ui.label(tr("sheet.personal_rules.delete_prompt"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if button(ui, "confirm_delete", tr("sheet.personal_rules.confirm_delete"), true) {
|
||||
if let Some(index) = library.find(&state.selected) {
|
||||
library.0.remove(index);
|
||||
}
|
||||
state = PanelState::default();
|
||||
}
|
||||
if ui_kit::button(ui, tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
|
||||
state.confirming_delete = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
if state.prompt.is_none()
|
||||
&& let Some(error) = state.error
|
||||
{
|
||||
ui_kit::text(ui, tr(error), Text::Report("personal_sheet_rules_error", true));
|
||||
}
|
||||
});
|
||||
ui.ctx().data_mut(|data| data.insert_temp(id, state));
|
||||
}
|
||||
|
||||
/// Global shortcuts run before dialogs. A pending name prompt owns their keyboard even when
|
||||
/// Escape has already left its text field. Stale state cannot block other tools.
|
||||
pub(crate) fn name_prompt_open(ctx: &egui::Context, dialog: Option<&Dialog>) -> bool {
|
||||
dialog.is_some_and(|d| matches!(&d.kind, Kind::Sheet(Sm::Rules { .. })))
|
||||
&& ctx.data_mut(|data| data.get_temp::<PanelState>(panel_id())).is_some_and(|state| state.prompt.is_some())
|
||||
}
|
||||
|
||||
/// Drawn after the manager's card, so the prompt owns input and its footer handles. `true` also
|
||||
/// covers the prompt's frame: its Enter and Cancel never reach the manager.
|
||||
pub(crate) fn name_prompt(ctx: &egui::Context, d: &mut Dialog, library: &mut Library, doc: &Document) -> bool {
|
||||
let Some(sm) = rule_manager(d) else { return false };
|
||||
let id = panel_id();
|
||||
let mut state = ctx.data_mut(|data| data.get_temp::<PanelState>(id)).unwrap_or_default();
|
||||
let Some(mut prompt) = state.prompt.take() else { return false };
|
||||
let title = tr(if prompt.renaming { "sheet.personal_rules.rename" } else { "sheet.personal_rules.save" });
|
||||
let (closed, action) = ui_kit::Confirm::new(egui::Id::new("personal_sheet_rule_name"), "save", title)
|
||||
.actions(if prompt.renaming { tr("sheet.personal_rules.rename") } else { crate::i18n::tr_label("Save") }, "Cancel", None)
|
||||
.show(ctx, true, |ui| {
|
||||
ui_kit::row(ui, "sheet.personal_rules.name", |ui| ui_kit::focused_text_field(ui, &mut prompt.name, tr("sheet.personal_rules.name_hint")));
|
||||
// Keep an over-long name for a clear error, but bound what the prompt holds.
|
||||
if prompt.name.len() > MAX_EXPRESSION {
|
||||
let end = prompt.name.char_indices().map(|(index, _)| index).take_while(|index| *index <= MAX_EXPRESSION).last().unwrap_or(0);
|
||||
prompt.name.truncate(end);
|
||||
}
|
||||
if let Some(error) = state.error {
|
||||
ui_kit::text(ui, tr(error), Text::Report("personal_sheet_rules_name_error", true));
|
||||
}
|
||||
});
|
||||
ctx.input_mut(|input| {
|
||||
input.consume_key(egui::Modifiers::NONE, egui::Key::Enter);
|
||||
input.consume_key(egui::Modifiers::NONE, egui::Key::Escape);
|
||||
});
|
||||
if closed || action == ui_kit::Action::Cancel {
|
||||
state.error = None;
|
||||
} else if action == ui_kit::Action::Ok {
|
||||
state.error = if prompt.renaming { library.rename(&state.selected, &prompt.name).err() } else { library.save(sm, &prompt.name, doc).err() };
|
||||
if state.error.is_none() {
|
||||
state.selected = prompt.name.trim().into();
|
||||
} else {
|
||||
state.prompt = Some(prompt);
|
||||
}
|
||||
} else {
|
||||
state.prompt = Some(prompt);
|
||||
}
|
||||
ctx.data_mut(|data| data.insert_temp(id, state));
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "personal_sheet_rules_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Personal Sheet Metal Rules (#9346): saved from one design's rule manager, offered by every
|
||||
//! design's, and added to a design only by the manager's OK.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{INVALID_VALUES, Library, Rule};
|
||||
use crate::dialogs::Kind;
|
||||
use crate::dialogs_sheet::Sm;
|
||||
use crate::scenario::Harness;
|
||||
|
||||
/// A design in `units` with the Sheet Metal Rules manager open and its Personal rules section
|
||||
/// unfolded.
|
||||
fn manager(units: &str) -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
if units != "mm" {
|
||||
h.app.run("document.units", json!({ "units": units })).unwrap();
|
||||
}
|
||||
h.app.start("sheet.manage_rules");
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"handle":"dialog:section:sheet.personal_rules.title"}})).unwrap();
|
||||
h.frames(2);
|
||||
h
|
||||
}
|
||||
|
||||
/// Another design whose profile has `library`.
|
||||
fn manager_with(units: &str, library: &Library) -> Harness {
|
||||
let mut h = manager(units);
|
||||
h.app.preferences.personal_sheet_rules = library.clone();
|
||||
h.frames(2);
|
||||
h
|
||||
}
|
||||
|
||||
fn sm(h: &mut Harness) -> &mut Sm {
|
||||
match &mut h.app.dialog.as_mut().expect("the rule manager is open").kind {
|
||||
Kind::Sheet(sm @ Sm::Rules { .. }) => sm,
|
||||
_ => panic!("expected the Sheet Metal Rules manager"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type the manager's values in its field order: thickness, K factor, bend radius, relief width,
|
||||
/// relief depth, corner relief, hem gap and flange gap.
|
||||
fn type_values(h: &mut Harness, typed: [&str; 8]) {
|
||||
let Sm::Rules { values, .. } = sm(h) else { panic!() };
|
||||
*values = typed.iter().map(|v| v.to_string()).collect();
|
||||
h.frames(2);
|
||||
}
|
||||
|
||||
fn save_personal(h: &mut Harness, name: &str) {
|
||||
h.step(&json!({"widget":"Save new"})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:sheet.personal_rules.name"}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":name})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
|
||||
fn choose_personal(h: &mut Harness, name: &str) {
|
||||
h.step(&json!({"click":{"handle":"dialog:sheet.personal_rules.rule"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":format!("dialog:sheet.personal_rules.rule:{name}")}})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
|
||||
/// What the manager's OK runs now.
|
||||
fn ok_commands(h: &Harness) -> Vec<(String, Value)> {
|
||||
crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn library_json(h: &Harness) -> Value {
|
||||
serde_json::to_value(&h.app.preferences.personal_sheet_rules).unwrap()
|
||||
}
|
||||
|
||||
fn row(name: &str) -> Value {
|
||||
json!({"name":name,"thickness":"2 mm","k_factor":"0.44","bend_radius":"Thickness","relief_width":"Thickness",
|
||||
"relief_depth":"Thickness * 0.5","corner_relief":"Thickness * 4.0","corner_relief_shape":"notch","hem_gap":"0.01 mm",
|
||||
"gap":"Thickness","angle_display":"bend"})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_personal_rule_saved_in_one_design_is_added_to_another_by_ok() {
|
||||
let mut h = manager("mm");
|
||||
type_values(&mut h, ["1.6", "0.42", "1.6", "Thickness", "Thickness * 0.5", "Thickness * 4.0", "0.01", "Thickness"]);
|
||||
save_personal(&mut h, "CRS 1.6");
|
||||
// Bare numbers are saved with the design's unit; expressions as written.
|
||||
assert_eq!(
|
||||
library_json(&h),
|
||||
json!([{"name":"CRS 1.6","thickness":"1.6 mm","k_factor":"0.42","bend_radius":"1.6 mm","relief_width":"Thickness",
|
||||
"relief_depth":"Thickness * 0.5","corner_relief":"Thickness * 4.0","corner_relief_shape":"notch","hem_gap":"0.01 mm",
|
||||
"gap":"Thickness","angle_display":"bend"}])
|
||||
);
|
||||
assert!(h.app.session.doc.sheet.rules.is_empty(), "saving a personal rule is a preference, not a design change");
|
||||
|
||||
let mut other = manager_with("mm", &h.app.preferences.personal_sheet_rules);
|
||||
let before = serde_json::to_value(&*other.app.session.doc).unwrap();
|
||||
choose_personal(&mut other, "CRS 1.6");
|
||||
assert_eq!(serde_json::to_value(&*other.app.session.doc).unwrap(), before, "picking a personal rule changes nothing in the design");
|
||||
assert_eq!(
|
||||
ok_commands(&other),
|
||||
vec![(
|
||||
"sheet.manage_rules".to_string(),
|
||||
json!({"name":"CRS 1.6","active":true,"thickness":"1.6 mm","k_factor":"0.42","bend_radius":"1.6 mm","relief_width":"Thickness",
|
||||
"relief_depth":"Thickness * 0.5","corner_relief":"Thickness * 4.0","hem_gap":"0.01 mm","gap":"Thickness",
|
||||
"corner_relief_shape":"notch","angle_display":"bend"})
|
||||
)]
|
||||
);
|
||||
other.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
|
||||
other.frames(4);
|
||||
let doc = &other.app.session.doc;
|
||||
assert_eq!(doc.sheet.active.as_deref(), Some("CRS 1.6"));
|
||||
let rule = doc.sheet.rules.iter().find(|r| r.name == "CRS 1.6").expect("OK adds the rule to the design");
|
||||
assert_eq!((rule.thickness.as_str(), rule.k_factor.as_str(), rule.bend_radius.as_str()), ("1.6 mm", "0.42", "1.6 mm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_personal_rule_keeps_its_size_between_inch_and_millimetre_designs() {
|
||||
let mut h = manager("in");
|
||||
type_values(&mut h, ["0.0625", "0.44", "Thickness", "Thickness", "Thickness * 0.5", "(1 + 1) / 8", "0.01", "Thickness"]);
|
||||
save_personal(&mut h, "16 ga");
|
||||
let saved = library_json(&h);
|
||||
assert_eq!(saved[0]["thickness"], "0.0625 in");
|
||||
assert_eq!(saved[0]["corner_relief"], "((1 + 1) / 8) * 1 in", "an expression without a unit is read in the design's");
|
||||
assert_eq!(saved[0]["bend_radius"], "Thickness");
|
||||
|
||||
let mut mm = manager_with("mm", &h.app.preferences.personal_sheet_rules);
|
||||
choose_personal(&mut mm, "16 ga");
|
||||
mm.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
|
||||
mm.frames(4);
|
||||
let doc = &mm.app.session.doc;
|
||||
let (vals, _) = doc.param_values();
|
||||
let values = doc.rule_values(&vals, &doc.sheet_rule(Some("16 ga"))).unwrap();
|
||||
// 1/16 in and 1/4 in, whatever the design's unit.
|
||||
assert!((values.thickness - 1.5875).abs() < 1e-9, "thickness {}", values.thickness);
|
||||
assert!((values.bend_radius - 1.5875).abs() < 1e-9, "bend radius {}", values.bend_radius);
|
||||
assert!((values.corner_relief - 6.35).abs() < 1e-9, "corner relief {}", values.corner_relief);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_personal_rule_updates_the_design_rule_of_the_same_name() {
|
||||
let mut h = manager("mm");
|
||||
type_values(&mut h, ["3", "0.4", "4.5", "Thickness", "Thickness * 0.5", "Thickness * 4.0", "0.01", "Thickness"]);
|
||||
save_personal(&mut h, "Shop 3 mm");
|
||||
h.app.dialog = None;
|
||||
h.frames(2);
|
||||
h.app.session.execute("sheet.manage_rules", &json!({"name":"Shop 3 mm","thickness":"2 mm","k_factor":0.5,"active":true})).unwrap();
|
||||
h.app.session.execute("sheet.manage_rules", &json!({"name":"Other","thickness":"1 mm","active":true})).unwrap();
|
||||
h.app.start("sheet.manage_rules");
|
||||
h.frames(4);
|
||||
choose_personal(&mut h, "Shop 3 mm");
|
||||
let Sm::Rules { pick, active, .. } = sm(&mut h) else { panic!() };
|
||||
assert_eq!((*pick, *active), (0, false), "the design's own rule of that name, still not its active rule");
|
||||
let commands = ok_commands(&h);
|
||||
assert_eq!(
|
||||
[&commands[0].1["name"], &commands[0].1["thickness"], &commands[0].1["k_factor"], &commands[0].1["bend_radius"], &commands[0].1["active"]],
|
||||
[&json!("Shop 3 mm"), &json!("3 mm"), &json!("0.4"), &json!("4.5 mm"), &json!(false)]
|
||||
);
|
||||
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
|
||||
h.frames(4);
|
||||
let doc = &h.app.session.doc;
|
||||
assert_eq!(doc.sheet.rules.len(), 2, "updated in place, not added again");
|
||||
assert_eq!(doc.sheet_rule(Some("Shop 3 mm")).thickness, "3 mm");
|
||||
assert_eq!(doc.sheet.active.as_deref(), Some("Other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_save_icon_rename_and_delete_change_only_the_personal_library() {
|
||||
let mut h = manager("mm");
|
||||
save_personal(&mut h, "Steel 2.5");
|
||||
assert_eq!(library_json(&h)[0]["thickness"], "2.5 mm");
|
||||
let Sm::Rules { values, .. } = sm(&mut h) else { panic!() };
|
||||
values[0] = "3 mm".into();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"dialog:sheet.personal_rules:overwrite"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(library_json(&h)[0]["thickness"], "3 mm", "the save icon overwrites the selected personal rule");
|
||||
h.step(&json!({"widget":"Rename"})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:sheet.personal_rules.name"}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"Steel 3"})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(library_json(&h)[0]["name"], "Steel 3");
|
||||
h.step(&json!({"widget":"Delete"})).unwrap();
|
||||
h.frames(1);
|
||||
h.step(&json!({"widget":"Confirm delete"})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(library_json(&h), json!([]));
|
||||
assert!(h.app.session.doc.sheet.rules.is_empty(), "the design never changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_refuses_a_rule_the_design_could_not_use() {
|
||||
let mut h = manager("mm");
|
||||
let doc = h.app.session.doc.clone();
|
||||
// A K factor past 1, an empty thickness, a definition and an unknown parameter.
|
||||
for (index, text) in [(1, "1.5"), (0, ""), (0, "T = 2"), (2, "Missing * 2")] {
|
||||
let Sm::Rules { values, .. } = sm(&mut h) else { panic!() };
|
||||
let kept = std::mem::replace(&mut values[index], text.into());
|
||||
assert_eq!(Rule::capture(sm(&mut h), "Bad", &doc), Err(INVALID_VALUES), "{text:?}");
|
||||
let Sm::Rules { values, .. } = sm(&mut h) else { panic!() };
|
||||
values[index] = kept;
|
||||
}
|
||||
assert!(Rule::capture(sm(&mut h), "Good", &doc).is_ok());
|
||||
let Sm::Rules { values, .. } = sm(&mut h) else { panic!() };
|
||||
values[1] = "1.5".into();
|
||||
h.frames(1);
|
||||
save_personal(&mut h, "Bad K");
|
||||
assert_eq!(library_json(&h), json!([]), "nothing is saved while the prompt reports the error");
|
||||
assert!(personal_sheet_rules_prompt_open(&h));
|
||||
}
|
||||
|
||||
fn personal_sheet_rules_prompt_open(h: &Harness) -> bool {
|
||||
super::name_prompt_open(&h.ctx, h.app.dialog.as_ref())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_rows_are_dropped_and_a_newer_versions_values_are_kept_but_never_recalled() {
|
||||
let mut bad_shape = row("Bad shape");
|
||||
bad_shape["corner_relief_shape"] = json!("hexagon");
|
||||
let mut empty = row("Empty");
|
||||
empty["thickness"] = json!(" ");
|
||||
let mut newer = row("Newer");
|
||||
newer["unfold"] = json!("bend table");
|
||||
let rows = json!([null, row("Shop"), bad_shape, empty, row(" padded "), row("SHOP"), {"name":"incomplete"}, newer]);
|
||||
let mut library: Library = serde_json::from_value(rows).unwrap();
|
||||
assert_eq!(library.0.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), ["Shop", "Newer"]);
|
||||
assert_eq!(serde_json::to_value(&library).unwrap()[1]["unfold"], "bend table", "a newer version's value survives");
|
||||
let many: Vec<_> = (0..100).map(|i| row(&format!("Rule {i}"))).collect();
|
||||
assert_eq!(serde_json::from_value::<Library>(json!(many)).unwrap().0.len(), 64);
|
||||
library.rename("Newer", "Newer rule").unwrap();
|
||||
assert_eq!(serde_json::to_value(&library).unwrap()[1]["unfold"], "bend table", "renaming keeps it too");
|
||||
assert_eq!(library.rename("Newer rule", "shop"), Err("sheet.personal_rules.name_exists"));
|
||||
assert_eq!(library.rename("Newer rule", " "), Err("sheet.personal_rules.invalid_name"));
|
||||
|
||||
let mut h = manager("mm");
|
||||
let doc = h.app.session.doc.clone();
|
||||
let before = format!("{:?}", sm(&mut h));
|
||||
let newer = library.0[1].clone();
|
||||
assert_eq!(newer.recall(sm(&mut h), &doc), Err("sheet.personal_rules.unsupported"));
|
||||
assert_eq!(format!("{:?}", sm(&mut h)), before, "a refused recall leaves the manager as it was");
|
||||
assert_eq!(library.overwrite("Newer rule", sm(&mut h), &doc), Err("sheet.personal_rules.unsupported"));
|
||||
}
|
||||
@@ -1223,6 +1223,9 @@ preference_settings! {
|
||||
/// Personal Hole parameter combinations, shared by every design in this profile.
|
||||
/// No page reset discards this user-authored library.
|
||||
hole_presets: crate::hole_presets::Library = crate::hole_presets::Library::default();
|
||||
/// Personal Sheet Metal Rules, offered by the rule manager of every design in this profile
|
||||
/// (#9346). No page reset discards this user-authored library.
|
||||
personal_sheet_rules: crate::personal_sheet_rules::Library = crate::personal_sheet_rules::Library::default();
|
||||
/// Save as mesh: the last format (`stl` or `3mf`). Older versions also kept a "send to slicer"
|
||||
/// choice here (`save_mesh_send`, `save_mesh_slicer`); a settings file that still has them loads,
|
||||
/// and they are ignored.
|
||||
@@ -5155,6 +5158,51 @@ mod tests {
|
||||
assert_eq!(saved[0]["thread_options"]["starts"], 2, "unknown thread semantics must not silently become the defaults");
|
||||
}
|
||||
|
||||
fn personal_sheet_rule_row(name: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"name": name, "thickness": "1.6 mm", "k_factor": "0.42", "bend_radius": "Thickness",
|
||||
"relief_width": "Thickness", "relief_depth": "Thickness * 0.5", "corner_relief": "Thickness * 4.0",
|
||||
"corner_relief_shape": "round", "hem_gap": "0.01 mm", "gap": "Thickness", "angle_display": "included"
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn personal_sheet_rules_corrupt_rows_and_limits_do_not_discard_other_preferences() {
|
||||
use serde_json::json;
|
||||
let good = personal_sheet_rule_row("CRS 1.6");
|
||||
let mut oversized = personal_sheet_rule_row("Too long");
|
||||
oversized["thickness"] = json!("x".repeat(513));
|
||||
let mut duplicate = good.clone();
|
||||
duplicate["name"] = json!("crs 1.6");
|
||||
let p = super::lenient(&json!({"length_decimals":4,"personal_sheet_rules":[null,good,oversized,duplicate,{"name":"incomplete"}]}));
|
||||
assert_eq!(p.length_decimals, 4);
|
||||
assert_eq!(serde_json::to_value(&p.personal_sheet_rules).unwrap(), json!([personal_sheet_rule_row("CRS 1.6")]));
|
||||
let corrupt = super::lenient(&json!({"length_decimals":3,"personal_sheet_rules":{"unexpected":true}}));
|
||||
assert_eq!(corrupt.length_decimals, 3);
|
||||
assert_eq!(serde_json::to_value(corrupt.personal_sheet_rules).unwrap(), json!([]));
|
||||
let many: Vec<_> = (0..100).map(|i| personal_sheet_rule_row(&format!("Rule {i}"))).collect();
|
||||
let capped = super::lenient(&json!({"personal_sheet_rules":many}));
|
||||
assert_eq!(serde_json::to_value(capped.personal_sheet_rules).unwrap().as_array().unwrap().len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn personal_sheet_rules_survive_application_preferences_roundtrip_and_page_resets() {
|
||||
use serde_json::json;
|
||||
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
super::set(&mut app, &json!({"personal_sheet_rules":[personal_sheet_rule_row("CRS 1.6")]})).unwrap();
|
||||
let expected = serde_json::to_value(&app.preferences.personal_sheet_rules).unwrap();
|
||||
assert_eq!(expected[0]["angle_display"], "included");
|
||||
for page in 0..super::PAGES.len() {
|
||||
super::reset_preferences(&mut app.preferences, page);
|
||||
}
|
||||
assert_eq!(serde_json::to_value(&app.preferences.personal_sheet_rules).unwrap(), expected);
|
||||
let saved = app.prefs();
|
||||
let mut other = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
other.load_prefs(&saved);
|
||||
assert_eq!(serde_json::to_value(&other.preferences.personal_sheet_rules).unwrap(), expected);
|
||||
assert!(other.session.doc.sheet.rules.is_empty(), "a personal rule is not a rule of the design");
|
||||
}
|
||||
|
||||
/// The interface font (#1290): a font that can be read, or empty for the built-in one; saved
|
||||
/// with the others and reset by Restore Defaults.
|
||||
#[test]
|
||||
|
||||
@@ -2965,8 +2965,10 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
// A dimension value that is taking the keyboard: letters belong to it (a parameter name
|
||||
// starting with L or D), even on the frame before its field holds the focus.
|
||||
let popup_owner =
|
||||
egui::Popup::is_any_open(ctx) || crate::dialogs_save_mesh::popup_open(ctx) || crate::hole_presets::name_prompt_open(ctx, app.dialog.as_ref());
|
||||
let popup_owner = egui::Popup::is_any_open(ctx)
|
||||
|| crate::dialogs_save_mesh::popup_open(ctx)
|
||||
|| crate::hole_presets::name_prompt_open(ctx, app.dialog.as_ref())
|
||||
|| crate::personal_sheet_rules::name_prompt_open(ctx, app.dialog.as_ref());
|
||||
let inline_editor = crate::dim_view::editing();
|
||||
if popup_owner
|
||||
|| inline_editor
|
||||
|
||||
@@ -17,6 +17,22 @@ and selected when the active body is a sheet), and builds parts from a sheet of
|
||||
Bends follow the rule's K-factor, so flat lengths match what a press brake will make. Tests
|
||||
check flat lengths against hand-computed bend allowances.
|
||||
|
||||
A rule is saved in its design, and a new design starts with only the default steel rule. To use
|
||||
the same rules in every design, keep them as **personal rules**, saved in your profile: open
|
||||
**Personal rules** at the top of Sheet Metal Rules.
|
||||
|
||||
- **Save new** keeps the rule the manager shows under a name: its thickness, K factor, bend
|
||||
radius, reliefs, gaps, corner relief shape and angle display. A bare number is saved with the
|
||||
design's unit, so the rule makes the same sheet in a millimetre or an inch design. `Thickness`
|
||||
and other expressions are kept as typed, and are worked out in the design the rule is used in.
|
||||
- Picking a personal rule fills the manager with it: the design's own rule of that name, or else
|
||||
a new rule. **OK** adds it to the design (or updates that rule); picking alone changes nothing.
|
||||
- The save icon beside the box saves the manager's values over the picked personal rule.
|
||||
**Rename** and **Delete** change the personal rule only, never the rules already in designs.
|
||||
|
||||
A design keeps its own copy of each rule it uses, so it builds the same on a computer without
|
||||
your personal rules. The profile keeps up to 64 of them.
|
||||
|
||||
A copy of a sheet metal body is sheet metal too. **Mirror** (`solid.mirror` with `bodies`)
|
||||
makes the opposite hand: its own flat pattern, DXF and bend table, the mirror image of the
|
||||
original's flat, with the same thickness side, bends and flat lengths, and holes where the
|
||||
|
||||
@@ -324,6 +324,19 @@ The first instance is the original.
|
||||
- **Elsewhere:** instance, element, occurrence.
|
||||
- **For translators:** not an occurrence of a component.
|
||||
|
||||
### personal sheet metal rule
|
||||
|
||||
A sheet metal rule kept in the user's preferences instead of a design, ready to add to any design.
|
||||
|
||||
Picking one in Sheet Metal Rules fills the rule manager, and its OK copies the rule into the
|
||||
design, which then keeps a copy of its own. It is an app setting, not part of a design.
|
||||
|
||||
- **In SolidCraft:** Sheet Metal Rules › Personal rules (`sheet.manage_rules`); the
|
||||
`personal_sheet_rules` key of the stored preferences.
|
||||
- **Don't use:** preset, template, library rule.
|
||||
- **Elsewhere:** rule library, sheet metal style library.
|
||||
- **For translators:** "personal" as in the user's own, not private or confidential.
|
||||
|
||||
### print face
|
||||
|
||||
The flat face of a body that lies on the print bed when the body is sent to a slicer.
|
||||
|
||||
Reference in New Issue
Block a user