From 22059e138c6b6388948722251dc1c9745890fa9e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 8 Jul 2026 22:48:33 +0300 Subject: [PATCH] feat(hatch): editable pattern props + origin grip Merge duplicate Type into pattern_type; Type/Associative/Island detection as dropdown/toggle; Angle/Scale/Spacing/Origin X-Y editable; Color via picker. Reorder Pattern group, drop redundant Color/Transparency/Layer override and the Misc section. Wire the pattern origin (per-line base_point) into the render (x0/y0 relative to world_origin) so it affects dashed/offset patterns; add a circle origin grip at the hatch centroid (new GripShape::Circle). Co-Authored-By: Claude Opus 4.8 --- src/entities/common.rs | 12 ++ src/entities/hatch.rs | 271 ++++++++++++++++++++++++++++++-------- src/scene/entity.rs | 23 +++- src/scene/model/object.rs | 1 + src/ui/overlay.rs | 1 + 5 files changed, 247 insertions(+), 61 deletions(-) diff --git a/src/entities/common.rs b/src/entities/common.rs index 0e337fc8a..36187b5be 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -141,6 +141,18 @@ pub fn center_grip(id: usize, world: glam::DVec3) -> GripDef { } } +/// Circle grip — a round handle, flagged as a whole-object move. Used for +/// special anchors like a hatch's pattern origin. +pub fn circle_grip(id: usize, world: glam::DVec3) -> GripDef { + GripDef { + id, + world, + is_midpoint: true, + shape: GripShape::Circle, + dir: None, + } +} + /// Mid-segment stretch grip oriented along `dir` (the segment's in-plane /// world-XY direction). Drawn as a small rectangle elongated along the /// segment so the affordance reads as "stretch perpendicular". diff --git a/src/entities/hatch.rs b/src/entities/hatch.rs index 7c7f45af9..d2b6b00ad 100644 --- a/src/entities/hatch.rs +++ b/src/entities/hatch.rs @@ -2,7 +2,7 @@ use acadrust::entities::{BoundaryEdge, Hatch}; use glam::Vec3; use crate::command::EntityTransform; -use crate::entities::common::{center_grip, edit_prop as edit, parse_f64, ro_prop as ro}; +use crate::entities::common::{center_grip, circle_grip, edit_prop as edit, parse_f64, ro_prop as ro}; use crate::entities::traits::{FallbackTess, Grippable, PropertyEditable, Transformable}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::convert::tess_util::{arc_segments, arc_signed_span, wire_chord_tol, FallbackGeometry}; @@ -56,6 +56,49 @@ fn boundary_area(h: &Hatch) -> f64 { area } +/// Mean of every boundary edge point (OCS) — the centroid used to place the +/// pattern-origin grip on the hatch body. `None` when there are no boundary +/// points. +fn boundary_centroid(h: &Hatch) -> Option<(f64, f64)> { + let mut sx = 0.0; + let mut sy = 0.0; + let mut n = 0.0; + let mut add = |x: f64, y: f64| { + sx += x; + sy += y; + n += 1.0; + }; + for path in &h.paths { + for edge in &path.edges { + match edge { + BoundaryEdge::Polyline(p) => { + for v in &p.vertices { + add(v.x, v.y); + } + } + BoundaryEdge::Line(l) => { + add(l.start.x, l.start.y); + add(l.end.x, l.end.y); + } + BoundaryEdge::CircularArc(a) => add(a.center.x, a.center.y), + BoundaryEdge::EllipticArc(e) => add(e.center.x, e.center.y), + BoundaryEdge::Spline(s) => { + if !s.fit_points.is_empty() { + for p in &s.fit_points { + add(p.x, p.y); + } + } else { + for p in &s.control_points { + add(p.x, p.y); + } + } + } + } + } + } + (n > 0.0).then(|| (sx / n, sy / n)) +} + /// Format a color for a read-only row (`ByLayer` / `ByBlock` / index / RGB). fn color_str(c: acadrust::types::Color) -> String { c.to_string() @@ -120,58 +163,83 @@ fn properties(h: &Hatch) -> Vec { } // ── Hatch (pattern / solid) ──────────────────────────────────────────── - let hatch_type = if h.is_solid { "Solid" } else { "Pattern" }; + // "Type" = pattern definition source (Predefined / User Defined / Custom). + let type_row = Property { + label: "Type".into(), + 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: "Pattern name".into(), + field: "pattern_name", + value: PropValue::HatchPatternChoice(h.pattern.name.clone()), + }; + let associative_row = Property { + label: "Associative".into(), + field: "associative", + value: PropValue::BoolToggle { + field: "associative", + value: h.is_associative, + }, + }; + let double_row = Property { + label: "Double".into(), + field: "double", + value: PropValue::BoolToggle { + field: "double", + value: h.is_double, + }, + }; + let island_row = Property { + label: "Island detection style".into(), + field: "style", + value: PropValue::Choice { + selected: style.to_string(), + options: vec!["Normal".into(), "Outer".into(), "Ignore".into()], + }, + }; + let spacing_row = edit( + "Spacing", + "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)); + vec![ PropSection { title: "Pattern".into(), props: vec![ - ro("Type", "pattern_type_label", hatch_type), - Property { - label: "Pattern name".into(), - field: "pattern_name", - value: PropValue::HatchPatternChoice(h.pattern.name.clone()), - }, - ro("Color", "color", color_str(h.common.color)), - ro("Background color", "background_color", String::new()), - ro( - "Transparency", - "transparency", - format!("{}%", (h.common.transparency.as_percent() * 100.0).round() as i32), - ), + type_row, + pattern_name_row, + ro("Annotative", "annotative", String::new()), edit("Angle", "pattern_angle", h.pattern_angle.to_degrees()), edit("Scale", "pattern_scale", h.pattern_scale), - ro("Origin X", "origin_x", String::new()), - ro("Origin Y", "origin_y", String::new()), - ro("Annotative", "annotative", String::new()), - ro( - "Associative", - "associative", - if h.is_associative { "Yes" } else { "No" }, - ), - ro("Layer override", "layer_override", String::new()), - Property { - label: "Double".into(), - field: "double", - value: PropValue::BoolToggle { - field: "double", - value: h.is_double, - }, - }, - ro( - "Spacing", - "spacing", - h.pattern - .lines - .first() - .map(|l| format!("{:.4}", l.offset.length())) - .unwrap_or_default(), - ), + edit("Origin X", "origin_x", origin_x), + edit("Origin Y", "origin_y", origin_y), + spacing_row, ro("ISO pen width", "iso_pen_width", String::new()), + double_row, + associative_row, + island_row, + ro("Background color", "background_color", String::new()), + // ── Gradient info (read-only) ────────────────────────────── ro("Gradient colors", "gradient_colors", g.colors.len().to_string()), ro("Gradient tint", "gradient_tint", format!("{:.4}", g.color_tint)), ro("Gradient color 1", "gradient_color_1", grad_c1), ro("Gradient color 2", "gradient_color_2", grad_c2), - ro("Pattern type", "pattern_type", pattern_type), ], }, PropSection { @@ -182,24 +250,49 @@ fn properties(h: &Hatch) -> Vec { ro("Cumulative area", "cumulative_area", format!("{:.4}", area)), ], }, - PropSection { - title: "Misc".into(), - props: vec![ - ro("Boundary retention", "boundary_retention", String::new()), - ro("Island detection style", "style", style), - ], - }, ] } fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { - if field == "double" { - h.is_double = if value == "toggle" { - !h.is_double - } else { - value == "true" - }; - return; + use acadrust::entities::{HatchPatternType, HatchStyleType}; + // Non-numeric fields (bool toggles / enum choices) — handled before the + // f64 parse below, which would otherwise reject their string values. + match field { + "double" => { + h.is_double = if value == "toggle" { + !h.is_double + } else { + value == "true" + }; + return; + } + "associative" => { + h.is_associative = if value == "toggle" { + !h.is_associative + } else { + value == "true" + }; + return; + } + "pattern_type_label" => { + h.pattern_type = match value { + "Predefined" => HatchPatternType::Predefined, + "User Defined" => HatchPatternType::UserDefined, + "Custom" => HatchPatternType::Custom, + _ => h.pattern_type, + }; + return; + } + "style" => { + h.style = match value { + "Normal" => HatchStyleType::Normal, + "Outer" => HatchStyleType::Outer, + "Ignore" => HatchStyleType::Ignore, + _ => h.style, + }; + return; + } + _ => {} } let Some(v) = parse_f64(value) else { return; @@ -208,6 +301,41 @@ fn apply_geom_prop(h: &mut Hatch, field: &str, value: &str) { "pattern_angle" if h.gradient_color.enabled => h.gradient_color.angle = v.to_radians(), "pattern_angle" => h.pattern_angle = v.to_radians(), "pattern_scale" if v > 0.0 => h.pattern_scale = v, + // 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; + } + } + } + // Move the pattern origin: shift every line's base point by the delta + // from the current origin (first line), preserving their relative offsets. + "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; + } + } + } + "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; + } + } + } "elevation" => h.elevation = v, _ => {} } @@ -257,6 +385,15 @@ impl Grippable for Hatch { let elev = self.elevation; let mut out = Vec::new(); let mut id = 0usize; + // Grip 0 = pattern-origin handle, shown at the hatch centroid (only when a + // pattern exists). Dragging it moves the pattern tiling origin; boundary + // grips follow from the next id. + if let Some(l0) = self.pattern.lines.first() { + let (gx, gy) = + 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; + } for path in &self.paths { for edge in &path.edges { match edge { @@ -308,7 +445,6 @@ impl Grippable for Hatch { fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { let elev = self.elevation as f32; - let mut id = 0usize; fn resolve(apply: &GripApply, cur: Vec3) -> (f64, f64) { let p = match apply { @@ -318,6 +454,27 @@ impl Grippable for Hatch { (p.x, p.y) } + let mut id = 0usize; + // Grip 0 = pattern origin: shift every line's base point by the delta, + // preserving relative offsets (mirrors the origin_x / origin_y edits). + if let Some((ox, oy)) = self + .pattern + .lines + .first() + .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); + for line in self.pattern.lines.iter_mut() { + line.base_point.x += dx; + line.base_point.y += dy; + } + return; + } + id += 1; + } + 'outer: for path in &mut self.paths { for edge in &mut path.edges { match edge { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 50846b27c..9c18a67d8 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -7,9 +7,9 @@ use super::*; /// 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` stay 0: for an infinite line family the phase -/// is not observable, and the stored base point is in the source (possibly -/// block-local) frame anyway. +/// 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. fn family_from_stored_line( ln: &acadrust::entities::hatch::HatchPatternLine, ) -> crate::scene::model::hatch_model::PatFamily { @@ -1268,7 +1268,7 @@ impl Scene { && !dxf.gradient_color.is_enabled() && !dxf.pattern.lines.is_empty(); - let pattern = if dxf.gradient_color.is_enabled() { + let mut pattern = if dxf.gradient_color.is_enabled() { let color2 = dxf .gradient_color .colors @@ -1356,6 +1356,21 @@ impl Scene { } else { [0.0, 0.0] }; + + // Anchor each prebaked family at its stored pattern origin (per-line + // `base_point`), expressed relative to `world_origin` so it shares the + // boundary's coordinate frame. Left at 0 the pattern tiles from the + // boundary centre instead: invisible for a solid infinite-line family + // but the wrong phase for dashed / offset patterns and for a user-moved + // origin (Origin X/Y property + origin grip). + if prebaked { + if let model::hatch_model::HatchPattern::Pattern(fams) = &mut pattern { + for (fam, ln) in fams.iter_mut().zip(dxf.pattern.lines.iter()) { + fam.x0 = (ln.base_point.x - world_origin[0]) as f32; + fam.y0 = (ln.base_point.y - world_origin[1]) as f32; + } + } + } let boundary_f32: Vec<[f32; 2]> = boundary .iter() .map(|&[x, y]| { diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index fcf05aa44..169099950 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -75,6 +75,7 @@ pub enum GripShape { Square, Rectangle, Triangle, + Circle, } /// Describes one grip point for an entity. diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 629881714..eedc793b0 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -597,6 +597,7 @@ impl canvas::Program for SelectionCanvas { b.line_to(Point::new(sp.x - h, sp.y + h)); b.close(); }), + GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h), }; if grip.is_hot {