From f0c1be0db161cef9a8eb92ba5dcb427d62e069e7 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 31 Jul 2026 23:08:41 +0300 Subject: [PATCH] fix(plot): correct layout hatch output Keep Layout output on the selected physical sheet and preserve hatch lineweights and plot styles through paper, viewport, and nested block paths.\n\nRefs #607 --- src/app/document.rs | 6 ++ src/app/update/file.rs | 46 ++++++++-- src/app/update/viewport.rs | 6 ++ src/io/pdf_export.rs | 42 +++++++-- src/modules/draw/draw/hatch.rs | 3 + src/scene/entity.rs | 162 +++++++++++++++++++++++++-------- src/scene/mod.rs | 1 + src/scene/model/hatch_model.rs | 3 + src/scene/paper.rs | 4 + src/scene/preview.rs | 40 ++++++-- src/scene/project.rs | 1 + src/ui/properties.rs | 1 + 12 files changed, 258 insertions(+), 57 deletions(-) diff --git a/src/app/document.rs b/src/app/document.rs index 292544b2a..63d89f4ec 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -410,6 +410,12 @@ impl DocumentTab { l.max_limits = (297.0, 210.0); l.min_extents = (0.0, 0.0, 0.0); l.max_extents = (297.0, 210.0, 0.0); + l.paper_width = 297.0; + l.paper_height = 210.0; + l.plot_paper_units = 1; + l.plot_scale_numerator = 1.0; + l.plot_scale_denominator = 1.0; + l.paper_size = "ISO_A4_(297.00_x_210.00_MM)".into(); } } } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 479308b09..7f2d0eb73 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2294,9 +2294,23 @@ pub(super) fn on_open_file(&mut self) -> Task { } fn layout_plot_params_for(&self, plot_area: &str) -> LayoutPlotParams { + use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; let i = self.active_tab; let scene = &self.tabs[i].scene; let paper_space = scene.current_layout != "Model"; + let selected_paper = match self.plot_dialog.paper.as_str() { + "A3" => PaperSize::A3, + "A2" => PaperSize::A2, + "A1" => PaperSize::A1, + "A0" => PaperSize::A0, + _ => PaperSize::A4, + }; + let selected_orientation = if self.plot_dialog.orientation == "Portrait" { + Orientation::Portrait + } else { + Orientation::Landscape + }; + let selected_sheet = sheet_mm(selected_paper, selected_orientation); let (source_wires, hatches, wipeouts, mut group_splits) = plot_scene_content( scene, @@ -2380,11 +2394,24 @@ pub(super) fn on_open_file(&mut self) -> Task { 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) + // Map the complete paper-space sheet to the physical paper + // selected in the dialog. Layout bounds can be stored in mm, + // inches, or carry incomplete legacy metadata; deriving the + // scale from the visible sheet avoids applying a stale unit + // factor twice while preserving loaded layouts exactly when + // their metadata is valid. + let bounds_w = (x1 - x0).max(1e-9); + let bounds_h = (y1 - y0).max(1e-9); + let scale = (selected_sheet.0 / bounds_w) + .min(selected_sheet.1 / bounds_h) + .max(1e-9); + let target_x = (selected_sheet.0 - bounds_w * scale) * 0.5; + let target_y = (selected_sheet.1 - bounds_h * scale) * 0.5; + ( + scale, + target_x / scale - x0, + target_y / scale - y0, + ) } else { // Legacy direct callers still get dialog positioning. Normal // Display/Window paths use `area_plot_job` instead. @@ -2401,9 +2428,14 @@ pub(super) fn on_open_file(&mut self) -> Task { }; (scale, target_x / scale - x0, target_y / scale - y0) }; + let (base_page_w, base_page_h) = if plot_layout { + selected_sheet + } else { + (paper_w, paper_h) + }; let (page_w, page_h) = match rotation { - 90 | 270 => (paper_h, paper_w), - _ => (paper_w, paper_h), + 90 | 270 => (base_page_h, base_page_w), + _ => (base_page_w, base_page_h), }; // Layout plots keep the full physical sheet as the PDF page, but // ink is restricted to the device's printable rectangle. Extents diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c699a2559..f209d4c48 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4199,6 +4199,12 @@ impl OpenCADStudio { l.max_limits = (297.0, 210.0); l.min_extents = (0.0, 0.0, 0.0); l.max_extents = (297.0, 210.0, 0.0); + l.paper_width = 297.0; + l.paper_height = 210.0; + l.plot_paper_units = 1; + l.plot_scale_numerator = 1.0; + l.plot_scale_denominator = 1.0; + l.paper_size = "ISO_A4_(297.00_x_210.00_MM)".into(); break; } } diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index df300f176..27e8f19b4 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -302,6 +302,7 @@ fn build_pdf( ox, oy, plot_style, + scale, options, normal_blend.as_ref(), ); @@ -642,6 +643,7 @@ fn emit_hatch( ox: f64, oy: f64, plot_style: Option<&PlotStyleTable>, + scale: f32, options: PdfPlotOptions, normal_blend: Option<&ExtendedGraphicsStateId>, ) { @@ -659,6 +661,7 @@ fn emit_hatch( // Genuine colours are untouched; WIPEOUTS keep their paper-white mask. let is_wipeout = hatch.name == "WIPEOUT_FILL"; let mut screening = 1.0; + let mut lw_override = None; let mut color_overridden = false; if !is_wipeout { if let Some(table) = plot_style { @@ -670,6 +673,9 @@ fn emit_hatch( color_overridden = true; } screening = table.resolve_screening(hatch.aci); + lw_override = table + .resolve_lineweight(hatch.aci) + .map(|mm| (mm * MM_TO_PT).max(0.1)); } } } @@ -738,12 +744,16 @@ fn emit_hatch( HatchPattern::Gradient { color2, .. } => { // PDF gradients are stored in resource dictionaries; for the // fast path we average the two colours, matching paper_canvas. - let second = plotted_color( - adapt_text_color([color2[0], color2[1], color2[2]]), - color2[3], - 1.0, - options, - ); + let second = if color_overridden { + [r, g, b] + } else { + plotted_color( + adapt_text_color([color2[0], color2[1], color2[2]]), + color2[3], + screening, + options, + ) + }; let avg = [ (r + second[0]) * 0.5, (g + second[1]) * 0.5, @@ -768,6 +778,26 @@ fn emit_hatch( 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) + } else { + 0.1 + } + }); + let divisor = if options.scale_lineweights { + 1.0 + } else { + scale.max(1e-6) + }; + ops.push(Op::SetOutlineThickness { + pt: Pt(physical / divisor), + }); + // Pattern dashes are already materialized by `pattern_segments`. + // Clear any linetype left by the preceding paper/model render group. + ops.push(Op::SetLineDashPattern { + dash: LineDashPattern::default(), + }); for [a, b_pt] in segments { // `pattern_segments` returns absolute world f64; cancel the offset // before narrowing, as everywhere else in this file. diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index 2a72b6ccc..1c01349c5 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -239,6 +239,7 @@ impl HatchCommand { name: pat_name.into(), color: [0.75, 0.75, 0.75, 0.85], aci: 0, + line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, world_origin: origin, @@ -407,6 +408,7 @@ impl GradientCommand { name: self.kind.dxf_name(self.invert).into(), color: [0.30, 0.60, 0.95, 0.80], aci: 0, + line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, world_origin: origin, @@ -611,6 +613,7 @@ impl CadCommand for BoundaryCommand { name: "SOLID".into(), color: [0.45, 0.45, 0.45, 0.60], aci: 0, + line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, world_origin: origin, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index a02efd831..495778154 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -880,11 +880,14 @@ impl Scene { // emitted first so LessEqual layering keeps it underneath. let mut backdrop: Option = None; if let Some(e) = entity { + let style = self.render_style(e); + m.aci = style.4; + m.line_weight_px = style.3; // A gradient's colour is its first stop (already baked into // the cached model); only solid / pattern fills take the // entity's resolved colour. if !matches!(m.pattern, model::hatch_model::HatchPattern::Gradient { .. }) { - m.color = self.render_style(e).0; + m.color = style.0; } if let EntityType::Hatch(dxf) = e { if let Some(bg) = crate::entities::hatch::background_color(dxf) { @@ -893,19 +896,38 @@ impl Scene { // ByLayer / ByBlock backgrounds resolve through the // normal style chain instead of the raw ACI table // (#415). - b.color = match bg { + let (bg_color, bg_aci) = match bg { acadrust::types::Color::ByLayer => { - crate::scene::view::render::layer_render_style( - &self.document, - &dxf.common.layer, + let layer = self.document.layers.get(&dxf.common.layer); + let aci = layer + .and_then(|layer| match &layer.color { + acadrust::types::Color::Index(index) => Some(*index), + _ => None, + }) + .unwrap_or(0); + ( + crate::scene::view::render::layer_render_style( + &self.document, + &dxf.common.layer, + ) + .color, + aci, ) - .color - } - acadrust::types::Color::ByBlock => self.render_style(e).0, - other => { - crate::scene::convert::tess_util::aci_to_rgba(&other) } + acadrust::types::Color::ByBlock => (style.0, style.4), + acadrust::types::Color::Index(index) => ( + crate::scene::convert::tess_util::aci_to_rgba( + &acadrust::types::Color::Index(index), + ), + index, + ), + other => ( + crate::scene::convert::tess_util::aci_to_rgba(&other), + 0, + ), }; + b.color = bg_color; + b.aci = bg_aci; b.name = "SOLID".into(); backdrop = Some(b); } @@ -1120,16 +1142,27 @@ impl Scene { // hatches land in the correct world position. A depth guard keeps // a malformed cyclic block reference from looping forever. let normalize = crate::modules::draw::modify::explode::normalize_insert_entity; - // Block-child colour inheritance sources (#221), kept RAW and - // adapted at the leaf. `ins_color` feeds ByBlock; `l0` (the - // INSERT's *layer* style) feeds the layer-0 rule. Both chain - // through nested inserts, mirroring expand_insert. - let ins_color = - crate::scene::view::render::render_style_for(&self.document, entity).0; + // Block-child style inheritance sources (#221), kept raw and + // adapted at the leaf. The INSERT style feeds ByBlock; `l0` (the + // INSERT's layer style) feeds the layer-0 rule. Colour, ACI, + // linetype, and lineweight all chain through nested inserts. + let ins_style = + crate::scene::view::render::render_style_for(&self.document, entity); let l0 = crate::scene::view::render::layer_render_style( &self.document, &ins.common.layer, ); + let layer_aci = |layer: &str| -> u8 { + self.document + .layers + .get(layer) + .and_then(|layer| match &layer.color { + acadrust::types::Color::Index(index) => Some(*index), + _ => None, + }) + .unwrap_or(0) + }; + let l0_aci = layer_aci(&ins.common.layer); let [base_depth, half_gap] = depth_map .get(&ins.common.handle.value()) .copied() @@ -1145,11 +1178,13 @@ impl Scene { && matches!(e, EntityType::Insert(ni) if crate::scene::annotative::annotative_offscale(&self.document, &ni.common)) }; + type ResolvedStyle = ([f32; 4], f32, [f32; 8], f32, u8); let mut stack: Vec<( EntityType, usize, - [f32; 4], + ResolvedStyle, crate::scene::view::render::InheritStyle, + u8, (f32, f32), )> = explode_including_dims(ins, &self.document) .into_iter() @@ -1158,9 +1193,26 @@ impl Scene { crate::scene::annotative::entity_for_active_context(&self.document, &e) .into_owned() }) - .map(|e| (normalize(e), 0usize, ins_color, l0, (base_depth, half_gap))) + .map(|e| { + ( + normalize(e), + 0usize, + ins_style, + l0, + l0_aci, + (base_depth, half_gap), + ) + }) .collect(); - while let Some((sub, depth, sub_ins_color, sub_l0, (d_base, d_half))) = stack.pop() { + while let Some(( + sub, + depth, + sub_ins_style, + sub_l0, + sub_l0_aci, + (d_base, d_half), + )) = stack.pop() + { match sub { EntityType::Insert(nins) => { if depth >= 32 { @@ -1180,21 +1232,27 @@ impl Scene { &self.document, &nested_entity, ); - let child_ins_color = if !has_book_color + let mut child_ins_style = + crate::scene::view::render::render_style_for_block_sub( + &self.document, + &nested_entity, + sub_ins_style.0, + sub_ins_style.1, + sub_ins_style.2, + sub_ins_style.3, + sub_l0, + ); + child_ins_style.4 = if !has_book_color && nins.common.color == Color::ByBlock { - sub_ins_color + sub_ins_style.4 } else if !has_book_color && on_l0 && nins.common.color == Color::ByLayer { - sub_l0.color + sub_l0_aci } else { - crate::scene::view::render::render_style_for( - &self.document, - &nested_entity, - ) - .0 + child_ins_style.4 }; let child_l0 = if on_l0 { sub_l0 @@ -1204,6 +1262,11 @@ impl Scene { &nins.common.layer, ) }; + let child_l0_aci = if on_l0 { + sub_l0_aci + } else { + layer_aci(&nins.common.layer) + }; // Nested insert: its slot = parent slot + its rank in // the parent block, subdivided by its own block size. let nd_base = d_base @@ -1225,8 +1288,9 @@ impl Scene { stack.push(( normalize(e), depth + 1, - child_ins_color, + child_ins_style, child_l0, + child_l0_aci, (nd_base, nd_half), )); } @@ -1236,17 +1300,38 @@ impl Scene { continue; } // Resolve ByBlock / layer-0 inheritance for this block - // child, then adapt to the background (#221). Pattern / - // lineweight args are unused by a hatch's colour. - let style = crate::scene::view::render::render_style_for_block_sub( + // child, including the pattern-stroke lineweight and + // effective ACI used by plot style tables. + let hatch_entity = EntityType::Hatch(dxf.clone()); + let mut style = crate::scene::view::render::render_style_for_block_sub( &self.document, - &EntityType::Hatch(dxf.clone()), - sub_ins_color, - 0.0, - [0.0; 8], - 0.0, + &hatch_entity, + sub_ins_style.0, + sub_ins_style.1, + sub_ins_style.2, + sub_ins_style.3, sub_l0, ); + let has_book_color = + crate::scene::view::render::has_resolved_book_color( + &self.document, + &hatch_entity, + ); + let on_l0 = crate::scene::view::render::is_effective_layer_zero( + &dxf.common.layer, + ); + style.4 = if !has_book_color + && dxf.common.color == acadrust::types::Color::ByBlock + { + sub_ins_style.4 + } else if !has_book_color + && on_l0 + && dxf.common.color == acadrust::types::Color::ByLayer + { + sub_l0_aci + } else { + style.4 + }; let color = style.0; let color = crate::scene::view::render::adapt_to_bg(color, hatch_bg); @@ -1254,6 +1339,7 @@ impl Scene { Self::hatch_model_from_dxf(&dxf, color) { model.aci = style.4; + model.line_weight_px = style.3; // In-block rank → within the insert's depth slot. model.draw_depth = d_base + depth_map @@ -1360,6 +1446,7 @@ impl Scene { name: "WIPEOUT_FILL".into(), color: fill_color, aci: 0, + line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, world_origin: fill_origin, @@ -1478,6 +1565,7 @@ impl Scene { name: "WIPEOUT_FILL".into(), color: fill_color, aci: 0, + line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, world_origin: fill_origin, @@ -2038,6 +2126,7 @@ impl Scene { // entity colour. color: gradient_color1.unwrap_or(color), aci: 0, + line_weight_px: 1.0, angle_offset: if prebaked { 0.0 } else { dxf.pattern_angle as f32 }, scale: if prebaked { 1.0 } else { dxf.pattern_scale as f32 }, world_origin, @@ -2343,6 +2432,7 @@ impl Scene { name: "SOLID".into(), color, aci: 0, + line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, world_origin, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 42dbdbe77..f11b6e825 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3160,6 +3160,7 @@ impl Scene { name: "SOLID".to_string(), color: self.paper_bg_color, aci: 0, + line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, // Draw-order bias is signed: entity fills/wires land in (-1, 1) diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index c17e00ce6..4924f38ff 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -179,6 +179,9 @@ pub struct HatchModel { pub color: [f32; 4], /// Effective indexed color used by plot style tables; 0 means RGB/default. pub aci: u8, + /// Effective display lineweight used by pattern strokes. PDF export converts + /// this back to a physical thickness unless a plot style overrides it. + pub line_weight_px: f32, /// Pattern rotation offset in radians (from DXF `pattern_angle`). /// Applied on top of each family's base angle at render time. pub angle_offset: f32, diff --git a/src/scene/paper.rs b/src/scene/paper.rs index b7715242e..fe22c740d 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -379,6 +379,7 @@ impl Scene { let style = self.render_style(entity); m.color = style.0; m.aci = style.4; + m.line_weight_px = style.3; if let EntityType::Hatch(dxf) = entity { // Only re-apply pattern_scale/angle for catalog-derived patterns // (empty stored lines). A pattern built from the hatch's own @@ -459,6 +460,7 @@ impl Scene { let style = self.render_style(entity); hatch.color = style.0; hatch.aci = style.4; + hatch.line_weight_px = style.3; if let EntityType::Hatch(dxf) = entity { if let model::hatch_model::HatchPattern::Pattern(_) = &hatch.pattern { if dxf.pattern.lines.is_empty() { @@ -516,6 +518,7 @@ impl Scene { name: "WIPEOUT_FILL".into(), color: self.paper_bg_color, aci: 0, + line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, world_origin, @@ -604,6 +607,7 @@ impl Scene { name: "WIPEOUT_FILL".into(), color: fill_color, aci: 0, + line_weight_px: 1.0, angle_offset: 0.0, scale: 1.0, world_origin: fill_origin, diff --git a/src/scene/preview.rs b/src/scene/preview.rs index 925d6310b..d57c0ee33 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -31,11 +31,14 @@ impl Scene { return; }; + let style = self.render_style(entity); + model.aci = style.4; + model.line_weight_px = style.3; if !matches!( model.pattern, crate::scene::model::hatch_model::HatchPattern::Gradient { .. } ) { - model.color = self.render_style(entity).0; + model.color = style.0; } if self.selected.contains(&handle) { model.color = [0.15, 0.55, 1.00, model.color[3]]; @@ -50,17 +53,38 @@ impl Scene { let mut backdrop = model.clone(); backdrop.pattern = crate::scene::model::hatch_model::HatchPattern::Solid; - backdrop.color = match background { + let (background_color, background_aci) = match background { acadrust::types::Color::ByLayer => { - crate::scene::view::render::layer_render_style( - &self.document, - &hatch.common.layer, + let layer = self.document.layers.get(&hatch.common.layer); + let aci = layer + .and_then(|layer| match &layer.color { + acadrust::types::Color::Index(index) => Some(*index), + _ => None, + }) + .unwrap_or(0); + ( + crate::scene::view::render::layer_render_style( + &self.document, + &hatch.common.layer, + ) + .color, + aci, ) - .color } - acadrust::types::Color::ByBlock => self.render_style(entity).0, - other => crate::scene::convert::tess_util::aci_to_rgba(&other), + acadrust::types::Color::ByBlock => (style.0, style.4), + acadrust::types::Color::Index(index) => ( + crate::scene::convert::tess_util::aci_to_rgba( + &acadrust::types::Color::Index(index), + ), + index, + ), + other => ( + crate::scene::convert::tess_util::aci_to_rgba(&other), + 0, + ), }; + backdrop.color = background_color; + backdrop.aci = background_aci; backdrop.name = "SOLID".into(); models.push(backdrop); } diff --git a/src/scene/project.rs b/src/scene/project.rs index c63feffd6..39c31b117 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -469,6 +469,7 @@ impl Scene { false, ); wire.aci = hatch.aci; + wire.line_weight_px = hatch.line_weight_px; wire.aabb = aabb; pattern_wires.push(wire); } diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 3f1bacc75..8cbaa7941 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -131,6 +131,7 @@ impl canvas::Program for HatchPatternPreview { name: String::new(), color: [1.0; 4], aci: 0, + line_weight_px: 1.0, angle_offset: 0.0, scale: hatch_preview_scale(&self.pattern), draw_depth: 0.0,