From 2e4b728fca9e2ad8151dcebb26ea44043e4a032b Mon Sep 17 00:00:00 2001 From: PeppaWutz <141839514+LouisBunt@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:09:46 +0000 Subject: [PATCH] Agents and scripts: Inspect Silhouette and Export View DXF describe their exact parameters and reply (#12611) (#12636) * Agents and scripts: Inspect Silhouette and Export View DXF describe their exact parameters and reply (#12611) inspect.silhouette and file.export_view_dxf now publish typed input and output schemas. Each parameter is read the way the handler reads it today and refused with the handler's own message, and the replies are built from the same types the output schemas describe, so the JSON they return does not change. Their lines leave unconverted.txt. Part of #2829. Made with Claude Sonnet 5.5 (medium effort, fast mode off) through the Claude API, with Claude Opus 5.5 as advisor. * Export View DXF: read the triangle count through the meshes module The typed reply read `meshes.triangles` in `view_dxf.rs`, which `cargo xtask mesh-decisions` takes for a model decision on a mesh. The count is only reported in the reply's stats, so `view_dxf_meshes.rs`, the module that builds the drawing's display meshes, now hands it out. Made with Claude Opus 5.5 in Claude Code (Ultra plan, default effort, fast mode off, Ultracode off). --- crates/engine/src/cmd/silhouette.rs | 38 +- crates/engine/src/cmd/view_dxf.rs | 50 ++- crates/engine/src/cmd/view_dxf_meshes.rs | 7 + crates/engine/src/cmd/view_dxf_tests.rs | 1 + crates/engine/src/schema/mod.rs | 1 + crates/engine/src/schema/silhouette.rs | 417 +++++++++++++++++++ crates/engine/src/schema/silhouette_tests.rs | 299 +++++++++++++ crates/engine/src/schema/unconverted.txt | 2 - crates/engine/src/schema/view_navigation.rs | 4 +- 9 files changed, 788 insertions(+), 31 deletions(-) create mode 100644 crates/engine/src/schema/silhouette.rs create mode 100644 crates/engine/src/schema/silhouette_tests.rs 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()) }