forked from 1b-agent/opencadstudio-upstream
Merge branch 'main' into feature/parametric-constraint-system
# Conflicts: # src/app/automation.rs # src/app/mod.rs # src/ui/ribbon/widgets.rs
This commit is contained in:
commit
e2d39b049d
114 files changed
+13641
-1685
No files matched your search
@@ -7,7 +7,7 @@ use cadkernel::geom2d::{self, Curve as KernelCurve};
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||||
use crate::modules::IconKind;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use crate::scene::model::wire_model::{TangentGeom, WireModel};
|
||||
use glam::DVec3;
|
||||
|
||||
const TAU: f64 = std::f64::consts::TAU;
|
||||
@@ -71,9 +71,9 @@ fn arc_preview(
|
||||
end_angle: f64,
|
||||
plane: WorkingPlane,
|
||||
) -> Option<WireModel> {
|
||||
let center = plane.to_local(center);
|
||||
let center_local = plane.to_local(center);
|
||||
let arc = geom2d::bounded_arc(
|
||||
[center.x, center.y],
|
||||
[center_local.x, center_local.y],
|
||||
radius,
|
||||
start_angle,
|
||||
end_angle,
|
||||
@@ -81,14 +81,23 @@ fn arc_preview(
|
||||
let points = KernelCurve::Arc(arc)
|
||||
.tessellate_angle(TAU / 64.0)
|
||||
.into_iter()
|
||||
.map(|point| plane.to_world(DVec3::new(point[0], point[1], center.z)).to_array())
|
||||
.map(|point| plane.to_world(DVec3::new(point[0], point[1], center_local.z)).to_array())
|
||||
.collect();
|
||||
Some(WireModel::solid_f64(
|
||||
let mut wire = WireModel::solid_f64(
|
||||
"rubber_band".into(),
|
||||
points,
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
))
|
||||
);
|
||||
wire.tangent_geoms.push(TangentGeom::Arc {
|
||||
center: [center.x, center.y, center.z],
|
||||
axis_x: plane.x.to_array(),
|
||||
axis_y: plane.y.to_array(),
|
||||
radius: arc.radius,
|
||||
start_angle: arc.start_angle,
|
||||
end_angle: arc.end_angle,
|
||||
});
|
||||
Some(wire)
|
||||
}
|
||||
|
||||
fn make_arc(
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::t;
|
||||
use crate::command::{CadCommand, CmdResult, DynField, TangentObject, WorkingPlane};
|
||||
use crate::modules::draw::defaults;
|
||||
use crate::modules::IconKind;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use crate::scene::model::wire_model::{TangentGeom, WireModel};
|
||||
use glam::DVec3;
|
||||
|
||||
const TAU: f64 = std::f64::consts::TAU;
|
||||
@@ -56,16 +56,23 @@ pub const ICON: IconKind = ICON_CR;
|
||||
// ── Shared geometry ────────────────────────────────────────────────────────
|
||||
|
||||
fn circle_wire(center: DVec3, radius: f64, plane: WorkingPlane) -> WireModel {
|
||||
let center = plane.to_local(center);
|
||||
let center_local = plane.to_local(center);
|
||||
let points = KernelCurve::Circle(KernelCircle {
|
||||
centre: [center.x, center.y],
|
||||
centre: [center_local.x, center_local.y],
|
||||
radius,
|
||||
})
|
||||
.tessellate_angle(TAU / 64.0)
|
||||
.into_iter()
|
||||
.map(|point| plane.to_world(DVec3::new(point[0], point[1], center.z)).to_array())
|
||||
.map(|point| plane.to_world(DVec3::new(point[0], point[1], center_local.z)).to_array())
|
||||
.collect();
|
||||
WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false)
|
||||
let mut wire = WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false);
|
||||
wire.tangent_geoms.push(TangentGeom::PlanarCircle {
|
||||
center: [center.x, center.y, center.z],
|
||||
axis_x: plane.x.to_array(),
|
||||
axis_y: plane.y.to_array(),
|
||||
radius,
|
||||
});
|
||||
wire
|
||||
}
|
||||
|
||||
fn make_circle(center: DVec3, radius: f64, plane: WorkingPlane) -> EntityType {
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::t;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||||
use crate::modules::IconKind;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use crate::scene::model::wire_model::{TangentGeom, WireModel};
|
||||
use glam::DVec3;
|
||||
|
||||
fn parse_num(text: &str) -> Option<f64> {
|
||||
@@ -75,7 +75,16 @@ fn ellipse_wire(
|
||||
[p.x, p.y, p.z]
|
||||
})
|
||||
.collect();
|
||||
WireModel::solid_f64("rubber_band".into(), pts, WireModel::CYAN, false)
|
||||
let mut wire = WireModel::solid_f64("rubber_band".into(), pts, WireModel::CYAN, false);
|
||||
wire.tangent_geoms.push(TangentGeom::PlanarEllipse {
|
||||
center: [center.x, center.y, center.z],
|
||||
major_axis: [major.x, major.y, major.z],
|
||||
normal: plane.z.to_array(),
|
||||
minor_axis_ratio: ratio,
|
||||
start_param: t_start,
|
||||
end_param: t_e,
|
||||
});
|
||||
wire
|
||||
}
|
||||
|
||||
/// Convert a world point to the parametric angle on the ellipse.
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::t;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use crate::scene::model::wire_model::{TangentGeom, WireModel};
|
||||
|
||||
// ── Ribbon definition ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -443,6 +443,7 @@ impl CadCommand for PlineCommand {
|
||||
let pt = self.plane.to_local(pt_world).as_vec3();
|
||||
|
||||
let mut pts: Vec<[f32; 3]> = Vec::new();
|
||||
let mut tangent_geom = None;
|
||||
match self.mode {
|
||||
SegMode::Line => {
|
||||
pts.push([last.x, last.y, last.z]);
|
||||
@@ -456,8 +457,44 @@ impl CadCommand for PlineCommand {
|
||||
.map(|t| t.as_dvec2())
|
||||
.unwrap_or(DVec2::new(1.0, 0.0));
|
||||
let bulge = compute_bulge(a, tangent, b);
|
||||
let arc_pts = arc_sample_points(last, bulge, pt, 16);
|
||||
pts.extend_from_slice(&arc_pts);
|
||||
if bulge.abs() >= 1e-9 && (b - a).length_squared() >= 1e-12 {
|
||||
if let Some(arc) =
|
||||
crate::entities::common::BulgeArc::from_bulge([a.x, a.y], [b.x, b.y], bulge)
|
||||
{
|
||||
for s in arc.tessellate_angle(std::f64::consts::TAU / 64.0) {
|
||||
pts.push([s[0] as f32, s[1] as f32, last.z]);
|
||||
}
|
||||
let (sa, ea) = if arc.sweep >= 0.0 {
|
||||
(arc.start_angle, arc.end_angle)
|
||||
} else {
|
||||
(arc.end_angle, arc.start_angle)
|
||||
};
|
||||
let world_center = self
|
||||
.plane
|
||||
.to_world(DVec3::new(arc.center[0], arc.center[1], last.z as f64));
|
||||
if arc.radius > 0.0
|
||||
&& arc.radius.is_finite()
|
||||
&& arc.radius <= 1e6
|
||||
&& sa.is_finite()
|
||||
&& ea.is_finite()
|
||||
{
|
||||
tangent_geom = Some(TangentGeom::Arc {
|
||||
center: world_center.to_array(),
|
||||
axis_x: self.plane.x.to_array(),
|
||||
axis_y: self.plane.y.to_array(),
|
||||
radius: arc.radius,
|
||||
start_angle: sa,
|
||||
end_angle: ea,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let arc_pts = arc_sample_points(last, bulge, pt, 64);
|
||||
pts.extend_from_slice(&arc_pts);
|
||||
}
|
||||
} else {
|
||||
pts.push([last.x, last.y, last.z]);
|
||||
pts.push([pt.x, pt.y, pt.z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,12 +502,16 @@ impl CadCommand for PlineCommand {
|
||||
.into_iter()
|
||||
.map(|point| self.plane.to_world(Vec3::from_array(point).as_dvec3()).as_vec3().to_array())
|
||||
.collect();
|
||||
Some(WireModel::solid(
|
||||
let mut wire = WireModel::solid(
|
||||
"rubber_band".into(),
|
||||
world_points,
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
))
|
||||
);
|
||||
if let Some(tg) = tangent_geom {
|
||||
wire.tangent_geoms.push(tg);
|
||||
}
|
||||
Some(wire)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,4 +589,20 @@ mod tests {
|
||||
CmdResult::FinalizeLiveEntity(h) if h == handle
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draw_line_then_arc() {
|
||||
let mut cmd = PlineCommand::new();
|
||||
cmd.on_point(DVec3::new(0.0, 0.0, 0.0));
|
||||
let res = cmd.on_point(DVec3::new(10.0, 0.0, 0.0));
|
||||
assert!(matches!(res, CmdResult::CommitLiveEntity(_)));
|
||||
cmd.set_live_handle(Handle::new(1));
|
||||
cmd.on_text_input("A");
|
||||
let wire = cmd.on_mouse_move(DVec3::new(10.0, 5.0, 0.0)).expect("arc wire");
|
||||
assert_eq!(wire.tangent_geoms.len(), 1);
|
||||
assert!(matches!(wire.tangent_geoms[0], TangentGeom::Arc { .. }));
|
||||
assert!(wire.points.len() >= 16);
|
||||
let res2 = cmd.on_point(DVec3::new(10.0, 5.0, 0.0));
|
||||
assert!(matches!(res2, CmdResult::UpdateLiveEntity { .. }));
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,122 @@ fn project_click(click: [f64; 2], p1: [f64; 2], unit: [f64; 2]) -> f64 {
|
||||
}
|
||||
|
||||
// ── Fillet ─────────────────────────────────────────────────────────────────
|
||||
/// Fillet two parallel lines with a semicircle tangent to both.
|
||||
///
|
||||
/// The current FILLET radius is intentionally ignored for parallel lines:
|
||||
/// the effective radius is half the perpendicular distance between them.
|
||||
///
|
||||
/// The endpoint of the first line nearest its pick determines where the
|
||||
/// semicircle is placed. The second line is trimmed or extended to the
|
||||
/// corresponding perpendicular tangent point.
|
||||
fn fillet_parallel_lines(
|
||||
l1: &LineEnt,
|
||||
click1: [f64; 2],
|
||||
l2: &LineEnt,
|
||||
click2: [f64; 2],
|
||||
) -> Option<(EntityType, EntityType, Option<EntityType>)> {
|
||||
let (p1, p2, u1, len1) = line_geom(l1);
|
||||
let (p3, _p4, u2, len2) = line_geom(l2);
|
||||
|
||||
if len1 <= 1.0e-12 || len2 <= 1.0e-12 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// This helper is only for parallel / anti-parallel lines.
|
||||
let cross = u1[0] * u2[1] - u1[1] * u2[0];
|
||||
if cross.abs() > 1.0e-6 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The pick on the first line determines which end receives the round.
|
||||
let dist_sq = |a: [f64; 2], b: [f64; 2]| {
|
||||
let dx = a[0] - b[0];
|
||||
let dy = a[1] - b[1];
|
||||
dx * dx + dy * dy
|
||||
};
|
||||
|
||||
let (tangent1, other1) = if dist_sq(click1, p1) <= dist_sq(click1, p2) {
|
||||
(p1, p2)
|
||||
} else {
|
||||
(p2, p1)
|
||||
};
|
||||
|
||||
// Unit normal to line 1. Project tangent1 perpendicularly onto line 2.
|
||||
let normal = [-u1[1], u1[0]];
|
||||
let separation =
|
||||
(p3[0] - tangent1[0]) * normal[0] + (p3[1] - tangent1[1]) * normal[1];
|
||||
|
||||
if separation.abs() <= 1.0e-9 {
|
||||
// Coincident lines do not define a useful semicircle.
|
||||
return None;
|
||||
}
|
||||
|
||||
let tangent2 = [
|
||||
tangent1[0] + normal[0] * separation,
|
||||
tangent1[1] + normal[1] * separation,
|
||||
];
|
||||
|
||||
let centre = [
|
||||
(tangent1[0] + tangent2[0]) * 0.5,
|
||||
(tangent1[1] + tangent2[1]) * 0.5,
|
||||
];
|
||||
|
||||
let radius = separation.abs() * 0.5;
|
||||
|
||||
// Keep the existing part of each selected line and trim/extend the picked
|
||||
// end to its tangent point.
|
||||
let new_l1 = trim_line_to_point(l1, tangent1, click1)?;
|
||||
let new_l2 = trim_line_to_point(l2, tangent2, click2)?;
|
||||
|
||||
// The semicircle must bulge away from the retained portion of line 1.
|
||||
// `other1 - tangent1` points back into the line, so negate it.
|
||||
let keep = [
|
||||
other1[0] - tangent1[0],
|
||||
other1[1] - tangent1[1],
|
||||
];
|
||||
let keep_len = (keep[0] * keep[0] + keep[1] * keep[1]).sqrt();
|
||||
|
||||
if keep_len <= 1.0e-12 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bulge_dir = [-keep[0] / keep_len, -keep[1] / keep_len];
|
||||
|
||||
let a1 = norm_angle(
|
||||
(tangent1[1] - centre[1]).atan2(tangent1[0] - centre[0]),
|
||||
);
|
||||
let a2 = norm_angle(
|
||||
(tangent2[1] - centre[1]).atan2(tangent2[0] - centre[0]),
|
||||
);
|
||||
|
||||
// Between two antipodal points there are two possible semicircles.
|
||||
// Determine which CCW orientation bulges toward the selected end.
|
||||
let midpoint_angle = a1 + std::f64::consts::FRAC_PI_2;
|
||||
let midpoint_dir = [midpoint_angle.cos(), midpoint_angle.sin()];
|
||||
|
||||
let candidate_matches =
|
||||
midpoint_dir[0] * bulge_dir[0] + midpoint_dir[1] * bulge_dir[1] >= 0.0;
|
||||
|
||||
let (start_angle, end_angle) = if candidate_matches {
|
||||
(a1, a2)
|
||||
} else {
|
||||
(a2, a1)
|
||||
};
|
||||
|
||||
let mut arc = ArcEnt::new();
|
||||
arc.common = l1.common.clone();
|
||||
arc.common.handle = Handle::NULL;
|
||||
arc.center = Vector3::new(centre[0], centre[1], l1.start.z);
|
||||
arc.radius = radius;
|
||||
arc.start_angle = start_angle;
|
||||
arc.end_angle = end_angle;
|
||||
|
||||
Some((
|
||||
EntityType::Line(new_l1),
|
||||
EntityType::Line(new_l2),
|
||||
Some(EntityType::Arc(arc)),
|
||||
))
|
||||
}
|
||||
/// Compute fillet: trim l1/l2 and insert a tangent arc of `radius`.
|
||||
/// Returns (trimmed_l1, trimmed_l2, fillet_arc).
|
||||
fn compute_fillet(
|
||||
@@ -90,8 +205,18 @@ fn compute_fillet(
|
||||
let (p1, p2, u1, _len1) = line_geom(l1);
|
||||
let (p3, p4, u2, _len2) = line_geom(l2);
|
||||
|
||||
// Intersection of infinite lines
|
||||
let (t_p, _u_p) = ll(p1[0], p1[1], u1[0], u1[1], p3[0], p3[1], u2[0], u2[1])?;
|
||||
// Parallel lines are a special FILLET case: there is no intersection.
|
||||
// Build the tangent semicircle directly, temporarily ignoring the
|
||||
// configured radius.
|
||||
let cross = u1[0] * u2[1] - u1[1] * u2[0];
|
||||
|
||||
if cross.abs() <= 1.0e-6 {
|
||||
return fillet_parallel_lines(l1, click1, l2, click2);
|
||||
}
|
||||
|
||||
// Intersection of infinite non-parallel lines.
|
||||
let (t_p, _u_p) =
|
||||
ll(p1[0], p1[1], u1[0], u1[1], p3[0], p3[1], u2[0], u2[1])?;
|
||||
|
||||
// Intersection point
|
||||
let px = p1[0] + t_p * u1[0];
|
||||
@@ -123,14 +248,6 @@ fn compute_fillet(
|
||||
[-u2[0], -u2[1]]
|
||||
};
|
||||
|
||||
// Angle between the two keep-directions
|
||||
let cos_a = (dir1[0] * dir2[0] + dir1[1] * dir2[1]).clamp(-1.0, 1.0);
|
||||
let angle = cos_a.acos();
|
||||
|
||||
// Lines are parallel / anti-parallel
|
||||
if angle < 1e-6 || (angle - std::f64::consts::PI).abs() < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let z = l1.start.z;
|
||||
|
||||
@@ -337,7 +454,194 @@ fn compute_bulge(t1: [f64; 2], t2: [f64; 2], arc_center: [f64; 2]) -> f64 {
|
||||
-(span_cw / 4.0).tan() // CW arc
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct PolylineCornerFillet {
|
||||
incoming: [f64; 2],
|
||||
outgoing: [f64; 2],
|
||||
bulge: f64,
|
||||
}
|
||||
|
||||
fn segment_parameter(point: [f64; 2], start: [f64; 2], end: [f64; 2]) -> Option<f64> {
|
||||
let dx = end[0] - start[0];
|
||||
let dy = end[1] - start[1];
|
||||
let len2 = dx * dx + dy * dy;
|
||||
|
||||
if len2 <= 1.0e-20 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / len2,
|
||||
)
|
||||
}
|
||||
|
||||
/// Apply the current FILLET radius to every eligible corner of a 2D polyline.
|
||||
///
|
||||
/// Initial scope deliberately handles straight-segment corners only. Corners
|
||||
/// adjacent to an existing bulge arc are preserved unchanged.
|
||||
fn fillet_entire_lwpolyline(poly: &LwPolyline, radius: f64) -> Option<LwPolyline> {
|
||||
let n = poly.vertices.len();
|
||||
|
||||
if n < 3 || radius <= 1.0e-9 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut corners: Vec<Option<PolylineCornerFillet>> = vec![None; n];
|
||||
|
||||
let first_corner = if poly.is_closed { 0 } else { 1 };
|
||||
let end_corner = if poly.is_closed {
|
||||
n
|
||||
} else {
|
||||
n.saturating_sub(1)
|
||||
};
|
||||
|
||||
for i in first_corner..end_corner {
|
||||
let prev = if i == 0 { n - 1 } else { i - 1 };
|
||||
|
||||
// A bulge belongs to the segment starting at that vertex.
|
||||
// For now only fillet corners where both adjacent segments are straight.
|
||||
if poly.vertices[prev].bulge.abs() >= 1.0e-9
|
||||
|| poly.vertices[i].bulge.abs() >= 1.0e-9
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let incoming = lwpoly_seg_as_line(poly, prev);
|
||||
let outgoing = lwpoly_seg_as_line(poly, i);
|
||||
|
||||
// Pick points halfway along each segment, away from the corner.
|
||||
// This tells the existing line-line FILLET code which side to keep.
|
||||
let click_in = [
|
||||
(incoming.start.x + incoming.end.x) * 0.5,
|
||||
(incoming.start.y + incoming.end.y) * 0.5,
|
||||
];
|
||||
let click_out = [
|
||||
(outgoing.start.x + outgoing.end.x) * 0.5,
|
||||
(outgoing.start.y + outgoing.end.y) * 0.5,
|
||||
];
|
||||
|
||||
let Some((trimmed_in, trimmed_out, maybe_arc)) =
|
||||
compute_fillet(&incoming, click_in, &outgoing, click_out, radius)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let (
|
||||
EntityType::Line(trimmed_in),
|
||||
EntityType::Line(trimmed_out),
|
||||
Some(EntityType::Arc(arc)),
|
||||
) = (trimmed_in, trimmed_out, maybe_arc)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let tangent_in = [trimmed_in.end.x, trimmed_in.end.y];
|
||||
let tangent_out = [trimmed_out.start.x, trimmed_out.start.y];
|
||||
|
||||
let in_start = [incoming.start.x, incoming.start.y];
|
||||
let in_end = [incoming.end.x, incoming.end.y];
|
||||
let out_start = [outgoing.start.x, outgoing.start.y];
|
||||
let out_end = [outgoing.end.x, outgoing.end.y];
|
||||
|
||||
let Some(t_in) = segment_parameter(tangent_in, in_start, in_end) else {
|
||||
continue;
|
||||
};
|
||||
let Some(t_out) = segment_parameter(tangent_out, out_start, out_end) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Whole-polyline FILLET should not extend past neighbouring vertices.
|
||||
// If this radius cannot fit at this corner, leave that corner unchanged.
|
||||
if !(-1.0e-9..=1.0 + 1.0e-9).contains(&t_in)
|
||||
|| !(-1.0e-9..=1.0 + 1.0e-9).contains(&t_out)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
corners[i] = Some(PolylineCornerFillet {
|
||||
incoming: tangent_in,
|
||||
outgoing: tangent_out,
|
||||
bulge: compute_bulge(
|
||||
tangent_in,
|
||||
tangent_out,
|
||||
[arc.center.x, arc.center.y],
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if corners.iter().all(Option::is_none) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Adjacent fillets must not consume more than the shared segment.
|
||||
// Reject the operation rather than producing an inverted/overlapping edge.
|
||||
let segment_count = if poly.is_closed { n } else { n - 1 };
|
||||
|
||||
for i in 0..segment_count {
|
||||
let next = (i + 1) % n;
|
||||
|
||||
let start = corners[i]
|
||||
.map(|corner| corner.outgoing)
|
||||
.unwrap_or([
|
||||
poly.vertices[i].location.x,
|
||||
poly.vertices[i].location.y,
|
||||
]);
|
||||
|
||||
let end = corners[next]
|
||||
.map(|corner| corner.incoming)
|
||||
.unwrap_or([
|
||||
poly.vertices[next].location.x,
|
||||
poly.vertices[next].location.y,
|
||||
]);
|
||||
|
||||
let original_start = [
|
||||
poly.vertices[i].location.x,
|
||||
poly.vertices[i].location.y,
|
||||
];
|
||||
let original_end = [
|
||||
poly.vertices[next].location.x,
|
||||
poly.vertices[next].location.y,
|
||||
];
|
||||
|
||||
let Some(start_t) = segment_parameter(start, original_start, original_end) else {
|
||||
continue;
|
||||
};
|
||||
let Some(end_t) = segment_parameter(end, original_start, original_end) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if start_t > end_t + 1.0e-9 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = poly.clone();
|
||||
result.common.handle = Handle::NULL;
|
||||
result.vertices.clear();
|
||||
|
||||
for (i, original) in poly.vertices.iter().enumerate() {
|
||||
if let Some(corner) = corners[i] {
|
||||
let mut tangent_in = original.clone();
|
||||
tangent_in.location.x = corner.incoming[0];
|
||||
tangent_in.location.y = corner.incoming[1];
|
||||
tangent_in.bulge = corner.bulge;
|
||||
|
||||
let mut tangent_out = original.clone();
|
||||
tangent_out.location.x = corner.outgoing[0];
|
||||
tangent_out.location.y = corner.outgoing[1];
|
||||
|
||||
// This corner is only eligible when its outgoing segment is straight.
|
||||
tangent_out.bulge = 0.0;
|
||||
|
||||
result.vertices.push(tangent_in);
|
||||
result.vertices.push(tangent_out);
|
||||
} else {
|
||||
result.vertices.push(original.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Some(result)
|
||||
}
|
||||
/// Rebuild a LwPolyline, replacing the corner vertex at `corner_idx` with two
|
||||
/// new vertices T1 (start of fillet arc) and T2 (end of fillet arc).
|
||||
/// `bulge` encodes the direction and span of the arc (T1 → T2).
|
||||
@@ -991,6 +1295,7 @@ fn compute_chamfer(
|
||||
|
||||
enum FilletStep {
|
||||
First,
|
||||
Polyline,
|
||||
WaitingForRadius,
|
||||
Second {
|
||||
h1: Handle,
|
||||
@@ -1072,7 +1377,12 @@ impl CadCommand for FilletCommand {
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
FilletStep::First => crate::tf!(
|
||||
"FILLET Select first object (Line/Arc/LwPolyline) [R={:.4}]:",
|
||||
"FILLET Select first object or [Polyline/Radius] [R={:.4}]:",
|
||||
self.radius
|
||||
)
|
||||
.into_owned(),
|
||||
FilletStep::Polyline => crate::tf!(
|
||||
"FILLET Select 2D polyline [R={:.4}]:",
|
||||
self.radius
|
||||
)
|
||||
.into_owned(),
|
||||
@@ -1091,11 +1401,16 @@ impl CadCommand for FilletCommand {
|
||||
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
use crate::command::CmdOption;
|
||||
|
||||
match self.step {
|
||||
// Offer the radius keyword while selecting objects; the "R" token is
|
||||
// already handled by on_text_input.
|
||||
FilletStep::First | FilletStep::Second { .. } => vec![CmdOption::new("Radius", "R")],
|
||||
FilletStep::WaitingForRadius => vec![],
|
||||
FilletStep::First => vec![
|
||||
CmdOption::new("Polyline", "P"),
|
||||
CmdOption::new("Radius", "R"),
|
||||
],
|
||||
FilletStep::Second { .. } => {
|
||||
vec![CmdOption::new("Radius", "R")]
|
||||
}
|
||||
FilletStep::Polyline | FilletStep::WaitingForRadius => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1134,6 +1449,10 @@ impl CadCommand for FilletCommand {
|
||||
FilletStep::First | FilletStep::Second { .. } => {
|
||||
let t = text.trim();
|
||||
let upper = t.to_uppercase();
|
||||
if matches!(self.step, FilletStep::First) && upper == "P" {
|
||||
self.step = FilletStep::Polyline;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
// "R" alone → enter sub-step to collect radius
|
||||
if upper == "R" {
|
||||
self.enter_radius_substep();
|
||||
@@ -1156,6 +1475,7 @@ impl CadCommand for FilletCommand {
|
||||
}
|
||||
None
|
||||
}
|
||||
FilletStep::Polyline => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1171,6 +1491,28 @@ impl CadCommand for FilletCommand {
|
||||
|
||||
match &self.step {
|
||||
FilletStep::WaitingForRadius => return CmdResult::NeedPoint,
|
||||
FilletStep::Polyline => {
|
||||
let poly = self
|
||||
.entity_index
|
||||
.get(&self.all_entities, handle)
|
||||
.and_then(|entity| match entity {
|
||||
EntityType::LwPolyline(poly) => Some(poly.clone()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let Some(poly) = poly else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
|
||||
let Some(result) = fillet_entire_lwpolyline(&poly, self.radius) else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
|
||||
self.continue_after_fillet(vec![(
|
||||
handle,
|
||||
vec![EntityType::LwPolyline(result)],
|
||||
)])
|
||||
}
|
||||
FilletStep::First => {
|
||||
let e1 = self
|
||||
.entity_index
|
||||
@@ -1245,6 +1587,28 @@ impl CadCommand for FilletCommand {
|
||||
|
||||
match &self.step {
|
||||
FilletStep::WaitingForRadius => vec![],
|
||||
FilletStep::Polyline => {
|
||||
let preview = self
|
||||
.entity_index
|
||||
.get(&self.all_entities, handle)
|
||||
.and_then(|entity| match entity {
|
||||
EntityType::LwPolyline(poly) => {
|
||||
fillet_entire_lwpolyline(poly, self.radius)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
|
||||
preview
|
||||
.map(|poly| {
|
||||
vec![WireModel::solid(
|
||||
"fillet_polyline_preview".into(),
|
||||
lwpoly_pts(&poly),
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
)]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
FilletStep::First => {
|
||||
let pts = self
|
||||
.entity_index
|
||||
@@ -1515,12 +1879,13 @@ impl CadCommand for ChamferCommand {
|
||||
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
use crate::command::CmdOption;
|
||||
|
||||
match self.step {
|
||||
// Offer the distance keyword while selecting objects; the "D" token
|
||||
// is already handled by on_text_input.
|
||||
ChamferStep::First
|
||||
| ChamferStep::Second { .. }
|
||||
| ChamferStep::SecondPoly { .. } => vec![CmdOption::new(t!("Distance").as_ref(), "D")],
|
||||
| ChamferStep::SecondPoly { .. } => {
|
||||
vec![CmdOption::new(t!("Distance").as_ref(), "D")]
|
||||
}
|
||||
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::modules::draw::fence::FencePick;
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
pub struct SelectObjectsCommand {
|
||||
prompt_cmd: String,
|
||||
pending_cmd: String,
|
||||
/// Selection accumulated so far (kept in sync with the scene selection by
|
||||
/// each `on_selection_complete` call). Applied on Enter when `commit_on_enter`.
|
||||
@@ -24,6 +25,8 @@ pub struct SelectObjectsCommand {
|
||||
/// When true, gathering continues until Enter / right-click commits the set.
|
||||
/// When false, the first completed selection action fires immediately.
|
||||
commit_on_enter: bool,
|
||||
/// Whether the generic selection methods are shown as command buttons.
|
||||
show_options: bool,
|
||||
/// A Fence / WPolygon / CPolygon path being picked point by point. While
|
||||
/// one is under way the host must send clicks here as points rather than
|
||||
/// treating them as picks, which is what `is_selection_gathering` reports.
|
||||
@@ -37,9 +40,25 @@ impl SelectObjectsCommand {
|
||||
/// Standard selection set: accumulate picks, apply on Enter / right-click.
|
||||
pub fn new(pending_cmd: &str) -> Self {
|
||||
Self {
|
||||
prompt_cmd: pending_cmd.to_string(),
|
||||
pending_cmd: pending_cmd.to_string(),
|
||||
handles: Vec::new(),
|
||||
commit_on_enter: true,
|
||||
show_options: true,
|
||||
pick: None,
|
||||
pick_crossing: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain gather prompt with no generic selection-method buttons. The
|
||||
/// visible command name may differ from the private apply command.
|
||||
pub fn plain(prompt_cmd: &str, pending_cmd: &str) -> Self {
|
||||
Self {
|
||||
prompt_cmd: prompt_cmd.to_string(),
|
||||
pending_cmd: pending_cmd.to_string(),
|
||||
handles: Vec::new(),
|
||||
commit_on_enter: true,
|
||||
show_options: false,
|
||||
pick: None,
|
||||
pick_crossing: true,
|
||||
}
|
||||
@@ -49,9 +68,11 @@ impl SelectObjectsCommand {
|
||||
/// immediately, with no Enter (used by commands that act on one object).
|
||||
pub fn instant(pending_cmd: &str) -> Self {
|
||||
Self {
|
||||
prompt_cmd: pending_cmd.to_string(),
|
||||
pending_cmd: pending_cmd.to_string(),
|
||||
handles: Vec::new(),
|
||||
commit_on_enter: false,
|
||||
show_options: false,
|
||||
pick: None,
|
||||
pick_crossing: true,
|
||||
}
|
||||
@@ -82,12 +103,12 @@ impl CadCommand for SelectObjectsCommand {
|
||||
if self.commit_on_enter && !self.handles.is_empty() {
|
||||
t!(
|
||||
"%{cmd} Select objects (%{count} selected, Enter to apply):",
|
||||
cmd = self.pending_cmd,
|
||||
cmd = self.prompt_cmd,
|
||||
count = self.handles.len()
|
||||
)
|
||||
.into_owned()
|
||||
} else {
|
||||
t!("%{cmd} Select objects:", cmd = self.pending_cmd).into_owned()
|
||||
t!("%{cmd} Select objects:", cmd = self.prompt_cmd).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +129,7 @@ impl CadCommand for SelectObjectsCommand {
|
||||
// they work typed as well — these buttons only surface them, which is what
|
||||
// the on-screen keyboard-less case needs.
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
if self.commit_on_enter {
|
||||
if self.commit_on_enter && self.show_options {
|
||||
vec![
|
||||
CmdOption::new(t!("Window").as_ref(), "W"),
|
||||
CmdOption::new(t!("Crossing").as_ref(), "C"),
|
||||
|
||||
@@ -2211,7 +2211,7 @@ impl LoftCommand {
|
||||
fn option_prompt(&self) -> String {
|
||||
match self.state.step {
|
||||
LoftStep::Sections => format!(
|
||||
"{} ({}):", t!("LOFT Select cross-sections in order or [Point/Join/Mode/Undo] (Enter to finish)"),
|
||||
"{} ({}):", t!("LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish)"),
|
||||
self.state.sections.len(),
|
||||
),
|
||||
LoftStep::Join => format!(
|
||||
@@ -2224,9 +2224,9 @@ impl LoftCommand {
|
||||
LoftStep::Options => {
|
||||
let (start, end) = self.point_ends();
|
||||
if start || end {
|
||||
t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude/Mode/Undo] <Cross sections only>:").into_owned()
|
||||
t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] <Cross sections only>:").into_owned()
|
||||
} else {
|
||||
t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Mode/Undo] <Cross sections only>:").into_owned()
|
||||
t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings] <Cross sections only>:").into_owned()
|
||||
}
|
||||
}
|
||||
LoftStep::Guides => format!("{} ({}):", t!("LOFT Select guide curves or [Undo] (Enter to finish)"), self.state.guides.len()),
|
||||
@@ -2272,7 +2272,7 @@ impl CadCommand for LoftCommand {
|
||||
}
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
let mut options = match self.state.step {
|
||||
LoftStep::Sections => vec![CmdOption::new("Point", "POINT"), CmdOption::new("Join", "JOIN"), CmdOption::new("Mode", "MODE"), CmdOption::enter("Done")],
|
||||
LoftStep::Sections => vec![CmdOption::new("Point", "POINT"), CmdOption::new("Join", "JOIN"), CmdOption::new("Mode", "MODE")],
|
||||
LoftStep::Join | LoftStep::Guides => vec![CmdOption::enter("Done")],
|
||||
LoftStep::Mode => vec![CmdOption::new("Solid", "SOLID"), CmdOption::new("Surface", "SURFACE")],
|
||||
LoftStep::Options => {
|
||||
@@ -2282,7 +2282,6 @@ impl CadCommand for LoftCommand {
|
||||
choices.push(CmdOption::new("Continuity", "CONTINUITY"));
|
||||
choices.push(CmdOption::new("Bulge magnitude", "BULGE"));
|
||||
}
|
||||
choices.push(CmdOption::new("Mode", "MODE"));
|
||||
choices
|
||||
}
|
||||
LoftStep::Settings => {
|
||||
@@ -2308,7 +2307,9 @@ impl CadCommand for LoftCommand {
|
||||
LoftStep::StartContinuity | LoftStep::EndContinuity => vec![CmdOption::new("G0", "G0"), CmdOption::new("G1", "G1")],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
options.push(CmdOption::new("Undo", "UNDO"));
|
||||
if !matches!(self.state.step, LoftStep::Sections | LoftStep::Options) {
|
||||
options.push(CmdOption::new("Undo", "UNDO"));
|
||||
}
|
||||
options
|
||||
}
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
|
||||
@@ -25,16 +25,20 @@ impl BoolOp {
|
||||
enum SubtractStep {
|
||||
Bases,
|
||||
Cutters,
|
||||
ConvertMeshes,
|
||||
}
|
||||
|
||||
pub struct SubtractCommand {
|
||||
step: SubtractStep,
|
||||
bases: Vec<Handle>,
|
||||
cutters: Vec<Handle>,
|
||||
selected: Vec<Handle>,
|
||||
bases_have_mesh: bool,
|
||||
selected_has_mesh: bool,
|
||||
}
|
||||
|
||||
impl SubtractCommand {
|
||||
pub fn new(bases: Vec<Handle>) -> Self {
|
||||
pub fn new(bases: Vec<Handle>, bases_have_mesh: bool) -> Self {
|
||||
let step = if bases.is_empty() {
|
||||
SubtractStep::Bases
|
||||
} else {
|
||||
@@ -43,7 +47,18 @@ impl SubtractCommand {
|
||||
Self {
|
||||
step,
|
||||
bases,
|
||||
cutters: Vec::new(),
|
||||
selected: Vec::new(),
|
||||
bases_have_mesh,
|
||||
selected_has_mesh: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, convert_meshes: bool) -> CmdResult {
|
||||
CmdResult::SolidSubtract {
|
||||
bases: std::mem::take(&mut self.bases),
|
||||
cutters: std::mem::take(&mut self.cutters),
|
||||
convert_meshes,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,11 +70,26 @@ impl CadCommand for SubtractCommand {
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
SubtractStep::Bases => crate::t!("SUBTRACT Select base solids, then press Enter:")
|
||||
SubtractStep::Bases => crate::t!("SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter:")
|
||||
.into_owned(),
|
||||
SubtractStep::Cutters => {
|
||||
crate::t!("SUBTRACT Select solids to subtract, then press Enter:").into_owned()
|
||||
crate::t!("SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter:").into_owned()
|
||||
}
|
||||
SubtractStep::ConvertMeshes => crate::t!(
|
||||
"SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] <Yes>:"
|
||||
)
|
||||
.into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
if matches!(self.step, SubtractStep::ConvertMeshes) {
|
||||
vec![
|
||||
crate::command::CmdOption::new(crate::t!("Yes").as_ref(), "Y"),
|
||||
crate::command::CmdOption::new(crate::t!("No").as_ref(), "N"),
|
||||
]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,31 +98,72 @@ impl CadCommand for SubtractCommand {
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if matches!(self.step, SubtractStep::ConvertMeshes) {
|
||||
return self.finish(true);
|
||||
}
|
||||
if self.selected.is_empty() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
match self.step {
|
||||
SubtractStep::Bases => {
|
||||
self.bases = std::mem::take(&mut self.selected);
|
||||
self.bases_have_mesh = self.selected_has_mesh;
|
||||
self.selected_has_mesh = false;
|
||||
self.step = SubtractStep::Cutters;
|
||||
CmdResult::DeselectAndContinue
|
||||
}
|
||||
SubtractStep::Cutters => CmdResult::SolidSubtract {
|
||||
bases: std::mem::take(&mut self.bases),
|
||||
cutters: std::mem::take(&mut self.selected),
|
||||
},
|
||||
SubtractStep::Cutters => {
|
||||
self.cutters = std::mem::take(&mut self.selected);
|
||||
if self.bases_have_mesh || self.selected_has_mesh {
|
||||
self.step = SubtractStep::ConvertMeshes;
|
||||
CmdResult::NeedPoint
|
||||
} else {
|
||||
self.finish(false)
|
||||
}
|
||||
}
|
||||
SubtractStep::ConvertMeshes => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.step, SubtractStep::ConvertMeshes)
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if !matches!(self.step, SubtractStep::ConvertMeshes) {
|
||||
return None;
|
||||
}
|
||||
Some(match text.trim().to_ascii_lowercase().as_str() {
|
||||
"" | "y" | "yes" => self.finish(true),
|
||||
"n" | "no" => self.finish(false),
|
||||
_ => CmdResult::NeedPoint,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_selection_gathering(&self) -> bool {
|
||||
true
|
||||
!matches!(self.step, SubtractStep::ConvertMeshes)
|
||||
}
|
||||
|
||||
fn inject_selection_entities(&mut self, entities: Vec<SelectionEntity>) {
|
||||
self.selected_has_mesh = entities.iter().any(|entity| {
|
||||
matches!(
|
||||
entity.entity,
|
||||
EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_)
|
||||
)
|
||||
});
|
||||
self.selected = entities
|
||||
.into_iter()
|
||||
.filter_map(|entity| {
|
||||
matches!(entity.entity, EntityType::Solid3D(_)).then_some(entity.handle)
|
||||
matches!(
|
||||
entity.entity,
|
||||
EntityType::Solid3D(_)
|
||||
| EntityType::Region(_)
|
||||
| EntityType::Surface(_)
|
||||
| EntityType::Mesh(_)
|
||||
| EntityType::PolygonMesh(_)
|
||||
| EntityType::PolyfaceMesh(_)
|
||||
)
|
||||
.then_some(entity.handle)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
+781
-43
@@ -1,42 +1,311 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use acadrust::Handle;
|
||||
use cadkernel::brep::{Body, EdgeKey, FaceKey};
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{
|
||||
CadCommand, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec,
|
||||
CadCommand, CmdOption, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec,
|
||||
};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
static FILLET_RADIUS_BITS: AtomicU64 = AtomicU64::new(1.0f64.to_bits());
|
||||
|
||||
fn fillet_radius() -> f64 {
|
||||
f64::from_bits(FILLET_RADIUS_BITS.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn set_fillet_radius(value: f64) {
|
||||
FILLET_RADIUS_BITS.store(value.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EdgeOperation {
|
||||
Fillet,
|
||||
Chamfer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum EdgeSelectionMode {
|
||||
Edge,
|
||||
Chain,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum EdgeStep {
|
||||
Selecting,
|
||||
PickingLoop,
|
||||
LoopConfirm,
|
||||
PreviewConfirm,
|
||||
Radius,
|
||||
Expression,
|
||||
ChamferDistance1,
|
||||
ChamferDistance2,
|
||||
}
|
||||
|
||||
pub struct SolidEdgeCommand {
|
||||
operation: EdgeOperation,
|
||||
target: Option<Handle>,
|
||||
picked: Option<(Handle, DVec3)>,
|
||||
bodies: Vec<(Handle, Body)>,
|
||||
handle: Option<Handle>,
|
||||
selected_edges: Vec<EdgeKey>,
|
||||
base_face: Option<FaceKey>,
|
||||
selection_batches: Vec<(Vec<EdgeKey>, DVec3)>,
|
||||
selection_mode: EdgeSelectionMode,
|
||||
step: EdgeStep,
|
||||
value_return: EdgeStep,
|
||||
expression_return: EdgeStep,
|
||||
loop_candidates: Vec<(FaceKey, Vec<EdgeKey>)>,
|
||||
loop_index: usize,
|
||||
last_pick: Option<DVec3>,
|
||||
default_value: f64,
|
||||
other_value: f64,
|
||||
preview_color: [f32; 4],
|
||||
preview_wires: Vec<WireModel>,
|
||||
preview_hidden: Vec<Handle>,
|
||||
}
|
||||
|
||||
impl SolidEdgeCommand {
|
||||
pub fn new(operation: EdgeOperation, target: Option<Handle>) -> Self {
|
||||
pub fn new(
|
||||
operation: EdgeOperation,
|
||||
target: Option<Handle>,
|
||||
bodies: Vec<(Handle, Body)>,
|
||||
preview_color: [f32; 4],
|
||||
) -> Self {
|
||||
Self {
|
||||
operation,
|
||||
target,
|
||||
picked: None,
|
||||
default_value: 1.0,
|
||||
bodies,
|
||||
handle: None,
|
||||
selected_edges: Vec::new(),
|
||||
base_face: None,
|
||||
selection_batches: Vec::new(),
|
||||
selection_mode: EdgeSelectionMode::Edge,
|
||||
step: EdgeStep::Selecting,
|
||||
value_return: EdgeStep::Selecting,
|
||||
expression_return: EdgeStep::Radius,
|
||||
loop_candidates: Vec::new(),
|
||||
loop_index: 0,
|
||||
last_pick: None,
|
||||
default_value: if operation == EdgeOperation::Fillet {
|
||||
fillet_radius()
|
||||
} else {
|
||||
1.0
|
||||
},
|
||||
other_value: 1.0,
|
||||
preview_color,
|
||||
preview_wires: Vec::new(),
|
||||
preview_hidden: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self, value: f64) -> CmdResult {
|
||||
let Some((handle, pick)) = self.picked else {
|
||||
pub fn new_chamfer(
|
||||
target: Option<Handle>,
|
||||
bodies: Vec<(Handle, Body)>,
|
||||
preview_color: [f32; 4],
|
||||
distances: (f64, f64),
|
||||
) -> Self {
|
||||
let mut command = Self::new(EdgeOperation::Chamfer, target, bodies, preview_color);
|
||||
command.default_value = positive_default(distances.0);
|
||||
command.other_value = positive_default(distances.1);
|
||||
command
|
||||
}
|
||||
|
||||
pub fn chamfer_distances(&self) -> (f64, f64) {
|
||||
(self.default_value, self.other_value)
|
||||
}
|
||||
|
||||
fn body(&self, handle: Handle) -> Option<&Body> {
|
||||
self.bodies
|
||||
.iter()
|
||||
.find_map(|(candidate, body)| (*candidate == handle).then_some(body))
|
||||
}
|
||||
|
||||
fn active_body(&self) -> Option<(Handle, &Body)> {
|
||||
let handle = self.handle?;
|
||||
self.body(handle).map(|body| (handle, body))
|
||||
}
|
||||
|
||||
fn pick_allowed(&self, handle: Handle) -> bool {
|
||||
!handle.is_null()
|
||||
&& self.target.is_none_or(|target| target == handle)
|
||||
&& self.handle.is_none_or(|selected| selected == handle)
|
||||
&& self.body(handle).is_some()
|
||||
}
|
||||
|
||||
fn add_batch(&mut self, edges: Vec<EdgeKey>, anchor: DVec3) -> bool {
|
||||
let batch = edges
|
||||
.into_iter()
|
||||
.filter(|edge| !self.selected_edges.contains(edge))
|
||||
.collect::<Vec<_>>();
|
||||
if batch.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.selected_edges.extend(batch.iter().copied());
|
||||
self.selection_batches.push((batch, anchor));
|
||||
true
|
||||
}
|
||||
|
||||
fn current_loop(&self) -> Option<&[EdgeKey]> {
|
||||
self.loop_candidates
|
||||
.get(self.loop_index)
|
||||
.map(|(_, edges)| edges.as_slice())
|
||||
}
|
||||
|
||||
fn current_loop_face(&self) -> Option<FaceKey> {
|
||||
self.loop_candidates
|
||||
.get(self.loop_index)
|
||||
.map(|(face, _)| *face)
|
||||
}
|
||||
|
||||
fn preview_edges(&self) -> Vec<EdgeKey> {
|
||||
let mut edges = self.selected_edges.clone();
|
||||
if self.step == EdgeStep::LoopConfirm {
|
||||
if let Some(loop_edges) = self.current_loop() {
|
||||
for edge in loop_edges {
|
||||
if !edges.contains(edge) {
|
||||
edges.push(*edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
edges
|
||||
}
|
||||
|
||||
fn preview_active(&self) -> bool {
|
||||
matches!(
|
||||
self.step,
|
||||
EdgeStep::Selecting
|
||||
| EdgeStep::PickingLoop
|
||||
| EdgeStep::LoopConfirm
|
||||
| EdgeStep::PreviewConfirm
|
||||
| EdgeStep::Radius
|
||||
| EdgeStep::Expression
|
||||
| EdgeStep::ChamferDistance1
|
||||
| EdgeStep::ChamferDistance2
|
||||
)
|
||||
}
|
||||
|
||||
fn preview_for_values(
|
||||
&self,
|
||||
value: f64,
|
||||
other_value: f64,
|
||||
) -> Option<(Handle, Vec<WireModel>)> {
|
||||
if !self.preview_active() {
|
||||
return None;
|
||||
}
|
||||
let edges = self.preview_edges();
|
||||
if edges.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (handle, body) = self.active_body()?;
|
||||
let result = match self.operation {
|
||||
EdgeOperation::Fillet => cadkernel::brep::fillet_edges(body, &edges, value).ok()?,
|
||||
EdgeOperation::Chamfer => cadkernel::brep::chamfer_edges(
|
||||
body,
|
||||
&edges,
|
||||
self.base_face.or_else(|| self.current_loop_face())?,
|
||||
value,
|
||||
other_value,
|
||||
)
|
||||
.ok()?,
|
||||
};
|
||||
let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle);
|
||||
for wire in &mut wires {
|
||||
wire.color = self.preview_color;
|
||||
wire.name = format!("{}-{}-PREVIEW", handle.value(), self.name());
|
||||
}
|
||||
(!wires.is_empty()).then_some((handle, wires))
|
||||
}
|
||||
|
||||
fn rebuild_preview(&mut self) {
|
||||
self.preview_wires.clear();
|
||||
self.preview_hidden.clear();
|
||||
if let Some((handle, wires)) =
|
||||
self.preview_for_values(self.default_value, self.other_value)
|
||||
{
|
||||
self.preview_wires = wires;
|
||||
self.preview_hidden.push(handle);
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_radius(&mut self, return_to: EdgeStep) -> CmdResult {
|
||||
self.value_return = return_to;
|
||||
self.step = EdgeStep::Radius;
|
||||
self.rebuild_preview();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn accept_radius(&mut self, value: f64) -> Option<CmdResult> {
|
||||
if value <= 0.0 || !value.is_finite() {
|
||||
return None;
|
||||
}
|
||||
self.default_value = value;
|
||||
if self.operation == EdgeOperation::Fillet {
|
||||
set_fillet_radius(value);
|
||||
}
|
||||
self.step = self.value_return;
|
||||
self.rebuild_preview();
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn begin_distances(&mut self, return_to: EdgeStep) -> CmdResult {
|
||||
self.value_return = return_to;
|
||||
self.step = EdgeStep::ChamferDistance1;
|
||||
self.rebuild_preview();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn accept_distance(&mut self, value: f64) -> Option<CmdResult> {
|
||||
if value <= 0.0 || !value.is_finite() {
|
||||
return None;
|
||||
}
|
||||
match self.step {
|
||||
EdgeStep::ChamferDistance1 => {
|
||||
self.default_value = value;
|
||||
self.step = EdgeStep::ChamferDistance2;
|
||||
}
|
||||
EdgeStep::ChamferDistance2 => {
|
||||
self.other_value = value;
|
||||
self.step = self.value_return;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
self.rebuild_preview();
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn accept_loop(&mut self) -> CmdResult {
|
||||
if let (Some(edges), Some(anchor)) = (
|
||||
self.current_loop().map(|edges| edges.to_vec()),
|
||||
self.last_pick,
|
||||
) {
|
||||
if self.operation == EdgeOperation::Chamfer && self.base_face.is_none() {
|
||||
self.base_face = self.current_loop_face();
|
||||
}
|
||||
self.add_batch(edges, anchor);
|
||||
}
|
||||
self.loop_candidates.clear();
|
||||
self.loop_index = 0;
|
||||
self.step = EdgeStep::Selecting;
|
||||
self.rebuild_preview();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn finish(&self) -> CmdResult {
|
||||
let Some(handle) = self.handle else {
|
||||
return CmdResult::Cancel;
|
||||
};
|
||||
if self.selected_edges.is_empty() {
|
||||
return CmdResult::Cancel;
|
||||
}
|
||||
CmdResult::SolidEdgeBlend {
|
||||
handle,
|
||||
pick,
|
||||
value,
|
||||
fillet: matches!(self.operation, EdgeOperation::Fillet),
|
||||
edges: self.selected_edges.clone(),
|
||||
base_face: self.base_face,
|
||||
value: self.default_value,
|
||||
other_value: self.other_value,
|
||||
fillet: self.operation == EdgeOperation::Fillet,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,27 +313,114 @@ impl SolidEdgeCommand {
|
||||
impl CadCommand for SolidEdgeCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
match self.operation {
|
||||
EdgeOperation::Fillet => "SOLIDFILLET",
|
||||
EdgeOperation::Chamfer => "SOLIDCHAMFER",
|
||||
EdgeOperation::Fillet => "FILLETEDGE",
|
||||
EdgeOperation::Chamfer => "CHAMFEREDGE",
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.picked.is_none() {
|
||||
return crate::t!("Select a solid edge:").into_owned();
|
||||
match (self.operation, self.step, self.selection_mode) {
|
||||
(EdgeOperation::Chamfer, EdgeStep::Selecting, _) => {
|
||||
crate::t!("Select an edge or [Loop/Distance]:").into_owned()
|
||||
}
|
||||
(EdgeOperation::Chamfer, EdgeStep::PickingLoop, _) => {
|
||||
crate::t!("Select edge of loop or [Edge/Distance]:").into_owned()
|
||||
}
|
||||
(EdgeOperation::Chamfer, EdgeStep::PreviewConfirm, _) => {
|
||||
crate::t!("Press Enter to accept the chamfer or [Distance]:").into_owned()
|
||||
}
|
||||
(EdgeOperation::Chamfer, EdgeStep::ChamferDistance1, _) => crate::tf!(
|
||||
"Specify Distance1 or [Expression] <{:.4}>:",
|
||||
self.default_value
|
||||
)
|
||||
.into_owned(),
|
||||
(EdgeOperation::Chamfer, EdgeStep::ChamferDistance2, _) => crate::tf!(
|
||||
"Specify Distance2 or [Expression] <{:.4}>:",
|
||||
self.other_value
|
||||
)
|
||||
.into_owned(),
|
||||
(_, EdgeStep::Selecting, EdgeSelectionMode::Edge) => {
|
||||
crate::t!("Select an edge or [Chain/Loop/Radius]:").into_owned()
|
||||
}
|
||||
(_, EdgeStep::Selecting, EdgeSelectionMode::Chain) => {
|
||||
crate::t!("Select an edge chain or [Edge/Radius]:").into_owned()
|
||||
}
|
||||
(_, EdgeStep::PickingLoop, _) => {
|
||||
crate::t!("Select edge of loop or [Edge/Chain/Radius]:").into_owned()
|
||||
}
|
||||
(_, EdgeStep::LoopConfirm, _) => {
|
||||
crate::t!("Enter an option [Accept/Next] <Accept>:").into_owned()
|
||||
}
|
||||
(_, EdgeStep::PreviewConfirm, _) => {
|
||||
crate::t!("Press Enter to accept the fillet or [Radius]:").into_owned()
|
||||
}
|
||||
(_, EdgeStep::Radius, _) if self.value_return == EdgeStep::PreviewConfirm => crate::tf!(
|
||||
"Specify Radius or [Expression] <{:.4}>:",
|
||||
self.default_value
|
||||
)
|
||||
.into_owned(),
|
||||
(_, EdgeStep::Radius, _) => crate::tf!(
|
||||
"Enter fillet radius or [Expression] <{:.4}>:",
|
||||
self.default_value
|
||||
)
|
||||
.into_owned(),
|
||||
(_, EdgeStep::Expression, _) => crate::t!("Enter expression:").into_owned(),
|
||||
_ => String::new(),
|
||||
}
|
||||
match self.operation {
|
||||
EdgeOperation::Fillet => {
|
||||
crate::tf!("Specify fillet radius <{:.3}>:", self.default_value).into_owned()
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match (self.operation, self.step, self.selection_mode) {
|
||||
(EdgeOperation::Chamfer, EdgeStep::Selecting, _) => vec![
|
||||
CmdOption::new("Loop", "L"),
|
||||
CmdOption::new("Distance", "D"),
|
||||
],
|
||||
(EdgeOperation::Chamfer, EdgeStep::PickingLoop, _) => vec![
|
||||
CmdOption::new("Edge", "E"),
|
||||
CmdOption::new("Distance", "D"),
|
||||
],
|
||||
(EdgeOperation::Chamfer, EdgeStep::LoopConfirm, _) => vec![
|
||||
CmdOption::new("Accept", "A"),
|
||||
CmdOption::new("Next", "N"),
|
||||
],
|
||||
(EdgeOperation::Chamfer, EdgeStep::PreviewConfirm, _) => {
|
||||
vec![CmdOption::new("Distance", "D")]
|
||||
}
|
||||
EdgeOperation::Chamfer => {
|
||||
crate::tf!("Specify chamfer distance <{:.3}>:", self.default_value).into_owned()
|
||||
(
|
||||
EdgeOperation::Chamfer,
|
||||
EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2,
|
||||
_,
|
||||
) => vec![CmdOption::new("Expression", "E")],
|
||||
(EdgeOperation::Fillet, EdgeStep::Selecting, EdgeSelectionMode::Edge) => vec![
|
||||
CmdOption::new("Chain", "C"),
|
||||
CmdOption::new("Loop", "L"),
|
||||
CmdOption::new("Radius", "R"),
|
||||
],
|
||||
(EdgeOperation::Fillet, EdgeStep::Selecting, EdgeSelectionMode::Chain) => vec![
|
||||
CmdOption::new("Edge", "E"),
|
||||
CmdOption::new("Radius", "R"),
|
||||
],
|
||||
(EdgeOperation::Fillet, EdgeStep::PickingLoop, _) => vec![
|
||||
CmdOption::new("Edge", "E"),
|
||||
CmdOption::new("Chain", "C"),
|
||||
CmdOption::new("Radius", "R"),
|
||||
],
|
||||
(EdgeOperation::Fillet, EdgeStep::LoopConfirm, _) => vec![
|
||||
CmdOption::new("Accept", "A"),
|
||||
CmdOption::new("Next", "N"),
|
||||
],
|
||||
(EdgeOperation::Fillet, EdgeStep::PreviewConfirm, _) => {
|
||||
vec![CmdOption::new("Radius", "R")]
|
||||
}
|
||||
(EdgeOperation::Fillet, EdgeStep::Radius, _) => {
|
||||
vec![CmdOption::new("Expression", "E")]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
self.picked.is_none()
|
||||
matches!(self.step, EdgeStep::Selecting | EdgeStep::PickingLoop)
|
||||
}
|
||||
|
||||
fn entity_pick_includes_fills(&self) -> bool {
|
||||
@@ -80,52 +436,281 @@ impl CadCommand for SolidEdgeCommand {
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
|
||||
if handle.is_null() || self.target.is_some_and(|target| target != handle) {
|
||||
if !self.pick_allowed(handle) {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.picked = Some((handle, point));
|
||||
let Some(seed) = self
|
||||
.body(handle)
|
||||
.and_then(|body| crate::scene::model::solid_model::nearest_edge(body, point.to_array()))
|
||||
else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
self.handle = Some(handle);
|
||||
self.last_pick = Some(point);
|
||||
|
||||
if self.step == EdgeStep::PickingLoop {
|
||||
self.loop_candidates = edge_loops(self.body(handle).expect("pick body exists"), seed);
|
||||
if self.operation == EdgeOperation::Chamfer {
|
||||
if let Some(base_face) = self.base_face {
|
||||
self.loop_candidates
|
||||
.retain(|(face, _)| *face == base_face);
|
||||
}
|
||||
}
|
||||
self.loop_index = 0;
|
||||
self.step = if self.loop_candidates.is_empty() {
|
||||
EdgeStep::Selecting
|
||||
} else {
|
||||
EdgeStep::LoopConfirm
|
||||
};
|
||||
self.rebuild_preview();
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
|
||||
if self.operation == EdgeOperation::Chamfer {
|
||||
if self.base_face.is_none() {
|
||||
self.base_face = self.body(handle).and_then(|body| {
|
||||
nearest_edge_face(body, seed, point.to_array())
|
||||
});
|
||||
}
|
||||
let Some(base_face) = self.base_face else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
if !edge_belongs_to_face(self.body(handle).expect("pick body exists"), seed, base_face)
|
||||
{
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.add_batch(vec![seed], point);
|
||||
self.rebuild_preview();
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
|
||||
let edges = match self.selection_mode {
|
||||
EdgeSelectionMode::Edge => vec![seed],
|
||||
EdgeSelectionMode::Chain => edge_chain(self.body(handle).expect("pick body exists"), seed),
|
||||
};
|
||||
self.add_batch(edges, point);
|
||||
self.rebuild_preview();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_point(&mut self, point: DVec3) -> CmdResult {
|
||||
let Some((_, pick)) = self.picked else {
|
||||
if !matches!(
|
||||
self.step,
|
||||
EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2
|
||||
) {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
let Some(anchor) = self.last_pick else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
let value = point.distance(pick);
|
||||
if value > 0.0 && value.is_finite() {
|
||||
self.finish(value)
|
||||
} else {
|
||||
CmdResult::NeedPoint
|
||||
let value = point.distance(anchor);
|
||||
if value <= 0.0 || !value.is_finite() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
match self.step {
|
||||
EdgeStep::Radius => self.accept_radius(value).unwrap_or(CmdResult::NeedPoint),
|
||||
EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => {
|
||||
self.accept_distance(value).unwrap_or(CmdResult::NeedPoint)
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if self.picked.is_none() {
|
||||
return None;
|
||||
let keyword = text.trim().to_ascii_uppercase();
|
||||
if self.operation == EdgeOperation::Chamfer {
|
||||
return match self.step {
|
||||
EdgeStep::Selecting => match keyword.as_str() {
|
||||
"L" | "LOOP" => {
|
||||
self.step = EdgeStep::PickingLoop;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::Selecting)),
|
||||
_ => None,
|
||||
},
|
||||
EdgeStep::PickingLoop => match keyword.as_str() {
|
||||
"E" | "EDGE" => {
|
||||
self.step = EdgeStep::Selecting;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::PickingLoop)),
|
||||
_ => None,
|
||||
},
|
||||
EdgeStep::LoopConfirm => match keyword.as_str() {
|
||||
"A" | "ACCEPT" => Some(self.accept_loop()),
|
||||
"N" | "NEXT" if !self.loop_candidates.is_empty() => {
|
||||
self.loop_index = (self.loop_index + 1) % self.loop_candidates.len();
|
||||
self.rebuild_preview();
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
EdgeStep::PreviewConfirm => match keyword.as_str() {
|
||||
"D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::PreviewConfirm)),
|
||||
_ => None,
|
||||
},
|
||||
EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => {
|
||||
if matches!(keyword.as_str(), "E" | "EXPRESSION") {
|
||||
self.expression_return = self.step;
|
||||
self.step = EdgeStep::Expression;
|
||||
Some(CmdResult::NeedPoint)
|
||||
} else {
|
||||
let value = keyword.parse::<f64>().ok()?;
|
||||
self.accept_distance(value)
|
||||
}
|
||||
}
|
||||
EdgeStep::Expression => {
|
||||
let value = crate::app::expr_eval::eval_number(text)?;
|
||||
self.step = self.expression_return;
|
||||
self.accept_distance(value)
|
||||
}
|
||||
EdgeStep::Radius => None,
|
||||
};
|
||||
}
|
||||
|
||||
match self.step {
|
||||
EdgeStep::Selecting => match keyword.as_str() {
|
||||
"R" | "RADIUS" => Some(self.begin_radius(EdgeStep::Selecting)),
|
||||
"C" | "CHAIN" => {
|
||||
self.selection_mode = EdgeSelectionMode::Chain;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"E" | "EDGE" if self.selection_mode == EdgeSelectionMode::Chain => {
|
||||
self.selection_mode = EdgeSelectionMode::Edge;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"L" | "LOOP" => {
|
||||
self.step = EdgeStep::PickingLoop;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
EdgeStep::PickingLoop => match keyword.as_str() {
|
||||
"E" | "EDGE" => {
|
||||
self.selection_mode = EdgeSelectionMode::Edge;
|
||||
self.step = EdgeStep::Selecting;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"C" | "CHAIN" => {
|
||||
self.selection_mode = EdgeSelectionMode::Chain;
|
||||
self.step = EdgeStep::Selecting;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"R" | "RADIUS" => Some(self.begin_radius(EdgeStep::PickingLoop)),
|
||||
_ => None,
|
||||
},
|
||||
EdgeStep::LoopConfirm => match keyword.as_str() {
|
||||
"A" | "ACCEPT" => Some(self.accept_loop()),
|
||||
"N" | "NEXT" if !self.loop_candidates.is_empty() => {
|
||||
self.loop_index = (self.loop_index + 1) % self.loop_candidates.len();
|
||||
self.rebuild_preview();
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
EdgeStep::PreviewConfirm => match keyword.as_str() {
|
||||
"R" | "RADIUS" => Some(self.begin_radius(EdgeStep::PreviewConfirm)),
|
||||
_ => None,
|
||||
},
|
||||
EdgeStep::Radius => {
|
||||
if matches!(keyword.as_str(), "E" | "EXPRESSION") {
|
||||
self.step = EdgeStep::Expression;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
let value = keyword.parse::<f64>().ok()?;
|
||||
self.accept_radius(value)
|
||||
}
|
||||
EdgeStep::Expression => {
|
||||
let value = crate::app::expr_eval::eval_number(text)?;
|
||||
self.accept_radius(value)
|
||||
}
|
||||
EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => None,
|
||||
}
|
||||
let value = text.trim().parse::<f64>().ok()?;
|
||||
(value > 0.0 && value.is_finite()).then(|| self.finish(value))
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.picked.is_some() {
|
||||
self.finish(self.default_value)
|
||||
} else {
|
||||
CmdResult::Cancel
|
||||
match (self.operation, self.step) {
|
||||
(EdgeOperation::Chamfer, EdgeStep::Selecting) if !self.selected_edges.is_empty() => {
|
||||
self.step = EdgeStep::PreviewConfirm;
|
||||
self.rebuild_preview();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
(EdgeOperation::Chamfer, EdgeStep::LoopConfirm) => self.accept_loop(),
|
||||
(EdgeOperation::Chamfer, EdgeStep::PreviewConfirm) => self.finish(),
|
||||
(EdgeOperation::Chamfer, EdgeStep::ChamferDistance1) => self
|
||||
.accept_distance(self.default_value)
|
||||
.unwrap_or(CmdResult::NeedPoint),
|
||||
(EdgeOperation::Chamfer, EdgeStep::ChamferDistance2) => self
|
||||
.accept_distance(self.other_value)
|
||||
.unwrap_or(CmdResult::NeedPoint),
|
||||
(EdgeOperation::Chamfer, EdgeStep::Expression) => CmdResult::NeedPoint,
|
||||
(EdgeOperation::Chamfer, _) => CmdResult::Cancel,
|
||||
(_, EdgeStep::Selecting) if !self.selected_edges.is_empty() => {
|
||||
self.step = EdgeStep::PreviewConfirm;
|
||||
self.rebuild_preview();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
(_, EdgeStep::LoopConfirm) => self.accept_loop(),
|
||||
(_, EdgeStep::PreviewConfirm) => self.finish(),
|
||||
(_, EdgeStep::Radius) => self
|
||||
.accept_radius(self.default_value)
|
||||
.unwrap_or(CmdResult::NeedPoint),
|
||||
(_, EdgeStep::Expression) => CmdResult::NeedPoint,
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_undo_step(&mut self) -> Option<CmdResult> {
|
||||
if self.step == EdgeStep::LoopConfirm {
|
||||
self.loop_candidates.clear();
|
||||
self.loop_index = 0;
|
||||
self.step = EdgeStep::Selecting;
|
||||
self.rebuild_preview();
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
let (batch, _) = self.selection_batches.pop()?;
|
||||
for edge in batch {
|
||||
if let Some(index) = self.selected_edges.iter().position(|candidate| *candidate == edge) {
|
||||
self.selected_edges.remove(index);
|
||||
}
|
||||
}
|
||||
if self.selected_edges.is_empty() {
|
||||
self.handle = None;
|
||||
self.base_face = None;
|
||||
}
|
||||
self.last_pick = self
|
||||
.selection_batches
|
||||
.last()
|
||||
.map(|(_, anchor)| *anchor);
|
||||
self.step = EdgeStep::Selecting;
|
||||
self.rebuild_preview();
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.picked.is_some()
|
||||
matches!(
|
||||
self.step,
|
||||
EdgeStep::Radius
|
||||
| EdgeStep::Expression
|
||||
| EdgeStep::ChamferDistance1
|
||||
| EdgeStep::ChamferDistance2
|
||||
)
|
||||
}
|
||||
|
||||
fn dyn_commit_as_text(&self) -> bool {
|
||||
self.picked.is_some()
|
||||
matches!(
|
||||
self.step,
|
||||
EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2
|
||||
)
|
||||
}
|
||||
|
||||
fn dyn_spec(&self) -> Option<DynSpec> {
|
||||
let (_, pick) = self.picked?;
|
||||
Some(DynSpec {
|
||||
let pick = self.last_pick?;
|
||||
matches!(
|
||||
self.step,
|
||||
EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2
|
||||
)
|
||||
.then(|| DynSpec {
|
||||
anchor: DynAnchor::Point(pick),
|
||||
fields: vec![DynFieldSpec::new(match self.operation {
|
||||
EdgeOperation::Fillet => DynRole::Radius,
|
||||
@@ -137,10 +722,163 @@ impl CadCommand for SolidEdgeCommand {
|
||||
}
|
||||
|
||||
fn dyn_live_value(&self, cursor: DVec3) -> Option<f64> {
|
||||
self.picked.map(|(_, pick)| cursor.distance(pick))
|
||||
matches!(
|
||||
self.step,
|
||||
EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2
|
||||
)
|
||||
.then(|| self.last_pick.map(|pick| cursor.distance(pick)))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn on_preview_wires(&mut self, cursor: DVec3) -> Vec<WireModel> {
|
||||
if matches!(
|
||||
self.step,
|
||||
EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2
|
||||
) {
|
||||
if let Some(value) = self.last_pick.map(|pick| cursor.distance(pick)) {
|
||||
if value > 0.0 && value.is_finite() {
|
||||
let values = match self.step {
|
||||
EdgeStep::ChamferDistance2 => (self.default_value, value),
|
||||
_ => (value, self.other_value),
|
||||
};
|
||||
if let Some((handle, wires)) = self.preview_for_values(values.0, values.1) {
|
||||
self.preview_wires = wires;
|
||||
self.preview_hidden.clear();
|
||||
self.preview_hidden.push(handle);
|
||||
} else {
|
||||
self.preview_wires.clear();
|
||||
self.preview_hidden.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.preview_wires.clone()
|
||||
}
|
||||
|
||||
fn preview_hidden_handles(&self) -> &[Handle] {
|
||||
&self.preview_hidden
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_loops(body: &Body, seed: EdgeKey) -> Vec<(FaceKey, Vec<EdgeKey>)> {
|
||||
let Some(edge) = body.edges.get(seed) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut candidates = Vec::new();
|
||||
for coedge_key in &edge.coedges {
|
||||
let Some(coedge) = body.coedges.get(*coedge_key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(edge_loop) = body.loops.get(coedge.owner) else {
|
||||
continue;
|
||||
};
|
||||
let edges = edge_loop
|
||||
.coedges
|
||||
.iter()
|
||||
.filter_map(|key| body.coedges.get(*key).map(|coedge| coedge.edge))
|
||||
.collect::<Vec<_>>();
|
||||
let candidate = (edge_loop.owner, edges);
|
||||
if !candidate.1.is_empty() && !candidates.contains(&candidate) {
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn edge_belongs_to_face(body: &Body, edge: EdgeKey, face: FaceKey) -> bool {
|
||||
body.edges.get(edge).is_some_and(|edge| {
|
||||
edge.coedges.iter().any(|coedge| {
|
||||
body.coedges
|
||||
.get(*coedge)
|
||||
.and_then(|coedge| body.loops.get(coedge.owner))
|
||||
.is_some_and(|edge_loop| edge_loop.owner == face)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn nearest_edge_face(body: &Body, edge: EdgeKey, point: [f64; 3]) -> Option<FaceKey> {
|
||||
let edge = body.edges.get(edge)?;
|
||||
edge.coedges
|
||||
.iter()
|
||||
.filter_map(|coedge| {
|
||||
let edge_loop = body.loops.get(body.coedges.get(*coedge)?.owner)?;
|
||||
let face = body.faces.get(edge_loop.owner)?;
|
||||
let surface = body.surfaces.get(face.surface)?;
|
||||
let distance = surface.distance_to(point).abs();
|
||||
distance.is_finite().then_some((edge_loop.owner, distance))
|
||||
})
|
||||
.min_by(|first, second| first.1.total_cmp(&second.1))
|
||||
.map(|(face, _)| face)
|
||||
}
|
||||
|
||||
fn positive_default(value: f64) -> f64 {
|
||||
if value.is_finite() && value > 0.0 {
|
||||
value
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_chain(body: &Body, seed: EdgeKey) -> Vec<EdgeKey> {
|
||||
let mut chain = vec![seed];
|
||||
let mut next = 0;
|
||||
while next < chain.len() {
|
||||
let current_key = chain[next];
|
||||
next += 1;
|
||||
let Some(current) = body.edges.get(current_key) else {
|
||||
continue;
|
||||
};
|
||||
for vertex in [current.start, current.end] {
|
||||
let Some(current_tangent) = edge_tangent_from_vertex(body, current_key, vertex) else {
|
||||
continue;
|
||||
};
|
||||
for candidate_key in body.edge_keys() {
|
||||
if chain.contains(&candidate_key) {
|
||||
continue;
|
||||
}
|
||||
let Some(candidate) = body.edges.get(candidate_key) else {
|
||||
continue;
|
||||
};
|
||||
if candidate.start != vertex && candidate.end != vertex {
|
||||
continue;
|
||||
}
|
||||
let Some(candidate_tangent) =
|
||||
edge_tangent_from_vertex(body, candidate_key, vertex)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if current_tangent.dot(candidate_tangent) < -0.999 {
|
||||
chain.push(candidate_key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
fn edge_tangent_from_vertex(
|
||||
body: &Body,
|
||||
edge_key: EdgeKey,
|
||||
vertex: cadkernel::brep::VertexKey,
|
||||
) -> Option<DVec3> {
|
||||
let edge = body.edges.get(edge_key)?;
|
||||
let curve = body.curves.get(edge.curve)?;
|
||||
let span = edge.end_parameter - edge.start_parameter;
|
||||
if !span.is_finite() || span.abs() <= f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
let step = span * 1.0e-6;
|
||||
let (at, inside) = if edge.start == vertex {
|
||||
(edge.start_parameter, edge.start_parameter + step)
|
||||
} else if edge.end == vertex {
|
||||
(edge.end_parameter, edge.end_parameter - step)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let tangent = DVec3::from(curve.point_at(inside)) - DVec3::from(curve.point_at(at));
|
||||
(tangent.length_squared() > 0.0 && tangent.is_finite()).then(|| tangent.normalize())
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["SOLIDFILLET", "SOLIDCHAMFER"]
|
||||
names: &["FILLETEDGE", "SOLIDFILLET", "CHAMFEREDGE", "SOLIDCHAMFER"]
|
||||
});
|
||||
@@ -5,6 +5,8 @@ pub mod cylinder_cmd;
|
||||
pub mod edge_cmd;
|
||||
pub mod polysolid_cmd;
|
||||
pub mod primitive_cmd;
|
||||
pub mod shell_cmd;
|
||||
pub mod slice_cmd;
|
||||
|
||||
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||
|
||||
@@ -88,8 +90,9 @@ impl CadModule for ModelModule {
|
||||
RibbonGroup {
|
||||
title: "Edges",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)),
|
||||
RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)),
|
||||
RibbonItem::LargeTool(tool("FILLETEDGE", "Fillet Edge", FILLET_ICON)),
|
||||
RibbonItem::LargeTool(tool("CHAMFEREDGE", "Chamfer", CHAMFER_ICON)),
|
||||
RibbonItem::LargeTool(tool("SHELL", "Shell", PRESSPULL_ICON)),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
use acadrust::Handle;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, ShellFaceAction};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ShellStep {
|
||||
SolidEditOption,
|
||||
BodyOption,
|
||||
SelectSolid,
|
||||
RemoveFaces,
|
||||
AddFaces,
|
||||
Distance,
|
||||
}
|
||||
|
||||
pub struct ShellCommand {
|
||||
entry_name: &'static str,
|
||||
step: ShellStep,
|
||||
target: Option<Handle>,
|
||||
actions: Vec<ShellFaceAction>,
|
||||
default_distance: f64,
|
||||
}
|
||||
|
||||
impl ShellCommand {
|
||||
pub fn direct(target: Option<Handle>) -> Self {
|
||||
Self {
|
||||
entry_name: "SHELL",
|
||||
step: if target.is_some() {
|
||||
ShellStep::RemoveFaces
|
||||
} else {
|
||||
ShellStep::SelectSolid
|
||||
},
|
||||
target,
|
||||
actions: Vec::new(),
|
||||
default_distance: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn solid_edit(target: Option<Handle>) -> Self {
|
||||
Self {
|
||||
entry_name: "SOLIDEDIT",
|
||||
step: ShellStep::SolidEditOption,
|
||||
target,
|
||||
actions: Vec::new(),
|
||||
default_distance: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, distance: f64) -> CmdResult {
|
||||
let Some(handle) = self.target else {
|
||||
return CmdResult::Cancel;
|
||||
};
|
||||
CmdResult::SolidShell {
|
||||
handle,
|
||||
actions: std::mem::take(&mut self.actions),
|
||||
distance,
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_face_action(&mut self) -> CmdResult {
|
||||
self.actions.pop();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn face_options(add: bool) -> Vec<CmdOption> {
|
||||
if add {
|
||||
vec![
|
||||
CmdOption::new("Undo", "UNDO"),
|
||||
CmdOption::new("Remove", "REMOVE"),
|
||||
CmdOption::new("ALL", "ALL"),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
CmdOption::new("Undo", "UNDO"),
|
||||
CmdOption::new("Add", "ADD"),
|
||||
CmdOption::new("ALL", "ALL"),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for ShellCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
self.entry_name
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
ShellStep::SolidEditOption => {
|
||||
crate::t!("Enter a solid editing option [Body/eXit]:").into_owned()
|
||||
}
|
||||
ShellStep::BodyOption => {
|
||||
crate::t!("Enter a body editing option [Shell/eXit]:").into_owned()
|
||||
}
|
||||
ShellStep::SelectSolid => crate::t!("Select a 3D solid:").into_owned(),
|
||||
ShellStep::RemoveFaces => {
|
||||
crate::t!("Select faces to remove [Undo/Add/ALL] <done>:").into_owned()
|
||||
}
|
||||
ShellStep::AddFaces => {
|
||||
crate::t!("Select faces to add [Undo/Remove/ALL] <done>:").into_owned()
|
||||
}
|
||||
ShellStep::Distance => crate::tf!(
|
||||
"Specify the shell offset distance <{:.3}>:",
|
||||
self.default_distance
|
||||
)
|
||||
.into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
ShellStep::SolidEditOption => vec![
|
||||
CmdOption::new("Body", "BODY"),
|
||||
CmdOption::new("eXit", "EXIT"),
|
||||
],
|
||||
ShellStep::BodyOption => vec![
|
||||
CmdOption::new("Shell", "SHELL"),
|
||||
CmdOption::new("eXit", "EXIT"),
|
||||
],
|
||||
ShellStep::RemoveFaces => Self::face_options(false),
|
||||
ShellStep::AddFaces => Self::face_options(true),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(
|
||||
self.step,
|
||||
ShellStep::SelectSolid | ShellStep::RemoveFaces | ShellStep::AddFaces
|
||||
)
|
||||
}
|
||||
|
||||
fn entity_pick_includes_fills(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn entity_pick_uses_surface_point(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn entity_pick_highlights_hover(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
|
||||
if handle.is_null() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
match self.step {
|
||||
ShellStep::SelectSolid => {
|
||||
self.target = Some(handle);
|
||||
self.step = ShellStep::RemoveFaces;
|
||||
}
|
||||
ShellStep::RemoveFaces if self.target == Some(handle) => {
|
||||
self.actions.push(ShellFaceAction::Remove(point));
|
||||
}
|
||||
ShellStep::AddFaces if self.target == Some(handle) => {
|
||||
self.actions.push(ShellFaceAction::Add(point));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _point: DVec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let value = text.trim();
|
||||
let keyword = value.to_ascii_uppercase();
|
||||
let result = match self.step {
|
||||
ShellStep::SolidEditOption => match keyword.as_str() {
|
||||
"B" | "BODY" => {
|
||||
self.step = ShellStep::BodyOption;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"X" | "EXIT" => CmdResult::Cancel,
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
},
|
||||
ShellStep::BodyOption => match keyword.as_str() {
|
||||
"S" | "SHELL" => {
|
||||
self.step = if self.target.is_some() {
|
||||
ShellStep::RemoveFaces
|
||||
} else {
|
||||
ShellStep::SelectSolid
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"X" | "EXIT" => CmdResult::Cancel,
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
},
|
||||
ShellStep::RemoveFaces => match keyword.as_str() {
|
||||
"U" | "UNDO" => return Some(self.undo_face_action()),
|
||||
"A" | "ADD" => {
|
||||
self.step = ShellStep::AddFaces;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"ALL" => {
|
||||
self.actions.push(ShellFaceAction::RemoveAll);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
},
|
||||
ShellStep::AddFaces => match keyword.as_str() {
|
||||
"U" | "UNDO" => return Some(self.undo_face_action()),
|
||||
"R" | "REMOVE" => {
|
||||
self.step = ShellStep::RemoveFaces;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"ALL" => {
|
||||
self.actions.push(ShellFaceAction::AddAll);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
},
|
||||
ShellStep::Distance => {
|
||||
let Ok(distance) = value.parse::<f64>() else {
|
||||
return Some(CmdResult::ReportError(
|
||||
crate::t!("A numeric shell offset distance is required.").into_owned(),
|
||||
));
|
||||
};
|
||||
if !distance.is_finite() || distance.abs() <= f64::EPSILON {
|
||||
return Some(CmdResult::ReportError(
|
||||
crate::t!("Value must be nonzero.").into_owned(),
|
||||
));
|
||||
}
|
||||
self.default_distance = distance;
|
||||
self.finish(distance)
|
||||
}
|
||||
ShellStep::SelectSolid => return None,
|
||||
};
|
||||
Some(result)
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match self.step {
|
||||
ShellStep::RemoveFaces | ShellStep::AddFaces => {
|
||||
self.step = ShellStep::Distance;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
ShellStep::Distance => self.finish(self.default_distance),
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_undo_step(&mut self) -> Option<CmdResult> {
|
||||
matches!(self.step, ShellStep::RemoveFaces | ShellStep::AddFaces)
|
||||
.then(|| self.undo_face_action())
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
if matches!(self.step, ShellStep::SelectSolid) {
|
||||
InputKind::Point
|
||||
} else {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["SHELL", "SOLIDEDIT"]
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn direct_shell_collects_face_edits_and_signed_distance() {
|
||||
let handle = Handle::new(42);
|
||||
let first = DVec3::new(1.0, 2.0, 3.0);
|
||||
let second = DVec3::new(4.0, 5.0, 6.0);
|
||||
let mut command = ShellCommand::direct(Some(handle));
|
||||
|
||||
command.on_entity_pick(handle, first);
|
||||
command.on_text_input("ADD");
|
||||
command.on_entity_pick(handle, second);
|
||||
command.on_text_input("UNDO");
|
||||
command.on_text_input("ALL");
|
||||
assert!(matches!(command.on_enter(), CmdResult::NeedPoint));
|
||||
|
||||
assert!(matches!(
|
||||
command.on_text_input("-0.5"),
|
||||
Some(CmdResult::SolidShell {
|
||||
handle: result_handle,
|
||||
actions,
|
||||
distance: -0.5,
|
||||
}) if result_handle == handle
|
||||
&& actions == vec![ShellFaceAction::Remove(first), ShellFaceAction::AddAll]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_distance_keeps_the_distance_step_active() {
|
||||
let mut command = ShellCommand::direct(Some(Handle::new(7)));
|
||||
command.on_enter();
|
||||
|
||||
assert!(matches!(
|
||||
command.on_text_input("zero"),
|
||||
Some(CmdResult::ReportError(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
command.on_text_input("0.25"),
|
||||
Some(CmdResult::SolidShell { distance: 0.25, .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
use acadrust::{EntityType, Handle};
|
||||
use cadkernel::space::Plane;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, SelectionEntity, WorkingPlane};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum PlaneKind {
|
||||
Xy,
|
||||
Yz,
|
||||
Zx,
|
||||
View,
|
||||
}
|
||||
|
||||
enum Step {
|
||||
Targets,
|
||||
FirstPoint,
|
||||
ThreeFirst,
|
||||
ThreeSecond(DVec3),
|
||||
ThirdPoint(DVec3, DVec3),
|
||||
AxisFirst,
|
||||
AxisSecond(DVec3),
|
||||
PlanePoint(PlaneKind),
|
||||
PickObject,
|
||||
PickSurface,
|
||||
Side(Plane),
|
||||
SurfaceSide,
|
||||
}
|
||||
|
||||
/// Interactive front-end for plane-based SLICE.
|
||||
pub struct SliceCommand {
|
||||
step: Step,
|
||||
targets: Vec<Handle>,
|
||||
selected: Vec<Handle>,
|
||||
working: WorkingPlane,
|
||||
view_normal: DVec3,
|
||||
preview_center: DVec3,
|
||||
preview_radius: f64,
|
||||
picked: Option<EntityType>,
|
||||
surface_tool: Option<cadkernel::brep::Body>,
|
||||
notice: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl SliceCommand {
|
||||
pub fn new(
|
||||
targets: Vec<Handle>,
|
||||
view_normal: DVec3,
|
||||
preview_center: DVec3,
|
||||
preview_radius: f64,
|
||||
) -> Self {
|
||||
let step = if targets.is_empty() { Step::Targets } else { Step::FirstPoint };
|
||||
Self {
|
||||
step,
|
||||
targets,
|
||||
selected: Vec::new(),
|
||||
working: WorkingPlane::default(),
|
||||
view_normal: view_normal.normalize_or(DVec3::Z),
|
||||
preview_center,
|
||||
preview_radius: preview_radius.max(1.0),
|
||||
picked: None,
|
||||
surface_tool: None,
|
||||
notice: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn plane(origin: DVec3, x: DVec3, normal: DVec3) -> Option<Plane> {
|
||||
Plane::orthonormal(origin.to_array(), x.to_array(), normal.to_array())
|
||||
}
|
||||
|
||||
fn enter_side(&mut self, plane: Plane) -> CmdResult {
|
||||
self.notice = None;
|
||||
self.step = Step::Side(plane);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn finish(&mut self, keep_point: Option<DVec3>) -> CmdResult {
|
||||
let Step::Side(plane) = self.step else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
CmdResult::SliceEntities {
|
||||
targets: std::mem::take(&mut self.targets),
|
||||
plane,
|
||||
keep_point,
|
||||
}
|
||||
}
|
||||
|
||||
fn picked_plane(&self) -> Option<Plane> {
|
||||
let entity = self.picked.as_ref()?;
|
||||
if !matches!(
|
||||
entity,
|
||||
EntityType::Arc(_)
|
||||
| EntityType::Circle(_)
|
||||
| EntityType::Ellipse(_)
|
||||
| EntityType::Spline(_)
|
||||
| EntityType::LwPolyline(_)
|
||||
| EntityType::Polyline(_)
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
crate::scene::model::presspull_model::profile_geometry(entity)
|
||||
.map(|(plane, _, _)| plane)
|
||||
}
|
||||
|
||||
fn finish_surface(&mut self, keep_point: Option<DVec3>) -> CmdResult {
|
||||
let Some(cutter) = self.surface_tool.take() else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
CmdResult::SliceSurfaceEntities {
|
||||
targets: std::mem::take(&mut self.targets),
|
||||
cutter: Box::new(cutter),
|
||||
keep_point,
|
||||
}
|
||||
}
|
||||
|
||||
fn plane_at(&self, point: DVec3, kind: PlaneKind) -> Option<Plane> {
|
||||
let (x, normal) = match kind {
|
||||
PlaneKind::Xy => (self.working.x, self.working.z),
|
||||
PlaneKind::Yz => (self.working.y, self.working.x),
|
||||
PlaneKind::Zx => (self.working.z, self.working.y),
|
||||
PlaneKind::View => {
|
||||
let candidate = if self.working.x.dot(self.view_normal).abs() < 0.98 {
|
||||
self.working.x
|
||||
} else {
|
||||
self.working.y
|
||||
};
|
||||
(candidate, self.view_normal)
|
||||
}
|
||||
};
|
||||
Self::plane(point, x, normal)
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for SliceCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"SLICE"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if let Some(notice) = self.notice {
|
||||
return crate::t!(notice).into_owned();
|
||||
}
|
||||
match self.step {
|
||||
Step::Targets => crate::t!(
|
||||
"SLICE Select solids or surfaces to slice, then press Enter:"
|
||||
)
|
||||
.into_owned(),
|
||||
Step::FirstPoint => crate::t!(
|
||||
"SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>:"
|
||||
)
|
||||
.into_owned(),
|
||||
Step::ThreeFirst => {
|
||||
crate::t!("SLICE Specify first point on slicing plane:").into_owned()
|
||||
}
|
||||
Step::ThreeSecond(_) => {
|
||||
crate::t!("SLICE Specify second point on slicing plane:").into_owned()
|
||||
}
|
||||
Step::ThirdPoint(_, _) => {
|
||||
crate::t!("SLICE Specify third point on slicing plane:").into_owned()
|
||||
}
|
||||
Step::AxisFirst => {
|
||||
crate::t!("SLICE Specify a point on the slicing plane:").into_owned()
|
||||
}
|
||||
Step::AxisSecond(_) => {
|
||||
crate::t!("SLICE Specify a point on the plane's normal axis:").into_owned()
|
||||
}
|
||||
Step::PlanePoint(PlaneKind::View) => {
|
||||
crate::t!("SLICE Specify a point on the current view plane:").into_owned()
|
||||
}
|
||||
Step::PlanePoint(_) => {
|
||||
crate::t!("SLICE Specify a point on the current UCS plane:").into_owned()
|
||||
}
|
||||
Step::PickObject => crate::t!("SLICE Select a planar curve object:").into_owned(),
|
||||
Step::PickSurface => crate::t!("SLICE Select a cutting surface:").into_owned(),
|
||||
Step::Side(_) => crate::t!(
|
||||
"SLICE Specify a point on the desired side or [Both] <Both>:"
|
||||
)
|
||||
.into_owned(),
|
||||
Step::SurfaceSide => crate::t!(
|
||||
"SLICE Select a sliced object to keep or [Both] <Both>:"
|
||||
)
|
||||
.into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
Step::FirstPoint => vec![
|
||||
CmdOption::new(crate::t!("planar Object").as_ref(), "O"),
|
||||
CmdOption::new(crate::t!("Surface").as_ref(), "S"),
|
||||
CmdOption::new(crate::t!("Zaxis").as_ref(), "Z"),
|
||||
CmdOption::new(crate::t!("View").as_ref(), "V"),
|
||||
CmdOption::new("XY", "XY"),
|
||||
CmdOption::new("YZ", "YZ"),
|
||||
CmdOption::new("ZX", "ZX"),
|
||||
CmdOption::new(crate::t!("3points").as_ref(), "3"),
|
||||
],
|
||||
Step::Side(_) | Step::SurfaceSide => {
|
||||
vec![CmdOption::new(crate::t!("Both").as_ref(), "B")]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_working_plane(&mut self, plane: WorkingPlane) {
|
||||
self.working = plane;
|
||||
}
|
||||
|
||||
fn on_point(&mut self, point: DVec3) -> CmdResult {
|
||||
self.notice = None;
|
||||
match self.step {
|
||||
Step::FirstPoint => {
|
||||
self.step = Step::ThreeSecond(point);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::ThreeFirst => {
|
||||
self.step = Step::ThreeSecond(point);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::ThreeSecond(first) => {
|
||||
self.step = Step::ThirdPoint(first, point);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::ThirdPoint(first, second) => {
|
||||
let x = second - first;
|
||||
let normal = x.cross(point - first);
|
||||
Self::plane(first, x, normal)
|
||||
.map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane))
|
||||
}
|
||||
Step::AxisFirst => {
|
||||
self.step = Step::AxisSecond(point);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::AxisSecond(origin) => {
|
||||
let normal = point - origin;
|
||||
let x = if self.working.x.dot(normal.normalize_or(DVec3::Z)).abs() < 0.98 {
|
||||
self.working.x
|
||||
} else {
|
||||
self.working.y
|
||||
};
|
||||
Self::plane(origin, x, normal)
|
||||
.map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane))
|
||||
}
|
||||
Step::PlanePoint(kind) => self
|
||||
.plane_at(point, kind)
|
||||
.map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane)),
|
||||
Step::Side(_) => self.finish(Some(point)),
|
||||
Step::SurfaceSide => self.finish_surface(Some(point)),
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match self.step {
|
||||
Step::Targets if !self.selected.is_empty() => {
|
||||
self.targets = std::mem::take(&mut self.selected);
|
||||
self.step = Step::FirstPoint;
|
||||
CmdResult::DeselectAndContinue
|
||||
}
|
||||
Step::FirstPoint => {
|
||||
self.step = Step::ThreeFirst;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Side(_) => self.finish(None),
|
||||
Step::SurfaceSide => self.finish_surface(None),
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let keyword = text.trim().to_ascii_uppercase();
|
||||
Some(match self.step {
|
||||
Step::FirstPoint => match keyword.as_str() {
|
||||
"O" | "OBJECT" => {
|
||||
self.step = Step::PickObject;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"S" | "SURFACE" => {
|
||||
self.step = Step::PickSurface;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"Z" | "ZAXIS" => {
|
||||
self.step = Step::AxisFirst;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"V" | "VIEW" => {
|
||||
self.step = Step::PlanePoint(PlaneKind::View);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"XY" => {
|
||||
self.step = Step::PlanePoint(PlaneKind::Xy);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"YZ" => {
|
||||
self.step = Step::PlanePoint(PlaneKind::Yz);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"ZX" | "XZ" => {
|
||||
self.step = Step::PlanePoint(PlaneKind::Zx);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
"3" | "3P" | "3POINTS" => {
|
||||
self.step = Step::ThreeFirst;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
},
|
||||
Step::Side(_) if matches!(keyword.as_str(), "" | "B" | "BOTH") => self.finish(None),
|
||||
Step::SurfaceSide if matches!(keyword.as_str(), "" | "B" | "BOTH") => {
|
||||
self.finish_surface(None)
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
})
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
matches!(self.step, Step::FirstPoint | Step::Side(_) | Step::SurfaceSide)
|
||||
}
|
||||
|
||||
fn is_selection_gathering(&self) -> bool {
|
||||
matches!(self.step, Step::Targets)
|
||||
}
|
||||
|
||||
fn selection_forces_add(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn inject_selection_entities(&mut self, entities: Vec<SelectionEntity>) {
|
||||
self.selected = entities
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
matches!(item.entity, EntityType::Solid3D(_) | EntityType::Surface(_))
|
||||
.then_some(item.handle)
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
fn on_selection_complete(&mut self, _handles: Vec<Handle>) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(
|
||||
self.step,
|
||||
Step::PickObject | Step::PickSurface | Step::SurfaceSide
|
||||
)
|
||||
}
|
||||
|
||||
fn entity_pick_highlights_hover(&self) -> bool {
|
||||
self.needs_entity_pick()
|
||||
}
|
||||
|
||||
fn inject_before_entity_pick(&self) -> bool {
|
||||
matches!(self.step, Step::PickObject | Step::PickSurface)
|
||||
}
|
||||
|
||||
fn entity_pick_uses_surface_point(&self) -> bool {
|
||||
matches!(self.step, Step::SurfaceSide)
|
||||
}
|
||||
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) {
|
||||
self.picked = Some(entity);
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, _handle: Handle, _point: DVec3) -> CmdResult {
|
||||
if matches!(self.step, Step::SurfaceSide) {
|
||||
return self.finish_surface(Some(_point));
|
||||
}
|
||||
let surface_only = matches!(self.step, Step::PickSurface);
|
||||
if surface_only {
|
||||
let body = self.picked.as_ref().and_then(|entity| match entity {
|
||||
EntityType::Surface(surface) => {
|
||||
crate::scene::convert::solid3d_tess::kernel_surface_body(surface)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if let Some(body) = body {
|
||||
if let Some(plane) = planar_body_plane(&body) {
|
||||
return self.enter_side(plane);
|
||||
}
|
||||
if body.face_keys().count() == 1 {
|
||||
self.surface_tool = Some(body);
|
||||
self.notice = None;
|
||||
self.step = Step::SurfaceSide;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
}
|
||||
self.notice = Some(
|
||||
"SLICE The selected surface does not define one supported cutting sheet. Select another surface:",
|
||||
);
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
match self.picked_plane() {
|
||||
Some(plane) => self.enter_side(plane),
|
||||
None => {
|
||||
self.notice = Some(
|
||||
"SLICE The selected object does not define a plane. Select a planar curve object:",
|
||||
);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_preview_wires(&mut self, point: DVec3) -> Vec<WireModel> {
|
||||
let mut wires = Vec::new();
|
||||
let plane = match self.step {
|
||||
Step::ThirdPoint(first, second) => {
|
||||
wires.push(WireModel::solid_f64(
|
||||
"slice_triangle".into(),
|
||||
vec![
|
||||
first.to_array(),
|
||||
second.to_array(),
|
||||
point.to_array(),
|
||||
first.to_array(),
|
||||
],
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
));
|
||||
Self::plane(first, second - first, (second - first).cross(point - first))
|
||||
}
|
||||
Step::AxisSecond(origin) => {
|
||||
wires.push(WireModel::solid_f64(
|
||||
"slice_normal".into(),
|
||||
vec![origin.to_array(), point.to_array()],
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
));
|
||||
let normal = point - origin;
|
||||
let x = if self.working.x.dot(normal.normalize_or(DVec3::Z)).abs() < 0.98 {
|
||||
self.working.x
|
||||
} else {
|
||||
self.working.y
|
||||
};
|
||||
Self::plane(origin, x, normal)
|
||||
}
|
||||
Step::PlanePoint(kind) => self.plane_at(point, kind),
|
||||
Step::Side(plane) => Some(plane),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(plane) = plane {
|
||||
wires.extend(plane_grid(
|
||||
plane,
|
||||
self.preview_center,
|
||||
self.preview_radius,
|
||||
));
|
||||
}
|
||||
wires
|
||||
}
|
||||
}
|
||||
|
||||
fn planar_body_plane(body: &cadkernel::brep::Body) -> Option<Plane> {
|
||||
let mut faces = body.face_keys();
|
||||
let first = cadkernel::brep::planar_face_profile(body, faces.next()?)?.plane;
|
||||
let normal = DVec3::from_array(first.normal()?);
|
||||
let tolerance = cadkernel::brep::operation_tolerance(&[body]);
|
||||
faces
|
||||
.all(|face| {
|
||||
let Some(profile) = cadkernel::brep::planar_face_profile(body, face) else {
|
||||
return false;
|
||||
};
|
||||
profile.plane.normal().is_some_and(|other| {
|
||||
normal.dot(DVec3::from_array(other)).abs() >= 1.0 - 1e-9
|
||||
&& first
|
||||
.distance_to(profile.plane.origin)
|
||||
.is_some_and(|gap| gap.abs() <= tolerance * 4.0)
|
||||
})
|
||||
})
|
||||
.then_some(first)
|
||||
}
|
||||
|
||||
fn plane_grid(plane: Plane, centre: DVec3, radius: f64) -> Vec<WireModel> {
|
||||
let normal = DVec3::from_array(plane.normal().unwrap_or([0.0, 0.0, 1.0]));
|
||||
let projected = centre
|
||||
- normal
|
||||
* plane
|
||||
.distance_to(centre.to_array())
|
||||
.unwrap_or_default();
|
||||
let x = DVec3::from_array(plane.x_axis);
|
||||
let y = DVec3::from_array(plane.y_axis);
|
||||
let color = [0.2, 0.55, 1.0, 1.0];
|
||||
(-5..=5)
|
||||
.flat_map(|index| {
|
||||
let offset = radius * index as f64 / 5.0;
|
||||
[
|
||||
WireModel::solid_f64(
|
||||
format!("slice_grid_x_{index}"),
|
||||
vec![
|
||||
(projected + x * offset - y * radius).to_array(),
|
||||
(projected + x * offset + y * radius).to_array(),
|
||||
],
|
||||
color,
|
||||
false,
|
||||
),
|
||||
WireModel::solid_f64(
|
||||
format!("slice_grid_y_{index}"),
|
||||
vec![
|
||||
(projected + y * offset - x * radius).to_array(),
|
||||
(projected + y * offset + x * radius).to_array(),
|
||||
],
|
||||
color,
|
||||
false,
|
||||
),
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["SLICE", "SL"]
|
||||
});
|
||||
Reference in new issue
Block a user