git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit66ea2adc6393c222a82c9844d2368bd362e11dc7
parent596c4071fd
authorLucas Galante <[email protected]>
date2026-08-15 21:06
feat: cce-relief gains an edge selector

One profile reads four different ways around a control, because a carve's
shading depends on the angle between the wall's outward normal and the DE
light. That is why a seam's two rims never match — cce-files' pane gap
measures 187 grey on one side and 125 on the other from the same carve —
and until now the tool could only show one of the four.

Edge picks the wall; `facing` follows, and both bands and the live swatch
follow that.

Right and Bottom draw MIRRORED, and must. A wall's outward normal always
points at its plateau, so "plateau on the left" IS the left wall; there is
no way to render a real right wall with its plateau on the left. Without
the flip the prediction and the swatch disagree by a reflection, which
reads as a shading bug rather than an orientation one — it measured as a
bright lobe in the wrong column before the fix.

A horizontal wall's shading varies DOWN the band rather than along it, so
for Top/Bottom both bands become vertical gradients at depth = the band
height. That is roughly life size (a real wall is 4.8-9.3 logical px) but
coarse: agreement is 0.7% / 0.6% for left / right against 5.1% / 3.2% for
top / bottom, the difference being 14 samples across the wall instead of
~800. The residual is quantisation, not model error.

Two fixes the work forced out:
- The popover was hardcoded to the shape dropdown. A second dropdown whose
  popover is neither registered nor rendered still OPENS on a press — it
  just opens invisibly, so its items cannot be hit and the widget reads as
  stone dead. Layout, paint, registration and event routing were all
  correct and it still did nothing.
- The default window is 700 tall now, not 480. The opening carries the
  section plus two bands, and the proportional axes are height-bound, so a
  short window squeezes the wall to a sliver.

 src/bin/cce-relief.rs | 191 +++++++++++++++++++++++++++++++++++++++++---------
 1 file changed, 159 insertions(+), 32 deletions(-)

diff --git a/src/bin/cce-relief.rs b/src/bin/cce-relief.rs
index 7cac0d9..485009a 100644
--- a/src/bin/cce-relief.rs
+++ b/src/bin/cce-relief.rs
@@ -85,6 +85,50 @@ enum Curve {
     Edge,
 }
 
+/// Which wall of the rect the section is taken through.
+///
+/// Not cosmetic. A carve's shading depends on the angle between the wall's
+/// outward normal and the DE light, so ONE profile reads four different ways
+/// around a control — the reason a seam's two rims never match (measured on
+/// cce-files' pane gap: 187 grey one side, 125 the other, same carve).
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Edge {
+    Left,
+    Right,
+    Top,
+    Bottom,
+}
+
+impl Edge {
+    const ALL: [Edge; 4] = [Edge::Left, Edge::Right, Edge::Top, Edge::Bottom];
+
+    fn label(self) -> &'static str {
+        match self {
+            Edge::Left => "Left wall",
+            Edge::Right => "Right wall",
+            Edge::Top => "Top wall",
+            Edge::Bottom => "Bottom wall",
+        }
+    }
+
+    /// The SDF gradient at the wall: the unit vector pointing OUT of the carve.
+    fn facing(self) -> [f32; 2] {
+        match self {
+            Edge::Left => [-1.0, 0.0],
+            Edge::Right => [1.0, 0.0],
+            Edge::Top => [0.0, -1.0],
+            Edge::Bottom => [0.0, 1.0],
+        }
+    }
+
+    /// Does the wall run horizontally? Then its shading varies DOWN the band
+    /// rather than along it, and both bands must be sampled that way to stay
+    /// comparable to each other.
+    fn horizontal_wall(self) -> bool {
+        matches!(self, Edge::Top | Edge::Bottom)
+    }
+}
+
 /// A relief shape, as a cross-section. This is the `Prim` family from
 /// `scene::paint` — the point of the picker is that the section shows the shape
 /// you will actually emit, not an idealised wall standing in for all of them.
