From 1a45f6bd97d4deea5fb0a3b0f36fc9b37373f2a7 Mon Sep 17 00:00:00 2001 From: sLuCHa <142535358+sLuCHaa@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:24:10 +0200 Subject: [PATCH] fix(plot): correct hatch rendering in PDF / plot export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hatched content — especially fills nested inside a block INSERT, such as a title-block logo — plotted incorrectly: missing, mis-spaced, or with phantom bars. Five related fixes make the PDF/plot output match AutoCAD. 1. Export block-internal hatch fills. The export collected only hatches owned directly by the layout block, so a hatch nested in a block INSERT was dropped and printed as bare monochrome outlines. The insert-explosion the viewport already does is extracted into a shared `exploded_insert_hatch_models()` and called from both the viewport (`synced_hatch_models`) and the export (`paper_canvas_hatches`), so a plot draws block-internal hatches identically to the screen. 2. Honour the hatch's own stored pattern-line spacing. `hatch_model_from_dxf` re-derived pattern spacing from the name-matched catalog entry x pattern_scale, ignoring the resolved line geometry the DWG stores on the hatch. When a drawing was authored against a different base spacing (imperial 0.125 vs the catalog's metric 3.175 for ANSI31), lines came out up to ~25x too coarse and a dense fill collapsed to a few stray lines. When the hatch carries its own line geometry it is now used directly (identity scale/angle); the catalog path remains the fallback. 3. Fill far-from-origin pattern hatches. `pattern_segments` clamped the ABSOLUTE scan-line index to +/-MAX_LINES_PER_FAMILY. A fine-spaced hatch far from the pattern origin has large-magnitude indices at both ends but a small span, so the clamp inverted the range and emitted nothing — silently dropping the fill. Cap the line count (span) instead of the absolute index. 4. Skip TEXTBOX boundary paths. TEXTBOX boundary paths (flag bit 3) are text bounding-boxes AutoCAD derives for island detection; they are never drawn or filled. Treating one as a fill boundary painted its rectangle solid — a phantom bar. It is now skipped when building the fill boundary. 5. Print white/ACI-7 hatch fills black on paper. Hatch fills arrive adapted to the dark screen background, so a white/ACI-7 fill would vanish white-on-white on the sheet. Mirror the wire pass: near-white/near-yellow -> black, near-cyan -> dark blue, matching AutoCAD's colour-7-on-white plotting. Wipeouts keep their paper-white mask. Adds `tests/block_hatch_export.rs` covering: block-internal hatch reaches the export set, stored-line spacing is honoured, far-from-origin fills are not dropped, and TEXTBOX paths are not filled. --- src/io/pdf_export.rs | 21 +++- src/scene/entity.rs | 193 +++++++++++++++++++++++-------- src/scene/model/hatch_model.rs | 11 +- src/scene/paper.rs | 22 +++- tests/block_hatch_export.rs | 201 +++++++++++++++++++++++++++++++++ 5 files changed, 393 insertions(+), 55 deletions(-) create mode 100644 tests/block_hatch_export.rs diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index a2df67a3..3e298383 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -381,10 +381,29 @@ fn emit_hatch(ops: &mut Vec, hatch: &HatchModel, ox: f32, oy: f32) { if hatch.boundary.is_empty() { return; } - let [r, g, b, a] = hatch.color; + let [mut r, mut g, mut b, a] = hatch.color; if a < 0.01 { return; } + // Adapt hatch fills to the white sheet, mirroring the wire pass: colours + // arrive adapted to the (dark) screen background, so a white/ACI-7 fill + // 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" { + 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; + if is_light || is_yellow { + r = 0.0; + g = 0.0; + b = 0.0; + } else if is_cyan { + r = 0.0; + g = 0.15; + b = 0.50; + } + } let world_ox = hatch.world_origin[0] as f32; let world_oy = hatch.world_origin[1] as f32; diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 8a04a53f..a65fb706 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1,6 +1,31 @@ // Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged. use super::*; +/// Convert a HATCH's own resolved pattern line (world-unit `offset` = step to +/// the next parallel line, plus a base angle) into a render `PatFamily` whose +/// geometry is already final — the HatchModel that carries it uses scale 1 and +/// angle_offset 0 (see `prebaked` in `hatch_model_from_dxf`). The world-space +/// offset is rotated into the line's local frame so `pattern_segments` and the +/// GPU shader, which rotate `(dx, dy)` back out by the family angle, reproduce +/// the exact stored step. `x0/y0` stay 0: for an infinite line family the phase +/// is not observable, and the stored base point is in the source (possibly +/// block-local) frame anyway. +fn family_from_stored_line( + ln: &acadrust::entities::hatch::HatchPatternLine, +) -> crate::scene::model::hatch_model::PatFamily { + let (ca, sa) = (ln.angle.cos(), ln.angle.sin()); + let dx = ln.offset.x * ca + ln.offset.y * sa; + let dy = -ln.offset.x * sa + ln.offset.y * ca; + crate::scene::model::hatch_model::PatFamily { + angle_deg: ln.angle.to_degrees() as f32, + x0: 0.0, + y0: 0.0, + dx: dx as f32, + dy: dy as f32, + dashes: ln.dash_lengths.iter().map(|&d| d as f32).collect(), + } +} + impl Scene { // ── Entity management ───────────────────────────────────────────────── @@ -465,7 +490,13 @@ impl Scene { m.color = self.render_style(e).0; if let EntityType::Hatch(dxf) = e { match &mut m.pattern { - model::hatch_model::HatchPattern::Pattern(_) => { + // Pattern built from the hatch's own stored lines is + // already final (scale 1 / angle 0) — don't re-apply + // pattern_scale/angle. Only the catalog-derived path + // (empty stored lines) needs the override. + model::hatch_model::HatchPattern::Pattern(_) + if dxf.pattern.lines.is_empty() => + { m.angle_offset = dxf.pattern_angle as f32; let anno = if self.current_layout == "Model" { self.annotation_scale @@ -477,7 +508,8 @@ impl Scene { model::hatch_model::HatchPattern::Gradient { angle_deg, .. } => { *angle_deg = dxf.pattern_angle.to_degrees() as f32; } - model::hatch_model::HatchPattern::Solid => {} + model::hatch_model::HatchPattern::Pattern(_) + | model::hatch_model::HatchPattern::Solid => {} } } } @@ -495,6 +527,87 @@ impl Scene { } else { 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.) + models.extend(self.exploded_insert_hatch_models(layout_block, hatch_bg, true)); + + // Wide LWPolyline and Polyline2D fills + for entity in self.document.entities() { + let (common, fill_origin, fills) = match entity { + EntityType::LwPolyline(pl) => { + let (o, f) = crate::entities::lwpolyline::wide_fills(pl); + (&pl.common, o, f) + } + EntityType::Polyline2D(pl) => { + let (o, f) = crate::entities::polyline::wide_fills(pl); + (&pl.common, o, f) + } + _ => continue, + }; + if fills.is_empty() { + continue; + } + if common.invisible || layer_hidden(&common.layer) { + continue; + } + if !self.belongs_to_visible_block(common.handle, common.owner_handle, layout_block) { + continue; + } + let base_color = self.render_style(entity).0; + let selected = self.selected.contains(&common.handle); + let color = if selected { + [0.15, 0.55, 1.00, 1.0] + } else { + base_color + }; + for boundary in fills { + models.push(HatchModel { + boundary: Arc::new(boundary), + pattern: model::hatch_model::HatchPattern::Solid, + name: "SOLID".into(), + color, + angle_offset: 0.0, + scale: 1.0, + world_origin: fill_origin, + vp_scissor: None, + draw_depth: depth_map.get(&common.handle.value()).copied().unwrap_or(0.0), + }); + } + } + + models + } + + /// Materialize the hatch fills that live *inside* the visible INSERTs of + /// `layout_block`, baking each INSERT's transform (and nested-INSERT + /// transforms) so the fills land in world position with resolved + /// ByBlock / layer-0 colours. + /// + /// Shared by the on-screen hatch set (`synced_hatch_models`) and the + /// paper/export hatch set (`paper_canvas_hatches`) so a plot draws + /// block-internal hatches identically to the viewport. Without this the + /// export path only saw top-level hatches, so any fill nested in a block + /// INSERT printed as bare monochrome outlines. + /// + /// `hatch_bg` adapts pure black/white leaf colours to the target + /// background; `tint_selected` re-colours fills of a selected INSERT + /// (screen highlight) and should be `false` for export. + pub(super) fn exploded_insert_hatch_models( + &self, + layout_block: Handle, + hatch_bg: [f32; 4], + tint_selected: bool, + ) -> 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 = Vec::new(); // Exploding an INSERT materializes (clones) every child of its block — // including 3D solids that each carry megabytes of SAB geometry — just // to scan the result for hatch fills. On xref-heavy drawings whose @@ -522,7 +635,7 @@ impl Scene { if !self.block_has_hatch(&ins.block_name, &mut hatch_block_memo) { continue; } - let selected = self.selected.contains(&ins.common.handle); + let selected = tint_selected && self.selected.contains(&ins.common.handle); // XCLIP: clip this insert's exploded hatch fills to the boundary, // matching how the line geometry is clipped in expand_insert. let clip_poly = pick::xclip::insert_spatial_filter(&self.document, ins) @@ -632,51 +745,6 @@ impl Scene { } } } - - // Wide LWPolyline and Polyline2D fills - for entity in self.document.entities() { - let (common, fill_origin, fills) = match entity { - EntityType::LwPolyline(pl) => { - let (o, f) = crate::entities::lwpolyline::wide_fills(pl); - (&pl.common, o, f) - } - EntityType::Polyline2D(pl) => { - let (o, f) = crate::entities::polyline::wide_fills(pl); - (&pl.common, o, f) - } - _ => continue, - }; - if fills.is_empty() { - continue; - } - if common.invisible || layer_hidden(&common.layer) { - continue; - } - if !self.belongs_to_visible_block(common.handle, common.owner_handle, layout_block) { - continue; - } - let base_color = self.render_style(entity).0; - let selected = self.selected.contains(&common.handle); - let color = if selected { - [0.15, 0.55, 1.00, 1.0] - } else { - base_color - }; - for boundary in fills { - models.push(HatchModel { - boundary: Arc::new(boundary), - pattern: model::hatch_model::HatchPattern::Solid, - name: "SOLID".into(), - color, - angle_offset: 0.0, - scale: 1.0, - world_origin: fill_origin, - vp_scissor: None, - draw_depth: depth_map.get(&common.handle.value()).copied().unwrap_or(0.0), - }); - } - } - models } @@ -829,6 +897,13 @@ impl Scene { let mut boundary: Vec<[f64; 2]> = Vec::new(); for path in &dxf.paths { + // Skip TEXTBOX boundary paths (flag bit 3). These are text + // bounding-boxes AutoCAD derives for island detection; they are + // never drawn or filled. Treating one as a fill boundary paints its + // rectangle solid — a phantom bar AutoCAD never shows. + if path.flags.bits() & 8 != 0 { + continue; + } let before_path = boundary.len(); if !boundary.is_empty() { boundary.push([f64::NAN, f64::NAN]); @@ -1076,6 +1151,20 @@ impl Scene { boundary.truncate(cut); } + // When the HATCH carries its own resolved pattern-line geometry + // (angle + world-unit offset, exactly as the DWG stores it), use THAT + // instead of re-deriving spacing from the name-matched catalog entry + // × pattern_scale. The catalog's base spacing (e.g. metric acadiso + // ANSI31 = 3.175) rarely matches what the drawing was authored against + // (imperial 0.125), so the catalog path rendered lines up to ~25× + // (= inch→mm) too coarse — a dense fill collapsed to a few stray + // lines. The stored offset is the authoritative world-unit spacing, so + // the resulting families are already final: no pattern_scale / angle + // is re-applied (see `prebaked` below and the HatchModel fields). + let prebaked = !dxf.is_solid + && !dxf.gradient_color.is_enabled() + && !dxf.pattern.lines.is_empty(); + let pattern = if dxf.gradient_color.is_enabled() { let color2 = dxf .gradient_color @@ -1088,6 +1177,10 @@ impl Scene { model::hatch_model::HatchPattern::Gradient { angle_deg, color2 } } else if dxf.is_solid { model::hatch_model::HatchPattern::Solid + } else if prebaked { + model::hatch_model::HatchPattern::Pattern( + dxf.pattern.lines.iter().map(family_from_stored_line).collect(), + ) } else { let pat_name = &dxf.pattern.name; if let Some(entry) = crate::scene::model::hatch_patterns::find(pat_name) { @@ -1176,8 +1269,8 @@ impl Scene { pattern, name, color, - angle_offset: dxf.pattern_angle as f32, - scale: dxf.pattern_scale as f32, + 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, vp_scissor: None, draw_depth: 0.0, diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 7f5a3a45..97fa8080 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -210,8 +210,15 @@ impl HatchModel { if k_lo > k_hi { std::mem::swap(&mut k_lo, &mut k_hi); } - k_lo = k_lo.max(-MAX_LINES_PER_FAMILY); - k_hi = k_hi.min(MAX_LINES_PER_FAMILY); + // Cap the line COUNT (span), not the absolute index. A hatch far + // from the pattern origin (0,0) — e.g. a fine-spaced fill at large + // drawing coordinates — has large-magnitude k at both ends but a + // small span. Clamping the absolute index to ±MAX_LINES_PER_FAMILY + // would invert the range (k_lo > k_hi) and emit nothing, silently + // dropping the whole fill. + if k_hi.saturating_sub(k_lo) > MAX_LINES_PER_FAMILY { + k_hi = k_lo.saturating_add(MAX_LINES_PER_FAMILY); + } let period: f32 = family.dashes.iter().map(|d| d.abs()).sum::() * scale; let has_dashes = !family.dashes.is_empty() && period > 1e-6; diff --git a/src/scene/paper.rs b/src/scene/paper.rs index bdbdc769..3bb499fd 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -212,9 +212,14 @@ impl Scene { let mut m = model.clone(); m.color = self.render_style(entity).0; 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 + // stored lines is already final (scale 1 / angle 0). if let model::hatch_model::HatchPattern::Pattern(_) = &m.pattern { - m.angle_offset = dxf.pattern_angle as f32; - m.scale = dxf.pattern_scale as f32; + if dxf.pattern.lines.is_empty() { + m.angle_offset = dxf.pattern_angle as f32; + m.scale = dxf.pattern_scale as f32; + } } } if self.selected.contains(&handle) { @@ -222,6 +227,19 @@ impl Scene { } models.push(m); } + // Hatch fills nested inside a block INSERT are owned by the block + // record, so the loop above — which only keeps hatches owned by + // `layout_block` — never sees them. Explode the layout's visible + // INSERTs and materialize their fills at world position, exactly as the + // viewport does, so the export carries the block's colours instead of + // bare outlines. (No selection tint on export.) + let hatch_bg = if self.current_layout != "Model" { + self.paper_bg_color + } else { + self.bg_color + }; + let exploded = self.exploded_insert_hatch_models(layout_block, hatch_bg, false); + models.extend(exploded); Arc::new(models) } diff --git a/tests/block_hatch_export.rs b/tests/block_hatch_export.rs new file mode 100644 index 00000000..b3b40d55 --- /dev/null +++ b/tests/block_hatch_export.rs @@ -0,0 +1,201 @@ +// Regression: hatch fills that live *inside* a block INSERT must reach the +// plot / PDF-export hatch set, not just the on-screen viewport. +// +// The export path (`paper_canvas_hatches`) used to collect only hatches owned +// directly by the layout block, so a hatch nested in a block was dropped and a +// plot printed the block as bare monochrome outlines. `synced_hatch_models` +// (the viewport) explodes visible INSERTs and materializes their fills; both +// paths now share `exploded_insert_hatch_models`. +use acadrust::entities::hatch::{ + BoundaryEdge, BoundaryPath, BoundaryPathFlags, HatchPatternLine, LineEdge, +}; +use acadrust::entities::Hatch; +use acadrust::types::{Color as AcadColor, Vector2}; +use acadrust::EntityType; +use OpenCADStudio::scene::model::hatch_model::HatchPattern; +use OpenCADStudio::scene::Scene; + +fn is_blue(c: &[f32; 4]) -> bool { + c[2] > 0.85 && c[0] < 0.20 && c[1] < 0.20 +} + +/// Build a 10x10 square solid hatch of the given ACI colour, offset by (cx, cy). +fn square_hatch_at(aci: u8, cx: f64, cy: f64) -> Hatch { + let mut path = BoundaryPath::new(); + for (s, e) in [ + ((0.0, 0.0), (10.0, 0.0)), + ((10.0, 0.0), (10.0, 10.0)), + ((10.0, 10.0), (0.0, 10.0)), + ((0.0, 10.0), (0.0, 0.0)), + ] { + path.edges.push(BoundaryEdge::Line(LineEdge { + start: Vector2::new(s.0 + cx, s.1 + cy), + end: Vector2::new(e.0 + cx, e.1 + cy), + })); + } + let mut hatch = Hatch::new(); + hatch.paths.push(path); + hatch.common.color = AcadColor::Index(aci); // 5 = blue + hatch +} + +fn square_hatch(aci: u8) -> Hatch { + square_hatch_at(aci, 0.0, 0.0) +} + +// A pattern (non-solid) hatch that carries its OWN resolved pattern line — +// a 45° family whose world-unit perpendicular spacing is `spacing` — plus a +// large `pattern_scale` that the (wrong) catalog path would multiply in. +fn ansi31_stored(spacing: f64, pattern_scale: f64) -> Hatch { + ansi31_stored_at(spacing, pattern_scale, 0.0, 0.0) +} + +fn ansi31_stored_at(spacing: f64, pattern_scale: f64, cx: f64, cy: f64) -> Hatch { + let mut hatch = square_hatch_at(5, cx, cy); + hatch.is_solid = false; + hatch.pattern.name = "ANSI31".into(); + // offset = perpendicular step to the next line at 45°: (-s/√2, s/√2). + let d = spacing / std::f64::consts::SQRT_2; + // Set the field directly — `set_pattern_scale()` would recompute and + // clobber the stored lines we are deliberately testing. + hatch.pattern_scale = pattern_scale; + hatch.pattern.lines = vec![HatchPatternLine { + angle: std::f64::consts::FRAC_PI_4, + base_point: Vector2::new(0.0, 0.0), + offset: Vector2::new(-d, d), + dash_lengths: vec![], + }]; + hatch +} + +// Self-contained guard that runs everywhere (no external asset needed). +#[test] +fn block_internal_hatch_reaches_export() { + let mut scene = Scene::new(); + + // A blue hatch, wrapped into a block and inserted in model space — the + // minimal shape of "coloured fill nested in a block". + let h = scene.add_entity(EntityType::Hatch(square_hatch(5))); + scene + .create_block_from_entities(&[h], "LOGO", glam::Vec3::ZERO) + .expect("wrap hatch into a block + insert"); + scene.populate_hatches_from_document(); + + let hatches = scene.paper_canvas_hatches(); + let blue = hatches.iter().filter(|m| is_blue(&m.color)).count(); + assert!( + blue > 0, + "block-internal hatch dropped from the export set (len={}) — a plot \ + would print the block without its colours", + hatches.len() + ); +} + +// A pattern hatch that stores its own resolved line geometry must render at +// THAT spacing — not the name-matched catalog spacing × pattern_scale. Before +// the fix, ANSI31 was re-derived from the metric catalog (3.175) × scale, so a +// hatch authored at ~0.5-unit spacing collapsed to a near-empty few lines. +#[test] +fn pattern_hatch_uses_stored_line_spacing() { + let mut scene = Scene::new(); + // Spacing 0.5; a big pattern_scale (10) that the catalog path would apply. + scene.add_entity(EntityType::Hatch(ansi31_stored(0.5, 10.0))); + scene.populate_hatches_from_document(); + + let hatches = scene.paper_canvas_hatches(); + let m = hatches + .iter() + .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) + .expect("pattern hatch present in export set"); + + let HatchPattern::Pattern(fams) = &m.pattern else { unreachable!() }; + // Effective perpendicular spacing = family.dy * model.scale. + let dy = fams[0].dy.abs(); + let spacing = dy * m.scale; + assert!( + (spacing - 0.5).abs() < 0.05, + "expected ~0.5-unit line spacing from the stored offset, got {spacing} \ + (dy={dy}, scale={}) — catalog×pattern_scale would give ~31.75", + m.scale + ); + // Density sanity: a 10x10 boundary at 0.5 spacing -> ~28 lines, not ~1. + let lines = m.pattern_segments().len(); + assert!( + lines >= 10, + "expected a dense fill (~28 lines), got {lines} — pattern too sparse" + ); +} + +// A fine-spaced pattern hatch placed far from the pattern origin (0,0) must +// still fill. `pattern_segments` used to clamp the ABSOLUTE line index to +// ±MAX_LINES_PER_FAMILY; a hatch thousands of units away has large-magnitude k +// on both ends, so the clamp inverted the range and emitted nothing — a +// fine-spaced fill far from the origin printed as empty outlines. +#[test] +fn far_from_origin_pattern_hatch_still_fills() { + let mut scene = Scene::new(); + // Spacing 0.3 at ~4000 units out → k ≈ 4000/0.3·√2 ≈ 18000, well past the + // 4096 clamp on both ends. + scene.add_entity(EntityType::Hatch(ansi31_stored_at(0.3, 1.0, 4000.0, 4000.0))); + scene.populate_hatches_from_document(); + + let hatches = scene.paper_canvas_hatches(); + let m = hatches + .iter() + .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) + .expect("pattern hatch present"); + let lines = m.pattern_segments().len(); + assert!( + lines >= 10, + "far-from-origin pattern hatch produced {lines} lines — fill was dropped" + ); +} + +// A TEXTBOX boundary path (AutoCAD's text-bounding-box, used only for island +// detection) must NOT be filled. Painting its rectangle solid produces a +// phantom bar AutoCAD never shows. +#[test] +fn textbox_boundary_path_is_not_filled() { + fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> BoundaryPath { + let mut p = BoundaryPath::new(); + for (s, e) in [ + ((x0, y0), (x1, y0)), + ((x1, y0), (x1, y1)), + ((x1, y1), (x0, y1)), + ((x0, y1), (x0, y0)), + ] { + p.edges.push(BoundaryEdge::Line(LineEdge { + start: Vector2::new(s.0, s.1), + end: Vector2::new(e.0, e.1), + })); + } + p + } + + let mut scene = Scene::new(); + let mut hatch = Hatch::new(); + hatch.common.color = AcadColor::Index(5); + // A small real fill region... + hatch.paths.push(rect_path(0.0, 0.0, 10.0, 10.0)); + // ...plus a large TEXTBOX rectangle that must be ignored. + let mut tb = rect_path(0.0, 0.0, 200.0, 50.0); + tb.flags = BoundaryPathFlags::from_bits(8 | 1); // TEXTBOX + EXTERNAL + hatch.paths.push(tb); + scene.add_entity(EntityType::Hatch(hatch)); + scene.populate_hatches_from_document(); + + let hatches = scene.paper_canvas_hatches(); + let m = hatches.first().expect("hatch present"); + // The boundary must not extend into the 200x50 TEXTBOX rectangle. + let max_x = m + .boundary + .iter() + .filter(|v| v[0].is_finite()) + .map(|v| v[0]) + .fold(f32::NEG_INFINITY, f32::max); + assert!( + max_x < 50.0, + "TEXTBOX rectangle leaked into the fill boundary (max_x={max_x}) — it \ + would paint a phantom bar" + ); +}