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

commita5f079e6ed49debb2d608dfa9d9005e6e5e21302
parent79962502fa
authorLucas Galante <[email protected]>
date2026-09-24 21:58
feat: the viewport menu sets the display mode — wireframe, flat or smooth shading, polygon opacity

The viewport's right-click menu gains a display section: the Show
Wireframe switch, Flat / Smooth Shading as a radio pair over a new
toggle_smooth_shading command, and polygon opacity presets that land
through the palette's Geometry Opacity row.

Smooth shading is baked: the raster light is world-fixed, so each corner
is lit from its smooth point normal (geometry::smooth_lit_vertices, with
shade_factor matching the shader's derivative-normal convention) and the
fill draws with cce-ui's new SceneDraw::prelit. The path tracer keeps the
unlit colours. Persisted as render.smooth_shading. cce-ui pinned to
8c2f2d3, which adds the flag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

 CLAUDE.md       |  30 +++++++++++
 Cargo.toml      |   2 +-
 src/app.rs      | 160 ++++++++++++++++++++++++++++++++++++++++++++------------
 src/command.rs  |   1 +
 src/dialog.rs   |   1 +
 src/geometry.rs |  52 ++++++++++++++++++
 src/main.rs     | 102 ++++++++++++++++++++++++++++++++++++
 src/render.rs   |   6 +++
 src/shortcut.rs |   2 +
 src/vk_smoke.rs |   6 +--
 10 files changed, 324 insertions(+), 38 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b6af66b..bbdff4e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1353,6 +1353,36 @@ Houdini uses: bare hjkl is the cursor, and shift+hjkl is reserved for the
 select family this app cannot implement until the Graph widget has
 multi-selection, so taking `Shift+L` now would have to be given back later.
 
+### Display mode: the viewport menu, and smooth shading
+
+The viewport's right-click menu carries the DISPLAY MODE under Frame All
+and View 1:1: the Show Wireframe switch (its registry command), **Flat
+Shading / Smooth Shading** as a radio pair over `toggle_smooth_shading`,
+and the polygon **Opacity** as presets (`VIEWPORT_OPACITIES`, landing
+through `apply_setting("Geometry Opacity", …)` so the palette's row, the
+persist and the menu are one path — a menu cannot hold a slider, and an
+opacity set off the presets marks none of them). `viewport_menu_rows` and
+`run_viewport_menu_action` are split from the open and the click so a test
+reads and runs the rows.
+
+**Smooth shading is baked, not shaded.** The raster pass flat-shades every
+fill in `scene3d.wgsl` from screen-space derivative normals, and cce-ui's
+`Vertex3D` carries no normal. The light is fixed in WORLD space, though, so
+lighting each vertex from its smooth point normal and interpolating is
+exact: `geometry::smooth_lit_vertices` multiplies each corner's colour by
+`shade_factor(point_normals[p])`, and the fill draws with
+`SceneDraw::prelit` (cce-ui, 2026-09-24) so the shader does not shade it
+twice. `shade_factor` has to agree with the shader about which side is
+lit: the shader's normal is screen-right × framebuffer-DOWN, which for any
+visible surface points AWAY from the viewer — into the surface — so the
+bake uses `dot(-n_outward, l)`; on a plane the two modes give identical
+brightness (`smooth_shading_bakes_the_flat_shaders_light_per_vertex`). The
+lit copy is `State::scene_smooth_verts`, kept only while smooth is on; the
+path tracer keeps reading the unlit `rt_sphere_verts`, whose colours are
+its materials. Smoothing follows topology, so a welded mesh rounds off and
+a soup of unshared triangles stays faceted. Persisted as
+`render.smooth_shading` in state.kdl.
+
 ### Dragging the scene orbits the camera
 
 `State::orbit_camera_by` turns the camera by a drag delta, armed by a left
diff --git a/Cargo.toml b/Cargo.toml
index 049de0a..53fd969 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ version = "0.1.0"
 edition = "2021"
 
 [dependencies]
-cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "f3c970be244ad54121021a8223986ba553e09838" }
+cce-ui = { git = "https://github.com/lsgalante/cce-ui.git", rev = "8c2f2d334caebcb5c1217fd0247303c333192ad7" }
 smithay-client-toolkit = "0.19.2"
 calloop = "0.13.0"
 calloop-wayland-source = "0.3.0"
