git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit8c8ba0e17fc7855b04af569e295ee30862234c53
parent6fac187233
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-25 15:03
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; },