mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 04:52:10 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c18aaf7cc3 | |||
| 1e6b1ec20f | |||
| 4d96c780e1 | |||
| 52afe212a8 |
@@ -842,6 +842,7 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
||||
| crates/sketch/assets/fonts/liberation-sans/LiberationSans-Regular.ttf | Red Hat, Inc. (Liberation Fonts 2.x; digitized data Google) | https://github.com/liberationfonts (Debian fonts-liberation), unmodified; licence text in crates/sketch/assets/fonts/liberation-sans/OFL.txt; used for sketch Text outlines | SIL OFL 1.1 |
|
||||
| crates/sketch/assets/fonts/liberation-sans/OFL.txt | Red Hat, Inc.; Liberation Fonts contributors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/locales/pending/drag-to-zoom.json | SolidCraft contributors | Original drag-to-zoom labels and translations | MIT OR Apache-2.0 |
|
||||
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
|
||||
|
||||
## Code adapted from sibling projects
|
||||
|
||||
|
||||
@@ -2561,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "درجات",
|
||||
"prefs.design_tools.double_click_help": "أو لتحرير مكوّن مرتبط في موضعه.",
|
||||
"prefs.design_tools.ghost": "إظهار الموضع القديم للأجزاء المنقولة كشبح",
|
||||
"prefs.design_tools.hole_group": "تجميع الثقوب المصنوعة معًا",
|
||||
"prefs.design_tools.hole_group_help": "الثقوب المصنوعة دفعة واحدة تشترك في مجلد في السجل، مغلق في البداية؛ افتحه لتحرير كل ثقب على حدة.",
|
||||
"prefs.design_tools.hole_moves_help": "اختيار الوجه مجددًا ينقل الثقب بدلًا من إزالته.",
|
||||
"prefs.design_tools.hole_several": "عدة ثقوب على وجه واحد",
|
||||
"prefs.design_tools.hole_several_help": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
|
||||
|
||||
@@ -2348,6 +2348,8 @@
|
||||
"prefs.design_tools.degrees": "Grad",
|
||||
"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_group": "Gemeinsam erstellte Bohrungen gruppieren",
|
||||
"prefs.design_tools.hole_group_help": "In einem Schritt erstellte Bohrungen teilen sich einen Ordner im Verlauf, zunächst geschlossen; öffnen Sie ihn, um eine Bohrung einzeln zu bearbeiten.",
|
||||
"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.",
|
||||
|
||||
@@ -2561,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "Degrees",
|
||||
"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_group": "Group holes made together",
|
||||
"prefs.design_tools.hole_group_help": "Holes made in one go share a folder in History, closed at first; open it to edit a hole on its own.",
|
||||
"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.",
|
||||
|
||||
@@ -2561,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "מעלות",
|
||||
"prefs.design_tools.double_click_help": "או לעריכת רכיב מקושר במקומו.",
|
||||
"prefs.design_tools.ghost": "הצגת המיקום הקודם של חלקים שהוזזו כרפאים",
|
||||
"prefs.design_tools.hole_group": "קיבוץ חורים שנוצרו יחד",
|
||||
"prefs.design_tools.hole_group_help": "חורים שנוצרו בפעולה אחת חולקים תיקייה בהיסטוריה, סגורה בהתחלה; פתחו אותה כדי לערוך חור בנפרד.",
|
||||
"prefs.design_tools.hole_moves_help": "בחירה חוזרת בפאה מזיזה את החור במקום להסירו.",
|
||||
"prefs.design_tools.hole_several": "כמה חורים בפאה אחת",
|
||||
"prefs.design_tools.hole_several_help": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
|
||||
|
||||
@@ -2561,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "リンクされたコンポーネントをその場で編集することもできます。",
|
||||
"prefs.design_tools.ghost": "移動したパーツの元の位置をゴースト表示",
|
||||
"prefs.design_tools.hole_group": "一緒に作成した穴をまとめる",
|
||||
"prefs.design_tools.hole_group_help": "一度に作成した穴は履歴で1つのフォルダーにまとめられ、最初は閉じています。開くと穴を個別に編集できます。",
|
||||
"prefs.design_tools.hole_moves_help": "面をもう一度選ぶと穴を削除せずに移動します。",
|
||||
"prefs.design_tools.hole_several": "1つの面に複数の穴",
|
||||
"prefs.design_tools.hole_several_help": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
|
||||
|
||||
@@ -1929,6 +1929,8 @@
|
||||
"prefs.design_tools.degrees": "도",
|
||||
"prefs.design_tools.double_click_help": "연결된 구성요소를 제자리에서 편집할 수도 있습니다.",
|
||||
"prefs.design_tools.ghost": "이동한 부품의 이전 위치를 고스트로 표시",
|
||||
"prefs.design_tools.hole_group": "함께 만든 구멍 그룹화",
|
||||
"prefs.design_tools.hole_group_help": "한 번에 만든 구멍은 기록에서 하나의 폴더를 공유하며, 처음에는 닫혀 있습니다. 폴더를 열어 각 구멍을 개별적으로 편집하세요.",
|
||||
"prefs.design_tools.hole_moves_help": "면을 다시 선택하면 구멍을 제거하지 않고 이동합니다.",
|
||||
"prefs.design_tools.hole_snaps_help": "면의 가운데와 원형 모서리의 중심입니다.",
|
||||
"prefs.design_tools.imported_files": "가져온 파일",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"en": {
|
||||
"prefs.design_tools.hole_group": "Group holes made together",
|
||||
"prefs.design_tools.hole_group_help": "Holes made in one go share a folder in History, closed at first; open it to edit a hole on its own."
|
||||
},
|
||||
"ar": {
|
||||
"prefs.design_tools.hole_group": "تجميع الثقوب المصنوعة معًا",
|
||||
"prefs.design_tools.hole_group_help": "الثقوب المصنوعة دفعة واحدة تشترك في مجلد في السجل، مغلق في البداية؛ افتحه لتحرير كل ثقب على حدة."
|
||||
},
|
||||
"de": {
|
||||
"prefs.design_tools.hole_group": "Gemeinsam erstellte Bohrungen gruppieren",
|
||||
"prefs.design_tools.hole_group_help": "In einem Schritt erstellte Bohrungen teilen sich einen Ordner im Verlauf, zunächst geschlossen; öffnen Sie ihn, um eine Bohrung einzeln zu bearbeiten."
|
||||
},
|
||||
"he": {
|
||||
"prefs.design_tools.hole_group": "קיבוץ חורים שנוצרו יחד",
|
||||
"prefs.design_tools.hole_group_help": "חורים שנוצרו בפעולה אחת חולקים תיקייה בהיסטוריה, סגורה בהתחלה; פתחו אותה כדי לערוך חור בנפרד."
|
||||
},
|
||||
"ja": {
|
||||
"prefs.design_tools.hole_group": "一緒に作成した穴をまとめる",
|
||||
"prefs.design_tools.hole_group_help": "一度に作成した穴は履歴で1つのフォルダーにまとめられ、最初は閉じています。開くと穴を個別に編集できます。"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.design_tools.hole_group": "함께 만든 구멍 그룹화",
|
||||
"prefs.design_tools.hole_group_help": "한 번에 만든 구멍은 기록에서 하나의 폴더를 공유하며, 처음에는 닫혀 있습니다. 폴더를 열어 각 구멍을 개별적으로 편집하세요."
|
||||
},
|
||||
"vi": {
|
||||
"prefs.design_tools.hole_group": "Nhóm các lỗ được tạo cùng lúc",
|
||||
"prefs.design_tools.hole_group_help": "Các lỗ được tạo trong một lần dùng chung một thư mục trong Lịch sử, ban đầu được đóng; mở thư mục để sửa từng lỗ."
|
||||
},
|
||||
"zh": {
|
||||
"prefs.design_tools.hole_group": "将一起创建的孔分组",
|
||||
"prefs.design_tools.hole_group_help": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。"
|
||||
}
|
||||
}
|
||||
@@ -2561,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "Độ",
|
||||
"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_group": "Nhóm các lỗ được tạo cùng lúc",
|
||||
"prefs.design_tools.hole_group_help": "Các lỗ được tạo trong một lần dùng chung một thư mục trong Lịch sử, ban đầu được đóng; mở thư mục để sửa từng lỗ.",
|
||||
"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ỗ đó.",
|
||||
|
||||
@@ -2561,6 +2561,8 @@
|
||||
"prefs.design_tools.degrees": "度",
|
||||
"prefs.design_tools.double_click_help": "或就地编辑链接的组件。",
|
||||
"prefs.design_tools.ghost": "以虚影显示已移动零件的原位置",
|
||||
"prefs.design_tools.hole_group": "将一起创建的孔分组",
|
||||
"prefs.design_tools.hole_group_help": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。",
|
||||
"prefs.design_tools.hole_moves_help": "再次选择该面会移动孔而非移除孔。",
|
||||
"prefs.design_tools.hole_several": "一个面上多个孔",
|
||||
"prefs.design_tools.hole_several_help": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
| File | Author | Source | Licence |
|
||||
| --- | --- | --- | --- |
|
||||
| `assets/locales/pending/hole-group-folder.json` | SolidCraft contributors | Original Hole grouping preference labels and translations | MIT OR Apache-2.0 |
|
||||
@@ -129,6 +129,7 @@ mod sketch_import;
|
||||
mod sketch_inspect;
|
||||
mod sketch_modify;
|
||||
mod sketch_project;
|
||||
pub(crate) use sketch_project::{RefForm, ref_form};
|
||||
mod sketch_store;
|
||||
mod surface;
|
||||
mod surface_analysis;
|
||||
|
||||
@@ -6,29 +6,48 @@ use std::collections::HashMap;
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_io::{DxfEntity, DxfLayer, Geom2, MAX_DRAWING_BYTES};
|
||||
use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
|
||||
use solidcraft_sketch::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::sketch::{edit, ids_of};
|
||||
use super::sketch::{Left, edit_solved, ids_of};
|
||||
use super::sketch_store;
|
||||
use crate::params::{bad, num, str_, vec2};
|
||||
use crate::schema::sketch_import::{DrawingInserted, DxfExported, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams, SketchSolve};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
const INSERT_DXF_CONTRACT: TypedContract<InsertDxfParams> = TypedContract::new();
|
||||
const INSERT_SVG_CONTRACT: TypedContract<InsertSvgParams> = TypedContract::new();
|
||||
const EXPORT_DXF_CONTRACT: TypedContract<ExportDxfParams> = TypedContract::new();
|
||||
|
||||
/// A command's reply as JSON, built from the type its output schema describes 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()))
|
||||
}
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.insert_dxf", "Insert DXF", insert_dxf)
|
||||
CommandSpec::new("sketch.insert_dxf", "Insert DXF", crate::checked!("sketch.insert_dxf", insert_dxf))
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("sketch_dxf")
|
||||
.contract(&INSERT_DXF_CONTRACT)
|
||||
.output(output_schema_for::<DrawingInserted>)
|
||||
.params(concat!("path | text: ASCII DXF (blocks expanded); at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
CommandSpec::new("sketch.insert_svg", "Insert SVG", insert_svg)
|
||||
CommandSpec::new("sketch.insert_svg", "Insert SVG", crate::checked!("sketch.insert_svg", insert_svg))
|
||||
.at("SKETCH", "INSERT")
|
||||
.icon("svg")
|
||||
.contract(&INSERT_SVG_CONTRACT)
|
||||
.output(output_schema_for::<DrawingInserted>)
|
||||
.params(concat!("path | text: SVG; at?: [x,y] offset, scale? | width? | height?: mm, the drawing's size before rotation, aspect kept (win over scale); angle?: degrees, about the drawing's origin (or its centre); align?: \"origin\" (default: `at` is where the file's origin goes) | \"center\" (`at` is where the drawing's middle goes and it turns and scales about it); tolerance?: mm within which ends join (default 0.0001); plane?: (when no sketch is active, a new sketch on it; default XY)", created_doc!())),
|
||||
CommandSpec::new("sketch.drawing_bounds", "Drawing Size", drawing_bounds)
|
||||
.icon("svg")
|
||||
.noundo()
|
||||
.params("path | text: SVG or DXF (by `format`: \"svg\" | \"dxf\", default by the file name, else SVG); returns min, max, width and height in the file's own size"),
|
||||
CommandSpec::new("sketch.export_dxf", "Save As DXF", export_dxf).icon("sketch_dxf")
|
||||
CommandSpec::new("sketch.export_dxf", "Save As DXF", crate::checked!("sketch.export_dxf", export_dxf))
|
||||
.icon("sketch_dxf")
|
||||
.noundo()
|
||||
.contract(&EXPORT_DXF_CONTRACT)
|
||||
.output(output_schema_for::<ExportDxfResult>)
|
||||
.params("sketch?: id|name (default active); path?: file to write (else the DXF text is returned); units?: \"design\" | \"in\" | \"inch\" | \"mm\" | \"cm\" | \"m\" | \"ft\" (default mm); layer?: profile layer name (default \"0\"); layers?: \"single\" | \"roles\" (default \"single\": all on `layer`; \"roles\": outer on \"OUTLINE\", holes on \"CUT\"); join?: bool (default false: R12 LINE/ARC; true: closed loops as LWPOLYLINE with arc bulges)"),
|
||||
];
|
||||
|
||||
@@ -251,10 +270,13 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
if geom.len() > solidcraft_sketch::MAX_POINTS {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
let ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
// `sketch::edit`, but keeping the solve as a typed value for the reply.
|
||||
let target = sketch_store::target(s, p, cmd)?;
|
||||
let ((curves, texts), report) = edit_solved(s, target, false, Analysis::Freedom, |sk, _| {
|
||||
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
|
||||
Ok((ids_of(sk, &cs), texts))
|
||||
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
|
||||
})?;
|
||||
let info = report.map(|rep| SketchSolve { solved: rep.keeps_orientation(), dof: rep.dof });
|
||||
// Text becomes sketch text.
|
||||
let id = sketch_store::active(s).ok_or_else(|| bad(cmd, "no sketch"))?;
|
||||
let mut n_text = 0;
|
||||
@@ -279,7 +301,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"sketch": id, "created_sketch": created, "curves": curves.len(), "texts": n_text, "info": info}))
|
||||
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -522,9 +544,10 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "entities": entity_count}));
|
||||
let exported = DxfExported::Written { path: path.to_string(), entities: entity_count };
|
||||
return reply(cmd, ExportDxfResult { exported, param_warnings: Vec::new() });
|
||||
}
|
||||
Ok(json!({"dxf": text, "entities": entity_count}))
|
||||
reply(cmd, ExportDxfResult { exported: DxfExported::Text { dxf: text, entities: entity_count }, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -13,6 +13,12 @@ use solidcraft_sketch::{ConstraintKind, LinkKind, LinkSource, Sketch};
|
||||
use super::sketch_store;
|
||||
use super::{CommandSpec, in_sketch};
|
||||
use crate::params::{bad, bool_, str_, vec3};
|
||||
use crate::schema::sketch_project::{
|
||||
AutoProjectParams, AutoProjectResult, FitCurvesParams, FitSectionResult, Include3dParams, IntersectParams, IntersectionCurveParams,
|
||||
IsoCurveParams, LinkMade, ProjectParams, ProjectResult, ProjectToSurfaceParams, ProjectToSurfaceResult, ProjectedLink, SketchBreakLinkParams,
|
||||
SketchBreakLinkResult, SketchLinkResult, SpunProfileParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
macro_rules! refs_doc {
|
||||
@@ -21,105 +27,160 @@ macro_rules! refs_doc {
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECT_CONTRACT: TypedContract<ProjectParams> = TypedContract::new();
|
||||
const INTERSECT_CONTRACT: TypedContract<IntersectParams> = TypedContract::new();
|
||||
const INCLUDE_3D_CONTRACT: TypedContract<Include3dParams> = TypedContract::new();
|
||||
const PROJECT_TO_SURFACE_CONTRACT: TypedContract<ProjectToSurfaceParams> = TypedContract::new();
|
||||
const INTERSECTION_CURVE_CONTRACT: TypedContract<IntersectionCurveParams> = TypedContract::new();
|
||||
const SPUN_PROFILE_CONTRACT: TypedContract<SpunProfileParams> = TypedContract::new();
|
||||
const ISO_CURVE_CONTRACT: TypedContract<IsoCurveParams> = TypedContract::new();
|
||||
const FIT_SECTION_CONTRACT: TypedContract<FitCurvesParams> = TypedContract::new();
|
||||
const BREAK_LINK_CONTRACT: TypedContract<SketchBreakLinkParams> = TypedContract::new();
|
||||
const AUTO_PROJECT_CONTRACT: TypedContract<AutoProjectParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.project", "Project", project_cmd).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).params(
|
||||
CommandSpec::new("sketch.project", "Project", crate::checked!("sketch.project", project_cmd)).at("SKETCH", "CREATE").icon("project").key("P").enabled(in_sketch).contract(&PROJECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
|
||||
concat!(refs_doc!(), "; projection_link?: bool (default true)", created_doc!()),
|
||||
),
|
||||
CommandSpec::new("sketch.intersect", "Intersect", intersect_cmd).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).params(
|
||||
CommandSpec::new("sketch.intersect", "Intersect", crate::checked!("sketch.intersect", intersect_cmd)).at("SKETCH", "CREATE").icon("intersect").enabled(in_sketch).contract(&INTERSECT_CONTRACT).output(output_schema_for::<ProjectResult>).params(
|
||||
concat!("refs: [{body}, {face: [x,y,z], body?}, {edge: [x,y,z], body?}, {sketch, curve}]: section curves (or points) with the sketch plane; projection_link?: bool (default true)", created_doc!()),
|
||||
),
|
||||
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", include_cmd)
|
||||
CommandSpec::new("sketch.include_3d", "Include 3D Geometry", crate::checked!("sketch.include_3d", include_cmd))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("include")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
|
||||
CommandSpec::new("sketch.project_to_surface", "Project To Surface", project_to_surface)
|
||||
.contract(&INCLUDE_3D_CONTRACT).output(output_schema_for::<ProjectResult>).params(concat!("refs: like Project (edges, vertices, sketch curves and points); curves out of the sketch plane are flattened onto it", created_doc!())),
|
||||
CommandSpec::new("sketch.project_to_surface", "Project To Surface", crate::checked!("sketch.project_to_surface", project_to_surface))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("project_surface")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", intersection_curve)
|
||||
.contract(&PROJECT_TO_SURFACE_CONTRACT).output(output_schema_for::<ProjectToSurfaceResult>).params(concat!("curves: [{sketch, curve}…] (curves of other sketches), face: [x,y,z] point on the target face, body?: projected along the active sketch's normal onto the face, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.intersection_curve", "Intersection Curve", crate::checked!("sketch.intersection_curve", intersection_curve))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("intersection_curve")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("a, b: {body} | {face: [x,y,z], body?} | {sketch, curve} (a curve of another sketch, standing in for its extrusion along that sketch's normal): where they meet, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.spun_profile", "Spun Profile", spun_profile)
|
||||
.contract(&INTERSECTION_CURVE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("a, b: {body} | {face: [x,y,z], body?} | {sketch, curve} (a curve of another sketch, standing in for its extrusion along that sketch's normal): where they meet, as linked 3D curves", created_doc!())),
|
||||
CommandSpec::new("sketch.spun_profile", "Spun Profile", crate::checked!("sketch.spun_profile", spun_profile))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spun")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
|
||||
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", iso_curve)
|
||||
.contract(&SPUN_PROFILE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("body: name, axis: a line of the active sketch (curve id) | X|Y|Z: the body's outline revolved about the axis, in the sketch plane", created_doc!())),
|
||||
CommandSpec::new("sketch.isoparametric_curve", "Isoparametric Curve", crate::checked!("sketch.isoparametric_curve", iso_curve))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("iso_curve")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
|
||||
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", fit_section)
|
||||
.contract(&ISO_CURVE_CONTRACT).output(output_schema_for::<SketchLinkResult>).params(concat!("face: [x,y,z] point on the face (the curve passes through it), body?, direction?: u|v|[x,y,z] (default u): linked 3D curve", created_doc!())),
|
||||
CommandSpec::new("sketch.fit_curves_to_section", "Fit Curves to Mesh Section", crate::checked!("sketch.fit_curves_to_section", fit_section))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("fit_section")
|
||||
.enabled(in_sketch)
|
||||
.params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
|
||||
CommandSpec::new("sketch.break_link", "Break Link", break_link).icon("sketch_break")
|
||||
.contract(&FIT_SECTION_CONTRACT).output(output_schema_for::<FitSectionResult>).params(concat!("body: name (a mesh or solid body): its section with the sketch plane, fitted with lines and arcs, as normal (unlinked) geometry", created_doc!())),
|
||||
CommandSpec::new("sketch.break_link", "Break Link", crate::checked!("sketch.break_link", break_link)).icon("sketch_break")
|
||||
.enabled(in_sketch)
|
||||
.params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
|
||||
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", auto_project).icon("project")
|
||||
.contract(&BREAK_LINK_CONTRACT).output(output_schema_for::<SketchBreakLinkResult>).params("link?: link id (j1) | entities?: [curve or point ids]: linked geometry becomes normal sketch geometry"),
|
||||
CommandSpec::new("sketch.auto_project", "Auto-Project Edges on Reference", crate::checked!("sketch.auto_project", auto_project)).icon("project")
|
||||
.noundo()
|
||||
.params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
|
||||
.contract(&AUTO_PROJECT_CONTRACT).output(output_schema_for::<AutoProjectResult>).params("value?: bool (default toggles): project a face's edges when a sketch is created on it"),
|
||||
];
|
||||
|
||||
/// Parse one reference.
|
||||
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
/// What a reference says in its JSON, before the design is asked what it names (`source` goes on
|
||||
/// from here, and so does the contract of the commands that take references).
|
||||
pub(crate) enum RefForm<'a> {
|
||||
/// Text: `origin`, an axis letter or a body name, which the design decides.
|
||||
Name(&'a str),
|
||||
/// `{edge: [x,y,z], body?}`.
|
||||
Edge { body: String, at: Vec3 },
|
||||
/// `{face: [x,y,z], body?}`.
|
||||
Face { body: String, at: Vec3 },
|
||||
/// `{vertex: [x,y,z], body?}`.
|
||||
Vertex { body: String, at: Vec3 },
|
||||
/// `{sketch, curve}` or `{sketch, point}`: the sketch as the lookup reads it (an id or a name)
|
||||
/// and the `curve` or `point` given. With neither, the handler refuses it once the sketch is
|
||||
/// found.
|
||||
Sketch { key: String, curve: Option<&'a str>, point: Option<&'a str> },
|
||||
/// `{body: name}`.
|
||||
Body(&'a str),
|
||||
/// `{axis: X|Y|Z|name}`.
|
||||
Axis(&'a str),
|
||||
/// `{plane: XY|XZ|YZ|name}`.
|
||||
Plane(&'a str),
|
||||
/// `{origin: …}`.
|
||||
Origin,
|
||||
}
|
||||
|
||||
/// The form of one reference, or the message (without the command's name) for what no design
|
||||
/// could make sense of. The forms are tried in this order, the first that fits wins: an edge, face
|
||||
/// or vertex whose value is not a point is passed over, not refused.
|
||||
pub(crate) fn ref_form(v: &Value) -> std::result::Result<RefForm<'_>, String> {
|
||||
if let Some(name) = v.as_str() {
|
||||
return match name.to_ascii_lowercase().as_str() {
|
||||
"origin" => Ok(LinkSource::Origin),
|
||||
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
|
||||
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
|
||||
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
|
||||
};
|
||||
return Ok(RefForm::Name(name));
|
||||
}
|
||||
let body = || str_(v, "body").unwrap_or("").to_string();
|
||||
if let Some(at) = v.get("edge").and_then(vec3) {
|
||||
return Ok(LinkSource::Edge { body: body(), at });
|
||||
return Ok(RefForm::Edge { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("face").and_then(vec3) {
|
||||
return Ok(LinkSource::Face { body: body(), at });
|
||||
return Ok(RefForm::Face { body: body(), at });
|
||||
}
|
||||
if let Some(at) = v.get("vertex").and_then(vec3) {
|
||||
return Ok(LinkSource::Vertex { body: body(), at });
|
||||
return Ok(RefForm::Vertex { body: body(), at });
|
||||
}
|
||||
if let Some(sk) = v.get("sketch") {
|
||||
let key = match sk {
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::String(x) => x.clone(),
|
||||
_ => return Err(bad(cmd, "`sketch` must be an id or a name")),
|
||||
_ => return Err("`sketch` must be an id or a name".into()),
|
||||
};
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sketch")));
|
||||
}
|
||||
if Some(f.id) == s.active_sketch {
|
||||
return Err(bad(cmd, "a sketch cannot project its own geometry"));
|
||||
}
|
||||
if let Some(c) = str_(v, "curve") {
|
||||
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
|
||||
}
|
||||
if let Some(p) = str_(v, "point") {
|
||||
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
|
||||
}
|
||||
return Err(bad(cmd, "a sketch reference needs `curve` or `point`"));
|
||||
return Ok(RefForm::Sketch { key, curve: str_(v, "curve"), point: str_(v, "point") });
|
||||
}
|
||||
if let Some(b) = str_(v, "body") {
|
||||
return Ok(LinkSource::Body { body: b.to_string() });
|
||||
return Ok(RefForm::Body(b));
|
||||
}
|
||||
if let Some(a) = str_(v, "axis") {
|
||||
return axis_source(s, a, cmd);
|
||||
return Ok(RefForm::Axis(a));
|
||||
}
|
||||
if let Some(p) = str_(v, "plane") {
|
||||
return Ok(LinkSource::Plane { name: p.to_string() });
|
||||
return Ok(RefForm::Plane(p));
|
||||
}
|
||||
if v.get("origin").is_some() {
|
||||
return Ok(LinkSource::Origin);
|
||||
return Ok(RefForm::Origin);
|
||||
}
|
||||
Err(format!("cannot project {v}"))
|
||||
}
|
||||
|
||||
/// Parse one reference.
|
||||
fn source(s: &Session, v: &Value, cmd: &str) -> Result<LinkSource> {
|
||||
match ref_form(v).map_err(|why| bad(cmd, why))? {
|
||||
RefForm::Name(name) => match name.to_ascii_lowercase().as_str() {
|
||||
"origin" => Ok(LinkSource::Origin),
|
||||
"x" | "y" | "z" => Ok(LinkSource::Axis { name: name.to_ascii_uppercase() }),
|
||||
_ if s.model.state().body(name).is_some() => Ok(LinkSource::Body { body: name.to_string() }),
|
||||
_ => Err(bad(cmd, format!("unknown reference `{name}`"))),
|
||||
},
|
||||
RefForm::Edge { body, at } => Ok(LinkSource::Edge { body, at }),
|
||||
RefForm::Face { body, at } => Ok(LinkSource::Face { body, at }),
|
||||
RefForm::Vertex { body, at } => Ok(LinkSource::Vertex { body, at }),
|
||||
RefForm::Sketch { key, curve, point } => {
|
||||
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
|
||||
if !matches!(f.kind, FeatureKind::Sketch { .. }) {
|
||||
return Err(bad(cmd, format!("`{key}` is not a sketch")));
|
||||
}
|
||||
if Some(f.id) == s.active_sketch {
|
||||
return Err(bad(cmd, "a sketch cannot project its own geometry"));
|
||||
}
|
||||
if let Some(c) = curve {
|
||||
return Ok(LinkSource::SketchCurve { sketch: f.id, curve: c.to_string() });
|
||||
}
|
||||
if let Some(p) = point {
|
||||
return Ok(LinkSource::SketchPoint { sketch: f.id, point: p.to_string() });
|
||||
}
|
||||
Err(bad(cmd, "a sketch reference needs `curve` or `point`"))
|
||||
}
|
||||
RefForm::Body(b) => Ok(LinkSource::Body { body: b.to_string() }),
|
||||
RefForm::Axis(a) => axis_source(s, a, cmd),
|
||||
RefForm::Plane(p) => Ok(LinkSource::Plane { name: p.to_string() }),
|
||||
RefForm::Origin => Ok(LinkSource::Origin),
|
||||
}
|
||||
Err(bad(cmd, format!("cannot project {v}")))
|
||||
}
|
||||
|
||||
/// An origin axis (`X`, `Y` or `Z`, in any case), or a construction axis by its name (#9324).
|
||||
@@ -391,10 +452,17 @@ fn add_link_unless_present(
|
||||
Ok(Some(link))
|
||||
}
|
||||
|
||||
fn link_json(sk: &Sketch, link: &str) -> 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()))
|
||||
}
|
||||
|
||||
/// The ids of the curves and of the points that link `link` made.
|
||||
fn link_parts(sk: &Sketch, link: &str) -> (Vec<String>, Vec<String>) {
|
||||
let curves: Vec<String> = sk.link_curves(link).iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect();
|
||||
let points: Vec<String> = sk.link_points(link).iter().filter_map(|p| sk.points.get(*p).map(|p| p.id.clone())).collect();
|
||||
json!({"link": link, "curves": curves, "points": points})
|
||||
(curves, points)
|
||||
}
|
||||
|
||||
fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_link: bool) -> Result<Value> {
|
||||
@@ -418,9 +486,10 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
|
||||
let what = src.describe();
|
||||
match add_link_unless_present(s, doc, id, sk, kind, src, kind == LinkKind::Project) {
|
||||
Ok(Some(l)) => {
|
||||
let mut result = link_json(sk, &l);
|
||||
let (curves, points) = link_parts(sk, &l);
|
||||
let mut result = ProjectedLink { link: Some(l.clone()), curves, points };
|
||||
if !projection_link {
|
||||
result["link"] = Value::Null;
|
||||
result.link = None;
|
||||
sk.break_link(&l)?;
|
||||
}
|
||||
out.push(result);
|
||||
@@ -435,11 +504,7 @@ fn run_links(s: &mut Session, p: &Value, cmd: &str, kind: LinkKind, projection_l
|
||||
}
|
||||
Ok((out, present))
|
||||
})?;
|
||||
let mut r = json!({"links": out});
|
||||
if !present.is_empty() {
|
||||
r["already_projected"] = json!(present);
|
||||
}
|
||||
Ok(r)
|
||||
reply(cmd, ProjectResult { links: out, already_projected: present, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn project_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -543,16 +608,15 @@ pub(super) fn project_entities(s: &mut Session, p: &Value, cmd: &str) -> Result<
|
||||
}
|
||||
|
||||
/// Add one link from `src` to the active sketch and report it.
|
||||
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<Value> {
|
||||
fn one_link(s: &mut Session, cmd: &str, kind: LinkKind, src: LinkSource) -> Result<LinkMade> {
|
||||
let id = sketch_store::active(s).ok_or_else(|| EngineError::Other("no sketch is being edited".into()))?;
|
||||
sketch_store::update(s, id, |s, doc, sk| {
|
||||
refresh(s, doc, id, sk);
|
||||
let what = src.describe();
|
||||
let l = add_link(s, doc, id, sk, kind, src).map_err(|e| bad(cmd, format!("{what}: {e}")))?;
|
||||
let mut out = link_json(sk, &l);
|
||||
let (curves, points) = link_parts(sk, &l);
|
||||
let wires: Vec<String> = sk.link_wires(&l).iter().filter_map(|w| sk.wires.get(*w).map(|w| w.id.clone())).collect();
|
||||
out["wires"] = json!(wires);
|
||||
Ok(out)
|
||||
Ok(LinkMade { link: l, curves, points, wires })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -571,7 +635,7 @@ fn project_to_surface(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
out.push(one_link(s, cmd, LinkKind::Project, LinkSource::OnSurface { sketch, curve, body: body.clone(), at })?);
|
||||
}
|
||||
Ok(json!({"links": out}))
|
||||
reply(cmd, ProjectToSurfaceResult { links: out, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -584,7 +648,8 @@ fn intersection_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let (a, b) = (side("a")?, side("b")?);
|
||||
one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })
|
||||
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Intersection { a: Box::new(a), b: Box::new(b) })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -605,7 +670,8 @@ fn spun_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(wa, wb - wa)
|
||||
}
|
||||
};
|
||||
one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })
|
||||
let made = one_link(s, cmd, LinkKind::Project, LinkSource::Spun { body, origin, dir })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -620,7 +686,8 @@ fn iso_curve(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format!("{},{},{}", d.x, d.y, d.z)
|
||||
}
|
||||
};
|
||||
one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })
|
||||
let made = one_link(s, cmd, LinkKind::Include, LinkSource::Iso { body, at, dir })?;
|
||||
reply(cmd, SketchLinkResult { made, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -636,7 +703,7 @@ fn fit_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sk.break_link(&l)?;
|
||||
Ok(curves)
|
||||
})?;
|
||||
Ok(json!({"curves": curves}))
|
||||
reply(cmd, FitSectionResult { curves, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -670,12 +737,12 @@ fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(links)
|
||||
})?;
|
||||
Ok(json!({"broken": links}))
|
||||
reply(cmd, SketchBreakLinkResult { broken: links, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
fn auto_project(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.auto_project = bool_(p, "value").unwrap_or(!s.auto_project);
|
||||
Ok(json!({"auto_project": s.auto_project}))
|
||||
reply("sketch.auto_project", AutoProjectResult { auto_project: s.auto_project, created: None, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
/// `"x:<a>+<b>:x,y"` from interactive snapping: two distinct curve ids and a hint point.
|
||||
|
||||
@@ -110,8 +110,10 @@ pub mod sketch_constraints;
|
||||
pub mod sketch_create;
|
||||
pub mod sketch_ends;
|
||||
pub mod sketch_equation;
|
||||
pub mod sketch_import;
|
||||
pub mod sketch_inspect;
|
||||
pub mod sketch_modify;
|
||||
pub mod sketch_project;
|
||||
pub mod sketch_shapes;
|
||||
pub mod surface_analysis;
|
||||
pub mod surface_boundary;
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
//! Contracts and result types for the commands in `cmd/sketch_import.rs`: `sketch.insert_dxf`,
|
||||
//! `sketch.insert_svg` and `sketch.export_dxf`.
|
||||
//!
|
||||
//! 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 file can be read or written, a sketch
|
||||
//! exists or a drawing has anything to import 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 handlers in their own order).
|
||||
//!
|
||||
//! Two insert keys are read only in some cases, and the contract reads them in the same cases:
|
||||
//! `path` only when `text` is not given, and of `width`, `height` and `scale` only the first one
|
||||
//! given, in that order (a `height` next to a `width`, or a `scale` next to either, is never read).
|
||||
//! A value of the wrong type that the handler reads as if the key were left out (`text: 5` next
|
||||
//! to a good `path`, a text for `scale`) is refused by the contract, as schemas.md has it: that
|
||||
//! difference is what warn and strict report.
|
||||
//! `plane` and `name` are read only when no sketch is being edited, which the check can't see
|
||||
//! (it has no session), so they take any value.
|
||||
|
||||
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;
|
||||
use solidcraft_io::MAX_DRAWING_BYTES;
|
||||
|
||||
use super::check::{located, whole};
|
||||
use super::sketch_ends::SketchTarget;
|
||||
use super::types::{Point2, not_blank_pattern};
|
||||
|
||||
/// A key that, once given (`null` included), must parse as `T`, as the handler reads it.
|
||||
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
|
||||
T::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// The keys the two insert commands read. See [`InsertDxfParams`] and [`InsertSvgParams`], which
|
||||
/// leave `path`, `height` and `scale` unread in the cases the handler does.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = source_required)]
|
||||
pub struct InsertDrawingKeys {
|
||||
/// The drawing itself, as text. Wins over `path`. At most 16 MiB (`MAX_DRAWING_BYTES`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<DrawingText>,
|
||||
/// The drawing file to read (not blank, fewer than 4096 UTF-8 bytes). Used when `text` is
|
||||
/// not given; ignored when it is.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(schema_with = "path_schema")]
|
||||
pub path: Option<DrawingPath>,
|
||||
/// An offset, in mm, [x, y]. Default: [0, 0]. With `align` "origin" it is where the file's
|
||||
/// origin goes; with "center", where the drawing's middle goes.
|
||||
#[serde(default)]
|
||||
pub at: Option<Point2>,
|
||||
/// A size factor, 1e-9 to 1e6 (a value outside is read as 1). Default: 1. Used only when
|
||||
/// neither `width` nor `height` is given.
|
||||
#[serde(default)]
|
||||
pub scale: Option<f64>,
|
||||
/// The drawing's width in mm after sizing, before rotation; the aspect ratio is kept. Wins
|
||||
/// over `height` and `scale`. The drawing must have a width.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PositiveMm")]
|
||||
pub width: Option<PositiveMm>,
|
||||
/// The drawing's height in mm after sizing, before rotation; the aspect ratio is kept. Wins
|
||||
/// over `scale`; ignored when `width` is given. The drawing must have a height.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "PositiveMm")]
|
||||
pub height: Option<PositiveMm>,
|
||||
/// A turn in degrees, about the drawing's origin (with `align` "center": about its middle).
|
||||
/// Default: 0.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "AngleDegrees")]
|
||||
pub angle: Option<AngleDegrees>,
|
||||
/// What `at` places: the file's origin ("origin") or the drawing's middle ("center", which it
|
||||
/// then also turns and scales about). Default: origin.
|
||||
#[serde(default)]
|
||||
pub align: Option<DrawingAlign>,
|
||||
/// The distance in mm within which ends join into one point, 1e-9 to 10 (a value outside is
|
||||
/// brought inside; one that is not above 0 reads as the default). Default: 0.0001.
|
||||
#[serde(default)]
|
||||
pub tolerance: Option<f64>,
|
||||
/// Read only when no sketch is being edited: the plane of the new sketch, as `sketch.create`
|
||||
/// takes it. Default: XY. Ignored (and not checked) while a sketch is being edited.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plane: Option<Value>,
|
||||
/// Read only when no sketch is being edited: the new sketch's name. Ignored (and not checked)
|
||||
/// while a sketch is being edited.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<Value>,
|
||||
/// The sketch to insert into: its feature id or its name. Default: the one being edited (a
|
||||
/// new one when none is).
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
}
|
||||
|
||||
/// Leaves out the keys the handler never reads for this call: `path` next to a `text` that is
|
||||
/// text, `height` next to a `width`, and `scale` next to either.
|
||||
fn leave_unread(value: &mut Value) {
|
||||
let Some(keys) = value.as_object_mut() else { return };
|
||||
if keys.get("text").is_some_and(Value::is_string) {
|
||||
keys.remove("path");
|
||||
}
|
||||
if keys.contains_key("width") {
|
||||
keys.remove("height");
|
||||
}
|
||||
if keys.contains_key("width") || keys.contains_key("height") {
|
||||
keys.remove("scale");
|
||||
}
|
||||
}
|
||||
|
||||
fn read_keys<'de, D: Deserializer<'de>>(d: D) -> Result<InsertDrawingKeys, D::Error> {
|
||||
let mut value = Value::deserialize(d)?;
|
||||
leave_unread(&mut value);
|
||||
serde_path_to_error::deserialize(&value).map_err(|e| D::Error::custom(located(&e)))
|
||||
}
|
||||
|
||||
/// Insert an ASCII DXF drawing into a sketch as curves: the active sketch, or a new one when none
|
||||
/// is being edited. Give the drawing as `path` or as `text`. Blocks are expanded. It can be
|
||||
/// sized, turned and placed. Text in the drawing becomes sketch text. One undo step.
|
||||
#[derive(Debug)]
|
||||
pub struct InsertDxfParams(pub InsertDrawingKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for InsertDxfParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
read_keys(d).map(InsertDxfParams)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for InsertDxfParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"InsertDxfParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = InsertDrawingKeys::json_schema(generator);
|
||||
schema.insert("description".into(), INSERT_DXF.into());
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
const INSERT_DXF: &str = "Insert an ASCII DXF drawing into a sketch as curves: the active sketch, or a new one when none is being edited. Give the drawing as `path` or as `text`. Blocks are expanded. It can be sized, turned and placed. Text in the drawing becomes sketch text. One undo step.";
|
||||
|
||||
/// Insert an SVG drawing into a sketch as curves: the active sketch, or a new one when none is
|
||||
/// being edited. Give the drawing as `path` or as `text`. It can be sized, turned and placed.
|
||||
/// Text in the drawing becomes sketch text. One undo step.
|
||||
#[derive(Debug)]
|
||||
pub struct InsertSvgParams(pub InsertDrawingKeys);
|
||||
|
||||
impl<'de> Deserialize<'de> for InsertSvgParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
read_keys(d).map(InsertSvgParams)
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for InsertSvgParams {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"InsertSvgParams".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let mut schema = InsertDrawingKeys::json_schema(generator);
|
||||
schema.insert("description".into(), INSERT_SVG.into());
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
const INSERT_SVG: &str = "Insert an SVG drawing into a sketch as curves: the active sketch, or a new one when none is being edited. Give the drawing as `path` or as `text`. It can be sized, turned and placed. Text in the drawing becomes sketch text. One undo step.";
|
||||
|
||||
/// The insert commands need `path` or `text`; the handler says so when both are left out. A
|
||||
/// `path` it reads (no `text`) is not blank and under 4096 bytes; `maxLength` counts
|
||||
/// characters, so 4095 is as close as the schema gets. `height` is read only without a `width`
|
||||
/// and `scale` only without either, so their shapes apply only then, as [`leave_unread`] has it.
|
||||
fn source_required(schema: &mut Schema) {
|
||||
let read_path = serde_json::json!({"type": "string", "maxLength": 4095, "pattern": not_blank_pattern()});
|
||||
schema.insert("anyOf".into(), serde_json::json!([{"required": ["text"]}, {"required": ["path"], "properties": {"path": read_path}}]));
|
||||
let Some(props) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return };
|
||||
let mut read_only_without = |key: &str| {
|
||||
let shape = props.get_mut(key).map(Value::take).unwrap_or_default();
|
||||
props.insert(key.into(), serde_json::json!({"description": shape.get("description").cloned().unwrap_or_default()}));
|
||||
shape
|
||||
};
|
||||
let height = read_only_without("height");
|
||||
let scale = read_only_without("scale");
|
||||
schema.insert(
|
||||
"allOf".into(),
|
||||
serde_json::json!([
|
||||
{"anyOf": [{"required": ["width"]}, {"properties": {"height": height}}]},
|
||||
{"anyOf": [{"required": ["width"]}, {"required": ["height"]}, {"properties": {"scale": scale}}]},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// `path` is checked by [`source_required`] only when it is read (no `text`).
|
||||
fn path_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({})
|
||||
}
|
||||
|
||||
/// A drawing's text, as `source_text` reads it: at most 16 MiB, or the handler's refusal.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DrawingText(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for DrawingText {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let text = String::deserialize(d)?;
|
||||
if text.len() > MAX_DRAWING_BYTES {
|
||||
return Err(D::Error::custom(whole("the drawing is too large")));
|
||||
}
|
||||
Ok(DrawingText(text))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for DrawingText {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DrawingText".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing file name, as `source_text` reads `path`: not blank and under 4096 bytes, or the
|
||||
/// handler's "give `path` or `text`" (anything but text reads as no `path`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DrawingPath(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for DrawingPath {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(s) if !s.trim().is_empty() && s.len() < 4096 => Ok(DrawingPath(s)),
|
||||
_ => Err(D::Error::custom(whole("give `path` or `text`"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A size in mm above 0, as the insert commands' `width` and `height` are read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PositiveMm(pub f64);
|
||||
|
||||
/// The message the insert commands give for `width` or `height`: the check puts the key first.
|
||||
const POSITIVE_MM: &str = "must be a positive number (mm)";
|
||||
|
||||
impl<'de> Deserialize<'de> for PositiveMm {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?.as_f64().filter(|w| w.is_finite() && *w > 0.0).map(PositiveMm).ok_or_else(|| D::Error::custom(POSITIVE_MM))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for PositiveMm {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"PositiveMm".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A size in mm, above 0.", "type": "number", "exclusiveMinimum": 0})
|
||||
}
|
||||
}
|
||||
|
||||
/// An angle in degrees as `angle` is read: a number, whatever its size.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AngleDegrees(pub f64);
|
||||
|
||||
impl<'de> Deserialize<'de> for AngleDegrees {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
Value::deserialize(d)?.as_f64().filter(|a| a.is_finite()).map(AngleDegrees).ok_or_else(|| D::Error::custom("must be a number (degrees)"))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for AngleDegrees {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"AngleDegrees".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "An angle in degrees.", "type": "number"})
|
||||
}
|
||||
}
|
||||
|
||||
/// What an insert's `at` places, as `align` is read (the words are case-sensitive).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum DrawingAlign {
|
||||
/// `at` is where the file's origin goes.
|
||||
Origin,
|
||||
/// `at` is where the drawing's middle goes, and it turns and scales about it.
|
||||
Center,
|
||||
}
|
||||
|
||||
impl JsonSchema for DrawingAlign {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"DrawingAlign".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "What `at` places: \"origin\" (the file's origin goes there) or \"center\" (the drawing's middle goes there, and it turns and scales about it). Lower case only.",
|
||||
"type": "string",
|
||||
"enum": ["origin", "center"]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DrawingAlign {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)?.as_str() {
|
||||
Some("origin") => Ok(DrawingAlign::Origin),
|
||||
Some("center") => Ok(DrawingAlign::Center),
|
||||
_ => Err(D::Error::custom("must be \"origin\" or \"center\"")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `sketch.insert_dxf` and `sketch.insert_svg` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct DrawingInserted {
|
||||
/// The sketch the drawing went into, by feature id.
|
||||
pub sketch: u64,
|
||||
/// The new sketch's id when no sketch was being edited and one was made; else null.
|
||||
pub created_sketch: Option<u64>,
|
||||
/// How many curves were added.
|
||||
pub curves: usize,
|
||||
/// How many sketch texts were added.
|
||||
pub texts: usize,
|
||||
/// The sketch's solve afterwards; null when it was not solved.
|
||||
pub info: Option<SketchSolve>,
|
||||
/// 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 sketch's solve, as the sketch commands report it.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchSolve {
|
||||
/// Whether the solve kept the sketch's orientation.
|
||||
pub solved: bool,
|
||||
/// The degrees of freedom left (0 = fully constrained).
|
||||
pub dof: usize,
|
||||
}
|
||||
|
||||
/// Save a sketch's curves as an ASCII DXF: to the file `path`, or, without it, as text in the
|
||||
/// reply. Construction curves are left out; splines and other curves are written as line
|
||||
/// segments.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExportDxfParams {
|
||||
/// The sketch: its feature id or its name. Default: the one being edited.
|
||||
#[serde(default)]
|
||||
pub sketch: Option<SketchTarget>,
|
||||
/// The file to write. Default (also when blank): none, and the DXF text is returned.
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
/// The drawing's units (any case): `design` (the design's length unit), `mm`, `in` or
|
||||
/// `inch`, `cm`, `m` or `ft`. Default: mm.
|
||||
#[serde(default, deserialize_with = "dxf_units")]
|
||||
#[schemars(schema_with = "dxf_units_schema")]
|
||||
pub units: Option<String>,
|
||||
/// The layer of the profiles (blank: the default). Default: `0`.
|
||||
#[serde(default, deserialize_with = "dxf_layer")]
|
||||
#[schemars(schema_with = "dxf_layer_schema")]
|
||||
pub layer: Option<String>,
|
||||
/// `single` (any case, the default): everything on `layer`; `roles`: outer loops on
|
||||
/// `OUTLINE` and holes on `CUT`.
|
||||
#[serde(default, deserialize_with = "dxf_layers")]
|
||||
#[schemars(schema_with = "dxf_layers_schema")]
|
||||
pub layers: Option<String>,
|
||||
/// `true` writes closed loops as LWPOLYLINE with arc bulges. Default: false (LINE and ARC).
|
||||
#[serde(default, deserialize_with = "dxf_join")]
|
||||
pub join: Option<bool>,
|
||||
}
|
||||
|
||||
/// The units `sketch.export_dxf` takes, in lower case (the handler reads them in any case).
|
||||
const DXF_UNITS: [&str; 7] = ["design", "mm", "in", "inch", "cm", "m", "ft"];
|
||||
|
||||
fn dxf_units<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Option::<Value>::deserialize(d)? {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(units)) => {
|
||||
let lower = units.to_ascii_lowercase();
|
||||
if DXF_UNITS.contains(&lower.as_str()) {
|
||||
Ok(Some(lower))
|
||||
} else {
|
||||
Err(D::Error::custom(whole(format!("unsupported unit `{lower}` (use \"design\", \"mm\", \"in\", \"cm\", \"m\", or \"ft\")"))))
|
||||
}
|
||||
}
|
||||
Some(_) => Err(D::Error::custom(whole("`units` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn dxf_units_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["string", "null"], "description": "design, mm, in, inch, cm, m or ft, in any case. Default: mm."})
|
||||
}
|
||||
|
||||
fn dxf_layer<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Option::<Value>::deserialize(d)? {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(layer)) => Ok(Some(layer)),
|
||||
Some(_) => Err(D::Error::custom(whole("`layer` must be a string"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn dxf_layer_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["string", "null"], "description": "The profiles' layer (blank: the default). Default: 0."})
|
||||
}
|
||||
|
||||
fn dxf_layers<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
|
||||
match Option::<Value>::deserialize(d)? {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(layers)) if ["single", "roles"].contains(&layers.to_ascii_lowercase().as_str()) => Ok(Some(layers.to_ascii_lowercase())),
|
||||
Some(_) => Err(D::Error::custom(whole("`layers` must be \"single\" or \"roles\""))),
|
||||
}
|
||||
}
|
||||
|
||||
fn dxf_layers_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"type": ["string", "null"], "description": "single or roles, in any case. Default: single."})
|
||||
}
|
||||
|
||||
fn dxf_join<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
|
||||
match Option::<Value>::deserialize(d)? {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Bool(join)) => Ok(Some(join)),
|
||||
Some(_) => Err(D::Error::custom(whole("`join` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// What `sketch.export_dxf` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExportDxfResult {
|
||||
#[serde(flatten)]
|
||||
pub exported: DxfExported,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// Where the DXF went.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[serde(untagged)]
|
||||
pub enum DxfExported {
|
||||
/// With a `path`: the file was written.
|
||||
Written {
|
||||
/// The file written, as given.
|
||||
path: String,
|
||||
/// How many entities it holds.
|
||||
entities: usize,
|
||||
},
|
||||
/// Without one: the DXF as text.
|
||||
Text {
|
||||
/// The DXF file's text.
|
||||
dxf: String,
|
||||
/// How many entities it holds.
|
||||
entities: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "sketch_import_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,313 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_io::MAX_DRAWING_BYTES;
|
||||
|
||||
use super::{DrawingInserted, ExportDxfParams, ExportDxfResult, InsertDxfParams, InsertSvgParams};
|
||||
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 30 x 20 rectangle as DXF lines.
|
||||
const DXF: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n30\n21\n0\n0\nLINE\n10\n30\n20\n0\n11\n30\n21\n20\n0\nLINE\n10\n30\n20\n20\n11\n0\n21\n20\n0\nLINE\n10\n0\n20\n20\n11\n0\n21\n0\n0\nENDSEC\n0\nEOF\n";
|
||||
|
||||
const SVG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="20mm" height="10mm" viewBox="0 0 20 10"><rect x="0" y="0" width="20" height="10"/><text x="2" y="5" font-size="3">Hi</text></svg>"#;
|
||||
|
||||
fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-12508-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
let insert = ["align", "angle", "at", "height", "name", "path", "plane", "scale", "sketch", "text", "tolerance", "width"];
|
||||
assert_keys(input_schema_for::<InsertDxfParams>(), &insert);
|
||||
assert_keys(input_schema_for::<InsertSvgParams>(), &insert);
|
||||
assert_keys(input_schema_for::<ExportDxfParams>(), &["join", "layer", "layers", "path", "sketch", "units"]);
|
||||
}
|
||||
|
||||
/// Strict gives the handlers' messages for the calls they refuse for a key (a missing `path` and
|
||||
/// `text` included: the handler's words, not "is required"). The session has a sketch being edited.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
let too_big = "x".repeat(MAX_DRAWING_BYTES + 1);
|
||||
let mut cases: Vec<(&str, Value)> = vec![
|
||||
("sketch.insert_dxf", json!({})),
|
||||
("sketch.insert_dxf", json!({"path": ""})),
|
||||
("sketch.insert_dxf", json!({"path": " "})),
|
||||
("sketch.insert_dxf", json!({"path": 5})),
|
||||
("sketch.insert_dxf", json!({"path": null})),
|
||||
("sketch.insert_dxf", json!({"path": "x".repeat(4096)})),
|
||||
("sketch.insert_dxf", json!({"text": too_big})),
|
||||
("sketch.insert_svg", json!({})),
|
||||
("sketch.insert_svg", json!({"path": " "})),
|
||||
("sketch.insert_svg", json!({"text": null})),
|
||||
("sketch.export_dxf", json!({"sketch": true})),
|
||||
("sketch.export_dxf", json!({"sketch": [1]})),
|
||||
("sketch.export_dxf", json!({"sketch": -1})),
|
||||
("sketch.export_dxf", json!({"sketch": 1.5})),
|
||||
];
|
||||
for (id, text) in [("sketch.insert_dxf", DXF), ("sketch.insert_svg", SVG)] {
|
||||
for extra in [
|
||||
json!({"width": 0}),
|
||||
json!({"width": -2}),
|
||||
json!({"width": null}),
|
||||
json!({"width": "wide"}),
|
||||
json!({"width": [1]}),
|
||||
json!({"height": 0}),
|
||||
json!({"height": null}),
|
||||
json!({"height": true}),
|
||||
json!({"angle": "x"}),
|
||||
json!({"angle": null}),
|
||||
json!({"angle": {}}),
|
||||
json!({"align": "left"}),
|
||||
json!({"align": "Center"}),
|
||||
json!({"align": ""}),
|
||||
json!({"sketch": true}),
|
||||
json!({"sketch": null, "width": 0}),
|
||||
json!({"sketch": [1]}),
|
||||
json!({"sketch": 1.5}),
|
||||
] {
|
||||
let mut p = json!({"text": text});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
cases.push((id, p));
|
||||
}
|
||||
}
|
||||
assert_strict_keeps_todays_errors(&cases);
|
||||
}
|
||||
|
||||
/// The same refusals for a sketch that is named but doesn't exist, a width the drawing can't
|
||||
/// give (a drawing with no width to size is refused by the handler alone), and the export's
|
||||
/// options, which the handler reads only once a sketch is being edited.
|
||||
#[test]
|
||||
fn strict_keeps_the_handlers_own_refusals() {
|
||||
// A vertical line has no width to size.
|
||||
let vertical = "0\nSECTION\n2\nENTITIES\n0\nLINE\n10\n0\n20\n0\n11\n0\n21\n9\n0\nENDSEC\n0\nEOF\n";
|
||||
for (id, p) in [
|
||||
("sketch.insert_dxf", json!({"text": DXF, "sketch": "Nope"})),
|
||||
("sketch.insert_dxf", json!({"text": DXF, "sketch": 9999})),
|
||||
("sketch.insert_dxf", json!({"text": vertical, "width": 10})),
|
||||
("sketch.insert_dxf", json!({"text": "0\nEOF\n"})),
|
||||
("sketch.export_dxf", json!({"sketch": "Nope"})),
|
||||
("sketch.export_dxf", json!({"sketch": 9999})),
|
||||
("sketch.export_dxf", json!({"units": 5})),
|
||||
("sketch.export_dxf", json!({"units": "Yard"})),
|
||||
("sketch.export_dxf", json!({"layer": 1})),
|
||||
("sketch.export_dxf", json!({"layers": "all"})),
|
||||
("sketch.export_dxf", json!({"layers": true})),
|
||||
("sketch.export_dxf", json!({"join": "yes"})),
|
||||
] {
|
||||
let error = |level: ParamCheck| {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||||
s.execute(id, &p).err().map(|e| e.to_string())
|
||||
};
|
||||
let off = error(ParamCheck::Off);
|
||||
assert!(off.is_some(), "{id} {p} should fail today");
|
||||
assert_eq!(error(ParamCheck::Strict), off, "{id} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The types read each key as the handler does: keys it never reads in a call are not read by the
|
||||
/// contract either, and the ones it reads fail with its words.
|
||||
#[test]
|
||||
fn the_types_read_like_the_handler() {
|
||||
let reads = |p: Value| serde_json::from_value::<InsertDxfParams>(p).is_ok();
|
||||
let issues = |p: Value| crate::schema::issues("sketch.insert_svg", &p).into_iter().map(|i| i.message).collect::<Vec<_>>();
|
||||
// `path` is not read when `text` is given; `height` not when `width` is; `scale` not when
|
||||
// either is.
|
||||
assert!(reads(json!({"text": "x", "path": 5})) && reads(json!({"text": "x", "path": ""})));
|
||||
assert!(reads(json!({"width": 5, "height": "x", "scale": "y"})) && reads(json!({"width": 5, "height": null})));
|
||||
assert!(reads(json!({"height": 5, "scale": "y"})) && reads(json!({"height": 5, "scale": null})));
|
||||
assert!(reads(json!({"path": "a.dxf", "scale": 2, "tolerance": 0.01, "at": [1, 2], "angle": 90, "align": "center"})));
|
||||
assert!(reads(json!({"text": "x", "at": {"x": 1, "y": 2}, "angle": 720.5, "width": 1e12, "scale": 0, "tolerance": -1})));
|
||||
// They are read when the handler reads them.
|
||||
// Stricter than the handler, which reads a `text` that isn't text as left out.
|
||||
assert!(!reads(json!({"text": 5, "path": "a.dxf"})) && !reads(json!({"path": "a.dxf", "scale": "y"})));
|
||||
assert!(!reads(json!({"path": ""})) && !reads(json!({"path": 5, "scale": 1})));
|
||||
assert!(!reads(json!({"text": "x", "height": 0})) && !reads(json!({"text": "x", "height": "x", "scale": 2})));
|
||||
assert!(!reads(json!({"text": "x", "width": null, "height": 5})) && !reads(json!({"text": "x", "scale": "y", "width": 0})));
|
||||
// Only while no sketch is being edited does `plane` or `name` matter, which the check can't
|
||||
// see: they take anything.
|
||||
assert!(reads(json!({"text": "x", "plane": 5, "name": [1]})) && reads(json!({"text": "x", "plane": null, "name": null})));
|
||||
assert_eq!(issues(json!({"text": "x", "width": 0})), ["`width` must be a positive number (mm)"]);
|
||||
assert_eq!(issues(json!({"text": "x", "height": -1})), ["`height` must be a positive number (mm)"]);
|
||||
assert_eq!(issues(json!({"text": "x", "angle": "1"})), ["`angle` must be a number (degrees)"]);
|
||||
assert_eq!(issues(json!({"text": "x", "align": "left"})), ["`align` must be \"origin\" or \"center\""]);
|
||||
assert_eq!(issues(json!({"path": " "})), ["give `path` or `text`"]);
|
||||
assert_eq!(issues(json!({"text": "x", "sketch": true})), ["`sketch` must be an id or a name"]);
|
||||
assert_eq!(issues(json!({"text": "x", "wdth": 5})), ["unknown key `wdth`; did you mean `width`?"]);
|
||||
assert!(issues(json!({"text": "x", "width": 5, "height": "x", "path": 5})).is_empty());
|
||||
let export = |p: Value| serde_json::from_value::<ExportDxfParams>(p).is_ok();
|
||||
assert!(export(json!({})) && export(json!({"sketch": null, "path": null})) && export(json!({"sketch": "S", "path": ""})));
|
||||
assert!(export(json!({"sketch": 3, "path": "a.dxf"})) && !export(json!({"sketch": true})) && !export(json!({"path": 5})));
|
||||
}
|
||||
|
||||
/// What the schemas take: a drawing from `path` or `text`, and the keys' own shapes.
|
||||
#[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}");
|
||||
}
|
||||
};
|
||||
for schema in [input_schema_for::<InsertDxfParams>(), input_schema_for::<InsertSvgParams>()] {
|
||||
takes(
|
||||
schema,
|
||||
&[
|
||||
(json!({"text": "x"}), true),
|
||||
(json!({"path": "a.svg", "at": [1, 2], "scale": 2, "angle": 45, "align": "center", "tolerance": 0.01}), true),
|
||||
(json!({"text": "x", "width": 40, "plane": "XZ", "name": "Logo", "sketch": "Sketch1"}), true),
|
||||
(json!({"text": "x", "plane": {"origin": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]}, "sketch": 4}), true),
|
||||
(json!({}), false),
|
||||
(json!({"width": 40}), false),
|
||||
(json!({"text": "x", "width": 0}), false),
|
||||
(json!({"text": "x", "height": -1}), false),
|
||||
(json!({"text": "x", "angle": "x"}), false),
|
||||
(json!({"text": "x", "align": "left"}), false),
|
||||
(json!({"text": "x", "at": [1]}), false),
|
||||
(json!({"text": "x", "wdth": 5}), false),
|
||||
(json!({"path": "x".repeat(4096)}), false),
|
||||
(json!({"text": "x", "path": " "}), true),
|
||||
// Unread keys take anything, as in the handler; read ones keep their shapes.
|
||||
(json!({"text": "x", "path": 5}), true),
|
||||
(json!({"text": "x", "width": 5, "height": -1}), true),
|
||||
(json!({"text": "x", "width": 5, "scale": "y"}), true),
|
||||
(json!({"text": "x", "height": 5, "scale": "y"}), true),
|
||||
(json!({"path": 5}), false),
|
||||
(json!({"text": "x", "height": "x", "scale": 2}), false),
|
||||
(json!({"text": "x", "scale": "y"}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
// A `path` read without `text` is not blank (`conforms` doesn't check patterns).
|
||||
for schema in [input_schema_for::<InsertDxfParams>(), input_schema_for::<InsertSvgParams>()] {
|
||||
let read_path = &schema.as_value()["anyOf"][1]["properties"]["path"];
|
||||
assert_eq!(read_path["pattern"], json!(crate::schema::types::not_blank_pattern()));
|
||||
}
|
||||
takes(
|
||||
input_schema_for::<ExportDxfParams>(),
|
||||
&[
|
||||
(json!({}), true),
|
||||
(json!({"sketch": "S", "path": "a.dxf"}), true),
|
||||
(json!({"sketch": 2}), true),
|
||||
(json!({"sketch": true}), false),
|
||||
(json!({"file": "a.dxf"}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn matches(schema: schemars::Schema, r: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
}
|
||||
|
||||
/// Every form of the three commands replies as its output schema says, in Warn without warnings;
|
||||
/// the replies carry the keys they always had; a misspelt key gets its warning and the reply still
|
||||
/// matches, and strict refuses it.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
let dir = temp_dir("replies");
|
||||
let svg_file = dir.join("plate.svg");
|
||||
let dxf_file = dir.join("plate.dxf");
|
||||
std::fs::write(&svg_file, SVG).unwrap();
|
||||
std::fs::write(&dxf_file, DXF).unwrap();
|
||||
let (svg_path, dxf_path) = (svg_file.to_string_lossy().into_owned(), dxf_file.to_string_lossy().into_owned());
|
||||
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||
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
|
||||
};
|
||||
// The session adds `created` (the ids a sketch command made) to every `sketch.*` reply, outside
|
||||
// the handlers; it is not part of what they build.
|
||||
let keys = |r: &Value| {
|
||||
r.as_object().map(|o| o.keys().filter(|k| *k != "created").cloned().collect::<std::collections::BTreeSet<_>>()).unwrap_or_default()
|
||||
};
|
||||
let insert_keys: std::collections::BTreeSet<String> = ["created_sketch", "curves", "info", "sketch", "texts"].map(String::from).into();
|
||||
|
||||
// With no sketch being edited: a new one is made, on the plane and with the name given.
|
||||
let r = run(&mut s, "sketch.insert_dxf", json!({"text": DXF, "plane": "XZ", "name": "Plate", "at": [5, 5]}));
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
assert_eq!(keys(&r), insert_keys, "{r}");
|
||||
assert_eq!((r["curves"].as_u64(), r["texts"].as_u64()), (Some(4), Some(0)), "{r}");
|
||||
assert_eq!(r["created_sketch"], r["sketch"], "{r}");
|
||||
// Exactly what the handler returned before it built its reply from the type.
|
||||
let mut handler_reply = r.clone();
|
||||
handler_reply.as_object_mut().unwrap().remove("created");
|
||||
assert_eq!(
|
||||
handler_reply,
|
||||
json!({"sketch": r["sketch"], "created_sketch": r["sketch"], "curves": 4, "texts": 0, "info": {"solved": true, "dof": 8}})
|
||||
);
|
||||
assert!(r["info"]["solved"].is_boolean() && r["info"]["dof"].is_u64(), "{r}");
|
||||
// With one: into it, and `plane` and `name` (of any kind) are not read.
|
||||
let id = s.active_sketch.unwrap();
|
||||
for p in [
|
||||
json!({"text": DXF, "path": 5, "plane": 7, "name": [1]}),
|
||||
json!({"text": DXF, "width": 60, "height": "x", "scale": "y", "angle": 90, "align": "center", "at": {"x": 1, "y": 2}}),
|
||||
json!({"text": DXF, "height": 10, "scale": "y", "tolerance": 0.01}),
|
||||
json!({"text": DXF, "scale": 2, "align": "origin", "sketch": id}),
|
||||
json!({"text": DXF, "sketch": s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default()}),
|
||||
json!({"path": dxf_path, "text": null, "sketch": null}),
|
||||
] {
|
||||
let r = run(&mut s, "sketch.insert_dxf", p);
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
assert_eq!(keys(&r), insert_keys);
|
||||
assert_eq!((r["sketch"].as_u64(), r["created_sketch"].clone(), r["curves"].as_u64()), (Some(id), Value::Null, Some(4)), "{r}");
|
||||
}
|
||||
let r = run(&mut s, "sketch.insert_svg", json!({"text": SVG, "angle": 15}));
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
assert_eq!((r["curves"].as_u64(), r["texts"].as_u64()), (Some(4), Some(1)), "{r}");
|
||||
let r = run(&mut s, "sketch.insert_svg", json!({"path": svg_path, "width": 40, "align": "center", "at": [10, 10]}));
|
||||
assert_eq!(r["texts"], 1, "{r}");
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
let r = run(&mut s, "sketch.insert_svg", json!({"path": svg_path, "plane": "YZ"}));
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
assert_ne!(r["created_sketch"], Value::Null, "{r}");
|
||||
s.execute("sketch.finish", &json!({})).unwrap();
|
||||
|
||||
// Export: the text, or a file.
|
||||
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": id}));
|
||||
matches(output_schema_for::<ExportDxfResult>(), &r);
|
||||
assert_eq!(keys(&r), ["dxf", "entities"].map(String::from).into(), "{r}");
|
||||
assert!(r["dxf"].as_str().is_some_and(|t| t.contains("ENTITIES")), "{r}");
|
||||
assert!(r["entities"].as_u64().is_some_and(|n| n >= 4), "{r}");
|
||||
let out = dir.join("out.dxf");
|
||||
let out_path = out.to_string_lossy().into_owned();
|
||||
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": id, "path": out_path}));
|
||||
matches(output_schema_for::<ExportDxfResult>(), &r);
|
||||
assert_eq!(r["path"], json!(out_path), "{r}");
|
||||
assert_eq!(keys(&r), ["entities", "path"].map(String::from).into(), "{r}");
|
||||
assert!(std::fs::read_to_string(&out).unwrap().contains("ENTITIES"));
|
||||
// A blank `path` and a null `sketch` read as left out.
|
||||
s.execute("sketch.edit", &json!({"sketch": id})).unwrap();
|
||||
let r = run(&mut s, "sketch.export_dxf", json!({"sketch": null, "path": " "}));
|
||||
assert!(r.get("dxf").is_some(), "{r}");
|
||||
matches(output_schema_for::<ExportDxfResult>(), &r);
|
||||
// The units and layer options, in any case; null and a blank layer read as left out.
|
||||
for p in [
|
||||
json!({"units": "IN", "layer": "PART"}),
|
||||
json!({"units": "design", "layers": "Roles"}),
|
||||
json!({"join": true, "layer": " "}),
|
||||
json!({"units": null, "layer": null, "layers": null, "join": null}),
|
||||
] {
|
||||
let r = run(&mut s, "sketch.export_dxf", p);
|
||||
matches(output_schema_for::<ExportDxfResult>(), &r);
|
||||
assert_eq!(keys(&r), ["dxf", "entities"].map(String::from).into(), "{r}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// A misspelt key runs in warn and says so; the reply still matches. Strict refuses it.
|
||||
let p = json!({"text": DXF, "wdth": 5});
|
||||
let r = s.execute("sketch.insert_dxf", &p).unwrap();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| w.iter().any(|m| m.as_str().is_some_and(|m| m.contains("`wdth`")))), "{r}");
|
||||
matches(output_schema_for::<DrawingInserted>(), &r);
|
||||
let r = s.execute("sketch.export_dxf", &json!({"pth": "a.dxf"})).unwrap();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||||
matches(output_schema_for::<ExportDxfResult>(), &r);
|
||||
let r = s.execute("sketch.insert_svg", &json!({"text": SVG, "angel": 5})).unwrap();
|
||||
assert!(r["param_warnings"].as_array().is_some_and(|w| !w.is_empty()), "{r}");
|
||||
s.param_check = ParamCheck::Strict;
|
||||
let error = s.execute("sketch.insert_dxf", &p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(error.contains("`wdth`") && error.contains("did you mean `width`"), "{error}");
|
||||
}
|
||||
@@ -0,0 +1,767 @@
|
||||
//! Contracts and result types for the commands in `cmd/sketch_project.rs`: `sketch.project`,
|
||||
//! `sketch.intersect`, `sketch.include_3d`, `sketch.project_to_surface`,
|
||||
//! `sketch.intersection_curve`, `sketch.spun_profile`, `sketch.isoparametric_curve`,
|
||||
//! `sketch.fit_curves_to_section`, `sketch.break_link` and `sketch.auto_project`.
|
||||
//!
|
||||
//! 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. What only the design can tell (a body, sketch or
|
||||
//! curve that exists, an axis name, whether a reference projects to anything) stays the handler's
|
||||
//! to check: a reference given as text, or as an axis name, passes here and is looked up there, and
|
||||
//! so does a reference to a sketch, whose `curve` or `point` the handler checks once it has found
|
||||
//! the sketch.
|
||||
//! 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 handlers in their 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::{Map, Value, json};
|
||||
|
||||
use super::check::whole;
|
||||
use super::motion::require;
|
||||
use super::types::Point3;
|
||||
use crate::cmd::{RefForm, ref_form};
|
||||
use crate::params;
|
||||
|
||||
/// What a reference to model geometry may look like, for the descriptions.
|
||||
const REF_FORMS: &str = "a reference: `{\"edge\": [x, y, z], \"body\"?}` (the edge through that point), `{\"face\": [x, y, z], \"body\"?}` (the edges of the face), `{\"vertex\": [x, y, z], \"body\"?}`, `{\"body\": name}` (its silhouette or outline), `{\"sketch\": id or name, \"curve\": id}` or `{\"sketch\": id or name, \"point\": id}` (a curve or point of another sketch), `\"origin\"` or `{\"origin\": …}`, `{\"axis\": \"X\"|\"Y\"|\"Z\"|a construction axis's name}`, `{\"plane\": \"XY\"|\"XZ\"|\"YZ\"|a plane's name}`; text alone is `origin`, X, Y, Z (any case) or a body's name";
|
||||
|
||||
/// The most references one `refs` takes into account: the handler reads no more.
|
||||
const MAX_REFS: usize = 1000;
|
||||
|
||||
/// Whether text is what `cmd/sketch_project.rs` reads as the origin or an origin axis, which is
|
||||
/// settled without the design; any other text may be a body's name.
|
||||
fn is_origin_or_axis(name: &str) -> bool {
|
||||
["origin", "x", "y", "z"].iter().any(|word| name.eq_ignore_ascii_case(word))
|
||||
}
|
||||
|
||||
/// The handler's message for a reference that says nothing a design could be asked about, in its
|
||||
/// own words, or `None` when the form is fine. A sketch reference with neither `curve` nor `point`
|
||||
/// is fine here: the handler refuses it only once it has looked the sketch up, and says "no sketch"
|
||||
/// first when there is none.
|
||||
fn ref_problem(v: &Value) -> Option<String> {
|
||||
ref_form(v).err()
|
||||
}
|
||||
|
||||
/// One reference of `sketch.project`, `sketch.intersect` and `sketch.include_3d`, kept as given
|
||||
/// for the handler.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProjectRef(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for ProjectRef {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
match ref_problem(&v) {
|
||||
Some(why) => Err(D::Error::custom(whole(why))),
|
||||
None => Ok(ProjectRef(v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ProjectRef {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ProjectRef".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
let with_body = |key: &str| {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {key: point, "body": {"type": "string", "description": "The body's name. Default: any body. Text only: the handler reads anything else as left out, which the schema does not take."}},
|
||||
"required": [key]
|
||||
})
|
||||
};
|
||||
json_schema!({
|
||||
"description": REF_FORMS,
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
with_body("edge"),
|
||||
with_body("face"),
|
||||
with_body("vertex"),
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"sketch": {"type": ["number", "string"]}, "curve": {"type": "string"}},
|
||||
"required": ["sketch", "curve"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"sketch": {"type": ["number", "string"]}, "point": {"type": "string"}},
|
||||
"required": ["sketch", "point"]
|
||||
},
|
||||
{"type": "object", "properties": {"body": {"type": "string"}}, "required": ["body"]},
|
||||
{"type": "object", "properties": {"axis": {"type": "string"}}, "required": ["axis"]},
|
||||
{"type": "object", "properties": {"plane": {"type": "string"}}, "required": ["plane"]},
|
||||
{"type": "object", "required": ["origin"]}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The references of `refs` (or `ref`): a list, or one reference. Only the first 1000 of a list
|
||||
/// count, as the handler reads no more.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ProjectRefs(pub Vec<Value>);
|
||||
|
||||
impl ProjectRefs {
|
||||
/// The references `v` lists, or the handler's message for them. A value that is not a list is
|
||||
/// one reference.
|
||||
fn read(v: &Value) -> Result<ProjectRefs, String> {
|
||||
let list: Vec<&Value> = match v {
|
||||
Value::Array(a) => a.iter().take(MAX_REFS).collect(),
|
||||
one => vec![one],
|
||||
};
|
||||
if list.is_empty() {
|
||||
return Err("`refs` is empty".into());
|
||||
}
|
||||
if let Some(why) = list.iter().find_map(|r| ref_problem(r)) {
|
||||
return Err(why);
|
||||
}
|
||||
Ok(ProjectRefs(list.into_iter().cloned().collect()))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for ProjectRefs {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"ProjectRefs".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let one = generator.subschema_for::<ProjectRef>();
|
||||
json_schema!({
|
||||
"description": "One reference or a non-empty list of them (the first 1000 count).",
|
||||
"anyOf": [one, {"type": "array", "items": one, "minItems": 1}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `refs`, or `ref` when `refs` is absent (the handler reads `ref` only then, `null` included).
|
||||
fn read_refs(o: &Map<String, Value>) -> Result<(Option<ProjectRefs>, Option<ProjectRefs>), String> {
|
||||
match (o.get("refs"), o.get("ref")) {
|
||||
(Some(v), _) => Ok((Some(ProjectRefs::read(v)?), None)),
|
||||
(None, Some(v)) => Ok((None, Some(ProjectRefs::read(v)?))),
|
||||
(None, None) => Ok((None, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `projection_link`: a boolean, and `null` is refused too (the message is the same for both
|
||||
/// commands that take it).
|
||||
fn read_link_flag(o: &Map<String, Value>) -> Result<Option<bool>, String> {
|
||||
match o.get("projection_link") {
|
||||
None => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err("`projection_link` must be a boolean".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `refs` and `ref` are one of them required: left out, the handler refuses with its own message.
|
||||
fn refs_required(schema: &mut Schema) {
|
||||
schema.insert("anyOf".into(), json!([{"required": ["refs"]}, {"required": ["ref"]}]));
|
||||
}
|
||||
|
||||
/// Bring model edges, faces, bodies, other sketches' curves and points, the origin, an axis or a
|
||||
/// plane into the active sketch as linked reference geometry: fixed, drawn purple and updated
|
||||
/// when the upstream geometry changes. What the sketch already shows (the border of its face, an
|
||||
/// edge projected before) is skipped and listed in `already_projected`; a call that adds nothing is
|
||||
/// refused. One undo step.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = refs_required)]
|
||||
pub struct ProjectParams {
|
||||
/// What to project: one reference or a list.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProjectRefs")]
|
||||
pub refs: Option<ProjectRefs>,
|
||||
/// The same as `refs`, used only when `refs` is not given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(rename = "ref", with = "ProjectRefs")]
|
||||
pub reference: Option<ProjectRefs>,
|
||||
/// Keep the geometry linked to its source (true), or make it normal sketch geometry at once
|
||||
/// (false: the replies' `link` is then null). Default: true.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub projection_link: Option<bool>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ProjectParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let o = Map::<String, Value>::deserialize(d)?;
|
||||
// The handler reads `projection_link` before the references.
|
||||
let projection_link = read_link_flag(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
let (refs, reference) = read_refs(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
Ok(ProjectParams { refs, reference, projection_link })
|
||||
}
|
||||
}
|
||||
|
||||
/// Intersect the sketch plane with model bodies, faces, edges and other sketches' curves, and
|
||||
/// bring the section curves (or points) into the active sketch as linked reference geometry. One
|
||||
/// undo step.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = refs_required)]
|
||||
pub struct IntersectParams {
|
||||
/// What to intersect with the sketch plane: one reference or a list (`{body}`, `{face}`,
|
||||
/// `{edge}` or `{sketch, curve}` are the forms that make sense here).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProjectRefs")]
|
||||
pub refs: Option<ProjectRefs>,
|
||||
/// The same as `refs`, used only when `refs` is not given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(rename = "ref", with = "ProjectRefs")]
|
||||
pub reference: Option<ProjectRefs>,
|
||||
/// Keep the geometry linked to its source (true), or make it normal sketch geometry at once
|
||||
/// (false: the reply's `link` is then null). Default: true.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "bool")]
|
||||
pub projection_link: Option<bool>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for IntersectParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let o = Map::<String, Value>::deserialize(d)?;
|
||||
let projection_link = read_link_flag(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
let (refs, reference) = read_refs(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
Ok(IntersectParams { refs, reference, projection_link })
|
||||
}
|
||||
}
|
||||
|
||||
/// Include model edges, vertices, and other sketches' curves and points in the active sketch as
|
||||
/// linked geometry; curves out of the sketch plane are flattened onto it. Takes the references of
|
||||
/// `sketch.project`, and always keeps the link. One undo step.
|
||||
#[derive(Debug, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = refs_required)]
|
||||
pub struct Include3dParams {
|
||||
/// What to include: one reference or a list.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "ProjectRefs")]
|
||||
pub refs: Option<ProjectRefs>,
|
||||
/// The same as `refs`, used only when `refs` is not given.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(rename = "ref", with = "ProjectRefs")]
|
||||
pub reference: Option<ProjectRefs>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Include3dParams {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let o = Map::<String, Value>::deserialize(d)?;
|
||||
let (refs, reference) = read_refs(&o).map_err(|why| D::Error::custom(whole(why)))?;
|
||||
Ok(Include3dParams { refs, reference })
|
||||
}
|
||||
}
|
||||
|
||||
/// A key that, once given (`null` included), must parse as `T`, as the handlers read it.
|
||||
fn some<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Option<T>, D::Error> {
|
||||
T::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
/// A point `[x, y, z]` or `{x, y, z}` with the handler's own message.
|
||||
macro_rules! point_with_message {
|
||||
($(#[$doc:meta])* $name:ident, $message:literal) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct $name(pub solidcore_record::Vec3);
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
params::vec3(&Value::deserialize(d)?).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 {
|
||||
generator.subschema_for::<Point3>()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
point_with_message!(
|
||||
/// `sketch.project_to_surface`'s `face`, with the handler's message.
|
||||
TargetFacePoint,
|
||||
"`face` must be a point [x,y,z] on the target face"
|
||||
);
|
||||
point_with_message!(
|
||||
/// `sketch.isoparametric_curve`'s `face`, with the handler's message.
|
||||
IsoFacePoint,
|
||||
"`face` must be a point [x,y,z] on the face"
|
||||
);
|
||||
|
||||
/// A curve to project onto a surface: `{sketch, curve}`, read as the handler reads it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SurfaceCurve(pub Value);
|
||||
|
||||
impl SurfaceCurve {
|
||||
fn problem(v: &Value) -> Option<String> {
|
||||
const EACH: &str = "each entry must be {sketch, curve}";
|
||||
match ref_form(v) {
|
||||
Err(why) => Some(why),
|
||||
// What the handler says of a sketch reference comes after it has found the sketch.
|
||||
Ok(RefForm::Sketch { .. }) => None,
|
||||
// Whether text names a body, or an axis a construction axis, only the design knows;
|
||||
// either way it is not a curve.
|
||||
Ok(RefForm::Name(name)) if !is_origin_or_axis(name) => None,
|
||||
Ok(RefForm::Axis(name)) if !is_origin_or_axis(name) => None,
|
||||
Ok(_) => Some(EACH.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `curves` of `sketch.project_to_surface`: 1 to 500 of them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SurfaceCurves(pub Vec<Value>);
|
||||
|
||||
impl<'de> Deserialize<'de> for SurfaceCurves {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let list = match Value::deserialize(d)? {
|
||||
Value::Array(list) => list,
|
||||
_ => return Err(D::Error::custom(whole("`curves` must list {sketch, curve}"))),
|
||||
};
|
||||
if list.is_empty() || list.len() > 500 {
|
||||
return Err(D::Error::custom(whole("`curves` must list 1…500 curves")));
|
||||
}
|
||||
if let Some(why) = list.iter().find_map(SurfaceCurve::problem) {
|
||||
return Err(D::Error::custom(whole(why)));
|
||||
}
|
||||
Ok(SurfaceCurves(list))
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SurfaceCurves {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SurfaceCurves".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "From 1 to 500 curves of other sketches, each {\"sketch\": id or name, \"curve\": curve id}.",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 500,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {"sketch": {"type": ["number", "string"]}, "curve": {"type": "string"}},
|
||||
"required": ["sketch", "curve"]
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Project curves of other sketches along the active sketch's normal onto a face of the model,
|
||||
/// as linked 3D curves. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = surface_required)]
|
||||
pub struct ProjectToSurfaceParams {
|
||||
// Optional in the type and required in the schema (`surface_required`): left out, the handler
|
||||
// refuses it with its own message, not the check's "is required".
|
||||
/// The curves to project: 1 to 500 of `{"sketch": id or name, "curve": id}`, curves of other
|
||||
/// sketches.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SurfaceCurves")]
|
||||
pub curves: Option<SurfaceCurves>,
|
||||
/// A point [x, y, z] in mm on the target face.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "TargetFacePoint")]
|
||||
pub face: Option<TargetFacePoint>,
|
||||
/// The face's body. Default: any body the point is on. Text only; anything else is read as
|
||||
/// left out.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
fn surface_required(schema: &mut Schema) {
|
||||
require(schema, "curves");
|
||||
require(schema, "face");
|
||||
}
|
||||
|
||||
/// One side of `sketch.intersection_curve`: a body, a face, or a curve of another sketch.
|
||||
macro_rules! intersection_side {
|
||||
($(#[$doc:meta])* $name:ident, $key:literal) => {
|
||||
$(#[$doc])*
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct $name(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let wrong = || D::Error::custom(whole(concat!("`", $key, "` must be a body, a face or a curve of another sketch")));
|
||||
match ref_form(&v) {
|
||||
Err(why) => Err(D::Error::custom(whole(why))),
|
||||
// A sketch reference is judged by the handler once it has found the sketch.
|
||||
Ok(RefForm::Body(_) | RefForm::Face { .. } | RefForm::Sketch { .. }) => Ok($name(v)),
|
||||
// Text is a body's name, or the origin or an axis; an axis name is looked up.
|
||||
Ok(RefForm::Name(name)) if !is_origin_or_axis(name) => Ok($name(v)),
|
||||
Ok(RefForm::Axis(name)) if !is_origin_or_axis(name) => Ok($name(v)),
|
||||
Ok(_) => Err(wrong()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for $name {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
stringify!($name).into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "A body `{\"body\": name}` (or its name as text), a face `{\"face\": [x, y, z], \"body\"?}` (the face through that point), or a curve of another sketch `{\"sketch\": id or name, \"curve\": id}`, which stands for its extrusion along that sketch's normal.",
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "object", "properties": {"body": {"type": "string"}}, "required": ["body"]},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"face": point, "body": {"type": "string"}},
|
||||
"required": ["face"]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"sketch": {"type": ["number", "string"]}, "curve": {"type": "string"}},
|
||||
"required": ["sketch", "curve"]
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
intersection_side!(
|
||||
/// `sketch.intersection_curve`'s `a`.
|
||||
IntersectionSideA,
|
||||
"a"
|
||||
);
|
||||
intersection_side!(
|
||||
/// `sketch.intersection_curve`'s `b`.
|
||||
IntersectionSideB,
|
||||
"b"
|
||||
);
|
||||
|
||||
/// Add where two pieces of model geometry meet to the active sketch, as a linked 3D curve. One
|
||||
/// undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = sides_required)]
|
||||
pub struct IntersectionCurveParams {
|
||||
// Optional in the type and required in the schema, as `curves` above.
|
||||
/// The first side: a body, a face or a curve of another sketch.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IntersectionSideA")]
|
||||
pub a: Option<IntersectionSideA>,
|
||||
/// The second side, of the same forms.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IntersectionSideB")]
|
||||
pub b: Option<IntersectionSideB>,
|
||||
}
|
||||
|
||||
fn sides_required(schema: &mut Schema) {
|
||||
require(schema, "a");
|
||||
require(schema, "b");
|
||||
}
|
||||
|
||||
/// A body's name, with the handler's message for text it is not given as.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BodyName(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for BodyName {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(name) => Ok(BodyName(name)),
|
||||
_ => Err(D::Error::custom(whole("`body` must name a body"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for BodyName {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"BodyName".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({"description": "A body's name.", "type": "string"})
|
||||
}
|
||||
}
|
||||
|
||||
/// `sketch.spun_profile`'s `axis`: X, Y or Z (any case), or the id of a line of the active sketch.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SpunAxis(pub String);
|
||||
|
||||
impl<'de> Deserialize<'de> for SpunAxis {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
match Value::deserialize(d)? {
|
||||
Value::String(axis) => Ok(SpunAxis(axis)),
|
||||
_ => Err(D::Error::custom(whole("`axis` must be a sketch line or X|Y|Z"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for SpunAxis {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"SpunAxis".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The axis to revolve about: X, Y or Z (any case: the origin's axis), or the id of a line of the active sketch.",
|
||||
"type": "string"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the outline of a body, revolved about an axis, to the active sketch as a linked 3D curve
|
||||
/// in the sketch plane. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = spun_required)]
|
||||
pub struct SpunProfileParams {
|
||||
// Optional in the type and required in the schema, as `curves` above.
|
||||
/// The body to revolve.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "BodyName")]
|
||||
pub body: Option<BodyName>,
|
||||
/// The axis to revolve about: X, Y or Z, or the id of a line of the active sketch.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "SpunAxis")]
|
||||
pub axis: Option<SpunAxis>,
|
||||
}
|
||||
|
||||
fn spun_required(schema: &mut Schema) {
|
||||
require(schema, "body");
|
||||
require(schema, "axis");
|
||||
}
|
||||
|
||||
/// `sketch.isoparametric_curve`'s `direction`: `u` or `v` (any case), or a direction [x, y, z].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct IsoDirection(pub Value);
|
||||
|
||||
impl<'de> Deserialize<'de> for IsoDirection {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
let word = matches!(&v, Value::String(d) if d.eq_ignore_ascii_case("u") || d.eq_ignore_ascii_case("v"));
|
||||
if word || params::vec3(&v).is_some() { Ok(IsoDirection(v)) } else { Err(D::Error::custom(whole("`direction` must be u, v or [x,y,z]"))) }
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for IsoDirection {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"IsoDirection".into()
|
||||
}
|
||||
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
|
||||
let point = generator.subschema_for::<Point3>();
|
||||
json_schema!({
|
||||
"description": "u or v (any case): along the face's parametric direction; or a direction [x, y, z].",
|
||||
"anyOf": [{"type": "string", "enum": ["u", "v", "U", "V"]}, point]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Add the curve of constant u or v through a point of a face to the active sketch, as a linked
|
||||
/// 3D curve. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = face_required)]
|
||||
pub struct IsoCurveParams {
|
||||
// Optional in the type and required in the schema, as `curves` above.
|
||||
/// A point [x, y, z] in mm on the face: the curve passes through it.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IsoFacePoint")]
|
||||
pub face: Option<IsoFacePoint>,
|
||||
/// The face's body. Default: any body the point is on. Text only; anything else is read as
|
||||
/// left out.
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
/// Which way the curve runs: u or v, or a direction [x, y, z]. Default: u.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "IsoDirection")]
|
||||
pub direction: Option<IsoDirection>,
|
||||
}
|
||||
|
||||
fn face_required(schema: &mut Schema) {
|
||||
require(schema, "face");
|
||||
}
|
||||
|
||||
/// Fit lines and arcs to the section of a mesh or solid body with the sketch plane, and add them
|
||||
/// to the active sketch as normal (unlinked) geometry. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = body_required)]
|
||||
pub struct FitCurvesParams {
|
||||
// Optional in the type and required in the schema, as `curves` above.
|
||||
/// The body to cut with the sketch plane.
|
||||
#[serde(default, deserialize_with = "some", skip_serializing_if = "Option::is_none")]
|
||||
#[schemars(with = "BodyName")]
|
||||
pub body: Option<BodyName>,
|
||||
}
|
||||
|
||||
fn body_required(schema: &mut Schema) {
|
||||
require(schema, "body");
|
||||
}
|
||||
|
||||
/// `sketch.break_link`'s `entities`: one id or a list of ids, as `params::string_list` reads them.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct EntityIds(pub Vec<String>);
|
||||
|
||||
impl<'de> Deserialize<'de> for EntityIds {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
let v = Value::deserialize(d)?;
|
||||
match params::string_list("", &json!({"entities": v}), "entities") {
|
||||
Ok(ids) => Ok(EntityIds(ids)),
|
||||
Err(crate::EngineError::BadParams { msg, .. }) => Err(D::Error::custom(whole(msg))),
|
||||
Err(other) => Err(D::Error::custom(whole(other))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsonSchema for EntityIds {
|
||||
fn schema_name() -> Cow<'static, str> {
|
||||
"EntityIds".into()
|
||||
}
|
||||
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||
json_schema!({
|
||||
"description": "The id of a curve or point, or a list of ids (at most 10000).",
|
||||
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "string"}, "maxItems": 10000}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn linked sketch geometry into normal sketch geometry, by its link or by any of its curves
|
||||
/// and points. Give `link` or `entities`. One undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields, transform = link_or_entities)]
|
||||
pub struct SketchBreakLinkParams {
|
||||
/// A link's id (`j1`). Text only; anything else is read as left out.
|
||||
#[serde(default)]
|
||||
pub link: Option<String>,
|
||||
/// The ids of linked curves or points: each one's whole link is broken. Default: none.
|
||||
#[serde(default)]
|
||||
pub entities: Option<EntityIds>,
|
||||
}
|
||||
|
||||
/// The handler refuses a call with neither key (or an empty list), with its own message.
|
||||
fn link_or_entities(schema: &mut Schema) {
|
||||
schema.insert("anyOf".into(), json!([{"required": ["link"]}, {"required": ["entities"]}]));
|
||||
}
|
||||
|
||||
/// Choose whether a sketch created on a face projects that face's edges into it at once, or
|
||||
/// toggle the choice. A setting the session keeps: not an undo step.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct AutoProjectParams {
|
||||
/// True to project, false not to. Default: the opposite of what it is now. Anything but true
|
||||
/// or false is read as left out.
|
||||
#[serde(default)]
|
||||
pub value: Option<bool>,
|
||||
}
|
||||
|
||||
/// One link a projection made, and what it brought into the sketch.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProjectedLink {
|
||||
/// The link's id (`j1`); null when `projection_link` was false: the geometry is normal sketch
|
||||
/// geometry then.
|
||||
pub link: Option<String>,
|
||||
/// The ids of the curves it made.
|
||||
pub curves: Vec<String>,
|
||||
/// The ids of the points it made.
|
||||
pub points: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.project`, `sketch.intersect` and `sketch.include_3d` return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProjectResult {
|
||||
/// One entry for each reference that added something, in order.
|
||||
pub links: Vec<ProjectedLink>,
|
||||
/// What was left out because the sketch shows it already (`sketch.project` only).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub already_projected: Vec<String>,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<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>,
|
||||
}
|
||||
|
||||
/// The link one command made: what `sketch.intersection_curve`, `sketch.spun_profile` and
|
||||
/// `sketch.isoparametric_curve` return, and each of the `links` of `sketch.project_to_surface`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct LinkMade {
|
||||
/// The link's id (`j1`).
|
||||
pub link: String,
|
||||
/// The ids of the curves it made.
|
||||
pub curves: Vec<String>,
|
||||
/// The ids of the points it made.
|
||||
pub points: Vec<String>,
|
||||
/// The ids of the wires it made.
|
||||
pub wires: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `sketch.intersection_curve`, `sketch.spun_profile` and `sketch.isoparametric_curve`
|
||||
/// return.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchLinkResult {
|
||||
#[serde(flatten)]
|
||||
pub made: LinkMade,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<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 `sketch.project_to_surface` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProjectToSurfaceResult {
|
||||
/// One link for each curve, in order.
|
||||
pub links: Vec<LinkMade>,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<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 `sketch.fit_curves_to_section` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct FitSectionResult {
|
||||
/// The ids of the curves added.
|
||||
pub curves: Vec<String>,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<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 `sketch.break_link` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SketchBreakLinkResult {
|
||||
/// The ids of the links that were broken.
|
||||
pub broken: Vec<String>,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<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 `sketch.auto_project` returns.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AutoProjectResult {
|
||||
/// Whether a sketch created on a face projects its edges now.
|
||||
pub auto_project: bool,
|
||||
/// What the command added to the sketch, as every sketch command reports it (the engine adds
|
||||
/// it): `{points, curves, constraints, dimensions}`, and `wires` when there are any.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<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_project_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,514 @@
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
AutoProjectParams, AutoProjectResult, FitCurvesParams, FitSectionResult, Include3dParams, IntersectParams, IntersectionCurveParams,
|
||||
IsoCurveParams, ProjectParams, ProjectRef, ProjectResult, ProjectToSurfaceParams, ProjectToSurfaceResult, SketchBreakLinkParams,
|
||||
SketchBreakLinkResult, SketchLinkResult, SpunProfileParams,
|
||||
};
|
||||
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};
|
||||
|
||||
/// A box and a tall cylinder through it (two bodies), a finished sketch `Src` with a line `source`,
|
||||
/// and a sketch being edited, on the XY plane at height 5.
|
||||
fn scene(level: ParamCheck) -> Session {
|
||||
scene_on(level, "XY")
|
||||
}
|
||||
|
||||
/// [`scene`] with the sketch being edited on `plane`.
|
||||
fn scene_on(level: ParamCheck, plane: &str) -> Session {
|
||||
let mut s = Session { param_check: level, ..Session::default() };
|
||||
for (id, p) in [
|
||||
("solid.box", json!({"corner": [0, 0, 0], "length": 20, "width": 20, "height": 10, "body_name": "Block"})),
|
||||
("solid.cylinder", json!({"base": [10, 10, -5], "radius": 4, "height": 30, "operation": "new", "body_name": "Pin"})),
|
||||
("sketch.create", json!({"plane": "XY", "offset": 20, "name": "Src"})),
|
||||
("sketch.line", json!({"points": [[2, 2], [18, 2]], "ids": ["source"]})),
|
||||
("sketch.line", json!({"points": [[2, 4], [18, 8]], "ids": ["other"]})),
|
||||
("sketch.finish", json!({})),
|
||||
("sketch.create", json!({"plane": plane, "offset": 5})),
|
||||
] {
|
||||
if let Err(e) = s.execute(id, &p) {
|
||||
panic!("{id} {p}: {e}");
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn error(level: ParamCheck, id: &str, p: &Value) -> Option<String> {
|
||||
scene(level).execute(id, p).err().map(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_and_the_key_check_agree() {
|
||||
assert_keys(input_schema_for::<ProjectParams>(), &["projection_link", "ref", "refs"]);
|
||||
assert_keys(input_schema_for::<IntersectParams>(), &["projection_link", "ref", "refs"]);
|
||||
assert_keys(input_schema_for::<Include3dParams>(), &["ref", "refs"]);
|
||||
assert_keys(input_schema_for::<ProjectToSurfaceParams>(), &["body", "curves", "face"]);
|
||||
assert_keys(input_schema_for::<IntersectionCurveParams>(), &["a", "b"]);
|
||||
assert_keys(input_schema_for::<SpunProfileParams>(), &["axis", "body"]);
|
||||
assert_keys(input_schema_for::<IsoCurveParams>(), &["body", "direction", "face"]);
|
||||
assert_keys(input_schema_for::<FitCurvesParams>(), &["body"]);
|
||||
assert_keys(input_schema_for::<SketchBreakLinkParams>(), &["entities", "link"]);
|
||||
assert_keys(input_schema_for::<AutoProjectParams>(), &["value"]);
|
||||
}
|
||||
|
||||
/// Strict gives the handlers' messages for the calls they refuse for a key. These fail before the
|
||||
/// handler looks at the sketch being edited, so they hold with none open.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors() {
|
||||
assert_strict_keeps_todays_errors(&[
|
||||
("sketch.project", json!({})),
|
||||
("sketch.project", json!({"refs": []})),
|
||||
("sketch.project", json!({"ref": []})),
|
||||
("sketch.project", json!({"refs": [], "ref": "origin"})),
|
||||
("sketch.project", json!({"projection_link": "no", "refs": ["origin"]})),
|
||||
("sketch.project", json!({"projection_link": null, "refs": ["origin"]})),
|
||||
("sketch.project", json!({"projection_link": 1})),
|
||||
("sketch.intersect", json!({})),
|
||||
("sketch.intersect", json!({"refs": []})),
|
||||
("sketch.intersect", json!({"projection_link": "no", "refs": [{"body": "Block"}]})),
|
||||
("sketch.include_3d", json!({})),
|
||||
("sketch.include_3d", json!({"refs": []})),
|
||||
("sketch.project_to_surface", json!({})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2]})),
|
||||
("sketch.project_to_surface", json!({"face": null})),
|
||||
("sketch.project_to_surface", json!({"face": "top"})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3]})),
|
||||
("sketch.spun_profile", json!({})),
|
||||
("sketch.spun_profile", json!({"body": 5, "axis": "Z"})),
|
||||
("sketch.spun_profile", json!({"body": null})),
|
||||
("sketch.spun_profile", json!({"body": "Block"})),
|
||||
("sketch.spun_profile", json!({"body": "Block", "axis": 3})),
|
||||
("sketch.spun_profile", json!({"body": "Block", "axis": null})),
|
||||
("sketch.isoparametric_curve", json!({})),
|
||||
("sketch.isoparametric_curve", json!({"face": [1, 2]})),
|
||||
("sketch.isoparametric_curve", json!({"face": {"x": 1, "y": 2}})),
|
||||
("sketch.fit_curves_to_section", json!({})),
|
||||
("sketch.fit_curves_to_section", json!({"body": 7})),
|
||||
("sketch.fit_curves_to_section", json!({"body": null})),
|
||||
("sketch.fit_curves_to_section", json!({"body": ["Block"]})),
|
||||
]);
|
||||
}
|
||||
|
||||
/// The same for the calls that fail only once the handler has a sketch to work in: what it
|
||||
/// refuses in a reference or a list, with the sketch being edited open.
|
||||
#[test]
|
||||
fn strict_keeps_todays_errors_in_a_sketch() {
|
||||
let cases = [
|
||||
("sketch.project", json!({"refs": null})),
|
||||
("sketch.project", json!({"refs": [true]})),
|
||||
("sketch.project", json!({"refs": [5]})),
|
||||
("sketch.project", json!({"refs": [null]})),
|
||||
("sketch.project", json!({"refs": [[1, 2, 3]]})),
|
||||
("sketch.project", json!({"refs": [{}]})),
|
||||
("sketch.project", json!({"refs": [{"edge": [1, 2]}]})),
|
||||
("sketch.project", json!({"refs": [{"edge": "x"}, {"plane": 3}]})),
|
||||
("sketch.project", json!({"refs": ["origin", {"sketch": [1], "curve": "source"}]})),
|
||||
("sketch.project", json!({"refs": [{"sketch": true}]})),
|
||||
("sketch.project", json!({"refs": [{"sketch": "Src"}]})),
|
||||
("sketch.project", json!({"refs": [{"sketch": "Src", "curve": 4}]})),
|
||||
("sketch.project", json!({"ref": {"axis": 5}})),
|
||||
("sketch.project", json!({"ref": 5})),
|
||||
("sketch.intersect", json!({"refs": [{"vertex": [1, 2]}]})),
|
||||
("sketch.intersect", json!({"ref": {"sketch": "Src", "point": 1}})),
|
||||
("sketch.include_3d", json!({"refs": [1.5]})),
|
||||
("sketch.include_3d", json!({"refs": {"colour": "red"}})),
|
||||
("sketch.include_3d", json!({"refs": null})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": 5})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": null})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": {"sketch": "Src", "curve": "source"}})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": []})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": vec![json!({"sketch": "Src", "curve": "source"}); 501]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"edge": [1, 2, 3]}]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"sketch": "Src", "point": "p1"}]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"body": "Block"}]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": ["origin"]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"origin": true}]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"nothing": 1}]})),
|
||||
("sketch.project_to_surface", json!({"face": [1, 2, 3], "curves": [{"sketch": "Src"}]})),
|
||||
("sketch.intersection_curve", json!({})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": null, "b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": 5, "b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"nothing": 1}})),
|
||||
("sketch.intersection_curve", json!({"a": "origin", "b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": "x"})),
|
||||
("sketch.intersection_curve", json!({"a": {"edge": [1, 2, 3]}, "b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"vertex": [1, 2, 3]}})),
|
||||
("sketch.intersection_curve", json!({"a": {"plane": "XY"}, "b": {"body": "Block"}})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"sketch": "Src", "point": "p1"}})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"sketch": "Src"}})),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "direction": null})),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "direction": "w"})),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "direction": [1, 2]})),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "direction": 3})),
|
||||
("sketch.break_link", json!({})),
|
||||
("sketch.break_link", json!({"entities": []})),
|
||||
("sketch.break_link", json!({"entities": 5})),
|
||||
("sketch.break_link", json!({"entities": [1]})),
|
||||
("sketch.break_link", json!({"entities": ["a", true]})),
|
||||
("sketch.break_link", json!({"entities": {"a": 1}})),
|
||||
("sketch.break_link", json!({"link": "j9"})),
|
||||
];
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict says the same wherever the handler's message comes from the contract's reading: every
|
||||
/// shape of reference is read by the type as the handler reads it, so the contract refuses it
|
||||
/// exactly when the handler does for its form (a sketch that exists and is another sketch's).
|
||||
#[test]
|
||||
fn the_reference_type_reads_like_the_handler() {
|
||||
let forms = [
|
||||
json!("origin"),
|
||||
json!("ORIGIN"),
|
||||
json!("x"),
|
||||
json!("Z"),
|
||||
json!("Block"),
|
||||
json!("nothing"),
|
||||
json!({"edge": [10, 0, 5]}),
|
||||
json!({"edge": {"x": 10, "y": 0, "z": 5}, "body": "Block"}),
|
||||
json!({"edge": "bad", "body": "Block"}),
|
||||
json!({"edge": [1, 2], "face": [10, 10, 10]}),
|
||||
json!({"edge": [1, 2, 3, 4]}),
|
||||
json!({"face": [10, 10, 10], "body": 4}),
|
||||
json!({"vertex": [0, 0, 10]}),
|
||||
json!({"vertex": [1e9, 0, 0]}),
|
||||
json!({"sketch": "Src", "curve": "source"}),
|
||||
json!({"sketch": "Src", "point": "p1"}),
|
||||
json!({"sketch": "Src", "curve": "source", "point": "p1"}),
|
||||
json!({"sketch": "Src", "curve": 7, "point": "p1"}),
|
||||
json!({"sketch": "Src"}),
|
||||
json!({"sketch": "Src", "curve": null}),
|
||||
json!({"sketch": 1.5}),
|
||||
json!({"sketch": true}),
|
||||
json!({"sketch": null}),
|
||||
json!({"sketch": ["Src"]}),
|
||||
json!({"sketch": {"name": "Src"}, "body": "Block"}),
|
||||
json!({"body": "Block"}),
|
||||
json!({"body": 5}),
|
||||
json!({"body": "Block", "axis": "Z"}),
|
||||
json!({"axis": "X"}),
|
||||
json!({"axis": "x"}),
|
||||
json!({"axis": 5}),
|
||||
json!({"plane": "XY"}),
|
||||
json!({"plane": null}),
|
||||
json!({"origin": null}),
|
||||
json!({"origin": [0, 0, 0]}),
|
||||
json!({}),
|
||||
json!([]),
|
||||
json!(null),
|
||||
json!(true),
|
||||
json!(5),
|
||||
json!({"nothing": 1}),
|
||||
];
|
||||
for form in forms {
|
||||
let issues = issues("sketch.include_3d", &json!({"refs": [form]}));
|
||||
let off = error(ParamCheck::Off, "sketch.include_3d", &json!({"refs": [form]}));
|
||||
let strict = error(ParamCheck::Strict, "sketch.include_3d", &json!({"refs": [form]}));
|
||||
let typed = serde_json::from_value::<ProjectRef>(form.clone());
|
||||
// The type, the check and the handler agree on what is refused for its form.
|
||||
assert_eq!(typed.is_err(), !issues.is_empty(), "{form}");
|
||||
if typed.is_err() {
|
||||
assert_eq!(strict, off, "{form}");
|
||||
} else {
|
||||
let message = off.unwrap_or_default();
|
||||
// What the handler refuses of a form the type accepts needs the design.
|
||||
for shape in ["cannot project", "`sketch` must be", "`refs` is empty"] {
|
||||
assert!(!message.contains(shape), "{form}: {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// A single reference is read as a list of one, and `ref` is read only without `refs`.
|
||||
let count = |p: Value| issues("sketch.project", &p).len();
|
||||
assert_eq!(count(json!({"refs": "origin"})), 0);
|
||||
assert_eq!(count(json!({"refs": {"origin": 1}})), 0);
|
||||
assert_eq!(count(json!({"ref": "origin"})), 0);
|
||||
assert_eq!(count(json!({"refs": ["origin"], "ref": 5})), 0, "`ref` is not read when `refs` is given");
|
||||
assert_eq!(count(json!({"refs": null, "ref": "origin"})), 1, "`refs` is read even when null");
|
||||
assert_eq!(count(json!({"ref": 5})), 1);
|
||||
assert_eq!(count(json!({"refs": []})), 1);
|
||||
assert_eq!(count(json!({"refs": [], "ref": "origin"})), 1);
|
||||
// Only the first 1000 references are read.
|
||||
let mut many = vec![json!("origin"); 1000];
|
||||
many.push(json!(5));
|
||||
assert_eq!(count(json!({"refs": many})), 0);
|
||||
many.insert(0, json!(5));
|
||||
assert_eq!(count(json!({"refs": many})), 1);
|
||||
// `include_3d` takes no `projection_link`: the handler never reads it, so strict names it.
|
||||
assert_eq!(issues("sketch.include_3d", &json!({"refs": ["origin"], "projection_link": false})).len(), 1);
|
||||
assert_eq!(issues("sketch.project", &json!({"refs": ["origin"], "projection_link": false})).len(), 0);
|
||||
}
|
||||
|
||||
/// What the contracts take and refuse in the schemas as well as the types.
|
||||
#[test]
|
||||
fn the_schemas_take_what_the_handlers_read() {
|
||||
let check = |schema: schemars::Schema, cases: &[(Value, bool)]| {
|
||||
let schema = schema.as_value().clone();
|
||||
for (p, takes) in cases {
|
||||
assert_eq!(conforms(&schema, &schema, p), *takes, "{p}");
|
||||
}
|
||||
};
|
||||
for schema in [input_schema_for::<ProjectParams>(), input_schema_for::<IntersectParams>()] {
|
||||
check(
|
||||
schema,
|
||||
&[
|
||||
(json!({"refs": [{"edge": [1, 2, 3], "body": "Block"}, {"face": [1, 2, 3]}, "origin", {"axis": "X"}]}), true),
|
||||
(json!({"refs": {"sketch": 2, "curve": "c1"}, "projection_link": false}), true),
|
||||
(json!({"ref": {"body": "Block"}}), true),
|
||||
(json!({"refs": [{"sketch": "Src", "point": "p1"}, {"plane": "XY"}, {"vertex": {"x": 1, "y": 2, "z": 3}}]}), true),
|
||||
(json!({}), false),
|
||||
(json!({"projection_link": true}), false),
|
||||
(json!({"refs": []}), false),
|
||||
(json!({"refs": [{"edge": [1, 2]}]}), false),
|
||||
(json!({"refs": ["origin"], "projection_link": "yes"}), false),
|
||||
(json!({"refs": ["origin"], "projection_lnk": true}), false),
|
||||
],
|
||||
);
|
||||
}
|
||||
check(
|
||||
input_schema_for::<Include3dParams>(),
|
||||
&[(json!({"refs": ["origin"]}), true), (json!({"refs": ["origin"], "projection_link": true}), false)],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<ProjectToSurfaceParams>(),
|
||||
&[
|
||||
(json!({"face": [0, 0, 20], "curves": [{"sketch": 4, "curve": "source"}]}), true),
|
||||
(json!({"face": [0, 0, 20], "curves": [{"sketch": "Src", "curve": "source"}], "body": "Block"}), true),
|
||||
(json!({"face": [0, 0, 20]}), false),
|
||||
(json!({"curves": [{"sketch": 4, "curve": "source"}]}), false),
|
||||
(json!({"face": [0, 0, 20], "curves": []}), false),
|
||||
(json!({"face": [0, 0, 20], "curves": [{"sketch": 4}]}), false),
|
||||
],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<IntersectionCurveParams>(),
|
||||
&[
|
||||
(json!({"a": {"body": "Block"}, "b": {"face": [1, 2, 3], "body": "Pin"}}), true),
|
||||
(json!({"a": {"sketch": "Src", "curve": "source"}, "b": "Pin"}), true),
|
||||
(json!({"a": {"body": "Block"}}), false),
|
||||
(json!({"a": {"body": "Block"}, "b": {"edge": [1, 2, 3]}}), false),
|
||||
],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<SpunProfileParams>(),
|
||||
&[
|
||||
(json!({"body": "Pin", "axis": "Z"}), true),
|
||||
(json!({"body": "Pin"}), false),
|
||||
(json!({"axis": "Z"}), false),
|
||||
(json!({"body": 1, "axis": "Z"}), false),
|
||||
],
|
||||
);
|
||||
check(
|
||||
input_schema_for::<IsoCurveParams>(),
|
||||
&[
|
||||
(json!({"face": [1, 2, 3]}), true),
|
||||
(json!({"face": [1, 2, 3], "body": "Block", "direction": "v"}), true),
|
||||
(json!({"face": [1, 2, 3], "direction": [0, 1, 0]}), true),
|
||||
(json!({"direction": "u"}), false),
|
||||
(json!({"face": [1, 2, 3], "direction": "w"}), false),
|
||||
],
|
||||
);
|
||||
check(input_schema_for::<FitCurvesParams>(), &[(json!({"body": "Pin"}), true), (json!({}), false)]);
|
||||
check(
|
||||
input_schema_for::<SketchBreakLinkParams>(),
|
||||
&[
|
||||
(json!({"link": "j1"}), true),
|
||||
(json!({"entities": "c1"}), true),
|
||||
(json!({"entities": ["c1", "p2"], "link": "j1"}), true),
|
||||
(json!({}), false),
|
||||
(json!({"entities": [1]}), false),
|
||||
],
|
||||
);
|
||||
check(input_schema_for::<AutoProjectParams>(), &[(json!({}), true), (json!({"value": false}), true), (json!({"value": "no"}), false)]);
|
||||
}
|
||||
|
||||
/// Every command replies as its output schema says, in Warn without warnings, and in the same JSON
|
||||
/// as with the check off.
|
||||
#[test]
|
||||
fn replies_match_the_output_schema() {
|
||||
fn matches(schema: schemars::Schema, r: &Value) {
|
||||
let schema = schema.as_value();
|
||||
assert!(conforms(schema, schema, r), "{r} doesn't match {schema}");
|
||||
}
|
||||
let calls: Vec<(&str, Value, fn() -> schemars::Schema)> = vec![
|
||||
("sketch.project", json!({"refs": [{"edge": [10, 0, 10]}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"refs": [{"vertex": [0, 0, 10]}, {"face": [10, 10, 10], "body": "Block"}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"refs": {"edge": [20, 10, 10], "body": "Block"}, "projection_link": true}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"ref": {"edge": [10, 20, 10]}, "projection_link": false}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"refs": ["origin"]}), output_schema_for::<ProjectResult>),
|
||||
(
|
||||
"sketch.project",
|
||||
json!({"refs": [{"axis": "X"}, {"plane": "XZ"}, {"sketch": "Src", "curve": "source"}]}),
|
||||
output_schema_for::<ProjectResult>,
|
||||
),
|
||||
("sketch.project", json!({"refs": [{"sketch": "Src", "point": "source.start"}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"refs": [{"edge": "bad", "body": "Pin"}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.project", json!({"refs": ["Pin"], "ref": 5}), output_schema_for::<ProjectResult>),
|
||||
("sketch.intersect", json!({"refs": [{"body": "Block"}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.intersect", json!({"refs": {"face": [0, 10, 5], "body": "Block"}, "projection_link": false}), output_schema_for::<ProjectResult>),
|
||||
("sketch.intersect", json!({"ref": {"edge": [0, 0, 5]}}), output_schema_for::<ProjectResult>),
|
||||
("sketch.include_3d", json!({"refs": [{"edge": [20, 10, 10]}]}), output_schema_for::<ProjectResult>),
|
||||
("sketch.include_3d", json!({"ref": {"vertex": [0, 0, 10]}}), output_schema_for::<ProjectResult>),
|
||||
(
|
||||
"sketch.project_to_surface",
|
||||
json!({"face": [10, 10, 10], "curves": [{"sketch": "Src", "curve": "source"}, {"sketch": "Src", "curve": "other"}]}),
|
||||
output_schema_for::<ProjectToSurfaceResult>,
|
||||
),
|
||||
(
|
||||
"sketch.project_to_surface",
|
||||
json!({"face": {"x": 10, "y": 10, "z": 10}, "body": "Block", "curves": [{"sketch": 3, "curve": "source"}]}),
|
||||
output_schema_for::<ProjectToSurfaceResult>,
|
||||
),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"body": "Pin"}}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.intersection_curve", json!({"a": {"face": [10, 10, 10], "body": "Block"}, "b": "Pin"}), output_schema_for::<SketchLinkResult>),
|
||||
(
|
||||
"sketch.intersection_curve",
|
||||
json!({"a": {"sketch": "Src", "curve": "source"}, "b": {"body": "Block"}}),
|
||||
output_schema_for::<SketchLinkResult>,
|
||||
),
|
||||
("sketch.spun_profile", json!({"body": "Pin", "axis": "Z"}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.spun_profile", json!({"body": "Pin", "axis": "x"}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10]}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "body": "Block", "direction": "V"}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.isoparametric_curve", json!({"face": {"x": 10, "y": 10, "z": 10}, "direction": [1, 0, 0]}), output_schema_for::<SketchLinkResult>),
|
||||
("sketch.fit_curves_to_section", json!({"body": "Block"}), output_schema_for::<FitSectionResult>),
|
||||
("sketch.auto_project", json!({}), output_schema_for::<AutoProjectResult>),
|
||||
("sketch.auto_project", json!({"value": true}), output_schema_for::<AutoProjectResult>),
|
||||
];
|
||||
for (id, p, output) in calls {
|
||||
// The spun profile needs a plane the cylinder's axis lies in.
|
||||
let plane = if id == "sketch.spun_profile" { "XZ" } else { "XY" };
|
||||
let a = scene_on(ParamCheck::Off, plane).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let b = scene_on(ParamCheck::Warn, plane).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert!(b.get("param_warnings").is_none(), "{id} {p}: {b}");
|
||||
assert_eq!(a, b, "{id} {p}");
|
||||
matches(output(), &b);
|
||||
}
|
||||
let run = |p: Value| scene(ParamCheck::Warn).execute("sketch.project", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
// The shapes a caller sees: the link, its curves and points; null where the link is not kept.
|
||||
let r = run(json!({"refs": [{"edge": [10, 0, 10]}]}));
|
||||
assert_eq!(r["links"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let l = &r["links"][0];
|
||||
assert!(l["link"].is_string() && l["curves"].is_array() && l["points"].is_array(), "{r}");
|
||||
assert_eq!(l.as_object().map(|o| o.len()), Some(3), "{r}");
|
||||
assert!(r.get("already_projected").is_none(), "no `already_projected` when all was projected: {r}");
|
||||
assert!(r["created"]["curves"].is_array(), "the engine reports what a sketch command created: {r}");
|
||||
let r = run(json!({"refs": [{"edge": [10, 0, 10]}], "projection_link": false}));
|
||||
assert!(r["links"][0]["link"].is_null() && r["links"][0]["curves"][0].is_string(), "{r}");
|
||||
matches(output_schema_for::<ProjectResult>(), &r);
|
||||
// Projecting what the sketch shows already says so, and the reply still matches.
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
s.execute("sketch.project", &json!({"refs": [{"edge": [10, 0, 10]}]})).unwrap_or_default();
|
||||
let r = s.execute("sketch.project", &json!({"refs": [{"edge": [10, 0, 10]}, {"edge": [10, 20, 10]}]})).unwrap_or_default();
|
||||
assert_eq!(r["already_projected"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
assert!(r.get("param_warnings").is_none(), "{r}");
|
||||
matches(output_schema_for::<ProjectResult>(), &r);
|
||||
// A `value` that is not true or false is read as left out, and the check says so.
|
||||
let r = scene(ParamCheck::Warn).execute("sketch.auto_project", &json!({"value": "maybe"})).unwrap_or_default();
|
||||
assert_eq!(r["param_warnings"], json!(["`value` must be true or false"]), "{r}");
|
||||
matches(output_schema_for::<AutoProjectResult>(), &r);
|
||||
// The shape of the others.
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let r = s.execute("sketch.isoparametric_curve", &json!({"face": [10, 10, 10]})).unwrap_or_default();
|
||||
let mut keys: Vec<_> = r.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();
|
||||
keys.sort();
|
||||
assert_eq!(keys, ["created", "curves", "link", "points", "wires"], "{r}");
|
||||
let r = s.execute("sketch.auto_project", &json!({"value": true})).unwrap_or_default();
|
||||
assert_eq!(r["auto_project"], json!(true));
|
||||
let r = s.execute("sketch.auto_project", &json!({})).unwrap_or_default();
|
||||
assert_eq!(r["auto_project"], json!(false), "no value toggles");
|
||||
let r = s.execute("sketch.fit_curves_to_section", &json!({"body": "Block"})).unwrap_or_default();
|
||||
assert!(r["curves"].is_array(), "{r}");
|
||||
}
|
||||
|
||||
/// Breaking links: by `link`, by `entities` of both kinds (a curve, a point), and with both.
|
||||
#[test]
|
||||
fn break_link_replies_without_warnings() {
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let linked = |s: &mut Session, p: Value| {
|
||||
let r = s.execute("sketch.project", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
let l = &r["links"][0];
|
||||
let text = |v: &Value| v.as_str().unwrap_or_default().to_string();
|
||||
(text(&l["link"]), text(&l["curves"][0]), text(&l["points"][0]))
|
||||
};
|
||||
let (l1, _, _) = linked(&mut s, json!({"refs": [{"edge": [10, 0, 10]}]}));
|
||||
let (_, c2, _) = linked(&mut s, json!({"refs": [{"edge": [10, 20, 10]}]}));
|
||||
let (l3, _, _) = linked(&mut s, json!({"refs": [{"edge": [20, 10, 10]}]}));
|
||||
let (_, _, p4) = linked(&mut s, json!({"refs": [{"vertex": [0, 0, 10]}]}));
|
||||
assert!(!c2.is_empty() && !p4.is_empty());
|
||||
let schema = output_schema_for::<SketchBreakLinkResult>();
|
||||
for p in [json!({"link": l1}), json!({"entities": c2}), json!({"entities": [p4], "link": l3})] {
|
||||
let r = s.execute("sketch.break_link", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
assert_eq!(r["broken"].as_array().map(Vec::len), Some(p.as_object().map_or(0, |o| o.len())), "{p}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A key spelt wrong runs in warn and says so (the reply still matches), and strict refuses it.
|
||||
#[test]
|
||||
fn a_misspelt_key_is_reported_in_warn_and_refused_in_strict() {
|
||||
let cases = [
|
||||
("sketch.project", json!({"refs": ["origin"], "projection_lnk": false})),
|
||||
("sketch.intersect", json!({"refs": [{"body": "Block"}], "projectionlink": false})),
|
||||
("sketch.include_3d", json!({"refs": [{"edge": [20, 10, 10]}], "projection_link": false})),
|
||||
("sketch.project_to_surface", json!({"face": [10, 10, 10], "curves": [{"sketch": "Src", "curve": "source"}], "bdy": "Block"})),
|
||||
("sketch.intersection_curve", json!({"a": {"body": "Block"}, "b": {"body": "Pin"}, "c": 1})),
|
||||
("sketch.spun_profile", json!({"body": "Pin", "axis": "Z", "axes": "Y"})),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "directon": "v"})),
|
||||
("sketch.fit_curves_to_section", json!({"body": "Block", "bodies": []})),
|
||||
("sketch.auto_project", json!({"vlaue": false})),
|
||||
];
|
||||
for (id, p) in cases {
|
||||
let plane = if id == "sketch.spun_profile" { "XZ" } else { "XY" };
|
||||
let r = scene_on(ParamCheck::Warn, plane).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let warnings = r["param_warnings"].as_array().unwrap_or_else(|| panic!("{id} {p}: {r}"));
|
||||
assert_eq!(warnings.len(), 1, "{id} {p}: {r}");
|
||||
assert!(warnings[0].as_str().is_some_and(|w| w.contains("unknown key")), "{id}: {r}");
|
||||
let error = error(ParamCheck::Strict, id, &p).unwrap_or_default();
|
||||
assert!(error.contains("unknown key"), "{id}: {error}");
|
||||
}
|
||||
let r = scene(ParamCheck::Warn).execute("sketch.auto_project", &json!({"vlaue": false})).unwrap_or_default();
|
||||
assert_eq!(r["param_warnings"], json!(["unknown key `vlaue`"]), "{r}");
|
||||
// The reply with its warnings still matches its schema.
|
||||
let schema = output_schema_for::<AutoProjectResult>();
|
||||
assert!(conforms(schema.as_value(), schema.as_value(), &r), "{r}");
|
||||
}
|
||||
|
||||
/// Wrong-typed values the handlers read as left out: the typed field names them in warn (and
|
||||
/// strict refuses), `null` is left out and gets no warning, and the reply is the same as with the
|
||||
/// check off.
|
||||
#[test]
|
||||
fn values_read_as_left_out_are_reported_but_change_nothing() {
|
||||
let cases = [
|
||||
("sketch.project_to_surface", json!({"face": [10, 10, 10], "curves": [{"sketch": "Src", "curve": "source"}], "body": 5}), true),
|
||||
("sketch.project_to_surface", json!({"face": [10, 10, 10], "curves": [{"sketch": "Src", "curve": "source"}], "body": null}), false),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "body": 5}), true),
|
||||
("sketch.isoparametric_curve", json!({"face": [10, 10, 10], "body": null}), false),
|
||||
("sketch.auto_project", json!({"value": null}), false),
|
||||
("sketch.auto_project", json!({"value": 1}), true),
|
||||
];
|
||||
for (id, p, warns) in cases {
|
||||
let off = scene(ParamCheck::Off).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
let mut warn = scene(ParamCheck::Warn).execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"));
|
||||
assert_eq!(warn.get("param_warnings").is_some(), warns, "{id} {p}: {warn}");
|
||||
if let Some(o) = warn.as_object_mut() {
|
||||
o.remove("param_warnings");
|
||||
}
|
||||
assert_eq!(off, warn, "{id} {p}");
|
||||
}
|
||||
// break_link: `link` of the wrong type is left out, so `entities` alone does the work.
|
||||
let mut s = scene(ParamCheck::Warn);
|
||||
let r = s.execute("sketch.project", &json!({"refs": [{"edge": [10, 0, 10]}]})).unwrap_or_default();
|
||||
let curve = r["links"][0]["curves"][0].clone();
|
||||
let r = s.execute("sketch.break_link", &json!({"link": 5, "entities": [curve]})).unwrap_or_default();
|
||||
assert_eq!(r["broken"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
assert_eq!(r["param_warnings"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let r = scene(ParamCheck::Warn).execute("sketch.break_link", &json!({"link": null}));
|
||||
assert!(r.is_err(), "nothing to break");
|
||||
}
|
||||
@@ -58,8 +58,6 @@ sheet.export_bend_table
|
||||
sheet.export_dxf
|
||||
sheet.flat_pattern
|
||||
sheet.path_flange
|
||||
sketch.auto_project
|
||||
sketch.break_link
|
||||
sketch.chain
|
||||
sketch.construction
|
||||
sketch.create
|
||||
@@ -69,21 +67,11 @@ sketch.dimension_text
|
||||
sketch.drag
|
||||
sketch.drag_end
|
||||
sketch.draw.dimensioned
|
||||
sketch.export_dxf
|
||||
sketch.fit_curves_to_section
|
||||
sketch.include_3d
|
||||
sketch.insert_dxf
|
||||
sketch.insert_svg
|
||||
sketch.intersect
|
||||
sketch.intersection_curve
|
||||
sketch.isoparametric_curve
|
||||
sketch.move_point
|
||||
sketch.options
|
||||
sketch.polygon.circumscribed
|
||||
sketch.polygon.edge
|
||||
sketch.polygon.inscribed
|
||||
sketch.project
|
||||
sketch.project_to_surface
|
||||
sketch.rectangle.center
|
||||
sketch.rectangle.rounded
|
||||
sketch.rectangle.three_point
|
||||
@@ -91,7 +79,6 @@ sketch.rectangle.two_point
|
||||
sketch.slot.center_to_center
|
||||
sketch.slot.overall
|
||||
sketch.solve
|
||||
sketch.spun_profile
|
||||
solid.align
|
||||
solid.chamfer
|
||||
solid.coil
|
||||
|
||||
@@ -161,7 +161,16 @@ fn smlib_body(record: &solidcore_record::brep::Brep) -> std::result::Result<(Bod
|
||||
let (body, body_order) = crate::native::body_from_smlib_ordered(&brep).map_err(failed)?;
|
||||
// SMLib's face i is the record's `smlib_order` face and the body's `body_order` face (an
|
||||
// empty order is the same one).
|
||||
let (Some(smlib_order), Some(body_order)) = (smlib_order, body_order) else { return Err(renumbered()) };
|
||||
let Some(body_order) = body_order else { return Err(renumbered()) };
|
||||
// SMLib's export may number the edges and vertices otherwise than the record (a surface model
|
||||
// whose open shell uses faces against their surfaces beside closed shells: SMLib walks those
|
||||
// faces as their surfaces face). An import still lists SMLib's faces in the record's order;
|
||||
// that is taken where each face is checked to be the record's face it would be.
|
||||
let smlib_order = match smlib_order {
|
||||
Some(order) => order,
|
||||
None if faces_in_record_order(record, body.record(), &body_order.faces) => Default::default(),
|
||||
None => return Err(renumbered()),
|
||||
};
|
||||
let at = |order: &[usize], i: usize| if order.is_empty() { Some(i) } else { order.get(i).copied() };
|
||||
let faces = brep.counts().map_err(|e| failed(KernelError::Failed(format!("SMLib: {e}"))))?[0];
|
||||
let mut of_record = vec![usize::MAX; body.face_count()];
|
||||
@@ -177,6 +186,44 @@ fn smlib_body(record: &solidcore_record::brep::Brep) -> std::result::Result<(Bod
|
||||
Ok((body, of_record))
|
||||
}
|
||||
|
||||
/// Whether SMLib's face `i` of a body made from `record` (the body's face `body_order[i]`, or `i`
|
||||
/// for an empty order) is the record's face `i`, face by face: the same kind of surface, the same
|
||||
/// loops of the same lengths, each loop through the same corners (within the vertices' and the
|
||||
/// records' tolerances, whichever way and from wherever SMLib walks it).
|
||||
fn faces_in_record_order(record: &solidcore_record::brep::Brep, body: &solidcore_record::brep::Brep, body_order: &[usize]) -> bool {
|
||||
use solidcore_record::brep::{Brep, Loop};
|
||||
if record.faces.len() != body.faces.len() || !(body_order.is_empty() || body_order.len() == body.faces.len()) {
|
||||
return false;
|
||||
}
|
||||
// Each loop's corners: the start of each of its edges as the loop walks them, with its tolerance.
|
||||
let corners = |b: &Brep, l: &Loop| -> Option<Vec<([f64; 3], f64)>> {
|
||||
let vertex = |v: u32| b.vertices.get(usize::try_from(v).ok()?).map(|x| (x.point, x.tolerance));
|
||||
match l {
|
||||
Loop::Vertex { vertex: v } => Some(vec![vertex(*v)?]),
|
||||
Loop::Edges { edges } => edges
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let edge = b.edges.get(usize::try_from(e.edge).ok()?)?;
|
||||
vertex(if e.forward { edge.vertices[0] } else { edge.vertices[1] })
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
};
|
||||
let slack = record.tolerance + body.tolerance;
|
||||
(0..record.faces.len()).all(|i| {
|
||||
let at = if body_order.is_empty() { Some(i) } else { body_order.get(i).copied() };
|
||||
let (Some(a), Some(b)) = (record.faces.get(i), at.and_then(|k| body.faces.get(k))) else { return false };
|
||||
if std::mem::discriminant(&a.surface) != std::mem::discriminant(&b.surface) || a.loops.len() != b.loops.len() {
|
||||
return false;
|
||||
}
|
||||
a.loops.iter().zip(&b.loops).all(|(x, y)| {
|
||||
let (Some(p), Some(q)) = (corners(record, x), corners(body, y)) else { return false };
|
||||
p.len() == q.len()
|
||||
&& p.iter().all(|(u, tu)| q.iter().any(|(w, tw)| (0..3).map(|k| (u[k] - w[k]).powi(2)).sum::<f64>().sqrt() <= tu + tw + slack))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The face of its one operand each face of `body` is, from its history.
|
||||
fn kept_faces(body: &Body) -> Option<Vec<usize>> {
|
||||
let h = body.history()?;
|
||||
|
||||
@@ -662,6 +662,69 @@ fn a_surface_models_closed_shell_beside_a_sheet_is_a_solid_of_its_own() {
|
||||
assert!(imp.bodies.iter().all(|b| b.color == Some([1.0, 0.5, 0.0])));
|
||||
}
|
||||
|
||||
/// A surface model whose shells are all OPEN_SHELLs, one of them closed all the same (a unit
|
||||
/// cube) beside a sheet of two faces, the first used against its plane (as SolidWorks 2011 wrote
|
||||
/// a vendor part): SMLib's export walks that face as its plane faces and numbers the edges and
|
||||
/// vertices otherwise than the reader, its faces still in the reader's order. The model comes in
|
||||
/// as one body of all its faces, each the file's face it was; it once came in as nothing ("SMLib
|
||||
/// renumbered the solid's faces"), and the file with it.
|
||||
#[test]
|
||||
fn a_surface_model_of_open_shells_one_closed_beside_a_turned_sheet_imports() {
|
||||
let mut t = String::from(HEAD);
|
||||
t += &cube_entities().replace("#100=CLOSED_SHELL", "#100=OPEN_SHELL");
|
||||
// Two unit squares at z = 5 sharing the edge x = 1, both facing +z: the first on a plane
|
||||
// facing -z used against it, the second on a plane facing +z.
|
||||
let corners = [(0, 0), (1, 0), (2, 0), (2, 1), (1, 1), (0, 1)];
|
||||
for (i, (x, y)) in corners.iter().enumerate() {
|
||||
t += &format!("#{}=CARTESIAN_POINT('',({x}.,{y}.,5.));\n#{}=VERTEX_POINT('',#{});\n", 800 + i, 810 + i, 800 + i);
|
||||
}
|
||||
let edges = [(0, 1), (1, 4), (4, 5), (5, 0), (1, 2), (2, 3), (3, 4)];
|
||||
for (k, (a, b)) in edges.iter().enumerate() {
|
||||
let (p, q) = (corners[*a], corners[*b]);
|
||||
t += &format!("#{}=DIRECTION('',({}.,{}.,0.));\n#{}=VECTOR('',#{},1.);\n", 900 + 2 * k, q.0 - p.0, q.1 - p.1, 901 + 2 * k, 900 + 2 * k);
|
||||
t += &format!(
|
||||
"#{}=LINE('',#{},#{});\n#{}=EDGE_CURVE('',#{},#{},#{},.T.);\n",
|
||||
820 + k,
|
||||
800 + a,
|
||||
901 + 2 * k,
|
||||
830 + k,
|
||||
810 + a,
|
||||
810 + b,
|
||||
820 + k
|
||||
);
|
||||
}
|
||||
let faces: [(&[(usize, bool)], i32, &str); 2] =
|
||||
[(&[(0, true), (1, true), (2, true), (3, true)], -1, ".F."), (&[(4, true), (5, true), (6, true), (1, false)], 1, ".T.")];
|
||||
for (f, (lp, nz, sense)) in faces.iter().enumerate() {
|
||||
let base = 940 + 20 * f;
|
||||
let oes: Vec<String> = lp
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, (e, fwd))| {
|
||||
t += &format!("#{}=ORIENTED_EDGE('',*,*,#{},{});\n", base + j, 830 + e, if *fwd { ".T." } else { ".F." });
|
||||
format!("#{}", base + j)
|
||||
})
|
||||
.collect();
|
||||
t += &format!("#{}=EDGE_LOOP('',({}));\n#{}=FACE_OUTER_BOUND('',#{},.T.);\n", base + 5, oes.join(","), base + 6, base + 5);
|
||||
t += &format!("#{}=CARTESIAN_POINT('',(0.,0.,5.));\n#{}=DIRECTION('',(0.,0.,{nz}.));\n", base + 7, base + 8);
|
||||
t += &format!("#{}=AXIS2_PLACEMENT_3D('',#{},#{},$);\n#{}=PLANE('',#{});\n", base + 9, base + 7, base + 8, base + 10, base + 9);
|
||||
t += &format!("#{}=ADVANCED_FACE('',(#{}),#{},{sense});\n", base + 11, base + 6, base + 10);
|
||||
}
|
||||
t += "#130=OPEN_SHELL('',(#951,#971));\n#110=SHELL_BASED_SURFACE_MODEL('Bracket',(#130,#100));\n";
|
||||
t += &context(20, "(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))");
|
||||
t += "#60=MANIFOLD_SURFACE_SHAPE_REPRESENTATION('',(#110),#20);\n";
|
||||
t += TAIL;
|
||||
let imp = step_import(&t).unwrap_or_else(|e| panic!("{e}"));
|
||||
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
|
||||
let b = &imp.bodies[0];
|
||||
assert_eq!((b.source_item, b.file_faces, b.body.face_count()), (110, 8, 8), "{:?}", imp.warnings);
|
||||
assert!(b.refused.is_none(), "{:?}", b.refused);
|
||||
// Each face is the file's face it was: the sheet's first, then the cube's.
|
||||
assert_eq!(b.face_origin, [951, 971, 411, 431, 451, 471, 491, 511]);
|
||||
let m = measure(&b.body).unwrap();
|
||||
assert!(rel(m.area, 8.0) < 1e-9, "area {}", m.area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_written_part_in_inches_with_colour() {
|
||||
let mut t = String::from(HEAD);
|
||||
|
||||
Reference in New Issue
Block a user