diff --git a/README.md b/README.md index d906bcc..38f929a 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,19 @@ cadcraft-cli mcp --connect 127.0.0.1:7979 cadcraft-cli run --sample --script 'CIRCLE 22,3 1\n' --save out.dxf --export out.png cadcraft-cli info drawing.dxf cadcraft-cli convert drawing.dxf drawing.svg + +# Frame part of the drawing (model space, drawing units, lower-left corner first): +cadcraft-cli convert drawing.dwg detail.png --window 0,0,20000,15000 +cadcraft-cli run drawing.dwg --cmd 'zoom {"mode":"window","p1":[0,0],"p2":[20000,15000]}' --export detail.png +cadcraft-cli run drawing.dwg --cmd 'select {"window":[[0,0],[20000,15000]]}' --cmd 'zoom {"mode":"object"}' --export sel.png ``` +How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the model-space extents. +`run --export` shows the current view when the script or commands changed it (`zoom`, `pan`, +`view.set`, …) and otherwise fits the extents too. `--window x0,y0,x1,y1` shows exactly that +rectangle, centred and scaled without distortion; `--view extents` or `--view current` forces +either framing. To frame a selection set, `zoom {"mode":"object"}` after selecting. + MCP tools include `command_line` (type at the prompt), `execute` (any command with JSON), `inspect_drawing`, `query_entities`, `render` (returns a PNG) and, when connected to the app, `screenshot` and `ui_click`. The desktop app's JSON control channel is documented in diff --git a/apps/cadcraft-cli/src/main.rs b/apps/cadcraft-cli/src/main.rs index 3f1ba80..4d583cd 100644 --- a/apps/cadcraft-cli/src/main.rs +++ b/apps/cadcraft-cli/src/main.rs @@ -2,8 +2,8 @@ //! //! ```text //! cadcraft-cli info FILE.dxf summary as JSON -//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) convert / export -//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png] +//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) [--window x0,y0,x1,y1 | --view extents|current] +//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png] [--window ... | --view ...] //! cadcraft-cli commands [FILTER] command catalog (JSON) //! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app) //! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing @@ -13,18 +13,118 @@ use std::process::ExitCode; -use cadcraft_engine::Session; +use cadcraft_engine::doc::Space; +use cadcraft_engine::geom::{Bounds2, Vec2}; +use cadcraft_engine::{Session, View}; use serde_json::{Value, json}; const USAGE: &str = "usage: cadcraft-cli info FILE.dxf - cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) - cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg)] + cadcraft-cli convert IN.dxf OUT.(dxf|dwg|svg|png|pdf) [FRAMING] + cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING] cadcraft-cli commands [FILTER] cadcraft-cli mcp [--connect HOST:PORT] cadcraft-cli perf [N] cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png) - cadcraft-cli --version"; + cadcraft-cli --version + +image framing (png 2400x1600, svg, pdf; always model space): + default convert: fit the drawing extents. + run --export: the current view if the script/commands changed it + (zoom, pan, view.set...), otherwise fit the drawing extents. + --window x0,y0,x1,y1 show this rectangle (drawing units, x0 Result { + let parts: Vec<&str> = text.split(',').map(str::trim).collect(); + let [x0, y0, x1, y1] = parts.as_slice() else { + return Err(format!("--window needs four numbers x0,y0,x1,y1, got `{text}`")); + }; + let num = |name: &str, v: &str| -> Result { + match v.parse::() { + Ok(n) if n.is_finite() => Ok(n), + _ => Err(format!("--window: {name} must be a finite number, got `{v}`")), + } + }; + let (x0, y0, x1, y1) = (num("x0", x0)?, num("y0", y0)?, num("x1", x1)?, num("y1", y1)?); + if !(x0 < x1 && y0 < y1) { + return Err(format!("--window: need x0 < x1 and y0 < y1 (lower-left corner first), got {x0},{y0},{x1},{y1}")); + } + let w = Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }; + cadcraft_io::check_window(&w).map_err(|e| format!("--window: {e}"))?; + Ok(w) +} + +/// Parse a framing option (`--window`, `--view`) at `a`, taking its value from `it`. `Ok(false)` +/// means `a` is not a framing option. +fn framing_option<'a>(a: &str, it: &mut impl Iterator, framing: &mut Framing) -> Result { + let f = match a { + "--window" => Framing::Window(parse_window(it.next().ok_or("--window needs x0,y0,x1,y1")?)?), + "--view" => match it.next().map(|v| v.to_ascii_lowercase()).as_deref() { + Some("extents") => Framing::Extents, + Some("current") => Framing::Current, + _ => return Err("--view needs `extents` or `current`".into()), + }, + _ => return Ok(false), + }; + if *framing != Framing::Auto && std::mem::discriminant(framing) != std::mem::discriminant(&f) { + return Err("use only one of --window and --view".into()); + } + *framing = f; + Ok(true) +} + +/// The model-space view of every open drawing, by drawing id (to tell whether commands changed it). +fn model_views(s: &Session) -> Vec<(u64, Option)> { + s.docs.iter().map(|st| (st.uid, st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v))).collect() +} + +/// The active drawing's current model-space view as a rectangle with the viewport's aspect ratio +/// (the CLI viewport is 3:2 like the PNG export, so the image shows exactly this view). +fn current_window(s: &Session) -> Option { + let st = s.state().ok()?; + let v = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v)?; + let (w, h) = s.viewport_px; + let half = Vec2::new(v.height * w / h.max(1.0), v.height) * 0.5; + Some(Bounds2::new(v.center - half, v.center + half)) +} + +/// The window an image export shows, or `None` to fit the extents. `before` is [`model_views`] +/// taken before the commands ran (`None` for `convert`, where nothing ran). +fn export_window(s: &Session, framing: Framing, before: Option<&[(u64, Option)]>) -> Result, String> { + match framing { + Framing::Window(w) => Ok(Some(w)), + Framing::Extents => Ok(None), + Framing::Current => current_window(s).map(Some).ok_or_else(|| "--view current: the drawing has no model-space view".into()), + Framing::Auto => { + let Some(before) = before else { return Ok(None) }; + let Ok(st) = s.state() else { return Ok(None) }; + let now = model_views(s); + let was = before.iter().find(|(uid, _)| *uid == st.uid); + let is = now.iter().find(|(uid, _)| *uid == st.uid); + // Only a view the commands changed counts; a drawing opened by the commands keeps + // the extents framing. + match (was, is) { + (Some(a), Some(b)) if a.1 != b.1 => Ok(current_window(s)), + _ => Ok(None), + } + } + } +} fn install_io() { cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks { @@ -47,8 +147,12 @@ fn info(path: &str) -> Result<(), String> { } fn export(s: &Session, out: &str) -> Result<(), String> { + export_framed(s, out, None) +} + +fn export_framed(s: &Session, out: &str, window: Option) -> Result<(), String> { let d = s.doc().map_err(|e| e.to_string())?; - let bytes = cadcraft_io::write(d, out).map_err(|e| e.to_string())?; + let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?; std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?; eprintln!("wrote {out} ({} bytes)", bytes.len()); Ok(()) @@ -67,13 +171,44 @@ fn sample(args: &[String]) -> Result<(), String> { Ok(()) } +/// Reject an explicit framing option without an image output to apply it to. +fn check_framed_output(framing: Framing, out: Option<&str>) -> Result<(), String> { + match out { + _ if framing == Framing::Auto => Ok(()), + Some(o) if !cadcraft_io::is_image(o) => Err(format!("--window/--view only apply to image output (png, svg, pdf), not `{o}`")), + Some(_) => Ok(()), + None => Err("--window/--view need an image output (--export OUT.png|svg|pdf)".into()), + } +} + +fn convert(args: &[String]) -> Result<(), String> { + let mut framing = Framing::Auto; + let mut files = Vec::new(); + let mut it = args.iter(); + while let Some(a) = it.next() { + if !framing_option(a, &mut it, &mut framing)? { + files.push(a); + } + } + let [i, o] = files.as_slice() else { return Err(USAGE.into()) }; + check_framed_output(framing, Some(o))?; + let mut s = Session::empty(); + open(&mut s, i)?; + let window = export_window(&s, framing, None)?; + export_framed(&s, o, window) +} + fn run(args: &[String]) -> Result<(), String> { let mut s = Session::empty(); let mut it = args.iter(); let mut save = None; - let mut exp = None; + let mut exp: Option = None; + let mut framing = Framing::Auto; let mut steps: Vec<(String, String)> = Vec::new(); while let Some(a) = it.next() { + if framing_option(a, &mut it, &mut framing)? { + continue; + } match a.as_str() { "--sample" => { s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None); @@ -96,6 +231,8 @@ fn run(args: &[String]) -> Result<(), String> { if s.docs.is_empty() { s.new_drawing(false); } + check_framed_output(framing, exp.as_deref())?; + let before = model_views(&s); for (kind, body) in steps { match kind.as_str() { "script" => s.script(&body).map_err(|e| e.to_string())?, @@ -116,7 +253,8 @@ fn run(args: &[String]) -> Result<(), String> { export(&s, &p)?; } if let Some(p) = exp { - export(&s, &p)?; + let window = if cadcraft_io::is_image(&p) { export_window(&s, framing, Some(&before))? } else { None }; + export_framed(&s, &p, window)?; } Ok(()) } @@ -352,13 +490,7 @@ fn main() -> ExitCode { let rest: Vec = args.iter().skip(1).cloned().collect(); let r = match args.first().map(String::as_str) { Some("info") => rest.first().ok_or_else(|| USAGE.to_string()).and_then(|p| info(p)), - Some("convert") => match rest.as_slice() { - [i, o] => { - let mut s = Session::empty(); - open(&mut s, i).and_then(|_| export(&s, o)) - } - _ => Err(USAGE.into()), - }, + Some("convert") => convert(&rest), Some("run") => run(&rest), Some("perf") => perf(&rest), Some("sample") => sample(&rest), @@ -388,3 +520,162 @@ fn main() -> ExitCode { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 { + Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1)) + } + + fn sample_session() -> Session { + let mut s = Session::empty(); + s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None); + s + } + + fn view_box(svg: &str) -> String { + let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0); + svg[i..].split('"').next().unwrap_or("").to_string() + } + + fn assert_near(a: Bounds2, e: Bounds2) { + let d = (a.min - e.min).len().max((a.max - e.max).len()); + assert!(d < 1e-9, "{a:?} != {e:?}"); + } + + #[test] + fn window_parses() { + assert_eq!(parse_window("0,0,30,20"), Ok(b(0.0, 0.0, 30.0, 20.0))); + assert_eq!(parse_window(" -5.5, -1e3 ,2e3,4 "), Ok(b(-5.5, -1000.0, 2000.0, 4.0))); + } + + #[test] + fn hostile_window_is_an_error() { + for w in [ + "", + "1,2,3", + "1,2,3,4,5", + "a,0,1,1", + "NaN,0,1,1", + "0,0,inf,1", + "0,0,1e400,1", + "0,0,0,1", + "0,0,1,0", + "5,0,1,1", + "0,5,1,1", + "-1e300,-1e300,1e300,1e300", + "0,0,1e-12,1e-12", + ] { + assert!(parse_window(w).is_err(), "`{w}` should be rejected"); + } + } + + #[test] + fn framing_options() { + let parse = |args: &[&str]| -> Result { + let args: Vec = args.iter().map(|a| a.to_string()).collect(); + let mut f = Framing::Auto; + let mut it = args.iter(); + while let Some(a) = it.next() { + assert!(framing_option(a, &mut it, &mut f)?, "{a} is a framing option"); + } + Ok(f) + }; + assert_eq!(parse(&["--view", "extents"]), Ok(Framing::Extents)); + assert_eq!(parse(&["--view", "CURRENT"]), Ok(Framing::Current)); + assert_eq!(parse(&["--window", "0,0,2,1"]), Ok(Framing::Window(b(0.0, 0.0, 2.0, 1.0)))); + assert!(parse(&["--view"]).is_err()); + assert!(parse(&["--view", "selection"]).is_err()); + assert!(parse(&["--window"]).is_err()); + assert!(parse(&["--window", "0,0,2,1", "--view", "extents"]).is_err()); + assert!(check_framed_output(Framing::Extents, Some("a.dxf")).is_err()); + assert!(check_framed_output(Framing::Extents, None).is_err()); + assert!(check_framed_output(Framing::Extents, Some("a.png")).is_ok()); + assert!(check_framed_output(Framing::Auto, Some("a.dxf")).is_ok()); + } + + #[test] + fn default_framing_is_extents() { + // convert: nothing ran, always the extents. + let s = sample_session(); + assert_eq!(export_window(&s, Framing::Auto, None), Ok(None)); + // run: commands that leave the view alone keep the extents framing. + let mut s = sample_session(); + let before = model_views(&s); + s.script("LINE 0,0 10,10\n\n").unwrap(); + assert_eq!(export_window(&s, Framing::Auto, Some(&before)), Ok(None)); + // A fresh drawing too. + let mut s = Session::empty(); + s.new_drawing(false); + let before = model_views(&s); + s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap(); + assert_eq!(export_window(&s, Framing::Auto, Some(&before)), Ok(None)); + } + + #[test] + fn zoom_window_frames_export() { + let mut s = sample_session(); + let before = model_views(&s); + s.execute("zoom", &json!({"mode": "window", "p1": [0, 0], "p2": [30, 20]})).unwrap(); + let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(); + // The CLI viewport is 3:2, so a 3:2 window is shown exactly. + assert_near(w, b(0.0, 0.0, 30.0, 20.0)); + assert_eq!(export_window(&s, Framing::Current, Some(&before)), Ok(Some(w))); + // --view extents and --window override the zoom. + assert_eq!(export_window(&s, Framing::Extents, Some(&before)), Ok(None)); + let explicit = b(1.0, 1.0, 2.0, 2.0); + assert_eq!(export_window(&s, Framing::Window(explicit), Some(&before)), Ok(Some(explicit))); + // A taller window keeps its height; the 3:2 image adds room left and right. + s.execute("zoom", &json!({"mode": "center", "center": [5, 5], "height": 4})).unwrap(); + assert_near(export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(), b(2.0, 3.0, 8.0, 7.0)); + } + + #[test] + fn zoom_object_frames_the_selection() { + let mut s = sample_session(); + let before = model_views(&s); + // The smallest entity with an area, selected then framed with ZOOM Object. + let d = s.doc().unwrap(); + let bounds = |e: &cadcraft_engine::doc::Entity| cadcraft_engine::doc::entity_bounds(d, e, 0); + let e = d + .model + .iter() + .filter(|e| bounds(e).width() > 0.0 && bounds(e).height() > 0.0) + .min_by(|a, b| bounds(a).width().total_cmp(&bounds(b).width())) + .unwrap(); + let (h, eb, ext) = (e.handle, bounds(e), d.extents(&Space::Model)); + s.set_selection(vec![h]); + s.execute("zoom", &json!({"mode": "object"})).unwrap(); + let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(); + assert!(w.contains(eb.min) && w.contains(eb.max)); + assert!(w.width() < ext.width() && w.height() < ext.height(), "{w:?} vs extents {ext:?}"); + } + + #[test] + fn run_export_honours_zoom() { + let dir = std::env::temp_dir().join(format!("cadcraft-cli-165-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = |n: &str| dir.join(n).to_string_lossy().to_string(); + let run_svg = |extra: &[&str], out: &str| -> String { + let mut args: Vec = ["--sample"].iter().chain(extra).map(|a| a.to_string()).collect(); + args.extend(["--export".to_string(), path(out)]); + run(&args).unwrap(); + std::fs::read_to_string(path(out)).unwrap() + }; + let base = run_svg(&[], "base.svg"); + let zoomed = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#], "zoom.svg"); + let window = run_svg(&["--window", "0,0,30,20"], "window.svg"); + let forced = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#, "--view", "extents"], "forced.svg"); + assert_ne!(view_box(&zoomed), view_box(&base)); + assert_eq!(view_box(&zoomed), "0 0 30.000000 20.000000"); + assert_eq!(zoomed, window); + assert_eq!(forced, base); + // Hostile --window: a clear error, nothing written. + let bad: Vec = ["--sample", "--window", "0,0,NaN,1", "--export", &path("bad.png")].iter().map(|a| a.to_string()).collect(); + assert!(run(&bad).unwrap_err().contains("--window")); + assert!(!dir.join("bad.png").exists()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index d7326e2..c06d677 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -9,6 +9,7 @@ pub mod pdf; pub mod svg; use cadcraft_doc::{Drawing, Space}; +use cadcraft_geom::Bounds2; pub use pdf::{PdfOptions, pdf, plot}; @@ -39,26 +40,82 @@ pub fn read(bytes: &[u8], name: &str) -> Result { } } -/// Write a drawing in the format chosen by the name's extension. +/// Write a drawing in the format chosen by the name's extension. Images (PNG, SVG, PDF) show +/// model space fitted to its extents. pub fn write(d: &Drawing, name: &str) -> Result> { + write_framed(d, name, None) +} + +/// Like [`write`], but image formats (PNG, SVG, PDF) show `window` (a model-space rectangle in +/// drawing units, centred and scaled to fit the image without distortion) instead of the +/// extents. Formats that are not images (DXF, DWG) ignore the window. The window is checked with +/// [`check_window`]. +pub fn write_framed(d: &Drawing, name: &str, window: Option) -> Result> { + if let Some(w) = &window + && is_image(name) + { + check_window(w)?; + } match ext(name).as_str() { "dxf" | "" => Ok(dxf_write::write(d).into_bytes()), "dwg" => cadcraft_dwg::dxf_to_dwg(dxf_write::write(d).as_bytes()).map_err(IoError::Format), - "svg" => Ok(svg::export(d, &Space::Model).into_bytes()), - "png" => png(d, &Space::Model, 2400, 1600), - "pdf" => pdf::pdf(d, &Space::Model, &PdfOptions { compress: true, ..PdfOptions::default() }), + "svg" => Ok(svg::export_window(d, &Space::Model, window).into_bytes()), + "png" => png_framed(d, &Space::Model, 2400, 1600, window), + "pdf" => pdf::pdf(d, &Space::Model, &PdfOptions { compress: true, window, ..PdfOptions::default() }), e => Err(IoError::Unsupported(e.to_string())), } } +/// Whether `name` is written as an image (PNG, SVG, PDF), i.e. whether framing applies to it. +pub fn is_image(name: &str) -> bool { + matches!(ext(name).as_str(), "png" | "svg" | "pdf") +} + +/// Largest coordinate magnitude accepted in an export window. +pub const MAX_WINDOW_COORD: f64 = 1.0e12; + +/// Validate an export window: finite corners within ±[`MAX_WINDOW_COORD`], and a width and height +/// that are positive and not vanishingly small next to the coordinates (at least 1e-9 of the +/// largest coordinate magnitude, and at least 1e-9 drawing units). +pub fn check_window(w: &Bounds2) -> Result<()> { + let coords = [w.min.x, w.min.y, w.max.x, w.max.y]; + if coords.iter().any(|c| !c.is_finite()) { + return Err(IoError::Format("export window: coordinates must be finite numbers".into())); + } + let mag = coords.iter().fold(0.0_f64, |m, c| m.max(c.abs())); + if mag > MAX_WINDOW_COORD { + return Err(IoError::Format(format!("export window: coordinates must lie within ±{MAX_WINDOW_COORD:e}"))); + } + let min_side = mag.max(1.0) * 1e-9; + let (width, height) = (w.max.x - w.min.x, w.max.y - w.min.y); + if !(width >= min_side && height >= min_side) { + return Err(IoError::Format(format!("export window: width and height must be positive (got {width} x {height})"))); + } + Ok(()) +} + /// Render a space to PNG, fitted to its extents. pub fn png(d: &Drawing, space: &Space, width: u32, height: u32) -> Result> { + png_framed(d, space, width, height, None) +} + +/// Render a space to PNG showing `window` (centred, fitted without margin), or fitted to its +/// extents (5% margin) when `window` is `None`. +pub fn png_framed(d: &Drawing, space: &Space, width: u32, height: u32, window: Option) -> Result> { let list = cadcraft_render::build(d, space, &cadcraft_render::Options::default()); - let view = cadcraft_render::raster::View::fit(&list.bounds, width, height, 0.05); + let view = png_view(&list.bounds, width, height, window); cadcraft_render::raster::render_png(&list, &view, &cadcraft_render::raster::RasterOptions::default()) .ok_or_else(|| IoError::Format("render failed".into())) } +/// The raster view a PNG export uses. +pub fn png_view(extents: &Bounds2, width: u32, height: u32, window: Option) -> cadcraft_render::raster::View { + match window { + Some(w) => cadcraft_render::raster::View::fit(&w, width, height, 0.0), + None => cadcraft_render::raster::View::fit(extents, width, height, 0.05), + } +} + pub fn read_dxf(bytes: &[u8]) -> Result { dxf_read::read(bytes) } diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 1ee0049..3cb4184 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -38,6 +38,9 @@ pub struct PdfOptions { /// Flate-compress the content stream. pub compress: bool, pub title: String, + /// Model space only: plot this rectangle (drawing units), fitted to the printable area, + /// instead of the extents. Ignored for layouts. + pub window: Option, } impl PdfOptions { @@ -57,6 +60,7 @@ impl PdfOptions { lineweights: v.get("lineweights").and_then(Value::as_bool), compress: v.get("compress").and_then(Value::as_bool).unwrap_or(true), title: v.get("title").and_then(Value::as_str).unwrap_or("").to_string(), + window: None, } } } @@ -128,13 +132,14 @@ pub fn pdf(d: &Drawing, space: &Space, o: &PdfOptions) -> Result> { }); let ropts = cadcraft_render::Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights }; let k = unit_mm * PT_PER_MM; - let fit = o.fit.unwrap_or(matches!(space, Space::Model)); + let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); + let fit = window.is_some() || o.fit.unwrap_or(matches!(space, Space::Model)); // Chord tolerance: about 0.05 mm on paper. - let est = plot_scale(&d.extents(space), &sheet, fit, o.scale); + let est = plot_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, fit, o.scale); let tol = 0.05 / unit_mm / est.max(1e-300); let tolerance = if tol.is_finite() && tol > 0.0 { tol } else { ropts.tolerance }; let list = cadcraft_render::build_plot(d, space, &cadcraft_render::Options { tolerance, ..ropts }); - let b = list.bounds; + let b = window.unwrap_or(list.bounds); let s = plot_scale(&b, &sheet, fit, o.scale); let map = if fit || matches!(space, Space::Model) { let from = if b.is_empty() { Vec2::ZERO } else { b.center() }; diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index f019a07..01f465d 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -4,14 +4,22 @@ use std::fmt::Write as _; use cadcraft_color::{Rgb, display_rgb}; use cadcraft_doc::{Drawing, Space}; +use cadcraft_geom::Bounds2; use cadcraft_render::Kind; -/// Export with a white background (colour 7 prints black). +/// Export with a white background (colour 7 prints black), fitted to the extents. pub fn export(d: &Drawing, space: &Space) -> String { + export_window(d, space, None) +} + +/// Export showing exactly `window` (the `viewBox`; geometry outside it is clipped by the viewer), +/// or the extents with a 2% margin when `window` is `None`. +pub fn export_window(d: &Drawing, space: &Space, window: Option) -> String { let list = cadcraft_render::build(d, space, &cadcraft_render::Options::default()); - let b = list.bounds; + let window = window.filter(|w| !w.is_empty()); + let b = window.unwrap_or(list.bounds); let (w, h) = if b.is_empty() { (1.0, 1.0) } else { (b.width().max(1e-9), b.height().max(1e-9)) }; - let m = (w.max(h)) * 0.02; + let m = if window.is_some() { 0.0 } else { (w.max(h)) * 0.02 }; let (x0, y1) = if b.is_empty() { (0.0, 1.0) } else { (b.min.x - m, b.max.y + m) }; let vw = w + 2.0 * m; let vh = h + 2.0 * m; @@ -56,7 +64,8 @@ pub fn export(d: &Drawing, space: &Space) -> String { } Kind::Infinite { ray } => { if let (Some(bp), Some(dir)) = (pts.first(), pts.get(1)) { - let far = (vw + vh) * 2.0; + // A window can lie far from the base point: reach past it. + let far = (vw + vh) * 2.0 + if window.is_some() { bp.dist(b.center()) } else { 0.0 }; let a = if ray { *bp } else { *bp - *dir * far }; let e = *bp + *dir * far; let _ = write!( diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 8bfa06b..48ababa 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -307,6 +307,66 @@ fn svg_and_png_export() { assert_eq!(&png[1..4], b"PNG"); } +fn view_box(svg: &str) -> String { + let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0); + svg[i..].split('"').next().unwrap_or("").to_string() +} + +#[test] +fn image_export_frames_window() { + use cadcraft_geom::Bounds2; + let d = sample(); + let win = Bounds2::new(Vec2::new(1.0, 2.0), Vec2::new(31.0, 22.0)); + // SVG: the viewBox is exactly the window (no margin); the default still fits the extents. + let framed = String::from_utf8(write_framed(&d, "a.svg", Some(win)).unwrap()).unwrap(); + let plain = String::from_utf8(write(&d, "a.svg").unwrap()).unwrap(); + assert_eq!(view_box(&framed), "0 0 30.000000 20.000000"); + assert_ne!(view_box(&framed), view_box(&plain)); + assert_eq!(plain, String::from_utf8(write_framed(&d, "a.svg", None).unwrap()).unwrap()); + let ext = cadcraft_render::build(&d, &Space::Model, &cadcraft_render::Options::default()).bounds; + let m = ext.width().max(ext.height()) * 0.02; + assert_eq!(view_box(&plain), format!("0 0 {:.6} {:.6}", ext.width() + 2.0 * m, ext.height() + 2.0 * m)); + // PNG: the window fills the 3:2 image exactly, centred. + let v = png_view(&ext, 2400, 1600, Some(win)); + assert_eq!(v.center, Vec2::new(16.0, 12.0)); + assert!((v.scale - 80.0).abs() < 1e-9); + assert_eq!(png_view(&ext, 2400, 1600, None), cadcraft_render::raster::View::fit(&ext, 2400, 1600, 0.05)); + let png_framed = write_framed(&d, "a.png", Some(win)).unwrap(); + assert_eq!(&png_framed[1..4], b"PNG"); + assert_ne!(png_framed, write(&d, "a.png").unwrap()); + // PDF: the window is fitted to the sheet instead of the extents. + let pdf_framed = write_framed(&d, "a.pdf", Some(win)).unwrap(); + check_pdf(&pdf_framed); + assert_ne!(pdf_framed, write(&d, "a.pdf").unwrap()); + // Non-image formats ignore the window. + assert_eq!(write_framed(&d, "a.dxf", Some(win)).unwrap(), write(&d, "a.dxf").unwrap()); +} + +#[test] +fn hostile_export_window_is_an_error() { + use cadcraft_geom::Bounds2; + let d = sample(); + let raw = |x0: f64, y0: f64, x1: f64, y1: f64| Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) }; + let bad = [ + raw(f64::NAN, 0.0, 1.0, 1.0), + raw(0.0, 0.0, f64::INFINITY, 1.0), + raw(0.0, 0.0, 0.0, 1.0), // zero width + raw(0.0, 0.0, 1.0, 0.0), // zero height + raw(5.0, 0.0, 1.0, 1.0), // inverted + raw(-1e300, -1e300, 1e300, 1e300), // huge + raw(1e11, 1e11, 1e11 + 1e-6, 1e11 + 1e-6), // vanishingly small next to its coordinates + Bounds2::EMPTY, + ]; + for w in bad { + assert!(check_window(&w).is_err(), "{w:?}"); + for name in ["a.png", "a.svg", "a.pdf"] { + assert!(write_framed(&d, name, Some(w)).is_err(), "{name} {w:?}"); + } + } + assert!(check_window(&raw(-1e6, -1e6, 1e6, 1e6)).is_ok()); + assert!(check_window(&raw(0.0, 0.0, 1e-3, 1e-3)).is_ok()); +} + /// Structural sanity check of a PDF: header, object count, xref offsets pointing at `n 0 obj`. fn check_pdf(bytes: &[u8]) -> String { assert!(bytes.starts_with(b"%PDF-1.4"));