@@ -340,8 +384,10 @@ impl ProfileKnobs {
 }
 
 struct BevelPopup {
-    /// Which profile the single section shows/edits: 0 = wall, 1 = edge.
+    /// Which SHAPE the section shows; the curve it edits follows from it.
     profile_dropdown: Adapted<Dropdown>,
+    /// Which wall of the rect the section is through — see [`Edge`].
+    edge_dropdown: Adapted<Dropdown>,
     /// The carve wall — what `carve_slope` renders on every
     /// recess/boss/ridge in the DE.
     wall: ProfileKnobs,
@@ -385,7 +431,7 @@ use cce_ui::widget::parse_bevel_knobs as parse_knobs;
 /// light azimuth. `has_floor` distinguishes the carve (wall meets a floor
 /// inside the material) from the roll (the surface drops to the silhouette
 /// and the material simply ends — air beyond the edge).
-fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Shape) {
+fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Shape, edge: Edge) {
     let has_floor = shape.has_floor();
     let radius = 6.0f32;
     let radii = (radius, radius, radius, radius);
@@ -470,13 +516,25 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
         gt += 0.25;
     }
 
+    // A Right or Bottom wall genuinely draws MIRRORED: a wall's outward normal
+    // always points at its plateau, so "plateau on the left" IS the left/top
+    // wall. Flipping the run keeps the drawing, the predicted band and the real
+    // swatch all describing the same physical wall — without it the prediction
+    // and the swatch disagree by a reflection, which reads as a shading bug.
+    let flip = matches!(edge, Edge::Right | Edge::Bottom);
+    let t_of = |x: f32| -> f32 {
+        if flip { (x1 - x) / unit } else { (x - x0) / unit }
+    };
+
     // Surface height at a section x.
     let surface_y = |x: f32| -> Option<f32> {
-        if x <= x0 {
+        let inside = if flip { x >= x0 } else { x <= x1 };
+        let outside = if flip { x >= x1 } else { x <= x0 };
+        if outside {
             Some(y_zero)
-        } else if x <= x1 {
+        } else if inside {
             shape
-                .height(profile, (x - x0) / unit)
+                .height(profile, t_of(x))
                 .map(|hv| y_zero + hv * unit)
         } else if has_floor {
             shape.height(profile, run).map(|hv| y_zero + hv * unit)
@@ -525,7 +583,7 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
     while rt <= run + 1e-3 {
         pc.text_with(
             format!("{rt:.2}"),
-            x0 + rt * unit - 11.0,
+            (if flip { x1 - rt * unit } else { x0 + rt * unit }) - 11.0,
             slab_bot + 3.0,
             10.0,
             num_color,
@@ -567,29 +625,42 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
         // dark side about 4:1, and over a pale swatch it reads the other way.
         let plate = cce_ui::color::page_low_color();
         let surface = [plate[0], plate[1], plate[2]];
+        // A HORIZONTAL wall's shading varies down the band, not along it: the
+        // run axis is y there, so the band becomes rows of one colour rather
+        // than columns. Both bands do this, so they still compare to each
+        // other — and at 14px the vertical version is roughly life size, since
+        // a real wall is 4.8-9.3 logical px.
+        let horiz = edge.horizontal_wall();
         let step = 1.0f32;
-        let mut x = x_l;
-        while x < x_r {
-            let t = (x - x0) / unit;
+        let (scan_lo, scan_hi) = if horiz { (strip_y, strip_y + strip_h) } else { (x_l, x_r) };
+        let mut x = scan_lo;
+        while x < scan_hi {
+            let t = if horiz {
+                // One wall across the band's height, centred like the swatch's.
+                // Bottom mirrors for the same reason Right does.
+                let raw = (x - (strip_y + strip_h * 0.5)) / strip_h + 0.5;
+                if flip { 1.0 - raw } else { raw }
+            } else {
+                t_of(x)
+            };
             let mut c = surface;
             for (mode, w0, w1) in &walls {
-                let u = (t - w0) / (w1 - w0);
+                let u = if horiz { t } else { (t - w0) / (w1 - w0) };
                 if !(-0.02..=1.02).contains(&u) {
                     continue;
                 }
-                // Facing: the SDF gradient along the section, pointing out of
-                // the carve. The section is drawn descending to the right, so
-                // that is -x. The OTHER three walls of a real rect face other
-                // ways and shade differently — the same profile reads brighter
-                // on one side of a control than the other, which is why a
-                // seam's two rims never match.
-                let v = relief_shade::carve_shade(*mode, u, [-1.0, 0.0], &slope_at, light, &mat);
+                // Facing: the SDF gradient at this wall, pointing out of the
+                // carve. THIS is what the edge selector changes, and it is the
+                // whole of the difference between the four walls.
+                let v = relief_shade::carve_shade(*mode, u, edge.facing(), &slope_at, light, &mat);
                 c = relief_shade::composite(c, v);
             }
-            pc.quad(
-                Rect { x, y: strip_y, width: step.min(x_r - x), height: strip_h },
-                [c[0], c[1], c[2], 1.0],
-            );
+            let band = if horiz {
+                Rect { x: x_l, y: x, width: x_r - x_l, height: step.min(scan_hi - x) }
+            } else {
+                Rect { x, y: strip_y, width: step.min(scan_hi - x), height: strip_h }
+            };
+            pc.quad(band, [c[0], c[1], c[2], 1.0]);
             x += step;
         }
         // THE LIVE SWATCH, directly under the prediction and on the same
@@ -622,12 +693,30 @@ fn draw_section(pc: &mut PaintCtx, rect: Rect, profile: &ProfileKnobs, shape: Sh
                     Rect { x: x_l, y: swatch_y, width: x_r - x_l, height: strip_h },
                     [plate[0], plate[1], plate[2], 1.0],
                 );
-                let tall = Rect {
-                    x: edge_x,
-                    y: swatch_y - 400.0,
-                    width: 4000.0,
-                    height: strip_h + 800.0,
+                // The box is placed so the SELECTED wall is the one crossing
+                // the band, and its other three edges are pushed far outside
+                // it. For a horizontal wall the box spans the band's width and
+                // its top/bottom edge sits at the band's mid-height, so the
+                // wall runs across the band at depth = strip_h.
+                let (tall, d_sw) = match edge {
+                    Edge::Left => (
+                        Rect { x: edge_x, y: swatch_y - 400.0, width: 4000.0, height: strip_h + 800.0 },
+                        unit,
+                    ),
+                    Edge::Right => (
+                        Rect { x: edge_x - 4000.0, y: swatch_y - 400.0, width: 4000.0, height: strip_h + 800.0 },
+                        unit,
+                    ),
+                    Edge::Top => (
+                        Rect { x: x_l - 400.0, y: swatch_y + strip_h * 0.5, width: (x_r - x_l) + 800.0, height: 4000.0 },
+                        strip_h,
+                    ),
+                    Edge::Bottom => (
+                        Rect { x: x_l - 400.0, y: swatch_y + strip_h * 0.5 - 4000.0, width: (x_r - x_l) + 800.0, height: 4000.0 },
+                        strip_h,
+                    ),
                 };
+                let unit = d_sw;
                 let sq = (0.0, 0.0, 0.0, 0.0);
                 match shape {
                     Shape::Recess | Shape::Fillet => pc.recess(tall, sq, unit),
@@ -719,14 +808,20 @@ impl BevelPopup {
             .unwrap_or(Shape::Recess)
     }
 
+    /// The wall the section is taken through.
+    fn active_edge(&self) -> Edge {
+        Edge::ALL.get(self.edge_dropdown.selected).copied().unwrap_or(Edge::Left)
+    }
+
     /// The curve the knobs are editing for the selected shape.
     fn active_curve(&self) -> Curve {
         self.active_shape().curve()
     }
 
-    fn root_ids(&self) -> [WidgetId; 11] {
+    fn root_ids(&self) -> [WidgetId; 12] {
         [
             self.profile_dropdown.id(),
+            self.edge_dropdown.id(),
             self.wall.shoulder.id(),
             self.wall.base.id(),
             self.wall.bias.id(),
@@ -740,9 +835,10 @@ impl BevelPopup {
         ]
     }
 
-    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 11] {
+    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 12] {
         [
             self.profile_dropdown.as_ptr_mut(),
+            self.edge_dropdown.as_ptr_mut(),
             self.wall.shoulder.as_ptr_mut(),
             self.wall.base.as_ptr_mut(),
             self.wall.bias.as_ptr_mut(),
@@ -759,6 +855,9 @@ impl BevelPopup {
     /// `take_*` plumbing after any routed dispatch — state-gated, so it does
     /// not matter which propagate call consumed the event.
     fn drain_widget_changes(&mut self) {
+        if self.edge_dropdown.take_change() {
+            self.needs_rebuild = true;
+        }
         if self.profile_dropdown.take_change() {
             // Switch which profile the section shows — re-arrange parks the
             // other set's knobs off-screen.
@@ -950,6 +1049,11 @@ impl Application for BevelPopup {
                 0,
             )
             .with_label("Shape"),
+            edge_dropdown: Dropdown::new(
+                Edge::ALL.iter().map(|e| e.label().to_string()).collect(),
+                0,
+            )
+            .with_label("Edge"),
             wall: ProfileKnobs::new(wall_seed),
             edge: ProfileKnobs::new(edge_seed),
             depth_slider: Slider::new()
@@ -996,7 +1100,11 @@ impl Application for BevelPopup {
             },
             app_id: "cce-relief".to_string(),
             width: 520,
-            height: 480,
+            // Taller than the old cce-bevel default: the opening now carries
+            // the section PLUS the predicted and real shading bands, and a
+            // short window squeezes the section's wall to a sliver because the
+            // proportional axes are height-bound.
+            height: 700,
             fullscreen: false,
             min_size: Some((440, 420)),
         }
@@ -1061,7 +1169,8 @@ impl Application for BevelPopup {
                 + 2.0 * CUT_MARGIN
                 + UNDERSIDE
                 + GUTTER_B
-                + SHADE_STRIP_H
+                + 2.0 * SHADE_STRIP_H
+                + 6.0
                 + GUTTER_B;
             let cut_h = (self.height as f32 - 2.0 * pad - fixed).min(natural).max(90.0);
 
@@ -1078,7 +1187,12 @@ impl Application for BevelPopup {
             };
 
             let mut y = pad;
-            self.profile_dropdown.set_rect(x, y, (w * 0.62).max(220.0).min(w), knob_h);
+            // Shape takes the row's left portion, Edge the rest — one row,
+            // because the cutaway is what should get the spare height.
+            let edge_w = (w * 0.32).max(120.0).min(w * 0.5);
+            let shape_w = (w - edge_w - gap).max(140.0);
+            self.profile_dropdown.set_rect(x, y, shape_w, knob_h);
+            self.edge_dropdown.set_rect(x + shape_w + gap, y, edge_w, knob_h);
             y += knob_h + gap;
             self.cut_rect = Rect { x, y, width: w, height: cut_h };
             y += cut_h + gap;
@@ -1108,10 +1222,19 @@ impl Application for BevelPopup {
             self.ui_context.rebuild_spatial_grid();
         }
 
+        // BOTH selectors, not just the shape one. A dropdown whose popover is
+        // neither registered nor rendered still OPENS on a press — it just
+        // opens invisibly, so its items cannot be hit and the widget reads as
+        // completely dead. That is what adding the Edge selector looked like
+        // until this list grew: paint, layout, registration and routing were
+        // all correct and the thing still did nothing.
         self.ui_context.clear_popovers();
         if self.profile_dropdown.popover_rect().is_some() {
             self.ui_context.register_popover(&mut self.profile_dropdown);
         }
+        if self.edge_dropdown.popover_rect().is_some() {
+            self.ui_context.register_popover(&mut self.edge_dropdown);
+        }
 
         let mut pc = PaintCtx::new();
         let (w, h) = (self.width as f32, self.height as f32);
@@ -1143,7 +1266,7 @@ impl Application for BevelPopup {
         let shape = self.active_shape();
         let wall_active = shape.curve() == Curve::Wall;
         let active = if wall_active { &self.wall } else { &self.edge };
-        draw_section(&mut pc, self.cut_rect, active, shape);
+        draw_section(&mut pc, self.cut_rect, active, shape, self.active_edge());
 
         let knobs = if wall_active { &self.wall } else { &self.edge };
         for s in [
@@ -1155,6 +1278,7 @@ impl Application for BevelPopup {
         ] {
             cce_ui::scene::painter::paint_root_into(&self.ui_context, s, &mut pc);
         }
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.edge_dropdown, &mut pc);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.profile_dropdown, &mut pc);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.save_button, &mut pc);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.reset_button, &mut pc);
@@ -1166,6 +1290,9 @@ impl Application for BevelPopup {
             // dropdown's expanded inset-plate surface) with its own bounds.
             self.profile_dropdown.render_popover(&mut pc);
         }
+        if self.edge_dropdown.popover_rect().is_some() {
+            self.edge_dropdown.render_popover(&mut pc);
+        }
 
         // The shared context menu (slider Copy/Paste), last, on top.
         if self.ui_context.is_context_menu_visible() {