From 7a8da58d5537668e96871631c3a65ed2d2b39112 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 13 Aug 2026 17:16:29 +0300 Subject: [PATCH] feat(hatch): add multi-region selection Refs #532 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/command_driver.rs | 10 +- src/app/commands/draw.rs | 10 +- src/command.rs | 6 + src/modules/draw/draw/hatch.rs | 227 +++++++++++++++++++++++++++++---- src/scene/boundary.rs | 76 ++++++----- src/scene/entity.rs | 130 +++++++++++-------- src/scene/mod.rs | 1 + src/scene/model/hatch_model.rs | 2 + src/scene/preview.rs | 5 + src/ui/properties.rs | 1 + 12 files changed, 352 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd87836de..9841c6ed7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -891,7 +891,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=8d05593#8d05593221cf6158076c38e98fef54ab767b81d9" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=1fe426d#1fe426d1922dfc79685b8f6d34ce66ab15d97814" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 42a3ec4c7..c3272527c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4736846", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8d05593", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "1fe426d", features = ["acis", "offset"] } acadifc = { git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "47b5603", optional = true } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 4491f33dd..04db3aeec 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -14,7 +14,15 @@ impl OpenCADStudio { true } - fn refresh_area_preview(&mut self, i: usize) { + pub(super) fn refresh_area_preview(&mut self, i: usize) { + let hatches = self.tabs[i] + .active_cmd + .as_ref() + .and_then(|command| command.hatch_preview_models()); + if let Some(hatches) = hatches { + self.tabs[i].scene.set_command_preview_hatches(hatches); + return; + } let regions = self.tabs[i] .active_cmd .as_ref() diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 20a3d3934..4fd5a3e26 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -619,9 +619,17 @@ impl OpenCADStudio { "HATCH" => { use crate::modules::draw::draw::hatch::HatchCommand; let outlines = self.tabs[i].scene.hatch_boundary_outlines(); - let new_cmd = HatchCommand::new(outlines); + let boundary_sources = self.tabs[i].scene.hatch_boundary_sources(); + let selected = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(handle, _)| handle) + .collect(); + let new_cmd = HatchCommand::new(outlines, boundary_sources, selected); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + self.refresh_area_preview(i); } "HATCHEDIT" => { diff --git a/src/command.rs b/src/command.rs index 4ebe6d8b2..5c135ff91 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1844,6 +1844,12 @@ pub trait CadCommand: Send { None } + fn hatch_preview_models( + &self, + ) -> Option> { + None + } + /// Returns `true` when the current step picks a corner of a selection /// *window* by point (e.g. STRETCH's crossing window). Such a pick must be a /// free point: applying the Ortho/Polar lock would pin the opposite corner to diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index 265829137..e8cf5ce96 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -13,6 +13,8 @@ use crate::command::{CadCommand, CmdResult}; use crate::modules::IconKind; use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily}; use crate::scene::model::wire_model::WireModel; +use acadrust::Handle; +use cadkernel::geom2d::{bounded_faces, Line, Tolerance}; use glam::DVec3; use crate::t; @@ -48,6 +50,13 @@ enum Mode { Manual, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum HatchMode { + PickInside, + SelectObjects, + Manual, +} + // ── CPU point-in-polygon (ray casting) ──────────────────────────────────── fn point_in_polygon(p: [f64; 2], poly: &[[f64; 2]]) -> bool { @@ -195,23 +204,91 @@ fn rte_boundary(pts: impl Iterator) -> (Vec<[f32; 2]>, [f64; pub struct HatchCommand { outlines: Vec>, - mode: Mode, + boundary_sources: rustc_hash::FxHashMap>, + point_regions: Vec>>, + object_regions: Vec>>, + selected_objects: Vec, + mode: HatchMode, manual_pts: Vec, missed: bool, } impl HatchCommand { - pub fn new(outlines: Vec>) -> Self { - Self { + pub fn new( + outlines: Vec>, + boundary_sources: rustc_hash::FxHashMap>, + selected_objects: Vec, + ) -> Self { + let has_selection = !selected_objects.is_empty(); + let mut command = Self { outlines, - mode: Mode::PickInside, + boundary_sources, + point_regions: Vec::new(), + object_regions: Vec::new(), + selected_objects: Vec::new(), + mode: if has_selection { + HatchMode::SelectObjects + } else { + HatchMode::PickInside + }, manual_pts: vec![], missed: false, + }; + command.set_object_selection(selected_objects); + command + } + + fn set_object_selection(&mut self, handles: Vec) { + let mut segments = Vec::new(); + for handle in &handles { + if let Some(source) = self.boundary_sources.get(handle) { + segments.extend(source.iter().copied()); + } } + self.object_regions = bounded_faces(&segments, Tolerance::new(1.0e-6)) + .into_iter() + .map(|ring| vec![ring]) + .collect(); + self.missed = !handles.is_empty() && self.object_regions.is_empty(); + self.selected_objects = handles; + } + + fn add_point_region(&mut self, rings: Vec>) { + let duplicate = rings.first().is_some_and(|outer| { + self.point_regions + .iter() + .any(|region| region.first() == Some(outer)) + }); + if !duplicate { + self.point_regions.push(rings); + } + } + + fn region_count(&self) -> usize { + self.point_regions.len() + self.object_regions.len() + } + + fn combined_rings(&self) -> Vec> { + let mut rings = Vec::new(); + for ring in self + .point_regions + .iter() + .chain(self.object_regions.iter()) + .flat_map(|region| region.iter()) + { + if !rings.iter().any(|existing| existing == ring) { + rings.push(ring.clone()); + } + } + rings } fn make_hatch(&self, rings: Vec>) -> HatchModel { let (rel, origin, wcs) = pack_rings(&rings); + let exterior = cadkernel::geom2d::ring_nesting_depths(&rings) + .into_iter() + .map(|depth| depth % 2 == 0) + .collect(); // Default: ANSI31 from catalog; fallback to a single 45° family. let pat_name = "ANSI31"; let families = crate::scene::model::hatch_patterns::find(pat_name) @@ -246,6 +323,7 @@ impl HatchCommand { scale: 1.0, world_origin: origin, boundary_wcs: Some(std::sync::Arc::new(wcs)), + boundary_exterior: Some(std::sync::Arc::new(exterior)), draw_depth: 0.0, } } @@ -258,15 +336,34 @@ impl CadCommand for HatchCommand { fn prompt(&self) -> String { match &self.mode { - Mode::PickInside => { + HatchMode::PickInside => { let miss = if self.missed { t!(" ⚠ No closed boundary found.").into_owned() } else { String::new() }; - t!("HATCH Pick internal point:%{miss}", miss = miss).into_owned() + t!( + "HATCH Pick internal point (%{count} regions selected, Enter to apply):%{miss}", + count = self.region_count(), + miss = miss + ) + .into_owned() } - Mode::Manual => { + HatchMode::SelectObjects => { + let miss = if self.missed { + t!(" ⚠ Selection has no closed boundary.").into_owned() + } else { + String::new() + }; + t!( + "HATCH Select boundary objects (%{objects} objects, %{count} regions; Enter to apply):%{miss}", + objects = self.selected_objects.len(), + count = self.region_count(), + miss = miss + ) + .into_owned() + } + HatchMode::Manual => { if self.manual_pts.is_empty() { t!("HATCH Boundary point 1:").into_owned() } else { @@ -280,8 +377,27 @@ impl CadCommand for HatchCommand { fn options(&self) -> Vec { use crate::command::CmdOption; match &self.mode { - Mode::PickInside => vec![CmdOption::new(t!("Draw manually").as_ref(), "S")], - Mode::Manual => { + HatchMode::PickInside => { + let mut options = vec![ + CmdOption::new(t!("Select objects").as_ref(), "O"), + CmdOption::new(t!("Draw manually").as_ref(), "S"), + ]; + if self.region_count() > 0 { + options.push(CmdOption::enter(t!("Accept").as_ref())); + } + options + } + HatchMode::SelectObjects => { + let mut options = vec![ + CmdOption::new(t!("Pick internal points").as_ref(), "I"), + CmdOption::new(t!("Draw manually").as_ref(), "S"), + ]; + if self.region_count() > 0 { + options.push(CmdOption::enter(t!("Accept").as_ref())); + } + options + } + HatchMode::Manual => { // Enter accepts the boundary once at least 3 points are picked. if self.manual_pts.len() >= 3 { vec![CmdOption::enter(t!("Accept").as_ref())] @@ -294,12 +410,13 @@ impl CadCommand for HatchCommand { fn on_point(&mut self, pt: DVec3) -> CmdResult { match &self.mode { - Mode::PickInside => { + HatchMode::PickInside => { let xy = [pt.x, pt.y]; match resolve_hatch_rings(&self.outlines, xy) { Some(rings) => { self.missed = false; - return CmdResult::CommitHatch(self.make_hatch(rings)); + self.add_point_region(rings); + CmdResult::NeedPoint } None => { self.missed = true; @@ -307,7 +424,8 @@ impl CadCommand for HatchCommand { } } } - Mode::Manual => { + HatchMode::SelectObjects => CmdResult::NeedPoint, + HatchMode::Manual => { // Keep the typed/snapped point exact (issue #311). self.manual_pts.push(pt); CmdResult::NeedPoint @@ -316,16 +434,55 @@ impl CadCommand for HatchCommand { } fn on_enter(&mut self) -> CmdResult { - match &self.mode { - Mode::PickInside => CmdResult::Cancel, - Mode::Manual => { - if self.manual_pts.len() < 3 { - return CmdResult::Cancel; - } - let wcs = self.manual_pts.iter().map(|p| [p.x, p.y]).collect(); - CmdResult::CommitHatch(self.make_hatch(vec![wcs])) - } + if matches!(self.mode, HatchMode::Manual) && self.manual_pts.len() >= 3 { + let ring = self.manual_pts.iter().map(|p| [p.x, p.y]).collect(); + self.add_point_region(vec![ring]); } + let rings = self.combined_rings(); + if rings.is_empty() { + CmdResult::Cancel + } else { + CmdResult::CommitHatch(self.make_hatch(rings)) + } + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.mode, HatchMode::SelectObjects) + } + + fn selection_forces_add(&self) -> bool { + matches!(self.mode, HatchMode::SelectObjects) + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + if matches!(self.mode, HatchMode::SelectObjects) { + self.set_object_selection(handles); + CmdResult::NeedPoint + } else { + CmdResult::Cancel + } + } + + fn on_undo_step(&mut self) -> Option { + if matches!(self.mode, HatchMode::PickInside) && self.point_regions.pop().is_some() { + Some(CmdResult::NeedPoint) + } else { + None + } + } + + fn hatch_preview_models(&self) -> Option> { + let mut rings = self.combined_rings(); + if matches!(self.mode, HatchMode::Manual) && self.manual_pts.len() >= 3 { + rings.push(self.manual_pts.iter().map(|point| [point.x, point.y]).collect()); + } + Some(if rings.is_empty() { + Vec::new() + } else { + let mut preview = self.make_hatch(rings); + preview.color = [0.15, 0.55, 1.0, 0.75]; + vec![preview] + }) } fn on_escape(&mut self) -> CmdResult { @@ -333,20 +490,32 @@ impl CadCommand for HatchCommand { } fn wants_text_input(&self) -> bool { - matches!(self.mode, Mode::PickInside) + !matches!(self.mode, HatchMode::Manual) } fn on_text_input(&mut self, text: &str) -> Option { - if text.trim().eq_ignore_ascii_case("s") { - self.mode = Mode::Manual; - self.missed = false; - return Some(CmdResult::NeedPoint); + match text.trim().to_ascii_uppercase().as_str() { + "O" | "OBJECT" | "OBJECTS" => { + self.mode = HatchMode::SelectObjects; + self.missed = false; + Some(CmdResult::NeedPoint) + } + "I" | "INTERNAL" => { + self.mode = HatchMode::PickInside; + self.missed = false; + Some(CmdResult::NeedPoint) + } + "S" => { + self.mode = HatchMode::Manual; + self.missed = false; + Some(CmdResult::NeedPoint) + } + _ => None, } - None } fn on_mouse_move(&mut self, pt: DVec3) -> Option { let pt = pt.as_vec3(); - if let Mode::Manual = &self.mode { + if let HatchMode::Manual = &self.mode { if self.manual_pts.is_empty() { return None; } @@ -416,6 +585,7 @@ impl GradientCommand { scale: 1.0, world_origin: origin, boundary_wcs: Some(std::sync::Arc::new(wcs)), + boundary_exterior: None, draw_depth: 0.0, } } @@ -627,6 +797,7 @@ impl CadCommand for BoundaryCommand { scale: 1.0, world_origin: origin, boundary_wcs: Some(std::sync::Arc::new(wcs)), + boundary_exterior: None, draw_depth: 0.0, }; CmdResult::CommitHatch(model) diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index d6b4d4bc9..3c1a5438b 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -10,6 +10,31 @@ use cadkernel::geom2d::{bounded_faces, Line, Tolerance}; /// enough not to weld genuinely separate corners together. const WELD_TOLERANCE: f64 = 1.0e-6; +fn wire_segments(wire: &WireModel) -> Vec { + let mut segments = Vec::new(); + let mut previous: Option<[f64; 2]> = None; + + for (index, high) in wire.points.iter().copied().enumerate() { + if !high[0].is_finite() || !high[1].is_finite() { + previous = None; + continue; + } + let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); + let current = [ + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + ]; + if let Some(start) = previous { + let (dx, dy) = (current[0] - start[0], current[1] - start[1]); + if dx.hypot(dy) > WELD_TOLERANCE { + segments.push(Line { start, end: current }); + } + } + previous = Some(current); + } + segments +} + impl Scene { /// Build closed planar regions from the visible wire geometry. /// @@ -30,41 +55,26 @@ impl Scene { let mut segments = Vec::::new(); for wire in self.entity_wires().iter() { - let mut previous: Option<[f64; 2]> = None; - - for (index, high) in wire.points.iter().copied().enumerate() { - // NaNs delimit independent segments inside some WireModels, - // notably polylines stored as A-B | B-C | C-D. - if !high[0].is_finite() || !high[1].is_finite() { - previous = None; - continue; - } - - // The wire carries its coordinates as a double-single pair, so - // both halves are needed to recover the f64 the tessellation - // produced. Reading only the high half would put every vertex - // of a survey-coordinate drawing on a grid coarser than the - // weld tolerance. - let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); - let current = [ - high[0] as f64 + low[0] as f64, - high[1] as f64 + low[1] as f64, - ]; - - if let Some(start) = previous { - let (dx, dy) = (current[0] - start[0], current[1] - start[1]); - if dx.hypot(dy) > WELD_TOLERANCE { - segments.push(Line { - start, - end: current, - }); - } - } - - previous = Some(current); - } + segments.extend(wire_segments(wire)); } bounded_faces(&segments, Tolerance::new(WELD_TOLERANCE)) } + + /// Tessellated boundary segments grouped by their selectable entity. + pub fn hatch_boundary_sources( + &self, + ) -> rustc_hash::FxHashMap> { + let mut sources = rustc_hash::FxHashMap::default(); + for wire in self.entity_wires().iter() { + let Some(handle) = Self::handle_from_wire_name(&wire.name) else { + continue; + }; + sources + .entry(handle) + .or_insert_with(Vec::new) + .extend(wire_segments(wire)); + } + sources + } } diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 130bd0f80..7bc02908c 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1362,6 +1362,7 @@ impl Scene { render_instance, boundary: Arc::new(boundary), boundary_wcs: None, + boundary_exterior: None, pattern: model::hatch_model::HatchPattern::Solid, name: "WIPEOUT_FILL".into(), color, @@ -1466,6 +1467,7 @@ impl Scene { } let mut boundary: Vec<[f64; 2]> = Vec::new(); + let mut boundary_exterior = Vec::new(); for path in &dxf.paths { // Skip TEXTBOX boundary paths (flag bit 3). These are text @@ -1499,6 +1501,7 @@ impl Scene { boundary.truncate(before_path); continue; } + boundary_exterior.push(path.flags.is_external() || path.flags.is_outermost()); if boundary.len() >= path_start + 3 { let first = boundary[path_start]; let last = *boundary.last().unwrap(); @@ -1511,6 +1514,11 @@ impl Scene { if boundary.is_empty() { return None; } + if !boundary_exterior.iter().any(|role| *role) { + if let Some(first) = boundary_exterior.first_mut() { + *first = true; + } + } // The batched hatch renderer keeps boundaries in a GPU storage // buffer (no fixed length), so a hatch with many island loops must // retain *every* loop or even-odd island detection breaks. The old @@ -1705,6 +1713,7 @@ impl Scene { render_instance: None, boundary: std::sync::Arc::new(boundary_f32), boundary_wcs: None, + boundary_exterior: Some(std::sync::Arc::new(boundary_exterior)), pattern, name, // A gradient starts from its first stop; other fills use the @@ -2019,6 +2028,7 @@ impl Scene { render_instance: None, boundary: std::sync::Arc::new(boundary), boundary_wcs: None, + boundary_exterior: None, pattern: model::hatch_model::HatchPattern::Solid, name: "SOLID".into(), color, @@ -2037,64 +2047,74 @@ impl Scene { model.pattern, crate::scene::model::hatch_model::HatchPattern::Solid ); - // Prefer the command-supplied exact f64 boundary so a typed vertex is - // persisted without f32 quantization (issue #311). Falling back to the - // render-side `boundary`, the points arrive in local render space - // (world_offset already subtracted), so add `world_origin` back — - // otherwise the boundary wire lands `world_offset` away from the fill. - // Build one DXF boundary path per ring. The command layer separates - // the outer boundary from its holes with NaN sentinels in - // `boundary_wcs`; split on those so nested hatches (e.g. a small - // rectangle inside a big one) persist with real holes instead of a - // single self-intersecting polyline. Only the FIRST ring carries the - // external / outermost flags — every later ring is a hole and must not - // be flagged external, or DXF/DWG consumers treat the inner loop as - // another outer island rather than a hole. - if let Some(wcs) = &model.boundary_wcs { - let mut ring: Vec = Vec::new(); - let mut first = true; - let mut push_ring = |r: &mut Vec, is_outer: bool| { - if !r.is_empty() { - let edge = PolylineEdge::new(std::mem::take(r), true); - let mut path = if is_outer { - let mut p = BoundaryPath::external(); - p.flags = acadrust::entities::hatch::BoundaryPathFlags::from_bits( - p.flags.bits() - | acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(), - ); - p - } else { - BoundaryPath::new() - }; - path.add_edge(BoundaryEdge::Polyline(edge)); - dxf.paths.push(path); - } - }; - for &[x, y] in wcs.iter() { - if x.is_finite() && y.is_finite() { - ring.push(Vector2::new(x, y)); - } else { - let is_outer = first; - first = false; - push_ring(&mut ring, is_outer); - } - } - push_ring(&mut ring, first); - } - if dxf.paths.is_empty() { - let wx = model.world_origin[0]; - let wy = model.world_origin[1]; - let verts: Vec = model + // Prefer exact command geometry; otherwise reconstruct every ring from + // the render offsets without dropping its separators. + // Build one DXF path per NaN-separated ring and retain each outer/hole role. + let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() { + let [wx, wy] = model.world_origin; + model .boundary .iter() - .filter(|v| v[0].is_finite() && v[1].is_finite()) - .map(|&[x, y]| Vector2::new(x as f64 + wx, y as f64 + wy)) - .collect(); - let edge = PolylineEdge::new(verts, true); - let mut path = BoundaryPath::external(); - path.add_edge(BoundaryEdge::Polyline(edge)); - dxf.paths.push(path); + .map(|&[x, y]| { + if x.is_finite() && y.is_finite() { + [x as f64 + wx, y as f64 + wy] + } else { + [f64::NAN, f64::NAN] + } + }) + .collect() + } else { + Vec::new() + }; + let wcs = model + .boundary_wcs + .as_deref() + .map(|points| points.as_slice()) + .unwrap_or(reconstructed_wcs.as_slice()); + let mut ring: Vec = Vec::new(); + let mut first = true; + let mut ring_index = 0usize; + let mut push_ring = |r: &mut Vec, is_outer: bool| { + if !r.is_empty() { + let edge = PolylineEdge::new(std::mem::take(r), true); + let mut path = if is_outer { + let mut p = BoundaryPath::external(); + p.flags = acadrust::entities::hatch::BoundaryPathFlags::from_bits( + p.flags.bits() + | acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(), + ); + p + } else { + BoundaryPath::new() + }; + path.add_edge(BoundaryEdge::Polyline(edge)); + dxf.paths.push(path); + } + }; + for &[x, y] in wcs { + if x.is_finite() && y.is_finite() { + ring.push(Vector2::new(x, y)); + } else { + let is_outer = model + .boundary_exterior + .as_deref() + .and_then(|roles| roles.get(ring_index)) + .copied() + .unwrap_or(first); + if !ring.is_empty() { + ring_index += 1; + } + first = false; + push_ring(&mut ring, is_outer); + } } + let is_outer = model + .boundary_exterior + .as_deref() + .and_then(|roles| roles.get(ring_index)) + .copied() + .unwrap_or(first); + push_ring(&mut ring, is_outer); if let Some(entry) = crate::scene::model::hatch_patterns::find(&model.name) { dxf.pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 85245b5e2..205794af1 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3181,6 +3181,7 @@ impl Scene { world_origin: [0.0, 0.0], boundary: Arc::new(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]), boundary_wcs: None, + boundary_exterior: None, pattern: crate::scene::model::hatch_model::HatchPattern::Solid, name: "SOLID".to_string(), color: self.paper_bg_color, diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 492ce02cd..4572d2acc 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -180,6 +180,8 @@ pub struct HatchModel { /// rebuilt from a DXF entity — `add_hatch` then reconstructs the persisted /// vertices from `boundary` + `world_origin` instead. pub boundary_wcs: Option>>, + /// Per-ring DXF role, aligned with the NaN-separated boundary paths. + pub boundary_exterior: Option>>, /// Fill pattern. pub pattern: HatchPattern, /// Catalog name for this pattern (e.g. "ANSI31", "SOLID", "LINEAR"). diff --git a/src/scene/preview.rs b/src/scene/preview.rs index a6b42d9ea..08b63d926 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -29,6 +29,10 @@ impl Scene { self.preview_hatches = std::sync::Arc::new(models); } + pub fn set_command_preview_hatches(&mut self, models: Vec) { + self.preview_hatches = std::sync::Arc::new(models); + } + pub fn set_area_preview_regions(&mut self, regions: &[AreaPreviewRegion]) { let mut models = Vec::new(); for region in regions { @@ -154,6 +158,7 @@ impl Scene { world_origin: origin, boundary: std::sync::Arc::new(boundary), boundary_wcs: None, + boundary_exterior: None, pattern: HatchPattern::Solid, name: "AREA_PREVIEW".into(), color: [0.0; 4], diff --git a/src/ui/properties.rs b/src/ui/properties.rs index fda11b9cc..55fd780af 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -130,6 +130,7 @@ impl canvas::Program for HatchPatternPreview { [pad, bounds.height - pad], ]), boundary_wcs: None, + boundary_exterior: None, pattern: self.pattern.clone(), name: String::new(), color: [1.0; 4],