diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index f9276e4..ad695cf 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ mod entity; mod extents; mod header; pub mod library; +mod plotstyle; mod store; mod tables; @@ -22,6 +23,7 @@ pub use constraint::*; pub use entity::*; pub use extents::{MAX_BLOCK_DEPTH, entity_bounds}; pub use header::{HVal, Header}; +pub use plotstyle::*; pub use store::EntityStore; pub use tables::*; diff --git a/crates/doc/src/plotstyle.rs b/crates/doc/src/plotstyle.rs new file mode 100644 index 0000000..7458e06 --- /dev/null +++ b/crates/doc/src/plotstyle.rs @@ -0,0 +1,262 @@ +//! Plot style tables: how objects print. +//! +//! A colour-dependent table (`.ctb`) has one plot style per index colour 1..=255: an object +//! prints with the style of its colour. A named table (`.stb`) holds styles that layers and +//! objects name directly. Each style can replace the object's colour, fade it (screening), turn +//! it grey, and replace its linetype and lineweight. +//! +//! 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. + +use cadcraft_color::Rgb; +use serde::{Deserialize, Serialize}; + +/// Built-in colour-dependent table that prints objects with their own properties. +pub const DEFAULT_CTB: &str = "default.ctb"; +/// Built-in colour-dependent table that prints everything black. +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"; + +/// The names of the built-in tables. +pub const BUILTIN_PLOT_STYLE_TABLES: &[&str] = &[DEFAULT_CTB, MONOCHROME_CTB, GRAYSCALE_CTB]; + +/// Number of styles in a colour-dependent table (one per index colour 1..=255). +pub const CTB_STYLES: usize = 255; + +/// Line end style of a plot style. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EndStyle { + #[default] + Object, + Butt, + Square, + Round, + Diamond, +} + +/// Line join style of a plot style. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum JoinStyle { + #[default] + Object, + Miter, + Bevel, + Round, + Diamond, +} + +/// Fill style of a plot style (for filled areas). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum FillStyle { + #[default] + Object, + Solid, + Checkerboard, + Crosshatch, + Diamonds, + HorizontalBars, + SlantLeft, + SlantRight, + SquareDots, + VerticalBars, +} + +/// One plot style. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PlotStyle { + pub name: String, + pub description: String, + /// Output colour; `None` uses the object's colour. + pub color: Option, + /// Print the colour as the grey of its luminance. + pub grayscale: bool, + /// Ink intensity in percent, 0 (white) to 100 (full colour). + pub screening: u8, + /// Linetype name; `None` uses the object's linetype. + pub linetype: Option, + /// Lineweight in hundredths of a millimetre (0..=211); `None` uses the object's lineweight. + pub lineweight: Option, + pub end: EndStyle, + pub join: JoinStyle, + pub fill: FillStyle, +} + +impl Default for PlotStyle { + fn default() -> Self { + PlotStyle::object("") + } +} + +impl PlotStyle { + /// A style that keeps every object property. + pub fn object(name: &str) -> PlotStyle { + PlotStyle { + name: name.into(), + description: String::new(), + color: None, + grayscale: false, + screening: 100, + linetype: None, + lineweight: None, + end: EndStyle::Object, + join: JoinStyle::Object, + fill: FillStyle::Object, + } + } + /// Whether the style leaves the colour alone. + pub fn keeps_color(&self) -> bool { + self.color.is_none() && !self.grayscale && self.screening >= 100 + } + /// The printed colour of an object drawn in `rgb`: the style's colour (else `rgb`), turned + /// grey when asked, then screened toward white. + pub fn apply_color(&self, rgb: Rgb) -> Rgb { + let c = self.color.unwrap_or(rgb); + let c = if self.grayscale { + let g = (c.luma().clamp(0.0, 1.0) * 255.0).round() as u8; + Rgb(g, g, g) + } else { + c + }; + let s = u16::from(self.screening.min(100)); + // Screening mixes the ink with the white paper: 100 % = full ink, 0 % = white. + let screen = |v: u8| (255 - (255 - u16::from(v)) * s / 100) as u8; + Rgb(screen(c.0), screen(c.1), screen(c.2)) + } +} + +/// Whether a table maps colours or names to styles. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PlotStyleKind { + /// Colour-dependent (`.ctb`): style `i - 1` applies to index colour `i`. + #[default] + ColorDependent, + /// Named (`.stb`): styles are picked by name. + Named, +} + +/// A plot style table. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PlotStyleTable { + /// File name, such as `monochrome.ctb`. + pub name: String, + pub description: String, + pub kind: PlotStyleKind, + pub styles: Vec, +} + +impl PlotStyleTable { + /// A colour-dependent table whose style for index colour `i` is `f(i)` (else object + /// properties), named `Color_i`. + pub fn color_dependent(name: &str, description: &str, f: impl Fn(u8) -> Option) -> PlotStyleTable { + let styles = (1..=255u8) + .map(|i| { + let label = format!("Color_{i}"); + let mut s = f(i).unwrap_or_else(|| PlotStyle::object(&label)); + s.name = label; + s + }) + .collect(); + 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). + 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") }; + match n.as_str() { + 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() }) + })), + GRAYSCALE_CTB => Some(PlotStyleTable::color_dependent(GRAYSCALE_CTB, "Every colour prints as the grey of its luminance", |_| { + Some(PlotStyle { grayscale: true, ..PlotStyle::default() }) + })), + _ => None, + } + } + + /// Whether this table is colour-dependent. + pub fn is_color_dependent(&self) -> bool { + self.kind == PlotStyleKind::ColorDependent + } + + /// The style for index colour `aci` (1..=255) of a colour-dependent table. + pub fn by_color(&self, aci: u8) -> Option<&PlotStyle> { + if !self.is_color_dependent() || aci == 0 { + return None; + } + self.styles.get(usize::from(aci) - 1) + } + + /// The style called `name` (case-insensitive). + pub fn by_name(&self, name: &str) -> Option<&PlotStyle> { + self.styles.iter().find(|s| s.name.eq_ignore_ascii_case(name)) + } +} + +/// Whether a page setup's table name means "no plot style table". +pub fn no_plot_style_table(name: &str) -> bool { + let n = name.trim(); + 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 { + if no_plot_style_table(name) { + return None; + } + PlotStyleTable::builtin(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtins_are_complete_color_dependent_tables() { + for n in BUILTIN_PLOT_STYLE_TABLES { + let t = PlotStyleTable::builtin(n).unwrap(); + 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"); + assert_eq!(t.by_color(255).unwrap().name, "Color_255"); + assert!(t.by_color(0).is_none()); + } + assert!(PlotStyleTable::builtin("Monochrome").is_some()); + assert!(PlotStyleTable::builtin("nope.ctb").is_none()); + assert!(no_plot_style_table("") && no_plot_style_table(" None ") && !no_plot_style_table("monochrome.ctb")); + } + + #[test] + fn colour_transforms() { + let mono = PlotStyleTable::builtin(MONOCHROME_CTB).unwrap(); + assert_eq!(mono.by_color(1).unwrap().apply_color(Rgb(255, 0, 0)), Rgb(0, 0, 0)); + let gray = PlotStyleTable::builtin(GRAYSCALE_CTB).unwrap(); + let g = gray.by_color(3).unwrap().apply_color(Rgb(0, 255, 0)); + assert!(g.0 == g.1 && g.1 == g.2 && g.0 > 128, "{g:?}"); + let half = PlotStyle { color: Some(Rgb(0, 0, 0)), screening: 50, ..PlotStyle::default() }; + assert_eq!(half.apply_color(Rgb(9, 9, 9)), Rgb(128, 128, 128)); + let none = PlotStyle { screening: 0, ..PlotStyle::default() }; + assert_eq!(none.apply_color(Rgb(10, 20, 30)), Rgb(255, 255, 255)); + assert!(PlotStyle::default().keeps_color() && !half.keeps_color()); + } + + #[test] + fn json_round_trip_with_defaults() { + let t = PlotStyleTable::builtin(GRAYSCALE_CTB).unwrap(); + let back: PlotStyleTable = serde_json::from_value(serde_json::to_value(&t).unwrap()).unwrap(); + assert_eq!(back, t); + let s: PlotStyle = serde_json::from_value(serde_json::json!({"name": "Thick", "lineweight": 50})).unwrap(); + assert_eq!((s.screening, s.lineweight, s.color), (100, Some(50), None)); + } +} diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 89b4e87..d3b992d 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -538,6 +538,9 @@ pub struct PageSetup { pub plot_style_table: String, pub center: bool, pub lineweights: bool, + /// Draw the layout on screen (and in its PNG/SVG exports) with its plot style table + /// applied ("Display plot styles"; DXF plot layout flag 2). + pub show_plot_styles: bool, } impl Default for PageSetup { @@ -555,6 +558,7 @@ impl Default for PageSetup { plot_style_table: String::new(), center: false, lineweights: true, + show_plot_styles: false, } } } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 99e7fe1..fe24a11 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -61,7 +61,7 @@ pub fn specs() -> Vec { CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup) .menu(&["File", "Page Setup Manager..."]) .params( - "{layout?: current, paper?: \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?}", + "{layout?: current, paper?: \"A4\"|\"A3\"|\"Letter\"|\"ANSI B\"|…, width?, height? (mm), landscape?, margins?: [l,b,r,t] mm, lineweights?, plotArea?, scale?, scaleToFit?, center?, plotStyleTable?: \"monochrome.ctb\"|\"grayscale.ctb\"|\"default.ctb\"|\"None\"|…, displayPlotStyles?: bool}", ), CommandSpec::new("plot", "Print...", |s, p| run_plot(s, p, "plot")) .menu(&["File", "Print..."]) @@ -692,7 +692,20 @@ fn run_viewport_set(s: &mut Session, p: &Value) -> Result { fn run_pagesetup(s: &mut Session, p: &Value) -> Result { let cmd = "pagesetup"; - let keys = ["paper", "width", "height", "landscape", "margins", "lineweights", "plotArea", "scale", "scaleToFit", "center", "plotStyleTable"]; + let keys = [ + "paper", + "width", + "height", + "landscape", + "margins", + "lineweights", + "plotArea", + "scale", + "scaleToFit", + "center", + "plotStyleTable", + "displayPlotStyles", + ]; let changes = keys.iter().any(|k| p.get(k).is_some()); let name = match (str_param(p, "layout"), s.space()) { (Some(n), _) => Some(layout_name(cmd, s.doc()?, n)?), @@ -708,6 +721,7 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result { return Ok(json!({ "layouts": d.layouts.iter().map(|l| json!({"name": l.name, "page": serde_json::to_value(&l.page).unwrap_or(Value::Null)})).collect::>(), "papers": cadcraft_render::PAPER_SIZES.iter().map(|p| p.name).collect::>(), + "plotStyleTables": cadcraft_doc::BUILTIN_PLOT_STYLE_TABLES, })); }; let mut page = s.doc()?.layout(&name).map(|l| l.page.clone()).unwrap_or_default(); @@ -763,6 +777,9 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result { if let Some(v) = str_param(p, "plotStyleTable") { page.plot_style_table = v.chars().take(260).collect(); } + if let Some(v) = p.get("displayPlotStyles").and_then(Value::as_bool) { + page.show_plot_styles = v; + } if changes { if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == name) { l.page = page.clone(); diff --git a/crates/engine/tests/plot_styles.rs b/crates/engine/tests/plot_styles.rs new file mode 100644 index 0000000..2c261d5 --- /dev/null +++ b/crates/engine/tests/plot_styles.rs @@ -0,0 +1,20 @@ +//! PAGESETUP: the plot style table and "Display plot styles" of a layout (issue #410). + +use cadcraft_engine::Session; +use serde_json::json; + +#[test] +fn pagesetup_sets_table_and_display_flag() { + let mut s = Session::new(); + let r = s.execute("pagesetup", &json!({"layout": "Layout1", "plotStyleTable": "monochrome.ctb", "displayPlotStyles": true})).unwrap(); + assert_eq!(r["page"]["plotStyleTable"], "monochrome.ctb"); + assert_eq!(r["page"]["showPlotStyles"], true); + let page = &s.doc().unwrap().layout("Layout1").unwrap().page; + assert!(page.show_plot_styles && page.plot_style_table == "monochrome.ctb"); + // The report lists the built-in tables. + let report = s.execute("pagesetup", &json!({})).unwrap(); + let tables: Vec<&str> = report["plotStyleTables"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect(); + assert!(tables.contains(&"monochrome.ctb") && tables.contains(&"grayscale.ctb"), "{tables:?}"); + s.execute("pagesetup", &json!({"layout": "Layout1", "displayPlotStyles": false})).unwrap(); + assert!(!s.doc().unwrap().layout("Layout1").unwrap().page.show_plot_styles); +} diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 1c771d6..c4e9daf 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -1686,8 +1686,10 @@ fn page_setup(t: &T) -> PageSetup { if let Some(dev) = t.s(2).map(|s| s.trim().to_string()) { p.device = if dev.is_empty() || dev.eq_ignore_ascii_case("none_device") { "None".into() } else { dev }; } - // Plot layout flags: 4 centred, 16 standard scale (75 = 0: scaled to fit), 128 lineweights. + // Plot layout flags: 2 display plot styles, 4 centred, 16 standard scale (75 = 0: scaled + // to fit), 128 lineweights. if let Some(flags) = t.i(70) { + p.show_plot_styles = flags & 2 != 0; p.center = flags & 4 != 0; p.lineweights = flags & 128 != 0; p.scale_to_fit = flags & 16 != 0 && t.i(75) == Some(0); diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index 44c8325..8bb55b4 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -1859,7 +1859,12 @@ pub fn write(d: &Drawing) -> String { // per 143 drawing units). let scale = if page.scale.is_finite() && page.scale > 0.0 { page.scale } else { 1.0 }; let standard = page.scale_to_fit || scale == 1.0; - let plot_flags = 512 | 32 | if standard { 16 } else { 0 } | if page.lineweights { 128 } else { 0 } | if page.center { 4 } else { 0 }; + let plot_flags = 512 + | 32 + | if standard { 16 } else { 0 } + | if page.lineweights { 128 } else { 0 } + | if page.center { 4 } else { 0 } + | if page.show_plot_styles { 2 } else { 0 }; let plot_type = match page.plot_area.as_str() { "display" => 0, "extents" => 1, diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index 1705c37..7aa44c6 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -3,7 +3,8 @@ //! One page per plot: model-space extents fitted to a sheet, or a layout at 1:1 on its paper //! (paper units are millimetres in metric drawings, inches otherwise). Lines are stroked paths //! (`m`/`l`/`S`), fills are `f` paths, colours are RGB with colour 7 printing black on white -//! paper, and line widths come from lineweights when enabled. +//! paper, and line widths come from lineweights when enabled. The page setup's plot style +//! table (or the `plotStyleTable` option) sets the printed colours, linetypes and lineweights. use std::fmt::Write as _; @@ -24,6 +25,8 @@ const MAX_SHEET_MM: f64 = 5080.0; /// model space). #[derive(Clone, Debug, Default)] pub struct PdfOptions { + /// Plot style table name, replacing the page setup's (`"None"` plots without one). + pub plot_style_table: Option, /// Paper name from [`cadcraft_render::PAPER_SIZES`] (`"A4"`, `"Letter"`, `"ANSI B"`…). pub paper: Option, /// Custom paper size in millimetres (portrait width, height). @@ -52,6 +55,7 @@ impl PdfOptions { _ => None, }; PdfOptions { + plot_style_table: v.get("plotStyleTable").and_then(Value::as_str).map(|s| s.chars().take(260).collect()), paper: v.get("paper").and_then(Value::as_str).map(str::to_string), paper_mm, landscape: v.get("landscape").and_then(Value::as_bool), @@ -100,6 +104,9 @@ pub fn page_for(d: &Drawing, space: &Space, o: &PdfOptions) -> Result if let Some(l) = o.landscape { page.landscape = l; } + if let Some(t) = &o.plot_style_table { + page.plot_style_table = t.clone(); + } page.width_mm = page.width_mm.clamp(1.0, MAX_SHEET_MM); page.height_mm = page.height_mm.clamp(1.0, MAX_SHEET_MM); Ok(page) @@ -130,7 +137,8 @@ pub fn pdf(d: &Drawing, space: &Space, o: &PdfOptions) -> Result> { Space::Paper(_) => page.lineweights, Space::Model => true, }); - let ropts = cadcraft_render::Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights, view_height: 0.0 }; + let plot_style_table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(std::sync::Arc::new); + let ropts = cadcraft_render::Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights, view_height: 0.0, plot_style_table }; let k = unit_mm * PT_PER_MM; let window = o.window.filter(|w| matches!(space, Space::Model) && !w.is_empty()); let fit = window.is_some() || o.fit.unwrap_or(matches!(space, Space::Model)); diff --git a/crates/io/tests/plot_styles.rs b/crates/io/tests/plot_styles.rs new file mode 100644 index 0000000..6e7721b --- /dev/null +++ b/crates/io/tests/plot_styles.rs @@ -0,0 +1,62 @@ +//! Plot style tables in plots and exports (issue #410): the PDF content stream carries the +//! table's colours and pen widths, layout SVG exports follow "Display plot styles", and the +//! page setup's table and flag survive a DXF round trip. + +use cadcraft_color::Color; +use cadcraft_doc::{Common, Drawing, EntityKind, Line, Space}; +use cadcraft_geom::Vec3; +use cadcraft_io::{read_dxf, svg, write_dxf}; +use serde_json::json; + +fn layout_with_red_line() -> Drawing { + let mut d = Drawing::new_metric(); + let line = EntityKind::Line(Line { a: Vec3::new(10.0, 10.0, 0.0), b: Vec3::new(100.0, 10.0, 0.0) }); + d.add(&Space::Paper("Layout1".into()), Common { color: Color::Index(1), ..Default::default() }, line).unwrap(); + d +} + +/// The uncompressed PDF of Layout1 with extra plot options. +fn pdf_text(d: &Drawing, extra: serde_json::Value) -> String { + let mut opts = json!({"compress": false}); + if let (Some(o), Some(e)) = (opts.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + let bytes = cadcraft_io::plot(d, &Space::Paper("Layout1".into()), &opts).unwrap(); + String::from_utf8_lossy(&bytes).into_owned() +} + +#[test] +fn pdf_uses_the_page_setup_table() { + let mut d = layout_with_red_line(); + assert!(pdf_text(&d, json!({})).contains("1 0 0 RG"), "no table: red"); + d.layouts[0].page.plot_style_table = "monochrome.ctb".into(); + let mono = pdf_text(&d, json!({})); + assert!(mono.contains("0 0 0 RG") && !mono.contains("1 0 0 RG"), "{mono}"); + // The plot's own option replaces the page setup's; "None" plots object colours. + assert!(pdf_text(&d, json!({"plotStyleTable": "None"})).contains("1 0 0 RG")); + let gray = pdf_text(&d, json!({"plotStyleTable": "grayscale.ctb"})); + assert!(!gray.contains("1 0 0 RG") && !gray.contains("0 0 0 RG"), "red prints mid grey: {gray}"); +} + +#[test] +fn layout_svg_follows_display_plot_styles() { + let mut d = layout_with_red_line(); + d.layouts[0].page.plot_style_table = "monochrome.ctb".into(); + let space = Space::Paper("Layout1".into()); + assert!(svg::export(&d, &space).contains("#ff0000")); + d.layouts[0].page.show_plot_styles = true; + let s = svg::export(&d, &space); + assert!(!s.contains("#ff0000") && s.contains("#000000"), "{s}"); +} + +#[test] +fn page_setup_table_and_display_flag_round_trip() { + let mut d = layout_with_red_line(); + d.layouts[0].page.plot_style_table = "grayscale.ctb".into(); + d.layouts[0].page.show_plot_styles = true; + let back = read_dxf(write_dxf(&d).as_bytes()).unwrap(); + let page = &back.layout("Layout1").unwrap().page; + assert_eq!(page.plot_style_table, "grayscale.ctb"); + assert!(page.show_plot_styles); + assert!(!read_dxf(write_dxf(&layout_with_red_line()).as_bytes()).unwrap().layout("Layout1").unwrap().page.show_plot_styles); +} diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index 08917cc..7ffd92e 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -120,7 +120,9 @@ fn gradient(b: &mut Builder, ctx: &Ctx, g: &Gradient, layer: Color, tris: &[Vec2 /// `a` blended towards `b` by `t`; colour 7 only when both ends are. fn mix(a: Ink, b: Ink, t: f64) -> Ink { let l = |x: u8, y: u8| (f64::from(x) + (f64::from(y) - f64::from(x)) * t).round().clamp(0.0, 255.0) as u8; - Ink { rgb: Rgb(l(a.rgb.0, b.rgb.0), l(a.rgb.1, b.rgb.1), l(a.rgb.2, b.rgb.2)), aci7: a.aci7 && b.aci7 } + // The plot style follows the nearer end's colour. + let aci = if t < 0.5 { a.aci } else { b.aci }; + Ink { rgb: Rgb(l(a.rgb.0, b.rgb.0), l(a.rgb.1, b.rgb.1), l(a.rgb.2, b.rgb.2)), aci7: a.aci7 && b.aci7, aci } } /// Gradient shapes by name (the `INV` forms swap the two colours). diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 84e2bd7..30c8cc5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -14,6 +14,7 @@ mod hatch; mod linetype; mod linetype_text; pub mod paper; +mod plotstyle; mod point; pub mod raster; pub mod units; @@ -82,11 +83,13 @@ impl DPrim { } } -/// A resolved colour: its RGB and whether it is colour 7. +/// A resolved colour: its RGB, whether it is colour 7, and its index colour (0 = a true +/// colour), which picks the style of a colour-dependent plot style table. #[derive(Clone, Copy, Debug, PartialEq)] struct Ink { rgb: Rgb, aci7: bool, + aci: u8, } /// Resolve `c` like [`Color::resolve`], noting whether it comes out as colour 7. @@ -98,7 +101,17 @@ fn ink(c: Color, layer: Color, block: Color) -> Ink { Color::ByBlock => is7(block), c => c == Color::Index(7), }; - Ink { rgb: c.resolve(layer, block), aci7 } + let aci = match c { + Color::ByLayer => layer, + Color::ByBlock => block, + c => c, + }; + let aci = match aci { + Color::Index(i) => i, + Color::True(_) => 0, + Color::ByLayer | Color::ByBlock => 7, + }; + Ink { rgb: c.resolve(layer, block), aci7, aci } } impl DisplayList { @@ -129,11 +142,14 @@ pub struct Options { /// Height of the visible area in world units of the space being built. Relative point /// sizes (`PDSIZE` <= 0) are a percentage of it; 0 = the height of the space's extents. pub view_height: f64, + /// Plot style table to draw with. When `None`, [`build`] of a layout whose page setup has + /// "Display plot styles" on uses the layout's table; [`build_plot`] uses none. + pub plot_style_table: Option>, } impl Default for Options { fn default() -> Self { - Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false, view_height: 0.0 } + Options { tolerance: 0.001, min_dash: 0.0, text: true, fill: true, lineweights: false, view_height: 0.0, plot_style_table: None } } } @@ -172,6 +188,8 @@ struct Builder<'a> { opts: &'a Options, /// Plotting: skip layers marked "do not plot". plotting: bool, + /// The plot style table drawn with. + styles: Option<&'a plotstyle::Styler>, } /// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy @@ -181,10 +199,18 @@ struct Builder<'a> { pub const MAX_BLOCK_EXPANSION: usize = 2_000_000; 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), + None => (color, lw), + } + } fn polyline(&mut self, ctx: &Ctx, color: Ink, lw: f32, pts: &[Vec2]) { if pts.len() < 2 { return; } + let (color, lw) = self.styled(color, lw); let start = self.list.verts.len() as u32; for p in pts { let q = ctx.xf.apply(*p); @@ -197,6 +223,7 @@ impl Builder<'_> { if tris.len() < 3 { return; } + let (color, _) = self.styled(color, 0.0); let start = self.list.tris.len() as u32; for p in tris { let q = ctx.xf.apply(*p); @@ -226,6 +253,7 @@ impl Builder<'_> { } } fn point(&mut self, ctx: &Ctx, color: Ink, p: Vec2) { + let (color, _) = self.styled(color, 0.0); let q = ctx.xf.apply(p); self.list.bounds.add(q); let start = self.list.verts.len() as u32; @@ -233,6 +261,7 @@ impl Builder<'_> { self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw: 0.0, kind: Kind::Point, start, len: 1 }); } fn infinite(&mut self, ctx: &Ctx, color: Ink, base: Vec2, dir: Vec2, ray: bool) { + let (color, _) = self.styled(color, 0.0); let start = self.list.verts.len() as u32; self.list.verts.push(ctx.xf.apply(base)); self.list.verts.push(ctx.xf.apply_vec(dir).normalized()); @@ -274,7 +303,16 @@ fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_c fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> DisplayList { let opts = &with_point_view(d, space, opts); - let mut b = Builder { list: DisplayList::default(), opts, plotting }; + let table = opts.plot_style_table.clone().or_else(|| match space { + Space::Paper(n) if !plotting => d + .layout(n) + .filter(|l| l.page.show_plot_styles) + .and_then(|l| cadcraft_doc::plot_style_table(d, &l.page.plot_style_table)) + .map(std::sync::Arc::new), + _ => None, + }); + let styler = table.map(|t| plotstyle::Styler::new(d, t)); + let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref() }; if let Space::Paper(name) = space { b.list.sheet = paper::sheet(d, name); } @@ -340,7 +378,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 }; + let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles }; // 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 }; @@ -425,7 +463,7 @@ pub fn build_entities<'a, I: IntoIterator>(d: &Drawing, ents: } else { opts }; - let mut b = Builder { list: DisplayList::default(), opts, plotting: false }; + let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None }; for e in ents { entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); } @@ -479,6 +517,11 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { if !visible { return; } + // A plot style's linetype replaces the object's. + let lt = match b.styles.and_then(|s| s.linetype(rgb)) { + Some(over) => over, + None => lt, + }; let tol = b.opts.tolerance / ctx.xf.scale_factor().max(1e-12); let lw = if b.opts.lineweights { lw } else { 0.0 }; // Stroke a polyline with the entity's linetype. @@ -736,7 +779,7 @@ fn mtext_color(c: Option, rgb: Ink) -> Ink { u8::try_from(i).ok().filter(|i| *i > 0).map(|i| ink(Color::Index(i), Color::Index(7), Color::Index(7))).unwrap_or(rgb) } Some(cadcraft_fonts::MTextColor::Rgb(v)) => { - Ink { rgb: Rgb(((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8), aci7: false } + Ink { rgb: Rgb(((v >> 16) & 0xFF) as u8, ((v >> 8) & 0xFF) as u8, (v & 0xFF) as u8), aci7: false, aci: 0 } } None => rgb, } diff --git a/crates/render/src/plotstyle.rs b/crates/render/src/plotstyle.rs new file mode 100644 index 0000000..b51a19b --- /dev/null +++ b/crates/render/src/plotstyle.rs @@ -0,0 +1,78 @@ +//! Plot style resolution: a plot style table applied to the display list while it is built. +//! +//! 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 +//! 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 crate::Ink; + +/// A table ready for the builder: styles plus their resolved linetype overrides. +pub(crate) struct Styler { + table: std::sync::Arc, + /// Per style: `None` keeps the object's linetype; `Some(None)` draws continuous; + /// `Some(Some(lt))` uses `lt`. + linetypes: Vec>>, +} + +impl Styler { + pub(crate) fn new(d: &Drawing, table: std::sync::Arc) -> Styler { + let mut library: Option> = None; + let linetypes = table + .styles + .iter() + .map(|s| { + let name = s.linetype.as_deref()?; + if name.eq_ignore_ascii_case("continuous") || name.eq_ignore_ascii_case("solid") { + return Some(None); + } + let found = d.linetype(name).cloned().or_else(|| { + let lib = library.get_or_insert_with(|| cadcraft_doc::library::standard_linetypes_for(d)); + lib.iter().find(|l| l.name.eq_ignore_ascii_case(name)).cloned() + })?; + Some((!found.pattern.is_empty()).then_some(found)) + }) + .collect(); + Styler { table, linetypes } + } + + /// Index of the style that applies to `ink`. + fn index(&self, ink: Ink) -> Option { + if !self.table.is_color_dependent() { + return None; + } + 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)?) + } + + /// `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) }; + let lw = match s.lineweight { + Some(w) if lineweights => f32::from(w.min(211)) / 100.0, + _ => lw, + }; + if s.keeps_color() { + return (ink, lw); + } + // Colour 7 is black on the paper the style prints on. + let base = if ink.aci7 { Rgb(0, 0, 0) } else { ink.rgb }; + (Ink { rgb: s.apply_color(base), aci7: false, ..ink }, lw) + } + + /// 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() + } +} diff --git a/crates/render/tests/plot_styles.rs b/crates/render/tests/plot_styles.rs new file mode 100644 index 0000000..264bf92 --- /dev/null +++ b/crates/render/tests/plot_styles.rs @@ -0,0 +1,117 @@ +//! Plot style tables applied to the display list (issue #410): colour-dependent tables map an +//! object's colour to its printed colour, screening, linetype and lineweight; layouts show them +//! when "Display plot styles" is on. + +use std::sync::Arc; + +use cadcraft_color::{Color, Rgb}; +use cadcraft_doc::{Common, Drawing, EntityKind, Line, Lineweight, PlotStyle, PlotStyleKind, PlotStyleTable, Space}; +use cadcraft_geom::Vec3; +use cadcraft_render::{DisplayList, Options, build, build_plot}; + +const BLACK: Rgb = Rgb(0, 0, 0); + +fn line(y: f64) -> EntityKind { + EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(10.0, y, 0.0) }) +} + +/// Lines in red (1), green (3), colour 7 (ByLayer on layer 0) and a true orange, in `space`. +fn drawing(space: &Space) -> Drawing { + let mut d = Drawing::new_metric(); + let c = |color| Common { color, ..Default::default() }; + d.add(space, c(Color::Index(1)), line(0.0)).unwrap(); + d.add(space, c(Color::Index(3)), line(1.0)).unwrap(); + d.add(space, Common::default(), line(2.0)).unwrap(); + d.add(space, c(Color::True(Rgb(255, 128, 0))), line(3.0)).unwrap(); + d +} + +fn with_table(t: PlotStyleTable) -> Options { + Options { plot_style_table: Some(Arc::new(t)), lineweights: true, ..Default::default() } +} + +fn colours(l: &DisplayList) -> Vec { + l.prims.iter().map(|p| p.display_rgb(Rgb(255, 255, 255))).collect() +} + +#[test] +fn monochrome_prints_every_colour_black() { + let d = drawing(&Space::Model); + let l = build_plot(&d, &Space::Model, &with_table(PlotStyleTable::builtin("monochrome.ctb").unwrap())); + assert_eq!(l.prims.len(), 4); + assert!(l.prims.iter().all(|p| p.color == BLACK && !p.aci7), "{:?}", l.prims); + // Without a table the colours are the objects'. + let plain = build_plot(&d, &Space::Model, &Options::default()); + assert_eq!(colours(&plain), [Rgb(255, 0, 0), Rgb(0, 255, 0), BLACK, Rgb(255, 128, 0)]); +} + +#[test] +fn grayscale_and_default_tables() { + let d = drawing(&Space::Model); + let l = build_plot(&d, &Space::Model, &with_table(PlotStyleTable::builtin("grayscale.ctb").unwrap())); + for c in colours(&l) { + assert!(c.0 == c.1 && c.1 == c.2, "grey: {c:?}"); + } + let c = colours(&l); + assert!(c[1].0 > c[0].0, "green is lighter than red"); + assert_eq!(c[2], BLACK, "colour 7 prints black"); + let keep = build_plot(&d, &Space::Model, &with_table(PlotStyleTable::builtin("default.ctb").unwrap())); + assert_eq!(colours(&keep), colours(&build_plot(&d, &Space::Model, &Options::default()))); +} + +#[test] +fn screening_lineweight_and_linetype_overrides() { + let mut d = Drawing::new_metric(); + d.header.set_f64("LTSCALE", 0.05); + d.add(&Space::Model, Common { color: Color::Index(1), lineweight: Lineweight::Mm100(13), ..Default::default() }, line(0.0)).unwrap(); + d.add(&Space::Model, Common { color: Color::Index(5), ..Default::default() }, line(1.0)).unwrap(); + let table = PlotStyleTable::color_dependent("pens.ctb", "", |i| { + (i == 1).then(|| PlotStyle { + color: Some(BLACK), + screening: 50, + lineweight: Some(70), + linetype: Some("DASHED".into()), + ..PlotStyle::default() + }) + }); + let l = build_plot(&d, &Space::Model, &with_table(table.clone())); + let red: Vec<_> = l.prims.iter().filter(|p| p.color == Rgb(128, 128, 128)).collect(); + assert!(red.len() > 1, "dashed into several pieces: {}", red.len()); + assert!(red.iter().all(|p| p.lw == 0.7), "pen lineweight replaces 0.13 mm"); + let blue: Vec<_> = l.prims.iter().filter(|p| p.color == Rgb(0, 0, 255)).collect(); + assert_eq!(blue.len(), 1, "colour 5 keeps its own style"); + assert_eq!(blue[0].lw, 0.25, "object lineweight (LWDEFAULT)"); + // With lineweights off nothing gets a width. + let thin = build_plot(&d, &Space::Model, &Options { lineweights: false, ..with_table(table) }); + assert!(thin.prims.iter().all(|p| p.lw == 0.0)); +} + +#[test] +fn layout_display_plot_styles_flag() { + let space = Space::Paper("Layout1".into()); + let mut d = drawing(&space); + d.layouts[0].page.plot_style_table = "monochrome.ctb".into(); + let red = |l: &DisplayList| l.prims.iter().any(|p| p.color == Rgb(255, 0, 0)); + assert!(red(&build(&d, &space, &Options::default())), "flag off: object colours on screen"); + d.layouts[0].page.show_plot_styles = true; + assert!(!red(&build(&d, &space, &Options::default())), "flag on: monochrome on screen"); + // Plotting takes its table from the options (the plotter passes the page's). + assert!(red(&build_plot(&d, &space, &Options::default()))); + // Model space and unknown tables draw object colours. + d.layouts[0].page.plot_style_table = "unknown.ctb".into(); + assert!(red(&build(&d, &space, &Options::default()))); +} + +#[test] +fn hostile_tables_do_not_panic() { + let d = drawing(&Space::Model); + for styles in [Vec::new(), vec![PlotStyle { screening: 255, lineweight: Some(u16::MAX), linetype: Some("NOPE".into()), ..PlotStyle::default() }]] + { + let t = PlotStyleTable { name: "x.ctb".into(), styles, ..PlotStyleTable::default() }; + let l = build_plot(&d, &Space::Model, &with_table(t)); + assert_eq!(l.prims.len(), 4); + assert!(l.prims.iter().all(|p| p.lw <= 2.11)); + } + 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); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 492c476..f9f0502 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -191,6 +191,7 @@ fn ensure_list(app: &mut CadApp, px: f64) { fill: true, lineweights: app.session.settings.lwdisplay, view_height: st.paper_view().height, + plot_style_table: None, }; let opts = cadcraft_render::Options { tolerance: 2f64.powi(band) * 0.5, ..opts }; let space = st.space.clone(); diff --git a/docs/gaps.md b/docs/gaps.md index c5e65e1..8e6e133 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]** Plot styles (CTB/STB) not applied; no plot preview, plotter devices, PUBLISH/batch plot; Print opens no plot dialog (#160, PR #200) | `palettes.rs:666` | High for sheet output | 25–35 | +| 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 | | 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 |