fix(plot): correct hatch rendering in PDF / plot export

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.
This commit is contained in:
sLuCHa 2026-07-06 12:24:10 +02:00
commit 1a45f6bd97
5 changed files with 393 additions and 55 deletions

View file

@ -381,10 +381,29 @@ fn emit_hatch(ops: &mut Vec<Op>, 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;

View file

@ -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<HatchModel> {
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<HatchModel> = 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,

View file

@ -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::<f32>() * scale;
let has_dashes = !family.dashes.is_empty() && period > 1e-6;

View file

@ -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)
}

201
tests/block_hatch_export.rs Normal file
View file

@ -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"
);
}