graphic design tool
git clone https://git.lucas.co/cce-designer.git
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 },
],
);
}