forked from 1b-agent/opencadstudio-upstream
Pass join settings through the shared polyline result handler
This commit is contained in:
commit
1e82fb9f78
160 files changed
+16291
-16106
No files matched your search
@@ -0,0 +1,84 @@
|
||||
//! CCONSTRAINT — manual Coincident constraint input.
|
||||
//! unlike the rest of this module's plain select-and-click constraints,
|
||||
//! Coincident addresses a *point* on an entity (an endpoint, a circle/arc
|
||||
//! center), not the whole entity, so it needs two point picks instead of a
|
||||
//! selection.
|
||||
//!
|
||||
//! A `CadCommand` has no document access (see `value.rs`'s module doc
|
||||
//! comment for the same constraint on `DCONSTRAINT`/`ACONSTRAINT`), so this
|
||||
//! command only accumulates the two raw points `on_point` receives and hands
|
||||
//! them back as `CmdResult::AddCoincidentConstraint` — the host resolves
|
||||
//! each point to a real `SketchRef` (`sketch_constraints::nearest_sketch_point`)
|
||||
//! and adds the constraint, the same split `ReassociateCenterMark` already
|
||||
//! uses for a similar reason.
|
||||
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub mod coincident_tool {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "CCONSTRAINT",
|
||||
label: "Coincident",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/coincident.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("CCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CoincidentConstraintCommand {
|
||||
first: Option<DVec3>,
|
||||
}
|
||||
|
||||
impl CoincidentConstraintCommand {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for CoincidentConstraintCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"CCONSTRAINT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.first.is_none() {
|
||||
"COINCIDENT Specify first point:".to_string()
|
||||
} else {
|
||||
"COINCIDENT Specify second point:".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
match self.first {
|
||||
None => {
|
||||
self.first = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Some(first) => CmdResult::AddCoincidentConstraint {
|
||||
point_a: first,
|
||||
point_b: pt,
|
||||
label: "Coincident constraint",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["CCONSTRAINT"]
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
//! EDCONSTRAINT makes the distance between one point pair equal the
|
||||
//! distance between another pair, rather than comparing whole entities.
|
||||
//! being equal length/radius. Needs four point picks (unlike the two
|
||||
//! whole-entity selections `ECONSTRAINT` uses), so — like
|
||||
//! `CoincidentConstraintCommand` — it accumulates raw points and hands them
|
||||
//! back for the host to resolve (`CadCommand` has no document access).
|
||||
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub mod equal_distance_tool {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "EDCONSTRAINT",
|
||||
label: "Equal Distance",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/equal_distance.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("EDCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EqualDistanceConstraintCommand {
|
||||
points: Vec<DVec3>,
|
||||
}
|
||||
|
||||
impl EqualDistanceConstraintCommand {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for EqualDistanceConstraintCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"EDCONSTRAINT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match self.points.len() {
|
||||
0 => "EQUAL DISTANCE Specify first point of first pair:".to_string(),
|
||||
1 => "EQUAL DISTANCE Specify second point of first pair:".to_string(),
|
||||
2 => "EQUAL DISTANCE Specify first point of second pair:".to_string(),
|
||||
_ => "EQUAL DISTANCE Specify second point of second pair:".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
self.points.push(pt);
|
||||
if self.points.len() < 4 {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
let [a, b, c, d] = self.points[..] else {
|
||||
unreachable!("just checked len == 4")
|
||||
};
|
||||
CmdResult::AddEqualDistanceConstraint {
|
||||
points: [a, b, c, d],
|
||||
label: "Equal distance constraint",
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["EDCONSTRAINT"]
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Ribbon tools and typed-value commands for persistent constraints.
|
||||
//!
|
||||
//! Every one adds a [`crate::scene::sketch_constraints::SketchConstraint`]
|
||||
//! to the current scope and lets `Scene::bump_entities` solve it, the same
|
||||
//! path any later edit to the constrained entities re-solves through
|
||||
//! (`src/scene/sketch_solve.rs`). By how they gather input:
|
||||
//! - **Plain selection, no picking** — Horizontal, Vertical, Fixed (select
|
||||
//! one entity); Parallel, Perpendicular, Equal, Tangent, Colinear,
|
||||
//! Concentric, Normal (select two); Symmetric (select three: the
|
||||
//! mirrored pair, then the mirror line) — dispatch straight to
|
||||
//! `CmdResult::AddSketchConstraint` from `src/app/commands/draw.rs`.
|
||||
//! - **A typed value** — Distance, Angle (`value.rs`): the target value
|
||||
//! comes from the command line after the tool is clicked.
|
||||
//! - **A picked point, entity pre-selected** — CenterPoint, Midpoint,
|
||||
//! PointOnCurve (`point_on_entity.rs`): the entity side has no ambiguity
|
||||
//! (select it first, like Distance/Angle's target), but the point side
|
||||
//! does (which of a line's two endpoints?), so it's picked interactively
|
||||
//! and resolved by the host.
|
||||
//! - **Picked points only, nothing pre-selected** — Coincident
|
||||
//! (`coincident.rs`, two points), EqualDistance (`equal_distance.rs`,
|
||||
//! four points): every side is a point, so plain selection can't express
|
||||
//! it at all.
|
||||
|
||||
mod coincident;
|
||||
mod equal_distance;
|
||||
mod point_on_entity;
|
||||
mod tools;
|
||||
mod value;
|
||||
pub use coincident::{coincident_tool, CoincidentConstraintCommand};
|
||||
pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand};
|
||||
pub use point_on_entity::{
|
||||
center_point_tool, midpoint_tool, point_on_curve_tool, PointOnEntityConstraintCommand,
|
||||
};
|
||||
pub use tools::{
|
||||
colinear, concentric, equal, fixed, horizontal, normal, parallel, perpendicular, symmetric,
|
||||
tangent, vertical,
|
||||
};
|
||||
pub use value::{angle_tool, distance_tool, AngleConstraintCommand, DistanceConstraintCommand};
|
||||
@@ -0,0 +1,117 @@
|
||||
//! CENTERPOINT / MIDPOINT / ONCURVE — constraints between one picked point
|
||||
//! and a whole entity selected *before* the tool runs (same "select first,
|
||||
//! then invoke" convention `DistanceConstraintCommand`
|
||||
//! (`src/modules/draw/constrain/value.rs`) already uses for its own target
|
||||
//! entity). The entity side has no ambiguity (a whole circle, or a whole
|
||||
//! line whose midpoint/curve is being addressed, not one specific
|
||||
//! endpoint) so it doesn't need interactive picking; the point side does
|
||||
//! (which of a line's two endpoints, or some other addressable point), so
|
||||
//! this command accumulates just that one point pick and hands it back for
|
||||
//! the host to resolve via `nearest_sketch_point` — the same split
|
||||
//! `CoincidentConstraintCommand` uses and for the same reason (`CadCommand`
|
||||
//! has no document access).
|
||||
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::sketch_constraints::ConstraintKind;
|
||||
use acadrust::types::Handle;
|
||||
|
||||
pub mod center_point_tool {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "CPCONSTRAINT",
|
||||
label: "Center Point",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/center_point.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("CPCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod midpoint_tool {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "MPCONSTRAINT",
|
||||
label: "Midpoint",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/midpoint.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("MPCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod point_on_curve_tool {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "OCCONSTRAINT",
|
||||
label: "Point on Curve",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/point_on_curve.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("OCCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared by all three tools above — only `name`/`kind`/`label` differ.
|
||||
pub struct PointOnEntityConstraintCommand {
|
||||
name: &'static str,
|
||||
kind: ConstraintKind,
|
||||
target: Handle,
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl PointOnEntityConstraintCommand {
|
||||
pub fn new(
|
||||
name: &'static str,
|
||||
kind: ConstraintKind,
|
||||
target: Handle,
|
||||
label: &'static str,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
kind,
|
||||
target,
|
||||
label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PointOnEntityConstraintCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
self.name
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
"Specify point:".to_string()
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
CmdResult::AddPointOnEntityConstraint {
|
||||
point: pt,
|
||||
target: self.target,
|
||||
kind: self.kind,
|
||||
label: self.label,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["CPCONSTRAINT", "MPCONSTRAINT", "OCCONSTRAINT"]
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Ribbon button definitions for the Constraints group.
|
||||
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
|
||||
pub mod horizontal {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "HCONSTRAINT",
|
||||
label: "Horizontal",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/horizontal.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("HCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod vertical {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "VCONSTRAINT",
|
||||
label: "Vertical",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/vertical.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("VCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod parallel {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "PCONSTRAINT",
|
||||
label: "Parallel",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/parallel.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("PCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod perpendicular {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "QCONSTRAINT",
|
||||
label: "Perpendicular",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/perpendicular.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("QCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod equal {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "ECONSTRAINT",
|
||||
label: "Equal",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/equal.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("ECONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod tangent {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "TCONSTRAINT",
|
||||
label: "Tangent",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/tangent.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("TCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod concentric {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "NCONSTRAINT",
|
||||
label: "Concentric",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/concentric.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("NCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line perpendicular to a circle/arc's tangent at their point of
|
||||
/// contact — for the Line/Circle-only entity model this is equivalent to
|
||||
/// "the line passes through the circle's center" (a circle's radius is
|
||||
/// always normal to its own tangent), so it's built from the same
|
||||
/// `PointOnLine` primitive `PointOnCurve` already uses for a point-on-line
|
||||
/// case (`sketch_solve.rs`). Distinct from `Perpendicular`, which only
|
||||
/// covers line-to-line.
|
||||
pub mod normal {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "NRCONSTRAINT",
|
||||
label: "Normal",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/normal.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("NRCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod colinear {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "LCONSTRAINT",
|
||||
label: "Colinear",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/colinear.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("LCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod fixed {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "FXCONSTRAINT",
|
||||
label: "Fixed",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/fixed.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("FXCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod symmetric {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "SYCONSTRAINT",
|
||||
label: "Symmetric",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/symmetric.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("SYCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &[
|
||||
"HCONSTRAINT",
|
||||
"VCONSTRAINT",
|
||||
"PCONSTRAINT",
|
||||
"QCONSTRAINT",
|
||||
"ECONSTRAINT",
|
||||
"TCONSTRAINT",
|
||||
"NCONSTRAINT",
|
||||
"NRCONSTRAINT",
|
||||
"LCONSTRAINT",
|
||||
"FXCONSTRAINT",
|
||||
"SYCONSTRAINT",
|
||||
]
|
||||
});
|
||||
@@ -0,0 +1,639 @@
|
||||
//! DCONSTRAINT / ACONSTRAINT — constraints that need a typed target value
|
||||
//! (a distance or an angle), unlike the plain select-and-click constraints
|
||||
//! in `mod.rs`.
|
||||
//!
|
||||
//! These commands don't solve anything themselves. A `CadCommand`'s `on_text_input` only
|
||||
//! gets `&mut self` — no document access — so it can't add a
|
||||
//! `SketchConstraint` to the scene directly; instead these commands hand the
|
||||
//! typed value back as `CmdResult::AddSketchConstraint`, and the host (which
|
||||
//! does have `&mut Scene`) adds the record and solves it via the same
|
||||
//! `Scene::bump_entities` path any later edit to these entities will use too.
|
||||
//! `default_value` (what the prompt shows in `<...>`, and what a bare Enter
|
||||
//! submits) is still computed from a one-time read of current geometry —
|
||||
//! that part doesn't change.
|
||||
|
||||
use acadrust::entities::EntityType;
|
||||
use acadrust::types::Handle;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult, InputKind};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::named_parameters::DrivingValue;
|
||||
use crate::scene::sketch_constraints::{ConstraintKind, SketchRef};
|
||||
use crate::scene::Scene;
|
||||
|
||||
/// Recognizes a typed prompt token as either a numeric literal or a
|
||||
/// reference to an existing named parameter. `known_names` is a one-time snapshot taken at
|
||||
/// command construction (`DistanceConstraintCommand`/`AngleConstraintCommand
|
||||
/// ::new`, which already has `&Scene`) since `on_text_input` itself has no
|
||||
/// document access — the same constraint `default_value` already works
|
||||
/// around. Only *existence* is checked here; the actual value is resolved
|
||||
/// fresh at solve time (`sketch_solve::build_constraint`), consistent with
|
||||
/// this project's "no incremental/cached resolution" approach throughout.
|
||||
fn parse_driving_value(text: &str, known_names: &[String]) -> Option<DrivingValue> {
|
||||
let text = text.trim();
|
||||
if let Ok(value) = text.parse::<f64>() {
|
||||
return Some(DrivingValue::Literal(value));
|
||||
}
|
||||
// Case-insensitive: the command line uppercases `SingleToken` input
|
||||
// (InputKind::SingleToken) before it reaches this parser, so a typed
|
||||
// lowercase parameter name like "hole_dia" arrives here as "HOLE_DIA".
|
||||
// Matching case-insensitively and returning the canonically-cased name
|
||||
// keeps the driving link pointed at the actual parameter.
|
||||
known_names
|
||||
.iter()
|
||||
.find(|n| n.eq_ignore_ascii_case(text))
|
||||
.map(|n| DrivingValue::Named(n.clone()))
|
||||
}
|
||||
|
||||
pub mod distance_tool {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "DCONSTRAINT",
|
||||
label: "Distance",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/distance.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("DCONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod angle_tool {
|
||||
use super::*;
|
||||
pub fn tool() -> ToolDef {
|
||||
ToolDef {
|
||||
id: "ACONSTRAINT",
|
||||
label: "Angle",
|
||||
icon: IconKind::Svg(include_bytes!(
|
||||
"../../../../assets/icons/constrain/angle.svg"
|
||||
)),
|
||||
event: ModuleEvent::Command("ACONSTRAINT".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which flavor of dimensional constraint the typed value drives.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum DistanceMode {
|
||||
/// Line length (two-point distance) or a circle/arc's radius —
|
||||
/// whichever `is_circle` selects.
|
||||
Auto,
|
||||
X,
|
||||
Y,
|
||||
Diameter,
|
||||
/// Arc length — only offered when `is_arc` (not a plain Circle, which
|
||||
/// has no "arc length" distinct from its circumference).
|
||||
ArcLength,
|
||||
}
|
||||
|
||||
/// Constrains a single line's length (or its X/Y-only component) between
|
||||
/// its two endpoints, or a circle/arc's radius (or diameter, or — for an
|
||||
/// arc specifically — its swept arc length), to a typed target value.
|
||||
pub struct DistanceConstraintCommand {
|
||||
handle: Handle,
|
||||
/// Whether `handle` is a circle/arc (Radius/Diameter) or a line
|
||||
/// (Distance/DistanceX/DistanceY) — decided once at construction from
|
||||
/// the entity's type.
|
||||
is_circle: bool,
|
||||
/// Whether `handle` is specifically an Arc (as opposed to a full
|
||||
/// Circle) — gates the `[Arclength]` prompt option.
|
||||
is_arc: bool,
|
||||
mode: DistanceMode,
|
||||
default_value: f64,
|
||||
default_x: f64,
|
||||
default_y: f64,
|
||||
/// The entity's current arc length, for `ArcLength` mode's `<default>`
|
||||
/// — meaningless (left at 0) unless `is_arc`.
|
||||
default_arc_length: f64,
|
||||
/// Snapshot of `Scene::named_parameters`' names at construction time —
|
||||
/// see `parse_driving_value`'s doc comment for why a snapshot.
|
||||
known_param_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl DistanceConstraintCommand {
|
||||
/// `None` if `handle` isn't a Line, Circle, or Arc.
|
||||
pub fn new(scene: &Scene, handle: Handle) -> Option<Self> {
|
||||
let entity = scene.document.get_entity(handle)?;
|
||||
let (is_circle, is_arc, default_value, default_x, default_y, default_arc_length) =
|
||||
match entity {
|
||||
EntityType::Line(l) => {
|
||||
let dx = l.end.x - l.start.x;
|
||||
let dy = l.end.y - l.start.y;
|
||||
(false, false, (dx * dx + dy * dy).sqrt(), dx, dy, 0.0)
|
||||
}
|
||||
EntityType::Circle(c) => (true, false, c.radius, 0.0, 0.0, 0.0),
|
||||
EntityType::Arc(a) => (true, true, a.radius, 0.0, 0.0, a.arc_length()),
|
||||
_ => return None,
|
||||
};
|
||||
let known_param_names = scene
|
||||
.named_parameters()
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
Some(Self {
|
||||
handle,
|
||||
is_circle,
|
||||
is_arc,
|
||||
mode: DistanceMode::Auto,
|
||||
default_value,
|
||||
default_x,
|
||||
default_y,
|
||||
default_arc_length,
|
||||
known_param_names,
|
||||
})
|
||||
}
|
||||
|
||||
fn build(&self, target: DrivingValue) -> Option<CmdResult> {
|
||||
if let DrivingValue::Literal(v) = &target {
|
||||
let signed_component = matches!(self.mode, DistanceMode::X | DistanceMode::Y);
|
||||
if !v.is_finite() || (!signed_component && *v <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let (kind, refs, label) = match (self.is_circle, self.mode) {
|
||||
(true, DistanceMode::Diameter) => (
|
||||
ConstraintKind::Diameter,
|
||||
vec![SketchRef::whole(self.handle)],
|
||||
"Diameter constraint",
|
||||
),
|
||||
(true, DistanceMode::ArcLength) if self.is_arc => (
|
||||
ConstraintKind::ArcLength,
|
||||
vec![SketchRef::whole(self.handle)],
|
||||
"Arc length constraint",
|
||||
),
|
||||
(true, _) => (
|
||||
ConstraintKind::Radius,
|
||||
vec![SketchRef::whole(self.handle)],
|
||||
"Radius constraint",
|
||||
),
|
||||
(false, DistanceMode::X) => (
|
||||
ConstraintKind::DistanceX,
|
||||
vec![
|
||||
SketchRef::point(self.handle, 0),
|
||||
SketchRef::point(self.handle, 1),
|
||||
],
|
||||
"DistanceX constraint",
|
||||
),
|
||||
(false, DistanceMode::Y) => (
|
||||
ConstraintKind::DistanceY,
|
||||
vec![
|
||||
SketchRef::point(self.handle, 0),
|
||||
SketchRef::point(self.handle, 1),
|
||||
],
|
||||
"DistanceY constraint",
|
||||
),
|
||||
(false, _) => (
|
||||
ConstraintKind::Distance,
|
||||
vec![
|
||||
SketchRef::point(self.handle, 0),
|
||||
SketchRef::point(self.handle, 1),
|
||||
],
|
||||
"Distance constraint",
|
||||
),
|
||||
};
|
||||
Some(CmdResult::AddSketchConstraint {
|
||||
kind,
|
||||
refs,
|
||||
driving_param: Some(target),
|
||||
label,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for DistanceConstraintCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"DCONSTRAINT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match (self.is_circle, self.mode) {
|
||||
(true, DistanceMode::Diameter) => format!(
|
||||
"Specify diameter <{:.4}> or [Radius]: ",
|
||||
self.default_value * 2.0
|
||||
),
|
||||
(true, DistanceMode::ArcLength) if self.is_arc => {
|
||||
format!(
|
||||
"Specify arc length <{:.4}> or [Radius]: ",
|
||||
self.default_arc_length
|
||||
)
|
||||
}
|
||||
(true, _) if self.is_arc => format!(
|
||||
"Specify distance <{:.4}> or [Diameter/Arclength]: ",
|
||||
self.default_value
|
||||
),
|
||||
(true, _) => format!(
|
||||
"Specify distance <{:.4}> or [Diameter]: ",
|
||||
self.default_value
|
||||
),
|
||||
(false, DistanceMode::X) => format!("Specify X distance <{:.4}>: ", self.default_x),
|
||||
(false, DistanceMode::Y) => format!("Specify Y distance <{:.4}>: ", self.default_y),
|
||||
(false, _) => format!(
|
||||
"Specify distance <{:.4}> or [Xdistance/Ydistance]: ",
|
||||
self.default_value
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
let default = match self.mode {
|
||||
DistanceMode::Diameter => self.default_value * 2.0,
|
||||
DistanceMode::ArcLength if self.is_arc => self.default_arc_length,
|
||||
DistanceMode::X => self.default_x,
|
||||
DistanceMode::Y => self.default_y,
|
||||
_ => self.default_value,
|
||||
};
|
||||
self.build(DrivingValue::Literal(default))
|
||||
.unwrap_or(CmdResult::Cancel)
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
// Mode-switch keywords re-prompt instead of building — consumed
|
||||
// inputs return `Some(NeedPoint)` so the driver doesn't re-offer
|
||||
// the same token a second time (matches e.g. `trim.rs`'s
|
||||
// `T`/`B`/`F` keyword handling). Defensively uppercased same as
|
||||
// `trim.rs` even though `SingleToken` input already arrives
|
||||
// uppercased from the command line.
|
||||
let keyword = text.trim().to_uppercase();
|
||||
match (self.is_circle, keyword.as_str()) {
|
||||
(true, "D" | "DIAMETER") => {
|
||||
self.mode = DistanceMode::Diameter;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
(true, "A" | "ARCLENGTH") if self.is_arc => {
|
||||
self.mode = DistanceMode::ArcLength;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
(true, "R" | "RADIUS") => {
|
||||
self.mode = DistanceMode::Auto;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
(false, "X" | "XDISTANCE") => {
|
||||
self.mode = DistanceMode::X;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
(false, "Y" | "YDISTANCE") => {
|
||||
self.mode = DistanceMode::Y;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let value = parse_driving_value(text, &self.known_param_names)?;
|
||||
self.build(value)
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
/// Constrains the angle (in degrees) from `fixed`'s direction to `moving`'s.
|
||||
pub struct AngleConstraintCommand {
|
||||
fixed_handle: Handle,
|
||||
moving_handle: Handle,
|
||||
default_value: f64,
|
||||
/// Snapshot of `Scene::named_parameters`' names at construction time —
|
||||
/// see `parse_driving_value`'s doc comment for why a snapshot.
|
||||
known_param_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl AngleConstraintCommand {
|
||||
/// `None` unless both `fixed` and `moving` are lines.
|
||||
pub fn new(scene: &Scene, fixed: Handle, moving: Handle) -> Option<Self> {
|
||||
let fixed_entity = scene.document.get_entity(fixed)?;
|
||||
let moving_entity = scene.document.get_entity(moving)?;
|
||||
let (EntityType::Line(f), EntityType::Line(m)) = (fixed_entity, moving_entity) else {
|
||||
return None;
|
||||
};
|
||||
let a1 = (f.end.y - f.start.y).atan2(f.end.x - f.start.x);
|
||||
let a2 = (m.end.y - m.start.y).atan2(m.end.x - m.start.x);
|
||||
let default_value = (a2 - a1).to_degrees();
|
||||
let known_param_names = scene
|
||||
.named_parameters()
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
Some(Self {
|
||||
fixed_handle: fixed,
|
||||
moving_handle: moving,
|
||||
default_value,
|
||||
known_param_names,
|
||||
})
|
||||
}
|
||||
|
||||
fn build(&self, target: DrivingValue) -> Option<CmdResult> {
|
||||
if matches!(&target, DrivingValue::Literal(value) if !value.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(CmdResult::AddSketchConstraint {
|
||||
kind: ConstraintKind::Angle,
|
||||
refs: vec![
|
||||
SketchRef::whole(self.fixed_handle),
|
||||
SketchRef::whole(self.moving_handle),
|
||||
],
|
||||
driving_param: Some(target),
|
||||
label: "Angle constraint",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for AngleConstraintCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"ACONSTRAINT"
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
format!("Specify angle in degrees <{:.4}>: ", self.default_value)
|
||||
}
|
||||
|
||||
fn input_kind(&self) -> InputKind {
|
||||
InputKind::SingleToken
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
self.build(DrivingValue::Literal(self.default_value))
|
||||
.unwrap_or(CmdResult::Cancel)
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let value = parse_driving_value(text, &self.known_param_names)?;
|
||||
self.build(value)
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["DCONSTRAINT", "ACONSTRAINT"]
|
||||
});
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_driving_value_prefers_a_numeric_literal() {
|
||||
let known = vec!["hole_dia".to_string()];
|
||||
assert_eq!(
|
||||
parse_driving_value("12.5", &known),
|
||||
Some(DrivingValue::Literal(12.5))
|
||||
);
|
||||
// Distance's own `build` rejects a non-positive target later; the
|
||||
// parse step itself accepts any number, negative included.
|
||||
assert_eq!(
|
||||
parse_driving_value("-3", &known),
|
||||
Some(DrivingValue::Literal(-3.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_driving_value_recognizes_a_known_parameter_name() {
|
||||
let known = vec!["hole_dia".to_string(), "plate_len".to_string()];
|
||||
assert_eq!(
|
||||
parse_driving_value("hole_dia", &known),
|
||||
Some(DrivingValue::Named("hole_dia".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_driving_value_rejects_an_unknown_token() {
|
||||
let known = vec!["hole_dia".to_string()];
|
||||
assert_eq!(parse_driving_value("bogus", &known), None);
|
||||
assert_eq!(parse_driving_value("", &known), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_driving_value_matches_a_known_parameter_regardless_of_typed_case() {
|
||||
// The command line uppercases `SingleToken` input before it reaches
|
||||
// here, so a lowercase-named parameter must still resolve — and
|
||||
// resolve to its canonically-cased name, not the uppercased token.
|
||||
let known = vec!["hole_dia".to_string()];
|
||||
assert_eq!(
|
||||
parse_driving_value("HOLE_DIA", &known),
|
||||
Some(DrivingValue::Named("hole_dia".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_driving_value("Hole_Dia", &known),
|
||||
Some(DrivingValue::Named("hole_dia".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
fn add_line(scene: &mut Scene) -> Handle {
|
||||
scene.add_entity(EntityType::Line(acadrust::entities::Line::from_points(
|
||||
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
|
||||
acadrust::types::Vector3::new(6.0, 8.0, 0.0),
|
||||
)))
|
||||
}
|
||||
|
||||
fn add_circle(scene: &mut Scene) -> Handle {
|
||||
scene.add_entity(EntityType::Circle(
|
||||
acadrust::entities::Circle::from_center_radius(
|
||||
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
|
||||
3.0,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn add_arc(scene: &mut Scene) -> Handle {
|
||||
scene.add_entity(EntityType::Arc(
|
||||
acadrust::entities::Arc::from_center_radius_angles(
|
||||
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
|
||||
3.0,
|
||||
0.0,
|
||||
std::f64::consts::FRAC_PI_2,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x_keyword_switches_mode_and_then_builds_a_distance_x_constraint() {
|
||||
let mut scene = Scene::new();
|
||||
let line = add_line(&mut scene);
|
||||
let mut cmd =
|
||||
DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT");
|
||||
|
||||
// The keyword itself only switches mode and re-prompts — it must
|
||||
// not build a constraint yet.
|
||||
assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint)));
|
||||
assert!(
|
||||
cmd.prompt().contains("X distance"),
|
||||
"prompt should reflect the new mode: {}",
|
||||
cmd.prompt()
|
||||
);
|
||||
|
||||
match cmd.on_text_input("10") {
|
||||
Some(CmdResult::AddSketchConstraint {
|
||||
kind,
|
||||
driving_param: Some(DrivingValue::Literal(v)),
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(kind, ConstraintKind::DistanceX);
|
||||
assert_eq!(v, 10.0);
|
||||
}
|
||||
_ => panic!("expected an AddSketchConstraint{{DistanceX}}, got a different result"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_distances_accept_signed_values() {
|
||||
let mut scene = Scene::new();
|
||||
let line = add_line(&mut scene);
|
||||
let mut cmd =
|
||||
DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT");
|
||||
assert!(matches!(cmd.on_text_input("X"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(
|
||||
cmd.on_text_input("-3"),
|
||||
Some(CmdResult::AddSketchConstraint {
|
||||
kind: ConstraintKind::DistanceX,
|
||||
driving_param: Some(DrivingValue::Literal(-3.0)),
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_distance_defaults_use_the_selected_axis() {
|
||||
let mut scene = Scene::new();
|
||||
let line = add_line(&mut scene);
|
||||
let mut cmd =
|
||||
DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT");
|
||||
assert!(matches!(cmd.on_text_input("Y"), Some(CmdResult::NeedPoint)));
|
||||
assert!(cmd.prompt().contains("<8.0000>"));
|
||||
assert!(matches!(
|
||||
cmd.on_enter(),
|
||||
CmdResult::AddSketchConstraint {
|
||||
kind: ConstraintKind::DistanceY,
|
||||
driving_param: Some(DrivingValue::Literal(8.0)),
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_distances_reject_nonpositive_and_nonfinite_values() {
|
||||
let mut scene = Scene::new();
|
||||
let line = add_line(&mut scene);
|
||||
let mut cmd =
|
||||
DistanceConstraintCommand::new(&scene, line).expect("Line supports DCONSTRAINT");
|
||||
assert!(cmd.on_text_input("-3").is_none());
|
||||
assert!(cmd.build(DrivingValue::Literal(f64::INFINITY)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d_keyword_switches_a_circles_command_to_diameter_mode() {
|
||||
let mut scene = Scene::new();
|
||||
let circle = add_circle(&mut scene);
|
||||
let mut cmd =
|
||||
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
|
||||
|
||||
assert!(matches!(cmd.on_text_input("D"), Some(CmdResult::NeedPoint)));
|
||||
assert!(
|
||||
cmd.prompt().contains("diameter"),
|
||||
"prompt should reflect Diameter mode: {}",
|
||||
cmd.prompt()
|
||||
);
|
||||
|
||||
match cmd.on_text_input("16") {
|
||||
Some(CmdResult::AddSketchConstraint {
|
||||
kind,
|
||||
driving_param: Some(DrivingValue::Literal(v)),
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(kind, ConstraintKind::Diameter);
|
||||
assert_eq!(v, 16.0);
|
||||
}
|
||||
_ => panic!("expected an AddSketchConstraint{{Diameter}}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_mode_still_builds_a_plain_radius_constraint() {
|
||||
let mut scene = Scene::new();
|
||||
let circle = add_circle(&mut scene);
|
||||
let mut cmd =
|
||||
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
|
||||
|
||||
match cmd.on_text_input("5") {
|
||||
Some(CmdResult::AddSketchConstraint { kind, .. }) => {
|
||||
assert_eq!(kind, ConstraintKind::Radius)
|
||||
}
|
||||
_ => panic!("expected an AddSketchConstraint{{Radius}}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyword_switches_an_arcs_command_to_arc_length_mode() {
|
||||
let mut scene = Scene::new();
|
||||
let arc = add_arc(&mut scene);
|
||||
let mut cmd =
|
||||
DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT");
|
||||
|
||||
assert!(matches!(cmd.on_text_input("A"), Some(CmdResult::NeedPoint)));
|
||||
assert!(
|
||||
cmd.prompt().contains("arc length"),
|
||||
"prompt should reflect ArcLength mode: {}",
|
||||
cmd.prompt()
|
||||
);
|
||||
|
||||
match cmd.on_text_input("8") {
|
||||
Some(CmdResult::AddSketchConstraint {
|
||||
kind,
|
||||
driving_param: Some(DrivingValue::Literal(v)),
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(kind, ConstraintKind::ArcLength);
|
||||
assert_eq!(v, 8.0);
|
||||
}
|
||||
_ => panic!("expected an AddSketchConstraint{{ArcLength}}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_length_keyword_is_not_offered_for_a_plain_circle() {
|
||||
let mut scene = Scene::new();
|
||||
let circle = add_circle(&mut scene);
|
||||
let mut cmd =
|
||||
DistanceConstraintCommand::new(&scene, circle).expect("Circle supports DCONSTRAINT");
|
||||
|
||||
assert!(
|
||||
!cmd.prompt().contains("Arclength"),
|
||||
"a plain circle has no arc length option: {}",
|
||||
cmd.prompt()
|
||||
);
|
||||
// "A" isn't a recognized keyword here and doesn't parse as a
|
||||
// number or a named parameter either, so it's rejected outright.
|
||||
assert!(cmd.on_text_input("A").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_length_default_is_the_arcs_current_swept_length() {
|
||||
let mut scene = Scene::new();
|
||||
let arc = add_arc(&mut scene); // radius 3, 90° sweep -> length = 3 * pi/2
|
||||
let mut cmd =
|
||||
DistanceConstraintCommand::new(&scene, arc).expect("Arc supports DCONSTRAINT");
|
||||
cmd.on_text_input("A");
|
||||
|
||||
let expected = 3.0 * std::f64::consts::FRAC_PI_2;
|
||||
assert!(
|
||||
cmd.prompt().contains(&format!("{expected:.4}")),
|
||||
"prompt should default to the arc's current length: {}",
|
||||
cmd.prompt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,7 @@ pub struct HatchCommand {
|
||||
pattern_override: Option<(String, HatchPattern)>,
|
||||
angle_override: Option<f32>,
|
||||
scale_override: Option<f32>,
|
||||
default_origin: [f64; 2],
|
||||
associative: bool,
|
||||
separate_hatches: bool,
|
||||
island_style: acadrust::entities::HatchStyleType,
|
||||
@@ -244,6 +245,7 @@ impl HatchCommand {
|
||||
pattern_override: None,
|
||||
angle_override: None,
|
||||
scale_override: None,
|
||||
default_origin: [0.0, 0.0],
|
||||
associative: true,
|
||||
separate_hatches: false,
|
||||
island_style: inherited
|
||||
@@ -257,6 +259,8 @@ impl HatchCommand {
|
||||
command
|
||||
}
|
||||
|
||||
pub fn with_origin(mut self, origin: [f64; 2]) -> Self { self.default_origin = origin; self }
|
||||
|
||||
fn set_object_selection(&mut self, handles: Vec<Handle>) {
|
||||
let mut segments = Vec::new();
|
||||
for handle in &handles {
|
||||
@@ -383,6 +387,7 @@ impl HatchCommand {
|
||||
}
|
||||
}
|
||||
return HatchModel {
|
||||
pattern_origin: source.pattern_origin,
|
||||
render_instance: None,
|
||||
boundary: std::sync::Arc::new(rel),
|
||||
pattern,
|
||||
@@ -425,11 +430,23 @@ impl HatchCommand {
|
||||
dashes: vec![],
|
||||
}])
|
||||
});
|
||||
let (name, pattern) = self
|
||||
let (name, mut pattern) = self
|
||||
.pattern_override
|
||||
.clone()
|
||||
.unwrap_or_else(|| (pat_name.to_string(), default_pattern));
|
||||
let angle = self.angle_override.unwrap_or(0.0);
|
||||
let scale = self.scale_override.unwrap_or(1.0).max(1.0e-6);
|
||||
if let (HatchPattern::Pattern(families), Some(anchor)) = (&mut pattern, local_boundary.first()) {
|
||||
let (sin, cos) = (angle as f64).sin_cos();
|
||||
let dx = self.default_origin[0] - anchor[0] as f64;
|
||||
let dy = self.default_origin[1] - anchor[1] as f64;
|
||||
for family in families {
|
||||
family.x0 += ((dx * cos + dy * sin) / scale as f64) as f32;
|
||||
family.y0 += ((-dx * sin + dy * cos) / scale as f64) as f32;
|
||||
}
|
||||
}
|
||||
HatchModel {
|
||||
pattern_origin: Some(self.default_origin),
|
||||
render_instance: None,
|
||||
boundary: std::sync::Arc::new(rel),
|
||||
pattern,
|
||||
@@ -954,6 +971,7 @@ impl GradientCommand {
|
||||
1.0e-6,
|
||||
);
|
||||
HatchModel {
|
||||
pattern_origin: None,
|
||||
render_instance: None,
|
||||
boundary: std::sync::Arc::new(rel),
|
||||
pattern: HatchPattern::Gradient {
|
||||
|
||||
@@ -28,9 +28,23 @@ pub struct HatcheditCommand {
|
||||
step: HatcheditStep,
|
||||
origin: Option<(f64, f64)>,
|
||||
disassociate: bool,
|
||||
store_origin: bool,
|
||||
current_origin: [f64; 2],
|
||||
origin_plane: cadkernel::space::Plane,
|
||||
origin_bounds: Option<([f64;2],[f64;2])>,
|
||||
origin_bounds_unavailable: bool,
|
||||
style: Option<acadrust::entities::HatchStyleType>,
|
||||
annotative: Option<bool>,
|
||||
annotative_current: bool,
|
||||
style_current: acadrust::entities::HatchStyleType,
|
||||
input: Option<&'static str>,
|
||||
source_appearance: Option<(acadrust::types::Color,String,acadrust::types::Transparency)>,
|
||||
current_color: acadrust::types::Color,
|
||||
current_transparency: acadrust::types::Transparency,
|
||||
boundary_region: bool,
|
||||
association_sources: Option<(crate::command::WorkingPlane,rustc_hash::FxHashMap<Handle,crate::scene::BoundarySource>)>,
|
||||
association_paths: Vec<acadrust::entities::BoundaryPath>,
|
||||
association_missed: bool,
|
||||
}
|
||||
|
||||
impl HatcheditCommand {
|
||||
@@ -39,9 +53,23 @@ impl HatcheditCommand {
|
||||
step: HatcheditStep::PickHatch,
|
||||
origin: None,
|
||||
disassociate: false,
|
||||
store_origin: false,
|
||||
origin_bounds: None,
|
||||
origin_bounds_unavailable: false,
|
||||
current_origin: [0.0, 0.0],
|
||||
origin_plane: cadkernel::space::Plane::from_axes([0.0;3],[1.0,0.0,0.0],[0.0,1.0,0.0]),
|
||||
style: None,
|
||||
annotative: None,
|
||||
annotative_current: false,
|
||||
style_current: acadrust::entities::HatchStyleType::Normal,
|
||||
input: None,
|
||||
source_appearance: None,
|
||||
current_color: acadrust::types::Color::ByLayer,
|
||||
current_transparency: acadrust::types::Transparency::ByLayer,
|
||||
boundary_region: false,
|
||||
association_sources: None,
|
||||
association_paths: Vec::new(),
|
||||
association_missed: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +89,23 @@ impl HatcheditCommand {
|
||||
},
|
||||
origin: None,
|
||||
disassociate: false,
|
||||
store_origin: false,
|
||||
origin_bounds: None,
|
||||
origin_bounds_unavailable: false,
|
||||
current_origin: [0.0, 0.0],
|
||||
origin_plane: cadkernel::space::Plane::from_axes([0.0;3],[1.0,0.0,0.0],[0.0,1.0,0.0]),
|
||||
style: None,
|
||||
annotative: None,
|
||||
annotative_current: annotative,
|
||||
style_current: acadrust::entities::HatchStyleType::Normal,
|
||||
input: None,
|
||||
source_appearance: None,
|
||||
current_color: acadrust::types::Color::ByLayer,
|
||||
current_transparency: acadrust::types::Transparency::ByLayer,
|
||||
boundary_region: false,
|
||||
association_sources: None,
|
||||
association_paths: Vec::new(),
|
||||
association_missed: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,9 +128,57 @@ impl HatcheditCommand {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn for_association(handle:Handle,name:String,scale:f32,angle:f32,plane:crate::command::WorkingPlane,sources:rustc_hash::FxHashMap<Handle,crate::scene::BoundarySource>)->Self {
|
||||
let mut command=Self::with_handle(handle,name,scale,angle,false);
|
||||
command.input=Some("associate-point");command.association_sources=Some((plane,sources));command
|
||||
}
|
||||
fn add_association_rings(&mut self,rings:Vec<Vec<[f64;2]>>,sources:&rustc_hash::FxHashMap<Handle,crate::scene::BoundarySource>) {
|
||||
let exterior=cadkernel::geom2d::ring_nesting_depths(&rings).into_iter().map(|depth|depth==0).collect::<Vec<_>>();
|
||||
let paths=crate::scene::exact_hatch_paths(&rings,&exterior,sources,1e-6);
|
||||
self.association_missed=paths.is_empty()||paths.len()!=rings.len()||paths.iter().any(|path|path.boundary_handles.is_empty());
|
||||
if !self.association_missed {
|
||||
for path in paths {if !self.association_paths.contains(&path){self.association_paths.push(path);}}
|
||||
}
|
||||
}
|
||||
pub fn with_appearance(mut self,entity:Option<&acadrust::EntityType>,current_color:acadrust::types::Color,current_transparency:acadrust::types::Transparency)->Self {
|
||||
if let Some(acadrust::EntityType::Hatch(hatch)) = entity {
|
||||
self.style_current = hatch.style;
|
||||
self.origin_plane = crate::entities::curve::ocs_plane(hatch.normal, hatch.elevation);
|
||||
self.origin_bounds = hatch.paths.iter().flat_map(|path| &path.edges)
|
||||
.map(crate::entities::hatch::edge_curve).collect::<Option<Vec<_>>>()
|
||||
.and_then(|curves| {
|
||||
// Full ellipses use the four axis endpoints as origin anchors,
|
||||
// not their geometric extrema. Keep the general bounds contract
|
||||
// unchanged and aggregate these command-specific anchor segments.
|
||||
let mut anchors = Vec::new();
|
||||
for curve in curves {
|
||||
match curve {
|
||||
cadkernel::geom2d::Curve::Ellipse(arc) => {
|
||||
if (arc.end_parameter - arc.start_parameter).abs() < std::f64::consts::TAU { return None; }
|
||||
for angle in [0.0, std::f64::consts::FRAC_PI_2] {
|
||||
anchors.push(cadkernel::geom2d::Curve::Line(cadkernel::geom2d::Line {
|
||||
start: arc.ellipse.point_at(angle),
|
||||
end: arc.ellipse.point_at(angle + std::f64::consts::PI),
|
||||
}));
|
||||
}
|
||||
}
|
||||
cadkernel::geom2d::Curve::Nurbs(_) => return None,
|
||||
other => anchors.push(other),
|
||||
}
|
||||
}
|
||||
cadkernel::geom2d::analytic_curve_bounds(&anchors)
|
||||
});
|
||||
}
|
||||
self.source_appearance=entity.map(|e|{let c=e.common();(c.color,c.layer.clone(),c.transparency)});
|
||||
self.current_color=current_color;self.current_transparency=current_transparency;self
|
||||
}
|
||||
|
||||
pub fn with_origin(mut self, origin: [f64; 2]) -> Self { self.current_origin = origin; self }
|
||||
|
||||
fn update_operation(&self) -> HatchEditOperation {
|
||||
HatchEditOperation::Update {
|
||||
origin: self.origin,
|
||||
store_origin: self.store_origin,
|
||||
disassociate: self.disassociate,
|
||||
style: self.style,
|
||||
annotative: self.annotative,
|
||||
@@ -102,6 +192,58 @@ impl CadCommand for HatcheditCommand {
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if let Some(input)=self.input {
|
||||
if input == "style" {
|
||||
let current = match self.style_current {
|
||||
acadrust::entities::HatchStyleType::Normal => "Normal",
|
||||
acadrust::entities::HatchStyleType::Outer => "Outer",
|
||||
acadrust::entities::HatchStyleType::Ignore => "Ignore",
|
||||
};
|
||||
return format!("Enter hatching style [Ignore/Outer/Normal] <{current}>:");
|
||||
}
|
||||
if let Some((color,layer,transparency))=&self.source_appearance {
|
||||
match input {
|
||||
"color"=>return format!("New color [Truecolor/. (for use current)] <{color:?}>:"),
|
||||
"layer"=>return format!("Specify layer or [. (for use current)] <{layer}>:"),
|
||||
"transparency"=>return format!("Specify transparency (0-90) or ByLayer/ByBlock <{}>:",match transparency {
|
||||
acadrust::types::Transparency::ByLayer=>"ByLayer".into(),
|
||||
acadrust::types::Transparency::ByBlock=>"ByBlock".into(),
|
||||
value=>format!("{:.0}",value.as_percent()*100.0),
|
||||
}),
|
||||
_=>{},
|
||||
}
|
||||
}
|
||||
if let HatcheditStep::EditOptions{name,scale,angle,..}=&self.step {
|
||||
match input {
|
||||
"pattern"=>return format!("Enter a pattern name or [Solid] <{name}>:"),
|
||||
"scale"=>return format!("Specify a scale for the pattern <{scale:.4}>:"),
|
||||
"angle"=>return format!("Specify an angle for the pattern <{angle:.4}>:"),
|
||||
_=>{},
|
||||
}
|
||||
}
|
||||
return match input {
|
||||
"annotative"=>if self.annotative_current {"Make hatch annotative [Yes/No] <Y>:"} else {"Make hatch annotative [Yes/No] <N>:"},
|
||||
"origin" if self.origin_bounds_unavailable=>"Boundary extents unavailable for this geometry. [Use current origin/Set new origin/Default to boundary extents] <Use current origin>:",
|
||||
"origin"=>"[Use current origin/Set new origin/Default to boundary extents] <Use current origin>:",
|
||||
"origin-extents"=>"[bottom Left/bottom Right/top rIght/top lEft/Center] <bottom Left>:",
|
||||
"origin-point"=>"Select point:",
|
||||
"origin-store"=>"Store as default origin? [Yes/No] <N>:",
|
||||
"pattern"=>"Enter a pattern name or [Solid]:",
|
||||
"scale"=>"Specify a scale for the pattern:",
|
||||
"angle"=>"Specify an angle for the pattern:",
|
||||
"color"=>"New color [Truecolor] <ByLayer>:",
|
||||
"truecolor"=>"Specify RGB color (red,green,blue):",
|
||||
"layer"=>"Specify layer or [. (for use current)]:",
|
||||
"transparency"=>"Specify transparency value (0-90) or ByLayer/ByBlock:",
|
||||
"draworder"=>"Enter draw order [do Not change/send to Back/bring to Front/send beHind boundary/bring in front of bounDary] <do Not change>:",
|
||||
"boundary-type"=>"Enter type of boundary object [Region/Polyline] <Polyline>:",
|
||||
"boundary-associate"=>"Reassociate hatch with new boundary? [Yes/No] <No>:",
|
||||
"associate-select"=>"Select boundary objects:",
|
||||
"associate-point" if self.association_missed=>"No closed boundary found. Specify internal point or [Select objects]:",
|
||||
"associate-point"=>"Specify internal point or [Select objects]:",
|
||||
_=>"Specify value:",
|
||||
}.into();
|
||||
}
|
||||
match &self.step {
|
||||
HatcheditStep::PickHatch => t!("HATCHEDIT Select hatch:").into_owned(),
|
||||
HatcheditStep::EditOptions {
|
||||
@@ -110,7 +252,7 @@ impl CadCommand for HatcheditCommand {
|
||||
let scale = format!("{scale:.4}");
|
||||
let angle = format!("{angle:.1}");
|
||||
t!(
|
||||
"HATCHEDIT Pattern:%{name} Scale:%{scale} Angle:%{angle} [P pattern / S scale / A angle / O x,y / D disassociate / Y style / N annotative / R recreate / E separate / + handles / - handles | Enter]:",
|
||||
"HATCHEDIT Pattern:%{name} Scale:%{scale} Angle:%{angle} [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] <Properties>:",
|
||||
name = name,
|
||||
scale = scale,
|
||||
angle = angle
|
||||
@@ -123,6 +265,15 @@ impl CadCommand for HatcheditCommand {
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(self.step, HatcheditStep::PickHatch)
|
||||
}
|
||||
fn is_selection_gathering(&self)->bool {self.input==Some("associate-select")}
|
||||
fn on_selection_complete(&mut self,handles:Vec<Handle>)->CmdResult {
|
||||
if let Some((_,sources))=&self.association_sources {
|
||||
let sources=sources.iter().filter(|(handle,_)|handles.contains(handle)).map(|(handle,source)|(*handle,source.clone())).collect();
|
||||
let rings=crate::scene::boundary_faces(&sources,1e-6);
|
||||
self.add_association_rings(rings,&sources);
|
||||
}
|
||||
self.input=Some("associate-point");CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult {
|
||||
if handle.is_null() {
|
||||
@@ -140,25 +291,159 @@ impl CadCommand for HatcheditCommand {
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.step, HatcheditStep::EditOptions { .. })
|
||||
matches!(self.step, HatcheditStep::EditOptions { .. })&&!self.is_selection_gathering()
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
if self.input==Some("associate-point") {return vec![crate::command::CmdOption::new("Select objects","S")];}
|
||||
if let Some(input) = self.input {
|
||||
use crate::command::CmdOption;
|
||||
return match input {
|
||||
"origin" => vec![CmdOption::new("Use current origin", "U"), CmdOption::new("Set new origin", "S"), CmdOption::new("Default to boundary extents", "D")],
|
||||
"origin-extents" => vec![CmdOption::new("Bottom left", "L"), CmdOption::new("Bottom right", "R"), CmdOption::new("Top right", "I"), CmdOption::new("Top left", "E"), CmdOption::new("Center", "C")],
|
||||
"origin-store" => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")],
|
||||
"style" => vec![CmdOption::new("Ignore", "I"), CmdOption::new("Outer", "O"), CmdOption::new("Normal", "N")],
|
||||
"draworder" => vec![CmdOption::new("Do not change", "N"), CmdOption::new("Send to back", "B"), CmdOption::new("Bring to front", "F"), CmdOption::new("Behind boundary", "H"), CmdOption::new("In front of boundary", "D")],
|
||||
"annotative" | "boundary-associate" => vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")],
|
||||
"boundary-type" => vec![CmdOption::new("Region", "R"), CmdOption::new("Polyline", "P")],
|
||||
"color" => vec![CmdOption::new("Truecolor", "T")],
|
||||
"pattern" => vec![CmdOption::new("Solid", "SOLID")],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
}
|
||||
if !matches!(self.step, HatcheditStep::EditOptions { .. }) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![
|
||||
crate::command::CmdOption::new("Disassociate", "D"),
|
||||
crate::command::CmdOption::new("Annotative", "N"),
|
||||
crate::command::CmdOption::new("Recreate boundary", "R"),
|
||||
crate::command::CmdOption::new("Separate hatches", "E"),
|
||||
crate::command::CmdOption::new("Draw front", "F"),
|
||||
crate::command::CmdOption::new("Draw back", "B"),
|
||||
crate::command::CmdOption::enter("Apply"),
|
||||
crate::command::CmdOption::new("Style", "S"),
|
||||
crate::command::CmdOption::new("Origin", "O"),
|
||||
crate::command::CmdOption::new("Properties", "P"),
|
||||
crate::command::CmdOption::new("Color", "CO"),
|
||||
crate::command::CmdOption::new("Layer", "LA"),
|
||||
crate::command::CmdOption::new("Transparency", "T"),
|
||||
crate::command::CmdOption::new("Draw order", "DR"),
|
||||
crate::command::CmdOption::new("Associate", "AS"),
|
||||
crate::command::CmdOption::new("Disassociate", "DI"),
|
||||
crate::command::CmdOption::new("Annotative", "AN"),
|
||||
crate::command::CmdOption::new("Recreate boundary", "B"),
|
||||
crate::command::CmdOption::new("Separate hatches", "H"),
|
||||
crate::command::CmdOption::enter("Properties"),
|
||||
]
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let keyword=text.trim().to_ascii_uppercase();
|
||||
if let Some(input)=self.input {
|
||||
use acadrust::types::{Color,Transparency};
|
||||
let appearance=|color,layer,transparency|HatchEditOperation::Appearance{color,layer,transparency};
|
||||
match input {
|
||||
"origin" => match keyword.as_str() {
|
||||
"U" | "USE" => { self.origin = Some((self.current_origin[0], self.current_origin[1])); return self.apply_result(self.update_operation()); }
|
||||
"S" | "SET" => { self.input = Some("origin-point"); }
|
||||
"D" | "DEFAULT" => {
|
||||
self.origin_bounds_unavailable = self.origin_bounds.is_none();
|
||||
if !self.origin_bounds_unavailable { self.input = Some("origin-extents"); }
|
||||
}
|
||||
_ => {},
|
||||
},
|
||||
"origin-extents" => {
|
||||
if let Some((min,max)) = self.origin_bounds {
|
||||
let origin = match keyword.as_str() {
|
||||
"L" | "LEFT" => min,
|
||||
"R" | "RIGHT" => [max[0],min[1]],
|
||||
"I" | "TOP RIGHT" => max,
|
||||
"E" | "TOP LEFT" => [min[0],max[1]],
|
||||
"C" | "CENTER" => [min[0]*0.5+max[0]*0.5,min[1]*0.5+max[1]*0.5],
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
};
|
||||
self.origin = Some((origin[0],origin[1]));
|
||||
self.input = Some("origin-store");
|
||||
}
|
||||
}
|
||||
"origin-point" => {
|
||||
let values: Option<Vec<f64>> = text.split(',').map(|s| s.trim().parse().ok()).collect();
|
||||
if let Some(values) = values.filter(|v| (2..=3).contains(&v.len()) && v.iter().all(|n| n.is_finite())) {
|
||||
return Some(self.on_point(DVec3::new(values[0], values[1], values.get(2).copied().unwrap_or(0.0))));
|
||||
}
|
||||
}
|
||||
"origin-store" => {
|
||||
self.store_origin = match keyword.as_str() { "Y" | "YES" => true, "N" | "NO" => false, _ => return Some(CmdResult::NeedPoint) };
|
||||
return self.apply_result(self.update_operation());
|
||||
}
|
||||
"style" => {
|
||||
self.style = match keyword.as_str() {
|
||||
"I" | "IGNORE" => Some(acadrust::entities::HatchStyleType::Ignore),
|
||||
"O" | "OUTER" => Some(acadrust::entities::HatchStyleType::Outer),
|
||||
"N" | "NORMAL" => Some(acadrust::entities::HatchStyleType::Normal),
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
};
|
||||
return self.apply_result(self.update_operation());
|
||||
}
|
||||
"annotative" => {
|
||||
let value = match keyword.as_str() { "Y" | "YES" => true, "N" | "NO" => false, _ => return Some(CmdResult::NeedPoint) };
|
||||
self.annotative = Some(value);
|
||||
return self.apply_result(self.update_operation());
|
||||
}
|
||||
"associate-point"=>{
|
||||
if matches!(keyword.as_str(),"S"|"SELECT"|"SELECT OBJECTS") {self.input=Some("associate-select");}
|
||||
return Some(CmdResult::NeedPoint);
|
||||
},
|
||||
"color"=>{
|
||||
if matches!(keyword.as_str(),"T"|"TRUECOLOR") {self.input=Some("truecolor");return Some(CmdResult::NeedPoint);}
|
||||
let color=match keyword.as_str(){"."=>Some(self.current_color),"BYLAYER"=>Some(Color::ByLayer),"BYBLOCK"=>Some(Color::ByBlock),
|
||||
"RED"=>Some(Color::Index(1)),"YELLOW"=>Some(Color::Index(2)),"GREEN"=>Some(Color::Index(3)),
|
||||
"CYAN"=>Some(Color::Index(4)),"BLUE"=>Some(Color::Index(5)),"MAGENTA"=>Some(Color::Index(6)),"WHITE"=>Some(Color::Index(7)),
|
||||
n=>n.parse::<i16>().ok().filter(|v|(0..=256).contains(v)).map(Color::from_index)};
|
||||
return Some(color.and_then(|v|self.apply_result(appearance(Some(v),None,None))).unwrap_or(CmdResult::NeedPoint));
|
||||
}
|
||||
"truecolor"=>{
|
||||
let rgb:Option<Vec<u8>>=keyword.split(',').map(|s|s.trim().parse().ok()).collect();
|
||||
if let Some(rgb)=rgb.filter(|rgb|rgb.len()==3) {return self.apply_result(appearance(Some(Color::from_rgb(rgb[0],rgb[1],rgb[2])),None,None));}
|
||||
}
|
||||
"layer"=>if !text.trim().is_empty(){return self.apply_result(appearance(None,Some(text.trim().to_owned()),None));},
|
||||
"transparency"=>{
|
||||
let value=match keyword.as_str(){"."=>Some(self.current_transparency),"BYLAYER"=>Some(Transparency::BY_LAYER),"BYBLOCK"=>Some(Transparency::BY_BLOCK),
|
||||
n=>n.parse::<u8>().ok().filter(|v|*v<=90).map(|v|Transparency::from_percent(v as f64 / 100.0))};
|
||||
if let Some(value)=value{return self.apply_result(appearance(None,None,Some(value)));}
|
||||
}
|
||||
"draworder"=>return match keyword.as_str(){"F"|"FRONT"=>self.apply_result(HatchEditOperation::DrawOrderFront),
|
||||
"H"|"BEHIND"=>self.apply_result(HatchEditOperation::DrawOrderBoundary{above:false}),
|
||||
"D"=>self.apply_result(HatchEditOperation::DrawOrderBoundary{above:true}),
|
||||
"B"|"BACK"=>self.apply_result(HatchEditOperation::DrawOrderBack),"N"|"NOT"=>Some(CmdResult::Cancel),_=>Some(CmdResult::NeedPoint)},
|
||||
"boundary-type"=>match keyword.as_str(){
|
||||
"P"|"POLYLINE"=>{self.boundary_region=false;self.input=Some("boundary-associate");},
|
||||
"R"|"REGION"=>{self.boundary_region=true;self.input=Some("boundary-associate");},
|
||||
_=>{},
|
||||
},
|
||||
"boundary-associate"=>return match keyword.as_str(){
|
||||
"Y"|"YES"=>self.apply_result(HatchEditOperation::RecreateBoundary{associate:true,region:self.boundary_region}),
|
||||
"N"|"NO"=>self.apply_result(HatchEditOperation::RecreateBoundary{associate:false,region:self.boundary_region}),
|
||||
_=>Some(CmdResult::NeedPoint),
|
||||
},
|
||||
"pattern"=>{
|
||||
if crate::scene::model::hatch_patterns::find(&keyword).is_some() {
|
||||
if let HatcheditStep::EditOptions{name,..}=&mut self.step{*name=keyword.clone();}
|
||||
if keyword=="SOLID" {return self.apply_result(self.update_operation());}
|
||||
self.input=Some("scale");
|
||||
}
|
||||
}
|
||||
"scale"|"angle"=>if let Ok(value)=keyword.parse::<f32>() {if value.is_finite()&&(input=="angle"||value>0.0){
|
||||
if let HatcheditStep::EditOptions{scale,angle,..}=&mut self.step{if input=="scale"{*scale=value;}else{*angle=value;}}
|
||||
if input=="scale"{self.input=Some("angle");}else{return self.apply_result(self.update_operation());}
|
||||
}},
|
||||
_=>{},
|
||||
}
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
let next=match keyword.as_str(){"O"|"ORIGIN"=>Some("origin"),"S"|"STYLE"=>Some("style"),"P"|"PROPERTIES"=>Some("pattern"),"CO"|"COLOR"=>Some("color"),"LA"|"LAYER"=>Some("layer"),
|
||||
"AN"|"ANNOTATIVE"=>Some("annotative"),"T"|"TRANSPARENCY"=>Some("transparency"),"DR"|"DRAW"|"DRAW ORDER"=>Some("draworder"),
|
||||
"B"|"BOUNDARY"|"R"|"RECREATE"=>Some("boundary-type"),_=>None};
|
||||
if let Some(input)=next {self.input=Some(input);return Some(CmdResult::NeedPoint);}
|
||||
if matches!(keyword.as_str(),"H"|"HATCHES"|"SEPARATE") {return self.apply_result(HatchEditOperation::Separate);}
|
||||
if matches!(keyword.as_str(),"AS"|"ASSOCIATE"){return self.apply_result(HatchEditOperation::BeginAssociate);}
|
||||
if matches!(keyword.as_str(),"DI"|"DISASSOCIATE"){
|
||||
self.disassociate=true;return self.apply_result(self.update_operation());
|
||||
}
|
||||
let (_handle, name, scale, angle) = match &mut self.step {
|
||||
HatcheditStep::EditOptions {
|
||||
handle,
|
||||
@@ -236,7 +521,7 @@ impl CadCommand for HatcheditCommand {
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if text == "R" || text == "RECREATE" {
|
||||
return self.apply_result(HatchEditOperation::RecreateBoundary);
|
||||
self.input=Some("boundary-type");return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if text == "E" || text == "SEPARATE" {
|
||||
return self.apply_result(HatchEditOperation::Separate);
|
||||
@@ -269,18 +554,295 @@ impl CadCommand for HatcheditCommand {
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
if self.input == Some("origin-point") {
|
||||
if let Some(point) = self.origin_plane.project(pt.to_array()) {
|
||||
if point.iter().all(|value| value.is_finite()) {
|
||||
self.origin = Some((point[0], point[1]));
|
||||
self.input = Some("origin-store");
|
||||
}
|
||||
}
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if self.input==Some("associate-point") {
|
||||
if let Some((plane,sources))=&self.association_sources {
|
||||
let point=plane.to_local(pt);
|
||||
let sources=sources.clone();
|
||||
if let Some(rings)=crate::scene::model::presspull_model::selected_rings(&sources,[point.x,point.y]) {
|
||||
self.add_association_rings(rings,&sources);
|
||||
}else{self.association_missed=true;}
|
||||
}
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
// Enter without text → apply current settings
|
||||
self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel)
|
||||
match self.input {
|
||||
None if matches!(self.step,HatcheditStep::EditOptions{..})=>{
|
||||
self.input=Some("pattern");CmdResult::NeedPoint
|
||||
}
|
||||
Some("origin")=>{self.origin=Some((self.current_origin[0],self.current_origin[1]));self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel)},
|
||||
Some("origin-extents")=>{
|
||||
if let Some((min,_)) = self.origin_bounds { self.origin=Some((min[0],min[1])); self.input=Some("origin-store"); }
|
||||
CmdResult::NeedPoint
|
||||
},
|
||||
Some("origin-point")=>CmdResult::NeedPoint,
|
||||
Some("origin-store")=>self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel),
|
||||
Some("style")=>{self.style=Some(self.style_current);self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel)},
|
||||
Some("annotative")=>{self.annotative=Some(self.annotative_current);self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel)},
|
||||
Some("pattern")=>{
|
||||
if matches!(&self.step,HatcheditStep::EditOptions{name,..} if name.eq_ignore_ascii_case("SOLID")) {
|
||||
self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel)
|
||||
}else{self.input=Some("scale");CmdResult::NeedPoint}
|
||||
}
|
||||
Some("scale")=>{self.input=Some("angle");CmdResult::NeedPoint}
|
||||
Some("angle")=>self.apply_result(self.update_operation()).unwrap_or(CmdResult::Cancel),
|
||||
Some("boundary-type")=>{self.boundary_region=false;self.input=Some("boundary-associate");CmdResult::NeedPoint},
|
||||
Some("boundary-associate")=>self.apply_result(HatchEditOperation::RecreateBoundary{associate:false,region:self.boundary_region}).unwrap_or(CmdResult::Cancel),
|
||||
Some("associate-select")=>{self.input=Some("associate-point");CmdResult::NeedPoint},
|
||||
Some("associate-point")=>if self.association_paths.is_empty(){CmdResult::Cancel}else{
|
||||
self.apply_result(HatchEditOperation::AssociatePaths(self.association_paths.clone())).unwrap_or(CmdResult::Cancel)
|
||||
},
|
||||
_=>CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::hatch::{
|
||||
BoundaryEdge, BoundaryPath, EllipticArcEdge, LineEdge,
|
||||
};
|
||||
use acadrust::entities::{Hatch, HatchStyleType};
|
||||
use acadrust::types::{Color, Transparency, Vector2};
|
||||
use acadrust::EntityType;
|
||||
|
||||
fn hatch_command() -> HatcheditCommand {
|
||||
let mut hatch = Hatch::new();
|
||||
hatch.common.handle = Handle::new(7);
|
||||
hatch.common.layer = "Boundary".into();
|
||||
hatch.common.color = Color::Index(3);
|
||||
hatch.common.transparency = Transparency::T_20;
|
||||
hatch.style = HatchStyleType::Outer;
|
||||
let mut path = BoundaryPath::new();
|
||||
for (start, end) in [
|
||||
([1.0, 2.0], [5.0, 2.0]),
|
||||
([5.0, 2.0], [5.0, 6.0]),
|
||||
([5.0, 6.0], [1.0, 6.0]),
|
||||
([1.0, 6.0], [1.0, 2.0]),
|
||||
] {
|
||||
path.edges.push(BoundaryEdge::Line(LineEdge {
|
||||
start: Vector2::new(start[0], start[1]),
|
||||
end: Vector2::new(end[0], end[1]),
|
||||
}));
|
||||
}
|
||||
hatch.paths.push(path);
|
||||
let entity = EntityType::Hatch(hatch);
|
||||
HatcheditCommand::with_handle(Handle::new(7), "ANSI31".into(), 1.0, 0.0, false)
|
||||
.with_appearance(Some(&entity), Color::Index(6), Transparency::T_40)
|
||||
.with_origin([9.0, 8.0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_stages_validate_and_apply_pattern_values() {
|
||||
let mut command = hatch_command();
|
||||
assert!(matches!(command.on_enter(), CmdResult::NeedPoint));
|
||||
assert!(command.prompt().contains("ANSI31"));
|
||||
assert!(matches!(
|
||||
command.on_text_input("missing"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
command.on_text_input("ANSI31"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
command.on_text_input("0"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
command.on_text_input("2.5"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
let Some(CmdResult::HatcheditApply {
|
||||
name,
|
||||
scale,
|
||||
angle,
|
||||
operation: HatchEditOperation::Update { .. },
|
||||
..
|
||||
}) = command.on_text_input("30")
|
||||
else {
|
||||
panic!("expected a validated hatch update");
|
||||
};
|
||||
assert_eq!(name, "ANSI31");
|
||||
assert_eq!(scale, 2.5);
|
||||
assert_eq!(angle, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_stages_keep_exact_color_layer_and_transparency_choices() {
|
||||
let mut color = hatch_command();
|
||||
assert!(matches!(
|
||||
color.on_text_input("CO"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
color.on_text_input("red"),
|
||||
Some(CmdResult::HatcheditApply {
|
||||
operation: HatchEditOperation::Appearance {
|
||||
color: Some(Color::Index(1)),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
let mut layer = hatch_command();
|
||||
assert!(matches!(
|
||||
layer.on_text_input("LA"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
layer.on_text_input("CaseSensitiveLayer"),
|
||||
Some(CmdResult::HatcheditApply {
|
||||
operation: HatchEditOperation::Appearance {
|
||||
layer: Some(value),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) if value == "CaseSensitiveLayer"
|
||||
));
|
||||
|
||||
let mut transparency = hatch_command();
|
||||
assert!(matches!(
|
||||
transparency.on_text_input("T"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
transparency.on_text_input("91"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
transparency.on_text_input("25"),
|
||||
Some(CmdResult::HatcheditApply {
|
||||
operation: HatchEditOperation::Appearance {
|
||||
transparency: Some(Transparency::Explicit(64)),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_choices_use_drawing_default_or_analytic_boundary_extents() {
|
||||
let mut drawing_default = hatch_command();
|
||||
assert!(matches!(
|
||||
drawing_default.on_text_input("O"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
drawing_default.on_text_input("U"),
|
||||
Some(CmdResult::HatcheditApply {
|
||||
operation: HatchEditOperation::Update {
|
||||
origin: Some((9.0, 8.0)),
|
||||
store_origin: false,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
|
||||
let mut extents = hatch_command();
|
||||
assert!(matches!(
|
||||
extents.on_text_input("O"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
extents.on_text_input("D"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(extents.on_enter(), CmdResult::NeedPoint));
|
||||
assert!(matches!(
|
||||
extents.on_text_input("Y"),
|
||||
Some(CmdResult::HatcheditApply {
|
||||
operation: HatchEditOperation::Update {
|
||||
origin: Some((1.0, 2.0)),
|
||||
store_origin: true,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_enter_keeps_the_selected_hatch_default() {
|
||||
let mut command = hatch_command();
|
||||
assert!(matches!(
|
||||
command.on_text_input("S"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
command.on_enter(),
|
||||
CmdResult::HatcheditApply {
|
||||
operation: HatchEditOperation::Update {
|
||||
style: Some(HatchStyleType::Outer),
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_ellipse_origin_uses_axis_anchor_bounds() {
|
||||
let mut hatch = Hatch::new();
|
||||
let mut path = BoundaryPath::new();
|
||||
path.edges.push(BoundaryEdge::EllipticArc(EllipticArcEdge {
|
||||
center: Vector2::new(10.0, 20.0),
|
||||
major_axis_endpoint: Vector2::new(3.0, 4.0),
|
||||
minor_axis_ratio: 0.4,
|
||||
start_angle: 0.0,
|
||||
end_angle: std::f64::consts::TAU,
|
||||
counter_clockwise: true,
|
||||
}));
|
||||
hatch.paths.push(path);
|
||||
let entity = EntityType::Hatch(hatch);
|
||||
let mut command = HatcheditCommand::with_handle(
|
||||
Handle::new(7),
|
||||
"ANSI31".into(),
|
||||
1.0,
|
||||
0.0,
|
||||
false,
|
||||
)
|
||||
.with_appearance(Some(&entity), Color::ByLayer, Transparency::ByLayer);
|
||||
|
||||
assert!(matches!(
|
||||
command.on_text_input("O"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(
|
||||
command.on_text_input("D"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(matches!(command.on_enter(), CmdResult::NeedPoint));
|
||||
assert!(matches!(
|
||||
command.on_enter(),
|
||||
CmdResult::HatcheditApply {
|
||||
operation: HatchEditOperation::Update {
|
||||
origin: Some((7.0, 16.0)),
|
||||
..
|
||||
},
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["HATCHEDIT"] }); // HatcheditCommand
|
||||
@@ -122,7 +122,7 @@ impl HelixCommand {
|
||||
.iter()
|
||||
.map(|point| Vector3::new(point[0], point[1], point[2]))
|
||||
.collect();
|
||||
spline.flags.planar = false;
|
||||
spline.flags.planar = height == 0.0;
|
||||
spline.flags.rational = nurbs.is_rational();
|
||||
if spline.flags.rational {
|
||||
spline.weights = nurbs.weights().to_vec();
|
||||
@@ -134,9 +134,9 @@ impl HelixCommand {
|
||||
let start = self.center + start_direction * curve.base_radius;
|
||||
helix.start_point = Vector3::new(start.x, start.y, start.z);
|
||||
helix.axis_vector = Vector3::new(axis.x, axis.y, axis.z);
|
||||
helix.radius = curve.base_radius;
|
||||
helix.radius = curve.top_radius;
|
||||
helix.turns = curve.turns;
|
||||
helix.turn_height = height / curve.turns;
|
||||
helix.turn_height = if curve.turns == 0.0 { 0.0 } else { height / curve.turns };
|
||||
helix.handedness = self.counter_clockwise;
|
||||
helix.constraint = self.constraint;
|
||||
Some(EntityType::Helix(helix))
|
||||
@@ -262,7 +262,7 @@ impl CadCommand for HelixCommand {
|
||||
} else {
|
||||
distance
|
||||
};
|
||||
if radius > EPSILON {
|
||||
if radius >= 0.0 {
|
||||
self.base_radius = radius;
|
||||
self.top_radius = radius;
|
||||
self.step = Step::TopRadius;
|
||||
@@ -272,21 +272,20 @@ impl CadCommand for HelixCommand {
|
||||
Step::TopRadius | Step::TopDiameter => {
|
||||
let local = self.plane.vector_to_local(point - self.center);
|
||||
let distance = local.x.hypot(local.y);
|
||||
self.top_radius = if self.step == Step::TopDiameter {
|
||||
let top_radius = if self.step == Step::TopDiameter {
|
||||
distance * 0.5
|
||||
} else {
|
||||
distance
|
||||
};
|
||||
self.step = Step::Final;
|
||||
if self.base_radius > 0.0 || top_radius > 0.0 {
|
||||
self.top_radius = top_radius;
|
||||
self.step = Step::Final;
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Final => {
|
||||
let height = (point - self.center).dot(self.plane.z);
|
||||
if height.abs() <= EPSILON {
|
||||
CmdResult::NeedPoint
|
||||
} else {
|
||||
self.commit(height, self.plane.z)
|
||||
}
|
||||
self.commit(height, self.plane.z)
|
||||
}
|
||||
Step::AxisEndpoint => {
|
||||
let vector = point - self.center;
|
||||
@@ -310,6 +309,9 @@ impl CadCommand for HelixCommand {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::TopRadius | Step::TopDiameter => {
|
||||
if self.base_radius == 0.0 {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.top_radius = self.base_radius;
|
||||
self.step = Step::Final;
|
||||
CmdResult::NeedPoint
|
||||
@@ -382,7 +384,7 @@ impl CadCommand for HelixCommand {
|
||||
}
|
||||
Step::BaseRadius => {
|
||||
let radius = Self::parse_value(value)?;
|
||||
if radius > EPSILON {
|
||||
if radius >= 0.0 {
|
||||
self.base_radius = radius;
|
||||
self.top_radius = radius;
|
||||
self.step = Step::TopRadius;
|
||||
@@ -391,7 +393,7 @@ impl CadCommand for HelixCommand {
|
||||
}
|
||||
Step::BaseDiameter => {
|
||||
let diameter = Self::parse_value(value)?;
|
||||
if diameter > EPSILON {
|
||||
if diameter >= 0.0 {
|
||||
self.base_radius = diameter * 0.5;
|
||||
self.top_radius = self.base_radius;
|
||||
self.step = Step::TopRadius;
|
||||
@@ -400,7 +402,7 @@ impl CadCommand for HelixCommand {
|
||||
}
|
||||
Step::TopRadius => {
|
||||
let radius = Self::parse_value(value)?;
|
||||
if radius >= 0.0 {
|
||||
if radius >= 0.0 && (self.base_radius > 0.0 || radius > 0.0) {
|
||||
self.top_radius = radius;
|
||||
self.step = Step::Final;
|
||||
}
|
||||
@@ -408,7 +410,7 @@ impl CadCommand for HelixCommand {
|
||||
}
|
||||
Step::TopDiameter => {
|
||||
let diameter = Self::parse_value(value)?;
|
||||
if diameter >= 0.0 {
|
||||
if diameter >= 0.0 && (self.base_radius > 0.0 || diameter > 0.0) {
|
||||
self.top_radius = diameter * 0.5;
|
||||
self.step = Step::Final;
|
||||
}
|
||||
@@ -416,11 +418,15 @@ impl CadCommand for HelixCommand {
|
||||
}
|
||||
Step::Final => {
|
||||
let height = Self::parse_value(value)?;
|
||||
(height.abs() > EPSILON).then(|| self.commit(height, self.plane.z))
|
||||
Some(self.commit(height, self.plane.z))
|
||||
}
|
||||
Step::AxisEndpoint => None,
|
||||
Step::Turns => {
|
||||
let turns = Self::parse_turns(value)?;
|
||||
if turns > 500.0 {
|
||||
self.step = Step::Final;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if turns > EPSILON {
|
||||
self.turns = turns;
|
||||
self.requested_turn_height = None;
|
||||
@@ -493,3 +499,26 @@ impl CadCommand for HelixCommand {
|
||||
}
|
||||
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["HELIX"] });
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn zero_turn_build_keeps_finite_native_parameters() {
|
||||
let mut command = HelixCommand::new();
|
||||
command.center = DVec3::new(1.0, 2.0, 3.0);
|
||||
command.base_radius = 2.0;
|
||||
command.top_radius = 4.0;
|
||||
command.turns = 0.0;
|
||||
let EntityType::Helix(helix) = command.build(0.0, DVec3::Z).unwrap() else {
|
||||
panic!("expected helix");
|
||||
};
|
||||
|
||||
assert_eq!(helix.turn_height, 0.0);
|
||||
assert!(helix.turn_height.is_finite());
|
||||
assert!(helix.spline.flags.planar);
|
||||
assert_eq!(helix.spline.control_points.len(), 2);
|
||||
assert_eq!(helix.spline.control_points[0], helix.spline.control_points[1]);
|
||||
}
|
||||
}
|
||||
+300
-29
@@ -9,7 +9,7 @@ use acadrust::entities::{Ray as RayEnt, XLine as XLineEnt};
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::command::{CadCommand, CmdResult, WorkingPlane};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::DVec3;
|
||||
|
||||
@@ -50,6 +50,9 @@ impl CadCommand for RayCommand {
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
if !pt.is_finite() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if let Some(base) = self.base {
|
||||
let dir = pt - base;
|
||||
let len = dir.length();
|
||||
@@ -123,11 +126,64 @@ impl CadCommand for RayCommand {
|
||||
|
||||
pub struct XLineCommand {
|
||||
base: Option<DVec3>,
|
||||
mode: XLineMode,
|
||||
plane: WorkingPlane,
|
||||
reference: Option<(DVec3, DVec3)>,
|
||||
picked: Option<EntityType>,
|
||||
offset: Option<f64>,
|
||||
value_origin: Option<DVec3>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum XLineMode {
|
||||
Points,
|
||||
Direction(DVec3),
|
||||
Angle,
|
||||
AngleReference,
|
||||
Bisect,
|
||||
OffsetDistance,
|
||||
OffsetPick,
|
||||
OffsetSide,
|
||||
}
|
||||
|
||||
impl XLineCommand {
|
||||
pub fn new() -> Self {
|
||||
Self { base: None }
|
||||
Self {
|
||||
base: None,
|
||||
mode: XLineMode::Points,
|
||||
plane: WorkingPlane::default(),
|
||||
reference: None,
|
||||
picked: None,
|
||||
offset: None,
|
||||
value_origin: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry(&self, pt: DVec3) -> Option<(DVec3, DVec3)> {
|
||||
if !pt.is_finite() { return None; }
|
||||
let (base, direction) = match self.mode {
|
||||
XLineMode::Points => (self.base?, pt - self.base?),
|
||||
XLineMode::Direction(dir) => (pt, dir),
|
||||
XLineMode::Bisect => {
|
||||
let base = self.base?;
|
||||
let first = self.reference?.1;
|
||||
let last = (pt - base).try_normalize()?;
|
||||
(base, first + last)
|
||||
}
|
||||
XLineMode::OffsetSide => {
|
||||
let (base, dir) = self.reference?;
|
||||
if let Some(distance) = self.offset {
|
||||
let normal = self.plane.z.cross(dir).try_normalize()?;
|
||||
let side = (pt - base).dot(normal);
|
||||
if side.abs() < 1e-10 { return None; }
|
||||
(base + normal * distance * side.signum(), dir)
|
||||
} else {
|
||||
(pt, dir)
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some((base, direction.try_normalize()?))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,43 +193,168 @@ impl CadCommand for XLineCommand {
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.base.is_none() {
|
||||
t!("XLINE Specify a point:").into_owned()
|
||||
} else {
|
||||
t!("XLINE Specify through point:").into_owned()
|
||||
}
|
||||
if self.value_origin.is_some() { return "XLINE Specify second point:".into(); }
|
||||
match self.mode {
|
||||
XLineMode::Points if self.base.is_none() => "XLINE Specify a point or [Hor/Ver/Ang/Bisect/Offset]:",
|
||||
XLineMode::Angle => "XLINE Enter angle of xline <0> or [Reference]:",
|
||||
XLineMode::AngleReference | XLineMode::OffsetPick => "XLINE Select a line object:",
|
||||
XLineMode::Bisect if self.base.is_none() => "XLINE Specify angle vertex point:",
|
||||
XLineMode::Bisect if self.reference.is_none() => "XLINE Specify angle start point:",
|
||||
XLineMode::Bisect => "XLINE Specify angle end point:",
|
||||
XLineMode::OffsetDistance => "XLINE Specify offset distance or [Through] <Through>:",
|
||||
XLineMode::OffsetSide if self.offset.is_some() => "XLINE Specify side to offset:",
|
||||
_ => "XLINE Specify through point:",
|
||||
}.into()
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
use crate::command::CmdOption;
|
||||
if self.base.is_some() {
|
||||
vec![CmdOption::enter(t!("Done").as_ref())]
|
||||
} else {
|
||||
vec![]
|
||||
if self.value_origin.is_some() { return vec![]; }
|
||||
match self.mode {
|
||||
XLineMode::Points if self.base.is_none() => vec![
|
||||
CmdOption::new("Hor", "H"), CmdOption::new("Ver", "V"),
|
||||
CmdOption::new("Ang", "A"), CmdOption::new("Bisect", "B"),
|
||||
CmdOption::new("Offset", "O"),
|
||||
],
|
||||
XLineMode::Angle => vec![CmdOption::new("Reference", "R")],
|
||||
XLineMode::OffsetDistance => vec![CmdOption::new("Through", "T")],
|
||||
_ => vec![CmdOption::enter(t!("Done").as_ref())],
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
if let Some(base) = self.base {
|
||||
let dir = pt - base;
|
||||
let len = dir.length();
|
||||
if len < 1e-6 {
|
||||
return CmdResult::NeedPoint;
|
||||
if !pt.is_finite() { return CmdResult::NeedPoint; }
|
||||
if matches!(self.mode, XLineMode::Angle | XLineMode::OffsetDistance) {
|
||||
if self.value_origin.is_none() { self.value_origin = Some(pt); }
|
||||
else if let Some(value) = self.dyn_live_value(pt) {
|
||||
return self.on_text_input(&value.to_string()).unwrap_or(CmdResult::NeedPoint);
|
||||
}
|
||||
let dir_n = dir / len;
|
||||
let xline = XLineEnt::new(
|
||||
Vector3::new(base.x, base.y, base.z),
|
||||
Vector3::new(dir_n.x, dir_n.y, dir_n.z),
|
||||
);
|
||||
CmdResult::CommitEntity(EntityType::XLine(xline))
|
||||
} else {
|
||||
self.base = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if let Some((base, dir)) = self.geometry(pt) {
|
||||
let entity = XLineEnt::new(
|
||||
Vector3::new(base.x, base.y, base.z),
|
||||
Vector3::new(dir.x, dir.y, dir.z),
|
||||
);
|
||||
if matches!(self.mode, XLineMode::OffsetSide) {
|
||||
self.mode = XLineMode::OffsetPick;
|
||||
self.reference = None;
|
||||
}
|
||||
return CmdResult::CommitEntity(EntityType::XLine(entity));
|
||||
}
|
||||
match self.mode {
|
||||
XLineMode::Points | XLineMode::Bisect if self.base.is_none() => self.base = Some(pt),
|
||||
XLineMode::Bisect if self.reference.is_none() => {
|
||||
if let Some(dir) = (pt - self.base.unwrap()).try_normalize() {
|
||||
self.reference = Some((self.base.unwrap(), dir));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
if self.value_origin.is_some() { return CmdResult::NeedPoint; }
|
||||
match self.mode {
|
||||
XLineMode::Angle => self.on_text_input("0").unwrap_or(CmdResult::NeedPoint),
|
||||
XLineMode::OffsetDistance => {
|
||||
self.mode = XLineMode::OffsetPick;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn set_working_plane(&mut self, plane: WorkingPlane) { self.plane = plane; }
|
||||
fn wants_text_input(&self) -> bool { true }
|
||||
fn point_step_accepts_keywords(&self) -> bool { true }
|
||||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
match self.mode {
|
||||
XLineMode::Angle => crate::command::DynField::Angle,
|
||||
XLineMode::OffsetDistance => crate::command::DynField::Distance,
|
||||
_ => crate::command::DynField::Point,
|
||||
}
|
||||
}
|
||||
fn dyn_commit_as_text(&self) -> bool {
|
||||
matches!(self.mode, XLineMode::Angle | XLineMode::OffsetDistance)
|
||||
}
|
||||
fn dyn_live_value(&self, cursor: DVec3) -> Option<f64> {
|
||||
let origin = self.value_origin?;
|
||||
if !cursor.is_finite() { return None; }
|
||||
match self.mode {
|
||||
XLineMode::Angle => {
|
||||
let delta = self.plane.vector_to_local(cursor - origin);
|
||||
(delta.x.hypot(delta.y) > 1e-10).then(|| delta.y.atan2(delta.x).to_degrees())
|
||||
}
|
||||
XLineMode::OffsetDistance => {
|
||||
let distance = cadkernel::space::Vec3::from(cursor.to_array()).distance(origin.to_array().into());
|
||||
(distance.is_finite() && distance > 0.0).then_some(distance)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn dyn_auto_sign_angle(&self) -> bool { false }
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(self.mode, XLineMode::AngleReference | XLineMode::OffsetPick)
|
||||
}
|
||||
fn inject_before_entity_pick(&self) -> bool { true }
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); }
|
||||
fn on_entity_pick(&mut self, _handle: acadrust::Handle, _pt: DVec3) -> CmdResult {
|
||||
let xyz = |v: Vector3| DVec3::new(v.x, v.y, v.z);
|
||||
let reference = match self.picked.take() {
|
||||
Some(EntityType::Line(line)) => Some((xyz(line.start), xyz(line.end) - xyz(line.start))),
|
||||
Some(EntityType::Ray(line)) => Some((xyz(line.base_point), xyz(line.direction))),
|
||||
Some(EntityType::XLine(line)) => Some((xyz(line.base_point), xyz(line.direction))),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((base, dir)) = reference {
|
||||
if let Some(dir) = dir.try_normalize() {
|
||||
self.reference = Some((base, dir));
|
||||
self.mode = if matches!(self.mode, XLineMode::AngleReference) {
|
||||
XLineMode::Angle
|
||||
} else { XLineMode::OffsetSide };
|
||||
}
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let key = text.trim().to_ascii_uppercase();
|
||||
match self.mode {
|
||||
XLineMode::Points if self.base.is_none() => {
|
||||
self.mode = match key.as_str() {
|
||||
"H" | "HOR" => XLineMode::Direction(self.plane.x),
|
||||
"V" | "VER" => XLineMode::Direction(self.plane.y),
|
||||
"A" | "ANG" => XLineMode::Angle,
|
||||
"B" | "BISECT" => XLineMode::Bisect,
|
||||
"O" | "OFFSET" => XLineMode::OffsetDistance,
|
||||
_ => return None,
|
||||
};
|
||||
}
|
||||
XLineMode::Angle => {
|
||||
if key == "R" || key == "REFERENCE" {
|
||||
if self.value_origin.is_some() { return None; }
|
||||
self.mode = XLineMode::AngleReference;
|
||||
} else {
|
||||
let angle: f64 = key.parse().ok().filter(|v: &f64| v.is_finite())?;
|
||||
let (sin, cos) = angle.to_radians().sin_cos();
|
||||
let axis = self.reference.map_or(self.plane.x, |(_, dir)| dir);
|
||||
self.mode = XLineMode::Direction(axis * cos + self.plane.z.cross(axis) * sin);
|
||||
}
|
||||
}
|
||||
XLineMode::OffsetDistance => {
|
||||
self.offset = if key == "T" || key == "THROUGH" {
|
||||
if self.value_origin.is_some() { return None; }
|
||||
None
|
||||
} else {
|
||||
Some(key.parse::<f64>().ok().filter(|v| v.is_finite() && *v > 0.0)?)
|
||||
};
|
||||
self.mode = XLineMode::OffsetPick;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
self.value_origin = None;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
@@ -181,9 +362,9 @@ impl CadCommand for XLineCommand {
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||||
let pt = pt.as_vec3();
|
||||
let base = self.base?.as_vec3();
|
||||
let dir = (pt - base).normalize_or_zero();
|
||||
let (base, dir) = self.geometry(pt)?;
|
||||
let base = base.as_vec3();
|
||||
let dir = dir.as_vec3();
|
||||
let far_pos = base + dir * DISPLAY_EXTENT;
|
||||
let far_neg = base - dir * DISPLAY_EXTENT;
|
||||
Some(WireModel {
|
||||
@@ -230,6 +411,13 @@ impl CadCommand for XLineCommand {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn direction(entity: EntityType) -> DVec3 {
|
||||
let EntityType::XLine(entity) = entity else {
|
||||
panic!("expected a construction line");
|
||||
};
|
||||
DVec3::new(entity.direction.x, entity.direction.y, entity.direction.z)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_rays_and_xlines_keep_their_first_point() {
|
||||
let base = DVec3::new(2.0, 3.0, 4.0);
|
||||
@@ -250,6 +438,89 @@ mod tests {
|
||||
assert_eq!(entity.base_point, Vector3::new(base.x, base.y, base.z));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_rejects_nonfinite_points_without_losing_its_base() {
|
||||
let mut ray = RayCommand::new();
|
||||
assert!(matches!(ray.on_point(DVec3::splat(f64::NAN)), CmdResult::NeedPoint));
|
||||
let base = DVec3::new(1.0, 2.0, 3.0);
|
||||
assert!(matches!(ray.on_point(base), CmdResult::NeedPoint));
|
||||
assert!(matches!(ray.on_point(DVec3::splat(f64::INFINITY)), CmdResult::NeedPoint));
|
||||
|
||||
let CmdResult::CommitEntity(EntityType::Ray(entity)) = ray.on_point(base + DVec3::X)
|
||||
else {
|
||||
panic!("valid direction must commit a ray");
|
||||
};
|
||||
assert_eq!(entity.base_point, Vector3::new(base.x, base.y, base.z));
|
||||
assert_eq!(entity.direction, Vector3::new(1.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xline_horizontal_and_two_point_angle_use_the_working_plane() {
|
||||
let plane = WorkingPlane::new(DVec3::ZERO, DVec3::Y, DVec3::Z);
|
||||
let mut horizontal = XLineCommand::new();
|
||||
horizontal.set_working_plane(plane);
|
||||
assert!(matches!(horizontal.on_text_input("H"), Some(CmdResult::NeedPoint)));
|
||||
let CmdResult::CommitEntity(entity) = horizontal.on_point(DVec3::new(3.0, 4.0, 5.0))
|
||||
else {
|
||||
panic!("horizontal mode must commit immediately");
|
||||
};
|
||||
assert!(direction(entity).abs_diff_eq(plane.x, 1.0e-12));
|
||||
|
||||
let mut angle = XLineCommand::new();
|
||||
angle.set_working_plane(plane);
|
||||
assert!(matches!(angle.on_text_input("A"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(angle.on_point(DVec3::ZERO), CmdResult::NeedPoint));
|
||||
assert!(matches!(angle.on_point(plane.y * 2.0), CmdResult::NeedPoint));
|
||||
let CmdResult::CommitEntity(entity) = angle.on_point(DVec3::new(7.0, 8.0, 9.0))
|
||||
else {
|
||||
panic!("acquired angle must accept a through point");
|
||||
};
|
||||
assert!(direction(entity).abs_diff_eq(plane.y, 1.0e-12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xline_bisector_reuses_its_vertex_and_first_ray() {
|
||||
let mut command = XLineCommand::new();
|
||||
assert!(matches!(command.on_text_input("B"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.on_point(DVec3::X), CmdResult::NeedPoint));
|
||||
|
||||
for end in [DVec3::Y, -DVec3::Y] {
|
||||
let CmdResult::CommitEntity(entity) = command.on_point(end) else {
|
||||
panic!("valid bisector must commit");
|
||||
};
|
||||
let expected = (DVec3::X + end).normalize();
|
||||
assert!(direction(entity).abs_diff_eq(expected, 1.0e-12));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xline_offset_distance_returns_to_source_selection() {
|
||||
let mut command = XLineCommand::new();
|
||||
assert!(matches!(command.on_text_input("O"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.on_point(DVec3::Y * 2.0), CmdResult::NeedPoint));
|
||||
assert!(command.needs_entity_pick());
|
||||
|
||||
let line = acadrust::entities::Line::from_points(
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(1.0, 0.0, 0.0),
|
||||
);
|
||||
command.inject_picked_entity(EntityType::Line(line));
|
||||
assert!(matches!(
|
||||
command.on_entity_pick(acadrust::Handle::new(1), DVec3::ZERO),
|
||||
CmdResult::NeedPoint
|
||||
));
|
||||
let CmdResult::CommitEntity(EntityType::XLine(entity)) =
|
||||
command.on_point(DVec3::Y)
|
||||
else {
|
||||
panic!("offset side must commit a construction line");
|
||||
};
|
||||
assert_eq!(entity.base_point, Vector3::new(0.0, 2.0, 0.0));
|
||||
assert_eq!(entity.direction, Vector3::new(1.0, 0.0, 0.0));
|
||||
assert!(command.needs_entity_pick());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
|
||||
+319
-76
@@ -5,7 +5,8 @@
|
||||
|
||||
use crate::t;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{EntityType, Spline};
|
||||
use acadrust::{CadDocument, EntityType, Handle, Spline};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
@@ -26,6 +27,16 @@ pub struct SplineCommand {
|
||||
pts: Vec<DVec3>,
|
||||
control_vertices: bool,
|
||||
choosing_method: bool,
|
||||
choosing_knots: bool,
|
||||
choosing_tangent: bool,
|
||||
knot_parameterization: i32,
|
||||
begin_tangent: Vector3,
|
||||
end_tangent: Vector3,
|
||||
choosing_objects: bool,
|
||||
convertible: HashMap<Handle, Spline>,
|
||||
selected_objects: Vec<Handle>,
|
||||
choosing_degree: bool,
|
||||
degree: usize,
|
||||
}
|
||||
|
||||
impl SplineCommand {
|
||||
@@ -34,6 +45,16 @@ impl SplineCommand {
|
||||
pts: Vec::new(),
|
||||
control_vertices: false,
|
||||
choosing_method: false,
|
||||
choosing_knots: false,
|
||||
choosing_tangent: false,
|
||||
knot_parameterization: 0,
|
||||
begin_tangent: Vector3::ZERO,
|
||||
end_tangent: Vector3::ZERO,
|
||||
choosing_objects: false,
|
||||
convertible: HashMap::new(),
|
||||
selected_objects: Vec::new(),
|
||||
choosing_degree: false,
|
||||
degree: 3,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,75 +65,86 @@ impl SplineCommand {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_document(mut self, document: &CadDocument) -> Self {
|
||||
self.convertible = document.entities().filter_map(|entity| {
|
||||
spline_from_polyline(entity).map(|spline| (entity.common().handle, spline))
|
||||
}).collect();
|
||||
self
|
||||
}
|
||||
|
||||
fn build(&self, closed: bool) -> Option<EntityType> {
|
||||
if self.pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(EntityType::Spline(make_spline(
|
||||
&self.pts,
|
||||
closed,
|
||||
self.control_vertices,
|
||||
)))
|
||||
let mut spline = if self.control_vertices {
|
||||
let points: Vec<_> = self.pts.iter().map(|point| point.to_array()).collect();
|
||||
control_spline(&points, self.degree.min(points.len() - 1), closed)?
|
||||
} else {
|
||||
make_spline(&self.pts, closed)
|
||||
};
|
||||
spline.knot_parameterization = self.knot_parameterization;
|
||||
spline.begin_tangent = self.begin_tangent;
|
||||
spline.end_tangent = self.end_tangent;
|
||||
Some(EntityType::Spline(spline))
|
||||
}
|
||||
}
|
||||
|
||||
fn control_spline(points: &[[f64; 3]], degree: usize, closed: bool) -> Option<Spline> {
|
||||
let curve = cadkernel::space::NurbsCurve3::from_control_polygon(degree, points, closed)?;
|
||||
let mut spline = Spline {
|
||||
degree: curve.degree() as i32,
|
||||
control_points: curve.control_points().iter().map(|p| Vector3::new(p[0], p[1], p[2])).collect(),
|
||||
knots: curve.knots().to_vec(),
|
||||
weights: curve.weights().to_vec(),
|
||||
..Default::default()
|
||||
};
|
||||
spline.flags.closed = closed;
|
||||
spline.flags.periodic = closed;
|
||||
spline.flags.planar = crate::entities::curve::spline_is_planar(&spline);
|
||||
Some(spline)
|
||||
}
|
||||
|
||||
fn spline_from_polyline(entity: &EntityType) -> Option<Spline> {
|
||||
let (degree, points, closed, normal) = match entity {
|
||||
EntityType::Polyline2D(poly) if poly.flags.is_spline_fit() => {
|
||||
let degree = match poly.smooth_surface as i16 { 5 => 2, 6 => 3, _ => return None };
|
||||
let plane = crate::entities::curve::ocs_plane(poly.normal, poly.elevation);
|
||||
let controls: Vec<_> = poly.vertices.iter().filter(|vertex| vertex.flags.bits() & 16 != 0)
|
||||
.map(|vertex| plane.point_at([vertex.location.x, vertex.location.y])).collect();
|
||||
(degree, controls, poly.is_closed(), poly.normal)
|
||||
}
|
||||
EntityType::Polyline3D(poly) if poly.flags.spline_fit => {
|
||||
let degree = match poly.smooth_type as i16 { 5 => 2, 6 => 3, _ => return None };
|
||||
let controls: Vec<_> = poly.vertices.iter().filter(|vertex| vertex.flags & 16 != 0)
|
||||
.map(|vertex| [vertex.position.x, vertex.position.y, vertex.position.z]).collect();
|
||||
(degree, controls, poly.is_closed(), poly.normal)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let mut spline = control_spline(&points, degree, closed)?;
|
||||
spline.normal = normal;
|
||||
spline.common = entity.common().clone();
|
||||
spline.common.handle = Handle::NULL;
|
||||
Some(spline)
|
||||
}
|
||||
|
||||
/// Store the chosen construction method directly in the persistent spline.
|
||||
fn make_spline(pts: &[DVec3], closed: bool, control_vertices: bool) -> Spline {
|
||||
let mut points: Vec<Vector3> = pts.iter().map(|p| Vector3::new(p.x, p.y, p.z)).collect();
|
||||
let mut spline = if control_vertices {
|
||||
if closed && pts.first() != pts.last() {
|
||||
points.push(points[0]);
|
||||
}
|
||||
let count = points.len();
|
||||
let degree = 3.min(count.saturating_sub(1));
|
||||
let spans = count - degree;
|
||||
// Open uniform knot vector: degree + 1 equal knots at each endpoint.
|
||||
let knots = (0..count + degree + 1)
|
||||
.map(|index| {
|
||||
if index <= degree {
|
||||
0.0
|
||||
} else if index >= count {
|
||||
1.0
|
||||
} else {
|
||||
(index - degree) as f64 / spans as f64
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Spline {
|
||||
degree: degree as i32,
|
||||
control_points: points,
|
||||
knots,
|
||||
weights: vec![1.0; count],
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
Spline {
|
||||
degree: 3,
|
||||
fit_points: points,
|
||||
..Default::default()
|
||||
}
|
||||
fn make_spline(pts: &[DVec3], closed: bool) -> Spline {
|
||||
let mut spline = Spline {
|
||||
degree: 3,
|
||||
fit_points: pts
|
||||
.iter()
|
||||
.map(|point| Vector3::new(point.x, point.y, point.z))
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
spline.flags.closed = closed;
|
||||
// Closed fit curves use the kernel's periodic interpolation rather than
|
||||
// appending a closing straight segment to an open interpolant.
|
||||
spline.flags.periodic = closed;
|
||||
spline
|
||||
}
|
||||
|
||||
/// Preview the exact representation that `build()` commits.
|
||||
fn sample_curve(pts: &[DVec3], closed: bool, control_vertices: bool) -> Vec<[f32; 3]> {
|
||||
if pts.len() < 2 {
|
||||
return pts
|
||||
.iter()
|
||||
.map(|p| [p.x as f32, p.y as f32, p.z as f32])
|
||||
.collect();
|
||||
}
|
||||
let spline = make_spline(pts, closed, control_vertices);
|
||||
crate::entities::curve::spline_curve(&spline)
|
||||
.map(|curve| crate::entities::curve::curve_points(&curve))
|
||||
.unwrap_or_else(|| crate::entities::spline::measurement_polyline(&spline))
|
||||
.into_iter()
|
||||
.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32])
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl CadCommand for SplineCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.control_vertices {
|
||||
@@ -123,8 +155,15 @@ impl CadCommand for SplineCommand {
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.choosing_method {
|
||||
if self.choosing_objects { return t!("Select spline-fit polylines:").to_string(); }
|
||||
if self.choosing_degree {
|
||||
format!("SPLINE Enter degree of spline <{}>:", self.degree)
|
||||
} else if self.choosing_method {
|
||||
"SPLINE Choose creation method [Fit/Control vertices]:".into()
|
||||
} else if self.choosing_knots {
|
||||
"SPLINE Enter knot parameterization [Chord/Square root/Uniform]:".into()
|
||||
} else if self.choosing_tangent {
|
||||
"SPLINE Specify tangent direction:".into()
|
||||
} else if self.pts.is_empty() && self.control_vertices {
|
||||
t!("SPLINE Specify first control point:").into_owned()
|
||||
} else if self.pts.is_empty() {
|
||||
@@ -137,16 +176,30 @@ impl CadCommand for SplineCommand {
|
||||
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
use crate::command::CmdOption;
|
||||
if self.choosing_objects { return Vec::new(); }
|
||||
if self.choosing_method {
|
||||
return vec![
|
||||
CmdOption::new("Fit", "FIT"),
|
||||
CmdOption::new("Control vertices", "CV"),
|
||||
];
|
||||
}
|
||||
if self.pts.is_empty() {
|
||||
return vec![CmdOption::new(t!("Method").as_ref(), "M")];
|
||||
if self.choosing_degree { return vec![]; }
|
||||
if self.choosing_tangent { return vec![]; }
|
||||
if self.choosing_knots {
|
||||
return vec![CmdOption::new("Chord", "CH"), CmdOption::new("Square root", "S"), CmdOption::new("Uniform", "U")];
|
||||
}
|
||||
let mut opts = vec![CmdOption::new(t!("Close").as_ref(), "C")];
|
||||
if self.pts.is_empty() {
|
||||
let mut options = vec![CmdOption::new(t!("Method").as_ref(), "M"), CmdOption::new("Object", "O")];
|
||||
if self.control_vertices {
|
||||
options.push(CmdOption::new("Degree", "D"));
|
||||
} else {
|
||||
options.push(CmdOption::new("Knots", "K"));
|
||||
}
|
||||
return options;
|
||||
}
|
||||
let mut opts = vec![];
|
||||
if self.pts.len() >= 3 { opts.push(CmdOption::new(t!("Close").as_ref(), "C")); }
|
||||
if !self.control_vertices { opts.push(CmdOption::new("Tangency", "T")); }
|
||||
// Undo only makes sense once a control point exists.
|
||||
opts.push(CmdOption::new(t!("Undo").as_ref(), "U"));
|
||||
opts.push(CmdOption::enter(t!("Done").as_ref()));
|
||||
@@ -154,7 +207,23 @@ impl CadCommand for SplineCommand {
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
if self.choosing_method || !pt.is_finite() {
|
||||
if self.choosing_objects || self.choosing_method || self.choosing_knots
|
||||
|| self.choosing_degree || !pt.is_finite() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if self.choosing_tangent {
|
||||
let Some(anchor) = self.pts.last() else { return CmdResult::NeedPoint; };
|
||||
let Some(dir) = (pt - *anchor).try_normalize() else { return CmdResult::NeedPoint; };
|
||||
let tangent = Vector3::new(dir.x, dir.y, dir.z);
|
||||
if self.pts.len() == 1 { self.begin_tangent = tangent; }
|
||||
else { self.end_tangent = tangent; }
|
||||
self.choosing_tangent = false;
|
||||
if self.pts.len() > 1 {
|
||||
return self.build(false).map_or(CmdResult::NeedPoint, CmdResult::CommitAndExit);
|
||||
}
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if self.pts.last().is_some_and(|last| last.distance_squared(pt) < 1e-20) {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.pts.push(pt);
|
||||
@@ -162,10 +231,21 @@ impl CadCommand for SplineCommand {
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.choosing_method {
|
||||
if self.choosing_objects {
|
||||
let replacements = self.selected_objects.iter().filter_map(|handle| {
|
||||
self.convertible.get(handle).map(|spline| (*handle, vec![EntityType::Spline(spline.clone())]))
|
||||
}).collect::<Vec<_>>();
|
||||
return if replacements.is_empty() { CmdResult::Cancel }
|
||||
else { CmdResult::ReplaceMany(replacements, Vec::new()) };
|
||||
}
|
||||
|
||||
if self.choosing_method || self.choosing_knots || self.choosing_degree {
|
||||
self.choosing_degree = false;
|
||||
self.choosing_method = false;
|
||||
self.choosing_knots = false;
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if self.choosing_tangent { return CmdResult::NeedPoint; }
|
||||
match self.build(false) {
|
||||
Some(e) => CmdResult::CommitAndExit(e),
|
||||
None => CmdResult::Cancel,
|
||||
@@ -173,13 +253,29 @@ impl CadCommand for SplineCommand {
|
||||
}
|
||||
|
||||
fn enter_accepts_default_start(&self) -> bool {
|
||||
self.pts.is_empty() && !self.choosing_method
|
||||
self.pts.is_empty() && !self.choosing_method && !self.choosing_objects
|
||||
&& !self.choosing_knots && !self.choosing_degree && !self.choosing_tangent
|
||||
}
|
||||
|
||||
fn is_selection_gathering(&self) -> bool { self.choosing_objects }
|
||||
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
|
||||
self.selected_objects = handles.into_iter().filter(|handle| self.convertible.contains_key(handle)).collect();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_undo_step(&mut self) -> Option<CmdResult> {
|
||||
if self.pts.is_empty() { return None; }
|
||||
self.pts.pop();
|
||||
self.end_tangent = Vector3::ZERO;
|
||||
self.choosing_tangent = false;
|
||||
if self.pts.is_empty() { self.begin_tangent = Vector3::ZERO; }
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -189,7 +285,39 @@ impl CadCommand for SplineCommand {
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if self.choosing_tangent { return None; }
|
||||
if self.choosing_degree {
|
||||
self.degree = text.trim().parse::<usize>().ok().filter(|d| (1..=10).contains(d))?;
|
||||
self.choosing_degree = false;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if self.choosing_knots {
|
||||
self.knot_parameterization = match text.trim().to_ascii_uppercase().as_str() {
|
||||
"CH" | "CHORD" => 0,
|
||||
"S" | "SQUARE" | "SQUAREROOT" => 1,
|
||||
"U" | "UNIFORM" => 2,
|
||||
_ => return None,
|
||||
};
|
||||
self.choosing_knots = false;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
match text.trim().to_uppercase().as_str() {
|
||||
"D" | "DEGREE" if self.pts.is_empty() && self.control_vertices => {
|
||||
self.choosing_degree = true;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"K" | "KNOTS" if self.pts.is_empty() && !self.control_vertices => {
|
||||
self.choosing_knots = true;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"T" | "TANGENCY" if !self.pts.is_empty() && !self.control_vertices => {
|
||||
self.choosing_tangent = true;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"O" | "OBJECT" if self.pts.is_empty() => {
|
||||
self.choosing_objects = true;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"M" | "METHOD" if self.pts.is_empty() => {
|
||||
self.choosing_method = true;
|
||||
Some(CmdResult::NeedPoint)
|
||||
@@ -204,28 +332,33 @@ impl CadCommand for SplineCommand {
|
||||
self.choosing_method = false;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"C" | "CLOSE" => match self.build(true) {
|
||||
"C" | "CLOSE" if self.pts.len() >= 3 => match self.build(true) {
|
||||
Some(e) => Some(CmdResult::CommitAndExit(e)),
|
||||
None => Some(CmdResult::NeedPoint),
|
||||
},
|
||||
"U" | "UNDO" => {
|
||||
self.pts.pop();
|
||||
Some(CmdResult::NeedPoint)
|
||||
self.on_undo_step().or(Some(CmdResult::NeedPoint))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
|
||||
if self.pts.is_empty() {
|
||||
if self.pts.is_empty() || self.choosing_objects || self.choosing_method
|
||||
|| self.choosing_knots || self.choosing_degree || self.choosing_tangent {
|
||||
return None;
|
||||
}
|
||||
// Preview the committed construction method.
|
||||
let mut ctrl = self.pts.clone();
|
||||
ctrl.push(pt);
|
||||
self.pts.push(pt);
|
||||
let entity = self.build(false);
|
||||
self.pts.pop();
|
||||
let Some(EntityType::Spline(spline)) = entity else { return None; };
|
||||
let points = crate::entities::curve::spline_curve(&spline)
|
||||
.map(|curve| crate::entities::curve::curve_points(&curve))
|
||||
.unwrap_or_else(|| crate::entities::spline::measurement_polyline(&spline));
|
||||
Some(WireModel::solid(
|
||||
"rubber_band".into(),
|
||||
sample_curve(&ctrl, false, self.control_vertices),
|
||||
points.into_iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(),
|
||||
WireModel::CYAN,
|
||||
false,
|
||||
))
|
||||
@@ -244,16 +377,126 @@ mod tests {
|
||||
DVec3::new(2.0, 2.0, 0.0),
|
||||
DVec3::new(3.0, 0.0, 0.0),
|
||||
];
|
||||
let spline = make_spline(&points, false, true);
|
||||
let controls: Vec<_> = points.iter().map(|point| point.to_array()).collect();
|
||||
let spline = control_spline(&controls, 3, false).expect("valid control polygon");
|
||||
|
||||
assert_eq!(spline.degree, 3);
|
||||
assert_eq!(spline.control_points.len(), 4);
|
||||
assert_eq!(spline.knots, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]);
|
||||
assert_eq!(spline.weights, vec![1.0; 4]);
|
||||
assert!(sample_curve(&points, false, true)
|
||||
.iter()
|
||||
.flatten()
|
||||
.all(|value| value.is_finite()));
|
||||
assert!(crate::entities::curve::spline_curve(&spline).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_options_persist_parameterization_and_endpoint_tangents() {
|
||||
let mut command = SplineCommand::new();
|
||||
assert!(matches!(command.on_text_input("K"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(command.on_text_input("S"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.on_text_input("T"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(command.on_point(DVec3::X), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.on_point(DVec3::new(1.0, 1.0, 0.0)), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.on_point(DVec3::new(2.0, 0.0, 0.0)), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.on_text_input("T"), Some(CmdResult::NeedPoint)));
|
||||
|
||||
let CmdResult::CommitAndExit(EntityType::Spline(spline)) =
|
||||
command.on_point(DVec3::new(2.0, 1.0, 0.0))
|
||||
else {
|
||||
panic!("end tangency must complete the fit spline");
|
||||
};
|
||||
assert_eq!(spline.knot_parameterization, 1);
|
||||
assert_eq!(spline.begin_tangent, Vector3::new(1.0, 0.0, 0.0));
|
||||
assert_eq!(spline.end_tangent, Vector3::new(0.0, 1.0, 0.0));
|
||||
assert!(crate::entities::curve::spline_curve(&spline).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_degree_is_clamped_to_the_available_points() {
|
||||
let mut command = SplineCommand::control_vertices();
|
||||
assert!(matches!(command.on_text_input("D"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(command.on_text_input("10"), Some(CmdResult::NeedPoint)));
|
||||
for point in [DVec3::ZERO, DVec3::X, DVec3::Y] {
|
||||
assert!(matches!(command.on_point(point), CmdResult::NeedPoint));
|
||||
}
|
||||
let CmdResult::CommitAndExit(EntityType::Spline(spline)) = command.on_enter() else {
|
||||
panic!("valid control polygon must commit");
|
||||
};
|
||||
assert_eq!(spline.degree, 2);
|
||||
assert!(!spline.flags.closed);
|
||||
assert!(!spline.flags.periodic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_conversion_waits_for_enter_and_preserves_the_replacement() {
|
||||
let handle = Handle::new(17);
|
||||
let converted = control_spline(
|
||||
&[[0.0, 0.0, 0.0], [1.0, 2.0, 0.0], [3.0, 0.0, 0.0]],
|
||||
2,
|
||||
false,
|
||||
)
|
||||
.expect("valid converted spline");
|
||||
let mut command = SplineCommand::new();
|
||||
command.convertible.insert(handle, converted.clone());
|
||||
assert!(matches!(command.on_text_input("O"), Some(CmdResult::NeedPoint)));
|
||||
assert!(command.is_selection_gathering());
|
||||
assert!(matches!(
|
||||
command.on_selection_complete(vec![handle]),
|
||||
CmdResult::NeedPoint
|
||||
));
|
||||
assert!(matches!(
|
||||
command.on_enter(),
|
||||
CmdResult::ReplaceMany(replacements, erased)
|
||||
if erased.is_empty()
|
||||
&& replacements == vec![(handle, vec![EntityType::Spline(converted)])]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_fitted_polyline_conversion_preserves_a_periodic_spline() {
|
||||
use acadrust::entities::{
|
||||
Polyline2D, PolylineFlags, SmoothSurfaceType, Vertex2D, VertexFlags,
|
||||
};
|
||||
|
||||
let mut polyline = Polyline2D::new();
|
||||
polyline.flags = PolylineFlags::SPLINE_FIT | PolylineFlags::CLOSED;
|
||||
polyline.smooth_surface = SmoothSurfaceType::CubicBSpline;
|
||||
polyline.common.layer = "DETAIL".into();
|
||||
polyline.vertices = [
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(2.0, 0.0, 0.0),
|
||||
Vector3::new(2.0, 2.0, 0.0),
|
||||
Vector3::new(0.0, 2.0, 0.0),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|point| {
|
||||
let mut vertex = Vertex2D::new(point);
|
||||
vertex.flags = VertexFlags::SPLINE_CONTROL;
|
||||
vertex
|
||||
})
|
||||
.collect();
|
||||
|
||||
let spline = spline_from_polyline(&EntityType::Polyline2D(polyline))
|
||||
.expect("fitted polyline must convert");
|
||||
assert_eq!(spline.degree, 3);
|
||||
assert!(spline.flags.closed);
|
||||
assert!(spline.flags.periodic);
|
||||
assert!(spline.flags.planar);
|
||||
assert_eq!(spline.common.layer, "DETAIL");
|
||||
assert!(crate::entities::curve::spline_curve(&spline).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_points_and_point_undo_keep_tangent_state_consistent() {
|
||||
let mut command = SplineCommand::new();
|
||||
assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint));
|
||||
assert_eq!(command.pts.len(), 1);
|
||||
assert!(matches!(command.on_text_input("T"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(command.on_point(DVec3::X), CmdResult::NeedPoint));
|
||||
assert_ne!(command.begin_tangent, Vector3::ZERO);
|
||||
assert!(command.on_undo_step().is_some());
|
||||
assert!(command.pts.is_empty());
|
||||
assert_eq!(command.begin_tangent, Vector3::ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ pub struct WipeoutCommand {
|
||||
plane: WorkingPlane,
|
||||
selected_polyline: Option<Handle>,
|
||||
frame_mode: i16,
|
||||
picked_entity: Option<EntityType>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
@@ -50,6 +51,7 @@ impl WipeoutCommand {
|
||||
plane: WorkingPlane::default(),
|
||||
selected_polyline: None,
|
||||
frame_mode: frame_mode.clamp(0, 2),
|
||||
picked_entity: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +63,7 @@ impl WipeoutCommand {
|
||||
plane: WorkingPlane::default(),
|
||||
selected_polyline: None,
|
||||
frame_mode: 1,
|
||||
picked_entity: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +76,7 @@ impl WipeoutCommand {
|
||||
plane: WorkingPlane::default(),
|
||||
selected_polyline: None,
|
||||
frame_mode: 1,
|
||||
picked_entity: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,13 +120,14 @@ impl CadCommand for WipeoutCommand {
|
||||
WipeoutMode::Draw if self.points.is_empty() => {
|
||||
t!("WIPEOUT Specify first point or [Frames/Polyline] <Polyline>:").into_owned()
|
||||
}
|
||||
WipeoutMode::Draw if self.points.len() == 1 => {
|
||||
t!("WIPEOUT Specify next point:").into_owned()
|
||||
}
|
||||
WipeoutMode::Draw if self.points.len() == 2 => {
|
||||
t!("WIPEOUT Specify next point or [Undo]:").into_owned()
|
||||
}
|
||||
WipeoutMode::Draw => {
|
||||
let n = self.points.len();
|
||||
t!(
|
||||
"WIPEOUT Specify next point or [Undo/Close] (%{n} points):",
|
||||
n = n
|
||||
)
|
||||
.into_owned()
|
||||
t!("WIPEOUT Specify next point or [Close/Undo]:").into_owned()
|
||||
}
|
||||
WipeoutMode::Polyline => {
|
||||
t!("WIPEOUT Polyline Select a closed planar polyline:").into_owned()
|
||||
@@ -152,9 +157,13 @@ impl CadCommand for WipeoutCommand {
|
||||
CmdOption::new(t!("Polyline").as_ref(), "P"),
|
||||
]
|
||||
}
|
||||
WipeoutMode::Draw => vec![
|
||||
WipeoutMode::Draw if self.points.len() == 1 => Vec::new(),
|
||||
WipeoutMode::Draw if self.points.len() == 2 => vec![
|
||||
CmdOption::new(t!("Undo").as_ref(), "U"),
|
||||
],
|
||||
WipeoutMode::Draw => vec![
|
||||
CmdOption::new(t!("Close").as_ref(), "C"),
|
||||
CmdOption::new(t!("Undo").as_ref(), "U"),
|
||||
],
|
||||
WipeoutMode::Frames => vec![
|
||||
CmdOption::new(t!("Off").as_ref(), "OFF"),
|
||||
@@ -215,6 +224,7 @@ impl CadCommand for WipeoutCommand {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
WipeoutMode::Draw if self.points.len() >= 3 => self.finish_draw(),
|
||||
WipeoutMode::Draw => CmdResult::NeedPoint,
|
||||
WipeoutMode::Frames => {
|
||||
CmdResult::Dispatch(format!("WIPEOUTFRAME {}", self.frame_mode))
|
||||
}
|
||||
@@ -232,6 +242,15 @@ impl CadCommand for WipeoutCommand {
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, _point: DVec3) -> CmdResult {
|
||||
let Some(entity)=self.picked_entity.take().filter(|e|e.common().handle==handle) else {
|
||||
return CmdResult::NeedPoint;
|
||||
};
|
||||
if !matches!(entity,EntityType::LwPolyline(_)|EntityType::Polyline2D(_)) {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
if wipeout_from_polyline(&entity).is_none() {
|
||||
return CmdResult::Measurement("WIPEOUT: select a closed planar polyline made of zero-width straight segments.".into());
|
||||
}
|
||||
if handle.is_null() {
|
||||
CmdResult::NeedPoint
|
||||
} else {
|
||||
@@ -241,6 +260,9 @@ impl CadCommand for WipeoutCommand {
|
||||
}
|
||||
}
|
||||
|
||||
fn inject_before_entity_pick(&self)->bool {self.mode==WipeoutMode::Polyline}
|
||||
fn inject_picked_entity(&mut self,entity:EntityType){self.picked_entity=Some(entity);}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(
|
||||
self.mode,
|
||||
@@ -267,7 +289,7 @@ impl CadCommand for WipeoutCommand {
|
||||
self.mode = WipeoutMode::Polyline;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"U" | "UNDO" if !self.points.is_empty() => Some(self.undo_point()),
|
||||
"U" | "UNDO" if self.points.len() >= 2 => Some(self.undo_point()),
|
||||
"C" | "CLOSE" if self.points.len() >= 3 => Some(self.finish_draw()),
|
||||
_ => None,
|
||||
},
|
||||
@@ -289,7 +311,7 @@ impl CadCommand for WipeoutCommand {
|
||||
}
|
||||
|
||||
fn on_undo_step(&mut self) -> Option<CmdResult> {
|
||||
if self.mode == WipeoutMode::Draw && !self.points.is_empty() {
|
||||
if self.mode == WipeoutMode::Draw && self.points.len() >= 2 {
|
||||
Some(self.undo_point())
|
||||
} else {
|
||||
None
|
||||
@@ -424,7 +446,9 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option<EntityType> {
|
||||
|
||||
match entity {
|
||||
EntityType::LwPolyline(polyline) => {
|
||||
if polyline.vertices.iter().any(|vertex| vertex.bulge != 0.0) {
|
||||
if polyline.constant_width != 0.0 || polyline.vertices.iter().any(|vertex| {
|
||||
vertex.bulge != 0.0 || vertex.start_width != 0.0 || vertex.end_width != 0.0
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let raw: Vec<[f64; 2]> = polyline
|
||||
@@ -438,7 +462,10 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option<EntityType> {
|
||||
from_ocs(&raw, polyline.normal, polyline.elevation)
|
||||
}
|
||||
EntityType::Polyline2D(polyline) => {
|
||||
if polyline.vertices.iter().any(|vertex| vertex.bulge != 0.0) {
|
||||
if polyline.start_width != 0.0 || polyline.end_width != 0.0
|
||||
|| polyline.vertices.iter().any(|vertex| {
|
||||
vertex.bulge != 0.0 || vertex.start_width != 0.0 || vertex.end_width != 0.0
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
let raw: Vec<[f64; 2]> = polyline
|
||||
@@ -457,3 +484,32 @@ pub(crate) fn wipeout_from_polyline(entity: &EntityType) -> Option<EntityType> {
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["WIPEOUT"] });
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::LwPolyline;
|
||||
|
||||
#[test]
|
||||
fn command_undo_never_removes_the_first_point() {
|
||||
let mut command = WipeoutCommand::new_polygonal(1);
|
||||
assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint));
|
||||
assert!(command.on_undo_step().is_none());
|
||||
|
||||
assert!(matches!(command.on_point(DVec3::X), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.on_undo_step(), Some(CmdResult::NeedPoint)));
|
||||
assert_eq!(command.points, vec![DVec3::ZERO]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_polyline_is_not_accepted_as_a_wipeout_boundary() {
|
||||
let mut polyline = LwPolyline::from_points(vec![
|
||||
Vector2::new(0.0, 0.0),
|
||||
Vector2::new(2.0, 0.0),
|
||||
Vector2::new(0.0, 2.0),
|
||||
]);
|
||||
polyline.is_closed = true;
|
||||
polyline.constant_width = 1.0;
|
||||
assert!(wipeout_from_polyline(&EntityType::LwPolyline(polyline)).is_none());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod changelog;
|
||||
pub mod clipboard;
|
||||
pub mod constrain;
|
||||
pub mod defaults;
|
||||
mod donate;
|
||||
pub mod draw;
|
||||
@@ -33,6 +34,11 @@ impl CadModule for DrawModule {
|
||||
};
|
||||
use crate::modules::insert::{create_block, insert_block};
|
||||
use clipboard::{copy_clip, cut, paste};
|
||||
use constrain::{
|
||||
angle_tool, center_point_tool, coincident_tool, colinear, concentric, distance_tool, equal,
|
||||
equal_distance_tool, fixed, horizontal, midpoint_tool, normal, parallel, perpendicular, point_on_curve_tool,
|
||||
symmetric, tangent, vertical,
|
||||
};
|
||||
use draw::{arc, circle, ellipse, hatch, line, polyline, shapes};
|
||||
use groups::{group, ungroup};
|
||||
use layers::{
|
||||
@@ -120,6 +126,29 @@ impl CadModule for DrawModule {
|
||||
offset::tool().into(),
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Constraints",
|
||||
tools: vec![
|
||||
horizontal::tool().into(),
|
||||
vertical::tool().into(),
|
||||
parallel::tool().into(),
|
||||
perpendicular::tool().into(),
|
||||
equal::tool().into(),
|
||||
tangent::tool().into(),
|
||||
normal::tool().into(),
|
||||
coincident_tool::tool().into(),
|
||||
distance_tool::tool().into(),
|
||||
angle_tool::tool().into(),
|
||||
concentric::tool().into(),
|
||||
colinear::tool().into(),
|
||||
fixed::tool().into(),
|
||||
symmetric::tool().into(),
|
||||
center_point_tool::tool().into(),
|
||||
midpoint_tool::tool().into(),
|
||||
point_on_curve_tool::tool().into(),
|
||||
equal_distance_tool::tool().into(),
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Annotation",
|
||||
tools: vec![
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// ALIGN command — align selected objects using 1 or 2 point pairs.
|
||||
// ALIGN command — rigid 3D placement using one, two, or three point pairs.
|
||||
//
|
||||
// Workflow:
|
||||
// 1. Select objects (Enter to finish selection)
|
||||
// 2. First source point → first destination point
|
||||
// 3. Second source point → second destination point (Enter to skip = translate only)
|
||||
// 4. Enter = apply (scale = optional: Y/N prompt after 2nd pair)
|
||||
// 4. Continue for a third pair, or press Enter to choose two-pair scaling
|
||||
//
|
||||
// With 1 pair: pure translation (src1 → dst1)
|
||||
// With 2 pairs: translate + rotate (+ optional uniform scale to fit)
|
||||
// With 3 pairs: rigid 3D placement without scaling
|
||||
|
||||
use acadrust::Handle;
|
||||
use glam::DVec3;
|
||||
@@ -23,6 +24,8 @@ pub struct AlignCommand {
|
||||
dst1: Option<DVec3>,
|
||||
src2: Option<DVec3>,
|
||||
dst2: Option<DVec3>,
|
||||
src3: Option<DVec3>,
|
||||
dst3: Option<DVec3>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
@@ -32,6 +35,8 @@ enum AlignState {
|
||||
Dst1,
|
||||
Src2,
|
||||
Dst2,
|
||||
Src3,
|
||||
Dst3,
|
||||
AskScale,
|
||||
}
|
||||
|
||||
@@ -50,6 +55,8 @@ impl AlignCommand {
|
||||
dst1: None,
|
||||
src2: None,
|
||||
dst2: None,
|
||||
src3: None,
|
||||
dst3: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,9 +79,11 @@ impl CadCommand for AlignCommand {
|
||||
t!("ALIGN Specify 2nd source point (Enter = translate only):").into_owned()
|
||||
}
|
||||
AlignState::Dst2 => t!("ALIGN Specify 2nd destination point:").into_owned(),
|
||||
AlignState::Src3 => "ALIGN Specify 3rd source point or <continue>:".into(),
|
||||
AlignState::Dst3 => "ALIGN Specify 3rd destination point:".into(),
|
||||
AlignState::AskScale => {
|
||||
t!(
|
||||
"ALIGN Scale objects based on alignment points? [Yes / No]:"
|
||||
"ALIGN Scale objects based on alignment points? [Yes / No] <No>:"
|
||||
)
|
||||
.into_owned()
|
||||
}
|
||||
@@ -103,6 +112,7 @@ impl CadCommand for AlignCommand {
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
if !pt.is_finite() { return CmdResult::NeedPoint; }
|
||||
match self.state {
|
||||
AlignState::Gathering => CmdResult::NeedPoint,
|
||||
AlignState::Src1 => {
|
||||
@@ -116,15 +126,36 @@ impl CadCommand for AlignCommand {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
AlignState::Src2 => {
|
||||
let pair = [self.src1.unwrap().to_array(), pt.to_array()];
|
||||
if cadkernel::space::align_point_pairs(&pair, &pair, false).is_none() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.src2 = Some(pt);
|
||||
self.state = AlignState::Dst2;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
AlignState::Dst2 => {
|
||||
let pair = [self.dst1.unwrap().to_array(), pt.to_array()];
|
||||
if cadkernel::space::align_point_pairs(&pair, &pair, false).is_none() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.dst2 = Some(pt);
|
||||
self.state = AlignState::AskScale;
|
||||
self.state = AlignState::Src3;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
AlignState::Src3 => {
|
||||
let frame = [self.src1.unwrap().to_array(), self.src2.unwrap().to_array(), pt.to_array()];
|
||||
if cadkernel::space::align_point_pairs(&frame, &frame, false).is_none() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.src3 = Some(pt);
|
||||
self.state = AlignState::Dst3;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
AlignState::Dst3 => {
|
||||
self.dst3 = Some(pt);
|
||||
self.compute_align(false)
|
||||
}
|
||||
AlignState::AskScale => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
@@ -156,6 +187,10 @@ impl CadCommand for AlignCommand {
|
||||
}
|
||||
|
||||
// Default option shown as <No>.
|
||||
AlignState::Src3 => {
|
||||
self.state = AlignState::AskScale;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
AlignState::AskScale => self.compute_align(false),
|
||||
|
||||
_ => CmdResult::Cancel,
|
||||
@@ -204,6 +239,8 @@ impl CadCommand for AlignCommand {
|
||||
match self.state {
|
||||
AlignState::Src2
|
||||
| AlignState::Dst2
|
||||
| AlignState::Src3
|
||||
| AlignState::Dst3
|
||||
| AlignState::AskScale => {
|
||||
out.push(line(src1, dst1, "align_pair_1"));
|
||||
}
|
||||
@@ -230,10 +267,15 @@ impl CadCommand for AlignCommand {
|
||||
|
||||
// Once both pairs are complete, keep both visible while
|
||||
// waiting for the Scale / No Scale decision.
|
||||
AlignState::AskScale => {
|
||||
AlignState::Src3 | AlignState::Dst3 | AlignState::AskScale => {
|
||||
if let (Some(src2), Some(dst2)) = (self.src2, self.dst2) {
|
||||
out.push(line(src2, dst2, "align_pair_2"));
|
||||
}
|
||||
if self.state == AlignState::Dst3 {
|
||||
if let Some(src3) = self.src3 {
|
||||
out.push(line(src3, pt, "align_pair_3_preview"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
@@ -245,50 +287,67 @@ impl CadCommand for AlignCommand {
|
||||
|
||||
impl AlignCommand {
|
||||
fn compute_align(&self, with_scale: bool) -> CmdResult {
|
||||
let (s1, d1, s2, d2) = match (self.src1, self.dst1, self.src2, self.dst2) {
|
||||
(Some(a), Some(b), Some(c), Some(d)) => (a, b, c, d),
|
||||
_ => return CmdResult::Cancel,
|
||||
};
|
||||
|
||||
// Build transform: move s1→d1, rotate so s2-s1 aligns with d2-d1 (in the world XY plane)
|
||||
let src_vec = s2 - s1;
|
||||
let dst_vec = d2 - d1;
|
||||
|
||||
let src_len = src_vec.length();
|
||||
let dst_len = dst_vec.length();
|
||||
|
||||
if src_len < 1e-6 || dst_len < 1e-6 {
|
||||
// Degenerate: just translate
|
||||
let delta = d1 - s1;
|
||||
return CmdResult::TransformSelected(
|
||||
self.handles.clone(),
|
||||
EntityTransform::Translate(delta),
|
||||
);
|
||||
}
|
||||
|
||||
// Angle from src_vec to dst_vec in the world XY plane
|
||||
let src_angle = src_vec.y.atan2(src_vec.x);
|
||||
let dst_angle = dst_vec.y.atan2(dst_vec.x);
|
||||
let angle = dst_angle - src_angle;
|
||||
|
||||
let scale_factor = if with_scale { dst_len / src_len } else { 1.0 };
|
||||
|
||||
// Apply: translate to origin (s1), scale, rotate, translate to d1
|
||||
// We use the EntityTransform enum — it doesn't support composed transforms directly.
|
||||
// Return a special align result that carries the full matrix.
|
||||
let _ = (angle, scale_factor, with_scale);
|
||||
|
||||
// Compose via AlignTransform CmdResult
|
||||
CmdResult::AlignSelected {
|
||||
handles: self.handles.clone(),
|
||||
src1: s1,
|
||||
dst1: d1,
|
||||
angle_rad: angle,
|
||||
scale: scale_factor,
|
||||
let (Some(s1), Some(d1), Some(s2), Some(d2)) = (self.src1, self.dst1, self.src2, self.dst2)
|
||||
else { return CmdResult::NeedPoint; };
|
||||
let mut source = vec![s1.to_array(), s2.to_array()];
|
||||
let mut target = vec![d1.to_array(), d2.to_array()];
|
||||
if let (Some(s3), Some(d3)) = (self.src3, self.dst3) {
|
||||
source.push(s3.to_array());
|
||||
target.push(d3.to_array());
|
||||
}
|
||||
let Some(matrix) = cadkernel::space::align_point_pairs(&source, &target, with_scale)
|
||||
else { return CmdResult::NeedPoint; };
|
||||
CmdResult::TransformSelected(self.handles.clone(), EntityTransform::Affine(
|
||||
acadrust::types::Transform::from_matrix(acadrust::types::Matrix4 { m: matrix }),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["ALIGN"] }); // AlignCommand
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn three_pairs_apply_a_rigid_spatial_frame() {
|
||||
let handle = Handle::new(9);
|
||||
let mut command = AlignCommand::with_selection(vec![handle]);
|
||||
let source = [DVec3::ZERO, DVec3::X, DVec3::Y];
|
||||
let origin = DVec3::new(5.0, 6.0, 7.0);
|
||||
let target = [origin, origin + DVec3::Y, origin + DVec3::Z];
|
||||
|
||||
assert!(matches!(command.on_point(DVec3::splat(f64::NAN)), CmdResult::NeedPoint));
|
||||
for point in [source[0], target[0], source[1], target[1]] {
|
||||
assert!(matches!(command.on_point(point), CmdResult::NeedPoint));
|
||||
}
|
||||
assert!(matches!(command.on_point(DVec3::X * 2.0), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.state, AlignState::Src3));
|
||||
assert!(matches!(command.on_point(source[2]), CmdResult::NeedPoint));
|
||||
|
||||
let CmdResult::TransformSelected(handles, EntityTransform::Affine(transform)) =
|
||||
command.on_point(target[2])
|
||||
else {
|
||||
panic!("third destination must complete alignment");
|
||||
};
|
||||
assert_eq!(handles, vec![handle]);
|
||||
for (from, expected) in source.into_iter().zip(target) {
|
||||
let actual = transform.apply(acadrust::types::Vector3::new(from.x, from.y, from.z));
|
||||
let actual = DVec3::new(actual.x, actual.y, actual.z);
|
||||
assert!(actual.abs_diff_eq(expected, 1.0e-12));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_pair_still_translates_without_requesting_a_frame() {
|
||||
let handle = Handle::new(3);
|
||||
let mut command = AlignCommand::with_selection(vec![handle]);
|
||||
assert!(matches!(command.on_point(DVec3::new(1.0, 2.0, 3.0)), CmdResult::NeedPoint));
|
||||
assert!(matches!(command.on_point(DVec3::new(4.0, 6.0, 8.0)), CmdResult::NeedPoint));
|
||||
assert!(matches!(
|
||||
command.on_enter(),
|
||||
CmdResult::TransformSelected(handles, EntityTransform::Translate(delta))
|
||||
if handles == vec![handle] && delta == DVec3::new(3.0, 4.0, 5.0)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ impl CadCommand for BreakInteractiveCommand {
|
||||
} else if self.p1.is_none() {
|
||||
crate::t!("BREAK Specify first break point:").into_owned()
|
||||
} else {
|
||||
crate::t!("BREAK Specify second break point:").into_owned()
|
||||
crate::t!("BREAK Specify second break point or [First point]:").into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +390,28 @@ impl CadCommand for BreakInteractiveCommand {
|
||||
self.target.is_none()
|
||||
}
|
||||
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
if self.target.is_some() && self.p1.is_some() {
|
||||
vec![crate::command::CmdOption::new("First point", "F")]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let handle = self.target?;
|
||||
match text.trim().to_ascii_uppercase().as_str() {
|
||||
"F" | "FIRST" => {
|
||||
self.p1 = None;
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"@" => self.p1.map(|point| CmdResult::BreakEntity {
|
||||
handle, p1: point, p2: point,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult {
|
||||
if handle.is_null() {
|
||||
return CmdResult::NeedPoint;
|
||||
@@ -480,3 +502,28 @@ impl CadCommand for BreakAtPointCommand {
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["BREAKATPOINT"] }); // BreakAtPointCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["BREAK"] }); // BreakInteractiveCommand
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_option_replaces_the_selection_point_and_at_reuses_it() {
|
||||
let handle = Handle::new(7);
|
||||
let mut command = BreakInteractiveCommand::new();
|
||||
assert!(matches!(
|
||||
command.on_entity_pick(handle, DVec3::new(1.0, 2.0, 0.0)),
|
||||
CmdResult::NeedPoint
|
||||
));
|
||||
assert_eq!(command.options().len(), 1);
|
||||
assert!(matches!(command.on_text_input("F"), Some(CmdResult::NeedPoint)));
|
||||
|
||||
let replacement = DVec3::new(3.0, 4.0, 0.0);
|
||||
assert!(matches!(command.on_point(replacement), CmdResult::NeedPoint));
|
||||
assert!(matches!(
|
||||
command.on_text_input("@"),
|
||||
Some(CmdResult::BreakEntity { handle: result, p1, p2 })
|
||||
if result == handle && p1 == replacement && p2 == replacement
|
||||
));
|
||||
}
|
||||
}
|
||||
+258
-44
@@ -12,62 +12,152 @@
|
||||
use acadrust::types::{Vector2, Vector3};
|
||||
use acadrust::{EntityType, Handle};
|
||||
use glam::DVec3;
|
||||
use crate::t;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
|
||||
// ── Command ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct JoinCommand {
|
||||
source: Option<(Handle, EntityType)>,
|
||||
picked: Option<EntityType>,
|
||||
handles: Vec<Handle>,
|
||||
gathering: bool,
|
||||
}
|
||||
|
||||
impl JoinCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
handles: vec![],
|
||||
gathering: true,
|
||||
}
|
||||
pub fn new() -> Self { Self { source: None, picked: None, handles: Vec::new() } }
|
||||
pub fn with_source(mut self, handle: Handle, entity: EntityType) -> Self {
|
||||
self.picked = Some(entity); self.on_entity_pick(handle, DVec3::ZERO); self
|
||||
}
|
||||
fn has_arc_source(&self) -> bool { matches!(&self.source, Some((_, EntityType::Arc(_)))) }
|
||||
}
|
||||
|
||||
impl CadCommand for JoinCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"JOIN"
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str { "JOIN" }
|
||||
fn prompt(&self) -> String {
|
||||
t!(
|
||||
"JOIN Select objects to join (%{count} selected, Enter to apply):",
|
||||
count = self.handles.len()
|
||||
)
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
fn is_selection_gathering(&self) -> bool {
|
||||
self.gathering
|
||||
}
|
||||
|
||||
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
|
||||
self.handles = handles;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.handles.len() < 2 {
|
||||
return CmdResult::Cancel;
|
||||
match self.source.as_ref().map(|(_, entity)| entity) {
|
||||
None => "JOIN Select source object:".into(),
|
||||
Some(EntityType::Line(_)) => "JOIN Select lines to join to source (Enter to apply):".into(),
|
||||
Some(EntityType::Arc(_)) => "JOIN Select arcs to join to source or [cLose] (Enter to apply):".into(),
|
||||
Some(EntityType::Spline(_)) => "JOIN Select open curves to join to source (Enter to apply):".into(),
|
||||
_ => "JOIN Select objects to join to source (Enter to apply):".into(),
|
||||
}
|
||||
self.gathering = false;
|
||||
CmdResult::JoinEntities(self.handles.clone())
|
||||
}
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
if matches!(&self.source,Some((_,EntityType::Arc(_)))) { vec![crate::command::CmdOption::new("Close","L")] } else {Vec::new()}
|
||||
}
|
||||
fn wants_text_input(&self) -> bool { self.has_arc_source() }
|
||||
fn point_step_accepts_keywords(&self) -> bool { self.has_arc_source() }
|
||||
fn on_text_input(&mut self,text:&str) -> Option<CmdResult> {
|
||||
if matches!(text.trim().to_ascii_uppercase().as_str(),"L"|"CLOSE") {
|
||||
if let Some((handle,EntityType::Arc(arc)))=&self.source {
|
||||
let mut circle=acadrust::entities::Circle::new();
|
||||
circle.common=arc.common.clone();circle.common.handle=Handle::NULL;
|
||||
circle.center=arc.center.clone();circle.normal=arc.normal.clone();circle.radius=arc.radius;circle.thickness=arc.thickness;
|
||||
return Some(CmdResult::ReplaceMany(vec![(*handle,vec![EntityType::Circle(circle)])],Vec::new()));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn needs_entity_pick(&self) -> bool { self.source.is_none() }
|
||||
fn inject_before_entity_pick(&self) -> bool { true }
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); }
|
||||
fn on_entity_pick(&mut self, handle: Handle, _: DVec3) -> CmdResult {
|
||||
if handle.is_null() { return CmdResult::NeedPoint; }
|
||||
if let Some(entity) = self.picked.take() {
|
||||
let supported = match &entity {
|
||||
EntityType::Line(_) | EntityType::Arc(_) => true,
|
||||
EntityType::LwPolyline(p) => !p.is_closed,
|
||||
EntityType::Polyline2D(p) => !p.is_closed(),
|
||||
EntityType::Spline(p) => !p.flags.closed && !p.flags.periodic,
|
||||
_ => false,
|
||||
};
|
||||
if supported { self.source = Some((handle, entity)); }
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn is_selection_gathering(&self) -> bool { self.source.is_some() }
|
||||
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
|
||||
self.handles = handles.into_iter().filter(|handle| self.source.as_ref().is_none_or(|(source,_)| handle != source)).collect(); CmdResult::NeedPoint
|
||||
}
|
||||
fn on_point(&mut self, _: DVec3) -> CmdResult { CmdResult::NeedPoint }
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
let Some((source, _)) = self.source.as_ref() else { return CmdResult::Cancel; };
|
||||
if self.handles.is_empty() { return CmdResult::Cancel; }
|
||||
CmdResult::JoinToSource { source: *source, handles: self.handles.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Geometry ───────────────────────────────────────────────────────────────
|
||||
/// Join compatible candidates while preserving the source identity and style.
|
||||
pub fn join_to_source(source: &EntityType, candidates: &[(Handle, &EntityType)]) -> Option<(EntityType, Vec<Handle>)> {
|
||||
let mut result = source.clone(); let mut consumed = Vec::new();
|
||||
loop {
|
||||
let mut progress = false;
|
||||
for (handle, candidate) in candidates {
|
||||
if consumed.contains(handle) { continue; }
|
||||
let next = match (&result, *candidate) {
|
||||
(EntityType::Line(a), EntityType::Line(b)) => {
|
||||
let point = |p: &Vector3| [p.x,p.y,p.z];
|
||||
cadkernel::space::source_join::join_collinear_lines([point(&a.start),point(&a.end)],[point(&b.start),point(&b.end)],JOIN_EPS).map(|span| {
|
||||
let mut line = a.clone(); line.start = Vector3::new(span[0][0],span[0][1],span[0][2]); line.end = Vector3::new(span[1][0],span[1][1],span[1][2]); EntityType::Line(line)
|
||||
})
|
||||
}
|
||||
(EntityType::Arc(a), EntityType::Arc(b)) => {
|
||||
let data = |a: &acadrust::entities::Arc| ([a.center.x,a.center.y,a.center.z],[a.normal.x,a.normal.y,a.normal.z],a.radius,[a.start_angle,a.end_angle]);
|
||||
cadkernel::space::source_join::join_cocircular_arcs(data(a),data(b),JOIN_EPS).map(|span| {
|
||||
if span[1]-span[0] >= std::f64::consts::TAU {
|
||||
let mut circle = acadrust::entities::Circle::new(); circle.common = a.common.clone(); circle.center = a.center.clone(); circle.normal = a.normal.clone(); circle.radius = a.radius; circle.thickness = a.thickness; EntityType::Circle(circle)
|
||||
} else { let mut arc = a.clone(); arc.start_angle = span[0]; arc.end_angle = span[1].rem_euclid(std::f64::consts::TAU); EntityType::Arc(arc) }
|
||||
})
|
||||
}
|
||||
(EntityType::Spline(source), candidate) => {
|
||||
let curve = |s:&acadrust::entities::Spline| {
|
||||
if s.flags.closed || s.flags.periodic {return None;}
|
||||
if s.control_points.is_empty() && s.fit_points.len() >= 2 {
|
||||
use cadkernel::space::{NurbsCurve3, Parameterization};
|
||||
let points: Vec<_> = s.fit_points.iter().map(|point| [point.x,point.y,point.z]).collect();
|
||||
let parameterization = match s.knot_parameterization {
|
||||
1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord,
|
||||
};
|
||||
let tangent = |point: &Vector3| (point.x != 0.0 || point.y != 0.0 || point.z != 0.0)
|
||||
.then_some([point.x,point.y,point.z]);
|
||||
return NurbsCurve3::interpolate_fit(&points,tangent(&s.begin_tangent),tangent(&s.end_tangent),parameterization)?
|
||||
.compact_knots(s.control_tolerance.max(1e-9));
|
||||
}
|
||||
let weights=if s.weights.is_empty(){vec![1.0;s.control_points.len()]}else{s.weights.clone()};
|
||||
cadkernel::space::NurbsCurve3::new_strict(s.degree as usize,s.control_points.iter().map(|p|[p.x,p.y,p.z]).collect(),s.knots.clone(),weights)
|
||||
};
|
||||
curve(source).and_then(|a| {
|
||||
let b=match candidate {
|
||||
EntityType::Spline(s)=>curve(s),
|
||||
EntityType::Line(line)=>cadkernel::space::source_join::line_as_nurbs([[line.start.x,line.start.y,line.start.z],[line.end.x,line.end.y,line.end.z]],a.degree()),
|
||||
_=>None,
|
||||
}?;
|
||||
let joined=cadkernel::space::source_join::join_nurbs_curves(&a,&b,JOIN_EPS)?;
|
||||
let mut spline=source.clone();
|
||||
spline.degree=joined.degree() as i32;
|
||||
spline.control_points=joined.control_points().iter().map(|p|Vector3::new(p[0],p[1],p[2])).collect();
|
||||
spline.knots=joined.knots().to_vec();spline.weights=joined.weights().to_vec();
|
||||
spline.fit_points.clear();spline.flags.rational=joined.is_rational();
|
||||
spline.dwg_flags1 &= !1;spline.dxf_flags &= !(32 | 1024);
|
||||
spline.flags.planar=cadkernel::space::are_coplanar(joined.control_points(),&[]);
|
||||
spline.begin_tangent=Vector3::new(0.0,0.0,0.0);spline.end_tangent=Vector3::new(0.0,0.0,0.0);
|
||||
Some(EntityType::Spline(spline))
|
||||
})
|
||||
}
|
||||
(EntityType::LwPolyline(_) | EntityType::Polyline2D(_), _) => {
|
||||
join_entities(&[(result.common().handle,&result),(*handle,*candidate)]).and_then(|(_,mut entities)| {
|
||||
let entity = entities.pop()?;
|
||||
if matches!(&entity,EntityType::Line(_)) { super::pedit::convert_to_polyline(&entity) } else { Some(entity) }
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(mut entity) = next {
|
||||
*entity.common_mut() = source.common().clone(); result = entity; consumed.push(*handle); progress = true;
|
||||
}
|
||||
}
|
||||
if !progress { break; }
|
||||
}
|
||||
(!consumed.is_empty()).then_some((result,consumed))
|
||||
}
|
||||
|
||||
/// Endpoint-match tolerance (model units). Segments split from a shared
|
||||
/// vertex meet exactly, so this only absorbs float noise.
|
||||
@@ -81,12 +171,14 @@ struct Seg {
|
||||
a: DVec3,
|
||||
b: DVec3,
|
||||
bulge: f64,
|
||||
widths: Option<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl Seg {
|
||||
fn flip(&mut self) {
|
||||
std::mem::swap(&mut self.a, &mut self.b);
|
||||
self.bulge = -self.bulge;
|
||||
self.widths = self.widths.map(|(start,end)| (end,start));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +216,7 @@ fn segs_of(e: &EntityType) -> Option<Vec<Seg>> {
|
||||
a: DVec3::new(l.start.x, l.start.y, l.start.z),
|
||||
b: DVec3::new(l.end.x, l.end.y, l.end.z),
|
||||
bulge: 0.0,
|
||||
widths: None,
|
||||
}]),
|
||||
EntityType::Arc(arc) => {
|
||||
// The bulge below assumes the arc lies in a +Z plane; a tilted
|
||||
@@ -139,6 +232,7 @@ fn segs_of(e: &EntityType) -> Option<Vec<Seg>> {
|
||||
a: DVec3::new(cx + r * sa.cos(), cy + r * sa.sin(), cz),
|
||||
b: DVec3::new(cx + r * ea.cos(), cy + r * ea.sin(), cz),
|
||||
bulge: (swept / 4.0).tan(),
|
||||
widths: None,
|
||||
}])
|
||||
}
|
||||
EntityType::LwPolyline(p) => {
|
||||
@@ -154,6 +248,7 @@ fn segs_of(e: &EntityType) -> Option<Vec<Seg>> {
|
||||
a: DVec3::new(w[0].location.x, w[0].location.y, z),
|
||||
b: DVec3::new(w[1].location.x, w[1].location.y, z),
|
||||
bulge: w[0].bulge,
|
||||
widths: Some(if p.constant_width != 0.0 { (p.constant_width,p.constant_width) } else { (w[0].start_width,w[0].end_width) }),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
@@ -176,10 +271,12 @@ fn segs_of(e: &EntityType) -> Option<Vec<Seg>> {
|
||||
curve
|
||||
.vertices
|
||||
.windows(2)
|
||||
.map(|w| Seg {
|
||||
.enumerate()
|
||||
.map(|(index,w)| Seg {
|
||||
a: DVec3::new(w[0].position[0], w[0].position[1], z),
|
||||
b: DVec3::new(w[1].position[0], w[1].position[1], z),
|
||||
bulge: w[0].bulge,
|
||||
widths: Some((if p.vertices[index].start_width == 0.0 {p.start_width} else {p.vertices[index].start_width}, if p.vertices[index].end_width == 0.0 {p.end_width} else {p.vertices[index].end_width})),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
@@ -208,7 +305,13 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec<Handle>,
|
||||
let common = entities[0].1.common().clone();
|
||||
let (thickness, normal) = extrusion(entities[0].1)?;
|
||||
|
||||
let (chain, closed) = stitch(segs)?;
|
||||
let (mut chain, closed) = stitch(segs)?;
|
||||
// Newly appended line/arc spans inherit the adjoining polyline width.
|
||||
let mut width = chain.iter().find_map(|segment| segment.widths.map(|pair| pair.0)).unwrap_or(0.0);
|
||||
for segment in &mut chain {
|
||||
if let Some((_,end)) = segment.widths { width = end; }
|
||||
else { segment.widths = Some((width,width)); }
|
||||
}
|
||||
|
||||
// Ordered vertices, each tagged with the bulge of the segment that
|
||||
// starts there. A closed chain reuses the first vertex as the wrap
|
||||
@@ -223,7 +326,7 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec<Handle>,
|
||||
let planar = verts.iter().all(|(p, _)| (p.z - z0).abs() <= JOIN_EPS);
|
||||
|
||||
// An open run of collinear straight segments collapses back to one Line.
|
||||
if !closed && !has_arc && is_collinear(&verts) {
|
||||
if !closed && !has_arc && chain.iter().all(|segment| segment.widths == Some((0.0,0.0))) && !matches!(entities[0].1, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)) && is_collinear(&verts) {
|
||||
let mut line = acadrust::entities::Line::new();
|
||||
line.common = common;
|
||||
line.common.handle = Handle::NULL;
|
||||
@@ -240,13 +343,16 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec<Handle>,
|
||||
}
|
||||
let flipped = thickness != 0.0 && normal.z < 0.0;
|
||||
let lw_verts: Vec<acadrust::entities::LwVertex> = verts
|
||||
.iter()
|
||||
.map(|(p, bulge)| {
|
||||
.iter().enumerate()
|
||||
.map(|(index,(p, bulge))| {
|
||||
let mut v = acadrust::entities::LwVertex::new(Vector2::new(
|
||||
if flipped { -p.x } else { p.x },
|
||||
p.y,
|
||||
));
|
||||
v.bulge = if flipped { -*bulge } else { *bulge };
|
||||
let (start,end) = chain.get(index).and_then(|segment| segment.widths)
|
||||
.unwrap_or_else(|| {let width=chain.last().and_then(|segment| segment.widths).map_or(0.0,|pair|pair.1);(width,width)});
|
||||
v.start_width = start; v.end_width = end;
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
@@ -260,6 +366,26 @@ pub fn join_entities(entities: &[(Handle, &EntityType)]) -> Option<(Vec<Handle>,
|
||||
if flipped {
|
||||
pl.normal = Vector3::new(0.0, 0.0, -1.0);
|
||||
}
|
||||
if let EntityType::Polyline2D(source) = entities[0].1 {
|
||||
let mut polyline = source.clone();
|
||||
let input = crate::entities::curve::ocs_plane(pl.normal.clone(),pl.elevation);
|
||||
let output = crate::entities::curve::ocs_plane(source.normal.clone(),source.elevation);
|
||||
polyline.vertices = pl.vertices.iter().map(|vertex| {
|
||||
let point = output.project(input.point_at([vertex.location.x,vertex.location.y]))?;
|
||||
let mut result = acadrust::entities::polyline::Vertex2D::new(Vector3::new(point[0],point[1],source.elevation));
|
||||
result.bulge = if cadkernel::space::Vec3::from(input.normal()?).dot(cadkernel::space::Vec3::from(output.normal()?)) < 0.0 {-vertex.bulge} else {vertex.bulge}; result.start_width = vertex.start_width; result.end_width = vertex.end_width;
|
||||
Some(result)
|
||||
}).collect::<Option<Vec<_>>>()?;
|
||||
polyline.start_width = 0.0; polyline.end_width = 0.0;
|
||||
polyline.flags.set_closed(pl.is_closed);
|
||||
return Some((handles,vec![EntityType::Polyline2D(polyline)]));
|
||||
}
|
||||
if let EntityType::LwPolyline(source) = entities[0].1 {
|
||||
pl.plinegen = source.plinegen;
|
||||
if source.constant_width != 0.0 && pl.vertices.iter().all(|vertex| vertex.start_width == source.constant_width && vertex.end_width == source.constant_width) {
|
||||
pl.constant_width = source.constant_width;
|
||||
}
|
||||
}
|
||||
return Some((handles, vec![EntityType::LwPolyline(pl)]));
|
||||
}
|
||||
|
||||
@@ -399,6 +525,94 @@ mod join_tests {
|
||||
l.thickness
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_join_consumes_only_compatible_lines_and_keeps_direction() {
|
||||
let source_handle = Handle::new(11);
|
||||
let rejected_handle = Handle::new(12);
|
||||
let joined_handle = Handle::new(13);
|
||||
let mut source = line(5.0, 0.0, 1.0, 0.0, 2.0);
|
||||
source.common_mut().handle = source_handle;
|
||||
let rejected = line(1.0, 0.0, 1.0, 2.0, 0.0);
|
||||
let joined = line(8.0, 0.0, 6.0, 0.0, 0.0);
|
||||
let (result, consumed) = join_to_source(
|
||||
&source,
|
||||
&[(rejected_handle, &rejected), (joined_handle, &joined)],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(consumed, vec![joined_handle]);
|
||||
let EntityType::Line(result) = result else {
|
||||
panic!("expected a line");
|
||||
};
|
||||
assert_eq!(result.common.handle, source_handle);
|
||||
assert_eq!(result.start, Vector3::new(8.0, 0.0, 0.0));
|
||||
assert_eq!(result.end, Vector3::new(1.0, 0.0, 0.0));
|
||||
assert_eq!(result.thickness, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_join_elevates_splines_and_clears_fit_representation() {
|
||||
let source_handle = Handle::new(21);
|
||||
let candidate_handle = Handle::new(22);
|
||||
let mut source = acadrust::entities::Spline::new();
|
||||
source.common.handle = source_handle;
|
||||
source.degree = 1;
|
||||
source.control_points = vec![
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(1.0, 0.0, 0.0),
|
||||
];
|
||||
source.knots = vec![0.0, 0.0, 1.0, 1.0];
|
||||
source.fit_points = vec![Vector3::new(0.0, 0.0, 0.0)];
|
||||
source.dwg_flags1 = 1;
|
||||
source.dxf_flags = 32 | 1024;
|
||||
let mut candidate = acadrust::entities::Spline::new();
|
||||
candidate.degree = 3;
|
||||
candidate.control_points = vec![
|
||||
Vector3::new(1.0, 0.0, 0.0),
|
||||
Vector3::new(1.3, 0.2, 0.0),
|
||||
Vector3::new(1.7, 0.8, 0.0),
|
||||
Vector3::new(2.0, 1.0, 0.0),
|
||||
];
|
||||
candidate.knots = vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0];
|
||||
let candidate_entity = EntityType::Spline(candidate);
|
||||
let (result, consumed) = join_to_source(
|
||||
&EntityType::Spline(source),
|
||||
&[(candidate_handle, &candidate_entity)],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(consumed, vec![candidate_handle]);
|
||||
let EntityType::Spline(result) = result else {
|
||||
panic!("expected a spline");
|
||||
};
|
||||
assert_eq!(result.common.handle, source_handle);
|
||||
assert_eq!(result.degree, 3);
|
||||
assert!(result.fit_points.is_empty());
|
||||
assert_eq!(result.dwg_flags1 & 1, 0);
|
||||
assert_eq!(result.dxf_flags & (32 | 1024), 0);
|
||||
assert!(result.flags.planar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_source_accepts_the_typed_close_option() {
|
||||
let handle = Handle::new(31);
|
||||
let mut arc = acadrust::entities::Arc::new();
|
||||
arc.common.handle = handle;
|
||||
arc.center = Vector3::new(2.0, 3.0, 4.0);
|
||||
arc.radius = 5.0;
|
||||
arc.thickness = 0.75;
|
||||
let mut command = JoinCommand::new().with_source(handle, EntityType::Arc(arc));
|
||||
assert!(command.input_kind().wants_text());
|
||||
assert!(command.point_step_accepts_keywords());
|
||||
let Some(CmdResult::ReplaceMany(mut replacements, _)) = command.on_text_input("Close") else {
|
||||
panic!("expected the arc to close");
|
||||
};
|
||||
let Some(EntityType::Circle(circle)) = replacements[0].1.pop() else {
|
||||
panic!("expected a circle");
|
||||
};
|
||||
assert_eq!(circle.center, Vector3::new(2.0, 3.0, 4.0));
|
||||
assert_eq!(circle.radius, 5.0);
|
||||
assert_eq!(circle.thickness, 0.75);
|
||||
}
|
||||
}
|
||||
|
||||
/// True when every vertex lies on one straight line (within tolerance).
|
||||
|
||||
+276
-172
@@ -1,6 +1,6 @@
|
||||
// LENGTHEN command — extend or trim a Line or Arc by a specified delta or total.
|
||||
//
|
||||
// Options (entered as text after the entity pick):
|
||||
// Choose an option, enter its value, then pick objects repeatedly:
|
||||
// DE <value> — extend by delta (positive extends, negative trims)
|
||||
// TO <value> — set total length (Line) or arc length (Arc)
|
||||
// P <pct> — change by percentage (100 = no change, 150 = +50%)
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use crate::modules::draw::modify::spline_ops::{spline_cut, spline_to_nurbs};
|
||||
use acadrust::entities::{
|
||||
Arc as ArcEnt, Ellipse as EllipseEnt, Line as LineEnt, LwPolyline, Spline as SplineEnt,
|
||||
Ellipse as EllipseEnt, LwPolyline, Spline as SplineEnt,
|
||||
};
|
||||
use cadkernel::geom2d::{
|
||||
Curve, Ellipse as KernelEllipse, EllipseArc as KernelEllipseArc,
|
||||
@@ -25,120 +25,191 @@ const TAU: f64 = std::f64::consts::TAU;
|
||||
|
||||
pub struct LengthenCommand {
|
||||
state: LenState,
|
||||
picked: Option<EntityType>,
|
||||
measurement: Option<f64>,
|
||||
edits: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ValueMode { Delta, Total, Percent, DeltaAngle, TotalAngle, Dynamic }
|
||||
|
||||
struct LengthenDefaults { mode: ValueMode, delta: f64, total: f64, percent: f64, delta_angle: f64, total_angle: f64 }
|
||||
static LENGTHEN_DEFAULTS: std::sync::Mutex<LengthenDefaults> = std::sync::Mutex::new(
|
||||
LengthenDefaults { mode: ValueMode::Total, delta: 0.0, total: 1.0, percent: 100.0, delta_angle: 0.0, total_angle: 180.0 }
|
||||
);
|
||||
|
||||
enum LenState {
|
||||
PickEntity,
|
||||
PickOption { handle: Handle, pick_pt: Vec3 },
|
||||
ChooseMode,
|
||||
Value(ValueMode),
|
||||
Apply(LenMode),
|
||||
DynamicPick,
|
||||
DynamicPoint { handle: Handle, entity: EntityType, pick: DVec3 },
|
||||
}
|
||||
|
||||
impl LengthenCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: LenState::PickEntity,
|
||||
}
|
||||
Self { state: LenState::ChooseMode, picked: None, measurement: None, edits: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for LengthenCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"LENGTHEN"
|
||||
}
|
||||
fn name(&self) -> &'static str { "LENGTHEN" }
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.state {
|
||||
LenState::PickEntity => t!("LENGTHEN Select object:").into_owned(),
|
||||
LenState::PickOption { .. } => {
|
||||
t!("LENGTHEN Enter option [DE <delta> / TO <total> / P <pct>]:").into_owned()
|
||||
LenState::ChooseMode => {
|
||||
let prompt = t!("LENGTHEN Select an object to measure or [Delta/Percent/Total/Dynamic]:");
|
||||
self.measurement.map_or_else(|| prompt.to_string(), |length| format!("Length: {length:.4} {prompt}"))
|
||||
}
|
||||
LenState::Value(ValueMode::Delta) => format!("LENGTHEN Enter delta length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().delta),
|
||||
LenState::Value(ValueMode::Total) => format!("LENGTHEN Enter total length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().total),
|
||||
LenState::Value(ValueMode::Percent) => format!("LENGTHEN Enter percentage length <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().percent),
|
||||
LenState::Value(ValueMode::DeltaAngle) => format!("LENGTHEN Enter delta angle <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().delta_angle),
|
||||
LenState::Value(ValueMode::TotalAngle) => format!("LENGTHEN Enter total angle <{}>:", LENGTHEN_DEFAULTS.lock().unwrap().total_angle),
|
||||
LenState::DynamicPoint { .. } => "LENGTHEN Specify new end point:".into(),
|
||||
LenState::Apply(_) | LenState::DynamicPick | LenState::Value(ValueMode::Dynamic) => t!("LENGTHEN Select an object to change or [Undo]:").into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(self.state, LenState::PickEntity)
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { let pt = pt.as_vec3();
|
||||
if handle.is_null() {
|
||||
return CmdResult::NeedPoint;
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
use crate::command::CmdOption;
|
||||
match self.state {
|
||||
LenState::ChooseMode => vec![CmdOption::new("Delta", "DE"), CmdOption::new("Percent", "P"), CmdOption::new("Total", "TO"), CmdOption::new("Dynamic", "DY")],
|
||||
LenState::Value(ValueMode::Delta | ValueMode::Total) => vec![CmdOption::new("Angle", "A")],
|
||||
LenState::Apply(_) | LenState::DynamicPick => vec![CmdOption::new("Undo", "U")],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
self.state = LenState::PickOption {
|
||||
handle,
|
||||
pick_pt: pt,
|
||||
};
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.state, LenState::PickOption { .. })
|
||||
fn needs_entity_pick(&self) -> bool { matches!(self.state, LenState::ChooseMode | LenState::Apply(_) | LenState::DynamicPick) }
|
||||
fn inject_before_entity_pick(&self) -> bool { true }
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); }
|
||||
|
||||
fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult {
|
||||
if handle.is_null() { return CmdResult::NeedPoint; }
|
||||
let Some(entity) = self.picked.take() else { return CmdResult::NeedPoint; };
|
||||
match &self.state {
|
||||
LenState::ChooseMode => {
|
||||
self.measurement = crate::entities::curve::entity_curve(&entity)
|
||||
.map(|curve| curve.curve.length()).filter(|length| length.is_finite());
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
LenState::Apply(mode) => match lengthen_entity_precise(&entity, pt, mode) {
|
||||
Some(replacement) => CmdResult::ReplaceManyContinue(vec![(handle, vec![replacement])]),
|
||||
None => CmdResult::NeedPoint,
|
||||
},
|
||||
LenState::DynamicPick if matches!(entity, EntityType::Line(_) | EntityType::Arc(_)) => {
|
||||
self.state = LenState::DynamicPoint { handle, entity, pick: pt };
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_entity_replaced(&mut self, _old: Handle, _new_handles: &[Handle]) { self.edits += 1; }
|
||||
fn wants_text_input(&self) -> bool { true }
|
||||
fn dyn_commit_as_text(&self) -> bool { matches!(self.state, LenState::Value(_)) }
|
||||
fn dyn_auto_sign_angle(&self) -> bool { false }
|
||||
fn dyn_field(&self) -> crate::command::DynField {
|
||||
if matches!(self.state, LenState::PickOption { .. }) {
|
||||
crate::command::DynField::Scalar
|
||||
} else {
|
||||
crate::command::DynField::Point
|
||||
}
|
||||
if matches!(self.state, LenState::Value(ValueMode::DeltaAngle | ValueMode::TotalAngle)) { crate::command::DynField::Angle }
|
||||
else if matches!(self.state, LenState::Value(_)) { crate::command::DynField::Scalar }
|
||||
else { crate::command::DynField::Point }
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let (handle, pick_pt) = match &self.state {
|
||||
LenState::PickOption { handle, pick_pt } => (*handle, *pick_pt),
|
||||
_ => return None,
|
||||
};
|
||||
let pick_pt = pick_pt.as_dvec3();
|
||||
|
||||
let text = text.trim().to_uppercase();
|
||||
if let Some(rest) = text.strip_prefix("DE ").or_else(|| text.strip_prefix("DE")) {
|
||||
let delta: f64 = rest.trim().replace(',', ".").parse().ok()?;
|
||||
Some(CmdResult::LengthenEntity {
|
||||
handle,
|
||||
pick_pt,
|
||||
mode: LenMode::Delta(delta),
|
||||
})
|
||||
} else if let Some(rest) = text.strip_prefix("TO ").or_else(|| text.strip_prefix("TO")) {
|
||||
let total: f64 = rest
|
||||
.trim()
|
||||
.replace(',', ".")
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|&v: &f64| v > 0.0)?;
|
||||
Some(CmdResult::LengthenEntity {
|
||||
handle,
|
||||
pick_pt,
|
||||
mode: LenMode::Total(total),
|
||||
})
|
||||
} else if let Some(rest) = text.strip_prefix("P ").or_else(|| text.strip_prefix("P")) {
|
||||
let pct: f64 = rest
|
||||
.trim()
|
||||
.replace(',', ".")
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|&v: &f64| v > 0.0)?;
|
||||
Some(CmdResult::LengthenEntity {
|
||||
handle,
|
||||
pick_pt,
|
||||
mode: LenMode::Percent(pct),
|
||||
})
|
||||
} else {
|
||||
// Try plain number as delta
|
||||
let delta: f64 = text.replace(',', ".").parse().ok()?;
|
||||
Some(CmdResult::LengthenEntity {
|
||||
handle,
|
||||
pick_pt,
|
||||
mode: LenMode::Delta(delta),
|
||||
})
|
||||
if matches!(self.state, LenState::DynamicPoint { .. }) { return None; }
|
||||
let upper = text.trim().to_uppercase();
|
||||
if upper.is_empty() { return Some(self.on_enter()); }
|
||||
if matches!(self.state, LenState::Apply(_) | LenState::DynamicPick) {
|
||||
return Some(if matches!(upper.as_str(), "U" | "UNDO") && self.edits > 0 {
|
||||
self.edits -= 1;
|
||||
CmdResult::UndoDocument
|
||||
} else { CmdResult::NeedPoint });
|
||||
}
|
||||
if matches!(self.state, LenState::ChooseMode) {
|
||||
let mut parts = upper.split_whitespace();
|
||||
let mode = match parts.next().unwrap_or("") {
|
||||
"D" | "DE" | "DELTA" => ValueMode::Delta,
|
||||
"T" | "TO" | "TOTAL" => ValueMode::Total,
|
||||
"P" | "PERCENT" => ValueMode::Percent,
|
||||
"DY" | "DYNAMIC" => ValueMode::Dynamic,
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
};
|
||||
LENGTHEN_DEFAULTS.lock().unwrap().mode = mode;
|
||||
self.state = if matches!(mode, ValueMode::Dynamic) { LenState::DynamicPick } else { LenState::Value(mode) };
|
||||
if let Some(value) = parts.next() { return self.on_text_input(value); }
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
let LenState::Value(mode) = self.state else { return Some(CmdResult::NeedPoint); };
|
||||
if matches!(upper.as_str(), "A" | "ANGLE") {
|
||||
self.state = match mode {
|
||||
ValueMode::Delta => LenState::Value(ValueMode::DeltaAngle),
|
||||
ValueMode::Total => LenState::Value(ValueMode::TotalAngle),
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
};
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
let Some(value) = upper.replace(',', ".").parse::<f64>().ok().filter(|v| v.is_finite()) else {
|
||||
return Some(CmdResult::NeedPoint);
|
||||
};
|
||||
if !matches!(mode, ValueMode::Delta | ValueMode::DeltaAngle) && value <= 0.0 { return Some(CmdResult::NeedPoint); }
|
||||
if matches!(mode, ValueMode::TotalAngle) && value >= 360.0 { return Some(CmdResult::NeedPoint); }
|
||||
{
|
||||
let mut defaults = LENGTHEN_DEFAULTS.lock().unwrap();
|
||||
match mode { ValueMode::Delta => defaults.delta = value, ValueMode::Total => defaults.total = value, ValueMode::Percent => defaults.percent = value,
|
||||
ValueMode::DeltaAngle => defaults.delta_angle = value, ValueMode::TotalAngle => defaults.total_angle = value, ValueMode::Dynamic => {} }
|
||||
}
|
||||
self.state = LenState::Apply(match mode {
|
||||
ValueMode::Delta => LenMode::Delta(value),
|
||||
ValueMode::Total => LenMode::Total(value),
|
||||
ValueMode::Percent => LenMode::Percent(value),
|
||||
ValueMode::DeltaAngle => LenMode::DeltaAngle(value.to_radians()),
|
||||
ValueMode::TotalAngle => LenMode::TotalAngle(value.to_radians()),
|
||||
ValueMode::Dynamic => return Some(CmdResult::NeedPoint),
|
||||
});
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
if let LenState::DynamicPoint { handle, entity, pick } = &self.state {
|
||||
if let Some(replacement) = lengthen_entity_precise(entity, *pick, &LenMode::Dynamic(pt)) {
|
||||
let handle = *handle;
|
||||
self.state = LenState::DynamicPick;
|
||||
return CmdResult::ReplaceManyContinue(vec![(handle, vec![replacement])]);
|
||||
}
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn on_mouse_move(&mut self, pt: DVec3) -> Option<crate::scene::model::wire_model::WireModel> {
|
||||
let LenState::DynamicPoint { entity, pick, .. } = &self.state else { return None; };
|
||||
let replacement = lengthen_entity_precise(entity, *pick, &LenMode::Dynamic(pt))?;
|
||||
let curve = crate::entities::curve::entity_curve(&replacement)?;
|
||||
let points = crate::entities::curve::curve_points(&curve).into_iter()
|
||||
.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect();
|
||||
Some(crate::scene::model::wire_model::WireModel::solid(
|
||||
"lengthen_dynamic_preview".into(), points,
|
||||
crate::scene::model::wire_model::WireModel::CYAN, false,
|
||||
))
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
match self.state {
|
||||
LenState::ChooseMode => {
|
||||
let mode = LENGTHEN_DEFAULTS.lock().unwrap().mode;
|
||||
self.state = if matches!(mode, ValueMode::Dynamic) { LenState::DynamicPick } else { LenState::Value(mode) };
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
LenState::Value(mode) => {
|
||||
let value = {
|
||||
let defaults = LENGTHEN_DEFAULTS.lock().unwrap();
|
||||
match mode { ValueMode::Delta => defaults.delta, ValueMode::Total => defaults.total, ValueMode::Percent => defaults.percent,
|
||||
ValueMode::DeltaAngle => defaults.delta_angle, ValueMode::TotalAngle => defaults.total_angle, ValueMode::Dynamic => 0.0 }
|
||||
};
|
||||
self.on_text_input(&value.to_string()).unwrap_or(CmdResult::NeedPoint)
|
||||
}
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode enum (also used in CmdResult) ────────────────────────────────────
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -146,6 +217,9 @@ pub enum LenMode {
|
||||
Delta(f64),
|
||||
Total(f64),
|
||||
Percent(f64),
|
||||
DeltaAngle(f64),
|
||||
TotalAngle(f64),
|
||||
Dynamic(DVec3),
|
||||
}
|
||||
|
||||
// ── Geometry ───────────────────────────────────────────────────────────────
|
||||
@@ -153,96 +227,48 @@ pub enum LenMode {
|
||||
/// Apply LENGTHEN to a Line, Arc, Ellipse, or Spline.
|
||||
/// `pick_pt` determines which end to extend/trim (closest end is modified).
|
||||
pub fn lengthen_entity(entity: &EntityType, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
|
||||
lengthen_entity_precise(entity, pick_pt.as_dvec3(), mode)
|
||||
}
|
||||
|
||||
fn lengthen_entity_precise(entity: &EntityType, pick: DVec3, mode: &LenMode) -> Option<EntityType> {
|
||||
use cadkernel::space::lengthen::{LengthChange, lengthen_line, lengthen_arc};
|
||||
let change = match mode {
|
||||
LenMode::Delta(value) => LengthChange::Delta(*value),
|
||||
LenMode::Total(value) => LengthChange::Total(*value),
|
||||
LenMode::Percent(value) => LengthChange::Percent(*value),
|
||||
LenMode::DeltaAngle(value) => LengthChange::DeltaAngle(*value),
|
||||
LenMode::TotalAngle(value) => LengthChange::TotalAngle(*value),
|
||||
LenMode::Dynamic(point) => LengthChange::Dynamic(point.to_array()),
|
||||
};
|
||||
match entity {
|
||||
EntityType::Line(l) => lengthen_line(l, pick_pt, mode),
|
||||
EntityType::Arc(a) => lengthen_arc(a, pick_pt, mode),
|
||||
EntityType::Ellipse(e) => lengthen_ellipse(e, pick_pt, mode),
|
||||
EntityType::Spline(s) => lengthen_spline(s, pick_pt, mode),
|
||||
EntityType::Line(line) => {
|
||||
let [start, end] = lengthen_line(
|
||||
[line.start.x, line.start.y, line.start.z],
|
||||
[line.end.x, line.end.y, line.end.z], pick.to_array(), change)?;
|
||||
let mut result = line.clone();
|
||||
result.common.handle = Handle::NULL;
|
||||
result.start = Vector3::new(start[0], start[1], start[2]);
|
||||
result.end = Vector3::new(end[0], end[1], end[2]);
|
||||
Some(EntityType::Line(result))
|
||||
}
|
||||
EntityType::Arc(arc) => {
|
||||
let (start, end) = lengthen_arc(&crate::entities::curve::arc_curve(arc), pick.to_array(), change)?;
|
||||
let mut result = arc.clone();
|
||||
result.common.handle = Handle::NULL;
|
||||
result.start_angle = start;
|
||||
result.end_angle = end;
|
||||
Some(EntityType::Arc(result))
|
||||
}
|
||||
EntityType::Ellipse(e) => lengthen_ellipse(e, pick.as_vec3(), mode),
|
||||
EntityType::Spline(s) => lengthen_spline(s, pick.as_vec3(), mode),
|
||||
EntityType::LwPolyline(p) => {
|
||||
let p = crate::entities::curve::lwpolyline_world_xy(p)?;
|
||||
lengthen_lwpoly(&p, pick_pt, mode)
|
||||
lengthen_lwpoly(&p, pick.as_vec3(), mode)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn lengthen_line(line: &LineEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
|
||||
// Keep the line's own coordinates on the f64 grid; only pick_pt (screen-only,
|
||||
// nearest-end selection) widens for the distance compare.
|
||||
let sx = line.start.x;
|
||||
let sy = line.start.y;
|
||||
let ex = line.end.x;
|
||||
let ey = line.end.y;
|
||||
|
||||
let dx = ex - sx;
|
||||
let dy = ey - sy;
|
||||
let current_len = (dx * dx + dy * dy).sqrt();
|
||||
if current_len < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let new_len = apply_mode(current_len, mode)?;
|
||||
if new_len < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ux = dx / current_len;
|
||||
let uy = dy / current_len;
|
||||
|
||||
// Which end is closer to pick?
|
||||
let px = pick_pt.x as f64;
|
||||
let py = pick_pt.y as f64;
|
||||
let dist_to_start = (px - sx).hypot(py - sy);
|
||||
let dist_to_end = (px - ex).hypot(py - ey);
|
||||
|
||||
let mut result = line.clone();
|
||||
result.common.handle = Handle::NULL;
|
||||
|
||||
if dist_to_end <= dist_to_start {
|
||||
// Extend/trim the end
|
||||
let new_x = sx + ux * new_len;
|
||||
let new_y = sy + uy * new_len;
|
||||
result.end = Vector3::new(new_x, new_y, line.end.z);
|
||||
} else {
|
||||
// Extend/trim the start (move start backward along dir)
|
||||
let new_x = ex - ux * new_len;
|
||||
let new_y = ey - uy * new_len;
|
||||
result.start = Vector3::new(new_x, new_y, line.start.z);
|
||||
}
|
||||
Some(EntityType::Line(result))
|
||||
}
|
||||
|
||||
fn lengthen_arc(arc: &ArcEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
|
||||
// Current arc span
|
||||
let span = arc_span_rad(arc.start_angle, arc.end_angle);
|
||||
let current_arc_len = arc.radius * span;
|
||||
|
||||
let new_arc_len = apply_mode(current_arc_len, mode)?;
|
||||
if new_arc_len < 1e-10 || new_arc_len >= std::f64::consts::TAU * arc.radius - 1.0e-9 {
|
||||
return None;
|
||||
}
|
||||
let new_span = new_arc_len / arc.radius;
|
||||
|
||||
// Which end (start or end angle) is closer to pick?
|
||||
let curve = crate::entities::curve::arc_curve(arc);
|
||||
let start_pt = Vec3::from_array(curve.point_at(0.0).map(|value| value as f32));
|
||||
let end_pt = Vec3::from_array(curve.point_at(1.0).map(|value| value as f32));
|
||||
let dist_start = (pick_pt - start_pt).length();
|
||||
let dist_end = (pick_pt - end_pt).length();
|
||||
|
||||
let mut result = arc.clone();
|
||||
result.common.handle = Handle::NULL;
|
||||
|
||||
if dist_end <= dist_start {
|
||||
// Extend end angle
|
||||
result.end_angle = arc.start_angle + new_span;
|
||||
} else {
|
||||
// Extend start angle (move start backwards)
|
||||
result.start_angle = arc.end_angle - new_span;
|
||||
}
|
||||
Some(EntityType::Arc(result))
|
||||
}
|
||||
|
||||
fn lengthen_ellipse(ell: &EllipseEnt, pick_pt: Vec3, mode: &LenMode) -> Option<EntityType> {
|
||||
let a = (ell.major_axis.x.powi(2) + ell.major_axis.y.powi(2)).sqrt();
|
||||
if a < 1e-9 {
|
||||
@@ -318,15 +344,7 @@ fn apply_mode(current: f64, mode: &LenMode) -> Option<f64> {
|
||||
LenMode::Delta(d) => Some(current + d),
|
||||
LenMode::Total(t) => Some(*t),
|
||||
LenMode::Percent(p) => Some(current * p / 100.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn arc_span_rad(start: f64, end: f64) -> f64 {
|
||||
let span = (end - start).rem_euclid(TAU);
|
||||
if span < 1e-6 {
|
||||
TAU
|
||||
} else {
|
||||
span
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,3 +454,89 @@ fn lengthen_spline(spl: &SplineEnt, pick_pt: Vec3, mode: &LenMode) -> Option<Ent
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["LENGTHEN"] }); // LengthenCommand
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn spatial_line() -> EntityType {
|
||||
EntityType::Line(acadrust::entities::Line::from_points(
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(0.0, 0.0, 4.0),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_delta_edits_offer_one_undo_per_replacement() {
|
||||
let handle = Handle::new(11);
|
||||
let mut command = LengthenCommand::new();
|
||||
assert!(matches!(command.on_text_input("DE 2"), Some(CmdResult::NeedPoint)));
|
||||
|
||||
for _ in 0..2 {
|
||||
command.inject_picked_entity(spatial_line());
|
||||
let CmdResult::ReplaceManyContinue(replacements) =
|
||||
command.on_entity_pick(handle, DVec3::new(0.0, 0.0, 4.0))
|
||||
else {
|
||||
panic!("delta edit must replace the picked line");
|
||||
};
|
||||
let EntityType::Line(line) = &replacements[0].1[0] else {
|
||||
panic!("replacement must stay a line");
|
||||
};
|
||||
assert_eq!(line.end, Vector3::new(0.0, 0.0, 6.0));
|
||||
command.on_entity_replaced(handle, &[]);
|
||||
}
|
||||
|
||||
assert!(matches!(command.on_text_input("U"), Some(CmdResult::UndoDocument)));
|
||||
assert!(matches!(command.on_text_input("U"), Some(CmdResult::UndoDocument)));
|
||||
assert!(matches!(command.on_text_input("U"), Some(CmdResult::NeedPoint)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_line_endpoint_is_projected_onto_its_spatial_support() {
|
||||
let handle = Handle::new(12);
|
||||
let mut command = LengthenCommand::new();
|
||||
assert!(matches!(command.on_text_input("DY"), Some(CmdResult::NeedPoint)));
|
||||
command.inject_picked_entity(spatial_line());
|
||||
assert!(matches!(
|
||||
command.on_entity_pick(handle, DVec3::new(0.0, 0.0, 4.0)),
|
||||
CmdResult::NeedPoint
|
||||
));
|
||||
|
||||
let CmdResult::ReplaceManyContinue(replacements) =
|
||||
command.on_point(DVec3::new(2.0, -3.0, 7.0))
|
||||
else {
|
||||
panic!("dynamic point must replace the line");
|
||||
};
|
||||
let EntityType::Line(line) = &replacements[0].1[0] else {
|
||||
panic!("replacement must stay a line");
|
||||
};
|
||||
assert_eq!(line.start, Vector3::new(0.0, 0.0, 0.0));
|
||||
assert_eq!(line.end, Vector3::new(0.0, 0.0, 7.0));
|
||||
assert!(command.needs_entity_pick());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circular_arc_accepts_total_angle_in_radians() {
|
||||
let arc = acadrust::entities::Arc::from_center_radius_angles(
|
||||
Vector3::ZERO,
|
||||
2.0,
|
||||
0.0,
|
||||
std::f64::consts::FRAC_PI_2,
|
||||
);
|
||||
let EntityType::Arc(result) = lengthen_entity_precise(
|
||||
&EntityType::Arc(arc),
|
||||
DVec3::new(0.0, 2.0, 0.0),
|
||||
&LenMode::TotalAngle(std::f64::consts::PI),
|
||||
)
|
||||
.expect("valid angular total")
|
||||
else {
|
||||
panic!("replacement must stay an arc");
|
||||
};
|
||||
assert!((result.end_angle - result.start_angle - std::f64::consts::PI).abs() < 1.0e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_total_angle_is_a_half_turn_in_degrees() {
|
||||
assert_eq!(LENGTHEN_DEFAULTS.lock().unwrap().total_angle, 180.0);
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,16 @@ pub mod join;
|
||||
pub mod lengthen;
|
||||
pub mod mirror;
|
||||
pub mod mledit;
|
||||
pub mod ncopy;
|
||||
pub mod offset;
|
||||
pub mod overkill;
|
||||
pub mod pedit;
|
||||
pub mod block_edit;
|
||||
pub mod refedit;
|
||||
pub mod reverse;
|
||||
pub mod rotate;
|
||||
pub mod scale;
|
||||
pub mod setbylayer;
|
||||
pub mod spline_ops;
|
||||
pub mod splinedit;
|
||||
pub mod stretch;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
use acadrust::{CadDocument, EntityType, Handle};
|
||||
use glam::DVec3;
|
||||
use acadrust::nested_copy::{NestedCopyMode, NestedCopySymbolNames};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, EntityTransform};
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
use crate::scene::convert::acad_to_render::RenderObject;
|
||||
|
||||
enum Step { Select, Settings, Base, Target(DVec3), ArrayCount(DVec3), ArrayTarget(DVec3, usize, bool) }
|
||||
pub struct NcopyCommand {
|
||||
nested: HashMap<Handle, Vec<EntityType>>,
|
||||
hit_paths: HashMap<Handle, Vec<Vec<[f64; 3]>>>,
|
||||
selected: Vec<(Handle, usize)>,
|
||||
step: Step,
|
||||
multiple: bool,
|
||||
placements: usize,
|
||||
mode: NestedCopyMode,
|
||||
insert_names: NestedCopySymbolNames,
|
||||
bind_names: NestedCopySymbolNames,
|
||||
}
|
||||
impl NcopyCommand {
|
||||
pub fn new(document: &CadDocument, bind: bool) -> Self {
|
||||
fn leaves(entity: &EntityType, document: &CadDocument, path: &mut HashSet<String>, output: &mut Vec<EntityType>) {
|
||||
let EntityType::Insert(insert) = entity else { output.push(entity.clone()); return; };
|
||||
let name = insert.block_name.to_ascii_uppercase();
|
||||
if !path.insert(name.clone()) { return; }
|
||||
for child in super::explode::explode_entity(entity, document) {
|
||||
leaves(&child, document, path, output);
|
||||
}
|
||||
path.remove(&name);
|
||||
}
|
||||
let nested: HashMap<_, _> = document.entities().filter(|e| matches!(e, EntityType::Insert(_)))
|
||||
.map(|e| {
|
||||
let mut entities = Vec::new();
|
||||
leaves(e, document, &mut HashSet::new(), &mut entities);
|
||||
(e.common().handle, entities)
|
||||
}).collect();
|
||||
let hit_paths = nested.iter().map(|(&handle, entities)| {
|
||||
let paths = entities.iter().map(|entity| {
|
||||
if crate::entities::curve::entity_curve(entity).is_some() { return Vec::new(); }
|
||||
match entity.to_render_entity(document).map(|render| render.object) {
|
||||
Some(RenderObject::Dot(point)) => vec![point],
|
||||
Some(RenderObject::Lines(points) | RenderObject::SegmentedLines(points)
|
||||
| RenderObject::TaperedLines(points, _) | RenderObject::BoundaryLines { points, .. }) => points,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}).collect();
|
||||
(handle, paths)
|
||||
}).collect();
|
||||
Self { nested, hit_paths, selected: Vec::new(), step: Step::Select, multiple: false, placements: 0,
|
||||
mode: if bind { NestedCopyMode::Bind } else { NestedCopyMode::Insert },
|
||||
insert_names: document.nested_copy_symbol_names(NestedCopyMode::Insert),
|
||||
bind_names: document.nested_copy_symbol_names(NestedCopyMode::Bind),
|
||||
}
|
||||
}
|
||||
fn place(&self, delta: DVec3) -> CmdResult {
|
||||
if !delta.is_finite() { return CmdResult::NeedPoint; }
|
||||
let copies = self.selected.iter().filter_map(|(h, index)| self.nested.get(h)?.get(*index)).cloned()
|
||||
.map(|mut entity| {
|
||||
crate::scene::view::dispatch::apply_transform(&mut entity, &EntityTransform::Translate(delta));
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
entity
|
||||
}).collect();
|
||||
if self.multiple { CmdResult::CommitEntities(copies) }
|
||||
else { CmdResult::CommitEntitiesAndExit(copies) }
|
||||
}
|
||||
}
|
||||
impl CadCommand for NcopyCommand {
|
||||
fn name(&self) -> &'static str { "NCOPY" }
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
Step::Select => format!("Current setting: {}. Select nested objects or [Settings]:", if self.mode == NestedCopyMode::Bind { "Bind" } else { "Insert" }),
|
||||
Step::Settings => format!("Enter setting [Insert/Bind] <{}>:", if self.mode == NestedCopyMode::Bind { "Bind" } else { "Insert" }),
|
||||
Step::Base => "Specify base point or [Displacement/Multiple] <Displacement>:".into(),
|
||||
Step::Target(_) => "Specify second point or [Array] <use first point as displacement>:".into(),
|
||||
Step::ArrayCount(_) => "Enter number of items to array:".into(),
|
||||
Step::ArrayTarget(_, _, false) => "Specify second point or [Fit]:".into(),
|
||||
Step::ArrayTarget(_, _, true) => "Specify last point:".into(),
|
||||
}
|
||||
}
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
Step::Select => vec![CmdOption::new("Settings", "S")],
|
||||
Step::Settings => vec![CmdOption::new("Insert", "I"), CmdOption::new("Bind", "B")],
|
||||
Step::Base => vec![CmdOption::new("Displacement", "D"), CmdOption::new("Multiple", "M")],
|
||||
Step::Target(_) if self.multiple && self.placements > 0 => vec![CmdOption::new("Array", "A"), CmdOption::new("Exit", "E"), CmdOption::new("Undo", "U")],
|
||||
Step::Target(_) => vec![CmdOption::new("Array", "A")],
|
||||
Step::ArrayTarget(_, _, false) => vec![CmdOption::new("Fit", "F")],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn nested_copy_bind_setting(&self) -> Option<bool> { Some(self.mode == NestedCopyMode::Bind) }
|
||||
fn nested_copy_symbol_names(&self) -> Option<&NestedCopySymbolNames> {
|
||||
Some(if self.mode == NestedCopyMode::Bind { &self.bind_names } else { &self.insert_names })
|
||||
}
|
||||
fn preserve_commit_style(&self) -> bool { true }
|
||||
fn preserve_commit_layer(&self) -> bool { true }
|
||||
fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::Select) }
|
||||
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
|
||||
if let Some(entities) = self.nested.get(&handle) {
|
||||
let nearest = entities.iter().enumerate().filter_map(|(index, entity)| {
|
||||
let distance = if let Some(curve) = crate::entities::curve::entity_curve(entity) {
|
||||
let projected = curve.plane.project([point.x, point.y, point.z])?;
|
||||
cadkernel::geom2d::closest_point(&curve.curve, projected).distance
|
||||
.hypot(curve.plane.distance_to(point.to_array())?)
|
||||
} else {
|
||||
let path = self.hit_paths.get(&handle)?.get(index)?;
|
||||
let point = cadkernel::space::Vec3::from(point.to_array());
|
||||
if path.len() == 1 {
|
||||
point.distance(cadkernel::space::Vec3::from(path[0]))
|
||||
} else {
|
||||
path.windows(2).filter(|segment| segment.iter().flatten().all(|v| v.is_finite()))
|
||||
.map(|segment| point.distance_to_segment(segment[0].into(), segment[1].into()))
|
||||
.min_by(f64::total_cmp)?
|
||||
}
|
||||
};
|
||||
Some((index, distance))
|
||||
}).min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
if let Some((index, _)) = nearest {
|
||||
if !self.selected.contains(&(handle, index)) { self.selected.push((handle, index)); }
|
||||
}
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn on_point(&mut self, point: DVec3) -> CmdResult {
|
||||
match self.step {
|
||||
Step::Select | Step::Settings => CmdResult::NeedPoint,
|
||||
Step::Base => { self.step = Step::Target(point); CmdResult::NeedPoint }
|
||||
Step::Target(base) => {
|
||||
if !point.is_finite() { return CmdResult::NeedPoint; }
|
||||
self.placements += 1;
|
||||
self.place(point - base)
|
||||
}
|
||||
Step::ArrayCount(_) => CmdResult::NeedPoint,
|
||||
Step::ArrayTarget(base, count, fit) => {
|
||||
let delta = (point - base) / if fit { (count - 1) as f64 } else { 1.0 };
|
||||
if !delta.is_finite() { return CmdResult::NeedPoint; }
|
||||
let mut copies = Vec::new();
|
||||
for index in 0..count {
|
||||
if let CmdResult::CommitEntities(mut entities) | CmdResult::CommitEntitiesAndExit(mut entities) = self.place(delta * index as f64) {
|
||||
copies.append(&mut entities);
|
||||
}
|
||||
}
|
||||
CmdResult::CommitEntitiesAndExit(copies)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match self.step {
|
||||
Step::Settings => { self.step = Step::Select; CmdResult::NeedPoint }
|
||||
Step::Select if !self.selected.is_empty() => { self.step = Step::Base; CmdResult::NeedPoint }
|
||||
Step::Base => { self.step = Step::Target(DVec3::ZERO); CmdResult::NeedPoint }
|
||||
Step::Target(base) if !self.multiple => self.place(base),
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
fn point_step_accepts_keywords(&self) -> bool { matches!(self.step, Step::Select | Step::Settings | Step::Base | Step::Target(_) | Step::ArrayTarget(..)) }
|
||||
fn wants_text_input(&self) -> bool { true }
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
if matches!(self.step, Step::Settings) {
|
||||
match text.trim().to_uppercase().as_str() {
|
||||
"I" | "INSERT" => self.mode = NestedCopyMode::Insert,
|
||||
"B" | "BIND" => self.mode = NestedCopyMode::Bind,
|
||||
_ => return Some(CmdResult::NeedPoint),
|
||||
}
|
||||
self.step = Step::Select;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if let Step::ArrayCount(base) = self.step {
|
||||
if let Ok(count) = text.trim().parse::<usize>() {
|
||||
if (2..=32767).contains(&count) { self.step = Step::ArrayTarget(base, count, false); }
|
||||
}
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
match text.trim().to_uppercase().as_str() {
|
||||
"S" | "SETTINGS" if matches!(self.step, Step::Select) => { self.step = Step::Settings; Some(CmdResult::NeedPoint) }
|
||||
"A" | "ARRAY" => {
|
||||
if let Step::Target(base) = self.step { self.step = Step::ArrayCount(base); }
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"F" | "FIT" => {
|
||||
if let Step::ArrayTarget(base, count, _) = self.step { self.step = Step::ArrayTarget(base, count, true); }
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
"U" | "UNDO" if self.multiple && self.placements > 0 => {
|
||||
self.placements -= 1;
|
||||
Some(CmdResult::UndoDocument)
|
||||
}
|
||||
"E" | "EXIT" if self.multiple => Some(CmdResult::Cancel),
|
||||
"M" | "MULTIPLE" if matches!(self.step, Step::Base) => { self.multiple = true; Some(CmdResult::NeedPoint) }
|
||||
"D" | "DISPLACEMENT" if matches!(self.step, Step::Base) => { self.step = Step::Target(DVec3::ZERO); Some(CmdResult::NeedPoint) }
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::Line;
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
#[test]
|
||||
fn placement_moves_only_geometry_and_keeps_source_style() {
|
||||
let handle = Handle::new(7);
|
||||
let mut line = Line::from_points(Vector3::ZERO, Vector3::new(2.0, 0.0, 0.0));
|
||||
line.common.handle = handle;
|
||||
line.common.layer = "DETAIL".into();
|
||||
line.common.linetype = "DASHED".into();
|
||||
let mut nested = HashMap::new();
|
||||
nested.insert(handle, vec![EntityType::Line(line)]);
|
||||
let command = NcopyCommand { nested, hit_paths: HashMap::new(), selected: vec![(handle, 0)],
|
||||
step: Step::Base, multiple: false, placements: 0,
|
||||
mode: NestedCopyMode::Insert,
|
||||
insert_names: NestedCopySymbolNames::default(),
|
||||
bind_names: NestedCopySymbolNames::default() };
|
||||
|
||||
let CmdResult::CommitEntitiesAndExit(entities) = command.place(DVec3::new(3.0, 4.0, 5.0))
|
||||
else { panic!("expected copied entity") };
|
||||
let EntityType::Line(line) = &entities[0] else { panic!("expected line") };
|
||||
assert!(line.common.handle.is_null());
|
||||
assert_eq!((line.common.layer.as_str(), line.common.linetype.as_str()), ("DETAIL", "DASHED"));
|
||||
assert_eq!(line.start, Vector3::new(3.0, 4.0, 5.0));
|
||||
assert_eq!(line.end, Vector3::new(5.0, 4.0, 5.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use acadrust::{EntityType, Handle};
|
||||
use glam::DVec3;
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Settings {
|
||||
pub tolerance: f64,
|
||||
pub ignore: u16,
|
||||
pub optimize: bool,
|
||||
pub overlap: bool,
|
||||
pub end_to_end: bool,
|
||||
pub associativity: bool,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self { Self { tolerance: 1e-6, ignore: 0, optimize: true,
|
||||
overlap: true, end_to_end: true, associativity: true } }
|
||||
}
|
||||
fn remembered() -> &'static std::sync::Mutex<Settings> {
|
||||
static SETTINGS: std::sync::OnceLock<std::sync::Mutex<Settings>> = std::sync::OnceLock::new();
|
||||
SETTINGS.get_or_init(|| std::sync::Mutex::new(Settings::default()))
|
||||
}
|
||||
enum Step { Selection, Options, Ignore, Tolerance, Optimize, Overlap, EndToEnd, Associativity }
|
||||
pub struct OverkillCommand { selected: Vec<Handle>, step: Step, settings: Settings }
|
||||
impl OverkillCommand {
|
||||
pub fn new(selected: Vec<Handle>) -> Self {
|
||||
let step = if selected.is_empty() { Step::Selection } else { Step::Options };
|
||||
Self { selected, step, settings: *remembered().lock().unwrap_or_else(|e|e.into_inner()) }
|
||||
}
|
||||
fn finish(&self) -> CmdResult {
|
||||
let s = self.settings;
|
||||
*remembered().lock().unwrap_or_else(|e|e.into_inner())=s;
|
||||
CmdResult::Relaunch(format!("OVERKILL_APPLY {} {} {} {} {} {}", s.tolerance,
|
||||
s.ignore, u8::from(s.optimize), u8::from(s.overlap), u8::from(s.end_to_end),
|
||||
u8::from(s.associativity)), self.selected.clone())
|
||||
}
|
||||
}
|
||||
impl CadCommand for OverkillCommand {
|
||||
fn name(&self) -> &'static str { "OVERKILL" }
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
Step::Selection => "Select objects:".into(),
|
||||
Step::Options => format!("Tolerance={} Ignore={} Optimize={} Overlap={} End-to-end={}\nEnter an option [Done/Ignore/tOlerance/optimize Plines/combine parTial overlap/combine Endtoend/Associativity] <Done>:", self.settings.tolerance, self.settings.ignore, self.settings.optimize, self.settings.overlap, self.settings.end_to_end),
|
||||
Step::Ignore => "Properties to ignore [None/All/Color/LAyer/Ltype/ltScale/LWeight/Thickness/TRansparency/plotSTyle/Material]:".into(),
|
||||
Step::Tolerance => format!("Specify tolerance <{}>:", self.settings.tolerance),
|
||||
Step::Optimize => format!("Optimize segments within polylines [Yes/No] <{}>:",if self.settings.optimize{"Yes"}else{"No"}),
|
||||
Step::Overlap => format!("Combine partially overlapping objects [Yes/No] <{}>:",if self.settings.overlap{"Yes"}else{"No"}),
|
||||
Step::EndToEnd => format!("Combine end-to-end objects [Yes/No] <{}>:",if self.settings.end_to_end{"Yes"}else{"No"}),
|
||||
Step::Associativity => format!("Maintain associative objects [Yes/No] <{}>:",if self.settings.associativity{"Yes"}else{"No"}),
|
||||
}
|
||||
}
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
Step::Options => [("Done","D"),("Ignore","I"),("Tolerance","O"),("Optimize polylines","P"),("Combine overlap","T"),("Combine end-to-end","E"),("Associativity","A")].into_iter().map(|(l,k)|CmdOption::new(l,k)).collect(),
|
||||
Step::Optimize|Step::Overlap|Step::EndToEnd|Step::Associativity => vec![CmdOption::new("Yes","Y"),CmdOption::new("No","N")],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn is_selection_gathering(&self) -> bool { matches!(self.step,Step::Selection) }
|
||||
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult { self.selected=handles; CmdResult::NeedPoint }
|
||||
fn wants_text_input(&self) -> bool { !self.is_selection_gathering() }
|
||||
fn on_point(&mut self, _:DVec3) -> CmdResult { CmdResult::NeedPoint }
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
match self.step {
|
||||
Step::Selection if self.selected.is_empty() => CmdResult::Cancel,
|
||||
Step::Options => self.finish(),
|
||||
_ => { self.step=Step::Options; CmdResult::NeedPoint }
|
||||
}
|
||||
}
|
||||
fn on_text_input(&mut self, text:&str) -> Option<CmdResult> {
|
||||
let k=text.trim().to_ascii_uppercase();
|
||||
match self.step {
|
||||
Step::Options => match k.as_str() {
|
||||
"D"|"DONE" => return Some(self.finish()),
|
||||
"I"|"IGNORE" => self.step=Step::Ignore,
|
||||
"O"|"TOLERANCE" => self.step=Step::Tolerance,
|
||||
"P"|"PLINES" => self.step=Step::Optimize,
|
||||
"T"|"PARTIAL" => self.step=Step::Overlap,
|
||||
"E"|"ENDTOEND" => self.step=Step::EndToEnd,
|
||||
"A"|"ASSOCIATIVITY" => self.step=Step::Associativity,
|
||||
_=>{},
|
||||
},
|
||||
Step::Ignore => {
|
||||
let flag=match k.as_str() { "N"|"NONE"=>{self.settings.ignore=0;0}, "A"|"ALL"=>511,
|
||||
"C"|"COLOR"=>1,"LA"|"LAYER"=>2,"L"|"LTYPE"=>4,"S"|"LTSCALE"=>8,
|
||||
"LW"|"LWEIGHT"=>16,"T"|"THICKNESS"=>32,"TR"|"TRANSPARENCY"=>64,
|
||||
"ST"|"PLOTSTYLE"=>128,"M"|"MATERIAL"=>256,_=>return Some(CmdResult::NeedPoint)};
|
||||
self.settings.ignore|=flag; self.step=Step::Options;
|
||||
}
|
||||
Step::Tolerance => if let Ok(v)=k.parse::<f64>() { if v.is_finite()&&v>=0.0 {
|
||||
self.settings.tolerance=v; self.step=Step::Options;
|
||||
} },
|
||||
Step::Optimize|Step::Overlap|Step::EndToEnd|Step::Associativity => {
|
||||
let yes=match k.as_str(){"Y"|"YES"=>true,"N"|"NO"=>false,_=>return Some(CmdResult::NeedPoint)};
|
||||
match self.step {Step::Optimize=>self.settings.optimize=yes,Step::Overlap=>self.settings.overlap=yes,
|
||||
Step::EndToEnd=>self.settings.end_to_end=yes,Step::Associativity=>self.settings.associativity=yes,_=>{}}
|
||||
self.step=Step::Options;
|
||||
}
|
||||
_=>{},
|
||||
}
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalized(entity:&EntityType, ignore:u16) -> EntityType {
|
||||
let mut e=entity.clone();
|
||||
let common=e.common_mut();
|
||||
let default=acadrust::entities::EntityCommon::new();
|
||||
common.handle=Handle::NULL;
|
||||
if ignore&1!=0 {common.color=default.color;common.color_name=None;common.color_book_handle=None;}
|
||||
if ignore&2!=0 {common.layer.clear();}
|
||||
if ignore&4!=0 {common.linetype.clear();common.linetype_handle=None;}
|
||||
if ignore&8!=0 {common.linetype_scale=1.0;}
|
||||
if ignore&16!=0 {common.line_weight=default.line_weight;}
|
||||
if ignore&64!=0 {common.transparency=default.transparency;}
|
||||
if ignore&128!=0 {common.plotstyle_flags=0;common.plotstyle_handle=None;}
|
||||
if ignore&256!=0 {common.material_flags=0;common.material_handle=None;}
|
||||
if ignore&32!=0 {match &mut e {EntityType::Line(v)=>v.thickness=0.0,
|
||||
EntityType::Circle(v)=>v.thickness=0.0,EntityType::Arc(v)=>v.thickness=0.0,
|
||||
EntityType::LwPolyline(v)=>v.thickness=0.0,_=>{}}}
|
||||
e
|
||||
}
|
||||
|
||||
pub fn optimize(entity:&mut EntityType, tolerance:f64) {
|
||||
if let EntityType::LwPolyline(p)=entity {
|
||||
// Width changes and bulged spans carry geometry that must be retained.
|
||||
if p.vertices.iter().any(|v|v.bulge!=0.0||v.start_width!=0.0||v.end_width!=0.0) {return;}
|
||||
let points:Vec<_>=p.vertices.iter().map(|v|[v.location.x,v.location.y,p.elevation]).collect();
|
||||
let indices=cadkernel::space::simplify_linear_chain(&points,tolerance);
|
||||
if indices.len()>=if p.is_closed{3}else{2} {
|
||||
p.vertices=indices.into_iter().map(|i|p.vertices[i].clone()).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{Line, LwPolyline};
|
||||
use acadrust::types::{Color, Vector2};
|
||||
|
||||
#[test]
|
||||
fn normalization_ignores_only_the_requested_properties() {
|
||||
let mut first = Line::from_coords(0.0, 0.0, 0.0, 2.0, 0.0, 0.0);
|
||||
first.common.handle = Handle::new(1);
|
||||
first.common.layer = "FIRST".into();
|
||||
first.common.color = Color::from_index(1);
|
||||
let mut second = first.clone();
|
||||
second.common.handle = Handle::new(2);
|
||||
second.common.layer = "SECOND".into();
|
||||
second.common.color = Color::from_index(2);
|
||||
|
||||
assert_ne!(normalized(&EntityType::Line(first.clone()), 0), normalized(&EntityType::Line(second.clone()), 0));
|
||||
assert_eq!(normalized(&EntityType::Line(first), 1 | 2), normalized(&EntityType::Line(second), 1 | 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_optimization_removes_only_plain_collinear_vertices() {
|
||||
let mut plain = EntityType::LwPolyline(LwPolyline::from_points(vec![
|
||||
Vector2::new(0.0, 0.0),
|
||||
Vector2::new(1.0, 0.0),
|
||||
Vector2::new(2.0, 0.0),
|
||||
]));
|
||||
optimize(&mut plain, 1e-9);
|
||||
let EntityType::LwPolyline(plain) = plain else { unreachable!() };
|
||||
assert_eq!(plain.vertices.len(), 2);
|
||||
|
||||
let mut styled = LwPolyline::from_points(vec![
|
||||
Vector2::new(0.0, 0.0),
|
||||
Vector2::new(1.0, 0.0),
|
||||
Vector2::new(2.0, 0.0),
|
||||
]);
|
||||
styled.vertices[1].start_width = 1.0;
|
||||
let mut styled = EntityType::LwPolyline(styled);
|
||||
optimize(&mut styled, 1e-9);
|
||||
let EntityType::LwPolyline(styled) = styled else { unreachable!() };
|
||||
assert_eq!(styled.vertices.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -433,7 +433,7 @@ impl CadCommand for PeditCommand {
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
!matches!(self.mode, Mode::PickTarget | Mode::MeshVertexMove(_) | Mode::PolyMove(_) | Mode::PolyInsert(_) | Mode::PolyTangent(_))
|
||||
!matches!(self.mode, Mode::PickTarget | Mode::MeshVertexMove(_) | Mode::PolyMove(_) | Mode::PolyInsert(_))
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
@@ -973,7 +973,7 @@ pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool {
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
PeditOp::SetWidth(w) => match entity {
|
||||
PeditOp::SetWidth(w) if w.is_finite() && *w >= 0.0 => match entity {
|
||||
EntityType::LwPolyline(p) => {
|
||||
p.constant_width = *w;
|
||||
for v in &mut p.vertices {
|
||||
@@ -982,8 +982,18 @@ pub fn apply_pedit(entity: &mut EntityType, op: &PeditOp) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
EntityType::Polyline2D(p) => {
|
||||
p.start_width = *w;
|
||||
p.end_width = *w;
|
||||
for vertex in &mut p.vertices {
|
||||
vertex.start_width = *w;
|
||||
vertex.end_width = *w;
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
PeditOp::SetWidth(_) => false,
|
||||
PeditOp::Fit | PeditOp::FitWithTangents(_) => {
|
||||
let overrides = match op { PeditOp::FitWithTangents(values) => values.as_slice(), _ => &[] };
|
||||
let Some(fitted) = fit_entity(entity, overrides) else { return false; };
|
||||
@@ -1337,6 +1347,228 @@ fn spline_smooth(p: &mut acadrust::LwPolyline) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod editing_tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{Polyline2D, Vertex2D};
|
||||
|
||||
fn lightweight(handle: u64, points: &[[f64; 2]]) -> EntityType {
|
||||
let mut polyline = acadrust::LwPolyline::new();
|
||||
polyline.common.handle = Handle::new(handle);
|
||||
polyline.vertices = points
|
||||
.iter()
|
||||
.map(|point| LwVertex::new(Vector2::new(point[0], point[1])))
|
||||
.collect();
|
||||
EntityType::LwPolyline(polyline)
|
||||
}
|
||||
|
||||
fn command(entities: Vec<EntityType>, preselected: &[Handle]) -> PeditCommand {
|
||||
let info = entities
|
||||
.iter()
|
||||
.map(|entity| {
|
||||
(
|
||||
entity.common().handle.value(),
|
||||
PeditTarget {
|
||||
is_poly: matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)),
|
||||
convertible: matches!(entity, EntityType::Line(_) | EntityType::Arc(_)),
|
||||
mesh_size: None,
|
||||
mesh_closed: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
PeditCommand::new(info, 0, 6, 6)
|
||||
.with_preselection(preselected)
|
||||
.with_entities(entities)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_accepts_a_typed_angle_and_forwards_it_to_fit() {
|
||||
let handle = Handle::new(1);
|
||||
let mut command = command(
|
||||
vec![lightweight(1, &[[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]])],
|
||||
&[handle],
|
||||
);
|
||||
assert!(matches!(command.on_text_input("E"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(command.on_text_input("T"), Some(CmdResult::NeedPoint)));
|
||||
assert!(command.input_kind().wants_text());
|
||||
assert!(matches!(command.on_text_input("90"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(command.on_text_input("X"), Some(CmdResult::NeedPoint)));
|
||||
|
||||
let Some(CmdResult::PeditOp { op: PeditOp::FitWithTangents(values), .. }) =
|
||||
command.on_text_input("F")
|
||||
else {
|
||||
panic!("expected the tangent constraint to be forwarded to Fit");
|
||||
};
|
||||
assert_eq!(values.len(), 1);
|
||||
assert_eq!(values[0].0, 0);
|
||||
assert!((values[0].1 - std::f64::consts::FRAC_PI_2).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_width_applies_to_both_polyline_representations() {
|
||||
let mut heavy = Polyline2D::new();
|
||||
heavy.start_width = 1.0;
|
||||
heavy.end_width = 2.0;
|
||||
heavy.vertices = vec![
|
||||
Vertex2D::new(Vector3::new(0.0, 0.0, 0.0)),
|
||||
Vertex2D::new(Vector3::new(1.0, 0.0, 0.0)),
|
||||
];
|
||||
let mut entity = EntityType::Polyline2D(heavy);
|
||||
assert!(apply_pedit(&mut entity, &PeditOp::SetWidth(3.5)));
|
||||
let EntityType::Polyline2D(polyline) = &entity else { unreachable!() };
|
||||
assert_eq!((polyline.start_width, polyline.end_width), (3.5, 3.5));
|
||||
assert!(polyline
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|vertex| vertex.start_width == 3.5 && vertex.end_width == 3.5));
|
||||
assert!(!apply_pedit(&mut entity, &PeditOp::SetWidth(f64::NAN)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_ranges_preserve_complete_endpoint_records() {
|
||||
let mut entity = lightweight(
|
||||
7,
|
||||
&[[0.0, 0.0], [1.0, 0.0], [2.0, 1.0], [3.0, 1.0], [4.0, 0.0]],
|
||||
);
|
||||
let EntityType::LwPolyline(polyline) = &mut entity else { unreachable!() };
|
||||
for (index, vertex) in polyline.vertices.iter_mut().enumerate() {
|
||||
vertex.bulge = index as f64 / 10.0;
|
||||
vertex.start_width = index as f64 + 0.25;
|
||||
vertex.end_width = index as f64 + 0.75;
|
||||
}
|
||||
|
||||
let split = edit_vertex_range(&entity, 1, 3, true).unwrap();
|
||||
assert_eq!(split.len(), 2);
|
||||
let EntityType::LwPolyline(first) = &split[0] else { unreachable!() };
|
||||
let EntityType::LwPolyline(second) = &split[1] else { unreachable!() };
|
||||
assert_eq!(first.vertices.len(), 2);
|
||||
assert_eq!(second.vertices.len(), 2);
|
||||
assert_eq!(first.vertices[1].end_width, 1.75);
|
||||
assert_eq!(second.vertices[0].bulge, 0.3);
|
||||
assert_eq!(second.vertices[0].start_width, 3.25);
|
||||
|
||||
let straightened = edit_vertex_range(&entity, 1, 3, false).unwrap();
|
||||
let EntityType::LwPolyline(straightened) = &straightened[0] else { unreachable!() };
|
||||
assert_eq!(straightened.vertices.len(), 4);
|
||||
assert_eq!(straightened.vertices[1].bulge, 0.0);
|
||||
assert_eq!(straightened.vertices[1].start_width, 1.25);
|
||||
assert_eq!(straightened.vertices[1].end_width, 1.75);
|
||||
assert!(edit_vertex_range(&entity, 0, 0, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_and_decurve_preserve_plane_identity_and_uniform_width() {
|
||||
let mut entity = lightweight(9, &[[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]]);
|
||||
let EntityType::LwPolyline(polyline) = &mut entity else { unreachable!() };
|
||||
polyline.constant_width = 2.0;
|
||||
polyline.elevation = 4.0;
|
||||
polyline.thickness = -1.5;
|
||||
polyline.normal = Vector3::new(0.0, 0.0, 1.0);
|
||||
|
||||
assert!(apply_pedit(
|
||||
&mut entity,
|
||||
&PeditOp::FitWithTangents(vec![(0, 0.0)])
|
||||
));
|
||||
let EntityType::Polyline2D(fitted) = &entity else { panic!("Fit must use the legacy fitted representation") };
|
||||
assert_eq!(fitted.common.handle, Handle::new(9));
|
||||
assert_eq!(fitted.elevation, 4.0);
|
||||
assert_eq!(fitted.thickness, -1.5);
|
||||
assert!(fitted.vertices.len() > 3);
|
||||
assert!(fitted.vertices[0].flags.bits() & 2 != 0);
|
||||
|
||||
assert!(apply_pedit(&mut entity, &PeditOp::Decurve));
|
||||
let EntityType::LwPolyline(straight) = &entity else { panic!("Decurve must restore a lightweight polyline") };
|
||||
assert_eq!(straight.common.handle, Handle::new(9));
|
||||
assert_eq!(straight.constant_width, 2.0);
|
||||
assert_eq!(straight.vertices.len(), 3);
|
||||
assert!(straight.vertices.iter().all(|vertex| vertex.bulge == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_decline_and_fuzz_join_keep_the_command_active() {
|
||||
let mut line = acadrust::entities::Line::new();
|
||||
line.common.handle = Handle::new(11);
|
||||
let mut arc = acadrust::entities::Arc::new();
|
||||
arc.common.handle = Handle::new(12);
|
||||
let mut declined = command(
|
||||
vec![EntityType::Line(line), EntityType::Arc(arc)],
|
||||
&[Handle::new(11), Handle::new(12)],
|
||||
);
|
||||
assert!(matches!(declined.on_text_input("N"), Some(CmdResult::NeedPoint)));
|
||||
assert!(declined.needs_entity_pick());
|
||||
|
||||
let handles = [Handle::new(21), Handle::new(22)];
|
||||
let mut joined = command(
|
||||
vec![
|
||||
lightweight(21, &[[0.0, 0.0], [1.0, 0.0]]),
|
||||
lightweight(22, &[[1.0, 0.0], [2.0, 0.0]]),
|
||||
],
|
||||
&handles,
|
||||
);
|
||||
assert!(matches!(joined.on_text_input("J"), Some(CmdResult::NeedPoint)));
|
||||
let Some(CmdResult::PeditOp {
|
||||
op: PeditOp::JoinSelection(selected, fuzz),
|
||||
..
|
||||
}) = joined.on_text_input("0.25")
|
||||
else {
|
||||
panic!("expected a source-directed multiple join");
|
||||
};
|
||||
assert_eq!(selected, handles);
|
||||
assert_eq!(fuzz, 0.25);
|
||||
|
||||
let mut remembered = command(
|
||||
vec![
|
||||
lightweight(21, &[[0.0, 0.0], [1.0, 0.0]]),
|
||||
lightweight(22, &[[1.0, 0.0], [2.0, 0.0]]),
|
||||
],
|
||||
&handles,
|
||||
);
|
||||
assert!(matches!(
|
||||
remembered.on_text_input("J"),
|
||||
Some(CmdResult::NeedPoint)
|
||||
));
|
||||
assert!(remembered.prompt().contains("<0.25>"));
|
||||
*JOIN_FUZZ.lock().unwrap_or_else(|error| error.into_inner()) = 0.0;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_fuzz_extends_only_supported_straight_terminals_within_the_limit() {
|
||||
let source = lightweight(31, &[[0.0, 0.0], [1.0, 0.0]]);
|
||||
let candidate = lightweight(32, &[[2.0, 2.0], [2.0, 1.0]]);
|
||||
let candidates = [(Handle::new(32), &candidate)];
|
||||
|
||||
assert!(join_selection_extend(&source, &candidates, 0.99).is_none());
|
||||
let (joined, consumed) = join_selection_extend(&source, &candidates, 1.0).unwrap();
|
||||
assert_eq!(consumed, vec![Handle::new(32)]);
|
||||
let EntityType::LwPolyline(joined) = joined else {
|
||||
panic!("expected a lightweight polyline");
|
||||
};
|
||||
assert_eq!(joined.common.handle, Handle::new(31));
|
||||
let points: Vec<_> = joined
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|vertex| [vertex.location.x, vertex.location.y])
|
||||
.collect();
|
||||
assert_eq!(points, vec![[0.0, 0.0], [2.0, 0.0], [2.0, 2.0]]);
|
||||
|
||||
let mut curved = source.clone();
|
||||
let EntityType::LwPolyline(polyline) = &mut curved else {
|
||||
unreachable!()
|
||||
};
|
||||
polyline.vertices[0].bulge = 0.25;
|
||||
assert!(join_selection_extend(&curved, &candidates, 2.0).is_none());
|
||||
|
||||
let parallel = lightweight(33, &[[2.0, 1.0], [1.0, 1.0]]);
|
||||
assert!(join_selection_extend(
|
||||
&source,
|
||||
&[(Handle::new(33), ¶llel)],
|
||||
2.0,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["PEDIT"] }); // PeditCommand
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
// Any other entity type is left untouched: the command returns
|
||||
// `CmdResult::NeedPoint` and keeps prompting so nothing is corrupted.
|
||||
|
||||
use crate::t;
|
||||
use acadrust::entities::Spline;
|
||||
use acadrust::{EntityType, Handle};
|
||||
use glam::DVec3;
|
||||
use crate::t;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
@@ -54,7 +54,7 @@ impl ReverseCommand {
|
||||
}
|
||||
|
||||
/// Build a reversed copy of `entity`, or `None` for an unsupported type.
|
||||
pub fn reversed(entity: &EntityType) -> Option<EntityType> {
|
||||
pub(crate) fn reversed(entity: &EntityType) -> Option<EntityType> {
|
||||
match entity {
|
||||
EntityType::Line(line) => {
|
||||
let mut out = line.clone();
|
||||
@@ -62,9 +62,7 @@ impl ReverseCommand {
|
||||
Some(EntityType::Line(out))
|
||||
}
|
||||
EntityType::LwPolyline(pl) => Some(EntityType::LwPolyline(reverse_lwpolyline(pl))),
|
||||
EntityType::Polyline2D(pl) => {
|
||||
Some(EntityType::Polyline2D(reverse_polyline2d(pl)))
|
||||
}
|
||||
EntityType::Polyline2D(pl) => Some(EntityType::Polyline2D(reverse_polyline2d(pl))),
|
||||
EntityType::Polyline(pl) => {
|
||||
let mut out = pl.clone();
|
||||
out.vertices.reverse();
|
||||
@@ -76,6 +74,9 @@ impl ReverseCommand {
|
||||
Some(EntityType::Polyline3D(out))
|
||||
}
|
||||
EntityType::Spline(sp) => Some(EntityType::Spline(reverse_spline(sp))),
|
||||
EntityType::Helix(helix) => {
|
||||
crate::entities::helix::reversed(helix).map(EntityType::Helix)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -153,8 +154,8 @@ fn reverse_polyline2d(pl: &acadrust::entities::Polyline2D) -> acadrust::entities
|
||||
for vertex in &mut out.vertices {
|
||||
let tangent_flag = acadrust::entities::VertexFlags::CURVE_FIT_TANGENT.bits();
|
||||
if vertex.flags.bits() & tangent_flag != 0 {
|
||||
vertex.curve_tangent = (vertex.curve_tangent + std::f64::consts::PI)
|
||||
.rem_euclid(std::f64::consts::TAU);
|
||||
vertex.curve_tangent =
|
||||
(vertex.curve_tangent + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU);
|
||||
}
|
||||
}
|
||||
if pl.is_closed() {
|
||||
@@ -176,9 +177,8 @@ fn reverse_polyline2d(pl: &acadrust::entities::Polyline2D) -> acadrust::entities
|
||||
out
|
||||
}
|
||||
|
||||
/// Reverse a Spline: flip control points and fit points, regenerate the
|
||||
/// clamped knot vector. Mirrors `splinedit::apply_spline_op`'s REVERSE branch.
|
||||
fn reverse_spline(sp: &Spline) -> Spline {
|
||||
/// Reverse stored coordinates in 3D and let the kernel mirror the knot domain.
|
||||
pub(super) fn reverse_spline(sp: &Spline) -> Spline {
|
||||
let mut out = sp.clone();
|
||||
out.control_points.reverse();
|
||||
out.fit_points.reverse();
|
||||
@@ -186,8 +186,16 @@ fn reverse_spline(sp: &Spline) -> Spline {
|
||||
if out.weights.len() == out.control_points.len() {
|
||||
out.weights.reverse();
|
||||
}
|
||||
out.knots =
|
||||
Spline::generate_clamped_knots(out.degree as usize, out.control_points.len());
|
||||
if let Some(curve) = super::spline_ops::spline_to_nurbs(sp) {
|
||||
out.knots = curve.reversed().knots().to_vec();
|
||||
}
|
||||
out.begin_tangent =
|
||||
acadrust::types::Vector3::new(-sp.end_tangent.x, -sp.end_tangent.y, -sp.end_tangent.z);
|
||||
out.end_tangent = acadrust::types::Vector3::new(
|
||||
-sp.begin_tangent.x,
|
||||
-sp.begin_tangent.y,
|
||||
-sp.begin_tangent.z,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
@@ -197,7 +205,7 @@ impl CadCommand for ReverseCommand {
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
t!("REVERSE Select line, polyline or spline to reverse:").into_owned()
|
||||
t!("REVERSE Select line, polyline, spline or helix to reverse:").into_owned()
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
@@ -240,3 +248,47 @@ impl CadCommand for ReverseCommand {
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["REVERSE"]
|
||||
}); // ReverseCommand
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::types::Vector3;
|
||||
|
||||
#[test]
|
||||
fn spline_reversal_preserves_knot_spacing_weights_and_tangents() {
|
||||
let mut spline = Spline::new();
|
||||
spline.degree = 2;
|
||||
spline.control_points = vec![
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(1.0, 2.0, 0.0),
|
||||
Vector3::new(3.0, 1.0, 1.0),
|
||||
Vector3::new(4.0, 0.0, 2.0),
|
||||
];
|
||||
spline.knots = vec![0.0, 0.0, 0.0, 0.25, 1.0, 1.0, 1.0];
|
||||
spline.weights = vec![1.0, 2.0, 3.0, 4.0];
|
||||
spline.begin_tangent = Vector3::new(1.0, 2.0, 3.0);
|
||||
spline.end_tangent = Vector3::new(4.0, 5.0, 6.0);
|
||||
|
||||
let reversed = reverse_spline(&spline);
|
||||
assert_eq!(
|
||||
reversed.control_points,
|
||||
spline
|
||||
.control_points
|
||||
.iter()
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(reversed.weights, vec![4.0, 3.0, 2.0, 1.0]);
|
||||
assert_eq!(reversed.knots, vec![0.0, 0.0, 0.0, 0.75, 1.0, 1.0, 1.0]);
|
||||
assert_eq!(reversed.begin_tangent, Vector3::new(-4.0, -5.0, -6.0));
|
||||
assert_eq!(reversed.end_tangent, Vector3::new(-1.0, -2.0, -3.0));
|
||||
|
||||
let restored = reverse_spline(&reversed);
|
||||
assert_eq!(restored.control_points, spline.control_points);
|
||||
assert_eq!(restored.weights, spline.weights);
|
||||
assert_eq!(restored.knots, spline.knots);
|
||||
assert_eq!(restored.begin_tangent, spline.begin_tangent);
|
||||
assert_eq!(restored.end_tangent, spline.end_tangent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use acadrust::Handle;
|
||||
use glam::DVec3;
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult};
|
||||
|
||||
static MODE: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(255);
|
||||
pub fn mode() -> u8 { MODE.load(std::sync::atomic::Ordering::Relaxed) }
|
||||
pub fn set_mode(value: u8) { MODE.store(value, std::sync::atomic::Ordering::Relaxed); }
|
||||
|
||||
pub fn apply_mask(common: &mut acadrust::entities::EntityCommon, mask: u8, change_byblock: bool) -> bool {
|
||||
let before = common.clone();
|
||||
if mask & 1 != 0 && (change_byblock || common.color != acadrust::types::Color::ByBlock) {
|
||||
common.color = acadrust::types::Color::ByLayer;
|
||||
common.color_name = None;
|
||||
common.color_book_handle = None;
|
||||
}
|
||||
if mask & 2 != 0 && (change_byblock || !common.linetype.eq_ignore_ascii_case("ByBlock")) {
|
||||
common.linetype = "ByLayer".to_string();
|
||||
common.linetype_handle = None;
|
||||
}
|
||||
if mask & 4 != 0 && (change_byblock || common.line_weight != acadrust::types::LineWeight::ByBlock) {
|
||||
common.line_weight = acadrust::types::LineWeight::ByLayer;
|
||||
}
|
||||
if mask & 8 != 0 && (change_byblock || common.material_flags != 1) {
|
||||
common.material_flags = 0;
|
||||
common.material_handle = None;
|
||||
}
|
||||
if mask & 16 != 0 && (change_byblock || common.plotstyle_flags != 1) {
|
||||
common.plotstyle_flags = 0;
|
||||
common.plotstyle_handle = None;
|
||||
}
|
||||
if mask & 128 != 0 && (change_byblock || common.transparency != acadrust::types::Transparency::BY_BLOCK) {
|
||||
common.transparency = acadrust::types::Transparency::BY_LAYER;
|
||||
}
|
||||
*common != before
|
||||
}
|
||||
|
||||
enum Step { Selection, ByBlock, Blocks }
|
||||
|
||||
pub struct SetByLayerCommand {
|
||||
selected: Vec<Handle>,
|
||||
step: Step,
|
||||
change_byblock: bool,
|
||||
include_blocks: bool,
|
||||
}
|
||||
|
||||
impl SetByLayerCommand {
|
||||
pub fn new(selected: Vec<Handle>) -> Self {
|
||||
let step = if selected.is_empty() { Step::Selection } else { Step::ByBlock };
|
||||
Self { selected, step, change_byblock: mode() & 32 != 0, include_blocks: mode() & 64 != 0 }
|
||||
}
|
||||
|
||||
fn answer(&mut self, yes: bool) -> CmdResult {
|
||||
match self.step {
|
||||
Step::ByBlock => {
|
||||
self.change_byblock = yes;
|
||||
self.step = Step::Blocks;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Blocks => {
|
||||
let flags = (mode() & !(32 | 64)) | if self.change_byblock { 32 } else { 0 } | if yes { 64 } else { 0 };
|
||||
set_mode(flags);
|
||||
CmdResult::Relaunch(
|
||||
format!("SETBYLAYER_APPLY {} {}", u8::from(self.change_byblock), u8::from(yes)),
|
||||
self.selected.clone(),
|
||||
) },
|
||||
Step::Selection => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for SetByLayerCommand {
|
||||
fn name(&self) -> &'static str { "SETBYLAYER" }
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
Step::Selection => "Select objects:".into(),
|
||||
Step::ByBlock => format!("Change ByBlock to ByLayer? [Yes/No] <{}>:", if self.change_byblock { "Yes" } else { "No" }),
|
||||
Step::Blocks => format!("Include blocks? [Yes/No] <{}>:", if self.include_blocks { "Yes" } else { "No" }),
|
||||
}
|
||||
}
|
||||
fn is_selection_gathering(&self) -> bool { matches!(self.step, Step::Selection) }
|
||||
fn on_selection_complete(&mut self, handles: Vec<Handle>) -> CmdResult {
|
||||
self.selected = handles;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
if self.is_selection_gathering() { Vec::new() }
|
||||
else { vec![CmdOption::new("Yes", "Y"), CmdOption::new("No", "N")] }
|
||||
}
|
||||
fn wants_text_input(&self) -> bool { !self.is_selection_gathering() }
|
||||
fn on_point(&mut self, _: DVec3) -> CmdResult { CmdResult::NeedPoint }
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.is_selection_gathering() {
|
||||
if self.selected.is_empty() { CmdResult::Cancel }
|
||||
else { self.step = Step::ByBlock; CmdResult::NeedPoint }
|
||||
} else { self.answer(if matches!(self.step, Step::ByBlock) { self.change_byblock } else { self.include_blocks }) }
|
||||
}
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
Some(match text.trim().to_uppercase().as_str() {
|
||||
"Y" | "YES" => self.answer(true),
|
||||
"N" | "NO" => self.answer(false),
|
||||
_ => CmdResult::NeedPoint,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ModeCommand;
|
||||
impl CadCommand for ModeCommand {
|
||||
fn name(&self) -> &'static str { "SETBYLAYERMODE" }
|
||||
fn prompt(&self) -> String { format!("Enter new value for SETBYLAYERMODE <{}>:", mode()) }
|
||||
fn wants_text_input(&self) -> bool { true }
|
||||
fn on_point(&mut self, _: DVec3) -> CmdResult { CmdResult::NeedPoint }
|
||||
fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel }
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
Some(if let Ok(value) = text.trim().parse::<u8>() {
|
||||
set_mode(value); CmdResult::Cancel
|
||||
} else { CmdResult::NeedPoint })
|
||||
}
|
||||
}
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["SETBYLAYERMODE"] });
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::EntityCommon;
|
||||
use acadrust::types::{Color, Transparency};
|
||||
|
||||
#[test]
|
||||
fn mask_changes_only_requested_properties() {
|
||||
let mut common = EntityCommon::default();
|
||||
common.color = Color::from_index(3);
|
||||
common.linetype = "DASHED".into();
|
||||
common.transparency = Transparency::from_percent(40.0);
|
||||
|
||||
assert!(apply_mask(&mut common, 1 | 128, false));
|
||||
assert_eq!(common.color, Color::ByLayer);
|
||||
assert_eq!(common.linetype, "DASHED");
|
||||
assert_eq!(common.transparency, Transparency::BY_LAYER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byblock_values_are_kept_unless_requested() {
|
||||
let mut common = EntityCommon::default();
|
||||
common.color = Color::ByBlock;
|
||||
common.linetype = "ByBlock".into();
|
||||
common.transparency = Transparency::BY_BLOCK;
|
||||
|
||||
assert!(!apply_mask(&mut common, 1 | 2 | 128, false));
|
||||
assert!(apply_mask(&mut common, 1 | 2 | 128, true));
|
||||
assert_eq!(common.color, Color::ByLayer);
|
||||
assert_eq!(common.linetype, "ByLayer");
|
||||
assert_eq!(common.transparency, Transparency::BY_LAYER);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::modules::draw::modify::spline_ops::spline_to_nurbs;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
@@ -29,209 +28,765 @@ pub fn tool() -> ToolDef {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Step {
|
||||
/// Waiting for the user to pick a spline entity.
|
||||
SelectSpline,
|
||||
/// Spline selected; waiting for sub-command text input.
|
||||
SubCommand { handle: acadrust::Handle },
|
||||
Options,
|
||||
Refine,
|
||||
Join,
|
||||
PolylinePrecision,
|
||||
Add,
|
||||
Delete,
|
||||
Elevate,
|
||||
Move { index: usize, refine: bool },
|
||||
Weight { index: usize },
|
||||
SelectVertex { weight: bool, refine: bool },
|
||||
}
|
||||
|
||||
pub struct SplineditCommand {
|
||||
step: Step,
|
||||
handle: acadrust::Handle,
|
||||
spline: Option<acadrust::entities::Spline>,
|
||||
pending: Option<acadrust::entities::Spline>,
|
||||
history: Vec<(acadrust::Handle, acadrust::entities::Spline)>,
|
||||
pick_context: Option<crate::command::PointPickContext>,
|
||||
join_candidates: Vec<crate::command::SelectionEntity>,
|
||||
delete_source: bool,
|
||||
}
|
||||
|
||||
fn clear_fit_method(spline: &mut acadrust::entities::Spline) {
|
||||
spline.fit_points.clear();
|
||||
spline.begin_tangent = Vector3::ZERO;
|
||||
spline.end_tangent = Vector3::ZERO;
|
||||
spline.dwg_flags1 &= !1;
|
||||
spline.dxf_flags &= !(32 | 1024);
|
||||
}
|
||||
|
||||
fn weights_are_rational(weights: &[f64]) -> bool {
|
||||
weights.first().is_some_and(|first| {
|
||||
weights
|
||||
.iter()
|
||||
.any(|weight| (*weight - *first).abs() > 1e-12)
|
||||
})
|
||||
}
|
||||
|
||||
impl SplineditCommand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
step: Step::SelectSpline,
|
||||
Self { step: Step::SelectSpline, handle: acadrust::Handle::NULL, spline: None, pending: None, history: Vec::new(), pick_context: None, join_candidates: Vec::new(), delete_source: true }
|
||||
}
|
||||
|
||||
pub fn with_delete_source(mut self, delete: bool) -> Self { self.delete_source = delete; self }
|
||||
|
||||
fn convert_polyline(&mut self, precision: u8) -> CmdResult {
|
||||
let result = self.spline.as_ref().and_then(|source| {
|
||||
let curve = spatial_spline(source)?;
|
||||
let approximation = curve.to_polyline_precision(precision)?;
|
||||
let mut points = approximation.points;
|
||||
if self.closed() {
|
||||
if points.first() != points.last() { return None; }
|
||||
points.pop();
|
||||
}
|
||||
if points.len() < 2 { return None; }
|
||||
let entity = if let Some(planar) = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone())) {
|
||||
let normal = planar.plane.normal()?;
|
||||
let elevation = cadkernel::space::Vec3::from(points[0]).dot(cadkernel::space::Vec3::from(normal));
|
||||
let normal = Vector3::new(normal[0], normal[1], normal[2]);
|
||||
let plane = crate::entities::curve::ocs_plane(normal.clone(), elevation);
|
||||
let mut polyline = acadrust::LwPolyline::new();
|
||||
polyline.common = source.common.clone();
|
||||
polyline.elevation = elevation; polyline.normal = normal; polyline.is_closed = self.closed();
|
||||
polyline.vertices = points.iter().map(|point| {
|
||||
let uv = plane.project(*point)?;
|
||||
Some(acadrust::entities::LwVertex::new(acadrust::types::Vector2::new(uv[0], uv[1])))
|
||||
}).collect::<Option<Vec<_>>>()?;
|
||||
EntityType::LwPolyline(polyline)
|
||||
} else {
|
||||
let mut polyline = acadrust::entities::Polyline3D::from_points(points.iter().map(|p| Vector3::new(p[0],p[1],p[2])).collect());
|
||||
polyline.common = source.common.clone(); polyline.flags.closed = self.closed();
|
||||
EntityType::Polyline3D(polyline)
|
||||
};
|
||||
Some(entity)
|
||||
});
|
||||
let Some(mut entity) = result else { return CmdResult::ReportError(crate::t!("Spline cannot be converted within the requested precision.").into_owned()); };
|
||||
if self.delete_source { CmdResult::ReplaceMany(vec![(self.handle,vec![entity])], Vec::new()) }
|
||||
else { entity.common_mut().handle = acadrust::Handle::NULL; CmdResult::ReplaceMany(Vec::new(), vec![entity]) }
|
||||
}
|
||||
|
||||
fn picked_vertex(&self, point: DVec3) -> Option<usize> {
|
||||
let context = self.pick_context?;
|
||||
let project = |point: DVec3| {
|
||||
let clip = context.view * (point - context.eye).as_vec3().extend(1.0);
|
||||
if !clip.is_finite() || clip.w <= 0.0 { return None; }
|
||||
let screen = crate::scene::pick::hit_test::world_to_screen(
|
||||
point, context.view, context.eye, context.bounds);
|
||||
(screen.x.is_finite() && screen.y.is_finite()
|
||||
&& screen.x >= 0.0 && screen.x <= context.bounds.width
|
||||
&& screen.y >= 0.0 && screen.y <= context.bounds.height).then_some(screen)
|
||||
};
|
||||
let cursor = project(point)?;
|
||||
self.spline.as_ref()?.control_points.iter().enumerate().filter_map(|(index, vertex)| {
|
||||
let screen = project(DVec3::new(vertex.x, vertex.y, vertex.z))?;
|
||||
let distance = (screen.x - cursor.x).hypot(screen.y - cursor.y);
|
||||
(distance.is_finite() && distance <= context.aperture_px).then_some((index, distance))
|
||||
}).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(index, _)| index)
|
||||
}
|
||||
|
||||
fn closed(&self) -> bool {
|
||||
self.spline.as_ref().is_some_and(|spline| spline.flags.closed || spline.flags.periodic)
|
||||
}
|
||||
|
||||
fn replace(&mut self, spline: acadrust::entities::Spline) -> CmdResult {
|
||||
self.pending = Some(spline.clone());
|
||||
CmdResult::ReplaceManyContinue(vec![(self.handle, vec![EntityType::Spline(spline)])])
|
||||
}
|
||||
|
||||
fn finish_join(&mut self) -> CmdResult {
|
||||
self.step = Step::Options;
|
||||
let candidates = std::mem::take(&mut self.join_candidates);
|
||||
let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; };
|
||||
let selected: Vec<_> = candidates.iter().filter(|item| item.handle != self.handle)
|
||||
.map(|item| (item.handle, &item.entity)).collect();
|
||||
let Some((EntityType::Spline(mut spline), consumed)) = super::join::join_to_source(
|
||||
&EntityType::Spline(source.clone()), &selected) else { return CmdResult::NeedPoint; };
|
||||
spline.dwg_flags1 &= !1;
|
||||
spline.dxf_flags &= !(32 | 1024);
|
||||
self.pending = Some(spline.clone());
|
||||
let mut replacements = vec![(self.handle, vec![EntityType::Spline(spline)])];
|
||||
replacements.extend(consumed.into_iter().map(|handle| (handle, Vec::new())));
|
||||
// The host stores one document snapshot, including every consumed curve.
|
||||
// The existing command-local Undo restores that snapshot and the source cache.
|
||||
CmdResult::ReplaceManyContinue(replacements)
|
||||
}
|
||||
|
||||
fn refined(&self, point: Option<DVec3>, degree: Option<usize>) -> Option<acadrust::entities::Spline> {
|
||||
let source = self.spline.as_ref()?;
|
||||
if let Some(degree) = degree {
|
||||
let current = usize::try_from(source.degree).ok()?;
|
||||
if degree <= current || degree > 25 { return None; }
|
||||
let curve = spatial_spline(source)?;
|
||||
let elevation = degree.checked_sub(curve.degree())?;
|
||||
let curve = if elevation == 0 { curve } else { curve.elevated(elevation)? }
|
||||
.compact_knots(source.control_tolerance.max(1e-9))?;
|
||||
let mut result = source.clone();
|
||||
result.degree = curve.degree() as i32;
|
||||
result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect();
|
||||
result.knots = curve.knots().to_vec();
|
||||
result.weights = curve.weights().to_vec();
|
||||
clear_fit_method(&mut result);
|
||||
result.flags.rational = curve.is_rational();
|
||||
result.flags.planar = crate::entities::curve::spline_is_planar(&result);
|
||||
return Some(result);
|
||||
}
|
||||
let planar = crate::entities::curve::entity_curve(&EntityType::Spline(source.clone()))?;
|
||||
let cadkernel::geom2d::Curve::Nurbs(mut curve) = planar.curve else { return None; };
|
||||
if let Some(point) = point {
|
||||
let projected = planar.plane.project([point.x, point.y, point.z])?;
|
||||
let nearest = cadkernel::geom2d::closest_point(&cadkernel::geom2d::Curve::Nurbs(curve.clone()), projected);
|
||||
let (start, end) = curve.domain();
|
||||
let parameter = start + nearest.t * (end - start);
|
||||
if parameter <= start || parameter >= end { return None; }
|
||||
curve.insert_knot(parameter);
|
||||
}
|
||||
let mut result = crate::modules::draw::modify::spline_ops::nurbs_to_spline(&curve, source);
|
||||
result.control_points = curve.control_points().iter().map(|point| {
|
||||
let world = planar.plane.point_at(*point);
|
||||
Vector3::new(world[0], world[1], world[2])
|
||||
}).collect();
|
||||
clear_fit_method(&mut result);
|
||||
result.flags.rational = curve.is_rational();
|
||||
result.flags.planar = crate::entities::curve::spline_is_planar(&result);
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for SplineditCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
"SPLINEDIT"
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str { "SPLINEDIT" }
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
match self.step {
|
||||
Step::SelectSpline => crate::t!("SPLINEDIT Select spline:").into_owned(),
|
||||
Step::SubCommand { .. } => crate::t!("SPLINEDIT [CLOSE/OPEN/REVERSE/EXIT]:").into_owned(),
|
||||
Step::Options if self.closed() => crate::t!("SPLINEDIT [Open/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] <eXit>:").into_owned(),
|
||||
Step::Options => crate::t!("SPLINEDIT [Close/Join/Move vertex/Refine/rEverse/convert to Polyline/Undo/eXit] <eXit>:").into_owned(),
|
||||
Step::PolylinePrecision => crate::t!("SPLINEDIT Specify precision 0-99 <10> (straight segments):").into_owned(),
|
||||
Step::Join => crate::t!("SPLINEDIT Select any open curves to join to source:").into_owned(),
|
||||
Step::Refine => crate::t!("SPLINEDIT [Add/Delete/Elevate order/Move/Weight/eXit] <eXit>:").into_owned(),
|
||||
Step::Add => crate::t!("SPLINEDIT Specify a point on the spline <exit>:").into_owned(),
|
||||
Step::Delete => crate::t!("SPLINEDIT Specify control vertex to delete:").into_owned(),
|
||||
Step::Elevate => format!("SPLINEDIT Enter new order <{}>:", self.spline.as_ref().map_or(4, |s| s.degree + 1)),
|
||||
Step::Move { index, .. } => format!("SPLINEDIT Vertex {}: specify new location or [Next/Previous/Select point/eXit] <Next>:", index + 1),
|
||||
Step::SelectVertex { .. } => crate::t!("SPLINEDIT Specify control vertex:").into_owned(),
|
||||
Step::Weight { index } => format!("SPLINEDIT Vertex {}: enter new weight or [Next/Previous/Select point/eXit] <Next>:", index + 1),
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_entity_pick(&self) -> bool {
|
||||
matches!(self.step, Step::SelectSpline)
|
||||
fn options(&self) -> Vec<crate::command::CmdOption> {
|
||||
use crate::command::CmdOption;
|
||||
match self.step {
|
||||
Step::Options => {
|
||||
let mut options = vec![if self.closed() { CmdOption::new("Open", "O") } else { CmdOption::new("Close", "C") }];
|
||||
if !self.closed() { options.push(CmdOption::new("Join", "J")); }
|
||||
options.extend([CmdOption::new("Move vertex", "M"), CmdOption::new("Refine", "R"), CmdOption::new("Reverse", "E"), CmdOption::new("Polyline (lines)", "P"), CmdOption::new("Undo", "U"), CmdOption::new("Exit", "X")]);
|
||||
options
|
||||
},
|
||||
Step::Refine => vec![CmdOption::new("Add", "A"), CmdOption::new("Delete", "D"), CmdOption::new("Elevate order", "E"), CmdOption::new("Move", "M"), CmdOption::new("Weight", "W"), CmdOption::new("Exit", "X")],
|
||||
Step::Move { .. } | Step::Weight { .. } => vec![CmdOption::new("Next", "N"), CmdOption::new("Previous", "P"), CmdOption::new("Select point", "S"), CmdOption::new("Exit", "X")],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
fn is_selection_gathering(&self) -> bool { matches!(self.step, Step::Join) }
|
||||
fn selection_entities_exclude_locked(&self) -> bool { matches!(self.step, Step::Join) }
|
||||
fn inject_selection_entities(&mut self, entities: Vec<crate::command::SelectionEntity>) {
|
||||
if matches!(self.step, Step::Join) { self.join_candidates = entities; }
|
||||
}
|
||||
fn on_selection_complete(&mut self, handles: Vec<acadrust::Handle>) -> CmdResult {
|
||||
self.join_candidates.retain(|item| handles.contains(&item.handle) && item.handle != self.handle);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn needs_entity_pick(&self) -> bool { matches!(self.step, Step::SelectSpline) }
|
||||
fn inject_before_entity_pick(&self) -> bool { true }
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) {
|
||||
self.spline = match entity { EntityType::Spline(spline) => Some(spline), _ => None };
|
||||
}
|
||||
|
||||
fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult {
|
||||
if handle.is_null() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
self.step = Step::SubCommand { handle };
|
||||
if handle.is_null() || self.spline.is_none() { return CmdResult::NeedPoint; }
|
||||
self.handle = handle;
|
||||
self.step = Step::Options;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.step, Step::SubCommand { .. })
|
||||
fn on_entity_replaced(&mut self, old: acadrust::Handle, new: &[acadrust::Handle]) {
|
||||
if old == self.handle {
|
||||
if let (Some(&handle), Some(replacement)) = (new.first(), self.pending.take()) {
|
||||
if let Some(previous) = self.spline.replace(replacement) { self.history.push((old, previous)); }
|
||||
self.handle = handle;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool { !matches!(self.step, Step::SelectSpline | Step::Join | Step::Add | Step::Delete | Step::Move { .. } | Step::SelectVertex { .. }) }
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let Step::SubCommand { handle } = &self.step else {
|
||||
return None;
|
||||
};
|
||||
let handle = *handle;
|
||||
match text.trim().to_uppercase().as_str() {
|
||||
"CLOSE" | "C" => Some(CmdResult::ReplaceEntity(
|
||||
handle,
|
||||
vec![SplineOp::Close.into_marker(handle)],
|
||||
)),
|
||||
"OPEN" | "O" => Some(CmdResult::ReplaceEntity(
|
||||
handle,
|
||||
vec![SplineOp::Open.into_marker(handle)],
|
||||
)),
|
||||
"REVERSE" | "R" | "REV" => Some(CmdResult::ReplaceEntity(
|
||||
handle,
|
||||
vec![SplineOp::Reverse.into_marker(handle)],
|
||||
)),
|
||||
"EXIT" | "X" | "" => Some(CmdResult::Cancel),
|
||||
_ => None,
|
||||
let upper = text.trim().to_uppercase();
|
||||
if upper.is_empty() { return Some(self.on_enter()); }
|
||||
match self.step {
|
||||
Step::Options => match upper.as_str() {
|
||||
"P" | "POLYLINE" => self.step = Step::PolylinePrecision,
|
||||
"J" | "JOIN" if !self.closed() => { self.join_candidates.clear(); self.step = Step::Join; },
|
||||
"R" | "REFINE" => self.step = Step::Refine,
|
||||
"M" | "MOVE" => self.step = Step::Move { index: 0, refine: false },
|
||||
"X" | "EXIT" => return Some(CmdResult::Cancel),
|
||||
"U" | "UNDO" => {
|
||||
if let Some((handle, spline)) = self.history.pop() {
|
||||
self.handle = handle;
|
||||
self.spline = Some(spline);
|
||||
return Some(CmdResult::UndoDocument);
|
||||
}
|
||||
}
|
||||
"C" | "CLOSE" | "O" | "OPEN" | "E" | "REVERSE" | "REV" => {
|
||||
if (matches!(upper.as_str(), "C" | "CLOSE") && self.closed())
|
||||
|| (matches!(upper.as_str(), "O" | "OPEN") && !self.closed()) {
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
let mut spline = self.spline.clone()?;
|
||||
let op = match upper.as_str() { "C" | "CLOSE" => "__SPLINEDIT_CLOSE__", "O" | "OPEN" => "__SPLINEDIT_OPEN__", _ => "__SPLINEDIT_REVERSE__" };
|
||||
apply_to_spline(&mut spline, op);
|
||||
if self.spline.as_ref() == Some(&spline) { return Some(CmdResult::NeedPoint); }
|
||||
return Some(self.replace(spline));
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Step::PolylinePrecision => {
|
||||
let Ok(precision) = upper.parse::<u8>() else { return Some(CmdResult::ReportError(crate::t!("Requires an integer between 0 and 99.").into_owned())); };
|
||||
if precision > 99 { return Some(CmdResult::ReportError(crate::t!("Requires an integer between 0 and 99.").into_owned())); }
|
||||
return Some(self.convert_polyline(precision));
|
||||
}
|
||||
Step::Refine => match upper.as_str() {
|
||||
"A" | "ADD" => self.step = Step::Add,
|
||||
"D" | "DELETE" => self.step = Step::Delete,
|
||||
"E" | "ELEVATE" => self.step = Step::Elevate,
|
||||
"M" | "MOVE" => self.step = Step::Move { index: 0, refine: true },
|
||||
"W" | "WEIGHT" => self.step = Step::Weight { index: 0 },
|
||||
"X" | "EXIT" => self.step = Step::Options,
|
||||
_ => {}
|
||||
},
|
||||
Step::Elevate => {
|
||||
let current_order = self.spline.as_ref()?.degree.max(1) as usize + 1;
|
||||
let Some(order) = upper.parse::<usize>().ok()
|
||||
.filter(|order| *order >= current_order && *order <= 26) else {
|
||||
return Some(CmdResult::NeedPoint);
|
||||
};
|
||||
if order == current_order {
|
||||
self.step = Step::Refine;
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
if let Some(spline) = self.refined(None, Some(order - 1)) {
|
||||
self.step = Step::Refine;
|
||||
return Some(self.replace(spline));
|
||||
}
|
||||
}
|
||||
Step::Move { index, .. } | Step::Weight { index } => {
|
||||
let count = self.spline.as_ref().map_or(0, |s| s.control_points.len());
|
||||
if count == 0 { return Some(CmdResult::NeedPoint); }
|
||||
let next = match upper.as_str() { "N" | "NEXT" => Some((index + 1) % count), "P" | "PREVIOUS" => Some((index + count - 1) % count), _ => None };
|
||||
if let Some(next) = next {
|
||||
self.step = match self.step { Step::Move { refine, .. } => Step::Move { index: next, refine }, _ => Step::Weight { index: next } };
|
||||
} else if matches!(upper.as_str(), "S" | "SELECT") {
|
||||
self.step = match self.step {
|
||||
Step::Move { refine, .. } => Step::SelectVertex { weight: false, refine },
|
||||
_ => Step::SelectVertex { weight: true, refine: true },
|
||||
};
|
||||
} else if matches!(upper.as_str(), "X" | "EXIT") {
|
||||
self.step = match self.step { Step::Move { refine: false, .. } => Step::Options, _ => Step::Refine };
|
||||
} else if matches!(self.step, Step::Weight { .. }) {
|
||||
if let Some(weight) = upper.replace(',', ".").parse::<f64>().ok().filter(|weight| weight.is_finite() && *weight > 0.0) {
|
||||
let mut spline = self.spline.clone()?;
|
||||
spline.weights.resize(count, 1.0);
|
||||
spline.weights[index] = weight;
|
||||
spline.flags.rational = weights_are_rational(&spline.weights);
|
||||
clear_fit_method(&mut spline);
|
||||
return Some(self.replace(spline));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
fn wants_point_pick_context(&self) -> bool {
|
||||
matches!(self.step, Step::Delete | Step::SelectVertex { .. })
|
||||
}
|
||||
fn set_point_pick_context(&mut self, context: Option<crate::command::PointPickContext>) {
|
||||
self.pick_context = context;
|
||||
}
|
||||
fn on_point(&mut self, point: DVec3) -> CmdResult {
|
||||
if !point.is_finite() { return CmdResult::NeedPoint; }
|
||||
match self.step {
|
||||
Step::SelectVertex { weight, refine } => {
|
||||
if let Some(index) = self.picked_vertex(point) {
|
||||
self.step = if weight { Step::Weight { index } } else { Step::Move { index, refine } };
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::Delete => {
|
||||
let Some(source) = self.spline.as_ref() else { return CmdResult::NeedPoint; };
|
||||
let Some(index) = self.picked_vertex(point) else { return CmdResult::NeedPoint; };
|
||||
let controls = source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect();
|
||||
let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] }
|
||||
else { source.weights.clone() };
|
||||
let curve = cadkernel::space::NurbsCurve3::new_strict(source.degree as usize, controls,
|
||||
source.knots.clone(), weights).map(|curve| curve.with_periodicity(source.flags.periodic || source.flags.closed));
|
||||
let Some(curve) = curve.and_then(|curve| curve.without_control_vertex(index)) else { return CmdResult::NeedPoint; };
|
||||
let mut spline = source.clone();
|
||||
spline.degree = curve.degree() as i32;
|
||||
spline.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect();
|
||||
spline.weights = curve.weights().to_vec();
|
||||
spline.knots = curve.knots().to_vec();
|
||||
clear_fit_method(&mut spline);
|
||||
spline.flags.rational = curve.is_rational();
|
||||
spline.flags.planar = crate::entities::curve::spline_is_planar(&spline);
|
||||
self.replace(spline)
|
||||
}
|
||||
Step::Add => self.refined(Some(point), None).map_or(CmdResult::NeedPoint, |spline| self.replace(spline)),
|
||||
Step::Move { index, .. } => {
|
||||
let Some(mut spline) = self.spline.clone() else { return CmdResult::NeedPoint; };
|
||||
let Some(vertex) = spline.control_points.get_mut(index) else { return CmdResult::NeedPoint; };
|
||||
*vertex = Vector3::new(point.x, point.y, point.z);
|
||||
clear_fit_method(&mut spline);
|
||||
spline.flags.planar = crate::entities::curve::spline_is_planar(&spline);
|
||||
self.replace(spline)
|
||||
}
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
match self.step {
|
||||
Step::SelectSpline | Step::Options => CmdResult::Cancel,
|
||||
Step::Join => self.finish_join(),
|
||||
Step::PolylinePrecision => self.convert_polyline(10),
|
||||
Step::Refine => { self.step = Step::Options; CmdResult::NeedPoint }
|
||||
Step::Add | Step::Delete | Step::SelectVertex { .. } => { self.step = Step::Refine; CmdResult::NeedPoint }
|
||||
Step::Elevate => {
|
||||
let order = self.spline.as_ref().map_or(4, |s| s.degree as usize + 1);
|
||||
self.on_text_input(&order.to_string()).unwrap_or(CmdResult::NeedPoint)
|
||||
}
|
||||
Step::Move { .. } | Step::Weight { .. } => self.on_text_input("N").unwrap_or(CmdResult::NeedPoint),
|
||||
}
|
||||
}
|
||||
fn on_preview_wires(&mut self, _pt: DVec3) -> Vec<WireModel> {
|
||||
vec![]
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
if matches!(self.step, Step::Join | Step::PolylinePrecision) {
|
||||
self.join_candidates.clear();
|
||||
self.step = Step::Options;
|
||||
CmdResult::NeedPoint
|
||||
} else { CmdResult::Cancel }
|
||||
}
|
||||
fn on_preview_wires(&mut self, _pt: DVec3) -> Vec<WireModel> { vec![] }
|
||||
}
|
||||
|
||||
// ── Spline operation helpers ───────────────────────────────────────────────
|
||||
//
|
||||
// Because `CmdResult::ReplaceEntity` takes `Vec<EntityType>` and we need the
|
||||
// host to apply it to the actual document, we use a small "deferred op" trick:
|
||||
// the host applies the spline transform in `apply_cmd_result` by detecting
|
||||
// that we replaced an entity with zero new entities (delete) or one new one.
|
||||
//
|
||||
// Here we actually build the modified entity without accessing the document —
|
||||
// we only have the handle. The real transformation (close/open/reverse) is
|
||||
// applied in the Scene via `apply_spline_op`.
|
||||
|
||||
enum SplineOp {
|
||||
Close,
|
||||
Open,
|
||||
Reverse,
|
||||
}
|
||||
|
||||
impl SplineOp {
|
||||
/// Return a placeholder that encodes the op in a comment field.
|
||||
/// The host `cmd_result.rs` detects SPLINEDIT replaces and calls
|
||||
/// `Scene::apply_spline_op`.
|
||||
fn into_marker(self, _handle: acadrust::Handle) -> EntityType {
|
||||
// We can't build the modified spline here without the document.
|
||||
// Return a sentinel — the actual transformation is handled by the
|
||||
// apply_spline_op path in Scene. We use an XLine as a sentinel
|
||||
// that the host will never actually commit (it detects the SPLINEDIT
|
||||
// replace path and calls apply_spline_op instead).
|
||||
//
|
||||
// In practice: use ReplaceMany with empty new entities + a SplineOp
|
||||
// side-channel via the xattach_path mechanism isn't clean.
|
||||
//
|
||||
// Simpler: encode op in the sentinel entity's layer field.
|
||||
let mut sentinel = acadrust::entities::XLine::new(
|
||||
acadrust::types::Vector3::zero(),
|
||||
acadrust::types::Vector3::new(1.0, 0.0, 0.0),
|
||||
);
|
||||
sentinel.common.layer = match self {
|
||||
SplineOp::Close => "__SPLINEDIT_CLOSE__".to_string(),
|
||||
SplineOp::Open => "__SPLINEDIT_OPEN__".to_string(),
|
||||
SplineOp::Reverse => "__SPLINEDIT_REVERSE__".to_string(),
|
||||
};
|
||||
EntityType::XLine(sentinel)
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a spline operation (CLOSE/OPEN/REVERSE) to a spline entity.
|
||||
/// Called from `cmd_result.rs` when the ReplaceEntity sentinel is detected.
|
||||
pub fn apply_spline_op(doc: &mut acadrust::CadDocument, handle: acadrust::Handle, op: &str) {
|
||||
let Some(EntityType::Spline(spline)) = doc.get_entity_mut(handle) else {
|
||||
return;
|
||||
};
|
||||
match op {
|
||||
"__SPLINEDIT_CLOSE__" => {
|
||||
if spline.control_points.len() >= 2 {
|
||||
let first = spline.control_points[0];
|
||||
spline.control_points.push(first);
|
||||
// Regenerate clamped knots.
|
||||
spline.knots = acadrust::entities::Spline::generate_clamped_knots(
|
||||
spline.degree as usize,
|
||||
spline.control_points.len(),
|
||||
);
|
||||
spline.fit_points.clear();
|
||||
}
|
||||
apply_to_spline(spline, op);
|
||||
}
|
||||
|
||||
fn apply_to_spline(spline: &mut acadrust::entities::Spline, op: &str) {
|
||||
let result = match op {
|
||||
"__SPLINEDIT_CLOSE__" => change_closure(spline, true),
|
||||
"__SPLINEDIT_OPEN__" => change_closure(spline, false),
|
||||
"__SPLINEDIT_REVERSE__" => Some(super::reverse::reverse_spline(spline)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(result) = result { *spline = result; }
|
||||
}
|
||||
|
||||
fn change_closure(source: &acadrust::entities::Spline, closed: bool) -> Option<acadrust::entities::Spline> {
|
||||
use cadkernel::space::{NurbsCurve3, Parameterization};
|
||||
if (source.flags.closed || source.flags.periodic) == closed { return None; }
|
||||
let fit_method = !source.fit_points.is_empty() || source.dwg_flags1 & 1 != 0 || source.dxf_flags & 32 != 0;
|
||||
let parameterization = match source.knot_parameterization {
|
||||
1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord,
|
||||
};
|
||||
let xyz = |point: &Vector3| [point.x, point.y, point.z];
|
||||
let controls: Vec<_> = source.control_points.iter().map(xyz).collect();
|
||||
let degree = usize::try_from(source.degree).ok()?;
|
||||
let weights = if source.weights.is_empty() { vec![1.0; controls.len()] } else { source.weights.clone() };
|
||||
if fit_method && weights.windows(2).any(|pair| pair[0] != pair[1]) { return None; }
|
||||
let stored_curve = || NurbsCurve3::new_strict(degree, controls.clone(), source.knots.clone(), weights.clone());
|
||||
let mut fit_points = Vec::new();
|
||||
let curve = if fit_method {
|
||||
fit_points = source.fit_points.iter().map(xyz).collect();
|
||||
if fit_points.is_empty() {
|
||||
let curve = stored_curve()?;
|
||||
let (start, end) = curve.domain();
|
||||
let mut parameters: Vec<_> = curve.knots().iter().copied()
|
||||
.filter(|parameter| *parameter >= start && if closed { *parameter <= end } else { *parameter < end }).collect();
|
||||
parameters.dedup();
|
||||
fit_points = parameters.into_iter().map(|parameter| curve.point_at_knot(parameter)).collect();
|
||||
}
|
||||
"__SPLINEDIT_OPEN__" => {
|
||||
if spline.control_points.len() >= 2 {
|
||||
let n = spline.control_points.len();
|
||||
let first = spline.control_points[0];
|
||||
let last = spline.control_points[n - 1];
|
||||
if (first.x - last.x).abs() < 1e-9
|
||||
&& (first.y - last.y).abs() < 1e-9
|
||||
&& (first.z - last.z).abs() < 1e-9
|
||||
{
|
||||
spline.control_points.pop();
|
||||
spline.knots = acadrust::entities::Spline::generate_clamped_knots(
|
||||
spline.degree as usize,
|
||||
spline.control_points.len(),
|
||||
);
|
||||
spline.fit_points.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
"__SPLINEDIT_REVERSE__" => {
|
||||
spline.fit_points.reverse();
|
||||
let begin = spline.begin_tangent;
|
||||
spline.begin_tangent = spline.end_tangent;
|
||||
spline.end_tangent = begin;
|
||||
// The knots mirror within the domain rather than being
|
||||
// regenerated. Rebuilding a clamped *uniform* vector here changed
|
||||
// the shape of any spline whose knots were unevenly spaced —
|
||||
// which is most of them once a modeller has been near one.
|
||||
match spline_to_nurbs(spline).map(|curve| curve.reversed()) {
|
||||
Some(reversed) => {
|
||||
let elevation = spline
|
||||
.control_points
|
||||
.first()
|
||||
.or_else(|| spline.fit_points.first())
|
||||
.map(|p| p.z)
|
||||
.unwrap_or(0.0);
|
||||
spline.degree = reversed.degree() as i32;
|
||||
spline.knots = reversed.knots().to_vec();
|
||||
spline.control_points = reversed
|
||||
.control_points()
|
||||
.iter()
|
||||
.map(|p| Vector3::new(p[0], p[1], elevation))
|
||||
.collect();
|
||||
spline.weights = if reversed.is_rational() {
|
||||
reversed.weights().to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
}
|
||||
// Nothing the kernel can read — a spline with too few points
|
||||
// to describe a curve. Reversing its stored points is still
|
||||
// the honest answer.
|
||||
None => spline.control_points.reverse(),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
let curve = if closed {
|
||||
NurbsCurve3::interpolate_periodic(&fit_points, parameterization)?
|
||||
} else {
|
||||
NurbsCurve3::interpolate_fit(&fit_points, None, None, parameterization)?
|
||||
};
|
||||
curve.compact_knots(source.control_tolerance.max(1e-9))?
|
||||
} else if closed {
|
||||
NurbsCurve3::from_weighted_control_polygon(degree, &controls, &weights, true)?
|
||||
} else {
|
||||
let curve = stored_curve()?.with_periodicity(false);
|
||||
curve.without_control_vertex(controls.len().checked_sub(1)?)?
|
||||
};
|
||||
let mut result = source.clone();
|
||||
result.degree = curve.degree() as i32;
|
||||
result.control_points = curve.control_points().iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect();
|
||||
result.knots = curve.knots().to_vec();
|
||||
result.weights = curve.weights().to_vec();
|
||||
result.flags.closed = closed;
|
||||
result.flags.periodic = closed;
|
||||
if closed { result.dxf_flags |= 2048; } else { result.dxf_flags &= !2048; }
|
||||
result.flags.rational = !fit_method && curve.is_rational();
|
||||
result.fit_points = if !closed && fit_method {
|
||||
fit_points.iter().map(|point| Vector3::new(point[0], point[1], point[2])).collect()
|
||||
} else { Vec::new() };
|
||||
if fit_method {
|
||||
result.dwg_flags1 |= 1;
|
||||
result.dxf_flags |= 32 | 1024;
|
||||
result.begin_tangent = Vector3::ZERO;
|
||||
result.end_tangent = Vector3::ZERO;
|
||||
}
|
||||
result.flags.planar = crate::entities::curve::spline_is_planar(&result);
|
||||
Some(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::Spline;
|
||||
use acadrust::Handle;
|
||||
use iced::Rectangle;
|
||||
|
||||
fn control_spline(points: &[[f64; 3]], degree: usize) -> Spline {
|
||||
let mut spline = Spline::new();
|
||||
spline.degree = degree as i32;
|
||||
spline.control_points = points
|
||||
.iter()
|
||||
.map(|point| Vector3::new(point[0], point[1], point[2]))
|
||||
.collect();
|
||||
spline.knots = cadkernel::space::clamped_uniform_knots(degree, points.len());
|
||||
spline
|
||||
}
|
||||
|
||||
fn selected_command(spline: Spline) -> SplineditCommand {
|
||||
let mut command = SplineditCommand::new();
|
||||
command.inject_picked_entity(EntityType::Spline(spline));
|
||||
command.on_entity_pick(Handle::new(7), DVec3::ZERO);
|
||||
command
|
||||
}
|
||||
|
||||
fn replacement(result: CmdResult) -> Spline {
|
||||
let CmdResult::ReplaceManyContinue(mut replacements) = result else {
|
||||
panic!("expected a continuing replacement");
|
||||
};
|
||||
let Some(EntityType::Spline(spline)) = replacements[0].1.pop() else {
|
||||
panic!("expected a spline replacement");
|
||||
};
|
||||
spline
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_control_vertex_clears_fit_state_and_recomputes_planarity() {
|
||||
let mut spline = control_spline(
|
||||
&[
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[2.0, 1.0, 0.0],
|
||||
[3.0, 0.0, 0.0],
|
||||
],
|
||||
3,
|
||||
);
|
||||
spline.fit_points = vec![Vector3::new(0.0, 0.0, 0.0)];
|
||||
spline.begin_tangent = Vector3::new(1.0, 0.0, 0.0);
|
||||
spline.end_tangent = Vector3::new(1.0, 0.0, 0.0);
|
||||
spline.dwg_flags1 = 1;
|
||||
spline.dxf_flags = 32 | 1024;
|
||||
spline.flags.planar = true;
|
||||
let mut command = selected_command(spline);
|
||||
command.on_text_input("MOVE");
|
||||
let moved = replacement(command.on_point(DVec3::new(0.0, 0.0, 1.0)));
|
||||
assert!(moved.fit_points.is_empty());
|
||||
assert_eq!(moved.begin_tangent, Vector3::ZERO);
|
||||
assert_eq!(moved.end_tangent, Vector3::ZERO);
|
||||
assert_eq!(moved.dwg_flags1 & 1, 0);
|
||||
assert_eq!(moved.dxf_flags & (32 | 1024), 0);
|
||||
assert!(!moved.flags.planar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_control_weights_are_not_marked_rational() {
|
||||
let spline = control_spline(
|
||||
&[
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[2.0, 1.0, 0.0],
|
||||
[3.0, 0.0, 0.0],
|
||||
],
|
||||
3,
|
||||
);
|
||||
let mut command = selected_command(spline);
|
||||
command.on_text_input("REFINE");
|
||||
command.on_text_input("WEIGHT");
|
||||
let weighted = replacement(command.on_text_input("1").unwrap());
|
||||
assert!(!weighted.flags.rational);
|
||||
assert_eq!(weighted.weights, vec![1.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_only_weighted_vertex_clears_rational_and_fit_flags() {
|
||||
let mut spline = control_spline(
|
||||
&[
|
||||
[-0.6, 0.0, 0.0],
|
||||
[-0.2, 0.4, 0.0],
|
||||
[0.2, 0.4, 0.0],
|
||||
[0.6, 0.0, 0.0],
|
||||
],
|
||||
2,
|
||||
);
|
||||
spline.weights = vec![1.0, 2.0, 1.0, 1.0];
|
||||
spline.flags.rational = true;
|
||||
spline.dwg_flags1 = 1;
|
||||
spline.dxf_flags = 32 | 1024;
|
||||
let mut command = selected_command(spline);
|
||||
command.on_text_input("REFINE");
|
||||
command.on_text_input("DELETE");
|
||||
command.pick_context = Some(crate::command::PointPickContext {
|
||||
view: glam::Mat4::IDENTITY,
|
||||
eye: DVec3::ZERO,
|
||||
bounds: Rectangle::new(iced::Point::ORIGIN, iced::Size::new(100.0, 100.0)),
|
||||
aperture_px: 2.0,
|
||||
});
|
||||
let deleted = replacement(command.on_point(DVec3::new(-0.2, 0.4, 0.0)));
|
||||
assert_eq!(deleted.control_points.len(), 3);
|
||||
assert!(!deleted.flags.rational);
|
||||
assert_eq!(deleted.dwg_flags1 & 1, 0);
|
||||
assert_eq!(deleted.dxf_flags & (32 | 1024), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_only_degree_elevation_uses_the_interpolated_curve_degree() {
|
||||
let mut spline = Spline::new();
|
||||
spline.degree = 1;
|
||||
spline.fit_points = vec![
|
||||
Vector3::new(0.0, 0.0, 0.0),
|
||||
Vector3::new(1.0, 2.0, 0.0),
|
||||
Vector3::new(3.0, 0.0, 0.0),
|
||||
];
|
||||
let mut command = selected_command(spline);
|
||||
command.on_text_input("REFINE");
|
||||
command.on_text_input("ELEVATE");
|
||||
assert!(matches!(command.on_text_input("invalid"), Some(CmdResult::NeedPoint)));
|
||||
assert!(matches!(command.step, Step::Elevate));
|
||||
let elevated = replacement(command.on_text_input("5").unwrap());
|
||||
assert_eq!(elevated.degree, 4);
|
||||
assert!(elevated.fit_points.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_weighted_control_curve_can_be_opened() {
|
||||
let curve = cadkernel::space::NurbsCurve3::from_weighted_control_polygon(
|
||||
2,
|
||||
&[
|
||||
[0.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[2.0, 2.0, 1.0],
|
||||
[0.0, 2.0, 0.0],
|
||||
],
|
||||
&[1.0, 2.0, 0.75, 1.5],
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let mut spline = control_spline(curve.control_points(), curve.degree());
|
||||
spline.knots = curve.knots().to_vec();
|
||||
spline.weights = curve.weights().to_vec();
|
||||
spline.flags.closed = true;
|
||||
spline.flags.periodic = true;
|
||||
spline.flags.rational = true;
|
||||
let opened = change_closure(&spline, false).expect("closed control curve opens");
|
||||
assert!(!opened.flags.closed);
|
||||
assert!(!opened.flags.periodic);
|
||||
assert!(cadkernel::space::NurbsCurve3::new_strict(
|
||||
opened.degree as usize,
|
||||
opened
|
||||
.control_points
|
||||
.iter()
|
||||
.map(|point| [point.x, point.y, point.z])
|
||||
.collect(),
|
||||
opened.knots.clone(),
|
||||
opened.weights.clone(),
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_vertex_pick_uses_the_screen_aperture() {
|
||||
let spline = control_spline(&[[-0.5, 0.0, 0.0], [0.5, 0.0, 0.0]], 1);
|
||||
let mut command = selected_command(spline);
|
||||
command.pick_context = Some(crate::command::PointPickContext {
|
||||
view: glam::Mat4::IDENTITY,
|
||||
eye: DVec3::ZERO,
|
||||
bounds: Rectangle::new(iced::Point::ORIGIN, iced::Size::new(100.0, 100.0)),
|
||||
aperture_px: 2.0,
|
||||
});
|
||||
assert_eq!(command.picked_vertex(DVec3::new(-0.49, 0.0, 0.0)), Some(0));
|
||||
assert_eq!(command.picked_vertex(DVec3::new(0.0, 0.5, 0.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planar_polyline_conversion_honors_source_deletion_policy() {
|
||||
let handle = Handle::new(41);
|
||||
let mut spline = control_spline(
|
||||
&[
|
||||
[0.0, 0.0, 2.0],
|
||||
[1.0, 2.0, 2.0],
|
||||
[3.0, 0.0, 2.0],
|
||||
],
|
||||
2,
|
||||
);
|
||||
spline.common.handle = handle;
|
||||
spline.flags.planar = true;
|
||||
let mut command = SplineditCommand::new().with_delete_source(false);
|
||||
command.inject_picked_entity(EntityType::Spline(spline));
|
||||
command.on_entity_pick(handle, DVec3::ZERO);
|
||||
let CmdResult::ReplaceMany(replacements, additions) = command.convert_polyline(10) else {
|
||||
panic!("expected a polyline conversion");
|
||||
};
|
||||
assert!(replacements.is_empty());
|
||||
let [EntityType::LwPolyline(polyline)] = additions.as_slice() else {
|
||||
panic!("expected a lightweight polyline");
|
||||
};
|
||||
assert!(polyline.common.handle.is_null());
|
||||
assert_eq!(polyline.elevation, 2.0);
|
||||
assert!(polyline.vertices.len() > 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonplanar_spline_converts_to_a_spatial_polyline() {
|
||||
let spline = control_spline(
|
||||
&[
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[2.0, 1.0, 0.0],
|
||||
[3.0, 0.0, 2.0],
|
||||
],
|
||||
3,
|
||||
);
|
||||
let mut command = selected_command(spline);
|
||||
let CmdResult::ReplaceMany(replacements, additions) = command.convert_polyline(10) else {
|
||||
panic!("expected a polyline conversion");
|
||||
};
|
||||
assert!(additions.is_empty());
|
||||
let [(_, entities)] = replacements.as_slice() else {
|
||||
panic!("expected one source replacement");
|
||||
};
|
||||
let [EntityType::Polyline3D(polyline)] = entities.as_slice() else {
|
||||
panic!("expected a spatial polyline");
|
||||
};
|
||||
assert!(polyline.vertices.len() > 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_conversion_closes_once_and_invalid_precision_retries() {
|
||||
let curve = cadkernel::space::NurbsCurve3::from_control_polygon(
|
||||
2,
|
||||
&[
|
||||
[0.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[2.0, 2.0, 0.0],
|
||||
[0.0, 2.0, 0.0],
|
||||
],
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let mut spline = control_spline(curve.control_points(), curve.degree());
|
||||
spline.knots = curve.knots().to_vec();
|
||||
spline.weights = curve.weights().to_vec();
|
||||
spline.flags.closed = true;
|
||||
spline.flags.periodic = true;
|
||||
let mut command = selected_command(spline);
|
||||
command.on_text_input("POLYLINE");
|
||||
assert!(matches!(command.on_text_input("100"), Some(CmdResult::ReportError(_))));
|
||||
assert!(matches!(command.step, Step::PolylinePrecision));
|
||||
let CmdResult::ReplaceMany(replacements, _) = command.on_enter() else {
|
||||
panic!("expected the default conversion");
|
||||
};
|
||||
let [(_, entities)] = replacements.as_slice() else {
|
||||
panic!("expected one source replacement");
|
||||
};
|
||||
let [EntityType::LwPolyline(polyline)] = entities.as_slice() else {
|
||||
panic!("expected a lightweight polyline");
|
||||
};
|
||||
assert!(polyline.is_closed);
|
||||
assert!(polyline.vertices.len() >= 4);
|
||||
assert_ne!(polyline.vertices.first().unwrap().location, polyline.vertices.last().unwrap().location);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["SPLINEDIT"] }); // SplineditCommand
|
||||
|
||||
fn spatial_spline(source: &acadrust::entities::Spline) -> Option<cadkernel::space::NurbsCurve3> {
|
||||
let current = usize::try_from(source.degree).ok()?;
|
||||
let weights = if source.weights.is_empty() { vec![1.0; source.control_points.len()] } else { source.weights.clone() };
|
||||
let curve = if source.control_points.is_empty() && source.fit_points.len() >= 2 {
|
||||
use cadkernel::space::{NurbsCurve3, Parameterization};
|
||||
let points: Vec<_> = source.fit_points.iter().map(|point| [point.x, point.y, point.z]).collect();
|
||||
let parameterization = match source.knot_parameterization {
|
||||
1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord,
|
||||
};
|
||||
let tangent = |point: &Vector3| (point.x != 0.0 || point.y != 0.0 || point.z != 0.0).then_some([point.x, point.y, point.z]);
|
||||
if source.flags.closed || source.flags.periodic {
|
||||
NurbsCurve3::interpolate_periodic(&points, parameterization)?
|
||||
} else {
|
||||
NurbsCurve3::interpolate_fit(&points, tangent(&source.begin_tangent), tangent(&source.end_tangent), parameterization)?
|
||||
}
|
||||
} else {
|
||||
cadkernel::space::NurbsCurve3::new_strict(current,
|
||||
source.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(),
|
||||
source.knots.clone(), weights)?
|
||||
};
|
||||
Some(curve.with_periodicity(source.flags.closed || source.flags.periodic))
|
||||
}
|
||||
Reference in new issue
Block a user