From fc4220e9b7d7cc8e9d26704ccf0078d94caaa6c0 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 01:21:24 +0200 Subject: [PATCH] Plot styles: named styles (STB), PLOTSTYLE, STYLESMANAGER and table files (part of #410) (#418) 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 --- crates/doc/src/entity.rs | 4 + crates/doc/src/lib.rs | 4 + crates/doc/src/plotstyle.rs | 265 ++++++++++++++++- crates/engine/src/cmd/layer.rs | 10 +- crates/engine/src/cmd/layout.rs | 4 + crates/engine/src/cmd/layout/plotstyle.rs | 347 ++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/tests/plot_styles.rs | 86 ++++++ crates/io/src/dxf_ext.rs | 44 ++- crates/io/src/dxf_read.rs | 70 ++++- crates/io/src/dxf_write.rs | 61 +++- crates/io/tests/plot_styles.rs | 43 +++ crates/render/src/lib.rs | 28 +- crates/render/src/plotstyle.rs | 51 +++- crates/render/tests/plot_styles.rs | 42 +++ crates/ui-egui/src/dialogs.rs | 1 + crates/ui-egui/src/i18n/uk.tsv | 22 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 3 + crates/ui-egui/src/palettes.rs | 37 ++- crates/ui-egui/src/plotstyles.rs | 210 +++++++++++++ docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/plot-styles.md | 106 +++++++ docs/roadmap.md | 2 +- docs/target-app-parity.md | 2 +- 26 files changed, 1399 insertions(+), 49 deletions(-) create mode 100644 crates/engine/src/cmd/layout/plotstyle.rs create mode 100644 crates/ui-egui/src/plotstyles.rs create mode 100644 docs/plot-styles.md diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 1e88289..296430d 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -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(), } } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ad695cf..a569ab8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -66,6 +66,9 @@ pub struct Drawing { pub ucss: Vec, pub layer_states: Vec, pub groups: Vec, + /// Plot style tables kept with the drawing (STYLESMANAGER); they shadow built-in tables of + /// the same name. + pub plot_style_tables: Vec, /// Parametric constraints (GEOMCONSTRAINT / DIMCONSTRAINT). pub constraints: Vec, /// 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, diff --git a/crates/doc/src/plotstyle.rs b/crates/doc/src/plotstyle.rs index 7458e06..bbc356f 100644 --- a/crates/doc/src/plotstyle.rs +++ b/crates/doc/src/plotstyle.rs @@ -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, /// 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, /// Lineweight in hundredths of a millimetre (0..=211); `None` uses the object's lineweight. + #[serde(skip_serializing_if = "Option::is_none")] pub lineweight: Option, + #[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(v: &T) -> bool { + *v == T::default() +} + +/// `Option` as `"#rrggbb"` (also read from `[r, g, b]`). +mod rgb_hex { + use cadcraft_color::Rgb; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(c: &Option, s: S) -> Result { + 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, D::Error> { + Ok(match Option::::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) -> 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 { 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::(); + 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 { + 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 { +/// 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 { 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 { + let mut v: Vec = 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::>(), ["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::(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(); diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index ddaf33c..a120d96 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { .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() diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index fe24a11..9a20d66 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -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 { @@ -79,6 +80,9 @@ pub fn specs() -> Vec { .noundo(), CommandSpec::new("pspace", "Paper Space", run_pspace).alias(&["ps"]).noundo(), ] + .into_iter() + .chain(plotstyle::specs()) + .collect() } // ---------- model space through viewports ---------- diff --git a/crates/engine/src/cmd/layout/plotstyle.rs b/crates/engine/src/cmd/layout/plotstyle.rs new file mode 100644 index 0000000..b92b158 --- /dev/null +++ b/crates/engine/src/cmd/layout/plotstyle.rs @@ -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 { + 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 { + let mut used: Vec = + 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 = 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 { + 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 { + 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 { + 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 { + 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>> { + 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(cmd: &str, v: &Value, k: &str, msg: &str) -> Result> { + 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 { + 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 = 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::())); + 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)"))), + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ed2120c..5650c80 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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() } } diff --git a/crates/engine/tests/plot_styles.rs b/crates/engine/tests/plot_styles.rs index 2c261d5..06f8cb4 100644 --- a/crates/engine/tests/plot_styles.rs +++ b/crates/engine/tests/plot_styles.rs @@ -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(); +} diff --git a/crates/io/src/dxf_ext.rs b/crates/io/src/dxf_ext.rs index 2bb1831..661239a 100644 --- a/crates/io/src/dxf_ext.rs +++ b/crates/io/src/dxf_ext.rs @@ -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> { 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, +} + +/// JSON chunks for the plot style tables XRECORD, or `None` when the drawing keeps none. +pub(crate) fn plot_style_chunks(tables: &[PlotStyleTable]) -> Option> { + 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> { + 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, Parametric)> { if text.len() > MAX_PAYLOAD { diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 500ac21..95e73ab 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -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, + /// Plot style name placeholder handle (upper case) → plot style name (`ACAD_PLOTSTYLENAME`). + pstyles: HashMap, +} + +/// 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 { + 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 = 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 { + 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)], 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 = 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 { } 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, 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 }; diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 4095b6a..4c1f968 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -12,6 +12,8 @@ use crate::dxf_ext::{self, DimVal, K}; struct W { t: Vec, next: u64, + /// Upper-case plot style name → its `ACDBPLACEHOLDER` handle (group 390 of layers and objects). + pstyles: HashMap, } 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 = 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 = 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); diff --git a/crates/io/tests/plot_styles.rs b/crates/io/tests/plot_styles.rs index 6e7721b..4e6a3f6 100644 --- a/crates/io/tests/plot_styles.rs +++ b/crates/io/tests/plot_styles.rs @@ -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}"); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index eb9bc52..a5beddb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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, } 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, } /// 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>(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(_)) { diff --git a/crates/render/src/plotstyle.rs b/crates/render/src/plotstyle.rs index b51a19b..ef87f99 100644 --- a/crates/render/src/plotstyle.rs +++ b/crates/render/src/plotstyle.rs @@ -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 { - 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 { + 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) -> Option { + 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) -> 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, 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> { - self.linetypes.get(self.index(ink)?)?.clone() + pub(crate) fn linetype(&self, ink: Ink, named: Option) -> Option> { + self.linetypes.get(self.index(ink, named)?)?.clone() } } diff --git a/crates/render/tests/plot_styles.rs b/crates/render/tests/plot_styles.rs index 264bf92..7513491 100644 --- a/crates/render/tests/plot_styles.rs +++ b/crates/render/tests/plot_styles.rs @@ -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)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ee7daf9..4f50ddb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index fd885ad..d4c71da 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Фільтри точок diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8f86319..1da602d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 45295db..12c607f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -61,6 +61,9 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option 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); } diff --git a/crates/ui-egui/src/plotstyles.rs b/crates/ui-egui/src/plotstyles.rs new file mode 100644 index 0000000..5240c6d --- /dev/null +++ b/crates/ui-egui/src/plotstyles.rs @@ -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> { + if !params.is_null() || id != "stylesmanager" { + return None; + } + app.ui.dialog = Some("stylesmanager".into()); + Some(Ok(Value::Null)) +} + +fn temp(ctx: &egui::Context, id: &str) -> Option { + ctx.data_mut(|d| d.get_temp::(egui::Id::new(id))) +} + +fn set_temp(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::(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 = + 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::(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 +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5b26609..27c0882 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) | diff --git a/docs/gaps.md b/docs/gaps.md index 289ab41..ea92874 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/plot-styles.md b/docs/plot-styles.md new file mode 100644 index 0000000..8adb90a --- /dev/null +++ b/docs/plot-styles.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 5428a59..328682f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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** | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0299014..c9cfa3e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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) |