diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1b4880868..1245fe716 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 2decde5dd..0cd85a890 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 8cd075bad..e66ed025d 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 8c8c75b74..ef0015f45 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 e06decf4b..05036ee30 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 ead0b1e2c..528ea46fc 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 e4add5dab..d88ed407d 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 697d38def..020ba2e7e 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 a41363d56..b219f3766 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 e587815c3..f7281c5a5 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 064847293..f0f13c93b 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,