mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-28 04:46:50 -06:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5e5645cf6 | ||
|
|
7d2ca90783 | ||
|
|
af9798e200 | ||
|
|
c042e98abb | ||
|
|
994af5cac9 | ||
|
|
970e6bb9d4 | ||
|
|
736a83464a | ||
|
|
aa5dfc2099 | ||
|
|
f12b7570b7 | ||
|
|
795eb62762 | ||
|
|
e7b92c5b8e | ||
|
|
6028cdf187 | ||
|
|
172f9ca44c | ||
|
|
c5e7db78e4 | ||
|
|
31a04e3dbe | ||
|
|
627ffb8d7e | ||
|
|
18f47e021f | ||
|
|
21d80b3231 | ||
|
|
62deed7880 | ||
|
|
45bcfa6f77 | ||
|
|
627b5a3350 | ||
|
|
1006e487da | ||
|
|
fcd4a0c9eb | ||
|
|
dbd2bf5dad | ||
|
|
d7915c5f3b | ||
|
|
1bbc5ec76b | ||
|
|
012732136c | ||
|
|
273303134e | ||
|
|
48d25a0414 | ||
|
|
6021d96628 | ||
|
|
ad329273f2 | ||
|
|
f3796d1f7a | ||
|
|
3a64a190ea | ||
|
|
5074d2a54c | ||
|
|
bccba7261e | ||
|
|
c8ee56a44f |
@@ -1,3 +0,0 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
+3
-1
@@ -69,9 +69,11 @@ arboard = { version = "3.6", default-features = false }
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
|
||||
@@ -2,3 +2,4 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
+15
-934
@@ -9,130 +9,6 @@ use serde::{Deserialize, Serialize};
|
||||
mod mline;
|
||||
pub use mline::*;
|
||||
|
||||
/// A database handle (DXF group 5), unique per drawing.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct Handle(pub u64);
|
||||
|
||||
impl Handle {
|
||||
pub fn hex(self) -> String {
|
||||
format!("{:X}", self.0)
|
||||
}
|
||||
pub fn parse_hex(s: &str) -> Option<Handle> {
|
||||
let s = s.trim();
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u64::from_str_radix(s, 16).ok().map(Handle)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lineweight in hundredths of a millimetre, or a logical value.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Lineweight {
|
||||
#[default]
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Default,
|
||||
/// 0..=211 (hundredths of mm).
|
||||
Mm100(u16),
|
||||
}
|
||||
|
||||
impl Lineweight {
|
||||
/// The standard lineweight values (mm × 100).
|
||||
pub const STANDARD: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
pub fn to_dxf(self) -> i16 {
|
||||
match self {
|
||||
Lineweight::ByLayer => -1,
|
||||
Lineweight::ByBlock => -2,
|
||||
Lineweight::Default => -3,
|
||||
Lineweight::Mm100(v) => v.min(211) as i16,
|
||||
}
|
||||
}
|
||||
pub fn from_dxf(v: i16) -> Lineweight {
|
||||
match v {
|
||||
-2 => Lineweight::ByBlock,
|
||||
-3 => Lineweight::Default,
|
||||
0..=211 => Lineweight::Mm100(v as u16),
|
||||
_ => Lineweight::ByLayer,
|
||||
}
|
||||
}
|
||||
pub fn name(self) -> String {
|
||||
match self {
|
||||
Lineweight::ByLayer => "ByLayer".into(),
|
||||
Lineweight::ByBlock => "ByBlock".into(),
|
||||
Lineweight::Default => "Default".into(),
|
||||
Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparency: ByLayer, ByBlock, or 0..=90 percent.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Transparency {
|
||||
#[default]
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Percent(u8),
|
||||
}
|
||||
|
||||
impl Transparency {
|
||||
/// "ByLayer", "ByBlock" or the percentage ("50").
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
Transparency::ByLayer => "ByLayer".into(),
|
||||
Transparency::ByBlock => "ByBlock".into(),
|
||||
Transparency::Percent(p) => p.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties every entity has.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Common {
|
||||
pub layer: String,
|
||||
pub color: Color,
|
||||
pub linetype: String,
|
||||
pub lineweight: Lineweight,
|
||||
pub ltscale: f64,
|
||||
pub transparency: Transparency,
|
||||
pub visible: bool,
|
||||
pub thickness: f64,
|
||||
pub extrusion: Vec3,
|
||||
/// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot
|
||||
/// style table.
|
||||
pub plot_style: String,
|
||||
/// The annotation scales an annotative object supports (empty: not annotative).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub anno_scales: Vec<crate::AnnoScale>,
|
||||
/// Other applications' extended data (xdata) as read from the drawing file: whole
|
||||
/// applications, each from its `1001` group on. It is part of the object, so a copy of the
|
||||
/// object (COPY, MIRROR, ARRAY, paste) carries it, as in AutoCAD.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub xdata: Vec<RawTag>,
|
||||
}
|
||||
|
||||
impl Default for Common {
|
||||
fn default() -> Self {
|
||||
Common {
|
||||
layer: "0".into(),
|
||||
color: Color::ByLayer,
|
||||
linetype: "ByLayer".into(),
|
||||
lineweight: Lineweight::ByLayer,
|
||||
ltscale: 1.0,
|
||||
transparency: Transparency::ByLayer,
|
||||
visible: true,
|
||||
thickness: 0.0,
|
||||
extrusion: Vec3::Z,
|
||||
plot_style: "ByLayer".into(),
|
||||
anno_scales: Vec::new(),
|
||||
xdata: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Entity {
|
||||
pub handle: Handle,
|
||||
@@ -146,108 +22,6 @@ impl Entity {
|
||||
Entity { handle, common: Common::default(), kind }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Line {
|
||||
pub a: Vec3,
|
||||
pub b: Vec3,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Point {
|
||||
pub p: Vec3,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Circle {
|
||||
pub center: Vec3,
|
||||
pub radius: f64,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Arc {
|
||||
pub center: Vec3,
|
||||
pub radius: f64,
|
||||
/// Radians, CCW.
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Ellipse {
|
||||
pub center: Vec3,
|
||||
pub major: Vec3,
|
||||
pub ratio: f64,
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LwPolyline {
|
||||
pub vertices: Vec<PolyVertex>,
|
||||
pub closed: bool,
|
||||
#[serde(default)]
|
||||
pub const_width: f64,
|
||||
#[serde(default)]
|
||||
pub elevation: f64,
|
||||
/// Polyline linetype generation (PLINEGEN).
|
||||
#[serde(default)]
|
||||
pub plinegen: bool,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Polyline3d {
|
||||
pub points: Vec<Vec3>,
|
||||
pub closed: bool,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Aligned,
|
||||
Middle,
|
||||
Fit,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Baseline,
|
||||
Bottom,
|
||||
Middle,
|
||||
Top,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Text {
|
||||
pub insert: Vec3,
|
||||
#[serde(default)]
|
||||
pub align_pt: Option<Vec3>,
|
||||
pub height: f64,
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
#[serde(default = "one")]
|
||||
pub width_factor: f64,
|
||||
#[serde(default)]
|
||||
pub oblique: f64,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default)]
|
||||
pub halign: HAlign,
|
||||
#[serde(default)]
|
||||
pub valign: VAlign,
|
||||
}
|
||||
impl Text {
|
||||
/// The point the text is placed by, as the Insertion snap and the grip show it: the alignment
|
||||
/// point (DXF group 11) of justified text, the start point (group 10) of left-baseline,
|
||||
/// aligned and fit text.
|
||||
pub fn justify_point(&self) -> Vec2 {
|
||||
match (self.halign, self.valign) {
|
||||
(HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(),
|
||||
_ => self.align_pt.unwrap_or(self.insert).xy(),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn one() -> f64 {
|
||||
1.0
|
||||
}
|
||||
@@ -257,720 +31,27 @@ fn standard() -> String {
|
||||
|
||||
mod helix;
|
||||
pub use helix::*;
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MText {
|
||||
pub insert: Vec3,
|
||||
pub height: f64,
|
||||
/// Reference rectangle width (0 = no wrapping).
|
||||
#[serde(default)]
|
||||
pub width: f64,
|
||||
/// 1..=9: TL TC TR ML MC MR BL BC BR.
|
||||
#[serde(default = "attach_tl")]
|
||||
pub attach: u8,
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
/// Raw MTEXT contents with inline codes (\P, {\fArial|b1;...}, \S1/2; …).
|
||||
pub contents: String,
|
||||
#[serde(default = "one")]
|
||||
pub line_spacing: f64,
|
||||
/// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character
|
||||
/// heights; At least (`false`, the default) lets taller characters push lines apart.
|
||||
#[serde(default)]
|
||||
pub line_spacing_exact: bool,
|
||||
/// Columns the text flows through, each `width` wide (DXF 75/76/78/79/48/49/50); `None`:
|
||||
/// one column.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub columns: Option<MTextColumns>,
|
||||
}
|
||||
fn attach_tl() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Static columns: a fixed number of columns of one height. Dynamic columns: as many columns
|
||||
/// as the text needs, of one height (auto height) or each its own (manual heights).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MTextColumnType {
|
||||
#[default]
|
||||
Static,
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// MTEXT columns (MTEXT Columns option, DXF Reference MTEXT groups 75–79, 48–50).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MTextColumns {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: MTextColumnType,
|
||||
/// Number of columns (static columns: the columns there are).
|
||||
pub count: u16,
|
||||
/// Space between two columns.
|
||||
pub gutter: f64,
|
||||
/// Height of every column (static, and dynamic with auto height).
|
||||
pub height: f64,
|
||||
/// Dynamic columns: one height for all columns (`height`) instead of `heights`.
|
||||
pub auto_height: bool,
|
||||
/// Dynamic columns with manual heights: the height of each column.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub heights: Vec<f64>,
|
||||
/// Columns run right to left.
|
||||
pub flow_reversed: bool,
|
||||
}
|
||||
|
||||
impl MTextColumns {
|
||||
/// The width of `count` columns of `column_width` and their gutters.
|
||||
pub fn total_width(&self, column_width: f64) -> f64 {
|
||||
let n = f64::from(self.count.max(1));
|
||||
column_width * n + self.gutter.max(0.0) * (n - 1.0)
|
||||
}
|
||||
|
||||
/// The tallest column.
|
||||
pub fn max_height(&self) -> f64 {
|
||||
self.heights.iter().copied().fold(self.height, f64::max).max(0.0)
|
||||
}
|
||||
|
||||
/// Scale every length (SCALE, block insertion).
|
||||
pub fn scale(&mut self, s: f64) {
|
||||
self.gutter *= s;
|
||||
self.height *= s;
|
||||
self.heights.iter_mut().for_each(|h| *h *= s);
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Attrib {
|
||||
pub tag: String,
|
||||
pub text: Text,
|
||||
#[serde(default)]
|
||||
pub invisible: bool,
|
||||
#[serde(default)]
|
||||
pub constant: bool,
|
||||
#[serde(default)]
|
||||
pub prompt: String,
|
||||
/// An attribute's own properties; each one left unset follows the attribute definition.
|
||||
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
|
||||
pub props: AttribProps,
|
||||
/// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as
|
||||
/// multiline text. `text.value` holds the plain text, which value edits change.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub multiline: Option<MultilineAttrib>,
|
||||
/// Its other modes: Verify, Preset and Lock position.
|
||||
#[serde(default, skip_serializing_if = "AttribModes::is_default")]
|
||||
pub modes: AttribModes,
|
||||
}
|
||||
|
||||
/// An attribute's modes beyond invisible and constant.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttribModes {
|
||||
/// Verify: the value is asked for twice when the block is inserted (DXF flag 4).
|
||||
#[serde(default)]
|
||||
pub verify: bool,
|
||||
/// Preset: the default value is used without asking (DXF flag 8).
|
||||
#[serde(default)]
|
||||
pub preset: bool,
|
||||
/// Lock position: the attribute can't be moved on its own within a reference (DXF group
|
||||
/// 280 after the flags). A reference's attributes take it from their definitions.
|
||||
#[serde(default)]
|
||||
pub lock_position: bool,
|
||||
}
|
||||
|
||||
impl AttribModes {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
/// The modes of a reference's attribute made from a definition with these: AutoCAD keeps
|
||||
/// only Lock position there.
|
||||
pub fn for_reference(self) -> Self {
|
||||
AttribModes { lock_position: self.lock_position, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The multiline text of a multiline attribute.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MultilineAttrib {
|
||||
pub mtext: MText,
|
||||
/// The plain text `mtext`'s contents read as: an attribute whose value differs was edited
|
||||
/// since, and its value replaces the contents.
|
||||
pub read_as: String,
|
||||
}
|
||||
|
||||
impl MultilineAttrib {
|
||||
/// This text placed by `m` (a block reference's transform, for its attribute made from a
|
||||
/// definition).
|
||||
pub fn transformed(&self, m: &Mat3) -> MultilineAttrib {
|
||||
let mut k = EntityKind::MText(self.mtext.clone());
|
||||
k.transform(m);
|
||||
let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() };
|
||||
MultilineAttrib { mtext, read_as: self.read_as.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Attrib {
|
||||
/// Move a multiline attribute's text by `m` along with the attribute (nothing for others).
|
||||
pub fn transform_multiline(&mut self, m: &Mat3) {
|
||||
if let Some(ml) = &mut self.multiline {
|
||||
*ml = ml.transformed(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline attribute's text as drawn and saved: its multiline text, with the contents
|
||||
/// replaced by the value when the value was edited since it was read.
|
||||
pub fn multiline_text(&self) -> Option<MText> {
|
||||
let m = self.multiline.as_ref()?;
|
||||
let mut t = m.mtext.clone();
|
||||
if self.text.value != m.read_as {
|
||||
t.contents = mtext_literal(&self.text.value);
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as
|
||||
/// paragraph breaks.
|
||||
pub fn mtext_literal(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'{' => out.push_str("\\{"),
|
||||
'}' => out.push_str("\\}"),
|
||||
'\n' => out.push_str("\\P"),
|
||||
'\r' => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
|
||||
/// lineweight), overriding those of its definition.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AttribProps {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Color>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub linetype: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lineweight: Option<Lineweight>,
|
||||
}
|
||||
|
||||
impl AttribProps {
|
||||
/// Nothing set: the attribute is drawn with its definition's properties.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == AttribProps::default()
|
||||
}
|
||||
/// `base` (the definition's properties) with the ones set here in place.
|
||||
pub fn over(&self, base: &Common) -> Common {
|
||||
Common {
|
||||
layer: self.layer.clone().unwrap_or_else(|| base.layer.clone()),
|
||||
color: self.color.unwrap_or(base.color),
|
||||
linetype: self.linetype.clone().unwrap_or_else(|| base.linetype.clone()),
|
||||
lineweight: self.lineweight.unwrap_or(base.lineweight),
|
||||
..base.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Insert {
|
||||
pub block: String,
|
||||
pub insert: Vec3,
|
||||
#[serde(default = "unit_scale")]
|
||||
pub scale: Vec3,
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
#[serde(default)]
|
||||
pub attribs: Vec<Attrib>,
|
||||
#[serde(default = "one_u")]
|
||||
pub cols: u32,
|
||||
#[serde(default = "one_u")]
|
||||
pub rows: u32,
|
||||
#[serde(default)]
|
||||
pub col_spacing: f64,
|
||||
#[serde(default)]
|
||||
pub row_spacing: f64,
|
||||
}
|
||||
fn unit_scale() -> Vec3 {
|
||||
Vec3::new(1.0, 1.0, 1.0)
|
||||
}
|
||||
fn one_u() -> u32 {
|
||||
1
|
||||
}
|
||||
impl Insert {
|
||||
/// Block-to-world transform (2D part).
|
||||
pub fn transform(&self, base: Vec2) -> Mat3 {
|
||||
Mat3::translate(self.insert.xy())
|
||||
.then_before(Mat3::rotate(self.rotation))
|
||||
.then_before(Mat3::scale(self.scale.x, self.scale.y))
|
||||
.then_before(Mat3::translate(-base))
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DimKind {
|
||||
/// Rotated linear dimension (horizontal = 0, vertical = 90°).
|
||||
Linear {
|
||||
rotation: f64,
|
||||
},
|
||||
Aligned,
|
||||
Angular,
|
||||
Angular3P,
|
||||
Diameter,
|
||||
Radius,
|
||||
Ordinate {
|
||||
x_type: bool,
|
||||
},
|
||||
ArcLength,
|
||||
}
|
||||
/// A dimension. Definition points follow DXF: `defpt` (10: dimension line point),
|
||||
/// `p13`/`p14` (extension origins), `p15`/`p16` (arc/angle points), `text_mid` (11).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Dimension {
|
||||
pub kind: DimKind,
|
||||
pub defpt: Vec3,
|
||||
pub text_mid: Vec3,
|
||||
#[serde(default)]
|
||||
pub p13: Vec3,
|
||||
#[serde(default)]
|
||||
pub p14: Vec3,
|
||||
#[serde(default)]
|
||||
pub p15: Vec3,
|
||||
#[serde(default)]
|
||||
pub p16: Vec3,
|
||||
/// "" = measured value, "<>" inside = measurement placeholder, " " = suppressed.
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default)]
|
||||
pub measurement: f64,
|
||||
#[serde(default)]
|
||||
pub text_rotation: f64,
|
||||
/// User moved the text (text_mid is authoritative).
|
||||
#[serde(default)]
|
||||
pub user_text_pos: bool,
|
||||
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
|
||||
#[serde(default)]
|
||||
pub block: Option<String>,
|
||||
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
|
||||
/// merged over the named style.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub overrides: serde_json::Map<String, serde_json::Value>,
|
||||
/// Associativity: which definition points follow which objects.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assoc: Vec<DimAssoc>,
|
||||
/// Oblique extension lines, jogs, breaks and inspection frames.
|
||||
#[serde(default, skip_serializing_if = "DimMarks::is_empty")]
|
||||
pub marks: DimMarks,
|
||||
}
|
||||
|
||||
/// What dimension edits add to a dimension's drawing: DIMEDIT Oblique, DIMJOGLINE, DIMJOGGED,
|
||||
/// DIMBREAK and DIMINSPECT.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DimMarks {
|
||||
/// Angle (radians) of the extension lines of a linear or aligned dimension; None =
|
||||
/// perpendicular to the dimension line.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oblique: Option<f64>,
|
||||
/// A jog on the dimension line of a linear or aligned dimension, at this point (DIMJOGLINE).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jog_line: Option<Vec3>,
|
||||
/// A jogged radius dimension (DIMJOGGED).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jogged: Option<JoggedRadius>,
|
||||
/// Gaps in the dimension and extension lines (DIMBREAK).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub breaks: Vec<DimBreak>,
|
||||
/// Inspection frame, label and rate (DIMINSPECT).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inspect: Option<Inspection>,
|
||||
}
|
||||
|
||||
impl DimMarks {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == DimMarks::default()
|
||||
}
|
||||
|
||||
/// Transform the points and angles (part of [`EntityKind::transform`]).
|
||||
fn transform(&mut self, t3: &dyn Fn(&mut Vec3), rot: f64, s: f64, mirror: bool) {
|
||||
if let Some(a) = &mut self.oblique {
|
||||
*a = cadcraft_geom::norm_angle(if mirror { rot - (*a - rot) } else { *a + rot });
|
||||
}
|
||||
if let Some(j) = &mut self.jog_line {
|
||||
t3(j);
|
||||
}
|
||||
if let Some(j) = &mut self.jogged {
|
||||
t3(&mut j.center);
|
||||
t3(&mut j.jog);
|
||||
}
|
||||
for b in &mut self.breaks {
|
||||
t3(&mut b.at);
|
||||
b.size *= s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The definition of a jogged radius dimension: the dimension line starts at an overriding
|
||||
/// centre and jogs on its way to the arc.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct JoggedRadius {
|
||||
/// Where the dimension line starts instead of the true centre.
|
||||
pub center: Vec3,
|
||||
/// The middle of the jog.
|
||||
pub jog: Vec3,
|
||||
}
|
||||
|
||||
/// A gap in a dimension's lines: everything within `size / 2` of `at` is left out.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DimBreak {
|
||||
pub at: Vec3,
|
||||
pub size: f64,
|
||||
/// Made by DIMBREAK Auto (recomputed by the next Auto) rather than picked by hand.
|
||||
pub auto: bool,
|
||||
}
|
||||
|
||||
/// An inspection dimension (DIMINSPECT).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Inspection {
|
||||
/// Frame shape: 0 round ends, 1 angular ends, 2 none.
|
||||
pub shape: u8,
|
||||
/// Text left of the value ("" = none).
|
||||
pub label: String,
|
||||
/// Inspection rate right of the value ("" = none).
|
||||
pub rate: String,
|
||||
}
|
||||
|
||||
/// A dimension definition point that follows an object (associative dimensions).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DimAssoc {
|
||||
/// Definition point: "defpt", "p13", "p14", "p15" or "p16".
|
||||
pub point: String,
|
||||
/// The object it is attached to.
|
||||
pub handle: Handle,
|
||||
pub snap: AssocSnap,
|
||||
}
|
||||
|
||||
/// Where on the object an associative definition point sits.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AssocSnap {
|
||||
/// Start point of a line/arc (or polyline vertex 0).
|
||||
Start,
|
||||
/// End point of a line/arc.
|
||||
End,
|
||||
Mid,
|
||||
/// Centre of a circle/arc.
|
||||
Center,
|
||||
/// On a circle/arc at a fixed angle (radians) from its centre.
|
||||
OnCircle {
|
||||
angle: f64,
|
||||
},
|
||||
/// Intersection of this (line) object with another line.
|
||||
Intersection {
|
||||
other: Handle,
|
||||
},
|
||||
/// Vertex `index` of a polyline.
|
||||
Vertex {
|
||||
index: usize,
|
||||
},
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Leader {
|
||||
pub vertices: Vec<Vec3>,
|
||||
#[serde(default = "yes")]
|
||||
pub arrow: bool,
|
||||
#[serde(default)]
|
||||
pub spline: bool,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
}
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MLeader {
|
||||
pub leaders: Vec<Vec<Vec3>>,
|
||||
pub landing: Vec3,
|
||||
pub dogleg: f64,
|
||||
pub text: Option<MText>,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default = "one")]
|
||||
pub arrow_size: f64,
|
||||
/// Leader lines are smooth curves through their points (leader type Spline). An empty
|
||||
/// `leaders` is a multileader without a leader line (leader type None).
|
||||
#[serde(default)]
|
||||
pub spline: bool,
|
||||
/// Block content in place of the text: a block reference placed at the landing.
|
||||
#[serde(default)]
|
||||
pub block: Option<Insert>,
|
||||
}
|
||||
|
||||
impl MLeader {
|
||||
/// Each leader line as drawn: through its points to the landing, smoothed for spline leaders.
|
||||
pub fn leader_paths(&self) -> Vec<Vec<Vec2>> {
|
||||
self.leaders
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(self.landing.xy())).collect();
|
||||
if self.spline && pts.len() > 2 { GSpline::from_fit_points(&pts).tessellate(1e-3) } else { pts }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
/// A hatch boundary loop: closed polyline with bulges.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HatchLoop {
|
||||
pub vertices: Vec<PolyVertex>,
|
||||
#[serde(default)]
|
||||
pub outer: bool,
|
||||
/// The objects the loop was made from (DXF group 97 count, 330 handles): an associative
|
||||
/// hatch re-fills the loop from them when they change.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub sources: Vec<Handle>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Hatch {
|
||||
pub pattern: String,
|
||||
pub solid: bool,
|
||||
pub loops: Vec<HatchLoop>,
|
||||
#[serde(default = "one")]
|
||||
pub scale: f64,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
#[serde(default)]
|
||||
pub associative: bool,
|
||||
/// 0 normal (odd parity), 1 outer, 2 ignore.
|
||||
#[serde(default)]
|
||||
pub style: u8,
|
||||
#[serde(default)]
|
||||
pub elevation: f64,
|
||||
/// Gradient fill: name + second colour.
|
||||
#[serde(default)]
|
||||
pub gradient: Option<Gradient>,
|
||||
#[serde(default)]
|
||||
pub origin: Vec2,
|
||||
#[serde(default)]
|
||||
pub background: Option<Color>,
|
||||
/// The pattern's line families as a file defined them, for a pattern that isn't in the
|
||||
/// standard library (unit scale and angle, like the library's; empty otherwise).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pattern_lines: Vec<crate::library::PatternLine>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Gradient {
|
||||
pub name: String,
|
||||
pub color1: Color,
|
||||
pub color2: Color,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
#[serde(default)]
|
||||
pub centered: bool,
|
||||
}
|
||||
/// SOLID / TRACE: up to four corners (DXF order 1,2,4,3 when drawn).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Solid {
|
||||
pub corners: [Vec3; 4],
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Face3d {
|
||||
pub corners: [Vec3; 4],
|
||||
#[serde(default)]
|
||||
pub hidden_edges: u8,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RayLine {
|
||||
pub base: Vec3,
|
||||
pub dir: Vec3,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Viewport {
|
||||
pub center: Vec3,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub view_center: Vec2,
|
||||
pub view_height: f64,
|
||||
#[serde(default)]
|
||||
pub id: u32,
|
||||
#[serde(default)]
|
||||
pub locked: bool,
|
||||
#[serde(default)]
|
||||
pub frozen_layers: Vec<String>,
|
||||
/// Per-viewport layer colour overrides (VP Color in the Layer Properties Manager).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub layer_colors: Vec<(String, Color)>,
|
||||
/// The paper-space entity whose outline clips the viewport (a closed polyline, circle,
|
||||
/// ellipse or spline; DXF group 340). `None`: the rectangle `center`/`width`/`height`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clip: Option<Handle>,
|
||||
/// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.
|
||||
#[serde(default = "yes")]
|
||||
pub on: bool,
|
||||
}
|
||||
impl Default for Viewport {
|
||||
fn default() -> Self {
|
||||
Viewport {
|
||||
center: Vec3::ZERO,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
view_center: Vec2::ZERO,
|
||||
view_height: 1.0,
|
||||
id: 0,
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Image {
|
||||
pub insert: Vec3,
|
||||
/// One pixel's width (U) and height (V) vectors in drawing units.
|
||||
pub u: Vec3,
|
||||
pub v: Vec3,
|
||||
/// Image size in pixels.
|
||||
pub size: Vec2,
|
||||
/// The image file (its IMAGEDEF object's path).
|
||||
pub path: String,
|
||||
/// The image definition's name (its key in the drawing's image dictionary).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
/// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down):
|
||||
/// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub clip: Vec<Vec2>,
|
||||
/// The clip boundary is applied.
|
||||
#[serde(default)]
|
||||
pub clipping: bool,
|
||||
/// Display flags: 1 show the image, 2 show it when not aligned with the screen, 4 use the
|
||||
/// clip boundary, 8 transparency on.
|
||||
#[serde(default = "image_display")]
|
||||
pub display: u16,
|
||||
/// Brightness, contrast and fade, 0..=100.
|
||||
#[serde(default = "image_fifty")]
|
||||
pub brightness: u8,
|
||||
#[serde(default = "image_fifty")]
|
||||
pub contrast: u8,
|
||||
#[serde(default)]
|
||||
pub fade: u8,
|
||||
/// The definition's default size of one pixel and its resolution unit (0 none, 2 cm, 5 inch).
|
||||
#[serde(default = "image_pixel")]
|
||||
pub pixel_size: Vec2,
|
||||
#[serde(default)]
|
||||
pub resolution_units: u8,
|
||||
}
|
||||
fn image_display() -> u16 {
|
||||
7
|
||||
}
|
||||
fn image_fifty() -> u8 {
|
||||
50
|
||||
}
|
||||
fn image_pixel() -> Vec2 {
|
||||
Vec2::new(1.0, 1.0)
|
||||
}
|
||||
impl Default for Image {
|
||||
fn default() -> Self {
|
||||
Image {
|
||||
insert: Vec3::ZERO,
|
||||
u: Vec3::new(1.0, 0.0, 0.0),
|
||||
v: Vec3::new(0.0, 1.0, 0.0),
|
||||
size: Vec2::new(1.0, 1.0),
|
||||
path: String::new(),
|
||||
name: String::new(),
|
||||
clip: Vec::new(),
|
||||
clipping: false,
|
||||
display: image_display(),
|
||||
brightness: image_fifty(),
|
||||
contrast: image_fifty(),
|
||||
fade: 0,
|
||||
pixel_size: image_pixel(),
|
||||
resolution_units: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Wipeout {
|
||||
pub boundary: Vec<Vec2>,
|
||||
}
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableCell {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub merged: Option<(u32, u32)>,
|
||||
/// The cell's own cell style; empty: its row's.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub style: String,
|
||||
/// A formula (`=Sum(A1:B3)`); `text` holds its value.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub formula: Option<String>,
|
||||
/// The cell's own text height (DXF 140 in the cell record), before the table's scale;
|
||||
/// `None`: its cell style's.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text_height: Option<f64>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Table {
|
||||
pub insert: Vec3,
|
||||
pub col_widths: Vec<f64>,
|
||||
pub row_heights: Vec<f64>,
|
||||
pub cells: Vec<Vec<TableCell>>,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default = "default_text_h")]
|
||||
pub text_height: f64,
|
||||
/// First row is a title (merged across, larger text).
|
||||
#[serde(default)]
|
||||
pub title: bool,
|
||||
/// The row after the title holds column headers.
|
||||
#[serde(default)]
|
||||
pub header: bool,
|
||||
/// The cell style of each row; missing or empty: by position (Title, Header, Data).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub row_styles: Vec<String>,
|
||||
/// Table breaking (TABLEBREAK): the table drawn in several parts.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub breaks: Option<crate::TableBreak>,
|
||||
/// The CSV file the cells come from (DATALINK).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_link: Option<crate::DataLink>,
|
||||
}
|
||||
fn default_text_h() -> f64 {
|
||||
0.18
|
||||
}
|
||||
mod region;
|
||||
pub use region::*;
|
||||
|
||||
/// A raw DXF group: kept for round-tripping entities we do not model.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RawTag {
|
||||
pub code: i32,
|
||||
pub value: String,
|
||||
}
|
||||
mod block;
|
||||
mod common;
|
||||
mod curves;
|
||||
mod dim;
|
||||
mod fill;
|
||||
mod layout;
|
||||
mod text;
|
||||
|
||||
pub use block::{Attrib, AttribModes, AttribProps, Insert, MultilineAttrib, mtext_literal};
|
||||
pub use common::{Common, Handle, Lineweight, RawTag, Transparency};
|
||||
pub use curves::{Arc, Circle, Ellipse, Face3d, Line, LwPolyline, Point, Polyline3d, RayLine, Solid};
|
||||
pub use dim::{AssocSnap, DimAssoc, DimBreak, DimKind, DimMarks, Dimension, Inspection, JoggedRadius, Leader, MLeader};
|
||||
pub use fill::{Gradient, Hatch, HatchLoop, Wipeout};
|
||||
pub use layout::{Image, Table, TableCell, Viewport};
|
||||
pub use text::{HAlign, MText, MTextColumnType, MTextColumns, Text, VAlign};
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Unknown {
|
||||
pub dxf_type: String,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
use super::common::{Common, Lineweight};
|
||||
use super::text::{MText, Text};
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Attrib {
|
||||
pub tag: String,
|
||||
pub text: Text,
|
||||
#[serde(default)]
|
||||
pub invisible: bool,
|
||||
#[serde(default)]
|
||||
pub constant: bool,
|
||||
#[serde(default)]
|
||||
pub prompt: String,
|
||||
/// An attribute's own properties; each one left unset follows the attribute definition.
|
||||
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
|
||||
pub props: AttribProps,
|
||||
/// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as
|
||||
/// multiline text. `text.value` holds the plain text, which value edits change.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub multiline: Option<MultilineAttrib>,
|
||||
/// Its other modes: Verify, Preset and Lock position.
|
||||
#[serde(default, skip_serializing_if = "AttribModes::is_default")]
|
||||
pub modes: AttribModes,
|
||||
}
|
||||
|
||||
/// An attribute's modes beyond invisible and constant.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttribModes {
|
||||
/// Verify: the value is asked for twice when the block is inserted (DXF flag 4).
|
||||
#[serde(default)]
|
||||
pub verify: bool,
|
||||
/// Preset: the default value is used without asking (DXF flag 8).
|
||||
#[serde(default)]
|
||||
pub preset: bool,
|
||||
/// Lock position: the attribute can't be moved on its own within a reference (DXF group
|
||||
/// 280 after the flags). A reference's attributes take it from their definitions.
|
||||
#[serde(default)]
|
||||
pub lock_position: bool,
|
||||
}
|
||||
|
||||
impl AttribModes {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
/// The modes of a reference's attribute made from a definition with these: AutoCAD keeps
|
||||
/// only Lock position there.
|
||||
pub fn for_reference(self) -> Self {
|
||||
AttribModes { lock_position: self.lock_position, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The multiline text of a multiline attribute.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MultilineAttrib {
|
||||
pub mtext: MText,
|
||||
/// The plain text `mtext`'s contents read as: an attribute whose value differs was edited
|
||||
/// since, and its value replaces the contents.
|
||||
pub read_as: String,
|
||||
}
|
||||
|
||||
impl MultilineAttrib {
|
||||
/// This text placed by `m` (a block reference's transform, for its attribute made from a
|
||||
/// definition).
|
||||
pub fn transformed(&self, m: &Mat3) -> MultilineAttrib {
|
||||
let mut k = EntityKind::MText(self.mtext.clone());
|
||||
k.transform(m);
|
||||
let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() };
|
||||
MultilineAttrib { mtext, read_as: self.read_as.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Attrib {
|
||||
/// Move a multiline attribute's text by `m` along with the attribute (nothing for others).
|
||||
pub fn transform_multiline(&mut self, m: &Mat3) {
|
||||
if let Some(ml) = &mut self.multiline {
|
||||
*ml = ml.transformed(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline attribute's text as drawn and saved: its multiline text, with the contents
|
||||
/// replaced by the value when the value was edited since it was read.
|
||||
pub fn multiline_text(&self) -> Option<MText> {
|
||||
let m = self.multiline.as_ref()?;
|
||||
let mut t = m.mtext.clone();
|
||||
if self.text.value != m.read_as {
|
||||
t.contents = mtext_literal(&self.text.value);
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as
|
||||
/// paragraph breaks.
|
||||
pub fn mtext_literal(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'{' => out.push_str("\\{"),
|
||||
'}' => out.push_str("\\}"),
|
||||
'\n' => out.push_str("\\P"),
|
||||
'\r' => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
|
||||
/// lineweight), overriding those of its definition.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AttribProps {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Color>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub linetype: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lineweight: Option<Lineweight>,
|
||||
}
|
||||
|
||||
impl AttribProps {
|
||||
/// Nothing set: the attribute is drawn with its definition's properties.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == AttribProps::default()
|
||||
}
|
||||
/// `base` (the definition's properties) with the ones set here in place.
|
||||
pub fn over(&self, base: &Common) -> Common {
|
||||
Common {
|
||||
layer: self.layer.clone().unwrap_or_else(|| base.layer.clone()),
|
||||
color: self.color.unwrap_or(base.color),
|
||||
linetype: self.linetype.clone().unwrap_or_else(|| base.linetype.clone()),
|
||||
lineweight: self.lineweight.unwrap_or(base.lineweight),
|
||||
..base.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Insert {
|
||||
pub block: String,
|
||||
pub insert: Vec3,
|
||||
#[serde(default = "unit_scale")]
|
||||
pub scale: Vec3,
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
#[serde(default)]
|
||||
pub attribs: Vec<Attrib>,
|
||||
#[serde(default = "one_u")]
|
||||
pub cols: u32,
|
||||
#[serde(default = "one_u")]
|
||||
pub rows: u32,
|
||||
#[serde(default)]
|
||||
pub col_spacing: f64,
|
||||
#[serde(default)]
|
||||
pub row_spacing: f64,
|
||||
}
|
||||
|
||||
fn unit_scale() -> Vec3 {
|
||||
Vec3::new(1.0, 1.0, 1.0)
|
||||
}
|
||||
|
||||
fn one_u() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl Insert {
|
||||
/// Block-to-world transform (2D part).
|
||||
pub fn transform(&self, base: Vec2) -> Mat3 {
|
||||
Mat3::translate(self.insert.xy())
|
||||
.then_before(Mat3::rotate(self.rotation))
|
||||
.then_before(Mat3::scale(self.scale.x, self.scale.y))
|
||||
.then_before(Mat3::translate(-base))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use super::*;
|
||||
|
||||
/// A database handle (DXF group 5), unique per drawing.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct Handle(pub u64);
|
||||
|
||||
impl Handle {
|
||||
pub fn hex(self) -> String {
|
||||
format!("{:X}", self.0)
|
||||
}
|
||||
pub fn parse_hex(s: &str) -> Option<Handle> {
|
||||
let s = s.trim();
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u64::from_str_radix(s, 16).ok().map(Handle)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lineweight in hundredths of a millimetre, or a logical value.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Lineweight {
|
||||
#[default]
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Default,
|
||||
/// 0..=211 (hundredths of mm).
|
||||
Mm100(u16),
|
||||
}
|
||||
|
||||
impl Lineweight {
|
||||
/// The standard lineweight values (mm × 100).
|
||||
pub const STANDARD: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
pub fn to_dxf(self) -> i16 {
|
||||
match self {
|
||||
Lineweight::ByLayer => -1,
|
||||
Lineweight::ByBlock => -2,
|
||||
Lineweight::Default => -3,
|
||||
Lineweight::Mm100(v) => v.min(211) as i16,
|
||||
}
|
||||
}
|
||||
pub fn from_dxf(v: i16) -> Lineweight {
|
||||
match v {
|
||||
-2 => Lineweight::ByBlock,
|
||||
-3 => Lineweight::Default,
|
||||
0..=211 => Lineweight::Mm100(v as u16),
|
||||
_ => Lineweight::ByLayer,
|
||||
}
|
||||
}
|
||||
pub fn name(self) -> String {
|
||||
match self {
|
||||
Lineweight::ByLayer => "ByLayer".into(),
|
||||
Lineweight::ByBlock => "ByBlock".into(),
|
||||
Lineweight::Default => "Default".into(),
|
||||
Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparency: ByLayer, ByBlock, or 0..=90 percent.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Transparency {
|
||||
#[default]
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Percent(u8),
|
||||
}
|
||||
|
||||
impl Transparency {
|
||||
/// "ByLayer", "ByBlock" or the percentage ("50").
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
Transparency::ByLayer => "ByLayer".into(),
|
||||
Transparency::ByBlock => "ByBlock".into(),
|
||||
Transparency::Percent(p) => p.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties every entity has.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Common {
|
||||
pub layer: String,
|
||||
pub color: Color,
|
||||
pub linetype: String,
|
||||
pub lineweight: Lineweight,
|
||||
pub ltscale: f64,
|
||||
pub transparency: Transparency,
|
||||
pub visible: bool,
|
||||
pub thickness: f64,
|
||||
pub extrusion: Vec3,
|
||||
/// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot
|
||||
/// style table.
|
||||
pub plot_style: String,
|
||||
/// The annotation scales an annotative object supports (empty: not annotative).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub anno_scales: Vec<crate::AnnoScale>,
|
||||
/// Other applications' extended data (xdata) as read from the drawing file: whole
|
||||
/// applications, each from its `1001` group on. It is part of the object, so a copy of the
|
||||
/// object (COPY, MIRROR, ARRAY, paste) carries it, as in AutoCAD.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub xdata: Vec<RawTag>,
|
||||
}
|
||||
|
||||
impl Default for Common {
|
||||
fn default() -> Self {
|
||||
Common {
|
||||
layer: "0".into(),
|
||||
color: Color::ByLayer,
|
||||
linetype: "ByLayer".into(),
|
||||
lineweight: Lineweight::ByLayer,
|
||||
ltscale: 1.0,
|
||||
transparency: Transparency::ByLayer,
|
||||
visible: true,
|
||||
thickness: 0.0,
|
||||
extrusion: Vec3::Z,
|
||||
plot_style: "ByLayer".into(),
|
||||
anno_scales: Vec::new(),
|
||||
xdata: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw DXF group: kept for round-tripping entities we do not model.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RawTag {
|
||||
pub code: i32,
|
||||
pub value: String,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Line {
|
||||
pub a: Vec3,
|
||||
pub b: Vec3,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Point {
|
||||
pub p: Vec3,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Circle {
|
||||
pub center: Vec3,
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Arc {
|
||||
pub center: Vec3,
|
||||
pub radius: f64,
|
||||
/// Radians, CCW.
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Ellipse {
|
||||
pub center: Vec3,
|
||||
pub major: Vec3,
|
||||
pub ratio: f64,
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LwPolyline {
|
||||
pub vertices: Vec<PolyVertex>,
|
||||
pub closed: bool,
|
||||
#[serde(default)]
|
||||
pub const_width: f64,
|
||||
#[serde(default)]
|
||||
pub elevation: f64,
|
||||
/// Polyline linetype generation (PLINEGEN).
|
||||
#[serde(default)]
|
||||
pub plinegen: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Polyline3d {
|
||||
pub points: Vec<Vec3>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// SOLID / TRACE: up to four corners (DXF order 1,2,4,3 when drawn).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Solid {
|
||||
pub corners: [Vec3; 4],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Face3d {
|
||||
pub corners: [Vec3; 4],
|
||||
#[serde(default)]
|
||||
pub hidden_edges: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RayLine {
|
||||
pub base: Vec3,
|
||||
pub dir: Vec3,
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use super::block::Insert;
|
||||
use super::common::Handle;
|
||||
use super::text::MText;
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DimKind {
|
||||
/// Rotated linear dimension (horizontal = 0, vertical = 90°).
|
||||
Linear {
|
||||
rotation: f64,
|
||||
},
|
||||
Aligned,
|
||||
Angular,
|
||||
Angular3P,
|
||||
Diameter,
|
||||
Radius,
|
||||
Ordinate {
|
||||
x_type: bool,
|
||||
},
|
||||
ArcLength,
|
||||
}
|
||||
|
||||
/// A dimension. Definition points follow DXF: `defpt` (10: dimension line point),
|
||||
/// `p13`/`p14` (extension origins), `p15`/`p16` (arc/angle points), `text_mid` (11).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Dimension {
|
||||
pub kind: DimKind,
|
||||
pub defpt: Vec3,
|
||||
pub text_mid: Vec3,
|
||||
#[serde(default)]
|
||||
pub p13: Vec3,
|
||||
#[serde(default)]
|
||||
pub p14: Vec3,
|
||||
#[serde(default)]
|
||||
pub p15: Vec3,
|
||||
#[serde(default)]
|
||||
pub p16: Vec3,
|
||||
/// "" = measured value, "<>" inside = measurement placeholder, " " = suppressed.
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default)]
|
||||
pub measurement: f64,
|
||||
#[serde(default)]
|
||||
pub text_rotation: f64,
|
||||
/// User moved the text (text_mid is authoritative).
|
||||
#[serde(default)]
|
||||
pub user_text_pos: bool,
|
||||
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
|
||||
#[serde(default)]
|
||||
pub block: Option<String>,
|
||||
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
|
||||
/// merged over the named style.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub overrides: serde_json::Map<String, serde_json::Value>,
|
||||
/// Associativity: which definition points follow which objects.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assoc: Vec<DimAssoc>,
|
||||
/// Oblique extension lines, jogs, breaks and inspection frames.
|
||||
#[serde(default, skip_serializing_if = "DimMarks::is_empty")]
|
||||
pub marks: DimMarks,
|
||||
}
|
||||
|
||||
/// What dimension edits add to a dimension's drawing: DIMEDIT Oblique, DIMJOGLINE, DIMJOGGED,
|
||||
/// DIMBREAK and DIMINSPECT.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DimMarks {
|
||||
/// Angle (radians) of the extension lines of a linear or aligned dimension; None =
|
||||
/// perpendicular to the dimension line.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oblique: Option<f64>,
|
||||
/// A jog on the dimension line of a linear or aligned dimension, at this point (DIMJOGLINE).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jog_line: Option<Vec3>,
|
||||
/// A jogged radius dimension (DIMJOGGED).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jogged: Option<JoggedRadius>,
|
||||
/// Gaps in the dimension and extension lines (DIMBREAK).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub breaks: Vec<DimBreak>,
|
||||
/// Inspection frame, label and rate (DIMINSPECT).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inspect: Option<Inspection>,
|
||||
}
|
||||
|
||||
impl DimMarks {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == DimMarks::default()
|
||||
}
|
||||
|
||||
/// Transform the points and angles (part of [`EntityKind::transform`]).
|
||||
pub(super) fn transform(&mut self, t3: &dyn Fn(&mut Vec3), rot: f64, s: f64, mirror: bool) {
|
||||
if let Some(a) = &mut self.oblique {
|
||||
*a = cadcraft_geom::norm_angle(if mirror { rot - (*a - rot) } else { *a + rot });
|
||||
}
|
||||
if let Some(j) = &mut self.jog_line {
|
||||
t3(j);
|
||||
}
|
||||
if let Some(j) = &mut self.jogged {
|
||||
t3(&mut j.center);
|
||||
t3(&mut j.jog);
|
||||
}
|
||||
for b in &mut self.breaks {
|
||||
t3(&mut b.at);
|
||||
b.size *= s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The definition of a jogged radius dimension: the dimension line starts at an overriding
|
||||
/// centre and jogs on its way to the arc.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct JoggedRadius {
|
||||
/// Where the dimension line starts instead of the true centre.
|
||||
pub center: Vec3,
|
||||
/// The middle of the jog.
|
||||
pub jog: Vec3,
|
||||
}
|
||||
|
||||
/// A gap in a dimension's lines: everything within `size / 2` of `at` is left out.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DimBreak {
|
||||
pub at: Vec3,
|
||||
pub size: f64,
|
||||
/// Made by DIMBREAK Auto (recomputed by the next Auto) rather than picked by hand.
|
||||
pub auto: bool,
|
||||
}
|
||||
|
||||
/// An inspection dimension (DIMINSPECT).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Inspection {
|
||||
/// Frame shape: 0 round ends, 1 angular ends, 2 none.
|
||||
pub shape: u8,
|
||||
/// Text left of the value ("" = none).
|
||||
pub label: String,
|
||||
/// Inspection rate right of the value ("" = none).
|
||||
pub rate: String,
|
||||
}
|
||||
|
||||
/// A dimension definition point that follows an object (associative dimensions).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DimAssoc {
|
||||
/// Definition point: "defpt", "p13", "p14", "p15" or "p16".
|
||||
pub point: String,
|
||||
/// The object it is attached to.
|
||||
pub handle: Handle,
|
||||
pub snap: AssocSnap,
|
||||
}
|
||||
|
||||
/// Where on the object an associative definition point sits.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AssocSnap {
|
||||
/// Start point of a line/arc (or polyline vertex 0).
|
||||
Start,
|
||||
/// End point of a line/arc.
|
||||
End,
|
||||
Mid,
|
||||
/// Centre of a circle/arc.
|
||||
Center,
|
||||
/// On a circle/arc at a fixed angle (radians) from its centre.
|
||||
OnCircle {
|
||||
angle: f64,
|
||||
},
|
||||
/// Intersection of this (line) object with another line.
|
||||
Intersection {
|
||||
other: Handle,
|
||||
},
|
||||
/// Vertex `index` of a polyline.
|
||||
Vertex {
|
||||
index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Leader {
|
||||
pub vertices: Vec<Vec3>,
|
||||
#[serde(default = "yes")]
|
||||
pub arrow: bool,
|
||||
#[serde(default)]
|
||||
pub spline: bool,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MLeader {
|
||||
pub leaders: Vec<Vec<Vec3>>,
|
||||
pub landing: Vec3,
|
||||
pub dogleg: f64,
|
||||
pub text: Option<MText>,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default = "one")]
|
||||
pub arrow_size: f64,
|
||||
/// Leader lines are smooth curves through their points (leader type Spline). An empty
|
||||
/// `leaders` is a multileader without a leader line (leader type None).
|
||||
#[serde(default)]
|
||||
pub spline: bool,
|
||||
/// Block content in place of the text: a block reference placed at the landing.
|
||||
#[serde(default)]
|
||||
pub block: Option<Insert>,
|
||||
}
|
||||
|
||||
impl MLeader {
|
||||
/// Each leader line as drawn: through its points to the landing, smoothed for spline leaders.
|
||||
pub fn leader_paths(&self) -> Vec<Vec<Vec2>> {
|
||||
self.leaders
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(self.landing.xy())).collect();
|
||||
if self.spline && pts.len() > 2 { GSpline::from_fit_points(&pts).tessellate(1e-3) } else { pts }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::common::Handle;
|
||||
use super::*;
|
||||
|
||||
/// A hatch boundary loop: closed polyline with bulges.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HatchLoop {
|
||||
pub vertices: Vec<PolyVertex>,
|
||||
#[serde(default)]
|
||||
pub outer: bool,
|
||||
/// The objects the loop was made from (DXF group 97 count, 330 handles): an associative
|
||||
/// hatch re-fills the loop from them when they change.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub sources: Vec<Handle>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Hatch {
|
||||
pub pattern: String,
|
||||
pub solid: bool,
|
||||
pub loops: Vec<HatchLoop>,
|
||||
#[serde(default = "one")]
|
||||
pub scale: f64,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
#[serde(default)]
|
||||
pub associative: bool,
|
||||
/// 0 normal (odd parity), 1 outer, 2 ignore.
|
||||
#[serde(default)]
|
||||
pub style: u8,
|
||||
#[serde(default)]
|
||||
pub elevation: f64,
|
||||
/// Gradient fill: name + second colour.
|
||||
#[serde(default)]
|
||||
pub gradient: Option<Gradient>,
|
||||
#[serde(default)]
|
||||
pub origin: Vec2,
|
||||
#[serde(default)]
|
||||
pub background: Option<Color>,
|
||||
/// The pattern's line families as a file defined them, for a pattern that isn't in the
|
||||
/// standard library (unit scale and angle, like the library's; empty otherwise).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pattern_lines: Vec<crate::library::PatternLine>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Gradient {
|
||||
pub name: String,
|
||||
pub color1: Color,
|
||||
pub color2: Color,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
#[serde(default)]
|
||||
pub centered: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Wipeout {
|
||||
pub boundary: Vec<Vec2>,
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
use super::common::Handle;
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Viewport {
|
||||
pub center: Vec3,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub view_center: Vec2,
|
||||
pub view_height: f64,
|
||||
#[serde(default)]
|
||||
pub id: u32,
|
||||
#[serde(default)]
|
||||
pub locked: bool,
|
||||
#[serde(default)]
|
||||
pub frozen_layers: Vec<String>,
|
||||
/// Per-viewport layer colour overrides (VP Color in the Layer Properties Manager).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub layer_colors: Vec<(String, Color)>,
|
||||
/// The paper-space entity whose outline clips the viewport (a closed polyline, circle,
|
||||
/// ellipse or spline; DXF group 340). `None`: the rectangle `center`/`width`/`height`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clip: Option<Handle>,
|
||||
/// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.
|
||||
#[serde(default = "yes")]
|
||||
pub on: bool,
|
||||
}
|
||||
|
||||
impl Default for Viewport {
|
||||
fn default() -> Self {
|
||||
Viewport {
|
||||
center: Vec3::ZERO,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
view_center: Vec2::ZERO,
|
||||
view_height: 1.0,
|
||||
id: 0,
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Image {
|
||||
pub insert: Vec3,
|
||||
/// One pixel's width (U) and height (V) vectors in drawing units.
|
||||
pub u: Vec3,
|
||||
pub v: Vec3,
|
||||
/// Image size in pixels.
|
||||
pub size: Vec2,
|
||||
/// The image file (its IMAGEDEF object's path).
|
||||
pub path: String,
|
||||
/// The image definition's name (its key in the drawing's image dictionary).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
/// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down):
|
||||
/// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub clip: Vec<Vec2>,
|
||||
/// The clip boundary is applied.
|
||||
#[serde(default)]
|
||||
pub clipping: bool,
|
||||
/// Display flags: 1 show the image, 2 show it when not aligned with the screen, 4 use the
|
||||
/// clip boundary, 8 transparency on.
|
||||
#[serde(default = "image_display")]
|
||||
pub display: u16,
|
||||
/// Brightness, contrast and fade, 0..=100.
|
||||
#[serde(default = "image_fifty")]
|
||||
pub brightness: u8,
|
||||
#[serde(default = "image_fifty")]
|
||||
pub contrast: u8,
|
||||
#[serde(default)]
|
||||
pub fade: u8,
|
||||
/// The definition's default size of one pixel and its resolution unit (0 none, 2 cm, 5 inch).
|
||||
#[serde(default = "image_pixel")]
|
||||
pub pixel_size: Vec2,
|
||||
#[serde(default)]
|
||||
pub resolution_units: u8,
|
||||
}
|
||||
|
||||
fn image_display() -> u16 {
|
||||
7
|
||||
}
|
||||
|
||||
fn image_fifty() -> u8 {
|
||||
50
|
||||
}
|
||||
|
||||
fn image_pixel() -> Vec2 {
|
||||
Vec2::new(1.0, 1.0)
|
||||
}
|
||||
|
||||
impl Default for Image {
|
||||
fn default() -> Self {
|
||||
Image {
|
||||
insert: Vec3::ZERO,
|
||||
u: Vec3::new(1.0, 0.0, 0.0),
|
||||
v: Vec3::new(0.0, 1.0, 0.0),
|
||||
size: Vec2::new(1.0, 1.0),
|
||||
path: String::new(),
|
||||
name: String::new(),
|
||||
clip: Vec::new(),
|
||||
clipping: false,
|
||||
display: image_display(),
|
||||
brightness: image_fifty(),
|
||||
contrast: image_fifty(),
|
||||
fade: 0,
|
||||
pixel_size: image_pixel(),
|
||||
resolution_units: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableCell {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub merged: Option<(u32, u32)>,
|
||||
/// The cell's own cell style; empty: its row's.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub style: String,
|
||||
/// A formula (`=Sum(A1:B3)`); `text` holds its value.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub formula: Option<String>,
|
||||
/// The cell's own text height (DXF 140 in the cell record), before the table's scale;
|
||||
/// `None`: its cell style's.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text_height: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Table {
|
||||
pub insert: Vec3,
|
||||
pub col_widths: Vec<f64>,
|
||||
pub row_heights: Vec<f64>,
|
||||
pub cells: Vec<Vec<TableCell>>,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default = "default_text_h")]
|
||||
pub text_height: f64,
|
||||
/// First row is a title (merged across, larger text).
|
||||
#[serde(default)]
|
||||
pub title: bool,
|
||||
/// The row after the title holds column headers.
|
||||
#[serde(default)]
|
||||
pub header: bool,
|
||||
/// The cell style of each row; missing or empty: by position (Title, Header, Data).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub row_styles: Vec<String>,
|
||||
/// Table breaking (TABLEBREAK): the table drawn in several parts.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub breaks: Option<crate::TableBreak>,
|
||||
/// The CSV file the cells come from (DATALINK).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_link: Option<crate::DataLink>,
|
||||
}
|
||||
|
||||
fn default_text_h() -> f64 {
|
||||
0.18
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Aligned,
|
||||
Middle,
|
||||
Fit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Baseline,
|
||||
Bottom,
|
||||
Middle,
|
||||
Top,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Text {
|
||||
pub insert: Vec3,
|
||||
#[serde(default)]
|
||||
pub align_pt: Option<Vec3>,
|
||||
pub height: f64,
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
#[serde(default = "one")]
|
||||
pub width_factor: f64,
|
||||
#[serde(default)]
|
||||
pub oblique: f64,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default)]
|
||||
pub halign: HAlign,
|
||||
#[serde(default)]
|
||||
pub valign: VAlign,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
/// The point the text is placed by, as the Insertion snap and the grip show it: the alignment
|
||||
/// point (DXF group 11) of justified text, the start point (group 10) of left-baseline,
|
||||
/// aligned and fit text.
|
||||
pub fn justify_point(&self) -> Vec2 {
|
||||
match (self.halign, self.valign) {
|
||||
(HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(),
|
||||
_ => self.align_pt.unwrap_or(self.insert).xy(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MText {
|
||||
pub insert: Vec3,
|
||||
pub height: f64,
|
||||
/// Reference rectangle width (0 = no wrapping).
|
||||
#[serde(default)]
|
||||
pub width: f64,
|
||||
/// 1..=9: TL TC TR ML MC MR BL BC BR.
|
||||
#[serde(default = "attach_tl")]
|
||||
pub attach: u8,
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
/// Raw MTEXT contents with inline codes (\P, {\fArial|b1;...}, \S1/2; …).
|
||||
pub contents: String,
|
||||
#[serde(default = "one")]
|
||||
pub line_spacing: f64,
|
||||
/// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character
|
||||
/// heights; At least (`false`, the default) lets taller characters push lines apart.
|
||||
#[serde(default)]
|
||||
pub line_spacing_exact: bool,
|
||||
/// Columns the text flows through, each `width` wide (DXF 75/76/78/79/48/49/50); `None`:
|
||||
/// one column.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub columns: Option<MTextColumns>,
|
||||
}
|
||||
|
||||
fn attach_tl() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Static columns: a fixed number of columns of one height. Dynamic columns: as many columns
|
||||
/// as the text needs, of one height (auto height) or each its own (manual heights).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MTextColumnType {
|
||||
#[default]
|
||||
Static,
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// MTEXT columns (MTEXT Columns option, DXF Reference MTEXT groups 75–79, 48–50).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MTextColumns {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: MTextColumnType,
|
||||
/// Number of columns (static columns: the columns there are).
|
||||
pub count: u16,
|
||||
/// Space between two columns.
|
||||
pub gutter: f64,
|
||||
/// Height of every column (static, and dynamic with auto height).
|
||||
pub height: f64,
|
||||
/// Dynamic columns: one height for all columns (`height`) instead of `heights`.
|
||||
pub auto_height: bool,
|
||||
/// Dynamic columns with manual heights: the height of each column.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub heights: Vec<f64>,
|
||||
/// Columns run right to left.
|
||||
pub flow_reversed: bool,
|
||||
}
|
||||
|
||||
impl MTextColumns {
|
||||
/// The width of `count` columns of `column_width` and their gutters.
|
||||
pub fn total_width(&self, column_width: f64) -> f64 {
|
||||
let n = f64::from(self.count.max(1));
|
||||
column_width * n + self.gutter.max(0.0) * (n - 1.0)
|
||||
}
|
||||
|
||||
/// The tallest column.
|
||||
pub fn max_height(&self) -> f64 {
|
||||
self.heights.iter().copied().fold(self.height, f64::max).max(0.0)
|
||||
}
|
||||
|
||||
/// Scale every length (SCALE, block insertion).
|
||||
pub fn scale(&mut self, s: f64) {
|
||||
self.gutter *= s;
|
||||
self.height *= s;
|
||||
self.heights.iter_mut().for_each(|h| *h *= s);
|
||||
}
|
||||
}
|
||||
+15
-1708
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,330 @@
|
||||
use super::common::{DimText, add_dim, assoc_enabled, dim, hex_param, pick_at, segment_at, with_assoc};
|
||||
use super::*;
|
||||
|
||||
/// Two-line angular dimension (type 2) between two lines, attached to them.
|
||||
fn angular_from_lines(s: &Session, h1: Handle, h2: Handle, at: Vec2) -> Option<EntityKind> {
|
||||
let d = s.doc().ok()?;
|
||||
let line = |h: Handle| match &d.entity(h)?.kind {
|
||||
EntityKind::Line(l) => Some((l.a.xy(), l.b.xy())),
|
||||
_ => None,
|
||||
};
|
||||
let ((a1, a2), (b1, b2)) = (line(h1)?, line(h2)?);
|
||||
let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
|
||||
// Use the leg ends on the side of the picked arc location (the quadrant the user meant).
|
||||
let pick = |p: Vec2, q: Vec2, snap_p: AssocSnap, snap_q: AssocSnap| {
|
||||
let dir = if (p - v).len() > (q - v).len() { (p, snap_p) } else { (q, snap_q) };
|
||||
let other = if dir.1 == AssocSnap::Start { (q, AssocSnap::End) } else { (p, AssocSnap::Start) };
|
||||
let side = |x: Vec2| (x - v).normalized().dot((at - v).normalized());
|
||||
if side(other.0) > side(dir.0) && other.0.dist(v) > 1e-9 { other } else { dir }
|
||||
};
|
||||
let (p1, s1) = pick(a1, a2, AssocSnap::Start, AssocSnap::End);
|
||||
let (p2, s2) = pick(b1, b2, AssocSnap::Start, AssocSnap::End);
|
||||
// A two-line angular dimension (type 2): line 1 is p13 -> p14, line 2 is defpt -> p15, p16 is on the arc.
|
||||
// The picked leg end goes last in each pair, so the renderer measures between p14 and p15.
|
||||
let (o1, t1) = if s1 == AssocSnap::Start { (a2, AssocSnap::End) } else { (a1, AssocSnap::Start) };
|
||||
let (o2, t2) = if s2 == AssocSnap::Start { (b2, AssocSnap::End) } else { (b1, AssocSnap::Start) };
|
||||
let mut k = dim(s, DimKind::Angular, o2, o1, p1, p2, "");
|
||||
if let EntityKind::Dimension(d) = &mut k {
|
||||
d.p16 = v3(at);
|
||||
}
|
||||
let mut assoc = Vec::new();
|
||||
if assoc_enabled(s) {
|
||||
assoc = vec![
|
||||
DimAssoc { point: "p13".into(), handle: h1, snap: t1 },
|
||||
DimAssoc { point: "p14".into(), handle: h1, snap: s1 },
|
||||
DimAssoc { point: "defpt".into(), handle: h2, snap: t2 },
|
||||
DimAssoc { point: "p15".into(), handle: h2, snap: s2 },
|
||||
];
|
||||
}
|
||||
Some(with_assoc(k, assoc))
|
||||
}
|
||||
|
||||
pub(super) fn run_angular(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(ls) = p.get("lines").and_then(Value::as_array) {
|
||||
let hs: Vec<Handle> = ls.iter().filter_map(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))).collect();
|
||||
let (Some(h1), Some(h2)) = (hs.first(), hs.get(1)) else { return Err(bad("dimangular", "`lines` needs two line handles")) };
|
||||
let at = point_req("dimangular", p, "at")?;
|
||||
let k = angular_from_lines(s, *h1, *h2, at).ok_or_else(|| bad("dimangular", "objects must be two non-parallel lines"))?;
|
||||
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
|
||||
}
|
||||
if let Some(h) = hex_param(p, "arc") {
|
||||
let at = point_req("dimangular", p, "at")?;
|
||||
let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::Arc(a)) => a,
|
||||
_ => return Err(bad("dimangular", "object is not an arc")),
|
||||
};
|
||||
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
let k = dim(s, DimKind::Angular3P, at, g.start_point(), g.end_point(), g.center, "");
|
||||
return Ok(json!({ "handle": add_dim(s, k)?.hex() }));
|
||||
}
|
||||
let v = point_req("dimangular", p, "vertex")?;
|
||||
let a = point_req("dimangular", p, "p1")?;
|
||||
let b = point_req("dimangular", p, "p2")?;
|
||||
let at = point_req("dimangular", p, "at")?;
|
||||
if v.near(a, 1e-12) || v.near(b, 1e-12) {
|
||||
return Err(bad("dimangular", "`p1` and `p2` must differ from `vertex`"));
|
||||
}
|
||||
let k = dim(s, DimKind::Angular3P, at, a, b, v, "");
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
pub(super) fn run_arclen(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("dimarc", "`handle` of an arc is required"))?;
|
||||
let at = point_req("dimarc", p, "at")?;
|
||||
let a = match s.doc()?.entity(h).map(|e| e.kind.clone()) {
|
||||
Some(EntityKind::Arc(a)) => a,
|
||||
_ => return Err(bad("dimarc", "object is not an arc")),
|
||||
};
|
||||
let ga = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
let k = dim(s, DimKind::ArcLength, at, ga.start_point(), ga.end_point(), ga.center, "");
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct AngularM {
|
||||
handles: Vec<Handle>,
|
||||
first: Option<(Vec2, Vec2)>,
|
||||
second: Option<(Vec2, Vec2)>,
|
||||
vertex_mode: Vec<Vec2>,
|
||||
vertex: bool,
|
||||
arc: Option<(Vec2, Vec2, Vec2)>,
|
||||
text: DimText,
|
||||
/// Quadrant: a point inside the angle to measure; the arc location then only sets the radius.
|
||||
quadrant: Option<Vec2>,
|
||||
asking_quadrant: bool,
|
||||
}
|
||||
|
||||
impl AngularM {
|
||||
/// (vertex, p1, p2) of the angle being dimensioned.
|
||||
fn geometry(&self) -> Option<(Vec2, Vec2, Vec2)> {
|
||||
if let Some(a) = self.arc {
|
||||
return Some(a);
|
||||
}
|
||||
if let [v, a, b] = self.vertex_mode.as_slice() {
|
||||
return Some((*v, *a, *b));
|
||||
}
|
||||
let ((a1, a2), (b1, b2)) = (self.first?, self.second?);
|
||||
let (v, _, _) = cadcraft_geom::line_line_infinite(a1, a2, b1, b2)?;
|
||||
let far = |p: Vec2, q: Vec2| if p.dist(v) > q.dist(v) { p } else { q };
|
||||
Some((v, far(a1, a2), far(b1, b2)))
|
||||
}
|
||||
/// The arc location `at`, turned into the locked quadrant (same radius) when one is set.
|
||||
fn located(&self, v: Vec2, at: Vec2) -> Vec2 {
|
||||
match self.quadrant {
|
||||
Some(q) if q.dist(v) > 1e-12 && at.dist(v) > 1e-12 => v + (q - v).normalized() * at.dist(v),
|
||||
_ => at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AngularM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIMANGULAR"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(p) = self.text.prompt() {
|
||||
return p;
|
||||
}
|
||||
if self.asking_quadrant {
|
||||
return Prompt::new("Specify quadrant", Accept::POINT);
|
||||
}
|
||||
if self.geometry().is_some() {
|
||||
return Prompt::new("Specify dimension arc line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Quadrant"]);
|
||||
}
|
||||
if self.vertex {
|
||||
return Prompt::new(
|
||||
["Specify angle vertex", "Specify first angle endpoint", "Specify second angle endpoint"]
|
||||
.get(self.vertex_mode.len())
|
||||
.copied()
|
||||
.unwrap_or(""),
|
||||
Accept::POINT,
|
||||
);
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Select arc, circle, line", Accept::POINT).default("specify vertex"),
|
||||
Some(_) => Prompt::new("Select second line", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.text.answer(s, &i) {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.asking_quadrant {
|
||||
if let Input::Point(q) = i {
|
||||
self.quadrant = Some(q);
|
||||
}
|
||||
self.asking_quadrant = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if let Some((v, a, b)) = self.geometry() {
|
||||
return match i {
|
||||
Input::Point(at) => {
|
||||
let at = self.located(v, at);
|
||||
let k = match (self.handles.as_slice(), self.arc) {
|
||||
([h1, h2], None) => angular_from_lines(s, *h1, *h2, at),
|
||||
_ => None,
|
||||
};
|
||||
let k = k.unwrap_or_else(|| dim(s, DimKind::Angular3P, at, a, b, v, ""));
|
||||
add_dim(s, self.text.apply(k))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Keyword(k) => {
|
||||
if k == "Quadrant" {
|
||||
self.asking_quadrant = true;
|
||||
} else {
|
||||
self.text.keyword(&k);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
if self.vertex {
|
||||
match i {
|
||||
Input::Point(p) => self.vertex_mode.push(p),
|
||||
Input::Enter => return Ok(Step::Cancel),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Enter if self.first.is_none() => {
|
||||
self.vertex = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
let Some(h) = pick_at(s, p) else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let circle = s
|
||||
.doc()?
|
||||
.entity(h)
|
||||
.and_then(|e| if let EntityKind::Circle(c) = &e.kind { Some(cadcraft_geom::Circle::new(c.center.xy(), c.radius)) } else { None });
|
||||
if let Some(g) = circle.filter(|_| self.first.is_none()) {
|
||||
// The center is the vertex and the pick the first endpoint; ask for the second.
|
||||
self.vertex = true;
|
||||
self.vertex_mode = vec![g.center, g.closest(p)];
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match segment_at(s, h, p) {
|
||||
Some(Segment::Arc { arc: g, .. }) if self.first.is_none() => {
|
||||
self.arc = Some((g.center, g.start_point(), g.end_point()));
|
||||
}
|
||||
Some(Segment::Line(l)) => {
|
||||
self.handles.push(h);
|
||||
let seg = (l.a, l.b);
|
||||
if self.first.is_none() {
|
||||
self.first = Some(seg);
|
||||
} else {
|
||||
self.second = Some(seg);
|
||||
}
|
||||
}
|
||||
_ => s.echo("Select an arc, circle or line."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.geometry().map(|(v, a, b)| vec![self.text.apply(dim(s, DimKind::Angular3P, self.located(v, c), a, b, v, ""))]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ArcLenM {
|
||||
arc: Option<cadcraft_geom::Arc>,
|
||||
text: DimText,
|
||||
/// Partial: the points picked so far on the arc (`None` when not choosing a part).
|
||||
partial: Option<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
/// The part of `arc` between the points nearest `p` and `q` (in the arc's direction).
|
||||
fn arc_part(arc: &cadcraft_geom::Arc, p: Vec2, q: Vec2) -> Option<cadcraft_geom::Arc> {
|
||||
let sweep = arc.sweep();
|
||||
// Offset along the arc from its start, clamped to the nearer end when the point is off the arc.
|
||||
let along = |x: Vec2| {
|
||||
let t = cadcraft_geom::ccw_sweep(arc.start, arc.center.angle_to(x));
|
||||
if t <= sweep {
|
||||
t
|
||||
} else if t - sweep < std::f64::consts::TAU - t {
|
||||
sweep
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
let (t1, t2) = (along(p), along(q));
|
||||
let (lo, hi) = (t1.min(t2), t1.max(t2));
|
||||
(hi - lo > 1e-9).then(|| cadcraft_geom::Arc::new(arc.center, arc.radius, arc.start + lo, arc.start + hi))
|
||||
}
|
||||
|
||||
impl Interactive for ArcLenM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIMARC"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(p) = self.text.prompt() {
|
||||
return p;
|
||||
}
|
||||
if let Some(picked) = &self.partial {
|
||||
return match picked.first() {
|
||||
None => Prompt::new("Specify first point for arc length dimension", Accept::POINT),
|
||||
Some(&f) => Prompt::new("Specify second point for arc length dimension", Accept::POINT).base(f),
|
||||
};
|
||||
}
|
||||
match self.arc {
|
||||
None => Prompt::new("Select arc or polyline arc segment", Accept::POINT),
|
||||
Some(_) => Prompt::new("Specify arc length dimension location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Partial"]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.text.answer(s, &i) {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if let Some(picked) = self.partial.take() {
|
||||
match (i, picked.first().copied(), self.arc) {
|
||||
(Input::Point(p), None, _) => self.partial = Some(vec![p]),
|
||||
(Input::Point(q), Some(p), Some(g)) => match arc_part(&g, p, q) {
|
||||
Some(part) => self.arc = Some(part),
|
||||
None => s.echo("The two points must be different."),
|
||||
},
|
||||
(Input::Enter, ..) => {}
|
||||
_ => self.partial = Some(picked),
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.arc, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
if let Some(EntityKind::Arc(a)) = pick_at(s, p).and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
|
||||
self.arc = Some(cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end));
|
||||
} else {
|
||||
s.echo("Select an arc.");
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(g), Input::Point(at)) => {
|
||||
add_dim(s, self.text.apply(dim(s, DimKind::ArcLength, at, g.start_point(), g.end_point(), g.center, "")))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Some(_), Input::Keyword(k)) => {
|
||||
if k == "Partial" {
|
||||
self.partial = Some(Vec::new());
|
||||
} else {
|
||||
self.text.keyword(&k);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.partial.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.arc.map(|g| vec![self.text.apply(dim(s, DimKind::ArcLength, c, g.start_point(), g.end_point(), g.center, ""))]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn style(s: &Session) -> String {
|
||||
s.doc().map(|d| d.header.str("DIMSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into())
|
||||
}
|
||||
|
||||
pub(super) fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2, text: &str) -> EntityKind {
|
||||
EntityKind::Dimension(Dimension {
|
||||
kind,
|
||||
defpt: v3(defpt),
|
||||
text_mid: Vec3::ZERO,
|
||||
p13: v3(p13),
|
||||
p14: v3(p14),
|
||||
p15: v3(p15),
|
||||
p16: v3(Vec2::ZERO),
|
||||
text: text.into(),
|
||||
style: style(s),
|
||||
measurement: 0.0,
|
||||
text_rotation: 0.0,
|
||||
user_text_pos: false,
|
||||
block: None,
|
||||
overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(),
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Horizontal (0) or vertical (90°) from where the dimension line is placed (AutoCAD's rule).
|
||||
fn auto_rotation(p1: Vec2, p2: Vec2, at: Vec2) -> f64 {
|
||||
let (x0, x1) = (p1.x.min(p2.x), p1.x.max(p2.x));
|
||||
let (y0, y1) = (p1.y.min(p2.y), p1.y.max(p2.y));
|
||||
let outside_y = at.y < y0 || at.y > y1;
|
||||
let outside_x = at.x < x0 || at.x > x1;
|
||||
if outside_x && !outside_y { std::f64::consts::FRAC_PI_2 } else { 0.0 }
|
||||
}
|
||||
|
||||
pub(super) fn linear_kind(p1: Vec2, p2: Vec2, at: Vec2, rotation: Option<f64>, aligned: bool) -> DimKind {
|
||||
if aligned { DimKind::Aligned } else { DimKind::Linear { rotation: rotation.unwrap_or_else(|| auto_rotation(p1, p2, at)) } }
|
||||
}
|
||||
|
||||
/// Add a dimension. With DIMASSOC on, definition points that sit on object endpoints,
|
||||
/// vertices or centres are attached to them (associative dimensions).
|
||||
pub(super) fn add_dim(s: &mut Session, k: EntityKind) -> Result<Handle> {
|
||||
let mut k = k;
|
||||
if let EntityKind::Dimension(dm) = &mut k
|
||||
&& dm.assoc.is_empty()
|
||||
&& assoc_enabled(s)
|
||||
{
|
||||
dm.assoc = crate::assoc::reassociate(s.doc()?, &s.space(), dm);
|
||||
}
|
||||
let h = s.add_entity(k)?;
|
||||
s.last_dim = Some(h);
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
pub(super) fn assoc_enabled(s: &Session) -> bool {
|
||||
s.doc().map(|d| d.header.i64("DIMASSOC", 2) != 0).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(super) fn with_assoc(k: EntityKind, assoc: Vec<DimAssoc>) -> EntityKind {
|
||||
match k {
|
||||
EntityKind::Dimension(mut d) => {
|
||||
d.assoc = assoc;
|
||||
EntityKind::Dimension(d)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Centre and radius of a circle/arc entity.
|
||||
pub(super) fn circle_of(s: &Session, h: Handle) -> Option<(Vec2, f64)> {
|
||||
match &s.doc().ok()?.entity(h)?.kind {
|
||||
EntityKind::Circle(c) => Some((c.center.xy(), c.radius)),
|
||||
EntityKind::Arc(a) => Some((a.center.xy(), a.radius)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dimension line location of a radius/diameter dimension puts the text there, inside or
|
||||
/// outside the circle, as a user-positioned text (DXF flag 70 bit 128), like AutoCAD.
|
||||
pub(super) fn text_at(mut k: EntityKind, at: Vec2) -> EntityKind {
|
||||
if let EntityKind::Dimension(d) = &mut k {
|
||||
d.text_mid = v3(at);
|
||||
d.user_text_pos = true;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
pub(super) fn hex_param(p: &Value, key: &str) -> Option<Handle> {
|
||||
p.get(key).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
pub(super) fn pick_at(s: &Session, p: Vec2) -> Option<Handle> {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
crate::select::pick(s.doc().ok()?, &s.space(), p, ap)
|
||||
}
|
||||
|
||||
/// The straight or curved piece of `h` picked at `p`: a line, an arc, or the polyline segment nearest `p`.
|
||||
pub(in crate::cmd) fn segment_at(s: &Session, h: Handle, p: Vec2) -> Option<Segment> {
|
||||
match &s.doc().ok()?.entity(h)?.kind {
|
||||
EntityKind::Line(l) => Some(Segment::Line(cadcraft_geom::Line::new(l.a.xy(), l.b.xy()))),
|
||||
EntityKind::Arc(a) => Some(Segment::Arc { arc: cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }),
|
||||
EntityKind::LwPolyline(pl) => cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed }
|
||||
.segments()
|
||||
.into_iter()
|
||||
.min_by(|x, y| x.closest(p).dist(p).total_cmp(&y.closest(p).dist(p))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a dimension placement prompt asks after Mtext, Text or Angle.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum DimAsk {
|
||||
Text,
|
||||
Angle,
|
||||
}
|
||||
|
||||
/// The Mtext / Text / Angle options of the dimension placement prompts: replace the measured text
|
||||
/// (`<>` stands for the measurement) and rotate the text.
|
||||
#[derive(Default)]
|
||||
pub(super) struct DimText {
|
||||
text: String,
|
||||
angle: f64,
|
||||
asking: Option<DimAsk>,
|
||||
}
|
||||
|
||||
impl DimText {
|
||||
/// Opens the sub-prompt for Mtext, Text or Angle; `false` for any other keyword.
|
||||
pub(super) fn keyword(&mut self, k: &str) -> bool {
|
||||
self.asking = match k {
|
||||
"Mtext" | "Text" => Some(DimAsk::Text),
|
||||
"Angle" => Some(DimAsk::Angle),
|
||||
_ => return false,
|
||||
};
|
||||
true
|
||||
}
|
||||
pub(super) fn prompt(&self) -> Option<Prompt> {
|
||||
Some(match self.asking? {
|
||||
DimAsk::Text => Prompt::new("Enter dimension text (<> = measurement)", Accept::TEXT),
|
||||
DimAsk::Angle => Prompt::new("Specify angle of dimension text", Accept::NUMBER),
|
||||
})
|
||||
}
|
||||
/// Takes the answer to the open sub-prompt (Enter keeps the current value); `false` when none is open.
|
||||
pub(super) fn answer(&mut self, s: &mut Session, i: &Input) -> bool {
|
||||
let Some(a) = self.asking else { return false };
|
||||
match (a, i) {
|
||||
(DimAsk::Text, Input::Text(t)) => self.text = t.clone(),
|
||||
(DimAsk::Angle, Input::Text(t)) => match s.angle_settings().direction(t) {
|
||||
Some(r) => self.angle = r,
|
||||
None => {
|
||||
s.echo("Requires a valid angle.");
|
||||
return true;
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
self.asking = None;
|
||||
true
|
||||
}
|
||||
/// `k` with this text and text angle.
|
||||
pub(super) fn apply(&self, mut k: EntityKind) -> EntityKind {
|
||||
if let EntityKind::Dimension(d) = &mut k {
|
||||
d.text = self.text.clone();
|
||||
d.text_rotation = self.angle;
|
||||
}
|
||||
k
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
use super::common::{hex_param, style};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let st = style(s);
|
||||
let d = s.doc_mut()?;
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
let _ = d.modify_entity(h, |e| {
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
dm.style = st.clone();
|
||||
dm.block = None;
|
||||
n += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(json!({ "updated": n }))
|
||||
}
|
||||
|
||||
/// DIMOVERRIDE: per-dimension style overrides (`clear` removes them); `text` sets the text.
|
||||
pub(super) fn run_override(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("text").is_none() {
|
||||
return crate::cmd::props::dim_override(s, p);
|
||||
}
|
||||
let hs = targets(s, p)?;
|
||||
let t = str_param(p, "text").unwrap_or("").to_string();
|
||||
// Dimension variables given next to `text` are applied first, so an unknown name still errors before anything changes.
|
||||
let vars: serde_json::Map<String, Value> = p
|
||||
.as_object()
|
||||
.map(|o| o.iter().filter(|(k, _)| !matches!(k.as_str(), "text" | "handles" | "handle")).map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
.unwrap_or_default();
|
||||
let mut ignored = Value::Null;
|
||||
if !vars.is_empty() {
|
||||
let mut rest = vars;
|
||||
rest.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||||
ignored = crate::cmd::props::dim_override(s, &Value::Object(rest))?.get("ignored").cloned().unwrap_or(Value::Null);
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
for h in &hs {
|
||||
let _ = d.modify_entity(*h, |e| {
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
dm.text = t.clone();
|
||||
dm.block = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
match ignored {
|
||||
Value::Null => Ok(json!({ "changed": hs.len() })),
|
||||
ig => Ok(json!({ "changed": hs.len(), "ignored": ig })),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dims_of(s: &Session, hs: &[Handle]) -> Vec<Handle> {
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
hs.iter().copied().filter(|h| d.entity(*h).is_some_and(|e| matches!(e.kind, EntityKind::Dimension(_)))).collect()
|
||||
}
|
||||
|
||||
pub(super) fn run_reassociate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = dims_of(s, &targets(s, p)?);
|
||||
let space = s.space();
|
||||
let mut plan = Vec::new();
|
||||
{
|
||||
let d = s.doc()?;
|
||||
for h in &hs {
|
||||
if let Some(EntityKind::Dimension(dm)) = d.entity(*h).map(|e| &e.kind) {
|
||||
plan.push((*h, crate::assoc::reassociate(d, &space, dm)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut points = 0;
|
||||
let mut n = 0;
|
||||
let d = s.doc_mut()?;
|
||||
for (h, a) in plan {
|
||||
points += a.len();
|
||||
n += usize::from(!a.is_empty());
|
||||
let _ = d.modify_entity(h, |e| {
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind {
|
||||
dm.assoc = a;
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(json!({ "dimensions": hs.len(), "associated": n, "points": points, "message": format!("{n} of {} dimension(s) associated.", hs.len()) }))
|
||||
}
|
||||
|
||||
pub(super) fn run_disassociate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = dims_of(s, &targets(s, p)?);
|
||||
let d = s.doc_mut()?;
|
||||
let mut n = 0;
|
||||
for h in &hs {
|
||||
let _ = d.modify_entity(*h, |e| {
|
||||
if let EntityKind::Dimension(dm) = &mut e.kind
|
||||
&& !dm.assoc.is_empty()
|
||||
{
|
||||
dm.assoc.clear();
|
||||
n += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(json!({ "disassociated": n, "message": format!("{n} disassociated.") }))
|
||||
}
|
||||
|
||||
/// DIMTEDIT: move/rotate/justify dimension text.
|
||||
pub(super) fn run_tedit(s: &mut Session, p: &Value, mode: &str) -> Result<Value> {
|
||||
let mut q = p.as_object().cloned().unwrap_or_default();
|
||||
q.insert("mode".into(), json!(mode));
|
||||
run_dimtedit(s, &Value::Object(q))
|
||||
}
|
||||
|
||||
pub(super) fn run_dimtedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = dims_of(s, &targets(s, p)?);
|
||||
if hs.is_empty() {
|
||||
return Err(bad("dimtedit", "select dimensions"));
|
||||
}
|
||||
let mode = str_param(p, "mode").unwrap_or("").to_ascii_lowercase();
|
||||
let at = point_param(p, "at");
|
||||
let angle = p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).map(f64::to_radians);
|
||||
if mode == "angle" && angle.is_none() {
|
||||
return Err(bad("dimtedit", "`angle` (degrees) is required"));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
let dimscale = d.header.f64("DIMSCALE", 1.0);
|
||||
for h in &hs {
|
||||
let st = match d.entity(*h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Dimension(dm)) => d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides),
|
||||
_ => continue,
|
||||
};
|
||||
d.modify_entity(*h, |e| {
|
||||
let EntityKind::Dimension(dm) = &mut e.kind else { return };
|
||||
dm.block = None;
|
||||
if let Some(q) = at {
|
||||
dm.text_mid = v3(q);
|
||||
dm.user_text_pos = true;
|
||||
// DIMTMOVE 0: the dimension line moves with the text.
|
||||
st.follow_text(dm, dimscale);
|
||||
}
|
||||
match mode.as_str() {
|
||||
"home" => {
|
||||
dm.user_text_pos = false;
|
||||
dm.text_rotation = 0.0;
|
||||
dm.overrides.remove("textJust");
|
||||
}
|
||||
"angle" => dm.text_rotation = angle.unwrap_or(0.0),
|
||||
"left" | "center" | "right" => {
|
||||
dm.user_text_pos = false;
|
||||
let j = match mode.as_str() {
|
||||
"left" => 1,
|
||||
"right" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
dm.overrides.insert("textJust".into(), json!(j));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "changed": hs.len() }))
|
||||
}
|
||||
|
||||
/// DIMSPACE: equal spacing of parallel linear dimensions from a base dimension.
|
||||
pub(super) fn run_dimspace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = hex_param(p, "base").ok_or_else(|| bad("dimspace", "`base` dimension is required"))?;
|
||||
let hs: Vec<Handle> = dims_of(s, &targets(s, p)?).into_iter().filter(|h| *h != base).collect();
|
||||
let d = s.doc()?;
|
||||
let Some(EntityKind::Dimension(b)) = d.entity(base).map(|e| e.kind.clone()) else { return Err(bad("dimspace", "`base` is not a dimension")) };
|
||||
let st = d.dim_style(&b.style).cloned().unwrap_or_default().with_overrides(&b.overrides);
|
||||
let spacing = p
|
||||
.get("spacing")
|
||||
.and_then(Value::as_f64)
|
||||
.filter(|v| v.is_finite() && *v >= 0.0)
|
||||
.unwrap_or(st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0)) * 2.0);
|
||||
let dir = match b.kind {
|
||||
DimKind::Linear { rotation } => Vec2::from_angle(rotation),
|
||||
DimKind::Aligned => (b.p14.xy() - b.p13.xy()).normalized(),
|
||||
_ => return Err(bad("dimspace", "base must be a linear or aligned dimension")),
|
||||
};
|
||||
let n = dir.perp();
|
||||
let base_off = (b.defpt.xy() - b.p13.xy()).dot(n);
|
||||
let side = if base_off >= 0.0 { 1.0 } else { -1.0 };
|
||||
// Order the others by their current distance from the base line.
|
||||
let mut others: Vec<(Handle, f64)> = hs
|
||||
.iter()
|
||||
.filter_map(|h| match d.entity(*h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Dimension(o)) if matches!(o.kind, DimKind::Linear { .. } | DimKind::Aligned) => {
|
||||
Some((*h, (o.defpt.xy() - b.defpt.xy()).dot(n) * side))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
others.sort_by(|a, c| a.1.total_cmp(&c.1));
|
||||
let line_pt = b.defpt.xy();
|
||||
let dm = s.doc_mut()?;
|
||||
for (i, (h, _)) in others.iter().enumerate() {
|
||||
let target = line_pt + n * (side * spacing * (i as f64 + 1.0));
|
||||
dm.modify_entity(*h, |e| {
|
||||
if let EntityKind::Dimension(o) = &mut e.kind {
|
||||
let cur = o.defpt.xy();
|
||||
let moved = cur + n * (target - cur).dot(n);
|
||||
o.defpt = v3(moved);
|
||||
o.user_text_pos = false;
|
||||
o.block = None;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "spaced": others.len() }))
|
||||
}
|
||||
|
||||
/// DIMSPACE at the command line: select the base dimension, select the dimensions to space, then
|
||||
/// the spacing (Enter or Auto takes the DIMDLI-based default).
|
||||
#[derive(Default)]
|
||||
pub(super) struct DimSpaceM {
|
||||
base: Option<Handle>,
|
||||
sel: SelectPhase,
|
||||
spacing_phase: bool,
|
||||
}
|
||||
|
||||
impl DimSpaceM {
|
||||
fn apply(&self, s: &mut Session, base: Handle, spacing: Option<f64>) -> Result<Step> {
|
||||
let mut p = serde_json::Map::new();
|
||||
p.insert("base".into(), json!(base.hex()));
|
||||
p.insert("handles".into(), json!(self.sel.picked.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||||
if let Some(v) = spacing {
|
||||
p.insert("spacing".into(), json!(v));
|
||||
}
|
||||
let r = run_dimspace(s, &Value::Object(p))?;
|
||||
s.set_selection(Vec::new());
|
||||
s.echo(format!("{} dimension(s) spaced.", r.get("spaced").and_then(Value::as_u64).unwrap_or(0)));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for DimSpaceM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIMSPACE"
|
||||
}
|
||||
fn begin(&mut self, _s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::single();
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.spacing_phase {
|
||||
return Prompt::new("Enter value", Accept::NUMBER).kw(&["Auto"]).default("Auto");
|
||||
}
|
||||
if self.base.is_none() {
|
||||
return Prompt::new("Select base dimension", Accept::SELECT);
|
||||
}
|
||||
Prompt::new("Select dimensions to space", Accept::SELECT)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.spacing_phase {
|
||||
let Some(base) = self.base else { return Ok(Step::Cancel) };
|
||||
return match i {
|
||||
Input::Enter => self.apply(s, base, None),
|
||||
Input::Keyword(k) | Input::Text(k) if k.trim().eq_ignore_ascii_case("a") || k.trim().eq_ignore_ascii_case("auto") => {
|
||||
self.apply(s, base, None)
|
||||
}
|
||||
Input::Text(t) => match crate::cmd::machines::number(&t).filter(|v| v.is_finite() && *v >= 0.0) {
|
||||
Some(v) => self.apply(s, base, Some(v)),
|
||||
None => {
|
||||
s.echo("Requires a non-negative number or Auto.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
if self.base.is_none() {
|
||||
match hs.first().copied().filter(|h| !dims_of(s, &[*h]).is_empty()) {
|
||||
Some(b) => {
|
||||
self.base = Some(b);
|
||||
self.sel = SelectPhase::default();
|
||||
}
|
||||
None => {
|
||||
s.echo("Select a dimension.");
|
||||
self.sel = SelectPhase::single();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.sel.picked = hs;
|
||||
self.spacing_phase = true;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DIMOVERRIDE at the command line: variable, value (repeat), Enter, then select dimensions.
|
||||
pub(super) struct OverrideM {
|
||||
vars: serde_json::Map<String, Value>,
|
||||
var: Option<String>,
|
||||
clear: bool,
|
||||
selecting: Option<crate::cmd::machines::SelectPhase>,
|
||||
}
|
||||
|
||||
impl OverrideM {
|
||||
pub(super) fn new(_s: &Session) -> Self {
|
||||
OverrideM { vars: serde_json::Map::new(), var: None, clear: false, selecting: None }
|
||||
}
|
||||
fn apply(&self, s: &mut Session, hs: Vec<Handle>) -> Result<Step> {
|
||||
let mut p = self.vars.clone();
|
||||
p.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>()));
|
||||
if self.clear {
|
||||
p.insert("clear".into(), json!(true));
|
||||
}
|
||||
let r = crate::cmd::props::dim_override(s, &Value::Object(p))?;
|
||||
s.echo(format!("{} dimension(s) changed.", r.get("changed").and_then(Value::as_u64).unwrap_or(0)));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for OverrideM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIMOVERRIDE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(sel) = &self.selecting {
|
||||
return sel.prompt();
|
||||
}
|
||||
match &self.var {
|
||||
Some(v) => Prompt::new(format!("Enter new value for dimension variable {}", v.to_ascii_uppercase()), Accept::TEXT),
|
||||
None => Prompt::new("Enter dimension variable name to override", Accept::TEXT).kw(&["Clear overrides"]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(sel) = &mut self.selecting {
|
||||
return match sel.feed(s, &i)? {
|
||||
crate::cmd::machines::SelOutcome::More => Ok(Step::Continue),
|
||||
crate::cmd::machines::SelOutcome::Empty => Ok(Step::Done),
|
||||
crate::cmd::machines::SelOutcome::Done(hs) => self.apply(s, hs),
|
||||
};
|
||||
}
|
||||
match (self.var.take(), i) {
|
||||
(None, Input::Keyword(k)) if k.starts_with("Clear") => {
|
||||
self.clear = true;
|
||||
self.selecting = Some(crate::cmd::machines::SelectPhase::default());
|
||||
}
|
||||
(None, Input::Text(t)) => {
|
||||
let t = t.trim().to_string();
|
||||
if t.eq_ignore_ascii_case("c") {
|
||||
self.clear = true;
|
||||
self.selecting = Some(crate::cmd::machines::SelectPhase::default());
|
||||
} else if cadcraft_doc::DimStyle::field_name(&t).is_some() {
|
||||
self.var = Some(t);
|
||||
} else {
|
||||
s.echo(format!("Unknown dimension variable \"{t}\"."));
|
||||
}
|
||||
}
|
||||
(None, Input::Enter) => {
|
||||
if self.vars.is_empty() {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
self.selecting = Some(crate::cmd::machines::SelectPhase::default());
|
||||
}
|
||||
(Some(v), Input::Text(t)) => {
|
||||
let val = t.trim().parse::<f64>().map(|x| json!(x)).unwrap_or_else(|_| json!(t.trim()));
|
||||
self.vars.insert(v, val);
|
||||
}
|
||||
(Some(v), _) => self.var = Some(v),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, _c: Vec2) -> Vec<EntityKind> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
use super::common::{DimText, add_dim, assoc_enabled, circle_of, dim, linear_kind, pick_at};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_linear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (a, b) = match p.get("object").and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle))) {
|
||||
Some(h) => object_extent(s, h).ok_or_else(|| bad("dimlinear", "`object` must be a line, arc, circle or polyline segment"))?,
|
||||
None => (point_req("dimlinear", p, "p1")?, point_req("dimlinear", p, "p2")?),
|
||||
};
|
||||
let at = point_req("dimlinear", p, "at")?;
|
||||
let rot = p.get("rotation").and_then(Value::as_f64).map(f64::to_radians);
|
||||
let k = dim(s, linear_kind(a, b, at, rot, false), at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
pub(super) fn run_aligned(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("dimaligned", p, "p1")?;
|
||||
let b = point_req("dimaligned", p, "p2")?;
|
||||
let at = point_req("dimaligned", p, "at")?;
|
||||
let k = dim(s, DimKind::Aligned, at, a, b, Vec2::ZERO, str_param(p, "text").unwrap_or(""));
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
pub(super) fn run_ordinate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = point_req("dimordinate", p, "feature")?;
|
||||
let l = point_req("dimordinate", p, "leader")?;
|
||||
let xtype = p.get("xtype").and_then(Value::as_bool).unwrap_or((l - f).y.abs() > (l - f).x.abs());
|
||||
let k = dim(s, DimKind::Ordinate { x_type: xtype }, Vec2::ZERO, f, l, Vec2::ZERO, "");
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
/// The last linear/aligned dimension (for CONTINUE/BASELINE).
|
||||
fn last_linear(s: &Session) -> Option<Dimension> {
|
||||
let h = s.last_dim?;
|
||||
match &s.doc().ok()?.entity(h)?.kind {
|
||||
EntityKind::Dimension(d) if matches!(d.kind, DimKind::Linear { .. } | DimKind::Aligned) => Some(d.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Next dimension in a chain from `prev` to a new second origin.
|
||||
fn chained(s: &Session, prev: &Dimension, next: Vec2, baseline: bool) -> EntityKind {
|
||||
// DIMDLI × the overall scale of the dimension being continued: its overrides (SETVAR DIM*)
|
||||
// included, and DIMSCALE 0 falling back to the drawing's DIMSCALE as when it is drawn.
|
||||
let spacing = s
|
||||
.doc()
|
||||
.ok()
|
||||
.map(|d| {
|
||||
let st = d.dim_style(&prev.style).cloned().unwrap_or_default().with_overrides(&prev.overrides);
|
||||
st.baseline_spacing * st.effective_scale(d.header.f64("DIMSCALE", 1.0))
|
||||
})
|
||||
.filter(|v| v.is_finite())
|
||||
.unwrap_or(0.38);
|
||||
let dir = match prev.kind {
|
||||
DimKind::Linear { rotation } => Vec2::from_angle(rotation),
|
||||
_ => (prev.p14.xy() - prev.p13.xy()).normalized(),
|
||||
};
|
||||
let first = if baseline { prev.p13.xy() } else { prev.p14.xy() };
|
||||
let mut defpt = prev.defpt.xy();
|
||||
if baseline {
|
||||
// Offset the dimension line away from the measured points.
|
||||
let n = dir.perp();
|
||||
let side = if (defpt - prev.p13.xy()).dot(n) >= 0.0 { 1.0 } else { -1.0 };
|
||||
defpt += n * (spacing * side);
|
||||
}
|
||||
EntityKind::Dimension(Dimension {
|
||||
p13: v3(first),
|
||||
p14: v3(next),
|
||||
defpt: v3(defpt),
|
||||
text: String::new(),
|
||||
block: None,
|
||||
assoc: Vec::new(),
|
||||
marks: Default::default(),
|
||||
user_text_pos: false,
|
||||
..prev.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Most dimensions one DIMCONTINUE/DIMBASELINE call adds.
|
||||
pub(super) const MAX_CHAIN: usize = 100_000;
|
||||
|
||||
fn chain_run(s: &mut Session, p: &Value, baseline: bool) -> Result<Value> {
|
||||
let id = if baseline { "dimbaseline" } else { "dimcontinue" };
|
||||
let pts = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(id, "`points` is required"))?;
|
||||
if pts.len() > MAX_CHAIN {
|
||||
return Err(bad(id, format!("at most {MAX_CHAIN} `points` per call")));
|
||||
}
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad(id, "`points` must be a list of points"))?;
|
||||
// One snap index for the whole chain: looking up each new point's object by scanning the
|
||||
// drawing made long chains quadratic (the added dimensions are no snap targets).
|
||||
let snaps = if assoc_enabled(s) { Some(crate::assoc::SnapIndex::new(s.doc()?, &s.space())) } else { None };
|
||||
let mut out = Vec::new();
|
||||
for q in pts {
|
||||
let prev = last_linear(s).ok_or_else(|| bad(id, "no linear dimension to continue"))?;
|
||||
let mut k = chained(s, &prev, q, baseline);
|
||||
if let (Some(ix), EntityKind::Dimension(dm)) = (&snaps, &mut k) {
|
||||
dm.assoc = ix.auto_assoc(&[("p13", dm.p13.xy()), ("p14", dm.p14.xy())]);
|
||||
}
|
||||
let h = s.add_entity(k)?;
|
||||
s.last_dim = Some(h);
|
||||
out.push(h.hex());
|
||||
}
|
||||
Ok(json!({ "handles": out }))
|
||||
}
|
||||
|
||||
pub(super) fn run_continue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chain_run(s, p, false)
|
||||
}
|
||||
|
||||
pub(super) fn run_baseline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
chain_run(s, p, true)
|
||||
}
|
||||
|
||||
/// The two points a picked object is dimensioned between (DIMLINEAR "select object").
|
||||
fn object_extent(s: &Session, h: Handle) -> Option<(Vec2, Vec2)> {
|
||||
let e = s.doc().ok()?.entity(h)?;
|
||||
if let Some((c, r)) = circle_of(s, h).filter(|_| matches!(e.kind, EntityKind::Circle(_))) {
|
||||
return Some((c - Vec2::X * r, c + Vec2::X * r));
|
||||
}
|
||||
e.kind.prims().into_iter().find_map(|pr| match pr {
|
||||
Prim::Seg(sg @ (Segment::Line(_) | Segment::Arc { .. })) => Some((sg.start(), sg.end())),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct LinearM {
|
||||
aligned: bool,
|
||||
pts: Vec<Vec2>,
|
||||
rotation: Option<f64>,
|
||||
text: DimText,
|
||||
selecting: bool,
|
||||
/// Asking for the dimension line angle (Rotated).
|
||||
rotating: bool,
|
||||
}
|
||||
|
||||
impl LinearM {
|
||||
pub(super) fn new(aligned: bool) -> Self {
|
||||
LinearM { aligned, pts: Vec::new(), rotation: None, text: DimText::default(), selecting: false, rotating: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for LinearM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.aligned { "DIMALIGNED" } else { "DIMLINEAR" }
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.rotating {
|
||||
return Prompt::new("Specify angle of dimension line", Accept::NUMBER);
|
||||
}
|
||||
if let Some(p) = self.text.prompt() {
|
||||
return p;
|
||||
}
|
||||
if self.selecting {
|
||||
return Prompt::new("Select object to dimension", Accept::POINT);
|
||||
}
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify first extension line origin", Accept::POINT).default("select object"),
|
||||
1 => Prompt::new("Specify second extension line origin", Accept::POINT).base_opt(self.pts.first().copied()),
|
||||
// Once the orientation is forced, only the text options remain.
|
||||
_ if self.aligned || self.rotation.is_some() => {
|
||||
Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"])
|
||||
}
|
||||
_ => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle", "Horizontal", "Vertical", "Rotated"]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.rotating {
|
||||
if let Input::Text(t) = &i {
|
||||
let Some(r) = s.angle_settings().direction(t) else {
|
||||
s.echo("Requires a valid angle.");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
self.rotation = Some(r);
|
||||
}
|
||||
self.rotating = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.text.answer(s, &i) {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.selecting {
|
||||
if let Input::Point(p) = i {
|
||||
let Some(h) = pick_at(s, p) else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let seg =
|
||||
s.doc()?.entity(h).and_then(|e| e.kind.prims().into_iter().find_map(|pr| if let Prim::Seg(sg) = pr { Some(sg) } else { None }));
|
||||
match seg {
|
||||
Some(sg @ Segment::Line(_)) | Some(sg @ Segment::Arc { .. }) => {
|
||||
self.pts = vec![sg.start(), sg.end()];
|
||||
self.selecting = false;
|
||||
}
|
||||
None => {
|
||||
if let Some((c, r)) = circle_of(s, h) {
|
||||
self.pts = vec![c - Vec2::X * r, c + Vec2::X * r];
|
||||
self.selecting = false;
|
||||
} else {
|
||||
s.echo("Object cannot be dimensioned this way.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.pts.len(), i) {
|
||||
(0, Input::Enter) => {
|
||||
self.selecting = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(n, Input::Point(p)) if n < 2 => {
|
||||
self.pts.push(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, Input::Point(at)) => {
|
||||
let (Some(&a), Some(&b)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) };
|
||||
let k = self.text.apply(dim(s, linear_kind(a, b, at, self.rotation, self.aligned), at, a, b, Vec2::ZERO, ""));
|
||||
add_dim(s, k)?;
|
||||
if let Some(EntityKind::Dimension(d)) = s.last_dim.and_then(|h| s.doc().ok()?.entity(h).map(|e| e.kind.clone())) {
|
||||
let st = s.doc()?.dim_style(&d.style).cloned().unwrap_or_default();
|
||||
let g = cadcraft_render::dimension_geometry(&d, &st, 1.0);
|
||||
s.echo(format!("Dimension text = {}", g.value));
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, Input::Keyword(k)) => {
|
||||
match k.as_str() {
|
||||
"Horizontal" => self.rotation = Some(0.0),
|
||||
"Vertical" => self.rotation = Some(std::f64::consts::FRAC_PI_2),
|
||||
"Rotated" => self.rotating = true,
|
||||
other => {
|
||||
self.text.keyword(other);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match self.pts.as_slice() {
|
||||
[a] => vec![crate::cmd::helpers::line(*a, c)],
|
||||
[a, b] => vec![self.text.apply(dim(s, linear_kind(*a, *b, c, self.rotation, self.aligned), c, *a, *b, Vec2::ZERO, ""))],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct OrdinateM {
|
||||
feature: Option<Vec2>,
|
||||
/// Xdatum / Ydatum: the forced ordinate type (`true` = X datum).
|
||||
xtype: Option<bool>,
|
||||
text: DimText,
|
||||
}
|
||||
|
||||
impl OrdinateM {
|
||||
fn kind(&self, s: &Session, f: Vec2, l: Vec2) -> EntityKind {
|
||||
let x = self.xtype.unwrap_or((l - f).y.abs() > (l - f).x.abs());
|
||||
self.text.apply(dim(s, DimKind::Ordinate { x_type: x }, Vec2::ZERO, f, l, Vec2::ZERO, ""))
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for OrdinateM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIMORDINATE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(p) = self.text.prompt() {
|
||||
return p;
|
||||
}
|
||||
match (self.feature, self.xtype) {
|
||||
(None, _) => Prompt::new("Specify feature location", Accept::POINT),
|
||||
(Some(f), None) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Xdatum", "Ydatum", "Mtext", "Text", "Angle"]).base(f),
|
||||
(Some(f), Some(_)) => Prompt::new("Specify leader endpoint", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(f),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.text.answer(s, &i) {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.feature, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
self.feature = Some(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(_), Input::Keyword(k)) => {
|
||||
match k.as_str() {
|
||||
"Xdatum" => self.xtype = Some(true),
|
||||
"Ydatum" => self.xtype = Some(false),
|
||||
other => {
|
||||
self.text.keyword(other);
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(f), Input::Point(l)) => {
|
||||
add_dim(s, self.kind(s, f, l))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.feature.map(|f| vec![self.kind(s, f, c)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct ChainM {
|
||||
baseline: bool,
|
||||
created: Vec<Handle>,
|
||||
}
|
||||
|
||||
impl ChainM {
|
||||
pub(super) fn new(_s: &Session, baseline: bool) -> Self {
|
||||
ChainM { baseline, created: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for ChainM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.baseline { "DIMBASELINE" } else { "DIMCONTINUE" }
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
if last_linear(s).is_none() {
|
||||
s.echo("No linear dimension to continue. Create one with DIMLINEAR first.");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
Prompt::new("Specify a second extension line origin", Accept::POINT)
|
||||
.kw(&["Select", "Undo"])
|
||||
.default("Select")
|
||||
.base_opt(last_linear(s).map(|d| if self.baseline { d.p13.xy() } else { d.p14.xy() }))
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
let prev = last_linear(s).ok_or_else(|| EngineError::Other("no linear dimension".into()))?;
|
||||
let k = chained(s, &prev, p, self.baseline);
|
||||
self.created.push(add_dim(s, k)?);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
if let Some(h) = self.created.pop() {
|
||||
s.doc_mut()?.remove_entity(h);
|
||||
s.last_dim = self.created.last().copied();
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
last_linear(s).map(|d| vec![chained(s, &d, c, self.baseline)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use super::common::style;
|
||||
use super::*;
|
||||
|
||||
/// A multileader from arrowhead to landing; `landing` false leaves out the landing line (dogleg).
|
||||
fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<EntityKind> {
|
||||
let (landing_pt, arrow_pts) = pts.split_last()?;
|
||||
let d = s.doc().ok()?;
|
||||
let cur = d.header.str("CMLEADERSTYLE", "Standard");
|
||||
let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default();
|
||||
// The style's sizes are the drawn sizes: a multileader style has no scale of its own and
|
||||
// DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD.
|
||||
let th = st.text_height;
|
||||
let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 };
|
||||
let attach = if dir > 0.0 { 4 } else { 6 };
|
||||
let dogleg = if landing { st.dogleg } else { 0.0 };
|
||||
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
|
||||
Some(EntityKind::MLeader(MLeader {
|
||||
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
|
||||
landing: v3(*landing_pt),
|
||||
dogleg,
|
||||
text: (!text.is_empty()).then(|| MText {
|
||||
insert: v3(tpos),
|
||||
height: th,
|
||||
width: 0.0,
|
||||
attach,
|
||||
rotation: 0.0,
|
||||
style: st.text_style.clone(),
|
||||
contents: text.replace('\n', "\\P"),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}),
|
||||
style: st.name.clone(),
|
||||
arrow_size: st.arrow_size,
|
||||
spline: false,
|
||||
block: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn run_mleader(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("mleader", "`points` (arrow … landing) is required"))?;
|
||||
let content = crate::cmd::mleader_content::json_content(s, p)?;
|
||||
// Leader type None places the content at the one point given.
|
||||
if pts.len() < 2 && !(content.leader == crate::LeaderType::None && pts.len() == 1) {
|
||||
return Err(bad("mleader", "need an arrowhead and a landing point"));
|
||||
}
|
||||
let k = mleader_kind(s, &pts, str_param(p, "text").unwrap_or(""), true).ok_or_else(|| bad("mleader", "bad points"))?;
|
||||
let k = crate::cmd::mleader_content::apply(s, k, &content)?;
|
||||
Ok(json!({ "handle": s.add_entity(k)?.hex() }))
|
||||
}
|
||||
|
||||
pub(super) fn run_leader(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("leader", "`points` is required"))?;
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("leader", "need 2+ points"));
|
||||
}
|
||||
let st = style(s);
|
||||
let h = s.add_entity(EntityKind::Leader(cadcraft_doc::Leader {
|
||||
vertices: pts.iter().map(|q| v3(*q)).collect(),
|
||||
arrow: true,
|
||||
spline: false,
|
||||
style: st,
|
||||
}))?;
|
||||
let mut out = vec![h.hex()];
|
||||
if let (Some(t), Some(last)) = (str_param(p, "text"), pts.last()) {
|
||||
let th = s.doc()?.dim_style(&style(s)).map(|d| d.text_height).unwrap_or(0.18);
|
||||
out.push(
|
||||
s.add_entity(EntityKind::MText(MText {
|
||||
insert: v3(*last + Vec2::new(th * 0.5, th * 0.5)),
|
||||
height: th,
|
||||
width: 0.0,
|
||||
attach: 4,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: t.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}))?
|
||||
.hex(),
|
||||
);
|
||||
}
|
||||
Ok(json!({ "handles": out }))
|
||||
}
|
||||
|
||||
/// The MLEADER Options prompt being answered.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum LeaderOpt {
|
||||
Menu,
|
||||
LeaderType,
|
||||
Landing,
|
||||
ContentType,
|
||||
MaxPoints,
|
||||
FirstAngle,
|
||||
SecondAngle,
|
||||
BlockName,
|
||||
BlockAttach,
|
||||
}
|
||||
|
||||
/// Upper bound for MLEADER Maxpoints.
|
||||
const MAX_LEADER_POINTS: usize = 64;
|
||||
|
||||
pub(super) struct MLeaderM {
|
||||
order: LeaderOrder,
|
||||
/// Points in the order they were placed.
|
||||
pts: Vec<Vec2>,
|
||||
/// All leader points are placed.
|
||||
leader_done: bool,
|
||||
/// The leader text once entered (Content first enters it before the leader).
|
||||
text: Option<String>,
|
||||
opt: Option<LeaderOpt>,
|
||||
max_points: usize,
|
||||
/// First and second angle constraints (radians; 0 = free).
|
||||
angles: [f64; 2],
|
||||
landing: bool,
|
||||
content: bool,
|
||||
leader_type: crate::LeaderType,
|
||||
/// Block content: the block (`block_name`) instead of text.
|
||||
block: bool,
|
||||
block_name: String,
|
||||
block_center: bool,
|
||||
}
|
||||
|
||||
impl MLeaderM {
|
||||
/// Starts with the order and Options chosen the last time (as AutoCAD does).
|
||||
pub(super) fn new(s: &Session) -> Self {
|
||||
let o = s.last_used.mleader;
|
||||
let max_points = o.max_points.clamp(2, MAX_LEADER_POINTS);
|
||||
MLeaderM {
|
||||
order: o.order,
|
||||
pts: Vec::new(),
|
||||
leader_done: false,
|
||||
text: None,
|
||||
opt: None,
|
||||
max_points,
|
||||
angles: o.angles,
|
||||
landing: o.landing,
|
||||
content: o.content,
|
||||
leader_type: o.leader_type,
|
||||
// Block content needs a block that still exists.
|
||||
block: o.block && s.doc().is_ok_and(|d| d.block(&s.last_used.mleader_block).is_some()),
|
||||
block_name: s.last_used.mleader_block.clone(),
|
||||
block_center: o.block_center,
|
||||
}
|
||||
}
|
||||
/// Keep the order and Options for the next MLEADER.
|
||||
fn remember(&self, s: &mut Session) {
|
||||
s.last_used.mleader = MLeaderOptions {
|
||||
order: self.order,
|
||||
max_points: self.max_points,
|
||||
angles: self.angles,
|
||||
landing: self.landing,
|
||||
content: self.content,
|
||||
leader_type: self.leader_type,
|
||||
block: self.block,
|
||||
block_center: self.block_center,
|
||||
};
|
||||
s.last_used.mleader_block = self.block_name.clone();
|
||||
}
|
||||
/// The leader type and block content for `mleader_content::apply`.
|
||||
fn content_spec(&self) -> crate::cmd::mleader_content::Content {
|
||||
crate::cmd::mleader_content::Content {
|
||||
leader: self.leader_type,
|
||||
block: (self.content && self.block).then(|| (self.block_name.clone(), self.block_center)),
|
||||
}
|
||||
}
|
||||
/// Leader type None: the one point placed is the content location.
|
||||
fn no_leader(&self) -> bool {
|
||||
self.leader_type == crate::LeaderType::None
|
||||
}
|
||||
/// The leader from arrowhead to landing, with `extra` placed next.
|
||||
fn leader(&self, extra: Option<Vec2>) -> Vec<Vec2> {
|
||||
let mut pts = self.pts.clone();
|
||||
pts.extend(extra);
|
||||
if self.order != LeaderOrder::Arrowhead {
|
||||
pts.reverse();
|
||||
}
|
||||
pts
|
||||
}
|
||||
/// `p` with the first/second angle constraint applied to the segment it ends (counted from the
|
||||
/// first point placed).
|
||||
fn constrained(&self, p: Vec2) -> Vec2 {
|
||||
let (Some(&prev), Some(&inc)) = (self.pts.last(), self.angles.get(self.pts.len().wrapping_sub(1))) else { return p };
|
||||
let len = prev.dist(p);
|
||||
if inc <= 1e-9 || !inc.is_finite() || len <= 1e-12 {
|
||||
return p;
|
||||
}
|
||||
Vec2::polar(prev, len, ((p - prev).angle() / inc).round() * inc)
|
||||
}
|
||||
/// The text is asked now: right after the content location (Content first) or after the leader.
|
||||
fn wants_text(&self) -> bool {
|
||||
self.content
|
||||
&& !self.block
|
||||
&& self.text.is_none()
|
||||
&& (self.leader_done || (self.order == LeaderOrder::Content && self.pts.len() == 1 && !self.no_leader()))
|
||||
}
|
||||
fn finish(&self, s: &mut Session) -> Result<Step> {
|
||||
if self.no_leader() && !self.content {
|
||||
s.echo("A multileader needs a leader line or content; nothing was drawn.");
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
let text = self.text.clone().unwrap_or_default();
|
||||
let k = mleader_kind(s, &self.leader(None), &text, self.landing).ok_or_else(|| EngineError::Other("bad leader".into()))?;
|
||||
let k = crate::cmd::mleader_content::apply(s, k, &self.content_spec())?;
|
||||
s.add_entity(k)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
fn option_prompt(o: LeaderOpt) -> Prompt {
|
||||
let kw = crate::cmd::curves::KW;
|
||||
match o {
|
||||
LeaderOpt::Menu => Prompt::new("Enter an option", kw)
|
||||
.kw(&["Leader type", "leader lAnding", "Content type", "Maxpoints", "First angle", "Second angle", "eXit options"])
|
||||
.default("eXit options"),
|
||||
LeaderOpt::LeaderType => Prompt::new("Select a leader type", kw).kw(&["Straight", "sPline", "None"]).default("Straight"),
|
||||
LeaderOpt::Landing => Prompt::new("Use landing", kw).kw(&["Yes", "No"]).default("Yes"),
|
||||
LeaderOpt::ContentType => Prompt::new("Select a content type", kw).kw(&["Block", "Mtext", "None"]).default("Mtext"),
|
||||
LeaderOpt::MaxPoints => Prompt::new("Enter the maximum points for leader line", Accept::NUMBER),
|
||||
LeaderOpt::FirstAngle => Prompt::new("Enter first angle constraint", Accept::NUMBER),
|
||||
LeaderOpt::SecondAngle => Prompt::new("Enter second angle constraint", Accept::NUMBER),
|
||||
LeaderOpt::BlockName => Prompt::new("Enter block name", kw),
|
||||
LeaderOpt::BlockAttach => {
|
||||
Prompt::new("Specify block attachment", kw).kw(&["Center extents", "Insertion point"]).default("Center extents")
|
||||
}
|
||||
}
|
||||
}
|
||||
/// One answer at the Options prompts; Enter keeps the current value.
|
||||
fn option(&mut self, s: &mut Session, o: LeaderOpt, i: Input) -> Option<LeaderOpt> {
|
||||
use LeaderOpt::*;
|
||||
match (o, i) {
|
||||
(Menu, Input::Keyword(k)) => match k.as_str() {
|
||||
"Leader type" => Some(LeaderType),
|
||||
"leader lAnding" => Some(Landing),
|
||||
"Content type" => Some(ContentType),
|
||||
"Maxpoints" => Some(MaxPoints),
|
||||
"First angle" => Some(FirstAngle),
|
||||
"Second angle" => Some(SecondAngle),
|
||||
_ => None,
|
||||
},
|
||||
(Menu, Input::Enter) => None,
|
||||
(LeaderType, Input::Keyword(k)) => {
|
||||
self.leader_type = match k.as_str() {
|
||||
"sPline" => crate::LeaderType::Spline,
|
||||
"None" => crate::LeaderType::None,
|
||||
_ => crate::LeaderType::Straight,
|
||||
};
|
||||
Some(Menu)
|
||||
}
|
||||
(Landing, Input::Keyword(k)) => {
|
||||
self.landing = k == "Yes";
|
||||
Some(Menu)
|
||||
}
|
||||
(ContentType, Input::Keyword(k)) => {
|
||||
self.content = k != "None";
|
||||
self.block = false;
|
||||
// Block content asks which block, then how it attaches.
|
||||
if k == "Block" { Some(BlockName) } else { Some(Menu) }
|
||||
}
|
||||
(BlockName, Input::Text(t)) => match crate::cmd::mleader_content::block_name(s, &t) {
|
||||
Ok(n) => {
|
||||
self.block_name = n;
|
||||
self.block = true;
|
||||
Some(BlockAttach)
|
||||
}
|
||||
Err(e) => {
|
||||
s.echo(e.to_string());
|
||||
Some(BlockName)
|
||||
}
|
||||
},
|
||||
(BlockName, Input::Enter) => {
|
||||
// Enter keeps the block chosen before, if there is one.
|
||||
if crate::cmd::mleader_content::block_name(s, &self.block_name).is_ok() {
|
||||
self.block = true;
|
||||
Some(BlockAttach)
|
||||
} else {
|
||||
s.echo("Requires the name of a block defined in the drawing.");
|
||||
Some(BlockName)
|
||||
}
|
||||
}
|
||||
(BlockAttach, Input::Keyword(k)) => {
|
||||
self.block_center = k == "Center extents";
|
||||
Some(Menu)
|
||||
}
|
||||
(MaxPoints, Input::Text(t)) => match t.trim().parse::<usize>() {
|
||||
Ok(n) if (2..=MAX_LEADER_POINTS).contains(&n) => {
|
||||
self.max_points = n;
|
||||
Some(Menu)
|
||||
}
|
||||
_ => {
|
||||
s.echo(format!("Requires an integer between 2 and {MAX_LEADER_POINTS}."));
|
||||
Some(MaxPoints)
|
||||
}
|
||||
},
|
||||
(FirstAngle | SecondAngle, Input::Text(t)) => match s.angle_settings().amount(&t).filter(|a| *a >= 0.0) {
|
||||
Some(a) => {
|
||||
self.angles[usize::from(o == SecondAngle)] = a;
|
||||
Some(Menu)
|
||||
}
|
||||
None => {
|
||||
s.echo("Requires a valid angle.");
|
||||
Some(o)
|
||||
}
|
||||
},
|
||||
(Menu | LeaderType | Landing | ContentType | BlockAttach, Input::Text(_)) => {
|
||||
s.echo("Invalid option keyword.");
|
||||
Some(o)
|
||||
}
|
||||
(_, Input::Enter) => Some(Menu),
|
||||
_ => Some(o),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for MLeaderM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MLEADER"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(o) = self.opt {
|
||||
return Self::option_prompt(o);
|
||||
}
|
||||
if self.wants_text() {
|
||||
return Prompt::new("Enter leader text", Accept::TEXT);
|
||||
}
|
||||
if self.no_leader() && self.pts.is_empty() {
|
||||
let what = if self.content && self.block { "Specify location of the block" } else { "Specify location of the text" };
|
||||
return Prompt::new(what, Accept::POINT).kw(&["Options"]).default("Options");
|
||||
}
|
||||
let last = self.pts.len() + 1 >= self.max_points;
|
||||
match (self.pts.last(), self.order) {
|
||||
(None, LeaderOrder::Arrowhead) => Prompt::new("Specify leader arrowhead location", Accept::POINT)
|
||||
.kw(&["leader Landing first", "Content first", "Options"])
|
||||
.default("Options"),
|
||||
(None, LeaderOrder::Landing) => Prompt::new("Specify leader landing location", Accept::POINT)
|
||||
.kw(&["leader arrowHead first", "Content first", "Options"])
|
||||
.default("Options"),
|
||||
(None, LeaderOrder::Content) => Prompt::new("Specify location of the text", Accept::POINT)
|
||||
.kw(&["leader arrowHead first", "leader Landing first", "Options"])
|
||||
.default("Options"),
|
||||
(Some(&b), _) if !last => Prompt::new("Specify next point", Accept::POINT).base(b),
|
||||
(Some(&b), LeaderOrder::Arrowhead) => Prompt::new("Specify leader landing location", Accept::POINT).base(b),
|
||||
(Some(&b), _) => Prompt::new("Specify leader arrowhead location", Accept::POINT).base(b),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(o) = self.opt {
|
||||
self.opt = self.option(s, o, i);
|
||||
self.remember(s);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.wants_text() {
|
||||
self.text = Some(if let Input::Text(t) = i { t } else { String::new() });
|
||||
return if self.leader_done { self.finish(s) } else { Ok(Step::Continue) };
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
let p = self.constrained(p);
|
||||
self.pts.push(p);
|
||||
if self.pts.len() >= self.max_points || self.no_leader() {
|
||||
self.leader_done = true;
|
||||
}
|
||||
}
|
||||
Input::Keyword(k) if self.pts.is_empty() => {
|
||||
match k.as_str() {
|
||||
"leader Landing first" => self.order = LeaderOrder::Landing,
|
||||
"leader arrowHead first" => self.order = LeaderOrder::Arrowhead,
|
||||
"Content first" => self.order = LeaderOrder::Content,
|
||||
_ => self.opt = Some(LeaderOpt::Menu),
|
||||
}
|
||||
self.remember(s);
|
||||
}
|
||||
Input::Enter if self.pts.is_empty() => self.opt = Some(LeaderOpt::Menu),
|
||||
// Enter at "next point" ends the leader at the last point.
|
||||
Input::Enter if self.pts.len() >= 2 => self.leader_done = true,
|
||||
Input::Enter => return Ok(Step::Cancel),
|
||||
_ => {}
|
||||
}
|
||||
if self.leader_done && !self.wants_text() { self.finish(s) } else { Ok(Step::Continue) }
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.opt.is_some() || self.wants_text() || self.leader_done || self.pts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let text = self.text.as_deref().unwrap_or("");
|
||||
mleader_kind(s, &self.leader(Some(self.constrained(c))), text, self.landing)
|
||||
.and_then(|k| crate::cmd::mleader_content::apply(s, k, &self.content_spec()).ok())
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use super::common::{DimText, add_dim, circle_of, dim, pick_at, text_at};
|
||||
use super::*;
|
||||
|
||||
fn radial(s: &mut Session, p: &Value, diameter: bool) -> Result<Value> {
|
||||
let id = if diameter { "dimdiameter" } else { "dimradius" };
|
||||
let (c, pt, at) = if let (Some(c), Some(pt)) = (point_param(p, "center"), point_param(p, "point")) {
|
||||
(c, pt, None)
|
||||
} else {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad(id, "`handle` of a circle/arc, or {center, point}"))?;
|
||||
let (c, r) = circle_of(s, h).ok_or_else(|| bad(id, "object is not a circle or arc"))?;
|
||||
let at = point_param(p, "at");
|
||||
let dir = at.unwrap_or(c + Vec2::from_angle(std::f64::consts::FRAC_PI_4));
|
||||
(c, c + (dir - c).normalized() * r, at)
|
||||
};
|
||||
if c.near(pt, 1e-12) {
|
||||
return Err(bad(id, "`point` must differ from `center`"));
|
||||
}
|
||||
let k = if diameter {
|
||||
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "")
|
||||
} else {
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "")
|
||||
};
|
||||
let k = match at {
|
||||
Some(at) => text_at(k, at),
|
||||
None => k,
|
||||
};
|
||||
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
|
||||
}
|
||||
|
||||
pub(super) fn run_radius(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
radial(s, p, false)
|
||||
}
|
||||
|
||||
pub(super) fn run_diameter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
radial(s, p, true)
|
||||
}
|
||||
|
||||
pub(super) struct RadialM {
|
||||
diameter: bool,
|
||||
target: Option<(Vec2, f64)>,
|
||||
text: DimText,
|
||||
}
|
||||
|
||||
impl RadialM {
|
||||
pub(super) fn new(diameter: bool) -> Self {
|
||||
RadialM { diameter, target: None, text: DimText::default() }
|
||||
}
|
||||
fn kind(&self, s: &Session, c: Vec2, r: f64, at: Vec2) -> EntityKind {
|
||||
let pt = c + (at - c).normalized() * r;
|
||||
let k = self.text.apply(if self.diameter {
|
||||
dim(s, DimKind::Diameter, c + (c - pt), Vec2::ZERO, Vec2::ZERO, pt, "")
|
||||
} else {
|
||||
dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, pt, "")
|
||||
});
|
||||
text_at(k, at)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for RadialM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.diameter { "DIMDIAMETER" } else { "DIMRADIUS" }
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some(p) = self.text.prompt() {
|
||||
return p;
|
||||
}
|
||||
match self.target {
|
||||
None => Prompt::new("Select arc or circle", Accept::POINT),
|
||||
Some((c, _)) => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(c),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.text.answer(s, &i) {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.target, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
match pick_at(s, p).and_then(|h| circle_of(s, h)) {
|
||||
Some(t) => self.target = Some(t),
|
||||
None => s.echo("Object selected is not a circle or arc."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some((c, r)), Input::Point(at)) => {
|
||||
let k = self.kind(s, c, r, at);
|
||||
add_dim(s, k)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Some(_), Input::Keyword(k)) => {
|
||||
self.text.keyword(&k);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.target.map(|(ctr, r)| vec![self.kind(s, ctr, r, c)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -1200,5 +1200,4 @@ pub fn after_command(s: &mut Session, before: Option<&Arc<Drawing>>, ok: bool) {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "constraints_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+17
-1526
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_circle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = if let Some(center) = point_param(p, "center") {
|
||||
// Checked before `Circle::new`, which would turn a negative radius positive.
|
||||
let r = match size_param("circle", p, "radius", false)? {
|
||||
Some(r) => r,
|
||||
None => size_param("circle", p, "diameter", false)?.ok_or_else(|| bad("circle", "`radius` or `diameter` is required"))? / 2.0,
|
||||
};
|
||||
Circle::new(center, r)
|
||||
} else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) {
|
||||
match point_param(p, "p3") {
|
||||
Some(c3) => Circle::from_3_points(a, b, c3).ok_or_else(|| bad("circle", "points are collinear"))?,
|
||||
None => Circle::from_2_points(a, b),
|
||||
}
|
||||
} else {
|
||||
return Err(bad("circle", "give {center, radius} or {p1, p2[, p3]}"));
|
||||
};
|
||||
if !(c.radius > 1e-12 && c.radius.is_finite()) {
|
||||
return Err(bad("circle", "radius must be positive"));
|
||||
}
|
||||
added(s.add_entity(circle(c.center, c.radius))?)
|
||||
}
|
||||
|
||||
pub(super) fn run_arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = if let (Some(a), Some(b), Some(c)) = (point_param(p, "p1"), point_param(p, "p2"), point_param(p, "p3")) {
|
||||
Arc::from_3_points(a, b, c).ok_or_else(|| bad("arc", "points are collinear"))?
|
||||
} else if let (Some(st), Some(c), Some(e)) = (point_param(p, "start"), point_param(p, "center"), point_param(p, "end")) {
|
||||
Arc::from_start_center_end(st, c, e)
|
||||
} else if let Some(c) = point_param(p, "center") {
|
||||
let r = f64_req("arc", p, "radius")?;
|
||||
// Checked before `Arc::new`, which would turn a negative radius positive (zero is
|
||||
// refused below).
|
||||
if r < 0.0 {
|
||||
return Err(bad("arc", "`radius` must be positive"));
|
||||
}
|
||||
Arc::new(c, r, f64_req("arc", p, "start")?.to_radians(), f64_req("arc", p, "end")?.to_radians())
|
||||
} else {
|
||||
return Err(bad("arc", "give {p1,p2,p3}, {start,center,end} or {center,radius,start,end}"));
|
||||
};
|
||||
// Zero or non-finite radius (e.g. start == center): refuse, like the interactive ARC and the
|
||||
// arc.* variants, before anything is added (#85).
|
||||
let a = crate::cmd::curves::finite_arc(a).ok_or_else(|| bad("arc", "these values do not define an arc"))?;
|
||||
added(s.add_entity(arc(&a))?)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct CircleM {
|
||||
mode: u8, // 0 center-radius, 1 = 3P, 2 = 2P, 3 = TTR
|
||||
pts: Vec<Vec2>,
|
||||
diameter: bool,
|
||||
/// The `Ttr` option hands over to the tangent-circle machine.
|
||||
delegate: Option<Box<dyn Interactive>>,
|
||||
}
|
||||
|
||||
impl Interactive for CircleM {
|
||||
fn name(&self) -> &'static str {
|
||||
"CIRCLE"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if let Some(d) = &self.delegate {
|
||||
return d.prompt(s);
|
||||
}
|
||||
let n = self.pts.len();
|
||||
let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
|
||||
match (self.mode, n) {
|
||||
(0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]),
|
||||
(0, _) if self.diameter => {
|
||||
Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance()
|
||||
}
|
||||
(0, _) => {
|
||||
let p =
|
||||
Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance();
|
||||
if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p }
|
||||
}
|
||||
(1, k) => Prompt::new(
|
||||
["Specify first point on circle", "Specify second point on circle", "Specify third point on circle"].get(k).copied().unwrap_or(""),
|
||||
Accept::POINT,
|
||||
)
|
||||
.base_opt(self.pts.last().copied()),
|
||||
(2, k) => Prompt::new(
|
||||
if k == 0 { "Specify first end point of circle's diameter" } else { "Specify second end point of circle's diameter" },
|
||||
Accept::POINT,
|
||||
)
|
||||
.base_opt(self.pts.last().copied()),
|
||||
_ => Prompt::new("Specify center point for circle", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(d) = &mut self.delegate {
|
||||
return d.input(s, i);
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) => {
|
||||
self.mode = match k.as_str() {
|
||||
"3P" => 1,
|
||||
"2P" => 2,
|
||||
"Diameter" => {
|
||||
self.diameter = true;
|
||||
self.mode
|
||||
}
|
||||
_ => {
|
||||
self.delegate = Some(Box::new(crate::cmd::draw2::TanCircleM::new(true)));
|
||||
3
|
||||
}
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
if self.mode == 0 && self.pts.first().is_some_and(|c| c.near(p, 1e-12)) {
|
||||
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
|
||||
}
|
||||
self.pts.push(p);
|
||||
let done = match self.mode {
|
||||
0 if self.pts.len() == 2 => {
|
||||
let (Some(c), Some(e)) = (self.pts.first(), self.pts.get(1)) else { return Ok(Step::Continue) };
|
||||
let r = if self.diameter { c.dist(*e) / 2.0 } else { c.dist(*e) };
|
||||
Some(Circle::new(*c, r))
|
||||
}
|
||||
1 if self.pts.len() == 3 => Circle::from_3_points(self.pts[0], self.pts[1], self.pts[2]),
|
||||
2 if self.pts.len() == 2 => Some(Circle::from_2_points(self.pts[0], self.pts[1])),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(c) = done {
|
||||
finish_circle(s, c)?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Text(t) if self.mode == 0 && self.pts.len() == 1 => {
|
||||
let v = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or second point.".into()))?;
|
||||
let r = if self.diameter { v / 2.0 } else { v };
|
||||
if r <= 0.0 {
|
||||
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
|
||||
}
|
||||
finish_circle(s, Circle::new(self.pts[0], r))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Enter if self.mode == 0 && self.pts.len() == 1 => {
|
||||
let r = s.doc()?.header.f64("CIRCLERAD", 0.0);
|
||||
if r > 0.0 {
|
||||
finish_circle(s, Circle::new(self.pts[0], r))?;
|
||||
Ok(Step::Done)
|
||||
} else {
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
// Garbage at a point prompt: report it and ask again.
|
||||
Input::Text(_) if self.mode == 0 && self.pts.is_empty() => Err(crate::EngineError::Other("Point or option keyword required.".into())),
|
||||
Input::Text(_) => Err(crate::EngineError::Other("Invalid point.".into())),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some(d) = &self.delegate {
|
||||
return d.preview(s, c);
|
||||
}
|
||||
match (self.mode, self.pts.as_slice()) {
|
||||
(0, [center]) => {
|
||||
let r = if self.diameter { center.dist(c) / 2.0 } else { center.dist(c) };
|
||||
vec![circle(*center, r), line(*center, c)]
|
||||
}
|
||||
(1, [a, b]) => Circle::from_3_points(*a, *b, c).map(|ci| vec![circle(ci.center, ci.radius)]).unwrap_or_default(),
|
||||
(1, [a]) | (2, [a]) => {
|
||||
let ci = Circle::from_2_points(*a, c);
|
||||
vec![circle(ci.center, ci.radius)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_circle(s: &mut Session, c: Circle) -> Result<()> {
|
||||
s.add_entity(circle(c.center, c.radius))?;
|
||||
s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_ellipse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("isocircle").is_some() {
|
||||
return crate::cmd::settings::iso::run_isocircle(s, p);
|
||||
}
|
||||
let c = point_req("ellipse", p, "center")?;
|
||||
let m = point_req("ellipse", p, "major")?;
|
||||
let ratio = f64_req("ellipse", p, "ratio")?;
|
||||
if !(ratio > 0.0 && ratio <= 1.0) || m.len() < 1e-12 {
|
||||
return Err(bad("ellipse", "ratio must be in (0, 1] and major non-zero"));
|
||||
}
|
||||
let start = f64_or(p, "start", 0.0).to_radians();
|
||||
let end = f64_or(p, "end", 360.0).to_radians();
|
||||
added(s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(c), major: v3(m), ratio, start, end }))?)
|
||||
}
|
||||
|
||||
/// ELLIPSE prompt machine starting in axis-end or center mode, optionally for an elliptical arc.
|
||||
pub(crate) fn ellipse_machine(center: bool, arc: bool) -> Box<dyn Interactive> {
|
||||
Box::new(EllipseM { center_mode: center, arc_mode: arc, ..Default::default() })
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct EllipseM {
|
||||
center_mode: bool,
|
||||
arc_mode: bool,
|
||||
pts: Vec<Vec2>,
|
||||
/// The `Rotation` option: the next value is the rotation around the major axis.
|
||||
rotation: bool,
|
||||
ellipse: Option<Ellipse>,
|
||||
/// The `Parameter` option: arc start/end values are ellipse parameters, not true angles.
|
||||
param: bool,
|
||||
start: Option<f64>,
|
||||
/// The Isocircle option (isometric snap style) in progress.
|
||||
iso: Option<crate::cmd::settings::iso::IsoCircle>,
|
||||
}
|
||||
|
||||
impl EllipseM {
|
||||
fn axis(&self) -> Option<(Vec2, Vec2)> {
|
||||
// (center, major vector)
|
||||
match (self.center_mode, self.pts.as_slice()) {
|
||||
(true, [c, e, ..]) => Some((*c, *e - *c)),
|
||||
(false, [a, b, ..]) => Some((a.mid(*b), *b - a.mid(*b))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn build(&self, other: Vec2) -> Option<Ellipse> {
|
||||
let (c, m) = self.axis()?;
|
||||
let ml = m.len();
|
||||
if ml < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let u = m / ml;
|
||||
let minor = ((other - c).dot(u.perp())).abs();
|
||||
let (major, ratio) = if minor > ml { (u.perp() * minor, ml / minor) } else { (m, minor / ml) };
|
||||
(ratio > 1e-9).then_some(Ellipse::full(c, major, ratio.min(1.0)))
|
||||
}
|
||||
/// The ellipse seen as a circle on the first axis rotated by `a` around it: ratio = cos(a),
|
||||
/// for rotations up to 89.4°.
|
||||
fn rotated(&self, a: f64) -> Option<Ellipse> {
|
||||
let (c, m) = self.axis()?;
|
||||
let ratio = a.cos().abs();
|
||||
(m.len() >= 1e-12 && ratio >= 89.4f64.to_radians().cos()).then_some(Ellipse::full(c, m, ratio.min(1.0)))
|
||||
}
|
||||
/// The full ellipse is known: draw it, or go on to the arc angles.
|
||||
fn place(&mut self, s: &mut Session, e: Ellipse) -> Result<Step> {
|
||||
if self.arc_mode {
|
||||
self.ellipse = Some(e);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
|
||||
center: v3(e.center),
|
||||
major: v3(e.major),
|
||||
ratio: e.ratio,
|
||||
start: 0.0,
|
||||
end: cadcraft_geom::TAU,
|
||||
}))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for EllipseM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ELLIPSE"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if let Some(e) = &self.ellipse {
|
||||
let msg = match (self.start.is_none(), self.param) {
|
||||
(true, false) => "Specify start angle",
|
||||
(false, false) => "Specify end angle",
|
||||
(true, true) => "Specify start parameter",
|
||||
(false, true) => "Specify end parameter",
|
||||
};
|
||||
return Prompt::new(msg, Accept::POINT_OR_NUMBER).kw(if self.param { &["Angle"] } else { &["Parameter"] }).base(e.center);
|
||||
}
|
||||
if let Some(iso) = &self.iso {
|
||||
return iso.prompt();
|
||||
}
|
||||
match (self.center_mode, self.pts.len()) {
|
||||
(false, 0) if s.settings.isodraft => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center", "Isocircle"]),
|
||||
(false, 0) => Prompt::new("Specify axis endpoint of ellipse", Accept::POINT).kw(&["Arc", "Center"]),
|
||||
(false, 1) => Prompt::new("Specify other endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()),
|
||||
(true, 0) => Prompt::new("Specify center of ellipse", Accept::POINT),
|
||||
(true, 1) => Prompt::new("Specify endpoint of axis", Accept::POINT).base_opt(self.pts.first().copied()),
|
||||
_ if self.rotation => Prompt::new("Specify rotation around major axis", Accept::POINT_OR_NUMBER).base_opt(self.axis().map(|a| a.0)),
|
||||
_ => Prompt::new("Specify distance to other axis", Accept::POINT_OR_NUMBER).kw(&["Rotation"]).base_opt(self.axis().map(|a| a.0)),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(e) = self.ellipse {
|
||||
let ge = Ellipse { start: 0.0, end: cadcraft_geom::TAU, ..e };
|
||||
let ang = match i {
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Parameter" => self.param = true,
|
||||
"Angle" => self.param = false,
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// A parameter is the angle from the major axis taken as the parameter itself.
|
||||
Input::Point(p) if self.param => cadcraft_geom::norm_angle((p - e.center).angle() - e.major.angle()),
|
||||
Input::Point(p) => ge.param_of(p),
|
||||
Input::Text(t) => {
|
||||
let a = s.angle_settings().amount(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?;
|
||||
if self.param {
|
||||
cadcraft_geom::norm_angle(a)
|
||||
} else {
|
||||
// A typed angle is a true angle from the major axis; convert it to an ellipse parameter like a picked point.
|
||||
ge.param_of(e.center + Vec2::from_angle(a + e.major.angle()))
|
||||
}
|
||||
}
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
match self.start {
|
||||
None => {
|
||||
self.start = Some(ang);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Some(st) => {
|
||||
s.add_entity(EntityKind::Ellipse(cadcraft_doc::Ellipse {
|
||||
center: v3(e.center),
|
||||
major: v3(e.major),
|
||||
ratio: e.ratio,
|
||||
start: st,
|
||||
end: ang,
|
||||
}))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(iso) = &mut self.iso {
|
||||
return match iso.input(s, i)? {
|
||||
Some(e) => self.place(s, e),
|
||||
None => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) if k == "Isocircle" && self.pts.is_empty() => self.iso = Some(Default::default()),
|
||||
Input::Keyword(k) if k == "Center" => self.center_mode = true,
|
||||
Input::Keyword(k) if k == "Arc" => self.arc_mode = true,
|
||||
Input::Keyword(k) if k == "Rotation" && self.pts.len() == 2 => self.rotation = true,
|
||||
Input::Point(p) if self.pts.len() < 2 => self.pts.push(p),
|
||||
Input::Point(p) if self.rotation => {
|
||||
if let Some(e) = self.axis().and_then(|(c, _)| self.rotated((p - c).angle())) {
|
||||
return self.place(s, e);
|
||||
}
|
||||
}
|
||||
Input::Point(p) => {
|
||||
if let Some(e) = self.build(p) {
|
||||
return self.place(s, e);
|
||||
}
|
||||
}
|
||||
Input::Text(t) if self.pts.len() == 2 && self.rotation => {
|
||||
let e = s
|
||||
.angle_settings()
|
||||
.amount(&t)
|
||||
.and_then(|a| self.rotated(a))
|
||||
.ok_or_else(|| crate::EngineError::Other("Requires an angle between 0 and 89.4 degrees.".into()))?;
|
||||
return self.place(s, e);
|
||||
}
|
||||
Input::Text(t) if self.pts.len() == 2 => {
|
||||
let d = number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
|
||||
if let Some((c, m)) = self.axis()
|
||||
&& let Some(e) = self.build(c + m.normalized().perp() * d)
|
||||
{
|
||||
return self.place(s, e);
|
||||
}
|
||||
}
|
||||
Input::Enter => return Ok(Step::Cancel),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some(iso) = &self.iso {
|
||||
return iso.preview(s, c).map(|e| crate::cmd::settings::iso::ellipse_entity(&e)).into_iter().collect();
|
||||
}
|
||||
if self.pts.len() < 2 {
|
||||
return self.pts.first().map(|a| vec![line(*a, c)]).unwrap_or_default();
|
||||
}
|
||||
let e = if self.rotation { self.axis().and_then(|(ctr, _)| self.rotated((c - ctr).angle())) } else { self.build(c) };
|
||||
e.map(|e| {
|
||||
vec![EntityKind::Ellipse(cadcraft_doc::Ellipse {
|
||||
center: v3(e.center),
|
||||
major: v3(e.major),
|
||||
ratio: e.ratio,
|
||||
start: 0.0,
|
||||
end: cadcraft_geom::TAU,
|
||||
})]
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------- JSON forms ----------------
|
||||
|
||||
pub(super) fn run_line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("line", "`points` [[x,y],...] (2 or more) is required"))?;
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("line", "need at least 2 points"));
|
||||
}
|
||||
// A repeated point would make a zero-length line.
|
||||
let pts = distinct_points(&pts);
|
||||
if pts.len() < 2 {
|
||||
return Err(bad("line", "need at least 2 different points"));
|
||||
}
|
||||
let mut hs = Vec::new();
|
||||
for w in pts.windows(2) {
|
||||
if let [a, b] = w {
|
||||
hs.push(s.add_entity(line(*a, *b))?.hex());
|
||||
}
|
||||
}
|
||||
if bool_or(p, "closed", false)
|
||||
&& pts.len() > 2
|
||||
&& let (Some(a), Some(b)) = (pts.last(), pts.first())
|
||||
&& !a.near(*b, 1e-12)
|
||||
{
|
||||
hs.push(s.add_entity(line(*a, *b))?.hex());
|
||||
}
|
||||
Ok(json!({ "handles": hs }))
|
||||
}
|
||||
|
||||
fn vertex_value(v: &Value) -> Option<PolyVertex> {
|
||||
if let Some(p) = point_value(v) {
|
||||
return Some(PolyVertex::new(p));
|
||||
}
|
||||
let p = point_value(v.get("p")?)?;
|
||||
Some(PolyVertex { p, bulge: f64_or(v, "bulge", 0.0), start_width: f64_or(v, "startWidth", 0.0), end_width: f64_or(v, "endWidth", 0.0) })
|
||||
}
|
||||
|
||||
pub(super) fn run_pline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let raw = p.get("vertices").or_else(|| p.get("points")).and_then(Value::as_array);
|
||||
for v in raw.into_iter().flatten() {
|
||||
size_param("pline", v, "startWidth", true)?;
|
||||
size_param("pline", v, "endWidth", true)?;
|
||||
}
|
||||
let vs: Vec<PolyVertex> = raw.map(|a| a.iter().filter_map(vertex_value).collect()).unwrap_or_default();
|
||||
if vs.len() < 2 {
|
||||
return Err(bad("pline", "`vertices` needs at least 2 points"));
|
||||
}
|
||||
let width = size_param("pline", p, "width", true)?.unwrap_or(0.0);
|
||||
let mut k = lwpoly(vs, bool_or(p, "closed", false));
|
||||
if let EntityKind::LwPolyline(pl) = &mut k {
|
||||
pl.const_width = width;
|
||||
}
|
||||
added(s.add_entity(k)?)
|
||||
}
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct LineM {
|
||||
pts: Vec<Vec2>,
|
||||
handles: Vec<cadcraft_doc::Handle>,
|
||||
/// A first point picked as a deferred tangent/perpendicular, resolved by the next point.
|
||||
first: Option<snap::Deferred>,
|
||||
}
|
||||
|
||||
impl LineM {
|
||||
/// Draw the first segment from the deferred first point to `end`.
|
||||
fn resolve_first(&mut self, s: &mut Session, first: snap::Deferred, end: snap::LineEnd) -> Result<Step> {
|
||||
let Some((a, b)) = snap::resolve_deferred(snap::LineEnd::Deferred(first), end) else {
|
||||
let what = if first.mode == snap::mode::TAN { "tangent" } else { "perpendicular" };
|
||||
return Err(crate::EngineError::Other(format!("No {what} line through that point; pick another point.")));
|
||||
};
|
||||
self.handles.push(s.add_entity(line(a, b))?);
|
||||
self.pts = vec![a, b];
|
||||
self.first = None;
|
||||
s.last_point = b;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for LineM {
|
||||
fn name(&self) -> &'static str {
|
||||
"LINE"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.pts.len() {
|
||||
0 if self.first.is_some() => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).deferred(),
|
||||
0 => Prompt::new("Specify first point", Accept::POINT).deferred(),
|
||||
1 | 2 => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
|
||||
_ => Prompt::new("Specify next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.pts.is_empty()
|
||||
&& let Some(first) = self.first
|
||||
{
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
// A deferred TAN first point with a typed `@d<a` next point: resolve
|
||||
// exactly from the direction and distance the user gave.
|
||||
if let Some((dist, ang)) = s.last_polar.take()
|
||||
&& let Some(t) = snap::tangent_point_in_direction(&first.curve, ang, first.at)
|
||||
{
|
||||
let e = t + Vec2::new(ang.cos(), ang.sin()) * dist;
|
||||
self.handles.push(s.add_entity(line(t, e))?);
|
||||
self.pts = vec![t, e];
|
||||
s.last_point = e;
|
||||
self.first = None;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
return self.resolve_first(s, first, snap::LineEnd::Point(p));
|
||||
}
|
||||
Input::Deferred(d) => return self.resolve_first(s, first, snap::LineEnd::Deferred(d)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match i {
|
||||
Input::Deferred(d) if self.pts.is_empty() => {
|
||||
self.first = Some(d);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
// A deferred pick with a known last point (not offered by the prompt, but harmless).
|
||||
Input::Deferred(d) => match self.pts.last().copied() {
|
||||
Some(last) => match snap::resolve_deferred(snap::LineEnd::Point(last), snap::LineEnd::Deferred(d)) {
|
||||
Some((_, p)) => self.input(s, Input::Point(p)),
|
||||
None => Err(crate::EngineError::Other("No tangent or perpendicular from the last point.".into())),
|
||||
},
|
||||
None => Ok(Step::Continue),
|
||||
},
|
||||
Input::Point(p) => {
|
||||
if let Some(last) = self.pts.last().copied() {
|
||||
if last.near(p, 1e-12) {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.handles.push(s.add_entity(line(last, p))?);
|
||||
}
|
||||
self.pts.push(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
if self.first.take().is_some() {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if let Some(h) = self.handles.pop() {
|
||||
s.doc_mut()?.remove_entity(h);
|
||||
}
|
||||
self.pts.pop();
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Close" => {
|
||||
if let (Some(a), Some(b)) = (self.pts.last().copied(), self.pts.first().copied()) {
|
||||
s.add_entity(line(a, b))?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Err(crate::EngineError::Other("Point or option keyword required.".into())),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some(first) = self.first {
|
||||
// Over a second curve, show the tangent-to-tangent line the click would draw.
|
||||
let end = s.cursor_deferred.map_or(snap::LineEnd::Point(c), snap::LineEnd::Deferred);
|
||||
return snap::resolve_deferred(snap::LineEnd::Deferred(first), end).map(|(a, b)| vec![line(a, b)]).unwrap_or_default();
|
||||
}
|
||||
self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PlineM {
|
||||
verts: Vec<PolyVertex>,
|
||||
arc_mode: bool,
|
||||
/// Total width at the start and end of the next segment (Width / Halfwidth answers).
|
||||
start_w: f64,
|
||||
end_w: f64,
|
||||
handle: Option<cadcraft_doc::Handle>,
|
||||
/// 1 = asking the starting width, 2 = the ending width.
|
||||
asking_width: u8,
|
||||
/// The widths are being asked as half-widths (centre line to edge).
|
||||
half: bool,
|
||||
/// An option asking further questions (arc mode's Angle, CEnter…, line mode's Length).
|
||||
ask: Option<PlineAsk>,
|
||||
}
|
||||
|
||||
impl PlineM {
|
||||
fn entity(&self, closed: bool) -> EntityKind {
|
||||
let mut k = lwpoly(self.verts.clone(), closed);
|
||||
if let EntityKind::LwPolyline(pl) = &mut k {
|
||||
// Every segment the same uniform width: store it as the constant width, as before.
|
||||
let segs = if closed { pl.vertices.len() } else { pl.vertices.len().saturating_sub(1) };
|
||||
let w = pl.vertices.first().map_or(0.0, |v| v.start_width);
|
||||
if pl.vertices.iter().take(segs).all(|v| v.start_width == w && v.end_width == w) {
|
||||
pl.const_width = w;
|
||||
for v in &mut pl.vertices {
|
||||
(v.start_width, v.end_width) = (0.0, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
k
|
||||
}
|
||||
/// The next segment, starting at the last vertex, takes the requested widths (#104); the
|
||||
/// segments after it are uniform at the ending width, as in AutoCAD.
|
||||
fn begin_segment(&mut self) {
|
||||
if let Some(last) = self.verts.last_mut() {
|
||||
(last.start_width, last.end_width) = (self.start_w, self.end_w);
|
||||
self.start_w = self.end_w;
|
||||
}
|
||||
}
|
||||
fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> {
|
||||
if self.verts.len() < 2 {
|
||||
if let Some(h) = self.handle.take() {
|
||||
s.doc_mut()?.remove_entity(h);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let k = self.entity(closed);
|
||||
match self.handle {
|
||||
Some(h) => {
|
||||
s.doc_mut()?.modify_entity(h, |e| e.kind = k)?;
|
||||
}
|
||||
None => self.handle = Some(s.add_entity(k)?),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The direction the polyline runs in at its last vertex (+X before the second vertex).
|
||||
fn tangent(&self) -> Vec2 {
|
||||
let n = self.verts.len();
|
||||
let Some(last) = self.verts.last() else { return Vec2::X };
|
||||
if n >= 2 {
|
||||
let prev = self.verts.get(n - 2).copied().unwrap_or_default();
|
||||
match bulge_to_arc(prev.p, last.p, prev.bulge) {
|
||||
Some((a, ccw)) => {
|
||||
let r = (last.p - a.center).perp().normalized();
|
||||
if ccw { r } else { -r }
|
||||
}
|
||||
None => (last.p - prev.p).normalized(),
|
||||
}
|
||||
} else {
|
||||
Vec2::X
|
||||
}
|
||||
}
|
||||
/// Bulge for a tangent-continuing arc from the last vertex to `p`.
|
||||
fn tangent_bulge(&self, p: Vec2) -> f64 {
|
||||
let Some(last) = self.verts.last() else { return 0.0 };
|
||||
let dir = self.tangent();
|
||||
let chord = p - last.p;
|
||||
// Included angle = 2 × angle between tangent and chord.
|
||||
let ang = dir.cross(chord).atan2(dir.dot(chord));
|
||||
arc_to_bulge(2.0 * ang)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for PlineM {
|
||||
fn name(&self) -> &'static str {
|
||||
"PLINE"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if self.asking_width > 0 {
|
||||
let unit = if self.half { "half-width" } else { "width" };
|
||||
let (which, w) = if self.asking_width == 1 { ("starting", self.start_w) } else { ("ending", self.end_w) };
|
||||
let shown = if self.half { w / 2.0 } else { w };
|
||||
return Prompt::new(format!("Specify {which} {unit}"), Accept::NUMBER).default(format!("{shown:.4}"));
|
||||
}
|
||||
let base = self.verts.last().map(|v| v.p);
|
||||
if let (Some(a), Some(st)) = (self.ask, base) {
|
||||
return a.prompt(st, self.tangent(), s);
|
||||
}
|
||||
match (self.verts.len(), self.arc_mode) {
|
||||
(0, _) => Prompt::new("Specify start point", Accept::POINT),
|
||||
(n, false) => {
|
||||
let mut kw = vec!["Arc", "Halfwidth", "Length", "Undo", "Width"];
|
||||
if n >= 2 {
|
||||
kw.insert(1, "Close");
|
||||
}
|
||||
Prompt::new("Specify next point", Accept::POINT).kw(&kw).base_opt(base)
|
||||
}
|
||||
(_, true) => Prompt::new("Specify endpoint of arc", Accept::POINT)
|
||||
.kw(&["Angle", "CEnter", "CLose", "Direction", "Halfwidth", "Line", "Radius", "Second pt", "Undo", "Width"])
|
||||
.base_opt(base),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.asking_width > 0 {
|
||||
match i {
|
||||
Input::Text(t) => {
|
||||
let w = number(&t).filter(|w| w.is_finite()).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?;
|
||||
// Halfwidth is centre line to edge: the total width is twice the answer (#83).
|
||||
let w = w.max(0.0) * if self.half { 2.0 } else { 1.0 };
|
||||
if self.asking_width == 1 {
|
||||
self.start_w = w;
|
||||
} else {
|
||||
self.end_w = w;
|
||||
}
|
||||
}
|
||||
Input::Enter => {}
|
||||
_ => return Ok(Step::Continue),
|
||||
}
|
||||
if self.asking_width == 1 {
|
||||
// The ending width defaults to the starting width.
|
||||
self.end_w = self.start_w;
|
||||
}
|
||||
self.asking_width = if self.asking_width == 1 { 2 } else { 0 };
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if let (Some(a), Some(st)) = (self.ask, self.verts.last().map(|v| v.p)) {
|
||||
match a.input(s, i, st, self.tangent())? {
|
||||
Outcome::Ask(next) => self.ask = Some(next),
|
||||
Outcome::Back => self.ask = None,
|
||||
Outcome::Segment(end, bulge) => {
|
||||
self.ask = None;
|
||||
self.begin_segment();
|
||||
if let Some(last) = self.verts.last_mut() {
|
||||
last.bulge = bulge;
|
||||
}
|
||||
self.verts.push(PolyVertex::new(end));
|
||||
self.sync(s, false)?;
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
if self.verts.is_empty() {
|
||||
s.echo(format!("Current line-width is {:.4}", self.start_w));
|
||||
}
|
||||
self.begin_segment();
|
||||
if self.arc_mode && !self.verts.is_empty() {
|
||||
let b = self.tangent_bulge(p);
|
||||
if let Some(last) = self.verts.last_mut() {
|
||||
last.bulge = b;
|
||||
}
|
||||
}
|
||||
self.verts.push(PolyVertex::new(p));
|
||||
self.sync(s, false)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) => match k.as_str() {
|
||||
"Arc" => {
|
||||
self.arc_mode = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
"Line" => {
|
||||
self.arc_mode = false;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
"Close" | "CLose" => {
|
||||
if self.arc_mode
|
||||
&& let Some(first) = self.verts.first().map(|v| v.p)
|
||||
{
|
||||
let b = self.tangent_bulge(first);
|
||||
if let Some(last) = self.verts.last_mut() {
|
||||
last.bulge = b;
|
||||
}
|
||||
}
|
||||
// The closing segment starts at the last vertex.
|
||||
self.begin_segment();
|
||||
self.sync(s, true)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
"Undo" => {
|
||||
self.verts.pop();
|
||||
if let Some(l) = self.verts.last_mut() {
|
||||
l.bulge = 0.0;
|
||||
(l.start_width, l.end_width) = (0.0, 0.0);
|
||||
}
|
||||
self.sync(s, false)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
"Width" | "Halfwidth" => {
|
||||
self.half = k == "Halfwidth";
|
||||
self.asking_width = 1;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
k => {
|
||||
match PlineAsk::from_keyword(k, self.arc_mode) {
|
||||
Some(a) if !self.verts.is_empty() => self.ask = Some(a),
|
||||
_ => return Err(crate::EngineError::Other("Point or option keyword required.".into())),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Err(crate::EngineError::Other("Point or option keyword required.".into())),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let Some(last) = self.verts.last() else { return Vec::new() };
|
||||
if let Some(a) = self.ask {
|
||||
return a
|
||||
.preview(last.p, self.tangent(), c)
|
||||
.map(|(e, b)| vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(e)], false)])
|
||||
.unwrap_or_default();
|
||||
}
|
||||
if self.arc_mode {
|
||||
let b = self.tangent_bulge(c);
|
||||
vec![lwpoly(vec![PolyVertex::with_bulge(last.p, b), PolyVertex::new(c)], false)]
|
||||
} else {
|
||||
vec![line(last.p, c)]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts = match point_param(p, "at") {
|
||||
Some(a) => vec![a],
|
||||
None => points_param(p, "points").ok_or_else(|| bad("point", "`at` or `points` is required"))?,
|
||||
};
|
||||
let mut hs = Vec::new();
|
||||
for q in pts {
|
||||
hs.push(s.add_entity(EntityKind::Point(Point { p: v3(q), angle: 0.0 }))?.hex());
|
||||
}
|
||||
Ok(json!({ "handles": hs }))
|
||||
}
|
||||
|
||||
pub(super) fn run_ray(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let b = point_req("ray", p, "base")?;
|
||||
let t = point_req("ray", p, "through")?;
|
||||
let dir = (t - b).normalized();
|
||||
if dir == Vec2::ZERO {
|
||||
return Err(bad("ray", "through point equals base"));
|
||||
}
|
||||
added(s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(dir) }))?)
|
||||
}
|
||||
|
||||
fn donut(center: Vec2, inside: f64, outside: f64) -> EntityKind {
|
||||
let r = (inside.abs() + outside.abs()) / 4.0;
|
||||
let w = (outside.abs() - inside.abs()).abs() / 2.0;
|
||||
let mut k = lwpoly(vec![PolyVertex::with_bulge(center - Vec2::X * r, 1.0), PolyVertex::with_bulge(center + Vec2::X * r, 1.0)], true);
|
||||
if let EntityKind::LwPolyline(pl) = &mut k {
|
||||
pl.const_width = w;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
pub(super) fn run_donut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = point_req("donut", p, "center")?;
|
||||
let inside = size_param("donut", p, "inside", true)?.unwrap_or(0.5);
|
||||
let outside = size_param("donut", p, "outside", false)?.unwrap_or(1.0);
|
||||
if inside > outside {
|
||||
return Err(bad("donut", "`inside` must not be larger than `outside`"));
|
||||
}
|
||||
added(s.add_entity(donut(c, inside, outside))?)
|
||||
}
|
||||
|
||||
pub(super) struct PointM {
|
||||
pub(super) multiple: bool,
|
||||
}
|
||||
|
||||
impl Interactive for PointM {
|
||||
fn name(&self) -> &'static str {
|
||||
"POINT"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let d = s.doc().ok();
|
||||
let _ = d;
|
||||
Prompt::new("Specify a point", Accept::POINT)
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let (m, sz) = s.doc().map(|d| (d.header.i64("PDMODE", 0), d.header.f64("PDSIZE", 0.0))).unwrap_or((0, 0.0));
|
||||
s.echo(format!("Current point modes: PDMODE={m} PDSIZE={sz:.4}"));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
s.add_entity(EntityKind::Point(Point { p: v3(p), angle: 0.0 }))?;
|
||||
Ok(if self.multiple { Step::Continue } else { Step::Done })
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RAY: a start point, then through points until Enter (XLINE is `draw2::XlineM2`).
|
||||
#[derive(Default)]
|
||||
pub(super) struct RayM {
|
||||
base: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl Interactive for RayM {
|
||||
fn name(&self) -> &'static str {
|
||||
"RAY"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.base {
|
||||
None => Prompt::new("Specify start point", Accept::POINT),
|
||||
Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
match self.base {
|
||||
None => self.base = Some(p),
|
||||
Some(b) => {
|
||||
let d = (p - b).normalized();
|
||||
if d != Vec2::ZERO {
|
||||
s.add_entity(EntityKind::Ray(RayLine { base: v3(b), dir: v3(d) }))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match self.base {
|
||||
Some(b) if !b.near(c, 1e-12) => vec![EntityKind::Ray(RayLine { base: v3(b), dir: v3((c - b).normalized()) })],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct DonutM {
|
||||
inside: Option<f64>,
|
||||
outside: Option<f64>,
|
||||
}
|
||||
|
||||
impl Interactive for DonutM {
|
||||
fn name(&self) -> &'static str {
|
||||
"DONUT"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let d = s.doc().ok();
|
||||
let di = d.map(|d| d.header.f64("DONUTID", 0.5)).unwrap_or(0.5);
|
||||
let dout = d.map(|d| d.header.f64("DONUTOD", 1.0)).unwrap_or(1.0);
|
||||
match (self.inside, self.outside) {
|
||||
(None, _) => Prompt::new("Specify inside diameter of donut", Accept::NUMBER).default(format!("{di:.4}")),
|
||||
(Some(_), None) => Prompt::new("Specify outside diameter of donut", Accept::NUMBER).default(format!("{dout:.4}")),
|
||||
_ => Prompt::new("Specify center of donut", Accept::POINT).kw(&[]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let hdr = |s: &Session, k: &str, d: f64| s.doc().map(|x| x.header.f64(k, d)).unwrap_or(d);
|
||||
match (self.inside, self.outside, i) {
|
||||
(None, _, Input::Text(t)) => {
|
||||
self.inside = Some(number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?.abs())
|
||||
}
|
||||
(None, _, Input::Enter) => self.inside = Some(hdr(s, "DONUTID", 0.5)),
|
||||
(Some(_), None, Input::Text(t)) => {
|
||||
self.outside = Some(number(&t).ok_or_else(|| crate::EngineError::Other("Requires a distance.".into()))?.abs())
|
||||
}
|
||||
(Some(_), None, Input::Enter) => self.outside = Some(hdr(s, "DONUTOD", 1.0)),
|
||||
(Some(a), Some(b), Input::Point(p)) => {
|
||||
s.add_entity(donut(p, a, b))?;
|
||||
let d = s.doc_mut()?;
|
||||
d.header.set_f64("DONUTID", a);
|
||||
d.header.set_f64("DONUTOD", b);
|
||||
}
|
||||
(Some(_), Some(_), Input::Enter) => return Ok(Step::Done),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.inside, self.outside) {
|
||||
(Some(a), Some(b)) => vec![donut(c, a, b)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_polygon(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A `sides` that is not a whole number (e.g. -3) is refused, not replaced by the default 4.
|
||||
let n = match p.get("sides") {
|
||||
None | Some(Value::Null) => 4,
|
||||
Some(v) => v.as_u64().and_then(|n| usize::try_from(n).ok()).unwrap_or(0),
|
||||
};
|
||||
if !(3..=1024).contains(&n) {
|
||||
return Err(bad("polygon", "sides must be 3..1024"));
|
||||
}
|
||||
let vs = if let Some(e) = points_param(p, "edge") {
|
||||
let (Some(a), Some(b)) = (e.first(), e.get(1)) else { return Err(bad("polygon", "edge needs 2 points")) };
|
||||
if a.near(*b, 1e-12) {
|
||||
return Err(bad("polygon", "edge endpoints must differ"));
|
||||
}
|
||||
polygon_from_edge(*a, *b, n)
|
||||
} else {
|
||||
let c = point_req("polygon", p, "center")?;
|
||||
let r = size_param("polygon", p, "radius", false)?.ok_or_else(|| bad("polygon", "`radius` (number) is required"))?;
|
||||
let inscribed = bool_or(p, "inscribed", true);
|
||||
// Without an angle, the bottom edge is horizontal, as with a typed radius.
|
||||
let rp = match p.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()) {
|
||||
Some(a) => Vec2::polar(c, r, a.to_radians()),
|
||||
None => polygon_typed_radius_point(c, n, r, inscribed),
|
||||
};
|
||||
polygon_vertices(c, n, rp, inscribed)
|
||||
};
|
||||
added(s.add_entity(lwpoly(vs, true))?)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PolygonM {
|
||||
sides: Option<usize>,
|
||||
center: Option<Vec2>,
|
||||
edge: bool,
|
||||
edge_pts: Vec<Vec2>,
|
||||
inscribed: Option<bool>,
|
||||
}
|
||||
|
||||
impl Interactive for PolygonM {
|
||||
fn name(&self) -> &'static str {
|
||||
"POLYGON"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let def = s.doc().map(|d| d.header.i64("POLYSIDES", 4)).unwrap_or(4);
|
||||
match (self.sides, self.edge, self.center, self.inscribed) {
|
||||
(None, _, _, _) => Prompt::new("Enter number of sides", Accept::NUMBER).default(def.to_string()),
|
||||
(Some(_), true, _, _) => Prompt::new(
|
||||
if self.edge_pts.is_empty() { "Specify first endpoint of edge" } else { "Specify second endpoint of edge" },
|
||||
Accept::POINT,
|
||||
)
|
||||
.base_opt(self.edge_pts.first().copied()),
|
||||
(Some(_), false, None, _) => Prompt::new("Specify center of polygon", Accept::POINT).kw(&["Edge"]),
|
||||
(Some(_), false, Some(_), None) => Prompt::new("Enter an option", Accept::TEXT)
|
||||
.kw(&["Inscribed in circle", "Circumscribed about circle"])
|
||||
.default(if s.last_used.polygon_circumscribed { "C" } else { "I" }),
|
||||
(Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c).distance(),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.sides.is_none() {
|
||||
let n = match &i {
|
||||
Input::Text(t) => t
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|n| (3..=1024).contains(n))
|
||||
.ok_or_else(|| crate::EngineError::Other("Requires an integer between 3 and 1024.".into()))?,
|
||||
Input::Enter => s.doc()?.header.i64("POLYSIDES", 4).clamp(3, 1024) as usize,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
self.sides = Some(n);
|
||||
s.doc_mut()?.header.set_i64("POLYSIDES", n as i64);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let n = self.sides.unwrap_or(4);
|
||||
if self.edge {
|
||||
if let Input::Point(p) = i {
|
||||
if self.edge_pts.first().is_some_and(|a| a.near(p, 1e-12)) {
|
||||
return Err(crate::EngineError::Other("Edge endpoints must differ.".into()));
|
||||
}
|
||||
self.edge_pts.push(p);
|
||||
if self.edge_pts.len() == 2 {
|
||||
s.add_entity(lwpoly(polygon_from_edge(self.edge_pts[0], self.edge_pts[1], n), true))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.center, self.inscribed, i) {
|
||||
(None, _, Input::Keyword(k)) if k == "Edge" => self.edge = true,
|
||||
(None, _, Input::Point(p)) => self.center = Some(p),
|
||||
(Some(_), None, inp @ (Input::Keyword(_) | Input::Enter | Input::Text(_))) => {
|
||||
// Enter takes the option used last time (the prompt's default).
|
||||
let ins = match inp {
|
||||
Input::Keyword(k) => k.starts_with('I'),
|
||||
Input::Text(t) => !t.trim().to_ascii_lowercase().starts_with('c'),
|
||||
_ => !s.last_used.polygon_circumscribed,
|
||||
};
|
||||
self.inscribed = Some(ins);
|
||||
s.last_used.polygon_circumscribed = !ins;
|
||||
}
|
||||
(Some(c), Some(ins), Input::Point(p)) => {
|
||||
if c.near(p, 1e-12) {
|
||||
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
|
||||
}
|
||||
s.add_entity(lwpoly(polygon_vertices(c, n, p, ins), true))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(Some(c), Some(ins), Input::Text(t)) => {
|
||||
let r = number(&t).ok_or_else(|| crate::EngineError::Other("Requires numeric distance or point.".into()))?;
|
||||
if !r.is_finite() || r <= 0.0 {
|
||||
return Err(crate::EngineError::Other("Value must be positive and nonzero.".into()));
|
||||
}
|
||||
let rp = polygon_typed_radius_point(c, n, r, ins);
|
||||
s.add_entity(lwpoly(polygon_vertices(c, n, rp, ins), true))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let n = self.sides.unwrap_or(4);
|
||||
if self.edge {
|
||||
return self.edge_pts.first().map(|a| vec![lwpoly(polygon_from_edge(*a, c, n), true)]).unwrap_or_default();
|
||||
}
|
||||
match (self.center, self.inscribed) {
|
||||
(Some(ctr), Some(ins)) => vec![lwpoly(polygon_vertices(ctr, n, c, ins), true)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_spline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("object").is_some() {
|
||||
return crate::cmd::spline_opts::run_object(s, p);
|
||||
}
|
||||
let sp = if let Some(f) = points_param(p, "fit") {
|
||||
if distinct_points(&f).len() < 2 {
|
||||
return Err(bad("spline", "need 2+ different fit points"));
|
||||
}
|
||||
Spline::fit_with(&f, bool_or(p, "closed", false), crate::cmd::spline_opts::json_options(s, p)?)
|
||||
} else if let Some(c) = points_param(p, "control") {
|
||||
if c.len() < 2 || c.len() > crate::cmd::curves::MAX_GEN {
|
||||
return Err(bad("spline", "need 2+ control points"));
|
||||
}
|
||||
// Degree 1..=10 as at the Degree prompt, `spline.cv` and DXF input (tessellation grows
|
||||
// with its square).
|
||||
Spline::from_control(c, p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize)
|
||||
} else {
|
||||
return Err(bad("spline", "`fit` or `control` points are required"));
|
||||
};
|
||||
added(s.add_entity(EntityKind::Spline(sp))?)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SplineM {
|
||||
pts: Vec<Vec2>,
|
||||
asking_method: bool,
|
||||
/// `Method` > `CV` hands over to the control-vertex machine (`spline.cv`).
|
||||
delegate: Option<Box<dyn Interactive>>,
|
||||
/// Knots, end tangents and fit tolerance chosen so far.
|
||||
opts: cadcraft_geom::FitOptions,
|
||||
/// An option asking a further question (Knots, Tangency, toLerance, Object).
|
||||
ask: Option<crate::cmd::spline_opts::SplineAsk>,
|
||||
}
|
||||
|
||||
impl SplineM {
|
||||
fn make(&self, closed: bool) -> Spline {
|
||||
Spline::fit_with(&self.pts, closed, self.opts)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for SplineM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SPLINE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.opts.knots = crate::cmd::spline_opts::current_knots(s);
|
||||
s.echo(format!("Current settings: Method=Fit Knots={}", self.opts.knots.name()));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if let Some(d) = &self.delegate {
|
||||
return d.prompt(s);
|
||||
}
|
||||
if let Some(a) = &self.ask {
|
||||
return a.prompt(&self.opts, &self.pts);
|
||||
}
|
||||
if self.asking_method {
|
||||
return Prompt::new("Enter spline creation method", crate::cmd::curves::KW).kw(&["Fit", "CV"]).default("Fit");
|
||||
}
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Knots", "Object"]),
|
||||
1 => Prompt::new("Enter next point", Accept::POINT).kw(&["start Tangency", "toLerance"]).base_opt(self.pts.last().copied()),
|
||||
2 => Prompt::new("Enter next point", Accept::POINT).kw(&["end Tangency", "toLerance", "Undo"]).base_opt(self.pts.last().copied()),
|
||||
_ => {
|
||||
Prompt::new("Enter next point", Accept::POINT).kw(&["end Tangency", "toLerance", "Undo", "Close"]).base_opt(self.pts.last().copied())
|
||||
}
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(d) = &mut self.delegate {
|
||||
return d.input(s, i);
|
||||
}
|
||||
if let Some(a) = &mut self.ask {
|
||||
use crate::cmd::spline_opts::Outcome;
|
||||
return match a.input(s, i, &mut self.opts, &self.pts)? {
|
||||
Outcome::Stay => Ok(Step::Continue),
|
||||
Outcome::Back => {
|
||||
self.ask = None;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Outcome::Finish => {
|
||||
self.ask = None;
|
||||
if self.pts.len() >= 2 {
|
||||
s.add_entity(EntityKind::Spline(self.make(false)))?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Outcome::Done => Ok(Step::Done),
|
||||
};
|
||||
}
|
||||
if self.asking_method {
|
||||
match i {
|
||||
Input::Keyword(k) if k == "CV" => {
|
||||
let Some(factory) = find_command("spline.cv").and_then(|c| c.interactive) else {
|
||||
return Err(crate::EngineError::Other("CV: not available yet".into()));
|
||||
};
|
||||
let mut m = factory(s)?;
|
||||
self.asking_method = false;
|
||||
let step = m.begin(s)?;
|
||||
self.delegate = Some(m);
|
||||
return Ok(step);
|
||||
}
|
||||
Input::Keyword(_) | Input::Enter => self.asking_method = false,
|
||||
Input::Text(_) => return Err(crate::EngineError::Other("Invalid option keyword.".into())),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
self.pts.push(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
self.pts.pop();
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Close" => {
|
||||
// A smooth closed (periodic) curve, not a repeat of the first point.
|
||||
s.add_entity(EntityKind::Spline(self.make(true)))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Keyword(k) if k == "Method" => {
|
||||
self.asking_method = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) => {
|
||||
// Knots, Object, start/end Tangency, toLerance ask their own question.
|
||||
self.ask = Some(
|
||||
crate::cmd::spline_opts::SplineAsk::from_keyword(&k)
|
||||
.ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?,
|
||||
);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => {
|
||||
if self.pts.len() >= 2 {
|
||||
s.add_entity(EntityKind::Spline(self.make(false)))?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Text(_) => Err(crate::EngineError::Other("Point or option keyword required.".into())),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some(d) = &self.delegate {
|
||||
return d.preview(s, c);
|
||||
}
|
||||
if self.pts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut opts = self.opts;
|
||||
let mut pts = self.pts.clone();
|
||||
match self.ask {
|
||||
// Rubber-band the tangent being picked.
|
||||
Some(crate::cmd::spline_opts::SplineAsk::StartTangent) => {
|
||||
opts.start_tangent = self.pts.first().map(|f| c - *f).filter(|d| d.len() > 1e-12)
|
||||
}
|
||||
Some(crate::cmd::spline_opts::SplineAsk::EndTangent) => opts.end_tangent = self.pts.last().map(|l| c - *l).filter(|d| d.len() > 1e-12),
|
||||
Some(_) => {}
|
||||
None => pts.push(c),
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![EntityKind::Spline(Spline::fit_with(&pts, false, opts))]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
use super::*;
|
||||
|
||||
/// Justification code → (halign, valign).
|
||||
pub(crate) fn justify(j: &str) -> Option<(HAlign, VAlign)> {
|
||||
Some(match j.to_ascii_uppercase().as_str() {
|
||||
"L" | "LEFT" => (HAlign::Left, VAlign::Baseline),
|
||||
"C" | "CENTER" => (HAlign::Center, VAlign::Baseline),
|
||||
"R" | "RIGHT" => (HAlign::Right, VAlign::Baseline),
|
||||
"A" | "ALIGN" => (HAlign::Aligned, VAlign::Baseline),
|
||||
"M" | "MIDDLE" => (HAlign::Middle, VAlign::Baseline),
|
||||
"F" | "FIT" => (HAlign::Fit, VAlign::Baseline),
|
||||
"TL" => (HAlign::Left, VAlign::Top),
|
||||
"TC" => (HAlign::Center, VAlign::Top),
|
||||
"TR" => (HAlign::Right, VAlign::Top),
|
||||
"ML" => (HAlign::Left, VAlign::Middle),
|
||||
"MC" => (HAlign::Center, VAlign::Middle),
|
||||
"MR" => (HAlign::Right, VAlign::Middle),
|
||||
"BL" => (HAlign::Left, VAlign::Bottom),
|
||||
"BC" => (HAlign::Center, VAlign::Bottom),
|
||||
"BR" => (HAlign::Right, VAlign::Bottom),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn make_text(s: &Session, at: Vec2, value: &str, height: f64, rotation: f64, j: (HAlign, VAlign)) -> EntityKind {
|
||||
let style = s.doc().map(|d| d.header.str("TEXTSTYLE", "Standard")).unwrap_or_else(|_| "Standard".into());
|
||||
let (h, v) = j;
|
||||
let left = h == HAlign::Left && v == VAlign::Baseline;
|
||||
EntityKind::Text(Text {
|
||||
insert: v3(at),
|
||||
align_pt: if left { None } else { Some(v3(at)) },
|
||||
height,
|
||||
value: value.into(),
|
||||
rotation,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style,
|
||||
halign: h,
|
||||
valign: v,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn run_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = point_req("text", p, "at")?;
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text", "`text` is required"))?;
|
||||
let height = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2));
|
||||
if height <= 0.0 {
|
||||
return Err(bad("text", "height must be positive"));
|
||||
}
|
||||
let j = justify(str_param(p, "justify").unwrap_or("L")).ok_or_else(|| bad("text", "unknown justification"))?;
|
||||
let k = make_text(s, at, text, height, f64_or(p, "rotation", 0.0).to_radians(), j);
|
||||
added(s.add_entity(k)?)
|
||||
}
|
||||
|
||||
pub(super) fn run_mtext(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = point_req("mtext", p, "at")?;
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("mtext", "`text` is required"))?;
|
||||
let height = f64_or(p, "height", s.doc()?.header.f64("TEXTSIZE", 0.2));
|
||||
if height <= 0.0 {
|
||||
return Err(bad("mtext", "height must be positive"));
|
||||
}
|
||||
let style = match str_param(p, "style") {
|
||||
Some(n) => s.doc()?.text_style(n).map(|t| t.name.clone()).ok_or_else(|| bad("mtext", format!("no text style \"{n}\"")))?,
|
||||
None => s.doc()?.header.str("TEXTSTYLE", "Standard"),
|
||||
};
|
||||
let attach = match str_param(p, "justify") {
|
||||
Some(j) => crate::cmd::mtext_opts::attach_code(j).ok_or_else(|| bad("mtext", "`justify` must be TL, TC, TR, ML, MC, MR, BL, BC or BR"))?,
|
||||
None => p.get("attach").and_then(Value::as_u64).unwrap_or(1).clamp(1, 9) as u8,
|
||||
};
|
||||
let width = f64_or(p, "width", 0.0).max(0.0);
|
||||
let k = EntityKind::MText(MText {
|
||||
insert: v3(at),
|
||||
height,
|
||||
width,
|
||||
attach,
|
||||
rotation: f64_or(p, "rotation", 0.0).to_radians(),
|
||||
style,
|
||||
contents: crate::cmd::mtext_opts::json_paragraphs(p, text.replace('\n', "\\P"), height)?,
|
||||
line_spacing: f64_or(p, "lineSpacing", 1.0),
|
||||
line_spacing_exact: bool_or(p, "lineSpacingExact", false),
|
||||
columns: crate::cmd::mtext_opts::json_columns(p, height, width)?,
|
||||
});
|
||||
added(s.add_entity(k)?)
|
||||
}
|
||||
|
||||
pub(super) struct TextM {
|
||||
at: Option<Vec2>,
|
||||
height: Option<f64>,
|
||||
rotation: Option<f64>,
|
||||
justify: (HAlign, VAlign),
|
||||
asking_justify: bool,
|
||||
line: usize,
|
||||
/// Run by a LISP routine (`command`): one line of text ends the command, as AutoLISP's
|
||||
/// `command` runs TEXT; typed and in scripts each line asks for the next.
|
||||
single_line: bool,
|
||||
}
|
||||
|
||||
impl TextM {
|
||||
pub(super) fn new(s: &Session) -> Self {
|
||||
TextM {
|
||||
at: None,
|
||||
height: None,
|
||||
rotation: None,
|
||||
justify: (HAlign::Left, VAlign::Baseline),
|
||||
asking_justify: false,
|
||||
line: 0,
|
||||
single_line: s.lisp_running(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for TextM {
|
||||
fn name(&self) -> &'static str {
|
||||
"TEXT"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let th = s.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2);
|
||||
if self.asking_justify {
|
||||
return Prompt::new("Enter an option", Accept::TEXT)
|
||||
.kw(&["Left", "Center", "Right", "Align", "Middle", "Fit", "TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"]);
|
||||
}
|
||||
match (self.at, self.height, self.rotation) {
|
||||
(None, _, _) => Prompt::new("Specify start point of text", Accept::POINT).kw(&["Justify", "Style"]),
|
||||
(Some(a), None, _) => Prompt::new("Specify height", Accept::POINT_OR_NUMBER).default(format!("{th:.4}")).base(a),
|
||||
(Some(a), Some(_), None) => Prompt::new("Specify rotation angle of text", Accept::POINT_OR_NUMBER).default("0").base(a),
|
||||
_ => Prompt::new("Enter text", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let d = s.doc()?;
|
||||
let style = d.header.str("TEXTSTYLE", "Standard");
|
||||
let anno = if d.text_style(&style).is_some_and(|st| st.annotative) { "Yes" } else { "No" };
|
||||
let msg =
|
||||
format!("Current text style: \"{style}\" Text height: {:.4} Annotative: {anno} Justify: Left", d.header.f64("TEXTSIZE", 0.2));
|
||||
s.echo(msg);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.asking_justify {
|
||||
let code = match &i {
|
||||
Input::Keyword(k) | Input::Text(k) => k.clone(),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
self.justify = justify(&code).unwrap_or(self.justify);
|
||||
self.asking_justify = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let th = s.doc()?.header.f64("TEXTSIZE", 0.2);
|
||||
match (self.at, self.height, self.rotation, i) {
|
||||
(None, _, _, Input::Keyword(k)) if k == "Justify" => self.asking_justify = true,
|
||||
(None, _, _, Input::Point(p)) => self.at = Some(p),
|
||||
(Some(_), None, _, Input::Enter) => self.height = Some(th),
|
||||
(Some(a), None, _, Input::Point(p)) => self.height = Some(a.dist(p).max(1e-9)),
|
||||
(Some(_), None, _, Input::Text(t)) => {
|
||||
let h = number(&t).filter(|h| *h > 0.0).ok_or_else(|| crate::EngineError::Other("Requires a positive height.".into()))?;
|
||||
self.height = Some(h);
|
||||
s.doc_mut()?.header.set_f64("TEXTSIZE", h);
|
||||
}
|
||||
(Some(_), Some(_), None, Input::Enter) => self.rotation = Some(0.0),
|
||||
(Some(a), Some(_), None, Input::Point(p)) => self.rotation = Some(a.angle_to(p)),
|
||||
(Some(_), Some(_), None, Input::Text(t)) => {
|
||||
self.rotation = Some(s.angle_settings().direction(&t).ok_or_else(|| crate::EngineError::Other("Requires an angle.".into()))?)
|
||||
}
|
||||
(Some(a), Some(h), Some(r), Input::Text(t)) => {
|
||||
let at = a + Vec2::from_angle(r - std::f64::consts::FRAC_PI_2) * (h * 5.0 / 3.0 * self.line as f64);
|
||||
let k = make_text(s, at, &t, h, r, self.justify);
|
||||
s.add_entity(k)?;
|
||||
self.line += 1;
|
||||
if self.single_line {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_), Some(_), Input::Enter) => return Ok(Step::Done),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct MTextM {
|
||||
first: Option<Vec2>,
|
||||
second: Option<Vec2>,
|
||||
/// Paragraphs typed so far: one per input line, an empty line ends the text (as on
|
||||
/// AutoCAD's command line and in scripts).
|
||||
lines: Vec<String>,
|
||||
/// Height, justification, line spacing, rotation, style and width set by the options.
|
||||
opts: crate::cmd::mtext_opts::MTextOpts,
|
||||
/// An option asking a further question.
|
||||
ask: Option<crate::cmd::mtext_opts::MTextAsk>,
|
||||
}
|
||||
|
||||
impl MTextM {
|
||||
/// The box is known: the opposite corner was picked or the Width option answered.
|
||||
fn boxed(&self) -> bool {
|
||||
self.second.is_some() || self.opts.width.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for MTextM {
|
||||
fn name(&self) -> &'static str {
|
||||
"MTEXT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.opts = crate::cmd::mtext_opts::MTextOpts::new(s);
|
||||
let anno = s.doc()?.text_style(&self.opts.style).is_some_and(|st| st.annotative);
|
||||
s.echo(format!(
|
||||
"Current text style: \"{}\" Text height: {:.4} Annotative: {}",
|
||||
self.opts.style,
|
||||
self.opts.height,
|
||||
if anno { "Yes" } else { "No" }
|
||||
));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let (Some(a), Some(q)) = (self.first, self.ask) {
|
||||
return q.prompt(&self.opts, a);
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Specify first corner", Accept::POINT),
|
||||
Some(a) if !self.boxed() => Prompt::new("Specify opposite corner", Accept::POINT)
|
||||
.kw(&["Height", "Justify", "Line spacing", "Rotation", "Style", "Width", "Columns"])
|
||||
.base(a),
|
||||
_ if self.lines.is_empty() => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT),
|
||||
_ => Prompt::new("Enter next line of text (empty line to finish)", Accept::TEXT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let (Some(a), Some(q)) = (self.first, self.ask) {
|
||||
use crate::cmd::mtext_opts::Outcome;
|
||||
self.ask = match q.input(s, i, &mut self.opts, a)? {
|
||||
Outcome::Ask(next) => Some(next),
|
||||
Outcome::Back | Outcome::Text => None,
|
||||
};
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.first, self.boxed(), i) {
|
||||
(None, _, Input::Point(p)) => self.first = Some(p),
|
||||
(Some(_), false, Input::Point(p)) => self.second = Some(p),
|
||||
(Some(_), false, Input::Keyword(k)) => {
|
||||
self.ask = Some(
|
||||
crate::cmd::mtext_opts::MTextAsk::from_keyword(&k).ok_or_else(|| crate::EngineError::Other("Invalid option keyword.".into()))?,
|
||||
);
|
||||
}
|
||||
(Some(_), true, Input::Text(t)) if !t.is_empty() => self.lines.push(t),
|
||||
(Some(a), true, Input::Text(_) | Input::Enter) => {
|
||||
if self.lines.is_empty() {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
s.add_entity(EntityKind::MText(self.opts.mtext(a, self.second, self.lines.join("\\P"))))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
(_, _, Input::Enter) => return Ok(Step::Done),
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn editing_mtext(&self) -> Option<MText> {
|
||||
let a = self.first?;
|
||||
(self.boxed() && self.ask.is_none()).then(|| self.opts.mtext(a, self.second, String::new()))
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.first, self.boxed(), self.ask) {
|
||||
(Some(a), false, None) => {
|
||||
// The box, turned by the rotation about the first corner.
|
||||
let r = self.opts.rotation;
|
||||
let l = (c - a).rotate(-r);
|
||||
let corners = [Vec2::ZERO, Vec2::new(l.x, 0.0), l, Vec2::new(0.0, l.y)];
|
||||
vec![lwpoly(corners.iter().map(|q| PolyVertex::new(a + q.rotate(r))).collect(), true)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+24
-2215
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,381 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// Generic point/value sequences (arc and circle variants)
|
||||
// =====================================================================================
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ArcV {
|
||||
/// The ARC command itself: three points, with options switching to the other variants.
|
||||
ThreeP,
|
||||
/// Start, end, then center.
|
||||
Sec,
|
||||
Sce,
|
||||
Sca,
|
||||
Scl,
|
||||
Sea,
|
||||
Sed,
|
||||
Ser,
|
||||
Cse,
|
||||
Csa,
|
||||
Csl,
|
||||
CircleCd,
|
||||
Circle2p,
|
||||
Circle3p,
|
||||
}
|
||||
|
||||
/// What a step asks for.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Ask {
|
||||
Point,
|
||||
/// Absolute angle from value `base` to the picked point, or a typed angle.
|
||||
Angle(usize),
|
||||
/// CCW angle at value `center` from the ray to value `from` to the ray to the picked point.
|
||||
Sweep(usize, usize),
|
||||
/// Distance from value `base`, or a typed distance (may be negative).
|
||||
Dist(usize),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Val {
|
||||
P(Vec2),
|
||||
N(f64),
|
||||
}
|
||||
|
||||
impl Val {
|
||||
fn p(self) -> Option<Vec2> {
|
||||
if let Val::P(p) = self { Some(p) } else { None }
|
||||
}
|
||||
fn n(self) -> Option<f64> {
|
||||
if let Val::N(n) = self { Some(n) } else { None }
|
||||
}
|
||||
}
|
||||
|
||||
impl ArcV {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
ArcV::CircleCd | ArcV::Circle2p | ArcV::Circle3p => "CIRCLE",
|
||||
_ => "ARC",
|
||||
}
|
||||
}
|
||||
fn steps(self) -> &'static [(&'static str, Ask)] {
|
||||
const S: &str = "Specify start point of arc";
|
||||
const C: &str = "Specify center point of arc";
|
||||
const E: &str = "Specify end point of arc";
|
||||
const A: &str = "Specify included angle";
|
||||
const L: &str = "Specify length of chord";
|
||||
match self {
|
||||
ArcV::ThreeP => &[(S, Ask::Point), ("Specify second point of arc", Ask::Point), (E, Ask::Point)],
|
||||
ArcV::Sec => &[(S, Ask::Point), (E, Ask::Point), (C, Ask::Point)],
|
||||
ArcV::Sce => &[(S, Ask::Point), (C, Ask::Point), (E, Ask::Point)],
|
||||
ArcV::Sca => &[(S, Ask::Point), (C, Ask::Point), (A, Ask::Sweep(1, 0))],
|
||||
ArcV::Scl => &[(S, Ask::Point), (C, Ask::Point), (L, Ask::Dist(0))],
|
||||
ArcV::Sea => &[(S, Ask::Point), (E, Ask::Point), (A, Ask::Angle(0))],
|
||||
ArcV::Sed => &[(S, Ask::Point), (E, Ask::Point), ("Specify tangent direction for the start point of arc", Ask::Angle(0))],
|
||||
ArcV::Ser => &[(S, Ask::Point), (E, Ask::Point), ("Specify radius of arc", Ask::Dist(1))],
|
||||
ArcV::Cse => &[(C, Ask::Point), (S, Ask::Point), ("Specify end point of arc (hold Ctrl to switch direction)", Ask::Point)],
|
||||
ArcV::Csa => &[(C, Ask::Point), (S, Ask::Point), (A, Ask::Sweep(0, 1))],
|
||||
ArcV::Csl => &[(C, Ask::Point), (S, Ask::Point), (L, Ask::Dist(1))],
|
||||
ArcV::CircleCd => &[("Specify center point for circle", Ask::Point), ("Specify diameter of circle", Ask::Dist(0))],
|
||||
ArcV::Circle2p => {
|
||||
&[("Specify first end point of circle's diameter", Ask::Point), ("Specify second end point of circle's diameter", Ask::Point)]
|
||||
}
|
||||
ArcV::Circle3p => &[
|
||||
("Specify first point on circle", Ask::Point),
|
||||
("Specify second point on circle", Ask::Point),
|
||||
("Specify third point on circle", Ask::Point),
|
||||
],
|
||||
}
|
||||
}
|
||||
fn build(self, v: &[Val], ctrl: bool) -> Option<EntityKind> {
|
||||
let p = |i: usize| v.get(i).and_then(|x| x.p());
|
||||
let n = |i: usize| v.get(i).and_then(|x| x.n());
|
||||
let a = match self {
|
||||
ArcV::ThreeP => Arc::from_3_points(p(0)?, p(1)?, p(2)?),
|
||||
ArcV::Sec => Some(Arc::from_start_center_end(p(0)?, p(2)?, p(1)?)),
|
||||
ArcV::Sce => Some(Arc::from_start_center_end(p(0)?, p(1)?, p(2)?)),
|
||||
ArcV::Sca => curves::arc_sca(p(0)?, p(1)?, n(2)?),
|
||||
ArcV::Scl => curves::arc_scl(p(0)?, p(1)?, n(2)?),
|
||||
ArcV::Sea => curves::arc_sea(p(0)?, p(1)?, n(2)?),
|
||||
ArcV::Sed => curves::arc_sed(p(0)?, p(1)?, Vec2::from_angle(n(2)?)),
|
||||
ArcV::Ser => curves::arc_ser(p(0)?, p(1)?, n(2)?),
|
||||
// Center, Start, End. Ctrl keeps the two endpoints on the circle and walks the
|
||||
// other way around: swapping the start/end angles is the mirror arc through the
|
||||
// same two endpoints. (Continue below flips differently, by negating the start
|
||||
// tangent; both are "reverse", but only here is the chord fixed by the picks.)
|
||||
ArcV::Cse => {
|
||||
let a = Arc::from_start_center_end(p(1)?, p(0)?, p(2)?);
|
||||
Some(if ctrl { Arc::new(a.center, a.radius, a.end, a.start) } else { a })
|
||||
}
|
||||
ArcV::Csa => curves::arc_sca(p(1)?, p(0)?, n(2)?),
|
||||
ArcV::Csl => curves::arc_scl(p(1)?, p(0)?, n(2)?),
|
||||
ArcV::CircleCd => {
|
||||
let d = n(1)?.abs();
|
||||
let c = p(0)?;
|
||||
return (d > 1e-12 && d.is_finite()).then(|| circle(c, d / 2.0));
|
||||
}
|
||||
ArcV::Circle2p => {
|
||||
let c = Circle::from_2_points(p(0)?, p(1)?);
|
||||
return (c.radius > 1e-12).then(|| circle(c.center, c.radius));
|
||||
}
|
||||
ArcV::Circle3p => {
|
||||
let c = Circle::from_3_points(p(0)?, p(1)?, p(2)?)?;
|
||||
return Some(circle(c.center, c.radius));
|
||||
}
|
||||
}?;
|
||||
(a.radius > 1e-12 && a.radius.is_finite() && a.center.is_finite()).then(|| arc(&a))
|
||||
}
|
||||
}
|
||||
|
||||
impl ArcV {
|
||||
/// Options at step `i` and the variant each one switches to (the values so far carry over).
|
||||
fn alts(self, i: usize) -> &'static [(&'static str, ArcV)] {
|
||||
match (self, i) {
|
||||
(ArcV::ThreeP, 0) => &[("Center", ArcV::Cse)],
|
||||
(ArcV::ThreeP, 1) => &[("Center", ArcV::Sce), ("End", ArcV::Sec)],
|
||||
(ArcV::Sce, 2) => &[("Angle", ArcV::Sca), ("chord Length", ArcV::Scl)],
|
||||
(ArcV::Cse, 2) => &[("Angle", ArcV::Csa), ("chord Length", ArcV::Csl)],
|
||||
(ArcV::Sec, 2) => &[("Angle", ArcV::Sea), ("Direction", ArcV::Sed), ("Radius", ArcV::Ser)],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn seq(v: ArcV) -> Box<dyn Interactive> {
|
||||
Box::new(SeqM { v, vals: Vec::new(), cont: None })
|
||||
}
|
||||
|
||||
/// The full ARC prompt sequence (all options; Enter at the first prompt continues the last
|
||||
/// line or arc).
|
||||
pub(crate) fn arc_machine() -> Box<dyn Interactive> {
|
||||
seq(ArcV::ThreeP)
|
||||
}
|
||||
|
||||
struct SeqM {
|
||||
v: ArcV,
|
||||
vals: Vec<Val>,
|
||||
/// ARC continuation: start point and tangent.
|
||||
cont: Option<(Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
impl SeqM {
|
||||
fn ask(&self) -> Option<(&'static str, Ask)> {
|
||||
self.v.steps().get(self.vals.len()).copied()
|
||||
}
|
||||
fn value_from_point(&self, ask: Ask, q: Vec2) -> Option<Val> {
|
||||
let pt = |i: usize| self.vals.get(i).and_then(|x| x.p());
|
||||
Some(match ask {
|
||||
Ask::Point => Val::P(q),
|
||||
Ask::Angle(b) => Val::N(pt(b)?.angle_to(q)),
|
||||
Ask::Sweep(c, f) => {
|
||||
let c = pt(c)?;
|
||||
Val::N(norm_angle(c.angle_to(q) - c.angle_to(pt(f)?)))
|
||||
}
|
||||
Ask::Dist(b) => Val::N(pt(b)?.dist(q)),
|
||||
})
|
||||
}
|
||||
fn base(&self) -> Option<Vec2> {
|
||||
match self.ask()?.1 {
|
||||
Ask::Point => self.vals.last().and_then(|v| v.p()),
|
||||
Ask::Angle(b) | Ask::Dist(b) => self.vals.get(b).and_then(|v| v.p()),
|
||||
Ask::Sweep(c, _) => self.vals.get(c).and_then(|v| v.p()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for SeqM {
|
||||
fn name(&self) -> &'static str {
|
||||
self.v.name()
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let Some((st, _)) = self.cont {
|
||||
return Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).base(st);
|
||||
}
|
||||
let kws: Vec<&str> = self.v.alts(self.vals.len()).iter().map(|a| a.0).collect();
|
||||
let p = match self.ask() {
|
||||
Some((m, Ask::Point)) => Prompt::new(m, Accept::POINT).base_opt(self.base()),
|
||||
Some((m, _)) => Prompt::new(m, Accept::POINT_OR_NUMBER).base_opt(self.base()),
|
||||
None => Prompt::new("", Accept::default()),
|
||||
};
|
||||
p.kw(&kws)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some((st, dir)) = self.cont {
|
||||
// Ctrl flips the arc: negating the start tangent makes `arc_sed` pick the mirror
|
||||
// arc (same two points and center, opposite sweep). `ctrl_down` comes from the
|
||||
// UI's per-frame write on `Session`; the `Input` enum itself stays free of
|
||||
// modifiers so it remains UI-agnostic.
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
return match i {
|
||||
Input::Point(e) => {
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
|
||||
s.add_entity(arc(&a))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Err(other("Point required.")),
|
||||
};
|
||||
}
|
||||
if let Input::Keyword(k) = &i
|
||||
&& let Some((_, v)) = self.v.alts(self.vals.len()).iter().find(|a| a.0 == k)
|
||||
{
|
||||
self.v = *v;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if i == Input::Enter && self.v == ArcV::ThreeP && self.vals.is_empty() {
|
||||
self.cont = curves::last_curve_end(s);
|
||||
if self.cont.is_none() {
|
||||
s.echo("No line or arc to continue.");
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let Some((_, ask)) = self.ask() else { return Ok(Step::Done) };
|
||||
let val = match (&i, ask) {
|
||||
(Input::Point(q), _) => self.value_from_point(ask, *q),
|
||||
(Input::Text(t), Ask::Angle(_) | Ask::Sweep(..)) => {
|
||||
// The tangent direction is a direction; included angles are sizes.
|
||||
let au = s.angle_settings();
|
||||
let a = if self.v == ArcV::Sed { au.direction(t) } else { au.amount(t) };
|
||||
Some(Val::N(a.ok_or_else(|| other("Requires a valid angle."))?))
|
||||
}
|
||||
(Input::Text(t), Ask::Dist(_)) => Some(Val::N(number(t).ok_or_else(|| other("Requires numeric distance or a point."))?)),
|
||||
(Input::Enter, _) => return Ok(Step::Cancel),
|
||||
_ => return Err(other("Point or value required.")),
|
||||
};
|
||||
let Some(val) = val else { return Ok(Step::Continue) };
|
||||
self.vals.push(val);
|
||||
if self.vals.len() < self.v.steps().len() {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match self.v.build(&self.vals, s.ctrl_down) {
|
||||
Some(k) => {
|
||||
if let EntityKind::Circle(c) = &k {
|
||||
let r = c.radius;
|
||||
s.add_entity(k)?;
|
||||
s.doc_mut()?.header.set_f64("CIRCLERAD", r);
|
||||
} else {
|
||||
s.add_entity(k)?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
None => {
|
||||
self.vals.pop();
|
||||
Err(other("Invalid arc or circle for these values."))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some((st, d)) = self.cont {
|
||||
let d = if s.ctrl_down { -d } else { d };
|
||||
return curves::arc_sed(st, c, d).map(|a| vec![arc(&a)]).unwrap_or_default();
|
||||
}
|
||||
let Some((_, ask)) = self.ask() else { return Vec::new() };
|
||||
let Some(v) = self.value_from_point(ask, c) else { return Vec::new() };
|
||||
let mut vals = self.vals.clone();
|
||||
vals.push(v);
|
||||
let mut out: Vec<EntityKind> = self.base().map(|b| vec![line(b, c)]).unwrap_or_default();
|
||||
if vals.len() == self.v.steps().len() {
|
||||
out.extend(self.v.build(&vals, s.ctrl_down));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn direction_param(p: &Value) -> Option<f64> {
|
||||
match p.get("direction") {
|
||||
Some(v) if v.is_array() || v.is_object() => point_value(v).filter(|d| d.len() > 1e-12).map(Vec2::angle),
|
||||
Some(v) => v.as_f64().filter(|x| x.is_finite()).map(f64::to_radians),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn arc_from_params(p: &Value) -> Result<EntityKind> {
|
||||
let st = point_param(p, "start");
|
||||
let c = point_param(p, "center");
|
||||
let e = point_param(p, "end");
|
||||
let ang = p.get("angle").and_then(Value::as_f64).filter(|v| v.is_finite()).map(f64::to_radians);
|
||||
let len = p.get("length").and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
let rad = p.get("radius").and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
let dir = direction_param(p);
|
||||
let a = match (st, c, e) {
|
||||
(Some(st), Some(c), Some(e)) => Some(Arc::from_start_center_end(st, c, e)),
|
||||
(Some(st), Some(c), None) => match (ang, len) {
|
||||
(Some(a), _) => curves::arc_sca(st, c, a),
|
||||
(None, Some(l)) => curves::arc_scl(st, c, l),
|
||||
_ => return Err(bad("arc", "start+center needs `end`, `angle` or `length`")),
|
||||
},
|
||||
(Some(st), None, Some(e)) => match (ang, dir, rad) {
|
||||
(Some(a), _, _) => curves::arc_sea(st, e, a),
|
||||
(None, Some(d), _) => curves::arc_sed(st, e, Vec2::from_angle(d)),
|
||||
(None, None, Some(r)) => curves::arc_ser(st, e, r),
|
||||
_ => return Err(bad("arc", "start+end needs `angle`, `direction` or `radius`")),
|
||||
},
|
||||
_ => return Err(bad("arc", "give start with center and/or end (see params)")),
|
||||
};
|
||||
let a = a.filter(|a| a.radius > 1e-12 && a.radius.is_finite()).ok_or_else(|| bad("arc", "these values do not define an arc"))?;
|
||||
Ok(arc(&a))
|
||||
}
|
||||
|
||||
pub(super) fn run_arc_variant(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let k = arc_from_params(p)?;
|
||||
added(s.add_entity(k)?)
|
||||
}
|
||||
|
||||
pub(super) fn run_arc_continue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e = point_req("arc.continue", p, "end")?;
|
||||
let (st, dir) = match (point_param(p, "start"), direction_param(p)) {
|
||||
(Some(st), Some(d)) => (st, Vec2::from_angle(d)),
|
||||
_ => curves::last_curve_end(s).ok_or_else(|| bad("arc.continue", "no line or arc to continue from"))?,
|
||||
};
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| bad("arc.continue", "end point is on the tangent line"))?;
|
||||
let h = s.add_entity(arc(&a))?;
|
||||
// The next continuation starts where this arc was drawn to, not at its stored CCW end.
|
||||
s.last_point = e;
|
||||
added(h)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ArcContinueM {
|
||||
from: Option<(Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
impl Interactive for ArcContinueM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ARC"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.from = curves::last_curve_end(s);
|
||||
if self.from.is_none() {
|
||||
s.echo("No line or arc to continue.");
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
Prompt::new("Specify end point of arc (hold Ctrl to switch direction)", Accept::POINT).base_opt(self.from.map(|f| f.0))
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some((st, dir)) = self.from else { return Ok(Step::Cancel) };
|
||||
// Ctrl flips the direction, exactly as in `SeqM::input`.
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
match i {
|
||||
Input::Point(e) => {
|
||||
let a = curves::arc_sed(st, e, dir).ok_or_else(|| other("Invalid end point."))?;
|
||||
s.add_entity(arc(&a))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Err(other("Point required.")),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.from
|
||||
.and_then(|(st, dir)| {
|
||||
let dir = if s.ctrl_down { -dir } else { dir };
|
||||
curves::arc_sed(st, c, dir)
|
||||
})
|
||||
.map(|a| vec![arc(&a)])
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// CENTERMARK / CENTERLINE
|
||||
// =====================================================================================
|
||||
|
||||
/// Add a line on the CENTER linetype (loading it from the standard library when missing).
|
||||
fn add_center_line(s: &mut Session, a: Vec2, b: Vec2) -> Result<Handle> {
|
||||
let d = s.doc_mut()?;
|
||||
if d.linetype("CENTER").is_none()
|
||||
&& let Some(lt) = cadcraft_doc::library::standard_linetypes_for(d).into_iter().find(|l| l.name == "CENTER")
|
||||
{
|
||||
d.linetypes.push(lt);
|
||||
}
|
||||
let h = s.add_entity(line(a, b))?;
|
||||
s.doc_mut()?.modify_entity(h, |e| e.common.linetype = "CENTER".into())?;
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
fn center_ext(s: &Session) -> f64 {
|
||||
let d = s.doc().ok();
|
||||
let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1);
|
||||
d.map(|d| d.header.f64("CENTEREXE", if metric { 3.5 } else { 0.12 })).unwrap_or(0.12)
|
||||
}
|
||||
|
||||
fn center_mark(s: &mut Session, h: Handle) -> Result<Vec<String>> {
|
||||
let e = curves::entity(s, h)?;
|
||||
let (c, r) = match &e.kind {
|
||||
EntityKind::Circle(c) => (c.center.xy(), c.radius),
|
||||
EntityKind::Arc(a) => (a.center.xy(), a.radius),
|
||||
_ => return Err(other("Select a circle or arc.")),
|
||||
};
|
||||
let ext = r + center_ext(s);
|
||||
let h1 = add_center_line(s, c - Vec2::X * ext, c + Vec2::X * ext)?;
|
||||
let h2 = add_center_line(s, c - Vec2::Y * ext, c + Vec2::Y * ext)?;
|
||||
Ok(vec![h1.hex(), h2.hex()])
|
||||
}
|
||||
|
||||
fn center_line(s: &mut Session, h1: Handle, h2: Handle) -> Result<Handle> {
|
||||
let l = |h: Handle| -> Result<(Vec2, Vec2)> {
|
||||
match curves::entity(s, h)?.kind {
|
||||
EntityKind::Line(l) => Ok((l.a.xy(), l.b.xy())),
|
||||
_ => Err(other("Select a line.")),
|
||||
}
|
||||
};
|
||||
let (a1, b1) = l(h1)?;
|
||||
let (mut a2, mut b2) = l(h2)?;
|
||||
if (b1 - a1).dot(b2 - a2) < 0.0 {
|
||||
std::mem::swap(&mut a2, &mut b2);
|
||||
}
|
||||
let (st, en) = (a1.mid(a2), b1.mid(b2));
|
||||
let dir = (en - st).normalized();
|
||||
if dir == Vec2::ZERO {
|
||||
return Err(other("The lines do not define a center line."));
|
||||
}
|
||||
let ext = center_ext(s);
|
||||
add_center_line(s, st - dir * ext, en + dir * ext)
|
||||
}
|
||||
|
||||
pub(super) fn run_centermark(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
if hs.is_empty() {
|
||||
return Err(bad("centermark", "`handle` or `handles` is required"));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for h in hs {
|
||||
out.extend(center_mark(s, h)?);
|
||||
}
|
||||
Ok(json!({ "handles": out }))
|
||||
}
|
||||
|
||||
pub(super) fn run_centerline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h1 = curves::handle_param(p, "h1").ok_or_else(|| bad("centerline", "`h1` is required"))?;
|
||||
let h2 = curves::handle_param(p, "h2").ok_or_else(|| bad("centerline", "`h2` is required"))?;
|
||||
added(center_line(s, h1, h2)?)
|
||||
}
|
||||
|
||||
pub(super) struct CenterM {
|
||||
pub(super) first: Option<Handle>,
|
||||
pub(super) mark: bool,
|
||||
}
|
||||
|
||||
impl Interactive for CenterM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.mark { "CENTERMARK" } else { "CENTERLINE" }
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (self.mark, self.first) {
|
||||
(true, _) => Prompt::new("Select circle or arc to add center mark", Accept::POINT),
|
||||
(false, None) => Prompt::new("Select first line", Accept::POINT),
|
||||
(false, Some(_)) => Prompt::new("Select second line", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
if self.mark {
|
||||
center_mark(s, h)?;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match self.first {
|
||||
None => {
|
||||
if !matches!(curves::entity(s, h)?.kind, EntityKind::Line(_)) {
|
||||
return Err(other("Select a line."));
|
||||
}
|
||||
self.first = Some(h);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some(h1) => {
|
||||
center_line(s, h1, h)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// 3DPOLY
|
||||
// =====================================================================================
|
||||
|
||||
fn point3_value(v: &Value) -> Option<Vec3> {
|
||||
let a = v.as_array()?;
|
||||
let x = a.first()?.as_f64()?;
|
||||
let y = a.get(1)?.as_f64()?;
|
||||
let z = a.get(2).and_then(Value::as_f64).unwrap_or(0.0);
|
||||
let p = Vec3::new(x, y, z);
|
||||
p.is_finite().then_some(p)
|
||||
}
|
||||
|
||||
pub(super) fn run_3dpoly(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pts: Vec<Vec3> = p.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(point3_value).collect()).unwrap_or_default();
|
||||
if pts.len() < 2 || pts.len() > MAX_GEN {
|
||||
return Err(bad("3dpoly", "`points` needs 2 or more [x,y,z] points"));
|
||||
}
|
||||
added(s.add_entity(EntityKind::Polyline3d(Polyline3d { points: pts, closed: bool_or(p, "closed", false) }))?)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Poly3dM {
|
||||
pts: Vec<Vec2>,
|
||||
handle: Option<Handle>,
|
||||
}
|
||||
|
||||
impl Poly3dM {
|
||||
fn sync(&mut self, s: &mut Session, closed: bool) -> Result<()> {
|
||||
let k = EntityKind::Polyline3d(Polyline3d { points: self.pts.iter().map(|p| v3(*p)).collect(), closed });
|
||||
match (self.handle, self.pts.len() >= 2) {
|
||||
(Some(h), true) => s.doc_mut()?.modify_entity(h, |e| e.kind = k)?,
|
||||
(None, true) => self.handle = Some(s.add_entity(k)?),
|
||||
(Some(h), false) => {
|
||||
s.doc_mut()?.remove_entity(h);
|
||||
self.handle = None;
|
||||
}
|
||||
(None, false) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for Poly3dM {
|
||||
fn name(&self) -> &'static str {
|
||||
"3DPOLY"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify start point of polyline", Accept::POINT),
|
||||
1 | 2 => Prompt::new("Specify endpoint of line", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
|
||||
_ => Prompt::new("Specify endpoint of line", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
if self.pts.last().is_none_or(|l| !l.near(p, 1e-12)) && self.pts.len() < MAX_GEN {
|
||||
self.pts.push(p);
|
||||
self.sync(s, false)?;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
self.pts.pop();
|
||||
self.sync(s, false)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Close" => {
|
||||
self.sync(s, true)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
self.pts.last().map(|l| vec![line(*l, c)]).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// HELIX (3D polyline approximation)
|
||||
// =====================================================================================
|
||||
|
||||
/// A helix entity (base centre `c`, radii, height, turns, twist, start angle).
|
||||
fn helix_kind(c: Vec2, rb: f64, rt: f64, height: f64, turns: f64, ccw: bool, a0: f64) -> EntityKind {
|
||||
let h = Helix { center: c.to3(0.0), base_radius: rb, top_radius: rt, height, turns, start_angle: a0, ccw, constrain: HelixConstrain::Height };
|
||||
EntityKind::Helix(h.sanitized())
|
||||
}
|
||||
|
||||
pub(super) fn run_helix(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = point_req("helix", p, "center")?;
|
||||
let rb = f64_req("helix", p, "baseRadius")?;
|
||||
let rt = f64_or(p, "topRadius", rb);
|
||||
let turns = f64_or(p, "turns", 3.0);
|
||||
if !(rb > 0.0 && rt >= 0.0 && turns > 0.0 && turns <= 500.0) {
|
||||
return Err(bad("helix", "radii must be positive and turns in (0, 500]"));
|
||||
}
|
||||
let height = f64_or(p, "height", 1.0);
|
||||
if height.abs() > 1e12 || rb > 1e12 || rt > 1e12 {
|
||||
return Err(bad("helix", "sizes are out of range"));
|
||||
}
|
||||
added(s.add_entity(helix_kind(c, rb, rt, height, turns, bool_or(p, "ccw", true), f64_or(p, "startAngle", 0.0).to_radians()))?)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct HelixM {
|
||||
center: Option<Vec2>,
|
||||
rb: Option<f64>,
|
||||
rt: Option<f64>,
|
||||
turns: Option<f64>,
|
||||
cw: bool,
|
||||
diameter: bool,
|
||||
asking: u8, // 0 main, 1 turns, 2 twist, 3 turn height
|
||||
start: Option<f64>,
|
||||
}
|
||||
|
||||
impl HelixM {
|
||||
fn turns(&self) -> f64 {
|
||||
self.turns.unwrap_or(3.0)
|
||||
}
|
||||
fn create(&self, s: &mut Session, height: f64) -> Result<Step> {
|
||||
let (Some(c), Some(rb)) = (self.center, self.rb) else { return Ok(Step::Cancel) };
|
||||
let rt = self.rt.unwrap_or(rb);
|
||||
if !height.is_finite() || height.abs() > 1e12 {
|
||||
return Err(other("Requires a distance."));
|
||||
}
|
||||
s.add_entity(helix_kind(c, rb, rt, height, self.turns(), !self.cw, self.start.unwrap_or(0.0)))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for HelixM {
|
||||
fn name(&self) -> &'static str {
|
||||
"HELIX"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.echo(format!("Number of turns = {:.4} Twist=CCW", self.turns()));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.asking {
|
||||
1 => return Prompt::new("Enter number of turns", Accept::NUMBER).default(format!("{:.4}", self.turns())),
|
||||
2 => return Prompt::new("Enter twist direction of helix", curves::KW).kw(&["CW", "CCW"]).default("CCW"),
|
||||
3 => return Prompt::new("Specify distance between turns", Accept::NUMBER),
|
||||
_ => {}
|
||||
}
|
||||
match (self.center, self.rb, self.rt) {
|
||||
(None, ..) => Prompt::new("Specify center point of base", Accept::POINT),
|
||||
(Some(c), None, _) => {
|
||||
Prompt::new(if self.diameter { "Specify base diameter" } else { "Specify base radius or" }, Accept::POINT_OR_NUMBER)
|
||||
.kw(if self.diameter { &[] } else { &["Diameter"] })
|
||||
.default("1.0000")
|
||||
.base(c)
|
||||
}
|
||||
(Some(c), Some(rb), None) => {
|
||||
Prompt::new(if self.diameter { "Specify top diameter" } else { "Specify top radius or" }, Accept::POINT_OR_NUMBER)
|
||||
.kw(if self.diameter { &[] } else { &["Diameter"] })
|
||||
.default(format!("{rb:.4}"))
|
||||
.base(c)
|
||||
}
|
||||
(Some(c), Some(_), Some(_)) => Prompt::new("Specify helix height or", Accept::POINT_OR_NUMBER)
|
||||
.kw(&["Axis endpoint", "Turns", "turn Height", "tWist"])
|
||||
.default("1.0000")
|
||||
.base(c),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.asking {
|
||||
1 => {
|
||||
if let Input::Text(t) = &i {
|
||||
let v = number(t).filter(|v| *v > 0.0 && *v <= 500.0).ok_or_else(|| other("Turns must be in (0, 500]."))?;
|
||||
self.turns = Some(v);
|
||||
}
|
||||
self.asking = 0;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
2 => {
|
||||
if let Input::Keyword(k) | Input::Text(k) = &i {
|
||||
self.cw = k.eq_ignore_ascii_case("cw");
|
||||
}
|
||||
self.asking = 0;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
3 => {
|
||||
self.asking = 0;
|
||||
if let Input::Text(t) = &i {
|
||||
let v = number(t).ok_or_else(|| other("Requires a distance."))?;
|
||||
return self.create(s, v * self.turns());
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let num = |i: &Input, base: Option<Vec2>| -> Option<f64> {
|
||||
match i {
|
||||
Input::Point(p) => base.map(|b| b.dist(*p)),
|
||||
Input::Text(t) => number(t),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
match (self.center, self.rb, self.rt, &i) {
|
||||
(None, _, _, Input::Point(p)) => {
|
||||
self.center = Some(*p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(_), None | Some(_), _, Input::Keyword(k)) if k == "Diameter" => {
|
||||
self.diameter = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(c), None, _, _) => {
|
||||
let v = match &i {
|
||||
Input::Enter => Some(1.0),
|
||||
_ => num(&i, Some(c)),
|
||||
};
|
||||
let v = v.filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive distance."))?;
|
||||
if let Input::Point(p) = &i {
|
||||
self.start = Some(c.angle_to(*p));
|
||||
}
|
||||
self.rb = Some(if self.diameter { v / 2.0 } else { v });
|
||||
self.diameter = false;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(c), Some(rb), None, _) => {
|
||||
let v = match &i {
|
||||
Input::Enter => Some(if self.diameter { rb * 2.0 } else { rb }),
|
||||
_ => num(&i, Some(c)),
|
||||
};
|
||||
let v = v.filter(|v| *v >= 0.0).ok_or_else(|| other("Requires a distance."))?;
|
||||
self.rt = Some(if self.diameter { v / 2.0 } else { v });
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(c), Some(_), Some(_), _) => match &i {
|
||||
Input::Keyword(k) => {
|
||||
self.asking = match k.as_str() {
|
||||
"Turns" => 1,
|
||||
"tWist" => 2,
|
||||
"turn Height" => 3,
|
||||
_ => 0,
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => self.create(s, 1.0),
|
||||
_ => {
|
||||
let h = num(&i, Some(c)).ok_or_else(|| other("Requires a distance."))?;
|
||||
self.create(s, h)
|
||||
}
|
||||
},
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match (self.center, self.rb, self.rt) {
|
||||
(Some(ct), None, _) => vec![circle(ct, ct.dist(c))],
|
||||
(Some(ct), Some(rb), None) => {
|
||||
vec![helix_kind(ct, rb, ct.dist(c), 0.0, self.turns(), !self.cw, self.start.unwrap_or(0.0))]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// SPLINE by control vertices
|
||||
// =====================================================================================
|
||||
|
||||
pub(super) fn run_spline_cv(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut c = points_param(p, "control").ok_or_else(|| bad("spline.cv", "`control` points are required"))?;
|
||||
if c.len() < 2 || c.len() > MAX_GEN {
|
||||
return Err(bad("spline.cv", "need 2+ control points"));
|
||||
}
|
||||
let closed = bool_or(p, "closed", false);
|
||||
if closed && let Some(f) = c.first().copied() {
|
||||
c.push(f);
|
||||
}
|
||||
let deg = p.get("degree").and_then(Value::as_u64).unwrap_or(3).clamp(1, 10) as usize;
|
||||
let mut sp = Spline::from_control(c, deg);
|
||||
sp.closed = closed;
|
||||
added(s.add_entity(EntityKind::Spline(sp))?)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct SplineCvM {
|
||||
pts: Vec<Vec2>,
|
||||
degree: Option<usize>,
|
||||
asking_degree: bool,
|
||||
}
|
||||
|
||||
impl Interactive for SplineCvM {
|
||||
fn name(&self) -> &'static str {
|
||||
"SPLINE"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.echo(format!("Current settings: Method=CV Degree={}", self.degree.unwrap_or(3)));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.asking_degree {
|
||||
return Prompt::new("Enter degree of spline", Accept::NUMBER).default(self.degree.unwrap_or(3).to_string());
|
||||
}
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify first point", Accept::POINT).kw(&["Method", "Degree", "Object"]),
|
||||
1 => Prompt::new("Enter next point", Accept::POINT).base_opt(self.pts.last().copied()),
|
||||
2 => Prompt::new("Enter next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
|
||||
_ => Prompt::new("Enter next point", Accept::POINT).kw(&["Close", "Undo"]).base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.asking_degree {
|
||||
if let Input::Text(t) = &i {
|
||||
let d = number(t).filter(|d| (1.0..=10.0).contains(d)).ok_or_else(|| other("Degree must be 1..10."))?;
|
||||
self.degree = Some(d as usize);
|
||||
}
|
||||
self.asking_degree = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let deg = self.degree.unwrap_or(3);
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
if self.pts.len() < MAX_GEN {
|
||||
self.pts.push(p);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Degree" => self.asking_degree = true,
|
||||
"Undo" => {
|
||||
self.pts.pop();
|
||||
}
|
||||
"Close" => {
|
||||
let mut c = self.pts.clone();
|
||||
c.extend(self.pts.first().copied());
|
||||
let mut sp = Spline::from_control(c, deg);
|
||||
sp.closed = true;
|
||||
s.add_entity(EntityKind::Spline(sp))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
_ => s.echo(format!("{k}: use SPLINE for fit points.")),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => {
|
||||
if self.pts.len() >= 2 {
|
||||
s.add_entity(EntityKind::Spline(Spline::from_control(self.pts.clone(), deg)))?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.pts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut pts = self.pts.clone();
|
||||
pts.push(c);
|
||||
vec![EntityKind::Spline(Spline::from_control(pts, self.degree.unwrap_or(3)))]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// DIVIDE / MEASURE
|
||||
// =====================================================================================
|
||||
|
||||
fn chain_of(s: &Session, h: Handle) -> Result<Chain> {
|
||||
let e = curves::entity(s, h)?;
|
||||
Chain::of(&e.kind).ok_or_else(|| other("Cannot divide/measure that object."))
|
||||
}
|
||||
|
||||
fn place_markers(s: &mut Session, at: &[(Vec2, Vec2)], block: Option<&str>, align: bool) -> Result<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
for (p, t) in at {
|
||||
let h = match block {
|
||||
Some(b) => crate::cmd::blocks::insert(s, b, *p, 1.0, if align { t.angle() } else { 0.0 }, &serde_json::Map::new())?,
|
||||
None => s.add_entity(EntityKind::Point(Point { p: v3(*p), angle: 0.0 }))?,
|
||||
};
|
||||
out.push(h.hex());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn check_block(s: &Session, b: Option<&str>) -> Result<()> {
|
||||
if let Some(b) = b
|
||||
&& s.doc()?.block(b).is_none()
|
||||
{
|
||||
return Err(other(format!("Block \"{b}\" not found.")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn divide_points(c: &Chain, n: usize) -> Vec<(Vec2, Vec2)> {
|
||||
let l = c.len();
|
||||
let range = if c.closed { 0..n } else { 1..n };
|
||||
range.filter_map(|i| c.at_length(l * i as f64 / n as f64)).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn measure_points(c: &Chain, step: f64) -> Vec<(Vec2, Vec2)> {
|
||||
let l = c.len();
|
||||
let mut out = Vec::new();
|
||||
let mut k = 1usize;
|
||||
while (k as f64) * step < l - 1e-9 && out.len() < MAX_GEN {
|
||||
let d = k as f64 * step;
|
||||
out.extend(c.at_length(d));
|
||||
k += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn run_divide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("divide", "`handle` is required"))?;
|
||||
let n = p.get("segments").and_then(Value::as_u64).ok_or_else(|| bad("divide", "`segments` (2..32767) is required"))?;
|
||||
if !(2..=32767).contains(&n) {
|
||||
return Err(bad("divide", "segments must be 2..32767"));
|
||||
}
|
||||
let block = str_param(p, "block").map(str::to_string);
|
||||
check_block(s, block.as_deref())?;
|
||||
let c = chain_of(s, h)?;
|
||||
let pts = divide_points(&c, n as usize);
|
||||
let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?;
|
||||
Ok(json!({ "handles": hs }))
|
||||
}
|
||||
|
||||
pub(super) fn run_measure(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("measure", "`handle` is required"))?;
|
||||
let step = f64_req("measure", p, "length")?;
|
||||
let mut c = chain_of(s, h)?;
|
||||
if step.is_nan() || step <= 0.0 || c.len() / step > MAX_GEN as f64 {
|
||||
return Err(bad("measure", "length must be positive (and not tiny)"));
|
||||
}
|
||||
let block = str_param(p, "block").map(str::to_string);
|
||||
check_block(s, block.as_deref())?;
|
||||
if let Some(from) = point_param(p, "from")
|
||||
&& !c.closed
|
||||
&& let (Some(a), Some(b)) = (c.start(), c.end())
|
||||
&& from.dist(b) < from.dist(a)
|
||||
{
|
||||
c = c.reversed();
|
||||
}
|
||||
let pts = measure_points(&c, step);
|
||||
let hs = place_markers(s, &pts, block.as_deref(), bool_or(p, "align", true))?;
|
||||
Ok(json!({ "handles": hs }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum DivPhase {
|
||||
Object,
|
||||
Value,
|
||||
BlockName,
|
||||
Align,
|
||||
}
|
||||
|
||||
pub(super) struct DivideM {
|
||||
measure: bool,
|
||||
obj: Option<(Handle, Vec2)>,
|
||||
phase: DivPhase,
|
||||
block: Option<String>,
|
||||
align: bool,
|
||||
}
|
||||
|
||||
impl DivideM {
|
||||
pub(super) fn new(measure: bool) -> Self {
|
||||
DivideM { measure, obj: None, phase: DivPhase::Object, block: None, align: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for DivideM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.measure { "MEASURE" } else { "DIVIDE" }
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.phase {
|
||||
DivPhase::Object => Prompt::new(if self.measure { "Select object to measure" } else { "Select object to divide" }, Accept::POINT),
|
||||
DivPhase::Value => {
|
||||
let m = if self.measure { "Specify length of segment" } else { "Enter the number of segments" };
|
||||
let p = Prompt::new(m, if self.measure { Accept::POINT_OR_NUMBER } else { Accept::NUMBER });
|
||||
let p = if self.measure { p.base_opt(self.obj.map(|o| o.1)) } else { p };
|
||||
if self.block.is_some() { p } else { p.kw(&["Block"]) }
|
||||
}
|
||||
DivPhase::BlockName => Prompt::new("Enter name of block to insert", Accept::TEXT),
|
||||
DivPhase::Align => Prompt::new("Align block with object?", curves::KW).kw(&["Yes", "No"]).default("Y"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.phase, i) {
|
||||
(DivPhase::Object, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
chain_of(s, h)?;
|
||||
self.obj = Some((h, p));
|
||||
self.phase = DivPhase::Value;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(DivPhase::Value, Input::Keyword(k)) if k == "Block" => {
|
||||
self.phase = DivPhase::BlockName;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(DivPhase::BlockName, Input::Text(t) | Input::Keyword(t)) => {
|
||||
let name = t.trim().to_string();
|
||||
check_block(s, Some(&name))?;
|
||||
self.block = Some(name);
|
||||
self.phase = DivPhase::Align;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(DivPhase::Align, Input::Keyword(k) | Input::Text(k)) => {
|
||||
self.align = !k.trim().to_ascii_lowercase().starts_with('n');
|
||||
self.phase = DivPhase::Value;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(DivPhase::Align, Input::Enter) => {
|
||||
self.phase = DivPhase::Value;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(DivPhase::Value, inp @ (Input::Text(_) | Input::Point(_))) => {
|
||||
let Some((h, pick)) = self.obj else { return Ok(Step::Cancel) };
|
||||
let v = match inp {
|
||||
Input::Text(t) => number(&t).ok_or_else(|| other("Requires a number."))?,
|
||||
Input::Point(q) => pick.dist(q),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let mut params = json!({ "handle": h.hex(), "align": self.align, "from": [pick.x, pick.y] });
|
||||
if let Some(b) = &self.block {
|
||||
params["block"] = json!(b);
|
||||
}
|
||||
if self.measure {
|
||||
params["length"] = json!(v);
|
||||
run_measure(s, ¶ms)?;
|
||||
} else {
|
||||
if !(2.0..=32767.0).contains(&v) || v.fract() != 0.0 {
|
||||
return Err(other("Requires an integer between 2 and 32767."));
|
||||
}
|
||||
params["segments"] = json!(v as u64);
|
||||
run_divide(s, ¶ms)?;
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Err(other("Invalid input.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------- ellipse entries ----------------
|
||||
|
||||
fn ellipse_from_axis(p1: Vec2, p2: Vec2, half: f64) -> Option<Ellipse> {
|
||||
let c = p1.mid(p2);
|
||||
let m = p2 - c;
|
||||
let ml = m.len();
|
||||
let half = half.abs();
|
||||
if ml < 1e-12 || half < 1e-12 || !half.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(if half > ml { Ellipse::full(c, m.normalized().perp() * half, ml / half) } else { Ellipse::full(c, m, half / ml) })
|
||||
}
|
||||
|
||||
fn axis_ellipse_params(cmd: &str, p: &Value) -> Result<Ellipse> {
|
||||
let p1 = point_req(cmd, p, "p1")?;
|
||||
let p2 = point_req(cmd, p, "p2")?;
|
||||
let half = match point_param(p, "p3") {
|
||||
Some(p3) => {
|
||||
let ax = Line::new(p1, p2);
|
||||
ax.project(p3).dist(p3)
|
||||
}
|
||||
None => f64_req(cmd, p, "distance")?,
|
||||
};
|
||||
ellipse_from_axis(p1, p2, half).ok_or_else(|| bad(cmd, "degenerate ellipse"))
|
||||
}
|
||||
|
||||
fn ellipse_kind(e: &Ellipse) -> EntityKind {
|
||||
EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(e.center), major: v3(e.major), ratio: e.ratio, start: e.start, end: e.end })
|
||||
}
|
||||
|
||||
pub(super) fn run_ellipse_axis(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e = axis_ellipse_params("ellipse.axis", p)?;
|
||||
added(s.add_entity(ellipse_kind(&e))?)
|
||||
}
|
||||
|
||||
pub(super) fn run_ellipse_arc(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut e = if let Some(c) = point_param(p, "center") {
|
||||
let m = point_req("ellipse.arc", p, "major")?;
|
||||
let ratio = f64_req("ellipse.arc", p, "ratio")?;
|
||||
if !(ratio > 0.0 && ratio <= 1.0) || m.len() < 1e-12 {
|
||||
return Err(bad("ellipse.arc", "ratio must be in (0, 1] and major non-zero"));
|
||||
}
|
||||
Ellipse::full(c, m, ratio)
|
||||
} else {
|
||||
axis_ellipse_params("ellipse.arc", p)?
|
||||
};
|
||||
// Angles are measured from the major axis (like the prompts); convert to parameters.
|
||||
let to_param = |deg: f64| {
|
||||
let a = deg.to_radians();
|
||||
let ge = Ellipse { start: 0.0, end: TAU, ..e };
|
||||
ge.param_of(e.center + Vec2::from_angle(a + e.major.angle()))
|
||||
};
|
||||
let st = to_param(f64_req("ellipse.arc", p, "start")?);
|
||||
let en = to_param(f64_req("ellipse.arc", p, "end")?);
|
||||
e.start = st;
|
||||
e.end = en;
|
||||
added(s.add_entity(ellipse_kind(&e))?)
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// RECTANG with Area / Dimensions / Rotation / Width / Elevation / Thickness
|
||||
// =====================================================================================
|
||||
|
||||
fn rect_kind(first: Vec2, (w, h): (f64, f64), rot: f64, fillet: f64, chamfer: (f64, f64), width: f64, elevation: f64) -> EntityKind {
|
||||
let local = rect_with_corners(Vec2::ZERO, Vec2::new(w, h), fillet, chamfer);
|
||||
let vs = local.into_iter().map(|v| PolyVertex { p: first + v.p.rotate(rot), ..v }).collect();
|
||||
let mut k = lwpoly(vs, true);
|
||||
if let EntityKind::LwPolyline(pl) = &mut k {
|
||||
pl.const_width = width.max(0.0);
|
||||
pl.elevation = elevation;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// The width and height of a rectangle from its `dimensions`, its `p2` corner, its `area` with
|
||||
/// `length` or `breadth`, or its second corner `p2`, rotated by `rot` about `a`.
|
||||
fn rect_size(p: &Value, a: Vec2, rot: f64, centered: bool) -> Result<(f64, f64)> {
|
||||
Ok(if let Some(d) = p.get("dimensions").and_then(Value::as_array) {
|
||||
let l = d.first().and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?;
|
||||
let wd = d.get(1).and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("rectang", "dimensions: [length, width]"))?;
|
||||
let (sx, sy) = match point_param(p, "p2") {
|
||||
Some(q) => {
|
||||
let loc = (q - a).rotate(-rot);
|
||||
(if loc.x < 0.0 { -1.0 } else { 1.0 }, if loc.y < 0.0 { -1.0 } else { 1.0 })
|
||||
}
|
||||
None => (1.0, 1.0),
|
||||
};
|
||||
(l.abs() * sx, wd.abs() * sy)
|
||||
} else if let Some(area) = p.get("area").and_then(Value::as_f64).filter(|v| v.is_finite()) {
|
||||
if area <= 0.0 {
|
||||
return Err(bad("rectang", "area must be positive"));
|
||||
}
|
||||
match (p.get("length").and_then(Value::as_f64), p.get("breadth").and_then(Value::as_f64)) {
|
||||
(Some(l), _) if l.is_finite() && l > 0.0 => (l, area / l),
|
||||
(_, Some(b)) if b.is_finite() && b > 0.0 => (area / b, b),
|
||||
_ => return Err(bad("rectang", "area needs `length` or `breadth`")),
|
||||
}
|
||||
} else {
|
||||
let b = point_req("rectang", p, "p2")?;
|
||||
let loc = (b - a).rotate(-rot);
|
||||
// In centre mode the picked point is one corner, mirrored across the centre.
|
||||
if centered { (2.0 * loc.x, 2.0 * loc.y) } else { (loc.x, loc.y) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Draws a rectangle from the JSON form: `p1` is a corner, or the centre when `center` is
|
||||
/// true, and the size comes from `p2`, `dimensions` or `area` plus `length`/`breadth`.
|
||||
pub(crate) fn run_rectang2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let a = point_req("rectang", p, "p1")?;
|
||||
// Centre mode: `p1` is the rectangle's centre, so the rectangle spans it evenly.
|
||||
let centered = bool_or(p, "center", false);
|
||||
let rot = f64_or(p, "rotation", 0.0).to_radians();
|
||||
let ch = p
|
||||
.get("chamfer")
|
||||
.and_then(Value::as_array)
|
||||
.map(|c| (c.first().and_then(Value::as_f64).unwrap_or(0.0), c.get(1).and_then(Value::as_f64).unwrap_or(0.0)))
|
||||
.map(|(x, y)| (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }))
|
||||
.unwrap_or((0.0, 0.0));
|
||||
if ch.0 < 0.0 || ch.1 < 0.0 {
|
||||
return Err(bad("rectang", "`chamfer` distances must be zero or positive"));
|
||||
}
|
||||
let fillet = size_param("rectang", p, "fillet", true)?.unwrap_or(0.0);
|
||||
let width = size_param("rectang", p, "width", true)?.unwrap_or(0.0);
|
||||
let (w, h) = rect_size(p, a, rot, centered)?;
|
||||
if w.abs() < 1e-12 || h.abs() < 1e-12 || !w.is_finite() || !h.is_finite() {
|
||||
return Err(bad("rectang", "rectangle has no area"));
|
||||
}
|
||||
let first = if centered { a - Vec2::new(w / 2.0, h / 2.0).rotate(rot) } else { a };
|
||||
let k = rect_kind(first, (w, h), rot, fillet, ch, width, f64_or(p, "elevation", 0.0));
|
||||
let hd = s.add_entity(k)?;
|
||||
// A rectangle has its own thickness option and does not take THICKNESS.
|
||||
let th = f64_or(p, "thickness", 0.0);
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = th)?;
|
||||
added(hd)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum RAsk {
|
||||
Chamfer1,
|
||||
Chamfer2,
|
||||
Fillet,
|
||||
Width,
|
||||
Elevation,
|
||||
Thickness,
|
||||
Area,
|
||||
AreaBasis,
|
||||
AreaLength,
|
||||
AreaWidth,
|
||||
DimL,
|
||||
DimW,
|
||||
Rotation,
|
||||
RotPick1,
|
||||
RotPick2(Vec2),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct RectM2 {
|
||||
first: Option<Vec2>,
|
||||
/// The first point is the rectangle's centre (the `Center` option), not a corner.
|
||||
center: bool,
|
||||
fillet: f64,
|
||||
chamfer: (f64, f64),
|
||||
width: f64,
|
||||
elevation: f64,
|
||||
thickness: f64,
|
||||
rotation: f64,
|
||||
dims: Option<(f64, f64)>,
|
||||
area: f64,
|
||||
asking: Option<RAsk>,
|
||||
}
|
||||
|
||||
impl RectM2 {
|
||||
/// Adds the rectangle of full size `w` × `h` with the collected options, drawing it from
|
||||
/// the centre when centre mode is on, then applies the requested thickness.
|
||||
fn make(&self, s: &mut Session, w: f64, h: f64) -> Result<Step> {
|
||||
let Some(a) = self.first else { return Ok(Step::Cancel) };
|
||||
if w.abs() < 1e-12 || h.abs() < 1e-12 {
|
||||
return Err(other("The rectangle has no area."));
|
||||
}
|
||||
let first = self.origin(w, h).unwrap_or(a);
|
||||
let hd = s.add_entity(rect_kind(first, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation))?;
|
||||
// A rectangle has its own thickness option and does not take THICKNESS.
|
||||
let t = self.thickness;
|
||||
s.doc_mut()?.modify_entity(hd, |e| e.common.thickness = t)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
/// The rectangle's local origin (the corner `rect_kind` draws from) for a size: the first
|
||||
/// point itself as a corner, or the first point less half the size around the centre.
|
||||
fn origin(&self, w: f64, h: f64) -> Option<Vec2> {
|
||||
let a = self.first?;
|
||||
Some(if self.center { a - Vec2::new(w / 2.0, h / 2.0).rotate(self.rotation) } else { a })
|
||||
}
|
||||
/// The rectangle's signed full size implied by the picked point `p`: explicit `dims` only
|
||||
/// take their quadrant from `p`, otherwise `p` is a corner, doubled about the centre in
|
||||
/// centre mode.
|
||||
fn other_corner(&self, p: Vec2) -> Option<(f64, f64)> {
|
||||
let a = self.first?;
|
||||
let loc = (p - a).rotate(-self.rotation);
|
||||
Some(match self.dims {
|
||||
Some((l, w)) => (if loc.x < 0.0 { -l } else { l }, if loc.y < 0.0 { -w } else { w }),
|
||||
// In centre mode the picked point is one corner, mirrored across the centre.
|
||||
None if self.center => (2.0 * loc.x, 2.0 * loc.y),
|
||||
None => (loc.x, loc.y),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for RectM2 {
|
||||
fn name(&self) -> &'static str {
|
||||
"RECTANG"
|
||||
}
|
||||
/// Asks for the pending option's value, or for the first and second points; in centre mode
|
||||
/// the first prompt asks for the centre instead of a corner.
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
let num = |m: &str, d: f64| Prompt::new(m, Accept::NUMBER).default(format!("{d:.4}"));
|
||||
if let Some(a) = self.asking {
|
||||
return match a {
|
||||
RAsk::Chamfer1 => num("Specify first chamfer distance for rectangles", self.chamfer.0),
|
||||
RAsk::Chamfer2 => num("Specify second chamfer distance for rectangles", self.chamfer.1),
|
||||
RAsk::Fillet => num("Specify fillet radius for rectangles", self.fillet),
|
||||
RAsk::Width => num("Specify line width for rectangles", self.width),
|
||||
RAsk::Elevation => num("Specify the elevation for rectangles", self.elevation),
|
||||
RAsk::Thickness => num("Specify thickness for rectangles", self.thickness),
|
||||
RAsk::Area => num("Enter area of rectangle in current units", if self.area > 0.0 { self.area } else { 100.0 }),
|
||||
RAsk::AreaBasis => Prompt::new("Calculate rectangle dimensions based on", curves::KW).kw(&["Length", "Width"]).default("Length"),
|
||||
RAsk::AreaLength => num("Enter rectangle length", 10.0),
|
||||
RAsk::AreaWidth => num("Enter rectangle width", 10.0),
|
||||
RAsk::DimL => num("Specify length for rectangles", self.dims.map(|d| d.0).unwrap_or(10.0)),
|
||||
RAsk::DimW => num("Specify width for rectangles", self.dims.map(|d| d.1).unwrap_or(10.0)),
|
||||
RAsk::Rotation => Prompt::new("Specify rotation angle or", Accept::POINT_OR_NUMBER)
|
||||
.kw(&["Pick points"])
|
||||
.default(format!("{:.0}", self.rotation.to_degrees()))
|
||||
.base_opt(self.first),
|
||||
RAsk::RotPick1 => Prompt::new("Specify first point", Accept::POINT),
|
||||
RAsk::RotPick2(b) => Prompt::new("Specify second point", Accept::POINT).base(b),
|
||||
};
|
||||
}
|
||||
match self.first {
|
||||
None => {
|
||||
let msg = if self.center { "Specify center point for rectangle" } else { "Specify first corner point" };
|
||||
Prompt::new(msg, Accept::POINT).kw(&["Chamfer", "CEnter", "Elevation", "Fillet", "Thickness", "Width"])
|
||||
}
|
||||
Some(p) => Prompt::new("Specify other corner point", Accept::POINT).kw(&["Area", "Dimensions", "Rotation"]).corner(p),
|
||||
}
|
||||
}
|
||||
/// Consumes one input: option keywords (`Center` only switches the first point's meaning
|
||||
/// to the centre and asks nothing), option values, and the points that size the rectangle.
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(a) = self.asking {
|
||||
let val = |i: &Input, default: f64| -> Result<f64> {
|
||||
match i {
|
||||
Input::Text(t) => number(t).ok_or_else(|| other("Requires a number.")),
|
||||
Input::Enter => Ok(default),
|
||||
_ => Err(other("Requires a number.")),
|
||||
}
|
||||
};
|
||||
self.asking = None;
|
||||
match a {
|
||||
RAsk::Chamfer1 => {
|
||||
self.chamfer.0 = val(&i, self.chamfer.0)?.max(0.0);
|
||||
self.chamfer.1 = self.chamfer.0;
|
||||
self.asking = Some(RAsk::Chamfer2);
|
||||
}
|
||||
RAsk::Chamfer2 => self.chamfer.1 = val(&i, self.chamfer.1)?.max(0.0),
|
||||
RAsk::Fillet => self.fillet = val(&i, self.fillet)?.max(0.0),
|
||||
RAsk::Width => self.width = val(&i, self.width)?.max(0.0),
|
||||
RAsk::Elevation => self.elevation = val(&i, self.elevation)?,
|
||||
RAsk::Thickness => self.thickness = val(&i, self.thickness)?,
|
||||
RAsk::Area => {
|
||||
let v = val(&i, if self.area > 0.0 { self.area } else { 100.0 })?;
|
||||
if v <= 0.0 {
|
||||
self.asking = Some(RAsk::Area);
|
||||
return Err(other("Value must be positive."));
|
||||
}
|
||||
self.area = v;
|
||||
self.asking = Some(RAsk::AreaBasis);
|
||||
}
|
||||
RAsk::AreaBasis => {
|
||||
let w = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('w'));
|
||||
self.asking = Some(if w { RAsk::AreaWidth } else { RAsk::AreaLength });
|
||||
}
|
||||
RAsk::AreaLength | RAsk::AreaWidth => {
|
||||
let v = val(&i, 10.0)?;
|
||||
if v <= 0.0 {
|
||||
self.asking = Some(a);
|
||||
return Err(other("Value must be positive."));
|
||||
}
|
||||
let (w, h) = if a == RAsk::AreaLength { (v, self.area / v) } else { (self.area / v, v) };
|
||||
return self.make(s, w, h);
|
||||
}
|
||||
RAsk::DimL => {
|
||||
let l = val(&i, self.dims.map(|d| d.0).unwrap_or(10.0))?.abs();
|
||||
self.dims = Some((l, self.dims.map(|d| d.1).unwrap_or(10.0)));
|
||||
self.asking = Some(RAsk::DimW);
|
||||
}
|
||||
RAsk::DimW => {
|
||||
let w = val(&i, self.dims.map(|d| d.1).unwrap_or(10.0))?.abs();
|
||||
self.dims = Some((self.dims.map(|d| d.0).unwrap_or(10.0), w));
|
||||
}
|
||||
RAsk::Rotation => match i {
|
||||
Input::Keyword(_) => self.asking = Some(RAsk::RotPick1),
|
||||
Input::Point(p) => self.rotation = self.first.map(|f| f.angle_to(p)).unwrap_or(0.0),
|
||||
Input::Text(t) => self.rotation = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?,
|
||||
_ => {}
|
||||
},
|
||||
RAsk::RotPick1 => {
|
||||
if let Input::Point(p) = i {
|
||||
self.asking = Some(RAsk::RotPick2(p));
|
||||
}
|
||||
}
|
||||
RAsk::RotPick2(b) => {
|
||||
if let Input::Point(p) = i {
|
||||
self.rotation = b.angle_to(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) => {
|
||||
// `Center` only switches the first point's meaning; it asks nothing.
|
||||
if k == "CEnter" {
|
||||
self.center = true;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.asking = Some(match k.as_str() {
|
||||
"Chamfer" => RAsk::Chamfer1,
|
||||
"Elevation" => RAsk::Elevation,
|
||||
"Fillet" => RAsk::Fillet,
|
||||
"Thickness" => RAsk::Thickness,
|
||||
"Width" => RAsk::Width,
|
||||
"Area" => RAsk::Area,
|
||||
"Dimensions" => RAsk::DimL,
|
||||
_ => RAsk::Rotation,
|
||||
});
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => match self.first {
|
||||
None => {
|
||||
self.first = Some(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some(_) => {
|
||||
let (w, h) = self.other_corner(p).ok_or_else(|| other("No first corner."))?;
|
||||
self.make(s, w, h)
|
||||
}
|
||||
},
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
/// The rubber-band rectangle for the cursor at `c`, mapped through the same size-and-origin
|
||||
/// rules as the committed entity so centre mode previews where it will land.
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.asking.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
match self.other_corner(c).and_then(|(w, h)| self.origin(w, h).map(|a| (a, w, h))) {
|
||||
Some((a, w, h)) if w.abs() > 1e-12 && h.abs() > 1e-12 => {
|
||||
vec![rect_kind(a, (w, h), self.rotation, self.fillet, self.chamfer, self.width, self.elevation)]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// REVCLOUD
|
||||
// =====================================================================================
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum CloudKind {
|
||||
Rect,
|
||||
Poly,
|
||||
Free,
|
||||
}
|
||||
|
||||
fn default_arc_len(s: &Session) -> f64 {
|
||||
let d = s.doc().ok();
|
||||
let metric = d.is_some_and(|d| d.header.i64("MEASUREMENT", 0) == 1);
|
||||
d.map(|d| d.header.f64("REVCLOUDARCLEN", if metric { 15.0 } else { 0.5 })).unwrap_or(0.5).max(1e-6)
|
||||
}
|
||||
|
||||
/// Bulged closed polyline vertices for a revision cloud around `boundary`.
|
||||
pub(crate) fn cloud_vertices(boundary: &[Vec2], arc_len: f64, per_edge: bool, reverse: bool) -> Option<Vec<PolyVertex>> {
|
||||
let mut pts: Vec<Vec2> = Vec::new();
|
||||
for p in boundary {
|
||||
if p.is_finite() && pts.last().is_none_or(|l| !l.near(*p, 1e-9)) {
|
||||
pts.push(*p);
|
||||
}
|
||||
}
|
||||
while pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.near(*b, 1e-9)) {
|
||||
pts.pop();
|
||||
}
|
||||
if pts.len() < 3 || shoelace(&pts).abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
if shoelace(&pts) < 0.0 {
|
||||
pts.reverse();
|
||||
}
|
||||
let n = pts.len();
|
||||
let perim: f64 = (0..n).filter_map(|i| Some(pts.get(i)?.dist(*pts.get((i + 1) % n)?))).sum();
|
||||
if !(arc_len.is_finite() && arc_len > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let arc_len = arc_len.max(perim / MAX_GEN as f64);
|
||||
let mut out: Vec<Vec2> = Vec::new();
|
||||
if per_edge {
|
||||
for i in 0..n {
|
||||
let (Some(a), Some(b)) = (pts.get(i).copied(), pts.get((i + 1) % n).copied()) else { continue };
|
||||
let k = ((a.dist(b) / arc_len).round() as usize).clamp(1, MAX_GEN);
|
||||
for j in 0..k {
|
||||
out.push(a.lerp(b, j as f64 / k as f64));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let chain = Chain {
|
||||
segs: (0..n).filter_map(|i| Some(cadcraft_geom::Segment::Line(Line::new(*pts.get(i)?, *pts.get((i + 1) % n)?)))).collect(),
|
||||
closed: true,
|
||||
};
|
||||
let k = ((perim / arc_len).round() as usize).clamp(3, MAX_GEN);
|
||||
for j in 0..k {
|
||||
out.extend(chain.at_length(perim * j as f64 / k as f64).map(|x| x.0));
|
||||
}
|
||||
}
|
||||
let b = if reverse { -0.5 } else { 0.5 };
|
||||
Some(out.into_iter().map(|p| PolyVertex::with_bulge(p, b)).collect())
|
||||
}
|
||||
|
||||
fn object_outline(s: &Session, h: Handle) -> Result<Vec<Vec2>> {
|
||||
let e = curves::entity(s, h)?;
|
||||
let tol = 1e-3;
|
||||
let pts = match &e.kind {
|
||||
EntityKind::LwPolyline(p) if p.closed => Polyline { vertices: p.vertices.clone(), closed: true }.tessellate(tol),
|
||||
EntityKind::Circle(c) => {
|
||||
let mut v = Vec::new();
|
||||
Circle::new(c.center.xy(), c.radius).tessellate(c.radius * 1e-3, &mut v);
|
||||
v
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let mut v = Vec::new();
|
||||
Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end }
|
||||
.tessellate(el.major.xy().len() * 1e-3, &mut v);
|
||||
v
|
||||
}
|
||||
EntityKind::Spline(sp) if sp.closed => sp.tessellate(tol),
|
||||
_ => return Err(other("Select a closed polyline, circle, ellipse or closed spline.")),
|
||||
};
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
fn cloud_entity(vs: Vec<PolyVertex>, calligraphy: bool, arc_len: f64) -> EntityKind {
|
||||
let vs = if calligraphy { vs.into_iter().map(|v| PolyVertex { start_width: 0.0, end_width: arc_len * 0.15, ..v }).collect() } else { vs };
|
||||
lwpoly(vs, true)
|
||||
}
|
||||
|
||||
pub(super) fn run_revcloud(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let arc_len = p.get("arcLength").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0).unwrap_or_else(|| default_arc_len(s));
|
||||
let reverse = bool_or(p, "reverse", false);
|
||||
let calligraphy = str_param(p, "style").is_some_and(|x| x.eq_ignore_ascii_case("calligraphy"));
|
||||
let (pts, per_edge, src) = if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) {
|
||||
(rect_vertices(a, b).into_iter().map(|v| v.p).collect(), true, None)
|
||||
} else if let Some(pts) = points_param(p, "points") {
|
||||
(pts, true, None)
|
||||
} else if let Some(pts) = points_param(p, "freehand") {
|
||||
(pts, false, None)
|
||||
} else if let Some(h) = curves::handle_param(p, "handle") {
|
||||
(object_outline(s, h)?, false, Some(h))
|
||||
} else {
|
||||
return Err(bad("revcloud", "give {p1, p2}, {points}, {freehand} or {handle}"));
|
||||
};
|
||||
if pts.len() > MAX_GEN {
|
||||
return Err(bad("revcloud", "too many points"));
|
||||
}
|
||||
let vs = cloud_vertices(&pts, arc_len, per_edge, reverse).ok_or_else(|| bad("revcloud", "the boundary needs an area"))?;
|
||||
let h = s.add_entity(cloud_entity(vs, calligraphy, arc_len))?;
|
||||
if let Some(src) = src {
|
||||
let common = curves::entity(s, src)?.common;
|
||||
let d = s.doc_mut()?;
|
||||
d.modify_entity(h, |e| e.common = common)?;
|
||||
d.remove_entity(src);
|
||||
}
|
||||
added(h)
|
||||
}
|
||||
|
||||
pub(super) struct RevcloudM {
|
||||
kind: CloudKind,
|
||||
pts: Vec<Vec2>,
|
||||
arc_len: f64,
|
||||
asking_arc: bool,
|
||||
object: bool,
|
||||
calligraphy: bool,
|
||||
asking_style: bool,
|
||||
}
|
||||
|
||||
impl RevcloudM {
|
||||
pub(super) fn new(s: &Session, kind: CloudKind) -> Self {
|
||||
RevcloudM { kind, pts: Vec::new(), arc_len: default_arc_len(s), asking_arc: false, object: false, calligraphy: false, asking_style: false }
|
||||
}
|
||||
fn boundary(&self, extra: Option<Vec2>) -> Vec<Vec2> {
|
||||
let mut pts = self.pts.clone();
|
||||
pts.extend(extra);
|
||||
if self.kind == CloudKind::Rect
|
||||
&& let (Some(a), Some(b)) = (pts.first().copied(), pts.get(1).copied())
|
||||
{
|
||||
return rect_vertices(a, b).into_iter().map(|v| v.p).collect();
|
||||
}
|
||||
pts
|
||||
}
|
||||
fn finish(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let b = self.boundary(None);
|
||||
match cloud_vertices(&b, self.arc_len, self.kind != CloudKind::Free, false) {
|
||||
Some(vs) => {
|
||||
s.add_entity(cloud_entity(vs, self.calligraphy, self.arc_len))?;
|
||||
s.echo("Revision cloud finished.");
|
||||
Ok(Step::Done)
|
||||
}
|
||||
None => Err(other("The revision cloud needs at least three points enclosing an area.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for RevcloudM {
|
||||
fn name(&self) -> &'static str {
|
||||
"REVCLOUD"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let t = match self.kind {
|
||||
CloudKind::Rect => "Rectangular",
|
||||
CloudKind::Poly => "Polygonal",
|
||||
CloudKind::Free => "Freehand",
|
||||
};
|
||||
s.echo(format!("Minimum arc length: {0:.4} Maximum arc length: {0:.4} Style: Normal Type: {t}", self.arc_len));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.asking_arc {
|
||||
return Prompt::new("Specify minimum length of arc", Accept::NUMBER).default(format!("{:.4}", self.arc_len));
|
||||
}
|
||||
if self.asking_style {
|
||||
return Prompt::new("Select arc style", curves::KW).kw(&["Normal", "Calligraphy"]).default("Normal");
|
||||
}
|
||||
if self.object {
|
||||
return Prompt::new("Select object", Accept::POINT);
|
||||
}
|
||||
let first_kw: &[&str] = &["Arc length", "Object", "Rectangular", "Polygonal", "Freehand", "Style"];
|
||||
match (self.kind, self.pts.len()) {
|
||||
(CloudKind::Rect, 0) => Prompt::new("Specify first corner point", Accept::POINT).kw(first_kw).default("Object"),
|
||||
(CloudKind::Rect, _) => {
|
||||
Prompt { corner: true, ..Prompt::new("Specify opposite corner", Accept::POINT).base_opt(self.pts.first().copied()) }
|
||||
}
|
||||
(CloudKind::Poly, 0) => Prompt::new("Specify start point", Accept::POINT).kw(first_kw).default("Object"),
|
||||
(CloudKind::Poly, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()),
|
||||
(CloudKind::Poly, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
|
||||
(CloudKind::Free, 0) => Prompt::new("Specify first point", Accept::POINT).kw(first_kw).default("Object"),
|
||||
(CloudKind::Free, _) => Prompt::new("Guide crosshairs along cloud path", Accept::POINT).base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.asking_arc {
|
||||
if let Input::Text(t) = &i {
|
||||
let v = number(t).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive value."))?;
|
||||
self.arc_len = v;
|
||||
s.doc_mut()?.header.set_f64("REVCLOUDARCLEN", v);
|
||||
}
|
||||
self.asking_arc = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.asking_style {
|
||||
if let Input::Keyword(k) | Input::Text(k) = &i {
|
||||
self.calligraphy = k.to_ascii_lowercase().starts_with('c');
|
||||
}
|
||||
self.asking_style = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.object {
|
||||
let Input::Point(p) = i else { return if i == Input::Enter { Ok(Step::Cancel) } else { Ok(Step::Continue) } };
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let pts = object_outline(s, h)?;
|
||||
let vs = cloud_vertices(&pts, self.arc_len, false, false).ok_or_else(|| other("The object has no area."))?;
|
||||
let nh = s.add_entity(cloud_entity(vs, self.calligraphy, self.arc_len))?;
|
||||
let common = curves::entity(s, h)?.common;
|
||||
let d = s.doc_mut()?;
|
||||
d.modify_entity(nh, |e| e.common = common)?;
|
||||
d.remove_entity(h);
|
||||
s.echo("Revision cloud finished.");
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) => {
|
||||
match k.as_str() {
|
||||
"Arc length" => self.asking_arc = true,
|
||||
"Object" => self.object = true,
|
||||
"Rectangular" => self.kind = CloudKind::Rect,
|
||||
"Polygonal" => self.kind = CloudKind::Poly,
|
||||
"Freehand" => self.kind = CloudKind::Free,
|
||||
"Style" => self.asking_style = true,
|
||||
"Undo" => {
|
||||
self.pts.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
self.pts.push(p);
|
||||
if self.kind == CloudKind::Rect && self.pts.len() == 2 {
|
||||
return self.finish(s);
|
||||
}
|
||||
if self.pts.len() > MAX_GEN {
|
||||
return self.finish(s);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter if self.pts.is_empty() => {
|
||||
self.object = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => self.finish(s),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.pts.is_empty() || self.object {
|
||||
return Vec::new();
|
||||
}
|
||||
let b = self.boundary(Some(c));
|
||||
match cloud_vertices(&b, self.arc_len, self.kind != CloudKind::Free, false) {
|
||||
Some(vs) if vs.len() < 2000 => vec![lwpoly(vs, true)],
|
||||
_ => {
|
||||
let mut pts = self.pts.clone();
|
||||
pts.push(c);
|
||||
vec![lwpoly(pts.into_iter().map(PolyVertex::new).collect(), false)]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn run_circle_variant(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = if let (Some(c), Some(d)) = (point_param(p, "center"), p.get("diameter").and_then(Value::as_f64)) {
|
||||
Circle::new(c, d / 2.0)
|
||||
} else if let (Some(a), Some(b)) = (point_param(p, "p1"), point_param(p, "p2")) {
|
||||
match point_param(p, "p3") {
|
||||
Some(c3) => Circle::from_3_points(a, b, c3).ok_or_else(|| bad("circle", "points are collinear"))?,
|
||||
None => Circle::from_2_points(a, b),
|
||||
}
|
||||
} else {
|
||||
return Err(bad("circle", "give {center, diameter}, {p1, p2} or {p1, p2, p3}"));
|
||||
};
|
||||
if !(c.radius > 1e-12 && c.radius.is_finite()) {
|
||||
return Err(bad("circle", "radius must be positive"));
|
||||
}
|
||||
let h = s.add_entity(circle(c.center, c.radius))?;
|
||||
s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius);
|
||||
added(h)
|
||||
}
|
||||
|
||||
// ---------------- tangent circles ----------------
|
||||
|
||||
fn tan_obj(s: &Session, h: Handle, pick: Vec2) -> Result<TanObj> {
|
||||
let e = curves::entity(s, h)?;
|
||||
TanObj::from_kind(&e.kind, pick).ok_or_else(|| other("Select a line, circle, arc or polyline segment."))
|
||||
}
|
||||
|
||||
fn pick_pair(cmd: &str, p: &Value, hk: &str, pk: &str) -> Result<(Handle, Vec2)> {
|
||||
let h = curves::handle_param(p, hk).ok_or_else(|| bad(cmd, format!("`{hk}` is required")))?;
|
||||
let q = point_req(cmd, p, pk)?;
|
||||
Ok((h, q))
|
||||
}
|
||||
|
||||
pub(super) fn run_circle_ttr(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (h1, p1) = pick_pair("circle.ttr", p, "h1", "p1")?;
|
||||
let (h2, p2) = pick_pair("circle.ttr", p, "h2", "p2")?;
|
||||
let r = f64_req("circle.ttr", p, "radius")?;
|
||||
let c = curves::ttr(tan_obj(s, h1, p1)?, p1, tan_obj(s, h2, p2)?, p2, r).ok_or_else(|| other("Circle does not exist."))?;
|
||||
let h = s.add_entity(circle(c.center, c.radius))?;
|
||||
s.doc_mut()?.header.set_f64("CIRCLERAD", c.radius);
|
||||
added(h)
|
||||
}
|
||||
|
||||
pub(super) fn run_circle_ttt(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (h1, p1) = pick_pair("circle.ttt", p, "h1", "p1")?;
|
||||
let (h2, p2) = pick_pair("circle.ttt", p, "h2", "p2")?;
|
||||
let (h3, p3) = pick_pair("circle.ttt", p, "h3", "p3")?;
|
||||
let objs = [(tan_obj(s, h1, p1)?, p1), (tan_obj(s, h2, p2)?, p2), (tan_obj(s, h3, p3)?, p3)];
|
||||
let c = curves::ttt(objs).ok_or_else(|| other("Circle does not exist."))?;
|
||||
added(s.add_entity(circle(c.center, c.radius))?)
|
||||
}
|
||||
|
||||
/// TTR (two picks + radius) or TTT (three picks). Also used by CIRCLE's `Ttr` option.
|
||||
pub(crate) struct TanCircleM {
|
||||
radius_mode: bool,
|
||||
picks: Vec<(TanObj, Vec2)>,
|
||||
}
|
||||
|
||||
impl TanCircleM {
|
||||
pub(crate) fn new(radius_mode: bool) -> Self {
|
||||
TanCircleM { radius_mode, picks: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for TanCircleM {
|
||||
fn name(&self) -> &'static str {
|
||||
"CIRCLE"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let n = self.picks.len();
|
||||
if self.radius_mode {
|
||||
match n {
|
||||
0 => Prompt::new("Specify point on object for first tangent of circle", Accept::POINT),
|
||||
1 => Prompt::new("Specify point on object for second tangent of circle", Accept::POINT),
|
||||
_ => {
|
||||
let r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0);
|
||||
let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)).distance();
|
||||
if r > 0.0 { p.default(format!("{r:.4}")) } else { p }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let m = ["Specify first point on circle: _tan to", "Specify second point on circle: _tan to", "Specify third point on circle: _tan to"];
|
||||
Prompt::new(m.get(n).copied().unwrap_or(""), Accept::POINT)
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let need = if self.radius_mode { 2 } else { 3 };
|
||||
if self.picks.len() < need {
|
||||
return match i {
|
||||
Input::Point(p) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let o = tan_obj(s, h, p)?;
|
||||
self.picks.push((o, p));
|
||||
if !self.radius_mode && self.picks.len() == 3 {
|
||||
let (Some(a), Some(b), Some(c)) = (self.picks.first(), self.picks.get(1), self.picks.get(2)) else {
|
||||
return Ok(Step::Cancel);
|
||||
};
|
||||
let c = curves::ttt([*a, *b, *c]).ok_or_else(|| {
|
||||
self.picks.clear();
|
||||
other("Circle does not exist.")
|
||||
})?;
|
||||
s.add_entity(circle(c.center, c.radius))?;
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Err(other("Select an object.")),
|
||||
};
|
||||
}
|
||||
let r = match i {
|
||||
Input::Point(p) => self.picks.last().map(|x| x.1.dist(p)).unwrap_or(0.0),
|
||||
Input::Text(t) => number(&t).ok_or_else(|| other("Requires numeric distance or second point."))?,
|
||||
Input::Enter => s.doc()?.header.f64("CIRCLERAD", 0.0),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if r.is_nan() || r <= 0.0 {
|
||||
return Err(other("Value must be positive and nonzero."));
|
||||
}
|
||||
let ((o1, p1), (o2, p2)) = match (self.picks.first(), self.picks.get(1)) {
|
||||
(Some(a), Some(b)) => (*a, *b),
|
||||
_ => return Ok(Step::Cancel),
|
||||
};
|
||||
match curves::ttr(o1, p1, o2, p2, r) {
|
||||
Some(c) => {
|
||||
s.add_entity(circle(c.center, c.radius))?;
|
||||
s.doc_mut()?.header.set_f64("CIRCLERAD", r);
|
||||
Ok(Step::Done)
|
||||
}
|
||||
None => {
|
||||
s.echo("Circle does not exist.");
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// WIPEOUT
|
||||
// =====================================================================================
|
||||
|
||||
fn set_frames(s: &mut Session, mode: &str) -> Result<i64> {
|
||||
let v = match mode.trim().to_ascii_lowercase().as_str() {
|
||||
"on" | "1" => 1,
|
||||
"off" | "0" => 0,
|
||||
"display" | "d" | "2" | "display but not plot" => 2,
|
||||
_ => return Err(other("Frames mode must be ON, OFF or Display.")),
|
||||
};
|
||||
s.doc_mut()?.header.set_i64("WIPEOUTFRAME", v);
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn wipeout_from_polyline(s: &mut Session, h: Handle, erase: bool) -> Result<Handle> {
|
||||
let e = curves::entity(s, h)?;
|
||||
let EntityKind::LwPolyline(p) = &e.kind else { return Err(other("Select a closed polyline.")) };
|
||||
if !p.closed || p.vertices.len() < 3 {
|
||||
return Err(other("The polyline must be closed with at least three vertices."));
|
||||
}
|
||||
if p.vertices.iter().any(|v| v.bulge.abs() > 1e-12) {
|
||||
return Err(other("Polylines with arc segments cannot be used for wipeouts."));
|
||||
}
|
||||
let nh = s.add_entity(EntityKind::Wipeout(Wipeout { boundary: p.vertices.iter().map(|v| v.p).collect() }))?;
|
||||
if erase {
|
||||
s.doc_mut()?.remove_entity(h);
|
||||
}
|
||||
Ok(nh)
|
||||
}
|
||||
|
||||
pub(super) fn run_wipeout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(f) = str_param(p, "frames") {
|
||||
let v = set_frames(s, f)?;
|
||||
return Ok(json!({ "WIPEOUTFRAME": v }));
|
||||
}
|
||||
if let Some(h) = curves::handle_param(p, "handle") {
|
||||
return added(wipeout_from_polyline(s, h, bool_or(p, "erase", false))?);
|
||||
}
|
||||
let pts = points_param(p, "points").ok_or_else(|| bad("wipeout", "`points` (3 or more) or `handle` is required"))?;
|
||||
if pts.len() < 3 || pts.len() > MAX_GEN || shoelace(&pts).abs() < 1e-12 {
|
||||
return Err(bad("wipeout", "need 3+ points enclosing an area"));
|
||||
}
|
||||
added(s.add_entity(EntityKind::Wipeout(Wipeout { boundary: pts }))?)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct WipeoutM {
|
||||
pts: Vec<Vec2>,
|
||||
phase: u8, // 0 points, 1 select polyline, 2 erase?, 3 frames
|
||||
poly: Option<Handle>,
|
||||
}
|
||||
|
||||
impl Interactive for WipeoutM {
|
||||
fn name(&self) -> &'static str {
|
||||
"WIPEOUT"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (self.phase, self.pts.len()) {
|
||||
(1, _) => Prompt::new("Select a closed polyline", Accept::POINT),
|
||||
(2, _) => Prompt::new("Erase polyline?", curves::KW).kw(&["Yes", "No"]).default("No"),
|
||||
(3, _) => Prompt::new("Enter mode", curves::KW).kw(&["ON", "OFF", "Display but not plot"]).default("ON"),
|
||||
(_, 0) => Prompt::new("Specify first point", Accept::POINT).kw(&["Frames", "Polyline"]).default("Polyline"),
|
||||
(_, 1) => Prompt::new("Specify next point", Accept::POINT).base_opt(self.pts.last().copied()),
|
||||
(_, _) => Prompt::new("Specify next point", Accept::POINT).kw(&["Undo"]).base_opt(self.pts.last().copied()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.phase, i) {
|
||||
(1, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let e = curves::entity(s, h)?;
|
||||
if !matches!(&e.kind, EntityKind::LwPolyline(pl) if pl.closed) {
|
||||
return Err(other("Select a closed polyline."));
|
||||
}
|
||||
self.poly = Some(h);
|
||||
self.phase = 2;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(2, inp @ (Input::Keyword(_) | Input::Text(_) | Input::Enter)) => {
|
||||
let erase = matches!(&inp, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('y'));
|
||||
let h = self.poly.ok_or_else(|| other("No polyline."))?;
|
||||
wipeout_from_polyline(s, h, erase)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(3, inp @ (Input::Keyword(_) | Input::Text(_) | Input::Enter)) => {
|
||||
let m = match &inp {
|
||||
Input::Keyword(k) | Input::Text(k) => k.clone(),
|
||||
_ => "on".into(),
|
||||
};
|
||||
let m = if m.to_ascii_lowercase().starts_with('d') { "display".to_string() } else { m };
|
||||
set_frames(s, &m)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(0, Input::Keyword(k)) => {
|
||||
match k.as_str() {
|
||||
"Frames" => self.phase = 3,
|
||||
"Polyline" => self.phase = 1,
|
||||
"Undo" => {
|
||||
self.pts.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(0, Input::Point(p)) => {
|
||||
if self.pts.len() < MAX_GEN {
|
||||
self.pts.push(p);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(0, Input::Enter) if self.pts.is_empty() => {
|
||||
self.phase = 1;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(0, Input::Enter) => {
|
||||
if self.pts.len() < 3 || shoelace(&self.pts).abs() < 1e-12 {
|
||||
return Err(other("A wipeout needs three or more points enclosing an area."));
|
||||
}
|
||||
s.add_entity(EntityKind::Wipeout(Wipeout { boundary: self.pts.clone() }))?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.phase != 0 || self.pts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut pts = self.pts.clone();
|
||||
pts.push(c);
|
||||
vec![lwpoly(pts.into_iter().map(PolyVertex::new).collect(), true)]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// XLINE with Hor/Ver/Ang/Bisect/Offset
|
||||
// =====================================================================================
|
||||
|
||||
fn add_xline(s: &mut Session, base: Vec2, dir: Vec2) -> Result<Handle> {
|
||||
let d = dir.normalized();
|
||||
if d == Vec2::ZERO || !base.is_finite() {
|
||||
return Err(other("Invalid direction."));
|
||||
}
|
||||
s.add_entity(EntityKind::XLine(RayLine { base: v3(base), dir: v3(d) }))
|
||||
}
|
||||
|
||||
/// A line-like object for XLINE Offset/Reference: (base, direction). For a polyline, the
|
||||
/// segment nearest `pick` (the first without one), which must be straight.
|
||||
fn linear_of(s: &Session, h: Handle, pick: Option<Vec2>) -> Result<(Vec2, Vec2)> {
|
||||
let e = curves::entity(s, h)?;
|
||||
match &e.kind {
|
||||
EntityKind::Line(l) => Ok((l.a.xy(), (l.b.xy() - l.a.xy()).normalized())),
|
||||
EntityKind::XLine(r) | EntityKind::Ray(r) => Ok((r.base.xy(), r.dir.xy().normalized())),
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let segs = Polyline { vertices: p.vertices.clone(), closed: p.closed }.segments();
|
||||
let seg = match pick {
|
||||
Some(q) => segs.iter().min_by(|a, b| a.closest(q).dist(q).total_cmp(&b.closest(q).dist(q))),
|
||||
None => segs.first(),
|
||||
};
|
||||
match seg {
|
||||
Some(cadcraft_geom::Segment::Line(l)) => Ok((l.a, l.dir())),
|
||||
_ => Err(other("Select a line object.")),
|
||||
}
|
||||
}
|
||||
_ => Err(other("Select a line object.")),
|
||||
}
|
||||
}
|
||||
|
||||
fn bisect_dir(v: Vec2, a: Vec2, b: Vec2) -> Option<Vec2> {
|
||||
let u1 = (a - v).normalized();
|
||||
let u2 = (b - v).normalized();
|
||||
if u1 == Vec2::ZERO || u2 == Vec2::ZERO {
|
||||
return None;
|
||||
}
|
||||
let s = u1 + u2;
|
||||
if s.len() < 1e-12 { Some(u1.perp()) } else { Some(s.normalized()) }
|
||||
}
|
||||
|
||||
fn offset_xline(base: Vec2, dir: Vec2, dist: f64, side: Vec2) -> (Vec2, Vec2) {
|
||||
let n = dir.perp();
|
||||
let sgn = if n.dot(side - base) >= 0.0 { 1.0 } else { -1.0 };
|
||||
(base + n * (dist * sgn), dir)
|
||||
}
|
||||
|
||||
pub(crate) fn run_xline2(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let (Some(v), Some(a), Some(b)) = (point_param(p, "vertex"), point_param(p, "start"), point_param(p, "end")) {
|
||||
let d = bisect_dir(v, a, b).ok_or_else(|| bad("xline", "degenerate angle"))?;
|
||||
return added(add_xline(s, v, d)?);
|
||||
}
|
||||
if let Some(h) = curves::handle_param(p, "handle") {
|
||||
let (base, dir) = linear_of(s, h, None)?;
|
||||
let (nb, nd) = if let Some(t) = point_param(p, "through") {
|
||||
(t, dir)
|
||||
} else {
|
||||
let dist = f64_req("xline", p, "distance")?;
|
||||
let side = point_req("xline", p, "side")?;
|
||||
offset_xline(base, dir, dist, side)
|
||||
};
|
||||
return added(add_xline(s, nb, nd)?);
|
||||
}
|
||||
let b = point_param(p, "base").or_else(|| point_param(p, "through")).ok_or_else(|| bad("xline", "`base` is required"))?;
|
||||
let dir = if bool_or(p, "hor", false) {
|
||||
Vec2::X
|
||||
} else if bool_or(p, "ver", false) {
|
||||
Vec2::Y
|
||||
} else if let (Some(t), Some(_)) = (point_param(p, "through"), point_param(p, "base")) {
|
||||
t - b
|
||||
} else {
|
||||
Vec2::from_angle(f64_req("xline", p, "angle")?.to_radians())
|
||||
};
|
||||
if dir.len() < 1e-12 {
|
||||
return Err(bad("xline", "through point equals base"));
|
||||
}
|
||||
added(add_xline(s, b, dir)?)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum XMode {
|
||||
#[default]
|
||||
Two,
|
||||
Fixed(f64),
|
||||
AngAsk,
|
||||
RefSelect,
|
||||
RefAngle(f64),
|
||||
Bisect,
|
||||
OffDist,
|
||||
OffSelect,
|
||||
OffSide,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct XlineM2 {
|
||||
mode: XMode,
|
||||
base: Option<Vec2>,
|
||||
bis: Vec<Vec2>,
|
||||
offset: Option<f64>, // None = Through
|
||||
line: Option<(Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
impl Interactive for XlineM2 {
|
||||
fn name(&self) -> &'static str {
|
||||
"XLINE"
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.mode {
|
||||
XMode::Two => match self.base {
|
||||
None => Prompt::new("Specify a point", Accept::POINT).kw(&["Hor", "Ver", "Ang", "Bisect", "Offset"]),
|
||||
Some(b) => Prompt::new("Specify through point", Accept::POINT).base(b),
|
||||
},
|
||||
XMode::Fixed(_) => Prompt::new("Specify through point", Accept::POINT),
|
||||
XMode::AngAsk => Prompt::new("Enter angle of xline (0) or", Accept::NUMBER).kw(&["Reference"]),
|
||||
XMode::RefSelect => Prompt::new("Select a line object", Accept::POINT),
|
||||
XMode::RefAngle(_) => Prompt::new("Enter angle of xline", Accept::NUMBER).default("0"),
|
||||
XMode::Bisect => match self.bis.len() {
|
||||
0 => Prompt::new("Specify angle vertex point", Accept::POINT),
|
||||
1 => Prompt::new("Specify angle start point", Accept::POINT).base_opt(self.bis.first().copied()),
|
||||
_ => Prompt::new("Specify angle end point", Accept::POINT).base_opt(self.bis.first().copied()),
|
||||
},
|
||||
XMode::OffDist => {
|
||||
let d = s.doc().map(|d| d.header.f64("OFFSETDIST", 1.0)).unwrap_or(1.0);
|
||||
Prompt::new("Specify offset distance or", Accept::NUMBER).kw(&["Through"]).default(if d < 0.0 {
|
||||
"Through".into()
|
||||
} else {
|
||||
format!("{d:.4}")
|
||||
})
|
||||
}
|
||||
XMode::OffSelect => Prompt::new("Select a line object", Accept::POINT),
|
||||
XMode::OffSide => Prompt::new(if self.offset.is_some() { "Specify side to offset" } else { "Specify through point" }, Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.mode, i) {
|
||||
(_, Input::Enter) if matches!(self.mode, XMode::OffDist) => {
|
||||
let d = s.doc()?.header.f64("OFFSETDIST", 1.0);
|
||||
self.offset = (d >= 0.0).then_some(d);
|
||||
self.mode = XMode::OffSelect;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Done),
|
||||
(XMode::Two, Input::Keyword(k)) => {
|
||||
self.mode = match k.as_str() {
|
||||
"Hor" => XMode::Fixed(0.0),
|
||||
"Ver" => XMode::Fixed(std::f64::consts::FRAC_PI_2),
|
||||
"Ang" => XMode::AngAsk,
|
||||
"Bisect" => XMode::Bisect,
|
||||
_ => XMode::OffDist,
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::Two, Input::Point(p)) => {
|
||||
match self.base {
|
||||
None => self.base = Some(p),
|
||||
Some(b) => {
|
||||
if !b.near(p, 1e-12) {
|
||||
add_xline(s, b, p - b)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::Fixed(a), Input::Point(p)) => {
|
||||
add_xline(s, p, Vec2::from_angle(a))?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::AngAsk, Input::Keyword(_)) => {
|
||||
self.mode = XMode::RefSelect;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::AngAsk, Input::Text(t)) => {
|
||||
let a = s.angle_settings().direction(&t).ok_or_else(|| other("Requires a valid angle."))?;
|
||||
self.mode = XMode::Fixed(a);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::RefSelect, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let (_, d) = linear_of(s, h, Some(p))?;
|
||||
self.mode = XMode::RefAngle(d.angle());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::RefAngle(base), Input::Text(t)) => {
|
||||
let a = s.angle_settings().rotation(&t).ok_or_else(|| other("Requires a valid angle."))?;
|
||||
self.mode = XMode::Fixed(base + a);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::Bisect, Input::Point(p)) => {
|
||||
if self.bis.len() < 2 {
|
||||
self.bis.push(p);
|
||||
} else if let (Some(v), Some(a)) = (self.bis.first().copied(), self.bis.get(1).copied()) {
|
||||
let d = bisect_dir(v, a, p).ok_or_else(|| other("Invalid angle."))?;
|
||||
add_xline(s, v, d)?;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::OffDist, Input::Keyword(_)) => {
|
||||
self.offset = None;
|
||||
s.doc_mut()?.header.set_f64("OFFSETDIST", -1.0);
|
||||
self.mode = XMode::OffSelect;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::OffDist, Input::Text(t)) => {
|
||||
let d = number(&t).filter(|d| *d >= 0.0).ok_or_else(|| other("Requires a non-negative distance."))?;
|
||||
self.offset = Some(d);
|
||||
s.doc_mut()?.header.set_f64("OFFSETDIST", d);
|
||||
self.mode = XMode::OffSelect;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::OffSelect, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
self.line = Some(linear_of(s, h, Some(p))?);
|
||||
self.mode = XMode::OffSide;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(XMode::OffSide, Input::Point(p)) => {
|
||||
let (b, d) = self.line.ok_or_else(|| other("No line selected."))?;
|
||||
let (nb, nd) = match self.offset {
|
||||
Some(dist) => offset_xline(b, d, dist, p),
|
||||
None => (p, d),
|
||||
};
|
||||
add_xline(s, nb, nd)?;
|
||||
self.mode = XMode::OffSelect;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Err(other("Invalid input.")),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let xl = |b: Vec2, d: Vec2| {
|
||||
let d = d.normalized();
|
||||
(d != Vec2::ZERO).then(|| EntityKind::XLine(RayLine { base: v3(b), dir: v3(d) }))
|
||||
};
|
||||
match self.mode {
|
||||
XMode::Two => self.base.and_then(|b| xl(b, c - b)).into_iter().collect(),
|
||||
XMode::Fixed(a) => xl(c, Vec2::from_angle(a)).into_iter().collect(),
|
||||
XMode::Bisect => match self.bis.as_slice() {
|
||||
[v, a] => bisect_dir(*v, *a, c).and_then(|d| xl(*v, d)).into_iter().collect(),
|
||||
_ => Vec::new(),
|
||||
},
|
||||
XMode::OffSide => self
|
||||
.line
|
||||
.and_then(|(b, d)| {
|
||||
let (nb, nd) = match self.offset {
|
||||
Some(dist) => offset_xline(b, d, dist, c),
|
||||
None => (c, d),
|
||||
};
|
||||
xl(nb, nd)
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -432,5 +432,4 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "usage_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -884,303 +884,4 @@ fn plot_window(s: &Session, p: &Value, cmd: &str, space: &Space) -> Result<Optio
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn session_with_model() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("line 0,0 100,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("circle 50,25 20").unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn viewports(s: &Session, layout: &str) -> Vec<(Handle, Viewport)> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.layout(layout)
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some((e.handle, v.clone())) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layout_gets_viewport_zoomed_to_extents() {
|
||||
let mut s = session_with_model();
|
||||
let r = s.execute("layout.new", &json!({})).unwrap();
|
||||
assert_eq!(r["name"], "Layout3");
|
||||
let vps = viewports(&s, "Layout3");
|
||||
assert_eq!(vps.len(), 1);
|
||||
let (_, v) = &vps[0];
|
||||
let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout3").unwrap();
|
||||
assert!((v.width - sheet.printable.width()).abs() < 1e-9 && (v.height - sheet.printable.height()).abs() < 1e-9);
|
||||
assert!(v.id >= 2);
|
||||
// Model extents (0,0)..(100,45) are centred and fully visible.
|
||||
assert!(v.view_center.near(Vec2::new(50.0, 22.5), 1e-6));
|
||||
let model_w = v.view_height * v.width / v.height;
|
||||
assert!(model_w >= 100.0 && v.view_height >= 45.0);
|
||||
// The render shows model geometry inside the viewport only.
|
||||
let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout3".into()), &cadcraft_render::Options::default());
|
||||
let rect = Bounds2::new(v.center.xy() - Vec2::new(v.width, v.height) / 2.0, v.center.xy() + Vec2::new(v.width, v.height) / 2.0).expand(1e-9);
|
||||
let content: Vec<_> = list.prims.iter().filter(|p| p.handle == cadcraft_render::VIEWPORT_CONTENT).collect();
|
||||
assert!(content.len() >= 2);
|
||||
assert!(content.iter().all(|p| list.points(p).iter().all(|q| rect.contains(*q))));
|
||||
assert!(list.sheet.is_some());
|
||||
// Undo removes the layout.
|
||||
s.undo().unwrap();
|
||||
assert!(s.doc().unwrap().layout("Layout3").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_keeps_the_active_layout_whatever_its_case() {
|
||||
let mut s = session_with_model();
|
||||
s.doc_mut().unwrap().active_layout = Some("layout1".into());
|
||||
s.execute("layout.rename", &json!({"from": "Layout1", "to": "Sheet A"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().active_layout.as_deref(), Some("Sheet A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_copy_delete() {
|
||||
let mut s = session_with_model();
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
s.execute("layout.rename", &json!({"to": "Sheet A"})).unwrap();
|
||||
assert_eq!(s.space(), Space::Paper("Sheet A".into()));
|
||||
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "Layout2"})).is_err(), "duplicate");
|
||||
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "a/b"})).is_err(), "bad char");
|
||||
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "model"})).is_err(), "reserved");
|
||||
s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap();
|
||||
let r = s.execute("layout.copy", &json!({"from": "Sheet A"})).unwrap();
|
||||
assert_eq!(r["name"], "Sheet A (2)");
|
||||
let a = viewports(&s, "Sheet A");
|
||||
let b = viewports(&s, "Sheet A (2)");
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(b.len(), 1);
|
||||
assert_ne!(a[0].0, b[0].0, "copied entities get new handles");
|
||||
s.execute("layout.delete", &json!({"name": "sheet a"})).unwrap();
|
||||
assert_eq!(s.space(), Space::Model, "deleting the current layout returns to Model");
|
||||
s.execute("layout", &json!({"option": "delete", "name": "Layout2"})).unwrap();
|
||||
assert!(s.execute("layout.delete", &json!({"name": "Sheet A (2)"})).is_err(), "last layout stays");
|
||||
let l = s.execute("layout", &json!({"option": "list"})).unwrap();
|
||||
assert_eq!(l.as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("layout.delete", &json!({"name": "Model"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layout_from_the_menu_asks_for_a_name() {
|
||||
let mut s = session_with_model();
|
||||
s.start("layout.new").unwrap();
|
||||
assert_eq!(s.current_prompt().map(|p| p.display()).as_deref(), Some("Enter name of new layout <Layout3>:"));
|
||||
s.cmdline("Layout1").unwrap();
|
||||
assert!(s.running.is_some(), "a taken name asks again");
|
||||
s.cmdline("Sheet A").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
s.start("layout.new").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.layout("Sheet A").is_some() && d.layout("Layout3").is_some(), "Enter takes the default name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mview_arrangements_and_interactive() {
|
||||
let mut s = session_with_model();
|
||||
assert!(s.execute("mview", &json!({"p1": [0, 0], "p2": [1, 1]})).is_err(), "not in model space");
|
||||
s.execute("mview", &json!({"layout": "Layout2", "count": 4})).unwrap();
|
||||
assert_eq!(viewports(&s, "Layout2").len(), 4);
|
||||
s.execute("vports.3", &json!({"layout": "Layout2", "p1": [0, 0], "p2": [9, 6]})).unwrap();
|
||||
let v = viewports(&s, "Layout2");
|
||||
assert_eq!(v.len(), 7);
|
||||
let total: f64 = v[4..].iter().map(|(_, v)| v.width * v.height).sum();
|
||||
assert!((total - 54.0).abs() < 1e-9, "three viewports tile the window");
|
||||
// Interactive MVIEW: two corners.
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
s.cmdline("mview").unwrap();
|
||||
s.cmdline("1,1").unwrap();
|
||||
s.cmdline("6,4").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let v = viewports(&s, "Layout1");
|
||||
assert_eq!(v.len(), 1);
|
||||
assert!((v[0].1.width - 5.0).abs() < 1e-9 && (v[0].1.height - 3.0).abs() < 1e-9);
|
||||
// MVIEW 2 → arrangement (Enter: Vertical) → Fit (Enter).
|
||||
s.cmdline("mview").unwrap();
|
||||
s.cmdline("2").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(viewports(&s, "Layout1").len(), 3);
|
||||
// One undo step per MVIEW.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(viewports(&s, "Layout1").len(), 1);
|
||||
// In model space the interactive command ends at once.
|
||||
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
|
||||
s.cmdline("vports.4").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_scale_lock_and_layers() {
|
||||
let mut s = session_with_model();
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
let r = s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap();
|
||||
let h = r["viewports"][0].as_str().unwrap().to_string();
|
||||
let out = s.execute("viewport.set", &json!({"handle": h, "scale": "1:20", "center": [50, 20]})).unwrap();
|
||||
assert!((out[0]["viewHeight"].as_f64().unwrap() - 60.0).abs() < 1e-9);
|
||||
assert!((out[0]["scale"].as_f64().unwrap() - 0.05).abs() < 1e-12);
|
||||
s.execute("viewport.set", &json!({"handle": h, "locked": true})).unwrap();
|
||||
assert!(s.execute("viewport.set", &json!({"handle": h, "scale": 0.1})).is_err(), "locked");
|
||||
s.execute("viewport.set", &json!({"handle": h, "scale": 0.1, "locked": false})).unwrap();
|
||||
s.execute("vplayer", &json!({"handle": h, "freeze": ["0", "Walls"]})).unwrap();
|
||||
s.execute("vplayer", &json!({"handle": h, "thaw": "walls"})).unwrap();
|
||||
let v = viewports(&s, "Layout1");
|
||||
assert_eq!(v[0].1.frozen_layers, vec!["0".to_string()]);
|
||||
s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": "red"}})).unwrap();
|
||||
assert!(s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": "bylayer"}})).is_err());
|
||||
assert!(s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": [1]}})).is_err());
|
||||
let vv = viewports(&s, "Layout1");
|
||||
assert_eq!(vv[0].1.layer_colors, vec![("Walls".to_string(), cadcraft_color::Color::Index(1))]);
|
||||
s.execute("vplayer", &json!({"handle": h, "colors": {"walls": null}})).unwrap();
|
||||
assert!(viewports(&s, "Layout1")[0].1.layer_colors.is_empty());
|
||||
assert!((v[0].1.view_height - 30.0).abs() < 1e-9);
|
||||
// Layer 0 frozen in the viewport: nothing of the model shows.
|
||||
let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout1".into()), &cadcraft_render::Options::default());
|
||||
assert!(list.prims.iter().all(|p| p.handle != cadcraft_render::VIEWPORT_CONTENT));
|
||||
for bad_p in [
|
||||
json!({"handle": h, "scale": "0:0"}),
|
||||
json!({"handle": h, "scale": -1}),
|
||||
json!({"handle": h, "viewHeight": 0}),
|
||||
json!({"handle": "FFFFFF"}),
|
||||
] {
|
||||
assert!(s.execute("viewport.set", &bad_p).is_err(), "{bad_p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagesetup_paper_table() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "A3", "landscape": true, "margins": [5, 5, 5, 5]})).unwrap();
|
||||
assert_eq!(r["page"]["widthMm"], 297.0);
|
||||
let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout1").unwrap();
|
||||
// Imperial drawing: paper units are inches.
|
||||
assert!((sheet.size.x - 420.0 / 25.4).abs() < 1e-9 && (sheet.size.y - 297.0 / 25.4).abs() < 1e-9);
|
||||
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "Z12"})).is_err());
|
||||
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "margins": [1, 2]})).is_err());
|
||||
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "width": 1e308, "height": 5})).is_err());
|
||||
assert!(s.execute("pagesetup", &json!({"paper": "A4"})).is_err(), "model tab needs a layout");
|
||||
for area in ["extents", "display", "window", "limits"] {
|
||||
let e = s.execute("pagesetup", &json!({"layout": "Layout1", "plotArea": area})).unwrap_err();
|
||||
assert!(e.to_string().contains("not available yet"), "{area}: {e}");
|
||||
}
|
||||
let r = s.execute("pagesetup", &json!({"layout": "Layout1", "plotArea": "Layout"})).unwrap();
|
||||
assert_eq!(r["page"]["plotArea"], "layout");
|
||||
let report = s.execute("pagesetup", &Value::Null).unwrap();
|
||||
assert!(report["papers"].as_array().unwrap().len() >= 10);
|
||||
s.execute("pagesetup", &json!({"layout": "Layout2", "width": 500, "height": 300})).unwrap();
|
||||
let p = &s.doc().unwrap().layout("Layout2").unwrap().page;
|
||||
assert_eq!((p.width_mm, p.height_mm), (300.0, 500.0));
|
||||
}
|
||||
|
||||
fn fake_plot(d: &Drawing, space: &Space, opts: &Value) -> std::result::Result<Vec<u8>, String> {
|
||||
let _ = d;
|
||||
Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plot_goes_through_the_hook() {
|
||||
super::super::file::set_io(super::super::file::IoHooks {
|
||||
read: |_, _| Err("no reader in tests".into()),
|
||||
write: |_, _| Err("no writer in tests".into()),
|
||||
plot: Some(fake_plot),
|
||||
});
|
||||
let mut s = session_with_model();
|
||||
if io().and_then(|h| h.plot).is_none() {
|
||||
return; // another test installed hooks without a plotter first
|
||||
}
|
||||
let r = s.execute("plot", &json!({"layout": "Layout1", "paper": "A4"})).unwrap();
|
||||
let data = super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap();
|
||||
let text = String::from_utf8(data).unwrap();
|
||||
assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4"));
|
||||
let r = s.execute("exportpdf", &json!({})).unwrap();
|
||||
assert_eq!(r["layout"], "Model");
|
||||
let baseline = String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(!baseline.contains("\"center\"") && !baseline.contains("\"offset\""), "omitted options remain omitted: {baseline}");
|
||||
assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err());
|
||||
// Plot area and a ratio scale reach the plotter as a model-space window and a number.
|
||||
let opts = |r: Value| String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let p = json!({"layout": "Model", "plotArea": "window", "window": [[10, 0], [0, 5]], "fit": false, "scale": "1:2"});
|
||||
let text = opts(s.execute("plot", &p).unwrap());
|
||||
assert!(text.contains(r#""window":[[0.0,0.0],[10.0,5.0]]"#) && text.contains(r#""scale":0.5"#), "{text}");
|
||||
let text = opts(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": [12.5, -7.0]})).unwrap());
|
||||
assert!(text.contains(r#""center":false"#) && text.contains(r#""offset":[12.5,-7.0]"#), "{text}");
|
||||
for offset in [json!([1]), json!([1, 2, 3]), json!([f64::NAN, 0]), json!([100_001, 0])] {
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": offset})).is_err());
|
||||
}
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "output": "svg", "center": false, "offset": [0, 0]})).is_err());
|
||||
assert!(opts(s.execute("plot", &json!({"layout": "Model", "plotArea": "limits"})).unwrap()).contains(r#""window":[[0.0,0.0],"#));
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners");
|
||||
assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet");
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "scale": -1})).is_err());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-plot-test-{}.pdf", std::process::id()));
|
||||
let ps = path.to_string_lossy().to_string();
|
||||
let r = s.execute("plot", &json!({"path": ps})).unwrap();
|
||||
assert!(r["bytes"].as_u64().unwrap() > 0);
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_layout_params() {
|
||||
let mut s = session_with_model();
|
||||
let long = "x".repeat(300);
|
||||
for (c, p) in [
|
||||
("layout.new", json!({"name": ""})),
|
||||
("layout.new", json!({"name": long})),
|
||||
("layout.new", json!({"name": "a\u{0}b"})),
|
||||
("layout.new", json!({"name": "Layout1"})),
|
||||
("mview", json!({"layout": "Layout1", "p1": [1e308, -1e308], "p2": [-1e308, 1e308]})),
|
||||
("mview", json!({"layout": "Layout1", "p1": [1, 1], "p2": [1, 1]})),
|
||||
("mview", json!({"layout": "Layout1", "count": 99})),
|
||||
("mview", json!({"layout": "Layout1", "count": -1})),
|
||||
("layout.rename", json!({"from": "Layout1"})),
|
||||
("layout.copy", json!({"from": "nope"})),
|
||||
("viewport.set", json!({"handles": ["zz"]})),
|
||||
] {
|
||||
assert!(s.execute(c, &p).is_err(), "{c} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mspace_edits_model_through_viewport() {
|
||||
let mut s = session_with_model();
|
||||
let before = s.doc().unwrap().model.len();
|
||||
s.execute("layout.new", &json!({"name": "L"})).unwrap();
|
||||
s.execute("layout.set", &json!({"name": "L"})).unwrap();
|
||||
assert!(s.execute("mspace", &json!({"at": [-1000, -1000]})).is_err());
|
||||
let vps = viewports(&s, "L");
|
||||
let (h, vp) = vps.iter().find(|(_, v)| v.id != 1).cloned().unwrap();
|
||||
s.execute("mspace", &json!({"at": [vp.center.x, vp.center.y]})).unwrap();
|
||||
assert_eq!(s.space(), Space::Model);
|
||||
assert_eq!(s.layout_space(), Space::Paper("L".into()));
|
||||
// The screen view in MSPACE maps the viewport centre to its model view centre.
|
||||
let st = s.state().unwrap();
|
||||
let (pv, v) = (st.paper_view(), st.view());
|
||||
let k = vp.view_height / vp.height;
|
||||
assert!((v.height - pv.height * k).abs() < 1e-9);
|
||||
// New geometry goes to model space.
|
||||
s.cmdline("circle 10,10 3").unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), before + 1);
|
||||
// Zooming changes the viewport's model view, not the sheet.
|
||||
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 });
|
||||
let vp2 = viewports(&s, "L").into_iter().find(|(x, _)| *x == h).unwrap().1;
|
||||
assert!((vp2.view_height - vp.view_height / 2.0).abs() < 1e-9);
|
||||
assert_eq!(s.state().unwrap().paper_view(), pv);
|
||||
s.execute("pspace", &json!({})).unwrap();
|
||||
assert_eq!(s.space(), Space::Paper("L".into()));
|
||||
s.execute("mspace", &json!({})).unwrap();
|
||||
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
|
||||
assert!(s.state().unwrap().mspace.is_none());
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
use super::*;
|
||||
|
||||
fn session_with_model() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.cmdline("line 0,0 100,0").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("circle 50,25 20").unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn viewports(s: &Session, layout: &str) -> Vec<(Handle, Viewport)> {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.layout(layout)
|
||||
.unwrap()
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|e| if let EntityKind::Viewport(v) = &e.kind { Some((e.handle, v.clone())) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layout_gets_viewport_zoomed_to_extents() {
|
||||
let mut s = session_with_model();
|
||||
let r = s.execute("layout.new", &json!({})).unwrap();
|
||||
assert_eq!(r["name"], "Layout3");
|
||||
let vps = viewports(&s, "Layout3");
|
||||
assert_eq!(vps.len(), 1);
|
||||
let (_, v) = &vps[0];
|
||||
let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout3").unwrap();
|
||||
assert!((v.width - sheet.printable.width()).abs() < 1e-9 && (v.height - sheet.printable.height()).abs() < 1e-9);
|
||||
assert!(v.id >= 2);
|
||||
// Model extents (0,0)..(100,45) are centred and fully visible.
|
||||
assert!(v.view_center.near(Vec2::new(50.0, 22.5), 1e-6));
|
||||
let model_w = v.view_height * v.width / v.height;
|
||||
assert!(model_w >= 100.0 && v.view_height >= 45.0);
|
||||
// The render shows model geometry inside the viewport only.
|
||||
let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout3".into()), &cadcraft_render::Options::default());
|
||||
let rect = Bounds2::new(v.center.xy() - Vec2::new(v.width, v.height) / 2.0, v.center.xy() + Vec2::new(v.width, v.height) / 2.0).expand(1e-9);
|
||||
let content: Vec<_> = list.prims.iter().filter(|p| p.handle == cadcraft_render::VIEWPORT_CONTENT).collect();
|
||||
assert!(content.len() >= 2);
|
||||
assert!(content.iter().all(|p| list.points(p).iter().all(|q| rect.contains(*q))));
|
||||
assert!(list.sheet.is_some());
|
||||
// Undo removes the layout.
|
||||
s.undo().unwrap();
|
||||
assert!(s.doc().unwrap().layout("Layout3").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_keeps_the_active_layout_whatever_its_case() {
|
||||
let mut s = session_with_model();
|
||||
s.doc_mut().unwrap().active_layout = Some("layout1".into());
|
||||
s.execute("layout.rename", &json!({"from": "Layout1", "to": "Sheet A"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().active_layout.as_deref(), Some("Sheet A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_copy_delete() {
|
||||
let mut s = session_with_model();
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
s.execute("layout.rename", &json!({"to": "Sheet A"})).unwrap();
|
||||
assert_eq!(s.space(), Space::Paper("Sheet A".into()));
|
||||
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "Layout2"})).is_err(), "duplicate");
|
||||
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "a/b"})).is_err(), "bad char");
|
||||
assert!(s.execute("layout.rename", &json!({"from": "Sheet A", "to": "model"})).is_err(), "reserved");
|
||||
s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap();
|
||||
let r = s.execute("layout.copy", &json!({"from": "Sheet A"})).unwrap();
|
||||
assert_eq!(r["name"], "Sheet A (2)");
|
||||
let a = viewports(&s, "Sheet A");
|
||||
let b = viewports(&s, "Sheet A (2)");
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(b.len(), 1);
|
||||
assert_ne!(a[0].0, b[0].0, "copied entities get new handles");
|
||||
s.execute("layout.delete", &json!({"name": "sheet a"})).unwrap();
|
||||
assert_eq!(s.space(), Space::Model, "deleting the current layout returns to Model");
|
||||
s.execute("layout", &json!({"option": "delete", "name": "Layout2"})).unwrap();
|
||||
assert!(s.execute("layout.delete", &json!({"name": "Sheet A (2)"})).is_err(), "last layout stays");
|
||||
let l = s.execute("layout", &json!({"option": "list"})).unwrap();
|
||||
assert_eq!(l.as_array().unwrap().len(), 1);
|
||||
assert!(s.execute("layout.delete", &json!({"name": "Model"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_layout_from_the_menu_asks_for_a_name() {
|
||||
let mut s = session_with_model();
|
||||
s.start("layout.new").unwrap();
|
||||
assert_eq!(s.current_prompt().map(|p| p.display()).as_deref(), Some("Enter name of new layout <Layout3>:"));
|
||||
s.cmdline("Layout1").unwrap();
|
||||
assert!(s.running.is_some(), "a taken name asks again");
|
||||
s.cmdline("Sheet A").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
s.start("layout.new").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.layout("Sheet A").is_some() && d.layout("Layout3").is_some(), "Enter takes the default name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mview_arrangements_and_interactive() {
|
||||
let mut s = session_with_model();
|
||||
assert!(s.execute("mview", &json!({"p1": [0, 0], "p2": [1, 1]})).is_err(), "not in model space");
|
||||
s.execute("mview", &json!({"layout": "Layout2", "count": 4})).unwrap();
|
||||
assert_eq!(viewports(&s, "Layout2").len(), 4);
|
||||
s.execute("vports.3", &json!({"layout": "Layout2", "p1": [0, 0], "p2": [9, 6]})).unwrap();
|
||||
let v = viewports(&s, "Layout2");
|
||||
assert_eq!(v.len(), 7);
|
||||
let total: f64 = v[4..].iter().map(|(_, v)| v.width * v.height).sum();
|
||||
assert!((total - 54.0).abs() < 1e-9, "three viewports tile the window");
|
||||
// Interactive MVIEW: two corners.
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
s.cmdline("mview").unwrap();
|
||||
s.cmdline("1,1").unwrap();
|
||||
s.cmdline("6,4").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
let v = viewports(&s, "Layout1");
|
||||
assert_eq!(v.len(), 1);
|
||||
assert!((v[0].1.width - 5.0).abs() < 1e-9 && (v[0].1.height - 3.0).abs() < 1e-9);
|
||||
// MVIEW 2 → arrangement (Enter: Vertical) → Fit (Enter).
|
||||
s.cmdline("mview").unwrap();
|
||||
s.cmdline("2").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
s.cmdline("").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
assert_eq!(viewports(&s, "Layout1").len(), 3);
|
||||
// One undo step per MVIEW.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(viewports(&s, "Layout1").len(), 1);
|
||||
// In model space the interactive command ends at once.
|
||||
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
|
||||
s.cmdline("vports.4").unwrap();
|
||||
assert!(s.running.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewport_scale_lock_and_layers() {
|
||||
let mut s = session_with_model();
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
let r = s.execute("mview", &json!({"p1": [1, 1], "p2": [5, 4]})).unwrap();
|
||||
let h = r["viewports"][0].as_str().unwrap().to_string();
|
||||
let out = s.execute("viewport.set", &json!({"handle": h, "scale": "1:20", "center": [50, 20]})).unwrap();
|
||||
assert!((out[0]["viewHeight"].as_f64().unwrap() - 60.0).abs() < 1e-9);
|
||||
assert!((out[0]["scale"].as_f64().unwrap() - 0.05).abs() < 1e-12);
|
||||
s.execute("viewport.set", &json!({"handle": h, "locked": true})).unwrap();
|
||||
assert!(s.execute("viewport.set", &json!({"handle": h, "scale": 0.1})).is_err(), "locked");
|
||||
s.execute("viewport.set", &json!({"handle": h, "scale": 0.1, "locked": false})).unwrap();
|
||||
s.execute("vplayer", &json!({"handle": h, "freeze": ["0", "Walls"]})).unwrap();
|
||||
s.execute("vplayer", &json!({"handle": h, "thaw": "walls"})).unwrap();
|
||||
let v = viewports(&s, "Layout1");
|
||||
assert_eq!(v[0].1.frozen_layers, vec!["0".to_string()]);
|
||||
s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": "red"}})).unwrap();
|
||||
assert!(s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": "bylayer"}})).is_err());
|
||||
assert!(s.execute("vplayer", &json!({"handle": h, "colors": {"Walls": [1]}})).is_err());
|
||||
let vv = viewports(&s, "Layout1");
|
||||
assert_eq!(vv[0].1.layer_colors, vec![("Walls".to_string(), cadcraft_color::Color::Index(1))]);
|
||||
s.execute("vplayer", &json!({"handle": h, "colors": {"walls": null}})).unwrap();
|
||||
assert!(viewports(&s, "Layout1")[0].1.layer_colors.is_empty());
|
||||
assert!((v[0].1.view_height - 30.0).abs() < 1e-9);
|
||||
// Layer 0 frozen in the viewport: nothing of the model shows.
|
||||
let list = cadcraft_render::build(s.doc().unwrap(), &Space::Paper("Layout1".into()), &cadcraft_render::Options::default());
|
||||
assert!(list.prims.iter().all(|p| p.handle != cadcraft_render::VIEWPORT_CONTENT));
|
||||
for bad_p in
|
||||
[json!({"handle": h, "scale": "0:0"}), json!({"handle": h, "scale": -1}), json!({"handle": h, "viewHeight": 0}), json!({"handle": "FFFFFF"})]
|
||||
{
|
||||
assert!(s.execute("viewport.set", &bad_p).is_err(), "{bad_p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pagesetup_paper_table() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "A3", "landscape": true, "margins": [5, 5, 5, 5]})).unwrap();
|
||||
assert_eq!(r["page"]["widthMm"], 297.0);
|
||||
let sheet = cadcraft_render::sheet(s.doc().unwrap(), "Layout1").unwrap();
|
||||
// Imperial drawing: paper units are inches.
|
||||
assert!((sheet.size.x - 420.0 / 25.4).abs() < 1e-9 && (sheet.size.y - 297.0 / 25.4).abs() < 1e-9);
|
||||
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "paper": "Z12"})).is_err());
|
||||
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "margins": [1, 2]})).is_err());
|
||||
assert!(s.execute("pagesetup", &json!({"layout": "Layout1", "width": 1e308, "height": 5})).is_err());
|
||||
assert!(s.execute("pagesetup", &json!({"paper": "A4"})).is_err(), "model tab needs a layout");
|
||||
for area in ["extents", "display", "window", "limits"] {
|
||||
let e = s.execute("pagesetup", &json!({"layout": "Layout1", "plotArea": area})).unwrap_err();
|
||||
assert!(e.to_string().contains("not available yet"), "{area}: {e}");
|
||||
}
|
||||
let r = s.execute("pagesetup", &json!({"layout": "Layout1", "plotArea": "Layout"})).unwrap();
|
||||
assert_eq!(r["page"]["plotArea"], "layout");
|
||||
let report = s.execute("pagesetup", &Value::Null).unwrap();
|
||||
assert!(report["papers"].as_array().unwrap().len() >= 10);
|
||||
s.execute("pagesetup", &json!({"layout": "Layout2", "width": 500, "height": 300})).unwrap();
|
||||
let p = &s.doc().unwrap().layout("Layout2").unwrap().page;
|
||||
assert_eq!((p.width_mm, p.height_mm), (300.0, 500.0));
|
||||
}
|
||||
|
||||
fn fake_plot(d: &Drawing, space: &Space, opts: &Value) -> std::result::Result<Vec<u8>, String> {
|
||||
let _ = d;
|
||||
Ok(format!("%PDF-1.4 fake {space:?} {opts}").into_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plot_goes_through_the_hook() {
|
||||
super::super::file::set_io(super::super::file::IoHooks {
|
||||
read: |_, _| Err("no reader in tests".into()),
|
||||
write: |_, _| Err("no writer in tests".into()),
|
||||
plot: Some(fake_plot),
|
||||
});
|
||||
let mut s = session_with_model();
|
||||
if io().and_then(|h| h.plot).is_none() {
|
||||
return; // another test installed hooks without a plotter first
|
||||
}
|
||||
let r = s.execute("plot", &json!({"layout": "Layout1", "paper": "A4"})).unwrap();
|
||||
let data = super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap();
|
||||
let text = String::from_utf8(data).unwrap();
|
||||
assert!(text.starts_with("%PDF") && text.contains("Paper(\"Layout1\")") && text.contains("A4"));
|
||||
let r = s.execute("exportpdf", &json!({})).unwrap();
|
||||
assert_eq!(r["layout"], "Model");
|
||||
let baseline = String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(!baseline.contains("\"center\"") && !baseline.contains("\"offset\""), "omitted options remain omitted: {baseline}");
|
||||
assert!(s.execute("plot", &json!({"layout": "Nope"})).is_err());
|
||||
// Plot area and a ratio scale reach the plotter as a model-space window and a number.
|
||||
let opts = |r: Value| String::from_utf8(super::super::file::base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap();
|
||||
let p = json!({"layout": "Model", "plotArea": "window", "window": [[10, 0], [0, 5]], "fit": false, "scale": "1:2"});
|
||||
let text = opts(s.execute("plot", &p).unwrap());
|
||||
assert!(text.contains(r#""window":[[0.0,0.0],[10.0,5.0]]"#) && text.contains(r#""scale":0.5"#), "{text}");
|
||||
let text = opts(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": [12.5, -7.0]})).unwrap());
|
||||
assert!(text.contains(r#""center":false"#) && text.contains(r#""offset":[12.5,-7.0]"#), "{text}");
|
||||
for offset in [json!([1]), json!([1, 2, 3]), json!([f64::NAN, 0]), json!([100_001, 0])] {
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "center": false, "offset": offset})).is_err());
|
||||
}
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "output": "svg", "center": false, "offset": [0, 0]})).is_err());
|
||||
assert!(opts(s.execute("plot", &json!({"layout": "Model", "plotArea": "limits"})).unwrap()).contains(r#""window":[[0.0,0.0],"#));
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "plotArea": "window"})).is_err(), "window needs corners");
|
||||
assert!(s.execute("plot", &json!({"layout": "Layout1", "plotArea": "display"})).is_err(), "layouts plot their sheet");
|
||||
assert!(s.execute("plot", &json!({"layout": "Model", "scale": -1})).is_err());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-plot-test-{}.pdf", std::process::id()));
|
||||
let ps = path.to_string_lossy().to_string();
|
||||
let r = s.execute("plot", &json!({"path": ps})).unwrap();
|
||||
assert!(r["bytes"].as_u64().unwrap() > 0);
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_layout_params() {
|
||||
let mut s = session_with_model();
|
||||
let long = "x".repeat(300);
|
||||
for (c, p) in [
|
||||
("layout.new", json!({"name": ""})),
|
||||
("layout.new", json!({"name": long})),
|
||||
("layout.new", json!({"name": "a\u{0}b"})),
|
||||
("layout.new", json!({"name": "Layout1"})),
|
||||
("mview", json!({"layout": "Layout1", "p1": [1e308, -1e308], "p2": [-1e308, 1e308]})),
|
||||
("mview", json!({"layout": "Layout1", "p1": [1, 1], "p2": [1, 1]})),
|
||||
("mview", json!({"layout": "Layout1", "count": 99})),
|
||||
("mview", json!({"layout": "Layout1", "count": -1})),
|
||||
("layout.rename", json!({"from": "Layout1"})),
|
||||
("layout.copy", json!({"from": "nope"})),
|
||||
("viewport.set", json!({"handles": ["zz"]})),
|
||||
] {
|
||||
assert!(s.execute(c, &p).is_err(), "{c} {p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mspace_edits_model_through_viewport() {
|
||||
let mut s = session_with_model();
|
||||
let before = s.doc().unwrap().model.len();
|
||||
s.execute("layout.new", &json!({"name": "L"})).unwrap();
|
||||
s.execute("layout.set", &json!({"name": "L"})).unwrap();
|
||||
assert!(s.execute("mspace", &json!({"at": [-1000, -1000]})).is_err());
|
||||
let vps = viewports(&s, "L");
|
||||
let (h, vp) = vps.iter().find(|(_, v)| v.id != 1).cloned().unwrap();
|
||||
s.execute("mspace", &json!({"at": [vp.center.x, vp.center.y]})).unwrap();
|
||||
assert_eq!(s.space(), Space::Model);
|
||||
assert_eq!(s.layout_space(), Space::Paper("L".into()));
|
||||
// The screen view in MSPACE maps the viewport centre to its model view centre.
|
||||
let st = s.state().unwrap();
|
||||
let (pv, v) = (st.paper_view(), st.view());
|
||||
let k = vp.view_height / vp.height;
|
||||
assert!((v.height - pv.height * k).abs() < 1e-9);
|
||||
// New geometry goes to model space.
|
||||
s.cmdline("circle 10,10 3").unwrap();
|
||||
assert_eq!(s.doc().unwrap().model.len(), before + 1);
|
||||
// Zooming changes the viewport's model view, not the sheet.
|
||||
s.state_mut().unwrap().set_view(crate::View { center: v.center, height: v.height / 2.0 });
|
||||
let vp2 = viewports(&s, "L").into_iter().find(|(x, _)| *x == h).unwrap().1;
|
||||
assert!((vp2.view_height - vp.view_height / 2.0).abs() < 1e-9);
|
||||
assert_eq!(s.state().unwrap().paper_view(), pv);
|
||||
s.execute("pspace", &json!({})).unwrap();
|
||||
assert_eq!(s.space(), Space::Paper("L".into()));
|
||||
s.execute("mspace", &json!({})).unwrap();
|
||||
s.execute("layout.set", &json!({"name": "Model"})).unwrap();
|
||||
assert!(s.state().unwrap().mspace.is_none());
|
||||
}
|
||||
@@ -793,5 +793,4 @@ impl Interactive for CollectM {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mleader_edit_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
+20
-2972
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,277 @@
|
||||
use super::trim::{param_on, sub_polyline};
|
||||
use super::*;
|
||||
|
||||
/// Gives a rebuilt arc piece its source centre Z, or a rebuilt polyline piece its source elevation.
|
||||
fn with_z(mut k: EntityKind, z: f64) -> EntityKind {
|
||||
match &mut k {
|
||||
EntityKind::Arc(a) => a.center.z = z,
|
||||
EntityKind::LwPolyline(p) => p.elevation = z,
|
||||
_ => {}
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// Break `h` between two points (or at one point when p1 == p2).
|
||||
pub(crate) fn break_entity(s: &mut Session, h: Handle, p1: Vec2, p2: Vec2) -> Result<Vec<Handle>> {
|
||||
if crate::cmd::curves::is_locked(s, h) {
|
||||
return Err(EngineError::Other("The object is on a locked layer.".into()));
|
||||
}
|
||||
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
let pieces: Vec<EntityKind> = match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
let ln = Line::new(l.a.xy(), l.b.xy());
|
||||
let (mut t1, mut t2) = (ln.param_of(p1).clamp(0.0, 1.0), ln.param_of(p2).clamp(0.0, 1.0));
|
||||
if t1 > t2 {
|
||||
std::mem::swap(&mut t1, &mut t2);
|
||||
}
|
||||
let at3 = |t: f64| ln.at(t).to3(l.a.z + (l.b.z - l.a.z) * t);
|
||||
let mut v = Vec::new();
|
||||
if t1 > 1e-12 {
|
||||
v.push(EntityKind::Line(cadcraft_doc::Line { a: l.a, b: at3(t1) }));
|
||||
}
|
||||
if t2 < 1.0 - 1e-12 {
|
||||
v.push(EntityKind::Line(cadcraft_doc::Line { a: at3(t2), b: l.b }));
|
||||
}
|
||||
v
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
if p1.near(p2, 1e-12) {
|
||||
return Err(EngineError::Other("Arc cannot be full 360 degrees.".into()));
|
||||
}
|
||||
let a1 = c.center.xy().angle_to(p1);
|
||||
let a2 = c.center.xy().angle_to(p2);
|
||||
// AutoCAD removes the CCW portion from the first to the second point.
|
||||
vec![with_z(arc(&Arc::new(c.center.xy(), c.radius, a2, a1)), c.center.z)]
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
let par = |p: Vec2| norm_angle(ga.center.angle_to(p) - ga.start).min(ga.sweep());
|
||||
let (mut t1, mut t2) = (par(p1), par(p2));
|
||||
if t1 > t2 {
|
||||
std::mem::swap(&mut t1, &mut t2);
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if t1 > 1e-12 {
|
||||
v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + t1)), a.center.z));
|
||||
}
|
||||
if t2 < ga.sweep() - 1e-12 {
|
||||
v.push(with_z(arc(&Arc::new(ga.center, ga.radius, ga.start + t2, ga.end)), a.center.z));
|
||||
}
|
||||
v
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
let segs = pl.segments();
|
||||
let total: f64 = segs.iter().map(Segment::len).sum();
|
||||
let lp = |q: Vec2| {
|
||||
let mut acc = 0.0;
|
||||
let mut best = (f64::INFINITY, 0.0);
|
||||
for s in &segs {
|
||||
let c = s.closest(q);
|
||||
let dd = c.dist(q);
|
||||
if dd < best.0 {
|
||||
best = (dd, acc + param_on(s, c) * s.len());
|
||||
}
|
||||
acc += s.len();
|
||||
}
|
||||
best.1
|
||||
};
|
||||
let (mut t1, mut t2) = (lp(p1), lp(p2));
|
||||
if p.closed {
|
||||
if (t1 - t2).abs() < 1e-9 {
|
||||
return Err(EngineError::Other("Cannot break a closed polyline at a single point.".into()));
|
||||
}
|
||||
// Like a circle: the stretch from the first point to the second, along the
|
||||
// vertices, goes; what is left runs from the second point round to the first.
|
||||
let doubled: Vec<Segment> = segs.iter().chain(segs.iter()).copied().collect();
|
||||
let end = if t1 > t2 { t1 } else { t1 + total };
|
||||
vec![with_z(lwpoly(sub_polyline(&doubled, t2, end), false), p.elevation)]
|
||||
} else {
|
||||
if t1 > t2 {
|
||||
std::mem::swap(&mut t1, &mut t2);
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if t1 > 1e-12 {
|
||||
v.push(with_z(lwpoly(sub_polyline(&segs, 0.0, t1), false), p.elevation));
|
||||
}
|
||||
if t2 < total - 1e-12 {
|
||||
v.push(with_z(lwpoly(sub_polyline(&segs, t2, total), false), p.elevation));
|
||||
}
|
||||
v
|
||||
}
|
||||
}
|
||||
_ => return Err(EngineError::Other("Cannot break that object.".into())),
|
||||
};
|
||||
let common = e.common.clone();
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
d.remove_entity(h);
|
||||
let mut out = Vec::new();
|
||||
for k in pieces {
|
||||
let nh = d.new_handle();
|
||||
if let Some(st) = d.space_mut(&space) {
|
||||
st.push(Entity { handle: nh, common: common.clone(), kind: k });
|
||||
out.push(nh);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn run_break(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("break", "`handle` is required"))?;
|
||||
let p1 = point_req("break", p, "p1")?;
|
||||
let p2 = point_param(p, "p2").unwrap_or(p1);
|
||||
let r = break_entity(s, h, p1, p2)?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
pub(super) fn run_breakat(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("breakatpoint", "`handle` is required"))?;
|
||||
let at = point_req("breakatpoint", p, "at")?;
|
||||
let r = break_entity(s, h, at, at)?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// Join lines/arcs/polylines that share endpoints into one polyline (or merge collinear lines).
|
||||
pub(crate) fn join(s: &mut Session, hs: &[Handle]) -> Result<Option<Handle>> {
|
||||
if let Some(h) = crate::cmd::modify2::join_curves(s, hs)? {
|
||||
return Ok(Some(h));
|
||||
}
|
||||
if hs.iter().any(|h| crate::cmd::curves::is_locked(s, *h)) {
|
||||
return Err(EngineError::Other("An object is on a locked layer.".into()));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let mut segs: Vec<Segment> = Vec::new();
|
||||
// Source entity of each segment in `segs`, so only entities that end up in the chain are removed.
|
||||
let mut owners: Vec<Handle> = Vec::new();
|
||||
let mut common = None;
|
||||
for h in hs {
|
||||
let Some(e) = d.entity(*h) else { continue };
|
||||
common.get_or_insert_with(|| e.common.clone());
|
||||
match &e.kind {
|
||||
EntityKind::Line(l) => segs.push(Segment::Line(Line::new(l.a.xy(), l.b.xy()))),
|
||||
EntityKind::Arc(a) => segs.push(Segment::Arc { arc: Arc::new(a.center.xy(), a.radius, a.start, a.end), ccw: true }),
|
||||
EntityKind::LwPolyline(p) if !p.closed => segs.extend(Polyline { vertices: p.vertices.clone(), closed: false }.segments()),
|
||||
_ => {}
|
||||
}
|
||||
owners.resize(segs.len(), *h);
|
||||
}
|
||||
if segs.len() < 2 {
|
||||
return Err(EngineError::Other("Select at least two objects to join.".into()));
|
||||
}
|
||||
// Chain greedily from the first segment.
|
||||
let mut chain = vec![segs.remove(0)];
|
||||
let mut used = vec![owners.remove(0)];
|
||||
let tol = 1e-6;
|
||||
loop {
|
||||
let start = chain.first().map(Segment::start).unwrap_or_default();
|
||||
let end = chain.last().map(Segment::end).unwrap_or_default();
|
||||
let Some(i) =
|
||||
segs.iter().position(|s| s.start().near(end, tol) || s.end().near(end, tol) || s.start().near(start, tol) || s.end().near(start, tol))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let s0 = segs.remove(i);
|
||||
used.push(owners.remove(i));
|
||||
if s0.start().near(end, tol) {
|
||||
chain.push(s0);
|
||||
} else if s0.end().near(end, tol) {
|
||||
chain.push(s0.reversed());
|
||||
} else if s0.end().near(start, tol) {
|
||||
chain.insert(0, s0);
|
||||
} else {
|
||||
chain.insert(0, s0.reversed());
|
||||
}
|
||||
}
|
||||
if chain.len() < 2 {
|
||||
return Err(EngineError::Other("Objects are not connected.".into()));
|
||||
}
|
||||
let closed = chain.first().zip(chain.last()).is_some_and(|(a, b)| a.start().near(b.end(), tol));
|
||||
let mut vs: Vec<PolyVertex> = chain
|
||||
.iter()
|
||||
.map(|s| PolyVertex {
|
||||
p: s.start(),
|
||||
bulge: match s {
|
||||
Segment::Line(_) => 0.0,
|
||||
Segment::Arc { arc, ccw } => cadcraft_geom::arc_to_bulge(if *ccw { arc.sweep() } else { -arc.sweep() }),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
if !closed && let Some(l) = chain.last() {
|
||||
vs.push(PolyVertex::new(l.end()));
|
||||
}
|
||||
let space = s.space();
|
||||
let doc = s.doc_mut()?;
|
||||
for h in hs.iter().filter(|h| used.contains(*h)) {
|
||||
doc.remove_entity(*h);
|
||||
}
|
||||
let nh = doc.new_handle();
|
||||
if let Some(st) = doc.space_mut(&space) {
|
||||
st.push(Entity { handle: nh, common: common.unwrap_or_default(), kind: lwpoly(vs, closed) });
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Some(nh))
|
||||
}
|
||||
|
||||
pub(super) fn run_join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
if p.get("close").and_then(Value::as_bool) == Some(true) {
|
||||
let h = crate::cmd::modify2::join_close(s, &hs)?;
|
||||
return Ok(json!({ "handle": h.hex() }));
|
||||
}
|
||||
let r = join(s, &hs)?;
|
||||
Ok(json!({ "handle": r.map(|h| h.hex()) }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BreakM {
|
||||
obj: Option<(Handle, Vec2)>,
|
||||
/// The `First point` option: the next point replaces the selection pick as the first break point.
|
||||
first_point: bool,
|
||||
}
|
||||
|
||||
impl Interactive for BreakM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BREAK"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.obj {
|
||||
None => Prompt::new("Select object", Accept::POINT),
|
||||
Some(_) if self.first_point => Prompt::new("Specify first break point", Accept::POINT),
|
||||
Some(_) => Prompt::new("Specify second break point", Accept::POINT).kw(&["First point"]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.obj, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
match crate::select::pick(s.doc()?, &space, p, ap) {
|
||||
Some(h) => {
|
||||
self.obj = Some((h, p));
|
||||
s.set_selection(vec![h]);
|
||||
}
|
||||
None => s.echo("*Invalid selection*"),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(_), Input::Keyword(k)) if k == "First point" => {
|
||||
self.first_point = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some((h, _)), Input::Point(p1)) if self.first_point => {
|
||||
self.obj = Some((h, p1));
|
||||
self.first_point = false;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some((h, p1)), Input::Point(p2)) => {
|
||||
break_entity(s, h, p1, p2)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------- explode / array / order / break / join ----------------
|
||||
|
||||
fn explode_block_ref(d: &cadcraft_doc::Drawing, e: &Entity, ins: &cadcraft_doc::Insert) -> Option<Vec<Entity>> {
|
||||
if let Some(items) = crate::cmd::array::explode_array(d, e) {
|
||||
return Some(items);
|
||||
}
|
||||
let blk = d.block(&ins.block)?;
|
||||
let m = ins.transform(blk.base.xy());
|
||||
Some(
|
||||
blk.entities
|
||||
.iter()
|
||||
.map(|be| {
|
||||
let mut ne = (**be).clone();
|
||||
ne.kind.transform(&m);
|
||||
if ne.common.layer == "0" {
|
||||
ne.common.layer = e.common.layer.clone();
|
||||
}
|
||||
if ne.common.color == cadcraft_color::Color::ByBlock {
|
||||
ne.common.color = e.common.color;
|
||||
}
|
||||
ne
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn explode_mtext(e: &Entity, m: &cadcraft_doc::MText) -> Vec<Entity> {
|
||||
cadcraft_fonts::plain_mtext(&m.contents)
|
||||
.split('\n')
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let at = m.insert.xy() + Vec2::from_angle(m.rotation - std::f64::consts::FRAC_PI_2) * (m.height * (1.0 + 5.0 / 3.0 * i as f64));
|
||||
Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: EntityKind::Text(cadcraft_doc::Text {
|
||||
insert: v3(at),
|
||||
align_pt: None,
|
||||
height: m.height,
|
||||
value: l.into(),
|
||||
rotation: m.rotation,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: m.style.clone(),
|
||||
halign: Default::default(),
|
||||
valign: Default::default(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The entities of a boundary's lines and arcs, on the object's layer and style.
|
||||
fn segment_entities(e: &Entity, segs: impl IntoIterator<Item = Segment>) -> Vec<Entity> {
|
||||
segs.into_iter()
|
||||
.map(|s| {
|
||||
let k = match s {
|
||||
Segment::Line(l) => line(l.a, l.b),
|
||||
Segment::Arc { arc: a, .. } => arc(&a),
|
||||
};
|
||||
Entity { handle: Handle(0), common: e.common.clone(), kind: k }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn explode_dimension(d: &cadcraft_doc::Drawing, e: &Entity, dm: &cadcraft_doc::Dimension) -> Vec<Entity> {
|
||||
// The dimension as drawn: its overrides, DIMSCALE (0 = the drawing's) and text font.
|
||||
let style = d.dim_style(&dm.style).cloned().unwrap_or_default().with_overrides(&dm.overrides);
|
||||
let g = cadcraft_render::dimension_in(d, dm);
|
||||
let line_entity = |l: &Vec<Vec2>| {
|
||||
if l.len() == 2 {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: line(*l.first()?, *l.get(1)?) })
|
||||
} else {
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind: lwpoly(l.iter().map(|p| PolyVertex::new(*p)).collect(), false) })
|
||||
}
|
||||
};
|
||||
// The text's marks (arc length symbol, DIMINSPECT frame) go after the text background.
|
||||
let text_marks = &g.text_marks;
|
||||
let marks = |want: bool| g.lines.iter().enumerate().filter(move |(i, _)| text_marks.contains(i) == want).map(|(_, l)| l);
|
||||
let mut v: Vec<Entity> = marks(false).filter_map(&line_entity).collect();
|
||||
for t in &g.fills {
|
||||
if let [a, b, c] = t.as_slice() {
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*a), v3(*b), v3(*c), v3(*c)] }),
|
||||
});
|
||||
}
|
||||
}
|
||||
// DIMTFILL 2: the text background as a SOLID in DIMTFILLCLR, over the lines and
|
||||
// arrowheads, under the text and its marks (SOLID corners go 1, 2, 4, 3).
|
||||
if style.text_fill == 2
|
||||
&& let [p0, p1, p2, p3] = g.text_fill.as_slice()
|
||||
{
|
||||
let fill = style.text_fill_color;
|
||||
let color = if fill == cadcraft_doc::color::Color::ByBlock { e.common.color } else { fill };
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: cadcraft_doc::Common { color, ..e.common.clone() },
|
||||
kind: EntityKind::Solid(cadcraft_doc::Solid { corners: [v3(*p0), v3(*p1), v3(*p3), v3(*p2)] }),
|
||||
});
|
||||
}
|
||||
v.extend(marks(true).filter_map(&line_entity));
|
||||
if !g.value.is_empty() {
|
||||
v.push(Entity {
|
||||
handle: Handle(0),
|
||||
common: e.common.clone(),
|
||||
kind: EntityKind::MText(cadcraft_doc::MText {
|
||||
insert: v3(g.text_pos),
|
||||
height: g.text_height,
|
||||
width: 0.0,
|
||||
attach: 5,
|
||||
rotation: g.text_angle,
|
||||
style: style.text_style.clone(),
|
||||
contents: g.mtext_contents(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}),
|
||||
});
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
// An annotative object explodes into plain objects as it draws at the current annotation scale.
|
||||
if cadcraft_doc::is_annotative(e) {
|
||||
let mut se = match d.anno_draw(e) {
|
||||
cadcraft_doc::AnnoDraw::Scaled(f) => cadcraft_doc::anno_scaled(e, f),
|
||||
_ => e.clone(),
|
||||
};
|
||||
se.common.anno_scales.clear();
|
||||
return explode_kind(d, &se);
|
||||
}
|
||||
match &e.kind {
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
Some(segment_entities(e, pl.segments()))
|
||||
}
|
||||
EntityKind::Insert(ins) => explode_block_ref(d, e, ins),
|
||||
// A helix explodes into its spline (seen from above).
|
||||
EntityKind::Helix(h) => Some(vec![Entity { handle: Handle(0), common: e.common.clone(), kind: EntityKind::Spline(h.spline()) }]),
|
||||
EntityKind::Polyline3d(p) => Some(
|
||||
p.points
|
||||
.windows(2)
|
||||
.filter_map(|w| {
|
||||
let kind = EntityKind::Line(cadcraft_doc::Line { a: *w.first()?, b: *w.get(1)? });
|
||||
Some(Entity { handle: Handle(0), common: e.common.clone(), kind })
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
EntityKind::MLine(m) => Some(crate::cmd::draw2::mline::explode(e, m)),
|
||||
EntityKind::MText(m) => Some(explode_mtext(e, m)),
|
||||
// A region explodes into its boundary's lines and arcs.
|
||||
EntityKind::Region(r) => Some(segment_entities(e, r.segments())),
|
||||
EntityKind::Dimension(dm) => Some(explode_dimension(d, e, dm)),
|
||||
EntityKind::Tolerance(t) => Some(crate::cmd::tolerance::explode(d, e, t)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn explode(s: &mut Session, hs: &[Handle]) -> Result<Vec<Handle>> {
|
||||
explode_with(s, hs, explode_kind)
|
||||
}
|
||||
|
||||
/// Replace each of `hs` (off locked layers) by the pieces `pieces` gives for it; objects it gives
|
||||
/// none for are left alone. EXPLODE, and BURST with its own pieces for block references.
|
||||
pub(crate) fn explode_with(
|
||||
s: &mut Session,
|
||||
hs: &[Handle],
|
||||
pieces: impl Fn(&cadcraft_doc::Drawing, &Entity) -> Option<Vec<Entity>>,
|
||||
) -> Result<Vec<Handle>> {
|
||||
let space = s.space();
|
||||
let mut out = Vec::new();
|
||||
for h in hs {
|
||||
if crate::cmd::curves::is_locked(s, *h) {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = s.doc()?.entity(*h).map(|e| (**e).clone()) else { continue };
|
||||
let Some(parts) = pieces(s.doc()?, &e) else { continue };
|
||||
// The pieces of an object are new objects: its xdata goes with it. A block reference's
|
||||
// pieces are copies of the block's objects and keep theirs.
|
||||
let own_pieces = !matches!(e.kind, EntityKind::Insert(_));
|
||||
let d = s.doc_mut()?;
|
||||
d.remove_entity(*h);
|
||||
for mut p in parts {
|
||||
if own_pieces {
|
||||
p.common.xdata.clear();
|
||||
}
|
||||
p.handle = d.new_handle();
|
||||
out.push(p.handle);
|
||||
if let Some(st) = d.space_mut(&space) {
|
||||
st.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn run_explode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let r = explode(s, &hs)?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
@@ -0,0 +1,571 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------- fillet / chamfer ----------------
|
||||
|
||||
/// What CHAMFER takes (as in AutoCAD, not arcs, circles, ellipses or splines).
|
||||
const CHAMFER_LINES_ONLY: &str =
|
||||
"CHAMFER works on lines (and polylines with the Polyline option); it cannot chamfer arcs, circles, ellipses or splines.";
|
||||
|
||||
fn as_line(e: &Entity) -> Option<Line> {
|
||||
match &e.kind {
|
||||
EntityKind::Line(l) => Some(Line::new(l.a.xy(), l.b.xy())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fillet (or chamfer) two lines. `p1`/`p2` mark the kept side of each line.
|
||||
pub(crate) fn fillet_lines(
|
||||
s: &mut Session,
|
||||
h1: Handle,
|
||||
p1: Vec2,
|
||||
h2: Handle,
|
||||
p2: Vec2,
|
||||
radius: f64,
|
||||
chamfer: Option<(f64, f64)>,
|
||||
) -> Result<Option<Handle>> {
|
||||
if crate::cmd::curves::is_locked(s, h1) || crate::cmd::curves::is_locked(s, h2) {
|
||||
return Err(EngineError::Other("The object is on a locked layer.".into()));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let e1 = d.entity(h1).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
let e2 = d.entity(h2).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
let (Some(l1), Some(l2)) = (as_line(&e1), as_line(&e2)) else {
|
||||
if chamfer.is_none() {
|
||||
return crate::cmd::modify2::fillet_curves(s, h1, p1, h2, p2, radius);
|
||||
}
|
||||
return Err(EngineError::Other(CHAMFER_LINES_ONLY.into()));
|
||||
};
|
||||
let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?;
|
||||
// Keep the far end on the picked side of the corner.
|
||||
let keep = |l: &Line, p: Vec2| -> Vec2 {
|
||||
let ta = (l.a - x).dot(p - x);
|
||||
let tb = (l.b - x).dot(p - x);
|
||||
if ta >= tb { l.a } else { l.b }
|
||||
};
|
||||
let k1 = keep(&l1, p1);
|
||||
let k2 = keep(&l2, p2);
|
||||
let u1 = (k1 - x).normalized();
|
||||
let u2 = (k2 - x).normalized();
|
||||
let mut new_arc = None;
|
||||
let (t1, t2) = if let Some((d1, d2)) = chamfer {
|
||||
(x + u1 * d1, x + u2 * d2)
|
||||
} else if radius <= 1e-12 {
|
||||
(x, x)
|
||||
} else {
|
||||
let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0;
|
||||
if half.sin().abs() < 1e-12 {
|
||||
return Err(EngineError::Other("Lines are parallel.".into()));
|
||||
}
|
||||
let dist = radius / half.tan();
|
||||
let t1 = x + u1 * dist;
|
||||
let t2 = x + u2 * dist;
|
||||
let bis = (u1 + u2).normalized();
|
||||
let c = x + bis * (radius / half.sin());
|
||||
let a1 = c.angle_to(t1);
|
||||
let a2 = c.angle_to(t2);
|
||||
let ar = if ccw_sweep(a1, a2) <= std::f64::consts::PI { Arc::new(c, radius, a1, a2) } else { Arc::new(c, radius, a2, a1) };
|
||||
new_arc = Some(ar);
|
||||
(t1, t2)
|
||||
};
|
||||
let common = e1.common.clone();
|
||||
let doc = s.doc_mut()?;
|
||||
doc.modify_entity(h1, |e| e.kind = line(k1, t1))?;
|
||||
doc.modify_entity(h2, |e| e.kind = line(k2, t2))?;
|
||||
let space = s.space();
|
||||
let doc = s.doc_mut()?;
|
||||
let extra = match (chamfer, new_arc) {
|
||||
(Some(_), _) if !t1.near(t2, 1e-12) => Some(line(t1, t2)),
|
||||
(None, Some(a)) => Some(arc(&a)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(k) = extra {
|
||||
let nh = doc.new_handle();
|
||||
if let Some(st) = doc.space_mut(&space) {
|
||||
st.push(Entity { handle: nh, common, kind: k });
|
||||
}
|
||||
return Ok(Some(nh));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn h_param(p: &Value, k: &str) -> Option<Handle> {
|
||||
p.get(k).and_then(|v| v.as_str().and_then(Handle::parse_hex).or_else(|| v.as_u64().map(Handle)))
|
||||
}
|
||||
|
||||
pub(super) fn run_fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "polyline", false) {
|
||||
let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("fillet", "`handle` (polyline) is required"))?;
|
||||
let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0));
|
||||
let n = crate::cmd::modify2::fillet_polyline(s, h, r)?;
|
||||
return Ok(json!({ "filleted": n }));
|
||||
}
|
||||
let h1 = h_param(p, "h1").ok_or_else(|| bad("fillet", "`h1` is required"))?;
|
||||
let h2 = h_param(p, "h2").ok_or_else(|| bad("fillet", "`h2` is required"))?;
|
||||
let r = f64_or(p, "radius", s.doc()?.header.f64("FILLETRAD", 0.0));
|
||||
let p1 = point_param(p, "p1").unwrap_or_default();
|
||||
let p2 = point_param(p, "p2").unwrap_or_default();
|
||||
let a = fillet_corner(s, h1, p1, h2, p2, r, None, bool_or(p, "trim", true))?;
|
||||
Ok(json!({ "arc": a.map(|h| h.hex()) }))
|
||||
}
|
||||
|
||||
pub(super) fn run_chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cut = match (p.get("length").and_then(Value::as_f64), p.get("angle").and_then(Value::as_f64)) {
|
||||
(Some(len), Some(deg)) if len.is_finite() && len >= 0.0 && deg.is_finite() && (0.0..180.0).contains(°) => {
|
||||
ChamferCut::Angle(len, deg.to_radians())
|
||||
}
|
||||
(None, None) => {
|
||||
let d1 = f64_or(p, "d1", s.doc()?.header.f64("CHAMFERA", 0.0));
|
||||
ChamferCut::Dist(d1, f64_or(p, "d2", d1))
|
||||
}
|
||||
_ => return Err(bad("chamfer", "`length` (>= 0) and `angle` (degrees, 0 to 180) go together")),
|
||||
};
|
||||
if bool_or(p, "polyline", false) {
|
||||
let h = h_param(p, "handle").or_else(|| h_param(p, "h1")).ok_or_else(|| bad("chamfer", "`handle` (polyline) is required"))?;
|
||||
return Ok(json!({ "chamfered": chamfer_polyline(s, h, cut)? }));
|
||||
}
|
||||
let h1 = h_param(p, "h1").ok_or_else(|| bad("chamfer", "`h1` is required"))?;
|
||||
let h2 = h_param(p, "h2").ok_or_else(|| bad("chamfer", "`h2` is required"))?;
|
||||
let p1 = point_param(p, "p1").unwrap_or_default();
|
||||
let p2 = point_param(p, "p2").unwrap_or_default();
|
||||
let a = fillet_corner(s, h1, p1, h2, p2, 0.0, Some(cut), bool_or(p, "trim", true))?;
|
||||
Ok(json!({ "line": a.map(|h| h.hex()) }))
|
||||
}
|
||||
|
||||
/// A FILLET/CHAMFER option prompt opened from the select prompts.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum FilletAsk {
|
||||
/// "Enter Trim mode option [Trim/No trim]" (TRIMMODE).
|
||||
Trim,
|
||||
/// CHAMFER "Enter trim method [Distance/Angle]" (CHAMMODE).
|
||||
Method,
|
||||
/// CHAMFER Angle: the length on the first line (CHAMFERC), then the angle (CHAMFERD).
|
||||
Length,
|
||||
Angle,
|
||||
}
|
||||
|
||||
pub(super) struct FilletM {
|
||||
chamfer: bool,
|
||||
first: Option<(Handle, Vec2)>,
|
||||
/// Asking for the radius (FILLET) or a chamfer distance: 1 = first, 2 = second.
|
||||
asking: u8,
|
||||
polyline: bool,
|
||||
ask: Option<FilletAsk>,
|
||||
/// Multiple: keep asking for pairs until Enter.
|
||||
multiple: bool,
|
||||
/// Undo: the drawing before each fillet/chamfer made in this command.
|
||||
undo: Vec<std::sync::Arc<cadcraft_doc::Drawing>>,
|
||||
}
|
||||
|
||||
impl FilletM {
|
||||
pub(super) fn new(_s: &Session, chamfer: bool) -> Self {
|
||||
FilletM { chamfer, first: None, asking: 0, polyline: false, ask: None, multiple: false, undo: Vec::new() }
|
||||
}
|
||||
|
||||
/// One fillet/chamfer done: carry on in Multiple mode, else finish.
|
||||
fn next(&mut self, s: &mut Session, before: std::sync::Arc<cadcraft_doc::Drawing>) -> Step {
|
||||
self.undo.push(before);
|
||||
self.first = None;
|
||||
self.polyline = false;
|
||||
s.set_selection(Vec::new());
|
||||
if self.multiple { Step::Continue } else { Step::Done }
|
||||
}
|
||||
}
|
||||
|
||||
/// TRIMMODE: FILLET and CHAMFER trim or extend the selected objects to the new corner (default).
|
||||
fn trim_mode(s: &Session) -> bool {
|
||||
s.doc().map(|d| d.header.i64("TRIMMODE", 1) != 0).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// The chamfer CHAMFER uses now: two distances, or length and angle when CHAMMODE = 1.
|
||||
fn chamfer_cut(s: &Session) -> Result<ChamferCut> {
|
||||
let h = &s.doc()?.header;
|
||||
Ok(if h.i64("CHAMMODE", 0) == 1 {
|
||||
ChamferCut::Angle(h.f64("CHAMFERC", 0.0), h.f64("CHAMFERD", 0.0))
|
||||
} else {
|
||||
ChamferCut::Dist(h.f64("CHAMFERA", 0.0), h.f64("CHAMFERB", 0.0))
|
||||
})
|
||||
}
|
||||
|
||||
impl Interactive for FilletM {
|
||||
fn name(&self) -> &'static str {
|
||||
if self.chamfer { "CHAMFER" } else { "FILLET" }
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.set_selection(Vec::new());
|
||||
let trim = trim_mode(s);
|
||||
let d = s.doc()?;
|
||||
let msg = if self.chamfer {
|
||||
let mode = if trim { "TRIM" } else { "NOTRIM" };
|
||||
if d.header.i64("CHAMMODE", 0) == 1 {
|
||||
let (au, ap) = (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0));
|
||||
let a = crate::units::format_angle(d.header.f64("CHAMFERD", 0.0), au, ap);
|
||||
format!("({mode} mode) Current chamfer Length = {:.4}, Angle = {a}", d.header.f64("CHAMFERC", 0.0))
|
||||
} else {
|
||||
format!("({mode} mode) Current chamfer Dist1 = {:.4}, Dist2 = {:.4}", d.header.f64("CHAMFERA", 0.0), d.header.f64("CHAMFERB", 0.0))
|
||||
}
|
||||
} else {
|
||||
format!("Current settings: Mode = {}, Radius = {:.4}", if trim { "TRIM" } else { "NOTRIM" }, d.header.f64("FILLETRAD", 0.0))
|
||||
};
|
||||
s.echo(msg);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
// The radius and distances are remembered in the drawing (FILLETRAD, CHAMFERA/B) and
|
||||
// offered as defaults; the second chamfer distance defaults to the first.
|
||||
if self.asking > 0 {
|
||||
let hdr = |k: &str| s.doc().map(|d| d.header.f64(k, 0.0)).unwrap_or(0.0);
|
||||
let (msg, v) = match (self.chamfer, self.asking) {
|
||||
(false, _) => ("Specify fillet radius", hdr("FILLETRAD")),
|
||||
(true, 1) => ("Specify first chamfer distance", hdr("CHAMFERA")),
|
||||
(true, _) => ("Specify second chamfer distance", hdr("CHAMFERA")),
|
||||
};
|
||||
return Prompt::new(msg, Accept::NUMBER).default(format!("{v:.4}"));
|
||||
}
|
||||
let hdr_i = |k: &str, dflt: i64| s.doc().map(|d| d.header.i64(k, dflt)).unwrap_or(dflt);
|
||||
match self.ask {
|
||||
Some(FilletAsk::Trim) => {
|
||||
let cur = if trim_mode(s) { "Trim" } else { "No trim" };
|
||||
return Prompt::new("Enter Trim mode option", Accept::TEXT).kw(&["Trim", "No trim"]).default(cur);
|
||||
}
|
||||
Some(FilletAsk::Method) => {
|
||||
let cur = if hdr_i("CHAMMODE", 0) == 1 { "Angle" } else { "Distance" };
|
||||
return Prompt::new("Enter trim method", Accept::TEXT).kw(&["Distance", "Angle"]).default(cur);
|
||||
}
|
||||
Some(FilletAsk::Length) => {
|
||||
let v = s.doc().map(|d| d.header.f64("CHAMFERC", 0.0)).unwrap_or(0.0);
|
||||
return Prompt::new("Specify chamfer length on the first line", Accept::NUMBER).default(format!("{v:.4}"));
|
||||
}
|
||||
Some(FilletAsk::Angle) => {
|
||||
let (a, au, ap) =
|
||||
s.doc().map(|d| (d.header.f64("CHAMFERD", 0.0), d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0.0, 0, 0));
|
||||
return Prompt::new("Specify chamfer angle from the first line", Accept::NUMBER).default(crate::units::format_angle(a, au, ap));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if self.polyline {
|
||||
return Prompt::new("Select 2D polyline", Accept::POINT);
|
||||
}
|
||||
match (self.first, self.chamfer) {
|
||||
(None, false) => Prompt::new("Select first object", Accept::POINT).kw(&["Undo", "Polyline", "Radius", "Trim", "Multiple"]),
|
||||
(None, true) => {
|
||||
Prompt::new("Select first line", Accept::POINT).kw(&["Undo", "Polyline", "Distance", "Angle", "Trim", "mEthod", "Multiple"])
|
||||
}
|
||||
(Some(_), false) => Prompt::new("Select second object or shift-select to apply corner", Accept::POINT).kw(&["Radius"]),
|
||||
(Some(_), true) => Prompt::new("Select second line or shift-select to apply corner", Accept::POINT).kw(&["Distance", "Angle", "Method"]),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.asking > 0 {
|
||||
// Enter keeps the default.
|
||||
let v = match &i {
|
||||
Input::Text(t) => Some(
|
||||
number(t).filter(|v| v.is_finite() && *v >= 0.0).ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?,
|
||||
),
|
||||
Input::Enter => None,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let d = s.doc_mut()?;
|
||||
match (self.chamfer, self.asking) {
|
||||
(false, _) => {
|
||||
if let Some(v) = v {
|
||||
d.header.set_f64("FILLETRAD", v);
|
||||
}
|
||||
self.asking = 0;
|
||||
}
|
||||
(true, 1) => {
|
||||
if let Some(v) = v {
|
||||
d.header.set_f64("CHAMFERA", v);
|
||||
}
|
||||
self.asking = 2;
|
||||
}
|
||||
(true, _) => {
|
||||
let v = v.unwrap_or_else(|| d.header.f64("CHAMFERA", 0.0));
|
||||
d.header.set_f64("CHAMFERB", v);
|
||||
self.asking = 0;
|
||||
}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if let Some(ask) = self.ask {
|
||||
return self.option(s, ask, i);
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(k) if k == "Radius" || k == "Distance" => {
|
||||
if self.chamfer {
|
||||
s.doc_mut()?.header.set_i64("CHAMMODE", 0);
|
||||
}
|
||||
self.asking = 1;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Angle" && self.chamfer => {
|
||||
s.doc_mut()?.header.set_i64("CHAMMODE", 1);
|
||||
self.ask = Some(FilletAsk::Length);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "mEthod" || k == "Method" => {
|
||||
self.ask = Some(FilletAsk::Method);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Trim" => {
|
||||
self.ask = Some(FilletAsk::Trim);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Multiple" => {
|
||||
self.multiple = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Polyline" => {
|
||||
self.polyline = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
match self.undo.pop() {
|
||||
Some(doc) => {
|
||||
// Settings changed meanwhile stay.
|
||||
let header = s.doc()?.header.clone();
|
||||
s.state_mut()?.doc = doc;
|
||||
s.doc_mut()?.header = header;
|
||||
}
|
||||
None => s.echo("Nothing to undo."),
|
||||
}
|
||||
self.first = None;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
let Some(h) = crate::select::pick(s.doc()?, &space, p, ap) else {
|
||||
s.echo("*Invalid selection*");
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
if self.polyline {
|
||||
let before = s.state()?.doc.clone();
|
||||
if self.chamfer {
|
||||
let n = chamfer_polyline(s, h, chamfer_cut(s)?)?;
|
||||
s.echo(format!("{n} lines were chamfered"));
|
||||
} else {
|
||||
let r = s.doc()?.header.f64("FILLETRAD", 0.0);
|
||||
let n = crate::cmd::modify2::fillet_polyline(s, h, r)?;
|
||||
s.echo(format!("{n} lines were filleted"));
|
||||
}
|
||||
return Ok(self.next(s, before));
|
||||
}
|
||||
match self.first {
|
||||
None => {
|
||||
self.first = Some((h, p));
|
||||
s.set_selection(vec![h]);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some((h1, p1)) => {
|
||||
let r = s.doc()?.header.f64("FILLETRAD", 0.0);
|
||||
let cut = if self.chamfer { Some(chamfer_cut(s)?) } else { None };
|
||||
let before = s.state()?.doc.clone();
|
||||
let trim = trim_mode(s);
|
||||
match fillet_corner(s, h1, p1, h, p, r, cut, trim) {
|
||||
Ok(_) => Ok(self.next(s, before)),
|
||||
Err(e) => {
|
||||
s.echo(e.to_string());
|
||||
self.first = None;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FilletM {
|
||||
/// Input at a Trim / Method / Length / Angle option prompt; Enter keeps the current value.
|
||||
fn option(&mut self, s: &mut Session, ask: FilletAsk, i: Input) -> Result<Step> {
|
||||
let text = match &i {
|
||||
Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(),
|
||||
Input::Enter => String::new(),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let invalid = || EngineError::Other("Invalid option keyword.".into());
|
||||
let flag = |yes: &str, no: &str| -> Result<Option<i64>> {
|
||||
if text.is_empty() {
|
||||
Ok(None)
|
||||
} else if text.starts_with(yes) {
|
||||
Ok(Some(1))
|
||||
} else if text.starts_with(no) {
|
||||
Ok(Some(0))
|
||||
} else {
|
||||
Err(invalid())
|
||||
}
|
||||
};
|
||||
let au = s.angle_settings();
|
||||
let d = s.doc_mut()?;
|
||||
self.ask = None;
|
||||
match ask {
|
||||
FilletAsk::Trim => {
|
||||
if let Some(v) = flag("t", "n")? {
|
||||
d.header.set_i64("TRIMMODE", v);
|
||||
}
|
||||
}
|
||||
FilletAsk::Method => {
|
||||
if let Some(v) = flag("a", "d")? {
|
||||
d.header.set_i64("CHAMMODE", v);
|
||||
}
|
||||
}
|
||||
FilletAsk::Length => {
|
||||
if !text.is_empty() {
|
||||
let v = number(&text)
|
||||
.filter(|v| v.is_finite() && *v >= 0.0)
|
||||
.ok_or_else(|| EngineError::Other("Requires a non-negative distance.".into()))?;
|
||||
d.header.set_f64("CHAMFERC", v);
|
||||
}
|
||||
self.ask = Some(FilletAsk::Angle);
|
||||
}
|
||||
FilletAsk::Angle => {
|
||||
if !text.is_empty() {
|
||||
let a = au
|
||||
.amount(&text)
|
||||
.filter(|a| a.is_finite() && *a >= 0.0 && *a < std::f64::consts::PI)
|
||||
.ok_or_else(|| EngineError::Other("Requires an angle from 0 to 180 degrees.".into()))?;
|
||||
d.header.set_f64("CHAMFERD", a);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
/// How CHAMFER cuts a corner: two distances, or a length on the first line and an angle
|
||||
/// (radians) from it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum ChamferCut {
|
||||
Dist(f64, f64),
|
||||
Angle(f64, f64),
|
||||
}
|
||||
|
||||
impl ChamferCut {
|
||||
/// The distances back from the corner along the first and second lines, which leave it
|
||||
/// along the unit directions `u1` and `u2`.
|
||||
fn distances(self, u1: Vec2, u2: Vec2) -> Result<(f64, f64)> {
|
||||
match self {
|
||||
ChamferCut::Dist(a, b) => Ok((a, b)),
|
||||
ChamferCut::Angle(len, ang) => {
|
||||
// Triangle corner–t1–t2: the corner angle and the chamfer angle at t1 give t2 by
|
||||
// the law of sines.
|
||||
let corner = u1.dot(u2).clamp(-1.0, 1.0).acos();
|
||||
let s = (corner + ang).sin();
|
||||
let d2 = len * ang.sin() / s;
|
||||
if corner + ang >= std::f64::consts::PI - 1e-9 || s.abs() < 1e-12 || !d2.is_finite() || d2 < 0.0 {
|
||||
return Err(EngineError::Other("The chamfer angle does not fit this corner.".into()));
|
||||
}
|
||||
Ok((len, d2))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// FILLET (`cut` None) or CHAMFER two objects, honouring TRIMMODE: with `trim` false the new
|
||||
/// arc or line is added and both objects are left as they were.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn fillet_corner(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64, cut: Option<ChamferCut>, trim: bool) -> Result<Option<Handle>> {
|
||||
let dist = cut.map(|c| move |u1: Vec2, u2: Vec2| c.distances(u1, u2));
|
||||
if let Some(made) =
|
||||
crate::cmd::modify2::fillet_pline(s, h1, p1, h2, p2, r, dist.as_ref().map(|f| f as crate::cmd::modify2::ChamferDistances), trim)?
|
||||
{
|
||||
return Ok(made);
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let k1 = d.entity(h1).map(|e| e.kind.clone());
|
||||
let k2 = d.entity(h2).map(|e| e.kind.clone());
|
||||
let chamfer = match cut {
|
||||
None => None,
|
||||
Some(c) => {
|
||||
let l = |h| d.entity(h).and_then(|e| as_line(e)).ok_or_else(|| EngineError::Other(CHAMFER_LINES_ONLY.into()));
|
||||
let (l1, l2) = (l(h1)?, l(h2)?);
|
||||
let (x, _, _) = line_line_infinite(l1.a, l1.b, l2.a, l2.b).ok_or_else(|| EngineError::Other("Lines are parallel.".into()))?;
|
||||
// The direction along each line towards its picked side.
|
||||
let dir = |l: &Line, p: Vec2| if (l.a - x).dot(p - x) >= (l.b - x).dot(p - x) { (l.a - x).normalized() } else { (l.b - x).normalized() };
|
||||
Some(c.distances(dir(&l1, p1), dir(&l2, p2))?)
|
||||
}
|
||||
};
|
||||
let made = fillet_lines(s, h1, p1, h2, p2, r, chamfer)?;
|
||||
if !trim {
|
||||
let doc = s.doc_mut()?;
|
||||
for (h, k) in [(h1, k1), (h2, k2)] {
|
||||
if let Some(k) = k {
|
||||
doc.modify_entity(h, |e| e.kind = k)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(made)
|
||||
}
|
||||
|
||||
/// CHAMFER Polyline: cut every corner between two straight segments of a 2D polyline. Returns
|
||||
/// how many corners were chamfered.
|
||||
fn chamfer_polyline(s: &mut Session, h: Handle, cut: ChamferCut) -> Result<usize> {
|
||||
let d = s.doc()?;
|
||||
let e = d.entity(h).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
if d.layer(&e.common.layer).is_some_and(|l| l.locked) {
|
||||
return Err(EngineError::Other("The object is on a locked layer.".into()));
|
||||
}
|
||||
let EntityKind::LwPolyline(pl) = &e.kind else { return Err(EngineError::Other("Select a 2D polyline.".into())) };
|
||||
let vs = &pl.vertices;
|
||||
let n = vs.len();
|
||||
if n < 3 {
|
||||
return Ok(0);
|
||||
}
|
||||
let at = |i: usize| vs.get(i % n).copied().unwrap_or_default();
|
||||
// Distances cut back from each corner along the incoming and outgoing segments.
|
||||
let mut cuts = vec![(0.0f64, 0.0f64); n];
|
||||
for (i, c) in cuts.iter_mut().enumerate() {
|
||||
if !pl.closed && (i == 0 || i + 1 == n) {
|
||||
continue;
|
||||
}
|
||||
let (prev, cur, next) = (at(i + n - 1), at(i), at(i + 1));
|
||||
if prev.bulge.abs() > 1e-12 || cur.bulge.abs() > 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let u1 = (prev.p - cur.p).normalized();
|
||||
let u2 = (next.p - cur.p).normalized();
|
||||
if u1 == Vec2::ZERO || u2 == Vec2::ZERO || u1.dot(u2) < -1.0 + 1e-9 {
|
||||
continue;
|
||||
}
|
||||
if let Ok(ab) = cut.distances(u1, u2) {
|
||||
*c = ab;
|
||||
}
|
||||
}
|
||||
// Drop chamfers that do not fit their segments.
|
||||
for i in 0..n {
|
||||
let j = (i + 1) % n;
|
||||
let seg = at(i).p.dist(at(j).p);
|
||||
let (out_i, in_j) = (cuts.get(i).map_or(0.0, |c| c.1), cuts.get(j).map_or(0.0, |c| c.0));
|
||||
if out_i + in_j > seg + 1e-9 {
|
||||
for k in [i, j] {
|
||||
if let Some(c) = cuts.get_mut(k) {
|
||||
*c = (0.0, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<PolyVertex> = Vec::with_capacity(n * 2);
|
||||
let mut count = 0;
|
||||
for i in 0..n {
|
||||
let v = at(i);
|
||||
let (a, b) = cuts.get(i).copied().unwrap_or_default();
|
||||
if a <= 1e-12 && b <= 1e-12 {
|
||||
out.push(v);
|
||||
continue;
|
||||
}
|
||||
let t1 = v.p + (at(i + n - 1).p - v.p).normalized() * a;
|
||||
let t2 = v.p + (at(i + 1).p - v.p).normalized() * b;
|
||||
out.push(PolyVertex { p: t1, bulge: 0.0, ..v });
|
||||
out.push(PolyVertex { p: t2, bulge: 0.0, ..v });
|
||||
count += 1;
|
||||
}
|
||||
let mut npl = pl.clone();
|
||||
npl.vertices = out;
|
||||
s.doc_mut()?.modify_entity(h, |e| e.kind = EntityKind::LwPolyline(npl))?;
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
use super::*;
|
||||
|
||||
/// Whether OFFSET can work on this kind of object at all.
|
||||
fn offsettable(k: &EntityKind) -> bool {
|
||||
matches!(
|
||||
k,
|
||||
EntityKind::Line(_)
|
||||
| EntityKind::Circle(_)
|
||||
| EntityKind::Arc(_)
|
||||
| EntityKind::Ellipse(_)
|
||||
| EntityKind::LwPolyline(_)
|
||||
| EntityKind::Spline(_)
|
||||
| EntityKind::XLine(_)
|
||||
| EntityKind::Ray(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// Offset an entity by `dist` towards `side`. Returns the new geometry.
|
||||
pub(crate) fn offset_kind(k: &EntityKind, dist: f64, side: Vec2) -> Option<EntityKind> {
|
||||
match k {
|
||||
EntityKind::Line(l) => {
|
||||
let ln = Line::new(l.a.xy(), l.b.xy());
|
||||
let sgn = if ln.side(side) >= 0.0 { 1.0 } else { -1.0 };
|
||||
let o = ln.offset(dist * sgn);
|
||||
Some(line(o.a, o.b))
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
let inside = side.dist(c.center.xy()) < c.radius;
|
||||
let r = if inside { c.radius - dist } else { c.radius + dist };
|
||||
(r > 1e-12).then(|| circle(c.center.xy(), r))
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let inside = side.dist(a.center.xy()) < a.radius;
|
||||
let r = if inside { a.radius - dist } else { a.radius + dist };
|
||||
(r > 1e-12).then(|| arc(&Arc::new(a.center.xy(), r, a.start, a.end)))
|
||||
}
|
||||
EntityKind::Ellipse(e) => {
|
||||
// Approximate with a polyline offset of the tessellation.
|
||||
let ge = cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end };
|
||||
// Inside = on the centre side of the full ellipse, even for an open arc.
|
||||
let inside = {
|
||||
let a = e.major.xy().len();
|
||||
let b = a * e.ratio.abs();
|
||||
let u = e.major.xy().normalized();
|
||||
let d = side - e.center.xy();
|
||||
let (x, y) = (d.dot(u) * b, d.dot(u.perp()) * a);
|
||||
x * x + y * y < (a * b) * (a * b)
|
||||
};
|
||||
// offset_points shifts along the left normal, which points into a counter-clockwise ellipse.
|
||||
let mut pts = Vec::new();
|
||||
ge.tessellate(e.major.xy().len() * 1e-3, &mut pts);
|
||||
let off = offset_points(&pts, if inside { dist } else { -dist }, ge.is_full());
|
||||
Some(lwpoly(off.into_iter().map(PolyVertex::new).collect(), ge.is_full()))
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
// Side: compare with the nearest segment.
|
||||
let segs = pl.segments();
|
||||
let nearest = segs.iter().min_by(|a, b| a.closest(side).dist(side).total_cmp(&b.closest(side).dist(side)))?;
|
||||
let c = nearest.closest(side);
|
||||
let t = nearest.tangent(0.5);
|
||||
let left = t.cross(side - c) > 0.0;
|
||||
let d = if left { dist } else { -dist };
|
||||
offset_polyline(&pl, d).map(|vs| {
|
||||
let mut k = lwpoly(vs, p.closed);
|
||||
if let EntityKind::LwPolyline(n) = &mut k {
|
||||
n.const_width = p.const_width;
|
||||
}
|
||||
k
|
||||
})
|
||||
}
|
||||
EntityKind::Spline(sp) => {
|
||||
let pts = sp.tessellate(1e-3);
|
||||
let n = pts.len();
|
||||
let mid = pts.get(n / 2).copied()?;
|
||||
let next = pts.get((n / 2 + 1).min(n.saturating_sub(1))).copied()?;
|
||||
let left = (next - mid).cross(side - mid) > 0.0;
|
||||
let off = offset_points(&pts, if left { dist } else { -dist }, sp.closed);
|
||||
// A closed spline stays closed (periodic), smooth across its start point.
|
||||
Some(EntityKind::Spline(cadcraft_geom::Spline::from_fit(&decimate(&off, 40), sp.closed)))
|
||||
}
|
||||
EntityKind::XLine(r) | EntityKind::Ray(r) => {
|
||||
let ln = Line::new(r.base.xy(), r.base.xy() + r.dir.xy());
|
||||
let sgn = if ln.side(side) >= 0.0 { 1.0 } else { -1.0 };
|
||||
let o = ln.offset(dist * sgn);
|
||||
let rl = cadcraft_doc::RayLine { base: v3(o.a), dir: r.dir };
|
||||
Some(if matches!(k, EntityKind::XLine(_)) { EntityKind::XLine(rl) } else { EntityKind::Ray(rl) })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn decimate(pts: &[Vec2], max: usize) -> Vec<Vec2> {
|
||||
if pts.len() <= max {
|
||||
return pts.to_vec();
|
||||
}
|
||||
let step = pts.len() as f64 / max as f64;
|
||||
let mut v: Vec<Vec2> = (0..max).filter_map(|i| pts.get((i as f64 * step) as usize).copied()).collect();
|
||||
if let Some(l) = pts.last() {
|
||||
v.push(*l);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn offset_points(pts: &[Vec2], d: f64, closed: bool) -> Vec<Vec2> {
|
||||
let n = pts.len();
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let p = pts.get(i).copied().unwrap_or_default();
|
||||
let prev = if i > 0 {
|
||||
pts.get(i - 1)
|
||||
} else if closed {
|
||||
pts.get(n.saturating_sub(2))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let next = if i + 1 < n {
|
||||
pts.get(i + 1)
|
||||
} else if closed {
|
||||
pts.get(1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let t = match (prev, next) {
|
||||
(Some(a), Some(b)) => (*b - *a).normalized(),
|
||||
(None, Some(b)) => (*b - p).normalized(),
|
||||
(Some(a), None) => (p - *a).normalized(),
|
||||
_ => Vec2::X,
|
||||
};
|
||||
out.push(p + t.perp() * d);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Offset a polyline: offset each segment, then join neighbours at their intersections.
|
||||
pub(crate) fn offset_polyline(pl: &Polyline, d: f64) -> Option<Vec<PolyVertex>> {
|
||||
let segs: Vec<Segment> = pl.segments().iter().filter_map(|s| s.offset(d)).collect();
|
||||
if segs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let n = segs.len();
|
||||
// Where each offset segment starts after joining it to the previous one.
|
||||
let mut starts: Vec<Vec2> = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let s = segs.get(i)?;
|
||||
starts.push(if i == 0 && !pl.closed {
|
||||
s.start()
|
||||
} else {
|
||||
let prev = segs.get((i + n - 1) % n)?;
|
||||
join_point(prev, s).unwrap_or(s.start())
|
||||
});
|
||||
}
|
||||
let mut verts: Vec<PolyVertex> = Vec::new();
|
||||
for i in 0..n {
|
||||
let s = segs.get(i)?;
|
||||
let start = *starts.get(i)?;
|
||||
let end = if i + 1 < n {
|
||||
*starts.get(i + 1)?
|
||||
} else if pl.closed {
|
||||
*starts.first()?
|
||||
} else {
|
||||
s.end()
|
||||
};
|
||||
let bulge = match s {
|
||||
Segment::Line(_) => 0.0,
|
||||
Segment::Arc { arc, ccw } => {
|
||||
// The joined endpoints may have moved along the circle: take the sweep between them.
|
||||
let sweep = if start.near(end, 1e-9) {
|
||||
arc.sweep()
|
||||
} else if *ccw {
|
||||
ccw_sweep(arc.center.angle_to(start), arc.center.angle_to(end))
|
||||
} else {
|
||||
ccw_sweep(arc.center.angle_to(end), arc.center.angle_to(start))
|
||||
};
|
||||
cadcraft_geom::arc_to_bulge(if *ccw { sweep } else { -sweep })
|
||||
}
|
||||
};
|
||||
verts.push(PolyVertex { p: start, bulge, start_width: 0.0, end_width: 0.0 });
|
||||
}
|
||||
if !pl.closed {
|
||||
verts.push(PolyVertex::new(segs.last()?.end()));
|
||||
}
|
||||
Some(verts)
|
||||
}
|
||||
|
||||
fn join_point(a: &Segment, b: &Segment) -> Option<Vec2> {
|
||||
if a.end().near(b.start(), 1e-9) {
|
||||
return Some(b.start());
|
||||
}
|
||||
let hits = intersect_ext(a, b, true);
|
||||
hits.into_iter().min_by(|x, y| x.dist(a.end()).total_cmp(&y.dist(a.end())))
|
||||
}
|
||||
|
||||
pub(super) fn run_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("offset", "`handle` is required"))?;
|
||||
let dist = f64_req("offset", p, "distance")?;
|
||||
let side = point_req("offset", p, "side")?;
|
||||
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| bad("offset", "no such object"))?;
|
||||
let k = offset_kind(&e.kind, dist.abs(), side).ok_or_else(|| bad("offset", "cannot offset that object"))?;
|
||||
let current_layer = match str_param(p, "layer").map(str::to_ascii_lowercase).as_deref() {
|
||||
None | Some("source") => false,
|
||||
Some("current") => true,
|
||||
Some(_) => return Err(bad("offset", "`layer` is \"source\" or \"current\"")),
|
||||
};
|
||||
let nh = add_offset(s, &e, k, current_layer, bool_or(p, "erase", false))?;
|
||||
Ok(json!({ "handle": nh.hex() }))
|
||||
}
|
||||
|
||||
/// Add the offset copy `k` of `src` (on the current layer instead of the source's when
|
||||
/// `current_layer`), then erase the source when `erase` and its layer is not locked.
|
||||
fn add_offset(s: &mut Session, src: &Entity, k: EntityKind, current_layer: bool, erase: bool) -> Result<Handle> {
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
let mut common = src.common.clone();
|
||||
if current_layer {
|
||||
common.layer = d.header.str("CLAYER", "0");
|
||||
}
|
||||
let nh = d.new_handle();
|
||||
if let Some(st) = d.space_mut(&space) {
|
||||
st.push(Entity { handle: nh, common, kind: k });
|
||||
}
|
||||
let locked = d.layer(&src.common.layer).is_some_and(|l| l.locked);
|
||||
if erase && !locked {
|
||||
d.remove_entity(src.handle);
|
||||
}
|
||||
Ok(nh)
|
||||
}
|
||||
|
||||
/// An OFFSET option prompt opened from "Specify offset distance".
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum OffsetAsk {
|
||||
Erase,
|
||||
Layer,
|
||||
}
|
||||
|
||||
pub(super) struct OffsetM {
|
||||
dist: Option<f64>,
|
||||
distance_first: Option<Vec2>,
|
||||
through: bool,
|
||||
picked: Option<Handle>,
|
||||
ask: Option<OffsetAsk>,
|
||||
/// Multiple mode: each side point offsets the newest copy again, until Enter.
|
||||
multiple: bool,
|
||||
/// Undo: the drawing before each offset, with the object picked then.
|
||||
undo: Vec<(std::sync::Arc<cadcraft_doc::Drawing>, Option<Handle>)>,
|
||||
}
|
||||
|
||||
impl OffsetM {
|
||||
pub(super) fn new(s: &Session) -> Self {
|
||||
let _ = s;
|
||||
OffsetM { dist: None, distance_first: None, through: false, picked: None, ask: None, multiple: false, undo: Vec::new() }
|
||||
}
|
||||
|
||||
fn set_distance(&mut self, s: &mut Session, distance: f64) -> Result<()> {
|
||||
if !(distance.is_finite() && distance > 0.0) {
|
||||
return Err(EngineError::Other("Requires a positive distance.".into()));
|
||||
}
|
||||
s.doc_mut()?.header.set_f64("OFFSETDIST", distance);
|
||||
self.dist = Some(distance);
|
||||
self.distance_first = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// OFFSET's remembered options: erase the source (OFFSETERASE) and put copies on the current
|
||||
/// layer instead of the source's (kept in the drawing header beside it).
|
||||
fn offset_options(s: &Session) -> (bool, bool) {
|
||||
s.doc().map(|d| (d.header.i64("OFFSETERASE", 0) != 0, d.header.i64("OFFSETLAYER", 0) != 0)).unwrap_or((false, false))
|
||||
}
|
||||
|
||||
impl Interactive for OffsetM {
|
||||
fn name(&self) -> &'static str {
|
||||
"OFFSET"
|
||||
}
|
||||
/// Starts OFFSET, keeping a single preselected offsettable object (issue #1610) so the prompt
|
||||
/// sequence goes distance then side instead of asking for the object again; any other
|
||||
/// selection is cleared and the object loop asks for it. With PICKFIRST off the selection is
|
||||
/// likewise ignored and cleared.
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
// Exactly one offsettable object selected before the command is taken at once, so only the
|
||||
// side is asked for; the object loop stays for the ones after it (issue #1610). A multiple
|
||||
// selection is left for the select prompt, as AutoCAD does.
|
||||
let pre = s.selection();
|
||||
if s.settings.pickfirst
|
||||
&& let [h] = pre[..]
|
||||
&& s.doc()?.entity(h).is_some_and(|e| offsettable(&e.kind))
|
||||
{
|
||||
self.picked = Some(h);
|
||||
s.set_selection(vec![h]);
|
||||
} else {
|
||||
s.set_selection(Vec::new());
|
||||
}
|
||||
let (erase, current) = offset_options(s);
|
||||
s.echo(format!(
|
||||
"Current settings: Erase source={} Layer={} OFFSETGAPTYPE=0",
|
||||
if erase { "Yes" } else { "No" },
|
||||
if current { "Current" } else { "Source" }
|
||||
));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
let (erase, current) = offset_options(s);
|
||||
match self.ask {
|
||||
Some(OffsetAsk::Erase) => {
|
||||
return Prompt::new("Erase source object after offsetting?", Accept::TEXT).kw(&["Yes", "No"]).default(if erase {
|
||||
"Yes"
|
||||
} else {
|
||||
"No"
|
||||
});
|
||||
}
|
||||
Some(OffsetAsk::Layer) => {
|
||||
return Prompt::new("Enter layer option for offset objects", Accept::TEXT).kw(&["Current", "Source"]).default(if current {
|
||||
"Current"
|
||||
} else {
|
||||
"Source"
|
||||
});
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let last = s.doc().map(|d| d.header.f64("OFFSETDIST", -1.0)).unwrap_or(-1.0);
|
||||
if self.dist.is_none()
|
||||
&& !self.through
|
||||
&& let Some(base) = self.distance_first
|
||||
{
|
||||
return Prompt::new("Specify second point", Accept::POINT).base(base);
|
||||
}
|
||||
match (self.dist.is_some() || self.through, self.picked) {
|
||||
(false, _) => Prompt::new("Specify offset distance", Accept::POINT_OR_NUMBER).kw(&["Through", "Erase", "Layer"]).default(if last < 0.0 {
|
||||
"Through".to_string()
|
||||
} else {
|
||||
format!("{last:.4}")
|
||||
}),
|
||||
(true, None) => Prompt::new("Select object to offset", Accept::POINT).kw(&["Exit", "Undo"]).default("Exit"),
|
||||
(true, Some(_)) if self.multiple => {
|
||||
let msg = if self.through { "Specify through point" } else { "Specify point on side to offset" };
|
||||
Prompt::new(msg, Accept::POINT).kw(&["Exit", "Undo"]).default("next object")
|
||||
}
|
||||
(true, Some(_)) if self.through => Prompt::new("Specify through point", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"),
|
||||
(true, Some(_)) => Prompt::new("Specify point on side to offset", Accept::POINT).kw(&["Exit", "Multiple", "Undo"]).default("Exit"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(ask) = self.ask {
|
||||
let word = match &i {
|
||||
Input::Keyword(k) | Input::Text(k) => k.trim().to_ascii_lowercase(),
|
||||
Input::Enter => String::new(),
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
let (key, yes, no) = match ask {
|
||||
OffsetAsk::Erase => ("OFFSETERASE", "y", "n"),
|
||||
OffsetAsk::Layer => ("OFFSETLAYER", "c", "s"),
|
||||
};
|
||||
let v = if word.is_empty() {
|
||||
None
|
||||
} else if word.starts_with(yes) {
|
||||
Some(1)
|
||||
} else if word.starts_with(no) {
|
||||
Some(0)
|
||||
} else {
|
||||
return Err(EngineError::Other("Invalid option keyword.".into()));
|
||||
};
|
||||
if let Some(v) = v {
|
||||
s.doc_mut()?.header.set_i64(key, v);
|
||||
}
|
||||
self.ask = None;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
if self.dist.is_none() && !self.through {
|
||||
match i {
|
||||
Input::Text(t) => {
|
||||
let d = number(&t).ok_or_else(|| EngineError::Other("Requires a positive distance.".into()))?;
|
||||
self.set_distance(s, d)?;
|
||||
}
|
||||
Input::Point(p) => {
|
||||
if !p.is_finite() {
|
||||
return Err(EngineError::Other("Invalid point.".into()));
|
||||
}
|
||||
match self.distance_first {
|
||||
Some(base) => self.set_distance(s, base.dist(p))?,
|
||||
None => self.distance_first = Some(p),
|
||||
}
|
||||
}
|
||||
Input::Keyword(k) if k == "Through" => {
|
||||
self.through = true;
|
||||
self.distance_first = None;
|
||||
}
|
||||
Input::Keyword(k) if k == "Erase" => self.ask = Some(OffsetAsk::Erase),
|
||||
Input::Keyword(k) if k == "Layer" => self.ask = Some(OffsetAsk::Layer),
|
||||
Input::Enter => {
|
||||
self.distance_first = None;
|
||||
let last = s.doc()?.header.f64("OFFSETDIST", -1.0);
|
||||
if last > 0.0 {
|
||||
self.dist = Some(last);
|
||||
} else {
|
||||
self.through = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (self.picked, i) {
|
||||
(_, Input::Keyword(k)) if k == "Exit" => {
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, Input::Keyword(k)) if k == "Undo" => {
|
||||
match self.undo.pop() {
|
||||
Some((doc, picked)) => {
|
||||
// Settings changed meanwhile stay.
|
||||
let header = s.doc()?.header.clone();
|
||||
s.state_mut()?.doc = doc;
|
||||
s.doc_mut()?.header = header;
|
||||
self.picked = if self.multiple { picked } else { None };
|
||||
if self.picked.is_none() {
|
||||
self.multiple = false;
|
||||
}
|
||||
s.set_selection(self.picked.into_iter().collect());
|
||||
}
|
||||
None => s.echo("Nothing to undo."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(_), Input::Keyword(k)) if k == "Multiple" => {
|
||||
self.multiple = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(_), Input::Enter) if self.multiple => {
|
||||
// <next object>: back to selecting.
|
||||
self.multiple = false;
|
||||
self.picked = None;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, Input::Enter) => {
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(None, Input::Point(p)) => {
|
||||
let ap = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
match crate::select::pick(s.doc()?, &space, p, ap) {
|
||||
Some(h) => {
|
||||
self.picked = Some(h);
|
||||
s.set_selection(vec![h]);
|
||||
}
|
||||
None => s.echo("*Invalid selection*"),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(h), Input::Point(p)) => {
|
||||
let e = s.doc()?.entity(h).map(|e| (**e).clone());
|
||||
let mut made = None;
|
||||
if let Some(e) = e {
|
||||
let dist = match self.dist {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
let polys = crate::select::hit_polylines(s.doc()?, &e, 1e-4);
|
||||
polys
|
||||
.iter()
|
||||
.flat_map(|pl| pl.windows(2).filter_map(|w| Some(Line::new(*w.first()?, *w.get(1)?).dist(p))))
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
};
|
||||
match offset_kind(&e.kind, dist, p) {
|
||||
Some(k) => {
|
||||
let before = s.state()?.doc.clone();
|
||||
let (erase, current) = offset_options(s);
|
||||
made = Some(add_offset(s, &e, k, current, erase)?);
|
||||
self.undo.push((before, Some(h)));
|
||||
}
|
||||
None => s.echo("Cannot offset that object."),
|
||||
}
|
||||
}
|
||||
// Multiple mode carries on from the new copy.
|
||||
self.picked = if self.multiple { made.or(self.picked) } else { None };
|
||||
s.set_selection(self.picked.into_iter().collect());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let Some(base) = self.distance_first
|
||||
&& c.is_finite()
|
||||
{
|
||||
return vec![line(base, c)];
|
||||
}
|
||||
let (Some(h), Ok(d)) = (self.picked, s.doc()) else { return Vec::new() };
|
||||
let Some(e) = d.entity(h) else { return Vec::new() };
|
||||
let dist = match self.dist {
|
||||
Some(x) => x,
|
||||
None => crate::select::entity_distance(d, e, c, 1e-3),
|
||||
};
|
||||
offset_kind(&e.kind, dist, c).into_iter().collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,593 @@
|
||||
use super::break_join::join;
|
||||
use super::explode::explode;
|
||||
use super::transform::{erase, transform_entities};
|
||||
use super::*;
|
||||
|
||||
// ---------------- interactive ----------------
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(super) enum Op {
|
||||
Erase,
|
||||
Move,
|
||||
Copy,
|
||||
Rotate,
|
||||
Scale,
|
||||
Mirror,
|
||||
Explode,
|
||||
Join,
|
||||
}
|
||||
|
||||
/// Select objects, then collect the points/values the operation needs.
|
||||
pub(super) struct SelectThen {
|
||||
op: Op,
|
||||
sel: SelectPhase,
|
||||
objs: Vec<Handle>,
|
||||
pts: Vec<Vec2>,
|
||||
reference: bool,
|
||||
/// ROTATE Reference: the reference angle (radians), once known. Its first point, and the
|
||||
/// first of the two new-angle points, are kept in `ref_from`.
|
||||
ref_angle: Option<f64>,
|
||||
/// ROTATE Reference: the new angle is given by two points (the `Points` option).
|
||||
ref_points: bool,
|
||||
/// SCALE Reference: first point of a reference length given by two points.
|
||||
ref_from: Option<Vec2>,
|
||||
/// SCALE Reference: the reference length, once known.
|
||||
ref_len: Option<f64>,
|
||||
copy_mode: bool,
|
||||
ask_erase: bool,
|
||||
/// JOIN of a single arc or elliptical arc: the prompt asks for arcs to join to it, or cLose.
|
||||
join_source: Option<Handle>,
|
||||
}
|
||||
|
||||
impl SelectThen {
|
||||
pub(super) fn new(op: Op) -> Self {
|
||||
SelectThen {
|
||||
op,
|
||||
sel: SelectPhase::default(),
|
||||
objs: Vec::new(),
|
||||
pts: Vec::new(),
|
||||
reference: false,
|
||||
ref_angle: None,
|
||||
ref_points: false,
|
||||
ref_from: None,
|
||||
ref_len: None,
|
||||
copy_mode: false,
|
||||
ask_erase: false,
|
||||
join_source: None,
|
||||
}
|
||||
}
|
||||
fn apply(&mut self, s: &mut Session) -> Result<Step> {
|
||||
// Immediate operations once selection is done.
|
||||
match self.op {
|
||||
Op::Erase => {
|
||||
let n = erase(s, &self.objs)?;
|
||||
let _ = n;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Op::Explode => {
|
||||
explode(s, &self.objs)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Op::Join => {
|
||||
if let [h] = self.objs[..]
|
||||
&& s.doc()?.entity(h).is_some_and(|e| crate::cmd::modify2::closed_kind(&e.kind).is_some())
|
||||
{
|
||||
self.join_source = Some(h);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.join_now(s)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
|
||||
/// JOIN the selected objects and report what they became.
|
||||
fn join_now(&mut self, s: &mut Session) -> Result<Step> {
|
||||
match join(s, &self.objs) {
|
||||
Ok(h) => {
|
||||
// Objects outside the joined chain are kept, so count only the ones JOIN consumed
|
||||
// (and the source object an ellipse or spline join keeps).
|
||||
let d = s.doc()?;
|
||||
let n = self.objs.iter().filter(|o| d.entity(**o).is_none() || h == Some(**o)).count();
|
||||
let what = match h.and_then(|h| d.entity(h)).map(|e| &e.kind) {
|
||||
Some(EntityKind::Spline(_)) => "spline",
|
||||
Some(EntityKind::Ellipse(_)) => "ellipse",
|
||||
_ => "polyline",
|
||||
};
|
||||
s.echo(format!("{n} objects joined into 1 {what}"))
|
||||
}
|
||||
Err(e) => s.echo(e.to_string()),
|
||||
}
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
/// JOIN of a single arc or elliptical arc: more arcs picked join to it on Enter, cLose makes
|
||||
/// it its circle or full ellipse.
|
||||
fn join_source_input(&mut self, s: &mut Session, src: Handle, i: Input) -> Result<Step> {
|
||||
match i {
|
||||
Input::Keyword(k) if k == "cLose" => {
|
||||
let ellipse = matches!(s.doc()?.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_)));
|
||||
crate::cmd::modify2::join_close(s, &[src])?;
|
||||
s.echo(if ellipse { "The elliptical arc is now an ellipse." } else { "The arc is now a circle." });
|
||||
Ok(Step::Done)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
if let Some(h) = crate::cmd::curves::pick_at(s, p)
|
||||
&& !self.objs.contains(&h)
|
||||
{
|
||||
self.objs.push(h);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => self.join_now(s),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
|
||||
/// SCALE Reference: reference length (typed, or two points), then new length (typed, or a
|
||||
/// point measured from the base point). The scale factor is new length / reference length.
|
||||
fn scale_reference(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) };
|
||||
match (self.ref_len, self.ref_from, i) {
|
||||
(None, None, Input::Point(p)) => {
|
||||
self.ref_from = Some(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(None, None, Input::Text(t)) => {
|
||||
self.ref_len = Some(require_length(number(&t))?);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(None, None, Input::Enter) => {
|
||||
self.ref_len = Some(1.0);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(None, Some(a), Input::Point(p)) => {
|
||||
self.ref_len = Some(require_length(Some(a.dist(p)))?);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(r), _, Input::Point(p)) => self.scale_by(s, base, base.dist(p) / r),
|
||||
(Some(r), _, Input::Text(t)) => {
|
||||
let n = require_length(number(&t))?;
|
||||
self.scale_by(s, base, n / r)
|
||||
}
|
||||
(_, _, Input::Enter) => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
|
||||
/// ROTATE Reference: reference angle (typed, or two points), then the new angle (typed, a
|
||||
/// point measured from the base point, or two points after `Points`). The objects turn by the
|
||||
/// new angle minus the reference angle.
|
||||
fn rotate_reference(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let Some(base) = self.pts.first().copied() else { return Ok(Step::Done) };
|
||||
let au = s.angle_settings();
|
||||
let angle = |t: &str| au.direction(t).filter(|a| a.is_finite()).ok_or_else(|| EngineError::Other("Requires an angle or point.".into()));
|
||||
let two_points = |a: Vec2, p: Vec2| {
|
||||
if a.near(p, 1e-12) { Err(EngineError::Other("The two points must differ.".into())) } else { Ok(a.angle_to(p)) }
|
||||
};
|
||||
let Some(r) = self.ref_angle else {
|
||||
match (self.ref_from, i) {
|
||||
(None, Input::Point(p)) => self.ref_from = Some(p),
|
||||
(None, Input::Text(t)) => self.ref_angle = Some(angle(&t)?),
|
||||
(None, Input::Enter) => self.ref_angle = Some(0.0),
|
||||
(Some(a), Input::Point(p)) => {
|
||||
self.ref_angle = Some(two_points(a, p)?);
|
||||
self.ref_from = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
let new = match (self.ref_points, self.ref_from, i) {
|
||||
(false, _, Input::Keyword(k)) if k == "Points" => {
|
||||
self.ref_points = true;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
(false, _, Input::Point(p)) => base.angle_to(p),
|
||||
(false, _, Input::Text(t)) => angle(&t)?,
|
||||
(false, _, Input::Enter) => 0.0,
|
||||
(true, None, Input::Point(p)) => {
|
||||
self.ref_from = Some(p);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
(true, Some(a), Input::Point(p)) => two_points(a, p)?,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
transform_entities(s, &self.objs, &Mat3::rotate_about(base, new - r), self.copy_mode)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
|
||||
/// The prompt while ROTATE Reference collects its angles.
|
||||
fn rotate_reference_prompt(&self) -> Prompt {
|
||||
match (self.ref_angle, self.ref_points, self.ref_from) {
|
||||
(None, _, None) => Prompt::new("Specify the reference angle", Accept::POINT_OR_NUMBER).default("0"),
|
||||
(None, _, Some(p)) | (Some(_), true, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p),
|
||||
(Some(_), false, _) => {
|
||||
Prompt::new("Specify the new angle", Accept::POINT_OR_NUMBER).kw(&["Points"]).default("0").base_opt(self.pts.first().copied())
|
||||
}
|
||||
(Some(_), true, None) => Prompt::new("Specify first point", Accept::POINT),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rubber band while ROTATE Reference collects its angles: the objects turned to the cursor
|
||||
/// once the reference angle is known, else the line from the first point.
|
||||
fn rotate_reference_preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let (Some(base), Ok(d)) = (self.pts.first().copied(), s.doc()) else { return Vec::new() };
|
||||
match (self.ref_angle, self.ref_points, self.ref_from) {
|
||||
(Some(r), false, _) => {
|
||||
let m = Mat3::rotate_about(base, base.angle_to(c) - r);
|
||||
let mut out: Vec<EntityKind> = self
|
||||
.objs
|
||||
.iter()
|
||||
.take(500)
|
||||
.filter_map(|h| d.entity(*h))
|
||||
.map(|e| {
|
||||
let mut k = e.kind.clone();
|
||||
k.transform(&m);
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
out.push(line(base, c));
|
||||
out
|
||||
}
|
||||
(_, _, Some(a)) => vec![line(a, c)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_by(&mut self, s: &mut Session, base: Vec2, f: f64) -> Result<Step> {
|
||||
let f = require_length(Some(f))?;
|
||||
// The next SCALE offers this factor.
|
||||
s.last_used.scale_factor = f;
|
||||
transform_entities(s, &self.objs, &Mat3::scale_about(base, f), self.copy_mode)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
/// Objects whose text MIRROR keeps readable instead of mirroring it with the geometry.
|
||||
fn keeps_reading_when_mirrored(k: &EntityKind) -> bool {
|
||||
match k {
|
||||
EntityKind::Insert(i) => !i.attribs.is_empty(),
|
||||
k => matches!(
|
||||
k,
|
||||
EntityKind::Text(_)
|
||||
| EntityKind::AttDef(_)
|
||||
| EntityKind::MText(_)
|
||||
| EntityKind::Dimension(_)
|
||||
| EntityKind::MLeader(_)
|
||||
| EntityKind::Table(_)
|
||||
| EntityKind::Tolerance(_)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text objects transformed for each frame of a MIRROR preview (on the CPU); more are not drawn.
|
||||
const MAX_MIRROR_TEXT_PREVIEW: usize = 1000;
|
||||
|
||||
/// A finite length greater than zero (rejects 0, negatives, NaN and infinity).
|
||||
fn positive_length(v: Option<f64>) -> Option<f64> {
|
||||
v.filter(|f| f.is_finite() && *f > 1e-12)
|
||||
}
|
||||
|
||||
/// [`positive_length`], or the error that makes the prompt ask again.
|
||||
fn require_length(v: Option<f64>) -> Result<f64> {
|
||||
positive_length(v).ok_or_else(|| EngineError::Other("Requires a positive number.".into()))
|
||||
}
|
||||
|
||||
impl Interactive for SelectThen {
|
||||
fn name(&self) -> &'static str {
|
||||
match self.op {
|
||||
Op::Erase => "ERASE",
|
||||
Op::Move => "MOVE",
|
||||
Op::Copy => "COPY",
|
||||
Op::Rotate => "ROTATE",
|
||||
Op::Scale => "SCALE",
|
||||
Op::Mirror => "MIRROR",
|
||||
Op::Explode => "EXPLODE",
|
||||
Op::Join => "JOIN",
|
||||
}
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.objs = self.sel.picked.clone();
|
||||
return self.apply(s);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if !self.sel.done {
|
||||
return self.sel.prompt();
|
||||
}
|
||||
if self.ask_erase {
|
||||
return Prompt::new("Erase source objects?", Accept::TEXT).kw(&["Yes", "No"]).default("No");
|
||||
}
|
||||
if let Some(src) = self.join_source {
|
||||
let ellipse = s.doc().is_ok_and(|d| matches!(d.entity(src).map(|e| &e.kind), Some(EntityKind::Ellipse(_))));
|
||||
let msg = if ellipse { "Select elliptical arcs to join to source" } else { "Select arcs to join to source" };
|
||||
return Prompt::new(msg, Accept::POINT).kw(&["cLose"]);
|
||||
}
|
||||
let k = self.pts.len();
|
||||
let base = self.pts.first().copied();
|
||||
if self.op == Op::Scale && self.reference {
|
||||
return match (self.ref_len, self.ref_from) {
|
||||
(Some(_), _) => Prompt::new("Specify new length", Accept::POINT_OR_NUMBER).base_opt(base),
|
||||
(None, Some(p)) => Prompt::new("Specify second point", Accept::POINT).base(p),
|
||||
(None, None) => Prompt::new("Specify reference length", Accept::POINT_OR_NUMBER).default("1"),
|
||||
};
|
||||
}
|
||||
if self.op == Op::Rotate && self.reference {
|
||||
return self.rotate_reference_prompt();
|
||||
}
|
||||
match (self.op, k) {
|
||||
(Op::Move | Op::Copy, 0) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]),
|
||||
(Op::Move, _) => Prompt::new("Specify second point or <use first point as displacement>", Accept::POINT).base_opt(base),
|
||||
(Op::Copy, _) => Prompt::new("Specify second point", Accept::POINT).kw(&["Array", "Exit", "Undo"]).base_opt(base),
|
||||
(Op::Rotate | Op::Scale, 0) => Prompt::new("Specify base point", Accept::POINT),
|
||||
// ROTATE and SCALE offer the angle/factor used last time.
|
||||
(Op::Rotate, _) => {
|
||||
let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
|
||||
// Offered as it would be typed: clockwise is positive when ANGDIR is 1.
|
||||
let a = if s.angle_settings().clockwise { -s.last_used.rotate_angle } else { s.last_used.rotate_angle };
|
||||
Prompt::new("Specify rotation angle", Accept::POINT_OR_NUMBER)
|
||||
.kw(&["Copy", "Reference"])
|
||||
.default(crate::units::format_angle(a, au, ap))
|
||||
.base_opt(base)
|
||||
}
|
||||
(Op::Scale, _) => Prompt::new("Specify scale factor", Accept::POINT_OR_NUMBER)
|
||||
.kw(&["Copy", "Reference"])
|
||||
.default(format!("{:.4}", s.last_used.scale_factor))
|
||||
.base_opt(base),
|
||||
(Op::Mirror, 0) => Prompt::new("Specify first point of mirror line", Accept::POINT),
|
||||
(Op::Mirror, _) => Prompt::new("Specify second point of mirror line", Accept::POINT).base_opt(base),
|
||||
_ => Prompt::new("", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.sel.done {
|
||||
match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => return Ok(Step::Continue),
|
||||
SelOutcome::Empty => return Ok(Step::Done),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.objs = hs;
|
||||
return self.apply(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(src) = self.join_source {
|
||||
return self.join_source_input(s, src, i);
|
||||
}
|
||||
if self.ask_erase {
|
||||
let yes = matches!(&i, Input::Keyword(k) if k == "Yes") || matches!(&i, Input::Text(t) if t.trim().to_ascii_lowercase().starts_with('y'));
|
||||
let (a, b) = (self.pts[0], self.pts[1]);
|
||||
transform_entities(s, &self.objs, &Mat3::mirror(a, b), !yes)?;
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
let k = self.pts.len();
|
||||
if self.op == Op::Scale && self.reference {
|
||||
return self.scale_reference(s, i);
|
||||
}
|
||||
if self.op == Op::Rotate && self.reference {
|
||||
return self.rotate_reference(s, i);
|
||||
}
|
||||
match (self.op, k, i) {
|
||||
(_, _, Input::Keyword(kw)) if kw == "Copy" => {
|
||||
self.copy_mode = true;
|
||||
s.echo("Rotating/scaling a copy of the selected objects.");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Op::Scale | Op::Rotate, 1, Input::Keyword(kw)) if kw == "Reference" => {
|
||||
self.reference = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Op::Copy, _, Input::Keyword(kw)) if kw == "Exit" => {
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(_, 0, Input::Point(p)) => {
|
||||
self.pts.push(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Op::Move, 1, Input::Point(p)) => {
|
||||
let d = p - self.pts[0];
|
||||
transform_entities(s, &self.objs, &Mat3::translate(d), false)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Op::Move, 1, Input::Enter) => {
|
||||
let d = self.pts[0];
|
||||
transform_entities(s, &self.objs, &Mat3::translate(d), false)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Op::Copy, _, Input::Point(p)) => {
|
||||
let d = p - self.pts[0];
|
||||
transform_entities(s, &self.objs, &Mat3::translate(d), true)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Op::Copy, _, Input::Enter) => {
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Op::Rotate, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => {
|
||||
let a = match inp {
|
||||
Input::Point(p) => self.pts[0].angle_to(p),
|
||||
Input::Text(t) => s
|
||||
.angle_settings()
|
||||
.rotation(&t)
|
||||
.filter(|a| a.is_finite())
|
||||
.ok_or_else(|| EngineError::Other("Requires an angle or point.".into()))?,
|
||||
_ => s.last_used.rotate_angle,
|
||||
};
|
||||
s.last_used.rotate_angle = a;
|
||||
transform_entities(s, &self.objs, &Mat3::rotate_about(self.pts[0], a), self.copy_mode)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Op::Scale, 1, inp @ (Input::Point(_) | Input::Text(_) | Input::Enter)) => {
|
||||
let f = match inp {
|
||||
Input::Point(p) => self.pts[0].dist(p),
|
||||
Input::Text(t) => {
|
||||
number(&t).filter(|f| f.is_finite() && *f > 0.0).ok_or_else(|| EngineError::Other("Requires a positive number.".into()))?
|
||||
}
|
||||
_ => s.last_used.scale_factor,
|
||||
};
|
||||
if f > 1e-12 && f.is_finite() {
|
||||
s.last_used.scale_factor = f;
|
||||
transform_entities(s, &self.objs, &Mat3::scale_about(self.pts[0], f), self.copy_mode)?;
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Op::Mirror, 1, Input::Point(p)) => {
|
||||
if p.near(self.pts[0], 1e-12) {
|
||||
return Err(EngineError::Other("Mirror line points must differ.".into()));
|
||||
}
|
||||
self.pts.push(p);
|
||||
self.ask_erase = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, _, Input::Enter) => Ok(Step::Done),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.op == Op::Rotate && self.reference {
|
||||
return self.rotate_reference_preview(s, c);
|
||||
}
|
||||
if !self.sel.done {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(base) = self.pts.first().copied() else { return Vec::new() };
|
||||
let m = match self.op {
|
||||
Op::Move | Op::Copy => Mat3::translate(c - base),
|
||||
Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)),
|
||||
Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)),
|
||||
Op::Scale => match self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r))) {
|
||||
Some(f) => Mat3::scale_about(base, f),
|
||||
None if self.ref_len.is_some() => return Vec::new(),
|
||||
None => return self.ref_from.map(|a| vec![line(a, c)]).unwrap_or_default(),
|
||||
},
|
||||
Op::Mirror if !self.ask_erase => Mat3::mirror(base, c),
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
let Ok(d) = s.doc() else { return Vec::new() };
|
||||
let mut out: Vec<EntityKind> = self
|
||||
.objs
|
||||
.iter()
|
||||
.take(500)
|
||||
.filter_map(|h| d.entity(*h))
|
||||
.map(|e| {
|
||||
let mut k = e.kind.clone();
|
||||
k.transform(&m);
|
||||
k
|
||||
})
|
||||
.collect();
|
||||
out.push(line(base, c));
|
||||
out
|
||||
}
|
||||
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
|
||||
if (self.op == Op::Rotate && self.reference) || !self.sel.done {
|
||||
return None;
|
||||
}
|
||||
let base = self.pts.first().copied()?;
|
||||
let matrix = match self.op {
|
||||
Op::Move | Op::Copy => Mat3::translate(c - base),
|
||||
Op::Rotate => Mat3::rotate_about(base, base.angle_to(c)),
|
||||
Op::Scale if !self.reference => Mat3::scale_about(base, base.dist(c).max(1e-9)),
|
||||
Op::Scale => Mat3::scale_about(base, self.ref_len.and_then(|r| positive_length(Some(base.dist(c) / r)))?),
|
||||
Op::Mirror if !self.ask_erase => Mat3::mirror(base, c),
|
||||
_ => return None,
|
||||
};
|
||||
let mut extra = vec![line(base, c)];
|
||||
if self.op != Op::Mirror {
|
||||
return Some(crate::prompt::Drag { handles: self.objs.clone(), matrix, extra, clipboard: false });
|
||||
}
|
||||
// Mirrored text keeps reading left to right (MIRRTEXT 0), which the matrix alone can't
|
||||
// draw: those objects are transformed one by one, the rest is mirrored on the GPU.
|
||||
let d = s.doc().ok()?;
|
||||
let mut handles = Vec::with_capacity(self.objs.len());
|
||||
let mut texts = 0;
|
||||
for h in &self.objs {
|
||||
let Some(e) = d.entity(*h) else { continue };
|
||||
if !keeps_reading_when_mirrored(&e.kind) {
|
||||
handles.push(*h);
|
||||
} else if texts < MAX_MIRROR_TEXT_PREVIEW {
|
||||
texts += 1;
|
||||
let mut k = e.kind.clone();
|
||||
k.transform(&matrix);
|
||||
extra.push(k);
|
||||
}
|
||||
}
|
||||
Some(crate::prompt::Drag { handles, matrix, extra, clipboard: false })
|
||||
}
|
||||
}
|
||||
|
||||
/// MOVE and COPY: [`SelectThen`], plus the Displacement option at the base point prompt
|
||||
/// (the keyword, or Enter): the typed point is the displacement itself.
|
||||
pub(super) struct DisplaceM {
|
||||
inner: SelectThen,
|
||||
displacement: bool,
|
||||
}
|
||||
|
||||
impl DisplaceM {
|
||||
pub(super) fn new(op: Op) -> Self {
|
||||
DisplaceM { inner: SelectThen::new(op), displacement: false }
|
||||
}
|
||||
/// At "Specify base point": objects selected, no point yet.
|
||||
fn at_base(&self) -> bool {
|
||||
self.inner.sel.done && self.inner.pts.is_empty() && !self.displacement
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for DisplaceM {
|
||||
fn name(&self) -> &'static str {
|
||||
self.inner.name()
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.inner.begin(s)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
if self.displacement {
|
||||
return Prompt::new("Specify displacement", Accept::POINT).default("0,0");
|
||||
}
|
||||
if self.at_base() {
|
||||
return Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement");
|
||||
}
|
||||
self.inner.prompt(s)
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.displacement {
|
||||
let d = match i {
|
||||
Input::Point(p) => p,
|
||||
Input::Enter => Vec2::ZERO,
|
||||
_ => return Ok(Step::Continue),
|
||||
};
|
||||
if d != Vec2::ZERO {
|
||||
transform_entities(s, &self.inner.objs, &Mat3::translate(d), self.inner.op == Op::Copy)?;
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
if self.at_base() && (matches!(&i, Input::Enter) || matches!(&i, Input::Keyword(k) if k == "Displacement")) {
|
||||
self.displacement = true;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
self.inner.input(s, i)
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if self.displacement {
|
||||
return Vec::new();
|
||||
}
|
||||
self.inner.preview(s, c)
|
||||
}
|
||||
fn drag(&self, s: &Session, c: Vec2) -> Option<crate::prompt::Drag> {
|
||||
if self.displacement {
|
||||
return None;
|
||||
}
|
||||
self.inner.drag(s, c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
use super::transform::{delta_of, transform_entities};
|
||||
use super::*;
|
||||
|
||||
/// An area whose vertices STRETCH moves: a window's box, or a polygon closed back to its first
|
||||
/// point.
|
||||
#[derive(Clone)]
|
||||
enum StretchArea {
|
||||
Box(cadcraft_geom::Bounds2),
|
||||
Polygon(Vec<Vec2>),
|
||||
}
|
||||
|
||||
impl StretchArea {
|
||||
fn contains(&self, p: Vec2) -> bool {
|
||||
match self {
|
||||
StretchArea::Box(bx) => bx.contains(p),
|
||||
StretchArea::Polygon(poly) => cadcraft_geom::point_in_polygon(poly, p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing
|
||||
/// selection tests the drawn element lines, which sit away from the control vertices by the
|
||||
/// multiline's scale, so a small area around a vertex alone would miss them.
|
||||
fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec<Handle>) {
|
||||
let Some(store) = d.space(space) else { return };
|
||||
for e in store.iter() {
|
||||
if let EntityKind::MLine(m) = &e.kind
|
||||
&& d.is_visible(e)
|
||||
&& !hs.contains(&e.handle)
|
||||
&& m.vertices.iter().any(|v| area.contains(v.p.xy()))
|
||||
{
|
||||
hs.push(e.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the vertices of `e` that lie inside any of the areas `wins` by `d` (STRETCH).
|
||||
fn stretch_entity(e: &mut Entity, wins: &[StretchArea], d: Vec2) {
|
||||
let inside = |p: Vec2| wins.iter().any(|a| a.contains(p));
|
||||
let mv = |p: &mut cadcraft_geom::Vec3| {
|
||||
if inside(p.xy()) {
|
||||
p.x += d.x;
|
||||
p.y += d.y;
|
||||
}
|
||||
};
|
||||
match &mut e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
mv(&mut l.a);
|
||||
mv(&mut l.b);
|
||||
}
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
for v in &mut pl.vertices {
|
||||
if inside(v.p) {
|
||||
v.p += d;
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| mv(&mut v.p)),
|
||||
EntityKind::Arc(a) => {
|
||||
// Arcs keep their shape: move when the centre is inside.
|
||||
if inside(a.center.xy()) {
|
||||
a.center.x += d.x;
|
||||
a.center.y += d.y;
|
||||
}
|
||||
}
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(mv),
|
||||
EntityKind::Dimension(dm) => {
|
||||
for p in [&mut dm.p13, &mut dm.p14, &mut dm.defpt, &mut dm.text_mid] {
|
||||
mv(p);
|
||||
}
|
||||
dm.block = None;
|
||||
}
|
||||
k => {
|
||||
let g = k.grips();
|
||||
if g.first().is_some_and(|p| inside(*p)) {
|
||||
k.transform(&Mat3::translate(d));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = delta_of("stretch", p)?;
|
||||
let space = s.space();
|
||||
let listed = |key: &str, least: usize| -> Result<Option<Vec<Vec2>>> {
|
||||
let Some(pts) = points_param(p, key) else { return Ok(None) };
|
||||
if pts.len() < least || pts.len() > crate::select::MAX_MODE_POINTS || pts.iter().any(|q| !q.is_finite()) {
|
||||
return Err(bad("stretch", format!("`{key}` needs {least} to {} finite points", crate::select::MAX_MODE_POINTS)));
|
||||
}
|
||||
Ok(Some(pts))
|
||||
};
|
||||
// A fence marks no area: the objects it crosses move whole.
|
||||
if let Some(fence) = listed("fence", 2)? {
|
||||
let hs = crate::select::select_fence(s.doc()?, &space, &fence);
|
||||
let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?;
|
||||
return Ok(json!({ "stretched": moved.len() }));
|
||||
}
|
||||
let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? {
|
||||
(crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly))
|
||||
} else {
|
||||
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?;
|
||||
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
|
||||
let bx = cadcraft_geom::Bounds2::new(*a, *b);
|
||||
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
|
||||
};
|
||||
add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs);
|
||||
hs.retain(|h| !crate::cmd::curves::is_locked(s, *h));
|
||||
let doc = s.doc_mut()?;
|
||||
let areas = [area];
|
||||
for h in &hs {
|
||||
doc.modify_entity(*h, |e| stretch_entity(e, &areas, d))?;
|
||||
}
|
||||
Ok(json!({ "stretched": hs.len() }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq)]
|
||||
enum StretchPhase {
|
||||
#[default]
|
||||
Select,
|
||||
Base,
|
||||
Displacement,
|
||||
Second,
|
||||
}
|
||||
|
||||
/// STRETCH: "Select objects" repeats until Enter. Crossing windows (implied right to left, or after
|
||||
/// `C`), windows (left to right, or after `W`) and polygons (`CP`, `WP`) select objects and mark
|
||||
/// the area whose vertices move; objects picked one at a time (or by a fence, `F`, or
|
||||
/// All/Last/Previous) move whole. Then the base point or Displacement, and the second point.
|
||||
#[derive(Default)]
|
||||
pub(super) struct StretchM {
|
||||
phase: StretchPhase,
|
||||
objs: Vec<Handle>,
|
||||
/// Objects selected without a window: they move whole.
|
||||
whole: Vec<Handle>,
|
||||
/// The selection windows and polygons: vertices inside any of them move.
|
||||
windows: Vec<StretchArea>,
|
||||
removing: bool,
|
||||
/// `C`, `W`, `BOX`, `CP`, `WP` or `F` was typed: the next points are that kind of selection.
|
||||
mode: Option<SelectMode>,
|
||||
/// First corner of the window being drawn.
|
||||
corner: Option<Vec2>,
|
||||
base: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl StretchM {
|
||||
fn add(&mut self, s: &mut Session, hs: Vec<Handle>, whole: bool) {
|
||||
let n = hs.len();
|
||||
for h in hs {
|
||||
if self.removing {
|
||||
self.objs.retain(|o| *o != h);
|
||||
self.whole.retain(|o| *o != h);
|
||||
} else {
|
||||
if !self.objs.contains(&h) {
|
||||
self.objs.push(h);
|
||||
}
|
||||
if whole && !self.whole.contains(&h) {
|
||||
self.whole.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
s.echo(format!("{n} found, {} total", self.objs.len()));
|
||||
} else {
|
||||
s.echo("0 found");
|
||||
}
|
||||
s.set_selection(self.objs.clone());
|
||||
}
|
||||
|
||||
/// Select with a typed mode that has its points. With too few (a fence of one point, a polygon
|
||||
/// of two) the mode is dropped.
|
||||
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
|
||||
let space = s.space();
|
||||
let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) };
|
||||
let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
|
||||
(SelectKind::Crossing | SelectKind::CPolygon, _) => true,
|
||||
(SelectKind::Box, Some((a, b))) => b.x < a.x,
|
||||
_ => false,
|
||||
};
|
||||
let area = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
|
||||
(SelectKind::WPolygon | SelectKind::CPolygon, _) => Some(StretchArea::Polygon(m.pts)),
|
||||
(SelectKind::Window | SelectKind::Crossing | SelectKind::Box, Some((a, b))) => {
|
||||
Some(StretchArea::Box(cadcraft_geom::Bounds2::new(*a, *b)))
|
||||
}
|
||||
// A fence marks no area: what it crosses moves whole.
|
||||
_ => None,
|
||||
};
|
||||
let whole = area.is_none();
|
||||
if let (Some(a), true, false) = (&area, crossing, self.removing) {
|
||||
add_mline_vertex_hits(s.doc()?, &space, a, &mut hs);
|
||||
}
|
||||
if let (Some(a), false) = (area, self.removing) {
|
||||
self.windows.push(a);
|
||||
}
|
||||
self.add(s, hs, whole);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn select(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if let Some(mut m) = self.mode.take() {
|
||||
match &i {
|
||||
Input::Point(p) => {
|
||||
if m.push(*p) {
|
||||
self.finish_mode(s, m)?;
|
||||
} else {
|
||||
self.mode = Some(m);
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
Input::Text(t) | Input::Keyword(t) if matches!(t.trim().to_ascii_lowercase().as_str(), "u" | "undo") => {
|
||||
m.pts.pop();
|
||||
self.mode = Some(m);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// Enter ends a fence or closes a polygon.
|
||||
Input::Enter => {
|
||||
self.finish_mode(s, m)?;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
// Anything else leaves the mode and is read as usual.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => match self.corner.take() {
|
||||
Some(c) => {
|
||||
let bx = cadcraft_geom::Bounds2::new(c, p);
|
||||
let space = s.space();
|
||||
let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
|
||||
if !self.removing {
|
||||
if p.x < c.x {
|
||||
add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs);
|
||||
}
|
||||
self.windows.push(StretchArea::Box(bx));
|
||||
}
|
||||
self.add(s, hs, false);
|
||||
}
|
||||
None => {
|
||||
let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
|
||||
let space = s.space();
|
||||
match crate::select::pick(s.doc()?, &space, p, aperture) {
|
||||
Some(h) => self.add(s, vec![h], true),
|
||||
None => self.corner = Some(p),
|
||||
}
|
||||
}
|
||||
},
|
||||
Input::Pick(hs) => self.add(s, hs, true),
|
||||
Input::Text(t) | Input::Keyword(t) => {
|
||||
self.corner = None;
|
||||
let d = s.doc()?;
|
||||
let store = d.space(&s.space());
|
||||
let word = t.trim().to_ascii_lowercase();
|
||||
// `_C`, `_L`: the English name, as scripts write it.
|
||||
let word = word.strip_prefix('_').unwrap_or(&word).to_string();
|
||||
if let Some(m) = SelectMode::from_keyword(&word).filter(|m| m.kind != SelectKind::Group) {
|
||||
self.mode = Some(m);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let picked = match word.as_str() {
|
||||
"r" | "remove" => {
|
||||
self.removing = true;
|
||||
None
|
||||
}
|
||||
"a" | "add" => {
|
||||
self.removing = false;
|
||||
None
|
||||
}
|
||||
"all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()),
|
||||
"l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()),
|
||||
"p" | "previous" => Some(s.state()?.previous_selection.clone()),
|
||||
_ => {
|
||||
s.echo("*Invalid selection*");
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(hs) = picked {
|
||||
self.add(s, hs, true);
|
||||
}
|
||||
}
|
||||
Input::Enter if self.corner.is_some() => self.corner = None,
|
||||
Input::Enter if self.objs.is_empty() => return Ok(Step::Done),
|
||||
Input::Enter => {
|
||||
s.remember_selection(&self.objs);
|
||||
self.phase = StretchPhase::Base;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
|
||||
fn apply(&self, s: &mut Session, d: Vec2) -> Result<Step> {
|
||||
let whole: Vec<Handle> = self.objs.iter().filter(|h| self.whole.contains(h)).copied().collect();
|
||||
transform_entities(s, &whole, &Mat3::translate(d), false)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for h in self.objs.iter().filter(|h| !self.whole.contains(h)) {
|
||||
let locked = doc.entity(*h).and_then(|e| doc.layer(&e.common.layer)).is_some_and(|l| l.locked);
|
||||
if !locked {
|
||||
doc.modify_entity(*h, |e| stretch_entity(e, &self.windows, d))?;
|
||||
}
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for StretchM {
|
||||
fn name(&self) -> &'static str {
|
||||
"STRETCH"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.set_selection(Vec::new());
|
||||
s.echo("Select objects to stretch by crossing-window or crossing-polygon...");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) {
|
||||
return m.prompt();
|
||||
}
|
||||
match (self.phase, self.corner) {
|
||||
(StretchPhase::Select, Some(c)) => Prompt::new("Specify opposite corner", Accept::POINT).corner(c),
|
||||
(StretchPhase::Select, None) => Prompt::new(if self.removing { "Remove objects" } else { "Select objects" }, Accept::POINT),
|
||||
(StretchPhase::Base, _) => Prompt::new("Specify base point", Accept::POINT).kw(&["Displacement"]).default("Displacement"),
|
||||
(StretchPhase::Displacement, _) => Prompt::new("Specify displacement", Accept::POINT).default("0,0"),
|
||||
(StretchPhase::Second, _) => Prompt::new("Specify second point or <use first point as displacement>", Accept::POINT).base_opt(self.base),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match (self.phase, i) {
|
||||
(StretchPhase::Select, i) => self.select(s, i),
|
||||
(StretchPhase::Base, Input::Point(p)) => {
|
||||
self.base = Some(p);
|
||||
self.phase = StretchPhase::Second;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(StretchPhase::Base, Input::Enter) => {
|
||||
self.phase = StretchPhase::Displacement;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(StretchPhase::Base, Input::Keyword(k)) if k == "Displacement" => {
|
||||
self.phase = StretchPhase::Displacement;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(StretchPhase::Displacement, Input::Point(p)) => self.apply(s, p),
|
||||
(StretchPhase::Displacement, Input::Enter) => Ok(Step::Done),
|
||||
(StretchPhase::Second, Input::Point(p)) => self.apply(s, p - self.base.unwrap_or(p)),
|
||||
(StretchPhase::Second, Input::Enter) => self.apply(s, self.base.unwrap_or(Vec2::ZERO)),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let rect = |a: Vec2| {
|
||||
let (b, d) = (Vec2::new(c.x, a.y), Vec2::new(a.x, c.y));
|
||||
vec![line(a, b), line(b, c), line(c, d), line(d, a)]
|
||||
};
|
||||
if let (StretchPhase::Select, Some(m)) = (self.phase, &self.mode) {
|
||||
let Some(first) = m.pts.first().copied() else { return Vec::new() };
|
||||
if !matches!(m.kind, SelectKind::Fence | SelectKind::WPolygon | SelectKind::CPolygon) {
|
||||
return rect(first);
|
||||
}
|
||||
// The fence or polygon so far, on to the cursor; a polygon closes back to its start.
|
||||
let mut out: Vec<EntityKind> = m.pts.windows(2).filter_map(|w| Some(line(*w.first()?, *w.get(1)?))).collect();
|
||||
out.extend(m.pts.last().map(|l| line(*l, c)));
|
||||
if m.kind != SelectKind::Fence && m.pts.len() >= 2 {
|
||||
out.push(line(c, first));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
match (self.phase, self.corner, self.base) {
|
||||
(StretchPhase::Select, Some(a), _) => rect(a),
|
||||
(StretchPhase::Second, _, Some(base)) => {
|
||||
let Ok(doc) = s.doc() else { return Vec::new() };
|
||||
let m = Mat3::translate(c - base);
|
||||
let mut out: Vec<EntityKind> = self
|
||||
.objs
|
||||
.iter()
|
||||
.take(500)
|
||||
.filter_map(|h| doc.entity(*h))
|
||||
.map(|e| {
|
||||
let mut e = (**e).clone();
|
||||
if self.whole.contains(&e.handle) {
|
||||
e.kind.transform(&m);
|
||||
} else {
|
||||
stretch_entity(&mut e, &self.windows, c - base);
|
||||
}
|
||||
e.kind
|
||||
})
|
||||
.collect();
|
||||
out.push(line(base, c));
|
||||
out
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------- core operations ----------------
|
||||
|
||||
pub(crate) fn transform_entities(s: &mut Session, hs: &[Handle], m: &Mat3, copy: bool) -> Result<Vec<Handle>> {
|
||||
let d = s.doc_mut()?;
|
||||
let mut out = Vec::new();
|
||||
for h in hs {
|
||||
let Some(sp) = d.space_of(*h) else { continue };
|
||||
if copy {
|
||||
let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue };
|
||||
let nh = d.new_handle();
|
||||
let mut ne = Entity { handle: nh, ..e };
|
||||
ne.kind.transform(m);
|
||||
if let Some(st) = d.space_mut(&sp) {
|
||||
st.push(ne);
|
||||
out.push(nh);
|
||||
}
|
||||
} else {
|
||||
let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked);
|
||||
if locked {
|
||||
continue;
|
||||
}
|
||||
d.modify_entity(*h, |e| e.kind.transform(m))?;
|
||||
out.push(*h);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn erase(s: &mut Session, hs: &[Handle]) -> Result<usize> {
|
||||
let d = s.doc_mut()?;
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
let locked = d.entity(*h).and_then(|e| d.layer(&e.common.layer)).is_some_and(|l| l.locked);
|
||||
if !locked && d.remove_entity(*h).is_some() {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
pub(super) fn run_erase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let n = erase(s, &hs)?;
|
||||
Ok(json!({ "erased": n }))
|
||||
}
|
||||
|
||||
pub(super) fn delta_of(cmd: &str, p: &Value) -> Result<Vec2> {
|
||||
if let Some(d) = point_param(p, "delta") {
|
||||
return Ok(d);
|
||||
}
|
||||
let a = point_req(cmd, p, "from")?;
|
||||
let b = point_req(cmd, p, "to")?;
|
||||
Ok(b - a)
|
||||
}
|
||||
|
||||
pub(super) fn run_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let d = delta_of("move", p)?;
|
||||
let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?;
|
||||
Ok(json!({ "moved": moved.len() }))
|
||||
}
|
||||
|
||||
pub(super) fn run_copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let d = delta_of("copy", p)?;
|
||||
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 10_000);
|
||||
let mut out = Vec::new();
|
||||
for k in 1..=n {
|
||||
out.extend(transform_entities(s, &hs, &Mat3::translate(d * k as f64), true)?.into_iter().map(|h| h.hex()));
|
||||
}
|
||||
Ok(json!({ "handles": out }))
|
||||
}
|
||||
|
||||
pub(super) fn run_rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let base = point_req("rotate", p, "base")?;
|
||||
let a = f64_req("rotate", p, "angle")?.to_radians();
|
||||
let r = transform_entities(s, &hs, &Mat3::rotate_about(base, a), bool_or(p, "copy", false))?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
pub(super) fn run_scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let base = point_req("scale", p, "base")?;
|
||||
let f = f64_req("scale", p, "factor")?;
|
||||
if f <= 0.0 {
|
||||
return Err(bad("scale", "factor must be positive"));
|
||||
}
|
||||
let r = transform_entities(s, &hs, &Mat3::scale_about(base, f), bool_or(p, "copy", false))?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
pub(super) fn run_mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let a = point_req("mirror", p, "p1")?;
|
||||
let b = point_req("mirror", p, "p2")?;
|
||||
if a.near(b, 1e-12) {
|
||||
return Err(bad("mirror", "mirror line points coincide"));
|
||||
}
|
||||
let erase_src = bool_or(p, "erase", false);
|
||||
let r = transform_entities(s, &hs, &Mat3::mirror(a, b), !erase_src)?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
use super::*;
|
||||
|
||||
// ---------------- trim / extend ----------------
|
||||
|
||||
/// The cutting (TRIM) or boundary (EXTEND) edges.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct Edges<'a> {
|
||||
/// Only these objects; `None`: every visible object.
|
||||
pub only: Option<&'a [Handle]>,
|
||||
/// Implied edges (EDGEMODE 1): lines and the end segments of open polylines count as running
|
||||
/// on without end, arcs as whole circles.
|
||||
pub implied: bool,
|
||||
}
|
||||
|
||||
/// All primitives of the cutting edges (excluding `exclude`).
|
||||
fn edge_segments(s: &Session, edges: Edges, exclude: Handle) -> Result<Vec<Segment>> {
|
||||
let d = s.doc()?;
|
||||
let space = s.space();
|
||||
let store = d.space(&space).ok_or(EngineError::NoDocument)?;
|
||||
let mut out = Vec::new();
|
||||
for e in store.iter() {
|
||||
if e.handle == exclude || !d.is_visible(e) {
|
||||
continue;
|
||||
}
|
||||
if let Some(es) = edges.only
|
||||
&& !es.contains(&e.handle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if edges.implied
|
||||
&& let Some(segs) = implied_edge(&e.kind)
|
||||
{
|
||||
out.extend(segs);
|
||||
continue;
|
||||
}
|
||||
for p in e.kind.prims() {
|
||||
out.extend(prim_to_segments(&p));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `k` as an implied edge (EDGEMODE 1), for lines, arcs and open polylines.
|
||||
fn implied_edge(k: &EntityKind) -> Option<Vec<Segment>> {
|
||||
const FAR: f64 = 1e8;
|
||||
let whole = |a: &Arc| Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..*a }, ccw: true };
|
||||
// Run a segment on past its start and/or its end.
|
||||
let run_on = |sg: Segment, back: bool, fwd: bool| match sg {
|
||||
Segment::Line(l) => {
|
||||
let d = (l.b - l.a).normalized();
|
||||
Segment::Line(Line::new(if back { l.a - d * FAR } else { l.a }, if fwd { l.b + d * FAR } else { l.b }))
|
||||
}
|
||||
Segment::Arc { arc, .. } => whole(&arc),
|
||||
};
|
||||
match k {
|
||||
EntityKind::Line(l) => Some(vec![run_on(Segment::Line(Line::new(l.a.xy(), l.b.xy())), true, true)]),
|
||||
EntityKind::Arc(a) => Some(vec![whole(&Arc::new(a.center.xy(), a.radius, a.start, a.end))]),
|
||||
EntityKind::LwPolyline(p) if !p.closed => {
|
||||
let mut segs = Polyline { vertices: p.vertices.clone(), closed: false }.segments();
|
||||
let last = segs.len().checked_sub(1)?;
|
||||
for (i, sg) in segs.iter_mut().enumerate() {
|
||||
if i == 0 || i == last {
|
||||
*sg = run_on(*sg, i == 0, i == last);
|
||||
}
|
||||
}
|
||||
Some(segs)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn prim_to_segments(p: &Prim) -> Vec<Segment> {
|
||||
match p {
|
||||
Prim::Seg(s) => vec![*s],
|
||||
Prim::Circle(c) => vec![Segment::Arc { arc: Arc { center: c.center, radius: c.radius, start: 0.0, end: TAU - 1e-12 }, ccw: true }],
|
||||
Prim::Infinite { base, dir, .. } => vec![Segment::Line(Line::new(*base - *dir * 1e8, *base + *dir * 1e8))],
|
||||
Prim::Ellipse(e) => {
|
||||
let mut pts = Vec::new();
|
||||
e.tessellate(e.major.len() * 1e-4, &mut pts);
|
||||
pts.windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect()
|
||||
}
|
||||
Prim::Spline(sp) => sp.tessellate(1e-4).windows(2).filter_map(|w| Some(Segment::Line(Line::new(*w.first()?, *w.get(1)?)))).collect(),
|
||||
Prim::Fill(f) => {
|
||||
let n = f.len();
|
||||
(0..n).filter_map(|i| Some(Segment::Line(Line::new(*f.get(i)?, *f.get((i + 1) % n)?)))).collect()
|
||||
}
|
||||
Prim::Point(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim `h` at the piece containing `pick`. Returns the handles that replace it.
|
||||
pub(crate) fn trim(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<Vec<Handle>> {
|
||||
if crate::cmd::curves::is_locked(s, h) {
|
||||
return Err(EngineError::Other("The object is on a locked layer.".into()));
|
||||
}
|
||||
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
let cut = edge_segments(s, edges, h)?;
|
||||
let pieces: Vec<EntityKind> = match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
let ln = Line::new(l.a.xy(), l.b.xy());
|
||||
let mut ts: Vec<f64> = cut
|
||||
.iter()
|
||||
.flat_map(|c| intersect_ext(&Segment::Line(ln), c, false))
|
||||
.map(|x| ln.param_of(x))
|
||||
.filter(|t| *t > 1e-9 && *t < 1.0 - 1e-9)
|
||||
.collect();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
let tp = ln.param_of(pick).clamp(0.0, 1.0);
|
||||
let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max);
|
||||
let hi = ts.iter().copied().filter(|t| *t > tp).fold(1.0, f64::min);
|
||||
if lo == 0.0 && hi == 1.0 {
|
||||
return Err(EngineError::Other("Object does not intersect a cutting edge.".into()));
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if lo > 0.0 {
|
||||
v.push(line(ln.a, ln.at(lo)));
|
||||
}
|
||||
if hi < 1.0 {
|
||||
v.push(line(ln.at(hi), ln.b));
|
||||
}
|
||||
v
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
let seg = Segment::Arc { arc: ga, ccw: true };
|
||||
let sweep = ga.sweep();
|
||||
let param = |p: Vec2| norm_angle(ga.center.angle_to(p) - ga.start);
|
||||
let mut ts: Vec<f64> =
|
||||
cut.iter().flat_map(|c| intersect_ext(&seg, c, false)).map(param).filter(|t| *t > 1e-9 && *t < sweep - 1e-9).collect();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
let tp = param(pick).min(sweep);
|
||||
let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max);
|
||||
let hi = ts.iter().copied().filter(|t| *t > tp).fold(sweep, f64::min);
|
||||
if lo == 0.0 && hi == sweep {
|
||||
return Err(EngineError::Other("Object does not intersect a cutting edge.".into()));
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if lo > 0.0 {
|
||||
v.push(arc(&Arc::new(ga.center, ga.radius, ga.start, ga.start + lo)));
|
||||
}
|
||||
if hi < sweep {
|
||||
v.push(arc(&Arc::new(ga.center, ga.radius, ga.start + hi, ga.end)));
|
||||
}
|
||||
v
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
let full = Segment::Arc { arc: Arc { center: c.center.xy(), radius: c.radius, start: 0.0, end: TAU - 1e-12 }, ccw: true };
|
||||
let mut angs: Vec<f64> = cut.iter().flat_map(|x| intersect_ext(&full, x, false)).map(|p| c.center.xy().angle_to(p)).collect();
|
||||
angs.sort_by(f64::total_cmp);
|
||||
angs.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
if angs.len() < 2 {
|
||||
return Err(EngineError::Other("Circle must intersect cutting edges at two or more points.".into()));
|
||||
}
|
||||
let ap = c.center.xy().angle_to(pick);
|
||||
// The removed arc runs between the two intersections around the pick.
|
||||
let n = angs.len();
|
||||
let mut keep = None;
|
||||
for i in 0..n {
|
||||
let a0 = angs[i];
|
||||
let a1 = angs[(i + 1) % n];
|
||||
if angle_in_sweep(ap, a0, a1) {
|
||||
keep = Some(arc(&Arc::new(c.center.xy(), c.radius, a1, a0)));
|
||||
}
|
||||
}
|
||||
keep.into_iter().collect()
|
||||
}
|
||||
EntityKind::LwPolyline(p) => trim_polyline(p, &cut, pick)?,
|
||||
k => crate::cmd::modify2::trim_other(k, &cut, pick)?,
|
||||
};
|
||||
let common = e.common.clone();
|
||||
let space = s.space();
|
||||
let d = s.doc_mut()?;
|
||||
d.remove_entity(h);
|
||||
let mut out = Vec::new();
|
||||
for k in pieces {
|
||||
let nh = d.new_handle();
|
||||
if let Some(st) = d.space_mut(&space) {
|
||||
st.push(Entity { handle: nh, common: common.clone(), kind: k });
|
||||
out.push(nh);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Sub-polyline between length parameters `a` and `b` (open polylines).
|
||||
pub(super) fn sub_polyline(segs: &[Segment], a: f64, b: f64) -> Vec<PolyVertex> {
|
||||
let mut out: Vec<PolyVertex> = Vec::new();
|
||||
let mut acc = 0.0;
|
||||
for s in segs {
|
||||
let len = s.len();
|
||||
let s0 = acc;
|
||||
let s1 = acc + len;
|
||||
acc = s1;
|
||||
if s1 <= a + 1e-12 || s0 >= b - 1e-12 || len <= 1e-15 {
|
||||
continue;
|
||||
}
|
||||
let t0 = ((a - s0) / len).clamp(0.0, 1.0);
|
||||
let t1 = ((b - s0) / len).clamp(0.0, 1.0);
|
||||
let p0 = s.at(t0);
|
||||
let bulge = match s {
|
||||
Segment::Line(_) => 0.0,
|
||||
Segment::Arc { arc, ccw } => {
|
||||
let sw = arc.sweep() * (t1 - t0);
|
||||
cadcraft_geom::arc_to_bulge(if *ccw { sw } else { -sw })
|
||||
}
|
||||
};
|
||||
if out.last().is_none_or(|l| !l.p.near(p0, 1e-9)) {
|
||||
out.push(PolyVertex { p: p0, bulge, ..Default::default() });
|
||||
} else if let Some(l) = out.last_mut() {
|
||||
l.bulge = bulge;
|
||||
}
|
||||
out.push(PolyVertex::new(s.at(t1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn trim_polyline(p: &cadcraft_doc::LwPolyline, cut: &[Segment], pick: Vec2) -> Result<Vec<EntityKind>> {
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
let segs = pl.segments();
|
||||
let total: f64 = segs.iter().map(Segment::len).sum();
|
||||
let mut ts: Vec<f64> = Vec::new();
|
||||
let mut acc = 0.0;
|
||||
let mut tp = 0.0;
|
||||
let mut best = f64::INFINITY;
|
||||
for s in &segs {
|
||||
let len = s.len();
|
||||
for x in cut.iter().flat_map(|c| intersect_ext(s, c, false)) {
|
||||
ts.push(acc + param_on(s, x) * len);
|
||||
}
|
||||
let c = s.closest(pick);
|
||||
if c.dist(pick) < best {
|
||||
best = c.dist(pick);
|
||||
tp = acc + param_on(s, c) * len;
|
||||
}
|
||||
acc += len;
|
||||
}
|
||||
ts.retain(|t| *t > 1e-9 && *t < total - 1e-9);
|
||||
ts.sort_by(f64::total_cmp);
|
||||
if ts.is_empty() {
|
||||
return Err(EngineError::Other("Object does not intersect a cutting edge.".into()));
|
||||
}
|
||||
let lo = ts.iter().copied().filter(|t| *t < tp).fold(f64::NEG_INFINITY, f64::max);
|
||||
let hi = ts.iter().copied().filter(|t| *t > tp).fold(f64::INFINITY, f64::min);
|
||||
let mk = |vs: Vec<PolyVertex>| lwpoly(vs, false);
|
||||
let mut out = Vec::new();
|
||||
if p.closed {
|
||||
// Keep the complementary run from hi around to lo.
|
||||
// The pick lies before the first cut or after the last: the piece wraps past the start
|
||||
// vertex, so the run to keep is the stretch between the first and the last cut.
|
||||
let (lo, hi) = (
|
||||
if lo.is_finite() { lo } else { ts.last().copied().unwrap_or(0.0) - total },
|
||||
if hi.is_finite() { hi } else { ts.first().copied().unwrap_or(total) + total },
|
||||
);
|
||||
let doubled: Vec<Segment> = segs.iter().chain(segs.iter()).copied().collect();
|
||||
let (a, mut b) = if hi > total { (hi - total, lo) } else { (hi, lo + total) };
|
||||
// A single cut leaves the whole outline, opened there.
|
||||
if b <= a + 1e-9 {
|
||||
b += total;
|
||||
}
|
||||
out.push(mk(sub_polyline(&doubled, a, b)));
|
||||
} else {
|
||||
if lo.is_finite() {
|
||||
out.push(mk(sub_polyline(&segs, 0.0, lo)));
|
||||
}
|
||||
if hi.is_finite() {
|
||||
out.push(mk(sub_polyline(&segs, hi, total)));
|
||||
}
|
||||
}
|
||||
Ok(out.into_iter().filter(|k| matches!(k, EntityKind::LwPolyline(l) if l.vertices.len() >= 2)).collect())
|
||||
}
|
||||
|
||||
pub(super) fn param_on(s: &Segment, p: Vec2) -> f64 {
|
||||
match s {
|
||||
Segment::Line(l) => l.param_of(p).clamp(0.0, 1.0),
|
||||
Segment::Arc { arc, ccw } => {
|
||||
let t = norm_angle(arc.center.angle_to(p) - arc.start) / arc.sweep().max(1e-15);
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if *ccw { t } else { 1.0 - t }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend the end of `h` nearest `pick` to the nearest boundary edge.
|
||||
pub(crate) fn extend(s: &mut Session, h: Handle, pick: Vec2, edges: Edges) -> Result<()> {
|
||||
if crate::cmd::curves::is_locked(s, h) {
|
||||
return Err(EngineError::Other("The object is on a locked layer.".into()));
|
||||
}
|
||||
let e = s.doc()?.entity(h).map(|e| (**e).clone()).ok_or_else(|| EngineError::Other("no such object".into()))?;
|
||||
let cut = edge_segments(s, edges, h)?;
|
||||
let new_kind = match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
let (a, b) = (l.a.xy(), l.b.xy());
|
||||
let from_b = pick.dist(b) < pick.dist(a);
|
||||
let (fixed, end) = if from_b { (a, b) } else { (b, a) };
|
||||
let dir = (end - fixed).normalized();
|
||||
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
|
||||
let best = cut
|
||||
.iter()
|
||||
.flat_map(|c| intersect_ext(&ray, c, false))
|
||||
.filter(|x| (*x - end).dot(dir) > 1e-9)
|
||||
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)));
|
||||
let np = best.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?;
|
||||
if from_b { line(a, np) } else { line(np, b) }
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
let ga = Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
let from_end = pick.dist(ga.end_point()) < pick.dist(ga.start_point());
|
||||
let full = Segment::Arc { arc: Arc { start: 0.0, end: TAU - 1e-12, ..ga }, ccw: true };
|
||||
let hits: Vec<f64> =
|
||||
cut.iter().flat_map(|c| intersect_ext(&full, c, false)).map(|x| ga.center.angle_to(x)).filter(|t| !ga.contains_angle(*t)).collect();
|
||||
let pick_ang = if from_end {
|
||||
hits.iter().copied().min_by(|x, y| ccw_sweep(ga.end, *x).total_cmp(&ccw_sweep(ga.end, *y)))
|
||||
} else {
|
||||
hits.iter().copied().min_by(|x, y| ccw_sweep(*x, ga.start).total_cmp(&ccw_sweep(*y, ga.start)))
|
||||
};
|
||||
let t = pick_ang.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?;
|
||||
if from_end { arc(&Arc::new(ga.center, ga.radius, ga.start, t)) } else { arc(&Arc::new(ga.center, ga.radius, t, ga.end)) }
|
||||
}
|
||||
EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => {
|
||||
let mut vs = p.vertices.clone();
|
||||
let n = vs.len();
|
||||
let from_end = vs.last().is_some_and(|l| pick.dist(l.p) < vs.first().map(|f| pick.dist(f.p)).unwrap_or(f64::INFINITY));
|
||||
let (i_end, i_prev) = if from_end { (n - 1, n - 2) } else { (0, 1) };
|
||||
let end = vs.get(i_end).map(|v| v.p).unwrap_or_default();
|
||||
let prev = vs.get(i_prev).map(|v| v.p).unwrap_or_default();
|
||||
let dir = (end - prev).normalized();
|
||||
let ray = Segment::Line(Line::new(end, end + dir * 1e8));
|
||||
let np = cut
|
||||
.iter()
|
||||
.flat_map(|c| intersect_ext(&ray, c, false))
|
||||
.filter(|x| (*x - end).dot(dir) > 1e-9)
|
||||
.min_by(|x, y| x.dist(end).total_cmp(&y.dist(end)))
|
||||
.ok_or_else(|| EngineError::Other("Object does not intersect an edge.".into()))?;
|
||||
if let Some(v) = vs.get_mut(i_end) {
|
||||
v.p = np;
|
||||
}
|
||||
lwpoly(vs, false)
|
||||
}
|
||||
k => crate::cmd::modify2::extend_other(k, &cut, pick)?,
|
||||
};
|
||||
s.doc_mut()?.modify_entity(h, |e| e.kind = new_kind)?;
|
||||
Ok(())
|
||||
}
|
||||
+25
-2149
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// ALIGN
|
||||
// =====================================================================================
|
||||
|
||||
pub(crate) fn align_matrix(s1: Vec2, d1: Vec2, pair2: Option<(Vec2, Vec2)>, scale: bool) -> Mat3 {
|
||||
let mut m = Mat3::translate(d1 - s1);
|
||||
if let Some((s2, d2)) = pair2 {
|
||||
let vs = s2 - s1;
|
||||
let vd = d2 - d1;
|
||||
if vs.len() > 1e-12 && vd.len() > 1e-12 {
|
||||
m = Mat3::rotate_about(d1, vd.angle() - vs.angle()).then_before(m);
|
||||
if scale {
|
||||
m = Mat3::scale_about(d1, vd.len() / vs.len()).then_before(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
pub(super) fn run_align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let s1 = point_req("align", p, "s1")?;
|
||||
let d1 = point_req("align", p, "d1")?;
|
||||
let pair2 = point_param(p, "s2").zip(point_param(p, "d2"));
|
||||
let m = align_matrix(s1, d1, pair2, bool_or(p, "scale", false));
|
||||
let r = crate::cmd::modify::transform_entities(s, &hs, &m, false)?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct AlignM {
|
||||
sel: SelectPhase,
|
||||
objs: Vec<Handle>,
|
||||
pts: Vec<Vec2>,
|
||||
ask_scale: bool,
|
||||
}
|
||||
|
||||
impl AlignM {
|
||||
fn finish(&self, s: &mut Session, scale: bool) -> Result<Step> {
|
||||
let (Some(s1), Some(d1)) = (self.pts.first().copied(), self.pts.get(1).copied()) else { return Ok(Step::Cancel) };
|
||||
let pair2 = self.pts.get(2).copied().zip(self.pts.get(3).copied());
|
||||
let m = align_matrix(s1, d1, pair2, scale);
|
||||
crate::cmd::modify::transform_entities(s, &self.objs, &m, false)?;
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for AlignM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ALIGN"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.objs = self.sel.picked.clone();
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if !self.sel.done {
|
||||
return self.sel.prompt();
|
||||
}
|
||||
if self.ask_scale {
|
||||
return Prompt::new("Scale objects based on alignment points?", curves::KW).kw(&["Yes", "No"]).default("N");
|
||||
}
|
||||
match self.pts.len() {
|
||||
0 => Prompt::new("Specify first source point", Accept::POINT),
|
||||
1 => Prompt::new("Specify first destination point", Accept::POINT).base_opt(self.pts.first().copied()),
|
||||
2 => Prompt::new("Specify second source point", Accept::POINT),
|
||||
3 => Prompt::new("Specify second destination point", Accept::POINT).base_opt(self.pts.get(2).copied()),
|
||||
_ => Prompt::new("Specify third source point or <continue>", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.sel.done {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Cancel),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.objs = hs;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
};
|
||||
}
|
||||
if self.ask_scale {
|
||||
let yes = matches!(&i, Input::Keyword(k) | Input::Text(k) if k.to_ascii_lowercase().starts_with('y'));
|
||||
return self.finish(s, yes);
|
||||
}
|
||||
match i {
|
||||
Input::Point(p) => {
|
||||
if self.pts.len() < 4 {
|
||||
self.pts.push(p);
|
||||
} else {
|
||||
s.echo("Third pair ignored (2D align).");
|
||||
self.ask_scale = true;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => match self.pts.len() {
|
||||
n if n >= 4 => {
|
||||
self.ask_scale = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
2 | 3 => {
|
||||
self.pts.truncate(2);
|
||||
self.finish(s, false)
|
||||
}
|
||||
_ => Ok(Step::Cancel),
|
||||
},
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
match self.pts.as_slice() {
|
||||
[a] => vec![line(*a, c)],
|
||||
[_, _, a] => vec![line(*a, c)],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// BLEND
|
||||
// =====================================================================================
|
||||
|
||||
/// Endpoint of a curve nearest `pick` and the outward unit tangent there.
|
||||
fn curve_end(k: &EntityKind, pick: Vec2) -> Option<(Vec2, Vec2)> {
|
||||
let c = Chain::of(k)?;
|
||||
if c.closed {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (c.start()?, c.end()?);
|
||||
if pick.dist(b) <= pick.dist(a) {
|
||||
let t = c.segs.last()?.tangent(1.0);
|
||||
Some((b, t))
|
||||
} else {
|
||||
let t = c.segs.first()?.tangent(0.0);
|
||||
Some((a, -t))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn blend_spline(e1: (Vec2, Vec2), e2: (Vec2, Vec2), smooth: bool) -> Option<Spline> {
|
||||
let (p1, t1) = e1;
|
||||
let (p2, t2) = e2;
|
||||
let d = p1.dist(p2);
|
||||
if d < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let ctrl = if smooth {
|
||||
let k = d / 5.0;
|
||||
vec![p1, p1 + t1 * k, p1 + t1 * (2.0 * k), p2 + t2 * (2.0 * k), p2 + t2 * k, p2]
|
||||
} else {
|
||||
let k = d / 3.0;
|
||||
vec![p1, p1 + t1 * k, p2 + t2 * k, p2]
|
||||
};
|
||||
let deg = ctrl.len() - 1;
|
||||
Some(Spline::from_control(ctrl, deg))
|
||||
}
|
||||
|
||||
fn blend(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, smooth: bool) -> Result<Handle> {
|
||||
let k1 = curves::entity(s, h1)?.kind;
|
||||
let k2 = curves::entity(s, h2)?.kind;
|
||||
let e1 = curve_end(&k1, p1).ok_or_else(|| other("Select an open line, arc, polyline, ellipse arc or spline."))?;
|
||||
let e2 = curve_end(&k2, p2).ok_or_else(|| other("Select an open line, arc, polyline, ellipse arc or spline."))?;
|
||||
let sp = blend_spline(e1, e2, smooth).ok_or_else(|| other("The ends coincide."))?;
|
||||
s.add_entity(EntityKind::Spline(sp))
|
||||
}
|
||||
|
||||
pub(super) fn run_blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h1 = curves::handle_param(p, "h1").ok_or_else(|| bad("blend", "`h1` is required"))?;
|
||||
let h2 = curves::handle_param(p, "h2").ok_or_else(|| bad("blend", "`h2` is required"))?;
|
||||
let p1 = point_req("blend", p, "p1")?;
|
||||
let p2 = point_req("blend", p, "p2")?;
|
||||
let smooth = str_param(p, "continuity").is_some_and(|c| c.eq_ignore_ascii_case("smooth"));
|
||||
let h = blend(s, h1, p1, h2, p2, smooth)?;
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BlendM {
|
||||
first: Option<(Handle, Vec2)>,
|
||||
smooth: bool,
|
||||
asking: bool,
|
||||
}
|
||||
|
||||
impl Interactive for BlendM {
|
||||
fn name(&self) -> &'static str {
|
||||
"BLEND"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.set_selection(Vec::new());
|
||||
s.echo(format!("Continuity = {}", if self.smooth { "Smooth" } else { "Tangent" }));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.asking {
|
||||
return Prompt::new("Enter continuity", curves::KW).kw(&["Tangent", "Smooth"]).default("Tangent");
|
||||
}
|
||||
match self.first {
|
||||
None => Prompt::new("Select first object or", Accept::POINT).kw(&["CONtinuity"]),
|
||||
Some(_) => Prompt::new("Select second point", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.asking {
|
||||
if let Input::Keyword(k) | Input::Text(k) = &i {
|
||||
self.smooth = k.to_ascii_lowercase().starts_with('s');
|
||||
}
|
||||
self.asking = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match i {
|
||||
Input::Keyword(_) => {
|
||||
self.asking = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Point(p) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
match self.first {
|
||||
None => {
|
||||
let k = curves::entity(s, h)?.kind;
|
||||
curve_end(&k, p).ok_or_else(|| other("Select an open curve."))?;
|
||||
self.first = Some((h, p));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Some((h1, p1)) => {
|
||||
blend(s, h1, p1, h, p, self.smooth)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// FILLET extensions (arcs, circles, polylines)
|
||||
// =====================================================================================
|
||||
|
||||
/// How a fillet operand is trimmed: the new kind and the arrival direction at the tangent point.
|
||||
pub(super) fn trim_for_fillet(k: &EntityKind, pick: Vec2, t: Vec2) -> Option<(Option<EntityKind>, Option<Vec2>)> {
|
||||
match k {
|
||||
EntityKind::Line(l) => {
|
||||
let (a, b) = (l.a.xy(), l.b.xy());
|
||||
let keep = if (a - t).dot(pick - t) >= (b - t).dot(pick - t) { a } else { b };
|
||||
let dir = (t - keep).normalized();
|
||||
if dir == Vec2::ZERO {
|
||||
return Some((None, None));
|
||||
}
|
||||
Some((Some(if keep == a { line(a, t) } else { line(t, b) }), Some(dir)))
|
||||
}
|
||||
EntityKind::Arc(ar) => {
|
||||
let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end);
|
||||
let ta = g.center.angle_to(t);
|
||||
let ccw_tan = (t - g.center).perp().normalized();
|
||||
let ap = g.center.angle_to(pick);
|
||||
let keep_start_side = if g.contains_angle(ta) {
|
||||
// Keep the side of the tangent point that holds the pick.
|
||||
cadcraft_geom::angle_in_sweep(ap, g.start, ta)
|
||||
} else {
|
||||
// Extend the nearer end.
|
||||
ccw_sweep(g.end, ta) <= ccw_sweep(ta, g.start)
|
||||
};
|
||||
if keep_start_side {
|
||||
let na = Arc::new(g.center, g.radius, g.start, ta);
|
||||
Some((Some(arc(&na)), Some(ccw_tan)))
|
||||
} else {
|
||||
let na = Arc::new(g.center, g.radius, ta, g.end);
|
||||
Some((Some(arc(&na)), Some(-ccw_tan)))
|
||||
}
|
||||
}
|
||||
EntityKind::Circle(_) => Some((None, None)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fillet two curves (lines, arcs, circles; elliptical arcs and splines in `curve_edits`) with
|
||||
/// radius `r`. Returns the new arc, if any.
|
||||
pub(crate) fn fillet_curves(s: &mut Session, h1: Handle, p1: Vec2, h2: Handle, p2: Vec2, r: f64) -> Result<Option<Handle>> {
|
||||
if curves::is_locked(s, h1) || curves::is_locked(s, h2) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let e1 = curves::entity(s, h1)?;
|
||||
let e2 = curves::entity(s, h2)?;
|
||||
if [&e1, &e2].iter().any(|e| matches!(e.kind, EntityKind::Ellipse(_) | EntityKind::Spline(_))) {
|
||||
return curve_edits::fillet_curvy(s, h1, p1, h2, p2, r, &e1, &e2);
|
||||
}
|
||||
let unsupported = || other("Fillet works on lines, arcs, circles, elliptical arcs and splines (and polylines with the Polyline option).");
|
||||
let o1 = TanObj::from_kind(&e1.kind, p1).filter(|_| matches!(e1.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_)));
|
||||
let o2 = TanObj::from_kind(&e2.kind, p2).filter(|_| matches!(e2.kind, EntityKind::Line(_) | EntityKind::Arc(_) | EntityKind::Circle(_)));
|
||||
let (Some(o1), Some(o2)) = (o1, o2) else { return Err(unsupported()) };
|
||||
if h1 == h2 {
|
||||
return Err(other("Cannot fillet an object to itself."));
|
||||
}
|
||||
let (t1, t2, fc) = if r <= 1e-12 {
|
||||
// Sharp corner: the intersection nearest the picks.
|
||||
let hits: Vec<Vec2> = match (o1, o2) {
|
||||
(TanObj::Line(a), TanObj::Line(b)) => line_line_infinite(a.a, a.b, b.a, b.b).map(|x| vec![x.0]).unwrap_or_default(),
|
||||
(TanObj::Line(l), TanObj::Circle(c)) | (TanObj::Circle(c), TanObj::Line(l)) => line_circle(&l, &c).into_iter().map(|x| x.0).collect(),
|
||||
(TanObj::Circle(a), TanObj::Circle(b)) => cadcraft_geom::circle_circle(&a, &b),
|
||||
};
|
||||
let x = hits
|
||||
.into_iter()
|
||||
.min_by(|a, b| (a.dist(p1) + a.dist(p2)).total_cmp(&(b.dist(p1) + b.dist(p2))))
|
||||
.ok_or_else(|| other("The objects do not intersect."))?;
|
||||
(x, x, None)
|
||||
} else {
|
||||
let c = curves::ttr(o1, p1, o2, p2, r).ok_or_else(|| other("Radius is too large."))?;
|
||||
(o1.touch(&c), o2.touch(&c), Some(c))
|
||||
};
|
||||
let (n1, d1) = trim_for_fillet(&e1.kind, p1, t1).ok_or_else(unsupported)?;
|
||||
let (n2, d2) = trim_for_fillet(&e2.kind, p2, t2).ok_or_else(unsupported)?;
|
||||
let new_arc = fc.map(|c| {
|
||||
let a1 = c.center.angle_to(t1);
|
||||
let a2 = c.center.angle_to(t2);
|
||||
let ccw1 = (t1 - c.center).perp().normalized();
|
||||
let ccw2 = (t2 - c.center).perp().normalized();
|
||||
let ccw = match (d1, d2) {
|
||||
(Some(d), _) => ccw1.dot(d) > 0.0,
|
||||
(None, Some(d)) => ccw2.dot(-d) > 0.0,
|
||||
_ => ccw_sweep(a1, a2) <= std::f64::consts::PI,
|
||||
};
|
||||
if ccw { Arc::new(c.center, c.radius, a1, a2) } else { Arc::new(c.center, c.radius, a2, a1) }
|
||||
});
|
||||
if let Some(k) = n1 {
|
||||
set_kind(s, h1, k)?;
|
||||
}
|
||||
if let Some(k) = n2 {
|
||||
set_kind(s, h2, k)?;
|
||||
}
|
||||
match new_arc {
|
||||
Some(a) => {
|
||||
let space = s.doc()?.space_of(h1).unwrap_or_else(|| s.space());
|
||||
Ok(Some(push_entity(s, &space, e1.common.clone(), arc(&a))?))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fillet every corner between two straight segments of a polyline. Returns the count.
|
||||
pub(crate) fn fillet_polyline(s: &mut Session, h: Handle, r: f64) -> Result<usize> {
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let e = curves::entity(s, h)?;
|
||||
let EntityKind::LwPolyline(pl) = &e.kind else { return Err(other("Select a 2D polyline.")) };
|
||||
let vs = &pl.vertices;
|
||||
let n = vs.len();
|
||||
if n < 3 {
|
||||
return Ok(0);
|
||||
}
|
||||
let at = |i: usize| vs.get(i % n).copied().unwrap_or_default();
|
||||
// Distance cut back from each corner (0 = not filleted).
|
||||
let mut cut = vec![0.0f64; n];
|
||||
let mut turn = vec![0.0f64; n];
|
||||
for (i, c) in cut.iter_mut().enumerate() {
|
||||
if !pl.closed && (i == 0 || i + 1 == n) {
|
||||
continue;
|
||||
}
|
||||
let prev = at(i + n - 1);
|
||||
let cur = at(i);
|
||||
let next = at(i + 1);
|
||||
if prev.bulge.abs() > 1e-12 || cur.bulge.abs() > 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let u1 = (prev.p - cur.p).normalized();
|
||||
let u2 = (next.p - cur.p).normalized();
|
||||
if u1 == Vec2::ZERO || u2 == Vec2::ZERO {
|
||||
continue;
|
||||
}
|
||||
let half = u1.dot(u2).clamp(-1.0, 1.0).acos() / 2.0;
|
||||
if half < 1e-9 || (std::f64::consts::FRAC_PI_2 - half).abs() < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
*c = if r <= 1e-12 { 0.0 } else { r / half.tan() };
|
||||
if let Some(t) = turn.get_mut(i) {
|
||||
*t = (-u1).cross(u2).atan2((-u1).dot(u2));
|
||||
}
|
||||
}
|
||||
// Drop fillets that do not fit their segments.
|
||||
for i in 0..n {
|
||||
let j = (i + 1) % n;
|
||||
let seg = at(i).p.dist(at(j).p);
|
||||
let (ci, cj) = (cut.get(i).copied().unwrap_or(0.0), cut.get(j).copied().unwrap_or(0.0));
|
||||
if ci + cj > seg + 1e-9 {
|
||||
if let Some(x) = cut.get_mut(i) {
|
||||
*x = 0.0;
|
||||
}
|
||||
if let Some(x) = cut.get_mut(j) {
|
||||
*x = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out: Vec<PolyVertex> = Vec::with_capacity(n * 2);
|
||||
let mut count = 0;
|
||||
for i in 0..n {
|
||||
let v = at(i);
|
||||
let c = cut.get(i).copied().unwrap_or(0.0);
|
||||
if c <= 1e-12 {
|
||||
out.push(v);
|
||||
continue;
|
||||
}
|
||||
let prev = at(i + n - 1).p;
|
||||
let next = at(i + 1).p;
|
||||
let t1 = v.p + (prev - v.p).normalized() * c;
|
||||
let t2 = v.p + (next - v.p).normalized() * c;
|
||||
let b = cadcraft_geom::arc_to_bulge(turn.get(i).copied().unwrap_or(0.0));
|
||||
out.push(PolyVertex { p: t1, bulge: b, ..v });
|
||||
out.push(PolyVertex { p: t2, bulge: 0.0, ..v });
|
||||
count += 1;
|
||||
}
|
||||
set_kind(s, h, EntityKind::LwPolyline(LwPolyline { vertices: out, ..pl.clone() }))?;
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
use super::trimext::ellipse_of;
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// LENGTHEN
|
||||
// =====================================================================================
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum LenMode {
|
||||
Delta(f64),
|
||||
DeltaAngle(f64),
|
||||
Percent(f64),
|
||||
Total(f64),
|
||||
TotalAngle(f64),
|
||||
To(Vec2),
|
||||
}
|
||||
|
||||
/// Current length (and included angle for arcs) of a curve.
|
||||
fn measure_len(k: &EntityKind) -> Option<(f64, Option<f64>)> {
|
||||
match k {
|
||||
EntityKind::Arc(a) => {
|
||||
let g = Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
Some((g.len(), Some(g.sweep())))
|
||||
}
|
||||
EntityKind::Ellipse(e) => Some((ellipse_of(e).length(), None)),
|
||||
EntityKind::Spline(sp) if sp.is_valid() => Some((sp.length(), None)),
|
||||
_ => Chain::of(k).map(|c| (c.len(), None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lengthen a line, arc, open polyline, elliptical arc or open spline at the end nearest `pick`.
|
||||
pub(crate) fn lengthen_kind(k: &EntityKind, pick: Option<Vec2>, mode: LenMode) -> Result<EntityKind> {
|
||||
let too_short = || other("Lengthening would make the object zero length or negative.");
|
||||
match k {
|
||||
EntityKind::Line(l) => {
|
||||
let (a, b) = (l.a.xy(), l.b.xy());
|
||||
let at_b = pick.is_none_or(|p| p.dist(b) <= p.dist(a));
|
||||
let (fixed, end) = if at_b { (a, b) } else { (b, a) };
|
||||
let len = fixed.dist(end);
|
||||
let dir = (end - fixed).normalized();
|
||||
if dir == Vec2::ZERO {
|
||||
return Err(other("Zero-length line."));
|
||||
}
|
||||
let nl = match mode {
|
||||
LenMode::Delta(d) => len + d,
|
||||
LenMode::Percent(p) => len * p / 100.0,
|
||||
LenMode::Total(t) => t,
|
||||
LenMode::To(q) => (q - fixed).dot(dir),
|
||||
LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")),
|
||||
};
|
||||
if !(nl > 1e-12 && nl.is_finite()) {
|
||||
return Err(too_short());
|
||||
}
|
||||
let ne = fixed + dir * nl;
|
||||
let mut out = l.clone();
|
||||
if at_b {
|
||||
out.b = ne.to3(l.b.z);
|
||||
} else {
|
||||
out.a = ne.to3(l.a.z);
|
||||
}
|
||||
Ok(EntityKind::Line(out))
|
||||
}
|
||||
EntityKind::Arc(ar) => {
|
||||
let g = Arc::new(ar.center.xy(), ar.radius, ar.start, ar.end);
|
||||
let at_end = pick.is_none_or(|p| p.dist(g.end_point()) <= p.dist(g.start_point()));
|
||||
let sw = g.sweep();
|
||||
let r = g.radius.max(1e-300);
|
||||
let ns = match mode {
|
||||
LenMode::Delta(d) => sw + d / r,
|
||||
LenMode::DeltaAngle(a) => sw + a,
|
||||
LenMode::Percent(p) => sw * p / 100.0,
|
||||
LenMode::Total(t) => t / r,
|
||||
LenMode::TotalAngle(a) => a,
|
||||
LenMode::To(q) => {
|
||||
let a = g.center.angle_to(q);
|
||||
if at_end { ccw_sweep(g.start, a) } else { ccw_sweep(a, g.end) }
|
||||
}
|
||||
};
|
||||
if !(ns > 1e-12 && ns < TAU - 1e-9) {
|
||||
return Err(other("The new arc would be zero length or a full circle."));
|
||||
}
|
||||
let na = if at_end { Arc::new(g.center, g.radius, g.start, g.start + ns) } else { Arc::new(g.center, g.radius, g.end - ns, g.end) };
|
||||
Ok(EntityKind::Arc(cadcraft_doc::Arc { radius: na.radius, start: na.start, end: na.end, ..ar.clone() }))
|
||||
}
|
||||
EntityKind::LwPolyline(p) if !p.closed && p.vertices.len() >= 2 => {
|
||||
let total = Polyline { vertices: p.vertices.clone(), closed: false }.len();
|
||||
let mut vs = p.vertices.clone();
|
||||
let n = vs.len();
|
||||
let (first, last) = (vs.first().map(|v| v.p).unwrap_or_default(), vs.last().map(|v| v.p).unwrap_or_default());
|
||||
let at_end = pick.is_none_or(|q| q.dist(last) <= q.dist(first));
|
||||
if !at_end {
|
||||
vs = curves::reverse_vertices(&vs, false);
|
||||
}
|
||||
let seg_start = vs.get(n - 2).copied().unwrap_or_default();
|
||||
if seg_start.bulge.abs() > 1e-12 {
|
||||
return Err(other("The end segment is an arc; explode the polyline or use an open line end."));
|
||||
}
|
||||
let end = vs.get(n - 1).map(|v| v.p).unwrap_or_default();
|
||||
let seg_len = seg_start.p.dist(end);
|
||||
let dir = (end - seg_start.p).normalized();
|
||||
if dir == Vec2::ZERO {
|
||||
return Err(other("Zero-length end segment."));
|
||||
}
|
||||
let delta = match mode {
|
||||
LenMode::Delta(d) => d,
|
||||
LenMode::Percent(pc) => total * pc / 100.0 - total,
|
||||
LenMode::Total(t) => t - total,
|
||||
LenMode::To(q) => (q - seg_start.p).dot(dir) - seg_len,
|
||||
LenMode::DeltaAngle(_) | LenMode::TotalAngle(_) => return Err(other("Angle options apply to arcs.")),
|
||||
};
|
||||
let nl = seg_len + delta;
|
||||
if !(nl > 1e-12 && nl.is_finite()) {
|
||||
return Err(too_short());
|
||||
}
|
||||
if let Some(v) = vs.get_mut(n - 1) {
|
||||
v.p = seg_start.p + dir * nl;
|
||||
}
|
||||
if !at_end {
|
||||
vs = curves::reverse_vertices(&vs, false);
|
||||
}
|
||||
Ok(EntityKind::LwPolyline(LwPolyline { vertices: vs, ..p.clone() }))
|
||||
}
|
||||
EntityKind::Ellipse(e) => curve_edits::lengthen_ellipse(e, pick, mode),
|
||||
EntityKind::Spline(sp) => curve_edits::lengthen_spline(sp, pick, mode),
|
||||
_ => Err(other("Select a line, arc, open polyline, elliptical arc or open spline.")),
|
||||
}
|
||||
}
|
||||
|
||||
fn len_mode(p: &Value) -> Option<LenMode> {
|
||||
let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
if let Some(v) = f("delta") {
|
||||
Some(LenMode::Delta(v))
|
||||
} else if let Some(v) = f("deltaAngle") {
|
||||
Some(LenMode::DeltaAngle(v.to_radians()))
|
||||
} else if let Some(v) = f("percent") {
|
||||
Some(LenMode::Percent(v))
|
||||
} else if let Some(v) = f("total") {
|
||||
Some(LenMode::Total(v))
|
||||
} else if let Some(v) = f("totalAngle") {
|
||||
Some(LenMode::TotalAngle(v.to_radians()))
|
||||
} else {
|
||||
point_param(p, "to").map(LenMode::To)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_lengthen(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("lengthen", "`handle` is required"))?;
|
||||
let e = curves::entity(s, h)?;
|
||||
let Some(mode) = len_mode(p) else {
|
||||
let (l, a) = measure_len(&e.kind).ok_or_else(|| other("Cannot measure that object."))?;
|
||||
return Ok(json!({ "length": l, "angle": a.map(f64::to_degrees) }));
|
||||
};
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let k = lengthen_kind(&e.kind, point_param(p, "pick"), mode)?;
|
||||
let l = measure_len(&k).map(|x| x.0).unwrap_or(0.0);
|
||||
set_kind(s, h, k)?;
|
||||
Ok(json!({ "handle": h.hex(), "length": l }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum LPhase {
|
||||
#[default]
|
||||
Main,
|
||||
Delta,
|
||||
DeltaAngle,
|
||||
Percent,
|
||||
Total,
|
||||
TotalAngle,
|
||||
Change,
|
||||
DynEnd(Handle, Vec2),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct LengthenM {
|
||||
phase: LPhase,
|
||||
mode: Option<LenMode>,
|
||||
dynamic: bool,
|
||||
history: Vec<(Handle, EntityKind)>,
|
||||
}
|
||||
|
||||
impl Interactive for LengthenM {
|
||||
fn name(&self) -> &'static str {
|
||||
"LENGTHEN"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match self.phase {
|
||||
LPhase::Main => {
|
||||
Prompt::new("Select an object to measure or", Accept::POINT).kw(&["DElta", "Percent", "Total", "DYnamic"]).default("DYnamic")
|
||||
}
|
||||
LPhase::Delta => Prompt::new("Enter delta length or", Accept::NUMBER).kw(&["Angle"]).default("0.0000"),
|
||||
LPhase::DeltaAngle => Prompt::new("Enter delta angle", Accept::NUMBER).default("0"),
|
||||
LPhase::Percent => Prompt::new("Enter percentage length", Accept::NUMBER).default("100.0000"),
|
||||
LPhase::Total => Prompt::new("Specify total length or", Accept::NUMBER).kw(&["Angle"]).default("1.0000"),
|
||||
LPhase::TotalAngle => Prompt::new("Specify total angle", Accept::NUMBER).default("57"),
|
||||
LPhase::Change => Prompt::new("Select an object to change or", Accept::POINT).kw(&["Undo"]),
|
||||
LPhase::DynEnd(..) => Prompt::new("Specify new end point", Accept::POINT),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let num = |i: &Input| -> Option<f64> { if let Input::Text(t) = i { number(t) } else { None } };
|
||||
let au = s.angle_settings();
|
||||
let ang = |i: &Input| -> Option<f64> { if let Input::Text(t) = i { au.amount(t) } else { None } };
|
||||
match (self.phase, &i) {
|
||||
(LPhase::Main, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let k = curves::entity(s, h)?.kind;
|
||||
match measure_len(&k) {
|
||||
Some((l, Some(a))) => s.echo(format!("Current length: {l:.4}, included angle: {:.0}", a.to_degrees())),
|
||||
Some((l, None)) => s.echo(format!("Current length: {l:.4}")),
|
||||
None => s.echo("Cannot measure that object."),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::Main, Input::Keyword(k)) => {
|
||||
self.phase = match k.as_str() {
|
||||
"DElta" => LPhase::Delta,
|
||||
"Percent" => LPhase::Percent,
|
||||
"Total" => LPhase::Total,
|
||||
_ => {
|
||||
self.dynamic = true;
|
||||
LPhase::Change
|
||||
}
|
||||
};
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::Main, Input::Enter) => {
|
||||
self.dynamic = true;
|
||||
self.phase = LPhase::Change;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::Delta | LPhase::Total, Input::Keyword(_)) => {
|
||||
self.phase = if self.phase == LPhase::Delta { LPhase::DeltaAngle } else { LPhase::TotalAngle };
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::Delta, _) => {
|
||||
self.mode = Some(LenMode::Delta(num(&i).ok_or_else(|| other("Requires a distance."))?));
|
||||
self.phase = LPhase::Change;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::DeltaAngle, _) => {
|
||||
self.mode = Some(LenMode::DeltaAngle(ang(&i).ok_or_else(|| other("Requires an angle."))?));
|
||||
self.phase = LPhase::Change;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::Percent, _) => {
|
||||
let v = num(&i).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive percentage."))?;
|
||||
self.mode = Some(LenMode::Percent(v));
|
||||
self.phase = LPhase::Change;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::Total, _) => {
|
||||
let v = num(&i).filter(|v| *v > 0.0).ok_or_else(|| other("Requires a positive length."))?;
|
||||
self.mode = Some(LenMode::Total(v));
|
||||
self.phase = LPhase::Change;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::TotalAngle, _) => {
|
||||
self.mode = Some(LenMode::TotalAngle(ang(&i).ok_or_else(|| other("Requires an angle."))?));
|
||||
self.phase = LPhase::Change;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::Change, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, *p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
if self.dynamic {
|
||||
self.phase = LPhase::DynEnd(h, *p);
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
let mode = self.mode.ok_or_else(|| other("No lengthen mode."))?;
|
||||
let k = curves::entity(s, h)?.kind;
|
||||
let nk = lengthen_kind(&k, Some(*p), mode)?;
|
||||
set_kind(s, h, nk)?;
|
||||
self.history.push((h, k));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::DynEnd(h, pick), Input::Point(p)) => {
|
||||
let k = curves::entity(s, h)?.kind;
|
||||
let nk = lengthen_kind(&k, Some(pick), LenMode::To(*p))?;
|
||||
set_kind(s, h, nk)?;
|
||||
self.history.push((h, k));
|
||||
self.phase = LPhase::Change;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(LPhase::Change, Input::Keyword(_)) => {
|
||||
if let Some((h, k)) = self.history.pop() {
|
||||
set_kind(s, h, k)?;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(_, Input::Enter) => Ok(Step::Done),
|
||||
_ => Err(other("Invalid input.")),
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if let LPhase::DynEnd(h, pick) = self.phase
|
||||
&& let Ok(e) = curves::entity(s, h)
|
||||
{
|
||||
return lengthen_kind(&e.kind, Some(pick), LenMode::To(c)).map(|k| vec![k]).unwrap_or_default();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// NCOPY
|
||||
// =====================================================================================
|
||||
|
||||
/// The leaf objects of a block reference in world coordinates (nested references expanded).
|
||||
fn nested_leaves(d: &cadcraft_doc::Drawing, ins: &cadcraft_doc::Insert, m: &Mat3, depth: usize, out: &mut Vec<(Common, EntityKind)>) {
|
||||
if depth >= MAX_BLOCK_DEPTH || out.len() >= MAX_GEN {
|
||||
return;
|
||||
}
|
||||
let Some(b) = d.block(&ins.block) else { return };
|
||||
let mm = m.then_before(ins.transform(b.base.xy()));
|
||||
for e in b.entities.iter() {
|
||||
match &e.kind {
|
||||
EntityKind::Insert(inner) => nested_leaves(d, inner, &mm, depth + 1, out),
|
||||
EntityKind::AttDef(_) => {}
|
||||
k => {
|
||||
let mut k = k.clone();
|
||||
k.transform(&mm);
|
||||
out.push((e.common.clone(), k));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn leaves_of(s: &Session, h: Handle) -> Result<Vec<(Common, EntityKind)>> {
|
||||
let e = curves::entity(s, h)?;
|
||||
let EntityKind::Insert(ins) = &e.kind else { return Err(other("Select a block reference.")) };
|
||||
let mut out = Vec::new();
|
||||
nested_leaves(s.doc()?, ins, &Mat3::IDENTITY, 0, &mut out);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn nearest_leaf(leaves: Vec<(Common, EntityKind)>, p: Vec2) -> Option<(Common, EntityKind)> {
|
||||
leaves.into_iter().min_by(|a, b| curves::kind_distance(&a.1, p).total_cmp(&curves::kind_distance(&b.1, p)))
|
||||
}
|
||||
|
||||
fn place_copies(s: &mut Session, items: &[(Common, EntityKind)], delta: Vec2) -> Result<Vec<Handle>> {
|
||||
let space = s.space();
|
||||
let m = Mat3::translate(delta);
|
||||
let mut out = Vec::new();
|
||||
for (c, k) in items {
|
||||
let mut k = k.clone();
|
||||
k.transform(&m);
|
||||
out.push(push_entity(s, &space, c.clone(), k)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn run_ncopy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("ncopy", "`handle` (block reference) is required"))?;
|
||||
let leaves = leaves_of(s, h)?;
|
||||
let items: Vec<(Common, EntityKind)> = match point_param(p, "pick") {
|
||||
Some(q) => nearest_leaf(leaves, q).into_iter().collect(),
|
||||
None => leaves,
|
||||
};
|
||||
if items.is_empty() {
|
||||
return Err(other("The block reference has no nested objects."));
|
||||
}
|
||||
let delta = match (point_param(p, "delta"), point_param(p, "from"), point_param(p, "to")) {
|
||||
(Some(d), _, _) => d,
|
||||
(None, Some(a), Some(b)) => b - a,
|
||||
_ => Vec2::ZERO,
|
||||
};
|
||||
let hs = place_copies(s, &items, delta)?;
|
||||
Ok(json!({ "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct NcopyM {
|
||||
items: Vec<(Common, EntityKind)>,
|
||||
selecting_done: bool,
|
||||
base: Option<Vec2>,
|
||||
}
|
||||
|
||||
impl Interactive for NcopyM {
|
||||
fn name(&self) -> &'static str {
|
||||
"NCOPY"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
match (self.selecting_done, self.base) {
|
||||
(false, _) => Prompt::new("Select nested objects to copy or", Accept::POINT).kw(&["Settings"]),
|
||||
(true, None) => Prompt::new("Specify base point or", Accept::POINT).kw(&["Displacement", "Multiple"]).default("Displacement"),
|
||||
(true, Some(b)) => Prompt::new("Specify second point or <use first point as displacement>", Accept::POINT).base(b),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.selecting_done {
|
||||
return match i {
|
||||
Input::Point(p) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let leaf = nearest_leaf(leaves_of(s, h)?, p).ok_or_else(|| other("Nothing nested there."))?;
|
||||
s.echo(format!("Nested object: {}", leaf.1.type_name()));
|
||||
if self.items.len() < MAX_GEN {
|
||||
self.items.push(leaf);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(_) => {
|
||||
s.echo("Insert mode: Copy (nested objects are copied as independent objects).");
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Enter => {
|
||||
if self.items.is_empty() {
|
||||
return Ok(Step::Cancel);
|
||||
}
|
||||
self.selecting_done = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
};
|
||||
}
|
||||
match (self.base, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
self.base = Some(p);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(None, Input::Enter | Input::Keyword(_)) => {
|
||||
place_copies(s, &self.items, Vec2::ZERO)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Some(b), Input::Point(p)) => {
|
||||
place_copies(s, &self.items, p - b)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(Some(b), Input::Enter) => {
|
||||
place_copies(s, &self.items, b)?;
|
||||
Ok(Step::Done)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
fn preview(&self, _s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
let Some(b) = self.base else { return Vec::new() };
|
||||
let m = Mat3::translate(c - b);
|
||||
self.items
|
||||
.iter()
|
||||
.take(500)
|
||||
.map(|(_, k)| {
|
||||
let mut k = k.clone();
|
||||
k.transform(&m);
|
||||
k
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// ARRAYPATH (non-associative copies along a curve)
|
||||
// =====================================================================================
|
||||
|
||||
pub(crate) fn arraypath(
|
||||
s: &mut Session,
|
||||
hs: &[Handle],
|
||||
path: Handle,
|
||||
count: Option<usize>,
|
||||
spacing: Option<f64>,
|
||||
align: bool,
|
||||
) -> Result<Vec<Handle>> {
|
||||
let pk = curves::entity(s, path)?.kind;
|
||||
let c = Chain::of(&pk).ok_or_else(|| other("Select a line, arc, circle, polyline, ellipse or spline as the path."))?;
|
||||
let l = c.len();
|
||||
if l <= 1e-12 {
|
||||
return Err(other("The path has no length."));
|
||||
}
|
||||
let objs: Vec<Handle> = hs.iter().copied().filter(|h| *h != path).collect();
|
||||
if objs.is_empty() {
|
||||
return Err(other("Select objects to array."));
|
||||
}
|
||||
let (n, step) = match (count, spacing) {
|
||||
(_, Some(sp)) if sp.is_finite() && sp > 0.0 => (((l / sp).floor() as usize).saturating_add(1), sp),
|
||||
(Some(n), _) => {
|
||||
let n = n.max(1);
|
||||
(n, if c.closed { l / n as f64 } else { l / (n.max(2) - 1) as f64 })
|
||||
}
|
||||
_ => (6, if c.closed { l / 6.0 } else { l / 5.0 }),
|
||||
};
|
||||
crate::cmd::array::check_size(s, n as u64, objs.len())?;
|
||||
let Some((p0, t0)) = c.at_length(0.0) else { return Ok(Vec::new()) };
|
||||
let mut out = Vec::new();
|
||||
for i in 1..n {
|
||||
let Some((p, t)) = c.at_length(step * i as f64) else { continue };
|
||||
let mut m = Mat3::translate(p - p0);
|
||||
if align {
|
||||
m = Mat3::rotate_about(p, t.angle() - t0.angle()).then_before(m);
|
||||
}
|
||||
out.extend(crate::cmd::modify::transform_entities(s, &objs, &m, true)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn run_arraypath(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let path = curves::handle_param(p, "path").ok_or_else(|| bad("arraypath", "`path` (handle) is required"))?;
|
||||
let count = p.get("count").and_then(Value::as_u64).map(|n| usize::try_from(n).unwrap_or(usize::MAX));
|
||||
let spacing = p.get("spacing").and_then(Value::as_f64);
|
||||
let r = arraypath(s, &hs, path, count, spacing, bool_or(p, "align", true))?;
|
||||
Ok(json!({ "handles": r.iter().map(|h| h.hex()).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(in crate::cmd) struct PathArrayM {
|
||||
sel: SelectPhase,
|
||||
objs: Vec<Handle>,
|
||||
path: Option<Handle>,
|
||||
}
|
||||
|
||||
impl PathArrayM {
|
||||
/// Start at the path prompt with the objects already selected (ARRAY > PAth).
|
||||
pub(in crate::cmd) fn with_objects(objs: Vec<Handle>) -> Self {
|
||||
PathArrayM { sel: SelectPhase { picked: objs.clone(), done: true, ..SelectPhase::default() }, objs, path: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for PathArrayM {
|
||||
fn name(&self) -> &'static str {
|
||||
"ARRAYPATH"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
self.objs = self.sel.picked.clone();
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if !self.sel.done {
|
||||
return self.sel.prompt();
|
||||
}
|
||||
match self.path {
|
||||
None => Prompt::new("Select path curve", Accept::POINT),
|
||||
Some(_) => Prompt::new("Enter number of items along path", Accept::NUMBER).default("6"),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if !self.sel.done {
|
||||
return match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Cancel),
|
||||
SelOutcome::Done(hs) => {
|
||||
self.objs = hs;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
};
|
||||
}
|
||||
match (self.path, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
Chain::of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a curve as the path."))?;
|
||||
self.path = Some(h);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some(path), inp @ (Input::Text(_) | Input::Enter)) => {
|
||||
let n = match &inp {
|
||||
Input::Text(t) => number(t).filter(|n| *n >= 1.0).ok_or_else(|| other("Requires a count of 1 or more."))? as usize,
|
||||
_ => 6,
|
||||
};
|
||||
arraypath(s, &self.objs, path, Some(n), None, true)?;
|
||||
s.set_selection(Vec::new());
|
||||
s.echo(format!("Type = Path Associative = No ({n} items)"));
|
||||
Ok(Step::Done)
|
||||
}
|
||||
(None, Input::Enter) => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
use super::*;
|
||||
|
||||
/// Fit curve: a tangent-continuous pair of arcs (biarc) per segment through every vertex,
|
||||
/// following the tangent directions given per vertex (Edit vertex Tangent).
|
||||
pub(crate) fn fit_vertices(vs: &[PolyVertex], closed: bool, tans: &[Option<Vec2>]) -> Vec<PolyVertex> {
|
||||
let pts: Vec<Vec2> = vs.iter().map(|v| v.p).collect();
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return vs.iter().map(|v| PolyVertex { bulge: 0.0, ..*v }).collect();
|
||||
}
|
||||
let at = |i: usize| pts.get(i).copied().unwrap_or_default();
|
||||
let tangent = |i: usize| -> Vec2 {
|
||||
let (prev, next) = if closed {
|
||||
(at((i + n - 1) % n), at((i + 1) % n))
|
||||
} else if i == 0 {
|
||||
(at(0), at(1))
|
||||
} else if i + 1 == n {
|
||||
(at(n - 2), at(n - 1))
|
||||
} else {
|
||||
(at(i - 1), at(i + 1))
|
||||
};
|
||||
let t = (next - prev).normalized();
|
||||
if t == Vec2::ZERO { Vec2::X } else { t }
|
||||
};
|
||||
// Open ends: mirror the neighbouring tangent across the chord for a natural end.
|
||||
let tan_at = |i: usize| -> Vec2 {
|
||||
if let Some(Some(t)) = tans.get(i) {
|
||||
return *t;
|
||||
}
|
||||
if closed || (i != 0 && i + 1 != n) {
|
||||
return tangent(i);
|
||||
}
|
||||
let (a, b, tb) = if i == 0 { (at(0), at(1), tangent(1)) } else { (at(n - 1), at(n - 2), -tangent(n - 2)) };
|
||||
let c = (b - a).normalized();
|
||||
let m = c * (2.0 * tb.dot(c)) - tb;
|
||||
let m = if i == 0 { m } else { -m };
|
||||
if m.len() < 1e-12 { c } else { m.normalized() }
|
||||
};
|
||||
let count = if closed { n } else { n - 1 };
|
||||
let mut out = Vec::with_capacity(count * 2 + 1);
|
||||
for i in 0..count {
|
||||
let p0 = at(i);
|
||||
let p1 = at((i + 1) % n);
|
||||
let t0 = tan_at(i);
|
||||
let t1 = tan_at((i + 1) % n);
|
||||
let v = p1 - p0;
|
||||
let tsum = t0 + t1;
|
||||
let denom = 2.0 * (1.0 - t0.dot(t1));
|
||||
let d = if denom.abs() < 1e-12 {
|
||||
let vt = v.dot(t1);
|
||||
if vt.abs() < 1e-12 { v.len() / 2.0 } else { v.len2() / (4.0 * vt) }
|
||||
} else {
|
||||
let vt = v.dot(tsum);
|
||||
(-vt + (vt * vt + denom * v.len2()).max(0.0).sqrt()) / denom
|
||||
};
|
||||
let pm = (p0 + t0 * d + p1 - t1 * d) / 2.0;
|
||||
let b1 = curves::bulge_from_tangent(p0, pm, t0);
|
||||
let b2 = -curves::bulge_from_tangent(p1, pm, -t1);
|
||||
let w = vs.get(i).copied().unwrap_or_default();
|
||||
out.push(PolyVertex { p: p0, bulge: if b1.is_finite() { b1 } else { 0.0 }, start_width: w.start_width, end_width: w.end_width });
|
||||
out.push(PolyVertex { p: pm, bulge: if b2.is_finite() { b2 } else { 0.0 }, start_width: w.end_width, end_width: w.end_width });
|
||||
}
|
||||
if !closed {
|
||||
out.push(PolyVertex::new(at(n - 1)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Spline curve: a cubic B-spline with the vertices as its frame (control points).
|
||||
pub(crate) fn spline_vertices(vs: &[PolyVertex], closed: bool, segs: usize) -> Vec<PolyVertex> {
|
||||
let pts: Vec<Vec2> = vs.iter().map(|v| v.p).collect();
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return vs.to_vec();
|
||||
}
|
||||
let segs = segs.clamp(1, 64).min((MAX_GEN / n).max(1));
|
||||
let mut out: Vec<Vec2> = Vec::new();
|
||||
if closed {
|
||||
// Uniform periodic cubic B-spline.
|
||||
let at = |i: usize| pts.get(i % n).copied().unwrap_or_default();
|
||||
for i in 0..n {
|
||||
let (p0, p1, p2, p3) = (at(i + n - 1), at(i), at(i + 1), at(i + 2));
|
||||
for k in 0..segs {
|
||||
let t = k as f64 / segs as f64;
|
||||
let (t2, t3) = (t * t, t * t * t);
|
||||
let b0 = (1.0 - t).powi(3) / 6.0;
|
||||
let b1 = (3.0 * t3 - 6.0 * t2 + 4.0) / 6.0;
|
||||
let b2 = (-3.0 * t3 + 3.0 * t2 + 3.0 * t + 1.0) / 6.0;
|
||||
let b3 = t3 / 6.0;
|
||||
out.push(p0 * b0 + p1 * b1 + p2 * b2 + p3 * b3);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let sp = Spline::from_control(pts.clone(), 3);
|
||||
let (lo, hi) = sp.domain();
|
||||
let m = (segs * (n - 1)).min(MAX_GEN);
|
||||
for k in 0..=m {
|
||||
out.push(sp.eval(lo + (hi - lo) * k as f64 / m as f64));
|
||||
}
|
||||
}
|
||||
out.into_iter().map(PolyVertex::new).collect()
|
||||
}
|
||||
|
||||
/// Join connected lines/arcs/open polylines onto the ends of polyline `h` (kept in place).
|
||||
pub(crate) fn pedit_join(s: &mut Session, h: Handle, others: &[Handle], fuzz: f64) -> Result<usize> {
|
||||
let e = curves::entity(s, h)?;
|
||||
let mut base = as_lwpoly(&e.kind).ok_or_else(|| other("Select a polyline."))?;
|
||||
if base.closed {
|
||||
return Err(other("Cannot join to a closed polyline."));
|
||||
}
|
||||
let mut chain = Chain::of(&EntityKind::LwPolyline(base.clone())).unwrap_or_default();
|
||||
let tol = fuzz.max(1e-6);
|
||||
let mut pool: Vec<(Handle, Chain)> = Vec::new();
|
||||
for o in others {
|
||||
if *o == h {
|
||||
continue;
|
||||
}
|
||||
let Ok(oe) = curves::entity(s, *o) else { continue };
|
||||
let ok = matches!(&oe.kind, EntityKind::Line(_) | EntityKind::Arc(_)) || matches!(&oe.kind, EntityKind::LwPolyline(p) if !p.closed);
|
||||
if ok && let Some(c) = Chain::of(&oe.kind) {
|
||||
pool.push((*o, c));
|
||||
}
|
||||
}
|
||||
let mut joined = Vec::new();
|
||||
while let (Some(st), Some(en)) = (chain.start(), chain.end()) {
|
||||
let Some(i) = pool
|
||||
.iter()
|
||||
.position(|(_, c)| c.start().zip(c.end()).is_some_and(|(a, b)| a.near(en, tol) || b.near(en, tol) || a.near(st, tol) || b.near(st, tol)))
|
||||
else {
|
||||
break;
|
||||
};
|
||||
let (oh, c) = pool.remove(i);
|
||||
let (Some(a), Some(b)) = (c.start(), c.end()) else { continue };
|
||||
if a.near(en, tol) {
|
||||
chain.segs.extend(c.segs);
|
||||
} else if b.near(en, tol) {
|
||||
chain.segs.extend(c.reversed().segs);
|
||||
} else if b.near(st, tol) {
|
||||
let mut v = c.segs;
|
||||
v.extend(chain.segs);
|
||||
chain.segs = v;
|
||||
} else {
|
||||
let mut v = c.reversed().segs;
|
||||
v.extend(chain.segs);
|
||||
chain.segs = v;
|
||||
}
|
||||
joined.push(oh);
|
||||
}
|
||||
if joined.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
chain.closed = chain.start().zip(chain.end()).is_some_and(|(a, b)| a.near(b, tol)) && chain.segs.len() > 1;
|
||||
base.vertices = chain.vertices();
|
||||
base.closed = chain.closed;
|
||||
let d = s.doc_mut()?;
|
||||
d.modify_entity(h, |e| e.kind = EntityKind::LwPolyline(base))?;
|
||||
for o in &joined {
|
||||
d.remove_entity(*o);
|
||||
}
|
||||
Ok(joined.len())
|
||||
}
|
||||
|
||||
fn pedit_apply(s: &mut Session, h: Handle, opt: &str, p: &Value) -> Result<Value> {
|
||||
let e = curves::entity(s, h)?;
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let mut pl = match &e.kind {
|
||||
EntityKind::LwPolyline(pl) => pl.clone(),
|
||||
EntityKind::Line(_) | EntityKind::Arc(_) if bool_or(p, "convert", true) => as_lwpoly(&e.kind).ok_or_else(|| other("Cannot convert."))?,
|
||||
_ => return Err(other("Object selected is not a polyline.")),
|
||||
};
|
||||
match opt {
|
||||
"close" | "c" => pl.closed = pl.vertices.len() > 1,
|
||||
"open" | "o" => pl.closed = false,
|
||||
"width" | "w" => {
|
||||
let w = f64_req("pedit", p, "width")?;
|
||||
if w < 0.0 {
|
||||
return Err(bad("pedit", "width must be non-negative"));
|
||||
}
|
||||
pl.const_width = w;
|
||||
for v in &mut pl.vertices {
|
||||
v.start_width = 0.0;
|
||||
v.end_width = 0.0;
|
||||
}
|
||||
}
|
||||
"fit" | "f" => pl.vertices = fit_vertices(&pl.vertices, pl.closed, &pedit_vertex::tangent_param(p, pl.vertices.len())?),
|
||||
"spline" | "s" => {
|
||||
let segs = s.doc()?.header.i64("SPLINESEGS", 8).clamp(1, 64) as usize;
|
||||
pl.vertices = spline_vertices(&pl.vertices, pl.closed, segs);
|
||||
}
|
||||
"decurve" | "d" => pl.vertices.iter_mut().for_each(|v| v.bulge = 0.0),
|
||||
"reverse" | "r" => pl.vertices = curves::reverse_vertices(&pl.vertices, pl.closed),
|
||||
"ltypegen" | "l" => pl.plinegen = bool_or(p, "on", !pl.plinegen),
|
||||
"join" | "j" => {
|
||||
set_kind(s, h, EntityKind::LwPolyline(pl))?;
|
||||
let others: Vec<Handle> = p
|
||||
.get("handles2")
|
||||
.or_else(|| p.get("join"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(curves::handle_value).collect())
|
||||
.unwrap_or_default();
|
||||
let n = pedit_join(s, h, &others, f64_or(p, "fuzz", 0.0))?;
|
||||
return Ok(json!({ "handle": h.hex(), "joined": n }));
|
||||
}
|
||||
"vertex" | "e" => {
|
||||
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("pedit", "`index` is required"))? as usize;
|
||||
let n = pl.vertices.len();
|
||||
if i >= n {
|
||||
return Err(bad("pedit", "vertex index out of range"));
|
||||
}
|
||||
match str_param(p, "action").unwrap_or("move") {
|
||||
"move" => {
|
||||
let to = point_req("pedit", p, "to")?;
|
||||
if let Some(v) = pl.vertices.get_mut(i) {
|
||||
v.p = to;
|
||||
}
|
||||
}
|
||||
"insert" => {
|
||||
let to = point_req("pedit", p, "to")?;
|
||||
if n >= MAX_GEN {
|
||||
return Err(bad("pedit", "too many vertices"));
|
||||
}
|
||||
if let Some(v) = pl.vertices.get_mut(i) {
|
||||
v.bulge = 0.0;
|
||||
}
|
||||
pl.vertices.insert(i + 1, PolyVertex::new(to));
|
||||
}
|
||||
"straighten" => {
|
||||
let j = p.get("index2").and_then(Value::as_u64).map_or(i, |j| j as usize);
|
||||
pedit_vertex::straighten(&mut pl, i, j)?;
|
||||
}
|
||||
"break" => {
|
||||
let j = p.get("index2").and_then(Value::as_u64).map_or(i, |j| j as usize);
|
||||
let hs = pedit_vertex::break_poly(s, h, &pl, i, j)?;
|
||||
return Ok(json!({ "handle": h.hex(), "handles": hs.iter().map(|h| h.hex()).collect::<Vec<_>>() }));
|
||||
}
|
||||
// The display is always current; tangents only exist within an interactive PEDIT.
|
||||
"regen" => {}
|
||||
"width" => {
|
||||
let sw = f64_or(p, "startWidth", 0.0).max(0.0);
|
||||
let ew = f64_or(p, "endWidth", sw).max(0.0);
|
||||
if let Some(v) = pl.vertices.get_mut(i) {
|
||||
v.start_width = sw;
|
||||
v.end_width = ew;
|
||||
}
|
||||
}
|
||||
_ => return Err(bad("pedit", "action must be move, insert, straighten, break, width or regen")),
|
||||
}
|
||||
}
|
||||
_ => return Err(bad("pedit", "unknown option")),
|
||||
}
|
||||
set_kind(s, h, EntityKind::LwPolyline(pl))?;
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
}
|
||||
|
||||
pub(super) fn run_pedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
if hs.is_empty() {
|
||||
return Err(bad("pedit", "`handle` is required"));
|
||||
}
|
||||
let opt = str_param(p, "option").ok_or_else(|| bad("pedit", "`option` is required"))?.to_ascii_lowercase();
|
||||
let mut out = Vec::new();
|
||||
for h in hs {
|
||||
out.push(pedit_apply(s, h, &opt, p)?);
|
||||
}
|
||||
Ok(if out.len() == 1 { out.pop().unwrap_or(Value::Null) } else { json!({ "results": out }) })
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub(super) enum PPhase {
|
||||
#[default]
|
||||
Select,
|
||||
Convert,
|
||||
Main,
|
||||
Width,
|
||||
Join,
|
||||
Vertex,
|
||||
VMove,
|
||||
VInsert,
|
||||
VWidthStart,
|
||||
VWidthEnd,
|
||||
/// The second marker of Break / Straighten.
|
||||
VMark,
|
||||
VTangent,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PeditM {
|
||||
pub(super) phase: PPhase,
|
||||
pub(super) h: Option<Handle>,
|
||||
pub(super) history: Vec<EntityKind>,
|
||||
original: Option<Vec<PolyVertex>>,
|
||||
join: SelectPhase,
|
||||
pub(super) vertex: usize,
|
||||
vwidth: f64,
|
||||
/// Break / Straighten: the second marked vertex, and which of the two runs.
|
||||
pub(super) mark2: usize,
|
||||
pub(super) breaking: bool,
|
||||
/// Edit vertex Tangent directions by vertex position, used by Fit.
|
||||
pub(super) tangents: Vec<(Vec2, Vec2)>,
|
||||
/// Pieces split off by Break, with the history length after it (erased again by Undo).
|
||||
pub(super) split_off: Vec<(usize, Vec<Handle>)>,
|
||||
}
|
||||
|
||||
impl PeditM {
|
||||
pub(super) fn poly(&self, s: &Session) -> Option<LwPolyline> {
|
||||
let e = s.doc().ok()?.entity(self.h?)?.clone();
|
||||
match &e.kind {
|
||||
EntityKind::LwPolyline(p) => Some(p.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn snapshot(&mut self, s: &Session) {
|
||||
if let Some(h) = self.h
|
||||
&& let Ok(e) = curves::entity(s, h)
|
||||
{
|
||||
self.history.push(e.kind);
|
||||
}
|
||||
}
|
||||
pub(super) fn apply(&mut self, s: &mut Session, opt: &str, p: Value) -> Result<()> {
|
||||
let h = self.h.ok_or_else(|| other("No polyline."))?;
|
||||
self.snapshot(s);
|
||||
if let Err(e) = pedit_apply(s, h, opt, &p) {
|
||||
self.history.pop();
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for PeditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"PEDIT"
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
let sel = s.selection();
|
||||
if let [h] = sel.as_slice() {
|
||||
let k = curves::entity(s, *h)?.kind;
|
||||
match k {
|
||||
EntityKind::LwPolyline(_) => {
|
||||
self.h = Some(*h);
|
||||
self.original = self.poly(s).map(|p| p.vertices);
|
||||
self.phase = PPhase::Main;
|
||||
}
|
||||
EntityKind::Line(_) | EntityKind::Arc(_) => {
|
||||
self.h = Some(*h);
|
||||
self.phase = PPhase::Convert;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, s: &Session) -> Prompt {
|
||||
match self.phase {
|
||||
PPhase::Select => Prompt::new("Select polyline or", Accept::POINT).kw(&["Multiple"]),
|
||||
PPhase::Convert => {
|
||||
Prompt::new("Object selected is not a polyline. Do you want to turn it into one?", curves::KW).kw(&["Yes", "No"]).default("Y")
|
||||
}
|
||||
PPhase::Main => {
|
||||
let closed = self.poly(s).is_some_and(|p| p.closed);
|
||||
let first = if closed { "Open" } else { "Close" };
|
||||
Prompt::new("Enter an option", curves::KW).kw(&[
|
||||
first,
|
||||
"Join",
|
||||
"Width",
|
||||
"Edit vertex",
|
||||
"Fit",
|
||||
"Spline",
|
||||
"Decurve",
|
||||
"Ltype gen",
|
||||
"Reverse",
|
||||
"Undo",
|
||||
])
|
||||
}
|
||||
PPhase::Width => Prompt::new("Specify new width for all segments", Accept::NUMBER),
|
||||
PPhase::Join => self.join.prompt(),
|
||||
PPhase::Vertex => Prompt::new("Enter a vertex editing option", curves::KW)
|
||||
.kw(&["Next", "Previous", "Break", "Insert", "Move", "Regen", "Straighten", "Tangent", "Width", "eXit"])
|
||||
.default("N"),
|
||||
PPhase::VMove => Prompt::new("Specify new location for marked vertex", Accept::POINT),
|
||||
PPhase::VInsert => Prompt::new("Specify location for new vertex", Accept::POINT),
|
||||
PPhase::VWidthStart => Prompt::new("Specify starting width for next segment", Accept::NUMBER),
|
||||
PPhase::VWidthEnd => Prompt::new("Specify ending width for next segment", Accept::NUMBER).default(format!("{:.4}", self.vwidth)),
|
||||
PPhase::VMark => Prompt::new("Enter an option", curves::KW).kw(&["Next", "Previous", "Go", "eXit"]).default("N"),
|
||||
PPhase::VTangent => Prompt::new("Specify direction of vertex tangent", Accept::POINT_OR_NUMBER)
|
||||
.base_opt(self.poly(s).and_then(|p| p.vertices.get(self.vertex).map(|v| v.p))),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
let kw = |i: &Input| match i {
|
||||
Input::Keyword(k) | Input::Text(k) => Some(k.trim().to_ascii_lowercase()),
|
||||
_ => None,
|
||||
};
|
||||
match self.phase {
|
||||
PPhase::Select => match i {
|
||||
Input::Point(p) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let k = curves::entity(s, h)?.kind;
|
||||
self.h = Some(h);
|
||||
match k {
|
||||
EntityKind::LwPolyline(pl) => {
|
||||
self.original = Some(pl.vertices);
|
||||
self.phase = PPhase::Main;
|
||||
}
|
||||
EntityKind::Line(_) | EntityKind::Arc(_) => self.phase = PPhase::Convert,
|
||||
_ => {
|
||||
self.h = None;
|
||||
return Err(other("Object selected is not a polyline."));
|
||||
}
|
||||
}
|
||||
s.set_selection(vec![h]);
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
Input::Keyword(_) => Err(other("Select one polyline (Multiple is available through the JSON form).")),
|
||||
Input::Enter => Ok(Step::Cancel),
|
||||
_ => Ok(Step::Continue),
|
||||
},
|
||||
PPhase::Convert => {
|
||||
let no = kw(&i).is_some_and(|k| k.starts_with('n'));
|
||||
if no {
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
let h = self.h.ok_or_else(|| other("No object."))?;
|
||||
let k = curves::entity(s, h)?.kind;
|
||||
let pl = as_lwpoly(&k).ok_or_else(|| other("Cannot convert."))?;
|
||||
self.original = Some(pl.vertices.clone());
|
||||
set_kind(s, h, EntityKind::LwPolyline(pl))?;
|
||||
self.phase = PPhase::Main;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::Main => {
|
||||
let Some(k) = kw(&i) else {
|
||||
if i == Input::Enter {
|
||||
s.set_selection(Vec::new());
|
||||
return Ok(Step::Done);
|
||||
}
|
||||
return Ok(Step::Continue);
|
||||
};
|
||||
match k.as_str() {
|
||||
"open" | "o" => self.apply(s, "open", json!({}))?,
|
||||
"close" | "c" => self.apply(s, "close", json!({}))?,
|
||||
"join" | "j" => {
|
||||
self.join = SelectPhase::default();
|
||||
self.phase = PPhase::Join;
|
||||
}
|
||||
"width" | "w" => self.phase = PPhase::Width,
|
||||
"edit vertex" | "e" => {
|
||||
self.vertex = 0;
|
||||
self.phase = PPhase::Vertex;
|
||||
}
|
||||
"fit" | "f" => self.apply(s, "fit", json!({ "tangents": self.tangents_json(s) }))?,
|
||||
"spline" | "s" => self.apply(s, "spline", json!({}))?,
|
||||
"decurve" | "d" => {
|
||||
// Within the session, Decurve restores the frame before Fit/Spline.
|
||||
let h = self.h.ok_or_else(|| other("No polyline."))?;
|
||||
self.snapshot(s);
|
||||
match (self.poly(s), self.original.clone()) {
|
||||
(Some(mut pl), Some(orig)) => {
|
||||
pl.vertices = orig.into_iter().map(|v| PolyVertex { bulge: 0.0, ..v }).collect();
|
||||
set_kind(s, h, EntityKind::LwPolyline(pl))?;
|
||||
}
|
||||
_ => {
|
||||
self.history.pop();
|
||||
self.apply(s, "decurve", json!({}))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
"ltype gen" | "l" => self.apply(s, "ltypegen", json!({}))?,
|
||||
"reverse" | "r" => self.apply(s, "reverse", json!({}))?,
|
||||
"undo" | "u" => {
|
||||
self.undo_split(s)?;
|
||||
if let (Some(h), Some(k)) = (self.h, self.history.pop()) {
|
||||
set_kind(s, h, k)?;
|
||||
}
|
||||
}
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::Width => {
|
||||
if let Input::Text(t) = &i {
|
||||
let w = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?;
|
||||
self.apply(s, "width", json!({ "width": w }))?;
|
||||
}
|
||||
self.phase = PPhase::Main;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::Join => match self.join.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => {
|
||||
self.phase = PPhase::Main;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
SelOutcome::Done(hs) => {
|
||||
let h = self.h.ok_or_else(|| other("No polyline."))?;
|
||||
self.snapshot(s);
|
||||
let n = pedit_join(s, h, &hs, 0.0)?;
|
||||
s.echo(format!("{n} segments added to polyline"));
|
||||
s.set_selection(vec![h]);
|
||||
self.phase = PPhase::Main;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
},
|
||||
PPhase::Vertex => {
|
||||
let n = self.poly(s).map(|p| p.vertices.len()).unwrap_or(0).max(1);
|
||||
let k = kw(&i).unwrap_or_else(|| if i == Input::Enter { "n".into() } else { String::new() });
|
||||
match k.as_str() {
|
||||
"next" | "n" => self.vertex = (self.vertex + 1) % n,
|
||||
"previous" | "p" => self.vertex = (self.vertex + n - 1) % n,
|
||||
"move" | "m" => self.phase = PPhase::VMove,
|
||||
"insert" | "i" => self.phase = PPhase::VInsert,
|
||||
"straighten" | "s" | "break" | "b" => {
|
||||
self.breaking = k.starts_with('b');
|
||||
self.mark2 = self.vertex;
|
||||
self.phase = PPhase::VMark;
|
||||
}
|
||||
"tangent" | "t" => self.phase = PPhase::VTangent,
|
||||
// The display is always current.
|
||||
"regen" | "r" => {}
|
||||
"width" | "w" => self.phase = PPhase::VWidthStart,
|
||||
"exit" | "x" => self.phase = PPhase::Main,
|
||||
"" => {}
|
||||
_ => return Err(other("Invalid option keyword.")),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::VMove | PPhase::VInsert => {
|
||||
if let Input::Point(p) = i {
|
||||
let action = if self.phase == PPhase::VMove { "move" } else { "insert" };
|
||||
self.apply(s, "vertex", json!({ "action": action, "index": self.vertex, "to": [p.x, p.y] }))?;
|
||||
if action == "insert" {
|
||||
self.vertex += 1;
|
||||
}
|
||||
}
|
||||
self.phase = PPhase::Vertex;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::VWidthStart => {
|
||||
if let Input::Text(t) = &i {
|
||||
self.vwidth = number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?;
|
||||
self.phase = PPhase::VWidthEnd;
|
||||
} else {
|
||||
self.phase = PPhase::Vertex;
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::VMark => {
|
||||
let k = kw(&i).unwrap_or_else(|| if i == Input::Enter { "n".into() } else { String::new() });
|
||||
self.mark_input(s, &k)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::VTangent => {
|
||||
self.tangent_input(s, &i)?;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
PPhase::VWidthEnd => {
|
||||
let ew = match &i {
|
||||
Input::Text(t) => number(t).filter(|w| *w >= 0.0).ok_or_else(|| other("Requires a non-negative width."))?,
|
||||
_ => self.vwidth,
|
||||
};
|
||||
self.apply(s, "vertex", json!({ "action": "width", "index": self.vertex, "startWidth": self.vwidth, "endWidth": ew }))?;
|
||||
self.phase = PPhase::Vertex;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn preview(&self, s: &Session, c: Vec2) -> Vec<EntityKind> {
|
||||
if !matches!(
|
||||
self.phase,
|
||||
PPhase::Vertex | PPhase::VMove | PPhase::VInsert | PPhase::VWidthStart | PPhase::VWidthEnd | PPhase::VMark | PPhase::VTangent
|
||||
) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(v) = self.poly(s).and_then(|p| p.vertices.get(self.vertex).copied()) else { return Vec::new() };
|
||||
let k = s.pixel_size() * 8.0;
|
||||
let mut out = vec![line(v.p - Vec2::new(k, k), v.p + Vec2::new(k, k)), line(v.p - Vec2::new(k, -k), v.p + Vec2::new(k, -k))];
|
||||
if matches!(self.phase, PPhase::VMove | PPhase::VInsert | PPhase::VTangent) {
|
||||
out.push(line(v.p, c));
|
||||
}
|
||||
if let (PPhase::VMark, Some(m)) = (self.phase, self.poly(s).and_then(|p| p.vertices.get(self.mark2).copied())) {
|
||||
out.extend([line(m.p - Vec2::new(k, k), m.p + Vec2::new(k, k)), line(m.p - Vec2::new(k, -k), m.p + Vec2::new(k, -k))]);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// REVERSE / CHSPACE / FLATTEN (select, then act)
|
||||
// =====================================================================================
|
||||
|
||||
pub(crate) fn reverse_kind(k: &EntityKind) -> Option<EntityKind> {
|
||||
Some(match k {
|
||||
EntityKind::Line(l) => EntityKind::Line(cadcraft_doc::Line { a: l.b, b: l.a }),
|
||||
EntityKind::LwPolyline(p) => EntityKind::LwPolyline(LwPolyline { vertices: curves::reverse_vertices(&p.vertices, p.closed), ..p.clone() }),
|
||||
EntityKind::Polyline3d(p) => {
|
||||
EntityKind::Polyline3d(cadcraft_doc::Polyline3d { points: p.points.iter().rev().copied().collect(), closed: p.closed })
|
||||
}
|
||||
EntityKind::Spline(sp) => EntityKind::Spline(curves::reverse_spline(sp)),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn reverse(s: &mut Session, hs: &[Handle]) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
if curves::is_locked(s, *h) {
|
||||
continue;
|
||||
}
|
||||
let Ok(e) = curves::entity(s, *h) else { continue };
|
||||
if let Some(k) = reverse_kind(&e.kind) {
|
||||
set_kind(s, *h, k)?;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
pub(super) fn run_reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let n = reverse(s, &hs)?;
|
||||
Ok(json!({ "reversed": n }))
|
||||
}
|
||||
|
||||
/// Zero every z coordinate; 3D polylines become 2D polylines.
|
||||
pub(crate) fn flatten_kind(k: &mut EntityKind) {
|
||||
let z = |p: &mut cadcraft_geom::Vec3| p.z = 0.0;
|
||||
match k {
|
||||
EntityKind::Line(l) => {
|
||||
z(&mut l.a);
|
||||
z(&mut l.b);
|
||||
}
|
||||
EntityKind::Point(p) => z(&mut p.p),
|
||||
EntityKind::Circle(c) => z(&mut c.center),
|
||||
EntityKind::Arc(a) => z(&mut a.center),
|
||||
EntityKind::Ellipse(e) => {
|
||||
z(&mut e.center);
|
||||
z(&mut e.major);
|
||||
}
|
||||
EntityKind::Helix(h) => *k = EntityKind::Spline(h.spline()),
|
||||
EntityKind::LwPolyline(p) => p.elevation = 0.0,
|
||||
EntityKind::Polyline3d(p) => {
|
||||
*k = lwpoly(p.points.iter().map(|q| PolyVertex::new(q.xy())).collect(), p.closed);
|
||||
}
|
||||
EntityKind::Ray(r) | EntityKind::XLine(r) => {
|
||||
z(&mut r.base);
|
||||
r.dir.z = 0.0;
|
||||
r.dir = r.dir.normalized();
|
||||
}
|
||||
EntityKind::Text(t) => {
|
||||
z(&mut t.insert);
|
||||
t.align_pt.iter_mut().for_each(z);
|
||||
}
|
||||
EntityKind::AttDef(a) => {
|
||||
z(&mut a.text.insert);
|
||||
a.text.align_pt.iter_mut().for_each(z);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
EntityKind::MText(t) => z(&mut t.insert),
|
||||
EntityKind::Insert(i) => {
|
||||
z(&mut i.insert);
|
||||
for a in &mut i.attribs {
|
||||
z(&mut a.text.insert);
|
||||
a.text.align_pt.iter_mut().for_each(z);
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
z(&mut ml.mtext.insert);
|
||||
}
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(d) => {
|
||||
for p in [&mut d.defpt, &mut d.text_mid, &mut d.p13, &mut d.p14, &mut d.p15, &mut d.p16] {
|
||||
z(p);
|
||||
}
|
||||
d.block = None;
|
||||
}
|
||||
EntityKind::Leader(l) => l.vertices.iter_mut().for_each(z),
|
||||
EntityKind::MLine(m) => m.vertices.iter_mut().for_each(|v| z(&mut v.p)),
|
||||
EntityKind::MLeader(m) => {
|
||||
m.leaders.iter_mut().for_each(|l| l.iter_mut().for_each(z));
|
||||
z(&mut m.landing);
|
||||
m.block.iter_mut().for_each(|b| z(&mut b.insert));
|
||||
}
|
||||
EntityKind::Hatch(h) => h.elevation = 0.0,
|
||||
EntityKind::Solid(so) | EntityKind::Trace(so) => so.corners.iter_mut().for_each(z),
|
||||
EntityKind::Face3d(f) => f.corners.iter_mut().for_each(z),
|
||||
EntityKind::Region(_) => {}
|
||||
EntityKind::Image(i) => {
|
||||
z(&mut i.insert);
|
||||
i.u.z = 0.0;
|
||||
i.v.z = 0.0;
|
||||
}
|
||||
EntityKind::Table(t) => z(&mut t.insert),
|
||||
EntityKind::Tolerance(t) => {
|
||||
z(&mut t.insert);
|
||||
t.dir.z = 0.0;
|
||||
}
|
||||
EntityKind::Spline(_) | EntityKind::Viewport(_) | EntityKind::Wipeout(_) | EntityKind::Unknown(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten(s: &mut Session, hs: &[Handle]) -> Result<usize> {
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
if curves::is_locked(s, *h) {
|
||||
continue;
|
||||
}
|
||||
if s.doc()?.entity(*h).is_none() {
|
||||
continue;
|
||||
}
|
||||
s.doc_mut()?.modify_entity(*h, |e| {
|
||||
flatten_kind(&mut e.kind);
|
||||
e.common.thickness = 0.0;
|
||||
})?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
pub(super) fn run_flatten(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let n = flatten(s, &hs)?;
|
||||
Ok(json!({ "flattened": n }))
|
||||
}
|
||||
|
||||
/// Model↔paper transform through a viewport: paper = center + (model − viewCenter) / scale.
|
||||
fn viewport_of(s: &Session, layout: &str, vp: Option<Handle>) -> Option<cadcraft_doc::Viewport> {
|
||||
let d = s.doc().ok()?;
|
||||
if let Some(h) = vp
|
||||
&& let Some(e) = d.entity(h)
|
||||
&& let EntityKind::Viewport(v) = &e.kind
|
||||
{
|
||||
return Some(v.clone());
|
||||
}
|
||||
let st = d.space(&Space::Paper(layout.to_string()))?;
|
||||
st.iter().find_map(|e| match &e.kind {
|
||||
EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 && v.id != 1 => Some(v.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn chspace(s: &mut Session, hs: &[Handle], to: Option<&str>, vp: Option<Handle>) -> Result<(usize, Space, Space)> {
|
||||
let from = s.space();
|
||||
let (target, layout) = match (&from, to) {
|
||||
(Space::Model, t) => {
|
||||
let name = match t {
|
||||
Some(n) if !n.eq_ignore_ascii_case("model") => n.to_string(),
|
||||
_ => {
|
||||
let d = s.doc()?;
|
||||
let mut ls: Vec<_> = d.layouts.iter().collect();
|
||||
ls.sort_by_key(|l| l.tab_order);
|
||||
ls.first().map(|l| l.name.clone()).ok_or_else(|| other("No layout to move objects to."))?
|
||||
}
|
||||
};
|
||||
if s.doc()?.layout(&name).is_none() {
|
||||
return Err(other(format!("Layout \"{name}\" not found.")));
|
||||
}
|
||||
let real = s.doc()?.layout(&name).map(|l| l.name.clone()).unwrap_or(name);
|
||||
(Space::Paper(real.clone()), real)
|
||||
}
|
||||
(Space::Paper(n), _) => (Space::Model, n.clone()),
|
||||
};
|
||||
let m = match viewport_of(s, &layout, vp) {
|
||||
Some(v) => {
|
||||
let k = v.view_height / v.height;
|
||||
let to_paper = Mat3::translate(v.center.xy()).then_before(Mat3::scale(1.0 / k, 1.0 / k)).then_before(Mat3::translate(-v.view_center));
|
||||
if target == Space::Model { to_paper.inverse().unwrap_or(Mat3::IDENTITY) } else { to_paper }
|
||||
}
|
||||
None => Mat3::IDENTITY,
|
||||
};
|
||||
let mut n = 0;
|
||||
for h in hs {
|
||||
if curves::is_locked(s, *h) {
|
||||
continue;
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
if d.space_of(*h).as_ref() != Some(&from) {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = d.entity(*h).map(|e| (**e).clone()) else { continue };
|
||||
if matches!(e.kind, EntityKind::Viewport(_)) {
|
||||
continue;
|
||||
}
|
||||
let mut e = e;
|
||||
e.kind.transform(&m);
|
||||
d.remove_entity(*h);
|
||||
if let Some(st) = d.space_mut(&target) {
|
||||
st.push(e);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok((n, from, target))
|
||||
}
|
||||
|
||||
pub(super) fn run_chspace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let hs = targets(s, p)?;
|
||||
let (n, _, target) = chspace(s, &hs, str_param(p, "to"), curves::handle_param(p, "viewport"))?;
|
||||
Ok(json!({ "moved": n, "to": target.layout().unwrap_or("model") }))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum SelKind {
|
||||
Reverse,
|
||||
Chspace,
|
||||
Flatten,
|
||||
}
|
||||
|
||||
pub(super) struct SelOp {
|
||||
kind: SelKind,
|
||||
sel: SelectPhase,
|
||||
}
|
||||
|
||||
impl SelOp {
|
||||
pub(super) fn new(kind: SelKind) -> Self {
|
||||
SelOp { kind, sel: SelectPhase::default() }
|
||||
}
|
||||
fn act(&self, s: &mut Session, hs: &[Handle]) -> Result<Step> {
|
||||
match self.kind {
|
||||
SelKind::Reverse => {
|
||||
let n = reverse(s, hs)?;
|
||||
s.echo(format!("{n} object(s) direction has been reversed."));
|
||||
}
|
||||
SelKind::Flatten => {
|
||||
let n = flatten(s, hs)?;
|
||||
s.echo(format!("{n} object(s) flattened."));
|
||||
}
|
||||
SelKind::Chspace => {
|
||||
let (n, from, to) = chspace(s, hs, None, None)?;
|
||||
let nm = |sp: &Space| if *sp == Space::Model { "MODEL" } else { "PAPER" };
|
||||
s.echo(format!("{n} object(s) changed from {} space to {} space.", nm(&from), nm(&to)));
|
||||
}
|
||||
}
|
||||
s.set_selection(Vec::new());
|
||||
Ok(Step::Done)
|
||||
}
|
||||
}
|
||||
|
||||
impl Interactive for SelOp {
|
||||
fn name(&self) -> &'static str {
|
||||
match self.kind {
|
||||
SelKind::Reverse => "REVERSE",
|
||||
SelKind::Chspace => "CHSPACE",
|
||||
SelKind::Flatten => "FLATTEN",
|
||||
}
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
self.sel = SelectPhase::begin(s);
|
||||
if self.sel.done {
|
||||
let hs = self.sel.picked.clone();
|
||||
return self.act(s, &hs);
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
self.sel.prompt()
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
match self.sel.feed(s, &i)? {
|
||||
SelOutcome::More => Ok(Step::Continue),
|
||||
SelOutcome::Empty => Ok(Step::Cancel),
|
||||
SelOutcome::Done(hs) => self.act(s, &hs),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// TEXTEDIT
|
||||
// =====================================================================================
|
||||
|
||||
fn text_of(k: &EntityKind) -> Option<String> {
|
||||
match k {
|
||||
EntityKind::Text(t) => Some(t.value.clone()),
|
||||
EntityKind::AttDef(a) => Some(a.text.value.clone()),
|
||||
EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")),
|
||||
EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> {
|
||||
if curves::is_locked(s, h) {
|
||||
return Err(other("The object is on a locked layer."));
|
||||
}
|
||||
let mut k = curves::entity(s, h)?.kind;
|
||||
match &mut k {
|
||||
EntityKind::Text(t) => t.value = text.to_string(),
|
||||
EntityKind::AttDef(a) => a.text.value = text.to_string(),
|
||||
EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"),
|
||||
EntityKind::Dimension(d) => {
|
||||
d.text = if text == "<>" { String::new() } else { text.to_string() };
|
||||
d.block = None;
|
||||
}
|
||||
_ => return Err(other("Select a text, mtext, attribute definition or dimension.")),
|
||||
}
|
||||
set_kind(s, h, k)
|
||||
}
|
||||
|
||||
pub(super) fn run_textedit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("textedit", "`handle` is required"))?;
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("textedit", "`text` is required"))?;
|
||||
if text.len() > 1_000_000 {
|
||||
return Err(bad("textedit", "text is too long"));
|
||||
}
|
||||
set_text(s, h, text)?;
|
||||
Ok(json!({ "handle": h.hex() }))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct TextEditM {
|
||||
h: Option<(Handle, String)>,
|
||||
/// Edits made in this run, newest last: each object as it was before (the Undo option).
|
||||
edits: Vec<(Handle, EntityKind)>,
|
||||
/// Single mode: end after one edit (the Mode option; Multiple by default).
|
||||
single: bool,
|
||||
/// Asking for the edit mode.
|
||||
mode: bool,
|
||||
}
|
||||
|
||||
impl Interactive for TextEditM {
|
||||
fn name(&self) -> &'static str {
|
||||
"TEXTEDIT"
|
||||
}
|
||||
fn prompt(&self, _s: &Session) -> Prompt {
|
||||
if self.mode {
|
||||
let current = if self.single { "Single" } else { "Multiple" };
|
||||
return Prompt::new("Enter a text edit mode option", curves::KW).kw(&["Single", "Multiple"]).default(current);
|
||||
}
|
||||
match &self.h {
|
||||
None => Prompt::new("Select an annotation object", Accept::POINT).kw(&["Undo", "Mode"]),
|
||||
Some((_, old)) => Prompt::new("Enter new text", Accept::TEXT).default(old.chars().take(40).collect::<String>()),
|
||||
}
|
||||
}
|
||||
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
||||
if self.mode {
|
||||
match i {
|
||||
Input::Keyword(k) if k == "Single" => self.single = true,
|
||||
Input::Keyword(k) if k == "Multiple" => self.single = false,
|
||||
Input::Enter => {}
|
||||
_ => return Err(other("Enter Single or Multiple.")),
|
||||
}
|
||||
self.mode = false;
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
match (&self.h, i) {
|
||||
(None, Input::Point(p)) => {
|
||||
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
|
||||
let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;
|
||||
self.h = Some((h, old));
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(Some((h, _)), Input::Text(t) | Input::Keyword(t)) => {
|
||||
let h = *h;
|
||||
let before = curves::entity(s, h)?.kind;
|
||||
set_text(s, h, &t)?;
|
||||
self.edits.push((h, before));
|
||||
self.h = None;
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(Some(_), Input::Enter) => {
|
||||
self.h = None;
|
||||
Ok(if self.single { Step::Done } else { Step::Continue })
|
||||
}
|
||||
(None, Input::Enter) => Ok(Step::Done),
|
||||
(None, Input::Keyword(k)) if k == "Undo" => {
|
||||
match self.edits.pop() {
|
||||
Some((h, before)) => set_kind(s, h, before)?,
|
||||
None => s.echo("Everything has been undone"),
|
||||
}
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
(None, Input::Keyword(k)) if k == "Mode" => {
|
||||
self.mode = true;
|
||||
Ok(Step::Continue)
|
||||
}
|
||||
_ => Ok(Step::Continue),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use super::*;
|
||||
|
||||
// =====================================================================================
|
||||
// TRIM / EXTEND for ellipses and splines
|
||||
// =====================================================================================
|
||||
|
||||
pub(super) fn ellipse_of(e: &cadcraft_doc::Ellipse) -> Ellipse {
|
||||
Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end }
|
||||
}
|
||||
|
||||
pub(super) fn ellipse_kind(e: &Ellipse) -> EntityKind {
|
||||
EntityKind::Ellipse(cadcraft_doc::Ellipse { center: v3(e.center), major: v3(e.major), ratio: e.ratio, start: e.start, end: e.end })
|
||||
}
|
||||
|
||||
/// Intersections of a sampled curve `eval(t)` (parameters `ts`) with the cutting segments,
|
||||
/// as curve parameters refined by bisection on the exact curve.
|
||||
fn hits_on(eval: &dyn Fn(f64) -> Vec2, ts: &[f64], cut: &[Segment]) -> Vec<f64> {
|
||||
let mut out = Vec::new();
|
||||
for w in ts.windows(2) {
|
||||
let (Some(t0), Some(t1)) = (w.first().copied(), w.get(1).copied()) else { continue };
|
||||
let (a, b) = (eval(t0), eval(t1));
|
||||
let sg = Segment::Line(Line::new(a, b));
|
||||
for c in cut {
|
||||
for x in intersect_ext(&sg, c, false) {
|
||||
let f = Line::new(a, b).param_of(x).clamp(0.0, 1.0);
|
||||
out.push(refine_hit(eval, c, t0, t1, t0 + (t1 - t0) * f));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Signed side of `p` relative to a cutting segment (line side, or inside/outside an arc's circle).
|
||||
fn side_of(c: &Segment, p: Vec2) -> f64 {
|
||||
match c {
|
||||
Segment::Line(l) => l.side(p) / l.len().max(1e-300),
|
||||
Segment::Arc { arc, .. } => p.dist(arc.center) - arc.radius,
|
||||
}
|
||||
}
|
||||
|
||||
fn refine_hit(eval: &dyn Fn(f64) -> Vec2, c: &Segment, t0: f64, t1: f64, guess: f64) -> f64 {
|
||||
let (mut a, mut b) = (t0, t1);
|
||||
let (mut fa, fb) = (side_of(c, eval(a)), side_of(c, eval(b)));
|
||||
if !(fa.is_finite() && fb.is_finite()) || fa * fb > 0.0 {
|
||||
return guess;
|
||||
}
|
||||
for _ in 0..60 {
|
||||
let m = (a + b) / 2.0;
|
||||
let fm = side_of(c, eval(m));
|
||||
if fa * fm <= 0.0 {
|
||||
b = m;
|
||||
} else {
|
||||
a = m;
|
||||
fa = fm;
|
||||
}
|
||||
}
|
||||
(a + b) / 2.0
|
||||
}
|
||||
|
||||
fn ellipse_samples(ge: &Ellipse) -> Vec<f64> {
|
||||
let n = 720usize;
|
||||
let sw = ge.sweep();
|
||||
(0..=n).map(|i| ge.start + sw * i as f64 / n as f64).collect()
|
||||
}
|
||||
|
||||
/// Trim objects that modify.rs does not handle itself (ellipses and splines).
|
||||
pub(crate) fn trim_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result<Vec<EntityKind>> {
|
||||
let none = || other("Object does not intersect a cutting edge.");
|
||||
match k {
|
||||
EntityKind::Ellipse(e) => {
|
||||
let ge = ellipse_of(e);
|
||||
let full = Ellipse { start: 0.0, end: TAU, ..ge };
|
||||
let eval = |t: f64| full.at_param(t);
|
||||
let params: Vec<f64> = hits_on(&eval, &ellipse_samples(&ge), cut).into_iter().map(norm_angle).collect();
|
||||
if ge.is_full() {
|
||||
let mut ps = params;
|
||||
ps.sort_by(f64::total_cmp);
|
||||
ps.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
if ps.len() < 2 {
|
||||
return Err(other("Ellipse must intersect cutting edges at two or more points."));
|
||||
}
|
||||
let tp = full.param_of(pick);
|
||||
let n = ps.len();
|
||||
for i in 0..n {
|
||||
let (Some(a0), Some(a1)) = (ps.get(i).copied(), ps.get((i + 1) % n).copied()) else { continue };
|
||||
if cadcraft_geom::angle_in_sweep(tp, a0, a1) {
|
||||
return Ok(vec![ellipse_kind(&Ellipse { start: a1, end: a0, ..ge })]);
|
||||
}
|
||||
}
|
||||
return Err(none());
|
||||
}
|
||||
let sweep = ge.sweep();
|
||||
let rel = |t: f64| norm_angle(t - ge.start);
|
||||
let mut ts: Vec<f64> = params.into_iter().map(rel).filter(|t| *t > 1e-9 && *t < sweep - 1e-9).collect();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
let tp = rel(full.param_of(pick)).min(sweep);
|
||||
let lo = ts.iter().copied().filter(|t| *t < tp).fold(0.0, f64::max);
|
||||
let hi = ts.iter().copied().filter(|t| *t > tp).fold(sweep, f64::min);
|
||||
if lo == 0.0 && hi == sweep {
|
||||
return Err(none());
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if lo > 0.0 {
|
||||
v.push(ellipse_kind(&Ellipse { end: norm_angle(ge.start + lo), ..ge }));
|
||||
}
|
||||
if hi < sweep {
|
||||
v.push(ellipse_kind(&Ellipse { start: norm_angle(ge.start + hi), ..ge }));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
EntityKind::Spline(sp) => {
|
||||
if !sp.is_valid() {
|
||||
return Err(other("Invalid spline."));
|
||||
}
|
||||
let (lo_d, hi_d) = sp.domain();
|
||||
let n = 512;
|
||||
let params: Vec<f64> = (0..=n).map(|i| lo_d + (hi_d - lo_d) * i as f64 / n as f64).collect();
|
||||
let eval = |t: f64| sp.eval(t);
|
||||
let mut ts: Vec<f64> =
|
||||
hits_on(&eval, ¶ms, cut).into_iter().filter(|t| *t > lo_d + 1e-9 * (hi_d - lo_d) && *t < hi_d - 1e-9 * (hi_d - lo_d)).collect();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
|
||||
let tp = curves::spline_param_of(sp, pick);
|
||||
if sp.closed {
|
||||
return curve_edits::trim_closed_spline(sp, &ts, tp);
|
||||
}
|
||||
let lo = ts.iter().copied().filter(|t| *t < tp).fold(lo_d, f64::max);
|
||||
let hi = ts.iter().copied().filter(|t| *t > tp).fold(hi_d, f64::min);
|
||||
if lo == lo_d && hi == hi_d {
|
||||
return Err(none());
|
||||
}
|
||||
let mut v = Vec::new();
|
||||
if lo > lo_d
|
||||
&& let Some(a) = curves::sub_spline(sp, lo_d, lo)
|
||||
{
|
||||
v.push(EntityKind::Spline(a));
|
||||
}
|
||||
if hi < hi_d
|
||||
&& let Some(b) = curves::sub_spline(sp, hi, hi_d)
|
||||
{
|
||||
v.push(EntityKind::Spline(b));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
_ => Err(other("Cannot trim that object.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend objects that modify.rs does not handle itself (elliptical arcs and open splines).
|
||||
pub(crate) fn extend_other(k: &EntityKind, cut: &[Segment], pick: Vec2) -> Result<EntityKind> {
|
||||
match k {
|
||||
EntityKind::Ellipse(e) => {
|
||||
let ge = ellipse_of(e);
|
||||
if ge.is_full() {
|
||||
return Err(other("Cannot extend a closed ellipse."));
|
||||
}
|
||||
let full = Ellipse { start: 0.0, end: TAU, ..ge };
|
||||
let from_end = pick.dist(ge.at_param(ge.end)) < pick.dist(ge.at_param(ge.start));
|
||||
let eval = |t: f64| full.at_param(t);
|
||||
let hits: Vec<f64> = hits_on(&eval, &ellipse_samples(&full), cut)
|
||||
.into_iter()
|
||||
.map(norm_angle)
|
||||
.filter(|t| !cadcraft_geom::angle_in_sweep(*t, ge.start, ge.end))
|
||||
.collect();
|
||||
let t = if from_end {
|
||||
hits.iter().copied().min_by(|x, y| ccw_sweep(ge.end, *x).total_cmp(&ccw_sweep(ge.end, *y)))
|
||||
} else {
|
||||
hits.iter().copied().min_by(|x, y| ccw_sweep(*x, ge.start).total_cmp(&ccw_sweep(*y, ge.start)))
|
||||
};
|
||||
let t = t.ok_or_else(|| other("Object does not intersect an edge."))?;
|
||||
Ok(ellipse_kind(&if from_end { Ellipse { end: t, ..ge } } else { Ellipse { start: t, ..ge } }))
|
||||
}
|
||||
EntityKind::Spline(sp) => curve_edits::extend_spline(sp, cut, pick),
|
||||
_ => Err(other("Cannot extend that object.")),
|
||||
}
|
||||
}
|
||||
@@ -334,5 +334,4 @@ impl Interactive for Txt2MtxtM {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "txt2mtxt_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
use super::view::{ModelTiles, View, usable_view};
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Snapshot {
|
||||
pub label: String,
|
||||
pub doc: Arc<Drawing>,
|
||||
pub selection: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// One open drawing.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DocState {
|
||||
pub doc: Arc<Drawing>,
|
||||
pub undo: Vec<Snapshot>,
|
||||
pub redo: Vec<Snapshot>,
|
||||
/// UNDO marks, BEgin/End group and Control (see [`undoctl`]).
|
||||
pub undo_ctl: undoctl::UndoCtl,
|
||||
pub selection: Vec<Handle>,
|
||||
pub previous_selection: Vec<Handle>,
|
||||
pub path: Option<String>,
|
||||
pub title: String,
|
||||
pub saved: Arc<Drawing>,
|
||||
pub revision: u64,
|
||||
pub space: Space,
|
||||
/// Tiled model viewports (VPORTS).
|
||||
pub tiles: ModelTiles,
|
||||
/// MSPACE: the layout viewport whose model space is being edited (None = paper space).
|
||||
pub mspace: Option<Handle>,
|
||||
pub views: Vec<(Space, View)>,
|
||||
pub view_history: Vec<View>,
|
||||
pub uid: u64,
|
||||
/// The drawing is saved as binary DXF (it was opened from one, or last saved as one).
|
||||
pub binary_dxf: bool,
|
||||
/// The Block Editor (BEDIT), when it is open: `doc` is then the editor's drawing, and the
|
||||
/// drawing itself waits in it ([`DocState::drawing`]).
|
||||
pub block_edit: Option<blockedit::BlockEdit>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
|
||||
impl DocState {
|
||||
pub fn new(doc: Drawing, title: &str, path: Option<String>) -> Self {
|
||||
let doc = Arc::new(doc);
|
||||
DocState {
|
||||
saved: doc.clone(),
|
||||
doc,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
undo_ctl: undoctl::UndoCtl::default(),
|
||||
selection: Vec::new(),
|
||||
previous_selection: Vec::new(),
|
||||
path,
|
||||
title: title.into(),
|
||||
revision: 1,
|
||||
space: Space::Model,
|
||||
tiles: ModelTiles::default(),
|
||||
mspace: None,
|
||||
views: Vec::new(),
|
||||
view_history: Vec::new(),
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
binary_dxf: false,
|
||||
block_edit: None,
|
||||
}
|
||||
}
|
||||
/// The drawing has changes not saved to its file, or the Block Editor has changes not yet
|
||||
/// saved into its block.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!Arc::ptr_eq(self.drawing(), &self.saved) || self.block_unsaved()
|
||||
}
|
||||
/// After undo/redo replaced the drawing (`before` is the one shown until then): if the current
|
||||
/// layout no longer exists, follow it to its other name when it was renamed (same tab order,
|
||||
/// a name `before` did not have), else go to the Model tab.
|
||||
pub(crate) fn follow_layout(&mut self, before: &Drawing) {
|
||||
let Space::Paper(name) = &self.space else { return };
|
||||
if self.doc.layouts.iter().any(|l| l.name == *name) {
|
||||
return;
|
||||
}
|
||||
let renamed = before
|
||||
.layouts
|
||||
.iter()
|
||||
.find(|l| l.name == *name)
|
||||
.and_then(|old| self.doc.layouts.iter().find(|l| l.tab_order == old.tab_order && !before.layouts.iter().any(|b| b.name == l.name)));
|
||||
match renamed.map(|l| Space::Paper(l.name.clone())) {
|
||||
Some(to) => {
|
||||
let from = self.space.clone();
|
||||
for (sp, _) in self.views.iter_mut() {
|
||||
if *sp == from {
|
||||
*sp = to.clone();
|
||||
}
|
||||
}
|
||||
self.space = to;
|
||||
}
|
||||
None => {
|
||||
self.space = Space::Model;
|
||||
self.mspace = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The space edits, picks and snaps act on: model space inside an active viewport.
|
||||
pub fn edit_space(&self) -> Space {
|
||||
if self.active_viewport().is_some() { Space::Model } else { self.space.clone() }
|
||||
}
|
||||
/// The active MSPACE viewport, if it still exists in the current layout.
|
||||
pub fn active_viewport(&self) -> Option<(Handle, cadcraft_doc::Viewport)> {
|
||||
let h = self.mspace?;
|
||||
let e = self.doc.space(&self.space)?.get(h)?;
|
||||
match &e.kind {
|
||||
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn paper_view(&self) -> View {
|
||||
self.views.iter().find(|(s, _)| *s == self.space).map(|(_, v)| *v).unwrap_or_default()
|
||||
}
|
||||
/// The screen view. Inside an MSPACE viewport this is the model-space view that maps the
|
||||
/// screen consistently with the paper view, so picks, snaps and previews work in model units.
|
||||
pub fn view(&self) -> View {
|
||||
let pv = self.paper_view();
|
||||
match self.active_viewport() {
|
||||
Some((_, vp)) => {
|
||||
let k = vp.view_height / vp.height;
|
||||
View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
|
||||
}
|
||||
None => pv,
|
||||
}
|
||||
}
|
||||
/// Map a requested screen view to the paper view, or (in MSPACE) to the viewport's model view.
|
||||
/// A finite request can still map to a view too large to represent: then nothing changes and
|
||||
/// this returns false.
|
||||
fn store_view(&mut self, v: View) -> bool {
|
||||
if let Some((h, vp)) = self.active_viewport() {
|
||||
let pv = self.paper_view();
|
||||
let k = v.height / pv.height.max(1e-12);
|
||||
if vp.locked {
|
||||
// A locked viewport keeps its scale: pan/zoom the sheet instead.
|
||||
let k0 = vp.view_height / vp.height;
|
||||
let pc = vp.center.xy() + (v.center - vp.view_center) / k0;
|
||||
let paper = View { center: pc, height: v.height / k0 };
|
||||
if !usable_view(&paper) {
|
||||
return false;
|
||||
}
|
||||
self.put_paper_view(paper);
|
||||
} else {
|
||||
let center = v.center - (pv.center - vp.center.xy()) * k;
|
||||
let height = vp.height * k;
|
||||
if !usable_view(&View { center, height }) {
|
||||
return false;
|
||||
}
|
||||
let doc = Arc::make_mut(&mut self.doc);
|
||||
if let Some(store) = doc.space_mut(&self.space.clone()) {
|
||||
store.modify(h, |e| {
|
||||
if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind {
|
||||
x.view_center = center;
|
||||
x.view_height = height;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
self.put_paper_view(v);
|
||||
true
|
||||
}
|
||||
fn put_paper_view(&mut self, v: View) {
|
||||
match self.views.iter_mut().find(|(s, _)| *s == self.space) {
|
||||
Some((_, slot)) => *slot = v,
|
||||
None => self.views.push((self.space.clone(), v)),
|
||||
}
|
||||
}
|
||||
/// Set the view, recording view history. Returns false, changing nothing, when the view (or,
|
||||
/// in MSPACE, the viewport view it maps to) isn't finite with a positive height.
|
||||
pub fn set_view(&mut self, v: View) -> bool {
|
||||
if !usable_view(&v) {
|
||||
return false;
|
||||
}
|
||||
let old = self.view();
|
||||
if !self.store_view(v) {
|
||||
return false;
|
||||
}
|
||||
if old != v {
|
||||
self.view_history.push(old);
|
||||
if self.view_history.len() > 50 {
|
||||
self.view_history.remove(0);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// The active MSPACE viewport when ZOOM works on its own view, as in AutoCAD: extents and
|
||||
/// windows fit the viewport, and zooms keep its centre. A locked viewport keeps its scale, so
|
||||
/// zooming it moves the sheet instead (see `store_view`).
|
||||
pub fn zoom_viewport(&self) -> Option<cadcraft_doc::Viewport> {
|
||||
self.active_viewport().map(|(_, v)| v).filter(|v| !v.locked && v.width > 1e-12)
|
||||
}
|
||||
/// The view ZOOM starts from: the model view of the active unlocked viewport, else [`Self::view`].
|
||||
pub fn zoom_frame(&self) -> View {
|
||||
match self.zoom_viewport() {
|
||||
Some(v) => View { center: v.view_center, height: v.view_height },
|
||||
None => self.view(),
|
||||
}
|
||||
}
|
||||
/// Width / height of the area ZOOM fits into: the active unlocked viewport, else `screen`.
|
||||
pub fn zoom_aspect(&self, screen: f64) -> f64 {
|
||||
self.zoom_viewport().map_or(screen, |v| v.width / v.height)
|
||||
}
|
||||
/// Set the view ZOOM works on (see [`Self::zoom_frame`]), recording view history.
|
||||
pub fn set_zoom_frame(&mut self, f: View) {
|
||||
let v = match self.zoom_viewport() {
|
||||
// The screen view that `store_view` maps to this model view of the viewport.
|
||||
Some(vp) => {
|
||||
let pv = self.paper_view();
|
||||
let k = f.height / vp.height;
|
||||
View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
|
||||
}
|
||||
None => f,
|
||||
};
|
||||
self.set_view(v);
|
||||
}
|
||||
/// Set without recording view history (realtime pan/zoom frames).
|
||||
pub fn set_view_quiet(&mut self, v: View) {
|
||||
if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) {
|
||||
return;
|
||||
}
|
||||
self.store_view(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
#[error("unknown command `{0}`")]
|
||||
UnknownCommand(String),
|
||||
#[error("command `{0}` is not available right now: {1}")]
|
||||
Disabled(String, String),
|
||||
#[error("invalid parameters for `{cmd}`: {msg}")]
|
||||
BadParams { cmd: String, msg: String },
|
||||
#[error("no active document")]
|
||||
NoDocument,
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// A command panicked; the guard kept the drawing as it was (a bug: please report it).
|
||||
#[error("internal error in `{0}` (the drawing was kept as it was): {1}")]
|
||||
Internal(String, String),
|
||||
}
|
||||
|
||||
impl From<cadcraft_doc::DocError> for EngineError {
|
||||
fn from(e: cadcraft_doc::DocError) -> Self {
|
||||
EngineError::Other(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, EngineError>;
|
||||
@@ -538,5 +538,4 @@ pub fn polyline_grip_count(p: &LwPolyline) -> usize {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "grips_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
use super::*;
|
||||
|
||||
/// Options and values the interactive commands remember for the rest of the session, offered as
|
||||
/// the `<default>` the next time they ask (as AutoCAD does). Values AutoCAD keeps in a drawing
|
||||
/// system variable (HPNAME, OFFSETDIST, FILLETRAD, CHAMFERA, POLYSIDES…) live in the drawing
|
||||
/// header instead. JSON calls never read these: their explicit parameters and documented defaults
|
||||
/// stay authoritative.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LastUsed {
|
||||
/// HATCH/GRADIENT starts at "Select objects" instead of "Pick internal point".
|
||||
pub hatch_select: bool,
|
||||
/// POLYGON: circumscribed about the circle (the `C` option) instead of inscribed.
|
||||
pub polygon_circumscribed: bool,
|
||||
/// ROTATE angle (radians).
|
||||
pub rotate_angle: f64,
|
||||
/// SCALE factor.
|
||||
pub scale_factor: f64,
|
||||
/// TXT2MTXT's Settings.
|
||||
pub txt2mtxt: cmd::Txt2MtxtOptions,
|
||||
/// MATCHPROP Settings: the properties it copies.
|
||||
pub matchprop: cmd::MatchProps,
|
||||
/// MLEADER placement order and Options.
|
||||
pub mleader: MLeaderOptions,
|
||||
/// MLEADER's block content name (Options > Content type > Block).
|
||||
pub mleader_block: String,
|
||||
/// BREAKLINE's Block, Size and Extension.
|
||||
pub breakline: cmd::BreaklineOptions,
|
||||
}
|
||||
|
||||
impl Default for LastUsed {
|
||||
fn default() -> Self {
|
||||
LastUsed {
|
||||
hatch_select: false,
|
||||
polygon_circumscribed: false,
|
||||
rotate_angle: 0.0,
|
||||
scale_factor: 1.0,
|
||||
txt2mtxt: cmd::Txt2MtxtOptions::default(),
|
||||
matchprop: cmd::MatchProps::default(),
|
||||
mleader: MLeaderOptions::default(),
|
||||
mleader_block: String::new(),
|
||||
breakline: cmd::BreaklineOptions::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which part of a multileader MLEADER places first.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LeaderOrder {
|
||||
#[default]
|
||||
Arrowhead,
|
||||
Landing,
|
||||
Content,
|
||||
}
|
||||
|
||||
/// The MLEADER placement order and the values set at its Options prompt.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MLeaderOptions {
|
||||
pub order: LeaderOrder,
|
||||
/// Maximum points of the leader line (2..=64).
|
||||
pub max_points: usize,
|
||||
/// First and second angle constraints (radians; 0 = free).
|
||||
pub angles: [f64; 2],
|
||||
/// Draw the landing line (dogleg).
|
||||
pub landing: bool,
|
||||
/// Ask for multiline text content (Content type None turns it off).
|
||||
pub content: bool,
|
||||
/// Leader lines: straight, spline, or none (content only).
|
||||
pub leader_type: LeaderType,
|
||||
/// The content is a block (`LastUsed::mleader_block`) instead of multiline text.
|
||||
pub block: bool,
|
||||
/// The block's extents centre (not its insertion point) sits at the end of the landing.
|
||||
pub block_center: bool,
|
||||
}
|
||||
|
||||
/// MLEADER's leader line type (Options > Leader type).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum LeaderType {
|
||||
#[default]
|
||||
Straight,
|
||||
Spline,
|
||||
None,
|
||||
}
|
||||
|
||||
impl Default for MLeaderOptions {
|
||||
fn default() -> Self {
|
||||
MLeaderOptions {
|
||||
order: LeaderOrder::Arrowhead,
|
||||
max_points: 2,
|
||||
angles: [0.0; 2],
|
||||
landing: true,
|
||||
content: true,
|
||||
leader_type: LeaderType::Straight,
|
||||
block: false,
|
||||
block_center: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-1634
File diff suppressed because it is too large
Load Diff
+38
-1368
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) type R = Result<Value, LispError>;
|
||||
|
||||
/// `too few arguments` / `too many arguments`.
|
||||
pub fn args_err(few: bool) -> LispError {
|
||||
LispError::new(if few { "too few arguments" } else { "too many arguments" })
|
||||
}
|
||||
|
||||
pub(super) fn arity(a: &[Value], min: usize, max: usize) -> Result<(), LispError> {
|
||||
if a.len() < min {
|
||||
return Err(args_err(true));
|
||||
}
|
||||
if a.len() > max {
|
||||
return Err(args_err(false));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn num(v: &Value) -> Result<f64, LispError> {
|
||||
v.as_number().ok_or_else(|| LispError::bad_arg("numberp", v))
|
||||
}
|
||||
|
||||
pub(super) fn int(v: &Value) -> Result<i32, LispError> {
|
||||
match v {
|
||||
Value::Int(i) => Ok(*i),
|
||||
other => Err(LispError::bad_arg("fixnump", other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn string(v: &Value) -> Result<Arc<str>, LispError> {
|
||||
match v {
|
||||
Value::Str(s) => Ok(s.clone()),
|
||||
other => Err(LispError::bad_arg("stringp", other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn list_arg(v: &Value) -> Result<Vec<Value>, LispError> {
|
||||
if v.is_list() { Ok(v.to_vec()) } else { Err(LispError::bad_arg("listp", v)) }
|
||||
}
|
||||
|
||||
pub(super) fn point(v: &Value) -> Result<[f64; 3], LispError> {
|
||||
v.as_point().ok_or_else(|| LispError::bad_arg("2D/3D point", v))
|
||||
}
|
||||
|
||||
pub(super) fn real_or_int(r: f64, all_int: bool) -> Value {
|
||||
if all_int { Value::Int(r as i32) } else { Value::Real(r) }
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
use super::args::{R, arity, string};
|
||||
use super::output::emit;
|
||||
use super::*;
|
||||
|
||||
// ---------------- files ----------------
|
||||
|
||||
/// A file opened by `open`: read files are read whole; written ones are written at `close`.
|
||||
pub struct LispFile {
|
||||
pub path: String,
|
||||
pub writing: bool,
|
||||
pub append: bool,
|
||||
pub lines: std::collections::VecDeque<String>,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
pub(super) fn open(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 3)?;
|
||||
let path = string(&a[0])?.to_string();
|
||||
let mode = string(&a[1])?.to_ascii_lowercase();
|
||||
let (writing, append) = match mode.as_str() {
|
||||
"r" => (false, false),
|
||||
"w" => (true, false),
|
||||
"a" => (true, true),
|
||||
_ => return Err(LispError::new(format!("bad argument value: file mode {mode}"))),
|
||||
};
|
||||
let mut f = LispFile { path: path.clone(), writing, append, lines: Default::default(), text: String::new() };
|
||||
if !writing {
|
||||
match read_text_file(&path) {
|
||||
Some(t) => {
|
||||
charge_bytes(t.len())?;
|
||||
f.lines = t.lines().map(str::to_string).collect();
|
||||
}
|
||||
None => return Ok(Flow::Value(Value::Nil)),
|
||||
}
|
||||
} else if !can_write(&path) {
|
||||
return Ok(Flow::Value(Value::Nil));
|
||||
}
|
||||
let id = ctx.env.next_file;
|
||||
ctx.env.next_file = ctx.env.next_file.wrapping_add(1);
|
||||
ctx.env.files.insert(id, f);
|
||||
Ok(Flow::Value(Value::File(id)))
|
||||
}
|
||||
|
||||
pub(super) fn close(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let Value::File(id) = a[0] else { return Err(LispError::bad_arg("streamp", &a[0])) };
|
||||
let Some(f) = ctx.env.files.remove(&id) else { return Err(LispError::bad_arg("streamp", &a[0])) };
|
||||
if f.writing {
|
||||
write_text_file(&f.path, &f.text, f.append);
|
||||
}
|
||||
Ok(Flow::Value(Value::Nil))
|
||||
}
|
||||
|
||||
pub(super) fn read_line(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 1)?;
|
||||
let Some(Value::File(id)) = a.first() else { return Ok(Flow::Value(Value::Nil)) };
|
||||
let line = ctx.env.files.get_mut(id).filter(|f| !f.writing).and_then(|f| f.lines.pop_front());
|
||||
Ok(Flow::Value(line.map(|l| Value::str(&l)).unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub(super) fn write_line(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 2)?;
|
||||
let s = string(&a[0])?;
|
||||
emit(ctx, &format!("{s}\n"), a.get(1))?;
|
||||
Ok(Flow::Value(Value::Str(s)))
|
||||
}
|
||||
|
||||
pub(super) fn findfile(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let p = string(&a[0])?;
|
||||
Ok(if file_exists(&p) { Value::Str(p) } else { Value::Nil })
|
||||
}
|
||||
|
||||
/// Read a regular text file up to 64 MiB, replacing invalid UTF-8 and returning `None` on read failure.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_text_file(path: &str) -> Option<String> {
|
||||
// Read through a byte-limited, regular-file-only reader so a path naming an endless device
|
||||
// cannot exhaust memory before the 64 MiB cap is enforced (CWE-400).
|
||||
const MAX: usize = 64 * 1024 * 1024;
|
||||
let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?;
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
/// Return `None` on the web, where native filesystem paths are unavailable.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read_text_file(_path: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn can_write(path: &str) -> bool {
|
||||
std::path::Path::new(path).parent().is_none_or(|d| d.as_os_str().is_empty() || d.exists())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn can_write(_path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_text_file(path: &str, text: &str, append: bool) {
|
||||
use std::io::Write;
|
||||
let f = std::fs::OpenOptions::new().create(true).write(true).append(append).truncate(!append).open(path);
|
||||
if let Ok(mut f) = f {
|
||||
let _ = f.write_all(text.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_text_file(_path: &str, _text: &str, _append: bool) {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn file_exists(path: &str) -> bool {
|
||||
std::path::Path::new(path).is_file()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn file_exists(_path: &str) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use super::args::{R, arity, list_arg, string};
|
||||
use super::*;
|
||||
|
||||
// ---------------- functions of functions ----------------
|
||||
|
||||
/// A function argument: a function, or a symbol naming one (`'car`, `'1+`).
|
||||
fn callable(ctx: &Ctx<'_>, f: &Value) -> Result<Value, LispError> {
|
||||
match f {
|
||||
Value::Sym(s) => {
|
||||
let v = crate::lisp::eval::lookup(ctx.env, s);
|
||||
if matches!(v, Value::Subr(_) | Value::Func(_)) { Ok(v) } else { Err(LispError::new(format!("no function definition: {}", s.name()))) }
|
||||
}
|
||||
Value::Subr(_) | Value::Func(_) => Ok(f.clone()),
|
||||
// A quoted lambda expression: '(lambda (x) …).
|
||||
Value::Cons(c) if c.car.as_sym().is_some_and(|s| s.name() == "LAMBDA") => {
|
||||
let def = c.cdr.clone();
|
||||
let arglist = def.car();
|
||||
if !arglist.is_list() {
|
||||
return Err(LispError::bad_arg("listp", &arglist));
|
||||
}
|
||||
let (mut params, mut locals, mut slash) = (Vec::new(), Vec::new(), false);
|
||||
for p in arglist.iter() {
|
||||
let Value::Sym(s) = p else { return Err(LispError::bad_arg("symbolp", &p)) };
|
||||
if s.name() == "/" {
|
||||
slash = true;
|
||||
} else if slash {
|
||||
locals.push(s);
|
||||
} else {
|
||||
params.push(s);
|
||||
}
|
||||
}
|
||||
Ok(Value::Func(Arc::new(crate::lisp::value::Func { name: None, params, locals, body: def.cdr() })))
|
||||
}
|
||||
other => Err(LispError::new(format!("bad function: {}", to_text(other, true)))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let f = callable(ctx, &a[0])?;
|
||||
Ok(Flow::Call(f, list_arg(&a[1])?))
|
||||
}
|
||||
|
||||
pub(super) fn mapcar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, usize::MAX)?;
|
||||
let f = callable(ctx, &a[0])?;
|
||||
for l in &a[1..] {
|
||||
list_arg(l)?;
|
||||
}
|
||||
Ok(Flow::Map(f, a[1..].to_vec()))
|
||||
}
|
||||
|
||||
pub(super) fn eval_fn(_ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
Ok(Flow::Eval(a[0].clone()))
|
||||
}
|
||||
|
||||
pub(super) fn set(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let Value::Sym(s) = &a[0] else { return Err(LispError::bad_arg("symbolp", &a[0])) };
|
||||
ctx.env.set(s, a[1].clone());
|
||||
Ok(Flow::Value(a[1].clone()))
|
||||
}
|
||||
|
||||
pub(super) fn boundp(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let Value::Sym(s) = &a[0] else { return Err(LispError::bad_arg("symbolp", &a[0])) };
|
||||
Ok(Flow::Value(Value::bool(ctx.env.globals.contains_key(s))))
|
||||
}
|
||||
|
||||
pub(super) fn vl_symbol_value(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let Value::Sym(s) = &a[0] else { return Err(LispError::bad_arg("symbolp", &a[0])) };
|
||||
Ok(Flow::Value(crate::lisp::eval::lookup(ctx.env, s)))
|
||||
}
|
||||
|
||||
pub(super) fn vl_sort(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let f = callable(ctx, &a[1])?;
|
||||
let mut items = list_arg(&a[0])?;
|
||||
// A merge sort that calls back into LISP for each comparison.
|
||||
let mut err = None;
|
||||
merge_sort(&mut items, &mut |x, y| {
|
||||
if err.is_some() {
|
||||
return false;
|
||||
}
|
||||
match ctx.call_sync(&f, vec![x.clone(), y.clone()]) {
|
||||
Ok(v) => !v.is_nil(),
|
||||
Err(e) => {
|
||||
err = Some(e);
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(e) = err {
|
||||
return Err(e);
|
||||
}
|
||||
Ok(Flow::Value(Value::list(items)))
|
||||
}
|
||||
|
||||
/// A stable sort with a fallible-free comparison (`less`).
|
||||
fn merge_sort(v: &mut Vec<Value>, less: &mut dyn FnMut(&Value, &Value) -> bool) {
|
||||
if v.len() <= 1 {
|
||||
return;
|
||||
}
|
||||
let mut right = v.split_off(v.len() / 2);
|
||||
merge_sort(v, less);
|
||||
merge_sort(&mut right, less);
|
||||
let left = std::mem::take(v);
|
||||
let (mut l, mut r) = (left.into_iter().peekable(), right.into_iter().peekable());
|
||||
while let (Some(x), Some(y)) = (l.peek(), r.peek()) {
|
||||
if less(y, x) {
|
||||
v.extend(r.next());
|
||||
} else {
|
||||
v.extend(l.next());
|
||||
}
|
||||
}
|
||||
v.extend(l);
|
||||
v.extend(r);
|
||||
}
|
||||
|
||||
fn filter(ctx: &mut Ctx<'_>, a: Vec<Value>, keep_true: bool) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let f = callable(ctx, &a[0])?;
|
||||
let mut out = Vec::new();
|
||||
for v in list_arg(&a[1])? {
|
||||
let r = ctx.call_sync(&f, vec![v.clone()])?;
|
||||
if r.is_nil() != keep_true {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
Ok(Flow::Value(Value::list(out)))
|
||||
}
|
||||
|
||||
pub(super) fn vl_remove_if(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
filter(ctx, a, false)
|
||||
}
|
||||
|
||||
pub(super) fn vl_remove_if_not(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
filter(ctx, a, true)
|
||||
}
|
||||
|
||||
pub(super) fn vl_member_if(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let f = callable(ctx, &a[0])?;
|
||||
let mut v = a[1].clone();
|
||||
while let Value::Cons(c) = v.clone() {
|
||||
if !ctx.call_sync(&f, vec![c.car.clone()])?.is_nil() {
|
||||
return Ok(Flow::Value(v));
|
||||
}
|
||||
v = c.cdr.clone();
|
||||
}
|
||||
Ok(Flow::Value(Value::Nil))
|
||||
}
|
||||
|
||||
fn every_some(ctx: &mut Ctx<'_>, a: Vec<Value>, every: bool) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, usize::MAX)?;
|
||||
let f = callable(ctx, &a[0])?;
|
||||
let mut lists: Vec<Value> = a[1..].to_vec();
|
||||
loop {
|
||||
if lists.iter().any(|l| !matches!(l, Value::Cons(_))) {
|
||||
return Ok(Flow::Value(Value::bool(every)));
|
||||
}
|
||||
let r = ctx.call_sync(&f, lists.iter().map(Value::car).collect())?;
|
||||
if every && r.is_nil() {
|
||||
return Ok(Flow::Value(Value::Nil));
|
||||
}
|
||||
if !every && !r.is_nil() {
|
||||
return Ok(Flow::Value(r));
|
||||
}
|
||||
lists = lists.iter().map(Value::cdr).collect();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn vl_every(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
every_some(ctx, a, true)
|
||||
}
|
||||
|
||||
pub(super) fn vl_some(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
every_some(ctx, a, false)
|
||||
}
|
||||
|
||||
/// The value `vl-catch-all-apply` returns for an error.
|
||||
pub fn catch_all_error(msg: &str) -> Value {
|
||||
Value::cons(Value::sym("*VL-CATCH-ALL-APPLY-ERROR*"), Value::str(msg))
|
||||
}
|
||||
|
||||
fn catch_all_message(v: &Value) -> Option<Value> {
|
||||
(v.car().as_sym().is_some_and(|s| s.name() == "*VL-CATCH-ALL-APPLY-ERROR*")).then(|| v.cdr())
|
||||
}
|
||||
|
||||
pub(super) fn vl_catch_all_apply(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let f = callable(ctx, &a[0])?;
|
||||
Ok(Flow::Catch(f, list_arg(&a[1])?))
|
||||
}
|
||||
|
||||
pub(super) fn vl_catch_all_error_p(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::bool(catch_all_message(&a[0]).is_some()))
|
||||
}
|
||||
|
||||
pub(super) fn vl_catch_all_error_message(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
catch_all_message(&a[0]).ok_or_else(|| LispError::bad_arg("vl-catch-all-apply-error-p", &a[0]))
|
||||
}
|
||||
|
||||
pub(super) fn exit(_ctx: &mut Ctx<'_>, _a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
Err(LispError::quit("quit / exit abort"))
|
||||
}
|
||||
|
||||
/// `(load file [onfailure])`: evaluate a file's forms; its last value is the result.
|
||||
pub(super) fn load(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 2)?;
|
||||
let name = string(&a[0])?.to_string();
|
||||
let text = crate::lisp::load_source(ctx.s, &name);
|
||||
match text {
|
||||
Some(t) => Ok(Flow::Forms(read::read_all(&t)?)),
|
||||
None => match a.get(1) {
|
||||
Some(v) => Ok(Flow::Value(v.clone())),
|
||||
None => Err(LispError::new(format!("LOAD failed: \"{name}\""))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `(autoload file '("CMD" …))`: each command loads `file` the first time it is typed, then runs
|
||||
/// the definition the file gave it. Startup files register commands this way without loading
|
||||
/// every routine up front.
|
||||
pub(super) fn autoload(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let file = string(&a[0])?;
|
||||
for cmd in list_arg(&a[1])? {
|
||||
let name = string(&cmd)?.to_ascii_uppercase();
|
||||
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')) {
|
||||
return Err(LispError::bad_arg("command name", &cmd));
|
||||
}
|
||||
// The stub forgets itself, loads the file and calls what it defined.
|
||||
let file_lit = to_text(&Value::Str(file.clone()), true);
|
||||
let src = format!(
|
||||
"(lambda () (setq C:{name} nil) (load {file_lit}) (if C:{name} (C:{name}) (princ \"\\n; error: {} did not define {name}\")) (princ))",
|
||||
file.replace(['"', '\\'], "/")
|
||||
);
|
||||
let stub = callable(ctx, &read::read_one(&src)?)?;
|
||||
ctx.env.set(&Sym::new(&format!("C:{name}")), stub);
|
||||
}
|
||||
Ok(Flow::Value(Value::Nil))
|
||||
}
|
||||
|
||||
pub(super) fn noop(_a: &[Value]) -> R {
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
|
||||
pub(super) fn ver(_a: &[Value]) -> R {
|
||||
Ok(Value::str("Visual LISP 2026 (en)"))
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
use super::args::{R, arity, num, point};
|
||||
use super::*;
|
||||
|
||||
// ---------------- geometry ----------------
|
||||
|
||||
pub(super) fn polar(a: &[Value]) -> R {
|
||||
arity(a, 3, 3)?;
|
||||
let p = point(&a[0])?;
|
||||
let (ang, d) = (num(&a[1])?, num(&a[2])?);
|
||||
let out = [p[0] + d * ang.cos(), p[1] + d * ang.sin(), p[2]];
|
||||
Ok(if a[0].iter().count() == 2 { Value::point(&out[..2]) } else { Value::point(&out) })
|
||||
}
|
||||
|
||||
pub(super) fn distance(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
let (p, q) = (point(&a[0])?, point(&a[1])?);
|
||||
// Two 2D points, or one: a 2D distance.
|
||||
let dz = if a[0].iter().count() == 2 || a[1].iter().count() == 2 { 0.0 } else { q[2] - p[2] };
|
||||
Ok(Value::Real(((q[0] - p[0]).powi(2) + (q[1] - p[1]).powi(2) + dz * dz).sqrt()))
|
||||
}
|
||||
|
||||
pub(super) fn angle(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
let (p, q) = (point(&a[0])?, point(&a[1])?);
|
||||
Ok(Value::Real((q[1] - p[1]).atan2(q[0] - p[0]).rem_euclid(std::f64::consts::TAU)))
|
||||
}
|
||||
|
||||
/// `(inters p1 p2 p3 p4 [onseg])`: where lines p1-p2 and p3-p4 cross; with `onseg` (the default)
|
||||
/// only within both segments.
|
||||
pub(super) fn inters(a: &[Value]) -> R {
|
||||
arity(a, 4, 5)?;
|
||||
let (p1, p2, p3, p4) = (point(&a[0])?, point(&a[1])?, point(&a[2])?, point(&a[3])?);
|
||||
let onseg = a.get(4).is_none_or(|v| !v.is_nil());
|
||||
let (d1x, d1y, d2x, d2y) = (p2[0] - p1[0], p2[1] - p1[1], p4[0] - p3[0], p4[1] - p3[1]);
|
||||
let den = d1x * d2y - d1y * d2x;
|
||||
if den.abs() < 1e-12 {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
let t = ((p3[0] - p1[0]) * d2y - (p3[1] - p1[1]) * d2x) / den;
|
||||
let u = ((p3[0] - p1[0]) * d1y - (p3[1] - p1[1]) * d1x) / den;
|
||||
let eps = 1e-10;
|
||||
if onseg && (t < -eps || t > 1.0 + eps || u < -eps || u > 1.0 + eps) {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
let z = p1[2] + t * (p2[2] - p1[2]);
|
||||
let x = [p1[0] + t * d1x, p1[1] + t * d1y, z];
|
||||
let two_d = a.iter().take(4).any(|p| p.iter().count() == 2);
|
||||
Ok(if two_d { Value::point(&x[..2]) } else { Value::point(&x) })
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use super::args::{R, arity, int, list_arg, num, string};
|
||||
use super::*;
|
||||
|
||||
// ---------------- predicates and lists ----------------
|
||||
|
||||
pub(super) fn atom(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::bool(!matches!(a[0], Value::Cons(_))))
|
||||
}
|
||||
|
||||
pub(super) fn listp(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::bool(matches!(a[0], Value::Cons(_) | Value::Nil)))
|
||||
}
|
||||
|
||||
pub(super) fn consp(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::bool(matches!(a[0], Value::Cons(_))))
|
||||
}
|
||||
|
||||
pub(super) fn null(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::bool(a[0].is_nil()))
|
||||
}
|
||||
|
||||
pub(super) fn type_of(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(a[0].type_name().map(Value::sym).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn eq_fn(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
Ok(Value::bool(eq(&a[0], &a[1])))
|
||||
}
|
||||
|
||||
pub(super) fn equal_fn(a: &[Value]) -> R {
|
||||
arity(a, 2, 3)?;
|
||||
let fuzz = a.get(2).map(num).transpose()?.unwrap_or(0.0);
|
||||
Ok(Value::bool(equal(&a[0], &a[1], fuzz)))
|
||||
}
|
||||
|
||||
pub(super) fn cxr(a: &[Value], path: &str) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let mut v = a[0].clone();
|
||||
// The path is read right to left: cadr = car of cdr.
|
||||
for c in path.chars().rev() {
|
||||
if !matches!(v, Value::Cons(_) | Value::Nil) {
|
||||
return Err(LispError::bad_arg("consp", &v));
|
||||
}
|
||||
v = if c == 'a' { v.car() } else { v.cdr() };
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub(super) fn cons(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
Ok(Value::cons(a[0].clone(), a[1].clone()))
|
||||
}
|
||||
|
||||
pub(super) fn list(a: &[Value]) -> R {
|
||||
Ok(Value::list(a.to_vec()))
|
||||
}
|
||||
|
||||
pub(super) fn append(a: &[Value]) -> R {
|
||||
let mut items = Vec::new();
|
||||
for v in a {
|
||||
items.extend(list_arg(v)?);
|
||||
}
|
||||
Ok(Value::list(items))
|
||||
}
|
||||
|
||||
pub(super) fn reverse(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let mut v = list_arg(&a[0])?;
|
||||
v.reverse();
|
||||
Ok(Value::list(v))
|
||||
}
|
||||
|
||||
pub(super) fn length(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::Int(i32::try_from(list_arg(&a[0])?.len()).unwrap_or(i32::MAX)))
|
||||
}
|
||||
|
||||
pub(super) fn nth(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
let n = int(&a[0])?;
|
||||
if n < 0 {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
Ok(a[1].iter().nth(n as usize).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn last(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(list_arg(&a[0])?.pop().unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn member(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
let mut v = a[1].clone();
|
||||
while let Value::Cons(c) = &v {
|
||||
if equal(&c.car, &a[0], 0.0) {
|
||||
return Ok(v);
|
||||
}
|
||||
let next = c.cdr.clone();
|
||||
v = next;
|
||||
}
|
||||
Ok(Value::Nil)
|
||||
}
|
||||
|
||||
pub(super) fn assoc(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
Ok(a[1].iter().find(|item| matches!(item, Value::Cons(_)) && equal(&item.car(), &a[0], 0.0)).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn subst(a: &[Value]) -> R {
|
||||
arity(a, 3, 3)?;
|
||||
let (new, old) = (&a[0], &a[1]);
|
||||
if !a[2].is_list() {
|
||||
return Ok(if equal(&a[2], old, 0.0) { new.clone() } else { a[2].clone() });
|
||||
}
|
||||
Ok(Value::list(a[2].iter().map(|v| if equal(&v, old, 0.0) { new.clone() } else { v }).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) fn vl_position(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
Ok(a[1].iter().position(|v| equal(&v, &a[0], 0.0)).map(|i| Value::Int(i32::try_from(i).unwrap_or(i32::MAX))).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn vl_remove(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
Ok(Value::list(list_arg(&a[1])?.into_iter().filter(|v| !equal(v, &a[0], 0.0)).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) fn vl_list_length(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(if a[0].is_list() { Value::Int(i32::try_from(a[0].to_vec().len()).unwrap_or(i32::MAX)) } else { Value::Nil })
|
||||
}
|
||||
|
||||
pub(super) fn acad_strlsort(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let mut v = list_arg(&a[0])?.iter().map(string).collect::<Result<Vec<_>, _>>()?;
|
||||
v.sort_by_key(|s| s.to_lowercase());
|
||||
Ok(Value::list(v.into_iter().map(Value::Str).collect::<Vec<_>>()))
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
use super::args::{R, arity, int, num, real_or_int};
|
||||
use super::*;
|
||||
|
||||
// ---------------- numbers ----------------
|
||||
|
||||
/// Integer arithmetic wraps at 32 bits, as AutoLISP's does; any real makes the result real.
|
||||
fn arith(a: &[Value], op: char) -> R {
|
||||
let all_int = a.iter().all(|v| matches!(v, Value::Int(_)));
|
||||
let mut it = a.iter();
|
||||
let Some(first) = it.next() else {
|
||||
return Ok(Value::Int(if op == '*' { 1 } else { 0 }));
|
||||
};
|
||||
if all_int {
|
||||
let mut acc = int(first)?;
|
||||
if a.len() == 1 {
|
||||
return Ok(Value::Int(match op {
|
||||
'-' => acc.wrapping_neg(),
|
||||
_ => acc,
|
||||
}));
|
||||
}
|
||||
for v in it {
|
||||
let x = int(v)?;
|
||||
acc = match op {
|
||||
'+' => acc.wrapping_add(x),
|
||||
'-' => acc.wrapping_sub(x),
|
||||
'*' => acc.wrapping_mul(x),
|
||||
_ => {
|
||||
if x == 0 {
|
||||
return Err(LispError::new("divide by zero"));
|
||||
}
|
||||
acc.wrapping_div(x)
|
||||
}
|
||||
};
|
||||
}
|
||||
return Ok(Value::Int(acc));
|
||||
}
|
||||
let mut acc = num(first)?;
|
||||
if a.len() == 1 {
|
||||
return Ok(Value::Real(if op == '-' { -acc } else { acc }));
|
||||
}
|
||||
for v in it {
|
||||
let x = num(v)?;
|
||||
acc = match op {
|
||||
'+' => acc + x,
|
||||
'-' => acc - x,
|
||||
'*' => acc * x,
|
||||
_ => {
|
||||
if x == 0.0 {
|
||||
return Err(LispError::new("divide by zero"));
|
||||
}
|
||||
acc / x
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(Value::Real(acc))
|
||||
}
|
||||
|
||||
pub(super) fn add(a: &[Value]) -> R {
|
||||
arith(a, '+')
|
||||
}
|
||||
|
||||
pub(super) fn sub(a: &[Value]) -> R {
|
||||
arith(a, '-')
|
||||
}
|
||||
|
||||
pub(super) fn mul(a: &[Value]) -> R {
|
||||
arith(a, '*')
|
||||
}
|
||||
|
||||
pub(super) fn div(a: &[Value]) -> R {
|
||||
arity(a, 1, usize::MAX)?;
|
||||
arith(a, '/')
|
||||
}
|
||||
|
||||
pub(super) fn rem(a: &[Value]) -> R {
|
||||
arity(a, 1, usize::MAX)?;
|
||||
if a.iter().all(|v| matches!(v, Value::Int(_))) {
|
||||
let mut it = a.iter();
|
||||
let mut acc = int(it.next().unwrap_or(&Value::Int(0)))?;
|
||||
for v in it {
|
||||
let x = int(v)?;
|
||||
if x == 0 {
|
||||
return Err(LispError::new("divide by zero"));
|
||||
}
|
||||
acc = acc.wrapping_rem(x);
|
||||
}
|
||||
return Ok(Value::Int(acc));
|
||||
}
|
||||
let mut it = a.iter();
|
||||
let mut acc = num(it.next().unwrap_or(&Value::Int(0)))?;
|
||||
for v in it {
|
||||
let x = num(v)?;
|
||||
if x == 0.0 {
|
||||
return Err(LispError::new("divide by zero"));
|
||||
}
|
||||
acc %= x;
|
||||
}
|
||||
Ok(Value::Real(acc))
|
||||
}
|
||||
|
||||
pub(super) fn one_plus(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
arith(&[a[0].clone(), Value::Int(1)], '+')
|
||||
}
|
||||
|
||||
pub(super) fn one_minus(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
arith(&[a[0].clone(), Value::Int(1)], '-')
|
||||
}
|
||||
|
||||
pub(super) fn abs(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(match &a[0] {
|
||||
Value::Int(i) => Value::Int(i.wrapping_abs()),
|
||||
v => Value::Real(num(v)?.abs()),
|
||||
})
|
||||
}
|
||||
|
||||
fn min_max(a: &[Value], max: bool) -> R {
|
||||
arity(a, 1, usize::MAX)?;
|
||||
let all_int = a.iter().all(|v| matches!(v, Value::Int(_)));
|
||||
let mut best = num(&a[0])?;
|
||||
for v in &a[1..] {
|
||||
let x = num(v)?;
|
||||
if (max && x > best) || (!max && x < best) {
|
||||
best = x;
|
||||
}
|
||||
}
|
||||
Ok(real_or_int(best, all_int))
|
||||
}
|
||||
|
||||
pub(super) fn min(a: &[Value]) -> R {
|
||||
min_max(a, false)
|
||||
}
|
||||
|
||||
pub(super) fn max(a: &[Value]) -> R {
|
||||
min_max(a, true)
|
||||
}
|
||||
|
||||
fn unary(a: &[Value], f: fn(f64) -> f64) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let r = f(num(&a[0])?);
|
||||
if r.is_finite() { Ok(Value::Real(r)) } else { Err(LispError::new("function undefined for argument")) }
|
||||
}
|
||||
|
||||
pub(super) fn sqrt(a: &[Value]) -> R {
|
||||
if num(a.first().unwrap_or(&Value::Nil))? < 0.0 {
|
||||
return Err(LispError::new("function undefined for argument"));
|
||||
}
|
||||
unary(a, f64::sqrt)
|
||||
}
|
||||
|
||||
pub(super) fn exp(a: &[Value]) -> R {
|
||||
unary(a, f64::exp)
|
||||
}
|
||||
|
||||
pub(super) fn log(a: &[Value]) -> R {
|
||||
if num(a.first().unwrap_or(&Value::Nil))? <= 0.0 {
|
||||
return Err(LispError::new("function undefined for argument"));
|
||||
}
|
||||
unary(a, f64::ln)
|
||||
}
|
||||
|
||||
pub(super) fn sin(a: &[Value]) -> R {
|
||||
unary(a, f64::sin)
|
||||
}
|
||||
|
||||
pub(super) fn cos(a: &[Value]) -> R {
|
||||
unary(a, f64::cos)
|
||||
}
|
||||
|
||||
pub(super) fn atan(a: &[Value]) -> R {
|
||||
arity(a, 1, 2)?;
|
||||
let y = num(&a[0])?;
|
||||
Ok(Value::Real(match a.get(1) {
|
||||
Some(x) => y.atan2(num(x)?),
|
||||
None => y.atan(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn expt(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
match (&a[0], &a[1]) {
|
||||
(Value::Int(b), Value::Int(e)) if *e >= 0 => Ok(Value::Int(b.wrapping_pow(u32::try_from(*e).unwrap_or(0)))),
|
||||
(b, e) => {
|
||||
let r = num(b)?.powf(num(e)?);
|
||||
if r.is_finite() { Ok(Value::Real(r)) } else { Err(LispError::new("function undefined for argument")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn fix(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(match &a[0] {
|
||||
Value::Int(i) => Value::Int(*i),
|
||||
v => {
|
||||
let r = num(v)?.trunc();
|
||||
if r.is_finite() && r >= f64::from(i32::MIN) && r <= f64::from(i32::MAX) { Value::Int(r as i32) } else { Value::Real(r) }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn float(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::Real(num(&a[0])?))
|
||||
}
|
||||
|
||||
pub(super) fn gcd(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
let (mut x, mut y) = (i64::from(int(&a[0])?).abs(), i64::from(int(&a[1])?).abs());
|
||||
if x == 0 && y == 0 {
|
||||
return Err(LispError::new("bad argument value: 0"));
|
||||
}
|
||||
while y != 0 {
|
||||
let t = x % y;
|
||||
x = y;
|
||||
y = t;
|
||||
}
|
||||
Ok(Value::Int(i32::try_from(x).unwrap_or(i32::MAX)))
|
||||
}
|
||||
|
||||
pub(super) fn logand(a: &[Value]) -> R {
|
||||
a.iter().try_fold(-1i32, |acc, v| Ok(acc & int(v)?)).map(|r| Value::Int(if a.is_empty() { 0 } else { r }))
|
||||
}
|
||||
|
||||
pub(super) fn logior(a: &[Value]) -> R {
|
||||
a.iter().try_fold(0i32, |acc, v| Ok(acc | int(v)?)).map(Value::Int)
|
||||
}
|
||||
|
||||
pub(super) fn lsh(a: &[Value]) -> R {
|
||||
arity(a, 0, 2)?;
|
||||
let x = a.first().map(int).transpose()?.unwrap_or(0);
|
||||
let n = a.get(1).map(int).transpose()?.unwrap_or(0);
|
||||
let r = if n >= 32 || n <= -32 {
|
||||
0
|
||||
} else if n >= 0 {
|
||||
x.wrapping_shl(n as u32)
|
||||
} else {
|
||||
((x as u32) >> ((-n) as u32)) as i32
|
||||
};
|
||||
Ok(Value::Int(r))
|
||||
}
|
||||
|
||||
pub(super) fn lognot(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::Int(!int(&a[0])?))
|
||||
}
|
||||
|
||||
/// Numbers compare by value; strings by text.
|
||||
fn compare(a: &Value, b: &Value) -> Result<std::cmp::Ordering, LispError> {
|
||||
match (a, b) {
|
||||
(Value::Str(x), Value::Str(y)) => Ok(x.cmp(y)),
|
||||
_ => num(a)?.partial_cmp(&num(b)?).ok_or_else(|| LispError::new("bad argument value")),
|
||||
}
|
||||
}
|
||||
|
||||
fn chain(a: &[Value], ok: fn(std::cmp::Ordering) -> bool) -> R {
|
||||
arity(a, 1, usize::MAX)?;
|
||||
for w in a.windows(2) {
|
||||
if let [x, y] = w
|
||||
&& !ok(compare(x, y)?)
|
||||
{
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
}
|
||||
Ok(Value::t())
|
||||
}
|
||||
|
||||
/// `=` and `/=`: numbers and strings compare as [`compare`] does, and any other atom compares by
|
||||
/// what it is, as `eq` does. AutoLISP answers `(= (type x) 'LIST)` -- the idiom for a type test,
|
||||
/// since `type` returns a symbol -- where coercing that symbol to a number failed the whole
|
||||
/// routine with "bad argument type" instead (#1597). A mismatch is `nil`, not an error, so
|
||||
/// `(= 1 "1")` and `(= '(1) '(1))` answer too. The ordering comparisons stay numbers and strings
|
||||
/// only, as AutoLISP has them.
|
||||
fn eq_chain(a: &[Value], want_equal: bool) -> R {
|
||||
arity(a, 1, usize::MAX)?;
|
||||
for w in a.windows(2) {
|
||||
let [x, y] = w else { continue };
|
||||
let same = match (x, y) {
|
||||
(Value::Str(_), Value::Str(_)) => compare(x, y)?.is_eq(),
|
||||
_ if x.as_number().is_some() && y.as_number().is_some() => compare(x, y)?.is_eq(),
|
||||
_ => eq(x, y),
|
||||
};
|
||||
if same != want_equal {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
}
|
||||
Ok(Value::t())
|
||||
}
|
||||
|
||||
pub(super) fn num_eq(a: &[Value]) -> R {
|
||||
eq_chain(a, true)
|
||||
}
|
||||
|
||||
pub(super) fn num_ne(a: &[Value]) -> R {
|
||||
eq_chain(a, false)
|
||||
}
|
||||
|
||||
pub(super) fn lt(a: &[Value]) -> R {
|
||||
chain(a, Ordering::is_lt)
|
||||
}
|
||||
|
||||
pub(super) fn gt(a: &[Value]) -> R {
|
||||
chain(a, Ordering::is_gt)
|
||||
}
|
||||
|
||||
pub(super) fn le(a: &[Value]) -> R {
|
||||
chain(a, Ordering::is_le)
|
||||
}
|
||||
|
||||
pub(super) fn ge(a: &[Value]) -> R {
|
||||
chain(a, Ordering::is_ge)
|
||||
}
|
||||
|
||||
pub(super) fn zerop(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::bool(num(&a[0])? == 0.0))
|
||||
}
|
||||
|
||||
pub(super) fn minusp(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::bool(num(&a[0])? < 0.0))
|
||||
}
|
||||
|
||||
pub(super) fn numberp(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::bool(a[0].as_number().is_some()))
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use super::args::{arity, string};
|
||||
use super::*;
|
||||
|
||||
// ---------------- output ----------------
|
||||
|
||||
/// Text printed to a file (an open file argument) or the command line.
|
||||
pub(super) fn emit(ctx: &mut Ctx<'_>, text: &str, file: Option<&Value>) -> Result<(), LispError> {
|
||||
charge_bytes(text.len())?;
|
||||
match file {
|
||||
None | Some(Value::Nil) => {
|
||||
ctx.env.print(text);
|
||||
Ok(())
|
||||
}
|
||||
Some(Value::File(id)) => match ctx.env.files.get_mut(id) {
|
||||
Some(f) if f.writing => {
|
||||
f.text.push_str(text);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(LispError::bad_arg("streamp", &Value::File(*id))),
|
||||
},
|
||||
Some(other) => Err(LispError::bad_arg("streamp", other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `(princ)` with no argument: a symbol that prints as nothing, ending a routine quietly.
|
||||
pub fn null_symbol() -> Value {
|
||||
Value::Sym(Sym(Arc::from("")))
|
||||
}
|
||||
|
||||
pub fn is_null_symbol(v: &Value) -> bool {
|
||||
matches!(v, Value::Sym(s) if s.name().is_empty())
|
||||
}
|
||||
|
||||
pub(super) fn princ(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 2)?;
|
||||
let Some(v) = a.first() else { return Ok(Flow::Value(null_symbol())) };
|
||||
emit(ctx, &to_text(v, false), a.get(1))?;
|
||||
Ok(Flow::Value(v.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn prin1(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 2)?;
|
||||
let Some(v) = a.first() else { return Ok(Flow::Value(null_symbol())) };
|
||||
emit(ctx, &to_text(v, true), a.get(1))?;
|
||||
Ok(Flow::Value(v.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn print(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 2)?;
|
||||
let Some(v) = a.first() else { return Ok(Flow::Value(null_symbol())) };
|
||||
emit(ctx, &format!("\n{} ", to_text(v, true)), a.get(1))?;
|
||||
Ok(Flow::Value(v.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn prompt(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
let text = string(&a[0])?;
|
||||
charge_bytes(text.len())?;
|
||||
ctx.env.print(&text);
|
||||
Ok(Flow::Value(Value::Nil))
|
||||
}
|
||||
|
||||
pub(super) fn terpri(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 0, 1)?;
|
||||
emit(ctx, "\n", a.first())?;
|
||||
Ok(Flow::Value(Value::Nil))
|
||||
}
|
||||
|
||||
pub(super) fn alert(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 1, 1)?;
|
||||
// No dialog from LISP: the message goes to the command line.
|
||||
let msg = string(&a[0])?;
|
||||
charge_bytes(msg.len())?;
|
||||
let out = std::mem::take(&mut ctx.env.out);
|
||||
if !out.is_empty() {
|
||||
ctx.s.echo(out);
|
||||
}
|
||||
for l in msg.lines() {
|
||||
ctx.s.echo(l.to_string());
|
||||
}
|
||||
Ok(Flow::Value(Value::Nil))
|
||||
}
|
||||
|
||||
/// A real as `princ` shows it (for messages).
|
||||
pub fn real(r: f64) -> String {
|
||||
real_text(r)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
use super::args::{R, arity, int, list_arg, string};
|
||||
use super::*;
|
||||
|
||||
// ---------------- strings ----------------
|
||||
|
||||
pub(super) fn strcat(a: &[Value]) -> R {
|
||||
let parts = a.iter().map(string).collect::<Result<Vec<_>, _>>()?;
|
||||
// Check the length before joining: many long arguments could join to more than memory.
|
||||
check_string_len(parts.iter().fold(0usize, |n, p| n.saturating_add(p.len())))?;
|
||||
Ok(Value::str(&parts.concat()))
|
||||
}
|
||||
|
||||
pub(super) fn strlen(a: &[Value]) -> R {
|
||||
let mut n = 0usize;
|
||||
for v in a {
|
||||
n += string(v)?.chars().count();
|
||||
}
|
||||
Ok(Value::Int(i32::try_from(n).unwrap_or(i32::MAX)))
|
||||
}
|
||||
|
||||
pub(super) fn substr(a: &[Value]) -> R {
|
||||
arity(a, 2, 3)?;
|
||||
let s = string(&a[0])?;
|
||||
let start = int(&a[1])?;
|
||||
if start < 1 {
|
||||
return Err(LispError::new(format!("bad argument value: positive {start}")));
|
||||
}
|
||||
let chars = s.chars().skip(start as usize - 1);
|
||||
let out: String = match a.get(2) {
|
||||
Some(n) => {
|
||||
let n = int(n)?;
|
||||
if n < 0 {
|
||||
return Err(LispError::new(format!("bad argument value: non-negative {n}")));
|
||||
}
|
||||
chars.take(n as usize).collect()
|
||||
}
|
||||
None => chars.collect(),
|
||||
};
|
||||
Ok(Value::str(&out))
|
||||
}
|
||||
|
||||
pub(super) fn strcase(a: &[Value]) -> R {
|
||||
arity(a, 1, 2)?;
|
||||
let s = string(&a[0])?;
|
||||
let lower = a.get(1).is_some_and(|v| !v.is_nil());
|
||||
Ok(Value::str(&if lower { s.to_lowercase() } else { s.to_uppercase() }))
|
||||
}
|
||||
|
||||
pub(super) fn itoa(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::str(&int(&a[0])?.to_string()))
|
||||
}
|
||||
|
||||
/// C's `atoi`: leading blanks, a sign, digits; 0 when there are none.
|
||||
pub(super) fn atoi(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let s = string(&a[0])?;
|
||||
let t = s.trim_start();
|
||||
let end = t.char_indices().take_while(|(i, c)| c.is_ascii_digit() || (*i == 0 && (*c == '-' || *c == '+'))).count();
|
||||
Ok(Value::Int(t.get(..end).and_then(|n| n.parse::<i64>().ok()).map(|n| n as i32).unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// C's `atof`: the longest number at the start; 0.0 when there is none.
|
||||
pub(super) fn atof(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let s = string(&a[0])?;
|
||||
// The longest prefix (of the first 64 characters) that reads as a finite number.
|
||||
let t: String = s.trim_start().chars().take(64).collect();
|
||||
let ends: Vec<usize> = t.char_indices().map(|(i, _)| i).skip(1).chain(std::iter::once(t.len())).collect();
|
||||
let v = ends.iter().rev().find_map(|&e| t.get(..e).and_then(|p| p.parse::<f64>().ok()).filter(|v| v.is_finite() && !p_is_word(&t[..e])));
|
||||
Ok(Value::Real(v.unwrap_or(0.0)))
|
||||
}
|
||||
|
||||
/// Words Rust reads as numbers that C's `atof` doesn't ("inf", "nan", "infinity").
|
||||
fn p_is_word(p: &str) -> bool {
|
||||
p.chars().any(|c| c.is_ascii_alphabetic() && !matches!(c, 'e' | 'E'))
|
||||
}
|
||||
|
||||
pub(super) fn chr(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let c = u32::try_from(int(&a[0])?).ok().and_then(char::from_u32).map(String::from).unwrap_or_default();
|
||||
Ok(Value::str(&c))
|
||||
}
|
||||
|
||||
pub(super) fn ascii(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::Int(string(&a[0])?.chars().next().map(|c| c as i32).unwrap_or(0)))
|
||||
}
|
||||
|
||||
pub(super) fn read_fn(a: &[Value]) -> R {
|
||||
arity(a, 0, 1)?;
|
||||
match a.first() {
|
||||
None | Some(Value::Nil) => Ok(Value::Nil),
|
||||
Some(v) => read::read_one(&string(v)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn vl_prin1_to_string(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::str(&to_text(&a[0], true)))
|
||||
}
|
||||
|
||||
pub(super) fn vl_princ_to_string(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::str(&to_text(&a[0], false)))
|
||||
}
|
||||
|
||||
pub(super) fn vl_symbol_name(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
match &a[0] {
|
||||
Value::Sym(s) => Ok(Value::str(s.name())),
|
||||
other => Err(LispError::bad_arg("symbolp", other)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn vl_symbolp(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::bool(matches!(a[0], Value::Sym(_))))
|
||||
}
|
||||
|
||||
pub(super) fn vl_string_search(a: &[Value]) -> R {
|
||||
arity(a, 2, 3)?;
|
||||
let (pat, s) = (string(&a[0])?, string(&a[1])?);
|
||||
let start = a.get(2).map(int).transpose()?.unwrap_or(0).max(0) as usize;
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let tail: String = chars.iter().skip(start).collect();
|
||||
Ok(tail.find(&*pat).map(|b| Value::Int((start + tail[..b].chars().count()) as i32)).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn vl_string_subst(a: &[Value]) -> R {
|
||||
arity(a, 3, 4)?;
|
||||
let (new, pat, s) = (string(&a[0])?, string(&a[1])?, string(&a[2])?);
|
||||
let start = a.get(3).map(int).transpose()?.unwrap_or(0).max(0) as usize;
|
||||
let byte_start = s.char_indices().nth(start).map(|(b, _)| b).unwrap_or(s.len());
|
||||
let out = match s.get(byte_start..).and_then(|t| t.find(&*pat)) {
|
||||
Some(i) if !pat.is_empty() => format!("{}{}{}", &s[..byte_start + i], new, &s[byte_start + i + pat.len()..]),
|
||||
_ => s.to_string(),
|
||||
};
|
||||
Ok(Value::str(&out))
|
||||
}
|
||||
|
||||
fn trim_fn(a: &[Value], left: bool, right: bool) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
let (set, s) = (string(&a[0])?, string(&a[1])?);
|
||||
let is = |c: char| set.contains(c);
|
||||
let mut t: &str = &s;
|
||||
if left {
|
||||
t = t.trim_start_matches(is);
|
||||
}
|
||||
if right {
|
||||
t = t.trim_end_matches(is);
|
||||
}
|
||||
Ok(Value::str(t))
|
||||
}
|
||||
|
||||
pub(super) fn vl_string_trim(a: &[Value]) -> R {
|
||||
trim_fn(a, true, true)
|
||||
}
|
||||
|
||||
pub(super) fn vl_string_left_trim(a: &[Value]) -> R {
|
||||
trim_fn(a, true, false)
|
||||
}
|
||||
|
||||
pub(super) fn vl_string_right_trim(a: &[Value]) -> R {
|
||||
trim_fn(a, false, true)
|
||||
}
|
||||
|
||||
pub(super) fn vl_string_translate(a: &[Value]) -> R {
|
||||
arity(a, 3, 3)?;
|
||||
let (from, to, s) = (string(&a[0])?, string(&a[1])?, string(&a[2])?);
|
||||
let to: Vec<char> = to.chars().collect();
|
||||
let out: String = s.chars().map(|c| from.chars().position(|f| f == c).and_then(|i| to.get(i).or(to.last()).copied()).unwrap_or(c)).collect();
|
||||
Ok(Value::str(&out))
|
||||
}
|
||||
|
||||
pub(super) fn vl_string_to_list(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
Ok(Value::list(string(&a[0])?.chars().map(|c| Value::Int(c as i32)).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) fn vl_list_to_string(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let mut s = String::new();
|
||||
for v in list_arg(&a[0])? {
|
||||
s.extend(u32::try_from(int(&v)?).ok().and_then(char::from_u32));
|
||||
}
|
||||
Ok(Value::str(&s))
|
||||
}
|
||||
|
||||
pub(super) fn vl_filename_base(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let s = string(&a[0])?;
|
||||
Ok(Value::str(std::path::Path::new(&*s.replace('\\', "/")).file_stem().and_then(OsStr::to_str).unwrap_or("")))
|
||||
}
|
||||
|
||||
pub(super) fn vl_filename_extension(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let s = string(&a[0])?;
|
||||
Ok(std::path::Path::new(&*s.replace('\\', "/")).extension().and_then(OsStr::to_str).map(|e| Value::str(&format!(".{e}"))).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(super) fn vl_filename_directory(a: &[Value]) -> R {
|
||||
arity(a, 1, 1)?;
|
||||
let s = string(&a[0])?;
|
||||
let cut = s.rfind(['/', '\\']).unwrap_or(0);
|
||||
Ok(Value::str(&s[..cut]))
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use super::args::{R, arity, string};
|
||||
use super::*;
|
||||
|
||||
/// `wcmatch`: `#` a digit, `@` a letter, `.` neither, `*` any run, `?` any character, `~` (first)
|
||||
/// not, `[…]` a set (`[~…]` not in it, ranges `a-z`), `` ` `` the next character as is, `,`
|
||||
/// between alternative patterns. Letters compare case-sensitively.
|
||||
pub(super) fn wcmatch(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
let (s, pat) = (string(&a[0])?, string(&a[1])?);
|
||||
let text: Vec<char> = s.chars().collect();
|
||||
// Native wildcard matching runs as a single interpreter step, so its internal `*` backtracking
|
||||
// must not explore exponentially many placements unchecked (CWE-400). Charge every glob step
|
||||
// against a shared budget and fail the call if it is exhausted.
|
||||
let mut budget: u64 = WCMATCH_BUDGET;
|
||||
for p in split_patterns(&pat, &mut budget).map_err(|()| LispError::new("wcmatch: pattern too complex"))? {
|
||||
match match_pattern(&text, &p, &mut budget) {
|
||||
Ok(true) => return Ok(Value::bool(true)),
|
||||
Ok(false) => {}
|
||||
Err(()) => return Err(LispError::new("wcmatch: pattern too complex")),
|
||||
}
|
||||
}
|
||||
Ok(Value::bool(false))
|
||||
}
|
||||
|
||||
/// Shared step budget for one `wcmatch` call's wildcard matching. Large enough for the patterns
|
||||
/// AutoLISP routines legitimately use (including the pathological-but-bounded cases the tests
|
||||
/// cover), but finite so truly exponential `*`-heavy patterns are rejected rather than hanging.
|
||||
const WCMATCH_BUDGET: u64 = 200_000_000;
|
||||
|
||||
/// Spend one wildcard work unit, returning an error when the shared budget is exhausted.
|
||||
fn charge_match(budget: &mut u64) -> Result<(), ()> {
|
||||
*budget = budget.checked_sub(1).ok_or(())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Split comma-separated wildcard alternatives while preserving escapes and bracket sets.
|
||||
/// Every consumed character spends from the same budget used for matching.
|
||||
fn split_patterns(pat: &str, budget: &mut u64) -> Result<Vec<Vec<char>>, ()> {
|
||||
let mut out = vec![Vec::new()];
|
||||
let mut chars = pat.chars().peekable();
|
||||
let mut in_set = false;
|
||||
while let Some(c) = chars.next() {
|
||||
charge_match(budget)?;
|
||||
match c {
|
||||
'`' => {
|
||||
if let Some(cur) = out.last_mut() {
|
||||
cur.push('`');
|
||||
if let Some(escaped) = chars.next() {
|
||||
charge_match(budget)?;
|
||||
cur.push(escaped);
|
||||
}
|
||||
}
|
||||
}
|
||||
'[' => {
|
||||
in_set = true;
|
||||
if let Some(cur) = out.last_mut() {
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
']' => {
|
||||
in_set = false;
|
||||
if let Some(cur) = out.last_mut() {
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
',' if !in_set => out.push(Vec::new()),
|
||||
c => {
|
||||
if let Some(cur) = out.last_mut() {
|
||||
cur.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Match one wildcard alternative, including leading `~` negation, within the shared budget.
|
||||
fn match_pattern(text: &[char], pat: &[char], budget: &mut u64) -> Result<bool, ()> {
|
||||
if let Some(('~', rest)) = pat.split_first().map(|(c, r)| (*c, r))
|
||||
&& !rest.is_empty()
|
||||
{
|
||||
return glob(text, rest, 0, budget).map(|m| !m);
|
||||
}
|
||||
glob(text, pat, 0, budget)
|
||||
}
|
||||
|
||||
/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`. Every step is
|
||||
/// charged against `budget`; `Err(())` means the shared budget ran out (treated as too complex).
|
||||
fn glob(text: &[char], pat: &[char], depth: usize, budget: &mut u64) -> Result<bool, ()> {
|
||||
glob_cached(text, pat, depth, budget, &mut std::collections::HashSet::new())
|
||||
}
|
||||
|
||||
/// Match suffixes while memoizing failures; both matching work and retained failure entries are bounded.
|
||||
fn glob_cached(
|
||||
text: &[char],
|
||||
pat: &[char],
|
||||
depth: usize,
|
||||
budget: &mut u64,
|
||||
failed: &mut std::collections::HashSet<(usize, usize, usize)>,
|
||||
) -> Result<bool, ()> {
|
||||
charge_match(budget)?;
|
||||
let key = (text.len(), pat.len(), depth);
|
||||
if failed.contains(&key) {
|
||||
return Ok(false);
|
||||
}
|
||||
let matched = glob_inner(text, pat, depth, budget, failed)?;
|
||||
// Avoid revisiting the same failed suffix exponentially. Bound the memo table independently
|
||||
// of the step limit so a very large pattern cannot turn the optimization into an allocation bomb.
|
||||
if !matched && failed.len() < 100_000 {
|
||||
failed.insert(key);
|
||||
}
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
/// For the bracket class opening at `pat[p]`, whether `text[t]` is in it: the index of its closing
|
||||
/// `]` when it is, `None` when it is not or the class is never closed.
|
||||
fn class_end(text: &[char], pat: &[char], p: usize, t: usize, budget: &mut u64) -> Result<Option<usize>, ()> {
|
||||
let mut close = p;
|
||||
while let Some(c) = pat.get(close) {
|
||||
charge_match(budget)?;
|
||||
if *c == ']' {
|
||||
break;
|
||||
}
|
||||
close += 1;
|
||||
}
|
||||
if close == pat.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(&c) = text.get(t) else { return Ok(None) };
|
||||
let set = &pat[p + 1..close];
|
||||
let (neg, set) = match set.split_first() {
|
||||
Some(('~', r)) if !r.is_empty() => (true, r),
|
||||
_ => (false, set),
|
||||
};
|
||||
let mut hit = false;
|
||||
let mut i = 0;
|
||||
while i < set.len() {
|
||||
charge_match(budget)?;
|
||||
if set.get(i + 1) == Some(&'-') && i + 2 < set.len() {
|
||||
if set[i] <= c && c <= set[i + 2] {
|
||||
hit = true;
|
||||
}
|
||||
i += 3;
|
||||
} else {
|
||||
if set[i] == c {
|
||||
hit = true;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if hit == neg {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(close))
|
||||
}
|
||||
|
||||
/// Evaluate wildcard tokens and backtracking branches under shared work and recursion limits.
|
||||
fn glob_inner(
|
||||
text: &[char],
|
||||
pat: &[char],
|
||||
depth: usize,
|
||||
budget: &mut u64,
|
||||
failed: &mut std::collections::HashSet<(usize, usize, usize)>,
|
||||
) -> Result<bool, ()> {
|
||||
if *budget == 0 {
|
||||
return Err(());
|
||||
}
|
||||
*budget -= 1;
|
||||
if depth > 256 {
|
||||
return Err(());
|
||||
}
|
||||
let (mut t, mut p) = (0usize, 0usize);
|
||||
while p < pat.len() {
|
||||
charge_match(budget)?;
|
||||
match pat[p] {
|
||||
'*' => {
|
||||
let rest = &pat[p + 1..];
|
||||
for i in t..=text.len() {
|
||||
if glob_cached(&text[i..], rest, depth + 1, budget, failed)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
'`' => {
|
||||
let Some(&lit) = pat.get(p + 1) else { return Ok(false) };
|
||||
if text.get(t) != Some(&lit) {
|
||||
return Ok(false);
|
||||
}
|
||||
t += 1;
|
||||
p += 2;
|
||||
}
|
||||
'[' => {
|
||||
let Some(close) = class_end(text, pat, p, t, budget)? else { return Ok(false) };
|
||||
t += 1;
|
||||
p = close + 1;
|
||||
}
|
||||
pc => {
|
||||
let Some(&c) = text.get(t) else { return Ok(false) };
|
||||
let ok = match pc {
|
||||
'#' => c.is_ascii_digit(),
|
||||
'@' => c.is_alphabetic(),
|
||||
'.' => !c.is_alphanumeric(),
|
||||
'?' => true,
|
||||
other => other == c,
|
||||
};
|
||||
if !ok {
|
||||
return Ok(false);
|
||||
}
|
||||
t += 1;
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(t == text.len())
|
||||
}
|
||||
|
||||
/// `wcmatch` for other callers (`ssget` filters).
|
||||
pub fn wild_match(text: &str, pattern: &str) -> bool {
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
let mut budget: u64 = WCMATCH_BUDGET;
|
||||
split_patterns(pattern, &mut budget).is_ok_and(|patterns| patterns.iter().any(|p| match_pattern(&t, p, &mut budget) == Ok(true)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wildcard_budget_tests {
|
||||
use super::*;
|
||||
|
||||
/// Pattern splitting, literal matching, and bracket scans all stop when their shared fuel runs out.
|
||||
#[test]
|
||||
fn all_native_scans_consume_fuel() {
|
||||
assert!(split_patterns("abcdefgh", &mut 4).is_err());
|
||||
assert!(split_patterns("`a`b`c", &mut 4).is_err());
|
||||
let text: Vec<_> = "abcdefgh".chars().collect();
|
||||
assert!(glob(&text, &text, 0, &mut 4).is_err());
|
||||
let pattern: Vec<_> = "[abcdefgh]".chars().collect();
|
||||
assert!(glob(&['!'], &pattern, 0, &mut 6).is_err());
|
||||
// Enough for finding the closing bracket, but not scanning the entire set again.
|
||||
assert!(glob(&['!'], &pattern, 0, &mut 14).is_err());
|
||||
assert_eq!(glob(&['a'], &pattern, 0, &mut 100), Ok(true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use super::*;
|
||||
|
||||
/// An interactive command in progress.
|
||||
pub struct Running {
|
||||
pub id: String,
|
||||
pub machine: Box<dyn Interactive>,
|
||||
pub before: Arc<Drawing>,
|
||||
pub selection_before: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// A pending window/crossing selection started by a click on empty space.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct PendingWindow {
|
||||
pub corner: Vec2,
|
||||
pub during_command: bool,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use super::doc_state::{DocState, Snapshot};
|
||||
use super::error::{EngineError, Result};
|
||||
use super::running::{PendingWindow, Running};
|
||||
use super::view::View;
|
||||
use super::*;
|
||||
|
||||
mod cmdline;
|
||||
mod execute;
|
||||
mod files;
|
||||
mod interactive;
|
||||
mod picking;
|
||||
mod zoom;
|
||||
@@ -0,0 +1,225 @@
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
/// Process one line typed at the command line (Enter pressed). Spaces separate inputs as in a
|
||||
/// script line (see [`Session::script`]), so `MOVE L 0,0 5,5` ends the selection with the
|
||||
/// second space and `ERASE L ` with the trailing one. Input after the command ends is ignored.
|
||||
pub fn cmdline(&mut self, text: &str) -> Result<()> {
|
||||
let t = text.trim_end_matches(['\r', '\n']);
|
||||
// The command line types spaces literally at free-text prompts: the line is one input.
|
||||
if self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number) {
|
||||
return self.typed(t);
|
||||
}
|
||||
// At `Command:` leading blanks before a command name are not Enter (`" LINE"` from the
|
||||
// control channel starts LINE); a line of only blanks still is.
|
||||
let t = if self.running.is_none() && !t.trim().is_empty() { t.trim_start() } else { t };
|
||||
self.input_line(t, false)
|
||||
}
|
||||
|
||||
/// One line of input: each space ends an input and an empty input (two spaces in a row, a
|
||||
/// trailing space, an empty line) is Enter, which at `Command:` repeats the last command. In a
|
||||
/// script, a free-text prompt (TEXT/MTEXT contents) takes the rest of the line, a prompt with
|
||||
/// keywords still splits so `I 15` reaches the keyword and the value, and the line goes on
|
||||
/// after its command ends; at the command line the rest after the command ends is ignored.
|
||||
fn input_line(&mut self, line: &str, script: bool) -> Result<()> {
|
||||
let mut rest = line;
|
||||
let mut first = true;
|
||||
loop {
|
||||
if self.running.is_none() {
|
||||
if !script && !first {
|
||||
return Ok(());
|
||||
}
|
||||
// `cmd {json}`: programmatic call with parameters.
|
||||
if let Some((name, json)) = rest.trim().split_once(' ')
|
||||
&& json.trim_start().starts_with('{')
|
||||
{
|
||||
let params: Value =
|
||||
serde_json::from_str(json.trim()).map_err(|e| EngineError::BadParams { cmd: name.into(), msg: e.to_string() })?;
|
||||
let id = cmd::resolve_alias(&name.to_ascii_lowercase());
|
||||
let r = self.execute(&id, ¶ms)?;
|
||||
if !r.is_null() {
|
||||
self.echo(r.to_string());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
} else if script && self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number && p.keywords.is_empty()) {
|
||||
return self.typed(rest);
|
||||
}
|
||||
first = false;
|
||||
let (tok, more) = next_token(rest);
|
||||
if self.running.is_some() || self.lisp_prompt().is_some() {
|
||||
self.typed(tok)?;
|
||||
} else if tok.trim().is_empty() {
|
||||
self.input(Input::Enter)?;
|
||||
} else if lisp_expression(tok) {
|
||||
// `(expression)` or `!variable` at Command: evaluates LISP and shows the value.
|
||||
let src = tok.trim().strip_prefix('!').unwrap_or(tok.trim()).to_string();
|
||||
if let Err(e) = self.lisp_eval(&src, true, false, Vec::new()) {
|
||||
self.echo(e.to_string());
|
||||
}
|
||||
} else {
|
||||
self.start(tok)?;
|
||||
}
|
||||
match more {
|
||||
Some(more) => rest = more,
|
||||
None => return Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One token typed while a command runs.
|
||||
pub(crate) fn typed(&mut self, t: &str) -> Result<()> {
|
||||
let Some(prompt) = self.current_prompt() else { return Ok(()) };
|
||||
let tt = t.trim();
|
||||
if tt.is_empty() {
|
||||
return self.input(Input::Enter);
|
||||
}
|
||||
// FROM, M2P, point filters, `<a` (before `'`: `'_from` is a modifier).
|
||||
if let Some(r) = self.typed_modifier(&prompt, tt) {
|
||||
return r;
|
||||
}
|
||||
// Transparent command.
|
||||
if tt.starts_with('\'') {
|
||||
return self.start(tt);
|
||||
}
|
||||
// A LISP expression typed at a command's prompt: its value is the answer.
|
||||
// Not at a free-text prompt: there the text is the answer (CAL takes "(3+4)*2").
|
||||
if lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() {
|
||||
let src = tt.strip_prefix('!').unwrap_or(tt).to_string();
|
||||
let v = match self.lisp_eval(&src, false, true, Vec::new()) {
|
||||
Ok(Some(Ok(v))) => v,
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
self.echo(e.to_string());
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
return match v {
|
||||
lisp::value::Value::Nil => Ok(()),
|
||||
lisp::value::Value::Int(i) => self.typed(&i.to_string()),
|
||||
lisp::value::Value::Real(r) => self.typed(&format!("{r}")),
|
||||
lisp::value::Value::Str(s) => self.typed(&s),
|
||||
other => match other.as_point() {
|
||||
Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))),
|
||||
None => {
|
||||
self.echo(format!("Invalid input: {}", lisp::value::to_text(&other, true)));
|
||||
Ok(())
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
// At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command
|
||||
// keyword sharing the letter (HATCH's "picK internal point" against `P`).
|
||||
let selection_mode = prompt.accept.select
|
||||
&& matches!(tt.strip_prefix('_').unwrap_or(tt).to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
|
||||
// A leading underscore asks for a keyword by its English name, whatever the interface
|
||||
// language (`_M`, `_Yes`): scripts and LISP routines write them that way. (Snap
|
||||
// overrides such as `_cen` were taken above.)
|
||||
if !selection_mode
|
||||
&& let Some(k) = prompt.match_keyword(tt).or_else(|| tt.strip_prefix('_').and_then(|r| prompt.match_keyword(r)))
|
||||
&& !(prompt.accept.number && tt.parse::<f64>().is_ok())
|
||||
{
|
||||
return self.input(Input::Keyword(k));
|
||||
}
|
||||
if prompt.accept.point {
|
||||
if let Some(p) = prompt::parse_point_with(tt, self.last_point, &self.angle_settings()) {
|
||||
self.last_polar = prompt::parse_polar_text(tt, &self.angle_settings());
|
||||
return self.input(Input::Point(p));
|
||||
}
|
||||
// Direct distance entry along the tracking path the cursor is on, from the point it
|
||||
// comes from (an acquired or temporary tracking point needs no base point).
|
||||
if let Some(dist) = units::parse_distance(tt).filter(|_| !prompt.accept.number)
|
||||
&& let Some((from, dir)) = self.tracking.last.as_ref().filter(|t| t.point.near(self.cursor, 1e-9)).and_then(|t| t.along())
|
||||
{
|
||||
return self.input(Input::Point(from + dir * dist));
|
||||
}
|
||||
// Direct distance entry along the rubber band.
|
||||
if let (Some(base), Some(dist)) = (prompt.base, units::parse_distance(tt))
|
||||
&& !prompt.accept.number
|
||||
{
|
||||
let mut c = self.cursor;
|
||||
if self.settings.orthomode && !prompt.corner {
|
||||
c = snap::iso::constrain(&self.drafting_settings(), base, c);
|
||||
}
|
||||
let dir = (c - base).normalized();
|
||||
let dir = if dir == Vec2::ZERO { Vec2::X } else { dir };
|
||||
return self.input(Input::Point(base + dir * dist));
|
||||
}
|
||||
}
|
||||
self.input(Input::Text(tt.to_string()))
|
||||
}
|
||||
|
||||
/// Run a script, as AutoCAD reads one: a space or a line end ends an input, and an empty
|
||||
/// input (two spaces in a row, a space at the end of a line, an empty line) is Enter. At
|
||||
/// free-text prompts spaces are part of the text. Lines starting with `;` are comments.
|
||||
///
|
||||
/// A script cannot run a script: the SCRIPT command (or another `cmd {json}` call) inside a
|
||||
/// running one is refused, so two files that run each other cannot recurse until the stack dies.
|
||||
pub fn script(&mut self, text: &str) -> Result<()> {
|
||||
if self.in_script {
|
||||
return Err(EngineError::BadParams { cmd: "script".into(), msg: "a script is already running; nested scripts are not supported".into() });
|
||||
}
|
||||
self.in_script = true;
|
||||
let r = self.script_lines(text);
|
||||
self.in_script = false;
|
||||
r
|
||||
}
|
||||
|
||||
fn script_lines(&mut self, text: &str) -> Result<()> {
|
||||
for line in text.lines() {
|
||||
// Trailing spaces are inputs (Enter); other trailing whitespace (`\r`) is not.
|
||||
let line = line.trim_end_matches(|c: char| c.is_whitespace() && c != ' ');
|
||||
if line.trim_start().starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
self.input_line(line, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed text that is LISP: `(expression)` or `!variable`.
|
||||
fn lisp_expression(t: &str) -> bool {
|
||||
let t = t.trim();
|
||||
t.starts_with('(') || (t.starts_with('!') && t.len() > 1)
|
||||
}
|
||||
|
||||
/// The next command-line input in `rest` and what follows it: up to a space or tab, except that
|
||||
/// a LISP expression runs to its closing parenthesis (spaces and quoted strings inside it).
|
||||
fn next_token(rest: &str) -> (&str, Option<&str>) {
|
||||
let lead = rest.len() - rest.trim_start_matches('!').len();
|
||||
if rest.get(lead..).is_some_and(|r| r.starts_with('(')) {
|
||||
let (mut depth, mut in_str, mut escaped) = (0i32, false, false);
|
||||
for (i, c) in rest.char_indices().skip(lead) {
|
||||
if in_str {
|
||||
match (escaped, c) {
|
||||
(true, _) => escaped = false,
|
||||
(false, '\\') => escaped = true,
|
||||
(false, '"') => in_str = false,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' => in_str = true,
|
||||
'(' => depth += 1,
|
||||
')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
let end = i + 1;
|
||||
// As after any input, a space ends it and a trailing one is Enter.
|
||||
let more = rest.get(end..).filter(|m| !m.is_empty()).map(|m| m.strip_prefix([' ', '\t']).unwrap_or(m));
|
||||
return (&rest[..end], more);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Unbalanced: all of it (the reader reports the error).
|
||||
return (rest, None);
|
||||
}
|
||||
match rest.split_once([' ', '\t']) {
|
||||
Some((tok, more)) => (tok, Some(more)),
|
||||
None => (rest, None),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
// ---------------- programmatic execution ----------------
|
||||
|
||||
/// Run a command with JSON parameters (no dialogs). Records undo when the drawing changes.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
|
||||
if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) {
|
||||
return Err(EngineError::Disabled(spec.id.into(), why));
|
||||
}
|
||||
let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid));
|
||||
let editing = self.state().is_ok_and(|s| s.block_edit.is_some());
|
||||
let run = spec.run;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params)));
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg = p
|
||||
.downcast_ref::<&str>()
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| p.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "panic".into());
|
||||
// Restore the drawing as it was.
|
||||
if let Some((doc, sel, uid)) = &before
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == *uid)
|
||||
{
|
||||
st.doc = doc.clone();
|
||||
st.selection = sel.clone();
|
||||
}
|
||||
return Err(EngineError::Internal(id.into(), msg));
|
||||
}
|
||||
};
|
||||
let violation = before.as_ref().and_then(|b| cmd::blocks::refedit::after_command(self, &b.0, spec.id));
|
||||
let result = match (violation, result) {
|
||||
(Some(m), Ok(_)) => Err(EngineError::Other(m)),
|
||||
(_, r) => r,
|
||||
};
|
||||
// Opening or closing the Block Editor swaps the whole drawing: nothing in it changed.
|
||||
if self.state().is_ok_and(|s| s.block_edit.is_some()) == editing {
|
||||
self.after_command(before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2), spec.undoable && result.is_ok());
|
||||
}
|
||||
let result = match (result, &before) {
|
||||
(Ok(v), Some((doc, sel, uid))) if spec.undoable => self.refuse_non_finite(Some(*uid), doc, sel, spec.id).map(|()| v),
|
||||
(r, _) => r,
|
||||
};
|
||||
let grouped = self.lisp_groups_undo();
|
||||
if spec.undoable
|
||||
&& let Some((doc, sel, uid)) = before
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& !Arc::ptr_eq(&doc, &st.doc)
|
||||
{
|
||||
// Inside a LISP routine the routine's single undo step records it.
|
||||
if !grouped {
|
||||
st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel });
|
||||
}
|
||||
st.revision += 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Bump the revision without an undo step (view-only or settings changes that redraw).
|
||||
pub fn touch(&mut self) {
|
||||
if let Ok(st) = self.state_mut() {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- undo ----------------
|
||||
|
||||
pub fn undo(&mut self) -> Result<Option<String>> {
|
||||
let st = self.state_mut()?;
|
||||
let Some(snap) = st.undo.pop() else { return Ok(None) };
|
||||
let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };
|
||||
st.doc = snap.doc;
|
||||
st.follow_layout(&cur.doc);
|
||||
st.selection = Vec::new();
|
||||
st.redo.push(cur);
|
||||
st.revision += 1;
|
||||
Ok(Some(snap.label))
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> Result<Option<String>> {
|
||||
let st = self.state_mut()?;
|
||||
let Some(snap) = st.redo.pop() else { return Ok(None) };
|
||||
let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };
|
||||
st.doc = snap.doc;
|
||||
st.follow_layout(&cur.doc);
|
||||
st.selection = Vec::new();
|
||||
st.undo.push(cur);
|
||||
st.revision += 1;
|
||||
Ok(Some(snap.label))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
/// Append a new untitled drawing (`DrawingN`, metric or imperial default view), end a running command, make it active, bump the document epoch and return its index.
|
||||
pub fn new_drawing(&mut self, metric: bool) -> usize {
|
||||
self.untitled_counter += 1;
|
||||
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
|
||||
let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None);
|
||||
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() });
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
self.active = self.docs.len() - 1;
|
||||
self.bump_doc_epoch();
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Append a loaded drawing, fit the view to its model extents (aspect-corrected, minimum height 1e-6) when non-empty, end a running command, make it active, bump the epoch and return its index.
|
||||
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
|
||||
let mut st = DocState::new(d, title, path);
|
||||
let ext = st.doc.extents(&Space::Model);
|
||||
if !ext.is_empty() {
|
||||
st.set_view_quiet(View {
|
||||
center: ext.center(),
|
||||
height: (ext.height().max(ext.width() * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6),
|
||||
});
|
||||
}
|
||||
self.end_running();
|
||||
self.docs.push(st);
|
||||
self.active = self.docs.len() - 1;
|
||||
self.bump_doc_epoch();
|
||||
self.active
|
||||
}
|
||||
|
||||
/// Record that which drawing is open or active changed (screenshot binding).
|
||||
pub fn bump_doc_epoch(&mut self) {
|
||||
self.doc_epoch = self.doc_epoch.wrapping_add(1);
|
||||
}
|
||||
|
||||
/// A command still waiting for input belongs to the drawing it started in: end it there
|
||||
/// before another drawing becomes current, so its undo step lands in that drawing.
|
||||
fn end_running(&mut self) {
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a file read off-thread with [`cmd::file::read_file`] as the active drawing (OPEN's fast
|
||||
/// half). A panic while adding it is reported as an error, never propagated.
|
||||
pub fn finish_open(&mut self, f: cmd::file::ReadFile) -> Result<Value> {
|
||||
let n = self.docs.len();
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd::file::finish_open(self, f))) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
self.docs.truncate(n);
|
||||
self.active = self.active.min(self.docs.len().saturating_sub(1));
|
||||
self.bump_doc_epoch();
|
||||
Err(EngineError::Internal("open".into(), "panic".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
// ---------------- interactive commands ----------------
|
||||
|
||||
/// Start a command by name or alias as if typed at the command line.
|
||||
pub fn start(&mut self, name: &str) -> Result<()> {
|
||||
self.last_polar = None;
|
||||
let name = name.trim();
|
||||
let (transparent, name) = match name.strip_prefix('\'') {
|
||||
Some(n) => (true, n),
|
||||
None => (false, name),
|
||||
};
|
||||
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
|
||||
// A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes.
|
||||
let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase());
|
||||
let id = if find_command(&dashed).is_some() { dashed } else { cmd::resolve_alias(&lower) };
|
||||
let Some(spec) = find_command(&id) else {
|
||||
// A command defined in LISP: (defun c:NAME …).
|
||||
if !transparent && let Some(f) = self.lisp_command(&lower) {
|
||||
if self.running.is_some() {
|
||||
self.cancel();
|
||||
}
|
||||
self.last_command = Some(lower.clone());
|
||||
self.echo(format!("Command: {}", lower.to_ascii_uppercase()));
|
||||
return self.lisp_run_command(&lower, f);
|
||||
}
|
||||
// A bare system variable name (ORTHOMODE, GRIDMODE…) acts as SETVAR on that variable.
|
||||
let var = lower.to_ascii_uppercase();
|
||||
if !name.starts_with(['_', '.', '-']) && sysvars::get(self, &var).is_some() {
|
||||
self.start(if transparent { "'setvar" } else { "setvar" })?;
|
||||
return self.input(Input::Text(var));
|
||||
}
|
||||
return Err(EngineError::UnknownCommand(name.to_string()));
|
||||
};
|
||||
if transparent && spec.transparent && self.running.is_some() {
|
||||
// Transparent commands (zoom, pan…) run without cancelling the active one.
|
||||
if spec.interactive.is_none() {
|
||||
self.echo(format!(">>{}", spec.label));
|
||||
return self.execute(spec.id, &Value::Null).map(|_| ());
|
||||
}
|
||||
if self.suspended.is_some() {
|
||||
self.echo("A transparent command is already in progress.");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let nested = transparent && spec.transparent && self.running.is_some() && spec.interactive.is_some();
|
||||
// Ctrl+Z / U / UNDO while a command's prompt offers its own Undo keyword (LINE, PLINE…)
|
||||
// takes back the last segment and keeps the command running, as the keyword does.
|
||||
if !nested && matches!(spec.id, "u" | "undo") && self.current_prompt().is_some_and(|p| p.keywords.iter().any(|k| k == "Undo")) {
|
||||
self.echo(format!("Command: {}", spec.id.to_ascii_uppercase()));
|
||||
return self.input(Input::Keyword("Undo".into()));
|
||||
}
|
||||
if !nested && self.running.is_some() {
|
||||
self.cancel();
|
||||
}
|
||||
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
|
||||
if let Some(why) = cmd::blocks::bedit::refused_in_editor(self, spec.id) {
|
||||
return Err(EngineError::Disabled(spec.id.into(), why));
|
||||
}
|
||||
if nested {
|
||||
// The outer command stays the one Enter repeats.
|
||||
self.echo(format!(">>{}", spec.id.to_ascii_uppercase()));
|
||||
} else {
|
||||
self.last_command = Some(spec.id.to_string());
|
||||
self.echo(format!("Command: {}", spec.id.to_ascii_uppercase()));
|
||||
}
|
||||
match spec.interactive {
|
||||
Some(factory) => {
|
||||
let machine = guard::Guarded::create(spec.id, factory, self)?;
|
||||
let st = self.state()?;
|
||||
let before = st.doc.clone();
|
||||
let selection_before = st.selection.clone();
|
||||
if nested {
|
||||
self.suspended = self.running.take();
|
||||
}
|
||||
self.running = Some(Running { id: spec.id.to_string(), machine, before, selection_before });
|
||||
self.pending_window = None;
|
||||
// Some commands complete immediately (e.g. ERASE with a pickfirst selection).
|
||||
self.feed(None)?;
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
let r = self.execute(spec.id, &Value::Null)?;
|
||||
if let Some(s) = r.as_str() {
|
||||
self.echo(s.to_string());
|
||||
} else if !r.is_null()
|
||||
&& let Some(msg) = r.get("message").and_then(Value::as_str)
|
||||
{
|
||||
for l in msg.lines() {
|
||||
self.echo(l.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed an object-snap pick, retaining its viewport origin for measurements. Point modifiers
|
||||
/// that combine or constrain coordinates produce ordinary drawing-space points.
|
||||
pub fn input_snapped(&mut self, hit: snap::SnapHit) -> Result<()> {
|
||||
let source = hit.viewport.filter(|_| self.point_mods.iter().all(|f| matches!(f, pointmod::Frame::Snap { mode } if *mode != 0)));
|
||||
let previous = std::mem::replace(&mut self.picked_viewport, source);
|
||||
let result = self.input(hit.input());
|
||||
self.picked_viewport = previous;
|
||||
result
|
||||
}
|
||||
|
||||
/// Give an input to the running command. With no command running, `Enter` repeats the last
|
||||
/// command (as in AutoCAD) and points do nothing. A LISP routine waiting in a `get…`
|
||||
/// function takes the input instead; one paused in `command` carries on after it.
|
||||
pub fn input(&mut self, input: Input) -> Result<()> {
|
||||
if self.lisp_input(input.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
let r = self.input_to_command(input);
|
||||
self.lisp_after_command_input();
|
||||
r
|
||||
}
|
||||
|
||||
fn input_to_command(&mut self, input: Input) -> Result<()> {
|
||||
if self.running.is_none() {
|
||||
if input == Input::Enter
|
||||
&& let Some(last) = self.last_command.clone()
|
||||
{
|
||||
return self.start(&last);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if !self.point_mods.is_empty() {
|
||||
return self.modifier_input(input);
|
||||
}
|
||||
// A deferred snap is only meaningful where the prompt resolves it; elsewhere it is the
|
||||
// point it was picked at.
|
||||
let input = match input {
|
||||
Input::Deferred(d) if !self.current_prompt().is_some_and(|p| p.deferred) => Input::Point(d.at),
|
||||
other => other,
|
||||
};
|
||||
// Generic selection handling during "Select objects:" prompts.
|
||||
let input = self.preprocess_selection(input)?;
|
||||
match input {
|
||||
Some(i) => {
|
||||
// SIngle: the first selection that finds objects also ends the prompt.
|
||||
let single = self.select_single && matches!(&i, Input::Pick(hs) if !hs.is_empty());
|
||||
self.feed(Some(i))?;
|
||||
if single {
|
||||
self.select_single = false;
|
||||
if self.current_prompt().is_some_and(|p| p.accept.select) {
|
||||
self.feed(Some(Input::Enter))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn preprocess_selection(&mut self, input: Input) -> Result<Option<Input>> {
|
||||
// The command's own prompt: a typed selection mode only applies at its "Select objects".
|
||||
let Some(prompt) = self.running.as_ref().map(|r| r.machine.prompt(self)) else { return Ok(Some(input)) };
|
||||
if !prompt.accept.select {
|
||||
return Ok(Some(input));
|
||||
}
|
||||
if self.select_mode.is_some() {
|
||||
return self.select_mode_input(input);
|
||||
}
|
||||
// `_L`, `_ALL`, `_W`: the English name of a selection keyword, as scripts write it.
|
||||
let input = match input {
|
||||
Input::Text(t) => Input::Text(t.trim().strip_prefix('_').map(str::to_string).unwrap_or(t)),
|
||||
other => other,
|
||||
};
|
||||
if let Input::Text(t) | Input::Keyword(t) = &input
|
||||
&& let Some(m) = select::SelectMode::from_keyword(t)
|
||||
{
|
||||
self.pending_window = None;
|
||||
self.select_mode = Some(m);
|
||||
return Ok(None);
|
||||
}
|
||||
if let Input::Text(t) | Input::Keyword(t) = &input
|
||||
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(t.trim()))
|
||||
{
|
||||
match t.trim().to_ascii_lowercase().as_str() {
|
||||
"si" | "single" => {
|
||||
self.select_single = true;
|
||||
return Ok(None);
|
||||
}
|
||||
// AUto (a pick, or a window where nothing is picked) is how points always select;
|
||||
// Multiple picks the same way until Enter.
|
||||
"au" | "auto" | "m" | "multiple" => return Ok(None),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let space = self.space();
|
||||
match input {
|
||||
Input::Point(p) => {
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = p.x < pw.corner.x;
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
|
||||
}
|
||||
match select::pick(self.doc()?, &space, p, ap) {
|
||||
Some(h) => Ok(Some(Input::Pick(cmd::groups::expand(self, vec![h])))),
|
||||
None => {
|
||||
self.pending_window = Some(PendingWindow { corner: p, during_command: true });
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
Input::Text(ref t) | Input::Keyword(ref t) => {
|
||||
let tl = t.trim().to_ascii_lowercase();
|
||||
let d = self.doc()?;
|
||||
let store = d.space(&space);
|
||||
let picked = match tl.as_str() {
|
||||
"all" => store.map(|s| s.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()),
|
||||
"l" | "last" => store.and_then(|s| s.last()).map(|e| vec![e.handle]),
|
||||
"p" | "previous" => Some(self.state()?.previous_selection.clone()),
|
||||
_ => None,
|
||||
};
|
||||
match picked {
|
||||
Some(hs) => Ok(Some(Input::Pick(cmd::groups::expand(self, hs)))),
|
||||
None => Ok(Some(input)),
|
||||
}
|
||||
}
|
||||
Input::Cancel => {
|
||||
self.pending_window = None;
|
||||
Ok(Some(Input::Cancel))
|
||||
}
|
||||
other => Ok(Some(other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Input while a typed selection mode (W, C, F, WP, CP) collects its points: the selected
|
||||
/// objects go to the command as a pick once the mode is complete.
|
||||
fn select_mode_input(&mut self, input: Input) -> Result<Option<Input>> {
|
||||
let Some(mut mode) = self.select_mode.take() else { return Ok(Some(input)) };
|
||||
if mode.kind == select::SelectKind::Group {
|
||||
return match input {
|
||||
Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) {
|
||||
Some(hs) => Ok(Some(Input::Pick(hs))),
|
||||
None => {
|
||||
self.echo(format!("No group named \"{}\".", name.trim()));
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
// Enter or Esc leaves the group name prompt.
|
||||
Input::Cancel => Ok(Some(input)),
|
||||
Input::Enter => Ok(None),
|
||||
_ => {
|
||||
self.echo("Invalid group name.");
|
||||
self.select_mode = Some(mode);
|
||||
Ok(None)
|
||||
}
|
||||
};
|
||||
}
|
||||
match input {
|
||||
Input::Point(p) => {
|
||||
if mode.push(p) {
|
||||
return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick));
|
||||
}
|
||||
}
|
||||
Input::Keyword(k) if k == "Undo" => {
|
||||
mode.pts.pop();
|
||||
}
|
||||
// Enter ends a fence or polygon; with too few points the mode is dropped.
|
||||
Input::Enter => return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)),
|
||||
Input::Cancel | Input::Pick(_) => return Ok(Some(input)),
|
||||
_ => self.echo("Invalid point."),
|
||||
}
|
||||
self.select_mode = Some(mode);
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) fn feed(&mut self, input: Option<Input>) -> Result<()> {
|
||||
let base = if matches!(input, Some(Input::Point(_))) { self.current_prompt().and_then(|p| p.base) } else { None };
|
||||
let Some(mut run) = self.running.take() else { return Ok(()) };
|
||||
let step = match input {
|
||||
Some(Input::Cancel) => Ok(Step::Cancel),
|
||||
Some(i) => {
|
||||
if let Input::Point(p) = &i {
|
||||
self.last_point = *p;
|
||||
if let Some(b) = base.filter(|b| b.is_finite() && p.is_finite() && b.dist(*p) > 1e-12) {
|
||||
self.last_segment = Some((b, *p));
|
||||
}
|
||||
// A temporary tracking point (TT) serves one point.
|
||||
self.tracking.release_temporary();
|
||||
}
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run.machine.input(self, i)));
|
||||
match r {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(EngineError::Internal(run.id.clone(), "panic".into())),
|
||||
}
|
||||
}
|
||||
// A command starts with no modifiers pending.
|
||||
None => {
|
||||
self.point_mods.clear();
|
||||
run.machine.begin(self)
|
||||
}
|
||||
};
|
||||
// Redraw what the command has added so far (LINE adds a segment per point), not only when it ends.
|
||||
if let Ok(st) = self.state_mut()
|
||||
&& !Arc::ptr_eq(&run.before, &st.doc)
|
||||
{
|
||||
st.revision += 1;
|
||||
}
|
||||
match step {
|
||||
Ok(Step::Continue) => {
|
||||
self.running = Some(run);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Step::Done) => {
|
||||
self.finish(run, false);
|
||||
self.resume_suspended();
|
||||
Ok(())
|
||||
}
|
||||
Ok(Step::Cancel) => {
|
||||
self.echo("*Cancel*");
|
||||
self.finish(run, true);
|
||||
self.resume_suspended();
|
||||
Ok(())
|
||||
}
|
||||
Err(EngineError::Internal(id, m)) => {
|
||||
// Restore and end the command.
|
||||
if let Ok(st) = self.state_mut() {
|
||||
st.doc = run.before.clone();
|
||||
}
|
||||
self.echo(format!("Internal error in {id}: {m}"));
|
||||
self.resume_suspended();
|
||||
Err(EngineError::Internal(id, m))
|
||||
}
|
||||
Err(e) => {
|
||||
// Invalid input: report and keep prompting (AutoCAD re-prompts).
|
||||
self.echo(e.to_string());
|
||||
self.running = Some(run);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, run: Running, cancelled: bool) {
|
||||
// A typed selection mode or SIngle never outlives its command.
|
||||
self.select_mode = None;
|
||||
self.select_single = false;
|
||||
if find_command(&run.id).is_some_and(|c| c.undoable)
|
||||
&& let Err(e) = self.refuse_non_finite(None, &run.before, &run.selection_before, &run.id)
|
||||
{
|
||||
self.echo(e.to_string());
|
||||
}
|
||||
// A command outside undo that changed the drawing (UNDO, which swapped it for an earlier
|
||||
// one) records no undo step, as in `execute`.
|
||||
if find_command(&run.id).is_some_and(|c| !c.undoable) && self.state().is_ok_and(|st| !Arc::ptr_eq(&run.before, &st.doc)) {
|
||||
self.pending_window = None;
|
||||
return;
|
||||
}
|
||||
self.pending_window = None;
|
||||
self.select_mode = None;
|
||||
let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command");
|
||||
let _ = cancelled;
|
||||
if let Some(m) = cmd::blocks::refedit::after_command(self, &run.before, &run.id) {
|
||||
self.echo(m);
|
||||
}
|
||||
self.after_command(Some(&run.before), None, true);
|
||||
// A transparent command's changes belong to the command it ran inside, and a command a
|
||||
// LISP routine ran belongs to the routine's single undo step.
|
||||
if self.suspended.is_some() || self.lisp_groups_undo() {
|
||||
if let Ok(st) = self.state_mut()
|
||||
&& !Arc::ptr_eq(&run.before, &st.doc)
|
||||
{
|
||||
st.revision += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Ok(st) = self.state_mut()
|
||||
&& !Arc::ptr_eq(&run.before, &st.doc)
|
||||
{
|
||||
st.push_undo(Snapshot { label: label.to_string(), doc: run.before, selection: run.selection_before });
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The post-command passes, in order, run after every command — by [`Session::execute`] and
|
||||
/// when an interactive command finishes — so their edits land in the command's own undo step:
|
||||
/// group membership is pruned, constraints are inferred, purged and re-solved, and
|
||||
/// associative dimensions and hatches follow their sources. A new pass is added here, once.
|
||||
///
|
||||
/// `before` is the drawing the command started from; `uid` (when known) is the document it
|
||||
/// started in, so associativity is not applied across a document switch; `ok` is false for a
|
||||
/// failed or non-undoable programmatic command, which skips the constraint pass.
|
||||
pub(super) fn after_command(&mut self, before: Option<&Arc<Drawing>>, uid: Option<u64>, ok: bool) {
|
||||
cmd::groups::after_command(self, before);
|
||||
cmd::constraints::after_command(self, before, ok);
|
||||
assoc::after_command(self, before, uid);
|
||||
}
|
||||
|
||||
fn resume_suspended(&mut self) {
|
||||
if let Some(outer) = self.suspended.take() {
|
||||
self.echo(format!("Resuming {} command.", outer.id.to_ascii_uppercase()));
|
||||
self.running = Some(outer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the running command (Esc). With no command, clears the selection. Inside a
|
||||
/// transparent command, cancels it and the command it interrupted.
|
||||
pub fn cancel(&mut self) {
|
||||
self.point_mods.clear();
|
||||
// Esc stops a LISP routine waiting for input, and the command it paused in.
|
||||
if self.lisp_waiting() {
|
||||
if self.running.is_some() {
|
||||
let _ = self.feed(Some(Input::Cancel));
|
||||
}
|
||||
self.echo("*Cancel*");
|
||||
self.lisp_abort("Function cancelled");
|
||||
return;
|
||||
}
|
||||
if self.running.is_some() {
|
||||
if self.suspended.is_some() {
|
||||
if let Some(nested) = self.running.take() {
|
||||
self.finish(nested, true);
|
||||
}
|
||||
self.running = self.suspended.take();
|
||||
}
|
||||
let _ = self.feed(Some(Input::Cancel));
|
||||
} else {
|
||||
self.pending_window = None;
|
||||
self.set_selection(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// The multiline text the running command is collecting, for the in-place editor
|
||||
/// ([`Interactive::editing_mtext`]).
|
||||
pub fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
|
||||
self.running.as_ref().and_then(|r| r.machine.editing_mtext())
|
||||
}
|
||||
|
||||
pub fn current_prompt(&self) -> Option<Prompt> {
|
||||
if let Some(p) = self.lisp_prompt() {
|
||||
return Some(p);
|
||||
}
|
||||
self.running.as_ref().map(|r| {
|
||||
let p = r.machine.prompt(self);
|
||||
match &self.select_mode {
|
||||
Some(m) if p.accept.select => m.prompt(),
|
||||
_ => p,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the cursor picks objects right now, so the UI shows the pick box (PICKBOX) at the
|
||||
/// crosshair: at prompts that select objects, and with no command running when PICKFIRST is on
|
||||
/// (noun-verb selection).
|
||||
pub fn picking_objects(&self) -> bool {
|
||||
match self.current_prompt() {
|
||||
Some(p) => p.picks_objects(),
|
||||
None => self.settings.pickfirst,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the object under the cursor is highlighted (SELECTIONPREVIEW): the cursor picks
|
||||
/// objects ([`Self::picking_objects`]) and the bit for a command asking for them (2) or for
|
||||
/// no command (1) is on.
|
||||
pub fn previews_selection(&self) -> bool {
|
||||
let bit = if self.current_prompt().is_some() { 2 } else { 1 };
|
||||
self.picking_objects() && self.settings.selectionpreview & bit != 0
|
||||
}
|
||||
|
||||
/// Whether selected objects show grips while `selected` objects are selected (GRIPS,
|
||||
/// GRIPOBJLIMIT).
|
||||
pub fn shows_grips(&self, selected: usize) -> bool {
|
||||
let limit = self.settings.gripobjlimit;
|
||||
self.settings.grips > 0 && (limit == 0 || selected <= limit as usize)
|
||||
}
|
||||
|
||||
/// The text shown on the command line: the active prompt, or "Command:".
|
||||
pub fn prompt_text(&self) -> String {
|
||||
if let Some(p) = self.lisp_prompt() {
|
||||
return p.display();
|
||||
}
|
||||
match &self.running {
|
||||
Some(r) if self.suspended.is_some() => format!(">>{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()),
|
||||
Some(r) => format!("{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()),
|
||||
None => "Command:".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Preview entities for the cursor: objects drawn with their own properties
|
||||
/// ([`Interactive::preview_objects`]), then the rubber band with the current properties, in
|
||||
/// the grip blue over such objects (so it shows over their fills, whatever their colour).
|
||||
pub fn preview(&self, cursor: Vec2) -> Vec<Entity> {
|
||||
let mut common = self.current_common();
|
||||
let (kinds, mut out) = match (self.lisp_preview(cursor), &self.running) {
|
||||
(Some(k), _) => (k, Vec::new()),
|
||||
(None, Some(r)) => (r.machine.preview(self, cursor), r.machine.preview_objects(self, cursor)),
|
||||
(None, None) => (Vec::new(), Vec::new()),
|
||||
};
|
||||
if !out.is_empty() {
|
||||
common.color = cadcraft_doc::color::Color::True(cadcraft_doc::color::Rgb(0x2f, 0x6b, 0xff));
|
||||
}
|
||||
out.extend(kinds.into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }));
|
||||
out
|
||||
}
|
||||
|
||||
/// The active command's preview as a transform of existing objects (see
|
||||
/// [`Interactive::drag`]), with its extra rubber-band geometry as entities.
|
||||
pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec<Entity>)> {
|
||||
let r = self.running.as_ref()?;
|
||||
let d = r.machine.drag(self, cursor)?;
|
||||
let common = self.current_common();
|
||||
let extra = d.extra.iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k.clone() }).collect();
|
||||
Some((d, extra))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
// ---------------- picking without a command ----------------
|
||||
|
||||
/// Open a selection window with one corner at `corner`, for press-and-drag selection
|
||||
/// (AutoCAD's PICKDRAG = 2: a drag opens the window wherever it starts, even over an object;
|
||||
/// the next point, where the drag ends, closes it). Only while objects are being selected: no
|
||||
/// command running, or a command asking for objects. Returns whether a window was opened.
|
||||
pub fn begin_window(&mut self, corner: Vec2) -> bool {
|
||||
let during_command = self.running.is_some();
|
||||
if !self.settings.pickdrag || !corner.is_finite() || (during_command && !self.current_prompt().is_some_and(|p| p.accept.select)) {
|
||||
return false;
|
||||
}
|
||||
self.pending_window = Some(PendingWindow { corner, during_command });
|
||||
true
|
||||
}
|
||||
|
||||
/// A click with no command running: pick/toggle objects or start a selection window.
|
||||
///
|
||||
/// PICKADD on (the default): picks add to the selection and Shift removes. PICKADD off: each pick
|
||||
/// replaces the selection and Shift adds (Shift+pick on a selected object removes it).
|
||||
pub fn idle_click(&mut self, p: Vec2, shift: bool) -> Result<()> {
|
||||
let space = self.space();
|
||||
let pickadd = self.settings.pickadd;
|
||||
if let Some(pw) = self.pending_window.take() {
|
||||
let crossing = p.x < pw.corner.x;
|
||||
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
|
||||
let hs = cmd::groups::expand(self, hs);
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
if shift && pickadd {
|
||||
sel.retain(|h| !hs.contains(h));
|
||||
} else {
|
||||
sel.extend(hs);
|
||||
}
|
||||
self.set_selection(sel);
|
||||
return Ok(());
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
match select::pick(self.doc()?, &space, p, ap) {
|
||||
Some(h) => self.idle_pick(h, shift),
|
||||
None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An idle click that picked `h`, with [`Self::idle_click`]'s PICKADD and Shift rules.
|
||||
fn idle_pick(&mut self, h: Handle, shift: bool) {
|
||||
let pickadd = self.settings.pickadd;
|
||||
// With group selection the object's selectable groups come (or go) with it.
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
|
||||
let had = sel.contains(&h);
|
||||
if shift && (pickadd || had) {
|
||||
sel.retain(|x| !hs.contains(x));
|
||||
} else if !had {
|
||||
sel.extend(hs);
|
||||
}
|
||||
self.set_selection(sel);
|
||||
}
|
||||
|
||||
/// Whether a click at `p` selects objects by picking: no command running, or a command at
|
||||
/// "Select objects" with no selection window or typed mode under way.
|
||||
pub fn click_picks(&self) -> bool {
|
||||
self.pending_window.is_none()
|
||||
&& match self.running.as_ref() {
|
||||
None => self.lisp_prompt().is_none(),
|
||||
Some(r) => self.select_mode.is_none() && r.machine.prompt(self).accept.select,
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects under the pick box at `p` when selection cycling is on and more than one is
|
||||
/// there: what a click at `p` would choose between.
|
||||
fn cycling_hits(&self, p: Vec2) -> Vec<Handle> {
|
||||
if !self.settings.selection_cycling {
|
||||
return Vec::new();
|
||||
}
|
||||
let hs = self.pick_candidates(p);
|
||||
if hs.len() > 1 { hs } else { Vec::new() }
|
||||
}
|
||||
|
||||
/// Every object under the pick box at `p`, nearest first, when a click there picks objects
|
||||
/// (empty otherwise): what Shift+Space steps the highlight through.
|
||||
pub fn pick_candidates(&self, p: Vec2) -> Vec<Handle> {
|
||||
if !self.click_picks() {
|
||||
return Vec::new();
|
||||
}
|
||||
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
|
||||
self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping
|
||||
/// objects a click there would choose between.
|
||||
pub fn cycling_badge(&self, p: Vec2) -> bool {
|
||||
self.settings.selection_cycling_display >= 1 && !self.cycling_hits(p).is_empty()
|
||||
}
|
||||
|
||||
/// The objects a click at `p` lists to choose from (SELECTIONCYCLING 2), nearest first; empty
|
||||
/// when the click should pick as usual.
|
||||
pub fn cycling_candidates(&self, p: Vec2) -> Vec<Handle> {
|
||||
if self.settings.selection_cycling_display < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
self.cycling_hits(p)
|
||||
}
|
||||
|
||||
/// Pick `h` from the selection cycling list, as a click on it alone would have: into the
|
||||
/// command at "Select objects", else into the selection (Shift as for [`Self::idle_click`]).
|
||||
pub fn pick_cycled(&mut self, h: Handle, shift: bool) -> Result<()> {
|
||||
// The list may be older than an undo or an erase.
|
||||
if self.doc()?.entity(h).is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if self.running.is_some() {
|
||||
let hs = cmd::groups::expand(self, vec![h]);
|
||||
return self.input(Input::Pick(hs));
|
||||
}
|
||||
self.idle_pick(h, shift);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remember the selection used by a command as "Previous".
|
||||
pub fn remember_selection(&mut self, sel: &[Handle]) {
|
||||
if let Ok(st) = self.state_mut() {
|
||||
st.previous_selection = sel.to_vec();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
// ---------------- views ----------------
|
||||
|
||||
/// The limits of the space being edited: the sheet in paper space, else LIMMIN/LIMMAX.
|
||||
pub fn zoom_limits(&self) -> Result<Bounds2> {
|
||||
let d = self.doc()?;
|
||||
if let Space::Paper(name) = self.space()
|
||||
&& let Some(sheet) = cadcraft_render::sheet(d, &name)
|
||||
{
|
||||
return Ok(sheet.bounds());
|
||||
}
|
||||
let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO);
|
||||
let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));
|
||||
Ok(Bounds2::new(lo, hi))
|
||||
}
|
||||
|
||||
pub fn zoom_extents(&mut self) -> Result<()> {
|
||||
let space = self.space();
|
||||
let ext = self.doc()?.extents(&space);
|
||||
let (w, h) = self.viewport_px;
|
||||
let ext = if ext.is_empty() { self.zoom_limits()? } else { ext };
|
||||
let st = self.state_mut()?;
|
||||
let aspect = st.zoom_aspect(w / h.max(1.0));
|
||||
let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05;
|
||||
st.set_zoom_frame(View { center: ext.center(), height });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Zoom by `factor` (> 1 zooms in) keeping `about` fixed on screen.
|
||||
pub fn zoom_about(&mut self, factor: f64, about: Vec2) -> Result<()> {
|
||||
if !(factor.is_finite() && factor > 0.0) {
|
||||
return Ok(());
|
||||
}
|
||||
let st = self.state_mut()?;
|
||||
let v = st.view();
|
||||
let h = (v.height / factor).clamp(1e-9, 1e12);
|
||||
let k = h / v.height;
|
||||
let c = about + (v.center - about) * k;
|
||||
st.set_view_quiet(View { center: c, height: h });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use super::*;
|
||||
|
||||
/// Session-level drafting settings. [`Session::drafting_settings`] supplies the active drawing's
|
||||
/// snap/grid spacing in the corresponding fields for UI and JSON consumers.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Settings {
|
||||
pub osmode: u32,
|
||||
pub orthomode: bool,
|
||||
pub polarmode: bool,
|
||||
/// POLARANG (radians).
|
||||
pub polarang: f64,
|
||||
pub gridmode: bool,
|
||||
pub snapmode: bool,
|
||||
/// SNAPUNIT in a drafting settings snapshot; stored in the drawing header.
|
||||
pub snapunit: Vec2,
|
||||
/// OSNAPHATCH: object snaps find hatch objects (off by default, as in AutoCAD).
|
||||
pub osnaphatch: bool,
|
||||
/// GRIDUNIT in a drafting settings snapshot; zero components follow SNAPUNIT.
|
||||
pub gridunit: Vec2,
|
||||
/// Major grid line every N minor lines (GRIDMAJOR).
|
||||
pub gridmajor: u32,
|
||||
/// ZOOMFACTOR: how far one mouse wheel step zooms (3..=100).
|
||||
pub zoomfactor: u32,
|
||||
/// MEASUREINIT: the units of a new drawing made without a template (NEW, QNEW and the first
|
||||
/// drawing at startup): `false` (0, the default) imperial, `true` (1) metric.
|
||||
pub measureinit: bool,
|
||||
/// -VIEW sEttings > Layer snapshot: named views save the layer settings with them.
|
||||
pub view_layer_snapshot: bool,
|
||||
pub dynmode: bool,
|
||||
/// DYNDIGRIP: which dimensions grip dimensional input shows while a grip is stretched (bits:
|
||||
/// 1 resulting length, 2 length change, 4 absolute angle, 8 angle change, 16 radius); read
|
||||
/// when DYNDIVIS = 2.
|
||||
pub dyndigrip: u32,
|
||||
/// DYNDIVIS: how many of them: 0 the first, 1 the first two (default), 2 all DYNDIGRIP asks for.
|
||||
pub dyndivis: u8,
|
||||
/// DYNPIFORMAT = 1: Dynamic Input shows second and next points as Cartesian `x,y` instead of
|
||||
/// polar `distance<angle` (the default).
|
||||
pub dynpi_cartesian: bool,
|
||||
/// DYNPICOORDS = 1: second and next points typed into Dynamic Input are absolute instead of
|
||||
/// relative to the last point (the default).
|
||||
pub dynpi_absolute: bool,
|
||||
/// DYNPROMPT: Dynamic Input shows the prompt beside the cursor, and Down Arrow opens the
|
||||
/// prompt's options there (on by default).
|
||||
pub dynprompt: bool,
|
||||
pub lwdisplay: bool,
|
||||
/// TRANSPARENCYDISPLAY: draw objects with their transparency on screen (on by default).
|
||||
pub transparency_display: bool,
|
||||
/// PLOTTRANSPARENCYOVERRIDE: 0 never plots transparency, 1 (default) uses the page setup's or
|
||||
/// the Plot dialog's "Plot transparency", 2 always plots it.
|
||||
pub plot_transparency_override: u8,
|
||||
/// Selection cycling on (the status-bar toggle, the sign of SELECTIONCYCLING).
|
||||
pub selection_cycling: bool,
|
||||
/// What selection cycling shows over overlapping objects (SELECTIONCYCLING's magnitude): 0
|
||||
/// nothing, 1 a badge, 2 (default) the badge and a list to pick from on a click.
|
||||
pub selection_cycling_display: u8,
|
||||
/// SELECTSIMILARMODE: what SELECTSIMILAR matches besides the object type (bits: 1 colour,
|
||||
/// 2 layer, 4 linetype, 8 linetype scale, 16 lineweight, 32 plot style, 64 object style,
|
||||
/// 128 name).
|
||||
pub selectsimilarmode: u32,
|
||||
pub otrack: bool,
|
||||
/// PICKBOX in pixels.
|
||||
pub pickbox: f64,
|
||||
/// APERTURE in pixels.
|
||||
pub aperture: f64,
|
||||
pub pickfirst: bool,
|
||||
/// ACADLSPASDOC: acad.lsp loads into every drawing, not once per session.
|
||||
pub acadlspasdoc: bool,
|
||||
/// CMDECHO: kept for routines that switch command echo around their commands; no command reads it yet.
|
||||
pub cmdecho: bool,
|
||||
/// FILEDIA: kept for routines that switch file dialogs off around a script; no command reads it yet.
|
||||
pub filedia: bool,
|
||||
pub pickadd: bool,
|
||||
/// PICKDRAG: false = two-click windows (0), true = clicks or dragging (2).
|
||||
/// Drag-only mode (1) is not supported yet.
|
||||
pub pickdrag: bool,
|
||||
/// PICKSTYLE: bit 1 picks whole selectable groups ([`cmd::groups`]), bit 2 associative hatches
|
||||
/// with their boundaries (stored, not used yet).
|
||||
pub pickstyle: u8,
|
||||
pub gripsize: f64,
|
||||
pub cursorsize: f64,
|
||||
/// MAXARRAY: the most objects one array command creates (items × selected objects).
|
||||
pub maxarray: u64,
|
||||
/// SNAPSTYL 1: isometric snap and grid, ortho and crosshair along the isometric plane's axes.
|
||||
pub isodraft: bool,
|
||||
/// SNAPISOPAIR: the current isometric plane (ISOPLANE, F5).
|
||||
pub isoplane: snap::iso::IsoPlane,
|
||||
pub annoallvisible: bool,
|
||||
pub annoautoscale: bool,
|
||||
/// QPMODE: show the Quick Properties palette when objects are selected.
|
||||
pub qpmode: bool,
|
||||
/// HIGHLIGHT: selected objects are highlighted (their grips show either way). Not saved:
|
||||
/// every session starts with it on, as in AutoCAD, so it can't stay off unnoticed.
|
||||
pub highlight: bool,
|
||||
/// SELECTIONEFFECT: how selected objects are highlighted: 0 dashed, 1 redrawn in the
|
||||
/// selection colour.
|
||||
pub selectioneffect: u8,
|
||||
/// SELECTIONPREVIEW bits: the object under the cursor is highlighted (1) when no command is
|
||||
/// active, (2) when a command asks for objects.
|
||||
pub selectionpreview: u8,
|
||||
/// PREVIEWEFFECT: how that object is highlighted: 0 dashed, 1 thicker, 2 dashed and thicker.
|
||||
pub previeweffect: u8,
|
||||
/// PREVIEWFILTER bits: objects the cursor never highlights: 1 on locked layers, 2 xrefs,
|
||||
/// 4 tables, 8 multiline text, 16 hatches, 32 in groups.
|
||||
pub previewfilter: u8,
|
||||
/// GRIPS: selected objects show their grips (0 off; AutoCAD's 2, more grips on polylines,
|
||||
/// shows them as 1 does).
|
||||
pub grips: u8,
|
||||
/// GRIPOBJLIMIT: no grips while more objects than this are selected (0: no limit).
|
||||
pub gripobjlimit: u32,
|
||||
/// SELECTIONAREA: the selection window and crossing areas are filled.
|
||||
pub selectionarea: bool,
|
||||
/// WINDOWAREACOLOR, CROSSINGAREACOLOR: their colours (ACI 1-255).
|
||||
pub windowareacolor: u8,
|
||||
pub crossingareacolor: u8,
|
||||
/// SELECTIONAREAOPACITY: their opacity, 0-100.
|
||||
pub selectionareaopacity: u8,
|
||||
/// POLARMODE bits ([`snap::tracking::polarmode`]); polar tracking itself is on while
|
||||
/// `polarmode` is (AUTOSNAP bit 8, F10).
|
||||
pub polar_flags: u32,
|
||||
/// POLARADDANG: additional polar tracking angles (radians), used with POLARMODE bit 4.
|
||||
pub polaraddang: Vec<f64>,
|
||||
/// SNAPTYPE 1: snap mode (F9) snaps along tracking paths at POLARDIST steps (PolarSnap)
|
||||
/// instead of to the grid.
|
||||
pub polarsnap: bool,
|
||||
/// POLARDIST: the PolarSnap step; 0 uses the snap X spacing.
|
||||
pub polardist: f64,
|
||||
/// AUDITCTL: 1 creates an audit report (.adt) file when AUDIT is run (default: 0).
|
||||
pub auditctl: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
osmode: 4133,
|
||||
orthomode: false,
|
||||
polarmode: true,
|
||||
polarang: std::f64::consts::FRAC_PI_2,
|
||||
gridmode: true,
|
||||
snapmode: false,
|
||||
snapunit: Vec2::new(0.5, 0.5),
|
||||
osnaphatch: false,
|
||||
gridunit: Vec2::new(0.5, 0.5),
|
||||
gridmajor: 5,
|
||||
zoomfactor: 60,
|
||||
measureinit: false,
|
||||
view_layer_snapshot: true,
|
||||
dynmode: true,
|
||||
dyndigrip: 31,
|
||||
dyndivis: 1,
|
||||
dynpi_cartesian: false,
|
||||
dynpi_absolute: false,
|
||||
dynprompt: true,
|
||||
lwdisplay: false,
|
||||
transparency_display: true,
|
||||
plot_transparency_override: 1,
|
||||
selection_cycling: false,
|
||||
selection_cycling_display: 2,
|
||||
selectsimilarmode: 130,
|
||||
otrack: true,
|
||||
pickbox: 3.0,
|
||||
aperture: 10.0,
|
||||
pickfirst: true,
|
||||
acadlspasdoc: false,
|
||||
cmdecho: true,
|
||||
filedia: true,
|
||||
pickadd: true,
|
||||
pickdrag: true,
|
||||
pickstyle: 1,
|
||||
gripsize: 5.0,
|
||||
cursorsize: 5.0,
|
||||
maxarray: 100_000,
|
||||
isodraft: false,
|
||||
isoplane: snap::iso::IsoPlane::Left,
|
||||
annoallvisible: true,
|
||||
annoautoscale: false,
|
||||
qpmode: false,
|
||||
highlight: true,
|
||||
selectioneffect: 1,
|
||||
selectionpreview: 3,
|
||||
previeweffect: 2,
|
||||
previewfilter: 3,
|
||||
grips: 1,
|
||||
gripobjlimit: 100,
|
||||
selectionarea: true,
|
||||
windowareacolor: 150,
|
||||
crossingareacolor: 100,
|
||||
selectionareaopacity: 25,
|
||||
polar_flags: 0,
|
||||
polaraddang: Vec::new(),
|
||||
polarsnap: false,
|
||||
polardist: 0.0,
|
||||
auditctl: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use super::*;
|
||||
|
||||
/// A 2D view of a space: world centre and visible height in drawing units.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct View {
|
||||
pub center: Vec2,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
View { center: Vec2::new(6.0, 4.5), height: 10.0 }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn usable_view(v: &View) -> bool {
|
||||
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12
|
||||
}
|
||||
|
||||
/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ModelTiles {
|
||||
/// The tile corners these views belong to (when the drawing's tiles change, by VPORTS or
|
||||
/// undo, its views take over).
|
||||
pub rects: Vec<(Vec2, Vec2)>,
|
||||
/// Each tile's view; the current tile shows the model space view itself.
|
||||
pub views: Vec<View>,
|
||||
/// The current tile.
|
||||
pub active: usize,
|
||||
}
|
||||
|
||||
/// One tile of the model space drawing area, for the UI ([`Session::model_tiles`]): its corners
|
||||
/// as fractions of the drawing area (0,0 lower left) and its view.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ModelTile {
|
||||
pub min: Vec2,
|
||||
pub max: Vec2,
|
||||
pub view: View,
|
||||
pub active: bool,
|
||||
}
|
||||
+1
-314
@@ -318,317 +318,4 @@ pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `basis` before it evaluated on the stack, kept as the reference.
|
||||
fn basis_ref(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec<f64> {
|
||||
let mut n = vec![0.0; p + 1];
|
||||
let mut left = vec![0.0; p + 1];
|
||||
let mut right = vec![0.0; p + 1];
|
||||
n[0] = 1.0;
|
||||
for j in 1..=p {
|
||||
let k = |i: isize| knots.get(i.max(0) as usize).copied().unwrap_or(0.0);
|
||||
left[j] = u - k(span as isize + 1 - j as isize);
|
||||
right[j] = k(span as isize + j as isize) - u;
|
||||
let mut saved = 0.0;
|
||||
for r in 0..j {
|
||||
let den = right[r + 1] + left[j - r];
|
||||
let tmp = if den.abs() > 1e-300 { n[r] / den } else { 0.0 };
|
||||
n[r] = saved + right[r + 1] * tmp;
|
||||
saved = left[j - r] * tmp;
|
||||
}
|
||||
n[j] = saved;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `eval` before it evaluated on the stack, kept as the reference.
|
||||
fn eval_ref(s: &Spline, t: f64) -> Vec2 {
|
||||
let n = s.control.len() - 1;
|
||||
let (lo, hi) = s.domain();
|
||||
let t = t.clamp(lo, hi);
|
||||
let span = find_span(n, s.degree, t, &s.knots);
|
||||
let b = basis_ref(span, t, s.degree, &s.knots);
|
||||
let mut p = Vec2::ZERO;
|
||||
let mut wsum = 0.0;
|
||||
for (k, nb) in b.iter().enumerate() {
|
||||
let idx = span + k - s.degree;
|
||||
let w = if s.weights.is_empty() { 1.0 } else { s.weights[idx] };
|
||||
p += s.control[idx] * (nb * w);
|
||||
wsum += nb * w;
|
||||
}
|
||||
if wsum.abs() > 1e-300 { p / wsum } else { p }
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random numbers in `0..1`.
|
||||
fn noise(seed: &mut u64) -> f64 {
|
||||
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(*seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Splines of degree 1..=11 over 14 control points: clamped and unclamped (uniform) knots,
|
||||
/// unit and random weights.
|
||||
fn samples() -> Vec<Spline> {
|
||||
let mut seed = 7;
|
||||
let mut out = Vec::new();
|
||||
for degree in 1..=11 {
|
||||
let control: Vec<Vec2> = (0..14).map(|i| Vec2::new(i as f64 * 3.0 + noise(&mut seed), 10.0 * noise(&mut seed) - 5.0)).collect();
|
||||
let weights: Vec<f64> = (0..14).map(|_| 0.5 + 1.5 * noise(&mut seed)).collect();
|
||||
let clamped = Spline::from_control(control.clone(), degree);
|
||||
let uniform = Spline { knots: (0..control.len() + degree + 1).map(|i| i as f64 * 0.25).collect(), ..clamped.clone() };
|
||||
for base in [clamped, uniform] {
|
||||
out.push(Spline { weights: weights.clone(), ..base.clone() });
|
||||
out.push(base);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_basis_matches_the_heap_basis_bit_for_bit() {
|
||||
for s in samples() {
|
||||
assert!(s.is_valid());
|
||||
let (lo, hi) = s.domain();
|
||||
for i in -20..=1020 {
|
||||
let t = lo + (hi - lo) * i as f64 / 1000.0;
|
||||
let got = s.eval(t);
|
||||
let want = eval_ref(&s, t);
|
||||
assert_eq!(
|
||||
(got.x.to_bits(), got.y.to_bits()),
|
||||
(want.x.to_bits(), want.y.to_bits()),
|
||||
"degree {} at {t}: {got:?} vs {want:?}",
|
||||
s.degree
|
||||
);
|
||||
let span = find_span(s.control.len() - 1, s.degree, t.clamp(lo, hi), &s.knots);
|
||||
let b = basis(span, t.clamp(lo, hi), s.degree, &s.knots);
|
||||
let r = basis_ref(span, t.clamp(lo, hi), s.degree, &s.knots);
|
||||
assert!(b.iter().zip(&r).all(|(a, b)| a.to_bits() == b.to_bits()) && b.len() == r.len(), "degree {}", s.degree);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_into_needs_room_for_every_function() {
|
||||
let knots = clamped_uniform_knots(6, 3);
|
||||
let (mut n, mut left, mut right) = ([0.5; 3], [0.0; 4], [0.0; 4]);
|
||||
basis_into(3, 0.5, 3, &knots, &mut n, &mut left, &mut right);
|
||||
assert_eq!(n, [0.5; 3]);
|
||||
}
|
||||
|
||||
/// A cubic through 300 points of a sine wave, open or closed.
|
||||
fn wiggly(closed: bool) -> Spline {
|
||||
let fit: Vec<Vec2> = (0..300)
|
||||
.map(|i| {
|
||||
let x = i as f64 / 299.0;
|
||||
Vec2::new(x * 100.0, 10.0 * (x * 60.0).sin())
|
||||
})
|
||||
.collect();
|
||||
Spline::from_fit(&fit, closed)
|
||||
}
|
||||
|
||||
/// The box from 4000 evaluations, which `bounds` approximates.
|
||||
fn fine_box(s: &Spline) -> Bounds2 {
|
||||
uniform_box(s, MAX_SAMPLES)
|
||||
}
|
||||
|
||||
/// The box of `n + 1` samples evenly spaced in parameter over the domain, as `bounds` sampled
|
||||
/// before it spread its samples over the knot spans.
|
||||
fn uniform_box(s: &Spline, n: usize) -> Bounds2 {
|
||||
let (lo, hi) = s.domain();
|
||||
Bounds2::from_points((0..=n).map(|i| s.eval(lo + (hi - lo) * i as f64 / n as f64)))
|
||||
}
|
||||
|
||||
/// A degree-2 spline whose fourth control point, the only one off the x axis, is supported
|
||||
/// on the single knot span [0.5003, 0.5004) (double knots either side): the curve is flat
|
||||
/// except for a peak of 50 at t = 0.50035, inside a span of width 1e-4 that no point of a
|
||||
/// uniform grid of up to 4000 hits (0.50025 and 0.5005 are the nearest).
|
||||
fn narrow_span() -> Spline {
|
||||
let control = (0..7).map(|i| Vec2::new(i as f64, if i == 3 { 100.0 } else { 0.0 })).collect();
|
||||
Spline { degree: 2, knots: vec![0.0, 0.0, 0.0, 0.5003, 0.5003, 0.5004, 0.5004, 1.0, 1.0, 1.0], control, ..Spline::default() }
|
||||
}
|
||||
|
||||
/// A span held to the floor samples its peak off the grid: the sag stays under 1% of the
|
||||
/// spline's size, which is what `bounds` promises for it.
|
||||
#[test]
|
||||
fn an_off_grid_peak_in_a_narrow_span_is_within_a_percent() {
|
||||
let mut s = narrow_span();
|
||||
// y(s) = 200 s (1 - s) + 30 s² on the narrow span: its maximum is at s = 10 / 17, between
|
||||
// the samples of an eight-segment grid.
|
||||
s.control[4].y = 30.0;
|
||||
assert!(s.is_valid());
|
||||
let (a, b) = (0.5003, 0.5004);
|
||||
let peak = (0..=10_000).map(|i| s.eval(a + (b - a) * i as f64 / 10_000.0)).fold(Vec2::ZERO, |m, p| if p.y > m.y { p } else { m });
|
||||
assert!(peak.y > 58.0 && peak.y < 59.0, "{peak:?}");
|
||||
let hull = Bounds2::from_points(s.control.iter().copied());
|
||||
let diag = hull.min.dist(hull.max);
|
||||
let got = s.bounds();
|
||||
assert!(got.max.y <= peak.y + 1e-9 * diag && got.max.y >= peak.y - 1e-2 * diag, "{got:?} vs peak {peak:?}");
|
||||
// Four segments, the floor before, missed it by more than that.
|
||||
let four = (0..=4).map(|i| s.eval(a + (b - a) * i as f64 / 4.0)).fold(f64::MIN, |m, p| m.max(p.y));
|
||||
assert!(four < peak.y - 1e-2 * diag, "{four} vs {peak:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_see_an_extreme_inside_a_narrow_knot_span() {
|
||||
let s = narrow_span();
|
||||
assert!(s.is_valid());
|
||||
let (a, b) = (0.5003, 0.5004);
|
||||
let peak = (0..=1000).map(|i| s.eval(a + (b - a) * i as f64 / 1000.0)).fold(Vec2::ZERO, |m, p| if p.y > m.y { p } else { m });
|
||||
assert!((peak.x - 3.0).abs() < 1e-9 && (peak.y - 50.0).abs() < 1e-9, "{peak:?}");
|
||||
let hull = Bounds2::from_points(s.control.iter().copied());
|
||||
let diag = hull.min.dist(hull.max);
|
||||
// One uniform grid, at the count `bounds` used to take or at the cap, steps over the span
|
||||
// and sees only the flat parts of the curve.
|
||||
let old = s.segments_for(s.polygon_length() / (diag * 1e-3)).max((s.control.len() - s.degree) * 4).min(MAX_SAMPLES);
|
||||
for n in [old, MAX_SAMPLES] {
|
||||
let grid = uniform_box(&s, n);
|
||||
assert!(grid.max.y < 1e-9 && !grid.contains(peak), "{n} segments: {grid:?}");
|
||||
}
|
||||
let got = s.bounds();
|
||||
assert!(got.expand(1e-9 * diag).contains(peak), "{got:?} misses {peak:?}");
|
||||
assert!((got.max.y - peak.y).abs() <= 1e-9 * diag, "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_keep_their_cap_over_many_spans() {
|
||||
// 1500 spans: eight per span would pass the cap, so two; 2500 spans: more than the cap
|
||||
// allows two for, so the control box, which contains the curve. A smooth curve stays
|
||||
// within the margin either way.
|
||||
for n in [1502, 2502] {
|
||||
let control: Vec<Vec2> = (0..n).map(|i| Vec2::from_angle(i as f64 / n as f64 * std::f64::consts::TAU) * 50.0).collect();
|
||||
let hull = Bounds2::from_points(control.iter().copied());
|
||||
let s = Spline::from_control(control, 2);
|
||||
let (b, want) = (s.bounds(), fine_box(&s));
|
||||
if n > MAX_SAMPLES / 2 {
|
||||
assert_eq!(b, hull, "{n} control points");
|
||||
assert!(b.contains(want.min) && b.contains(want.max), "{b:?} vs {want:?}");
|
||||
}
|
||||
let diag = want.min.dist(want.max);
|
||||
let err = [b.min.x - want.min.x, b.min.y - want.min.y, b.max.x - want.max.x, b.max.y - want.max.y]
|
||||
.into_iter()
|
||||
.map(f64::abs)
|
||||
.fold(0.0, f64::max);
|
||||
assert!(err <= 1e-3 * diag, "{n} control points: {b:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_keep_their_cap_over_huge_knot_spans() {
|
||||
// Knots a hostile edit can produce (lengthening by 1e308): a span's count must not
|
||||
// overflow into a boundless loop or a sum that panics.
|
||||
let control = vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)];
|
||||
for knots in [
|
||||
vec![0.0, 0.0, 0.0, 1e308, 1.5e308, 1.5e308, 1.5e308],
|
||||
vec![0.0, 0.0, 0.0, f64::MAX, f64::MAX, f64::MAX, f64::MAX],
|
||||
vec![-1e308, -1e308, -1e308, 0.0, 1e308, 1e308, 1e308],
|
||||
] {
|
||||
let s = Spline { knots, ..Spline::from_control(control.clone(), 2) };
|
||||
assert!(s.is_valid());
|
||||
let b = s.bounds();
|
||||
assert!(b.is_empty() || Bounds2::from_points(control.iter().copied()).expand(1e-9).contains_box(&b), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn scaled(s: &Spline, k: f64) -> Spline {
|
||||
Spline { control: s.control.iter().map(|c| *c * k).collect(), fit: s.fit.iter().map(|c| *c * k).collect(), ..s.clone() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_stay_within_a_small_margin_of_the_finely_sampled_box() {
|
||||
let mut all = samples();
|
||||
all.push(wiggly(false));
|
||||
all.push(wiggly(true));
|
||||
all.push(Spline::from_fit_points_closed(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]));
|
||||
for s in all {
|
||||
// Whatever the drawing's units: the same relative margin at any scale, including
|
||||
// splines far smaller than any absolute tolerance floor.
|
||||
for k in [1e-12, 1e-9, 1e-6, 1.0, 1e6] {
|
||||
let s = scaled(&s, k);
|
||||
let (b, want) = (s.bounds(), fine_box(&s));
|
||||
let diag = want.min.dist(want.max);
|
||||
let err = [b.min.x - want.min.x, b.min.y - want.min.y, b.max.x - want.max.x, b.max.y - want.max.y]
|
||||
.into_iter()
|
||||
.map(f64::abs)
|
||||
.fold(0.0, f64::max);
|
||||
assert!(err <= 1e-3 * diag, "degree {} with {} control points at scale {k}: {b:?} vs {want:?}", s.degree, s.control.len());
|
||||
// The curve lies in its control points' convex hull (positive weights), up to rounding.
|
||||
assert!(Bounds2::from_points(s.control.iter().copied()).expand(1e-9 * diag).contains_box(&b), "degree {} at scale {k}", s.degree);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A negative weight voids the convex hull property: a degree-1 spline is then not its
|
||||
/// control polygon, so it is sampled like any other.
|
||||
#[test]
|
||||
fn a_degree_one_spline_with_a_negative_weight_is_sampled() {
|
||||
let mut s = Spline::from_control(vec![Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0)], 1);
|
||||
s.weights = vec![1.0, -0.5];
|
||||
assert!(s.is_valid());
|
||||
let far = s.eval(0.75);
|
||||
assert!((far.x - 4.0).abs() < 1e-9, "{far:?}");
|
||||
let b = s.bounds();
|
||||
assert!(b.contains(far), "{b:?} misses {far:?}");
|
||||
s.weights = vec![1.0, 0.5];
|
||||
assert_eq!(s.bounds(), Bounds2::new(Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0)));
|
||||
}
|
||||
|
||||
/// Zero weights evaluate to the origin, so a spline whose control points coincide is not its
|
||||
/// control box either; it is sampled.
|
||||
#[test]
|
||||
fn zero_weights_are_sampled_not_boxed() {
|
||||
let mut s = Spline::from_control(vec![Vec2::new(3.0, 4.0); 2], 1);
|
||||
s.weights = vec![0.0, 0.0];
|
||||
assert!(s.is_valid());
|
||||
assert_eq!(s.eval(0.5), Vec2::ZERO);
|
||||
let b = s.bounds();
|
||||
assert!(b.contains(Vec2::ZERO), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_splines_get_their_control_box() {
|
||||
let point = Spline::from_control(vec![Vec2::new(3.0, 4.0); 4], 3);
|
||||
assert_eq!(point.bounds(), Bounds2::new(Vec2::new(3.0, 4.0), Vec2::new(3.0, 4.0)));
|
||||
let invalid = Spline { knots: vec![0.0; 3], ..Spline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(5.0, -1.0)], 2) };
|
||||
assert!(!invalid.is_valid());
|
||||
assert_eq!(invalid.bounds(), Bounds2::new(Vec2::new(0.0, -1.0), Vec2::new(5.0, 2.0)));
|
||||
let hostile = Spline::from_control(vec![Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, f64::INFINITY), Vec2::new(2.0, 2.0)], 2);
|
||||
assert_eq!(hostile.bounds(), Bounds2::new(Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0)));
|
||||
// A non-finite control point among finite ones poisons every sample: the box of the
|
||||
// finite control points, not the empty box.
|
||||
for bad in [Vec2::new(f64::NAN, 5.0), Vec2::new(5.0, f64::NAN), Vec2::new(f64::INFINITY, 5.0), Vec2::new(5.0, f64::NEG_INFINITY)] {
|
||||
let s = Spline::from_control(vec![Vec2::ZERO, bad, Vec2::new(10.0, 10.0), Vec2::new(20.0, 0.0)], 3);
|
||||
assert!(s.is_valid());
|
||||
assert_eq!(s.bounds(), Bounds2::new(Vec2::ZERO, Vec2::new(20.0, 10.0)), "{bad:?}");
|
||||
}
|
||||
// A non-finite weight poisons the samples the same way.
|
||||
for bad in [f64::NAN, f64::INFINITY] {
|
||||
let mut s = Spline::from_control(vec![Vec2::ZERO, Vec2::new(5.0, 5.0), Vec2::new(10.0, 10.0), Vec2::new(20.0, 0.0)], 3);
|
||||
s.weights = vec![1.0, bad, 1.0, 1.0];
|
||||
assert!(s.is_valid());
|
||||
assert_eq!(s.bounds(), Bounds2::new(Vec2::ZERO, Vec2::new(20.0, 10.0)), "weight {bad:?}");
|
||||
}
|
||||
assert!(Spline::default().bounds().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_degree_spline_is_invalid_and_cheap_to_tessellate() {
|
||||
// A HATCH/DXF spline edge can declare a very high degree with matching knots. Such a
|
||||
// spline is treated as invalid, so eval/tessellate fall back to the control polygon
|
||||
// instead of doing O(degree^2) work per sample (CWE-407, rows 18/28).
|
||||
let degree = 50_000usize;
|
||||
let control = vec![Vec2::ZERO; degree + 2];
|
||||
let knots = clamped_uniform_knots(control.len(), degree);
|
||||
let s = Spline { degree, knots, control: control.clone(), ..Spline::default() };
|
||||
assert!(!s.is_valid(), "a degree-{degree} spline must be rejected as invalid");
|
||||
// Returns the control polygon rather than sampling, so this is fast.
|
||||
assert_eq!(s.tessellate(1e-3).len(), control.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_low_degree_spline_is_valid() {
|
||||
let s = Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 0.0), Vec2::new(3.0, 1.0)], 3);
|
||||
assert!(s.is_valid());
|
||||
assert!(s.tessellate(1e-3).len() > 4);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
use super::*;
|
||||
|
||||
/// `basis` before it evaluated on the stack, kept as the reference.
|
||||
fn basis_ref(span: usize, u: f64, p: usize, knots: &[f64]) -> Vec<f64> {
|
||||
let mut n = vec![0.0; p + 1];
|
||||
let mut left = vec![0.0; p + 1];
|
||||
let mut right = vec![0.0; p + 1];
|
||||
n[0] = 1.0;
|
||||
for j in 1..=p {
|
||||
let k = |i: isize| knots.get(i.max(0) as usize).copied().unwrap_or(0.0);
|
||||
left[j] = u - k(span as isize + 1 - j as isize);
|
||||
right[j] = k(span as isize + j as isize) - u;
|
||||
let mut saved = 0.0;
|
||||
for r in 0..j {
|
||||
let den = right[r + 1] + left[j - r];
|
||||
let tmp = if den.abs() > 1e-300 { n[r] / den } else { 0.0 };
|
||||
n[r] = saved + right[r + 1] * tmp;
|
||||
saved = left[j - r] * tmp;
|
||||
}
|
||||
n[j] = saved;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `eval` before it evaluated on the stack, kept as the reference.
|
||||
fn eval_ref(s: &Spline, t: f64) -> Vec2 {
|
||||
let n = s.control.len() - 1;
|
||||
let (lo, hi) = s.domain();
|
||||
let t = t.clamp(lo, hi);
|
||||
let span = find_span(n, s.degree, t, &s.knots);
|
||||
let b = basis_ref(span, t, s.degree, &s.knots);
|
||||
let mut p = Vec2::ZERO;
|
||||
let mut wsum = 0.0;
|
||||
for (k, nb) in b.iter().enumerate() {
|
||||
let idx = span + k - s.degree;
|
||||
let w = if s.weights.is_empty() { 1.0 } else { s.weights[idx] };
|
||||
p += s.control[idx] * (nb * w);
|
||||
wsum += nb * w;
|
||||
}
|
||||
if wsum.abs() > 1e-300 { p / wsum } else { p }
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random numbers in `0..1`.
|
||||
fn noise(seed: &mut u64) -> f64 {
|
||||
*seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
(*seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Splines of degree 1..=11 over 14 control points: clamped and unclamped (uniform) knots,
|
||||
/// unit and random weights.
|
||||
fn samples() -> Vec<Spline> {
|
||||
let mut seed = 7;
|
||||
let mut out = Vec::new();
|
||||
for degree in 1..=11 {
|
||||
let control: Vec<Vec2> = (0..14).map(|i| Vec2::new(i as f64 * 3.0 + noise(&mut seed), 10.0 * noise(&mut seed) - 5.0)).collect();
|
||||
let weights: Vec<f64> = (0..14).map(|_| 0.5 + 1.5 * noise(&mut seed)).collect();
|
||||
let clamped = Spline::from_control(control.clone(), degree);
|
||||
let uniform = Spline { knots: (0..control.len() + degree + 1).map(|i| i as f64 * 0.25).collect(), ..clamped.clone() };
|
||||
for base in [clamped, uniform] {
|
||||
out.push(Spline { weights: weights.clone(), ..base.clone() });
|
||||
out.push(base);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_basis_matches_the_heap_basis_bit_for_bit() {
|
||||
for s in samples() {
|
||||
assert!(s.is_valid());
|
||||
let (lo, hi) = s.domain();
|
||||
for i in -20..=1020 {
|
||||
let t = lo + (hi - lo) * i as f64 / 1000.0;
|
||||
let got = s.eval(t);
|
||||
let want = eval_ref(&s, t);
|
||||
assert_eq!((got.x.to_bits(), got.y.to_bits()), (want.x.to_bits(), want.y.to_bits()), "degree {} at {t}: {got:?} vs {want:?}", s.degree);
|
||||
let span = find_span(s.control.len() - 1, s.degree, t.clamp(lo, hi), &s.knots);
|
||||
let b = basis(span, t.clamp(lo, hi), s.degree, &s.knots);
|
||||
let r = basis_ref(span, t.clamp(lo, hi), s.degree, &s.knots);
|
||||
assert!(b.iter().zip(&r).all(|(a, b)| a.to_bits() == b.to_bits()) && b.len() == r.len(), "degree {}", s.degree);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basis_into_needs_room_for_every_function() {
|
||||
let knots = clamped_uniform_knots(6, 3);
|
||||
let (mut n, mut left, mut right) = ([0.5; 3], [0.0; 4], [0.0; 4]);
|
||||
basis_into(3, 0.5, 3, &knots, &mut n, &mut left, &mut right);
|
||||
assert_eq!(n, [0.5; 3]);
|
||||
}
|
||||
|
||||
/// A cubic through 300 points of a sine wave, open or closed.
|
||||
fn wiggly(closed: bool) -> Spline {
|
||||
let fit: Vec<Vec2> = (0..300)
|
||||
.map(|i| {
|
||||
let x = i as f64 / 299.0;
|
||||
Vec2::new(x * 100.0, 10.0 * (x * 60.0).sin())
|
||||
})
|
||||
.collect();
|
||||
Spline::from_fit(&fit, closed)
|
||||
}
|
||||
|
||||
/// The box from 4000 evaluations, which `bounds` approximates.
|
||||
fn fine_box(s: &Spline) -> Bounds2 {
|
||||
uniform_box(s, MAX_SAMPLES)
|
||||
}
|
||||
|
||||
/// The box of `n + 1` samples evenly spaced in parameter over the domain, as `bounds` sampled
|
||||
/// before it spread its samples over the knot spans.
|
||||
fn uniform_box(s: &Spline, n: usize) -> Bounds2 {
|
||||
let (lo, hi) = s.domain();
|
||||
Bounds2::from_points((0..=n).map(|i| s.eval(lo + (hi - lo) * i as f64 / n as f64)))
|
||||
}
|
||||
|
||||
/// A degree-2 spline whose fourth control point, the only one off the x axis, is supported
|
||||
/// on the single knot span [0.5003, 0.5004) (double knots either side): the curve is flat
|
||||
/// except for a peak of 50 at t = 0.50035, inside a span of width 1e-4 that no point of a
|
||||
/// uniform grid of up to 4000 hits (0.50025 and 0.5005 are the nearest).
|
||||
fn narrow_span() -> Spline {
|
||||
let control = (0..7).map(|i| Vec2::new(i as f64, if i == 3 { 100.0 } else { 0.0 })).collect();
|
||||
Spline { degree: 2, knots: vec![0.0, 0.0, 0.0, 0.5003, 0.5003, 0.5004, 0.5004, 1.0, 1.0, 1.0], control, ..Spline::default() }
|
||||
}
|
||||
|
||||
/// A span held to the floor samples its peak off the grid: the sag stays under 1% of the
|
||||
/// spline's size, which is what `bounds` promises for it.
|
||||
#[test]
|
||||
fn an_off_grid_peak_in_a_narrow_span_is_within_a_percent() {
|
||||
let mut s = narrow_span();
|
||||
// y(s) = 200 s (1 - s) + 30 s² on the narrow span: its maximum is at s = 10 / 17, between
|
||||
// the samples of an eight-segment grid.
|
||||
s.control[4].y = 30.0;
|
||||
assert!(s.is_valid());
|
||||
let (a, b) = (0.5003, 0.5004);
|
||||
let peak = (0..=10_000).map(|i| s.eval(a + (b - a) * i as f64 / 10_000.0)).fold(Vec2::ZERO, |m, p| if p.y > m.y { p } else { m });
|
||||
assert!(peak.y > 58.0 && peak.y < 59.0, "{peak:?}");
|
||||
let hull = Bounds2::from_points(s.control.iter().copied());
|
||||
let diag = hull.min.dist(hull.max);
|
||||
let got = s.bounds();
|
||||
assert!(got.max.y <= peak.y + 1e-9 * diag && got.max.y >= peak.y - 1e-2 * diag, "{got:?} vs peak {peak:?}");
|
||||
// Four segments, the floor before, missed it by more than that.
|
||||
let four = (0..=4).map(|i| s.eval(a + (b - a) * i as f64 / 4.0)).fold(f64::MIN, |m, p| m.max(p.y));
|
||||
assert!(four < peak.y - 1e-2 * diag, "{four} vs {peak:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_see_an_extreme_inside_a_narrow_knot_span() {
|
||||
let s = narrow_span();
|
||||
assert!(s.is_valid());
|
||||
let (a, b) = (0.5003, 0.5004);
|
||||
let peak = (0..=1000).map(|i| s.eval(a + (b - a) * i as f64 / 1000.0)).fold(Vec2::ZERO, |m, p| if p.y > m.y { p } else { m });
|
||||
assert!((peak.x - 3.0).abs() < 1e-9 && (peak.y - 50.0).abs() < 1e-9, "{peak:?}");
|
||||
let hull = Bounds2::from_points(s.control.iter().copied());
|
||||
let diag = hull.min.dist(hull.max);
|
||||
// One uniform grid, at the count `bounds` used to take or at the cap, steps over the span
|
||||
// and sees only the flat parts of the curve.
|
||||
let old = s.segments_for(s.polygon_length() / (diag * 1e-3)).max((s.control.len() - s.degree) * 4).min(MAX_SAMPLES);
|
||||
for n in [old, MAX_SAMPLES] {
|
||||
let grid = uniform_box(&s, n);
|
||||
assert!(grid.max.y < 1e-9 && !grid.contains(peak), "{n} segments: {grid:?}");
|
||||
}
|
||||
let got = s.bounds();
|
||||
assert!(got.expand(1e-9 * diag).contains(peak), "{got:?} misses {peak:?}");
|
||||
assert!((got.max.y - peak.y).abs() <= 1e-9 * diag, "{got:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_keep_their_cap_over_many_spans() {
|
||||
// 1500 spans: eight per span would pass the cap, so two; 2500 spans: more than the cap
|
||||
// allows two for, so the control box, which contains the curve. A smooth curve stays
|
||||
// within the margin either way.
|
||||
for n in [1502, 2502] {
|
||||
let control: Vec<Vec2> = (0..n).map(|i| Vec2::from_angle(i as f64 / n as f64 * std::f64::consts::TAU) * 50.0).collect();
|
||||
let hull = Bounds2::from_points(control.iter().copied());
|
||||
let s = Spline::from_control(control, 2);
|
||||
let (b, want) = (s.bounds(), fine_box(&s));
|
||||
if n > MAX_SAMPLES / 2 {
|
||||
assert_eq!(b, hull, "{n} control points");
|
||||
assert!(b.contains(want.min) && b.contains(want.max), "{b:?} vs {want:?}");
|
||||
}
|
||||
let diag = want.min.dist(want.max);
|
||||
let err =
|
||||
[b.min.x - want.min.x, b.min.y - want.min.y, b.max.x - want.max.x, b.max.y - want.max.y].into_iter().map(f64::abs).fold(0.0, f64::max);
|
||||
assert!(err <= 1e-3 * diag, "{n} control points: {b:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_keep_their_cap_over_huge_knot_spans() {
|
||||
// Knots a hostile edit can produce (lengthening by 1e308): a span's count must not
|
||||
// overflow into a boundless loop or a sum that panics.
|
||||
let control = vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)];
|
||||
for knots in [
|
||||
vec![0.0, 0.0, 0.0, 1e308, 1.5e308, 1.5e308, 1.5e308],
|
||||
vec![0.0, 0.0, 0.0, f64::MAX, f64::MAX, f64::MAX, f64::MAX],
|
||||
vec![-1e308, -1e308, -1e308, 0.0, 1e308, 1e308, 1e308],
|
||||
] {
|
||||
let s = Spline { knots, ..Spline::from_control(control.clone(), 2) };
|
||||
assert!(s.is_valid());
|
||||
let b = s.bounds();
|
||||
assert!(b.is_empty() || Bounds2::from_points(control.iter().copied()).expand(1e-9).contains_box(&b), "{b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn scaled(s: &Spline, k: f64) -> Spline {
|
||||
Spline { control: s.control.iter().map(|c| *c * k).collect(), fit: s.fit.iter().map(|c| *c * k).collect(), ..s.clone() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_stay_within_a_small_margin_of_the_finely_sampled_box() {
|
||||
let mut all = samples();
|
||||
all.push(wiggly(false));
|
||||
all.push(wiggly(true));
|
||||
all.push(Spline::from_fit_points_closed(&[Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]));
|
||||
for s in all {
|
||||
// Whatever the drawing's units: the same relative margin at any scale, including
|
||||
// splines far smaller than any absolute tolerance floor.
|
||||
for k in [1e-12, 1e-9, 1e-6, 1.0, 1e6] {
|
||||
let s = scaled(&s, k);
|
||||
let (b, want) = (s.bounds(), fine_box(&s));
|
||||
let diag = want.min.dist(want.max);
|
||||
let err = [b.min.x - want.min.x, b.min.y - want.min.y, b.max.x - want.max.x, b.max.y - want.max.y]
|
||||
.into_iter()
|
||||
.map(f64::abs)
|
||||
.fold(0.0, f64::max);
|
||||
assert!(err <= 1e-3 * diag, "degree {} with {} control points at scale {k}: {b:?} vs {want:?}", s.degree, s.control.len());
|
||||
// The curve lies in its control points' convex hull (positive weights), up to rounding.
|
||||
assert!(Bounds2::from_points(s.control.iter().copied()).expand(1e-9 * diag).contains_box(&b), "degree {} at scale {k}", s.degree);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A negative weight voids the convex hull property: a degree-1 spline is then not its
|
||||
/// control polygon, so it is sampled like any other.
|
||||
#[test]
|
||||
fn a_degree_one_spline_with_a_negative_weight_is_sampled() {
|
||||
let mut s = Spline::from_control(vec![Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0)], 1);
|
||||
s.weights = vec![1.0, -0.5];
|
||||
assert!(s.is_valid());
|
||||
let far = s.eval(0.75);
|
||||
assert!((far.x - 4.0).abs() < 1e-9, "{far:?}");
|
||||
let b = s.bounds();
|
||||
assert!(b.contains(far), "{b:?} misses {far:?}");
|
||||
s.weights = vec![1.0, 0.5];
|
||||
assert_eq!(s.bounds(), Bounds2::new(Vec2::new(1.0, 0.0), Vec2::new(2.0, 0.0)));
|
||||
}
|
||||
|
||||
/// Zero weights evaluate to the origin, so a spline whose control points coincide is not its
|
||||
/// control box either; it is sampled.
|
||||
#[test]
|
||||
fn zero_weights_are_sampled_not_boxed() {
|
||||
let mut s = Spline::from_control(vec![Vec2::new(3.0, 4.0); 2], 1);
|
||||
s.weights = vec![0.0, 0.0];
|
||||
assert!(s.is_valid());
|
||||
assert_eq!(s.eval(0.5), Vec2::ZERO);
|
||||
let b = s.bounds();
|
||||
assert!(b.contains(Vec2::ZERO), "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_splines_get_their_control_box() {
|
||||
let point = Spline::from_control(vec![Vec2::new(3.0, 4.0); 4], 3);
|
||||
assert_eq!(point.bounds(), Bounds2::new(Vec2::new(3.0, 4.0), Vec2::new(3.0, 4.0)));
|
||||
let invalid = Spline { knots: vec![0.0; 3], ..Spline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(5.0, -1.0)], 2) };
|
||||
assert!(!invalid.is_valid());
|
||||
assert_eq!(invalid.bounds(), Bounds2::new(Vec2::new(0.0, -1.0), Vec2::new(5.0, 2.0)));
|
||||
let hostile = Spline::from_control(vec![Vec2::new(f64::NAN, 0.0), Vec2::new(1.0, f64::INFINITY), Vec2::new(2.0, 2.0)], 2);
|
||||
assert_eq!(hostile.bounds(), Bounds2::new(Vec2::new(2.0, 2.0), Vec2::new(2.0, 2.0)));
|
||||
// A non-finite control point among finite ones poisons every sample: the box of the
|
||||
// finite control points, not the empty box.
|
||||
for bad in [Vec2::new(f64::NAN, 5.0), Vec2::new(5.0, f64::NAN), Vec2::new(f64::INFINITY, 5.0), Vec2::new(5.0, f64::NEG_INFINITY)] {
|
||||
let s = Spline::from_control(vec![Vec2::ZERO, bad, Vec2::new(10.0, 10.0), Vec2::new(20.0, 0.0)], 3);
|
||||
assert!(s.is_valid());
|
||||
assert_eq!(s.bounds(), Bounds2::new(Vec2::ZERO, Vec2::new(20.0, 10.0)), "{bad:?}");
|
||||
}
|
||||
// A non-finite weight poisons the samples the same way.
|
||||
for bad in [f64::NAN, f64::INFINITY] {
|
||||
let mut s = Spline::from_control(vec![Vec2::ZERO, Vec2::new(5.0, 5.0), Vec2::new(10.0, 10.0), Vec2::new(20.0, 0.0)], 3);
|
||||
s.weights = vec![1.0, bad, 1.0, 1.0];
|
||||
assert!(s.is_valid());
|
||||
assert_eq!(s.bounds(), Bounds2::new(Vec2::ZERO, Vec2::new(20.0, 10.0)), "weight {bad:?}");
|
||||
}
|
||||
assert!(Spline::default().bounds().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_degree_spline_is_invalid_and_cheap_to_tessellate() {
|
||||
// A HATCH/DXF spline edge can declare a very high degree with matching knots. Such a
|
||||
// spline is treated as invalid, so eval/tessellate fall back to the control polygon
|
||||
// instead of doing O(degree^2) work per sample (CWE-407, rows 18/28).
|
||||
let degree = 50_000usize;
|
||||
let control = vec![Vec2::ZERO; degree + 2];
|
||||
let knots = clamped_uniform_knots(control.len(), degree);
|
||||
let s = Spline { degree, knots, control: control.clone(), ..Spline::default() };
|
||||
assert!(!s.is_valid(), "a degree-{degree} spline must be rejected as invalid");
|
||||
// Returns the control polygon rather than sampling, so this is fast.
|
||||
assert_eq!(s.tessellate(1e-3).len(), control.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_low_degree_spline_is_valid() {
|
||||
let s = Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 0.0), Vec2::new(3.0, 1.0)], 3);
|
||||
assert!(s.is_valid());
|
||||
assert!(s.tessellate(1e-3).len() > 4);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user