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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-08 22:48:33 +03:00
1 parent 954a2e8b43
commit 22059e138c
5 files changed
+247 -61

No files matched your search

+12
View File
@@ -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".
+214 -57
View File
@@ -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<PropSection> {
}
// ── 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<PropSection> {
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 {
+19 -4
View File
@@ -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]| {
+1
View File
@@ -75,6 +75,7 @@ pub enum GripShape {
Square,
Rectangle,
Triangle,
Circle,
}
/// Describes one grip point for an entity.
+1
View File
@@ -597,6 +597,7 @@ impl canvas::Program<Message> 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 {