Dimension edits: DIMEDIT Oblique/New/Rotate/Home, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMINSPECT (part of #507) (#522)

Squash of PR #522 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 12:10:38 -04:00
1 parent b7b37e29cd
commit c334fedf31
21 files changed
+1504 -5

No files matched your search

+84
View File
@@ -412,6 +412,89 @@ pub struct Dimension {
/// 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).
@@ -1025,6 +1108,7 @@ impl EntityKind {
if let DimKind::Linear { rotation } = &mut d.kind {
*rotation = cadcraft_geom::norm_angle(*rotation + rot);
}
d.marks.transform(&t3, rot, s, mirror);
d.block = None;
}
EntityKind::MLine(ml) => {
+1
View File
@@ -442,6 +442,7 @@ fn invisible_dimblock_entity_is_ignored_in_dimension_extents() {
block: Some("*D1".into()),
overrides: Default::default(),
assoc: vec![],
marks: Default::default(),
}),
)
.unwrap();
+10 -1
View File
@@ -1,6 +1,7 @@
//! Dimensions and leaders: DIMLINEAR, DIMALIGNED, DIMRADIUS, DIMDIAMETER, DIMANGULAR, DIMARC,
//! DIMORDINATE, DIMCONTINUE, DIMBASELINE, QDIM, DIM (smart), MLEADER, LEADER, DIMOVERRIDE,
//! DIMREASSOCIATE / DIMDISASSOCIATE, DIMTEDIT, DIMSPACE.
//! DIMREASSOCIATE / DIMDISASSOCIATE, DIMTEDIT, DIMSPACE; DIMEDIT, DIMJOGGED, DIMJOGLINE,
//! DIMBREAK and DIMINSPECT in `annotate_dimedit.rs`.
use cadcraft_doc::{AssocSnap, DimAssoc, DimKind, Dimension, EntityKind, Handle, MLeader, MText, Prim};
use cadcraft_geom::{Segment, Vec2, Vec3};
@@ -106,6 +107,9 @@ pub fn specs() -> Vec<CommandSpec> {
.menu(&["Dimension", "Dimension Space"])
.params("{base: hex, handles: [hex], spacing?: number (default DIMDLI)}"),
]
.into_iter()
.chain(dimedit::specs())
.collect()
}
fn style(s: &Session) -> String {
@@ -129,6 +133,7 @@ fn dim(s: &Session, kind: DimKind, defpt: Vec2, p13: Vec2, p14: Vec2, p15: Vec2,
block: None,
overrides: s.doc().map(|d| d.dim_overrides()).unwrap_or_default(),
assoc: Vec::new(),
marks: Default::default(),
})
}
@@ -371,6 +376,7 @@ fn chained(s: &Session, prev: &Dimension, next: Vec2, baseline: bool) -> EntityK
text: String::new(),
block: None,
assoc: Vec::new(),
marks: Default::default(),
user_text_pos: false,
..prev.clone()
})
@@ -1778,6 +1784,9 @@ impl Interactive for MLeaderM {
#[path = "annotate_tedit.rs"]
mod tedit;
#[path = "annotate_dimedit.rs"]
mod dimedit;
#[cfg(test)]
#[path = "annotate_tests.rs"]
mod tests;
+778
View File
@@ -0,0 +1,778 @@
//! Dimension edits: DIMEDIT (Home/New/Rotate/Oblique), DIMJOGGED, DIMJOGLINE, DIMBREAK and
//! DIMINSPECT. Each has a JSON form; the prompt sequences run the same JSON form.
use cadcraft_doc::{DimBreak, DimKind, Dimension, EntityKind, Handle, Inspection, JoggedRadius, Prim};
use cadcraft_geom::{Segment, Vec2};
use serde_json::{Value, json};
use super::machines::{SelOutcome, SelectPhase};
use super::*;
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Most objects DIMBREAK Auto looks at, and most breaks it adds to one dimension.
const MAX_BREAK_OBJECTS: usize = 20_000;
const MAX_AUTO_BREAKS: usize = 1000;
pub(super) fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("dimedit", "Dimension Edit", run_dimedit)
.alias(&["ded", "dimed"])
.params("{handles?, mode?: home|new|rotate|oblique (default home), text? (new: \"\" or \"<>\" = the measurement), angle?: degrees (rotate: text angle; oblique: extension line angle, absent = none)}")
.interactive(|_| Ok(Box::new(DimEditM::new(None)))),
CommandSpec::new("dimedit.oblique", "Oblique", |s, p| run_dimedit(s, &with_mode(p, "oblique")))
.menu(&["Dimension", "Oblique"])
.params("{handles?, angle?: degrees}")
.interactive(|_| Ok(Box::new(DimEditM::new(Some(Mode::Oblique))))),
CommandSpec::new("dimjogged", "Jogged", run_jogged)
.menu(&["Dimension", "Jogged"])
.alias(&["djo", "jog"])
.params("{handle (arc or circle), centerOverride: [x,y], at: [x,y] (dimension line location), jog?: [x,y], text?}")
.interactive(|_| Ok(Box::new(JoggedM::default()))),
CommandSpec::new("dimjogline", "Jogged Linear", run_jogline)
.menu(&["Dimension", "Jogged Linear"])
.alias(&["djl"])
.params("{handle (linear or aligned dimension), at?: [x,y] (jog location; default between the text and the first extension line), remove?: bool}")
.interactive(|_| Ok(Box::new(JogLineM::default()))),
CommandSpec::new("dimbreak", "Dimension Break", run_dimbreak)
.menu(&["Dimension", "Dimension Break"])
.params("{handles?, mode?: auto|manual|remove (default auto), objects?: [hex] (auto: the objects that break; default all crossing objects), first?/second?: [x,y] (manual), size?: number (default 0.125 × DIMSCALE)}")
.interactive(|_| Ok(Box::new(DimBreakM::default()))),
CommandSpec::new("diminspect", "Inspection", run_diminspect)
.menu(&["Dimension", "Inspection"])
.params("{handles?, shape?: round|angular|none (default round), label?: string, rate?: string (default \"100%\"), remove?: bool}")
.interactive(|_| Ok(Box::new(InspectM::default()))),
]
}
fn with_mode(p: &Value, mode: &str) -> Value {
let mut p = p.clone();
if let Some(o) = p.as_object_mut() {
o.insert("mode".into(), json!(mode));
}
p
}
fn handles_json(hs: &[Handle]) -> Value {
json!(hs.iter().map(|h| h.hex()).collect::<Vec<_>>())
}
/// Change each dimension of `hs` with `f` (its block regenerated); returns how many changed.
fn edit_dims(s: &mut Session, hs: &[Handle], f: &dyn Fn(&mut Dimension) -> bool) -> Result<usize> {
let d = s.doc_mut()?;
let mut n = 0;
for h in hs {
let mut changed = false;
d.modify_entity(*h, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind
&& f(dm)
{
dm.block = None;
changed = true;
}
})?;
n += usize::from(changed);
}
Ok(n)
}
fn is_linear(dm: &Dimension) -> bool {
matches!(dm.kind, DimKind::Linear { .. } | DimKind::Aligned)
}
// ---------------- DIMEDIT ----------------
#[derive(Clone, Copy, Debug, PartialEq)]
enum Mode {
Home,
New,
Rotate,
Oblique,
}
fn run_dimedit(s: &mut Session, p: &Value) -> Result<Value> {
let hs = dims_of(s, &targets(s, p)?);
if hs.is_empty() {
return Err(bad("dimedit", "select dimensions"));
}
let mode = str_param(p, "mode").unwrap_or("home").to_ascii_lowercase();
let angle = match p.get("angle") {
None | Some(Value::Null) => None,
Some(v) => Some(v.as_f64().filter(|a| a.is_finite()).ok_or_else(|| bad("dimedit", "`angle` must be a number (degrees)"))?.to_radians()),
};
let n = match mode.as_str() {
"home" => edit_dims(s, &hs, &|dm| {
dm.user_text_pos = false;
dm.text_rotation = 0.0;
dm.overrides.remove("textJust");
true
})?,
"new" => {
let t = str_param(p, "text").unwrap_or("").to_string();
// "<>" alone is the measurement, which is what no override text means.
let t = if t.trim() == "<>" { String::new() } else { t };
edit_dims(s, &hs, &|dm| {
dm.text = t.clone();
true
})?
}
"rotate" => {
let a = angle.ok_or_else(|| bad("dimedit", "`angle` (degrees) is required"))?;
edit_dims(s, &hs, &|dm| {
dm.text_rotation = cadcraft_geom::norm_angle(a);
true
})?
}
"oblique" => edit_dims(s, &hs, &|dm| {
if !is_linear(dm) {
return false;
}
dm.marks.oblique = angle.map(cadcraft_geom::norm_angle);
true
})?,
other => return Err(bad("dimedit", format!("unknown mode `{other}` (home, new, rotate, oblique)"))),
};
Ok(json!({ "changed": n }))
}
/// DIMEDIT at the command line: the kind of edit, then (New) the text or (Rotate) the angle,
/// the dimensions, and (Oblique) the extension line angle.
struct DimEditM {
mode: Option<Mode>,
text: Option<String>,
angle: Option<f64>,
selecting: Option<SelectPhase>,
picked: Vec<Handle>,
}
impl DimEditM {
fn new(mode: Option<Mode>) -> Self {
DimEditM { mode, text: None, angle: None, selecting: None, picked: Vec::new() }
}
/// "Select objects", or the pickfirst selection at once.
fn select(&mut self, s: &mut Session) -> Result<Step> {
let ph = SelectPhase::begin(s);
if ph.done {
return self.after_selection(s, ph.picked);
}
self.selecting = Some(ph);
Ok(Step::Continue)
}
fn run(&self, s: &mut Session, hs: &[Handle], angle: Option<f64>) -> Result<Step> {
let (mode, extra) = match self.mode {
Some(Mode::New) => ("new", json!({ "text": self.text.clone().unwrap_or_default() })),
Some(Mode::Rotate) => ("rotate", json!({ "angle": angle.unwrap_or(0.0).to_degrees() })),
Some(Mode::Oblique) => ("oblique", json!({ "angle": angle.map(f64::to_degrees) })),
_ => ("home", json!({})),
};
let mut p = extra;
if let Some(o) = p.as_object_mut() {
o.insert("handles".into(), handles_json(hs));
o.insert("mode".into(), json!(mode));
}
let r = run_dimedit(s, &p)?;
s.echo(format!("{} dimension(s) changed.", r.get("changed").and_then(Value::as_u64).unwrap_or(0)));
s.set_selection(Vec::new());
Ok(Step::Done)
}
fn after_selection(&mut self, s: &mut Session, hs: Vec<Handle>) -> Result<Step> {
let hs = dims_of(s, &hs);
if hs.is_empty() {
s.echo("No dimensions selected.");
return Ok(Step::Done);
}
if self.mode == Some(Mode::Oblique) {
self.picked = hs;
return Ok(Step::Continue);
}
self.run(s, &hs, self.angle)
}
}
impl Interactive for DimEditM {
fn name(&self) -> &'static str {
"DIMEDIT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
// Dimension ▸ Oblique asks for the dimensions first.
if self.mode == Some(Mode::Oblique) {
return self.select(s);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(sel) = &self.selecting {
return sel.prompt();
}
match self.mode {
None => Prompt::new("Enter type of dimension editing", Accept::TEXT).kw(&["Home", "New", "Rotate", "Oblique"]).default("Home"),
Some(Mode::New) => Prompt::new("Enter new dimension text", Accept::TEXT).default("<>"),
Some(Mode::Rotate) => Prompt::new("Specify angle for dimension text", Accept::NUMBER),
_ => Prompt::new("Enter obliquing angle (press ENTER for none)", Accept::NUMBER),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(sel) = &mut self.selecting {
return match sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
self.selecting = None;
self.after_selection(s, hs)
}
};
}
match (self.mode, i) {
(None, Input::Keyword(k)) => {
self.mode = match k.as_str() {
"New" => Some(Mode::New),
"Rotate" => Some(Mode::Rotate),
"Oblique" => Some(Mode::Oblique),
_ => Some(Mode::Home),
};
if matches!(self.mode, Some(Mode::Home | Mode::Oblique)) {
return self.select(s);
}
}
(None, Input::Enter) => {
self.mode = Some(Mode::Home);
return self.select(s);
}
(None, Input::Text(_)) => s.echo("Invalid option keyword."),
(Some(Mode::New), Input::Text(t)) if self.text.is_none() => {
self.text = Some(t);
return self.select(s);
}
(Some(Mode::New), Input::Enter) if self.text.is_none() => {
self.text = Some(String::new());
return self.select(s);
}
(Some(Mode::Rotate), Input::Text(t)) if self.angle.is_none() => match s.angle_settings().direction(&t) {
Some(a) => {
self.angle = Some(a);
return self.select(s);
}
None => s.echo("Requires a valid angle."),
},
(Some(Mode::Oblique), Input::Text(t)) if !self.picked.is_empty() => match s.angle_settings().direction(&t) {
Some(a) => return self.run(s, &self.picked.clone(), Some(a)),
None => s.echo("Requires a valid angle."),
},
(Some(Mode::Oblique), Input::Enter) if !self.picked.is_empty() => return self.run(s, &self.picked.clone(), None),
(_, Input::Cancel) => return Ok(Step::Cancel),
(Some(_), Input::Enter) => return Ok(Step::Cancel),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, _c: Vec2) -> Vec<EntityKind> {
Vec::new()
}
}
// ---------------- DIMJOGGED ----------------
/// A jogged radius dimension of the circle (`c`, `r`): the arrow where the direction of `at`
/// meets it, the line from `center` (the override) with a jog at `jog` (default halfway).
fn jogged_kind(s: &Session, c: Vec2, r: f64, center: Vec2, at: Vec2, jog: Option<Vec2>, text: &str) -> Option<EntityKind> {
let dir = (at - c).normalized();
if dir == Vec2::ZERO || !dir.is_finite() || r.is_nan() || r <= 0.0 {
return None;
}
let tip = c + dir * r;
let jog = jog.unwrap_or_else(|| center.mid(tip));
let mut k = dim(s, DimKind::Radius, c, Vec2::ZERO, Vec2::ZERO, tip, Vec2::ZERO, text);
if let EntityKind::Dimension(dm) = &mut k {
dm.marks.jogged = Some(JoggedRadius { center: v3(center), jog: v3(jog) });
if at.dist(c) > r + 1e-9 {
dm.text_mid = v3(at);
dm.user_text_pos = true;
}
}
Some(k)
}
fn run_jogged(s: &mut Session, p: &Value) -> Result<Value> {
let h = targets(s, p)?.first().copied().ok_or_else(|| bad("dimjogged", "`handle` of an arc or circle is required"))?;
let (c, r) = circle_of(s, h).ok_or_else(|| bad("dimjogged", "object is not a circle or arc"))?;
let center = point_req("dimjogged", p, "centerOverride")?;
let at = point_req("dimjogged", p, "at")?;
let k = jogged_kind(s, c, r, center, at, point_param(p, "jog"), str_param(p, "text").unwrap_or(""))
.ok_or_else(|| bad("dimjogged", "`at` must differ from the centre"))?;
Ok(json!({ "handle": add_dim(s, k)?.hex() }))
}
#[derive(Default)]
struct JoggedM {
circle: Option<(Vec2, f64)>,
center: Option<Vec2>,
at: Option<Vec2>,
text: DimText,
}
impl Interactive for JoggedM {
fn name(&self) -> &'static str {
"DIMJOGGED"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(p) = self.text.prompt() {
return p;
}
match (self.circle, self.center, self.at) {
(None, ..) => Prompt::new("Select arc or circle", Accept::POINT),
(Some((c, _)), None, _) => Prompt::new("Specify center location override", Accept::POINT).base(c),
(Some(_), Some(o), None) => Prompt::new("Specify dimension line location", Accept::POINT).kw(&["Mtext", "Text", "Angle"]).base(o),
(Some(_), Some(o), Some(_)) => Prompt::new("Specify jog location", Accept::POINT).base(o),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if self.text.answer(s, &i) {
return Ok(Step::Continue);
}
match (self.circle, self.center, self.at, i) {
(None, _, _, Input::Point(p)) => match pick_at(s, p).and_then(|h| circle_of(s, h)) {
Some(t) => self.circle = Some(t),
None => s.echo("Object selected is not a circle or arc."),
},
(Some(_), None, _, Input::Point(p)) => self.center = Some(p),
(Some(_), Some(_), None, Input::Keyword(k)) => {
self.text.keyword(&k);
}
(Some(_), Some(_), None, Input::Point(p)) => self.at = Some(p),
(Some((c, r)), Some(o), Some(at), Input::Point(j)) => {
if let Some(k) = jogged_kind(s, c, r, o, at, Some(j), "") {
add_dim(s, self.text.apply(k))?;
}
return Ok(Step::Done);
}
(_, _, _, Input::Enter | Input::Cancel) => return Ok(Step::Cancel),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, s: &Session, p: Vec2) -> Vec<EntityKind> {
let Some((c, r)) = self.circle else { return Vec::new() };
let (o, at, jog) = match (self.center, self.at) {
(Some(o), None) => (o, p, None),
(Some(o), Some(at)) => (o, at, Some(p)),
_ => return Vec::new(),
};
jogged_kind(s, c, r, o, at, jog, "").map(|k| vec![self.text.apply(k)]).unwrap_or_default()
}
}
// ---------------- DIMJOGLINE ----------------
/// Where a dimension line meets the first extension line, and the dimension line direction.
fn first_foot(dm: &Dimension) -> Option<(Vec2, Vec2)> {
let dir = match dm.kind {
DimKind::Linear { rotation } => Vec2::from_angle(rotation),
DimKind::Aligned => (dm.p14.xy() - dm.p13.xy()).normalized(),
_ => return None,
};
if dir == Vec2::ZERO || !dir.is_finite() {
return None;
}
let on = dm.defpt.xy();
Some((on + dir * (dm.p13.xy() - on).dot(dir), dir))
}
fn run_jogline(s: &mut Session, p: &Value) -> Result<Value> {
let hs = dims_of(s, &targets(s, p)?);
let h = *hs.first().ok_or_else(|| bad("dimjogline", "`handle` of a linear or aligned dimension is required"))?;
let d = s.doc()?;
let Some(EntityKind::Dimension(dm)) = d.entity(h).map(|e| &e.kind) else { return Err(bad("dimjogline", "not a dimension")) };
let (f1, dir) = first_foot(dm).ok_or_else(|| bad("dimjogline", "only linear and aligned dimensions take a jog"))?;
let remove = bool_or(p, "remove", false);
let at = match point_param(p, "at") {
Some(at) => at,
None => {
// Halfway between the text and the first extension line.
let t = cadcraft_render::dimension_in(d, dm).text_pos;
f1.mid(f1 + dir * (t - f1).dot(dir))
}
};
edit_dims(s, &[h], &|dm| {
dm.marks.jog_line = if remove { None } else { Some(v3(at)) };
true
})?;
Ok(json!({ "handle": h.hex() }))
}
#[derive(Default)]
struct JogLineM {
dim: Option<Handle>,
removing: bool,
}
impl Interactive for JogLineM {
fn name(&self) -> &'static str {
"DIMJOGLINE"
}
fn prompt(&self, _s: &Session) -> Prompt {
match (self.dim, self.removing) {
(None, false) => Prompt::new("Select dimension to add jog", Accept::SELECT).kw(&["Remove"]),
(None, true) => Prompt::new("Select jog to remove", Accept::SELECT),
(Some(_), _) => Prompt::new("Specify jog location (or press ENTER)", Accept::POINT),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
match (self.dim, i) {
(None, Input::Keyword(k)) if k == "Remove" => self.removing = true,
(None, Input::Pick(hs)) => {
let lin = |s: &Session, h: Handle| {
s.doc().ok().and_then(|d| d.entity(h)).is_some_and(|e| matches!(&e.kind, EntityKind::Dimension(dm) if is_linear(dm)))
};
match dims_of(s, &hs).into_iter().find(|h| lin(s, *h)) {
Some(h) if self.removing => {
run_jogline(s, &json!({ "handle": h.hex(), "remove": true }))?;
return Ok(Step::Done);
}
Some(h) => self.dim = Some(h),
None if hs.is_empty() => {}
None => s.echo("Select a linear or aligned dimension."),
}
}
(Some(h), Input::Point(at)) => {
run_jogline(s, &json!({ "handle": h.hex(), "at": [at.x, at.y] }))?;
return Ok(Step::Done);
}
(Some(h), Input::Enter) => {
run_jogline(s, &json!({ "handle": h.hex() }))?;
return Ok(Step::Done);
}
(_, Input::Enter | Input::Cancel) => return Ok(Step::Cancel),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, _c: Vec2) -> Vec<EntityKind> {
Vec::new()
}
}
// ---------------- DIMBREAK ----------------
/// Where the dimension's drawn lines cross the objects: (point, direction of the line there).
fn crossings(dm_lines: &[Vec<Vec2>], prims: &[Prim]) -> Vec<Vec2> {
let mut out = Vec::new();
for l in dm_lines {
for w in l.windows(2) {
let seg = Segment::Line(cadcraft_geom::Line::new(w[0], w[1]));
for p in prims {
let pts = match p {
Prim::Seg(o) => cadcraft_geom::intersect_segments(&seg, o),
Prim::Circle(c) => cadcraft_geom::line_circle(&cadcraft_geom::Line::new(w[0], w[1]), c)
.into_iter()
.filter(|(_, t)| (-1e-9..=1.0 + 1e-9).contains(t))
.map(|(q, _)| q)
.collect(),
Prim::Infinite { base, dir, ray } => cadcraft_geom::line_line_infinite(w[0], w[1], *base, *base + *dir)
.filter(|(_, t, u)| (-1e-9..=1.0 + 1e-9).contains(t) && (!ray || *u >= -1e-9))
.map(|(q, _, _)| vec![q])
.unwrap_or_default(),
_ => Vec::new(),
};
for q in pts {
if out.len() >= MAX_AUTO_BREAKS {
return out;
}
if !out.iter().any(|o: &Vec2| o.dist(q) < 1e-9) {
out.push(q);
}
}
}
}
}
out
}
fn run_dimbreak(s: &mut Session, p: &Value) -> Result<Value> {
let hs = dims_of(s, &targets(s, p)?);
if hs.is_empty() {
return Err(bad("dimbreak", "select dimensions"));
}
let mode = str_param(p, "mode").unwrap_or("auto").to_ascii_lowercase();
let d = s.doc()?;
let size = match size_param("dimbreak", p, "size", false)? {
Some(x) => x,
None => 0.125 * Some(d.header.f64("DIMSCALE", 1.0)).filter(|k| *k > 0.0 && *k < 1e12).unwrap_or(1.0),
};
match mode.as_str() {
"remove" => {
let n = edit_dims(s, &hs, &|dm| {
let had = !dm.marks.breaks.is_empty();
dm.marks.breaks.clear();
had
})?;
Ok(json!({ "changed": n }))
}
"manual" => {
let (a, b) = (point_req("dimbreak", p, "first")?, point_req("dimbreak", p, "second")?);
if a.near(b, 1e-12) {
return Err(bad("dimbreak", "`first` and `second` must differ"));
}
let br = DimBreak { at: v3(a.mid(b)), size: a.dist(b), auto: false };
let n = edit_dims(s, &hs[..1], &|dm| {
dm.marks.breaks.push(br.clone());
true
})?;
Ok(json!({ "changed": n }))
}
"auto" => {
// The breaking objects: those given, else every object (not a dimension or text).
let given: Option<Vec<Handle>> = match p.get("objects") {
None | Some(Value::Null) => None,
Some(_) => Some(targets(s, &json!({ "handles": p.get("objects") }))?),
};
let space = s.space();
let store = d.space(&space).ok_or_else(|| bad("dimbreak", "no current space"))?;
let others: Vec<(Handle, Vec<Prim>)> = store
.iter()
.filter(|e| match &given {
Some(g) => g.contains(&e.handle),
None => !matches!(e.kind, EntityKind::Dimension(_) | EntityKind::Text(_) | EntityKind::MText(_)),
})
.take(MAX_BREAK_OBJECTS)
.map(|e| (e.handle, e.kind.prims()))
.collect();
let mut plan = Vec::new();
for h in &hs {
let Some(EntityKind::Dimension(dm)) = d.entity(*h).map(|e| &e.kind) else { continue };
let mut plain = dm.clone();
plain.marks.breaks.clear();
let g = cadcraft_render::dimension_in(d, &plain);
let prims: Vec<Prim> = others.iter().filter(|(o, _)| o != h).flat_map(|(_, p)| p.iter().cloned()).collect();
plan.push((*h, crossings(&g.lines, &prims)));
}
let mut n = 0;
for (h, pts) in plan {
n += edit_dims(s, &[h], &|dm| {
dm.marks.breaks.retain(|b| !b.auto);
dm.marks.breaks.extend(pts.iter().map(|q| DimBreak { at: v3(*q), size, auto: true }));
true
})?;
}
Ok(json!({ "changed": n }))
}
other => Err(bad("dimbreak", format!("unknown mode `{other}` (auto, manual, remove)"))),
}
}
#[derive(Default)]
struct DimBreakM {
dims: Vec<Handle>,
multiple: Option<SelectPhase>,
objects: Vec<Handle>,
manual: Vec<Vec2>,
manual_mode: bool,
}
impl DimBreakM {
fn run(&self, s: &mut Session, mut p: Value) -> Result<Step> {
if let Some(o) = p.as_object_mut() {
o.insert("handles".into(), handles_json(&self.dims));
}
let r = run_dimbreak(s, &p)?;
s.echo(format!("{} dimension(s) changed.", r.get("changed").and_then(Value::as_u64).unwrap_or(0)));
s.set_selection(Vec::new());
Ok(Step::Done)
}
}
impl Interactive for DimBreakM {
fn name(&self) -> &'static str {
"DIMBREAK"
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(sel) = &self.multiple {
return sel.prompt();
}
if self.dims.is_empty() {
return Prompt::new("Select dimension to add/remove break", Accept::SELECT).kw(&["Multiple"]);
}
if self.manual_mode {
return Prompt::new(if self.manual.is_empty() { "Specify first break point" } else { "Specify second break point" }, Accept::POINT)
.base_opt(self.manual.first().copied());
}
if self.objects.is_empty() {
let kws: &[&str] = if self.dims.len() == 1 { &["Auto", "Manual", "Remove"] } else { &["Auto", "Remove"] };
Prompt::new("Select object to break dimension", Accept::SELECT).kw(kws).default("Auto")
} else {
Prompt::new("Select object to break dimension", Accept::SELECT)
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(sel) = &mut self.multiple {
return match sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
self.multiple = None;
self.dims = dims_of(s, &hs);
Ok(if self.dims.is_empty() { Step::Done } else { Step::Continue })
}
};
}
if self.dims.is_empty() {
match i {
Input::Keyword(k) if k == "Multiple" => self.multiple = Some(SelectPhase::default()),
Input::Pick(hs) => match dims_of(s, &hs).first() {
Some(h) => self.dims = vec![*h],
None if hs.is_empty() => {}
None => s.echo("Object selected is not a dimension."),
},
Input::Enter | Input::Cancel => return Ok(Step::Cancel),
_ => {}
}
return Ok(Step::Continue);
}
if self.manual_mode {
return match i {
Input::Point(p) if self.manual.is_empty() => {
self.manual.push(p);
Ok(Step::Continue)
}
Input::Point(p) => {
let a = self.manual.first().copied().unwrap_or(p);
self.run(s, json!({ "mode": "manual", "first": [a.x, a.y], "second": [p.x, p.y] }))
}
Input::Enter | Input::Cancel => Ok(Step::Cancel),
_ => Ok(Step::Continue),
};
}
match i {
Input::Keyword(k) if k == "Auto" => return self.run(s, json!({ "mode": "auto" })),
Input::Keyword(k) if k == "Remove" => return self.run(s, json!({ "mode": "remove" })),
Input::Keyword(k) if k == "Manual" => self.manual_mode = true,
Input::Pick(hs) => {
for h in hs {
if !self.dims.contains(&h) && !self.objects.contains(&h) {
self.objects.push(h);
}
}
}
Input::Enter if self.objects.is_empty() => return self.run(s, json!({ "mode": "auto" })),
Input::Enter => return self.run(s, json!({ "mode": "auto", "objects": handles_json(&self.objects) })),
Input::Cancel => return Ok(Step::Cancel),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, _c: Vec2) -> Vec<EntityKind> {
Vec::new()
}
}
// ---------------- DIMINSPECT ----------------
fn run_diminspect(s: &mut Session, p: &Value) -> Result<Value> {
let hs = dims_of(s, &targets(s, p)?);
if hs.is_empty() {
return Err(bad("diminspect", "select dimensions"));
}
if bool_or(p, "remove", false) {
let n = edit_dims(s, &hs, &|dm| dm.marks.inspect.take().is_some())?;
return Ok(json!({ "changed": n }));
}
let shape = match str_param(p, "shape").unwrap_or("round").to_ascii_lowercase().as_str() {
"round" => 0,
"angular" => 1,
"none" => 2,
other => return Err(bad("diminspect", format!("unknown shape `{other}` (round, angular, none)"))),
};
let cap = |t: &str| t.chars().take(256).collect::<String>();
let ins = Inspection { shape, label: cap(str_param(p, "label").unwrap_or("")), rate: cap(str_param(p, "rate").unwrap_or("100%")) };
let n = edit_dims(s, &hs, &|dm| {
dm.marks.inspect = Some(ins.clone());
true
})?;
Ok(json!({ "changed": n }))
}
/// DIMINSPECT at the command line: the dimensions, then shape (or Remove), label and rate.
#[derive(Default)]
struct InspectM {
selecting: Option<SelectPhase>,
dims: Vec<Handle>,
shape: Option<&'static str>,
label: Option<String>,
}
impl Interactive for InspectM {
fn name(&self) -> &'static str {
"DIMINSPECT"
}
fn begin(&mut self, s: &mut Session) -> Result<Step> {
let ph = SelectPhase::begin(s);
if ph.done {
self.dims = dims_of(s, &ph.picked);
if self.dims.is_empty() {
return Ok(Step::Done);
}
} else {
self.selecting = Some(ph);
}
Ok(Step::Continue)
}
fn prompt(&self, _s: &Session) -> Prompt {
if let Some(sel) = &self.selecting {
return sel.prompt();
}
match (self.shape, &self.label) {
(None, _) => Prompt::new("Enter inspection shape", Accept::TEXT).kw(&["Round", "Angular", "None", "Remove"]).default("Round"),
(Some(_), None) => Prompt::new("Enter label (press ENTER for none)", Accept::TEXT),
(Some(_), Some(_)) => Prompt::new("Enter inspection rate", Accept::TEXT).default("100%"),
}
}
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
if let Some(sel) = &mut self.selecting {
return match sel.feed(s, &i)? {
SelOutcome::More => Ok(Step::Continue),
SelOutcome::Empty => Ok(Step::Done),
SelOutcome::Done(hs) => {
self.selecting = None;
self.dims = dims_of(s, &hs);
if self.dims.is_empty() {
s.echo("No dimensions selected.");
return Ok(Step::Done);
}
Ok(Step::Continue)
}
};
}
let handles = handles_json(&self.dims);
match (self.shape, self.label.clone(), i) {
(None, _, Input::Keyword(k)) if k == "Remove" => {
run_diminspect(s, &json!({ "handles": handles, "remove": true }))?;
s.set_selection(Vec::new());
return Ok(Step::Done);
}
(None, _, Input::Keyword(k)) => {
self.shape = Some(match k.as_str() {
"Angular" => "angular",
"None" => "none",
_ => "round",
})
}
(None, _, Input::Enter) => self.shape = Some("round"),
(Some(_), None, Input::Text(t)) => self.label = Some(t),
(Some(_), None, Input::Enter) => self.label = Some(String::new()),
(Some(shape), Some(label), Input::Text(t)) => {
run_diminspect(s, &json!({ "handles": handles, "shape": shape, "label": label, "rate": t }))?;
s.set_selection(Vec::new());
return Ok(Step::Done);
}
(Some(shape), Some(label), Input::Enter) => {
run_diminspect(s, &json!({ "handles": handles, "shape": shape, "label": label }))?;
s.set_selection(Vec::new());
return Ok(Step::Done);
}
(_, _, Input::Cancel) => return Ok(Step::Cancel),
_ => {}
}
Ok(Step::Continue)
}
fn preview(&self, _s: &Session, _c: Vec2) -> Vec<EntityKind> {
Vec::new()
}
}
+3
View File
@@ -31,6 +31,7 @@ fn dim(kind: DimKind, p13: Vec2, p14: Vec2, defpt: Vec2) -> EntityKind {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
})
}
@@ -180,6 +181,7 @@ pub fn bracket() -> Drawing {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}),
);
add(
@@ -200,6 +202,7 @@ pub fn bracket() -> Drawing {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}),
);
+259
View File
@@ -0,0 +1,259 @@
//! DIMEDIT (Home/New/Rotate/Oblique), DIMJOGGED, DIMJOGLINE, DIMBREAK and DIMINSPECT: the JSON
//! forms, the prompt sequences and what they draw.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{Dimension, EntityKind, Handle};
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::render::{DimGeometry, LineRole};
use serde_json::{Value, json};
fn dim_of(s: &Session, h: &str) -> (Dimension, DimGeometry) {
let d = s.doc().unwrap();
match &d.entity(Handle::parse_hex(h).unwrap()).unwrap().kind {
EntityKind::Dimension(dm) => (dm.clone(), cadcraft_engine::render::dimension_in(d, dm)),
k => panic!("not a dimension: {k:?}"),
}
}
fn handle(r: &Value) -> String {
r["handle"].as_str().unwrap().to_string()
}
fn linear(s: &mut Session) -> String {
handle(&s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [10, 0], "at": [5, 3] })).unwrap())
}
/// Whether a line of the role on y = `y` covers `x`.
fn covers(g: &DimGeometry, role: LineRole, y: f64, x: f64) -> bool {
g.lines_of(role)
.any(|l| l.windows(2).any(|w| (w[0].y - y).abs() < 1e-9 && (w[1].y - y).abs() < 1e-9 && w[0].x.min(w[1].x) < x && x < w[0].x.max(w[1].x)))
}
#[test]
fn dimedit_modes() {
let mut s = Session::new();
let h = linear(&mut s);
s.execute("dimedit", &json!({ "handles": [h], "mode": "new", "text": "<> TYP" })).unwrap();
assert_eq!(dim_of(&s, &h).1.value, "10.0000 TYP");
s.execute("dimedit", &json!({ "handles": [h], "mode": "rotate", "angle": 30 })).unwrap();
assert!((dim_of(&s, &h).1.text_angle - 30f64.to_radians()).abs() < 1e-9);
s.execute("dimedit", &json!({ "handles": [h], "mode": "home" })).unwrap();
let (dm, g) = dim_of(&s, &h);
assert!(dm.text_rotation == 0.0 && !dm.user_text_pos && g.text_angle == 0.0);
// Oblique: extension lines at 60°, the measurement unchanged.
s.execute("dimedit", &json!({ "handles": [h], "mode": "oblique", "angle": 60 })).unwrap();
let (dm, g) = dim_of(&s, &h);
assert!((dm.marks.oblique.unwrap() - 60f64.to_radians()).abs() < 1e-9);
assert_eq!(g.value, "10.0000 TYP");
for l in g.lines_of(LineRole::Ext) {
let d = l[1] - l[0];
assert!((d.y.atan2(d.x).to_degrees() - 60.0).abs() < 1e-6, "{l:?}");
}
// The dimension line still runs along y = 3, between the oblique feet.
let foot = 3.0 / 60f64.to_radians().tan();
assert!(covers(&g, LineRole::Dim, 3.0, foot + 0.5) && covers(&g, LineRole::Dim, 3.0, 10.0 + foot - 0.5));
// No angle: back to perpendicular. Other kinds are left alone.
s.execute("dimedit", &json!({ "handles": [h], "mode": "oblique" })).unwrap();
assert_eq!(dim_of(&s, &h).0.marks.oblique, None);
s.execute("circle", &json!({ "center": [30, 0], "radius": 5 })).unwrap();
let c = s.doc().unwrap().model.last().unwrap().handle.hex();
let r = handle(&s.execute("dimradius", &json!({ "handle": c })).unwrap());
let n = s.execute("dimedit", &json!({ "handles": [r], "mode": "oblique", "angle": 60 })).unwrap();
assert_eq!(n["changed"], json!(0));
assert!(s.execute("dimedit", &json!({ "handles": [h], "mode": "bogus" })).is_err());
assert!(s.execute("dimedit", &json!({ "handles": [h], "mode": "rotate" })).is_err());
assert!(s.execute("dimedit", &json!({ "handles": [h], "mode": "rotate", "angle": "x" })).is_err());
}
#[test]
fn dimedit_prompts() {
let mut s = Session::new();
let h = linear(&mut s);
s.cmdline("dimedit").unwrap();
assert!(s.prompt_text().contains("[Home/New/Rotate/Oblique] <Home>"), "{}", s.prompt_text());
// Oblique: the dimensions first, then the angle.
s.script("o\n4,3\n\n45\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!((dim_of(&s, &h).0.marks.oblique.unwrap() - 45f64.to_radians()).abs() < 1e-9);
// Rotate: the angle first.
s.script("dimedit r 15\n4,3\n\n").unwrap();
assert!(s.running.is_none());
assert!((dim_of(&s, &h).0.text_rotation - 15f64.to_radians()).abs() < 1e-9);
// Home by default.
s.script("dimedit\n\n4,3\n\n").unwrap();
assert_eq!(dim_of(&s, &h).0.text_rotation, 0.0);
// The Dimension ▸ Oblique menu item asks for the dimensions straight away.
s.cmdline("dimedit.oblique").unwrap();
assert!(s.prompt_text().contains("Select objects"), "{}", s.prompt_text());
s.script("4,3\n\n\n").unwrap();
assert_eq!(dim_of(&s, &h).0.marks.oblique, None);
}
#[test]
fn jogged_radius() {
let mut s = Session::new();
s.execute("circle", &json!({ "center": [0, 0], "radius": 50 })).unwrap();
let c = s.doc().unwrap().model.last().unwrap().handle.hex();
let r = s.execute("dimjogged", &json!({ "handle": c, "centerOverride": [20, -10], "at": [40, 40], "jog": [30, 15] })).unwrap();
let h = handle(&r);
let (dm, g) = dim_of(&s, &h);
let j = dm.marks.jogged.clone().unwrap();
assert_eq!(g.value, "R50.0000", "the true radius");
assert!(j.center.xy().dist(Vec2::new(20.0, -10.0)) < 1e-9);
// The line runs from the arrowhead on the circle to the override, with a 45° jog (DIMJOGANG).
let tip = Vec2::new(1.0, 1.0).normalized() * 50.0;
let path = g.lines_of(LineRole::Dim).find(|l| l.len() == 4).unwrap().clone();
assert!(path[0].dist(tip) < 1e-9 && path[3].dist(Vec2::new(20.0, -10.0)) < 1e-9, "{path:?}");
let u = tip.normalized();
let jog = path[2] - path[1];
let angle = jog.normalized().dot(u).abs().acos().to_degrees();
assert!((angle - 45.0).abs() < 1e-6, "{angle}");
assert!((path[3] - path[2]).normalized().cross(u).abs() < 1e-9, "parallel to the first part");
// DIMJOGANG changes the jog.
s.execute("setvar", &json!({ "name": "DIMJOGANG", "value": 60 })).unwrap();
let h2 = handle(&s.execute("dimjogged", &json!({ "handle": c, "centerOverride": [20, -10], "at": [40, 40] })).unwrap());
let g = dim_of(&s, &h2).1;
let path = g.lines_of(LineRole::Dim).find(|l| l.len() == 4).unwrap().clone();
assert!(((path[2] - path[1]).normalized().dot(u).abs().acos().to_degrees() - 60.0).abs() < 1e-6);
// At the command line.
s.script("dimjogged 0,50\n10,-20\n0,70\n5,20\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let last = s.doc().unwrap().model.last().unwrap().handle.hex();
let (dm, _) = dim_of(&s, &last);
let j = dm.marks.jogged.unwrap();
assert!(j.center.xy().dist(Vec2::new(10.0, -20.0)) < 1e-9 && j.jog.xy().dist(Vec2::new(5.0, 20.0)) < 1e-9);
assert!(dm.p15.xy().dist(Vec2::new(0.0, 50.0)) < 1e-9);
// Not a circle: refused.
s.execute("line", &json!({ "points": [[100, 0], [110, 0]] })).unwrap();
let l = s.doc().unwrap().model.last().unwrap().handle.hex();
assert!(s.execute("dimjogged", &json!({ "handle": l, "centerOverride": [0, 0], "at": [1, 1] })).is_err());
}
#[test]
fn jogged_linear() {
let mut s = Session::new();
let h = linear(&mut s);
s.execute("dimjogline", &json!({ "handle": h, "at": [2, 3] })).unwrap();
let (dm, g) = dim_of(&s, &h);
assert!(dm.marks.jog_line.is_some());
// The line is cut around the jog and a zigzag drawn there (1.5 × the text height).
assert!(!covers(&g, LineRole::Dim, 3.0, 2.0));
let zig = g.lines_of(LineRole::Dim).find(|l| l.len() == 4).unwrap();
let h_jog = zig.iter().map(|p| p.y).fold(f64::MIN, f64::max) - zig.iter().map(|p| p.y).fold(f64::MAX, f64::min);
assert!((h_jog - 1.5 * 0.18).abs() < 1e-9, "{h_jog}");
// Default place: halfway between the first extension line and the text.
s.execute("dimjogline", &json!({ "handle": h })).unwrap();
assert!(dim_of(&s, &h).0.marks.jog_line.unwrap().xy().dist(Vec2::new(2.5, 3.0)) < 1e-9);
s.execute("dimjogline", &json!({ "handle": h, "remove": true })).unwrap();
assert_eq!(dim_of(&s, &h).0.marks.jog_line, None);
// Prompts: pick the dimension, then the jog location; Remove picks the jog to remove.
s.script("dimjogline 1,3\n8,3\n").unwrap();
assert!(s.running.is_none());
assert!(dim_of(&s, &h).0.marks.jog_line.unwrap().xy().dist(Vec2::new(8.0, 3.0)) < 1e-9);
s.script("dimjogline r\n1,3\n").unwrap();
assert!(s.running.is_none());
assert_eq!(dim_of(&s, &h).0.marks.jog_line, None);
// Radial dimensions take no jog.
s.execute("circle", &json!({ "center": [30, 0], "radius": 5 })).unwrap();
let c = s.doc().unwrap().model.last().unwrap().handle.hex();
let r = handle(&s.execute("dimradius", &json!({ "handle": c })).unwrap());
assert!(s.execute("dimjogline", &json!({ "handle": r })).is_err());
}
#[test]
fn breaks() {
let mut s = Session::new();
let h = linear(&mut s);
// A line crossing the dimension line and the second extension line.
s.execute("line", &json!({ "points": [[4, -1], [4, 6]] })).unwrap();
s.execute("line", &json!({ "points": [[8, 1], [12, 1]] })).unwrap();
s.execute("dimbreak", &json!({ "handles": [h] })).unwrap();
let (dm, g) = dim_of(&s, &h);
assert_eq!(dm.marks.breaks.len(), 2, "{:?}", dm.marks.breaks);
assert!(dm.marks.breaks.iter().all(|b| b.auto && (b.size - 0.125).abs() < 1e-12));
assert!(!covers(&g, LineRole::Dim, 3.0, 4.0) && covers(&g, LineRole::Dim, 3.0, 3.8));
let ext_at = |g: &DimGeometry, y: f64| {
g.lines_of(LineRole::Ext).any(|l| l.windows(2).any(|w| (w[0].x - 10.0).abs() < 1e-9 && w[0].y.min(w[1].y) < y && y < w[0].y.max(w[1].y)))
};
assert!(!ext_at(&g, 1.0) && ext_at(&g, 2.0), "extension line 2 broken at y = 1");
// Auto again doesn't pile up; only the chosen objects; manual; remove.
let first = s.doc().unwrap().model.iter().nth(1).unwrap().handle.hex();
s.execute("dimbreak", &json!({ "handles": [h], "objects": [first], "size": 0.5 })).unwrap();
assert_eq!(dim_of(&s, &h).0.marks.breaks.len(), 1);
s.execute("dimbreak", &json!({ "handles": [h], "mode": "manual", "first": [6, 3], "second": [7, 3] })).unwrap();
let (dm, g) = dim_of(&s, &h);
assert_eq!(dm.marks.breaks.len(), 2);
assert!(!covers(&g, LineRole::Dim, 3.0, 6.5) && covers(&g, LineRole::Dim, 3.0, 7.2));
s.execute("dimbreak", &json!({ "handles": [h], "mode": "remove" })).unwrap();
assert!(dim_of(&s, &h).0.marks.breaks.is_empty());
// Prompts: the dimension, then objects (Enter ends) or Auto/Manual/Remove.
s.script("dimbreak 1,3\n\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(dim_of(&s, &h).0.marks.breaks.len(), 2);
s.script("dimbreak 1,3 r\n").unwrap();
assert!(dim_of(&s, &h).0.marks.breaks.is_empty());
s.script("dimbreak 1,3 m 1,3 2,3\n").unwrap();
assert_eq!(dim_of(&s, &h).0.marks.breaks.len(), 1);
// Bad input.
assert!(s.execute("dimbreak", &json!({ "handles": [h], "size": -1 })).is_err());
assert!(s.execute("dimbreak", &json!({ "handles": [h], "mode": "manual", "first": [1, 1], "second": [1, 1] })).is_err());
assert!(s.execute("dimbreak", &json!({ "handles": [h], "mode": "nope" })).is_err());
}
#[test]
fn inspection_dimensions() {
let mut s = Session::new();
let h = linear(&mut s);
s.execute("diminspect", &json!({ "handles": [h], "label": "A", "rate": "50%" })).unwrap();
let (dm, g) = dim_of(&s, &h);
assert_eq!(dm.marks.inspect.as_ref().unwrap().shape, 0);
assert_eq!(g.value, "A 10.0000 50%");
// The frame: two long sides, two separators, two round ends.
let plain = {
let mut p = dm.clone();
p.marks.inspect = None;
cadcraft_engine::render::dimension_in(s.doc().unwrap(), &p)
};
assert_eq!(g.lines_of(LineRole::Dim).count(), plain.lines_of(LineRole::Dim).count() + 6);
s.execute("diminspect", &json!({ "handles": [h], "shape": "none", "label": "", "rate": "" })).unwrap();
let (_, g) = dim_of(&s, &h);
assert_eq!(g.value, "10.0000");
assert_eq!(g.lines_of(LineRole::Dim).count(), plain.lines_of(LineRole::Dim).count());
s.execute("diminspect", &json!({ "handles": [h], "remove": true })).unwrap();
assert_eq!(dim_of(&s, &h).0.marks.inspect, None);
assert!(s.execute("diminspect", &json!({ "handles": [h], "shape": "oval" })).is_err());
// Prompts: dimensions, shape, label, rate.
s.script("diminspect 1,3\n\na\nQC\n25%\n").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
let ins = dim_of(&s, &h).0.marks.inspect.unwrap();
assert_eq!((ins.shape, ins.label.as_str(), ins.rate.as_str()), (1, "QC", "25%"));
s.script("diminspect 1,3\n\nremove\n").unwrap();
assert_eq!(dim_of(&s, &h).0.marks.inspect, None);
}
#[test]
fn marks_follow_move_and_rotate_and_hostile_values_do_not_panic() {
let mut s = Session::new();
let h = linear(&mut s);
s.execute("dimjogline", &json!({ "handle": h, "at": [2, 3] })).unwrap();
s.execute("dimedit", &json!({ "handles": [h], "mode": "oblique", "angle": 60 })).unwrap();
s.execute("move", &json!({ "handles": [h], "from": [0, 0], "to": [10, 5] })).unwrap();
let (dm, _) = dim_of(&s, &h);
assert!(dm.marks.jog_line.unwrap().xy().dist(Vec2::new(12.0, 8.0)) < 1e-9);
s.execute("rotate", &json!({ "handles": [h], "base": [10, 5], "angle": 90 })).unwrap();
let (dm, _) = dim_of(&s, &h);
assert!((dm.marks.oblique.unwrap() - 150f64.to_radians()).abs() < 1e-9);
// Damaged marks still draw.
let d = s.doc_mut().unwrap();
let hh = Handle::parse_hex(&h).unwrap();
d.modify_entity(hh, |e| {
if let EntityKind::Dimension(dm) = &mut e.kind {
dm.marks.oblique = Some(f64::NAN);
dm.marks.jog_line = Some(cadcraft_engine::geom::Vec3::new(f64::INFINITY, 0.0, 0.0));
dm.marks.breaks.push(cadcraft_engine::doc::DimBreak { at: Default::default(), size: f64::NAN, auto: false });
dm.marks.inspect = Some(cadcraft_engine::doc::Inspection { shape: 9, label: "x".into(), rate: String::new() });
}
})
.unwrap();
let _ = dim_of(&s, &h);
}
+72
View File
@@ -453,6 +453,78 @@ pub(crate) fn arclen_xdata(center: cadcraft_geom::Vec3) -> Vec<Tag> {
vec![Tag::s(1000, "ARCLEN"), Tag::s(1002, "{"), Tag::f(1010, center.x), Tag::f(1020, center.y), Tag::f(1030, center.z), Tag::s(1002, "}")]
}
/// Dimension marks as CADCraft xdata (`1000 MARKS`, `1002 {`, items, `1002 }`): `1000 JOGLINE`
/// and a point; `1000 JOGGED`, the centre and the jog point; `1000 BREAK`, a point, `1040` size
/// and `1070` auto; `1000 INSPECT`, `1070` shape, `1000` label and `1000` rate. Points are
/// `1010/1020/1030`. The oblique angle is the standard group 52 instead.
pub(crate) fn marks_xdata(m: &cadcraft_doc::DimMarks) -> Vec<Tag> {
let pt = |v: &mut Vec<Tag>, p: cadcraft_geom::Vec3| v.extend([Tag::f(1010, p.x), Tag::f(1020, p.y), Tag::f(1030, p.z)]);
let mut v = Vec::new();
if let Some(j) = m.jog_line {
v.push(Tag::s(1000, "JOGLINE"));
pt(&mut v, j);
}
if let Some(j) = &m.jogged {
v.push(Tag::s(1000, "JOGGED"));
pt(&mut v, j.center);
pt(&mut v, j.jog);
}
for b in m.breaks.iter().take(MAX_XDATA_ITEMS / 8) {
v.push(Tag::s(1000, "BREAK"));
pt(&mut v, b.at);
v.extend([Tag::f(1040, b.size), Tag::i(1070, i64::from(b.auto))]);
}
if let Some(i) = &m.inspect {
v.extend([Tag::s(1000, "INSPECT"), Tag::i(1070, i64::from(i.shape)), Tag::s(1000, i.label.clone()), Tag::s(1000, i.rate.clone())]);
}
if v.is_empty() {
return v;
}
let mut out = vec![Tag::s(1000, "MARKS"), Tag::s(1002, "{")];
out.extend(v);
out.push(Tag::s(1002, "}"));
out
}
/// The marks written by [`marks_xdata`] (no oblique angle); empty for other writers. Damaged
/// items are skipped.
pub(crate) fn read_marks(tags: &[Tag]) -> cadcraft_doc::DimMarks {
let list = xdata_list(xdata(tags, APP), "MARKS");
let list = list.get(..list.len().min(MAX_XDATA_ITEMS)).unwrap_or(&[]);
let mut m = cadcraft_doc::DimMarks::default();
let num = |i: usize, code: i32| list.get(i).filter(|t| t.code == code).map(Tag::f64).filter(|x| x.is_finite());
let point = |i: usize| Some(cadcraft_geom::Vec3::new(num(i, 1010)?, num(i + 1, 1020)?, num(i + 2, 1030)?));
let text = |i: usize| list.get(i).filter(|t| t.code == 1000).map(Tag::str);
for (i, t) in list.iter().enumerate() {
if t.code != 1000 {
continue;
}
match t.str().as_str() {
"JOGLINE" => m.jog_line = point(i + 1).or(m.jog_line),
"JOGGED" => {
if let (Some(center), Some(jog)) = (point(i + 1), point(i + 4)) {
m.jogged = Some(cadcraft_doc::JoggedRadius { center, jog });
}
}
"BREAK" => {
let size = list.get(i + 4).filter(|t| t.code == 1040).map(Tag::f64).filter(|x| x.is_finite() && *x > 0.0);
let auto = list.get(i + 5).filter(|t| t.code == 1070).is_some_and(|t| t.i64() != 0);
if let (Some(at), Some(size)) = (point(i + 1), size) {
m.breaks.push(cadcraft_doc::DimBreak { at, size, auto });
}
}
"INSPECT" => {
let shape = list.get(i + 1).filter(|t| t.code == 1070).and_then(|t| u8::try_from(t.i64()).ok()).filter(|s| *s <= 2);
if let (Some(shape), Some(label), Some(rate)) = (shape, text(i + 2), text(i + 3)) {
m.inspect = Some(cadcraft_doc::Inspection { shape, label, rate });
}
}
_ => {}
}
}
m
}
/// The arc centre written by [`arclen_xdata`]; None for other dimensions and other writers.
pub(crate) fn read_arclen(tags: &[Tag]) -> Option<cadcraft_geom::Vec3> {
let list = xdata_list(xdata(tags, APP), "ARCLEN");
+11
View File
@@ -393,6 +393,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
block: t.s(2).filter(|s| !s.is_empty()),
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
})
}
"LEADER" => EntityKind::Leader(Leader {
@@ -928,6 +929,16 @@ fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &m
dm.kind = DimKind::ArcLength;
dm.p15 = center;
}
dm.marks = crate::dxf_ext::read_marks(tags);
// Group 52: the extension lines' angle relative to the dimension line (DIMEDIT Oblique).
let base = match dm.kind {
DimKind::Linear { rotation } => Some(rotation),
DimKind::Aligned => Some((dm.p14.xy() - dm.p13.xy()).angle()),
_ => None,
};
if let (Some(base), Some(o)) = (base, T(tags).f(52).filter(|o| o.is_finite() && o.rem_euclid(180.0) != 0.0)) {
dm.marks.oblique = Some(cadcraft_geom::norm_angle(base + o.to_radians()));
}
}
("ACAD_TABLE", EntityKind::Table(_)) => {
let t = T(tags);
+13
View File
@@ -190,12 +190,21 @@ fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) {
if !dm.assoc.is_empty() {
ours.extend(dxf_ext::assoc_xdata(&dm.assoc));
}
ours.extend(dxf_ext::marks_xdata(&dm.marks));
if !ours.is_empty() {
w.s(1001, dxf_ext::APP);
w.xdata(ours);
}
}
/// Group 52 of a linear or aligned dimension with oblique extension lines: their angle less the
/// dimension line's (`base`), in degrees.
fn oblique_52(w: &mut W, dm: &Dimension, base: f64) {
if let Some(a) = dm.marks.oblique.filter(|a| a.is_finite()) {
w.f(52, cadcraft_geom::norm_angle(a - base).to_degrees());
}
}
/// Header variables we write, with their group codes (DXF Reference, HEADER section).
/// `P2`/`P3` = 2D/3D point.
const HEADER_VARS: &[(&str, i32)] = &[
@@ -695,12 +704,16 @@ fn entity(w: &mut W, d: &Drawing, e: &Entity, owner: &str, paper: bool, cx: &Ctx
w.p(13, dm.p13);
w.p(14, dm.p14);
w.f(50, rotation.to_degrees());
oblique_52(w, dm, rotation);
w.s(100, "AcDbRotatedDimension");
}
DimKind::Aligned | DimKind::ArcLength => {
w.s(100, "AcDbAlignedDimension");
w.p(13, dm.p13);
w.p(14, dm.p14);
if matches!(dm.kind, DimKind::Aligned) {
oblique_52(w, dm, (dm.p14.xy() - dm.p13.xy()).angle());
}
}
DimKind::Radius => {
w.s(100, "AcDbRadialDimension");
+2
View File
@@ -93,6 +93,7 @@ fn sample() -> Drawing {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}),
)
.unwrap();
@@ -576,6 +577,7 @@ fn dim(kind: DimKind, p13: Vec3, p14: Vec3) -> Dimension {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}
}
@@ -26,6 +26,7 @@ fn linear(a: [f64; 2], b: [f64; 2], at: [f64; 2]) -> Dimension {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}
}
+1
View File
@@ -33,6 +33,7 @@ fn height_written(style_scale: f64, overrides: serde_json::Value) -> f64 {
block: None,
overrides: overrides.as_object().cloned().unwrap_or_default(),
assoc: Vec::new(),
marks: Default::default(),
};
d.add(&Space::Model, Common::default(), EntityKind::Dimension(dm)).unwrap();
block_mtext_height(&cadcraft_io::write_dxf(&d))
+94
View File
@@ -0,0 +1,94 @@
//! Dimension edits survive DXF: the oblique angle as group 52 (relative to the dimension line,
//! DXF Reference), jogs, breaks and inspection frames as CADCraft xdata. Damaged xdata is
//! skipped.
use cadcraft_doc::{Common, DimBreak, DimKind, DimMarks, Dimension, Drawing, EntityKind, Inspection, JoggedRadius, Space};
use cadcraft_geom::Vec3;
use cadcraft_io::{read_dxf, write_dxf};
fn dim(kind: DimKind, marks: DimMarks) -> EntityKind {
EntityKind::Dimension(Dimension {
kind,
defpt: Vec3::new(0.0, 3.0, 0.0),
text_mid: Vec3::ZERO,
p13: Vec3::ZERO,
p14: Vec3::new(10.0, 0.0, 0.0),
p15: Vec3::new(3.0, 4.0, 0.0),
p16: Vec3::ZERO,
text: String::new(),
style: "Standard".into(),
measurement: 0.0,
text_rotation: 0.0,
user_text_pos: false,
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks,
})
}
fn marks_of(d: &Drawing) -> Vec<DimMarks> {
d.model
.iter()
.filter_map(|e| match &e.kind {
EntityKind::Dimension(dm) => Some(dm.marks.clone()),
_ => None,
})
.collect()
}
#[test]
fn dimension_marks_roundtrip() {
let mut d = Drawing::new_imperial();
let linear = DimMarks {
oblique: Some(60f64.to_radians()),
jog_line: Some(Vec3::new(2.0, 3.0, 0.0)),
breaks: vec![
DimBreak { at: Vec3::new(4.0, 3.0, 0.0), size: 0.125, auto: true },
DimBreak { at: Vec3::new(7.0, 3.0, 0.0), size: 0.5, auto: false },
],
inspect: Some(Inspection { shape: 1, label: "QC".into(), rate: "25%".into() }),
..Default::default()
};
let aligned = DimMarks { oblique: Some(100f64.to_radians()), ..Default::default() };
let jogged = DimMarks { jogged: Some(JoggedRadius { center: Vec3::new(1.0, -2.0, 0.0), jog: Vec3::new(2.0, 1.0, 0.0) }), ..Default::default() };
for (k, m) in [(DimKind::Linear { rotation: 0.0 }, &linear), (DimKind::Aligned, &aligned), (DimKind::Radius, &jogged)] {
d.add(&Space::Model, Common::default(), dim(k, m.clone())).unwrap();
}
let text = write_dxf(&d);
// The oblique angle is the standard group 52, not xdata.
let lines: Vec<&str> = text.lines().map(str::trim).collect();
assert!(lines.windows(2).any(|w| w[0] == "52" && (w[1].parse::<f64>().unwrap() - 60.0).abs() < 1e-9));
let back = read_dxf(text.as_bytes()).unwrap();
let got = marks_of(&back);
assert_eq!(got.len(), 3);
let close = |a: Option<f64>, b: Option<f64>| a.zip(b).is_some_and(|(a, b)| (a - b).abs() < 1e-9);
assert!(close(got[0].oblique, linear.oblique) && close(got[1].oblique, aligned.oblique));
assert_eq!(DimMarks { oblique: None, ..got[0].clone() }, DimMarks { oblique: None, ..linear.clone() });
assert_eq!(got[2], jogged);
// And again: nothing piles up.
assert_eq!(marks_of(&read_dxf(write_dxf(&back).as_bytes()).unwrap()).len(), 3);
}
#[test]
fn damaged_marks_are_skipped() {
let mut d = Drawing::new_imperial();
let m = DimMarks {
jog_line: Some(Vec3::new(2.0, 3.0, 0.0)),
breaks: vec![DimBreak { at: Vec3::new(4.0, 3.0, 0.0), size: 0.125, auto: false }],
inspect: Some(Inspection { shape: 0, label: "A".into(), rate: "B".into() }),
..Default::default()
};
d.add(&Space::Model, Common::default(), dim(DimKind::Linear { rotation: 0.0 }, m)).unwrap();
let text = write_dxf(&d);
// A break size of NaN, an inspection shape out of range, a jog point cut short.
let bad = text.replacen("1040\r\n0.125\r\n", "1040\r\nnan\r\n", 1).replacen("INSPECT\r\n1070\r\n0\r\n", "INSPECT\r\n1070\r\n77\r\n", 1).replacen(
"JOGLINE\r\n1010\r\n",
"JOGLINE\r\n1000\r\n",
1,
);
assert_ne!(bad, text);
let back = read_dxf(bad.as_bytes()).unwrap();
let got = marks_of(&back);
assert_eq!(got.first(), Some(&DimMarks::default()), "{got:?}");
}
@@ -59,6 +59,7 @@ fn dimension(overrides: serde_json::Value) -> EntityKind {
block: None,
overrides: overrides.as_object().cloned().unwrap_or_default(),
assoc: Vec::new(),
marks: Default::default(),
})
}
+163 -4
View File
@@ -12,6 +12,9 @@ use cadcraft_geom::{Arc, Vec2, ccw_sweep};
use crate::units::{NumFormat, format_angle, format_decimal, format_linear};
/// Most DIMBREAK gaps drawn per dimension.
const MAX_BREAKS: usize = 1000;
/// What a dimension line primitive is (for DIMCLRD / DIMCLRE colours).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineRole {
@@ -58,6 +61,26 @@ impl DimGeometry {
self.line_roles.push(LineRole::Ext);
}
}
/// Leave out the parts of the lines (of `role`, or all) within `r` of `c`.
fn cut(&mut self, c: Vec2, r: f64, role: Option<LineRole>) {
if !(r > 0.0 && r.is_finite() && c.is_finite()) {
return;
}
let mut lines = Vec::with_capacity(self.lines.len());
let mut roles = Vec::with_capacity(self.lines.len());
let mut ext2 = Vec::new();
for (i, (l, rl)) in self.lines.iter().zip(&self.line_roles).enumerate() {
let pieces = if role.is_none_or(|x| x == *rl) { cut_polyline(l, c, r) } else { vec![l.clone()] };
for p in pieces {
if self.ext2_lines.contains(&i) {
ext2.push(lines.len());
}
lines.push(p);
roles.push(*rl);
}
}
(self.lines, self.line_roles, self.ext2_lines) = (lines, roles, ext2);
}
/// Extension line 1 or 2 (`second`).
fn ext_n(&mut self, l: Option<Vec<Vec2>>, second: bool) {
if let Some(l) = l.filter(|l| l.len() >= 2) {
@@ -263,6 +286,44 @@ pub fn arrowhead(kind: Arrowhead, tip: Vec2, dir: Vec2, size: f64) -> ArrowGeom
g
}
/// The pieces of a polyline outside the circle (`c`, `r`).
fn cut_polyline(l: &[Vec2], c: Vec2, r: f64) -> Vec<Vec<Vec2>> {
let mut out: Vec<Vec<Vec2>> = Vec::new();
let mut keep = |a: Vec2, b: Vec2| {
if a.dist(b) <= 1e-12 {
return;
}
match out.last_mut() {
Some(last) if last.last() == Some(&a) => last.push(b),
_ => out.push(vec![a, b]),
}
};
for w in l.windows(2) {
let (p, q) = (w[0], w[1]);
let dv = q - p;
let (qa, f) = (dv.dot(dv), p - c);
let (qb, qc) = (2.0 * f.dot(dv), f.dot(f) - r * r);
let disc = qb * qb - 4.0 * qa * qc;
if qa < 1e-24 || disc.is_nan() || disc <= 0.0 {
keep(p, q);
continue;
}
let sq = disc.sqrt();
let (t0, t1) = ((-qb - sq) / (2.0 * qa), (-qb + sq) / (2.0 * qa));
if t1 <= 0.0 || t0 >= 1.0 {
keep(p, q);
continue;
}
if t0 > 0.0 {
keep(p, p + dv * t0);
}
if t1 < 1.0 {
keep(p + dv * t1, q);
}
}
out
}
/// A closed filled arrowhead at `tip` pointing along `dir` (unit), as one triangle.
pub fn arrow(tip: Vec2, dir: Vec2, size: f64) -> Vec<Vec2> {
let back = tip - dir * size;
@@ -331,7 +392,15 @@ fn linear_text(meas: f64, d: &Dimension, st: &DimStyle, prefix: &str) -> String
let alt = format_linear(v * st.alt_factor, &af).1;
s += &format!(" [{}]", post(&st.alt_post, &alt));
}
apply_override(&s, d)
inspected(apply_override(&s, d), d)
}
/// DIMINSPECT: the label and the rate either side of the value, two spaces apart.
fn inspected(value: String, d: &Dimension) -> String {
match &d.marks.inspect {
Some(ins) => [ins.label.as_str(), value.as_str(), ins.rate.as_str()].iter().filter(|p| !p.is_empty()).copied().collect::<Vec<_>>().join(" "),
None => value,
}
}
/// `\H` code for stacked tolerance parts (DIMTFAC relative to the 70% stack scale).
@@ -445,11 +514,19 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
_ => (p2 - p1).normalized(),
};
let dir = if dir == Vec2::ZERO || !dir.is_finite() { Vec2::X } else { dir };
let foot = |p: Vec2| on + dir * (p - on).dot(dir);
// DIMEDIT Oblique: extension lines at their own angle (not along the dimension line).
let oblique = d.marks.oblique.map(Vec2::from_angle).filter(|e| e.is_finite() && e.cross(dir).abs() > 1e-6);
let foot = |p: Vec2| match oblique {
Some(e) => p + e * ((on - p).cross(dir) / e.cross(dir)),
None => on + dir * (p - on).dot(dir),
};
let f1 = foot(p1);
let f2 = foot(p2);
let meas = f1.dist(f2);
// The measurement is along the dimension direction whatever the extension lines do.
let meas = (p2 - p1).dot(dir).abs();
g.mtext = linear_text(meas, d, st, "");
// From here on: the length drawn between the extension lines.
let meas = f1.dist(f2);
if !st.suppress_ext1 {
g.ext_n(ext_line(p1, f1, st, k), false);
}
@@ -607,6 +684,47 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
g.dim(vec![base, text_mid + side * (tw / 2.0 + gap)]);
}
}
// DIMJOGLINE: a zigzag in the dimension line, 1.5 text heights tall.
if let Some(j) = d.marks.jog_line {
let at = f1 + u * (j.xy() - f1).dot(u);
let h = 1.5 * th;
if at.is_finite() && h > 0.0 {
let r = h / 2.0;
g.cut(at, r, Some(LineRole::Dim));
g.dim(vec![at - u * r, at - u * (r / 4.0) + n * (h / 2.0), at + u * (r / 4.0) - n * (h / 2.0), at + u * r]);
}
}
}
DimKind::Radius if d.marks.jogged.is_some() => {
// DIMJOGGED: from the arrowhead on the arc toward the centre, a jog at DIMJOGANG,
// then on to the overriding centre (parallel to the first part).
let center = d.defpt.xy();
let tip = d.p15.xy();
let r = center.dist(tip);
let j = d.marks.jogged.clone().unwrap_or_default();
let (o, jp) = (j.center.xy(), j.jog.xy());
g.mtext = linear_text(r, d, st, "R");
let (tw, _) = measure(&g.mtext);
let u = (tip - center).normalized();
let u = if u == Vec2::ZERO || !u.is_finite() { Vec2::X } else { u };
let n = u.perp();
let h = (o - center).dot(n);
let ang = if st.jog_angle.is_finite() { st.jog_angle.clamp(5.0, 90.0) } else { 45.0 }.to_radians();
let run = h.abs() / ang.tan().max(1e-6);
let tj = (jp - center).dot(u).clamp(0.0, r);
let (j1, j2) = (center + u * (tj + run / 2.0), center + u * (tj - run / 2.0) + n * h);
g.dim(vec![tip, j1, j2, o]);
end(&mut g, blk2, tip, u);
let tpos = if d.user_text_pos { text_mid } else { tip + u * (asz.max(tsz) * 3.0) };
if tpos.dist(center) > r {
g.dim(vec![tip, tpos]);
}
text_angle = if st.text_outside_horizontal { 0.0 } else { readable(u.angle()) };
if !d.user_text_pos {
let x = Vec2::from_angle(text_angle);
let x = if x.dot(u) >= 0.0 { x } else { -x };
text_mid = tpos + x * (tw / 2.0 + gap);
}
}
DimKind::Radius | DimKind::Diameter => {
let a = d.defpt.xy();
@@ -698,7 +816,7 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
let azin = (st.angular_zero_suppression & 3) * 4;
let txt = format_angle(sweep, st.angular_unit, st.angular_decimals, azin);
// DIMPOST is a linear-dimension prefix/suffix; angles have their own format only.
g.mtext = apply_override(&txt, d);
g.mtext = inspected(apply_override(&txt, d), d);
let mut pts = Vec::new();
arc.tessellate(r * 1e-3, &mut pts);
g.dim(pts);
@@ -786,6 +904,46 @@ pub fn dimension_geometry_with(d: &Dimension, st: &DimStyle, dimscale: f64, font
if d.text_rotation != 0.0 && d.text_rotation.is_finite() {
text_angle = d.text_rotation;
}
// DIMBREAK: gaps in the dimension and extension lines.
for b in d.marks.breaks.iter().take(MAX_BREAKS) {
g.cut(b.at.xy(), b.size / 2.0, None);
}
// DIMINSPECT: a frame round the text, with separators between label, value and rate.
if let Some(ins) = &d.marks.inspect
&& ins.shape < 2
&& !g.mtext.is_empty()
{
let (w, h) = measure(&g.mtext);
let (x, y) = (Vec2::from_angle(text_angle), Vec2::from_angle(text_angle).perp());
let at = |px: f64, py: f64| text_mid + x * px + y * py;
let (hw, hh) = (w / 2.0 + gap, h / 2.0 + gap);
// Widths of the label, value and rate as laid out: the two spaces between parts measured
// in "x x", the value is what the rest leaves.
let gap2 = (measure("x x").0 - 2.0 * measure("x").0).max(0.0);
let (wl, wr) = (measure(&ins.label).0, measure(&ins.rate).0);
let seps = usize::from(!ins.label.is_empty()) + usize::from(!ins.rate.is_empty());
let wv = (w - wl - wr - gap2 * seps as f64).max(0.0);
let mut left = -w / 2.0;
if !ins.label.is_empty() {
left += wl;
g.dim(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]);
left += gap2;
}
if !ins.rate.is_empty() {
left += wv;
g.dim(vec![at(left + gap2 / 2.0, -hh), at(left + gap2 / 2.0, hh)]);
}
g.dim(vec![at(-hw, hh), at(hw, hh)]);
g.dim(vec![at(-hw, -hh), at(hw, -hh)]);
for side in [-1.0, 1.0] {
let ends: Vec<Vec2> = if ins.shape == 0 {
(0..=12).map(|i| f64::from(i) * std::f64::consts::PI / 12.0).map(|a| at(side * (hw + hh * a.sin()), hh * a.cos())).collect()
} else {
vec![at(side * hw, hh), at(side * (hw + hh), 0.0), at(side * hw, -hh)]
};
g.dim(ends);
}
}
g.value = decode_value(&cadcraft_fonts::plain_mtext(&g.mtext));
if !g.mtext.is_empty() {
let l = cadcraft_fonts::layout_mtext_with(&g.mtext, &params(text_mid, text_angle));
@@ -826,6 +984,7 @@ mod tests {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}
}
+1
View File
@@ -317,6 +317,7 @@ fn lin_dim() -> cadcraft_doc::Dimension {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}
}
@@ -22,6 +22,7 @@ fn dim(kind: DimKind) -> Dimension {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}
}
@@ -21,6 +21,7 @@ fn text(vars: serde_json::Value) -> (String, String) {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
};
let mut st = DimStyle::default();
let rejected = st.apply_fields(vars.as_object().unwrap());
+1
View File
@@ -22,6 +22,7 @@ fn linear(len: f64) -> Dimension {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
}
}
+1
View File
@@ -46,6 +46,7 @@ fn dimensions_take_the_viewport_layer_colour() {
block: None,
overrides: Default::default(),
assoc: Vec::new(),
marks: Default::default(),
};
d.add(&Space::Model, on("A"), EntityKind::Dimension(dim)).unwrap();
d.add(&Space::Model, on("A"), EntityKind::Line(Line { a: Vec3::new(0.0, -1.0, 0.0), b: Vec3::new(10.0, -1.0, 0.0) })).unwrap();
+6
View File
@@ -183,6 +183,8 @@
Deselect All Скасувати вибір
Diameter Діаметр
Dim style Розмірний стиль
Dimension Break Розрив розміру
Dimension Edit Редагувати розмір
Dimension Розміри
Dimension Objects Only Лише розміри
Dimension Space Відстань між розмірами
@@ -372,6 +374,7 @@
Insertion Z Вставлення Z
Inspect Constraints Переглянути обмеження
Inspect Drawing Переглянути креслення
Inspection Контрольний розмір
Interface Language Мова інтерфейсу
Interface Theme Тема інтерфейсу
Intersect Перетин
@@ -380,6 +383,8 @@
Isolate Layer Ізолювати шар
Isolate Objects Ізолювати об'єкти
Isometric Drafting Ізометричне креслення
Jogged Зі зламом
Jogged Linear Лінійний зі зламом
Join Об'єднати
Join the ArtCraft Discord… Приєднатися до Discord ArtCraft…
Join us on Discord Приєднуйтеся до нас у Discord
@@ -512,6 +517,7 @@
Object Snap Tracking (F11) Відстеження об'єктної прив'язки (F11)
Object Snap Tracking Settings Параметри відстеження об'єктної прив'язки
Object type: Тип об'єкта:
Oblique Похилий
Off Вимкнено
Offset Зміщення
On Увімкнено
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
183
184
185
186
187
188
189
190
374
375
376
377
378
379
380
383
384
385
386
387
388
389
390
517
518
519
520
521
522
523