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 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 18:14:37 +02:00 committed by GitHub
parent 0cdde077ef
commit 3fe93806b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 153 additions and 4 deletions

View File

@ -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")]

View File

@ -236,7 +236,7 @@ fn run_status(s: &mut Session, _p: &Value) -> Result<Value> {
}
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<Value> {

View File

@ -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<CommandSpec> {
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?, <geometry fields: radius, center, start, end, text, height, rotation…>}"),
CommandSpec::new("properties.set", "Set Properties", run_set).params("{handles?, layer?, color?, linetype?, lineweight?, ltscale?, transparency?: \"ByLayer\" | \"ByBlock\" | 0..90, visible?, <geometry fields: radius, center, start, end, text, height, rotation…>}"),
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<Color> {
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<Transparency> {
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::<f64>().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<Value> {
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<Value> {
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;
}

View File

@ -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();

View File

@ -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

View File

@ -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<i64> {
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);
}

View File

@ -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::<Vec<_>>(), 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::<Vec<_>>(), [Transparency::ByLayer, Transparency::Percent(90)]);
}
#[test]
fn hostile_dxf_does_not_panic() {
for t in [

View File

@ -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<String> = d.layers.iter().map(|l| l.name.clone()).collect();
let linetypes: Vec<String> = d.linetypes.iter().map(|l| l.name.clone()).collect();
let ids: Vec<String> = 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::<u8>().ok();
let mut pct = ui.data_mut(|d| d.get_temp::<u8>(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::<u8>(tid));
}
});
});
});
if sel.len() == 1 {
group(ui, "Geometry");