mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-07 20:41:50 +00:00
Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0160e67ea1 | |||
| c29f396f72 | |||
| 46edecca67 | |||
| a36c1743dc | |||
| 7b3ca00054 | |||
| 91a06a546e | |||
| fd02123b3e | |||
| 13ab728d07 | |||
| 4a07865288 | |||
| b73d6e6e6f | |||
| 3b2ca62c86 | |||
| 24c7bb7d70 | |||
| 670636ecd6 | |||
| 3623466acf | |||
| ecbd05ef5e | |||
| c1bd94e937 | |||
| 28fed4d6dd | |||
| be648ef742 | |||
| a8d775c12a | |||
| e02e1d40b5 | |||
| 136b62fa47 | |||
| a2c4a1267b | |||
| 735688dbc5 | |||
| 0debd9759f | |||
| 2c6230eafc | |||
| 850509120b | |||
| 72d2beff71 | |||
| 5395387dad | |||
| 0e83a0e494 | |||
| 9387a1b93c | |||
| 304aba2a49 | |||
| b9270a6884 | |||
| ea46e07300 | |||
| eaf014eb54 | |||
| 1c03f2be06 | |||
| 3dc258c203 | |||
| 565ba4a37e | |||
| 1c1018aa6e | |||
| 1bd05a2c51 | |||
| 5d8cd60f70 | |||
| 046eba5ba3 | |||
| 96a6629ed1 | |||
| 203ce2672b | |||
| 43c14bdbdf | |||
| 6b5b49b285 | |||
| 3d49d0cb0b | |||
| 9ecf28834f | |||
| 2e4b728fca | |||
| 5524224222 | |||
| 2c4e3fe4d6 |
@@ -725,16 +725,10 @@ jobs:
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
sha256sum --check SHA256SUMS.txt
|
||||
# Generated notes start at the newest published release, pre-releases included. Left
|
||||
# to GitHub they start at the Latest stable one, and the list outgrows the 125,000
|
||||
# characters a release body may have.
|
||||
start=()
|
||||
prev="$(gh release list --exclude-drafts --limit 30 --json tagName,publishedAt --jq 'sort_by(.publishedAt) | last | .tagName // empty')"
|
||||
if [ -n "$prev" ]; then start=(--notes-start-tag "$prev"); fi
|
||||
# Stage all packages before publishing. Published tags are never replaced.
|
||||
gh release create "$tag" --draft --prerelease --latest=false \
|
||||
--title "SolidCraft v$VERSION (nightly)" --target "$GITHUB_SHA" \
|
||||
--notes-file ../notes.md --generate-notes "${start[@]}" -- *
|
||||
--notes-file ../notes.md --generate-notes -- *
|
||||
gh release edit "$tag" --draft=false --prerelease --latest=false
|
||||
echo "### Published nightly: $tag (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
- name: Create or update the draft release
|
||||
@@ -766,13 +760,8 @@ jobs:
|
||||
--target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
# As for nightlies: start the generated notes at the newest published release.
|
||||
start=()
|
||||
prev="$(gh release list --exclude-drafts --limit 30 --json tagName,publishedAt --jq 'sort_by(.publishedAt) | last | .tagName // empty')"
|
||||
if [ -n "$prev" ]; then start=(--notes-start-tag "$prev"); fi
|
||||
gh release create "$tag" --draft --title "SolidCraft v$VERSION" \
|
||||
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${start[@]}" \
|
||||
"${pre[@]}" -- *
|
||||
--target "$GITHUB_SHA" --notes-file ../notes.md --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft **SolidCraft v$VERSION** at \`${GITHUB_SHA::9}\`:"
|
||||
|
||||
Generated
+3
-2
@@ -4051,15 +4051,16 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "solidcore"
|
||||
version = "1.0.1"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=8ddce901671f1cebd9214a31d21895d725e886c8#8ddce901671f1cebd9214a31d21895d725e886c8"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=a445bdaac60a980b0e9d1dba3ee28eb7ada44f50#a445bdaac60a980b0e9d1dba3ee28eb7ada44f50"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solidcore-record"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f#f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f"
|
||||
source = "git+https://github.com/tangency-labs/solidcore?rev=a445bdaac60a980b0e9d1dba3ee28eb7ada44f50#a445bdaac60a980b0e9d1dba3ee28eb7ada44f50"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+2
-2
@@ -36,10 +36,10 @@ solidcraft-3dx-client = { path = "crates/threedx-client" }
|
||||
# move the rev on its own commit. For a
|
||||
# local checkout, add `[patch."https://github.com/tangency-labs/solidcore"] solidcore = { path =
|
||||
# "../solidcore/crates/solidcore" }` to a `.cargo/config.toml` outside the repository.
|
||||
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "8ddce901671f1cebd9214a31d21895d725e886c8" }
|
||||
solidcore = { git = "https://github.com/tangency-labs/solidcore", rev = "a445bdaac60a980b0e9d1dba3ee28eb7ada44f50" }
|
||||
# Geometry as data (solidcore's layers §1, "Record"): the B-rep record, its structural validation
|
||||
# and the native body file. Pinned like solidcore; for a local checkout, patch it as above.
|
||||
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "f5e8e9a29e0c9b7cf6c4040a5bc8878382b3c62f" }
|
||||
solidcore-record = { git = "https://github.com/tangency-labs/solidcore", rev = "a445bdaac60a980b0e9d1dba3ee28eb7ada44f50" }
|
||||
# test tooling: a dev-dependency only (xtask/src/layers.rs)
|
||||
solidcraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ fn a_model_saved_under_another_check_mode_is_evaluated_again() {
|
||||
/// opened from its model or evaluated anew. Two edges of a 20 mm box rounded r2 (a mitre at the
|
||||
/// corner (20, 0, 20)), saved; opened, the upright edge there rounded r3: the corner all three
|
||||
/// rounded at once make (SMLib's setback corner, kernel `three_rounds_at_a_corner`), 15 faces and
|
||||
/// 7928.142563938 mm³, the same body as the design evaluated anew.
|
||||
/// 7927.72428251348 mm³, the same body as the design evaluated anew.
|
||||
#[test]
|
||||
fn a_later_round_after_opening_blends_the_saved_rounds_again() {
|
||||
let mut built = Session::default();
|
||||
@@ -336,7 +336,7 @@ fn a_later_round_after_opening_blends_the_saved_rounds_again() {
|
||||
let b = &st.body("Box").unwrap().body;
|
||||
let v = solidcraft_engine::kernel::measure(b).unwrap().volume;
|
||||
assert_eq!(b.face_count(), 15);
|
||||
assert!((v - 7928.142563938).abs() < 1e-6, "{v}");
|
||||
assert!((v - 7927.72428251348).abs() < 1e-6, "{v}"); // solidcore#2382's setback corner
|
||||
}
|
||||
assert_eq!(model("later round", &cached), model("later round", &rebuilt));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
@@ -173,6 +173,35 @@ pub fn is_open(open: Option<&str>, wanted: &str) -> bool {
|
||||
open == Some(wanted)
|
||||
}
|
||||
|
||||
/// What follows once a design restored from a kept copy, or recovered, is open: it opens in
|
||||
/// stages, a few features a frame with progress and Cancel (#10198), so this waits for the answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum AfterOpen {
|
||||
/// A kept copy (#10220): it is its own design again (saving goes to `path`, it is named
|
||||
/// `name`), with its changes unsaved; then the copy `id` goes when `drop` says so.
|
||||
KeptCopy { opened: String, id: String, path: Option<String>, name: String, drop: bool },
|
||||
/// The saved design offered beside a kept copy: the copy `id` goes when `drop` says so.
|
||||
KeptSaved { opened: String, id: String, drop: bool },
|
||||
/// Unsaved work from the page's last visit: it belongs to `path` again, named `name`, unsaved.
|
||||
Recovery { opened: String, path: Option<String>, name: String },
|
||||
}
|
||||
|
||||
impl AfterOpen {
|
||||
/// The file the open reads.
|
||||
pub fn opened(&self) -> &str {
|
||||
match self {
|
||||
AfterOpen::KeptCopy { opened, .. } | AfterOpen::KeptSaved { opened, .. } | AfterOpen::Recovery { opened, .. } => opened,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the open of `wanted` answered with `reply` put it in: the answer says so, and it is
|
||||
/// the design now open (`open`: the open design's path). A refusal, a Cancel or another file
|
||||
/// leaves the copy or the recovered work offered.
|
||||
pub fn opened(reply: &Value, open: Option<&str>, wanted: &str) -> bool {
|
||||
reply["ok"].as_bool() == Some(true) && is_open(open, wanted)
|
||||
}
|
||||
|
||||
/// Whether leaving the page asks through the browser's "leave site?" box: there is unsaved work,
|
||||
/// and either the user still wants to be asked or it isn't all kept in the browser yet (with the
|
||||
/// keep preference off it never is, so there the box always stays).
|
||||
@@ -224,6 +253,20 @@ pub fn answers(entry: &Entry, saved_exists: bool) -> Vec<Answer> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A restored or recovered design opens in stages (#10198): only an answer that it is in, for
|
||||
/// the file asked for, counts; a refusal, a Cancel or another design open leaves it offered.
|
||||
#[test]
|
||||
fn a_staged_restore_counts_only_when_its_file_is_in() {
|
||||
let copy = "/opfs/.kept/a.sc";
|
||||
assert!(opened(&json!({"ok": true}), Some(copy), copy));
|
||||
assert!(!opened(&json!({"ok": false, "error": "cancelled"}), Some(copy), copy));
|
||||
assert!(!opened(&json!({"ok": false, "error": "not a design"}), None, copy));
|
||||
assert!(!opened(&json!({"ok": true}), Some("/opfs/other.sc"), copy));
|
||||
assert!(!opened(&json!({}), Some(copy), copy));
|
||||
let after = AfterOpen::KeptCopy { opened: copy.into(), id: "a".into(), path: None, name: "Bracket".into(), drop: true };
|
||||
assert_eq!(after.opened(), copy);
|
||||
}
|
||||
|
||||
fn entry(id: &str, changed_at: f64, path: Option<&str>) -> Entry {
|
||||
Entry { id: id.into(), name: "Bracket".into(), path: path.map(str::to_string), changed_at }
|
||||
}
|
||||
|
||||
@@ -382,6 +382,9 @@ mod web {
|
||||
keep_track: std::collections::HashMap<u64, Track>,
|
||||
/// Every unsaved design's kept copy is current (and the preference is on).
|
||||
all_kept: bool,
|
||||
/// A kept copy or recovered work being opened, in stages with progress and Cancel
|
||||
/// (#10198): the answer to come and what follows it ([`Shell::restored`]).
|
||||
restoring: Option<(std::sync::mpsc::Receiver<serde_json::Value>, keep::AfterOpen)>,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
@@ -633,7 +636,7 @@ mod web {
|
||||
/// (when it has a file) and Discard.
|
||||
fn keep_prompt(&mut self, ctx: &egui::Context) {
|
||||
let offered = self.files.keep_offered.borrow().clone();
|
||||
if offered.is_empty() || !self.files.ready.get() {
|
||||
if offered.is_empty() || !self.files.ready.get() || self.restoring.is_some() {
|
||||
return;
|
||||
}
|
||||
let now = (js_sys::Date::now() / 1000.0) as u64;
|
||||
@@ -673,24 +676,21 @@ mod web {
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.app.open_path(©);
|
||||
if self.app.session.path.as_deref() != Some(copy.as_str()) {
|
||||
// Not opened (the status says why): it stays offered.
|
||||
return;
|
||||
}
|
||||
// It is its own design again (saving goes to its file), with its changes unsaved.
|
||||
self.app.session.path = entry.path.clone();
|
||||
self.app.session.doc_mut().name = entry.name.clone();
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
// Finished in `restored` once it is in.
|
||||
let after = keep::AfterOpen::KeptCopy {
|
||||
opened: copy.clone(),
|
||||
id: entry.id.clone(),
|
||||
path: entry.path.clone(),
|
||||
name: entry.name.clone(),
|
||||
drop: plan.drop_copy,
|
||||
};
|
||||
self.restore(©, after);
|
||||
return;
|
||||
}
|
||||
if let Some(path) = &plan.open_saved {
|
||||
if vfs::exists(path) {
|
||||
self.app.open_path(path);
|
||||
if !keep::is_open(self.app.session.path.as_deref(), path) {
|
||||
// Not opened (the status says why): the copy stays offered.
|
||||
return;
|
||||
}
|
||||
self.restore(path, keep::AfterOpen::KeptSaved { opened: path.clone(), id: entry.id.clone(), drop: plan.drop_copy });
|
||||
return;
|
||||
} else {
|
||||
// A file on the computer: the browser asks for access again, in this click.
|
||||
// The copy goes once the file has opened, not before: if access is refused,
|
||||
@@ -708,8 +708,62 @@ mod web {
|
||||
}
|
||||
}
|
||||
if plan.drop_copy {
|
||||
self.files.keep_offered.borrow_mut().retain(|e| e.id != entry.id);
|
||||
files::remove_kept(&self.files, &entry.id);
|
||||
self.drop_kept(&entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
fn drop_kept(&self, id: &str) {
|
||||
self.files.keep_offered.borrow_mut().retain(|e| e.id != id);
|
||||
files::remove_kept(&self.files, id);
|
||||
}
|
||||
|
||||
/// Open a kept copy, its saved design or recovered work as any design opens, a few
|
||||
/// features a frame with progress and Cancel (#10198), so a large design doesn't freeze the
|
||||
/// page; `after` follows once it is in ([`Self::restored`]).
|
||||
fn restore(&mut self, path: &str, after: keep::AfterOpen) {
|
||||
self.restoring = Some((self.app.open_path_answered(path), after));
|
||||
self.restored();
|
||||
}
|
||||
|
||||
/// What follows a restore that is in: the design gets back its file and name, and the
|
||||
/// copy or the recovered work it came from goes. One that didn't open (refused or
|
||||
/// cancelled; the status says why) stays offered.
|
||||
fn restored(&mut self) {
|
||||
let Some((rx, _)) = &self.restoring else { return };
|
||||
let reply = match rx.try_recv() {
|
||||
Ok(reply) => reply,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => serde_json::json!({"ok": false}),
|
||||
};
|
||||
let Some((_, after)) = self.restoring.take() else { return };
|
||||
if !keep::opened(&reply, self.app.session.path.as_deref(), after.opened()) {
|
||||
return;
|
||||
}
|
||||
match after {
|
||||
keep::AfterOpen::KeptCopy { id, path, name, drop, .. } => {
|
||||
// It is its own design again (saving goes to its file), with its changes unsaved.
|
||||
self.app.session.path = path;
|
||||
self.app.session.doc_mut().name = name;
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
if drop {
|
||||
self.drop_kept(&id);
|
||||
}
|
||||
}
|
||||
keep::AfterOpen::KeptSaved { id, drop, .. } => {
|
||||
if drop {
|
||||
self.drop_kept(&id);
|
||||
}
|
||||
}
|
||||
keep::AfterOpen::Recovery { path, name, .. } => {
|
||||
// It belongs to its own file again (saving goes there); it is unsaved.
|
||||
self.app.session.path = path;
|
||||
self.app.session.doc_mut().name = name;
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
files::autosave(&self.files, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,6 +971,9 @@ mod web {
|
||||
|
||||
/// Offer the autosave an earlier session left.
|
||||
fn recovery_prompt(&mut self, ctx: &egui::Context) {
|
||||
if self.restoring.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
|
||||
let mut answer = None;
|
||||
let _ = solidcraft_ui_egui::ui_kit::Window::new(egui::Id::unique("sc_browser_recovery"), "restore", "Recover unsaved work?")
|
||||
@@ -941,15 +998,10 @@ mod web {
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.app.open_path(&recovery);
|
||||
if self.app.session.path.as_deref() != Some(recovery.as_str()) {
|
||||
return;
|
||||
}
|
||||
// It belongs to its own file again (saving goes there); it is unsaved.
|
||||
self.app.session.path = path;
|
||||
self.app.session.doc_mut().name = name;
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
// Finished in `restored` once it is in; until then the prompt stays away.
|
||||
let after = keep::AfterOpen::Recovery { opened: recovery.clone(), path, name };
|
||||
self.restore(&recovery, after);
|
||||
return;
|
||||
}
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
files::autosave(&self.files, None);
|
||||
@@ -1093,6 +1145,7 @@ mod web {
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.linked.tick(&mut self.app, ui.ctx());
|
||||
self.restored();
|
||||
UNSAVED.with(|u| u.set(self.asks_before_unload()));
|
||||
if self.launch.embed {
|
||||
ui_kit::Container::new(ui.ctx(), ui_kit::Surface::Chrome).edge("embed_header", ui_kit::Edge::Top).show(ui, |ui| {
|
||||
@@ -1306,6 +1359,7 @@ mod web {
|
||||
other_tab_prefs,
|
||||
keep_track: Default::default(),
|
||||
all_kept: false,
|
||||
restoring: None,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
|
||||
+17
-10
@@ -265,6 +265,7 @@
|
||||
"Edge": "حافة",
|
||||
"Edges": "الحواف",
|
||||
"Edit": "تعديل",
|
||||
"Edit Holes": "تحرير الثقوب",
|
||||
"Edit Profile Sketch": "تعديل رسم المقطع",
|
||||
"Edit shortcuts": "تعديل الاختصارات",
|
||||
"Enable rule": "تفعيل القاعدة",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "مماس يمر بنقطة خارجية",
|
||||
"cmd.sketch.construction_line.tangent_two": "مماس مشترك",
|
||||
"cmd.sketch.construction_line.vertical": "خط إنشائي رأسي",
|
||||
"cmd.sketch.replace": "استبدال الرسم",
|
||||
"cmd.sketch_arc3d": "قوس ثلاثي الأبعاد",
|
||||
"cmd.sketch_arc_center_point": "قوس بنقطة مركز",
|
||||
"cmd.sketch_arc_tangent": "قوس مماسي",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "يبدّل طريقة رسم النموذج: مظلَّل مع الحواف أو بدونها، أو إطار سلكي، أو مع الحواف المخفية. استخدم الإطار السلكي لرؤية الداخل، والمظلَّل لمظهر نظيف.",
|
||||
"history.sketch_under_constrained": "قيود ناقصة ({count} درجات حرية)",
|
||||
"history.sketch_under_constrained_one": "قيود ناقصة ({count} درجة حرية)",
|
||||
"hole_folder.applies_to": "تنطبق التغييرات على الثقوب الـ {count} في {folder}؛ ويحتفظ كل ثقب بموضعه.",
|
||||
"hole_presets.confirm_delete": "تأكيد الحذف",
|
||||
"hole_presets.delete": "حذف",
|
||||
"hole_presets.delete_prompt": "هل تحذف هذا الإعداد المسبق الشخصي؟ الثقوب الموجودة لا تتغير.",
|
||||
@@ -2556,6 +2559,8 @@
|
||||
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
|
||||
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
|
||||
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
|
||||
"prefs.design_tools.hole_several": "عدة ثقوب على وجه واحد",
|
||||
"prefs.design_tools.hole_several_help": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
|
||||
"prefs.design_tools.hole_snaps_help": "منتصف الوجه ومراكز حوافه الدائرية.",
|
||||
"prefs.design_tools.imported_files": "الملفات المستوردة",
|
||||
"prefs.design_tools.note": "ملاحظة",
|
||||
@@ -2828,7 +2833,7 @@
|
||||
"prefs.select_hidden_edges_help": "يتيح تحديد الحواف عبر الأوجه عند تفعيل نمط «مظلل مع الحواف المخفية»، بما في ذلك عند استخدام التقويس. عند الإيقاف يبقى سلوك التحديد كما في v0.30.1. تظل الأجسام المخفية غير قابلة للتحديد.",
|
||||
"prefs.select_one_item": "تحديد عنصر واحد",
|
||||
"prefs.select_other_hidden": "تحديد العناصر الهندسية المخفية باستخدام «تحديد عنصر آخر»",
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والمخططات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
"prefs.select_other_hidden_help": "عند التفعيل: تضمين الأجسام والرسومات المخفية والحواف المحجوبة قرب المؤشر. عند التعطيل: استخدام القائمة الأصلية للعناصر الهندسية المرئية أسفل المؤشر.",
|
||||
"prefs.selecting": "التحديد",
|
||||
"prefs.shift": "Shift",
|
||||
"prefs.shift_add_ctrl_toggle": "Shift يضيف فقط، وCtrl / Cmd يبدّل",
|
||||
@@ -3074,7 +3079,7 @@
|
||||
"render.aging_metallic": "النسبة المعدنية للطبقة",
|
||||
"render.aging_origin": "أصل الحقل · mm العالم",
|
||||
"render.aging_patina": "زنجار نحاسي أخضر",
|
||||
"render.aging_raytrace_help": "يُطبّق التعتيق في تتبع الأشعة على المعالج وتتبع المسارات على المعالج الرسومي. يبقى التصيير النقطي تخطيطياً.",
|
||||
"render.aging_raytrace_help": "يُطبّق التعتيق في تتبع الأشعة على المعالج وتتبع المسارات على المعالج الرسومي. يبقى التصيير النقطي تخطيطيًا.",
|
||||
"render.aging_refresh_targets": "فحص الأجسام الحالية",
|
||||
"render.aging_roughness": "خشونة الطبقة",
|
||||
"render.aging_rust": "حديد مؤكسد / صدأ",
|
||||
@@ -3409,8 +3414,8 @@
|
||||
"render.cpu_sky_bounds": "يجب أن يكون دوران السماء محدودًا ضمن ±1,000,000 راديان، وأن تكون الشدة بين 0 و100.",
|
||||
"render.cpu_studio_enable": "تفعيل الإضاءة غير المباشرة لهذا التوزيع",
|
||||
"render.cpu_studio_enable_apply": "تفعيل الإضاءة غير المباشرة وتطبيقها",
|
||||
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلاً، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
|
||||
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعاً آخر أو GPU.",
|
||||
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلًا، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
|
||||
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعًا آخر أو GPU.",
|
||||
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
|
||||
"render.cpu_summary": "تتبع الأشعة على CPU · انعكاسات · HDR · أضواء اتجاهية ونقطية",
|
||||
@@ -3430,7 +3435,7 @@
|
||||
"render.diag_preflight_details": "تفاصيل الفحص المسبق",
|
||||
"render.diag_request": "الطلب الحالي بالضبط",
|
||||
"render.diag_triangles": "المثلثات",
|
||||
"render.directional_sun_limit": "تدعم إضاءة GPU ثمانية مصادر ضوء اتجاهية إجمالاً، من بينها شمس واحدة بظلال ناعمة كحد أقصى. انسخ ضوءاً اتجاهياً بظلال حادة لإضافة مصدر بعيد آخر.",
|
||||
"render.directional_sun_limit": "تدعم إضاءة GPU ثمانية مصادر ضوء اتجاهية إجمالًا، من بينها شمس واحدة بظلال ناعمة كحد أقصى. انسخ ضوءًا اتجاهيًا بظلال حادة لإضافة مصدر بعيد آخر.",
|
||||
"render.discard_continue": "تجاهل المسودة والمتابعة",
|
||||
"render.draft_confirm_help": "في مسودة التصيير الحالية تعديلات غير مطبّقة. احفظها قبل التبديل، أو تجاهلها صراحةً، أو تابع التحرير.",
|
||||
"render.draft_confirm_title": "الحفاظ على إعدادات التصيير المعدّلة",
|
||||
@@ -4014,7 +4019,7 @@
|
||||
"render.output_lock_ratio": "قفل نسبة الأبعاد",
|
||||
"render.output_master_unavailable": "لتصدير EXR، اختر صورة مكتملة تحتوي على بيانات EXR خام. فعّل EXR الخام قبل تصيير صور جديدة.",
|
||||
"render.output_over_budget": "الصورة المطلوبة تتجاوز نطاق الموارد المختار. اختر حجمًا أو ملفًا متوافقًا صراحةً؛ لا يحدث تخفيض تلقائي.",
|
||||
"render.output_presentation_limits": "تدعم مؤثرات الصور على CPU وإخراج EXR صوراً بعرض وارتفاع لا يتجاوزان 8192 بكسل، وبإجمالي لا يتجاوز 16,777,216 بكسل.",
|
||||
"render.output_presentation_limits": "تدعم مؤثرات الصور على CPU وإخراج EXR صورًا بعرض وارتفاع لا يتجاوزان 8192 بكسل، وبإجمالي لا يتجاوز 16,777,216 بكسل.",
|
||||
"render.output_presets_help": "الصيغة والجودة وقائمة الفحص في محرر المخرجات.",
|
||||
"render.output_quality": "الجودة",
|
||||
"render.output_quality_waiting": "بانتظار عقد الجودة",
|
||||
@@ -4265,7 +4270,7 @@
|
||||
"render.soft_focus": "تركيز ناعم",
|
||||
"render.spot_axis": "اتجاه انبعاث الضوء",
|
||||
"render.spot_cone": "مخروط الكشّاف",
|
||||
"render.spot_gpu_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية. تتطلب نطاقات التلاشي الضيقة جداً التصيير باستخدام CPU.",
|
||||
"render.spot_gpu_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية. تتطلب نطاقات التلاشي الضيقة جدًا التصيير باستخدام CPU.",
|
||||
"render.spot_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية.",
|
||||
"render.spot_inner": "نصف الزاوية الداخلية",
|
||||
"render.spot_outer": "نصف الزاوية الخارجية",
|
||||
@@ -5446,7 +5451,7 @@
|
||||
"ui.finest": "الأدق",
|
||||
"ui.finish_sketch": "إنهاء الرسم",
|
||||
"ui.first_dimension_scales": "البُعد الأول يغيّر مقياس الرسم",
|
||||
"ui.first_dimension_scales_tip": "تشغيل: أول بُعد طولي في المخطط يغيّر مقياس الرسم الذي يقيسه، فيبقى شكله كما هو. إيقاف: يغيّر البُعد حجم ما يقيسه فقط.",
|
||||
"ui.first_dimension_scales_tip": "تشغيل: أول بُعد طولي في الرسم يغيّر مقياس الشكل الذي يقيسه، فيبقى شكله كما هو. إيقاف: يغيّر البُعد حجم ما يقيسه فقط.",
|
||||
"ui.fit": "ملاءمة",
|
||||
"ui.fix_unfix": "تثبيت/فك التثبيت",
|
||||
"ui.flat": "مسطّح",
|
||||
@@ -5771,7 +5776,7 @@
|
||||
"ui.panel_layout.custom_hint": "تم نقل اللوحات منذ {name}.",
|
||||
"ui.panel_layout.details": "التفاصيل",
|
||||
"ui.panel_layout.layout_workspace": "مساحة عمل التخطيط",
|
||||
"ui.panel_layout.page_defaults_hint": "إعادة ضبط إعدادات هذه الصفحة. تُحفظ ترتيبات اللوحات والتخطيطات المحفوظة.",
|
||||
"ui.panel_layout.page_defaults_hint": "يعيد ضبط إعدادات هذه الصفحة. تُحفظ ترتيبات اللوحات والتخطيطات المحفوظة.",
|
||||
"ui.panel_layout.panel_layout": "تخطيط اللوحات",
|
||||
"ui.panel_layout.panel_style": "نمط اللوحات",
|
||||
"ui.panel_layout.replace": "استبدال التخطيط",
|
||||
@@ -5796,7 +5801,7 @@
|
||||
"ui.panel_layout.workspace.modelling": "النمذجة",
|
||||
"ui.panel_layout.workspace.render": "التصيير",
|
||||
"ui.panel_layout.workspace.review": "المراجعة",
|
||||
"ui.panel_layout.workspace.sketch": "الرسم التخطيطي",
|
||||
"ui.panel_layout.workspace.sketch": "الرسم",
|
||||
"ui.panel_layout.your_layout": "تخطيطك",
|
||||
"ui.parallel": "متوازي",
|
||||
"ui.parameters": "المعاملات",
|
||||
@@ -5906,6 +5911,8 @@
|
||||
"ui.renderer": "محرك التصيير",
|
||||
"ui.repeat": "تكرار",
|
||||
"ui.repeat_nothing": "لا يوجد ما يُكرَّر بعد: ابدأ أداة أو مربع حوار أولًا.",
|
||||
"ui.replace_sketch.from_file": "من ملف تصميم…",
|
||||
"ui.replace_sketch.no_sketches": "لا يحتوي هذا التصميم على رسم يمكن أخذه",
|
||||
"ui.replace_with_link_done": "أصبحت النسخ الآن مواضع للتصميم المرتبط.",
|
||||
"ui.report_a_bug": "الإبلاغ عن خطأ",
|
||||
"ui.report_characters_left_one": "الأحرف المتبقية: {count}",
|
||||
|
||||
+448
-16
File diff suppressed because it is too large
Load Diff
@@ -206,6 +206,7 @@
|
||||
"Edge": "Kante",
|
||||
"Edges": "Kanten",
|
||||
"Edit": "Bearbeiten",
|
||||
"Edit Holes": "Bohrungen bearbeiten",
|
||||
"Edit Profile Sketch": "Profile-Skizze bearbeiten",
|
||||
"Edit shortcuts": "Tastenkürzel bearbeiten",
|
||||
"Enable rule": "Regel aktivieren",
|
||||
@@ -1177,6 +1178,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangente durch äußeren Punkt",
|
||||
"cmd.sketch.construction_line.tangent_two": "Gemeinsame Tangente",
|
||||
"cmd.sketch.construction_line.vertical": "Vertikale Konstruktionslinie",
|
||||
"cmd.sketch.replace": "Skizze ersetzen",
|
||||
"cmd.sketch_arc3d": "3D-Bogen",
|
||||
"cmd.sketch_arc_center_point": "Mittelpunkt-Kreisbogen",
|
||||
"cmd.sketch_arc_tangent": "Tangentialbogen",
|
||||
@@ -1819,6 +1821,7 @@
|
||||
"help.view.visual_style": "Schaltet um, wie das Modell gezeichnet wird: schattiert mit oder ohne Kanten, Drahtmodell oder mit verdeckten Kanten. Drahtmodell zum Hineinschauen, schattiert für ein sauberes Bild.",
|
||||
"history.sketch_under_constrained": "Unterbestimmt ({count} Freiheitsgrade)",
|
||||
"history.sketch_under_constrained_one": "Unterbestimmt ({count} Freiheitsgrad)",
|
||||
"hole_folder.applies_to": "Änderungen gelten für die {count} Bohrungen in {folder}; jede behält ihre Position.",
|
||||
"hole_presets.confirm_delete": "Löschen bestätigen",
|
||||
"hole_presets.delete": "Löschen",
|
||||
"hole_presets.delete_prompt": "Möchten Sie diese persönliche Vorlage wirklich löschen? Bereits vorhandene Bohrungen bleiben unverändert.",
|
||||
@@ -2343,6 +2346,8 @@
|
||||
"prefs.design_tools.double_click_help": "Oder zum Bearbeiten einer verknüpften Komponente an Ort und Stelle.",
|
||||
"prefs.design_tools.ghost": "Alte Position verschobener Teile als Geisterbild zeigen",
|
||||
"prefs.design_tools.hole_moves_help": "Erneutes Auswählen der Fläche verschiebt die Bohrung, statt sie zu entfernen.",
|
||||
"prefs.design_tools.hole_several": "Mehrere Bohrungen auf einer Fläche",
|
||||
"prefs.design_tools.hole_several_help": "Eine Auswahl an anderer Stelle der Fläche fügt eine weitere Bohrung hinzu; die Auswahl einer Bohrung entfernt sie.",
|
||||
"prefs.design_tools.hole_snaps_help": "Die Mitte einer Fläche und die Mittelpunkte ihrer kreisförmigen Kanten.",
|
||||
"prefs.design_tools.imported_files": "Importierte Dateien",
|
||||
"prefs.design_tools.note": "Hinweis",
|
||||
@@ -5620,6 +5625,8 @@
|
||||
"ui.renderer": "Renderer",
|
||||
"ui.repeat": "Wiederholen",
|
||||
"ui.repeat_nothing": "Noch nichts zu wiederholen: Starten Sie zuerst ein Werkzeug oder einen Dialog.",
|
||||
"ui.replace_sketch.from_file": "Aus einer Konstruktionsdatei…",
|
||||
"ui.replace_sketch.no_sketches": "Diese Konstruktion enthält keine Skizze",
|
||||
"ui.replace_with_link_done": "Die Kopien sind jetzt Platzierungen des verknüpften Designs.",
|
||||
"ui.report_a_bug": "Fehler melden",
|
||||
"ui.report_characters_left_one": "{count} Zeichen übrig",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "Edge",
|
||||
"Edges": "Edges",
|
||||
"Edit": "Edit",
|
||||
"Edit Holes": "Edit Holes",
|
||||
"Edit Profile Sketch": "Edit Profile Sketch",
|
||||
"Edit shortcuts": "Edit shortcuts",
|
||||
"Enable rule": "Enable rule",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangent through External Point",
|
||||
"cmd.sketch.construction_line.tangent_two": "Common Tangent",
|
||||
"cmd.sketch.construction_line.vertical": "Vertical Construction Line",
|
||||
"cmd.sketch.replace": "Replace Sketch",
|
||||
"cmd.sketch_arc3d": "3D Arc",
|
||||
"cmd.sketch_arc_center_point": "Center Point Arc",
|
||||
"cmd.sketch_arc_tangent": "Tangent Arc",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "Switches how the model is drawn: shaded with or without edges, wireframe, or with hidden edges. Use wireframe to see inside, shaded for a clean look.",
|
||||
"history.sketch_under_constrained": "Under constrained ({count} degrees of freedom)",
|
||||
"history.sketch_under_constrained_one": "Under constrained ({count} degree of freedom)",
|
||||
"hole_folder.applies_to": "Changes apply to the {count} holes in {folder}; each keeps its place.",
|
||||
"hole_presets.confirm_delete": "Confirm delete",
|
||||
"hole_presets.delete": "Delete",
|
||||
"hole_presets.delete_prompt": "Delete this personal preset? Existing holes are unchanged.",
|
||||
@@ -2556,6 +2559,8 @@
|
||||
"prefs.design_tools.double_click_help": "Or to edit a linked one in place.",
|
||||
"prefs.design_tools.ghost": "Show moved parts' old place as a ghost",
|
||||
"prefs.design_tools.hole_moves_help": "Picking the face again moves the hole instead of removing it.",
|
||||
"prefs.design_tools.hole_several": "Several holes on one face",
|
||||
"prefs.design_tools.hole_several_help": "Picking elsewhere on a face adds another hole; picking a hole removes it.",
|
||||
"prefs.design_tools.hole_snaps_help": "The middle of a face and the centers of its round edges.",
|
||||
"prefs.design_tools.imported_files": "Imported files",
|
||||
"prefs.design_tools.note": "Note",
|
||||
@@ -5906,6 +5911,8 @@
|
||||
"ui.renderer": "Renderer",
|
||||
"ui.repeat": "Repeat",
|
||||
"ui.repeat_nothing": "Nothing to repeat yet: start a tool or dialog first.",
|
||||
"ui.replace_sketch.from_file": "From a Design File…",
|
||||
"ui.replace_sketch.no_sketches": "That design has no sketch to take",
|
||||
"ui.replace_with_link_done": "The copies are now placements of the linked design.",
|
||||
"ui.report_a_bug": "Report a bug",
|
||||
"ui.report_characters_left_one": "{count} character left",
|
||||
|
||||
@@ -1139,6 +1139,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tangente passant par un point extérieur",
|
||||
"cmd.sketch.construction_line.tangent_two": "Tangente commune",
|
||||
"cmd.sketch.construction_line.vertical": "Droite de construction verticale",
|
||||
"cmd.sketch.replace": "Remplacer l’esquisse",
|
||||
"cmd.sketch_arc3d": "Arc 3D",
|
||||
"cmd.sketch_arc_center_point": "Arc par centre",
|
||||
"cmd.sketch_arc_tangent": "Arc tangent",
|
||||
@@ -5015,6 +5016,8 @@
|
||||
"ui.renderer": "Moteur de rendu",
|
||||
"ui.repeat": "Répéter",
|
||||
"ui.repeat_nothing": "Rien à répéter pour l'instant : lancer d'abord un outil ou une boîte de dialogue.",
|
||||
"ui.replace_sketch.from_file": "Depuis un fichier de conception…",
|
||||
"ui.replace_sketch.no_sketches": "Cette conception ne contient aucune esquisse",
|
||||
"ui.replace_with_link_done": "Les copies sont maintenant des placements de la conception liée.",
|
||||
"ui.report_a_bug": "Signaler un bug",
|
||||
"ui.reset": "Réinitialiser",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "מקצוע",
|
||||
"Edges": "מקצועות",
|
||||
"Edit": "עריכה",
|
||||
"Edit Holes": "עריכת חורים",
|
||||
"Edit Profile Sketch": "עריכת סקיצת הפרופיל",
|
||||
"Edit shortcuts": "עריכת קיצורי דרך",
|
||||
"Enable rule": "הפעלת הכלל",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "משיק דרך נקודה חיצונית",
|
||||
"cmd.sketch.construction_line.tangent_two": "משיק משותף",
|
||||
"cmd.sketch.construction_line.vertical": "ישר עזר אנכי",
|
||||
"cmd.sketch.replace": "החלפת סקיצה",
|
||||
"cmd.sketch_arc3d": "קשת תלת־ממדית",
|
||||
"cmd.sketch_arc_center_point": "קשת מרכז ונקודה",
|
||||
"cmd.sketch_arc_tangent": "קשת משיקה",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "החלפת אופן הציור של המודל: מוצלל עם מקצועות או בלעדיהם, מסגרת תיל, או עם מקצועות מוסתרים. מסגרת תיל מאפשרת לראות פנימה, ומוצלל נותן מראה נקי.",
|
||||
"history.sketch_under_constrained": "אילוצים חסרים ({count} דרגות חופש)",
|
||||
"history.sketch_under_constrained_one": "אילוצים חסרים (דרגת חופש {count})",
|
||||
"hole_folder.applies_to": "השינויים חלים על {count} החורים ב-{folder}; כל חור שומר על מיקומו.",
|
||||
"hole_presets.confirm_delete": "אישור מחיקה",
|
||||
"hole_presets.delete": "מחיקה",
|
||||
"hole_presets.delete_prompt": "למחוק את ההגדרה המוכנה האישית הזו? חורים קיימים לא ישתנו.",
|
||||
@@ -2556,6 +2559,8 @@
|
||||
"prefs.design_tools.double_click_help": "או לעריכת רכיב מקושר במקומו.",
|
||||
"prefs.design_tools.ghost": "הצגת המיקום הקודם של חלקים שהוזזו כרפאים",
|
||||
"prefs.design_tools.hole_moves_help": "בחירה חוזרת בפאה מזיזה את החור במקום להסירו.",
|
||||
"prefs.design_tools.hole_several": "כמה חורים בפאה אחת",
|
||||
"prefs.design_tools.hole_several_help": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
|
||||
"prefs.design_tools.hole_snaps_help": "אמצע הפאה ומרכזי הקצוות העגולים שלה.",
|
||||
"prefs.design_tools.imported_files": "קבצים מיובאים",
|
||||
"prefs.design_tools.note": "הערה",
|
||||
@@ -5906,6 +5911,8 @@
|
||||
"ui.renderer": "מנוע גרפי",
|
||||
"ui.repeat": "חזרה",
|
||||
"ui.repeat_nothing": "אין עדיין מה לחזור עליו: התחל קודם כלי או תיבת דו־שיח.",
|
||||
"ui.replace_sketch.from_file": "מקובץ תכנון…",
|
||||
"ui.replace_sketch.no_sketches": "בתכנון הזה אין סקיצה לקחת",
|
||||
"ui.replace_with_link_done": "העותקים הם עכשיו מיקומים של התכן המקושר.",
|
||||
"ui.report_a_bug": "דיווח על באג",
|
||||
"ui.report_characters_left_one": "תווים שנותרו: {count}",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "エッジ",
|
||||
"Edges": "エッジ",
|
||||
"Edit": "編集",
|
||||
"Edit Holes": "穴を編集",
|
||||
"Edit Profile Sketch": "プロファイルスケッチを編集",
|
||||
"Edit shortcuts": "ショートカットを編集",
|
||||
"Enable rule": "ルールを有効化",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "外部点を通る接線",
|
||||
"cmd.sketch.construction_line.tangent_two": "共通接線",
|
||||
"cmd.sketch.construction_line.vertical": "垂直補助直線",
|
||||
"cmd.sketch.replace": "スケッチを置き換え",
|
||||
"cmd.sketch_arc3d": "3D 円弧",
|
||||
"cmd.sketch_arc_center_point": "中心点円弧",
|
||||
"cmd.sketch_arc_tangent": "正接円弧",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "モデルの描画方法(エッジ付きまたはなしのシェード、ワイヤーフレーム、隠線付き)を切り替えます。内部を見るにはワイヤーフレーム、すっきり見せるにはシェードを使います。",
|
||||
"history.sketch_under_constrained": "拘束不足(自由度 {count})",
|
||||
"history.sketch_under_constrained_one": "拘束不足(自由度 {count})",
|
||||
"hole_folder.applies_to": "変更は {folder} の {count} 個の穴に適用されます。各穴の位置はそのままです。",
|
||||
"hole_presets.confirm_delete": "削除を確認",
|
||||
"hole_presets.delete": "削除",
|
||||
"hole_presets.delete_prompt": "この個人用プリセットを削除しますか?既存の穴は変更されません。",
|
||||
@@ -2556,6 +2559,8 @@
|
||||
"prefs.design_tools.double_click_help": "リンクされたコンポーネントをその場で編集することもできます。",
|
||||
"prefs.design_tools.ghost": "移動したパーツの元の位置をゴースト表示",
|
||||
"prefs.design_tools.hole_moves_help": "面をもう一度選ぶと穴を削除せずに移動します。",
|
||||
"prefs.design_tools.hole_several": "1つの面に複数の穴",
|
||||
"prefs.design_tools.hole_several_help": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
|
||||
"prefs.design_tools.hole_snaps_help": "面の中央と円形エッジの中心。",
|
||||
"prefs.design_tools.imported_files": "インポートしたファイル",
|
||||
"prefs.design_tools.note": "通知",
|
||||
@@ -5906,6 +5911,8 @@
|
||||
"ui.renderer": "レンダラー",
|
||||
"ui.repeat": "繰り返し",
|
||||
"ui.repeat_nothing": "まだ繰り返すものがありません。先にツールかダイアログを開始してください。",
|
||||
"ui.replace_sketch.from_file": "デザインファイルから…",
|
||||
"ui.replace_sketch.no_sketches": "そのデザインには取り込めるスケッチがありません",
|
||||
"ui.replace_with_link_done": "コピーはリンクされたデザインの配置になりました。",
|
||||
"ui.report_a_bug": "不具合を報告",
|
||||
"ui.report_characters_left_one": "残り {count} 文字",
|
||||
|
||||
@@ -1117,6 +1117,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "외부 점을 지나는 접선",
|
||||
"cmd.sketch.construction_line.tangent_two": "공통 접선",
|
||||
"cmd.sketch.construction_line.vertical": "수직 보조 직선",
|
||||
"cmd.sketch.replace": "스케치 바꾸기",
|
||||
"cmd.sketch_arc_center_point": "중심점 호",
|
||||
"cmd.sketch_arc_tangent": "접선 호",
|
||||
"cmd.sketch_arc_three_point": "3점 호",
|
||||
@@ -5078,6 +5079,8 @@
|
||||
"ui.renderer": "렌더러",
|
||||
"ui.repeat": "반복",
|
||||
"ui.repeat_nothing": "아직 반복할 것이 없습니다. 먼저 도구나 대화 상자를 시작하세요.",
|
||||
"ui.replace_sketch.from_file": "디자인 파일에서…",
|
||||
"ui.replace_sketch.no_sketches": "그 디자인에는 가져올 스케치가 없습니다",
|
||||
"ui.replace_with_link_done": "복사본이 이제 링크된 디자인의 배치가 되었습니다.",
|
||||
"ui.report_a_bug": "버그 신고",
|
||||
"ui.reset": "재설정",
|
||||
|
||||
+2703
-1
File diff suppressed because it is too large
Load Diff
+3721
-1
File diff suppressed because it is too large
Load Diff
+527
-4
File diff suppressed because it is too large
Load Diff
@@ -265,6 +265,7 @@
|
||||
"Edge": "Cạnh",
|
||||
"Edges": "Các cạnh",
|
||||
"Edit": "Sửa",
|
||||
"Edit Holes": "Sửa các lỗ",
|
||||
"Edit Profile Sketch": "Chỉnh sửa phác thảo biên dạng",
|
||||
"Edit shortcuts": "Sửa phím tắt",
|
||||
"Enable rule": "Bật quy tắc",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "Tiếp tuyến qua điểm ngoài",
|
||||
"cmd.sketch.construction_line.tangent_two": "Tiếp tuyến chung",
|
||||
"cmd.sketch.construction_line.vertical": "Đường dựng hình thẳng đứng",
|
||||
"cmd.sketch.replace": "Thay thế phác thảo",
|
||||
"cmd.sketch_arc3d": "Cung tròn 3D",
|
||||
"cmd.sketch_arc_center_point": "Cung tròn xác định bởi tâm và điểm",
|
||||
"cmd.sketch_arc_tangent": "Cung tròn tiếp tuyến",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "Đổi cách vẽ mô hình: tô bóng có hoặc không có cạnh, khung dây, hoặc hiện cạnh khuất. Dùng khung dây để nhìn vào bên trong, tô bóng để có hình ảnh gọn gàng.",
|
||||
"history.sketch_under_constrained": "Thiếu ràng buộc ({count} bậc tự do)",
|
||||
"history.sketch_under_constrained_one": "Thiếu ràng buộc ({count} bậc tự do)",
|
||||
"hole_folder.applies_to": "Thay đổi áp dụng cho {count} lỗ trong {folder}; mỗi lỗ giữ nguyên vị trí.",
|
||||
"hole_presets.confirm_delete": "Xác nhận xóa",
|
||||
"hole_presets.delete": "Xóa",
|
||||
"hole_presets.delete_prompt": "Xóa mẫu thiết lập sẵn cá nhân này? Các lỗ ren/khoan hiện có sẽ không thay đổi.",
|
||||
@@ -2556,6 +2559,8 @@
|
||||
"prefs.design_tools.double_click_help": "Hoặc để chỉnh sửa thành phần được liên kết tại chỗ.",
|
||||
"prefs.design_tools.ghost": "Hiện vị trí cũ của chi tiết đã di chuyển dưới dạng bóng mờ",
|
||||
"prefs.design_tools.hole_moves_help": "Chọn lại mặt sẽ di chuyển lỗ thay vì xóa lỗ.",
|
||||
"prefs.design_tools.hole_several": "Nhiều lỗ trên một mặt",
|
||||
"prefs.design_tools.hole_several_help": "Chọn chỗ khác trên mặt sẽ thêm một lỗ; chọn một lỗ sẽ xóa lỗ đó.",
|
||||
"prefs.design_tools.hole_snaps_help": "Điểm giữa của mặt và tâm các cạnh tròn của mặt đó.",
|
||||
"prefs.design_tools.imported_files": "Tệp đã nhập",
|
||||
"prefs.design_tools.note": "Thông báo",
|
||||
@@ -5906,6 +5911,8 @@
|
||||
"ui.renderer": "Trình kết xuất hình ảnh (Renderer)",
|
||||
"ui.repeat": "Lặp lại",
|
||||
"ui.repeat_nothing": "Chưa có gì để lặp lại: hãy bắt đầu một công cụ hoặc hộp thoại trước.",
|
||||
"ui.replace_sketch.from_file": "Từ một tệp thiết kế…",
|
||||
"ui.replace_sketch.no_sketches": "Thiết kế đó không có phác thảo nào để lấy",
|
||||
"ui.replace_with_link_done": "Các bản sao hiện đã trở thành các vị trí đặt của thiết kế liên kết.",
|
||||
"ui.report_a_bug": "Báo cáo lỗi",
|
||||
"ui.report_characters_left_one": "Còn {count} ký tự",
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"Edge": "边",
|
||||
"Edges": "边",
|
||||
"Edit": "编辑",
|
||||
"Edit Holes": "编辑孔",
|
||||
"Edit Profile Sketch": "编辑轮廓草图",
|
||||
"Edit shortcuts": "编辑快捷键",
|
||||
"Enable rule": "启用规则",
|
||||
@@ -1366,6 +1367,7 @@
|
||||
"cmd.sketch.construction_line.tangent_external": "过外部点的切线",
|
||||
"cmd.sketch.construction_line.tangent_two": "公切线",
|
||||
"cmd.sketch.construction_line.vertical": "竖直构造直线",
|
||||
"cmd.sketch.replace": "替换草图",
|
||||
"cmd.sketch_arc3d": "三维圆弧",
|
||||
"cmd.sketch_arc_center_point": "中心点圆弧",
|
||||
"cmd.sketch_arc_tangent": "相切圆弧",
|
||||
@@ -2016,6 +2018,7 @@
|
||||
"help.view.visual_style": "切换模型的绘制方式:带边或不带边的着色、线框,或带隐藏边。用线框看内部,用着色得到干净的外观。",
|
||||
"history.sketch_under_constrained": "欠约束({count} 个自由度)",
|
||||
"history.sketch_under_constrained_one": "欠约束({count} 个自由度)",
|
||||
"hole_folder.applies_to": "更改将应用于 {folder} 中的 {count} 个孔;每个孔保持原位置。",
|
||||
"hole_presets.confirm_delete": "确认删除",
|
||||
"hole_presets.delete": "删除",
|
||||
"hole_presets.delete_prompt": "删除此个人预设?现有孔不会改变。",
|
||||
@@ -2556,6 +2559,8 @@
|
||||
"prefs.design_tools.double_click_help": "或就地编辑链接的组件。",
|
||||
"prefs.design_tools.ghost": "以虚影显示已移动零件的原位置",
|
||||
"prefs.design_tools.hole_moves_help": "再次选择该面会移动孔而非移除孔。",
|
||||
"prefs.design_tools.hole_several": "一个面上多个孔",
|
||||
"prefs.design_tools.hole_several_help": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
|
||||
"prefs.design_tools.hole_snaps_help": "面的中点及其圆形边的圆心。",
|
||||
"prefs.design_tools.imported_files": "导入的文件",
|
||||
"prefs.design_tools.note": "提示",
|
||||
@@ -5906,6 +5911,8 @@
|
||||
"ui.renderer": "渲染器",
|
||||
"ui.repeat": "重复",
|
||||
"ui.repeat_nothing": "还没有可重复的操作:请先启动一个工具或对话框。",
|
||||
"ui.replace_sketch.from_file": "从设计文件…",
|
||||
"ui.replace_sketch.no_sketches": "该设计中没有可用的草图",
|
||||
"ui.replace_with_link_done": "这些副本现在是链接设计的放置。",
|
||||
"ui.report_a_bug": "报告缺陷",
|
||||
"ui.report_characters_left_one": "剩余 {count} 个字符",
|
||||
|
||||
@@ -196,6 +196,45 @@ fn edge_pick<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
/// The closest point on a polyline to `target`, projecting onto each segment rather than
|
||||
/// choosing only its vertices. This keeps an edge pick on the edge between tessellation points.
|
||||
fn closest_point_on_polyline(pts: &[Vec3], target: Vec3) -> Option<Vec3> {
|
||||
let mut best: Option<(f64, Vec3)> = None;
|
||||
for w in pts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let ab = b - a;
|
||||
let len_sq = ab.dot(ab);
|
||||
let q = if len_sq < 1e-18 {
|
||||
a
|
||||
} else {
|
||||
let t = ((target - a).dot(ab) / len_sq).clamp(0.0, 1.0);
|
||||
a + ab * t
|
||||
};
|
||||
let d = target.dist(q);
|
||||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||||
best = Some((d, q));
|
||||
}
|
||||
}
|
||||
best.map(|(_, q)| q)
|
||||
}
|
||||
|
||||
/// The point on an edge nearest a pick location, projecting onto exact edge geometry
|
||||
/// (such as `kernel::edge_circle`) when available, else onto its drawn polyline segments.
|
||||
fn edge_point_at_pick(st: &ModelState, pick: Vec3, name: &Option<String>) -> Option<Vec3> {
|
||||
let (b, i, pts) = edge_pick(st, pick, name).ok()?;
|
||||
if let Some(c) = kernel::edge_circle(&b.body, i) {
|
||||
let v = pick - c.center;
|
||||
let v_perp = v - *c.axis * v.dot(*c.axis);
|
||||
if let Some(dir) = v_perp.normalized() {
|
||||
return Some(c.center + dir * c.radius);
|
||||
}
|
||||
}
|
||||
// Until the kernel can project onto any edge geometry (circles are exact above via
|
||||
// kernel::edge_circle), the fallback projects onto the drawn polyline. This is exact for
|
||||
// straight edges and approximates splines within mesh display tolerance.
|
||||
closest_point_on_polyline(&pts, pick)
|
||||
}
|
||||
|
||||
/// The circle a picked edge lies on, from its exact curve (`kernel::edge_circle`) when the drawn
|
||||
/// edge lies on it, else fitted to the drawn points: (centre, unit normal).
|
||||
fn edge_circle(b: &ModelBody, index: usize, pts: &[Vec3]) -> Option<(Vec3, Vec3)> {
|
||||
@@ -743,7 +782,29 @@ pub(crate) fn resolve_geo_plane(ctx: &Ctx, p: &PlaneRef) -> Result<Plane> {
|
||||
PlaneRef::AxisPoint { axis, point: at } => {
|
||||
let (origin, dir) = line(ctx, axis)?;
|
||||
let dir = dir.normalized().ok_or_else(|| bad("the axis has no direction"))?;
|
||||
let delta = point(ctx, at)? - origin;
|
||||
let p = match point(ctx, at) {
|
||||
Ok(p) => {
|
||||
let delta = p - origin;
|
||||
let across = delta - dir * delta.dot(dir);
|
||||
if across.len().is_finite() && across.len() >= 1e-9 {
|
||||
p
|
||||
} else if let GeoRef::Edge { at: pick, name } = at.as_ref() {
|
||||
// When the centre of a circular edge lies on the axis, resolve to the picked point on the edge.
|
||||
edge_point_at_pick(ctx.st, *pick, name).unwrap_or(p)
|
||||
} else {
|
||||
p
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Non-circular edges are not converted to centres by `point`; resolve to the picked edge point.
|
||||
if let GeoRef::Edge { at: pick, name } = at.as_ref() {
|
||||
edge_point_at_pick(ctx.st, *pick, name).ok_or(e)?
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
let delta = p - origin;
|
||||
let across = delta - dir * delta.dot(dir);
|
||||
if !across.len().is_finite() || across.len() < 1e-9 {
|
||||
return Err(bad("the point is on the axis: pick a point off the axis to define a unique plane"));
|
||||
|
||||
+178
-71
@@ -143,7 +143,7 @@ impl ModelBody {
|
||||
|
||||
/// Face measurements for joint origins, at their original chord tolerance. Pose changes
|
||||
/// and scratch copies share them; a geometry edit makes a new body and cache.
|
||||
pub(crate) fn joint_faces(&self) -> Result<Arc<[kernel::FaceInfo]>> {
|
||||
pub fn joint_faces(&self) -> Result<Arc<[kernel::FaceInfo]>> {
|
||||
let measure = || {
|
||||
#[cfg(test)]
|
||||
cost_tests::count(|c| c.joint_faces += 1);
|
||||
@@ -1161,6 +1161,18 @@ pub struct Model {
|
||||
placement_inputs: Option<placement_reuse::Inputs>,
|
||||
/// All evaluated features use supported local inputs; computed with the existing owner index.
|
||||
placement_local: bool,
|
||||
/// A failed sketch shown while it is edited ([`Model::hold_failed_sketch`]).
|
||||
held: Option<HeldSketch>,
|
||||
}
|
||||
|
||||
/// A failed sketch built for its editor (#12085): `shown` is the final state `base` with that
|
||||
/// sketch in it. [`Model::state`] gives it only while the results still end in `base`, so the
|
||||
/// next evaluation, or anything else that changes the results, lets it go.
|
||||
#[derive(Clone, Debug)]
|
||||
struct HeldSketch {
|
||||
feature: u64,
|
||||
base: Arc<ModelState>,
|
||||
shown: Arc<ModelState>,
|
||||
}
|
||||
|
||||
/// Parameters a feature uses directly (inputs, sketch dimensions and the expressions of a
|
||||
@@ -1647,6 +1659,21 @@ impl PlacementReads {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bodies To / From Object's stored face names find, each where it is placed: evaluation
|
||||
/// finds a named face by its name ([`with_named_face_points`] fails a feature whose name finds
|
||||
/// none). Everything when a name is missing or empty, finds no one body, or `st` is `None`:
|
||||
/// the face is then found by its point, on whatever body lies there.
|
||||
fn object_faces(&mut self, st: Option<&ModelState>, names: Option<&[String]>) {
|
||||
let bodies: Option<Vec<&str>> = st.zip(names).and_then(|(st, names)| {
|
||||
let first_of_its_name = |b: &&ModelBody| st.body(&b.name).is_some_and(|first| std::ptr::eq(first, *b));
|
||||
names.iter().map(|n| crate::name_lookup::face_body(st, n).filter(first_of_its_name).map(|b| b.name.as_str())).collect()
|
||||
});
|
||||
match bodies {
|
||||
Some(bodies) => bodies.into_iter().for_each(|b| self.placed_body(st, b)),
|
||||
None => self.all = true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The same target selection as `apply_traced`, expanded only over the timeline input.
|
||||
fn drawn_targets(&mut self, st: Option<&ModelState>, f: &Feature) {
|
||||
let Some(st) = st else {
|
||||
@@ -1826,11 +1853,22 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64
|
||||
}
|
||||
}
|
||||
}
|
||||
// The faces To / From Object names, in `face_points` order.
|
||||
let object_names = match &f.kind {
|
||||
FeatureKind::Extrude { extent, .. } => usize::from(extent.to.is_some()) + usize::from(extent.from.is_some()),
|
||||
FeatureKind::Revolve { to, .. } => usize::from(to.is_some()),
|
||||
_ => 0,
|
||||
};
|
||||
match &f.kind {
|
||||
// To / From Object finds its target where that target's component is placed (#928),
|
||||
// over every body.
|
||||
FeatureKind::Extrude { extent, .. } if extent.to.is_some() || extent.from.is_some() => reads.all = true,
|
||||
FeatureKind::Revolve { to: Some(_), .. } => reads.all = true,
|
||||
// To / From Object finds its target where the target's body is placed (#928): only that
|
||||
// body's placement, not every part's (#6325). Its tools meet their targets as other
|
||||
// joins and cuts do.
|
||||
kind if object_names > 0 => {
|
||||
reads.object_faces(st, f.face_names.get(..object_names));
|
||||
if drawn_tools_change_bodies(kind) {
|
||||
reads.drawn_targets(st, f);
|
||||
}
|
||||
}
|
||||
// A mirror of placed bodies follows the placements it was picked at and its own component's
|
||||
// frame, not every occurrence: moving an unrelated one leaves it (and its cache) alone.
|
||||
FeatureKind::Mirror { placements, bodies, .. } if !placements.is_empty() => return mirror_placements_digest(doc, f, placements, bodies),
|
||||
@@ -1978,9 +2016,56 @@ impl Model {
|
||||
|
||||
/// The final model state.
|
||||
pub fn state(&self) -> Arc<ModelState> {
|
||||
let last = self.evaluated_state();
|
||||
match &self.held {
|
||||
Some(held) if Arc::ptr_eq(&held.base, &last) => held.shown.clone(),
|
||||
_ => last,
|
||||
}
|
||||
}
|
||||
|
||||
/// The state after the last feature evaluated, without a failed sketch held for its editor.
|
||||
fn evaluated_state(&self) -> Arc<ModelState> {
|
||||
self.results.iter().rev().find(|r| !r.skipped).map(|r| r.state.clone()).unwrap_or_else(|| self.empty.clone())
|
||||
}
|
||||
|
||||
/// Show the sketch `feature` while it is edited although it failed (#12085): a link whose
|
||||
/// named face or edge is gone fails the sketch, so the evaluation keeps no sketch for it, and
|
||||
/// its editor had nothing to show or repair. Here the sketch is built as it is evaluated, but
|
||||
/// with such a link marked lost and kept where it was (no other face or edge is taken for
|
||||
/// it), and added to the final state only: the sketch's result keeps its error and the
|
||||
/// features after it still fail. `None`, a sketch that did not fail, or one that fails for
|
||||
/// another reason too, shows nothing more. Returns whether a held sketch is shown.
|
||||
pub fn hold_failed_sketch(&mut self, doc: &Document, feature: Option<u64>) -> bool {
|
||||
let Some(id) = feature else {
|
||||
self.held = None;
|
||||
return false;
|
||||
};
|
||||
let base = self.evaluated_state();
|
||||
if self.held.as_ref().is_some_and(|held| held.feature == id && Arc::ptr_eq(&held.base, &base)) {
|
||||
return true;
|
||||
}
|
||||
self.held = None;
|
||||
if base.sketch(id).is_some() || !self.result(id).is_some_and(|r| r.error.is_some() && !r.skipped && !r.stale) {
|
||||
return false;
|
||||
}
|
||||
let Some(f) = doc.feature(id) else { return false };
|
||||
let FeatureKind::Sketch { plane, sketch } = &f.kind else { return false };
|
||||
let mut next = (*self.state_before(id)).clone();
|
||||
let vals = evaluation_values(doc).0;
|
||||
let mut warning = None;
|
||||
let built = kernel::guard(&f.name, || {
|
||||
let _frames = ToolFrames::install(doc, f, &next);
|
||||
eval_sketch(doc, &vals, f, plane, sketch, &mut next, &mut warning, crate::project::LostNames::Mark)
|
||||
.map_err(|e| kernel::KernelError::Failed(e.to_string()))
|
||||
});
|
||||
let Some(solved) = built.ok().and_then(|()| next.sketches.iter().find(|s| s.feature == id).cloned()) else { return false };
|
||||
let mut shown = (*base).clone();
|
||||
shown.sketches.retain(|s| s.feature != id);
|
||||
shown.sketches.push(solved);
|
||||
self.held = Some(HeldSketch { feature: id, base, shown: Arc::new(shown) });
|
||||
true
|
||||
}
|
||||
|
||||
/// State just before the feature with this id (for editing a sketch in context).
|
||||
pub fn state_before(&self, id: u64) -> Arc<ModelState> {
|
||||
let Some(i) = self.results.iter().position(|r| r.id == id) else { return self.state() };
|
||||
@@ -2144,6 +2229,10 @@ impl Model {
|
||||
|
||||
/// Re-evaluate the document, reusing results up to the first changed feature.
|
||||
pub fn evaluate(&mut self, doc: &Document) {
|
||||
// A failed sketch held for its editor is built again from the evaluated design: when the
|
||||
// sketch and every feature after it fail, the results end in the state they ended in
|
||||
// before, which would keep showing the sketch as it was before the edit.
|
||||
self.held = None;
|
||||
let _span = solidcraft_profile::span("doc", "evaluate");
|
||||
self.placement_inputs = None;
|
||||
self.fingerprinted = None;
|
||||
@@ -7380,72 +7469,7 @@ fn eval_feature(
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
| FeatureKind::Align { .. }
|
||||
| FeatureKind::Remove { .. } => more::eval(doc, vals, f, st, warning),
|
||||
FeatureKind::Sketch { plane, sketch } => {
|
||||
let anchor = crate::face_anchor::plane_face_anchor(plane);
|
||||
let fst = match anchor {
|
||||
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
|
||||
// A sketch that reads no geometry (a fixed plane, no projections) needs no other
|
||||
// component's bodies moved into its frame: placing every body of a large
|
||||
// assembly again is what made each sketch edit as slow as the design is big.
|
||||
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
|
||||
None => state_in_frame(doc, st, f.component)?,
|
||||
};
|
||||
// A further placement a link reads is placed in its frame too (#9957).
|
||||
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
|
||||
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
|
||||
_ if named.is_empty() => fst,
|
||||
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
|
||||
Some(Err(_)) => fst,
|
||||
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
|
||||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||||
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
|
||||
}
|
||||
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
|
||||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
let mut sk = sketch.clone();
|
||||
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
|
||||
// ones its expressions give come first.
|
||||
doc.apply_text_values(vals, &mut sk)?;
|
||||
let mut warns = match anchor {
|
||||
Some(anchor) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
|
||||
None => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
|
||||
};
|
||||
drop(fst);
|
||||
sk.refresh_offsets()?;
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
// The stored sketch is solved for its stored values, with its links where they
|
||||
// were stored: a link that moved must not change the side a dimension from it is on.
|
||||
let stored = sk.driving_values();
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
|
||||
if !report.keeps_orientation() {
|
||||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||||
} else {
|
||||
// The solve may have moved the curve a text runs along.
|
||||
warns.extend(crate::project::relay_path_texts(&mut sk));
|
||||
}
|
||||
if !warns.is_empty() {
|
||||
*warning = Some(warns.join("; "));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
// A group of touching curves too large to search whole says so, rather than
|
||||
// leaving profiles out silently (#6896).
|
||||
let search = solidcraft_sketch::find_profiles_checked(&sk);
|
||||
if search.incomplete {
|
||||
append_warning(warning, profiles_cut_short_note());
|
||||
}
|
||||
let profiles = search.profiles;
|
||||
let ends = solidcraft_sketch::loose_ends(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Sketch { plane, sketch } => eval_sketch(doc, vals, f, plane, sketch, st, warning, crate::project::LostNames::Fail),
|
||||
FeatureKind::Extrude { operation, targets, .. }
|
||||
| FeatureKind::Pipe { operation, targets, .. }
|
||||
| FeatureKind::Revolve { operation, targets, .. }
|
||||
@@ -8264,6 +8288,88 @@ fn eval_feature(
|
||||
}
|
||||
}
|
||||
|
||||
/// A sketch feature: its links refreshed against the model before it, solved, its profiles
|
||||
/// found. `lost_names` says what a link whose named face or edge is gone does: the evaluation
|
||||
/// fails the sketch ([`crate::project::LostNames::Fail`]); a failed sketch shown for editing
|
||||
/// marks the link lost instead ([`Model::hold_failed_sketch`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn eval_sketch(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
f: &Feature,
|
||||
plane: &PlaneRef,
|
||||
sketch: &solidcraft_sketch::Sketch,
|
||||
st: &mut ModelState,
|
||||
warning: &mut Option<String>,
|
||||
lost_names: crate::project::LostNames,
|
||||
) -> Result<()> {
|
||||
let anchor = crate::face_anchor::plane_face_anchor(plane);
|
||||
let fst = match anchor {
|
||||
Some(anchor) => crate::state_in_face_anchor_frame(doc, st, anchor, f.component)?,
|
||||
// A sketch that reads no geometry (a fixed plane, no projections) needs no other
|
||||
// component's bodies moved into its frame: placing every body of a large
|
||||
// assembly again is what made each sketch edit as slow as the design is big.
|
||||
None if !plane_reads_geometry(plane) && sketch.links.is_empty() => std::borrow::Cow::Borrowed(st),
|
||||
None => state_in_frame(doc, st, f.component)?,
|
||||
};
|
||||
// A further placement a link reads is placed in its frame too (#9957).
|
||||
let named: Vec<&str> = sketch.links.iter().flat_map(|l| l.source.bodies()).collect();
|
||||
let fst = match anchor.map(|anchor| anchor.author_frame(doc, f.component)) {
|
||||
_ if named.is_empty() => fst,
|
||||
Some(Ok(frame)) => with_placed_copies(doc, st, fst, &frame, &named),
|
||||
Some(Err(_)) => fst,
|
||||
None => with_placed_copies(doc, st, fst, &doc.component_transform(f.component), &named),
|
||||
};
|
||||
let (plane, face_warning) = match (anchor, plane) {
|
||||
(Some(_), plane) => doc.resolve_attached_plane_noted(vals, plane, f.component, st, &fst)?,
|
||||
// A flat face of a sheet: the panel's exact plane, from its flat region and fold.
|
||||
(None, plane) if crate::sheet::plane_sheet_face(plane).is_some() => {
|
||||
(doc.resolve_plane_in(vals, &sheet_face_plane_in_frame(doc, st, f.component, plane)?, 0, Some(&fst))?, None)
|
||||
}
|
||||
(None, PlaneRef::Face { plane: picked, at, name, anchor: None }) => face_plane(&fst, picked, *at, name.as_deref())?,
|
||||
(None, other) => (doc.resolve_plane_in(vals, other, 0, Some(&fst))?, None),
|
||||
};
|
||||
let mut sk = sketch.clone();
|
||||
// A text's outlines are rebuilt from its numbers when the links are refreshed: the
|
||||
// ones its expressions give come first.
|
||||
doc.apply_text_values(vals, &mut sk)?;
|
||||
let mut warns = match (anchor, lost_names) {
|
||||
(Some(anchor), crate::project::LostNames::Fail) => crate::project::refresh_face_anchor_links(doc, vals, &fst, &plane, &mut sk, anchor)?,
|
||||
(None, crate::project::LostNames::Fail) => crate::project::refresh_links(doc, vals, &fst, &plane, &mut sk)?,
|
||||
(anchor, crate::project::LostNames::Mark) => crate::project::refresh_links_marking_lost(doc, vals, &fst, &plane, &mut sk, anchor)?,
|
||||
};
|
||||
drop(fst);
|
||||
sk.refresh_offsets()?;
|
||||
if let Some(w) = face_warning {
|
||||
warns.insert(0, w);
|
||||
}
|
||||
// The stored sketch is solved for its stored values, with its links where they
|
||||
// were stored: a link that moved must not change the side a dimension from it is on.
|
||||
let stored = sk.driving_values();
|
||||
let report = doc.solve_sketch(vals, &mut sk, &stored, Some(sketch))?;
|
||||
if !report.keeps_orientation() {
|
||||
warns.insert(0, report.failure(|f| format!("the sketch constraints conflict ({f})")));
|
||||
} else {
|
||||
// The solve may have moved the curve a text runs along.
|
||||
warns.extend(crate::project::relay_path_texts(&mut sk));
|
||||
}
|
||||
if !warns.is_empty() {
|
||||
*warning = Some(warns.join("; "));
|
||||
}
|
||||
sk.validate_periodic_splines()?;
|
||||
// A group of touching curves too large to search whole says so, rather than
|
||||
// leaving profiles out silently (#6896).
|
||||
let search = solidcraft_sketch::find_profiles_checked(&sk);
|
||||
if search.incomplete {
|
||||
append_warning(warning, profiles_cut_short_note());
|
||||
}
|
||||
let profiles = search.profiles;
|
||||
let ends = solidcraft_sketch::loose_ends(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(Arc::new(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles, ends }));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `copy`, a moved copy of `source` (instance `instance` of a pattern or mirror): its faces are
|
||||
/// named after the source's.
|
||||
fn moved_copy(source: &ModelBody, copy: Body, instance: usize) -> Traced {
|
||||
@@ -8739,6 +8845,7 @@ impl PlacedBodies {
|
||||
let mut fresh = ModelBody::new(name.to_string(), placed.body.clone(), b.feature);
|
||||
fresh.mesh = placed.mesh.clone();
|
||||
fresh.measured = placed.measured.clone();
|
||||
fresh.joint_faces = placed.joint_faces.clone();
|
||||
fresh.mesh_placement = placed.mesh_placement.clone();
|
||||
fresh.plan = placed.plan.clone();
|
||||
fresh.plan_mesh = placed.plan_mesh.clone();
|
||||
|
||||
@@ -656,6 +656,126 @@ fn a_tool_cutting_another_part_cuts_again_when_that_part_moves() {
|
||||
assert!((volume(&model, &body) - (400.0 - 16.0)).abs() < 1e-6, "{}", volume(&model, &body));
|
||||
}
|
||||
|
||||
/// A root design with two 10 x 10 x 4 mm plates, "Plate" at the origin and "Other" 30 mm along Y,
|
||||
/// and a sketch on origin plane `plane` of the rectangle `profile` spans (opposite corners, in mm):
|
||||
/// (the plate part, the other part, the sketch).
|
||||
fn two_plates_and_a_sketch(doc: &mut Document, plane: &str, profile: [Vec2; 2]) -> (u64, u64, u64) {
|
||||
let plate = doc.add_component(Some("Plate"), 0).unwrap();
|
||||
let other = doc.add_component(Some("Other"), 0).unwrap();
|
||||
place(doc, other, rigid(Vec3::new(0.0, 30.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
plate_with_holes(doc, other, other, 0);
|
||||
plate_with_holes(doc, plate, plate, 0);
|
||||
let mut sk = Sketch::new();
|
||||
let [a, c] = profile;
|
||||
let p = [a, Vec2::new(c.x, a.y), c, Vec2::new(a.x, c.y)].map(|q| sk.add_point(q, None).unwrap());
|
||||
for k in 0..4 {
|
||||
sk.add_line_pts(p[k], p[(k + 1) % 4], None).unwrap();
|
||||
}
|
||||
let sketch = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: plane.into() }, sketch: sk }, None).unwrap();
|
||||
(plate, other, sketch)
|
||||
}
|
||||
|
||||
/// `kind` added to the root with its face points named after the faces under them, as the
|
||||
/// command that makes it names them.
|
||||
fn add_named(doc: &mut Document, kind: FeatureKind) -> u64 {
|
||||
let mut model = Model::new();
|
||||
model.evaluate(doc);
|
||||
let names = face_point_names(doc, &model.state(), &kind, 0).unwrap();
|
||||
assert!(names.iter().all(|n| !n.is_empty()), "{names:?}");
|
||||
let id = doc.add_feature(kind, None).unwrap();
|
||||
doc.feature_mut(id).unwrap().face_names = names;
|
||||
id
|
||||
}
|
||||
|
||||
/// An extrude in the root from the top of the plate (z 4) 6 mm up, over 2 to 8 mm on X and Y.
|
||||
fn extrude_from_the_plate(doc: &mut Document) -> (u64, u64, u64) {
|
||||
let (plate, other, sketch) = two_plates_and_a_sketch(doc, "XY", [Vec2::new(2.0, 2.0), Vec2::new(8.0, 8.0)]);
|
||||
let kind = FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: SketchId(sketch),
|
||||
profiles: ProfileSel::All,
|
||||
extent: serde_json::from_value(serde_json::json!({"distance": "6", "from": [5.0, 5.0, 4.0]})).unwrap(),
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
};
|
||||
(plate, other, add_named(doc, kind))
|
||||
}
|
||||
|
||||
/// Where the body feature `id` made has its centre.
|
||||
fn centroid_of(model: &Model, id: u64) -> Vec3 {
|
||||
let st = model.state();
|
||||
kernel::measure(&st.bodies.iter().find(|b| b.feature == id).unwrap().body).unwrap().centroid
|
||||
}
|
||||
|
||||
/// An extrude from a face of the plate reads where the plate is, and nothing else (#6325): a
|
||||
/// joint moving another part leaves its result, and every result after it, as it was.
|
||||
#[test]
|
||||
fn an_extrude_from_its_own_part_keeps_its_result_when_another_part_moves() {
|
||||
let mut doc = Document::new("from object");
|
||||
let (plate, other, extrude) = extrude_from_the_plate(&mut doc);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
|
||||
assert!((centroid_of(&model, extrude).z - 7.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
|
||||
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.last_recomputed, 0);
|
||||
// The plate lifted 5 mm: the extrude starts on its top where it is now (#928).
|
||||
place(&mut doc, plate, rigid(Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.last_recomputed, 1);
|
||||
assert!((centroid_of(&model, extrude).z - 12.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
|
||||
}
|
||||
|
||||
/// A revolve To a face of the plate, as the extrude from it: it turns again only when the plate
|
||||
/// moves.
|
||||
#[test]
|
||||
fn a_revolve_to_its_own_part_keeps_its_result_when_another_part_moves() {
|
||||
let mut doc = Document::new("revolve to object");
|
||||
let (plate, other, sketch) = two_plates_and_a_sketch(&mut doc, "XZ", [Vec2::new(12.0, 0.0), Vec2::new(15.0, 2.0)]);
|
||||
let kind = FeatureKind::Revolve {
|
||||
sketch: SketchId(sketch),
|
||||
profiles: ProfileSel::All,
|
||||
axis: AxisRef::SketchAxis { axis: "y".into() },
|
||||
angle: "90 deg".into(),
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
angle2: None,
|
||||
symmetric: false,
|
||||
convention: Default::default(),
|
||||
to: Some(serde_json::from_value(serde_json::json!([5.0, 5.0, 4.0])).unwrap()),
|
||||
};
|
||||
let revolve = add_named(&mut doc, kind);
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some()));
|
||||
let turned = volume(&model, &model.state().bodies.iter().find(|b| b.feature == revolve).unwrap().name.clone());
|
||||
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.last_recomputed, 0);
|
||||
// The plate turned a quarter about Z: the revolve turns to its top where it is now.
|
||||
place(&mut doc, plate, rigid(Vec3::ZERO, Vec3::ZERO, Vec3::Z, std::f64::consts::FRAC_PI_2));
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.last_recomputed, 1);
|
||||
let now = volume(&model, &model.state().bodies.iter().find(|b| b.feature == revolve).unwrap().name.clone());
|
||||
assert!((now - turned).abs() > 1.0, "{turned} then {now}");
|
||||
}
|
||||
|
||||
/// A From face without a name is found by its point, on whatever body lies there: moving any
|
||||
/// part extrudes again.
|
||||
#[test]
|
||||
fn an_extrude_from_a_face_without_a_name_extrudes_again_when_any_part_moves() {
|
||||
let mut doc = Document::new("from a point");
|
||||
let (_, other, extrude) = extrude_from_the_plate(&mut doc);
|
||||
doc.feature_mut(extrude).unwrap().face_names.clear();
|
||||
let mut model = Model::new();
|
||||
model.evaluate(&doc);
|
||||
assert!((centroid_of(&model, extrude).z - 7.0).abs() < 1e-6, "{:?}", centroid_of(&model, extrude));
|
||||
place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 3.0), Vec3::ZERO, Vec3::Z, 0.0));
|
||||
model.evaluate(&doc);
|
||||
assert_eq!(model.last_recomputed, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_is_measured_once_while_it_stays_the_same() {
|
||||
let mut doc = Document::new("measured once");
|
||||
|
||||
@@ -1122,4 +1122,31 @@ mod tests {
|
||||
// The square stop leaves about half a pitch's worth of groove uncut: well over a mm³.
|
||||
assert!(square > trimmed + 1.0, "{square} vs {trimmed}");
|
||||
}
|
||||
|
||||
/// A modelled thread on one hole leaves the other hole's wall a cylinder to thread: the
|
||||
/// second hole still takes its thread (#11367).
|
||||
///
|
||||
/// The modelled thread cuts the body, and the cut must not take the body's other cylinders
|
||||
/// with it: the second hole's wall is still a cylinder, so it still takes a thread, modelled
|
||||
/// or cosmetic.
|
||||
#[test]
|
||||
fn a_modelled_thread_leaves_the_next_hole_a_cylinder_to_thread() {
|
||||
// An 80 x 80 x 20 mm block with two 5 mm holes.
|
||||
let block = kernel::box_solid(Vec3::new(-40.0, -40.0, 0.0), Vec3::new(40.0, 40.0, 20.0)).unwrap();
|
||||
let hole = |x: f64| kernel::cylinder(Vec3::new(x, 0.0, -1.0), Vec3::new(0.0, 0.0, 1.0), 2.5, 22.0).unwrap();
|
||||
let mut body = kernel::boolean(&block, &hole(-20.0), BoolOp::Cut).unwrap().unwrap();
|
||||
body = kernel::boolean(&body, &hole(20.0), BoolOp::Cut).unwrap().unwrap();
|
||||
let mut st = ModelState::default();
|
||||
st.bodies.push(ModelBody::new("Part".into(), body, 1));
|
||||
let ask = ThreadAsk { designation: Some("M6"), ..ask() };
|
||||
// The first hole takes a modelled M6 thread.
|
||||
let on = thread_on(&st, 1, Vec3::new(-17.5, 0.0, 10.0), &ask).unwrap();
|
||||
cut(&mut st, on, (ThreadTip::None, None, None)).unwrap();
|
||||
// The second hole's wall is still a cylinder: it takes a thread too.
|
||||
let on = thread_on(&st, 1, Vec3::new(22.5, 0.0, 10.0), &ask);
|
||||
assert!(on.is_ok(), "the second hole still has a cylinder to thread: {:?}", on.err());
|
||||
// And a modelled one cuts there as well.
|
||||
let on = thread_on(&st, 1, Vec3::new(22.5, 0.0, 10.0), &ask).unwrap();
|
||||
assert!(cut(&mut st, on, (ThreadTip::None, None, None)).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -982,6 +982,12 @@ fn call(f: &str, args: &[Value], d: Defaults) -> Result<Value, DocError> {
|
||||
// Round in the value's own default unit (round(2.4 in) = 2 in for an inch parameter).
|
||||
let scale = scale_bare(Value::num(1.0), a.len, a.ang, d);
|
||||
let x = if scale != 0.0 { a.v / scale } else { a.v };
|
||||
// Unit conversions leave a rounding error of an ulp or two (`3 in / 1 in` is
|
||||
// 2.9999999999999996): a value that close to a whole number is that number, so `floor`
|
||||
// does not drop to 2. The tolerance is relative to the whole number, so a tiny value
|
||||
// never snaps to 0, and it stays under 1/8 so no real fraction is absorbed.
|
||||
let nearest = x.round();
|
||||
let x = if (x - nearest).abs() <= (4.0 * f64::EPSILON * nearest.abs()).min(0.125) { nearest } else { x };
|
||||
let r = match f {
|
||||
"floor" => x.floor(),
|
||||
"ceil" => x.ceil(),
|
||||
@@ -1444,6 +1450,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `floor`, `ceil`, `trunc` and `round` work on the number a person typed, not on float noise:
|
||||
/// `3 in / 1 in` is 2.9999999999999996 in floats, and `floor` of it must still be 3.
|
||||
#[test]
|
||||
fn rounding_functions_ignore_float_noise() {
|
||||
for (e, want) in [("floor(3 in / 1 in)", 3.0), ("ceil(3 in / 1 in)", 3.0), ("trunc(3 in / 1 in)", 3.0), ("floor(-3 in / 1 in)", -3.0)] {
|
||||
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
|
||||
}
|
||||
let d = Defaults { len: 25.4, ang: std::f64::consts::PI / 180.0 };
|
||||
let look = |_: &str| Err(DocError::Expr("no parameters".into()));
|
||||
for e in ["floor(3 in)", "ceil(3 in)", "trunc(3 in)", "round(3 in)"] {
|
||||
let got = eval_with_defaults(e, &look, d).unwrap().to_kind_in(Kind::Length, d).unwrap();
|
||||
assert!((got - 76.2).abs() < 1e-9, "{e} gave {got}");
|
||||
}
|
||||
let got = eval_with_defaults("floor(30 deg)", &look, Defaults::default()).unwrap().to_kind(Kind::Angle).unwrap();
|
||||
assert!((got - 30f64.to_radians()).abs() < 1e-9, "floor(30 deg) gave {got}");
|
||||
// A real fraction still rounds the way its name says, however small, and large values keep working.
|
||||
for (e, want) in [
|
||||
("floor(2.9999)", 2.0),
|
||||
("ceil(3.0001)", 4.0),
|
||||
("floor(100.99999995)", 100.0),
|
||||
("ceil(100.00000005)", 101.0),
|
||||
("floor(1000000000.7)", 1_000_000_000.0),
|
||||
("ceil(1000000000.7)", 1_000_000_001.0),
|
||||
("trunc(1000000000.7)", 1_000_000_000.0),
|
||||
("round(1000000000.7)", 1_000_000_001.0),
|
||||
("ceil(1e-16)", 1.0),
|
||||
("floor(-1e-16)", -1.0),
|
||||
("trunc(1e-16)", 0.0),
|
||||
("trunc(-1e-16)", 0.0),
|
||||
("round(1e-16)", 0.0),
|
||||
("round(-1e-16)", 0.0),
|
||||
("floor(1000000000000000.5)", 1_000_000_000_000_000.0),
|
||||
] {
|
||||
assert_eq!(ev(e, Kind::Unitless).unwrap(), want, "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A count from a length (`(L - 20) / 210`, the 210 read as unit-less) names the fix (#5901).
|
||||
#[test]
|
||||
fn a_length_where_a_count_is_expected_says_to_divide_by_a_length() {
|
||||
|
||||
@@ -339,6 +339,27 @@ pub fn locate_face(st: &ModelState, name: &str, p: Vec3) -> Option<FaceHit> {
|
||||
locate_in(st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh()), name, p).ok()
|
||||
}
|
||||
|
||||
/// The one body of `st` with a face the stored face name finds, as [`locate_face`] looks for it.
|
||||
/// `None` when no body has one, several do, or the name is one saved before format 14.
|
||||
pub(crate) fn face_body<'a>(st: &'a ModelState, name: &str) -> Option<&'a ModelBody> {
|
||||
if let Some((body, _)) = crate::sheet::split_sheet_face_name(name) {
|
||||
return st.bodies.iter().find(|b| b.name == body && !b.body.is_mesh());
|
||||
}
|
||||
if name.is_empty() || legacy_name(name).is_some() {
|
||||
return None;
|
||||
}
|
||||
let solids = || st.bodies.iter().filter(|b| !b.body.is_mesh());
|
||||
naming::warm_names(solids());
|
||||
let mut holders = solids().filter(|b| {
|
||||
let names = naming::names(b);
|
||||
(0..names.faces.len()).any(|face| crate::history_names::matches(&names, face, name).is_some_and(exactly))
|
||||
});
|
||||
match (holders.next(), holders.next()) {
|
||||
(Some(b), None) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`locate_face`] for a feature that works on the faces of the body called `body` (a shell, a
|
||||
/// draft…): the face of that body the name finds, found without working out the names of every
|
||||
/// other body (a body named from geometry is meshed for it: seconds in a large model). When
|
||||
|
||||
@@ -120,6 +120,22 @@ pub fn profile_fallback_notes(ss: &SolvedSketch, selection: &ProfileSel) -> Vec<
|
||||
profile_selection_indices_lenient(ss, selection).map(|(_, notes)| notes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A curve that is a line, an arc or a circle, and not the output of a free-form offset.
|
||||
fn is_analytic(sketch: &solidcraft_sketch::Sketch, id: &str) -> bool {
|
||||
sketch.curves.iter().any(|curve| {
|
||||
curve.id == id
|
||||
&& matches!(
|
||||
curve.kind,
|
||||
solidcraft_sketch::CurveKind::Line { .. } | solidcraft_sketch::CurveKind::Arc { .. } | solidcraft_sketch::CurveKind::Circle { .. }
|
||||
)
|
||||
}) && !sketch.freeform_offsets.iter().any(|offset| offset.output == id)
|
||||
}
|
||||
|
||||
/// A loop made only of analytic curves: how many pieces each is cut into does not matter.
|
||||
fn is_analytic_loop(sketch: &solidcraft_sketch::Sketch, curves: &[String]) -> bool {
|
||||
!curves.is_empty() && curves.iter().all(|id| is_analytic(sketch, id))
|
||||
}
|
||||
|
||||
fn quoted(ids: &[String]) -> String {
|
||||
ids.iter().map(|id| format!("`{id}`")).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
@@ -135,17 +151,32 @@ fn quoted(ids: &[String]) -> String {
|
||||
/// them; a tie goes to the one with the fewest curves the loop did not have, and a tie left
|
||||
/// after that is not guessed.
|
||||
///
|
||||
/// Loops of offset curves and legacy graph-span multisets (a curve listed once per piece) keep
|
||||
/// their exact meaning and never fall back.
|
||||
/// Analytic loops (lines, arcs and circles only) may list a curve more than once, as the pieces a
|
||||
/// circle was cut into when the loop was saved; the repeats are one curve here, since a sketch
|
||||
/// edit changes how many pieces a curve has but not the region it bounds. The outputs of an
|
||||
/// analytic offset (`sketch.offsets`) take step 1 only: their loop closes on its own or the pick
|
||||
/// is lost, a partial match by identifiers never stands for it. Loops with free-form offset
|
||||
/// outputs, free curves (splines, ellipses, conics) and legacy graph-span multisets keep their
|
||||
/// exact meaning and never fall back.
|
||||
fn fallback_loop(ss: &SolvedSketch, curves: &[String]) -> Option<(Vec<usize>, String)> {
|
||||
let sketch = &ss.sketch;
|
||||
let analytic_loop = is_analytic_loop(sketch, curves);
|
||||
let deduped: Vec<String> = if analytic_loop {
|
||||
let mut seen = BTreeSet::new();
|
||||
curves.iter().filter(|id| seen.insert(id.as_str())).cloned().collect()
|
||||
} else {
|
||||
curves.to_vec()
|
||||
};
|
||||
let curves = deduped.as_slice();
|
||||
let stored: BTreeSet<&str> = curves.iter().map(String::as_str).collect();
|
||||
if stored.is_empty() || stored.len() != curves.len() || curves.len() > 512 || ss.profiles.len() > 10_000 {
|
||||
return None;
|
||||
}
|
||||
let sketch = &ss.sketch;
|
||||
let offset =
|
||||
|id: &str| sketch.offsets.iter().any(|offset| offset.owns_output(id)) || sketch.freeform_offsets.iter().any(|offset| offset.output == id);
|
||||
if stored.iter().any(|id| offset(id)) {
|
||||
if stored.iter().any(|id| sketch.freeform_offsets.iter().any(|offset| offset.output == *id)) {
|
||||
return None;
|
||||
}
|
||||
let from_offset = stored.iter().any(|id| sketch.offsets.iter().any(|offset| offset.owns_output(id)));
|
||||
if from_offset && !analytic_loop {
|
||||
return None;
|
||||
}
|
||||
let drawn = |id: &str| sketch.curves.iter().any(|curve| curve.id == id && !curve.construction && !curve.centerline);
|
||||
@@ -180,6 +211,9 @@ fn fallback_loop(ss: &SolvedSketch, curves: &[String]) -> Option<(Vec<usize>, St
|
||||
}
|
||||
}
|
||||
}
|
||||
if from_offset {
|
||||
return None;
|
||||
}
|
||||
// 2. The closest loop by the curves it shares.
|
||||
let count = |profile: &solidcraft_sketch::Profile, inside: bool| {
|
||||
profile.outer_curves.iter().map(String::as_str).filter(|id| stored.contains(id) == inside).collect::<BTreeSet<_>>().len()
|
||||
@@ -231,9 +265,12 @@ fn selection_indices(ss: &SolvedSketch, selection: &ProfileSel, mut notes: Optio
|
||||
}
|
||||
indices.push(unique_profile(ss, &ss.sketch.offset_boundary(id)?)?);
|
||||
} else {
|
||||
// Repeated saved IDs identify exact graph spans. Their multiplicity
|
||||
// must not silently turn into a whole-source certificate on resize.
|
||||
// Repeated saved IDs of free curves identify exact graph spans. Their
|
||||
// multiplicity must not silently turn into a whole-source certificate on
|
||||
// resize. Lines, arcs and circles are exempt: how many pieces such a curve is
|
||||
// cut into changes with every line drawn across it (#9368).
|
||||
if curves.iter().collect::<BTreeSet<_>>().len() != curves.len()
|
||||
&& !is_analytic_loop(&ss.sketch, curves)
|
||||
&& !ss.profiles.iter().any(|profile| same_ids(curves, &profile.outer_curves))
|
||||
{
|
||||
return Err(crate::curve_pick_error(solidcraft_sketch::CurvePick::Missing, curves, &ss.name));
|
||||
|
||||
+192
-16
@@ -912,7 +912,31 @@ pub fn refresh_face_anchor_links(
|
||||
sk: &mut Sketch,
|
||||
anchor: &crate::FaceAnchor,
|
||||
) -> Result<Vec<String>> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, Some(anchor))
|
||||
refresh_links_in(doc, vals, st, plane, sk, Some(anchor), LostNames::Fail)
|
||||
}
|
||||
|
||||
/// What a link kept by the name of a face or edge does when the name finds nothing any more.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum LostNames {
|
||||
/// The sketch fails, asking for the reference to be picked again ([`DocError::Reselect`]).
|
||||
Fail,
|
||||
/// The link is marked lost and keeps its last geometry, with a warning naming the reference.
|
||||
/// No other face or edge is taken for it. Only for showing a failed sketch while it is edited
|
||||
/// (#12085), never for the evaluation the features after it build from.
|
||||
Mark,
|
||||
}
|
||||
|
||||
/// [`refresh_links`] (or [`refresh_face_anchor_links`] with `anchor`) that marks a link whose
|
||||
/// named face or edge is gone lost instead of failing ([`LostNames::Mark`]).
|
||||
pub(crate) fn refresh_links_marking_lost(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: Option<&crate::FaceAnchor>,
|
||||
) -> Result<Vec<String>> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, anchor, LostNames::Mark)
|
||||
}
|
||||
|
||||
/// Remove degree-two straight stations introduced by coplanar face subdivision.
|
||||
@@ -989,7 +1013,7 @@ fn merge_family_lines(geom: Vec<LinkGeom>) -> Result<Vec<LinkGeom>> {
|
||||
/// longer finds exactly that face or edge is not lost: the sketch fails, asking for the
|
||||
/// reference to be picked again ([`DocError::Reselect`]).
|
||||
pub fn refresh_links(doc: &Document, vals: &BTreeMap<String, Value>, st: &ModelState, plane: &Plane, sk: &mut Sketch) -> Result<Vec<String>> {
|
||||
refresh_links_in(doc, vals, st, plane, sk, None)
|
||||
refresh_links_in(doc, vals, st, plane, sk, None, LostNames::Fail)
|
||||
}
|
||||
|
||||
/// Lay every text that runs along a curve of its sketch along that curve as the solve left it.
|
||||
@@ -1025,6 +1049,7 @@ fn refresh_links_in(
|
||||
plane: &Plane,
|
||||
sk: &mut Sketch,
|
||||
anchor: Option<&crate::FaceAnchor>,
|
||||
lost_names: LostNames,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut warn = Vec::new();
|
||||
let links = sk.links.clone();
|
||||
@@ -1034,16 +1059,17 @@ fn refresh_links_in(
|
||||
if !l.auto_face
|
||||
&& let (Some(name), LinkSource::Face { body, at }) = (&l.face_name, &mut source)
|
||||
{
|
||||
let (index, p) = st
|
||||
.body(body)
|
||||
.and_then(|target| {
|
||||
if l.kind == LinkKind::Project {
|
||||
crate::name_lookup::locate_projected_face(target, name, *at)
|
||||
} else {
|
||||
crate::naming::face_on_body(target, name, *at)
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| DocError::reselect(name.as_str()))?;
|
||||
let found = st.body(body).and_then(|target| {
|
||||
if l.kind == LinkKind::Project {
|
||||
crate::name_lookup::locate_projected_face(target, name, *at)
|
||||
} else {
|
||||
crate::naming::face_on_body(target, name, *at)
|
||||
}
|
||||
});
|
||||
let Some((index, p)) = found else {
|
||||
lose_named(sk, &l.id, name, lost_names, &mut warn)?;
|
||||
continue;
|
||||
};
|
||||
*at = p;
|
||||
named_face = Some((body.clone(), index, p));
|
||||
}
|
||||
@@ -1051,7 +1077,10 @@ fn refresh_links_in(
|
||||
// edge has the name any more no other edge is taken for it: the sketch fails.
|
||||
let mut named_edge = None;
|
||||
if let (Some(name), LinkSource::Edge { body, at }) = (&l.edge_name, &source) {
|
||||
let (body, hit) = locate_named_edge(st, body, name, *at).ok_or_else(|| DocError::reselect(name.as_str()))?;
|
||||
let Some((body, hit)) = locate_named_edge(st, body, name, *at) else {
|
||||
lose_named(sk, &l.id, name, lost_names, &mut warn)?;
|
||||
continue;
|
||||
};
|
||||
warn.extend(hit.note(name).map(|note| format!("projected {note}")));
|
||||
named_edge = Some((body, hit.edge, *at));
|
||||
}
|
||||
@@ -1119,6 +1148,19 @@ fn refresh_links_in(
|
||||
Ok(warn)
|
||||
}
|
||||
|
||||
/// A link whose face or edge `name` finds nothing: the sketch fails asking for it again, or (only
|
||||
/// while a failed sketch is shown for editing) the link is marked lost, with a warning naming it.
|
||||
fn lose_named(sk: &mut Sketch, id: &str, name: &str, lost_names: LostNames, warn: &mut Vec<String>) -> Result<()> {
|
||||
match lost_names {
|
||||
LostNames::Fail => Err(DocError::reselect(name)),
|
||||
LostNames::Mark => {
|
||||
sk.set_link_lost(id, true);
|
||||
warn.push(format!("projected reference lost: `{name}` no longer exists as it was picked; reselect it"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Model lookups
|
||||
|
||||
@@ -1612,8 +1654,7 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
|
||||
let size = m.bounds().diagonal().max(1e-9);
|
||||
let (segs, exact) = section_segments(plane, m, face);
|
||||
let segs: Vec<(Vec2, Vec2)> = segs.into_iter().map(|(a, b, _)| (a, b)).collect();
|
||||
let loose = size * 2e-3;
|
||||
let refine = |v: Vec<LinkGeom>| -> Vec<LinkGeom> { v.into_iter().map(|g| refine_round(g, &exact, loose)).collect() };
|
||||
let refine = |v: Vec<LinkGeom>| -> Vec<LinkGeom> { refine_rounds(v, &exact, size) };
|
||||
let lossy = refine(fit_segments(&segs, size, false));
|
||||
// Lines and arcs that follow the section closely (the corners of a rounded profile stay
|
||||
// where they are) are kept when they take no more curves than the loose fit.
|
||||
@@ -1627,7 +1668,57 @@ pub(crate) fn section(plane: &Plane, m: &Mesh, face: Option<u32>) -> Vec<LinkGeo
|
||||
let tight = size * 1e-4;
|
||||
let checkable = exact.len().saturating_mul(close.len()) <= 4_000_000;
|
||||
let strays = |v: &[LinkGeom]| exact.iter().any(|p| !v.iter().any(|g| plain_distance(g, *p).is_none_or(|d| d <= tight)));
|
||||
if plain(&close) && (close.len() <= lossy.len() || (checkable && strays(&lossy) && !strays(&close))) { close } else { lossy }
|
||||
let fitted = if plain(&close) && (close.len() <= lossy.len() || (checkable && strays(&lossy) && !strays(&close))) { close } else { lossy };
|
||||
join_rounds(fitted, size * 1e-5)
|
||||
}
|
||||
|
||||
/// Where the fit left a line and an arc ends apart by at most `tol`, both end at one point. A
|
||||
/// line can swallow an arc's first chord (a mesh crossing just past the tangent line), and
|
||||
/// [`refine_round`] moves an arc's ends but not the lines': the outline would not close.
|
||||
fn join_rounds(mut fitted: Vec<LinkGeom>, tol: f64) -> Vec<LinkGeom> {
|
||||
for i in 0..fitted.len() {
|
||||
let Some(&LinkGeom::Arc { c, mut a, mut b }) = fitted.get(i) else { continue };
|
||||
let r = c.dist(a);
|
||||
for end in [&mut a, &mut b] {
|
||||
for curve in &mut fitted {
|
||||
let LinkGeom::Line(p, q) = curve else { continue };
|
||||
let (near, far) = if p.dist(*end) <= q.dist(*end) { (p, q) } else { (q, p) };
|
||||
if near.dist(*end) <= tol {
|
||||
let joint = line_meets_round(*far, *near, c, r, tol);
|
||||
*near = joint;
|
||||
*end = joint;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(arc) = fitted.get_mut(i) {
|
||||
*arc = LinkGeom::Arc { c, a, b };
|
||||
}
|
||||
}
|
||||
fitted
|
||||
}
|
||||
|
||||
/// Where a line from `far` ending near `near` meets the circle (`c`, `r`): the tangent point when
|
||||
/// the line runs into the circle there, within `tol` of it over the arc's first chord, else its
|
||||
/// crossing nearest `near`, else the circle's point nearest `near`.
|
||||
fn line_meets_round(far: Vec2, near: Vec2, c: Vec2, r: f64, tol: f64) -> Vec2 {
|
||||
let nearest = |[x, y]: [Vec2; 2]| if x.dist(near) <= y.dist(near) { x } else { y };
|
||||
let to_centre = c - far;
|
||||
let span = to_centre.len();
|
||||
if let Some(unit) = to_centre.normalized().filter(|_| span > r) {
|
||||
let reach = (span * span - r * r).sqrt();
|
||||
let (along, side) = (unit * (reach * reach / span), unit.perp() * (reach * r / span));
|
||||
let tangent = nearest([far + along + side, far + along - side]);
|
||||
if tangent.dist(near) <= tol + (2.0 * r * tol).sqrt() {
|
||||
return tangent;
|
||||
}
|
||||
}
|
||||
let Some(dir) = (near - far).normalized() else { return near };
|
||||
let foot = far + dir * (c - far).dot(dir);
|
||||
let half = r * r - (foot - c).len2();
|
||||
if half >= 0.0 {
|
||||
return nearest([foot + dir * half.sqrt(), foot - dir * half.sqrt()]);
|
||||
}
|
||||
(near - c).normalized().map_or(near, |unit| c + unit * r)
|
||||
}
|
||||
|
||||
/// How far `p` lies from a line, arc or circle; None for any other curve.
|
||||
@@ -1785,6 +1876,91 @@ fn section_segments(plane: &Plane, m: &Mesh, face: Option<u32>) -> (Vec<(Vec2, V
|
||||
(segs, exact.into_inner())
|
||||
}
|
||||
|
||||
/// Re-fit each circle and arc of a fitted section through the exact section points near it.
|
||||
/// The outline stays closed: a line meeting an arc's end moves with it. Where two arcs meet,
|
||||
/// their refits stand only while their ends stay within the distance a profile joins ends
|
||||
/// across; else both keep their fits, since moving an arc's end would take it off its circle.
|
||||
fn refine_rounds(mut v: Vec<LinkGeom>, exact: &[Vec2], size: f64) -> Vec<LinkGeom> {
|
||||
// Ends this close meet: the fit joins crossings this close into one point.
|
||||
let meet = size * 1e-7;
|
||||
let ends = |h: &LinkGeom| match *h {
|
||||
LinkGeom::Arc { a, b, .. } => Some([a, b]),
|
||||
_ => None,
|
||||
};
|
||||
let fitted = v.clone();
|
||||
// Each arc's refit, and whether it stands.
|
||||
let refits: Vec<Option<LinkGeom>> =
|
||||
fitted.iter().map(|g| matches!(g, LinkGeom::Arc { .. }).then(|| refine_round(g.clone(), exact, size * 2e-3))).collect();
|
||||
let mut stands: Vec<bool> = refits.iter().map(Option::is_some).collect();
|
||||
// Where two arcs met as fitted: (arc, its end, other arc, its end).
|
||||
let mut joints = Vec::new();
|
||||
for (i, gi) in fitted.iter().enumerate() {
|
||||
for (j, gj) in fitted.iter().enumerate().skip(i + 1) {
|
||||
let (Some(ei), Some(ej)) = (ends(gi), ends(gj)) else { continue };
|
||||
for (ki, pi) in ei.iter().enumerate() {
|
||||
for (kj, pj) in ej.iter().enumerate() {
|
||||
if pi.dist(*pj) <= meet {
|
||||
joints.push((i, ki, j, kj));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Take back both refits at a joint they would tear, until no joint tears.
|
||||
let end_now = |stands: &[bool], i: usize, k: usize| {
|
||||
let g = if stands.get(i).copied().unwrap_or(false) { refits.get(i).and_then(Option::as_ref) } else { fitted.get(i) };
|
||||
g.and_then(ends).and_then(|e| e.get(k).copied())
|
||||
};
|
||||
let mut changed = true;
|
||||
while changed {
|
||||
changed = false;
|
||||
for &(i, ki, j, kj) in &joints {
|
||||
let (Some(p), Some(q)) = (end_now(&stands, i, ki), end_now(&stands, j, kj)) else { continue };
|
||||
if p.dist(q) > solidcraft_sketch::ENDPOINT_JOIN_TOL {
|
||||
for k in [i, j] {
|
||||
if let Some(s) = stands.get_mut(k).filter(|s| **s) {
|
||||
*s = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, g) in fitted.iter().enumerate() {
|
||||
match g {
|
||||
LinkGeom::Arc { a: a0, b: b0, .. } => {
|
||||
let (true, Some(refined @ LinkGeom::Arc { a: a1, b: b1, .. })) =
|
||||
(stands.get(i).copied().unwrap_or(false), refits.get(i).and_then(Option::as_ref))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for h in v.iter_mut() {
|
||||
if let LinkGeom::Line(s, t) = h {
|
||||
for end in [s, t] {
|
||||
if end.dist(*a0) <= meet {
|
||||
*end = *a1;
|
||||
} else if end.dist(*b0) <= meet {
|
||||
*end = *b1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(slot) = v.get_mut(i) {
|
||||
*slot = refined.clone();
|
||||
}
|
||||
}
|
||||
LinkGeom::Circle(..) => {
|
||||
if let Some(slot) = v.get_mut(i) {
|
||||
*slot = refine_round(g.clone(), exact, size * 2e-3);
|
||||
}
|
||||
}
|
||||
// Lines have moved with the arcs they meet; nothing else is refitted.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Re-fit a circle or arc through the exact section points near it, when there are enough.
|
||||
fn refine_round(g: LinkGeom, exact: &[Vec2], tol: f64) -> LinkGeom {
|
||||
let (c, r) = match g {
|
||||
|
||||
@@ -368,6 +368,133 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_refitted_to_the_exact_section_still_meets_its_neighbours() {
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_sketch::LinkGeom;
|
||||
let v = Vec2::new;
|
||||
// A 10 x 10 square with an R2 round at its top right corner, fitted from mesh crossings:
|
||||
// the arc misses the round's circle (centre (8, 8)) by 5e-4 but meets the sides exactly.
|
||||
// The exact crossings lie on the circle, and the refit puts the arc on it. The outline has
|
||||
// to stay closed: a profile joins ends only within 1e-4, so a wider gap leaves none (the
|
||||
// R2 rounds of a section at y = 20 through grooved walls, tangency-labs/solidcore#2908).
|
||||
let c = v(7.9995, 7.9995);
|
||||
let (p, q) = (v(10., c.y), v(c.x, 10.));
|
||||
let exact: Vec<Vec2> =
|
||||
(0..=8).map(|k| f64::from(k) * std::f64::consts::FRAC_PI_8 / 2.).map(|t| v(8. + 2. * t.cos(), 8. + 2. * t.sin())).collect();
|
||||
let square = |top: Vec<LinkGeom>| {
|
||||
let mut out = vec![LinkGeom::Line(v(0., 0.), v(10., 0.)), LinkGeom::Line(v(10., 0.), p), LinkGeom::Arc { c, a: p, b: q }];
|
||||
out.extend(top);
|
||||
out.push(LinkGeom::Line(v(0., 10.), v(0., 0.)));
|
||||
out
|
||||
};
|
||||
// The top side a line, or an arc of radius 10 (a shallow round on it) and then a line.
|
||||
let bulge = v(c.x, 20.);
|
||||
let from = bulge + v(-(10f64.to_radians().sin()), -(10f64.to_radians().cos())) * 10.;
|
||||
let cases =
|
||||
[square(vec![LinkGeom::Line(q, v(0., 10.))]), square(vec![LinkGeom::Arc { c: bulge, a: from, b: q }, LinkGeom::Line(from, v(0., 10.))])];
|
||||
// Ends as the sketch takes them: an arc ends on the circle through its start.
|
||||
let ends = |g: &LinkGeom| match *g {
|
||||
LinkGeom::Line(s, t) => vec![s, t],
|
||||
LinkGeom::Arc { c, a, b } => vec![a, c + (b - c).normalized().unwrap_or(Vec2::X) * c.dist(a)],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for (n, fitted) in cases.into_iter().enumerate() {
|
||||
// The size of the section: the square's diagonal.
|
||||
let out = super::super::refine_rounds(fitted, &exact, 200f64.sqrt());
|
||||
for (i, g) in out.iter().enumerate() {
|
||||
for e in ends(g) {
|
||||
let gap =
|
||||
out.iter().enumerate().filter(|(j, _)| *j != i).flat_map(|(_, h)| ends(h)).map(|x| x.dist(e)).fold(f64::INFINITY, f64::min);
|
||||
assert!(gap < 1e-9, "case {n}: gap {gap} at {e:?}: {out:?}");
|
||||
}
|
||||
}
|
||||
// Where the round meets only lines, the arc lies on the round's circle.
|
||||
if n == 0 {
|
||||
let Some(LinkGeom::Arc { c, a, b }) = out.get(2) else { panic!("{out:?}") };
|
||||
assert!(c.dist(v(8., 8.)) < 1e-9 && (c.dist(*a) - 2.).abs() < 1e-9 && (c.dist(*b) - 2.).abs() < 1e-9, "{out:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_beside_another_arc_is_refitted_when_their_joint_stays_put() {
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_sketch::LinkGeom;
|
||||
let v = Vec2::new;
|
||||
// The R2 round of a 10 x 10 square misses its circle (centre (8, 8)) by `s`, and an arc of
|
||||
// radius 10 runs on from its end. The refit moves that joint by `s`. While that is within
|
||||
// the 1e-4 a profile joins ends across, the round is refitted as before the fix for
|
||||
// tangency-labs/solidcore#2908, and the profile still closes.
|
||||
let exact: Vec<Vec2> =
|
||||
(0..=8).map(|k| f64::from(k) * std::f64::consts::FRAC_PI_8 / 2.).map(|t| v(8. + 2. * t.cos(), 8. + 2. * t.sin())).collect();
|
||||
for s in [1e-8, 1e-6, 1e-5, 5e-5, 9e-5] {
|
||||
let c = v(8., 8. + s);
|
||||
let (p, q) = (v(10., c.y), v(8., 10. + s));
|
||||
let bulge = v(8., 20. + s);
|
||||
let from = bulge + v(-(10f64.to_radians().sin()), -(10f64.to_radians().cos())) * 10.;
|
||||
let fitted = vec![
|
||||
LinkGeom::Line(v(0., 0.), v(10., 0.)),
|
||||
LinkGeom::Line(v(10., 0.), p),
|
||||
LinkGeom::Arc { c, a: p, b: q },
|
||||
LinkGeom::Arc { c: bulge, a: from, b: q },
|
||||
LinkGeom::Line(from, v(0., 10.)),
|
||||
LinkGeom::Line(v(0., 10.), v(0., 0.)),
|
||||
];
|
||||
let out = super::super::refine_rounds(fitted, &exact, 200f64.sqrt());
|
||||
let (Some(LinkGeom::Arc { c, b, .. }), Some(LinkGeom::Arc { b: joint, .. })) = (out.get(2), out.get(3)) else { panic!("{out:?}") };
|
||||
assert!(c.dist(v(8., 8.)) < 1e-12, "shift {s}: the round is refitted: {out:?}");
|
||||
assert!(b.dist(*joint) < 1e-4, "shift {s}: {out:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_arcs_refitted_the_same_way_keep_their_joint() {
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_sketch::LinkGeom;
|
||||
let v = Vec2::new;
|
||||
// The R2 round of a 10 x 10 square and the arc of radius 10 running on from it both miss
|
||||
// their circles (centres (8, 8) and (8, 20)) by `s` in the same direction. Both refits move
|
||||
// their joint from (8, 10 + s) to (8, 10), so the two refitted arcs still meet, as they do
|
||||
// before the fix for tangency-labs/solidcore#2908, even where `s` is past a profile's 1e-4.
|
||||
let round = (0..=8).map(|k| f64::from(k) * std::f64::consts::FRAC_PI_8 / 2.).map(|t| v(8. + 2. * t.cos(), 8. + 2. * t.sin()));
|
||||
let side = [261., 263., 265., 267., 269.].map(|d: f64| v(8. + 10. * d.to_radians().cos(), 20. + 10. * d.to_radians().sin()));
|
||||
let exact: Vec<Vec2> = round.chain(side).collect();
|
||||
for s in [1e-5, 2e-4, 5e-4] {
|
||||
let c = v(8., 8. + s);
|
||||
let (p, q) = (v(10., c.y), v(8., 10. + s));
|
||||
let bulge = v(8., 20. + s);
|
||||
let from = bulge + v(-(10f64.to_radians().sin()), -(10f64.to_radians().cos())) * 10.;
|
||||
let fitted = vec![
|
||||
LinkGeom::Line(v(0., 0.), v(10., 0.)),
|
||||
LinkGeom::Line(v(10., 0.), p),
|
||||
LinkGeom::Arc { c, a: p, b: q },
|
||||
LinkGeom::Arc { c: bulge, a: from, b: q },
|
||||
LinkGeom::Line(from, v(0., 10.)),
|
||||
LinkGeom::Line(v(0., 10.), v(0., 0.)),
|
||||
];
|
||||
let out = super::super::refine_rounds(fitted, &exact, 200f64.sqrt());
|
||||
let (Some(LinkGeom::Arc { c: c1, b: b1, .. }), Some(LinkGeom::Arc { c: c2, b: b2, .. })) = (out.get(2), out.get(3)) else {
|
||||
panic!("{out:?}")
|
||||
};
|
||||
assert!(c1.dist(v(8., 8.)) < 1e-9 && c2.dist(v(8., 20.)) < 1e-9, "shift {s}: both arcs are refitted: {out:?}");
|
||||
assert!(b1.dist(*b2) < 1e-4, "shift {s}: {out:?}");
|
||||
// The lines on the arcs' other ends move with them: the whole outline stays closed.
|
||||
let ends = |g: &LinkGeom| match *g {
|
||||
LinkGeom::Line(s, t) => vec![s, t],
|
||||
LinkGeom::Arc { c, a, b } => vec![a, c + (b - c).normalized().unwrap_or(Vec2::X) * c.dist(a)],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
for (i, g) in out.iter().enumerate() {
|
||||
for e in ends(g) {
|
||||
let gap =
|
||||
out.iter().enumerate().filter(|(j, _)| *j != i).flat_map(|(_, h)| ends(h)).map(|x| x.dist(e)).fold(f64::INFINITY, f64::min);
|
||||
assert!(gap < 1e-9, "shift {s}: gap {gap} at {e:?}: {out:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundaries_touching_at_a_vertex_project() {
|
||||
// Two squares of one face family meet only at the corner (1, 1): four boundary edges meet
|
||||
|
||||
@@ -871,6 +871,86 @@ fn a_join_keeps_blocks_apart_in_one_body() {
|
||||
assert_eq!(kept, None);
|
||||
}
|
||||
|
||||
/// A body of two pieces joined apart, split by a plane through both pieces: the split gives the
|
||||
/// halves of every piece, not the halves of one of them (#6738).
|
||||
#[test]
|
||||
fn a_split_of_a_body_of_two_pieces_gives_the_halves_of_both() {
|
||||
let mut doc = Document::new("split two pieces");
|
||||
// One sketch, two squares 20 mm across and 30 mm apart.
|
||||
let mut sk = Sketch::new();
|
||||
let mut corners = Vec::new();
|
||||
for x in [0.0, 50.0] {
|
||||
let p: Vec<usize> = [Vec2::new(x, 10.0), Vec2::new(x + 20.0, 10.0), Vec2::new(x + 20.0, 30.0), Vec2::new(x, 30.0)]
|
||||
.iter()
|
||||
.map(|c| sk.add_point(*c, None).unwrap())
|
||||
.collect();
|
||||
for i in 0..4 {
|
||||
sk.add_line_pts(p[i], p[(i + 1) % 4], None).unwrap();
|
||||
}
|
||||
corners.extend(p);
|
||||
}
|
||||
for p in corners {
|
||||
sk.add_constraint(ConstraintKind::Fix { p }, None).unwrap();
|
||||
}
|
||||
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Extrude {
|
||||
thin: None,
|
||||
sketch: crate::SketchId(s),
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent {
|
||||
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||
distance: "10".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
second_follows_first: false,
|
||||
taper: None,
|
||||
corner: Default::default(),
|
||||
to: None,
|
||||
to_offset: None,
|
||||
from: None,
|
||||
to_body_apart: false,
|
||||
},
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
doc.add_feature(
|
||||
FeatureKind::Combine { target: "Body1".into(), tools: vec!["Body2".into()], operation: Operation::Join, keep_tools: false },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
// A plane through the middle of both squares (the extrusion is 10 mm tall).
|
||||
let split = doc
|
||||
.add_feature(
|
||||
FeatureKind::Split {
|
||||
body: "Body1".into(),
|
||||
plane: PlaneRef::Offset { base: Box::new(PlaneRef::Origin { name: "XY".into() }), distance: "5".into() },
|
||||
tool: None,
|
||||
sketch: None,
|
||||
line: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Model::new();
|
||||
m.evaluate(&doc);
|
||||
let error = m.result(split).unwrap().error.clone();
|
||||
assert!(error.is_none(), "the split of a body of two pieces: {error:?}");
|
||||
let bodies: Vec<(String, f64, usize)> =
|
||||
m.state().bodies.iter().map(|b| (b.name.clone(), measure(&b.body).unwrap().volume, b.body.lumps().unwrap().len())).collect();
|
||||
assert_eq!(bodies.len(), 2, "{bodies:?}");
|
||||
for (name, volume, lumps) in &bodies {
|
||||
// Each half holds the half of both squares: two pieces, half the body each.
|
||||
assert!(rel(*volume, 4000.0) < 1e-6, "{name}: {bodies:?}");
|
||||
assert_eq!(*lumps, 2, "{name}: {bodies:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A square ring joined with an arm touching its outer corner along an edge and a hub apart in
|
||||
/// its hole: the ring and the hub stay one body under the ring's name, the arm is split off, and
|
||||
/// the join says it touched along an edge.
|
||||
|
||||
@@ -6,9 +6,9 @@ use crate::model::{Integrator, MAX_MAGNITUDE, MAX_STEPS, Model, Settings};
|
||||
use crate::system::System;
|
||||
use crate::{DynamicsError, Result, Sample, Stats, Trajectory};
|
||||
|
||||
/// Dormand–Prince 5(4) tableau (the system is autonomous, so the nodes are not needed): stage
|
||||
/// weights, fifth-order weights and the difference between the fifth- and fourth-order weights
|
||||
/// (the error estimate).
|
||||
/// Dormand–Prince 5(4) tableau: nodes, stage weights, fifth-order weights and the difference
|
||||
/// between the fifth- and fourth-order weights (the error estimate).
|
||||
const DP_C: [f64; 7] = [0.0, 1.0 / 5.0, 3.0 / 10.0, 4.0 / 5.0, 8.0 / 9.0, 1.0, 1.0];
|
||||
const DP_A: [[f64; 6]; 7] = [
|
||||
[0.0; 6],
|
||||
[1.0 / 5.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
||||
@@ -58,17 +58,18 @@ struct Run<'a> {
|
||||
}
|
||||
|
||||
impl Run<'_> {
|
||||
fn deriv(&self, y: &[f64]) -> Result<Vec<f64>> {
|
||||
self.sys.accelerations(y).map(|a| a.ydot)
|
||||
fn deriv(&self, t: f64, y: &[f64]) -> Result<Vec<f64>> {
|
||||
self.sys.accelerations(t, y).map(|a| a.ydot)
|
||||
}
|
||||
|
||||
/// Normalise, project and check a new state.
|
||||
fn finish_step(&mut self, y: &mut [f64], t: f64) -> Result<()> {
|
||||
self.sys.normalize(y).map_err(|e| at_time(e, t))?;
|
||||
if self.settings.projection {
|
||||
self.sys.project_positions(y, self.settings.projection_tol).map_err(|e| at_time(e, t))?;
|
||||
self.sys.project_velocities(y).map_err(|e| at_time(e, t))?;
|
||||
self.sys.project_positions(t, y, self.settings.projection_tol).map_err(|e| at_time(e, t))?;
|
||||
self.sys.project_velocities(t, y).map_err(|e| at_time(e, t))?;
|
||||
}
|
||||
self.sys.resync(y);
|
||||
if !y.iter().all(|x| x.is_finite() && x.abs() < MAX_MAGNITUDE) {
|
||||
return Err(DynamicsError::Diverged { time: t });
|
||||
}
|
||||
@@ -82,23 +83,23 @@ impl Run<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rk4(&mut self, y: &[f64], h: f64) -> Result<Vec<f64>> {
|
||||
let k1 = self.deriv(y)?;
|
||||
let k2 = self.deriv(&combine(y, std::slice::from_ref(&k1), &[0.5], h))?;
|
||||
let k3 = self.deriv(&combine(y, std::slice::from_ref(&k2), &[0.5], h))?;
|
||||
let k4 = self.deriv(&combine(y, std::slice::from_ref(&k3), &[1.0], h))?;
|
||||
fn rk4(&mut self, t: f64, y: &[f64], h: f64) -> Result<Vec<f64>> {
|
||||
let k1 = self.deriv(t, y)?;
|
||||
let k2 = self.deriv(t + 0.5 * h, &combine(y, std::slice::from_ref(&k1), &[0.5], h))?;
|
||||
let k3 = self.deriv(t + 0.5 * h, &combine(y, std::slice::from_ref(&k2), &[0.5], h))?;
|
||||
let k4 = self.deriv(t + h, &combine(y, std::slice::from_ref(&k3), &[1.0], h))?;
|
||||
Ok(combine(y, &[k1, k2, k3, k4], &[1.0 / 6.0, 1.0 / 3.0, 1.0 / 3.0, 1.0 / 6.0], h))
|
||||
}
|
||||
|
||||
/// One Dormand–Prince step: the fifth-order state and the scaled error norm.
|
||||
fn dopri(&mut self, y: &[f64], h: f64, rel_tol: f64, abs_tol: f64) -> Result<(Vec<f64>, f64)> {
|
||||
fn dopri(&mut self, t: f64, y: &[f64], h: f64, rel_tol: f64, abs_tol: f64) -> Result<(Vec<f64>, f64)> {
|
||||
let mut ks: Vec<Vec<f64>> = Vec::with_capacity(7);
|
||||
for a in DP_A.iter().take(6) {
|
||||
for (a, c) in DP_A.iter().zip(DP_C).take(6) {
|
||||
let stage = combine(y, &ks, a, h);
|
||||
ks.push(self.deriv(&stage)?);
|
||||
ks.push(self.deriv(t + c * h, &stage)?);
|
||||
}
|
||||
let y5 = combine(y, &ks, &DP_B, h);
|
||||
ks.push(self.deriv(&y5)?);
|
||||
ks.push(self.deriv(t + h, &y5)?);
|
||||
let err = combine(&vec![0.0; y.len()], &ks, &DP_E, h);
|
||||
let n = y.len().max(1) as f64;
|
||||
let sum: f64 = err
|
||||
@@ -123,7 +124,7 @@ impl Run<'_> {
|
||||
let h = span / n;
|
||||
for _ in 0..n as usize {
|
||||
self.count_step(*t)?;
|
||||
let mut next = self.rk4(y, h).map_err(|e| at_time(e, *t))?;
|
||||
let mut next = self.rk4(*t, y, h).map_err(|e| at_time(e, *t))?;
|
||||
*t += h;
|
||||
self.finish_step(&mut next, *t)?;
|
||||
*y = next;
|
||||
@@ -136,7 +137,7 @@ impl Run<'_> {
|
||||
self.count_step(*t)?;
|
||||
let remaining = t_end - *t;
|
||||
let h = h_next.clamp(min_step, max_step).min(remaining);
|
||||
let (mut next, err) = self.dopri(y, h, rel_tol, abs_tol).map_err(|e| at_time(e, *t))?;
|
||||
let (mut next, err) = self.dopri(*t, y, h, rel_tol, abs_tol).map_err(|e| at_time(e, *t))?;
|
||||
// The usual controller: 0.9·err^(−1/5), within [0.2, 5].
|
||||
let factor = if err > 0.0 { (0.9 * err.powf(-0.2)).clamp(0.2, 5.0) } else { 5.0 };
|
||||
if err <= 1.0 {
|
||||
@@ -164,11 +165,18 @@ impl Run<'_> {
|
||||
}
|
||||
|
||||
fn sample(&mut self, y: &[f64], t: f64) -> Result<Sample> {
|
||||
let acc = self.sys.accelerations(y).map_err(|e| at_time(e, t))?;
|
||||
let constraint_error = self.sys.constraint_error(y);
|
||||
let acc = self.sys.accelerations(t, y).map_err(|e| at_time(e, t))?;
|
||||
let constraint_error = self.sys.constraint_error(t, y);
|
||||
self.stats.max_constraint_error = self.stats.max_constraint_error.max(constraint_error);
|
||||
self.stats.redundant_equations = self.stats.redundant_equations.max(acc.row_count().saturating_sub(acc.rank));
|
||||
Ok(Sample { time: t, bodies: self.sys.body_states(y), joints: self.sys.joint_results(y, &acc), energy: self.sys.energy(y), constraint_error })
|
||||
Ok(Sample {
|
||||
time: t,
|
||||
bodies: self.sys.body_states(y),
|
||||
joints: self.sys.joint_results(y, &acc),
|
||||
drives: self.sys.drive_efforts(&acc),
|
||||
energy: self.sys.energy(y),
|
||||
constraint_error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,11 +185,12 @@ impl Run<'_> {
|
||||
pub fn simulate(model: &Model, settings: &Settings) -> Result<Trajectory> {
|
||||
settings.validate()?;
|
||||
let sys = System::new(model, settings.baumgarte)?;
|
||||
let mut y = System::initial_state(model)?;
|
||||
// An inconsistent start (frames a little apart, velocities the joints don't allow) is
|
||||
// brought onto the joints first, whether or not later steps project.
|
||||
sys.project_positions(&mut y, settings.projection_tol).map_err(|e| at_time(e, 0.0))?;
|
||||
sys.project_velocities(&mut y).map_err(|e| at_time(e, 0.0))?;
|
||||
let mut y = sys.initial_state(model)?;
|
||||
// An inconsistent start (frames a little apart, velocities the joints don't allow, drives
|
||||
// starting elsewhere) is brought onto the joints first, whether or not later steps project.
|
||||
sys.project_positions(0.0, &mut y, settings.projection_tol).map_err(|e| at_time(e, 0.0))?;
|
||||
sys.project_velocities(0.0, &mut y).map_err(|e| at_time(e, 0.0))?;
|
||||
sys.resync(&mut y);
|
||||
let mut run = Run { sys: &sys, settings, stats: Stats::default() };
|
||||
let count = (settings.duration / settings.output_interval).ceil() as usize;
|
||||
let mut samples = Vec::with_capacity(count + 1);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//!
|
||||
//! The crate knows nothing of documents or occurrences: a [`Model`] lists bodies (mass,
|
||||
//! inertia about the centre of mass, starting pose and velocities), joints between frames on
|
||||
//! them ([`JointKind`]: rigid, revolute, slider, cylindrical, planar, ball) and force elements;
|
||||
//! them ([`JointKind`]: rigid, revolute, slider, cylindrical, planar, ball), force elements
|
||||
//! (point and joint spring-dampers, motors along joint values, loads), drives that make a joint
|
||||
//! value follow a [`Profile`] in time, and motion links (gears, racks, screws) between values;
|
||||
//! [`simulate`] returns a [`Trajectory`] of poses, joint values and rates, joint reactions and
|
||||
//! energies at a fixed output interval. The assembly layer maps occurrences and joints to it.
|
||||
//!
|
||||
@@ -31,8 +33,8 @@ use solidcore_record::Vec3;
|
||||
pub use integrate::simulate;
|
||||
pub use math::{Mat3, Quat};
|
||||
pub use model::{
|
||||
Anchor, Body, Force, Integrator, Joint, JointKind, MAX_BODIES, MAX_CONSTRAINT_ROWS, MAX_FORCES, MAX_JOINTS, MAX_MAGNITUDE,
|
||||
MAX_PROJECTION_ITERATIONS, MAX_SAMPLES, MAX_STEPS, Marker, Model, STANDARD_GRAVITY, Settings,
|
||||
Anchor, Body, Coord, Drive, Force, Integrator, Joint, JointKind, Link, MAX_BODIES, MAX_CONSTRAINT_ROWS, MAX_FORCES, MAX_JOINTS, MAX_MAGNITUDE,
|
||||
MAX_PROFILE_POINTS, MAX_PROJECTION_ITERATIONS, MAX_SAMPLES, MAX_STEPS, Marker, Model, Profile, STANDARD_GRAVITY, Settings,
|
||||
};
|
||||
|
||||
/// Why a simulation could not run or stopped.
|
||||
@@ -90,14 +92,25 @@ pub struct Energy {
|
||||
pub kinetic: f64,
|
||||
/// Potential energy of gravity, zero at the world origin.
|
||||
pub gravity: f64,
|
||||
/// Energy stored in springs (preload included).
|
||||
/// Energy stored in springs, point and joint ones (preload included).
|
||||
pub spring: f64,
|
||||
/// Work put in since the start by motors, drives and loads.
|
||||
#[serde(default)]
|
||||
pub input: f64,
|
||||
/// Energy taken out since the start by dampers.
|
||||
#[serde(default)]
|
||||
pub dissipated: f64,
|
||||
}
|
||||
|
||||
impl Energy {
|
||||
/// Kinetic plus potential energy.
|
||||
pub fn total(&self) -> f64 {
|
||||
self.kinetic + self.gravity + self.spring
|
||||
}
|
||||
/// `total + dissipated − input`: constant along a run up to the integration error.
|
||||
pub fn balance(&self) -> f64 {
|
||||
self.total() + self.dissipated - self.input
|
||||
}
|
||||
}
|
||||
|
||||
/// The system at one output time.
|
||||
@@ -107,8 +120,12 @@ pub struct Sample {
|
||||
pub time: f64,
|
||||
pub bodies: Vec<BodyState>,
|
||||
pub joints: Vec<JointResult>,
|
||||
/// What each drive applies along its joint value to make it follow (mN·mm for an angle, mN
|
||||
/// for a distance), in [`Model::drives`] order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub drives: Vec<f64>,
|
||||
pub energy: Energy,
|
||||
/// The largest joint equation error (mm for points; directions are unitless).
|
||||
/// The largest constraint equation error (mm for points; directions are unitless).
|
||||
pub constraint_error: f64,
|
||||
}
|
||||
|
||||
|
||||
@@ -141,10 +141,172 @@ pub struct Joint {
|
||||
pub b: Marker,
|
||||
}
|
||||
|
||||
/// A force element.
|
||||
/// One value of a joint: `dof` indexes [`JointKind::values`]. Revolute, slider, cylindrical
|
||||
/// and planar joints have values that springs, motors, drives and links can act on; ball and
|
||||
/// rigid joints have none.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Coord {
|
||||
pub joint: usize,
|
||||
#[serde(default)]
|
||||
pub dof: usize,
|
||||
}
|
||||
|
||||
impl Coord {
|
||||
pub fn new(joint: usize, dof: usize) -> Coord {
|
||||
Coord { joint, dof }
|
||||
}
|
||||
}
|
||||
|
||||
/// Points a table may hold at most.
|
||||
pub const MAX_PROFILE_POINTS: usize = 10_000;
|
||||
|
||||
/// A value along time (s): a motor's torque or force, or the value a drive imposes (radians,
|
||||
/// mm).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Profile {
|
||||
Constant {
|
||||
value: f64,
|
||||
},
|
||||
/// `value + rate·t`.
|
||||
Linear {
|
||||
value: f64,
|
||||
rate: f64,
|
||||
},
|
||||
/// From `from` to `to` between `start` and `start + duration`, along a cycloidal step
|
||||
/// (`τ − sin(2πτ)/2π`) whose speed and acceleration both start and end at zero.
|
||||
Ramp {
|
||||
from: f64,
|
||||
to: f64,
|
||||
#[serde(default)]
|
||||
start: f64,
|
||||
duration: f64,
|
||||
},
|
||||
/// `offset + amplitude·sin(2π·frequency·t + phase)` (frequency in Hz).
|
||||
Harmonic {
|
||||
#[serde(default)]
|
||||
offset: f64,
|
||||
amplitude: f64,
|
||||
frequency: f64,
|
||||
#[serde(default)]
|
||||
phase: f64,
|
||||
},
|
||||
/// (time, value) points, times increasing: linear in between, held before the first and
|
||||
/// after the last. As a drive its speed jumps at the points (its acceleration is taken as 0).
|
||||
Table {
|
||||
points: Vec<(f64, f64)>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// The value and its first and second time derivatives at `t`.
|
||||
pub fn eval(&self, t: f64) -> (f64, f64, f64) {
|
||||
match self {
|
||||
Profile::Constant { value } => (*value, 0.0, 0.0),
|
||||
Profile::Linear { value, rate } => (value + rate * t, *rate, 0.0),
|
||||
Profile::Ramp { from, to, start, duration } => {
|
||||
let tau = (t - start) / duration;
|
||||
if !(tau > 0.0) {
|
||||
return (*from, 0.0, 0.0);
|
||||
}
|
||||
if tau >= 1.0 {
|
||||
return (*to, 0.0, 0.0);
|
||||
}
|
||||
let span = to - from;
|
||||
let (s, c) = (std::f64::consts::TAU * tau).sin_cos();
|
||||
(
|
||||
from + span * (tau - s / std::f64::consts::TAU),
|
||||
span / duration * (1.0 - c),
|
||||
span / (duration * duration) * std::f64::consts::TAU * s,
|
||||
)
|
||||
}
|
||||
Profile::Harmonic { offset, amplitude, frequency, phase } => {
|
||||
let w = std::f64::consts::TAU * frequency;
|
||||
let (s, c) = (w * t + phase).sin_cos();
|
||||
(offset + amplitude * s, amplitude * w * c, -amplitude * w * w * s)
|
||||
}
|
||||
Profile::Table { points } => {
|
||||
let (Some(first), Some(last)) = (points.first(), points.last()) else { return (0.0, 0.0, 0.0) };
|
||||
if t <= first.0 {
|
||||
return (first.1, 0.0, 0.0);
|
||||
}
|
||||
if t >= last.0 {
|
||||
return (last.1, 0.0, 0.0);
|
||||
}
|
||||
points
|
||||
.windows(2)
|
||||
.find_map(|w| {
|
||||
let (a, b) = (w.first()?, w.get(1)?);
|
||||
(t >= a.0 && t < b.0).then(|| {
|
||||
let slope = (b.1 - a.1) / (b.0 - a.0);
|
||||
(a.1 + slope * (t - a.0), slope, 0.0)
|
||||
})
|
||||
})
|
||||
.unwrap_or((last.1, 0.0, 0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(&self) -> std::result::Result<(), String> {
|
||||
let fin = |x: f64| x.is_finite() && x.abs() < MAX_MAGNITUDE;
|
||||
let ok = match self {
|
||||
Profile::Constant { value } => fin(*value),
|
||||
Profile::Linear { value, rate } => fin(*value) && fin(*rate),
|
||||
Profile::Ramp { from, to, start, duration } => fin(*from) && fin(*to) && fin(*start) && fin(*duration) && *duration > 1e-9,
|
||||
Profile::Harmonic { offset, amplitude, frequency, phase } => {
|
||||
fin(*offset) && fin(*amplitude) && fin(*frequency) && fin(*phase) && frequency.abs() < 1e6
|
||||
}
|
||||
Profile::Table { points } => {
|
||||
if points.is_empty() || points.len() > MAX_PROFILE_POINTS {
|
||||
return Err(format!("a table needs 1 to {MAX_PROFILE_POINTS} points"));
|
||||
}
|
||||
points.iter().all(|(t, v)| fin(*t) && fin(*v))
|
||||
&& points.windows(2).all(|w| matches!((w.first(), w.get(1)), (Some(a), Some(b)) if b.0 > a.0))
|
||||
}
|
||||
};
|
||||
if ok { Ok(()) } else { Err("a profile's numbers must be finite (ramps need a duration, tables increasing times)".into()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// A joint value made to follow a profile along time (radians or mm): the joint turns or
|
||||
/// slides as told, whatever it takes; [`crate::Sample::drives`] reports what it takes.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Drive {
|
||||
pub at: Coord,
|
||||
pub motion: Profile,
|
||||
}
|
||||
|
||||
/// A motion link: `follower = ratio·driver + offset`, between values of two joints (a gear
|
||||
/// pair: angle to angle; a rack and pinion: angle to distance, the ratio being the pinion's
|
||||
/// radius; a screw: angle to distance along the same cylindrical joint, the ratio being
|
||||
/// pitch / 2π). Without an offset, the one that holds at the start is kept.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Link {
|
||||
pub driver: Coord,
|
||||
pub follower: Coord,
|
||||
pub ratio: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub offset: Option<f64>,
|
||||
}
|
||||
|
||||
/// A force element.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Force {
|
||||
/// A spring and damper on a joint value: the joint is pushed back by
|
||||
/// `stiffness·(value − rest) + damping·rate` (torsional for an angle: mN·mm per radian and
|
||||
/// mN·mm·s; linear for a distance: kg/s² and kg/s).
|
||||
JointSpring {
|
||||
at: Coord,
|
||||
stiffness: f64,
|
||||
#[serde(default)]
|
||||
damping: f64,
|
||||
#[serde(default)]
|
||||
rest: f64,
|
||||
},
|
||||
/// A motor's torque (mN·mm) or force (mN) along a joint value, following a profile: it
|
||||
/// pushes B's side forward and A's side back.
|
||||
Motor { at: Coord, effort: Profile },
|
||||
/// A linear spring and damper between two points: the tension is
|
||||
/// `stiffness·(length − free_length) + damping·(rate of length) + preload`.
|
||||
Spring {
|
||||
@@ -243,6 +405,10 @@ pub struct Model {
|
||||
pub joints: Vec<Joint>,
|
||||
#[serde(default)]
|
||||
pub forces: Vec<Force>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub drives: Vec<Drive>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub links: Vec<Link>,
|
||||
/// Gravity (mm/s²), e.g. `(0, 0, −9806.65)`.
|
||||
#[serde(default)]
|
||||
pub gravity: Vec3,
|
||||
@@ -282,7 +448,10 @@ impl Model {
|
||||
if self.forces.len() > MAX_FORCES {
|
||||
return Err(DynamicsError::TooLarge(format!("{} force elements (at most {MAX_FORCES})", self.forces.len())));
|
||||
}
|
||||
let rows: usize = self.joints.iter().map(|j| j.kind.equations()).sum();
|
||||
if self.drives.len() > MAX_JOINTS || self.links.len() > MAX_JOINTS {
|
||||
return Err(DynamicsError::TooLarge(format!("drives and links: at most {MAX_JOINTS} each")));
|
||||
}
|
||||
let rows: usize = self.joints.iter().map(|j| j.kind.equations()).sum::<usize>() + self.drives.len() + self.links.len();
|
||||
if rows > MAX_CONSTRAINT_ROWS {
|
||||
return Err(DynamicsError::TooLarge(format!("{rows} constraint equations (at most {MAX_CONSTRAINT_ROWS})")));
|
||||
}
|
||||
@@ -339,8 +508,46 @@ impl Model {
|
||||
return Err(bad(format!("force {i}: point, force and torque must be finite")));
|
||||
}
|
||||
}
|
||||
Force::JointSpring { at, stiffness, damping, rest } => {
|
||||
self.check_coord(*at).map_err(|e| bad(format!("force {i}: {e}")))?;
|
||||
let ok = |x: f64| (0.0..MAX_MAGNITUDE).contains(&x);
|
||||
if !ok(*stiffness) || !ok(*damping) || !rest.is_finite() || rest.abs() >= MAX_MAGNITUDE {
|
||||
return Err(bad(format!("force {i}: stiffness and damping must be ≥ 0 and finite, rest finite")));
|
||||
}
|
||||
}
|
||||
Force::Motor { at, effort } => {
|
||||
self.check_coord(*at).map_err(|e| bad(format!("force {i}: {e}")))?;
|
||||
effort.validate().map_err(|e| bad(format!("force {i}: {e}")))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, d) in self.drives.iter().enumerate() {
|
||||
self.check_coord(d.at).map_err(|e| bad(format!("drive {i}: {e}")))?;
|
||||
d.motion.validate().map_err(|e| bad(format!("drive {i}: {e}")))?;
|
||||
}
|
||||
for (i, l) in self.links.iter().enumerate() {
|
||||
self.check_coord(l.driver).map_err(|e| bad(format!("link {i}: {e}")))?;
|
||||
self.check_coord(l.follower).map_err(|e| bad(format!("link {i}: {e}")))?;
|
||||
if l.driver == l.follower {
|
||||
return Err(bad(format!("link {i}: a value can't drive itself")));
|
||||
}
|
||||
let fin = |x: f64| x.is_finite() && x.abs() < MAX_MAGNITUDE;
|
||||
if !fin(l.ratio) || !l.offset.is_none_or(fin) {
|
||||
return Err(bad(format!("link {i}: ratio and offset must be finite")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The joint and value a [`Coord`] names, if they exist and can be acted on.
|
||||
fn check_coord(&self, c: Coord) -> std::result::Result<(), String> {
|
||||
let j = self.joints.get(c.joint).ok_or_else(|| format!("no joint {}", c.joint))?;
|
||||
if matches!(j.kind, JointKind::Ball | JointKind::Rigid) {
|
||||
return Err(format!("joint {}: not supported yet: springs, motors, drives and links on {:?} joints", c.joint, j.kind));
|
||||
}
|
||||
if c.dof >= j.kind.values().len() {
|
||||
return Err(format!("joint {} has no value {}", c.joint, c.dof));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+306
-40
@@ -12,13 +12,20 @@
|
||||
//!
|
||||
//! through the Schur complement `(J M⁻¹ Jᵀ) λ = J M⁻¹ F − γ` (`linalg::solve_psd`, which takes
|
||||
//! repeated joint equations), and `λ` gives the joints' reactions.
|
||||
//!
|
||||
//! Joint springs and motors act along joint values (generalized forces `Jᵀ Q` along a value's
|
||||
//! row); drives add a rheonomic equation `value − f(t) = 0` (so `J u = ḟ`, `J u̇ = f̈ − J̇ u`),
|
||||
//! and motion links a linear one between two values. The values they use are also kept in the
|
||||
//! state, so an angle counts whole turns.
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
use crate::linalg::{Sym, solve_psd};
|
||||
use crate::math::{Mat3, Quat, cross2};
|
||||
use crate::model::{Force, JointKind, MAX_MAGNITUDE, MAX_PROJECTION_ITERATIONS, Model, marker_axes};
|
||||
use crate::model::{Coord, Force, JointKind, MAX_MAGNITUDE, MAX_PROJECTION_ITERATIONS, Model, Profile, marker_axes};
|
||||
use crate::{BodyState, DynamicsError, Energy, JointResult, Result};
|
||||
|
||||
/// Numbers per body in the flat state: x (3), q (4), v (3), ω (3).
|
||||
@@ -97,18 +104,32 @@ struct MarkerWorld {
|
||||
/// A body's part of one Jacobian row: (linear, angular).
|
||||
type Block = (usize, Vec3, Vec3);
|
||||
|
||||
/// One constraint equation at a state.
|
||||
/// One constraint equation at a state: `Φ = 0`, `J u = rate_target`.
|
||||
pub(crate) struct Row {
|
||||
/// A's and B's parts (for a joint's own equations, its two sides).
|
||||
a: Option<Block>,
|
||||
b: Option<Block>,
|
||||
/// A motion link's driver's parts.
|
||||
c: Option<Block>,
|
||||
d: Option<Block>,
|
||||
pub(crate) phi: f64,
|
||||
/// `J̇ u`: the equation's second derivative at zero accelerations.
|
||||
/// `J̇ u` less the imposed acceleration: the equation's second derivative at zero
|
||||
/// accelerations.
|
||||
jdot_u: f64,
|
||||
/// The rate a drive imposes (`ḟ`); 0 for the others.
|
||||
rate_target: f64,
|
||||
}
|
||||
|
||||
fn scaled(b: Option<Block>, k: f64) -> Option<Block> {
|
||||
b.map(|(i, l, a)| (i, l * k, a * k))
|
||||
}
|
||||
|
||||
impl Row {
|
||||
fn new(a: Option<Block>, b: Option<Block>, phi: f64, jdot_u: f64) -> Row {
|
||||
Row { a, b, c: None, d: None, phi, jdot_u, rate_target: 0.0 }
|
||||
}
|
||||
fn blocks(&self) -> impl Iterator<Item = &Block> {
|
||||
self.a.iter().chain(self.b.iter())
|
||||
self.a.iter().chain(self.b.iter()).chain(self.c.iter()).chain(self.d.iter())
|
||||
}
|
||||
/// `J u`.
|
||||
fn rate(&self, kin: &[Kin]) -> f64 {
|
||||
@@ -116,13 +137,58 @@ impl Row {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a joint value is measured.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum CoordKind {
|
||||
/// B's turn about A's z (from A's x towards A's y).
|
||||
Angle,
|
||||
/// B's origin along one of A's axes (0, 1, 2 = x, y, z).
|
||||
Along(usize),
|
||||
}
|
||||
|
||||
/// A joint value springs, motors, drives or links act on. Its value is kept in the state too,
|
||||
/// so an angle counts whole turns.
|
||||
#[derive(Clone, Copy)]
|
||||
struct CoordConst {
|
||||
joint: usize,
|
||||
kind: CoordKind,
|
||||
}
|
||||
|
||||
struct LinkConst {
|
||||
driver: usize,
|
||||
follower: usize,
|
||||
ratio: f64,
|
||||
offset: f64,
|
||||
}
|
||||
|
||||
/// A model ready to integrate.
|
||||
///
|
||||
/// The flat state holds every body (`STRIDE` numbers each), then the tracked joint values,
|
||||
/// then the work put in (motors, drives, loads) and the energy dissipated (dampers).
|
||||
pub(crate) struct System {
|
||||
bodies: Vec<BodyConst>,
|
||||
joints: Vec<JointConst>,
|
||||
/// Point springs and loads.
|
||||
forces: Vec<Force>,
|
||||
gravity: Vec3,
|
||||
baumgarte: Option<(f64, f64)>,
|
||||
coords: Vec<CoordConst>,
|
||||
/// (value, stiffness, damping, rest).
|
||||
springs: Vec<(usize, f64, f64, f64)>,
|
||||
/// (value, effort).
|
||||
motors: Vec<(usize, Profile)>,
|
||||
/// (value, motion).
|
||||
drives: Vec<(usize, Profile)>,
|
||||
links: Vec<LinkConst>,
|
||||
}
|
||||
|
||||
/// `θ` turned by whole turns to lie nearest `near`.
|
||||
fn unwrap_angle(theta: f64, near: f64) -> f64 {
|
||||
if !near.is_finite() {
|
||||
return theta;
|
||||
}
|
||||
let turns = ((near - theta) / TAU).round();
|
||||
if turns.is_finite() { theta + TAU * turns } else { theta }
|
||||
}
|
||||
|
||||
/// Accelerations and multipliers at one state.
|
||||
@@ -164,12 +230,81 @@ impl System {
|
||||
.iter()
|
||||
.map(|j| Ok(JointConst { kind: j.kind, a: marker_const(&j.a)?, b: marker_const(&j.b)? }))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(System { bodies, joints, forces: model.forces.clone(), gravity: model.gravity, baumgarte })
|
||||
let mut sys = System {
|
||||
bodies,
|
||||
joints,
|
||||
forces: model.forces.iter().filter(|f| matches!(f, Force::Spring { .. } | Force::Load { .. })).cloned().collect(),
|
||||
gravity: model.gravity,
|
||||
baumgarte,
|
||||
coords: Vec::new(),
|
||||
springs: Vec::new(),
|
||||
motors: Vec::new(),
|
||||
drives: Vec::new(),
|
||||
links: Vec::new(),
|
||||
};
|
||||
let mut found: Vec<Coord> = Vec::new();
|
||||
let mut index = |c: Coord, sys: &mut System| -> Result<usize> {
|
||||
if let Some(i) = found.iter().position(|f| *f == c) {
|
||||
return Ok(i);
|
||||
}
|
||||
let kind = model.joints.get(c.joint).map(|j| j.kind);
|
||||
let kind = match (kind, c.dof) {
|
||||
(Some(JointKind::Revolute | JointKind::Cylindrical), 0) | (Some(JointKind::Planar), 2) => CoordKind::Angle,
|
||||
(Some(JointKind::Slider), 0) | (Some(JointKind::Cylindrical), 1) => CoordKind::Along(2),
|
||||
(Some(JointKind::Planar), d @ (0 | 1)) => CoordKind::Along(d),
|
||||
_ => return Err(DynamicsError::InvalidModel(format!("joint {} has no value {} to act on", c.joint, c.dof))),
|
||||
};
|
||||
found.push(c);
|
||||
sys.coords.push(CoordConst { joint: c.joint, kind });
|
||||
Ok(found.len() - 1)
|
||||
};
|
||||
for f in &model.forces {
|
||||
match f {
|
||||
Force::JointSpring { at, stiffness, damping, rest } => {
|
||||
let i = index(*at, &mut sys)?;
|
||||
sys.springs.push((i, *stiffness, *damping, *rest));
|
||||
}
|
||||
Force::Motor { at, effort } => {
|
||||
let i = index(*at, &mut sys)?;
|
||||
sys.motors.push((i, effort.clone()));
|
||||
}
|
||||
Force::Spring { .. } | Force::Load { .. } => {}
|
||||
}
|
||||
}
|
||||
for d in &model.drives {
|
||||
let i = index(d.at, &mut sys)?;
|
||||
sys.drives.push((i, d.motion.clone()));
|
||||
}
|
||||
let mut pending = Vec::new();
|
||||
for l in &model.links {
|
||||
pending.push((index(l.driver, &mut sys)?, index(l.follower, &mut sys)?, l.ratio, l.offset));
|
||||
}
|
||||
// A link without an offset keeps the one that holds at the start.
|
||||
let y = sys.initial_state(model)?;
|
||||
let kin = sys.kinematics(&y);
|
||||
let value = |i: usize| sys.coord_row(&kin, &y, i).phi;
|
||||
let links = pending
|
||||
.into_iter()
|
||||
.map(|(d, f, ratio, offset)| LinkConst { driver: d, follower: f, ratio, offset: offset.unwrap_or_else(|| value(f) - ratio * value(d)) })
|
||||
.collect();
|
||||
sys.links = links;
|
||||
Ok(sys)
|
||||
}
|
||||
|
||||
fn coord_base(&self) -> usize {
|
||||
self.bodies.len() * STRIDE
|
||||
}
|
||||
fn work_base(&self) -> usize {
|
||||
self.coord_base() + self.coords.len()
|
||||
}
|
||||
/// Numbers in the flat state.
|
||||
pub(crate) fn state_len(&self) -> usize {
|
||||
self.work_base() + 2
|
||||
}
|
||||
|
||||
/// The flat state of the model's starting conditions.
|
||||
pub(crate) fn initial_state(model: &Model) -> Result<Vec<f64>> {
|
||||
let mut y = vec![0.0; model.bodies.len() * STRIDE];
|
||||
pub(crate) fn initial_state(&self, model: &Model) -> Result<Vec<f64>> {
|
||||
let mut y = vec![0.0; self.state_len()];
|
||||
for (i, b) in model.bodies.iter().enumerate() {
|
||||
let at = i * STRIDE;
|
||||
let q = b.orientation.normalized().ok_or_else(|| DynamicsError::InvalidModel(format!("body {i}: orientation is not a rotation")))?;
|
||||
@@ -178,9 +313,48 @@ impl System {
|
||||
put3(&mut y, at + 7, b.velocity);
|
||||
put3(&mut y, at + 10, b.angular_velocity);
|
||||
}
|
||||
// A driven angle counts its turns from where its motion starts.
|
||||
for (i, motion) in &self.drives {
|
||||
if let Some(s) = y.get_mut(self.coord_base() + i) {
|
||||
*s = motion.eval(0.0).0;
|
||||
}
|
||||
}
|
||||
self.resync(&mut y);
|
||||
Ok(y)
|
||||
}
|
||||
|
||||
/// Sets each tracked joint value to the one the bodies show (an angle: the turn nearest
|
||||
/// the tracked one).
|
||||
pub(crate) fn resync(&self, y: &mut [f64]) {
|
||||
let kin = self.kinematics(y);
|
||||
let values: Vec<f64> = (0..self.coords.len()).map(|i| self.coord_row(&kin, y, i).phi).collect();
|
||||
for (i, v) in values.into_iter().enumerate() {
|
||||
if let Some(s) = y.get_mut(self.coord_base() + i) {
|
||||
*s = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tracked joint value's equation row: `Φ` is the value itself, `J u` its rate.
|
||||
fn coord_row(&self, kin: &[Kin], y: &[f64], i: usize) -> Row {
|
||||
let (Some(c), tracked) = (self.coords.get(i), y.get(self.coord_base() + i).copied().unwrap_or(f64::NAN)) else {
|
||||
return Row::new(None, None, 0.0, 0.0);
|
||||
};
|
||||
let Some(j) = self.joints.get(c.joint) else { return Row::new(None, None, 0.0, 0.0) };
|
||||
let a = Self::marker_world(kin, &j.a);
|
||||
let b = Self::marker_world(kin, &j.b);
|
||||
let [ax, ay, az] = a.axes;
|
||||
match c.kind {
|
||||
CoordKind::Angle => {
|
||||
let bx = b.axes[0];
|
||||
let theta = unwrap_angle(bx.dot(ay).atan2(bx.dot(ax)), tracked);
|
||||
// θ̇ = (ω_B − ω_A)·z_A; at zero accelerations its rate is (ω_B − ω_A)·(ω_A × z_A).
|
||||
Row::new(a.body.map(|k| (k, Vec3::ZERO, -az)), b.body.map(|k| (k, Vec3::ZERO, az)), theta, (b.w - a.w).dot(a.w.cross(az)))
|
||||
}
|
||||
CoordKind::Along(k) => dot2_row(&a, a.axes.get(k).copied().unwrap_or(az), &b),
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit quaternions again after a step.
|
||||
pub(crate) fn normalize(&self, y: &mut [f64]) -> Result<()> {
|
||||
for i in 0..self.bodies.len() {
|
||||
@@ -210,17 +384,42 @@ impl System {
|
||||
MarkerWorld { body: m.body, r, p: k.x + r, pdot: k.v + k.w.cross(r), axes, w: k.w }
|
||||
}
|
||||
|
||||
/// The constraint equations of every joint at `kin`, in joint order.
|
||||
fn rows(&self, kin: &[Kin]) -> Vec<Row> {
|
||||
/// The constraint equations at time `t`: every joint's in joint order, then one per drive
|
||||
/// (`value − f(t) = 0`), then one per link (`follower − ratio·driver − offset = 0`).
|
||||
fn rows(&self, kin: &[Kin], t: f64, y: &[f64]) -> Vec<Row> {
|
||||
let mut rows = Vec::new();
|
||||
for j in &self.joints {
|
||||
let a = Self::marker_world(kin, &j.a);
|
||||
let b = Self::marker_world(kin, &j.b);
|
||||
joint_rows(j.kind, &a, &b, &mut rows);
|
||||
}
|
||||
for (i, motion) in &self.drives {
|
||||
let (f, fd, fdd) = motion.eval(t);
|
||||
let mut r = self.coord_row(kin, y, *i);
|
||||
r.phi -= f;
|
||||
r.jdot_u -= fdd;
|
||||
r.rate_target = fd;
|
||||
rows.push(r);
|
||||
}
|
||||
for l in &self.links {
|
||||
let (f, d) = (self.coord_row(kin, y, l.follower), self.coord_row(kin, y, l.driver));
|
||||
rows.push(Row {
|
||||
a: f.a,
|
||||
b: f.b,
|
||||
c: scaled(d.a, -l.ratio),
|
||||
d: scaled(d.b, -l.ratio),
|
||||
phi: f.phi - l.ratio * d.phi - l.offset,
|
||||
jdot_u: f.jdot_u - l.ratio * d.jdot_u,
|
||||
rate_target: 0.0,
|
||||
});
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn joint_row_count(&self) -> usize {
|
||||
self.joints.iter().map(|j| j.kind.equations()).sum()
|
||||
}
|
||||
|
||||
/// `M⁻¹` applied to one body's (force, torque).
|
||||
fn minv(&self, kin: &[Kin], body: usize, f: Vec3, t: Vec3) -> (Vec3, Vec3) {
|
||||
match (self.bodies.get(body), kin.get(body)) {
|
||||
@@ -264,8 +463,38 @@ impl System {
|
||||
(s, w)
|
||||
}
|
||||
|
||||
/// Applied forces and torques (about each centre of mass, world), gyroscopic terms included.
|
||||
fn applied(&self, kin: &[Kin]) -> Vec<(Vec3, Vec3)> {
|
||||
/// Applied forces and torques (about each centre of mass, world), gyroscopic terms included,
|
||||
/// and the power they put in and the power dampers take out.
|
||||
fn applied(&self, kin: &[Kin], t: f64, y: &[f64]) -> (Vec<(Vec3, Vec3)>, f64, f64) {
|
||||
let mut input = 0.0;
|
||||
let mut dissipated = 0.0;
|
||||
let mut out = self.point_forces(kin, &mut input, &mut dissipated);
|
||||
// A generalized force Q along a joint value is Jᵀ Q on the bodies.
|
||||
let mut along = |row: &Row, q: f64| {
|
||||
for (i, l, a) in row.blocks() {
|
||||
if let Some(slot) = out.get_mut(*i) {
|
||||
slot.0 += *l * q;
|
||||
slot.1 += *a * q;
|
||||
}
|
||||
}
|
||||
};
|
||||
for (i, k, c, rest) in &self.springs {
|
||||
let row = self.coord_row(kin, y, *i);
|
||||
let rate = row.rate(kin);
|
||||
along(&row, -k * (row.phi - rest) - c * rate);
|
||||
dissipated += c * rate * rate;
|
||||
}
|
||||
for (i, effort) in &self.motors {
|
||||
let row = self.coord_row(kin, y, *i);
|
||||
let q = effort.eval(t).0;
|
||||
along(&row, q);
|
||||
input += q * row.rate(kin);
|
||||
}
|
||||
(out, input, dissipated)
|
||||
}
|
||||
|
||||
/// Gravity, gyroscopic terms, point springs and loads.
|
||||
fn point_forces(&self, kin: &[Kin], input: &mut f64, dissipated: &mut f64) -> Vec<(Vec3, Vec3)> {
|
||||
let mut out: Vec<(Vec3, Vec3)> = self
|
||||
.bodies
|
||||
.iter()
|
||||
@@ -293,21 +522,25 @@ impl System {
|
||||
let tension = stiffness * (len - free_length) + damping * rate + preload;
|
||||
add(a.body, ra, n * tension, Vec3::ZERO);
|
||||
add(b.body, rb, n * -tension, Vec3::ZERO);
|
||||
*dissipated += damping * rate * rate;
|
||||
}
|
||||
Force::Load { body, point, force, torque } => {
|
||||
let r = kin.get(body).map_or(Vec3::ZERO, |k| k.rot.mul_vec(point));
|
||||
let k = kin.get(body).copied().unwrap_or(GROUND);
|
||||
let r = k.rot.mul_vec(point);
|
||||
add(Some(body), r, force, torque);
|
||||
*input += force.dot(k.v + k.w.cross(r)) + torque.dot(k.w);
|
||||
}
|
||||
Force::JointSpring { .. } | Force::Motor { .. } => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The state's derivative and the joints' multipliers.
|
||||
pub(crate) fn accelerations(&self, y: &[f64]) -> Result<Accel> {
|
||||
pub(crate) fn accelerations(&self, t: f64, y: &[f64]) -> Result<Accel> {
|
||||
let kin = self.kinematics(y);
|
||||
let applied = self.applied(&kin);
|
||||
let rows = self.rows(&kin);
|
||||
let (applied, mut input, dissipated) = self.applied(&kin, t, y);
|
||||
let rows = self.rows(&kin, t, y);
|
||||
let (s, w) = self.schur(&kin, &rows);
|
||||
// Unconstrained accelerations M⁻¹F.
|
||||
let free: Vec<(Vec3, Vec3)> = applied.iter().enumerate().map(|(i, (f, t))| self.minv(&kin, i, *f, *t)).collect();
|
||||
@@ -315,7 +548,7 @@ impl System {
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let jmf: f64 = r.blocks().map(|(i, l, a)| free.get(*i).map_or(0.0, |(fl, fa)| l.dot(*fl) + a.dot(*fa))).sum();
|
||||
let stab = self.baumgarte.map_or(0.0, |(alpha, beta)| 2.0 * alpha * r.rate(&kin) + beta * beta * r.phi);
|
||||
let stab = self.baumgarte.map_or(0.0, |(alpha, beta)| 2.0 * alpha * (r.rate(&kin) - r.rate_target) + beta * beta * r.phi);
|
||||
jmf + r.jdot_u + stab
|
||||
})
|
||||
.collect();
|
||||
@@ -338,25 +571,50 @@ impl System {
|
||||
put3(&mut ydot, at + 7, a.0);
|
||||
put3(&mut ydot, at + 10, a.1);
|
||||
}
|
||||
for i in 0..self.coords.len() {
|
||||
let rate = self.coord_row(&kin, y, i).rate(&kin);
|
||||
if let Some(s) = ydot.get_mut(self.coord_base() + i) {
|
||||
*s = rate;
|
||||
}
|
||||
}
|
||||
// A drive's generalized force is −λ along its value: its power is −λ·(value rate).
|
||||
let first_drive = self.joint_row_count();
|
||||
for k in 0..self.drives.len() {
|
||||
if let (Some(r), Some(l)) = (rows.get(first_drive + k), lambda.get(first_drive + k)) {
|
||||
input -= l * r.rate(&kin);
|
||||
}
|
||||
}
|
||||
let wb = self.work_base();
|
||||
for (k, p) in [input, dissipated].into_iter().enumerate() {
|
||||
if let Some(s) = ydot.get_mut(wb + k) {
|
||||
*s = p;
|
||||
}
|
||||
}
|
||||
if !ydot.iter().all(|x| x.is_finite()) {
|
||||
return Err(DynamicsError::Diverged { time: f64::NAN });
|
||||
}
|
||||
Ok(Accel { ydot, lambda, rows, rank })
|
||||
}
|
||||
|
||||
/// The largest joint equation error at `y`.
|
||||
pub(crate) fn constraint_error(&self, y: &[f64]) -> f64 {
|
||||
self.rows(&self.kinematics(y)).iter().map(|r| r.phi.abs()).fold(0.0, f64::max)
|
||||
/// What each drive applies along its value (mN·mm or mN), from the multipliers.
|
||||
pub(crate) fn drive_efforts(&self, acc: &Accel) -> Vec<f64> {
|
||||
let first = self.joint_row_count();
|
||||
(0..self.drives.len()).map(|k| -acc.lambda.get(first + k).copied().unwrap_or(0.0)).collect()
|
||||
}
|
||||
|
||||
/// The largest constraint equation error at `y` and time `t`.
|
||||
pub(crate) fn constraint_error(&self, t: f64, y: &[f64]) -> f64 {
|
||||
self.rows(&self.kinematics(y), t, y).iter().map(|r| r.phi.abs()).fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
/// Moves the bodies the least (mass-weighted) onto the joints: Gauss–Newton on `Φ = 0`.
|
||||
/// Returns the error left.
|
||||
pub(crate) fn project_positions(&self, y: &mut [f64], tol: f64) -> Result<f64> {
|
||||
pub(crate) fn project_positions(&self, t: f64, y: &mut [f64], tol: f64) -> Result<f64> {
|
||||
let mut err;
|
||||
let mut iteration = 0;
|
||||
loop {
|
||||
let kin = self.kinematics(y);
|
||||
let rows = self.rows(&kin);
|
||||
let rows = self.rows(&kin, t, y);
|
||||
err = rows.iter().map(|r| r.phi.abs()).fold(0.0, f64::max);
|
||||
if !(err > tol) || iteration >= MAX_PROJECTION_ITERATIONS {
|
||||
break;
|
||||
@@ -387,15 +645,16 @@ impl System {
|
||||
Ok(err)
|
||||
}
|
||||
|
||||
/// Removes the velocities the joints don't allow (mass-weighted): `J u = 0`.
|
||||
pub(crate) fn project_velocities(&self, y: &mut [f64]) -> Result<()> {
|
||||
/// Removes the velocities the joints don't allow (mass-weighted): `J u = ḟ` (0 but for
|
||||
/// drives).
|
||||
pub(crate) fn project_velocities(&self, t: f64, y: &mut [f64]) -> Result<()> {
|
||||
let kin = self.kinematics(y);
|
||||
let rows = self.rows(&kin);
|
||||
let rows = self.rows(&kin, t, y);
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let (s, w) = self.schur(&kin, &rows);
|
||||
let ju: Vec<f64> = rows.iter().map(|r| r.rate(&kin)).collect();
|
||||
let ju: Vec<f64> = rows.iter().map(|r| r.rate(&kin) - r.rate_target).collect();
|
||||
let (mu, _) = solve_psd(s, &ju, RANK_TOL).ok_or(DynamicsError::Singular { time: f64::NAN })?;
|
||||
for (wr, m) in w.iter().zip(&mu) {
|
||||
for (i, ml, ma) in wr {
|
||||
@@ -417,7 +676,7 @@ impl System {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Kinetic, gravity and spring energy.
|
||||
/// Kinetic, gravity and spring energy, and the work put in and dissipated so far.
|
||||
pub(crate) fn energy(&self, y: &[f64]) -> Energy {
|
||||
let kin = self.kinematics(y);
|
||||
let mut e = Energy::default();
|
||||
@@ -433,6 +692,13 @@ impl System {
|
||||
e.spring += 0.5 * stiffness * stretch * stretch + preload * stretch;
|
||||
}
|
||||
}
|
||||
for (i, k, _, rest) in &self.springs {
|
||||
let stretch = self.coord_row(&kin, y, *i).phi - rest;
|
||||
e.spring += 0.5 * k * stretch * stretch;
|
||||
}
|
||||
let wb = self.work_base();
|
||||
e.input = y.get(wb).copied().unwrap_or(0.0);
|
||||
e.dissipated = y.get(wb + 1).copied().unwrap_or(0.0);
|
||||
e
|
||||
}
|
||||
|
||||
@@ -477,19 +743,19 @@ impl System {
|
||||
/// `(Φ, J, J̇u)` for one body point's world component along `e`: `e·(p_B − p_A)`.
|
||||
fn point_row(a: &MarkerWorld, b: &MarkerWorld, e: Vec3) -> Row {
|
||||
let acc = |m: &MarkerWorld| cross2(m.w, m.r);
|
||||
Row { a: a.body.map(|i| (i, -e, -a.r.cross(e))), b: b.body.map(|i| (i, e, b.r.cross(e))), phi: e.dot(b.p - a.p), jdot_u: e.dot(acc(b) - acc(a)) }
|
||||
Row::new(a.body.map(|i| (i, -e, -a.r.cross(e))), b.body.map(|i| (i, e, b.r.cross(e))), e.dot(b.p - a.p), e.dot(acc(b) - acc(a)))
|
||||
}
|
||||
|
||||
/// `u·v = 0` for a direction `u` of A and `v` of B.
|
||||
fn dot1_row(a: &MarkerWorld, u: Vec3, b: &MarkerWorld, v: Vec3) -> Row {
|
||||
let (ud, vd) = (a.w.cross(u), b.w.cross(v));
|
||||
let (udd, vdd) = (cross2(a.w, u), cross2(b.w, v));
|
||||
Row {
|
||||
a: a.body.map(|i| (i, Vec3::ZERO, u.cross(v))),
|
||||
b: b.body.map(|i| (i, Vec3::ZERO, v.cross(u))),
|
||||
phi: u.dot(v),
|
||||
jdot_u: udd.dot(v) + 2.0 * ud.dot(vd) + u.dot(vdd),
|
||||
}
|
||||
Row::new(
|
||||
a.body.map(|i| (i, Vec3::ZERO, u.cross(v))),
|
||||
b.body.map(|i| (i, Vec3::ZERO, v.cross(u))),
|
||||
u.dot(v),
|
||||
udd.dot(v) + 2.0 * ud.dot(vd) + u.dot(vdd),
|
||||
)
|
||||
}
|
||||
|
||||
/// `d·u = 0` for `d = p_B − p_A` and a direction `u` of A.
|
||||
@@ -498,12 +764,12 @@ fn dot2_row(a: &MarkerWorld, u: Vec3, b: &MarkerWorld) -> Row {
|
||||
let dd = b.pdot - a.pdot;
|
||||
let ddd = cross2(b.w, b.r) - cross2(a.w, a.r);
|
||||
let (ud, udd) = (a.w.cross(u), cross2(a.w, u));
|
||||
Row {
|
||||
a: a.body.map(|i| (i, -u, u.cross(d) - a.r.cross(u))),
|
||||
b: b.body.map(|i| (i, u, b.r.cross(u))),
|
||||
phi: d.dot(u),
|
||||
jdot_u: ddd.dot(u) + 2.0 * dd.dot(ud) + d.dot(udd),
|
||||
}
|
||||
Row::new(
|
||||
a.body.map(|i| (i, -u, u.cross(d) - a.r.cross(u))),
|
||||
b.body.map(|i| (i, u, b.r.cross(u))),
|
||||
d.dot(u),
|
||||
ddd.dot(u) + 2.0 * dd.dot(ud) + d.dot(udd),
|
||||
)
|
||||
}
|
||||
|
||||
fn joint_rows(kind: JointKind, a: &MarkerWorld, b: &MarkerWorld, rows: &mut Vec<Row>) {
|
||||
@@ -576,7 +842,7 @@ pub(crate) mod testing {
|
||||
/// `(Φ, J u, J̇ u)` of every row at `y`.
|
||||
pub(crate) fn rows_at(sys: &System, y: &[f64]) -> Vec<(f64, f64, f64)> {
|
||||
let kin = sys.kinematics(y);
|
||||
sys.rows(&kin).iter().map(|r| (r.phi, r.rate(&kin), r.jdot_u)).collect()
|
||||
sys.rows(&kin, 0.0, y).iter().map(|r| (r.phi, r.rate(&kin), r.jdot_u)).collect()
|
||||
}
|
||||
|
||||
/// The state moved along its velocities for `h` (positions and orientations only).
|
||||
|
||||
@@ -38,7 +38,7 @@ fn pendulum(len: f64, theta: f64, omega: f64, inertia: f64) -> Model {
|
||||
a: Marker::ground(Vec3::ZERO, Vec3::Y, Vec3::X),
|
||||
b: Marker::new(Some(0), Vec3::new(0.0, 0.0, len), Vec3::Y, Vec3::X),
|
||||
};
|
||||
Model { bodies: vec![body], joints: vec![joint], forces: vec![], gravity: down() }
|
||||
Model { bodies: vec![body], joints: vec![joint], forces: vec![], drives: vec![], links: vec![], gravity: down() }
|
||||
}
|
||||
|
||||
/// Times where `f` crosses zero upwards, linearly interpolated between samples.
|
||||
@@ -68,7 +68,7 @@ fn free_fall_follows_the_parabola() {
|
||||
let mut body = Body::at_rest(2.5, Vec3::new(10.0, 20.0, 30.0), Vec3::new(1.0, 2.0, 3.0));
|
||||
body.velocity = Vec3::new(10.0, -5.0, 50.0);
|
||||
body.angular_velocity = Vec3::new(0.0, 0.0, 1.0);
|
||||
let model = Model { bodies: vec![body.clone()], joints: vec![], forces: vec![], gravity: down() };
|
||||
let model = Model { bodies: vec![body.clone()], joints: vec![], forces: vec![], drives: vec![], links: vec![], gravity: down() };
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.0, 0.01, 0.1)));
|
||||
assert_eq!(traj.samples.len(), 11);
|
||||
for s in &traj.samples {
|
||||
@@ -158,6 +158,7 @@ fn double_pendulum() -> Model {
|
||||
],
|
||||
forces: vec![],
|
||||
gravity: down(),
|
||||
..Model::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +216,7 @@ fn slider_crank_follows_its_closed_form() {
|
||||
],
|
||||
forces: vec![],
|
||||
gravity: Vec3::ZERO,
|
||||
..Model::default()
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
|
||||
// A planar linkage built from 3D joints repeats three equations; they are solved, not refused.
|
||||
@@ -256,6 +258,7 @@ fn spring_mass(damping: f64) -> Model {
|
||||
preload: 0.0,
|
||||
}],
|
||||
gravity: down(),
|
||||
..Model::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +297,7 @@ fn torque_free_body_keeps_energy_and_momentum() {
|
||||
let mut body = Body::at_rest(1.0, Vec3::new(1000.0, 2000.0, 3000.0), Vec3::ZERO);
|
||||
body.orientation = Quat::from_axis_angle(Vec3::new(1.0, 2.0, 3.0), 0.7);
|
||||
body.angular_velocity = Vec3::new(1.0, 0.3, 5.0);
|
||||
let model = Model { bodies: vec![body], joints: vec![], forces: vec![], gravity: Vec3::ZERO };
|
||||
let model = Model { bodies: vec![body], joints: vec![], forces: vec![], drives: vec![], links: vec![], gravity: Vec3::ZERO };
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-4, 1e-2)));
|
||||
let momentum = |s: &Sample| {
|
||||
let b = s.bodies.first().copied();
|
||||
@@ -329,6 +332,7 @@ fn spherical_pendulum_keeps_energy_and_vertical_momentum() {
|
||||
}],
|
||||
forces: vec![],
|
||||
gravity: down(),
|
||||
..Model::default()
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(3.0, 5e-4, 1e-2)));
|
||||
let lz = |s: &Sample| {
|
||||
@@ -369,9 +373,10 @@ fn jacobians_match_finite_differences() {
|
||||
a: Marker::new(a_body, Vec3::new(4.0, -3.0, 7.0), Vec3::new(0.2, 0.5, 1.0), Vec3::new(1.0, 0.0, 0.3)),
|
||||
b: Marker::new(Some(1), Vec3::new(-6.0, 2.0, 1.0), Vec3::new(-0.4, 1.0, 0.1), Vec3::new(0.0, 0.3, 1.0)),
|
||||
};
|
||||
let model = Model { bodies: vec![a.clone(), b.clone()], joints: vec![joint], forces: vec![], gravity: Vec3::ZERO };
|
||||
let model =
|
||||
Model { bodies: vec![a.clone(), b.clone()], joints: vec![joint], forces: vec![], drives: vec![], links: vec![], gravity: Vec3::ZERO };
|
||||
let sys = ok(System::new(&model, None));
|
||||
let y = ok(System::initial_state(&model));
|
||||
let y = ok(sys.initial_state(&model));
|
||||
let h = 1e-4;
|
||||
let (now, plus, minus) = (
|
||||
testing::rows_at(&sys, &y),
|
||||
@@ -400,6 +405,7 @@ fn joint_values_read_the_relative_motion() {
|
||||
joints: vec![Joint { kind, a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X), b: Marker::new(Some(0), Vec3::ZERO, Vec3::Z, Vec3::X) }],
|
||||
forces: vec![],
|
||||
gravity: Vec3::ZERO,
|
||||
..Model::default()
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(0.01, 0.01, 0.01)));
|
||||
traj.samples.first().and_then(|s| s.joints.first()).map(|j| j.values.clone()).unwrap_or_default()
|
||||
@@ -523,6 +529,10 @@ fn hostile_input_is_an_error_not_a_panic() {
|
||||
fn models_round_trip_through_json() {
|
||||
let mut model = spring_mass(1.5);
|
||||
model.forces.push(Force::Load { body: 0, point: Vec3::new(1.0, 0.0, 0.0), force: Vec3::new(0.0, 5.0, 0.0), torque: Vec3::ZERO });
|
||||
model.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: 3.0, damping: 0.5, rest: 1.0 });
|
||||
model.forces.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Table { points: vec![(0.0, 1.0), (2.0, 3.0)] } });
|
||||
model.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Ramp { from: 0.0, to: 1.0, start: 0.5, duration: 2.0 } });
|
||||
model.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 2.0, offset: Some(0.5) });
|
||||
let text = match serde_json::to_string(&model) {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("{e}"),
|
||||
@@ -538,3 +548,253 @@ fn models_round_trip_through_json() {
|
||||
};
|
||||
assert!(settings.projection && settings.validate().is_ok());
|
||||
}
|
||||
|
||||
// Joint springs, motors, drives and motion links.
|
||||
|
||||
/// `a − b` turned into (−π, π].
|
||||
fn wrap(x: f64) -> f64 {
|
||||
(x + PI).rem_euclid(TAU) - PI
|
||||
}
|
||||
|
||||
/// A wheel turning about Z on a revolute to the ground at `at`, its centre of mass on the axis.
|
||||
/// Wheels turning about Z on revolutes to the ground, centres of mass on their axes (inertia,
|
||||
/// centre); joint `i` holds body `i`.
|
||||
fn rotors(parts: &[(f64, Vec3)]) -> Model {
|
||||
let mut model = Model::default();
|
||||
for (i, (inertia, at)) in parts.iter().enumerate() {
|
||||
model.bodies.push(Body::at_rest(1.0, Vec3::new(*inertia, *inertia, *inertia), *at));
|
||||
model.joints.push(Joint {
|
||||
kind: JointKind::Revolute,
|
||||
a: Marker::ground(*at, Vec3::Z, Vec3::X),
|
||||
b: Marker::new(Some(i), Vec3::ZERO, Vec3::Z, Vec3::X),
|
||||
});
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
fn value(s: &Sample, joint: usize, dof: usize) -> f64 {
|
||||
s.joints.get(joint).and_then(|j| j.values.get(dof)).copied().unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_give_consistent_derivatives() {
|
||||
let profiles = [
|
||||
Profile::Constant { value: 2.0 },
|
||||
Profile::Linear { value: 1.0, rate: -3.0 },
|
||||
Profile::Ramp { from: 0.5, to: 2.0, start: 0.2, duration: 0.6 },
|
||||
Profile::Harmonic { offset: 1.0, amplitude: 0.3, frequency: 2.0, phase: 0.4 },
|
||||
];
|
||||
let h = 1e-5;
|
||||
for p in &profiles {
|
||||
for k in 0..100 {
|
||||
let t = k as f64 * 0.0123;
|
||||
let (f, fd, fdd) = p.eval(t);
|
||||
let (fp, fdp, _) = p.eval(t + h);
|
||||
let (fm, fdm, _) = p.eval(t - h);
|
||||
assert!((fd - (fp - fm) / (2.0 * h)).abs() < 1e-5 * (1.0 + fd.abs()), "{p:?} at {t}: ḟ");
|
||||
assert!((fdd - (fdp - fdm) / (2.0 * h)).abs() < 1e-3 * (1.0 + fdd.abs()), "{p:?} at {t}: f̈");
|
||||
assert!(f.is_finite());
|
||||
}
|
||||
}
|
||||
let ramp = Profile::Ramp { from: 0.5, to: 2.0, start: 0.2, duration: 0.6 };
|
||||
assert_eq!(ramp.eval(0.0).0, 0.5);
|
||||
assert_eq!(ramp.eval(0.8).0, 2.0);
|
||||
assert!((ramp.eval(0.5).0 - 1.25).abs() < 1e-12);
|
||||
let table = Profile::Table { points: vec![(0.0, 1.0), (1.0, 3.0), (2.0, 0.0)] };
|
||||
assert_eq!(table.eval(-1.0), (1.0, 0.0, 0.0));
|
||||
assert_eq!(table.eval(0.5), (2.0, 2.0, 0.0));
|
||||
assert_eq!(table.eval(1.5), (1.5, -3.0, 0.0));
|
||||
assert_eq!(table.eval(9.0), (0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driven_pendulum_reaches_its_commanded_angle_and_holds_it() {
|
||||
// A drive lifts the pendulum from hanging to horizontal along a cycloidal ramp in 1 s,
|
||||
// then holds it: the angle follows the ramp, and the holding torque is m·g·L.
|
||||
let (len, mass) = (100.0, 1.0);
|
||||
let mut model = pendulum(len, 0.0, 0.0, 40.0);
|
||||
let ramp = Profile::Ramp { from: 0.0, to: PI / 2.0, start: 0.0, duration: 1.0 };
|
||||
model.drives.push(Drive { at: Coord::new(0, 0), motion: ramp.clone() });
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.5, 1e-3, 1e-2)));
|
||||
let b0 = traj.samples.first().map(|s| s.energy.balance()).unwrap_or(f64::NAN);
|
||||
for s in &traj.samples {
|
||||
let angle = value(s, 0, 0);
|
||||
assert!((angle - ramp.eval(s.time).0).abs() < 1e-9, "t={}: angle {angle} vs {}", s.time, ramp.eval(s.time).0);
|
||||
// Work put in = kinetic + gravity energy gained.
|
||||
assert!((s.energy.balance() - b0).abs() < 1e-6 * G * len, "t={}: balance {}", s.time, s.energy.balance() - b0);
|
||||
}
|
||||
let held: Vec<&Sample> = traj.samples.iter().filter(|s| s.time >= 1.0).collect();
|
||||
assert!(held.len() > 40);
|
||||
for s in held {
|
||||
let effort = s.drives.first().copied().unwrap_or(f64::NAN);
|
||||
assert!((effort.abs() - mass * G * len).abs() < 1e-6 * G * len, "t={}: holding torque {effort}", s.time);
|
||||
assert!((s.energy.input - mass * G * len).abs() < 1e-6 * G * len, "work put in {}", s.energy.input);
|
||||
assert!(s.energy.kinetic < 1e-9 * G * len);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driven_speed_counts_whole_turns() {
|
||||
// 10 rad/s for 2 s: more than three turns; the driven angle never jumps a turn.
|
||||
let mut model = rotors(&[(500.0, Vec3::ZERO)]);
|
||||
model.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Linear { value: 0.0, rate: 10.0 } });
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
|
||||
for s in &traj.samples {
|
||||
assert!(wrap(value(s, 0, 0) - 10.0 * s.time).abs() < 1e-9, "t={}", s.time);
|
||||
let w = s.bodies.first().map(|b| b.angular_velocity.z).unwrap_or(f64::NAN);
|
||||
assert!((w - 10.0).abs() < 1e-9);
|
||||
assert!(s.drives.first().is_some_and(|e| e.abs() < 1e-6), "{:?}", s.drives);
|
||||
}
|
||||
assert!(traj.stats.max_constraint_error < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn torsion_spring_period() {
|
||||
// ω = √(k/I) = √(5e5 / 500) rad/s; released from 0.3 rad.
|
||||
let (inertia, k) = (500.0_f64, 5e5_f64);
|
||||
let w = (k / inertia).sqrt();
|
||||
let mut model = rotors(&[(inertia, Vec3::ZERO)]);
|
||||
if let Some(b) = model.bodies.first_mut() {
|
||||
b.orientation = Quat::from_axis_angle(Vec3::Z, 0.3);
|
||||
}
|
||||
model.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: k, damping: 0.0, rest: 0.0 });
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.0, 1e-4, 1e-3)));
|
||||
for s in &traj.samples {
|
||||
let expect = 0.3 * (w * s.time).cos();
|
||||
assert!((value(s, 0, 0) - expect).abs() < 1e-7, "t={}: {} vs {expect}", s.time, value(s, 0, 0));
|
||||
}
|
||||
let measured = mean_period(&upward_crossings(&traj, |s| value(s, 0, 0)));
|
||||
assert!((measured - TAU / w).abs() < 1e-5 * TAU / w);
|
||||
let e0 = traj.samples.first().map(|s| s.energy.total()).unwrap_or(0.0);
|
||||
assert!((e0 - 0.5 * k * 0.09).abs() < 1e-9 * e0);
|
||||
// With damping the energy left plus the energy dissipated stays what it was.
|
||||
if let Some(Force::JointSpring { damping, .. }) = model.forces.first_mut() {
|
||||
*damping = 2.0 * 0.05 * (k * inertia).sqrt();
|
||||
}
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.0, 1e-4, 1e-2)));
|
||||
let last = traj.samples.last().map(|s| s.energy).unwrap_or_default();
|
||||
assert!(last.total() < 0.2 * e0 && last.dissipated > 0.8 * e0);
|
||||
assert!(traj.samples.iter().all(|s| (s.energy.balance() - e0).abs() < 1e-7 * e0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gear_pair_keeps_its_ratio_and_motor_work_is_kinetic_energy() {
|
||||
// A motor's constant torque τ on gear 1; gear 2 turns at −r₁/r₂ = −0.5 of it. The pair
|
||||
// accelerates as one inertia I₁ + 0.25·I₂: θ₁ = ½(τ/I_eff)t², and the motor's work τ·θ₁ is
|
||||
// all kinetic energy. Over 3 s gear 1 makes more than half a turn past ±π.
|
||||
let (i1, i2, ratio, tau) = (1000.0, 2000.0, -0.5, 1500.0);
|
||||
let alpha = tau / (i1 + ratio * ratio * i2);
|
||||
let mut model = rotors(&[(i1, Vec3::ZERO), (i2, Vec3::new(30.0, 0.0, 0.0))]);
|
||||
model.forces.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Constant { value: tau } });
|
||||
model.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio, offset: None });
|
||||
let traj = ok(simulate(&model, &Settings::rk4(3.0, 1e-3, 1e-2)));
|
||||
for s in &traj.samples {
|
||||
let theta = 0.5 * alpha * s.time * s.time;
|
||||
assert!(wrap(value(s, 0, 0) - theta).abs() < 1e-8, "t={}: gear 1 {} vs {theta}", s.time, value(s, 0, 0));
|
||||
assert!(wrap(value(s, 1, 0) - ratio * theta).abs() < 1e-8, "t={}: gear 2", s.time);
|
||||
assert!((s.energy.input - tau * theta).abs() < 1e-6 * tau * 4.5, "t={}: work {}", s.time, s.energy.input);
|
||||
assert!((s.energy.kinetic - s.energy.input).abs() < 1e-6 * tau * 4.5, "t={}: kinetic {}", s.time, s.energy.kinetic);
|
||||
}
|
||||
assert!(0.5 * alpha * 9.0 > PI);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rack_and_pinion_follow_the_pinion_radius() {
|
||||
// A pinion of radius 20 mm drives a rack on a slider along X; a harmonic motor torque. The
|
||||
// rack's travel is 20·θ, and the motor's work is the kinetic energy of both.
|
||||
let mut model = rotors(&[(800.0, Vec3::ZERO)]);
|
||||
model.bodies.push(Body::at_rest(0.5, Vec3::new(100.0, 100.0, 100.0), Vec3::new(0.0, 20.0, 0.0)));
|
||||
model.joints.push(Joint {
|
||||
kind: JointKind::Slider,
|
||||
a: Marker::ground(Vec3::new(0.0, 20.0, 0.0), Vec3::X, Vec3::Y),
|
||||
b: Marker::new(Some(1), Vec3::ZERO, Vec3::X, Vec3::Y),
|
||||
});
|
||||
model
|
||||
.forces
|
||||
.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Harmonic { offset: 0.0, amplitude: 2000.0, frequency: 1.0, phase: 0.0 } });
|
||||
model.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 20.0, offset: None });
|
||||
let traj = ok(simulate(&model, &Settings::rk4(2.0, 1e-3, 1e-2)));
|
||||
// I_eff = 800 + 0.5·20²; θ̈ = τ(t)/I_eff with τ = A sin(2πt): θ = A/I_eff·(t/2π − sin(2πt)/4π²).
|
||||
let i_eff = 800.0 + 0.5 * 400.0;
|
||||
for s in &traj.samples {
|
||||
let t = s.time;
|
||||
let theta = 2000.0 / i_eff * (t / TAU - (TAU * t).sin() / (TAU * TAU));
|
||||
assert!((value(s, 0, 0) - theta).abs() < 1e-8, "t={t}: pinion {} vs {theta}", value(s, 0, 0));
|
||||
assert!((value(s, 1, 0) - 20.0 * theta).abs() < 1e-7, "t={t}: rack {}", value(s, 1, 0));
|
||||
assert!((s.energy.kinetic - s.energy.input).abs() < 1e-8 * 2000.0, "t={t}: {:?}", s.energy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screw_turns_as_it_falls() {
|
||||
// A nut on a vertical cylindrical joint, its travel tied to its turn by a 5 mm pitch, falls
|
||||
// under gravity: z̈ = −m·g·k² / (I + m·k²) with k = pitch / 2π.
|
||||
let (mass, inertia, k) = (0.2, 50.0, 5.0 / TAU);
|
||||
let model = Model {
|
||||
bodies: vec![Body::at_rest(mass, Vec3::new(inertia, inertia, inertia), Vec3::ZERO)],
|
||||
joints: vec![Joint {
|
||||
kind: JointKind::Cylindrical,
|
||||
a: Marker::ground(Vec3::ZERO, Vec3::Z, Vec3::X),
|
||||
b: Marker::new(Some(0), Vec3::ZERO, Vec3::Z, Vec3::X),
|
||||
}],
|
||||
links: vec![Link { driver: Coord::new(0, 0), follower: Coord::new(0, 1), ratio: k, offset: None }],
|
||||
gravity: down(),
|
||||
..Model::default()
|
||||
};
|
||||
let traj = ok(simulate(&model, &Settings::rk4(1.0, 1e-3, 1e-2)));
|
||||
let a = -mass * G * k * k / (inertia + mass * k * k);
|
||||
for s in &traj.samples {
|
||||
let z = s.bodies.first().map(|b| b.position.z).unwrap_or(f64::NAN);
|
||||
assert!((z - 0.5 * a * s.time * s.time).abs() < 1e-7, "t={}: z {z}", s.time);
|
||||
assert!(wrap(value(s, 0, 0) - z / k).abs() < 1e-8, "t={}: turn", s.time);
|
||||
assert!(s.energy.balance().abs() < 1e-6 * mass * G, "t={}: {:?}", s.time, s.energy);
|
||||
}
|
||||
// It went down more than one turn.
|
||||
assert!(0.5 * a < -5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_springs_motors_drives_and_links_are_errors() {
|
||||
let good = rotors(&[(500.0, Vec3::ZERO), (500.0, Vec3::new(30.0, 0.0, 0.0))]);
|
||||
let mut bad: Vec<Model> = Vec::new();
|
||||
let mut with = |f: &dyn Fn(&mut Model)| {
|
||||
let mut m = good.clone();
|
||||
f(&mut m);
|
||||
bad.push(m);
|
||||
};
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(5, 0), motion: Profile::Constant { value: 0.0 } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 1), motion: Profile::Constant { value: 0.0 } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Constant { value: f64::NAN } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Ramp { from: 0.0, to: 1.0, start: 0.0, duration: 0.0 } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Table { points: vec![] } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Table { points: vec![(1.0, 0.0), (0.5, 1.0)] } }));
|
||||
with(&|m| m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Table { points: vec![(0.0, 0.0); MAX_PROFILE_POINTS + 1] } }));
|
||||
with(&|m| m.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: -1.0, damping: 0.0, rest: 0.0 }));
|
||||
with(&|m| m.forces.push(Force::JointSpring { at: Coord::new(0, 0), stiffness: 1.0, damping: f64::INFINITY, rest: 0.0 }));
|
||||
with(&|m| m.forces.push(Force::Motor { at: Coord::new(9, 0), effort: Profile::Constant { value: 1.0 } }));
|
||||
with(&|m| {
|
||||
m.forces
|
||||
.push(Force::Motor { at: Coord::new(0, 0), effort: Profile::Harmonic { offset: 0.0, amplitude: 1.0, frequency: f64::NAN, phase: 0.0 } })
|
||||
});
|
||||
with(&|m| m.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(0, 0), ratio: 1.0, offset: None }));
|
||||
with(&|m| m.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: f64::NAN, offset: None }));
|
||||
with(&|m| m.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 1.0, offset: Some(f64::INFINITY) }));
|
||||
with(&|m| m.links = vec![Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 1.0, offset: None }; MAX_JOINTS + 1]);
|
||||
with(&|m| {
|
||||
// Ball and rigid joints have no value to act on yet.
|
||||
if let Some(j) = m.joints.get_mut(0) {
|
||||
j.kind = JointKind::Ball;
|
||||
}
|
||||
m.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Constant { value: 0.0 } });
|
||||
});
|
||||
for (i, m) in bad.iter().enumerate() {
|
||||
assert!(simulate(m, &Settings::rk4(0.1, 1e-3, 1e-2)).is_err(), "case {i} should be refused");
|
||||
}
|
||||
// Links that fight each other, and drives that can't be met, stop or finish but never panic.
|
||||
let mut locked = good.clone();
|
||||
locked.links.push(Link { driver: Coord::new(0, 0), follower: Coord::new(1, 0), ratio: 1.0, offset: None });
|
||||
locked.links.push(Link { driver: Coord::new(1, 0), follower: Coord::new(0, 0), ratio: 2.0, offset: None });
|
||||
locked.drives.push(Drive { at: Coord::new(0, 0), motion: Profile::Harmonic { offset: 0.0, amplitude: 1e6, frequency: 1e3, phase: 0.0 } });
|
||||
let _ = simulate(&locked, &Settings::rk4(0.1, 1e-3, 1e-2));
|
||||
let _ = simulate(&locked, &Settings::adaptive(0.1, 1e-2, 1e-9, 1e-9));
|
||||
}
|
||||
|
||||
@@ -13,30 +13,45 @@
|
||||
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::BomType;
|
||||
use solidcraft_doc::metadata::{BOM_KEYS, BOM_TYPE, MAX_KEYS, Owner, check_text, key_list};
|
||||
use solidcraft_doc::metadata::{BOM_KEYS, BOM_TYPE, DESCRIPTION, MAX_KEYS, Owner, PART_NUMBER, REVISION, check_text, key_list};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::bom::{Bom, BomChild, BomParams, BomRow};
|
||||
use crate::schema::bom::{
|
||||
Bom, BomChild, BomParams, BomRow, ComponentMetadataParams, ComponentMetadataResult, ComponentMetadataSetParams, ComponentMetadataSetResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const BOM_CONTRACT: TypedContract<BomParams> = TypedContract::new();
|
||||
const METADATA_CONTRACT: TypedContract<ComponentMetadataParams> = TypedContract::new();
|
||||
const METADATA_SET_CONTRACT: TypedContract<ComponentMetadataSetParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("component.metadata", "Part Properties", read).icon("settings").noundo().doc_only().params(
|
||||
"component: id or name (0 or \"root\": the design's own properties) → component, name, part_number, description, revision, \
|
||||
CommandSpec::new("component.metadata", "Part Properties", crate::checked!("component.metadata", read))
|
||||
.icon("settings")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.contract(&METADATA_CONTRACT)
|
||||
.output(output_schema_for::<ComponentMetadataResult>)
|
||||
.params(
|
||||
"component: id or name (0 or \"root\": the design's own properties) → component, name, part_number, description, revision, \
|
||||
bom_type (components only), metadata (as saved, other tools' keys too), properties: {name: text}",
|
||||
),
|
||||
CommandSpec::new("component.metadata.set", "Change Part Properties", set).icon("settings").doc_only().params(
|
||||
"component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \
|
||||
),
|
||||
CommandSpec::new("component.metadata.set", "Change Part Properties", crate::checked!("component.metadata.set", set))
|
||||
.icon("settings")
|
||||
.doc_only()
|
||||
.contract(&METADATA_SET_CONTRACT)
|
||||
.output(output_schema_for::<ComponentMetadataSetResult>)
|
||||
.params(
|
||||
"component: id or name (not the root: use document.metadata.set); part_number?, description?, revision?: text (empty or null \
|
||||
removes); bom_type?: normal|phantom|reference|purchased|inseparable (empty or null: normal); values?: {key: value} \
|
||||
(SolidCraft's keys, as document.metadata's properties lists them, bom_type, or a reverse-DNS key holding an object); \
|
||||
properties?: {name: text, …} (free properties; empty or null removes). All or nothing, one undo step → component, \
|
||||
metadata, properties, changed: [keys]",
|
||||
),
|
||||
),
|
||||
CommandSpec::new("assembly.bom", "Bill of Materials", crate::checked!("assembly.bom", bom))
|
||||
.at("SOLID", "INSPECT")
|
||||
.icon("bom")
|
||||
@@ -80,24 +95,25 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = component_id(s, p, cmd)?;
|
||||
// The root is the design: its own properties, and no free ones.
|
||||
let (name, m, properties) = if id == 0 {
|
||||
(&s.doc.name, &s.doc.metadata, json!({}))
|
||||
(&s.doc.name, &s.doc.metadata, BTreeMap::new())
|
||||
} else {
|
||||
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
|
||||
(&c.name, &c.metadata, json!(c.properties))
|
||||
(&c.name, &c.metadata, c.properties.clone())
|
||||
};
|
||||
let mut out = Map::new();
|
||||
out.insert("component".into(), json!(id));
|
||||
out.insert("name".into(), json!(name));
|
||||
for k in BOM_KEYS {
|
||||
out.insert(k.to_string(), json!(m.text(k)));
|
||||
}
|
||||
// A bill of materials type is a component's; the design has none.
|
||||
if id != 0 {
|
||||
out.insert(BOM_TYPE.into(), json!(m.bom_type().as_str()));
|
||||
}
|
||||
out.insert("metadata".into(), json!(m));
|
||||
out.insert("properties".into(), properties);
|
||||
Ok(Value::Object(out))
|
||||
let text = |k: &str| m.text(k).map(str::to_string);
|
||||
let reply = ComponentMetadataResult {
|
||||
component: id,
|
||||
name: name.clone(),
|
||||
part_number: text(PART_NUMBER),
|
||||
description: text(DESCRIPTION),
|
||||
revision: text(REVISION),
|
||||
// A bill of materials type is a component's; the design has none.
|
||||
bom_type: (id != 0).then(|| m.bom_type().as_str().to_string()),
|
||||
metadata: m.0.clone(),
|
||||
properties,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// A free property as it may be saved (`None`: remove it), or why not.
|
||||
@@ -178,8 +194,15 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
c.metadata = metadata;
|
||||
c.properties = properties;
|
||||
}
|
||||
let c = s.doc.components.iter().find(|c| c.id == id);
|
||||
Ok(json!({"component": id, "metadata": c.map(|c| &c.metadata), "properties": c.map(|c| &c.properties), "changed": changed}))
|
||||
let c = s.doc.components.iter().find(|c| c.id == id).ok_or_else(|| bad(cmd, format!("no component `{id}`")))?;
|
||||
let reply = ComponentMetadataSetResult {
|
||||
component: id,
|
||||
metadata: c.metadata.0.clone(),
|
||||
properties: c.properties.clone(),
|
||||
changed,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
|
||||
@@ -1193,7 +1193,9 @@ fn repair_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use solidcraft_doc::expr::Kind;
|
||||
use solidcraft_kernel::{Continuity, SurfaceKind};
|
||||
let cmd = "body.repair";
|
||||
let name = str_(p, "body").filter(|b| !b.trim().is_empty()).ok_or_else(|| bad(cmd, "`body` must name the part to repair"))?.to_string();
|
||||
let name = str_(p, "body").filter(|b| !b.trim().is_empty()).ok_or_else(|| bad(cmd, "`body` must name the part to repair"))?;
|
||||
// A placed copy's name means the body it shows (#9740); the repair goes on that body.
|
||||
let name = s.own_body_name(name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
|
||||
let state = s.model.state();
|
||||
let source = state.body(&name).ok_or_else(|| bad(cmd, format!("no body `{name}`")))?;
|
||||
if source.body.is_mesh() {
|
||||
@@ -1546,6 +1548,23 @@ mod tests {
|
||||
assert!(back.iter().any(|n| n == "KB") && back.iter().any(|n| n == "KB (K:2)"), "{back:?}");
|
||||
}
|
||||
|
||||
/// Repair takes a placed copy's name (`KB (K:2)`) for the body it shows, as Rename does.
|
||||
#[test]
|
||||
fn repair_part_takes_a_placed_copys_name() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "K"}));
|
||||
run(&mut s, "solid.box", json!({"body_name": "KB", "length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "surface.unstitch", json!({"body": "KB", "faces": [[5, 5, 10]]}));
|
||||
run(&mut s, "occurrence.copy", json!({"component": "K", "translate": [30, 0, 0]}));
|
||||
assert!(s.model.state().body("KB").is_some_and(|b| b.body.is_surface()));
|
||||
let features = s.doc.features.len();
|
||||
run(&mut s, "body.repair", json!({"body": "KB (K:2)", "patch_holes": true}));
|
||||
assert_eq!(s.doc.features.len(), features + 1);
|
||||
assert!(s.model.state().body("KB").is_some_and(|b| !b.body.is_surface()), "the body both placements show is closed");
|
||||
let err = s.execute("body.repair", &json!({"body": "KB (K:9)"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no body `KB (K:9)`"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_part_patches_a_missing_face_as_one_undoable_feature() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -102,13 +102,14 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let author_path = match given_path {
|
||||
Some(path) => path,
|
||||
None if comp == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(comp)),
|
||||
None => super::component::placing::placement_of(s, &s.placed_document(), comp, None, "component_path", cmd)?.0,
|
||||
None => super::component::placing::placement_of(s, &s.design_document(), comp, None, "component_path", cmd)?.0,
|
||||
};
|
||||
solidcraft_doc::EmbossFace::component_frame(&s.placed_document(), comp, &author_path)?;
|
||||
solidcraft_doc::EmbossFace::component_frame(&s.design_document(), comp, &author_path)?;
|
||||
// A copy from another component goes where its original is in the world: its geometry moves
|
||||
// from its component's frame (its first placement, where its bodies have their own names)
|
||||
// into the target's.
|
||||
let placed = s.placed_document();
|
||||
// into the target's. The design's placements: an exploded view shown is left out, or the copy
|
||||
// would keep its offset once the view is closed.
|
||||
let placed = s.design_document();
|
||||
let target_frame = placed.path_transform(comp, &author_path).ok_or_else(|| bad(cmd, "that path is not a placement of the target"))?;
|
||||
let to_target = solidcraft_doc::mat_inverse(&target_frame).ok_or_else(|| bad(cmd, "the target's frame is not invertible"))?;
|
||||
let frame_change: BTreeMap<u64, solidcraft_doc::Mat> = feats
|
||||
@@ -373,6 +374,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sketch pasted from another design while an exploded view moves the target is where the
|
||||
/// original is once the target is shown assembled again.
|
||||
#[test]
|
||||
fn foreign_sketch_pasted_while_an_exploded_view_is_shown_keeps_its_world_position() {
|
||||
let mut source = dimensioned_sketch();
|
||||
run(&mut source, "timeline.copy", json!({"features": ["S"]}));
|
||||
let mut target = Session::default();
|
||||
let made = run(&mut target, "component.create", json!({"name": "Target", "activate": false}));
|
||||
let component = made["component"].as_u64().unwrap();
|
||||
let occurrence = made["occurrence"].as_u64().unwrap();
|
||||
run(&mut target, "occurrence.ground", json!({"occurrence": occurrence, "grounded": false}));
|
||||
run(&mut target, "occurrence.move", json!({"occurrence": occurrence, "translate": [100, 20, 30]}));
|
||||
run(&mut target, "component.create", json!({"name": "B", "parent": 0, "activate": false}));
|
||||
let view =
|
||||
run(&mut target, "explode.create", json!({"moves": [{"occurrence": occurrence, "translate": [0, 0, 40]}]}))["view"].as_u64().unwrap();
|
||||
run(&mut target, "explode.show", json!({"view": view}));
|
||||
target.foreign_copy = source.foreign_copy.clone();
|
||||
let id = run(&mut target, "timeline.paste", json!({"from_design": true, "component": component, "component_path": [occurrence]}))["pasted"]
|
||||
[0]["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
run(&mut target, "explode.show", json!({}));
|
||||
let original = source.world_state().unwrap().sketch(1).unwrap().plane;
|
||||
let pasted = target.world_state().unwrap().sketch(id).unwrap().plane;
|
||||
assert!(pasted.origin.dist(original.origin) < 1e-6, "{original:?} vs {pasted:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_sketch_paste_refuses_external_references_and_bad_parameters_atomically() {
|
||||
let mut source = dimensioned_sketch();
|
||||
@@ -496,6 +524,32 @@ mod tests {
|
||||
assert_eq!(s.doc.model_body_component(state.body("Block (2)").unwrap()), a);
|
||||
}
|
||||
|
||||
/// An exploded view shown while pasting is display only: once the design is shown assembled
|
||||
/// again, the copy is where the original is, as it is without the view.
|
||||
#[test]
|
||||
fn features_pasted_while_an_exploded_view_is_shown_stay_where_they_were() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "S", "distance": 5, "body_name": "Block"}));
|
||||
let made = run(&mut s, "component.create", json!({"name": "A", "activate": false}));
|
||||
let (a, at) = (made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap());
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": at, "grounded": false}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": at, "translate": [100, 0, 0], "axis": [0, 0, 1], "angle": "90 deg"}));
|
||||
// An exploded view needs two placements: B stays put.
|
||||
run(&mut s, "component.create", json!({"name": "B", "parent": 0, "activate": false}));
|
||||
let view = run(&mut s, "explode.create", json!({"moves": [{"occurrence": at, "translate": [0, 50, 0]}]}))["view"].as_u64().unwrap();
|
||||
run(&mut s, "explode.show", json!({"view": view}));
|
||||
run(&mut s, "timeline.copy", json!({"features": ["S", "Extrude1"]}));
|
||||
let r = run(&mut s, "timeline.paste", json!({"component": a, "component_path": [at]}));
|
||||
assert_eq!(r["errors"].as_array().map(Vec::len), Some(0), "{r}");
|
||||
run(&mut s, "explode.show", json!({}));
|
||||
let world = s.world_state().unwrap();
|
||||
let (original, copy) = (world.body("Block").unwrap().mesh().bounds(), world.body("Block (2)").unwrap().mesh().bounds());
|
||||
assert!(copy.min.dist(original.min) < 1e-6 && copy.max.dist(original.max) < 1e-6, "{original:?} vs {copy:?}");
|
||||
}
|
||||
|
||||
/// A sketch attached to a face of a body that isn't copied with it can't be carried into a
|
||||
/// component placed elsewhere: the paste is refused, saying to make its plane fixed first.
|
||||
#[test]
|
||||
|
||||
@@ -192,9 +192,10 @@ pub(super) fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.and_then(|a| a.iter().map(Value::as_u64).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(CMD, "`component_path` must list 0..64 occurrence ids"))?,
|
||||
None if component == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(component)),
|
||||
None => super::component::placing::placement_of(s, &s.placed_document(), component, None, "component_path", CMD)?.0,
|
||||
None => super::component::placing::placement_of(s, &s.design_document(), component, None, "component_path", CMD)?.0,
|
||||
};
|
||||
let placed = s.placed_document();
|
||||
// The target as the design places it, without an exploded view shown.
|
||||
let placed = s.design_document();
|
||||
let frame = placed.path_transform(component, &path).ok_or_else(|| bad(CMD, "that path is not a placement of the target"))?;
|
||||
let into_target = solidcraft_doc::mat_inverse(&frame).ok_or_else(|| bad(CMD, "the target's frame is not invertible"))?;
|
||||
drop(placed);
|
||||
|
||||
@@ -25,9 +25,10 @@ pub(super) fn paste_from_design(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = match given {
|
||||
Some(path) => path,
|
||||
None if to == s.active_component => s.active_occurrence_path.clone().unwrap_or_else(|| s.doc.first_path(to)),
|
||||
None => placing::placement_of(s, &s.placed_document(), to, None, "component_path", cmd)?.0,
|
||||
None => placing::placement_of(s, &s.design_document(), to, None, "component_path", cmd)?.0,
|
||||
};
|
||||
let frame = s.placed_document().path_transform(to, &path).ok_or_else(|| bad(cmd, "that path is not a placement of the target"))?;
|
||||
// The target as the design places it, without an exploded view shown.
|
||||
let frame = s.design_document().path_transform(to, &path).ok_or_else(|| bad(cmd, "that path is not a placement of the target"))?;
|
||||
let into_target = solidcraft_doc::mat_inverse(&frame).ok_or_else(|| bad(cmd, "the target's frame is not invertible"))?;
|
||||
if copy.bodies.is_empty() {
|
||||
return paste_components(s, ©, to, path, into_target, p);
|
||||
|
||||
@@ -36,7 +36,9 @@ pub(super) fn reauthor(s: &mut Session, id: u64, to: u64, cmd: &str) -> Result<(
|
||||
if from == to {
|
||||
return Ok(());
|
||||
}
|
||||
let doc = s.placed_document();
|
||||
// Both placements as the design has them: an exploded view shown would move the sketch by
|
||||
// the difference of its offsets.
|
||||
let doc = s.design_document();
|
||||
let path = if to == anchor.component {
|
||||
anchor.path.clone()
|
||||
} else if to == 0 {
|
||||
|
||||
@@ -136,10 +136,12 @@ fn column_of(s: &Session, p: &Value, cmd: &str) -> Result<Column> {
|
||||
for (key, appearance) in [("material", false), ("appearance", true)] {
|
||||
if let Some(body) = str_(p, key) {
|
||||
let c = if appearance { Column::Appearance(body.to_string()) } else { Column::Material(body.to_string()) };
|
||||
if !s.doc.configs.columns.contains(&c) && s.model.state().body(body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
if s.doc.configs.columns.contains(&c) {
|
||||
return Ok(c);
|
||||
}
|
||||
return Ok(c);
|
||||
// A placed copy's name means the body it shows (#9740); the column is keyed by that body's own name.
|
||||
let body = s.own_body_name(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
|
||||
return Ok(if appearance { Column::Appearance(body) } else { Column::Material(body) });
|
||||
}
|
||||
}
|
||||
if let Some(n) = str_(p, "param") {
|
||||
@@ -538,6 +540,20 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A placed copy's body name (`KB (K:2)`, as inspect.measure lists it) means the body it
|
||||
/// shows: the column is keyed by that body's own name, for every placement.
|
||||
#[test]
|
||||
fn a_column_by_a_placed_copys_name_is_keyed_by_the_body_it_shows() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "K"}));
|
||||
run(&mut s, "solid.box", json!({"body_name": "KB", "length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "occurrence.copy", json!({"component": "K", "translate": [30, 0, 0]}));
|
||||
run(&mut s, "config.column", json!({"material": "KB (K:2)"}));
|
||||
assert!(s.doc.configs.columns.contains(&super::Column::Material("KB".into())), "{:?}", s.doc.configs.columns);
|
||||
let err = s.execute("config.column", &json!({"material": "KB (K:9)"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no body `KB (K:9)`"), "{err}");
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
match s.execute(id, &p) {
|
||||
Ok(v) => v,
|
||||
|
||||
@@ -10,11 +10,19 @@ use solidcraft_doc::FeatureKind;
|
||||
|
||||
use super::{CommandSpec, sketch_store};
|
||||
use crate::params::{bad, bool_};
|
||||
use crate::schema::delete::{SelectionDeleteParams, SelectionDeleteResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Sel, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "Delete", delete).icon("delete").params(
|
||||
"items?: [selection items]; components?, occurrences?, canvases?, point_clouds?, joints?: [ids or names]; dry_run?: bool → deleted, deleted_joints, would_fail, skipped (one undo step)",
|
||||
)];
|
||||
const DELETE_CONTRACT: TypedContract<SelectionDeleteParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("selection.delete", "Delete", crate::checked!("selection.delete", delete))
|
||||
.icon("delete")
|
||||
.contract(&DELETE_CONTRACT)
|
||||
.output(output_schema_for::<SelectionDeleteResult>)
|
||||
.params(
|
||||
"items?: [selection items]; components?, occurrences?, canvases?, point_clouds?, joints?: [ids or names]; dry_run?: bool → deleted, deleted_joints, would_fail, skipped (one undo step)",
|
||||
)];
|
||||
|
||||
/// Run another command's body inside this one (so the whole delete is one undo step).
|
||||
fn sub(s: &mut Session, id: &str, p: Value) -> Result<Value> {
|
||||
@@ -227,11 +235,12 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, skipped.first().cloned().unwrap_or_else(|| "nothing to delete".into())));
|
||||
}
|
||||
s.refresh();
|
||||
let deleted: Vec<&String> = features_before.iter().filter(|(id, _)| s.doc.feature(*id).is_none()).map(|(_, n)| n).collect();
|
||||
let deleted: Vec<String> = features_before.iter().filter(|(id, _)| s.doc.feature(*id).is_none()).map(|(_, n)| n.clone()).collect();
|
||||
// Features after a rolled-back marker count too (#3471).
|
||||
let would_fail: Vec<String> =
|
||||
super::edit::newly_broken_in_history(&before_doc, &before, &s.doc, &s.model).into_iter().map(|(name, _)| name).collect();
|
||||
Ok(json!({ "deleted": deleted, "deleted_joints": joints, "bodies": bodies, "would_fail": would_fail, "skipped": skipped }))
|
||||
let reply = SelectionDeleteResult { deleted, deleted_joints: joints, bodies, would_fail, skipped, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+110
-43
@@ -8,6 +8,13 @@ use solidcraft_doc::{Feature, FeatureKind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, bool_, name_or_id_values, num, str_};
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::edit::{
|
||||
FailingFeature, FeatureInfo, FeatureInput, Inspected, OmittedField, SuppressChanged, TimelineComputeAllParams, TimelineComputeAllResult,
|
||||
TimelineDeleteParams, TimelineDeleteResult, TimelineDependentsParams, TimelineDependentsResult, TimelineEditParams, TimelineEditResult,
|
||||
TimelineInspectParams, TimelineInspectResult, TimelineMarkerResult, TimelineRenameParams, TimelineRenameResult, TimelineRollToParams,
|
||||
TimelineRollbackParams, TimelineSuppressParams, TimelineSuppressResult,
|
||||
};
|
||||
use crate::{EngineError, Result, Sel, Session, Snapshot};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
@@ -53,7 +60,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&RESTORE_CHECKPOINT_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::edit::CheckpointResult>)
|
||||
.params("checkpoint: id; forget?: bool (true = drop it without going back). Goes back to it, undo history included. Restoring or forgetting uses it and every later checkpoint up: take a new one to go back again"),
|
||||
CommandSpec::new("timeline.compute_all", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(),
|
||||
CommandSpec::new("timeline.compute_all", "Compute All", crate::checked!("timeline.compute_all", compute_all))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("compute")
|
||||
.noundo()
|
||||
.contract(&COMPUTE_ALL_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineComputeAllResult>),
|
||||
CommandSpec::new("timeline.play", "Play History", crate::checked!("timeline.play", play))
|
||||
.at("INSPECT", "HISTORY")
|
||||
.icon("timeline_play")
|
||||
@@ -63,12 +75,18 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&PLAY_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::edit::TimelinePlayResult>)
|
||||
.params("action?: toggle|play|stop (default toggle) → action: validated display request; the connected UI plays from the marker to the end with a half-second pause per rebuilt step. Headless callers only validate the request; no dialog, document edit or animation clock is created"),
|
||||
CommandSpec::new("timeline.delete", "Delete", delete)
|
||||
CommandSpec::new("timeline.delete", "Delete", crate::checked!("timeline.delete", delete))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("delete")
|
||||
.key("Delete")
|
||||
.contract(&DELETE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineDeleteResult>)
|
||||
.params("feature: id|name | features: [id|name] | groups: [timeline folder ids] (a folder goes with all its features, joints and nested folders; one undo step; a sketch takes its dependent features with it) → deleted: [feature ids]; joints?: [joint ids]; groups?: [folder ids deleted]; failing?: [{feature, error}] (later features that worked and now fail)"),
|
||||
CommandSpec::new("timeline.rename", "Rename", rename).icon("rename").params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.rename", "Rename", crate::checked!("timeline.rename", rename))
|
||||
.icon("rename")
|
||||
.contract(&RENAME_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineRenameResult>)
|
||||
.params("feature: id|name, name (references to it follow)"),
|
||||
CommandSpec::new("timeline.group", "New Feature Folder", group)
|
||||
.icon("folder")
|
||||
.doc_only()
|
||||
@@ -83,8 +101,15 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("timeline.move_many", "Move Timeline Items", move_many).icon("reorder").params(
|
||||
"features?: [id|name], groups?: [folder ids], group?: folder id, position: insertion slot, active?: bool (true = the moved contents end up before the history marker, false = behind it; other features keep their state); atomically changes folder membership and moves all selected contents as one block",
|
||||
),
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).icon("rollback").params("position: number of features to keep active, or \"end\" (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", roll_to).icon("rollback")
|
||||
CommandSpec::new("timeline.rollback", "Move Timeline Marker", crate::checked!("timeline.rollback", rollback))
|
||||
.icon("rollback")
|
||||
.contract(&ROLLBACK_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineMarkerResult>)
|
||||
.params("position: number of features to keep active, or \"end\" (omit = end)"),
|
||||
CommandSpec::new("timeline.roll_to", "Roll History Marker", crate::checked!("timeline.roll_to", roll_to))
|
||||
.icon("rollback")
|
||||
.contract(&ROLL_TO_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineMarkerResult>)
|
||||
.params("position: number of features to keep active, or \"end\" (omit = end) | feature: id|name (the marker goes right after it)"),
|
||||
CommandSpec::new("timeline.reorder", "Reorder Feature", reorder).icon("reorder")
|
||||
.params("feature: id|name, position: its new index, active?: bool (true = it ends up before the history marker, false = behind it; other features keep their state); refused when a feature that worked would fail, unless allow_errors?: bool (default false): then made, and broken: [{feature, error}] lists the features it broke → feature, position: its index now, from: its index before, moved: bool, note?: why nothing moved, or that a position past the end meant the last slot"),
|
||||
@@ -93,21 +118,37 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
),
|
||||
CommandSpec::new("timeline.redefine", "Edit Feature", redefine).icon("edit")
|
||||
.params("feature: id|name, command: the feature's command id, params: its parameters (the feature is rebuilt in place) → feature, recomputed, restored, rolled_back: bool (the feature is after the history marker, so the edit is not built yet; `note` then says so), warning?"),
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", dependents).icon("graph")
|
||||
CommandSpec::new("timeline.dependents", "Feature Dependents", crate::checked!("timeline.dependents", dependents))
|
||||
.icon("graph")
|
||||
.noundo()
|
||||
.contract(&DEPENDENTS_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineDependentsResult>)
|
||||
.params("feature: id|name → features deleted with it, and features that would fail without it"),
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).icon("suppress").params(
|
||||
CommandSpec::new("timeline.suppress", "Suppress Feature", crate::checked!("timeline.suppress", suppress))
|
||||
.icon("suppress")
|
||||
.contract(&SUPPRESS_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineSuppressResult>)
|
||||
.params(
|
||||
"feature: id|name | features: [id|name] | groups: [timeline folder ids] (every feature and joint in those folders and their nested \
|
||||
folders; one undo step), suppressed?: bool (default toggles: features and joints are all \
|
||||
suppressed unless all of them already are, then all are unsuppressed) → feature: id (or features: [ids]; groups: [ids] and joints: [ids] when groups are given), suppressed",
|
||||
),
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", edit_feature).icon("edit").params(
|
||||
CommandSpec::new("timeline.edit", "Edit Feature", crate::checked!("timeline.edit", edit_feature))
|
||||
.icon("edit")
|
||||
.contract(&EDIT_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineEditResult>)
|
||||
.params(
|
||||
"feature: id|name, set: {fields to change, merged into the feature's definition as timeline.inspect returns it, \
|
||||
e.g. {\"extent\": {\"distance\": \"30\"}} (extrude), {\"angle\": \"90 deg\"} (revolve); \
|
||||
a dotted key names a nested field ({\"plane.distance\": \"8 mm\"} = {\"plane\": {\"distance\": \"8 mm\"}}); \
|
||||
expressions are text, or a number in mm or degrees} → recomputed: features rebuilt, restored: features whose earlier result for the same inputs was reused",
|
||||
),
|
||||
CommandSpec::new("timeline.inspect", "Inspect Feature", inspect_feature).icon("inspect").noundo().params(
|
||||
CommandSpec::new("timeline.inspect", "Inspect Feature", crate::checked!("timeline.inspect", inspect_feature))
|
||||
.icon("inspect")
|
||||
.noundo()
|
||||
.contract(&INSPECT_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<TimelineInspectResult>)
|
||||
.params(
|
||||
"group: folder id → group, name, parent, features, joints; or feature: id|name → type, definition (the fields timeline.edit's set changes, with their current values), \
|
||||
inputs: [{label, param, expression, unit}] (named inputs parameters.change can also set), error, warning",
|
||||
),
|
||||
@@ -121,6 +162,15 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("body?: world body name, face?: face index; defaults to the current body/face selection → selects objects using the resolved appearance"),
|
||||
];
|
||||
|
||||
const COMPUTE_ALL_CONTRACT: TypedContract<TimelineComputeAllParams> = TypedContract::new();
|
||||
const DELETE_CONTRACT: TypedContract<TimelineDeleteParams> = TypedContract::new();
|
||||
const RENAME_CONTRACT: TypedContract<TimelineRenameParams> = TypedContract::new();
|
||||
const ROLLBACK_CONTRACT: TypedContract<TimelineRollbackParams> = TypedContract::new();
|
||||
const ROLL_TO_CONTRACT: TypedContract<TimelineRollToParams> = TypedContract::new();
|
||||
const DEPENDENTS_CONTRACT: TypedContract<TimelineDependentsParams> = TypedContract::new();
|
||||
const SUPPRESS_CONTRACT: TypedContract<TimelineSuppressParams> = TypedContract::new();
|
||||
const EDIT_CONTRACT: TypedContract<TimelineEditParams> = TypedContract::new();
|
||||
const INSPECT_CONTRACT: TypedContract<TimelineInspectParams> = TypedContract::new();
|
||||
const PLAY_CONTRACT: crate::schema::TypedContract<crate::schema::edit::TimelinePlayParams> = crate::schema::TypedContract::new();
|
||||
|
||||
fn play(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -222,7 +272,7 @@ fn compute_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
s.set_validation(validation);
|
||||
s.refresh();
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
Ok(json!({"features": s.model.results.len(), "errors": errors}))
|
||||
reply("timeline.compute_all", TimelineComputeAllResult { features: s.model.results.len(), errors, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
pub(super) fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result<u64> {
|
||||
@@ -269,24 +319,21 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.selection.clear();
|
||||
s.refresh();
|
||||
let failing: Vec<Value> = s
|
||||
let failing: Vec<FailingFeature> = s
|
||||
.model
|
||||
.results
|
||||
.iter()
|
||||
.filter(|r| worked.contains(&r.id))
|
||||
.filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e})))
|
||||
.filter_map(|r| r.error.as_ref().map(|e| FailingFeature { feature: r.name.clone(), error: e.clone() }))
|
||||
.collect();
|
||||
let mut out = json!({"deleted": gone});
|
||||
if !joints.is_empty() {
|
||||
out["joints"] = json!(joints);
|
||||
}
|
||||
if !folders.is_empty() {
|
||||
out["groups"] = json!(folders);
|
||||
}
|
||||
if !failing.is_empty() {
|
||||
out["failing"] = json!(failing);
|
||||
}
|
||||
Ok(out)
|
||||
let out = TimelineDeleteResult {
|
||||
deleted: gone,
|
||||
joints: (!joints.is_empty()).then_some(joints),
|
||||
groups: (!folders.is_empty()).then(|| folders.into_iter().collect()),
|
||||
failing: (!failing.is_empty()).then_some(failing),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -302,7 +349,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::features::check_plane_name(name, cmd)?;
|
||||
}
|
||||
if name == old {
|
||||
return Ok(json!({"feature": id, "name": name}));
|
||||
return reply(cmd, TimelineRenameResult { feature: id, name: name.to_string(), param_warnings: Vec::new() });
|
||||
}
|
||||
let names = BTreeMap::from([(old.clone(), name.to_string())]);
|
||||
let sketches = BTreeMap::new();
|
||||
@@ -322,7 +369,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
*n = name.to_string();
|
||||
}
|
||||
}
|
||||
Ok(json!({"feature": id, "name": name}))
|
||||
reply(cmd, TimelineRenameResult { feature: id, name: name.to_string(), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn timeline_group_id(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
|
||||
@@ -756,7 +803,7 @@ fn move_marker(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
_ => None,
|
||||
};
|
||||
s.selection.clear();
|
||||
Ok(json!({"marker": s.doc.marker.unwrap_or(n), "features": n}))
|
||||
reply(cmd, TimelineMarkerResult { marker: s.doc.marker.unwrap_or(n), features: n, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The features named by `feature: id|name` and/or `features: [id|name]` (or one id|name), in
|
||||
@@ -838,11 +885,12 @@ fn suppress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
f.suppressed = want;
|
||||
}
|
||||
super::joints::suppress_joints(s.doc_mut(), &joints, want);
|
||||
match ids.as_slice() {
|
||||
[id] if groups.is_empty() && p.get("features").is_none_or(Value::is_null) => Ok(json!({"feature": id, "suppressed": want})),
|
||||
_ if groups.is_empty() => Ok(json!({"features": ids, "suppressed": want})),
|
||||
_ => Ok(json!({"features": ids, "joints": joints, "groups": groups, "suppressed": want})),
|
||||
}
|
||||
let changed = match ids.as_slice() {
|
||||
[id] if groups.is_empty() && p.get("features").is_none_or(Value::is_null) => SuppressChanged::One { feature: *id, suppressed: want },
|
||||
_ if groups.is_empty() => SuppressChanged::Features { features: ids, suppressed: want },
|
||||
_ => SuppressChanged::Folders { features: ids, joints, groups, suppressed: want },
|
||||
};
|
||||
reply(cmd, TimelineSuppressResult { changed, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn merge(dst: &mut Value, src: &Value, depth: usize) {
|
||||
@@ -1204,7 +1252,14 @@ fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("group").is_some() {
|
||||
let id = timeline_group_id(s, p, cmd)?;
|
||||
let group = s.doc.timeline_groups.iter().find(|group| group.id == id).ok_or_else(|| bad(cmd, "folder"))?;
|
||||
return Ok(json!({"group": id, "name": group.name, "parent": group.parent, "features": group.features, "joints": group.joints}));
|
||||
let inspected = Inspected::Group {
|
||||
group: id,
|
||||
name: group.name.clone(),
|
||||
parent: group.parent,
|
||||
features: group.features.clone(),
|
||||
joints: group.joints.clone(),
|
||||
};
|
||||
return reply(cmd, TimelineInspectResult { inspected, param_warnings: Vec::new() });
|
||||
}
|
||||
let id = feature_id(s, p.get("feature"), cmd)?;
|
||||
let f = s.doc.feature(id).ok_or_else(|| bad(cmd, "feature"))?;
|
||||
@@ -1216,27 +1271,39 @@ fn inspect_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
for (k, why) in heavy_fields(&f.kind) {
|
||||
if o.remove(*k).is_some() {
|
||||
omitted.push(json!({"field": k, "what": why}));
|
||||
omitted.push(OmittedField { field: k.to_string(), what: why.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
let inputs: Vec<Value> = f
|
||||
let inputs: Vec<FeatureInput> = f
|
||||
.kind
|
||||
.inputs()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (label, e, k))| {
|
||||
json!({"label": label, "param": f.param_names.get(i).filter(|n| !n.is_empty()), "expression": e, "unit": k.default_unit()})
|
||||
.map(|(i, (label, e, k))| FeatureInput {
|
||||
label: label.to_string(),
|
||||
param: f.param_names.get(i).filter(|n| !n.is_empty()).cloned(),
|
||||
expression: e,
|
||||
unit: k.default_unit().to_string(),
|
||||
})
|
||||
.collect();
|
||||
let marker = s.doc.marker.unwrap_or(usize::MAX);
|
||||
let rolled_back = s.doc.features.iter().position(|x| x.id == id).is_some_and(|i| i >= marker);
|
||||
let r = s.model.result(id);
|
||||
Ok(json!({
|
||||
"id": f.id, "name": f.name, "type": f.kind.kind_key(), "type_name": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": rolled_back,
|
||||
"definition": def, "omitted": omitted, "inputs": inputs,
|
||||
"error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()),
|
||||
}))
|
||||
let info = FeatureInfo {
|
||||
id: f.id,
|
||||
name: f.name.clone(),
|
||||
kind: f.kind.kind_key().to_string(),
|
||||
type_name: f.kind.type_name().to_string(),
|
||||
suppressed: f.suppressed,
|
||||
rolled_back,
|
||||
definition: def,
|
||||
omitted,
|
||||
inputs,
|
||||
error: r.and_then(|r| r.error.clone()),
|
||||
warning: r.and_then(|r| r.warning.clone()),
|
||||
};
|
||||
reply(cmd, TimelineInspectResult { inspected: Inspected::Feature(Box::new(info)), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1334,7 +1401,7 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) {
|
||||
return Err(EngineError::Other(e));
|
||||
}
|
||||
Ok(json!({"feature": id, "recomputed": s.model.last_recomputed, "restored": s.model.last_restored}))
|
||||
reply(cmd, TimelineEditResult { feature: id, recomputed: s.model.last_recomputed, restored: s.model.last_restored, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Complete a partial script pick without changing the stored selection type or reinterpreting
|
||||
@@ -2049,7 +2116,7 @@ fn dependents(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
after.evaluate(&next);
|
||||
let names = |ids: &[u64]| -> Vec<String> { ids.iter().filter(|x| **x != id).filter_map(|x| s.doc.feature(*x).map(|f| f.name.clone())).collect() };
|
||||
let broken: Vec<String> = newly_broken_in_history(&s.doc, &s.model, &next, &after).into_iter().map(|(n, _)| n).collect();
|
||||
Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken}))
|
||||
reply(cmd, TimelineDependentsResult { deleted_with_it: names(&gone), would_fail: broken, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,8 +13,9 @@ use solidcraft_geom::prelude::*;
|
||||
use super::{CommandSpec, not_in_sketch};
|
||||
use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3};
|
||||
use crate::schema::features::{
|
||||
AppliedRadius, BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice, FeatureResult, FilletParams,
|
||||
FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
||||
AppliedRadius, BoundingSolidParams, BoxParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice,
|
||||
FeatureResult, FilletParams, FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams,
|
||||
TorusParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -28,6 +29,7 @@ const CONE_CONTRACT: TypedContract<ConeParams> = TypedContract::new();
|
||||
const PIPE_CONTRACT: TypedContract<PipeParams> = TypedContract::new();
|
||||
const BOUNDING_SOLID_CONTRACT: TypedContract<BoundingSolidParams> = TypedContract::new();
|
||||
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
|
||||
const COMBINE_CONTRACT: TypedContract<CombineParams> = TypedContract::new();
|
||||
const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::new();
|
||||
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
|
||||
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
|
||||
@@ -87,7 +89,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.output(output_schema_for::<FilletResult>)
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
||||
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
||||
CommandSpec::new("solid.combine", "Combine", crate::checked!("solid.combine", combine))
|
||||
.at("SOLID", "MODIFY")
|
||||
.icon("combine")
|
||||
.contract(&COMBINE_CONTRACT)
|
||||
.output(output_schema_for::<CombineResult>)
|
||||
.params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
||||
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pattern_rect")
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::mesh_distance::TriangleSet;
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
@@ -21,6 +21,8 @@ use solidcraft_kernel::Body;
|
||||
use solidcraft_kernel::profile::KernelTimes;
|
||||
|
||||
use crate::params::{bad, num, string_list};
|
||||
use crate::schema::clearance::{Overlap, OverlapBounds, Unchecked};
|
||||
use crate::schema::interference::{InterferenceResult, PairTime};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Most pairs with overlapping bounding boxes one call intersects.
|
||||
@@ -49,7 +51,7 @@ struct Job {
|
||||
|
||||
/// What an exact check of one pair came to.
|
||||
enum Outcome {
|
||||
Hit(Value),
|
||||
Hit(Overlap),
|
||||
Clear,
|
||||
Unchecked(String),
|
||||
}
|
||||
@@ -159,26 +161,30 @@ pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let waited_ms = waited.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
// Mesh bodies are not intersected: say so, rather than let `count: 0` read as "no overlap".
|
||||
let mut unchecked: Vec<Value> = meshes_left.iter().map(|b| json!({"a": b.name, "b": null, "reason": MESH_NOT_CHECKED})).collect();
|
||||
let mut unchecked: Vec<Unchecked> =
|
||||
meshes_left.iter().map(|b| Unchecked { a: b.name.clone(), b: None, reason: MESH_NOT_CHECKED.to_string() }).collect();
|
||||
let (mut hits, mut timed) = (Vec::new(), Vec::new());
|
||||
let (mut not_checked, mut boolean_ms, mut tessellate_ms) = (0usize, 0.0, 0.0);
|
||||
for ((a, b), d) in done {
|
||||
let Some(d) = d else {
|
||||
not_checked += 1;
|
||||
unchecked.push(json!({"a": a, "b": b, "reason": format!("not checked: the time budget of {budget_s} s ran out (budget_s)")}));
|
||||
unchecked.push(Unchecked { a, b: Some(b), reason: format!("not checked: the time budget of {budget_s} s ran out (budget_s)") });
|
||||
continue;
|
||||
};
|
||||
boolean_ms += d.kernel.boolean_ms;
|
||||
tessellate_ms += d.kernel.tessellate_ms;
|
||||
timed.push((
|
||||
d.ms,
|
||||
json!({"a": a, "b": b, "ms": round_ms(d.ms), "boolean_ms": round_ms(d.kernel.boolean_ms),
|
||||
"tessellate_ms": round_ms(d.kernel.tessellate_ms)}),
|
||||
));
|
||||
let time = PairTime {
|
||||
a: a.clone(),
|
||||
b: b.clone(),
|
||||
ms: round_ms(d.ms),
|
||||
boolean_ms: round_ms(d.kernel.boolean_ms),
|
||||
tessellate_ms: round_ms(d.kernel.tessellate_ms),
|
||||
};
|
||||
timed.push((d.ms, time));
|
||||
match d.outcome {
|
||||
Outcome::Hit(v) => hits.push(v),
|
||||
Outcome::Hit(overlap) => hits.push(overlap),
|
||||
Outcome::Clear => {}
|
||||
Outcome::Unchecked(reason) => unchecked.push(json!({"a": a, "b": b, "reason": reason})),
|
||||
Outcome::Unchecked(reason) => unchecked.push(Unchecked { a, b: Some(b), reason }),
|
||||
}
|
||||
}
|
||||
// A timing profile of the command (`--profile`) counts the kernel time the workers spent,
|
||||
@@ -189,15 +195,28 @@ pub(super) fn interference(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
solidcraft_kernel::profile::credit(KernelTimes { boolean_ms: boolean_ms * share, tessellate_ms: tessellate_ms * share });
|
||||
}
|
||||
timed.sort_by(|x, y| y.0.total_cmp(&x.0));
|
||||
let slowest: Vec<Value> = timed.into_iter().take(SLOWEST).map(|t| t.1).collect();
|
||||
Ok(json!({
|
||||
"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked,
|
||||
"truncated": skipped > 0, "skipped_pairs": skipped,
|
||||
"pairs_candidate": pairs.len(), "pairs_cleared": cleared, "pairs_intersected": intersected,
|
||||
"pairs_not_checked": not_checked, "budget_s": budget_s, "budget_exceeded": not_checked > 0,
|
||||
"threads": threads, "boolean_ms": round_ms(boolean_ms), "tessellate_ms": round_ms(tessellate_ms),
|
||||
"slowest": slowest, "ms": round_ms(started.elapsed().as_secs_f64() * 1000.0),
|
||||
}))
|
||||
let slowest: Vec<PairTime> = timed.into_iter().take(SLOWEST).map(|t| t.1).collect();
|
||||
let reply = InterferenceResult {
|
||||
count: hits.len(),
|
||||
interferences: hits,
|
||||
checked: bodies.len(),
|
||||
unchecked,
|
||||
truncated: skipped > 0,
|
||||
skipped_pairs: skipped,
|
||||
pairs_candidate: pairs.len(),
|
||||
pairs_cleared: cleared,
|
||||
pairs_intersected: intersected,
|
||||
pairs_not_checked: not_checked,
|
||||
budget_s,
|
||||
budget_exceeded: not_checked > 0,
|
||||
threads,
|
||||
boolean_ms: round_ms(boolean_ms),
|
||||
tessellate_ms: round_ms(tessellate_ms),
|
||||
slowest,
|
||||
ms: round_ms(started.elapsed().as_secs_f64() * 1000.0),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| crate::EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn round_ms(ms: f64) -> f64 {
|
||||
@@ -284,9 +303,14 @@ fn check_pair(a: &Body, b: &Body, (na, nb): &(String, String)) -> Outcome {
|
||||
Err(e) => return Outcome::Unchecked(e.to_string()),
|
||||
};
|
||||
if m.volume > threshold {
|
||||
let hit = json!({"a": na, "b": nb, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid,
|
||||
"bbox": {"min": m.bbox.min, "max": m.bbox.max}});
|
||||
return Outcome::Hit(hit);
|
||||
return Outcome::Hit(Overlap {
|
||||
a: na.clone(),
|
||||
b: nb.clone(),
|
||||
volume_mm3: m.volume,
|
||||
area_mm2: m.area,
|
||||
bbox: OverlapBounds { min: m.bbox.min.into(), max: m.bbox.max.into() },
|
||||
center: m.centroid.into(),
|
||||
});
|
||||
}
|
||||
Outcome::Clear
|
||||
}
|
||||
|
||||
@@ -109,7 +109,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
links: [{index, role: driver|driven, …as joint.list}], errors, conflicts}] (reads only: nothing changes)",
|
||||
),
|
||||
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"),
|
||||
CommandSpec::new("inspect.interference", "Interference", super::interference::interference).at("SOLID", "INSPECT").icon("interference").noundo().params(
|
||||
CommandSpec::new("inspect.interference", "Interference", crate::checked!("inspect.interference", super::interference::interference))
|
||||
.contract(&crate::schema::TypedContract::<crate::schema::interference::InterferenceParams>::new())
|
||||
.output(crate::schema::output_schema_for::<crate::schema::interference::InterferenceResult>)
|
||||
.at("SOLID", "INSPECT")
|
||||
.icon("interference")
|
||||
.noundo()
|
||||
.params(
|
||||
"bodies?: [names] (default: all, in world placement; a name that is no body is refused); budget_s?: seconds (default 240, at most 86400: pairs not finished by then are listed in unchecked as \"not checked: the time budget … ran out\" and the call returns) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: [{a, b, reason}], the pairs that could not be checked, with the reason, and each mesh body (STL, 3MF or OBJ import: not intersected) as {a: name, b: null, reason: \"mesh body: not checked\"}, so count: 0 is not read as no overlap (checked counts the solid bodies). Bodies are pruned by bounding box first, then by their display meshes (pairs whose meshes stay apart by more than their chord tolerances, neither inside the other, are clear without a boolean), so any number of bodies is fine; the pairs left are intersected exactly, in parallel on desktop, the slowest first; at most 20000 pairs whose boxes overlap are intersected per call (truncated: true and skipped_pairs: n say when more were left out). Also: pairs_candidate (boxes overlap), pairs_cleared (by the meshes), pairs_intersected, pairs_not_checked, budget_exceeded, threads (worker threads; 0: on the calling thread), boolean_ms and tessellate_ms (kernel time summed over the pairs), slowest: [{a, b, ms, boolean_ms, tessellate_ms}] and ms",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -3,26 +3,161 @@
|
||||
//! A curved face's area is the area of the surface it lies on: a cylinder's band is
|
||||
//! r·θ·h and a whole sphere 4πr². The display mesh's triangles run inside a curve, so
|
||||
//! their sum falls short of it.
|
||||
//!
|
||||
//! A flat face's area is the area its exact edges enclose (#12372): its outer loop's area
|
||||
//! less its holes'. The display mesh's polygon for a round hole runs inside the circle, so
|
||||
//! the mesh's holes are too small and the face reads too big.
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
use solidcore_record::brep::Curve;
|
||||
use solidcore_record::brep::{Curve, Loop};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_geom::{Mesh, UnitVec3};
|
||||
use solidcraft_kernel::{Body, FaceSurface};
|
||||
|
||||
/// The area of the patch of faces `patch` of `body`, worked out on the surface behind
|
||||
/// them: a cylinder's band, or a sphere. `None` where the surface does not give one, and
|
||||
/// the caller keeps the measure of the mesh.
|
||||
/// them: a flat patch, a cylinder's band, or a sphere. `None` where the surface does not
|
||||
/// give one, and the caller keeps the measure of the mesh.
|
||||
pub(crate) fn patch_area(body: &Body, mesh: &Mesh, patch: &[usize]) -> Option<f64> {
|
||||
match solidcraft_kernel::face_surface(body, *patch.first()?)? {
|
||||
FaceSurface::Plane { normal, point } => flat_patch(body, patch, normal, point),
|
||||
FaceSurface::Cylinder { origin, axis, radius } => cylinder_band(body, mesh, patch, origin, axis, radius),
|
||||
FaceSurface::Sphere { radius, .. } => sphere(body, patch, radius),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two orthonormal directions in a plane and a point on it: the plane's own 2D coordinates.
|
||||
struct PlaneFrame {
|
||||
origin: Vec3,
|
||||
normal: Vec3,
|
||||
x: Vec3,
|
||||
y: Vec3,
|
||||
}
|
||||
|
||||
impl PlaneFrame {
|
||||
fn new(normal: UnitVec3, origin: Vec3) -> Option<PlaneFrame> {
|
||||
let n = normal.vec();
|
||||
// The world axis least along the normal gives a direction across it.
|
||||
let across = if n.x.abs() <= n.y.abs() && n.x.abs() <= n.z.abs() {
|
||||
Vec3::X
|
||||
} else if n.y.abs() <= n.z.abs() {
|
||||
Vec3::Y
|
||||
} else {
|
||||
Vec3::Z
|
||||
};
|
||||
let x = n.cross(across).normalized()?;
|
||||
Some(PlaneFrame { origin, normal: n, x, y: n.cross(x) })
|
||||
}
|
||||
|
||||
/// The 2D coordinates of point `p`.
|
||||
fn point(&self, p: Vec3) -> (f64, f64) {
|
||||
let d = p - self.origin;
|
||||
(d.dot(self.x), d.dot(self.y))
|
||||
}
|
||||
|
||||
/// The 2D components of direction `d`.
|
||||
fn dir(&self, d: Vec3) -> (f64, f64) {
|
||||
(d.dot(self.x), d.dot(self.y))
|
||||
}
|
||||
}
|
||||
|
||||
/// The area of a flat patch: every face of it lies on one plane, and each face's area is the
|
||||
/// area its loops of exact edges enclose.
|
||||
fn flat_patch(body: &Body, patch: &[usize], normal: UnitVec3, point: Vec3) -> Option<f64> {
|
||||
let tol = (body.size() * 1e-9).max(1e-9);
|
||||
let frame = PlaneFrame::new(normal, point)?;
|
||||
let mut total = 0.0;
|
||||
for &face in patch {
|
||||
match solidcraft_kernel::face_surface(body, face)? {
|
||||
FaceSurface::Plane { normal: n, point: p }
|
||||
if n.vec().dot(frame.normal).abs() >= 1.0 - 1e-9 && (p - point).dot(frame.normal).abs() <= tol => {}
|
||||
_ => return None,
|
||||
}
|
||||
total += flat_face(body, face, &frame, tol)?;
|
||||
}
|
||||
(total.is_finite() && total > 0.0).then_some(total)
|
||||
}
|
||||
|
||||
/// The area of flat face `face`: the area its outer loop encloses, less the areas its holes
|
||||
/// enclose. The outer loop is the one that encloses the most: every hole lies inside it.
|
||||
fn flat_face(body: &Body, face: usize, frame: &PlaneFrame, tol: f64) -> Option<f64> {
|
||||
let mut loops: Vec<f64> = Vec::new();
|
||||
for l in &body.record().faces.get(face)?.loops {
|
||||
let Loop::Edges { edges } = l else { continue };
|
||||
let mut enclosed = 0.0;
|
||||
for used in edges {
|
||||
enclosed += edge_sweep(body, used.edge as usize, used.forward, frame, tol)?;
|
||||
}
|
||||
loops.push(enclosed.abs());
|
||||
}
|
||||
let outer = loops.iter().copied().fold(0.0, f64::max);
|
||||
let area = outer - (loops.iter().sum::<f64>() - outer);
|
||||
(area.is_finite() && area > 0.0).then_some(area)
|
||||
}
|
||||
|
||||
/// Four-point Gauss–Legendre nodes and weights on `[−1, 1]`.
|
||||
const GAUSS4: [(f64, f64); 4] = [
|
||||
(-0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
|
||||
(-0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
|
||||
(0.339_981_043_584_856_3, 0.652_145_154_862_546_2),
|
||||
(0.861_136_311_594_052_6, 0.347_854_845_137_453_8),
|
||||
];
|
||||
|
||||
/// Gauss panels along an edge that is neither a line nor a circle.
|
||||
const SWEEP_PANELS: usize = 64;
|
||||
|
||||
/// The signed area that edge `edge` sweeps about the frame's origin, `½∫(x dy − y dx)`, run
|
||||
/// along the loop's direction (`forward`: from the edge's start to its end). Summed round a
|
||||
/// loop, it is the area the loop encloses (by Green's theorem). A circle or an arc of one is
|
||||
/// worked out in closed form: the triangle to its chord and the circular segment beyond it.
|
||||
/// `None` for an edge off the plane, or one whose curve gives no derivative.
|
||||
fn edge_sweep(body: &Body, edge: usize, forward: bool, frame: &PlaneFrame, tol: f64) -> Option<f64> {
|
||||
let triangle = |a: Vec3, b: Vec3| {
|
||||
let ((ax, ay), (bx, by)) = (frame.point(a), frame.point(b));
|
||||
0.5 * (ax * by - bx * ay)
|
||||
};
|
||||
if let Some(c) = solidcraft_kernel::edge_circle(body, edge) {
|
||||
let along = c.axis.vec().dot(frame.normal);
|
||||
if along.abs() < 1.0 - 1e-9 || (c.center - frame.origin).dot(frame.normal).abs() > tol {
|
||||
return None;
|
||||
}
|
||||
// The arc runs counter-clockwise about its axis from its start to its end.
|
||||
let (a, b, turn) = if forward { (c.start, c.end, along.signum()) } else { (c.end, c.start, -along.signum()) };
|
||||
let segment = 0.5 * c.radius * c.radius * (c.angle - c.angle.sin());
|
||||
return Some(triangle(a, b) + turn * segment);
|
||||
}
|
||||
let e = body.record().edges.get(edge)?;
|
||||
if matches!(e.curve, Curve::Line { .. }) {
|
||||
let c = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()).ok()?;
|
||||
let [t0, t1] = e.interval;
|
||||
let (a, b) = (c.point(t0)?, c.point(t1)?);
|
||||
let off = |p: Vec3| (p - frame.origin).dot(frame.normal).abs() > tol.max(e.tolerance);
|
||||
if off(a) || off(b) {
|
||||
return None;
|
||||
}
|
||||
return Some(if forward { triangle(a, b) } else { triangle(b, a) });
|
||||
}
|
||||
let c = solidcraft_geom::brep::eval::CurveEval::new(e.parameters()).ok()?;
|
||||
let [t0, t1] = e.interval;
|
||||
let h = (t1 - t0) / SWEEP_PANELS as f64;
|
||||
let mut swept = 0.0;
|
||||
for panel in 0..SWEEP_PANELS {
|
||||
let start = t0 + h * panel as f64;
|
||||
for (x, w) in GAUSS4 {
|
||||
let (p, d) = c.derivs(start + 0.5 * h * (x + 1.0))?;
|
||||
if (p - frame.origin).dot(frame.normal).abs() > tol.max(e.tolerance) {
|
||||
return None;
|
||||
}
|
||||
let ((px, py), (dx, dy)) = (frame.point(p), frame.dir(d));
|
||||
swept += 0.5 * h * w * 0.5 * (px * dy - py * dx);
|
||||
}
|
||||
}
|
||||
let swept = if forward { swept } else { -swept };
|
||||
swept.is_finite().then_some(swept)
|
||||
}
|
||||
|
||||
/// The edges on the border of the patch: the ones a face of the patch shares with a face
|
||||
/// outside it, and the ones it keeps to itself.
|
||||
fn border_edges(mesh: &Mesh, patch: &[usize]) -> Vec<usize> {
|
||||
|
||||
@@ -351,8 +351,9 @@ fn describe(s: &Session, st: &solidcraft_doc::ModelState, sel: &Sel) -> Option<I
|
||||
let patch = m.surface_patch(*index);
|
||||
let tris = patch.iter().flat_map(|f| face_tris(&m, *f)).collect::<Vec<_>>();
|
||||
let on_mesh: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
|
||||
// A curved face is measured on the surface it lies on, not on the triangles of
|
||||
// the display mesh, which run inside the curve.
|
||||
// A face is measured on the surface it lies on and the exact edges around it, not
|
||||
// on the triangles of the display mesh, which run inside every curve: a curved
|
||||
// face's and a round hole's alike.
|
||||
let area = super::measure_exact::patch_area(&b.body, &m, &patch).unwrap_or(on_mesh);
|
||||
let normals: Vec<UnitVec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).unit()).collect();
|
||||
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
|
||||
@@ -456,8 +457,10 @@ fn axis_named(s: &Session, name: &str) -> Option<(Vec3, Vec3)> {
|
||||
}
|
||||
|
||||
/// The closest points of two exact shapes, where the sampled points would be wrong: a plane or
|
||||
/// an axis runs on, so a point set of it is not the object.
|
||||
fn exact_min(a: &Shape, b: &Shape) -> Option<(f64, Vec3, Vec3)> {
|
||||
/// an axis runs on, so a point set of it is not the object. `anchor` is a point of `a` to
|
||||
/// measure from where any point would do (two parallel planes); `unbounded` says whether `a`
|
||||
/// and `b` run on for ever (datum planes and axes) rather than ending (faces and edges).
|
||||
fn exact_min(a: &Shape, b: &Shape, anchor: Option<Vec3>, unbounded: (bool, bool)) -> Option<(f64, Vec3, Vec3)> {
|
||||
use Shape::*;
|
||||
let to_plane = |p: Vec3, point: Vec3, normal: UnitVec3| {
|
||||
let n = normal.vec();
|
||||
@@ -483,17 +486,7 @@ fn exact_min(a: &Shape, b: &Shape) -> Option<(f64, Vec3, Vec3)> {
|
||||
}
|
||||
(Point(p), Point(q)) => Some((p.dist(*q), *p, *q)),
|
||||
(Plane { point: p1, normal: n1 }, Plane { point: p2, normal: n2 }) => {
|
||||
let (v1, v2) = (n1.vec(), n2.vec());
|
||||
if v1.cross(v2).len() < 1e-9 {
|
||||
let h = (*p2 - *p1).dot(v1);
|
||||
Some((h.abs(), *p1, *p2 - v1 * h))
|
||||
} else {
|
||||
// They meet: a point on the line of intersection has distance zero.
|
||||
let dir = v1.cross(v2);
|
||||
let t = -(*p1 - *p2).dot(v2) / dir.dot(v2);
|
||||
let p = *p1 + dir * t;
|
||||
Some((0.0, p, p))
|
||||
}
|
||||
super::measure_shape::plane_plane(*p1, *n1, *p2, *n2, anchor.unwrap_or(*p1), unbounded.0 && unbounded.1)
|
||||
}
|
||||
(Plane { point, normal }, Line { a: la, b: lb }) => {
|
||||
let d = *lb - *la;
|
||||
@@ -711,7 +704,10 @@ pub(crate) fn measure_items(s: &Session, items: &Value) -> Result<Value> {
|
||||
out["delta"] = json!(q - p);
|
||||
out["min"] = json!(d);
|
||||
}
|
||||
if let Some((d, p, q)) = exact_min(&a.shape, &b.shape) {
|
||||
let unbounded = |i: usize| matches!(sels.get(i), Some(Sel::Plane { .. } | Sel::Axis { .. }));
|
||||
if let Some((d, p, q)) = exact_min(&a.shape, &b.shape, near.map(|(p, _)| p), (unbounded(0), unbounded(1)))
|
||||
.filter(|(d, p, q)| d.is_finite() && p.is_finite() && q.is_finite())
|
||||
{
|
||||
out["distance_mm"] = json!(d);
|
||||
out["from"] = json!(p);
|
||||
out["to"] = json!(q);
|
||||
|
||||
@@ -271,6 +271,32 @@ fn from_round(a: &Shape, b: &Shape) -> Option<Centre> {
|
||||
(c.distance.is_finite() && c.from.is_finite() && c.to.is_finite()).then_some(c)
|
||||
}
|
||||
|
||||
/// The distance between two planes, `from` on the first and `to` on the second.
|
||||
///
|
||||
/// Parallel planes (normals within 1e-6 rad, as [`parallel`] counts axes) are the
|
||||
/// perpendicular gap between them (#11833), measured from `anchor` (a point of the first item,
|
||||
/// so the dimension is drawn on it) moved onto the first plane. Planes at an angle meet: only
|
||||
/// when both run on for ever (datum planes, `meet`) is that a distance of zero on their line of
|
||||
/// intersection; a face ends, so its distance is left to its own points (`None`).
|
||||
pub(crate) fn plane_plane(p1: Vec3, n1: UnitVec3, p2: Vec3, n2: UnitVec3, anchor: Vec3, meet: bool) -> Option<(f64, Vec3, Vec3)> {
|
||||
let (v1, v2) = (n1.vec(), n2.vec());
|
||||
let across = v1.cross(v2);
|
||||
if across.len() < 1e-6 {
|
||||
let from = anchor - v1 * (anchor - p1).dot(v1);
|
||||
let h = (from - p2).dot(v1);
|
||||
return Some((h.abs(), from, from - v1 * h));
|
||||
}
|
||||
if !meet {
|
||||
return None;
|
||||
}
|
||||
// The point of both planes nearest the first plane's point: from `p1` across the first
|
||||
// plane, square to the line `n1 × n2`, until it reaches the second plane. Worked from the
|
||||
// cross product, not `1 - (n1·n2)²`, which cancels when the planes are nearly parallel.
|
||||
let w = across.cross(v1);
|
||||
let p = p1 + w * ((p2 - p1).dot(v2) / across.dot(across));
|
||||
p.is_finite().then_some((0.0, p, p))
|
||||
}
|
||||
|
||||
/// A slot's sizes: width (the ends' diameter), the distance between the ends' centres and the
|
||||
/// overall length, with the two centres.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -504,4 +530,42 @@ mod tests {
|
||||
assert!(slot_outline(&arcs, &skew).is_none());
|
||||
assert!(slot_outline(&[(Vec2::new(0.0, 0.0), 3.0, PI), (Vec2::new(20.0, 0.0), 4.0, PI)], &lines).is_none());
|
||||
}
|
||||
|
||||
/// Two walls of a box whose normals are parallel only to rounding (#11833): the gap between
|
||||
/// the planes, from the picked point on the first to its foot on the second, never NaN.
|
||||
#[test]
|
||||
fn nearly_parallel_planes_measure_the_gap_between_them() {
|
||||
let tilt = UnitVec3::new(Vec3::new(1e-8, 0.0, 1.0)).unwrap();
|
||||
for (n2, gap) in [(UnitVec3::Z, 20.0), (-UnitVec3::Z, 20.0), (tilt, 20.0)] {
|
||||
let anchor = Vec3::new(7.0, 3.0, 0.5);
|
||||
let (d, from, to) = plane_plane(Vec3::new(100.0, -50.0, 0.0), UnitVec3::Z, Vec3::new(-4.0, 9.0, gap), n2, anchor, false).unwrap();
|
||||
assert!((d - gap).abs() < 1e-6, "{n2:?}: {d}");
|
||||
assert!(from.is_finite() && to.is_finite());
|
||||
assert!(from.dist(Vec3::new(7.0, 3.0, 0.0)) < 1e-12, "from lies on the first plane under the pick: {from:?}");
|
||||
assert!((to.z - gap).abs() < 1e-6 && (to - from).cross(Vec3::Z).len() < 1e-6, "to lies on the second plane: {to:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Planes at an angle: datum planes meet (zero, on both planes); faces end, so their
|
||||
/// distance is left to their points.
|
||||
#[test]
|
||||
fn planes_at_an_angle_meet_only_when_they_run_on() {
|
||||
let (p1, p2) = (Vec3::new(0.0, 0.0, 5.0), Vec3::new(3.0, 0.0, 0.0));
|
||||
let n2 = UnitVec3::new(Vec3::new(1.0, 0.0, 1.0)).unwrap();
|
||||
let (d, from, to) = plane_plane(p1, UnitVec3::Z, p2, n2, p1, true).unwrap();
|
||||
assert_eq!((d, from), (0.0, to));
|
||||
assert!(from.is_finite() && (from.z - 5.0).abs() < 1e-12 && (from - p2).dot(n2.vec()).abs() < 1e-12, "{from:?}");
|
||||
assert!(plane_plane(p1, UnitVec3::Z, p2, n2, p1, false).is_none());
|
||||
assert!(plane_plane(p1, UnitVec3::Z, p2, UnitVec3::X, p1, true).is_some_and(|(_, p, _)| p.is_finite()));
|
||||
// Just past parallel and far from the origin: the point still lies on both planes.
|
||||
for (tilt, z1, z2) in [(2e-6, 0.0, 20.0), (2e-6, 100.0, 120.0), (1.01e-6, 1e5, 100_020.0)] {
|
||||
let (p1, p2) = (Vec3::new(3.0, -4.0, z1), Vec3::new(-7.0, 2.0, z2));
|
||||
let n2 = UnitVec3::new(Vec3::new(tilt, 0.0, 1.0)).unwrap();
|
||||
let (d, p, _) = plane_plane(p1, UnitVec3::Z, p2, n2, p1, true).unwrap();
|
||||
let scale = 1.0 + p.len();
|
||||
assert_eq!(d, 0.0);
|
||||
assert!((p.z - z1).abs() < 1e-9 * scale, "tilt {tilt}: {p:?} off the first plane");
|
||||
assert!((p - p2).dot(n2.vec()).abs() < 1e-9 * scale, "tilt {tilt}: {p:?} off the second plane");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1830,6 +1830,31 @@ fn a_body_copied_in_another_design_pastes_as_an_imported_body() {
|
||||
assert!(s.model.state().body("Cube (2)").is_none());
|
||||
}
|
||||
|
||||
/// A body pasted from another design while an exploded view moves the target component is at
|
||||
/// the world coordinates it had there once the target is shown assembled again.
|
||||
#[test]
|
||||
fn a_body_pasted_from_another_design_while_an_exploded_view_is_shown_keeps_its_world_position() {
|
||||
let mut source = Session::default();
|
||||
source.doc_mut().name = "Bracket".into();
|
||||
run(&mut source, "solid.box", json!({"length": 2, "width": 2, "height": 2, "body_name": "Cube"}));
|
||||
let was = source.world_state().unwrap().body("Cube").unwrap().mesh().bounds();
|
||||
let copy = crate::ForeignCopy::of(&source, vec!["Cube".into()], Vec::new()).unwrap();
|
||||
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 1, "width": 1, "height": 1, "body_name": "Block"}));
|
||||
let (a, a_at) = created(&mut s, "A");
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": a_at, "translate": [100,0,0]}));
|
||||
// An exploded view needs two placements: B stays put.
|
||||
run(&mut s, "component.create", json!({"name": "B", "parent": 0, "activate": false}));
|
||||
let view = run(&mut s, "explode.create", json!({"moves": [{"occurrence": a_at, "translate": [0, 60, 0]}]}))["view"].as_u64().unwrap();
|
||||
run(&mut s, "explode.show", json!({"view": view}));
|
||||
s.foreign_copy = Some(std::sync::Arc::new(copy));
|
||||
run(&mut s, "component.paste_from_design", json!({"component": a, "component_path": [a_at]}));
|
||||
run(&mut s, "explode.show", json!({}));
|
||||
let is = s.world_state().unwrap().body("Cube").unwrap().mesh().bounds();
|
||||
assert!(was.min.dist(is.min) < 1e-6 && was.max.dist(is.max) < 1e-6, "{was:?} vs {is:?}");
|
||||
}
|
||||
|
||||
/// A body seen in another placement of its component ("Cube (Holder:2)") pastes into another
|
||||
/// design under its own name, at that placement's position.
|
||||
#[test]
|
||||
|
||||
@@ -12,8 +12,10 @@ use solidcraft_geom::prelude::*;
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, check_expr};
|
||||
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3};
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::plastic::{
|
||||
AssignRuleParams, AssignRuleResult, ManageRulesParams, ManageRulesResult, RuleExpressions, RuleInfo, RuleValuesOrError, RulesParams, RulesResult,
|
||||
AssignRuleParams, AssignRuleResult, BossParams, LipParams, ManageRulesParams, ManageRulesResult, RestParams, RuleExpressions, RuleInfo,
|
||||
RuleValuesOrError, RulesParams, RulesResult, SnapFitParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -21,23 +23,47 @@ use crate::{EngineError, Result, Session};
|
||||
const MANAGE_RULES_CONTRACT: TypedContract<ManageRulesParams> = TypedContract::new();
|
||||
const ASSIGN_RULE_CONTRACT: TypedContract<AssignRuleParams> = TypedContract::new();
|
||||
const RULES_CONTRACT: TypedContract<RulesParams> = TypedContract::new();
|
||||
const BOSS_CONTRACT: TypedContract<BossParams> = TypedContract::new();
|
||||
const LIP_CONTRACT: TypedContract<LipParams> = TypedContract::new();
|
||||
const SNAP_FIT_CONTRACT: TypedContract<SnapFitParams> = TypedContract::new();
|
||||
const REST_CONTRACT: TypedContract<RestParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("plastic.boss", "Boss", boss).at("PLASTIC", "CREATE").icon("boss").params(
|
||||
"position: [x,y,z] on a face; direction? (default: out of the face); diameter, height; hole_diameter?, hole_depth? (default: to the face); \
|
||||
draft? (angle), fillet? (root radius); ribs? (count), rib_thickness?, rib_length? (out from the wall), rib_offset? (below the top); body?",
|
||||
),
|
||||
CommandSpec::new("plastic.lip", "Lip", lip).at("PLASTIC", "CREATE").icon("lip").params(
|
||||
"face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?",
|
||||
),
|
||||
CommandSpec::new("plastic.snap_fit", "Snap Fit", snap_fit).at("PLASTIC", "CREATE").icon("snap_fit").params(
|
||||
"position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; \
|
||||
base_height? (extends the base below the face); new_body?: bool (make a separate body instead of joining); body?",
|
||||
),
|
||||
CommandSpec::new("plastic.rest", "Rest", rest).at("PLASTIC", "CREATE").icon("rest").params(
|
||||
"position: [x,y,z] on a face (the rest's centre); direction? (default: out of the face); width (round: the diameter); length? (a rectangle, along `along`); along?: [x,y,z]; \
|
||||
height; draft? (angle; default: the body's plastic rule); thickness? (wall of a hollow rest); body?",
|
||||
),
|
||||
CommandSpec::new("plastic.boss", "Boss", crate::checked!("plastic.boss", boss))
|
||||
.at("PLASTIC", "CREATE")
|
||||
.icon("boss")
|
||||
.contract(&BOSS_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"position: [x,y,z] on a face; direction? (default: out of the face); diameter, height; hole_diameter?, hole_depth? (default: to the face); \
|
||||
draft? (angle), fillet? (root radius); ribs? (count), rib_thickness?, rib_length? (out from the wall), rib_offset? (below the top); body?",
|
||||
),
|
||||
CommandSpec::new("plastic.lip", "Lip", crate::checked!("plastic.lip", lip))
|
||||
.at("PLASTIC", "CREATE")
|
||||
.icon("lip")
|
||||
.contract(&LIP_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"face: [x,y,z] on the rim face of a shelled body; width, height; type?: lip|groove; gap? (groove clearance); side?: inside|outside (which rim edge); body?",
|
||||
),
|
||||
CommandSpec::new("plastic.snap_fit", "Snap Fit", crate::checked!("plastic.snap_fit", snap_fit))
|
||||
.at("PLASTIC", "CREATE")
|
||||
.icon("snap_fit")
|
||||
.contract(&SNAP_FIT_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"position: [x,y,z] on a face; direction? (default: out of the face); hook: [x,y,z] (the catch's direction); length, thickness, width, catch_depth, catch_length; \
|
||||
base_height? (extends the base below the face); new_body?: bool (make a separate body instead of joining); body?",
|
||||
),
|
||||
CommandSpec::new("plastic.rest", "Rest", crate::checked!("plastic.rest", rest))
|
||||
.at("PLASTIC", "CREATE")
|
||||
.icon("rest")
|
||||
.contract(&REST_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"position: [x,y,z] on a face (the rest's centre); direction? (default: out of the face); width (round: the diameter); length? (a rectangle, along `along`); along?: [x,y,z]; \
|
||||
height; draft? (angle; default: the body's plastic rule); thickness? (wall of a hollow rest); body?",
|
||||
),
|
||||
CommandSpec::new("plastic.manage_rules", "Manage Plastic Rules", crate::checked!("plastic.manage_rules", manage_rules))
|
||||
.at("PLASTIC", "SETUP")
|
||||
.icon("plastic_rule")
|
||||
@@ -152,10 +178,20 @@ fn assign_rule(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bodies.is_empty() || bodies.len() > 10_000 {
|
||||
return Err(bad(cmd, "`bodies` must list bodies"));
|
||||
}
|
||||
// A placed copy's name means the body it shows (#9740); the rule is kept by that body's own
|
||||
// name. The placed bodies are named once for the whole list, not once per name.
|
||||
let st = s.model.state();
|
||||
if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) {
|
||||
return Err(bad(cmd, format!("no body `{b}`")));
|
||||
}
|
||||
let mut frames = None;
|
||||
let bodies = bodies
|
||||
.iter()
|
||||
.map(|b| {
|
||||
if st.body(b).is_some() {
|
||||
return Ok(b.clone());
|
||||
}
|
||||
let frames = frames.get_or_insert_with(|| s.body_frames());
|
||||
frames.iter().find(|(world, ..)| world == b).map(|(_, own, _)| own.clone()).ok_or_else(|| bad(cmd, format!("no body `{b}`")))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
let rule = str_(p, "rule").map(str::trim).unwrap_or_default().to_string();
|
||||
if !rule.is_empty() && s.doc.plastic_rule(&rule).is_none() {
|
||||
return Err(bad(cmd, format!("no plastic rule `{rule}` (plastic.rules lists them)")));
|
||||
@@ -324,6 +360,22 @@ mod tests {
|
||||
(a - b).abs() / b.abs().max(1e-12)
|
||||
}
|
||||
|
||||
/// A placed copy's body name (`KB (K:2)`, as inspect.measure lists it) means the body it
|
||||
/// shows: the rule goes on that body, under its own name, for every placement.
|
||||
#[test]
|
||||
fn a_rule_assigned_by_a_placed_copys_name_goes_on_the_body_it_shows() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "K"}));
|
||||
run(&mut s, "solid.box", json!({"body_name": "KB", "length": 10, "width": 10, "height": 10}));
|
||||
run(&mut s, "occurrence.copy", json!({"component": "K", "translate": [30, 0, 0]}));
|
||||
run(&mut s, "plastic.manage_rules", json!({"name": "Thin", "thickness": "1.2 mm"}));
|
||||
let r = run(&mut s, "plastic.assign_rule", json!({"bodies": ["KB (K:2)"], "rule": "Thin"}));
|
||||
assert_eq!(r["bodies"], json!(["KB"]), "{r}");
|
||||
assert_eq!(s.doc.plastic.assigned.get("KB").map(String::as_str), Some("Thin"));
|
||||
let err = s.execute("plastic.assign_rule", &json!({"bodies": ["KB (K:9)"], "rule": "Thin"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no body `KB (K:9)`"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boss_with_hole_fillet_and_ribs() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -9,6 +9,7 @@ use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::schema::render_camera::{CameraApplied, CameraFitted, CameraInfo, CameraSet, RenderCamera};
|
||||
use crate::schema::render_settings::{RenderSettingsGot, RenderSettingsInfo, RenderSettingsSaved, SETUP_FIELDS, SetupFields, set_message};
|
||||
use crate::{Result, Session};
|
||||
|
||||
mod clear_coat;
|
||||
@@ -140,7 +141,9 @@ CommandSpec::new("render.library.preview", "Inspect Original Surface Map", crate
|
||||
.contract(&library::PREVIEW_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryPreview>)
|
||||
.params("id: SURF-*. Returns bounded hash-verified original base-color PNG bytes and source-map fidelity label, not a rendered material thumbnail. No external paths, rendering or document edits."),
|
||||
CommandSpec::new("render.library.physical.prepare", "Prepare Physical Reference", physical_library::prepare).icon("inspect").noundo().doc_only()
|
||||
CommandSpec::new("render.library.physical.prepare", "Prepare Physical Reference", crate::checked!("render.library.physical.prepare", physical_library::prepare)).icon("inspect").noundo().doc_only()
|
||||
.contract(&physical_library::PREPARE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::PhysicalPlan>)
|
||||
.params("id: named built-in physical profile, name: proposed new material label, acknowledge_reference:true. Read-only loss-aware creation plan using native ValidatedCard admission and one point density; returns source receipts, extended fields and omissions. Does not execute material.create, assign bodies or infer effective-part physics."),
|
||||
CommandSpec::new("render.stage.ground", "Save Render Ground", crate::checked!("render.stage.ground", staging::ground)).icon("render_scene")
|
||||
.contract(&staging::GROUND_CONTRACT)
|
||||
@@ -176,15 +179,25 @@ CommandSpec::new("render.library.describe", "Inspect Render Library Resource", c
|
||||
.contract(&library::DESCRIBE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::LibraryDescription>)
|
||||
.params("kind, id: exact authored resource. Returns original specification, runtime projection variants and explicit omissions. A concept is not an executable preset."),
|
||||
CommandSpec::new("render.library.appearance.apply", "Apply Library Opaque Appearance", library::apply_appearance).icon("appearance")
|
||||
CommandSpec::new("render.library.appearance.apply", "Apply Library Opaque Appearance", crate::checked!("render.library.appearance.apply", library::apply_appearance)).icon("appearance")
|
||||
.contract(&library::APPEARANCE_APPLY_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::AppearanceApplied>)
|
||||
.params("id: SURF-* or SELENE-AP-*; variant: uniform_opaque (required); bodies: 1..256 exact body names. Applies the original scalar baseline through ordinary appearance assignment in one undo, preserving physical properties. Spatial maps and advanced target effects are explicitly excluded."),
|
||||
CommandSpec::new("render.library.environment.apply", "Apply Built-in HDR Environment", library::apply_environment).icon("settings").doc_only()
|
||||
CommandSpec::new("render.library.environment.apply", "Apply Built-in HDR Environment", crate::checked!("render.library.environment.apply", library::apply_environment)).icon("settings").doc_only()
|
||||
.contract(&library::ENVIRONMENT_APPLY_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::EnvironmentApplied>)
|
||||
.params("id: HDR-*; name: saved setup; set?: typed CPU/GPU setup patch; rotation_rad?: authored rotation (native origin offset is explicit in the reply); intensity?:1; samples?:16 for CPU; replace_environment?:false. Hash-verifies and embeds original bytes, saves one atomic undo without external files or a render device. An HDR-owned sun cannot duplicate an analytic sun."),
|
||||
CommandSpec::new("render.library.surface.prepare", "Prepare Authored Surface Maps", library::prepare_surface).icon("appearance").noundo()
|
||||
CommandSpec::new("render.library.surface.prepare", "Prepare Authored Surface Maps", crate::checked!("render.library.surface.prepare", library::prepare_surface)).icon("appearance").noundo()
|
||||
.contract(&library::SURFACE_PREPARE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::SurfacePrepared>)
|
||||
.params("id: SURF-*; body_index and body_name from render.scene; origin?:[0,0,0]; plane?:xy/yz/zx; scale?:1; rotation_rad?:0 (positive around the plane normal). Returns an explicit GPU temporary planar binding and a source usable by saved CPU/GPU surface.bind, with source hashes, original millimetre scale, image-up normal orientation and absolute base-color semantics. No document change; height/displacement and true anisotropy are not included."),
|
||||
CommandSpec::new("render.library.studio.prepare", "Prepare Authored Studio Lights", library::prepare_studio).icon("inspect").noundo().doc_only()
|
||||
CommandSpec::new("render.library.studio.prepare", "Prepare Authored Studio Lights", crate::checked!("render.library.studio.prepare", library::prepare_studio)).icon("inspect").noundo().doc_only()
|
||||
.contract(&library::STUDIO_PREPARE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::StudioPrepared>)
|
||||
.params("id: RIG-*; variant: complete for emitter-only rigs, emitters_only for rigs with passive cards; center_mm and subject_extent_mm required. Converts exact rectangular half-edges and solar direction/radius. Optional name and typed CPU set return the complete CPU projection and explicit indirect-lighting adoption plan; authored radiance beyond CPU intensity64 refuses without scaling. Blocked calibration and external-sun rigs refuse. Returns a geometry-clearance review requirement; does not create cards or modify the scene."),
|
||||
CommandSpec::new("render.library.studio.apply", "Apply Authored Studio Lights", library::apply_studio).icon("settings")
|
||||
CommandSpec::new("render.library.studio.apply", "Apply Authored Studio Lights", crate::checked!("render.library.studio.apply", library::apply_studio)).icon("settings")
|
||||
.contract(&library::STUDIO_APPLY_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_library::StudioApplied>)
|
||||
.params("rig: id, variant, center_mm and subject_extent_mm; name; set?: typed CPU/GPU setup patch; expected_revision?:u64; enable_indirect?:false (explicit CPU Sun adoption after review); replace_lighting_and_environment:true required. Saves the explicit rig projection in one undo, removing prior lighting/environment only with that confirmation. Subject clearance still needs review; passive cards are never silently omitted."),
|
||||
CommandSpec::new("render.hdr.inspect", "Inspect HDR Source", crate::checked!("render.hdr.inspect", resources::inspect)).icon("inspect").noundo().doc_only()
|
||||
.contract(&resources::INSPECT_CONTRACT)
|
||||
@@ -210,9 +223,13 @@ CommandSpec::new("render.backends", "Render Backends", backends).icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("No parameters. Returns {schema_version: 1, default_backend: builtin, backends: [{id, label, availability, executors: [{id, availability, profiles, limits, scratch}]}]}. Metadata only; does not start rendering or probe a GPU. See docs/render-backends.md"),
|
||||
CommandSpec::new("render.settings.get", "Render Settings", get_settings).icon("settings").noundo().doc_only()
|
||||
CommandSpec::new("render.settings.get", "Render Settings", crate::checked!("render.settings.get", get_settings)).icon("settings").noundo().doc_only()
|
||||
.contract(&crate::schema::TypedContract::<crate::schema::render_settings::GetParams>::new())
|
||||
.output(crate::schema::output_schema_for::<RenderSettingsGot>)
|
||||
.params("name?: saved setup. Without name returns {setups, defaults}; with name returns {name, setup}. Does not evaluate the model or apply a camera. See docs/render-settings.md"),
|
||||
CommandSpec::new("render.settings.set", "Save Render Settings", set_settings).icon("settings").doc_only()
|
||||
CommandSpec::new("render.settings.set", "Save Render Settings", crate::checked!("render.settings.set", set_settings)).icon("settings").doc_only()
|
||||
.contract(&crate::schema::TypedContract::<crate::schema::render_settings::SetParams>::new())
|
||||
.output(crate::schema::output_schema_for::<RenderSettingsSaved>)
|
||||
.params("name: setup name, set: object of fields to replace (backend, mode, camera, width, height, format, exposure_ev, environment_rotation, environment_intensity, background, job, surfaces, optics, coats, stage). job?: typed active raster/cpu_raytrace/gpu_path configuration with optional validated embedded HDR reference; omitted preserves, null clears, object replaces the complete job. Creates from defaults and saves without a host, validates atomically, one undo step; identical edits are no-ops. See docs/render-settings.md"),
|
||||
CommandSpec::new("render.scene", "Inspect Render Scene", scene).noundo().at("RENDER", "SCENE").icon("render_scene")
|
||||
.params("No parameters. Authoritative product-scene metadata, without modelling overlays or mesh payloads. Explicitly settles stale geometry; does not render an image. See docs/render-workspace.md"),
|
||||
@@ -317,13 +334,25 @@ struct GetSettings {
|
||||
fn get_settings(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let cmd = "render.settings.get";
|
||||
let request: GetSettings = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
match request.name {
|
||||
None => Ok(json!({"setups": s.doc.render.setups, "defaults": RenderSetup::default()})),
|
||||
let info = match request.name {
|
||||
None => {
|
||||
let setups = s.doc.render.setups.iter().map(|(name, setup)| Ok((name.clone(), setup_fields(cmd, setup)?))).collect::<Result<_>>()?;
|
||||
RenderSettingsInfo::All { setups, defaults: setup_fields(cmd, &RenderSetup::default())? }
|
||||
}
|
||||
Some(name) => {
|
||||
RenderSettings::validate_name(&name).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let setup = s.doc.render.setups.get(&name).ok_or_else(|| bad(cmd, "no saved render setup with that name"))?;
|
||||
Ok(json!({"name": name, "setup": setup}))
|
||||
RenderSettingsInfo::One { setup: setup_fields(cmd, setup)?, name }
|
||||
}
|
||||
};
|
||||
typed_reply(cmd, RenderSettingsGot { info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A render setup's fields, as it saves them, for a reply.
|
||||
fn setup_fields(cmd: &str, setup: &RenderSetup) -> Result<SetupFields> {
|
||||
match serde_json::to_value(setup).map_err(|e| bad(cmd, e.to_string()))? {
|
||||
Value::Object(fields) => Ok(fields),
|
||||
_ => Err(bad(cmd, "invalid render setup")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,23 +367,23 @@ fn set_settings(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let cmd = "render.settings.set";
|
||||
let request: SetSettings = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let setup = patched_settings(s, &request.name, &request.set, cmd)?;
|
||||
let reply = json!({"name": request.name, "setup": setup});
|
||||
let reply = RenderSettingsSaved { name: request.name.clone(), setup: setup_fields(cmd, &setup)?, param_warnings: Vec::new() };
|
||||
if s.doc.render.setups.get(&request.name) == Some(&setup) {
|
||||
return Ok(reply);
|
||||
return typed_reply(cmd, reply);
|
||||
}
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(request.name, setup);
|
||||
settings.validate().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
Ok(reply)
|
||||
typed_reply(cmd, reply)
|
||||
}
|
||||
|
||||
fn patched_settings(s: &Session, name: &str, set: &Value, cmd: &str) -> Result<RenderSetup> {
|
||||
RenderSettings::validate_name(name).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
// A full RenderSetup has 15 known fields, including maps, optics, coats and
|
||||
// stage. Bound the patch by that schema; decoding below still rejects any
|
||||
// A RenderSetup has `SETUP_FIELDS`, including surfaces, optics, coats and stage; the published
|
||||
// schemas list the same fields. Bound the patch by them; decoding below still rejects any
|
||||
// unknown field instead of silently dropping saved intent.
|
||||
let patch = set.as_object().filter(|p| p.len() <= 15).ok_or_else(|| bad(cmd, "`set` must be an object with at most 15 fields"))?;
|
||||
let patch = set.as_object().filter(|p| p.len() <= SETUP_FIELDS.len()).ok_or_else(|| bad(cmd, set_message()))?;
|
||||
let current = s.doc.render.setups.get(name).cloned().unwrap_or_default();
|
||||
let mut value = serde_json::to_value(¤t).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let object = value.as_object_mut().ok_or_else(|| bad(cmd, "invalid render setup"))?;
|
||||
|
||||
@@ -18,8 +18,10 @@ use solidcraft_render::{
|
||||
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_library::{
|
||||
LibraryDescribeParams, LibraryListParams, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryResource, LibraryTargets,
|
||||
LibraryTargetsParams, Pbr, RenderTarget, ResourceSummary, SceneBounds, StableRenderTarget,
|
||||
AppearanceApplied, AppearanceApplyParams, CpuProjection, EnvironmentApplied, EnvironmentApplyParams, IndirectAdoption, LibraryDescribeParams,
|
||||
LibraryListParams, LibraryLook, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryResource, LibraryTargets, LibraryTargetsParams, Pbr,
|
||||
PreparedTexture, ProgressiveDefaults, RenderTarget, ResourceSummary, RigProjection, SceneBounds, StableRenderTarget, StudioApplied,
|
||||
StudioApplyParams, StudioPrepareParams, StudioPrepared, SurfacePrepareParams, SurfacePrepared,
|
||||
};
|
||||
use crate::{Result, Session, params::bad};
|
||||
|
||||
@@ -27,6 +29,11 @@ pub(super) const LIST_CONTRACT: TypedContract<LibraryListParams> = TypedContract
|
||||
pub(super) const DESCRIBE_CONTRACT: TypedContract<LibraryDescribeParams> = TypedContract::new();
|
||||
pub(super) const TARGETS_CONTRACT: TypedContract<LibraryTargetsParams> = TypedContract::new();
|
||||
pub(super) const PREVIEW_CONTRACT: TypedContract<LibraryPreviewParams> = TypedContract::new();
|
||||
pub(super) const APPEARANCE_APPLY_CONTRACT: TypedContract<AppearanceApplyParams> = TypedContract::new();
|
||||
pub(super) const ENVIRONMENT_APPLY_CONTRACT: TypedContract<EnvironmentApplyParams> = TypedContract::new();
|
||||
pub(super) const SURFACE_PREPARE_CONTRACT: TypedContract<SurfacePrepareParams> = TypedContract::new();
|
||||
pub(super) const STUDIO_PREPARE_CONTRACT: TypedContract<StudioPrepareParams> = TypedContract::new();
|
||||
pub(super) const STUDIO_APPLY_CONTRACT: TypedContract<StudioApplyParams> = TypedContract::new();
|
||||
|
||||
fn reply(command: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(command, e.to_string()))
|
||||
@@ -397,20 +404,39 @@ pub(super) fn apply_appearance(s: &mut Session, params: &Value) -> Result<Value>
|
||||
let color = std::array::from_fn::<_, 3, _>(|i| crate::geom::srgb::encode8((lo[i] + hi[i]) * 0.5));
|
||||
(
|
||||
value,
|
||||
json!({"color":color,"opacity":1.0,"pbr":{"metallic":value["metallic"],"roughness":value["roughness"],"dielectric_ior":1.5}}),
|
||||
LibraryLook {
|
||||
name: String::new(),
|
||||
color: json!(color),
|
||||
opacity: json!(1.0),
|
||||
pbr: json!({"metallic":value["metallic"],"roughness":value["roughness"],"dielectric_ior":1.5}),
|
||||
bodies: Vec::new(),
|
||||
},
|
||||
json!(["spatial_maps", "height_displacement", "true_anisotropic_brdf"]),
|
||||
)
|
||||
} else {
|
||||
let value = item("selene_appearances", &input.id, command)?;
|
||||
let p = &value["baseline_projection"]["runtime_parameters"];
|
||||
(value, json!({"color":p["color"],"opacity":p["opacity"],"pbr":p["pbr"]}), value["baseline_projection"]["omitted_target_features"].clone())
|
||||
(
|
||||
value,
|
||||
LibraryLook { name: String::new(), color: p["color"].clone(), opacity: p["opacity"].clone(), pbr: p["pbr"].clone(), bodies: Vec::new() },
|
||||
value["baseline_projection"]["omitted_target_features"].clone(),
|
||||
)
|
||||
};
|
||||
let name = value["name"].as_str().ok_or_else(|| bad(command, "authored appearance has no name"))?;
|
||||
look["name"] = json!(format!("{} {}", input.id, name).chars().take(128).collect::<String>());
|
||||
look["bodies"] = json!(input.bodies);
|
||||
let assigned = crate::cmd::inspect::appearance(s, &look)?;
|
||||
Ok(
|
||||
json!({"id":input.id,"variant":input.variant,"omitted":omitted,"appearance":look,"assignment":assigned,"engineering_properties_unchanged":true}),
|
||||
look.name = format!("{} {}", input.id, name).chars().take(128).collect::<String>();
|
||||
look.bodies = input.bodies;
|
||||
let assigned = crate::cmd::inspect::appearance(s, &reply(command, &look)?)?;
|
||||
reply(
|
||||
command,
|
||||
AppearanceApplied {
|
||||
id: input.id,
|
||||
variant: input.variant,
|
||||
omitted,
|
||||
appearance: look,
|
||||
assignment: assigned,
|
||||
engineering_properties_unchanged: true,
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -500,8 +526,21 @@ pub(super) fn apply_environment(s: &mut Session, params: &Value) -> Result<Value
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
Ok(json!({"id":input.id,"name":input.name,"setup":setup,"resource":digest,"author_rotation_rad":input.rotation_rad,
|
||||
"native_rotation_rad":rotation,"orientation_offset_rad":std::f64::consts::PI,"self_contained":true,"source_sha256":metadata["sha256"]}))
|
||||
reply(
|
||||
command,
|
||||
EnvironmentApplied {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
setup,
|
||||
resource: digest,
|
||||
author_rotation_rad: input.rotation_rad,
|
||||
native_rotation_rad: rotation,
|
||||
orientation_offset_rad: std::f64::consts::PI,
|
||||
self_contained: true,
|
||||
source_sha256: metadata["sha256"].clone(),
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -562,14 +601,39 @@ pub(super) fn prepare_surface(s: &mut Session, params: &Value) -> Result<Value>
|
||||
let frame = solidcraft_render::job::textures::PlanarFrame { origin, u, v };
|
||||
frame.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let manifest = item("surface_manifest", &input.id, command)?;
|
||||
Ok(json!({"id":input.id,"revision":s.revision,"executor":"gpu-path","variant":"gpu_planar_maps",
|
||||
"texture":{"body_index":input.body_index,"body_name":input.body_name,"frame":frame,"normal_strength":1.0,"flip_v":true,"base_color_mode":"replace",
|
||||
"base_color_srgb":format!("builtin://{}/base_color",input.id),"normal_opengl":format!("builtin://{}/normal_open_gl",input.id),
|
||||
"roughness_linear":format!("builtin://{}/roughness",input.id),"metallic_linear":format!("builtin://{}/metallic",input.id)},
|
||||
"source_maps":manifest["maps"],"omitted":["height_displacement","true_anisotropic_brdf","manufacturing_geometry"],
|
||||
"dielectric_ior":"captured_body_value","persistence":"transient_request_binding; authored source bytes are built in",
|
||||
"compatible_saved_methods":if cfg!(feature="bundled-render-library") {vec!["cpu_raytrace","gpu_path"]} else {Vec::<&str>::new()},
|
||||
"cpu_maps":"saved_builtin_opaque_only; direct_or_indirect_lighting; level_zero_bilinear; incompatible_optics_refuse"}))
|
||||
let map = |role: &str| format!("builtin://{}/{role}", input.id);
|
||||
reply(
|
||||
command,
|
||||
SurfacePrepared {
|
||||
revision: s.revision,
|
||||
executor: "gpu-path".into(),
|
||||
variant: "gpu_planar_maps".into(),
|
||||
texture: PreparedTexture {
|
||||
body_index: input.body_index,
|
||||
body_name: input.body_name,
|
||||
frame,
|
||||
normal_strength: 1.0,
|
||||
flip_v: true,
|
||||
base_color_mode: "replace".into(),
|
||||
base_color_srgb: map("base_color"),
|
||||
normal_opengl: map("normal_open_gl"),
|
||||
roughness_linear: map("roughness"),
|
||||
metallic_linear: map("metallic"),
|
||||
},
|
||||
source_maps: manifest["maps"].clone(),
|
||||
omitted: ["height_displacement", "true_anisotropic_brdf", "manufacturing_geometry"].map(String::from).into(),
|
||||
dielectric_ior: "captured_body_value".into(),
|
||||
persistence: "transient_request_binding; authored source bytes are built in".into(),
|
||||
compatible_saved_methods: if cfg!(feature = "bundled-render-library") {
|
||||
vec!["cpu_raytrace".into(), "gpu_path".into()]
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
cpu_maps: "saved_builtin_opaque_only; direct_or_indirect_lighting; level_zero_bilinear; incompatible_optics_refuse".into(),
|
||||
id: input.id.clone(),
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -581,7 +645,9 @@ pub(super) struct RigInput {
|
||||
pub(super) subject_extent_mm: f64,
|
||||
}
|
||||
|
||||
pub(super) fn rig_lighting(input: &RigInput, command: &str) -> Result<(StudioLighting, Value)> {
|
||||
/// The rig's lights and what it was scaled to, as `render.library.studio.prepare` and `.apply`
|
||||
/// report them; `rig_lighting` gives the same projection as JSON.
|
||||
fn rig_projection(input: &RigInput, command: &str) -> Result<(StudioLighting, RigProjection)> {
|
||||
let value = item("rigs", &input.id, command)?;
|
||||
if value["state"] != "authored_numeric_unbound" {
|
||||
return Err(bad(command, "rig requires unresolved calibration or an existing external sun"));
|
||||
@@ -643,11 +709,23 @@ pub(super) fn rig_lighting(input: &RigInput, command: &str) -> Result<(StudioLig
|
||||
lighting.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok((
|
||||
lighting,
|
||||
json!({"id":input.id,"variant":input.variant,"passive_cards_omitted":if input.variant=="emitters_only" {cards}else{0},"passive_cards_included":if input.variant=="complete" {cards}else{0},"scale":scale,
|
||||
"physical_units":"world millimetres; authored relative linear-sRGB radiance","reference_calibration_not_remeasured":true}),
|
||||
RigProjection {
|
||||
id: input.id.clone(),
|
||||
variant: input.variant.clone(),
|
||||
passive_cards_omitted: if input.variant == "emitters_only" { cards } else { 0 },
|
||||
passive_cards_included: if input.variant == "complete" { cards } else { 0 },
|
||||
scale,
|
||||
physical_units: "world millimetres; authored relative linear-sRGB radiance".into(),
|
||||
reference_calibration_not_remeasured: true,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn rig_lighting(input: &RigInput, command: &str) -> Result<(StudioLighting, Value)> {
|
||||
let (lighting, projection) = rig_projection(input, command)?;
|
||||
Ok((lighting, reply(command, projection)?))
|
||||
}
|
||||
|
||||
pub(super) fn rig_stage(input: &RigInput, command: &str) -> Result<solidcraft_render::stage::Stage> {
|
||||
use solidcraft_render::stage::{Stage, StagePanel};
|
||||
if input.variant != "complete" {
|
||||
@@ -774,10 +852,17 @@ pub(super) fn prepare_studio(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.studio.prepare";
|
||||
let input: StudioPrepare = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let rig = RigInput { id: input.id, variant: input.variant, center_mm: input.center_mm, subject_extent_mm: input.subject_extent_mm };
|
||||
let (lighting, projection) = rig_lighting(&rig, command)?;
|
||||
let (lighting, projection) = rig_projection(&rig, command)?;
|
||||
let stage = rig_stage(&rig, command)?;
|
||||
let mut report =
|
||||
json!({"lighting":lighting,"stage":stage,"projection":projection,"requires_geometry_clearance_review":true,"document_changed":false});
|
||||
let mut report = StudioPrepared {
|
||||
lighting,
|
||||
stage: stage.clone(),
|
||||
projection,
|
||||
requires_geometry_clearance_review: true,
|
||||
document_changed: false,
|
||||
cpu: None,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
if let Some(name) = input.name {
|
||||
let mut setup = super::patched_settings(s, &name, &input.set, command)?;
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else {
|
||||
@@ -791,18 +876,22 @@ pub(super) fn prepare_studio(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
}
|
||||
setup.stage = stage;
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
report["setup"] = json!(setup);
|
||||
report["executor"] = json!("cpu-raytrace");
|
||||
report["revision"] = json!(s.revision);
|
||||
report["requires_indirect"] = json!(needs_indirect);
|
||||
report["indirect_adoption"] = json!(
|
||||
needs_indirect
|
||||
.then_some(json!({"path_depth":8,"progressive_if_absent":{"samples_per_pixel":16,"seed":0},"primary_samples":1,"shadows":true}))
|
||||
);
|
||||
report.cpu = Some(CpuProjection {
|
||||
setup,
|
||||
executor: "cpu-raytrace".into(),
|
||||
revision: s.revision,
|
||||
requires_indirect: needs_indirect,
|
||||
indirect_adoption: needs_indirect.then_some(IndirectAdoption {
|
||||
path_depth: 8,
|
||||
progressive_if_absent: ProgressiveDefaults { samples_per_pixel: 16, seed: 0 },
|
||||
primary_samples: 1,
|
||||
shadows: true,
|
||||
}),
|
||||
});
|
||||
} else if input.enable_indirect || input.set != json!({}) {
|
||||
return Err(bad(command, "CPU rig context needs name and typed set"));
|
||||
}
|
||||
Ok(report)
|
||||
reply(command, report)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -832,7 +921,7 @@ pub(super) fn apply_studio(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if !input.replace_stage && s.doc.render.setups.get(&input.name).is_some_and(|setup| !setup.stage.is_empty()) {
|
||||
return Err(bad(command, "existing stage requires replace_stage: true"));
|
||||
}
|
||||
let (lighting, projection) = rig_lighting(&input.rig, command)?;
|
||||
let (lighting, projection) = rig_projection(&input.rig, command)?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
let stage = rig_stage(&input.rig, command)?;
|
||||
if !setup.stage.is_empty() && !input.replace_stage {
|
||||
@@ -865,8 +954,15 @@ pub(super) fn apply_studio(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
Ok(
|
||||
json!({"name":input.name,"setup":setup,"projection":projection,"clearance":"conservative product/stage AABB separation passed; not exact B-rep collision"}),
|
||||
reply(
|
||||
command,
|
||||
StudioApplied {
|
||||
name: input.name,
|
||||
setup,
|
||||
projection,
|
||||
clearance: "conservative product/stage AABB separation passed; not exact B-rep collision".into(),
|
||||
param_warnings: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Read-only bridge between optical concepts and sourced physical references.
|
||||
//! No appearance identifies a grade, assigns density, or supplies missing constants.
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_library::{PhysicalPlan, PhysicalPrepareParams, PlannedCommand, PlannedMaterial};
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -161,6 +163,7 @@ pub(super) fn associations(id: &str) -> Result<Value> {
|
||||
ids.into_iter().map(|id| Ok(summary(by(profiles, "id", id, command)?, by(cards, "profile_id", id, command)?))).collect::<Result<Vec<_>>>()?;
|
||||
Ok(json!({"specifications":selected,"candidate_profiles":references,"auto_assign":false,"unknown_is_zero":false}))
|
||||
}
|
||||
pub(super) const PREPARE_CONTRACT: TypedContract<PhysicalPrepareParams> = TypedContract::new();
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Prepare {
|
||||
@@ -191,11 +194,30 @@ pub(super) fn prepare(_s: &mut Session, params: &Value) -> Result<Value> {
|
||||
}
|
||||
let density =
|
||||
density.ok_or_else(|| bad(command, "profile lacks one supported point density; ranges and unknowns cannot be replaced by a midpoint"))?;
|
||||
Ok(json!({"status":"plan_only_not_executed","profile_id":input.id,"receipts":description["receipts"],
|
||||
"native_validation":description["native_validation"],"not_projected":description["native_projection"]["not_projected"],
|
||||
"extended_profile":description["resource"],"document_changed":false,"assigned_bodies":[],
|
||||
"command":{"command":"material.create","params":{"name":input.name,"density_g_cm3":density/1000.0,"physical_card":card}},
|
||||
"requirements":["Review grade, formulation, source and conditions before execution.","This partial card is not a complete constitutive model or a certification.","Create does not assign a body; never use a constituent as an effective laminate, foam or populated assembly.","Retain this profile and omission receipt; the native card cannot encode all extended properties."]}))
|
||||
let plan = PhysicalPlan {
|
||||
status: "plan_only_not_executed".into(),
|
||||
profile_id: input.id,
|
||||
receipts: description["receipts"].clone(),
|
||||
native_validation: description["native_validation"].clone(),
|
||||
not_projected: description["native_projection"]["not_projected"].clone(),
|
||||
extended_profile: description["resource"].clone(),
|
||||
document_changed: false,
|
||||
assigned_bodies: Vec::new(),
|
||||
command: PlannedCommand {
|
||||
command: "material.create".into(),
|
||||
params: PlannedMaterial { name: input.name, density_g_cm3: density / 1000.0, physical_card: card },
|
||||
},
|
||||
requirements: [
|
||||
"Review grade, formulation, source and conditions before execution.",
|
||||
"This partial card is not a complete constitutive model or a certification.",
|
||||
"Create does not assign a body; never use a constituent as an effective laminate, foam or populated assembly.",
|
||||
"Retain this profile and omission receipt; the native card cannot encode all extended properties.",
|
||||
]
|
||||
.map(String::from)
|
||||
.into(),
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(plan).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -38,11 +38,12 @@ fn section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let axis = [normal.x, normal.y, normal.z].iter().position(|c| c.abs() > 0.5);
|
||||
(pl.origin, normal, axis)
|
||||
}
|
||||
// A construction plane (name or id): where it is now.
|
||||
// A construction plane (name or id): where it stands in the world, its component placed,
|
||||
// as the viewport draws it. The cut and the reply are in the world.
|
||||
Some(v @ (Value::String(_) | Value::Number(_))) => {
|
||||
let r = super::features::plane_param(s, Some(v), cmd)?;
|
||||
let solidcraft_doc::PlaneRef::Construction { feature } = &r else { return Err(bad(cmd, "`plane` must be a plane")) };
|
||||
let st = s.model.state();
|
||||
let st = s.world_state()?;
|
||||
let c = st
|
||||
.construct
|
||||
.iter()
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
//! A tightly framed, view-aligned union of the projected body meshes.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::render::{Camera, StandardView};
|
||||
use crate::schema::silhouette::{SilhouetteParams, SilhouetteProjection, SilhouetteResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "inspect.silhouette";
|
||||
const CONTRACT: TypedContract<SilhouetteParams> = TypedContract::new();
|
||||
const MAX_PIXELS: usize = 4 * 1024 * 1024;
|
||||
|
||||
// For agents comparing the model with a picture; nothing to show a person, so no toolbar button.
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).noundo().icon("silhouette").params(
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", crate::checked!("inspect.silhouette", run))
|
||||
.noundo()
|
||||
.icon("silhouette")
|
||||
.contract(&CONTRACT)
|
||||
.output(output_schema_for::<SilhouetteResult>)
|
||||
.params(
|
||||
"view?: front|back|left|right|top|bottom|iso (default front); or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (mm, radians; fov 0 means parallel); bodies?: [world body names] (default visible bodies, named hidden bodies included); rows?: mask height in pixels (default 240, clamped to 16..1024) → tightly framed run-length mask, box_mm, mm_per_px, right/up, projection, body and triangle counts",
|
||||
)];
|
||||
|
||||
@@ -23,18 +31,20 @@ fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let wanted = body_names(p)?;
|
||||
let out = outline(s, &camera, wanted.as_deref(), rows)?;
|
||||
let mask: Vec<Vec<[usize; 2]>> = out.mask.iter().map(|row| spans(row)).collect();
|
||||
Ok(json!({
|
||||
"width": out.width,
|
||||
"height": out.height,
|
||||
"mm_per_px": round4(out.mm_per_px),
|
||||
"box_mm": [[round4(out.lo[0]), round4(out.lo[1])], [round4(out.hi[0]), round4(out.hi[1])]],
|
||||
"right": [clean(out.right.x), clean(out.right.y), clean(out.right.z)],
|
||||
"up": [clean(out.up.x), clean(out.up.y), clean(out.up.z)],
|
||||
"projection": if out.perspective { "perspective" } else { "parallel" },
|
||||
"bodies": out.bodies,
|
||||
"triangles": out.triangles,
|
||||
"mask": mask,
|
||||
}))
|
||||
let reply = SilhouetteResult {
|
||||
width: out.width,
|
||||
height: out.height,
|
||||
mm_per_px: round4(out.mm_per_px),
|
||||
box_mm: [[round4(out.lo[0]), round4(out.lo[1])], [round4(out.hi[0]), round4(out.hi[1])]],
|
||||
right: [clean(out.right.x), clean(out.right.y), clean(out.right.z)],
|
||||
up: [clean(out.up.x), clean(out.up.y), clean(out.up.z)],
|
||||
projection: if out.perspective { SilhouetteProjection::Perspective } else { SilhouetteProjection::Parallel },
|
||||
bodies: out.bodies,
|
||||
triangles: out.triangles,
|
||||
mask,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(CMD, e.to_string()))
|
||||
}
|
||||
|
||||
/// A projected, tightly framed silhouette: the pixel grid, its scale and placement, and the
|
||||
|
||||
@@ -8,7 +8,11 @@ use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Point
|
||||
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list, vec2, vec3};
|
||||
use crate::schema::sketch::{CancelResult, EditParams, EditResult, ExitParams, FinishResult};
|
||||
use crate::schema::sketch::{
|
||||
CancelResult, CoincidentMade, CoincidentParams, CoincidentResult, CollinearParams, ConcentricParams, Constrained, ConstraintResult, EditParams,
|
||||
EditResult, EqualMade, EqualParams, EqualResult, ExitParams, FinishResult, FixOutcome, FixParams, FixResult, HorizontalVerticalParams,
|
||||
MidpointParams, ParallelParams, PerpendicularParams, SymmetryParams, SymmetryResult, TangentParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session, SketchEntry, Snapshot};
|
||||
|
||||
@@ -38,6 +42,17 @@ macro_rules! model_operand_doc {
|
||||
|
||||
const EDIT_CONTRACT: TypedContract<EditParams> = TypedContract::new();
|
||||
const EXIT_CONTRACT: TypedContract<ExitParams> = TypedContract::new();
|
||||
const HORIZONTAL_VERTICAL_CONTRACT: TypedContract<HorizontalVerticalParams> = TypedContract::new();
|
||||
const COINCIDENT_CONTRACT: TypedContract<CoincidentParams> = TypedContract::new();
|
||||
const TANGENT_CONTRACT: TypedContract<TangentParams> = TypedContract::new();
|
||||
const EQUAL_CONTRACT: TypedContract<EqualParams> = TypedContract::new();
|
||||
const PARALLEL_CONTRACT: TypedContract<ParallelParams> = TypedContract::new();
|
||||
const PERPENDICULAR_CONTRACT: TypedContract<PerpendicularParams> = TypedContract::new();
|
||||
const FIX_CONTRACT: TypedContract<FixParams> = TypedContract::new();
|
||||
const MIDPOINT_CONTRACT: TypedContract<MidpointParams> = TypedContract::new();
|
||||
const CONCENTRIC_CONTRACT: TypedContract<ConcentricParams> = TypedContract::new();
|
||||
const COLLINEAR_CONTRACT: TypedContract<CollinearParams> = TypedContract::new();
|
||||
const SYMMETRY_CONTRACT: TypedContract<SymmetryParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.create", "Create Sketch", create_sketch)
|
||||
@@ -143,60 +158,82 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params(
|
||||
"entities: [refs] (a line midpoint is \"mid:line_id\"; a model edge or vertex, {edge: [x,y,z], body?} or {vertex: [x,y,z], body?}, is projected into the sketch first and measured there: a point against the projected edge measures the distance to it), type?: auto|distance|horizontal|distance_x|vertical|distance_y|length|radius|diameter|major|minor|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"; also `#name = expression`), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them (between the perpendiculars through them, or a tenth of their distance past) is the distance between them; pulled further past an end, up or down from it horizontal, sideways vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically, anywhere else (inside it or past its corners) the distance between them. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A point and a circle or arc: the distance from the point to its edge (or how far within it a point inside lies). Two circles or arcs: the gap between their edges (or how far they overlap when they cross, or the narrowest gap between them when one lies inside the other), or the centre-to-centre distance when they are the two caps of a slot. An ellipse: a half-axis, the distance from the centre to an axis end (major or minor; with auto, text_at pulled across the major axis gives the major half-axis, pulled out past an end of it the minor one, the minor without text_at; previous_axis?: major|minor, the half-axis an earlier placement chose, kept while text_at is near the boundary between them; the reply's axis names the one chosen; with auto, when a driving dimension already holds one half-axis (or its whole axis), the other one, wherever text_at is; without its major axis line the major half-axis goes to the ellipse's own major end, without its minor axis line the minor half-axis is the minor radius); any other type, radius among them, the minor radius; an ellipse's axis line with auto: its length, the whole axis, wherever text_at is; half_axes?: bool (default true; false: with auto, the minor radius wherever text_at is, as before half-axes, which the Dimension tool sends with Draw ellipses with their axes off). An arc and one of its end points: the arc's length (type arc_length does it from the arc alone). A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends the Sketch preference, on by default): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled",
|
||||
),
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical)
|
||||
CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", crate::checked!("sketch.constraint.horizontal_vertical", c_horizontal_vertical))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_hv")
|
||||
.enabled(in_sketch)
|
||||
.contract(&HORIZONTAL_VERTICAL_CONTRACT)
|
||||
.output(output_schema_for::<ConstraintResult>)
|
||||
.params("line: id | points: [a, b] (point ids or {midpoint: line_id}; accepted midpoints reuse or create a point with a Midpoint constraint); mode?: horizontal|vertical"),
|
||||
CommandSpec::new("sketch.constraint.coincident", "Coincident", c_coincident)
|
||||
CommandSpec::new("sketch.constraint.coincident", "Coincident", crate::checked!("sketch.constraint.coincident", c_coincident))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_coincident")
|
||||
.enabled(in_sketch)
|
||||
.contract(&COINCIDENT_CONTRACT)
|
||||
.output(output_schema_for::<CoincidentResult>)
|
||||
.params(concat!("a, b: points (or a line's middle as {midpoint: line}, which becomes a point tied there), a point and a curve or sketch axis (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created), or two distinct lines (joins their closest endpoints), or two circles/arcs (the second lies on the first: concentric and equal, `constraints` lists both)", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.constraint.tangent", "Tangent", c_tangent)
|
||||
CommandSpec::new("sketch.constraint.tangent", "Tangent", crate::checked!("sketch.constraint.tangent", c_tangent))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_tangent")
|
||||
.enabled(in_sketch)
|
||||
.contract(&TANGENT_CONTRACT)
|
||||
.output(output_schema_for::<ConstraintResult>)
|
||||
.params(concat!("a, b: curves or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.constraint.equal", "Equal", c_equal)
|
||||
CommandSpec::new("sketch.constraint.equal", "Equal", crate::checked!("sketch.constraint.equal", c_equal))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_equal")
|
||||
.enabled(in_sketch)
|
||||
.contract(&EQUAL_CONTRACT)
|
||||
.output(output_schema_for::<EqualResult>)
|
||||
.params(concat!("a, b: lines or circles/arcs; or curves: [2 to 200 lines or circles/arcs], each made equal to the first, in one step", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.constraint.parallel", "Parallel", c_parallel)
|
||||
CommandSpec::new("sketch.constraint.parallel", "Parallel", crate::checked!("sketch.constraint.parallel", c_parallel))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_parallel")
|
||||
.enabled(in_sketch)
|
||||
.contract(&PARALLEL_CONTRACT)
|
||||
.output(output_schema_for::<ConstraintResult>)
|
||||
.params(concat!("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", c_perpendicular)
|
||||
CommandSpec::new("sketch.constraint.perpendicular", "Perpendicular", crate::checked!("sketch.constraint.perpendicular", c_perpendicular))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_perpendicular")
|
||||
.enabled(in_sketch)
|
||||
.contract(&PERPENDICULAR_CONTRACT)
|
||||
.output(output_schema_for::<ConstraintResult>)
|
||||
.params(concat!("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.constraint.fix", "Fix/Unfix", c_fix)
|
||||
CommandSpec::new("sketch.constraint.fix", "Fix/Unfix", crate::checked!("sketch.constraint.fix", c_fix))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_fix")
|
||||
.enabled(in_sketch)
|
||||
.contract(&FIX_CONTRACT)
|
||||
.output(output_schema_for::<FixResult>)
|
||||
.params("entity: point or curve | entities: [points and curves]; fixed?: bool (default: fix them all unless all are fixed, then unfix)"),
|
||||
CommandSpec::new("sketch.constraint.midpoint", "MidPoint", c_midpoint)
|
||||
CommandSpec::new("sketch.constraint.midpoint", "MidPoint", crate::checked!("sketch.constraint.midpoint", c_midpoint))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_midpoint")
|
||||
.enabled(in_sketch)
|
||||
.contract(&MIDPOINT_CONTRACT)
|
||||
.output(output_schema_for::<ConstraintResult>)
|
||||
.params(concat!("point, line | a, b in pick order: a point and a line (either way round), or two lines whose midpoints meet (the first stays, the second moves)", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.constraint.concentric", "Concentric", c_concentric)
|
||||
CommandSpec::new("sketch.constraint.concentric", "Concentric", crate::checked!("sketch.constraint.concentric", c_concentric))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_concentric")
|
||||
.enabled(in_sketch)
|
||||
.contract(&CONCENTRIC_CONTRACT)
|
||||
.output(output_schema_for::<ConstraintResult>)
|
||||
.params(concat!("a, b: two circles/arcs, or a circle/arc and a sketch point (including origin), in either order; the point and the curve's centre become coincident", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.constraint.collinear", "Collinear", c_collinear)
|
||||
CommandSpec::new("sketch.constraint.collinear", "Collinear", crate::checked!("sketch.constraint.collinear", c_collinear))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_collinear")
|
||||
.enabled(in_sketch)
|
||||
.contract(&COLLINEAR_CONTRACT)
|
||||
.output(output_schema_for::<ConstraintResult>)
|
||||
.params(concat!("a, b: lines or sketch axes (x / y: the sketch's own axes, unless a curve has that id; an axis becomes a linked construction line on it, reported in created)", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", c_symmetry)
|
||||
CommandSpec::new("sketch.constraint.symmetry", "Symmetry", crate::checked!("sketch.constraint.symmetry", c_symmetry))
|
||||
.at("SKETCH", "CONSTRAINTS")
|
||||
.icon("c_symmetry")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SYMMETRY_CONTRACT)
|
||||
.output(output_schema_for::<SymmetryResult>)
|
||||
.params(concat!("a, b: two points or two curves (lines mirror their ends, circles/arcs their centres); line: symmetry line id, or x / y for the sketch's own axes (a curve with that id wins; an axis becomes a linked construction line on it, reported in created); or point (or a point id as line, \"origin\" too): symmetric about that point, each pair joined by a construction line it is the midpoint of", model_operand_doc!())),
|
||||
CommandSpec::new("sketch.construction", "Normal/Construction", construction)
|
||||
.at("SKETCH", "CREATE")
|
||||
@@ -1947,7 +1984,27 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Constraints
|
||||
|
||||
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Value> {
|
||||
/// A command's reply as JSON: they build their replies from the types their output schemas
|
||||
/// describe, so the two can't drift apart.
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// [`constrain`], replying as the commands that add one constraint do.
|
||||
fn constrain_reply(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Value> {
|
||||
let made = constrain(s, p, cmd, f)?;
|
||||
reply(cmd, ConstraintResult { made, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The constraint a command added and, when the sketch already determined it, what determines it
|
||||
/// (see [`constraint_result`], which the other constraint commands build their reply with).
|
||||
fn constrained(p: &Value, cmd: &str, id: String, info: Value, by: Option<Vec<String>>) -> Result<Constrained> {
|
||||
let Some(by) = by else { return Ok(Constrained { constraint: id, sketch: info, redundant: None, determined_by: None }) };
|
||||
keep_redundant(p, cmd, &by)?;
|
||||
Ok(Constrained { constraint: id, sketch: info, redundant: Some(true), determined_by: Some(by) })
|
||||
}
|
||||
|
||||
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch) -> Result<ConstraintKind>) -> Result<Constrained> {
|
||||
let mut before = None;
|
||||
let mut residual = None;
|
||||
let (id, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
@@ -1966,7 +2023,7 @@ fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut Sketch)
|
||||
Ok(id)
|
||||
})?;
|
||||
let by = redundancy(s, p, cmd, before, &info, residual, &id)?;
|
||||
constraint_result(p, cmd, id, info, by, Value::Null)
|
||||
constrained(p, cmd, id, info, by)
|
||||
}
|
||||
|
||||
/// A sketch entity a constraint is applied to.
|
||||
@@ -2310,7 +2367,7 @@ impl AlignmentTarget {
|
||||
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.horizontal_vertical";
|
||||
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
|
||||
constrain(s, p, cmd, |sk| {
|
||||
constrain_reply(s, p, cmd, |sk| {
|
||||
if let Some(pts) = p.get("points").and_then(Value::as_array) {
|
||||
if pts.len() != 2 {
|
||||
return Err(bad(cmd, "`points` needs exactly two point references"));
|
||||
@@ -2375,7 +2432,8 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(c) = sk.constraints.iter().find(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
|
||||
{
|
||||
let rep = solve(&mut sk.clone());
|
||||
return Ok(json!({"constraint": c.id, "existing": true, "sketch": report_json(&rep)}));
|
||||
let made = CoincidentMade::Existing { constraint: c.id.clone(), existing: true, sketch: report_json(&rep) };
|
||||
return reply(cmd, CoincidentResult { made, param_warnings: Vec::new() });
|
||||
}
|
||||
let pick = |sk: &mut Sketch| -> Result<ConstraintKind> {
|
||||
if let Some((a, b)) = line_points {
|
||||
@@ -2418,7 +2476,7 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = curve_or_axis(sk, bref)?.ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?;
|
||||
Ok(ConstraintKind::PointOnCurve { p: a, c })
|
||||
};
|
||||
constrain(s, p, cmd, |sk| {
|
||||
let made = constrain(s, p, cmd, |sk| {
|
||||
let k = pick(sk)?;
|
||||
// A line's two ends on each other shrink it to a point, as a line's own end at its
|
||||
// middle does (see `c_midpoint`).
|
||||
@@ -2428,7 +2486,8 @@ fn c_coincident(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "a line's two ends can't be coincident: the line would shrink to a point"));
|
||||
}
|
||||
Ok(k)
|
||||
})
|
||||
})?;
|
||||
reply(cmd, CoincidentResult { made: CoincidentMade::Added(made), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// `a` and `b` both circles or arcs (two different ones).
|
||||
@@ -2463,7 +2522,8 @@ fn coincident_circles(s: &mut Session, p: &Value, cmd: &str, a: usize, b: usize)
|
||||
apply_in_order(sk, &rep, &[Ent::Curve(a)], Ent::Curve(b), cmd, &ids)?;
|
||||
Ok(ids)
|
||||
})?;
|
||||
Ok(json!({"constraint": ids[0], "constraints": ids, "sketch": info}))
|
||||
let constraint = ids.first().cloned().unwrap_or_default();
|
||||
reply(cmd, CoincidentResult { made: CoincidentMade::Circles { constraint, constraints: ids, sketch: info }, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Four endpoint pairs, ordered independently of pick order when distances tie.
|
||||
@@ -2519,7 +2579,7 @@ fn two_curves_or_axes(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<(usize, u
|
||||
|
||||
fn c_tangent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.tangent", &["a", "b"])?;
|
||||
constrain(s, p, "sketch.constraint.tangent", |sk| {
|
||||
constrain_reply(s, p, "sketch.constraint.tangent", |sk| {
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.tangent")
|
||||
.and_then(|ab| two_different("sketch.constraint.tangent", ab))
|
||||
.map(|(a, b)| ConstraintKind::Tangent { a, b })
|
||||
@@ -2530,11 +2590,9 @@ fn c_equal(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return equal_curves(s, p);
|
||||
}
|
||||
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.equal", &["a", "b"])?;
|
||||
constrain(s, p, "sketch.constraint.equal", |sk| {
|
||||
two_curves(sk, p, "sketch.constraint.equal")
|
||||
.and_then(|ab| two_different("sketch.constraint.equal", ab))
|
||||
.map(|(a, b)| ConstraintKind::Equal { a, b })
|
||||
})
|
||||
let cmd = "sketch.constraint.equal";
|
||||
let made = constrain(s, p, cmd, |sk| two_curves(sk, p, cmd).and_then(|ab| two_different(cmd, ab)).map(|(a, b)| ConstraintKind::Equal { a, b }))?;
|
||||
reply(cmd, EqualResult { made: EqualMade::Pair(made), param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Equal over a list of curves (#4985): each one is made equal to the first, in one edit (one
|
||||
@@ -2566,11 +2624,11 @@ fn equal_curves(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({"constraints": out, "sketch": info}))
|
||||
reply(cmd, EqualResult { made: EqualMade::Curves { constraints: out, sketch: info }, param_warnings: Vec::new() })
|
||||
}
|
||||
fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.parallel", &["a", "b"])?;
|
||||
constrain(s, p, "sketch.constraint.parallel", |sk| {
|
||||
constrain_reply(s, p, "sketch.constraint.parallel", |sk| {
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.parallel")
|
||||
.and_then(|ab| two_different("sketch.constraint.parallel", ab))
|
||||
.map(|(a, b)| ConstraintKind::Parallel { a, b })
|
||||
@@ -2578,7 +2636,7 @@ fn c_parallel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.perpendicular", &["a", "b"])?;
|
||||
constrain(s, p, "sketch.constraint.perpendicular", |sk| {
|
||||
constrain_reply(s, p, "sketch.constraint.perpendicular", |sk| {
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.perpendicular")
|
||||
.and_then(|ab| two_different("sketch.constraint.perpendicular", ab))
|
||||
.map(|(a, b)| ConstraintKind::Perpendicular { a, b })
|
||||
@@ -2587,7 +2645,7 @@ fn c_perpendicular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.constraint.concentric";
|
||||
let p = &super::sketch_project::project_operands(s, p, cmd, &["a", "b"])?;
|
||||
constrain(s, p, cmd, |sk| {
|
||||
constrain_reply(s, p, cmd, |sk| {
|
||||
let centre = |key: &str| {
|
||||
let curve = sk.curves.get(sk.curve_index(p.get(key)?.as_str()?)?)?;
|
||||
match curve.kind {
|
||||
@@ -2604,7 +2662,7 @@ fn c_concentric(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn c_collinear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = &super::sketch_project::project_operands(s, p, "sketch.constraint.collinear", &["a", "b"])?;
|
||||
constrain(s, p, "sketch.constraint.collinear", |sk| {
|
||||
constrain_reply(s, p, "sketch.constraint.collinear", |sk| {
|
||||
two_curves_or_axes(sk, p, "sketch.constraint.collinear")
|
||||
.and_then(|ab| two_different("sketch.constraint.collinear", ab))
|
||||
.map(|(a, b)| ConstraintKind::Collinear { a, b })
|
||||
@@ -2648,7 +2706,7 @@ fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => Err(bad(cmd, "MidPoint needs a point and a line, or two lines")),
|
||||
}
|
||||
};
|
||||
constrain(s, p, cmd, |sk| {
|
||||
constrain_reply(s, p, cmd, |sk| {
|
||||
let k = pick(sk)?;
|
||||
// A line's own end at its middle shrinks the line to a point; an angle, tangent or
|
||||
// parallel on it afterwards had no direction to hold and threw points millions of
|
||||
@@ -2764,7 +2822,7 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({"constraints": ids, "sketch": info}))
|
||||
reply(cmd, SymmetryResult { constraints: ids, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// True when the ends of line `a` mirror (about the line `l`) closer onto the ends of `b` crossed
|
||||
@@ -2890,9 +2948,9 @@ fn c_fix(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sk.constraints.retain(|k| !matches!(k.kind, ConstraintKind::Fix { p } if ids.contains(&p)));
|
||||
}
|
||||
}
|
||||
Ok(json!({"fixed": fix, "changed": n}))
|
||||
Ok(FixOutcome { fixed: fix, changed: n })
|
||||
})?;
|
||||
Ok(json!({"result": n, "sketch": info}))
|
||||
reply(cmd, FixResult { result: n, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -9,6 +9,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_doc::Document;
|
||||
use solidcraft_geom::Aabb2;
|
||||
use solidcraft_sketch::{ConstraintKind, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
@@ -16,13 +17,14 @@ use super::sketch::edit;
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, string_list, vec2};
|
||||
use crate::schema::sketch_clipboard::{Copied, CopyParams, CutParams, PasteParams, Pasted};
|
||||
use crate::schema::sketch_clipboard::{Copied, CopyParams, CutParams, PasteParams, Pasted, ReplaceParams, Replaced};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
const COPY_CONTRACT: TypedContract<CopyParams> = TypedContract::new();
|
||||
const CUT_CONTRACT: TypedContract<CutParams> = TypedContract::new();
|
||||
const PASTE_CONTRACT: TypedContract<PasteParams> = TypedContract::new();
|
||||
const REPLACE_CONTRACT: TypedContract<ReplaceParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.copy", "Copy Sketch Entities", crate::checked!("sketch.copy", copy)).icon("copy")
|
||||
@@ -44,6 +46,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params(
|
||||
"at?: [x, y] (sketch point the copy's centre goes to) | translate?: [x, y]; without either, offset from the original in its own sketch and at the same coordinates in another; sketch?: target (default: the active sketch)",
|
||||
),
|
||||
CommandSpec::new("sketch.replace", "Replace Sketch", crate::checked!("sketch.replace", replace))
|
||||
.icon("sketch")
|
||||
.contract(&REPLACE_CONTRACT)
|
||||
.output(output_schema_for::<Replaced>)
|
||||
.params(
|
||||
"sketch?: the sketch to replace (default: the active sketch); from: the sketch whose contents take its place (id or name); file?: a design file `from` is in (default: this design). The sketch keeps its plane, place in the timeline and the features built on it; its curves, points, constraints, dimensions and texts become a copy of `from`'s, at the same sketch coordinates (one undo step)",
|
||||
),
|
||||
];
|
||||
|
||||
/// A curve of the clipboard; its points are indices into [`SketchClip::points`].
|
||||
@@ -85,8 +94,14 @@ pub struct SketchClip {
|
||||
pub extent: f64,
|
||||
/// Pastes made without a position since the copy (each lands one offset further on).
|
||||
pub pastes: u32,
|
||||
/// The clipboard point that stands for the sketch origin (in a whole-sketch copy): a paste
|
||||
/// ties it to the origin of the sketch it goes to rather than adding a point.
|
||||
pub origin: Option<usize>,
|
||||
}
|
||||
|
||||
/// The id of a sketch's origin point.
|
||||
const ORIGIN: &str = "origin";
|
||||
|
||||
impl SketchClip {
|
||||
/// This clipboard as another design pastes it (#9258): at the same sketch coordinates (its
|
||||
/// sketch ids mean nothing there), and with nothing that names the copied design's
|
||||
@@ -146,8 +161,17 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "select the sketch curves or points to copy"));
|
||||
}
|
||||
let sk = sketch_store::read(&s.doc, id)?;
|
||||
let (clip, out) = clip_of(&s.doc, sk, id, &ids, false, cmd)?;
|
||||
s.sketch_clipboard = Some(clip);
|
||||
reply(cmd, out)
|
||||
}
|
||||
|
||||
/// The clipboard copy of the entities `ids` of sketch `id` (`sk`, in `doc`), and what it holds.
|
||||
/// `whole` is a copy of the whole sketch for [`replace`]: its fixes come along, its constraints to
|
||||
/// the origin stay tied to the origin of the sketch it goes to, and an empty sketch is a copy too.
|
||||
fn clip_of(doc: &Document, sk: &Sketch, id: u64, ids: &[String], whole: bool, cmd: &str) -> Result<(SketchClip, Copied)> {
|
||||
let (mut cs, mut ps, mut texts): (Vec<usize>, Vec<usize>, Vec<String>) = (Vec::new(), Vec::new(), Vec::new());
|
||||
for e in &ids {
|
||||
for e in ids {
|
||||
if let Some(c) = sk.curve_index(e) {
|
||||
match text_of(sk, c) {
|
||||
Some(t) if !texts.iter().any(|x| x == t) => texts.push(t.to_string()),
|
||||
@@ -169,6 +193,11 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut clip = SketchClip { sketch: id, ..Default::default() };
|
||||
// Points: those of the copied curves, then the lone ones, each once.
|
||||
let mut pmap: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
if whole && let Some(o) = sk.points.iter().position(|q| q.id == ORIGIN) {
|
||||
clip.points.push(Vec2::ZERO);
|
||||
clip.origin = Some(0);
|
||||
pmap.insert(o, 0);
|
||||
}
|
||||
let take = |q: usize, clip: &mut SketchClip, pmap: &mut BTreeMap<usize, usize>| -> usize {
|
||||
*pmap.entry(q).or_insert_with(|| {
|
||||
clip.points.push(sk.point(q).unwrap_or_default());
|
||||
@@ -193,21 +222,17 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
for c in &sk.constraints {
|
||||
// Fixes and pattern or offset relations stay with the original.
|
||||
if matches!(
|
||||
c.kind,
|
||||
ConstraintKind::Fix { .. }
|
||||
| ConstraintKind::PatternPoint { .. }
|
||||
| ConstraintKind::PatternRadius { .. }
|
||||
| ConstraintKind::OffsetDistance { .. }
|
||||
) {
|
||||
// Pattern or offset relations stay with the original, and so do the fixes of a part copy.
|
||||
if matches!(c.kind, ConstraintKind::PatternPoint { .. } | ConstraintKind::PatternRadius { .. } | ConstraintKind::OffsetDistance { .. })
|
||||
|| (!whole && matches!(c.kind, ConstraintKind::Fix { .. }))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut kind = c.kind.clone();
|
||||
if !kind.remap(&|q| pmap.get(&q).copied(), &|k| cmap.get(&k).copied()) {
|
||||
continue;
|
||||
}
|
||||
let param = c.param.as_ref().map(|name| match s.doc.param(name) {
|
||||
let param = c.param.as_ref().map(|name| match doc.param(name) {
|
||||
Some(x) => (x.expr.clone(), x.unit.clone()),
|
||||
None => (format!("{}", kind.value().unwrap_or_default()), if kind.is_angle() { "rad".into() } else { "mm".into() }),
|
||||
});
|
||||
@@ -238,12 +263,13 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add(*q);
|
||||
}
|
||||
}
|
||||
if bounds.is_empty() {
|
||||
if !bounds.is_empty() {
|
||||
clip.center = bounds.center();
|
||||
let size = bounds.size();
|
||||
clip.extent = size.x.max(size.y);
|
||||
} else if !whole {
|
||||
return Err(bad(cmd, "nothing to copy"));
|
||||
}
|
||||
clip.center = bounds.center();
|
||||
let size = bounds.size();
|
||||
clip.extent = size.x.max(size.y);
|
||||
let out = Copied {
|
||||
curves: clip.curves.len() + texts.iter().map(|t| sk.link_curves(t).len()).sum::<usize>(),
|
||||
points: clip.lone.len(),
|
||||
@@ -252,8 +278,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
center: [clip.center.x, clip.center.y],
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
s.sketch_clipboard = Some(clip);
|
||||
reply(cmd, out)
|
||||
Ok((clip, out))
|
||||
}
|
||||
|
||||
fn reply(cmd: &str, reply: impl serde::Serialize) -> Result<Value> {
|
||||
@@ -315,58 +340,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut p = p.clone();
|
||||
p["sketch"] = json!(target);
|
||||
let ((curves, lone, constraints), info) = edit(s, &p, cmd, false, |sk, doc| {
|
||||
if sk.curves.len().saturating_add(clip.curves.len()) > solidcraft_sketch::MAX_CURVES {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
let mut pts = Vec::with_capacity(clip.points.len());
|
||||
for q in &clip.points {
|
||||
pts.push(sk.add_point(*q + t, None)?);
|
||||
}
|
||||
let mut made = Vec::with_capacity(clip.curves.len());
|
||||
for c in &clip.curves {
|
||||
let mut kind = c.kind.clone();
|
||||
kind.map_points(&|q| pts.get(q).copied().unwrap_or(usize::MAX));
|
||||
let i = sk.add_curve(kind, None)?;
|
||||
if let Some(cu) = sk.curves.get_mut(i) {
|
||||
cu.construction = c.construction;
|
||||
cu.centerline = c.centerline;
|
||||
cu.reversed = c.reversed;
|
||||
cu.infinite = c.infinite;
|
||||
}
|
||||
made.push(i);
|
||||
}
|
||||
let mut cons = Vec::new();
|
||||
for c in &clip.constraints {
|
||||
let mut kind = c.kind.clone();
|
||||
if !kind.remap(&|q| pts.get(q).copied(), &|k| made.get(k).copied()) {
|
||||
continue;
|
||||
}
|
||||
// Each pasted dimension gets its own parameter, with the same expression.
|
||||
let param = c.param.as_ref().map(|(e, u)| doc.new_model_param(e, u));
|
||||
let id = sk.add_constraint(kind, param)?;
|
||||
if let Some(nc) = sk.constraints.last_mut() {
|
||||
nc.driven = c.driven;
|
||||
nc.text = c.text;
|
||||
}
|
||||
cons.push(id);
|
||||
}
|
||||
let mut curve_ids: Vec<String> = made.iter().filter_map(|i| sk.curves.get(*i).map(|c| c.id.clone())).collect();
|
||||
for (src, path) in &clip.texts {
|
||||
let mut src = src.clone();
|
||||
let LinkSource::Text { at, style, exprs, .. } = &mut src else { continue };
|
||||
style.path = path.and_then(|k| made.get(k)).and_then(|i| sk.curves.get(*i)).map(|c| c.id.clone());
|
||||
*at += t;
|
||||
// The copy sits where it was pasted: its place no longer follows the original's expressions.
|
||||
exprs.x = None;
|
||||
exprs.y = None;
|
||||
let geom = sk.text_source_geometry(&src)?;
|
||||
let link = sk.add_link(LinkKind::Text, src, &geom)?;
|
||||
curve_ids.extend(sk.link_curves(&link).into_iter().filter_map(|i| sk.curves.get(i).map(|c| c.id.clone())));
|
||||
}
|
||||
let lone: Vec<String> = clip.lone.iter().filter_map(|n| pts.get(*n)).filter_map(|i| sk.points.get(*i).map(|q| q.id.clone())).collect();
|
||||
Ok((curve_ids, lone, cons))
|
||||
})?;
|
||||
let ((curves, lone, constraints), info) = edit(s, &p, cmd, false, |sk, doc| paste_clip(sk, doc, &clip, t))?;
|
||||
if !positioned
|
||||
&& target == clip.sketch
|
||||
&& let Some(c) = s.sketch_clipboard.as_mut()
|
||||
@@ -384,6 +358,160 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reply(cmd, Pasted { curves, points: lone, constraints, translate: [t.x, t.y], sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The feature id of the sketch `key` (an id or a name, as [`sketch_store::target`] reads them)
|
||||
/// in `doc`.
|
||||
fn sketch_in(doc: &Document, key: &Value, cmd: &str) -> Result<u64> {
|
||||
let (name, found) = match key {
|
||||
Value::Number(n) => {
|
||||
let id = n.as_u64().ok_or_else(|| bad(cmd, "`from` must be a sketch id or name"))?;
|
||||
(id.to_string(), doc.feature(id))
|
||||
}
|
||||
Value::String(x) => (x.clone(), doc.find_feature(x)),
|
||||
_ => return Err(bad(cmd, "`from` must be a sketch id or name")),
|
||||
};
|
||||
let f = found.ok_or_else(|| bad(cmd, format!("no sketch `{name}`")))?;
|
||||
if !matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. }) {
|
||||
return Err(bad(cmd, format!("`{name}` is not a sketch")));
|
||||
}
|
||||
Ok(f.id)
|
||||
}
|
||||
|
||||
/// A copy of every curve, point, constraint, dimension and text of sketch `id` in `doc`.
|
||||
fn whole_clip(doc: &Document, id: u64, cmd: &str) -> Result<SketchClip> {
|
||||
let sk = sketch_store::read(doc, id)?;
|
||||
let ids: Vec<String> = sk.curves.iter().map(|c| c.id.clone()).chain(sk.points.iter().filter(|q| q.id != ORIGIN).map(|q| q.id.clone())).collect();
|
||||
Ok(clip_of(doc, sk, id, &ids, true, cmd)?.0)
|
||||
}
|
||||
|
||||
/// `sketch.replace` (#11125): the sketch keeps its feature (plane, place in the timeline, name,
|
||||
/// display options and what is built on it) and takes a copy of another sketch's contents, as a
|
||||
/// whole-sketch copy and paste would. Projected geometry comes as ordinary geometry; patterns,
|
||||
/// offsets and formula curves come as the geometry they made. The dimension parameters the old
|
||||
/// contents had, when nothing else uses them, go with them.
|
||||
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.replace";
|
||||
if !(p.is_object() || p.is_null()) {
|
||||
return Err(bad(cmd, "params must be an object"));
|
||||
}
|
||||
let target = sketch_store::target(s, p, cmd)?;
|
||||
let from = p.get("from").filter(|v| !v.is_null()).ok_or_else(|| bad(cmd, "`from`: the sketch to take the contents of"))?;
|
||||
let file = match p.get("file").filter(|v| !v.is_null()) {
|
||||
None => None,
|
||||
Some(Value::String(f)) if !f.trim().is_empty() => Some(f.clone()),
|
||||
Some(_) => return Err(bad(cmd, "`file` must be the path of a design file")),
|
||||
};
|
||||
let clip = match &file {
|
||||
Some(path) => {
|
||||
let (other, _) =
|
||||
solidcraft_io::read_design_file(path, None::<fn(solidcraft_io::DesignPreview)>).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
let source = sketch_in(&other, from, cmd)?;
|
||||
whole_clip(&other, source, cmd)?.for_another_design()
|
||||
}
|
||||
None => {
|
||||
let source = sketch_in(&s.doc, from, cmd)?;
|
||||
if source == target {
|
||||
return Err(bad(cmd, "a sketch can't be replaced by itself"));
|
||||
}
|
||||
whole_clip(&s.doc, source, cmd)?
|
||||
}
|
||||
};
|
||||
let ((curves, points, constraints), info) = edit(s, &json!({ "sketch": target }), cmd, false, |sk, doc| {
|
||||
let old: Vec<String> = sk.constraints.iter().filter_map(|c| c.param.clone()).collect();
|
||||
// Emptied, but its id counters kept: the new contents' ids carry on from the old ones, so
|
||||
// a reference to an old curve or point (a sweep path, a profile picked by its curves)
|
||||
// finds nothing and says so, rather than taking a new, unrelated curve that happens to get
|
||||
// the same id.
|
||||
let mut fresh = sk.clone();
|
||||
fresh.points.retain(|q| q.id == ORIGIN);
|
||||
fresh.curves.clear();
|
||||
fresh.constraints.clear();
|
||||
fresh.patterns.clear();
|
||||
fresh.links.clear();
|
||||
fresh.offsets.clear();
|
||||
fresh.freeform_offsets.clear();
|
||||
fresh.wires.clear();
|
||||
fresh.equations.clear();
|
||||
// The old contents' own dimension parameters go with them; one something else uses stays.
|
||||
sketch_store::write(doc, target, fresh.clone())?;
|
||||
for name in &old {
|
||||
if doc.param(name).is_some_and(|x| x.model) {
|
||||
let _ = doc.remove_param(name);
|
||||
}
|
||||
}
|
||||
*sk = fresh;
|
||||
paste_clip(sk, doc, &clip, Vec2::ZERO)
|
||||
})?;
|
||||
if s.active_sketch == Some(target) {
|
||||
s.selection.clear();
|
||||
}
|
||||
let source = match from {
|
||||
Value::String(x) => x.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
reply(cmd, Replaced { sketch_id: target, from: source, file, curves, points, constraints, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The ids of what a paste added: curves (those of texts included), lone points, constraints.
|
||||
type PastedIds = (Vec<String>, Vec<String>, Vec<String>);
|
||||
|
||||
/// Add a copy of `clip`, moved by `t`, to `sk`.
|
||||
fn paste_clip(sk: &mut Sketch, doc: &mut Document, clip: &SketchClip, t: Vec2) -> Result<PastedIds> {
|
||||
if sk.curves.len().saturating_add(clip.curves.len()) > solidcraft_sketch::MAX_CURVES {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
let mut pts = Vec::with_capacity(clip.points.len());
|
||||
let origin = sk.points.iter().position(|q| q.id == ORIGIN);
|
||||
for (i, q) in clip.points.iter().enumerate() {
|
||||
match origin.filter(|_| clip.origin == Some(i)) {
|
||||
Some(o) => pts.push(o),
|
||||
None => pts.push(sk.add_point(*q + t, None)?),
|
||||
}
|
||||
}
|
||||
let mut made = Vec::with_capacity(clip.curves.len());
|
||||
for c in &clip.curves {
|
||||
let mut kind = c.kind.clone();
|
||||
kind.map_points(&|q| pts.get(q).copied().unwrap_or(usize::MAX));
|
||||
let i = sk.add_curve(kind, None)?;
|
||||
if let Some(cu) = sk.curves.get_mut(i) {
|
||||
cu.construction = c.construction;
|
||||
cu.centerline = c.centerline;
|
||||
cu.reversed = c.reversed;
|
||||
cu.infinite = c.infinite;
|
||||
}
|
||||
made.push(i);
|
||||
}
|
||||
let mut cons = Vec::new();
|
||||
for c in &clip.constraints {
|
||||
let mut kind = c.kind.clone();
|
||||
if !kind.remap(&|q| pts.get(q).copied(), &|k| made.get(k).copied()) {
|
||||
continue;
|
||||
}
|
||||
// Each pasted dimension gets its own parameter, with the same expression.
|
||||
let param = c.param.as_ref().map(|(e, u)| doc.new_model_param(e, u));
|
||||
let id = sk.add_constraint(kind, param)?;
|
||||
if let Some(nc) = sk.constraints.last_mut() {
|
||||
nc.driven = c.driven;
|
||||
nc.text = c.text;
|
||||
}
|
||||
cons.push(id);
|
||||
}
|
||||
let mut curve_ids: Vec<String> = made.iter().filter_map(|i| sk.curves.get(*i).map(|c| c.id.clone())).collect();
|
||||
for (src, path) in &clip.texts {
|
||||
let mut src = src.clone();
|
||||
let LinkSource::Text { at, style, exprs, .. } = &mut src else { continue };
|
||||
style.path = path.and_then(|k| made.get(k)).and_then(|i| sk.curves.get(*i)).map(|c| c.id.clone());
|
||||
*at += t;
|
||||
// The copy sits where it was pasted: its place no longer follows the original's expressions.
|
||||
exprs.x = None;
|
||||
exprs.y = None;
|
||||
let geom = sk.text_source_geometry(&src)?;
|
||||
let link = sk.add_link(LinkKind::Text, src, &geom)?;
|
||||
curve_ids.extend(sk.link_curves(&link).into_iter().filter_map(|i| sk.curves.get(i).map(|c| c.id.clone())));
|
||||
}
|
||||
let lone: Vec<String> = clip.lone.iter().filter_map(|n| pts.get(*n)).filter_map(|i| sk.points.get(*i).map(|q| q.id.clone())).collect();
|
||||
Ok((curve_ids, lone, cons))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_clipboard_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -294,3 +294,166 @@ fn sketch_geometry_pastes_into_another_design_with_values_not_foreign_parameters
|
||||
let at = pos(sketch(&b, sb), &again[0]);
|
||||
assert!(at.iter().zip(&corner).all(|(p, q)| p.dist(*q) < 1e-6), "{at:?}");
|
||||
}
|
||||
|
||||
fn volume(s: &Session) -> f64 {
|
||||
s.model.state().bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum()
|
||||
}
|
||||
|
||||
/// A design with sketch `Small` (a 10 x 10 square from the origin) extruded 5 mm, then sketch
|
||||
/// `Big` (a dimensioned 30 x 20 rectangle tied to the origin) on the same plane.
|
||||
fn two_sketches() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Small", "distance": 5.0, "operation": "new_body"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Big"}));
|
||||
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}))["curves"]);
|
||||
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", rect[0]), "b": "origin"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [rect[0]], "value": 30}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [rect[1]], "value": 20}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
s
|
||||
}
|
||||
|
||||
/// Replace Sketch (#11125): the sketch keeps its feature, and the extrude built on it now makes
|
||||
/// the other sketch's shape (10 x 10 x 5 = 500 mm³ becomes 30 x 20 x 5 = 3000 mm³). The copy's
|
||||
/// constraints come along, the one to the origin tied to this sketch's origin, its dimensions
|
||||
/// with their own parameters; the source is untouched; one undo step puts it all back.
|
||||
#[test]
|
||||
fn replace_sketch_gives_a_sketch_another_sketchs_contents_and_keeps_its_features() {
|
||||
let mut s = two_sketches();
|
||||
assert!((volume(&s) - 500.0).abs() < 1e-6, "{}", volume(&s));
|
||||
let small = s.doc.find_feature("Small").unwrap().id;
|
||||
let big = s.doc.find_feature("Big").unwrap().id;
|
||||
let features = s.doc.features.len();
|
||||
let params = s.doc.params.len();
|
||||
let source = s.doc.sketch(big).unwrap().clone();
|
||||
let before = s.doc.to_json();
|
||||
|
||||
let out = run(&mut s, "sketch.replace", json!({"sketch": "Small", "from": "Big"}));
|
||||
assert_eq!(out["sketch_id"], json!(small));
|
||||
assert_eq!(strs(&out["curves"]).len(), 4);
|
||||
assert_eq!(s.doc.features.len(), features, "no feature added or removed");
|
||||
assert_eq!(s.doc.find_feature("Small").map(|f| f.id), Some(small), "the sketch keeps its id and name");
|
||||
assert!((volume(&s) - 3000.0).abs() < 1e-6, "the extrude follows: {}", volume(&s));
|
||||
let sk = sketch(&s, small);
|
||||
assert_eq!(sk.constraints.len(), source.constraints.len(), "every constraint comes along");
|
||||
assert_eq!(sk.points.iter().filter(|q| q.id == "origin").count(), 1, "one origin");
|
||||
let origin = sk.points.iter().position(|q| q.id == "origin").unwrap();
|
||||
assert!(
|
||||
sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Coincident { p, q } if p == origin || q == origin)),
|
||||
"the corner stays tied to the origin: {:?}",
|
||||
sk.constraints
|
||||
);
|
||||
let names: Vec<String> = sk.constraints.iter().filter_map(|c| c.param.clone()).collect();
|
||||
let theirs: Vec<String> = source.constraints.iter().filter_map(|c| c.param.clone()).collect();
|
||||
assert_eq!(names.len(), 2);
|
||||
assert!(names.iter().all(|n| !theirs.contains(n)), "its own parameters: {names:?} vs {theirs:?}");
|
||||
assert_eq!(s.doc.params.len(), params + 2);
|
||||
assert_eq!(serde_json::to_value(s.doc.sketch(big).unwrap()).unwrap(), serde_json::to_value(&source).unwrap(), "the source is untouched");
|
||||
// Its dimensions drive it: a new width changes this sketch only.
|
||||
run(&mut s, "parameters.change", json!({"name": names[0], "expression": "40"}));
|
||||
assert!((volume(&s) - 4000.0).abs() < 1e-6, "{}", volume(&s));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc.to_json(), before, "one undo step");
|
||||
}
|
||||
|
||||
/// The old contents' dimension parameters go with them, unless something else uses them; a
|
||||
/// parameter the user made stays.
|
||||
#[test]
|
||||
fn replace_sketch_drops_the_old_dimension_parameters_nothing_else_uses() {
|
||||
let mut s = two_sketches();
|
||||
run(&mut s, "parameters.change", json!({"name": "w", "expression": "12"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Third"}));
|
||||
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [12, 8]}))["curves"]);
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [rect[0]], "value": "w"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [rect[1]], "value": 8}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let third = s.doc.find_feature("Third").unwrap().id;
|
||||
let params: Vec<String> = sketch(&s, third).constraints.iter().filter_map(|c| c.param.clone()).collect();
|
||||
assert_eq!(params.len(), 2, "{params:?}");
|
||||
run(&mut s, "sketch.replace", json!({"sketch": third, "from": "Small"}));
|
||||
for name in ¶ms {
|
||||
let kept = s.doc.param(name);
|
||||
assert!(kept.is_none_or(|p| !p.model), "`{name}` went with the old dimension: {kept:?}");
|
||||
}
|
||||
assert!(s.doc.param("w").is_some(), "the user's parameter stays");
|
||||
}
|
||||
|
||||
/// A feature that names the old contents' curves (here a profile picked by its curve ids) never
|
||||
/// takes the new contents' curves that happen to get the same ids: the new ids carry on from the
|
||||
/// old ones, so the reference finds nothing and the feature says so, instead of quietly building
|
||||
/// from unrelated geometry (30 x 20 x 5 = 3000 mm³ here).
|
||||
#[test]
|
||||
fn replace_sketch_never_rebinds_a_reference_to_an_old_curve_onto_a_new_one() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Small"}));
|
||||
let square = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]}))["curves"]);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Small", "distance": 5.0, "operation": "new_body", "profiles": [{"curves": square}]}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Big"}));
|
||||
let rect = strs(&run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]}))["curves"]);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(square, rect, "both sketches numbered their curves alike: {square:?} {rect:?}");
|
||||
assert!((volume(&s) - 500.0).abs() < 1e-6, "{}", volume(&s));
|
||||
let before = s.doc.to_json();
|
||||
match s.execute("sketch.replace", &json!({"sketch": "Small", "from": "Big"})) {
|
||||
Ok(out) => {
|
||||
let new = strs(&out["curves"]);
|
||||
assert!(new.iter().all(|id| !square.contains(id)), "new ids, not the old ones again: {new:?} vs {square:?}");
|
||||
assert!(s.model.results.iter().any(|r| r.error.is_some()), "the extrude says its curves are gone");
|
||||
}
|
||||
Err(_) => assert_eq!(s.doc.to_json(), before, "a refused replace changes nothing"),
|
||||
}
|
||||
assert!((volume(&s) - 3000.0).abs() > 1.0, "never built from the unrelated new curves: {}", volume(&s));
|
||||
}
|
||||
|
||||
/// Sketch contents come from a sketch in another design file, at the same sketch coordinates; a
|
||||
/// dimension that named that design's parameter keeps its value, without the parameter.
|
||||
#[test]
|
||||
fn replace_sketch_takes_a_sketch_from_another_design_file() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-replace-sketch-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Other.sc");
|
||||
let mut other = Session::default();
|
||||
run(&mut other, "parameters.change", json!({"name": "w", "expression": "1 in"}));
|
||||
run(&mut other, "sketch.create", json!({"plane": "XZ", "name": "Plate"}));
|
||||
let rect = strs(&run(&mut other, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [25.4, 10]}))["curves"]);
|
||||
run(&mut other, "sketch.dimension", json!({"entities": [rect[0]], "value": "w"}));
|
||||
run(&mut other, "sketch.finish", json!({}));
|
||||
run(&mut other, "file.save_as", json!({"path": path.to_string_lossy()}));
|
||||
|
||||
let mut s = two_sketches();
|
||||
let file = path.to_string_lossy().to_string();
|
||||
let out = run(&mut s, "sketch.replace", json!({"sketch": "Small", "from": "Plate", "file": file}));
|
||||
assert_eq!(out["file"], json!(file));
|
||||
assert!((volume(&s) - 25.4 * 10.0 * 5.0).abs() < 1e-6, "{}", volume(&s));
|
||||
assert!(s.doc.param("w").is_none(), "the other design's parameter is not brought in");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_sketch_refuses_bad_requests_and_leaves_the_design_as_it_was() {
|
||||
let mut s = two_sketches();
|
||||
let before = s.doc.to_json();
|
||||
let extrude = s.doc.features.iter().find(|f| !matches!(f.kind, solidcraft_doc::FeatureKind::Sketch { .. })).unwrap().id;
|
||||
for bad in [
|
||||
json!({"sketch": "Small"}),
|
||||
json!({"sketch": "Small", "from": "Small"}),
|
||||
json!({"sketch": "Small", "from": "Nope"}),
|
||||
json!({"sketch": "Small", "from": 1.5}),
|
||||
json!({"sketch": "Small", "from": [1]}),
|
||||
json!({"sketch": "Small", "from": extrude}),
|
||||
json!({"sketch": "Nope", "from": "Big"}),
|
||||
json!({"from": "Big"}),
|
||||
json!({"sketch": "Small", "from": "Big", "file": 3}),
|
||||
json!({"sketch": "Small", "from": "Big", "file": "Z:/no/such/file.solidcraft"}),
|
||||
json!([1, 2]),
|
||||
json!("text"),
|
||||
] {
|
||||
assert!(s.execute("sketch.replace", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.doc.to_json(), before, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ use super::sketch::{add_c, coincident_points, dof_now, edit, ids_of, line_or_axi
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, expr, num, req_expr, req_vec2, str_, string_list, vec2, vec2_list};
|
||||
use crate::schema::sketch_modify::{
|
||||
BreakParams, BreakResult, ChamferDistanceAngleParams, ChamferEqualParams, ChamferResult, ChamferTwoParams, CircularPatternParams, ExtendParams,
|
||||
ExtendResult, FilletParams, FilletResult, MAX_BREAK_POINTS, MAX_COPIES, MAX_INSTANCES, MAX_PATH_POINTS, MirrorParams, MirrorResult, MoveParams,
|
||||
MoveResult, OffsetBreakParams, OffsetBreakResult, OffsetParams, OffsetResult, OffsetSide, PatternResult, RectangularPatternParams, ScaleParams,
|
||||
ScaleResult, TrimOutcome, TrimParams, TrimPathParams, TrimPathResult, TrimResult,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[path = "sketch_pattern.rs"]
|
||||
@@ -20,28 +27,54 @@ mod pattern;
|
||||
#[path = "sketch_support.rs"]
|
||||
mod support;
|
||||
|
||||
const TRIM_CONTRACT: TypedContract<TrimParams> = TypedContract::new();
|
||||
const TRIM_PATH_CONTRACT: TypedContract<TrimPathParams> = TypedContract::new();
|
||||
const BREAK_CONTRACT: TypedContract<BreakParams> = TypedContract::new();
|
||||
const EXTEND_CONTRACT: TypedContract<ExtendParams> = TypedContract::new();
|
||||
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
|
||||
const CHAMFER_EQUAL_CONTRACT: TypedContract<ChamferEqualParams> = TypedContract::new();
|
||||
const CHAMFER_TWO_CONTRACT: TypedContract<ChamferTwoParams> = TypedContract::new();
|
||||
const CHAMFER_DISTANCE_ANGLE_CONTRACT: TypedContract<ChamferDistanceAngleParams> = TypedContract::new();
|
||||
const OFFSET_CONTRACT: TypedContract<OffsetParams> = TypedContract::new();
|
||||
const OFFSET_BREAK_CONTRACT: TypedContract<OffsetBreakParams> = TypedContract::new();
|
||||
const MIRROR_CONTRACT: TypedContract<MirrorParams> = TypedContract::new();
|
||||
const MOVE_CONTRACT: TypedContract<MoveParams> = TypedContract::new();
|
||||
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
|
||||
const CIRCULAR_PATTERN_CONTRACT: TypedContract<CircularPatternParams> = TypedContract::new();
|
||||
const RECTANGULAR_PATTERN_CONTRACT: TypedContract<RectangularPatternParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.fillet", "Fillet", fillet)
|
||||
CommandSpec::new("sketch.fillet", "Fillet", crate::checked!("sketch.fillet", fillet))
|
||||
.contract(&FILLET_CONTRACT)
|
||||
.output(output_schema_for::<FilletResult>)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_fillet")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("point: corner shared by two curves (\"l1.end\") | points: [corners] (all rounded alike: one radius, the rest equal; a corner is a point reference or {a, b}, the two curves meeting there) | a, b: two lines or arcs meeting at a corner (two lines that share no end are first brought to where they meet: the nearer end of a line that stops short moves there, and a line that runs past is cut there, losing its shorter piece); radius: expr. A corner that dimensions or constraints measure to stays as a virtual sharp corner on both curves' extensions (`virtual_corners`; the moved ones in `moved_to_virtual_corner`). An ellipse's axis line is never a side of a corner (also for the chamfers)", created_doc!())),
|
||||
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", chamfer_equal)
|
||||
CommandSpec::new("sketch.chamfer.equal_distance", "Equal Distance Chamfer", crate::checked!("sketch.chamfer.equal_distance", chamfer_equal))
|
||||
.contract(&CHAMFER_EQUAL_CONTRACT)
|
||||
.output(output_schema_for::<ChamferResult>)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_chamfer")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance", created_doc!())),
|
||||
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", chamfer_dist_angle)
|
||||
CommandSpec::new("sketch.chamfer.distance_angle", "Distance and Angle Chamfer", crate::checked!("sketch.chamfer.distance_angle", chamfer_dist_angle))
|
||||
.contract(&CHAMFER_DISTANCE_ANGLE_CONTRACT)
|
||||
.output(output_schema_for::<ChamferResult>)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_chamfer_angle")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}, and a point's `a` is the first of its two lines in curve order) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance: along a, angle: deg from a", created_doc!())),
|
||||
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", chamfer_two)
|
||||
CommandSpec::new("sketch.chamfer.two_distance", "Two Distance Chamfer", crate::checked!("sketch.chamfer.two_distance", chamfer_two))
|
||||
.contract(&CHAMFER_TWO_CONTRACT)
|
||||
.output(output_schema_for::<ChamferResult>)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_chamfer_two_distances")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("point | points: [corners] (every corner cut alike; a corner is a point reference or {a, b}, and a point's `a` is the first of its two lines in curve order) | a, b: a corner of two lines (two that share no end are first brought to where they meet, as in sketch.fillet); distance: along a, distance2: along b", created_doc!())),
|
||||
CommandSpec::new("sketch.offset", "Offset", offset)
|
||||
CommandSpec::new("sketch.offset", "Offset", crate::checked!("sketch.offset", offset))
|
||||
.contract(&OFFSET_CONTRACT)
|
||||
.output(output_schema_for::<OffsetResult>)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("offset")
|
||||
.key("O")
|
||||
@@ -53,33 +86,45 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
"instances?: whole number or unitless expression, 1…100 (default 1): each instance after the first offsets the instance before it by the same distance again, so instance n stands n distances from the curves picked. Every instance of a side is driven by that side's own dimension, and each entry of `sides` names its `instance`; an instance the shape cannot take refuses the whole command",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.offset.break", "Break Offset Association", break_offset).icon("sketch_break")
|
||||
CommandSpec::new("sketch.offset.break", "Break Offset Association", crate::checked!("sketch.offset.break", break_offset))
|
||||
.contract(&OFFSET_BREAK_CONTRACT)
|
||||
.output(output_schema_for::<OffsetBreakResult>).icon("sketch_break")
|
||||
.enabled(in_sketch)
|
||||
.params("curve: derived offset curve id (retains geometry and makes its points editable)"),
|
||||
CommandSpec::new("sketch.trim", "Trim", trim)
|
||||
CommandSpec::new("sketch.trim", "Trim", crate::checked!("sketch.trim", trim))
|
||||
.contract(&TRIM_CONTRACT)
|
||||
.output(output_schema_for::<TrimResult>)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("trim")
|
||||
.key("T")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("curve: id, at: [x,y] (the piece to remove: between the nearest intersections around it). Trimming a line source or its single-line offset releases linked extents (offset_released: curve ids), retaining their driving normal-distance dimensions and parallel relations", created_doc!())),
|
||||
CommandSpec::new("sketch.trim.path", "Trim Along Path", trim_path)
|
||||
CommandSpec::new("sketch.trim.path", "Trim Along Path", crate::checked!("sketch.trim.path", trim_path))
|
||||
.contract(&TRIM_PATH_CONTRACT)
|
||||
.output(output_schema_for::<TrimPathResult>)
|
||||
.icon("trim")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!(
|
||||
"points: [[x,y]…] (2…2000 positions: a path drawn across the sketch). Every piece the path crosses is removed as `sketch.trim` removes the piece at that crossing, in the order the path crosses them; a piece crossed again after it is gone is not trimmed twice; eligible single-line offset outputs are trimmed while preserving their distance; projected geometry, infinite lines, other offset outputs and pieces trim refuses are passed over (why in `skipped`). dry_run?: bool (report without changing the design). One undo step → trimmed: [each trim's result], removed: [[[x,y]…]] (each removed piece as a polyline)",
|
||||
created_doc!()
|
||||
)),
|
||||
CommandSpec::new("sketch.extend", "Extend", extend)
|
||||
CommandSpec::new("sketch.extend", "Extend", crate::checked!("sketch.extend", extend))
|
||||
.contract(&EXTEND_CONTRACT)
|
||||
.output(output_schema_for::<ExtendResult>)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("extend")
|
||||
.enabled(in_sketch)
|
||||
.params("curve: id, at: [x,y] near the end to extend (a line runs on to the nearest curve, or the previewed reach when nothing is in the way; an arc grows along its circle and closes into a whole circle when nothing stops it) | curves: [id, id] (two lines or arcs of this sketch, grown until they meet and joined there), at?: [[x,y],[x,y]] (one point per curve: the end to grow; default the end nearest the meeting point). `curve` and `curves` exclude each other; the two-curve form returns moved: the meeting point's id"),
|
||||
CommandSpec::new("sketch.break", "Break", break_cmd)
|
||||
CommandSpec::new("sketch.break", "Break", crate::checked!("sketch.break", break_cmd))
|
||||
.contract(&BREAK_CONTRACT)
|
||||
.output(output_schema_for::<BreakResult>)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_break")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("curve: id, at: [x,y] (split at intersections; at a point where the curve ends and another curve meets, the curve gets its own end there and the two are no longer joined) | points: [[x,y]…] (1…1000 positions projected onto the curve; duplicates are merged)", created_doc!())),
|
||||
CommandSpec::new("sketch.scale", "Sketch Scale", scale)
|
||||
CommandSpec::new("sketch.scale", "Sketch Scale", crate::checked!("sketch.scale", scale))
|
||||
.contract(&SCALE_CONTRACT)
|
||||
.output(output_schema_for::<ScaleResult>)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_scale")
|
||||
.enabled(in_sketch)
|
||||
@@ -90,34 +135,47 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.key("G")
|
||||
.enabled(in_sketch)
|
||||
.params("entities: [curve/point ids], factor?: number (scale), angle?: degrees (turn), center?: [x,y] or point ref (what scales and turns stay put; default [0,0]), translate?: [dx,dy] (applied last); scales, turns and moves in one step"),
|
||||
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", move_copy)
|
||||
CommandSpec::new("sketch.move", "Move/Copy Sketch Entities", crate::checked!("sketch.move", move_copy))
|
||||
.contract(&MOVE_CONTRACT)
|
||||
.output(output_schema_for::<MoveResult>)
|
||||
.at("SKETCH", "MODIFY")
|
||||
.icon("sketch_move")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], translate?: [dx,dy], angle?: deg, center?: [x,y], copy?: bool, rotate_constraints?: bool (default true; false keeps constraints unchanged); a rotation turns the constraints tied to the sketch axes when all their geometry moves (horizontal and vertical relations and dimensions swap at odd quarter turns and stay at half turns, line angles turn with the line) and removes the horizontal and vertical ones that cannot hold at other angles; picking a text outline or linked point transforms its whole text source once, and copies remain editable text; copy also accepts drawn 3D curves, preserving exact arcs and height above the sketch plane", created_doc!())),
|
||||
CommandSpec::new("sketch.mirror", "Mirror", mirror)
|
||||
CommandSpec::new("sketch.mirror", "Mirror", crate::checked!("sketch.mirror", mirror))
|
||||
.contract(&MIRROR_CONTRACT)
|
||||
.output(output_schema_for::<MirrorResult>)
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_mirror")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [curve/point ids], line: straight curve id, x / y for a sketch axis, or {axis: X|Y|Z} for an origin axis (the mirror line is the axis's projection into the sketch plane; an axis perpendicular to the plane is refused) (symmetry constraints are added)", created_doc!())),
|
||||
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", pattern_circular)
|
||||
CommandSpec::new("sketch.pattern.circular", "Circular Pattern", crate::checked!("sketch.pattern.circular", pattern_circular))
|
||||
.contract(&CIRCULAR_PATTERN_CONTRACT)
|
||||
.output(output_schema_for::<PatternResult>)
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_circ_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], center: [x,y] or point ref, count: 2…500 (number or unitless expression, retained through edits), angle?: total deg, number or expression (default 360); alignment?: left|center|right (default right); reversed?: bool (default false). Quantity includes the original. Center balances copies around it; reversed flips the direction and extra side for even totals. Partial arcs span angle with angle/(count-1) spacing; full turns use angle/count. Adds associative pattern constraints; a coordinate center creates a constrainable point. Returns pattern and center ids. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||
CommandSpec::new("sketch.pattern.rectangular", "Rectangular Pattern", crate::checked!("sketch.pattern.rectangular", pattern_rect))
|
||||
.contract(&RECTANGULAR_PATTERN_CONTRACT)
|
||||
.output(output_schema_for::<PatternResult>)
|
||||
.at("SKETCH", "PATTERN")
|
||||
.icon("sketch_rect_pattern")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("entities: [ids], count, spacing; count2?, spacing2?; counts: positive integer numbers or expressions, at most 500 instances total; spacings: length numbers or expressions; direction?, direction2?: sketch line ids (live references); dir?, dir2?: [x,y] vectors (defaults: sketch x, perpendicular to direction 1); alignment?, alignment2?: left|center|right (default right). Signed spacings reverse each direction. reversed?, reversed2?: bool (default false); reverses placement and the extra side of even centered totals. Quantities include the original. Adds associative pattern constraints. Returns pattern id. pattern?: existing pattern id to edit (omitted values retained; surviving instance ids retained)", created_doc!())),
|
||||
];
|
||||
|
||||
/// A reply built from its typed value, which is what the command's output schema describes.
|
||||
fn reply(cmd: &str, value: impl serde::Serialize) -> Result<Value> {
|
||||
serde_json::to_value(value).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// The strings of a JSON list, or `None` when it isn't one.
|
||||
fn strings(v: &Value) -> Option<Vec<String>> {
|
||||
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
|
||||
}
|
||||
|
||||
const TOL: f64 = 1e-7;
|
||||
const MAX_COPIES: usize = 500;
|
||||
/// The most offsets `sketch.offset` stacks one behind the other in a single run. Each instance
|
||||
/// is its own association, so this stays below the sketch's own caps on analytic offset groups
|
||||
/// and free-form associations (both 128).
|
||||
const MAX_INSTANCES: usize = 100;
|
||||
|
||||
fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a curve id"))?;
|
||||
@@ -523,14 +581,11 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let ci = curve_arg(sk, p, cmd)?;
|
||||
let released = release_line_offsets(sk, doc, ci, cmd)?;
|
||||
let (mut out, _) = remove_piece(sk, ci, at, cmd)?;
|
||||
if !released.is_empty() {
|
||||
out["offset_released"] = json!(released);
|
||||
}
|
||||
let (out, _) = remove_piece(sk, ci, at, cmd)?;
|
||||
doc.prune_model_params();
|
||||
Ok(out)
|
||||
Ok(out.released(released))
|
||||
})?;
|
||||
Ok(json!({"result": out, "sketch": info}))
|
||||
reply(cmd, TrimResult { result: out, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Source lines and their single-line offsets can have independent endpoints. Materialize
|
||||
@@ -685,7 +740,7 @@ fn hold_cut(sk: &mut Sketch, before: Option<&Sketch>, midpoints: &[usize], pi: u
|
||||
/// Remove the piece of curve `ci` around `at`: between the nearest intersections on either
|
||||
/// side, or the whole curve when nothing crosses it. Returns what `sketch.trim` reports and the
|
||||
/// removed piece as a polyline.
|
||||
fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Value, Vec<Vec2>)> {
|
||||
fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(TrimOutcome, Vec<Vec2>)> {
|
||||
loosen_along(sk, ci);
|
||||
let id = sk.curves.get(ci).map(|c| c.id.clone()).unwrap_or_default();
|
||||
if sk.curves.get(ci).is_some_and(|c| c.infinite) {
|
||||
@@ -698,7 +753,8 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu
|
||||
Some(pts) => pts.iter().filter_map(|q| Some(Vec2::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))).collect(),
|
||||
None => whole,
|
||||
};
|
||||
return Ok((json!({"trimmed": id, "pieces": r["curves"]}), removed));
|
||||
let pieces = r.get("curves").and_then(strings);
|
||||
return Ok((TrimOutcome::Freeform { trimmed: id, pieces, offset_released: Vec::new() }, removed));
|
||||
}
|
||||
let sh = sk.shape(ci).ok_or_else(|| bad(cmd, "curve"))?;
|
||||
let tq = sh.param(sh.project(at));
|
||||
@@ -707,7 +763,7 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu
|
||||
// Nothing crosses it: the whole curve goes.
|
||||
let removed = sk.polyline(ci);
|
||||
sk.remove_curves(&[ci]);
|
||||
return Ok((json!({"deleted": id}), removed));
|
||||
return Ok((TrimOutcome::Deleted { deleted: id, offset_released: Vec::new() }, removed));
|
||||
}
|
||||
refuse_offset_ends(sk, ci, "trim", cmd)?;
|
||||
// For parallel lines, any point on the output measures the same normal distance.
|
||||
@@ -788,14 +844,12 @@ fn remove_piece(sk: &mut Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<(Valu
|
||||
}
|
||||
let removed = sk.polyline(gone);
|
||||
sk.remove_curves(&[gone]);
|
||||
Ok((json!({"trimmed": id}), removed))
|
||||
Ok((TrimOutcome::Trimmed { trimmed: id, offset_released: Vec::new() }, removed))
|
||||
}
|
||||
|
||||
/// Most crossings one `sketch.trim.path` trims (each one splits a curve, so this also caps the
|
||||
/// work a hostile path can ask for).
|
||||
const MAX_PATH_TRIMS: usize = 500;
|
||||
/// Most positions in a trim path.
|
||||
const MAX_PATH_POINTS: usize = 2_000;
|
||||
|
||||
/// The first place along `path` after `after` (segment, parameter along it) where the path
|
||||
/// crosses a curve that a trim may change: (segment, parameter, curve, the crossing point). Crossings are found on the
|
||||
@@ -860,6 +914,10 @@ fn seg_cross(a: Vec2, b: Vec2, c: Vec2, d: Vec2) -> Option<(f64, Vec2)> {
|
||||
|
||||
/// Preview or trim the pieces crossed by a path, skipping protected or unsupported geometry.
|
||||
fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
reply("sketch.trim.path", trim_path_reply(s, p)?)
|
||||
}
|
||||
|
||||
fn trim_path_reply(s: &mut Session, p: &Value) -> Result<TrimPathResult> {
|
||||
let cmd = "sketch.trim.path";
|
||||
let path = vec2_list(cmd, p, "points", MAX_PATH_POINTS)?;
|
||||
if path.len() < 2 {
|
||||
@@ -871,13 +929,13 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("dry_run");
|
||||
}
|
||||
let mut out = trim_path(&mut t, &q)?;
|
||||
out["dry_run"] = json!(true);
|
||||
let mut out = trim_path_reply(&mut t, &q)?;
|
||||
out.dry_run = Some(true);
|
||||
return Ok(out);
|
||||
}
|
||||
let ((trimmed, removed, skipped), info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let mut trimmed: Vec<Value> = Vec::new();
|
||||
let mut removed: Vec<Value> = Vec::new();
|
||||
let mut trimmed: Vec<TrimOutcome> = Vec::new();
|
||||
let mut removed: Vec<Vec<[f64; 2]>> = Vec::new();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
let mut after = (0, f64::NEG_INFINITY);
|
||||
for _ in 0..MAX_PATH_TRIMS {
|
||||
@@ -886,16 +944,13 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A piece trim refuses (a closed spline crossed once) stays as it was.
|
||||
let before = sk.clone();
|
||||
let cut = release_line_offsets(sk, doc, ci, cmd).and_then(|released| {
|
||||
let (mut out, gone) = remove_piece(sk, ci, q, cmd)?;
|
||||
if !released.is_empty() {
|
||||
out["offset_released"] = json!(released);
|
||||
}
|
||||
Ok((out, gone))
|
||||
let (out, gone) = remove_piece(sk, ci, q, cmd)?;
|
||||
Ok((out.released(released), gone))
|
||||
});
|
||||
match cut {
|
||||
Ok((out, gone)) => {
|
||||
trimmed.push(out);
|
||||
removed.push(json!(gone.iter().map(|v| [v.x, v.y]).collect::<Vec<_>>()));
|
||||
removed.push(gone.iter().map(|v| [v.x, v.y]).collect());
|
||||
}
|
||||
Err(e) => {
|
||||
*sk = before;
|
||||
@@ -912,7 +967,7 @@ fn trim_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc.prune_model_params();
|
||||
Ok((trimmed, removed, skipped))
|
||||
})?;
|
||||
Ok(json!({"trimmed": trimmed, "removed": removed, "skipped": skipped, "sketch": info}))
|
||||
Ok(TrimPathResult { trimmed, removed, skipped, sketch: info, dry_run: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Cuts closer than this to a curve's end or to each other are one place (mm).
|
||||
@@ -920,7 +975,7 @@ const BREAK_GAP: f64 = 1e-6;
|
||||
|
||||
fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.break";
|
||||
let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", 1000)?) } else { None };
|
||||
let points = if p.get("points").is_some() { Some(vec2_list(cmd, p, "points", MAX_BREAK_POINTS)?) } else { None };
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, doc| {
|
||||
let ci = curve_arg(sk, p, cmd)?;
|
||||
if points.is_none()
|
||||
@@ -998,7 +1053,7 @@ fn break_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
tie(sk, &pieces)?;
|
||||
Ok(ids_of(sk, &pieces))
|
||||
})?;
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
reply(cmd, BreakResult { curves: out, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The end of curve `ci` at `at` that another curve also ends at or passes through: Break there
|
||||
@@ -1478,7 +1533,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
grow_line(sk, ci, aim)
|
||||
})?;
|
||||
Ok(json!({"moved": out, "sketch": info}))
|
||||
reply(cmd, ExtendResult { moved: out, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Grow line `ci` past the end `aim` names, to `aim.hit`: a new line when that end is joined to
|
||||
@@ -2166,8 +2221,8 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok((arcs, cons, pname, kept_json(kept)))
|
||||
})?;
|
||||
let (vc, moved) = out.3;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "param": out.2, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
|
||||
let (curves, constraints, param, (virtual_corners, moved_to_virtual_corner)) = out;
|
||||
reply(cmd, FilletResult { curves, constraints, param, virtual_corners, moved_to_virtual_corner, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// The virtual corners kept and the constraints moved to them, for a response.
|
||||
@@ -2381,8 +2436,8 @@ fn chamfer(s: &mut Session, p: &Value, cmd: &str, dists: impl Fn(f64) -> Result<
|
||||
}
|
||||
Ok((ids_of(sk, &lines), kept_json(kept)))
|
||||
})?;
|
||||
let (curves, (vc, moved)) = out;
|
||||
Ok(json!({"curves": curves, "virtual_corners": vc, "moved_to_virtual_corner": moved, "sketch": info}))
|
||||
let (curves, (virtual_corners, moved_to_virtual_corner)) = out;
|
||||
reply(cmd, ChamferResult { curves, virtual_corners, moved_to_virtual_corner, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Cut one corner (`point` or `a`, `b`) of two lines with a straight line, `dists` giving how
|
||||
@@ -3167,30 +3222,25 @@ fn offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc.params.retain(|p| !made.contains(&p.name) || param.as_ref() == Some(&p.name) || param2.as_ref() == Some(&p.name));
|
||||
Ok((sides, param, param2))
|
||||
})?;
|
||||
let curves: Vec<&String> = sides.iter().flat_map(|(_, side)| side.curves.iter()).collect();
|
||||
let constraints: Vec<&String> = sides.iter().flat_map(|(_, side)| side.constraints.iter()).collect();
|
||||
let each: Vec<Value> = sides
|
||||
let curves: Vec<String> = sides.iter().flat_map(|(_, side)| side.curves.iter().cloned()).collect();
|
||||
let constraints: Vec<String> = sides.iter().flat_map(|(_, side)| side.constraints.iter().cloned()).collect();
|
||||
let each: Vec<OffsetSide> = sides
|
||||
.iter()
|
||||
.map(|(instance, side)| {
|
||||
let mut entry = json!({"distance": side.distance, "curves": side.curves, "param": side.param, "instance": instance});
|
||||
if let Some(association) = &side.association {
|
||||
entry["association"] = json!(association);
|
||||
}
|
||||
entry
|
||||
.map(|(instance, side)| OffsetSide {
|
||||
distance: side.distance,
|
||||
curves: side.curves.clone(),
|
||||
param: side.param.clone(),
|
||||
instance: *instance,
|
||||
association: side.association.clone(),
|
||||
})
|
||||
.collect();
|
||||
let mut response = json!({"curves":curves,"constraints":constraints,"sketch":info,"sides":each,"param":param,"instances":instances});
|
||||
if let Some(param2) = param2 {
|
||||
response["param2"] = json!(param2);
|
||||
}
|
||||
// One side keeps the single `association` it always returned. Two sides, or an instance
|
||||
// behind another, have one each in `sides`, so there is no single association left to name.
|
||||
if let [(_, only)] = sides.as_slice()
|
||||
&& let Some(association) = &only.association
|
||||
{
|
||||
response["association"] = json!(association);
|
||||
}
|
||||
Ok(response)
|
||||
let association = match sides.as_slice() {
|
||||
[(_, only)] => only.association.clone(),
|
||||
_ => None,
|
||||
};
|
||||
reply(cmd, OffsetResult { curves, constraints, sketch: info, sides: each, param, instances, param2, association, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -3281,11 +3331,7 @@ fn break_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok((output.to_string(), None))
|
||||
})?;
|
||||
s.doc_mut().prune_model_params();
|
||||
let mut response = json!({"curve":out.0,"sketch":info});
|
||||
if let Some(curves) = out.1 {
|
||||
response["curves"] = json!(curves);
|
||||
}
|
||||
Ok(response)
|
||||
reply(cmd, OffsetBreakResult { curve: out.0, sketch: info, curves: out.1, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// A broken logical boundary becomes ordinary geometry. Keep downstream features
|
||||
@@ -3530,7 +3576,7 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok((ids_of(sk, &made), cons))
|
||||
})?;
|
||||
Ok(json!({"curves": out.0, "constraints": out.1, "sketch": info}))
|
||||
reply(cmd, MirrorResult { curves: out.0, constraints: out.1, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn point_or_ref(sk: &Sketch, p: &Value, k: &str, cmd: &str) -> Result<Vec2> {
|
||||
@@ -3740,7 +3786,7 @@ fn move_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(ids_of(sk, &cs))
|
||||
})?;
|
||||
s.doc_mut().prune_model_params();
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
reply(cmd, MoveResult { curves: out, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// Scale, turn and move the entities in one step: scaled about `center`, then turned about it,
|
||||
@@ -3838,7 +3884,7 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
transform_in_place(sk, &cs, &ps, &f, k, cmd)?;
|
||||
Ok(ids_of(sk, &cs))
|
||||
})?;
|
||||
Ok(json!({"curves": out, "sketch": info}))
|
||||
reply(cmd, ScaleResult { curves: out, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -4,6 +4,7 @@ use solidcraft_sketch::{SketchPattern, SketchPatternKind};
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::{alignment_param, reversed_param};
|
||||
use crate::schema::sketch_modify::PatternResult;
|
||||
|
||||
pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
let cmd = if circular { "sketch.pattern.circular" } else { "sketch.pattern.rectangular" };
|
||||
@@ -194,13 +195,10 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
let pattern = sk.patterns.get(index).ok_or_else(|| bad(cmd, "missing sketch pattern"))?;
|
||||
let curves: Vec<_> = pattern.instances.iter().flat_map(|i| i.curves.iter().cloned()).collect();
|
||||
let constraints: Vec<_> = pattern.instances.iter().flat_map(|i| i.constraints.iter().cloned()).collect();
|
||||
Ok(json!({"pattern":id,"center":center_id,"curves":curves,"constraints":constraints}))
|
||||
Ok((id, center_id, curves, constraints))
|
||||
})?;
|
||||
let mut out = out;
|
||||
if let Some(out) = out.as_object_mut() {
|
||||
out.insert("sketch".into(), info);
|
||||
}
|
||||
Ok(out)
|
||||
let (pattern, center, curves, constraints) = out;
|
||||
reply(cmd, PatternResult { pattern, center, curves, constraints, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn count(p: &Value, k: &str, cmd: &str) -> Result<usize> {
|
||||
|
||||
@@ -171,6 +171,59 @@ fn a_projected_edge_that_is_gone_fails_its_sketch_not_replaced() {
|
||||
/// A boss 20 x 20 x 10 made as its own body on the plate and joined into it afterwards: no body
|
||||
/// has the link's body name any more. The join left the boss's top edge from (30, 10, 30) to
|
||||
/// (30, 30, 30) as it was, and the link finds it by its name on the body that has it now.
|
||||
/// A sketch that fails for an edge gone by name still opens for editing (#12085): its own circle
|
||||
/// and the projected line, kept where it was and marked lost, are there to repair. It still
|
||||
/// fails, as does the extrude built from it; deleting the lost projection repairs both.
|
||||
#[test]
|
||||
fn a_sketch_that_lost_its_projected_edge_opens_with_its_curves_and_the_link_marked_lost() {
|
||||
let mut s = Session::default();
|
||||
plate(&mut s);
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[100, 25, 20]], "radius": "4 mm", "name": "Round"}));
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": true}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "Top"}));
|
||||
let projected_curves = run(&mut s, "sketch.project", json!({"refs": [{"edge": [100, 25, 20], "body": "Body1"}]}))["created"]["curves"].clone();
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [50, 25], "radius": 10}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Top", "distance": "5 mm", "name": "Boss"}));
|
||||
let top = s.doc.find_feature("Top").unwrap().id;
|
||||
let boss_error = |s: &Session| s.model.result(s.doc.find_feature("Boss").unwrap().id).unwrap().error.clone();
|
||||
let curves = |s: &mut Session| run(s, "sketch.inspect", json!({"sketch": "Top"}))["curves"].as_array().map_or(0, Vec::len);
|
||||
let name = stored(&s).edge_name.unwrap();
|
||||
run(&mut s, "timeline.suppress", json!({"feature": "Round", "suppressed": false}));
|
||||
assert_eq!(reselect(&s), Some(name.clone()), "{}", trouble(&s));
|
||||
assert!(s.model.state().sketch(top).is_none(), "a failed sketch not edited is not shown");
|
||||
assert!(boss_error(&s).is_some());
|
||||
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
|
||||
assert_eq!(reselect(&s), Some(name.clone()), "still fails while edited");
|
||||
assert!(boss_error(&s).is_some(), "the extrude does not build from the held sketch");
|
||||
assert!(link(&s).lost);
|
||||
assert_line(&s, 100.0, (0.0, 50.0), "kept where it was");
|
||||
assert_eq!(curves(&mut s), 2, "the circle and the lost projection");
|
||||
// Another edit keeps it shown, with what the edit added: the sketch and the extrude after it
|
||||
// fail again, so the design ends in the same state as before the edit.
|
||||
let has_point = |s: &Session| {
|
||||
let st = s.model.state();
|
||||
let sk = &st.sketch(top).unwrap().sketch;
|
||||
(0..sk.points.len()).filter_map(|i| sk.point(i)).any(|p| (p.x - 10.0).abs() < 1e-6 && (p.y - 10.0).abs() < 1e-6)
|
||||
};
|
||||
assert!(!has_point(&s));
|
||||
run(&mut s, "sketch.point", json!({"point": [10, 10]}));
|
||||
assert!(link(&s).lost);
|
||||
assert!(has_point(&s), "the held sketch shows the new point");
|
||||
assert_eq!(curves(&mut s), 2);
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert!(s.model.state().sketch(top).is_none(), "finished, it is not shown");
|
||||
|
||||
// Repaired from inside: the lost projection deleted.
|
||||
run(&mut s, "sketch.edit", json!({"sketch": "Top"}));
|
||||
run(&mut s, "sketch.delete", json!({"entities": projected_curves}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(trouble(&s), "");
|
||||
assert_eq!(boss_error(&s), None);
|
||||
assert!(s.doc.sketch(top).unwrap().links.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_projected_edge_whose_body_was_joined_into_another_is_found_there() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -10,19 +10,24 @@ mod projection;
|
||||
#[path = "view_dxf_sketch.rs"]
|
||||
pub(super) mod sketch;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_io::{DxfEntity, DxfLayer, DxfLinetype, DxfShape};
|
||||
use solidcraft_render::Camera;
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, str_};
|
||||
use crate::schema::silhouette::{DxfOutput, DxfProjection, DxfUnits, ExportViewDxfParams, ViewDxfResult, ViewDxfStats};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Session};
|
||||
|
||||
const CMD: &str = "file.export_view_dxf";
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current View as DXF", export)
|
||||
const CONTRACT: TypedContract<ExportViewDxfParams> = TypedContract::new();
|
||||
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current View as DXF", crate::checked!("file.export_view_dxf", export))
|
||||
.icon("export")
|
||||
.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); 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> {
|
||||
@@ -63,8 +68,8 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let drawing = projection::drawing(&meshes, &sketch::parts(s, tolerance).map_err(|e| bad(CMD, e))?, &cam).map_err(|e| bad(CMD, e))?;
|
||||
let writing = progress::Stage::new("dxf_write", 1);
|
||||
writing.check().map_err(|e| bad(CMD, e))?;
|
||||
let mut visible = 0;
|
||||
let mut hidden = 0;
|
||||
let mut visible = 0usize;
|
||||
let mut hidden = 0usize;
|
||||
let mut entities = Vec::new();
|
||||
for line in drawing.lines {
|
||||
if line.hidden && !dashed {
|
||||
@@ -90,18 +95,37 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let text = solidcraft_io::write_dxf_drawing(&layers, &entities)?;
|
||||
writing.check().map_err(|e| bad(CMD, e))?;
|
||||
let mut result = json!({"visible":visible,"hidden":hidden,"sketch":sketch,"units":"mm","tolerance":tolerance,"projection":if cam.fov > 0.0 {"perspective"} else {"orthographic"}});
|
||||
result["stats"] = json!({"bodies":meshes.placements.len(),"shared_meshes":meshes.meshes.len(),"triangles":meshes.triangles,
|
||||
"mesh_bytes":meshes.bytes,"worker_limit":meshes::WORKERS,"segments":drawing.segments,"occlusion_work":drawing.work,
|
||||
"prepare_ms":prepare_ms,"projection_ms":drawing.projection_ms,"occlusion_ms":drawing.occlusion_ms});
|
||||
if let Some(path) = path {
|
||||
let stats = ViewDxfStats {
|
||||
bodies: meshes.placements.len(),
|
||||
shared_meshes: meshes.meshes.len(),
|
||||
triangles: meshes.triangle_count(),
|
||||
mesh_bytes: meshes.bytes,
|
||||
worker_limit: meshes::WORKERS,
|
||||
segments: drawing.segments,
|
||||
occlusion_work: drawing.work,
|
||||
prepare_ms,
|
||||
projection_ms: drawing.projection_ms,
|
||||
occlusion_ms: drawing.occlusion_ms,
|
||||
};
|
||||
let output = if let Some(path) = path {
|
||||
solidcraft_io::vfs::check(path).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
solidcraft_io::write_atomic(std::path::Path::new(path), text.as_bytes(), false).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
result["path"] = json!(path);
|
||||
DxfOutput::File { path: path.to_string() }
|
||||
} else {
|
||||
result["dxf"] = json!(text);
|
||||
}
|
||||
Ok(result)
|
||||
DxfOutput::Text { dxf: text }
|
||||
};
|
||||
let reply = ViewDxfResult {
|
||||
visible,
|
||||
hidden,
|
||||
sketch,
|
||||
units: DxfUnits::Millimetres,
|
||||
tolerance,
|
||||
projection: if cam.fov > 0.0 { DxfProjection::Perspective } else { DxfProjection::Orthographic },
|
||||
stats,
|
||||
output,
|
||||
param_warnings: Vec::new(),
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| bad(CMD, e.to_string()))
|
||||
}
|
||||
|
||||
/// Sketch runs as entities: visible ones on SKETCH, hidden ones dashed on HIDDEN or omitted.
|
||||
|
||||
@@ -35,6 +35,13 @@ pub(super) struct Scene {
|
||||
pub bytes: usize,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// How many triangles the drawing's meshes hold, for the export's reply.
|
||||
pub(super) fn triangle_count(&self) -> usize {
|
||||
self.triangles
|
||||
}
|
||||
}
|
||||
|
||||
struct Group {
|
||||
first: usize,
|
||||
inverse: Mat,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use solidcore_record::{Vec2, Vec3};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_render::StandardView;
|
||||
|
||||
@@ -213,6 +213,44 @@ fn plane_through_axis_and_point_accepts_straight_model_and_sketch_lines() {
|
||||
assert!(parallel(plane(&s, "Side").normal(), Vec3::Y));
|
||||
}
|
||||
|
||||
/// Issue #12851: construct.plane.axis_point with an edge pick whose circle centre lies on the
|
||||
/// axis (e.g. rim of a cylinder or revolved torus). The centre is on the axis, so resolving the
|
||||
/// edge pick to the centre would refuse with "the point is on the axis". Instead, resolve to the
|
||||
/// picked point on the edge, supporting picks at any angle around the rim.
|
||||
#[test]
|
||||
fn plane_through_axis_and_circular_edge_centered_on_axis_succeeds() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 0], "axis": [0, 1, 0], "radius": 15, "height": 30, "body_name": "Cyl"}));
|
||||
// Edge at 0 degrees round the rim (x = 15, y = 30, z = 0).
|
||||
run(
|
||||
&mut s,
|
||||
"construct.plane.axis_point",
|
||||
json!({
|
||||
"axis": {"axis": "Y"},
|
||||
"point": {"edge": [15, 30, 0]},
|
||||
"name": "PlaneAt0Deg"
|
||||
}),
|
||||
);
|
||||
let pl0 = plane(&s, "PlaneAt0Deg");
|
||||
assert!(parallel(pl0.normal(), Vec3::Z), "normal is {:?}", pl0.normal());
|
||||
assert!(on_plane(&pl0, Vec3::new(15.0, 30.0, 0.0)));
|
||||
assert!(on_plane(&pl0, Vec3::new(0.0, 0.0, 0.0)));
|
||||
|
||||
// Edge pick at 90 degrees round the rim (x = 0, y = 30, z = 15).
|
||||
run(
|
||||
&mut s,
|
||||
"construct.plane.axis_point",
|
||||
json!({
|
||||
"axis": {"axis": "Y"},
|
||||
"point": {"edge": [0, 30, 15]},
|
||||
"name": "PlaneAt90Deg"
|
||||
}),
|
||||
);
|
||||
let pl90 = plane(&s, "PlaneAt90Deg");
|
||||
assert!(parallel(pl90.normal(), Vec3::X), "normal is {:?}", pl90.normal());
|
||||
assert!(on_plane(&pl90, Vec3::new(0.0, 30.0, 15.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_through_axis_and_point_in_a_moved_component_keeps_its_world_picks() {
|
||||
let mut s = Session::default();
|
||||
@@ -621,6 +659,27 @@ fn construction_lives_in_the_component_frame() {
|
||||
assert!(near(point(&s, "PinTop"), Vec3::new(50.0, 20.0, 10.0)), "{:?}", point(&s, "PinTop"));
|
||||
}
|
||||
|
||||
/// A construction plane in a moved (and turned) component sections where the viewport draws it,
|
||||
/// not where the component was modelled: the plane halves the box's height, which the turn
|
||||
/// about X laid along Y.
|
||||
#[test]
|
||||
fn a_section_on_a_construction_plane_cuts_where_the_component_stands() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "component.create", json!({"name": "Part", "grounded": false}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 5, "name": "Mid"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [0, 0, 30], "axis": [1, 0, 0], "angle": "90 deg"}));
|
||||
// The box as `inspect.measure` answers it, placed in the world.
|
||||
let bbox = run(&mut s, "inspect.measure", json!({}))["bodies"][0]["bbox"].clone();
|
||||
let mid_y = (bbox["min"][1].as_f64().unwrap() + bbox["max"][1].as_f64().unwrap()) / 2.0;
|
||||
assert!(mid_y.abs() > 1.0, "the turn moved the box's middle off y = 0: {bbox}");
|
||||
let r = run(&mut s, "inspect.section", json!({"plane": "Mid"}));
|
||||
let y = r["section"]["origin"][1].as_f64().unwrap();
|
||||
assert!((y - mid_y).abs() < 1e-9, "the cut halves the placed box: {r}");
|
||||
let n = &r["section"]["normal"];
|
||||
assert!((n[1].as_f64().unwrap().abs() - 1.0).abs() < 1e-9 && n[2].as_f64().unwrap().abs() < 1e-9, "the normal turned with the box: {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plane_perpendicular_to_a_plane_through_a_line() {
|
||||
let mut s = part();
|
||||
|
||||
+37
-23
@@ -318,25 +318,34 @@ pub fn install(dir: PathBuf, build: Build) -> Vec<Summary> {
|
||||
let _ = fs::write(&seen_file, all.iter().map(|s| s.id.as_str()).collect::<Vec<_>>().join("\n"));
|
||||
let known = all.into_iter().filter_map(|s| sig_of(&s.id).map(|sig| (sig, (0, s.id)))).collect();
|
||||
let journal = Journal::open(&dir, &build, started);
|
||||
if STATE
|
||||
.set(State {
|
||||
dir,
|
||||
build,
|
||||
environment: Mutex::new(String::new()),
|
||||
journal: Mutex::new(journal),
|
||||
known: Mutex::new(known),
|
||||
written: AtomicUsize::new(0),
|
||||
started,
|
||||
started_ms,
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if !hook(info) {
|
||||
previous(info);
|
||||
match STATE.set(State {
|
||||
dir,
|
||||
build,
|
||||
environment: Mutex::new(String::new()),
|
||||
journal: Mutex::new(journal),
|
||||
known: Mutex::new(known),
|
||||
written: AtomicUsize::new(0),
|
||||
started,
|
||||
started_ms,
|
||||
}) {
|
||||
Ok(()) => {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
if !hook(info) {
|
||||
previous(info);
|
||||
}
|
||||
}));
|
||||
}
|
||||
Err(state) => {
|
||||
// The journal was already installed in this process: the one just opened is an
|
||||
// orphan nobody holds, and the next start must not call it an unclean exit.
|
||||
if let Ok(mut j) = state.journal.lock()
|
||||
&& let Some(path) = j.path.take()
|
||||
{
|
||||
j.file = None;
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
fresh
|
||||
}
|
||||
@@ -414,11 +423,16 @@ fn remove_stray_native_files(dir: &Path) {
|
||||
/// The app is closing normally: its journal goes, and repeat counts are written.
|
||||
pub fn end_session() {
|
||||
let Some(s) = STATE.get() else { return };
|
||||
if let Ok(mut j) = s.journal.lock()
|
||||
&& let Some(path) = j.path.take()
|
||||
{
|
||||
j.file = None;
|
||||
let _ = fs::remove_file(path);
|
||||
if let Ok(mut j) = s.journal.lock() {
|
||||
// The session ended cleanly: the journal says so before it goes. A journal that stays
|
||||
// (the removal failed, the machine stopped in between) is then not an unclean exit at
|
||||
// the next start: the `reported` flag says there is nothing left to report.
|
||||
j.record(&json!({ "reported": "clean" }));
|
||||
if let Some(path) = j.path.take() {
|
||||
j.file = None;
|
||||
// Best-effort: a journal that cannot go is not an unclean exit (it says so).
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
let counts: Vec<(u64, String)> = s.known.lock().map(|k| k.values().filter(|(n, _)| *n > 0).cloned().collect()).unwrap_or_default();
|
||||
for (n, id) in counts {
|
||||
|
||||
@@ -18,7 +18,8 @@ fn child() {
|
||||
let (Some(dir), Some(mode)) = (solidcraft_geom::env::var_os("CRASH_CHILD_DIR"), solidcraft_geom::env::var("CRASH_CHILD")) else {
|
||||
return;
|
||||
};
|
||||
let fresh = install(PathBuf::from(dir), build());
|
||||
let dir = PathBuf::from(dir);
|
||||
let fresh = install(dir.clone(), build());
|
||||
println!("FRESH {}", serde_json::to_string(&fresh).unwrap());
|
||||
// Dying while starting up, before any command: in a graphics driver, with or without what
|
||||
// a native crash handler caught.
|
||||
@@ -203,6 +204,24 @@ fn child() {
|
||||
s.execute("solid.sphere", &json!({"diameter": 8})).unwrap();
|
||||
let _ = s.execute("diagnostics.crash_test", &json!({"mode": "abort"}));
|
||||
}
|
||||
// A clean exit whose journal cannot be removed: the directory is read-only.
|
||||
"clean_read_only" => {
|
||||
let sessions = dir.join("sessions");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&sessions, fs::Permissions::from_mode(0o500)).unwrap();
|
||||
end_session();
|
||||
fs::set_permissions(&sessions, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
end_session();
|
||||
println!("CLEAN_READ_ONLY_OK");
|
||||
}
|
||||
// A second install in the same process: the journal it opens is an orphan.
|
||||
"install_twice" => {
|
||||
install(dir.clone(), build());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
end_session();
|
||||
@@ -325,6 +344,37 @@ fn a_known_panic_that_ends_the_app_is_counted_not_reported_again_as_an_unclean_e
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A clean exit whose journal cannot be removed (its directory is read-only) is not an unclean
|
||||
/// exit at the next start: the journal says the session ended cleanly.
|
||||
#[test]
|
||||
fn a_clean_exit_whose_journal_cannot_be_removed_is_not_reported_as_an_unclean_exit() {
|
||||
let d = fresh_dir("clean-exit");
|
||||
let (out, ok) = run_child(&d, "clean_read_only");
|
||||
assert!(ok && out.contains("CLEAN_READ_ONLY_OK"), "{out}");
|
||||
// The next start finds the journal the clean exit could not remove: it says nothing about it.
|
||||
let (out, ok) = run_child(&d, "none");
|
||||
assert!(ok);
|
||||
assert!(out.contains("FRESH []"), "{out}");
|
||||
let reports = list(&d);
|
||||
assert!(reports.is_empty(), "{reports:?}");
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A second install in the same process leaves no journal behind: the next start has nothing to
|
||||
/// report about it.
|
||||
#[test]
|
||||
fn a_journal_left_behind_by_a_second_install_is_not_reported_as_an_unclean_exit() {
|
||||
let d = fresh_dir("install-twice");
|
||||
let (out, ok) = run_child(&d, "install_twice");
|
||||
assert!(ok, "{out}");
|
||||
let (out, ok) = run_child(&d, "none");
|
||||
assert!(ok);
|
||||
assert!(out.contains("FRESH []"), "{out}");
|
||||
let reports = list(&d);
|
||||
assert!(reports.is_empty(), "{reports:?}");
|
||||
let _ = fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abort_is_reported_at_the_next_start_with_a_replay() {
|
||||
let d = fresh_dir("abort");
|
||||
|
||||
@@ -1800,6 +1800,11 @@ impl Session {
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
// A sketch open for editing that failed (a projected edge lost by name) is still shown, its
|
||||
// lost link marked, so it can be repaired from inside it (#12085).
|
||||
if outermost {
|
||||
self.model.hold_failed_sketch(&self.doc, self.active_sketch);
|
||||
}
|
||||
if let Some((before, changed, failed)) = crash_outcome {
|
||||
// Refresh and body-name pinning can copy the shared document. Remember the final
|
||||
// document, so the next command does not mistake that work for an outside edit
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Measure on a body's curved faces: the area comes from the exact surface, not from the
|
||||
//! display mesh's triangles, which run inside a curve (#9086).
|
||||
//! Measure on a body's faces: the area comes from the exact surface and edges, not from the
|
||||
//! display mesh's triangles, which run inside a curve (#9086, #12372).
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use std::f64::consts::TAU;
|
||||
@@ -75,9 +75,9 @@ fn a_sphere_is_as_big_as_its_exact_surface() {
|
||||
assert!(close(&item["area_mm2"], 4.0 * std::f64::consts::PI * 100.0, 1e-4), "the sphere's area is its exact 4πr²: {r}");
|
||||
}
|
||||
|
||||
/// A planar face's area is unchanged: it is exact on the mesh already.
|
||||
/// A planar face with straight edges keeps its area.
|
||||
///
|
||||
/// The face picked is the box's side at x = 40 (30 × 10), which the mesh measures exactly.
|
||||
/// The face picked is the box's side at x = 40 (30 × 10).
|
||||
#[test]
|
||||
fn a_flat_face_keeps_its_area() {
|
||||
let mut s = Session::default();
|
||||
@@ -91,6 +91,78 @@ fn a_flat_face_keeps_its_area() {
|
||||
assert!(close(&r["items"][0]["area_mm2"], 300.0, 1e-6), "{r}");
|
||||
}
|
||||
|
||||
/// The flat face at height `z` whose plane is across Z, as a pick.
|
||||
fn flat_face_at(s: &Session, z: f64) -> Value {
|
||||
face_of(
|
||||
s,
|
||||
|f| matches!(f, solidcraft_kernel::FaceSurface::Plane { normal, point } if normal.vec().z.abs() > 1.0 - 1e-9 && (point.z - z).abs() < 1e-6),
|
||||
)
|
||||
}
|
||||
|
||||
/// A flat face with round holes is the plate less its exact holes: the display mesh's
|
||||
/// polygon for each hole runs inside the circle, so the mesh's holes were too small and the
|
||||
/// face read 2293.875 mm² (#12372, the report's own plate).
|
||||
#[test]
|
||||
fn a_flat_face_with_round_holes_is_the_plate_less_its_exact_holes() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 10}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 12, "base": [15, 20, -1], "operation": "cut"}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 12, "base": [40, 20, -1], "operation": "cut"}));
|
||||
for z in [0.0, 10.0] {
|
||||
let face = flat_face_at(&s, z);
|
||||
let r = measure(&mut s, vec![face]);
|
||||
let want = 60.0 * 40.0 - std::f64::consts::PI * (9.0 + 25.0);
|
||||
assert!(close(&r["items"][0]["area_mm2"], want, 1e-6), "the face at z = {z} is 60 × 40 − π(3² + 5²) = {want}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat face whose outer edge is a circle is the exact disc, πr² (#12372).
|
||||
#[test]
|
||||
fn a_cylinders_end_is_its_exact_disc() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 20, "base": [40, 10, 0], "operation": "new"}));
|
||||
let face = flat_face_at(&s, 20.0);
|
||||
let r = measure(&mut s, vec![face]);
|
||||
assert!(close(&r["items"][0]["area_mm2"], std::f64::consts::PI * 25.0, 1e-9), "the end is the disc π·5²: {r}");
|
||||
}
|
||||
|
||||
/// A flat face whose outer loop mixes straight edges and an arc: a quarter of a round bite
|
||||
/// cut from a plate's corner leaves 60 × 40 − π·8²/4 (#12372).
|
||||
#[test]
|
||||
fn a_flat_face_with_an_arc_in_its_outline_is_exact() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 10}));
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 8, "height": 12, "base": [0, 0, -1], "operation": "cut"}));
|
||||
let face = flat_face_at(&s, 10.0);
|
||||
let r = measure(&mut s, vec![face]);
|
||||
let want = 60.0 * 40.0 - std::f64::consts::PI * 64.0 / 4.0;
|
||||
assert!(close(&r["items"][0]["area_mm2"], want, 1e-6), "the plate less a quarter of the bite is {want}: {r}");
|
||||
}
|
||||
|
||||
/// A flat face with an elliptic hole is the plate less the ellipse, πab: an edge that is neither
|
||||
/// a line nor a circle is summed along its curve (#12372). The kernel keeps the ellipse as a
|
||||
/// cubic spline fitted to it, which encloses 2·10⁻³ mm² more than πab here; the display mesh's
|
||||
/// polygon read 2024.696 mm², 1.7 mm² off. Both faces read the same, though their loops run
|
||||
/// the hole's edges opposite ways round.
|
||||
#[test]
|
||||
fn a_flat_face_with_an_elliptic_hole_is_the_plate_less_the_exact_ellipse() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 60, "width": 40, "height": 10}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"}));
|
||||
run(&mut s, "sketch.ellipse", json!({"center": [30, 20], "major": [45, 20], "minor_radius": 8}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"}));
|
||||
let want = 60.0 * 40.0 - std::f64::consts::PI * 15.0 * 8.0;
|
||||
let mut areas = Vec::new();
|
||||
for z in [0.0, 10.0] {
|
||||
let face = flat_face_at(&s, z);
|
||||
let r = measure(&mut s, vec![face]);
|
||||
assert!(close(&r["items"][0]["area_mm2"], want, 1e-2), "the face at z = {z} is 60 × 40 − π·15·8 = {want}: {r}");
|
||||
areas.push(r["items"][0]["area_mm2"].as_f64().unwrap());
|
||||
}
|
||||
assert!((areas[0] - areas[1]).abs() < 1e-9, "both faces read the same: {areas:?}");
|
||||
}
|
||||
|
||||
/// The body's own volume is its exact πr²h, not the display mesh's (#9086).
|
||||
///
|
||||
/// The volume of a body is still measured on a tessellation of it: the exact volume of a
|
||||
|
||||
@@ -112,6 +112,52 @@ fn exact_matches_stay_quiet_and_nothing_plausible_is_an_error() {
|
||||
assert!(s.model.state().bodies.is_empty());
|
||||
}
|
||||
|
||||
/// The public example of #9368: a 40 x 20 rectangle with a circle tangent to its top and bottom
|
||||
/// (radius 10 at (20, 10)), or a ring offset 1 mm around a circle of radius 9 with `offset`.
|
||||
/// `Gauche` is the region left of the circle, `Disque` (joined) the disc or the ring.
|
||||
fn rectangle_and_circle(offset: bool) -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S1"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]}));
|
||||
run(&mut s, "sketch.circle.center", json!({"center": [20, 10], "radius": if offset { 9 } else { 10 }}));
|
||||
if offset {
|
||||
run(&mut s, "sketch.offset", json!({"curves": ["c1"], "distance": 1, "side": [20, 25]}));
|
||||
}
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "profiles": [{"point": [3, 10]}], "name": "Gauche"}));
|
||||
let disque = if offset { [20.0, 19.5] } else { [20.0, 15.0] };
|
||||
run(&mut s, "solid.extrude", json!({"distance": "10 mm", "profiles": [{"point": disque}], "operation": "join", "name": "Disque"}));
|
||||
s
|
||||
}
|
||||
|
||||
/// A line from the top side to the circle cuts a sliver off the left region and gives the circle
|
||||
/// more arcs: the disc is still the same region, so it is found without a word, and the left region
|
||||
/// is still found, as its two pieces.
|
||||
#[test]
|
||||
fn an_extrude_survives_a_line_ending_on_a_circle_split_by_tangents() {
|
||||
let mut s = rectangle_and_circle(false);
|
||||
edit(&mut s, &[("sketch.line", json!({"points": [[14, 20], [14, 18]]}))]);
|
||||
assert_eq!(result(&s, "Disque"), (None, None));
|
||||
let (error, warning) = result(&s, "Gauche");
|
||||
assert_eq!(error, None);
|
||||
assert!(warning.unwrap_or_default().contains("2 pieces"));
|
||||
assert!((volume(&s) - 5570.6).abs() < 0.5, "volume {}", volume(&s));
|
||||
}
|
||||
|
||||
/// Same sketch with a ring offset from a circle: the loops name the offset's output. The line
|
||||
/// cuts the left region (its closed loop is taken as two pieces, with a warning), and the ring,
|
||||
/// only given more arcs, is found as it was.
|
||||
#[test]
|
||||
fn an_extrude_with_an_offset_curve_survives_a_line_splitting_its_profile() {
|
||||
let mut s = rectangle_and_circle(true);
|
||||
edit(&mut s, &[("sketch.line", json!({"points": [[14, 20], [14, 18]]}))]);
|
||||
let (error, warning) = result(&s, "Gauche");
|
||||
assert_eq!(error, None);
|
||||
assert!(warning.unwrap_or_default().contains("2 pieces"));
|
||||
assert_eq!(result(&s, "Disque"), (None, None));
|
||||
assert!((volume(&s) - 3026.2).abs() < 0.5, "volume {}", volume(&s));
|
||||
}
|
||||
|
||||
/// A block `E1` with a sketch `S2` on its top cap and a hole cut from it, `E2`.
|
||||
fn block_with_hole() -> Session {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
//! Contract and result types for `assembly.bom` in `cmd/bom.rs` (#11075).
|
||||
//! Contracts and result types for the commands in `cmd/bom.rs`: `assembly.bom` (#11075), and
|
||||
//! `component.metadata` and `component.metadata.set` (#12609).
|
||||
//!
|
||||
//! The part property handlers read their parameters from the raw JSON, so each key here reads as
|
||||
//! its handler reads it and fails with the handler's message. A `component` the handlers refuse
|
||||
//! when it is left out is optional in the type and required in the schema, so that the handler's
|
||||
//! message, not the check's "is required", is what a caller sees. Whether a component exists
|
||||
//! (and that the root is not one `component.metadata.set` changes) stays the handler's to check.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -6,9 +13,204 @@ use std::collections::BTreeMap;
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value};
|
||||
use solidcraft_doc::metadata::MAX_KEYS;
|
||||
|
||||
use super::check::whole;
|
||||
use super::motion::require;
|
||||
|
||||
/// A component's id or name, as `component_id` in the handlers reads it: anything else, `null`
|
||||
/// included, is refused with the handler's message.
|
||||
fn component_key<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Number(n) => Ok(Some(n.to_string())),
|
||||
Value::String(s) => Ok(Some(s)),
|
||||
_ => Err(D::Error::custom(whole("`component` must be a component id or name"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn component_required(schema: &mut Schema) {
|
||||
require(schema, "component");
|
||||
}
|
||||
|
||||
fn component_id_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "The component, by id or name (0 or \"root\": the design itself).", "type": ["number", "string"]})
|
||||
}
|
||||
|
||||
fn text_or_null_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["string", "null"]})
|
||||
}
|
||||
|
||||
/// Read the part properties of a component, or of the design itself (`0` or `root`): part
|
||||
/// number, description, revision, bill of materials type, the `metadata` object as saved and the
|
||||
/// free properties.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = component_required)]
|
||||
pub struct ComponentMetadataParams {
|
||||
#[serde(default, deserialize_with = "component_key", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "component_id_schema")]
|
||||
pub component: Option<String>,
|
||||
}
|
||||
|
||||
/// What `component.metadata` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ComponentMetadataResult {
|
||||
/// The component, by id (0: the design).
|
||||
pub component: u64,
|
||||
/// Its name (the design's name for 0).
|
||||
pub name: String,
|
||||
/// The part number, or null when it has none.
|
||||
pub part_number: Option<String>,
|
||||
/// The description, or null when it has none.
|
||||
pub description: Option<String>,
|
||||
/// The revision, or null when it has none.
|
||||
pub revision: Option<String>,
|
||||
/// `normal`, `phantom`, `reference`, `purchased` or `inseparable`. Not given for the design.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bom_type: Option<String>,
|
||||
/// The metadata object as saved, other tools' keys included.
|
||||
pub metadata: Map<String, Value>,
|
||||
/// The free properties, `{name: text}`. Empty for the design.
|
||||
pub properties: BTreeMap<String, String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// An object, as `component.metadata.set` reads `values` and `properties`: an object of at
|
||||
/// most 256 entries, with the handler's messages for anything else (`null` included). Each
|
||||
/// entry's value is left to the handler, which has its own message for each.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entries(pub Map<String, Value>);
|
||||
|
||||
fn entries<'de, D: Deserializer<'de>>(d: D, key: &str, what: &str, shape: &str) -> Result<Option<Entries>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Object(v) if v.len() <= MAX_KEYS => Ok(Some(Entries(v))),
|
||||
Value::Object(_) => Err(D::Error::custom(whole(format!("`{key}` can hold at most {MAX_KEYS} {what}")))),
|
||||
_ => Err(D::Error::custom(whole(format!("`{key}` must be an object: {shape}")))),
|
||||
}
|
||||
}
|
||||
|
||||
fn values<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Entries>, D::Error> {
|
||||
entries(d, "values", "keys", "{key: value, …}")
|
||||
}
|
||||
|
||||
fn free_properties<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Entries>, D::Error> {
|
||||
entries(d, "properties", "names", "{name: text, …}")
|
||||
}
|
||||
|
||||
fn values_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Several properties at once, {key: value, …}, at most 256 keys: SolidCraft's keys (the ones `document.metadata`'s `properties` lists) and `bom_type`, which take text, or a reverse-DNS key such as `com.example.tool`, which takes an object. Each key reads its value as the parameter of the same name does (`part_number`, `bom_type`, …): for a text key, empty text or null removes it; for `bom_type` it means `normal`.",
|
||||
"type": "object",
|
||||
"maxProperties": MAX_KEYS
|
||||
})
|
||||
}
|
||||
|
||||
fn properties_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Free properties, {name: text, …}, at most 256 names (a name is 1 to 128 bytes of text without control characters; a number or boolean is kept as its text). Empty text or null removes a property.",
|
||||
"type": "object",
|
||||
"maxProperties": MAX_KEYS,
|
||||
"additionalProperties": {"type": ["string", "number", "boolean", "null"]}
|
||||
})
|
||||
}
|
||||
|
||||
// Deserialize is written out below rather than derived: the handler reads the other keys only for
|
||||
// a component that is not the design, so for `0` or `root` they are neither read nor checked.
|
||||
/// Change a component's part properties in one undo step (all or nothing): `part_number`,
|
||||
/// `description`, `revision` and `bom_type`, `values` for several keys at once (also another
|
||||
/// tool's reverse-DNS keys), and `properties` for free properties. Not the design's own
|
||||
/// (`document.metadata.set`). Give at least one of them. A change that sets what is already
|
||||
/// there makes no undo step.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = component_required)]
|
||||
pub struct ComponentMetadataSetParams {
|
||||
#[serde(default, deserialize_with = "component_key", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "component_id_schema")]
|
||||
pub component: Option<String>,
|
||||
/// The part number: text of at most 4096 bytes. Empty text or null removes it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "text_or_null_schema")]
|
||||
pub part_number: Option<Value>,
|
||||
/// The description: text of at most 4096 bytes. Empty text or null removes it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "text_or_null_schema")]
|
||||
pub description: Option<Value>,
|
||||
/// The revision: text of at most 4096 bytes. Empty text or null removes it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "text_or_null_schema")]
|
||||
pub revision: Option<Value>,
|
||||
/// How the component counts in the bill of materials: `normal`, `phantom`, `reference`,
|
||||
/// `purchased` or `inseparable`. Empty text or null: `normal`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "text_or_null_schema")]
|
||||
pub bom_type: Option<Value>,
|
||||
#[serde(default, deserialize_with = "values", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "values_schema")]
|
||||
pub values: Option<Entries>,
|
||||
#[serde(default, deserialize_with = "free_properties", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "properties_schema")]
|
||||
pub properties: Option<Entries>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ComponentMetadataSetParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let p = Map::<String, Value>::deserialize(d)?;
|
||||
set_form(&p).map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// A key's value through its reader, or `None` when the key is left out.
|
||||
fn key_with<'a, T>(
|
||||
p: &'a Map<String, Value>,
|
||||
key: &str,
|
||||
read: impl FnOnce(&'a Value) -> Result<Option<T>, serde_json::Error>,
|
||||
) -> Result<Option<T>, serde_json::Error> {
|
||||
p.get(key).map_or(Ok(None), read)
|
||||
}
|
||||
|
||||
/// The keys `component.metadata.set`'s handler reads, in its order: the component, and for any
|
||||
/// but the design (`0`, `root` in any case), the text keys, `values` and then `properties`.
|
||||
fn set_form(p: &Map<String, Value>) -> Result<ComponentMetadataSetParams, serde_json::Error> {
|
||||
let component = key_with(p, "component", component_key)?;
|
||||
let mut form = ComponentMetadataSetParams {
|
||||
component,
|
||||
part_number: None,
|
||||
description: None,
|
||||
revision: None,
|
||||
bom_type: None,
|
||||
values: None,
|
||||
properties: None,
|
||||
};
|
||||
if form.component.as_deref().is_some_and(|c| c == "0" || c.eq_ignore_ascii_case("root")) {
|
||||
return Ok(form);
|
||||
}
|
||||
form.values = key_with(p, "values", values)?;
|
||||
form.part_number = p.get("part_number").cloned();
|
||||
form.description = p.get("description").cloned();
|
||||
form.revision = p.get("revision").cloned();
|
||||
form.bom_type = p.get("bom_type").cloned();
|
||||
form.properties = key_with(p, "properties", free_properties)?;
|
||||
Ok(form)
|
||||
}
|
||||
|
||||
/// What `component.metadata.set` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ComponentMetadataSetResult {
|
||||
/// The component, by id.
|
||||
pub component: u64,
|
||||
/// Its metadata object after the change.
|
||||
pub metadata: Map<String, Value>,
|
||||
/// Its free properties after the change.
|
||||
pub properties: BTreeMap<String, String>,
|
||||
/// What changed: the keys whose value changed, and `properties.<name>` for each free
|
||||
/// property. Empty when nothing did (and then nothing was added to undo).
|
||||
pub changed: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The bill of materials below a component: its parts, counted through subassemblies, by part
|
||||
/// number. Each component's bom type counts: a phantom has no row (what it holds counts where it
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{Bom, BomParams};
|
||||
use super::{Bom, BomParams, ComponentMetadataParams, ComponentMetadataResult, ComponentMetadataSetParams, ComponentMetadataSetResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
@@ -31,6 +31,11 @@ fn assembly(s: &mut Session) {
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<BomParams>(), &["component", "format", "group_by", "group_children", "levels", "path"]);
|
||||
assert_keys(input_schema_for::<ComponentMetadataParams>(), &["component"]);
|
||||
assert_keys(
|
||||
input_schema_for::<ComponentMetadataSetParams>(),
|
||||
&["bom_type", "component", "description", "part_number", "properties", "revision", "values"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -50,7 +55,50 @@ fn strict_keeps_todays_errors() {
|
||||
("assembly.bom", json!({"group_children": "yes"})),
|
||||
("assembly.bom", json!({"path": file.replace(".csv", ".txt")})),
|
||||
("assembly.bom", json!({"path": file, "format": "xml"})),
|
||||
("component.metadata", json!({})),
|
||||
("component.metadata", json!({"component": null})),
|
||||
("component.metadata", json!({"component": true})),
|
||||
("component.metadata", json!({"component": [1]})),
|
||||
("component.metadata", json!({"component": "Nope"})),
|
||||
("component.metadata.set", json!({})),
|
||||
("component.metadata.set", json!({"component": null, "part_number": "A"})),
|
||||
("component.metadata.set", json!({"component": {"id": 1}, "part_number": "A"})),
|
||||
("component.metadata.set", json!({"component": "Nope", "part_number": "A"})),
|
||||
("component.metadata.set", json!({"component": 0, "part_number": "A"})),
|
||||
("component.metadata.set", json!({"component": "root", "part_number": "A"})),
|
||||
("component.metadata.set", json!({"component": 0, "values": 5})),
|
||||
]);
|
||||
// The cases that need a component to be there: a part with properties to set badly.
|
||||
let mut s = Session { param_check: ParamCheck::Strict, ..Session::default() };
|
||||
let mut off = Session { param_check: ParamCheck::Off, ..Session::default() };
|
||||
for session in [&mut s, &mut off] {
|
||||
run(session, "component.create", json!({"name": "Plate"}));
|
||||
}
|
||||
for p in [
|
||||
json!({"component": "Plate"}),
|
||||
json!({"component": "Plate", "values": null}),
|
||||
json!({"component": "Plate", "values": [1]}),
|
||||
json!({"component": "Plate", "values": "x"}),
|
||||
json!({"component": "Plate", "properties": null}),
|
||||
json!({"component": "Plate", "properties": ["a"]}),
|
||||
json!({"component": "Plate", "values": (0..257).map(|i| (format!("org.k{i}"), json!({}))).collect::<serde_json::Map<_, _>>()}),
|
||||
json!({"component": "Plate", "properties": (0..257).map(|i| (format!("p{i}"), json!("x"))).collect::<serde_json::Map<_, _>>()}),
|
||||
json!({"component": "Plate", "part_number": 5}),
|
||||
json!({"component": "Plate", "part_number": ["A"]}),
|
||||
json!({"component": "Plate", "bom_type": "kit"}),
|
||||
json!({"component": "Plate", "bom_type": 1}),
|
||||
json!({"component": "Plate", "values": {"nope": "x"}}),
|
||||
json!({"component": "Plate", "values": {"com.example.tool": "x"}}),
|
||||
json!({"component": "Plate", "properties": {"": "x"}}),
|
||||
json!({"component": "Plate", "properties": {"a": ["x"]}}),
|
||||
json!({"component": "Plate", "properties": {"a": {"b": 1}}}),
|
||||
json!({"component": "Plate", "description": "bad\u{7}"}),
|
||||
json!({"component": "Plate", "properties": {"a": "bad\u{7}"}}),
|
||||
] {
|
||||
let (a, b) = (off.execute("component.metadata.set", &p), s.execute("component.metadata.set", &p));
|
||||
assert!(a.is_err(), "{p} should fail today");
|
||||
assert_eq!(a.map_err(|e| e.to_string()), b.map_err(|e| e.to_string()), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #11075: a bill of materials replies as its output schema says, in Warn without warnings for
|
||||
@@ -84,3 +132,140 @@ fn a_bill_of_materials_replies_with_its_schema() {
|
||||
}
|
||||
matches(&run(&mut s, "assembly.bom", json!({"level": "top"})), true);
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value, warned: bool) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
assert_eq!(reply.get("param_warnings").is_some(), warned, "{id}: {reply}");
|
||||
}
|
||||
|
||||
/// #12609: the types read each key as the handlers do, with their messages, and the check
|
||||
/// accepts every form a handler takes today.
|
||||
#[test]
|
||||
fn the_part_property_types_read_keys_as_the_handlers_do() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
let none = Vec::<String>::new();
|
||||
for id in ["component.metadata", "component.metadata.set"] {
|
||||
let p = |component: Value| {
|
||||
if id == "component.metadata" { json!({"component": component}) } else { json!({"component": component, "revision": "A"}) }
|
||||
};
|
||||
assert_eq!(issues(id, p(json!(3))), none, "{id}");
|
||||
assert_eq!(issues(id, p(json!("Plate"))), none, "{id}");
|
||||
assert_eq!(issues(id, p(json!(0))), none, "{id}");
|
||||
assert_eq!(issues(id, p(json!("root"))), none, "{id}");
|
||||
for bad in [json!(null), json!(true), json!([1]), json!({"a": 1})] {
|
||||
assert_eq!(issues(id, p(bad.clone())), ["`component` must be a component id or name"], "{id} {bad}");
|
||||
}
|
||||
assert_eq!(issues(id, json!({"component": 1, "compnent": 1})).len(), 1, "{id}");
|
||||
}
|
||||
let set = |p: Value| issues("component.metadata.set", p);
|
||||
// The keys that take text hand any value to the handler, which names what it refuses.
|
||||
for k in ["part_number", "description", "revision", "bom_type"] {
|
||||
for v in [json!("x"), json!(""), json!(null), json!(5), json!(["x"])] {
|
||||
assert_eq!(set(json!({"component": 1, k: v})), none, "{k}");
|
||||
}
|
||||
}
|
||||
assert_eq!(set(json!({"component": 1, "values": {"com.example.tool": {"a": 1}, "author": 5}})), none);
|
||||
assert_eq!(set(json!({"component": 1, "properties": {"a": "x", "b": 2, "c": true, "d": null, "e": ["x"]}})), none);
|
||||
// For the design the handler refuses before it reads anything else, so nothing else is read.
|
||||
for root in [json!(0), json!("0"), json!("root"), json!("ROOT")] {
|
||||
assert_eq!(set(json!({"component": root, "values": 5, "properties": null})), none, "{root}");
|
||||
}
|
||||
assert_eq!(set(json!({"component": 1, "values": null})), ["`values` must be an object: {key: value, …}"]);
|
||||
assert_eq!(set(json!({"component": 1, "values": [1]})), ["`values` must be an object: {key: value, …}"]);
|
||||
assert_eq!(set(json!({"component": 1, "properties": "a"})), ["`properties` must be an object: {name: text, …}"]);
|
||||
let many = |prefix: &str| (0..257).map(|i| (format!("{prefix}{i}"), json!("x"))).collect::<serde_json::Map<_, _>>();
|
||||
assert_eq!(set(json!({"component": 1, "values": many("org.k")})), ["`values` can hold at most 256 keys"]);
|
||||
assert_eq!(set(json!({"component": 1, "properties": many("p")})), ["`properties` can hold at most 256 names"]);
|
||||
let mut full = many("p");
|
||||
full.remove("p256");
|
||||
assert_eq!(set(json!({"component": 1, "properties": full, "values": {}})), none);
|
||||
}
|
||||
|
||||
/// The schemas say what the handlers need: `component` is required though the types leave it to
|
||||
/// the handlers' message, and the limits and shapes are those of the handlers.
|
||||
#[test]
|
||||
fn the_part_property_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<ComponentMetadataParams>(),
|
||||
&[(json!({"component": "Plate"}), true), (json!({"component": 0}), true), (json!({}), false), (json!({"component": true}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ComponentMetadataSetParams>(),
|
||||
&[
|
||||
(json!({"component": "Plate", "part_number": "P-1", "description": null, "revision": "", "bom_type": "phantom"}), true),
|
||||
(
|
||||
json!({"component": 2, "values": {"com.example.tool": {"a": 1}}, "properties": {"finish": "zinc", "n": 3, "b": true, "gone": null}}),
|
||||
true,
|
||||
),
|
||||
(json!({"part_number": "P-1"}), false),
|
||||
(json!({"component": "Plate", "part_number": 5}), false),
|
||||
(json!({"component": "Plate", "values": []}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Read and change part properties of a component and of the design, in Warn: each real reply
|
||||
/// matches its output schema and carries no warning; a misspelt key's warning still matches.
|
||||
#[test]
|
||||
fn part_property_replies_match_the_output_schema() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
assembly(&mut s);
|
||||
let read = |s: &mut Session, p: Value| {
|
||||
let r = run(s, "component.metadata", p);
|
||||
matches(output_schema_for::<ComponentMetadataResult>(), "component.metadata", &r, false);
|
||||
r
|
||||
};
|
||||
let change = |s: &mut Session, p: Value| {
|
||||
let r = run(s, "component.metadata.set", p);
|
||||
matches(output_schema_for::<ComponentMetadataSetResult>(), "component.metadata.set", &r, false);
|
||||
r
|
||||
};
|
||||
let plate = read(&mut s, json!({"component": "Plate"}));
|
||||
assert_eq!(
|
||||
(plate["part_number"].as_str(), plate["revision"].as_str(), plate["bom_type"].as_str()),
|
||||
(Some("PL-100"), Some("B"), Some("normal")),
|
||||
"{plate}"
|
||||
);
|
||||
let pin = read(&mut s, json!({"component": "Pin"}));
|
||||
assert_eq!((pin["properties"]["finish"].as_str(), pin["revision"].is_null()), (Some("zinc"), true), "{pin}");
|
||||
let root = read(&mut s, json!({"component": 0}));
|
||||
assert!(root.get("bom_type").is_none() && root["properties"] == json!({}), "{root}");
|
||||
read(&mut s, json!({"component": "root"}));
|
||||
let by_id = read(&mut s, json!({"component": plate["component"]}));
|
||||
assert_eq!(by_id["name"], "Plate");
|
||||
|
||||
let r = change(
|
||||
&mut s,
|
||||
json!({"component": "Plate", "revision": "C", "bom_type": "purchased", "properties": {"material": "1.4301", "n": 3, "ok": true}}),
|
||||
);
|
||||
assert_eq!(r["changed"], json!(["revision", "bom_type", "properties.material", "properties.n", "properties.ok"]), "{r}");
|
||||
let r = change(
|
||||
&mut s,
|
||||
json!({"component": "Plate", "values": {"com.example.tool": {"a": 1}, "description": "Plate"}, "part_number": null, "properties": {"n": ""}}),
|
||||
);
|
||||
assert_eq!(r["changed"], json!(["com.example.tool", "description", "part_number", "properties.n"]), "{r}");
|
||||
assert_eq!(r["properties"], json!({"material": "1.4301", "ok": "true"}), "{r}");
|
||||
let r = change(&mut s, json!({"component": "Plate", "revision": "C"}));
|
||||
assert_eq!(r["changed"], json!([]), "{r}");
|
||||
assert_eq!(read(&mut s, json!({"component": "Plate"}))["metadata"]["com.example.tool"], json!({"a": 1}));
|
||||
|
||||
for (id, p) in [
|
||||
("component.metadata", json!({"component": "Plate", "compnent": 1})),
|
||||
("component.metadata.set", json!({"component": "Plate", "revision": "D", "revison": "E"})),
|
||||
] {
|
||||
let warned = run(&mut s, id, p);
|
||||
assert!(warned["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{warned}");
|
||||
if id == "component.metadata" {
|
||||
matches(output_schema_for::<ComponentMetadataResult>(), id, &warned, true);
|
||||
} else {
|
||||
matches(output_schema_for::<ComponentMetadataSetResult>(), id, &warned, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,3 +134,42 @@ fn sketches_move_to_the_part_whose_face_they_are_on() {
|
||||
let again = run(&mut s, "component.move_sketches.to_parts", json!({"sketches":["OnFace"]}));
|
||||
assert_eq!((again["moved"].as_u64(), again["kept"][0]["reason"].as_str()), (Some(0), Some("already in its part")));
|
||||
}
|
||||
|
||||
/// A sketch moved to its part while an exploded view moves the part saves the same picked point
|
||||
/// and guide plane as without the view: the view is display only.
|
||||
#[test]
|
||||
fn sketches_moved_to_a_part_while_an_exploded_view_is_shown_save_what_they_save_without_it() {
|
||||
let run = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"));
|
||||
let moved = |exploded: bool| {
|
||||
let mut s = Session::default();
|
||||
let part = run(&mut s, "component.create", json!({"name":"Part","parent":0,"grounded":false}));
|
||||
let occurrence = part["occurrence"].as_u64().unwrap();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"}));
|
||||
run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0]}));
|
||||
run(&mut s, "component.activate", json!({"component":0}));
|
||||
let world = s.world_state().unwrap();
|
||||
let base = world.body("Base").unwrap();
|
||||
let top = base.body.faces(0.01).unwrap().into_iter().max_by(|a, b| a.centroid.z.total_cmp(&b.centroid.z)).unwrap();
|
||||
let pick = json!({"body": base.name, "index": top.index, "face": [top.centroid.x, top.centroid.y, top.centroid.z], "path": [occurrence]});
|
||||
let on_face = run(&mut s, "sketch.create", json!({"name":"OnFace","plane":pick,"project_edges":false}))["sketch"].as_u64().unwrap();
|
||||
sketch_square(&mut s);
|
||||
// An exploded view needs two placements: B stays put, in both runs.
|
||||
run(&mut s, "component.create", json!({"name":"B","parent":0,"activate":false}));
|
||||
if exploded {
|
||||
let view =
|
||||
run(&mut s, "explode.create", json!({"moves": [{"occurrence": occurrence, "translate": [0, 25, 0]}]}))["view"].as_u64().unwrap();
|
||||
run(&mut s, "explode.show", json!({"view": view}));
|
||||
}
|
||||
run(&mut s, "component.move_sketches.to_parts", json!({}));
|
||||
run(&mut s, "explode.show", json!({}));
|
||||
let solidcraft_doc::FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { plane, at, .. }, .. } =
|
||||
&s.doc.feature(on_face).unwrap().kind
|
||||
else {
|
||||
panic!("not a face sketch")
|
||||
};
|
||||
(*plane, *at)
|
||||
};
|
||||
let ((plane, at), (assembled_plane, assembled_at)) = (moved(true), moved(false));
|
||||
assert!(at.dist(assembled_at) < 1e-9, "{at:?} vs {assembled_at:?}");
|
||||
assert!(plane.origin.dist(assembled_plane.origin) < 1e-9, "{plane:?} vs {assembled_plane:?}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Contracts and result types for the command in `cmd/delete.rs`: `selection.delete`.
|
||||
//!
|
||||
//! The handler reads its parameters from the raw JSON, so each key here reads as the handler
|
||||
//! reads it and fails with the handler's message: `items` and `joints` with their own words, the
|
||||
//! four lists of ids or names (`components`, `occurrences`, `canvases`, `point_clouds`) are read
|
||||
//! as the handler reads them: what it ignores is ignored, never refused. Whether an item, a joint, an occurrence or a component exists stays the handler's to
|
||||
//! check. A call with several problems may get a different one first in strict than with the check
|
||||
//! off (the check goes key by key, the handler in its own order).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::check::whole;
|
||||
use crate::Sel;
|
||||
|
||||
/// The most items or ids a list takes (the handler's limit).
|
||||
const MAX_LIST: usize = 10_000;
|
||||
|
||||
/// Delete what is selected, in one undo step: sketch entities (while sketching), timeline features
|
||||
/// and sketches (with the features that depend on them), construction planes and axes, bodies (a
|
||||
/// Remove feature from here on in the timeline), occurrences and components, canvases, point
|
||||
/// clouds and joints. `dry_run` reports what would go and what would fail without changing
|
||||
/// anything. Give something to delete in `items` or one of the lists; otherwise the call is
|
||||
/// refused.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SelectionDeleteParams {
|
||||
/// What to delete, as selection items: `{"type": "feature", "id": 3}`, `{"type": "body",
|
||||
/// "name": "Body1"}`, `{"type": "sketch_curve", "id": "c1"}` and so on. At most 10000. Items
|
||||
/// that can't be deleted (an origin plane, an edge or vertex, a profile outside the sketch
|
||||
/// being edited) are named in `skipped`.
|
||||
#[serde(default)]
|
||||
pub items: Option<SelectionItems>,
|
||||
/// Components to delete, by id or name (not the design itself). Only the first 10000 are
|
||||
/// read.
|
||||
#[serde(default)]
|
||||
pub components: Option<IdList>,
|
||||
/// Occurrences to delete, by id or name. One goes alone while its component has others; the
|
||||
/// last one takes the component. Only the first 10000 are read.
|
||||
#[serde(default)]
|
||||
pub occurrences: Option<IdList>,
|
||||
/// Canvases and decals to delete, by id or name. Only the first 10000 are read.
|
||||
#[serde(default)]
|
||||
pub canvases: Option<IdList>,
|
||||
/// Point clouds to delete, by id or name. Only the first 10000 are read.
|
||||
#[serde(default)]
|
||||
pub point_clouds: Option<IdList>,
|
||||
/// Joints to delete, by id or name (at most 10000).
|
||||
#[serde(default)]
|
||||
pub joints: Option<JointList>,
|
||||
/// Only report what would be deleted and what would fail, and change nothing. Default:
|
||||
/// false.
|
||||
#[serde(default)]
|
||||
pub dry_run: Option<bool>,
|
||||
}
|
||||
|
||||
/// `items`, as the handler reads it: a list of at most 10000 selection items, each as [`Sel`]
|
||||
/// reads it. Kept as parsed, for the handler to read again.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SelectionItems(pub Vec<Sel>);
|
||||
|
||||
impl<'de> Deserialize<'de> for SelectionItems {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Array(a) if a.len() <= MAX_LIST => a
|
||||
.into_iter()
|
||||
.map(|v| serde_json::from_value(v.clone()).map_err(|e| D::Error::custom(whole(format!("bad item {v}: {e}")))))
|
||||
.collect::<Result<_, _>>()
|
||||
.map(SelectionItems),
|
||||
_ => Err(D::Error::custom(whole("`items` must be a list of selection items"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SelectionItems {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SelectionItems".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let point = json!({"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3});
|
||||
let text = json!({"type": "string"});
|
||||
let count = json!({"type": "integer", "minimum": 0});
|
||||
// One object per kind of item: `type` and the keys it needs.
|
||||
let item = |kind: &str, keys: &[(&str, &Value)], optional: &[(&str, &Value)]| {
|
||||
let mut properties = serde_json::Map::new();
|
||||
properties.insert("type".into(), json!({"type": "string", "enum": [kind]}));
|
||||
let mut required = vec![json!("type")];
|
||||
for (key, schema) in keys {
|
||||
properties.insert((*key).into(), (*schema).clone());
|
||||
required.push(json!(key));
|
||||
}
|
||||
for (key, schema) in optional {
|
||||
properties.insert((*key).into(), (*schema).clone());
|
||||
}
|
||||
json!({"type": "object", "properties": properties, "required": required})
|
||||
};
|
||||
let path = json!({"type": "array", "items": {"type": "integer", "minimum": 0}});
|
||||
json_schema!({
|
||||
"description": "Selection items: objects with a `type` and the keys that type needs.",
|
||||
"type": "array",
|
||||
"maxItems": MAX_LIST,
|
||||
"items": {"anyOf": [
|
||||
item("occurrence", &[("path", &path)], &[]),
|
||||
item("body", &[("name", &text)], &[]),
|
||||
item("edge", &[("body", &text), ("index", &count), ("point", &point)], &[]),
|
||||
item("face", &[("body", &text), ("index", &count), ("point", &point)], &[]),
|
||||
item("feature", &[("id", &count)], &[]),
|
||||
item("sketch_curve", &[("id", &text)], &[("sketch", &count)]),
|
||||
item("sketch_point", &[("id", &text)], &[]),
|
||||
item("sketch_midpoint", &[("id", &text)], &[]),
|
||||
item("external_sketch_curve", &[("sketch", &count), ("id", &text)], &[]),
|
||||
item("external_sketch_point", &[("sketch", &count), ("id", &text)], &[]),
|
||||
item("sketch_constraint", &[("id", &text)], &[]),
|
||||
item("profile", &[("sketch", &count), ("index", &count)], &[]),
|
||||
item("plane", &[("name", &text)], &[]),
|
||||
item("construction_point", &[("name", &text)], &[]),
|
||||
item("axis", &[("name", &text)], &[]),
|
||||
item("vertex", &[("body", &text), ("point", &point)], &[])
|
||||
]}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An id (a number from 0 up) or a name, as the handler's list reader takes one.
|
||||
fn id_or_name<E: serde::de::Error>(v: Value, message: &str) -> Result<String, E> {
|
||||
match v {
|
||||
Value::Number(n) if n.is_u64() => Ok(n.to_string()),
|
||||
Value::String(s) => Ok(s),
|
||||
_ => Err(E::custom(message.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn id_or_name_schema() -> Value {
|
||||
json!({"anyOf": [{"type": "string"}, {"type": "integer", "minimum": 0}]})
|
||||
}
|
||||
|
||||
/// A list of ids or names, read as the handler's `keys()` reads `components`, `occurrences`,
|
||||
/// `canvases` and `point_clouds`: anything but a list is left out, so are items that are neither a
|
||||
/// name nor an id, and only the first 10000 count. Nothing is refused, as the handler refuses
|
||||
/// nothing here.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct IdList(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for IdList {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let Value::Array(a) = Value::deserialize(d)? else {
|
||||
return Ok(IdList(Vec::new()));
|
||||
};
|
||||
Ok(IdList(a.into_iter().take(MAX_LIST).filter_map(|v| id_or_name::<D::Error>(v, "").ok()).collect()))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for IdList {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"IdList".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "Ids (numbers) or names. Anything else is ignored.", "type": "array", "items": id_or_name_schema()})
|
||||
}
|
||||
}
|
||||
|
||||
/// `joints`, as the handler reads it: a list of at most 10000 ids or names.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct JointList(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for JointList {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let Value::Array(a) = Value::deserialize(d)? else {
|
||||
return Err(D::Error::custom(whole("`joints` must be a list of ids or names")));
|
||||
};
|
||||
let message = whole("`joints` must contain at most 10000 ids or names");
|
||||
if a.len() > MAX_LIST {
|
||||
return Err(D::Error::custom(message));
|
||||
}
|
||||
a.into_iter().map(|v| id_or_name(v, &message)).collect::<Result<_, _>>().map(JointList)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for JointList {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"JointList".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "Joint ids (numbers) or names.", "type": "array", "items": id_or_name_schema(), "maxItems": MAX_LIST})
|
||||
}
|
||||
}
|
||||
|
||||
/// What `selection.delete` returns. With `dry_run` it says what would happen.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SelectionDeleteResult {
|
||||
/// The names of the timeline features that went (or would go), the selected ones and those
|
||||
/// that depended on them.
|
||||
pub deleted: Vec<String>,
|
||||
/// The ids of the joints that went (or would go): the ones asked for, and those on an
|
||||
/// occurrence deleted.
|
||||
pub deleted_joints: Vec<u64>,
|
||||
/// The bodies still there that got a Remove feature, by name.
|
||||
pub bodies: Vec<String>,
|
||||
/// The names of the features that would fail (or now fail) to rebuild without what went,
|
||||
/// also those after a rolled-back marker.
|
||||
pub would_fail: Vec<String>,
|
||||
/// Why some items were not deleted (an origin plane, an edge or vertex, …), each reason once.
|
||||
pub skipped: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "delete_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,273 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{IdList, JointList, SelectionDeleteParams, SelectionDeleteResult, SelectionItems};
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
use crate::{Sel, Session};
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, r: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<SelectionDeleteParams>(),
|
||||
&["canvases", "components", "dry_run", "items", "joints", "occurrences", "point_clouds"],
|
||||
);
|
||||
}
|
||||
|
||||
/// Strict gives the handler's messages for the calls it refuses for a key.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("selection.delete", json!({})),
|
||||
("selection.delete", json!({"items": null})),
|
||||
("selection.delete", json!({"items": "all"})),
|
||||
("selection.delete", json!({"items": {"type": "body", "name": "Body1"}})),
|
||||
("selection.delete", json!({"items": [{"type": "body"}]})),
|
||||
("selection.delete", json!({"items": [{"type": "nothing"}]})),
|
||||
("selection.delete", json!({"items": [5]})),
|
||||
("selection.delete", json!({"items": [{"type": "feature", "id": -1}]})),
|
||||
("selection.delete", json!({"items": [{"type": "face", "body": "B", "index": 0, "point": [1, 2]}]})),
|
||||
("selection.delete", json!({"items": vec![json!({"type": "body", "name": "B"}); 10_001]})),
|
||||
("selection.delete", json!({"joints": "J1"})),
|
||||
("selection.delete", json!({"joints": [true]})),
|
||||
("selection.delete", json!({"joints": [1.5]})),
|
||||
("selection.delete", json!({"joints": [-1]})),
|
||||
("selection.delete", json!({"joints": vec![json!(1); 10_001]})),
|
||||
("selection.delete", json!({"joints": ["Nope"]})),
|
||||
("selection.delete", json!({"joints": [99]})),
|
||||
("selection.delete", json!({"components": ["Nope"]})),
|
||||
("selection.delete", json!({"components": [0]})),
|
||||
("selection.delete", json!({"occurrences": ["Nope"]})),
|
||||
("selection.delete", json!({"canvases": ["Nope"]})),
|
||||
("selection.delete", json!({"point_clouds": ["Nope"]})),
|
||||
("selection.delete", json!({"items": [{"type": "feature", "id": 99999}]})),
|
||||
("selection.delete", json!({"items": [{"type": "plane", "name": "XY"}]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The types read each key as the handler does: what it takes they take, what it refuses (with
|
||||
/// its message) they refuse.
|
||||
#[test]
|
||||
fn the_types_read_like_the_handler() {
|
||||
let items = |v: Value| serde_json::from_value::<SelectionItems>(v).map_err(|e| e.to_string());
|
||||
assert_eq!(items(json!([])).map(|i| i.0.len()), Ok(0));
|
||||
assert_eq!(items(json!([{"type": "feature", "id": 3}, {"type": "body", "name": "B"}])).map(|i| i.0.len()), Ok(2));
|
||||
assert_eq!(items(json!([{"type": "feature", "id": 3}])).map(|i| i.0), Ok(vec![Sel::Feature { id: 3 }]));
|
||||
assert!(items(json!("x")).is_err_and(|e| e.contains("`items` must be a list of selection items")));
|
||||
assert!(items(json!(null)).is_err());
|
||||
assert!(items(json!([{"type": "nothing"}])).is_err_and(|e| e.contains("bad item {\"type\":\"nothing\"}: ")));
|
||||
assert!(items(json!(vec![json!({"type": "body", "name": "B"}); 10_001])).is_err());
|
||||
assert!(items(json!(vec![json!({"type": "body", "name": "B"}); 10_000])).is_ok());
|
||||
|
||||
let ids = |v: Value| serde_json::from_value::<IdList>(v).map(|l| l.0).ok();
|
||||
assert_eq!(ids(json!(["Plate", 3])), Some(vec!["Plate".to_string(), "3".to_string()]));
|
||||
assert_eq!(ids(json!([])), Some(vec![]));
|
||||
// What the handler ignores is ignored, not refused: a call that runs today is not warned about.
|
||||
assert_eq!(ids(json!("Plate")), Some(vec![]));
|
||||
assert_eq!(ids(json!(["a", true, -1, 1.5, null, 2])), Some(vec!["a".to_string(), "2".to_string()]));
|
||||
assert_eq!(ids(json!(vec![json!(1); 10_001])).map(|l| l.len()), Some(10_000));
|
||||
|
||||
let joints = |v: Value| serde_json::from_value::<JointList>(v).map_err(|e| e.to_string());
|
||||
assert_eq!(joints(json!(["J1", 2])).map(|l| l.0), Ok(vec!["J1".to_string(), "2".to_string()]));
|
||||
assert!(joints(json!("J1")).is_err_and(|e| e.contains("`joints` must be a list of ids or names")));
|
||||
for bad in [json!([true]), json!([1.5]), json!([-1]), json!(vec![json!(1); 10_001])] {
|
||||
assert!(joints(bad).is_err_and(|e| e.contains("`joints` must contain at most 10000 ids or names")));
|
||||
}
|
||||
|
||||
// Nothing is needed, and a `null` is a key left out (the handler says "nothing to delete").
|
||||
let params = |v: Value| serde_json::from_value::<SelectionDeleteParams>(v).is_ok();
|
||||
assert!(params(json!({})));
|
||||
assert!(params(
|
||||
json!({"items": null, "components": null, "occurrences": null, "canvases": null, "point_clouds": null, "joints": null, "dry_run": null})
|
||||
));
|
||||
assert!(params(json!({"dry_run": true})));
|
||||
assert!(!params(json!({"dry_run": "yes"})));
|
||||
}
|
||||
|
||||
/// The schema takes every kind of item the handler can read, and refuses what it can't.
|
||||
#[test]
|
||||
fn the_schema_takes_every_selection_item() {
|
||||
let schema = input_schema_for::<SelectionDeleteParams>();
|
||||
let schema = schema.as_value();
|
||||
let point = solidcore_record::Vec3::new(1.0, 2.0, 3.0);
|
||||
let all = [
|
||||
Sel::Occurrence { path: vec![1, 2] },
|
||||
Sel::Body { name: "B".into() },
|
||||
Sel::Edge { body: "B".into(), index: 0, point },
|
||||
Sel::Face { body: "B".into(), index: 1, point },
|
||||
Sel::Feature { id: 4 },
|
||||
Sel::SketchCurve { id: "c1".into(), sketch: None },
|
||||
Sel::SketchCurve { id: "c1".into(), sketch: Some(2) },
|
||||
Sel::SketchPoint { id: "p1".into() },
|
||||
Sel::SketchMidpoint { id: "c1".into() },
|
||||
Sel::ExternalSketchCurve { sketch: 2, id: "c1".into() },
|
||||
Sel::ExternalSketchPoint { sketch: 2, id: "p1".into() },
|
||||
Sel::SketchConstraint { id: "k1".into() },
|
||||
Sel::Profile { sketch: 2, index: 0 },
|
||||
Sel::Plane { name: "XY".into() },
|
||||
Sel::ConstructionPoint { name: "P".into() },
|
||||
Sel::Axis { name: "X".into() },
|
||||
Sel::Vertex { body: "B".into(), point },
|
||||
];
|
||||
for item in all {
|
||||
let item = serde_json::to_value(&item).unwrap();
|
||||
assert!(conforms(schema, schema, &json!({"items": [item]})), "{item}");
|
||||
assert!(serde_json::from_value::<SelectionItems>(json!([item])).is_ok(), "{item}");
|
||||
}
|
||||
let takes = |p: Value, takes: bool| assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
takes(json!({}), true);
|
||||
takes(
|
||||
json!({"items": [], "components": ["A", 2], "occurrences": [1], "canvases": ["C"], "point_clouds": [3], "joints": ["J", 4], "dry_run": true}),
|
||||
true,
|
||||
);
|
||||
takes(json!({"items": [{"type": "nothing"}]}), false);
|
||||
takes(json!({"items": [{"type": "body"}]}), false);
|
||||
takes(json!({"items": "all"}), false);
|
||||
takes(json!({"joints": [true]}), false);
|
||||
takes(json!({"dry_run": "yes"}), false);
|
||||
takes(json!({"colour": 1}), false);
|
||||
}
|
||||
|
||||
/// Calls that run today with a list the handler ignores get no warning and no refusal, in warn
|
||||
/// and in strict.
|
||||
#[test]
|
||||
fn lists_the_handler_ignores_are_not_warned_about() {
|
||||
let mut s = part();
|
||||
let sketch = s.doc.features.first().map(|f| f.id).unwrap();
|
||||
for level in [ParamCheck::Warn, ParamCheck::Strict] {
|
||||
s.param_check = level;
|
||||
let p = json!({"items": [{"type": "feature", "id": sketch}], "dry_run": true, "components": "A", "canvases": [true, -1], "point_clouds": 5});
|
||||
let r = s.execute("selection.delete", &p).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A design with a sketch extruded into a body, a second box and an offset plane.
|
||||
fn part() -> Session {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance": 5}));
|
||||
run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 0, 0]}));
|
||||
run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 10}));
|
||||
s
|
||||
}
|
||||
|
||||
/// Three components, A grounded, with a joint from A to each of the others.
|
||||
fn joint_assembly() -> Session {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
for (name, x) in [("A", 0), ("B", 30), ("C", 60)] {
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "component.create", json!({"name": name}));
|
||||
run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 10, "corner": [x, 0, 0]}));
|
||||
}
|
||||
run(&mut s, "component.activate", json!({"component": "root"}));
|
||||
run(&mut s, "occurrence.ground", json!({"occurrence": "A:1", "grounded": true}));
|
||||
for (name, other, face) in [("Hinge", "B:1", [40, 10, 0]), ("Glue", "C:1", [70, 10, 0])] {
|
||||
let ends = json!({"a": {"occurrence": "A:1", "face": [10, 10, 10]}, "b": {"occurrence": other, "face": face}});
|
||||
let mut p = json!({"type": "revolute", "name": name});
|
||||
p.as_object_mut().unwrap().extend(ends.as_object().unwrap().clone());
|
||||
run(&mut s, "joint.create", p);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn png() -> String {
|
||||
let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
b.extend(400u32.to_be_bytes());
|
||||
b.extend(200u32.to_be_bytes());
|
||||
b.extend([8, 6, 0, 0, 0, 0, 0, 0, 0]);
|
||||
solidcraft_doc::canvas::base64_encode(&b)
|
||||
}
|
||||
|
||||
/// Every form of the command, in warn without warnings: the reply matches the output schema and
|
||||
/// has the keys it had before (and no `param_warnings`).
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let keys = |r: &Value| r.as_object().map(|o| o.keys().cloned().collect::<std::collections::BTreeSet<_>>()).unwrap_or_default();
|
||||
let expected: std::collections::BTreeSet<String> =
|
||||
["bodies", "deleted", "deleted_joints", "skipped", "would_fail"].into_iter().map(String::from).collect();
|
||||
let check = |r: &Value| {
|
||||
matches(output_schema_for::<SelectionDeleteResult>(), r);
|
||||
assert_eq!(keys(r), expected, "{r}");
|
||||
};
|
||||
let mut s = part();
|
||||
let sketch = s.doc.features.first().map(|f| f.id).unwrap();
|
||||
// A dry run, then the real thing.
|
||||
let n = s.doc.features.len();
|
||||
let r = run(&mut s, "selection.delete", json!({"items": [{"type": "feature", "id": sketch}], "dry_run": true}));
|
||||
check(&r);
|
||||
assert_eq!(s.doc.features.len(), n);
|
||||
assert!(r["deleted"].as_array().is_some_and(|d| d.len() >= 2), "{r}");
|
||||
let done = run(&mut s, "selection.delete", json!({"items": [{"type": "feature", "id": sketch}]}));
|
||||
check(&done);
|
||||
assert_eq!(done, r, "the dry run says what the delete did");
|
||||
assert_eq!((&done["deleted_joints"], &done["skipped"]), (&json!([]), &json!([])), "{done}");
|
||||
// A body (a Remove feature), and an item that can't go, with its reason once.
|
||||
let body = s.model.state().bodies[0].name.clone();
|
||||
let plane = json!({"type": "plane", "name": "XY"});
|
||||
let r = run(&mut s, "selection.delete", json!({"items": [{"type": "body", "name": body}, plane, plane]}));
|
||||
check(&r);
|
||||
assert_eq!(r["bodies"], json!([body]), "{r}");
|
||||
assert_eq!(r["skipped"], json!(["the origin planes can't be deleted"]), "{r}");
|
||||
// A canvas by id, with the other keys `null`.
|
||||
run(&mut s, "canvas.insert", json!({"data": png()}));
|
||||
let canvas = s.doc.canvases[0].id;
|
||||
let r = run(&mut s, "selection.delete", json!({"canvases": [canvas], "items": null, "components": null, "joints": null, "dry_run": false}));
|
||||
check(&r);
|
||||
assert!(s.doc.canvases.is_empty());
|
||||
// A point cloud, by id.
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-delete-schema-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("two.xyz");
|
||||
std::fs::write(&path, "0 0 0\n1 0 0\n").unwrap();
|
||||
let cloud = run(&mut s, "pointcloud.insert", json!({"path": path}))["cloud"].clone();
|
||||
let r = run(&mut s, "selection.delete", json!({"point_clouds": [cloud]}));
|
||||
check(&r);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
|
||||
// Joints, an occurrence of a component with others, and a component.
|
||||
let mut s = joint_assembly();
|
||||
let hinge = s.doc.assembly.joints[0].id;
|
||||
let r = run(&mut s, "selection.delete", json!({"joints": [hinge, "Glue"], "dry_run": true}));
|
||||
check(&r);
|
||||
assert_eq!(r["deleted_joints"].as_array().map(Vec::len), Some(2), "{r}");
|
||||
let r = run(&mut s, "selection.delete", json!({"joints": [hinge, "Glue"]}));
|
||||
check(&r);
|
||||
assert!(s.doc.assembly.joints.is_empty());
|
||||
let b2 = run(&mut s, "occurrence.copy", json!({"occurrence": "B:1", "translate": [0, 40, 0]}))["occurrence"].as_u64().unwrap();
|
||||
let r = run(&mut s, "selection.delete", json!({"occurrences": ["B:1"]}));
|
||||
check(&r);
|
||||
assert!(s.doc.occurrences.iter().any(|o| o.id == b2));
|
||||
let r = run(&mut s, "selection.delete", json!({"components": ["C"], "items": []}));
|
||||
check(&r);
|
||||
assert!(s.doc.components.iter().all(|c| c.name != "C"));
|
||||
// A selected occurrence, by its exact path.
|
||||
let o = s.doc.occurrences.iter().find(|o| o.name == "A:1").unwrap().id;
|
||||
let r = run(&mut s, "selection.delete", json!({"items": [{"type": "occurrence", "path": [o]}], "dry_run": true}));
|
||||
check(&r);
|
||||
}
|
||||
|
||||
/// A misspelt key runs in warn and says so, the reply still matches the schema; strict refuses it.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn_and_refused_in_strict() {
|
||||
let mut s = part();
|
||||
let sketch = s.doc.features.first().map(|f| f.id).unwrap();
|
||||
let p = json!({"items": [{"type": "feature", "id": sketch}], "dryrun": true});
|
||||
let r = s.execute("selection.delete", &p).unwrap();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| w.iter().any(|w| w.as_str().is_some_and(|w| w.contains("`dryrun`")))), "{r}");
|
||||
matches(output_schema_for::<SelectionDeleteResult>(), &r);
|
||||
assert!(s.doc.feature(sketch).is_none(), "the call still ran");
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute("selection.delete", &json!({"items": [], "dryrun": true})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`dryrun`"), "{error}");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,16 +5,20 @@
|
||||
//! fields whose descriptions say so; the contracts don't check the combinations, and the
|
||||
//! handlers ignore those keys otherwise.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::check::whole;
|
||||
use super::config::NameList;
|
||||
use super::types::{
|
||||
AngleExpr, AxisDir, AxisLine, ConventionParam, DirectionParam, EdgeRefs, ExtentKeyword, ExtrudeOperation, FacesParam, FeatureCommon,
|
||||
FilletCornerParam, FilletFitParam, FilletTurnParam, FilletTypeParam, FromPoint, GeoRefParam, IdOrName, LengthExpr, NumberExpr, OperationParam,
|
||||
PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors, SketchRef, StringList, ThinSideParam, ToPoint, TouchingParam,
|
||||
feature_common, present,
|
||||
AngleExpr, AxisDir, AxisLine, CombineOperation, ConventionParam, DirectionParam, EdgeRefs, ExtentKeyword, ExtrudeOperation, FacesParam,
|
||||
FeatureCommon, FilletCornerParam, FilletFitParam, FilletTurnParam, FilletTypeParam, FromPoint, GeoRefParam, IdOrName, LengthExpr, NumberExpr,
|
||||
OperationParam, PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors, SketchRef, StringList, ThinSideParam, ToPoint,
|
||||
TouchingParam, feature_common, present,
|
||||
};
|
||||
use crate::params::MAX_STRING_LIST;
|
||||
|
||||
/// Add a box primitive.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -398,6 +402,80 @@ pub struct ScaleParams {
|
||||
pub participants: Option<StringList>,
|
||||
}
|
||||
|
||||
/// Combine bodies: join the tool bodies to the target, cut them from it, or keep only the space
|
||||
/// the target shares with them. Whether each body exists stays the handler's to check.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct CombineParams {
|
||||
/// The body the tools combine with, by name.
|
||||
#[serde(deserialize_with = "target_body")]
|
||||
#[schemars(with = "String")]
|
||||
pub target: String,
|
||||
#[serde(deserialize_with = "tool_bodies")]
|
||||
#[schemars(schema_with = "tool_bodies_schema")]
|
||||
pub tools: NameList,
|
||||
/// What the tools do to the target. Default: join.
|
||||
#[serde(default)]
|
||||
pub operation: Option<CombineOperation>,
|
||||
/// What a join makes of pieces that share no face. Default: separate.
|
||||
#[serde(default)]
|
||||
pub touching: Option<TouchingParam>,
|
||||
/// Keep the tool bodies as they are. Default: false (the combine uses them up).
|
||||
#[serde(default)]
|
||||
pub keep_tools: Option<bool>,
|
||||
/// Move the result into a new component, made for it. Default: false.
|
||||
#[serde(default)]
|
||||
pub new_component: Option<bool>,
|
||||
/// With `new_component`: the new component's name. Default: the target's name and
|
||||
/// " Combined".
|
||||
#[serde(default)]
|
||||
pub component_name: Option<String>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// `target`: a body name, with the handler's message for anything else, `null` included.
|
||||
fn target_body<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(name) => Ok(name),
|
||||
_ => Err(D::Error::custom(whole("`target` must be a body name"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The handler's message for no tools.
|
||||
const NO_TOOLS: &str = "`tools` must list bodies";
|
||||
|
||||
/// `tools`: a name or a list of names, as `params::string_list` reads it and with its messages,
|
||||
/// and at least one. An empty list, and `null` (which the reader reads as none), get the
|
||||
/// handler's message for no tools.
|
||||
fn tool_bodies<'de, D: Deserializer<'de>>(d: D) -> Result<NameList, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Err(D::Error::custom(whole(NO_TOOLS))),
|
||||
v => match NameList::deserialize(v).map_err(D::Error::custom)? {
|
||||
NameList(names) if names.is_empty() => Err(D::Error::custom(whole(NO_TOOLS))),
|
||||
names => Ok(names),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_bodies_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The bodies combined with the target, by name: a name, or a list of one or more names (at most 10000).",
|
||||
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": MAX_STRING_LIST}]
|
||||
})
|
||||
}
|
||||
|
||||
/// What `solid.combine` returns: a feature command's reply, with the new component's id when
|
||||
/// `new_component` moved the result into one.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CombineResult {
|
||||
#[serde(flatten)]
|
||||
pub feature: FeatureResult,
|
||||
/// With `new_component`: the id of the component the result went into.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub component: Option<u64>,
|
||||
}
|
||||
|
||||
/// Add a construction plane parallel to `base`: `offset` away along its normal, or through a
|
||||
/// point.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, FilletParams, FilletResult, OffsetPlaneParams,
|
||||
PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
||||
BoundingSolidParams, BoxParams, CombineParams, CombineResult, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, FilletParams,
|
||||
FilletResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
|
||||
@@ -113,6 +115,26 @@ fn schemas_and_the_key_check_agree() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_schema_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<CombineParams>(),
|
||||
&[
|
||||
"body_name",
|
||||
"body_names",
|
||||
"component_name",
|
||||
"keep_tools",
|
||||
"name",
|
||||
"new_component",
|
||||
"operation",
|
||||
"participants",
|
||||
"target",
|
||||
"tools",
|
||||
"touching",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
@@ -188,9 +210,90 @@ fn strict_keeps_todays_errors() {
|
||||
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "corner": "round"})),
|
||||
("solid.fillet", json!({"edges": [], "radius": 1})),
|
||||
("solid.fillet", json!({"edges": 5, "radius": 1})),
|
||||
// #12917: Combine's keys of the wrong shape; bodies the design lacks stay the handler's
|
||||
// to refuse.
|
||||
("solid.combine", json!({"target": 5, "tools": ["B"]})),
|
||||
("solid.combine", json!({"target": null, "tools": ["B"]})),
|
||||
("solid.combine", json!({"target": "A", "tools": []})),
|
||||
("solid.combine", json!({"target": "A", "tools": null})),
|
||||
("solid.combine", json!({"target": "A", "tools": 5})),
|
||||
("solid.combine", json!({"target": "A", "tools": ["B", 1]})),
|
||||
("solid.combine", json!({"target": "A", "tools": vec!["B"; 10_001]})),
|
||||
("solid.combine", json!({"target": "A", "tools": ["B"], "operation": "new"})),
|
||||
("solid.combine", json!({"target": "A", "tools": ["B"], "operation": "bogus"})),
|
||||
("solid.combine", json!({"target": "A", "tools": ["B"]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #12917: Combine's contract takes what its input schema allows: each call's check finds no
|
||||
/// issue exactly when the call matches the schema. Left out: an operation spelt in capitals,
|
||||
/// which the check takes as the handler does and the schema's enum doesn't.
|
||||
#[test]
|
||||
fn combine_check_takes_what_the_schema_allows() {
|
||||
let schema = input_schema_for::<CombineParams>();
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in [
|
||||
(json!({"target": "A", "tools": ["B"]}), true),
|
||||
(json!({"target": "A", "tools": "B", "operation": "cut", "keep_tools": true}), true),
|
||||
(
|
||||
json!({"target": "A", "tools": ["B", "C"], "operation": "intersection", "touching": "one_body", "new_component": true, "component_name": "Merged", "name": "C1", "body_name": "X"}),
|
||||
true,
|
||||
),
|
||||
(json!({"target": "A", "tools": ["B"], "operation": null, "touching": null, "keep_tools": null, "component_name": null}), true),
|
||||
(json!({"target": 5, "tools": ["B"]}), false),
|
||||
(json!({"target": null, "tools": ["B"]}), false),
|
||||
(json!({"target": "A"}), false),
|
||||
(json!({"target": "A", "tools": []}), false),
|
||||
(json!({"target": "A", "tools": null}), false),
|
||||
(json!({"target": "A", "tools": [1]}), false),
|
||||
(json!({"target": "A", "tools": ["B"], "operation": "new"}), false),
|
||||
(json!({"target": "A", "tools": ["B"], "operation": 5}), false),
|
||||
(json!({"target": "A", "tools": ["B"], "touching": "glued"}), false),
|
||||
(json!({"target": "A", "tools": ["B"], "keep_tools": "yes"}), false),
|
||||
(json!({"target": "A", "tools": ["B"], "targets": ["A"]}), false),
|
||||
] {
|
||||
let issues = crate::schema::issues("solid.combine", &p);
|
||||
assert_eq!(issues.is_empty(), takes, "{p}: {issues:?}");
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12917: Combine replies with `CombineResult`, with the keys it gave before, in each of its
|
||||
/// forms: a join, a cut keeping its tool, an intersect, a join into one body, and a join into a
|
||||
/// new component (which adds the component's id). In Warn, without warnings, so its contract
|
||||
/// takes each call.
|
||||
#[test]
|
||||
fn combine_replies_with_combine_result() {
|
||||
let schema = output_schema_for::<CombineResult>();
|
||||
let schema = schema.as_value();
|
||||
let before = BTreeSet::from(["feature", "name", "bodies", "created_bodies", "changed_bodies", "removed_bodies", "warning", "ms"]);
|
||||
for p in [
|
||||
json!({"target": "A", "tools": ["B"]}),
|
||||
json!({"target": "A", "tools": "B", "operation": "cut", "keep_tools": true}),
|
||||
json!({"target": "A", "tools": ["B"], "operation": "intersect", "name": "Common"}),
|
||||
json!({"target": "A", "tools": ["B"], "operation": "union", "touching": "one_body"}),
|
||||
json!({"target": "A", "tools": ["B"], "new_component": true, "component_name": "Merged"}),
|
||||
] {
|
||||
// Two 10 mm cubes, `B` overlapping `A` by 5 mm along each axis; made again for each call.
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
for (corner, body) in [([0, 0, 0], "A"), ([5, 5, 5], "B")] {
|
||||
let box_ = json!({"length": 10, "width": 10, "height": 10, "corner": corner, "body_name": body});
|
||||
s.execute("solid.box", &box_).unwrap_or_else(|e| panic!("{e}"));
|
||||
}
|
||||
let r = s.execute("solid.combine", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||
let keys: BTreeSet<&str> = r.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
|
||||
let mut expected = before.clone();
|
||||
if p.get("new_component").is_some() {
|
||||
assert!(r["component"].is_u64(), "{p}: {r}");
|
||||
expected.insert("component");
|
||||
}
|
||||
assert_eq!(keys, expected, "{p}: {r}");
|
||||
assert!(r["changed_bodies"].as_array().is_some_and(|changed| changed.contains(&json!("A"))), "{p}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_result_serializes_like_the_old_reply() {
|
||||
for (warning, ms) in [(None, None), (Some("a Boolean needed a retry".to_string()), Some(12.5)), (None, Some(0.0)), (None, Some(1.0 / 3.0))] {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Contract and reply type for Interference (`inspect.interference`, #12874), whose handler is
|
||||
//! `interference` in `cmd/interference.rs`: the pairs of solid bodies that share volume, and how
|
||||
//! much, in world placement. It changes nothing.
|
||||
//!
|
||||
//! Its hits and its `unchecked` entries have the shapes of Inspect Clearance's `Overlap` and
|
||||
//! `Unchecked` (`schema/clearance.rs`), which it reuses. The handler reads its parameters from
|
||||
//! the raw JSON, so each key here reads as its handler reads it and fails with its message.
|
||||
//! Whether each body exists stays the handler's to check.
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::clearance::{Overlap, Unchecked};
|
||||
use super::config::NameList;
|
||||
|
||||
/// The longest `budget_s` a call takes: a day (`MAX_BUDGET_S` in `cmd/interference.rs`).
|
||||
const MAX_BUDGET_S: f64 = 86_400.0;
|
||||
|
||||
/// Find the pairs of solid bodies that share volume, in world placement, and how much. Pairs are
|
||||
/// ruled out by their boxes, then by their meshes; the pairs left are intersected exactly,
|
||||
/// slowest first, within `budget_s`. Mesh bodies aren't intersected: each is listed in
|
||||
/// `unchecked`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct InterferenceParams {
|
||||
/// The bodies to check, by name. Default (or `null`): every body. A name that is no body is
|
||||
/// refused.
|
||||
#[serde(default)]
|
||||
pub bodies: Option<NameList>,
|
||||
/// The seconds the exact checks may take, above 0, at most 86400 (a day). Pairs not finished
|
||||
/// by then are listed in `unchecked`, and the call returns. Default (or `null`): 240.
|
||||
#[serde(default, deserialize_with = "budget_s")]
|
||||
#[schemars(schema_with = "budget_s_schema")]
|
||||
pub budget_s: Option<f64>,
|
||||
}
|
||||
|
||||
/// `budget_s`: seconds above 0, at most a day, or `null`, as the handler reads it (with
|
||||
/// `params::num`, which takes the number finite and smaller than 1e9 in size), with its message
|
||||
/// (the check names the key).
|
||||
fn budget_s<'de, D: Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Null => Ok(None),
|
||||
v => v
|
||||
.as_f64()
|
||||
.filter(|s| s.is_finite() && *s > 0.0 && *s <= MAX_BUDGET_S)
|
||||
.map(Some)
|
||||
.ok_or_else(|| D::Error::custom(format!("must be a number of seconds above 0, at most {MAX_BUDGET_S}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn budget_s_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The seconds the exact checks may take, above 0, at most 86400 (a day). Pairs not finished by then are listed \
|
||||
in `unchecked`, and the call returns. Default (or `null`): 240.",
|
||||
"type": ["number", "null"],
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": MAX_BUDGET_S,
|
||||
})
|
||||
}
|
||||
|
||||
/// The time one pair's exact check took, in ms.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PairTime {
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
pub ms: f64,
|
||||
/// The kernel's boolean time within it.
|
||||
pub boolean_ms: f64,
|
||||
/// The kernel's tessellation time within it.
|
||||
pub tessellate_ms: f64,
|
||||
}
|
||||
|
||||
/// What `inspect.interference` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct InterferenceResult {
|
||||
/// The pairs that share volume, with the volume, its surface area, its box and its centre.
|
||||
pub interferences: Vec<Overlap>,
|
||||
/// How many: 0 isn't "no overlap" while `unchecked` lists pairs.
|
||||
pub count: usize,
|
||||
/// How many solid bodies were checked.
|
||||
pub checked: usize,
|
||||
/// The pairs that couldn't be checked, with the reason (a boolean that failed, or the time
|
||||
/// budget ran out), and each mesh body (`b` is then `null`).
|
||||
pub unchecked: Vec<Unchecked>,
|
||||
/// Whether more than 20000 pairs had overlapping boxes, so `skipped_pairs` were left out.
|
||||
pub truncated: bool,
|
||||
pub skipped_pairs: usize,
|
||||
/// The pairs whose boxes overlap.
|
||||
pub pairs_candidate: usize,
|
||||
/// Of those, the pairs cleared by their meshes, without a boolean.
|
||||
pub pairs_cleared: usize,
|
||||
/// The pairs intersected exactly.
|
||||
pub pairs_intersected: usize,
|
||||
/// The pairs not finished within the time budget.
|
||||
pub pairs_not_checked: usize,
|
||||
/// The time budget, in seconds.
|
||||
pub budget_s: f64,
|
||||
/// Whether it ran out.
|
||||
pub budget_exceeded: bool,
|
||||
/// The worker threads the exact checks ran on; 0: on the calling thread.
|
||||
pub threads: usize,
|
||||
/// The kernel's boolean and tessellation time, summed over the pairs, in ms.
|
||||
pub boolean_ms: f64,
|
||||
pub tessellate_ms: f64,
|
||||
/// The slowest pairs, at most 8, slowest first.
|
||||
pub slowest: Vec<PairTime>,
|
||||
/// The time the whole call took, in ms.
|
||||
pub ms: f64,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "interference_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,114 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{InterferenceParams, InterferenceResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<InterferenceParams>(), &["bodies", "budget_s"]);
|
||||
}
|
||||
|
||||
/// #12874: calls that fail today get the same message in strict: `string_list`'s for `bodies`
|
||||
/// of the wrong shape (`NameList` gives them), the handler's for `budget_s` out of range, and
|
||||
/// its own for a body the design lacks.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let too_many = vec![json!("A"); 10_001];
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("inspect.interference", json!({"bodies": 5})),
|
||||
("inspect.interference", json!({"bodies": {"name": "A"}})),
|
||||
("inspect.interference", json!({"bodies": ["A", 1]})),
|
||||
("inspect.interference", json!({"bodies": too_many})),
|
||||
("inspect.interference", json!({"bodies": ["Nope"]})),
|
||||
("inspect.interference", json!({"budget_s": 0})),
|
||||
("inspect.interference", json!({"budget_s": -1})),
|
||||
("inspect.interference", json!({"budget_s": 86401})),
|
||||
("inspect.interference", json!({"budget_s": "60"})),
|
||||
("inspect.interference", json!({"budget_s": true})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #12874: the contract takes what the input schema allows: each call's check finds no issue
|
||||
/// exactly when it matches the schema.
|
||||
#[test]
|
||||
fn the_check_takes_what_the_schema_allows() {
|
||||
let schema = input_schema_for::<InterferenceParams>();
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in [
|
||||
(json!({}), true),
|
||||
(json!({"bodies": "C1"}), true),
|
||||
(json!({"bodies": ["C1", "C2"]}), true),
|
||||
(json!({"bodies": []}), true),
|
||||
(json!({"bodies": null, "budget_s": null}), true),
|
||||
(json!({"budget_s": 0.5}), true),
|
||||
(json!({"budget_s": 86400}), true),
|
||||
(json!({"bodies": 5}), false),
|
||||
(json!({"bodies": ["C1", 1]}), false),
|
||||
(json!({"budget_s": 0}), false),
|
||||
(json!({"budget_s": 86401}), false),
|
||||
(json!({"budget_s": "60"}), false),
|
||||
] {
|
||||
let issues = crate::schema::issues("inspect.interference", &p);
|
||||
assert_eq!(issues.is_empty(), takes, "{p}: {issues:?}");
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys of an object.
|
||||
fn keys(v: &Value) -> BTreeSet<&str> {
|
||||
v.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect()
|
||||
}
|
||||
|
||||
/// #12874: Interference replies as its output schema says, in Warn without warnings, with the
|
||||
/// keys it gave before: on two cylinders that overlap and one apart, checked together and in
|
||||
/// pairs, with a time budget given.
|
||||
#[test]
|
||||
fn interference_replies_with_its_schema() {
|
||||
let mut s = Session::default();
|
||||
for p in [
|
||||
json!({"radius": 5, "height": 10, "base": [0, 0, 0], "body_name": "C1"}),
|
||||
json!({"radius": 5, "height": 10, "base": [7, 0, 0], "operation": "new", "body_name": "C2"}),
|
||||
json!({"radius": 5, "height": 10, "base": [40, 0, 0], "operation": "new", "body_name": "Far"}),
|
||||
] {
|
||||
s.execute("solid.cylinder", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
}
|
||||
s.param_check = ParamCheck::Warn;
|
||||
let schema = output_schema_for::<InterferenceResult>();
|
||||
let schema = schema.as_value();
|
||||
let reply_keys = BTreeSet::from([
|
||||
"interferences",
|
||||
"count",
|
||||
"checked",
|
||||
"unchecked",
|
||||
"truncated",
|
||||
"skipped_pairs",
|
||||
"pairs_candidate",
|
||||
"pairs_cleared",
|
||||
"pairs_intersected",
|
||||
"pairs_not_checked",
|
||||
"budget_s",
|
||||
"budget_exceeded",
|
||||
"threads",
|
||||
"boolean_ms",
|
||||
"tessellate_ms",
|
||||
"slowest",
|
||||
"ms",
|
||||
]);
|
||||
for p in [json!({}), json!({"bodies": "C1"}), json!({"bodies": ["C1", "C2"], "budget_s": 60}), json!({"bodies": ["C1", "Far"]})] {
|
||||
let r = s.execute("inspect.interference", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{p}: {r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||
assert_eq!(keys(&r), reply_keys, "{p}: {r}");
|
||||
}
|
||||
let r = s.execute("inspect.interference", &json!({"bodies": ["C1", "C2"]})).unwrap_or_default();
|
||||
let hit = &r["interferences"][0];
|
||||
assert_eq!((&r["count"], &hit["a"], &hit["b"]), (&json!(1), &json!("C1"), &json!("C2")), "{r}");
|
||||
assert_eq!(keys(hit), BTreeSet::from(["a", "b", "volume_mm3", "area_mm2", "bbox", "center"]), "{r}");
|
||||
assert!(hit["volume_mm3"].as_f64().is_some_and(|v| v > 0.0) && keys(&hit["bbox"]) == BTreeSet::from(["min", "max"]), "{r}");
|
||||
let slowest = &r["slowest"][0];
|
||||
assert_eq!(keys(slowest), BTreeSet::from(["a", "b", "ms", "boolean_ms", "tessellate_ms"]), "{r}");
|
||||
}
|
||||
@@ -64,12 +64,14 @@ pub mod component;
|
||||
pub mod component_link;
|
||||
pub mod config;
|
||||
pub mod construct;
|
||||
pub mod delete;
|
||||
pub mod diagnostics;
|
||||
pub mod edit;
|
||||
pub mod features;
|
||||
pub mod features_more;
|
||||
pub mod file;
|
||||
pub mod inspect;
|
||||
pub mod interference;
|
||||
pub mod joints;
|
||||
pub mod libraries;
|
||||
pub mod mass_properties;
|
||||
@@ -92,12 +94,14 @@ pub mod render_hdr;
|
||||
pub mod render_library;
|
||||
pub mod render_lights;
|
||||
pub mod render_presets;
|
||||
pub mod render_settings;
|
||||
pub mod render_shots;
|
||||
pub mod render_stage;
|
||||
pub mod render_surface_resources;
|
||||
pub mod render_ui;
|
||||
pub mod section;
|
||||
pub mod sheet;
|
||||
pub mod silhouette;
|
||||
pub mod sketch;
|
||||
pub mod sketch_clipboard;
|
||||
pub mod sketch_constraints;
|
||||
@@ -105,6 +109,7 @@ pub mod sketch_create;
|
||||
pub mod sketch_ends;
|
||||
pub mod sketch_equation;
|
||||
pub mod sketch_inspect;
|
||||
pub mod sketch_modify;
|
||||
pub mod surface_analysis;
|
||||
pub mod surface_boundary;
|
||||
pub mod surface_match;
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
//! Contracts and reply types for the plastic rule commands in `cmd/plastic.rs` (#12313): Manage,
|
||||
//! Assign and List Plastic Rules. A plastic rule holds a plastic part's design values, which
|
||||
//! Boss, Lip, Snap Fit and Rest take as defaults.
|
||||
//! Contracts and reply types for the commands in `cmd/plastic.rs`: the plastic rule commands
|
||||
//! Manage, Assign and List Plastic Rules (#12313), and the plastic features Boss, Lip, Snap Fit
|
||||
//! and Rest (#12622). A plastic rule holds a plastic part's design values, which the features
|
||||
//! take as defaults.
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether a rule or a body exists, and whether
|
||||
//! an expression evaluates, stay the handlers' to check. Where a handler ignores a key of the
|
||||
//! wrong type (a `delete` that isn't true or false, a rule value that is neither a number nor
|
||||
//! text), the contract refuses it.
|
||||
//! reads it and fails with the handler's message. Whether a rule, a body or a face exists, and
|
||||
//! whether an expression evaluates and fits the part, stay the handlers' to check. Where a
|
||||
//! handler ignores a key of the wrong type (a `delete` that isn't true or false, a rule value or
|
||||
//! a feature's optional size that is neither a number nor text), the contract refuses it.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_doc::plastic::{PlasticRule, PlasticValues};
|
||||
use solidcraft_geom::Vec3Geom as _;
|
||||
|
||||
use super::check::whole;
|
||||
use super::config::NameList;
|
||||
use super::types::{AngleExpr, LengthExpr, not_blank_pattern};
|
||||
use super::types::{
|
||||
AngleExpr, FeatureCommon, LengthExpr, LipSideParam, LipTypeParam, NumberExpr, Point3, TouchingParam, feature_common, not_blank_pattern, present,
|
||||
};
|
||||
|
||||
/// The longest rule name, in bytes once trimmed, as `plastic.manage_rules` reads it.
|
||||
const MAX_RULE_NAME_BYTES: usize = 128;
|
||||
@@ -117,15 +123,27 @@ fn rule_name_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
#[schemars(deny_unknown_fields, transform = bodies_required)]
|
||||
pub struct AssignRuleParams {
|
||||
/// The bodies, by name: 1 to 10 000.
|
||||
// Optional in the type, required in the schema: left out or empty, the handler refuses the
|
||||
// call with its own message.
|
||||
// Optional in the type, required in the schema: left out, `null` or empty, the handler
|
||||
// refuses the call with its own message, so the schema allows none of those.
|
||||
#[serde(default)]
|
||||
#[schemars(schema_with = "bodies_schema")]
|
||||
pub bodies: Option<NameList>,
|
||||
/// The rule, by name, as `plastic.rules` lists it. Empty or left out: the bodies have no
|
||||
/// rule of their own, so the active rule applies.
|
||||
pub rule: Option<String>,
|
||||
}
|
||||
|
||||
/// `bodies` as `plastic.assign_rule` takes it: a name, or a list of 1 to 10 000 names. Not
|
||||
/// `NameList`'s own schema, which allows the empty list the handler refuses.
|
||||
fn bodies_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": crate::params::MAX_STRING_LIST}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/// List the plastic rules (the library's and the design's), the active rule and the rules
|
||||
/// assigned to bodies. No parameters.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
@@ -263,6 +281,239 @@ pub struct RulesResult {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- Plastic features (#12622) ----
|
||||
//
|
||||
// Each feature joins the body it stands on (a groove cuts it) and replies as every feature
|
||||
// command does, with `FeatureResult`. The common keys (`name`, `body_name`, `body_names`,
|
||||
// `participants`) are `FeatureCommon`'s; `touching` is declared here because the features join.
|
||||
|
||||
/// `v` as `params::vec3` reads it. With `direction`, it must also have a length, since the
|
||||
/// handlers make it a unit vector and refuse a zero one.
|
||||
fn vector(v: &Value, direction: bool) -> Option<Vec3> {
|
||||
let p = crate::params::vec3(v)?;
|
||||
if direction { p.normalized() } else { Some(p) }
|
||||
}
|
||||
|
||||
/// A point or direction key of the plastic features: read as [`vector`] reads it, refused with
|
||||
/// the handler's whole message, published as a [`Point3`] with the key's own description.
|
||||
macro_rules! plastic_vector {
|
||||
($(#[$doc:meta])* $name:ident, direction: $direction:literal, $message:literal, $description:literal) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct $name(pub Vec3);
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
vector(&Value::deserialize(d)?, $direction).map($name).ok_or_else(|| D::Error::custom(whole($message)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for $name {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
stringify!($name).into()
|
||||
}
|
||||
fn inline_schema() -> bool {
|
||||
true
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = generator.subschema_for::<Point3>();
|
||||
schema.insert("description".into(), $description.into());
|
||||
schema
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
plastic_vector!(
|
||||
/// Boss, Snap Fit and Rest's `position`.
|
||||
FacePosition,
|
||||
direction: false,
|
||||
"`position` must be a point [x, y, z] on a face",
|
||||
"A point in mm on the face the feature stands on: [x, y, z] or {\"x\", \"y\", \"z\"}."
|
||||
);
|
||||
plastic_vector!(
|
||||
/// Boss, Snap Fit and Rest's `direction`.
|
||||
FaceDirection,
|
||||
direction: true,
|
||||
"`direction` must be a non-zero [x, y, z]",
|
||||
"The direction the feature stands out of the face, not zero. Default: square out of the face at `position`, which must then be planar."
|
||||
);
|
||||
plastic_vector!(
|
||||
/// Lip's `face`.
|
||||
RimFace,
|
||||
direction: false,
|
||||
"`face` must be a point [x, y, z] on the rim face",
|
||||
"A point in mm on the planar rim face of a shelled body, round its opening: [x, y, z] or {\"x\", \"y\", \"z\"}."
|
||||
);
|
||||
plastic_vector!(
|
||||
/// Snap Fit's `hook`.
|
||||
HookDirection,
|
||||
direction: true,
|
||||
"`hook` must be the catch's direction [x, y, z]",
|
||||
"The direction the catch sticks out of the arm: across `direction`, not zero."
|
||||
);
|
||||
plastic_vector!(
|
||||
/// Rest's `along`.
|
||||
AlongDirection,
|
||||
direction: true,
|
||||
"`along` must be a direction across the face",
|
||||
"The direction of a rectangular rest's `length`: across `direction`, not zero."
|
||||
);
|
||||
|
||||
/// Add a boss: a round post standing on a face, for a screw, with an optional hole, draft, root
|
||||
/// fillet and ribs. It joins the body it stands on. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct BossParams {
|
||||
pub position: FacePosition,
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "FaceDirection")]
|
||||
pub direction: Option<FaceDirection>,
|
||||
/// The post's diameter at the face, at least 0.01 mm.
|
||||
pub diameter: LengthExpr,
|
||||
/// How far the post stands out of the face, at least 0.01 mm.
|
||||
pub height: LengthExpr,
|
||||
/// The screw hole's diameter, down from the post's top. Default: no hole.
|
||||
#[serde(default)]
|
||||
pub hole_diameter: Option<LengthExpr>,
|
||||
/// How deep the hole goes from the post's top. Default: down to the face (the post's height).
|
||||
#[serde(default)]
|
||||
pub hole_depth: Option<LengthExpr>,
|
||||
/// The post's draft: it narrows towards its top by this angle. Default: the body's plastic
|
||||
/// rule's draft, or none.
|
||||
#[serde(default)]
|
||||
pub draft: Option<AngleExpr>,
|
||||
/// The radius of the round where the post meets the face. Default: none.
|
||||
#[serde(default)]
|
||||
pub fillet: Option<LengthExpr>,
|
||||
/// How many ribs brace the post, evenly round it: 0 to 64. Default: none.
|
||||
#[serde(default)]
|
||||
pub ribs: Option<NumberExpr>,
|
||||
/// Each rib's thickness. Default: 0.3 times the post's radius.
|
||||
#[serde(default)]
|
||||
pub rib_thickness: Option<LengthExpr>,
|
||||
/// How far each rib reaches out from the post's wall. Default: the post's radius.
|
||||
#[serde(default)]
|
||||
pub rib_length: Option<LengthExpr>,
|
||||
/// How far below the post's top the ribs stop. Default: 0, the ribs reach the top.
|
||||
#[serde(default)]
|
||||
pub rib_offset: Option<LengthExpr>,
|
||||
/// The body the boss joins, by name. Default: the body at `position`.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// What a join makes of pieces that share no face. Default: separate.
|
||||
#[serde(default)]
|
||||
pub touching: Option<TouchingParam>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Add a lip or a groove: a band along one edge of a shelled body's rim face, raised for a lip
|
||||
/// and cut for a groove, so two halves of a case fit together. A lip joins the body and a groove
|
||||
/// cuts it. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct LipParams {
|
||||
pub face: RimFace,
|
||||
/// The band's width across the rim.
|
||||
pub width: LengthExpr,
|
||||
/// A lip's height above the rim, or a groove's depth into it.
|
||||
pub height: LengthExpr,
|
||||
#[serde(default, rename = "type")]
|
||||
pub kind: Option<LipTypeParam>,
|
||||
/// A groove's clearance: it is this much wider than `width`, at least 0. Default: the body's
|
||||
/// plastic rule's clearance, or none. A lip doesn't use it.
|
||||
#[serde(default)]
|
||||
pub gap: Option<LengthExpr>,
|
||||
#[serde(default)]
|
||||
pub side: Option<LipSideParam>,
|
||||
/// True: the band runs along the outside edge, as `side: outside` does. Default: false.
|
||||
#[serde(default)]
|
||||
pub outside: Option<bool>,
|
||||
/// The shelled body, by name. Default: the body at `face`.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// What a lip's join makes of pieces that share no face. Default: separate.
|
||||
#[serde(default)]
|
||||
pub touching: Option<TouchingParam>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Add a snap fit: a cantilever arm standing on a face with a catch at its tip, which clips into
|
||||
/// another part. It joins the body it stands on, or makes a body of its own. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SnapFitParams {
|
||||
pub position: FacePosition,
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "FaceDirection")]
|
||||
pub direction: Option<FaceDirection>,
|
||||
pub hook: HookDirection,
|
||||
/// The arm's length out of the face, at least 0.01 mm.
|
||||
pub length: LengthExpr,
|
||||
/// The arm's thickness, along `hook`, at least 0.01 mm.
|
||||
pub thickness: LengthExpr,
|
||||
/// The arm's width, across `hook`, at least 0.01 mm.
|
||||
pub width: LengthExpr,
|
||||
/// How far the catch sticks out of the arm, at least 0.01 mm.
|
||||
pub catch_depth: LengthExpr,
|
||||
/// The catch's length along the arm, at the arm's tip: at least 0.01 mm and shorter than
|
||||
/// `length`.
|
||||
pub catch_length: LengthExpr,
|
||||
/// How far the arm's base reaches below the face, into the wall, at least 0.01 mm. Default:
|
||||
/// none, the arm starts at the face.
|
||||
#[serde(default)]
|
||||
pub base_height: Option<LengthExpr>,
|
||||
/// True: the snap fit is a body of its own instead of joining the body. Default: false.
|
||||
#[serde(default)]
|
||||
pub new_body: Option<bool>,
|
||||
/// The body it stands on, by name. Default: the body at `position`.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// What a join makes of pieces that share no face. Default: separate.
|
||||
#[serde(default)]
|
||||
pub touching: Option<TouchingParam>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
/// Add a rest: a pad standing on a face for another part to rest on, round or rectangular, solid
|
||||
/// or hollow. It joins the body it stands on. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct RestParams {
|
||||
pub position: FacePosition,
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "FaceDirection")]
|
||||
pub direction: Option<FaceDirection>,
|
||||
/// The pad's width at the face: a round rest's diameter.
|
||||
pub width: LengthExpr,
|
||||
/// A rectangular rest's length, along `along`. Default: a round rest.
|
||||
#[serde(default)]
|
||||
pub length: Option<LengthExpr>,
|
||||
#[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "AlongDirection")]
|
||||
pub along: Option<AlongDirection>,
|
||||
/// How far the pad stands out of the face, at least 0.01 mm.
|
||||
pub height: LengthExpr,
|
||||
/// The pad's draft, under 57°: it narrows towards its top by this angle. Default: the body's
|
||||
/// plastic rule's draft, or none.
|
||||
#[serde(default)]
|
||||
pub draft: Option<AngleExpr>,
|
||||
/// A hollow rest's wall thickness. Default: a solid rest.
|
||||
#[serde(default)]
|
||||
pub thickness: Option<LengthExpr>,
|
||||
/// The body it stands on, by name. Default: the body at `position`.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// What a join makes of pieces that share no face. Default: separate.
|
||||
#[serde(default)]
|
||||
pub touching: Option<TouchingParam>,
|
||||
#[serde(flatten, deserialize_with = "feature_common")]
|
||||
pub common: FeatureCommon,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "plastic_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{AssignRuleParams, AssignRuleResult, ManageRulesParams, ManageRulesResult, RulesParams, RulesResult};
|
||||
use super::{
|
||||
AssignRuleParams, AssignRuleResult, BossParams, LipParams, ManageRulesParams, ManageRulesResult, RestParams, RulesParams, RulesResult,
|
||||
SnapFitParams,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::features::FeatureResult;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
@@ -93,7 +97,7 @@ fn the_contracts_refuse_what_the_handlers_ignore() {
|
||||
|
||||
/// #12313: the schemas take what the handlers read: a rule's name as text, its values as
|
||||
/// numbers or expressions, `active` and `delete` as true or false; bodies as a name or a list of
|
||||
/// names, and the rule as text or `null`.
|
||||
/// one or more names (not `null`), and the rule as text or `null`.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let manage = input_schema_for::<ManageRulesParams>();
|
||||
@@ -119,6 +123,8 @@ fn the_schemas_take_what_the_handlers_read() {
|
||||
(json!({"bodies": ["Body1"], "rule": null}), true),
|
||||
(json!({}), false),
|
||||
(json!({"bodies": [5]}), false),
|
||||
(json!({"bodies": null}), false),
|
||||
(json!({"bodies": []}), false),
|
||||
(json!({"bodies": ["Body1"], "rule": 5}), false),
|
||||
] {
|
||||
assert_eq!(conforms(assign, assign, &p), takes, "{p}");
|
||||
@@ -171,3 +177,289 @@ fn plastic_rules_reply_with_their_schemas() {
|
||||
let r = run(&mut s, "plastic.manage_rules", json!({"name": "Mine", "delete": true}), output_schema_for::<ManageRulesResult>());
|
||||
assert_eq!(r["deleted"], "Mine", "{r}");
|
||||
}
|
||||
|
||||
// ---- Plastic features (#12622) ----
|
||||
|
||||
/// A 40 × 40 × 4 mm plate, `Body1`, whose top face (z = 4) the features stand on.
|
||||
fn plate(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 40, "height": 4})).unwrap_or_else(|e| panic!("{e}"));
|
||||
s
|
||||
}
|
||||
|
||||
/// An 80 × 50 × 30 mm box shelled 2 mm thick with its top open: the rim face is at z = 30.
|
||||
fn shelled(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in
|
||||
[("solid.box", json!({"length": 80, "width": 50, "height": 30})), ("solid.shell", json!({"faces": [[40, 25, 30]], "thickness": 2}))]
|
||||
{
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// `base` with `key` set to `value`.
|
||||
fn with(base: &Value, key: &str, value: Value) -> Value {
|
||||
let mut p = base.clone();
|
||||
p[key] = value;
|
||||
p
|
||||
}
|
||||
|
||||
fn boss() -> Value {
|
||||
json!({"position": [20, 20, 4], "diameter": 6, "height": 5})
|
||||
}
|
||||
|
||||
fn lip() -> Value {
|
||||
json!({"face": [1, 25, 30], "width": 1, "height": 2})
|
||||
}
|
||||
|
||||
fn snap_fit() -> Value {
|
||||
json!({"position": [20, 20, 4], "hook": [1, 0, 0], "length": 15, "thickness": 1.5, "width": 6, "catch_depth": 1, "catch_length": 3})
|
||||
}
|
||||
|
||||
fn rest() -> Value {
|
||||
json!({"position": [20, 20, 4], "width": 6, "height": 2})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_feature_schemas_and_the_key_check_agree() {
|
||||
assert_keys(
|
||||
input_schema_for::<BossParams>(),
|
||||
&[
|
||||
"body",
|
||||
"body_name",
|
||||
"body_names",
|
||||
"diameter",
|
||||
"direction",
|
||||
"draft",
|
||||
"fillet",
|
||||
"height",
|
||||
"hole_depth",
|
||||
"hole_diameter",
|
||||
"name",
|
||||
"participants",
|
||||
"position",
|
||||
"rib_length",
|
||||
"rib_offset",
|
||||
"rib_thickness",
|
||||
"ribs",
|
||||
"touching",
|
||||
],
|
||||
);
|
||||
assert_keys(
|
||||
input_schema_for::<LipParams>(),
|
||||
&["body", "body_name", "body_names", "face", "gap", "height", "name", "outside", "participants", "side", "touching", "type", "width"],
|
||||
);
|
||||
assert_keys(
|
||||
input_schema_for::<SnapFitParams>(),
|
||||
&[
|
||||
"base_height",
|
||||
"body",
|
||||
"body_name",
|
||||
"body_names",
|
||||
"catch_depth",
|
||||
"catch_length",
|
||||
"direction",
|
||||
"hook",
|
||||
"length",
|
||||
"name",
|
||||
"new_body",
|
||||
"participants",
|
||||
"position",
|
||||
"thickness",
|
||||
"touching",
|
||||
"width",
|
||||
],
|
||||
);
|
||||
assert_keys(
|
||||
input_schema_for::<RestParams>(),
|
||||
&[
|
||||
"along",
|
||||
"body",
|
||||
"body_name",
|
||||
"body_names",
|
||||
"direction",
|
||||
"draft",
|
||||
"height",
|
||||
"length",
|
||||
"name",
|
||||
"participants",
|
||||
"position",
|
||||
"thickness",
|
||||
"touching",
|
||||
"width",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// #12622: calls to the plastic features that fail today get the same message in strict. They
|
||||
/// fail before the feature is built, so the shared helper's sketch, which has no body, does for
|
||||
/// them. A required key left out is the exception the helper allows: strict says it is required.
|
||||
#[test]
|
||||
fn strict_keeps_the_features_errors() {
|
||||
let up = |p: Value| with(&p, "direction", json!([0, 0, 1]));
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("plastic.boss", with(&boss(), "position", json!(null))),
|
||||
("plastic.boss", with(&boss(), "position", json!([20, 20]))),
|
||||
("plastic.boss", with(&boss(), "position", json!("top"))),
|
||||
("plastic.boss", with(&boss(), "direction", json!([0, 0, 0]))),
|
||||
("plastic.boss", with(&boss(), "direction", json!(null))),
|
||||
("plastic.boss", with(&boss(), "direction", json!("up"))),
|
||||
// No direction, and no planar face at the point to stand out of.
|
||||
("plastic.boss", boss()),
|
||||
("plastic.boss", with(&up(boss()), "diameter", json!(null))),
|
||||
("plastic.boss", with(&up(boss()), "diameter", json!(true))),
|
||||
("plastic.boss", with(&up(boss()), "diameter", json!(" "))),
|
||||
("plastic.boss", with(&up(boss()), "diameter", json!(1e9))),
|
||||
("plastic.boss", with(&up(boss()), "diameter", json!("1".repeat(4097)))),
|
||||
("plastic.boss", with(&up(boss()), "height", json!("nope + 1"))),
|
||||
("plastic.boss", with(&up(boss()), "hole_diameter", json!("nope"))),
|
||||
("plastic.boss", with(&up(boss()), "ribs", json!("2 mm"))),
|
||||
("plastic.boss", with(&up(boss()), "draft", json!("2 mm"))),
|
||||
("plastic.lip", with(&lip(), "face", json!(null))),
|
||||
("plastic.lip", with(&lip(), "face", json!([1, 25]))),
|
||||
("plastic.lip", with(&lip(), "width", json!(null))),
|
||||
("plastic.lip", with(&lip(), "height", json!([]))),
|
||||
("plastic.lip", with(&lip(), "type", json!("tongue"))),
|
||||
("plastic.lip", with(&lip(), "type", json!("TONGUE"))),
|
||||
("plastic.lip", with(&lip(), "side", json!("left"))),
|
||||
("plastic.snap_fit", with(&up(snap_fit()), "hook", json!(null))),
|
||||
("plastic.snap_fit", with(&up(snap_fit()), "hook", json!([0, 0, 0]))),
|
||||
("plastic.snap_fit", with(&up(snap_fit()), "hook", json!("x"))),
|
||||
// A hook along the arm.
|
||||
("plastic.snap_fit", with(&up(snap_fit()), "hook", json!([0, 0, 1]))),
|
||||
("plastic.snap_fit", with(&up(snap_fit()), "catch_length", json!(true))),
|
||||
("plastic.rest", with(&up(rest()), "along", json!(null))),
|
||||
("plastic.rest", with(&up(rest()), "along", json!([0, 0, 0]))),
|
||||
// `along` along the rest's direction, not across the face.
|
||||
("plastic.rest", with(&up(rest()), "along", json!([0, 0, 2]))),
|
||||
("plastic.rest", with(&up(rest()), "width", json!(false))),
|
||||
]);
|
||||
// `add_feature` refuses an unknown `touching` without the command's name, as it does for
|
||||
// every feature; strict gives the same words after the command's name.
|
||||
let p = with(&up(boss()), "touching", json!("glued"));
|
||||
let error = |level: ParamCheck| Session { param_check: level, ..Session::default() }.execute("plastic.boss", &p).err().map(|e| e.to_string());
|
||||
let off = error(ParamCheck::Off).unwrap_or_default();
|
||||
assert_eq!(off, "`touching` must be separate or one_body");
|
||||
assert_eq!(error(ParamCheck::Strict), Some(format!("invalid parameters for `plastic.boss`: {off}")));
|
||||
}
|
||||
|
||||
/// #12622: optional keys of the wrong type that the features ignore today (the feature is then
|
||||
/// built without them) are refused by the contracts: in Warn the call still works, with a
|
||||
/// warning naming the key.
|
||||
#[test]
|
||||
fn the_feature_contracts_refuse_what_the_handlers_ignore() {
|
||||
for (id, p, key) in [
|
||||
("plastic.boss", with(&boss(), "hole_diameter", json!(true)), "hole_diameter"),
|
||||
("plastic.boss", with(&boss(), "draft", json!("")), "draft"),
|
||||
("plastic.boss", with(&boss(), "ribs", json!([])), "ribs"),
|
||||
("plastic.boss", with(&boss(), "fillet", json!(1e9)), "fillet"),
|
||||
("plastic.boss", with(&boss(), "body", json!(5)), "body"),
|
||||
("plastic.lip", with(&lip(), "type", json!(5)), "type"),
|
||||
("plastic.lip", with(&lip(), "side", json!(true)), "side"),
|
||||
("plastic.lip", with(&lip(), "outside", json!("yes")), "outside"),
|
||||
("plastic.lip", with(&lip(), "gap", json!({})), "gap"),
|
||||
("plastic.snap_fit", with(&snap_fit(), "new_body", json!("no")), "new_body"),
|
||||
("plastic.snap_fit", with(&snap_fit(), "base_height", json!(false)), "base_height"),
|
||||
("plastic.rest", with(&rest(), "thickness", json!(true)), "thickness"),
|
||||
("plastic.rest", with(&rest(), "length", json!("")), "length"),
|
||||
] {
|
||||
let fixture: fn(ParamCheck) -> Session = if id == "plastic.lip" { shelled } else { plate };
|
||||
assert!(fixture(ParamCheck::Off).execute(id, &p).is_ok(), "{p} works today");
|
||||
let warned = fixture(ParamCheck::Warn).execute(id, &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(warned["param_warnings"].to_string().contains(key), "{warned}");
|
||||
let error = fixture(ParamCheck::Strict).execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains(&format!("`{key}`")), "{p}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12622: the feature schemas take what the handlers take. Each call is checked against both
|
||||
/// Boss's schema and its handler on the plate, with the check off, so the two can't drift apart.
|
||||
/// Lip's, Snap Fit's and Rest's schemas are checked on their own keys.
|
||||
#[test]
|
||||
fn the_feature_schemas_take_what_the_handlers_take() {
|
||||
let schema = input_schema_for::<BossParams>();
|
||||
let schema = schema.as_value();
|
||||
for p in [
|
||||
boss(),
|
||||
with(&boss(), "position", json!({"x": 20, "y": 20, "z": 4})),
|
||||
with(&boss(), "position", json!([20, 20])),
|
||||
with(&boss(), "direction", json!([0, 0, 1])),
|
||||
with(&boss(), "diameter", json!("6 mm")),
|
||||
with(&boss(), "diameter", json!("")),
|
||||
with(&boss(), "diameter", json!(1e9)),
|
||||
with(&boss(), "hole_diameter", json!(2)),
|
||||
with(&boss(), "ribs", json!(4)),
|
||||
with(&boss(), "ribs", json!("4")),
|
||||
with(&boss(), "draft", json!("1 deg")),
|
||||
with(&boss(), "touching", json!("one_body")),
|
||||
with(&boss(), "touching", json!("glued")),
|
||||
with(&boss(), "body", json!("Body1")),
|
||||
with(&boss(), "name", json!("Post")),
|
||||
] {
|
||||
let takes = plate(ParamCheck::Off).execute("plastic.boss", &p).is_ok();
|
||||
assert_eq!(conforms(schema, schema, &p), takes, "{p}");
|
||||
}
|
||||
for (p, takes) in [
|
||||
(lip(), true),
|
||||
(with(&lip(), "type", json!("groove")), true),
|
||||
(with(&lip(), "side", json!("outside")), true),
|
||||
(with(&lip(), "outside", json!(true)), true),
|
||||
(with(&lip(), "type", json!("tongue")), false),
|
||||
(with(&lip(), "outside", json!("yes")), false),
|
||||
] {
|
||||
let schema = input_schema_for::<LipParams>();
|
||||
assert_eq!(conforms(schema.as_value(), schema.as_value(), &p), takes, "{p}");
|
||||
}
|
||||
for (p, takes) in [
|
||||
(snap_fit(), true),
|
||||
(with(&snap_fit(), "new_body", json!(true)), true),
|
||||
(with(&snap_fit(), "hook", json!(null)), false),
|
||||
(with(&snap_fit(), "base_height", json!(true)), false),
|
||||
] {
|
||||
let schema = input_schema_for::<SnapFitParams>();
|
||||
assert_eq!(conforms(schema.as_value(), schema.as_value(), &p), takes, "{p}");
|
||||
}
|
||||
for (p, takes) in [
|
||||
(rest(), true),
|
||||
(with(&with(&rest(), "length", json!(10)), "along", json!([1, 0, 0])), true),
|
||||
(with(&rest(), "along", json!(null)), false),
|
||||
(with(&rest(), "thickness", json!(true)), false),
|
||||
] {
|
||||
let schema = input_schema_for::<RestParams>();
|
||||
assert_eq!(conforms(schema.as_value(), schema.as_value(), &p), takes, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12622: each plastic feature replies as a feature command does, in Warn without warnings: a
|
||||
/// boss with a hole and ribs, a lip and a groove (its type in capitals, as the handler reads it),
|
||||
/// a snap fit as a body of its own, and a rectangular hollow rest. A misspelt key is ignored
|
||||
/// today: Warn says so, and strict refuses it.
|
||||
#[test]
|
||||
fn plastic_features_reply_with_their_schemas() {
|
||||
let schema = output_schema_for::<FeatureResult>();
|
||||
let schema = schema.as_value();
|
||||
let check = |s: &mut Session, id: &str, p: Value| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
r
|
||||
};
|
||||
let mut s = plate(ParamCheck::Warn);
|
||||
let r =
|
||||
check(&mut s, "plastic.boss", json!({"position": [10, 10, 4], "diameter": 6, "height": 5, "hole_diameter": 2, "ribs": 4, "name": "Post"}));
|
||||
assert_eq!((&r["name"], &r["changed_bodies"]), (&json!("Post"), &json!(["Body1"])), "{r}");
|
||||
let r = check(&mut s, "plastic.snap_fit", with(&with(&snap_fit(), "position", json!([30, 30, 4])), "new_body", json!(true)));
|
||||
assert_eq!(r["created_bodies"].as_array().map(Vec::len), Some(1), "a body of its own: {r}");
|
||||
check(&mut s, "plastic.rest", json!({"position": [30, 10, 4], "width": 6, "length": 8, "along": [1, 0, 0], "height": 2, "thickness": 1}));
|
||||
let mut s = shelled(ParamCheck::Warn);
|
||||
check(&mut s, "plastic.lip", lip());
|
||||
let mut s = shelled(ParamCheck::Warn);
|
||||
check(&mut s, "plastic.lip", with(&with(&lip(), "type", json!("GROOVE")), "side", json!("Outside")));
|
||||
// A misspelt key.
|
||||
let p = with(&boss(), "hole_dia", json!(2));
|
||||
let r = plate(ParamCheck::Warn).execute("plastic.boss", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(r["param_warnings"].to_string().contains("hole_dia"), "{r}");
|
||||
let error = plate(ParamCheck::Strict).execute("plastic.boss", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`hole_dia`"), "{error}");
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ pub struct ApplyParams {
|
||||
|
||||
/// A setup's name as `render.settings.apply` reads it: `null` is left out, other text than a
|
||||
/// string gets serde's message, and a string must be a valid setup name.
|
||||
fn setup_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
pub(super) fn setup_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
let Some(name) = Option::<String>::deserialize(Value::deserialize(d)?).map_err(|e| D::Error::custom(whole(e)))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -202,7 +202,7 @@ fn setup_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Erro
|
||||
/// The pattern says "not blank"; trimmed, at most 128 bytes and free of control characters,
|
||||
/// which JSON Schema can't count or class portably, are in the field's description and
|
||||
/// checked by [`setup_name`].
|
||||
fn setup_name_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
pub(super) fn setup_name_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
|
||||
@@ -11,6 +11,8 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use solidcraft_doc::render_resources::HdrId;
|
||||
|
||||
use super::render_settings::{SETUP_FIELDS, setup_patch_schema};
|
||||
|
||||
fn one() -> f64 {
|
||||
1.0
|
||||
}
|
||||
@@ -24,42 +26,14 @@ fn digest_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
})
|
||||
}
|
||||
|
||||
/// The fields of a render setup, as `render.settings.get` gives them. A `set` patch may give up
|
||||
/// to 13 of them; `patched_settings` reads the patched setup as a `RenderSetup`, which refuses any
|
||||
/// other key.
|
||||
const SETUP_FIELDS: &[&str] = &[
|
||||
"backend",
|
||||
"mode",
|
||||
"camera",
|
||||
"width",
|
||||
"height",
|
||||
"format",
|
||||
"exposure_ev",
|
||||
"environment_rotation",
|
||||
"environment_intensity",
|
||||
"background",
|
||||
"job",
|
||||
"surfaces",
|
||||
"optics",
|
||||
"stage",
|
||||
];
|
||||
|
||||
/// Each setup field, without its own schema: `patched_settings` checks the values.
|
||||
fn setup_properties() -> serde_json::Map<String, Value> {
|
||||
SETUP_FIELDS.iter().map(|field| (field.to_string(), Value::Object(serde_json::Map::new()))).collect()
|
||||
}
|
||||
|
||||
/// `set`: changes to the setup, as `render.settings.set` takes them. `patched_settings` in
|
||||
/// `cmd/render.rs` refuses anything but an object of at most 13 setup fields, with its own
|
||||
/// message.
|
||||
/// `cmd/render.rs` refuses anything but an object of at most [`SETUP_FIELDS`] setup fields, with
|
||||
/// its own message; the list is shared with Save Render Settings' schema, so the two can't drift.
|
||||
fn set_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "Changes to the setup the HDR is saved into, as `render.settings.set` takes them: at most 13 of the setup's fields. The setup they make (a saved one keeps the fields left out) must have a `job` with method `cpu_raytrace` or `gpu_path`, without an `hdr` or an `environment`.",
|
||||
"type": "object",
|
||||
"properties": setup_properties(),
|
||||
"additionalProperties": false,
|
||||
"maxProperties": 13
|
||||
})
|
||||
setup_patch_schema(&format!(
|
||||
"Changes to the setup the HDR is saved into, as `render.settings.set` takes them: at most {} of the setup's fields. The setup they make (a saved one keeps the fields left out) must have a `job` with method `cpu_raytrace` or `gpu_path`, without an `hdr` or an `environment`.",
|
||||
SETUP_FIELDS.len()
|
||||
))
|
||||
}
|
||||
|
||||
/// Read an HDR file and check it: its size in pixels, its byte count, its SHA-256 and the colour
|
||||
|
||||
@@ -120,17 +120,24 @@ fn hdr_commands_reply_with_their_schemas() {
|
||||
|
||||
/// `set` names the render setup's fields: `patched_settings` reads the patched setup as a
|
||||
/// `RenderSetup`, which refuses any other key, so the schema refuses a misspelt one too (and
|
||||
/// more than 13 at once).
|
||||
/// more than `SETUP_FIELDS` at once). `coats` is one of them (#12783).
|
||||
#[test]
|
||||
fn the_set_schema_names_the_setup_fields() {
|
||||
let root = input_schema_for::<HdrCaptureParams>();
|
||||
let root = root.as_value();
|
||||
let set = &root["properties"]["set"];
|
||||
for (patch, takes) in
|
||||
[(json!({}), true), (json!({"width": 640, "height": 480, "job": {"method": "gpu_path"}}), true), (json!({"widht": 640}), false)]
|
||||
{
|
||||
for (patch, takes) in [
|
||||
(json!({}), true),
|
||||
(json!({"width": 640, "height": 480, "job": {"method": "gpu_path"}}), true),
|
||||
(json!({"coats": {}}), true),
|
||||
(json!({"widht": 640}), false),
|
||||
] {
|
||||
assert_eq!(conforms(root, set, &patch), takes, "{patch}");
|
||||
}
|
||||
// `conforms` doesn't read `maxProperties`, so the limit is pinned here.
|
||||
assert_eq!((set["maxProperties"].as_u64(), set["properties"].as_object().map(|p| p.len())), (Some(13), Some(super::SETUP_FIELDS.len())), "{set}");
|
||||
assert_eq!(
|
||||
(set["maxProperties"].as_u64(), set["properties"].as_object().map(|p| p.len())),
|
||||
(Some(crate::schema::render_settings::SETUP_FIELDS.len() as u64), Some(crate::schema::render_settings::SETUP_FIELDS.len())),
|
||||
"{set}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,29 @@
|
||||
//! keeps the two readers it has: the physical profiles' takes `availability: null`, the other
|
||||
//! catalogs' doesn't. Its type takes what either takes, and its schema says which `kind` takes
|
||||
//! which (`availability_by_kind`). Like the handlers, a contract refuses unknown keys.
|
||||
//!
|
||||
//! The apply and prepare commands (`render.library.appearance.apply`, `.environment.apply`,
|
||||
//! `.physical.prepare`, `.studio.apply`, `.studio.prepare` and `.surface.prepare`, #12552) keep
|
||||
//! the readers they have in `cmd/render/library.rs` and `cmd/render/physical_library.rs`: each
|
||||
//! reads the raw parameters into a private struct. Their parameters types here read each key
|
||||
//! as that struct does and fail with its message, whole (see `reads!`), so strict refuses a
|
||||
//! call exactly as the handler does. A key the handler refuses when it is left out is optional
|
||||
//! in the type and required in the schema, so the handler keeps its message. The handlers read
|
||||
//! every key in every call, whatever the other keys are, so the types do too.
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::de::{DeserializeOwned, Error as _};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use solidcraft_doc::physical_material::ValidatedCard;
|
||||
use solidcraft_doc::render_resources::HdrId;
|
||||
use solidcraft_doc::render_settings::RenderSetup;
|
||||
use solidcraft_render::job::StudioLighting;
|
||||
use solidcraft_render::job::textures::PlanarFrame;
|
||||
use solidcraft_render::stage::Stage;
|
||||
|
||||
use super::appearance::AppearanceAssignResult;
|
||||
use super::check::whole;
|
||||
|
||||
/// Every catalog `render.library.list` and `render.library.describe` read.
|
||||
const KINDS: &[&str] = &[
|
||||
@@ -391,6 +410,874 @@ pub struct LibraryPreview {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A key's value as its handler's struct reads it: `T` is the type that struct gives the key, and
|
||||
/// anything it refuses gets serde's own message, whole (no key put in front), as the handler
|
||||
/// words it.
|
||||
fn read_value<'de, D: Deserializer<'de>, T: DeserializeOwned>(d: D) -> Result<T, D::Error> {
|
||||
serde_json::from_value(Value::deserialize(d)?).map_err(|e| D::Error::custom(whole(e)))
|
||||
}
|
||||
|
||||
/// A deserializer for a key the handler reads as a `$ty`: a value `null` or of another type is
|
||||
/// refused with the handler's message.
|
||||
macro_rules! reads {
|
||||
($name:ident -> $ty:ty) => {
|
||||
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
|
||||
read_value::<D, $ty>(d).map(Some)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// A deserializer for a key the handler reads as an `Option<$ty>`, which takes `null`.
|
||||
macro_rules! reads_option {
|
||||
($name:ident -> $ty:ty) => {
|
||||
fn $name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<$ty>, D::Error> {
|
||||
read_value::<D, Option<$ty>>(d)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
reads!(read_string -> String);
|
||||
reads!(read_bool -> bool);
|
||||
reads!(read_f64 -> f64);
|
||||
reads!(read_u32 -> u32);
|
||||
reads!(read_usize -> usize);
|
||||
reads!(read_strings -> Vec<String>);
|
||||
reads!(read_point -> [f64; 3]);
|
||||
reads!(read_plane -> Plane);
|
||||
reads!(read_rig -> RigInput);
|
||||
reads_option!(read_origin -> [f64; 3]);
|
||||
reads_option!(read_name -> String);
|
||||
reads_option!(read_revision -> u64);
|
||||
|
||||
/// The ids the handlers look up: 1 to 128 ASCII letters, digits, hyphens or underscores.
|
||||
const ID_PATTERN: &str = "^[A-Za-z0-9_-]{1,128}$";
|
||||
|
||||
/// The fields of a render setup a `set` patch may name, as `render.settings.get` gives them.
|
||||
/// `patched_settings` reads the patched setup as a `RenderSetup`, which refuses any other key.
|
||||
const SETUP_FIELDS: &[&str] = &[
|
||||
"backend",
|
||||
"mode",
|
||||
"camera",
|
||||
"width",
|
||||
"height",
|
||||
"format",
|
||||
"exposure_ev",
|
||||
"environment_rotation",
|
||||
"environment_intensity",
|
||||
"background",
|
||||
"job",
|
||||
"surfaces",
|
||||
"optics",
|
||||
"coats",
|
||||
"stage",
|
||||
];
|
||||
|
||||
/// `set`: changes to the setup, which `patched_settings` in `cmd/render.rs` refuses unless it is
|
||||
/// an object of at most 15 fields, with its own message. The type takes any value, so the
|
||||
/// handler keeps that message; the schema says what it takes.
|
||||
fn set_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let properties: Map<String, Value> = SETUP_FIELDS.iter().map(|field| (field.to_string(), Value::Object(Map::new()))).collect();
|
||||
json_schema!({
|
||||
"description": "Changes to the render setup before the command works on it, as `render.settings.set` takes them: an object of at most 15 of the setup's fields. Default: none.",
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"additionalProperties": false,
|
||||
"maxProperties": 15,
|
||||
"default": {}
|
||||
})
|
||||
}
|
||||
|
||||
fn id_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string", "pattern": ID_PATTERN})
|
||||
}
|
||||
|
||||
fn finite_vector_schema(maximum: f64) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "array",
|
||||
"items": {"type": "number", "minimum": -maximum, "maximum": maximum},
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
})
|
||||
}
|
||||
|
||||
fn require_all(schema: &mut Schema, keys: &[&str]) {
|
||||
for key in keys {
|
||||
super::motion::require(schema, key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a library surface's or a Selene appearance's original scalar look (colour, opacity and
|
||||
/// PBR response) to bodies, through the ordinary appearance assignment, in one undo step. The
|
||||
/// physical properties of the bodies are kept. Spatial maps and advanced target effects are
|
||||
/// left out.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = appearance_required)]
|
||||
pub struct AppearanceApplyParams {
|
||||
// `id`, `variant` and `bodies` are optional in the type and required in the schema
|
||||
// (`appearance_required`): left out, the handler refuses them with its own message.
|
||||
/// The appearance: a surface (`SURF-…`) or a Selene appearance (`SELENE-AP-…`), as
|
||||
/// `render.library.list` gives its id.
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "id_schema")]
|
||||
pub id: Option<String>,
|
||||
/// Always `uniform_opaque`: the original scalar look as one opaque colour.
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "uniform_opaque_schema")]
|
||||
pub variant: Option<String>,
|
||||
/// The bodies to give the look, by exact name: 1 to 256, each 1 to 1024 bytes without
|
||||
/// control characters.
|
||||
#[serde(default, deserialize_with = "read_strings", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "target_bodies_schema")]
|
||||
pub bodies: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn appearance_required(schema: &mut Schema) {
|
||||
require_all(schema, &["id", "variant", "bodies"]);
|
||||
}
|
||||
|
||||
fn uniform_opaque_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string", "enum": ["uniform_opaque"]})
|
||||
}
|
||||
|
||||
fn target_bodies_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "array", "items": {"type": "string", "minLength": 1, "maxLength": 1024}, "minItems": 1, "maxItems": 256})
|
||||
}
|
||||
|
||||
/// What `render.library.appearance.apply` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AppearanceApplied {
|
||||
/// The appearance's id.
|
||||
pub id: String,
|
||||
/// Always `uniform_opaque`.
|
||||
pub variant: String,
|
||||
/// What the library's description of the appearance leaves out of this look.
|
||||
#[schemars(schema_with = "omitted_schema")]
|
||||
pub omitted: Value,
|
||||
/// The look as it was assigned.
|
||||
pub appearance: LibraryLook,
|
||||
/// What `appearance.assign` reports for the bodies.
|
||||
#[schemars(with = "AppearanceAssignResult")]
|
||||
pub assignment: Value,
|
||||
/// Always true: the bodies' materials and physical properties are as they were.
|
||||
pub engineering_properties_unchanged: bool,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A library appearance's look, as `render.library.appearance.apply` hands it to
|
||||
/// `appearance.assign`. The library gives the values, so they are kept as it gives them.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct LibraryLook {
|
||||
/// The appearance's id and name, at most 128 characters.
|
||||
pub name: String,
|
||||
/// A surface's colour is `[r, g, b]` (0 to 255), a Selene appearance's `"#rrggbb"`.
|
||||
#[schemars(schema_with = "look_color_schema")]
|
||||
pub color: Value,
|
||||
/// Always 1: the look is opaque.
|
||||
#[schemars(schema_with = "look_number_schema")]
|
||||
pub opacity: Value,
|
||||
/// The PBR response.
|
||||
#[schemars(schema_with = "look_pbr_schema")]
|
||||
pub pbr: Value,
|
||||
/// The bodies it was given to.
|
||||
pub bodies: Vec<String>,
|
||||
}
|
||||
|
||||
fn omitted_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "What the library's description of the appearance leaves out of this look.", "type": "array", "items": {"type": "string"}})
|
||||
}
|
||||
|
||||
fn look_number_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "number"})
|
||||
}
|
||||
|
||||
fn look_color_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"anyOf": [
|
||||
{"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": 255}, "minItems": 3, "maxItems": 3},
|
||||
{"type": "string"}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn look_pbr_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"metallic": {"description": "0 (a dielectric) to 1 (a metal).", "type": "number"},
|
||||
"roughness": {"description": "0 (smooth) to 1 (rough).", "type": "number"},
|
||||
"dielectric_ior": {"description": "The refractive index of its dielectric part.", "type": "number"}
|
||||
},
|
||||
"required": ["metallic", "roughness", "dielectric_ior"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Save a built-in HDR environment (a library `HDR-…`) in a CPU raytrace or GPU path setup, in
|
||||
/// one undo step. The image's original bytes are checked against their hash and kept in the
|
||||
/// design, so it reopens without the library. The setup's existing environment is replaced only
|
||||
/// with `replace_environment`. An HDR that carries the sun cannot be used beside an analytic sun.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = environment_required)]
|
||||
pub struct EnvironmentApplyParams {
|
||||
// `id` and `name` are optional in the type and required in the schema
|
||||
// (`environment_required`), as above.
|
||||
/// The HDR, as `render.library.list` gives its id (`HDR-…`).
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "id_schema")]
|
||||
pub id: Option<String>,
|
||||
/// The render setup, saved or new: trimmed, 1 to 128 bytes, without control characters.
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub name: Option<String>,
|
||||
/// Changes to the setup before the HDR is added, as `render.settings.set` takes them: an
|
||||
/// object of at most 15 of the setup's fields. The setup must render with the CPU raytracer
|
||||
/// or the GPU path tracer. Default: none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "set_schema")]
|
||||
pub set: Option<Value>,
|
||||
/// The image's rotation as authored, in radians: finite, within ±1000000. The reply gives the
|
||||
/// rotation the renderers use, which has the image's origin offset by π. Default: 0.
|
||||
#[serde(default, deserialize_with = "read_f64", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "f64", range(min = -1_000_000.0, max = 1_000_000.0), default = "zero")]
|
||||
pub rotation_rad: Option<f64>,
|
||||
/// The environment's brightness, linear: 0 to 64 for a CPU setup, 0 to 100 for a GPU one.
|
||||
/// Default: 1.
|
||||
#[serde(default, deserialize_with = "read_f64", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "f64", range(min = 0.0, max = 100.0), default = "one")]
|
||||
pub intensity: Option<f64>,
|
||||
/// A CPU setup's samples per lighting estimate: 1, 4, 16 or 64. A GPU setup reads it and
|
||||
/// ignores it. Default: 16.
|
||||
#[serde(default, deserialize_with = "read_u32", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "u32", default = "sixteen")]
|
||||
pub samples: Option<u32>,
|
||||
/// Replace an environment the setup already has: an HDR, a sky or a constant one. Without
|
||||
/// it, such a setup is refused. Default: false.
|
||||
#[serde(default, deserialize_with = "read_bool", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool", default = "no")]
|
||||
pub replace_environment: Option<bool>,
|
||||
}
|
||||
|
||||
// The schemas' defaults, as the field types give them (the types are optional so that the
|
||||
// handler, not the check, fills in a key left out).
|
||||
fn zero() -> Option<f64> {
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn one() -> Option<f64> {
|
||||
Some(1.0)
|
||||
}
|
||||
|
||||
fn sixteen() -> Option<u32> {
|
||||
Some(16)
|
||||
}
|
||||
|
||||
fn no() -> Option<bool> {
|
||||
Some(false)
|
||||
}
|
||||
|
||||
fn xy() -> Option<Plane> {
|
||||
Some(Plane::Xy)
|
||||
}
|
||||
|
||||
fn environment_required(schema: &mut Schema) {
|
||||
require_all(schema, &["id", "name"]);
|
||||
}
|
||||
|
||||
/// What `render.library.environment.apply` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct EnvironmentApplied {
|
||||
/// The HDR's id.
|
||||
pub id: String,
|
||||
/// The render setup.
|
||||
pub name: String,
|
||||
/// The setup as saved, as the render settings commands give it.
|
||||
#[schemars(schema_with = "setup_reply_schema")]
|
||||
pub setup: RenderSetup,
|
||||
/// The image's SHA-256 in hex, the id the design keeps its bytes under.
|
||||
#[schemars(with = "String")]
|
||||
pub resource: HdrId,
|
||||
/// The rotation as given, in radians.
|
||||
pub author_rotation_rad: f64,
|
||||
/// The rotation the setup keeps: the authored one plus `orientation_offset_rad`, within 0 to
|
||||
/// 2π.
|
||||
pub native_rotation_rad: f64,
|
||||
/// Always π: where the image's origin sits in the renderers.
|
||||
pub orientation_offset_rad: f64,
|
||||
/// Always true: the design keeps the image's bytes.
|
||||
pub self_contained: bool,
|
||||
/// The SHA-256 the library records for the image, in hex: the same as `resource`.
|
||||
#[schemars(schema_with = "hash_schema")]
|
||||
pub source_sha256: Value,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn hash_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string"})
|
||||
}
|
||||
|
||||
/// A render setup as the render settings commands give it.
|
||||
fn setup_reply_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A render setup, as the render settings commands give it.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"backend": {"type": "string"},
|
||||
"mode": {"type": "string"},
|
||||
"camera": {"type": "object"},
|
||||
"width": {"type": "integer", "minimum": 0},
|
||||
"height": {"type": "integer", "minimum": 0},
|
||||
"format": {"type": "string"},
|
||||
"exposure_ev": {"type": "number"},
|
||||
"environment_rotation": {"type": "number"},
|
||||
"environment_intensity": {"type": "number"},
|
||||
"background": {"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": 255}, "minItems": 3, "maxItems": 3},
|
||||
"job": {"type": "object"},
|
||||
"surfaces": {},
|
||||
"optics": {},
|
||||
"coats": {},
|
||||
"stage": {"type": "array"}
|
||||
},
|
||||
"required": ["backend", "mode", "camera", "width", "height", "format", "exposure_ev", "environment_rotation", "environment_intensity", "background"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Plan a material from a built-in physical reference profile, without making it: a
|
||||
/// `material.create` call that carries the profile's density and its native card, the receipts
|
||||
/// of the sources it came from, and what the card leaves out. Nothing is created or assigned.
|
||||
/// A profile with no single supported density is refused: nothing is guessed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = physical_required)]
|
||||
pub struct PhysicalPrepareParams {
|
||||
// All three are optional in the type and required in the schema (`physical_required`).
|
||||
/// The physical profile, as `render.library.list` gives it with `kind` `physical_profiles`.
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "id_schema")]
|
||||
pub id: Option<String>,
|
||||
/// The name for the material: trimmed, 1 to 128 bytes, without control characters.
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "material_name_schema")]
|
||||
pub name: Option<String>,
|
||||
/// Must be true: the profile is a reference to review, not a grade to rely on.
|
||||
#[serde(default, deserialize_with = "read_bool", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "acknowledge_schema")]
|
||||
pub acknowledge_reference: Option<bool>,
|
||||
}
|
||||
|
||||
fn physical_required(schema: &mut Schema) {
|
||||
require_all(schema, &["id", "name", "acknowledge_reference"]);
|
||||
}
|
||||
|
||||
fn material_name_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string", "minLength": 1, "maxLength": 128})
|
||||
}
|
||||
|
||||
fn acknowledge_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "boolean", "const": true})
|
||||
}
|
||||
|
||||
/// What `render.library.physical.prepare` returns: a plan, not a change.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PhysicalPlan {
|
||||
/// Always `plan_only_not_executed`.
|
||||
pub status: String,
|
||||
/// The profile's id.
|
||||
pub profile_id: String,
|
||||
/// The SHA-256 digests of the profiles and cards read.
|
||||
#[schemars(with = "ProfileReceipts")]
|
||||
pub receipts: Value,
|
||||
/// The native card's check.
|
||||
pub native_validation: Value,
|
||||
/// The profile's properties the card leaves out, each with why.
|
||||
pub not_projected: Value,
|
||||
/// The profile as its catalog holds it, unchanged.
|
||||
#[schemars(with = "Map<String, Value>")]
|
||||
pub extended_profile: Value,
|
||||
/// Always false.
|
||||
pub document_changed: bool,
|
||||
/// Always empty: no body is assigned.
|
||||
pub assigned_bodies: Vec<String>,
|
||||
/// The `material.create` call that would make the material.
|
||||
pub command: PlannedCommand,
|
||||
/// What to do before running it.
|
||||
pub requirements: Vec<String>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A command to run, not run yet.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PlannedCommand {
|
||||
/// Always `material.create`.
|
||||
pub command: String,
|
||||
pub params: PlannedMaterial,
|
||||
}
|
||||
|
||||
/// `material.create`'s parameters, as planned.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PlannedMaterial {
|
||||
/// The name asked for.
|
||||
pub name: String,
|
||||
/// The profile's density, in g/cm³.
|
||||
pub density_g_cm3: f64,
|
||||
/// The profile's native physical material card.
|
||||
#[schemars(schema_with = "physical_card_schema")]
|
||||
pub physical_card: ValidatedCard,
|
||||
}
|
||||
|
||||
fn physical_card_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A physical material card: the designation, family, sourced properties and sources.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema_version": {"type": "integer"},
|
||||
"designation": {"type": "string"},
|
||||
"family": {"type": "string"},
|
||||
"properties": {"type": "array", "items": {"type": "object"}},
|
||||
"sources": {"type": "array", "items": {"type": "object"}}
|
||||
},
|
||||
"required": ["schema_version", "designation", "family", "properties", "sources"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Which plane a surface's maps lie in.
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Plane {
|
||||
/// The XY plane, repeating along X and Y.
|
||||
#[default]
|
||||
Xy,
|
||||
/// The YZ plane.
|
||||
Yz,
|
||||
/// The ZX plane.
|
||||
Zx,
|
||||
}
|
||||
|
||||
/// Prepare a library surface's original maps as a temporary planar binding on a body, for the
|
||||
/// GPU path tracer, and as the source `render.library.surface.bind` saves. Nothing in the design
|
||||
/// changes. Height and displacement maps and true anisotropy are not included. The body must be
|
||||
/// opaque.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = surface_required)]
|
||||
pub struct SurfacePrepareParams {
|
||||
// `id`, `body_index` and `body_name` are optional in the type and required in the schema
|
||||
// (`surface_required`).
|
||||
/// The surface, as `render.library.list` gives its id (`SURF-…`).
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "id_schema")]
|
||||
pub id: Option<String>,
|
||||
/// The body's place in the render scene, as `render.library.targets` gives it, from 0.
|
||||
#[serde(default, deserialize_with = "read_usize", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "usize")]
|
||||
pub body_index: Option<usize>,
|
||||
/// The body's exact name, which must be the one at `body_index`.
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub body_name: Option<String>,
|
||||
/// The point the maps start from, in mm. Default: the centre of the body's bounds.
|
||||
#[serde(default, deserialize_with = "read_origin", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "origin_schema")]
|
||||
pub origin: Option<[f64; 3]>,
|
||||
/// The repeat scale: 0.001 to 1000. Default: 1.
|
||||
#[serde(default, deserialize_with = "read_f64", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "f64", range(min = 0.001, max = 1000.0), default = "one")]
|
||||
pub scale: Option<f64>,
|
||||
/// The plane the maps lie in. Default: xy.
|
||||
#[serde(default, deserialize_with = "read_plane", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Plane", default = "xy")]
|
||||
pub plane: Option<Plane>,
|
||||
/// The grain's rotation about the plane's normal, in radians, positive around the normal:
|
||||
/// finite, within ±1000000. Default: 0.
|
||||
#[serde(default, deserialize_with = "read_f64", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "f64", range(min = -1_000_000.0, max = 1_000_000.0), default = "zero")]
|
||||
pub rotation_rad: Option<f64>,
|
||||
}
|
||||
|
||||
fn surface_required(schema: &mut Schema) {
|
||||
require_all(schema, &["id", "body_index", "body_name"]);
|
||||
}
|
||||
|
||||
fn origin_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = json_schema!({"description": "A point [x, y, z], in mm."});
|
||||
schema.insert("anyOf".into(), serde_json::json!([finite_vector_schema(f64::MAX), {"type": "null"}]));
|
||||
schema
|
||||
}
|
||||
|
||||
/// What `render.library.surface.prepare` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SurfacePrepared {
|
||||
/// The surface's id.
|
||||
pub id: String,
|
||||
/// The design's revision the body was read at.
|
||||
pub revision: u64,
|
||||
/// Always `gpu-path`.
|
||||
pub executor: String,
|
||||
/// Always `gpu_planar_maps`.
|
||||
pub variant: String,
|
||||
/// The binding, as `render.library.surface.bind` and the GPU render take it.
|
||||
pub texture: PreparedTexture,
|
||||
/// The surface's source maps by role (`base_color`, `normal_open_gl`, `roughness`,
|
||||
/// `metallic`, `height`): the file, size, encoding and hashes the library records.
|
||||
#[schemars(schema_with = "source_maps_schema")]
|
||||
pub source_maps: Value,
|
||||
/// What the binding leaves out.
|
||||
pub omitted: Vec<String>,
|
||||
/// Always `captured_body_value`: the body's own refractive index is used.
|
||||
pub dielectric_ior: String,
|
||||
/// Always says the binding is a request's, not saved.
|
||||
pub persistence: String,
|
||||
/// The setups the maps can be saved in: both in a build with the bundled render library, none
|
||||
/// without.
|
||||
pub compatible_saved_methods: Vec<String>,
|
||||
/// What a saved CPU setup does with the maps.
|
||||
pub cpu_maps: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn source_maps_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The surface's source maps by role, each with its file, size, encoding and hashes.",
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "object"}
|
||||
})
|
||||
}
|
||||
|
||||
/// A planar binding of a surface's maps to a body.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct PreparedTexture {
|
||||
/// The body's place in the render scene.
|
||||
pub body_index: usize,
|
||||
/// The body's name.
|
||||
pub body_name: String,
|
||||
/// The maps' origin and the two repeat vectors, in world mm.
|
||||
#[schemars(schema_with = "frame_schema")]
|
||||
pub frame: PlanarFrame,
|
||||
/// Always 1.
|
||||
pub normal_strength: f64,
|
||||
/// Always true: image v increases downward.
|
||||
pub flip_v: bool,
|
||||
/// Always `replace`: the map is the base colour.
|
||||
pub base_color_mode: String,
|
||||
/// The base-colour map, by its built-in address.
|
||||
pub base_color_srgb: String,
|
||||
/// The normal map, OpenGL convention.
|
||||
pub normal_opengl: String,
|
||||
/// The roughness map, linear.
|
||||
pub roughness_linear: String,
|
||||
/// The metallic map, linear.
|
||||
pub metallic_linear: String,
|
||||
}
|
||||
|
||||
fn frame_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let vector = finite_vector_schema(f64::MAX);
|
||||
json_schema!({
|
||||
"description": "The maps' origin and the repeat vectors u and v, in world mm.",
|
||||
"type": "object",
|
||||
"properties": {"origin": vector, "u": vector, "v": vector},
|
||||
"required": ["origin", "u", "v"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
/// A rig as the studio commands take it: which authored rig, how much of it, and where the
|
||||
/// subject is.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RigInput {
|
||||
/// The rig, as `render.library.list` gives its id with `kind` `rigs` (`RIG-…`).
|
||||
#[schemars(schema_with = "id_schema")]
|
||||
pub id: String,
|
||||
/// `complete` for every rig, `emitters_only` for a rig with passive cards, which it leaves
|
||||
/// out.
|
||||
#[schemars(schema_with = "variant_schema")]
|
||||
pub variant: String,
|
||||
/// The subject's centre, in mm: each within ±1e12.
|
||||
#[schemars(schema_with = "centre_schema")]
|
||||
pub center_mm: [f64; 3],
|
||||
/// The subject's largest size, in mm: 0.001 to 1000000. The rig scales to it.
|
||||
#[schemars(range(min = 0.001, max = 1_000_000.0))]
|
||||
pub subject_extent_mm: f64,
|
||||
}
|
||||
|
||||
fn variant_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string", "enum": ["complete", "emitters_only"]})
|
||||
}
|
||||
|
||||
fn centre_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"type": "array",
|
||||
"items": {"type": "number", "minimum": -1e12, "maximum": 1e12},
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert an authored lighting rig to the numbers the renderers use, for a subject: its
|
||||
/// rectangular lights, a sun, and its passive cards as a stage. With `name`, also the setup a
|
||||
/// CPU raytrace would save, and what adopting a sun's indirect lighting needs. Nothing is
|
||||
/// changed. Rigs whose calibration is unresolved or that use an external sun are refused, and a
|
||||
/// light brighter than the CPU takes is refused, not scaled.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = studio_prepare_required)]
|
||||
pub struct StudioPrepareParams {
|
||||
// `id`, `variant`, `center_mm` and `subject_extent_mm` are optional in the type and required
|
||||
// in the schema (`studio_prepare_required`).
|
||||
/// The rig (`RIG-…`).
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "id_schema")]
|
||||
pub id: Option<String>,
|
||||
/// `complete`, or `emitters_only` for a rig with passive cards, which it leaves out.
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "variant_schema")]
|
||||
pub variant: Option<String>,
|
||||
/// The subject's centre, in mm: each within ±1e12.
|
||||
#[serde(default, deserialize_with = "read_point", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "centre_schema")]
|
||||
pub center_mm: Option<[f64; 3]>,
|
||||
/// The subject's largest size, in mm: 0.001 to 1000000.
|
||||
#[serde(default, deserialize_with = "read_f64", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "f64", range(min = 0.001, max = 1_000_000.0))]
|
||||
pub subject_extent_mm: Option<f64>,
|
||||
/// The render setup to work out the CPU projection in, saved or new. Without it, only the
|
||||
/// rig's numbers are returned, and `set` and `enable_indirect` are refused. Default: none.
|
||||
#[serde(default, deserialize_with = "read_name", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "optional_name_schema")]
|
||||
pub name: Option<String>,
|
||||
/// Changes to the setup before the rig is projected, as `render.settings.set` takes them: an
|
||||
/// object of at most 15 of the setup's fields. Only with `name`; the setup must render with
|
||||
/// the CPU raytracer. Default: none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "set_schema")]
|
||||
pub set: Option<Value>,
|
||||
/// Has no effect when `name` is given: the reply's `cpu.indirect_adoption` already shows what
|
||||
/// `render.library.studio.apply` would adopt. Without `name`, `true` is refused (as is a
|
||||
/// non-empty `set`). Default: false.
|
||||
#[serde(default, deserialize_with = "read_bool", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool", default = "no")]
|
||||
pub enable_indirect: Option<bool>,
|
||||
}
|
||||
|
||||
fn studio_prepare_required(schema: &mut Schema) {
|
||||
require_all(schema, &["id", "variant", "center_mm", "subject_extent_mm"]);
|
||||
}
|
||||
|
||||
fn optional_name_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["string", "null"]})
|
||||
}
|
||||
|
||||
/// What `render.library.studio.prepare` returns. With `name`, also the CPU projection.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StudioPrepared {
|
||||
/// The rig's lights as the renderers take them.
|
||||
#[schemars(schema_with = "studio_lighting_schema")]
|
||||
pub lighting: StudioLighting,
|
||||
/// The rig's passive cards as stage panels; empty for `emitters_only` and for a rig without
|
||||
/// cards.
|
||||
#[schemars(schema_with = "stage_schema")]
|
||||
pub stage: Stage,
|
||||
/// What the rig was scaled to.
|
||||
pub projection: RigProjection,
|
||||
/// Always true: the lights' distance from the subject needs a look.
|
||||
pub requires_geometry_clearance_review: bool,
|
||||
/// Always false.
|
||||
pub document_changed: bool,
|
||||
/// With `name`: the CPU projection.
|
||||
#[serde(flatten)]
|
||||
pub cpu: Option<CpuProjection>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a rig becomes in a CPU raytrace setup, given with `name`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CpuProjection {
|
||||
/// The setup the rig would give, not saved.
|
||||
#[schemars(schema_with = "setup_reply_schema")]
|
||||
pub setup: RenderSetup,
|
||||
/// Always `cpu-raytrace`.
|
||||
pub executor: String,
|
||||
/// The design's revision it was read at: pass it as `expected_revision` to apply.
|
||||
pub revision: u64,
|
||||
/// Whether a sun needs indirect lighting the setup lacks.
|
||||
pub requires_indirect: bool,
|
||||
/// What `enable_indirect` adopts; `null` when no sun needs it.
|
||||
pub indirect_adoption: Option<IndirectAdoption>,
|
||||
}
|
||||
|
||||
/// The indirect lighting a CPU sun rig adopts.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct IndirectAdoption {
|
||||
/// Always 8.
|
||||
pub path_depth: u32,
|
||||
/// The progressive settings given when the setup has none.
|
||||
pub progressive_if_absent: ProgressiveDefaults,
|
||||
/// Always 1.
|
||||
pub primary_samples: u32,
|
||||
/// Always true.
|
||||
pub shadows: bool,
|
||||
}
|
||||
|
||||
/// The progressive sampling a CPU setup gets when it has none.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProgressiveDefaults {
|
||||
/// Always 16.
|
||||
pub samples_per_pixel: u32,
|
||||
/// Always 0.
|
||||
pub seed: u32,
|
||||
}
|
||||
|
||||
/// What a rig was scaled to.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RigProjection {
|
||||
/// The rig.
|
||||
pub id: String,
|
||||
/// The variant asked for.
|
||||
pub variant: String,
|
||||
/// The passive cards left out: all of them for `emitters_only`.
|
||||
pub passive_cards_omitted: usize,
|
||||
/// The passive cards in the stage: all of them for `complete`.
|
||||
pub passive_cards_included: usize,
|
||||
/// The rig's size in mm divided by 1000.
|
||||
pub scale: f64,
|
||||
/// Always says the lengths are world millimetres and the radiance is relative linear sRGB.
|
||||
pub physical_units: String,
|
||||
/// Always true: the rig's calibration is as authored.
|
||||
pub reference_calibration_not_remeasured: bool,
|
||||
}
|
||||
|
||||
fn studio_lighting_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let vector = finite_vector_schema(f64::MAX);
|
||||
json_schema!({
|
||||
"description": "Lights as the renderers take them, in world mm: rectangular area lights (a centre, half-edge vectors u and v, and an RGB radiance) and an optional sun.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sun": {
|
||||
"description": "A distant disk; `null` when the rig has none.",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"direction": {"description": "Unit vector toward the sun.", "type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
|
||||
"angular_radius_rad": {"type": "number"},
|
||||
"radiance": vector
|
||||
},
|
||||
"required": ["direction", "angular_radius_rad", "radiance"]
|
||||
},
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"environment": vector,
|
||||
"area_lights": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"center": vector, "u": vector, "v": vector, "radiance": vector},
|
||||
"required": ["center", "u", "v", "radiance"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["sun", "environment", "area_lights"]
|
||||
})
|
||||
}
|
||||
|
||||
fn stage_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let vector = finite_vector_schema(f64::MAX);
|
||||
json_schema!({
|
||||
"description": "Render-only panels: each a rectangle by its centre and half-edge vectors u and v, in world mm, with a linear reflectance.",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"center_mm": vector,
|
||||
"half_u_mm": vector,
|
||||
"half_v_mm": vector,
|
||||
"reflectance_linear": vector
|
||||
},
|
||||
"required": ["id", "center_mm", "half_u_mm", "half_v_mm", "reflectance_linear"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Save an authored lighting rig in a CPU raytrace or GPU path setup for a subject, in one undo
|
||||
/// step: its lights, and its passive cards as the setup's stage. The setup's existing lighting
|
||||
/// and environment go, and with them an existing stage only with `replace_stage`. The subject's
|
||||
/// clearance from the lights is checked against bounding boxes, not exact geometry.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = studio_apply_required)]
|
||||
pub struct StudioApplyParams {
|
||||
// `rig`, `name` and `replace_lighting_and_environment` are optional in the type and required
|
||||
// in the schema (`studio_apply_required`).
|
||||
/// The rig and the subject it is scaled to.
|
||||
#[serde(default, deserialize_with = "read_rig", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "RigInput")]
|
||||
pub rig: Option<RigInput>,
|
||||
/// The render setup, saved or new.
|
||||
#[serde(default, deserialize_with = "read_string", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub name: Option<String>,
|
||||
/// Changes to the setup before the rig is added, as `render.settings.set` takes them: an
|
||||
/// object of at most 15 of the setup's fields. The setup must render with the CPU raytracer
|
||||
/// or the GPU path tracer. Default: none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "set_schema")]
|
||||
pub set: Option<Value>,
|
||||
/// Must be true: the rig replaces the setup's lighting and environment, so that one thing
|
||||
/// owns the energy.
|
||||
#[serde(default, deserialize_with = "read_bool", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "confirm_schema")]
|
||||
pub replace_lighting_and_environment: Option<bool>,
|
||||
/// Replace a stage the setup already has. Without it, such a setup is refused. Default: false.
|
||||
#[serde(default, deserialize_with = "read_bool", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool", default = "no")]
|
||||
pub replace_stage: Option<bool>,
|
||||
/// Adopt the indirect lighting a CPU sun rig needs, as `render.library.studio.prepare`
|
||||
/// reports it. Without it, such a rig is refused. Default: false.
|
||||
#[serde(default, deserialize_with = "read_bool", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool", default = "no")]
|
||||
pub enable_indirect: Option<bool>,
|
||||
/// The design's revision when the caller last read it, as `render.library.studio.prepare`
|
||||
/// gives it. If the design changed since, the call is refused. Default: not checked.
|
||||
#[serde(default, deserialize_with = "read_revision", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "revision_schema")]
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
|
||||
fn studio_apply_required(schema: &mut Schema) {
|
||||
require_all(schema, &["rig", "name", "replace_lighting_and_environment"]);
|
||||
}
|
||||
|
||||
fn confirm_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "boolean", "const": true})
|
||||
}
|
||||
|
||||
fn revision_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["integer", "null"], "minimum": 0})
|
||||
}
|
||||
|
||||
/// What `render.library.studio.apply` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StudioApplied {
|
||||
/// The render setup.
|
||||
pub name: String,
|
||||
/// The setup as saved, as the render settings commands give it.
|
||||
#[schemars(schema_with = "setup_reply_schema")]
|
||||
pub setup: RenderSetup,
|
||||
/// What the rig was scaled to.
|
||||
pub projection: RigProjection,
|
||||
/// Says the clearance was checked against bounding boxes, not exact geometry.
|
||||
pub clearance: String,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "render_library_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
LibraryDescribeParams, LibraryDescription, LibraryListParams, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryTargets,
|
||||
LibraryTargetsParams, RenderTarget, StableRenderTarget,
|
||||
AppearanceApplied, AppearanceApplyParams, EnvironmentApplied, EnvironmentApplyParams, LibraryDescribeParams, LibraryDescription,
|
||||
LibraryListParams, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryTargets, LibraryTargetsParams, PhysicalPlan, PhysicalPrepareParams,
|
||||
RenderTarget, StableRenderTarget, StudioApplied, StudioApplyParams, StudioPrepareParams, StudioPrepared, SurfacePrepareParams, SurfacePrepared,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
@@ -14,6 +15,15 @@ fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<LibraryDescribeParams>(), &["id", "kind"]);
|
||||
assert_keys(input_schema_for::<LibraryTargetsParams>(), &["limit", "offset", "query"]);
|
||||
assert_keys(input_schema_for::<LibraryPreviewParams>(), &["id"]);
|
||||
assert_keys(input_schema_for::<AppearanceApplyParams>(), &["bodies", "id", "variant"]);
|
||||
assert_keys(input_schema_for::<EnvironmentApplyParams>(), &["id", "intensity", "name", "replace_environment", "rotation_rad", "samples", "set"]);
|
||||
assert_keys(input_schema_for::<PhysicalPrepareParams>(), &["acknowledge_reference", "id", "name"]);
|
||||
assert_keys(input_schema_for::<SurfacePrepareParams>(), &["body_index", "body_name", "id", "origin", "plane", "rotation_rad", "scale"]);
|
||||
assert_keys(input_schema_for::<StudioPrepareParams>(), &["center_mm", "enable_indirect", "id", "name", "set", "subject_extent_mm", "variant"]);
|
||||
assert_keys(
|
||||
input_schema_for::<StudioApplyParams>(),
|
||||
&["enable_indirect", "expected_revision", "name", "replace_lighting_and_environment", "replace_stage", "rig", "set"],
|
||||
);
|
||||
}
|
||||
|
||||
/// The list's schema takes an `availability` exactly when the reader for its `kind` does: the
|
||||
@@ -65,6 +75,103 @@ fn strict_keeps_todays_errors() {
|
||||
("render.library.preview", json!({"id": "MAT-001"})),
|
||||
("render.library.preview", json!({"id": ""})),
|
||||
]);
|
||||
// The six apply and prepare commands: the handlers' own messages, for a key left out and for
|
||||
// a value of the wrong type, which the types read as the handlers' structs do.
|
||||
let (rig, set) = (json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 1}), json!({"width": 4}));
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("render.library.appearance.apply", json!({})),
|
||||
("render.library.appearance.apply", json!({"id": "SURF-001"})),
|
||||
("render.library.appearance.apply", json!({"id": 5, "variant": "uniform_opaque", "bodies": ["Target"]})),
|
||||
("render.library.appearance.apply", json!({"id": null, "variant": "uniform_opaque", "bodies": ["Target"]})),
|
||||
("render.library.appearance.apply", json!({"id": "SURF-001", "variant": "uniform_opaque", "bodies": "Target"})),
|
||||
("render.library.appearance.apply", json!({"id": "SURF-001", "variant": "uniform_opaque", "bodies": [7]})),
|
||||
("render.library.appearance.apply", json!({"id": "SURF-001", "variant": "other", "bodies": ["Target"]})),
|
||||
("render.library.appearance.apply", json!({"id": "SURF-001", "variant": "uniform_opaque", "bodies": []})),
|
||||
("render.library.appearance.apply", json!({"id": "SURF-001", "variant": "uniform_opaque", "bodies": ["Nope"]})),
|
||||
("render.library.environment.apply", json!({})),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001"})),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001", "name": 3})),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001", "name": "E", "set": 5})),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001", "name": "E", "set": null})),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001", "name": "E", "rotation_rad": "x"})),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001", "name": "E", "rotation_rad": null})),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001", "name": "E", "intensity": "x"})),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001", "name": "E", "samples": -1})),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001", "name": "E", "samples": 1.5})),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001", "name": "E", "replace_environment": "yes"})),
|
||||
(
|
||||
"render.library.environment.apply",
|
||||
json!({"id": "HDR-001", "name": "E", "set": {"job": {"method": "gpu_path", "options": {}}}, "rotation_rad": 1e9}),
|
||||
),
|
||||
("render.library.environment.apply", json!({"id": "HDR-001", "name": "E", "set": set})),
|
||||
("render.library.physical.prepare", json!({})),
|
||||
("render.library.physical.prepare", json!({"id": "steel-304-plate", "name": "R"})),
|
||||
("render.library.physical.prepare", json!({"id": "steel-304-plate", "name": "R", "acknowledge_reference": false})),
|
||||
("render.library.physical.prepare", json!({"id": "steel-304-plate", "name": "R", "acknowledge_reference": "true"})),
|
||||
("render.library.physical.prepare", json!({"id": "steel-304-plate", "name": "R", "acknowledge_reference": null})),
|
||||
("render.library.physical.prepare", json!({"id": "steel-304-plate", "name": " R", "acknowledge_reference": true})),
|
||||
("render.library.physical.prepare", json!({"id": 1, "name": "R", "acknowledge_reference": true})),
|
||||
("render.library.physical.prepare", json!({"id": "no-such-profile", "name": "R", "acknowledge_reference": true})),
|
||||
("render.library.surface.prepare", json!({})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": "0", "body_name": "Target"})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": -1, "body_name": "Target"})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0.0, "body_name": "Target"})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0, "body_name": "Target", "origin": [1, 2]})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0, "body_name": "Target", "origin": {"x": 1, "y": 2, "z": 3}})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0, "body_name": "Target", "plane": "XY"})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0, "body_name": "Target", "plane": null})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0, "body_name": "Target", "scale": null})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0, "body_name": "Target", "scale": 0})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0, "body_name": "Target", "rotation_rad": null})),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0, "body_name": "Target"})),
|
||||
("render.library.studio.prepare", json!({})),
|
||||
("render.library.studio.prepare", json!({"id": "RIG-002", "variant": "complete"})),
|
||||
("render.library.studio.prepare", json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0], "subject_extent_mm": 1})),
|
||||
("render.library.studio.prepare", json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": "1"})),
|
||||
("render.library.studio.prepare", json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 0})),
|
||||
("render.library.studio.prepare", json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 1, "name": 5})),
|
||||
(
|
||||
"render.library.studio.prepare",
|
||||
json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 1, "set": null}),
|
||||
),
|
||||
(
|
||||
"render.library.studio.prepare",
|
||||
json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 1, "set": set}),
|
||||
),
|
||||
(
|
||||
"render.library.studio.prepare",
|
||||
json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 1, "enable_indirect": true}),
|
||||
),
|
||||
(
|
||||
"render.library.studio.prepare",
|
||||
json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 1, "enable_indirect": null}),
|
||||
),
|
||||
(
|
||||
"render.library.studio.prepare",
|
||||
json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 1, "name": "R"}),
|
||||
),
|
||||
("render.library.studio.apply", json!({})),
|
||||
("render.library.studio.apply", json!({"rig": rig})),
|
||||
("render.library.studio.apply", json!({"rig": rig, "name": "R"})),
|
||||
("render.library.studio.apply", json!({"rig": rig, "name": "R", "replace_lighting_and_environment": false})),
|
||||
("render.library.studio.apply", json!({"rig": 5, "name": "R", "replace_lighting_and_environment": true})),
|
||||
("render.library.studio.apply", json!({"rig": null, "name": "R", "replace_lighting_and_environment": true})),
|
||||
("render.library.studio.apply", json!({"rig": {"id": "RIG-002"}, "name": "R", "replace_lighting_and_environment": true})),
|
||||
(
|
||||
"render.library.studio.apply",
|
||||
json!({"rig": {"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 1, "x": 1}, "name": "R", "replace_lighting_and_environment": true}),
|
||||
),
|
||||
(
|
||||
"render.library.studio.apply",
|
||||
json!({"rig": {"id": "RIG-002", "variant": "complete", "center_mm": "a", "subject_extent_mm": 1}, "name": "R", "replace_lighting_and_environment": true}),
|
||||
),
|
||||
("render.library.studio.apply", json!({"rig": rig, "name": 3, "replace_lighting_and_environment": true})),
|
||||
("render.library.studio.apply", json!({"rig": rig, "name": "R", "replace_lighting_and_environment": true, "expected_revision": -1})),
|
||||
("render.library.studio.apply", json!({"rig": rig, "name": "R", "replace_lighting_and_environment": true, "expected_revision": 99999})),
|
||||
("render.library.studio.apply", json!({"rig": rig, "name": "R", "replace_lighting_and_environment": true, "replace_stage": "x"})),
|
||||
("render.library.studio.apply", json!({"rig": rig, "name": "R", "replace_lighting_and_environment": true, "set": 5})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #11443: the four commands reply as their output schemas say, in Warn without warnings. Every
|
||||
@@ -172,3 +279,514 @@ fn render_library_commands_reply_with_their_schemas() {
|
||||
assert!(error.contains("knd"), "{level:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A design with one box, `Target`, and a saved CPU setup, `Cpu`, at the check level `level`.
|
||||
fn with_a_target(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in [
|
||||
("solid.box", json!({"length": 10, "width": 20, "height": 5, "body_name": "Target"})),
|
||||
("render.settings.set", json!({"name": "Cpu", "set": {"width": 8, "height": 8, "job": {"method": "cpu_raytrace", "options": {}}}})),
|
||||
] {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Every call, of the six commands, that reads its keys: working calls in every form, and calls
|
||||
/// the handlers refuse for a key left out, a value of the wrong type or a value out of range.
|
||||
fn apply_and_prepare_calls() -> Vec<(&'static str, Value)> {
|
||||
let gpu = json!({"job": {"method": "gpu_path", "options": {}}});
|
||||
let cpu = json!({"width": 8, "height": 8, "job": {"method": "cpu_raytrace", "options": {}}});
|
||||
let rig = json!({"id": "RIG-002", "variant": "complete", "center_mm": [10, 20, 30], "subject_extent_mm": 100});
|
||||
let appearance = |extra: Value| {
|
||||
let mut p = json!({"id": "SURF-001", "variant": "uniform_opaque", "bodies": ["Target"]});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let environment = |extra: Value| {
|
||||
let mut p = json!({"id": "HDR-001", "name": "E", "set": cpu});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let physical = |extra: Value| {
|
||||
let mut p = json!({"id": "steel-304-plate", "name": "Reference", "acknowledge_reference": true});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let surface = |extra: Value| {
|
||||
let mut p = json!({"id": "SURF-001", "body_index": 0, "body_name": "Target"});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let prepare = |extra: Value| {
|
||||
let mut p = rig.clone();
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let apply = |extra: Value| {
|
||||
let mut p = json!({"rig": rig, "name": "Rig", "set": gpu, "replace_lighting_and_environment": true});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let mut calls = vec![];
|
||||
let mut add = |id: &'static str, p: Value| calls.push((id, p));
|
||||
let (a, e, ph, su, pr, ap) = (
|
||||
"render.library.appearance.apply",
|
||||
"render.library.environment.apply",
|
||||
"render.library.physical.prepare",
|
||||
"render.library.surface.prepare",
|
||||
"render.library.studio.prepare",
|
||||
"render.library.studio.apply",
|
||||
);
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"id": "SELENE-AP-001"}),
|
||||
json!({"id": "MAT-001"}),
|
||||
json!({"id": 5}),
|
||||
json!({"id": null}),
|
||||
json!({"variant": "x"}),
|
||||
json!({"variant": null}),
|
||||
json!({"bodies": []}),
|
||||
json!({"bodies": "Target"}),
|
||||
json!({"bodies": [1]}),
|
||||
json!({"bodies": null}),
|
||||
json!({"bodies": ["Nope"]}),
|
||||
json!({"bodies": ["Target", "Target"]}),
|
||||
] {
|
||||
add(a, appearance(p));
|
||||
}
|
||||
add(a, json!({}));
|
||||
add(a, json!({"id": "SURF-001"}));
|
||||
add(a, json!({"id": "SURF-001", "variant": "uniform_opaque"}));
|
||||
for id in ["SELENE-AP-001", "SELENE-AP-002", "SURF-002"] {
|
||||
add(a, json!({"id": id, "variant": "uniform_opaque", "bodies": ["Target"]}));
|
||||
}
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"rotation_rad": 0.5, "intensity": 2, "samples": 4, "replace_environment": true}),
|
||||
json!({"rotation_rad": 1, "intensity": 0, "samples": 1}),
|
||||
json!({"set": gpu, "intensity": 3, "samples": 5}),
|
||||
json!({"set": gpu, "intensity": 300}),
|
||||
json!({"set": {}}),
|
||||
json!({"set": null}),
|
||||
json!({"set": 5}),
|
||||
json!({"set": [1]}),
|
||||
json!({"rotation_rad": null}),
|
||||
json!({"rotation_rad": "x"}),
|
||||
json!({"rotation_rad": 1e9}),
|
||||
json!({"intensity": null}),
|
||||
json!({"intensity": "x"}),
|
||||
json!({"samples": null}),
|
||||
json!({"samples": -1}),
|
||||
json!({"samples": 1.5}),
|
||||
json!({"samples": 3}),
|
||||
json!({"samples": 4294967296u64}),
|
||||
json!({"replace_environment": null}),
|
||||
json!({"replace_environment": "yes"}),
|
||||
json!({"id": 5}),
|
||||
json!({"name": 3}),
|
||||
json!({"name": null}),
|
||||
json!({"name": " E"}),
|
||||
] {
|
||||
add(e, environment(p));
|
||||
}
|
||||
add(e, json!({}));
|
||||
add(e, json!({"id": "HDR-001"}));
|
||||
add(e, json!({"name": "E"}));
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"name": "R"}),
|
||||
json!({"acknowledge_reference": false}),
|
||||
json!({"acknowledge_reference": "true"}),
|
||||
json!({"acknowledge_reference": null}),
|
||||
json!({"name": " R"}),
|
||||
json!({"name": ""}),
|
||||
json!({"name": 4}),
|
||||
json!({"id": 1}),
|
||||
json!({"id": "no-such-profile"}),
|
||||
json!({"id": "../profiles"}),
|
||||
json!({"id": "aluminum-6061-t6"}),
|
||||
] {
|
||||
add(ph, physical(p));
|
||||
}
|
||||
add(ph, json!({}));
|
||||
add(ph, json!({"id": "steel-304-plate", "name": "R"}));
|
||||
add(ph, json!({"name": "R", "acknowledge_reference": true}));
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"origin": [1, 2, 3], "scale": 2.5, "plane": "yz", "rotation_rad": 0.4}),
|
||||
json!({"plane": "zx"}),
|
||||
json!({"plane": "xy"}),
|
||||
json!({"origin": null}),
|
||||
json!({"origin": [1, 2]}),
|
||||
json!({"origin": [1, 2, 3, 4]}),
|
||||
json!({"origin": ["a", 2, 3]}),
|
||||
json!({"origin": {"x": 1, "y": 2, "z": 3}}),
|
||||
json!({"plane": "XY"}),
|
||||
json!({"plane": null}),
|
||||
json!({"plane": 1}),
|
||||
json!({"scale": null}),
|
||||
json!({"scale": 0}),
|
||||
json!({"scale": "2"}),
|
||||
json!({"rotation_rad": null}),
|
||||
json!({"rotation_rad": 1e7}),
|
||||
json!({"body_index": "0"}),
|
||||
json!({"body_index": -1}),
|
||||
json!({"body_index": 1.5}),
|
||||
json!({"body_index": 1}),
|
||||
json!({"body_name": 1}),
|
||||
json!({"body_name": "Other"}),
|
||||
json!({"id": "MAT-001"}),
|
||||
] {
|
||||
add(su, surface(p));
|
||||
}
|
||||
add(su, json!({}));
|
||||
add(su, json!({"id": "SURF-001", "body_index": 0}));
|
||||
add(su, json!({"id": "SURF-001", "body_name": "Target"}));
|
||||
add(su, json!({"body_index": 0, "body_name": "Target"}));
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"variant": "emitters_only"}),
|
||||
json!({"id": "RIG-001", "variant": "emitters_only"}),
|
||||
json!({"id": "RIG-001", "variant": "complete"}),
|
||||
json!({"id": "RIG-020", "name": "Cpu", "set": cpu, "enable_indirect": true}),
|
||||
json!({"id": "RIG-020", "name": "Cpu", "set": cpu}),
|
||||
json!({"name": "Cpu"}),
|
||||
json!({"name": "Cpu", "set": {"width": 16}}),
|
||||
json!({"name": null}),
|
||||
json!({"name": 5}),
|
||||
json!({"name": "Cpu", "set": null}),
|
||||
json!({"name": "Cpu", "set": 5}),
|
||||
json!({"set": {"width": 4}}),
|
||||
json!({"enable_indirect": true}),
|
||||
json!({"enable_indirect": false}),
|
||||
json!({"enable_indirect": null}),
|
||||
json!({"enable_indirect": "yes"}),
|
||||
json!({"name": "Rig", "set": gpu}),
|
||||
json!({"center_mm": [0, 0]}),
|
||||
json!({"center_mm": [0, 0, 0, 0]}),
|
||||
json!({"center_mm": [0, "a", 0]}),
|
||||
json!({"center_mm": [1e13, 0, 0]}),
|
||||
json!({"center_mm": null}),
|
||||
json!({"subject_extent_mm": 0}),
|
||||
json!({"subject_extent_mm": "100"}),
|
||||
json!({"subject_extent_mm": null}),
|
||||
json!({"variant": 1}),
|
||||
json!({"id": 1}),
|
||||
] {
|
||||
add(pr, prepare(p));
|
||||
}
|
||||
for key in ["id", "variant", "center_mm", "subject_extent_mm"] {
|
||||
let mut p = rig.clone();
|
||||
p.as_object_mut().unwrap().remove(key);
|
||||
add(pr, p);
|
||||
}
|
||||
add(pr, json!({}));
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"set": cpu}),
|
||||
json!({"set": cpu, "enable_indirect": true}),
|
||||
json!({"set": cpu, "enable_indirect": null}),
|
||||
json!({"set": cpu, "enable_indirect": "x"}),
|
||||
json!({"rig": {"id": "RIG-020", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 100}, "set": cpu}),
|
||||
json!({"rig": {"id": "RIG-020", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 100}, "set": cpu, "enable_indirect": true}),
|
||||
json!({"rig": {"id": "RIG-001", "variant": "emitters_only", "center_mm": [0, 0, 0], "subject_extent_mm": 100}}),
|
||||
json!({"set": null}),
|
||||
json!({"set": 5}),
|
||||
json!({"set": {"width": 0}}),
|
||||
json!({"replace_stage": true}),
|
||||
json!({"replace_stage": null}),
|
||||
json!({"replace_stage": "x"}),
|
||||
json!({"expected_revision": 3}),
|
||||
json!({"expected_revision": null}),
|
||||
json!({"expected_revision": 99999}),
|
||||
json!({"expected_revision": -1}),
|
||||
json!({"expected_revision": "3"}),
|
||||
json!({"replace_lighting_and_environment": false}),
|
||||
json!({"replace_lighting_and_environment": null}),
|
||||
json!({"replace_lighting_and_environment": 1}),
|
||||
json!({"name": 3}),
|
||||
json!({"name": "Rig", "set": {}}),
|
||||
json!({"rig": 5}),
|
||||
json!({"rig": null}),
|
||||
json!({"rig": {}}),
|
||||
json!({"rig": {"id": "RIG-002"}}),
|
||||
json!({"rig": {"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0]}}),
|
||||
json!({"rig": {"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 1, "extra": 1}}),
|
||||
json!({"rig": {"id": 2, "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 1}}),
|
||||
json!({"rig": {"id": "RIG-002", "variant": "complete", "center_mm": "a", "subject_extent_mm": 1}}),
|
||||
json!({"rig": {"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": null}}),
|
||||
json!({"rig": {"id": "RIG-002", "variant": "other", "center_mm": [0, 0, 0], "subject_extent_mm": 1}}),
|
||||
] {
|
||||
add(ap, apply(p));
|
||||
}
|
||||
for key in ["rig", "name", "replace_lighting_and_environment"] {
|
||||
let mut p = apply(json!({}));
|
||||
p.as_object_mut().unwrap().remove(key);
|
||||
add(ap, p);
|
||||
}
|
||||
calls
|
||||
}
|
||||
|
||||
/// The types read each key as the handlers do: a call the handler accepts is accepted at every
|
||||
/// check level, with no `param_warnings`, and a call it refuses gets its own message in warn and
|
||||
/// in strict. That includes calls with a key left out or `null`, which the handler's struct
|
||||
/// refuses or reads as absent, depending on the key.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handlers_do() {
|
||||
let calls = apply_and_prepare_calls();
|
||||
assert!(calls.len() > 150, "{}", calls.len());
|
||||
let mut tally = std::collections::BTreeMap::<&str, (usize, usize)>::new();
|
||||
for (id, p) in &calls {
|
||||
let run = |level: ParamCheck| {
|
||||
let mut s = with_a_target(level);
|
||||
s.execute(id, p).map_err(|e| e.to_string())
|
||||
};
|
||||
let off = run(ParamCheck::Off);
|
||||
assert_eq!(run(ParamCheck::Warn), off, "warn: {id} {p}");
|
||||
assert_eq!(run(ParamCheck::Strict), off, "strict: {id} {p}");
|
||||
let count = tally.entry(id).or_default();
|
||||
match &off {
|
||||
Ok(reply) => {
|
||||
count.0 += 1;
|
||||
assert!(reply.get("param_warnings").is_none(), "{id} {p}: {reply}");
|
||||
assert!(crate::schema::issues(id, p).is_empty(), "{id} {p}");
|
||||
}
|
||||
Err(_) => count.1 += 1,
|
||||
}
|
||||
}
|
||||
// Every command ran calls of both kinds (the HDR ones accept only in a build with the bundled
|
||||
// library).
|
||||
assert_eq!(tally.len(), 6, "{tally:?}");
|
||||
for (id, (accepted, refused)) in &tally {
|
||||
let bundled = cfg!(feature = "bundled-render-library") || !id.ends_with("environment.apply");
|
||||
assert!((*accepted >= 2 || !bundled) && *refused >= 3, "{id}: {accepted} accepted, {refused} refused");
|
||||
}
|
||||
}
|
||||
|
||||
/// The schemas take what the handlers read: the keys a handler refuses without are required, a
|
||||
/// default is the one the handler fills in, and a value of the wrong type is refused.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let requires = |schema: schemars::Schema, keys: &[&str]| {
|
||||
let mut required: Vec<_> = schema.as_value()["required"].as_array().unwrap().iter().map(|k| k.as_str().unwrap().to_string()).collect();
|
||||
required.sort();
|
||||
assert_eq!(required, keys);
|
||||
};
|
||||
requires(input_schema_for::<AppearanceApplyParams>(), &["bodies", "id", "variant"]);
|
||||
requires(input_schema_for::<EnvironmentApplyParams>(), &["id", "name"]);
|
||||
requires(input_schema_for::<PhysicalPrepareParams>(), &["acknowledge_reference", "id", "name"]);
|
||||
requires(input_schema_for::<SurfacePrepareParams>(), &["body_index", "body_name", "id"]);
|
||||
requires(input_schema_for::<StudioPrepareParams>(), &["center_mm", "id", "subject_extent_mm", "variant"]);
|
||||
requires(input_schema_for::<StudioApplyParams>(), &["name", "replace_lighting_and_environment", "rig"]);
|
||||
let defaults = |schema: schemars::Schema, key: &str| schema.as_value()["properties"][key]["default"].clone();
|
||||
assert_eq!(defaults(input_schema_for::<EnvironmentApplyParams>(), "rotation_rad"), json!(0.0));
|
||||
assert_eq!(defaults(input_schema_for::<EnvironmentApplyParams>(), "intensity"), json!(1.0));
|
||||
assert_eq!(defaults(input_schema_for::<EnvironmentApplyParams>(), "samples"), json!(16));
|
||||
assert_eq!(defaults(input_schema_for::<EnvironmentApplyParams>(), "replace_environment"), json!(false));
|
||||
assert_eq!(defaults(input_schema_for::<EnvironmentApplyParams>(), "set"), json!({}));
|
||||
assert_eq!(defaults(input_schema_for::<SurfacePrepareParams>(), "scale"), json!(1.0));
|
||||
assert_eq!(defaults(input_schema_for::<SurfacePrepareParams>(), "plane"), json!("xy"));
|
||||
assert_eq!(defaults(input_schema_for::<SurfacePrepareParams>(), "rotation_rad"), json!(0.0));
|
||||
assert_eq!(defaults(input_schema_for::<StudioPrepareParams>(), "enable_indirect"), json!(false));
|
||||
assert_eq!(defaults(input_schema_for::<StudioApplyParams>(), "replace_stage"), json!(false));
|
||||
// Every call that works conforms to its schema; so does no call that is missing a required
|
||||
// key or has a value of the wrong type.
|
||||
let mut conforming = 0;
|
||||
for (id, p) in apply_and_prepare_calls() {
|
||||
let schema = crate::cmd::find_command(id).and_then(|c| c.contract).map(|c| c.input_schema()).unwrap();
|
||||
let schema = schema.as_value();
|
||||
let works = with_a_target(ParamCheck::Off).execute(id, &p).is_ok();
|
||||
let fits = conforms(schema, schema, &p);
|
||||
assert!(!works || fits, "{id} {p} works but doesn't match its schema");
|
||||
conforming += usize::from(fits);
|
||||
let object = p.as_object().unwrap();
|
||||
let required = schema["required"].as_array().unwrap();
|
||||
assert!(required.iter().all(|k| object.contains_key(k.as_str().unwrap())) || !fits, "{id} {p}");
|
||||
}
|
||||
assert!(conforming > 40, "{conforming}");
|
||||
}
|
||||
|
||||
/// The id pattern, the `const: true` confirmations and the cap on `set` are in the schemas, so
|
||||
/// that a client sees them without calling.
|
||||
#[test]
|
||||
fn the_id_pattern_the_confirmations_and_the_set_cap_are_pinned() {
|
||||
let id = json!({"type": "string", "pattern": "^[A-Za-z0-9_-]{1,128}$"});
|
||||
let property = |schema: schemars::Schema, key: &str| schema.as_value()["properties"][key].clone();
|
||||
for (name, schema) in [
|
||||
("appearance", input_schema_for::<AppearanceApplyParams>()),
|
||||
("environment", input_schema_for::<EnvironmentApplyParams>()),
|
||||
("surface", input_schema_for::<SurfacePrepareParams>()),
|
||||
("studio prepare", input_schema_for::<StudioPrepareParams>()),
|
||||
] {
|
||||
let found = property(schema, "id");
|
||||
assert_eq!((&found["type"], &found["pattern"]), (&id["type"], &id["pattern"]), "{name}");
|
||||
}
|
||||
let confirmed = json!({"type": "boolean", "const": true});
|
||||
for (schema, key) in [
|
||||
(input_schema_for::<PhysicalPrepareParams>(), "acknowledge_reference"),
|
||||
(input_schema_for::<StudioApplyParams>(), "replace_lighting_and_environment"),
|
||||
] {
|
||||
let found = property(schema, key);
|
||||
assert_eq!((&found["type"], &found["const"]), (&confirmed["type"], &confirmed["const"]), "{key}");
|
||||
}
|
||||
for (name, schema) in [
|
||||
("environment", input_schema_for::<EnvironmentApplyParams>()),
|
||||
("studio prepare", input_schema_for::<StudioPrepareParams>()),
|
||||
("studio apply", input_schema_for::<StudioApplyParams>()),
|
||||
] {
|
||||
let set = property(schema, "set");
|
||||
assert_eq!(set["maxProperties"], json!(15), "{name}");
|
||||
assert_eq!(set["type"], json!("object"), "{name}");
|
||||
}
|
||||
// The schema says what the handlers do about them.
|
||||
let apply = |p: Value| with_a_target(ParamCheck::Off).execute("render.library.studio.apply", &p).is_err();
|
||||
assert!(apply(json!({"name": "Rig", "rig": {"id": "RIG-001", "variant": "emitters_only", "center_mm": [0, 0, 0], "subject_extent_mm": 1}})));
|
||||
let physical = with_a_target(ParamCheck::Off)
|
||||
.execute("render.library.physical.prepare", &json!({"id": "PHYS-001", "name": "x", "acknowledge_reference": false}));
|
||||
assert!(physical.is_err());
|
||||
// Without `name`, enable_indirect true is refused; with it, it makes no difference to a prepare.
|
||||
let prepare = |p: Value| with_a_target(ParamCheck::Off).execute("render.library.studio.prepare", &p);
|
||||
let rig = json!({"id": "RIG-001", "variant": "emitters_only", "center_mm": [0, 0, 0], "subject_extent_mm": 100});
|
||||
let mut with = rig.clone();
|
||||
with["enable_indirect"] = json!(true);
|
||||
assert!(prepare(with).is_err());
|
||||
let description = property(input_schema_for::<StudioPrepareParams>(), "enable_indirect")["description"].as_str().unwrap().to_string();
|
||||
assert!(description.contains("no effect when `name` is given") && description.contains("refused"), "{description}");
|
||||
}
|
||||
|
||||
/// The six commands reply as their output schemas say, in Warn without warnings: every surface,
|
||||
/// Selene appearance, physical profile and rig, in each form the commands take.
|
||||
#[test]
|
||||
fn apply_and_prepare_commands_reply_with_their_schemas() {
|
||||
let (appearance, environment, physical, surface, prepared, applied) = (
|
||||
output_schema_for::<AppearanceApplied>(),
|
||||
output_schema_for::<EnvironmentApplied>(),
|
||||
output_schema_for::<PhysicalPlan>(),
|
||||
output_schema_for::<SurfacePrepared>(),
|
||||
output_schema_for::<StudioPrepared>(),
|
||||
output_schema_for::<StudioApplied>(),
|
||||
);
|
||||
let ids = |kind: &str| -> Vec<String> {
|
||||
let mut s = Session::default();
|
||||
let r = s.execute("render.library.list", &json!({"kind": kind, "limit": 128})).unwrap();
|
||||
r["items"].as_array().unwrap().iter().map(|i| i["id"].as_str().unwrap().to_string()).collect()
|
||||
};
|
||||
let run = |schema: &schemars::Schema, id: &str, p: Value| -> Result<Value, String> {
|
||||
let mut s = with_a_target(ParamCheck::Warn);
|
||||
let r = s.execute(id, &p).map_err(|e| e.to_string())?;
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, &r), "{id} {p}: {r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
Ok(r)
|
||||
};
|
||||
let mut made = [0usize; 6];
|
||||
for id in ids("surfaces").into_iter().chain(ids("selene_appearances")) {
|
||||
let r = run(&appearance, "render.library.appearance.apply", json!({"id": id, "variant": "uniform_opaque", "bodies": ["Target"]}));
|
||||
made[0] += usize::from(r.is_ok());
|
||||
}
|
||||
for id in ids("surfaces") {
|
||||
for plane in ["xy", "yz", "zx"] {
|
||||
let p = json!({"id": id, "body_index": 0, "body_name": "Target", "plane": plane, "scale": 2, "rotation_rad": 0.3, "origin": [1, 2, 3]});
|
||||
made[1] += usize::from(run(&surface, "render.library.surface.prepare", p).is_ok());
|
||||
}
|
||||
let p = json!({"id": id, "body_index": 0, "body_name": "Target", "origin": null});
|
||||
made[1] += usize::from(run(&surface, "render.library.surface.prepare", p).is_ok());
|
||||
}
|
||||
for id in ids("physical_profiles") {
|
||||
let r = run(&physical, "render.library.physical.prepare", json!({"id": id, "name": "Reference", "acknowledge_reference": true}));
|
||||
made[2] += usize::from(r.is_ok());
|
||||
}
|
||||
let cpu = json!({"width": 8, "height": 8, "job": {"method": "cpu_raytrace", "options": {}}});
|
||||
let gpu = json!({"width": 8, "height": 8, "job": {"method": "gpu_path", "options": {}}});
|
||||
for id in ids("rigs") {
|
||||
for variant in ["complete", "emitters_only"] {
|
||||
let rig = json!({"id": id, "variant": variant, "center_mm": [10, 20, 30], "subject_extent_mm": 100});
|
||||
made[3] += usize::from(run(&prepared, "render.library.studio.prepare", rig.clone()).is_ok());
|
||||
for extra in [json!({"name": "Cpu"}), json!({"name": "Rig", "set": cpu, "enable_indirect": true}), json!({"name": null, "set": {}})] {
|
||||
let mut p = rig.clone();
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
made[3] += usize::from(run(&prepared, "render.library.studio.prepare", p).is_ok());
|
||||
}
|
||||
for (set, indirect) in [(&gpu, false), (&cpu, false), (&cpu, true)] {
|
||||
let p = json!({"rig": rig, "name": "Rig", "set": set, "replace_lighting_and_environment": true, "enable_indirect": indirect,
|
||||
"replace_stage": false, "expected_revision": null});
|
||||
made[4] += usize::from(run(&applied, "render.library.studio.apply", p).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg!(feature = "bundled-render-library") {
|
||||
for id in ids("hdr") {
|
||||
for set in [&cpu, &gpu] {
|
||||
let p = json!({"id": id, "name": "E", "set": set, "rotation_rad": 0.5, "intensity": 2, "samples": 4, "replace_environment": false});
|
||||
made[5] += usize::from(run(&environment, "render.library.environment.apply", p).is_ok());
|
||||
}
|
||||
}
|
||||
let p = json!({"id": "HDR-001", "name": "Cpu", "replace_environment": true});
|
||||
made[5] += usize::from(run(&environment, "render.library.environment.apply", p).is_ok());
|
||||
}
|
||||
// Surfaces and appearances, profiles with one density, rigs with a CPU or GPU projection:
|
||||
// each command made some replies, and `physical.prepare` refuses the profiles without one.
|
||||
assert!(made[0] >= 90 && made[1] >= 190 && (40..=60).contains(&made[2]) && made[3] >= 50 && made[4] >= 30, "{made:?}");
|
||||
assert!(!cfg!(feature = "bundled-render-library") || made[5] >= 45, "{made:?}");
|
||||
// Exact fields, not only the shape.
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
let r = s
|
||||
.execute(
|
||||
"render.library.studio.prepare",
|
||||
&json!({"id": "RIG-001", "variant": "complete", "center_mm": [10, 20, 30], "subject_extent_mm": 100}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((r["projection"]["passive_cards_included"].as_u64(), r["stage"][0]["id"].as_str()), (Some(1), Some("RIG-001_fill_card")), "{r}");
|
||||
assert!(r.get("setup").is_none() && r.get("indirect_adoption").is_none(), "without `name`, no CPU projection: {r}");
|
||||
}
|
||||
|
||||
/// Like the handlers' structs, which refuse a key they don't know, warn and strict refuse a
|
||||
/// misspelt key. What warn reports for a call the handler accepted (`issues`) is the same issue
|
||||
/// strict refuses with: the key, and the one it was probably meant to be.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn_and_refused_in_strict() {
|
||||
let cases = [
|
||||
("render.library.appearance.apply", json!({"id": "SURF-001", "variant": "uniform_opaque", "bodies": ["Target"]}), "bodie", "bodies"),
|
||||
(
|
||||
"render.library.physical.prepare",
|
||||
json!({"id": "steel-304-plate", "name": "R", "acknowledge_reference": true}),
|
||||
"acknowledge_referense",
|
||||
"acknowledge_reference",
|
||||
),
|
||||
("render.library.surface.prepare", json!({"id": "SURF-001", "body_index": 0, "body_name": "Target"}), "scal", "scale"),
|
||||
(
|
||||
"render.library.studio.prepare",
|
||||
json!({"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 100}),
|
||||
"subject_extent",
|
||||
"subject_extent_mm",
|
||||
),
|
||||
(
|
||||
"render.library.studio.apply",
|
||||
json!({"rig": {"id": "RIG-002", "variant": "complete", "center_mm": [0, 0, 0], "subject_extent_mm": 100}, "name": "Rig",
|
||||
"set": {"job": {"method": "gpu_path", "options": {}}}, "replace_lighting_and_environment": true}),
|
||||
"replace_stag",
|
||||
"replace_stage",
|
||||
),
|
||||
(
|
||||
"render.library.environment.apply",
|
||||
json!({"id": "HDR-001", "name": "E", "set": {"width": 64, "height": 32, "job": {"method": "cpu_raytrace", "options": {}}}}),
|
||||
"intensty",
|
||||
"intensity",
|
||||
),
|
||||
];
|
||||
for (id, mut p, typo, key) in cases {
|
||||
let ok = p.clone();
|
||||
p[typo] = json!(1);
|
||||
assert!(crate::schema::issues(id, &ok).is_empty(), "{id}: the call without the typo is fine");
|
||||
let issues = crate::schema::issues(id, &p);
|
||||
let want = format!("unknown key `{typo}`; did you mean `{key}`?");
|
||||
assert_eq!(issues.iter().map(|i| i.message.as_str()).collect::<Vec<_>>(), [want.as_str()], "{id}");
|
||||
for level in [ParamCheck::Warn, ParamCheck::Strict] {
|
||||
let mut s = with_a_target(level);
|
||||
let error = s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains(typo), "{id} {level:?}: {error}");
|
||||
assert_eq!(error.contains(&want), level == ParamCheck::Strict, "{id} {level:?}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Contracts and reply types for Render Settings and Save Render Settings in `cmd/render.rs`
|
||||
//! (#12783), and the render setup fields a `set` patch may change, which Save Render Settings
|
||||
//! with HDR (`schema/render_hdr.rs`) shares.
|
||||
//!
|
||||
//! The handlers read their parameters with serde, so each key here reads as the handler reads
|
||||
//! it, with serde's message (`invalid type: …`) or the setup name rules'. Whether a setup exists,
|
||||
//! and whether the patched setup is valid, stay the handlers' to check.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use solidcraft_doc::render_settings::RenderSettings;
|
||||
|
||||
use super::check::whole;
|
||||
use super::render_camera::{setup_name, setup_name_schema};
|
||||
|
||||
/// The fields of a render setup (`solidcraft_doc::render_settings::RenderSetup`), as the render
|
||||
/// settings commands give and take them. `cmd/render.rs`'s `patched_settings` takes a patch of at
|
||||
/// most this many fields, so the published schemas and the handlers share one list and one bound;
|
||||
/// a test checks the list against `RenderSetup`'s own fields.
|
||||
pub(crate) const SETUP_FIELDS: &[&str] = &[
|
||||
"backend",
|
||||
"mode",
|
||||
"camera",
|
||||
"width",
|
||||
"height",
|
||||
"format",
|
||||
"exposure_ev",
|
||||
"environment_rotation",
|
||||
"environment_intensity",
|
||||
"background",
|
||||
"job",
|
||||
"surfaces",
|
||||
"optics",
|
||||
"coats",
|
||||
"stage",
|
||||
];
|
||||
|
||||
/// `patched_settings`' message for a `set` that isn't an object of at most [`SETUP_FIELDS`] keys.
|
||||
pub(crate) fn set_message() -> String {
|
||||
format!("`set` must be an object with at most {} fields", SETUP_FIELDS.len())
|
||||
}
|
||||
|
||||
/// The schema of a `set` patch: an object of setup fields, each without its own schema
|
||||
/// (`patched_settings` reads the patched setup as a `RenderSetup`, which checks the values and
|
||||
/// refuses any other key).
|
||||
pub(super) fn setup_patch_schema(description: &str) -> Schema {
|
||||
let properties: Map<String, Value> = SETUP_FIELDS.iter().map(|field| (field.to_string(), Value::Object(Map::new()))).collect();
|
||||
json_schema!({
|
||||
"description": description,
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"additionalProperties": false,
|
||||
"maxProperties": SETUP_FIELDS.len()
|
||||
})
|
||||
}
|
||||
|
||||
/// Give every saved render setup and the defaults a new one starts from, or one setup by name.
|
||||
/// Nothing is changed.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct GetParams {
|
||||
/// The saved setup, by name: trimmed, at most 128 bytes, with no control characters. Left out
|
||||
/// or `null`: every setup and the defaults.
|
||||
#[serde(default, deserialize_with = "setup_name")]
|
||||
#[schemars(schema_with = "optional_setup_name_schema")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
fn optional_setup_name_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"anyOf": [setup_name_schema(generator), {"type": "null"}]})
|
||||
}
|
||||
|
||||
/// Change a render setup's fields, or make a new setup from the defaults with them; the fields
|
||||
/// left out keep their values. One undo step, none when nothing changes.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SetParams {
|
||||
// Both keys are required, so the check refuses a call without one ("`name` is required")
|
||||
// before the handler runs, as for the other required keys. The handler's own words for it are
|
||||
// serde's ("missing field `name`").
|
||||
/// The setup, by name: trimmed, at most 128 bytes, with no control characters.
|
||||
#[serde(deserialize_with = "saved_name")]
|
||||
#[schemars(schema_with = "setup_name_schema")]
|
||||
pub name: String,
|
||||
#[serde(deserialize_with = "setup_patch")]
|
||||
#[schemars(schema_with = "set_schema")]
|
||||
pub set: Value,
|
||||
}
|
||||
|
||||
/// Save Render Settings' `name`: text, as the handler's serde struct reads it (its message for
|
||||
/// anything else, `null` included), and a valid setup name.
|
||||
fn saved_name<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
|
||||
let name = String::deserialize(Value::deserialize(d)?).map_err(|e| D::Error::custom(whole(e)))?;
|
||||
RenderSettings::validate_name(&name).map_err(|e| D::Error::custom(whole(e)))?;
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// `set`: an object of at most [`SETUP_FIELDS`] keys, with `patched_settings`' message for
|
||||
/// anything else, `null` included. Which keys and values a setup takes stays the handler's to
|
||||
/// check, with serde's messages.
|
||||
fn setup_patch<'de, D: Deserializer<'de>>(d: D) -> Result<Value, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::Object(patch) if patch.len() <= SETUP_FIELDS.len() => Ok(Value::Object(patch)),
|
||||
_ => Err(D::Error::custom(whole(set_message()))),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
setup_patch_schema(&format!(
|
||||
"The fields to change: at most {} of the setup's fields, as `render.settings.get` gives them. `job`: `null` clears it, an object replaces it whole.",
|
||||
SETUP_FIELDS.len()
|
||||
))
|
||||
}
|
||||
|
||||
/// A render setup's fields, as `RenderSetup` saves them.
|
||||
pub type SetupFields = Map<String, Value>;
|
||||
|
||||
/// The saved setups, or one of them.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum RenderSettingsInfo {
|
||||
/// Without `name`: every saved setup, by name, and the defaults a new setup starts from.
|
||||
All { setups: BTreeMap<String, SetupFields>, defaults: SetupFields },
|
||||
/// With `name`: that setup.
|
||||
One { name: String, setup: SetupFields },
|
||||
}
|
||||
|
||||
/// What `render.settings.get` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RenderSettingsGot {
|
||||
#[serde(flatten)]
|
||||
pub info: RenderSettingsInfo,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `render.settings.set` returns: the setup as saved.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RenderSettingsSaved {
|
||||
pub name: String,
|
||||
pub setup: SetupFields,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "render_settings_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,131 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::render_settings::RenderSetup;
|
||||
|
||||
use super::{GetParams, RenderSettingsGot, RenderSettingsSaved, SETUP_FIELDS, SetParams};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
/// A design with one saved render setup, `Studio`, 800 × 600.
|
||||
fn studio(level: ParamCheck) -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("render.settings.set", &json!({"name": "Studio", "set": {"width": 800, "height": 600}})).unwrap_or_else(|e| panic!("{e}"));
|
||||
s.param_check = level;
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<GetParams>(), &["name"]);
|
||||
assert_keys(input_schema_for::<SetParams>(), &["name", "set"]);
|
||||
}
|
||||
|
||||
/// #12783: `SETUP_FIELDS` is `RenderSetup`'s own list of fields, which serde gives in its message
|
||||
/// for a field it doesn't know. The schemas and `patched_settings`' bound both come from it.
|
||||
#[test]
|
||||
fn the_setup_fields_are_render_setups_fields() {
|
||||
let error = serde_json::from_value::<RenderSetup>(json!({"not_a_setup_field": 1})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
let expected = error.split_once("expected").map(|(_, list)| list).unwrap_or_default();
|
||||
let fields: Vec<&str> = expected.split('`').skip(1).step_by(2).collect();
|
||||
assert_eq!(fields, SETUP_FIELDS, "{error}");
|
||||
}
|
||||
|
||||
/// #12783: calls that fail today get the same message in strict: serde's for a name or patch of
|
||||
/// the wrong type, the setup name rules', `patched_settings`' and a missing setup. (A key left out
|
||||
/// is in `a_key_left_out_is_refused_by_the_check`.)
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let too_many: serde_json::Map<String, Value> = (0..16).map(|i| (format!("field{i}"), json!(1))).collect();
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("render.settings.get", json!({"name": 5})),
|
||||
("render.settings.get", json!({"name": ""})),
|
||||
("render.settings.get", json!({"name": " Studio"})),
|
||||
("render.settings.get", json!({"name": "Nope"})),
|
||||
("render.settings.set", json!({"name": null, "set": {}})),
|
||||
("render.settings.set", json!({"name": 5, "set": {}})),
|
||||
("render.settings.set", json!({"name": "", "set": {}})),
|
||||
("render.settings.set", json!({"name": "Studio", "set": null})),
|
||||
("render.settings.set", json!({"name": "Studio", "set": []})),
|
||||
("render.settings.set", json!({"name": "Studio", "set": too_many})),
|
||||
("render.settings.set", json!({"name": "Studio", "set": {"widht": 800}})),
|
||||
("render.settings.set", json!({"name": "Studio", "set": {"width": "wide"}})),
|
||||
("render.settings.set", json!({"name": "Studio", "set": {"width": 0}})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// #12783: Save Render Settings without `name` or `set` is refused by the check itself, before
|
||||
/// the handler runs: in strict with the check's "is required", where the handler says serde's
|
||||
/// "missing field". The first key left out, in the struct's order, is the one named either way.
|
||||
#[test]
|
||||
fn a_key_left_out_is_refused_by_the_check() {
|
||||
for (p, key) in [(json!({}), "name"), (json!({"set": {}}), "name"), (json!({"name": "Studio"}), "set")] {
|
||||
let messages: Vec<String> = crate::schema::issues("render.settings.set", &p).into_iter().map(|i| i.message).collect();
|
||||
assert_eq!(messages, [format!("`{key}` is required")], "{p}");
|
||||
let error = |level: ParamCheck| studio(level).execute("render.settings.set", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error(ParamCheck::Off).ends_with(&format!("missing field `{key}`")), "{p}: {}", error(ParamCheck::Off));
|
||||
assert!(error(ParamCheck::Strict).ends_with(&format!("`{key}` is required")), "{p}: {}", error(ParamCheck::Strict));
|
||||
}
|
||||
}
|
||||
|
||||
/// #12783: the schemas take what the handlers take. Each call is checked against both the schema
|
||||
/// and the handler on the design with `Studio`, with the check off, so the two can't drift apart.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_take() {
|
||||
let get = input_schema_for::<GetParams>();
|
||||
let set = input_schema_for::<SetParams>();
|
||||
for (id, schema, p) in [
|
||||
("render.settings.get", &get, json!({})),
|
||||
("render.settings.get", &get, json!({"name": null})),
|
||||
("render.settings.get", &get, json!({"name": "Studio"})),
|
||||
("render.settings.get", &get, json!({"name": 5})),
|
||||
("render.settings.get", &get, json!({"name": ""})),
|
||||
("render.settings.set", &set, json!({"name": "Studio", "set": {"width": 1024}})),
|
||||
("render.settings.set", &set, json!({"name": "Draft", "set": {}})),
|
||||
("render.settings.set", &set, json!({"name": "Studio", "set": {"exposure_ev": 0.5, "job": null}})),
|
||||
("render.settings.set", &set, json!({"name": "Studio", "set": {"widht": 1024}})),
|
||||
("render.settings.set", &set, json!({"name": "Studio", "set": []})),
|
||||
("render.settings.set", &set, json!({"name": "Studio"})),
|
||||
] {
|
||||
let takes = studio(ParamCheck::Off).execute(id, &p).is_ok();
|
||||
assert_eq!(conforms(schema.as_value(), schema.as_value(), &p), takes, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #12783: both commands reply as their output schemas say, in Warn without warnings, and the
|
||||
/// replies are the JSON the handlers gave before: every setup with the defaults, one setup by
|
||||
/// name, and a setup changed and saved again unchanged. Unknown keys fail in every mode, as the
|
||||
/// handlers' serde structs refuse them; strict refuses them in the check's own words.
|
||||
#[test]
|
||||
fn render_settings_reply_with_their_schemas() {
|
||||
let got = output_schema_for::<RenderSettingsGot>();
|
||||
let got = got.as_value();
|
||||
let saved = output_schema_for::<RenderSettingsSaved>();
|
||||
let saved = saved.as_value();
|
||||
let mut s = studio(ParamCheck::Warn);
|
||||
let mut run = |id: &str, p: Value, schema: &Value| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
r
|
||||
};
|
||||
let all = run("render.settings.get", json!({}), got);
|
||||
let one = run("render.settings.get", json!({"name": "Studio"}), got);
|
||||
let changed = run("render.settings.set", json!({"name": "Studio", "set": {"width": 1024}}), saved);
|
||||
let unchanged = run("render.settings.set", json!({"name": "Studio", "set": {"width": 1024}}), saved);
|
||||
assert_eq!(changed, unchanged, "saved again unchanged");
|
||||
assert_eq!(changed["setup"]["width"], 1024, "{changed}");
|
||||
let setups = s.doc.render.setups.clone();
|
||||
let studio_before = {
|
||||
let mut setup = setups.get("Studio").cloned().unwrap_or_default();
|
||||
setup.width = 800;
|
||||
setup
|
||||
};
|
||||
assert_eq!(all, json!({"setups": {"Studio": studio_before}, "defaults": RenderSetup::default()}));
|
||||
assert_eq!(one, json!({"name": "Studio", "setup": studio_before}));
|
||||
assert_eq!(changed, json!({"name": "Studio", "setup": setups.get("Studio")}));
|
||||
for level in [ParamCheck::Off, ParamCheck::Warn, ParamCheck::Strict] {
|
||||
s.param_check = level;
|
||||
let error = s.execute("render.settings.get", &json!({"nmae": "Studio"})).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("nmae"), "{level:?}: {error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
//! Contracts and result types for `inspect.silhouette` (`cmd/silhouette.rs`) and
|
||||
//! `file.export_view_dxf` (`cmd/view_dxf.rs`) (#12611).
|
||||
//!
|
||||
//! The handlers read their parameters from the raw JSON, so each key here reads as its handler
|
||||
//! reads it and fails with the handler's message. Whether a body exists, or anything is visible,
|
||||
//! stays the handler's to check. A key the handler refuses when it is left out (the DXF
|
||||
//! export's `camera`) is optional in the type and required in the schema, so that the handler's
|
||||
//! message, not the check's "is required", is what a caller sees.
|
||||
//!
|
||||
//! `inspect.silhouette` reads `view` or `camera`, and refuses the two together before it looks
|
||||
//! at either, so its `Deserialize` is written out to do the same.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::NamedView;
|
||||
|
||||
use super::check::{located, whole};
|
||||
use super::types::{CameraParam, not_blank_pattern};
|
||||
use super::view_navigation::{read_as, read_with};
|
||||
use crate::render::{Camera, StandardView};
|
||||
|
||||
/// The most body names `inspect.silhouette` reads from a list, as the handler does.
|
||||
const MAX_BODIES: usize = 10_000;
|
||||
|
||||
fn require(schema: &mut Schema, key: &str) {
|
||||
super::motion::require(schema, key);
|
||||
}
|
||||
|
||||
fn camera_required(schema: &mut Schema) {
|
||||
require(schema, "camera");
|
||||
}
|
||||
|
||||
// ---- inspect.silhouette ----
|
||||
|
||||
/// A standard view's name, as `inspect.silhouette` reads `view`: text that names one of the
|
||||
/// standard views, with the handler's messages for anything else.
|
||||
fn view_name<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(name) if StandardView::parse(&name).is_some() => Ok(Some(name)),
|
||||
Value::String(name) => Err(D::Error::custom(whole(format!("unknown standard view `{name}`")))),
|
||||
_ => Err(D::Error::custom(whole("`view` must be a standard view name"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn view_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A standard view: front, back, left, right, top, bottom or iso (also home or isometric); upper or lower case. Default: front.",
|
||||
"type": "string"
|
||||
})
|
||||
}
|
||||
|
||||
/// A camera as `inspect.silhouette` reads `camera`: an orbit camera `{target, yaw, pitch,
|
||||
/// distance, fov?}` in mm and radians, finite and at a positive distance. Other keys (a full
|
||||
/// camera's `roll` and `up`) are not read.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SilhouetteCamera(pub NamedView);
|
||||
|
||||
impl<'de> Deserialize<'de> for SilhouetteCamera {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let named: NamedView = serde_json::from_value(Value::deserialize(d)?).map_err(|e| D::Error::custom(whole(format!("bad camera: {e}"))))?;
|
||||
let camera =
|
||||
Camera { target: named.target, yaw: named.yaw, pitch: named.pitch, distance: named.distance, fov: named.fov, ..Default::default() };
|
||||
if !camera.is_valid() || !camera.fov.is_finite() || camera.fov < 0.0 {
|
||||
return Err(D::Error::custom(whole("the camera must be finite with a positive distance")));
|
||||
}
|
||||
Ok(SilhouetteCamera(named))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SilhouetteCamera {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SilhouetteCamera".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An orbit camera, in mm and radians. Other keys are ignored.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {"description": "The point the camera looks at, in mm: [x, y, z].", "type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3},
|
||||
"yaw": {"description": "Turn about the vertical axis; 0 looks along +Y, from the front.", "type": "number"},
|
||||
"pitch": {"description": "Elevation above the ground plane.", "type": "number"},
|
||||
"distance": {"description": "From the target to the eye, in mm.", "type": "number", "exclusiveMinimum": 0},
|
||||
"fov": {"description": "Vertical field of view; 0 is parallel. Default: 0.", "type": "number", "minimum": 0, "default": 0.0}
|
||||
},
|
||||
"required": ["target", "yaw", "pitch", "distance"]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn silhouette_camera<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SilhouetteCamera>, D::Error> {
|
||||
SilhouetteCamera::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// The bodies `inspect.silhouette` outlines: one name, or a list of up to 10000.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BodyNames(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for BodyNames {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(name) => Ok(BodyNames(vec![name])),
|
||||
Value::Array(names) if names.len() <= MAX_BODIES => names
|
||||
.into_iter()
|
||||
.map(|v| match v {
|
||||
Value::String(name) => Ok(name),
|
||||
_ => Err(D::Error::custom(whole("`bodies` must contain names"))),
|
||||
})
|
||||
.collect::<Result<_, _>>()
|
||||
.map(BodyNames),
|
||||
_ => Err(D::Error::custom(whole("`bodies` must be a list of names"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for BodyNames {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"BodyNames".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "One body name, or a list of up to 10000.",
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}, "maxItems": MAX_BODIES}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn body_names<'de, D: Deserializer<'de>>(d: D) -> Result<Option<BodyNames>, D::Error> {
|
||||
BodyNames::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// `rows` as the handler reads it: any integer (it is clamped, not refused), one above `i64`'s
|
||||
/// range read as the largest.
|
||||
fn rows<'de, D: Deserializer<'de>>(d: D) -> Result<Option<i64>, D::Error> {
|
||||
read_with(d, "`rows` must be an integer", |v| v.as_i64().or_else(|| v.as_u64().map(|n| n.min(i64::MAX as u64) as i64)))
|
||||
}
|
||||
|
||||
fn rows_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The mask's height in pixels, a whole number written without a decimal point (240, not 240.0); clamped to 16..1024. Default: 240.",
|
||||
"type": "integer",
|
||||
"default": 240
|
||||
})
|
||||
}
|
||||
|
||||
/// The keys `inspect.silhouette` reads, once `view` and `camera` are known not to come together.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SilhouetteKeys {
|
||||
/// A standard view to look from. Not with `camera`. Default: front.
|
||||
#[serde(default, deserialize_with = "view_name", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "view_schema")]
|
||||
pub view: Option<String>,
|
||||
/// A camera to look from, in mm and radians; `fov` 0 means parallel. Not with `view`.
|
||||
#[serde(default, deserialize_with = "silhouette_camera", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SilhouetteCamera")]
|
||||
pub camera: Option<SilhouetteCamera>,
|
||||
/// The world bodies to outline, by name; a named hidden body is included. Default: the
|
||||
/// visible bodies.
|
||||
#[serde(default, deserialize_with = "body_names", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "BodyNames")]
|
||||
pub bodies: Option<BodyNames>,
|
||||
/// The mask's height in pixels, clamped to 16..1024. Default: 240.
|
||||
#[serde(default, deserialize_with = "rows", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "rows_schema")]
|
||||
pub rows: Option<i64>,
|
||||
}
|
||||
|
||||
/// A tightly framed, view-aligned union of the projected body meshes, as a run-length mask the
|
||||
/// size of the model in the view: for an agent comparing the model with a picture. It changes
|
||||
/// nothing in the design.
|
||||
///
|
||||
/// `inspect.silhouette`'s parameters: [`SilhouetteKeys`], with `view` and `camera` together
|
||||
/// refused first, as the handler does.
|
||||
#[derive(Debug)]
|
||||
pub struct SilhouetteParams(pub SilhouetteKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for SilhouetteParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let value = Value::deserialize(d)?;
|
||||
if value.get("view").is_some() && value.get("camera").is_some() {
|
||||
return Err(D::Error::custom(whole("give `view` or `camera`, not both")));
|
||||
}
|
||||
serde_path_to_error::deserialize(&value).map(SilhouetteParams).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SilhouetteParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SilhouetteParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = SilhouetteKeys::json_schema(generator);
|
||||
schema.insert(
|
||||
"description".into(),
|
||||
"A tightly framed, view-aligned union of the projected body meshes, as a run-length mask. Changes nothing in the design.".into(),
|
||||
);
|
||||
schema.insert("not".into(), json!({"required": ["view", "camera"]}));
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
/// How the silhouette is projected.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SilhouetteProjection {
|
||||
Perspective,
|
||||
Parallel,
|
||||
}
|
||||
|
||||
/// What `inspect.silhouette` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SilhouetteResult {
|
||||
/// The mask's width in pixels.
|
||||
pub width: usize,
|
||||
/// The mask's height in pixels.
|
||||
pub height: usize,
|
||||
/// The size of a pixel in the view plane, in mm.
|
||||
pub mm_per_px: f64,
|
||||
/// The view-plane box the mask frames, in mm: `[[u_min, v_min], [u_max, v_max]]`, u along
|
||||
/// `right` and v along `up`.
|
||||
pub box_mm: [[f64; 2]; 2],
|
||||
/// The world direction of the mask's x axis.
|
||||
pub right: [f64; 3],
|
||||
/// The world direction of the mask's rows going up.
|
||||
pub up: [f64; 3],
|
||||
pub projection: SilhouetteProjection,
|
||||
/// How many bodies are in the outline.
|
||||
pub bodies: usize,
|
||||
/// How many triangles they have.
|
||||
pub triangles: usize,
|
||||
/// One entry per pixel row, top first: the filled runs of that row as `[start, end)` pixel
|
||||
/// columns.
|
||||
pub mask: Vec<Vec<[usize; 2]>>,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---- file.export_view_dxf ----
|
||||
|
||||
/// A camera as `file.export_view_dxf` reads `camera`: as the view commands read one, and also
|
||||
/// with yaw and roll within ±1e6 and pitch within ±π/2 (else "invalid camera").
|
||||
fn dxf_camera<'de, D: Deserializer<'de>>(d: D) -> Result<Option<CameraParam>, D::Error> {
|
||||
read_with(d, "invalid camera", |v| {
|
||||
crate::cmd::camera_form(v)
|
||||
.filter(|c| c.yaw.abs() <= 1e6 && c.roll.abs() <= 1e6 && c.pitch.abs() <= std::f64::consts::FRAC_PI_2)
|
||||
.map(CameraParam)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether hidden lines are drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HiddenLines {
|
||||
/// Leave them out.
|
||||
Omit,
|
||||
/// Draw them dashed on the HIDDEN layer.
|
||||
Dashed,
|
||||
}
|
||||
|
||||
fn hidden_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "`omit` leaves hidden lines out; `dashed` draws them dashed on the HIDDEN layer. Default: omit.",
|
||||
"enum": ["omit", "dashed"],
|
||||
"default": "omit"
|
||||
})
|
||||
}
|
||||
|
||||
fn hidden<'de, D: Deserializer<'de>>(d: D) -> Result<Option<HiddenLines>, D::Error> {
|
||||
read_as(d, "hidden must be omit or dashed")
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
|
||||
fn tolerance_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The chord tolerance for curves, in mm: 0.0001 to 1. Default: 0.01.",
|
||||
"type": "number",
|
||||
"minimum": 0.0001,
|
||||
"maximum": 1.0,
|
||||
"default": 0.01
|
||||
})
|
||||
}
|
||||
|
||||
/// The `.dxf` file's path, as the handler reads `path`: text with something in it, under 4096
|
||||
/// bytes; `null` is refused.
|
||||
fn export_path<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
read_with(d, "invalid path", |v| v.as_str().filter(|p| !p.trim().is_empty() && p.len() < 4096).map(str::to_string))
|
||||
}
|
||||
|
||||
fn path_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The `.dxf` file to write: not blank, under 4096 bytes (maxLength counts characters). Default: the reply carries the DXF text instead.",
|
||||
"type": "string",
|
||||
"pattern": not_blank_pattern(),
|
||||
"maxLength": 4095
|
||||
})
|
||||
}
|
||||
|
||||
/// Draw the visible placed bodies and the shown sketches' profile curves from a camera as a
|
||||
/// 2D DXF drawing in mm: LINE and LWPOLYLINE entities on the VISIBLE and HIDDEN layers, sketch
|
||||
/// curves on SKETCH. No construction geometry, points, dimensions or constraints; finished
|
||||
/// sketches are hidden behind bodies and the active sketch is drawn on top; a perspective
|
||||
/// camera is projected onto the target plane; there is no viewport crop and no overlay. Writes
|
||||
/// the file when `path` is given, else returns the text.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = camera_required)]
|
||||
pub struct ExportViewDxfParams {
|
||||
// Optional in the type and required in the schema (`camera_required`).
|
||||
/// The camera to draw from, as `ui.inspect` gives it. Its yaw and roll must also be within
|
||||
/// ±1e6 and its pitch within ±π/2.
|
||||
#[serde(default, deserialize_with = "dxf_camera", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "CameraParam")]
|
||||
pub camera: Option<CameraParam>,
|
||||
/// Whether to draw the hidden lines (dashed) or leave them out. Default: omit.
|
||||
#[serde(default, deserialize_with = "hidden", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "hidden_schema")]
|
||||
pub hidden: Option<HiddenLines>,
|
||||
/// The chord tolerance for curves, in mm: 0.0001 to 1. Default: 0.01.
|
||||
#[serde(default, deserialize_with = "tolerance", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "tolerance_schema")]
|
||||
pub tolerance: Option<f64>,
|
||||
/// 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")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// How the drawing is projected.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DxfProjection {
|
||||
Perspective,
|
||||
Orthographic,
|
||||
}
|
||||
|
||||
/// The drawing's unit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
|
||||
pub enum DxfUnits {
|
||||
#[serde(rename = "mm")]
|
||||
Millimetres,
|
||||
}
|
||||
|
||||
/// What the export measured while it ran.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewDxfStats {
|
||||
/// The placed bodies drawn.
|
||||
pub bodies: usize,
|
||||
/// The distinct meshes among them (placements that share geometry share a mesh).
|
||||
pub shared_meshes: usize,
|
||||
/// The triangles of those meshes.
|
||||
pub triangles: usize,
|
||||
/// The memory the meshes take, in bytes.
|
||||
pub mesh_bytes: usize,
|
||||
/// The most worker threads the meshing used.
|
||||
pub worker_limit: usize,
|
||||
/// The projected edge segments.
|
||||
pub segments: usize,
|
||||
/// The work the hidden-line test took.
|
||||
pub occlusion_work: usize,
|
||||
/// The time to mesh the bodies, in ms.
|
||||
pub prepare_ms: f64,
|
||||
/// The time to project them, in ms.
|
||||
pub projection_ms: f64,
|
||||
/// The time for the hidden-line test, in ms.
|
||||
pub occlusion_ms: f64,
|
||||
}
|
||||
|
||||
/// Where the drawing went.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum DxfOutput {
|
||||
/// With `path`: the file written.
|
||||
File {
|
||||
/// The path the drawing was written to, as given.
|
||||
path: String,
|
||||
},
|
||||
/// Without `path`: the DXF text.
|
||||
Text {
|
||||
/// The drawing, as DXF text.
|
||||
dxf: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// What `file.export_view_dxf` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ViewDxfResult {
|
||||
/// The lines found visible, before neighbouring ones are joined into polylines.
|
||||
pub visible: usize,
|
||||
/// The hidden lines drawn: always 0 unless `hidden` is dashed.
|
||||
pub hidden: usize,
|
||||
/// The entities drawn on the SKETCH layer.
|
||||
pub sketch: usize,
|
||||
pub units: DxfUnits,
|
||||
/// The chord tolerance used, in mm.
|
||||
pub tolerance: f64,
|
||||
pub projection: DxfProjection,
|
||||
pub stats: ViewDxfStats,
|
||||
#[serde(flatten)]
|
||||
pub output: DxfOutput,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "silhouette_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,299 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{BodyNames, ExportViewDxfParams, SilhouetteCamera, SilhouetteParams, SilhouetteResult, ViewDxfResult};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, output_schema_for};
|
||||
|
||||
const SIL: &str = "inspect.silhouette";
|
||||
const DXF: &str = "file.export_view_dxf";
|
||||
|
||||
fn camera() -> Value {
|
||||
json!({"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 100, "fov": 0})
|
||||
}
|
||||
|
||||
/// A session with one box in it, so a call the handlers accept runs to its reply.
|
||||
fn with_box(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
s
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, id: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}");
|
||||
}
|
||||
|
||||
#[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"]);
|
||||
}
|
||||
|
||||
/// Strict gives the handlers' messages for the calls they refuse for a key (a missing `camera`
|
||||
/// included: the handler's words, not "is required").
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let long = vec!["Plate"; 10_001];
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
(SIL, json!({"view": "front", "camera": camera()})),
|
||||
(SIL, json!({"view": null, "camera": camera()})),
|
||||
(SIL, json!({"view": 3})),
|
||||
(SIL, json!({"view": null})),
|
||||
(SIL, json!({"view": ["top"]})),
|
||||
(SIL, json!({"view": "sideways"})),
|
||||
(SIL, json!({"camera": null})),
|
||||
(SIL, json!({"camera": "front"})),
|
||||
(SIL, json!({"camera": {}})),
|
||||
(SIL, json!({"camera": {"target": [0, 0], "yaw": 0, "pitch": 0, "distance": 5}})),
|
||||
(SIL, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}})),
|
||||
(SIL, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": -4}})),
|
||||
(SIL, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 5, "fov": -1}})),
|
||||
(SIL, json!({"camera": {"target": [0, 0, 0], "yaw": "0", "pitch": 0, "distance": 5}})),
|
||||
(SIL, json!({"bodies": null})),
|
||||
(SIL, json!({"bodies": 7})),
|
||||
(SIL, json!({"bodies": {"a": 1}})),
|
||||
(SIL, json!({"bodies": ["Plate", 2]})),
|
||||
(SIL, json!({"bodies": long})),
|
||||
(SIL, json!({"rows": null})),
|
||||
(SIL, json!({"rows": "5"})),
|
||||
(SIL, json!({"rows": 24.5})),
|
||||
(SIL, json!({"rows": true})),
|
||||
(DXF, json!({})),
|
||||
(DXF, json!({"camera": null})),
|
||||
(DXF, json!({"camera": "top"})),
|
||||
(DXF, json!({"camera": {}})),
|
||||
(DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0, "fov": 0}})),
|
||||
(DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 2, "distance": 1, "fov": 0}})),
|
||||
(DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 2e6, "pitch": 0, "distance": 1, "fov": 0}})),
|
||||
(DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "roll": 2e6, "distance": 1, "fov": 0}})),
|
||||
(DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 1, "fov": 3.2}})),
|
||||
(DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 1}})),
|
||||
(DXF, json!({"camera": camera(), "hidden": "dash"})),
|
||||
(DXF, json!({"camera": camera(), "hidden": "Omit"})),
|
||||
(DXF, json!({"camera": camera(), "hidden": null})),
|
||||
(DXF, json!({"camera": camera(), "hidden": true})),
|
||||
(DXF, json!({"camera": camera(), "tolerance": 0})),
|
||||
(DXF, json!({"camera": camera(), "tolerance": 0.00001})),
|
||||
(DXF, json!({"camera": camera(), "tolerance": 1.5})),
|
||||
(DXF, json!({"camera": camera(), "tolerance": "0.1"})),
|
||||
(DXF, json!({"camera": camera(), "tolerance": null})),
|
||||
(DXF, json!({"camera": camera(), "path": ""})),
|
||||
(DXF, json!({"camera": camera(), "path": " \t"})),
|
||||
(DXF, json!({"camera": camera(), "path": 5})),
|
||||
(DXF, json!({"camera": camera(), "path": null})),
|
||||
(DXF, json!({"camera": camera(), "path": "x".repeat(4096)})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The contracts read each key as the handlers do: for every call, what runs today is not
|
||||
/// refused in strict or warned about in warn, and what fails today fails in strict with the same
|
||||
/// message. (A call the handler accepts but the contract refused would fail one of the three.)
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handlers_do() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-dxf-schema-read-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
|
||||
let file = dir.join("read.dxf");
|
||||
let named = |extra: Value| {
|
||||
let mut c = camera();
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
c[k] = v.clone();
|
||||
}
|
||||
c
|
||||
};
|
||||
let cases = [
|
||||
(SIL, json!({})),
|
||||
(SIL, Value::Null),
|
||||
(SIL, json!({"view": "front"})),
|
||||
(SIL, json!({"view": "TOP"})),
|
||||
(SIL, json!({"view": "Home"})),
|
||||
(SIL, json!({"view": "isometric"})),
|
||||
(SIL, json!({"view": "iso", "rows": 16})),
|
||||
(SIL, json!({"camera": camera()})),
|
||||
(SIL, json!({"camera": named(json!({"fov": 0.6}))})),
|
||||
(SIL, json!({"camera": {"target": [10, 10, 10], "yaw": 0.5, "pitch": 0.5, "distance": 300}})),
|
||||
(SIL, json!({"camera": named(json!({"name": "mine", "roll": 9, "up": "y", "extra": [1]}))})),
|
||||
(SIL, json!({"camera": named(json!({"pitch": 7, "yaw": 1e9}))})),
|
||||
(SIL, json!({"camera": named(json!({"fov": 3.5}))})),
|
||||
(SIL, json!({"camera": named(json!({"distance": 1e-9}))})),
|
||||
(SIL, json!({"bodies": "Plate"})),
|
||||
(SIL, json!({"bodies": ["Plate"]})),
|
||||
(SIL, json!({"bodies": []})),
|
||||
(SIL, json!({"bodies": "Nope"})),
|
||||
(SIL, json!({"rows": 0})),
|
||||
(SIL, json!({"rows": -5})),
|
||||
(SIL, json!({"rows": 5000})),
|
||||
(SIL, json!({"rows": 18446744073709551615u64})),
|
||||
(SIL, json!({"rows": 1e3})),
|
||||
(DXF, json!({"camera": camera()})),
|
||||
(DXF, json!({"camera": named(json!({"fov": 0.5}))})),
|
||||
(DXF, json!({"camera": named(json!({"roll": 1, "up": "y", "pitch": 1.5}))})),
|
||||
(DXF, json!({"camera": named(json!({"pitch": std::f64::consts::FRAC_PI_2, "yaw": 1e6, "roll": -1e6}))})),
|
||||
(DXF, json!({"camera": named(json!({"fov": 3.1}))})),
|
||||
(DXF, json!({"camera": camera(), "hidden": "omit"})),
|
||||
(DXF, json!({"camera": camera(), "hidden": "dashed", "tolerance": 1})),
|
||||
(DXF, json!({"camera": camera(), "tolerance": 0.0001})),
|
||||
(DXF, json!({"camera": camera(), "tolerance": 1.0})),
|
||||
(DXF, json!({"camera": camera(), "path": file})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let run = |level: ParamCheck| with_box(level).execute(id, &p).map_err(|e| e.to_string());
|
||||
let (off, warn, strict) = (run(ParamCheck::Off), run(ParamCheck::Warn), run(ParamCheck::Strict));
|
||||
match &off {
|
||||
Ok(_) => {
|
||||
let warned = warn.as_ref().ok().and_then(|r| r.get("param_warnings"));
|
||||
assert!(warned.is_none(), "{id} {p}: {warned:?}");
|
||||
assert!(strict.is_ok(), "{id} {p}: works today, strict says {strict:?}");
|
||||
}
|
||||
Err(e) => assert_eq!(strict.as_ref().err(), Some(e), "{id} {p}"),
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The types themselves: a camera, a list of bodies and the message for each refusal.
|
||||
#[test]
|
||||
fn the_camera_and_body_types_read_like_the_handler() {
|
||||
let camera = |v: Value| serde_json::from_value::<SilhouetteCamera>(v).map_err(|e| e.to_string());
|
||||
assert!(camera(json!({"target": [1, 2, 3], "yaw": 0.1, "pitch": 0.2, "distance": 5})).is_ok());
|
||||
assert!(camera(json!({"target": [1, 2, 3], "yaw": 0.1, "pitch": 0.2, "distance": 5, "fov": 0, "roll": 4})).is_ok());
|
||||
for bad in [json!(null), json!([]), json!({}), json!({"target": [1, 2, 3], "yaw": 0, "pitch": 0})] {
|
||||
assert!(camera(bad.clone()).is_err_and(|e| e.contains("bad camera: ")), "{bad}");
|
||||
}
|
||||
assert!(camera(json!({"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0})).is_err_and(|e| e.contains("positive distance")));
|
||||
let bodies = |v: Value| serde_json::from_value::<BodyNames>(v).ok().map(|b| b.0);
|
||||
assert_eq!(bodies(json!("A")), Some(vec!["A".to_string()]));
|
||||
assert_eq!(bodies(json!(["A", "B"])), Some(vec!["A".to_string(), "B".to_string()]));
|
||||
assert_eq!(bodies(json!([])), Some(Vec::new()));
|
||||
assert_eq!(bodies(json!(vec!["x"; 10_000])).map(|b| b.len()), Some(10_000));
|
||||
assert!(bodies(json!(vec!["x"; 10_001])).is_none() && bodies(json!([1])).is_none() && bodies(json!(null)).is_none());
|
||||
}
|
||||
|
||||
/// What the contracts take in the schemas too: the camera is required of the export, `view` and
|
||||
/// `camera` do not come together, and the plain-typed keys.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<SilhouetteParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"view": "top", "bodies": ["Plate"], "rows": 100}), true),
|
||||
(json!({"camera": camera(), "bodies": "Plate"}), true),
|
||||
(json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 5}}), true),
|
||||
(json!({"view": "top", "camera": camera()}), false),
|
||||
(json!({"view": 3}), false),
|
||||
(json!({"camera": {"target": [0, 0], "yaw": 0, "pitch": 0, "distance": 5}}), false),
|
||||
(json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}}), false),
|
||||
(json!({"bodies": [1]}), false),
|
||||
(json!({"rows": 2.5}), false),
|
||||
(json!({"colour": 1}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ExportViewDxfParams>(),
|
||||
&[
|
||||
(json!({"camera": camera()}), true),
|
||||
(json!({"camera": camera(), "hidden": "dashed", "tolerance": 0.5, "path": "a.dxf"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"camera": camera(), "hidden": "both"}), false),
|
||||
(json!({"camera": camera(), "tolerance": 2}), false),
|
||||
(json!({"camera": camera(), "tolerance": 0}), false),
|
||||
(json!({"camera": camera(), "pathh": "a.dxf"}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// The warning for what a key gets wrong, in the handlers' words.
|
||||
#[test]
|
||||
fn the_check_names_what_is_wrong_with_a_key() {
|
||||
let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
assert_eq!(issues(SIL, json!({"view": "top", "camera": camera()})), ["give `view` or `camera`, not both"]);
|
||||
assert_eq!(issues(SIL, json!({"view": "sideways"})), ["unknown standard view `sideways`"]);
|
||||
assert_eq!(issues(SIL, json!({"rows": 1.5})), ["`rows` must be an integer"]);
|
||||
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(), "path": " "})), ["invalid path"]);
|
||||
assert_eq!(issues(DXF, json!({"camera": 1})), ["invalid camera"]);
|
||||
// Left out, the handler's own refusal stands in for "is required".
|
||||
assert!(issues(DXF, json!({})).is_empty());
|
||||
}
|
||||
|
||||
fn folder() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-silhouette-schema-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}"));
|
||||
dir
|
||||
}
|
||||
|
||||
/// Every form of both commands, in Warn: each real reply matches its output schema and carries
|
||||
/// no warning. A misspelt key still runs, says so, and the reply still matches; strict refuses.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let mut s = with_box(ParamCheck::Warn);
|
||||
s.execute("solid.sphere", &json!({"radius": 5, "center": [60, 0, 0], "body_name": "Ball"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let dir = folder();
|
||||
let run = |s: &mut Session, id: &str, p: Value| {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p}: {r}");
|
||||
r
|
||||
};
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"view": "top", "rows": 40}),
|
||||
json!({"view": "ISO", "bodies": "Plate"}),
|
||||
json!({"view": "right", "bodies": ["Plate", "Ball"], "rows": 18446744073709551615u64}),
|
||||
json!({"camera": {"target": [20, 15, 5], "yaw": 0.4, "pitch": 0.3, "distance": 300}, "rows": 32}),
|
||||
json!({"camera": {"target": [20, 15, 5], "yaw": 0.4, "pitch": 0.3, "distance": 300, "fov": 0.6}, "bodies": ["Plate"]}),
|
||||
] {
|
||||
let r = run(&mut s, SIL, p.clone());
|
||||
matches(output_schema_for::<SilhouetteResult>(), SIL, &r);
|
||||
let projection = if p["camera"]["fov"].as_f64().is_some_and(|f| f > 0.0) { "perspective" } else { "parallel" };
|
||||
assert_eq!(r["projection"], projection, "{p}");
|
||||
assert!(r["mask"].as_array().is_some_and(|m| !m.is_empty()), "{r}");
|
||||
assert_eq!(r.as_object().map(|o| o.len()), Some(10), "{r}");
|
||||
}
|
||||
let target = json!([20, 15, 5]);
|
||||
let top = json!({"target": target, "yaw": 0, "pitch": std::f64::consts::FRAC_PI_2, "distance": 150, "fov": 0});
|
||||
let file = dir.join("top.dxf");
|
||||
for p in [
|
||||
json!({"camera": top}),
|
||||
json!({"camera": top, "hidden": "omit", "tolerance": 0.5}),
|
||||
json!({"camera": top, "hidden": "dashed", "tolerance": 0.0001}),
|
||||
json!({"camera": {"target": target, "yaw": 0.5, "pitch": 0.5, "distance": 200, "fov": 0.5}, "hidden": "dashed"}),
|
||||
json!({"camera": top, "path": file}),
|
||||
] {
|
||||
let r = run(&mut s, DXF, p.clone());
|
||||
matches(output_schema_for::<ViewDxfResult>(), DXF, &r);
|
||||
assert_eq!(r["units"], "mm", "{r}");
|
||||
if p.get("path").is_some() {
|
||||
assert!(r.get("dxf").is_none() && r["path"] == json!(file), "{r}");
|
||||
assert!(std::fs::read_to_string(&file).is_ok_and(|t| t.contains("LINE")));
|
||||
} else {
|
||||
assert!(r.get("path").is_none() && r["dxf"].as_str().is_some_and(|t| t.contains("LINE")), "{r}");
|
||||
}
|
||||
assert_eq!(r["projection"], if p["camera"]["fov"].as_f64().is_some_and(|f| f > 0.0) { "perspective" } else { "orthographic" }, "{r}");
|
||||
}
|
||||
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
|
||||
for (id, p, schema) in [
|
||||
(SIL, json!({"rowz": 40}), output_schema_for::<SilhouetteResult>()),
|
||||
(DXF, json!({"camera": top, "tolerence": 0.5}), output_schema_for::<ViewDxfResult>()),
|
||||
] {
|
||||
let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{r}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id}: {r}");
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("unknown key"), "{id}: {error}");
|
||||
s.param_check = ParamCheck::Warn;
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,9 +1,26 @@
|
||||
//! Contracts and result types for sketch editing and completion in `cmd/sketch.rs`.
|
||||
//!
|
||||
//! The geometric constraints (`sketch.constraint.coincident`, `collinear`, `concentric`, `equal`,
|
||||
//! `fix`, `horizontal_vertical`, `midpoint`, `parallel`, `perpendicular`, `symmetry` and
|
||||
//! `tangent`, #12549) read their parameters from the raw JSON, in the forms and cases their
|
||||
//! handlers do, so a key is read here only where its handler reads it (`line` of a Horizontal /
|
||||
//! Vertical call that gives `points`, `a` and `b` of an Equal call that gives `curves`, `line` of
|
||||
//! a Symmetry call that gives `point`, and so on): a call that works today gets no warning. Each
|
||||
//! one is therefore read by hand, in the handler's order, and fails with the handler's message.
|
||||
//! A key the handler refuses when it is left out is optional in the type and required in the
|
||||
//! schema. Whether a curve or point exists, whether a model edge can be projected and whether
|
||||
//! the constraint can be solved stay the handler's to check.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use super::types::{IdOrName, present};
|
||||
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
|
||||
use serde::de::Error as _;
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::check::whole;
|
||||
use super::motion::require;
|
||||
use super::types::{IdOrName, Point3, present};
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
@@ -49,3 +66,780 @@ pub struct CancelResult {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The geometric constraints
|
||||
|
||||
type Fields = Map<String, Value>;
|
||||
type Read<T> = Result<T, String>;
|
||||
|
||||
/// The handler's message, whole (the check puts no key in front of it).
|
||||
fn refuse<T>(message: impl std::fmt::Display) -> Read<T> {
|
||||
Err(whole(message))
|
||||
}
|
||||
|
||||
/// The sketch a command works in, as `sketch_store::target` reads `sketch`: a feature id (a whole
|
||||
/// number) or a name. `null` is read as left out.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum SketchKey {
|
||||
Id(u64),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl JsonSchema for SketchKey {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchKey".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A sketch's feature id (a whole number, 0 or more) or its name.",
|
||||
"anyOf": [{"type": "integer", "minimum": 0}, {"type": "string"}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sketch_of(m: &Fields) -> Read<Option<SketchKey>> {
|
||||
match m.get("sketch") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(name)) => Ok(Some(SketchKey::Name(name.clone()))),
|
||||
Some(Value::Number(n)) => match n.as_u64() {
|
||||
Some(id) => Ok(Some(SketchKey::Id(id))),
|
||||
None => refuse("`sketch` must be an id or a name"),
|
||||
},
|
||||
Some(_) => refuse("`sketch` must be an id or a name"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An optional text, as `str_` reads it (`null` is left out); anything else is refused, where
|
||||
/// the handler would read it as left out.
|
||||
fn text_of(m: &Fields, key: &str) -> Read<Option<String>> {
|
||||
match m.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) => Ok(Some(s.clone())),
|
||||
Some(_) => refuse(format!("`{key}` must be text")),
|
||||
}
|
||||
}
|
||||
|
||||
/// An optional flag, as `bool_` reads it (`null` is left out); anything else is refused, where
|
||||
/// the handler would read it as left out.
|
||||
fn flag_of(m: &Fields, key: &str) -> Read<Option<bool>> {
|
||||
match m.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => refuse(format!("`{key}` must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `if_redundant`: `refuse` (in any case) turns a redundant constraint away instead of keeping
|
||||
/// it; any other text keeps it.
|
||||
fn if_redundant_of(m: &Fields) -> Read<Option<String>> {
|
||||
text_of(m, "if_redundant")
|
||||
}
|
||||
|
||||
/// A model edge `{edge: [x, y, z], body?}` or vertex `{vertex: [x, y, z], body?}`, which
|
||||
/// `project_operands` projects into the sketch before the handler reads its operands.
|
||||
fn model_pick(v: &Value) -> bool {
|
||||
v.get("edge").is_some() || v.get("vertex").is_some()
|
||||
}
|
||||
|
||||
fn model_pick_schema(generator: &mut SchemaGenerator) -> Value {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {"edge": point, "vertex": point, "body": {"type": "string"}},
|
||||
"anyOf": [{"required": ["edge"]}, {"required": ["vertex"]}]
|
||||
})
|
||||
}
|
||||
|
||||
/// A sketch entity a constraint is applied to: its id, or a model edge or vertex, which is
|
||||
/// projected into the sketch first (the projected curve or point is then the operand, and is
|
||||
/// reported in `created`). Whether the id names something and whether the model geometry can
|
||||
/// be projected is the handler's to check.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Operand {
|
||||
Id(String),
|
||||
Model(Value),
|
||||
}
|
||||
|
||||
impl JsonSchema for Operand {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchOperand".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let model = model_pick_schema(generator);
|
||||
json_schema!({
|
||||
"description": "A sketch point or curve by its id, or a model edge {\"edge\": [x, y, z], \"body\"?} or vertex {\"vertex\": [x, y, z], \"body\"?}, projected into the sketch first.",
|
||||
"anyOf": [{"type": "string"}, model]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` as an operand; anything else is refused with `message`, the handler's. Left out is
|
||||
/// the handler's to refuse.
|
||||
fn operand_of(m: &Fields, key: &str, message: &str) -> Read<Option<Operand>> {
|
||||
match m.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::String(id)) => Ok(Some(Operand::Id(id.clone()))),
|
||||
Some(v) if model_pick(v) => Ok(Some(Operand::Model(v.clone()))),
|
||||
Some(_) => refuse(message),
|
||||
}
|
||||
}
|
||||
|
||||
/// A line's middle, as a point of a constraint: `{midpoint: line_id}`.
|
||||
fn midpoint_schema() -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {"midpoint": {"type": "string"}},
|
||||
"required": ["midpoint"]
|
||||
})
|
||||
}
|
||||
|
||||
/// A point of `sketch.constraint.coincident`: an id, a model edge or vertex, or a line's middle
|
||||
/// as `{midpoint: line_id}`, which becomes a point tied there.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PointOperand {
|
||||
Id(String),
|
||||
Model(Value),
|
||||
Midpoint(String),
|
||||
}
|
||||
|
||||
impl JsonSchema for PointOperand {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"CoincidentOperand".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let model = model_pick_schema(generator);
|
||||
json_schema!({
|
||||
"description": "A sketch point, curve or axis by its id, a model edge or vertex (projected into the sketch first), or a line's middle {\"midpoint\": line_id}.",
|
||||
"anyOf": [{"type": "string"}, model, midpoint_schema()]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a Coincident operand was refused: its `midpoint` is not a curve id, or it has no form.
|
||||
enum Wrong {
|
||||
Midpoint,
|
||||
Form,
|
||||
}
|
||||
|
||||
fn point_operand(v: Option<&Value>) -> Result<Option<PointOperand>, Wrong> {
|
||||
match v {
|
||||
None => Ok(None),
|
||||
Some(Value::String(id)) => Ok(Some(PointOperand::Id(id.clone()))),
|
||||
Some(v) if model_pick(v) => Ok(Some(PointOperand::Model(v.clone()))),
|
||||
Some(v) => match v.get("midpoint") {
|
||||
Some(Value::String(line)) => Ok(Some(PointOperand::Midpoint(line.clone()))),
|
||||
Some(_) => Err(Wrong::Midpoint),
|
||||
None => Err(Wrong::Form),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the two points of `sketch.constraint.horizontal_vertical`'s `points`: a point id or a
|
||||
/// line's middle as `{midpoint: line_id}`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum AlignPoint {
|
||||
Id(String),
|
||||
Midpoint(String),
|
||||
}
|
||||
|
||||
impl JsonSchema for AlignPoint {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"AlignPoint".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "A sketch point by its id, or a line's middle {\"midpoint\": line_id} (it reuses the point a Midpoint constraint ties there, or makes one).",
|
||||
"anyOf": [{"type": "string"}, midpoint_schema()]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn align_point(v: &Value) -> Read<AlignPoint> {
|
||||
match v.get("midpoint") {
|
||||
Some(Value::String(line)) => Ok(AlignPoint::Midpoint(line.clone())),
|
||||
Some(_) => refuse("`midpoint` must be a curve id"),
|
||||
None => match v {
|
||||
Value::String(id) => Ok(AlignPoint::Id(id.clone())),
|
||||
_ => refuse("`points` must be a point reference"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Names, as `params::string_list` reads them: one name or a list of names, `null` for none,
|
||||
/// with its messages.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Names(pub Vec<String>);
|
||||
|
||||
impl JsonSchema for Names {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SketchNames".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "An id, or a list of ids.",
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}, "maxItems": crate::params::MAX_STRING_LIST}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `key` as the handler's `string_list` reads it, with its message for anything else.
|
||||
fn names_of(m: &Fields, key: &str) -> Read<Option<Names>> {
|
||||
if m.get(key).is_none_or(Value::is_null) {
|
||||
return Ok(None);
|
||||
}
|
||||
let one: Fields = m.iter().filter(|(k, _)| *k == key).map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
match crate::params::string_list("", &Value::Object(one), key) {
|
||||
Ok(names) => Ok(Some(Names(names))),
|
||||
Err(crate::EngineError::BadParams { msg, .. }) => refuse(msg),
|
||||
Err(e) => refuse(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of a call.
|
||||
fn fields<'de, D: Deserializer<'de>>(d: D) -> Result<Fields, D::Error> {
|
||||
Fields::deserialize(d)
|
||||
}
|
||||
|
||||
/// Adds a key the type has optional so that the handler refuses it when it is left out, as
|
||||
/// `require`, for every one of `keys`.
|
||||
fn require_all(schema: &mut Schema, keys: &[&str]) {
|
||||
for key in keys {
|
||||
require(schema, key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Requires one of two sets of keys, for the commands that take two forms.
|
||||
fn require_either(schema: &mut Schema, first: &[&str], second: &[&str]) {
|
||||
schema.insert("anyOf".into(), json!([{"required": first}, {"required": second}]));
|
||||
}
|
||||
|
||||
fn a_and_b_required(schema: &mut Schema) {
|
||||
require_all(schema, &["a", "b"]);
|
||||
}
|
||||
|
||||
/// What a command that adds one constraint returns: the constraint, the sketch's solve and, when
|
||||
/// the sketch already determined the constraint, what determines it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Constrained {
|
||||
/// The new constraint's id.
|
||||
pub constraint: String,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// `true` when other constraints already determined it: it is kept, shown as redundant (not
|
||||
/// when `if_redundant` is `refuse`, which refuses the call instead). Absent otherwise.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redundant: Option<bool>,
|
||||
/// With `redundant`: the constraints that determine it, as "id (Name)".
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub determined_by: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// What `sketch.constraint.horizontal_vertical`, `tangent`, `parallel`, `perpendicular`,
|
||||
/// `collinear`, `concentric` and `midpoint` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ConstraintResult {
|
||||
#[serde(flatten)]
|
||||
pub made: Constrained,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.constraint.coincident` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct CoincidentResult {
|
||||
#[serde(flatten)]
|
||||
pub made: CoincidentMade,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a Coincident call did.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum CoincidentMade {
|
||||
/// A coincident (or point-on-curve) constraint was added.
|
||||
Added(Constrained),
|
||||
/// The two points (or two lines' closest ends) were already coincident: nothing was added.
|
||||
Existing {
|
||||
/// The constraint that already joins them.
|
||||
constraint: String,
|
||||
/// Always `true`.
|
||||
existing: bool,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
sketch: Value,
|
||||
},
|
||||
/// Two circles or arcs: the second now lies on the first (a Concentric and an Equal
|
||||
/// constraint; one of them is left out when it was already there).
|
||||
Circles {
|
||||
/// The first constraint added.
|
||||
constraint: String,
|
||||
/// Every constraint added.
|
||||
constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
sketch: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// What `sketch.constraint.equal` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct EqualResult {
|
||||
#[serde(flatten)]
|
||||
pub made: EqualMade,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What an Equal call added.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum EqualMade {
|
||||
/// `a` and `b`: one Equal constraint.
|
||||
Pair(Constrained),
|
||||
/// `curves`: an Equal constraint from the first curve to each other one (a pair that was
|
||||
/// already equal is left out).
|
||||
Curves {
|
||||
/// The constraints added.
|
||||
constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
sketch: Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// What `sketch.constraint.symmetry` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SymmetryResult {
|
||||
/// The constraints added: one Symmetric for two points, two for two lines, a Symmetric and an
|
||||
/// Equal for two circles or arcs; about a point, one Midpoint constraint for each pair, on
|
||||
/// a new construction line (reported in `created`).
|
||||
pub constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What a Fix call did to the entities.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FixOutcome {
|
||||
/// Whether they are fixed now (`false`: released).
|
||||
pub fixed: bool,
|
||||
/// How many points and curves changed; those already as asked, the origin, projected
|
||||
/// geometry and a point fixed by its own coordinates are not counted.
|
||||
pub changed: usize,
|
||||
}
|
||||
|
||||
/// What `sketch.constraint.fix` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FixResult {
|
||||
/// What the call did.
|
||||
pub result: FixOutcome,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A command that takes two operands, `a` and `b`, in one form: the same keys, read with the
|
||||
/// handler's message for each.
|
||||
macro_rules! two_operands {
|
||||
(
|
||||
$(#[$meta:meta])* $name:ident,
|
||||
a: $a_doc:literal, b: $b_doc:literal, what: $what:literal
|
||||
) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = a_and_b_required)]
|
||||
pub struct $name {
|
||||
#[doc = $a_doc]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub a: Option<Operand>,
|
||||
#[doc = $b_doc]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub b: Option<Operand>,
|
||||
/// `refuse` (in any case) refuses the call when the sketch already determines the new
|
||||
/// constraint, instead of keeping it as a redundant one (any other text keeps it).
|
||||
/// Read only when the constraint turns out redundant.
|
||||
#[serde(default)]
|
||||
pub if_redundant: Option<String>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchKey>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let m = fields(d)?;
|
||||
let read = || -> Read<Self> {
|
||||
let sketch = sketch_of(&m)?;
|
||||
let a = operand_of(&m, "a", &format!("`a` must be {}", $what))?;
|
||||
let b = operand_of(&m, "b", &format!("`b` must be {}", $what))?;
|
||||
Ok(Self { a, b, if_redundant: if_redundant_of(&m)?, sketch })
|
||||
};
|
||||
read().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
two_operands! {
|
||||
/// Make two curves, or a curve and a sketch axis, tangent. Two different curves are needed.
|
||||
TangentParams,
|
||||
a: "A curve's id, or `x` / `y` for the sketch's own axis (a curve with that id wins; an axis becomes a linked construction line, reported in `created`), or a model edge.",
|
||||
b: "The other curve, as `a`.",
|
||||
what: "a curve id, or x / y for a sketch axis"
|
||||
}
|
||||
|
||||
two_operands! {
|
||||
/// Make two lines, or a line and a sketch axis, parallel. Two different lines are needed.
|
||||
ParallelParams,
|
||||
a: "A line's id, or `x` / `y` for the sketch's own axis (a curve with that id wins; an axis becomes a linked construction line, reported in `created`), or a model edge.",
|
||||
b: "The other line, as `a`.",
|
||||
what: "a curve id, or x / y for a sketch axis"
|
||||
}
|
||||
|
||||
two_operands! {
|
||||
/// Make two lines, or a line and a sketch axis, perpendicular. Two different lines are
|
||||
/// needed.
|
||||
PerpendicularParams,
|
||||
a: "A line's id, or `x` / `y` for the sketch's own axis (a curve with that id wins; an axis becomes a linked construction line, reported in `created`), or a model edge.",
|
||||
b: "The other line, as `a`.",
|
||||
what: "a curve id, or x / y for a sketch axis"
|
||||
}
|
||||
|
||||
two_operands! {
|
||||
/// Make two lines, or a line and a sketch axis, lie on one line. Two different lines are
|
||||
/// needed.
|
||||
CollinearParams,
|
||||
a: "A line's id, or `x` / `y` for the sketch's own axis (a curve with that id wins; an axis becomes a linked construction line, reported in `created`), or a model edge.",
|
||||
b: "The other line, as `a`.",
|
||||
what: "a curve id, or x / y for a sketch axis"
|
||||
}
|
||||
|
||||
two_operands! {
|
||||
/// Give two circles or arcs one centre, or put a circle's or an arc's centre on a sketch point
|
||||
/// (the origin too), in either order.
|
||||
ConcentricParams,
|
||||
a: "A circle's or arc's id, or a sketch point's id, or a model edge or vertex.",
|
||||
b: "The other circle or arc, or the sketch point, as `a`.",
|
||||
what: "a curve id"
|
||||
}
|
||||
|
||||
/// Make two curves equal (two lines the same length, two circles or arcs the same radius), or
|
||||
/// each of a list of curves equal to the first, in one undo step.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = equal_required)]
|
||||
pub struct EqualParams {
|
||||
/// A line, circle or arc, or a model edge. Read when `curves` is not given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub a: Option<Operand>,
|
||||
/// The other line, circle or arc, as `a`. Read when `curves` is not given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub b: Option<Operand>,
|
||||
/// 2 to 200 lines, circles or arcs (ids), each made equal to the first. When the key is given
|
||||
/// (`null` too), `a` and `b` are ignored, and so is `if_redundant`.
|
||||
#[serde(default)]
|
||||
pub curves: Option<Names>,
|
||||
/// `refuse` (in any case) refuses the call when the sketch already determines the new
|
||||
/// constraint, instead of keeping it as a redundant one (any other text keeps it). Read
|
||||
/// only with `a` and `b`, and when the constraint turns out redundant.
|
||||
#[serde(default)]
|
||||
pub if_redundant: Option<String>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchKey>,
|
||||
}
|
||||
|
||||
fn equal_required(schema: &mut Schema) {
|
||||
require_either(schema, &["a", "b"], &["curves"]);
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for EqualParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let m = fields(d)?;
|
||||
let read = || -> Read<Self> {
|
||||
if m.contains_key("curves") {
|
||||
let curves = names_of(&m, "curves")?;
|
||||
if !(2..=200).contains(&curves.as_ref().map_or(0, |c| c.0.len())) {
|
||||
return refuse("`curves` must list 2 to 200 lines or circles/arcs");
|
||||
}
|
||||
return Ok(Self { a: None, b: None, curves, if_redundant: None, sketch: sketch_of(&m)? });
|
||||
}
|
||||
let sketch = sketch_of(&m)?;
|
||||
let a = operand_of(&m, "a", "`a` must be a curve id")?;
|
||||
let b = operand_of(&m, "b", "`b` must be a curve id")?;
|
||||
Ok(Self { a, b, curves: None, if_redundant: if_redundant_of(&m)?, sketch })
|
||||
};
|
||||
read().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Make two points coincident, put a point on a curve or a sketch axis, join the closest ends of
|
||||
/// two lines, or put one circle or arc on another (concentric and equal).
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = a_and_b_required)]
|
||||
pub struct CoincidentParams {
|
||||
/// A point, a curve or axis (`x` / `y`: the sketch's own axes, unless a curve has that id; an
|
||||
/// axis becomes a linked construction line on it, reported in `created`), a line's middle
|
||||
/// `{midpoint: line_id}` or a model edge or vertex.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PointOperand")]
|
||||
pub a: Option<PointOperand>,
|
||||
/// The other one, as `a`. Two lines join their closest ends; two circles or arcs make the
|
||||
/// second lie on the first.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PointOperand")]
|
||||
pub b: Option<PointOperand>,
|
||||
/// `refuse` (in any case) refuses the call when the sketch already determines the new
|
||||
/// constraint, instead of keeping it as a redundant one (any other text keeps it). Read
|
||||
/// only when a constraint is added for a point and a point, a point and a curve or two
|
||||
/// lines, and it turns out redundant.
|
||||
#[serde(default)]
|
||||
pub if_redundant: Option<String>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchKey>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CoincidentParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let m = fields(d)?;
|
||||
let read = || -> Read<Self> {
|
||||
let sketch = sketch_of(&m)?;
|
||||
let (a, b) = (point_operand(m.get("a")), point_operand(m.get("b")));
|
||||
// The handler looks at the middles first, then `b`, then `a`.
|
||||
if matches!(a, Err(Wrong::Midpoint)) || matches!(b, Err(Wrong::Midpoint)) {
|
||||
return refuse("`midpoint` must be a curve id");
|
||||
}
|
||||
let b = b.or_else(|_| refuse("`b` must be a point or curve"))?;
|
||||
// With no `b` the handler stops at `b` first, whatever `a` is.
|
||||
let a = a.or_else(|_| if b.is_none() { refuse("`b` must be a point or curve") } else { refuse("`a` must be a point reference") })?;
|
||||
Ok(Self { a, b, if_redundant: if_redundant_of(&m)?, sketch })
|
||||
};
|
||||
read().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a line horizontal or vertical, or two points level (same height) or plumb (one above the
|
||||
/// other), by `line` or by `points`.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = horizontal_vertical_required)]
|
||||
pub struct HorizontalVerticalParams {
|
||||
/// A line's id. Read when `points` is not a list; ignored when it is.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "String")]
|
||||
pub line: Option<String>,
|
||||
/// Exactly two points: ids, or a line's middle as `{midpoint: line_id}`. When it is a list,
|
||||
/// `line` is ignored; anything else is ignored, as if the key were left out.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "[AlignPoint; 2]")]
|
||||
pub points: Option<[AlignPoint; 2]>,
|
||||
/// `horizontal` or `vertical` (in any case). Default, and for any other text: horizontal when
|
||||
/// the line or the two points are further apart across than up, else vertical.
|
||||
#[serde(default)]
|
||||
pub mode: Option<String>,
|
||||
/// `refuse` (in any case) refuses the call when the sketch already determines the new
|
||||
/// constraint, instead of keeping it as a redundant one (any other text keeps it). Read
|
||||
/// only when the constraint turns out redundant.
|
||||
#[serde(default)]
|
||||
pub if_redundant: Option<String>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchKey>,
|
||||
}
|
||||
|
||||
fn horizontal_vertical_required(schema: &mut Schema) {
|
||||
require_either(schema, &["line"], &["points"]);
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for HorizontalVerticalParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let m = fields(d)?;
|
||||
let read = || -> Read<Self> {
|
||||
let mode = text_of(&m, "mode")?;
|
||||
let sketch = sketch_of(&m)?;
|
||||
let if_redundant = if_redundant_of(&m)?;
|
||||
if let Some(list) = m.get("points").and_then(Value::as_array) {
|
||||
let [a, b] = list.as_slice() else { return refuse("`points` needs exactly two point references") };
|
||||
let points = Some([align_point(a)?, align_point(b)?]);
|
||||
return Ok(Self { line: None, points, mode, if_redundant, sketch });
|
||||
}
|
||||
let line = match m.get("line") {
|
||||
None => None,
|
||||
Some(Value::String(id)) => Some(id.clone()),
|
||||
Some(_) => return refuse("`line` must be a curve id"),
|
||||
};
|
||||
Ok(Self { line, points: None, mode, if_redundant, sketch })
|
||||
};
|
||||
read().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tie a point to the middle of a line, or make two lines' middles meet (the first stays, the
|
||||
/// second moves), by `point` and `line` or by `a` and `b`.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = midpoint_required)]
|
||||
pub struct MidpointParams {
|
||||
/// A point or a line, in pick order (a point and a line either way round, or two lines). When
|
||||
/// `a` or `b` is given (`null` too), `point` and `line` are ignored.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub a: Option<Operand>,
|
||||
/// The other one, as `a`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub b: Option<Operand>,
|
||||
/// The point. Read when neither `a` nor `b` is given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub point: Option<Operand>,
|
||||
/// The line it goes to the middle of. Read when neither `a` nor `b` is given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub line: Option<Operand>,
|
||||
/// `refuse` (in any case) refuses the call when the sketch already determines the new
|
||||
/// constraint, instead of keeping it as a redundant one (any other text keeps it). Read
|
||||
/// only when the constraint turns out redundant.
|
||||
#[serde(default)]
|
||||
pub if_redundant: Option<String>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchKey>,
|
||||
}
|
||||
|
||||
fn midpoint_required(schema: &mut Schema) {
|
||||
require_either(schema, &["a", "b"], &["point", "line"]);
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for MidpointParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let m = fields(d)?;
|
||||
let read = || -> Read<Self> {
|
||||
let sketch = sketch_of(&m)?;
|
||||
let none = Self { a: None, b: None, point: None, line: None, if_redundant: None, sketch };
|
||||
if m.contains_key("a") || m.contains_key("b") {
|
||||
// A `null` is a value here, which the handler refuses.
|
||||
let a = operand_of(&m, "a", "`a` must be a point or line id")?;
|
||||
let b = operand_of(&m, "b", "`b` must be a point or line id")?;
|
||||
return Ok(Self { a, b, if_redundant: if_redundant_of(&m)?, ..none });
|
||||
}
|
||||
let point = operand_of(&m, "point", "`point` must be a point reference")?;
|
||||
let line = operand_of(&m, "line", "`line` must be a curve id")?;
|
||||
Ok(Self { point, line, if_redundant: if_redundant_of(&m)?, ..none })
|
||||
};
|
||||
read().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Make two points, two lines or two circles or arcs mirror images about a line or a sketch axis
|
||||
/// (`line`), or about a point (`point`): each pair then joined by a construction line that the
|
||||
/// point is the middle of.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = symmetry_required)]
|
||||
pub struct SymmetryParams {
|
||||
/// The first point or curve (lines mirror their ends, circles and arcs their centres). It
|
||||
/// stays; the second moves onto its mirror image.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub a: Option<Operand>,
|
||||
/// The second point or curve, as `a`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub b: Option<Operand>,
|
||||
/// The symmetry line's id, or `x` / `y` for the sketch's own axis (a curve with that id wins;
|
||||
/// an axis becomes a linked construction line, reported in `created`). A point's id (or
|
||||
/// `origin`) instead makes the symmetry about that point. Read when `point` is not given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub line: Option<Operand>,
|
||||
/// The point the symmetry is about. When the key is given (`null` too), `line` is ignored.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "Operand")]
|
||||
pub point: Option<Operand>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchKey>,
|
||||
}
|
||||
|
||||
fn symmetry_required(schema: &mut Schema) {
|
||||
require_all(schema, &["a", "b"]);
|
||||
require_either(schema, &["line"], &["point"]);
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SymmetryParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let m = fields(d)?;
|
||||
let read = || -> Read<Self> {
|
||||
let sketch = sketch_of(&m)?;
|
||||
let (mut point, mut line) = (None, None);
|
||||
if m.contains_key("point") {
|
||||
// A `null` is a value here, which the handler refuses.
|
||||
point = operand_of(&m, "point", "`point` must be a point reference")?;
|
||||
} else {
|
||||
line = operand_of(&m, "line", "`line` must be a curve id, or x / y for a sketch axis")?;
|
||||
}
|
||||
let a = operand_of(&m, "a", "`a` must be a point or curve")?;
|
||||
let b = operand_of(&m, "b", "`b` must be a point or curve")?;
|
||||
Ok(Self { a, b, line, point, sketch })
|
||||
};
|
||||
read().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fix points and curves where they are, or release them (a curve keeps its radius too; the
|
||||
/// origin, projected geometry and a point fixed by its own coordinates are left as they are).
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = fix_required)]
|
||||
pub struct FixParams {
|
||||
/// One point or curve (an id). Goes first among `entities`.
|
||||
#[serde(default)]
|
||||
pub entity: Option<String>,
|
||||
/// Points and curves (ids): one, or a list of up to 10000. `entity` or `entities` must name
|
||||
/// at least one.
|
||||
#[serde(default)]
|
||||
pub entities: Option<Names>,
|
||||
/// `true` fixes them all, `false` releases them all. Default: fix them, unless all are
|
||||
/// fixed already, then release them.
|
||||
#[serde(default)]
|
||||
pub fixed: Option<bool>,
|
||||
/// The sketch (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchKey>,
|
||||
}
|
||||
|
||||
fn fix_required(schema: &mut Schema) {
|
||||
require_either(schema, &["entity"], &["entities"]);
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FixParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let m = fields(d)?;
|
||||
let read = || -> Read<Self> {
|
||||
let entities = names_of(&m, "entities")?;
|
||||
let fixed = flag_of(&m, "fixed")?;
|
||||
// The handler reads `entity` as text only; the refusal for none at all comes first.
|
||||
if entities.as_ref().is_none_or(|e| e.0.is_empty()) && !m.get("entity").is_some_and(Value::is_string) {
|
||||
return refuse("`entity` or `entities` must name sketch points or curves");
|
||||
}
|
||||
let entity = text_of(&m, "entity")?;
|
||||
Ok(Self { entity, entities, fixed, sketch: sketch_of(&m)? })
|
||||
};
|
||||
read().map_err(D::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -114,6 +114,44 @@ pub struct Pasted {
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Replace the contents of a sketch with a copy of another sketch's, in one undo step. The sketch
|
||||
/// keeps its plane, its place in the timeline and the features built on it.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ReplaceParams {
|
||||
/// The sketch to replace (default: the one being edited).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchRef>,
|
||||
/// The sketch whose curves, points, constraints, dimensions and texts take its place.
|
||||
pub from: SketchRef,
|
||||
/// A design file `from` is in (default: this design).
|
||||
#[serde(default)]
|
||||
pub file: Option<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.replace` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Replaced {
|
||||
/// The replaced sketch's feature id.
|
||||
pub sketch_id: u64,
|
||||
/// The sketch the contents came from.
|
||||
pub from: String,
|
||||
/// The design file it is in, when not this design.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub file: Option<String>,
|
||||
/// The sketch's new curves' ids.
|
||||
pub curves: Vec<String>,
|
||||
/// Its new lone points' ids.
|
||||
pub points: Vec<String>,
|
||||
/// Its new constraints' and dimensions' ids.
|
||||
pub constraints: Vec<String>,
|
||||
/// The sketch's solve: `{solved, dof}`.
|
||||
pub sketch: Value,
|
||||
/// In the warn parameter-check mode: what the call's parameters got wrong.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub param_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_clipboard_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,744 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
BreakParams, BreakResult, ChamferDistanceAngleParams, ChamferEqualParams, ChamferResult, ChamferTwoParams, CircularPatternParams, ExtendParams,
|
||||
ExtendResult, FilletParams, FilletResult, MirrorParams, MirrorResult, MoveParams, MoveResult, OffsetBreakParams, OffsetBreakResult, OffsetParams,
|
||||
OffsetResult, OffsetSide, PatternResult, RectangularPatternParams, ScaleParams, ScaleResult, TrimOutcome, TrimParams, TrimPathParams,
|
||||
TrimPathResult, TrimResult,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
|
||||
|
||||
/// A session with a sketch being edited that holds a square (10 mm either side of the origin) and
|
||||
/// the ids of its four sides.
|
||||
fn editing(level: ParamCheck) -> (Session, Vec<String>) {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let r = s.execute("sketch.rectangle.center", &json!({"center": [0, 0], "corner": [10, 10]})).unwrap_or_else(|e| panic!("{e}"));
|
||||
let sides = r["created"]["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();
|
||||
(s, sides)
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn id_of(r: &Value, list: &str) -> String {
|
||||
r["created"][list][0].as_str().unwrap_or_else(|| panic!("{r}")).to_string()
|
||||
}
|
||||
|
||||
fn error(level: ParamCheck, id: &str, p: &Value) -> Option<String> {
|
||||
let (mut s, _) = editing(level);
|
||||
s.execute(id, p).err().map(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A call the handler already refuses gets the same message in strict, with a sketch being edited.
|
||||
/// (`assert_strict_keeps_todays_errors` runs after the sketch is finished, so a refusal that comes
|
||||
/// from the design's side would read "no sketch is being edited" there.)
|
||||
fn assert_strict_keeps_todays_errors(cases: &[(&str, Value)]) {
|
||||
for (id, p) in cases {
|
||||
let off = error(ParamCheck::Off, id, p);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict, id, p), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<TrimParams>(), &["at", "curve", "sketch"]);
|
||||
assert_keys(input_schema_for::<TrimPathParams>(), &["dry_run", "points", "sketch"]);
|
||||
assert_keys(input_schema_for::<BreakParams>(), &["at", "curve", "points", "sketch"]);
|
||||
assert_keys(input_schema_for::<ExtendParams>(), &["at", "curve", "curves", "sketch"]);
|
||||
assert_keys(input_schema_for::<FilletParams>(), &["a", "b", "point", "points", "radius", "sketch"]);
|
||||
assert_keys(input_schema_for::<ChamferEqualParams>(), &["a", "b", "distance", "point", "points", "sketch"]);
|
||||
assert_keys(input_schema_for::<ChamferTwoParams>(), &["a", "b", "distance", "distance2", "point", "points", "sketch"]);
|
||||
assert_keys(input_schema_for::<ChamferDistanceAngleParams>(), &["a", "angle", "b", "distance", "point", "points", "sketch"]);
|
||||
assert_keys(
|
||||
input_schema_for::<OffsetParams>(),
|
||||
&["chain", "curves", "direction", "distance", "distance2", "flip", "instances", "side", "sketch", "symmetric"],
|
||||
);
|
||||
assert_keys(input_schema_for::<OffsetBreakParams>(), &["curve", "sketch"]);
|
||||
assert_keys(input_schema_for::<MirrorParams>(), &["entities", "line", "sketch"]);
|
||||
assert_keys(input_schema_for::<MoveParams>(), &["angle", "center", "copy", "entities", "rotate_constraints", "sketch", "translate"]);
|
||||
assert_keys(input_schema_for::<ScaleParams>(), &["base", "entities", "factor", "sketch"]);
|
||||
assert_keys(input_schema_for::<CircularPatternParams>(), &["alignment", "angle", "center", "count", "entities", "pattern", "reversed", "sketch"]);
|
||||
assert_keys(
|
||||
input_schema_for::<RectangularPatternParams>(),
|
||||
&[
|
||||
"alignment",
|
||||
"alignment2",
|
||||
"count",
|
||||
"count2",
|
||||
"dir",
|
||||
"dir2",
|
||||
"direction",
|
||||
"direction2",
|
||||
"entities",
|
||||
"pattern",
|
||||
"reversed",
|
||||
"reversed2",
|
||||
"sketch",
|
||||
"spacing",
|
||||
"spacing2",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let many = |n: usize, item: Value| Value::Array(vec![item; n]);
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("sketch.trim", json!({})),
|
||||
("sketch.trim", json!({"curve": "l1"})),
|
||||
("sketch.trim", json!({"curve": "l1", "at": null})),
|
||||
("sketch.trim", json!({"curve": "l1", "at": [1]})),
|
||||
("sketch.trim", json!({"curve": "l1", "at": "x"})),
|
||||
("sketch.trim", json!({"at": [0, 0]})),
|
||||
("sketch.trim", json!({"at": [0, 0], "curve": 5})),
|
||||
("sketch.trim", json!({"at": [0, 0], "curve": null})),
|
||||
("sketch.trim", json!({"at": [0, 0], "curve": "l1", "sketch": -1})),
|
||||
("sketch.trim", json!({"at": [0, 0], "curve": "l1", "sketch": 1.5})),
|
||||
("sketch.trim", json!({"at": [0, 0], "curve": "l1", "sketch": true})),
|
||||
("sketch.trim.path", json!({})),
|
||||
("sketch.trim.path", json!({"points": 5})),
|
||||
("sketch.trim.path", json!({"points": null})),
|
||||
("sketch.trim.path", json!({"points": [[0, 0]]})),
|
||||
("sketch.trim.path", json!({"points": [[0, 0], "x"]})),
|
||||
("sketch.trim.path", json!({"points": many(2001, json!([0, 0]))})),
|
||||
("sketch.break", json!({})),
|
||||
("sketch.break", json!({"curve": "l1"})),
|
||||
("sketch.break", json!({"curve": "l1", "at": [1, 2, 3]})),
|
||||
("sketch.break", json!({"curve": 7, "at": [0, 0]})),
|
||||
("sketch.break", json!({"curve": "l1", "points": "x"})),
|
||||
("sketch.break", json!({"curve": "l1", "points": null})),
|
||||
("sketch.break", json!({"curve": "l1", "points": [[1, 2], "x"]})),
|
||||
("sketch.break", json!({"curve": "l1", "points": many(1001, json!([0, 0]))})),
|
||||
("sketch.extend", json!({})),
|
||||
("sketch.extend", json!({"curve": "l1"})),
|
||||
("sketch.extend", json!({"at": [0, 0]})),
|
||||
("sketch.extend", json!({"curve": 3, "at": [0, 0]})),
|
||||
("sketch.extend", json!({"curves": "x"})),
|
||||
("sketch.extend", json!({"curves": null})),
|
||||
("sketch.extend", json!({"curves": ["l1"]})),
|
||||
("sketch.extend", json!({"curves": ["l1", 2]})),
|
||||
("sketch.extend", json!({"curves": ["l1", "l2"], "curve": "l1"})),
|
||||
("sketch.extend", json!({"curves": ["l1", "l2"], "curve": null})),
|
||||
("sketch.extend", json!({"curves": ["l1", "l2"], "at": [0, 0]})),
|
||||
("sketch.extend", json!({"curves": ["l1", "l2"], "at": [[0, 0]]})),
|
||||
("sketch.extend", json!({"curves": ["l1", "l2"], "at": null})),
|
||||
("sketch.fillet", json!({"point": "l1.end"})),
|
||||
("sketch.fillet", json!({"point": "l1.end", "radius": true})),
|
||||
("sketch.fillet", json!({"point": "l1.end", "radius": " "})),
|
||||
("sketch.fillet", json!({"point": "l1.end", "radius": 1e9})),
|
||||
("sketch.fillet", json!({"radius": 1})),
|
||||
("sketch.fillet", json!({"radius": 1, "point": 5})),
|
||||
("sketch.fillet", json!({"radius": 1, "a": "l1"})),
|
||||
("sketch.fillet", json!({"radius": 1, "a": "l1", "b": 2})),
|
||||
("sketch.fillet", json!({"radius": 1, "a": 1, "b": "l2"})),
|
||||
("sketch.fillet", json!({"radius": 1, "points": []})),
|
||||
("sketch.fillet", json!({"radius": 1, "points": many(501, json!("l1.end"))})),
|
||||
("sketch.fillet", json!({"radius": 1, "points": [5]})),
|
||||
("sketch.fillet", json!({"radius": 1, "points": [{"a": "l1"}]})),
|
||||
("sketch.fillet", json!({"radius": 1, "points": [{}]})),
|
||||
("sketch.chamfer.equal_distance", json!({"point": "l1.end"})),
|
||||
("sketch.chamfer.equal_distance", json!({"point": "l1.end", "distance": "2"})),
|
||||
("sketch.chamfer.equal_distance", json!({"point": "l1.end", "distance": null})),
|
||||
("sketch.chamfer.equal_distance", json!({"point": "l1.end", "distance": 1e9})),
|
||||
("sketch.chamfer.equal_distance", json!({"distance": 2})),
|
||||
("sketch.chamfer.equal_distance", json!({"distance": 2, "points": []})),
|
||||
("sketch.chamfer.equal_distance", json!({"distance": 2, "points": [5]})),
|
||||
("sketch.chamfer.two_distance", json!({"point": "l1.end", "distance2": 2})),
|
||||
("sketch.chamfer.two_distance", json!({"point": "l1.end", "distance": 2})),
|
||||
("sketch.chamfer.two_distance", json!({"point": "l1.end", "distance": 2, "distance2": "x"})),
|
||||
("sketch.chamfer.two_distance", json!({"distance": 2, "distance2": 3, "a": "l1"})),
|
||||
("sketch.chamfer.distance_angle", json!({"point": "l1.end", "distance": 2})),
|
||||
("sketch.chamfer.distance_angle", json!({"point": "l1.end", "distance": 2, "angle": "45"})),
|
||||
("sketch.chamfer.distance_angle", json!({"point": "l1.end", "angle": 45})),
|
||||
("sketch.chamfer.distance_angle", json!({"distance": 2, "angle": 45})),
|
||||
("sketch.offset", json!({})),
|
||||
("sketch.offset", json!({"curves": []})),
|
||||
("sketch.offset", json!({"curves": null, "distance": 1})),
|
||||
("sketch.offset", json!({"curves": 5, "distance": 1})),
|
||||
("sketch.offset", json!({"curves": [5], "distance": 1})),
|
||||
("sketch.offset", json!({"curves": many(501, json!("l1")), "distance": 1})),
|
||||
("sketch.offset", json!({"curves": ["l1"]})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": true})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": ""})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1e9})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "direction": "negative"})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "direction": "Reverse"})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "direction": "up"})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "distance2": null})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "distance2": true})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "instances": 0})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "instances": 101})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "instances": 2.5})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "instances": true})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "instances": ["2"]})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "instances": " "})),
|
||||
("sketch.offset", json!({"curves": ["l1"], "distance": 1, "sketch": []})),
|
||||
("sketch.offset.break", json!({})),
|
||||
("sketch.offset.break", json!({"curve": 5})),
|
||||
("sketch.offset.break", json!({"curve": null})),
|
||||
("sketch.offset.break", json!({"curve": "l1", "sketch": -3})),
|
||||
("sketch.mirror", json!({})),
|
||||
("sketch.mirror", json!({"entities": ["l1"]})),
|
||||
("sketch.mirror", json!({"entities": ["l1"], "line": 5})),
|
||||
("sketch.mirror", json!({"entities": ["l1"], "line": null})),
|
||||
("sketch.mirror", json!({"entities": ["l1"], "line": ["l2"]})),
|
||||
("sketch.mirror", json!({"entities": 5, "line": "l2"})),
|
||||
("sketch.mirror", json!({"entities": [5], "line": "l2"})),
|
||||
("sketch.mirror", json!({"entities": ["l1"], "line": "l2", "sketch": 1.5})),
|
||||
("sketch.move", json!({"entities": ["l1"], "translate": 5})),
|
||||
("sketch.move", json!({"entities": ["l1"], "translate": null})),
|
||||
("sketch.move", json!({"entities": ["l1"], "translate": [1]})),
|
||||
("sketch.move", json!({"entities": ["l1"], "angle": "30"})),
|
||||
("sketch.move", json!({"entities": ["l1"], "angle": null})),
|
||||
("sketch.move", json!({"entities": ["l1"], "angle": 1e9})),
|
||||
("sketch.move", json!({"entities": null})),
|
||||
("sketch.move", json!({"entities": [true]})),
|
||||
("sketch.move", json!({"entities": ["l1"], "center": true})),
|
||||
("sketch.move", json!({"entities": ["l1"], "center": null})),
|
||||
("sketch.move", json!({"entities": ["l1"], "center": [1]})),
|
||||
("sketch.scale", json!({"entities": ["l1"]})),
|
||||
("sketch.scale", json!({"entities": ["l1"], "factor": 0})),
|
||||
("sketch.scale", json!({"entities": ["l1"], "factor": "2"})),
|
||||
("sketch.scale", json!({"entities": ["l1"], "factor": 1e6})),
|
||||
("sketch.scale", json!({"entities": ["l1"], "factor": 2, "base": true})),
|
||||
("sketch.scale", json!({"entities": ["l1"], "factor": 2, "base": [1]})),
|
||||
("sketch.scale", json!({"factor": 2})),
|
||||
("sketch.scale", json!({"entities": "l1", "factor": 2, "sketch": false})),
|
||||
("sketch.pattern.circular", json!({})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0]})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": true})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": null})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 0})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 2.5})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 501})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 1e9})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": 5, "count": 3})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": null, "count": 3})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "count": 3})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 3, "angle": true})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 3, "angle": 0})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 3, "angle": 361})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 3, "alignment": "middle"})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 3, "alignment": null})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 3, "reversed": "yes"})),
|
||||
("sketch.pattern.circular", json!({"entities": ["l1"], "center": [20, 0], "count": 3, "reversed": null})),
|
||||
("sketch.pattern.circular", json!({"center": [20, 0], "count": 3})),
|
||||
("sketch.pattern.circular", json!({"entities": 5, "center": [20, 0], "count": 3})),
|
||||
("sketch.pattern.rectangular", json!({})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": true})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": null})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 1e9})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "spacing": 5})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 0, "spacing": 5})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "count2": false})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "count2": 501})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "spacing2": [1]})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "dir": [0, 0]})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "dir": null})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "dir": "x"})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "dir2": [0, 0]})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "direction": 5})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "direction2": true})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "alignment": "up"})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "alignment2": 1})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "reversed": 1})),
|
||||
("sketch.pattern.rectangular", json!({"entities": ["l1"], "count": 3, "spacing": 5, "reversed2": "no"})),
|
||||
("sketch.pattern.rectangular", json!({"count": 3, "spacing": 5})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// What the check says about a call, one message per problem.
|
||||
fn warnings(id: &str, p: Value) -> Vec<String> {
|
||||
issues(id, &p).into_iter().map(|i| i.message).collect()
|
||||
}
|
||||
|
||||
/// The keys a handler reads only in some cases are read only in those cases, so a call that works
|
||||
/// today gets no warning for what the handler never looks at, and a call the handler refuses is
|
||||
/// refused for its key with the handler's message.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handler_does() {
|
||||
let none = Vec::<String>::new();
|
||||
// Break: `points` when the key is there, otherwise `at`.
|
||||
assert_eq!(warnings("sketch.break", json!({"curve": "l1", "points": [[0, 1]]})), none);
|
||||
assert_eq!(warnings("sketch.break", json!({"curve": "l1", "points": [[0, 1]], "at": 5})), none);
|
||||
assert_eq!(warnings("sketch.break", json!({"curve": "l1", "at": [0, 1]})), none);
|
||||
assert_eq!(warnings("sketch.break", json!({"curve": "l1", "at": 5})), ["`at` must be a point [x, y]"]);
|
||||
assert_eq!(warnings("sketch.break", json!({"curve": "l1", "points": null, "at": [0, 1]})), ["`points` must be a list of points"]);
|
||||
assert_eq!(warnings("sketch.break", json!({"curve": "l1", "points": [5]})), ["`points` must contain [x, y] points"]);
|
||||
// Extend: the two-curve form when `curves` is there.
|
||||
assert_eq!(warnings("sketch.extend", json!({"curve": "l1", "at": [0, 1]})), none);
|
||||
assert_eq!(warnings("sketch.extend", json!({"curves": ["l1", "l2"]})), none);
|
||||
assert_eq!(warnings("sketch.extend", json!({"curves": ["l1", "l2"], "at": [[0, 1], {"x": 1, "y": 2}]})), none);
|
||||
assert_eq!(
|
||||
warnings("sketch.extend", json!({"curves": ["l1", "l2"], "curve": "l1"})),
|
||||
["give `curve` with `at` (one curve) or `curves` (two curves), not both"]
|
||||
);
|
||||
assert_eq!(
|
||||
warnings("sketch.extend", json!({"curves": ["l1", "l2"], "at": [0, 1]})),
|
||||
["with `curves`, `at` is one point per curve: [[x,y],[x,y]]"]
|
||||
);
|
||||
assert_eq!(warnings("sketch.extend", json!({"curve": "l1", "at": [[0, 1], [1, 2]]})), ["`at` must be a point [x, y]"]);
|
||||
// Fillet and chamfers: `points` when it is a list; otherwise `point`, or `a` and `b` when
|
||||
// `point` is not text.
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "point": "l1.end"})), none);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": "r / 2", "a": "l1", "b": "l2"})), none);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "point": "l1.end", "a": 5, "b": null})), none);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "point": 7, "a": "l1", "b": "l2"})), none);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "points": ["l1.end", {"a": "l1", "b": "l2"}, {"point": "l3.end", "a": 4}]})), none);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "points": ["l1.end"], "point": 3, "a": 4, "b": 5})), none);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "points": "l1.end", "point": "l2.end"})), none);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "point": null, "a": "l1", "b": "l2"})), none);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "a": "l1"})), ["`b` must be a line"]);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "points": [{"b": "l1"}]})), ["`a` must be a line"]);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "points": [3]})), ["`a` must be a line"]);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 2, "points": []})), ["`points` must list 1…500 corners"]);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": [1], "point": "l1.end"})), ["`radius` must be a number or an expression"]);
|
||||
assert_eq!(warnings("sketch.fillet", json!({"radius": 1e10, "point": "l1.end"})), ["`radius` must be a number between -1e9 and 1e9"]);
|
||||
assert_eq!(warnings("sketch.chamfer.equal_distance", json!({"distance": 2, "points": ["l1.end"], "a": 1})), none);
|
||||
assert_eq!(warnings("sketch.chamfer.two_distance", json!({"distance": 2, "distance2": -1, "a": "l1", "b": "l2"})), none);
|
||||
assert_eq!(warnings("sketch.chamfer.distance_angle", json!({"distance": 2, "angle": 30, "point": "l1.end"})), none);
|
||||
assert_eq!(
|
||||
warnings("sketch.chamfer.distance_angle", json!({"distance": 2, "angle": "30", "point": "l1.end"})),
|
||||
["`angle` must be a number (degrees)"]
|
||||
);
|
||||
// Offset: `symmetric` is read unless `direction` is `symmetric`; `direction` that is not text
|
||||
// is not read at all.
|
||||
let offset = |extra: Value| {
|
||||
let mut p = json!({"curves": ["l1"], "distance": 2});
|
||||
for (k, v) in extra.as_object().into_iter().flatten() {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
warnings("sketch.offset", p)
|
||||
};
|
||||
assert_eq!(offset(json!({"direction": "Symmetric", "symmetric": "yes"})), none);
|
||||
assert_eq!(offset(json!({"direction": "one_side", "symmetric": true})), none);
|
||||
assert_eq!(offset(json!({"direction": 5, "symmetric": true})), none);
|
||||
assert_eq!(offset(json!({"direction": null, "side": [1, 2], "chain": false, "flip": true})), none);
|
||||
assert_eq!(offset(json!({"symmetric": "yes"})), ["`symmetric` must be true or false"]);
|
||||
assert_eq!(offset(json!({"direction": "positive", "symmetric": 1})), ["`symmetric` must be true or false"]);
|
||||
assert_eq!(offset(json!({"distance2": "d / 2", "instances": "n"})), none);
|
||||
assert_eq!(offset(json!({"instances": 100, "side": {"x": 1, "y": 2}})), none);
|
||||
assert_eq!(offset(json!({"instances": 2.0})), none);
|
||||
assert_eq!(offset(json!({"instances": 0})), ["`instances` must be a whole number from 1 to 100, or an expression for one"]);
|
||||
// A `side` that is not a point is read as left out, as the handler does.
|
||||
assert_eq!(offset(json!({"side": "left"})), none);
|
||||
assert_eq!(offset(json!({"flip": 1})), ["`flip` must be true or false"]);
|
||||
assert_eq!(offset(json!({"direction": "down"})), ["unknown direction `down` (one_side, symmetric)"]);
|
||||
assert_eq!(
|
||||
offset(json!({"direction": "FLIP"})),
|
||||
["an offset has no negative direction: give a negative `distance`, or a `side` to offset toward"]
|
||||
);
|
||||
assert_eq!(offset(json!({"distance2": null})), ["`distance2` must be a length expression"]);
|
||||
assert_eq!(warnings("sketch.offset", json!({"curves": "l1", "distance": "2 mm"})), none);
|
||||
// Move and scale: a point or a point's id.
|
||||
assert_eq!(warnings("sketch.move", json!({"entities": "l1", "translate": {"x": 1, "y": 2}, "angle": 90, "center": "p1", "copy": true})), none);
|
||||
assert_eq!(warnings("sketch.move", json!({"entities": ["l1"], "center": [1, 2], "rotate_constraints": false})), none);
|
||||
assert_eq!(warnings("sketch.move", json!({"entities": ["l1"], "center": [1]})), ["`center` must be [x, y] or a point id"]);
|
||||
assert_eq!(warnings("sketch.move", json!({"entities": ["l1"], "copy": "yes"})), ["`copy` must be true or false"]);
|
||||
assert_eq!(warnings("sketch.scale", json!({"entities": ["l1"], "factor": 2, "base": "p1"})), none);
|
||||
assert_eq!(warnings("sketch.scale", json!({"entities": ["l1"], "factor": 2, "base": [1, 2]})), none);
|
||||
assert_eq!(warnings("sketch.scale", json!({"entities": ["l1"], "factor": 0})), ["`factor` must be a positive number"]);
|
||||
assert_eq!(warnings("sketch.scale", json!({"entities": ["l1"], "factor": 2, "base": 7})), ["`base` must be [x, y] or a point id"]);
|
||||
// Mirror: a curve id, an axis, or an origin axis as an object.
|
||||
assert_eq!(warnings("sketch.mirror", json!({"entities": ["l1", "p2"], "line": "y"})), none);
|
||||
assert_eq!(warnings("sketch.mirror", json!({"entities": ["l1"], "line": {"axis": "Y"}})), none);
|
||||
assert_eq!(warnings("sketch.mirror", json!({"entities": ["l1"], "line": 3})), ["`line` must be a curve id, or x / y for a sketch axis"]);
|
||||
// Patterns: a pattern being edited needs only what changes.
|
||||
assert_eq!(warnings("sketch.pattern.circular", json!({"pattern": "pat1"})), none);
|
||||
assert_eq!(warnings("sketch.pattern.circular", json!({"pattern": "pat1", "count": "n + 1", "angle": "90 deg", "reversed": true})), none);
|
||||
assert_eq!(
|
||||
warnings("sketch.pattern.circular", json!({"entities": ["c1"], "center": "p3", "count": 3, "angle": -90, "alignment": "center"})),
|
||||
none
|
||||
);
|
||||
assert_eq!(warnings("sketch.pattern.circular", json!({"pattern": 4, "count": 3})), none);
|
||||
assert_eq!(warnings("sketch.pattern.rectangular", json!({"pattern": "pat1"})), none);
|
||||
assert_eq!(warnings("sketch.pattern.rectangular", json!({"pattern": "pat1", "spacing": "w * 2", "count2": "m"})), none);
|
||||
assert_eq!(
|
||||
warnings(
|
||||
"sketch.pattern.rectangular",
|
||||
json!({"entities": ["c1"], "count": 3, "spacing": -5, "count2": 2, "spacing2": 4, "dir": [1, 1], "dir2": null, "direction": "l1",
|
||||
"direction2": null, "alignment": "left", "alignment2": "center", "reversed": true, "reversed2": false})
|
||||
),
|
||||
none
|
||||
);
|
||||
assert_eq!(
|
||||
warnings("sketch.pattern.rectangular", json!({"entities": ["c1"], "count": 3, "spacing": 5, "dir2": [0, 0]})),
|
||||
["`dir2` must be a finite nonzero vector"]
|
||||
);
|
||||
assert_eq!(warnings("sketch.pattern.circular", json!({"pattern": "pat1", "angle": 400})), ["`angle` must be nonzero and at most 360 degrees"]);
|
||||
assert_eq!(warnings("sketch.pattern.circular", json!({"pattern": "pat1", "center": [1]})), ["`center` must be [x,y] or a point ref"]);
|
||||
// A key the command doesn't read is unknown to it, and says what it would have been.
|
||||
assert_eq!(
|
||||
warnings("sketch.pattern.circular", json!({"entities": ["c1"], "center": [1, 2], "count": 3, "spacing": 4})),
|
||||
["unknown key `spacing`"]
|
||||
);
|
||||
assert_eq!(warnings("sketch.trim", json!({"curve": "l1", "at": [1, 2], "curv": "l2"})), ["unknown key `curv`; did you mean `curve`?"]);
|
||||
}
|
||||
|
||||
/// The schemas take the calls the handlers take, and say which keys a handler cannot do without.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let takes = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(schema, schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
takes(
|
||||
input_schema_for::<TrimParams>(),
|
||||
&[
|
||||
(json!({"curve": "l1", "at": [1, 2]}), true),
|
||||
(json!({"curve": "l1"}), false),
|
||||
(json!({"at": [1, 2]}), false),
|
||||
(json!({"curve": "l1", "at": 3}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<TrimPathParams>(),
|
||||
&[
|
||||
(json!({"points": [[0, 0], [1, 1]]}), true),
|
||||
(json!({"points": [[0, 0], [1, 1]], "dry_run": true}), true),
|
||||
(json!({"points": [[0, 0]]}), false),
|
||||
(json!({}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<BreakParams>(),
|
||||
&[
|
||||
(json!({"curve": "l1", "at": [1, 2]}), true),
|
||||
(json!({"curve": "l1", "points": [[1, 2]], "sketch": 4}), true),
|
||||
(json!({"curve": "l1"}), false),
|
||||
(json!({"at": [1, 2]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ExtendParams>(),
|
||||
&[
|
||||
(json!({"curve": "l1", "at": [1, 2]}), true),
|
||||
(json!({"curves": ["l1", "l2"]}), true),
|
||||
(json!({"curves": ["l1", "l2"], "at": [[1, 2], [3, 4]]}), true),
|
||||
(json!({"curves": ["l1", "l2"], "curve": "l1"}), false),
|
||||
(json!({"curves": ["l1"]}), false),
|
||||
(json!({"curve": "l1"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<FilletParams>(),
|
||||
&[
|
||||
(json!({"radius": 2, "point": "l1.end"}), true),
|
||||
(json!({"radius": "r", "a": "l1", "b": "l2"}), true),
|
||||
(json!({"radius": 2, "points": ["l1.end", {"a": "l1", "b": "l2"}]}), true),
|
||||
(json!({"point": "l1.end"}), false),
|
||||
(json!({"radius": 2}), false),
|
||||
(json!({"radius": 2, "a": "l1"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ChamferTwoParams>(),
|
||||
&[(json!({"distance": 1, "distance2": 2, "point": "l1.end"}), true), (json!({"distance": 1, "point": "l1.end"}), false)],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<OffsetParams>(),
|
||||
&[
|
||||
(json!({"curves": ["l1"], "distance": 2}), true),
|
||||
(json!({"curves": "l1", "distance": "w / 2", "direction": "symmetric", "instances": 3, "side": [1, 2]}), true),
|
||||
(json!({"curves": ["l1"]}), false),
|
||||
(json!({"distance": 2}), false),
|
||||
(json!({"curves": ["l1"], "distance": 2, "instances": 101}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<ScaleParams>(),
|
||||
&[
|
||||
(json!({"entities": ["l1"], "factor": 2, "base": "p1"}), true),
|
||||
(json!({"entities": ["l1"], "factor": 0}), false),
|
||||
(json!({"entities": ["l1"]}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<CircularPatternParams>(),
|
||||
&[
|
||||
(json!({"entities": ["c1"], "center": [1, 2], "count": 4}), true),
|
||||
(json!({"entities": ["c1"], "center": "p1", "count": "n", "angle": "90 deg", "alignment": "center"}), true),
|
||||
(json!({"pattern": "pat1", "count": 5}), true),
|
||||
(json!({"entities": ["c1"], "count": 4}), false),
|
||||
(json!({"entities": ["c1"], "center": [1, 2], "count": 4, "alignment": "middle"}), false),
|
||||
],
|
||||
);
|
||||
takes(
|
||||
input_schema_for::<RectangularPatternParams>(),
|
||||
&[
|
||||
(json!({"entities": ["c1"], "count": 3, "spacing": 5, "dir": [1, 0], "dir2": [0, 1], "count2": 2}), true),
|
||||
(json!({"pattern": "pat1"}), true),
|
||||
(json!({"entities": ["c1"], "count": 3}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, what: &str, reply: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, reply), "{what}: {reply} doesn't match {schema}");
|
||||
assert!(reply.get("param_warnings").is_none(), "{what}: {reply}");
|
||||
}
|
||||
|
||||
/// A line of the square's sketch that crosses its left and right sides.
|
||||
fn crossing_line(s: &mut Session) -> String {
|
||||
id_of(&run(s, "sketch.line", json!({"points": [[-20, 3], [20, 3]], "infer": false})), "curves")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_and_break_replies_match_the_output_schema() {
|
||||
// Trim: a piece goes (the curve keeps its id), a curve nothing crosses goes whole, and a
|
||||
// spline is replaced by pieces or goes.
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let line = crossing_line(&mut s);
|
||||
let far = id_of(&run(&mut s, "sketch.line", json!({"points": [[40, 40], [50, 40]], "infer": false})), "curves");
|
||||
let spline = id_of(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[-5, 0], [0, 20], [5, 0]]})), "curves");
|
||||
let lone = id_of(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[60, 60], [70, 65], [80, 60]]})), "curves");
|
||||
let trimmed = run(&mut s, "sketch.trim", json!({"curve": line, "at": [15, 3]}));
|
||||
assert_eq!(trimmed["result"], json!({"trimmed": line}), "{trimmed}");
|
||||
matches(output_schema_for::<TrimResult>(), "sketch.trim", &trimmed);
|
||||
let sketch = s.active_sketch;
|
||||
let deleted = run(&mut s, "sketch.trim", json!({"curve": far, "at": [45, 40], "sketch": sketch}));
|
||||
assert_eq!(deleted["result"], json!({"deleted": far}), "{deleted}");
|
||||
matches(output_schema_for::<TrimResult>(), "sketch.trim", &deleted);
|
||||
let split = run(&mut s, "sketch.trim", json!({"curve": spline, "at": [0, 20]}));
|
||||
assert_eq!(split["result"]["trimmed"], json!(spline), "{split}");
|
||||
assert!(split["result"]["pieces"].is_array(), "{split}");
|
||||
matches(output_schema_for::<TrimResult>(), "sketch.trim", &split);
|
||||
let whole = run(&mut s, "sketch.trim", json!({"curve": lone, "at": [70, 65]}));
|
||||
assert_eq!(whole["result"], json!({"trimmed": lone, "pieces": null}), "{whole}");
|
||||
matches(output_schema_for::<TrimResult>(), "sketch.trim", &whole);
|
||||
|
||||
// A line that is a single-line offset's source releases its linked extents.
|
||||
let (mut s, sides) = editing(ParamCheck::Warn);
|
||||
let made = run(&mut s, "sketch.offset", json!({"curves": [sides[0]], "distance": 2}));
|
||||
assert!(made["curves"].is_array(), "{made}");
|
||||
let cutter = id_of(&run(&mut s, "sketch.line", json!({"points": [[0, -30], [0, 30]], "infer": false})), "curves");
|
||||
let released = run(&mut s, "sketch.trim", json!({"curve": sides[0], "at": [5, -10]}));
|
||||
matches(output_schema_for::<TrimResult>(), "sketch.trim", &released);
|
||||
let _ = cutter;
|
||||
|
||||
// Trim along a path, and its dry run.
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let path = json!({"points": [[-20, 3], [20, 3]]});
|
||||
let dry = run(&mut s, "sketch.trim.path", json!({"points": path["points"], "dry_run": true}));
|
||||
assert_eq!(dry["dry_run"], json!(true), "{dry}");
|
||||
let schema = output_schema_for::<TrimPathResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &dry), "{dry}");
|
||||
assert!(dry.get("param_warnings").is_none(), "{dry}");
|
||||
let done = run(&mut s, "sketch.trim.path", path);
|
||||
assert!(done.get("dry_run").is_none() && done["trimmed"].as_array().is_some_and(|t| !t.is_empty()), "{done}");
|
||||
matches(output_schema_for::<TrimPathResult>(), "sketch.trim.path", &done);
|
||||
|
||||
// Break, at the crossings and at given positions.
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let line = crossing_line(&mut s);
|
||||
let broken = run(&mut s, "sketch.break", json!({"curve": line, "at": [0, 3]}));
|
||||
assert!(broken["curves"].as_array().is_some_and(|c| c.len() == 3), "{broken}");
|
||||
matches(output_schema_for::<BreakResult>(), "sketch.break", &broken);
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let line = id_of(&run(&mut s, "sketch.line", json!({"points": [[-5, 30], [5, 30]], "infer": false})), "curves");
|
||||
let broken = run(&mut s, "sketch.break", json!({"curve": line, "points": [[-2, 30], [2, 31], [2, 30]]}));
|
||||
assert!(broken["curves"].as_array().is_some_and(|c| c.len() == 3), "{broken}");
|
||||
matches(output_schema_for::<BreakResult>(), "sketch.break", &broken);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_replies_match_the_output_schema() {
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let short = id_of(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [5, 0]], "infer": false})), "curves");
|
||||
let extended = run(&mut s, "sketch.extend", json!({"curve": short, "at": [5, 0]}));
|
||||
matches(output_schema_for::<ExtendResult>(), "sketch.extend", &extended);
|
||||
let a = id_of(&run(&mut s, "sketch.line", json!({"points": [[30, 0], [30, 5]], "infer": false})), "curves");
|
||||
let b = id_of(&run(&mut s, "sketch.line", json!({"points": [[40, 20], [45, 20]], "infer": false})), "curves");
|
||||
let met = run(&mut s, "sketch.extend", json!({"curves": [a, b]}));
|
||||
matches(output_schema_for::<ExtendResult>(), "sketch.extend", &met);
|
||||
let c = id_of(&run(&mut s, "sketch.line", json!({"points": [[60, 0], [60, 5]], "infer": false})), "curves");
|
||||
let d = id_of(&run(&mut s, "sketch.line", json!({"points": [[70, 20], [75, 20]], "infer": false})), "curves");
|
||||
let met = run(&mut s, "sketch.extend", json!({"curves": [c, d], "at": [[60, 5], [70, 20]]}));
|
||||
matches(output_schema_for::<ExtendResult>(), "sketch.extend", &met);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fillet_and_chamfer_replies_match_the_output_schema() {
|
||||
let fillet = output_schema_for::<FilletResult>();
|
||||
let chamfer = output_schema_for::<ChamferResult>();
|
||||
for p in [
|
||||
json!({"point": "l2.end", "radius": 2}),
|
||||
json!({"a": "l1", "b": "l2", "radius": "1 + 1"}),
|
||||
json!({"points": ["l1.end", {"a": "l2", "b": "l3"}, {"point": "l3.end"}], "radius": 2}),
|
||||
json!({"points": ["l1.end"], "point": 3, "a": 1, "radius": 2}),
|
||||
] {
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let r = run(&mut s, "sketch.fillet", p.clone());
|
||||
assert!(r["param"].is_string() && r["curves"].as_array().is_some_and(|c| !c.is_empty()), "{r}");
|
||||
matches(fillet.clone(), &format!("sketch.fillet {p}"), &r);
|
||||
}
|
||||
for (id, p) in [
|
||||
("sketch.chamfer.equal_distance", json!({"point": "l2.end", "distance": 2})),
|
||||
("sketch.chamfer.equal_distance", json!({"points": ["l1.end", {"a": "l2", "b": "l3"}], "distance": 2})),
|
||||
("sketch.chamfer.two_distance", json!({"a": "l1", "b": "l2", "distance": 2, "distance2": 3})),
|
||||
("sketch.chamfer.distance_angle", json!({"point": "l2.end", "distance": 2, "angle": 40})),
|
||||
] {
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let r = run(&mut s, id, p.clone());
|
||||
assert!(r["curves"].as_array().is_some_and(|c| !c.is_empty()), "{r}");
|
||||
matches(chamfer.clone(), &format!("{id} {p}"), &r);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_replies_match_the_output_schema() {
|
||||
let schema = output_schema_for::<OffsetResult>();
|
||||
for p in [
|
||||
json!({"curves": ["l1", "l2", "l3", "l4"], "distance": 2}),
|
||||
json!({"curves": ["l1", "l2", "l3", "l4"], "distance": "2 mm", "flip": true, "chain": false}),
|
||||
json!({"curves": ["l1"], "distance": 2, "side": [0, 30]}),
|
||||
json!({"curves": "l1", "distance": 2, "direction": "symmetric"}),
|
||||
json!({"curves": ["l1"], "distance": 2, "symmetric": true}),
|
||||
json!({"curves": ["l1"], "distance": 2, "distance2": 3, "side": [0, 30]}),
|
||||
json!({"curves": ["l1"], "distance": 2, "instances": 3, "side": [0, 30]}),
|
||||
json!({"curves": ["l1"], "distance": 2, "direction": "one_side", "instances": "1 + 1", "side": [0, 30]}),
|
||||
] {
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let r = run(&mut s, "sketch.offset", p.clone());
|
||||
assert!(r["sides"].as_array().is_some_and(|c| !c.is_empty()), "{r}");
|
||||
matches(schema.clone(), &format!("sketch.offset {p}"), &r);
|
||||
}
|
||||
// Break the association of what an offset made, a single curve's and a closed boundary's.
|
||||
for sources in [json!(["l1"]), json!(["l1", "l2", "l3", "l4"])] {
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let made = run(&mut s, "sketch.offset", json!({"curves": sources, "distance": 2, "side": [0, 30]}));
|
||||
let output = made["curves"][0].as_str().unwrap_or_default().to_string();
|
||||
let r = run(&mut s, "sketch.offset.break", json!({"curve": output}));
|
||||
assert_eq!(r["curve"], json!(output), "{r}");
|
||||
matches(output_schema_for::<OffsetBreakResult>(), "sketch.offset.break", &r);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirror_move_scale_and_pattern_replies_match_the_output_schema() {
|
||||
let mirror = output_schema_for::<MirrorResult>();
|
||||
for line in [json!("l3"), json!("y"), json!({"axis": "Y"})] {
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let r = run(&mut s, "sketch.mirror", json!({"entities": ["l1"], "line": line}));
|
||||
assert!(r["curves"].as_array().is_some_and(|c| !c.is_empty()), "{r}");
|
||||
matches(mirror.clone(), &format!("sketch.mirror {line}"), &r);
|
||||
}
|
||||
let moved = output_schema_for::<MoveResult>();
|
||||
let scaled = output_schema_for::<ScaleResult>();
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let c = id_of(&run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 3})), "curves");
|
||||
let r = run(&mut s, "sketch.move", json!({"entities": [c], "translate": [1, 2], "angle": 15, "center": [0, 0]}));
|
||||
matches(moved.clone(), "sketch.move", &r);
|
||||
let r = run(&mut s, "sketch.move", json!({"entities": c, "translate": {"x": 5, "y": 0}, "copy": true, "rotate_constraints": false}));
|
||||
assert_eq!(r["curves"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
matches(moved, "sketch.move", &r);
|
||||
let r = run(&mut s, "sketch.scale", json!({"entities": [c], "factor": 2, "base": [0, 0]}));
|
||||
matches(scaled.clone(), "sketch.scale", &r);
|
||||
let r = run(&mut s, "sketch.scale", json!({"entities": [c], "factor": 0.5}));
|
||||
matches(scaled, "sketch.scale", &r);
|
||||
|
||||
let pattern = output_schema_for::<PatternResult>();
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let c = id_of(&run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 2})), "curves");
|
||||
let made = run(&mut s, "sketch.pattern.circular", json!({"entities": [c], "center": [0, 0], "count": 4}));
|
||||
assert!(made["center"].is_string(), "{made}");
|
||||
matches(pattern.clone(), "sketch.pattern.circular", &made);
|
||||
let edited = run(&mut s, "sketch.pattern.circular", json!({"pattern": made["pattern"], "count": 5, "angle": 180, "alignment": "center"}));
|
||||
assert_eq!(edited["pattern"], made["pattern"], "{edited}");
|
||||
matches(pattern.clone(), "sketch.pattern.circular", &edited);
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let c = id_of(&run(&mut s, "sketch.circle.center", json!({"center": [30, 30], "radius": 2})), "curves");
|
||||
let made =
|
||||
run(&mut s, "sketch.pattern.rectangular", json!({"entities": [c], "count": 3, "spacing": 8, "count2": 2, "spacing2": "6 mm", "dir": [1, 0]}));
|
||||
assert!(made["center"].is_null(), "{made}");
|
||||
matches(pattern.clone(), "sketch.pattern.rectangular", &made);
|
||||
let edited =
|
||||
run(&mut s, "sketch.pattern.rectangular", json!({"pattern": made["pattern"], "spacing": 9, "alignment": "center", "reversed2": true}));
|
||||
matches(pattern, "sketch.pattern.rectangular", &edited);
|
||||
}
|
||||
|
||||
/// A misspelt key still runs in Warn, and says so, with a reply that matches.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn() {
|
||||
let line = |s: &mut Session| id_of(&run(s, "sketch.line", json!({"points": [[0, 0], [5, 0]], "infer": false})), "curves");
|
||||
let calls: Vec<(&str, fn(&mut Session) -> Value)> = vec![
|
||||
("sketch.trim", |s| json!({"curve": crossing_line(s), "at": [15, 3], "atx": [1, 2]})),
|
||||
("sketch.trim.path", |_| json!({"points": [[-20, 3], [20, 3]], "point": [1, 1]})),
|
||||
("sketch.break", |s| json!({"curve": crossing_line(s), "at": [0, 3], "curv": "l1"})),
|
||||
(
|
||||
"sketch.extend",
|
||||
|s| json!({"curve": id_of(&run(s, "sketch.line", json!({"points": [[0, 0], [5, 0]], "infer": false})), "curves"), "at": [5, 0], "att": 1}),
|
||||
),
|
||||
("sketch.fillet", |_| json!({"point": "l2.end", "radius": 2, "radious": 3})),
|
||||
("sketch.chamfer.equal_distance", |_| json!({"point": "l2.end", "distance": 2, "distence": 3})),
|
||||
("sketch.chamfer.two_distance", |_| json!({"point": "l2.end", "distance": 2, "distance2": 1, "distence": 3})),
|
||||
("sketch.chamfer.distance_angle", |_| json!({"point": "l2.end", "distance": 2, "angle": 40, "angel": 3})),
|
||||
("sketch.offset", |_| json!({"curves": ["l1"], "distance": 2, "side": [0, 30], "distence": 3})),
|
||||
("sketch.mirror", |_| json!({"entities": ["l1"], "line": "l3", "lines": 1})),
|
||||
("sketch.move", |_| json!({"entities": ["l1"], "translate": [1, 2], "translat": 1})),
|
||||
("sketch.scale", |_| json!({"entities": ["l1"], "factor": 2, "facter": 1})),
|
||||
];
|
||||
for (id, call) in calls {
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let p = call(&mut s);
|
||||
let r = run(&mut s, id, p.clone());
|
||||
let warned = r["param_warnings"].as_array().unwrap_or_else(|| panic!("{id} {p}: {r}"));
|
||||
assert_eq!(warned.len(), 1, "{id}: {r}");
|
||||
assert!(warned[0].as_str().is_some_and(|w| w.starts_with("unknown key `")), "{id}: {r}");
|
||||
}
|
||||
// The ones that need a shape of their own.
|
||||
let (mut s, _) = editing(ParamCheck::Warn);
|
||||
let a = line(&mut s);
|
||||
let c = id_of(&run(&mut s, "sketch.circle.center", json!({"center": [30, 0], "radius": 2})), "curves");
|
||||
for (id, p) in [
|
||||
("sketch.offset.break", None),
|
||||
("sketch.pattern.circular", Some(json!({"entities": [c], "center": [0, 0], "count": 3, "cnt": 1}))),
|
||||
("sketch.pattern.rectangular", Some(json!({"entities": [c], "count": 3, "spacing": 8, "spaceing": 1}))),
|
||||
] {
|
||||
let Some(p) = p else { continue };
|
||||
let r = run(&mut s, id, p.clone());
|
||||
let warned = r["param_warnings"].as_array().unwrap_or_else(|| panic!("{id} {p}: {r}"));
|
||||
assert_eq!(warned.len(), 1, "{id}: {r}");
|
||||
}
|
||||
let made = run(&mut s, "sketch.offset", json!({"curves": [a], "distance": 1, "side": [0, 30]}));
|
||||
let output = made["curves"][0].as_str().unwrap_or_default().to_string();
|
||||
let r = run(&mut s, "sketch.offset.break", json!({"curve": output, "sketches": 2}));
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{r}");
|
||||
let schema = output_schema_for::<OffsetBreakResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
}
|
||||
|
||||
/// The reply types serialize to the JSON the handlers built before: a sketch command's reply
|
||||
/// also carries the `created` the session adds, and an absent `param2` or `association` is left
|
||||
/// out.
|
||||
#[test]
|
||||
fn replies_keep_their_shape() {
|
||||
let keys = |v: &Value| v.as_object().map(|o| o.keys().cloned().collect::<std::collections::BTreeSet<_>>()).unwrap_or_default();
|
||||
let set = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<std::collections::BTreeSet<_>>();
|
||||
let (mut s, _) = editing(ParamCheck::Off);
|
||||
let r = run(&mut s, "sketch.offset", json!({"curves": ["l1", "l2", "l3", "l4"], "distance": 2}));
|
||||
assert_eq!(keys(&r), set(&["constraints", "created", "curves", "instances", "param", "sides", "sketch"]), "{r}");
|
||||
assert!(r["sketch"]["solved"].is_boolean() && r["sketch"]["dof"].is_number(), "{r}");
|
||||
assert_eq!(keys(&r["sides"][0]), set(&["curves", "distance", "instance", "param"]), "{r}");
|
||||
let (mut s, _) = editing(ParamCheck::Off);
|
||||
let r = run(&mut s, "sketch.offset", json!({"curves": ["l1"], "distance": 2, "distance2": 1, "side": [0, 30]}));
|
||||
assert!(r.get("association").is_none() && r["param2"].is_string(), "{r}");
|
||||
let r = run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 2}));
|
||||
assert_eq!(keys(&r), set(&["constraints", "created", "curves", "moved_to_virtual_corner", "param", "sketch", "virtual_corners"]), "{r}");
|
||||
let side = OffsetSide { distance: 1.5, curves: vec!["l1".into()], param: None, instance: 1, association: Some("o1".into()) };
|
||||
assert_eq!(
|
||||
serde_json::to_value(&side).unwrap_or_default(),
|
||||
json!({"distance": 1.5, "curves": ["l1"], "param": null, "instance": 1, "association": "o1"})
|
||||
);
|
||||
let trimmed = TrimOutcome::Trimmed { trimmed: "l1".into(), offset_released: Vec::new() }.released(vec!["l9".into()]);
|
||||
assert_eq!(serde_json::to_value(&trimmed).unwrap_or_default(), json!({"trimmed": "l1", "offset_released": ["l9"]}));
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
CoincidentParams, CoincidentResult, CollinearParams, ConcentricParams, ConstraintResult, EqualParams, EqualResult, FixParams, FixResult,
|
||||
HorizontalVerticalParams, MidpointParams, Operand, ParallelParams, PerpendicularParams, PointOperand, SketchKey, SymmetryParams, SymmetryResult,
|
||||
TangentParams,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
use crate::schema::{ParamCheck, input_schema_for, issues, output_schema_for};
|
||||
|
||||
const IDS: [&str; 11] = [
|
||||
"sketch.constraint.coincident",
|
||||
"sketch.constraint.collinear",
|
||||
"sketch.constraint.concentric",
|
||||
"sketch.constraint.equal",
|
||||
"sketch.constraint.fix",
|
||||
"sketch.constraint.horizontal_vertical",
|
||||
"sketch.constraint.midpoint",
|
||||
"sketch.constraint.parallel",
|
||||
"sketch.constraint.perpendicular",
|
||||
"sketch.constraint.symmetry",
|
||||
"sketch.constraint.tangent",
|
||||
];
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// A sketch being edited, with lines `l1` to `l6`, circles `c1` to `c4` and points `p1` and `p2`.
|
||||
fn scene(level: ParamCheck) -> Session {
|
||||
let mut s = Session { param_check: ParamCheck::Off, ..Session::default() };
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
for (id, a, b) in [
|
||||
("l1", [0, 0], [20, 5]),
|
||||
("l2", [30, 0], [50, 10]),
|
||||
("l3", [0, 20], [20, 30]),
|
||||
("l4", [30, 20], [50, 22]),
|
||||
("l5", [0, 40], [20, 41]),
|
||||
("l6", [30, 40], [49, 52]),
|
||||
] {
|
||||
run(&mut s, "sketch.line", json!({"points": [a, b], "ids": [id]}));
|
||||
}
|
||||
for (id, c, r) in [("c1", [0, 70], 5), ("c2", [30, 70], 8), ("c3", [60, 70], 3), ("c4", [60, 80], 3)] {
|
||||
run(&mut s, "sketch.circle.center", json!({"center": c, "radius": r, "ids": [id]}));
|
||||
}
|
||||
run(&mut s, "sketch.point", json!({"point": [5, 100], "id": "p1"}));
|
||||
run(&mut s, "sketch.point", json!({"point": [15, 105], "id": "p2"}));
|
||||
s.param_check = level;
|
||||
s
|
||||
}
|
||||
|
||||
fn output_conforms<T: schemars::JsonSchema>(r: &Value) {
|
||||
let schema = output_schema_for::<T>();
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<CoincidentParams>(), &["a", "b", "if_redundant", "sketch"]);
|
||||
assert_keys(input_schema_for::<CollinearParams>(), &["a", "b", "if_redundant", "sketch"]);
|
||||
assert_keys(input_schema_for::<ConcentricParams>(), &["a", "b", "if_redundant", "sketch"]);
|
||||
assert_keys(input_schema_for::<EqualParams>(), &["a", "b", "curves", "if_redundant", "sketch"]);
|
||||
assert_keys(input_schema_for::<FixParams>(), &["entities", "entity", "fixed", "sketch"]);
|
||||
assert_keys(input_schema_for::<HorizontalVerticalParams>(), &["if_redundant", "line", "mode", "points", "sketch"]);
|
||||
assert_keys(input_schema_for::<MidpointParams>(), &["a", "b", "if_redundant", "line", "point", "sketch"]);
|
||||
assert_keys(input_schema_for::<ParallelParams>(), &["a", "b", "if_redundant", "sketch"]);
|
||||
assert_keys(input_schema_for::<PerpendicularParams>(), &["a", "b", "if_redundant", "sketch"]);
|
||||
assert_keys(input_schema_for::<SymmetryParams>(), &["a", "b", "line", "point", "sketch"]);
|
||||
assert_keys(input_schema_for::<TangentParams>(), &["a", "b", "if_redundant", "sketch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commands_publish_their_schemas() {
|
||||
for id in IDS {
|
||||
let spec = crate::cmd::find_command(id).unwrap_or_else(|| panic!("{id}"));
|
||||
assert!(spec.contract.is_some() && spec.output.is_some(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keys a handler refuses when they are left out are required in the schema (and optional in the
|
||||
/// type, so that the handler's message is the one a caller sees).
|
||||
#[test]
|
||||
fn keys_the_handler_needs_are_required_in_the_schema() {
|
||||
let required = |schema: schemars::Schema| -> Vec<String> {
|
||||
schema.as_value()["required"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect()
|
||||
};
|
||||
assert_eq!(required(input_schema_for::<TangentParams>()), ["a", "b"]);
|
||||
assert_eq!(required(input_schema_for::<CoincidentParams>()), ["a", "b"]);
|
||||
assert_eq!(required(input_schema_for::<ConcentricParams>()), ["a", "b"]);
|
||||
assert_eq!(required(input_schema_for::<SymmetryParams>()), ["a", "b"]);
|
||||
// The forms that exclude each other are alternatives.
|
||||
let schema = input_schema_for::<EqualParams>();
|
||||
assert_eq!(schema.as_value()["anyOf"], json!([{"required": ["a", "b"]}, {"required": ["curves"]}]));
|
||||
let schema = input_schema_for::<MidpointParams>();
|
||||
assert_eq!(schema.as_value()["anyOf"], json!([{"required": ["a", "b"]}, {"required": ["point", "line"]}]));
|
||||
let schema = input_schema_for::<HorizontalVerticalParams>();
|
||||
assert_eq!(schema.as_value()["anyOf"], json!([{"required": ["line"]}, {"required": ["points"]}]));
|
||||
let schema = input_schema_for::<FixParams>();
|
||||
assert_eq!(schema.as_value()["anyOf"], json!([{"required": ["entity"]}, {"required": ["entities"]}]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
// The sketch is named: after the scene the handler would otherwise stop at "no sketch is being
|
||||
// edited" before it looks at the rest.
|
||||
let on = |mut p: Value| {
|
||||
p["sketch"] = json!("Sketch1");
|
||||
p
|
||||
};
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("sketch.constraint.tangent", on(json!({"a": 5, "b": "x"}))),
|
||||
("sketch.constraint.tangent", on(json!({"a": "x", "b": ["y"]}))),
|
||||
("sketch.constraint.tangent", on(json!({"a": null, "b": "x"}))),
|
||||
("sketch.constraint.tangent", on(json!({"a": {"point": 1}, "b": "x"}))),
|
||||
("sketch.constraint.tangent", on(json!({"b": "x"}))),
|
||||
("sketch.constraint.parallel", on(json!({"a": true, "b": "x"}))),
|
||||
("sketch.constraint.perpendicular", on(json!({"a": "x", "b": 3}))),
|
||||
("sketch.constraint.collinear", on(json!({"a": [1], "b": "x"}))),
|
||||
("sketch.constraint.equal", on(json!({"a": 5, "b": "l1"}))),
|
||||
("sketch.constraint.equal", on(json!({"a": "l1", "b": null}))),
|
||||
("sketch.constraint.equal", on(json!({"curves": ["a"]}))),
|
||||
("sketch.constraint.equal", on(json!({"curves": null}))),
|
||||
("sketch.constraint.equal", on(json!({"curves": 5}))),
|
||||
("sketch.constraint.equal", on(json!({"curves": ["a", 5]}))),
|
||||
("sketch.constraint.equal", on(json!({"curves": vec!["a"; 201]}))),
|
||||
("sketch.constraint.concentric", on(json!({"a": 5, "b": "x"}))),
|
||||
("sketch.constraint.concentric", on(json!({"a": "x", "b": 5}))),
|
||||
("sketch.constraint.coincident", on(json!({"a": 5, "b": "x"}))),
|
||||
("sketch.constraint.coincident", on(json!({"a": "x", "b": 5}))),
|
||||
("sketch.constraint.coincident", on(json!({"a": "x"}))),
|
||||
("sketch.constraint.coincident", on(json!({"a": 5}))),
|
||||
("sketch.constraint.coincident", on(json!({"a": {"nothing": 1}}))),
|
||||
("sketch.constraint.fix", on(json!({"fixed": "x", "entities": 5}))),
|
||||
("sketch.constraint.coincident", on(json!({"a": {"midpoint": 5}, "b": "x"}))),
|
||||
("sketch.constraint.coincident", on(json!({"a": {"nothing": 5}, "b": "x"}))),
|
||||
("sketch.constraint.horizontal_vertical", on(json!({}))),
|
||||
("sketch.constraint.horizontal_vertical", on(json!({"line": 5}))),
|
||||
("sketch.constraint.horizontal_vertical", on(json!({"points": ["l1.start"]}))),
|
||||
("sketch.constraint.horizontal_vertical", on(json!({"points": ["l1.start", 5]}))),
|
||||
("sketch.constraint.horizontal_vertical", on(json!({"points": ["l1.start", {"midpoint": 5}]}))),
|
||||
("sketch.constraint.horizontal_vertical", on(json!({"points": 5, "line": 5}))),
|
||||
("sketch.constraint.midpoint", on(json!({"a": 5, "b": "l1"}))),
|
||||
("sketch.constraint.midpoint", on(json!({"a": "l1", "b": null}))),
|
||||
("sketch.constraint.midpoint", on(json!({"a": "l1"}))),
|
||||
("sketch.constraint.midpoint", on(json!({"point": 5, "line": "l1"}))),
|
||||
("sketch.constraint.midpoint", on(json!({"point": "p1", "line": 5}))),
|
||||
("sketch.constraint.midpoint", on(json!({"point": "p1"}))),
|
||||
("sketch.constraint.symmetry", on(json!({"a": 5, "b": "x", "line": "y"}))),
|
||||
("sketch.constraint.symmetry", on(json!({"a": "x", "b": 5, "line": "y"}))),
|
||||
("sketch.constraint.symmetry", on(json!({"a": "x", "b": "y", "line": 5}))),
|
||||
("sketch.constraint.symmetry", on(json!({"a": "x", "b": "y", "point": 5}))),
|
||||
("sketch.constraint.symmetry", on(json!({"a": "x", "b": "y", "point": null}))),
|
||||
("sketch.constraint.fix", on(json!({}))),
|
||||
("sketch.constraint.fix", on(json!({"entities": []}))),
|
||||
("sketch.constraint.fix", on(json!({"entity": 5}))),
|
||||
("sketch.constraint.fix", on(json!({"entities": 5}))),
|
||||
("sketch.constraint.fix", on(json!({"entities": ["p1", 5]}))),
|
||||
("sketch.constraint.fix", on(json!({"entity": "p1", "entities": {"x": 1}}))),
|
||||
]);
|
||||
// `sketch` is read the way `sketch_store::target` reads it.
|
||||
let with_sketch = |sketch: Value| json!({"a": "l1", "b": "l2", "sketch": sketch});
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("sketch.constraint.parallel", with_sketch(json!(true))),
|
||||
("sketch.constraint.parallel", with_sketch(json!(-1))),
|
||||
("sketch.constraint.parallel", with_sketch(json!(1.5))),
|
||||
("sketch.constraint.parallel", with_sketch(json!(["Sketch1"]))),
|
||||
("sketch.constraint.parallel", with_sketch(json!(999))),
|
||||
("sketch.constraint.parallel", with_sketch(json!("nothing"))),
|
||||
("sketch.constraint.fix", json!({"entity": "p1", "sketch": {}})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The types read each key as the handlers do: what a handler accepts, they accept (and give the
|
||||
/// handler's message for what it refuses); keys a handler does not read in a case are not looked
|
||||
/// at in that case.
|
||||
#[test]
|
||||
fn the_types_read_each_key_as_the_handlers_do() {
|
||||
fn parse<T: serde::de::DeserializeOwned>(v: Value) -> Result<T, String> {
|
||||
serde_json::from_value(v).map_err(|e| e.to_string())
|
||||
}
|
||||
fn whole_message(e: String) -> String {
|
||||
e.trim_start_matches(['\u{1}', '\u{2}', '\u{3}']).split(" at line ").next().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
// `sketch`: an id, a name or null.
|
||||
let p: TangentParams = parse(json!({"a": "x", "b": "y", "sketch": 3})).unwrap();
|
||||
assert_eq!(p.sketch, Some(SketchKey::Id(3)));
|
||||
let p: TangentParams = parse(json!({"a": "x", "b": "y", "sketch": "S"})).unwrap();
|
||||
assert_eq!(p.sketch, Some(SketchKey::Name("S".into())));
|
||||
let p: TangentParams = parse(json!({"a": "x", "b": "y", "sketch": null})).unwrap();
|
||||
assert_eq!(p.sketch, None);
|
||||
for bad in [json!(-1), json!(1.5), json!(true), json!([1]), json!({})] {
|
||||
let e = parse::<TangentParams>(json!({"a": "x", "b": "y", "sketch": bad})).map(|_| ()).unwrap_err();
|
||||
assert_eq!(whole_message(e), "`sketch` must be an id or a name", "{bad}");
|
||||
}
|
||||
|
||||
// Operands: an id, or a model edge or vertex (projected before the handler reads them).
|
||||
let p: ParallelParams = parse(json!({"a": "l1", "b": {"edge": [0, 0, 0], "body": "Body1"}})).unwrap();
|
||||
assert_eq!((p.a, matches!(p.b, Some(Operand::Model(_)))), (Some(Operand::Id("l1".into())), true));
|
||||
let p: ParallelParams = parse(json!({"a": {"vertex": [1, 2, 3]}, "b": "y"})).unwrap();
|
||||
assert!(matches!(p.a, Some(Operand::Model(_))));
|
||||
let p: ParallelParams = parse(json!({})).unwrap();
|
||||
assert_eq!((p.a, p.b), (None, None), "left out: the handler's to refuse");
|
||||
let e = parse::<ParallelParams>(json!({"a": 5, "b": "y"})).map(|_| ()).unwrap_err();
|
||||
assert_eq!(whole_message(e), "`a` must be a curve id, or x / y for a sketch axis");
|
||||
let e = parse::<ConcentricParams>(json!({"a": "c1", "b": {"midpoint": "l1"}})).map(|_| ()).unwrap_err();
|
||||
assert_eq!(whole_message(e), "`b` must be a curve id");
|
||||
|
||||
// `if_redundant` and the plain flags and texts.
|
||||
let e = parse::<TangentParams>(json!({"a": "x", "b": "y", "if_redundant": 5})).map(|_| ()).unwrap_err();
|
||||
assert_eq!(whole_message(e), "`if_redundant` must be text");
|
||||
assert!(parse::<TangentParams>(json!({"a": "x", "b": "y", "if_redundant": null})).is_ok());
|
||||
let e = parse::<FixParams>(json!({"entity": "p1", "fixed": "yes"})).map(|_| ()).unwrap_err();
|
||||
assert_eq!(whole_message(e), "`fixed` must be true or false");
|
||||
|
||||
// Equal: with `curves`, `a`, `b` and `if_redundant` are not read.
|
||||
let p: EqualParams = parse(json!({"curves": ["c1", "c2"], "a": 5, "b": null, "if_redundant": 5})).unwrap();
|
||||
assert_eq!((p.curves.map(|c| c.0), p.a, p.b, p.if_redundant), (Some(vec!["c1".into(), "c2".into()]), None, None, None));
|
||||
assert_eq!(
|
||||
whole_message(parse::<EqualParams>(json!({"curves": ["c1"]})).map(|_| ()).unwrap_err()),
|
||||
"`curves` must list 2 to 200 lines or circles/arcs"
|
||||
);
|
||||
assert_eq!(
|
||||
whole_message(parse::<EqualParams>(json!({"curves": null, "a": "l1", "b": "l2"})).map(|_| ()).unwrap_err()),
|
||||
"`curves` must list 2 to 200 lines or circles/arcs"
|
||||
);
|
||||
assert_eq!(
|
||||
whole_message(parse::<EqualParams>(json!({"curves": ["c1", 7]})).map(|_| ()).unwrap_err()),
|
||||
"`curves` must list names; the entry 2 is 7"
|
||||
);
|
||||
assert_eq!(whole_message(parse::<EqualParams>(json!({"curves": 7})).map(|_| ()).unwrap_err()), "`curves` must be a list of names; it is 7");
|
||||
assert!(parse::<EqualParams>(json!({"curves": vec!["c"; 200]})).is_ok());
|
||||
assert!(parse::<EqualParams>(json!({"curves": vec!["c"; 201]})).is_err());
|
||||
// Without `curves`, `a` and `b` are read.
|
||||
assert_eq!(whole_message(parse::<EqualParams>(json!({"a": 5, "b": "l2"})).map(|_| ()).unwrap_err()), "`a` must be a curve id");
|
||||
|
||||
// Coincident: the middle of a line is a point.
|
||||
let p: CoincidentParams = parse(json!({"a": {"midpoint": "l1"}, "b": "p1"})).unwrap();
|
||||
assert_eq!(p.a, Some(PointOperand::Midpoint("l1".into())));
|
||||
let msg = |v: Value| whole_message(parse::<CoincidentParams>(v).map(|_| ()).unwrap_err());
|
||||
assert_eq!(msg(json!({"a": {"midpoint": 3}, "b": "p1"})), "`midpoint` must be a curve id");
|
||||
assert_eq!(msg(json!({"a": "p1", "b": 3})), "`b` must be a point or curve");
|
||||
assert_eq!(msg(json!({"a": 3, "b": "p1"})), "`a` must be a point reference");
|
||||
// The handler reads `b` before `a`.
|
||||
assert_eq!(msg(json!({"a": 3, "b": 3})), "`b` must be a point or curve");
|
||||
|
||||
// Horizontal / Vertical: `points` when it is a list, else `line`.
|
||||
let p: HorizontalVerticalParams = parse(json!({"points": ["l1.start", {"midpoint": "l2"}], "line": 5, "mode": "VERTICAL"})).unwrap();
|
||||
assert!(p.line.is_none() && p.points.is_some(), "`line` is not read when `points` is a list");
|
||||
let p: HorizontalVerticalParams = parse(json!({"points": "x", "line": "l1"})).unwrap();
|
||||
assert_eq!((p.line.as_deref(), p.points.is_some()), (Some("l1"), false), "a `points` that is not a list is not read");
|
||||
let p: HorizontalVerticalParams = parse(json!({"points": null, "line": "l1", "mode": "bogus"})).unwrap();
|
||||
assert_eq!(p.line.as_deref(), Some("l1"));
|
||||
let msg = |v: Value| whole_message(parse::<HorizontalVerticalParams>(v).map(|_| ()).unwrap_err());
|
||||
assert_eq!(msg(json!({"line": 5})), "`line` must be a curve id");
|
||||
assert_eq!(msg(json!({"points": "x", "line": 5})), "`line` must be a curve id");
|
||||
assert_eq!(msg(json!({"points": ["a"]})), "`points` needs exactly two point references");
|
||||
assert_eq!(msg(json!({"points": ["a", "b", "c"]})), "`points` needs exactly two point references");
|
||||
assert_eq!(msg(json!({"points": ["a", 3]})), "`points` must be a point reference");
|
||||
assert_eq!(msg(json!({"points": ["a", {"midpoint": 3}]})), "`midpoint` must be a curve id");
|
||||
assert_eq!(msg(json!({"points": [{"edge": [0, 0, 0]}, "b"]})), "`points` must be a point reference");
|
||||
|
||||
// Midpoint: `a` and `b` when either is given (`null` too), else `point` and `line`.
|
||||
let p: MidpointParams = parse(json!({"a": "l1", "b": "l2", "point": 5, "line": 6})).unwrap();
|
||||
assert!(p.point.is_none() && p.line.is_none() && p.a.is_some(), "`point` and `line` are not read with `a` and `b`");
|
||||
let p: MidpointParams = parse(json!({"point": "p1", "line": "l1"})).unwrap();
|
||||
assert!(p.point.is_some() && p.line.is_some());
|
||||
let msg = |v: Value| whole_message(parse::<MidpointParams>(v).map(|_| ()).unwrap_err());
|
||||
assert_eq!(msg(json!({"a": "l1", "b": null, "point": "p1", "line": "l1"})), "`b` must be a point or line id");
|
||||
assert_eq!(msg(json!({"a": 5, "b": "l2"})), "`a` must be a point or line id");
|
||||
assert_eq!(msg(json!({"b": 5})), "`b` must be a point or line id");
|
||||
assert_eq!(msg(json!({"point": 5, "line": "l1"})), "`point` must be a point reference");
|
||||
assert_eq!(msg(json!({"point": "p1", "line": 5})), "`line` must be a curve id");
|
||||
|
||||
// Symmetry: `point` when given (`null` too) and `line` is not read; else `line`.
|
||||
let p: SymmetryParams = parse(json!({"a": "p1", "b": "p2", "point": "origin", "line": 5})).unwrap();
|
||||
assert!(p.point.is_some() && p.line.is_none());
|
||||
let p: SymmetryParams = parse(json!({"a": "p1", "b": "p2", "line": "y"})).unwrap();
|
||||
assert!(p.line.is_some() && p.point.is_none());
|
||||
let msg = |v: Value| whole_message(parse::<SymmetryParams>(v).map(|_| ()).unwrap_err());
|
||||
assert_eq!(msg(json!({"a": "p1", "b": "p2", "point": null, "line": "y"})), "`point` must be a point reference");
|
||||
assert_eq!(msg(json!({"a": "p1", "b": "p2", "line": 5})), "`line` must be a curve id, or x / y for a sketch axis");
|
||||
assert_eq!(msg(json!({"a": 1, "b": "p2", "line": "y"})), "`a` must be a point or curve");
|
||||
assert_eq!(msg(json!({"a": "p1", "b": 2, "line": "y"})), "`b` must be a point or curve");
|
||||
|
||||
// Fix: `entity` or `entities`, one at least.
|
||||
let p: FixParams = parse(json!({"entity": "p1"})).unwrap();
|
||||
assert_eq!(p.entity.as_deref(), Some("p1"));
|
||||
let p: FixParams = parse(json!({"entities": "p1", "fixed": false})).unwrap();
|
||||
assert_eq!((p.entities.map(|e| e.0), p.fixed), (Some(vec!["p1".into()]), Some(false)));
|
||||
let p: FixParams = parse(json!({"entities": null, "entity": "p1"})).unwrap();
|
||||
assert!(p.entities.is_none());
|
||||
let msg = |v: Value| whole_message(parse::<FixParams>(v).map(|_| ()).unwrap_err());
|
||||
assert_eq!(msg(json!({})), "`entity` or `entities` must name sketch points or curves");
|
||||
assert_eq!(msg(json!({"entities": []})), "`entity` or `entities` must name sketch points or curves");
|
||||
assert_eq!(msg(json!({"entity": 5})), "`entity` or `entities` must name sketch points or curves");
|
||||
assert_eq!(msg(json!({"entities": 5})), "`entities` must be a list of names; it is 5");
|
||||
assert_eq!(msg(json!({"entities": ["a", true]})), "`entities` must list names; the entry 2 is true");
|
||||
// A `entity` that is not text is ignored by the handler when `entities` names something;
|
||||
// the type refuses it, which is what warn and strict report.
|
||||
assert_eq!(msg(json!({"entity": 5, "entities": ["p1"]})), "`entity` must be text");
|
||||
}
|
||||
|
||||
fn assert_clean<T: schemars::JsonSchema>(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
let r = run(s, id, p.clone());
|
||||
assert!(r.get("param_warnings").is_none(), "{id} {p} warned: {r}");
|
||||
output_conforms::<T>(&r);
|
||||
r
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_match_the_output_schemas_and_real_calls_get_no_warnings() {
|
||||
let new = || scene(ParamCheck::Warn);
|
||||
|
||||
// Horizontal / Vertical: a line, a line with a mode, two points, a line's middle, and a
|
||||
// `line` that `points` makes unread.
|
||||
let mut s = new();
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2", "mode": "Vertical"}));
|
||||
assert_clean::<ConstraintResult>(
|
||||
&mut s,
|
||||
"sketch.constraint.horizontal_vertical",
|
||||
json!({"points": ["l3.start", "l3.end"], "mode": "horizontal"}),
|
||||
);
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["c1.center", {"midpoint": "l4"}]}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": ["l5.start", "l5.end"], "line": 7}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.horizontal_vertical", json!({"points": "not a list", "line": "l6", "mode": "x"}));
|
||||
|
||||
// Coincident: two points, a point on a curve, a point on an axis, a line's middle, two lines,
|
||||
// two circles, and a pair that is joined already.
|
||||
let mut s = new();
|
||||
let r = assert_clean::<CoincidentResult>(&mut s, "sketch.constraint.coincident", json!({"a": "l1.end", "b": "l2.start"}));
|
||||
assert!(r.get("existing").is_none() && r.get("constraints").is_none(), "{r}");
|
||||
let r = assert_clean::<CoincidentResult>(&mut s, "sketch.constraint.coincident", json!({"a": "l1.end", "b": "l2.start"}));
|
||||
assert_eq!(r["existing"], true, "{r}");
|
||||
assert_clean::<CoincidentResult>(&mut s, "sketch.constraint.coincident", json!({"a": "p1", "b": "c1"}));
|
||||
assert_clean::<CoincidentResult>(&mut s, "sketch.constraint.coincident", json!({"a": "x", "b": "p2"}));
|
||||
assert_clean::<CoincidentResult>(&mut s, "sketch.constraint.coincident", json!({"a": {"midpoint": "l3"}, "b": "l4.start"}));
|
||||
assert_clean::<CoincidentResult>(&mut s, "sketch.constraint.coincident", json!({"a": "l5", "b": "l6"}));
|
||||
let r = assert_clean::<CoincidentResult>(&mut s, "sketch.constraint.coincident", json!({"a": "c3", "b": "c4"}));
|
||||
assert!(r["constraints"].as_array().is_some_and(|c| c.len() == 2) && r.get("constraint").is_some(), "{r}");
|
||||
|
||||
// Tangent, parallel, perpendicular, collinear.
|
||||
let mut s = new();
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.tangent", json!({"a": "l1", "b": "c1"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.parallel", json!({"a": "l2", "b": "l3"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.parallel", json!({"a": "l4", "b": "x"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.perpendicular", json!({"a": "l5", "b": "l6"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.perpendicular", json!({"a": "y", "b": "l1", "sketch": "Sketch1"}));
|
||||
let mut s = new();
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.collinear", json!({"a": "l1", "b": "l2"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.collinear", json!({"a": "l3", "b": "l4", "if_redundant": "refuse"}));
|
||||
|
||||
// Concentric: two circles, and a circle and a point.
|
||||
let mut s = new();
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.concentric", json!({"a": "c1", "b": "c2"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.concentric", json!({"a": "p1", "b": "c3"}));
|
||||
|
||||
// Equal: two curves, and a list, with the keys that list leaves unread.
|
||||
let mut s = new();
|
||||
let r = assert_clean::<EqualResult>(&mut s, "sketch.constraint.equal", json!({"a": "l1", "b": "l2"}));
|
||||
assert!(r.get("constraint").is_some() && r.get("constraints").is_none(), "{r}");
|
||||
let r = assert_clean::<EqualResult>(&mut s, "sketch.constraint.equal", json!({"curves": ["c1", "c2", "c3"], "a": 5, "if_redundant": 5}));
|
||||
assert!(r["constraints"].as_array().is_some_and(|c| c.len() == 2) && r.get("constraint").is_none(), "{r}");
|
||||
assert_clean::<EqualResult>(&mut s, "sketch.constraint.equal", json!({"curves": ["c3", "c4"]}));
|
||||
|
||||
// Midpoint: a point and a line, two lines, and the `a` and `b` form with `point` and `line`
|
||||
// left unread.
|
||||
let mut s = new();
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.midpoint", json!({"point": "p1", "line": "l1"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.midpoint", json!({"a": "l2", "b": "l3"}));
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.midpoint", json!({"a": "p2", "b": "l4", "point": 1, "line": 2}));
|
||||
|
||||
// Symmetry: about a sketch axis, a line, a point, two lines and two circles.
|
||||
let mut s = new();
|
||||
let r = assert_clean::<SymmetryResult>(&mut s, "sketch.constraint.symmetry", json!({"a": "l2.start", "b": "l2.end", "line": "y"}));
|
||||
assert_eq!(r["constraints"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
assert_clean::<SymmetryResult>(&mut s, "sketch.constraint.symmetry", json!({"a": "p1", "b": "p2", "line": "l5"}));
|
||||
assert_clean::<SymmetryResult>(&mut s, "sketch.constraint.symmetry", json!({"a": "l3", "b": "l4", "point": "origin"}));
|
||||
assert_clean::<SymmetryResult>(&mut s, "sketch.constraint.symmetry", json!({"a": "c1", "b": "c2", "line": "y"}));
|
||||
assert_clean::<SymmetryResult>(&mut s, "sketch.constraint.symmetry", json!({"a": "l5", "b": "l6", "line": "l1"}));
|
||||
|
||||
// Fix: one entity, a list, `fixed`, and a release.
|
||||
let mut s = new();
|
||||
let r = assert_clean::<FixResult>(&mut s, "sketch.constraint.fix", json!({"entity": "p1"}));
|
||||
assert_eq!(r["result"], json!({"fixed": true, "changed": 1}), "{r}");
|
||||
assert_clean::<FixResult>(&mut s, "sketch.constraint.fix", json!({"entities": ["l1", "c1"], "fixed": true}));
|
||||
assert_clean::<FixResult>(&mut s, "sketch.constraint.fix", json!({"entity": "p2", "entities": "l2"}));
|
||||
let r = assert_clean::<FixResult>(&mut s, "sketch.constraint.fix", json!({"entities": ["p1", "l1"], "fixed": false, "sketch": "Sketch1"}));
|
||||
assert_eq!(r["result"]["fixed"], false, "{r}");
|
||||
}
|
||||
|
||||
/// A constraint the sketch already determines is kept as a redundant one, and the reply says what
|
||||
/// determines it, unless `if_redundant` is `refuse`.
|
||||
#[test]
|
||||
fn a_redundant_constraint_is_reported() {
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.parallel", json!({"a": "l1", "b": "l2"}));
|
||||
let r = assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.parallel", json!({"a": "l1", "b": "l2"}));
|
||||
assert_eq!(r["redundant"], true, "{r}");
|
||||
assert!(r["determined_by"].is_array(), "{r}");
|
||||
let e = s.execute("sketch.constraint.parallel", &json!({"a": "l1", "b": "l2", "if_redundant": "Refuse"})).unwrap_err().to_string();
|
||||
assert!(e.contains("is already determined by"), "{e}");
|
||||
}
|
||||
|
||||
/// A key a call misspells is reported in warn, and the reply still matches its schema.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn() {
|
||||
let cases: [(&str, Value, &str); 11] = [
|
||||
("sketch.constraint.coincident", json!({"a": "l1.end", "b": "l2.start", "if_redundent": "refuse"}), "if_redundant"),
|
||||
("sketch.constraint.collinear", json!({"a": "l1", "b": "l2", "skech": "Sketch1"}), "sketch"),
|
||||
("sketch.constraint.concentric", json!({"a": "c1", "b": "c2", "bb": "x"}), "b"),
|
||||
("sketch.constraint.equal", json!({"curvs": ["c1", "c2"], "a": "l1", "b": "l2"}), "curves"),
|
||||
("sketch.constraint.fix", json!({"entites": ["p1"], "entity": "p1"}), "entities"),
|
||||
("sketch.constraint.horizontal_vertical", json!({"line": "l1", "mod": "vertical"}), "mode"),
|
||||
("sketch.constraint.midpoint", json!({"point": "p1", "lines": "l1", "line": "l1"}), "line"),
|
||||
("sketch.constraint.parallel", json!({"a": "l1", "b": "l2", "sketch_": "Sketch1"}), "sketch"),
|
||||
("sketch.constraint.perpendicular", json!({"a": "l1", "b": "l2", "if_redundnt": "refuse"}), "if_redundant"),
|
||||
("sketch.constraint.symmetry", json!({"a": "p1", "b": "p2", "lin": "y", "line": "y"}), "line"),
|
||||
("sketch.constraint.tangent", json!({"a": "l1", "b": "c1", "if_redundant ": "refuse"}), "if_redundant"),
|
||||
];
|
||||
for (id, p, nearest) in cases {
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let r = run(&mut s, id, p.clone());
|
||||
let warnings = r["param_warnings"].as_array().unwrap_or_else(|| panic!("{id} {p}: no warning in {r}"));
|
||||
assert_eq!(warnings.len(), 1, "{id}: {warnings:?}");
|
||||
let w = warnings[0].as_str().unwrap_or_default();
|
||||
assert!(w.starts_with("unknown key `") && w.contains(&format!("did you mean `{nearest}`?")), "{id}: {w}");
|
||||
// And the check names it, without running anything.
|
||||
assert_eq!(issues(id, &p).len(), 1, "{id}");
|
||||
match id {
|
||||
"sketch.constraint.fix" => output_conforms::<FixResult>(&r),
|
||||
"sketch.constraint.symmetry" => output_conforms::<SymmetryResult>(&r),
|
||||
"sketch.constraint.coincident" => output_conforms::<CoincidentResult>(&r),
|
||||
"sketch.constraint.equal" => output_conforms::<EqualResult>(&r),
|
||||
_ => output_conforms::<ConstraintResult>(&r),
|
||||
}
|
||||
// In strict the call is refused, and the sketch stays as it was.
|
||||
let mut s = scene(ParamCheck::Strict);
|
||||
let before = s.doc.clone();
|
||||
let e = s.execute(id, &p).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown key"), "{id}: {e}");
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.doc) || *before == *s.doc, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With the check off, the replies are the ones the commands gave before: the same keys, and no
|
||||
/// `param_warnings`.
|
||||
#[test]
|
||||
fn off_leaves_the_replies_as_they_were() {
|
||||
let mut s = scene(ParamCheck::Off);
|
||||
let r = run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1", "if_redundnt": 1}));
|
||||
let mut keys: Vec<_> = r.as_object().into_iter().flat_map(|o| o.keys().cloned()).collect();
|
||||
keys.sort();
|
||||
assert_eq!(keys, ["constraint", "created", "sketch"], "{r}");
|
||||
let r = run(&mut s, "sketch.constraint.fix", json!({"entity": "p1"}));
|
||||
let mut keys: Vec<_> = r.as_object().into_iter().flat_map(|o| o.keys().cloned()).collect();
|
||||
keys.sort();
|
||||
assert_eq!(keys, ["created", "result", "sketch"], "{r}");
|
||||
}
|
||||
|
||||
/// A model edge or vertex is an operand: projected into the sketch first, and the call gets no
|
||||
/// warning.
|
||||
#[test]
|
||||
fn model_edges_and_vertices_are_operands() {
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 50]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Base", "distance": "20 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "offset": "20 mm", "name": "Top"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 25]], "ids": ["l1"]}));
|
||||
let r = assert_clean::<ConstraintResult>(&mut s, "sketch.constraint.parallel", json!({"a": "l1", "b": {"edge": [100, 25, 20], "body": "Body1"}}));
|
||||
assert_eq!(r["created"]["curves"].as_array().map(Vec::len), Some(1), "the projected edge is reported: {r}");
|
||||
assert_clean::<CoincidentResult>(&mut s, "sketch.constraint.coincident", json!({"a": "l1.end", "b": {"vertex": [100, 0, 20]}}));
|
||||
}
|
||||
@@ -531,6 +531,22 @@ keyword_param!(
|
||||
"mitre or swept",
|
||||
"How two rounds turn round a sharp, straight, convex side edge square to both on a plane (the rounds along a boss's base, at its upright edges): mitre (the default) runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus. swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent. Matching ignores case."
|
||||
);
|
||||
keyword_param!(
|
||||
/// `plastic.lip`'s `type`.
|
||||
LipTypeParam,
|
||||
"type",
|
||||
&["lip", "groove"],
|
||||
"lip or groove",
|
||||
"What the feature makes along the rim: lip (the default), a raised band, or groove, a band cut into the rim for another part's lip. Matching ignores case."
|
||||
);
|
||||
keyword_param!(
|
||||
/// `plastic.lip`'s `side`.
|
||||
LipSideParam,
|
||||
"side",
|
||||
&["inside", "outside"],
|
||||
"inside or outside",
|
||||
"Which edge of the rim face the band runs along: inside (the default) or outside. Matching ignores case."
|
||||
);
|
||||
|
||||
/// How a feature combines with existing bodies, as `features::operation` reads it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -609,6 +625,41 @@ impl JsonSchema for ExtrudeOperation {
|
||||
}
|
||||
}
|
||||
|
||||
/// What Combine's tools do to its target, as `features::combine` reads `operation`: a spelling
|
||||
/// of join, cut or intersect that `Operation::parse` accepts. A spelling of new, or any other
|
||||
/// word, gets the handler's message.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CombineOperation(pub Operation);
|
||||
|
||||
/// The handler's message for an operation Combine doesn't take.
|
||||
const COMBINE_OPERATION_MESSAGE: &str = "operation must be join, cut or intersect";
|
||||
|
||||
/// Combine's operation for `s`: `Operation::parse`'s, but not new.
|
||||
fn combine_operation(s: &str) -> Option<Operation> {
|
||||
Operation::parse(s).filter(|o| *o != Operation::NewBody)
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for CombineOperation {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let ok = |s: &str| combine_operation(s).is_some();
|
||||
let s = string_matching(d, ok, "join, cut or intersect", |_| whole(COMBINE_OPERATION_MESSAGE))?;
|
||||
combine_operation(&s).map(CombineOperation).ok_or_else(|| D::Error::custom(whole(COMBINE_OPERATION_MESSAGE)))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for CombineOperation {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"CombineOperation".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
let values: Vec<&str> = OPERATIONS.iter().copied().filter(|s| combine_operation(s).is_some()).collect();
|
||||
enum_schema(
|
||||
&values,
|
||||
"What the tools do to the target: join (the default), cut or intersect. The others are aliases. Matching ignores case, spaces, '_' and '-'.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const DIRECTIONS: &[&str] = &["positive", "negative", "symmetric", "one_side", "reverse", "flip"];
|
||||
|
||||
/// Extrude's direction, as `extent_param` reads it (lowercased, not trimmed).
|
||||
|
||||
@@ -8,8 +8,6 @@ component.create
|
||||
component.delete
|
||||
component.from_bodies
|
||||
component.list
|
||||
component.metadata
|
||||
component.metadata.set
|
||||
component.move_bodies
|
||||
component.move_sketches
|
||||
component.paste_new
|
||||
@@ -30,7 +28,6 @@ explode.show
|
||||
expr.evaluate
|
||||
face.delete
|
||||
file.export
|
||||
file.export_view_dxf
|
||||
file.insert_mesh
|
||||
file.insert_step
|
||||
file.new
|
||||
@@ -42,9 +39,7 @@ frame.member
|
||||
frame.profile.insert
|
||||
frame.profile.list
|
||||
frame.profile.properties
|
||||
inspect.interference
|
||||
inspect.measure
|
||||
inspect.silhouette
|
||||
joint.as_built
|
||||
joint.cam
|
||||
joint.create
|
||||
@@ -83,32 +78,19 @@ parameters.graph
|
||||
parameters.import
|
||||
parameters.list
|
||||
parameters.users
|
||||
plastic.boss
|
||||
plastic.lip
|
||||
plastic.rest
|
||||
plastic.snap_fit
|
||||
render.aging.describe
|
||||
render.backends
|
||||
render.cancel
|
||||
render.effects.describe
|
||||
render.export
|
||||
render.library.appearance.apply
|
||||
render.library.environment.apply
|
||||
render.library.physical.prepare
|
||||
render.library.studio.apply
|
||||
render.library.studio.prepare
|
||||
render.library.surface.prepare
|
||||
render.preflight
|
||||
render.result.pin
|
||||
render.result.regrade
|
||||
render.scene
|
||||
render.settings.get
|
||||
render.settings.set
|
||||
render.start
|
||||
render.status
|
||||
select.clear
|
||||
select.set
|
||||
selection.delete
|
||||
sheet.export_bend_table
|
||||
sheet.export_dxf
|
||||
sheet.flat_pattern
|
||||
@@ -120,13 +102,9 @@ sketch.auto_constrain
|
||||
sketch.auto_constrain_from_datum
|
||||
sketch.auto_project
|
||||
sketch.blend_curve
|
||||
sketch.break
|
||||
sketch.break_link
|
||||
sketch.centerline
|
||||
sketch.chain
|
||||
sketch.chamfer.distance_angle
|
||||
sketch.chamfer.equal_distance
|
||||
sketch.chamfer.two_distance
|
||||
sketch.circle.center
|
||||
sketch.circle.three_point
|
||||
sketch.circle.three_tangent
|
||||
@@ -134,19 +112,8 @@ sketch.circle.two_point
|
||||
sketch.circle.two_tangent
|
||||
sketch.conic
|
||||
sketch.constrainer
|
||||
sketch.constraint.coincident
|
||||
sketch.constraint.collinear
|
||||
sketch.constraint.concentric
|
||||
sketch.constraint.curvature
|
||||
sketch.constraint.equal
|
||||
sketch.constraint.fix
|
||||
sketch.constraint.horizontal_vertical
|
||||
sketch.constraint.midpoint
|
||||
sketch.constraint.parallel
|
||||
sketch.constraint.perpendicular
|
||||
sketch.constraint.polygon
|
||||
sketch.constraint.symmetry
|
||||
sketch.constraint.tangent
|
||||
sketch.construction
|
||||
sketch.create
|
||||
sketch.delete
|
||||
@@ -158,8 +125,6 @@ sketch.draw.dimensioned
|
||||
sketch.edit_text
|
||||
sketch.ellipse
|
||||
sketch.export_dxf
|
||||
sketch.extend
|
||||
sketch.fillet
|
||||
sketch.finish_auto_constrain
|
||||
sketch.fit_curves_to_section
|
||||
sketch.glyphs
|
||||
@@ -174,15 +139,9 @@ sketch.isoparametric_curve
|
||||
sketch.line
|
||||
sketch.line.midpoint
|
||||
sketch.line3d
|
||||
sketch.mirror
|
||||
sketch.move
|
||||
sketch.move3d
|
||||
sketch.move_point
|
||||
sketch.offset
|
||||
sketch.offset.break
|
||||
sketch.options
|
||||
sketch.pattern.circular
|
||||
sketch.pattern.rectangular
|
||||
sketch.point
|
||||
sketch.point3d
|
||||
sketch.polygon.circumscribed
|
||||
@@ -195,7 +154,6 @@ sketch.rectangle.rounded
|
||||
sketch.rectangle.three_point
|
||||
sketch.rectangle.two_point
|
||||
sketch.redefine
|
||||
sketch.scale
|
||||
sketch.slot.arc_center
|
||||
sketch.slot.arc_three_point
|
||||
sketch.slot.center_point
|
||||
@@ -210,12 +168,9 @@ sketch.spline3d
|
||||
sketch.spun_profile
|
||||
sketch.text
|
||||
sketch.toggle_driven
|
||||
sketch.trim
|
||||
sketch.trim.path
|
||||
solid.align
|
||||
solid.chamfer
|
||||
solid.coil
|
||||
solid.combine
|
||||
solid.draft
|
||||
solid.emboss
|
||||
solid.hole
|
||||
@@ -251,25 +206,16 @@ surface.trim
|
||||
surface.unstitch
|
||||
surface.untrim
|
||||
timeline.add
|
||||
timeline.compute_all
|
||||
timeline.copy
|
||||
timeline.delete
|
||||
timeline.dependents
|
||||
timeline.edit
|
||||
timeline.export_script
|
||||
timeline.group
|
||||
timeline.inspect
|
||||
timeline.move_many
|
||||
timeline.move_to_group
|
||||
timeline.paste
|
||||
timeline.redefine
|
||||
timeline.rename
|
||||
timeline.rename_group
|
||||
timeline.reorder
|
||||
timeline.reorder_many
|
||||
timeline.roll_to
|
||||
timeline.rollback
|
||||
timeline.suppress
|
||||
timeline.ungroup
|
||||
view.apply
|
||||
view.delete
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user