diff --git a/crates/doc/src/anno.rs b/crates/doc/src/anno.rs new file mode 100644 index 0000000..e1a6fdf --- /dev/null +++ b/crates/doc/src/anno.rs @@ -0,0 +1,239 @@ +//! Annotative scaling. +//! +//! An annotation scale ("1:50") says how many drawing units one paper unit stands for. The +//! drawing keeps a list of them (SCALELISTEDIT) and a current one (`CANNOSCALE`). An annotative +//! object (text, MText, dimension, multileader, hatch, block reference) stores its size as it +//! should measure on paper and the set of scales it supports ([`Common::anno_scales`]); in a view +//! it draws at paper size × that view's annotation scale, and only in views whose scale it +//! supports unless `ANNOALLVISIBLE` is 1. Model space is seen at `CANNOSCALE`, a layout viewport +//! at its viewport scale, and paper space itself at 1:1. + +use serde::{Deserialize, Serialize}; + +use crate::{Common, Drawing, Entity, EntityKind}; + +/// A named annotation scale: `paper_units` on paper show `drawing_units` of the drawing. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AnnoScale { + pub name: String, + pub paper_units: f64, + pub drawing_units: f64, +} + +/// The most scales a drawing's list or one object holds (hostile files and parameters). +pub const MAX_SCALES: usize = 1000; + +impl AnnoScale { + pub fn new(name: &str, paper_units: f64, drawing_units: f64) -> Self { + AnnoScale { name: name.to_string(), paper_units, drawing_units } + } + /// The 1:1 scale. + pub fn unit() -> Self { + AnnoScale::new("1:1", 1.0, 1.0) + } + /// Both units finite and positive (and their ratio usable). + pub fn is_valid(&self) -> bool { + let ok = |v: f64| v.is_finite() && v > 0.0; + ok(self.paper_units) && ok(self.drawing_units) && ok(self.drawing_units / self.paper_units) + } + /// Drawing units per paper unit (1:50 → 50): what an annotative object's paper size is + /// multiplied by. 1 for an invalid scale. + pub fn factor(&self) -> f64 { + if self.is_valid() { self.drawing_units / self.paper_units } else { 1.0 } + } + /// Whether this scale has the ratio `factor` (drawing units per paper unit). + pub fn has_factor(&self, factor: f64) -> bool { + same_factor(self.factor(), factor) + } +} + +/// Two scale factors are the same ratio (relative tolerance). +pub fn same_factor(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1e-12) +} + +/// The default metric scale list. +pub fn metric_scales() -> Vec { + let mut v: Vec = + [1.0, 2.0, 4.0, 5.0, 8.0, 10.0, 16.0, 20.0, 30.0, 40.0, 50.0, 100.0].iter().map(|n| AnnoScale::new(&format!("1:{n}"), 1.0, *n)).collect(); + v.extend([2.0, 4.0, 8.0, 10.0, 100.0].iter().map(|n| AnnoScale::new(&format!("{n}:1"), *n, 1.0))); + v +} + +/// The default imperial scale list: the metric ratios, then the architectural inch-to-foot +/// scales. +pub fn imperial_scales() -> Vec { + let mut v = metric_scales(); + let arch: [(&str, f64); 15] = [ + ("1/128\" = 1'-0\"", 1.0 / 128.0), + ("1/64\" = 1'-0\"", 1.0 / 64.0), + ("1/32\" = 1'-0\"", 1.0 / 32.0), + ("1/16\" = 1'-0\"", 1.0 / 16.0), + ("3/32\" = 1'-0\"", 3.0 / 32.0), + ("1/8\" = 1'-0\"", 1.0 / 8.0), + ("3/16\" = 1'-0\"", 3.0 / 16.0), + ("1/4\" = 1'-0\"", 0.25), + ("3/8\" = 1'-0\"", 0.375), + ("1/2\" = 1'-0\"", 0.5), + ("3/4\" = 1'-0\"", 0.75), + ("1\" = 1'-0\"", 1.0), + ("1-1/2\" = 1'-0\"", 1.5), + ("3\" = 1'-0\"", 3.0), + ("6\" = 1'-0\"", 6.0), + ]; + v.extend(arch.iter().map(|(n, p)| AnnoScale::new(n, *p, 12.0))); + v.push(AnnoScale::new("1'-0\" = 1'-0\"", 12.0, 12.0)); + v +} + +/// Kinds that can be annotative. +pub fn can_be_annotative(k: &EntityKind) -> bool { + matches!( + k, + EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Dimension(_) | EntityKind::MLeader(_) | EntityKind::Hatch(_) | EntityKind::Insert(_) + ) +} + +/// An annotative object: a kind that can be annotative, with at least one supported scale. +pub fn is_annotative(e: &Entity) -> bool { + !e.common.anno_scales.is_empty() && can_be_annotative(&e.kind) +} + +/// Make `e` annotative (supporting `current` when it supports no scale yet) or not. Kinds that +/// can't be annotative are left alone. +pub fn set_annotative(e: &mut Entity, on: bool, current: &AnnoScale) { + if !on { + e.common.anno_scales.clear(); + } else if can_be_annotative(&e.kind) && e.common.anno_scales.is_empty() { + e.common.anno_scales.push(current.clone()); + } +} + +/// How an object draws in a view. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum AnnoDraw { + /// Not annotative: as stored. + Plain, + /// Annotative, at this factor (drawing units per paper unit). + Scaled(f64), + /// Annotative and not supporting the view's scale, with `ANNOALLVISIBLE` 0. + Hidden, +} + +impl Common { + /// Whether these properties list a scale with the ratio `factor`. + pub fn supports_factor(&self, factor: f64) -> bool { + self.anno_scales.iter().any(|s| s.has_factor(factor)) + } + /// Whether these properties list the scale named `name`. + pub fn supports_scale(&self, name: &str) -> bool { + self.anno_scales.iter().any(|s| s.name.eq_ignore_ascii_case(name)) + } +} + +/// How `e` draws in a view whose annotation scale is `view` (drawing units per paper unit). An +/// object that doesn't support the view's scale shows at its first scale when `all_visible`. +pub fn anno_draw(e: &Entity, view: f64, all_visible: bool) -> AnnoDraw { + if !is_annotative(e) { + return AnnoDraw::Plain; + } + if e.common.supports_factor(view) { + return AnnoDraw::Scaled(if view.is_finite() && view > 0.0 { view } else { 1.0 }); + } + match e.common.anno_scales.first() { + Some(s) if all_visible => AnnoDraw::Scaled(s.factor()), + _ => AnnoDraw::Hidden, + } +} + +/// `e` as it draws at annotation scale `f`: its paper sizes (text height and width, dimension +/// overall scale, multileader arrow, landing and content, hatch pattern scale, block scale and +/// attributes) multiplied by `f`. A dimension is drawn from its definition at that scale, not +/// from a stored geometry block. Other kinds come back unchanged. +pub fn anno_scaled(e: &Entity, f: f64) -> Entity { + let f = if f.is_finite() && f > 0.0 { f } else { 1.0 }; + let mut out = e.clone(); + match &mut out.kind { + EntityKind::Text(t) => t.height *= f, + EntityKind::MText(t) => { + t.height *= f; + t.width *= f; + } + EntityKind::Dimension(dm) => { + dm.overrides.insert("scale".into(), serde_json::json!(f)); + dm.block = None; + } + EntityKind::MLeader(m) => { + let land = m.landing; + m.arrow_size *= f; + m.dogleg *= f; + if let Some(t) = &mut m.text { + t.insert = land + (t.insert - land) * f; + t.height *= f; + t.width *= f; + } + if let Some(ins) = &mut m.block { + ins.insert = land + (ins.insert - land) * f; + ins.scale = ins.scale * f; + } + } + EntityKind::Hatch(h) if !h.solid => h.scale *= f, + EntityKind::Insert(ins) => { + let o = ins.insert; + ins.scale = ins.scale * f; + ins.col_spacing *= f; + ins.row_spacing *= f; + for a in &mut ins.attribs { + a.text.insert = o + (a.text.insert - o) * f; + a.text.align_pt = a.text.align_pt.map(|p| o + (p - o) * f); + a.text.height *= f; + } + } + _ => {} + } + out +} + +impl Drawing { + /// The scale named `name` in the drawing's scale list (case-insensitive). + pub fn anno_scale(&self, name: &str) -> Option<&AnnoScale> { + let n = name.trim(); + self.scales.iter().find(|s| s.name.eq_ignore_ascii_case(n)) + } + /// The current annotation scale (`CANNOSCALE`); 1:1 when it names no scale of the list. + pub fn current_anno_scale(&self) -> AnnoScale { + let name = self.header.str("CANNOSCALE", "1:1"); + self.anno_scale(&name).filter(|s| s.is_valid()).cloned().unwrap_or_else(AnnoScale::unit) + } + /// Whether annotative objects that don't support the view's scale show (`ANNOALLVISIBLE`). + pub fn anno_all_visible(&self) -> bool { + self.header.i64("ANNOALLVISIBLE", 1) != 0 + } + /// How top-level object `e` draws outside a viewport: in a layout's paper space at 1:1 + /// (paper space shows every annotative object), elsewhere (model space, or an object not in + /// the drawing yet) as [`anno_draw`] at the current annotation scale. + pub fn anno_draw(&self, e: &Entity) -> AnnoDraw { + if !is_annotative(e) { + return AnnoDraw::Plain; + } + if !self.model.contains(e.handle) && self.layouts.iter().any(|l| l.entities.contains(e.handle)) { + return AnnoDraw::Scaled(1.0); + } + anno_draw(e, self.current_anno_scale().factor(), self.anno_all_visible()) + } + /// Whether new objects of this kind are annotative: text and MText with an annotative text + /// style, dimensions with an annotative dimension style, multileaders with an annotative + /// multileader style, hatches while `HPANNOTATIVE` is 1, references to annotative blocks. + pub fn annotative_by_style(&self, k: &EntityKind) -> bool { + match k { + EntityKind::Text(t) => self.text_style(&t.style).is_some_and(|s| s.annotative), + EntityKind::MText(t) => self.text_style(&t.style).is_some_and(|s| s.annotative), + EntityKind::Dimension(dm) => self.dim_style(&dm.style).is_some_and(|s| s.annotative), + EntityKind::MLeader(m) => self.mleader_styles.iter().find(|s| s.name.eq_ignore_ascii_case(&m.style)).is_some_and(|s| s.annotative), + EntityKind::Hatch(_) => self.header.i64("HPANNOTATIVE", 0) != 0, + EntityKind::Insert(ins) => self.block(&ins.block).is_some_and(|b| b.annotative), + _ => false, + } + } +} diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 3b2229a..898e137 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -97,6 +97,9 @@ pub struct Common { /// 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, } impl Default for Common { @@ -112,6 +115,7 @@ impl Default for Common { thickness: 0.0, extrusion: Vec3::Z, plot_style: "ByLayer".into(), + anno_scales: Vec::new(), } } } diff --git a/crates/doc/src/extents.rs b/crates/doc/src/extents.rs index b17f476..9be191a 100644 --- a/crates/doc/src/extents.rs +++ b/crates/doc/src/extents.rs @@ -49,6 +49,14 @@ fn blank(s: &str) -> bool { } pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 { + // A top-level annotative object measures as it draws at the current annotation scale. + if depth == 0 { + match d.anno_draw(e) { + crate::AnnoDraw::Plain => {} + crate::AnnoDraw::Hidden => return Bounds2::EMPTY, + crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new()), + } + } bounds_in(d, e, depth, &mut HashMap::new()) } diff --git a/crates/doc/src/header.rs b/crates/doc/src/header.rs index fe3352f..5a53351 100644 --- a/crates/doc/src/header.rs +++ b/crates/doc/src/header.rs @@ -118,6 +118,9 @@ impl Header { // Snap grid origin and rotation (SNAPANG in degrees). h.set("SNAPBASE", HVal::Point(Vec3::ZERO)); h.set_f64("SNAPANG", 0.0); + // Annotation scale of model space; annotative objects of other scales show (1) or not. + h.set_str("CANNOSCALE", "1:1"); + h.set_i64("ANNOALLVISIBLE", 1); h } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2dfd140..464fd72 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -77,6 +77,8 @@ pub struct Drawing { pub constraints: Vec, /// User parameters and parametric settings. pub parametric: Parametric, + /// The annotation scale list (SCALELISTEDIT). + pub scales: Vec, /// Next free handle. pub handseed: u64, /// Objects hidden for this session by ISOLATEOBJECTS / HIDEOBJECTS. Never written to a file: @@ -101,6 +103,7 @@ impl Drawing { pub fn new_metric() -> Self { let mut d = Drawing::bare(); d.header = Header::metric(); + d.scales = metric_scales(); d.dim_styles.push(DimStyle::iso25()); d.header.set_str("DIMSTYLE", "ISO-25"); d @@ -128,6 +131,7 @@ impl Drawing { plot_style_tables: Vec::new(), constraints: Vec::new(), parametric: Parametric::default(), + scales: imperial_scales(), handseed: 0x100, hidden_objects: BTreeSet::new(), } @@ -317,5 +321,8 @@ impl Drawing { } } +mod anno; +pub use anno::*; + #[cfg(test)] mod tests; diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index c81b7f4..81a1647 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -459,11 +459,21 @@ pub struct MLeaderStyle { pub landing_gap: f64, pub dogleg: f64, pub text_style: String, + /// New multileaders of this style are annotative. + pub annotative: bool, } impl Default for MLeaderStyle { fn default() -> Self { - MLeaderStyle { name: "Standard".into(), arrow_size: 0.18, text_height: 0.18, landing_gap: 0.09, dogleg: 0.36, text_style: "Standard".into() } + MLeaderStyle { + name: "Standard".into(), + arrow_size: 0.18, + text_height: 0.18, + landing_gap: 0.09, + dogleg: 0.36, + text_style: "Standard".into(), + annotative: false, + } } } @@ -488,6 +498,9 @@ impl Default for TableStyle { #[serde(rename_all = "camelCase")] pub struct Block { pub name: String, + /// References to this block are annotative. + #[serde(default)] + pub annotative: bool, pub base: Vec3, #[serde(skip)] pub entities: EntityStore, @@ -511,6 +524,7 @@ impl Block { pub fn new(name: &str) -> Self { Block { name: name.into(), + annotative: false, base: Vec3::ZERO, entities: EntityStore::new(), description: String::new(), diff --git a/crates/engine/src/cmd/annoscale.rs b/crates/engine/src/cmd/annoscale.rs new file mode 100644 index 0000000..913cc5e --- /dev/null +++ b/crates/engine/src/cmd/annoscale.rs @@ -0,0 +1,589 @@ +//! Annotative scaling: the drawing's scale list (SCALELISTEDIT), the scales annotative objects +//! support (OBJECTSCALE, Add/Delete Current Scale, ANNORESET, ANNOUPDATE), which annotative +//! objects show (ANNOALLVISIBLE) and the current annotation scale (CANNOSCALE with +//! ANNOAUTOSCALE). + +use cadcraft_doc::{AnnoScale, Drawing, EntityKind, Handle, MAX_SCALES, is_annotative}; +use serde_json::{Value, json}; + +use super::machines::{SelOutcome, SelectPhase, SelectRun}; +use super::*; +use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step}; + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("scalelistedit", "Scale List...", run_scalelistedit) + .menu(&["Format", "Scale List..."]) + .alias(&["-scalelistedit"]) + .params( + "{add?: {name, ratio: \"paper:drawing\" (e.g. \"1:50\") | paperUnits + drawingUnits}, delete?: name | [names], reset?: true | \"metric\" | \"imperial\"} → scales, current (no parameters: list only)", + ) + .interactive(|_| Ok(Box::new(ScaleListM::default()))), + CommandSpec::new("objectscale", "Add/Delete Scales...", run_objectscale) + .menu(&["Modify", "Annotative Object Scale", "Add/Delete Scales..."]) + .alias(&["-objectscale"]) + .params("{handles?, add?: name | [names], delete?: name | [names]} (scales of the drawing's list; an object keeps at least one) → changed, objects") + .interactive(|_| Ok(Box::new(ObjectScaleM::default()))), + CommandSpec::new("aiobjectscaleadd", "Add Current Scale", run_add_current) + .menu(&["Modify", "Annotative Object Scale", "Add Current Scale"]) + .params("{handles?} (adds the current annotation scale to the annotative objects)") + .interactive(|_| Ok(Box::new(SelectRun::new("aiobjectscaleadd", "AIOBJECTSCALEADD")))), + CommandSpec::new("aiobjectscaleremove", "Delete Current Scale", run_remove_current) + .menu(&["Modify", "Annotative Object Scale", "Delete Current Scale"]) + .params("{handles?} (removes the current annotation scale from the annotative objects; an object keeps at least one)") + .interactive(|_| Ok(Box::new(SelectRun::new("aiobjectscaleremove", "AIOBJECTSCALEREMOVE")))), + CommandSpec::new("annoreset", "Synchronize Multiple-scale Positions", run_annoreset) + .menu(&["Modify", "Annotative Object Scale", "Synchronize Multiple-scale Positions"]) + .params("{handles?} (every scale of an annotative object shares one position)") + .interactive(|_| Ok(Box::new(SelectRun::new("annoreset", "ANNORESET")))), + CommandSpec::new("annoupdate", "Annotative Update", run_annoupdate) + .params("{handles?} (makes text, dimensions, multileaders and block references annotative or not as their style or block is)") + .interactive(|_| Ok(Box::new(SelectRun::new("annoupdate", "ANNOUPDATE")))), + CommandSpec::new("annovisible.current", "Current Scale Only", |s, _| set_all_visible(s, Some(false))) + .menu(&["View", "Show Annotative Objects", "Current Scale Only"]) + .params("{} (ANNOALLVISIBLE 0: annotative objects show only at scales they support)"), + CommandSpec::new("annovisible.all", "All Scales", |s, _| set_all_visible(s, Some(true))) + .menu(&["View", "Show Annotative Objects", "All Scales"]) + .params("{} (ANNOALLVISIBLE 1: every annotative object shows)"), + CommandSpec::new("annovisible.toggle", "Annotation Visibility", |s, _| set_all_visible(s, None)).params("{} (toggles ANNOALLVISIBLE)"), + ] +} + +/// The most objects one command call reports scales for. +const MAX_REPORTED: usize = 1000; + +fn scale_json(sc: &AnnoScale) -> Value { + json!({ "name": sc.name, "paperUnits": sc.paper_units, "drawingUnits": sc.drawing_units }) +} + +fn list_json(d: &Drawing) -> Value { + json!({ "scales": d.scales.iter().map(scale_json).collect::>(), "current": d.current_anno_scale().name }) +} + +/// Scale names given as one string or an array of strings. +fn names(cmd: &str, v: Option<&Value>) -> Result> { + match v { + None | Some(Value::Null) => Ok(Vec::new()), + Some(Value::String(s)) => Ok(vec![s.trim().to_string()]), + Some(Value::Array(a)) if a.len() <= MAX_SCALES => { + a.iter().map(|v| v.as_str().map(|s| s.trim().to_string()).ok_or_else(|| bad(cmd, "scale names must be strings"))).collect() + } + Some(_) => Err(bad(cmd, "expected a scale name or an array of scale names")), + } +} + +fn find_scale(d: &Drawing, cmd: &str, name: &str) -> Result { + d.anno_scale(name).cloned().ok_or_else(|| bad(cmd, format!("`{name}` is not in the scale list"))) +} + +/// A scale ratio typed as `paper:drawing` or `paper=drawing`, each side a number or distance +/// (`1:50`, `2:1`, `1/4":1'`). +pub(crate) fn parse_ratio(t: &str) -> Option<(f64, f64)> { + let (a, b) = t.split_once(':').or_else(|| t.split_once('='))?; + let ok = |v: f64| v.is_finite() && v > 0.0; + let a = crate::units::parse_distance(a).filter(|v| ok(*v))?; + let b = crate::units::parse_distance(b).filter(|v| ok(*v))?; + Some((a, b)) +} + +/// Make `name` the current annotation scale (CANNOSCALE). With ANNOAUTOSCALE on, the annotative +/// objects of model space on layers that are on, thawed and unlocked get the scale too. +pub(crate) fn set_current(s: &mut Session, name: &str) -> Result<()> { + let sc = find_scale(s.doc()?, "setvar", name)?; + let auto = s.settings.annoautoscale; + let d = s.doc_mut()?; + d.header.set_str("CANNOSCALE", &sc.name); + if auto { + let usable = |e: &cadcraft_doc::Entity| d.layer(&e.common.layer).is_none_or(|l| l.visible() && !l.locked); + let hs: Vec = d.model.iter().filter(|e| is_annotative(e) && usable(e)).map(|e| e.handle).collect(); + for h in hs { + add_scale(d, h, &sc); + } + } + Ok(()) +} + +/// Add `sc` to annotative object `h`; whether it changed. +fn add_scale(d: &mut Drawing, h: Handle, sc: &AnnoScale) -> bool { + let mut changed = false; + let _ = d.modify_entity(h, |e| { + if is_annotative(e) && !e.common.supports_scale(&sc.name) && e.common.anno_scales.len() < MAX_SCALES { + e.common.anno_scales.push(sc.clone()); + changed = true; + } + }); + changed +} + +/// What removing a scale from an object did. +#[derive(PartialEq)] +enum Removed { + Yes, + /// It is the object's only scale. + Last, + No, +} + +fn remove_scale(d: &mut Drawing, h: Handle, name: &str) -> Removed { + let mut r = Removed::No; + let _ = d.modify_entity(h, |e| { + if !is_annotative(e) || !e.common.supports_scale(name) { + return; + } + if e.common.anno_scales.len() < 2 { + r = Removed::Last; + return; + } + e.common.anno_scales.retain(|s| !s.name.eq_ignore_ascii_case(name)); + r = Removed::Yes; + }); + r +} + +/// The annotative objects among the targets (an error when nothing is selected). +fn annotative_targets(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, usize)> { + let hs = targets(s, p)?; + if hs.is_empty() { + return Err(bad(cmd, "nothing selected")); + } + let d = s.doc()?; + let total = hs.len(); + let anno: Vec = hs.into_iter().filter(|h| d.entity(*h).is_some_and(|e| is_annotative(e))).collect(); + Ok((anno, total)) +} + +fn objects_json(d: &Drawing, hs: &[Handle]) -> Value { + json!( + hs.iter() + .take(MAX_REPORTED) + .filter_map(|h| d.entity(*h)) + .map(|e| json!({ "handle": e.handle.hex(), "scales": e.common.anno_scales.iter().map(|s| s.name.clone()).collect::>() })) + .collect::>() + ) +} + +fn run_objectscale(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "objectscale"; + let (hs, total) = annotative_targets(s, p, CMD)?; + let d = s.doc()?; + let add = names(CMD, p.get("add"))?.iter().map(|n| find_scale(d, CMD, n)).collect::>>()?; + let del = names(CMD, p.get("delete"))?; + let d = s.doc_mut()?; + let (mut changed, mut last) = (0usize, 0usize); + for h in &hs { + let mut any = false; + for sc in &add { + any |= add_scale(d, *h, sc); + } + for n in &del { + match remove_scale(d, *h, n) { + Removed::Yes => any = true, + Removed::Last => last += 1, + Removed::No => {} + } + } + changed += usize::from(any); + } + let mut msg = format!("{changed} of {total} object(s) changed"); + if hs.len() < total { + msg.push_str(&format!(", {} not annotative", total - hs.len())); + } + if last > 0 { + msg.push_str(&format!(", {last} scale(s) kept as an object's only scale")); + } + Ok(json!({ "changed": changed, "annotative": hs.len(), "objects": objects_json(d, &hs), "message": msg })) +} + +fn run_add_current(s: &mut Session, p: &Value) -> Result { + let cur = s.doc()?.current_anno_scale(); + run_objectscale(s, &with_handles(s, p, json!({ "add": cur.name }))) +} + +fn run_remove_current(s: &mut Session, p: &Value) -> Result { + let cur = s.doc()?.current_anno_scale(); + run_objectscale(s, &with_handles(s, p, json!({ "delete": cur.name }))) +} + +/// `extra` with the targets of `p` as `handles`. +fn with_handles(s: &Session, p: &Value, mut extra: Value) -> Value { + let hs = targets(s, p).unwrap_or_default(); + if let Some(o) = extra.as_object_mut() { + o.insert("handles".into(), json!(hs.iter().map(|h| h.hex()).collect::>())); + } + extra +} + +fn run_annoreset(s: &mut Session, p: &Value) -> Result { + let (hs, _) = annotative_targets(s, p, "annoreset")?; + // One position serves every scale of an object, so they are always in step. + Ok(json!({ "objects": hs.len(), "message": format!("{} annotative object(s) synchronized", hs.len()) })) +} + +fn run_annoupdate(s: &mut Session, p: &Value) -> Result { + let hs = targets(s, p)?; + if hs.is_empty() { + return Err(bad("annoupdate", "nothing selected")); + } + let d = s.doc()?; + let cur = d.current_anno_scale(); + // Hatches have no style: HPANNOTATIVE only sets new ones. + let plan: Vec<(Handle, bool)> = hs + .iter() + .filter_map(|h| d.entity(*h)) + .filter(|e| cadcraft_doc::can_be_annotative(&e.kind) && !matches!(e.kind, EntityKind::Hatch(_))) + .map(|e| (e.handle, d.annotative_by_style(&e.kind))) + .filter(|(h, want)| d.entity(*h).is_some_and(|e| is_annotative(e) != *want)) + .collect(); + let d = s.doc_mut()?; + for (h, want) in &plan { + d.modify_entity(*h, |e| cadcraft_doc::set_annotative(e, *want, &cur))?; + } + Ok(json!({ "updated": plan.len(), "message": format!("{} object(s) updated", plan.len()) })) +} + +fn set_all_visible(s: &mut Session, on: Option) -> Result { + let d = s.doc_mut()?; + let on = on.unwrap_or(!d.anno_all_visible()); + d.header.set_i64("ANNOALLVISIBLE", i64::from(on)); + Ok(json!({ "annoallvisible": i64::from(on) })) +} + +// ---------------- the scale list ---------------- + +/// Scales objects use, and the current one: they stay when the list is reset and can't be +/// deleted. +fn in_use(d: &Drawing) -> Vec { + let mut v = vec![d.current_anno_scale()]; + let ents = d.model.iter().chain(d.layouts.iter().flat_map(|l| l.entities.iter())); + for e in ents.filter(|e| is_annotative(e)) { + for sc in &e.common.anno_scales { + if v.len() < MAX_SCALES && !v.iter().any(|u| u.name.eq_ignore_ascii_case(&sc.name)) { + v.push(sc.clone()); + } + } + } + v +} + +fn add_to_list(d: &mut Drawing, sc: AnnoScale) -> Result<()> { + const CMD: &str = "scalelistedit"; + let name = sc.name.trim(); + if name.is_empty() || name.chars().count() > 255 { + return Err(bad(CMD, "a scale name has 1 to 255 characters")); + } + if !sc.is_valid() { + return Err(bad(CMD, "paper and drawing units must be positive")); + } + if d.anno_scale(name).is_some() { + return Err(bad(CMD, format!("a scale named `{name}` already exists"))); + } + if d.scales.len() >= MAX_SCALES { + return Err(bad(CMD, "the scale list is full")); + } + d.scales.push(AnnoScale { name: name.to_string(), ..sc }); + Ok(()) +} + +fn delete_from_list(d: &mut Drawing, name: &str) -> Result<()> { + const CMD: &str = "scalelistedit"; + let sc = find_scale(d, CMD, name)?; + if d.current_anno_scale().name.eq_ignore_ascii_case(&sc.name) { + return Err(bad(CMD, format!("`{}` is the current annotation scale", sc.name))); + } + if in_use(d).iter().any(|u| u.name.eq_ignore_ascii_case(&sc.name)) { + return Err(bad(CMD, format!("`{}` is used by annotative objects", sc.name))); + } + d.scales.retain(|s| !s.name.eq_ignore_ascii_case(&sc.name)); + Ok(()) +} + +/// Replace the list with the default metric or imperial one, keeping scales in use. +fn reset_list(d: &mut Drawing, metric: bool) { + let keep = in_use(d); + let mut list = if metric { cadcraft_doc::metric_scales() } else { cadcraft_doc::imperial_scales() }; + for sc in keep { + if !list.iter().any(|l| l.name.eq_ignore_ascii_case(&sc.name)) { + list.push(sc); + } + } + d.scales = list; +} + +fn run_scalelistedit(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "scalelistedit"; + let metric = s.doc()?.header.i64("MEASUREMENT", 0) == 1; + let reset = match p.get("reset") { + None | Some(Value::Null) | Some(Value::Bool(false)) => None, + Some(Value::Bool(true)) => Some(metric), + Some(Value::String(t)) if t.eq_ignore_ascii_case("metric") => Some(true), + Some(Value::String(t)) if t.eq_ignore_ascii_case("imperial") => Some(false), + Some(_) => return Err(bad(CMD, "`reset` is true, \"metric\" or \"imperial\"")), + }; + let del = names(CMD, p.get("delete"))?; + let add = match p.get("add") { + None | Some(Value::Null) => None, + Some(a) => { + let name = a.get("name").and_then(Value::as_str).ok_or_else(|| bad(CMD, "`add.name` is required"))?; + let (pu, du) = match a.get("ratio").and_then(Value::as_str) { + Some(r) => parse_ratio(r).ok_or_else(|| bad(CMD, format!("`{r}` is not a ratio such as 1:50")))?, + None => (f64_req(CMD, a, "paperUnits")?, f64_req(CMD, a, "drawingUnits")?), + }; + Some(AnnoScale::new(name, pu, du)) + } + }; + let d = s.doc_mut()?; + if let Some(m) = reset { + reset_list(d, m); + } + for n in &del { + delete_from_list(d, n)?; + } + if let Some(sc) = add { + add_to_list(d, sc)?; + } + Ok(list_json(d)) +} + +fn echo_list(s: &mut Session) { + let lines: Vec = s + .doc() + .map(|d| { + d.scales + .iter() + .map(|sc| format!(" {} ({} paper = {} drawing)", sc.name, fmt_units(sc.paper_units), fmt_units(sc.drawing_units))) + .collect() + }) + .unwrap_or_default(); + for l in lines { + s.echo(l); + } +} + +fn fmt_units(v: f64) -> String { + let r = (v * 1e6).round() / 1e6; + if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r}") } +} + +#[derive(Default)] +enum ListStage { + #[default] + Option, + AddName, + AddRatio(String), + Delete, + Reset, +} + +/// -SCALELISTEDIT: "Enter option [?/Add/Delete/Reset/Exit]", repeated until Exit or Enter. +#[derive(Default)] +struct ScaleListM { + stage: ListStage, +} + +impl Interactive for ScaleListM { + fn name(&self) -> &'static str { + "SCALELISTEDIT" + } + fn prompt(&self, _s: &Session) -> Prompt { + match &self.stage { + ListStage::Option => Prompt::new("Enter option", Accept::TEXT).kw(&["?", "Add", "Delete", "Reset", "Exit"]).default("Exit"), + ListStage::AddName => Prompt::new("Enter name for new scale", Accept::TEXT), + ListStage::AddRatio(_) => Prompt::new("Enter scale ratio (paper units:drawing units)", Accept::TEXT).default("1:1"), + ListStage::Delete => Prompt::new("Enter name of scale to delete", Accept::TEXT).kw(&["?"]), + ListStage::Reset => Prompt::new("Reset the scale list to the defaults?", Accept::TEXT).kw(&["Yes", "No"]).default("No"), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + let text = match i { + Input::Cancel => return Ok(Step::Cancel), + Input::Enter => String::new(), + Input::Text(t) | Input::Keyword(t) => t.trim().to_string(), + _ => return Ok(Step::Continue), + }; + let stage = std::mem::take(&mut self.stage); + let result = match stage { + ListStage::Option => match text.to_ascii_lowercase().as_str() { + "" | "e" | "exit" => return Ok(Step::Done), + "?" => { + echo_list(s); + Ok(()) + } + "a" | "add" => { + self.stage = ListStage::AddName; + Ok(()) + } + "d" | "delete" => { + self.stage = ListStage::Delete; + Ok(()) + } + "r" | "reset" => { + self.stage = ListStage::Reset; + Ok(()) + } + _ => { + s.echo("Invalid option keyword."); + Ok(()) + } + }, + ListStage::AddName if text.is_empty() => Ok(()), + ListStage::AddName => { + if s.doc()?.anno_scale(&text).is_some() { + s.echo(format!("A scale named \"{text}\" already exists.")); + } else { + self.stage = ListStage::AddRatio(text); + } + Ok(()) + } + ListStage::AddRatio(name) => { + let t = if text.is_empty() { "1:1" } else { text.as_str() }; + match parse_ratio(t) { + Some((pu, du)) => add_to_list(s.doc_mut()?, AnnoScale::new(&name, pu, du)), + None => { + s.echo("Enter a ratio such as 1:50."); + self.stage = ListStage::AddRatio(name); + Ok(()) + } + } + } + ListStage::Delete => match text.as_str() { + "" => Ok(()), + "?" => { + echo_list(s); + self.stage = ListStage::Delete; + Ok(()) + } + n => delete_from_list(s.doc_mut()?, n), + }, + ListStage::Reset => { + if matches!(text.to_ascii_lowercase().as_str(), "y" | "yes") { + let metric = s.doc()?.header.i64("MEASUREMENT", 0) == 1; + reset_list(s.doc_mut()?, metric); + } + Ok(()) + } + }; + if let Err(e) = result { + s.echo(e.to_string()); + } + Ok(Step::Continue) + } +} + +#[derive(Default, Clone, Copy, PartialEq)] +enum ObjStage { + #[default] + Select, + Option, + Add, + Delete, +} + +/// -OBJECTSCALE: select annotative objects, then add or delete scales by name until Enter. +#[derive(Default)] +struct ObjectScaleM { + sel: SelectPhase, + stage: ObjStage, + hs: Vec, +} + +impl ObjectScaleM { + /// Keep the annotative objects of the selection; false when there are none. + fn take(&mut self, s: &mut Session, picked: Vec) -> Result { + let d = s.doc()?; + self.hs = picked.into_iter().filter(|h| d.entity(*h).is_some_and(|e| is_annotative(e))).collect(); + if self.hs.is_empty() { + s.echo("No annotative objects selected."); + return Ok(false); + } + self.stage = ObjStage::Option; + Ok(true) + } + fn apply(&self, s: &mut Session, key: &str, name: &str) { + let mut p = json!({ "handles": self.hs.iter().map(|h| h.hex()).collect::>() }); + if let Some(o) = p.as_object_mut() { + o.insert(key.to_string(), json!(name)); + } + match run_objectscale(s, &p) { + Ok(r) => { + if let Some(m) = r.get("message").and_then(Value::as_str) { + s.echo(m.to_string()); + } + } + Err(e) => s.echo(e.to_string()), + } + } + fn echo_supported(&self, s: &mut Session) { + let Ok(d) = s.doc() else { return }; + let mut names: Vec = Vec::new(); + for e in self.hs.iter().filter_map(|h| d.entity(*h)) { + for sc in &e.common.anno_scales { + if names.len() < MAX_SCALES && !names.iter().any(|n| n.eq_ignore_ascii_case(&sc.name)) { + names.push(sc.name.clone()); + } + } + } + s.echo(format!("Scales of the selected objects: {}", names.join(", "))); + } +} + +impl Interactive for ObjectScaleM { + fn name(&self) -> &'static str { + "OBJECTSCALE" + } + fn begin(&mut self, s: &mut Session) -> Result { + self.sel = SelectPhase::begin(s); + if self.sel.done { + let picked = self.sel.picked.clone(); + if !self.take(s, picked)? { + return Ok(Step::Done); + } + } + Ok(Step::Continue) + } + fn prompt(&self, s: &Session) -> Prompt { + let cur = s.doc().map(|d| d.current_anno_scale().name).unwrap_or_else(|_| "1:1".into()); + match self.stage { + ObjStage::Select => Prompt::new(if self.sel.removing { "Remove objects" } else { "Select annotative objects" }, Accept::SELECT), + ObjStage::Option => Prompt::new("Enter an option", Accept::TEXT).kw(&["Add", "Delete", "?"]), + ObjStage::Add => Prompt::new("Enter named scale to add", Accept::TEXT).kw(&["?"]).default(cur), + ObjStage::Delete => Prompt::new("Enter named scale to delete", Accept::TEXT).kw(&["?"]).default(cur), + } + } + fn input(&mut self, s: &mut Session, i: Input) -> Result { + if i == Input::Cancel { + return Ok(Step::Cancel); + } + if self.stage == ObjStage::Select { + return match self.sel.feed(s, &i)? { + SelOutcome::More => Ok(Step::Continue), + SelOutcome::Empty => Ok(Step::Done), + SelOutcome::Done(hs) => Ok(if self.take(s, hs)? { Step::Continue } else { Step::Done }), + }; + } + let text = match i { + Input::Enter => String::new(), + Input::Text(t) | Input::Keyword(t) => t.trim().to_string(), + _ => return Ok(Step::Continue), + }; + match self.stage { + ObjStage::Option => match text.to_ascii_lowercase().as_str() { + "" => return Ok(Step::Done), + "a" | "add" => self.stage = ObjStage::Add, + "d" | "delete" => self.stage = ObjStage::Delete, + "?" => self.echo_supported(s), + _ => s.echo("Invalid option keyword."), + }, + stage @ (ObjStage::Add | ObjStage::Delete) => { + if text == "?" { + echo_list(s); + return Ok(Step::Continue); + } + let name = if text.is_empty() { s.doc()?.current_anno_scale().name } else { text }; + self.apply(s, if stage == ObjStage::Add { "add" } else { "delete" }, &name); + self.stage = ObjStage::Option; + } + ObjStage::Select => {} + } + Ok(Step::Continue) + } +} diff --git a/crates/engine/src/cmd/annotate.rs b/crates/engine/src/cmd/annotate.rs index 58fd3e3..7f32b39 100644 --- a/crates/engine/src/cmd/annotate.rs +++ b/crates/engine/src/cmd/annotate.rs @@ -466,7 +466,8 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option< 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(); - let k = d.header.f64("DIMSCALE", 1.0); + // An annotative style's sizes are paper sizes: the annotation scale applies when drawn. + let k = if st.annotative { 1.0 } else { d.header.f64("DIMSCALE", 1.0) }; let th = st.text_height * k; 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 }; diff --git a/crates/engine/src/cmd/blocks.rs b/crates/engine/src/cmd/blocks.rs index 1fd7b3b..79afd27 100644 --- a/crates/engine/src/cmd/blocks.rs +++ b/crates/engine/src/cmd/blocks.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { CommandSpec::new("block", "Make...", run_block) .menu(&["Draw", "Block", "Make..."]) .alias(&["b", "bmake"]) - .params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description?}") + .params("{name, base: [x,y], handles?, keep?: \"convert\"|\"retain\"|\"delete\" (default \"convert\"), description?, annotative?}") .interactive(|_| Ok(Box::new(BlockM::default()))), // -BLOCK erases the selected objects once the block is defined. CommandSpec::new("-block", "Create Block", run_dash_block) @@ -189,6 +189,17 @@ fn run_block(s: &mut Session, p: &Value) -> Result { let hs = targets(s, p)?; let keep = str_param(p, "keep").unwrap_or("convert").to_string(); let r = make_block(s, &name, base, &hs, &keep, str_param(p, "description").unwrap_or(""))?; + // An annotative block: its references (the converted selection too) are annotative. + if bool_or(p, "annotative", false) { + let d = s.doc_mut()?; + let cur = d.current_anno_scale(); + if let Some(b) = d.blocks.iter_mut().find(|(k, _)| k.eq_ignore_ascii_case(&name)).map(|(_, b)| b) { + Arc::make_mut(b).annotative = true; + } + if let Some(h) = r { + d.modify_entity(h, |e| cadcraft_doc::set_annotative(e, true, &cur))?; + } + } Ok(json!({ "block": name, "insert": r.map(|h| h.hex()) })) } diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index b097a42..7fa14f8 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -1414,11 +1414,10 @@ impl Interactive for TextM { } fn begin(&mut self, s: &mut Session) -> Result { let d = s.doc()?; - let msg = format!( - "Current text style: \"{}\" Text height: {:.4} Annotative: No Justify: Left", - d.header.str("TEXTSTYLE", "Standard"), - d.header.f64("TEXTSIZE", 0.2) - ); + 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) } @@ -1487,7 +1486,13 @@ impl Interactive for MTextM { } fn begin(&mut self, s: &mut Session) -> Result { self.opts = super::mtext_opts::MTextOpts::new(s); - s.echo(format!("Current text style: \"{}\" Text height: {:.4} Annotative: No", self.opts.style, self.opts.height)); + 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 { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 6e80e7d..9efc88d 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -1,6 +1,7 @@ //! The command registry. Ids are AutoCAD command names in lower case; menu paths follow //! AutoCAD's menu bar so the catalog doubles as the parity metric. +pub(crate) mod annoscale; mod annotate; mod array; mod audit; @@ -172,6 +173,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(draw::specs()); v.extend(draw2::specs()); v.extend(annotate::specs()); + v.extend(annoscale::specs()); v.extend(hatch::specs()); v.extend(overkill::specs()); v.extend(blocks::specs()); diff --git a/crates/engine/src/cmd/modify.rs b/crates/engine/src/cmd/modify.rs index 6d0cd91..34f46f2 100644 --- a/crates/engine/src/cmd/modify.rs +++ b/crates/engine/src/cmd/modify.rs @@ -1134,6 +1134,15 @@ fn run_chamfer(s: &mut Session, p: &Value) -> Result { // ---------------- explode / array / order / break / join ---------------- pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option> { + // 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 }; diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index ac2552a..7b6dc15 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { CommandSpec::new("mleaderstyle", "Multileader Style...", run_mleaderstyle) .menu(&["Format", "Multileader Style..."]) .alias(&["mls"]) - .params("{name, arrowSize?, textHeight?, landingGap?, dogleg?, textStyle?, current?} → styles"), + .params("{name, arrowSize?, textHeight?, landingGap?, dogleg?, textStyle?, annotative?, current?} → styles"), CommandSpec::new("ddptype", "Point Style...", run_ptype).menu(&["Format", "Point Style..."]).params("{pdmode, pdsize}"), CommandSpec::new("rename", "Rename...", run_rename) .menu(&["Format", "Rename..."]) @@ -76,6 +76,12 @@ pub(super) fn entity_props(d: &cadcraft_doc::Drawing, e: &cadcraft_doc::Entity) "transparency": transparency_value(e.common.transparency), "visible": e.common.visible, }); + if cadcraft_doc::can_be_annotative(&e.kind) + && let Some(o) = v.as_object_mut() + { + o.insert("annotative".into(), json!(cadcraft_doc::is_annotative(e))); + o.insert("annotativeScales".into(), json!(e.common.anno_scales.iter().map(|s| s.name.clone()).collect::>())); + } let geo = serde_json::to_value(&e.kind).unwrap_or(Value::Null); if let (Some(o), Some(g)) = (v.as_object_mut(), geo.as_object()) { o.insert("geometry".into(), Value::Object(g.clone())); @@ -188,8 +194,13 @@ fn run_set(s: &mut Session, p: &Value) -> Result { let dim_style = str_param(p, "dimStyle") .map(|n| d.dim_style(n).map(|st| st.name.clone()).ok_or_else(|| bad("properties.set", format!("dimension style `{n}` not found")))) .transpose()?; + let anno_scale = d.current_anno_scale(); for h in &hs { d.modify_entity(*h, |e| { + // Annotative: Yes supports the current annotation scale; No drops every scale. + if let Some(a) = p.get("annotative").and_then(Value::as_bool) { + cadcraft_doc::set_annotative(e, a, &anno_scale); + } if let Some(l) = str_param(p, "layer") { e.common.layer = l.to_string(); } @@ -831,6 +842,9 @@ fn run_mleaderstyle(s: &mut Session, p: &Value) -> Result { if let Some(t) = str_param(p, "textStyle") { st.text_style = t.to_string(); } + if let Some(a) = p.get("annotative").and_then(Value::as_bool) { + st.annotative = a; + } } if bool_or(p, "current", true) { d.header.set_str("CMLEADERSTYLE", name); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2734b14..62e7ff7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -656,6 +656,12 @@ impl Session { if let Some(rgb) = true_color { common.color = cadcraft_color::Color::True(cadcraft_color::Rgb::from_u32(rgb as u32)); } + // Text, dimensions, multileaders, hatches and block references of annotative styles are + // annotative, supporting the current annotation scale. + let d = self.doc()?; + if d.annotative_by_style(&kind) { + common.anno_scales = vec![d.current_anno_scale()]; + } let h = self.doc_mut()?.add(&space, common, kind)?; Ok(h) } diff --git a/crates/engine/src/spatial.rs b/crates/engine/src/spatial.rs index 684840a..6277914 100644 --- a/crates/engine/src/spatial.rs +++ b/crates/engine/src/spatial.rs @@ -71,6 +71,8 @@ pub struct SpatialIndex { blocks: BTreeMap>, dim_styles: Vec, dimscale: f64, + /// Annotation scale factor and ANNOALLVISIBLE: annotative objects measure at that scale. + anno: (u64, bool), /// (draw index of the chunk's first entity, chunk), in draw order. chunks: Vec<(u32, Arc)>, /// Chunk serial → draw offset. @@ -81,6 +83,11 @@ pub struct SpatialIndex { len: usize, } +/// What annotative objects' bounds depend on besides the objects themselves. +fn anno_key(d: &Drawing) -> (u64, bool) { + (d.current_anno_scale().factor().to_bits(), d.anno_all_visible()) +} + impl SpatialIndex { /// Build an index over a store (no caching). pub fn build(d: &Drawing, store: &EntityStore) -> SpatialIndex { @@ -138,6 +145,7 @@ impl SpatialIndex { blocks: d.blocks.clone(), dim_styles: d.dim_styles.clone(), dimscale: d.header.f64("DIMSCALE", 1.0), + anno: anno_key(d), chunks, offsets, tree, @@ -151,6 +159,7 @@ impl SpatialIndex { && self.blocks.iter().zip(&d.blocks).all(|((ka, a), (kb, b))| ka == kb && Arc::ptr_eq(a, b)) && self.dimscale.to_bits() == d.header.f64("DIMSCALE", 1.0).to_bits() && self.dim_styles == d.dim_styles + && self.anno == anno_key(d) } fn matches(&self, d: &Drawing, store: &EntityStore) -> bool { @@ -271,6 +280,7 @@ mod tests { let mut d = Drawing::new_imperial(); let mut blk = Block { name: "B".into(), + annotative: false, base: Vec3::default(), entities: EntityStore::new(), description: String::new(), diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 7373281..7dd0525 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -9,6 +9,7 @@ mod table; const SESSION_VARS: &[&str] = &[ "OSMODE", "OSNAPHATCH", + "ANNOAUTOSCALE", "ORTHOMODE", "POLARMODE", "POLARANG", @@ -51,6 +52,10 @@ pub fn get(s: &Session, name: &str) -> Option { let v = match n.as_str() { "OSMODE" => json!(st.osmode), "OSNAPHATCH" => json!(i32::from(st.osnaphatch)), + // AutoCAD's values: positive on, negative off (4 = every annotative object). + "ANNOAUTOSCALE" => json!(if st.annoautoscale { 4 } else { -4 }), + // Paper units per drawing unit of the current annotation scale (1:50 → 0.02). + "CANNOSCALEVALUE" => json!(1.0 / s.doc().ok()?.current_anno_scale().factor()), "ORTHOMODE" => json!(i32::from(st.orthomode)), "POLARMODE" => json!(st.polar_flags), "POLARADDANG" => json!(format_angle_list(&st.polaraddang)), @@ -177,6 +182,10 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { match n.as_str() { "OSMODE" => st.osmode = v.as_u64().or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())).ok_or_else(bad)? as u32 & 0x7fff, "OSNAPHATCH" => st.osnaphatch = as_bool(v).ok_or_else(bad)?, + "ANNOAUTOSCALE" => st.annoautoscale = as_i(v).filter(|i| (-4..=4).contains(i)).ok_or_else(bad)? > 0, + // A scale of the drawing's list; ANNOAUTOSCALE adds it to the annotative objects. + "CANNOSCALE" => crate::cmd::annoscale::set_current(s, v.as_str().ok_or_else(bad)?)?, + "CANNOSCALEVALUE" => return Err(EngineError::BadParams { cmd: "setvar".into(), msg: format!("{n} is read-only") }), "ORTHOMODE" => st.orthomode = as_bool(v).ok_or_else(bad)?, "POLARMODE" => st.polar_flags = as_i(v).filter(|b| (0..=15).contains(b)).ok_or_else(bad)? as u32, "POLARADDANG" => { diff --git a/crates/engine/tests/annotative.rs b/crates/engine/tests/annotative.rs new file mode 100644 index 0000000..9c9d2ef --- /dev/null +++ b/crates/engine/tests/annotative.rs @@ -0,0 +1,285 @@ +//! Annotative scaling (#468): annotative objects keep their paper size at every annotation +//! scale, show only at scales they support unless ANNOALLVISIBLE is 1, and the scale list, +//! OBJECTSCALE, ANNOAUTOSCALE and ANNOUPDATE manage the scales. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{AnnoDraw, Drawing, Entity, EntityKind, Handle, Space, entity_bounds}; +use serde_json::{Value, json}; + +fn setvar(s: &mut Session, name: &str, value: Value) { + s.execute("setvar", &json!({ "name": name, "value": value })).unwrap(); +} + +/// A session whose current text style is annotative, with one text 0.25 high on paper. +fn with_text() -> (Session, Handle) { + let mut s = Session::new(); + s.execute("style", &json!({ "name": "Notes", "annotative": true, "current": true })).unwrap(); + let r = s.execute("text", &json!({ "at": [0, 0], "text": "NOTE", "height": 0.25 })).unwrap(); + let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap(); + (s, h) +} + +fn entity(s: &Session, h: Handle) -> Entity { + (**s.doc().unwrap().entity(h).unwrap()).clone() +} + +fn scales(s: &Session, h: Handle) -> Vec { + entity(s, h).common.anno_scales.iter().map(|x| x.name.clone()).collect() +} + +fn height(d: &Drawing, h: Handle) -> f64 { + let e = d.entity(h).unwrap(); + entity_bounds(d, e, 0).height() +} + +/// Height of what the renderer draws for `h` in model space. +fn drawn_height(d: &Drawing, h: Handle) -> Option { + let list = cadcraft_engine::render::build(d, &Space::Model, &Default::default()); + let mut b = cadcraft_engine::geom::Bounds2::EMPTY; + for p in list.prims.iter().filter(|p| p.handle == h) { + for q in list.points(p) { + b.add(*q); + } + } + (!b.is_empty()).then(|| b.height()) +} + +#[test] +fn text_of_an_annotative_style_supports_the_current_scale() { + let (s, h) = with_text(); + assert_eq!(scales(&s, h), ["1:1"]); + let mut s = s; + let p = s.execute("properties", &json!({ "handles": [h.hex()] })).unwrap(); + assert_eq!(p["objects"][0]["annotative"], json!(true), "{p}"); + assert_eq!(p["objects"][0]["annotativeScales"], json!(["1:1"]), "{p}"); + // A plain style makes plain text. + let mut s = Session::new(); + let r = s.execute("text", &json!({ "at": [0, 0], "text": "X", "height": 0.25 })).unwrap(); + let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap(); + assert!(scales(&s, h).is_empty()); +} + +#[test] +fn annotative_text_draws_at_paper_height_times_the_scale() { + let (mut s, h) = with_text(); + let at_1 = drawn_height(s.doc().unwrap(), h).unwrap(); + setvar(&mut s, "CANNOSCALE", json!("1:50")); + s.execute("objectscale", &json!({ "handles": [h.hex()], "add": "1:50" })).unwrap(); + assert_eq!(scales(&s, h), ["1:1", "1:50"]); + let at_50 = drawn_height(s.doc().unwrap(), h).unwrap(); + assert!((at_50 / at_1 - 50.0).abs() < 1e-6, "{at_1} → {at_50}"); + // Bounds (zoom, selection) follow the drawn size; the stored height stays the paper height. + assert!((height(s.doc().unwrap(), h) / at_50 - 1.0).abs() < 0.5); + match entity(&s, h).kind { + EntityKind::Text(t) => assert_eq!(t.height, 0.25), + k => panic!("{k:?}"), + } +} + +#[test] +fn unsupported_scale_hides_unless_annoallvisible() { + let (mut s, h) = with_text(); + setvar(&mut s, "CANNOSCALE", json!("1:20")); + // ANNOALLVISIBLE 1 (the default): shown, at the object's own scale. + assert_eq!(s.doc().unwrap().anno_draw(&entity(&s, h)), AnnoDraw::Scaled(1.0)); + assert!(drawn_height(s.doc().unwrap(), h).is_some()); + s.execute("annovisible.current", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().header.i64("ANNOALLVISIBLE", 1), 0); + assert_eq!(drawn_height(s.doc().unwrap(), h), None); + assert!(entity_bounds(s.doc().unwrap(), &entity(&s, h), 0).is_empty()); + // Back at a supported scale it shows again. + setvar(&mut s, "CANNOSCALE", json!("1:1")); + assert!(drawn_height(s.doc().unwrap(), h).is_some()); + s.execute("annovisible.toggle", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().header.i64("ANNOALLVISIBLE", 0), 1); +} + +#[test] +fn annoautoscale_adds_the_new_scale() { + let (mut s, h) = with_text(); + setvar(&mut s, "ANNOAUTOSCALE", json!(4)); + setvar(&mut s, "CANNOSCALE", json!("1:10")); + assert_eq!(scales(&s, h), ["1:1", "1:10"]); + let v = s.execute("getvar", &json!({ "name": "CANNOSCALEVALUE" })).unwrap(); + assert!((v["value"].as_f64().unwrap() - 0.1).abs() < 1e-12, "{v}"); + assert!(s.execute("setvar", &json!({ "name": "CANNOSCALEVALUE", "value": 1 })).is_err()); + setvar(&mut s, "ANNOAUTOSCALE", json!(-4)); + setvar(&mut s, "CANNOSCALE", json!("1:20")); + assert_eq!(scales(&s, h), ["1:1", "1:10"]); + // A scale that isn't in the list is refused. + assert!(s.execute("setvar", &json!({ "name": "CANNOSCALE", "value": "1:7" })).is_err()); + assert!(s.execute("setvar", &json!({ "name": "CANNOSCALE", "value": 7 })).is_err()); +} + +#[test] +fn add_and_delete_current_scale_keep_one_scale() { + let (mut s, h) = with_text(); + setvar(&mut s, "CANNOSCALE", json!("1:4")); + s.execute("aiobjectscaleadd", &json!({ "handles": [h.hex()] })).unwrap(); + assert_eq!(scales(&s, h), ["1:1", "1:4"]); + s.execute("aiobjectscaleremove", &json!({ "handles": [h.hex()] })).unwrap(); + assert_eq!(scales(&s, h), ["1:1"]); + // The last scale stays. + setvar(&mut s, "CANNOSCALE", json!("1:1")); + let r = s.execute("aiobjectscaleremove", &json!({ "handles": [h.hex()] })).unwrap(); + assert_eq!(scales(&s, h), ["1:1"]); + assert!(r["message"].as_str().unwrap().contains("only scale"), "{r}"); + assert!(s.execute("objectscale", &json!({ "handles": [h.hex()], "add": "no such" })).is_err()); +} + +#[test] +fn objectscale_at_the_command_line() { + let (mut s, h) = with_text(); + // The pickfirst selection is used. + s.set_selection(vec![h]); + s.script("-OBJECTSCALE\nA\n1:2\nA\n1:8\nD\n1:1\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(scales(&s, h), ["1:2", "1:8"]); +} + +#[test] +fn scale_list_add_delete_reset() { + let (mut s, h) = with_text(); + let r = s.execute("scalelistedit", &json!({ "add": { "name": "Site", "ratio": "1:250" } })).unwrap(); + assert!(r["scales"].as_array().unwrap().iter().any(|x| x["name"] == "Site" && x["drawingUnits"] == 250.0), "{r}"); + assert!(s.execute("scalelistedit", &json!({ "add": { "name": "site", "ratio": "1:3" } })).is_err()); + assert!(s.execute("scalelistedit", &json!({ "add": { "name": "Bad", "ratio": "0:3" } })).is_err()); + assert!(s.execute("scalelistedit", &json!({ "add": { "name": "Bad", "paperUnits": 1, "drawingUnits": -2 } })).is_err()); + // In use by the text, or current: can't be deleted. + s.execute("objectscale", &json!({ "handles": [h.hex()], "add": "Site" })).unwrap(); + assert!(s.execute("scalelistedit", &json!({ "delete": "Site" })).is_err()); + assert!(s.execute("scalelistedit", &json!({ "delete": "1:1" })).is_err()); + s.execute("scalelistedit", &json!({ "delete": "1:100" })).unwrap(); + assert!(s.doc().unwrap().anno_scale("1:100").is_none()); + // Reset brings the defaults back and keeps scales in use. + let r = s.execute("scalelistedit", &json!({ "reset": "metric" })).unwrap(); + let names: Vec<&str> = r["scales"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).collect(); + assert!(names.contains(&"1:100") && names.contains(&"Site") && !names.iter().any(|n| n.contains('"')), "{names:?}"); + let r = s.execute("scalelistedit", &json!({ "reset": "imperial" })).unwrap(); + assert!(r["scales"].as_array().unwrap().iter().any(|x| x["name"] == "1/4\" = 1'-0\"" && x["drawingUnits"] == 12.0)); +} + +#[test] +fn scalelistedit_at_the_command_line() { + let mut s = Session::new(); + s.script("-SCALELISTEDIT\nA\nDetail\n5:1\nD\n1:16\nE\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + let d = s.doc().unwrap(); + let detail = d.anno_scale("detail").unwrap(); + assert_eq!((detail.paper_units, detail.drawing_units), (5.0, 1.0)); + assert!(d.anno_scale("1:16").is_none()); + // Bad input is reported, not fatal. + s.script("-SCALELISTEDIT\nA\nOdd\nfoo\n1:3\nD\nnothing\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(s.doc().unwrap().anno_scale("Odd").map(|x| x.drawing_units), Some(3.0)); + s.script("-SCALELISTEDIT\nR\nY\nX\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert!(s.doc().unwrap().anno_scale("1:16").is_some(), "reset restores the defaults"); + assert!(s.doc().unwrap().anno_scale("Odd").is_none(), "an unused scale goes"); +} + +#[test] +fn annotative_dimension_scales_its_text_and_arrows() { + let mut s = Session::new(); + s.execute("dimstyle", &json!({ "name": "Anno", "annotative": true, "current": true })).unwrap(); + let r = s.execute("dimlinear", &json!({ "p1": [0, 0], "p2": [100, 0], "at": [50, 20] })).unwrap(); + let h = Handle::parse_hex(r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap()).unwrap(); + assert_eq!(scales(&s, h), ["1:1"]); + let at_1 = drawn_height(s.doc().unwrap(), h).unwrap(); + s.execute("scalelistedit", &json!({ "add": { "name": "1:3", "ratio": "1:3" } })).ok(); + setvar(&mut s, "ANNOAUTOSCALE", json!(1)); + setvar(&mut s, "CANNOSCALE", json!("1:4")); + let at_4 = drawn_height(s.doc().unwrap(), h).unwrap(); + // The dimension stays 20 tall between its points and dimension line; the extension line + // offset and overshoot (DIMEXO, DIMEXE) are 4× their paper size. + assert!(((at_4 - 20.0) / (at_1 - 20.0) - 4.0).abs() < 1e-6, "{at_1} → {at_4}"); +} + +#[test] +fn annotative_multileader_and_hatch() { + let mut s = Session::new(); + s.execute("mleaderstyle", &json!({ "name": "Anno", "annotative": true })).unwrap(); + let r = s.execute("mleader", &json!({ "points": [[0, 0], [5, 5]], "text": "A" })).unwrap(); + let ml = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap(); + assert_eq!(scales(&s, ml), ["1:1"]); + setvar(&mut s, "HPANNOTATIVE", json!(1)); + s.execute("rectang", &json!({ "p1": [20, 0], "p2": [30, 10] })).unwrap(); + let r = s.execute("hatch", &json!({ "points": [[25, 5]], "pattern": "ANSI31" })).unwrap(); + let hh = r["handle"].as_str().or_else(|| r["handles"][0].as_str()).and_then(Handle::parse_hex).unwrap(); + assert_eq!(scales(&s, hh), ["1:1"]); + // At 1:2 the hatch pattern doubles: fewer, longer lines over the same area. + let count = |s: &Session| { + let d = s.doc().unwrap(); + let list = cadcraft_engine::render::build(d, &Space::Model, &Default::default()); + list.prims.iter().filter(|p| p.handle == hh).count() + }; + let n1 = count(&s); + setvar(&mut s, "ANNOAUTOSCALE", json!(1)); + setvar(&mut s, "CANNOSCALE", json!("1:2")); + let n2 = count(&s); + assert!(n2 < n1, "{n1} → {n2}"); +} + +#[test] +fn explode_gives_plain_objects_at_the_current_scale() { + let (mut s, h) = with_text(); + s.execute("objectscale", &json!({ "handles": [h.hex()], "add": "1:10" })).unwrap(); + setvar(&mut s, "CANNOSCALE", json!("1:10")); + // Text doesn't explode; a block reference of an annotative block does. + s.execute("block", &json!({ "name": "TAG", "base": [0, 0], "handles": [h.hex()], "annotative": true })).unwrap(); + let ins = s.doc().unwrap().model.last().unwrap().handle; + assert_eq!(scales(&s, ins), ["1:10"]); + s.execute("explode", &json!({ "handles": [ins.hex()] })).unwrap(); + let d = s.doc().unwrap(); + let t = d.model.iter().find(|e| matches!(e.kind, EntityKind::Text(_))).unwrap(); + match &t.kind { + EntityKind::Text(t) => assert!((t.height - 2.5).abs() < 1e-9, "{}", t.height), + _ => unreachable!(), + } +} + +#[test] +fn annoupdate_and_properties_follow_the_style() { + let mut s = Session::new(); + s.execute("style", &json!({ "name": "Notes", "current": true })).unwrap(); + let r = s.execute("text", &json!({ "at": [0, 0], "text": "N", "height": 0.25 })).unwrap(); + let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap(); + assert!(scales(&s, h).is_empty()); + s.execute("style", &json!({ "name": "Notes", "annotative": true })).unwrap(); + s.execute("annoupdate", &json!({ "handles": [h.hex()] })).unwrap(); + assert_eq!(scales(&s, h), ["1:1"]); + s.execute("properties.set", &json!({ "handles": [h.hex()], "annotative": false })).unwrap(); + assert!(scales(&s, h).is_empty()); + s.execute("properties.set", &json!({ "handles": [h.hex()], "annotative": true })).unwrap(); + assert_eq!(scales(&s, h), ["1:1"]); + let r = s.execute("annoreset", &json!({ "handles": [h.hex()] })).unwrap(); + assert_eq!(r["objects"], 1); + // Lines can't be annotative. + let r = s.execute("line", &json!({ "points": [[0, 0], [1, 1]] })).unwrap(); + let l = r["handles"][0].as_str().or_else(|| r["handle"].as_str()).and_then(Handle::parse_hex).unwrap(); + s.execute("properties.set", &json!({ "handles": [l.hex()], "annotative": true })).unwrap(); + assert!(scales(&s, l).is_empty()); +} + +#[test] +fn hostile_parameters_are_refused() { + let (mut s, h) = with_text(); + for p in [ + json!({ "handles": [h.hex()], "add": 5 }), + json!({ "handles": [h.hex()], "delete": [1, 2] }), + json!({ "handles": "x" }), + json!({ "handles": [] }), + ] { + assert!(s.execute("objectscale", &p).is_err(), "{p}"); + } + for p in [ + json!({ "add": { "name": "", "ratio": "1:2" } }), + json!({ "add": { "name": "x", "ratio": "1:inf" } }), + json!({ "add": { "name": "x", "paperUnits": 1e308, "drawingUnits": 1e-308 } }), + json!({ "add": "1:2" }), + json!({ "reset": 3 }), + json!({ "delete": "missing" }), + ] { + assert!(s.execute("scalelistedit", &p).is_err(), "{p}"); + } +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 0014d49..be28d77 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1746,6 +1746,7 @@ impl Objects { landing_gap: num(42, def.landing_gap, 0.0), dogleg: num(43, def.dogleg, 0.0), text_style: text_style.unwrap_or(def.text_style), + annotative: false, }; match d.mleader_styles.iter_mut().find(|s| s.name.eq_ignore_ascii_case(name)) { Some(x) => *x = st, diff --git a/crates/io/tests/tables_objects_roundtrip.rs b/crates/io/tests/tables_objects_roundtrip.rs index 93b3898..15078c7 100644 --- a/crates/io/tests/tables_objects_roundtrip.rs +++ b/crates/io/tests/tables_objects_roundtrip.rs @@ -62,8 +62,15 @@ fn linetype_text_and_shape_elements_round_trip() { fn multileader_styles_and_current_style_round_trip() { let mut d = Drawing::new_imperial(); d.text_styles.push(TextStyle { name: "Slanted".into(), oblique: 0.2, ..TextStyle::default() }); - let callout = - MLeaderStyle { name: "Callout".into(), arrow_size: 2.0, text_height: 3.0, landing_gap: 1.0, dogleg: 5.0, text_style: "Slanted".into() }; + let callout = MLeaderStyle { + name: "Callout".into(), + arrow_size: 2.0, + text_height: 3.0, + landing_gap: 1.0, + dogleg: 5.0, + text_style: "Slanted".into(), + annotative: false, + }; d.mleader_styles.push(callout); d.header.set_str("CMLEADERSTYLE", "Callout"); let r = round_trip(&d); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 94cd328..fd36084 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -581,6 +581,24 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { } fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { + // A top-level annotative object draws at the view's annotation scale: a viewport's scale for + // model space seen through it, else the drawing's (CANNOSCALE in model space, 1:1 on paper). + if ctx.depth == 0 && cadcraft_doc::is_annotative(e) { + let mode = if ctx.top == VIEWPORT_CONTENT { + cadcraft_doc::anno_draw(e, 1.0 / ctx.xf.scale_factor().max(1e-12), ctx.d.anno_all_visible()) + } else { + ctx.d.anno_draw(e) + }; + match mode { + cadcraft_doc::AnnoDraw::Hidden => return, + cadcraft_doc::AnnoDraw::Scaled(f) => { + let mut scaled = cadcraft_doc::anno_scaled(e, f); + scaled.common.anno_scales.clear(); + return entity_parts(b, ctx, &scaled); + } + cadcraft_doc::AnnoDraw::Plain => {} + } + } let (rgb, lw, lt, ltscale, visible) = resolve(ctx, e, b.plotting); if !visible { return; diff --git a/crates/render/tests/annotative_viewport.rs b/crates/render/tests/annotative_viewport.rs new file mode 100644 index 0000000..1e93b1a --- /dev/null +++ b/crates/render/tests/annotative_viewport.rs @@ -0,0 +1,86 @@ +//! Annotative objects seen through layout viewports (#468): they draw at their paper size in +//! every viewport whose scale they support, and not at all in others when ANNOALLVISIBLE is 0. + +use cadcraft_doc::{AnnoScale, Common, Drawing, EntityKind, Space, Text, Viewport}; +use cadcraft_geom::{Bounds2, Vec3}; +use cadcraft_render::{Options, VIEWPORT_CONTENT, build}; + +/// A viewport on Layout1, 100 × 100 paper units, showing a model window `100 × scale`. +fn viewport(x: f64, scale: f64) -> Viewport { + serde_json::from_value(serde_json::json!({ + "center": [x, 50.0, 0.0], + "width": 100.0, + "height": 100.0, + "viewCenter": [0.0, 0.0], + "viewHeight": 100.0 * scale, + "id": 2, + })) + .unwrap() +} + +/// A metric drawing with annotative text 2.5 high on paper supporting 1:50 and 1:100, seen +/// through one viewport at `scale`. +fn drawing(scale: f64, all_visible: bool) -> Drawing { + let mut d = Drawing::new_metric(); + d.header.set_i64("ANNOALLVISIBLE", i64::from(all_visible)); + let anno = Common { anno_scales: vec![AnnoScale::new("1:50", 1.0, 50.0), AnnoScale::new("1:100", 1.0, 100.0)], ..Default::default() }; + let text = Text { + insert: Vec3::ZERO, + align_pt: None, + height: 2.5, + value: "ROOM".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: Default::default(), + valign: Default::default(), + }; + d.add(&Space::Model, anno, EntityKind::Text(text)).unwrap(); + d.add(&Space::Paper("Layout1".into()), Common::default(), EntityKind::Viewport(viewport(50.0, scale))).unwrap(); + d +} + +/// Bounds of the model-space content drawn in the viewport, in paper units. +fn content(d: &Drawing) -> Bounds2 { + let l = build(d, &Space::Paper("Layout1".into()), &Options::default()); + let mut b = Bounds2::EMPTY; + for p in l.prims.iter().filter(|p| p.handle == VIEWPORT_CONTENT) { + for q in l.points(p) { + b.add(*q); + } + } + b +} + +#[test] +fn same_paper_height_at_every_supported_scale() { + let at_50 = content(&drawing(50.0, false)); + let at_100 = content(&drawing(100.0, false)); + assert!(!at_50.is_empty() && !at_100.is_empty()); + assert!((at_50.height() / at_100.height() - 1.0).abs() < 1e-9, "{} vs {}", at_50.height(), at_100.height()); + assert!((at_50.width() / at_100.width() - 1.0).abs() < 1e-9); + // Glyphs span most of the 2.5 text height on paper. + assert!(at_50.height() > 1.5 && at_50.height() < 3.5, "{}", at_50.height()); +} + +#[test] +fn unsupported_viewport_scale_hides_or_shows_at_the_objects_scale() { + assert!(content(&drawing(20.0, false)).is_empty()); + // ANNOALLVISIBLE 1: drawn at its first scale (1:50), so 50/20 = 2.5× its paper size here. + let shown = content(&drawing(20.0, true)); + let at_50 = content(&drawing(50.0, false)); + assert!((shown.height() / at_50.height() - 2.5).abs() < 1e-9, "{} vs {}", shown.height(), at_50.height()); +} + +#[test] +fn model_space_uses_cannoscale() { + let mut d = drawing(50.0, false); + let list = |d: &Drawing| build(d, &Space::Model, &Options::default()).prims.len(); + // CANNOSCALE 1:1 isn't supported: hidden. + assert_eq!(list(&d), 0); + d.header.set_str("CANNOSCALE", "1:100"); + assert!(list(&d) > 0); + let b = build(&d, &Space::Model, &Options::default()).bounds; + assert!(b.height() > 150.0 && b.height() < 350.0, "{}", b.height()); +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b9f537e..2390952 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -329,6 +329,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { } // Right side: toggles, right-aligned. let s = app.session.settings.clone(); + let anno_all = app.session.doc().is_ok_and(|d| d.anno_all_visible()); use cadcraft_engine::snap::mode; let toggles: Vec<(Icon, bool, &str, &str)> = vec![ (Icon::Grid, s.gridmode, "grid", "Grid (F7)"), @@ -342,7 +343,7 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { (Icon::Transparency, s.transparency_display, "transparencydisplay", "Show/Hide Transparency"), (Icon::DynInput, s.dynmode, "dynmode", "Dynamic Input (F12)"), (Icon::QuickProps, s.qpmode, "qpmode", "Quick Properties"), - (Icon::Annotation, s.annoallvisible, "ui.noop", "Annotation Visibility"), + (Icon::Annotation, anno_all, "annovisible.toggle", "Annotation Visibility"), (Icon::Workspace, false, "ui.noop", "Workspace Switching"), (Icon::Gear, false, "ui.dialog.dsettings", "Drafting Settings"), ]; diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6465a25..b0ee326 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1038,6 +1038,16 @@ Import... Імпорт... Export... Експорт... Batch Publish... Пакетна публікація... + Scale List... Список масштабів... + Annotative Object Scale Масштаб анотативних об'єктів + Add Current Scale Додати поточний масштаб + Delete Current Scale Видалити поточний масштаб + Add/Delete Scales... Додати/видалити масштаби... + Synchronize Multiple-scale Positions Синхронізувати положення для кількох масштабів + Annotative Update Оновити анотативні об'єкти + Show Annotative Objects Показувати анотативні об'єкти + Current Scale Only Лише поточний масштаб + All Scales Усі масштаби Drawing Recovery Manager... Диспетчер відновлення креслень... Tolerance... Допуск... Add Vertex Додати вершину