Compare commits

...

4 Commits

Author SHA1 Message Date
PeppaWutz c18aaf7cc3 Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508) (#12601)
* Agents and scripts: Insert DXF, Insert SVG and Export DXF describe their exact parameters and reply (#12508)

sketch.insert_dxf, sketch.insert_svg and sketch.export_dxf now publish typed
input and output schemas. Each parameter is read the way its handler reads
it today and refused with the handler's own message, and the replies are
built from the same types the output schemas describe, so the JSON they
return does not change. Keys the handler reads only in some cases (a path
next to text, a height next to a width, a scale next to either) are read by
the check in the same cases only. Their lines leave unconverted.txt.

Part of #2829.

* Insert DXF and SVG: the schema refuses a blank or too-long path when it is read

* Insert DXF and SVG: the schema checks height, scale and path only when they are read

The published schema constrained `height` next to a `width`, `scale` next to
either, and `path` next to a `text`, which the handler never reads. Their shapes
now apply only in the cases the handler reads them, matching `leave_unread`.

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 02:38:51 +00:00
bherbruck 1e6b1ec20f STEP import: a surface model of open shells, some closed, beside a turned sheet imports again (#13226)
A SolidWorks 2011 vendor part (a SHELL_BASED_SURFACE_MODEL of three OPEN_SHELLs, two of
them closed, the third using faces against their surfaces) came in as nothing on the SMLib
kernel: "STEP import: no bodies (... not supported yet: SMLib renumbered the solid's faces)".
SMLib's export numbers such a body's edges and vertices otherwise than the reader, so the
face map was refused even though the faces were in the reader's order. Where the full
numbering does not match, the faces are now taken in the record's order when each one is
checked to be the record's face (same surface kind, loops, and corners).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:55:43 +00:00
PeppaWutz 4d96c780e1 Agents and scripts: the sketch projection commands describe their exact parameters and reply (#12505) (#12598)
* Agents and scripts: the sketch projection commands describe their exact parameters and reply (#12505)

sketch.project, intersect, include_3d, project_to_surface,
intersection_curve, spun_profile, isoparametric_curve,
fit_curves_to_section, break_link and auto_project now publish typed input
and output schemas. Each parameter is read the way the handler reads it
today and refused with the handler's own message, and the replies are built
from the same types the output schemas describe, so the JSON they return
does not change. Their lines leave unconverted.txt.

The shape check of a reference is split out of the handler's reference
reader so the contract can use it without changing what it accepts.

Part of #2829.

* Sort the schema module list for rustfmt

Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off).
2026-11-08 01:45:28 +00:00
github-actions[bot] 52afe212a8 Regenerate generated files and fold pending files [skip ci] 2026-11-08 01:37:34 +00:00
22 changed files with 2342 additions and 130 deletions
+1
View File
@@ -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
+2
View File
@@ -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": "الاختيار في موضع آخر من الوجه يضيف ثقبًا آخر، واختيار ثقب يزيله.",
+2
View File
@@ -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.",
+2
View File
@@ -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.",
+2
View File
@@ -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": "בחירה במקום אחר בפאה מוסיפה חור נוסף; בחירה בחור מסירה אותו.",
+2
View File
@@ -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": "面の別の場所を選ぶと穴を追加し、穴を選ぶとその穴を削除します。",
+2
View File
@@ -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": "一次创建的孔在历史中共用一个文件夹,初始为折叠;展开后可单独编辑每个孔。"
}
}
+2
View File
@@ -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ỗ đó.",
+2
View File
@@ -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": "在面的其他位置选择会再添加一个孔;选择孔会移除该孔。",
-3
View File
@@ -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 |
+1
View File
@@ -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;
+33 -10
View File
@@ -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)]
+136 -69
View File
@@ -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.
+2
View File
@@ -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;
+448
View File
@@ -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}");
}
+767
View File
@@ -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");
}
-13
View File
@@ -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
+48 -1
View File
@@ -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()?;
+63
View File
@@ -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);