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:
cyfung1031 2026-10-13 17:34:04 +09:00 committed by GitHub
parent e71e17a8df
commit bcd6322ee9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 293 additions and 16 deletions

View File

@ -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

View File

@ -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");

View File

@ -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)> {

View File

@ -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, &centered), (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, &centered_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"));
}
}

View File

@ -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 {

View File

@ -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();

View File

@ -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