diff --git a/src/app/update/file.rs b/src/app/update/file.rs index e9186c647..003941fad 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2168,7 +2168,6 @@ pub(super) fn on_open_file(&mut self) -> Task { &mut self, path: std::path::PathBuf, ) -> Task { - let paper_space = self.tabs[self.active_tab].scene.current_layout != "Model"; let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = self.direct_plot_params() else { @@ -2177,10 +2176,7 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); }; let plot_style = self.dialog_plot_style(&self.plot_dialog); - let mut render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); - if paper_space { - render_options.scale_lineweights = false; - } + let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); let work = move || { crate::io::pdf_export::export_pdf( @@ -2527,7 +2523,6 @@ pub(super) fn on_open_file(&mut self) -> Task { } pub(super) fn on_print_to_printer(&mut self) -> Task { - let paper_space = self.tabs[self.active_tab].scene.current_layout != "Model"; let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = self.direct_plot_params() else { @@ -2536,10 +2531,7 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); }; let plot_style = self.dialog_plot_style(&self.plot_dialog); - let mut options = self.plot_print_options(&self.plot_dialog, group_splits); - if paper_space { - options.render.scale_lineweights = false; - } + let options = self.plot_print_options(&self.plot_dialog, group_splits); self.command_line.push_info("Sending to system printer…"); background_task( move || { @@ -2850,7 +2842,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } } PlotFlag::Center if d.area != "Layout" => d.center = !d.center, - PlotFlag::ScaleLw if d.area != "Layout" && !d.fit_to_paper => { + PlotFlag::ScaleLw if !d.fit_to_paper => { d.scale_lw = !d.scale_lw } PlotFlag::UpsideDown => { @@ -3244,7 +3236,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .or_else(|| d.scales.first().map(|(name, _)| name.clone())) .unwrap_or_else(|| "1:1".into()); } - d.scale_lw = ps.flags.scale_lineweights && !d.fit_to_paper && d.area != "Layout"; + d.scale_lw = ps.flags.scale_lineweights && !d.fit_to_paper; d.lineweights = ps.flags.print_lineweights; d.paperspace_last = ps.flags.draw_viewports_first; d.shade = match ps.shade_plot_mode { @@ -3460,7 +3452,7 @@ pub(super) fn on_open_file(&mut self) -> Task { ) -> crate::io::pdf_export::PdfPlotOptions { crate::io::pdf_export::PdfPlotOptions { object_lineweights: d.lineweights, - scale_lineweights: d.scale_lw && !d.fit_to_paper && d.area != "Layout", + scale_lineweights: d.scale_lw && !d.fit_to_paper, transparency: d.transparency, stamp: d.stamp, merge_lines: d.merge_lines, diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 27e8f19b4..07e8c3f49 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -766,18 +766,6 @@ fn emit_hatch( // Pattern hatches: rasterise the family lines clipped to the boundary // and emit each as a stroked line. Skips the polygon outline entirely. if matches!(hatch.pattern, HatchPattern::Pattern(_)) { - let segments = hatch.pattern_segments(); - if segments.is_empty() { - return; - } - ops.push(Op::SetOutlineColor { - col: Color::Rgb(Rgb { - r, - g, - b, - icc_profile: None, - }), - }); let physical = lw_override.unwrap_or_else(|| { if options.object_lineweights { (hatch.line_weight_px * LW_PX_TO_PT).max(0.1) @@ -790,6 +778,21 @@ 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); + if segments.is_empty() { + return; + } + ops.push(Op::SetOutlineColor { + col: Color::Rgb(Rgb { + r, + g, + b, + icc_profile: None, + }), + }); ops.push(Op::SetOutlineThickness { pt: Pt(physical / divisor), }); diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 495778154..de3628911 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -974,10 +974,20 @@ impl Scene { self.bg_color }; // Block-internal hatch fills: explode every visible INSERT of this - // layout block and materialize its fills at world position. Shared with - // the export path so a plot draws them identically. (tint_selected = - // true applies the screen selection highlight.) + // layout block and materialize its fills at world position. Paper + // content viewports also need the model block's INSERT fills; without + // them the screen showed an empty/white block while plotting correctly + // materialized its solid hatch. (tint_selected = true applies the + // screen selection highlight.) models.extend(self.exploded_insert_hatch_models(layout_block, hatch_bg, true, frozen)); + if self.current_layout != "Model" { + models.extend(self.exploded_insert_hatch_models( + self.model_space_block_handle(), + hatch_bg, + true, + frozen, + )); + } // Wide LwPolyline / Polyline2D bands are no longer hatch fills at // model level: a flat band is drawn by expanding its centre-line wire diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 4924f38ff..9eca25d86 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -203,11 +203,29 @@ impl HatchModel { /// `world_origin + boundary[i]` resolved. Solid / gradient hatches return an /// empty vec — callers fall back to their solid-fill path. /// - /// The whole rasterisation is f64 because the pattern is anchored at world - /// (0, 0): a hatch at UTM sits ~5e5 away from that anchor, so the line index - /// `k` runs to ~1e7 and `origin + k · step` in f32 quantises the family lines - /// into visible garbage, as does the `edge − line` cancellation below. + /// The whole rasterisation is f64 because boundaries and family origins are + /// resolved in absolute WCS. Narrowing them first at UTM magnitudes would + /// quantise both the generated lines and the `edge − line` intersections. pub fn pattern_segments(&self) -> Vec<[[f64; 2]; 2]> { + 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))) + } + + fn pattern_segments_with_dot_length( + &self, + plot_dot_length: Option, + ) -> Vec<[[f64; 2]; 2]> { let HatchPattern::Pattern(families) = &self.pattern else { return Vec::new(); }; @@ -308,8 +326,8 @@ impl HatchModel { // on-screen render. let (fx0, fy0) = (family.x0 as f64, family.y0 as f64); let origin = [ - (fx0 * cos_off - fy0 * sin_off) * scale, - (fx0 * sin_off + fy0 * cos_off) * scale, + ox + (fx0 * cos_off - fy0 * sin_off) * scale, + oy + (fx0 * sin_off + fy0 * cos_off) * scale, ]; let mut p_min = f64::INFINITY; let mut p_max = f64::NEG_INFINITY; @@ -394,10 +412,10 @@ impl HatchModel { // absolute phase (so the pattern aligns across spans, // matching the GPU shader). Positive entries are // dashes, negative are gaps, and a zero-length entry is - // a dot — rendered as a short mark so dot patterns - // (e.g. DOTS) are visible instead of drawing nothing. + // a round-capped point. let n = family.dashes.len(); - let dot_len: f64 = (period * 0.06).max(1e-3); + let dot_len = plot_dot_length + .unwrap_or_else(|| (period * 0.06).max(1e-3)); let phase = t0.rem_euclid(period); // Start at the period boundary at or before t0; the // span clip below drops anything before t0. @@ -418,7 +436,6 @@ impl HatchModel { ]); } } else if d == 0.0 && seg_t >= t0 - 1e-6 && seg_t <= t1 + 1e-6 { - // Short centered mark for the dot. let a = (seg_t - dot_len * 0.5).max(t0); let b = (seg_t + dot_len * 0.5).min(t1); if b > a { diff --git a/src/scene/project.rs b/src/scene/project.rs index 39c31b117..c2d5efb7a 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -433,6 +433,13 @@ 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, @@ -440,7 +447,7 @@ impl Scene { f32::NEG_INFINITY, f32::NEG_INFINITY, ]; - for [a, b] in hatch.pattern_segments() { + for [a, b] in hatch.pattern_segments_for_plot(dot_length) { 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 3a6d6b8e4..fa233420b 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -681,7 +681,7 @@ pub fn view_window( "Scale lineweights", s.scale_lw && !s.fit_to_paper, PlotFlag::ScaleLw, - common_area && !s.fit_to_paper, + !s.fit_to_paper, ), ].spacing(7));