`convert` and `run --export` always fitted the model-space extents, so `zoom`, `pan` or `view.set` in a run had no effect on the image and there was no way to export a region. - io: `write_framed(d, name, window)` frames PNG, SVG and PDF on a model-space rectangle (fitted without margin, centred, no distortion); `write` is unchanged and still fits the extents. `check_window` rejects non-finite, inverted, zero-size and out-of-range (±1e12) windows. - CLI: `run --export` shows the current view when the commands changed it, otherwise the extents as before. `--window x0,y0,x1,y1` and `--view extents|current` on `convert` and `run` choose the framing explicitly. The usage text and README say how images are framed. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bcbbc5e8a4
commit
396b098d9a
11
README.md
11
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
|
||||
|
|
|
|||
|
|
@ -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<x1, y0<y1), centred and
|
||||
scaled to fit the image without distortion.
|
||||
--view extents always fit the drawing extents.
|
||||
--view current the current view (as last set by zoom/pan/view.set; a freshly
|
||||
opened drawing's view is its extents plus a 10% margin).";
|
||||
|
||||
/// What part of model space an image export shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Framing {
|
||||
/// `run`: the current view if the commands changed it, else the extents. `convert`: extents.
|
||||
Auto,
|
||||
Extents,
|
||||
Current,
|
||||
Window(Bounds2),
|
||||
}
|
||||
|
||||
/// Parse `--window x0,y0,x1,y1` (min corner first). Hostile values are errors, never panics.
|
||||
fn parse_window(text: &str) -> Result<Bounds2, String> {
|
||||
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<f64, String> {
|
||||
match v.parse::<f64>() {
|
||||
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<Item = &'a String>, framing: &mut Framing) -> Result<bool, String> {
|
||||
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<View>)> {
|
||||
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<Bounds2> {
|
||||
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<View>)]>) -> Result<Option<Bounds2>, 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<Bounds2>) -> 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<String> = 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<String> = 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<Framing, String> {
|
||||
let args: Vec<String> = 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<String> = ["--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<String> = ["--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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Drawing> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>> {
|
||||
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<Bounds2>) -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Bounds2>) -> Result<Vec<u8>> {
|
||||
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<Bounds2>) -> 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<Drawing> {
|
||||
dxf_read::read(bytes)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Bounds2>,
|
||||
}
|
||||
|
||||
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<Vec<u8>> {
|
|||
});
|
||||
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() };
|
||||
|
|
|
|||
|
|
@ -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<Bounds2>) -> 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!(
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Reference in New Issue