diff --git a/src/app.rs b/src/app.rs
index ff33f00..24b8ca8 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -405,7 +405,7 @@ pub enum ParamMenuAction {
     Separator,
 }
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[derive(Debug, Clone, Copy, PartialEq)]
 pub enum ViewportMenuAction {
     /// Move the active camera so the visible node geometry fills the view.
     FrameAll,
@@ -416,10 +416,25 @@ pub enum ViewportMenuAction {
     PinFollow,
     /// Lock the viewport to one editor's level (CONTENT_IDX / CONTENT2_IDX).
     PinTo(usize),
+    /// Run a registry command — the display toggles, so the menu's rows are
+    /// the palette's and a row is exactly as scriptable as its command.
+    Command(&'static str),
+    /// Set the shading mode: smooth (true) or flat. A radio pair over the
+    /// one `toggle_smooth_shading` flag, running the toggle only when the
+    /// pick differs, so picking the mode already on is a no-op rather than
+    /// a flip.
+    Shading(bool),
+    /// Set the polygon (geometry fill) opacity to one of `VIEWPORT_OPACITIES`
+    /// — a menu cannot hold a slider, and the palette's Geometry Opacity row
+    /// is the fine control.
+    Opacity(f32),
     /// A "-" row: engraved, inert.
     Separator,
 }
 
+/// The polygon opacities the viewport menu offers, highest first.
+pub const VIEWPORT_OPACITIES: [f32; 4] = [1.0, 0.75, 0.5, 0.25];
+
 /// The network editor's right-click context menu (on empty space — a press on
 /// a node still opens that node's menu). Every row but the separator names a
 /// COMMAND ID rather than a piece of work, so the labels cannot drift from the
@@ -1157,6 +1172,11 @@ pub struct RenderSettings {
     /// 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,
 }
 
 fn default_group_marker_scale() -> f32 {
@@ -1195,6 +1215,7 @@ impl Default for RenderSettings {
             point_size: default_point_size(),
             point_color: default_point_color(),
             group_marker_scale: default_group_marker_scale(),
+            smooth_shading: false,
         }
     }
 }
@@ -1994,6 +2015,15 @@ pub struct State {
     /// Selected-Group marker radius as a multiple of `point_size` (a
     /// setting row of the dialog; persisted in the render block).
     pub group_marker_scale: f32,
+    /// Smooth shading of the scene fill (`toggle_smooth_shading`). While on,
+    /// `scene_smooth_verts` holds the lit fill and the draw is `prelit`.
+    pub smooth_shading: bool,
+    /// The scene fill with smooth shading baked into its colours
+    /// (`geometry::smooth_lit_vertices`), built by `rebuild_scene_geometry`
+    /// while `smooth_shading` is on and empty otherwise. The raster pass
+    /// uploads this in place of `rt_sphere_verts`, which the path tracer
+    /// keeps reading unlit.
+    pub scene_smooth_verts: Vec<Vertex3D>,
     /// (geometry version, quantized size, color) the points mesh was last
     /// built from; `point_vertex_count` gates the draw.
     pub last_points_key: Option<(u64, i32, [u8; 3])>,
@@ -2213,6 +2243,7 @@ impl State {
                 point_size: self.point_size,
                 point_color: self.point_color,
                 group_marker_scale: self.group_marker_scale,
+                smooth_shading: self.smooth_shading,
             },
             default_project: self.default_project_setting.clone(),
         };
@@ -4142,8 +4173,44 @@ impl State {
 
     /// Open the viewport right-click context menu at the cursor.
     fn open_viewport_context_menu(&mut self) {
+        let (options, actions) = self.viewport_menu_rows();
+        let target = self.slots.viewport.id();
+        cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
+        self.viewport_menu_active = true;
+        self.viewport_menu_actions = actions;
+    }
+
+    /// The viewport menu's rows and what each does: framing, then the
+    /// DISPLAY MODE — the wireframe switch, flat or smooth shading as a
+    /// radio pair, and the polygon opacity as a radio group of presets —
+    /// then the editor pin. Split from the open so a test can read it.
+    ///
+    /// Marks are the ●/○ the pin rows and the network menu use. An opacity
+    /// set to anything off the presets (the palette's slider) marks none of
+    /// them, which says so honestly rather than rounding to the nearest.
+    pub(crate) fn viewport_menu_rows(&self) -> (Vec<String>, Vec<ViewportMenuAction>) {
         let mut options = vec!["Frame All".to_string(), "View 1:1".to_string()];
         let mut actions = vec![ViewportMenuAction::FrameAll, ViewportMenuAction::OneToOne];
+        let mark = |on: bool| if on { "●" } else { "○" };
+
+        options.push("-".to_string());
+        actions.push(ViewportMenuAction::Separator);
+        let wire_label = crate::command::by_id("toggle_wireframe").map(|c| c.label).unwrap_or("Show Wireframe");
+        options.push(format!("{} {wire_label}", mark(self.wireframe)));
+        actions.push(ViewportMenuAction::Command("toggle_wireframe"));
+        options.push(format!("{} Flat Shading", mark(!self.smooth_shading)));
+        actions.push(ViewportMenuAction::Shading(false));
+        options.push(format!("{} Smooth Shading", mark(self.smooth_shading)));
+        actions.push(ViewportMenuAction::Shading(true));
+
+        options.push("-".to_string());
+        actions.push(ViewportMenuAction::Separator);
+        for o in VIEWPORT_OPACITIES {
+            let on = (self.geo_opacity - o).abs() < 0.005;
+            options.push(format!("{} Opacity {}%", mark(on), (o * 100.0).round() as i32));
+            actions.push(ViewportMenuAction::Opacity(o));
+        }
+
         // The viewport's editor binding, as a radio group: follow the active
         // editor, or pin to one. Pin rows appear only while a second editor
         // exists — with one editor, following IS pinned.
@@ -4164,10 +4231,41 @@ impl State {
             ));
             actions.push(ViewportMenuAction::PinTo(crate::slots::CONTENT2_IDX));
         }
-        let target = self.slots.viewport.id();
-        cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
-        self.viewport_menu_active = true;
-        self.viewport_menu_actions = actions;
+        (options, actions)
+    }
+
+    /// Run one viewport menu row — the click path, and the tests'.
+    pub(crate) fn run_viewport_menu_action(&mut self, action: ViewportMenuAction) {
+        match action {
+            ViewportMenuAction::FrameAll => {
+                self.frame_all();
+            }
+            ViewportMenuAction::OneToOne => {
+                self.view_one_to_one();
+            }
+            ViewportMenuAction::PinFollow => {
+                self.viewport_pin = None;
+                self.rebuild_scene_geometry();
+            }
+            ViewportMenuAction::PinTo(e) => {
+                self.viewport_pin = Some(e);
+                self.rebuild_scene_geometry();
+            }
+            ViewportMenuAction::Command(id) => {
+                self.run_command(id);
+            }
+            ViewportMenuAction::Shading(smooth) => {
+                if self.smooth_shading != smooth {
+                    self.run_command("toggle_smooth_shading");
+                }
+            }
+            ViewportMenuAction::Opacity(o) => {
+                // The palette's Geometry Opacity row, so the write, the
+                // persist and the dialog's re-read are the one path.
+                self.apply_setting("Geometry Opacity", &format!("{o:.2}"));
+            }
+            ViewportMenuAction::Separator => {}
+        }
     }
 
     pub fn viewport_menu_open(&self) -> bool {
@@ -4191,23 +4289,7 @@ impl State {
             let picked = idx.and_then(|i| self.viewport_menu_actions.get(i).copied());
             self.close_viewport_menu();
             if let Some(action) = picked {
-                match action {
-                    ViewportMenuAction::FrameAll => {
-                        self.frame_all();
-                    }
-                    ViewportMenuAction::OneToOne => {
-                        self.view_one_to_one();
-                    }
-                    ViewportMenuAction::PinFollow => {
-                        self.viewport_pin = None;
-                        self.rebuild_scene_geometry();
-                    }
-                    ViewportMenuAction::PinTo(e) => {
-                        self.viewport_pin = Some(e);
-                        self.rebuild_scene_geometry();
-                    }
-                    ViewportMenuAction::Separator => {}
-                }
+                self.run_viewport_menu_action(action);
             }
             return true;
         }
@@ -5180,6 +5262,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             point_size: settings.render.point_size,
             point_color: settings.render.point_color,
             group_marker_scale: settings.render.group_marker_scale,
+            smooth_shading: settings.render.smooth_shading,
+            scene_smooth_verts: Vec::new(),
             last_points_key: None,
             point_vertex_count: 0,
             last_viewport_render_points: false,
@@ -6805,6 +6889,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 // rebuild — there is nothing staged to draw otherwise.
                 self.rebuild_scene_geometry();
             }
