From 1d2f0a09c150f06e8a3bf3302f28a8e59e9bb06f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 13 Aug 2026 23:47:50 +0300 Subject: [PATCH] feat(hatch): add associative boundaries --- src/app/command_driver.rs | 82 +++++++++- src/app/commands/draw.rs | 12 +- src/command.rs | 14 ++ src/modules/draw/draw/hatch.rs | 118 ++++++++++++--- src/scene/boundary.rs | 265 ++++++++++++++++++++++++++++++++- src/scene/entity.rs | 99 +++++++++--- src/scene/mod.rs | 32 +++- src/scene/model/hatch_model.rs | 2 + src/scene/preview.rs | 1 + src/ui/properties.rs | 1 + 10 files changed, 577 insertions(+), 49 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 8bbf0e03b..8b6b2764e 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -793,6 +793,24 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::CommitEntitiesAndExit(entities) => { + let label = self.history_label_from_active_cmd(i, "ENTITY"); + let delta_safe = entities + .iter() + .all(|entity| self.delta_add_safe(i, entity)); + let pending = self.begin_undo(i, label, entities.len(), delta_safe); + for entity in entities { + self.commit_entity(entity); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } CmdResult::MviewCreate { viewport, preserve_view, @@ -1271,7 +1289,67 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "HATCH"); let pending = self.begin_undo(i, label, 1, true); let layer = self.tabs[i].active_layer.clone(); - let new_handle = self.tabs[i].scene.add_hatch(hatch, Some(&layer)); + let new_handle = self.tabs[i].scene.add_hatch(hatch, Some(&layer), None); + if !new_handle.is_null() { + self.tabs[i].scene.select_entity(new_handle, true); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::CommitStyledHatch { + hatch, + color, + transparency, + } => { + let label = self.history_label_from_active_cmd(i, "HATCH"); + let pending = self.begin_undo(i, label, 1, true); + let layer = self.tabs[i].active_layer.clone(); + let new_handle = self.tabs[i].scene.add_hatch( + hatch, + Some(&layer), + Some((color, transparency)), + ); + if !new_handle.is_null() { + self.tabs[i].scene.select_entity(new_handle, true); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.refresh_properties(); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } + CmdResult::CommitHatchWithBoundaries { + mut hatch, + boundaries, + entity_style, + } => { + let label = self.history_label_from_active_cmd(i, "HATCH"); + let pending = self.begin_undo(i, label, boundaries.len() + 1, true); + let mut sources = Vec::with_capacity(boundaries.len()); + for boundary in boundaries { + let handles = self + .commit_entity_handle(boundary) + .into_iter() + .collect::>(); + sources.push(handles); + } + hatch.boundary_sources = Some(std::sync::Arc::new(sources)); + let layer = self.tabs[i].active_layer.clone(); + let new_handle = + self.tabs[i] + .scene + .add_hatch(hatch, Some(&layer), entity_style); if !new_handle.is_null() { self.tabs[i].scene.select_entity(new_handle, true); } @@ -3097,7 +3175,7 @@ impl OpenCADStudio { // Remove old hatch (entity + GPU model) self.tabs[i].scene.erase_entities(&[handle]); // Re-add with updated model - self.tabs[i].scene.add_hatch(model, Some(&layer)); + self.tabs[i].scene.add_hatch(model, Some(&layer), None); self.tabs[i].dirty = true; self.command_line.push_output(crate::t!("HATCHEDIT: hatch updated.").as_ref()); } else { diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 4fd5a3e26..f5dc9e6c7 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -625,8 +625,16 @@ impl OpenCADStudio { .selected_entities() .into_iter() .map(|(handle, _)| handle) - .collect(); - let new_cmd = HatchCommand::new(outlines, boundary_sources, selected); + .collect::>(); + let inherited = selected + .iter() + .find_map(|handle| { + let model = self.tabs[i].scene.hatches.get(handle)?.clone(); + let common = self.tabs[i].scene.document.get_entity(*handle)?.common(); + Some((model, common.color.clone(), common.transparency)) + }); + let new_cmd = + HatchCommand::new(outlines, boundary_sources, selected, inherited); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.refresh_area_preview(i); diff --git a/src/command.rs b/src/command.rs index 5c135ff91..d2473dc47 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1151,6 +1151,8 @@ pub enum CmdResult { CommitEntity(EntityType), /// Commit several acadrust entities in one undo step; keep the command active. CommitEntities(Vec), + /// Commit several entities in one undo step and end the command. + CommitEntitiesAndExit(Vec), /// Commit an acadrust entity to the document and end the command. CommitAndExit(EntityType), /// Commit a Model-tab 3D solid: the acadrust entity (for selection / @@ -1183,6 +1185,18 @@ pub enum CmdResult { CopySelected(Vec, EntityTransform), /// Commit a hatch fill (stored in Scene::hatches, not the DXF document). CommitHatch(HatchModel), + /// Commit a hatch with the selected hatch's entity colour and transparency. + CommitStyledHatch { + hatch: HatchModel, + color: acadrust::types::Color, + transparency: acadrust::types::Transparency, + }, + /// Commit a hatch and retain each boundary ring as an entity. + CommitHatchWithBoundaries { + hatch: HatchModel, + boundaries: Vec, + entity_style: Option<(acadrust::types::Color, acadrust::types::Transparency)>, + }, /// Copy selected entities with multiple transforms (e.g. rectangular array); end command. BatchCopy(Vec, Vec), /// Erase `handle` and replace with new entities; command stays active. diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index e8cf5ce96..614fee11b 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -211,6 +211,12 @@ pub struct HatchCommand { mode: HatchMode, manual_pts: Vec, missed: bool, + retain_boundaries: bool, + inherited: Option<( + HatchModel, + acadrust::types::Color, + acadrust::types::Transparency, + )>, } impl HatchCommand { @@ -218,7 +224,16 @@ impl HatchCommand { outlines: Vec>, boundary_sources: rustc_hash::FxHashMap>, selected_objects: Vec, + inherited: Option<( + HatchModel, + acadrust::types::Color, + acadrust::types::Transparency, + )>, ) -> Self { + let selected_objects: Vec<_> = selected_objects + .into_iter() + .filter(|handle| boundary_sources.contains_key(handle)) + .collect(); let has_selection = !selected_objects.is_empty(); let mut command = Self { outlines, @@ -233,6 +248,8 @@ impl HatchCommand { }, manual_pts: vec![], missed: false, + retain_boundaries: false, + inherited, }; command.set_object_selection(selected_objects); command @@ -289,6 +306,51 @@ impl HatchCommand { .into_iter() .map(|depth| depth % 2 == 0) .collect(); + let boundary_sources = rings + .iter() + .map(|ring| crate::scene::ring_source_handles(ring, &self.boundary_sources)) + .collect(); + if let Some((source, _, _)) = &self.inherited { + let mut pattern = source.pattern.clone(); + if let HatchPattern::Pattern(families) = &mut pattern { + let scale = if source.scale.abs() > 1.0e-6 { + source.scale + } else { + 1.0 + }; + let (sin, cos) = source.angle_offset.sin_cos(); + for family in families { + let base_x = source.world_origin[0] + + (family.x0 as f64 * cos as f64 + - family.y0 as f64 * sin as f64) + * scale as f64; + let base_y = source.world_origin[1] + + (family.x0 as f64 * sin as f64 + + family.y0 as f64 * cos as f64) + * scale as f64; + let dx = base_x - origin[0]; + let dy = base_y - origin[1]; + family.x0 = ((dx * cos as f64 + dy * sin as f64) / scale as f64) as f32; + family.y0 = ((-dx * sin as f64 + dy * cos as f64) / scale as f64) as f32; + } + } + return HatchModel { + render_instance: None, + boundary: std::sync::Arc::new(rel), + pattern, + name: source.name.clone(), + color: source.color, + aci: source.aci, + line_weight_px: source.line_weight_px, + angle_offset: source.angle_offset, + scale: source.scale, + world_origin: origin, + boundary_wcs: Some(std::sync::Arc::new(wcs)), + boundary_exterior: Some(std::sync::Arc::new(exterior)), + boundary_sources: Some(std::sync::Arc::new(boundary_sources)), + draw_depth: source.draw_depth, + }; + } // Default: ANSI31 from catalog; fallback to a single 45° family. let pat_name = "ANSI31"; let families = crate::scene::model::hatch_patterns::find(pat_name) @@ -324,6 +386,7 @@ impl HatchCommand { world_origin: origin, boundary_wcs: Some(std::sync::Arc::new(wcs)), boundary_exterior: Some(std::sync::Arc::new(exterior)), + boundary_sources: Some(std::sync::Arc::new(boundary_sources)), draw_depth: 0.0, } } @@ -381,6 +444,14 @@ impl CadCommand for HatchCommand { let mut options = vec![ CmdOption::new(t!("Select objects").as_ref(), "O"), CmdOption::new(t!("Draw manually").as_ref(), "S"), + CmdOption::new( + if self.retain_boundaries { + "Keep boundaries: on" + } else { + "Keep boundaries: off" + }, + "B", + ), ]; if self.region_count() > 0 { options.push(CmdOption::enter(t!("Accept").as_ref())); @@ -391,6 +462,14 @@ impl CadCommand for HatchCommand { let mut options = vec![ CmdOption::new(t!("Pick internal points").as_ref(), "I"), CmdOption::new(t!("Draw manually").as_ref(), "S"), + CmdOption::new( + if self.retain_boundaries { + "Keep boundaries: on" + } else { + "Keep boundaries: off" + }, + "B", + ), ]; if self.region_count() > 0 { options.push(CmdOption::enter(t!("Accept").as_ref())); @@ -441,6 +520,21 @@ impl CadCommand for HatchCommand { let rings = self.combined_rings(); if rings.is_empty() { CmdResult::Cancel + } else if self.retain_boundaries { + CmdResult::CommitHatchWithBoundaries { + hatch: self.make_hatch(rings.clone()), + boundaries: crate::scene::boundary_entities(&rings), + entity_style: self + .inherited + .as_ref() + .map(|(_, color, transparency)| (color.clone(), *transparency)), + } + } else if let Some((_, color, transparency)) = &self.inherited { + CmdResult::CommitStyledHatch { + hatch: self.make_hatch(rings), + color: color.clone(), + transparency: *transparency, + } } else { CmdResult::CommitHatch(self.make_hatch(rings)) } @@ -510,6 +604,10 @@ impl CadCommand for HatchCommand { self.missed = false; Some(CmdResult::NeedPoint) } + "B" | "BOUNDARY" | "BOUNDARIES" => { + self.retain_boundaries = !self.retain_boundaries; + Some(CmdResult::NeedPoint) + } _ => None, } } @@ -586,6 +684,7 @@ impl GradientCommand { world_origin: origin, boundary_wcs: Some(std::sync::Arc::new(wcs)), boundary_exterior: None, + boundary_sources: None, draw_depth: 0.0, } } @@ -783,24 +882,7 @@ impl CadCommand for BoundaryCommand { match resolve_hatch_rings(&self.outlines, xy) { Some(rings) => { self.missed = false; - // Store as a Hatch entity (solid fill) so it is selectable. - let (rel, origin, wcs) = pack_rings(&rings); - let model = HatchModel { - render_instance: None, - boundary: std::sync::Arc::new(rel), - 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, - world_origin: origin, - boundary_wcs: Some(std::sync::Arc::new(wcs)), - boundary_exterior: None, - draw_depth: 0.0, - }; - CmdResult::CommitHatch(model) + CmdResult::CommitEntitiesAndExit(crate::scene::boundary_entities(&rings)) } None => { self.missed = true; diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index 3c1a5438b..1e0c12ce0 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -1,6 +1,9 @@ use super::*; -use cadkernel::geom2d::{bounded_faces, Line, Tolerance}; +use cadkernel::geom2d::{ + bounded_faces, contains, distance_to, segment_crossing, triangulate, Curve, Line, + SegmentCrossing, Tolerance, +}; /// How far apart two points may be and still be taken for the same one. /// @@ -35,7 +38,267 @@ fn wire_segments(wire: &WireModel) -> Vec { segments } +fn ring_seed(model: &HatchModel, wanted: usize) -> Option<[f64; 2]> { + let mut ring = Vec::new(); + let mut index = 0usize; + for &[x, y] in model.boundary.iter() { + if x.is_finite() && y.is_finite() { + if index == wanted { + ring.push([ + model.world_origin[0] + x as f64, + model.world_origin[1] + y as f64, + ]); + } + } else if index == wanted { + break; + } else { + index += 1; + } + } + if ring.len() < 3 { + return None; + } + let (points, triangles) = triangulate(&ring, &[]); + if let Some(triangle) = triangles.first() { + let [a, b, c] = triangle.map(|vertex| points[vertex]); + return Some([ + (a[0] + b[0] + c[0]) / 3.0, + (a[1] + b[1] + c[1]) / 3.0, + ]); + } + Some([ + ring.iter().map(|point| point[0]).sum::() / ring.len() as f64, + ring.iter().map(|point| point[1]).sum::() / ring.len() as f64, + ]) +} + +fn face_curves(face: &[[f64; 2]]) -> Vec { + face.iter() + .copied() + .zip(face.iter().copied().cycle().skip(1)) + .take(face.len()) + .map(|(start, end)| Curve::Line(Line { start, end })) + .collect() +} + +fn matching_face(faces: &[Vec<[f64; 2]>], seed: Option<[f64; 2]>) -> Option<&Vec<[f64; 2]>> { + if faces.len() == 1 { + return faces.first(); + } + let seed = seed?; + let tolerance = Tolerance::new(WELD_TOLERANCE); + if let Some(face) = faces.iter().find(|face| { + let curves = face_curves(face); + contains(&curves, seed, tolerance) + }) { + return Some(face); + } + faces.iter().min_by(|a, b| { + let nearest = |face: &Vec<[f64; 2]>| { + face_curves(face) + .iter() + .map(|curve| distance_to(curve, seed)) + .fold(f64::INFINITY, f64::min) + }; + nearest(a).total_cmp(&nearest(b)) + }) +} + +pub(crate) fn ring_source_handles( + ring: &[[f64; 2]], + sources: &rustc_hash::FxHashMap>, +) -> Vec { + let mut handles = rustc_hash::FxHashSet::default(); + for (&start, &end) in ring + .iter() + .zip(ring.iter().cycle().skip(1)) + .take(ring.len()) + { + let edge = Line { start, end }; + let edge_length = (end[0] - start[0]).hypot(end[1] - start[1]); + for (&handle, lines) in sources { + if lines.iter().any(|line| { + matches!( + segment_crossing(edge, *line, Tolerance::new(WELD_TOLERANCE)), + SegmentCrossing::Overlap { a, .. } + if (a[1] - a[0]).abs() * edge_length > WELD_TOLERANCE + ) + }) { + handles.insert(handle); + } + } + } + let mut handles: Vec<_> = handles.into_iter().collect(); + handles.sort_by_key(|handle| handle.value()); + handles +} + +pub(crate) fn boundary_entities(rings: &[Vec<[f64; 2]>]) -> Vec { + rings + .iter() + .filter_map(|ring| { + let mut points = Vec::with_capacity(ring.len()); + for &point in ring { + if point.iter().all(|value| value.is_finite()) + && points.last() != Some(&point) + { + points.push(point); + } + } + if points.len() > 1 && points.first() == points.last() { + points.pop(); + } + if points.len() < 3 { + return None; + } + let mut polyline = acadrust::entities::LwPolyline::new(); + polyline.is_closed = true; + polyline.vertices = points + .into_iter() + .map(|[x, y]| { + acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(x, y)) + }) + .collect(); + Some(acadrust::EntityType::LwPolyline(polyline)) + }) + .collect() +} + impl Scene { + fn associative_boundary_segments(&self, handles: &[Handle]) -> Vec { + self.wire_models_for(handles) + .iter() + .flat_map(wire_segments) + .collect() + } + + fn associative_hatch_dependents( + &self, + changed: &rustc_hash::FxHashSet, + ) -> Vec { + if self.associative_hatch_source_cache.borrow().is_none() { + let mut index: HashMap> = HashMap::default(); + for entity in self.document.entities() { + let EntityType::Hatch(hatch) = entity else { + continue; + }; + if !hatch.is_associative { + continue; + } + for source in hatch + .paths + .iter() + .flat_map(|path| path.boundary_handles.iter().copied()) + { + let dependents = index.entry(source).or_default(); + if !dependents.contains(&hatch.common.handle) { + dependents.push(hatch.common.handle); + } + } + } + *self.associative_hatch_source_cache.borrow_mut() = Some(index); + } + let cache = self.associative_hatch_source_cache.borrow(); + let index = cache.as_ref().expect("associative hatch index"); + let mut handles = rustc_hash::FxHashSet::default(); + for source in changed { + if let Some(dependents) = index.get(source) { + handles.extend(dependents.iter().copied()); + } + } + handles.into_iter().collect() + } + + pub(crate) fn refresh_associative_hatches( + &mut self, + changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + if changes.is_empty() { + return Vec::new(); + } + let changed: rustc_hash::FxHashSet<_> = + changes.iter().map(|(handle, _)| *handle).collect(); + let candidates: Vec<_> = self + .associative_hatch_dependents(&changed) + .into_iter() + .filter_map(|handle| { + let EntityType::Hatch(hatch) = self.document.get_entity(handle)? else { + return None; + }; + Some({ + let seeds = self + .hatches + .get(&hatch.common.handle) + .map(|model| { + (0..hatch.paths.len()) + .map(|index| ring_seed(model, index)) + .collect::>() + }) + .unwrap_or_default(); + (handle, hatch.clone(), seeds) + }) + }) + .collect(); + + let mut refreshed = Vec::new(); + for (handle, mut hatch, seeds) in candidates { + let normal = hatch.normal; + if normal.x.abs() > 1.0e-8 || normal.y.abs() > 1.0e-8 { + continue; + } + let mut modified = false; + let mut association_changed = false; + for (index, path) in hatch.paths.iter_mut().enumerate() { + if !path + .boundary_handles + .iter() + .any(|source| changed.contains(source)) + { + continue; + } + let old_count = path.boundary_handles.len(); + path.boundary_handles + .retain(|source| self.document.get_entity(*source).is_some()); + association_changed |= path.boundary_handles.len() != old_count; + modified |= association_changed; + let segments = self.associative_boundary_segments(&path.boundary_handles); + let faces = bounded_faces(&segments, Tolerance::new(WELD_TOLERANCE)); + let Some(face) = matching_face(&faces, seeds.get(index).copied().flatten()) else { + continue; + }; + path.edges = vec![acadrust::entities::hatch::BoundaryEdge::Polyline( + acadrust::entities::hatch::PolylineEdge::new( + face.iter() + .map(|point| acadrust::types::Vector2::new(point[0], point[1])) + .collect(), + true, + ), + )]; + modified = true; + } + hatch.is_associative = hatch + .paths + .iter() + .any(|path| !path.boundary_handles.is_empty()); + if !modified { + continue; + } + if association_changed { + self.associative_hatch_source_cache.borrow_mut().take(); + } + if self.is_recording_undo() { + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } + if let Some(slot) = self.document.get_entity_mut(handle) { + *slot = EntityType::Hatch(hatch); + self.refresh_fill_model(handle); + refreshed.push((handle, ChangeKind::Modified)); + } + } + refreshed + } + /// Build closed planar regions from the visible wire geometry. /// /// Unlike `closed_outlines()`, the source entities do not need to be closed diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 454784885..b19b57807 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1363,6 +1363,7 @@ impl Scene { boundary: Arc::new(boundary), boundary_wcs: None, boundary_exterior: None, + boundary_sources: None, pattern: model::hatch_model::HatchPattern::Solid, name: "WIPEOUT_FILL".into(), color, @@ -1714,6 +1715,13 @@ impl Scene { boundary: std::sync::Arc::new(boundary_f32), boundary_wcs: None, boundary_exterior: Some(std::sync::Arc::new(boundary_exterior)), + boundary_sources: Some(std::sync::Arc::new( + dxf.paths + .iter() + .filter(|path| path.flags.bits() & 8 == 0) + .map(|path| path.boundary_handles.clone()) + .collect(), + )), pattern, name, // A gradient starts from its first stop; other fills use the @@ -2029,6 +2037,7 @@ impl Scene { boundary: std::sync::Arc::new(boundary), boundary_wcs: None, boundary_exterior: None, + boundary_sources: None, pattern: model::hatch_model::HatchPattern::Solid, name: "SOLID".into(), color, @@ -2041,7 +2050,12 @@ impl Scene { } } - pub fn add_hatch(&mut self, model: HatchModel, layer: Option<&str>) -> Handle { + pub fn add_hatch( + &mut self, + model: HatchModel, + layer: Option<&str>, + entity_style: Option<(acadrust::types::Color, acadrust::types::Transparency)>, + ) -> Handle { let mut dxf = DxfHatch::new(); dxf.is_solid = matches!( model.pattern, @@ -2074,7 +2088,7 @@ impl Scene { 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| { + let mut push_ring = |r: &mut Vec, is_outer: bool, index: usize| { if !r.is_empty() { let edge = PolylineEdge::new(std::mem::take(r), true); let mut path = if is_outer { @@ -2088,6 +2102,17 @@ impl Scene { BoundaryPath::new() }; path.add_edge(BoundaryEdge::Polyline(edge)); + if let Some(handles) = model + .boundary_sources + .as_deref() + .and_then(|sources| sources.get(index)) + { + for &handle in handles { + if handle.is_valid() { + path.add_boundary_handle(handle); + } + } + } dxf.paths.push(path); } }; @@ -2101,11 +2126,11 @@ impl Scene { .and_then(|roles| roles.get(ring_index)) .copied() .unwrap_or(first); + first = false; if !ring.is_empty() { + push_ring(&mut ring, is_outer, ring_index); ring_index += 1; } - first = false; - push_ring(&mut ring, is_outer); } } let is_outer = model @@ -2114,29 +2139,56 @@ impl Scene { .and_then(|roles| roles.get(ring_index)) .copied() .unwrap_or(first); - push_ring(&mut ring, is_outer); + push_ring(&mut ring, is_outer, ring_index); + dxf.is_associative = dxf + .paths + .iter() + .any(|path| !path.boundary_handles.is_empty()); let pattern_scale = if model.scale.abs() > 1e-6 { model.scale as f64 } else { 1.0 }; - if let Some(entry) = crate::scene::model::hatch_patterns::find(&model.name) { - dxf.pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); - if let Some(base) = dxf.pattern.lines.first().map(|line| line.base_point) { - let angle = model.angle_offset as f64; - let (sin, cos) = angle.sin_cos(); - let target_x = model.world_origin[0] - + (base.x * cos - base.y * sin) * pattern_scale; - let target_y = model.world_origin[1] - + (base.x * sin + base.y * cos) * pattern_scale; - crate::entities::hatch::scale_pattern_geometry(&mut dxf.pattern, pattern_scale); - crate::entities::hatch::rotate_pattern_geometry(&mut dxf.pattern, angle); - crate::entities::hatch::translate_pattern_geometry( - &mut dxf.pattern, - target_x - base.x, - target_y - base.y, - ); + if let crate::scene::model::hatch_model::HatchPattern::Pattern(families) = &model.pattern { + let mut pattern = acadrust::entities::HatchPattern::new(&model.name); + let rotation = model.angle_offset as f64; + let (rotation_sin, rotation_cos) = rotation.sin_cos(); + for family in families { + let family_angle = (family.angle_deg as f64).to_radians(); + let angle = family_angle + rotation; + let (family_sin, family_cos) = family_angle.sin_cos(); + let local_offset_x = family.dx as f64 * family_cos + - family.dy as f64 * family_sin; + let local_offset_y = family.dx as f64 * family_sin + + family.dy as f64 * family_cos; + let base_x = family.x0 as f64 * pattern_scale; + let base_y = family.y0 as f64 * pattern_scale; + pattern.lines.push(acadrust::entities::HatchPatternLine { + angle, + base_point: Vector2::new( + model.world_origin[0] + + base_x * rotation_cos + - base_y * rotation_sin, + model.world_origin[1] + + base_x * rotation_sin + + base_y * rotation_cos, + ), + offset: Vector2::new( + (local_offset_x * rotation_cos + - local_offset_y * rotation_sin) + * pattern_scale, + (local_offset_x * rotation_sin + + local_offset_y * rotation_cos) + * pattern_scale, + ), + dash_lengths: family + .dashes + .iter() + .map(|dash| *dash as f64 * pattern_scale) + .collect(), + }); } + dxf.pattern = pattern; } dxf.pattern_angle = model.angle_offset as f64; dxf.pattern_scale = pattern_scale; @@ -2195,6 +2247,10 @@ impl Scene { if let Some(layer) = layer { entity.as_entity_mut().set_layer(layer.to_string()); } + if let Some((color, transparency)) = entity_style { + entity.common_mut().color = color; + entity.common_mut().transparency = transparency; + } self.add_entity(entity) } @@ -2207,6 +2263,7 @@ impl Scene { self.preview_text = vec![]; self.current_layout = "Model".to_string(); self.hatches = HashMap::default(); + self.associative_hatch_source_cache.borrow_mut().take(); self.images = HashMap::default(); self.meshes = HashMap::default(); self.block_meshes = HashMap::default(); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 205794af1..406ca0738 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -33,6 +33,8 @@ mod project; mod scene_markers; mod selection; +pub(crate) use boundary::{boundary_entities, ring_source_handles}; + // Parallel tessellation free functions live in `convert::tess` (alongside the // other tessellation code); re-exported here so this root and sibling topic // modules (each does `use super::*`) keep referencing them unqualified. @@ -1677,6 +1679,9 @@ pub struct Scene { /// `geometry_epoch`: a layer colour toggle can reuse the index, invalidate /// only its dependants, and avoid a whole-document scan on every toggle. dependency_index_cache: RefCell>, + /// Boundary source → associative hatch handles. Source edits reuse this + /// index instead of scanning the document during every drag step. + associative_hatch_source_cache: RefCell>>>, /// Tessellated block definitions in block-local coords, keyed by render /// background and block epoch. Model and Paper adapt black/white colours /// differently; retaining both variants prevents a full block rebuild on @@ -1893,6 +1898,7 @@ impl Scene { model_extents_cache: RefCell::new(None), entity_block_map_cache: RefCell::new(None), dependency_index_cache: RefCell::new(None), + associative_hatch_source_cache: RefCell::new(None), block_defn_cache: RefCell::new(HashMap::default()), entity_index_cache: RefCell::new(None), last_render_aspect: std::cell::Cell::new(16.0 / 9.0), @@ -2368,6 +2374,21 @@ impl Scene { } pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) { + if changes.iter().any(|(handle, kind)| { + matches!(kind, ChangeKind::Removed) + || self + .document + .get_entity(*handle) + .is_some_and(|entity| matches!(entity, EntityType::Hatch(_))) + }) { + self.associative_hatch_source_cache.borrow_mut().take(); + } + let mut changes = changes.to_vec(); + for change in self.refresh_associative_hatches(&changes) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } + } if !changes.is_empty() { // A Modified delta can change layer/style/block references as well // as coordinates. Rebuild the reverse dependency map lazily on its @@ -2386,14 +2407,14 @@ impl Scene { .is_some_and(|entity| matches!(entity, EntityType::Light(_))) }); if cached_light_changed || live_light_changed { - for (handle, _) in changes { + for &(handle, _) in &changes { let exists = self .document - .get_entity(*handle) + .get_entity(handle) .is_some_and(|entity| matches!(entity, EntityType::Light(_))); crate::entities::object_data::update_light_entity( &mut self.object_data_cache, - *handle, + handle, exists, ); } @@ -2404,7 +2425,7 @@ impl Scene { { let mut tm = self.tess_memo.borrow_mut(); let mut rm = self.resident_tess_memo.borrow_mut(); - for &(h, k) in changes { + for &(h, k) in &changes { // A changed or removed entity must re-tessellate; a fresh Add has // no memo entry yet, so there is nothing to drop. if matches!(k, ChangeKind::Modified | ChangeKind::Removed) { @@ -2413,7 +2434,7 @@ impl Scene { } } } - self.push_geometry_delta(epoch, changes.to_vec(), false); + self.push_geometry_delta(epoch, changes, false); } /// True when any changed handle belongs to an ordinary block definition @@ -3182,6 +3203,7 @@ impl Scene { boundary: Arc::new(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]]), boundary_wcs: None, boundary_exterior: None, + boundary_sources: 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 4572d2acc..0afc3e667 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -182,6 +182,8 @@ pub struct HatchModel { pub boundary_wcs: Option>>, /// Per-ring DXF role, aligned with the NaN-separated boundary paths. pub boundary_exterior: Option>>, + /// Source entity handles for each boundary ring. + pub boundary_sources: 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 08b63d926..805363b0a 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -159,6 +159,7 @@ impl Scene { boundary: std::sync::Arc::new(boundary), boundary_wcs: None, boundary_exterior: None, + boundary_sources: 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 55fd780af..77bc5f99d 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -131,6 +131,7 @@ impl canvas::Program for HatchPatternPreview { ]), boundary_wcs: None, boundary_exterior: None, + boundary_sources: None, pattern: self.pattern.clone(), name: String::new(), color: [1.0; 4],