Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor e5e5645cf6 chore(clippy): enforce coding standards via workspace lints (PR #2239) 2026-10-27 01:20:13 +00:00
aiongg 7d2ca90783 refactor(engine): move Session methods from session.rs to session/ 2026-10-26 19:44:13 +00:00
aiongg af9798e200 refactor(doc): move entity.rs items to entity/ 2026-10-26 19:44:13 +00:00
aiongg c042e98abb refactor(render): move dim.rs items to dim/arrowheads.rs and dim/text.rs 2026-10-26 19:44:13 +00:00
aiongg 994af5cac9 refactor(render): move lib.rs items to display, pipeline, viewports, props, draw, text_entity, dim_draw, blocks 2026-10-26 19:44:13 +00:00
aiongg 970e6bb9d4 refactor(io): move dxf_read.rs items to dxf_read/ 2026-10-26 19:18:38 +00:00
aiongg 736a83464a refactor(io): move dxf_write.rs items to dxf_write/ 2026-10-26 19:18:38 +00:00
aiongg aa5dfc2099 refactor(engine): move lisp/builtins.rs items to lisp/builtins/ 2026-10-26 19:18:38 +00:00
aiongg f12b7570b7 refactor(engine): move lib.rs types to error, view, doc_state, settings, running, last_used and Session methods to session.rs 2026-10-26 19:18:38 +00:00
aiongg 795eb62762 refactor(engine): move cmd/draw.rs items to cmd/draw/ 2026-10-26 19:18:38 +00:00
aiongg e7b92c5b8e refactor(engine): move cmd/annotate.rs items to cmd/annotate/ 2026-10-26 19:18:38 +00:00
aiongg 6028cdf187 refactor(engine): move cmd/modify2.rs items to cmd/modify2/ 2026-10-26 19:18:38 +00:00
aiongg 172f9ca44c refactor(engine): move cmd/draw2.rs items to cmd/draw2/ 2026-10-26 19:18:38 +00:00
aiongg c5e7db78e4 refactor(engine): move cmd/modify.rs items to cmd/modify/ 2026-10-26 19:18:38 +00:00
aiongg 31a04e3dbe refactor(ui-egui): move canvas.rs items to canvas/ 2026-10-26 19:18:38 +00:00
aiongg 627ffb8d7e refactor(engine): move grips_tests.rs to grips/tests.rs 2026-10-26 18:18:08 +00:00
aiongg 18f47e021f refactor(engine): move cmd/inquiry/usage_tests.rs to cmd/inquiry/usage/tests.rs 2026-10-26 18:18:08 +00:00
aiongg 21d80b3231 refactor(engine): move cmd/txt2mtxt_tests.rs to cmd/txt2mtxt/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 62deed7880 refactor(engine): move cmd/mleader_edit_tests.rs to cmd/mleader_edit/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 45bcfa6f77 refactor(engine): move cmd/constraints_tests.rs to cmd/constraints/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 627b5a3350 refactor(engine): move cmd/annotate_tests.rs to cmd/annotate/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 1006e487da refactor(engine): move cmd/modify2_tests.rs to cmd/modify2/tests.rs 2026-10-26 18:18:07 +00:00
aiongg fcd4a0c9eb refactor(engine): move cmd/draw2_tests.rs to cmd/draw2/tests.rs 2026-10-26 18:18:07 +00:00
aiongg dbd2bf5dad style: cargo fmt after moving test modules out 2026-10-26 18:18:07 +00:00
aiongg d7915c5f3b refactor(ui-egui): move canvas.rs tests to canvas/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 1bbc5ec76b refactor(engine): move layout.rs tests to cmd/layout/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 012732136c refactor(geom): move spline.rs tests to spline/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 273303134e refactor(ui-egui): move hatchdlg.rs tests to hatchdlg/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 48d25a0414 refactor(render): move dim.rs tests to dim/tests.rs 2026-10-26 18:18:07 +00:00
aiongg 6021d96628 refactor(ui-egui): move capture.rs tests to capture/tests.rs 2026-10-26 18:18:07 +00:00
aiongg ad329273f2 refactor(ui-egui): move crates/ui-egui/src/options/mod.rs to crates/ui-egui/src/options.rs 2026-10-26 18:18:07 +00:00
aiongg f3796d1f7a style(ui-egui): cargo fmt after the i18n.rs move 2026-10-26 18:18:07 +00:00
aiongg 3a64a190ea refactor(ui-egui): move crates/ui-egui/src/i18n/mod.rs to crates/ui-egui/src/i18n.rs 2026-10-26 18:18:07 +00:00
aiongg 5074d2a54c refactor(engine): move crates/engine/src/lisp/mod.rs to crates/engine/src/lisp.rs 2026-10-26 18:15:12 +00:00
aiongg bccba7261e refactor(engine): move crates/engine/src/cmd/mod.rs to crates/engine/src/cmd.rs 2026-10-26 18:15:12 +00:00
aiongg c8ee56a44f test: scan source directories recursively
The translation gap report reads each listed UI module and every file under its directory, so code split into submodules stays in the report.
2026-10-26 17:59:21 +00:00
152 changed files with 26198 additions and 25573 deletions
-3
View File
@@ -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
View File
@@ -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."*"]
+1
View File
@@ -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
View File
@@ -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,
+182
View File
@@ -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))
}
}
+132
View File
@@ -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,
}
+76
View File
@@ -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,
}
+226
View File
@@ -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()
}
}
+61
View File
@@ -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>,
}
+168
View File
@@ -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
}
+141
View File
@@ -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);
}
}
File diff suppressed because it is too large Load Diff
+330
View File
@@ -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()
}
}
+170
View File
@@ -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
}
}
+371
View File
@@ -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()
}
}
+360
View File
@@ -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()
}
}
+389
View File
@@ -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()
}
}
+100
View File
@@ -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()
}
}
-1
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+178
View File
@@ -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(())
}
+214
View File
@@ -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()
}
}
+403
View File
@@ -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)]
}
}
}
+165
View File
@@ -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(),
}
}
}
+134
View File
@@ -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(),
}
}
}
+170
View File
@@ -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))]
}
}
+274
View File
@@ -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(),
}
}
}
File diff suppressed because it is too large Load Diff
+381
View File
@@ -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()
}
}
+124
View File
@@ -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),
}
}
}
+363
View File
@@ -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)))]
}
}
+181
View File
@@ -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, &params)?;
} 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, &params)?;
}
Ok(Step::Done)
}
(_, Input::Enter) => Ok(Step::Cancel),
_ => Err(other("Invalid input.")),
}
}
}
+60
View File
@@ -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))?)
}
+308
View File
@@ -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(),
}
}
}
+273
View File
@@ -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)]
}
}
}
}
+137
View File
@@ -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)
}
}
}
}
+137
View File
@@ -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)]
}
}
+260
View File
@@ -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(),
}
}
}
-1
View File
@@ -432,5 +432,4 @@ pub fn run(s: &mut Session, p: &Value) -> Result<Value> {
}
#[cfg(test)]
#[path = "usage_tests.rs"]
mod tests;
+1 -300
View File
@@ -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;
+295
View File
@@ -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());
}
-1
View File
@@ -793,5 +793,4 @@ impl Interactive for CollectM {
}
#[cfg(test)]
#[path = "mleader_edit_tests.rs"]
mod tests;
File diff suppressed because it is too large Load Diff
+277
View File
@@ -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),
}
}
}
+207
View File
@@ -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<_>>() }))
}
+571
View File
@@ -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(&deg) => {
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)
}
+497
View File
@@ -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()
}
}
+593
View File
@@ -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)
}
}
+393
View File
@@ -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(),
}
}
}
+106
View File
@@ -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<_>>() }))
}
+343
View File
@@ -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(())
}
File diff suppressed because it is too large Load Diff
+123
View File
@@ -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(),
}
}
}
+117
View File
@@ -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),
}
}
}
+181
View File
@@ -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)
}
+307
View File
@@ -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()
}
}
+151
View File
@@ -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()
}
}
+122
View File
@@ -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),
}
}
}
+587
View File
@@ -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
}
}
+281
View File
@@ -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),
}
}
}
+115
View File
@@ -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),
}
}
}
+176
View File
@@ -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, &params, 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.")),
}
}
-1
View File
@@ -334,5 +334,4 @@ impl Interactive for Txt2MtxtM {
}
#[cfg(test)]
#[path = "txt2mtxt_tests.rs"]
mod tests;
+226
View File
@@ -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);
}
}
+24
View File
@@ -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>;
-1
View File
@@ -538,5 +538,4 @@ pub fn polyline_grip_count(p: &LwPolyline) -> usize {
}
#[cfg(test)]
#[path = "grips_tests.rs"]
mod tests;
+97
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+48
View File
@@ -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) }
}
+120
View File
@@ -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) })
}
+145
View File
@@ -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<_>>()))
}
+328
View File
@@ -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()))
}
+87
View File
@@ -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)
}
+207
View File
@@ -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]))
}
+242
View File
@@ -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));
}
}
+16
View File
@@ -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,
}
+12
View File
@@ -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;
+225
View File
@@ -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, &params)?;
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),
}
}
+95
View File
@@ -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))
}
}
+61
View File
@@ -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()))
}
}
}
}
+511
View File
@@ -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))
}
}
+128
View File
@@ -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();
}
}
}
+44
View File
@@ -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(())
}
}
+196
View File
@@ -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,
}
}
}
+40
View File
@@ -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
View File
@@ -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;
+305
View File
@@ -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