mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 04:52:10 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2605261665 |
@@ -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
|
||||
|
||||
@@ -318,7 +318,7 @@ fn selected_row_paints_tab_soft_fill_and_accent_text_in_both_layouts() {
|
||||
let mut app = SolveApp::new(Default::default(), Default::default());
|
||||
app.ui.layout = mode;
|
||||
app.ui.tab = tab.into();
|
||||
crate::theme::TabAccent::publish(&app, &ctx);
|
||||
app.publish_tab_accent(&ctx);
|
||||
let accent = crate::theme::TabAccent::for_tab(&Tokens::get(), tab);
|
||||
let mut row_rect = Rect::NOTHING;
|
||||
let out = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| {
|
||||
@@ -677,7 +677,7 @@ fn collapsed_rail_shares_header_hover_inset_and_expands_from_its_whole_card() {
|
||||
app.ui.tab = "SURFACE".into();
|
||||
app.preferences.timeline_left = true;
|
||||
app.preferences.timeline_left_browser_collapsed = collapsed;
|
||||
crate::theme::TabAccent::publish(&app, &ctx);
|
||||
app.publish_tab_accent(&ctx);
|
||||
let bounds = Rect::from_min_size(Pos2::ZERO, vec2(600.0, 700.0));
|
||||
let draw = |app: &mut SolveApp, events| {
|
||||
crate::test_support::frame(&ctx, egui::RawInput { screen_rect: Some(bounds), events, ..Default::default() }, |ui| {
|
||||
|
||||
@@ -837,6 +837,12 @@ impl Drop for SolveApp {
|
||||
}
|
||||
|
||||
impl SolveApp {
|
||||
/// Publish this frame's tab accent from the open tab, the sketch being edited and the active
|
||||
/// tool or dialog, the three values [`theme::TabAccent::publish`] takes.
|
||||
pub(crate) fn publish_tab_accent(&self, ctx: &egui::Context) {
|
||||
theme::TabAccent::publish(&self.ui.tab, self.session.active_sketch.is_some(), self.tool.is_some() || self.dialog.is_some(), ctx);
|
||||
}
|
||||
|
||||
pub fn new(mut session: Session, services: Services) -> Self {
|
||||
#[cfg(test)]
|
||||
let image_host = Some(solidcraft_engine::render::job::CpuHost::cooperative());
|
||||
@@ -3276,7 +3282,7 @@ impl SolveApp {
|
||||
theme::apply(ctx);
|
||||
self.styled = true;
|
||||
}
|
||||
theme::TabAccent::publish(self, ctx);
|
||||
self.publish_tab_accent(ctx);
|
||||
if !self.fitted {
|
||||
self.fit_view();
|
||||
self.fitted = true;
|
||||
@@ -3520,7 +3526,7 @@ impl SolveApp {
|
||||
if !floating && !self.embedded {
|
||||
toolbar::toolbar(self, ui);
|
||||
}
|
||||
theme::TabAccent::publish(self, ui.ctx());
|
||||
self.publish_tab_accent(ui.ctx());
|
||||
browser::publish_row_prefs(self, ui.ctx());
|
||||
if !floating {
|
||||
agent_chat::show(self, ui);
|
||||
@@ -3573,7 +3579,7 @@ impl SolveApp {
|
||||
self.show_workspace(&mut viewport_ui);
|
||||
shell::paint_top_corners(ui.painter(), full, theme::Tokens::get().app_bar);
|
||||
toolbar::toolbar(self, ui);
|
||||
theme::TabAccent::publish(self, ui.ctx());
|
||||
self.publish_tab_accent(ui.ctx());
|
||||
}
|
||||
if floating && show_timeline && !left_timeline {
|
||||
timeline::timeline(self, ui);
|
||||
|
||||
@@ -975,7 +975,7 @@ impl crate::SolveApp {
|
||||
self.shell.history_dock_space = None;
|
||||
self.shell.history_folding = None;
|
||||
self.shell.history_fold_motion = None;
|
||||
crate::theme::TabAccent::publish(self, ui.ctx());
|
||||
self.publish_tab_accent(ui.ctx());
|
||||
crate::browser::publish_row_prefs(self, ui.ctx());
|
||||
let mut placement = render_domain;
|
||||
self.shell.status_bar = if crate::status_bar::shown(self) {
|
||||
|
||||
@@ -519,7 +519,7 @@ fn stage_catalogs_fill_a_290_by_530_panel_and_keep_two_line_labels_inside_tiles(
|
||||
h.app.preferences.render_browser_side = "left".into();
|
||||
super::super::select_image_method(&mut h.app, method);
|
||||
library::open_category(&mut h.app, kind);
|
||||
crate::theme::TabAccent::publish(&h.app, &h.ctx);
|
||||
h.app.publish_tab_accent(&h.ctx);
|
||||
let document = h.app.session.doc.to_json();
|
||||
let history = (h.app.session.undo.len(), h.app.session.redo.len());
|
||||
// Panel::workspace reserves an 8 pt bottom gap in these bounds.
|
||||
|
||||
+28
-29
@@ -20,7 +20,7 @@ thread_local! {
|
||||
/// must not change one another's palette while a frame or icon texture is being built.
|
||||
static DARK: Cell<bool> = const { Cell::new(true) };
|
||||
/// The colour vision mode, per thread. Each app
|
||||
/// sets it from its own preferences at the start of every frame (`SolveApp::logic`), on the
|
||||
/// sets it from its own preferences at the start of every frame (in its frame logic), on the
|
||||
/// thread that draws it. Tests run many apps side by side on their own threads in one process:
|
||||
/// with one global, a test in a mode changed the colours under another one
|
||||
/// (`hover_stays_off_during_spin_and_animated_views` failed that way). No worker thread reads
|
||||
@@ -580,7 +580,11 @@ pub(crate) fn scoped_tokens(tokens: Tokens) -> impl Drop {
|
||||
}
|
||||
|
||||
/// Temporarily bind a panel's paint palette without publishing a theme change.
|
||||
pub(crate) fn scoped_panel_tokens(tokens: Tokens) -> impl Drop {
|
||||
///
|
||||
/// `Tokens::get_in` returns these tokens first until the returned guard drops, which restores
|
||||
/// the previous override. A host uses it to draw any component in another palette or accent
|
||||
/// (for example another workspace's) without editing the component.
|
||||
pub fn scoped_panel_tokens(tokens: Tokens) -> impl Drop {
|
||||
struct Restore {
|
||||
previous: Option<Tokens>,
|
||||
// The override belongs to this UI thread, so its guard must not move to another one.
|
||||
@@ -791,11 +795,13 @@ impl TabAccent {
|
||||
ctx.data(|d| d.get_temp::<Option<&'static str>>(egui::Id::unique("sc_tab_icon_accent"))).flatten()
|
||||
}
|
||||
|
||||
pub fn publish(app: &crate::SolveApp, ctx: &egui::Context) {
|
||||
/// Publish the accent of `tab` for this frame. `sketch_open` is whether a sketch is being
|
||||
/// edited, `tool_active` whether a tool or dialog is open; both only steer focus painting.
|
||||
pub fn publish(tab: &str, sketch_open: bool, tool_active: bool, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
let (published, accent, fading, style_surfaces) = ctx.data_mut(|data| {
|
||||
let state = data.get_temp_mut_or_default::<TabThemeState>(egui::Id::unique(TAB_STATE));
|
||||
let (published, accent, fading) = state.publish(&app.ui.tab, now);
|
||||
let (published, accent, fading) = state.publish(tab, now);
|
||||
let style_surfaces = published.tab_surfaces || state.styled_surfaces;
|
||||
state.styled_surfaces = published.tab_surfaces;
|
||||
(published, accent, fading, style_surfaces)
|
||||
@@ -818,9 +824,9 @@ impl TabAccent {
|
||||
}
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(egui::Id::unique("sc_tab_accent"), accent);
|
||||
d.insert_temp(egui::Id::unique("sc_tab_icon_accent"), icon_accent_for_tab(&app.ui.tab));
|
||||
d.insert_temp(egui::Id::unique("sc_sketch_open"), app.session.active_sketch.is_some());
|
||||
d.insert_temp(egui::Id::unique("sc_tool_accent"), app.tool.is_some() || app.dialog.is_some());
|
||||
d.insert_temp(egui::Id::unique("sc_tab_icon_accent"), icon_accent_for_tab(tab));
|
||||
d.insert_temp(egui::Id::unique("sc_sketch_open"), sketch_open);
|
||||
d.insert_temp(egui::Id::unique("sc_tool_accent"), tool_active);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1958,15 +1964,13 @@ mod tests {
|
||||
#[test]
|
||||
fn tabs_publish_requests_repaints_only_during_fade_and_zero_is_instant() {
|
||||
use crate::theme_files::OptionValue;
|
||||
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
let ctx = crate::fonts::test_context();
|
||||
let mut state = tabs_state("dark", BTreeMap::new());
|
||||
state.publish("MODEL", 0.0);
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::unique(TAB_STATE), state));
|
||||
app.ui.tab = "SKETCH".into();
|
||||
let frame = |time| {
|
||||
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| {
|
||||
TabAccent::publish(&app, ui.ctx());
|
||||
TabAccent::publish("SKETCH", false, false, ui.ctx());
|
||||
crate::gpu::paint_background(ui.painter(), ui.max_rect(), &Tokens::get());
|
||||
});
|
||||
let colors: Vec<_> = out
|
||||
@@ -2175,7 +2179,7 @@ mod tests {
|
||||
themes.extend(crate::theme_files::builtin().iter().map(|theme| (theme.name.as_str(), crate::theme_files::resolve(theme))));
|
||||
let mut failures = Vec::new();
|
||||
for (name, tokens) in themes {
|
||||
for tab in crate::workspace::TABS.iter().copied().chain(["SKETCH", "MESH", "FORM", "SHEET METAL", "RENDER"]) {
|
||||
for tab in super::TAB_NAMES.iter().copied() {
|
||||
let accent = super::TabAccent::for_tab(&tokens, tab);
|
||||
let ratio = super::contrast_ratio(accent.ink, accent.soft);
|
||||
if ratio < 4.5 {
|
||||
@@ -2209,20 +2213,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tab_accent_context_is_per_app_and_does_not_change_widget_visuals() {
|
||||
let mut harness = crate::scenario::Harness::new();
|
||||
let app = &mut harness.app;
|
||||
let first = crate::fonts::test_context();
|
||||
let second = crate::fonts::test_context();
|
||||
let before = first.style_of(egui::Theme::Dark).visuals.clone();
|
||||
app.ui.tab = "SURFACE".into();
|
||||
super::TabAccent::publish(app, &first);
|
||||
super::TabAccent::publish("SURFACE", false, false, &first);
|
||||
assert_eq!(super::TabAccent::current(&first).color, super::Tokens::get().surface_fill);
|
||||
assert_eq!(super::TabAccent::current(&second).color, super::Tokens::get().accent);
|
||||
assert_eq!(super::TabAccent::current_icon_accent(&first), Some("surface"));
|
||||
assert_eq!(super::TabAccent::current_icon_accent(&second), None);
|
||||
assert_eq!(first.style_of(egui::Theme::Dark).visuals, before, "dialog OK, checkboxes and status styles must not inherit the tab");
|
||||
app.ui.tab = "MODEL".into();
|
||||
super::TabAccent::publish(app, &first);
|
||||
super::TabAccent::publish("MODEL", false, false, &first);
|
||||
assert_eq!(super::TabAccent::current(&first).soft, super::Tokens::get().accent_soft);
|
||||
assert_eq!(super::TabAccent::current_icon_accent(&first), None);
|
||||
}
|
||||
@@ -2232,7 +2232,6 @@ mod tests {
|
||||
use crate::theme_files::{OptionValue, ThemeFile};
|
||||
for base in ["dark", "light"] {
|
||||
let ctx = crate::fonts::test_context();
|
||||
let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
|
||||
let file = crate::theme_files::tab_fixture(base);
|
||||
set_dark(file.base.is_dark());
|
||||
set_vision(ColorVision::Off);
|
||||
@@ -2247,19 +2246,19 @@ mod tests {
|
||||
});
|
||||
set_custom(custom)
|
||||
};
|
||||
let publish = |app: &crate::SolveApp, time| {
|
||||
ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| TabAccent::publish(app, ui.ctx())).textures_delta.clear();
|
||||
let publish = |tab: &str, time| {
|
||||
ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| TabAccent::publish(tab, false, false, ui.ctx()))
|
||||
.textures_delta
|
||||
.clear();
|
||||
};
|
||||
prepare(&file, Some(true));
|
||||
apply(&ctx);
|
||||
let before = ctx.global_style().visuals.clone();
|
||||
let base_custom = CUSTOM.get();
|
||||
app.ui.tab = "MODEL".into();
|
||||
publish(&app, 0.0);
|
||||
publish("MODEL", 0.0);
|
||||
assert_eq!(ctx.global_style().visuals, before);
|
||||
app.ui.tab = "SKETCH".into();
|
||||
publish(&app, 1.0);
|
||||
publish(&app, 1.12);
|
||||
publish("SKETCH", 1.0);
|
||||
publish("SKETCH", 1.12);
|
||||
let panel = Tokens::get().panel;
|
||||
assert_ne!(panel, before.panel_fill, "exercise an actual animated panel fill");
|
||||
let mut animated = before.clone();
|
||||
@@ -2268,24 +2267,24 @@ mod tests {
|
||||
assert_eq!(ctx.global_style().visuals, animated, "all non-surface widget/selection/status visuals stay unchanged");
|
||||
// Repair either owned fill independently, even when the panel already matches.
|
||||
ctx.global_style_mut(|style| style.visuals.window_fill = before.window_fill);
|
||||
publish(&app, 1.12);
|
||||
publish("SKETCH", 1.12);
|
||||
assert_eq!(ctx.global_style().visuals, animated);
|
||||
|
||||
assert!(!prepare(&file, Some(false)), "toggling surfaces does not change the base Custom tokens");
|
||||
assert_eq!(CUSTOM.get(), base_custom);
|
||||
// Deliberately omit apply(): a full restyle would conceal stale animated fills.
|
||||
publish(&app, 1.13);
|
||||
publish("SKETCH", 1.13);
|
||||
assert_eq!(ctx.global_style().visuals, before, "turning surfaces off must restore both base fills");
|
||||
|
||||
assert!(!prepare(&file, Some(true)));
|
||||
publish(&app, 2.0);
|
||||
publish("SKETCH", 2.0);
|
||||
assert_ne!(ctx.global_style().visuals.panel_fill, before.panel_fill);
|
||||
let mut plain = file.clone();
|
||||
plain.tabs.clear();
|
||||
plain.options.clear();
|
||||
assert!(!prepare(&plain, None), "a no-option theme can retain the exact same base tokens");
|
||||
assert_eq!(CUSTOM.get(), base_custom);
|
||||
publish(&app, 2.1);
|
||||
publish("SKETCH", 2.1);
|
||||
assert_eq!(ctx.global_style().visuals, before, "restoring a no-option theme must release the surface fills");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1216,7 +1216,7 @@ const ELEMENTS: &[Element] = &[
|
||||
let (r, _) = ui.allocate_exact_size(vec2(360.0, 360.0), egui::Sense::hover());
|
||||
let mut app = crate::test_support::sample_app();
|
||||
app.ui.tab = "SURFACE".into();
|
||||
crate::theme::TabAccent::publish(&app, ui.ctx());
|
||||
app.publish_tab_accent(ui.ctx());
|
||||
radial_menu(ui, r.center(), &crate::context_menu::radial_items(&app), Some(3));
|
||||
}),
|
||||
element!("workspace_menu", |ui, _| {
|
||||
@@ -2773,7 +2773,7 @@ fn confirm_radial_and_history_rows_keep_multitone_artwork() {
|
||||
ctx.set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() });
|
||||
let mut app = crate::SolveApp::new(Default::default(), Default::default());
|
||||
app.ui.tab = tab.into();
|
||||
crate::theme::TabAccent::publish(&app, &ctx);
|
||||
app.publish_tab_accent(&ctx);
|
||||
let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(600.0, 360.0));
|
||||
let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(bounds), ..Default::default() }, |ui| {
|
||||
ui.painter().rect_filled(bounds, 0.0, Tokens::get().panel);
|
||||
|
||||
@@ -888,7 +888,7 @@ fn sketch_previews_selection_band_and_number_focus_follow_sketch_accent() {
|
||||
}
|
||||
h.app.ui.tab = "MODEL".into(); // A sketch remains open when another ribbon tab is selected.
|
||||
h.app.tool = crate::tools::Tool::for_command("sketch.line");
|
||||
crate::theme::TabAccent::publish(&h.app, &h.ctx);
|
||||
h.app.publish_tab_accent(&h.ctx);
|
||||
let output = draw(&h.ctx, |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut String::from("20")));
|
||||
r.request_focus();
|
||||
|
||||
Reference in New Issue
Block a user