diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 003941fad..da61faf16 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2632,6 +2632,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// layout's plot settings and the printers found on the system. pub(super) fn on_plot_dialog_open(&mut self) -> Task { use crate::io::paper_sizes::Orientation; + let previous = self.plot_dialog.clone(); let scales: Vec<(String, f64)> = self.tabs[self.active_tab] .scene .scale_list() @@ -2654,7 +2655,7 @@ pub(super) fn on_open_file(&mut self) -> Task { d.scale_lw = false; d.scale = one_to_one.clone(); } else if !d.scales.iter().any(|(name, _)| name == &d.scale) { - d.scale = one_to_one; + d.scale = one_to_one.clone(); } d.paper_space = self.tabs[self.active_tab].scene.current_layout != "Model"; d.paper = self.plot_format.label().to_string(); @@ -2704,28 +2705,39 @@ pub(super) fn on_open_file(&mut self) -> Task { if self.plot_dialog.fit_to_paper { self.plot_dialog.scale_lw = false; } - // Refresh the list ( / / layouts / named setups) and - // snapshot the just-loaded settings as the `` restore point. + // Refresh document/runtime lists, then restore the live choices from + // the preceding dialog session. Previously the snapshot happened + // after the layout values above were reloaded, so opening Plot itself + // destroyed the user's last paper, area, scale and output choices. self.refresh_page_setups(); - self.plot_prev = Some(self.plot_dialog.clone()); - // Model space keeps the last-used setup; a paper layout starts from its - // embedded setup. + self.plot_prev = Some(previous); + self.select_page_setup(crate::ui::window::plot::SETUP_PREV); + // `` may come from another tab/drawing whose custom scale is + // not present in this document. Validate again after restoring it; the + // pre-restore validation above only saw the temporarily reseeded state. + if self.plot_dialog.scale.eq_ignore_ascii_case("fit") { + self.plot_dialog.fit_to_paper = true; + self.plot_dialog.scale = one_to_one.clone(); + } else if !self + .plot_dialog + .scales + .iter() + .any(|(name, _)| name == &self.plot_dialog.scale) + { + self.plot_dialog.scale = one_to_one; + } + if self.plot_dialog.fit_to_paper { + self.plot_dialog.scale_lw = false; + } + // A model-space plot cannot use the paper-only Layout area. Other + // values remain exactly as the user left them; selecting a layout or + // named setup explicitly still reloads that setup below. let cur = self.tabs[self.active_tab].scene.current_layout.clone(); if cur == "Model" { - self.select_page_setup(crate::ui::window::plot::SETUP_PREV); if self.plot_dialog.area == "Layout" { self.plot_dialog.area = "Window".into(); self.normalize_common_plot_dialog(); } - } 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() diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 07e8c3f49..780f64939 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -54,13 +54,15 @@ pub async fn pick_pdf_path_owned(_stem: String) -> Option { /// mm to PDF points (1 mm = 2.834645 pt). #[cfg(not(target_arch = "wasm32"))] const MM_TO_PT: f32 = 2.834645; -/// `wire.line_weight_px` is the on-screen pixel weight: mm × (96/25.4) × 2.0, -/// where the ×2 is a screen-legibility boost (see render.rs). Print wants the -/// true physical weight, so undo both the 96-dpi scaling and the boost before -/// converting to points — otherwise weights export ~2× too heavy in pixels -/// (and the old `× 0.35278` left them inconsistent with the physical mm). +/// `wire.line_weight_px` is the on-screen pixel weight. Convert the 96-dpi +/// pixels to points while retaining the viewport's lineweight visibility +/// boost, so "As displayed" output has the same visual hierarchy as the +/// canvas instead of making 0.35 mm ByLayer outlines look half as thick. #[cfg(not(target_arch = "wasm32"))] -const LW_PX_TO_PT: f32 = MM_TO_PT / ((96.0 / 25.4) * 2.0); +const LW_PX_TO_PT: f32 = MM_TO_PT / (96.0 / 25.4); + +#[cfg(not(target_arch = "wasm32"))] +const SCREEN_DOT_MM: f32 = 25.4 / 96.0; /// Output controls shared by preview, PDF export, and printer rendering. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -365,14 +367,16 @@ fn build_pdf( .map(|c| (c[0] - r).abs() > 0.01 || (c[1] - g).abs() > 0.01 || (c[2] - b).abs() > 0.01) .unwrap_or(true) { - ops.push(Op::SetOutlineColor { - col: Color::Rgb(Rgb { - r, - g, - b, - icc_profile: None, - }), + let color = Color::Rgb(Rgb { + r, + g, + b, + icc_profile: None, }); + ops.push(Op::SetOutlineColor { + col: color.clone(), + }); + ops.push(Op::SetFillColor { col: color }); last_color = Some([r, g, b]); } @@ -431,9 +435,11 @@ fn build_pdf( // low-coordinate drawings came out clean. The result is a sheet-mm value // in single digits, so f32 is lossless from here. let mut segment: Vec = Vec::new(); + let dot_radius = (wire.name == "viewport_hatch_pattern") + .then_some(Pt(SCREEN_DOT_MM * MM_TO_PT / (2.0 * scale.max(1e-6)))); for (pi, &[x, y, _z]) in wire.points.iter().enumerate() { if x.is_nan() || y.is_nan() { - flush_line(&mut ops, &segment); + flush_line(&mut ops, &segment, dot_radius); segment.clear(); } else { let lo = wire.points_low.get(pi).copied().unwrap_or([0.0; 3]); @@ -445,7 +451,7 @@ fn build_pdf( }); } } - flush_line(&mut ops, &segment); + flush_line(&mut ops, &segment, dot_radius); } // Text (SDF glyph quads) — re-emitted as vector strokes / fills. Text now @@ -499,10 +505,21 @@ fn dash_array_from_pattern(pattern_length: f32, pattern: &[f32; 8], mm_to_pt: f3 } #[cfg(not(target_arch = "wasm32"))] -fn flush_line(ops: &mut Vec, pts: &[LinePoint]) { +fn flush_line(ops: &mut Vec, pts: &[LinePoint], dot_radius: Option) { if pts.len() < 2 { return; } + if let Some(radius) = dot_radius { + let first = pts[0].p; + let coincident = pts.iter().skip(1).all(|point| { + (point.p.x.0 - first.x.0).abs() <= 1e-6 + && (point.p.y.0 - first.y.0).abs() <= 1e-6 + }); + if coincident { + emit_round_dot(ops, first, radius); + return; + } + } ops.push(Op::DrawLine { line: Line { points: pts.to_vec(), @@ -511,6 +528,30 @@ fn flush_line(ops: &mut Vec, pts: &[LinePoint]) { }); } +#[cfg(not(target_arch = "wasm32"))] +fn emit_round_dot(ops: &mut Vec, center: Point, radius: Pt) { + const SIDES: usize = 12; + let points = (0..SIDES) + .map(|index| { + let angle = std::f32::consts::TAU * index as f32 / SIDES as f32; + LinePoint { + p: Point { + x: Pt(center.x.0 + radius.0 * angle.cos()), + y: Pt(center.y.0 + radius.0 * angle.sin()), + }, + bezier: false, + } + }) + .collect(); + ops.push(Op::DrawPolygon { + polygon: Polygon { + rings: vec![PolygonRing { points }], + mode: PaintMode::Fill, + winding_order: WindingOrder::NonZero, + }, + }); +} + #[cfg(not(target_arch = "wasm32"))] fn plotted_color( rgb: [f32; 3], @@ -685,7 +726,9 @@ fn emit_hatch( r = 1.0; g = 1.0; b = 1.0; - } else if !color_overridden { + } else if !color_overridden + && !(hatch.aci == 7 && matches!(hatch.pattern, HatchPattern::Solid)) + { 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; @@ -778,21 +821,20 @@ fn emit_hatch( } else { scale.max(1e-6) }; - let screen_dot_mm = 25.4 / 96.0; - let dot_length = (physical / MM_TO_PT / divisor) - .max(screen_dot_mm / scale.max(1e-6)); - let segments = hatch.pattern_segments_for_plot(dot_length as f64); + let segments = hatch.pattern_segments_for_plot(); if segments.is_empty() { return; } - ops.push(Op::SetOutlineColor { - col: Color::Rgb(Rgb { - r, - g, - b, - icc_profile: None, - }), + let color = Color::Rgb(Rgb { + r, + g, + b, + icc_profile: None, }); + ops.push(Op::SetOutlineColor { + col: color.clone(), + }); + ops.push(Op::SetFillColor { col: color }); ops.push(Op::SetOutlineThickness { pt: Pt(physical / divisor), }); @@ -806,21 +848,18 @@ fn emit_hatch( // before narrowing, as everywhere else in this file. let (ax, ay) = ((a[0] + ox) as f32, (a[1] + oy) as f32); let (bx, by) = ((b_pt[0] + ox) as f32, (b_pt[1] + oy) as f32); - ops.push(Op::DrawLine { - line: Line { - points: vec![ - LinePoint { - p: Point::new(Mm(ax), Mm(ay)), - bezier: false, - }, - LinePoint { - p: Point::new(Mm(bx), Mm(by)), - bezier: false, - }, - ], - is_closed: false, + let points = vec![ + LinePoint { + p: Point::new(Mm(ax), Mm(ay)), + bezier: false, }, - }); + LinePoint { + p: Point::new(Mm(bx), Mm(by)), + bezier: false, + }, + ]; + let dot_radius = Pt(SCREEN_DOT_MM * MM_TO_PT / (2.0 * scale.max(1e-6))); + flush_line(ops, &points, Some(dot_radius)); } return; } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index de3628911..41d03edc0 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1343,8 +1343,20 @@ impl Scene { style.4 }; let color = style.0; - let color = - crate::scene::view::render::adapt_to_bg(color, hatch_bg); + // Explicit ACI 7 solid fills inside blocks are often + // white masks. Keep the explicit white on a light + // layout; adapting it to paper turns the mask into a + // black rectangle unlike the viewport (#618). + let preserve_white_mask = dxf.is_solid + && matches!( + dxf.common.color, + acadrust::types::Color::Index(7) + ); + let color = if preserve_white_mask { + color + } else { + crate::scene::view::render::adapt_to_bg(color, hatch_bg) + }; if let Some(mut model) = Self::hatch_model_from_dxf(&dxf, color) { diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 9eca25d86..f2d459605 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -210,16 +210,12 @@ impl HatchModel { self.pattern_segments_with_dot_length(None) } - /// Plot variant of [`Self::pattern_segments`] that renders PAT zero-length - /// dash entries at a caller-provided world length. Screen rendering snaps - /// these entries to one pixel; plot callers know the model-to-paper scale - /// and can therefore choose the equivalent physical mark without tying its - /// size to the pattern period. - pub(crate) fn pattern_segments_for_plot( - &self, - dot_length: f64, - ) -> Vec<[[f64; 2]; 2]> { - self.pattern_segments_with_dot_length(Some(dot_length.max(1e-9))) + /// Plot variant of [`Self::pattern_segments`] that preserves PAT + /// zero-length dash entries as coincident endpoints. The PDF exporter can + /// then paint a round point; turning them into short segments here makes + /// point-only patterns such as AR-CONC visibly look like tiny lines. + pub(crate) fn pattern_segments_for_plot(&self) -> Vec<[[f64; 2]; 2]> { + self.pattern_segments_with_dot_length(Some(0.0)) } fn pattern_segments_with_dot_length( @@ -436,13 +432,18 @@ impl HatchModel { ]); } } else if d == 0.0 && seg_t >= t0 - 1e-6 && seg_t <= t1 + 1e-6 { - let a = (seg_t - dot_len * 0.5).max(t0); - let b = (seg_t + dot_len * 0.5).min(t1); - if b > a { - segments.push([ - [lx + a * cos_a, ly + a * sin_a], - [lx + b * cos_a, ly + b * sin_a], - ]); + if plot_dot_length == Some(0.0) { + let point = [lx + seg_t * cos_a, ly + seg_t * sin_a]; + segments.push([point, point]); + } else { + let a = (seg_t - dot_len * 0.5).max(t0); + let b = (seg_t + dot_len * 0.5).min(t1); + if b > a { + segments.push([ + [lx + a * cos_a, ly + a * sin_a], + [lx + b * cos_a, ly + b * sin_a], + ]); + } } } seg_t += dl; diff --git a/src/scene/project.rs b/src/scene/project.rs index c2d5efb7a..ef9e2a3b2 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -433,13 +433,6 @@ impl Scene { let hatches = self.plot_hatches_for_block(model_block, Some(&frozen)); for hatch in hatches { if matches!(&hatch.pattern, HatchPattern::Pattern(_)) { - // The GPU draws PAT zero-length dash entries as one-pixel - // dots. Convert that display pixel to model units before - // projecting so vector output keeps the same dot size - // instead of expanding it from the pattern period. - let dot_length = (25.4 / 96.0) - * self.paper_space_unit_factor() - / viewport_scale.max(1e-9); let mut points = Vec::new(); let mut aabb = [ f32::INFINITY, @@ -447,7 +440,7 @@ impl Scene { f32::NEG_INFINITY, f32::NEG_INFINITY, ]; - for [a, b] in hatch.pattern_segments_for_plot(dot_length) { + for [a, b] in hatch.pattern_segments_for_plot() { let (Some(a), Some(b)) = (project(a[0], a[1]), project(b[0], b[1])) else { diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index fa233420b..597a15da1 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -99,18 +99,13 @@ pub struct PlotDialogState { pub printer: Option, /// Output goes to a PDF file instead of a printer. pub to_file: bool, - #[serde(skip)] pub paper: String, - #[serde(skip)] pub orientation: String, pub upside_down: bool, pub copies: String, pub area: String, - #[serde(skip)] pub center: bool, - #[serde(skip)] pub offset_x: String, - #[serde(skip)] pub offset_y: String, pub scale: String, #[serde(default = "legacy_fit_to_paper_default")]