From f83037271c05b3e884e25ec3925d188c92894e64 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:57:19 +0300 Subject: [PATCH 1/2] Complete hatch creation and editing workflow --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/command_driver.rs | 259 ++++++++++++++++--- src/app/commands/draw.rs | 25 +- src/app/commands/view.rs | 2 +- src/app/properties.rs | 19 ++ src/app/update/command.rs | 3 + src/app/update/viewport.rs | 13 +- src/command.rs | 22 ++ src/entities/hatch.rs | 250 ++++++++++++------ src/modules/draw/draw/hatch.rs | 329 +++++++++++++++++++++--- src/modules/draw/draw/hatchedit.rs | 155 +++++++++-- src/scene/boundary.rs | 399 ++++++++++++++++++++--------- src/scene/entity.rs | 89 ++++--- src/scene/mod.rs | 6 +- src/scene/model/hatch_model.rs | 7 + src/scene/preview.rs | 2 + src/ui/properties.rs | 2 + 18 files changed, 1248 insertions(+), 338 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 395e2d54..c65770e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=ebeb2ec#ebeb2ecc2d17d92c588b0fa8bbe3864b156bc71f" +source = "git+https://github.com/ramox81/cadkernel.git?rev=1c4a077#1c4a0776cbf766b0a2d3c31eb3cc1a5387450ca0" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 8cbeac47..0a703b00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "0908da7", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "ebeb2ec", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "1c4a077", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index e0bf9acc..1b488086 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1394,6 +1394,33 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::CommitHatches { + hatches, + entity_style, + } => { + let label = self.history_label_from_active_cmd(i, "HATCH"); + let pending = self.begin_undo(i, label, hatches.len(), true); + let layer = self.tabs[i].active_layer.clone(); + for hatch in hatches { + let new_handle = self.tabs[i].scene.add_hatch( + hatch, + Some(&layer), + entity_style.clone(), + ); + 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::BatchCopy(mut handles, transforms) => { handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); if handles.is_empty() { @@ -3203,42 +3230,214 @@ impl OpenCADStudio { name, scale, angle, + operation, } => { if self.reject_locked_edit(i, handle) { return Task::none(); } - if let Some(mut model) = self.tabs[i].scene.hatches.get(&handle).cloned() { - let layer = self.tabs[i] - .scene - .document - .get_entity(handle) - .map(|entity| entity.as_entity().layer().to_string()) - .unwrap_or_else(|| "0".to_string()); - // Update model fields - if !name.is_empty() { - use crate::scene::model::hatch_model::HatchPattern; - use crate::scene::model::hatch_patterns; - model.name = name.clone(); - if name.to_uppercase() == "SOLID" { - model.pattern = HatchPattern::Solid; - } else if let Some(entry) = hatch_patterns::find(&name) { - model.pattern = entry.gpu.clone(); - } - // If not found in catalog, keep existing pattern type - } - model.scale = scale; - model.angle_offset = angle; - - self.push_undo_snapshot(i, "HATCHEDIT"); - // 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), None); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("HATCHEDIT: hatch updated.").as_ref()); - } else { + if !matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Hatch(_)) + ) { self.command_line .push_error(crate::t!("HATCHEDIT: hatch entity not found.").as_ref()); + } else { + use crate::command::HatchEditOperation; + if matches!( + &operation, + HatchEditOperation::DrawOrderFront | HatchEditOperation::DrawOrderBack + ) { + let command = if matches!(&operation, HatchEditOperation::DrawOrderFront) { + "DRAWORDER FRONT" + } else { + "DRAWORDER BACK" + }; + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].active_cmd = None; + return self.dispatch_view(command, i).unwrap_or_else(Task::none); + } + self.push_undo_snapshot(i, "HATCHEDIT"); + match operation { + HatchEditOperation::Update { + origin, + disassociate, + style, + annotative, + } => { + if let Some(acadrust::EntityType::Hatch(hatch)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + if !name.is_empty() && name != hatch.pattern.name { + if let Some(entry) = + crate::scene::model::hatch_patterns::find(&name) + { + let old_origin = hatch + .pattern + .lines + .first() + .map(|line| line.base_point); + let mut pattern = crate::scene::model::hatch_patterns::build_dxf_pattern(entry); + crate::entities::hatch::scale_pattern_geometry( + &mut pattern, + scale.max(1.0e-6) as f64, + ); + crate::entities::hatch::rotate_pattern_geometry( + &mut pattern, + (angle as f64).to_radians(), + ); + if let (Some(old), Some(new)) = ( + old_origin, + pattern.lines.first().map(|line| line.base_point), + ) { + crate::entities::hatch::translate_pattern_geometry( + &mut pattern, + old.x - new.x, + old.y - new.y, + ); + } + hatch.pattern = pattern; + hatch.is_solid = matches!( + entry.gpu, + crate::scene::model::hatch_model::HatchPattern::Solid + ); + hatch.pattern_type = + acadrust::entities::HatchPatternType::Predefined; + hatch.gradient_color.enabled = false; + } + } else { + let requested_scale = scale.max(1.0e-6) as f64; + if hatch.pattern_scale > 1.0e-12 { + let factor = requested_scale / hatch.pattern_scale; + crate::entities::hatch::scale_pattern_geometry( + &mut hatch.pattern, + factor, + ); + } + let requested_angle = (angle as f64).to_radians(); + let delta = requested_angle - hatch.pattern_angle; + crate::entities::hatch::rotate_pattern_geometry( + &mut hatch.pattern, + delta, + ); + } + hatch.pattern_scale = scale.max(1.0e-6) as f64; + hatch.pattern_angle = (angle as f64).to_radians(); + if let Some((x, y)) = origin { + if let Some(current) = + hatch.pattern.lines.first().map(|line| line.base_point) + { + crate::entities::hatch::translate_pattern_geometry( + &mut hatch.pattern, + x - current.x, + y - current.y, + ); + } + } + if disassociate { + for path in &mut hatch.paths { + path.boundary_handles.clear(); + } + hatch.is_associative = false; + } + if let Some(style) = style { + hatch.style = style; + } + } + if let Some(value) = annotative { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + handle, + value, + ); + if value { + if let Some(scale_handle) = + self.tabs[i].scene.creation_annotation_scale_handle() + { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + handle, + scale_handle, + ); + } + } + } + self.tabs[i].scene.bump_entities(&[( + handle, + crate::scene::ChangeKind::Modified, + )]); + } + HatchEditOperation::AddBoundaries(handles) => { + self.tabs[i] + .scene + .edit_hatch_boundary_handles(handle, &handles, true); + } + HatchEditOperation::RemoveBoundaries(handles) => { + self.tabs[i] + .scene + .edit_hatch_boundary_handles(handle, &handles, false); + } + HatchEditOperation::RecreateBoundary => { + let model = self.tabs[i].scene.hatches.get(&handle).cloned(); + if let Some(model) = model { + let mut rings = vec![Vec::new()]; + for &[x, y] in model.boundary.iter() { + if x.is_finite() && y.is_finite() { + rings.last_mut().unwrap().push([ + model.world_origin[0] + x as f64, + model.world_origin[1] + y as f64, + ]); + } else if !rings.last().unwrap().is_empty() { + rings.push(Vec::new()); + } + } + rings.retain(|ring| ring.len() >= 3); + let entities = crate::scene::boundary_entities(&rings); + let mut handles = Vec::new(); + for entity in entities { + if let Some(boundary) = self.commit_entity_handle(entity) { + handles.push(boundary); + } + } + if let Some(acadrust::EntityType::Hatch(hatch)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + for (path, boundary) in + hatch.paths.iter_mut().zip(handles.iter().copied()) + { + path.boundary_handles = vec![boundary]; + } + hatch.is_associative = !handles.is_empty(); + } + self.tabs[i].scene.bump_entities(&[( + handle, + crate::scene::ChangeKind::Modified, + )]); + } + } + HatchEditOperation::Separate => { + let source = self.tabs[i].scene.document.get_entity(handle).cloned(); + if let Some(acadrust::EntityType::Hatch(hatch)) = source { + for path in hatch.paths.iter().cloned() { + let mut separated = hatch.clone(); + separated.common.handle = acadrust::Handle::NULL; + separated.paths = vec![path]; + separated.is_associative = separated.paths.iter().any(|path| { + !path.boundary_handles.is_empty() + }); + self.tabs[i] + .scene + .add_entity(acadrust::EntityType::Hatch(separated)); + } + self.tabs[i].scene.erase_entities(&[handle]); + } + } + HatchEditOperation::DrawOrderFront + | HatchEditOperation::DrawOrderBack => unreachable!(), + } + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("HATCHEDIT: hatch updated.").as_ref()); } self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 4f32b015..2decde5d 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -635,8 +635,10 @@ impl OpenCADStudio { "HATCH" => { use crate::modules::draw::draw::hatch::HatchCommand; - let outlines = self.tabs[i].scene.hatch_boundary_outlines(); - let boundary_sources = self.tabs[i].scene.hatch_boundary_sources(); + let boundary_sources = self.tabs[i] + .scene + .boundary_sources_on_plane(crate::command::WorkingPlane::default(), 1.0e-6); + let outlines = crate::scene::boundary_faces(&boundary_sources, 1.0e-6); let selected = self.tabs[i] .scene .selected_entities() @@ -664,11 +666,22 @@ impl OpenCADStudio { if sel.len() == 1 { let (h, _) = sel[0]; if let Some(model) = self.tabs[i].scene.hatches.get(&h).cloned() { + let annotative = self.tabs[i] + .scene + .document + .get_entity(h) + .is_some_and(|entity| { + crate::scene::annotative::is_annotative( + &self.tabs[i].scene.document, + entity, + ) + }); let cmd = HatcheditCommand::with_handle( h, model.name.clone(), model.scale, - model.angle_offset, + model.angle_offset.to_degrees(), + annotative, ); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); @@ -685,8 +698,10 @@ impl OpenCADStudio { "GRADIENT" => { use crate::modules::draw::draw::hatch::GradientCommand; - let outlines = self.tabs[i].scene.hatch_boundary_outlines(); - let boundary_sources = self.tabs[i].scene.hatch_boundary_sources(); + let boundary_sources = self.tabs[i] + .scene + .boundary_sources_on_plane(crate::command::WorkingPlane::default(), 1.0e-6); + let outlines = crate::scene::boundary_faces(&boundary_sources, 1.0e-6); let new_cmd = GradientCommand::new(outlines, boundary_sources); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 49fb811d..89ceea80 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -1,7 +1,7 @@ use super::*; impl OpenCADStudio { - pub(super) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option> { + pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option> { match cmd { "DONATE" => { self.command_line.push_info(crate::t!("Opening Patreon page...").as_ref()); diff --git a/src/app/properties.rs b/src/app/properties.rs index 94ba10d7..8cd075ba 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2057,6 +2057,25 @@ pub(super) fn aggregate_sections( for sections in all_sections { result = merge_sections(&result, §ions); } + // Unlike ordinary common properties, cumulative area is an aggregate by + // definition. Preserve the individual Area row's "varies" state while + // summing every selected hatch's actual filled area (holes subtracted). + if selected.len() > 1 + && selected + .iter() + .all(|(_, entity)| matches!(entity, acadrust::EntityType::Hatch(_))) + { + let total = selected + .iter() + .filter_map(|(_, entity)| match entity { + acadrust::EntityType::Hatch(hatch) => { + Some(crate::entities::hatch::boundary_area(hatch)) + } + _ => None, + }) + .sum::(); + set_row(&mut result, "cumulative_area", format!("{total:.4}")); + } result } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index ff561f64..3ecaec32 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1726,6 +1726,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { entry.gpu, crate::scene::model::hatch_model::HatchPattern::Solid ); + dxf.pattern_type = + acadrust::entities::HatchPatternType::Predefined; + dxf.gradient_color.enabled = false; } if let Some(model) = self.tabs[i].scene.hatches.get_mut(&handle) { model.pattern = entry.gpu.clone(); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 14b427bc..8c8c75b7 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3490,13 +3490,24 @@ impl OpenCADStudio { .unwrap_or(false) { if let Some(model) = self.tabs[i].scene.hatches.get(&handle).cloned() { + let annotative = self.tabs[i] + .scene + .document + .get_entity(handle) + .is_some_and(|entity| { + crate::scene::annotative::is_annotative( + &self.tabs[i].scene.document, + entity, + ) + }); use crate::command::CadCommand; use crate::modules::draw::draw::hatchedit::HatcheditCommand; let cmd: Box = Box::new(HatcheditCommand::with_handle( handle, model.name.clone(), model.scale, - model.angle_offset, + model.angle_offset.to_degrees(), + annotative, )); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(cmd); diff --git a/src/command.rs b/src/command.rs index 96f5e4d0..b2f68515 100644 --- a/src/command.rs +++ b/src/command.rs @@ -11,6 +11,22 @@ use crate::scene::Scene; use acadrust::{EntityType, Handle}; use glam::DVec3; +#[derive(Clone, Debug)] +pub enum HatchEditOperation { + Update { + origin: Option<(f64, f64)>, + disassociate: bool, + style: Option, + annotative: Option, + }, + RecreateBoundary, + Separate, + AddBoundaries(Vec), + RemoveBoundaries(Vec), + DrawOrderFront, + DrawOrderBack, +} + // ── Working plane ───────────────────────────────────────────────────────── /// Full-precision coordinate frame used by interactive commands. @@ -1197,6 +1213,11 @@ pub enum CmdResult { boundaries: Vec, entity_style: Option<(acadrust::types::Color, acadrust::types::Transparency)>, }, + /// Commit independently editable hatch entities for every selected region. + CommitHatches { + hatches: 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. @@ -1338,6 +1359,7 @@ pub enum CmdResult { name: String, scale: f32, angle: f32, + operation: HatchEditOperation, }, /// STRETCH crossing-window selection. The command can accumulate several /// independent crossing windows before Enter ends the selection stage. diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index 1010bdc7..e06decf4 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -24,11 +24,13 @@ use crate::scene::model::wire_model::SnapHint; /// /// Outer paths and their holes both contribute; the sign of a loop says /// which it is, so the magnitude of the sum is the region's own area. -fn boundary_area(h: &Hatch) -> f64 { - let mut area = 0.0; +pub(crate) fn boundary_area(h: &Hatch) -> f64 { + let mut path_areas = Vec::new(); + let mut rings = Vec::new(); for path in &h.paths { let mut path_area = 0.0; let mut ends: Vec<[f64; 2]> = Vec::new(); + let mut ring = Vec::new(); for edge in &path.edges { let Some(curve) = edge_curve(edge) else { continue; @@ -36,6 +38,9 @@ fn boundary_area(h: &Hatch) -> f64 { path_area += curve.enclosed_area(); ends.push(curve.point_at(0.0)); ends.push(curve.point_at(1.0)); + let tessellated = curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE); + let skip = usize::from(!ring.is_empty()); + ring.extend(tessellated.into_iter().skip(skip)); } // Edges are stored as separate pieces, so the chain has to be closed // by the chord from the last end back to the first — the same closing @@ -43,9 +48,26 @@ fn boundary_area(h: &Hatch) -> f64 { if let (Some(first), Some(last)) = (ends.first(), ends.last()) { path_area += 0.5 * (last[0] * first[1] - first[0] * last[1]); } - area += path_area.abs(); + path_areas.push(path_area.abs()); + rings.push(ring); } - area + let depths = cadkernel::geom2d::ring_nesting_depths(&rings); + path_areas + .into_iter() + .zip(depths) + .filter_map(|(area, depth)| match h.style { + acadrust::entities::HatchStyleType::Normal => { + Some(if depth % 2 == 0 { area } else { -area }) + } + acadrust::entities::HatchStyleType::Outer if depth <= 1 => { + Some(if depth == 0 { area } else { -area }) + } + acadrust::entities::HatchStyleType::Outer => None, + acadrust::entities::HatchStyleType::Ignore if depth == 0 => Some(area), + acadrust::entities::HatchStyleType::Ignore => None, + }) + .sum::() + .abs() } /// A hatch boundary edge as a kernel curve, in the hatch's own OCS. @@ -461,14 +483,20 @@ fn properties(h: &Hatch) -> Vec { // ── Hatch (pattern / solid) ──────────────────────────────────────────── - // "Type" = pattern definition source (Predefined / User Defined / Custom). - let type_row = Property { - label: t!("Type").into_owned(), - field: "pattern_type_label", - value: PropValue::Choice { - selected: pattern_type.to_string(), - options: vec!["Predefined".into(), "User Defined".into(), "Custom".into()], - }, + // Pattern-specific rows are conditional: scale belongs to catalog/custom + // definitions, while spacing and double belong to user-defined hatches. + // A solid fill does not expose inert pattern controls. + let type_row = if h.is_solid { + ro(t!("Type").as_ref(), "fill_kind", t!("Solid").into_owned()) + } else { + Property { + label: t!("Type").into_owned(), + field: "pattern_type_label", + value: PropValue::Choice { + selected: pattern_type.to_string(), + options: vec!["Predefined".into(), "User Defined".into(), "Custom".into()], + }, + } }; let pattern_name_row = Property { label: t!("Pattern name").into_owned(), @@ -492,47 +520,53 @@ fn properties(h: &Hatch) -> Vec { options: vec!["Normal".into(), "Outer".into(), "Ignore".into()], }, }; - let spacing_row = edit(t!("Spacing").as_ref(), - "spacing", - h.pattern - .lines - .first() - .map(|l| l.offset.length()) - .unwrap_or_default(), - ); - // Pattern tiling origin: the base point the pattern lines are anchored to. - let (origin_x, origin_y) = h - .pattern - .lines - .first() - .map(|l| (l.base_point.x, l.base_point.y)) - .unwrap_or((0.0, 0.0)); + let spacing_row = edit(t!("Spacing").as_ref(), "spacing", h.pattern_scale); + + let mut pattern_props = vec![type_row, pattern_name_row]; + pattern_props.push(ro(t!("Annotative").as_ref(), "annotative", String::new())); + if !h.is_solid { + pattern_props.push(edit_angle( + t!("Angle").as_ref(), + "pattern_angle", + h.pattern_angle.to_degrees(), + )); + if matches!( + h.pattern_type, + acadrust::entities::HatchPatternType::UserDefined + ) { + pattern_props.push(spacing_row); + pattern_props.push(double_row); + } else { + pattern_props.push(edit(t!("Scale").as_ref(), "pattern_scale", h.pattern_scale)); + // Project convention: these fields are relative offsets, therefore + // they read zero after every committed move instead of leaking the + // absolute base point of the first stored pattern line. + pattern_props.push(edit(t!("Origin X").as_ref(), "origin_x", 0.0)); + pattern_props.push(edit(t!("Origin Y").as_ref(), "origin_y", 0.0)); + if h.pattern.name.to_ascii_uppercase().starts_with("ISO") { + pattern_props.push(edit( + t!("ISO pen width").as_ref(), + "iso_pen_width", + h.pattern_scale, + )); + } + } + } + pattern_props.push(associative_row); + pattern_props.push(island_row); + pattern_props.push(Property { + label: t!("Background").into_owned(), + field: "bg_enabled", + value: PropValue::BoolToggle { + field: "bg_enabled", + value: bg_on, + }, + }); let mut sections = vec![ PropSection { title: t!("Pattern").into_owned(), - props: vec![ - type_row, - pattern_name_row, - ro(t!("Annotative").as_ref(), "annotative", String::new()), - edit_angle(t!("Angle").as_ref(), "pattern_angle", h.pattern_angle.to_degrees()), - edit(t!("Scale").as_ref(), "pattern_scale", h.pattern_scale), - edit(t!("Origin X").as_ref(), "origin_x", origin_x), - edit(t!("Origin Y").as_ref(), "origin_y", origin_y), - spacing_row, - ro(t!("ISO pen width").as_ref(), "iso_pen_width", String::new()), - double_row, - associative_row, - island_row, - Property { - label: t!("Background").into_owned(), - field: "bg_enabled", - value: PropValue::BoolToggle { - field: "bg_enabled", - value: bg_on, - }, - }, - ], + props: pattern_props, }, PropSection { title: t!("Geometry").into_owned(), @@ -599,12 +633,46 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { return; } "pattern_type_label" => { - h.pattern_type = match value { + let requested = match value { "Predefined" => HatchPatternType::Predefined, "User Defined" => HatchPatternType::UserDefined, "Custom" => HatchPatternType::Custom, _ => h.pattern_type, }; + if requested != h.pattern_type { + let old_origin = h.pattern.lines.first().map(|line| line.base_point); + h.pattern_type = requested; + h.is_solid = false; + match requested { + HatchPatternType::UserDefined => { + // User-defined geometry is derived from angle, spacing + // and the Double flag; stale catalog lines would make + // the renderer treat it as a prebaked definition. + h.pattern = acadrust::entities::HatchPattern::new("_USER"); + } + HatchPatternType::Predefined => { + if let Some(entry) = + crate::scene::model::hatch_patterns::find("ANSI31") + { + let mut pattern = + crate::scene::model::hatch_patterns::build_dxf_pattern(entry); + scale_pattern_geometry(&mut pattern, h.pattern_scale); + rotate_pattern_geometry(&mut pattern, h.pattern_angle); + if let (Some(old), Some(new)) = + (old_origin, pattern.lines.first().map(|line| line.base_point)) + { + translate_pattern_geometry( + &mut pattern, + old.x - new.x, + old.y - new.y, + ); + } + h.pattern = pattern; + } + } + HatchPatternType::Custom => {} + } + } return; } "style" => { @@ -674,38 +742,33 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { // Scale every pattern line's offset so the first line's spacing = v, // preserving the relative spacing between lines. "spacing" if v > 0.0 => { - let cur = h - .pattern - .lines - .first() - .map(|l| l.offset.length()) - .unwrap_or(0.0); - if cur > 1e-9 { - let s = v / cur; - for line in h.pattern.lines.iter_mut() { - line.offset.x *= s; - line.offset.y *= s; - } + h.pattern_scale = v; + if matches!( + h.pattern_type, + acadrust::entities::HatchPatternType::UserDefined + ) { + h.pattern.lines.clear(); + h.pattern.name = "_USER".to_string(); } } - // Move the pattern origin: shift every line's base point by the delta - // from the current origin (first line), preserving their relative offsets. + // Origin rows are relative offsets and return to zero after commit. "origin_x" => { - if let Some(cur) = h.pattern.lines.first().map(|l| l.base_point.x) { - let d = v - cur; - for line in h.pattern.lines.iter_mut() { - line.base_point.x += d; - } + for line in h.pattern.lines.iter_mut() { + line.base_point.x += v; } } "origin_y" => { - if let Some(cur) = h.pattern.lines.first().map(|l| l.base_point.y) { - let d = v - cur; - for line in h.pattern.lines.iter_mut() { - line.base_point.y += d; - } + for line in h.pattern.lines.iter_mut() { + line.base_point.y += v; } } + "iso_pen_width" if v > 0.0 => { + let old = h.pattern_scale; + if old > 1e-12 { + scale_pattern_geometry(&mut h.pattern, v / old); + } + h.pattern_scale = v; + } "elevation" => h.elevation = v, _ => {} } @@ -763,6 +826,17 @@ impl Grippable for Hatch { boundary_centroid(self).unwrap_or((l0.base_point.x, l0.base_point.y)); out.push(circle_grip(id, glam::DVec3::new(gx, gy, elev))); id += 1; + } else if self.is_associative { + if let Some((gx, gy)) = boundary_centroid(self) { + out.push(circle_grip(id, glam::DVec3::new(gx, gy, elev))); + id += 1; + } + } + // Associative boundaries are edited through their source objects. A + // hatch therefore exposes only its circular pattern control instead + // of a second, conflicting set of boundary vertices. + if self.is_associative { + return out; } for path in &self.paths { for edge in &path.edges { @@ -834,8 +908,11 @@ impl Grippable for Hatch { .map(|l| (l.base_point.x, l.base_point.y)) { if grip_id == id { - let (nx, ny) = resolve(&apply, Vec3::new(ox as f32, oy as f32, elev)); - let (dx, dy) = (nx - ox, ny - oy); + let (gx, gy) = boundary_centroid(self).unwrap_or((ox, oy)); + let (dx, dy) = match apply { + GripApply::Absolute(point) => (point.x - gx, point.y - gy), + GripApply::Translate(delta) => (delta.x, delta.y), + }; for line in self.pattern.lines.iter_mut() { line.base_point.x += dx; line.base_point.y += dy; @@ -934,8 +1011,14 @@ impl Grippable for Hatch { } } - fn grip_menu(&self, _grip_id: usize) -> Vec { + fn grip_menu(&self, grip_id: usize) -> Vec { use crate::scene::model::object::{GripMenuAction, GripMenuItem}; + if self.pattern.lines.is_empty() || grip_id != 0 { + return vec![GripMenuItem { + label: "Stretch", + action: GripMenuAction::Stretch, + }]; + } vec![ GripMenuItem { label: "Stretch", @@ -956,9 +1039,16 @@ impl Grippable for Hatch { ] } - fn apply_grip_menu(&mut self, _grip_id: usize, _action: crate::scene::model::object::GripMenuAction) { - // Origin / Angle / Scale need a follow-up value — handled by - // `apply_grip_menu_value`. + fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) { + use crate::scene::model::object::GripMenuAction as A; + if grip_id == 0 && matches!(action, A::OriginPoint) { + if let (Some((gx, gy)), Some((ox, oy))) = ( + boundary_centroid(self), + self.pattern.lines.first().map(|line| (line.base_point.x, line.base_point.y)), + ) { + translate_pattern_geometry(&mut self.pattern, gx - ox, gy - oy); + } + } } fn grip_menu_value_prompt( diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index e8637358..ead0b1e2 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -206,14 +206,23 @@ fn rte_boundary(pts: impl Iterator) -> (Vec<[f32; 2]>, [f64; pub struct HatchCommand { outlines: Vec>, - boundary_sources: rustc_hash::FxHashMap>, + boundary_sources: rustc_hash::FxHashMap, point_regions: Vec>>, object_regions: Vec>>, selected_objects: Vec, mode: HatchMode, manual_pts: Vec, + manual_bulges: Vec, + manual_arc_mode: bool, + manual_arc_midpoint: Option, missed: bool, retain_boundaries: bool, + pattern_override: Option<(String, HatchPattern)>, + angle_override: Option, + scale_override: Option, + associative: bool, + separate_hatches: bool, + island_style: acadrust::entities::HatchStyleType, inherited: Option<( HatchModel, acadrust::types::Color, @@ -224,7 +233,7 @@ pub struct HatchCommand { impl HatchCommand { pub fn new( outlines: Vec>, - boundary_sources: rustc_hash::FxHashMap>, + boundary_sources: rustc_hash::FxHashMap, selected_objects: Vec, inherited: Option<( HatchModel, @@ -249,8 +258,20 @@ impl HatchCommand { HatchMode::PickInside }, manual_pts: vec![], + manual_bulges: vec![], + manual_arc_mode: false, + manual_arc_midpoint: None, missed: false, retain_boundaries: false, + pattern_override: None, + angle_override: None, + scale_override: None, + associative: true, + separate_hatches: false, + island_style: inherited + .as_ref() + .map(|(model, _, _)| model.style) + .unwrap_or(acadrust::entities::HatchStyleType::Normal), inherited, }; command.set_object_selection(selected_objects); @@ -261,7 +282,7 @@ impl HatchCommand { let mut segments = Vec::new(); for handle in &handles { if let Some(source) = self.boundary_sources.get(handle) { - segments.extend(source.iter().copied()); + segments.extend(source.segments.iter().copied()); } } self.object_regions = bounded_faces(&segments, Tolerance::new(1.0e-6)) @@ -287,6 +308,14 @@ impl HatchCommand { self.point_regions.len() + self.object_regions.len() } + fn island_style_label(&self) -> &'static str { + match self.island_style { + acadrust::entities::HatchStyleType::Normal => "Normal", + acadrust::entities::HatchStyleType::Outer => "Outer", + acadrust::entities::HatchStyleType::Ignore => "Ignore", + } + } + fn combined_rings(&self) -> Vec> { let mut rings = Vec::new(); for ring in self @@ -304,23 +333,37 @@ impl HatchCommand { fn make_hatch(&self, rings: Vec>) -> HatchModel { let (rel, origin, wcs) = pack_rings(&rings); - let exterior = cadkernel::geom2d::ring_nesting_depths(&rings) + let exterior: Vec = cadkernel::geom2d::ring_nesting_depths(&rings) .into_iter() .map(|depth| depth == 0) .collect(); - let boundary_sources = rings + let mut boundary_sources: Vec> = rings .iter() .map(|ring| crate::scene::ring_source_handles(ring, &self.boundary_sources)) .collect(); + let mut boundary_paths = crate::scene::exact_hatch_paths( + &rings, + &exterior, + &self.boundary_sources, + 1.0e-6, + ); + if !self.associative { + for handles in &mut boundary_sources { + handles.clear(); + } + for path in &mut boundary_paths { + path.boundary_handles.clear(); + } + } if let Some((source, _, _)) = &self.inherited { - let mut pattern = source.pattern.clone(); + let (name, mut pattern) = self + .pattern_override + .clone() + .unwrap_or_else(|| (source.name.clone(), source.pattern.clone())); + let angle = self.angle_override.unwrap_or(source.angle_offset); + let scale = self.scale_override.unwrap_or(source.scale).max(1.0e-6); 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(); + let (sin, cos) = angle.sin_cos(); for family in families { let base_x = source.world_origin[0] + (family.x0 as f64 * cos as f64 @@ -340,27 +383,29 @@ impl HatchCommand { render_instance: None, boundary: std::sync::Arc::new(rel), pattern, - name: source.name.clone(), + name, color: source.color, aci: source.aci, line_weight_px: source.line_weight_px, - angle_offset: source.angle_offset, - scale: source.scale, + angle_offset: angle, + scale, world_origin: origin, boundary_wcs: Some(std::sync::Arc::new(wcs)), fill_plane: None, fill_plane_boundary: None, boundary_exterior: Some(std::sync::Arc::new(exterior)), boundary_sources: Some(std::sync::Arc::new(boundary_sources)), + boundary_paths: Some(std::sync::Arc::new(boundary_paths)), + style: self.island_style, 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) + let default_pattern = crate::scene::model::hatch_patterns::find(pat_name) .and_then(|e| { if let HatchPattern::Pattern(f) = &e.gpu { - Some(f.clone()) + Some(HatchPattern::Pattern(f.clone())) } else { None } @@ -368,34 +413,97 @@ impl HatchCommand { .unwrap_or_else(|| { // 45° lines, perpendicular spacing ≈ 5 world units. let dy = 5.0_f32 / (45.0_f32.to_radians().cos()); - vec![PatFamily { + HatchPattern::Pattern(vec![PatFamily { angle_deg: 45.0, x0: 0.0, y0: 0.0, dx: 0.0, dy, dashes: vec![], - }] + }]) }); + let (name, pattern) = self + .pattern_override + .clone() + .unwrap_or_else(|| (pat_name.to_string(), default_pattern)); HatchModel { render_instance: None, boundary: std::sync::Arc::new(rel), - pattern: HatchPattern::Pattern(families), - name: pat_name.into(), + pattern, + name, color: [0.75, 0.75, 0.75, 0.85], aci: 0, line_weight_px: 1.0, - angle_offset: 0.0, - scale: 1.0, + angle_offset: self.angle_override.unwrap_or(0.0), + scale: self.scale_override.unwrap_or(1.0).max(1.0e-6), world_origin: origin, boundary_wcs: Some(std::sync::Arc::new(wcs)), fill_plane: None, fill_plane_boundary: None, boundary_exterior: Some(std::sync::Arc::new(exterior)), boundary_sources: Some(std::sync::Arc::new(boundary_sources)), + boundary_paths: Some(std::sync::Arc::new(boundary_paths)), + style: self.island_style, draw_depth: 0.0, } } + + fn manual_boundary_path(&self) -> Option { + use acadrust::entities::{BoundaryEdge, BoundaryPath, PolylineEdge}; + use acadrust::types::Vector3; + if self.manual_pts.len() < 3 { + return None; + } + let vertices = self + .manual_pts + .iter() + .enumerate() + .map(|(index, point)| { + Vector3::new( + point.x, + point.y, + self.manual_bulges.get(index).copied().unwrap_or(0.0), + ) + }) + .collect(); + let mut path = BoundaryPath::new(); + path.add_edge(BoundaryEdge::Polyline(PolylineEdge { + vertices, + is_closed: true, + })); + Some(path) + } +} + +fn arc_bulge(start: DVec3, middle: DVec3, end: DVec3) -> Option { + let d = 2.0 + * (start.x * (middle.y - end.y) + + middle.x * (end.y - start.y) + + end.x * (start.y - middle.y)); + if d.abs() <= 1.0e-12 { + return None; + } + let s2 = start.x * start.x + start.y * start.y; + let m2 = middle.x * middle.x + middle.y * middle.y; + let e2 = end.x * end.x + end.y * end.y; + let center_x = (s2 * (middle.y - end.y) + + m2 * (end.y - start.y) + + e2 * (start.y - middle.y)) + / d; + let center_y = (s2 * (end.x - middle.x) + + m2 * (start.x - end.x) + + e2 * (middle.x - start.x)) + / d; + let angle = |point: DVec3| (point.y - center_y).atan2(point.x - center_x); + let first = angle(start); + let through = (angle(middle) - first).rem_euclid(std::f64::consts::TAU); + let ccw = (angle(end) - first).rem_euclid(std::f64::consts::TAU); + let sweep = if through <= ccw + 1.0e-12 { + ccw + } else { + ccw - std::f64::consts::TAU + }; + Some((sweep * 0.25).tan()) } impl CadCommand for HatchCommand { @@ -412,7 +520,7 @@ impl CadCommand for HatchCommand { String::new() }; t!( - "HATCH Pick internal point (%{count} regions selected, Enter to apply):%{miss}", + "HATCH Pick internal point (%{count} regions selected; P / A / L ; Enter to apply):%{miss}", count = self.region_count(), miss = miss ) @@ -425,7 +533,7 @@ impl CadCommand for HatchCommand { String::new() }; t!( - "HATCH Select boundary objects (%{objects} objects, %{count} regions; Enter to apply):%{miss}", + "HATCH Select boundary objects (%{objects} objects, %{count} regions; P / A / L ; Enter to apply):%{miss}", objects = self.selected_objects.len(), count = self.region_count(), miss = miss @@ -458,6 +566,18 @@ impl CadCommand for HatchCommand { }, "B", ), + CmdOption::new( + if self.associative { "Associative: on" } else { "Associative: off" }, + "N", + ), + CmdOption::new( + if self.separate_hatches { "Separate hatches: on" } else { "Separate hatches: off" }, + "D", + ), + CmdOption::new( + &format!("Island style: {}", self.island_style_label()), + "Y", + ), ]; if self.region_count() > 0 { options.push(CmdOption::enter(t!("Accept").as_ref())); @@ -476,6 +596,18 @@ impl CadCommand for HatchCommand { }, "B", ), + CmdOption::new( + if self.associative { "Associative: on" } else { "Associative: off" }, + "N", + ), + CmdOption::new( + if self.separate_hatches { "Separate hatches: on" } else { "Separate hatches: off" }, + "D", + ), + CmdOption::new( + &format!("Island style: {}", self.island_style_label()), + "Y", + ), ]; if self.region_count() > 0 { options.push(CmdOption::enter(t!("Accept").as_ref())); @@ -483,12 +615,17 @@ impl CadCommand for HatchCommand { options } HatchMode::Manual => { - // Enter accepts the boundary once at least 3 points are picked. + let mut options = vec![ + CmdOption::new( + if self.manual_arc_mode { "Line" } else { "Arc" }, + if self.manual_arc_mode { "L" } else { "A" }, + ), + ]; if self.manual_pts.len() >= 3 { - vec![CmdOption::enter(t!("Accept").as_ref())] - } else { - vec![] + options.push(CmdOption::new("Close", "C")); + options.push(CmdOption::enter(t!("Accept").as_ref())); } + options } } } @@ -511,8 +648,19 @@ impl CadCommand for HatchCommand { } HatchMode::SelectObjects => CmdResult::NeedPoint, HatchMode::Manual => { - // Keep the typed/snapped point exact (issue #311). - self.manual_pts.push(pt); + if self.manual_pts.is_empty() || !self.manual_arc_mode { + if !self.manual_pts.is_empty() { + self.manual_bulges.push(0.0); + } + self.manual_pts.push(pt); + } else if let Some(middle) = self.manual_arc_midpoint.take() { + let start = *self.manual_pts.last().unwrap(); + self.manual_bulges + .push(arc_bulge(start, middle, pt).unwrap_or(0.0)); + self.manual_pts.push(pt); + } else { + self.manual_arc_midpoint = Some(pt); + } CmdResult::NeedPoint } } @@ -526,6 +674,35 @@ impl CadCommand for HatchCommand { let rings = self.combined_rings(); if rings.is_empty() { CmdResult::Cancel + } else if matches!(self.mode, HatchMode::Manual) { + let mut hatch = self.make_hatch(rings); + if let Some(path) = self.manual_boundary_path() { + hatch.boundary_paths = Some(std::sync::Arc::new(vec![path])); + } + if let Some((_, color, transparency)) = &self.inherited { + CmdResult::CommitStyledHatch { + hatch, + color: color.clone(), + transparency: *transparency, + } + } else { + CmdResult::CommitHatch(hatch) + } + } else if self.separate_hatches && !self.retain_boundaries { + let hatches = self + .point_regions + .iter() + .chain(self.object_regions.iter()) + .cloned() + .map(|region| self.make_hatch(region)) + .collect(); + CmdResult::CommitHatches { + hatches, + entity_style: self + .inherited + .as_ref() + .map(|(_, color, transparency)| (color.clone(), *transparency)), + } } else if self.retain_boundaries { CmdResult::CommitHatchWithBoundaries { hatch: self.make_hatch(rings.clone()), @@ -564,6 +741,15 @@ impl CadCommand for HatchCommand { } fn on_undo_step(&mut self) -> Option { + if matches!(self.mode, HatchMode::Manual) { + if self.manual_arc_midpoint.take().is_some() { + return Some(CmdResult::NeedPoint); + } + if self.manual_pts.pop().is_some() { + self.manual_bulges.pop(); + return Some(CmdResult::NeedPoint); + } + } if matches!(self.mode, HatchMode::PickInside) && self.point_regions.pop().is_some() { Some(CmdResult::NeedPoint) } else { @@ -590,11 +776,52 @@ impl CadCommand for HatchCommand { } fn wants_text_input(&self) -> bool { - !matches!(self.mode, HatchMode::Manual) + true } fn on_text_input(&mut self, text: &str) -> Option { - match text.trim().to_ascii_uppercase().as_str() { + let input = text.trim(); + let upper = input.to_ascii_uppercase(); + if matches!(self.mode, HatchMode::Manual) { + return match upper.as_str() { + "A" | "ARC" => { + self.manual_arc_mode = true; + self.manual_arc_midpoint = None; + Some(CmdResult::NeedPoint) + } + "L" | "LINE" => { + self.manual_arc_mode = false; + self.manual_arc_midpoint = None; + Some(CmdResult::NeedPoint) + } + "C" | "CLOSE" if self.manual_pts.len() >= 3 => Some(self.on_enter()), + _ => None, + }; + } + if let Some(rest) = upper.strip_prefix('P') { + let name = rest.trim(); + if !name.is_empty() { + if let Some(entry) = crate::scene::model::hatch_patterns::find(name) { + self.pattern_override = Some((entry.name.clone(), entry.gpu.clone())); + } + } + return Some(CmdResult::NeedPoint); + } + if let Some(rest) = upper.strip_prefix('A') { + if let Ok(value) = rest.trim().replace(',', ".").parse::() { + self.angle_override = Some(value.to_radians()); + } + return Some(CmdResult::NeedPoint); + } + if let Some(rest) = upper.strip_prefix('L') { + if let Ok(value) = rest.trim().replace(',', ".").parse::() { + if value > 0.0 { + self.scale_override = Some(value); + } + } + return Some(CmdResult::NeedPoint); + } + match upper.as_str() { "O" | "OBJECT" | "OBJECTS" => { self.mode = HatchMode::SelectObjects; self.missed = false; @@ -614,6 +841,28 @@ impl CadCommand for HatchCommand { self.retain_boundaries = !self.retain_boundaries; Some(CmdResult::NeedPoint) } + "N" | "ASSOCIATIVE" => { + self.associative = !self.associative; + Some(CmdResult::NeedPoint) + } + "D" | "SEPARATE" => { + self.separate_hatches = !self.separate_hatches; + Some(CmdResult::NeedPoint) + } + "Y" | "ISLAND" => { + self.island_style = match self.island_style { + acadrust::entities::HatchStyleType::Normal => { + acadrust::entities::HatchStyleType::Outer + } + acadrust::entities::HatchStyleType::Outer => { + acadrust::entities::HatchStyleType::Ignore + } + acadrust::entities::HatchStyleType::Ignore => { + acadrust::entities::HatchStyleType::Normal + } + }; + Some(CmdResult::NeedPoint) + } _ => None, } } @@ -649,7 +898,7 @@ impl CadCommand for HatchCommand { pub struct GradientCommand { outlines: Vec>, - boundary_sources: rustc_hash::FxHashMap>, + boundary_sources: rustc_hash::FxHashMap, mode: Mode, manual_pts: Vec, missed: bool, @@ -662,7 +911,7 @@ pub struct GradientCommand { impl GradientCommand { pub fn new( outlines: Vec>, - boundary_sources: rustc_hash::FxHashMap>, + boundary_sources: rustc_hash::FxHashMap, ) -> Self { Self { outlines, @@ -677,7 +926,7 @@ impl GradientCommand { fn make_hatch(&self, rings: Vec>) -> HatchModel { let (rel, origin, wcs) = pack_rings(&rings); - let exterior = cadkernel::geom2d::ring_nesting_depths(&rings) + let exterior: Vec = cadkernel::geom2d::ring_nesting_depths(&rings) .into_iter() .map(|depth| depth == 0) .collect(); @@ -685,6 +934,12 @@ impl GradientCommand { .iter() .map(|ring| crate::scene::ring_source_handles(ring, &self.boundary_sources)) .collect(); + let boundary_paths = crate::scene::exact_hatch_paths( + &rings, + &exterior, + &self.boundary_sources, + 1.0e-6, + ); HatchModel { render_instance: None, boundary: std::sync::Arc::new(rel), @@ -707,6 +962,8 @@ impl GradientCommand { fill_plane_boundary: None, boundary_exterior: Some(std::sync::Arc::new(exterior)), boundary_sources: Some(std::sync::Arc::new(boundary_sources)), + boundary_paths: Some(std::sync::Arc::new(boundary_paths)), + style: acadrust::entities::HatchStyleType::Normal, draw_depth: 0.0, } } diff --git a/src/modules/draw/draw/hatchedit.rs b/src/modules/draw/draw/hatchedit.rs index 6e850543..e4add5da 100644 --- a/src/modules/draw/draw/hatchedit.rs +++ b/src/modules/draw/draw/hatchedit.rs @@ -12,7 +12,7 @@ use acadrust::Handle; use glam::DVec3; use crate::t; -use crate::command::{CadCommand, CmdResult}; +use crate::command::{CadCommand, CmdResult, HatchEditOperation}; enum HatcheditStep { PickHatch, @@ -26,16 +26,32 @@ enum HatcheditStep { pub struct HatcheditCommand { step: HatcheditStep, + origin: Option<(f64, f64)>, + disassociate: bool, + style: Option, + annotative: Option, + annotative_current: bool, } impl HatcheditCommand { pub fn new() -> Self { Self { step: HatcheditStep::PickHatch, + origin: None, + disassociate: false, + style: None, + annotative: None, + annotative_current: false, } } - pub fn with_handle(handle: Handle, name: String, scale: f32, angle: f32) -> Self { + pub fn with_handle( + handle: Handle, + name: String, + scale: f32, + angle: f32, + annotative: bool, + ) -> Self { Self { step: HatcheditStep::EditOptions { handle, @@ -43,6 +59,39 @@ impl HatcheditCommand { scale, angle, }, + origin: None, + disassociate: false, + style: None, + annotative: None, + annotative_current: annotative, + } + } + + fn apply_result(&self, operation: HatchEditOperation) -> Option { + let HatcheditStep::EditOptions { + handle, + name, + scale, + angle, + } = &self.step + else { + return None; + }; + Some(CmdResult::HatcheditApply { + handle: *handle, + name: name.clone(), + scale: *scale, + angle: *angle, + operation, + }) + } + + fn update_operation(&self) -> HatchEditOperation { + HatchEditOperation::Update { + origin: self.origin, + disassociate: self.disassociate, + style: self.style, + annotative: self.annotative, } } } @@ -61,7 +110,7 @@ impl CadCommand for HatcheditCommand { let scale = format!("{scale:.4}"); let angle = format!("{angle:.1}"); t!( - "HATCHEDIT Pattern:%{name} Scale:%{scale} Angle:%{angle} [P / S / A | Enter to apply]:", + "HATCHEDIT Pattern:%{name} Scale:%{scale} Angle:%{angle} [P pattern / S scale / A angle / O x,y / D disassociate / Y style / N annotative / R recreate / E separate / + handles / - handles | Enter]:", name = name, scale = scale, angle = angle @@ -94,8 +143,23 @@ impl CadCommand for HatcheditCommand { matches!(self.step, HatcheditStep::EditOptions { .. }) } + fn options(&self) -> Vec { + if !matches!(self.step, HatcheditStep::EditOptions { .. }) { + return Vec::new(); + } + vec![ + crate::command::CmdOption::new("Disassociate", "D"), + crate::command::CmdOption::new("Annotative", "N"), + crate::command::CmdOption::new("Recreate boundary", "R"), + crate::command::CmdOption::new("Separate hatches", "E"), + crate::command::CmdOption::new("Draw front", "F"), + crate::command::CmdOption::new("Draw back", "B"), + crate::command::CmdOption::enter("Apply"), + ] + } + fn on_text_input(&mut self, text: &str) -> Option { - let (handle, name, scale, angle) = match &mut self.step { + let (_handle, name, scale, angle) = match &mut self.step { HatcheditStep::EditOptions { handle, name, @@ -108,13 +172,7 @@ impl CadCommand for HatcheditCommand { let text = text.trim().to_uppercase(); if text.is_empty() { - // Apply and exit - return Some(CmdResult::HatcheditApply { - handle, - name: name.clone(), - scale: *scale, - angle: *angle, - }); + return self.apply_result(self.update_operation()); } // Parse option: P/S/A followed by value @@ -140,6 +198,65 @@ impl CadCommand for HatcheditCommand { return Some(CmdResult::NeedPoint); } + if let Some(rest) = text.strip_prefix('O') { + let values: Vec<_> = rest + .trim() + .split([',', ';', ' ']) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.replace(',', ".").parse::().ok()) + .collect(); + if values.len() >= 2 { + self.origin = Some((values[0], values[1])); + } + return Some(CmdResult::NeedPoint); + } + if text == "D" || text == "DISASSOCIATE" { + self.disassociate = true; + return Some(CmdResult::NeedPoint); + } + if let Some(rest) = text.strip_prefix('Y') { + self.style = match rest.trim() { + "NORMAL" | "N" => Some(acadrust::entities::HatchStyleType::Normal), + "OUTER" | "O" => Some(acadrust::entities::HatchStyleType::Outer), + "IGNORE" | "I" => Some(acadrust::entities::HatchStyleType::Ignore), + _ => self.style, + }; + return Some(CmdResult::NeedPoint); + } + if text == "N" || text == "ANNOTATIVE" { + self.annotative = Some(!self.annotative.unwrap_or(self.annotative_current)); + return Some(CmdResult::NeedPoint); + } + if text == "R" || text == "RECREATE" { + return self.apply_result(HatchEditOperation::RecreateBoundary); + } + if text == "E" || text == "SEPARATE" { + return self.apply_result(HatchEditOperation::Separate); + } + if text == "F" || text == "FRONT" { + return self.apply_result(HatchEditOperation::DrawOrderFront); + } + if text == "B" || text == "BACK" { + return self.apply_result(HatchEditOperation::DrawOrderBack); + } + let parse_handles = |source: &str| { + source + .split([',', ';', ' ']) + .filter(|part| !part.is_empty()) + .filter_map(|part| { + u64::from_str_radix(part.trim_start_matches("0X"), 16) + .ok() + .map(Handle::new) + }) + .collect::>() + }; + if let Some(rest) = text.strip_prefix('+') { + return self.apply_result(HatchEditOperation::AddBoundaries(parse_handles(rest))); + } + if let Some(rest) = text.strip_prefix('-') { + return self.apply_result(HatchEditOperation::RemoveBoundaries(parse_handles(rest))); + } + // Unrecognized — stay and re-prompt Some(CmdResult::NeedPoint) } @@ -149,21 +266,7 @@ impl CadCommand for HatcheditCommand { } fn on_enter(&mut self) -> CmdResult { // Enter without text → apply current settings - let (handle, name, scale, angle) = match &self.step { - HatcheditStep::EditOptions { - handle, - name, - scale, - angle, - } => (*handle, name.clone(), *scale, *angle), - _ => return CmdResult::Cancel, - }; - CmdResult::HatcheditApply { - handle, - name, - scale, - angle, - } + self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel) } fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index aff47c86..697d38de 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -21,31 +21,6 @@ pub struct BoundarySource { /// 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 -} - fn wire_segments_on_plane( wire: &WireModel, plane: WorkingPlane, @@ -113,22 +88,12 @@ fn entity_curves_on_plane( } } -fn ring_seed(model: &HatchModel, wanted: usize) -> Option<[f64; 2]> { +fn hatch_path_seed(path: &acadrust::entities::BoundaryPath) -> 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; - } + for edge in &path.edges { + let curve = crate::entities::hatch::edge_curve(edge)?; + let points = curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE); + ring.extend(points.into_iter().skip(usize::from(!ring.is_empty()))); } if ring.len() < 3 { return None; @@ -136,15 +101,16 @@ fn ring_seed(model: &HatchModel, wanted: usize) -> Option<[f64; 2]> { let (points, triangles) = triangulate(&ring, &[]); if let Some(triangle) = triangles.first() { let [a, b, c] = triangle.map(|vertex| points[vertex]); - return Some([ + Some([ (a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0, - ]); + ]) + } else { + Some([ + ring.iter().map(|point| point[0]).sum::() / ring.len() as f64, + ring.iter().map(|point| point[1]).sum::() / ring.len() as f64, + ]) } - 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 { @@ -181,7 +147,7 @@ fn matching_face(faces: &[Vec<[f64; 2]>], seed: Option<[f64; 2]>) -> Option<&Vec pub(crate) fn ring_source_handles( ring: &[[f64; 2]], - sources: &rustc_hash::FxHashMap>, + sources: &rustc_hash::FxHashMap, ) -> Vec { let mut handles = rustc_hash::FxHashSet::default(); for (&start, &end) in ring @@ -191,8 +157,8 @@ pub(crate) fn ring_source_handles( { 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| { + for (&handle, source) in sources { + if source.segments.iter().any(|line| { matches!( segment_crossing(edge, *line, Tolerance::new(WELD_TOLERANCE)), SegmentCrossing::Overlap { a, .. } @@ -208,6 +174,203 @@ pub(crate) fn ring_source_handles( handles } +fn curve_forward(curve: &Curve, start: [f64; 2], next: [f64; 2]) -> bool { + let a = curve.parameter_at(start); + let b = curve.parameter_at(next); + let mut delta = b - a; + if curve.is_closed() { + if delta > 0.5 { + delta -= 1.0; + } else if delta < -0.5 { + delta += 1.0; + } + } + delta >= 0.0 +} + +fn exact_boundary_edge( + curve: Option<&Curve>, + start: [f64; 2], + end: [f64; 2], + next: [f64; 2], + whole_curve: bool, +) -> acadrust::entities::BoundaryEdge { + use acadrust::entities::{ + BoundaryEdge, CircularArcEdge, EllipticArcEdge, LineEdge, SplineEdge, + }; + use acadrust::types::{Vector2, Vector3}; + + let Some(curve) = curve else { + return BoundaryEdge::Line(LineEdge { + start: Vector2::new(start[0], start[1]), + end: Vector2::new(end[0], end[1]), + }); + }; + let forward = curve_forward(curve, start, next); + match curve { + Curve::Line(_) => BoundaryEdge::Line(LineEdge { + start: Vector2::new(start[0], start[1]), + end: Vector2::new(end[0], end[1]), + }), + Curve::Circle(circle) => { + let start_angle = (start[1] - circle.centre[1]).atan2(start[0] - circle.centre[0]); + let mut end_angle = (end[1] - circle.centre[1]).atan2(end[0] - circle.centre[0]); + if whole_curve { + end_angle = start_angle + std::f64::consts::TAU; + } + BoundaryEdge::CircularArc(CircularArcEdge { + center: Vector2::new(circle.centre[0], circle.centre[1]), + radius: circle.radius, + start_angle, + end_angle, + counter_clockwise: forward, + }) + } + Curve::Arc(arc) => { + let start_angle = (start[1] - arc.centre[1]).atan2(start[0] - arc.centre[0]); + let end_angle = (end[1] - arc.centre[1]).atan2(end[0] - arc.centre[0]); + BoundaryEdge::CircularArc(CircularArcEdge { + center: Vector2::new(arc.centre[0], arc.centre[1]), + radius: arc.radius, + start_angle, + end_angle, + counter_clockwise: forward, + }) + } + Curve::Ellipse(arc) => { + let ellipse = arc.ellipse; + let mut start_parameter = arc.start_parameter + curve.parameter_at(start) * arc.sweep(); + let mut end_parameter = arc.start_parameter + curve.parameter_at(end) * arc.sweep(); + if !forward { + std::mem::swap(&mut start_parameter, &mut end_parameter); + } + if whole_curve { + end_parameter = start_parameter + std::f64::consts::TAU; + } + BoundaryEdge::EllipticArc(EllipticArcEdge { + center: Vector2::new(ellipse.centre[0], ellipse.centre[1]), + major_axis_endpoint: Vector2::new( + ellipse.major_axis[0] * ellipse.major_radius, + ellipse.major_axis[1] * ellipse.major_radius, + ), + minor_axis_ratio: ellipse.minor_radius / ellipse.major_radius, + start_angle: start_parameter, + end_angle: end_parameter, + counter_clockwise: forward, + }) + } + Curve::Nurbs(source) => { + let trimmed = if whole_curve { + Some(if forward { source.clone() } else { source.reversed() }) + } else { + source.trimmed(source.parameter_at(start), source.parameter_at(end)) + } + .unwrap_or_else(|| source.clone()); + let rational = trimmed.is_rational(); + BoundaryEdge::Spline(SplineEdge { + degree: trimmed.degree() as i32, + rational, + periodic: trimmed.is_closed(), + knots: trimmed.knots().to_vec(), + control_points: trimmed + .control_points() + .iter() + .zip(trimmed.weights()) + .map(|(point, weight)| { + Vector3::new(point[0], point[1], if rational { *weight } else { 1.0 }) + }) + .collect(), + fit_points: Vec::new(), + start_tangent: Vector2::new(0.0, 0.0), + end_tangent: Vector2::new(0.0, 0.0), + }) + } + Curve::Polyline(_) | Curve::Ray(_) | Curve::XLine(_) => { + BoundaryEdge::Line(LineEdge { + start: Vector2::new(start[0], start[1]), + end: Vector2::new(end[0], end[1]), + }) + } + } +} + +/// Rebuild detected tessellated rings as analytic hatch paths wherever their +/// source entity exposes an exact curve. Intersections remain the graph's +/// vertices, while the edge between them is stored as a trimmed source curve. +pub(crate) fn exact_hatch_paths( + rings: &[Vec<[f64; 2]>], + exterior: &[bool], + sources: &rustc_hash::FxHashMap, + tolerance: f64, +) -> Vec { + use acadrust::entities::{BoundaryPath, BoundaryPathFlags}; + + rings + .iter() + .enumerate() + .filter_map(|(ring_index, ring)| { + let (points, curves) = refined_boundary_ring(ring, sources, tolerance); + let count = points.len(); + if count < 3 { + return None; + } + let handles = ring_source_handles(ring, sources); + let mut bits = 0; + if exterior.get(ring_index).copied().unwrap_or(ring_index == 0) { + bits |= BoundaryPathFlags::OUTERMOST.bits(); + } + if !handles.is_empty() { + bits |= BoundaryPathFlags::EXTERNAL.bits(); + } + let mut path = BoundaryPath::with_flags(BoundaryPathFlags::from_bits(bits)); + + let all_same = curves.first().is_some_and(|first| { + first.is_some() && curves.iter().all(|curve| curve == first) + }); + if all_same { + let curve = curves[0].as_ref(); + path.add_edge(exact_boundary_edge( + curve, + points[0], + points[0], + points[1], + true, + )); + } else { + let start_index = (0..count) + .find(|index| curves[*index] != curves[(*index + count - 1) % count]) + .unwrap_or(0); + let mut consumed = 0usize; + while consumed < count { + let edge_index = (start_index + consumed) % count; + let curve = curves.get(edge_index).and_then(Option::as_ref); + let mut length = 1usize; + if curve.is_some() { + while consumed + length < count + && curves[(edge_index + length) % count].as_ref() == curve + { + length += 1; + } + } + let end_index = (edge_index + length) % count; + path.add_edge(exact_boundary_edge( + curve, + points[edge_index], + points[end_index], + points[(edge_index + 1) % count], + false, + )); + consumed += length; + } + } + for handle in handles { + path.add_boundary_handle(handle); + } + Some(path) + }) + .collect() +} + pub(crate) fn boundary_entities(rings: &[Vec<[f64; 2]>]) -> Vec { rings .iter() @@ -409,11 +572,43 @@ pub(crate) fn boundary_polyline_entities( } impl Scene { - fn associative_boundary_segments(&self, handles: &[Handle]) -> Vec { - self.wire_models_for(handles) + pub(crate) fn edit_hatch_boundary_handles( + &mut self, + hatch_handle: Handle, + handles: &[Handle], + add: bool, + ) -> bool { + let Some(EntityType::Hatch(hatch)) = self.document.get_entity_mut(hatch_handle) else { + return false; + }; + let Some(path) = hatch.paths.first_mut() else { + return false; + }; + if add { + for handle in handles.iter().copied().filter(|handle| handle.is_valid()) { + if !path.boundary_handles.contains(&handle) { + path.boundary_handles.push(handle); + } + } + } else { + path.boundary_handles.retain(|handle| !handles.contains(handle)); + } + hatch.is_associative = hatch + .paths .iter() - .flat_map(wire_segments) - .collect() + .any(|candidate| !candidate.boundary_handles.is_empty()); + self.associative_hatch_source_cache.borrow_mut().take(); + if add && !handles.is_empty() { + let changes: Vec<_> = handles + .iter() + .copied() + .map(|handle| (handle, ChangeKind::Modified)) + .collect(); + self.refresh_associative_hatches(&changes); + } else { + self.refresh_fill_model(hatch_handle); + } + true } fn associative_hatch_dependents( @@ -470,15 +665,7 @@ impl Scene { 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(); + let seeds = hatch.paths.iter().map(hatch_path_seed).collect::>(); (handle, hatch.clone(), seeds) }) }) @@ -486,10 +673,13 @@ impl Scene { 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 storage = crate::entities::curve::ocs_plane(hatch.normal, hatch.elevation); + let plane = WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ); + let all_sources = self.boundary_sources_on_plane(plane, WELD_TOLERANCE); let mut modified = false; let mut association_changed = false; for (index, path) in hatch.paths.iter_mut().enumerate() { @@ -505,19 +695,36 @@ impl Scene { .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 sources: rustc_hash::FxHashMap<_, _> = path + .boundary_handles + .iter() + .filter_map(|source| { + all_sources + .get(source) + .cloned() + .map(|geometry| (*source, geometry)) + }) + .collect(); + let segments: Vec<_> = sources + .values() + .flat_map(|source| source.segments.iter().copied()) + .collect(); 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, - ), - )]; + let exterior = [path.flags.is_outermost()]; + if let Some(exact) = exact_hatch_paths( + std::slice::from_ref(face), + &exterior, + &sources, + WELD_TOLERANCE, + ) + .into_iter() + .next() + { + path.edges = exact.edges; + } modified = true; } hatch.is_associative = hatch @@ -543,48 +750,6 @@ impl Scene { refreshed } - /// Build closed planar regions from the visible wire geometry. - /// - /// Source entities do not need to be closed individually. Intersections are - /// inserted as temporary graph vertices and - /// the bounded faces of that planar graph are returned as hatch candidates. - /// - /// Curved entities participate through their already-tessellated WireModel - /// geometry, so arcs, circles, ellipses and splines can take part in the - /// boundary search without modifying the source entities. - /// - /// The arrangement itself is the kernel's: splitting at crossings, welding - /// coincident ends and tracing the bounded faces is the same problem a - /// B-rep boolean solves in a face's parameter space, and it is solved - /// once. What stays here is reading the wires — which is where the - /// drawing's own conventions live. - pub fn hatch_boundary_outlines(&self) -> Vec> { - let mut segments = Vec::::new(); - - for wire in self.entity_wires().iter() { - 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 - } - /// Boundary candidates in the active working plane, with exact curves /// where the source entity exposes them. pub fn boundary_sources_on_plane( diff --git a/src/scene/entity.rs b/src/scene/entity.rs index fd82c8cc..a41363d5 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1469,6 +1469,8 @@ impl Scene { fill_plane_boundary, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, pattern: model::hatch_model::HatchPattern::Solid, name: "WIPEOUT_FILL".into(), color, @@ -1885,6 +1887,8 @@ impl Scene { fill_plane_boundary: None, boundary_exterior: Some(std::sync::Arc::new(boundary_exterior)), boundary_sources: Some(std::sync::Arc::new(boundary_sources)), + boundary_paths: Some(std::sync::Arc::new(dxf.paths.clone())), + style: dxf.style, pattern, name, // A gradient starts from its first stop; other fills use the @@ -2203,6 +2207,8 @@ impl Scene { fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, pattern: model::hatch_model::HatchPattern::Solid, name: "SOLID".into(), color, @@ -2222,14 +2228,20 @@ impl Scene { entity_style: Option<(acadrust::types::Color, acadrust::types::Transparency)>, ) -> Handle { let mut dxf = DxfHatch::new(); + dxf.style = model.style; dxf.is_solid = matches!( model.pattern, crate::scene::model::hatch_model::HatchPattern::Solid ); - // 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() { + // Keep analytic command geometry when available. The tessellated model + // remains the render representation only; it must not replace circles, + // ellipse arcs or splines in the persisted entity. + if let Some(paths) = model.boundary_paths.as_deref() { + dxf.paths = paths.clone(); + } else { + // Otherwise reconstruct every ring from the render offsets without + // dropping its separators. + let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() { let [wx, wy] = model.world_origin; model .boundary @@ -2242,18 +2254,18 @@ impl Scene { } }) .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, index: usize| { + } 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, index: usize| { if !r.is_empty() { let edge = PolylineEdge::new(std::mem::take(r), true); let handles: Vec<_> = model @@ -2281,31 +2293,32 @@ impl Scene { } 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); - first = false; - if !ring.is_empty() { - push_ring(&mut ring, is_outer, ring_index); - ring_index += 1; + }; + 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); + first = false; + if !ring.is_empty() { + push_ring(&mut ring, is_outer, ring_index); + ring_index += 1; + } } } + 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, ring_index); } - 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, ring_index); dxf.is_associative = dxf .paths .iter() diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 04548003..e587815c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -35,8 +35,8 @@ mod scene_markers; mod selection; pub(crate) use boundary::{ - boundary_entities, boundary_faces, boundary_polyline_entities, ring_source_handles, - BoundarySource, + boundary_entities, boundary_faces, boundary_polyline_entities, exact_hatch_paths, + ring_source_handles, BoundarySource, }; // Parallel tessellation free functions live in `convert::tess` (alongside the @@ -3215,6 +3215,8 @@ impl Scene { fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, 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 a36f16af..06484729 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -210,6 +210,13 @@ pub struct HatchModel { pub boundary_exterior: Option>>, /// Source entity handles for each boundary ring. pub boundary_sources: Option>>>, + /// Exact persisted boundary paths for draw/edit workflows. Rendering keeps + /// using the compact tessellated boundary above, while persistence can + /// retain analytic arcs, ellipses and splines without rebuilding them as + /// straight polyline chords. + pub boundary_paths: Option>>, + /// Island handling used by the persisted hatch entity. + pub style: acadrust::entities::HatchStyleType, /// 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 9faeda98..5c838dc9 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -162,6 +162,8 @@ impl Scene { fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, pattern: HatchPattern::Solid, name: "AREA_PREVIEW".into(), color: [0.0; 4], diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 4aeab73b..e14838f3 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -134,6 +134,8 @@ impl canvas::Program for HatchPatternPreview { fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, pattern: self.pattern.clone(), name: String::new(), color: [1.0; 4], From 4bc02b6b4ad487e58a96623df40d1dcc3e6ba009 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 21 Aug 2026 20:52:03 +0300 Subject: [PATCH 2/2] fix: correct hatch geometry workflow --- src/app/command_driver.rs | 73 +++++++------ src/app/commands/draw.rs | 45 ++++++-- src/app/properties.rs | 4 +- src/app/update/viewport.rs | 18 +-- src/entities/hatch.rs | 61 ++--------- src/modules/draw/draw/hatch.rs | 139 ++++++++++++----------- src/modules/draw/draw/hatchedit.rs | 8 ++ src/scene/boundary.rs | 170 +++++++++++++++++++++++------ src/scene/entity.rs | 126 ++++++++++++--------- src/scene/mod.rs | 5 +- src/scene/model/hatch_model.rs | 7 +- 11 files changed, 401 insertions(+), 255 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1b488086..1245fe71 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1375,6 +1375,13 @@ impl OpenCADStudio { .collect::>(); sources.push(handles); } + if let Some(paths) = hatch.boundary_paths.as_mut() { + for (path, handles) in std::sync::Arc::make_mut(paths).iter_mut().zip(&sources) + { + path.boundary_handles = handles.clone(); + path.flags.set_external(!handles.is_empty()); + } + } hatch.boundary_sources = Some(std::sync::Arc::new(sources)); let layer = self.tabs[i].active_layer.clone(); let new_handle = @@ -1619,10 +1626,7 @@ impl OpenCADStudio { .into_iter() .map(|e| self.tabs[i].scene.add_entity(e)) .collect(); - // A replaced dimension carries edited geometry/text but still - // names its old *D block; drop that stale block so the next save - // re-bakes it — otherwise BricsCAD/ODA draw the pre-edit - // graphics while OCS shows the edit. (#181) + // Rebuild replaced dimensions from edited data. for &nh in &new_handles { if matches!( self.tabs[i].scene.document.get_entity(nh), @@ -1932,11 +1936,6 @@ impl OpenCADStudio { } // The command stays active after each apply so more targets // can keep being picked; Enter / Esc ends it (#362). - // Special (type-specific) properties travel like AutoCAD's - // Special Properties: each is captured from the source when it - // carries it and applied only to destinations that support it - // (#281). Text formatting crosses TEXT ↔ MTEXT (#361); the dim - // style crosses Dimension / Leader / Tolerance. let src_clone = self.tabs[i].scene.document.get_entity(src).cloned(); let src_common = src_clone.as_ref().map(|e| e.common().clone()); let thickness = src_clone @@ -3337,6 +3336,7 @@ impl OpenCADStudio { if disassociate { for path in &mut hatch.paths { path.boundary_handles.clear(); + path.flags.set_external(false); } hatch.is_associative = false; } @@ -3378,21 +3378,19 @@ impl OpenCADStudio { .edit_hatch_boundary_handles(handle, &handles, false); } HatchEditOperation::RecreateBoundary => { - let model = self.tabs[i].scene.hatches.get(&handle).cloned(); - if let Some(model) = model { - let mut rings = vec![Vec::new()]; - for &[x, y] in model.boundary.iter() { - if x.is_finite() && y.is_finite() { - rings.last_mut().unwrap().push([ - model.world_origin[0] + x as f64, - model.world_origin[1] + y as f64, - ]); - } else if !rings.last().unwrap().is_empty() { - rings.push(Vec::new()); - } - } - rings.retain(|ring| ring.len() >= 3); - let entities = crate::scene::boundary_entities(&rings); + let source = self.tabs[i].scene.document.get_entity(handle).cloned(); + if let Some(acadrust::EntityType::Hatch(source)) = source { + let storage = crate::entities::curve::ocs_plane( + source.normal, + source.elevation, + ); + let plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ); + let rings = crate::scene::hatch_boundary_rings(&source); + let entities = crate::scene::boundary_entities(&rings, plane); let mut handles = Vec::new(); for entity in entities { if let Some(boundary) = self.commit_entity_handle(entity) { @@ -3406,6 +3404,7 @@ impl OpenCADStudio { hatch.paths.iter_mut().zip(handles.iter().copied()) { path.boundary_handles = vec![boundary]; + path.flags.set_external(true); } hatch.is_associative = !handles.is_empty(); } @@ -3418,18 +3417,22 @@ impl OpenCADStudio { HatchEditOperation::Separate => { let source = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(acadrust::EntityType::Hatch(hatch)) = source { - for path in hatch.paths.iter().cloned() { - let mut separated = hatch.clone(); - separated.common.handle = acadrust::Handle::NULL; - separated.paths = vec![path]; - separated.is_associative = separated.paths.iter().any(|path| { - !path.boundary_handles.is_empty() - }); - self.tabs[i] - .scene - .add_entity(acadrust::EntityType::Hatch(separated)); + let groups = crate::scene::separated_hatch_path_groups(&hatch); + if groups.len() > 1 { + for paths in groups { + let mut separated = hatch.clone(); + separated.common.handle = acadrust::Handle::NULL; + separated.paths = paths; + separated.is_associative = separated + .paths + .iter() + .any(|path| !path.boundary_handles.is_empty()); + self.tabs[i] + .scene + .add_entity(acadrust::EntityType::Hatch(separated)); + } + self.tabs[i].scene.erase_entities(&[handle]); } - self.tabs[i].scene.erase_entities(&[handle]); } } HatchEditOperation::DrawOrderFront diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 2decde5d..0cd85a89 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -635,9 +635,25 @@ impl OpenCADStudio { "HATCH" => { use crate::modules::draw::draw::hatch::HatchCommand; + let working_plane = if self.tabs[i].editing_model_space() { + self.tabs[i].ucs_xform().working_plane() + } else { + crate::command::WorkingPlane::default() + }; + let normal = working_plane.z.normalize_or(glam::DVec3::Z); + let elevation = working_plane.origin.dot(normal); + let storage = crate::entities::curve::ocs_plane( + acadrust::types::Vector3::new(normal.x, normal.y, normal.z), + elevation, + ); + let plane = crate::command::WorkingPlane::new( + glam::DVec3::from_array(storage.origin), + glam::DVec3::from_array(storage.x_axis), + glam::DVec3::from_array(storage.y_axis), + ); let boundary_sources = self.tabs[i] .scene - .boundary_sources_on_plane(crate::command::WorkingPlane::default(), 1.0e-6); + .boundary_sources_on_plane(plane, 1.0e-6); let outlines = crate::scene::boundary_faces(&boundary_sources, 1.0e-6); let selected = self.tabs[i] .scene @@ -652,8 +668,13 @@ impl OpenCADStudio { 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); + let new_cmd = HatchCommand::new( + outlines, + boundary_sources, + selected, + inherited, + plane, + ); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); self.refresh_area_preview(i); @@ -666,21 +687,25 @@ impl OpenCADStudio { if sel.len() == 1 { let (h, _) = sel[0]; if let Some(model) = self.tabs[i].scene.hatches.get(&h).cloned() { - let annotative = self.tabs[i] - .scene - .document - .get_entity(h) - .is_some_and(|entity| { + let entity = self.tabs[i].scene.document.get_entity(h); + let annotative = entity.is_some_and(|entity| { crate::scene::annotative::is_annotative( &self.tabs[i].scene.document, entity, ) }); + let (scale, angle) = match entity { + Some(acadrust::EntityType::Hatch(hatch)) => ( + hatch.pattern_scale as f32, + hatch.pattern_angle.to_degrees() as f32, + ), + _ => (model.scale, model.angle_offset.to_degrees()), + }; let cmd = HatcheditCommand::with_handle( h, model.name.clone(), - model.scale, - model.angle_offset.to_degrees(), + scale, + angle, annotative, ); self.command_line.push_info(&cmd.prompt()); diff --git a/src/app/properties.rs b/src/app/properties.rs index 8cd075ba..e66ed025 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2057,9 +2057,7 @@ pub(super) fn aggregate_sections( for sections in all_sections { result = merge_sections(&result, §ions); } - // Unlike ordinary common properties, cumulative area is an aggregate by - // definition. Preserve the individual Area row's "varies" state while - // summing every selected hatch's actual filled area (holes subtracted). + // Sum the filled area while individual Area rows may still vary. if selected.len() > 1 && selected .iter() diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 8c8c75b7..ef0015f4 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3490,23 +3490,27 @@ impl OpenCADStudio { .unwrap_or(false) { if let Some(model) = self.tabs[i].scene.hatches.get(&handle).cloned() { - let annotative = self.tabs[i] - .scene - .document - .get_entity(handle) - .is_some_and(|entity| { + let entity = self.tabs[i].scene.document.get_entity(handle); + let annotative = entity.is_some_and(|entity| { crate::scene::annotative::is_annotative( &self.tabs[i].scene.document, entity, ) }); + let (scale, angle) = match entity { + Some(acadrust::EntityType::Hatch(hatch)) => ( + hatch.pattern_scale as f32, + hatch.pattern_angle.to_degrees() as f32, + ), + _ => (model.scale, model.angle_offset.to_degrees()), + }; use crate::command::CadCommand; use crate::modules::draw::draw::hatchedit::HatcheditCommand; let cmd: Box = Box::new(HatcheditCommand::with_handle( handle, model.name.clone(), - model.scale, - model.angle_offset.to_degrees(), + scale, + angle, annotative, )); self.command_line.push_info(&cmd.prompt()); diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index e06decf4..05036ee3 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -14,42 +14,19 @@ use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Pr use crate::scene::convert::tess_util::FallbackGeometry; use crate::scene::model::wire_model::SnapHint; -/// The area the hatch's boundary paths enclose. -/// -/// Summed edge by edge through the kernel, which measures what each edge -/// actually encloses rather than what a polygon through some of its points -/// would. The version this replaced pushed an arc's *centre* into the ring -/// and a spline's control points — neither of which is on the boundary — so -/// the number it produced was not the area of anything. -/// -/// Outer paths and their holes both contribute; the sign of a loop says -/// which it is, so the magnitude of the sum is the region's own area. +/// The area enclosed by the hatch boundary paths. pub(crate) fn boundary_area(h: &Hatch) -> f64 { let mut path_areas = Vec::new(); let mut rings = Vec::new(); for path in &h.paths { let mut path_area = 0.0; - let mut ends: Vec<[f64; 2]> = Vec::new(); - let mut ring = Vec::new(); - for edge in &path.edges { - let Some(curve) = edge_curve(edge) else { - continue; - }; - path_area += curve.enclosed_area(); - ends.push(curve.point_at(0.0)); - ends.push(curve.point_at(1.0)); - let tessellated = curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE); - let skip = usize::from(!ring.is_empty()); - ring.extend(tessellated.into_iter().skip(skip)); - } - // Edges are stored as separate pieces, so the chain has to be closed - // by the chord from the last end back to the first — the same closing - // an open polyline gets. - if let (Some(first), Some(last)) = (ends.first(), ends.last()) { - path_area += 0.5 * (last[0] * first[1] - first[0] * last[1]); + let directions = crate::scene::hatch_path_directions(path); + let curves = path.edges.iter().filter_map(edge_curve); + for (curve, direction) in curves.zip(directions) { + path_area += direction * curve.enclosed_area(); } path_areas.push(path_area.abs()); - rings.push(ring); + rings.push(crate::scene::hatch_path_ring(path).unwrap_or_default()); } let depths = cadkernel::geom2d::ring_nesting_depths(&rings); path_areas @@ -70,7 +47,7 @@ pub(crate) fn boundary_area(h: &Hatch) -> f64 { .abs() } -/// A hatch boundary edge as a kernel curve, in the hatch's own OCS. +/// A hatch boundary edge as a kernel curve in the hatch OCS. pub(crate) fn edge_curve(edge: &BoundaryEdge) -> Option { Some(match edge { BoundaryEdge::Line(l) => KernelCurve::Line(KernelLine { @@ -483,9 +460,7 @@ fn properties(h: &Hatch) -> Vec { // ── Hatch (pattern / solid) ──────────────────────────────────────────── - // Pattern-specific rows are conditional: scale belongs to catalog/custom - // definitions, while spacing and double belong to user-defined hatches. - // A solid fill does not expose inert pattern controls. + // Show only controls used by the selected fill type. let type_row = if h.is_solid { ro(t!("Type").as_ref(), "fill_kind", t!("Solid").into_owned()) } else { @@ -538,9 +513,7 @@ fn properties(h: &Hatch) -> Vec { pattern_props.push(double_row); } else { pattern_props.push(edit(t!("Scale").as_ref(), "pattern_scale", h.pattern_scale)); - // Project convention: these fields are relative offsets, therefore - // they read zero after every committed move instead of leaking the - // absolute base point of the first stored pattern line. + // Origin edits are relative offsets. pattern_props.push(edit(t!("Origin X").as_ref(), "origin_x", 0.0)); pattern_props.push(edit(t!("Origin Y").as_ref(), "origin_y", 0.0)); if h.pattern.name.to_ascii_uppercase().starts_with("ISO") { @@ -645,9 +618,7 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { h.is_solid = false; match requested { HatchPatternType::UserDefined => { - // User-defined geometry is derived from angle, spacing - // and the Double flag; stale catalog lines would make - // the renderer treat it as a prebaked definition. + // Rebuild user-defined geometry from its parameters. h.pattern = acadrust::entities::HatchPattern::new("_USER"); } HatchPatternType::Predefined => { @@ -776,13 +747,7 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { fn apply_transform(h: &mut Hatch, t: &EntityTransform) { crate::scene::view::transform::apply_standard_entity_transform(h, t, |entity, p1, p2| { - // Delegate the mirror to acadrust's transform_hatch (via the Entity - // trait): it flips the boundary-arc direction flags, re-mirrors the - // stored angles and preserves the stored sweep — including the - // wrap-encoded end angles above 2π that AutoCAD writes. The old - // hand-rolled angle-swap here was only valid for ccw boundary arcs on - // an axis-aligned mirror line and went stale the moment those - // conventions were fixed upstream. + // Keep boundary directions, angles, and sweeps consistent. let t = crate::scene::view::transform::reflection_about_xy_line(p1, p2); acadrust::entities::Entity::apply_transform(entity, &t); }); @@ -832,9 +797,7 @@ impl Grippable for Hatch { id += 1; } } - // Associative boundaries are edited through their source objects. A - // hatch therefore exposes only its circular pattern control instead - // of a second, conflicting set of boundary vertices. + // Edit associative boundaries through their source objects. if self.is_associative { return out; } diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index ead0b1e2..528ea46f 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -1,13 +1,4 @@ -// Hatch/Gradient/Boundary commands — OpenCADStudio Home > Draw > Hatch dropdown. -// -// Commands: -// HATCH — ANSI31: 45° hatch lines (pick inside or type S for manual) -// GRADIENT — Linear gradient fill (pick inside or type S for manual) -// BOUNDARY — Traces the enclosing boundary as a closed LwPolyline -// -// Primary workflow (matches OpenCADStudio): -// Click a point INSIDE a closed region → boundary auto-detected. -// Type "S" to switch to manual vertex-picking mode (HATCH/GRADIENT only). +// Hatch, gradient, and boundary commands. use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::IconKind; @@ -15,7 +6,7 @@ use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily}; use crate::scene::model::wire_model::WireModel; use acadrust::Handle; use cadkernel::geom2d::{ - bounded_faces, contains, ring_nesting_depths, signed_area, Curve, Line, Tolerance, + bounded_faces, contains, ring_nesting_depths, signed_area, Circle, Curve, Line, Tolerance, }; use glam::DVec3; use crate::t; @@ -110,17 +101,7 @@ fn polygon_contains_polygon(outer: &[[f64; 2]], inner: &[[f64; 2]]) -> bool { inner.iter().all(|&v| point_in_polygon(v, outer)) } -/// Resolve the hatch boundary for a "pick inside" click. -/// -/// The outer ring is the *smallest* outline containing the click point — the -/// innermost region the point belongs to. Its holes are that ring's **direct -/// children**: outlines nested one level inside it with no other outline in -/// between. Deeper (grandchild) outlines belong to those children's own fills, -/// so they are left out — otherwise even-odd rasterisation would flip the -/// innermost island back on for 3+ nesting levels. The result is intuitive and -/// draw-order independent: -/// * click inside the innermost shape → hatch just that shape, -/// * click in a gap → hatch that ring, with the next level in as holes. +/// Resolve the innermost clicked ring and its direct holes. fn resolve_hatch_rings( outlines: &[Vec<[f64; 2]>], p: [f64; 2], @@ -184,12 +165,7 @@ fn pack_rings(rings: &[Vec<[f64; 2]>]) -> (Vec<[f32; 2]>, [f64; 2], Vec<[f64; 2] (rel, origin, wcs) } -/// Split an absolute boundary into the `(f32 offsets, f64 origin)` pair that -/// `HatchModel` expects: the origin anchors on the first vertex in full f64 so a -/// typed coordinate (issue #311) and large/UTM positions keep their precision, -/// and `add_hatch` reconstructs each WCS vertex as `origin + offset`. A zero -/// origin with absolute f32 offsets — the previous command output — quantized -/// typed points and mis-placed the fill at large coordinates. +/// Store boundary points as precise-origin-relative offsets. fn rte_boundary(pts: impl Iterator) -> (Vec<[f32; 2]>, [f64; 2]) { let pts: Vec<(f64, f64)> = pts.collect(); let Some(&(ox, oy)) = pts.first() else { @@ -228,6 +204,7 @@ pub struct HatchCommand { acadrust::types::Color, acadrust::types::Transparency, )>, + plane: WorkingPlane, } impl HatchCommand { @@ -240,6 +217,7 @@ impl HatchCommand { acadrust::types::Color, acadrust::types::Transparency, )>, + plane: WorkingPlane, ) -> Self { let selected_objects: Vec<_> = selected_objects .into_iter() @@ -273,6 +251,7 @@ impl HatchCommand { .map(|(model, _, _)| model.style) .unwrap_or(acadrust::entities::HatchStyleType::Normal), inherited, + plane, }; command.set_object_selection(selected_objects); command @@ -332,7 +311,30 @@ impl HatchCommand { } fn make_hatch(&self, rings: Vec>) -> HatchModel { - let (rel, origin, wcs) = pack_rings(&rings); + let world_rings: Vec> = rings + .iter() + .map(|ring| { + ring.iter() + .map(|&[x, y]| { + let point = self.plane.to_world(DVec3::new(x, y, 0.0)); + [point.x, point.y] + }) + .collect() + }) + .collect(); + let (rel, origin, wcs) = pack_rings(&world_rings); + let mut local_boundary = Vec::new(); + for (index, ring) in rings.iter().enumerate() { + if index != 0 { + local_boundary.push([f32::NAN, f32::NAN]); + } + local_boundary.extend(ring.iter().map(|&[x, y]| [x as f32, y as f32])); + } + let fill_plane = crate::scene::model::hatch_model::FillPlane { + origin: self.plane.origin.to_array(), + x_axis: self.plane.x.to_array(), + y_axis: self.plane.y.to_array(), + }; let exterior: Vec = cadkernel::geom2d::ring_nesting_depths(&rings) .into_iter() .map(|depth| depth == 0) @@ -353,6 +355,7 @@ impl HatchCommand { } for path in &mut boundary_paths { path.boundary_handles.clear(); + path.flags.set_external(false); } } if let Some((source, _, _)) = &self.inherited { @@ -391,8 +394,8 @@ impl HatchCommand { scale, world_origin: origin, boundary_wcs: Some(std::sync::Arc::new(wcs)), - fill_plane: None, - fill_plane_boundary: None, + fill_plane: Some(fill_plane), + fill_plane_boundary: Some(std::sync::Arc::new(local_boundary)), boundary_exterior: Some(std::sync::Arc::new(exterior)), boundary_sources: Some(std::sync::Arc::new(boundary_sources)), boundary_paths: Some(std::sync::Arc::new(boundary_paths)), @@ -438,8 +441,8 @@ impl HatchCommand { scale: self.scale_override.unwrap_or(1.0).max(1.0e-6), world_origin: origin, boundary_wcs: Some(std::sync::Arc::new(wcs)), - fill_plane: None, - fill_plane_boundary: None, + fill_plane: Some(fill_plane), + fill_plane_boundary: Some(std::sync::Arc::new(local_boundary)), boundary_exterior: Some(std::sync::Arc::new(exterior)), boundary_sources: Some(std::sync::Arc::new(boundary_sources)), boundary_paths: Some(std::sync::Arc::new(boundary_paths)), @@ -476,32 +479,27 @@ impl HatchCommand { } fn arc_bulge(start: DVec3, middle: DVec3, end: DVec3) -> Option { - let d = 2.0 - * (start.x * (middle.y - end.y) - + middle.x * (end.y - start.y) - + end.x * (start.y - middle.y)); - if d.abs() <= 1.0e-12 { + let curvature = DVec3::from_array(cadkernel::space::curve::curvature_through( + start.to_array(), + middle.to_array(), + end.to_array(), + )); + let squared = curvature.length_squared(); + if squared <= f64::MIN_POSITIVE { return None; } - let s2 = start.x * start.x + start.y * start.y; - let m2 = middle.x * middle.x + middle.y * middle.y; - let e2 = end.x * end.x + end.y * end.y; - let center_x = (s2 * (middle.y - end.y) - + m2 * (end.y - start.y) - + e2 * (start.y - middle.y)) - / d; - let center_y = (s2 * (end.x - middle.x) - + m2 * (start.x - end.x) - + e2 * (middle.x - start.x)) - / d; - let angle = |point: DVec3| (point.y - center_y).atan2(point.x - center_x); - let first = angle(start); - let through = (angle(middle) - first).rem_euclid(std::f64::consts::TAU); - let ccw = (angle(end) - first).rem_euclid(std::f64::consts::TAU); + let centre = start + curvature / squared; + let circle = Curve::Circle(Circle { + centre: [centre.x, centre.y], + radius: squared.sqrt().recip(), + }); + let first = circle.parameter_at([start.x, start.y]); + let through = (circle.parameter_at([middle.x, middle.y]) - first).rem_euclid(1.0); + let ccw = (circle.parameter_at([end.x, end.y]) - first).rem_euclid(1.0); let sweep = if through <= ccw + 1.0e-12 { - ccw + ccw * std::f64::consts::TAU } else { - ccw - std::f64::consts::TAU + (ccw - 1.0) * std::f64::consts::TAU }; Some((sweep * 0.25).tan()) } @@ -631,6 +629,7 @@ impl CadCommand for HatchCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + let pt = self.plane.to_local(pt); match &self.mode { HatchMode::PickInside => { let xy = [pt.x, pt.y]; @@ -667,6 +666,9 @@ impl CadCommand for HatchCommand { } fn on_enter(&mut self) -> CmdResult { + if matches!(self.mode, HatchMode::Manual) && self.manual_arc_midpoint.is_some() { + return CmdResult::NeedPoint; + } 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]); @@ -706,7 +708,12 @@ impl CadCommand for HatchCommand { } else if self.retain_boundaries { CmdResult::CommitHatchWithBoundaries { hatch: self.make_hatch(rings.clone()), - boundaries: crate::scene::boundary_entities(&rings), + boundaries: crate::scene::boundary_entities_from_sources( + &rings, + self.plane, + &self.boundary_sources, + 1.0e-6, + ), entity_style: self .inherited .as_ref() @@ -798,6 +805,10 @@ impl CadCommand for HatchCommand { _ => None, }; } + if upper == "ASSOCIATIVE" { + self.associative = !self.associative; + return Some(CmdResult::NeedPoint); + } if let Some(rest) = upper.strip_prefix('P') { let name = rest.trim(); if !name.is_empty() { @@ -839,6 +850,9 @@ impl CadCommand for HatchCommand { } "B" | "BOUNDARY" | "BOUNDARIES" => { self.retain_boundaries = !self.retain_boundaries; + if self.retain_boundaries { + self.separate_hatches = false; + } Some(CmdResult::NeedPoint) } "N" | "ASSOCIATIVE" => { @@ -847,6 +861,9 @@ impl CadCommand for HatchCommand { } "D" | "SEPARATE" => { self.separate_hatches = !self.separate_hatches; + if self.separate_hatches { + self.retain_boundaries = false; + } Some(CmdResult::NeedPoint) } "Y" | "ISLAND" => { @@ -875,14 +892,10 @@ impl CadCommand for HatchCommand { let mut pts: Vec<[f32; 3]> = self .manual_pts .iter() - .map(|p| [p.x as f32, p.y as f32, p.z as f32]) + .map(|&p| self.plane.to_world(p).as_vec3().to_array()) .collect(); - pts.push([pt.x, pt.y, pt.z]); - pts.push([ - self.manual_pts[0].x as f32, - self.manual_pts[0].y as f32, - self.manual_pts[0].z as f32, - ]); + pts.push(pt.to_array()); + pts.push(self.plane.to_world(self.manual_pts[0]).as_vec3().to_array()); return Some(WireModel::solid( "rubber_band".into(), pts, diff --git a/src/modules/draw/draw/hatchedit.rs b/src/modules/draw/draw/hatchedit.rs index e4add5da..d88ed407 100644 --- a/src/modules/draw/draw/hatchedit.rs +++ b/src/modules/draw/draw/hatchedit.rs @@ -175,6 +175,14 @@ impl CadCommand for HatcheditCommand { return self.apply_result(self.update_operation()); } + if text == "ANNOTATIVE" { + self.annotative = Some(!self.annotative.unwrap_or(self.annotative_current)); + return Some(CmdResult::NeedPoint); + } + if text == "SEPARATE" { + return self.apply_result(HatchEditOperation::Separate); + } + // Parse option: P/S/A followed by value if let Some(rest) = text.strip_prefix('P') { let n = rest.trim().to_string(); diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index 697d38de..020ba2e7 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -1,8 +1,9 @@ use super::*; use cadkernel::geom2d::{ - bounded_faces, contains, distance_to, intersect, segment_crossing, triangulate, Curve, Line, - SegmentCrossing, Tolerance, Transform as CurveTransform, + bounded_faces, closest_point, contains, distance_to, intersect, ring_nesting_depths, + segment_crossing, signed_area, triangulate, Curve, Line, SegmentCrossing, Tolerance, + Transform as CurveTransform, }; use crate::command::WorkingPlane; @@ -13,12 +14,7 @@ pub struct BoundarySource { pub curves: Vec, } -/// How far apart two points may be and still be taken for the same one. -/// -/// The boundary search runs on already-tessellated wire geometry, so the -/// input is a chord approximation of the drawn curves to begin with; this -/// only has to be coarse enough to close the gaps that leaves and fine -/// enough not to weld genuinely separate corners together. +/// Boundary welding tolerance for tessellated wires. const WELD_TOLERANCE: f64 = 1.0e-6; fn wire_segments_on_plane( @@ -188,6 +184,19 @@ fn curve_forward(curve: &Curve, start: [f64; 2], next: [f64; 2]) -> bool { delta >= 0.0 } +fn stored_arc_angles(start: f64, end: f64, counter_clockwise: bool, whole: bool) -> (f64, f64) { + let stored_start = if counter_clockwise { start } else { -start } + .rem_euclid(std::f64::consts::TAU); + let sweep = if whole { + std::f64::consts::TAU + } else if counter_clockwise { + (end - start).rem_euclid(std::f64::consts::TAU) + } else { + (start - end).rem_euclid(std::f64::consts::TAU) + }; + (stored_start, stored_start + sweep) +} + fn exact_boundary_edge( curve: Option<&Curve>, start: [f64; 2], @@ -213,11 +222,10 @@ fn exact_boundary_edge( end: Vector2::new(end[0], end[1]), }), Curve::Circle(circle) => { - let start_angle = (start[1] - circle.centre[1]).atan2(start[0] - circle.centre[0]); - let mut end_angle = (end[1] - circle.centre[1]).atan2(end[0] - circle.centre[0]); - if whole_curve { - end_angle = start_angle + std::f64::consts::TAU; - } + let true_start = (start[1] - circle.centre[1]).atan2(start[0] - circle.centre[0]); + let true_end = (end[1] - circle.centre[1]).atan2(end[0] - circle.centre[0]); + let (start_angle, end_angle) = + stored_arc_angles(true_start, true_end, forward, whole_curve); BoundaryEdge::CircularArc(CircularArcEdge { center: Vector2::new(circle.centre[0], circle.centre[1]), radius: circle.radius, @@ -227,8 +235,10 @@ fn exact_boundary_edge( }) } Curve::Arc(arc) => { - let start_angle = (start[1] - arc.centre[1]).atan2(start[0] - arc.centre[0]); - let end_angle = (end[1] - arc.centre[1]).atan2(end[0] - arc.centre[0]); + let true_start = (start[1] - arc.centre[1]).atan2(start[0] - arc.centre[0]); + let true_end = (end[1] - arc.centre[1]).atan2(end[0] - arc.centre[0]); + let (start_angle, end_angle) = + stored_arc_angles(true_start, true_end, forward, whole_curve); BoundaryEdge::CircularArc(CircularArcEdge { center: Vector2::new(arc.centre[0], arc.centre[1]), radius: arc.radius, @@ -239,14 +249,10 @@ fn exact_boundary_edge( } Curve::Ellipse(arc) => { let ellipse = arc.ellipse; - let mut start_parameter = arc.start_parameter + curve.parameter_at(start) * arc.sweep(); - let mut end_parameter = arc.start_parameter + curve.parameter_at(end) * arc.sweep(); - if !forward { - std::mem::swap(&mut start_parameter, &mut end_parameter); - } - if whole_curve { - end_parameter = start_parameter + std::f64::consts::TAU; - } + let true_start = arc.start_parameter + curve.parameter_at(start) * arc.sweep(); + let true_end = arc.start_parameter + curve.parameter_at(end) * arc.sweep(); + let (start_parameter, end_parameter) = + stored_arc_angles(true_start, true_end, forward, whole_curve); BoundaryEdge::EllipticArc(EllipticArcEdge { center: Vector2::new(ellipse.centre[0], ellipse.centre[1]), major_axis_endpoint: Vector2::new( @@ -264,8 +270,13 @@ fn exact_boundary_edge( Some(if forward { source.clone() } else { source.reversed() }) } else { source.trimmed(source.parameter_at(start), source.parameter_at(end)) - } - .unwrap_or_else(|| source.clone()); + }; + let Some(trimmed) = trimmed else { + return BoundaryEdge::Line(LineEdge { + start: Vector2::new(start[0], start[1]), + end: Vector2::new(end[0], end[1]), + }); + }; let rational = trimmed.is_rational(); BoundaryEdge::Spline(SplineEdge { degree: trimmed.degree() as i32, @@ -294,9 +305,7 @@ fn exact_boundary_edge( } } -/// Rebuild detected tessellated rings as analytic hatch paths wherever their -/// source entity exposes an exact curve. Intersections remain the graph's -/// vertices, while the edge between them is stored as a trimmed source curve. +/// Rebuild detected rings from exact source curves. pub(crate) fn exact_hatch_paths( rings: &[Vec<[f64; 2]>], exterior: &[bool], @@ -371,7 +380,10 @@ pub(crate) fn exact_hatch_paths( .collect() } -pub(crate) fn boundary_entities(rings: &[Vec<[f64; 2]>]) -> Vec { +pub(crate) fn boundary_entities( + rings: &[Vec<[f64; 2]>], + plane: WorkingPlane, +) -> Vec { rings .iter() .filter_map(|ring| { @@ -397,7 +409,7 @@ pub(crate) fn boundary_entities(rings: &[Vec<[f64; 2]>]) -> Vec Op } fn project_to_curve(curve: &Curve, point: [f64; 2]) -> [f64; 2] { - curve.point_at(curve.parameter_at(point)) + closest_point(curve, point).point } fn refined_boundary_ring( @@ -571,6 +583,18 @@ pub(crate) fn boundary_polyline_entities( .collect() } +pub(crate) fn boundary_entities_from_sources( + rings: &[Vec<[f64; 2]>], + plane: WorkingPlane, + sources: &rustc_hash::FxHashMap, + tolerance: f64, +) -> Vec { + rings + .iter() + .filter_map(|ring| boundary_polyline(ring, plane, sources, tolerance)) + .collect() +} + impl Scene { pub(crate) fn edit_hatch_boundary_handles( &mut self, @@ -593,6 +617,11 @@ impl Scene { } else { path.boundary_handles.retain(|handle| !handles.contains(handle)); } + if path.boundary_handles.is_empty() { + path.flags.set_external(false); + } else { + path.flags.set_external(true); + } hatch.is_associative = hatch .paths .iter() @@ -693,6 +722,9 @@ impl Scene { let old_count = path.boundary_handles.len(); path.boundary_handles .retain(|source| self.document.get_entity(*source).is_some()); + if path.boundary_handles.is_empty() { + path.flags.set_external(false); + } association_changed |= path.boundary_handles.len() != old_count; modified |= association_changed; let sources: rustc_hash::FxHashMap<_, _> = path @@ -801,6 +833,82 @@ impl Scene { } } +fn hatch_path_geometry( + path: &acadrust::entities::BoundaryPath, +) -> (Vec<[f64; 2]>, Vec) { + let edges: Vec<_> = path + .edges + .iter() + .filter_map(crate::entities::hatch::edge_curve) + .map(|curve| curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)) + .collect(); + super::entity::chain_path_edges_with_directions(edges) +} + +pub(crate) fn hatch_path_ring(path: &acadrust::entities::BoundaryPath) -> Option> { + let (ring, _) = hatch_path_geometry(path); + (ring.len() >= 3).then_some(ring) +} + +pub(crate) fn hatch_path_directions(path: &acadrust::entities::BoundaryPath) -> Vec { + hatch_path_geometry(path).1 +} + +pub(crate) fn hatch_boundary_rings(hatch: &acadrust::entities::Hatch) -> Vec> { + hatch.paths.iter().filter_map(hatch_path_ring).collect() +} + +pub(crate) fn separated_hatch_path_groups( + hatch: &acadrust::entities::Hatch, +) -> Vec> { + let items: Vec<_> = hatch + .paths + .iter() + .filter_map(|path| hatch_path_ring(path).map(|ring| (path.clone(), ring))) + .collect(); + let rings: Vec<_> = items.iter().map(|(_, ring)| ring.clone()).collect(); + let depths = ring_nesting_depths(&rings); + let outer_indices: Vec<_> = depths + .iter() + .enumerate() + .filter_map(|(index, depth)| (*depth == 0).then_some(index)) + .collect(); + let mut groups: Vec<_> = outer_indices + .iter() + .map(|index| vec![items[*index].0.clone()]) + .collect(); + for (index, (path, ring)) in items.iter().enumerate() { + if depths.get(index) == Some(&0) { + continue; + } + let Some(seed) = ring.first().copied() else { + continue; + }; + let owner = outer_indices + .iter() + .enumerate() + .filter(|(_, outer)| { + contains( + &face_curves(&rings[**outer]), + seed, + Tolerance::new(WELD_TOLERANCE), + ) + }) + .min_by(|(_, left), (_, right)| { + signed_area(&rings[**left]) + .abs() + .total_cmp(&signed_area(&rings[**right]).abs()) + }) + .map(|(group, _)| group); + if let Some(owner) = owner { + groups[owner].push(path.clone()); + } else { + groups.push(vec![path.clone()]); + } + } + groups +} + pub(crate) fn boundary_faces( sources: &rustc_hash::FxHashMap, tolerance: f64, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index a41363d5..b219f376 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1,41 +1,33 @@ // 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` are filled in by the caller (from the stored -/// `base_point`, relative to `world_origin`) once the boundary anchor is known; -/// they set the pattern origin, observable for dashed / offset patterns. -/// Order a hatch boundary path's sampled edges into one tip-to-tail loop. -/// -/// Real files do not store boundary edges as a sequential walk: associative -/// hatches list them in boundary-source-entity order, with arbitrary -/// direction — the next edge in the list may attach to either end of the -/// chain built so far, or belong to the far side of the loop entirely. -/// Concatenating them verbatim draws a self-crossing "bowtie" outline and -/// flips the even-odd fill over the wrong region. -/// -/// Greedy nearest-endpoint assembly: keep the chain open at both ends and, at -/// each step, attach the unused edge whose endpoint lies closest to either -/// end (reversing / prepending as needed). Distance comparison, no tolerance: -/// a correctly-ordered file matches at distance 0 and reproduces exactly. -fn chain_path_edges(mut polys: Vec>) -> Vec<[f64; 2]> { +/// Order sampled boundary edges into one tip-to-tail loop. +pub(super) fn chain_path_edges(polys: Vec>) -> Vec<[f64; 2]> { + chain_path_edges_with_directions(polys).0 +} + +pub(super) fn chain_path_edges_with_directions( + polys: Vec>, +) -> (Vec<[f64; 2]>, Vec) { let d2 = |a: [f64; 2], b: [f64; 2]| (a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2); - polys.retain(|p| !p.is_empty()); + let mut directions = vec![0.0; polys.len()]; + let mut polys: Vec<_> = polys + .into_iter() + .enumerate() + .filter(|(_, points)| !points.is_empty()) + .collect(); if polys.is_empty() { - return Vec::new(); + return (Vec::new(), directions); } - let mut chain: std::collections::VecDeque<[f64; 2]> = polys.swap_remove(0).into(); + let (first_index, first) = polys.swap_remove(0); + directions[first_index] = 1.0; + let mut chain: std::collections::VecDeque<[f64; 2]> = first.into(); while !polys.is_empty() { let head = *chain.front().unwrap(); let tail = *chain.back().unwrap(); // (distance, index, reverse-points, attach-at-front) let mut best = (f64::MAX, 0usize, false, false); - for (i, p) in polys.iter().enumerate() { + for (i, (_, p)) in polys.iter().enumerate() { let s = p[0]; let e = *p.last().unwrap(); for c in [ @@ -50,7 +42,8 @@ fn chain_path_edges(mut polys: Vec>) -> Vec<[f64; 2]> { } } let (_, idx, rev, at_front) = best; - let mut p = polys.swap_remove(idx); + let (original_index, mut p) = polys.swap_remove(idx); + directions[original_index] = if rev { -1.0 } else { 1.0 }; if rev { p.reverse(); } @@ -71,7 +64,7 @@ fn chain_path_edges(mut polys: Vec>) -> Vec<[f64; 2]> { chain.extend(it); } } - chain.into() + (chain.into(), directions) } fn family_from_stored_line( @@ -1622,6 +1615,7 @@ impl Scene { } let mut rings = Vec::new(); + let mut local_rings = Vec::new(); let mut ring_sources = Vec::new(); for path in &dxf.paths { @@ -1636,26 +1630,27 @@ impl Scene { for edge in &path.edges { if let Some(curve) = crate::entities::hatch::edge_curve(edge) { edge_polys.push( - curve - .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) - .into_iter() - .map(|point| to_xy(point[0], point[1])) - .collect(), + curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE), ); } } - let mut ring = chain_path_edges(edge_polys); - if ring.is_empty() { + let mut local_ring = chain_path_edges(edge_polys); + if local_ring.is_empty() { continue; } - if ring.len() >= 3 { - let first = ring[0]; - let last = *ring.last().unwrap(); + if local_ring.len() >= 3 { + let first = local_ring[0]; + let last = *local_ring.last().unwrap(); if (first[0] - last[0]).abs() > 1e-5 || (first[1] - last[1]).abs() > 1e-5 { - ring.push(first); + local_ring.push(first); } } + let ring = local_ring + .iter() + .map(|point| to_xy(point[0], point[1])) + .collect(); rings.push(ring); + local_rings.push(local_ring); ring_sources.push(path.boundary_handles.clone()); } @@ -1665,9 +1660,15 @@ impl Scene { let depths = cadkernel::geom2d::ring_nesting_depths(&rings); let mut boundary = Vec::new(); + let mut local_boundary = Vec::new(); let mut boundary_exterior = Vec::new(); let mut boundary_sources = Vec::new(); - for ((ring, sources), depth) in rings.into_iter().zip(ring_sources).zip(depths) { + for (((ring, local_ring), sources), depth) in rings + .into_iter() + .zip(local_rings) + .zip(ring_sources) + .zip(depths) + { let keep = match dxf.style { acadrust::entities::HatchStyleType::Normal => true, acadrust::entities::HatchStyleType::Outer => depth <= 1, @@ -1678,8 +1679,14 @@ impl Scene { } if !boundary.is_empty() { boundary.push([f64::NAN, f64::NAN]); + local_boundary.push([f32::NAN, f32::NAN]); } boundary.extend(ring); + local_boundary.extend( + local_ring + .into_iter() + .map(|[x, y]| [x as f32, y as f32]), + ); boundary_exterior.push(depth == 0); boundary_sources.push(sources); } @@ -1879,12 +1886,17 @@ impl Scene { }) .collect(); + let storage = crate::entities::curve::ocs_plane(dxf.normal, dxf.elevation); Some(HatchModel { render_instance: None, boundary: std::sync::Arc::new(boundary_f32), boundary_wcs: None, - fill_plane: None, - fill_plane_boundary: None, + fill_plane: Some(model::hatch_model::FillPlane { + origin: storage.origin, + x_axis: storage.x_axis, + y_axis: storage.y_axis, + }), + fill_plane_boundary: Some(std::sync::Arc::new(local_boundary)), boundary_exterior: Some(std::sync::Arc::new(boundary_exterior)), boundary_sources: Some(std::sync::Arc::new(boundary_sources)), boundary_paths: Some(std::sync::Arc::new(dxf.paths.clone())), @@ -2229,18 +2241,22 @@ impl Scene { ) -> Handle { let mut dxf = DxfHatch::new(); dxf.style = model.style; + if let Some(plane) = model.fill_plane { + let x = glam::DVec3::from_array(plane.x_axis); + let y = glam::DVec3::from_array(plane.y_axis); + let normal = x.cross(y).normalize_or(glam::DVec3::Z); + dxf.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z); + dxf.elevation = glam::DVec3::from_array(plane.origin).dot(normal); + } dxf.is_solid = matches!( model.pattern, crate::scene::model::hatch_model::HatchPattern::Solid ); - // Keep analytic command geometry when available. The tessellated model - // remains the render representation only; it must not replace circles, - // ellipse arcs or splines in the persisted entity. + // Persist analytic command geometry when available. if let Some(paths) = model.boundary_paths.as_deref() { dxf.paths = paths.clone(); } else { - // Otherwise reconstruct every ring from the render offsets without - // dropping its separators. + // Otherwise reconstruct every ring from render offsets. let reconstructed_wcs: Vec<[f64; 2]> = if model.boundary_wcs.is_none() { let [wx, wy] = model.world_origin; model @@ -2328,6 +2344,16 @@ impl Scene { } else { 1.0 }; + let pattern_origin = model + .fill_plane_boundary + .as_deref() + .and_then(|points| { + points + .iter() + .find(|point| point[0].is_finite() && point[1].is_finite()) + }) + .map(|point| [point[0] as f64, point[1] as f64]) + .unwrap_or(model.world_origin); 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; @@ -2345,10 +2371,10 @@ impl Scene { pattern.lines.push(acadrust::entities::HatchPatternLine { angle, base_point: Vector2::new( - model.world_origin[0] + pattern_origin[0] + base_x * rotation_cos - base_y * rotation_sin, - model.world_origin[1] + pattern_origin[1] + base_x * rotation_sin + base_y * rotation_cos, ), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e587815c..f7281c5a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -35,8 +35,9 @@ mod scene_markers; mod selection; pub(crate) use boundary::{ - boundary_entities, boundary_faces, boundary_polyline_entities, exact_hatch_paths, - ring_source_handles, BoundarySource, + boundary_entities, boundary_entities_from_sources, boundary_faces, + boundary_polyline_entities, exact_hatch_paths, hatch_boundary_rings, hatch_path_directions, + hatch_path_ring, ring_source_handles, separated_hatch_path_groups, BoundarySource, }; // Parallel tessellation free functions live in `convert::tess` (alongside the diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 06484729..f0f13c93 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -203,17 +203,14 @@ pub struct HatchModel { /// rebuilt from a DXF entity — `add_hatch` then reconstructs the persisted /// vertices from `boundary` + `world_origin` instead. pub boundary_wcs: Option>>, - /// Optional 3-D placement used by wipeout fills. + /// Optional 3-D placement for planar fills. pub fill_plane: Option, pub fill_plane_boundary: 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>>>, - /// Exact persisted boundary paths for draw/edit workflows. Rendering keeps - /// using the compact tessellated boundary above, while persistence can - /// retain analytic arcs, ellipses and splines without rebuilding them as - /// straight polyline chords. + /// Exact boundary paths retained for persistence and editing. pub boundary_paths: Option>>, /// Island handling used by the persisted hatch entity. pub style: acadrust::entities::HatchStyleType,