mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 04:52:10 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5774d27c2f |
@@ -6,48 +6,29 @@ 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::{Analysis, CurveKind, LinkKind, LinkSource, Sketch};
|
||||
use solidcraft_sketch::{CurveKind, LinkKind, LinkSource, Sketch};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::sketch::{Left, edit_solved, ids_of};
|
||||
use super::sketch::{edit, 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", crate::checked!("sketch.insert_dxf", insert_dxf))
|
||||
CommandSpec::new("sketch.insert_dxf", "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", crate::checked!("sketch.insert_svg", insert_svg))
|
||||
CommandSpec::new("sketch.insert_svg", "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", crate::checked!("sketch.export_dxf", export_dxf))
|
||||
.icon("sketch_dxf")
|
||||
CommandSpec::new("sketch.export_dxf", "Save As 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)"),
|
||||
];
|
||||
|
||||
@@ -270,13 +251,10 @@ 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());
|
||||
}
|
||||
// `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 ((curves, texts), info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let (cs, texts) = add_geometry(sk, &geom, &f, k, tol)?;
|
||||
Ok(((ids_of(sk, &cs), texts), Left::Unsolved))
|
||||
Ok((ids_of(sk, &cs), texts))
|
||||
})?;
|
||||
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;
|
||||
@@ -301,7 +279,7 @@ fn insert(s: &mut Session, p: &Value, cmd: &str, svg: bool) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
reply(cmd, DrawingInserted { sketch: id, created_sketch: created, curves: curves.len(), texts: n_text, info, param_warnings: Vec::new() })
|
||||
Ok(json!({"sketch": id, "created_sketch": created, "curves": curves.len(), "texts": n_text, "info": info}))
|
||||
}
|
||||
|
||||
fn insert_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -544,10 +522,9 @@ 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}")))?;
|
||||
let exported = DxfExported::Written { path: path.to_string(), entities: entity_count };
|
||||
return reply(cmd, ExportDxfResult { exported, param_warnings: Vec::new() });
|
||||
return Ok(json!({"path": path, "entities": entity_count}));
|
||||
}
|
||||
reply(cmd, ExportDxfResult { exported: DxfExported::Text { dxf: text, entities: entity_count }, param_warnings: Vec::new() })
|
||||
Ok(json!({"dxf": text, "entities": entity_count}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -110,7 +110,6 @@ 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;
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
//! 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;
|
||||
@@ -1,313 +0,0 @@
|
||||
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}");
|
||||
}
|
||||
@@ -67,6 +67,9 @@ sketch.dimension_text
|
||||
sketch.drag
|
||||
sketch.drag_end
|
||||
sketch.draw.dimensioned
|
||||
sketch.export_dxf
|
||||
sketch.insert_dxf
|
||||
sketch.insert_svg
|
||||
sketch.move_point
|
||||
sketch.options
|
||||
sketch.polygon.circumscribed
|
||||
|
||||
@@ -1185,11 +1185,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// OBJ shares the vertices faces meet at and keeps the body name as written, spaces included.
|
||||
/// The 10 × 20 × 30 box meshes as the planner's grids, no side longer than a fifth of its
|
||||
/// extent (the 37.4 mm diagonal): its edges in 2, 3 and 5 pieces, so its faces are grids of
|
||||
/// 2 × 3, 2 × 5 and 3 × 5 cells, 2 · (6 + 10 + 15) · 2 = 124 triangles. Welded, their
|
||||
/// corners are the 3 · 4 · 6 = 72 nodes of the box's lattice less the 1 · 2 · 4 inside it: 64
|
||||
/// positions (unwelded, 372), and one normal per face.
|
||||
/// The 10 × 20 × 30 box meshes as two triangles a face (a flat face needs no interior
|
||||
/// nodes), 12 triangles on its 8 corners (unwelded, 36), and one normal per face.
|
||||
#[test]
|
||||
fn obj_export_is_welded_and_keeps_names() {
|
||||
let (_, m) = model();
|
||||
@@ -1200,10 +1197,10 @@ mod tests {
|
||||
let o = String::from_utf8(export(&st, &[], Format::Obj, "t").unwrap()).unwrap();
|
||||
let count = |p: &str| o.lines().filter(|l| l.starts_with(p)).count();
|
||||
assert!(o.contains("\no Red Block\n"), "{o}");
|
||||
assert_eq!((count("v "), count("vn "), count("f ")), (64, 6, 124), "{o}");
|
||||
assert_eq!((count("v "), count("vn "), count("f ")), (8, 6, 12), "{o}");
|
||||
let ids: Vec<usize> =
|
||||
o.lines().filter(|l| l.starts_with("f ")).flat_map(|l| l.split([' ', '/']).skip(1)).filter_map(|x| x.parse().ok()).collect();
|
||||
assert!(ids.iter().all(|i| (1..=64).contains(i)));
|
||||
assert!(ids.iter().all(|i| (1..=8).contains(i)));
|
||||
}
|
||||
|
||||
/// The OBJ export carries the colours of the bodies: the mesh file names the material
|
||||
|
||||
@@ -179,7 +179,12 @@ pub(crate) fn plan(b: &brep::Brep, tol: f64, turn: Option<size::Turn>) -> Result
|
||||
.into_iter()
|
||||
.collect::<Result<_>>()?;
|
||||
let face_h = |f: usize| sizes.get(f).map_or(rules.max_len, |(h, _)| *h);
|
||||
let edge_h: Vec<f64> = edge_faces.iter().map(|fs| fs.iter().map(|&f| face_h(f)).fold(rules.max_len, f64::min)).collect();
|
||||
// A flat face asks for no size (infinite): an edge between flat faces is whole, one beside
|
||||
// a curved face takes the curved face's size, so both meet at the same nodes.
|
||||
let edge_h: Vec<f64> = edge_faces
|
||||
.iter()
|
||||
.map(|fs| if fs.is_empty() { rules.max_len } else { fs.iter().map(|&f| face_h(f)).fold(f64::INFINITY, f64::min) })
|
||||
.collect();
|
||||
let jobs: Vec<(usize, f64)> = edge_h.into_iter().enumerate().collect();
|
||||
let solved = b.edges.iter().filter(|e| matches!(e.curve, Curve::SurfaceIntersection { .. })).count();
|
||||
let mut nodes: Vec<edges::Nodes> = crate::par::map("edge nodes", &jobs, solved >= PARALLEL_CURVES || jobs.len() >= PARALLEL_EDGES, |(e, h)| {
|
||||
@@ -1027,15 +1032,18 @@ fn plan_face(
|
||||
// Constrained Delaunay in (u, v) scaled to millimetres: equilateral seeds of side `h`,
|
||||
// refined where triangles leave the surface, are too long or bend too much, then shaped by
|
||||
// Ruppert refinement.
|
||||
let planar = matches!(s, EvalSurface::Plane { .. });
|
||||
let bare = planar && h.is_infinite();
|
||||
let polys: Vec<&[[f64; 2]]> = ranges.iter().filter_map(|r| uvs.get(r.clone())).collect();
|
||||
let step = [h / speed[0], h / speed[1]];
|
||||
let delaunay = || {
|
||||
let seeds = cdt::lattice(bbox, step, &polys, speed);
|
||||
// A flat face with no size asked of it: the boundary nodes alone, no interior seeds.
|
||||
let seeds = if bare { Vec::new() } else { cdt::lattice(bbox, step, &polys, speed) };
|
||||
let eval = |u: f64, v: f64| s.derivs(u, v).map(|(p, su, sv)| (p, su.cross(sv).normalized()));
|
||||
let sag = cdt::Sag { eval: &eval, tol, h, turn: rules.turn, scale: speed };
|
||||
let fill = cdt::Fill {
|
||||
seeds: &seeds,
|
||||
refine: Some((sag, MAX_REFINE)),
|
||||
refine: if bare { None } else { Some((sag, MAX_REFINE)) },
|
||||
shape: !matches!(s, EvalSurface::Plane { .. }),
|
||||
lenient: lenient == Lenient::Yes,
|
||||
};
|
||||
@@ -1043,7 +1051,6 @@ fn plan_face(
|
||||
};
|
||||
// Planar and curved faces alike: a structured grid where the boundary runs along the
|
||||
// parameter box (a rectangle, a cylinder half), a Coons grid on four corners, else Delaunay.
|
||||
let planar = matches!(s, EvalSurface::Plane { .. });
|
||||
// The lenient fill is the constrained Delaunay alone: the caller names the face in the stats.
|
||||
let (extra, triangles) = if lenient == Lenient::Yes {
|
||||
delaunay()?
|
||||
@@ -1051,7 +1058,7 @@ fn plan_face(
|
||||
let mut us = uv::divide(s, 0, bbox[0], bbox[1], rules, h, MAX_LINES);
|
||||
let mut vs = uv::divide(s, 1, bbox[1], bbox[0], rules, h, MAX_LINES);
|
||||
grid::thin(&mut us, &mut vs, grid::MAX_FACE_GRID);
|
||||
let structured = match (ranges.len(), if seamed { None } else { grid::quad(&uvs) }) {
|
||||
let structured = match (ranges.len(), if seamed || bare { None } else { grid::quad(&uvs) }) {
|
||||
(1, Some(q)) => {
|
||||
// Interior lines: the boundary's own where opposite sides agree (refined where the
|
||||
// surface turns more between them inside the face), else the division.
|
||||
@@ -1076,7 +1083,8 @@ fn plan_face(
|
||||
_ => None,
|
||||
};
|
||||
let structured = match structured {
|
||||
None if ranges.len() == 1 && !seamed => {
|
||||
// A flat face with no size asked of it is its boundary alone (the Delaunay below).
|
||||
None if ranges.len() == 1 && !seamed && !bare => {
|
||||
let fine = |a: [f64; 2], b: [f64; 2]| uv::fine_between(s, rules, h, a, b);
|
||||
coons::coons(&uvs, &fixed, speed, grid::MAX_FACE_GRID, h, Some(&fine))
|
||||
}
|
||||
@@ -1108,7 +1116,7 @@ fn plan_face(
|
||||
let trimmed = if seamed || strip {
|
||||
None
|
||||
} else if planar {
|
||||
if ranges.len() == 1 { plan_pieces(&face) } else { None }
|
||||
if ranges.len() == 1 && h.is_finite() { plan_pieces(&face) } else { None }
|
||||
} else {
|
||||
plan_pieces(&face).or_else(|| {
|
||||
let traces = motorcycle::traces(&uvs, &ranges, speed, 0.25 * h);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Target triangle size: one side length per face, so triangles come out about equilateral
|
||||
//! (well-shaped triangles shade evenly) and as small as the surface needs.
|
||||
//!
|
||||
//! A face's size is the smallest of the body's longest side, the length over which the surface
|
||||
//! A flat face (a plane, or a spline lying in one) has no size: any triangulation of its
|
||||
//! boundary is exact, so it gets no interior cells and its straight edges no extra nodes.
|
||||
//! A curved face's size is the smallest of the body's longest side, the length over which the surface
|
||||
//! normal turns the caller's facet angle (when one is given, see [`Turn`]), and the chord that
|
||||
//! sags the tolerance; an edge takes the smaller size of its two faces, so a curved face and
|
||||
//! its flat neighbour meet at the same spacing.
|
||||
@@ -107,11 +109,12 @@ pub(crate) fn extent(points: impl Iterator<Item = Vec3>) -> f64 {
|
||||
/// so the size is never larger than the face needs).
|
||||
pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> (f64, [f64; 2]) {
|
||||
if let EvalSurface::Plane { du, dv, .. } = s {
|
||||
return (rules.max_len, [du.len(), dv.len()]);
|
||||
return (f64::INFINITY, [du.len(), dv.len()]);
|
||||
}
|
||||
let (Some(ru), Some(rv)) = (dom.range[0], dom.range[1]) else { return (rules.max_len, [1.0, 1.0]) };
|
||||
let flat = planar(s);
|
||||
// A flat spline (sheet-metal walls, chamfers): no curvature to look for, only the speeds.
|
||||
let samples = if planar(s) { 3 } else { SAMPLES };
|
||||
let samples = if flat { 3 } else { SAMPLES };
|
||||
let normal = |u: f64, v: f64| -> Option<(Vec3, Vec3, [f64; 2])> {
|
||||
let (p, su, sv) = s.derivs(u, v)?;
|
||||
Some((p, su.cross(sv).normalized()?, [su.len(), sv.len()]))
|
||||
@@ -141,7 +144,9 @@ pub(crate) fn face_size(s: &EvalSurface, dom: &Domain, rules: &Rules) -> (f64, [
|
||||
}
|
||||
}
|
||||
let speed = if count > 0 { [speed[0] / count as f64, speed[1] / count as f64] } else { [1.0, 1.0] };
|
||||
(rules.for_curvature(k_max), speed.map(|x| if x.is_finite() && x > 0.0 { x } else { 1.0 }))
|
||||
// A flat face is exact for any triangulation of its boundary: no length cap.
|
||||
let size = if flat { f64::INFINITY } else { rules.for_curvature(k_max) };
|
||||
(size, speed.map(|x| if x.is_finite() && x > 0.0 { x } else { 1.0 }))
|
||||
}
|
||||
|
||||
/// A spline surface whose control points lie in one plane, with positive weights: the
|
||||
@@ -211,7 +216,7 @@ mod tests {
|
||||
assert!(!planar(&patch(0.5)));
|
||||
let rules = Rules::new(0.01, 100.0, THIRTY);
|
||||
let (h, speed) = face_size(&patch(0.0), &super::super::uv::domain(&patch(0.0)), &rules);
|
||||
assert_eq!(h, rules.max_len);
|
||||
assert_eq!(h, f64::INFINITY);
|
||||
assert!((speed[0] - 4.0).abs() < 1e-9 && (speed[1] - 3.0).abs() < 1e-9, "{speed:?}");
|
||||
// The twisted patch (a hyperbolic paraboloid) has curvature: a smaller side.
|
||||
assert!(face_size(&patch(0.5), &super::super::uv::domain(&patch(0.5)), &rules).0 < rules.max_len);
|
||||
@@ -232,9 +237,9 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plane_takes_the_longest_side() {
|
||||
fn a_plane_has_no_size_cap() {
|
||||
let s = EvalSurface::Plane { origin: Vec3::ZERO, du: Vec3::X, dv: Vec3::Y };
|
||||
let rules = Rules::new(0.01, 100.0, THIRTY);
|
||||
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, 20.0);
|
||||
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, f64::INFINITY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,31 +69,45 @@ fn cut(a: &Body, b: &Body) -> Body {
|
||||
|
||||
const PI: f64 = std::f64::consts::PI;
|
||||
|
||||
/// Smallest corner angle (degrees) over the mesh.
|
||||
fn min_angle(m: &Mesh) -> f64 {
|
||||
let mut least = 180.0f64;
|
||||
for t in &m.triangles {
|
||||
let [a, b, c] = t.map(|i| m.positions[i as usize]);
|
||||
for (p, q, r) in [(a, b, c), (b, c, a), (c, a, b)] {
|
||||
let (u, v) = (q - p, r - p);
|
||||
if let (Some(u), Some(v)) = (u.normalized(), v.normalized()) {
|
||||
least = least.min(u.dot(v).clamp(-1.0, 1.0).acos().to_degrees());
|
||||
}
|
||||
}
|
||||
/// A flat face is exact for any triangulation of its boundary, so a box is two triangles a
|
||||
/// face whatever its size (it was a grid capped at a fifth of the body's diagonal: 124
|
||||
/// triangles for 20 x 10 x 5).
|
||||
#[test]
|
||||
fn a_box_is_two_triangles_a_face() {
|
||||
for (name, size) in [("20 x 10 x 5", Vec3::new(20.0, 10.0, 5.0)), ("40 x 30 x 20", Vec3::new(40.0, 30.0, 20.0))] {
|
||||
let b = ok(crate::box_solid(Vec3::ZERO, size));
|
||||
let (m, s) = plan_body(&b, 0.05);
|
||||
assert_eq!(s.planar, 6);
|
||||
assert_watertight(&m, name);
|
||||
assert_eq!(m.triangles.len(), 12, "{name}: {:?}", (0..6).map(|i| m.tri_face.iter().filter(|t| **t == i).count()).collect::<Vec<_>>());
|
||||
assert!(max_chord(&b, &m) <= 1e-9, "{name}: flat faces are exact");
|
||||
}
|
||||
least
|
||||
}
|
||||
|
||||
/// Flat faces beside curved ones stay joined: the straight edge between a plane and a fillet
|
||||
/// takes the fillet's spacing, and the plane's boundary uses those nodes.
|
||||
#[test]
|
||||
fn a_box_is_a_regular_grid_on_every_face() {
|
||||
// 40 × 30 × 20: sides at most 1/5 of the diagonal (10.77 mm), so 4 × 3 × 2 cells.
|
||||
let b = ok(crate::box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)));
|
||||
let (m, s) = plan_body(&b, 0.05);
|
||||
assert_eq!(s.planar, 6);
|
||||
assert_watertight(&m, "box");
|
||||
let cells = 2 * (4 * 3 + 4 * 2 + 3 * 2);
|
||||
assert_eq!(m.triangles.len(), 2 * cells, "two triangles per grid cell");
|
||||
assert!(min_angle(&m) > 40.0, "{}", min_angle(&m));
|
||||
fn flat_faces_beside_curved_ones_stay_watertight_and_within_the_chord() {
|
||||
let tol = 0.01;
|
||||
let bx = ok(crate::box_solid(Vec3::ZERO, Vec3::new(20.0, 10.0, 5.0)));
|
||||
let rounded = ok(crate::fillet(&bx, &[Vec3::new(10.0, 0.0, 5.0), Vec3::new(20.0, 5.0, 5.0), Vec3::new(10.0, 10.0, 5.0), Vec3::new(0.0, 5.0, 5.0)], 1.5));
|
||||
let pin = ok(crate::cylinder(Vec3::new(10.0, 5.0, -1.0), Vec3::Z, 2.0, 7.0));
|
||||
let holed = cut(&bx, &pin);
|
||||
for (name, body) in [("filleted box", &rounded), ("box with a hole", &holed)] {
|
||||
let m = closed_display_mesh(name, body, tol);
|
||||
let chord = max_chord(body, &m);
|
||||
assert!(chord <= tol * 1.05, "{name}: chord {chord}");
|
||||
// The flat faces carry no interior nodes: far fewer triangles than a grid at the
|
||||
// curved neighbours' spacing would give.
|
||||
let (flat, curved) = (0..body.face_count()).fold((0, 0), |(f, c), i| {
|
||||
let n = m.tri_face.iter().filter(|t| **t as usize == i).count();
|
||||
match crate::face_surface(body, i) {
|
||||
Some(crate::FaceSurface::Plane { .. }) => (f + n, c),
|
||||
_ => (f, c + n),
|
||||
}
|
||||
});
|
||||
assert!(flat < curved, "{name}: {flat} flat vs {curved} curved triangles");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user