Plotting: honor model-space center and X/Y offsets (#751)
* plot: add bounded model-space paper placement * plot: expose center and paper offset parameters * ui: add model-space Plot offset controls
This commit is contained in:
parent
e71e17a8df
commit
bcd6322ee9
|
|
@ -48,3 +48,8 @@ sync_github_name = true
|
|||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.cyfung1031]
|
||||
real_name = "Chester Fung"
|
||||
display_name = "Chester Fung"
|
||||
sync_github_name = true
|
||||
|
|
|
|||
|
|
@ -80,11 +80,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.menu(&["File", "Print..."])
|
||||
.key("Cmd+P")
|
||||
.alias(&["print"])
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", lineweights?: bool, plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
|
||||
.params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)")
|
||||
.noundo(),
|
||||
CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf"))
|
||||
.menu(&["File", "Export to PDF..."])
|
||||
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, lineweights?, plotArea?, window?} (as PLOT)")
|
||||
.params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotArea?, window?} (as PLOT)")
|
||||
.noundo(),
|
||||
CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace)
|
||||
.alias(&["ms"])
|
||||
|
|
@ -774,6 +774,28 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
|||
None => s.space(),
|
||||
};
|
||||
let mut opts = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(raw) = p.get("offset") {
|
||||
let values = raw
|
||||
.as_array()
|
||||
.filter(|values| values.len() == 2)
|
||||
.ok_or_else(|| bad(cmd, "`offset` must contain exactly two finite millimetre values within ±100000"))?;
|
||||
let x = values.first().and_then(Value::as_f64);
|
||||
let y = values.get(1).and_then(Value::as_f64);
|
||||
if !x.zip(y).is_some_and(|(x, y)| {
|
||||
x.is_finite()
|
||||
&& y.is_finite()
|
||||
&& x.abs() <= cadcraft_render::paper::MAX_PLOT_OFFSET_MM
|
||||
&& y.abs() <= cadcraft_render::paper::MAX_PLOT_OFFSET_MM
|
||||
}) {
|
||||
return Err(bad(cmd, "`offset` must contain exactly two finite millimetre values within ±100000"));
|
||||
}
|
||||
}
|
||||
if matches!(space, Space::Model)
|
||||
&& opts.get("output").and_then(Value::as_str).is_some_and(|output| output.eq_ignore_ascii_case("svg"))
|
||||
&& opts.get("center").and_then(Value::as_bool) == Some(false)
|
||||
{
|
||||
return Err(bad(cmd, "model-space plot offsets are not supported for SVG; choose PDF/PNG or center the plot"));
|
||||
}
|
||||
if let Some(o) = opts.as_object_mut() {
|
||||
if let Some(v) = p.get("scale") {
|
||||
let k = parse_scale(v).ok_or_else(|| bad(cmd, "`scale` must be positive: paper units per drawing unit, or a ratio such as \"1:50\""))?;
|
||||
|
|
@ -1055,12 +1077,20 @@ mod tests {
|
|||
assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4"));
|
||||
let r = s.execute("exportpdf", &json!({})).unwrap();
|
||||
assert_eq!(r["layout"], "Model");
|
||||
let baseline = String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(!baseline.contains("\"center\"") && !baseline.contains("\"offset\""), "omitted options remain omitted: {baseline}");
|
||||
assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err());
|
||||
// Plot area and a ratio scale reach the plotter as a model-space window and a number.
|
||||
let opts = |r: Value| String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let p = json!({"layout": "Model", "plotArea": "window", "window": [[10, 0], [0, 5]], "fit": false, "scale": "1:2"});
|
||||
let text = opts(s.execute("plot", &p).unwrap());
|
||||
assert!(text.contains(r#""window":[[0.0,0.0],[10.0,5.0]]"#) && text.contains(r#""scale":0.5"#), "{text}");
|
||||
let text = opts(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": [12.5, -7.0]})).unwrap());
|
||||
assert!(text.contains(r#""center":false"#) && text.contains(r#""offset":[12.5,-7.0]"#), "{text}");
|
||||
for offset in [json!([1]), json!([1, 2, 3]), json!([f64::NAN, 0]), json!([100_001, 0])] {
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": offset})).is_err());
|
||||
}
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "output": "svg", "center": false, "offset": [0, 0]})).is_err());
|
||||
assert!(opts(s.execute("plot", &json!({"layout": "Model", "plotArea": "limits"})).unwrap()).contains(r#""window":[[0.0,0.0],"#));
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners");
|
||||
assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet");
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ pub struct PdfOptions {
|
|||
pub fit: Option<bool>,
|
||||
/// Plot scale in paper units per drawing unit (when not fitting).
|
||||
pub scale: Option<f64>,
|
||||
/// Model-space plot placement. `None` keeps the legacy centered behavior.
|
||||
pub center: Option<bool>,
|
||||
/// Model-space X/Y plot offset in millimetres from the printable area's lower-left corner.
|
||||
pub offset: Option<[f64; 2]>,
|
||||
/// Plot object lineweights. Default: the layout's setting; on for model space.
|
||||
pub lineweights: Option<bool>,
|
||||
/// Flate-compress the content stream.
|
||||
|
|
@ -87,6 +91,8 @@ impl Default for PdfOptions {
|
|||
landscape: None,
|
||||
fit: None,
|
||||
scale: None,
|
||||
center: None,
|
||||
offset: None,
|
||||
lineweights: None,
|
||||
compress: false,
|
||||
title: String::new(),
|
||||
|
|
@ -102,8 +108,9 @@ impl Default for PdfOptions {
|
|||
}
|
||||
|
||||
impl PdfOptions {
|
||||
/// Parse `{paper?, width?, height?, landscape?, fit?, scale?, lineweights?, compress?, title?,
|
||||
/// window?: [[x1, y1], [x2, y2]], margins?: [l, b, r, t] (mm), hyperlinks?: bool}`.
|
||||
/// Parse `{paper?, width?, height?, landscape?, fit?, scale?, center?, offset?: [x,y] mm,
|
||||
/// lineweights?, compress?, title?, window?: [[x1, y1], [x2, y2]], margins?: [l, b, r, t] (mm),
|
||||
/// hyperlinks?: bool}`.
|
||||
pub fn from_json(v: &Value) -> PdfOptions {
|
||||
let num = |k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite());
|
||||
let paper_mm = match (num("width"), num("height")) {
|
||||
|
|
@ -123,6 +130,8 @@ impl PdfOptions {
|
|||
landscape: v.get("landscape").and_then(Value::as_bool),
|
||||
fit: v.get("fit").and_then(Value::as_bool),
|
||||
scale: num("scale").filter(|s| *s > 0.0),
|
||||
center: v.get("center").and_then(Value::as_bool),
|
||||
offset: offset(v.get("offset")),
|
||||
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(),
|
||||
|
|
@ -133,6 +142,35 @@ impl PdfOptions {
|
|||
}
|
||||
}
|
||||
|
||||
fn offset(v: Option<&Value>) -> Option<[f64; 2]> {
|
||||
let a = v?.as_array()?;
|
||||
if a.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
let x = a.first()?.as_f64()?;
|
||||
let y = a.get(1)?.as_f64()?;
|
||||
valid_plot_offset([x, y]).then_some([x, y])
|
||||
}
|
||||
|
||||
fn valid_plot_offset(offset: [f64; 2]) -> bool {
|
||||
offset.iter().all(|v| v.is_finite() && v.abs() <= paper::MAX_PLOT_OFFSET_MM)
|
||||
}
|
||||
|
||||
/// Reject malformed offsets at the JSON plotting boundary, before a permissive option parser
|
||||
/// could treat them as absent.
|
||||
pub(crate) fn validate_plot_offset_json(v: &Value) -> Result<()> {
|
||||
if let Some(raw) = v.get("offset")
|
||||
&& offset(Some(raw)).is_none()
|
||||
{
|
||||
return Err(IoError::Format(format!(
|
||||
"plot offset must contain exactly two finite values from -{} to {} mm",
|
||||
paper::MAX_PLOT_OFFSET_MM,
|
||||
paper::MAX_PLOT_OFFSET_MM
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `[l, b, r, t]` margins in mm (finite, 0–1000), else `None`.
|
||||
pub(crate) fn margins(v: Option<&Value>) -> Option<[f64; 4]> {
|
||||
let a: Vec<f64> = v?.as_array()?.iter().take(5).filter_map(Value::as_f64).collect();
|
||||
|
|
@ -156,6 +194,7 @@ fn window(v: Option<&Value>) -> Option<Bounds2> {
|
|||
|
||||
/// Plot with JSON options (the engine's `plot` hook).
|
||||
pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
||||
validate_plot_offset_json(opts)?;
|
||||
// `layouts: [name | "Model"] | "all"` plots those sheets into one file, one page each,
|
||||
// instead of `space`; "all" = Model if it has objects, then the layouts in tab order.
|
||||
let o = PdfOptions::from_json(opts);
|
||||
|
|
@ -274,6 +313,11 @@ fn sheet_name(space: &Space) -> String {
|
|||
/// Write a vector PDF with one page per space (at most [`MAX_PAGES`]), each labelled with its
|
||||
/// layout name. The options apply to every sheet; layouts use their own page setups.
|
||||
pub fn pdf_sheets(d: &Drawing, spaces: &[Space], o: &PdfOptions) -> Result<Vec<u8>> {
|
||||
if let Some(offset) = o.offset
|
||||
&& !valid_plot_offset(offset)
|
||||
{
|
||||
return Err(IoError::Format("plot offset must contain two finite values within ±100000 mm".into()));
|
||||
}
|
||||
if spaces.is_empty() {
|
||||
return Err(IoError::Format("there are no sheets to plot".into()));
|
||||
}
|
||||
|
|
@ -348,8 +392,8 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String
|
|||
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() };
|
||||
Map { from, s, to: sheet.printable.center(), k }
|
||||
let (from, to) = placement(space, &b, fit, &sheet, o);
|
||||
Map { from, s, to, k }
|
||||
} else {
|
||||
Map { from: Vec2::ZERO, s, to: Vec2::ZERO, k }
|
||||
};
|
||||
|
|
@ -376,6 +420,30 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String
|
|||
Ok(SheetPage { media, list, map, clip_pt, layers, links, label: sheet_name(space) })
|
||||
}
|
||||
|
||||
/// Source and destination origins for the shared model-space PDF/PNG placement behavior.
|
||||
pub(crate) fn placement(space: &Space, bounds: &Bounds2, fit: bool, sheet: &Sheet, o: &PdfOptions) -> (Vec2, Vec2) {
|
||||
if matches!(space, Space::Model) {
|
||||
let from = if bounds.is_empty() {
|
||||
Vec2::ZERO
|
||||
} else if o.center.unwrap_or(true) {
|
||||
bounds.center()
|
||||
} else {
|
||||
bounds.min
|
||||
};
|
||||
let to = if o.center.unwrap_or(true) {
|
||||
sheet.printable.center()
|
||||
} else {
|
||||
let offset = o.offset.unwrap_or([0.0, 0.0]);
|
||||
sheet.printable.min + Vec2::new(offset[0] / sheet.unit_mm, offset[1] / sheet.unit_mm)
|
||||
};
|
||||
(from, to)
|
||||
} else if fit {
|
||||
(if bounds.is_empty() { Vec2::ZERO } else { bounds.center() }, sheet.printable.center())
|
||||
} else {
|
||||
(Vec2::ZERO, Vec2::ZERO)
|
||||
}
|
||||
}
|
||||
|
||||
/// The hyperlinks to plot: the options' own, then (with [`PdfOptions::hyperlinks`]) those the
|
||||
/// drawing file gave its entities as `PE_URL` extended data, at most [`MAX_LINKS`] in all.
|
||||
fn all_links(d: &Drawing, o: &PdfOptions) -> Vec<(Handle, String)> {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,13 @@ pub const MAX_PNG_PIXELS: f64 = 40.0e6;
|
|||
/// `svg`. `margins: [l, b, r, t]` (mm) bound the printable area of this plot (model space or a
|
||||
/// layout).
|
||||
pub fn plot(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
||||
pdf::validate_plot_offset_json(opts)?;
|
||||
if matches!(space, Space::Model)
|
||||
&& opts.get("output").and_then(Value::as_str).is_some_and(|output| output.eq_ignore_ascii_case("svg"))
|
||||
&& opts.get("center").and_then(Value::as_bool) == Some(false)
|
||||
{
|
||||
return Err(IoError::Format("model-space plot offsets are not supported for SVG; choose PDF/PNG or center the plot".into()));
|
||||
}
|
||||
let margins = pdf::margins(opts.get("margins"));
|
||||
let with_margins;
|
||||
let d = match (space, margins) {
|
||||
|
|
@ -97,9 +104,9 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
|||
// are already drawn).
|
||||
cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm);
|
||||
let view = if fit {
|
||||
let from = if b.is_empty() { Vec2::ZERO } else { b.center() };
|
||||
// The world point at the image centre, with `from` on the printable area's centre.
|
||||
let center = from + (half - sheet.printable.center()) / s;
|
||||
let (from, to) = pdf::placement(space, &b, true, &sheet, &o);
|
||||
// The world point at the image centre, mapped through the same paper origin as PDF.
|
||||
let center = from + (half - to) / s;
|
||||
raster::View { center, scale: k * s, width, height }
|
||||
} else {
|
||||
raster::View { center: half, scale: k, width, height }
|
||||
|
|
@ -136,7 +143,7 @@ fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result<Vec<u8>> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use cadcraft_doc::{Common, EntityKind, Line};
|
||||
use cadcraft_geom::Vec3;
|
||||
use cadcraft_geom::{Bounds2, Vec2, Vec3};
|
||||
use serde_json::json;
|
||||
|
||||
/// Width and height from a PNG's header.
|
||||
|
|
@ -168,4 +175,74 @@ mod tests {
|
|||
assert!(plot(&d, &Space::Model, &json!({"output": "dwf"})).is_err());
|
||||
assert!(plot(&d, &Space::Model, &json!({})).unwrap().starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_plot_placement_preserves_centering_and_applies_mm_offsets() {
|
||||
let model = Space::Model;
|
||||
let bounds = Bounds2::new(Vec2::new(10.0, 20.0), Vec2::new(110.0, 70.0));
|
||||
let metric = drawing();
|
||||
let metric_sheet = Sheet::from_page(&pdf::page_for(&metric, &model, &PdfOptions::default()).unwrap(), 1.0);
|
||||
let centered = PdfOptions::from_json(&json!({"center": true}));
|
||||
assert_eq!(pdf::placement(&model, &bounds, true, &metric_sheet, &PdfOptions::default()), (bounds.center(), metric_sheet.printable.center()));
|
||||
assert_eq!(pdf::placement(&model, &bounds, true, &metric_sheet, ¢ered), (bounds.center(), metric_sheet.printable.center()));
|
||||
let moved = PdfOptions::from_json(&json!({"center": false, "offset": [10.0, 20.0]}));
|
||||
assert_eq!(pdf::placement(&model, &bounds, true, &metric_sheet, &moved), (bounds.min, metric_sheet.printable.min + Vec2::new(10.0, 20.0)));
|
||||
let origin = PdfOptions::from_json(&json!({"center": false}));
|
||||
assert_eq!(pdf::placement(&model, &bounds, true, &metric_sheet, &origin), (bounds.min, metric_sheet.printable.min));
|
||||
let negative = PdfOptions::from_json(&json!({"center": false, "offset": [-10.0, -20.0]}));
|
||||
assert_eq!(pdf::placement(&model, &bounds, true, &metric_sheet, &negative), (bounds.min, metric_sheet.printable.min - Vec2::new(10.0, 20.0)));
|
||||
|
||||
let imperial = Drawing::new_imperial();
|
||||
let imperial_sheet = Sheet::from_page(&pdf::page_for(&imperial, &model, &PdfOptions::default()).unwrap(), 25.4);
|
||||
assert_eq!(
|
||||
pdf::placement(&model, &bounds, true, &imperial_sheet, &moved),
|
||||
(bounds.min, imperial_sheet.printable.min + Vec2::new(10.0 / 25.4, 20.0 / 25.4))
|
||||
);
|
||||
assert_eq!(
|
||||
pdf::placement(&model, &bounds, true, &imperial_sheet, &negative),
|
||||
(bounds.min, imperial_sheet.printable.min - Vec2::new(10.0 / 25.4, 20.0 / 25.4))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plot_offsets_keep_default_bytes_and_match_across_pdf_and_png() {
|
||||
let d = drawing();
|
||||
let model = Space::Model;
|
||||
let pdf_default = plot(&d, &model, &json!({})).unwrap();
|
||||
let pdf_centered = plot(&d, &model, &json!({"center": true})).unwrap();
|
||||
assert_eq!(pdf_default, pdf_centered, "explicit center preserves legacy PDF bytes");
|
||||
|
||||
let png_options = json!({"output": "png", "resolution": 100, "width": 210, "height": 297, "landscape": true, "margins": [5, 5, 5, 5]});
|
||||
let png_default = plot(&d, &model, &png_options).unwrap();
|
||||
let mut centered_png = png_options.clone();
|
||||
centered_png["center"] = json!(true);
|
||||
assert_eq!(png_default, plot(&d, &model, ¢ered_png).unwrap(), "explicit center preserves legacy PNG bytes");
|
||||
|
||||
let mut pdf_moved = json!({"center": false, "offset": [10.0, 20.0]});
|
||||
let moved_pdf = plot(&d, &model, &pdf_moved).unwrap();
|
||||
assert_ne!(pdf_default, moved_pdf);
|
||||
pdf_moved["output"] = json!("png");
|
||||
pdf_moved["resolution"] = json!(100);
|
||||
pdf_moved["width"] = json!(210);
|
||||
pdf_moved["height"] = json!(297);
|
||||
pdf_moved["landscape"] = json!(true);
|
||||
pdf_moved["margins"] = json!([5, 5, 5, 5]);
|
||||
let moved_png = plot(&d, &model, &pdf_moved).unwrap();
|
||||
assert_ne!(png_default, moved_png);
|
||||
|
||||
let layout = Space::Paper(d.layouts[0].name.clone());
|
||||
assert_eq!(plot(&d, &layout, &json!({})).unwrap(), plot(&d, &layout, &json!({"center": false, "offset": [10.0, 20.0]})).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_unsupported_plot_offsets_are_reported() {
|
||||
let d = drawing();
|
||||
let model = Space::Model;
|
||||
for offset in [json!([1]), json!([1, 2, 3]), json!([1, "2"]), json!([f64::NAN, 2]), json!([100_001, 0])] {
|
||||
assert!(plot(&d, &model, &json!({"offset": offset})).is_err());
|
||||
}
|
||||
let err = plot(&d, &model, &json!({"output": "svg", "center": false, "offset": [0, 0]})).unwrap_err();
|
||||
assert!(err.to_string().contains("not supported for SVG"));
|
||||
assert!(plot(&d, &model, &json!({"output": "svg", "center": true})).unwrap().starts_with(b"<svg"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
use cadcraft_doc::{Drawing, PageSetup};
|
||||
use cadcraft_geom::{Bounds2, Vec2};
|
||||
|
||||
/// Maximum model-space plot offset accepted by the PLOT command, in millimetres.
|
||||
pub const MAX_PLOT_OFFSET_MM: f64 = 100_000.0;
|
||||
|
||||
/// A standard paper size (portrait: `width_mm <= height_mm`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PaperSize {
|
||||
|
|
|
|||
|
|
@ -505,7 +505,7 @@ fn page_setup(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action
|
|||
// Layout plotting always prints the whole sheet, so only that area is offered.
|
||||
let rows = PageRows { areas: &["layout"], scale: true, center: true };
|
||||
egui::Grid::new("pagesetup_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(right, |ui| {
|
||||
if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices) {
|
||||
if let Some((k, v)) = page_rows(ui, egui::Id::new(("pagesetup", name)), page, &rows, &choices, None) {
|
||||
action = Some(("pagesetup", json!({ "layout": name, k: v })));
|
||||
}
|
||||
});
|
||||
|
|
@ -592,6 +592,7 @@ fn page_rows(
|
|||
page: &cadcraft_doc::PageSetup,
|
||||
rows: &PageRows,
|
||||
choices: &PageChoices,
|
||||
placement: Option<(&mut bool, &mut [f64; 2], bool)>,
|
||||
) -> Option<(&'static str, Value)> {
|
||||
let mut change = None;
|
||||
ui.label("Printer/plotter");
|
||||
|
|
@ -658,6 +659,31 @@ fn page_rows(
|
|||
change = Some(("scaleToFit", json!(fit)));
|
||||
}
|
||||
ui.end_row();
|
||||
if let Some((center, offset_mm, supported)) = placement {
|
||||
ui.label("Plot offset");
|
||||
ui.add_enabled_ui(supported, |ui| ui.checkbox(center, "Center the plot")).response.on_hover_text(if supported {
|
||||
"Center the plotted model bounds on the printable area"
|
||||
} else {
|
||||
"Model-space offset placement is not supported by SVG"
|
||||
});
|
||||
ui.end_row();
|
||||
for (i, label) in [(0, "X offset (mm)"), (1, "Y offset (mm)")] {
|
||||
ui.label(label);
|
||||
ui.add_enabled_ui(supported && !*center, |ui| {
|
||||
if let Some(v) = num_field(ui, ("plot_offset", i), offset_mm[i]) {
|
||||
offset_mm[i] = v;
|
||||
}
|
||||
})
|
||||
.response
|
||||
.on_hover_text("Distance from the printable area's lower-left corner; values range from −100000 to 100000 mm");
|
||||
ui.end_row();
|
||||
}
|
||||
if supported && !*center && offset_mm.iter().any(|v| !v.is_finite() || v.abs() > cadcraft_render::paper::MAX_PLOT_OFFSET_MM) {
|
||||
ui.label("");
|
||||
ui.colored_label(Tokens::get().warn, "Offsets must be within ±100000 mm");
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
if rows.scale {
|
||||
ui.label("Scale");
|
||||
ui.add_enabled_ui(!page.scale_to_fit, |ui| {
|
||||
|
|
@ -776,6 +802,9 @@ struct PlotState {
|
|||
page: cadcraft_doc::PageSetup,
|
||||
/// Plot area "window" corners in drawing units: [x1, y1, x2, y2].
|
||||
window: [f64; 4],
|
||||
/// Model-space placement; stays outside PageSetup so layouts keep their existing behavior.
|
||||
center: bool,
|
||||
offset_mm: [f64; 2],
|
||||
}
|
||||
|
||||
/// The page setup a plot of `target` starts from: the layout's, or model-space defaults (fitted
|
||||
|
|
@ -809,6 +838,7 @@ fn plot_params(st: &PlotState, preview_dpi: Option<f64>) -> Value {
|
|||
});
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
let device = device_label(page) != "None";
|
||||
let output = cadcraft_engine::cmd::plotters::find_device(&page.device).map_or("pdf", |d| d.output.as_str());
|
||||
match preview_dpi {
|
||||
// The page's own sheet, so a device's paper and printable area show as they plot.
|
||||
Some(dpi) => {
|
||||
|
|
@ -838,6 +868,12 @@ fn plot_params(st: &PlotState, preview_dpi: Option<f64>) -> Value {
|
|||
}
|
||||
if st.target == "Model" {
|
||||
o.insert("plotArea".into(), json!(page.plot_area));
|
||||
if output != "svg" {
|
||||
o.insert("center".into(), json!(st.center));
|
||||
if !st.center {
|
||||
o.insert("offset".into(), json!(st.offset_mm));
|
||||
}
|
||||
}
|
||||
if !page.scale_to_fit {
|
||||
o.insert("scale".into(), json!(page.scale));
|
||||
}
|
||||
|
|
@ -872,15 +908,31 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
|||
if ext.is_empty() {
|
||||
ext = cadcraft_geom::Bounds2::new(cadcraft_geom::Vec2::ZERO, cadcraft_geom::Vec2::new(12.0, 9.0));
|
||||
}
|
||||
PlotState { generation, page: plot_page(d, &target), target, window: [ext.min.x, ext.min.y, ext.max.x, ext.max.y] }
|
||||
PlotState {
|
||||
generation,
|
||||
page: plot_page(d, &target),
|
||||
target,
|
||||
window: [ext.min.x, ext.min.y, ext.max.x, ext.max.y],
|
||||
center: true,
|
||||
offset_mm: [0.0, 0.0],
|
||||
}
|
||||
});
|
||||
let model = st.target == "Model";
|
||||
let choices = PageChoices::new(ctx, d);
|
||||
let offset_supported =
|
||||
choices.devices.iter().find(|dev| dev.name.eq_ignore_ascii_case(&st.page.device)).is_none_or(|dev| dev.output.as_str() != "svg");
|
||||
// Print... in a preview opened from here plots with these settings.
|
||||
let mut ok = temp::<bool>(ctx, PLOT_NOW) == Some(true);
|
||||
set_temp(ctx, PLOT_NOW, false);
|
||||
let (mut cancel, mut action, mut pick, mut preview) = (false, None, false, false);
|
||||
egui::Window::new("Plot").open(open).collapsible(false).resizable(false).show(ctx, |ui| {
|
||||
let center = ctx.content_rect().center();
|
||||
egui::Window::new("Plot")
|
||||
.open(open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(center)
|
||||
.show(ctx, |ui| {
|
||||
egui::Grid::new("plot_target").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label("Layout");
|
||||
egui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| {
|
||||
|
|
@ -901,7 +953,8 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
|||
} else {
|
||||
PageRows { areas: &["layout"], scale: false, center: false }
|
||||
};
|
||||
if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices) {
|
||||
let placement = model.then_some((&mut st.center, &mut st.offset_mm, offset_supported));
|
||||
if let Some((k, v)) = page_rows(ui, egui::Id::new(("plot", st.target.as_str())), &st.page, &rows, &choices, placement) {
|
||||
apply_page_change(&mut st.page, k, &v);
|
||||
}
|
||||
if model && st.page.plot_area == "window" {
|
||||
|
|
@ -942,7 +995,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
|||
ok |= ui.button("OK").clicked();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
// The output file type follows the device.
|
||||
let ext = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&st.page.device)).map_or("pdf", |dv| dv.output.as_str());
|
||||
set_temp(ctx, "plot_state", st.clone());
|
||||
|
|
@ -1085,7 +1138,14 @@ mod plot_dialog_tests {
|
|||
|
||||
fn state(target: &str) -> PlotState {
|
||||
let d = cadcraft_doc::Drawing::new_metric();
|
||||
PlotState { generation: 0, page: plot_page(&d, target), target: target.into(), window: [0.0, 0.0, 10.0, 10.0] }
|
||||
PlotState {
|
||||
generation: 0,
|
||||
page: plot_page(&d, target),
|
||||
target: target.into(),
|
||||
window: [0.0, 0.0, 10.0, 10.0],
|
||||
center: true,
|
||||
offset_mm: [0.0, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1115,6 +1175,35 @@ mod plot_dialog_tests {
|
|||
assert!(plot_params(&st, None).get("plotStyleTable").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plot_center_and_offsets_are_model_only_and_omitted_for_svg() {
|
||||
let mut st = state("Model");
|
||||
assert_eq!(plot_params(&st, None)["center"], json!(true));
|
||||
st.center = false;
|
||||
st.offset_mm = [12.5, -7.0];
|
||||
let params = plot_params(&st, None);
|
||||
assert_eq!(params["center"], json!(false));
|
||||
assert_eq!(params["offset"], json!([12.5, -7.0]));
|
||||
let preview = plot_params(&st, Some(150.0));
|
||||
assert_eq!(preview["output"], json!("png"));
|
||||
assert_eq!(preview["offset"], json!([12.5, -7.0]));
|
||||
|
||||
let svg = cadcraft_engine::cmd::plotters::devices().into_iter().find(|dev| dev.output.as_str() == "svg").expect("built-in SVG device");
|
||||
apply_page_change(&mut st.page, "device", &json!(svg.name));
|
||||
let params = plot_params(&st, None);
|
||||
assert!(params.get("center").is_none() && params.get("offset").is_none(), "SVG does not receive unsupported placement options: {params}");
|
||||
let preview = plot_params(&st, Some(150.0));
|
||||
assert!(
|
||||
preview.get("center").is_none() && preview.get("offset").is_none(),
|
||||
"disabled SVG placement controls cannot affect preview output either"
|
||||
);
|
||||
|
||||
st.target = "Layout1".into();
|
||||
st.page = plot_page(&cadcraft_doc::Drawing::new_metric(), "Layout1");
|
||||
let params = plot_params(&st, None);
|
||||
assert!(params.get("center").is_none() && params.get("offset").is_none(), "layout plots retain their page setup mapping");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_pick_brings_the_dialog_back_with_the_picked_window() {
|
||||
let ctx = egui::Context::default();
|
||||
|
|
|
|||
|
|
@ -101,6 +101,11 @@ In the file name, characters other than letters, digits, spaces and `-_()+,` bec
|
|||
- The device sets the output and resolution.
|
||||
- `paper` (one of the device's papers) sets the size and margins for this plot.
|
||||
- A model-space plot without `paper` uses the device's default paper and orientation.
|
||||
- For Model plots, `center` defaults to `true`, preserving the existing centered output. Set
|
||||
`center: false` with `offset: [x, y]` to place the plotted bounds' lower-left corner from the
|
||||
printable area's lower-left origin; offsets are millimetres, default to `[0, 0]`, and are
|
||||
bounded to ±100,000 mm. PDF and PNG support these controls. SVG plots keep their extents view;
|
||||
an active offset is rejected rather than ignored. Layout plots do not use model offsets.
|
||||
- `exportpdf` takes the same options and always writes PDF.
|
||||
|
||||
Model-space PDF plots don't apply the paper's margins yet. They fit the drawing inside the
|
||||
|
|
|
|||
Loading…
Reference in New Issue