diff --git a/Cargo.lock b/Cargo.lock index 744d288..d4350e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -674,6 +674,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "tiny-skia", ] [[package]] diff --git a/crates/doc/src/dynblock.rs b/crates/doc/src/dynblock.rs index 248caa3..6b501a8 100644 --- a/crates/doc/src/dynblock.rs +++ b/crates/doc/src/dynblock.rs @@ -549,9 +549,7 @@ mod tests { let n = 20_000; let mut tags = vec![tag(93, "0"), tag(92, &n.to_string())]; tags.extend((0..n).map(|_| tag(94, &n.to_string()))); - let ParameterKind::Visibility { states, .. } = visibility(Vec3::default(), &tags) else { - panic!("a visibility parameter") - }; + let ParameterKind::Visibility { states, .. } = visibility(Vec3::default(), &tags) else { panic!("a visibility parameter") }; assert!(states.len() <= n && states.iter().all(|s| s.visible.is_empty())); let mut tags = vec![tag(73, &n.to_string())]; diff --git a/crates/doc/src/tables.rs b/crates/doc/src/tables.rs index 17026b1..f9824ec 100644 --- a/crates/doc/src/tables.rs +++ b/crates/doc/src/tables.rs @@ -747,6 +747,9 @@ pub struct PageSetup { /// 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, + /// Plot objects with their transparency ("Plot transparency"; PLOTTRANSPARENCYOVERRIDE can + /// override it). The DXF plot layout flags have no bit for it, so it is not saved in DXF. + pub plot_transparency: bool, } impl Default for PageSetup { @@ -765,6 +768,7 @@ impl Default for PageSetup { center: false, lineweights: true, show_plot_styles: false, + plot_transparency: false, } } } diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs index c6b60d6..6429af1 100644 --- a/crates/engine/src/cmd/exchange.rs +++ b/crates/engine/src/cmd/exchange.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { CommandSpec::new("preview", "Plot Preview", run_preview) .menu(&["File", "Plot Preview"]) .alias(&["pre"]) - .params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}") + .params("{layout?: current|\"Model\", paper?, landscape?, fit?, scale?, lineweights?, plotTransparency?, width?: pixels (default 1200)} → {data: base64 PNG, width, height, layout, paper}") .noundo(), CommandSpec::new("publish", "Batch Publish...", run_publish) .menu(&["File", "Batch Publish..."]) @@ -318,6 +318,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result { } let sheet = Sheet::from_page(&page, paper::paper_unit_mm(d)); let lineweights = p.get("lineweights").and_then(Value::as_bool).unwrap_or(if space.is_paper() { page.lineweights } else { true }); + let transparency = super::plotters::plot_transparency(s, p).unwrap_or(page.plot_transparency); let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(matches!(space, Space::Model)); let scale = p.get("scale").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); let width = p.get("width").and_then(Value::as_u64).unwrap_or(PREVIEW_PX).clamp(16, MAX_PREVIEW_PX); @@ -333,6 +334,7 @@ fn run_preview(s: &mut Session, p: &Value) -> Result { text: true, fill: true, lineweights, + transparency, ..Default::default() }; let list = cadcraft_render::build_plot(d, &space, &ropts); diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 2e4c67c..eecb2aa 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -74,17 +74,17 @@ pub fn specs() -> Vec { CommandSpec::new("pagesetup", "Page Setup Manager...", run_pagesetup) .menu(&["File", "Page Setup Manager..."]) .params( - "{layout?: current, device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's 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}", + "{layout?: current, device?: plotter name (PLOTTERMANAGER) | \"None\", paper?: the device's 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, plotTransparency?: bool}", ), CommandSpec::new("plot", "Print...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "plot")?, "plot")) .menu(&["File", "Print..."]) .key("Cmd+P") .alias(&["print"]) - .params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)") + .params("{path? (else returns base64 `data`), layout?: current|\"Model\", paper?, landscape?, fit?: bool, scale?: number | \"1:50\", center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?: bool, plotTransparency?: bool (default: the page setup's; PLOTTRANSPARENCYOVERRIDE 0/2 overrides it), plotArea?: extents|display|limits|window (Model) | layout, window?: [[x1,y1],[x2,y2]]} (typed or from the menu: the Plot dialog)") .noundo(), CommandSpec::new("exportpdf", "Export to PDF...", |s, p| run_plot(s, &super::plotters::plot_options(s, p, "exportpdf")?, "exportpdf")) .menu(&["File", "Export to PDF..."]) - .params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotArea?, window?} (as PLOT)") + .params("{path? (else returns base64 `data`), layout?, paper?, landscape?, fit?, scale?, center?: bool (Model, default true), offset?: [x,y] mm (Model, when center is false; each within ±100000), lineweights?, plotTransparency?, plotArea?, window?} (as PLOT)") .noundo(), CommandSpec::new("mspace", "Model Space (in viewport)", run_mspace) .alias(&["ms"]) @@ -634,6 +634,7 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result { "center", "plotStyleTable", "displayPlotStyles", + "plotTransparency", ]; let changes = keys.iter().any(|k| p.get(k).is_some()); let name = match str_param(p, "layout") { @@ -715,6 +716,9 @@ fn run_pagesetup(s: &mut Session, p: &Value) -> Result { if let Some(v) = p.get("displayPlotStyles").and_then(Value::as_bool) { page.show_plot_styles = v; } + if let Some(v) = p.get("plotTransparency").and_then(Value::as_bool) { + page.plot_transparency = v; + } if changes { if let Some(l) = s.doc_mut()?.layouts.iter_mut().find(|l| l.name == name) { l.page = page.clone(); @@ -803,6 +807,11 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result { if let Some(w) = plot_window(s, p, cmd, &space)? { o.insert("window".into(), json!([[w.min.x, w.min.y], [w.max.x, w.max.y]])); } + // Without one, the plotter uses each sheet's page setup. + match super::plotters::plot_transparency(s, p) { + Some(t) => o.insert("plotTransparency".into(), json!(t)), + None => o.remove("plotTransparency"), + }; } let bytes = hook(s.doc()?, &space, &opts).map_err(|e| bad(cmd, e))?; let layout = match &sheets { diff --git a/crates/engine/src/cmd/plotters.rs b/crates/engine/src/cmd/plotters.rs index 7cc9521..454b977 100644 --- a/crates/engine/src/cmd/plotters.rs +++ b/crates/engine/src/cmd/plotters.rs @@ -326,6 +326,16 @@ pub(crate) fn plot_options(s: &Session, p: &Value, cmd: &str) -> Result { Ok(out) } +/// Whether a plot draws transparency, as PLOTTRANSPARENCYOVERRIDE decides: 0 never, 2 always, +/// otherwise the plot's `plotTransparency` (`None` when it has none: the page setup decides). +pub(crate) fn plot_transparency(s: &Session, p: &Value) -> Option { + match s.settings.plot_transparency_override { + 0 => Some(false), + 2 => Some(true), + _ => p.get("plotTransparency").and_then(Value::as_bool), + } +} + // ---------- PLOTWINDOW ---------- fn run_plotwindow(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 75b7d5a..97f4b96 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -373,7 +373,11 @@ pub struct Settings { /// prompt's options there (on by default). pub dynprompt: bool, pub lwdisplay: bool, + /// TRANSPARENCYDISPLAY: draw objects with their transparency on screen (on by default). pub transparency_display: bool, + /// PLOTTRANSPARENCYOVERRIDE: 0 never plots transparency, 1 (default) uses the page setup's or + /// the Plot dialog's "Plot transparency", 2 always plots it. + pub plot_transparency_override: u8, pub selection_cycling: bool, /// SELECTSIMILARMODE: what SELECTSIMILAR matches besides the object type (bits: 1 colour, /// 2 layer, 4 linetype, 8 linetype scale, 16 lineweight, 32 plot style, 64 object style, @@ -463,7 +467,8 @@ impl Default for Settings { dynpi_absolute: false, dynprompt: true, lwdisplay: false, - transparency_display: false, + transparency_display: true, + plot_transparency_override: 1, selection_cycling: false, selectsimilarmode: 130, otrack: true, diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 16f9182..31451b6 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -34,6 +34,7 @@ const SESSION_VARS: &[&str] = &[ "DYNPICOORDS", "DYNPROMPT", "LWDISPLAY", + "PLOTTRANSPARENCYOVERRIDE", "SELECTSIMILARMODE", "OBJECTISOLATIONMODE", "QPMODE", @@ -100,6 +101,7 @@ pub fn get(s: &Session, name: &str) -> Option { "DYNPICOORDS" => json!(i32::from(st.dynpi_absolute)), "DYNPROMPT" => json!(i32::from(st.dynprompt)), "LWDISPLAY" => json!(i32::from(st.lwdisplay)), + "PLOTTRANSPARENCYOVERRIDE" => json!(st.plot_transparency_override), "SELECTSIMILARMODE" => json!(st.selectsimilarmode), // Isolation is temporary: hidden objects show again when the drawing is reopened. "OBJECTISOLATIONMODE" => json!(0), @@ -261,6 +263,9 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "DYNPICOORDS" => st.dynpi_absolute = as_bool(v).ok_or_else(bad)?, "DYNPROMPT" => st.dynprompt = as_bool(v).ok_or_else(bad)?, "LWDISPLAY" => st.lwdisplay = as_bool(v).ok_or_else(bad)?, + "PLOTTRANSPARENCYOVERRIDE" => { + st.plot_transparency_override = as_i(v).filter(|i| (0..=2).contains(i)).and_then(|i| u8::try_from(i).ok()).ok_or_else(bad)? + } "SELECTSIMILARMODE" => st.selectsimilarmode = as_i(v).filter(|b| (0..=255).contains(b)).ok_or_else(bad)? as u32, "OBJECTISOLATIONMODE" => match as_i(v) { Some(0) => {} diff --git a/crates/engine/tests/plot_transparency.rs b/crates/engine/tests/plot_transparency.rs new file mode 100644 index 0000000..a9f7735 --- /dev/null +++ b/crates/engine/tests/plot_transparency.rs @@ -0,0 +1,76 @@ +//! Plot transparency: the page setup's "Plot transparency" (PAGESETUP `plotTransparency`), the +//! plot's own option, and PLOTTRANSPARENCYOVERRIDE (0 never, 1 the setting, 2 always) deciding +//! what the plotter is told. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, base64_decode, set_io}; +use serde_json::{Value, json}; + +/// A plotter that echoes the `plotTransparency` it was given (`none` when it got none). +fn fake_plot(_: &cadcraft_doc::Drawing, _: &cadcraft_doc::Space, opts: &Value) -> Result, String> { + Ok(opts.get("plotTransparency").map_or("none".to_string(), Value::to_string).into_bytes()) +} + +fn session() -> Session { + set_io(IoHooks { read: |_, _| Err("no reader in this test".into()), write: |_, _| Err("no writer in this test".into()), plot: Some(fake_plot) }); + let mut s = Session::new(); + s.execute("line", &json!({"points": [[0, 0], [100, 0]]})).unwrap(); + s +} + +/// What the plotter was told for a PLOT with `params`. +fn told(s: &mut Session, params: Value) -> String { + let r = s.execute("plot", ¶ms).unwrap(); + String::from_utf8(base64_decode(r["data"].as_str().unwrap()).unwrap()).unwrap() +} + +#[test] +fn the_override_decides_what_the_plotter_is_told() { + let mut s = session(); + // 1 (default): the plot's option, else nothing (the plotter reads the page setup). + assert_eq!(s.settings.plot_transparency_override, 1); + assert_eq!(told(&mut s, json!({})), "none"); + assert_eq!(told(&mut s, json!({"plotTransparency": true})), "true"); + assert_eq!(told(&mut s, json!({"plotTransparency": false})), "false"); + assert_eq!(told(&mut s, json!({"plotTransparency": "yes"})), "none", "not a boolean: ignored"); + // 0: never. + s.execute("setvar", &json!({"name": "PLOTTRANSPARENCYOVERRIDE", "value": 0})).unwrap(); + assert_eq!(told(&mut s, json!({"plotTransparency": true})), "false"); + assert_eq!(told(&mut s, json!({})), "false"); + // 2: always. + s.execute("setvar", &json!({"name": "PLOTTRANSPARENCYOVERRIDE", "value": 2})).unwrap(); + assert_eq!(told(&mut s, json!({"plotTransparency": false})), "true"); + assert_eq!(s.execute("exportpdf", &json!({})).map(|r| r["bytes"].clone()).unwrap(), json!(4), "EXPORTPDF too: `true`"); + let r = s.execute("getvar", &json!({"name": "PLOTTRANSPARENCYOVERRIDE"})).unwrap(); + assert_eq!(r["value"], json!(2)); +} + +#[test] +fn page_setup_stores_plot_transparency() { + let mut s = session(); + let r = s.execute("pagesetup", &json!({"layout": "Layout1"})).unwrap(); + assert_eq!(r["page"]["plotTransparency"], json!(false), "off by default"); + let r = s.execute("pagesetup", &json!({"layout": "Layout1", "plotTransparency": true})).unwrap(); + assert_eq!(r["page"]["plotTransparency"], json!(true)); + assert!(s.doc().unwrap().layout("Layout1").unwrap().page.plot_transparency); + // One undo step, like the other page setup changes. + s.undo().unwrap(); + assert!(!s.doc().unwrap().layout("Layout1").unwrap().page.plot_transparency); +} + +#[test] +fn preview_follows_the_plot_setting() { + let mut s = session(); + // A red solid hatch at 50 % over the line. + s.execute("rectang", &json!({"p1": [10, -10], "p2": [90, 10]})).unwrap(); + s.execute("hatch", &json!({"points": [[50, 5]], "pattern": "SOLID"})).unwrap(); + let hatch = s.doc().unwrap().model.iter().find(|e| matches!(e.kind, cadcraft_doc::EntityKind::Hatch(_))).map(|e| e.handle).unwrap(); + s.execute("draworder", &json!({"handles": [hatch.hex()], "order": "front"})).unwrap(); + s.execute("properties.set", &json!({"handles": [hatch.hex()], "color": 1, "transparency": 50})).unwrap(); + let preview = |s: &mut Session, p: Value| s.execute("preview", &p).unwrap()["data"].as_str().unwrap().to_string(); + let off = preview(&mut s, json!({"layout": "Model", "width": 200})); + let on = preview(&mut s, json!({"layout": "Model", "width": 200, "plotTransparency": true})); + assert_ne!(off, on, "the preview shows transparency when the plot does"); + s.execute("setvar", &json!({"name": "PLOTTRANSPARENCYOVERRIDE", "value": 0})).unwrap(); + assert_eq!(preview(&mut s, json!({"layout": "Model", "width": 200, "plotTransparency": true})), off); +} diff --git a/crates/engine/tests/setvar_coverage.rs b/crates/engine/tests/setvar_coverage.rs index fd08f1d..639f6dd 100644 --- a/crates/engine/tests/setvar_coverage.rs +++ b/crates/engine/tests/setvar_coverage.rs @@ -31,7 +31,8 @@ fn defaults_are_the_ones_the_commands_use() { ("LWDEFAULT", json!(25)), ("DIMASSOC", json!(2)), ("CPLOTSTYLE", json!("ByLayer")), - ("TRANSPARENCYDISPLAY", json!(0)), + ("TRANSPARENCYDISPLAY", json!(1)), + ("PLOTTRANSPARENCYOVERRIDE", json!(1)), ] { assert_eq!(getvar(&mut s, name), v, "{name}"); } @@ -56,8 +57,10 @@ fn values_reach_the_commands() { // Typed at the command line. s.cmdline("setvar hpname net").unwrap(); assert_eq!(getvar(&mut s, "HPNAME"), json!("NET")); - s.cmdline("setvar transparencydisplay 1").unwrap(); - assert!(s.settings.transparency_display); + s.cmdline("setvar transparencydisplay 0").unwrap(); + assert!(!s.settings.transparency_display); + s.cmdline("setvar plottransparencyoverride 2").unwrap(); + assert_eq!(s.settings.plot_transparency_override, 2); assert!(setvar(&mut s, "INSNAME", json!("Valve"))); assert!(setvar(&mut s, "INSNAME", json!("."))); assert_eq!(getvar(&mut s, "INSNAME"), json!("")); @@ -77,6 +80,10 @@ fn bad_values_and_unknown_names_are_refused() { ("CIRCLERAD", json!(-1)), ("SPLINESEGS", json!(1e9)), ("TRANSPARENCYDISPLAY", json!(5)), + ("PLOTTRANSPARENCYOVERRIDE", json!(3)), + ("PLOTTRANSPARENCYOVERRIDE", json!(-1)), + ("PLOTTRANSPARENCYOVERRIDE", json!("always")), + ("PLOTTRANSPARENCYOVERRIDE", json!(1.5)), ("CHAMFERD", json!("abc")), ("CPLOTSTYLE", json!("Style 1")), ("OFFSETDIST", json!(f64::MAX.to_string() + "0")), diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index 9c04fe6..151e1ec 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -21,3 +21,6 @@ serde_json = { workspace = true } serde = { workspace = true } miniz_oxide = "0.8" quick-xml = { workspace = true } + +[dev-dependencies] +tiny-skia = { workspace = true } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a662a4e..ef56e66 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -163,9 +163,10 @@ pub fn png(d: &Drawing, space: &Space, width: u32, height: u32) -> Result) -> Result> { - let list = cadcraft_render::build(d, space, &cadcraft_render::Options::default()); + let list = cadcraft_render::build(d, space, &cadcraft_render::Options { transparency: true, ..cadcraft_render::Options::default() }); let view = png_view(&list.bounds, width, height, window); cadcraft_render::raster::render_png(&list, &view, &cadcraft_render::raster::RasterOptions::default()) .ok_or_else(|| IoError::Format("render failed".into())) diff --git a/crates/io/src/pdf.rs b/crates/io/src/pdf.rs index c43aa98..a9f59e5 100644 --- a/crates/io/src/pdf.rs +++ b/crates/io/src/pdf.rs @@ -80,6 +80,9 @@ pub struct PdfOptions { /// Entity hyperlinks read from a drawing file (`PE_URL` extended data) become link /// annotations too, after [`PdfOptions::links`]. Default on. pub hyperlinks: bool, + /// Plot objects with their transparency (PDF alpha). `None` = the page setup's "Plot + /// transparency" (off for model space). + pub plot_transparency: Option, } impl Default for PdfOptions { @@ -103,6 +106,7 @@ impl Default for PdfOptions { text_as_geometry: false, margins: None, hyperlinks: true, + plot_transparency: None, } } } @@ -110,7 +114,7 @@ impl Default for PdfOptions { impl PdfOptions { /// Parse `{paper?, width?, height?, landscape?, fit?, scale?, center?, offset?: [x,y] mm, /// lineweights?, compress?, title?, window?: [[x1, y1], [x2, y2]], margins?: [l, b, r, t] (mm), - /// hyperlinks?: bool}`. + /// hyperlinks?: bool, plotTransparency?: bool}`. pub fn from_json(v: &Value) -> PdfOptions { let num = |k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()); let paper_mm = match (num("width"), num("height")) { @@ -138,6 +142,7 @@ impl PdfOptions { window: window(v.get("window")), margins: margins(v.get("margins")), hyperlinks: v.get("hyperlinks").and_then(Value::as_bool).unwrap_or(true), + plot_transparency: v.get("plotTransparency").and_then(Value::as_bool), } } } @@ -394,6 +399,7 @@ fn sheet(d: &Drawing, space: &Space, o: &PdfOptions, links_to: &[(Handle, String let lineweights = o.lineweights.unwrap_or(if space.is_paper() { page.lineweights } else { true }); let plot_style_table = cadcraft_doc::plot_style_table(d, &page.plot_style_table).map(std::sync::Arc::new); let ropts = cadcraft_render::Options { + transparency: o.plot_transparency.unwrap_or(page.plot_transparency), tolerance: 0.001, min_dash: 0.0, text: true, @@ -584,13 +590,17 @@ fn color(out: &mut String, c: Rgb, op: &str) { /// /// Primitives in `texts` (filled glyph outlines) are written as text instead: the glyph in its /// embedded font (`/F`), placed by a text matrix. +/// +/// Transparent primitives are drawn with a graphics state of their alpha (`/T gs`); +/// the transparencies used are returned for the page's `/ExtGState` (see [`ext_gstates`]). A +/// list without transparency writes no graphics states. fn content_stream( list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, layers: &[u32], texts: &std::collections::HashMap, -) -> String { +) -> (String, Vec) { let marked: std::collections::HashSet = layers.iter().copied().collect(); let white = Rgb(255, 255, 255); let mut c = String::new(); @@ -616,6 +626,9 @@ fn content_stream( let mut layer: Option = None; // Inside a text object (`BT` … `ET`), with this font set. let mut text_font: Option> = None; + // The transparency (percent) marks are drawn with, and those used on the page. + let mut alpha: u8 = 0; + let mut alphas: Vec = Vec::new(); for (i, p) in list.prims.iter().enumerate() { let want = list.prim_layers.get(i).copied().filter(|l| marked.contains(l)); let text = u32::try_from(i).ok().and_then(|i| texts.get(&i)); @@ -638,6 +651,16 @@ fn content_stream( if raw.iter().any(|q| !q.is_finite()) { continue; } + // A wipeout's mask always hides what is under it. + let t = if p.kind == Kind::Mask { 0 } else { p.transparency.min(cadcraft_render::MAX_TRANSPARENCY) }; + if t != alpha { + flush(&mut c, &mut pending); + let _ = writeln!(c, "/T{t} gs"); + if !alphas.contains(&t) { + alphas.push(t); + } + alpha = t; + } let w = f64::from(p.lw) * PT_PER_MM; let w = if w.is_finite() && w > 0.0 { w.min(100.0) } else { 0.0 }; match p.kind { @@ -659,6 +682,13 @@ fn content_stream( if pts.len() < 2 { continue; } + // A transparent primitive is stroked on its own (one path is painted once), so + // where primitives overlap they build up, as in PNG plots and on screen. Block + // contents and viewport contents share their reference's handle, so this goes + // by primitive, not by object. + if t > 0 { + flush(&mut c, &mut pending); + } if stroke != Some(rgb) || width != Some(w) { flush(&mut c, &mut pending); if stroke != Some(rgb) { @@ -727,7 +757,23 @@ fn content_stream( c.push_str("EMC\n"); } c.push_str("Q\n"); - c + (c, alphas) +} + +/// The page resources' graphics states for transparencies `alphas` (percent): `/T` +/// sets the stroke and fill alpha (`/CA`, `/ca`). Empty when there are none. +fn ext_gstates(alphas: &[u8]) -> String { + if alphas.is_empty() { + return String::new(); + } + let mut out = String::from(" /ExtGState <<"); + for t in alphas { + let mut a = String::new(); + num(&mut a, f64::from(100 - u32::from((*t).min(cadcraft_render::MAX_TRANSPARENCY))) / 100.0); + let _ = write!(out, " /T{t} << /Type /ExtGState /CA {a} /ca {a} >>"); + } + out.push_str(" >>"); + out } /// Write one glyph as text: opens a text object and sets the font when needed, then places the @@ -849,11 +895,12 @@ fn assemble(d: &Drawing, pages: &[SheetPage], fonts: &fonts::Fonts, o: &PdfOptio let mut ocgs: Vec<(u32, usize)> = Vec::new(); let mut kids = Vec::new(); // Page dictionaries are completed once the fonts are written: (page, its fonts, the rest). - let mut page_dicts: Vec<(usize, Vec, String, String)> = Vec::new(); + let mut page_dicts: Vec<(usize, Vec, String, String, String)> = Vec::new(); for p in pages { let page = objs.reserve(); let texts = fonts.texts(&p.list); - let content = objs.add(stream(content_stream(&p.list, &p.map, &p.clip_pt, &p.layers, &texts).as_bytes(), o.compress, "")); + let (marks, alphas) = content_stream(&p.list, &p.map, &p.clip_pt, &p.layers, &texts); + let content = objs.add(stream(marks.as_bytes(), o.compress, "")); let mut used: Vec = texts.values().map(|(f, _)| *f).collect(); used.sort_unstable(); used.dedup(); @@ -883,15 +930,16 @@ fn assemble(d: &Drawing, pages: &[SheetPage], fonts: &fonts::Fonts, o: &PdfOptio let mut mb = String::new(); pt(&mut mb, p.media); let annots = if annots.is_empty() { String::new() } else { format!(" /Annots [{}]", refs(annots)) }; - page_dicts.push((page, used, props, format!("/MediaBox [0 0 {mb}] /Contents {content} 0 R{annots}"))); + page_dicts.push((page, used, props, ext_gstates(&alphas), format!("/MediaBox [0 0 {mb}] /Contents {content} 0 R{annots}"))); kids.push(page); } let font_ids = if fonts.is_empty() { Vec::new() } else { fonts.write(&mut objs, o.compress) }; - for (page, used, props, rest) in page_dicts { + for (page, used, props, gstates, rest) in page_dicts { let mut res = String::new(); if !props.is_empty() { let _ = write!(res, " /Properties <<{props} >>"); } + res.push_str(&gstates); let f: Vec = used.iter().filter_map(|i| Some(format!("/F{i} {} 0 R", font_ids.get(*i)?))).collect(); if !f.is_empty() { let _ = write!(res, " /Font << {} >>", f.join(" ")); diff --git a/crates/io/src/plotter.rs b/crates/io/src/plotter.rs index f5133e4..d916d76 100644 --- a/crates/io/src/plotter.rs +++ b/crates/io/src/plotter.rs @@ -84,7 +84,8 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { let est = if fit { fit_scale(&window.unwrap_or_else(|| d.extents(space)), &sheet, &o) } else { 1.0 }; // Chord tolerance: a fifth of a pixel. let tolerance = (0.2 / (k * est)).max(1e-9); - let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, ..cadcraft_render::Options::default() }; + let transparency = o.plot_transparency.unwrap_or(page.plot_transparency); + let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, transparency, ..cadcraft_render::Options::default() }; let mut list = cadcraft_render::build_plot(d, space, &ropts); let half = Vec2::new(f64::from(width), f64::from(height)) / (2.0 * k); let b = window.unwrap_or(list.bounds); @@ -108,8 +109,9 @@ fn png(d: &Drawing, space: &Space, opts: &Value) -> Result> { fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result> { let o = PdfOptions::from_json(opts); let Space::Model = space else { - let window = sheet(d, space, &o)?.0.bounds(); - return Ok(crate::svg::export_window(d, space, Some(window)).into_bytes()); + let (sheet, page) = sheet(d, space, &o)?; + let transparency = o.plot_transparency.unwrap_or(page.plot_transparency); + return Ok(crate::svg::export_window_with(d, space, Some(sheet.bounds()), transparency).into_bytes()); }; // Model space as plotted: the plot style table, lineweights, and the table's ends, joins and // fills at the scale that fits the window (or the extents) on the device's paper. @@ -121,7 +123,8 @@ fn svg(d: &Drawing, space: &Space, opts: &Value) -> Result> { // Chord tolerance: about 0.05 mm on paper. let tolerance = (0.05 / (sheet.unit_mm * est)).max(1e-9); let tolerance = if tolerance.is_finite() { tolerance } else { cadcraft_render::Options::default().tolerance }; - let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, ..cadcraft_render::Options::default() }; + let transparency = o.plot_transparency.unwrap_or(page.plot_transparency); + let ropts = cadcraft_render::Options { tolerance, lineweights, plot_style_table: table, transparency, ..cadcraft_render::Options::default() }; let mut list = cadcraft_render::build_plot(d, space, &ropts); let s = fit_scale(&window.unwrap_or(list.bounds), &sheet, &o); cadcraft_render::pen::apply(&mut list, s * sheet.unit_mm); diff --git a/crates/io/src/svg.rs b/crates/io/src/svg.rs index 7c97c51..14f7964 100644 --- a/crates/io/src/svg.rs +++ b/crates/io/src/svg.rs @@ -13,12 +13,26 @@ pub fn export(d: &Drawing, space: &Space) -> String { } /// Export showing exactly `window` (the `viewBox`; geometry outside it is clipped by the viewer), -/// or the extents with a 2% margin when `window` is `None`. +/// or the extents with a 2% margin when `window` is `None`. Objects are drawn with their +/// transparency, as on screen. pub fn export_window(d: &Drawing, space: &Space, window: Option) -> String { - let list = cadcraft_render::build(d, space, &cadcraft_render::Options::default()); + export_window_with(d, space, window, true) +} + +/// [`export_window`], drawing transparency only when `transparency` is on (plots). +pub fn export_window_with(d: &Drawing, space: &Space, window: Option, transparency: bool) -> String { + let list = cadcraft_render::build(d, space, &cadcraft_render::Options { transparency, ..cadcraft_render::Options::default() }); export_list(&list, window) } +/// ` opacity="…"` for a transparent primitive (none for an opaque one or a wipeout's mask). +fn opacity(p: &cadcraft_render::DPrim) -> String { + if p.kind == Kind::Mask || p.transparency == 0 { + return String::new(); + } + format!(r#" opacity="{:.2}""#, f64::from(p.alpha()) / 255.0) +} + /// Write a display list (as [`export_window`] does for a space's own). pub fn export_list(list: &cadcraft_render::DisplayList, window: Option) -> String { let window = window.filter(|w| !w.is_empty()); @@ -41,12 +55,13 @@ pub fn export_list(list: &cadcraft_render::DisplayList, window: Option) let ty = |y: f64| y1 - y; for p in &list.prims { let c = p.display_rgb(white).hex(); + let op = opacity(p); let pts = list.points(p); match p.kind { Kind::Polyline => { let _ = write!( s, - r#") Kind::Tris | Kind::Mask => { // A wipeout's mask hides what lies under it with the white background. let c = if p.kind == Kind::Mask { white.hex() } else { c }; - let _ = write!(s, r#") } Kind::Point => { if let Some(q) = pts.first() { - let _ = write!(s, r#""#, tx(q.x), ty(q.y), stroke); + let _ = write!(s, r#""#, tx(q.x), ty(q.y), stroke); } } Kind::Infinite { ray } => { @@ -77,7 +92,7 @@ pub fn export_list(list: &cadcraft_render::DisplayList, window: Option) let e = *bp + *dir * far; let _ = write!( s, - r#""#, + r#""#, tx(a.x), ty(a.y), tx(e.x), diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index 0c07e36..0fb8407 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -2192,6 +2192,168 @@ fn kept_xrecord_application_group_roundtrips_as_read() { assert!(!out.contains("291\n2"), "no handle may be written under a bool code"); } +// ---------- plot transparency ---------- + +/// A black line under a red solid at `percent` transparency (drawn after it, so on top). +fn transparent_over_line(percent: u8) -> Drawing { + let mut d = Drawing::new_metric(); + d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-10.0, 30.0, 0.0), b: Vec3::new(110.0, 30.0, 0.0) })).unwrap(); + let corners = [Vec3::new(0.0, 0.0, 0.0), Vec3::new(100.0, 0.0, 0.0), Vec3::new(0.0, 60.0, 0.0), Vec3::new(100.0, 60.0, 0.0)]; + let red = Common { color: Color::Index(1), transparency: Transparency::Percent(percent), ..Common::default() }; + d.add(&Space::Model, red, EntityKind::Solid(Solid { corners })).unwrap(); + d +} + +#[test] +fn pdf_plots_transparency_only_when_asked() { + let d = transparent_over_line(50); + let opts = |extra: serde_json::Value| { + let mut o = serde_json::json!({"paper": "A4", "compress": false}); + if let (Some(o), Some(e)) = (o.as_object_mut(), extra.as_object()) { + o.extend(e.clone()); + } + o + }; + let on = plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": true}))).unwrap(); + let content = check_pdf(&on); + let text = String::from_utf8_lossy(&on).to_string(); + assert!(text.contains("/ExtGState << /T50 << /Type /ExtGState /CA 0.5 /ca 0.5 >> >>"), "{text}"); + // The line is opaque, the solid at half alpha. + assert!(content.contains("/T50 gs\n1 0 0 rg"), "{content}"); + // Off (the default, and explicitly): no graphics states, and the same bytes either way. + let off = plot(&d, &Space::Model, &opts(serde_json::json!({}))).unwrap(); + assert_eq!(off, plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": false}))).unwrap()); + let off_text = String::from_utf8_lossy(&off).to_string(); + assert!(!off_text.contains("ExtGState") && !off_text.contains(" gs\n")); + // On, but nothing transparent: the same bytes as off. + let opaque = transparent_over_line(0); + assert_eq!( + plot(&opaque, &Space::Model, &opts(serde_json::json!({"plotTransparency": true}))).unwrap(), + plot(&opaque, &Space::Model, &opts(serde_json::json!({}))).unwrap() + ); + // A hostile flag is ignored (off). + assert_eq!(plot(&d, &Space::Model, &opts(serde_json::json!({"plotTransparency": "yes"}))).unwrap(), off); +} + +#[test] +fn layout_page_setup_plot_transparency_is_used() { + let mut d = transparent_over_line(90); + let paper = Space::Paper(d.layouts[0].name.clone()); + let corners = [Vec3::new(1.0, 1.0, 0.0), Vec3::new(5.0, 1.0, 0.0), Vec3::new(1.0, 4.0, 0.0), Vec3::new(5.0, 4.0, 0.0)]; + let faint = Common { color: Color::Index(5), transparency: Transparency::Percent(90), ..Common::default() }; + d.add(&paper, faint, EntityKind::Solid(Solid { corners })).unwrap(); + let raw = serde_json::json!({"compress": false}); + assert!(!String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).contains("ExtGState")); + d.layouts[0].page.plot_transparency = true; + let text = String::from_utf8_lossy(&plot(&d, &paper, &raw).unwrap()).to_string(); + assert!(text.contains("/T90 << /Type /ExtGState /CA 0.1 /ca 0.1 >>"), "{text}"); + // The plot's own option wins over the page setup. + let off = serde_json::json!({"compress": false, "plotTransparency": false}); + assert!(!String::from_utf8_lossy(&plot(&d, &paper, &off).unwrap()).contains("ExtGState")); +} + +#[test] +fn png_and_svg_plots_blend_transparency_when_asked() { + let d = transparent_over_line(50); + let png = |on: Option| { + let mut o = serde_json::json!({"output": "png", "resolution": 50, "width": 210, "height": 297, "landscape": true}); + if let (Some(on), Some(o)) = (on, o.as_object_mut()) { + o.insert("plotTransparency".into(), serde_json::json!(on)); + } + plotter::plot(&d, &Space::Model, &o).unwrap() + }; + let decode = |bytes: &[u8]| tiny_skia::Pixmap::decode_png(bytes).unwrap(); + // The centre of the sheet is inside the solid (the drawing is fitted to the paper). + let centre = |pm: &tiny_skia::Pixmap| { + let c = pm.pixel(pm.width() / 2, pm.height() / 2 + 20).unwrap(); + (c.red(), c.green(), c.blue()) + }; + assert_eq!(centre(&decode(&png(None))), (255, 0, 0), "off: opaque red"); + assert_eq!(png(None), png(Some(false))); + let (r, g, b) = centre(&decode(&png(Some(true)))); + assert!(r == 255 && g.abs_diff(127) <= 2 && b.abs_diff(127) <= 2, "half red on white paper: {:?}", (r, g, b)); + let svg = |on: bool| { + let o = serde_json::json!({"output": "svg", "plotTransparency": on}); + String::from_utf8(plotter::plot(&d, &Space::Model, &o).unwrap()).unwrap() + }; + assert!(svg(true).contains(r##"fill="#ff0000" opacity="0.50""##), "{}", svg(true)); + assert!(!svg(false).contains("opacity")); +} + +#[test] +fn view_exports_draw_transparency() { + let d = transparent_over_line(50); + let svg = String::from_utf8(write(&d, "a.svg").unwrap()).unwrap(); + assert!(svg.contains(r#"opacity="0.50""#)); + assert_ne!(write(&d, "a.png").unwrap(), write(&transparent_over_line(0), "a.png").unwrap()); +} + +#[test] +fn pdf_strokes_overlapping_transparent_objects_one_by_one() { + // Two crossing lines, ByLayer on layer `layer_t` percent transparent. + let crossing = |layer_t: u8| { + let mut d = Drawing::new_metric(); + d.layers.push(Layer { name: "Glass".into(), color: Color::Index(5), transparency: layer_t, ..Layer::default() }); + let on = || Common { layer: "Glass".into(), ..Common::default() }; + d.add(&Space::Model, on(), EntityKind::Line(Line { a: Vec3::new(0.0, 0.0, 0.0), b: Vec3::new(100.0, 100.0, 0.0) })).unwrap(); + d.add(&Space::Model, on(), EntityKind::Line(Line { a: Vec3::new(0.0, 100.0, 0.0), b: Vec3::new(100.0, 0.0, 0.0) })).unwrap(); + d + }; + let content = |d: &Drawing, on: bool| { + check_pdf(&plot(d, &Space::Model, &serde_json::json!({"paper": "A4", "compress": false, "plotTransparency": on})).unwrap()) + }; + // 50 %: each line is its own stroke, so they build up where they cross. + let glass = content(&crossing(50), true); + assert!(glass.contains("/T50 gs"), "{glass}"); + assert_eq!(glass.matches("S\n").count(), 2, "{glass}"); + // Opaque: one path as before, whether the option is on or off. + let opaque = content(&crossing(0), true); + assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}"); + assert_eq!(opaque, content(&crossing(0), false)); + // Transparency off: the transparent layer plots as before too. + assert_eq!(content(&crossing(50), false), opaque); +} + +#[test] +fn pdf_strokes_each_transparent_primitive_inside_a_block() { + // One block reference whose two children cross: they share the reference's handle. + let crossing_block = |t: u8| { + let mut d = Drawing::new_metric(); + let mut blk = Block::new("X"); + for (i, (a, b)) in [((0.0, 0.0), (100.0, 100.0)), ((0.0, 100.0), (100.0, 0.0))].into_iter().enumerate() { + let line = Line { a: Vec3::new(a.0, a.1, 0.0), b: Vec3::new(b.0, b.1, 0.0) }; + let mut e = Entity::new(Handle(i as u64 + 1), EntityKind::Line(line)); + e.common = Common { color: Color::Index(1), transparency: Transparency::ByBlock, ..Common::default() }; + blk.entities.push(e); + } + d.blocks.insert("X".into(), std::sync::Arc::new(blk)); + let ins = Insert { + block: "X".into(), + insert: Vec3::ZERO, + 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 { transparency: Transparency::Percent(t), ..Common::default() }, EntityKind::Insert(ins)).unwrap(); + d + }; + let content = |d: &Drawing, on: bool| { + check_pdf(&plot(d, &Space::Model, &serde_json::json!({"paper": "A4", "compress": false, "plotTransparency": on})).unwrap()) + }; + let half = content(&crossing_block(50), true); + assert!(half.contains("/T50 gs"), "{half}"); + assert_eq!(half.matches("S\n").count(), 2, "{half}"); + // Opaque: one path, the same with the option on or off. + let opaque = content(&crossing_block(0), true); + assert_eq!(opaque.matches("S\n").count(), 1, "{opaque}"); + assert_eq!(opaque, content(&crossing_block(0), false)); + assert_eq!(content(&crossing_block(50), false), opaque); +} + #[test] fn foreign_table_reads_cell_text_height_and_keeps_style_scale() { // A table from another writer: no CADCraft xdata; cell 1 sets 140 before its value block diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 5717561..cbde5c7 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -306,7 +306,7 @@ impl Backend for Headless { let h = p.get("height").and_then(Value::as_f64).unwrap_or(1000.0).clamp(16.0, 8192.0) as u32; let st = s.state().map_err(|e| e.to_string())?; let window = render_window(&p); - let opts = cadcraft_render::Options { view: window, ..Default::default() }; + let opts = cadcraft_render::Options { view: window, transparency: s.settings.transparency_display, ..Default::default() }; let list = cadcraft_render::build(&st.doc, &st.space, &opts); let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(true); let side = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x as u32); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 2cdb8fa..7241362 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -69,6 +69,9 @@ pub struct DPrim { pub aci7: bool, /// Lineweight in mm (0 = thinnest). pub lw: f32, + /// Transparency in percent (0 = opaque, at most 90): the object's own, its layer's when + /// ByLayer, the block reference's when ByBlock. Always 0 unless [`Options::transparency`]. + pub transparency: u8, pub kind: Kind, pub start: u32, pub len: u32, @@ -124,8 +127,21 @@ impl DPrim { pub fn display_rgb(&self, background: Rgb) -> Rgb { cadcraft_color::display_rgb(self.color, self.aci7, background) } + /// The opacity to draw with: 255 when opaque, down to 26 at 90 % transparency. + pub fn alpha(&self) -> u8 { + alpha(self.transparency) + } } +/// The opacity (0..=255) of a transparency percentage, which is clamped to 0..=90 first. +pub fn alpha(transparency: u8) -> u8 { + let t = u32::from(transparency.min(MAX_TRANSPARENCY)); + u8::try_from((255 * (100 - t) + 50) / 100).unwrap_or(u8::MAX) +} + +/// The most transparent an object can be, in percent. +pub const MAX_TRANSPARENCY: u8 = 90; + /// 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)] @@ -200,6 +216,9 @@ pub struct Options { /// The area being looked at (world units of the space being built), for on-screen builds: /// hatch patterns are only generated inside it. `None` = everywhere (plots, exports, picks). pub view: Option, + /// Draw objects with their transparency ([`DPrim::transparency`]). Off by default: the screen + /// follows TRANSPARENCYDISPLAY, plots the page setup's "Plot transparency". + pub transparency: bool, /// A layout viewport whose model-space contents are left out (its border is still drawn): /// the canvas draws the active viewport's contents itself, see [`viewport_content`]. pub skip_viewport: Option, @@ -217,6 +236,7 @@ impl Default for Options { plot_style_table: None, view: None, skip_viewport: None, + transparency: false, } } } @@ -252,6 +272,8 @@ struct Ctx<'a> { lt_factor: f64, /// Named plot style (table index) of the block reference being expanded, for `ByBlock`. block_pstyle: Option, + /// Transparency (percent) of the block reference being expanded, for `ByBlock`. + block_transparency: u8, } impl Ctx<'_> { @@ -275,6 +297,8 @@ struct Builder<'a> { named: Option, /// The block-expansion budget shared by the parts of a list built in parallel. shared_budget: Option<&'a std::sync::atomic::AtomicUsize>, + /// Transparency (percent) of the entity being drawn. + transparency: u8, } /// Upper bound on the block contents drawn per display list (each block reference, MINSERT copy @@ -313,7 +337,16 @@ impl Builder<'_> { self.list.bounds.add(q); self.list.verts.push(q); } - self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw, kind: Kind::Polyline, start, len: pts.len() as u32 }); + self.list.prims.push(DPrim { + handle: ctx.top, + color: color.rgb, + aci7: color.aci7, + lw, + transparency: self.transparency, + kind: Kind::Polyline, + start, + len: pts.len() as u32, + }); } fn tris(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2]) { self.tris_at(ctx, color, tris, ctx.xf); @@ -330,7 +363,16 @@ impl Builder<'_> { self.list.bounds.add(q); self.list.tris.push(q); } - self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw: 0.0, kind: Kind::Tris, start, len: tris.len() as u32 }); + self.list.prims.push(DPrim { + handle: ctx.top, + color: color.rgb, + aci7: color.aci7, + lw: 0.0, + transparency: self.transparency, + kind: Kind::Tris, + start, + len: tris.len() as u32, + }); } /// Shaped text: strokes as polylines; TrueType glyphs filled (TEXTFILL) or outlined. fn shaped(&mut self, ctx: &Ctx, color: Ink, lw: f32, sh: &cadcraft_fonts::Shaped) { @@ -381,14 +423,32 @@ impl Builder<'_> { self.list.bounds.add(q); let start = self.list.verts.len() as u32; self.list.verts.push(q); - self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw: 0.0, kind: Kind::Point, start, len: 1 }); + self.list.prims.push(DPrim { + handle: ctx.top, + color: color.rgb, + aci7: color.aci7, + lw: 0.0, + transparency: self.transparency, + 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()); - self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw: 0.0, kind: Kind::Infinite { ray }, start, len: 2 }); + self.list.prims.push(DPrim { + handle: ctx.top, + color: color.rgb, + aci7: color.aci7, + lw: 0.0, + transparency: self.transparency, + kind: Kind::Infinite { ray }, + start, + len: 2, + }); } /// Take one unit of the block-expansion budget; false once it is spent. fn expand(&mut self) -> bool { @@ -425,13 +485,14 @@ fn top_ctx<'a>(d: &'a Drawing, xf: Mat3, top: Handle, frozen: &'a [String], vp_c vp_colors, lt_factor: 1.0, block_pstyle: None, + block_transparency: 0, } } fn build_space(d: &Drawing, space: &Space, opts: &Options, plotting: bool) -> DisplayList { let opts = &with_point_view(d, space, opts); let styler = style_table(d, space, opts, plotting).map(|t| plotstyle::Styler::new(d, t)); - let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None }; + let mut b = Builder { list: DisplayList::default(), opts, plotting, styles: styler.as_ref(), named: None, shared_budget: None, transparency: 0 }; if let Space::Paper(name) = space { b.list.sheet = paper::sheet(d, name); } @@ -482,7 +543,8 @@ fn build_parallel(b: &mut Builder, d: &Drawing, store: &cadcraft_doc::EntityStor let parts: Vec = ents .par_chunks(PARALLEL_CHUNK) .map(|chunk| { - let mut part = Builder { list: DisplayList::default(), opts, plotting, styles: None, named: None, shared_budget: Some(&budget) }; + let mut part = + Builder { list: DisplayList::default(), opts, plotting, styles: None, named: None, shared_budget: Some(&budget), transparency: 0 }; for e in chunk { entity(&mut part, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); } @@ -608,7 +670,8 @@ pub fn viewport_content(d: &Drawing, space: &Space, handle: Handle, area: Bounds } let opts = &with_point_view(d, space, opts); let styler = style_table(d, space, opts, false).map(|t| plotstyle::Styler::new(d, t)); - let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: styler.as_ref(), named: None, shared_budget: None }; + let mut b = + Builder { list: DisplayList::default(), opts, plotting: false, styles: styler.as_ref(), named: None, shared_budget: None, transparency: 0 }; contents(&mut b, d, vp, area, &Clip::Rect(area)); b.list } @@ -647,7 +710,15 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou let eb = cadcraft_doc::entity_bounds(d, me, 0); eb.is_empty() || eb.intersects(&win) }; - let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None, shared_budget: None }; + let mut sub = Builder { + list: DisplayList::default(), + opts: b.opts, + plotting: b.plotting, + styles: b.styles, + named: None, + shared_budget: None, + transparency: 0, + }; #[cfg(not(target_arch = "wasm32"))] let parallel = !b.plotting && b.styles.is_none() && d.model.len() >= 2 * PARALLEL_CHUNK; #[cfg(target_arch = "wasm32")] @@ -663,8 +734,15 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou let parts: Vec = ents .par_chunks(PARALLEL_CHUNK) .map(|chunk| { - let mut part = - Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: Some(&budget) }; + let mut part = Builder { + list: DisplayList::default(), + opts, + plotting: false, + styles: None, + named: None, + shared_budget: Some(&budget), + transparency: 0, + }; let c = ctx(); for me in chunk.iter().filter(|me| seen(me)) { entity(&mut part, &c, me); @@ -787,7 +865,16 @@ fn push_raw(dst: &mut DisplayList, p: &DPrim, kind: Kind, pts: &[Vec2]) { } else { dst.verts.extend_from_slice(pts); } - dst.prims.push(DPrim { handle: p.handle, color: p.color, aci7: p.aci7, lw: p.lw, kind, start, len: pts.len() as u32 }); + dst.prims.push(DPrim { + handle: p.handle, + color: p.color, + aci7: p.aci7, + lw: p.lw, + transparency: p.transparency, + kind, + start, + len: pts.len() as u32, + }); } /// Build the display list for a set of loose entities (previews, rubber bands). @@ -801,7 +888,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, named: None, shared_budget: None }; + let mut b = Builder { list: DisplayList::default(), opts, plotting: false, styles: None, named: None, shared_budget: None, transparency: 0 }; for e in ents { entity(&mut b, &top_ctx(d, Mat3::IDENTITY, e.handle, &[], &[]), e); } @@ -844,12 +931,16 @@ fn take_viewport_layers(list: &mut DisplayList) { } } -fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> (Ink, f32, Option, f64, bool) { +/// An object's colour, lineweight (mm), linetype, linetype scale, visibility and the transparency +/// of the layer it is drawn on (for ByLayer, see [`transparency`]). +type Resolved = (Ink, f32, Option, f64, bool, u8); + +fn resolve(ctx: &Ctx, e: &Entity, plotting: bool) -> Resolved { resolve_common(ctx, &e.common, plotting) } /// [`resolve`] for an object's properties `c`. -fn resolve_common(ctx: &Ctx, c: &cadcraft_doc::Common, plotting: bool) -> (Ink, f32, Option, f64, bool) { +fn resolve_common(ctx: &Ctx, c: &cadcraft_doc::Common, plotting: bool) -> Resolved { let d = ctx.d; // Layer "0" inside a block takes the insert's layer. let layer_name = if c.layer == "0" { ctx.block_layer.as_deref().unwrap_or("0") } else { c.layer.as_str() }; @@ -874,7 +965,20 @@ fn resolve_common(ctx: &Ctx, c: &cadcraft_doc::Common, plotting: bool) -> (Ink, }; let lt = d.linetype(<_name).filter(|l| !l.pattern.is_empty()).cloned(); let scale = d.header.f64("LTSCALE", 1.0) * c.ltscale * ctx.lt_factor; - (rgb, lw_mm, lt, scale, visible) + (rgb, lw_mm, lt, scale, visible, layer.map_or(0, |l| l.transparency)) +} + +/// The transparency (percent, 0..=90) of an object whose own setting is `t`: the percentage, its +/// layer's `layer` when ByLayer (layer 0 inside a block: the block reference's layer, as +/// [`resolve_common`] finds it), the block reference's `block` when ByBlock (opaque outside +/// blocks). +fn transparency(t: cadcraft_doc::Transparency, layer: u8, block: u8) -> u8 { + let t = match t { + cadcraft_doc::Transparency::Percent(p) => p, + cadcraft_doc::Transparency::ByBlock => block, + cadcraft_doc::Transparency::ByLayer => layer, + }; + t.min(MAX_TRANSPARENCY) } /// The width in mm that the "Default" lineweight stands for: `LWDEFAULT` (hundredths of a mm, @@ -891,11 +995,13 @@ fn entity(b: &mut Builder, ctx: &Ctx, e: &Entity) { if ctx.d.is_hidden_object(e.handle) { return; } - // The entity's named plot style, for everything it draws (block contents set their own). - let saved = b.named; + // The entity's named plot style and transparency (set by `entity_parts`), for everything it + // draws (block contents set their own). + let (saved, saved_transparency) = (b.named, b.transparency); b.named = b.styles.and_then(|s| s.named_index(ctx, &e.common)); entity_parts(b, ctx, e); b.named = saved; + b.transparency = saved_transparency; if b.plotting && b.list.prim_layers.len() < b.list.prims.len() { // What it drew that its block contents didn't claim is on its own layer. let layer = entity_layer(ctx, e); @@ -930,10 +1036,13 @@ fn entity_parts(b: &mut Builder, ctx: &Ctx, e: &Entity) { cadcraft_doc::AnnoDraw::Plain => {} } } - let (rgb, lw, lt, ltscale, visible) = resolve(ctx, e, b.plotting); + let (rgb, lw, lt, ltscale, visible, layer_transparency) = resolve(ctx, e, b.plotting); if !visible { return; } + if b.opts.transparency { + b.transparency = transparency(e.common.transparency, layer_transparency, ctx.block_transparency); + } // A plot style's linetype replaces the object's. let lt = match b.styles.and_then(|s| s.linetype(rgb, b.named)) { Some(over) => over, @@ -986,7 +1095,7 @@ fn entity_parts(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 = Ctx { block_pstyle: b.named, ..sub_ctx(ctx, e, Mat3::IDENTITY) }; + let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..sub_ctx(ctx, e, Mat3::IDENTITY) }; for be in blk.entities.iter() { if !b.expand() { break; @@ -1372,6 +1481,8 @@ fn sub_ctx<'a>(ctx: &Ctx<'a>, e: &Entity, m: Mat3) -> Ctx<'a> { vp_colors: ctx.vp_colors, lt_factor: ctx.lt_factor, block_pstyle: ctx.block_pstyle, + // The reference's own, set by the caller (`Builder::transparency` while it draws). + block_transparency: ctx.block_transparency, } } @@ -1389,7 +1500,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 = Ctx { block_pstyle: b.named, ..sub_ctx(ctx, e, m) }; + let sub = Ctx { block_pstyle: b.named, block_transparency: b.transparency, ..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(_)) { @@ -1406,6 +1517,8 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg if b.opts.text { // ATTMODE (ATTDISP): 0 shows no attributes, 1 the visible ones, 2 all of them. let attmode = ctx.d.header.i64("ATTMODE", 1); + // The reference's transparency; each attribute draws with its own. + let reference = b.transparency; for a in &ins.attribs { if attmode == 0 || (a.invisible && attmode != 2) { continue; @@ -1417,15 +1530,17 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg // on insertion), except those set on the attribute itself: ByBlock = the reference's, // layer 0 = the reference's layer. let def = ctx.d.block(&ins.block).and_then(|blk| blk.entities.iter().find(|be| attdef_tag(be, &a.tag))); - let (ink, lw, shown) = match (def, a.props.is_empty()) { - (None, true) => (rgb, resolve(ctx, e, b.plotting).1, true), + let (ink, lw, shown, t) = match (def, a.props.is_empty()) { + (None, true) => (rgb, resolve(ctx, e, b.plotting).1, true, reference), (def, _) => { let c = a.props.over(&def.map(|d| d.common.clone()).unwrap_or_else(by_block)); - let (ink, lw, _, _, shown) = resolve_common(&sub_ctx(ctx, e, Mat3::IDENTITY), &c, b.plotting); - (ink, lw, shown) + let (ink, lw, _, _, shown, layer_t) = resolve_common(&sub_ctx(ctx, e, Mat3::IDENTITY), &c, b.plotting); + let t = if b.opts.transparency { transparency(c.transparency, layer_t, reference) } else { 0 }; + (ink, lw, shown, t) } }; if shown { + b.transparency = t; let lw = if b.opts.lineweights { lw } else { 0.0 }; match (&multiline, &sh) { (Some(m), _) => mtext(b, ctx, m, ink, lw), @@ -1434,12 +1549,19 @@ fn insert(b: &mut Builder, ctx: &Ctx, e: &Entity, ins: &cadcraft_doc::Insert, rg } } } + b.transparency = reference; } } /// Properties that follow the block reference's (an attribute without a definition). fn by_block() -> cadcraft_doc::Common { - cadcraft_doc::Common { color: Color::ByBlock, linetype: "ByBlock".into(), lineweight: Lineweight::ByBlock, ..cadcraft_doc::Common::default() } + cadcraft_doc::Common { + color: Color::ByBlock, + linetype: "ByBlock".into(), + lineweight: Lineweight::ByBlock, + transparency: cadcraft_doc::Transparency::ByBlock, + ..cadcraft_doc::Common::default() + } } /// The attribute definition with tag `tag` (any case). diff --git a/crates/render/src/mline.rs b/crates/render/src/mline.rs index 3870e39..bbaacb5 100644 --- a/crates/render/src/mline.rs +++ b/crates/render/src/mline.rs @@ -33,7 +33,7 @@ pub(super) fn draw(b: &mut Builder, ctx: &Ctx, e: &Entity, m: &MLine, rgb: Ink, if !lt.is_empty() && !lt.eq_ignore_ascii_case("bylayer") && !lt.eq_ignore_ascii_case("byblock") { c.linetype = lt.to_string(); } - let (rgb, w, lt, scale, _) = resolve_common(ctx, &c, b.plotting); + let (rgb, w, lt, scale, _, _) = resolve_common(ctx, &c, b.plotting); let lt = match b.styles.and_then(|s| s.linetype(rgb, b.named)) { Some(over) => over, None => lt, diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index cf81a44..379c083 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -81,7 +81,8 @@ pub fn render(list: &DisplayList, view: &View, o: &RasterOptions) -> Option (Common, EntityKind) { + (c, EntityKind::Line(cadcraft_doc::Line { a: Vec3::ZERO, b: Vec3::new(10.0, 0.0, 0.0) })) +} + +fn with_transparency() -> Options { + Options { transparency: true, ..Default::default() } +} + +fn block_entity(handle: u64, common: Common, kind: EntityKind) -> cadcraft_doc::Entity { + let mut e = cadcraft_doc::Entity::new(cadcraft_doc::Handle(handle), kind); + e.common = common; + e +} + +fn insert_of(block: &str) -> EntityKind { + EntityKind::Insert(cadcraft_doc::Insert { + block: block.into(), + insert: Vec3::ZERO, + 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, + }) +} + +#[test] +fn transparency_resolves_by_object_layer_and_block() { + use cadcraft_doc::Transparency::{ByBlock, ByLayer, Percent}; + let mut d = Drawing::new_imperial(); + d.layers.push(cadcraft_doc::Layer { name: "Walls".into(), transparency: 40, ..Default::default() }); + let add = |d: &mut Drawing, c: Common| { + let (c, k) = line_common(c); + d.add(&Space::Model, c, k).unwrap(); + }; + add(&mut d, Common { transparency: Percent(25), ..Default::default() }); + add(&mut d, Common { layer: "Walls".into(), transparency: ByLayer, ..Default::default() }); + // ByBlock outside a block: opaque. + add(&mut d, Common { transparency: ByBlock, ..Default::default() }); + // Block "Inner": a ByBlock line and a ByLayer line on layer 0 (the reference's layer). + let mut inner = cadcraft_doc::Block::new("Inner"); + let (c, k) = line_common(Common { transparency: ByBlock, ..Default::default() }); + inner.entities.push(block_entity(1, c, k)); + let (c, k) = line_common(Common::default()); + inner.entities.push(block_entity(2, c, k)); + d.blocks.insert("Inner".into(), std::sync::Arc::new(inner)); + // Block "Outer": a ByBlock reference to Inner, on layer Walls. + let mut outer = cadcraft_doc::Block::new("Outer"); + outer.entities.push(block_entity(3, Common { layer: "Walls".into(), transparency: ByBlock, ..Default::default() }, insert_of("Inner"))); + d.blocks.insert("Outer".into(), std::sync::Arc::new(outer)); + d.add(&Space::Model, Common { transparency: Percent(70), ..Default::default() }, insert_of("Outer")).unwrap(); + let l = build(&d, &Space::Model, &with_transparency()); + let t: Vec = l.prims.iter().map(|p| p.transparency).collect(); + // Nested: Inner's ByBlock line takes Outer's reference (70, through the ByBlock reference to + // Inner); its layer-0 line takes the reference's layer, Walls (40). + assert_eq!(t, vec![25, 40, 0, 70, 40]); + // Off (the default): everything is opaque, as before. + assert!(build(&d, &Space::Model, &Options::default()).prims.iter().all(|p| p.transparency == 0 && p.alpha() == 255)); +} + +#[test] +fn hostile_transparency_values_are_clamped() { + let mut d = Drawing::new_imperial(); + d.layers.push(cadcraft_doc::Layer { name: "Odd".into(), transparency: 250, ..Default::default() }); + let (c, k) = line_common(Common { transparency: cadcraft_doc::Transparency::Percent(u8::MAX), ..Default::default() }); + d.add(&Space::Model, c, k).unwrap(); + let (c, k) = line_common(Common { layer: "Odd".into(), ..Default::default() }); + d.add(&Space::Model, c, k).unwrap(); + // A ByLayer object on a layer that isn't in the table: opaque. + let (c, k) = line_common(Common { layer: "Missing".into(), ..Default::default() }); + d.add(&Space::Model, c, k).unwrap(); + let l = build(&d, &Space::Model, &with_transparency()); + assert_eq!(l.prims.iter().map(|p| p.transparency).collect::>(), vec![90, 90, 0]); + assert_eq!((alpha(0), alpha(50), alpha(90), alpha(200)), (255, 128, 26, 26)); +} + +#[test] +fn transparent_solid_hatch_blends_with_the_line_under_it() { + let mut d = Drawing::new_imperial(); + // The line runs mid-pixel (y = 2.025 is screen row 99.5) so it fills one row exactly. + d.add(&Space::Model, Common::default(), EntityKind::Line(cadcraft_doc::Line { a: Vec3::new(-2.0, 2.025, 0.0), b: Vec3::new(6.0, 2.025, 0.0) })) + .unwrap(); + let solid = Hatch { solid: true, pattern: "SOLID".into(), ..pattern_hatch("SOLID", 1.0, (0.0, 0.0, 4.0, 4.0)) }; + d.add( + &Space::Model, + Common { color: Color::Index(1), transparency: cadcraft_doc::Transparency::Percent(50), ..Default::default() }, + EntityKind::Hatch(solid), + ) + .unwrap(); + let pixel = |o: &Options, x: f64, y: f64| { + let l = build(&d, &Space::Model, o); + let v = View { center: Vec2::new(2.0, 2.0), scale: 20.0, width: 200, height: 200 }; + let pm = render(&l, &v, &RasterOptions { background: Rgb(0, 0, 0), antialias: false, ..Default::default() }).unwrap(); + let (px, py) = v.to_screen(Vec2::new(x, y)); + let c = pm.pixel(px as u32, py as u32).unwrap(); + (c.red(), c.green(), c.blue()) + }; + let near = |a: (u8, u8, u8), b: (u8, u8, u8)| [(a.0, b.0), (a.1, b.1), (a.2, b.2)].iter().all(|(x, y)| x.abs_diff(*y) <= 2); + // 50 %: half red over black, and half red over the white line, which shows through. + let inside = pixel(&with_transparency(), 1.0, 1.0); + assert!(near(inside, (128, 0, 0)), "{inside:?}"); + let on_line = pixel(&with_transparency(), 1.0, 2.025); + assert!(near(on_line, (255, 127, 127)), "{on_line:?}"); + // Transparency off: the hatch hides the line, as before. + assert_eq!(pixel(&Options::default(), 1.0, 2.025), (255, 0, 0)); +} + +fn attrib(tag: &str) -> cadcraft_doc::Attrib { + cadcraft_doc::Attrib { + tag: tag.into(), + text: cadcraft_doc::Text { + insert: Vec3::new(1.0, 1.0, 0.0), + align_pt: None, + height: 1.0, + value: "A1".into(), + rotation: 0.0, + width_factor: 1.0, + oblique: 0.0, + style: "Standard".into(), + halign: Default::default(), + valign: Default::default(), + }, + invisible: false, + constant: false, + prompt: String::new(), + props: Default::default(), + multiline: None, + modes: Default::default(), + } +} + +/// A block "B" with a line (layer 0, ByLayer) and an attribute definition with `def`'s +/// properties, and a reference to it with `reference`'s, carrying the attribute. +fn attributed(def: Common, reference: Common) -> Drawing { + let mut d = Drawing::new_imperial(); + let mut blk = cadcraft_doc::Block::new("B"); + let (c, k) = line_common(Common::default()); + blk.entities.push(block_entity(1, c, k)); + blk.entities.push(block_entity(2, def, EntityKind::AttDef(attrib("TAG")))); + d.blocks.insert("B".into(), std::sync::Arc::new(blk)); + let EntityKind::Insert(mut ins) = insert_of("B") else { panic!("an insert") }; + ins.attribs.push(attrib("TAG")); + d.add(&Space::Model, reference, EntityKind::Insert(ins)).unwrap(); + d +} + +/// The transparency of the block's line and of the attribute's text, from a list built with +/// transparency on (the line is drawn first, then the text). +fn line_and_text(d: &Drawing) -> (u8, Vec) { + let l = build(d, &Space::Model, &with_transparency()); + let (first, rest) = l.prims.split_first().unwrap(); + assert!(!rest.is_empty(), "the attribute is drawn"); + (first.transparency, rest.iter().map(|p| p.transparency).collect()) +} + +#[test] +fn attributes_draw_with_their_own_transparency() { + use cadcraft_doc::Transparency::{ByBlock, Percent}; + // A reference at 60 % on an opaque layer: layer-0 ByLayer contents (the line and the + // attribute) take the reference's layer, so both are opaque. + let d = attributed(Common::default(), Common { transparency: Percent(60), ..Default::default() }); + let (line, text) = line_and_text(&d); + assert_eq!(line, 0); + assert!(text.iter().all(|t| *t == 0), "{text:?}"); + // An attribute definition with its own 50 % in an opaque reference. + let d = attributed(Common { transparency: Percent(50), ..Default::default() }, Common::default()); + let (line, text) = line_and_text(&d); + assert_eq!(line, 0); + assert!(text.iter().all(|t| *t == 50), "{text:?}"); + // ByBlock follows the reference. + let d = attributed(Common { transparency: ByBlock, ..Default::default() }, Common { transparency: Percent(30), ..Default::default() }); + assert!(line_and_text(&d).1.iter().all(|t| *t == 30)); + // Off: everything opaque. + assert!(build(&d, &Space::Model, &Options::default()).prims.iter().all(|p| p.transparency == 0)); +} + +#[test] +fn attributes_follow_the_reference_layer_and_have_no_definition() { + use cadcraft_doc::Transparency::Percent; + // A layer-0 ByLayer definition in a reference on a 40 % layer: the line and the attribute + // both take the reference's layer. + let mut d = attributed(Common::default(), Common { layer: "Glass".into(), ..Default::default() }); + // Adding the reference made the layer. + d.layer_mut("Glass").unwrap().transparency = 40; + let (line, text) = line_and_text(&d); + assert_eq!(line, 40); + assert!(text.iter().all(|t| *t == 40), "{text:?}"); + // No definition in the block: the attribute follows the reference (30 %), with or without + // properties of its own. + for props in [cadcraft_doc::AttribProps::default(), cadcraft_doc::AttribProps { color: Some(Color::Index(3)), ..Default::default() }] { + let mut d = Drawing::new_imperial(); + let mut blk = cadcraft_doc::Block::new("B"); + let (c, k) = line_common(Common::default()); + blk.entities.push(block_entity(1, c, k)); + d.blocks.insert("B".into(), std::sync::Arc::new(blk)); + let EntityKind::Insert(mut ins) = insert_of("B") else { panic!("an insert") }; + ins.attribs.push(cadcraft_doc::Attrib { props: props.clone(), ..attrib("TAG") }); + d.add(&Space::Model, Common { transparency: Percent(30), ..Default::default() }, EntityKind::Insert(ins)).unwrap(); + let (line, text) = line_and_text(&d); + assert_eq!(line, 0, "layer-0 ByLayer contents take the reference's opaque layer"); + assert!(text.iter().all(|t| *t == 30), "{props:?}: {text:?}"); + } +} + #[test] fn mtext_exact_spacing_ignores_taller_lines() { // #711: a doubled middle line pushes the next baseline down with At least, not with Exactly. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 522ef91..0b18f0a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -38,7 +38,7 @@ impl Xf { #[derive(Default)] pub struct CanvasState { pub list: Option, - key: (u64, u64, i32, i32, bool, usize, u64), + key: (u64, u64, i32, i32, bool, bool, usize, u64), /// The area `list` was built for (hatch patterns exist only inside it); a view leaving it /// rebuilds the list. window: Option, @@ -174,10 +174,30 @@ fn prims_of(list: &DisplayList, handles: &[Handle]) -> Vec { list.prims.iter().enumerate().filter(|(_, pr)| set.contains(&pr.handle)).map(|(i, _)| i).collect() } +/// Build options for previews at `scale` pixels per drawing unit (opaque). +fn preview_options(scale: f64) -> cadcraft_render::Options { + cadcraft_render::Options { tolerance: 0.5 / scale, min_dash: 2.0 / scale, ..Default::default() } +} + +/// The display list of preview entities the session builds with the current properties +/// ([`cadcraft_engine::Session::preview`], the extras of [`cadcraft_engine::Session::drag`]): the +/// command's rubber band and rebuilt copies of existing objects. They carry CLAYER's properties, +/// not the objects', so they are drawn opaque (the GPU drag of the objects themselves keeps their +/// transparency, see [`draw_drag_gpu`]). +fn rebuilt_preview<'a>(doc: &cadcraft_doc::Drawing, ents: impl IntoIterator, scale: f64) -> DisplayList { + cadcraft_render::build_entities(doc, ents, &preview_options(scale)) +} + fn color32(c: Rgb) -> Color32 { Color32::from_rgb(c.0, c.1, c.2) } +/// A primitive's colour on `bg`, with its transparency. +fn prim_color32(prim: &cadcraft_render::DPrim, bg: Rgb) -> Color32 { + let c = prim.display_rgb(bg); + Color32::from_rgba_unmultiplied(c.0, c.1, c.2, prim.alpha()) +} + /// Rebuild the display list when needed. `px`: drawing units per pixel in the current view; /// `views`: every area on screen that shows the list (the current view, other model tiles) with /// its drawing units per pixel. @@ -204,7 +224,17 @@ fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) { } s.finish() }; - let key = (st.uid, st.revision, band(px), lod_band, app.session.settings.lwdisplay, std::sync::Arc::as_ptr(&st.doc) as usize, vp_view); + let settings = &app.session.settings; + let key = ( + st.uid, + st.revision, + band(px), + lod_band, + settings.lwdisplay, + settings.transparency_display, + std::sync::Arc::as_ptr(&st.doc) as usize, + vp_view, + ); let visible = views.iter().fold(Bounds2::EMPTY, |acc, v| acc.union(&v.0)); let inside = app.canvas.window.is_none_or(|w| visible.is_empty() || w.contains_box(&visible)); if app.canvas.list.is_some() && app.canvas.key == key && inside { @@ -222,6 +252,7 @@ fn ensure_list(app: &mut CadApp, px: f64, views: &[(Bounds2, f64)]) { view_height: st.paper_view().height, plot_style_table: None, view: build_window(views), + transparency: app.session.settings.transparency_display, skip_viewport: split, }; let space = st.space.clone(); @@ -263,7 +294,7 @@ struct VpContent { list: DisplayList, /// What the list was built from: drawing, revision, document, viewport, tessellation band /// and lineweight display. - key: Option<(u64, u64, usize, Handle, i32, bool)>, + key: Option<(u64, u64, usize, Handle, i32, bool, bool)>, /// The viewport's view (centre, height) the list was built for, and the paper area it covers. view: (Vec2, f64), area: Bounds2, @@ -313,7 +344,7 @@ struct VpFrame { /// Build or reuse the active viewport's [`VpContent`] for this frame. fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Context) -> Option { - let lwdisplay = app.session.settings.lwdisplay; + let (lwdisplay, transparency) = (app.session.settings.lwdisplay, app.session.settings.transparency_display); let stencil = stencil_clip(app); let c = &mut app.canvas.vp; let Some((st, (h, vp, clip))) = app.session.state().ok().and_then(|st| Some((st, split_viewport(st, stencil)?))) else { @@ -330,7 +361,7 @@ fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Context) -> Op }; let px = 1.0 / xf_paper.scale.max(1e-300); let band = px.max(1e-300).log2().floor() as i32; - let key = (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, h, band, lwdisplay); + let key = (st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, h, band, lwdisplay, transparency); let now = crate::now_ms(); let view = (vp.view_center, vp.view_height); if view != c.last_view { @@ -365,6 +396,7 @@ fn ensure_vp_content(app: &mut CadApp, xf_paper: &Xf, ctx: &egui::Context) -> Op plot_style_table: None, view: Some(area), skip_viewport: None, + transparency, }; c.list = cadcraft_render::viewport_content(&st.doc, &st.space, h, area, &opts); c.key = Some(key); @@ -449,7 +481,16 @@ fn draw_vp_mask(c: &mut VpContent, p: &egui::Painter, outline: &[Vec2], xf_paper } fan.bounds = Bounds2::from_points(outline.iter().copied()); let len = u32::try_from(fan.tris.len()).unwrap_or(0); - fan.prims.push(cadcraft_render::DPrim { handle: Handle(0), color: Rgb(0, 0, 0), aci7: false, lw: 0.0, kind: Kind::Tris, start: 0, len }); + fan.prims.push(cadcraft_render::DPrim { + handle: Handle(0), + color: Rgb(0, 0, 0), + aci7: false, + lw: 0.0, + transparency: 0, + kind: Kind::Tris, + start: 0, + len, + }); let origin = if fan.bounds.is_empty() { Vec2::ZERO } else { fan.bounds.center() }; if let Ok(mut slot) = c.mask_slot.lock() { *slot = Some(crate::gpu::build_mesh(&fan, origin, Rgb(0, 0, 0), false)); @@ -621,7 +662,7 @@ pub(crate) fn draw_list(p: &egui::Painter, xf: &Xf, list: &DisplayList, bg: Rgb, let mut shapes: Vec = Vec::with_capacity(list.prims.len()); let px = 1.0 / xf.scale; for prim in &list.prims { - let col = color32(prim.display_rgb(bg)); + let col = prim_color32(prim, bg); let pts = list.points(prim); match prim.kind { Kind::Polyline => { @@ -739,6 +780,8 @@ fn draw_highlight_gpu( /// the transformed copies as egui shapes every frame was slow with heavy selections and could /// overflow the GPU buffer limit. /// `clipboard` is the session's clipboard and its serial (dragged when `drag.clipboard`, by PASTE). +/// `transparency`: TRANSPARENCYDISPLAY (dragged objects keep their transparency). +#[allow(clippy::too_many_arguments)] fn draw_drag_gpu( c: &mut CanvasState, p: &egui::Painter, @@ -747,13 +790,14 @@ fn draw_drag_gpu( drag: &cadcraft_engine::Drag, clipboard: (&[cadcraft_doc::Entity], u64), bg: Rgb, + transparency: bool, ) { // Tessellate for a power-of-two zoom band so zooming mid-drag rebuilds only every 2x. let band = xf.scale.max(1e-300).log2().round() as i32; let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (c.key, band, bg.0, bg.1, bg.2, &drag.handles).hash(&mut h); + (c.key, band, bg.0, bg.1, bg.2, &drag.handles, transparency).hash(&mut h); if drag.clipboard { clipboard.1.hash(&mut h); } @@ -761,7 +805,7 @@ fn draw_drag_gpu( }; if c.drag_key != Some(key) { let band_scale = 2f64.powi(band); - let opts = cadcraft_render::Options { tolerance: 0.5 / band_scale, min_dash: 2.0 / band_scale, ..Default::default() }; + let opts = cadcraft_render::Options { transparency, ..preview_options(band_scale) }; let list = if drag.clipboard { cadcraft_render::build_entities(doc, clipboard.0.iter(), &opts) } else { @@ -839,7 +883,7 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis if let (Kind::Infinite { ray }, Some(b), Some(d)) = (prim.kind, pts.first(), pts.get(1)) && let Some(seg) = clip_infinite(xf, *b, *d, ray) { - shapes.push(Shape::line_segment(seg, Stroke::new(1.0, color32(prim.display_rgb(bg))))); + shapes.push(Shape::line_segment(seg, Stroke::new(1.0, prim_color32(prim, bg)))); } } p.extend(shapes); @@ -1649,12 +1693,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if let Some((drag, extra)) = drag { if let Ok(st) = app.session.state() { let serial = app.session.clipboard_source.as_ref().map_or(0, |c| c.serial); - draw_drag_gpu(&mut app.canvas, &painter, &xf, &st.doc, &drag, (&app.session.clipboard, serial), bg); - let list = cadcraft_render::build_entities( - &st.doc, - extra.iter(), - &cadcraft_render::Options { tolerance: 0.5 / scale, min_dash: 2.0 / scale, ..Default::default() }, - ); + let transparency = app.session.settings.transparency_display; + draw_drag_gpu(&mut app.canvas, &painter, &xf, &st.doc, &drag, (&app.session.clipboard, serial), bg, transparency); + let list = rebuilt_preview(&st.doc, extra.iter(), scale); draw_list(&painter, &xf, &list, bg, false); } } else if let (Some(c), true) = (app.canvas.cursor, app.session.prompting()) { @@ -1662,11 +1703,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if !ents.is_empty() && let Ok(st) = app.session.state() { - let list = cadcraft_render::build_entities( - &st.doc, - ents.iter(), - &cadcraft_render::Options { tolerance: 0.5 / scale, min_dash: 2.0 / scale, ..Default::default() }, - ); + let list = rebuilt_preview(&st.doc, ents.iter(), scale); draw_list(&painter, &xf, &list, bg, false); } } @@ -2240,7 +2277,8 @@ pub(crate) mod tests { use cadcraft_render::DPrim; fn list(handles: &[u64]) -> DisplayList { - let prim = |h| DPrim { handle: Handle(h), color: Rgb(255, 255, 255), aci7: true, lw: 0.0, kind: Kind::Polyline, start: 0, len: 0 }; + let prim = + |h| DPrim { handle: Handle(h), color: Rgb(255, 255, 255), aci7: true, lw: 0.0, transparency: 0, kind: Kind::Polyline, start: 0, len: 0 }; DisplayList { prims: handles.iter().map(|&h| prim(h)).collect(), ..Default::default() } } @@ -2248,6 +2286,66 @@ pub(crate) mod tests { v.iter().map(|&h| Handle(h)).collect() } + /// A dragged object keeps its transparency in the GPU preview when TRANSPARENCYDISPLAY is + /// on, and is opaque when it is off. + #[test] + fn dragged_objects_keep_their_transparency() { + let mut d = cadcraft_doc::Drawing::new_metric(); + let common = cadcraft_doc::Common { transparency: cadcraft_doc::Transparency::Percent(50), ..Default::default() }; + let line = cadcraft_doc::Line { a: cadcraft_geom::Vec3::ZERO, b: cadcraft_geom::Vec3::new(10.0, 0.0, 0.0) }; + let h = d.add(&cadcraft_doc::Space::Model, common, cadcraft_doc::EntityKind::Line(line)).unwrap(); + let drag = cadcraft_engine::Drag { handles: vec![h], matrix: cadcraft_geom::Mat3::IDENTITY, extra: Vec::new(), clipboard: false }; + let rect = Rect::from_min_size(Pos2::ZERO, vec2(200.0, 100.0)); + let xf = Xf { rect, center: Vec2::new(5.0, 0.0), scale: 10.0 }; + let painter = egui::Painter::new(egui::Context::default(), egui::LayerId::background(), rect); + // One canvas across the toggle: the cached drag mesh must be rebuilt. + let mut c = CanvasState::default(); + let mut alpha = |transparency: bool| { + draw_drag_gpu(&mut c, &painter, &xf, &d, &drag, (&[], 0), Rgb(0, 0, 0), transparency); + let mesh = c.drag_slot.lock().unwrap().take().unwrap(); + // The first line vertex's colour alpha (after its 2 × f32 position). + mesh.lines[11] + }; + assert_eq!(alpha(true), 128); + assert_eq!(alpha(false), 255); + assert_eq!(alpha(true), 128); + } + + /// Previews the session rebuilds with the current properties (the rubber band, copies of + /// objects) stay opaque even when CLAYER is transparent: they were opaque before transparency + /// was drawn, and CLAYER's transparency isn't the objects'. + #[test] + fn rebuilt_previews_stay_opaque_on_a_transparent_current_layer() { + let mut s = cadcraft_engine::Session::new(); + let d = s.doc_mut().unwrap(); + d.layers.push(cadcraft_doc::Layer { name: "Glass".into(), transparency: 40, ..Default::default() }); + d.header.set_str("CLAYER", "Glass"); + assert!(s.settings.transparency_display); + // LINE: the segment from the first point to the cursor. + s.cmdline("line").unwrap(); + s.cmdline("0,0").unwrap(); + let ents = s.preview(Vec2::new(10.0, 5.0)); + assert!(!ents.is_empty()); + assert_eq!(ents[0].common.layer, "Glass"); + let doc = s.doc().unwrap(); + // Built with transparency, the preview would take CLAYER's 40 %. + let with = cadcraft_render::build_entities(doc, ents.iter(), &cadcraft_render::Options { transparency: true, ..preview_options(1.0) }); + assert!(with.prims.iter().all(|p| p.transparency == 40)); + let list = rebuilt_preview(doc, ents.iter(), 1.0); + assert!(!list.prims.is_empty() && list.prims.iter().all(|p| p.transparency == 0)); + s.cancel(); + // MOVE's base-to-cursor rubber band (a drag extra). + s.execute("line", &serde_json::json!({"points": [[0, 0], [10, 0]]})).unwrap(); + s.cmdline("move").unwrap(); + s.cmdline("all").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("0,0").unwrap(); + let (_, extra) = s.drag(Vec2::new(4.0, 4.0)).unwrap(); + assert!(!extra.is_empty()); + let list = rebuilt_preview(s.doc().unwrap(), extra.iter(), 1.0); + assert!(!list.prims.is_empty() && list.prims.iter().all(|p| p.transparency == 0)); + } + #[test] fn viewport_highlight_tolerance_is_constant_within_a_zoom_band() { assert_eq!(viewport_highlight_tolerance(4.0), viewport_highlight_tolerance(3.0)); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 92bafb9..10edc9c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -473,7 +473,7 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou let Ok(st) = app.session.state() else { return err("no drawing") }; // `window`: just that area, at the size given or matching its shape. let window = render_window(p); - let opts = cadcraft_render::Options { view: window, ..Default::default() }; + let opts = cadcraft_render::Options { view: window, transparency: app.session.settings.transparency_display, ..Default::default() }; let list = cadcraft_render::build(&st.doc, &st.space, &opts); let v = st.view(); let fit = p.get("fit").and_then(Value::as_bool).unwrap_or(true); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 14e7ca0..1eed268 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -23,7 +23,8 @@ use cadcraft_render::{DisplayList, Kind}; use egui_wgpu::wgpu; use egui_wgpu::wgpu::util::DeviceExt; -/// Line/triangle vertex: position (2 × f32, relative to the origin) + sRGB colour (4 × u8). +/// Line/triangle vertex: position (2 × f32, relative to the origin) + sRGB colour (4 × u8, +/// premultiplied by its alpha: transparent objects blend with what is under them). const VERTEX_SIZE: usize = 12; /// Wide-line / point instance: a, b (2 × f32 each) + colour (4 × u8) + width in points (f32) + /// length along its polyline before `a`, in world units (f32; dashing). @@ -149,6 +150,12 @@ pub fn wide_width(lw: f32, lwdisplay: bool) -> Option { (w > 1.0).then_some(w) } +/// `c` with alpha `a`, premultiplied (as the pipelines blend). +fn premultiplied(c: Rgb, a: u8) -> [u8; 4] { + let k = |v: u8| u8::try_from((u32::from(v) * u32::from(a) + 127) / 255).unwrap_or(v); + [k(c.0), k(c.1), k(c.2), a] +} + /// Convert a display list into vertex data relative to `origin`, colours resolved against `bg`. pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> MeshData { let mut m = MeshData { @@ -158,8 +165,7 @@ pub fn build_mesh(list: &DisplayList, origin: Vec2, bg: Rgb, lwdisplay: bool) -> breaks: Vec::new(), }; for prim in &list.prims { - let c = prim.display_rgb(bg); - let col = [c.0, c.1, c.2, 255]; + let col = premultiplied(prim.display_rgb(bg), prim.alpha()); let pts = list.points(prim); match prim.kind { Kind::Polyline => match wide_width(prim.lw, lwdisplay) { @@ -396,11 +402,13 @@ fn px_to_clip(px: vec2) -> vec4 { return vec4(px.x / view.b.z * 2.0 - 1.0, 1.0 - px.y / view.b.w * 2.0, 0.0, 1.0); } +// `c` is premultiplied sRGB; a linear target gets it linearised, still premultiplied. fn out_color(c: vec4) -> vec4 { if (view.a.w > 0.5) { - let lo = c.rgb / 12.92; - let hi = pow((c.rgb + vec3(0.055)) / 1.055, vec3(2.4)); - return vec4(select(hi, lo, c.rgb <= vec3(0.04045)), c.a); + let s = c.rgb / max(c.a, 0.0001); + let lo = s / 12.92; + let hi = pow((s + vec3(0.055)) / 1.055, vec3(2.4)); + return vec4(select(hi, lo, s <= vec3(0.04045)) * c.a, c.a); } return c; } @@ -798,7 +806,7 @@ mod tests { tris: vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)], ..Default::default() }; - let p = |kind, start, len, lw| DPrim { handle: Handle(1), color: Rgb(255, 255, 255), aci7: true, lw, kind, start, len }; + let p = |kind, start, len, lw| DPrim { handle: Handle(1), color: Rgb(255, 255, 255), aci7: true, lw, transparency: 0, kind, start, len }; l.prims = vec![p(Kind::Polyline, 0, 3, 0.0), p(Kind::Tris, 0, 3, 0.0), p(Kind::Point, 3, 1, 0.0), p(Kind::Infinite { ray: false }, 0, 2, 0.0)]; for v in l.verts.clone() { @@ -862,6 +870,25 @@ mod tests { assert_eq!(x, 1.0); } + #[test] + fn transparent_objects_get_premultiplied_alpha() { + let mut l = list(); + for p in &mut l.prims { + p.transparency = 50; + } + // Colour 7 on a black background is white: half of it, premultiplied. + let m = build_mesh(&l, Vec2::ZERO, Rgb(0, 0, 0), false); + assert_eq!(&m.lines[8..12], &[128, 128, 128, 128]); + assert_eq!(&m.tris[8..12], &[128, 128, 128, 128]); + // A wipeout's mask stays opaque. + if let Some(p) = l.prims.get_mut(1) { + p.kind = Kind::Mask; + } + let m = build_mesh(&l, Vec2::ZERO, Rgb(10, 20, 30), false); + assert_eq!(&m.tris[8..12], &[10, 20, 30, 255]); + assert_eq!(premultiplied(Rgb(255, 0, 0), 255), [255, 0, 0, 255], "opaque colours are unchanged"); + } + #[test] fn lineweights_become_quads() { let mut l = list(); diff --git a/crates/ui-egui/src/managers.rs b/crates/ui-egui/src/managers.rs index 781dde3..4e008dd 100644 --- a/crates/ui-egui/src/managers.rs +++ b/crates/ui-egui/src/managers.rs @@ -580,9 +580,10 @@ fn parse_ratio(t: &str) -> Option { } /// The page-setup rows (device, paper, orientation, plot area, fit, scale, center, lineweights, -/// plot style table) shared by the Page Setup Manager and the Plot dialog, inside a two-column -/// grid. Returns the setting the user changed as a `pagesetup` parameter (`device`, `paper`, -/// `landscape`, `plotArea`, `scaleToFit`, `scale`, `center`, `lineweights` or `plotStyleTable`). +/// transparency, plot style table) shared by the Page Setup Manager and the Plot dialog, inside a +/// two-column grid. Returns the setting the user changed as a `pagesetup` parameter (`device`, +/// `paper`, `landscape`, `plotArea`, `scaleToFit`, `scale`, `center`, `lineweights`, +/// `plotTransparency` or `plotStyleTable`). fn page_rows( ui: &mut egui::Ui, salt: egui::Id, @@ -706,6 +707,16 @@ fn page_rows( change = Some(("lineweights", json!(lw))); } ui.end_row(); + ui.label(""); + let mut transparency = page.plot_transparency; + if ui + .checkbox(&mut transparency, "Plot transparency") + .on_hover_text("Plot objects with their transparency (PLOTTRANSPARENCYOVERRIDE can override it)") + .changed() + { + change = Some(("plotTransparency", json!(transparency))); + } + ui.end_row(); ui.label("Plot style table"); egui::ComboBox::from_id_salt(salt.with("styles")).selected_text(style_label(page)).width(190.0).show_ui(ui, |ui| { if ui.selectable_label(style_label(page) == "None", "None").clicked() && style_label(page) != "None" { @@ -751,6 +762,7 @@ fn apply_page_change(page: &mut cadcraft_doc::PageSetup, key: &str, v: &Value) { "scaleToFit" => page.scale_to_fit = b, "center" => page.center = b, "lineweights" => page.lineweights = b, + "plotTransparency" => page.plot_transparency = b, _ => {} } } @@ -832,6 +844,7 @@ fn plot_params(st: &PlotState, preview_dpi: Option) -> Value { "landscape": page.landscape, "fit": page.scale_to_fit, "lineweights": page.lineweights, + "plotTransparency": page.plot_transparency, }); if let Some(o) = p.as_object_mut() { let device = device_label(page) != "None"; @@ -920,13 +933,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { set_temp(ctx, PLOT_NOW, false); let (mut cancel, mut action, mut pick, mut preview) = (false, None, false, false); let center = ctx.content_rect().center(); - egui::Window::new("Plot") - .open(open) - .collapsible(false) - .resizable(false) - .pivot(egui::Align2::CENTER_CENTER) - .default_pos(center) - .show(ctx, |ui| { + egui::Window::new("Plot").open(open).collapsible(false).resizable(false).pivot(egui::Align2::CENTER_CENTER).default_pos(center).show(ctx, |ui| { egui::Grid::new("plot_target").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { ui.label("Layout"); egui::ComboBox::from_id_salt("plot_layout").selected_text(st.target.as_str()).width(190.0).show_ui(ui, |ui| { @@ -980,7 +987,14 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { let page = &st.page; action = Some(( "pagesetup", - json!({ "layout": st.target, "paper": page.paper, "landscape": page.landscape, "scaleToFit": page.scale_to_fit, "lineweights": page.lineweights }), + json!({ + "layout": st.target, + "paper": page.paper, + "landscape": page.landscape, + "scaleToFit": page.scale_to_fit, + "lineweights": page.lineweights, + "plotTransparency": page.plot_transparency, + }), )); } preview = ui.button("Preview").on_hover_text("The sheet as these settings plot it").clicked(); @@ -989,7 +1003,7 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action { ok |= ui.button("OK").clicked(); }); }); - }); + }); // The output file type follows the device. let ext = choices.devices.iter().find(|dv| dv.name.eq_ignore_ascii_case(&st.page.device)).map_or("pdf", |dv| dv.output.as_str()); set_temp(ctx, "plot_state", st.clone()); @@ -1169,6 +1183,25 @@ mod plot_dialog_tests { assert!(plot_params(&st, None).get("plotStyleTable").is_none()); } + #[test] + fn plot_transparency_checkbox_reaches_the_plot() { + let mut st = state("Model"); + assert!(!st.page.plot_transparency, "off by default"); + assert_eq!(plot_params(&st, None)["plotTransparency"], json!(false)); + apply_page_change(&mut st.page, "plotTransparency", &json!(true)); + assert!(st.page.plot_transparency); + assert_eq!(plot_params(&st, None)["plotTransparency"], json!(true)); + // The preview plots it the same way. + assert_eq!(plot_params(&st, Some(150.0))["plotTransparency"], json!(true)); + // Into a layout's page setup and back out to its Plot dialog. + let mut s = Session::new(); + s.execute("pagesetup", &json!({ "layout": "Layout1", "plotTransparency": true })).unwrap(); + let page = plot_page(s.doc().unwrap(), "Layout1"); + assert!(page.plot_transparency); + apply_page_change(&mut st.page, "plotTransparency", &json!(false)); + assert_eq!(plot_params(&st, None)["plotTransparency"], json!(false)); + } + #[test] fn plot_center_and_offsets_are_model_only_and_omitted_for_svg() { let mut st = state("Model"); diff --git a/crates/ui-egui/tests/gpu_viewport_stencil.rs b/crates/ui-egui/tests/gpu_viewport_stencil.rs index f7c6342..025789d 100644 --- a/crates/ui-egui/tests/gpu_viewport_stencil.rs +++ b/crates/ui-egui/tests/gpu_viewport_stencil.rs @@ -19,7 +19,8 @@ const STENCIL: wgpu::TextureFormat = wgpu::TextureFormat::Stencil8; fn tris(pts: Vec) -> DisplayList { let len = pts.len() as u32; let bounds = Bounds2::from_points(pts.iter().copied()); - let prim = DPrim { handle: Handle(1), color: cadcraft_color::Rgb(0, 0, 0), aci7: false, lw: 0.0, kind: Kind::Tris, start: 0, len }; + let prim = + DPrim { handle: Handle(1), color: cadcraft_color::Rgb(0, 0, 0), aci7: false, lw: 0.0, transparency: 0, kind: Kind::Tris, start: 0, len }; DisplayList { prims: vec![prim], tris: pts, bounds, ..Default::default() } } @@ -146,7 +147,16 @@ fn clipped_viewport_contents_draw_only_inside_their_outline() { // The selection highlight inside a clipped viewport stays inside its outline too: a wide // line across the whole target at world y 32, masked by the circle. let line = DisplayList { - prims: vec![DPrim { handle: Handle(1), color: cadcraft_color::Rgb(0, 0, 0), aci7: false, lw: 0.0, kind: Kind::Polyline, start: 0, len: 2 }], + prims: vec![DPrim { + handle: Handle(1), + color: cadcraft_color::Rgb(0, 0, 0), + aci7: false, + lw: 0.0, + transparency: 0, + kind: Kind::Polyline, + start: 0, + len: 2, + }], verts: vec![Vec2::new(0.0, 32.0), Vec2::new(64.0, 32.0)], bounds: Bounds2::new(Vec2::new(0.0, 32.0), Vec2::new(64.0, 32.0)), ..Default::default() diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2cf7406..5b3a94c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -105,7 +105,7 @@ early (#175), but the throughput problem remains. **DWG ≈ 30% read, ≈ 15% wr |---|---|---|---| | DWT templates | open, save | NEW/QNEW from `.dwt` and `.dxf` templates, SAVEAS `.dwt` (#552) | 0–1 | | DWS standards | open, save, CHECKSTANDARDS | no | 6–10 | -| PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF with lineweights, real text in embedded TrueType subsets, layers (OCG), several sheets per file and hyperlinks (#567, #571) | 1–2 | +| PDF output (PLOT, EXPORTPDF) | multi-sheet, layers, searchable text, hyperlinks | vector PDF with lineweights, real text in embedded TrueType subsets, layers (OCG), several sheets per file and hyperlinks (#567, #571); object transparency as PDF alpha when "Plot transparency" is on (the option isn't saved in DXF/DWG: no public plot layout flag) | 1–2 | | PDF import (PDFIMPORT) and PDF underlays (PDFATTACH) | yes | no | 15–25 | | Raster images attach (PNG, JPEG, TIFF, BMP …) | yes | IMAGE entity exists, no attach/loading/display path | 6–10 | | PNG / JPEG / BMP / TIFF export | PNGOUT, JPGOUT, BMPOUT, TIFOUT | PNG; SVG too (AutoCAD doesn't export SVG) | 1–2 | @@ -135,6 +135,7 @@ our output in AutoCAD black-box. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | PDF output: object transparency plotted as PDF alpha (`/ExtGState` `/ca` `/CA`) when the page setup's or the plot's "Plot transparency" is on; PNG plots blend it, SVG uses `opacity`; plots without it are unchanged byte for byte | | 2026-10-12 | minor | DXF/DWG save: the Model layout's plot settings are written as CADCraft plots model space, drawing extents scaled to fit (PLOTSETTINGS 74 = 1, 75 = 0), instead of plot type 5 "Layout information", which other programs' Plot dialogs can't use for model space | | 2026-10-12 | minor | VIEWPORT frozen layers also written and read as AutoCAD's group 341 layer handles, so third-party viewers honour per-viewport freezing in DXF (DWG converter gap noted) | | 2026-10-12 | minor | HATCH: associative hatches inside blocks keep their source objects; boundaries carry `{ACAD_REACTORS}` | diff --git a/docs/gaps.md b/docs/gaps.md index ce6c976..0adb80f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Block Editor for static blocks landed; rows 4 and F3 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (multiline attributes read, drawn and saved: F17 narrowed) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR @@ -35,8 +35,8 @@ do them first. | F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing MTEXT cannot be edited (no DDEDIT/MTEDIT), stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 | | F11 | Tables (cell styles, formulas, TABLEDIT, TABLEEXPORT, table breaking and CSV data links landed, #534): formulas aren't read from or written as AutoCAD FIELD objects (other programs see values); number formats apply to formula results only; cell text doesn't wrap; custom cell styles, breaks and data links travel as CADCraft xdata (not through DWG); data links read CSV only (no Excel ranges, no write-back, not on the web); no cell-style tab in the Table Style dialog | `cmd/table/*`, `io/dxf_table.rs` | Low-medium | 6–10 | | F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 | +| F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3); the page setup's "Plot transparency" is not saved in DXF/DWG (the public DXF Reference has no plot layout flag for it), so it lasts for the session and in JSON only. The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564), and plotted transparency (page setup and Plot dialog option, PLOTTRANSPARENCYOVERRIDE) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 | | F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties and can't add or remove boundaries of an existing hatch (HPDLGMODE 0 keeps HATCH on the command line), no MPolygon; HATCHEDIT can't add or remove boundaries; a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 8–12 | -| F14 | **[alpha blocker]** Plotting leftovers: binary `.ctb`/`.stb`/`.pc3` files can't be read; model-space PDF plots keep the default margins (a device paper's margins apply to layouts and PNG plots); no system printers (H3). The Plot dialog (#160), Plot Preview and PUBLISH (#186), plot styles (CTB and STB, [plot-styles.md](plot-styles.md), #410) with their end, join and fill styles, and plotter devices with paper sizes and printable areas ([plotters.md](plotters.md), #564) landed | `io/src/plotter.rs`, `cmd/plotters.rs` | Medium | 4–8 | | F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 | | F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 | | F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 | @@ -145,6 +145,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Object transparency (entity, ByLayer, ByBlock) is drawn: on screen (TRANSPARENCYDISPLAY, on by default), in PNG/SVG exports, and in plots when the page setup's "Plot transparency" is on (PDF alpha, PNG blending, SVG opacity; PLOTTRANSPARENCYOVERRIDE): F14 narrowed | | 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode | | 2026-10-12 | minor | A bare system variable name typed at the command line (ORTHOMODE, GRIDMODE…) runs SETVAR on that variable, as in AutoCAD; U10 narrowed | | 2026-10-13 | minor | STRETCH takes crossing and window polygons (CP, WP), BOX and a fence (F), typed and as JSON (`polygon`, `fence`): F1 narrowed | diff --git a/docs/plotters.md b/docs/plotters.md index a4fd5d6..7ca7c78 100644 --- a/docs/plotters.md +++ b/docs/plotters.md @@ -111,3 +111,22 @@ In the file name, characters other than letters, digits, spaces and `-_()+,` bec Model-space PDF plots don't apply the paper's margins yet. They fit the drawing inside the default margins. PNG plots apply them. SVG plots show a layout's whole sheet, or the model-space extents. + +## Plot transparency + +Objects plot with their transparency (their own, their layer's when ByLayer, the block +reference's when ByBlock) only when asked, as in AutoCAD: + +- `pagesetup {layout, plotTransparency: bool}` stores the layout's "Plot transparency" (off by + default). The Page Setup Manager and the Plot dialog have the checkbox. +- `plot {plotTransparency?: bool}` (and `exportpdf`, `preview`) overrides it for one plot. + Model-space plots have it off unless asked. +- `PLOTTRANSPARENCYOVERRIDE` decides first: 0 never plots transparency, 1 (default) uses the + setting above, 2 always plots it. + +PDF plots write it as alpha (`/ExtGState` with `/ca` and `/CA`), PNG plots blend it, SVG plots use +`opacity`. A plot without transparency is written exactly as before. The DXF plot layout flags +have no bit for the option, so it isn't saved in DXF or DWG files. + +On screen, `TRANSPARENCYDISPLAY` (on by default; the status bar's Transparency button) shows +transparency; PNG and SVG exports of a drawing always draw it.