From eb0861cf6e559dd38229581948eec1d8d868b88d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 31 Jul 2026 11:16:14 +0300 Subject: [PATCH] fix(plot): align paper space output Use the same area pipeline as model space while keeping Layout bound to the physical sheet and printable margins. Share scale, rotation, clipping, and viewport fills across preview, PDF, and printer output. --- src/app/command_driver.rs | 52 +- src/app/update/file.rs | 903 ++++++++++++++++++++------------ src/io/pdf_export.rs | 20 +- src/io/print_to_printer.rs | 16 +- src/modules/view/plot_window.rs | 4 +- src/scene/entity.rs | 2 +- src/scene/paper.rs | 143 +++++ src/scene/project.rs | 262 +++++++++ 8 files changed, 1032 insertions(+), 370 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 094c51ab5..448fcdf7a 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1948,7 +1948,6 @@ impl OpenCADStudio { } } CmdResult::SetPlotWindow { p1, p2 } => { - use acadrust::objects::{ObjectType, PlotSettings}; let layout_name = self.tabs[i].scene.current_layout.clone(); if layout_name == "Model" { // Model space: remember the window (world X/Y) for the plot dialog. @@ -1965,42 +1964,21 @@ impl OpenCADStudio { self.plot_dialog.area = "Window".to_string(); self.active_modal = Some(super::ModalKind::Plot); } else { - let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); - let doc = &mut self.tabs[i].scene.document; - let ps_handle = doc.objects.iter().find_map(|(h, obj)| { - if let ObjectType::PlotSettings(ps) = obj { - if ps.page_name == layout_name { - Some(*h) - } else { - None - } - } else { - None - } - }); - let ps_entry = match ps_handle { - Some(h) => doc.objects.get_mut(&h), - None => { - let nh = acadrust::Handle::new(doc.next_handle()); - let ps = PlotSettings::new(layout_name.clone()); - doc.objects.insert(nh, ObjectType::PlotSettings(ps)); - doc.objects.get_mut(&nh) - } - }; - let _ = block_handle; - if let Some(ObjectType::PlotSettings(ps)) = ps_entry { - // Convert world-space points to DXF coordinates (X, Z plane → DXF X, Y). - let x1 = p1.x.min(p2.x) as f64; - let y1 = p1.z.min(p2.z) as f64; - let x2 = p1.x.max(p2.x) as f64; - let y2 = p1.z.max(p2.z) as f64; - ps.set_plot_window(x1, y1, x2, y2); - self.push_undo_snapshot(i, "PLOTWINDOW"); - self.tabs[i].dirty = true; - self.command_line.push_output(&format!( - "PLOTWINDOW: ({x1:.3},{y1:.3}) → ({x2:.3},{y2:.3})" - )); - } + // PLOTWINDOW always describes the plotted layout. In MSPACE + // the command points are model coordinates, so map them back + // through the active floating viewport first. + let p1 = self.tabs[i].scene.model_to_paper(p1); + let p2 = self.tabs[i].scene.model_to_paper(p2); + let x1 = p1.x.min(p2.x); + let y1 = p1.y.min(p2.y); + let x2 = p1.x.max(p2.x); + let y2 = p1.y.max(p2.y); + self.plot_window = Some((x1, y1, x2, y2)); + self.command_line.push_output(&format!( + "Plot window: {x1:.2},{y1:.2} to {x2:.2},{y2:.2}" + )); + self.plot_dialog.area = "Window".to_string(); + self.active_modal = Some(super::ModalKind::Plot); } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 279ba226a..c05a471d4 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -54,6 +54,90 @@ fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool { } } +type LayoutPlotParams = ( + std::sync::Arc>, + Vec, + Vec, + f64, + f64, + f64, + f64, + i32, + f32, + Option<(f32, f32, f32, f32)>, +); + +type ClippedPlotParams = ( + Vec, + Vec, + Vec, + f64, + f64, + f64, + f64, + i32, + f32, + Option<(f32, f32, f32, f32)>, +); + +fn plot_content_extents( + wires: &[crate::scene::WireModel], + hatches: &[crate::scene::model::hatch_model::HatchModel], + wipeouts: &[crate::scene::model::hatch_model::HatchModel], +) -> Option<(f64, f64, f64, f64)> { + let mut bounds = ( + f64::INFINITY, + f64::INFINITY, + f64::NEG_INFINITY, + f64::NEG_INFINITY, + ); + let mut include = |x: f64, y: f64| { + if x.is_finite() && y.is_finite() { + bounds.0 = bounds.0.min(x); + bounds.1 = bounds.1.min(y); + bounds.2 = bounds.2.max(x); + bounds.3 = bounds.3.max(y); + } + }; + for wire in wires { + let [x0, y0, x1, y1] = wire.aabb; + include(x0 as f64, y0 as f64); + include(x1 as f64, y1 as f64); + } + for hatch in wipeouts.iter().chain(hatches.iter()) { + for &[x, y] in hatch.boundary.iter() { + include(hatch.world_origin[0] + x as f64, hatch.world_origin[1] + y as f64); + } + } + (bounds.0.is_finite() + && bounds.1.is_finite() + && bounds.2.is_finite() + && bounds.3.is_finite() + && bounds.2 > bounds.0 + && bounds.3 > bounds.1) + .then_some(bounds) +} + +fn plot_scene_content( + scene: &crate::scene::Scene, +) -> ( + std::sync::Arc>, + Vec, + Vec, +) { + let mut wires = scene.entity_wires().as_ref().clone(); + let mut hatches = scene.paper_canvas_hatches().as_ref().clone(); + let mut wipeouts = scene.paper_canvas_wipeouts().as_ref().clone(); + if scene.current_layout != "Model" { + let (pattern_wires, viewport_hatches, viewport_wipeouts) = + scene.viewport_plot_fills(); + wires.extend(pattern_wires); + hatches.extend(viewport_hatches); + wipeouts.extend(viewport_wipeouts); + } + (std::sync::Arc::new(wires), hatches, wipeouts) +} + impl OpenCADStudio { /// Before a save, give every cached truck solid that still has no ACIS /// geometry (EXTRUDE/REVOLVE/SWEEP/LOFT/boolean results) an exact modeler @@ -1864,7 +1948,7 @@ pub(super) fn on_open_file(&mut self) -> Task { &mut self, w: f64, h: f64, - extents: bool, + plot_area: &str, center: bool, offset_x: f64, offset_y: f64, @@ -1873,11 +1957,10 @@ pub(super) fn on_open_file(&mut self) -> Task { ) { let i = self.active_tab; let layout_name = self.tabs[i].scene.current_layout.clone(); + let plot_window = self.plot_window; if layout_name != "Model" { let w: f64 = w.max(1.0); let h: f64 = h.max(1.0); - let plot_area = if extents { "Extents" } else { "Layout" }; - // Update the Layout object's limits AND its embedded // PlotSettings fields. `paper_limits()` (sheet rendering) and // the DWG writer both read these from the Layout, so a page @@ -1946,11 +2029,17 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.paper_width = w; ps.paper_height = h; ps.paper_units = PlotPaperUnits::Millimeters; - ps.plot_type = if plot_area == "Extents" { - PlotType::Extents - } else { - PlotType::Layout + ps.plot_type = match plot_area { + "Window" => PlotType::Window, + "Display" => PlotType::LastScreenDisplay, + "Extents" => PlotType::Extents, + _ => PlotType::Layout, }; + if plot_area == "Window" { + if let Some((x0, y0, x1, y1)) = plot_window { + ps.set_plot_window(x0, y0, x1, y1); + } + } ps.flags.plot_centered = center; ps.origin_x = offset_x; ps.origin_y = offset_y; @@ -1987,113 +2076,27 @@ pub(super) fn on_open_file(&mut self) -> Task { &mut self, path: std::path::PathBuf, ) -> Task { - let i = self.active_tab; - let scene = &self.tabs[i].scene; - let wires = scene.entity_wires(); - let hatches = scene.paper_canvas_hatches(); - let wipeouts = scene.paper_canvas_wipeouts(); - - // Read PlotSettings for current layout (if available). - use acadrust::objects::PlotType; - let ps_snap = scene.effective_plot_settings(); - - // Determine paper size and drawing offset. - let (paper_w, paper_h, mut draw_ox, mut draw_oy, rotation_deg) = - if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() { - // `paper_limits()` is in paper-space units; the PDF page is - // physical millimetres, so scale the sheet size back to mm via - // the layout's unit factor (identity for millimetre layouts). - let uf = scene.paper_space_unit_factor(); - let (pw, ph) = ((x1 - x0) / uf, (y1 - y0) / uf); - - // If PlotSettings says Extents, use model space extents instead. - let use_extents = ps_snap - .as_ref() - .map(|ps| matches!(ps.plot_type, PlotType::Extents)) - .unwrap_or(false); - - let (ox, oy) = if use_extents { - if let Some((mn, _mx)) = scene.model_space_extents() { - (-mn.x as f64, -mn.y as f64) - } else { - (-x0, -y0) - } - } else { - (-x0, -y0) - }; - - let rot = ps_snap - .as_ref() - .map(|ps| ps.rotation.to_degrees() as i32) - .unwrap_or(0); - - (pw, ph, ox, oy, rot) - } else { - // Model space: fit with 5% margin. - let margin = 1.05_f64; - if let Some((mn, mx)) = scene.model_space_extents() { - let w = ((mx.x - mn.x) as f64 * margin).max(1.0); - let h = ((mx.y - mn.y) as f64 * margin).max(1.0); - let pad_x = (w - (mx.x - mn.x) as f64) * 0.5; - let pad_y = (h - (mx.y - mn.y) as f64) * 0.5; - (w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0) - } else { - (297.0, 210.0, 0.0, 0.0, 0) - } - }; - - // Apply PlotSettings offset and centering. - if let Some(ref ps) = ps_snap { - if ps.flags.plot_centered { - // Centering: compute wire extents and re-centre. - let all_x: Vec = wires - .iter() - .flat_map(|w| w.points.iter().map(|p| p[0])) - .filter(|v| !v.is_nan()) - .collect(); - let all_y: Vec = wires - .iter() - .flat_map(|w| w.points.iter().map(|p| p[1])) - .filter(|v| !v.is_nan()) - .collect(); - if let (Some(&min_x), Some(&max_x), Some(&min_y), Some(&max_y)) = ( - all_x.iter().copied().reduce(f32::min).as_ref(), - all_x.iter().copied().reduce(f32::max).as_ref(), - all_y.iter().copied().reduce(f32::min).as_ref(), - all_y.iter().copied().reduce(f32::max).as_ref(), - ) { - let cx = (min_x + max_x) as f64 / 2.0; - let cy = (min_y + max_y) as f64 / 2.0; - draw_ox += paper_w / 2.0 - cx; - draw_oy += paper_h / 2.0 - cy; - } - } else { - draw_ox += ps.origin_x; - draw_oy += ps.origin_y; - } - } - - // For rotation: swap paper dimensions and note angle for export. - let (eff_w, eff_h) = match rotation_deg { - 90 | 270 => (paper_h, paper_w), - _ => (paper_w, paper_h), - }; - - let plot_style = self.active_plot_style.clone(); + let (wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip) = + self.layout_plot_params(); + let plot_style = if self.plot_dialog.with_styles { + self.active_plot_style.clone() + } else { + None + }; let worker_path = path.clone(); background_task( move || { crate::io::pdf_export::export_pdf( &wires, - hatches.as_slice(), - wipeouts.as_slice(), - eff_w, - eff_h, - draw_ox, - draw_oy, - rotation_deg, - 1.0, - None, + &hatches, + &wipeouts, + page_w, + page_h, + ox, + oy, + rotation, + scale, + clip, &worker_path, plot_style.as_ref(), ) @@ -2104,82 +2107,43 @@ pub(super) fn on_open_file(&mut self) -> Task { ) } - /// 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, + /// Export the pending Extents/Window/Display area using the same clipped + /// render path in model space and paper space. + pub(super) fn on_plot_window_export_path_some( + &mut self, + path: std::path::PathBuf, ) -> Task { - 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.plot_scale.trim().eq_ignore_ascii_case("fit") { - PlotScale::Fit - } else { - let (num, den) = parse_plot_scale(&self.plot_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), (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() - .iter() - .filter(|w| { - w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0 - }) - .cloned() - .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; - let offset_y = (oy / scale) - y0; - // 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 plot_style = self.active_plot_style.clone(); + let job = match self.plot_dialog.area.as_str() { + "Display" => self.display_plot_job(), + "Extents" => self.extents_plot_job(), + _ => self.window_plot_job(), + }; + let Some((wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip)) = + job + else { + self.command_line + .push_error("Plot area is empty. Pick a larger window."); + return Task::none(); + }; + let plot_style = if self.plot_dialog.with_styles { + self.active_plot_style.clone() + } else { + None + }; let worker_path = path.clone(); self.close_active_modal(); background_task( move || { 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, + &hatches, + &wipeouts, + page_w, + page_h, + ox, + oy, + rotation, + scale, clip, &worker_path, plot_style.as_ref(), @@ -2197,91 +2161,194 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Build the render inputs and page geometry for a full-layout plot: wires /// plus hatch / wipeout fills, the effective (rotation-swapped) sheet size, - /// the draw-origin offset, and the rotation. Shared by print + preview so - /// the Plot dialog reuses the same tested derivation. - #[allow(clippy::type_complexity)] - pub(super) fn layout_plot_params( - &self, - ) -> ( - std::sync::Arc>, - Vec, - Vec, - f64, - f64, - // Draw offsets stay f64: they cancel an absolute world coordinate that - // an f32 cannot hold at UTM scale. - f64, - f64, - i32, - ) { + /// draw-origin offset, rotation, unit scale, and optional clip. Shared by + /// PDF export, preview, and printer output so all three render identically. + pub(super) fn layout_plot_params(&self) -> LayoutPlotParams { let i = self.active_tab; let scene = &self.tabs[i].scene; - let wires = scene.entity_wires(); - let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone(); - let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone(); - use acadrust::objects::PlotType; - let ps_snap = scene.effective_plot_settings(); - let (paper_w, paper_h, draw_ox, draw_oy, rotation_deg) = - if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() { - // `paper_limits()` is in paper-space units; the PDF page is - // physical millimetres, so scale the sheet size back to mm via - // the layout's unit factor (identity for millimetre layouts). - let uf = scene.paper_space_unit_factor(); - let (pw, ph) = ((x1 - x0) / uf, (y1 - y0) / uf); - let use_extents = ps_snap - .as_ref() - .map(|ps| matches!(ps.plot_type, PlotType::Extents)) - .unwrap_or(false); - let (ox, oy) = if use_extents { - if let Some((mn, _mx)) = scene.model_space_extents() { - (-mn.x as f64, -mn.y as f64) - } else { - (-x0, -y0) - } - } else { - (-x0, -y0) - }; - let rot = ps_snap - .as_ref() - .map(|ps| ps.rotation.to_degrees() as i32) - .unwrap_or(0); - (pw, ph, ox, oy, rot) - } else if let Some((mn, mx)) = scene.model_space_extents() { - let margin = 1.05_f64; - let w = ((mx.x - mn.x) as f64 * margin).max(1.0); - let h = ((mx.y - mn.y) as f64 * margin).max(1.0); - let pad_x = (w - (mx.x - mn.x) as f64) * 0.5; - let pad_y = (h - (mx.y - mn.y) as f64) * 0.5; - (w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0) - } else { - (297.0, 210.0, 0.0, 0.0, 0) - }; - let (eff_w, eff_h) = match rotation_deg { - 90 | 270 => (paper_h, paper_w), - _ => (paper_w, paper_h), + let paper_space = scene.current_layout != "Model"; + let (source_wires, hatches, wipeouts) = plot_scene_content(scene); + // The printable-area rectangle is an on-screen guide, not drawing + // content. It used to leak into every paper-space PDF/preview/print. + let wires = if paper_space { + std::sync::Arc::new( + source_wires + .iter() + .filter(|wire| wire.name != "paper_printable_area") + .cloned() + .collect(), + ) + } else { + source_wires }; + if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() { + // Paper geometry is stored in layout units; PDF pages use mm. + // Scale both geometry and offsets by the same unit conversion. + // The old path converted only page width/height, so inch/scaled + // layouts placed tiny or 25.4×-shifted content on the page. + let units_per_mm = scene.paper_space_unit_factor().max(1e-9); + let mm_per_unit = 1.0 / units_per_mm; + let paper_w = ((x1 - x0) * mm_per_unit).max(1.0); + let paper_h = ((y1 - y0) * mm_per_unit).max(1.0); + // Preview/print is a read-only operation unless "Save to layout" is + // checked. Therefore its transient choices must come from the dialog, + // not the layout's previously persisted PlotSettings. + let dialog_rotation = self + .plot_dialog + .rotation + .trim_end_matches('°') + .parse::() + .unwrap_or(0); + let plot_extents = self.plot_dialog.area == "Extents"; + let plot_layout = self.plot_dialog.area == "Layout"; + // `paper_limits()` already returns the current layout's final, + // rotation-adjusted sheet bounds. Applying its persisted plot + // rotation again swaps the page and rotates the printable clip out + // of view, producing a blank PDF for rotated custom sheets. + let rotation = if plot_layout { 0 } else { dialog_rotation }; + let centered = self.plot_dialog.center; + let origin_x = self + .plot_dialog + .offset_x + .parse::() + .unwrap_or(0.0); + let origin_y = self + .plot_dialog + .offset_y + .parse::() + .unwrap_or(0.0); + + let (scale, offset_x, offset_y) = if plot_extents { + let (min_x, min_y, max_x, max_y) = + plot_content_extents(&wires, &hatches, &wipeouts) + .unwrap_or((x0, y0, x1, y1)); + let content_w = (max_x - min_x).max(1e-9); + let content_h = (max_y - min_y).max(1e-9); + let scale = if self.plot_dialog.scale == "Fit" { + const MARGIN: f64 = 1.05; + ((paper_w / MARGIN) / content_w) + .min((paper_h / MARGIN) / content_h) + .max(1e-9) + } else { + let (num, den) = parse_plot_scale(&self.plot_dialog.scale); + ((num / den) * mm_per_unit).max(1e-9) + }; + let target_x = if centered { + (paper_w - content_w * scale) * 0.5 + } else { + origin_x + }; + let target_y = if centered { + (paper_h - content_h * scale) * 0.5 + } else { + origin_y + }; + ( + scale, + target_x / scale - min_x, + target_y / scale - min_y, + ) + } else if plot_layout { + // Layout is the one Paper-only plot mode: reproduce the sheet + // exactly. Its lower-left paper bound maps to PDF (0, 0); + // persisted plot origins/centering are already represented by + // the layout geometry and must not shift the sheet again. + (mm_per_unit, -x0, -y0) + } else { + // Legacy direct callers still get dialog positioning. Normal + // Display/Window paths use `area_plot_job` instead. + let scale = mm_per_unit; + let target_x = if centered { + (paper_w - (x1 - x0) * scale) * 0.5 + } else { + origin_x + }; + let target_y = if centered { + (paper_h - (y1 - y0) * scale) * 0.5 + } else { + origin_y + }; + (scale, target_x / scale - x0, target_y / scale - y0) + }; + let (page_w, page_h) = match rotation { + 90 | 270 => (paper_h, paper_w), + _ => (paper_w, paper_h), + }; + // Layout plots keep the full physical sheet as the PDF page, but + // ink is restricted to the device's printable rectangle. Extents + // has its own fitted area and must not inherit this paper clip. + let clip = if plot_layout { + scene.printable_area_limits().map(|((px0, py0), (px1, py1))| { + ( + (px0 + offset_x) as f32, + (py0 + offset_y) as f32, + (px1 - px0) as f32, + (py1 - py0) as f32, + ) + }) + } else { + None + }; + return ( + wires, + hatches, + wipeouts, + page_w, + page_h, + offset_x, + offset_y, + rotation, + scale as f32, + clip, + ); + } + + // Model space keeps its established extents + 5% margin behaviour. + let (page_w, page_h, offset_x, offset_y) = + if let Some((min, max)) = scene.model_space_extents() { + let margin = 1.05_f64; + let width = ((max.x - min.x) as f64 * margin).max(1.0); + let height = ((max.y - min.y) as f64 * margin).max(1.0); + let pad_x = (width - (max.x - min.x) as f64) * 0.5; + let pad_y = (height - (max.y - min.y) as f64) * 0.5; + ( + width, + height, + -(min.x as f64) + pad_x, + -(min.y as f64) + pad_y, + ) + } else { + (297.0, 210.0, 0.0, 0.0) + }; ( wires, hatches, wipeouts, - eff_w, - eff_h, - draw_ox, - draw_oy, - rotation_deg, + page_w, + page_h, + offset_x, + offset_y, + 0, + 1.0, + None, ) } pub(super) fn on_print_to_printer(&mut self) -> Task { - let (wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rotation_deg) = + let (wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip) = self.layout_plot_params(); - let plot_style = self.active_plot_style.clone(); + let plot_style = if self.plot_dialog.with_styles { + self.active_plot_style.clone() + } else { + None + }; self.command_line.push_info("Sending to system printer…"); background_task( move || { iced::futures::executor::block_on( crate::io::print_to_printer::print_wires( - wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rotation_deg, plot_style, + wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, + plot_style, )) }, Message::PrintResult, @@ -2415,11 +2482,40 @@ pub(super) fn on_open_file(&mut self) -> Task { self.select_page_setup(crate::ui::window::plot::SETUP_NONE); } else { self.select_page_setup(&format!("*{cur}*")); + if self.plot_dialog.area != "Layout" { + // A layout may persist Window/Extents/Display together with its + // sheet-only origin/rotation. Start common area modes with the + // same neutral transform as Model even when no Area-change event + // fires after opening the dialog. + self.normalize_common_plot_dialog(); + } } self.active_modal = Some(crate::app::ModalKind::Plot); Task::none() } + fn normalize_common_plot_dialog(&mut self) { + let layout_rotation = self + .plot_dialog + .rotation + .trim_end_matches('°') + .parse::() + .unwrap_or(0); + if matches!(layout_rotation, 90 | 270) { + self.plot_dialog.orientation = if self.plot_dialog.orientation == "Portrait" { + "Landscape" + } else { + "Portrait" + } + .into(); + } + self.plot_dialog.center = true; + self.plot_dialog.offset_x = "0.0".into(); + self.plot_dialog.offset_y = "0.0".into(); + self.plot_dialog.rotation = "0°".into(); + self.plot_dialog.scale = "Fit".into(); + } + /// Handle one edit / action from the Plot dialog. pub(super) fn on_plot_dlg( &mut self, @@ -2456,6 +2552,14 @@ pub(super) fn on_open_file(&mut self) -> Task { Task::none() } M::Area(s) => { + // A paper Layout page setup commonly carries physical-sheet + // rotation, origin and 1:1 scale. Those values are correct only + // for Layout; carrying them into Window/Extents/Display moves a + // large selected area off-page. Enter the three common area + // modes with the same neutral defaults used in Model space. + if self.plot_dialog.area == "Layout" && s != "Layout" { + self.normalize_common_plot_dialog(); + } self.plot_dialog.area = s; Task::none() } @@ -2510,6 +2614,21 @@ pub(super) fn on_open_file(&mut self) -> Task { Task::none() } M::PickWindow => { + let i = self.active_tab; + if self.tabs[i].scene.current_layout != "Model" + && self.tabs[i].scene.active_viewport.is_some() + { + // A layout Window is selected in paper coordinates and may + // extend anywhere on the canvas, including outside the sheet. + // Leave MSPACE so the floating viewport cannot constrain the + // two picks to its model view. + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.active_viewport = None; + self.adopt_view_display(i); + self.tabs[i].refresh_active_ucs(); + self.refresh_properties(); + self.sync_dyn_fields(); + } self.close_active_modal(); Task::done(Message::Command("PLOTWINDOW".into())) } @@ -2700,8 +2819,14 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.plot_type = match d.area.as_str() { "Window" => PlotType::Window, "Layout" => PlotType::Layout, + "Display" => PlotType::LastScreenDisplay, _ => PlotType::Extents, }; + if d.area == "Window" { + if let Some((x0, y0, x1, y1)) = self.plot_window { + ps.set_plot_window(x0, y0, x1, y1); + } + } ps.flags.plot_centered = d.center; ps.origin_x = d.offset_x.parse::().unwrap_or(0.0); ps.origin_y = d.offset_y.parse::().unwrap_or(0.0); @@ -2728,6 +2853,14 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Load a `PlotSettings` into the dialog editor fields. fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) { use acadrust::objects::PlotType; + if matches!(ps.plot_type, PlotType::Window) && !ps.plot_window.is_empty() { + self.plot_window = Some(( + ps.plot_window.lower_left_x, + ps.plot_window.lower_left_y, + ps.plot_window.upper_right_x, + ps.plot_window.upper_right_y, + )); + } let (paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); let d = &mut self.plot_dialog; d.paper = paper; @@ -2735,6 +2868,7 @@ pub(super) fn on_open_file(&mut self) -> Task { d.area = match ps.plot_type { PlotType::Window => "Window", PlotType::Layout => "Layout", + PlotType::LastScreenDisplay => "Display", _ => "Extents", } .to_string(); @@ -2758,10 +2892,10 @@ pub(super) fn on_open_file(&mut self) -> Task { }; } - /// Write the current dialog fields into the active layout's plot settings. - /// Shared by the "Set current" action and the print/export commit. - fn apply_dialog_to_layout(&mut self) { - use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; + /// Copy transient paper/scale choices out of the dialog without changing + /// the active layout. + fn sync_dialog_plot_runtime(&mut self) { + use crate::io::paper_sizes::{Orientation, PaperSize}; let d = self.plot_dialog.clone(); let paper = match d.paper.as_str() { "A3" => PaperSize::A3, @@ -2775,28 +2909,44 @@ pub(super) fn on_open_file(&mut self) -> Task { } else { Orientation::Landscape }; - let (sheet_w, sheet_h) = sheet_mm(paper, orient); self.plot_format = paper; self.plot_orientation = orient; self.plot_scale = d.scale.clone(); + } + + fn apply_dialog_to_layout(&mut self) { + use crate::io::paper_sizes::sheet_mm; + self.sync_dialog_plot_runtime(); + let d = self.plot_dialog.clone(); + let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); let rotation: i16 = d.rotation.trim_end_matches('°').parse().unwrap_or(0); let off_x = d.offset_x.parse::().unwrap_or(0.0); let off_y = d.offset_y.parse::().unwrap_or(0.0); - let extents = d.area != "Layout"; self.apply_plot_page_settings( - sheet_w, sheet_h, extents, d.center, off_x, off_y, rotation, &d.scale, + sheet_w, + sheet_h, + &d.area, + d.center, + off_x, + off_y, + rotation, + &d.scale, ); } - /// Apply the dialog's page settings to the layout, then either open a - /// preview PDF, export a PDF, or send the job to the chosen printer. + /// Open a preview PDF, export a PDF, or send the job to the chosen printer. + /// The active layout changes only when "Save to layout" is checked. fn on_plot_dlg_commit(&mut self, preview: bool) -> Task { // Remember the user's print preferences across sessions. self.save_config(); let d = self.plot_dialog.clone(); - // Persist the dialog's page settings into the layout, then reuse the - // tested layout-plot derivation. - self.apply_dialog_to_layout(); + // Preview and normal printing must not resize/mutate the live layout. + // Persist only when explicitly requested; the runtime paper/scale + // choices still drive this one plot operation. + self.sync_dialog_plot_runtime(); + if !preview && d.save_layout { + self.apply_dialog_to_layout(); + } self.active_modal = None; let plot_style = if d.with_styles { @@ -2805,24 +2955,40 @@ pub(super) fn on_open_file(&mut self) -> Task { None }; - // ── Window area: clipped model-space plot ──────────────────────────── - if d.area == "Window" { + // Extents, Window and Display use one plot path in both spaces. Only + // Paper-space Layout is special: it uses the physical sheet bounds. + if matches!(d.area.as_str(), "Extents" | "Window" | "Display") { + let job = match d.area.as_str() { + "Display" => self.display_plot_job(), + "Extents" => self.extents_plot_job(), + _ => self.window_plot_job(), + }; + let Some(( + w_wires, + w_hatches, + w_wipeouts, + sw, + sh, + wox, + woy, + wrotation, + wscale, + wclip, + )) = job + else { + self.command_line + .push_error("Plot area is empty. Pick a larger window."); + self.active_modal = Some(crate::app::ModalKind::Plot); + return Task::none(); + }; // Preview renders to a temp PDF and opens it — never a save dialog, // whatever the output target is. if preview { - let Some((w_wires, w_hatches, w_wipeouts, sw, sh, wox, woy, wscale, wclip)) = - self.window_plot_job() - else { - self.command_line - .push_error("Pick a plot window first (model space)."); - self.active_modal = Some(crate::app::ModalKind::Plot); - return Task::none(); - }; let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); return background_task( move || { crate::io::pdf_export::export_pdf( - &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp, + &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, plot_style.as_ref(), ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| "Opened plot preview.".to_string()).map_err(|e| format!("Preview failed: {e}")) @@ -2834,20 +3000,12 @@ pub(super) fn on_open_file(&mut self) -> Task { // Tested clipped export (opens a save dialog). return Task::done(Message::PlotWindowExport); } - let Some((w_wires, w_hatches, w_wipeouts, sw, sh, wox, woy, wscale, wclip)) = - self.window_plot_job() - else { - self.command_line - .push_error("Pick a plot window first (model space)."); - self.active_modal = Some(crate::app::ModalKind::Plot); - return Task::none(); - }; let tmp = std::env::temp_dir().join("open_cad_studio_print.pdf"); let opts = self.plot_print_options(&d); return background_task( move || { crate::io::pdf_export::export_pdf( - &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp, + &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, plot_style.as_ref(), ).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) .map(|printer| format!("Sent to printer: {printer}")) @@ -2857,7 +3015,8 @@ pub(super) fn on_open_file(&mut self) -> Task { ); } - let (wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot) = self.layout_plot_params(); + let (wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip) = + self.layout_plot_params(); if preview { let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); @@ -2867,13 +3026,13 @@ pub(super) fn on_open_file(&mut self) -> Task { &wires, &hatches, &wipeouts, - eff_w, - eff_h, + page_w, + page_h, ox, oy, - rot, - 1.0, - None, + rotation, + scale, + clip, &tmp, plot_style.as_ref(), ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) @@ -2894,7 +3053,8 @@ pub(super) fn on_open_file(&mut self) -> Task { move || { iced::futures::executor::block_on( crate::io::print_to_printer::print_wires_with( - wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot, plot_style, opts, + wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, + plot_style, opts, )) }, Message::PrintResult, @@ -2916,33 +3076,100 @@ pub(super) fn on_open_file(&mut self) -> Task { } } - /// Render inputs + page geometry for a clipped model-space window plot - /// (the pending `plot_window`, fitted onto the dialog's sheet). Returns - /// `None` when not in model space or no window has been picked. Mirrors - /// [`Self::on_plot_window_export_path_some`] but hands back the params so - /// the plot can go to a printer or preview, not only a save-dialog PDF. - #[allow(clippy::type_complexity)] - fn window_plot_job( - &self, - ) -> Option<( - Vec, - Vec, - Vec, - f64, - f64, - // Offsets stay f64 (they cancel a UTM-scale world coordinate); the plot - // scale is a small ratio and stays f32. - f64, - f64, - f32, - Option<(f32, f32, f32, f32)>, - )> { - use crate::io::paper_sizes::{sheet_mm, window_to_sheet, PlotScale}; - let i = self.active_tab; - if self.tabs[i].scene.current_layout != "Model" { + fn window_plot_job(&self) -> Option { + self.area_plot_job(self.plot_window?) + } + + fn display_plot_job(&self) -> Option { + self.area_plot_job(self.display_plot_window()?) + } + + fn extents_plot_job(&self) -> Option { + let scene = &self.tabs[self.active_tab].scene; + if scene.current_layout == "Model" { + let (min, max) = scene.model_space_extents()?; + return self.area_plot_job(( + min.x as f64, + min.y as f64, + max.x as f64, + max.y as f64, + )); + } + let (wires, hatches, wipeouts) = plot_scene_content(scene); + let extents = plot_content_extents( + &wires + .iter() + .filter(|wire| wire.name != "paper_printable_area") + .cloned() + .collect::>(), + &hatches, + &wipeouts, + )?; + self.area_plot_job(extents) + } + + /// Current visible rectangle in the active space. The result is deliberately + /// not clamped to the paper sheet: Display and Window may include the grey + /// canvas outside the sheet, matching Model-space plotting. + fn display_plot_window(&self) -> Option<(f64, f64, f64, f64)> { + let scene = &self.tabs[self.active_tab].scene; + let (canvas_w, canvas_h) = scene.selection.borrow().vp_size; + let viewport = if scene.current_layout == "Model" { + scene.active_model_tile_bounds(canvas_w, canvas_h) + } else { + iced::Rectangle { + x: 0.0, + y: 0.0, + width: canvas_w, + height: canvas_h, + } + }; + if viewport.width < 1.0 || viewport.height < 1.0 { return None; } - let (x0, y0, x1, y1) = self.plot_window?; + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: viewport.width, + height: viewport.height, + }; + let camera = scene.camera.borrow(); + let mut x0 = f64::INFINITY; + let mut y0 = f64::INFINITY; + let mut x1 = f64::NEG_INFINITY; + let mut y1 = f64::NEG_INFINITY; + for point in [ + iced::Point::new(0.0, 0.0), + iced::Point::new(bounds.width, 0.0), + iced::Point::new(bounds.width, bounds.height), + iced::Point::new(0.0, bounds.height), + ] { + let world = camera.unproject_on_plane( + point, + bounds, + glam::Vec3::Z, + glam::DVec3::ZERO, + ); + x0 = x0.min(world.x); + y0 = y0.min(world.y); + x1 = x1.max(world.x); + y1 = y1.max(world.y); + } + (x0.is_finite() + && y0.is_finite() + && x1.is_finite() + && y1.is_finite() + && x1 - x0 > 1e-6 + && y1 - y0 > 1e-6) + .then_some((x0, y0, x1, y1)) + } + + /// Render a selected rectangle through one shared Model/Paper path. The + /// window may lie partly or wholly outside a paper sheet. + fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { + use crate::io::paper_sizes::{sheet_mm, window_to_sheet, PlotScale}; + let i = self.active_tab; + let (x0, y0, x1, y1) = window; if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 { return None; } @@ -2954,32 +3181,66 @@ pub(super) fn on_open_file(&mut self) -> Task { } else { let (num, den) = parse_plot_scale(&self.plot_scale); if num > 0.0 && den > 0.0 { - PlotScale::Ratio(num / den) + let mm_per_unit = if self.tabs[i].scene.current_layout == "Model" { + 1.0 + } else { + 1.0 / self.tabs[i].scene.paper_space_unit_factor().max(1e-9) + }; + PlotScale::Ratio((num / den) * mm_per_unit) } else { PlotScale::Fit } }; - let (scale, ox, oy) = window_to_sheet((win_w, win_h), (sheet_w, sheet_h), scale_sel); + let (scale, centered_x, centered_y) = + window_to_sheet((win_w, win_h), (sheet_w, sheet_h), scale_sel); + let target_x = if self.plot_dialog.center { + centered_x + } else { + self.plot_dialog.offset_x.parse::().unwrap_or(0.0) + }; + let target_y = if self.plot_dialog.center { + centered_y + } else { + self.plot_dialog.offset_y.parse::().unwrap_or(0.0) + }; let scene = &self.tabs[i].scene; let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); - let wires: Vec<_> = scene - .entity_wires() + let (all_wires, hatches, wipeouts) = plot_scene_content(scene); + let wires: Vec<_> = all_wires .iter() + .filter(|w| w.name != "paper_printable_area") .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) .cloned() .collect(); - let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone(); - let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone(); - let offset_x = (ox / scale) - x0; - let offset_y = (oy / scale) - y0; + let offset_x = (target_x / scale) - x0; + let offset_y = (target_y / scale) - y0; let clip = Some(( - (ox / scale) as f32, - (oy / scale) as f32, + (target_x / scale) as f32, + (target_y / scale) as f32, win_w as f32, win_h as f32, )); + let rotation = self + .plot_dialog + .rotation + .trim_end_matches('°') + .parse::() + .unwrap_or(0); + let (page_w, page_h) = match rotation { + 90 | 270 => (sheet_h, sheet_w), + _ => (sheet_w, sheet_h), + }; Some(( - wires, hatches, wipeouts, sheet_w, sheet_h, offset_x, offset_y, scale as f32, clip, + wires, + hatches, + wipeouts, + page_w, + page_h, + offset_x, + offset_y, + rotation, + scale as f32, + clip, )) } diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 87d080cc8..7faf5019f 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -170,9 +170,12 @@ fn build_pdf( 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), + // `paper_w`/`paper_h` are already the effective, rotation-swapped + // page dimensions. A 90° turn maps x' = page_w - y, y' = x; + // 270° maps x' = y, y' = page_h - x. + 90 => (0.0_f64, 1.0_f64, paper_w as f64, 0.0), 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), + 270 => (0.0_f64, -1.0_f64, 0.0, paper_h as f64), _ => (1.0_f64, 0.0_f64, 0.0, 0.0), }; let s = scale as f64; @@ -245,8 +248,9 @@ fn build_pdf( if a < 0.01 { continue; } - // Skip the paper-boundary wire — the white PDF background already provides it. - if wire.name == "__paper_boundary__" { + // Skip screen-only paper helpers. The PDF page supplies its own white + // boundary, and the printable-area rectangle is a UI guide, not ink. + if matches!(wire.name.as_str(), "__paper_boundary__" | "paper_printable_area") { continue; } // Apply CTB plot style table overrides (color + lineweight). @@ -433,7 +437,13 @@ fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f64, oy: f64) { // would vanish white-on-white on paper. Force near-white/near-yellow → black // and near-cyan → dark blue, matching AutoCAD's colour-7-on-white plotting. // Genuine colours are untouched; WIPEOUTS keep their paper-white mask. - if hatch.name != "WIPEOUT_FILL" { + if hatch.name == "WIPEOUT_FILL" { + // Screen wipeouts match the configured canvas colour; printed + // wipeouts must mask with the white paper colour. + r = 1.0; + g = 1.0; + b = 1.0; + } else { let is_light = r > 0.80 && g > 0.80 && b > 0.80; let is_yellow = r > 0.80 && g > 0.70 && b < 0.30; let is_cyan = r < 0.30 && g > 0.70 && b > 0.70; diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 586eee0f9..fd45793e3 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -44,6 +44,8 @@ pub async fn print_wires( _offset_x: f64, _offset_y: f64, _rotation_deg: i32, + _scale: f32, + _clip: Option<(f32, f32, f32, f32)>, _plot_style: Option, ) -> Result { Err("Printing is not available in the web version.".into()) @@ -65,6 +67,8 @@ pub async fn print_wires_with( _offset_x: f64, _offset_y: f64, _rotation_deg: i32, + _scale: f32, + _clip: Option<(f32, f32, f32, f32)>, _plot_style: Option, _opts: PrintOptions, ) -> Result { @@ -95,6 +99,8 @@ pub async fn print_wires( offset_x: f64, offset_y: f64, rotation_deg: i32, + scale: f32, + clip: Option<(f32, f32, f32, f32)>, plot_style: Option, ) -> Result { // ── 1. Write to a named temp file ───────────────────────────────────── @@ -108,8 +114,8 @@ pub async fn print_wires( offset_x, offset_y, rotation_deg, - 1.0, - None, + scale, + clip, &tmp_path, plot_style.as_ref(), )?; @@ -158,6 +164,8 @@ pub async fn print_wires_with( offset_x: f64, offset_y: f64, rotation_deg: i32, + scale: f32, + clip: Option<(f32, f32, f32, f32)>, plot_style: Option, opts: PrintOptions, ) -> Result { @@ -171,8 +179,8 @@ pub async fn print_wires_with( offset_x, offset_y, rotation_deg, - 1.0, - None, + scale, + clip, &tmp_path, plot_style.as_ref(), )?; diff --git a/src/modules/view/plot_window.rs b/src/modules/view/plot_window.rs index c0f40d0d5..ccefd93f4 100644 --- a/src/modules/view/plot_window.rs +++ b/src/modules/view/plot_window.rs @@ -1,7 +1,7 @@ // PLOTWINDOW command — pick two corners to define the plot window area. // -// In paper space it writes the layout's PlotSettings (PlotType::Window); in -// model space the host stores the window for the plot dialog. +// In both model and paper space the host stores the picked rectangle for the +// plot dialog; committing the dialog persists it to layout PlotSettings. use crate::command::{CadCommand, CmdResult}; use crate::scene::model::wire_model::WireModel; diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 3a4163ffd..ff8250d06 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1420,7 +1420,7 @@ impl Scene { /// Recursively collect wipeout masks defined inside a block, transformed to /// world space by the accumulated insert transform. See `wipeout_models`. #[allow(clippy::too_many_arguments)] - fn collect_block_wipeouts( + pub(super) fn collect_block_wipeouts( &self, xform: &acadrust::types::Transform, block_name: &str, diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 218b58efb..511649355 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -409,6 +409,149 @@ impl Scene { Arc::new(models) } + /// Plot-only hatch set for a specific block. Paper PDF generation uses this + /// for the model block behind each floating viewport; unlike + /// `paper_canvas_hatches`, it must not include the active paper block. + pub(super) fn plot_hatches_for_block( + &self, + block: Handle, + frozen: Option<&rustc_hash::FxHashSet>, + ) -> Vec { + let layer_hidden = |layer: &str| { + self.document + .layers + .get(layer) + .map(|l| l.flags.off || l.flags.frozen) + .unwrap_or(false) + }; + let mut models = Vec::new(); + for (&handle, model) in self.hatches.iter() { + let Some(source) = self.document.get_entity(handle) else { + continue; + }; + let contextual = + crate::scene::annotative::entity_for_active_context(&self.document, source); + let entity = contextual.as_ref(); + let common = entity.common(); + if common.invisible + || self.entity_temporarily_hidden(handle) + || layer_hidden(&common.layer) + || self.layer_frozen_in(&common.layer, frozen) + || !self.belongs_to_visible_block(handle, common.owner_handle, block) + { + continue; + } + let mut hatch = match entity { + EntityType::Hatch(dxf) + if crate::scene::annotative::active_object_context( + &self.document, + handle, + ) + .is_some() => + { + Self::hatch_model_from_dxf(dxf, model.color) + .unwrap_or_else(|| model.clone()) + } + _ => model.clone(), + }; + hatch.color = self.render_style(entity).0; + if let EntityType::Hatch(dxf) = entity { + if let model::hatch_model::HatchPattern::Pattern(_) = &hatch.pattern { + if dxf.pattern.lines.is_empty() { + hatch.angle_offset = dxf.pattern_angle as f32; + hatch.scale = dxf.pattern_scale as f32; + } + } + } + models.push(hatch); + } + models.extend(self.exploded_insert_hatch_models( + block, + self.paper_bg_color, + false, + frozen, + )); + models + } + + /// Plot-only wipeout masks for a specific block. Nested inserts follow the + /// same recursive collector as the on-screen model path. + pub(super) fn plot_wipeouts_for_block( + &self, + block: Handle, + frozen: Option<&rustc_hash::FxHashSet>, + ) -> Vec { + let depth_map = self.draw_depth_map(); + let mut models = Vec::new(); + for entity in self.document.entities() { + let EntityType::Wipeout(wipeout) = entity else { + continue; + }; + let common = &wipeout.common; + if common.invisible + || self.entity_temporarily_hidden(common.handle) + || self + .document + .layers + .get(&common.layer) + .map(|layer| layer.flags.off || layer.flags.frozen) + .unwrap_or(false) + || self.layer_frozen_in(&common.layer, frozen) + || !self.belongs_to_visible_block(common.handle, common.owner_handle, block) + { + continue; + } + let (world_origin, boundary) = Self::wipeout_boundary_2d(wipeout); + if boundary.len() < 3 { + continue; + } + models.push(HatchModel { + boundary: Arc::new(boundary), + boundary_wcs: None, + pattern: model::hatch_model::HatchPattern::Solid, + name: "WIPEOUT_FILL".into(), + color: self.paper_bg_color, + angle_offset: 0.0, + scale: 1.0, + world_origin, + draw_depth: depth_map + .get(&common.handle.value()) + .map_or(0.0, |depth| depth[0]), + }); + } + for entity in self.document.entities() { + let contextual = + crate::scene::annotative::entity_for_active_context(&self.document, entity); + let EntityType::Insert(insert) = contextual.as_ref() else { + continue; + }; + let common = &insert.common; + if common.invisible + || self.entity_temporarily_hidden(common.handle) + || self + .document + .layers + .get(&common.layer) + .map(|layer| layer.flags.off || layer.flags.frozen) + .unwrap_or(false) + || self.layer_frozen_in(&common.layer, frozen) + || !self.belongs_to_visible_block(common.handle, common.owner_handle, block) + { + continue; + } + self.collect_block_wipeouts( + &insert.get_transform(), + &insert.block_name, + 0, + frozen, + self.paper_bg_color, + &depth_map, + &mut models, + ); + } + models + } + /// Paper-layout wipeout fills (paper hit-testing / export). Same rationale as /// `paper_canvas_hatches` — only include wipeouts owned by the /// active paper layout block, so model wipeouts (drawn through their diff --git a/src/scene/project.rs b/src/scene/project.rs index 645fdaa79..ac7909345 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -349,6 +349,149 @@ impl Scene { result } + /// Model-space fills projected into every active paper viewport for vector + /// plotting. The screen renderer projects these on the GPU; PDF generation + /// needs the equivalent paper-space geometry explicitly. + pub fn viewport_plot_fills( + &self, + ) -> (Vec, Vec, Vec) { + use acadrust::entities::Viewport; + use model::hatch_model::HatchPattern; + + if self.current_layout == "Model" { + return (Vec::new(), Vec::new(), Vec::new()); + } + let paper_block = self.current_layout_block_handle(); + let model_block = self.model_space_block_handle(); + let (_, _, viewport_handles) = self.paper_viewport_handles(); + let viewports: Vec<&Viewport> = viewport_handles + .iter() + .filter_map(|handle| match self.document.get_entity(*handle) { + Some(EntityType::Viewport(viewport)) + if viewport.common.owner_handle == paper_block && viewport.status.is_on => + { + Some(viewport) + } + _ => None, + }) + .collect(); + + let mut pattern_wires = Vec::new(); + let mut projected_hatches = Vec::new(); + let mut projected_wipeouts = Vec::new(); + + for viewport in viewports { + let Some(camera) = self.camera_for_viewport(viewport.common.handle) else { + continue; + }; + let view_right = camera.rotation * glam::Vec3::X; + let view_up = camera.rotation * glam::Vec3::Y; + let view_forward = camera.rotation * glam::Vec3::Z; + let view_height = (camera.ortho_size() * 2.0) as f64; + let viewport_scale = if view_height > 1e-9 { + viewport.height / view_height + } else { + 1.0 + }; + let center_x = viewport.center.x; + let center_y = viewport.center.y; + let half_w = viewport.width * 0.5; + let half_h = viewport.height * 0.5; + let (xmin, ymin, xmax, ymax) = ( + (center_x - half_w) as f32, + (center_y - half_h) as f32, + (center_x + half_w) as f32, + (center_y + half_h) as f32, + ); + let perspective = viewport.status.perspective && viewport.lens_length > 1.0; + let camera_distance = if perspective { + (viewport.view_height * viewport.lens_length / 24.0).max(0.001) + } else { + 0.0 + }; + let project = |x: f64, y: f64| -> Option<[f32; 2]> { + let delta = glam::DVec3::new(x, y, 0.0) - camera.target; + let u = delta.dot(view_right.as_dvec3()); + let v = delta.dot(view_up.as_dvec3()); + let factor = if perspective { + let depth = camera_distance - delta.dot(view_forward.as_dvec3()); + if depth <= 0.001 { + return None; + } + camera_distance / depth + } else { + 1.0 + }; + Some([ + (center_x + u * factor * viewport_scale) as f32, + (center_y + v * factor * viewport_scale) as f32, + ]) + }; + + let frozen: rustc_hash::FxHashSet = + viewport.frozen_layers.iter().copied().collect(); + let hatches = self.plot_hatches_for_block(model_block, Some(&frozen)); + for hatch in hatches { + if matches!(&hatch.pattern, HatchPattern::Pattern(_)) { + let mut points = Vec::new(); + let mut aabb = [ + f32::INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::NEG_INFINITY, + ]; + for [a, b] in hatch.pattern_segments() { + let (Some(a), Some(b)) = + (project(a[0], a[1]), project(b[0], b[1])) + else { + continue; + }; + let Some((ax, ay, bx, by)) = + cs_clip(a[0], a[1], b[0], b[1], xmin, ymin, xmax, ymax) + else { + continue; + }; + if !points.is_empty() { + points.push([f32::NAN, f32::NAN, f32::NAN]); + } + points.push([ax, ay, viewport.center.z as f32]); + points.push([bx, by, viewport.center.z as f32]); + aabb[0] = aabb[0].min(ax).min(bx); + aabb[1] = aabb[1].min(ay).min(by); + aabb[2] = aabb[2].max(ax).max(bx); + aabb[3] = aabb[3].max(ay).max(by); + } + if !points.is_empty() { + let mut wire = WireModel::solid( + "viewport_hatch_pattern".into(), + points, + hatch.color, + false, + ); + wire.aabb = aabb; + pattern_wires.push(wire); + } + continue; + } + if let Some(hatch) = + project_plot_fill(hatch, &project, xmin, ymin, xmax, ymax) + { + projected_hatches.push(hatch); + } + } + + for wipeout in self.plot_wipeouts_for_block(model_block, Some(&frozen)) { + if let Some(wipeout) = + project_plot_fill(wipeout, &project, xmin, ymin, xmax, ymax) + { + projected_wipeouts.push(wipeout); + } + } + } + + (pattern_wires, projected_hatches, projected_wipeouts) + } + /// A content viewport's clip boundary, projected into that viewport's /// render-target normalized device coords. Every content viewport goes /// through the same stencil path: a rectangular viewport contributes its @@ -592,6 +735,125 @@ fn sample_polyline_clip_boundary( output } +fn project_plot_fill( + mut fill: HatchModel, + project: &F, + xmin: f32, + ymin: f32, + xmax: f32, + ymax: f32, +) -> Option +where + F: Fn(f64, f64) -> Option<[f32; 2]>, +{ + let mut output = Vec::new(); + let mut ring = Vec::new(); + let flush_ring = |ring: &mut Vec<[f32; 2]>, output: &mut Vec<[f32; 2]>| { + if ring.len() < 3 { + ring.clear(); + return; + } + if ring.first() == ring.last() { + ring.pop(); + } + let mut clipped = clip_polygon_to_rect(ring, xmin, ymin, xmax, ymax); + ring.clear(); + if clipped.len() < 3 { + return; + } + if clipped.first() != clipped.last() { + clipped.push(clipped[0]); + } + if !output.is_empty() { + output.push([f32::NAN, f32::NAN]); + } + output.extend(clipped); + }; + for &[x, y] in fill.boundary.iter() { + if x.is_nan() || y.is_nan() { + flush_ring(&mut ring, &mut output); + continue; + } + let absolute_x = fill.world_origin[0] + x as f64; + let absolute_y = fill.world_origin[1] + y as f64; + if let Some(point) = project(absolute_x, absolute_y) { + ring.push(point); + } + } + flush_ring(&mut ring, &mut output); + if output.is_empty() { + return None; + } + fill.world_origin = [0.0, 0.0]; + fill.boundary = std::sync::Arc::new(output); + fill.boundary_wcs = None; + Some(fill) +} + +fn clip_polygon_to_rect( + polygon: &[[f32; 2]], + xmin: f32, + ymin: f32, + xmax: f32, + ymax: f32, +) -> Vec<[f32; 2]> { + let mut output = polygon.to_vec(); + for (edge, value) in [(0u8, xmin), (1, xmax), (2, ymin), (3, ymax)] { + if output.is_empty() { + break; + } + let input = std::mem::take(&mut output); + let mut previous = *input.last().unwrap(); + let mut previous_inside = polygon_edge_inside(previous, edge, value); + for current in input { + let current_inside = polygon_edge_inside(current, edge, value); + if current_inside != previous_inside { + output.push(polygon_edge_intersection(previous, current, edge, value)); + } + if current_inside { + output.push(current); + } + previous = current; + previous_inside = current_inside; + } + } + output +} + +fn polygon_edge_inside(point: [f32; 2], edge: u8, value: f32) -> bool { + match edge { + 0 => point[0] >= value, + 1 => point[0] <= value, + 2 => point[1] >= value, + _ => point[1] <= value, + } +} + +fn polygon_edge_intersection( + start: [f32; 2], + end: [f32; 2], + edge: u8, + value: f32, +) -> [f32; 2] { + if edge <= 1 { + let dx = end[0] - start[0]; + let t = if dx.abs() > 1e-12 { + (value - start[0]) / dx + } else { + 0.0 + }; + [value, start[1] + (end[1] - start[1]) * t] + } else { + let dy = end[1] - start[1]; + let t = if dy.abs() > 1e-12 { + (value - start[1]) / dy + } else { + 0.0 + }; + [start[0] + (end[0] - start[0]) * t, value] + } +} + // ── Paper boundary wire ──────────────────────────────────────────────────── // ── Cohen-Sutherland line clipping ───────────────────────────────────────