feat(boundary): trace exact planar loops

This commit is contained in:
Hakan Seven committed 2026-08-20 19:44:16 +03:00
commit 0e944c19a6
4 files changed
+685 -24

No files matched your search

+15 -2
View File
@@ -676,8 +676,21 @@ impl OpenCADStudio {
"BOUNDARY" => {
use crate::modules::draw::draw::hatch::BoundaryCommand;
let outlines = self.tabs[i].scene.closed_outlines();
let new_cmd = BoundaryCommand::new(outlines);
let plane = if self.tabs[i].editing_model_space() {
self.tabs[i].ucs_xform().working_plane()
} else {
crate::command::WorkingPlane::default()
};
let sources = self.tabs[i]
.scene
.boundary_sources_on_plane(plane, 1.0e-6);
let selected = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(handle, _)| *handle)
.collect();
let new_cmd = BoundaryCommand::new(sources, selected, plane);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
+359 -19
View File
@@ -9,12 +9,14 @@
// Click a point INSIDE a closed region → boundary auto-detected.
// Type "S" to switch to manual vertex-picking mode (HATCH/GRADIENT only).
use crate::command::{CadCommand, CmdResult};
use crate::command::{CadCommand, CmdResult, WorkingPlane};
use crate::modules::IconKind;
use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily};
use crate::scene::model::wire_model::WireModel;
use acadrust::Handle;
use cadkernel::geom2d::{bounded_faces, Line, Tolerance};
use cadkernel::geom2d::{
bounded_faces, contains, ring_nesting_depths, signed_area, Curve, Line, Tolerance,
};
use glam::DVec3;
use crate::t;
@@ -850,16 +852,163 @@ impl CadCommand for GradientCommand {
// ── BOUNDARY command ───────────────────────────────────────────────────────
pub struct BoundaryCommand {
sources: rustc_hash::FxHashMap<Handle, crate::scene::BoundarySource>,
active_sources: rustc_hash::FxHashMap<Handle, crate::scene::BoundarySource>,
restrict_sources: bool,
outlines: Vec<Vec<[f64; 2]>>,
point_regions: Vec<Vec<Vec<[f64; 2]>>>,
selected_objects: Vec<Handle>,
mode: BoundaryMode,
island_style: BoundaryIslandStyle,
gap_tolerance: f64,
plane: WorkingPlane,
missed: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BoundaryMode {
PickInside,
SelectObjects,
GapTolerance { return_to_selection: bool },
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BoundaryIslandStyle {
Normal,
Outer,
Ignore,
}
impl BoundaryCommand {
pub fn new(outlines: Vec<Vec<[f64; 2]>>) -> Self {
Self {
pub fn new(
sources: rustc_hash::FxHashMap<Handle, crate::scene::BoundarySource>,
selected_objects: Vec<Handle>,
plane: WorkingPlane,
) -> Self {
let outlines = crate::scene::boundary_faces(&sources, 1.0e-6);
let mut command = Self {
sources,
active_sources: rustc_hash::FxHashMap::default(),
restrict_sources: false,
outlines,
point_regions: Vec::new(),
selected_objects: Vec::new(),
mode: BoundaryMode::PickInside,
island_style: BoundaryIslandStyle::Normal,
gap_tolerance: 1.0e-6,
plane,
missed: false,
};
if !selected_objects.is_empty() {
command.set_boundary_set(selected_objects);
}
command
}
fn set_boundary_set(&mut self, handles: Vec<Handle>) {
let restrict_sources = !handles.is_empty();
let selected_sources: rustc_hash::FxHashMap<_, _> = handles
.iter()
.filter_map(|handle| self.sources.get(handle).cloned().map(|source| (*handle, source)))
.collect();
self.active_sources = selected_sources;
self.restrict_sources = restrict_sources;
let sources = if self.restrict_sources {
&self.active_sources
} else {
&self.sources
};
self.outlines = crate::scene::boundary_faces(sources, self.gap_tolerance);
self.missed = !handles.is_empty() && self.outlines.is_empty();
self.selected_objects = handles;
self.point_regions.clear();
}
fn rebuild_faces(&mut self) {
self.set_boundary_set(self.selected_objects.clone());
}
fn region_count(&self) -> usize {
self.point_regions.len()
}
fn island_label(&self) -> &'static str {
match self.island_style {
BoundaryIslandStyle::Normal => "Normal",
BoundaryIslandStyle::Outer => "Outer",
BoundaryIslandStyle::Ignore => "Ignore",
}
}
fn picked_region(&self, point: [f64; 2]) -> Option<Vec<Vec<[f64; 2]>>> {
let tolerance = Tolerance::new(self.gap_tolerance);
let curves = |outline: &Vec<[f64; 2]>| {
outline
.iter()
.copied()
.zip(outline.iter().copied().cycle().skip(1))
.take(outline.len())
.map(|(start, end)| Curve::Line(Line { start, end }))
.collect::<Vec<_>>()
};
let mut containing: Vec<(usize, f64)> = self
.outlines
.iter()
.enumerate()
.filter(|(_, outline)| contains(&curves(outline), point, tolerance))
.map(|(index, outline)| (index, signed_area(outline).abs()))
.collect();
containing.sort_by(|left, right| left.1.total_cmp(&right.1));
let outer_index = containing.first()?.0;
let outer = &self.outlines[outer_index];
let outer_curves = curves(outer);
let depths = ring_nesting_depths(&self.outlines);
let outer_depth = depths.get(outer_index).copied().unwrap_or(0);
let mut rings = vec![outer.clone()];
if self.island_style == BoundaryIslandStyle::Ignore {
return Some(rings);
}
for (index, candidate) in self.outlines.iter().enumerate() {
let Some(seed) = candidate.first().copied() else {
continue;
};
let candidate_curves = curves(candidate);
let candidate_depth = depths.get(index).copied().unwrap_or(0);
if index == outer_index
|| candidate_depth <= outer_depth
|| !contains(&outer_curves, seed, tolerance)
|| contains(&candidate_curves, point, tolerance)
{
continue;
}
if self.island_style == BoundaryIslandStyle::Outer
&& candidate_depth != outer_depth + 1
{
continue;
}
rings.push(candidate.clone());
}
Some(rings)
}
fn add_point_region(&mut self, region: Vec<Vec<[f64; 2]>>) {
if !self.point_regions.iter().any(|existing| existing == &region) {
self.point_regions.push(region);
}
}
fn make_entities(&self) -> Vec<acadrust::EntityType> {
let sources = if self.restrict_sources {
&self.active_sources
} else {
&self.sources
};
crate::scene::boundary_polyline_entities(
&self.point_regions,
self.plane,
sources,
self.gap_tolerance,
)
}
}
@@ -874,31 +1023,222 @@ impl CadCommand for BoundaryCommand {
} else {
String::new()
};
t!("BOUNDARY Pick internal point:%{miss}", miss = miss).into_owned()
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
let xy = [pt.x, pt.y];
match resolve_hatch_rings(&self.outlines, xy) {
Some(rings) => {
self.missed = false;
CmdResult::CommitEntitiesAndExit(crate::scene::boundary_entities(&rings))
match self.mode {
BoundaryMode::PickInside => {
t!("BOUNDARY Pick internal point:%{miss}", miss = miss).into_owned()
}
None => {
self.missed = true;
CmdResult::NeedPoint
BoundaryMode::SelectObjects => {
t!("%{cmd} Select objects:", cmd = self.name()).into_owned()
}
BoundaryMode::GapTolerance { .. } => format!(
"BOUNDARY {} <{}>:",
t!("Tolerance"),
self.gap_tolerance
),
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
if matches!(self.mode, BoundaryMode::GapTolerance { .. }) {
return Vec::new();
}
if matches!(self.mode, BoundaryMode::SelectObjects) {
return vec![CmdOption::enter(t!("Accept").as_ref())];
}
let island = format!(
"{}: {}",
t!("Island detection style"),
t!(self.island_label())
);
let mut options = vec![
CmdOption::new(t!("Boundary").as_ref(), "O"),
CmdOption::new(&island, "S"),
CmdOption::new(t!("Tolerance").as_ref(), "G"),
];
if self.region_count() > 0 {
options.push(CmdOption::enter("Accept"));
}
options
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
if matches!(self.mode, BoundaryMode::PickInside) {
let local = self.plane.to_local(pt);
match self.picked_region([local.x, local.y]) {
Some(region) => {
self.missed = false;
self.add_point_region(region);
}
None => self.missed = true,
}
}
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
if let BoundaryMode::GapTolerance { return_to_selection } = self.mode {
self.mode = if return_to_selection {
BoundaryMode::SelectObjects
} else {
BoundaryMode::PickInside
};
return CmdResult::NeedPoint;
}
if matches!(self.mode, BoundaryMode::SelectObjects) {
self.mode = BoundaryMode::PickInside;
return CmdResult::NeedPoint;
}
let entities = self.make_entities();
if entities.is_empty() {
CmdResult::Cancel
} else {
CmdResult::CommitEntitiesAndExit(entities)
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
fn wants_text_input(&self) -> bool {
true
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.mode, BoundaryMode::GapTolerance { .. }) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn dyn_commit_as_text(&self) -> bool {
matches!(self.mode, BoundaryMode::GapTolerance { .. })
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let BoundaryMode::GapTolerance { return_to_selection } = self.mode {
if let Ok(value) = text.trim().parse::<f64>() {
if value.is_finite() && value > 0.0 {
self.gap_tolerance = value;
self.rebuild_faces();
}
}
self.mode = if return_to_selection {
BoundaryMode::SelectObjects
} else {
BoundaryMode::PickInside
};
return Some(CmdResult::NeedPoint);
}
match text.trim().to_ascii_uppercase().as_str() {
"O" | "OBJECT" | "OBJECTS" => {
self.mode = BoundaryMode::SelectObjects;
self.missed = false;
}
"I" | "INTERNAL" | "POINTS" => {
self.mode = BoundaryMode::PickInside;
self.missed = false;
}
"S" | "ISLAND" | "ISLANDS" => {
let style = match self.island_style {
BoundaryIslandStyle::Normal => BoundaryIslandStyle::Outer,
BoundaryIslandStyle::Outer => BoundaryIslandStyle::Ignore,
BoundaryIslandStyle::Ignore => BoundaryIslandStyle::Normal,
};
self.island_style = style;
self.point_regions.clear();
}
"NORMAL" => {
self.island_style = BoundaryIslandStyle::Normal;
self.point_regions.clear();
}
"OUTER" => {
self.island_style = BoundaryIslandStyle::Outer;
self.point_regions.clear();
}
"IGNORE" => {
self.island_style = BoundaryIslandStyle::Ignore;
self.point_regions.clear();
}
"G" | "GAP" | "TOLERANCE" => {
self.mode = BoundaryMode::GapTolerance {
return_to_selection: matches!(self.mode, BoundaryMode::SelectObjects),
};
}
_ => return None,
}
Some(CmdResult::NeedPoint)
}
fn is_selection_gathering(&self) -> bool {
matches!(self.mode, BoundaryMode::SelectObjects)
}
fn selection_forces_add(&self) -> bool {
matches!(self.mode, BoundaryMode::SelectObjects)
}
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
if matches!(self.mode, BoundaryMode::SelectObjects) {
self.set_boundary_set(handles);
CmdResult::NeedPoint
} else {
CmdResult::Cancel
}
}
fn on_undo_step(&mut self) -> Option<CmdResult> {
if matches!(self.mode, BoundaryMode::PickInside) {
self.point_regions.pop().map(|_| CmdResult::NeedPoint)
} else {
None
}
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
if !matches!(self.mode, BoundaryMode::PickInside) {
return None;
}
let local = self.plane.to_local(pt);
let hovered = self.picked_region([local.x, local.y]);
let mut rings: Vec<&Vec<[f64; 2]>> = Vec::new();
for ring in self.point_regions.iter().flat_map(|region| region.iter()) {
if !rings.iter().any(|existing| *existing == ring) {
rings.push(ring);
}
}
if let Some(region) = &hovered {
for ring in region {
if !rings.iter().any(|existing| *existing == ring) {
rings.push(ring);
}
}
}
if rings.is_empty() {
return None;
}
let mut points = Vec::new();
for ring in rings {
if !points.is_empty() {
points.push([f64::NAN; 3]);
}
for [x, y] in ring {
points.push(self.plane.to_world(DVec3::new(*x, *y, 0.0)).to_array());
}
if let Some([x, y]) = ring.first() {
points.push(self.plane.to_world(DVec3::new(*x, *y, 0.0)).to_array());
}
}
Some(WireModel::solid_f64(
"boundary_preview".into(),
points,
WireModel::CYAN,
false,
))
}
}
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["BOUNDARY"] }); // BoundaryCommand
+307 -2
View File
@@ -1,10 +1,18 @@
use super::*;
use cadkernel::geom2d::{
bounded_faces, contains, distance_to, segment_crossing, triangulate, Curve, Line,
SegmentCrossing, Tolerance,
bounded_faces, contains, distance_to, intersect, segment_crossing, triangulate, Curve, Line,
SegmentCrossing, Tolerance, Transform as CurveTransform,
};
use crate::command::WorkingPlane;
#[derive(Clone)]
pub struct BoundarySource {
pub segments: Vec<Line>,
pub curves: Vec<Curve>,
}
/// 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
@@ -38,6 +46,73 @@ fn wire_segments(wire: &WireModel) -> Vec<Line> {
segments
}
fn wire_segments_on_plane(
wire: &WireModel,
plane: WorkingPlane,
tolerance: f64,
) -> Vec<Line> {
let mut segments = Vec::new();
let mut previous: Option<glam::DVec3> = None;
for (index, high) in wire.points.iter().copied().enumerate() {
if !high.iter().all(|value| value.is_finite()) {
previous = None;
continue;
}
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
let world = glam::DVec3::new(
high[0] as f64 + low[0] as f64,
high[1] as f64 + low[1] as f64,
high[2] as f64 + low[2] as f64,
);
let current = plane.to_local(world);
if current.z.abs() > tolerance {
previous = None;
continue;
}
if let Some(start) = previous {
let length = (current.x - start.x).hypot(current.y - start.y);
if length > tolerance {
segments.push(Line {
start: [start.x, start.y],
end: [current.x, current.y],
});
}
}
previous = Some(current);
}
segments
}
fn entity_curves_on_plane(
entity: &EntityType,
plane: WorkingPlane,
tolerance: f64,
) -> Vec<Curve> {
let Some(planar) = crate::entities::curve::entity_curve(entity) else {
return Vec::new();
};
let source = planar.plane;
let origin = plane.to_local(glam::DVec3::from_array(source.origin));
let x_axis = plane.vector_to_local(glam::DVec3::from_array(source.x_axis));
let y_axis = plane.vector_to_local(glam::DVec3::from_array(source.y_axis));
if origin.z.abs() > tolerance || x_axis.z.abs() > 1.0e-9 || y_axis.z.abs() > 1.0e-9 {
return Vec::new();
}
let Some(curve) = planar.curve.transformed(&CurveTransform {
x_axis: [x_axis.x, x_axis.y].into(),
y_axis: [y_axis.x, y_axis.y].into(),
origin: [origin.x, origin.y].into(),
}) else {
return Vec::new();
};
let segments = curve.segments();
if segments.is_empty() {
vec![curve]
} else {
segments
}
}
fn ring_seed(model: &HatchModel, wanted: usize) -> Option<[f64; 2]> {
let mut ring = Vec::new();
let mut index = 0usize;
@@ -164,6 +239,175 @@ pub(crate) fn boundary_entities(rings: &[Vec<[f64; 2]>]) -> Vec<acadrust::Entity
.collect()
}
fn edge_curve(
edge: Line,
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> Option<Curve> {
let edge_length = (edge.end[0] - edge.start[0]).hypot(edge.end[1] - edge.start[1]);
let mut best = None;
let mut best_overlap = 0.0;
for source in sources.values() {
for segment in &source.segments {
if let SegmentCrossing::Overlap { a, .. } =
segment_crossing(edge, *segment, Tolerance::new(tolerance))
{
let overlap = (a[1] - a[0]).abs() * edge_length;
if overlap > best_overlap {
best = Some(source);
best_overlap = overlap;
}
}
}
}
let candidates: Vec<&Curve> = match best {
Some(source) => source.curves.iter().collect(),
None => sources
.values()
.flat_map(|source| source.curves.iter())
.filter(|curve| {
distance_to(curve, edge.start).min(distance_to(curve, edge.end))
<= tolerance * 4.0
})
.collect(),
};
candidates
.into_iter()
.min_by(|left, right| {
let error = |curve: &Curve| {
distance_to(curve, edge.start) + distance_to(curve, edge.end)
};
error(left).total_cmp(&error(right))
})
.cloned()
}
fn nearest_crossing(a: &Curve, b: &Curve, point: [f64; 2], tolerance: f64) -> Option<[f64; 2]> {
intersect(a, b, Tolerance::new(tolerance))
.into_iter()
.min_by(|left, right| {
let distance = |candidate: [f64; 2]| {
(candidate[0] - point[0]).hypot(candidate[1] - point[1])
};
distance(left.point).total_cmp(&distance(right.point))
})
.map(|crossing| crossing.point)
}
fn project_to_curve(curve: &Curve, point: [f64; 2]) -> [f64; 2] {
curve.point_at(curve.parameter_at(point))
}
fn refined_boundary_ring(
ring: &[[f64; 2]],
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> (Vec<[f64; 2]>, Vec<Option<Curve>>) {
let count = ring.len();
if count < 3 {
return (ring.to_vec(), Vec::new());
}
let edge_curves: Vec<_> = (0..count)
.map(|index| {
edge_curve(
Line {
start: ring[index],
end: ring[(index + 1) % count],
},
sources,
tolerance,
)
})
.collect();
let points = (0..count)
.map(|index| {
let point = ring[index];
let incoming = edge_curves[(index + count - 1) % count].as_ref();
let outgoing = edge_curves[index].as_ref();
match (incoming, outgoing) {
(Some(a), Some(b)) if a != b => nearest_crossing(a, b, point, tolerance)
.unwrap_or_else(|| project_to_curve(b, point)),
(Some(curve), _) | (_, Some(curve)) => project_to_curve(curve, point),
_ => point,
}
})
.collect();
(points, edge_curves)
}
fn normalized_delta(from: f64, to: f64) -> f64 {
(to - from + std::f64::consts::PI)
.rem_euclid(std::f64::consts::TAU)
- std::f64::consts::PI
}
fn curve_edge_bulge(curve: &Curve, start: [f64; 2], end: [f64; 2]) -> f64 {
let direct = match curve {
Curve::Circle(circle) => Some((circle.centre, circle.radius)),
Curve::Arc(arc) => Some((arc.centre, arc.radius)),
_ => None,
};
if let Some((centre, radius)) = direct {
if radius.abs() <= 1.0e-12 {
return 0.0;
}
let start_angle = (start[1] - centre[1]).atan2(start[0] - centre[0]);
let end_angle = (end[1] - centre[1]).atan2(end[0] - centre[0]);
return (normalized_delta(start_angle, end_angle) * 0.25).tan();
}
0.0
}
fn boundary_polyline(
ring: &[[f64; 2]],
plane: WorkingPlane,
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> Option<EntityType> {
let (points, edge_curves) = refined_boundary_ring(ring, sources, tolerance);
if points.len() < 3 {
return None;
}
let mut polyline = acadrust::entities::LwPolyline::new();
polyline.is_closed = true;
polyline.vertices = points
.iter()
.enumerate()
.map(|(index, point)| {
let mut vertex = acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(
point[0], point[1],
));
vertex.bulge = edge_curves
.get(index)
.and_then(|curve| curve.as_ref())
.map(|curve| {
curve_edge_bulge(curve, *point, points[(index + 1) % points.len()])
})
.unwrap_or(0.0);
vertex
})
.collect();
Some(plane.place_entity(EntityType::LwPolyline(polyline)))
}
pub(crate) fn boundary_polyline_entities(
regions: &[Vec<Vec<[f64; 2]>>],
plane: WorkingPlane,
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> Vec<EntityType> {
let mut rings = Vec::new();
for ring in regions.iter().flat_map(|region| region.iter()) {
if !rings.iter().any(|existing| *existing == ring) {
rings.push(ring);
}
}
rings
.into_iter()
.filter_map(|ring| boundary_polyline(ring, plane, sources, tolerance))
.collect()
}
impl Scene {
fn associative_boundary_segments(&self, handles: &[Handle]) -> Vec<Line> {
self.wire_models_for(handles)
@@ -340,4 +584,65 @@ impl Scene {
}
sources
}
/// Boundary candidates in the active working plane, with exact curves
/// where the source entity exposes them.
pub fn boundary_sources_on_plane(
&self,
plane: WorkingPlane,
tolerance: f64,
) -> rustc_hash::FxHashMap<Handle, BoundarySource> {
let mut sources: rustc_hash::FxHashMap<Handle, BoundarySource> =
rustc_hash::FxHashMap::default();
let wires = if let Some(viewport) = self.active_viewport {
self.model_wires_for_viewport_arc(viewport, 0.0)
} else if self.current_layout == "Model" {
let camera = self.camera.borrow().clone();
self.model_tile_wires_arc(0, &camera, 1.0, 1.0)
} else {
self.paper_sheet_wires_arc()
};
for wire in wires.iter() {
let Some(handle) = Self::handle_from_wire_name(&wire.name) else {
continue;
};
let segments = wire_segments_on_plane(wire, plane, tolerance);
if segments.is_empty() {
continue;
}
sources
.entry(handle)
.or_insert_with(|| BoundarySource {
segments: Vec::new(),
curves: Vec::new(),
})
.segments
.extend(segments);
}
for (&handle, source) in &mut sources {
source.curves = self
.document
.get_entity(handle)
.map(|entity| entity_curves_on_plane(entity, plane, tolerance))
.unwrap_or_default();
source.curves.retain(|curve| {
source.segments.iter().any(|segment| {
distance_to(curve, segment.start).max(distance_to(curve, segment.end))
<= tolerance * 4.0
})
});
}
sources
}
}
pub(crate) fn boundary_faces(
sources: &rustc_hash::FxHashMap<Handle, BoundarySource>,
tolerance: f64,
) -> Vec<Vec<[f64; 2]>> {
let segments: Vec<_> = sources
.values()
.flat_map(|source| source.segments.iter().copied())
.collect();
bounded_faces(&segments, Tolerance::new(tolerance))
}
+4 -1
View File
@@ -33,7 +33,10 @@ mod project;
mod scene_markers;
mod selection;
pub(crate) use boundary::{boundary_entities, ring_source_handles};
pub(crate) use boundary::{
boundary_entities, boundary_faces, boundary_polyline_entities, ring_source_handles,
BoundarySource,
};
// Parallel tessellation free functions live in `convert::tess` (alongside the
// other tessellation code); re-exported here so this root and sibling topic