Compare commits

..

1 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
9 changed files with 837 additions and 79 deletions
+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)]
+1
View File
@@ -110,6 +110,7 @@ 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;
+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}");
}
-3
View File
@@ -67,9 +67,6 @@ 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
+7 -4
View File
@@ -1185,8 +1185,11 @@ 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 two triangles a face (a flat face needs no interior
/// nodes), 12 triangles on its 8 corners (unwelded, 36), and one normal per face.
/// 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.
#[test]
fn obj_export_is_welded_and_keeps_names() {
let (_, m) = model();
@@ -1197,10 +1200,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 ")), (8, 6, 12), "{o}");
assert_eq!((count("v "), count("vn "), count("f ")), (64, 6, 124), "{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..=8).contains(i)));
assert!(ids.iter().all(|i| (1..=64).contains(i)));
}
/// The OBJ export carries the colours of the bodies: the mesh file names the material
+7 -15
View File
@@ -179,12 +179,7 @@ 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);
// 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 edge_h: Vec<f64> = edge_faces.iter().map(|fs| fs.iter().map(|&f| face_h(f)).fold(rules.max_len, 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)| {
@@ -1032,18 +1027,15 @@ 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 = || {
// 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 seeds = 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: if bare { None } else { Some((sag, MAX_REFINE)) },
refine: Some((sag, MAX_REFINE)),
shape: !matches!(s, EvalSurface::Plane { .. }),
lenient: lenient == Lenient::Yes,
};
@@ -1051,6 +1043,7 @@ 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()?
@@ -1058,7 +1051,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 || bare { None } else { grid::quad(&uvs) }) {
let structured = match (ranges.len(), if seamed { 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.
@@ -1083,8 +1076,7 @@ fn plan_face(
_ => None,
};
let structured = match structured {
// A flat face with no size asked of it is its boundary alone (the Delaunay below).
None if ranges.len() == 1 && !seamed && !bare => {
None if ranges.len() == 1 && !seamed => {
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))
}
@@ -1116,7 +1108,7 @@ fn plan_face(
let trimmed = if seamed || strip {
None
} else if planar {
if ranges.len() == 1 && h.is_finite() { plan_pieces(&face) } else { None }
if ranges.len() == 1 { plan_pieces(&face) } else { None }
} else {
plan_pieces(&face).or_else(|| {
let traces = motorcycle::traces(&uvs, &ranges, speed, 0.25 * h);
+7 -12
View File
@@ -1,9 +1,7 @@
//! 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 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
//! A 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.
@@ -109,12 +107,11 @@ 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 (f64::INFINITY, [du.len(), dv.len()]);
return (rules.max_len, [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 flat { 3 } else { SAMPLES };
let samples = if planar(s) { 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()]))
@@ -144,9 +141,7 @@ 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] };
// 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 }))
(rules.for_curvature(k_max), 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
@@ -216,7 +211,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, f64::INFINITY);
assert_eq!(h, rules.max_len);
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);
@@ -237,9 +232,9 @@ mod tests {
}
#[test]
fn a_plane_has_no_size_cap() {
fn a_plane_takes_the_longest_side() {
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, f64::INFINITY);
assert_eq!(face_size(&s, &super::super::uv::domain(&s), &rules).0, 20.0);
}
}
+21 -35
View File
@@ -69,45 +69,31 @@ fn cut(a: &Body, b: &Body) -> Body {
const PI: f64 = std::f64::consts::PI;
/// 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");
/// 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());
}
}
}
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 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");
}
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));
}
#[test]