+            // The smooth-lit fill is baked with the scene and dropped while
+            // flat, so the flip rebuilds, as the wireframe's does.
+            Action::ToggleSmoothShading => {
+                self.smooth_shading = !self.smooth_shading;
+                self.rebuild_scene_geometry();
+                settings_changed = true;
+            }
             // The three point overlays. Each is collected in
             // `rebuild_scene_geometry` off the scene's own Detail, so the
             // flip has to re-run it: the meshes are built from the flags,
@@ -8985,7 +9076,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         }
         if self.spheres_dirty {
             self.spheres_dirty = false;
-            renderer.update_mesh(meshes.spheres, bytemuck::cast_slice(&self.rt_sphere_verts));
+            let fill = if self.smooth_shading { &self.scene_smooth_verts } else { &self.rt_sphere_verts };
+            renderer.update_mesh(meshes.spheres, bytemuck::cast_slice(fill));
             // Edge mesh for the wire pass, collected with the scene.
             renderer.update_mesh(meshes.sphere_edges, bytemuck::cast_slice(&self.scene_edge_verts));
         }
@@ -9240,36 +9332,36 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     // furniture stays opaque.
                     const NO_TINT: [f32; 4] = [0.0; 4];
                     let geo_opacity = self.geo_opacity.clamp(0.0, 1.0);
