mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
Squash of PR #486 by @vangel76. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c0a19dfb9e
commit
e7bb447d58
23 files changed
+1352
-13
No files matched your search
@@ -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<AnnoScale> {
|
||||
let mut v: Vec<AnnoScale> =
|
||||
[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<AnnoScale> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<crate::AnnoScale>,
|
||||
}
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ pub struct Drawing {
|
||||
pub constraints: Vec<Constraint>,
|
||||
/// User parameters and parametric settings.
|
||||
pub parametric: Parametric,
|
||||
/// The annotation scale list (SCALELISTEDIT).
|
||||
pub scales: Vec<AnnoScale>,
|
||||
/// 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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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::<Vec<_>>(), "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<Vec<String>> {
|
||||
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<AnnoScale> {
|
||||
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<Handle> = 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<Handle>, 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<Handle> = 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::<Vec<_>>() }))
|
||||
.collect::<Vec<_>>()
|
||||
)
|
||||
}
|
||||
|
||||
fn run_objectscale(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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::<Result<Vec<_>>>()?;
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>()));
|
||||
}
|
||||
extra
|
||||
}
|
||||
|
||||
fn run_annoreset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<bool>) -> Result<Value> {
|
||||
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<AnnoScale> {
|
||||
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<Value> {
|
||||
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<String> = 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<Step> {
|
||||
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<Handle>,
|
||||
}
|
||||
|
||||
impl ObjectScaleM {
|
||||
/// Keep the annotative objects of the selection; false when there are none.
|
||||
fn take(&mut self, s: &mut Session, picked: Vec<Handle>) -> Result<bool> {
|
||||
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::<Vec<_>>() });
|
||||
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<String> = 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<Step> {
|
||||
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<Step> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<Value> {
|
||||
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()) }))
|
||||
}
|
||||
|
||||
|
||||
@@ -1414,11 +1414,10 @@ impl Interactive for TextM {
|
||||
}
|
||||
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
||||
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<Step> {
|
||||
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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -1134,6 +1134,15 @@ fn run_chamfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ---------------- explode / array / order / break / join ----------------
|
||||
|
||||
pub(crate) fn explode_kind(d: &cadcraft_doc::Drawing, e: &Entity) -> Option<Vec<Entity>> {
|
||||
// An annotative object explodes into plain objects as it draws at the current annotation scale.
|
||||
if cadcraft_doc::is_annotative(e) {
|
||||
let mut se = match d.anno_draw(e) {
|
||||
cadcraft_doc::AnnoDraw::Scaled(f) => cadcraft_doc::anno_scaled(e, f),
|
||||
_ => e.clone(),
|
||||
};
|
||||
se.common.anno_scales.clear();
|
||||
return explode_kind(d, &se);
|
||||
}
|
||||
match &e.kind {
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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::<Vec<_>>()));
|
||||
}
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ pub struct SpatialIndex {
|
||||
blocks: BTreeMap<String, Arc<Block>>,
|
||||
dim_styles: Vec<DimStyle>,
|
||||
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<ChunkIx>)>,
|
||||
/// 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(),
|
||||
|
||||
@@ -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<Value> {
|
||||
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" => {
|
||||
|
||||
@@ -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<String> {
|
||||
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<f64> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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 Додати вершину
|
||||
|
||||
|
Reference in new issue
Block a user