cad-editor/tests/block_hatch_export.rs
2026-08-15 23:11:26 +03:00

331 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)));
let identity = acadrust::types::Transform::identity();
scene
.create_block_from_entities(&[h], "LOGO", &identity, &identity)
.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();
// The offset must be PERPENDICULAR to the 45° hatch lines to drive k far
// from 0 — a diagonal offset (e.g. (4000,4000)) lies ALONG the lines and
// projects to k≈0, so it would not exercise the clamp at all. At (4000,0)
// the perpendicular index is |k| ≈ 4000·sin45°/0.3 ≈ 9400, well past the
// 4096 clamp on both ends; the old absolute-index clamp inverts the range
// (k_lo > k_hi) there and emits nothing.
scene.add_entity(EntityType::Hatch(ansi31_stored_at(0.3, 1.0, 4000.0, 0.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"
);
}
// A hatch created in-app (HATCH command -> Scene::add_hatch) stores its pattern
// through build_dxf_pattern and is then rebuilt via hatch_model_from_dxf. The
// rebuilt spacing must equal the catalog's own spacing — not a rotated,
// too-dense value. Regression: build_dxf_pattern wrote the pattern line-LOCAL
// step into the world-frame `offset`, so the prebaked reader inverse-rotated it
// and ANSI31 at 45° collapsed its spacing by cos(45°) (3.175 -> 2.245) on both
// the viewport and the PDF/plot export.
#[test]
fn app_created_hatch_roundtrips_catalog_spacing() {
use std::sync::Arc;
use OpenCADStudio::scene::model::hatch_model::{HatchModel, PatFamily};
use OpenCADStudio::scene::model::hatch_patterns;
// Effective perpendicular spacing of a family, exactly as pattern_segments
// computes it: rotate the local step out by the angle, project onto the
// line-perpendicular direction.
fn perp_spacing(f: &PatFamily, scale: f32) -> f32 {
let a = f.angle_deg.to_radians();
let (ca, sa) = (a.cos(), a.sin());
let step_x = (f.dx * ca - f.dy * sa) * scale;
let step_y = (f.dx * sa + f.dy * ca) * scale;
(step_x * -sa + step_y * ca).abs()
}
let entry = hatch_patterns::find("ANSI31").expect("ANSI31 in catalog");
let HatchPattern::Pattern(cat_fams) = &entry.gpu else {
panic!("ANSI31 is a line pattern")
};
let expected = perp_spacing(&cat_fams[0], 1.0);
// Build the model the way the HATCH command does: catalog family, scale 1.
let mut scene = Scene::new();
let boundary: Vec<[f32; 2]> = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]];
let model = HatchModel {
render_instance: None,
world_origin: [0.0, 0.0],
boundary: Arc::new(boundary),
boundary_wcs: None,
boundary_exterior: None,
boundary_sources: None,
pattern: entry.gpu.clone(),
name: "ANSI31".into(),
color: [0.75, 0.75, 0.75, 0.85],
aci: 0,
line_weight_px: 1.0,
angle_offset: 0.0,
scale: 1.0,
draw_depth: 0.0,
};
scene.add_hatch(model, None, None);
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 after round-trip");
let HatchPattern::Pattern(fams) = &m.pattern else { unreachable!() };
let got = perp_spacing(&fams[0], m.scale);
assert!(
(got - expected).abs() < expected * 0.02,
"app-created ANSI31 round-tripped to spacing {got}, expected ~{expected} \
— build_dxf_pattern must store the world-frame offset, not the local step"
);
}
// Regression: a picked "big minus small" hatch must serialize the outer ring
// with the external / outermost flags and each hole ring WITHOUT them. If every
// NaN-separated ring were flagged external, DXF/DWG consumers would treat the
// inner loop as another outer island instead of a hole.
#[test]
fn nested_hatch_serializes_only_outer_as_external() {
use std::sync::Arc;
use OpenCADStudio::scene::model::hatch_model::HatchModel;
let outer: Vec<[f64; 2]> = vec![[-10.0, -10.0], [10.0, -10.0], [10.0, 10.0], [-10.0, 10.0]];
let hole: Vec<[f64; 2]> = vec![[-5.0, -5.0], [5.0, -5.0], [5.0, 5.0], [-5.0, 5.0]];
// Outer boundary + hole, NaN-separated, exactly as the HATCH command packs
// them in `boundary_wcs`.
let mut wcs: Vec<[f64; 2]> = outer.clone();
wcs.push([f64::NAN, f64::NAN]);
wcs.extend(hole.iter().copied());
let boundary_f32: Vec<[f32; 2]> = wcs.iter().map(|&[x, y]| [x as f32, y as f32]).collect();
let model = HatchModel {
render_instance: None,
world_origin: [0.0, 0.0],
boundary: Arc::new(boundary_f32),
boundary_wcs: Some(Arc::new(wcs)),
boundary_exterior: None,
boundary_sources: None,
pattern: HatchPattern::Solid,
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,
draw_depth: 0.0,
};
let mut scene = Scene::new();
scene.add_hatch(model, None, None);
let dxf = scene
.document
.entities()
.find_map(|e| if let EntityType::Hatch(h) = e { Some(h) } else { None })
.expect("nested hatch written to document");
assert_eq!(dxf.paths.len(), 2, "outer boundary + one hole path");
let ex = BoundaryPathFlags::EXTERNAL.bits();
let out = BoundaryPathFlags::OUTERMOST.bits();
assert!(dxf.paths[0].flags.bits() & ex != 0, "outer path must be flagged external");
assert!(dxf.paths[0].flags.bits() & out != 0, "outer path must be flagged outermost");
assert!(dxf.paths[1].flags.bits() & ex == 0, "hole path must NOT be flagged external");
assert!(dxf.paths[1].flags.bits() & out == 0, "hole path must NOT be flagged outermost");
}