Layers and objects get a PlotStyle property (ByLayer/ByBlock/name) applied through named tables (built-in default.stb and monochrome.stb, our own definitions). DXF keeps the names in the ACAD_PLOTSTYLENAME dictionary of ACDBPLACEHOLDER objects with group 390 on layers and objects; tables kept in the drawing travel in a CADCRAFT_PLOTSTYLES XRECORD. PLOTSTYLE sets an object's or the current (CPLOTSTYLE) style; STYLESMANAGER lists, creates, edits, deletes, loads and saves tables in our own JSON format (docs/plot-styles.md). A Plot Style Manager dialog edits a table's styles and the layout's table; the Properties palette shows the current plot style and table. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
58a349772e
commit
fc4220e9b7
|
|
@ -94,6 +94,9 @@ pub struct Common {
|
|||
pub visible: bool,
|
||||
pub thickness: f64,
|
||||
pub extrusion: Vec3,
|
||||
/// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot
|
||||
/// style table.
|
||||
pub plot_style: String,
|
||||
}
|
||||
|
||||
impl Default for Common {
|
||||
|
|
@ -108,6 +111,7 @@ impl Default for Common {
|
|||
visible: true,
|
||||
thickness: 0.0,
|
||||
extrusion: Vec3::Z,
|
||||
plot_style: "ByLayer".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,9 @@ pub struct Drawing {
|
|||
pub ucss: Vec<Ucs>,
|
||||
pub layer_states: Vec<LayerState>,
|
||||
pub groups: Vec<Group>,
|
||||
/// Plot style tables kept with the drawing (STYLESMANAGER); they shadow built-in tables of
|
||||
/// the same name.
|
||||
pub plot_style_tables: Vec<PlotStyleTable>,
|
||||
/// Parametric constraints (GEOMCONSTRAINT / DIMCONSTRAINT).
|
||||
pub constraints: Vec<Constraint>,
|
||||
/// User parameters and parametric settings.
|
||||
|
|
@ -115,6 +118,7 @@ impl Drawing {
|
|||
ucss: Vec::new(),
|
||||
layer_states: Vec::new(),
|
||||
groups: Vec::new(),
|
||||
plot_style_tables: Vec::new(),
|
||||
constraints: Vec::new(),
|
||||
parametric: Parametric::default(),
|
||||
handseed: 0x100,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@
|
|||
//!
|
||||
//! CADCraft's built-in tables are our own definitions, not copies of any vendor's files:
|
||||
//! [`DEFAULT_CTB`] keeps the object's properties, [`MONOCHROME_CTB`] prints everything black
|
||||
//! and [`GRAYSCALE_CTB`] prints every colour as the grey of its luminance.
|
||||
//! and [`GRAYSCALE_CTB`] prints every colour as the grey of its luminance; the named
|
||||
//! [`DEFAULT_STB`] offers `Normal`, `Black`, `Screened 50%` and `Thick`, and
|
||||
//! [`MONOCHROME_STB`] prints `Normal` black.
|
||||
//!
|
||||
//! Tables are saved as files in our own JSON format (see `docs/plot-styles.md`):
|
||||
//! [`PlotStyleTable::to_file_text`] and [`PlotStyleTable::from_file_text`].
|
||||
|
||||
use cadcraft_color::Rgb;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -19,8 +24,24 @@ pub const MONOCHROME_CTB: &str = "monochrome.ctb";
|
|||
/// Built-in colour-dependent table that prints every colour as a grey of its luminance.
|
||||
pub const GRAYSCALE_CTB: &str = "grayscale.ctb";
|
||||
|
||||
/// Built-in named table: `Normal` (object properties), `Black`, `Screened 50%` and `Thick`.
|
||||
pub const DEFAULT_STB: &str = "default.stb";
|
||||
/// Built-in named table whose `Normal` style prints black.
|
||||
pub const MONOCHROME_STB: &str = "monochrome.stb";
|
||||
|
||||
/// The names of the built-in tables.
|
||||
pub const BUILTIN_PLOT_STYLE_TABLES: &[&str] = &[DEFAULT_CTB, MONOCHROME_CTB, GRAYSCALE_CTB];
|
||||
pub const BUILTIN_PLOT_STYLE_TABLES: &[&str] = &[DEFAULT_CTB, MONOCHROME_CTB, GRAYSCALE_CTB, DEFAULT_STB, MONOCHROME_STB];
|
||||
|
||||
/// The style every named table has, which keeps the object's properties unless redefined.
|
||||
pub const NORMAL_STYLE: &str = "Normal";
|
||||
|
||||
/// Most styles a named table may hold.
|
||||
pub const MAX_NAMED_STYLES: usize = 1024;
|
||||
/// Largest table file we read, in bytes.
|
||||
pub const MAX_TABLE_FILE: usize = 4 << 20;
|
||||
/// Format marker and version of our table files.
|
||||
const FILE_MARKER: &str = "cadcraftPlotStyleTable";
|
||||
const FILE_VERSION: u64 = 1;
|
||||
|
||||
/// Number of styles in a colour-dependent table (one per index colour 1..=255).
|
||||
pub const CTB_STYLES: usize = 255;
|
||||
|
|
@ -66,27 +87,76 @@ pub enum FillStyle {
|
|||
VerticalBars,
|
||||
}
|
||||
|
||||
/// One plot style.
|
||||
/// One plot style. Serialized fields left at "use the object's" are omitted.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PlotStyle {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub description: String,
|
||||
/// Output colour; `None` uses the object's colour.
|
||||
/// Output colour (`"#rrggbb"` in JSON); `None` uses the object's colour.
|
||||
#[serde(with = "rgb_hex", skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Rgb>,
|
||||
/// Print the colour as the grey of its luminance.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
pub grayscale: bool,
|
||||
/// Ink intensity in percent, 0 (white) to 100 (full colour).
|
||||
#[serde(skip_serializing_if = "is_full")]
|
||||
pub screening: u8,
|
||||
/// Linetype name; `None` uses the object's linetype.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub linetype: Option<String>,
|
||||
/// Lineweight in hundredths of a millimetre (0..=211); `None` uses the object's lineweight.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lineweight: Option<u16>,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub end: EndStyle,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub join: JoinStyle,
|
||||
#[serde(skip_serializing_if = "is_default")]
|
||||
pub fill: FillStyle,
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
fn is_full(s: &u8) -> bool {
|
||||
*s >= 100
|
||||
}
|
||||
fn is_default<T: Default + PartialEq>(v: &T) -> bool {
|
||||
*v == T::default()
|
||||
}
|
||||
|
||||
/// `Option<Rgb>` as `"#rrggbb"` (also read from `[r, g, b]`).
|
||||
mod rgb_hex {
|
||||
use cadcraft_color::Rgb;
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(c: &Option<Rgb>, s: S) -> Result<S::Ok, S::Error> {
|
||||
match c {
|
||||
Some(c) => s.serialize_str(&c.hex()),
|
||||
None => s.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Any {
|
||||
Hex(String),
|
||||
Triple(u8, u8, u8),
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Rgb>, D::Error> {
|
||||
Ok(match Option::<Any>::deserialize(d)? {
|
||||
None => None,
|
||||
Some(Any::Triple(r, g, b)) => Some(Rgb(r, g, b)),
|
||||
Some(Any::Hex(h)) => {
|
||||
Some(Rgb::parse_hex(h.trim().trim_start_matches('#')).ok_or_else(|| serde::de::Error::custom("colour must be \"#rrggbb\""))?)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlotStyle {
|
||||
fn default() -> Self {
|
||||
PlotStyle::object("")
|
||||
|
|
@ -167,11 +237,39 @@ impl PlotStyleTable {
|
|||
PlotStyleTable { name: name.into(), description: description.into(), kind: PlotStyleKind::ColorDependent, styles }
|
||||
}
|
||||
|
||||
/// A built-in table by name (case-insensitive; the extension may be left out).
|
||||
/// A named table holding `Normal` (object properties) followed by `styles`.
|
||||
pub fn named(name: &str, description: &str, styles: Vec<PlotStyle>) -> PlotStyleTable {
|
||||
let mut all = vec![PlotStyle::object(NORMAL_STYLE)];
|
||||
all.extend(styles);
|
||||
PlotStyleTable { name: name.into(), description: description.into(), kind: PlotStyleKind::Named, styles: all }.sanitized()
|
||||
}
|
||||
|
||||
/// A built-in table by name (case-insensitive; a `.ctb` extension may be left out).
|
||||
pub fn builtin(name: &str) -> Option<PlotStyleTable> {
|
||||
let n = name.trim().to_ascii_lowercase();
|
||||
let n = if n.ends_with(".ctb") { n } else { format!("{n}.ctb") };
|
||||
let n = if n.ends_with(".ctb") || n.ends_with(".stb") { n } else { format!("{n}.ctb") };
|
||||
let black = Some(Rgb(0, 0, 0));
|
||||
match n.as_str() {
|
||||
DEFAULT_STB => Some(PlotStyleTable::named(
|
||||
DEFAULT_STB,
|
||||
"Normal keeps object properties; Black, Screened 50% and Thick",
|
||||
vec![
|
||||
PlotStyle { name: "Black".into(), color: black, ..PlotStyle::default() },
|
||||
PlotStyle { name: "Screened 50%".into(), screening: 50, ..PlotStyle::default() },
|
||||
PlotStyle { name: "Thick".into(), lineweight: Some(50), ..PlotStyle::default() },
|
||||
],
|
||||
)),
|
||||
MONOCHROME_STB => {
|
||||
let mut t = PlotStyleTable::named(
|
||||
MONOCHROME_STB,
|
||||
"Normal prints black; Screened 50% prints grey",
|
||||
vec![PlotStyle { name: "Screened 50%".into(), color: black, screening: 50, ..PlotStyle::default() }],
|
||||
);
|
||||
if let Some(n) = t.styles.first_mut() {
|
||||
n.color = black;
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
DEFAULT_CTB => Some(PlotStyleTable::color_dependent(DEFAULT_CTB, "Object colours, linetypes and lineweights", |_| None)),
|
||||
MONOCHROME_CTB => Some(PlotStyleTable::color_dependent(MONOCHROME_CTB, "Every colour prints black", |_| {
|
||||
Some(PlotStyle { color: Some(Rgb(0, 0, 0)), ..PlotStyle::default() })
|
||||
|
|
@ -200,6 +298,78 @@ impl PlotStyleTable {
|
|||
pub fn by_name(&self, name: &str) -> Option<&PlotStyle> {
|
||||
self.styles.iter().find(|s| s.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// The table with hostile or inconsistent values fixed: screening at most 100 %,
|
||||
/// lineweights at most 2.11 mm, names at most 255 characters; a colour-dependent table has
|
||||
/// exactly 255 styles named `Color_1`..`Color_255`; a named table has unique, non-empty
|
||||
/// style names (at most [`MAX_NAMED_STYLES`]) starting with `Normal`.
|
||||
pub fn sanitized(mut self) -> PlotStyleTable {
|
||||
let cut = |s: &str| s.chars().take(255).collect::<String>();
|
||||
self.name = cut(self.name.trim());
|
||||
self.description = cut(&self.description);
|
||||
for s in &mut self.styles {
|
||||
s.name = cut(s.name.trim());
|
||||
s.description = cut(&s.description);
|
||||
s.screening = s.screening.min(100);
|
||||
s.lineweight = s.lineweight.map(|w| w.min(211));
|
||||
s.linetype = s.linetype.take().map(|l| cut(l.trim())).filter(|l| !l.is_empty());
|
||||
}
|
||||
match self.kind {
|
||||
PlotStyleKind::ColorDependent => {
|
||||
self.styles.truncate(CTB_STYLES);
|
||||
while self.styles.len() < CTB_STYLES {
|
||||
self.styles.push(PlotStyle::object(""));
|
||||
}
|
||||
for (i, s) in self.styles.iter_mut().enumerate() {
|
||||
s.name = format!("Color_{}", i + 1);
|
||||
}
|
||||
}
|
||||
PlotStyleKind::Named => {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
self.styles.retain(|s| !s.name.is_empty() && seen.insert(s.name.to_lowercase()));
|
||||
self.styles.truncate(MAX_NAMED_STYLES);
|
||||
match self.styles.iter().position(|s| s.name.eq_ignore_ascii_case(NORMAL_STYLE)) {
|
||||
Some(0) => {}
|
||||
Some(i) => {
|
||||
let n = self.styles.remove(i);
|
||||
self.styles.insert(0, n);
|
||||
}
|
||||
None => {
|
||||
self.styles.insert(0, PlotStyle::object(NORMAL_STYLE));
|
||||
self.styles.truncate(MAX_NAMED_STYLES);
|
||||
}
|
||||
}
|
||||
if let Some(n) = self.styles.first_mut() {
|
||||
n.name = NORMAL_STYLE.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// The table as a file in our own format: pretty-printed JSON with a format marker.
|
||||
pub fn to_file_text(&self) -> String {
|
||||
let mut v = serde_json::to_value(self).unwrap_or(serde_json::Value::Null);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert(FILE_MARKER.into(), FILE_VERSION.into());
|
||||
}
|
||||
serde_json::to_string_pretty(&v).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Read a table file written by [`PlotStyleTable::to_file_text`] (sanitized).
|
||||
pub fn from_file_text(text: &str) -> std::result::Result<PlotStyleTable, String> {
|
||||
if text.len() > MAX_TABLE_FILE {
|
||||
return Err("the plot style table file is too large".into());
|
||||
}
|
||||
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("not a CADCraft plot style table: {e}"))?;
|
||||
match v.get(FILE_MARKER).and_then(serde_json::Value::as_u64) {
|
||||
Some(FILE_VERSION) => {}
|
||||
Some(n) => return Err(format!("plot style table format version {n} is newer than this CADCraft reads")),
|
||||
None => return Err(format!("not a CADCraft plot style table (no `{FILE_MARKER}`)")),
|
||||
}
|
||||
let t: PlotStyleTable = serde_json::from_value(v).map_err(|e| format!("bad plot style table: {e}"))?;
|
||||
Ok(t.sanitized())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a page setup's table name means "no plot style table".
|
||||
|
|
@ -208,13 +378,27 @@ pub fn no_plot_style_table(name: &str) -> bool {
|
|||
n.is_empty() || n.eq_ignore_ascii_case("none")
|
||||
}
|
||||
|
||||
/// The plot style table a page setup names, if it is one we know; `None` for no table
|
||||
/// (empty or "None") and for tables we don't have.
|
||||
pub fn plot_style_table(_d: &crate::Drawing, name: &str) -> Option<PlotStyleTable> {
|
||||
/// The plot style table a page setup names, if it is one we know: the drawing's own tables
|
||||
/// first, then the built-in ones. `None` for no table (empty or "None") and for tables we don't
|
||||
/// have.
|
||||
pub fn plot_style_table(d: &crate::Drawing, name: &str) -> Option<PlotStyleTable> {
|
||||
if no_plot_style_table(name) {
|
||||
return None;
|
||||
}
|
||||
PlotStyleTable::builtin(name)
|
||||
let n = name.trim();
|
||||
d.plot_style_tables.iter().find(|t| t.name.eq_ignore_ascii_case(n)).cloned().or_else(|| PlotStyleTable::builtin(n))
|
||||
}
|
||||
|
||||
/// The names of every table a drawing can use: its own, then the built-in ones it doesn't
|
||||
/// shadow.
|
||||
pub fn plot_style_table_names(d: &crate::Drawing) -> Vec<String> {
|
||||
let mut v: Vec<String> = d.plot_style_tables.iter().map(|t| t.name.clone()).collect();
|
||||
for b in BUILTIN_PLOT_STYLE_TABLES {
|
||||
if !v.iter().any(|n| n.eq_ignore_ascii_case(b)) {
|
||||
v.push((*b).to_string());
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -223,9 +407,9 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn builtins_are_complete_color_dependent_tables() {
|
||||
for n in BUILTIN_PLOT_STYLE_TABLES {
|
||||
for n in [DEFAULT_CTB, MONOCHROME_CTB, GRAYSCALE_CTB] {
|
||||
let t = PlotStyleTable::builtin(n).unwrap();
|
||||
assert_eq!(&t.name, n);
|
||||
assert_eq!(t.name, n);
|
||||
assert!(t.is_color_dependent());
|
||||
assert_eq!(t.styles.len(), CTB_STYLES);
|
||||
assert_eq!(t.by_color(1).unwrap().name, "Color_1");
|
||||
|
|
@ -251,6 +435,63 @@ mod tests {
|
|||
assert!(PlotStyle::default().keeps_color() && !half.keeps_color());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_builtins_and_sanitizing() {
|
||||
let t = PlotStyleTable::builtin("default.stb").unwrap();
|
||||
assert!(!t.is_color_dependent());
|
||||
assert_eq!(t.styles.first().unwrap().name, NORMAL_STYLE);
|
||||
assert!(t.by_name("black").is_some() && t.by_color(1).is_none());
|
||||
assert_eq!(PlotStyleTable::builtin(MONOCHROME_STB).unwrap().by_name("Normal").unwrap().color, Some(Rgb(0, 0, 0)));
|
||||
// Hostile values are clamped; names fixed.
|
||||
let bad = PlotStyleTable {
|
||||
name: " x.stb ".into(),
|
||||
kind: PlotStyleKind::Named,
|
||||
styles: vec![
|
||||
PlotStyle { name: "A".into(), screening: 250, lineweight: Some(9999), ..PlotStyle::default() },
|
||||
PlotStyle { name: "a".into(), ..PlotStyle::default() },
|
||||
PlotStyle { name: " ".into(), ..PlotStyle::default() },
|
||||
],
|
||||
..PlotStyleTable::default()
|
||||
}
|
||||
.sanitized();
|
||||
assert_eq!(bad.name, "x.stb");
|
||||
assert_eq!(bad.styles.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Normal", "A"]);
|
||||
assert_eq!((bad.styles[1].screening, bad.styles[1].lineweight), (100, Some(211)));
|
||||
let short = PlotStyleTable { styles: vec![PlotStyle::object("zz")], ..PlotStyleTable::default() }.sanitized();
|
||||
assert_eq!(short.styles.len(), CTB_STYLES);
|
||||
assert_eq!(short.styles[0].name, "Color_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_format_round_trip() {
|
||||
for name in BUILTIN_PLOT_STYLE_TABLES {
|
||||
let t = PlotStyleTable::builtin(name).unwrap();
|
||||
let text = t.to_file_text();
|
||||
assert!(text.contains("\"cadcraftPlotStyleTable\": 1"));
|
||||
assert_eq!(PlotStyleTable::from_file_text(&text).unwrap(), t, "{name}");
|
||||
}
|
||||
let mono = PlotStyleTable::builtin(MONOCHROME_CTB).unwrap().to_file_text();
|
||||
assert!(mono.contains("\"color\": \"#000000\""), "colours as hex");
|
||||
assert!(!mono.contains("screening"), "defaults are left out");
|
||||
assert!(PlotStyleTable::from_file_text("{}").is_err());
|
||||
assert!(PlotStyleTable::from_file_text("{\"cadcraftPlotStyleTable\": 9}").is_err());
|
||||
assert!(PlotStyleTable::from_file_text("not json").is_err());
|
||||
let rgb: PlotStyle = serde_json::from_value(serde_json::json!({"name": "x", "color": [1, 2, 3]})).unwrap();
|
||||
assert_eq!(rgb.color, Some(Rgb(1, 2, 3)));
|
||||
assert!(serde_json::from_value::<PlotStyle>(serde_json::json!({"color": "#zz0000"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_tables_shadow_builtins() {
|
||||
let mut d = crate::Drawing::new_metric();
|
||||
assert!(plot_style_table(&d, "monochrome.ctb").unwrap().by_color(1).unwrap().color.is_some());
|
||||
d.plot_style_tables.push(PlotStyleTable::color_dependent("Monochrome.ctb", "", |_| None));
|
||||
assert!(plot_style_table(&d, "monochrome.ctb").unwrap().by_color(1).unwrap().color.is_none());
|
||||
let names = plot_style_table_names(&d);
|
||||
assert_eq!(names.iter().filter(|n| n.eq_ignore_ascii_case("monochrome.ctb")).count(), 1);
|
||||
assert!(names.contains(&"default.stb".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_round_trip_with_defaults() {
|
||||
let t = PlotStyleTable::builtin(GRAYSCALE_CTB).unwrap();
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.interactive(|_| Ok(Box::new(prompts::LayerM::default()))),
|
||||
CommandSpec::new("layer.new", "New Layer", run_new).params("{name, color?, linetype?, lineweight? (mm), current?: bool}"),
|
||||
CommandSpec::new("layer.set", "Set Layer Properties", run_set)
|
||||
.params("{name, on?, frozen?, locked?, plot?, color?, linetype?, lineweight?, transparency?, description?, newVpFreeze?, newName?}"),
|
||||
.params("{name, on?, frozen?, locked?, plot?, color?, linetype?, lineweight?, transparency?, description?, newVpFreeze?, plotStyle? (named plot style), newName?}"),
|
||||
CommandSpec::new("layer.current", "Make Current", run_current)
|
||||
.menu(&["Format", "Layer Tools", "Make Current"])
|
||||
.alias(&["clayer"])
|
||||
|
|
@ -73,7 +73,7 @@ fn layer_json(l: &Layer, current: bool) -> Value {
|
|||
"name": l.name, "on": l.on, "frozen": l.frozen, "locked": l.locked, "plot": l.plot,
|
||||
"color": l.color.name(), "colorRgb": l.color.resolve(Color::Index(7), Color::Index(7)).hex(),
|
||||
"linetype": l.linetype, "lineweight": l.lineweight.name(), "transparency": l.transparency,
|
||||
"description": l.description, "current": current, "newVpFreeze": l.vp_freeze_new,
|
||||
"description": l.description, "current": current, "newVpFreeze": l.vp_freeze_new, "plotStyle": l.plot_style,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -132,6 +132,12 @@ fn apply(l: &mut Layer, p: &Value) -> Result<()> {
|
|||
if let Some(v) = p.get("plot").and_then(Value::as_bool) {
|
||||
l.plot = v;
|
||||
}
|
||||
if let Some(v) = str_param(p, "plotStyle").map(str::trim) {
|
||||
if v.is_empty() || v.eq_ignore_ascii_case("bylayer") || v.eq_ignore_ascii_case("byblock") {
|
||||
return Err(bad("layer", "a layer's plot style is a style name (such as Normal)"));
|
||||
}
|
||||
l.plot_style = v.chars().take(255).collect();
|
||||
}
|
||||
if let Some(c) = p.get("color") {
|
||||
let c = c
|
||||
.as_str()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use super::file::{base64_encode, io};
|
|||
use super::*;
|
||||
use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
|
||||
|
||||
mod plotstyle;
|
||||
mod prompts;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
|
@ -79,6 +80,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
.noundo(),
|
||||
CommandSpec::new("pspace", "Paper Space", run_pspace).alias(&["ps"]).noundo(),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(plotstyle::specs())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---------- model space through viewports ----------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,347 @@
|
|||
//! Plot styles: PLOTSTYLE (an object's or the current named plot style) and STYLESMANAGER (the
|
||||
//! plot style tables: list, view, create, edit, delete, load and save in our own file format).
|
||||
//!
|
||||
//! Tables the drawing keeps live in `Drawing::plot_style_tables` and are saved with it; they
|
||||
//! shadow built-in tables of the same name. Editing a built-in table stores an edited copy in
|
||||
//! the drawing.
|
||||
|
||||
use cadcraft_doc::{Drawing, PlotStyle, PlotStyleKind, PlotStyleTable};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("plotstyle", "Plot Style...", run_plotstyle)
|
||||
.menu(&["Format", "Plot Style..."])
|
||||
.params(
|
||||
"{name?: \"ByLayer\"|\"ByBlock\"|style name, handles?|handle? (default: the selection; nothing selected sets the current plot style for new objects, CPLOTSTYLE)} (no name: reports the current style and the named styles)",
|
||||
),
|
||||
CommandSpec::new("stylesmanager", "Plot Style Manager...", run_stylesmanager)
|
||||
.menu(&["File", "Plot Styles..."])
|
||||
.params(
|
||||
"{action?: list (default)|get|new|set|delete|load|save, name?: table (\"x.ctb\"/\"x.stb\"), kind?: colorDependent|named (new), from?: table to copy (new), description?, style?: {name, color?: \"#rrggbb\"|null, grayscale?, screening?: 0-100, linetype?: name|null, lineweight?: mm|null, end?, join?, fill?, description?} (set), removeStyle?: name (set, named tables), path? (load/save; save without one returns `text`), text? (load)}",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ---------- PLOTSTYLE ----------
|
||||
|
||||
/// The style names an object can be given: those of the named tables the layouts use, else of
|
||||
/// every named table the drawing can use.
|
||||
fn named_styles(d: &Drawing) -> Vec<String> {
|
||||
let mut used: Vec<PlotStyleTable> =
|
||||
d.layouts.iter().filter_map(|l| cadcraft_doc::plot_style_table(d, &l.page.plot_style_table)).filter(|t| !t.is_color_dependent()).collect();
|
||||
if used.is_empty() {
|
||||
used = cadcraft_doc::plot_style_table_names(d)
|
||||
.iter()
|
||||
.filter_map(|n| cadcraft_doc::plot_style_table(d, n))
|
||||
.filter(|t| !t.is_color_dependent())
|
||||
.collect();
|
||||
}
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for s in used.iter().flat_map(|t| t.styles.iter()) {
|
||||
if !out.iter().any(|n| n.eq_ignore_ascii_case(&s.name)) {
|
||||
out.push(s.name.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A plot style property value: `ByLayer`, `ByBlock` or a style name (at most 255 characters).
|
||||
fn style_value(cmd: &str, v: &str) -> Result<String> {
|
||||
let v = v.trim();
|
||||
if v.is_empty() {
|
||||
return Err(bad(cmd, "the plot style name is empty"));
|
||||
}
|
||||
Ok(if v.eq_ignore_ascii_case("bylayer") {
|
||||
"ByLayer".into()
|
||||
} else if v.eq_ignore_ascii_case("byblock") {
|
||||
"ByBlock".into()
|
||||
} else {
|
||||
v.chars().take(255).collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn run_plotstyle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "plotstyle";
|
||||
let Some(name) = p.get("name") else {
|
||||
let d = s.doc()?;
|
||||
return Ok(json!({ "current": d.header.str("CPLOTSTYLE", "ByLayer"), "styles": named_styles(d) }));
|
||||
};
|
||||
let name = style_value(cmd, name.as_str().ok_or_else(|| bad(cmd, "`name` must be a plot style name"))?)?;
|
||||
let handles = targets(s, p)?;
|
||||
if handles.is_empty() {
|
||||
s.doc_mut()?.header.set_str("CPLOTSTYLE", &name);
|
||||
return Ok(json!({ "current": name }));
|
||||
}
|
||||
let mut changed = 0usize;
|
||||
for h in &handles {
|
||||
if super::super::curves::is_locked(s, *h) {
|
||||
continue;
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
if d.entity(*h).is_none() {
|
||||
return Err(bad(cmd, format!("no object {}", h.hex())));
|
||||
}
|
||||
d.modify_entity(*h, |e| e.common.plot_style = name.clone())?;
|
||||
changed += 1;
|
||||
}
|
||||
Ok(json!({ "changed": changed, "plotStyle": name }))
|
||||
}
|
||||
|
||||
// ---------- STYLESMANAGER ----------
|
||||
|
||||
fn table_summary(d: &Drawing, t: &PlotStyleTable) -> Value {
|
||||
let in_drawing = d.plot_style_tables.iter().any(|x| x.name.eq_ignore_ascii_case(&t.name));
|
||||
json!({
|
||||
"name": t.name,
|
||||
"description": t.description,
|
||||
"kind": if t.is_color_dependent() { "colorDependent" } else { "named" },
|
||||
"styles": t.styles.len(),
|
||||
"inDrawing": in_drawing,
|
||||
"builtin": PlotStyleTable::builtin(&t.name).is_some() && !in_drawing,
|
||||
})
|
||||
}
|
||||
|
||||
fn table_name(cmd: &str, p: &Value) -> Result<String> {
|
||||
let n = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "`name` (the table) is required"))?;
|
||||
Ok(n.chars().take(255).collect())
|
||||
}
|
||||
|
||||
/// The table `name` as the drawing sees it.
|
||||
fn find_table(cmd: &str, d: &Drawing, name: &str) -> Result<PlotStyleTable> {
|
||||
cadcraft_doc::plot_style_table(d, name).ok_or_else(|| bad(cmd, format!("no plot style table `{name}`")))
|
||||
}
|
||||
|
||||
/// Store `t` in the drawing, replacing a table of the same name.
|
||||
fn store(d: &mut Drawing, t: PlotStyleTable) {
|
||||
match d.plot_style_tables.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&t.name)) {
|
||||
Some(x) => *x = t,
|
||||
None => d.plot_style_tables.push(t),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply the fields present in `v` to style `st`.
|
||||
fn edit_style(cmd: &str, st: &mut PlotStyle, v: &Value) -> Result<()> {
|
||||
let opt_str = |k: &str| -> Result<Option<Option<String>>> {
|
||||
match v.get(k) {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(s)) if s.trim().is_empty() || s.eq_ignore_ascii_case("object") => Ok(Some(None)),
|
||||
Some(Value::String(s)) => Ok(Some(Some(s.trim().chars().take(255).collect()))),
|
||||
Some(_) => Err(bad(cmd, format!("`{k}` must be a string or null"))),
|
||||
}
|
||||
};
|
||||
if let Some(c) = opt_str("color")? {
|
||||
st.color = match c {
|
||||
None => None,
|
||||
Some(h) => {
|
||||
Some(cadcraft_color::Rgb::parse_hex(h.trim_start_matches('#')).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or null"))?)
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(lt) = opt_str("linetype")? {
|
||||
st.linetype = lt;
|
||||
}
|
||||
if let Some(d) = opt_str("description")? {
|
||||
st.description = d.unwrap_or_default();
|
||||
}
|
||||
if let Some(g) = v.get("grayscale") {
|
||||
st.grayscale = g.as_bool().ok_or_else(|| bad(cmd, "`grayscale` must be true or false"))?;
|
||||
}
|
||||
if let Some(sc) = v.get("screening") {
|
||||
let n = sc.as_f64().filter(|n| n.is_finite() && (0.0..=100.0).contains(n)).ok_or_else(|| bad(cmd, "`screening` must be 0 to 100"))?;
|
||||
st.screening = n.round() as u8;
|
||||
}
|
||||
match v.get("lineweight") {
|
||||
None => {}
|
||||
Some(Value::Null) => st.lineweight = None,
|
||||
Some(Value::String(s)) if s.eq_ignore_ascii_case("object") => st.lineweight = None,
|
||||
Some(w) => {
|
||||
let mm = w
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && (0.0..=2.11).contains(n))
|
||||
.ok_or_else(|| bad(cmd, "`lineweight` must be 0 to 2.11 mm or null"))?;
|
||||
st.lineweight = Some((mm * 100.0).round() as u16);
|
||||
}
|
||||
}
|
||||
fn field<T: serde::de::DeserializeOwned>(cmd: &str, v: &Value, k: &str, msg: &str) -> Result<Option<T>> {
|
||||
v.get(k).map(|x| serde_json::from_value(x.clone()).map_err(|_| bad(cmd, msg))).transpose()
|
||||
}
|
||||
if let Some(e) = field(cmd, v, "end", "`end` must be object, butt, square, round or diamond")? {
|
||||
st.end = e;
|
||||
}
|
||||
if let Some(j) = field(cmd, v, "join", "`join` must be object, miter, bevel, round or diamond")? {
|
||||
st.join = j;
|
||||
}
|
||||
if let Some(f) = field(
|
||||
cmd,
|
||||
v,
|
||||
"fill",
|
||||
"`fill` must be object, solid, checkerboard, crosshatch, diamonds, horizontalBars, slantLeft, slantRight, squareDots or verticalBars",
|
||||
)? {
|
||||
st.fill = f;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "stylesmanager";
|
||||
let action = str_param(p, "action").unwrap_or("list").to_ascii_lowercase();
|
||||
match action.as_str() {
|
||||
"list" => {
|
||||
let d = s.doc()?;
|
||||
let tables: Vec<Value> = cadcraft_doc::plot_style_table_names(d)
|
||||
.iter()
|
||||
.filter_map(|n| cadcraft_doc::plot_style_table(d, n))
|
||||
.map(|t| table_summary(d, &t))
|
||||
.collect();
|
||||
Ok(json!({ "tables": tables }))
|
||||
}
|
||||
"get" => {
|
||||
let d = s.doc()?;
|
||||
let t = find_table(cmd, d, &table_name(cmd, p)?)?;
|
||||
let mut v = serde_json::to_value(&t).unwrap_or(Value::Null);
|
||||
if let (Some(o), Value::Object(sum)) = (v.as_object_mut(), table_summary(d, &t)) {
|
||||
o.insert("inDrawing".into(), sum.get("inDrawing").cloned().unwrap_or(Value::Null));
|
||||
o.insert("builtin".into(), sum.get("builtin").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
"new" => {
|
||||
let mut name = table_name(cmd, p)?;
|
||||
let d = s.doc()?;
|
||||
let mut t = match str_param(p, "from") {
|
||||
Some(from) => find_table(cmd, d, from)?,
|
||||
None => {
|
||||
let named = match str_param(p, "kind").map(str::to_ascii_lowercase).as_deref() {
|
||||
None if name.to_ascii_lowercase().ends_with(".stb") => true,
|
||||
None | Some("colordependent" | "ctb") => false,
|
||||
Some("named" | "stb") => true,
|
||||
Some(_) => return Err(bad(cmd, "`kind` must be colorDependent or named")),
|
||||
};
|
||||
if named { PlotStyleTable::named("", "", Vec::new()) } else { PlotStyleTable::color_dependent("", "", |_| None) }
|
||||
}
|
||||
};
|
||||
let ext = if t.is_color_dependent() { ".ctb" } else { ".stb" };
|
||||
let lower = name.to_ascii_lowercase();
|
||||
if !lower.ends_with(ext) {
|
||||
if lower.ends_with(".ctb") || lower.ends_with(".stb") {
|
||||
return Err(bad(cmd, format!("a {} table's name ends in {ext}", if ext == ".ctb" { "colour-dependent" } else { "named" })));
|
||||
}
|
||||
name.push_str(ext);
|
||||
}
|
||||
if d.plot_style_tables.iter().any(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(bad(cmd, format!("plot style table `{name}` already exists")));
|
||||
}
|
||||
t.name = name;
|
||||
if let Some(desc) = str_param(p, "description") {
|
||||
t.description = desc.chars().take(255).collect();
|
||||
}
|
||||
let t = t.sanitized();
|
||||
let out = table_summary(d, &t);
|
||||
store(s.doc_mut()?, t);
|
||||
Ok(out)
|
||||
}
|
||||
"set" => {
|
||||
let name = table_name(cmd, p)?;
|
||||
let mut t = find_table(cmd, s.doc()?, &name)?;
|
||||
if let Some(desc) = str_param(p, "description") {
|
||||
t.description = desc.chars().take(255).collect();
|
||||
}
|
||||
if let Some(rm) = str_param(p, "removeStyle") {
|
||||
if t.is_color_dependent() {
|
||||
return Err(bad(cmd, "a colour-dependent table always has 255 styles"));
|
||||
}
|
||||
if rm.trim().eq_ignore_ascii_case(cadcraft_doc::NORMAL_STYLE) {
|
||||
return Err(bad(cmd, "the Normal style can't be removed"));
|
||||
}
|
||||
let before = t.styles.len();
|
||||
t.styles.retain(|x| !x.name.eq_ignore_ascii_case(rm.trim()));
|
||||
if t.styles.len() == before {
|
||||
return Err(bad(cmd, format!("`{name}` has no style `{rm}`")));
|
||||
}
|
||||
}
|
||||
if let Some(v) = p.get("style") {
|
||||
let sname = v.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty());
|
||||
let sname = sname.ok_or_else(|| bad(cmd, "`style.name` is required"))?;
|
||||
let idx = match t.styles.iter().position(|x| x.name.eq_ignore_ascii_case(sname)) {
|
||||
Some(i) => i,
|
||||
None if t.kind == PlotStyleKind::Named => {
|
||||
if t.styles.len() >= cadcraft_doc::MAX_NAMED_STYLES {
|
||||
return Err(bad(cmd, "the table has too many styles"));
|
||||
}
|
||||
t.styles.push(PlotStyle::object(&sname.chars().take(255).collect::<String>()));
|
||||
t.styles.len() - 1
|
||||
}
|
||||
None => return Err(bad(cmd, format!("`{name}` has no style `{sname}` (colour-dependent styles are Color_1 to Color_255)"))),
|
||||
};
|
||||
if let Some(st) = t.styles.get_mut(idx) {
|
||||
edit_style(cmd, st, v)?;
|
||||
}
|
||||
}
|
||||
let t = t.sanitized();
|
||||
let out = serde_json::to_value(&t).unwrap_or(Value::Null);
|
||||
store(s.doc_mut()?, t);
|
||||
Ok(out)
|
||||
}
|
||||
"delete" => {
|
||||
let name = table_name(cmd, p)?;
|
||||
let d = s.doc_mut()?;
|
||||
let before = d.plot_style_tables.len();
|
||||
d.plot_style_tables.retain(|t| !t.name.eq_ignore_ascii_case(&name));
|
||||
if d.plot_style_tables.len() == before {
|
||||
return Err(bad(cmd, format!("the drawing keeps no plot style table `{name}` (built-in tables can't be deleted)")));
|
||||
}
|
||||
Ok(json!({ "deleted": name }))
|
||||
}
|
||||
"load" => {
|
||||
let text = match (str_param(p, "text"), str_param(p, "path")) {
|
||||
(Some(t), _) => t.to_string(),
|
||||
(None, Some(_path)) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
|
||||
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
|
||||
}
|
||||
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad(cmd, "files can't be read here: pass `text`"));
|
||||
}
|
||||
(None, None) => return Err(bad(cmd, "`path` or `text` is required")),
|
||||
};
|
||||
let mut t = PlotStyleTable::from_file_text(&text).map_err(|e| bad(cmd, e))?;
|
||||
if let Some(n) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
t.name = n.chars().take(255).collect();
|
||||
}
|
||||
if t.name.is_empty() {
|
||||
return Err(bad(cmd, "the table has no name: pass `name`"));
|
||||
}
|
||||
let out = table_summary(s.doc()?, &t);
|
||||
store(s.doc_mut()?, t);
|
||||
Ok(out)
|
||||
}
|
||||
"save" => {
|
||||
let name = table_name(cmd, p)?;
|
||||
let text = find_table(cmd, s.doc()?, &name)?.to_file_text();
|
||||
match str_param(p, "path") {
|
||||
None => Ok(json!({ "name": name, "text": text })),
|
||||
Some(_path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tmp = format!("{_path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, text.as_bytes()).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
std::fs::rename(&tmp, _path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
|
||||
Ok(json!({ "name": name, "path": _path, "bytes": text.len() }))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Err(bad(cmd, "files can't be written here: leave out `path` to get `text`"))
|
||||
}
|
||||
}
|
||||
}
|
||||
other => Err(bad(cmd, format!("unknown action `{other}` (list, get, new, set, delete, load, save)"))),
|
||||
}
|
||||
}
|
||||
|
|
@ -601,6 +601,7 @@ impl Session {
|
|||
linetype: h.str("CELTYPE", "ByLayer"),
|
||||
lineweight: cadcraft_doc::Lineweight::from_dxf(h.i64("CELWEIGHT", -1) as i16),
|
||||
ltscale: h.f64("CELTSCALE", 1.0),
|
||||
plot_style: h.str("CPLOTSTYLE", "ByLayer"),
|
||||
..Common::default()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,3 +18,89 @@ fn pagesetup_sets_table_and_display_flag() {
|
|||
s.execute("pagesetup", &json!({"layout": "Layout1", "displayPlotStyles": false})).unwrap();
|
||||
assert!(!s.doc().unwrap().layout("Layout1").unwrap().page.show_plot_styles);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plotstyle_sets_objects_and_the_current_style() {
|
||||
let mut s = Session::new();
|
||||
s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let handle = s.doc().unwrap().model.last().unwrap().handle;
|
||||
let h = handle.hex();
|
||||
// No name: a report.
|
||||
let r = s.execute("plotstyle", &json!({})).unwrap();
|
||||
assert_eq!(r["current"], "ByLayer");
|
||||
assert!(r["styles"].as_array().unwrap().iter().any(|v| v == "Normal"));
|
||||
// An object's style.
|
||||
let r = s.execute("plotstyle", &json!({"name": "Black", "handles": [h]})).unwrap();
|
||||
assert_eq!(r["changed"], 1);
|
||||
assert_eq!(s.doc().unwrap().entity(handle).unwrap().common.plot_style, "Black");
|
||||
// Nothing selected: the current style for new objects.
|
||||
s.execute("select", &json!({"handles": []})).ok();
|
||||
s.execute("plotstyle", &json!({"name": "byblock"})).unwrap();
|
||||
s.execute("line", &json!({"points": [[0, 1], [10, 1]]})).unwrap();
|
||||
let h2 = s.doc().unwrap().model.last().unwrap().handle;
|
||||
assert_eq!(s.doc().unwrap().entity(h2).unwrap().common.plot_style, "ByBlock");
|
||||
assert!(s.execute("plotstyle", &json!({"name": " "})).is_err());
|
||||
assert!(s.execute("plotstyle", &json!({"name": 5})).is_err());
|
||||
// A layer's plot style.
|
||||
s.execute("layer.set", &json!({"name": "0", "plotStyle": "Thick"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().layer("0").unwrap().plot_style, "Thick");
|
||||
assert!(s.execute("layer.set", &json!({"name": "0", "plotStyle": "ByLayer"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesmanager_creates_edits_saves_and_loads_tables() {
|
||||
let mut s = Session::new();
|
||||
let list = s.execute("stylesmanager", &json!({})).unwrap();
|
||||
let names: Vec<&str> = list["tables"].as_array().unwrap().iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
assert!(names.contains(&"monochrome.ctb") && names.contains(&"default.stb"), "{names:?}");
|
||||
// A new named table gets its extension and Normal.
|
||||
let r = s.execute("stylesmanager", &json!({"action": "new", "name": "office", "kind": "named"})).unwrap();
|
||||
assert_eq!(r["name"], "office.stb");
|
||||
let r = s
|
||||
.execute(
|
||||
"stylesmanager",
|
||||
&json!({"action": "set", "name": "office.stb", "style": {"name": "Pen 5", "color": "#00ff00", "screening": 40, "lineweight": 0.35, "linetype": "HIDDEN", "end": "round"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let pen = r["styles"].as_array().unwrap().iter().find(|v| v["name"] == "Pen 5").unwrap().clone();
|
||||
assert_eq!(
|
||||
(pen["color"].as_str(), pen["screening"].as_u64(), pen["lineweight"].as_u64(), pen["end"].as_str()),
|
||||
(Some("#00ff00"), Some(40), Some(35), Some("round"))
|
||||
);
|
||||
// Bad edits are refused.
|
||||
for bad in [
|
||||
json!({"name": "Pen 5", "screening": 101}),
|
||||
json!({"name": "Pen 5", "color": "green"}),
|
||||
json!({"name": "Pen 5", "lineweight": 9}),
|
||||
json!({"name": "Pen 5", "join": "zigzag"}),
|
||||
] {
|
||||
assert!(s.execute("stylesmanager", &json!({"action": "set", "name": "office.stb", "style": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("stylesmanager", &json!({"action": "set", "name": "office.stb", "removeStyle": "Normal"})).is_err());
|
||||
// Editing a built-in keeps a copy in the drawing; colour-dependent styles are Color_n.
|
||||
s.execute("stylesmanager", &json!({"action": "set", "name": "monochrome.ctb", "style": {"name": "Color_1", "color": null}})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().plot_style_tables.len(), 2);
|
||||
assert!(s.execute("stylesmanager", &json!({"action": "set", "name": "monochrome.ctb", "style": {"name": "Pen"}})).is_err());
|
||||
// Save as text, delete, load back.
|
||||
let text = s.execute("stylesmanager", &json!({"action": "save", "name": "office.stb"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
s.execute("stylesmanager", &json!({"action": "delete", "name": "office.stb"})).unwrap();
|
||||
assert!(s.execute("stylesmanager", &json!({"action": "get", "name": "office.stb"})).is_err());
|
||||
s.execute("stylesmanager", &json!({"action": "load", "text": text})).unwrap();
|
||||
let got = s.execute("stylesmanager", &json!({"action": "get", "name": "office.stb"})).unwrap();
|
||||
assert_eq!(got["inDrawing"], true);
|
||||
assert!(got["styles"].as_array().unwrap().iter().any(|v| v["name"] == "Pen 5"));
|
||||
assert!(s.execute("stylesmanager", &json!({"action": "load", "text": "{\"x\": 1}"})).is_err());
|
||||
assert!(s.execute("stylesmanager", &json!({"action": "delete", "name": "grayscale.ctb"})).is_err(), "built-ins stay");
|
||||
assert!(s.execute("stylesmanager", &json!({"action": "explode"})).is_err());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-pstyle-{}.stb.json", std::process::id()));
|
||||
let p = path.to_string_lossy().to_string();
|
||||
s.execute("stylesmanager", &json!({"action": "save", "name": "office.stb", "path": p})).unwrap();
|
||||
s.execute("stylesmanager", &json!({"action": "load", "path": p, "name": "copy.stb"})).unwrap();
|
||||
assert!(s.execute("stylesmanager", &json!({"action": "get", "name": "copy.stb"})).is_ok());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
// Undo takes a table edit back.
|
||||
s.execute("undo", &json!({})).unwrap();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@
|
|||
//! constraints).
|
||||
//!
|
||||
//! CADCraft's own data lives under the registered application `CADCRAFT` (xdata) and the
|
||||
//! named-object-dictionary entries `CADCRAFT_CONSTRAINTS` and `CADCRAFT_LAYERSTATES`
|
||||
//! (XRECORDs). Other readers keep or ignore them.
|
||||
//! named-object-dictionary entries `CADCRAFT_CONSTRAINTS`, `CADCRAFT_LAYERSTATES` and
|
||||
//! `CADCRAFT_PLOTSTYLES` (XRECORDs). Other readers keep or ignore them.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_doc::{AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric};
|
||||
use cadcraft_doc::{AssocSnap, Constraint, DimAssoc, DimStyle, Handle, LayerState, Parametric, PlotStyleTable};
|
||||
use cadcraft_dxf::Tag;
|
||||
use cadcraft_render::Arrowhead;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -23,6 +23,14 @@ pub(crate) const APP: &str = "CADCRAFT";
|
|||
pub(crate) const CONSTRAINTS_KEY: &str = "CADCRAFT_CONSTRAINTS";
|
||||
/// Named-object-dictionary key of the saved layer states XRECORD.
|
||||
pub(crate) const LAYER_STATES_KEY: &str = "CADCRAFT_LAYERSTATES";
|
||||
/// Named-object-dictionary key of the drawing's plot style tables XRECORD.
|
||||
pub(crate) const PLOT_STYLES_KEY: &str = "CADCRAFT_PLOTSTYLES";
|
||||
/// Named-object-dictionary key of the plot style names dictionary (DXF Reference): its entries
|
||||
/// name `ACDBPLACEHOLDER` objects, and layers and objects point at them with group 390.
|
||||
pub(crate) const PLOT_STYLE_NAMES_KEY: &str = "ACAD_PLOTSTYLENAME";
|
||||
/// Most plot style tables and plot style names read from a file.
|
||||
pub(crate) const MAX_PLOT_STYLE_TABLES: usize = 1000;
|
||||
pub(crate) const MAX_PLOT_STYLE_NAMES: usize = 100_000;
|
||||
|
||||
/// Caps for hostile input.
|
||||
pub(crate) const MAX_XDATA_ITEMS: usize = 4096;
|
||||
|
|
@ -522,6 +530,36 @@ pub(crate) fn parse_layer_states(text: &str) -> Option<Vec<LayerState>> {
|
|||
Some(p.states)
|
||||
}
|
||||
|
||||
/// The drawing's plot style tables stored in the `CADCRAFT_PLOTSTYLES` XRECORD.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct PlotStylesPayload {
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
tables: Vec<PlotStyleTable>,
|
||||
}
|
||||
|
||||
/// JSON chunks for the plot style tables XRECORD, or `None` when the drawing keeps none.
|
||||
pub(crate) fn plot_style_chunks(tables: &[PlotStyleTable]) -> Option<Vec<String>> {
|
||||
if tables.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let json = serde_json::to_string(&PlotStylesPayload { version: 1, tables: tables.to_vec() }).ok()?;
|
||||
Some(json_chunks(&json))
|
||||
}
|
||||
|
||||
/// Parse the plot style tables XRECORD text (tables sanitized); `None` when it is not a
|
||||
/// payload we understand.
|
||||
pub(crate) fn parse_plot_styles(text: &str) -> Option<Vec<PlotStyleTable>> {
|
||||
if text.len() > MAX_PAYLOAD {
|
||||
return None;
|
||||
}
|
||||
let p: PlotStylesPayload = serde_json::from_str(text).ok()?;
|
||||
if p.version != 1 || p.tables.len() > MAX_PLOT_STYLE_TABLES {
|
||||
return None;
|
||||
}
|
||||
Some(p.tables.into_iter().map(PlotStyleTable::sanitized).filter(|t| !t.name.is_empty()).collect())
|
||||
}
|
||||
|
||||
/// Parse the constraint XRECORD text; `None` when it is not a payload we understand.
|
||||
pub(crate) fn parse_constraints(text: &str) -> Option<(Vec<Constraint>, Parametric)> {
|
||||
if text.len() > MAX_PAYLOAD {
|
||||
|
|
|
|||
|
|
@ -810,6 +810,62 @@ struct Rx {
|
|||
table_style_fix: Vec<(Handle, String)>,
|
||||
/// Upper-case xref block name → the external drawing's path (BLOCK group 1).
|
||||
xref_paths: HashMap<String, String>,
|
||||
/// Plot style name placeholder handle (upper case) → plot style name (`ACAD_PLOTSTYLENAME`).
|
||||
pstyles: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// The plot style names of a file: the entries of the `ACAD_PLOTSTYLENAME` dictionary of the
|
||||
/// named object dictionary (the first object), by placeholder handle.
|
||||
fn plot_style_names(secs: &[cadcraft_dxf::Section]) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
let Some(objects) = secs.iter().find(|s| s.name == "OBJECTS") else { return out };
|
||||
let recs = records(&objects.tags);
|
||||
let entries = |tags: &[Tag]| {
|
||||
let mut v: Vec<(String, String)> = Vec::new();
|
||||
let mut name: Option<String> = None;
|
||||
for t in tags {
|
||||
match t.code {
|
||||
3 => name = Some(t.str()),
|
||||
350 | 360 => {
|
||||
if let Some(n) = name.take() {
|
||||
v.push((n, t.str().trim().to_ascii_uppercase()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
v
|
||||
};
|
||||
let Some((_, root)) = recs.iter().find(|(k, _)| k == "DICTIONARY") else { return out };
|
||||
let Some((_, dict)) = entries(root).into_iter().find(|(n, _)| n.eq_ignore_ascii_case(crate::dxf_ext::PLOT_STYLE_NAMES_KEY)) else {
|
||||
return out;
|
||||
};
|
||||
let is_dict = |k: &str| k == "DICTIONARY" || k == "ACDBDICTIONARYWDFLT";
|
||||
let Some((_, tags)) = recs.iter().find(|(k, tg)| is_dict(k) && T(tg).s(5).is_some_and(|h| h.trim().eq_ignore_ascii_case(&dict))) else {
|
||||
return out;
|
||||
};
|
||||
for (n, h) in entries(tags).into_iter().take(crate::dxf_ext::MAX_PLOT_STYLE_NAMES) {
|
||||
if !n.trim().is_empty() {
|
||||
out.insert(h, n.chars().take(255).collect());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The named plot style an entity record points at (group 390 among its AcDbEntity groups).
|
||||
fn entity_plot_style(tags: &[Tag], rx: &Rx) -> Option<String> {
|
||||
let mut in_entity = false;
|
||||
for t in tags {
|
||||
if t.code == 100 {
|
||||
if in_entity {
|
||||
return None;
|
||||
}
|
||||
in_entity = t.str() == "AcDbEntity";
|
||||
} else if t.code == 390 && in_entity {
|
||||
return rx.pstyles.get(&t.str().trim().to_ascii_uppercase()).cloned();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Dimension overrides and associativity, table references: data of an entity record that
|
||||
|
|
@ -851,6 +907,11 @@ fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing, rx: &mut Rx) ->
|
|||
let paper = t.i(67) == Some(1);
|
||||
let handle = t.s(5).and_then(|h| Handle::parse_hex(&h));
|
||||
let mut kindent = entity(kind, tags);
|
||||
if let Some((c, _)) = kindent.as_mut()
|
||||
&& let Some(n) = entity_plot_style(tags, rx)
|
||||
{
|
||||
c.plot_style = n;
|
||||
}
|
||||
// More entities from this record (the faces of a polyface or polygon mesh).
|
||||
let mut extra: Vec<EntityKind> = Vec::new();
|
||||
i += 1;
|
||||
|
|
@ -1131,6 +1192,9 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
|||
if let Some(tc) = t.i(420) {
|
||||
l.color = Color::True(Rgb::from_u32(tc as u32));
|
||||
}
|
||||
if let Some(n) = t.s(390).and_then(|h| rx.pstyles.get(&h.trim().to_ascii_uppercase())) {
|
||||
l.plot_style = n.clone();
|
||||
}
|
||||
let alpha = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_TRANSPARENCY_APP).iter().find(|x| x.code == 1071);
|
||||
if let Some(pct) = alpha.and_then(|x| crate::dxf_ext::transparency_from_dxf(x.i64())) {
|
||||
l.transparency = pct;
|
||||
|
|
@ -1296,7 +1360,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
|||
}
|
||||
let mut d = Drawing::new_imperial();
|
||||
d.layouts.clear();
|
||||
let mut rx = Rx::default();
|
||||
let mut rx = Rx { pstyles: plot_style_names(&secs), ..Rx::default() };
|
||||
let mut blocks: Vec<(String, Vec3, String, Vec<(Option<String>, bool, Entity)>, bool)> = Vec::new();
|
||||
let mut layout_objs: Vec<(String, u32, String, PageSetup)> = Vec::new();
|
||||
let mut entities = Vec::new();
|
||||
|
|
@ -1547,6 +1611,10 @@ impl Objects {
|
|||
if let Some(states) = self.xrecord_text(crate::dxf_ext::LAYER_STATES_KEY).and_then(|t| crate::dxf_ext::parse_layer_states(&t)) {
|
||||
d.layer_states = states;
|
||||
}
|
||||
// The drawing's plot style tables.
|
||||
if let Some(tables) = self.xrecord_text(crate::dxf_ext::PLOT_STYLES_KEY).and_then(|t| crate::dxf_ext::parse_plot_styles(&t)) {
|
||||
d.plot_style_tables = tables;
|
||||
}
|
||||
// Multileader styles (named by their ACAD_MLEADERSTYLE dictionary entries).
|
||||
for (h, tags) in &self.mleader_styles {
|
||||
let Some(name) = self.names.get(h).filter(|n| !n.is_empty()) else { continue };
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ use crate::dxf_ext::{self, DimVal, K};
|
|||
struct W {
|
||||
t: Vec<Tag>,
|
||||
next: u64,
|
||||
/// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects).
|
||||
pstyles: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl W {
|
||||
|
|
@ -145,7 +147,7 @@ fn annotative_xdata(w: &mut W) {
|
|||
|
||||
/// Override (`ACAD` DSTYLE) and associativity (`CADCRAFT` ASSOC) xdata of a dimension.
|
||||
fn dim_xdata(w: &mut W, dm: &Dimension, cx: &Ctx) {
|
||||
let mut ov = W { t: Vec::new(), next: 0 };
|
||||
let mut ov = W { t: Vec::new(), next: 0, pstyles: HashMap::new() };
|
||||
let mut sah = false;
|
||||
for (k, v) in &dm.overrides {
|
||||
let Some(field) = DimStyle::field_name(k) else { continue };
|
||||
|
|
@ -292,6 +294,10 @@ fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str, ass
|
|||
if let Some(v) = transparency_440(e.common.transparency) {
|
||||
w.i(440, v);
|
||||
}
|
||||
// A named plot style (not ByLayer/ByBlock): its placeholder object.
|
||||
if let Some(h) = w.pstyles.get(&e.common.plot_style.trim().to_ascii_uppercase()).cloned() {
|
||||
w.s(390, h);
|
||||
}
|
||||
if !subclass.is_empty() {
|
||||
w.s(100, subclass);
|
||||
}
|
||||
|
|
@ -1222,7 +1228,7 @@ fn record_head(w: &mut W, kind: &str, owner: &str, subclass: &str) -> String {
|
|||
/// Write a drawing as ASCII DXF.
|
||||
pub fn write(d: &Drawing) -> String {
|
||||
// Structural handles start above every entity handle.
|
||||
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100) + 0x1000 };
|
||||
let mut w = W { t: Vec::new(), next: d.handseed.max(0x100) + 0x1000, pstyles: HashMap::new() };
|
||||
// Pre-allocate handles for block records.
|
||||
let mut paper_layouts: Vec<&Layout> = d.layouts.iter().collect();
|
||||
paper_layouts.sort_by_key(|l| l.tab_order);
|
||||
|
|
@ -1348,6 +1354,22 @@ pub fn write(d: &Drawing) -> String {
|
|||
if let Some(chunks) = dxf_ext::layer_state_chunks(&d.layer_states) {
|
||||
xrecords.push((dxf_ext::LAYER_STATES_KEY, w.h(), chunks));
|
||||
}
|
||||
if let Some(chunks) = dxf_ext::plot_style_chunks(&d.plot_style_tables) {
|
||||
xrecords.push((dxf_ext::PLOT_STYLES_KEY, w.h(), chunks));
|
||||
}
|
||||
// Plot style names used by layers and objects (Normal always), each a placeholder object.
|
||||
let pstyle_dict = w.h();
|
||||
let mut pstyle_names: Vec<String> = vec![cadcraft_doc::NORMAL_STYLE.to_string()];
|
||||
let named = d.layers.iter().map(|l| l.plot_style.as_str()).chain(every.iter().map(|e| e.common.plot_style.as_str()));
|
||||
for n in named {
|
||||
let n = n.trim();
|
||||
let logical = n.is_empty() || n.eq_ignore_ascii_case("bylayer") || n.eq_ignore_ascii_case("byblock");
|
||||
if !logical && pstyle_names.len() < dxf_ext::MAX_PLOT_STYLE_NAMES && !pstyle_names.iter().any(|x| x.eq_ignore_ascii_case(n)) {
|
||||
pstyle_names.push(n.to_string());
|
||||
}
|
||||
}
|
||||
let pstyles: Vec<(String, String)> = pstyle_names.into_iter().map(|n| (n, w.h())).collect();
|
||||
w.pstyles = pstyles.iter().map(|(n, h)| (n.to_ascii_uppercase(), h.clone())).collect();
|
||||
let model_layout = w.h();
|
||||
let layout_handles: Vec<String> = ps_brs.iter().map(|_| w.h()).collect();
|
||||
|
||||
|
|
@ -1379,7 +1401,12 @@ pub fn write(d: &Drawing) -> String {
|
|||
// ---------------- CLASSES ----------------
|
||||
w.s(0, "SECTION");
|
||||
w.s(2, "CLASSES");
|
||||
let mut classes = vec![("TABLESTYLE", "AcDbTableStyle", 4095, false), ("MLEADERSTYLE", "AcDbMLeaderStyle", 4095, false)];
|
||||
let mut classes = vec![
|
||||
("TABLESTYLE", "AcDbTableStyle", 4095, false),
|
||||
("MLEADERSTYLE", "AcDbMLeaderStyle", 4095, false),
|
||||
("ACDBDICTIONARYWDFLT", "AcDbDictionaryWithDefault", 0, false),
|
||||
("ACDBPLACEHOLDER", "AcDbPlaceHolder", 0, false),
|
||||
];
|
||||
if !cx.tables.is_empty() {
|
||||
classes.push(("ACAD_TABLE", "AcDbTable", 1025, true));
|
||||
}
|
||||
|
|
@ -1487,6 +1514,10 @@ pub fn write(d: &Drawing) -> String {
|
|||
w.i(290, 0);
|
||||
}
|
||||
w.i(370, i64::from(l.lineweight.to_dxf()));
|
||||
let normal = w.pstyles.get(&cadcraft_doc::NORMAL_STYLE.to_ascii_uppercase()).cloned();
|
||||
if let Some(h) = w.pstyles.get(&l.plot_style.trim().to_ascii_uppercase()).cloned().or(normal) {
|
||||
w.s(390, h);
|
||||
}
|
||||
if l.transparency > 0 {
|
||||
w.s(1001, dxf_ext::LAYER_TRANSPARENCY_APP);
|
||||
w.i(1071, dxf_ext::transparency_to_dxf(l.transparency));
|
||||
|
|
@ -1770,6 +1801,8 @@ pub fn write(d: &Drawing) -> String {
|
|||
w.s(350, layout_dict.clone());
|
||||
w.s(3, "ACAD_MLEADERSTYLE");
|
||||
w.s(350, mleader_dict.clone());
|
||||
w.s(3, dxf_ext::PLOT_STYLE_NAMES_KEY);
|
||||
w.s(350, pstyle_dict.clone());
|
||||
w.s(3, "ACAD_TABLESTYLE");
|
||||
w.s(350, table_style_dict.clone());
|
||||
for (key, h, _) in &xrecords {
|
||||
|
|
@ -1815,7 +1848,27 @@ pub fn write(d: &Drawing) -> String {
|
|||
for (s, (_, h)) in table_styles.iter().zip(&cx.table_styles) {
|
||||
table_style_obj(&mut w, s, h, &table_style_dict);
|
||||
}
|
||||
// Parametric constraints and parameters, saved layer states (CADCraft data).
|
||||
// Plot style names: a dictionary with a default (Normal) of placeholder objects.
|
||||
w.s(0, "ACDBDICTIONARYWDFLT");
|
||||
w.s(5, pstyle_dict.clone());
|
||||
w.group("ACAD_REACTORS", 330, &[&root_dict]);
|
||||
w.s(330, root_dict.clone());
|
||||
w.s(100, "AcDbDictionary");
|
||||
w.i(281, 1);
|
||||
for (n, h) in &pstyles {
|
||||
w.s(3, n);
|
||||
w.s(350, h);
|
||||
}
|
||||
w.s(100, "AcDbDictionaryWithDefault");
|
||||
w.s(340, pstyles.first().map(|(_, h)| h.clone()).unwrap_or_default());
|
||||
for (_, h) in &pstyles {
|
||||
w.s(0, "ACDBPLACEHOLDER");
|
||||
w.s(5, h);
|
||||
w.group("ACAD_REACTORS", 330, &[&pstyle_dict]);
|
||||
w.s(330, pstyle_dict.clone());
|
||||
}
|
||||
// Parametric constraints and parameters, saved layer states, plot style tables (CADCraft
|
||||
// data).
|
||||
for (_, h, chunks) in &xrecords {
|
||||
w.s(0, "XRECORD");
|
||||
w.s(5, h);
|
||||
|
|
|
|||
|
|
@ -60,3 +60,46 @@ fn page_setup_table_and_display_flag_round_trip() {
|
|||
assert!(page.show_plot_styles);
|
||||
assert!(!read_dxf(write_dxf(&layout_with_red_line()).as_bytes()).unwrap().layout("Layout1").unwrap().page.show_plot_styles);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_plot_styles_round_trip_through_the_plot_style_dictionary() {
|
||||
use cadcraft_doc::{Layer, PlotStyle, PlotStyleTable};
|
||||
let mut d = layout_with_red_line();
|
||||
d.layers.push(Layer { plot_style: "Black".into(), ..Layer::new("Walls") });
|
||||
let line = |y: f64| EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(1.0, y, 0.0) });
|
||||
let named = d.add(&Space::Model, Common { plot_style: "Thick".into(), ..Default::default() }, line(0.0)).unwrap();
|
||||
let by_layer = d.add(&Space::Model, Common::default(), line(1.0)).unwrap();
|
||||
let mut pens = PlotStyleTable::named("pens.stb", "ours", vec![PlotStyle { name: "Thick".into(), lineweight: Some(70), ..PlotStyle::default() }]);
|
||||
pens.description = "office pens".into();
|
||||
d.plot_style_tables.push(pens.clone());
|
||||
let text = write_dxf(&d);
|
||||
assert!(text.contains("ACAD_PLOTSTYLENAME") && text.contains("ACDBPLACEHOLDER") && text.contains("ACDBDICTIONARYWDFLT"));
|
||||
assert!(text.contains("CADCRAFT_PLOTSTYLES"));
|
||||
let back = read_dxf(text.as_bytes()).unwrap();
|
||||
assert_eq!(back.layer("Walls").unwrap().plot_style, "Black");
|
||||
assert_eq!(back.layer("0").unwrap().plot_style, "Normal");
|
||||
assert_eq!(back.entity(named).unwrap().common.plot_style, "Thick");
|
||||
assert_eq!(back.entity(by_layer).unwrap().common.plot_style, "ByLayer");
|
||||
assert_eq!(back.plot_style_tables, vec![pens]);
|
||||
// A drawing without named styles still writes the dictionary with Normal, and no tables.
|
||||
let plain = write_dxf(&Drawing::new_metric());
|
||||
assert!(plain.contains("ACAD_PLOTSTYLENAME") && !plain.contains("CADCRAFT_PLOTSTYLES"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_applies_a_named_table_from_the_drawing() {
|
||||
use cadcraft_doc::{PlotStyle, PlotStyleTable};
|
||||
let mut d = layout_with_red_line();
|
||||
let blue = PlotStyleTable::named(
|
||||
"blue.stb",
|
||||
"",
|
||||
vec![PlotStyle { name: "Blue".into(), color: Some(cadcraft_color::Rgb(0, 0, 255)), ..PlotStyle::default() }],
|
||||
);
|
||||
d.plot_style_tables.push(blue);
|
||||
let space = Space::Paper("Layout1".into());
|
||||
let h = d.space(&space).unwrap().iter().next().unwrap().handle;
|
||||
d.modify_entity(h, |e| e.common.plot_style = "Blue".into()).unwrap();
|
||||
d.layouts[0].page.plot_style_table = "blue.stb".into();
|
||||
let text = pdf_text(&d, json!({}));
|
||||
assert!(text.contains("0 0 1 RG") && !text.contains("1 0 0 RG"), "{text}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,6 +171,8 @@ struct Ctx<'a> {
|
|||
/// Extra linetype scale for model space seen through a viewport: 1 / viewport scale when
|
||||
/// `PSLTSCALE` is on (dashes keep their paper-space length at any viewport scale), else 1.
|
||||
lt_factor: f64,
|
||||
/// Named plot style (table index) of the block reference being expanded, for `ByBlock`.
|
||||
block_pstyle: Option<usize>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
|
|
@ -190,6 +192,8 @@ struct Builder<'a> {
|
|||
plotting: bool,
|
||||
/// The plot style table drawn with.
|
||||
styles: Option<&'a plotstyle::Styler>,
|
||||
/// Named plot style (table index) of the entity being drawn.
|
||||
named: Option<usize>,
|
||||
}
|
||||
|
||||
/// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy
|
||||
|
|
@ -202,7 +206,7 @@ impl Builder<'_> {
|
|||
/// `color` and `lw` as the plot style table prints them.
|
||||
fn styled(&self, color: Ink, lw: f32) -> (Ink, f32) {
|
||||
match self.styles {
|
||||
Some(s) => s.apply(color, lw, self.opts.lineweights),
|
||||
Some(s) => s.apply(color, self.named, lw, self.opts.lineweights),
|
||||
None => (color, lw),
|
||||
}
|
||||
}
|
||||
|
|
@ -298,6 +302,7 @@ fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_c
|
|||
frozen,
|
||||
vp_colors,
|
||||
lt_factor: 1.0,
|
||||
block_pstyle: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,7 +317,7 @@ fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> Di
|
|||
_ => None,
|
||||
});
|
||||
let styler = table.map(|t| plotstyle::Styler::new(d, t));
|
||||
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref() };
|
||||
let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None };
|
||||
if let Space::Paper(name) = space {
|
||||
b.list.sheet = paper::sheet(d, name);
|
||||
}
|
||||
|
|
@ -378,7 +383,7 @@ fn viewport(b: &mut Builder, d: &Drawing, e: &Entity, vp: &cadcraft_doc::Viewpor
|
|||
let mhalf = half / s;
|
||||
// Model window with slack: entity bounds are approximate (text, dimensions).
|
||||
let win = Bounds2::new(vp.view_center - mhalf, vp.view_center + mhalf).expand(mhalf.x.max(mhalf.y) * 0.1);
|
||||
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles };
|
||||
let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None };
|
||||
// PSLTSCALE on: model-space dashes are scaled so they measure the same on paper as in
|
||||
// paper space, whatever the viewport scale.
|
||||
let lt_factor = if d.header.i64("PSLTSCALE", 1) != 0 { 1.0 / s } else { 1.0 };
|
||||
|
|
@ -463,7 +468,7 @@ pub fn build_entities<'a, I: IntoIterator<Item = &'a Entity>>(d: &Drawing, ents:
|
|||
} else {
|
||||
opts
|
||||
};
|
||||
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None };
|
||||
let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None };
|
||||
for e in ents {
|
||||
entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e);
|
||||
}
|
||||
|
|
@ -513,12 +518,20 @@ fn default_lineweight(d: &Drawing) -> f32 {
|
|||
}
|
||||
|
||||
fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
|
||||
// The entity's named plot style, for everything it draws (block contents set their own).
|
||||
let saved = b.named;
|
||||
b.named = b.styles.and_then(|s| s.named_index(ctx, &e.common));
|
||||
entity_parts(b, ctx, e);
|
||||
b.named = saved;
|
||||
}
|
||||
|
||||
fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) {
|
||||
let (rgb, lw, lt, ltscale, visible) = resolve(ctx, e, b.plotting);
|
||||
if !visible {
|
||||
return;
|
||||
}
|
||||
// A plot style's linetype replaces the object's.
|
||||
let lt = match b.styles.and_then(|s| s.linetype(rgb)) {
|
||||
let lt = match b.styles.and_then(|s| s.linetype(rgb, b.named)) {
|
||||
Some(over) => over,
|
||||
None => lt,
|
||||
};
|
||||
|
|
@ -569,7 +582,7 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) {
|
|||
if let Some(blk) = dm.block.as_ref().and_then(|n| ctx.d.block(n))
|
||||
&& ctx.depth < cadcraft_doc::MAX_BLOCK_DEPTH
|
||||
{
|
||||
let sub = sub_ctx(ctx, e, Mat3::IDENTITY);
|
||||
let sub = Ctx { block_pstyle: b.named, ..sub_ctx(ctx, e, Mat3::IDENTITY) };
|
||||
for be in blk.entities.iter() {
|
||||
if !b.expand() {
|
||||
break;
|
||||
|
|
@ -973,6 +986,7 @@ fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> {
|
|||
frozen: ctx.frozen,
|
||||
vp_colors: ctx.vp_colors,
|
||||
lt_factor: ctx.lt_factor,
|
||||
block_pstyle: ctx.block_pstyle,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -990,7 +1004,7 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg
|
|||
}
|
||||
let off = Vec2::new(ins.col_spacing * f64::from(c), ins.row_spacing * f64::from(r)).rotate(ins.rotation);
|
||||
let m = Mat3::translate(off).then_before(ins.transform(blk.base.xy()));
|
||||
let sub = sub_ctx(ctx, e, m);
|
||||
let sub = Ctx { block_pstyle: b.named, ..sub_ctx(ctx, e, m) };
|
||||
for be in blk.entities.iter() {
|
||||
// Constant/visible attribute definitions inside blocks are not drawn; attribs are.
|
||||
if matches!(be.kind, EntityKind::AttDef(_)) {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@
|
|||
//!
|
||||
//! Plots (PDF) and layouts shown with "Display plot styles" (screen, PNG and SVG) draw each
|
||||
//! object with the plot style that applies to it. In a colour-dependent table that is the style
|
||||
//! of the object's index colour; a true colour uses the style of its nearest index colour. The
|
||||
//! of the object's index colour; a true colour uses the style of its nearest index colour. In a
|
||||
//! named table it is the object's PlotStyle property: a style name, `ByLayer` (the layer's
|
||||
//! style; layer 0 inside a block takes the reference's layer) or `ByBlock` (the block
|
||||
//! reference's); names the table lacks print as `Normal`. The
|
||||
//! style replaces or keeps the colour, turns it grey and screens it (toward the white paper),
|
||||
//! and replaces the linetype and lineweight. Colour 7 prints black, as on paper.
|
||||
|
||||
use cadcraft_color::{Rgb, nearest_aci};
|
||||
use cadcraft_doc::{Drawing, Linetype, PlotStyle, PlotStyleTable};
|
||||
use cadcraft_doc::{Common, Drawing, Linetype, PlotStyle, PlotStyleTable};
|
||||
|
||||
use crate::Ink;
|
||||
use crate::{Ctx, Ink};
|
||||
|
||||
/// A table ready for the builder: styles plus their resolved linetype overrides.
|
||||
pub(crate) struct Styler {
|
||||
|
|
@ -40,24 +43,48 @@ impl Styler {
|
|||
Styler { table, linetypes }
|
||||
}
|
||||
|
||||
/// Index of the style that applies to `ink`.
|
||||
fn index(&self, ink: Ink) -> Option<usize> {
|
||||
if !self.table.is_color_dependent() {
|
||||
/// Whether styles are picked by name.
|
||||
pub(crate) fn is_named(&self) -> bool {
|
||||
!self.table.is_color_dependent()
|
||||
}
|
||||
|
||||
/// The named style an object with properties `c` prints with (index into the table).
|
||||
pub(crate) fn named_index(&self, ctx: &Ctx, c: &Common) -> Option<usize> {
|
||||
if !self.is_named() {
|
||||
return None;
|
||||
}
|
||||
let layer_name = if c.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { c.layer.as_str() };
|
||||
let by_name = |n: &str| self.table.styles.iter().position(|s| s.name.eq_ignore_ascii_case(n.trim()));
|
||||
let normal = || by_name(cadcraft_doc::NORMAL_STYLE);
|
||||
let p = c.plot_style.trim();
|
||||
if p.is_empty() || p.eq_ignore_ascii_case("bylayer") {
|
||||
ctx.d.layer(layer_name).and_then(|l| by_name(&l.plot_style)).or_else(normal)
|
||||
} else if p.eq_ignore_ascii_case("byblock") {
|
||||
ctx.block_pstyle.or_else(normal)
|
||||
} else {
|
||||
by_name(p).or_else(normal)
|
||||
}
|
||||
}
|
||||
|
||||
/// Index of the style that applies to `ink` (colour-dependent tables) or the object's
|
||||
/// named style `named`.
|
||||
fn index(&self, ink: Ink, named: Option<usize>) -> Option<usize> {
|
||||
if self.is_named() {
|
||||
return named.filter(|i| *i < self.table.styles.len());
|
||||
}
|
||||
let aci = if ink.aci == 0 { nearest_aci(ink.rgb) } else { ink.aci };
|
||||
let i = usize::from(aci).checked_sub(1)?;
|
||||
(i < self.table.styles.len()).then_some(i)
|
||||
}
|
||||
|
||||
fn style(&self, ink: Ink) -> Option<&PlotStyle> {
|
||||
self.table.styles.get(self.index(ink)?)
|
||||
fn style(&self, ink: Ink, named: Option<usize>) -> Option<&PlotStyle> {
|
||||
self.table.styles.get(self.index(ink, named)?)
|
||||
}
|
||||
|
||||
/// `ink` and lineweight `lw` (mm) as the plot style prints them. `lineweights`: lineweights
|
||||
/// are drawn at all (a style's lineweight replaces the object's only then).
|
||||
pub(crate) fn apply(&self, ink: Ink, lw: f32, lineweights: bool) -> (Ink, f32) {
|
||||
let Some(s) = self.style(ink) else { return (ink, lw) };
|
||||
pub(crate) fn apply(&self, ink: Ink, named: Option<usize>, lw: f32, lineweights: bool) -> (Ink, f32) {
|
||||
let Some(s) = self.style(ink, named) else { return (ink, lw) };
|
||||
let lw = match s.lineweight {
|
||||
Some(w) if lineweights => f32::from(w.min(211)) / 100.0,
|
||||
_ => lw,
|
||||
|
|
@ -72,7 +99,7 @@ impl Styler {
|
|||
|
||||
/// The linetype override of the style that applies to `ink`: `None` keeps the object's,
|
||||
/// `Some(None)` draws continuous.
|
||||
pub(crate) fn linetype(&self, ink: Ink) -> Option<Option<Linetype>> {
|
||||
self.linetypes.get(self.index(ink)?)?.clone()
|
||||
pub(crate) fn linetype(&self, ink: Ink, named: Option<usize>) -> Option<Option<Linetype>> {
|
||||
self.linetypes.get(self.index(ink, named)?)?.clone()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,3 +115,45 @@ fn hostile_tables_do_not_panic() {
|
|||
let named = PlotStyleTable { kind: PlotStyleKind::Named, styles: vec![PlotStyle::object("Normal")], ..PlotStyleTable::default() };
|
||||
assert_eq!(build_plot(&d, &Space::Model, &with_table(named)).prims.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_styles_by_layer_by_block_and_name() {
|
||||
use cadcraft_doc::{Block, Entity, Handle, Insert, Layer};
|
||||
let mut d = Drawing::new_metric();
|
||||
d.layers.push(Layer { name: "Walls".into(), color: Color::Index(1), plot_style: "Black".into(), ..Layer::new("Walls") });
|
||||
let red = |plot_style: &str| Common { layer: "Walls".into(), plot_style: plot_style.into(), ..Default::default() };
|
||||
d.add(&Space::Model, red("ByLayer"), line(0.0)).unwrap();
|
||||
d.add(&Space::Model, red("Normal"), line(1.0)).unwrap();
|
||||
d.add(&Space::Model, red("Screened 50%"), line(2.0)).unwrap();
|
||||
d.add(&Space::Model, red("No Such Style"), line(3.0)).unwrap();
|
||||
// A ByBlock line inside a block whose reference is "Thick".
|
||||
let mut blk = Block::new("B");
|
||||
blk.entities.push(Entity {
|
||||
common: Common { color: Color::Index(1), plot_style: "ByBlock".into(), ..Default::default() },
|
||||
..Entity::new(Handle(1), line(0.0))
|
||||
});
|
||||
d.blocks.insert("B".into(), Arc::new(blk));
|
||||
let ins = EntityKind::Insert(Insert {
|
||||
block: "B".into(),
|
||||
insert: Vec3::new(0.0, 4.0, 0.0),
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: vec![],
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
});
|
||||
d.add(&Space::Model, Common { plot_style: "Thick".into(), ..Default::default() }, ins).unwrap();
|
||||
let l = build_plot(&d, &Space::Model, &with_table(PlotStyleTable::builtin("default.stb").unwrap()));
|
||||
let got: Vec<(Rgb, f32)> = l.prims.iter().map(|p| (p.display_rgb(Rgb(255, 255, 255)), p.lw)).collect();
|
||||
let red_rgb = Rgb(255, 0, 0);
|
||||
assert_eq!(
|
||||
got,
|
||||
[(BLACK, 0.25), (red_rgb, 0.25), (Rgb(255, 128, 128), 0.25), (red_rgb, 0.25), (red_rgb, 0.5)],
|
||||
"ByLayer → Black, Normal, Screened 50%, unknown → Normal, ByBlock → the reference's Thick"
|
||||
);
|
||||
// A colour-dependent table ignores the property.
|
||||
let ctb = build_plot(&d, &Space::Model, &with_table(PlotStyleTable::builtin("default.ctb").unwrap()));
|
||||
assert!(ctb.prims.iter().all(|p| p.color == red_rgb));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
|||
"about" => about(ctx, &mut open),
|
||||
"commands" => commands(app, ctx, &mut open),
|
||||
"blocks" => blocks(app, ctx, &mut open),
|
||||
"stylesmanager" => crate::plotstyles::dialog(app, ctx, &mut open),
|
||||
other => crate::managers::dialog(app, ctx, other, &mut open),
|
||||
}
|
||||
if !open {
|
||||
|
|
|
|||
|
|
@ -524,7 +524,29 @@
|
|||
Plot style Стиль друку
|
||||
Plot style attached to Стиль друку застосовано до
|
||||
Plot style table Таблиця стилів друку
|
||||
Plot Style... Стиль друку...
|
||||
Plot Style Manager Диспетчер стилів друку
|
||||
Plot Style Manager... Диспетчер стилів друку...
|
||||
Plot Styles... Стилі друку...
|
||||
New CTB Нова CTB
|
||||
New STB Нова STB
|
||||
New style Новий стиль
|
||||
Use for this layout Використати для цього аркуша
|
||||
Display plot styles Показувати стилі друку
|
||||
No plot style tables. Немає таблиць стилів друку.
|
||||
A built-in table: editing it keeps an edited copy in the drawing. Вбудована таблиця: зміни зберігаються як її копія в кресленні.
|
||||
Keep an editable copy of this table in the drawing Зберегти в кресленні копію цієї таблиці для редагування
|
||||
Layout {name}: {table} Аркуш {name}: {table}
|
||||
Grayscale Відтінки сірого
|
||||
Screening Інтенсивність
|
||||
Use object linetype Тип лінії об'єкта
|
||||
Use object lineweight Товщина лінії об'єкта
|
||||
Remove Вилучити
|
||||
Add Додати
|
||||
Open a drawing first. Спершу відкрийте креслення.
|
||||
Plot table type Тип таблиці друку
|
||||
Color-dependent Залежна від кольору
|
||||
Named Іменована
|
||||
Plus/minus Плюс/мінус
|
||||
Point Точка
|
||||
Point Filters Фільтри точок
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 7.
|
|
|
@ -26,6 +26,7 @@ pub mod managers;
|
|||
pub mod menus;
|
||||
pub mod palettes;
|
||||
pub mod parametric;
|
||||
pub mod plotstyles;
|
||||
pub mod quick;
|
||||
pub mod theme;
|
||||
pub mod viewcube;
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
|
|||
if let Some(r) = crate::managers::route(app, id, params) {
|
||||
return Some(r);
|
||||
}
|
||||
if let Some(r) = crate::plotstyles::route(app, id, params) {
|
||||
return Some(r);
|
||||
}
|
||||
let toggle = |b: &mut bool, p: &Value| {
|
||||
*b = p.get("on").and_then(Value::as_bool).unwrap_or(!*b);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -696,10 +696,39 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {
|
|||
act("setvar", json!({ "name": "TEXTSIZE", "value": f }));
|
||||
}
|
||||
});
|
||||
prop_row(ui, "Plot style", |ui| unavailable(ui, "ByColor"));
|
||||
prop_row(ui, "Plot style table", |ui| unavailable(ui, "None"));
|
||||
prop_row(ui, "Plot style attached to", |ui| read_only(ui, "Model"));
|
||||
prop_row(ui, "Plot table type", |ui| read_only(ui, "Not available"));
|
||||
// The current layout's plot style table; named tables give the current plot style.
|
||||
let layout = match app.session.layout_space() {
|
||||
cadcraft_doc::Space::Paper(n) => d.layout(&n).map(|l| (n.clone(), l.page.plot_style_table.clone())),
|
||||
cadcraft_doc::Space::Model => None,
|
||||
};
|
||||
let table = layout.as_ref().and_then(|(_, t)| cadcraft_doc::plot_style_table(d, t));
|
||||
match table.as_ref().filter(|t| !t.is_color_dependent()) {
|
||||
Some(t) => prop_row(ui, "Plot style", |ui| {
|
||||
let items = ["ByLayer", "ByBlock"].map(String::from).into_iter().chain(t.styles.iter().map(|s| s.name.clone()));
|
||||
if let Some(n) = pick_menu(ui, &h.str("CPLOTSTYLE", "ByLayer"), items) {
|
||||
act("plotstyle", json!({ "name": n }));
|
||||
}
|
||||
}),
|
||||
None => prop_row(ui, "Plot style", |ui| read_only(ui, "ByColor")),
|
||||
}
|
||||
match &layout {
|
||||
Some((name, current)) => prop_row(ui, "Plot style table", |ui| {
|
||||
let shown = if current.trim().is_empty() { "None".to_string() } else { current.clone() };
|
||||
let items = std::iter::once("None".to_string()).chain(cadcraft_doc::plot_style_table_names(d));
|
||||
if let Some(n) = pick_menu(ui, &shown, items) {
|
||||
act("pagesetup", json!({ "layout": name, "plotStyleTable": n }));
|
||||
}
|
||||
}),
|
||||
None => prop_row(ui, "Plot style table", |ui| unavailable(ui, "None")),
|
||||
}
|
||||
let attached = layout.as_ref().map(|(n, _)| n.clone()).unwrap_or_else(|| "Model".into());
|
||||
prop_row(ui, "Plot style attached to", |ui| read_only(ui, &attached));
|
||||
let kind = match &table {
|
||||
Some(t) if t.is_color_dependent() => "Color-dependent",
|
||||
Some(_) => "Named",
|
||||
None => "Not available",
|
||||
};
|
||||
prop_row(ui, "Plot table type", |ui| read_only(ui, kind));
|
||||
if let Some((c, p)) = action {
|
||||
let _ = app.run(c, p);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
//! Plot Style Manager (STYLESMANAGER): the plot style tables the drawing can use, a table's
|
||||
//! styles in an editable grid, and the current layout's table and "Display plot styles".
|
||||
//!
|
||||
//! The dialog reads the drawing and changes it only through STYLESMANAGER and PAGESETUP
|
||||
//! (`app.run`), so every edit is undoable and scriptable.
|
||||
|
||||
use egui::{RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// Open the dialog for a typed or menu-invoked STYLESMANAGER (no parameters).
|
||||
pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {
|
||||
if !params.is_null() || id != "stylesmanager" {
|
||||
return None;
|
||||
}
|
||||
app.ui.dialog = Some("stylesmanager".into());
|
||||
Some(Ok(Value::Null))
|
||||
}
|
||||
|
||||
fn temp<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, id: &str) -> Option<T> {
|
||||
ctx.data_mut(|d| d.get_temp::<T>(egui::Id::new(id)))
|
||||
}
|
||||
|
||||
fn set_temp<T: Clone + Send + Sync + 'static>(ctx: &egui::Context, id: &str, v: T) {
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new(id), v));
|
||||
}
|
||||
|
||||
/// Lineweights offered for a style, in millimetres.
|
||||
const LINEWEIGHTS: [f64; 12] = [0.0, 0.09, 0.13, 0.18, 0.25, 0.3, 0.35, 0.5, 0.7, 1.0, 1.4, 2.11];
|
||||
|
||||
/// A command to run after the dialog is drawn.
|
||||
type Action = Option<(&'static str, Value)>;
|
||||
|
||||
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
|
||||
let action = window(app, ctx, open);
|
||||
if let Some((cmd, p)) = action {
|
||||
// Errors are echoed on the command line by `run`.
|
||||
let _ = app.run(cmd, p);
|
||||
}
|
||||
}
|
||||
|
||||
fn window(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
let t = Tokens::get();
|
||||
let title = crate::tl!("Plot Style Manager");
|
||||
let Ok(d) = app.session.doc() else {
|
||||
egui::Window::new(title).open(open).collapsible(false).resizable(false).show(ctx, |ui| {
|
||||
ui.label(crate::tl!("Open a drawing first."));
|
||||
});
|
||||
return None;
|
||||
};
|
||||
let names = cadcraft_doc::plot_style_table_names(d);
|
||||
let layout = match app.session.layout_space() {
|
||||
cadcraft_doc::Space::Paper(n) => d.layout(&n).map(|l| (n.clone(), l.page.plot_style_table.clone(), l.page.show_plot_styles)),
|
||||
cadcraft_doc::Space::Model => None,
|
||||
};
|
||||
let mut sel = temp::<String>(ctx, "pstyle_sel")
|
||||
.filter(|s| names.iter().any(|n| n.eq_ignore_ascii_case(s)))
|
||||
.or_else(|| layout.as_ref().map(|l| l.1.clone()).filter(|n| names.iter().any(|x| x.eq_ignore_ascii_case(n))))
|
||||
.or_else(|| names.first().cloned())
|
||||
.unwrap_or_default();
|
||||
let table = cadcraft_doc::plot_style_table(d, &sel);
|
||||
let in_drawing = d.plot_style_tables.iter().any(|x| x.name.eq_ignore_ascii_case(&sel));
|
||||
let linetypes: Vec<String> =
|
||||
d.linetypes.iter().map(|l| l.name.clone()).filter(|n| !n.eq_ignore_ascii_case("ByLayer") && !n.eq_ignore_ascii_case("ByBlock")).collect();
|
||||
let mut new_style = temp::<String>(ctx, "pstyle_new").unwrap_or_default();
|
||||
let mut action: Action = None;
|
||||
let set = |table: &str, style: Value| Some(("stylesmanager", json!({ "action": "set", "name": table, "style": style })));
|
||||
egui::Window::new(title).id(egui::Id::new("Plot Style Manager")).open(open).default_size(vec2(760.0, 460.0)).resizable(true).show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("Plot style table"));
|
||||
egui::ComboBox::from_id_salt("pstyle_table").selected_text(sel.as_str()).width(200.0).show_ui(ui, |ui| {
|
||||
for n in &names {
|
||||
if ui.selectable_label(n.eq_ignore_ascii_case(&sel), n).clicked() {
|
||||
sel = n.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
if ui.button(crate::tl!("Copy")).on_hover_text(crate::tl!("Keep an editable copy of this table in the drawing")).clicked() {
|
||||
let stem = sel.rsplit_once('.').map(|(s, _)| s).unwrap_or(&sel);
|
||||
let copy = format!("{stem}-copy");
|
||||
action = Some(("stylesmanager", json!({ "action": "new", "name": copy, "from": sel })));
|
||||
}
|
||||
if ui.button(crate::tl!("New CTB")).clicked() {
|
||||
action = Some(("stylesmanager", json!({ "action": "new", "name": "new.ctb" })));
|
||||
}
|
||||
if ui.button(crate::tl!("New STB")).clicked() {
|
||||
action = Some(("stylesmanager", json!({ "action": "new", "name": "new.stb" })));
|
||||
}
|
||||
if ui.add_enabled(in_drawing, egui::Button::new(crate::tl!("Delete"))).clicked() {
|
||||
action = Some(("stylesmanager", json!({ "action": "delete", "name": sel })));
|
||||
}
|
||||
});
|
||||
if let Some((name, current, show)) = &layout {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
RichText::new(crate::tf!("Layout {name}: {table}", name = name, table = if current.is_empty() { "None" } else { current }))
|
||||
.color(t.text_dim),
|
||||
);
|
||||
if ui.add_enabled(!current.eq_ignore_ascii_case(&sel), egui::Button::new(crate::tl!("Use for this layout"))).clicked() {
|
||||
action = Some(("pagesetup", json!({ "layout": name, "plotStyleTable": sel })));
|
||||
}
|
||||
let mut s = *show;
|
||||
if ui.checkbox(&mut s, crate::tl!("Display plot styles")).changed() {
|
||||
action = Some(("pagesetup", json!({ "layout": name, "displayPlotStyles": s })));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.separator();
|
||||
let Some(table) = &table else {
|
||||
ui.label(crate::tl!("No plot style tables."));
|
||||
return;
|
||||
};
|
||||
let named = !table.is_color_dependent();
|
||||
ui.label(RichText::new(&table.description).color(t.text_dim));
|
||||
if !in_drawing {
|
||||
ui.label(RichText::new(crate::tl!("A built-in table: editing it keeps an edited copy in the drawing.")).color(t.text_faint));
|
||||
}
|
||||
egui::ScrollArea::vertical().id_salt("pstyle_rows").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
egui::Grid::new("pstyle_grid").striped(true).num_columns(if named { 7 } else { 6 }).spacing(vec2(12.0, 4.0)).show(ui, |ui| {
|
||||
for h in [
|
||||
crate::tl!("Plot style"),
|
||||
crate::tl!("Color"),
|
||||
crate::tl!("Grayscale"),
|
||||
crate::tl!("Screening"),
|
||||
crate::tl!("Linetype"),
|
||||
crate::tl!("Lineweight"),
|
||||
] {
|
||||
ui.label(RichText::new(h).strong());
|
||||
}
|
||||
if named {
|
||||
ui.label("");
|
||||
}
|
||||
ui.end_row();
|
||||
for st in &table.styles {
|
||||
let style = |k: &str, v: Value| json!({ "name": st.name, k: v });
|
||||
ui.label(&st.name);
|
||||
ui.horizontal(|ui| {
|
||||
let mut object = st.color.is_none();
|
||||
if ui.checkbox(&mut object, crate::tl!("Object")).changed() {
|
||||
action = set(&table.name, style("color", if object { Value::Null } else { json!("#000000") }));
|
||||
}
|
||||
if let Some(c) = st.color {
|
||||
let mut rgb = [c.0, c.1, c.2];
|
||||
if ui.color_edit_button_srgb(&mut rgb).changed() {
|
||||
action = set(&table.name, style("color", json!(cadcraft_color::Rgb(rgb[0], rgb[1], rgb[2]).hex())));
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut gray = st.grayscale;
|
||||
if ui.checkbox(&mut gray, "").changed() {
|
||||
action = set(&table.name, style("grayscale", json!(gray)));
|
||||
}
|
||||
let mut screen = st.screening.min(100);
|
||||
if ui.add(egui::DragValue::new(&mut screen).range(0..=100).suffix(" %")).changed() {
|
||||
action = set(&table.name, style("screening", json!(screen)));
|
||||
}
|
||||
let lt = st.linetype.clone().unwrap_or_else(|| crate::tl!("Use object linetype").to_string());
|
||||
egui::ComboBox::from_id_salt(("pstyle_lt", &st.name)).selected_text(lt).width(140.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(st.linetype.is_none(), crate::tl!("Use object linetype")).clicked() {
|
||||
action = set(&table.name, style("linetype", Value::Null));
|
||||
}
|
||||
for n in &linetypes {
|
||||
if ui.selectable_label(st.linetype.as_deref().is_some_and(|x| x.eq_ignore_ascii_case(n)), n).clicked() {
|
||||
action = set(&table.name, style("linetype", json!(n)));
|
||||
}
|
||||
}
|
||||
});
|
||||
let lw = st
|
||||
.lineweight
|
||||
.map(|w| format!("{:.2} mm", f64::from(w) / 100.0))
|
||||
.unwrap_or_else(|| crate::tl!("Use object lineweight").to_string());
|
||||
egui::ComboBox::from_id_salt(("pstyle_lw", &st.name)).selected_text(lw).width(140.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(st.lineweight.is_none(), crate::tl!("Use object lineweight")).clicked() {
|
||||
action = set(&table.name, style("lineweight", Value::Null));
|
||||
}
|
||||
for mm in LINEWEIGHTS {
|
||||
let hundredths = (mm * 100.0).round() as u16;
|
||||
if ui.selectable_label(st.lineweight == Some(hundredths), format!("{mm:.2} mm")).clicked() {
|
||||
action = set(&table.name, style("lineweight", json!(mm)));
|
||||
}
|
||||
}
|
||||
});
|
||||
if named {
|
||||
let normal = st.name.eq_ignore_ascii_case(cadcraft_doc::NORMAL_STYLE);
|
||||
if ui.add_enabled(!normal, egui::Button::new(crate::tl!("Remove"))).clicked() {
|
||||
action = Some(("stylesmanager", json!({ "action": "set", "name": table.name, "removeStyle": st.name })));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
if named {
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::tl!("New style"));
|
||||
ui.add(egui::TextEdit::singleline(&mut new_style).desired_width(160.0));
|
||||
if ui.add_enabled(!new_style.trim().is_empty(), egui::Button::new(crate::tl!("Add"))).clicked() {
|
||||
action = set(&table.name, json!({ "name": new_style.trim() }));
|
||||
new_style.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
set_temp(ctx, "pstyle_sel", sel);
|
||||
set_temp(ctx, "pstyle_new", new_style);
|
||||
action
|
||||
}
|
||||
|
|
@ -107,7 +107,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr
|
|||
| WMF, EPS, ACIS SAT, STL, FBX, IGES/STEP (Windows), 3DS | various | no; most are 3D and wait for M11 | 20–40 |
|
||||
| SHX fonts (`.shx`, big fonts) | read | **not parsed**: our stroke font or FONTALT stands in, so text metrics differ from AutoCAD | 8–12 |
|
||||
| Linetype (`.lin`) and hatch pattern (`.pat`) files | load user files | own built-in library only; user files can't be loaded | 3–5 |
|
||||
| Plot styles (`.ctb`, `.stb`), plotter configs (`.pc3`), `.pmp` | yes | no | 8–12 |
|
||||
| Plot styles (`.ctb`, `.stb`), plotter configs (`.pc3`), `.pmp` | yes | plot style tables in our own JSON format ([plot-styles.md](plot-styles.md)) and kept in the drawing; binary `.ctb`/`.stb` not read (no public spec); no `.pc3`/`.pmp` | 6–10 |
|
||||
| Scripts (`.scr`) | SCRIPT | script text through the CLI, MCP and control channel; no SCRIPT command reading a `.scr` file | 1–2 |
|
||||
| Aliases (`.pgp`), CUI(x), workspaces | yes | no | 6–10 |
|
||||
| AutoLISP (`.lsp`, `.fas`, `.vlx`), `.dll`/`.arx` plug-ins | yes | no | see [gaps.md](gaps.md) (M12) |
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ do them first.
|
|||
| F11 | Tables: no cell styles, formulas, data links, table breaking | `cmd/table.rs` | Medium | 10–15 |
|
||||
| F12 | Annotative scaling: the flag is stored, nothing scales; no scale list, no "Annotative Object Scale" commands | 4 uncovered menu items | Medium-high in layouts | 10–15 |
|
||||
| F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); gradients render as a flat colour; associativity not updated when the boundary moves; origin always 0,0; island style always normal; no gap tolerance, separate hatches, hatch dialog, MPolygon | `doc/src/library.rs`, `render/lib.rs:430-437`, `cmd/hatch.rs:198,201` | Medium | 15–25 |
|
||||
| F14 | **[alpha blocker]** Named plot styles (STB, PlotStyle property) and plot style table editing/files missing (colour-dependent tables apply to PDF plots and "Display plot styles" layouts since #410: built-in `default`/`monochrome`/`grayscale.ctb`); no plot preview, plotter devices, PUBLISH/batch plot; Print opens no plot dialog (#160, PR #200) | `palettes.rs:666` | High for sheet output | 20–30 |
|
||||
| F14 | **[alpha blocker]** No plot preview, plotter devices, PUBLISH/batch plot; Print opens no plot dialog (#160, PR #200). Plot styles (CTB and STB, [plot-styles.md](plot-styles.md)) apply since #410; their end/join/fill styles aren't applied and binary `.ctb`/`.stb` files can't be read | `palettes.rs:666` | High for sheet output | 15–25 |
|
||||
| F15 | Viewports: no polygonal, object or clipped viewports; no viewport scale control in the UI | menu items uncovered | Medium | 6–10 |
|
||||
| F16 | Groups (GROUP, GROUPEDIT, PICKSTYLE) missing; ACAD_GROUP written empty | `dxf_write.rs` | Medium | 4–6 |
|
||||
| F17 | Attributes: BATTMAN lists only; no ATTSYNC, EATTEXT, global ATTEDIT | `blocks.rs:300` | Medium | 6–10 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
# Plot styles
|
||||
|
||||
A plot style table decides how objects print. CADCraft applies it to PDF plots (PLOT,
|
||||
EXPORTPDF and anything that plots through them). It also applies it to a layout shown with
|
||||
**Display plot styles** on: on screen and in the layout's PNG and SVG exports.
|
||||
|
||||
## Tables
|
||||
|
||||
| Kind | Picks the style by | Styles |
|
||||
|---|---|---|
|
||||
| Colour-dependent (`.ctb`) | the object's index colour (a true colour uses its nearest index colour) | exactly 255, `Color_1` … `Color_255` |
|
||||
| Named (`.stb`) | the object's **PlotStyle** property | `Normal` first, then any names |
|
||||
|
||||
A style can set these:
|
||||
|
||||
| Field | Meaning | "Use object" value |
|
||||
|---|---|---|
|
||||
| `color` | output colour, `"#rrggbb"` | absent / `null` |
|
||||
| `grayscale` | print the colour as the grey of its luminance (Rec. 709 weights) | `false` |
|
||||
| `screening` | ink intensity 0–100 %, mixing toward the white paper | `100` |
|
||||
| `linetype` | linetype name (loaded, or from the built-in library; `Continuous` draws solid) | absent / `null` |
|
||||
| `lineweight` | hundredths of a millimetre, 0–211; used when lineweights are plotted | absent / `null` |
|
||||
| `end`, `join`, `fill` | line end, line join and area fill style | `object` |
|
||||
|
||||
Colour 7 prints black. `end`, `join` and `fill` are stored but not applied yet.
|
||||
|
||||
### Built-in tables
|
||||
|
||||
These are CADCraft's own definitions, not copies of any vendor's files.
|
||||
|
||||
| Table | What it does |
|
||||
|---|---|
|
||||
| `default.ctb` | keeps every object property |
|
||||
| `monochrome.ctb` | prints every colour black |
|
||||
| `grayscale.ctb` | prints every colour as the grey of its luminance |
|
||||
| `default.stb` | `Normal` (object properties), `Black`, `Screened 50%`, `Thick` (0.50 mm) |
|
||||
| `monochrome.stb` | `Normal` prints black; `Screened 50%` prints black at 50 % |
|
||||
|
||||
A table kept in the drawing shadows a built-in table of the same name.
|
||||
|
||||
## Named plot styles
|
||||
|
||||
Layers have a plot style name (default `Normal`). Objects have `ByLayer` (the default), `ByBlock`
|
||||
or a style name. `ByLayer` takes the layer's style. On layer 0 inside a block, that is the block
|
||||
reference's layer. `ByBlock` takes the block reference's style. A name the table doesn't have
|
||||
prints as `Normal`. A colour-dependent table ignores the property.
|
||||
|
||||
## Commands
|
||||
|
||||
- `pagesetup {layout, plotStyleTable, displayPlotStyles}` sets the layout's table and the
|
||||
Display plot styles flag. Without a layout, it reports the built-in tables in `plotStyleTables`.
|
||||
- `plot` / `exportpdf {plotStyleTable}` replaces the page setup's table for one plot. `"None"`
|
||||
plots object colours.
|
||||
- `plotstyle {name, handles?}` sets the objects' plot style. With nothing selected, it sets the
|
||||
current plot style for new objects (`CPLOTSTYLE`). Without `name`, it reports the current
|
||||
style and the named styles.
|
||||
- `layer.set {name, plotStyle}` sets a layer's plot style.
|
||||
- `stylesmanager {action, …}` manages the tables: `list`, `get`, `new` (`kind`, `from`), `set`
|
||||
(`style: {name, …fields}`, `removeStyle`, `description`), `delete`, `load` (`path` or `text`)
|
||||
and `save` (`path`, or returns `text`). Typed or chosen from **File ▸ Plot Styles...**, it
|
||||
opens the Plot Style Manager dialog.
|
||||
|
||||
## Table files
|
||||
|
||||
Tables are saved in our own format: UTF-8 JSON with a format marker. Fields left at "use the
|
||||
object's" are omitted.
|
||||
|
||||
```json
|
||||
{
|
||||
"cadcraftPlotStyleTable": 1,
|
||||
"name": "office.stb",
|
||||
"description": "Office pens",
|
||||
"kind": "named",
|
||||
"styles": [
|
||||
{ "name": "Normal" },
|
||||
{ "name": "Pen 5", "color": "#000000", "screening": 40, "lineweight": 35, "linetype": "HIDDEN", "end": "round" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `kind` is `colorDependent` or `named`.
|
||||
- `end` is one of `object`, `butt`, `square`, `round` or `diamond`.
|
||||
- `join` is one of `object`, `miter`, `bevel`, `round` or `diamond`.
|
||||
- `fill` is one of `object`, `solid`, `checkerboard`, `crosshatch`, `diamonds`, `horizontalBars`,
|
||||
`slantLeft`, `slantRight`, `squareDots` or `verticalBars`.
|
||||
- `color` may also be written `[r, g, b]`.
|
||||
|
||||
Reading fixes hostile values:
|
||||
|
||||
- Screening is capped at 100 and lineweights at 211.
|
||||
- A colour-dependent table always gets 255 styles named `Color_n`.
|
||||
- A named table gets unique style names (at most 1024), with `Normal` first.
|
||||
- Files over 4 MB are refused.
|
||||
|
||||
Binary `.ctb`/`.stb` files are not read. We haven't found a public specification of that format.
|
||||
|
||||
## In DXF files
|
||||
|
||||
- The page setup's table is PLOTSETTINGS group 7, and Display plot styles is plot layout flag 2
|
||||
(group 70).
|
||||
- Plot style names follow the DXF Reference. The named object dictionary entry
|
||||
`ACAD_PLOTSTYLENAME` is an `ACDBDICTIONARYWDFLT` whose entries name `ACDBPLACEHOLDER` objects
|
||||
(default `Normal`). Layers and objects point at them with group 390. An object without a 390
|
||||
is `ByLayer`. `ByBlock` isn't written, so it reads back as `ByLayer`.
|
||||
- Tables kept in the drawing travel in the `CADCRAFT_PLOTSTYLES` XRECORD (JSON) of the named
|
||||
object dictionary.
|
||||
|
|
@ -35,7 +35,7 @@ the alpha checklist. Main platform: macOS.
|
|||
| Draw and modify precisely with object snaps and tracking | **partial, blocking** | Running snaps, polar, ortho and coordinate entry work; object snap tracking, extension and parallel snaps are toggles with no behaviour; no FROM/M2P/TK or typed snap overrides; TRIM/EXTEND standard mode, OFFSET Erase/Layer/Multiple, ROTATE Reference print "not available yet"; ellipse/spline intersections approximate ([ui-parity.md](ui-parity.md), [gaps.md](gaps.md) U1, U3, U4, F1) | 35–50 |
|
||||
| Dimension and annotate | partial, not blocking | All core DIM types, associativity, DIMSTYLE variables (43 of ~80), TEXT/MTEXT, MLEADER, tables work; no DIMSTYLE dialog, MTEXT editor not in place, MLEADER thin (F7, F9, F10). A drafter can dimension a sheet end to end | 0 for the gate (beta work) |
|
||||
| Use blocks (insert library blocks, edit them, attributes, xrefs) | **partial, blocking** | Static BLOCK/INSERT/attributes work; no block editor (BEDIT) or REFEDIT, so a block can't be edited in place; no xrefs; dynamic blocks from other drawings lose their behaviour (F2, F3) | 35–55 |
|
||||
| Layers, layouts and plotting a sheet | **partial, blocking** | Layers, layouts, viewports, page setups and PDF output work; plot styles (CTB/STB) aren't applied, so office-standard monochrome/lineweight plots come out wrong; no plot preview or plot dialog (#160); printers/plotters only via PDF (F14, H3) | 15–25 |
|
||||
| Layers, layouts and plotting a sheet | **partial, blocking** | Layers, layouts, viewports, page setups, PDF output and plot styles (CTB/STB, #410) work; no plot preview or plot dialog (#160); printers/plotters only via PDF (F14, H3) | 15–25 |
|
||||
| Save DWG that AutoCAD opens without loss, and reopen others' DWGs | **no** | Saving drops every entity and object we don't model (Unknown, IMAGE, WIPEOUT, gradients, groups, views, foreign xdata); MULTILEADER becomes LEADER+MTEXT; DWG always written as R2000; never checked in AutoCAD; no real-file tests; a 10 MB DWG failed after ~2 min (#57) (FF1–FF6) | 40–60 + owner (real-file corpus, AutoCAD check) |
|
||||
| **To alpha** | | | **≈ 125–190** |
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ presence: an option that prints "not available yet" doesn't count.
|
|||
| Leaders, tables, fields | 4% | 25% | 25–35 | basic MLEADER and TABLE; leader options ignored, no MLEADEREDIT/ALIGN/COLLECT, no block content; no formulas, data links, FIELD | [gaps](gaps.md#features) |
|
||||
| Hatch and gradients | 5% | 35% | 15–25 | pick points with islands; 23 patterns of ~85; gradients render flat; associativity flag not acted on; no origin, gap tolerance, separate hatches, hatch dialog | [gaps](gaps.md#features) |
|
||||
| Blocks, attributes, xrefs, groups | 9% | 25% | 80–120 | static blocks and attributes; no BEDIT, dynamic blocks, xrefs, REFEDIT, XCLIP, GROUP, ATTSYNC, EATTEXT, DesignCenter | [gaps](gaps.md#features) |
|
||||
| Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, rectangular viewports, per-VP layers, page setups, PDF; no plot styles (CTB/STB), plot preview, devices, PUBLISH, polygonal/object viewports, annotative scales | [gaps](gaps.md#features) |
|
||||
| Layouts, viewports, plotting | 9% | 35% | 35–50 | layouts, rectangular viewports, per-VP layers, page setups, PDF, plot styles (CTB/STB); no plot preview, devices, PUBLISH, polygonal/object viewports, annotative scales | [gaps](gaps.md#features) |
|
||||
| Inquiry and utilities | 4% | 30% | 30–45 | DIST, AREA, ID, LIST, MEASUREGEOM, CAL, FIND, COUNT; no autosave/recovery, AUDIT, RECOVER, OPTIONS, SPELL, QuickCalc, DWG Compare, ETRANSMIT | [gaps](gaps.md#features) |
|
||||
| Parametric constraints | 2% | 50% | 20–30 | all 12 geometric and the dimensional constraints, own solver; no DOF report, no G2 Smooth, no ellipse/spline geometry, no inference | [geometry-parity](geometry-parity.md#constraint-solver--50) |
|
||||
| 3D modelling, visualisation, rendering | 5% | 2% | 300–450 | no UCS, 3D views, orbit, solids, surfaces, meshes, sections, visual styles, materials, lights or rendering; 150 of the 491 menu items | [gaps](gaps.md#features) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue