Complete hatch creation and editing workflow

This commit is contained in:
ramox81 2026-08-21 12:57:19 +03:00
commit f83037271c
18 changed files with 1246 additions and 336 deletions

2
Cargo.lock generated
View file

@ -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",

View file

@ -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"] }

View file

@ -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;

View file

@ -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));

View file

@ -1,7 +1,7 @@
use super::*;
impl OpenCADStudio {
pub(super) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
pub(crate) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd {
"DONATE" => {
self.command_line.push_info(crate::t!("Opening Patreon page...").as_ref());

View file

@ -2057,6 +2057,25 @@ pub(super) fn aggregate_sections(
for sections in all_sections {
result = merge_sections(&result, &sections);
}
// 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::<f64>();
set_row(&mut result, "cumulative_area", format!("{total:.4}"));
}
result
}

View file

@ -1726,6 +1726,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
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();

View file

@ -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<dyn CadCommand> = 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);

View file

@ -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<acadrust::entities::HatchStyleType>,
annotative: Option<bool>,
},
RecreateBoundary,
Separate,
AddBoundaries(Vec<Handle>),
RemoveBoundaries(Vec<Handle>),
DrawOrderFront,
DrawOrderBack,
}
// ── Working plane ─────────────────────────────────────────────────────────
/// Full-precision coordinate frame used by interactive commands.
@ -1197,6 +1213,11 @@ pub enum CmdResult {
boundaries: Vec<EntityType>,
entity_style: Option<(acadrust::types::Color, acadrust::types::Transparency)>,
},
/// Commit independently editable hatch entities for every selected region.
CommitHatches {
hatches: Vec<HatchModel>,
entity_style: Option<(acadrust::types::Color, acadrust::types::Transparency)>,
},
/// Copy selected entities with multiple transforms (e.g. rectangular array); end command.
BatchCopy(Vec<Handle>, Vec<EntityTransform>),
/// 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.

View file