-                    let mut draws = vec![SceneDraw { mesh: meshes.viewport_bg, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 }];
+                    let mut draws = vec![SceneDraw { mesh: meshes.viewport_bg, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false }];
                     if self.viewport().show_grid {
-                        draws.push(SceneDraw { mesh: meshes.grid, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
+                        draws.push(SceneDraw { mesh: meshes.grid, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false });
                     }
                     if self.viewport().show_origin {
-                        draws.push(SceneDraw { mesh: meshes.origin, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
+                        draws.push(SceneDraw { mesh: meshes.origin, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false });
                     }
                     if self.viewport().show_camera_pivot {
-                        draws.push(SceneDraw { mesh: meshes.pivot, mvp: mvp_pivot, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
+                        draws.push(SceneDraw { mesh: meshes.pivot, mvp: mvp_pivot, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false });
                     }
                     if self.viewport().show_cube {
-                        draws.push(SceneDraw { mesh: meshes.cube, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
+                        draws.push(SceneDraw { mesh: meshes.cube, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false });
                     }
                     if self.render_points && self.point_vertex_count > 0 {
-                        draws.push(SceneDraw { mesh: meshes.points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: geo_opacity, line_width: 1.0, wire_base_width: 0.0 });
+                        draws.push(SceneDraw { mesh: meshes.points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: geo_opacity, line_width: 1.0, wire_base_width: 0.0, prelit: false });
                     }
                     // Selected-Group markers: full-opacity selection feedback,
                     // deliberately outside the Render node's Opacity.
                     if self.group_point_vertex_count > 0 {
-                        draws.push(SceneDraw { mesh: meshes.group_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
+                        draws.push(SceneDraw { mesh: meshes.group_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false });
                     }
                     // Per-node meta "Point Markers", same full-opacity tier.
                     if self.overlay_point_count > 0 {
-                        draws.push(SceneDraw { mesh: meshes.overlay_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
+                        draws.push(SceneDraw { mesh: meshes.overlay_points, mvp, wireframe: false, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false });
                     }
                     if self.vertex_count_spheres > 0 {
                         // With wires coming, the fill is pushed back by its
                         // slope-scaled offset so the lattice reads solid.
                         let base = if self.wireframe { self.wire_width } else { 0.0 };
-                        draws.push(SceneDraw { mesh: meshes.spheres, mvp, wireframe: false, wire_tint: NO_TINT, opacity: geo_opacity, line_width: 1.0, wire_base_width: base });
+                        draws.push(SceneDraw { mesh: meshes.spheres, mvp, wireframe: false, wire_tint: NO_TINT, opacity: geo_opacity, line_width: 1.0, wire_base_width: base, prelit: self.smooth_shading });
                         if self.wireframe {
                             // The wire pass rides ON TOP of the fill (never
                             // replaces it). Single-color mode replaces the
@@ -9288,13 +9380,13 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                                 [0.0, 0.0, 0.0, 0.0]
                             };
                             let wire_alpha = self.wire_color[3].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 });
+                            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 });
                         }
                         // Show Point Normals: thin cyan whiskers,
                         // width deliberately fixed (a chunky Wire Width is a
                         // wireframe styling choice, not a normals one).
                         if self.overlay_normal_count > 0 {
-                            draws.push(SceneDraw { mesh: meshes.overlay_normals, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 });
+                            draws.push(SceneDraw { mesh: meshes.overlay_normals, mvp, wireframe: true, wire_tint: NO_TINT, opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false });
                         }
                     }
                     renderer.stage_scene((sx, sy, cw, ch), draws);
diff --git a/src/command.rs b/src/command.rs
index c6b0a29..40ca7d8 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -189,6 +189,7 @@ pub const COMMANDS: &[Command] = &[
     Command { id: "toggle_origin", label: "Show Origin", context: Context::Viewport, run: Run::Key(Action::ToggleOrigin), default_chord: None },
     Command { id: "toggle_camera_pivot", label: "Show Camera Pivot", context: Context::Viewport, run: Run::Key(Action::ToggleCameraPivot), default_chord: None },
     Command { id: "toggle_wireframe", label: "Show Wireframe", context: Context::Viewport, run: Run::Key(Action::ToggleWireframe), default_chord: None },
+    Command { id: "toggle_smooth_shading", label: "Smooth Shading", context: Context::Viewport, run: Run::Key(Action::ToggleSmoothShading), default_chord: None },
     // The point overlays on the visible scene. Per-node `meta` child
     // preferences until 2026-09-23; global display settings now, reached
     // here like every other viewport toggle.
diff --git a/src/dialog.rs b/src/dialog.rs
index 401d414..0c5589a 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1609,6 +1609,7 @@ impl State {
             "toggle_origin" => self.viewport().show_origin,
             "toggle_camera_pivot" => self.viewport().show_camera_pivot,
             "toggle_wireframe" => self.wireframe,
+            "toggle_smooth_shading" => self.smooth_shading,
             "toggle_point_markers" => self.show_point_markers,
             "toggle_point_numbers" => self.show_point_numbers,
             "toggle_point_normals" => self.show_point_normals,
diff --git a/src/geometry.rs b/src/geometry.rs
index 09e13ee..adaef4c 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -124,6 +124,58 @@ pub fn detail_vertices(d: &Detail) -> Vec<Vertex3D> {
     d.triangulate(|position, color| Vertex3D { position, color })
 }
 
+/// The raster pass's light, in WORLD space — `scene3d.wgsl`'s `l`, which the
+/// smooth bake below has to match or switching shading modes would move
+/// the lit side of the model.
+pub const SCENE_LIGHT: [f32; 3] = [-0.55, 0.45, 0.7];
+
+/// The raster pass's shading factor for a surface whose OUTWARD normal is
+/// `n` — the multiplier `scene3d.wgsl` applies to a fragment's colour.
+///
+/// The shader's normal is `cross(dpdx(world), dpdy(world))`: screen right
+/// crossed with framebuffer DOWN, which for any visible surface points away
+/// from the viewer — into the surface. So the shader's `dot(n, l)` is this
+/// function's `dot(-n, l)`, and the wrap term and the 0.55 floor are its
+/// own. A zero normal (a point on no primitive) takes the midpoint.
+pub fn shade_factor(n: Vec3) -> f32 {
+    let l = Vec3::from_array(SCENE_LIGHT).normalize();
+    let d = if n.length_squared() > 0.0 { ((-n).dot(l) * 0.5 + 0.5).clamp(0.0, 1.0) } else { 0.5 };
+    0.55 + 0.45 * d
+}
+
+/// The scene's fill mesh with SMOOTH shading baked into its colours: each
+/// corner lit by its point's smooth normal (`point_normals`) under the
+/// raster pass's own world-fixed light, then drawn `prelit` so the shader
+/// adds nothing. Because the light never moves with the camera, lighting
+/// per vertex and interpolating is exact — Gouraud shading with no normal
+/// in the vertex format.
+///
+/// Smoothing follows the TOPOLOGY: points shared between primitives average
+/// their faces, so a welded mesh rounds off, while a soup of unshared
+/// triangles or a cut along a seam stays faceted there — as it would in
+/// any smooth-shaded viewport. Colours here only; the path tracer keeps
+/// reading the unlit ones.
+pub fn smooth_lit_vertices(d: &Detail) -> Vec<Vertex3D> {
+    let normals = point_normals(d);
+    let lit: Vec<f32> = normals.iter().map(|n| shade_factor(*n)).collect();
+    let mut out = Vec::new();
+    for prim in 0..d.num_prims() {
+        let pts = d.prim_points(prim);
+        if pts.len() < 3 {
+            continue;
+        }
+        for i in 1..pts.len() - 1 {
+            for &p in &[pts[0], pts[i], pts[i + 1]] {
+                let p = p as usize;
+                let k = lit.get(p).copied().unwrap_or(1.0);
+                let c = d.color(p);
+                out.push(Vertex3D { position: d.pos(p).to_array(), color: [c[0] * k, c[1] * k, c[2] * k] });
+            }
+        }
+    }
+    out
+}
+
 /// Fan-triangulate a [`Detail`] back into the triangle soup the evaluation
 /// pipeline still speaks, carrying attributes onto every corner.
 ///
diff --git a/src/main.rs b/src/main.rs
index 5fa0f33..62b9aa2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1384,6 +1384,106 @@ mod tests {
         assert_eq!(m.match_command(&plain, &Key::Named(NamedKey::ArrowDown)), Some("play_pause_reverse"));
     }
 
+    /// Smooth shading bakes the raster pass's own light, so on a PLANE —
+    /// where every point normal is the face normal — it gives exactly the
+    /// flat shader's factor at every corner: switching modes changes how
+    /// curved surfaces read, not the brightness of flat ones. On a closed
+    /// sphere the corners of one face differ, which is what smooth means.
+    #[test]
+    fn smooth_shading_bakes_the_flat_shaders_light_per_vertex() {
+        use crate::geometry::{shade_factor, smooth_lit_vertices, sphere_detail, detail_vertices};
+        use glam::Vec3;
+        // The shader's normal faces away from the viewer, so a face turned
+        // TOWARD the light (outward normal along l) is its darkest, and one
+        // turned away its brightest — the bake keeps that convention.
+        let l = Vec3::from_array(crate::geometry::SCENE_LIGHT).normalize();
+        assert!((shade_factor(l) - 0.55).abs() < 1e-5);
+        assert!((shade_factor(-l) - 1.0).abs() < 1e-5);
+        assert!((shade_factor(Vec3::ZERO) - (0.55 + 0.45 * 0.5)).abs() < 1e-5);
+
+        // A single quad in the XZ plane, wound to face +y.
+        let mut quad = crate::detail::Detail::new();
+        let a = quad.add_point(Vec3::new(0.0, 0.0, 0.0));
+        let b = quad.add_point(Vec3::new(0.0, 0.0, 1.0));
+        let c = quad.add_point(Vec3::new(1.0, 0.0, 1.0));
+        let d = quad.add_point(Vec3::new(1.0, 0.0, 0.0));
+        quad.add_prim(&[a, b, c, d]);
+        let n = crate::geometry::point_normals(&quad)[0];
+        assert!((n - Vec3::Y).length() < 1e-5, "the quad faces +y: {n}");
+        let lit = smooth_lit_vertices(&quad);
+        let flat = detail_vertices(&quad);
+        assert_eq!(lit.len(), flat.len(), "same triangles as the unlit fill");
+        let k = shade_factor(Vec3::Y);
+        for (l, f) in lit.iter().zip(&flat) {
+            assert_eq!(l.position, f.position);
+            for ch in 0..3 {
+                assert!((l.color[ch] - f.color[ch] * k).abs() < 1e-5);
+            }
+        }
+
+        // A closed UV sphere: same triangle list, and corners of one face
+        // no longer share one brightness.
+        let sphere = sphere_detail(Vec3::ZERO, 1.0, 12, 16);
+        let lit = smooth_lit_vertices(&sphere);
+        assert_eq!(lit.len(), detail_vertices(&sphere).len());
+        let varied = lit.chunks(3).filter(|t| {
+            let b = |v: &crate::geometry::Vertex3D| v.color[0] + v.color[1] + v.color[2];
+            (b(&t[0]) - b(&t[1])).abs() > 1e-4 || (b(&t[0]) - b(&t[2])).abs() > 1e-4
+        }).count();
+        assert!(varied > lit.len() / 6, "smooth shading varies across faces: {varied}");
+    }
+
+    /// The viewport's right-click menu sets the display mode: the wireframe
+    /// switch, flat or smooth shading as a radio pair, and the polygon
+    /// opacity as presets — each mark reading the live state, each row
+    /// landing on it.
+    #[test]
+    fn the_viewport_menu_sets_the_display_mode() {
+        use crate::app::ViewportMenuAction as A;
+        let mut state = State::new(false);
+        state.wireframe = false;
+        state.smooth_shading = false;
+        state.geo_opacity = 1.0;
+        let row = |state: &State, a: A| {
+            let (options, actions) = state.viewport_menu_rows();
+            let i = actions.iter().position(|x| *x == a).unwrap_or_else(|| panic!("no {a:?} row"));
+            options[i].clone()
+        };
+        assert!(row(&state, A::Command("toggle_wireframe")).starts_with('○'));
+        assert!(row(&state, A::Shading(false)).starts_with('●'));
+        assert!(row(&state, A::Shading(true)).starts_with('○'));
+        assert!(row(&state, A::Opacity(1.0)).starts_with('●'));
+        assert_eq!(row(&state, A::Opacity(0.5)), "○ Opacity 50%");
+
+        state.run_viewport_menu_action(A::Command("toggle_wireframe"));
+        assert!(state.wireframe);
+        assert!(row(&state, A::Command("toggle_wireframe")).starts_with('●'));
+
+        // Smooth: the flag, and a lit raster fill beside the unlit one the
+        // path tracer reads, triangle for triangle.
+        state.run_viewport_menu_action(A::Shading(true));
+        assert!(state.smooth_shading);
+        assert_eq!(state.scene_smooth_verts.len(), state.rt_sphere_verts.len());
+        assert!(!state.scene_smooth_verts.is_empty(), "the bundled scene draws something");
+        // Picking the mode already on is not a flip.
+        state.run_viewport_menu_action(A::Shading(true));
+        assert!(state.smooth_shading);
+        assert!(row(&state, A::Shading(true)).starts_with('●'));
+        assert_eq!(state.command_toggle_state("toggle_smooth_shading"), Some(true));
+        state.run_viewport_menu_action(A::Shading(false));
+        assert!(!state.smooth_shading);
+        assert!(state.scene_smooth_verts.is_empty(), "flat keeps no lit copy");
+
+        state.run_viewport_menu_action(A::Opacity(0.5));
+        assert!((state.geo_opacity - 0.5).abs() < 1e-6);
+        assert!(row(&state, A::Opacity(0.5)).starts_with('●'));
+        assert!(row(&state, A::Opacity(1.0)).starts_with('○'));
+        // An opacity off the presets marks none of them.
+        state.apply_setting("Geometry Opacity", "0.33");
+        let (options, _) = state.viewport_menu_rows();
+        assert!(options.iter().filter(|o| o.contains("Opacity")).all(|o| o.starts_with('○')));
+    }
+
     /// The dialog plate carries its own backdrop compression, above a
     /// menu's: whatever the plates' own is (0 in a config that keeps the
     /// panes clear), the modal pulls its backdrop toward the tint, and a
@@ -4697,6 +4797,7 @@ mod tests {
         a.point_size = 0.05;
         a.point_color = [0.0, 1.0, 0.0];
         a.group_marker_scale = 2.5;
+        a.smooth_shading = true;
         a.save_settings();
 
         let kdl = std::fs::read_to_string(DesignSettings::file_path()).expect("state.kdl was written");
@@ -4734,6 +4835,7 @@ mod tests {
         assert!(back.render.render_points);
         assert!(close(back.render.point_size, 0.05));
         assert!(close(back.render.group_marker_scale, 2.5));
+        assert!(back.render.smooth_shading);
     }
 
     /// Changing the wire colour turns single-colour mode on, so the colour
diff --git a/src/render.rs b/src/render.rs
index 2523076..7bebf50 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1145,6 +1145,12 @@ impl State {
 
         let verts = crate::geometry::detail_vertices(&geom);
         self.vertex_count_spheres = verts.len() as u32;
+        // Smooth shading bakes the light into a raster copy of the fill;
+        // `verts` stays unlit for the path tracer, whose materials are
+        // these colours. Same triangles in the same order, so the count
+        // above serves both.
+        self.scene_smooth_verts =
+            if self.smooth_shading { crate::geometry::smooth_lit_vertices(&geom) } else { Vec::new() };
         // Cache for the path tracer, so RT mode never re-runs the node
         // graph; the version bump invalidates its scene.
         // The raster mesh uploads from this same cache on the next
diff --git a/src/shortcut.rs b/src/shortcut.rs
index d677b1a..6988a2e 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -11,6 +11,8 @@ pub enum Action {
     ToggleOrigin,
     ToggleCameraPivot,
     ToggleWireframe,
+    /// Flat (faceted) or smooth shading of the scene fill.
+    ToggleSmoothShading,
     /// The three point overlays on the visible scene — markers, index
     /// numbers, normal whiskers. Global display settings reached from the
     /// command palette; they were per-node `meta` child preferences until
diff --git a/src/vk_smoke.rs b/src/vk_smoke.rs
index 574cfd8..496e664 100644
--- a/src/vk_smoke.rs
+++ b/src/vk_smoke.rs
@@ -474,9 +474,9 @@ fn main() {
                 renderer.stage_scene(
                     pane,
                     vec![
-                        SceneDraw { mesh: bg_mesh, mvp: Mat4::IDENTITY.to_cols_array_2d(), wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 },
-                        SceneDraw { mesh: grid_mesh, mvp, wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 },
-                        SceneDraw { mesh: cube_mesh, mvp, wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0 },
+                        SceneDraw { mesh: bg_mesh, mvp: Mat4::IDENTITY.to_cols_array_2d(), wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false },
+                        SceneDraw { mesh: grid_mesh, mvp, wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false },
+                        SceneDraw { mesh: cube_mesh, mvp, wireframe: false, wire_tint: [0.0; 4], opacity: 1.0, line_width: 1.0, wire_base_width: 0.0, prelit: false },
                     ],
                 );
             }