feat: plot a model-space window to PDF
Pick a rectangular window in model space with the existing PLOTWINDOW command (now enabled in model space, previously paper-space only) and export it to a PDF at a chosen ISO paper size (A4-A0), orientation and scale (fit or ratio). The window is a hard clip boundary. Adds paper-size presets, a window-to-sheet transform, scale + clip support in the PDF exporter, and Page Setup controls (format/orientation/pick window).
This commit is contained in:
parent
ced42daff5
commit
363593b09f
13 changed files with 444 additions and 24 deletions
|
|
@ -1148,8 +1148,19 @@ impl OpenCADStudio {
|
|||
use acadrust::objects::{ObjectType, PlotSettings};
|
||||
let layout_name = self.tabs[i].scene.current_layout.clone();
|
||||
if layout_name == "Model" {
|
||||
self.command_line
|
||||
.push_error("PLOTWINDOW: switch to a paper space layout first.");
|
||||
// Model space: remember the window (world X/Y) for the plot dialog.
|
||||
let x0 = p1.x.min(p2.x);
|
||||
let y0 = p1.y.min(p2.y);
|
||||
let x1 = p1.x.max(p2.x);
|
||||
let y1 = p1.y.max(p2.y);
|
||||
self.plot_window = Some((x0, y0, x1, y1));
|
||||
self.command_line.push_output(&format!(
|
||||
"Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}"
|
||||
));
|
||||
// Pick window closed Page Setup so the viewport could
|
||||
// receive the two clicks — bring it back for scale
|
||||
// review and the "Plot window → PDF" button.
|
||||
self.active_modal = Some(super::ModalKind::PageSetup);
|
||||
} else {
|
||||
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
|
||||
let doc = &mut self.tabs[i].scene.document;
|
||||
|
|
|
|||
|
|
@ -509,13 +509,11 @@ impl OpenCADStudio {
|
|||
}
|
||||
}
|
||||
|
||||
// Model space now has its own dialog section (window-plot format /
|
||||
// orientation / pick), so PAGESETUP opens there too, not just on
|
||||
// paper-space layouts.
|
||||
"PAGESETUP" => {
|
||||
if self.tabs[i].scene.current_layout == "Model" {
|
||||
self.command_line
|
||||
.push_error("PAGESETUP: switch to a paper space layout first.");
|
||||
} else {
|
||||
return Some(Task::done(Message::PageSetupOpen));
|
||||
}
|
||||
return Some(Task::done(Message::PageSetupOpen));
|
||||
}
|
||||
|
||||
// ── Recognized commands whose full implementation is pending ─────────
|
||||
|
|
|
|||
|
|
@ -520,6 +520,10 @@ pub(super) struct OpenCADStudio {
|
|||
page_setup_rotation: String,
|
||||
/// Plot scale: "Fit" | "1:1" | "1:2" | "1:4" | "1:5" | "1:10" | "1:20" | "1:50" | "1:100" | "2:1".
|
||||
page_setup_scale: String,
|
||||
/// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None.
|
||||
plot_window: Option<(f64, f64, f64, f64)>,
|
||||
plot_format: crate::io::paper_sizes::PaperSize,
|
||||
plot_orientation: crate::io::paper_sizes::Orientation,
|
||||
|
||||
// ── Plot Style Table ──────────────────────────────────────────────────
|
||||
/// Currently loaded CTB/STB table (None = no override).
|
||||
|
|
@ -1745,6 +1749,14 @@ pub enum Message {
|
|||
PlotExport,
|
||||
/// Callback after the user picks (or cancels) the export path.
|
||||
PlotExportPath(Option<std::path::PathBuf>),
|
||||
/// User picked a paper size for the model-space window plot.
|
||||
PlotFormat(crate::io::paper_sizes::PaperSize),
|
||||
/// User picked a sheet orientation for the model-space window plot.
|
||||
PlotOrientation(crate::io::paper_sizes::Orientation),
|
||||
/// Export the pending model-space plot window (from PLOTWINDOW) to PDF.
|
||||
PlotWindowExport,
|
||||
/// Callback after the user picks (or cancels) the window-export path.
|
||||
PlotWindowExportPath(Option<std::path::PathBuf>),
|
||||
/// Send current layout to the system printer (via lp / lpr).
|
||||
PrintToPrinter,
|
||||
/// Callback from the async printer job.
|
||||
|
|
@ -2089,6 +2101,9 @@ impl OpenCADStudio {
|
|||
page_setup_offset_y: "0.0".to_string(),
|
||||
page_setup_rotation: "0".to_string(),
|
||||
page_setup_scale: "Fit".to_string(),
|
||||
plot_window: None,
|
||||
plot_format: crate::io::paper_sizes::PaperSize::A4,
|
||||
plot_orientation: crate::io::paper_sizes::Orientation::Landscape,
|
||||
opening: None,
|
||||
pending_close: None,
|
||||
save_dialog_format: "DWG 2018".to_string(),
|
||||
|
|
|
|||
|
|
@ -1026,6 +1026,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
|||
draw_ox as f32,
|
||||
draw_oy as f32,
|
||||
rotation_deg,
|
||||
1.0,
|
||||
None,
|
||||
&path,
|
||||
self.active_plot_style.as_ref(),
|
||||
) {
|
||||
|
|
@ -1038,6 +1040,93 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
|||
Task::none()
|
||||
}
|
||||
|
||||
/// Export the pending model-space plot window (set by PLOTWINDOW while on
|
||||
/// the Model tab) to PDF, using the chosen paper size/orientation/scale.
|
||||
pub(super) fn on_plot_window_export_path_some(&mut self, path: std::path::PathBuf) -> Task<Message> {
|
||||
use crate::io::paper_sizes::{sheet_mm, window_to_sheet, PlotScale};
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].scene.current_layout != "Model" {
|
||||
self.command_line
|
||||
.push_error("Plot window export is for model space.");
|
||||
return Task::none();
|
||||
}
|
||||
let Some((x0, y0, x1, y1)) = self.plot_window else {
|
||||
self.command_line.push_error("No plot window. Pick one first.");
|
||||
return Task::none();
|
||||
};
|
||||
if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 {
|
||||
self.command_line
|
||||
.push_error("Plot window is empty. Pick a larger window.");
|
||||
return Task::none();
|
||||
}
|
||||
let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation);
|
||||
let win_w = (x1 - x0).max(1e-9);
|
||||
let win_h = (y1 - y0).max(1e-9);
|
||||
let scale_sel = if self.page_setup_scale.trim().eq_ignore_ascii_case("fit") {
|
||||
PlotScale::Fit
|
||||
} else {
|
||||
let (num, den) = parse_plot_scale(&self.page_setup_scale);
|
||||
if num > 0.0 && den > 0.0 {
|
||||
PlotScale::Ratio(num / den)
|
||||
} else {
|
||||
PlotScale::Fit
|
||||
}
|
||||
};
|
||||
let (scale, ox, oy) =
|
||||
window_to_sheet((win_w, win_h), (x0, y0), (sheet_w, sheet_h), scale_sel);
|
||||
|
||||
let scene = &self.tabs[i].scene;
|
||||
// Cull to the window so a small-area plot doesn't write the whole
|
||||
// drawing into the PDF; the clip below trims the partial crossers.
|
||||
// aabb is world X/Y [minx, miny, maxx, maxy].
|
||||
let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32);
|
||||
let wires: Vec<_> = scene
|
||||
.entity_wires()
|
||||
.into_iter()
|
||||
.filter(|w| {
|
||||
w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0
|
||||
})
|
||||
.collect();
|
||||
let hatches = scene.paper_canvas_hatches();
|
||||
let wipeouts = scene.paper_canvas_wipeouts();
|
||||
|
||||
// build_pdf maps a wire coordinate to sheet mm as (coord + offset) *
|
||||
// scale (rotation_deg == 0 adds no further CTM translation).
|
||||
// window_to_sheet's (ox, oy) is the sheet-mm target for the window's
|
||||
// min corner, so: scale * (x0 + offset_x) = ox => offset_x = ox/scale - x0.
|
||||
let offset_x = ((ox / scale) - x0) as f32;
|
||||
let offset_y = ((oy / scale) - y0) as f32;
|
||||
// Clip rect in pre-scale space (build_pdf's CTM applies `scale` to it,
|
||||
// same as the wires) so the final sheet-mm rect lands at (ox, oy).
|
||||
let clip = Some(((ox / scale) as f32, (oy / scale) as f32, win_w as f32, win_h as f32));
|
||||
|
||||
let res = crate::io::pdf_export::export_pdf(
|
||||
&wires,
|
||||
hatches.as_slice(),
|
||||
wipeouts.as_slice(),
|
||||
sheet_w,
|
||||
sheet_h,
|
||||
offset_x,
|
||||
offset_y,
|
||||
0,
|
||||
scale as f32,
|
||||
clip,
|
||||
&path,
|
||||
self.active_plot_style.as_ref(),
|
||||
);
|
||||
match res {
|
||||
Ok(()) => {
|
||||
self.command_line.push_info(&format!(
|
||||
"Plotted window to {}",
|
||||
path.file_name().unwrap_or_default().to_string_lossy()
|
||||
));
|
||||
self.close_active_modal();
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&format!("Plot failed: {e}")),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let scene = &self.tabs[i].scene;
|
||||
|
|
|
|||
|
|
@ -831,6 +831,14 @@ impl OpenCADStudio {
|
|||
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
|
||||
// Any command also dismisses the Isolate action menu.
|
||||
self.isolate_popup_open = false;
|
||||
// "Pick window" (PLOTWINDOW) from Page Setup needs the backdrop
|
||||
// gone so the viewport pick lands; every other command leaves an
|
||||
// open modal (and its staged edits) untouched.
|
||||
if cmd.trim().eq_ignore_ascii_case("PLOTWINDOW")
|
||||
|| cmd.trim().eq_ignore_ascii_case("PW")
|
||||
{
|
||||
self.close_active_modal();
|
||||
}
|
||||
self.dispatch_command(&cmd)
|
||||
}
|
||||
|
||||
|
|
@ -2987,6 +2995,30 @@ impl OpenCADStudio {
|
|||
Message::PlotExportPath(None) => Task::none(),
|
||||
Message::PlotExportPath(Some(path)) => self.on_plot_export_path_some(path),
|
||||
|
||||
Message::PlotFormat(f) => {
|
||||
self.plot_format = f;
|
||||
Task::none()
|
||||
}
|
||||
Message::PlotOrientation(o) => {
|
||||
self.plot_orientation = o;
|
||||
Task::none()
|
||||
}
|
||||
Message::PlotWindowExport => {
|
||||
let i = self.active_tab;
|
||||
let stem = self.tabs[i]
|
||||
.current_path
|
||||
.as_deref()
|
||||
.and_then(|p: &std::path::Path| p.file_stem())
|
||||
.map(|s: &std::ffi::OsStr| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "drawing".into());
|
||||
Task::perform(
|
||||
crate::io::pdf_export::pick_pdf_path_owned(stem),
|
||||
Message::PlotWindowExportPath,
|
||||
)
|
||||
}
|
||||
Message::PlotWindowExportPath(None) => Task::none(),
|
||||
Message::PlotWindowExportPath(Some(path)) => self.on_plot_window_export_path_some(path),
|
||||
|
||||
// ── Print to system printer ───────────────────────────────────────
|
||||
Message::PrintToPrinter => self.on_print_to_printer(),
|
||||
Message::PrintResult(Ok(printer)) => {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ impl OpenCADStudio {
|
|||
&self.page_setup_offset_y,
|
||||
&self.page_setup_rotation,
|
||||
&self.page_setup_scale,
|
||||
self.plot_format,
|
||||
self.plot_orientation,
|
||||
),
|
||||
520,
|
||||
460,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub mod xref;
|
|||
pub mod linetypes;
|
||||
pub mod patterns;
|
||||
pub mod update_check;
|
||||
pub mod paper_sizes;
|
||||
|
||||
use crate::scene::DerivedCaches;
|
||||
use acadrust::entities::EntityType;
|
||||
|
|
|
|||
130
src/io/paper_sizes.rs
Normal file
130
src/io/paper_sizes.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
//! ISO paper sizes and sheet orientation for window plotting.
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum PaperSize {
|
||||
A4,
|
||||
A3,
|
||||
A2,
|
||||
A1,
|
||||
A0,
|
||||
}
|
||||
|
||||
impl PaperSize {
|
||||
pub const ALL: [PaperSize; 5] = [
|
||||
PaperSize::A4,
|
||||
PaperSize::A3,
|
||||
PaperSize::A2,
|
||||
PaperSize::A1,
|
||||
PaperSize::A0,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
PaperSize::A4 => "A4",
|
||||
PaperSize::A3 => "A3",
|
||||
PaperSize::A2 => "A2",
|
||||
PaperSize::A1 => "A1",
|
||||
PaperSize::A0 => "A0",
|
||||
}
|
||||
}
|
||||
|
||||
/// Portrait dimensions in mm (width, height); width < height.
|
||||
pub fn dimensions_mm(self) -> (f64, f64) {
|
||||
match self {
|
||||
PaperSize::A4 => (210.0, 297.0),
|
||||
PaperSize::A3 => (297.0, 420.0),
|
||||
PaperSize::A2 => (420.0, 594.0),
|
||||
PaperSize::A1 => (594.0, 841.0),
|
||||
PaperSize::A0 => (841.0, 1189.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum Orientation {
|
||||
Portrait,
|
||||
Landscape,
|
||||
}
|
||||
|
||||
/// Sheet dimensions in mm for the given size and orientation.
|
||||
pub fn sheet_mm(size: PaperSize, o: Orientation) -> (f64, f64) {
|
||||
let (w, h) = size.dimensions_mm();
|
||||
match o {
|
||||
Orientation::Portrait => (w, h),
|
||||
Orientation::Landscape => (h, w),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
pub enum PlotScale {
|
||||
/// Scale so the window fills the sheet minus a 5% margin.
|
||||
Fit,
|
||||
/// Exact scale factor (mm per drawing unit).
|
||||
Ratio(f64),
|
||||
}
|
||||
|
||||
/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y):
|
||||
/// world (x, y) -> sheet mm ((x - window_min.0) * scale + offset_x, (y - window_min.1) * scale + offset_y).
|
||||
/// The scaled window is centered on the sheet.
|
||||
pub fn window_to_sheet(
|
||||
window_wh: (f64, f64),
|
||||
window_min: (f64, f64),
|
||||
sheet_mm: (f64, f64),
|
||||
scale: PlotScale,
|
||||
) -> (f64, f64, f64) {
|
||||
let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9));
|
||||
let scale = match scale {
|
||||
PlotScale::Ratio(r) => r.max(1e-9),
|
||||
PlotScale::Fit => {
|
||||
const MARGIN: f64 = 1.05;
|
||||
let sx = (sheet_mm.0 / MARGIN) / ww;
|
||||
let sy = (sheet_mm.1 / MARGIN) / wh;
|
||||
sx.min(sy)
|
||||
}
|
||||
};
|
||||
let scaled_w = ww * scale;
|
||||
let scaled_h = wh * scale;
|
||||
let offset_x = (sheet_mm.0 - scaled_w) / 2.0;
|
||||
let offset_y = (sheet_mm.1 - scaled_h) / 2.0;
|
||||
let _ = window_min; // offset already places the window min at (offset_x, offset_y)
|
||||
(scale, offset_x, offset_y)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn iso_dimensions_and_orientation() {
|
||||
assert_eq!(PaperSize::A4.dimensions_mm(), (210.0, 297.0));
|
||||
assert_eq!(PaperSize::A0.dimensions_mm(), (841.0, 1189.0));
|
||||
assert_eq!(PaperSize::ALL.len(), 5);
|
||||
assert_eq!(PaperSize::A3.label(), "A3");
|
||||
// Portrait keeps (w,h); landscape swaps.
|
||||
assert_eq!(sheet_mm(PaperSize::A4, Orientation::Portrait), (210.0, 297.0));
|
||||
assert_eq!(sheet_mm(PaperSize::A4, Orientation::Landscape), (297.0, 210.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_centers_and_scales_within_margin() {
|
||||
// 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width:
|
||||
// usable = 210/1.05 = 200; scale = 200/100 = 2.0.
|
||||
let (s, ox, oy) = window_to_sheet((100.0, 100.0), (0.0, 0.0), (210.0, 297.0), PlotScale::Fit);
|
||||
assert!((s - 2.0).abs() < 1e-9, "scale {s}");
|
||||
// Window is 100*2 = 200 wide/tall; centered on 210×297.
|
||||
assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}");
|
||||
assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratio_applies_exact_scale_and_offsets_window_min() {
|
||||
// Ratio(0.5): scale is exactly 0.5; a window starting at (10,20)
|
||||
// maps its min corner to the offset so (10,20) -> (ox,oy).
|
||||
let (s, ox, oy) = window_to_sheet((100.0, 80.0), (10.0, 20.0), (210.0, 297.0), PlotScale::Ratio(0.5));
|
||||
assert!((s - 0.5).abs() < 1e-9);
|
||||
// Centered: sheet_center - scaled_window_center, then min maps to offset.
|
||||
// scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2).
|
||||
assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}");
|
||||
assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}");
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,8 @@ pub fn export_pdf(
|
|||
_offset_x: f32,
|
||||
_offset_y: f32,
|
||||
_rotation_deg: i32,
|
||||
_scale: f32,
|
||||
_clip: Option<(f32, f32, f32, f32)>,
|
||||
_path: &Path,
|
||||
_plot_style: Option<&PlotStyleTable>,
|
||||
) -> Result<(), String> {
|
||||
|
|
@ -64,6 +66,8 @@ pub fn export_pdf(
|
|||
offset_x: f32,
|
||||
offset_y: f32,
|
||||
rotation_deg: i32,
|
||||
scale: f32,
|
||||
clip: Option<(f32, f32, f32, f32)>,
|
||||
path: &Path,
|
||||
plot_style: Option<&PlotStyleTable>,
|
||||
) -> Result<(), String> {
|
||||
|
|
@ -76,6 +80,8 @@ pub fn export_pdf(
|
|||
offset_x,
|
||||
offset_y,
|
||||
rotation_deg,
|
||||
scale,
|
||||
clip,
|
||||
plot_style,
|
||||
);
|
||||
let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?;
|
||||
|
|
@ -107,6 +113,8 @@ fn build_pdf(
|
|||
ox: f32,
|
||||
oy: f32,
|
||||
rotation_deg: i32,
|
||||
scale: f32,
|
||||
clip: Option<(f32, f32, f32, f32)>,
|
||||
plot_style: Option<&PlotStyleTable>,
|
||||
) -> Vec<u8> {
|
||||
let mut doc = PdfDocument::new("Open CAD Studio Export");
|
||||
|
|
@ -133,32 +141,67 @@ fn build_pdf(
|
|||
join: LineJoinStyle::Round,
|
||||
});
|
||||
|
||||
// Apply rotation transform if needed.
|
||||
// Apply rotation/scale/clip transform if needed.
|
||||
// PDF uses mm-based coordinate system with origin at bottom-left.
|
||||
// We save state, apply a CTM, then restore it after drawing.
|
||||
let needs_rotation = rotation_deg != 0;
|
||||
if needs_rotation {
|
||||
// We save state, apply a CTM (+ optional clip path), then restore after drawing.
|
||||
let needs_state = rotation_deg != 0 || (scale - 1.0).abs() > 1e-6 || clip.is_some();
|
||||
if needs_state {
|
||||
let (cos_a, sin_a, tx, ty) = match rotation_deg {
|
||||
90 => (0.0_f64, 1.0_f64, 0.0, paper_h as f64),
|
||||
180 => (-1.0_f64, 0.0_f64, paper_w as f64, paper_h as f64),
|
||||
270 => (0.0_f64, -1.0_f64, paper_w as f64, 0.0),
|
||||
_ => (1.0_f64, 0.0_f64, 0.0, 0.0),
|
||||
};
|
||||
// PDF CTM: [a b c d e f] = [cos sin -sin cos tx ty]
|
||||
let s = scale as f64;
|
||||
// PDF CTM: [a b c d e f] = [cos*s sin*s -sin*s cos*s tx ty]
|
||||
ops.push(Op::SaveGraphicsState);
|
||||
// Convert mm translation to points (1 mm = 2.834645 pt).
|
||||
let tx_pt = (tx * 2.834645) as f32;
|
||||
let ty_pt = (ty * 2.834645) as f32;
|
||||
ops.push(Op::SetTransformationMatrix {
|
||||
matrix: printpdf::CurTransMat::Raw([
|
||||
cos_a as f32,
|
||||
sin_a as f32,
|
||||
-(sin_a as f32),
|
||||
cos_a as f32,
|
||||
(cos_a * s) as f32,
|
||||
(sin_a * s) as f32,
|
||||
(-(sin_a) * s) as f32,
|
||||
(cos_a * s) as f32,
|
||||
tx_pt,
|
||||
ty_pt,
|
||||
]),
|
||||
});
|
||||
// Clip rectangle (mm), applied in the pre-scale coordinate space so it
|
||||
// matches the wires drawn under the same CTM.
|
||||
if let Some((cx, cy, cw, ch)) = clip {
|
||||
const MM_TO_PT: f32 = 2.834645;
|
||||
ops.push(Op::DrawPolygon {
|
||||
polygon: Polygon {
|
||||
rings: vec![PolygonRing {
|
||||
points: vec![
|
||||
LinePoint {
|
||||
p: Point { x: Pt(cx * MM_TO_PT), y: Pt(cy * MM_TO_PT) },
|
||||
bezier: false,
|
||||
},
|
||||
LinePoint {
|
||||
p: Point { x: Pt((cx + cw) * MM_TO_PT), y: Pt(cy * MM_TO_PT) },
|
||||
bezier: false,
|
||||
},
|
||||
LinePoint {
|
||||
p: Point {
|
||||
x: Pt((cx + cw) * MM_TO_PT),
|
||||
y: Pt((cy + ch) * MM_TO_PT),
|
||||
},
|
||||
bezier: false,
|
||||
},
|
||||
LinePoint {
|
||||
p: Point { x: Pt(cx * MM_TO_PT), y: Pt((cy + ch) * MM_TO_PT) },
|
||||
bezier: false,
|
||||
},
|
||||
],
|
||||
}],
|
||||
mode: PaintMode::Clip,
|
||||
winding_order: WindingOrder::NonZero,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// mm to PDF points (1 mm = 2.834645 pt).
|
||||
|
|
@ -203,7 +246,7 @@ fn build_pdf(
|
|||
}
|
||||
lw_override = ctb
|
||||
.resolve_lineweight(wire.aci)
|
||||
.map(|mm| (mm * MM_TO_PT).max(0.1));
|
||||
.map(|mm| (mm * MM_TO_PT).max(0.1) / scale.max(1e-6));
|
||||
}
|
||||
}
|
||||
// Near-white and near-yellow (viewport active border) → dark grey for print
|
||||
|
|
@ -239,8 +282,13 @@ fn build_pdf(
|
|||
last_color = Some([r, g, b]);
|
||||
}
|
||||
|
||||
// Line weight: CTB override (in pt) or screen px → points.
|
||||
let lw_pt = lw_override.unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1));
|
||||
// Line weight: CTB override (in pt) or screen px → points. Divided by
|
||||
// `scale` in both branches so pen widths stay absolute under the
|
||||
// scaled CTM above (AutoCAD keeps lineweights independent of plot
|
||||
// scale) — without this a Fit plot of a large window renders
|
||||
// near-invisible hairlines.
|
||||
let lw_pt = lw_override
|
||||
.unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1) / scale.max(1e-6));
|
||||
if last_lw.map(|l| (l - lw_pt).abs() > 0.01).unwrap_or(true) {
|
||||
ops.push(Op::SetOutlineThickness { pt: Pt(lw_pt) });
|
||||
last_lw = Some(lw_pt);
|
||||
|
|
@ -275,7 +323,7 @@ fn build_pdf(
|
|||
flush_line(&mut ops, &segment);
|
||||
}
|
||||
|
||||
if needs_rotation {
|
||||
if needs_state {
|
||||
ops.push(Op::RestoreGraphicsState);
|
||||
}
|
||||
|
||||
|
|
@ -440,3 +488,34 @@ fn emit_hatch(ops: &mut Vec<Op>, hatch: &HatchModel, ox: f32, oy: f32) {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clip_and_scale_emit_pdf_bytes() {
|
||||
let w = WireModel::solid(
|
||||
"test".into(),
|
||||
vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]],
|
||||
WireModel::WHITE,
|
||||
false,
|
||||
);
|
||||
let bytes = build_pdf(
|
||||
&[w],
|
||||
&[],
|
||||
&[],
|
||||
210.0,
|
||||
297.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
2.0,
|
||||
Some((10.0, 10.0, 100.0, 100.0)),
|
||||
None,
|
||||
);
|
||||
// A valid PDF is produced (starts with the PDF header) and is non-trivial.
|
||||
assert!(bytes.starts_with(b"%PDF"), "not a PDF");
|
||||
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ pub async fn print_wires(
|
|||
offset_x,
|
||||
offset_y,
|
||||
rotation_deg,
|
||||
1.0,
|
||||
None,
|
||||
&tmp_path,
|
||||
plot_style.as_ref(),
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ mod zoom_in;
|
|||
mod zoom_out;
|
||||
pub mod zoom_window;
|
||||
|
||||
use crate::modules::{CadModule, RibbonGroup, RibbonItem};
|
||||
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||
|
||||
pub struct ViewModule;
|
||||
|
||||
|
|
@ -137,6 +137,19 @@ impl CadModule for ViewModule {
|
|||
RibbonItem::Tool(cascade::tool()),
|
||||
],
|
||||
},
|
||||
// ── Plot ──────────────────────────────────────────────────────────
|
||||
// Model space has no paper-space side toolbar, so Page Setup
|
||||
// (format/orientation/pick window for PLOTWINDOW) needs an
|
||||
// entry here too.
|
||||
RibbonGroup {
|
||||
title: "Plot",
|
||||
tools: vec![RibbonItem::Tool(ToolDef {
|
||||
id: "PAGESETUP",
|
||||
label: "Page Setup",
|
||||
icon: IconKind::Svg(include_bytes!("../../../assets/icons/pagesetup.svg")),
|
||||
event: ModuleEvent::Command("PAGESETUP".to_string()),
|
||||
})],
|
||||
},
|
||||
]
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// PLOTWINDOW command — pick two corners to define the plot window area.
|
||||
//
|
||||
// Works only in paper space layouts. After picking P1 and P2, writes the
|
||||
// window to the layout's PlotSettings (PlotType::Window).
|
||||
// In paper space it writes the layout's PlotSettings (PlotType::Window); in
|
||||
// model space the host stores the window for the plot dialog.
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Page Setup window — fills the entire OS window.
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::io::paper_sizes::{Orientation, PaperSize};
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
|
||||
use iced::{Background, Border, Color, Element, Fill, Theme};
|
||||
|
||||
|
|
@ -154,6 +155,8 @@ pub fn view_window<'a>(
|
|||
offset_y: &'a str,
|
||||
rotation: &'a str,
|
||||
scale: &'a str,
|
||||
plot_format: PaperSize,
|
||||
plot_orientation: Orientation,
|
||||
) -> Element<'a, Message> {
|
||||
// ── Toolbar ───────────────────────────────────────────────────────────
|
||||
let toolbar = container(
|
||||
|
|
@ -286,6 +289,46 @@ pub fn view_window<'a>(
|
|||
]
|
||||
.spacing(4);
|
||||
|
||||
// ── Model-space window plot ───────────────────────────────────────────
|
||||
// Sheet size/orientation for PLOTWINDOW's clipped export; the scale
|
||||
// pills above (`scale_row1`/`scale_row2`) double as its plot scale.
|
||||
let format_row = {
|
||||
let mut r = row![lbl("Format")].spacing(4).align_y(iced::Center);
|
||||
for size in PaperSize::ALL {
|
||||
r = r.push(
|
||||
button(text(size.label()).size(10))
|
||||
.on_press(Message::PlotFormat(size))
|
||||
.style(pill(plot_format == size))
|
||||
.padding([3, 8]),
|
||||
);
|
||||
}
|
||||
r
|
||||
};
|
||||
let orient_row = row![
|
||||
lbl("Orientation"),
|
||||
button(text("Portrait").size(10))
|
||||
.on_press(Message::PlotOrientation(Orientation::Portrait))
|
||||
.style(pill(plot_orientation == Orientation::Portrait))
|
||||
.padding([3, 8]),
|
||||
button(text("Landscape").size(10))
|
||||
.on_press(Message::PlotOrientation(Orientation::Landscape))
|
||||
.style(pill(plot_orientation == Orientation::Landscape))
|
||||
.padding([3, 8]),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
let window_row = row![
|
||||
button(text("Pick window").size(10))
|
||||
.on_press(Message::Command("PLOTWINDOW".into()))
|
||||
.style(btn(false))
|
||||
.padding([4, 10]),
|
||||
button(text("Plot window → PDF").size(10))
|
||||
.on_press(Message::PlotWindowExport)
|
||||
.style(btn(true))
|
||||
.padding([4, 10]),
|
||||
]
|
||||
.spacing(6);
|
||||
|
||||
// ── Main scrollable form ──────────────────────────────────────────────
|
||||
let form = column![
|
||||
section_label("Paper Size"),
|
||||
|
|
@ -366,6 +409,11 @@ pub fn view_window<'a>(
|
|||
section_label("Plot Scale"),
|
||||
scale_row1,
|
||||
scale_row2,
|
||||
hdivider(),
|
||||
section_label("Model-Space Window Plot"),
|
||||
format_row,
|
||||
orient_row,
|
||||
window_row,
|
||||
]
|
||||
.spacing(10)
|
||||
.padding(16)
|
||||
|
|
|
|||
Loading…
Reference in a new issue