Compare commits

..

1 Commits

Author SHA1 Message Date
Brennen Herbruck 6298a09327 Control server test: wait for the stopped listener's port to be free instead of checking at one instant
a_stopped_listener_closes_its_port_and_its_connections re-bound the port straight after stop() returned. The listener thread closes its socket when it notices the stop, which on a loaded machine can be a moment later, so the bind failed ('the port is free again') and failed a queue batch. It now retries the bind for up to two seconds. With the whole control test binary at 16 threads, the test failed in 18 of 200 runs before and 0 of 200 after.

Changes a default: no

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 05:39:43 -05:00
156 changed files with 633 additions and 6033 deletions
Generated
+33 -33
View File
@@ -4259,7 +4259,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4302,11 +4302,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-cli"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4322,11 +4322,11 @@ dependencies = [
[[package]]
name = "solidcraft-color"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-control"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"getrandom 0.3.4",
"serde",
@@ -4335,7 +4335,7 @@ dependencies = [
[[package]]
name = "solidcraft-control-egui"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"image",
@@ -4345,7 +4345,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"proc-macro2",
"quote",
@@ -4354,7 +4354,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde",
@@ -4376,7 +4376,7 @@ dependencies = [
[[package]]
name = "solidcraft-dynamics"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
@@ -4387,7 +4387,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4413,7 +4413,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde",
"serde_json",
@@ -4426,7 +4426,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"log",
@@ -4451,7 +4451,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4479,7 +4479,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"proptest",
@@ -4500,7 +4500,7 @@ dependencies = [
[[package]]
name = "solidcraft-look"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"log",
@@ -4512,7 +4512,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"serde_json",
@@ -4526,14 +4526,14 @@ dependencies = [
[[package]]
name = "solidcraft-mcp-server"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
]
[[package]]
name = "solidcraft-profile"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde_json",
"web-time",
@@ -4541,7 +4541,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"image",
@@ -4564,7 +4564,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"harfrust",
"serde",
@@ -4578,7 +4578,7 @@ dependencies = [
[[package]]
name = "solidcraft-smlib"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"log",
@@ -4592,18 +4592,18 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"serde",
]
[[package]]
name = "solidcraft-testkit"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
[[package]]
name = "solidcraft-theme"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"serde",
@@ -4613,7 +4613,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-egui"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"arboard",
"egui",
@@ -4655,7 +4655,7 @@ dependencies = [
[[package]]
name = "solidcraft-ui-kit"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"arboard",
"egui",
@@ -4678,14 +4678,14 @@ dependencies = [
[[package]]
name = "solidcraft-ui-registry"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
]
[[package]]
name = "solidcraft-ui-text"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"serde_json",
@@ -4695,7 +4695,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"image",
"serde",
@@ -4707,7 +4707,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-egui"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"egui",
"egui-wgpu",
@@ -4718,7 +4718,7 @@ dependencies = [
[[package]]
name = "solidcraft-viewport-gpu"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"log",
"naga",
@@ -4734,7 +4734,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"eframe",
"egui",
@@ -6220,7 +6220,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
dependencies = [
"flate2",
"proc-macro2",
+1 -1
View File
@@ -6,7 +6,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["solidcore", "vendor"]
[workspace.package]
version = "0.40.0-rc.4"
version = "0.40.0-rc.3"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+6 -37
View File
@@ -361,54 +361,23 @@ const UNSTABLE: [(&str, &str); 1] = [(
"the halves of each swept wall lie level along every axis, so which is `#1` and which `#2` follows the order the kernel gives the faces in, which changes from one build to the next",
)];
/// The designs that take longest to build, heaviest first, with one build's time (ci profile,
/// CLI `run`, one design at a time, on P-cores, 2026-11-10). The test hands them out first, and
/// each design's two builds go to two workers, so a heavy design is never built twice in a row on
/// one worker while the rest wait. Every other design took under 3.6 s. Only the order of the
/// work depends on this list: a stale entry makes the test slower, never wrong. Re-measure when a
/// design here gets much faster, or one not listed takes over 5 s.
const HEAVIEST: [&str; 6] = [
"oracle/77-fillet-variable-linear", // 35.6 s
"oracle/83-boundary-loop-thicken", // 12.4 s
"regressions/closed_sweep_torus_cut", // 6.8 s
"oracle/41-tube-radial-holes", // 4.6 s
"oracle/42-overlapping-patterns", // 4.2 s
"oracle/35-fillet-curved-curved", // 3.2 s
];
#[test]
fn corpus_designs_name_alike_every_build_and_fail_where_a_reference_is_gone() {
let designs = designs();
// Every design is built twice, as two jobs: (design, which build). The heaviest go first.
for heavy in HEAVIEST {
assert!(designs.iter().any(|(name, _)| name == heavy), "HEAVIEST lists `{heavy}`, which is not in the corpus any more");
}
let rank = |name: &str| HEAVIEST.iter().position(|h| *h == name).unwrap_or(HEAVIEST.len());
let mut jobs: Vec<(usize, usize)> = (0..designs.len()).flat_map(|d| [(d, 0), (d, 1)]).collect();
jobs.sort_by_key(|&(d, build)| (rank(&designs[d].0), d, build));
let next = std::sync::atomic::AtomicUsize::new(0);
let builds = std::sync::Mutex::new(std::collections::BTreeMap::new());
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(jobs.len().max(1));
let results = std::sync::Mutex::new(Vec::new());
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).min(designs.len().max(1));
std::thread::scope(|scope| {
for _ in 0..workers {
scope.spawn(|| {
while let Some(&(d, which)) = jobs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let got = load(&designs[d].1).map(|s| build(&s));
builds.lock().unwrap_or_else(|e| e.into_inner()).insert((d, which), got);
while let Some((name, source)) = designs.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let got = load(source).and_then(|first| Ok((build(&first), build(&load(source)?))));
results.lock().unwrap_or_else(|e| e.into_inner()).push((name.clone(), got));
}
});
}
});
let mut builds = builds.into_inner().unwrap_or_else(|e| e.into_inner());
let mut results: Vec<(String, Result<(Build, Build), String>)> = designs
.iter()
.enumerate()
.map(|(d, (name, _))| {
let first = builds.remove(&(d, 0)).unwrap();
let second = builds.remove(&(d, 1)).unwrap();
(name.clone(), first.and_then(|first| Ok((first, second?))))
})
.collect();
let mut results = results.into_inner().unwrap_or_else(|e| e.into_inner());
results.sort_by(|a, b| a.0.cmp(&b.0));
let mut problems = Vec::new();
for (name, got) in &results {
@@ -1,26 +0,0 @@
{
"en": {
"cmd.assembly_constraint_create": "Constrain"
},
"de": {
"cmd.assembly_constraint_create": "Abhängigkeit"
},
"ja": {
"cmd.assembly_constraint_create": "拘束"
},
"zh": {
"cmd.assembly_constraint_create": "约束"
},
"ko": {
"cmd.assembly_constraint_create": "구속"
},
"vi": {
"cmd.assembly_constraint_create": "Ràng buộc"
},
"ar": {
"cmd.assembly_constraint_create": "تقييد"
},
"he": {
"cmd.assembly_constraint_create": "אילוץ"
}
}
@@ -1,80 +0,0 @@
{
"en": {
"cmd.construct.axis": "Axis",
"cmd.construct.plane": "Plane",
"cmd.construct.point": "Point",
"help.construct.axis": "Creates a construction axis with the method your picks call for: along an edge or line, through a hole or round face, where two planes meet, through two points, or square to a face through a point. Use it when you know the references and not the method.",
"help.construct.plane": "Creates a construction plane with the method your picks call for: offset from a plane, through a point, between two planes, through lines or points, tangent to a curved face, or square to a path. Use it when you know the references and not the method.",
"help.construct.point": "Creates a construction point with the method your picks call for: at a vertex, at the centre of a circle or sphere, along an edge, where two lines or three planes meet, or where a line crosses a plane. Use it when you know the references and not the method.",
"ui.construct_auto.chosen": "Chosen from your picks. Pick another reference, or take one out, to change the method.",
"ui.construct_auto.no_fit": "Pick other references: no method builds from these.",
"ui.construct_auto.pick": "Pick what to build from: the method follows from your picks.",
"ui.construct_auto.refs": "References",
"ui.construct_auto.select": "Select references to build from first"
},
"ja": {
"cmd.construct.axis": "軸",
"cmd.construct.plane": "平面",
"cmd.construct.point": "点",
"help.construct.axis": "選択に応じた方法で構築軸を作ります。エッジや線に沿って、穴や丸い面を通って、2つの平面の交線に、2点を通って、または点を通り面に垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
"help.construct.plane": "選択に応じた方法で構築平面を作ります。平面からのオフセット、点を通る、2つの平面の中間、線や点を通る、曲面に接する、またはパスに垂直に作ります。参照は決まっているが方法を選びたくないときに使います。",
"help.construct.point": "選択に応じた方法で構築点を作ります。頂点、円や球の中心、エッジ上、2本の線や3つの平面の交点、または線が平面と交わる位置に作ります。参照は決まっているが方法を選びたくないときに使います。",
"ui.construct_auto.chosen": "選択から方法を選びました。参照を追加または削除すると方法が変わります。",
"ui.construct_auto.no_fit": "別の参照を選択してください。これらから作れる方法がありません。",
"ui.construct_auto.pick": "作成元を選択してください。方法は選択から決まります。",
"ui.construct_auto.refs": "参照",
"ui.construct_auto.select": "先に作成元の参照を選択してください"
},
"zh": {
"cmd.construct.axis": "轴",
"cmd.construct.plane": "平面",
"cmd.construct.point": "点",
"help.construct.axis": "按所选对象对应的方法创建构造轴:沿边或直线、穿过孔或圆形面、两平面的交线、穿过两点,或过一点垂直于面。在已知参照但不想选择方法时使用。",
"help.construct.plane": "按所选对象对应的方法创建构造平面:偏移自平面、过一点、两平面之间、过直线或点、与曲面相切,或垂直于路径。在已知参照但不想选择方法时使用。",
"help.construct.point": "按所选对象对应的方法创建构造点:在顶点、圆或球的中心、沿边、两直线或三个平面的交点,或直线与平面的交点。在已知参照但不想选择方法时使用。",
"ui.construct_auto.chosen": "已根据所选对象选定方法。添加或移除参照即可更换方法。",
"ui.construct_auto.no_fit": "请选择其他参照:没有方法能由这些对象创建。",
"ui.construct_auto.pick": "选择创建依据:方法由所选对象决定。",
"ui.construct_auto.refs": "参照",
"ui.construct_auto.select": "请先选择创建依据的参照"
},
"ar": {
"cmd.construct.axis": "محور",
"cmd.construct.plane": "مستوى",
"cmd.construct.point": "نقطة",
"help.construct.axis": "ينشئ محورًا إنشائيًا بالطريقة التي تناسب ما تختاره: على امتداد حافة أو خط، أو عبر ثقب أو وجه دائري، أو عند تقاطع مستويين، أو عبر نقطتين، أو عموديًا على وجه عبر نقطة. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"help.construct.plane": "ينشئ مستوى إنشائيًا بالطريقة التي تناسب ما تختاره: إزاحة عن مستوى، أو عبر نقطة، أو بين مستويين، أو عبر خطوط أو نقاط، أو مماسًا لوجه منحنٍ، أو عموديًا على مسار. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"help.construct.point": "ينشئ نقطة إنشائية بالطريقة التي تناسب ما تختاره: عند رأس، أو في مركز دائرة أو كرة، أو على امتداد حافة، أو عند تقاطع خطين أو ثلاثة مستويات، أو حيث يقطع خط مستوى. استخدمه عندما تعرف المراجع ولا تعرف الطريقة.",
"ui.construct_auto.chosen": "اختيرت الطريقة من اختياراتك. اختر مرجعًا آخر أو أزل مرجعًا لتغيير الطريقة.",
"ui.construct_auto.no_fit": "اختر مراجع أخرى: لا توجد طريقة تبني من هذه المراجع.",
"ui.construct_auto.pick": "اختر ما تبني منه: الطريقة تتبع اختياراتك.",
"ui.construct_auto.refs": "المراجع",
"ui.construct_auto.select": "اختر أولًا المراجع التي تبني منها"
},
"he": {
"cmd.construct.axis": "ציר",
"cmd.construct.plane": "מישור",
"cmd.construct.point": "נקודה",
"help.construct.axis": "יוצר ציר עזר בשיטה שמתאימה למה שבחרת: לאורך קצה או קו, דרך חור או פאה עגולה, במפגש של שני מישורים, דרך שתי נקודות, או ניצב לפאה דרך נקודה. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"help.construct.plane": "יוצר מישור עזר בשיטה שמתאימה למה שבחרת: בהיסט ממישור, דרך נקודה, בין שני מישורים, דרך קווים או נקודות, משיק לפאה מעוקלת, או ניצב למסלול. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"help.construct.point": "יוצר נקודת עזר בשיטה שמתאימה למה שבחרת: בקודקוד, במרכז של מעגל או כדור, לאורך קצה, במפגש של שני קווים או שלושה מישורים, או במקום שבו קו חוצה מישור. השתמש בו כשאתה יודע את ההפניות ולא את השיטה.",
"ui.construct_auto.chosen": "השיטה נבחרה לפי הבחירות שלך. בחר הפניה נוספת, או הסר אחת, כדי לשנות את השיטה.",
"ui.construct_auto.no_fit": "בחר הפניות אחרות: אין שיטה שבונה מאלה.",
"ui.construct_auto.pick": "בחר ממה לבנות: השיטה נקבעת לפי הבחירות שלך.",
"ui.construct_auto.refs": "הפניות",
"ui.construct_auto.select": "בחר קודם את ההפניות לבנייה"
},
"vi": {
"cmd.construct.axis": "Trục",
"cmd.construct.plane": "Mặt phẳng",
"cmd.construct.point": "Điểm",
"help.construct.axis": "Tạo trục dựng theo phương pháp phù hợp với những gì bạn chọn: dọc theo cạnh hoặc đường, qua lỗ hoặc mặt tròn, nơi hai mặt phẳng giao nhau, qua hai điểm, hoặc vuông góc với mặt qua một điểm. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"help.construct.plane": "Tạo mặt phẳng dựng theo phương pháp phù hợp với những gì bạn chọn: cách một mặt phẳng, qua một điểm, giữa hai mặt phẳng, qua các đường hoặc điểm, tiếp xúc với mặt cong, hoặc vuông góc với đường dẫn. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"help.construct.point": "Tạo điểm dựng theo phương pháp phù hợp với những gì bạn chọn: tại đỉnh, tại tâm đường tròn hoặc mặt cầu, dọc theo cạnh, nơi hai đường hoặc ba mặt phẳng giao nhau, hoặc nơi một đường cắt mặt phẳng. Dùng khi bạn biết các tham chiếu nhưng không biết phương pháp.",
"ui.construct_auto.chosen": "Đã chọn phương pháp theo lựa chọn của bạn. Chọn thêm hoặc bỏ một tham chiếu để đổi phương pháp.",
"ui.construct_auto.no_fit": "Hãy chọn tham chiếu khác: không phương pháp nào dựng được từ các tham chiếu này.",
"ui.construct_auto.pick": "Chọn những gì để dựng: phương pháp theo lựa chọn của bạn.",
"ui.construct_auto.refs": "Tham chiếu",
"ui.construct_auto.select": "Hãy chọn tham chiếu để dựng trước"
}
}
@@ -1,34 +0,0 @@
{
"en": {
"prefs.dialog_first_letter_buttons": "Dialog buttons answer their first letter",
"prefs.dialog_first_letter_buttons_help": "With a dialog open, typing the first letter of a button's label presses it: O for OK, C for Cancel. A letter that fits two buttons does nothing, and a focused text field keeps the keystroke. Off: typing does nothing to the buttons, as before."
},
"ar": {
"prefs.dialog_first_letter_buttons": "أزرار نوافذ الحوار تستجيب لحرفها الأول",
"prefs.dialog_first_letter_buttons_help": "عند فتح نافذة حوار، كتابة الحرف الأول من تسمية الزر تضغطه: حرف «ا» يضغط زر «إلغاء» مثلًا. الحرف الذي يناسب زرَّين لا يفعل شيئًا، وحقل النص المركَّز عليه يحتفظ بالتضغطة. عند الإيقاف: الكتابة لا تفعل شيئًا للأزرار، كما كان سابقًا."
},
"de": {
"prefs.dialog_first_letter_buttons": "Dialogschaltflächen reagieren auf ihren Anfangsbuchstaben",
"prefs.dialog_first_letter_buttons_help": "Ist ein Dialog geöffnet, drückt der Anfangsbuchstabe einer Beschriftung die zugehörige Schaltfläche: O für OK, A für Abbrechen. Ein Buchstabe, der zu zwei Schaltflächen passt, tut nichts, und ein fokussiertes Textfeld behält die Tasteneingabe. Aus: Eingaben drücken keine Schaltfläche, wie bisher."
},
"he": {
"prefs.dialog_first_letter_buttons": "כפתורי תיבות דו-שיח נענים לאות הראשונה שלהם",
"prefs.dialog_first_letter_buttons_help": "כשתיבת דו-שיח פתוחה, הקלדת האות הראשונה של שם הכפתור לוחצת עליו: האות «ב» לוחצת על «ביטול», למשל. אות שמתאימה לשני כפתורים אינה עושה דבר, ושדה טקסט שבו נמצא הסמן שומר את ההקלדה. כבוי: הקלדה אינה משפיעה על הכפתורים, כמו קודם."
},
"ja": {
"prefs.dialog_first_letter_buttons": "ダイアログのボタンはラベルの先頭文字で押せる",
"prefs.dialog_first_letter_buttons_help": "ダイアログが開いているとき、ボタンのラベルの先頭文字を入力するとそのボタンが押されます:「キ」で「キャンセル」など。2 つのボタンに当てはまる文字は何もしません。フォーカス中のテキストフィールドはそのまま入力を受け取ります。オフ:従来どおり、入力ではボタンは押されません。"
},
"ko": {
"prefs.dialog_first_letter_buttons": "다이얼로그 단추가 첫 글자에 반응",
"prefs.dialog_first_letter_buttons_help": "다이얼로그가 열려 있을 때 단추 레이블의 첫 글자를 입력하면 그 단추를 누릅니다: 예를 들어 「ㅊ」은 「취소」를 누릅니다. 두 단추에 모두 해당하는 글자는 아무 동작을 하지 않으며, 포커스된 텍스트 필드는 입력을 그대로 받습니다. 끔: 기존처럼 입력이 단추에 영향을 주지 않습니다."
},
"vi": {
"prefs.dialog_first_letter_buttons": "Nút trong hộp thoại trả lời chữ cái đầu của nhãn",
"prefs.dialog_first_letter_buttons_help": "Khi một hộp thoại đang mở, gõ chữ cái đầu của nhãn một nút sẽ bấm nút đó: chữ H bấm nút Hủy bỏ, chẳng hạn. Một chữ khớp với hai nút không thực hiện gì, và trường văn bản đang tiêu điểm giữ lại phím gõ. Tắt: gõ phím không bấm nút, như trước đây."
},
"zh": {
"prefs.dialog_first_letter_buttons": "对话框按钮响应其首字母",
"prefs.dialog_first_letter_buttons_help": "打开对话框时,按下按钮标签的首字母即可触发该按钮:如“取”对应“取消”。一个字母若匹配两个按钮则不起作用,获得焦点的文本框保留按键输入。关闭时:按键不影响按钮,与以前相同。"
}
}
-38
View File
@@ -1,38 +0,0 @@
{
"ar": {
"cmd.note_add": "إضافة ملاحظة",
"cmd.note_delete": "حذف ملاحظة",
"cmd.note_list": "عرض الملاحظات",
"cmd.note_update": "تحرير الملاحظة"
},
"en": {
"cmd.note_add": "Add Note",
"cmd.note_delete": "Delete Note",
"cmd.note_list": "List Notes",
"cmd.note_update": "Edit Note"
},
"he": {
"cmd.note_add": "הוספת הערה",
"cmd.note_delete": "מחיקת הערה",
"cmd.note_list": "הצגת הערות",
"cmd.note_update": "עריכת הערה"
},
"ja": {
"cmd.note_add": "メモを追加",
"cmd.note_delete": "メモを削除",
"cmd.note_list": "メモを一覧表示",
"cmd.note_update": "メモを編集"
},
"vi": {
"cmd.note_add": "Thêm ghi chú",
"cmd.note_delete": "Xóa ghi chú",
"cmd.note_list": "Liệt kê ghi chú",
"cmd.note_update": "Chỉnh sửa ghi chú"
},
"zh": {
"cmd.note_add": "添加备注",
"cmd.note_delete": "删除备注",
"cmd.note_list": "列出备注",
"cmd.note_update": "编辑备注"
}
}
@@ -1,240 +0,0 @@
{
"en": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Baseline",
"sketch_text.valign_top": "Top",
"sketch_text.valign_middle": "Middle",
"sketch_text.valign_bottom": "Bottom"
},
"ar": {
"Vertical": "عمودي",
"sketch_text.valign_baseline": "خط الأساس",
"sketch_text.valign_top": "أعلى",
"sketch_text.valign_middle": "وسط",
"sketch_text.valign_bottom": "أسفل"
},
"bg": {
"Vertical": "Вертикално",
"sketch_text.valign_baseline": "Базова линия",
"sketch_text.valign_top": "Горе",
"sketch_text.valign_middle": "Среда",
"sketch_text.valign_bottom": "Долу"
},
"bs": {
"Vertical": "Vertikalno",
"sketch_text.valign_baseline": "Osnovna linija",
"sketch_text.valign_top": "Vrh",
"sketch_text.valign_middle": "Sredina",
"sketch_text.valign_bottom": "Dno"
},
"ca": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Línia base",
"sketch_text.valign_top": "Dalt",
"sketch_text.valign_middle": "Mig",
"sketch_text.valign_bottom": "Baix"
},
"cs": {
"Vertical": "Svisle",
"sketch_text.valign_baseline": "Účaří",
"sketch_text.valign_top": "Nahoře",
"sketch_text.valign_middle": "Uprostřed",
"sketch_text.valign_bottom": "Dole"
},
"da": {
"Vertical": "Lodret",
"sketch_text.valign_baseline": "Grundlinje",
"sketch_text.valign_top": "Top",
"sketch_text.valign_middle": "Midte",
"sketch_text.valign_bottom": "Bund"
},
"de": {
"Vertical": "Vertikal",
"sketch_text.valign_baseline": "Grundlinie",
"sketch_text.valign_top": "Oben",
"sketch_text.valign_middle": "Mitte",
"sketch_text.valign_bottom": "Unten"
},
"es": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Línea base",
"sketch_text.valign_top": "Arriba",
"sketch_text.valign_middle": "Centro",
"sketch_text.valign_bottom": "Abajo"
},
"fi": {
"Vertical": "Pystysuunta",
"sketch_text.valign_baseline": "Perusviiva",
"sketch_text.valign_top": "Ylä",
"sketch_text.valign_middle": "Keski",
"sketch_text.valign_bottom": "Ala"
},
"fr": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Ligne de base",
"sketch_text.valign_top": "Haut",
"sketch_text.valign_middle": "Milieu",
"sketch_text.valign_bottom": "Bas"
},
"gl": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Liña base",
"sketch_text.valign_top": "Arriba",
"sketch_text.valign_middle": "Medio",
"sketch_text.valign_bottom": "Abaixo"
},
"he": {
"Vertical": "אנכי",
"sketch_text.valign_baseline": "קו בסיס",
"sketch_text.valign_top": "למעלה",
"sketch_text.valign_middle": "אמצע",
"sketch_text.valign_bottom": "למטה"
},
"hr": {
"Vertical": "Okomito",
"sketch_text.valign_baseline": "Osnovna linija",
"sketch_text.valign_top": "Vrh",
"sketch_text.valign_middle": "Sredina",
"sketch_text.valign_bottom": "Dno"
},
"hu": {
"Vertical": "Függőleges",
"sketch_text.valign_baseline": "Alapvonal",
"sketch_text.valign_top": "Fent",
"sketch_text.valign_middle": "Középen",
"sketch_text.valign_bottom": "Lent"
},
"id": {
"Vertical": "Vertikal",
"sketch_text.valign_baseline": "Garis dasar",
"sketch_text.valign_top": "Atas",
"sketch_text.valign_middle": "Tengah",
"sketch_text.valign_bottom": "Bawah"
},
"it": {
"Vertical": "Verticale",
"sketch_text.valign_baseline": "Linea di base",
"sketch_text.valign_top": "Alto",
"sketch_text.valign_middle": "Centro",
"sketch_text.valign_bottom": "Basso"
},
"ja": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "ベースライン",
"sketch_text.valign_top": "上",
"sketch_text.valign_middle": "中央",
"sketch_text.valign_bottom": "下"
},
"ka": {
"Vertical": "ვერტიკალური",
"sketch_text.valign_baseline": "საბაზისო ხაზი",
"sketch_text.valign_top": "ზემოთ",
"sketch_text.valign_middle": "შუა",
"sketch_text.valign_bottom": "ქვემოთ"
},
"ko": {
"Vertical": "수직",
"sketch_text.valign_baseline": "기준선",
"sketch_text.valign_top": "위",
"sketch_text.valign_middle": "가운데",
"sketch_text.valign_bottom": "아래"
},
"ms": {
"Vertical": "Menegak",
"sketch_text.valign_baseline": "Garis dasar",
"sketch_text.valign_top": "Atas",
"sketch_text.valign_middle": "Tengah",
"sketch_text.valign_bottom": "Bawah"
},
"nb": {
"Vertical": "Loddrett",
"sketch_text.valign_baseline": "Grunnlinje",
"sketch_text.valign_top": "Topp",
"sketch_text.valign_middle": "Midt",
"sketch_text.valign_bottom": "Bunn"
},
"nl": {
"Vertical": "Verticaal",
"sketch_text.valign_baseline": "Basislijn",
"sketch_text.valign_top": "Boven",
"sketch_text.valign_middle": "Midden",
"sketch_text.valign_bottom": "Onder"
},
"pl": {
"Vertical": "Pionowo",
"sketch_text.valign_baseline": "Linia bazowa",
"sketch_text.valign_top": "Góra",
"sketch_text.valign_middle": "Środek",
"sketch_text.valign_bottom": "Dół"
},
"pt-BR": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Linha de base",
"sketch_text.valign_top": "Topo",
"sketch_text.valign_middle": "Meio",
"sketch_text.valign_bottom": "Base"
},
"ro": {
"Vertical": "Vertical",
"sketch_text.valign_baseline": "Linie de bază",
"sketch_text.valign_top": "Sus",
"sketch_text.valign_middle": "Mijloc",
"sketch_text.valign_bottom": "Jos"
},
"ru": {
"Vertical": "По вертикали",
"sketch_text.valign_baseline": "Базовая линия",
"sketch_text.valign_top": "Верх",
"sketch_text.valign_middle": "Середина",
"sketch_text.valign_bottom": "Низ"
},
"sr": {
"Vertical": "Вертикално",
"sketch_text.valign_baseline": "Основна линија",
"sketch_text.valign_top": "Врх",
"sketch_text.valign_middle": "Средина",
"sketch_text.valign_bottom": "Дно"
},
"sv": {
"Vertical": "Lodrät",
"sketch_text.valign_baseline": "Baslinje",
"sketch_text.valign_top": "Överkant",
"sketch_text.valign_middle": "Mitten",
"sketch_text.valign_bottom": "Nederkant"
},
"tr": {
"Vertical": "Dikey",
"sketch_text.valign_baseline": "Taban çizgisi",
"sketch_text.valign_top": "Üst",
"sketch_text.valign_middle": "Orta",
"sketch_text.valign_bottom": "Alt"
},
"uk": {
"Vertical": "По вертикалі",
"sketch_text.valign_baseline": "Базова лінія",
"sketch_text.valign_top": "Верх",
"sketch_text.valign_middle": "Середина",
"sketch_text.valign_bottom": "Низ"
},
"vi": {
"Vertical": "Dọc",
"sketch_text.valign_baseline": "Đường cơ sở",
"sketch_text.valign_top": "Trên",
"sketch_text.valign_middle": "Giữa",
"sketch_text.valign_bottom": "Dưới"
},
"zh-TW": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "基線",
"sketch_text.valign_top": "頂端",
"sketch_text.valign_middle": "置中",
"sketch_text.valign_bottom": "底端"
},
"zh": {
"Vertical": "垂直",
"sketch_text.valign_baseline": "基线",
"sketch_text.valign_top": "顶端",
"sketch_text.valign_middle": "居中",
"sketch_text.valign_bottom": "底端"
}
}
@@ -1,3 +0,0 @@
| File | Author | Source | Licence |
|---|---|---|---|
| crates/look/icons/design/svg/model/plane_auto.svg | SolidCraft contributors | original construction icon master (64 grid) | SolidCraft Brand and Interface Licence (LICENSE-BRAND) |
+9 -1
View File
@@ -1417,6 +1417,14 @@ mod tests {
}
}
assert!(started.elapsed() < Duration::from_secs(3), "before the token wait runs out");
assert!(TcpListener::bind(("127.0.0.1", port)).is_ok(), "the port is free again");
// The listener thread closes its socket when it notices the stop; on a loaded machine that can
// take a moment after `stop()` returns. Wait for the port to be free, not for one instant.
let freed = Instant::now();
let mut bound = TcpListener::bind(("127.0.0.1", port));
while bound.is_err() && freed.elapsed() < Duration::from_secs(2) {
std::thread::sleep(Duration::from_millis(10));
bound = TcpListener::bind(("127.0.0.1", port));
}
assert!(bound.is_ok(), "the port is free again: {:?}", bound.err());
}
}
-5
View File
@@ -3399,9 +3399,6 @@ pub struct Document {
/// Named views: saved cameras.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub named_views: Vec<NamedView>,
/// Notes: text anchored to points in model space, drawn as screen-facing callouts (#1631).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<crate::notes::Note>,
/// What was hidden when the design was saved. A session takes it from here when the design
/// opens and keeps the live state itself (`browser.visibility`: saved, and undone like any
/// other change), so a document in a session leaves this empty; the session puts it back when
@@ -3644,7 +3641,6 @@ impl Document {
.chain(self.timeline_groups.iter().map(|g| g.id))
.chain(self.canvases.iter().map(|c| c.id))
.chain(self.point_clouds.iter().map(|c| c.id))
.chain(self.notes.iter().map(|n| n.id))
.chain(a.joints.iter().map(|j| j.id))
.chain(a.contact_sets.iter().map(|c| c.id))
.chain(a.studies.iter().map(|s| s.id))
@@ -3700,7 +3696,6 @@ impl Document {
timeline_groups: Vec::new(),
browser_order: Default::default(),
named_views: Vec::new(),
notes: Vec::new(),
visibility: Visibility::default(),
}
}
-1
View File
@@ -142,7 +142,6 @@ fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
});
// One body supplies both old nearest-face snaps and persistent named-face snaps.
// A scratch model shares its measurements, even though each sample changes the pose.
@@ -1,9 +0,0 @@
//! A joint can say which assembly constraint it was made as (`"constraint": "mate"`, `flush`,
//! `distance`, `concentric`, `insert`, `parallel`, `perpendicular`, `angle`, `tangent` or
//! `point_on_axis`), written only for a joint made with `assembly.constraint.create`. The joint
//! itself is an ordinary joint of the kind the rule needs (a mate is a planar joint), so the solver
//! never reads the field; it names the rule in the browser and the Constrain dialog, and makes the
//! joint start where its parts are when it is changed. Older designs need no transformation: their
//! joints have no constraint and are written as before. The last release opens a design with such
//! a joint with a warning and keeps the field it can't read; there the joint still solves as its
//! kind.
-4
View File
@@ -1,4 +0,0 @@
//! A design saves its notes (`notes`): text anchored to a point in model space, drawn in the
//! viewport as a small screen-facing callout. An older design has none, and opens exactly as
//! before. The last release refuses a design that has notes and asks for an update, instead of
//! opening it and losing them on the next save.
@@ -1,5 +0,0 @@
//! A sketch text's style can say where its block of lines sits up and down against its anchor
//! (`at`, or the curve it follows): `vertical_align` is `top`, `middle` or `bottom`, and is left
//! out for the first line's baseline, where every text sat before. Older designs need no rewrite:
//! without the key a text stays on its baseline. The last release opens a design that carries the
//! key with a warning, keeps what it can't read, and draws such a text on its baseline.
-86
View File
@@ -429,75 +429,6 @@ impl JointKind {
}
}
/// The assembly constraint a joint was made as (`assembly.constraint.create`): the rule in the
/// words of the Constrain dialog. The joint itself is an ordinary joint of the kind the rule needs
/// (a mate is planar, a concentric constraint cylindrical); the solver never reads this.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Constraint {
/// Two planes against each other (planar), `offset` apart.
Mate,
/// Two planes facing the same way (planar, flipped), `offset` apart.
Flush,
/// A plane `offset` from a plane (planar) or a point `offset` from a plane (point on plane).
Distance,
/// Two axes on one line (cylindrical): B slides and turns along it.
Concentric,
/// Two round edges at one centre on one axis (revolute): B only turns.
Insert,
/// Two planes or axes parallel.
Parallel,
/// Two planes or axes square to each other.
Perpendicular,
/// Two planes or axes at `angle`.
Angle,
/// A cylinder resting on a plane (line on plane, `offset` its radius).
Tangent,
/// A point on an axis (point on line).
PointOnAxis,
}
impl Constraint {
pub const ALL: [Constraint; 10] = [
Constraint::Mate,
Constraint::Flush,
Constraint::Distance,
Constraint::Concentric,
Constraint::Insert,
Constraint::Parallel,
Constraint::Perpendicular,
Constraint::Angle,
Constraint::Tangent,
Constraint::PointOnAxis,
];
/// The rule's command name (`mate`, `point_on_axis`).
pub fn name(self) -> &'static str {
match self {
Constraint::Mate => "mate",
Constraint::Flush => "flush",
Constraint::Distance => "distance",
Constraint::Concentric => "concentric",
Constraint::Insert => "insert",
Constraint::Parallel => "parallel",
Constraint::Perpendicular => "perpendicular",
Constraint::Angle => "angle",
Constraint::Tangent => "tangent",
Constraint::PointOnAxis => "point_on_axis",
}
}
/// Case, spaces, `_` and `-` are ignored; `coincident` and `axis` are also taken.
pub fn parse(s: &str) -> Option<Constraint> {
let key = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
match key.as_str() {
"coincident" => Some(Constraint::Mate),
"axis" => Some(Constraint::Concentric),
_ => Constraint::ALL.into_iter().find(|c| c.name().replace('_', "") == key),
}
}
}
/// A joint between two occurrences.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Joint {
@@ -531,17 +462,6 @@ pub struct Joint {
/// files from before captured joint values: such a joint keeps its values on Revert Position.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub captured: Option<Vec<f64>>,
/// The assembly constraint it was made as; `None` for a joint made as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constraint: Option<Constraint>,
}
impl Joint {
/// Whether its values start where its parts are when it is made or changed, so B moves no
/// further than the joint needs: a relation, or any joint made as an assembly constraint.
pub fn starts_where_it_is(&self) -> bool {
self.kind.is_relation() || self.constraint.is_some()
}
}
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
@@ -2607,11 +2527,6 @@ impl Document {
let t = if o.occurrence == 0 { IDENTITY } else { occurrence_world(self, o.occurrence) };
Ok(mat_mul(&t, &f))
}
/// The radius of the round face a circle origin is on (mm; an error when it is on none).
pub fn origin_radius(&self, st: &ModelState, o: &JointOrigin) -> Result<f64> {
circle_at(self, st, o).map(|c| c.radius)
}
}
#[path = "joints_cam.rs"]
@@ -2841,7 +2756,6 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
.collect();
let links =
-1
View File
@@ -945,7 +945,6 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
-1
View File
@@ -94,7 +94,6 @@ mod tests {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
-2
View File
@@ -49,8 +49,6 @@ mod lineage;
pub mod metadata;
pub use metadata::Metadata;
pub mod motion;
pub mod notes;
pub use notes::Note;
mod name_lookup;
mod names;
pub mod naming;
-76
View File
@@ -1,76 +0,0 @@
//! Notes: text anchored to a point in model space, drawn in the viewport as a small
//! screen-facing callout (#1631). Personal design documentation — reminders, decisions,
//! warnings — saved with the design, not collaboration.
use serde::{Deserialize, Serialize};
use solidcore_record::Vec3;
/// Longest text a note may hold (characters).
pub const MAX_NOTE_CHARS: usize = 4000;
/// A note: an anchor point in model space (mm, design coordinates) and the text shown at
/// its projection. The callout always faces the screen because it is drawn in screen space.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Note {
pub id: u64,
/// The point the note is anchored to (design coordinates, mm).
pub anchor: Vec3,
/// The note's text (may span lines).
pub text: String,
/// Hidden notes are kept but not drawn (`browser.visibility` shows and hides them).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hidden: bool,
}
impl Note {
/// True when the text is usable: not empty and within [`MAX_NOTE_CHARS`].
pub fn text_is_valid(text: &str) -> bool {
!text.trim().is_empty() && text.chars().count() <= MAX_NOTE_CHARS
}
}
#[cfg(test)]
mod tests {
use super::*;
fn note(id: u64) -> Note {
Note { id, anchor: Vec3::new(1.0, 2.0, 3.0), text: format!("Check clearance here.\nLine {id}"), hidden: false }
}
#[test]
fn a_design_with_notes_survives_a_round_trip() {
let mut doc = crate::Document::new("Noted");
doc.notes.push(note(1));
doc.notes.push(Note { id: 2, anchor: Vec3::new(-4.0, 0.0, 6.5), text: "Adjust after the test print".into(), hidden: true });
let json = serde_json::to_string(&doc).unwrap();
let back: crate::Document = serde_json::from_str(&json).unwrap();
assert_eq!(back, doc);
assert_eq!(back.notes.len(), 2);
assert!(back.notes[1].hidden);
}
#[test]
fn a_design_saved_before_notes_loads_with_none() {
// An older design file: no `notes` key at all.
let json = serde_json::to_string(&crate::Document::new("Old")).unwrap();
let back: crate::Document = serde_json::from_str(&json).unwrap();
assert!(back.notes.is_empty());
}
#[test]
fn hidden_notes_are_not_written_when_they_are_visible() {
let mut doc = crate::Document::new("Noted");
doc.notes.push(note(1));
let json = serde_json::to_string(&doc).unwrap();
assert!(!json.contains("hidden"));
assert!(serde_json::from_str::<crate::Document>(&json).unwrap().notes[0].text.starts_with("Check"));
}
#[test]
fn note_text_must_be_non_empty_and_bounded() {
assert!(Note::text_is_valid("Extra thickness added for strength."));
assert!(!Note::text_is_valid(" "));
assert!(!Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS + 1)));
assert!(Note::text_is_valid(&"x".repeat(MAX_NOTE_CHARS)));
}
}
-1
View File
@@ -161,7 +161,6 @@ fn same_geometry_inputs(a: &Document, b: &Document) -> bool {
named_views,
visibility,
point_clouds,
notes: _,
} = a;
(Arc::ptr_eq(render, &b.render) || *render == b.render)
&& *format == b.format
@@ -1,188 +0,0 @@
//! Assembly constraints (`assembly.constraint.create`): a geometric rule between a reference on
//! each of two parts (a mate, a flush, concentric holes…), in the words of the Constrain dialog.
//! Each rule is kept as a joint of the kind it needs, made where the parts are and tagged with the
//! rule (`Joint::constraint`), so the one joint solver places, counts and checks it with the
//! others: several constraints between one pair add up, and b keeps every motion they leave free.
use serde_json::Value;
use solidcraft_doc::joints::{Constraint, JointKind, JointOrigin, Snap};
use super::{CommandSpec, add_joint, new_joint, origin_param, value};
use crate::params::{bad, bool_, str_};
use crate::schema::joints::{CreateConstraintParams, JointAddedResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const CREATE_CONTRACT: TypedContract<CreateConstraintParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] =
&[CommandSpec::new("assembly.constraint.create", "Constrain", crate::checked!("assembly.constraint.create", create))
.icon("joint")
.contract(&CREATE_CONTRACT)
.output(output_schema_for::<JointAddedResult>)
.params(
"type: mate|flush|distance|concentric|insert|parallel|perpendicular|angle|tangent|point_on_axis; a, b: references, as joint.create's \
origins (a face or a datum plane for a plane, a circle or an axis for an axis, a point; b moves); offset?: the gap (mm or an expression; needed for distance); \
angle?: deg or an expression (needed for angle); flip?: turn b over; name? (default: the rule and a number, Mate1); quiet?. \
Kept as a joint of the kind the rule needs (mate and flush planar, distance planar or point on plane, concentric cylindrical, \
insert revolute, tangent line on plane, point on axis point on line), made where the parts are, so b moves no further than the \
rule needs → joint, name, dof, conflicts, fixed as joint.create",
)];
/// What a reference is, for the rules that need one kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Shape {
/// A planar face or a frame: its plane and normal.
Plane,
/// A round face's or edge's centre and axis.
Round,
/// A point (a vertex, an edge, a sketch point): its position.
Point,
/// A datum or construction axis: a line, with no rim or radius.
Axis,
}
fn shape(o: &JointOrigin) -> Shape {
match o.snap {
Snap::FaceCenter { .. } | Snap::Frame { .. } | Snap::Plane { .. } => Shape::Plane,
Snap::Axis { .. } => Shape::Axis,
Snap::CircleCenter { .. } => Shape::Round,
Snap::Point { .. } | Snap::Edge { .. } => Shape::Point,
}
}
/// How a rule is kept: the joint's kind, whether b is turned to face along a (`flip`), and whether
/// the two references were given the other way round (the plane or axis goes on side a).
struct Kept {
kind: JointKind,
/// `Some` where the rule fixes the facing; `None` keeps the parts' facing now.
flip: Option<bool>,
swap: bool,
}
fn kept(c: Constraint, a: Shape, b: Shape, cmd: &str) -> Result<Kept> {
use Shape::*;
let fixed = |kind, flip| Ok(Kept { kind, flip: Some(flip), swap: false });
let facing = |kind| Ok(Kept { kind, flip: None, swap: false });
// A line: a round face's or edge's axis, or an axis itself.
let axial = |s: Shape| matches!(s, Round | Axis);
// A rule between one plane or axis and something else: the plane or axis on side a.
let onto = |kind, want: &dyn Fn(Shape) -> bool, other_ok: &dyn Fn(Shape) -> bool| {
if want(a) && other_ok(b) {
Ok(Kept { kind, flip: Some(false), swap: false })
} else if want(b) && other_ok(a) {
Ok(Kept { kind, flip: Some(false), swap: true })
} else {
Err(bad(cmd, format!("{} needs {}", c.name(), needs(c))))
}
};
match (c, a, b) {
(Constraint::Mate, Plane, Plane) => fixed(JointKind::Planar, false),
(Constraint::Flush, Plane, Plane) => fixed(JointKind::Planar, true),
(Constraint::Distance, Plane, Plane) => facing(JointKind::Planar),
(Constraint::Distance, ..) => onto(JointKind::PointOnPlane, &|s| s == Plane, &|s| matches!(s, Round | Point)),
(Constraint::Concentric, ..) if axial(a) && axial(b) => facing(JointKind::Cylindrical),
(Constraint::Insert, Round, Round) => facing(JointKind::Revolute),
(Constraint::Parallel, ..) => facing(JointKind::Parallel),
(Constraint::Perpendicular, ..) => fixed(JointKind::Perpendicular, false),
(Constraint::Angle, ..) => fixed(JointKind::Angle, false),
(Constraint::Tangent, ..) => onto(JointKind::LineOnPlane, &|s| s == Plane, &|s| s == Round),
(Constraint::PointOnAxis, ..) => onto(JointKind::PointOnLine, &axial, &|s| s == Point),
_ => Err(bad(cmd, format!("{} needs {}", c.name(), needs(c)))),
}
}
/// The references a rule takes, for its refusal.
fn needs(c: Constraint) -> &'static str {
match c {
Constraint::Mate | Constraint::Flush => "two planar faces",
Constraint::Distance => "two planar faces, or a planar face and a point",
Constraint::Concentric => "two round faces, round edges or axes",
Constraint::Insert => "two round edges",
Constraint::Parallel | Constraint::Perpendicular | Constraint::Angle => "two faces or axes",
Constraint::Tangent => "a planar face and a round face",
Constraint::PointOnAxis => "a round face or edge (its axis) or an axis, and a point",
}
}
/// The rule's default name: `Mate1`, `Point on axis2`…, the first number no joint's name has.
fn default_name(s: &Session, c: Constraint) -> String {
let word = c.name().replace('_', " ");
let mut label = word.clone();
if let Some(first) = label.get(..1) {
label = first.to_ascii_uppercase() + label.get(1..).unwrap_or("");
}
let names: std::collections::BTreeSet<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
let n = (1..=s.doc.assembly.joints.len().saturating_add(1)).find(|n| !names.contains(format!("{label}{n}").as_str())).unwrap_or(1);
format!("{label}{n}")
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "assembly.constraint.create";
let c = str_(p, "type").and_then(Constraint::parse).ok_or_else(|| {
bad(cmd, "`type` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis")
})?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
// The numbers are read before the references are checked, in the order the contract reads them.
for (key, angle) in [("offset", false), ("angle", true)] {
if let Some(v) = p.get(key) {
value(s, v, angle, cmd)?;
}
}
let rule = kept(c, shape(&a), shape(&b), cmd)?;
let (a, b) = if rule.swap { (b, a) } else { (a, b) };
if c == Constraint::Distance && p.get("offset").is_none() {
return Err(bad(cmd, "distance needs `offset`"));
}
if c == Constraint::Angle && p.get("angle").is_none() {
return Err(bad(cmd, "angle needs `angle`"));
}
let flip = {
let st = s.model.state();
// The way b faces a now: along (true) or against. A rule that fixes neither keeps it, so b is
// not turned over.
let along = || -> Result<bool> {
let za = s.doc.origin_world(&st, &a).map_err(|e| bad(cmd, e.to_string()))?;
let zb = s.doc.origin_world(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
Ok((0..3).map(|i| za[2][i] * zb[2][i]).sum::<f64>() > 0.0)
};
let mut flip = match rule.flip {
Some(f) => f,
None => along()?,
};
if bool_(p, "flip") == Some(true) {
flip = !flip;
}
// A cylinder resting on a plane needs its round face's radius (refused when it has none).
if c == Constraint::Tangent {
s.doc.origin_radius(&st, &b).map_err(|e| bad(cmd, e.to_string()))?;
}
flip
};
// The joint's own params: the kind and facing the rule needs, the rest as given.
let mut q = p.clone();
if let Some(m) = q.as_object_mut() {
m.remove("type");
m.remove("flip");
m.remove("values");
m.remove("value");
m.remove("limits");
m.insert("flip".into(), Value::Bool(flip));
// No name, `null` or blank text: the rule's own name (`Mate1`), not a joint's.
if !m.get("name").and_then(Value::as_str).is_some_and(|n| !n.trim().is_empty()) {
m.insert("name".into(), Value::String(default_name(s, c)));
}
}
let mut j = new_joint(s, &q, rule.kind, a, b, cmd)?;
if c == Constraint::Tangent {
// The gap is kept as the offset's expression; the radius is added to it whenever the joints
// are solved (`reexpress`, which `add_joint`'s solve runs), so the part follows its face.
j.exprs.offset = Some(j.exprs.offset.take().unwrap_or_else(|| format!("{} mm", j.offset)));
}
if j.offset != 0.0 && !j.offset.is_finite() {
return Err(bad(cmd, "`offset` must be finite"));
}
j.constraint = Some(c);
add_joint(s, j, &q)
}
@@ -24,7 +24,6 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
}
}
-207
View File
@@ -1,207 +0,0 @@
//! SOLID ▸ CONSTRUCT: Plane, Axis and Point. They have no methods of their own. Given
//! references, they try their family's CONSTRUCT commands (`construct.plane.*`,
//! `construct.axis.*`, `construct.point.*`, in the order the commands are listed) with the
//! references in each command's reference parameters, and build with the first that takes them.
//! A method added to a family is tried as well, with nothing to change here.
use serde_json::{Map, Value, json};
use super::CommandSpec;
use crate::params::bad;
use crate::schema::construct::{ConstructAutoParams, ConstructAutoResult};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const CONTRACT: TypedContract<ConstructAutoParams> = TypedContract::new();
const PARAMS: &str = "refs: 1-3 refs in any order; with?: {the method's other parameters: offset, angle, t, name…}. Builds with the first of the family's CONSTRUCT commands that takes the refs in its reference parameters (and every key of `with`). Returns method: the command used";
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("construct.plane", "Plane", crate::checked!("construct.plane", plane))
.at("SOLID", "CONSTRUCT")
.icon("plane_auto")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
CommandSpec::new("construct.axis", "Axis", crate::checked!("construct.axis", axis))
.at("SOLID", "CONSTRUCT")
.icon("axis")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
CommandSpec::new("construct.point", "Point", crate::checked!("construct.point", point))
.at("SOLID", "CONSTRUCT")
.icon("point")
.contract(&CONTRACT)
.output(output_schema_for::<ConstructAutoResult>)
.params(PARAMS),
];
/// Whether `id` is one of the tools that choose their method (`construct.plane`…).
pub fn is_construct_tool(id: &str) -> bool {
COMMANDS.iter().any(|c| c.id == id)
}
/// The methods of `tool` (`construct.plane` → `construct.plane.offset`, `construct.plane.midplane`
/// …), in the order the commands are listed: the order they are tried in. Planned commands are
/// left out.
pub fn construct_methods(tool: &str) -> Vec<&'static CommandSpec> {
let prefix = format!("{tool}.");
super::command_specs().into_iter().filter(|c| c.id.starts_with(&prefix) && c.planned.is_none() && c.contract.is_some()).collect()
}
/// How a reference parameter reads a reference (its schema type).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Form {
/// Any `GeoRef` form, as given.
Geo,
/// `PlaneOrFace`: a plane as such (`{plane: p}` given as `p`) or `{face}`.
Plane,
/// `AxisLine`: an axis as such (`{axis: a}` given as `a`), `{edge}` or `{face}`.
Axis,
}
/// A parameter of a method that takes a reference: its key, how it reads one, and whether the
/// method needs it.
struct Slot {
key: String,
form: Form,
required: bool,
}
/// The schema type a property refers to, directly or as one of its `anyOf`/`oneOf` (an
/// optional parameter's).
fn form_of(property: &Value) -> Option<Form> {
let named = |v: &Value| match v.get("$ref").and_then(Value::as_str).and_then(|r| r.rsplit('/').next()) {
Some("GeoRef") => Some(Form::Geo),
Some("PlaneOrFace") => Some(Form::Plane),
Some("AxisLine") => Some(Form::Axis),
_ => None,
};
named(property).or_else(|| ["anyOf", "oneOf"].iter().filter_map(|k| property.get(*k).and_then(Value::as_array)).flatten().find_map(named))
}
/// The reference parameters of `spec`, read from its input schema.
fn slots(spec: &CommandSpec) -> Vec<Slot> {
let Some(contract) = spec.contract else { return Vec::new() };
let schema = contract.input_schema();
let root = schema.as_value();
let required: Vec<&str> =
root.get("required").and_then(Value::as_array).map(|r| r.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
let Some(properties) = root.get("properties").and_then(Value::as_object) else { return Vec::new() };
properties
.iter()
.filter_map(|(key, p)| form_of(p).map(|form| Slot { key: key.clone(), form, required: required.contains(&key.as_str()) }))
.collect()
}
/// `r` as a parameter of `form` reads it.
fn as_form(r: &Value, form: Form) -> Value {
let key = match form {
Form::Geo => return r.clone(),
Form::Plane => "plane",
Form::Axis => "axis",
};
match r.as_object() {
Some(o) if o.len() == 1 => o.get(key).cloned().unwrap_or_else(|| r.clone()),
_ => r.clone(),
}
}
/// Every way to put each of `refs` in its own slot of `slots`, with every required slot filled:
/// the slot index of each reference. The first ways keep the references in the slots' order.
fn placements(refs: usize, slots: &[Slot]) -> Vec<Vec<usize>> {
/// Extend the placement in `at` by one reference, in every free slot, and keep each complete
/// one that fills every required slot.
fn go(refs: usize, slots: &[Slot], at: &mut Vec<usize>, out: &mut Vec<Vec<usize>>) {
if at.len() == refs {
if slots.iter().enumerate().all(|(i, s)| !s.required || at.contains(&i)) {
out.push(at.clone());
}
return;
}
for i in 0..slots.len() {
if !at.contains(&i) {
at.push(i);
go(refs, slots, at, out);
at.pop();
}
}
}
let mut out = Vec::new();
if refs <= slots.len() {
go(refs, slots, &mut Vec::with_capacity(refs), &mut out);
}
out
}
/// The parameters of `spec` with `refs` placed as `at` says, and `with`.
fn params(refs: &[Value], slots: &[Slot], at: &[usize], with: &Map<String, Value>) -> Value {
let mut p = with.clone();
for (r, i) in refs.iter().zip(at) {
if let Some(slot) = slots.get(*i) {
p.insert(slot.key.clone(), as_form(r, slot.form));
}
}
Value::Object(p)
}
/// The first method of `tool` that builds from `refs` with `with`, tried on a scratch copy of
/// `s`: its command and parameters. The error, when none does, is the first a method gave whose
/// parameters fitted, else a list of what can be built from.
pub fn construct_method(s: &Session, tool: &str, refs: &[Value], with: &Map<String, Value>) -> Result<(&'static str, Value)> {
let mut first_error = None;
for spec in construct_methods(tool) {
let Some(contract) = spec.contract else { continue };
let slots = slots(spec);
for at in placements(refs.len(), &slots) {
let p = params(refs, &slots, &at, with);
if !contract.check(&p).is_empty() {
continue;
}
let mut trial = s.scratch();
match spec.run_nested(&mut trial, &p) {
Ok(_) => return Ok((spec.id, p)),
Err(e) => {
if first_error.is_none() {
first_error = Some(e);
}
}
}
}
}
Err(first_error.unwrap_or_else(|| {
let names: Vec<&str> = construct_methods(tool).iter().map(|c| c.label).collect();
bad(tool, format!("no method takes these references; the methods are: {}", names.join(", ")))
}))
}
/// Run `tool` (`construct.plane`…): build with its first method that takes `refs` and `with`, as
/// one undo step, and name the method in the reply.
fn build(s: &mut Session, tool: &str, p: &Value) -> Result<Value> {
// The contract has checked `refs` and `with`; this reads them as given.
let refs = p.get("refs").and_then(Value::as_array).cloned().unwrap_or_default();
let with = p.get("with").and_then(Value::as_object).cloned().unwrap_or_default();
let (method, params) = construct_method(s, tool, &refs, &with)?;
let spec = super::find_command(method).ok_or_else(|| bad(tool, format!("no command `{method}`")))?;
let mut reply = spec.run_nested(s, &params)?;
if let Value::Object(m) = &mut reply {
m.insert("method".into(), json!(method));
}
Ok(reply)
}
/// `construct.plane`.
fn plane(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.plane", p)
}
/// `construct.axis`.
fn axis(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.axis", p)
}
/// `construct.point`.
fn point(s: &mut Session, p: &Value) -> Result<Value> {
build(s, "construct.point", p)
}
-1
View File
@@ -425,7 +425,6 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
})
})
.collect::<Result<_>>()?;
+8 -39
View File
@@ -47,9 +47,6 @@ const CAM_CONTRACT: TypedContract<CamParams> = TypedContract::new();
#[path = "joints_inspect.rs"]
mod inspect;
#[path = "assembly_constraint.rs"]
pub(crate) mod constraint;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("joint.origin", "Joint Origin", crate::checked!("joint.origin", joint_origin))
.at("SOLID", "CREATE")
@@ -239,12 +236,9 @@ pub fn place(s: &mut Session) -> joints::Solution {
/// Work out the offsets, angles and values joints were given as expressions again (a parameter
/// may have changed). One that can't be worked out (its parameter gone) keeps its numbers; the
/// solve leaves that joint out and says why. A tangent constraint's offset is its gap plus the
/// radius of its round face as the face is now, so the part keeps resting on the plane when the
/// radius changes; a face that can't be found keeps the numbers it has.
/// solve leaves that joint out and says why.
fn reexpress(s: &mut Session) {
let params = joints::Params::new(&s.doc);
let st = s.model.state();
let changed: Vec<(usize, (f64, f64, Vec<f64>))> = s
.doc
.assembly
@@ -253,10 +247,7 @@ fn reexpress(s: &mut Session) {
.enumerate()
.filter(|(_, j)| !j.exprs.is_empty())
.filter_map(|(i, j)| {
let (mut o, a, mut q) = joints::expressed(&params, j).ok()?;
if j.constraint == Some(joints::Constraint::Tangent) {
o += s.doc.origin_radius(&st, &j.b).ok()?;
}
let (o, a, mut q) = joints::expressed(&params, j).ok()?;
joints::clamp(j, &mut q);
((o, a) != (j.offset, j.angle) || q != j.values).then_some((i, (o, a, q)))
})
@@ -1161,14 +1152,14 @@ fn add_joint(s: &mut Session, mut j: Joint, p: &Value) -> Result<Value> {
return Err(EngineError::Other("too many joints".into()));
}
check_sides(&j.a, &j.b, "joint.create")?;
// Relations, constraints and loop-closing typed joints start where their parts are. A typed joint in
// Relations and loop-closing typed joints start where their parts are. A typed joint in
// an open chain keeps its usual initial values; explicitly supplied values also keep theirs.
// A joint on a plane or axis side anchors its part to that geometry: it starts where its
// parts are too, so the part keeps its place on the plane (or along the axis) instead of
// jumping to the geometry's origin.
if p.get("values").is_none()
&& p.get("value").is_none()
&& (j.starts_where_it_is() || closes_loop(s, &j) || datum_sided(&j))
&& (j.kind.is_relation() || closes_loop(s, &j) || datum_sided(&j))
&& let Some(q) = joints::relation_values(&s.doc, &s.model.state(), &j)
{
j.values = q;
@@ -1354,13 +1345,6 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
let kind = kind_param(p, cmd)?;
let a = origin_param(s, p.get("a"), cmd)?;
let b = origin_param(s, p.get("b"), cmd)?;
let j = new_joint(s, p, kind, a, b, cmd)?;
add_joint(s, j, p)
}
/// A joint of `kind` between origins `a` and `b`, with the values, limits, flip, offset, angle and
/// name `p` gives (as joint.create reads them), not yet added.
fn new_joint(s: &mut Session, p: &Value, kind: JointKind, a: JointOrigin, b: JointOrigin, cmd: &str) -> Result<Joint> {
let id = s.doc_mut().allocate_id()?;
let name = new_joint_name(s, p, kind);
let offset = match p.get("offset") {
@@ -1385,14 +1369,13 @@ fn new_joint(s: &mut Session, p: &Value, kind: JointKind, a: JointOrigin, b: Joi
suppressed: false,
exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) },
captured: None,
constraint: None,
};
// No angle given: B keeps its turn about the joint's axis where a half turn would otherwise
// come only from how its origin's x was chosen. (An angle relation's angle is its tilt.)
if angle.is_none() && !j.kind.is_relation() {
j.angle = joints::least_turn_angle(&s.doc, &s.model.state(), &j);
}
Ok(j)
add_joint(s, j, p)
}
fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1432,7 +1415,6 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
add_joint(s, j, p)
}
@@ -2087,14 +2069,6 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(k) = kind {
let was = j.kind;
j.kind = k;
// A constraint changed to another kind of joint is a plain joint now: its tag would keep
// restarting it, and a tangent's gap expression would leave the radius out of its offset.
if k != was {
if j.constraint == Some(joints::Constraint::Tangent) {
j.exprs.offset = None;
}
j.constraint = None;
}
j.values.resize(k.dofs().len(), 0.0);
j.limits.resize(k.dofs().len(), None);
j.exprs.values.truncate(k.dofs().len());
@@ -2109,10 +2083,6 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = offset {
j.offset = o;
j.exprs.offset = typed(p.get("offset"));
// A tangent constraint's offset is its gap: the radius is added when it is solved.
if j.constraint == Some(joints::Constraint::Tangent) {
j.exprs.offset.get_or_insert_with(|| format!("{o} mm"));
}
}
if let Some(a) = angle {
j.angle = a;
@@ -2150,11 +2120,11 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
&& kind.is_none_or(|k| k == was_kind)
&& offset.is_none_or(|o| (o - was_offset).abs() <= 1e-9)
&& bool_(p, "flip").is_none_or(|f| f == was_flip);
// Otherwise a relation or a constraint whose type, offset, tilt or flip changes, or whose
// sides move, starts again from where its parts are.
// Otherwise a relation whose type, offset, tilt or flip changes, or whose sides move, starts
// again from where its parts are.
let reseat = !tilt_only && (kind.is_some() || offset.is_some() || angle.is_some() || p.get("flip").is_some() || sides);
if reseat {
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.starts_where_it_is()).cloned();
let edited = s.doc.assembly.joints.iter().find(|j| j.id == id).filter(|j| j.kind.is_relation()).cloned();
if let Some(q) = edited.and_then(|j| joints::relation_values(&s.doc, &s.model.state(), &j))
&& let Some(j) = s.doc_mut().assembly.joints.iter_mut().find(|j| j.id == id)
{
@@ -2248,7 +2218,6 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
frame_a: world(&j.a),
frame_b: world(&j.b),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
constraint: j.constraint,
}
})
.collect();
-1
View File
@@ -64,7 +64,6 @@ fn joint_json(s: &Session, st: &ModelState, sol: &joints::Solution, j: &Joint, q
errors: why(&sol.errors),
conflicts: why(&sol.conflicts),
expressions: (!j.exprs.is_empty()).then(|| j.exprs.clone()),
constraint: j.constraint,
}
}
@@ -257,7 +257,6 @@ fn a_panel_full_of_parts_placed_by_relations_stays_solved() {
suppressed: false,
exprs: Default::default(),
captured: None,
constraint: None,
};
if let Some(q) = solidcraft_doc::joints::relation_values(&s.doc, &s.model.state(), &j) {
j.values = q;
@@ -374,273 +373,3 @@ fn an_angle_edit_while_the_joint_is_suppressed_starts_from_where_the_part_is() {
assert!(near(side(&s), moved, 1e-6), "together {together}: {:?}, moved to {moved:?}", side(&s));
}
}
// Assembly constraints (`assembly.constraint.create`): the rules in the Constrain dialog's words,
// kept as joints of the kinds they need and tagged with the rule.
fn constrain(s: &mut Session, kind: &str, a: Value, b: Value, extra: Value) -> Value {
let mut p = json!({"type": kind, "a": a, "b": b});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
run(s, "assembly.constraint.create", p)
}
fn dof_of(r: &Value, s: &Session, component: &str) -> Option<u64> {
let o = occ(s, component);
r["dof"].as_array()?.iter().find(|d| d["occurrence"] == json!(o))?["dof"].as_u64()
}
/// Mate, flush and a distance place the bracket fully, in the panel's words, and it follows the
/// panel as it changes size: its bottom on the top, its front flush with the panel's front, its
/// right end 40 mm in from the panel's.
#[test]
fn mate_flush_and_distance_place_a_part_and_follow_the_panel() {
let mut s = panel();
let r = constrain(&mut s, "mate", json!("Top"), json!("Bottom"), json!({}));
assert_eq!(dof(&r, &s), Some(3), "{r}");
assert_eq!(r["name"], "Mate1");
let r = constrain(&mut s, "flush", json!("Front"), json!("Face"), json!({}));
assert_eq!(dof(&r, &s), Some(1), "{r}");
let r = constrain(&mut s, "distance", json!("Right"), json!("Corner"), json!({"offset": -40}));
assert!(clean(&r), "{r}");
assert_eq!(dof(&r, &s), Some(0), "{r}");
for w in [400.0, 650.0, 250.0] {
run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{w} mm")}));
let r = run(&mut s, "joint.solve", json!({}));
assert!(clean(&r), "W {w}: {r}");
assert!(near(place(&s, "Bracket", Vec3::new(20.0, 500.0, 0.0)), Vec3::new(w - 40.0, 0.0, 2.0), 1e-6), "W {w}");
assert!(near(place(&s, "Bracket", Vec3::new(0.0, 500.0, 0.0)), Vec3::new(w - 60.0, 0.0, 2.0), 1e-6), "W {w}");
assert!(near(place(&s, "Bracket", Vec3::new(20.0, 510.0, 6.0)), Vec3::new(w - 40.0, 10.0, 8.0), 1e-6), "W {w}");
}
// Each is a joint of the kind its rule needs, tagged with the rule.
let list = run(&mut s, "joint.list", json!({}));
let kinds: Vec<(String, String)> = list["joints"]
.as_array()
.unwrap()
.iter()
.map(|j| (j["type"].as_str().unwrap().to_string(), j["constraint"].as_str().unwrap_or("").to_string()))
.collect();
assert_eq!(kinds, [("planar".into(), "mate".into()), ("planar".into(), "flush".into()), ("point_on_plane".into(), "distance".into())]);
let inspect = run(&mut s, "joint.inspect", json!({"joint": "Flush1"}));
assert_eq!(inspect["joints"][0]["constraint"], "flush", "{inspect}");
// Saved and reopened, the rules stay; a joint made as a joint has none.
let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
let rules: Vec<_> = back.assembly.joints.iter().map(|j| j.constraint).collect();
use solidcraft_doc::joints::Constraint;
assert_eq!(rules, [Some(Constraint::Mate), Some(Constraint::Flush), Some(Constraint::Distance)]);
relation(&mut s, "parallel", "Top", "Bottom", json!({}));
assert!(!s.doc.to_json().contains("\"constraint\":null"));
assert_eq!(s.doc.assembly.joints.last().unwrap().constraint, None);
}
/// A mate moves the part only across the gap: a bracket flat above the panel drops straight onto
/// it, not turned and not slid.
#[test]
fn a_mate_moves_the_part_only_across_the_gap() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Block"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 40, 50]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let plate = occ(&s, "Plate");
run(&mut s, "occurrence.ground", json!({"occurrence": plate, "grounded": true}));
let corners = [Vec3::new(30.0, 40.0, 50.0), Vec3::new(50.0, 40.0, 50.0), Vec3::new(30.0, 60.0, 70.0)];
let r = constrain(
&mut s,
"mate",
json!({"occurrence": "Plate", "face": [5, 5, 10]}),
json!({"occurrence": "Block", "face": [31, 41, 50]}),
json!({}),
);
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(3), "{r}");
for c in corners {
let got = place(&s, "Block", c);
assert!(near(got, c - Vec3::new(0.0, 0.0, 40.0), 1e-6), "{c:?}: {got:?}");
}
// A gap: 5 mm above the plate.
let r = run(&mut s, "joint.edit", json!({"joint": "Mate1", "offset": 5}));
assert!(clean(&r), "{r}");
assert!(near(place(&s, "Block", corners[0]), Vec3::new(30.0, 40.0, 15.0), 1e-6));
}
/// A part's own datum plane and axis are references too: a block mated onto the plate's XY plane
/// drops onto it, and a point on the plate's Z axis brings its corner onto that axis. Insert and
/// tangent need a rim or a radius, which an axis has not.
#[test]
fn datum_planes_and_axes_take_constraints() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "corner": [0, 0, 0]}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Block"}));
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 40, 50]}));
run(&mut s, "component.activate", json!({"component": "root"}));
let plate = occ(&s, "Plate");
run(&mut s, "occurrence.ground", json!({"occurrence": plate, "grounded": true}));
let xy = json!({"occurrence": "Plate", "plane": "XY"});
let z = json!({"occurrence": "Plate", "axis": "Z"});
let bottom = json!({"occurrence": "Block", "face": [31, 41, 50]});
let corner = json!({"occurrence": "Block", "point": [30, 40, 50]});
let r = constrain(&mut s, "mate", xy.clone(), bottom.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(3), "{r}");
assert!(near(place(&s, "Block", Vec3::new(30.0, 40.0, 50.0)), Vec3::new(30.0, 40.0, 0.0), 1e-6));
assert!(near(place(&s, "Block", Vec3::new(50.0, 60.0, 70.0)), Vec3::new(50.0, 60.0, 20.0), 1e-6));
let r = constrain(&mut s, "point_on_axis", z.clone(), corner.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Block"), Some(1), "{r}");
assert!(near(place(&s, "Block", Vec3::new(30.0, 40.0, 50.0)), Vec3::ZERO, 1e-6));
for (kind, a, b, says) in [
("insert", z.clone(), z.clone(), "two round edges"),
("tangent", xy.clone(), z.clone(), "a planar face and a round face"),
("distance", xy.clone(), z.clone(), "a planar face and a point"),
] {
let p = json!({"type": kind, "a": a, "b": b, "offset": 1});
let e = s.execute("assembly.constraint.create", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(says), "{p}: {e}");
}
assert_eq!(s.doc.assembly.joints.len(), 2);
}
/// A shaft and a collar: two round faces. Concentric leaves a slide and a turn; Insert only the
/// turn, with the collar's end on the shaft's.
#[test]
fn concentric_and_insert_leave_a_slide_and_a_turn_or_a_turn() {
let shaft = || {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Shaft"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [0, 0, 1], "radius": 5, "height": 20, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Collar"}));
run(&mut s, "solid.cylinder", json!({"base": [50, 10, 30], "axis": [0, 0, 1], "radius": 5, "height": 10, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let o = occ(&s, "Shaft");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
s
};
let top = json!({"occurrence": "Shaft", "circle": [5, 0, 20]});
let bottom = json!({"occurrence": "Collar", "circle": [55, 10, 30]});
let mut s = shaft();
let r = constrain(&mut s, "concentric", top.clone(), bottom.clone(), json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Collar"), Some(2), "{r}");
let c = place(&s, "Collar", Vec3::new(50.0, 10.0, 30.0));
assert!(c.x.abs() < 1e-6 && c.y.abs() < 1e-6, "on the shaft's axis: {c:?}");
let mut s = shaft();
let r = constrain(&mut s, "insert", top, bottom, json!({}));
assert!(clean(&r), "{r}");
assert_eq!(dof_of(&r, &s, "Collar"), Some(1), "{r}");
assert!(near(place(&s, "Collar", Vec3::new(50.0, 10.0, 30.0)), Vec3::new(0.0, 0.0, 20.0), 1e-6));
assert!(near(place(&s, "Collar", Vec3::new(50.0, 10.0, 40.0)), Vec3::new(0.0, 0.0, 30.0), 1e-6), "standing on the shaft, not through it");
}
/// The rules' remaining freedoms: parallel 4, perpendicular and angle 5, point on axis 4, tangent
/// 4 (a roller on the panel: it rolls, slides and turns), each made where the parts are.
#[test]
fn each_rule_leaves_the_freedom_it_should() {
let roller = |s: &mut Session| {
run(s, "component.create", json!({"name": "Roller"}));
run(s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": 4, "height": 30, "operation": "new"}));
run(s, "component.activate", json!({"component": "root"}));
};
for (kind, a, b, extra, part, want) in [
("parallel", json!("Top"), json!("Bottom"), json!({}), "Bracket", 4),
("perpendicular", json!("Top"), json!("Face"), json!({}), "Bracket", 5),
("angle", json!("Top"), json!("Bottom"), json!({"angle": 30}), "Bracket", 5),
("distance", json!("Top"), json!("Bottom"), json!({"offset": 12}), "Bracket", 3),
("tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}), "Roller", 4),
("point_on_axis", json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!("Corner"), json!({}), "Bracket", 4),
] {
let mut s = panel();
roller(&mut s);
if kind == "point_on_axis" {
let o = occ(&s, "Roller");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
}
let r = constrain(&mut s, kind, a, b, extra);
assert!(clean(&r), "{kind}: {r}");
assert_eq!(dof_of(&r, &s, part), Some(want), "{kind}: {r}");
if kind == "tangent" {
// Its axis a radius above the panel's top.
let p = place(&s, "Roller", Vec3::new(0.0, 600.0, 50.0));
assert!((p.z - 6.0).abs() < 1e-6, "tangent: {p:?}");
}
}
}
/// A rule given the wrong kind of reference, or without the number it needs, is refused with what
/// it needs, and nothing is added.
#[test]
fn a_rule_with_the_wrong_references_is_refused() {
let mut s = panel();
let cases = [
(json!({"type": "mate", "a": "Top", "b": "Corner"}), "two planar faces"),
(json!({"type": "insert", "a": "Top", "b": "Bottom"}), "two round edges"),
(json!({"type": "distance", "a": "Top", "b": "Bottom"}), "offset"),
(json!({"type": "angle", "a": "Top", "b": "Bottom"}), "angle"),
(json!({"type": "weld", "a": "Top", "b": "Bottom"}), "`type` must be"),
(json!({"type": "mate", "a": "Top", "b": "Bottom", "offset": "1e999"}), ""),
];
for (p, says) in cases {
let e = s.execute("assembly.constraint.create", &p).expect_err(&p.to_string()).to_string();
assert!(e.contains(says), "{p}: {e}");
}
assert!(s.doc.assembly.joints.is_empty());
}
/// A roller tangent on the panel keeps resting on it as its radius changes, and a gap edited in as
/// a number stays a gap above the face, not the axis's height.
/// A constraint given a blank or `null` name is named after its rule, as one given no name is. A
/// joint.edit to another kind of joint makes it a plain joint: the tag goes, and a former tangent's
/// offset no longer follows its face's radius.
#[test]
fn a_constraint_edited_to_another_joint_type_is_a_plain_joint() {
let mut s = panel();
run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"}));
run(&mut s, "component.create", json!({"name": "Roller"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": "R", "height": 30, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({"name": null}));
assert!(clean(&r), "{r}");
let r = constrain(&mut s, "parallel", json!("Top"), json!("Bottom"), json!({"name": " "}));
assert!(clean(&r), "{r}");
let names: Vec<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect();
assert_eq!(names, ["Tangent1", "Parallel1"]);
run(&mut s, "joint.edit", json!({"joint": "Tangent1", "type": "revolute"}));
let j = s.doc.assembly.joints.iter().find(|j| j.name == "Tangent1").cloned().unwrap();
assert_eq!((j.kind, j.constraint, j.exprs.offset.clone()), (solidcraft_doc::joints::JointKind::Revolute, None, None));
let list = run(&mut s, "joint.list", json!({}));
let listed = list["joints"].as_array().and_then(|js| js.iter().find(|x| x["name"] == "Tangent1")).cloned().unwrap_or_default();
assert!(listed.get("constraint").is_none(), "{listed}");
// The radius no longer moves it.
run(&mut s, "parameters.change", json!({"name": "R", "expression": "7 mm"}));
run(&mut s, "joint.solve", json!({}));
let after = s.doc.assembly.joints.iter().find(|j| j.name == "Tangent1").map(|j| j.offset);
assert_eq!(after, Some(j.offset));
}
#[test]
fn a_tangent_constraint_follows_its_faces_radius() {
let mut s = panel();
run(&mut s, "parameters.add", json!({"name": "R", "expression": "4 mm"}));
run(&mut s, "component.create", json!({"name": "Roller"}));
run(&mut s, "solid.cylinder", json!({"base": [0, 600, 50], "axis": [1, 0, 0], "radius": "R", "height": 30, "operation": "new"}));
run(&mut s, "component.activate", json!({"component": "root"}));
let r = constrain(&mut s, "tangent", json!("Top"), json!({"occurrence": "Roller", "circle": [0, 604, 50]}), json!({}));
assert!(clean(&r), "{r}");
let axis = |s: &Session| place(s, "Roller", Vec3::new(0.0, 600.0, 50.0)).z;
assert!((axis(&s) - 6.0).abs() < 1e-6, "{}", axis(&s));
for (radius, gap, want) in [(7.0, None, 9.0), (2.5, None, 4.5), (2.5, Some(1.0), 5.5), (6.0, None, 9.0)] {
run(&mut s, "parameters.change", json!({"name": "R", "expression": format!("{radius} mm")}));
if let Some(g) = gap {
run(&mut s, "joint.edit", json!({"joint": "Tangent1", "offset": g}));
}
let r = run(&mut s, "joint.solve", json!({}));
assert!(clean(&r), "R {radius}: {r}");
assert!((axis(&s) - want).abs() < 1e-6, "R {radius}, gap {gap:?}: axis at {}, expected {want}", axis(&s));
}
}
+2 -6
View File
@@ -42,12 +42,8 @@ fn target(s: &Session, p: &Value, cmd: &str) -> Result<String> {
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "mesh.convert";
let state = s.model.state();
let given = target(s, p, cmd)?;
// The selection and the Browser name a body of a further placement as the world shows it
// (`Mesh (Scan:2)`): that name means the body it shows, which the conversion edits in its
// component, as `appearance.assign` reads it (#9740).
let body = s.own_body_name(&given).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?;
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{given}`")))?;
let body = target(s, p, cmd)?;
let source = state.body(&body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
if !source.body.is_mesh() {
return Err(bad(cmd, "select a mesh body"));
}
-6
View File
@@ -33,7 +33,6 @@ mod component;
mod component_clone;
mod component_clone_assembly;
mod construct;
mod construct_auto;
mod delete;
pub(crate) mod diagnostics;
mod edit;
@@ -42,7 +41,6 @@ mod features;
mod features_more;
mod join_pieces;
pub(crate) use construct::{GeoForm, GeoFormError, REF as GEO_REF_FORMS, SKETCH_PART, geo_form, is_model_axis, plane_axis_point_kind};
pub use construct_auto::{construct_method, construct_methods, is_construct_tool};
pub use design_link::{LinkProblem, LinkState};
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
@@ -88,7 +86,6 @@ mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
mod notes;
mod parts;
mod point_cloud;
mod script;
@@ -351,7 +348,6 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct_auto::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
@@ -360,7 +356,6 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(joints::constraint::COMMANDS.iter());
v.extend(clearance::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(panels::COMMANDS.iter());
@@ -369,7 +364,6 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(bom::COMMANDS.iter());
v.extend(mass_properties::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(notes::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(selection::COMMANDS.iter());
-124
View File
@@ -1,124 +0,0 @@
//! Note commands (#1631): personal notes anchored to points in model space, saved with the
//! design and drawn in the viewport as screen-facing callouts. `note.add` anchors a note,
//! `note.update` edits its text, anchor, or hidden flag, `note.delete` removes it, and
//! `note.list` reads them back. Everything lives in the document, so notes are saved and
//! undone like any other design change.
use serde_json::{Value, json};
use solidcraft_doc::Note;
use super::CommandSpec;
use crate::params::{bad, bool_, req_vec3, str_};
use crate::schema::notes::{NoteAddParams, NoteDeleteParams, NoteDeleted, NoteId, NoteItem, NoteListParams, NoteUpdateParams, NotesList};
use crate::schema::{TypedContract, output_schema_for};
use crate::{Result, Session};
const ADD: TypedContract<NoteAddParams> = TypedContract::new();
const UPDATE: TypedContract<NoteUpdateParams> = TypedContract::new();
const DELETE: TypedContract<NoteDeleteParams> = TypedContract::new();
const LIST: TypedContract<NoteListParams> = TypedContract::new();
/// Notes a design may hold.
const MAX_NOTES: usize = 1000;
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("note.add", "Add Note", crate::checked!("note.add", note_add))
.icon("text")
.contract(&ADD)
.output(output_schema_for::<NoteId>)
.params("anchor: [x,y,z] (the point in model space, mm); text: the note (may span lines); hidden?: bool (saved but not drawn) → note: id"),
CommandSpec::new("note.update", "Edit Note", crate::checked!("note.update", note_update))
.icon("edit")
.contract(&UPDATE)
.output(output_schema_for::<NoteId>)
.params("note: id; text?: the new text; anchor?: [x,y,z] (move the anchor); hidden?: bool (show or hide) → note: id"),
CommandSpec::new("note.delete", "Delete Note", crate::checked!("note.delete", note_delete))
.icon("delete")
.contract(&DELETE)
.output(output_schema_for::<NoteDeleted>)
.params("note: id → deleted: id"),
CommandSpec::new("note.list", "List Notes", crate::checked!("note.list", note_list))
.icon("list")
.contract(&LIST)
.output(output_schema_for::<NotesList>)
.noundo()
.doc_only()
.params("→ notes: [{id, anchor: [x,y,z], text, hidden}] in creation order"),
];
fn note_add(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "note.add";
let anchor = req_vec3(CMD, p, "anchor")?;
let text = str_(p, "text").ok_or_else(|| bad(CMD, "`text` is required"))?;
if !Note::text_is_valid(text) {
return Err(bad(CMD, format!("`text` must not be empty and must be at most {} characters", solidcraft_doc::notes::MAX_NOTE_CHARS)));
}
if s.doc.notes.len() >= MAX_NOTES {
return Err(bad(CMD, "too many notes"));
}
let id = s.doc_mut().allocate_id()?;
let hidden = bool_(p, "hidden").unwrap_or(false);
s.doc_mut().notes.push(Note { id, anchor, text: text.to_string(), hidden });
Ok(json!({ "note": id }))
}
/// The index of a note by id.
fn note_index(s: &Session, p: &Value, cmd: &str) -> Result<usize> {
let id = p.get("note").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`note` must be a note id"))?;
s.doc.notes.iter().position(|n| n.id == id).ok_or_else(|| bad(cmd, format!("no note {id}")))
}
fn note_update(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "note.update";
let i = note_index(s, p, CMD)?;
let text = match str_(p, "text") {
Some(t) if Note::text_is_valid(t) => Some(t.to_string()),
Some(_) => {
return Err(bad(CMD, format!("`text` must not be empty and must be at most {} characters", solidcraft_doc::notes::MAX_NOTE_CHARS)));
}
None => None,
};
let anchor = match p.get("anchor") {
Some(_) => Some(req_vec3(CMD, p, "anchor")?),
None => None,
};
let hidden = bool_(p, "hidden");
if text.is_none() && anchor.is_none() && hidden.is_none() {
return Err(bad(CMD, "give at least one of `text`, `anchor`, `hidden`"));
}
let id = {
let n = &mut s.doc_mut().notes[i];
if let Some(t) = text {
n.text = t;
}
if let Some(a) = anchor {
n.anchor = a;
}
if let Some(h) = hidden {
n.hidden = h;
}
n.id
};
Ok(json!({ "note": id }))
}
fn note_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = note_index(s, p, "note.delete")?;
let n = s.doc_mut().notes.remove(i);
Ok(json!({ "deleted": n.id }))
}
fn note_list(s: &mut Session, _p: &Value) -> Result<Value> {
let notes: Vec<NoteItem> = s
.doc
.notes
.iter()
.map(|n| NoteItem { id: n.id, anchor: [n.anchor.x, n.anchor.y, n.anchor.z], text: n.text.clone(), hidden: n.hidden })
.collect();
Ok(json!({ "notes": notes }))
}
#[cfg(test)]
#[path = "notes_tests.rs"]
mod tests;
-87
View File
@@ -1,87 +0,0 @@
//! Note commands (#1631): add, edit, delete, list, undo, and persistence.
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, params: Value) -> Value {
s.execute(id, &params).unwrap_or_else(|e| panic!("{id} {params}: {e}"))
}
#[test]
fn add_update_delete_and_list_round_trip() {
let mut s = Session::default();
let added = run(&mut s, "note.add", json!({"anchor": [10.0, 20.0, 30.0], "text": "Check clearance here."}));
let id = added["note"].as_u64().unwrap();
assert!(id > 0);
let listed = run(&mut s, "note.list", json!({}));
let notes = listed["notes"].as_array().unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0]["id"], json!(id));
assert_eq!(notes[0]["text"], "Check clearance here.");
assert_eq!(notes[0]["anchor"], json!([10.0, 20.0, 30.0]));
assert_eq!(notes[0]["hidden"], json!(false));
run(&mut s, "note.update", json!({"note": id, "text": "Adjust this diameter after the test print.", "hidden": true}));
let listed = run(&mut s, "note.list", json!({}));
assert_eq!(listed["notes"][0]["text"], "Adjust this diameter after the test print.");
assert_eq!(listed["notes"][0]["hidden"], json!(true));
run(&mut s, "note.update", json!({"note": id, "anchor": [1.0, 2.0, 3.0]}));
assert_eq!(run(&mut s, "note.list", json!({}))["notes"][0]["anchor"], json!([1.0, 2.0, 3.0]));
assert_eq!(run(&mut s, "note.delete", json!({"note": id})), json!({"deleted": id}));
assert!(run(&mut s, "note.list", json!({}))["notes"].as_array().unwrap().is_empty());
}
#[test]
fn notes_survive_a_save_and_reopen() {
let mut s = Session::default();
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 5.0], "text": "Extra thickness added for strength."}));
let saved = serde_json::to_string(&*s.doc).unwrap();
let reopened = Session::new(serde_json::from_str(&saved).unwrap());
assert_eq!(reopened.doc.notes.len(), 1);
assert_eq!(reopened.doc.notes[0].text, "Extra thickness added for strength.");
}
#[test]
fn undo_undoes_note_changes() {
let mut s = Session::default();
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "first"}));
run(&mut s, "note.add", json!({"anchor": [1.0, 0.0, 0.0], "text": "second"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.notes.len(), 1);
assert_eq!(s.doc.notes[0].text, "first");
let id = s.doc.notes[0].id;
run(&mut s, "note.update", json!({"note": id, "text": "renamed"}));
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.notes[0].text, "first");
}
#[test]
fn ids_are_never_reused() {
let mut s = Session::default();
let a = run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "a"}))["note"].as_u64().unwrap();
run(&mut s, "note.delete", json!({"note": a}));
let b = run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "b"}))["note"].as_u64().unwrap();
assert_ne!(a, b);
}
#[test]
fn bad_calls_are_refused() {
let mut s = Session::default();
// Empty or missing text.
assert!(s.execute("note.add", &json!({"anchor": [0.0, 0.0, 0.0], "text": " "})).is_err());
assert!(s.execute("note.add", &json!({"anchor": [0.0, 0.0, 0.0]})).is_err());
// A non-finite anchor.
assert!(s.execute("note.add", &json!({"anchor": [f64::NAN, 0.0, 0.0], "text": "x"})).is_err());
// Unknown note id.
assert!(s.execute("note.update", &json!({"note": 999, "text": "x"})).is_err());
assert!(s.execute("note.delete", &json!({"note": 999})).is_err());
// An update that changes nothing.
run(&mut s, "note.add", json!({"anchor": [0.0, 0.0, 0.0], "text": "x"}));
let id = s.doc.notes[0].id;
assert!(s.execute("note.update", &json!({"note": id})).is_err());
assert_eq!(s.doc.notes[0].text, "x");
}
+2 -16
View File
@@ -4,12 +4,12 @@
use serde_json::{Value, json};
use solidcore_record::Vec2;
use solidcraft_geom::prelude::*;
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle, TextVerticalAlign};
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Shape, Sketch, TextAlign, TextExprs, TextStyle};
/// The style parameters of `sketch.text` and `sketch.edit_text`.
macro_rules! text_style_doc {
() => {
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), vertical_align?: baseline|top|middle|bottom (the block of lines against `at` or the path: the first line's baseline (default), its capitals' tops, halfway between those and the last line's baseline, or the bottom of the last line's descenders), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
"bold?, italic?: bool (an installed family's bold or italic face; italic without one is slanted), align?: left|center|right (the lines against `at`, or along the path), letter_spacing?: extra mm between letters, line_spacing?: times the font's line distance (default 1), path?: a curve id of the sketch the baseline follows (\"\": none; `at` and `angle` then do not place the text; the curve becomes construction, a guide the letters stand on), reverse?: bool (the other way along the path)"
};
}
@@ -246,20 +246,6 @@ fn text_style(p: &Value, cmd: &str, old: TextStyle) -> Result<TextStyle> {
}
Some(_) => return Err(bad(cmd, "`align` must be left, center or right")),
}
const VERTICAL_MUST: &str = "`vertical_align` must be baseline, top, middle or bottom";
match p.get("vertical_align") {
None | Some(Value::Null) => {}
Some(Value::String(a)) => {
style.vertical_align = match a.trim().to_ascii_lowercase().as_str() {
"baseline" => TextVerticalAlign::Baseline,
"top" => TextVerticalAlign::Top,
"middle" | "center" | "centre" => TextVerticalAlign::Middle,
"bottom" => TextVerticalAlign::Bottom,
_ => return Err(bad(cmd, VERTICAL_MUST)),
}
}
Some(_) => return Err(bad(cmd, VERTICAL_MUST)),
}
if p.get("letter_spacing").is_some_and(|v| !v.is_null()) {
style.letter_spacing =
num(p, "letter_spacing").filter(|v| v.is_finite()).ok_or_else(|| bad(cmd, "`letter_spacing` must be a length in mm"))?;
@@ -226,8 +226,6 @@ fn text_style_parameters_are_checked() {
(json!({"link":link,"bold":true}), "no bold face"),
(json!({"link":link,"bold":"yes"}), "`bold` must be true or false"),
(json!({"link":link,"align":"justify"}), "`align` must be left, center or right"),
(json!({"link":link,"vertical_align":"above"}), "`vertical_align` must be baseline, top, middle or bottom"),
(json!({"link":link,"vertical_align":1}), "`vertical_align` must be baseline, top, middle or bottom"),
(json!({"link":link,"line_spacing":0}), "`line_spacing` must be a positive factor"),
(json!({"link":link,"letter_spacing":"wide"}), "`letter_spacing` must be a length"),
(json!({"link":link,"path":"no-such-curve"}), "not a curve of this sketch"),
@@ -237,19 +235,14 @@ fn text_style_parameters_are_checked() {
assert!(e.contains(error), "{params}: {e}");
assert_eq!(s.doc.to_json(), before);
}
run(
&mut s,
"sketch.edit_text",
json!({"link":link,"italic":true,"align":"right","vertical_align":"middle","letter_spacing":1.5,"line_spacing":1.2}),
);
run(&mut s, "sketch.edit_text", json!({"link":link,"italic":true,"align":"right","letter_spacing":1.5,"line_spacing":1.2}));
let LinkSource::Text { style, text, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert_eq!(text, "II");
assert!(style.italic && style.align == solidcraft_sketch::TextAlign::Right && style.letter_spacing == 1.5 && style.line_spacing == 1.2);
assert_eq!(style.vertical_align, solidcraft_sketch::TextVerticalAlign::Middle);
// Left out, a style stays.
run(&mut s, "sketch.edit_text", json!({"link":link,"text":"I"}));
let LinkSource::Text { style, .. } = &s.doc.sketch(1).unwrap().link(&link).unwrap().source else { panic!() };
assert!(style.italic && style.letter_spacing == 1.5 && style.vertical_align == solidcraft_sketch::TextVerticalAlign::Middle);
assert!(style.italic && style.letter_spacing == 1.5);
}
/// `base` with the keys of `extra` set (a null `extra` value is sent as null).
+2 -22
View File
@@ -26,7 +26,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current Vi
.noundo()
.contract(&CONTRACT)
.output(output_schema_for::<ViewDxfResult>)
.params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); zero?: target|origin (where the drawing's 0,0 is: the camera target, default, or the model origin as seen from the camera); path?: .dxf file (else returns dxf text). Visible placed bodies and shown sketches' profile curves (no construction geometry, points, dimensions or constraints); 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers (sketch curves on SKETCH), mm; finished sketches are hidden behind bodies, the active sketch is drawn on top; perspective projected onto the camera target plane; no viewport crop or overlays.")];
.params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); path?: .dxf file (else returns dxf text). Visible placed bodies and shown sketches' profile curves (no construction geometry, points, dimensions or constraints); 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers (sketch curves on SKETCH), mm; finished sketches are hidden behind bodies, the active sketch is drawn on top; perspective projected onto the camera target plane; no viewport crop or overlays.")];
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cam: Camera =
@@ -48,12 +48,6 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if !sketch::exportable(s) {
return Err(bad(CMD, "no visible bodies or sketches to export"));
}
let at_origin = match p.get("zero") {
None => false,
Some(Value::String(v)) if v == "target" => false,
Some(Value::String(v)) if v == "origin" => true,
_ => return Err(bad(CMD, "zero must be target or origin")),
};
let tolerance = match p.get("tolerance") {
None => 0.01,
Some(v) => v.as_f64().filter(|t| (0.0001..=1.0).contains(t)).ok_or_else(|| bad(CMD, "tolerance must be 0.0001..1 mm"))?,
@@ -70,21 +64,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let meshes = mesh_lines::prepare(s, CMD, tolerance, |b| s.body_visible(&b.name))?;
let prepare_ms = started.elapsed().as_secs_f64() * 1000.;
let sketch = sketch::parts(s, tolerance).map_err(|e| bad(CMD, e))?;
let view = View::from_camera(&cam);
let mut drawing = mesh_lines::drawing(&meshes, &sketch, &view).map_err(|e| bad(CMD, e))?;
if at_origin {
// The drawing is centred on the camera target; move it so the model origin is 0,0.
let zero = view.world_origin().map_err(|e| bad(CMD, e))?;
for l in &mut drawing.lines {
l.a -= zero;
l.b -= zero;
}
for path in &mut drawing.sketch {
for pt in &mut path.points {
*pt -= zero;
}
}
}
let drawing = mesh_lines::drawing(&meshes, &sketch, &View::from_camera(&cam)).map_err(|e| bad(CMD, e))?;
let writing = Stage::new("dxf_write", 1);
writing.check().map_err(|e| bad(CMD, e))?;
let mut visible = 0usize;
-33
View File
@@ -252,36 +252,3 @@ fn view_dxf_hides_finished_sketches_behind_bodies_but_draws_the_active_sketch_on
assert_eq!(dashed["sketch"], 2);
assert!((length(&dashed) - 80.).abs() < 1e-3, "{}", length(&dashed));
}
#[test]
fn view_dxf_zero_origin_puts_the_model_origin_at_the_drawing_zero() {
let mut s = sample();
let bounds = |v: &Value| {
let (mut lo, mut hi) = (Vec2::new(f64::MAX, f64::MAX), Vec2::new(f64::MIN, f64::MIN));
for g in solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap() {
let solidcraft_io::Geom2::Line(a, b) = g else { panic!("{g:?}") };
for p in [a, b] {
(lo, hi) = (Vec2::new(lo.x.min(p.x), lo.y.min(p.y)), Vec2::new(hi.x.max(p.x), hi.y.max(p.y)));
}
}
(lo, hi)
};
let near = |a: Vec2, x: f64, y: f64| (a.x - x).abs() < 1e-3 && (a.y - y).abs() < 1e-3;
// By default the drawing is centred on the camera target (20, 15), whatever the model origin.
let (lo, hi) = bounds(&export(&mut s, camera(StandardView::Top), "omit"));
assert!(near(lo, -20., -15.) && near(hi, 20., 15.), "{lo:?} {hi:?}");
// `zero: origin` is the same drawing with the model origin, the plate's corner, at 0,0.
for zero in [None, Some("target")] {
let mut p = json!({"camera": camera(StandardView::Top)});
if let Some(z) = zero {
p["zero"] = json!(z);
}
let (lo, _) = bounds(&run(&mut s, CMD, p));
assert!(near(lo, -20., -15.), "{zero:?}");
}
let v = run(&mut s, CMD, json!({"camera": camera(StandardView::Top), "zero": "origin"}));
let (lo, hi) = bounds(&v);
assert!(near(lo, 0., 0.) && near(hi, 40., 30.), "{lo:?} {hi:?}");
let err = s.execute(CMD, &json!({"camera": camera(StandardView::Top), "zero": "corner"})).unwrap_err().to_string();
assert!(err.contains("zero must be target or origin"), "{err}");
}
@@ -318,35 +318,3 @@ fn an_editor_accepts_the_placements_rolling_the_marker_solves() {
assert_eq!(session.doc.marker, None);
assert_eq!(occurrence(&session, "B").transform, moved);
}
/// Cancel on a computing OK (solidcore#3247): a dialog's computation of a long Combine Cut (a
/// coil cut from a cylinder, as in #3962) stops within 500 ms of Cancel and says it was
/// cancelled. Nothing it computed is kept in the evaluation cache the session shares: OK again
/// computes the cut anew, and finishes.
#[test]
fn a_cancelled_computation_stops_promptly_and_is_never_cached() {
use solidcraft_doc::progress::{Progress, watch};
use std::time::{Duration, Instant};
let mut session = Session::default();
session.execute("solid.cylinder", &json!({"radius": 20, "height": 120, "body_name": "Stock"})).unwrap();
let coil = json!({"diameter": 51, "pitch": 15.24, "turns": 6, "section": "square", "section_size": 13, "body_name": "Tool", "operation": "new", "base": [0, 0, 12]});
session.execute("solid.coil", &coil).unwrap();
let cut = vec![("solid.combine".to_string(), json!({"operation": "cut", "target": "Stock", "tools": ["Tool"]}))];
let progress = Arc::new(Progress::default());
let (watching, scratch, commands) = (progress.clone(), session.scratch(), cut.clone());
let worker = std::thread::spawn(move || {
let r = scratch.stop_token().within(|| watch(watching, || scratch.compute_commands(&commands, None)));
(r.map(|_| ()).map_err(|e| e.to_string()), Instant::now())
});
std::thread::sleep(Duration::from_millis(300));
let cancelled = Instant::now();
progress.cancel();
let (r, returned) = worker.join().unwrap();
let took = returned.saturating_duration_since(cancelled);
assert!(r.as_ref().is_err_and(|e| e.contains("cancelled")), "{r:?}");
assert!(took < Duration::from_millis(500), "returned {took:?} after Cancel");
let again = session.scratch().compute_commands(&cut, None).unwrap();
// The cut is the one feature computed anew (the session's counter carries over to its copies).
assert_eq!(again.computed.model.recomputed, session.model.recomputed + 1, "nothing of the cancelled run was cached");
assert!(again.computed.model.results.iter().all(|r| r.error.is_none()), "OK again finishes the cut");
}
-79
View File
@@ -998,82 +998,3 @@ fn feature_named_like_a_datum_key_stays_reachable() {
assert_eq!(datum.item, solidcraft_doc::construct::DatumItem::Y);
assert_eq!(datum.path, vec![child]);
}
/// Plane, Axis and Point build with the first method of their family that takes the references:
/// the feature is that method's own, and the reply names it.
#[test]
fn plane_axis_and_point_build_with_the_first_method_that_takes_the_references() {
let mut s = part();
let top = json!({"face": [20, 15, 20]});
let x_edge = json!({"edge": [20, 0, 0]});
let corner = json!({"vertex": [40, 30, 20]});
let cases = [
("construct.plane", json!({"refs": [top], "with": {"offset": 5, "name": "Lid"}}), "construct.plane.offset"),
("construct.plane", json!({"refs": [corner, {"plane": "XY"}], "with": {"name": "Corner"}}), "construct.plane.offset"),
("construct.plane", json!({"refs": [{"plane": "XY"}, top], "with": {"name": "Mid"}}), "construct.plane.midplane"),
(
"construct.plane",
json!({"refs": [{"plane": "XY"}, {"axis": "X"}], "with": {"angle": "90 deg", "name": "Turned"}}),
"construct.plane.at_angle",
),
(
"construct.plane",
json!({"refs": [{"point": [0, 0, 7]}, {"point": [1, 0, 7]}, {"point": [0, 1, 7]}], "with": {"name": "Three"}}),
"construct.plane.three_points",
),
("construct.axis", json!({"refs": [{"face": [65, 0, 5]}], "with": {"name": "Bore"}}), "construct.axis.through_cylinder"),
("construct.axis", json!({"refs": [{"plane": "XZ"}, {"plane": "YZ"}], "with": {"name": "Meet"}}), "construct.axis.two_planes"),
("construct.point", json!({"refs": [corner], "with": {"name": "Tip"}}), "construct.point.vertex"),
(
"construct.point",
json!({"refs": [{"plane": "XY"}, {"plane": "XZ"}, {"plane": "YZ"}], "with": {"name": "Zero"}}),
"construct.point.three_planes",
),
("construct.point", json!({"refs": [x_edge], "with": {"t": 0.5, "name": "Half"}}), "construct.point.along_path"),
];
for (id, p, method) in cases {
let features = s.doc.features.len();
assert_eq!(run(&mut s, id, p.clone())["method"], method, "{id} {p}");
assert_eq!(s.doc.features.len(), features + 1, "{id} {p}: one feature, the method's own");
}
assert!(s.model.results.iter().all(|r| r.error.is_none()), "{:?}", s.model.results);
let lid = plane(&s, "Lid");
assert!(parallel(lid.normal(), Vec3::Z) && on_plane(&lid, Vec3::new(1.0, 1.0, 25.0)), "{lid:?}");
let corner_plane = plane(&s, "Corner");
assert!(on_plane(&corner_plane, Vec3::new(1.0, 1.0, 20.0)), "{corner_plane:?}");
let mid = plane(&s, "Mid");
assert!(on_plane(&mid, Vec3::new(1.0, 1.0, 10.0)), "{mid:?}");
let turned = plane(&s, "Turned");
assert!(parallel(turned.normal(), Vec3::Y), "{turned:?}");
let three = plane(&s, "Three");
assert!(on_plane(&three, Vec3::new(5.0, 5.0, 7.0)), "{three:?}");
let (o, d) = axis(&s, "Bore");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::new(60.0, 0.0, 3.0)), "{o:?} {d:?}");
let (o, d) = axis(&s, "Meet");
assert!(parallel(d, Vec3::Z) && on_line(o, d, Vec3::ZERO), "{o:?} {d:?}");
assert!(near(point(&s, "Tip"), Vec3::new(40.0, 30.0, 20.0)));
assert!(near(point(&s, "Zero"), Vec3::ZERO));
assert!(near(point(&s, "Half"), Vec3::new(20.0, 0.0, 0.0)), "{:?}", point(&s, "Half"));
// One undo step takes the feature away.
let features = s.doc.features.len();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.features.len(), features - 1);
}
/// References no method builds from, and a `with` key no method takes, are refused and leave the
/// design as it was.
#[test]
fn plane_axis_and_point_refuse_references_no_method_takes() {
let mut s = part();
let before = (*s.doc).clone();
for (id, p) in [
("construct.plane", json!({"refs": [{"point": [0, 0, 0]}, {"point": [1, 0, 0]}]})),
("construct.plane", json!({"refs": [{"plane": "XY"}, {"plane": "XZ"}], "with": {"bogus": 1}})),
("construct.axis", json!({"refs": [{"vertex": [40, 30, 20]}]})),
("construct.point", json!({"refs": [{"plane": "XY"}]})),
("construct.plane", json!({"refs": []})),
] {
assert!(s.execute(id, &p).is_err(), "{id} {p}");
assert_eq!(*s.doc, before, "{id} {p}: a refusal changes nothing");
}
}
-1
View File
@@ -86,7 +86,6 @@ fn classify(e: &EngineError) -> Outcome {
| EngineError::BadParams { .. }
| EngineError::Disabled(..)
| EngineError::Refused { .. }
| EngineError::Cancelled(_)
| EngineError::UnknownCommand(_) => Outcome::new("rejected", msg),
// A failed boolean is a failure even when it says "not supported": those are the cases
// to fix. Features the kernel doesn't do yet are limitations.
-15
View File
@@ -88,10 +88,6 @@ pub enum EngineError {
/// A command panicked; the document was kept as it was (a bug: please report it).
#[error("internal error in `{0}` (the design was kept as it was): {1}")]
Internal(String, String),
/// The work running the command was cancelled ([`solidcraft_doc::progress::Progress::cancel`])
/// while it ran: nothing was applied, whatever was computed before the cancel came in.
#[error("`{0}` was cancelled; nothing was applied")]
Cancelled(String),
}
impl From<solidcraft_doc::DocError> for EngineError {
@@ -1734,15 +1730,6 @@ impl Session {
Err(EngineError::Internal(spec.id.to_string(), msg))
}
};
// Cancelled while it ran: no result, even one finished before the cancel came in, and
// the design goes back as it was (solidcore#3247). Opening, starting or recovering a
// design (which replaces the session) keeps its own Cancel: the features not yet built
// are skipped and what is built so far is shown (#10577).
let r = if solidcraft_doc::progress::cancelled() && self.replacements == replacements {
Err(EngineError::Cancelled(spec.id.to_string()))
} else {
r
};
// The kernel's internal errors come up without the command's id.
let r = r.map_err(|e| match e {
EngineError::Internal(id, msg) if id.is_empty() => {
@@ -2542,7 +2529,6 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
timeline_groups,
browser_order,
named_views,
notes,
visibility,
next_component_color_index,
} = a;
@@ -2579,7 +2565,6 @@ fn same_but_features(a: &Document, b: &Document) -> bool {
&& *timeline_groups == b.timeline_groups
&& *browser_order == b.browser_order
&& *named_views == b.named_views
&& *notes == b.notes
&& *visibility == b.visibility
&& *next_component_color_index == b.next_component_color_index
}
@@ -73,13 +73,6 @@ impl View {
Ok(projected)
}
/// Where the world origin lands in the view's right and up, so a drawing can be shifted to
/// put its zero there.
pub(crate) fn world_origin(&self) -> Result<Vec2, &'static str> {
let p = self.project(Vec3::new(0., 0., 0.))?;
Ok(Vec2::new(p.x, p.y))
}
/// How far behind a surface a line must be to count as covered, in the depth `project` gives.
pub(super) fn depth_eps(&self, eps: f64) -> f64 {
self.perspective.map_or(eps, |distance| 1e-8 / distance)
+1 -26
View File
@@ -6,7 +6,7 @@
//! is refused when the feature is evaluated, which fails the command.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use super::types::{AngleExpr, ConstructionCommon, GeoRefParam, NumberExpr, PlaneCommon, Point3, feature_common, present};
@@ -254,31 +254,6 @@ pub struct PlanePerpendicularParams {
pub common: PlaneCommon,
}
/// Add a construction plane, axis or point (`construct.plane`, `construct.axis`,
/// `construct.point`) with the first of the family's methods (`construct.plane.*`…, in the order
/// the commands are listed) that takes the references in its reference parameters, with `with`.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ConstructAutoParams {
/// What to build it from, one to three references in any order.
#[schemars(length(min = 1, max = 3))]
pub refs: Vec<GeoRefParam>,
/// The method's other parameters (`offset`, `angle`, `t`, `name`…): only a method that takes
/// them all is used.
#[serde(default)]
pub with: Option<serde_json::Map<String, serde_json::Value>>,
}
/// What `construct.plane`, `construct.axis` and `construct.point` return: the feature, and the
/// method they chose.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ConstructAutoResult {
/// The id of the CONSTRUCT command whose method was used (`construct.plane.midplane`…).
pub method: String,
#[serde(flatten)]
pub feature: super::features::FeatureResult,
}
#[cfg(test)]
#[path = "construct_tests.rs"]
mod tests;
+1 -92
View File
@@ -8,7 +8,7 @@ use serde::{Deserialize, Deserializer, Serialize};
use serde_json::{Map, Value, json};
use solidcore_record::Vec3;
use solidcraft_doc::joints::cam::MAX_PROFILE_EDGES;
use solidcraft_doc::joints::{Constraint, Coupling, JointExprs, JointKind, Snap};
use solidcraft_doc::joints::{Coupling, JointExprs, JointKind, Snap};
use solidcraft_geom::Vec3Geom;
use super::check::whole;
@@ -559,88 +559,6 @@ impl<'de> Deserialize<'de> for CreateJointParams {
}
}
/// An assembly constraint's type, as `assembly.constraint.create` takes it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ConstraintTypeParam(pub Constraint);
const CONSTRAINT_MESSAGE: &str = "`type` must be mate, flush, distance, concentric, insert, parallel, perpendicular, angle, tangent or point_on_axis";
impl ConstraintTypeParam {
fn read(v: &Value) -> Result<Self, String> {
v.as_str().and_then(Constraint::parse).map(ConstraintTypeParam).ok_or_else(|| CONSTRAINT_MESSAGE.to_string())
}
}
impl JsonSchema for ConstraintTypeParam {
fn schema_name() -> Cow<'static, str> {
"ConstraintType".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "The rule: mate (two planes against each other), flush (two planes facing the same way), distance (a plane or a point `offset` from a plane), concentric (two round faces on one axis), insert (two round edges at one centre: only a turn is left), parallel, perpendicular, angle (two planes or axes), tangent (a cylinder resting on a plane) or point_on_axis. Case, spaces, `_` and `-` are ignored; coincident and axis are also taken.",
"type": "string",
"minLength": 1
})
}
}
/// Constrain two parts by a geometric rule between a reference on each: the rule is kept as a
/// joint of the kind it needs (a mate is planar), made where the parts are, so b moves no further
/// than the rule needs and keeps every motion it leaves free. Several constraints between one pair
/// add up. One undo step.
#[derive(Debug, JsonSchema)]
#[schemars(deny_unknown_fields, transform = create_required)]
pub struct CreateConstraintParams {
/// The rule.
#[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
#[schemars(with = "ConstraintTypeParam")]
pub kind: Option<ConstraintTypeParam>,
/// Side a's reference: a joint origin, as joint.create takes it (a face for a plane, a circle
/// for an axis, a point).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointOriginArg")]
pub a: Option<JointOriginArg>,
/// Side b's reference, as `a`; b is the part that moves.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointOriginArg")]
pub b: Option<JointOriginArg>,
/// The gap from a's reference to b's, in mm or an expression (a mate's or flush's gap, a
/// distance; needed for distance). Default: 0.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointValue")]
pub offset: Option<JointValue>,
/// An angle constraint's angle, in degrees or an expression (needed for angle).
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointValue")]
pub angle: Option<JointValue>,
/// Turn b over: its reference along a's instead of the way the rule puts it. Default: a mate
/// is against, a flush along, the others as the parts face now.
#[serde(default)]
pub flip: Option<bool>,
/// The constraint's name. Default: the rule and the first free number (Mate1).
#[serde(default)]
pub name: Option<String>,
/// Leave out `dof` and `fixed` in the reply. Default: false.
#[serde(default)]
pub quiet: Option<bool>,
}
impl<'de> Deserialize<'de> for CreateConstraintParams {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let m = keys(d)?;
Ok(CreateConstraintParams {
kind: given(&m, "type", ConstraintTypeParam::read)?,
a: given(&m, "a", JointOriginArg::read)?,
b: given(&m, "b", JointOriginArg::read)?,
offset: given(&m, "offset", JointValue::read)?,
angle: given(&m, "angle", JointValue::read)?,
flip: flag(&m, "flip")?,
name: text(&m, "name")?,
quiet: flag(&m, "quiet")?,
})
}
}
/// Add a joint between two occurrences that stay where they are: its frame is a joint origin
/// (`at`, default b's origin), and the joint starts with the origins coinciding as they are. An
/// origin on a or b is kept on its face or hole there, so the joint follows it as the design
@@ -1116,11 +1034,6 @@ pub struct ListedJoint {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointExpressions")]
pub expressions: Option<JointExprs>,
/// The assembly constraint the joint was made as (mate, flush…). Left out for a joint made
/// as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<String>")]
pub constraint: Option<Constraint>,
}
/// A named joint origin.
@@ -1405,10 +1318,6 @@ pub struct InspectedJoint {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "JointExpressions")]
pub expressions: Option<JointExprs>,
/// The assembly constraint it was made as (mate, flush…). Left out for a joint made as a joint.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(with = "Option<String>")]
pub constraint: Option<Constraint>,
}
/// What `joint.inspect` returns.
+4 -39
View File
@@ -3,10 +3,10 @@ use solidcore_record::Vec3;
use solidcraft_doc::joints::JointKind;
use super::{
AsBuiltJointParams, CamParams, CamResult, CouplingResult, CreateConstraintParams, CreateJointParams, DeleteJointParams, DeleteJointResult,
DriveJointParams, DriveJointResult, EditJointParams, GearParams, InspectJointParams, InspectJointResult, JointAddedResult, JointKindName,
JointLimitsParams, ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, RackPinionParams,
ReachParams, ReachResult, RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
AsBuiltJointParams, CamParams, CamResult, CouplingResult, CreateJointParams, DeleteJointParams, DeleteJointResult, DriveJointParams,
DriveJointResult, EditJointParams, GearParams, InspectJointParams, InspectJointResult, JointAddedResult, JointKindName, JointLimitsParams,
ListJointsParams, ListJointsResult, MakeJointOriginParams, MakeJointOriginResult, MotionLinkParams, RackPinionParams, ReachParams, ReachResult,
RigidGroupParams, RigidGroupResult, ScrewParams, SolveJointsParams, SolveResult,
};
use crate::Session;
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
@@ -19,7 +19,6 @@ fn schemas_and_the_key_check_agree() {
input_schema_for::<CreateJointParams>(),
&["a", "angle", "b", "flip", "limits", "name", "offset", "quiet", "type", "value", "values"],
);
assert_keys(input_schema_for::<CreateConstraintParams>(), &["a", "angle", "b", "flip", "name", "offset", "quiet", "type"]);
assert_keys(input_schema_for::<AsBuiltJointParams>(), &["a", "at", "b", "limits", "name", "quiet", "type", "value", "values"]);
assert_keys(input_schema_for::<DriveJointParams>(), &["continuous", "index", "joint", "quiet", "stop_at_budget", "value", "values"]);
assert_keys(input_schema_for::<JointLimitsParams>(), &["index", "joint", "max", "min", "quiet"]);
@@ -43,7 +42,6 @@ fn schemas_and_the_key_check_agree() {
#[test]
fn the_commands_publish_their_schemas() {
for id in [
"assembly.constraint.create",
"joint.create",
"joint.as_built",
"joint.drive",
@@ -206,13 +204,6 @@ fn strict_keeps_todays_errors() {
("joint.screw", json!({"a": true, "b": 1, "pitch": 1})),
("joint.cam", json!({})),
("joint.cam", json!({"a": false, "b": 1})),
("assembly.constraint.create", json!({})),
("assembly.constraint.create", json!({"type": "weld"})),
("assembly.constraint.create", json!({"type": 5})),
("assembly.constraint.create", json!({"type": "mate"})),
("assembly.constraint.create", json!({"type": "mate", "a": 5, "b": at(0, 1.0)})),
("assembly.constraint.create", json!({"type": "mate", "a": {}, "b": at(0, 1.0)})),
("assembly.constraint.create", json!({"type": "mate", "a": at(0, 0.0), "b": at(0, 1.0), "offset": true})),
]);
}
@@ -519,18 +510,6 @@ fn the_schemas_take_what_the_handlers_read() {
input_schema_for::<InspectJointParams>(),
&[(json!({}), true), (json!({"joint": "J"}), true), (json!({"joint": 4}), true), (json!({"joint": true}), false)],
);
takes(
input_schema_for::<CreateConstraintParams>(),
&[
(json!({"type": "mate", "a": {"face": [0, 0, 0]}, "b": "Origin"}), true),
(json!({"type": "distance", "a": {"face": [0, 0, 0]}, "b": {"point": [1, 2, 3]}, "offset": "gap"}), true),
(json!({"type": "angle", "a": "A", "b": "B", "angle": 30}), true),
(json!({"a": "A", "b": "B"}), false),
(json!({"type": 5, "a": "A", "b": "B"}), false),
(json!({"type": "mate", "a": "A", "b": "B", "flip": 1}), false),
(json!({"type": "mate", "a": "A", "b": "B", "values": [1]}), false),
],
);
takes(input_schema_for::<ListJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
takes(input_schema_for::<SolveJointsParams>(), &[(json!({}), true), (json!({"quiet": false}), true), (json!({"quiet": 1}), false)]);
}
@@ -576,20 +555,6 @@ fn replies_match_the_output_schema() {
json!({"type": "rigid", "a": "Top", "b": {"occurrence": "LeafC", "face": [20, 15, 0]}, "name": "Stack", "offset": "0 mm", "angle": 0}),
);
matches(output_schema_for::<JointAddedResult>(), "joint.create", &by_origin);
// assembly.constraint.create answers as joint.create does.
let rule = run(
"assembly.constraint.create",
json!({"type": "parallel", "a": {"occurrence": "LeafA", "face": [20, 15, 3]}, "b": {"occurrence": "LeafE", "face": [20, 15, 3]}, "name": "Rule"}),
);
matches(output_schema_for::<JointAddedResult>(), "assembly.constraint.create", &rule);
// Listed and inspected with the rule it was made as.
let list = run("joint.list", json!({}));
matches(output_schema_for::<ListJointsResult>(), "joint.list", &list);
assert!(list["joints"].as_array().is_some_and(|j| j.iter().any(|j| j["constraint"] == "parallel")), "{list}");
let inspected = run("joint.inspect", json!({"joint": "Rule"}));
matches(output_schema_for::<InspectJointResult>(), "joint.inspect", &inspected);
assert_eq!(inspected["joints"][0]["constraint"], "parallel", "{inspected}");
run("joint.delete", json!({"joint": "Rule"}));
let built = run("joint.as_built", json!({"type": "slider", "a": "LeafB", "b": "LeafC", "name": "Slide", "limits": [[0, 10]], "quiet": false}));
matches(output_schema_for::<JointAddedResult>(), "joint.as_built", &built);
let at = run("joint.as_built", json!({"type": "rigid", "a": "LeafA", "b": "LeafD", "at": {"point": [0, 0, 0]}, "values": [1]}));
-1
View File
@@ -89,7 +89,6 @@ pub mod motion_analysis;
pub mod motion_play;
pub mod motion_trace;
pub mod new_design;
pub mod notes;
pub mod panels;
pub mod parameter_sweep;
pub mod parameters;
-77
View File
@@ -1,77 +0,0 @@
//! Typed contracts for the note commands (#1631).
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::types::Point3;
/// Anchor a new note to a point in model space.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteAddParams {
/// The point the note is anchored to: `[x, y, z]` (mm).
pub anchor: Point3,
/// The note's text (may span lines).
pub text: String,
/// Save the note hidden: kept with the design but not drawn.
#[serde(default)]
pub hidden: bool,
}
/// Change a note's text, anchor, or hidden flag.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteUpdateParams {
/// The note's id.
pub note: u64,
/// The new text (may span lines).
#[serde(default)]
pub text: Option<String>,
/// The new anchor point: `[x, y, z]` (mm).
#[serde(default)]
pub anchor: Option<Point3>,
/// Show or hide the note.
#[serde(default)]
pub hidden: Option<bool>,
}
/// Delete a note.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteDeleteParams {
/// The note's id.
pub note: u64,
}
/// List the design's notes (takes nothing).
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NoteListParams {}
/// The result of `note.add` and `note.update`: the note's id.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoteId {
pub note: u64,
}
/// The result of `note.delete`: the id that was removed.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoteDeleted {
pub deleted: u64,
}
/// One note as `note.list` returns it.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoteItem {
pub id: u64,
/// The anchor point `[x, y, z]` (mm).
pub anchor: [f64; 3],
pub text: String,
pub hidden: bool,
}
/// The result of `note.list`.
#[derive(Debug, Serialize, JsonSchema)]
pub struct NotesList {
pub notes: Vec<NoteItem>,
}
-26
View File
@@ -277,28 +277,6 @@ fn hidden<'de, D: Deserializer<'de>>(d: D) -> Result<Option<HiddenLines>, D::Err
read_as(d, "hidden must be omit or dashed")
}
/// Where the drawing's zero point is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DxfZero {
/// The point the camera looks at.
Target,
/// The model's origin, seen from the camera.
Origin,
}
fn zero_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "Where the drawing's zero point is: `target` is the point the camera looks at, `origin` is the model's origin as seen from the camera (for machining from a part corner). Default: target.",
"enum": ["target", "origin"],
"default": "target"
})
}
fn zero<'de, D: Deserializer<'de>>(d: D) -> Result<Option<DxfZero>, D::Error> {
read_as(d, "zero must be target or origin")
}
fn tolerance<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
read_with(d, "tolerance must be 0.0001..1 mm", |v| v.as_f64().filter(|t| (0.0001..=1.0).contains(t)))
}
@@ -351,10 +329,6 @@ pub struct ExportViewDxfParams {
#[serde(default, deserialize_with = "tolerance", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "tolerance_schema")]
pub tolerance: Option<f64>,
/// Where the drawing's zero point is: the camera target or the model's origin. Default: target.
#[serde(default, deserialize_with = "zero", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "zero_schema")]
pub zero: Option<DxfZero>,
/// The `.dxf` file to write. Default: the reply carries the DXF text instead.
#[serde(default, deserialize_with = "export_path", skip_serializing_if = "Option::is_none")]
#[schemars(schema_with = "path_schema")]
+1 -4
View File
@@ -29,7 +29,7 @@ fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
#[test]
fn schemas_and_the_key_check_agree() {
assert_keys(input_schema_for::<SilhouetteParams>(), &["bodies", "camera", "rows", "view"]);
assert_keys(input_schema_for::<ExportViewDxfParams>(), &["camera", "hidden", "path", "tolerance", "zero"]);
assert_keys(input_schema_for::<ExportViewDxfParams>(), &["camera", "hidden", "path", "tolerance"]);
}
/// Strict gives the handlers' messages for the calls they refuse for a key (a missing `camera`
@@ -209,8 +209,6 @@ fn the_schemas_take_what_the_handlers_read() {
(json!({"camera": camera(), "tolerance": 2}), false),
(json!({"camera": camera(), "tolerance": 0}), false),
(json!({"camera": camera(), "pathh": "a.dxf"}), false),
(json!({"camera": camera(), "zero": "origin"}), true),
(json!({"camera": camera(), "zero": "corner"}), false),
],
);
}
@@ -225,7 +223,6 @@ fn the_check_names_what_is_wrong_with_a_key() {
assert_eq!(issues(SIL, json!({"bodies": [1]})), ["`bodies` must contain names"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "hidden": 1})), ["hidden must be omit or dashed"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "tolerance": 3})), ["tolerance must be 0.0001..1 mm"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "zero": "corner"})), ["zero must be target or origin"]);
assert_eq!(issues(DXF, json!({"camera": camera(), "path": " "})), ["invalid path"]);
assert_eq!(issues(DXF, json!({"camera": 1})), ["invalid camera"]);
// Left out, the handler's own refusal stands in for "is required".
-37
View File
@@ -640,40 +640,3 @@ fn conversion_from_another_branch_names_the_component_to_activate() {
assert_eq!(s.doc.to_json(), before);
assert_eq!((s.active_component, s.active_occurrence_path.clone()), context);
}
/// A mesh body of a component placed twice converts under its second placement's name, the one
/// the selection and the Browser hold: the conversion edits the component, so both placements
/// then show the solid. Given as `body` and taken from the selection.
#[test]
fn a_second_placements_mesh_body_converts_by_the_name_the_world_shows() {
let dir = std::env::temp_dir().join(format!("solidcraft-mesh-convert-placed-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let stl = dir.join("cube.stl").to_string_lossy().to_string();
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10}));
run(&mut s, "file.save_mesh", json!({"path": stl}));
for by_selection in [false, true] {
let mut s = Session::default();
run(&mut s, "file.insert_mesh", json!({"path": stl}));
run(&mut s, "occurrence.copy", json!({"component": "cube", "translate": [0, 40, 0]}));
let definition = s.model.state().bodies[0].name.clone();
let world = s.world_state().unwrap();
let second = world.bodies.iter().map(|b| b.name.clone()).find(|n| *n != definition).expect("a second placement");
assert!(world.bodies.len() == 2 && world.bodies.iter().all(|b| b.body.is_mesh()), "two placed mesh bodies");
if by_selection {
s.selection = vec![Sel::Body { name: second.clone() }];
run(&mut s, "mesh.convert", json!({"tolerance": "0.05 mm"}));
} else {
run(&mut s, "mesh.convert", json!({"body": second, "tolerance": "0.05 mm"}));
}
let world = s.world_state().unwrap();
assert_eq!(world.bodies.len(), 2);
assert!(world.bodies.iter().all(|b| !b.body.is_mesh()), "by selection {by_selection}: both placements show the solid");
match &s.doc.features.last().unwrap().kind {
FeatureKind::MeshConvert { body, .. } => assert_eq!(body.to_string(), definition, "the feature names the component's body"),
other => panic!("{other:?}"),
}
}
let _ = std::fs::remove_dir_all(&dir);
}
@@ -184,7 +184,6 @@ pipe false
plane false
plane_along_path false
plane_angle false
plane_auto false
plane_axis_point false
plane_midplane false
plane_perpendicular false
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>Plane from picks</title>
<path d="M32 18L56 32L32 46L8 32Z" fill="#b9ddf8" stroke="#236ead" stroke-width="2.55" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="0.1 5"/>
</svg>

Before

Width:  |  Height:  |  Size: 265 B

+1 -1
View File
@@ -81,7 +81,7 @@ pub use solver::{
flips_from_stored, measure_dimension, move_line_towards, move_point_towards, point_along, solve, solve_after_edit, solve_after_link_move,
solve_after_value_edit, solve_holding, solve_with,
};
pub use text::{MAX_TEXT_CHARS, TextAlign, TextMetrics, TextStyle, TextVerticalAlign, text_geometry, text_geometry_in, text_geometry_styled};
pub use text::{MAX_TEXT_CHARS, TextAlign, TextMetrics, TextStyle, text_geometry, text_geometry_in, text_geometry_styled};
pub use thin::{MAX_THIN_SEGMENTS, thin_wall_regions};
pub use wire::{MAX_FIT_POINTS, MAX_WIRE_POINTS, Wire, WireArc, WireCurve, WireSpace, arc_from_3_points, arc_polyline, arc_sweep, fit_polyline};
+2 -54
View File
@@ -105,40 +105,6 @@ impl TextAlign {
}
}
/// Where a text's block of lines sits up and down against its anchor (`at`, or its path).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TextVerticalAlign {
/// The first line's baseline runs through the anchor.
#[default]
Baseline,
/// The tops of the first line's capitals are at the anchor.
Top,
/// The block is centred on it: halfway between the first line's capital tops and the last
/// line's baseline.
Middle,
/// The bottom of the last line's descenders (the tails of g, p and y) is at the anchor.
Bottom,
}
impl TextVerticalAlign {
fn is_baseline(&self) -> bool {
*self == TextVerticalAlign::Baseline
}
/// How far up (mm) every baseline moves for a block of `lines` lines at capital height
/// `height`, `pitch` apart, whose descenders reach `descent` below a baseline.
fn shift(self, lines: usize, height: f64, pitch: f64, descent: f64) -> f64 {
let below = lines.saturating_sub(1) as f64 * pitch;
match self {
TextVerticalAlign::Baseline => 0.0,
TextVerticalAlign::Top => -height,
TextVerticalAlign::Middle => (below - height) / 2.0,
TextVerticalAlign::Bottom => below + descent,
}
}
}
/// How a text is set: the style of its font, where its lines sit, the space between its letters
/// and lines, and the sketch curve its baseline follows (instead of the straight line from `at`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -149,8 +115,6 @@ pub struct TextStyle {
pub italic: bool,
#[serde(default, skip_serializing_if = "TextAlign::is_left")]
pub align: TextAlign,
#[serde(default, skip_serializing_if = "TextVerticalAlign::is_baseline")]
pub vertical_align: TextVerticalAlign,
/// Extra space between letters (mm; negative brings them closer).
#[serde(default, skip_serializing_if = "is_zero")]
pub letter_spacing: f64,
@@ -168,16 +132,7 @@ pub struct TextStyle {
impl Default for TextStyle {
fn default() -> Self {
TextStyle {
bold: false,
italic: false,
align: TextAlign::Left,
vertical_align: TextVerticalAlign::Baseline,
letter_spacing: 0.0,
line_spacing: 1.0,
path: None,
reverse: false,
}
TextStyle { bold: false, italic: false, align: TextAlign::Left, letter_spacing: 0.0, line_spacing: 1.0, path: None, reverse: false }
}
}
@@ -294,12 +249,6 @@ impl<'a> TextMetrics<'a> {
Ok(own * self.line_spacing)
}
/// How far (mm) the font's descenders reach below a baseline at a capital height of `height` mm (none when the
/// font gives no descender below it).
pub fn descent(&self, height: f64) -> Result<f64> {
Ok((-f64::from(self.face.descender())).max(0.0) * self.scale(height)?)
}
/// The width (mm) of one line at a capital height of `height` mm, as it is drawn: from its first glyph's origin to
/// the end of its last glyph's advance. A line break in it is a character like any other.
pub fn line_width(&self, line: &str, height: f64) -> Result<f64> {
@@ -431,10 +380,9 @@ pub fn text_geometry_styled(
let along = path.map(|pts| Path::new(pts, style.reverse)).transpose()?;
let anchor = along.as_ref().map(|p| p.anchor(style.align)).unwrap_or(0.0);
let (sin, cos) = angle.sin_cos();
let lift = style.vertical_align.shift(text.split('\n').count(), height, pitch, metrics.descent(height)?);
let mut out = Vec::new();
for (row, line) in text.split('\n').enumerate() {
let base_y = lift - (row as f64) * pitch;
let base_y = -(row as f64) * pitch;
let start = match style.align {
TextAlign::Left => 0.0,
TextAlign::Center => -metrics.line_width(line, height)? / 2.0,
+1 -32
View File
@@ -1,4 +1,4 @@
//! Text styles: alignment along and up or down, letter and line spacing, a made-up italic, a bold the font lacks, and
//! Text styles: alignment, letter and line spacing, a made-up italic, a bold the font lacks, and
//! text that follows a curve of its sketch; and text measured (`TextMetrics`) by the rule that draws it.
use solidcore_record::Vec2;
@@ -58,35 +58,6 @@ fn lines_align_left_center_or_right_on_the_anchor() {
assert!((left.0.y - center.0.y).abs() < 1e-12, "alignment moves lines along, not up");
}
#[test]
fn the_block_of_lines_sits_on_its_baseline_top_middle_or_bottom() {
let at = |text: &str, vertical_align: TextVerticalAlign| extent(&styled(text, &TextStyle { vertical_align, ..TextStyle::default() }, None));
let near = |a: f64, b: f64| (a - b).abs() < 0.05;
// An H stands on the baseline and reaches the capital height, 10 mm.
let plain = at("H", TextVerticalAlign::Baseline);
assert!(near(plain.0.y, 0.0) && near(plain.1.y, 10.0), "{plain:?}");
let top = at("H", TextVerticalAlign::Top);
assert!(near(top.1.y, 0.0) && near(top.0.y, -10.0), "the capitals hang from the anchor: {top:?}");
// Two lines: the block runs from the first line's capital tops to the last line's baseline.
let two = at("H\nH", TextVerticalAlign::Baseline);
let middle = at("H\nH", TextVerticalAlign::Middle);
assert!(near(middle.0.y + middle.1.y, 0.0), "centred on the anchor: {middle:?}");
assert!(near(middle.1.y - middle.0.y, two.1.y - two.0.y), "the block keeps its size: {middle:?}");
// Bottom leaves room for the tails of g, p and y: the last line's g reaches down to the anchor
// (the font's descender is a little deeper than its g), and its H stands well above it.
let tail = at("H\nHg", TextVerticalAlign::Bottom);
assert!(tail.0.y > -1e-9 && tail.0.y < 0.3, "the tail ends at the anchor: {tail:?}");
let bottom = at("H\nH", TextVerticalAlign::Bottom);
assert!(bottom.0.y > 1.0 && bottom.0.y > tail.0.y, "the last line stands above the anchor: {bottom:?}");
assert!(near(bottom.1.y - bottom.0.y, two.1.y - two.0.y), "the block keeps its size: {bottom:?}");
assert!((plain.0.x - top.0.x).abs() < 1e-12, "vertical alignment moves lines up or down, not along");
// On a path the lines move off the curve the same way: centred, an H straddles it.
let line = [v(-20.0, 0.0), v(20.0, 0.0)];
let on =
extent(&styled("H", &TextStyle { path: Some("l".into()), vertical_align: TextVerticalAlign::Middle, ..TextStyle::default() }, Some(&line)));
assert!(near(on.0.y, -5.0) && near(on.1.y, 5.0), "{on:?}");
}
#[test]
fn letter_spacing_adds_between_letters_and_line_spacing_scales_the_lines() {
let plain = extent(&styled("HHH", &TextStyle::default(), None));
@@ -218,7 +189,6 @@ fn a_style_is_saved_only_when_it_is_not_plain_and_reads_back() {
bold: true,
italic: true,
align: TextAlign::Right,
vertical_align: TextVerticalAlign::Bottom,
letter_spacing: -0.5,
line_spacing: 1.5,
path: Some("l3".into()),
@@ -228,7 +198,6 @@ fn a_style_is_saved_only_when_it_is_not_plain_and_reads_back() {
};
let json = serde_json::to_value(&styled).unwrap();
assert_eq!(json["style"]["align"], "right");
assert_eq!(json["style"]["vertical_align"], "bottom");
assert_eq!(serde_json::from_value::<LinkSource>(json).unwrap(), styled);
// Saved without a style (before styles): plain.
let mut old = serde_json::to_value(&plain).unwrap();
-18
View File
@@ -81,7 +81,6 @@ pub(super) fn status(e: SolidError) -> Error {
e if is_primitive_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
e if is_mass_heal_or_geom_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
e if is_sweep_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
e if is_query_failure(&e) => Error::Status(e.sm_status().unwrap_or(solidcore::error::SM_ERR)),
SolidError::InvalidInput { .. } => Error::Status(INVALID_INPUT),
SolidError::Unsupported { reason, .. } if reason == MIXED_CORNER_RAILS_CROSS => Error::Unported(MIXED_CORNER_UNSUPPORTED),
e if is_boolean_failure(&e) => boolean_status(e),
@@ -143,12 +142,6 @@ fn is_sweep_failure(e: &SolidError) -> bool {
e.sweep_failure().is_some_and(|w| w.replaces_upstream_status())
}
/// A query's failure (solidcore's `QueryFailure`): it stands for the status the query returned
/// (`Upstream` before solidcore described it), and passes on as that status.
fn is_query_failure(e: &SolidError) -> bool {
e.query_failure().is_some()
}
/// `size_ok`.
fn size_ok(x: f64) -> bool {
x.is_finite() && x > 0.0 && x <= 1e6
@@ -1506,15 +1499,4 @@ mod tests {
// The same entity twice is picked once, by its first point.
assert_eq!(pick(&all, &[[3.1, 0.0, 0.0], [2.9, 0.0, 0.0]], 0.5, 1e-6, d).unwrap(), Ok((vec![3.0], vec![0])));
}
/// A query's failure passes on as the status it replaced, as the `Upstream` it replaced did.
#[test]
fn query_failures_pass_on_as_their_status() {
use solidcore::error::Failure;
use solidcore::error::query::QueryFailure;
let query = |q| Failure::new("query::edge", q).into_error();
assert!(matches!(status(query(QueryFailure::Failed("the region has no shells"))), Error::Status(1001)));
assert!(matches!(status(query(QueryFailure::Missing("the face's surface"))), Error::Status(1006)));
assert!(matches!(status(query(QueryFailure::InvalidValue("a vector of zero length has no direction"))), Error::Status(1007)));
}
}
+1 -8
View File
@@ -1538,7 +1538,7 @@ fn browser_header(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
crate::ui_kit::disclosure(ui.painter(), pos2(right - t.form_section_icon_size / 2.0, rect.center().y), !collapsed, t.text_dim);
right -= 18.0;
}
let bodies = listed_bodies(&app.session);
let bodies = app.session.model.state().bodies.len();
let key = if bodies == 1 { "ui.browser_bodies_one" } else { "ui.browser_bodies_other" };
let count = solidcraft_ui_text::format(key, &[("count", &bodies.to_string())]);
let galley = ui.painter().layout_no_wrap(count, FontId::proportional(t.font_body), t.text_dim);
@@ -1557,13 +1557,6 @@ fn browser_header(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
}
}
/// How many bodies the Browser lists for its header: one row per placement of each body, so a
/// component's body placed twice counts twice, as the world shows it. The model's own count when
/// the design cannot be placed (the rows then still list the model's bodies).
fn listed_bodies(session: &solidcraft_engine::Session) -> usize {
session.world_state().map_or_else(|_| session.model.state().bodies.len(), |world| world.bodies.len())
}
fn prepare_model(app: &mut SolveApp) {
if app.tree.model.as_ref().is_none_or(|model| !model.matches(&app.session)) {
app.tree.model = Some(model::Model::new(&app.session));
-18
View File
@@ -2219,21 +2219,3 @@ fn parked_reveals_are_pruned_against_their_own_document_when_the_target_is_remov
semantic_frame(&mut h, &[], Vec::new(), egui::Modifiers::NONE);
assert_no_pending_reveals(&h);
}
/// A component's body placed twice is two body rows in the Browser; the header's count agrees
/// with the rows it lists instead of counting the model's bodies ("1 body" over two rows).
#[test]
fn the_browser_header_counts_one_body_per_placement() {
let (mut h, _, _) = crate::test_support::component_box();
h.app.preferences.browser_components_open = true;
h.settle();
assert_eq!(listed_bodies(&h.app.session), 1);
h.app.run("occurrence.copy", json!({"component": "Block", "translate": [0, 40, 0]})).unwrap();
h.settle();
assert_eq!(crate::open_meshing::session_world(&h.app.session).bodies.len(), 2);
// One body row under each placement's row, both labelled with the body's own name.
let rows = drawn_rows();
let body_rows = rows.iter().filter(|(label, ..)| label == "Body1").count();
assert_eq!(body_rows, 2, "a body row per placement: {rows:?}");
assert_eq!(listed_bodies(&h.app.session), body_rows, "the header counts the rows it lists");
}
-3
View File
@@ -26,13 +26,11 @@ pub const DOCUMENTED: &[&str] = &[
"config.preview",
"config.rules",
"config.table",
"construct.axis",
"construct.axis.edge",
"construct.axis.normal_to_face",
"construct.axis.through_cylinder",
"construct.axis.two_planes",
"construct.axis.two_points",
"construct.plane",
"construct.plane.along_path",
"construct.plane.at_angle",
"construct.plane.axis_point",
@@ -42,7 +40,6 @@ pub const DOCUMENTED: &[&str] = &[
"construct.plane.tangent",
"construct.plane.three_points",
"construct.plane.two_edges",
"construct.point",
"construct.point.along_path",
"construct.point.center",
"construct.point.edge_plane",
-33
View File
@@ -25,8 +25,6 @@ use crate::tool_modifiers::{Audit, Scope, ToolModifiers};
use crate::viewport::Hit;
use solidcraft_theme::Tokens;
#[path = "dialogs_auto.rs"]
mod auto;
#[path = "dialogs_dense.rs"]
mod dense;
#[cfg(test)]
@@ -266,9 +264,6 @@ crate::dialog_descriptor::dialog_descriptors! {
keys: Vec<&'static str>,
t: Option<String>,
},
/// Plane, Axis or Point (`construct.plane|axis|point`) while no method takes its picks;
/// once one does, the dialog is that method's own (`dialogs_auto`).
ConstructAuto { command: &'static str },
/// A plane perpendicular to one selected path at a fractional distance.
PathPlane {
t: String,
@@ -574,9 +569,6 @@ pub struct Dialog {
pub press_pull: bool,
/// The operation the window chooses by itself (#6901).
pub op_auto: crate::op_auto::OpAuto,
/// Plane, Axis or Point standing in for the method its picks call for: this dialog is that
/// method's, and picks go to the tool, which chooses the method again (dialogs_auto).
pub(crate) auto: Option<auto::Auto>,
/// Where the face that reopened this fillet or chamfer was clicked (Press Pull): its arrow
/// sits on the edge nearest it. UI state only, never saved.
pub(crate) anchor_hint: Option<Vec3>,
@@ -820,8 +812,6 @@ impl Dialog {
self.error = Some(error);
}
/// A dialog of `kind` with these inputs and every other field at its starting value: no picks
/// taken yet beyond the inputs' own, no error, and not standing in for Plane, Axis or Point.
pub(crate) fn new(kind: Kind, inputs: Vec<SelInput>) -> Dialog {
let chain_applied = matches!(kind, Kind::Fillet { chain: true, .. });
Dialog {
@@ -855,7 +845,6 @@ impl Dialog {
op_auto: Default::default(),
anchor_hint: None,
started_from_sketch: None,
auto: None,
}
}
@@ -900,9 +889,6 @@ impl Dialog {
}
}
/// The dialog a toolbar click on command `id` opens, filled from the current selection where
/// its inputs take it; Plane, Axis and Point open as the method their picks call for
/// (`dialogs_auto`). `None` for a command that has no dialog.
pub fn for_command(app: &SolveApp, id: &str) -> Option<Dialog> {
let s = &app.session;
let has_bodies = !crate::open_meshing::session_world(s).bodies.is_empty();
@@ -992,8 +978,6 @@ impl Dialog {
Kind::MeshConvert { tolerance: crate::length_input::model(&s.doc, 0.05) },
vec![SelInput::new("Mesh body", BODIES, false)],
),
// Plane, Axis and Point open as the method their picks call for (`dialogs_auto`).
_ if solidcraft_engine::cmd::is_construct_tool(id) => return auto::start(app, id),
"construct.plane.midplane" => Dialog::new(
Kind::Midplane,
vec![SelInput::new("First plane", PLANES | PLANAR_FACES, false), SelInput::new("Second plane", PLANES | PLANAR_FACES, false)],
@@ -1643,10 +1627,6 @@ impl Dialog {
if matches!(self.kind, Kind::SketchModify(_)) {
return crate::sketch_modify_ui::pick_candidate(self, s, h);
}
// Plane, Axis and Point take whatever a method of theirs builds from.
if self.auto.is_some() {
return auto::candidate(s, h);
}
if let Some(target) = self.extrude_target(s, h) {
return Some(target);
}
@@ -1829,13 +1809,6 @@ impl Dialog {
if self.active_input().is_some_and(|i| i.rejects(&sel)) {
return;
}
// Plane, Axis and Point: the pick is the tool's, which chooses the method again.
if let Some(a) = &mut self.auto {
a.toggle(sel);
self.error = None;
self.focus = true;
return;
}
if self.take_extrude_target(s, &sel) {
return;
}
@@ -2649,7 +2622,6 @@ fn publish_check(ui: &egui::Ui, response: &egui::Response, selected: bool) {
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, ui.is_enabled(), selected, tr(crate::ui_kit::row_label(ui))));
}
/// The dialog's heading for `k`: a message id or label the header translates.
fn title(k: &Kind) -> &'static str {
match k {
Kind::Sketch => "CREATE SKETCH",
@@ -2665,9 +2637,6 @@ fn title(k: &Kind) -> &'static str {
Kind::Midplane => "MIDPLANE",
Kind::MeshConvert { .. } => "CONVERT MESH",
Kind::Construct { title, .. } => title,
Kind::ConstructAuto { command: "construct.plane", .. } => "cmd.construct.plane",
Kind::ConstructAuto { command: "construct.axis", .. } => "cmd.construct.axis",
Kind::ConstructAuto { .. } => "cmd.construct.point",
Kind::PathPlane { .. } => "PLANE ALONG PATH",
Kind::Rib { web: false, .. } => "RIB",
Kind::Rib { web: true, .. } => "WEB",
@@ -3909,7 +3878,6 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
return;
}
crate::sketch_text::sync_path(&mut d);
auto::sync(app, &mut d);
let _language = solidcraft_ui_text::scope(&app.preferences.language);
if let Kind::TextMove(k) = &d.kind
&& !crate::sketch_text_move::valid(app, k)
@@ -5831,7 +5799,6 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
}
(*command, Value::Object(p))
}
Kind::ConstructAuto { .. } => return Err(auto::refusal(d)),
Kind::PathPlane { t, sketch } => {
need(0, "a path")?;
let path = match sels(d, 0).first() {
-166
View File
@@ -1,166 +0,0 @@
//! Plane, Axis and Point (`construct.plane`, `construct.axis`, `construct.point`) have no
//! dialog of their own. With references picked, the window is the dialog of the first method of
//! the tool's family (`construct_methods`, in the engine's order) that takes every pick in its
//! inputs and builds from them, with that method's rows, preview and canvas handles. A pick goes
//! to the tool, which chooses the method again. A method added to a family is chosen as well,
//! with its own dialog, and nothing here changes.
use super::{
AXES, AXIS_PICKS, Accept, CURVES, Dialog, EDGES, FACES, Kind, PLANES, SelInput, apply_commands, fits, planar_face, selection, takes_as_axis,
};
use crate::SolveApp;
use crate::viewport::Hit;
use solidcraft_engine::{Sel, Session};
/// Whatever a method builds from.
const PICKS: Accept = PLANES | FACES | EDGES | AXES | CURVES | selection::VERTICES | selection::POINTS | selection::CONSTRUCTION_POINTS;
/// The most references a method takes.
const MAX: usize = 3;
const REFERENCES: &str = "ui.construct_auto.refs";
/// The tool a dialog stands in for, and what was picked for it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Auto {
tool: &'static str,
/// Every reference picked, in pick order.
picks: Vec<Sel>,
/// What the dialog's inputs held when it was made: a change there (a pick taken out in its
/// row, a box selection) is a change of picks.
placed: Vec<Sel>,
/// Picked since the method was chosen.
dirty: bool,
}
impl Auto {
/// A click: a new reference is added (the oldest goes past [`MAX`]); one picked already is
/// taken out.
pub(super) fn toggle(&mut self, sel: Sel) {
if let Some(i) = self.picks.iter().position(|p| selection::same_item(p, &sel)) {
self.picks.remove(i);
} else {
self.picks.push(sel);
if self.picks.len() > MAX {
self.picks.remove(0);
}
}
self.dirty = true;
}
}
/// The line over the form: that the method was chosen from the picks, or what to pick.
pub(super) fn hint(a: &Auto) -> String {
solidcraft_ui_text::tr(if a.picks.is_empty() { "ui.construct_auto.pick" } else { "ui.construct_auto.chosen" }).to_owned()
}
/// Why OK does nothing while no method takes the picks.
pub(super) fn refusal(d: &Dialog) -> String {
let none = d.auto.as_ref().is_none_or(|a| a.picks.is_empty());
solidcraft_ui_text::tr(if none { "ui.construct_auto.select" } else { "ui.construct_auto.no_fit" }).to_owned()
}
/// What a hit picks for the tool.
pub(super) fn candidate(s: &Session, h: &Hit) -> Option<Sel> {
SelInput::new(REFERENCES, PICKS, true).accepts(h, s.active_sketch, |body, face| planar_face(s, body, face).is_some())
}
/// The tool's window, with the selection as its first picks.
pub(super) fn start(app: &SolveApp, tool: &str) -> Option<Dialog> {
let tool = solidcraft_engine::find_command(tool)?.id;
let picks: Vec<Sel> = app.session.selection.iter().filter(|sel| fits(PICKS, sel)).take(MAX).cloned().collect();
Some(choose(app, tool, picks, None))
}
/// Choose again once the picks changed: a click, or the inputs edited in the form.
pub(super) fn sync(app: &SolveApp, d: &mut Dialog) {
let Some(a) = &d.auto else { return };
let items = d.items();
if !a.dirty && items == a.placed {
return;
}
let mut picks = a.picks.clone();
if !a.dirty {
picks.retain(|p| items.iter().any(|x| selection::same_item(x, p)));
for x in items {
if !picks.iter().any(|p| selection::same_item(p, &x)) {
picks.push(x);
}
}
}
let tool = a.tool;
let was = std::mem::replace(d, Dialog::new(Kind::ConstructAuto { command: tool }, Vec::new()));
*d = choose(app, tool, picks, Some(&was));
}
/// Whether `inp` can hold `sel` too, as a pre-selection fills it.
fn takes(s: &Session, inp: &SelInput, sel: &Sel) -> bool {
let planar_ok = match sel {
Sel::Face { body, index, .. } => inp.accept & FACES != 0 || planar_face(s, body, *index).is_some(),
_ => true,
};
(inp.multi || inp.items.is_empty()) && fits(inp.accept, sel) && planar_ok && (inp.accept != AXIS_PICKS || takes_as_axis(s, sel))
}
/// Put every one of `picks` in an input of `d` that takes it, the first way `good` accepts.
fn place(s: &Session, d: &mut Dialog, picks: &[Sel], good: &dyn Fn(&Dialog) -> bool) -> bool {
let Some((sel, rest)) = picks.split_first() else { return good(d) };
for k in 0..d.inputs.len() {
if !d.inputs.get(k).is_some_and(|inp| takes(s, inp, sel)) {
continue;
}
if let Some(inp) = d.inputs.get_mut(k) {
inp.items.push(sel.clone());
}
if place(s, d, rest, good) {
return true;
}
if let Some(inp) = d.inputs.get_mut(k) {
inp.items.pop();
}
}
false
}
/// The first method's dialog, in the family's order, whose inputs take `picks` and that builds
/// from them; else the first whose inputs take them (its error says why it doesn't build); else
/// the tool's own window, waiting for picks. A method chosen again keeps its values.
fn choose(app: &SolveApp, tool: &'static str, picks: Vec<Sel>, was: Option<&Dialog>) -> Dialog {
let s = &app.session;
let mut chosen = None;
if !picks.is_empty() {
let complete = |d: &Dialog| apply_commands(app, d).is_ok();
let builds = |d: &Dialog| apply_commands(app, d).is_ok_and(|cmds| s.preview(&cmds).is_ok());
let methods = solidcraft_engine::cmd::construct_methods(tool);
'tries: for good in [&builds as &dyn Fn(&Dialog) -> bool, &complete] {
for spec in &methods {
let Some(mut d) = Dialog::for_command(app, spec.id) else { continue };
if let Some(w) = was
&& w.auto.is_some()
&& w.command() == Some(spec.id)
{
d.kind = w.kind.clone();
d.extra = w.extra.clone();
}
for inp in &mut d.inputs {
inp.items.clear();
}
if place(s, &mut d, &picks, good) {
chosen = Some(d);
break 'tries;
}
}
}
}
let mut d = chosen.unwrap_or_else(|| {
let mut refs = SelInput::new(REFERENCES, PICKS, true);
refs.items = picks.clone();
Dialog::new(Kind::ConstructAuto { command: tool }, vec![refs])
});
if let Some(w) = was {
d.collapsed = w.collapsed;
d.preview_enabled = w.preview_enabled;
}
d.advance();
d.focus = true;
d.auto = Some(Auto { tool, placed: d.items(), picks, dirty: false });
d
}
-7
View File
@@ -5,7 +5,6 @@ use crate::ui_kit;
use crate::ui_kit::{choice_row, expr_row, hint_row};
use solidcraft_engine::kernel::Continuity;
/// The command whose icon and label head a dialog of `kind`, when it has one.
pub(super) fn command(kind: &Kind) -> Option<&'static str> {
match kind {
Kind::Material { .. } => Some("material.assign"),
@@ -45,7 +44,6 @@ pub(super) fn command(kind: &Kind) -> Option<&'static str> {
Kind::Midplane => Some("construct.plane.midplane"),
Kind::MeshConvert { .. } => Some("mesh.convert"),
Kind::Construct { command, .. } => Some(command),
Kind::ConstructAuto { command, .. } => Some(command),
Kind::PathPlane { .. } => Some("construct.plane.along_path"),
Kind::Split => Some("solid.split_body"),
Kind::DeleteFaces => Some("face.delete"),
@@ -367,7 +365,6 @@ fn choose_fillet_type(extra: &mut serde_json::Map<String, Value>, index: usize,
}
}
/// The dialog's form: its hints, inputs and value rows. True when Enter was pressed in a field.
pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut egui::Ui) -> bool {
let t = Tokens::get();
let mut enter = false;
@@ -407,10 +404,6 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
if let Some(key) = manage_hint {
ui_kit::text(ui, tr(key), ui_kit::Text::Hint("manage"));
}
// Plane, Axis or Point: which method its picks chose, and that more picks choose again.
if let Some(hint) = d.auto.as_ref().map(super::auto::hint) {
ui_kit::text(ui, &hint, ui_kit::Text::Hint("auto"));
}
placement_row(d, ui, app);
if crate::hole_refs::takes(d) {
// Each reference edge with its distance (#37, #6380).
+1 -1
View File
@@ -112,7 +112,7 @@ mod tests {
}
let construction: Vec<_> = specs.iter().filter(|c| c.panel == "CONSTRUCT").collect();
let distinct: std::collections::HashSet<_> = construction.iter().map(|c| c.icon).collect();
assert_eq!(construction.len(), 23);
assert_eq!(construction.len(), 20);
assert_eq!(construction.iter().find(|c| c.id == "construct.plane.axis_point").unwrap().icon, "plane_axis_point");
assert_eq!(distinct.len(), construction.len(), "construction methods need distinct visual identities");
}
-3
View File
@@ -3487,9 +3487,6 @@ impl SolveApp {
let _blur_scope = self.blur.begin(ui.ctx(), blur_source);
value_step::set_enabled(ui.ctx(), self.preferences.step_values);
canvas::set_boxes_shown(ui.ctx(), self.preferences.value_boxes_in_view);
// #9841: dialog buttons answer their first letter only when the preference says so;
// off is the old behaviour, so the kit default and this preference default agree.
ui_kit::set_dialog_mnemonics(self.preferences.dialog_first_letter_buttons);
recent_colors::share(ui.ctx(), &mut self.ui.recent_colors);
let t0 = now_ms();
// The first frames draw with the bundled faces while the system's CJK, Hangul and Thai
-24
View File
@@ -1433,30 +1433,6 @@ fn toolbar_pins_removes_reorders_and_keeps_its_layout() {
assert_eq!(ids(&app, "CREATE"), create);
}
/// The Construct panel's default buttons are Plane, Axis and Point (#11340). The old layout, Offset
/// Plane alone, stays one pin and three removals away, and is kept with the `toolbar` preference.
#[test]
fn the_old_construct_layout_is_kept_with_the_toolbar_preference() {
use crate::toolbar_custom as tc;
let mut app = sample_app();
app.ui.tab = "MODEL".into();
let ids = |app: &SolveApp| -> Vec<&'static str> {
let lists = tc::panel_lists("MODEL");
let (_, cmds, n) = lists.iter().find(|(p, ..)| *p == "CONSTRUCT").unwrap();
tc::buttons(app, "MODEL", "CONSTRUCT", cmds, *n).iter().map(|c| c.id).collect()
};
assert_eq!(ids(&app), vec!["construct.plane", "construct.axis", "construct.point"]);
act(&mut app, &Target::ToolbarCommand { id: "construct.plane.offset".into() }, "Pin to Toolbar");
for id in ["construct.plane", "construct.axis", "construct.point"] {
act(&mut app, &Target::ToolbarCommand { id: id.into() }, "Remove from Toolbar");
}
assert_eq!(ids(&app), vec!["construct.plane.offset"]);
let mut reopened = sample_app();
reopened.ui.tab = "MODEL".into();
reopened.load_prefs(&app.prefs());
assert_eq!(ids(&reopened), vec!["construct.plane.offset"], "the old layout comes back with the preferences");
}
#[test]
fn the_workspace_menu_lists_design_and_render_and_greys_future_workspaces() {
let app = sample_app();
+4 -3
View File
@@ -120,15 +120,16 @@ fn box_size(app: &SolveApp, d: &Dialog) -> Vec3 {
}
/// A stored or typed point of expressions (numbers, or strings in the design's terms); an empty
/// field is 0, as OK sends it. Text is read as the model reads it (`length_input::eval`): a bare
/// number or a unitless expression such as `2 * 1` is in the design's unit.
/// field is 0, as OK sends it.
fn expr_point(app: &SolveApp, v: &Value) -> Option<Vec3> {
let c = |i: usize| -> Option<f64> {
let x = v.as_array()?.get(i)?;
if x.as_str().is_some_and(|t| t.trim().is_empty()) {
return Some(0.0);
}
x.as_f64().or_else(|| crate::length_input::eval(&app.session.doc, x.as_str()?, ValueKind::Length).ok()).filter(|v| v.is_finite())
x.as_f64()
.or_else(|| app.session.doc.eval(&app.session.doc.with_design_unit(x.as_str()?, ValueKind::Length), ValueKind::Length).ok())
.filter(|v| v.is_finite())
};
Some(Vec3::new(c(0)?, c(1)?, c(2)?))
}
@@ -219,31 +219,6 @@ fn an_empty_placement_field_counts_as_zero_for_the_marker() {
assert!(base.dist(Vec3::new(300.0 + size.x / 2.0, size.y / 2.0, 400.0)) < 1e-6, "corner with y empty: {base:?}");
}
/// A typed corner is read as the size fields read it, by the marker and by OK alike: in an inch
/// design a decimal comma, a unitless expression, a value with its unit and a bare number are all
/// inches, and the marker stands over the corner of the box OK makes.
#[test]
fn a_typed_corner_is_in_the_design_unit_for_the_marker_and_for_ok() {
for (text, inches) in [("1,5", 1.5), ("2 * 1", 2.0), ("1,5 in", 1.5), ("3", 3.0)] {
let mut h = Harness::new();
h.app.preferences.placement_handles = true;
h.app.run("document.units", json!({"units": "in"})).unwrap();
h.settle();
start(&mut h, "solid.box");
let size = h.app.dialog.as_ref().map(|d| super::box_size(&h.app, d)).unwrap();
h.app.dialog.as_mut().unwrap().extra.insert("corner".into(), json!([text, text, text]));
let (base, _) = place(&h);
let marked = base - Vec3::new(size.x / 2.0, size.y / 2.0, 0.0);
ok(&mut h);
let r = h.app.session.execute("inspect.measure", &json!({})).unwrap();
let min = &r["bodies"][0]["bbox"]["min"];
let made = Vec3::new(min[0].as_f64().unwrap(), min[1].as_f64().unwrap(), min[2].as_f64().unwrap());
let want = Vec3::new(inches, inches, inches) * 25.4;
assert!(marked.dist(want) < 1e-6, "{text}: the marker's corner {marked:?}, want {want:?}");
assert!(made.dist(want) < 1e-6, "{text}: OK's corner {made:?}, want {want:?}");
}
}
/// A size typed as a bare expression ("2 * 1") is in the design's unit, as OK reads it: in an
/// inch design the corner a drop writes is 1 in back from the point, not 1 mm.
#[test]
-4
View File
@@ -964,10 +964,6 @@ preference_settings! {
/// A right-click in the view with a dialog open confirms its input: on to the next one, then OK.
/// Off: it does nothing there, as before (#6159).
right_click_confirms: bool = false, PAGE_GENERAL => check("Right-click confirms dialog inputs", "With a dialog open, a right-click in the view goes on to its next empty input, and on the last one runs OK");
/// With a dialog open, typing the first letter of a button's label presses it: O for OK,
/// C for Cancel (#9841). Off by default: typing does nothing to the buttons, as before.
/// A letter that fits two buttons does nothing, and a focused text field keeps the keystroke.
dialog_first_letter_buttons: bool = false, PAGE_GENERAL => check_id("prefs.dialog_first_letter_buttons");
/// View orientation widget: original signed axes (default) or compact cube.
navigator_style: NavigatorStyle = NavigatorStyle::default(), PAGE_NAVIGATION;
/// The custom view gizmo's colours: the cube's (themed) or the body's own (modeled).
-2
View File
@@ -147,8 +147,6 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui_kit::settings_section(ui, "dialogs", app.tr("prefs.dialogs"), |ui| {
let right_click = setting::right_click_confirms(app, ui);
crate::scenario::publish_target("prefs:right_click_confirms", right_click.rect);
let first_letter = setting::dialog_first_letter_buttons(app, ui).backing_on_hover_text(app.tr("prefs.dialog_first_letter_buttons_help"));
crate::scenario::publish_target("prefs:dialog_first_letter_buttons", first_letter.rect);
});
ui_kit::settings_section(ui, "updates", app.tr("prefs.updates"), |ui| {
crate::update::prefs_rows(app, ui);
-40
View File
@@ -3171,46 +3171,6 @@ fn hole_does_not_freeze_or_preselect_a_drawn_3d_wire_point() {
assert!(d.candidate(&app.session, &node).is_none());
}
/// The ribbon's Plane opens as the method its picks call for: a plane alone opens Offset Plane,
/// with its Distance, and a second plane turns the window into Midplane. OK adds that method's
/// own feature.
#[test]
fn plane_tool_opens_as_the_method_its_picks_call_for() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("construct.plane.offset", json!({"base":"XY","offset":"20 mm","name":"Shelf"})).unwrap();
h.app.session.selection = vec![Sel::Plane { name: "XY".into() }];
h.app.start("construct.plane");
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::OffsetPlane { .. }) && d.auto.is_some(), "{d:?}");
assert_eq!(d.items(), vec![Sel::Plane { name: "XY".into() }], "the selection is the first pick");
assert!(h.app.dialog.as_mut().unwrap().primary().is_some_and(|(label, ..)| label == "Distance"), "Offset Plane's own value and handle");
h.app.dialog.as_mut().unwrap().pick(&h.app.session, Sel::Plane { name: "Shelf".into() });
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::Midplane), "a second plane chooses Midplane: {d:?}");
assert_eq!(d.items(), vec![Sel::Plane { name: "XY".into() }, Sel::Plane { name: "Shelf".into() }]);
// Clicking Shelf again takes it out: one plane chooses Offset Plane again.
h.app.dialog.as_mut().unwrap().pick(&h.app.session, Sel::Plane { name: "Shelf".into() });
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::OffsetPlane { .. }), "taking the second plane out chooses Offset Plane again: {d:?}");
assert_eq!(d.items(), vec![Sel::Plane { name: "XY".into() }]);
h.app.dialog.as_mut().unwrap().pick(&h.app.session, Sel::Plane { name: "Shelf".into() });
h.frames(2);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(d.kind, Kind::Midplane), "{d:?}");
let commands = apply_commands(&h.app, d).unwrap();
assert_eq!(commands[0].0, "construct.plane.midplane", "OK runs the method's own command");
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().and_then(|d| d.error.clone()));
h.frames(2);
let (_, _, pl) =
solidcraft_engine::view::construction_planes(&h.app.session).into_iter().find(|(_, name, _)| name != "Shelf").expect("the midplane");
assert!(pl.normal().cross(Vec3::Z).len() < 1e-6 && (pl.origin.z - 10.0).abs() < 1e-6, "{pl:?}");
}
/// Plane at Angle draws the tilted plane while its dialog is open, when created and when edited,
/// and redraws it when the angle changes (it used to show only the picked plane and the label).
#[test]
+3 -4
View File
@@ -184,9 +184,8 @@ pub(crate) fn dropped(s: &Session, extra: &mut Map<String, Value>, p: Vec3, n: V
Some(json!(coords))
}
/// The typed placement into the command's parameters, read as the size fields read it
/// (`length_input::expression`): a bare number, a decimal comma or a unitless expression typed
/// into a field is in the design's units, an empty field is 0, and a stored number is kept.
/// The typed placement into the command's parameters: a bare number typed into a field is in the
/// design's units (as in the size fields), an empty field is 0, and a stored number is kept.
pub(crate) fn params(s: &Session, cmd: &str, extra: &Map<String, Value>, p: &mut Map<String, Value>) {
for key in keys(cmd) {
let Some(Value::Array(point)) = extra.get(*key) else { continue };
@@ -194,7 +193,7 @@ pub(crate) fn params(s: &Session, cmd: &str, extra: &Map<String, Value>, p: &mut
.iter()
.map(|c| match c {
Value::String(t) if t.trim().is_empty() => json!(0),
Value::String(t) => json!(crate::length_input::expression(&s.doc, t, ValueKind::Length)),
Value::String(t) => json!(s.doc.with_design_unit(t, ValueKind::Length)),
other => other.clone(),
})
.collect();
@@ -515,7 +515,6 @@ pub(super) fn same_model_source(app: &SolveApp, document: &std::sync::Weak<solid
timeline_groups,
browser_order,
named_views,
notes,
visibility,
)
}
+2 -69
View File
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
use solidcraft_engine::{
Sel,
doc::expr::Kind as ValueKind,
sketch::{LinkKind, LinkSource, TextAlign, TextStyle, TextVerticalAlign},
sketch::{LinkKind, LinkSource, TextAlign, TextStyle},
};
use crate::SolveApp;
@@ -538,7 +538,7 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], activ
TextAlign::Right => 2,
};
let labels = ["sketch_text.align_left", "sketch_text.align_center", "sketch_text.align_right"].map(solidcraft_ui_text::tr);
ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { compact: true, glyphs: Some(&paragraph_glyph), ..Default::default() });
ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { compact: true, ..Default::default() });
k.style.align = match selected {
1 => TextAlign::Center,
2 => TextAlign::Right,
@@ -550,26 +550,6 @@ pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Tx, inputs: &mut [SelInput], activ
}
}
});
ui_kit::row(ui, "Vertical", |ui| {
const ORDER: [TextVerticalAlign; 4] =
[TextVerticalAlign::Baseline, TextVerticalAlign::Top, TextVerticalAlign::Middle, TextVerticalAlign::Bottom];
let mut selected = ORDER.iter().position(|v| *v == k.style.vertical_align).unwrap_or(0);
let labels = ["sketch_text.valign_baseline", "sketch_text.valign_top", "sketch_text.valign_middle", "sketch_text.valign_bottom"]
.map(solidcraft_ui_text::tr);
// Four worded segments do not fit the dialog's width: each shows "Ag" with a line where the anchor is.
let glyph = |p: &egui::Painter, r: egui::Rect, i: usize, ink: egui::Color32| {
vertical_glyph(p, r, ORDER.get(i).copied().unwrap_or_default(), ink);
};
ui_kit::segmented(ui, &mut selected, &labels, ui_kit::Segments { compact: true, glyphs: Some(&glyph), ..Default::default() });
k.style.vertical_align = ORDER.get(selected).copied().unwrap_or_default();
for (label, handle) in
labels.iter().zip(["dialog:Vertical baseline", "dialog:Vertical top", "dialog:Vertical middle", "dialog:Vertical bottom"])
{
if let Some(at) = crate::scenario::handle_at(&format!("dialog:Vertical:{label}")) {
crate::scenario::publish_handle(handle, at);
}
}
});
// On a path the curve places the text: X, Y and the angle do not.
let on_path = k.style.path.is_some();
let (xy, rest) = k.values.split_at_mut(2);
@@ -662,44 +642,6 @@ fn is_font_file(s: &str) -> bool {
}
/// The application supplies families/file recognition; the kit owns search and list widgets.
/// The symbol of a horizontal alignment (0 left, 1 centre, 2 right): three lines of a paragraph set that way.
fn paragraph_glyph(p: &egui::Painter, r: egui::Rect, align: usize, ink: egui::Color32) {
let t = solidcraft_theme::Tokens::get();
let width = r.width().min(t.space_16 + t.space_2);
let stroke = egui::Stroke::new(1.5, ink);
for (row, share) in [1.0_f32, 0.6, 0.8].into_iter().enumerate() {
let length = width * share;
let left = match align {
0 => r.center().x - width / 2.0,
1 => r.center().x - length / 2.0,
_ => r.center().x + width / 2.0 - length,
};
p.hline(left..=left + length, r.center().y + (row as f32 - 1.0) * t.space_4, stroke);
}
}
/// The symbol of a vertical alignment: "Ag" with an accent line where the text's anchor is, at the
/// capitals' tops, halfway down them, on the baseline or under the g's tail.
fn vertical_glyph(p: &egui::Painter, r: egui::Rect, align: TextVerticalAlign, ink: egui::Color32) {
let t = solidcraft_theme::Tokens::get();
let size = t.font_label;
let galley = p.layout_no_wrap("Ag".to_owned(), crate::ui_kit::type_face::semibold(size), ink);
let at = r.center() - galley.size() / 2.0;
// The baseline: where the first row's glyphs stand, else an estimate from the font size.
let baseline = galley.rows.first().and_then(|row| row.row.glyphs.first().map(|g| row.pos.y + g.pos.y)).unwrap_or(galley.size().y * 0.8);
let base = at.y + baseline;
let cap = size * 0.72;
let y = match align {
TextVerticalAlign::Top => base - cap,
TextVerticalAlign::Middle => base - cap / 2.0,
TextVerticalAlign::Baseline => base,
TextVerticalAlign::Bottom => base + size * 0.24,
};
let half = (galley.size().x / 2.0 + t.space_3).min(r.width() / 2.0);
p.galley(at, galley, ink);
p.hline(r.center().x - half..=r.center().x + half, y, egui::Stroke::new(1.5, t.accent));
}
fn font_row(ui: &mut egui::Ui, k: &mut Tx) {
let builtin = solidcraft_ui_text::format("sketch_text.builtin_font", &[("family", solidcraft_engine::sketch::DEFAULT_FONT_FAMILY)]);
let current = if k.font.trim().is_empty() { builtin.clone() } else { k.font.clone() };
@@ -769,17 +711,10 @@ fn edit_params(app: &SolveApp, k: &Tx) -> Result<Value, String> {
TextAlign::Center => "center",
TextAlign::Right => "right",
};
let vertical_align = match k.style.vertical_align {
TextVerticalAlign::Baseline => "baseline",
TextVerticalAlign::Top => "top",
TextVerticalAlign::Middle => "middle",
TextVerticalAlign::Bottom => "bottom",
};
let bold = k.style.bold && font_has_bold_face(&k.font);
let mut params = json!({
"link": k.link, "at": [sent_x, sent_y], "height": sent_height,
"bold": bold, "italic": k.style.italic, "align": align,
"vertical_align": vertical_align,
"letter_spacing": letter_spacing, "line_spacing": line_spacing,
"path": k.style.path.clone().unwrap_or_default(), "reverse": k.style.reverse,
});
@@ -1592,14 +1527,12 @@ mod tests {
h.frames(3);
h.step(&json!({"click":{"handle":"dialog:Italic"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Align center"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Vertical middle"}})).unwrap();
replace(&mut h, "dialog:Letter Spacing", "1.5");
replace(&mut h, "dialog:Line Spacing", "1.25");
h.frames(2);
// On the canvas before OK.
let live = style_of(&h, &link);
assert!(live.italic && !live.bold && live.align == TextAlign::Center, "{live:?}");
assert_eq!(live.vertical_align, TextVerticalAlign::Middle, "{live:?}");
assert_eq!((live.letter_spacing, live.line_spacing), (1.5, 1.25));
ok(&mut h);
assert!(!editing(&h.app));
+14 -114
View File
@@ -3356,9 +3356,7 @@ fn first_triangle_point(m: &solidcraft_engine::geom::Mesh, index: usize) -> Opti
/// The faces a feature changed, for the states either side of it, on the bodies as they are now:
/// a face whose name is new, or whose area, centroid or plane moved. A face a later feature
/// renamed is found again by its plane and nearest centre, in the body's own coordinates. Each
/// face is lit on every copy of the body `now` shows: a component placed twice or turned shows
/// its body once per placement, and the copies share the body's face names.
/// renamed is found again by its plane and nearest centre.
fn changed_faces(app: &SolveApp, id: u64, now: &solidcraft_engine::doc::ModelState, shown_only: bool) -> Vec<Sel> {
let Some(index) = app.session.doc.features.iter().position(|f| f.id == id) else { return Vec::new() };
let after = match app.session.doc.features.get(index + 1) {
@@ -3366,22 +3364,12 @@ fn changed_faces(app: &SolveApp, id: u64, now: &solidcraft_engine::doc::ModelSta
None => app.session.model.state(),
};
let before = app.session.model.state_before(id);
let current = app.session.model.state();
let replaced = app.preview.effective_replaced();
let mut out = Vec::new();
for b in &after.bodies {
// The body as `now` shows it, once per placement (the body itself outside a component).
let copies: Vec<_> = now
.bodies
.iter()
.filter(|c| c.definition_name() == b.name)
.filter(|c| !shown_only || (app.body_visible(&c.name) && !replaced.contains(&c.name)))
.map(|c| (c, solidcraft_engine::doc::naming::face_names(c)))
.collect();
if copies.is_empty() {
if shown_only && (!app.session.body_visible(&b.name) || app.preview.effective_replaced().contains(&b.name)) {
continue;
}
let (Some(now_body), Some(old)) = (current.body(&b.name), before.body(&b.name)) else { continue };
let (Some(now_body), Some(old)) = (now.body(&b.name), before.body(&b.name)) else { continue };
let names = solidcraft_engine::doc::naming::face_names(b);
let old_names = solidcraft_engine::doc::naming::face_names(old);
// A body the feature left as it was (same record, same face names) has no face it
@@ -3392,7 +3380,6 @@ fn changed_faces(app: &SolveApp, id: u64, now: &solidcraft_engine::doc::ModelSta
}
let tol = (b.body.size() * 1e-3).max(1e-4);
let (Ok(new_faces), Ok(old_faces)) = (b.body.faces(tol), old.body.faces(tol)) else { continue };
let now_names = solidcraft_engine::doc::naming::face_names(now_body);
for (i, name) in names.iter().enumerate() {
let Some(new) = new_faces.get(i) else { continue };
// A face the feature moved takes a new name (Offset Face); a face it only trimmed
@@ -3409,31 +3396,27 @@ fn changed_faces(app: &SolveApp, id: u64, now: &solidcraft_engine::doc::ModelSta
if !moved {
continue;
}
// The face as the body has it now, by the name it carries there on every copy.
let Some(current_name) = current_face(now_body, &now_names, name, new, tol).and_then(|k| now_names.get(k)) else { continue };
for (copy, copy_names) in &copies {
let Some(index) = copy_names.iter().position(|n| n == current_name) else { continue };
let Some(point) = first_triangle_point(&copy.mesh(), index) else { continue };
if !out.iter().any(|s| matches!(s, Sel::Face { body, index: i, .. } if body == &copy.name && *i == index)) {
out.push(Sel::Face { body: copy.name.clone(), index, point });
}
let Some((index, point)) = current_face(now_body, name, new, tol) else { continue };
if !out.iter().any(|s| matches!(s, Sel::Face { body, index: i, .. } if body == &b.name && *i == index)) {
out.push(Sel::Face { body: b.name.clone(), index, point });
}
}
}
out
}
/// Face `name` of `now`, whose faces are `names`, or the nearest face on its plane when a later
/// feature renamed it.
/// Face `name` of `now` (or the nearest face on its plane when a later feature renamed it),
/// with a point on it.
fn current_face(
now: &solidcraft_engine::doc::ModelBody,
names: &[String],
name: &str,
sample: &solidcraft_engine::kernel::FaceInfo,
tol: f64,
) -> Option<usize> {
) -> Option<(usize, solidcore_record::Vec3)> {
let m = now.mesh();
let names = solidcraft_engine::doc::naming::face_names(now);
if let Some(index) = names.iter().position(|n| n == name) {
return Some(index);
return first_triangle_point(&m, index).map(|point| (index, point));
}
let normal = sample.plane_normal?;
let faces = now.body.faces(tol).ok()?;
@@ -3446,7 +3429,8 @@ fn current_face(
best = Some((off, d, f.index));
}
}
Some(best?.2)
let index = best?.2;
first_triangle_point(&m, index).map(|point| (index, point))
}
/// The sketches picked among `items` (a sketch row in the Browser or the timeline) that the
@@ -5839,7 +5823,6 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
crate::joint_handles::show(app, ui, &painter, &proj);
overlays(app, &painter, &proj);
paint_device_pivot(app, &painter, &proj);
draw_notes(app, &painter, &proj);
crate::dim_view::show(app, ui, &painter, &proj);
crate::sketch_text_handles::show(app, ui, &painter, &proj, hover);
crate::sketch3d::show(app, &painter, &proj);
@@ -7385,59 +7368,6 @@ impl Conflict {
}
}
/// Notes (#1631): text anchored to points in model space, drawn as small screen-facing
/// callouts. The callout is laid out in screen space at the anchor's projection, so its text
/// always faces the camera and stays readable at any rotation, while the anchor follows the
/// model. Hidden notes are kept with the design but not drawn. Notes stay out of the way
/// while a sketch is being edited.
///
/// A note as it is drawn this frame: the anchor on screen and the text box beside it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct DrawnNote {
/// The projected anchor point (screen space).
pub anchor: Pos2,
/// The text box (screen space), offset up-right from the anchor.
pub text_rect: Rect,
/// The note's text.
pub text: String,
}
/// Where the visible notes are drawn this frame (screen space), for painting and hit tests.
pub(crate) fn note_callouts(app: &SolveApp, painter: &egui::Painter, proj: &Proj) -> Vec<DrawnNote> {
if app.session.active_sketch.is_some() {
return Vec::new();
}
let t = Tokens::get();
app.session
.doc
.notes
.iter()
.filter(|n| !n.hidden)
.filter_map(|n| {
let anchor = proj.to_screen(n.anchor)?;
let galley = painter.layout(n.text.clone(), FontId::proportional(t.font_small), t.text, 240.0);
let text_rect = Rect::from_min_size(anchor + vec2(14.0, -14.0) - vec2(0.0, galley.size().y + 8.0), galley.size() + vec2(12.0, 8.0));
Some(DrawnNote { anchor, text_rect, text: n.text.clone() })
})
.collect()
}
fn draw_notes(app: &SolveApp, painter: &egui::Painter, proj: &Proj) {
if app.session.doc.notes.is_empty() {
return;
}
let t = Tokens::get();
for note in note_callouts(app, painter, proj) {
// A leader from the anchor to the text box, the dot, then the callout itself:
// a rounded panel with the text, always parallel to the screen.
painter.line_segment([note.anchor, note.text_rect.left_bottom() + vec2(6.0, -4.0)], Stroke::new(1.0, t.accent));
painter.circle_filled(note.anchor, 3.0, t.accent);
painter.rect_filled(note.text_rect, 4.0, t.panel);
painter.rect_stroke(note.text_rect, 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
painter.text(note.text_rect.min + vec2(6.0, 4.0), Align2::LEFT_TOP, &note.text, FontId::proportional(t.font_small), t.text);
}
}
/// Where a profile can fail: a ring round each open end (it touches no other curve) and a
/// diamond round each untied one (it touches another curve only by position), told apart by
/// shape. Both are warnings, not errors: an open sketch (a sweep path, an axis) is valid.
@@ -14563,34 +14493,4 @@ mod feature_highlight_tests {
// The plate's walls bulge past y = ±8; nothing of the tab does.
assert!(faces.iter().all(|p| p.y.abs() <= 8.0 + 1e-6), "{parts:?}");
}
/// A component placed twice, the first placement turned: a feature whose faces a later Offset
/// Face renamed lights the faces it changed on both placements, where each is shown.
#[test]
fn a_renamed_face_lights_on_every_placement_of_its_component() {
let mut s = solidcraft_engine::Session::default();
s.execute("component.create", &json!({"name": "Part", "grounded": false})).unwrap();
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "body_name": "Base"})).unwrap();
let first =
s.execute("solid.offset_face", &json!({"faces": [[20, 15, 20]], "distance": 5, "name": "Raise"})).unwrap()["feature"].as_u64().unwrap();
s.execute("solid.offset_face", &json!({"faces": [[20, 15, 25]], "distance": 2, "name": "RaiseAgain"})).unwrap();
s.execute("component.activate", &json!({"component": "root"})).unwrap();
s.execute("occurrence.copy", &json!({"component": "Part", "translate": [0, 40, 0]})).unwrap();
s.execute("occurrence.move", &json!({"occurrence": "Part:1", "axis": [0, 0, 1], "angle": "90 deg", "origin": [0, 0, 0]})).unwrap();
let app = SolveApp::new(s, crate::Services::default());
let parts = feature_parts(&app, first, true);
let mut bodies: Vec<&str> = parts.iter().filter_map(|p| if let Sel::Face { body, .. } = p { Some(body.as_str()) } else { None }).collect();
bodies.sort_unstable();
bodies.dedup();
assert_eq!(bodies.len(), 2, "faces lit on both placements: {parts:?}");
for body in bodies {
// The raised top face, at z = 27 on the turned placement as on the copy.
let top = parts.iter().any(|p| matches!(p, Sel::Face { body: b, point, .. } if b == body && (point.z - 27.0).abs() < 1e-6));
assert!(top, "{body} lights its raised top: {parts:?}");
}
// The copy stands 40 mm along Y; the turned first placement has its faces at x <= 0.
let copy = parts.iter().any(|p| matches!(p, Sel::Face { point, .. } if point.y > 40.0));
let turned = parts.iter().any(|p| matches!(p, Sel::Face { point, .. } if point.x < 0.0));
assert!(copy && turned, "{parts:?}");
}
}
@@ -2800,36 +2800,3 @@ fn a_section_card_shows_every_row_and_reads_like_its_pill() {
}
}
}
/// Notes (#1631): the callout is laid out in screen space at the projected anchor, so it
/// faces the camera at any rotation, follows the anchor, and hidden notes are not drawn.
#[test]
fn note_callouts_are_screen_facing_and_follow_the_anchor() {
fn shot(h: &Harness) -> Vec<DrawnNote> {
let proj = display_proj(&h.app);
let mut out = Vec::new();
draw(&h.ctx, |ui| out = note_callouts(&h.app, ui.painter(), &proj));
out
}
let mut h = Harness::new();
h.app.run("note.add", json!({"anchor": [10.0, 5.0, 20.0], "text": "Check clearance here."})).unwrap();
h.settle();
let first = shot(&h);
assert_eq!(first.len(), 1);
assert_eq!(first[0].text, "Check clearance here.");
assert!(first[0].text_rect.width() > 0.0 && first[0].text_rect.height() > 0.0);
// Another rotation: the same text, drawn at a new place — the callout faces the screen.
h.call("ui.view", json!({"view": "front"}));
h.settle();
let second = shot(&h);
assert_eq!(second.len(), 1);
assert_eq!(second[0].text, first[0].text);
assert_ne!(second[0].anchor, first[0].anchor, "the anchor follows the model as it turns");
// A hidden note is kept but not drawn.
let id = h.app.session.doc.notes[0].id;
h.app.run("note.update", json!({"note": id, "hidden": true})).unwrap();
h.settle();
assert!(shot(&h).is_empty());
}
+8 -22
View File
@@ -113,9 +113,7 @@ pub fn groups(tab: &str, panel: &str) -> &'static [CommandGroup] {
("MODEL", "CONSTRUCT") => &[
CommandGroup {
label: "ui.construct_planes",
// Plane, Axis and Point (method from the picks) lead their own heading.
ids: &[
"construct.plane",
"construct.plane.offset",
"construct.plane.at_angle",
"construct.plane.midplane",
@@ -130,7 +128,6 @@ pub fn groups(tab: &str, panel: &str) -> &'static [CommandGroup] {
CommandGroup {
label: "ui.construct_axes",
ids: &[
"construct.axis",
"construct.axis.through_cylinder",
"construct.axis.normal_to_face",
"construct.axis.two_planes",
@@ -141,7 +138,6 @@ pub fn groups(tab: &str, panel: &str) -> &'static [CommandGroup] {
CommandGroup {
label: "ui.construct_points",
ids: &[
"construct.point",
"construct.point.vertex",
"construct.point.two_edges",
"construct.point.three_planes",
@@ -225,13 +221,7 @@ pub const SECTIONS: &[Section] = &[
ids: &["solid.move", "solid.mirror", "solid.pattern.rectangular", "solid.align", "solid.pattern.circular", "solid.pattern.path"],
},
Section { tab: "MODEL", panel: "CONFIGURE", promoted: 2, ids: CONFIGURE },
// Plane, Axis and Point work out the method from the picks; the methods follow in the menu.
Section {
tab: "MODEL",
panel: "CONSTRUCT",
promoted: 3,
ids: &["construct.plane", "construct.axis", "construct.point", "construct.plane.offset"],
},
Section { tab: "MODEL", panel: "CONSTRUCT", promoted: 1, ids: &["construct.plane.offset"] },
// Only while a linked design is edited in place, pinned at the right as FINISH SKETCH is.
Section { tab: "MODEL", panel: "FINISH EDITING", promoted: 1, ids: &["component.finish_edit"] },
// Sketches drive surfaces too: New Sketch leads the tab, as on MODEL and SHEET METAL.
@@ -930,13 +920,11 @@ mod tests {
let mut previous = 0;
for (_, c) in &grouped {
assert!(seen.insert(c.id), "duplicate {}", c.id);
// The tool that picks its method from the selection leads its entity's heading.
let entity = |e: &str| c.id == format!("construct.{e}") || c.id.starts_with(&format!("construct.{e}."));
let rank = if entity("plane") {
let rank = if c.id.starts_with("construct.plane.") {
0
} else if entity("axis") {
} else if c.id.starts_with("construct.axis.") {
1
} else if entity("point") {
} else if c.id.starts_with("construct.point.") {
2
} else {
panic!("ungrouped {}", c.id)
@@ -944,13 +932,11 @@ mod tests {
assert!(rank >= previous, "out of order: {}", c.id);
previous = rank;
}
assert_eq!(grouped[0].1.id, "construct.plane");
assert_eq!(grouped[1].1.id, "construct.plane.offset");
assert_eq!(grouped[2].1.id, "construct.plane.at_angle");
assert_eq!(grouped[0].1.id, "construct.plane.offset");
assert_eq!(grouped[1].1.id, "construct.plane.at_angle");
assert!(ungrouped("MODEL", "CONSTRUCT", &commands).is_empty());
// Removing pinned tools must not leave an empty heading or lose other commands.
let without_planes: Vec<_> =
commands.iter().copied().filter(|c| c.id != "construct.plane" && !c.id.starts_with("construct.plane.")).collect();
let without_planes: Vec<_> = commands.iter().copied().filter(|c| !c.id.starts_with("construct.plane.")).collect();
let grouped = grouped_commands("MODEL", "CONSTRUCT", &without_planes);
assert_eq!(grouped.iter().filter_map(|(h, _)| *h).collect::<Vec<_>>(), ["ui.construct_axes", "ui.construct_points"]);
assert_eq!(grouped.len(), without_planes.len());
@@ -1085,7 +1071,7 @@ mod tests {
("CREATE", vec!["solid.extrude", "solid.revolve", "solid.hole"]),
("MODIFY", vec!["solid.fillet", "solid.chamfer", "solid.shell", "solid.press_pull"]),
("ARRANGE", vec!["solid.move", "solid.mirror", "solid.pattern.rectangular"]),
("CONSTRUCT", vec!["construct.plane", "construct.axis", "construct.point"]),
("CONSTRUCT", vec!["construct.plane.offset"]),
] {
let (cmds, n) = layout("MODEL", panel, &specs).unwrap();
assert_eq!(cmds.iter().take(n).map(|c| c.id).collect::<Vec<_>>(), expected);
+6 -7
View File
@@ -195,18 +195,17 @@ fn narrow_toolbar_reaches_collapsed_commands() {
use serde_json::json;
let mut h = Harness::new();
h.run(&json!([
// At 640 wide the ribbon still needs no » menu: Construct's three buttons (Plane, Axis
// and Point) fold into the panel's own button.
// The compact ribbon still fits every button at 640 wide.
{"call":"ui.resize","params":{"width":640,"height":800}},
{"expect":{"shown_handles":["collapsed:MODEL:CONSTRUCT"],"hidden_handles":["toolbar:overflow","button:construct.plane"]}},
{"expect":{"shown_handles":["button:construct.plane.offset"],"hidden_handles":["toolbar:overflow"]}},
// Too narrow for every button: the panels go into the » menu.
{"call":"ui.resize","params":{"width":440,"height":800}},
{"expect":{"shown_handles":["toolbar:overflow"],"hidden_handles":["button:construct.plane","collapsed:MODEL:CONSTRUCT"]}},
{"expect":{"shown_handles":["toolbar:overflow"],"hidden_handles":["button:construct.plane.offset"]}},
{"click":{"handle":"toolbar:overflow"}},
{"widget":"CONSTRUCT"},
{"expect":{"shown_handles":["toolbar:construct.plane"]}},
{"click":{"handle":"toolbar:construct.plane"}},
{"expect":{"dialog":"ConstructAuto"}}
{"expect":{"shown_handles":["toolbar:construct.plane.offset"]}},
{"click":{"handle":"toolbar:construct.plane.offset"}},
{"expect":{"dialog":"OffsetPlane"}}
]))
.unwrap();
}
-37
View File
@@ -198,43 +198,6 @@ pub fn keyboard(ctx: &egui::Context, ok_enabled: bool) -> Action {
}
}
/// First-letter accelerators (#9841): a typed character answers the visible button whose
/// translated label starts with it. The match is on the typed glyph (`Event::Text`), not the
/// physical key, so it works on any keyboard layout, and on the label after translation, so a
/// German dialog answers N, A and S. Ambiguous letters do nothing; a matched but disabled
/// button still swallows the key so it cannot reach the app behind the dialog. A focused text
/// editor keeps the keystroke, as with Enter in [`keyboard`]. The host's preference gates the
/// whole accelerator ([`super::set_dialog_mnemonics`]); this runs only when it is on.
pub(super) fn first_letter_action(ctx: &egui::Context, candidates: &[(Action, &str, bool)]) -> Action {
if ctx.memory(|m| m.focused().is_some()) {
return Action::None;
}
ctx.input_mut(|i| {
let hit = i.events.iter().enumerate().find_map(|(index, event)| match event {
egui::Event::Text(text) => {
let mut letters = text.chars();
let (Some(ch), None) = (letters.next(), letters.next()) else {
return None;
};
let mut matches = candidates.iter().filter(|(_, label, _)| {
solidcraft_ui_text::tr_label(label).trim().chars().next().is_some_and(|first| first.to_lowercase().eq(ch.to_lowercase()))
});
let (action, live) = match (matches.next(), matches.next()) {
(Some((action, _, live)), None) => (*action, *live),
_ => return None,
};
Some((index, action, live))
}
_ => None,
});
if let Some((index, action, live)) = hit {
i.events.remove(index);
return if live { action } else { Action::None };
}
Action::None
})
}
/// Pinned actions with optional Apply, compute status and keyboard selection mark.
#[derive(Default)]
pub struct Footer<'a> {
+18 -19
View File
@@ -33,9 +33,6 @@ pub struct Segments<'a> {
pub primary: bool,
pub intrinsic: bool,
pub proportional: bool,
/// Draws each segment's symbol (painter, segment rect, index, ink) in place of its label. The
/// labels stay the hover text, the accessible names and the handles.
pub glyphs: Option<&'a dyn Fn(&egui::Painter, egui::Rect, usize, egui::Color32)>,
}
pub fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], config: Segments<'_>) -> bool {
@@ -61,7 +58,6 @@ pub fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], config: Se
primary,
intrinsic,
proportional,
glyphs,
} = config;
let accent = solidcraft_theme::TabAccent::current(ui.ctx());
let t = Tokens::get();
@@ -178,21 +174,24 @@ pub fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], config: Se
crate::icons::paint_ink(ui.painter(), icon_rect, icon, if on && workspace_accent { accent.ink } else { t.form_ink });
}
let text_rect = if compact { text_rect.intersect(segment.shrink2(vec2(t.space_4, 0.0))) } else { text_rect };
let ink = if !segment_enabled {
t.form_dim
} else if on && primary {
t.on_accent
} else if on && workspace_accent {
accent.ink
} else if on {
t.selected_ink
} else {
t.form_ink
};
match glyphs {
Some(glyph) => glyph(ui.painter(), segment.shrink2(vec2(t.space_4, t.space_2)), index, ink),
None => paint_text(ui, text_rect, text, font, ink, false),
}
paint_text(
ui,
text_rect,
text,
font,
if !segment_enabled {
t.form_dim
} else if on && primary {
t.on_accent
} else if on && workspace_accent {
accent.ink
} else if on {
t.selected_ink
} else {
t.form_ink
},
false,
);
t.button_bevel_with_radius(ui, &response, on, rounding);
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, segment_enabled, on, *text));
solidcraft_ui_registry::publish_target(&format!("dialog:{}:{option}", label(ui)), segment);
+1 -49
View File
@@ -1,30 +1,8 @@
//! Modal confirmations share the Panel/Window frame and the single kit footer.
use super::{
Action, Footer, Window,
actions::{first_letter_action, footer_with_left},
text::paint_text,
};
use super::{Action, Footer, Window, actions::footer_with_left, text::paint_text};
use egui::{Ui, vec2};
use solidcraft_theme::Tokens;
thread_local! {
/// Does the host let dialog buttons answer the first letter of their labels (#9841)?
/// Off unless the host turns it on, so the old behaviour — typing does nothing to the
/// buttons — is the default. See [`set_dialog_mnemonics`].
static DIALOG_MNEMONICS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Do dialog buttons answer the first letter of their labels? See [`set_dialog_mnemonics`].
pub fn dialog_mnemonics() -> bool {
DIALOG_MNEMONICS.with(std::cell::Cell::get)
}
/// The host turns the first-letter button accelerators of [`Confirm`] on or off (#9841).
/// Off is the old behaviour: a typed letter does nothing to a dialog's buttons.
pub fn set_dialog_mnemonics(on: bool) {
DIALOG_MNEMONICS.with(|cell| cell.set(on));
}
#[derive(Clone, Copy)]
pub enum Tone {
Warning,
@@ -87,22 +65,6 @@ impl<'a> Confirm<'a> {
self
}
/// The footer's visible buttons with their live states, for the first-letter accelerator:
/// OK needs valid inputs, Cancel always answers, and a confirmation's left action is live.
fn accelerators(&self, valid: bool) -> Vec<(Action, &'a str, bool)> {
let mut out = Vec::new();
if !self.ok.is_empty() {
out.push((Action::Ok, self.ok, valid));
}
if let Some(left) = self.left {
out.push((Action::Apply, left, true));
}
if !self.cancel.is_empty() {
out.push((Action::Cancel, self.cancel, true));
}
out
}
pub fn show(self, ctx: &egui::Context, valid: bool, message: impl FnOnce(&mut Ui)) -> (bool, Action) {
// Reviewed reports and native pickers temporarily own input. Keep the pending
// answer untouched and resume the modal when that boundary closes.
@@ -118,10 +80,6 @@ impl<'a> Confirm<'a> {
ui.spacing_mut().item_spacing.y = 0.0;
// Consume before native button focus can interpret Enter as a different answer.
let enter = ui.is_enabled() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
// #9841: a typed letter answers the button whose translated label starts with it.
// Read here so the keystroke is consumed even when Escape or a click wins the frame.
// The host's preference gates the accelerator; off, the keystroke is left alone.
let accel = if ui.is_enabled() && dialog_mnemonics() { first_letter_action(ui.ctx(), &self.accelerators(valid)) } else { Action::None };
let mut closed = false;
egui::Frame::NONE
.inner_margin(egui::Margin {
@@ -190,8 +148,6 @@ impl<'a> Confirm<'a> {
action
} else if enter && valid {
self.selected.unwrap_or(Action::Ok)
} else if action == Action::None && accel != Action::None {
accel
} else {
action
};
@@ -199,7 +155,3 @@ impl<'a> Confirm<'a> {
})
}
}
#[cfg(test)]
#[path = "confirm_tests.rs"]
mod tests;
-130
View File
@@ -1,130 +0,0 @@
use crate::test_support::*;
use crate::*;
use egui::Ui;
/// A pressed letter as the platform delivers it: `Event::Text` carries the typed glyph, which is
/// what makes the accelerator layout-independent (#9841).
fn letter(ch: &str) -> Vec<egui::Event> {
vec![egui::Event::Text(ch.to_owned())]
}
/// Draws the prompt for two frames with the accelerator switched on, as the host does when its
/// preference is on. An egui Area spends its first frame on an invisible sizing pass, so the
/// modal only answers keys from its second frame on — the same warm-up the panel tests use.
fn prompt_frame(ctx: &egui::Context, events: Vec<egui::Event>, valid: bool, labels: (&str, &str, Option<&str>)) -> Action {
set_dialog_mnemonics(true);
prompt_draw(ctx, events, valid, labels)
}
fn prompt_draw(ctx: &egui::Context, events: Vec<egui::Event>, valid: bool, labels: (&str, &str, Option<&str>)) -> Action {
let mut action = Action::None;
let mut draw = |ui: &mut Ui| {
let (ok, cancel, left) = labels;
action = Confirm::new(ui.scope_id().with("prompt"), "status_warning", "Save changes?")
.tone(Tone::Warning)
.actions(ok, cancel, left)
.secondary_left()
.show(ui.ctx(), valid, |ui| {
text(ui, "Your changes will be lost.", Text::Report("message", false));
})
.1;
};
frame(ctx, vec![], &mut draw);
frame(ctx, events, &mut draw);
action
}
/// The changed-default rule: with the host's preference left at its default — off — the letters
/// answer nothing, the old behaviour. Turning it on makes each button answer its first letter.
#[test]
fn first_letters_are_inert_until_the_host_turns_them_on() {
let ctx = crate::test_support::context();
let labels = ("Save", "Cancel", Some("Don't Save"));
assert!(!dialog_mnemonics(), "off unless the host turns it on");
assert_eq!(prompt_draw(&ctx, letter("s"), true, labels), Action::None, "off: Save does not answer S");
assert_eq!(prompt_draw(&ctx, letter("c"), true, labels), Action::None, "off: Cancel does not answer C");
assert_eq!(prompt_frame(&ctx, letter("s"), true, labels), Action::Ok, "on: Save answers S");
}
/// Each button answers its own first letter, in either case; an unmatched letter answers nothing.
#[test]
fn confirm_answers_the_first_letter_of_each_button_label() {
let ctx = crate::test_support::context();
let labels = ("Save", "Cancel", Some("Don't Save"));
assert_eq!(prompt_frame(&ctx, letter("s"), true, labels), Action::Ok, "Save");
assert_eq!(prompt_frame(&ctx, letter("c"), true, labels), Action::Cancel, "Cancel");
assert_eq!(prompt_frame(&ctx, letter("d"), true, labels), Action::Apply, "Don't Save");
assert_eq!(prompt_frame(&ctx, letter("S"), true, labels), Action::Ok, "the letter matches whatever its case");
assert_eq!(prompt_frame(&ctx, letter("x"), true, labels), Action::None, "no label starts with x");
}
/// The accelerator follows the translated label: German shows „Speichern/Abbrechen/Nicht
/// speichern", so the keys are S, A and N — and the English D answers nothing.
#[test]
fn confirm_first_letter_follows_the_translated_label() {
let ctx = crate::test_support::context();
let _language = solidcraft_ui_text::scope("de");
let labels = (solidcraft_ui_text::tr("ui.save"), solidcraft_ui_text::tr("ui.cancel"), Some(solidcraft_ui_text::tr("ui.don_t_save")));
assert_eq!(labels, ("Speichern", "Abbrechen", Some("Nicht speichern")), "the catalog answers in German");
assert_eq!(prompt_frame(&ctx, letter("s"), true, labels), Action::Ok, "Speichern");
assert_eq!(prompt_frame(&ctx, letter("a"), true, labels), Action::Cancel, "Abbrechen");
assert_eq!(prompt_frame(&ctx, letter("n"), true, labels), Action::Apply, "Nicht speichern");
assert_eq!(prompt_frame(&ctx, letter("d"), true, labels), Action::None, "no German label starts with d");
}
/// Two visible buttons sharing a first letter make the key ambiguous: it answers nothing, while
/// a letter that still distinguishes one button keeps working.
#[test]
fn ambiguous_first_letters_do_nothing() {
let ctx = crate::test_support::context();
let labels = ("Delete", "Discard", Some("Keep"));
assert_eq!(prompt_frame(&ctx, letter("d"), true, labels), Action::None, "Delete and Discard both start with d");
assert_eq!(prompt_frame(&ctx, letter("k"), true, labels), Action::Apply, "Keep is still unambiguous");
}
/// The matched keystroke is consumed: the next frame sees no event and does not answer twice,
/// and Enter and Escape keep their existing meanings even alongside a letter.
#[test]
fn first_letter_is_consumed_and_never_overrides_enter_or_escape() {
let ctx = crate::test_support::context();
let labels = ("Save", "Cancel", Some("Don't Save"));
assert_eq!(prompt_frame(&ctx, letter("s"), true, labels), Action::Ok);
assert_eq!(prompt_frame(&ctx, vec![], true, labels), Action::None, "the consumed letter does not answer again");
let mut events = key(egui::Key::Enter);
events.extend(letter("c"));
assert_eq!(prompt_frame(&ctx, events, true, labels), Action::Ok, "Enter still picks the selected button");
let mut events = key(egui::Key::Escape);
events.extend(letter("s"));
assert_eq!(prompt_frame(&ctx, events, true, labels), Action::Cancel, "Escape still cancels");
}
/// A text field inside the dialog (preset and rule name prompts) owns the keystroke while focused.
#[test]
fn first_letter_yields_to_a_text_field_inside_the_dialog() {
set_dialog_mnemonics(true);
let ctx = crate::test_support::context();
let mut name = String::new();
let mut action = Action::None;
let mut draw = |ui: &mut Ui| {
action = Confirm::new(ui.scope_id().with("name"), "save", "Save preset?")
.actions("Save", "Cancel", None)
.show(ui.ctx(), true, |ui| {
ui.text_edit_singleline(&mut name).request_focus();
})
.1;
};
frame(&ctx, vec![], &mut draw);
frame(&ctx, letter("s"), &mut draw);
assert_eq!(action, Action::None, "the focused text field keeps the keystroke");
}
/// A disabled button swallows its letter instead of letting it reach the app behind the dialog.
#[test]
fn first_letter_respects_disabled_buttons() {
let ctx = crate::test_support::context();
let labels = ("Save", "Cancel", Some("Don't Save"));
assert_eq!(prompt_frame(&ctx, letter("s"), false, labels), Action::None, "Save is disabled while inputs are invalid");
assert_eq!(prompt_frame(&ctx, vec![], false, labels), Action::None, "the swallowed letter did not linger");
assert_eq!(prompt_frame(&ctx, letter("c"), false, labels), Action::Cancel, "Cancel is always live");
}
+1 -1
View File
@@ -93,7 +93,7 @@ pub use checks::*;
pub use choice::*;
pub use color::*;
pub use compare::*;
pub use confirm::{Confirm, Tone, dialog_mnemonics, set_dialog_mnemonics};
pub use confirm::{Confirm, Tone};
pub use container::{BackingTooltipExt, Container, DockEdges, Edge, LayoutMode, Surface};
pub use container::{SHELL_GAP, input_blocked, set_input_blocked};
pub use dash::dashed_line;
@@ -1,5 +0,0 @@
{
"ui.save": "Speichern",
"ui.cancel": "Abbrechen",
"ui.don_t_save": "Nicht speichern"
}
+1 -4
View File
@@ -1,8 +1,5 @@
{
"ui.expand": "Expand",
"ui.view_compass_tip": "View from {axis}. Click again to reverse. Drag to orbit.",
"ui.view_compass_tip_snap": "View from {axis}. Click again to reverse. Drag to orbit; hold Shift to snap.",
"ui.save": "Save",
"ui.cancel": "Cancel",
"ui.don_t_save": "Don't Save"
"ui.view_compass_tip_snap": "View from {axis}. Click again to reverse. Drag to orbit; hold Shift to snap."
}

Some files were not shown because too many files have changed in this diff Show More