diff --git a/crates/engine/src/cmd/silhouette.rs b/crates/engine/src/cmd/silhouette.rs index 2a0c377f10..fa943564f2 100644 --- a/crates/engine/src/cmd/silhouette.rs +++ b/crates/engine/src/cmd/silhouette.rs @@ -1,19 +1,27 @@ //! A tightly framed, view-aligned union of the projected body meshes. -use serde_json::{Value, json}; +use serde_json::Value; use solidcore_record::Vec3; use solidcraft_geom::prelude::*; use super::CommandSpec; use crate::params::bad; use crate::render::{Camera, StandardView}; +use crate::schema::silhouette::{SilhouetteParams, SilhouetteProjection, SilhouetteResult}; +use crate::schema::{TypedContract, output_schema_for}; use crate::{Result, Session}; const CMD: &str = "inspect.silhouette"; +const CONTRACT: TypedContract = TypedContract::new(); const MAX_PIXELS: usize = 4 * 1024 * 1024; // For agents comparing the model with a picture; nothing to show a person, so no toolbar button. -pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", run).noundo().icon("silhouette").params( +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Silhouette", crate::checked!("inspect.silhouette", run)) + .noundo() + .icon("silhouette") + .contract(&CONTRACT) + .output(output_schema_for::) + .params( "view?: front|back|left|right|top|bottom|iso (default front); or camera?: {target: [x,y,z], yaw, pitch, distance, fov?} (mm, radians; fov 0 means parallel); bodies?: [world body names] (default visible bodies, named hidden bodies included); rows?: mask height in pixels (default 240, clamped to 16..1024) → tightly framed run-length mask, box_mm, mm_per_px, right/up, projection, body and triangle counts", )]; @@ -23,18 +31,20 @@ fn run(s: &mut Session, p: &Value) -> Result { let wanted = body_names(p)?; let out = outline(s, &camera, wanted.as_deref(), rows)?; let mask: Vec> = out.mask.iter().map(|row| spans(row)).collect(); - Ok(json!({ - "width": out.width, - "height": out.height, - "mm_per_px": round4(out.mm_per_px), - "box_mm": [[round4(out.lo[0]), round4(out.lo[1])], [round4(out.hi[0]), round4(out.hi[1])]], - "right": [clean(out.right.x), clean(out.right.y), clean(out.right.z)], - "up": [clean(out.up.x), clean(out.up.y), clean(out.up.z)], - "projection": if out.perspective { "perspective" } else { "parallel" }, - "bodies": out.bodies, - "triangles": out.triangles, - "mask": mask, - })) + let reply = SilhouetteResult { + width: out.width, + height: out.height, + mm_per_px: round4(out.mm_per_px), + box_mm: [[round4(out.lo[0]), round4(out.lo[1])], [round4(out.hi[0]), round4(out.hi[1])]], + right: [clean(out.right.x), clean(out.right.y), clean(out.right.z)], + up: [clean(out.up.x), clean(out.up.y), clean(out.up.z)], + projection: if out.perspective { SilhouetteProjection::Perspective } else { SilhouetteProjection::Parallel }, + bodies: out.bodies, + triangles: out.triangles, + mask, + param_warnings: Vec::new(), + }; + serde_json::to_value(reply).map_err(|e| bad(CMD, e.to_string())) } /// A projected, tightly framed silhouette: the pixel grid, its scale and placement, and the diff --git a/crates/engine/src/cmd/view_dxf.rs b/crates/engine/src/cmd/view_dxf.rs index f48ab9def7..83ed14836d 100644 --- a/crates/engine/src/cmd/view_dxf.rs +++ b/crates/engine/src/cmd/view_dxf.rs @@ -10,19 +10,24 @@ mod projection; #[path = "view_dxf_sketch.rs"] pub(super) mod sketch; -use serde_json::{Value, json}; +use serde_json::Value; use solidcore_record::Vec2; use solidcraft_io::{DxfEntity, DxfLayer, DxfLinetype, DxfShape}; use solidcraft_render::Camera; use super::CommandSpec; use crate::params::{bad, str_}; +use crate::schema::silhouette::{DxfOutput, DxfProjection, DxfUnits, ExportViewDxfParams, ViewDxfResult, ViewDxfStats}; +use crate::schema::{TypedContract, output_schema_for}; use crate::{Result, Session}; const CMD: &str = "file.export_view_dxf"; -pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current View as DXF", export) +const CONTRACT: TypedContract = TypedContract::new(); +pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current View as DXF", crate::checked!("file.export_view_dxf", export)) .icon("export") .noundo() + .contract(&CONTRACT) + .output(output_schema_for::) .params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); path?: .dxf file (else returns dxf text). Visible placed bodies and shown sketches' profile curves (no construction geometry, points, dimensions or constraints); 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers (sketch curves on SKETCH), mm; finished sketches are hidden behind bodies, the active sketch is drawn on top; perspective projected onto the camera target plane; no viewport crop or overlays.")]; fn export(s: &mut Session, p: &Value) -> Result { @@ -63,8 +68,8 @@ fn export(s: &mut Session, p: &Value) -> Result { let drawing = projection::drawing(&meshes, &sketch::parts(s, tolerance).map_err(|e| bad(CMD, e))?, &cam).map_err(|e| bad(CMD, e))?; let writing = progress::Stage::new("dxf_write", 1); writing.check().map_err(|e| bad(CMD, e))?; - let mut visible = 0; - let mut hidden = 0; + let mut visible = 0usize; + let mut hidden = 0usize; let mut entities = Vec::new(); for line in drawing.lines { if line.hidden && !dashed { @@ -90,18 +95,37 @@ fn export(s: &mut Session, p: &Value) -> Result { } let text = solidcraft_io::write_dxf_drawing(&layers, &entities)?; writing.check().map_err(|e| bad(CMD, e))?; - let mut result = json!({"visible":visible,"hidden":hidden,"sketch":sketch,"units":"mm","tolerance":tolerance,"projection":if cam.fov > 0.0 {"perspective"} else {"orthographic"}}); - result["stats"] = json!({"bodies":meshes.placements.len(),"shared_meshes":meshes.meshes.len(),"triangles":meshes.triangles, - "mesh_bytes":meshes.bytes,"worker_limit":meshes::WORKERS,"segments":drawing.segments,"occlusion_work":drawing.work, - "prepare_ms":prepare_ms,"projection_ms":drawing.projection_ms,"occlusion_ms":drawing.occlusion_ms}); - if let Some(path) = path { + let stats = ViewDxfStats { + bodies: meshes.placements.len(), + shared_meshes: meshes.meshes.len(), + triangles: meshes.triangle_count(), + mesh_bytes: meshes.bytes, + worker_limit: meshes::WORKERS, + segments: drawing.segments, + occlusion_work: drawing.work, + prepare_ms, + projection_ms: drawing.projection_ms, + occlusion_ms: drawing.occlusion_ms, + }; + let output = if let Some(path) = path { solidcraft_io::vfs::check(path).map_err(|e| crate::EngineError::Other(e.to_string()))?; solidcraft_io::write_atomic(std::path::Path::new(path), text.as_bytes(), false).map_err(|e| crate::EngineError::Other(e.to_string()))?; - result["path"] = json!(path); + DxfOutput::File { path: path.to_string() } } else { - result["dxf"] = json!(text); - } - Ok(result) + DxfOutput::Text { dxf: text } + }; + let reply = ViewDxfResult { + visible, + hidden, + sketch, + units: DxfUnits::Millimetres, + tolerance, + projection: if cam.fov > 0.0 { DxfProjection::Perspective } else { DxfProjection::Orthographic }, + stats, + output, + param_warnings: Vec::new(), + }; + serde_json::to_value(reply).map_err(|e| bad(CMD, e.to_string())) } /// Sketch runs as entities: visible ones on SKETCH, hidden ones dashed on HIDDEN or omitted. diff --git a/crates/engine/src/cmd/view_dxf_meshes.rs b/crates/engine/src/cmd/view_dxf_meshes.rs index 2341336b8e..6d4b74172a 100644 --- a/crates/engine/src/cmd/view_dxf_meshes.rs +++ b/crates/engine/src/cmd/view_dxf_meshes.rs @@ -35,6 +35,13 @@ pub(super) struct Scene { pub bytes: usize, } +impl Scene { + /// How many triangles the drawing's meshes hold, for the export's reply. + pub(super) fn triangle_count(&self) -> usize { + self.triangles + } +} + struct Group { first: usize, inverse: Mat, diff --git a/crates/engine/src/cmd/view_dxf_tests.rs b/crates/engine/src/cmd/view_dxf_tests.rs index d9a9462bef..407d26eac3 100644 --- a/crates/engine/src/cmd/view_dxf_tests.rs +++ b/crates/engine/src/cmd/view_dxf_tests.rs @@ -1,4 +1,5 @@ use super::*; +use serde_json::json; use solidcore_record::{Vec2, Vec3}; use solidcraft_geom::prelude::*; use solidcraft_render::StandardView; diff --git a/crates/engine/src/schema/mod.rs b/crates/engine/src/schema/mod.rs index 91339b480d..818c6e875e 100644 --- a/crates/engine/src/schema/mod.rs +++ b/crates/engine/src/schema/mod.rs @@ -98,6 +98,7 @@ pub mod render_surface_resources; pub mod render_ui; pub mod section; pub mod sheet; +pub mod silhouette; pub mod sketch; pub mod sketch_clipboard; pub mod sketch_constraints; diff --git a/crates/engine/src/schema/silhouette.rs b/crates/engine/src/schema/silhouette.rs new file mode 100644 index 0000000000..c8e8e2f48d --- /dev/null +++ b/crates/engine/src/schema/silhouette.rs @@ -0,0 +1,417 @@ +//! Contracts and result types for `inspect.silhouette` (`cmd/silhouette.rs`) and +//! `file.export_view_dxf` (`cmd/view_dxf.rs`) (#12611). +//! +//! 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 body exists, or anything is visible, +//! stays the handler's to check. A key the handler refuses when it is left out (the DXF +//! export's `camera`) is optional in the type and required in the schema, so that the handler's +//! message, not the check's "is required", is what a caller sees. +//! +//! `inspect.silhouette` reads `view` or `camera`, and refuses the two together before it looks +//! at either, so its `Deserialize` is written out to do the same. + +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, json}; +use solidcraft_doc::NamedView; + +use super::check::{located, whole}; +use super::types::{CameraParam, not_blank_pattern}; +use super::view_navigation::{read_as, read_with}; +use crate::render::{Camera, StandardView}; + +/// The most body names `inspect.silhouette` reads from a list, as the handler does. +const MAX_BODIES: usize = 10_000; + +fn require(schema: &mut Schema, key: &str) { + super::motion::require(schema, key); +} + +fn camera_required(schema: &mut Schema) { + require(schema, "camera"); +} + +// ---- inspect.silhouette ---- + +/// A standard view's name, as `inspect.silhouette` reads `view`: text that names one of the +/// standard views, with the handler's messages for anything else. +fn view_name<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + match Value::deserialize(d)? { + Value::String(name) if StandardView::parse(&name).is_some() => Ok(Some(name)), + Value::String(name) => Err(D::Error::custom(whole(format!("unknown standard view `{name}`")))), + _ => Err(D::Error::custom(whole("`view` must be a standard view name"))), + } +} + +fn view_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "A standard view: front, back, left, right, top, bottom or iso (also home or isometric); upper or lower case. Default: front.", + "type": "string" + }) +} + +/// A camera as `inspect.silhouette` reads `camera`: an orbit camera `{target, yaw, pitch, +/// distance, fov?}` in mm and radians, finite and at a positive distance. Other keys (a full +/// camera's `roll` and `up`) are not read. +#[derive(Clone, Debug, PartialEq)] +pub struct SilhouetteCamera(pub NamedView); + +impl<'de> Deserialize<'de> for SilhouetteCamera { + fn deserialize>(d: D) -> Result { + let named: NamedView = serde_json::from_value(Value::deserialize(d)?).map_err(|e| D::Error::custom(whole(format!("bad camera: {e}"))))?; + let camera = + Camera { target: named.target, yaw: named.yaw, pitch: named.pitch, distance: named.distance, fov: named.fov, ..Default::default() }; + if !camera.is_valid() || !camera.fov.is_finite() || camera.fov < 0.0 { + return Err(D::Error::custom(whole("the camera must be finite with a positive distance"))); + } + Ok(SilhouetteCamera(named)) + } +} + +impl JsonSchema for SilhouetteCamera { + fn schema_name() -> Cow<'static, str> { + "SilhouetteCamera".into() + } + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "An orbit camera, in mm and radians. Other keys are ignored.", + "type": "object", + "properties": { + "target": {"description": "The point the camera looks at, in mm: [x, y, z].", "type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}, + "yaw": {"description": "Turn about the vertical axis; 0 looks along +Y, from the front.", "type": "number"}, + "pitch": {"description": "Elevation above the ground plane.", "type": "number"}, + "distance": {"description": "From the target to the eye, in mm.", "type": "number", "exclusiveMinimum": 0}, + "fov": {"description": "Vertical field of view; 0 is parallel. Default: 0.", "type": "number", "minimum": 0, "default": 0.0} + }, + "required": ["target", "yaw", "pitch", "distance"] + }) + } +} + +fn silhouette_camera<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + SilhouetteCamera::deserialize(d).map(Some) +} + +/// The bodies `inspect.silhouette` outlines: one name, or a list of up to 10000. +#[derive(Clone, Debug, PartialEq)] +pub struct BodyNames(pub Vec); + +impl<'de> Deserialize<'de> for BodyNames { + fn deserialize>(d: D) -> Result { + match Value::deserialize(d)? { + Value::String(name) => Ok(BodyNames(vec![name])), + Value::Array(names) if names.len() <= MAX_BODIES => names + .into_iter() + .map(|v| match v { + Value::String(name) => Ok(name), + _ => Err(D::Error::custom(whole("`bodies` must contain names"))), + }) + .collect::>() + .map(BodyNames), + _ => Err(D::Error::custom(whole("`bodies` must be a list of names"))), + } + } +} + +impl JsonSchema for BodyNames { + fn schema_name() -> Cow<'static, str> { + "BodyNames".into() + } + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "One body name, or a list of up to 10000.", + "anyOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}, "maxItems": MAX_BODIES} + ] + }) + } +} + +fn body_names<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + BodyNames::deserialize(d).map(Some) +} + +/// `rows` as the handler reads it: any integer (it is clamped, not refused), one above `i64`'s +/// range read as the largest. +fn rows<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + read_with(d, "`rows` must be an integer", |v| v.as_i64().or_else(|| v.as_u64().map(|n| n.min(i64::MAX as u64) as i64))) +} + +fn rows_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "The mask's height in pixels, a whole number written without a decimal point (240, not 240.0); clamped to 16..1024. Default: 240.", + "type": "integer", + "default": 240 + }) +} + +/// The keys `inspect.silhouette` reads, once `view` and `camera` are known not to come together. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SilhouetteKeys { + /// A standard view to look from. Not with `camera`. Default: front. + #[serde(default, deserialize_with = "view_name", skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "view_schema")] + pub view: Option, + /// A camera to look from, in mm and radians; `fov` 0 means parallel. Not with `view`. + #[serde(default, deserialize_with = "silhouette_camera", skip_serializing_if = "Option::is_none")] + #[schemars(with = "SilhouetteCamera")] + pub camera: Option, + /// The world bodies to outline, by name; a named hidden body is included. Default: the + /// visible bodies. + #[serde(default, deserialize_with = "body_names", skip_serializing_if = "Option::is_none")] + #[schemars(with = "BodyNames")] + pub bodies: Option, + /// The mask's height in pixels, clamped to 16..1024. Default: 240. + #[serde(default, deserialize_with = "rows", skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "rows_schema")] + pub rows: Option, +} + +/// A tightly framed, view-aligned union of the projected body meshes, as a run-length mask the +/// size of the model in the view: for an agent comparing the model with a picture. It changes +/// nothing in the design. +/// +/// `inspect.silhouette`'s parameters: [`SilhouetteKeys`], with `view` and `camera` together +/// refused first, as the handler does. +#[derive(Debug)] +pub struct SilhouetteParams(pub SilhouetteKeys); + +impl<'de> Deserialize<'de> for SilhouetteParams { + fn deserialize>(d: D) -> Result { + let value = Value::deserialize(d)?; + if value.get("view").is_some() && value.get("camera").is_some() { + return Err(D::Error::custom(whole("give `view` or `camera`, not both"))); + } + serde_path_to_error::deserialize(&value).map(SilhouetteParams).map_err(|e| D::Error::custom(located(&e))) + } +} + +impl JsonSchema for SilhouetteParams { + fn schema_name() -> Cow<'static, str> { + "SilhouetteParams".into() + } + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + let mut schema = SilhouetteKeys::json_schema(generator); + schema.insert( + "description".into(), + "A tightly framed, view-aligned union of the projected body meshes, as a run-length mask. Changes nothing in the design.".into(), + ); + schema.insert("not".into(), json!({"required": ["view", "camera"]})); + schema + } +} + +/// How the silhouette is projected. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SilhouetteProjection { + Perspective, + Parallel, +} + +/// What `inspect.silhouette` returns. +#[derive(Debug, Serialize, JsonSchema)] +pub struct SilhouetteResult { + /// The mask's width in pixels. + pub width: usize, + /// The mask's height in pixels. + pub height: usize, + /// The size of a pixel in the view plane, in mm. + pub mm_per_px: f64, + /// The view-plane box the mask frames, in mm: `[[u_min, v_min], [u_max, v_max]]`, u along + /// `right` and v along `up`. + pub box_mm: [[f64; 2]; 2], + /// The world direction of the mask's x axis. + pub right: [f64; 3], + /// The world direction of the mask's rows going up. + pub up: [f64; 3], + pub projection: SilhouetteProjection, + /// How many bodies are in the outline. + pub bodies: usize, + /// How many triangles they have. + pub triangles: usize, + /// One entry per pixel row, top first: the filled runs of that row as `[start, end)` pixel + /// columns. + pub mask: Vec>, + /// 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, +} + +// ---- file.export_view_dxf ---- + +/// A camera as `file.export_view_dxf` reads `camera`: as the view commands read one, and also +/// with yaw and roll within ±1e6 and pitch within ±π/2 (else "invalid camera"). +fn dxf_camera<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + read_with(d, "invalid camera", |v| { + crate::cmd::camera_form(v) + .filter(|c| c.yaw.abs() <= 1e6 && c.roll.abs() <= 1e6 && c.pitch.abs() <= std::f64::consts::FRAC_PI_2) + .map(CameraParam) + }) +} + +/// Whether hidden lines are drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HiddenLines { + /// Leave them out. + Omit, + /// Draw them dashed on the HIDDEN layer. + Dashed, +} + +fn hidden_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "`omit` leaves hidden lines out; `dashed` draws them dashed on the HIDDEN layer. Default: omit.", + "enum": ["omit", "dashed"], + "default": "omit" + }) +} + +fn hidden<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + read_as(d, "hidden must be omit or dashed") +} + +fn tolerance<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + read_with(d, "tolerance must be 0.0001..1 mm", |v| v.as_f64().filter(|t| (0.0001..=1.0).contains(t))) +} + +fn tolerance_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "The chord tolerance for curves, in mm: 0.0001 to 1. Default: 0.01.", + "type": "number", + "minimum": 0.0001, + "maximum": 1.0, + "default": 0.01 + }) +} + +/// The `.dxf` file's path, as the handler reads `path`: text with something in it, under 4096 +/// bytes; `null` is refused. +fn export_path<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + read_with(d, "invalid path", |v| v.as_str().filter(|p| !p.trim().is_empty() && p.len() < 4096).map(str::to_string)) +} + +fn path_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "The `.dxf` file to write: not blank, under 4096 bytes (maxLength counts characters). Default: the reply carries the DXF text instead.", + "type": "string", + "pattern": not_blank_pattern(), + "maxLength": 4095 + }) +} + +/// Draw the visible placed bodies and the shown sketches' profile curves from a camera as a +/// 2D DXF drawing in mm: LINE and LWPOLYLINE entities on the VISIBLE and HIDDEN layers, sketch +/// curves on SKETCH. No construction geometry, points, dimensions or constraints; finished +/// sketches are hidden behind bodies and the active sketch is drawn on top; a perspective +/// camera is projected onto the target plane; there is no viewport crop and no overlay. Writes +/// the file when `path` is given, else returns the text. +#[derive(Debug, Deserialize, JsonSchema)] +#[schemars(deny_unknown_fields, transform = camera_required)] +pub struct ExportViewDxfParams { + // Optional in the type and required in the schema (`camera_required`). + /// The camera to draw from, as `ui.inspect` gives it. Its yaw and roll must also be within + /// ±1e6 and its pitch within ±π/2. + #[serde(default, deserialize_with = "dxf_camera", skip_serializing_if = "Option::is_none")] + #[schemars(with = "CameraParam")] + pub camera: Option, + /// Whether to draw the hidden lines (dashed) or leave them out. Default: omit. + #[serde(default, deserialize_with = "hidden", skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "hidden_schema")] + pub hidden: Option, + /// The chord tolerance for curves, in mm: 0.0001 to 1. Default: 0.01. + #[serde(default, deserialize_with = "tolerance", skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "tolerance_schema")] + pub tolerance: Option, + /// The `.dxf` file to write. Default: the reply carries the DXF text instead. + #[serde(default, deserialize_with = "export_path", skip_serializing_if = "Option::is_none")] + #[schemars(schema_with = "path_schema")] + pub path: Option, +} + +/// How the drawing is projected. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum DxfProjection { + Perspective, + Orthographic, +} + +/// The drawing's unit. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)] +pub enum DxfUnits { + #[serde(rename = "mm")] + Millimetres, +} + +/// What the export measured while it ran. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ViewDxfStats { + /// The placed bodies drawn. + pub bodies: usize, + /// The distinct meshes among them (placements that share geometry share a mesh). + pub shared_meshes: usize, + /// The triangles of those meshes. + pub triangles: usize, + /// The memory the meshes take, in bytes. + pub mesh_bytes: usize, + /// The most worker threads the meshing used. + pub worker_limit: usize, + /// The projected edge segments. + pub segments: usize, + /// The work the hidden-line test took. + pub occlusion_work: usize, + /// The time to mesh the bodies, in ms. + pub prepare_ms: f64, + /// The time to project them, in ms. + pub projection_ms: f64, + /// The time for the hidden-line test, in ms. + pub occlusion_ms: f64, +} + +/// Where the drawing went. +#[derive(Debug, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum DxfOutput { + /// With `path`: the file written. + File { + /// The path the drawing was written to, as given. + path: String, + }, + /// Without `path`: the DXF text. + Text { + /// The drawing, as DXF text. + dxf: String, + }, +} + +/// What `file.export_view_dxf` returns. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ViewDxfResult { + /// The lines found visible, before neighbouring ones are joined into polylines. + pub visible: usize, + /// The hidden lines drawn: always 0 unless `hidden` is dashed. + pub hidden: usize, + /// The entities drawn on the SKETCH layer. + pub sketch: usize, + pub units: DxfUnits, + /// The chord tolerance used, in mm. + pub tolerance: f64, + pub projection: DxfProjection, + pub stats: ViewDxfStats, + #[serde(flatten)] + pub output: DxfOutput, + /// 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, +} + +#[cfg(test)] +#[path = "silhouette_tests.rs"] +mod tests; diff --git a/crates/engine/src/schema/silhouette_tests.rs b/crates/engine/src/schema/silhouette_tests.rs new file mode 100644 index 0000000000..8645c34f49 --- /dev/null +++ b/crates/engine/src/schema/silhouette_tests.rs @@ -0,0 +1,299 @@ +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use super::{BodyNames, ExportViewDxfParams, SilhouetteCamera, SilhouetteParams, SilhouetteResult, ViewDxfResult}; +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}; + +const SIL: &str = "inspect.silhouette"; +const DXF: &str = "file.export_view_dxf"; + +fn camera() -> Value { + json!({"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 100, "fov": 0}) +} + +/// A session with one box in it, so a call the handlers accept runs to its reply. +fn with_box(level: ParamCheck) -> Session { + let mut s = Session { param_check: level, ..Session::default() }; + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 10, "body_name": "Plate"})).unwrap_or_else(|e| panic!("{e}")); + s +} + +fn matches(schema: schemars::Schema, id: &str, reply: &Value) { + let schema = schema.as_value(); + assert!(conforms(schema, schema, reply), "{id}: {reply} doesn't match {schema}"); +} + +#[test] +fn schemas_and_the_key_check_agree() { + assert_keys(input_schema_for::(), &["bodies", "camera", "rows", "view"]); + assert_keys(input_schema_for::(), &["camera", "hidden", "path", "tolerance"]); +} + +/// Strict gives the handlers' messages for the calls they refuse for a key (a missing `camera` +/// included: the handler's words, not "is required"). +#[test] +fn strict_keeps_todays_errors() { + let long = vec!["Plate"; 10_001]; + assert_strict_keeps_todays_errors(&[ + (SIL, json!({"view": "front", "camera": camera()})), + (SIL, json!({"view": null, "camera": camera()})), + (SIL, json!({"view": 3})), + (SIL, json!({"view": null})), + (SIL, json!({"view": ["top"]})), + (SIL, json!({"view": "sideways"})), + (SIL, json!({"camera": null})), + (SIL, json!({"camera": "front"})), + (SIL, json!({"camera": {}})), + (SIL, json!({"camera": {"target": [0, 0], "yaw": 0, "pitch": 0, "distance": 5}})), + (SIL, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}})), + (SIL, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": -4}})), + (SIL, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 5, "fov": -1}})), + (SIL, json!({"camera": {"target": [0, 0, 0], "yaw": "0", "pitch": 0, "distance": 5}})), + (SIL, json!({"bodies": null})), + (SIL, json!({"bodies": 7})), + (SIL, json!({"bodies": {"a": 1}})), + (SIL, json!({"bodies": ["Plate", 2]})), + (SIL, json!({"bodies": long})), + (SIL, json!({"rows": null})), + (SIL, json!({"rows": "5"})), + (SIL, json!({"rows": 24.5})), + (SIL, json!({"rows": true})), + (DXF, json!({})), + (DXF, json!({"camera": null})), + (DXF, json!({"camera": "top"})), + (DXF, json!({"camera": {}})), + (DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0, "fov": 0}})), + (DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 2, "distance": 1, "fov": 0}})), + (DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 2e6, "pitch": 0, "distance": 1, "fov": 0}})), + (DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "roll": 2e6, "distance": 1, "fov": 0}})), + (DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 1, "fov": 3.2}})), + (DXF, json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 1}})), + (DXF, json!({"camera": camera(), "hidden": "dash"})), + (DXF, json!({"camera": camera(), "hidden": "Omit"})), + (DXF, json!({"camera": camera(), "hidden": null})), + (DXF, json!({"camera": camera(), "hidden": true})), + (DXF, json!({"camera": camera(), "tolerance": 0})), + (DXF, json!({"camera": camera(), "tolerance": 0.00001})), + (DXF, json!({"camera": camera(), "tolerance": 1.5})), + (DXF, json!({"camera": camera(), "tolerance": "0.1"})), + (DXF, json!({"camera": camera(), "tolerance": null})), + (DXF, json!({"camera": camera(), "path": ""})), + (DXF, json!({"camera": camera(), "path": " \t"})), + (DXF, json!({"camera": camera(), "path": 5})), + (DXF, json!({"camera": camera(), "path": null})), + (DXF, json!({"camera": camera(), "path": "x".repeat(4096)})), + ]); +} + +/// The contracts read each key as the handlers do: for every call, what runs today is not +/// refused in strict or warned about in warn, and what fails today fails in strict with the same +/// message. (A call the handler accepts but the contract refused would fail one of the three.) +#[test] +fn the_types_read_each_key_as_the_handlers_do() { + let dir = std::env::temp_dir().join(format!("solidcraft-dxf-schema-read-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}")); + let file = dir.join("read.dxf"); + let named = |extra: Value| { + let mut c = camera(); + for (k, v) in extra.as_object().into_iter().flatten() { + c[k] = v.clone(); + } + c + }; + let cases = [ + (SIL, json!({})), + (SIL, Value::Null), + (SIL, json!({"view": "front"})), + (SIL, json!({"view": "TOP"})), + (SIL, json!({"view": "Home"})), + (SIL, json!({"view": "isometric"})), + (SIL, json!({"view": "iso", "rows": 16})), + (SIL, json!({"camera": camera()})), + (SIL, json!({"camera": named(json!({"fov": 0.6}))})), + (SIL, json!({"camera": {"target": [10, 10, 10], "yaw": 0.5, "pitch": 0.5, "distance": 300}})), + (SIL, json!({"camera": named(json!({"name": "mine", "roll": 9, "up": "y", "extra": [1]}))})), + (SIL, json!({"camera": named(json!({"pitch": 7, "yaw": 1e9}))})), + (SIL, json!({"camera": named(json!({"fov": 3.5}))})), + (SIL, json!({"camera": named(json!({"distance": 1e-9}))})), + (SIL, json!({"bodies": "Plate"})), + (SIL, json!({"bodies": ["Plate"]})), + (SIL, json!({"bodies": []})), + (SIL, json!({"bodies": "Nope"})), + (SIL, json!({"rows": 0})), + (SIL, json!({"rows": -5})), + (SIL, json!({"rows": 5000})), + (SIL, json!({"rows": 18446744073709551615u64})), + (SIL, json!({"rows": 1e3})), + (DXF, json!({"camera": camera()})), + (DXF, json!({"camera": named(json!({"fov": 0.5}))})), + (DXF, json!({"camera": named(json!({"roll": 1, "up": "y", "pitch": 1.5}))})), + (DXF, json!({"camera": named(json!({"pitch": std::f64::consts::FRAC_PI_2, "yaw": 1e6, "roll": -1e6}))})), + (DXF, json!({"camera": named(json!({"fov": 3.1}))})), + (DXF, json!({"camera": camera(), "hidden": "omit"})), + (DXF, json!({"camera": camera(), "hidden": "dashed", "tolerance": 1})), + (DXF, json!({"camera": camera(), "tolerance": 0.0001})), + (DXF, json!({"camera": camera(), "tolerance": 1.0})), + (DXF, json!({"camera": camera(), "path": file})), + ]; + for (id, p) in cases { + let run = |level: ParamCheck| with_box(level).execute(id, &p).map_err(|e| e.to_string()); + let (off, warn, strict) = (run(ParamCheck::Off), run(ParamCheck::Warn), run(ParamCheck::Strict)); + match &off { + Ok(_) => { + let warned = warn.as_ref().ok().and_then(|r| r.get("param_warnings")); + assert!(warned.is_none(), "{id} {p}: {warned:?}"); + assert!(strict.is_ok(), "{id} {p}: works today, strict says {strict:?}"); + } + Err(e) => assert_eq!(strict.as_ref().err(), Some(e), "{id} {p}"), + } + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// The types themselves: a camera, a list of bodies and the message for each refusal. +#[test] +fn the_camera_and_body_types_read_like_the_handler() { + let camera = |v: Value| serde_json::from_value::(v).map_err(|e| e.to_string()); + assert!(camera(json!({"target": [1, 2, 3], "yaw": 0.1, "pitch": 0.2, "distance": 5})).is_ok()); + assert!(camera(json!({"target": [1, 2, 3], "yaw": 0.1, "pitch": 0.2, "distance": 5, "fov": 0, "roll": 4})).is_ok()); + for bad in [json!(null), json!([]), json!({}), json!({"target": [1, 2, 3], "yaw": 0, "pitch": 0})] { + assert!(camera(bad.clone()).is_err_and(|e| e.contains("bad camera: ")), "{bad}"); + } + assert!(camera(json!({"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0})).is_err_and(|e| e.contains("positive distance"))); + let bodies = |v: Value| serde_json::from_value::(v).ok().map(|b| b.0); + assert_eq!(bodies(json!("A")), Some(vec!["A".to_string()])); + assert_eq!(bodies(json!(["A", "B"])), Some(vec!["A".to_string(), "B".to_string()])); + assert_eq!(bodies(json!([])), Some(Vec::new())); + assert_eq!(bodies(json!(vec!["x"; 10_000])).map(|b| b.len()), Some(10_000)); + assert!(bodies(json!(vec!["x"; 10_001])).is_none() && bodies(json!([1])).is_none() && bodies(json!(null)).is_none()); +} + +/// What the contracts take in the schemas too: the camera is required of the export, `view` and +/// `camera` do not come together, and the plain-typed keys. +#[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}"); + } + }; + takes( + input_schema_for::(), + &[ + (json!({}), true), + (json!({"view": "top", "bodies": ["Plate"], "rows": 100}), true), + (json!({"camera": camera(), "bodies": "Plate"}), true), + (json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 5}}), true), + (json!({"view": "top", "camera": camera()}), false), + (json!({"view": 3}), false), + (json!({"camera": {"target": [0, 0], "yaw": 0, "pitch": 0, "distance": 5}}), false), + (json!({"camera": {"target": [0, 0, 0], "yaw": 0, "pitch": 0, "distance": 0}}), false), + (json!({"bodies": [1]}), false), + (json!({"rows": 2.5}), false), + (json!({"colour": 1}), false), + ], + ); + takes( + input_schema_for::(), + &[ + (json!({"camera": camera()}), true), + (json!({"camera": camera(), "hidden": "dashed", "tolerance": 0.5, "path": "a.dxf"}), true), + (json!({}), false), + (json!({"camera": camera(), "hidden": "both"}), false), + (json!({"camera": camera(), "tolerance": 2}), false), + (json!({"camera": camera(), "tolerance": 0}), false), + (json!({"camera": camera(), "pathh": "a.dxf"}), false), + ], + ); +} + +/// The warning for what a key gets wrong, in the handlers' words. +#[test] +fn the_check_names_what_is_wrong_with_a_key() { + let issues = |id: &str, p: Value| crate::schema::issues(id, &p).into_iter().map(|i| i.message).collect::>(); + assert_eq!(issues(SIL, json!({"view": "top", "camera": camera()})), ["give `view` or `camera`, not both"]); + assert_eq!(issues(SIL, json!({"view": "sideways"})), ["unknown standard view `sideways`"]); + assert_eq!(issues(SIL, json!({"rows": 1.5})), ["`rows` must be an integer"]); + assert_eq!(issues(SIL, json!({"bodies": [1]})), ["`bodies` must contain names"]); + assert_eq!(issues(DXF, json!({"camera": camera(), "hidden": 1})), ["hidden must be omit or dashed"]); + assert_eq!(issues(DXF, json!({"camera": camera(), "tolerance": 3})), ["tolerance must be 0.0001..1 mm"]); + assert_eq!(issues(DXF, json!({"camera": camera(), "path": " "})), ["invalid path"]); + assert_eq!(issues(DXF, json!({"camera": 1})), ["invalid camera"]); + // Left out, the handler's own refusal stands in for "is required". + assert!(issues(DXF, json!({})).is_empty()); +} + +fn folder() -> PathBuf { + let dir = std::env::temp_dir().join(format!("solidcraft-silhouette-schema-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("{e}")); + dir +} + +/// Every form of both commands, in Warn: each real reply matches its output schema and carries +/// no warning. A misspelt key still runs, says so, and the reply still matches; strict refuses. +#[test] +fn replies_match_the_output_schema() { + let mut s = with_box(ParamCheck::Warn); + s.execute("solid.sphere", &json!({"radius": 5, "center": [60, 0, 0], "body_name": "Ball"})).unwrap_or_else(|e| panic!("{e}")); + let dir = folder(); + 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 + }; + for p in [ + json!({}), + json!({"view": "top", "rows": 40}), + json!({"view": "ISO", "bodies": "Plate"}), + json!({"view": "right", "bodies": ["Plate", "Ball"], "rows": 18446744073709551615u64}), + json!({"camera": {"target": [20, 15, 5], "yaw": 0.4, "pitch": 0.3, "distance": 300}, "rows": 32}), + json!({"camera": {"target": [20, 15, 5], "yaw": 0.4, "pitch": 0.3, "distance": 300, "fov": 0.6}, "bodies": ["Plate"]}), + ] { + let r = run(&mut s, SIL, p.clone()); + matches(output_schema_for::(), SIL, &r); + let projection = if p["camera"]["fov"].as_f64().is_some_and(|f| f > 0.0) { "perspective" } else { "parallel" }; + assert_eq!(r["projection"], projection, "{p}"); + assert!(r["mask"].as_array().is_some_and(|m| !m.is_empty()), "{r}"); + assert_eq!(r.as_object().map(|o| o.len()), Some(10), "{r}"); + } + let target = json!([20, 15, 5]); + let top = json!({"target": target, "yaw": 0, "pitch": std::f64::consts::FRAC_PI_2, "distance": 150, "fov": 0}); + let file = dir.join("top.dxf"); + for p in [ + json!({"camera": top}), + json!({"camera": top, "hidden": "omit", "tolerance": 0.5}), + json!({"camera": top, "hidden": "dashed", "tolerance": 0.0001}), + json!({"camera": {"target": target, "yaw": 0.5, "pitch": 0.5, "distance": 200, "fov": 0.5}, "hidden": "dashed"}), + json!({"camera": top, "path": file}), + ] { + let r = run(&mut s, DXF, p.clone()); + matches(output_schema_for::(), DXF, &r); + assert_eq!(r["units"], "mm", "{r}"); + if p.get("path").is_some() { + assert!(r.get("dxf").is_none() && r["path"] == json!(file), "{r}"); + assert!(std::fs::read_to_string(&file).is_ok_and(|t| t.contains("LINE"))); + } else { + assert!(r.get("path").is_none() && r["dxf"].as_str().is_some_and(|t| t.contains("LINE")), "{r}"); + } + assert_eq!(r["projection"], if p["camera"]["fov"].as_f64().is_some_and(|f| f > 0.0) { "perspective" } else { "orthographic" }, "{r}"); + } + // A misspelt key runs in warn and says so; the reply still matches. Strict refuses it. + for (id, p, schema) in [ + (SIL, json!({"rowz": 40}), output_schema_for::()), + (DXF, json!({"camera": top, "tolerence": 0.5}), output_schema_for::()), + ] { + let r = s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")); + assert!(r["param_warnings"].as_array().is_some_and(|w| w.len() == 1), "{r}"); + assert!(conforms(schema.as_value(), schema.as_value(), &r), "{id}: {r}"); + s.param_check = ParamCheck::Strict; + let error = s.execute(id, &p).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(error.contains("unknown key"), "{id}: {error}"); + s.param_check = ParamCheck::Warn; + } + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/schema/unconverted.txt b/crates/engine/src/schema/unconverted.txt index 220047baf2..c9300b512b 100644 --- a/crates/engine/src/schema/unconverted.txt +++ b/crates/engine/src/schema/unconverted.txt @@ -30,7 +30,6 @@ explode.show expr.evaluate face.delete file.export -file.export_view_dxf file.insert_mesh file.insert_step file.new @@ -44,7 +43,6 @@ frame.profile.list frame.profile.properties inspect.interference inspect.measure -inspect.silhouette joint.as_built joint.cam joint.create diff --git a/crates/engine/src/schema/view_navigation.rs b/crates/engine/src/schema/view_navigation.rs index 9bf9386d25..0831241e26 100644 --- a/crates/engine/src/schema/view_navigation.rs +++ b/crates/engine/src/schema/view_navigation.rs @@ -322,13 +322,13 @@ fn nullable<'de, D: Deserializer<'de>>(d: D) -> Result>, D /// A key's value as its handler reads it: `read` takes the raw JSON value, and anything it /// refuses gets the handler's own message, whole (no key put in front). -fn read_with<'de, D: Deserializer<'de>, T>(d: D, message: &str, read: impl FnOnce(&Value) -> Option) -> Result, D::Error> { +pub(super) fn read_with<'de, D: Deserializer<'de>, T>(d: D, message: &str, read: impl FnOnce(&Value) -> Option) -> Result, D::Error> { let value = Value::deserialize(d)?; read(&value).map(Some).ok_or_else(|| serde::de::Error::custom(whole(message))) } /// A value `T` deserializes from, or the handler's message. -fn read_as<'de, D: Deserializer<'de>, T: serde::de::DeserializeOwned>(d: D, message: &str) -> Result, D::Error> { +pub(super) fn read_as<'de, D: Deserializer<'de>, T: serde::de::DeserializeOwned>(d: D, message: &str) -> Result, D::Error> { read_with(d, message, |v| serde_json::from_value(v.clone()).ok()) }