@ -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::<f64>()
.abs()
}
/// A hatch boundary edge as a kernel curve, in the hatch's own OCS.
@ -461,14 +483,20 @@ fn properties(h: &Hatch) -> Vec<PropSection> {
// ── 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<PropSection> {
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<crate::scene::model::object::GripMenuItem> {
fn grip_menu(&self, grip_id: usize) -> Vec<crate::scene::model::object::GripMenuItem> {
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(

View file

@ -206,14 +206,23 @@ fn rte_boundary(pts: impl Iterator<Item = (f64, f64)>) -> (Vec<[f32; 2]>, [f64;
pub struct HatchCommand {
outlines: Vec<Vec<[f64; 2]>>,
boundary_sources: rustc_hash::FxHashMap<Handle, Vec<Line>>,
boundary_sources: rustc_hash::FxHashMap<Handle, crate::scene::BoundarySource>,
point_regions: Vec<Vec<Vec<[f64; 2]>>>,
object_regions: Vec<Vec<Vec<[f64; 2]>>>,
selected_objects: Vec<Handle>,
mode: HatchMode,
manual_pts: Vec<DVec3>,
manual_bulges: Vec<f64>,
manual_arc_mode: bool,
manual_arc_midpoint: Option<DVec3>,
missed: bool,
retain_boundaries: bool,
pattern_override: Option<(String, HatchPattern)>,
angle_override: Option<f32>,
scale_override: Option<f32>,
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<Vec<[f64; 2]>>,
boundary_sources: rustc_hash::FxHashMap<Handle, Vec<Line>>,
boundary_sources: rustc_hash::FxHashMap<Handle, crate::scene::BoundarySource>,
selected_objects: Vec<Handle>,
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<Vec<[f64; 2]>> {
let mut rings = Vec::new();
for ring in self
@ -304,23 +333,37 @@ impl HatchCommand {
fn make_hatch(&self, rings: Vec<Vec<[f64; 2]>>) -> HatchModel {
let (rel, origin, wcs) = pack_rings(&rings);
let exterior = cadkernel::geom2d::ring_nesting_depths(&rings)
let exterior: Vec<bool> = cadkernel::geom2d::ring_nesting_depths(&rings)
.into_iter()
.map(|depth| depth == 0)
.collect();
let boundary_sources = rings
let mut boundary_sources: Vec<Vec<Handle>> = 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<acadrust::entities::BoundaryPath> {
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<f64> {
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 <pattern> / A <angle> / L <scale>; 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 <pattern> / A <angle> / L <scale>; 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<CmdResult> {
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<CmdResult> {
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::<f32>() {
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::<f32>() {
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<Vec<[f64; 2]>>,
boundary_sources: rustc_hash::FxHashMap<Handle, Vec<Line>>,
boundary_sources: rustc_hash::FxHashMap<Handle, crate::scene::BoundarySource>,
mode: Mode,
manual_pts: Vec<DVec3>,
missed: bool,
@ -662,7 +911,7 @@ pub struct GradientCommand {
impl GradientCommand {
pub fn new(
outlines: Vec<Vec<[f64; 2]>>,
boundary_sources: rustc_hash::FxHashMap<Handle, Vec<Line>>,
boundary_sources: rustc_hash::FxHashMap<Handle, crate::scene::BoundarySource>,
) -> Self {
Self {
outlines,
@ -677,7 +926,7 @@ impl GradientCommand {
fn make_hatch(&self, rings: Vec<Vec<[f64; 2]>>) -> HatchModel {
let (rel, origin, wcs) = pack_rings(&rings);
let exterior = cadkernel::geom2d::ring_nesting_depths(&rings)
let exterior: Vec<bool> = 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,
}
}

View file

@ -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<acadrust::entities::HatchStyleType>,
annotative: Option<bool>,
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<CmdResult> {
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 <pat> / S <scale> / A <angle> | 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<crate::command::CmdOption> {
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<CmdResult> {
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::<f64>().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::<Vec<_>>()
};
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

View file

@ -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<Line> {
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::<f64>() / ring.len() as f64,
ring.iter().map(|point| point[1]).sum::<f64>() / ring.len() as f64,
])
}
Some([
ring.iter().map(|point| point[0]).sum::<f64>() / ring.len() as f64,
ring.iter().map(|point| point[1]).sum::<f64>() / ring.len() as f64,
])
}
fn face_curves(face: &[[f64; 2]]) -> Vec<Curve> {
@ -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<acadrust::Handle, Vec<Line>>,
sources: &rustc_hash::FxHashMap<acadrust::Handle, BoundarySource>,
) -> Vec<acadrust::Handle> {
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<Handle, BoundarySource>,
tolerance: f64,
) -> Vec<acadrust::entities::BoundaryPath> {
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<acadrust::EntityType> {
rings
.iter()
@ -409,11 +572,43 @@ pub(crate) fn boundary_polyline_entities(
}
impl Scene {
fn associative_boundary_segments(&self, handles: &[Handle]) -> Vec<Line> {
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::<Vec<_>>()
})
.unwrap_or_default();
let seeds = hatch.paths.iter().map(hatch_path_seed).collect::<Vec<_>>();
(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<Vec<[f64; 2]>> {
let mut segments = Vec::<Line>::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<acadrust::Handle, Vec<Line>> {
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(

View file

@ -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<Vector2> = Vec::new();
let mut first = true;
let mut ring_index = 0usize;
let mut push_ring = |r: &mut Vec<Vector2>, 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<Vector2> = Vec::new();
let mut first = true;
let mut ring_index = 0usize;
let mut push_ring = |r: &mut Vec<Vector2>, 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()

View file

@ -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,

View file

@ -210,6 +210,13 @@ pub struct HatchModel {
pub boundary_exterior: Option<Arc<Vec<bool>>>,
/// Source entity handles for each boundary ring.
pub boundary_sources: Option<Arc<Vec<Vec<acadrust::Handle>>>>,
/// 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<Arc<Vec<acadrust::entities::BoundaryPath>>>,
/// 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").

View file

@ -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],

View file

@ -134,6 +134,8 @@ impl canvas::Program<Message> 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],