From 540d5f7b784ba0104ac0c42590943b3c33ffa4d8 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 15 Jun 2026 13:02:55 +0300 Subject: [PATCH] fix(xclip): clip hatch fills and render nested-block hatches Hatch fills render through the separate HatchModel pipeline, so the block wire clip never touched them: hatches outside an XCLIP boundary were still drawn. Clip each hatch boundary loop to the clip polygon (Sutherland-Hodgman, even-odd islands preserved) when the insert carries a spatial filter. Also fix hatches inside the boundary that were never drawn: the hatch collector exploded each insert only one level, so hatches nested in sub-blocks were dropped entirely. Walk the full block tree (depth guarded) so nested hatches render and clip like the rest. Co-Authored-By: Claude Opus 4.8 --- src/scene/mod.rs | 62 +++++++++++++++----- src/scene/xclip.rs | 143 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 14 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 74bc766da..74146b3fd 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3703,23 +3703,57 @@ impl Scene { continue; } let selected = self.selected.contains(&ins.common.handle); - for sub in ins + // XCLIP: clip this insert's exploded hatch fills to the boundary, + // matching how the line geometry is clipped in expand_insert. + let clip_poly = xclip::insert_spatial_filter(&self.document, ins) + .map(|sf| xclip::world_clip_polygon(sf, ins, hatch_offset)); + // Walk the full block tree: `explode_from_document` only descends + // one level, so nested INSERTs are re-exploded here. Each level + // bakes its transform into the children it returns, so nested + // hatches land in the correct world position. A depth guard keeps + // a malformed cyclic block reference from looping forever. + let normalize = crate::modules::home::modify::explode::normalize_insert_entity; + let mut stack: Vec<(EntityType, usize)> = ins .explode_from_document(&self.document) .into_iter() - .map(crate::modules::home::modify::explode::normalize_insert_entity) - { - let EntityType::Hatch(dxf) = sub else { - continue; - }; - if dxf.common.invisible || layer_hidden(&dxf.common.layer) { - continue; - } - let color = self.render_style(&EntityType::Hatch(dxf.clone())).0; - if let Some(mut model) = Self::hatch_model_from_dxf(&dxf, color, hatch_offset) { - if selected { - model.color = [0.15, 0.55, 1.00, model.color[3]]; + .map(|e| (normalize(e), 0usize)) + .collect(); + while let Some((sub, depth)) = stack.pop() { + match sub { + EntityType::Insert(nins) => { + if depth >= 32 { + continue; + } + for e in nins.explode_from_document(&self.document) { + stack.push((normalize(e), depth + 1)); + } } - models.push(model); + EntityType::Hatch(dxf) => { + if dxf.common.invisible || layer_hidden(&dxf.common.layer) { + continue; + } + let color = self.render_style(&EntityType::Hatch(dxf.clone())).0; + if let Some(mut model) = + Self::hatch_model_from_dxf(&dxf, color, hatch_offset) + { + if let Some(poly) = &clip_poly { + let clipped = xclip::clip_hatch_boundary( + &model.boundary, + model.world_origin, + poly, + ); + if clipped.is_empty() { + continue; + } + model.boundary = std::sync::Arc::new(clipped); + } + if selected { + model.color = [0.15, 0.55, 1.00, model.color[3]]; + } + models.push(model); + } + } + _ => {} } } } diff --git a/src/scene/xclip.rs b/src/scene/xclip.rs index c90efb2b0..d3f9f8441 100644 --- a/src/scene/xclip.rs +++ b/src/scene/xclip.rs @@ -110,6 +110,116 @@ pub fn clip_wires(wires: &mut Vec, poly: &[[f32; 2]]) { wires.retain(|w| !w.points.is_empty() || !w.fill_tris.is_empty()); } +/// Clip a hatch fill boundary to `poly`. +/// +/// `boundary` is a hatch's NaN-separated loops in f32 offsets from +/// `world_origin` (the [`HatchModel`](crate::scene::hatch_model::HatchModel) +/// representation); `poly` is the clip ring in the same world space the hatch +/// occupies. Each loop is intersected with the clip polygon independently — +/// the even-odd island structure is preserved because intersecting every loop +/// with the same region distributes over the even-odd fill. Returns the +/// clipped loops in offsets from `world_origin`; empty if nothing survives. +pub fn clip_hatch_boundary( + boundary: &[[f32; 2]], + world_origin: [f64; 2], + poly: &[[f32; 2]], +) -> Vec<[f32; 2]> { + if poly.len() < 3 { + return boundary.to_vec(); + } + let (ox, oy) = (world_origin[0] as f32, world_origin[1] as f32); + let mut out: Vec<[f32; 2]> = Vec::new(); + let mut i = 0; + while i < boundary.len() { + if !boundary[i][0].is_finite() || !boundary[i][1].is_finite() { + i += 1; + continue; + } + let start = i; + while i < boundary.len() + && boundary[i][0].is_finite() + && boundary[i][1].is_finite() + { + i += 1; + } + // Lift the loop into the clip polygon's world space. + let loop_abs: Vec<[f32; 2]> = boundary[start..i] + .iter() + .map(|p| [p[0] + ox, p[1] + oy]) + .collect(); + let clipped = clip_polygon_2d(&loop_abs, poly); + if clipped.len() >= 3 { + if !out.is_empty() { + out.push([f32::NAN, f32::NAN]); + } + for p in clipped { + out.push([p[0] - ox, p[1] - oy]); + } + } + } + out +} + +/// Sutherland–Hodgman clip of a closed 2D polygon `subject` against the convex +/// polygon `clip`. +fn clip_polygon_2d(subject: &[[f32; 2]], clip: &[[f32; 2]]) -> Vec<[f32; 2]> { + let n = clip.len(); + let mut area2 = 0.0f32; + let mut j = n - 1; + for i in 0..n { + area2 += clip[j][0] * clip[i][1] - clip[i][0] * clip[j][1]; + j = i; + } + let ccw = area2 > 0.0; + + let mut output: Vec<[f32; 2]> = subject.to_vec(); + let mut j = n - 1; + for i in 0..n { + if output.is_empty() { + break; + } + let (a, b) = (clip[j], clip[i]); + j = i; + let inside = |p: &[f32; 2]| { + let cr = (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); + if ccw { + cr >= 0.0 + } else { + cr <= 0.0 + } + }; + let input = std::mem::take(&mut output); + let len = input.len(); + for k in 0..len { + let cur = input[k]; + let prev = input[(k + len - 1) % len]; + let cur_in = inside(&cur); + let prev_in = inside(&prev); + if cur_in { + if !prev_in { + output.push(line_cross_2d(prev, cur, a, b)); + } + output.push(cur); + } else if prev_in { + output.push(line_cross_2d(prev, cur, a, b)); + } + } + } + output +} + +fn line_cross_2d(p0: [f32; 2], p1: [f32; 2], a: [f32; 2], b: [f32; 2]) -> [f32; 2] { + let r = (p1[0] - p0[0], p1[1] - p0[1]); + let s = (b[0] - a[0], b[1] - a[1]); + let denom = r.0 * s.1 - r.1 * s.0; + let t = if denom.abs() < 1e-12 { + 0.0 + } else { + ((a[0] - p0[0]) * s.1 - (a[1] - p0[1]) * s.0) / denom + }; + [p0[0] + r.0 * t, p0[1] + r.1 * t] +} + /// Ray-cast point-in-polygon test for a closed ring. fn point_in_poly(x: f32, y: f32, poly: &[[f32; 2]]) -> bool { let mut inside = false; @@ -420,6 +530,39 @@ mod tests { assert!(pts.iter().any(|p| (p[0] - 10.0).abs() < 1e-3)); } + #[test] + fn hatch_boundary_clipped_kept_and_dropped() { + let clip = square(); // 0..10 + // Hatch loop straddling the right edge → clipped to x<=10. + let straddle = [[5.0, 5.0], [15.0, 5.0], [15.0, 8.0], [5.0, 8.0]]; + let out = clip_hatch_boundary(&straddle, [0.0, 0.0], &clip); + assert!(!out.is_empty()); + assert!(out.iter().all(|p| p[0].is_nan() || p[0] <= 10.0 + 1e-3)); + + // Hatch fully inside → unchanged vertex count. + let inside = [[1.0, 1.0], [4.0, 1.0], [4.0, 4.0], [1.0, 4.0]]; + let out_in = clip_hatch_boundary(&inside, [0.0, 0.0], &clip); + assert_eq!(out_in.len(), 4); + + // Hatch fully outside → dropped. + let outside = [[20.0, 20.0], [25.0, 20.0], [25.0, 25.0]]; + let out_out = clip_hatch_boundary(&outside, [0.0, 0.0], &clip); + assert!(out_out.is_empty()); + } + + #[test] + fn hatch_boundary_respects_world_origin() { + // Same geometry as the straddle case but expressed as offsets from a + // large world_origin — clipping must account for the origin shift. + let clip = vec![[1000.0, 1000.0], [1010.0, 1000.0], [1010.0, 1010.0], [1000.0, 1010.0]]; + let origin = [1000.0, 1000.0]; + let loop_off = [[5.0, 5.0], [15.0, 5.0], [15.0, 8.0], [5.0, 8.0]]; + let out = clip_hatch_boundary(&loop_off, origin, &clip); + assert!(!out.is_empty()); + // Offsets must stay <= 10 (i.e. world x <= 1010). + assert!(out.iter().all(|p| p[0].is_nan() || p[0] <= 10.0 + 1e-3)); + } + #[test] fn world_polygon_applies_inverse_block_then_insert() { use acadrust::types::{Matrix4, Vector2};