graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: wire opacity is a setting of its own, apart from polygon opacity
The wire pass's opacity was the Wire Color's alpha channel, reachable only
through a colour picker. It is now State::wire_opacity: a Wire Opacity
slider in the viewport menu's wireframe group and a dialog row beside
Geometry Opacity, persisted in the render block. The wire colour is RGB.
Old files carry the opacity as the colour's alpha (#rrggbbaa in state.kdl,
a four-component array in a project's display block); StoredRenderSettings
moves it into wire_opacity on load, so a translucent wireframe stays one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 10 ++--
src/app.rs | 166 ++++++++++++++++++++++++++++++++++++++++-----------------
src/dialog.rs | 62 +++++++++------------
src/main.rs | 130 ++++++++++++++++++++++++++++++++++----------
src/project.rs | 21 ++------
5 files changed, 257 insertions(+), 132 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 851fee4..912a8b7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1384,7 +1384,7 @@ menubar addressed through `GUIDES_MENU` / `GUIDE_*` so no item slid onto
another's action, while old files carrying `show_cube_enabled` still load),
the
scene furniture that is not the geometry); wireframe (switch,
-thickness); points (Show Points, Point Size, and Group Marker Scale, which
+thickness, opacity); points (Show Points, Point Size, and Group Marker Scale, which
multiplies it); overlays (Show Point Markers and its size, Show Point
Numbers, Show Point Normals — the annotations over the scene's points);
surface (shading, opacity, Show Occluded).
@@ -1392,7 +1392,11 @@ surface (shading, opacity, Show Occluded).
order. The rows: the Show Wireframe switch (its registry command), **Flat
Shading / Smooth Shading** as a radio pair over `toggle_smooth_shading`,
a **Wire Thickness** slider under the wireframe switch (1–8 px by
-half a pixel, the palette row's range), a **Point Size** slider (0–0.1
+half a pixel, the palette row's range), a **Wire Opacity** slider under
+that (percent by 5, `State::wire_opacity` — the wires' own, apart from the
+polygons' Opacity; until 2026-09-25 it was the Wire Color's ALPHA, and
+`StoredRenderSettings` moves an old alpha, from state.kdl's `#rrggbbaa` or
+a project's four-component array, into it on load), a **Point Size** slider (0–0.1
world units by 0.005, no suffix since the World Unit names them), a
**Point Marker Size** slider (the palette row's 0.005–0.1), a **Group
Marker Scale** slider (0.5–4 by 0.05, read "1.25x"), and the
@@ -1650,7 +1654,7 @@ selection stays where it was:
- `Choice` — a fixed set (World Unit). The current option reads in the
chord column between two arrows; Enter or a click steps to the next,
Left/Right either way.
-- `Color` — a hex colour, with or without alpha. Behind each colour row the
+- `Color` — a hex colour. Behind each colour row the
dialog keeps one toolkit `ColorSelector` (`Dialog::colors`, by row id,
kept across re-rankings so a query that drops the row does not kill its
picker): a real widget, not a stamp, because it carries state — a hex
diff --git a/src/app.rs b/src/app.rs
index 32ac643..8f8631c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -463,6 +463,10 @@ pub enum ViewportMenuAction {
/// The wire pass's thickness in px, a slider row under Show Wireframe:
/// 1–8 like the palette's Wire Thickness row, half a pixel a notch.
WireThicknessSlider,
+ /// The wire pass's own opacity, in percent like the polygon Opacity row
+ /// and stepped the same 5%: the two are independent, so a translucent
+ /// fill can carry a solid lattice and the other way round.
+ WireOpacitySlider,
/// Point Size in world units, the Render points' radius and (times
/// Group Marker Scale) the group markers': 0–0.1 like the palette's row.
PointSizeSlider,
@@ -1192,49 +1196,102 @@ fn default_world_unit() -> String {
/// guides, so they persist here, and the dialog's Settings half is where
/// they are edited.
#[derive(Serialize, Deserialize, Clone, Debug)]
+#[serde(from = "StoredRenderSettings")]
pub struct RenderSettings {
- #[serde(default)]
pub wireframe: bool,
- #[serde(default)]
pub wire_single_color: bool,
- /// RGBA: the alpha is the wireframe's OWN opacity, in both colour modes
- /// (the geometry Opacity below is polygons-only).
- #[serde(default = "default_wire_color")]
- pub wire_color: [f32; 4],
- #[serde(default = "default_wire_width")]
+ /// RGB; used in single-colour mode only.
+ pub wire_color: [f32; 3],
+ /// The wire pass's own opacity, in both colour modes — `geo_opacity` is
+ /// the polygons'. Until 2026-09-25 it was the wire colour's ALPHA, which
+ /// made it a setting reachable only through a colour picker's alpha
+ /// channel; [`StoredRenderSettings`] moves an old alpha here.
+ pub wire_opacity: f32,
pub wire_width: f32,
- #[serde(default = "default_geo_opacity")]
pub geo_opacity: f32,
- #[serde(default)]
pub render_points: bool,
- #[serde(default = "default_point_size")]
pub point_size: f32,
- #[serde(default = "default_point_color")]
pub point_color: [f32; 3],
/// The Selected-Group markers' radius as a multiple of `point_size` —
/// they draw on the same vertices as the Render points, so the ratio is
/// what keeps both legible. Hard-coded at 1.25 until 2026-09-24.
- #[serde(default = "default_group_marker_scale")]
pub group_marker_scale: f32,
/// Smooth (vertex-normal) shading of the scene fill, where off is the
/// faceted look the raster pass has always had. Absent in older files:
/// flat.
- #[serde(default)]
pub smooth_shading: bool,
/// See-through fill: below full opacity the fill draws with no culling
/// and no depth writes, triangles sorted back to front for the eye, so
/// what it occludes — its own far side, the wires, the scene behind —
/// shows through. Absent in older files: off.
- #[serde(default)]
pub show_occluded: bool,
}
+/// [`RenderSettings`] as READ, from state.kdl and from a project's display
+/// block alike: every field optional in the file, and the wire colour taken
+/// with three components or four. A fourth is the wire opacity as it was
+/// stored before `wire_opacity` existed, and becomes it when the file names
+/// no `wire_opacity` of its own — dropping it would make every translucent
+/// wireframe opaque on the first load.
+#[derive(Deserialize)]
+struct StoredRenderSettings {
+ #[serde(default)]
+ wireframe: bool,
+ #[serde(default)]
+ wire_single_color: bool,
+ #[serde(default)]
+ wire_color: Option<Vec<f32>>,
+ #[serde(default)]
+ wire_opacity: Option<f32>,
+ #[serde(default = "default_wire_width")]
+ wire_width: f32,
+ #[serde(default = "default_geo_opacity")]
+ geo_opacity: f32,
+ #[serde(default)]
+ render_points: bool,
+ #[serde(default = "default_point_size")]
+ point_size: f32,
+ #[serde(default = "default_point_color")]
+ point_color: [f32; 3],
+ #[serde(default = "default_group_marker_scale")]
+ group_marker_scale: f32,
+ #[serde(default)]
+ smooth_shading: bool,
+ #[serde(default)]
+ show_occluded: bool,
+}
+
+impl From<StoredRenderSettings> for RenderSettings {
+ fn from(s: StoredRenderSettings) -> Self {
+ let c = s.wire_color.unwrap_or_default();
+ let (wire_color, old_alpha) = match c[..] {
+ [r, g, b] => ([r, g, b], None),
+ [r, g, b, a, ..] => ([r, g, b], Some(a)),
+ _ => (default_wire_color(), None),
+ };
+ Self {
+ wireframe: s.wireframe,
+ wire_single_color: s.wire_single_color,
+ wire_color,
+ wire_opacity: s.wire_opacity.or(old_alpha).unwrap_or(1.0).clamp(0.0, 1.0),
+ wire_width: s.wire_width,
+ geo_opacity: s.geo_opacity,
+ render_points: s.render_points,
+ point_size: s.point_size,
+ point_color: s.point_color,
+ group_marker_scale: s.group_marker_scale,
+ smooth_shading: s.smooth_shading,
+ show_occluded: s.show_occluded,
+ }
+ }
+}
+
fn default_group_marker_scale() -> f32 {
1.25
}
-fn default_wire_color() -> [f32; 4] {
- [0.0, 0.0, 0.0, 1.0]
+fn default_wire_color() -> [f32; 3] {
+ [0.0, 0.0, 0.0]
}
fn default_wire_width() -> f32 {
@@ -1259,6 +1316,7 @@ impl Default for RenderSettings {
wireframe: false,
wire_single_color: false,
wire_color: default_wire_color(),
+ wire_opacity: 1.0,
wire_width: default_wire_width(),
geo_opacity: default_geo_opacity(),
render_points: false,
@@ -1392,11 +1450,6 @@ fn hex_to_float_array(hex: &str) -> Option<[f32; 3]> {
cce_ui::color::parse_hex_rgb(hex)
}
-fn float_array_to_hex4(rgba: &[f32; 4]) -> String {
- let c = |v: f32| (v * 255.0).clamp(0.0, 255.0).round() as u8;
- format!("#{:02x}{:02x}{:02x}{:02x}", c(rgba[0]), c(rgba[1]), c(rgba[2]), c(rgba[3]))
-}
-
fn hex_to_float_array4(hex: &str) -> Option<[f32; 4]> {
let h = hex.trim_start_matches('#');
if h.len() == 8 {
@@ -1481,11 +1534,16 @@ impl DesignSettings {
Some(Self::from_kdl_str(&content))
}
- /// Every colour field, as `(block, field, components)`. KDL carries them
- /// as hex strings — `#rrggbb`, or `#rrggbbaa` for the four-component wire
- /// colour — so both directions walk this one table. It was a hand-written
- /// pair of `if let`s per colour, which is why only two of the five were
- /// ever converted once the render block arrived.
+ /// Every colour field, as `(block, field, components read)`. KDL carries
+ /// them as `#rrggbb` hex strings, so both directions walk this one table.
+ /// It was a hand-written pair of `if let`s per colour, which is why only
+ /// two of the five were ever converted once the render block arrived.
+ ///
+ /// Every colour is WRITTEN as three components. The wire colour is READ
+ /// as four, because until 2026-09-25 it was `#rrggbbaa` with the alpha
+ /// as the wire opacity; `StoredRenderSettings` moves that alpha into
+ /// `wire_opacity`, which a six-digit hex (alpha 1) never overrides since
+ /// the file then names `wire_opacity` itself.
const COLOR_FIELDS: &'static [(&'static str, &'static str, usize)] = &[
("viewport", "bg_color", 3),
("viewport", "grid_color", 3),
@@ -1551,15 +1609,10 @@ impl DesignSettings {
pub(crate) fn to_kdl_str(&self) -> Option<String> {
if let Ok(mut json_val) = serde_json::to_value(self) {
if let Some(obj) = json_val.as_object_mut() {
- for &(block, field, n) in Self::COLOR_FIELDS {
+ for &(block, field, _) in Self::COLOR_FIELDS {
let Some(b) = obj.get_mut(block).and_then(|v| v.as_object_mut()) else { continue };
let Some(val) = b.get(field).cloned() else { continue };
- let hex = if n == 4 {
- serde_json::from_value::<[f32; 4]>(val).ok().map(|a| float_array_to_hex4(&a))
- } else {
- serde_json::from_value::<[f32; 3]>(val).ok().map(|a| float_array_to_hex(&a))
- };
- if let Some(hex) = hex {
+ if let Some(hex) = serde_json::from_value::<[f32; 3]>(val).ok().map(|a| float_array_to_hex(&a)) {
b.insert(field.to_string(), serde_json::Value::String(hex));
}
}
@@ -2040,16 +2093,16 @@ pub struct State {
/// they carry the geometry's vertex colors unlit — brighter than the lit
/// fill beneath, which is what separates them.
pub wire_single_color: bool,
- /// RGBA: the alpha channel is the wireframe's OWN opacity in both color
- /// modes — the geometry Opacity slider affects only the polygons.
- pub wire_color: [f32; 4],
+ /// RGB, used in single-colour mode only.
+ pub wire_color: [f32; 3],
+ /// The wire pass's opacity ("Wire Opacity"), in both colour modes — the
+ /// geometry Opacity slider affects only the polygons.
+ pub wire_opacity: f32,
/// Wire line width in framebuffer pixels ("Wire Thickness" slider).
pub wire_width: f32,
pub last_viewport_wire_single_color: bool,
- pub last_viewport_wire_color: [f32; 4],
- /// The wire colour as last read off the Render node — so a CHANGE to
- /// it can be told from a load. `None` until the first read.
- pub last_applied_wire_color: Option<[f32; 4]>,
+ pub last_viewport_wire_color: [f32; 3],
+ pub last_viewport_wire_opacity: f32,
pub last_viewport_wire_width: f32,
/// Opacity of the rendered node geometry (the Render node's "Opacity"
/// slider): 1.0 opaque, straight-alpha blended toward the viewport bg.
@@ -2321,6 +2374,7 @@ impl State {
wireframe: self.wireframe,
wire_single_color: self.wire_single_color,
wire_color: self.wire_color,
+ wire_opacity: self.wire_opacity,
wire_width: self.wire_width,
geo_opacity: self.geo_opacity,
render_points: self.render_points,
@@ -2390,6 +2444,7 @@ impl State {
self.wireframe = r.wireframe;
self.wire_single_color = r.wire_single_color;
self.wire_color = r.wire_color;
+ self.wire_opacity = r.wire_opacity;
self.wire_width = r.wire_width;
self.geo_opacity = r.geo_opacity;
self.render_points = r.render_points;
@@ -4365,7 +4420,7 @@ impl State {
/// `land_viewport_menu_slider` writes each back, so a slider row is
/// added in those two matches and the row list.
///
- /// Opacity reads in percent, stepped by 5; Wire Thickness in px over the
+ /// Both opacities read in percent, stepped by 5; Wire Thickness in px over the
/// palette row's own 1–8, by half a pixel. The palette rows are the fine
/// controls.
pub(crate) fn viewport_menu_slider(&self, action: ViewportMenuAction) -> Option<cce_ui::widget::context_menu::MenuSlider> {
@@ -4379,6 +4434,14 @@ impl State {
decimals: 0,
suffix: "%",
},
+ ViewportMenuAction::WireOpacitySlider => MenuSlider {
+ value: (self.wire_opacity.clamp(0.0, 1.0) * 100.0).round(),
+ min: 0.0,
+ max: 100.0,
+ step: 5.0,
+ decimals: 0,
+ suffix: "%",
+ },
ViewportMenuAction::WireThicknessSlider => MenuSlider {
value: self.wire_width.clamp(1.0, 8.0),
min: 1.0,
@@ -4424,7 +4487,7 @@ impl State {
}
/// Write a slider row's value onto the live field, and redo only what
- /// that value feeds. Opacity and wire thickness are draw-time values (a
+ /// that value feeds. The opacities and wire thickness are draw-time values (a
/// uniform, a line width and the fill's matching depth bias). Point
/// size is baked into two meshes: the Render points re-bake in the stage
/// pass off their own size key, and the group markers are re-sized here
@@ -4435,6 +4498,7 @@ impl State {
match action {
ViewportMenuAction::OpacitySlider => self.geo_opacity = (v / 100.0).clamp(0.0, 1.0),
ViewportMenuAction::WireThicknessSlider => self.wire_width = v.clamp(1.0, 8.0),
+ ViewportMenuAction::WireOpacitySlider => self.wire_opacity = (v / 100.0).clamp(0.0, 1.0),
ViewportMenuAction::PointSizeSlider => {
self.point_size = v.clamp(0.0, 0.1);
self.rebuild_group_marker_verts();
@@ -4478,7 +4542,8 @@ impl State {
/// The viewport menu's rows and what each does, in groups a separator
/// apart: framing; the GUIDES (Show Grid, Show Origin — the scene
- /// furniture that is not the geometry); the WIREFRAME (its switch and thickness); the
+ /// furniture that is not the geometry); the WIREFRAME (its switch,
+ /// thickness and opacity); the
/// POINTS
/// (the Show Points switch, point size, and the group marker scale that
/// multiplies it); the OVERLAYS (Show Point Markers and its size, Show
@@ -4508,6 +4573,7 @@ impl State {
row(&mut options, &mut actions, "-".into(), sep);
row(&mut options, &mut actions, format!("{} {}", mark(self.wireframe), label("toggle_wireframe", "Show Wireframe")), ViewportMenuAction::Command("toggle_wireframe"));
row(&mut options, &mut actions, "Wire Thickness".into(), ViewportMenuAction::WireThicknessSlider);
+ row(&mut options, &mut actions, "Wire Opacity".into(), ViewportMenuAction::WireOpacitySlider);
// Points: the Render points, and the group markers sized off them.
row(&mut options, &mut actions, "-".into(), sep);
@@ -4580,6 +4646,7 @@ impl State {
// The slider row is worked, not picked.
ViewportMenuAction::OpacitySlider
| ViewportMenuAction::WireThicknessSlider
+ | ViewportMenuAction::WireOpacitySlider
| ViewportMenuAction::PointSizeSlider
| ViewportMenuAction::PointMarkerSizeSlider
| ViewportMenuAction::GroupMarkerScaleSlider => {}
@@ -5603,10 +5670,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
last_viewport_wireframe: false,
wire_single_color: settings.render.wire_single_color,
wire_color: settings.render.wire_color,
+ wire_opacity: settings.render.wire_opacity,
wire_width: settings.render.wire_width,
last_viewport_wire_single_color: false,
- last_viewport_wire_color: [1.0, 1.0, 1.0, 1.0],
- last_applied_wire_color: None,
+ last_viewport_wire_color: [1.0, 1.0, 1.0],
+ last_viewport_wire_opacity: 1.0,
last_viewport_wire_width: 1.0,
geo_opacity: settings.render.geo_opacity,
last_viewport_geo_opacity: 1.0,
@@ -9681,6 +9749,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
|| self.last_viewport_geo_opacity != self.geo_opacity
|| self.last_viewport_wire_single_color != self.wire_single_color
|| self.last_viewport_wire_color != self.wire_color
+ || self.last_viewport_wire_opacity != self.wire_opacity
|| self.last_viewport_wire_width != self.wire_width
|| self.last_viewport_render_points != self.render_points
|| self.last_viewport_point_size != self.point_size
@@ -9782,15 +9851,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// fill beneath. Far-side wires that clear the
// depth test near the limb show as their own
// (complementary) colors — a soft x-ray read.
- // The wires' opacity is the Wire Color ALPHA in
- // both modes; the geometry Opacity slider is
+ // The wires' opacity is Wire Opacity in both
+ // modes; the geometry Opacity slider is
// polygons-only.
let tint = if self.wire_single_color {
[self.wire_color[0], self.wire_color[1], self.wire_color[2], 1.0]
} else {
[0.0, 0.0, 0.0, 0.0]
};
- let wire_alpha = self.wire_color[3].clamp(0.0, 1.0);
+ let wire_alpha = self.wire_opacity.clamp(0.0, 1.0);
draws.push(SceneDraw { mesh: meshes.sphere_edges, mvp, wireframe: true, wire_tint: tint, opacity: wire_alpha, line_width: self.wire_width, wire_base_width: 0.0, prelit: false, see_through });
}
draws.extend(fill);
@@ -9825,6 +9894,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.last_viewport_geo_opacity = self.geo_opacity;
self.last_viewport_wire_single_color = self.wire_single_color;
self.last_viewport_wire_color = self.wire_color;
+ self.last_viewport_wire_opacity = self.wire_opacity;
self.last_viewport_wire_width = self.wire_width;
self.last_viewport_render_points = self.render_points;
self.last_viewport_point_size = self.point_size;
diff --git a/src/dialog.rs b/src/dialog.rs
index 8f30ff4..9babc4a 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -63,10 +63,9 @@ pub enum Control {
/// One of a fixed set: picking the row steps to the next option, the
/// arrows step either way.
Choice { options: Vec<String>, index: usize },
- /// A colour, `#rrggbb` (or `#rrggbbaa` with `alpha`), drawn as the
- /// toolkit's colour selector: a hex well and a swatch that opens the
- /// picker.
- Color { hex: String, alpha: bool },
+ /// A colour, `#rrggbb`, drawn as the toolkit's colour selector: a hex
+ /// well and a swatch that opens the picker.
+ Color { hex: String },
}
impl Control {
@@ -562,12 +561,11 @@ impl Dialog {
/// change to report back, so the flag it raises is dropped here.
fn sync_color_selectors(&mut self) {
for row in &self.rows {
- let Some(Control::Color { hex, alpha }) = &row.control else { continue };
+ let Some(Control::Color { hex }) = &row.control else { continue };
let k = match self.colors.iter().position(|(k, _)| *k == row.id) {
Some(k) => k,
None => {
- let sel = if *alpha { ColorSelector::new_rgba([0, 0, 0, 255]) } else { ColorSelector::new([0; 3]) };
- self.colors.push((row.id.clone(), sel));
+ self.colors.push((row.id.clone(), ColorSelector::new([0; 3])));
self.colors.len() - 1
}
};
@@ -1246,8 +1244,6 @@ pub enum Ctl {
Toggle,
/// `#rrggbb`.
Color,
- /// `#rrggbbaa` — the wire colour, whose alpha is its own opacity.
- Rgba,
/// A whole number over `min..=max`. The stored float is scaled by
/// `unit` (thousandths for Grid Thickness, tenths for Origin Size),
/// which is the convention those params already used.
@@ -1304,10 +1300,12 @@ pub const SETTINGS: &[Setting] = &[
Setting::field("World Unit", "world_unit", Ctl::Choice(&["mm", "cm", "m", "in"])),
Setting::field("Geometry Opacity", "geo_opacity", Ctl::Slider { min: 0.0, max: 1.0, dec: 2 }),
// The colour applies only in single-colour mode (off, the wires carry
- // the geometry's vertex colours and the colour row sets their alpha
- // alone) — so a colour edit turns that mode on, or a colour set here
- // looks ignored.
- Setting::field("Wireframe Color", "wire_color", Ctl::Rgba),
+ // the geometry's vertex colours) — so a colour edit turns that mode on,
+ // or a colour set here looks ignored.
+ Setting::field("Wireframe Color", "wire_color", Ctl::Color),
+ // The wires' own opacity, in both colour modes; Geometry Opacity is the
+ // polygons'. It was the wire colour's alpha until 2026-09-25.
+ Setting::field("Wire Opacity", "wire_opacity", Ctl::Slider { min: 0.0, max: 1.0, dec: 2 }),
Setting::field("Wire Thickness", "wire_width", Ctl::Slider { min: 1.0, max: 8.0, dec: 1 }),
Setting::field("Point Size", "point_size", Ctl::Slider { min: 0.0, max: 0.1, dec: 3 }),
Setting::field("Point Color", "point_color", Ctl::Color),
@@ -1674,8 +1672,7 @@ impl State {
let value = self.setting_value(s);
match s.ctl {
Ctl::Toggle => Control::Toggle(value == "true"),
- Ctl::Color => Control::Color { hex: value, alpha: false },
- Ctl::Rgba => Control::Color { hex: value, alpha: true },
+ Ctl::Color => Control::Color { hex: value },
Ctl::Spin { min, max, .. } => Control::Slider {
value: value.parse().unwrap_or(min),
min,
@@ -1709,13 +1706,6 @@ impl State {
Owner::Field(key) => match s.ctl {
Ctl::Toggle => if self.settings_field_bool(key) { "true" } else { "false" }.to_string(),
Ctl::Color => crate::project::color_to_hex(self.settings_field_color(key)),
- Ctl::Rgba => {
- let c = match key {
- "wire_color" => self.wire_color,
- _ => [0.0, 0.0, 0.0, 1.0],
- };
- crate::project::color_to_hex8(c)
- }
Ctl::Spin { unit, .. } => ((self.settings_field_f32(key) * unit).round() as i32).to_string(),
Ctl::Slider { dec, .. } => format!("{:.*}", dec, self.settings_field_f32(key)),
Ctl::Choice(_) => self.settings_field_text(key),
@@ -1772,6 +1762,7 @@ impl State {
"grid_color" => self.viewport().grid_color,
"point_color" => self.point_color,
"point_marker_color" => self.point_marker_color,
+ "wire_color" => self.wire_color,
_ => [0.0; 3],
}
}
@@ -1783,6 +1774,7 @@ impl State {
"point_marker_size" => self.point_marker_size,
"wire_width" => self.wire_width,
"geo_opacity" => self.geo_opacity,
+ "wire_opacity" => self.wire_opacity,
"point_size" => self.point_size,
"group_marker_scale" => self.group_marker_scale,
_ => 0.0,
@@ -1819,21 +1811,18 @@ impl State {
"grid_color" => self.viewport_mut().grid_color = c,
"point_color" => self.point_color = c,
"point_marker_color" => self.point_marker_color = c,
- _ => {}
- }
- }
- Ctl::Rgba => {
- let Some(c) = crate::project::hex_to_rgba(value) else { return };
- if key == "wire_color" {
- // Setting a wire colour means wanting to see it: the
- // colour applies in single-colour mode only, so a colour
- // edit turns that mode on if it was off. Twice read as
- // "the colour did not take" (2026-09-21).
- let changed = c != self.wire_color;
- self.wire_color = c;
- if changed && !self.wire_single_color {
- self.wire_single_color = true;
+ "wire_color" => {
+ // Setting a wire colour means wanting to see it: the
+ // colour applies in single-colour mode only, so a
+ // colour edit turns that mode on if it was off. Twice
+ // read as "the colour did not take" (2026-09-21).
+ let changed = c != self.wire_color;
+ self.wire_color = c;
+ if changed && !self.wire_single_color {
+ self.wire_single_color = true;
+ }
}
+ _ => {}
}
}
Ctl::Spin { unit, .. } => {
@@ -1852,6 +1841,7 @@ impl State {
match key {
"wire_width" => self.wire_width = v,
"geo_opacity" => self.geo_opacity = v,
+ "wire_opacity" => self.wire_opacity = v,
"point_size" => self.point_size = v,
"group_marker_scale" => self.group_marker_scale = v,
_ => {}
diff --git a/src/main.rs b/src/main.rs
index 85630ef..e72d2a5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1676,6 +1676,83 @@ mod tests {
context_menu::hide();
}
+ /// The wires' opacity is a setting of its own, apart from the polygons':
+ /// the viewport menu's Wire Opacity slider moves `wire_opacity` and
+ /// leaves `geo_opacity` alone (and the other way round), the dialog has
+ /// a row for it, and it persists. The wire pass reads it in both colour
+ /// modes, so it is not tied to single-colour mode the way the colour is.
+ #[test]
+ fn wire_opacity_is_separate_from_polygon_opacity() {
+ use crate::app::ViewportMenuAction as A;
+ use crate::window::WindowEvent;
+ use cce_ui::widget::{context_menu, MouseScrollDelta};
+ let mut state = State::new(false);
+ state.geo_opacity = 0.5;
+ state.wire_opacity = 0.5;
+ state.cursor_x = 300.0;
+ state.cursor_y = 200.0;
+ state.open_viewport_context_menu();
+ let acts = state.viewport_menu_actions.clone();
+ let i = acts.iter().position(|a| *a == A::WireOpacitySlider).expect("a Wire Opacity row");
+ let sl = context_menu::slider(i).expect("the row is a slider");
+ assert_eq!((sl.value, sl.min, sl.max, sl.step), (50.0, 0.0, 100.0, 5.0));
+
+ state.cursor_x = context_menu::x() + 20.0;
+ state.cursor_y = context_menu::row_y(i) + context_menu::ROW_H * 0.5;
+ state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, -2.0) });
+ assert!((state.wire_opacity - 0.40).abs() < 1e-6, "{}", state.wire_opacity);
+ assert!((state.geo_opacity - 0.5).abs() < 1e-6, "the polygon opacity moved with the wires'");
+
+ let j = acts.iter().position(|a| *a == A::OpacitySlider).expect("an Opacity row");
+ state.cursor_y = context_menu::row_y(j) + context_menu::ROW_H * 0.5;
+ state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 2.0) });
+ assert!((state.geo_opacity - 0.60).abs() < 1e-6, "{}", state.geo_opacity);
+ assert!((state.wire_opacity - 0.40).abs() < 1e-6, "the wires' opacity moved with the polygons'");
+ context_menu::hide();
+
+ let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+ let back = crate::app::DesignSettings::from_kdl_str(&kdl);
+ assert!((back.render.wire_opacity - 0.40).abs() < 0.01, "persisted: {kdl}");
+
+ // The dialog row edits the same field.
+ state.apply_setting("Wire Opacity", "0.25");
+ assert!((state.wire_opacity - 0.25).abs() < 1e-6);
+ assert!((state.geo_opacity - 0.60).abs() < 1e-6);
+ }
+
+ /// Until 2026-09-25 the wire opacity was the wire colour's alpha:
+ /// `#rrggbbaa` in state.kdl and a four-component array in a project's
+ /// display block. Both load with the alpha as `wire_opacity`, or every
+ /// translucent wireframe would come back opaque; and a file that names
+ /// `wire_opacity` itself keeps it.
+ #[test]
+ fn an_old_wire_colour_alpha_becomes_the_wire_opacity() {
+ let old = "render {\n wireframe (bool)true\n wire_color (rgba)\"#33669980\"\n}\n";
+ let back = crate::app::DesignSettings::from_kdl_str(old);
+ assert!(back.render.wireframe, "the rest of the block reads: {old}");
+ let rgb = back.render.wire_color;
+ assert!((rgb[0] - 0.2).abs() < 0.01 && (rgb[1] - 0.4).abs() < 0.01 && (rgb[2] - 0.6).abs() < 0.01, "{rgb:?}");
+ assert!((back.render.wire_opacity - 128.0 / 255.0).abs() < 0.01, "{}", back.render.wire_opacity);
+
+ // Written back, the colour is six digits and the opacity its own key.
+ let written = back.to_kdl_str().expect("kdl");
+ assert!(written.contains("#336699\""), "{written}");
+ let again = crate::app::DesignSettings::from_kdl_str(&written);
+ assert!((again.render.wire_opacity - back.render.wire_opacity).abs() < 1e-6);
+
+ // A project's display block, likewise.
+ let json = serde_json::json!({ "render": { "wire_color": [0.2, 0.4, 0.6, 0.3] } });
+ let d: crate::app::DisplaySettings = serde_json::from_value(json).expect("an old display block loads");
+ assert!((d.render.wire_opacity - 0.3).abs() < 1e-6);
+ assert_eq!(d.render.wire_color, [0.2, 0.4, 0.6]);
+ let json = serde_json::json!({ "render": { "wire_color": [0.2, 0.4, 0.6], "wire_opacity": 0.7 } });
+ let d: crate::app::DisplaySettings = serde_json::from_value(json).unwrap();
+ assert!((d.render.wire_opacity - 0.7).abs() < 1e-6);
+ // Absent altogether: opaque.
+ let d: crate::app::DisplaySettings = serde_json::from_value(serde_json::json!({ "render": {} })).unwrap();
+ assert_eq!((d.render.wire_color, d.render.wire_opacity), ([0.0; 3], 1.0));
+ }
+
/// The viewport menu reads in groups a separator apart: framing, then
/// the guides, the wireframe, the points, the point overlays, and the surface
/// (shading, opacity, Show Occluded) — every display row in exactly one
@@ -1691,7 +1768,7 @@ mod tests {
.collect();
assert_eq!(groups[0], vec![A::FrameAll, A::OneToOne]);
assert_eq!(groups[1], vec![A::Command("toggle_grid"), A::Command("toggle_origin")]);
- assert_eq!(groups[2], vec![A::Command("toggle_wireframe"), A::WireThicknessSlider]);
+ assert_eq!(groups[2], vec![A::Command("toggle_wireframe"), A::WireThicknessSlider, A::WireOpacitySlider]);
assert_eq!(
groups[3],
vec![A::Command("toggle_render_points"), A::PointSizeSlider, A::GroupMarkerScaleSlider]
@@ -5278,7 +5355,7 @@ mod tests {
assert!(crate::command::by_id("wireframe_color").is_none(), "the command went with the Settings half");
let mut state = State::new(false);
- state.wire_color = [0.2, 0.6, 0.9, 0.5];
+ state.wire_color = [0.2, 0.6, 0.9];
state.run_command("command_palette");
let id = setting_row_id("Wireframe Color");
let row = state
@@ -5290,25 +5367,26 @@ mod tests {
.expect("the palette lists Wireframe Color");
assert_eq!(row.label, "Wireframe Color");
assert!(row.chord.is_empty(), "a setting has no chord");
- // The control carries the live colour with its alpha, and a
- // toolkit colour selector stands behind it at the same value.
- assert_eq!(row.control, Some(Control::Color { hex: crate::project::color_to_hex8([0.2, 0.6, 0.9, 0.5]), alpha: true }));
+ // The control carries the live colour, and a toolkit colour
+ // selector stands behind it at the same value.
+ let hex = crate::project::color_to_hex([0.2, 0.6, 0.9]);
+ assert_eq!(row.control, Some(Control::Color { hex: hex.clone() }));
let sel = state.slots.dialog.color_selector(&id).expect("a colour selector behind the row");
- assert_eq!(sel.get_value_string().as_deref(), Some(crate::project::color_to_hex8([0.2, 0.6, 0.9, 0.5]).as_str()));
+ assert_eq!(sel.get_value_string().as_deref(), Some(hex.as_str()));
let s = SETTINGS.iter().find(|s| s.label == "Wireframe Color").unwrap();
assert_eq!(s.owner, Owner::Field("wire_color"));
// Editing the row reaches the live state: the colour AND the switch
// that makes the wire pass use it (off, the wires carry the
- // geometry's colours and the colour row is their alpha alone). The
- // dialog stays up, and the row re-reads the value.
+ // geometry's colours). The dialog stays up, and the row re-reads the
+ // value.
state.wire_single_color = false;
- state.apply_setting("Wireframe Color", "#000000ff");
- assert_eq!(state.wire_color, [0.0, 0.0, 0.0, 1.0], "the colour row writes the live wire colour");
+ state.apply_setting("Wireframe Color", "#000000");
+ assert_eq!(state.wire_color, [0.0, 0.0, 0.0], "the colour row writes the live wire colour");
assert!(state.wire_single_color, "a colour edit turns single-colour mode on");
assert!(state.dialog_visible());
let row = state.slots.dialog.rows.iter().find(|r| r.id == id).unwrap();
- assert_eq!(row.control, Some(Control::Color { hex: "#000000ff".into(), alpha: true }));
+ assert_eq!(row.control, Some(Control::Color { hex: "#000000".into() }));
// And the switch is a command row of its own, flipped in place.
state.take_dialog_pick("toggle_wire_single_color".to_string());
@@ -5417,7 +5495,8 @@ mod tests {
a.world_unit = cce_ui::units::Unit::Cm;
a.wireframe = true;
a.wire_single_color = true;
- a.wire_color = [0.2, 0.4, 0.6, 0.5];
+ a.wire_color = [0.2, 0.4, 0.6];
+ a.wire_opacity = 0.5;
a.wire_width = 3.0;
a.geo_opacity = 0.75;
a.render_points = true;
@@ -5452,12 +5531,10 @@ mod tests {
}
}
assert!(back.render.wireframe && back.render.wire_single_color);
- // The wire colour is the four-component one: its ALPHA is the wire's
- // own opacity, and dropping it would silently make every wireframe
- // fully opaque.
- for k in 0..4 {
- assert!(close(back.render.wire_color[k], [0.2, 0.4, 0.6, 0.5][k]), "{:?}", back.render.wire_color);
+ for k in 0..3 {
+ assert!(close(back.render.wire_color[k], [0.2, 0.4, 0.6][k]), "{:?}", back.render.wire_color);
}
+ assert!(close(back.render.wire_opacity, 0.5));
assert!(close(back.render.wire_width, 3.0));
assert!(close(back.render.geo_opacity, 0.75));
assert!(back.render.render_points);
@@ -5475,19 +5552,19 @@ mod tests {
fn changing_the_wire_colour_turns_single_colour_mode_on() {
let mut state = State::new(false);
state.wire_single_color = false;
- state.wire_color = [1.0, 1.0, 1.0, 1.0];
+ state.wire_color = [1.0, 1.0, 1.0];
// An edit through the dialog's colour row, which is the only way
// in now that the Render node is gone.
state.open_dialog();
- state.apply_setting("Wireframe Color", "#000000ff");
- assert_eq!(state.wire_color, [0.0, 0.0, 0.0, 1.0]);
+ state.apply_setting("Wireframe Color", "#000000");
+ assert_eq!(state.wire_color, [0.0, 0.0, 0.0]);
assert!(state.wire_single_color, "a colour change switches single-colour mode on");
// Off again by hand stays off while the colour is unchanged: the
// auto-enable fires on a CHANGE, not on every settings pass.
state.wire_single_color = false;
- state.apply_setting("Wireframe Color", "#000000ff");
+ state.apply_setting("Wireframe Color", "#000000");
assert!(!state.wire_single_color, "an unrelated pass flipped it back on");
// A load carries the project's geometry and leaves the wire
@@ -5496,7 +5573,7 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
state.save_to_file(&dir).expect("save");
state.load_from_file(&dir).expect("load");
- assert_eq!(state.wire_color, [0.0, 0.0, 0.0, 1.0]);
+ assert_eq!(state.wire_color, [0.0, 0.0, 0.0]);
assert!(!state.wire_single_color, "a load never flips the switch");
let _ = std::fs::remove_dir_all(&dir);
}
@@ -9739,11 +9816,6 @@ mod tests {
assert_eq!(state.settings_row_value(s.label), "#123456",
"row '{}' (key '{key}') did not take a write", s.label);
}
- Ctl::Rgba => {
- state.settings_write_row(s.label, "#12345678");
- assert_eq!(state.settings_row_value(s.label), "#12345678",
- "row '{}' (key '{key}') did not take a write", s.label);
- }
Ctl::Spin { min, max, .. } => {
let v = ((min + max) / 2.0).round() as i32;
state.settings_write_row(s.label, &v.to_string());
@@ -10156,7 +10228,7 @@ mod tests {
let control = |label: &str| {
state.slots.dialog.rows.iter().find(|r| r.id == setting_row_id(label)).and_then(|r| r.control.clone())
};
- assert!(matches!(control("Grid Color"), Some(Control::Color { alpha: false, .. })));
+ assert!(matches!(control("Grid Color"), Some(Control::Color { .. })));
assert!(matches!(control("Grid Thickness"), Some(Control::Slider { dec: 0, .. })), "a spin is a whole-number slider");
assert!(matches!(control("Geometry Opacity"), Some(Control::Slider { dec: 2, .. })));
assert!(matches!(control("World Unit"), Some(Control::Choice { .. })));
@@ -10297,7 +10369,7 @@ mod tests {
"Grid Color", "Grid Thickness", "Origin Size", "Point Marker Size",
"Point Marker Color", "World Unit",
// render
- "Wireframe Color", "Wire Thickness", "Geometry Opacity", "Point Size", "Point Color",
+ "Wireframe Color", "Wire Opacity", "Wire Thickness", "Geometry Opacity", "Point Size", "Point Color",
// main
"Background Color",
// camera
diff --git a/src/project.rs b/src/project.rs
index 19e0816..60051da 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -16,15 +16,6 @@ pub(crate) fn hex_to_color(hex: &str) -> Option<[f32; 3]> {
cce_ui::color::parse_hex_rgb(hex)
}
-pub(crate) fn color_to_hex8(rgba: [f32; 4]) -> String {
- format!("#{:02x}{:02x}{:02x}{:02x}",
- (rgba[0] * 255.0).round().clamp(0.0, 255.0) as u8,
- (rgba[1] * 255.0).round().clamp(0.0, 255.0) as u8,
- (rgba[2] * 255.0).round().clamp(0.0, 255.0) as u8,
- (rgba[3] * 255.0).round().clamp(0.0, 255.0) as u8
- )
-}
-
/// 6- or 8-digit hex → RGBA (alpha 1.0 when absent).
pub(crate) fn hex_to_rgba(hex: &str) -> Option<[f32; 4]> {
cce_ui::color::parse_hex_rgba(hex)
@@ -396,10 +387,6 @@ impl State {
proj.migrate_param_refs();
crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
self.fs_root = proj.root;
- // A load is not a colour change: an older save's wire colour is
- // the baseline, so the auto-enable of single-colour mode stays
- // quiet while the migration reads it.
- self.last_applied_wire_color = None;
self.migrate_meta_settings_node();
// Before the default view, whose camera-node rule has the last
// word on the square aspect and the pivot marker.
@@ -460,8 +447,6 @@ impl State {
proj.migrate_param_refs();
crate::app::merge_template_defs(&mut proj.root, &self.node_templates);
self.fs_root = proj.root;
- // As in the default-project branch.
- self.last_applied_wire_color = None;
self.migrate_meta_settings_node();
// As in the default-project branch.
if let Some(d) = &proj.view_state.display {
@@ -697,7 +682,11 @@ impl State {
match p.name.as_str() {
"Show Wireframe" => if let Some(v) = as_bool(&p) { self.wireframe = v; },
"Wire Single Color" => if let Some(v) = as_bool(&p) { self.wire_single_color = v; },
- "Wire Color" => if let Some(c) = hex_to_rgba(&p.default) { self.wire_color = c; },
+ // Its alpha was the wire opacity until that was a setting.
+ "Wire Color" => if let Some([r, g, b, a]) = hex_to_rgba(&p.default) {
+ self.wire_color = [r, g, b];
+ self.wire_opacity = a;
+ },
"Wire Thickness" => if let Some(v) = as_f32(&p) { self.wire_width = v.clamp(1.0, 8.0); },
"Opacity" => if let Some(v) = as_f32(&p) { self.geo_opacity = v.clamp(0.0, 1.0); },
"Render Points" => if let Some(v) = as_bool(&p) { self.render_points = v; },