From 3fe93806b796dee222c94c91de3644b4e63e5c7f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 18:14:37 +0200 Subject: [PATCH] Entity transparency: set it, report it, round-trip DXF group 440 (#174) properties.set documented `transparency` but never read it, nothing reported it, and DXF dropped group 440. Fixes #132. - properties.set takes "ByLayer", "ByBlock" or a percentage 0..90 (a number or "50"/"50%"); anything else (out of range, NaN, junk) is an error and leaves the selection unchanged. - properties and drawing.inspect / drawing.entities report it in the same form. - Properties palette: the Transparency row is a menu (ByLayer, ByBlock, a 0..90 percentage) that acts through properties.set. - DXF: entities write 440 unless ByLayer (ByBlock 0x01000000, a percentage its alpha | 0x02000000, the alpha mapping layers use); the reader parses it back, any other value reads as ByLayer. Co-authored-by: Claude Opus 5.5 --- crates/doc/src/entity.rs | 11 +++++++++++ crates/engine/src/cmd/inquiry.rs | 2 +- crates/engine/src/cmd/props.rs | 31 ++++++++++++++++++++++++++++-- crates/engine/src/tests.rs | 33 ++++++++++++++++++++++++++++++++ crates/io/src/dxf_read.rs | 17 ++++++++++++++++ crates/io/src/dxf_write.rs | 13 +++++++++++++ crates/io/src/tests.rs | 22 +++++++++++++++++++++ crates/ui-egui/src/palettes.rs | 28 ++++++++++++++++++++++++++- 8 files changed, 153 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/entity.rs b/crates/doc/src/entity.rs index 5cbf902..e77a3bc 100644 --- a/crates/doc/src/entity.rs +++ b/crates/doc/src/entity.rs @@ -70,6 +70,17 @@ pub enum Transparency { Percent(u8), } +impl Transparency { + /// "ByLayer", "ByBlock" or the percentage ("50"). + pub fn name(&self) -> String { + match self { + Transparency::ByLayer => "ByLayer".into(), + Transparency::ByBlock => "ByBlock".into(), + Transparency::Percent(p) => p.to_string(), + } + } +} + /// Properties every entity has. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/engine/src/cmd/inquiry.rs b/crates/engine/src/cmd/inquiry.rs index 8955b1e..b53caad 100644 --- a/crates/engine/src/cmd/inquiry.rs +++ b/crates/engine/src/cmd/inquiry.rs @@ -236,7 +236,7 @@ fn run_status(s: &mut Session, _p: &Value) -> Result { } fn entity_summary(e: &cadcraft_doc::Entity) -> Value { - json!({ "handle": e.handle.hex(), "type": e.kind.type_name(), "layer": e.common.layer, "color": e.common.color.name(), "geometry": e.kind }) + json!({ "handle": e.handle.hex(), "type": e.kind.type_name(), "layer": e.common.layer, "color": e.common.color.name(), "transparency": super::props::transparency_value(e.common.transparency), "geometry": e.kind }) } fn run_inspect(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 25498a0..16600c9 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -1,7 +1,7 @@ //! Properties: per-object property edits, current properties, MATCHPROP, linetypes, units. use cadcraft_color::Color; -use cadcraft_doc::{EntityKind, Lineweight}; +use cadcraft_doc::{EntityKind, Lineweight, Transparency}; use cadcraft_geom::Vec3; use serde_json::{Value, json}; @@ -11,7 +11,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { vec![ CommandSpec::new("properties", "Properties", run_get).menu(&["Modify", "Properties"]).alias(&["pr", "props", "ch"]).params("{handles?} → properties of the selection").noundo(), - CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?, visible?, }"), + CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, }"), CommandSpec::new("matchprop", "Match Properties", run_matchprop).menu(&["Modify", "Match Properties"]).alias(&["ma", "painter"]).params("{source, targets: [hex]}"), CommandSpec::new("color", "Color...", run_color).menu(&["Format", "Color..."]).alias(&["col", "colour"]).params("{color: \"ByLayer\" | \"red\" | 1..255 | \"r,g,b\"}"), CommandSpec::new("linetype", "Linetype...", run_linetype).menu(&["Format", "Linetype..."]).alias(&["lt", "ltype"]).params("{current?: name, load?: name | \"*\"}"), @@ -60,6 +60,7 @@ pub(super) fn entity_props(d: &cadcraft_doc::Drawing, e: &cadcraft_doc::Entity) "linetype": e.common.linetype, "lineweight": e.common.lineweight.name(), "ltscale": e.common.ltscale, + "transparency": transparency_value(e.common.transparency), "visible": e.common.visible, }); let geo = serde_json::to_value(&e.kind).unwrap_or(Value::Null); @@ -121,6 +122,25 @@ fn parse_color(v: &Value) -> Option { v.as_str().and_then(Color::parse).or_else(|| v.as_i64().and_then(|i| i16::try_from(i).ok()).map(Color::from_aci)) } +/// A transparency as JSON, in the form `properties.set` takes: "ByLayer", "ByBlock" or a percentage. +pub(super) fn transparency_value(t: Transparency) -> Value { + match t { + Transparency::Percent(p) => json!(p), + other => json!(other.name()), + } +} + +/// "ByLayer", "ByBlock" or a percentage 0..=90 (a number, or a string such as "50" or "50%"). +fn parse_transparency(v: &Value) -> Option { + let pct = match v.as_str() { + Some(s) if s.trim().eq_ignore_ascii_case("bylayer") => return Some(Transparency::ByLayer), + Some(s) if s.trim().eq_ignore_ascii_case("byblock") => return Some(Transparency::ByBlock), + Some(s) => s.trim().trim_end_matches('%').trim_end().parse::().ok()?, + None => v.as_f64()?, + }; + (pct.is_finite() && (0.0..=90.0).contains(&pct)).then(|| Transparency::Percent(pct.round() as u8)) +} + fn set_xy(p: &mut Vec3, v: &Value) { if let Some(q) = point_value(v) { p.x = q.x; @@ -134,6 +154,10 @@ fn run_set(s: &mut Session, p: &Value) -> Result { return Err(bad("properties.set", "nothing selected")); } let color = p.get("color").map(|c| parse_color(c).ok_or_else(|| bad("properties.set", "bad colour"))).transpose()?; + let transparency = p + .get("transparency") + .map(|v| parse_transparency(v).ok_or_else(|| bad("properties.set", "transparency must be ByLayer, ByBlock or a percentage from 0 to 90"))) + .transpose()?; let lw = p.get("lineweight").map(|v| match v.as_str().map(str::to_ascii_lowercase).as_deref() { Some("bylayer") => Lineweight::ByLayer, Some("byblock") => Lineweight::ByBlock, @@ -165,6 +189,9 @@ fn run_set(s: &mut Session, p: &Value) -> Result { if let Some(x) = p.get("ltscale").and_then(Value::as_f64).filter(|x| *x > 0.0) { e.common.ltscale = x; } + if let Some(t) = transparency { + e.common.transparency = t; + } if let Some(v) = p.get("visible").and_then(Value::as_bool) { e.common.visible = v; } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ebe3258..8296141 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -224,6 +224,39 @@ fn layers_and_properties() { assert!(s.execute("layer.new", &json!({"name": "bad/name"})).is_err()); } +#[test] +fn properties_set_transparency() { + use cadcraft_doc::Transparency; + // Issue #132: `properties.set` used to ignore `transparency`. + let mut s = Session::new(); + let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap(); + let h = r["handles"][0].as_str().unwrap().to_string(); + let hh = cadcraft_doc::Handle::parse_hex(&h).unwrap(); + let tr = |s: &Session| s.doc().unwrap().entity(hh).unwrap().common.transparency; + for (v, want) in [ + (json!(50), Transparency::Percent(50)), + (json!("ByBlock"), Transparency::ByBlock), + (json!("25%"), Transparency::Percent(25)), + (json!(0), Transparency::Percent(0)), + (json!("bylayer"), Transparency::ByLayer), + (json!(90), Transparency::Percent(90)), + ] { + s.execute("properties.set", &json!({"handles": [h], "transparency": v})).unwrap(); + assert_eq!(tr(&s), want, "{v}"); + } + // Out of range or not a transparency: an error, and the entity is unchanged. + for v in [json!(91), json!(-1), json!("NaN"), json!("inf"), json!("half"), json!(null), json!([50])] { + assert!(s.execute("properties.set", &json!({"handles": [h], "transparency": v})).is_err(), "{v}"); + assert_eq!(tr(&s), Transparency::Percent(90), "{v}"); + } + // `properties` and `drawing.inspect` report it in the form `properties.set` takes. + let p = s.execute("properties", &json!({"handles": [h]})).unwrap(); + assert_eq!(p["objects"][0]["transparency"], json!(90)); + s.execute("properties.set", &json!({"handles": [h], "transparency": "ByBlock"})).unwrap(); + let i = s.execute("drawing.inspect", &json!({"entities": true})).unwrap(); + assert_eq!(i["entities"][0]["transparency"], json!("ByBlock")); +} + #[test] fn zoom_extents_fits_drawing() { let mut s = Session::new(); diff --git a/crates/io/src/dxf_read.rs b/crates/io/src/dxf_read.rs index 5c45542..131a7da 100644 --- a/crates/io/src/dxf_read.rs +++ b/crates/io/src/dxf_read.rs @@ -38,6 +38,20 @@ impl T<'_> { } } +/// An entity's group 440: `0x01` in the top byte is ByBlock, `0x02` a fixed alpha (mapped to a +/// percentage as for layers), anything else ByLayer. +fn transparency_440(v: i64) -> Transparency { + match (v >> 24) & 0xff { + 1 => Transparency::ByBlock, + 2 => { + let alpha = u32::try_from(v & 0xff).unwrap_or(255); + let opaque = (alpha * 100 + 127) / 255; + Transparency::Percent(u8::try_from(100u32.saturating_sub(opaque).min(90)).unwrap_or(90)) + } + _ => Transparency::ByLayer, + } +} + fn common(t: &T) -> Common { let mut c = Common { layer: t.s(8).unwrap_or_else(|| "0".into()), linetype: t.s(6).unwrap_or_else(|| "ByLayer".into()), ..Common::default() }; if let Some(v) = t.i(62) { @@ -59,6 +73,9 @@ fn common(t: &T) -> Common { if t.i(60) == Some(1) { c.visible = false; } + if let Some(v) = t.i(440) { + c.transparency = transparency_440(v); + } c.thickness = t.fd(39, 0.0); c.extrusion = Vec3::new(t.fd(210, 0.0), t.fd(220, 0.0), t.fd(230, 1.0)); c diff --git a/crates/io/src/dxf_write.rs b/crates/io/src/dxf_write.rs index fb4d03b..a145103 100644 --- a/crates/io/src/dxf_write.rs +++ b/crates/io/src/dxf_write.rs @@ -244,6 +244,16 @@ fn header_vars(w: &mut W, d: &Drawing) { } } +/// An entity's group 440 (DXF Reference): ByBlock is `0x01000000`, a fixed transparency its alpha +/// with the "by alpha" flag `0x02000000` (the alpha mapping layers use). ByLayer writes nothing. +fn transparency_440(t: Transparency) -> Option { + match t { + Transparency::ByLayer => None, + Transparency::ByBlock => Some(0x0100_0000), + Transparency::Percent(p) => Some(i64::from(0x0200_0000 | ((100 - u32::from(p.min(90))) * 255 / 100))), + } +} + fn common(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str) { common_x(w, e, owner, paper, subclass, None); } @@ -281,6 +291,9 @@ fn common_x(w: &mut W, e: &Entity, owner: &str, paper: bool, subclass: &str, ass if !e.common.visible { w.i(60, 1); } + if let Some(v) = transparency_440(e.common.transparency) { + w.i(440, v); + } if !subclass.is_empty() { w.s(100, subclass); } diff --git a/crates/io/src/tests.rs b/crates/io/src/tests.rs index fa2e567..7df7e2a 100644 --- a/crates/io/src/tests.rs +++ b/crates/io/src/tests.rs @@ -260,6 +260,28 @@ fn reads_r12_style_polyline_and_paper_flag() { assert_eq!(d.layouts[0].entities.len(), 1); } +#[test] +fn entity_transparency_roundtrips_as_group_440() { + // Issue #132: ByLayer writes nothing, ByBlock 0x01000000, a percentage its alpha | 0x02000000. + let ts = [Transparency::ByLayer, Transparency::ByBlock, Transparency::Percent(0), Transparency::Percent(40), Transparency::Percent(90)]; + let mut d = Drawing::new_imperial(); + for (i, t) in ts.iter().enumerate() { + let y = i as f64; + let line = EntityKind::Line(Line { a: Vec3::new(0.0, y, 0.0), b: Vec3::new(1.0, y, 0.0) }); + d.add(&Space::Model, Common { transparency: *t, ..Common::default() }, line).unwrap(); + } + let text = write_dxf(&d); + assert_eq!(text.matches("\r\n440\r\n").count(), 4, "ByLayer writes no 440"); + assert!(text.contains("\r\n440\r\n16777216\r\n"), "ByBlock"); + assert!(text.contains("\r\n440\r\n33554585\r\n"), "40% is alpha 153"); + let back = read_dxf(text.as_bytes()).unwrap(); + assert_eq!(back.model.iter().map(|e| e.common.transparency).collect::>(), ts); + // Values that are neither ByBlock nor "by alpha" read as ByLayer. + let text = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n440\n-1\n10\n0\n20\n0\n11\n1\n21\n1\n0\nLINE\n8\n0\n440\n33554432\n10\n0\n20\n0\n11\n1\n21\n1\n0\nENDSEC\n0\nEOF\n"; + let d = read(text.as_bytes(), "a.dxf").unwrap(); + assert_eq!(d.model.iter().map(|e| e.common.transparency).collect::>(), [Transparency::ByLayer, Transparency::Percent(90)]); +} + #[test] fn hostile_dxf_does_not_panic() { for t in [ diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index 0f0b504..df4c959 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -576,6 +576,7 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let linetype = same(&|e| e.common.linetype.clone()); let lts = same(&|e| format!("{:.4}", e.common.ltscale)); let lw = same(&|e| e.common.lineweight.name()); + let tr = same(&|e| e.common.transparency.name()); let layers: Vec = d.layers.iter().map(|l| l.name.clone()).collect(); let linetypes: Vec = d.linetypes.iter().map(|l| l.name.clone()).collect(); let ids: Vec = sel.iter().map(|h| h.hex()).collect(); @@ -653,7 +654,32 @@ fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); }); prop_row(ui, "Transparency", |ui| { - value_box(ui, "ByLayer", true, true); + ui.menu_button(tr.clone(), |ui| { + for v in ["ByLayer", "ByBlock"] { + if ui.button(v).clicked() { + set = Some(json!({ "handles": ids, "transparency": v })); + ui.close(); + } + } + ui.separator(); + // A fixed percentage, committed when the drag or edit ends. + let tid = egui::Id::new(("prop_tr", &ids)); + let cur = tr.parse::().ok(); + let mut pct = ui.data_mut(|d| d.get_temp::(tid)).or(cur).unwrap_or(0); + ui.horizontal(|ui| { + ui.label("Percent"); + let r = ui.add(egui::DragValue::new(&mut pct).range(0..=90).speed(0.5)); + if r.dragged() || r.has_focus() { + ui.data_mut(|d| d.insert_temp(tid, pct)); + } + if r.drag_stopped() || r.lost_focus() { + if cur != Some(pct) { + set = Some(json!({ "handles": ids, "transparency": pct })); + } + ui.data_mut(|d| d.remove::(tid)); + } + }); + }); }); if sel.len() == 1 { group(ui, "Geometry");