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

commit72866e9d2e13e371d51f6f2741e6db21804920cf
parent7a0ef4eaed
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 12:47
feat: attribute visualizers, edited in the dialog

Houdini-style viewport visualizers: a point attribute of the displayed
scene coloured through a ramp or drawn as vectors, with no node in the
graph. Each is the Visualize node's settings and runs through the node's
own apply_visualize. They are display settings (state.kdl and the project's
display block), applied to the evaluated scene in a new present_scene, so
an edit re-presents the cached scene without evaluating the graph.

The attribute_visualizers command (palette, viewport menu) opens the
dialog's Visualizers list: a switch each, Add Visualizer, and a row that
opens the visualizer's settings page (Enabled, Attribute, Mode, Ramp,
Range, From/To, Blend, Opacity, Scale, Group, Delete, Back).

Stored as an escaped key=value string: cce-ui's KDL writer does not escape
quotes inside strings, so a JSON string would not read back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md         |  56 +++++++
 src/app.rs        |  35 +++++
 src/command.rs    |   1 +
 src/dialog.rs     | 152 +++++++++++++++++-
 src/main.rs       | 111 +++++++++++++-
 src/render.rs     |  44 ++++--
 src/visualizer.rs | 448 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 832 insertions(+), 15 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a7aa967..c00f3f0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2930,6 +2930,59 @@ simulation rebuilds at every frame, and the numbers flickered
 the whiskers' order has none, being a draw list only a renderer reads, and
 was checked in a shadow session before and after.
 
+### Attribute visualizers
+
+`src/visualizer.rs` (since 2026-10-01) is Houdini's viewport visualizers:
+a point attribute of whatever the viewport displays, coloured through a
+ramp or drawn as a line from each point, with NO node in the graph. A
+`Visualizer` is the Visualize node's settings under the node's own names
+and options (Attribute, Mode, Ramp, Range, From/To, Blend, Opacity, Scale,
+Group) plus a switch, and it runs through `geometry::apply_visualize` over
+a node built from them (`Visualizer::as_node`), so a visualizer and a
+Visualize node cannot disagree about what they draw
+(`a_visualizer_reads_as_the_visualize_node_does`). Several apply in order,
+the later over the earlier, as a chain of Visualize nodes composites.
+
+- **They are display settings**, by the rule "There are no meta nodes"
+  states: `State::visualizers`, persisted in the viewport block of
+  state.kdl and with the project's display block, so a project keeps its
+  own. **As one string** (`visualizer::encode` / `decode`: `key=value`
+  joined by `|`, a visualizer per `;`, percent-escaped) — and not JSON,
+  because cce-ui's `json_to_kdl_string` writes a string between quotes
+  WITHOUT escaping the ones inside it: a JSON string came back as a line no
+  parser reads, and a settings file that fails to parse is read as the
+  DEFAULTS. A key the reader does not know is skipped.
+- **They are applied to the scene, not evaluated with it.**
+  `rebuild_scene_geometry` evaluates the graph, keeps the result as
+  `State::scene_base` with its attributes and ranges
+  (`State::scene_attributes`), and hands a copy to `present_scene`, which
+  applies the visualizers and does everything the viewport draws of a
+  scene — the fill, the traced copy, the groups, the overlays, the edges.
+  An edit to a visualizer runs `revisualize`, which presents `scene_base`
+  again: no graph evaluation, so a dragged slider costs a re-mesh. A
+  visualizer naming an attribute the scene lacks draws nothing and says
+  "not in the scene" in the list; it is kept, since the scene it was made
+  for may come back. Not in `--thumbnail` or `--export`, which have no
+  display settings.
+- **They are edited in the dialog**, two modes: `Mode::Visualizers` (the
+  `attribute_visualizers` command — the palette, and a row of the viewport
+  menu under its submenus) lists them, a switch each, and Add Visualizer;
+  a press on a row's SWITCH turns it on or off and a press on the rest of
+  the row, or Enter, opens it (`Dialog::activated_on_control`,
+  `take_dialog_pick_at`). `Mode::VisualizerEdit` (`State::vis_editing`) is
+  that visualizer's settings as rows — the Visualize node's `show_when`
+  applied, so Ramp's rows or Vector's; Attribute and Group are choices
+  over the scene's attributes and groups; a Manual range's From and To are
+  sliders over the attribute's range in the scene with a quarter to spare
+  — then Delete Visualizer and Back. Escape goes back to the list. Rows
+  re-read IN PLACE (`refresh_dialog_rows_in_place`, selection and scroll
+  kept), since switching the mode swaps rows under the one selected.
+  A new visualizer starts on the scene's first attribute that is not
+  `P`, `Cd` or `N`.
+
+`attribute_visualizers_are_edited_in_the_dialog_and_shown_on_the_scene`
+drives the dialog end to end.
+
 ### The Normal node writes point or vertex normals
 
 `normal` has a **Class** row (since 2026-09-29): `Points`, what it always
@@ -3249,6 +3302,9 @@ rather than two:
   A marked name the scene has no group for marks nothing and is kept, so
   a group that comes and goes with a frame does not lose its switch.
   `the_group_markers_dialog_marks_a_groups_points` is the test.
+- `Mode::Visualizers` / `Mode::VisualizerEdit` (the
+  `attribute_visualizers` command) — the attribute visualizers and one
+  visualizer's settings; see "Attribute visualizers".
 - `Mode::AddNode` (**Tab**, in the network pane) — one list of node
   templates, and a pick that instantiates at the grid cursor. Tab is what
   opened it, so Tab closes it again. The query hint names the mode; there
diff --git a/src/app.rs b/src/app.rs
index e5a937b..52dcad4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1533,6 +1533,11 @@ pub struct ViewportSettings {
     /// the two ends.
     #[serde(default)]
     pub marked_groups: String,
+    /// The attribute visualizers, as one JSON string — see
+    /// `crate::visualizer::encode` for why one string. Absent from older
+    /// files — none.
+    #[serde(default)]
+    pub visualizers: String,
     /// World-unit radius and colour of the Show Point Markers overlay.
     #[serde(default = "default_point_marker_size")]
     pub point_marker_size: f32,
@@ -1731,6 +1736,7 @@ impl Default for ViewportSettings {
             show_vertex_markers: false,
             show_vertex_normals: false,
             marked_groups: String::new(),
+            visualizers: String::new(),
             point_marker_size: default_point_marker_size(),
             point_marker_color: default_point_marker_color(),
             world_unit: default_world_unit(),
@@ -2728,6 +2734,18 @@ pub struct State {
     /// the list and marks nothing, so a switch set for a group that comes
     /// and goes with a frame or an edit is not lost with it.
     pub marked_groups: Vec<String>,
+    /// The attribute visualizers, applied in order to the displayed scene
+    /// (`crate::visualizer`). Persisted in the viewport block.
+    pub visualizers: Vec<crate::visualizer::Visualizer>,
+    /// The visualizer the dialog's VisualizerEdit page is editing.
+    pub vis_editing: Option<usize>,
+    /// The displayed scene's point attributes as last built, with their
+    /// ranges: what the visualizer editor offers.
+    pub scene_attributes: Vec<crate::visualizer::SceneAttribute>,
+    /// The displayed scene as last EVALUATED, before the visualizers: what a
+    /// visualizer edit re-presents (`State::revisualize`) without running
+    /// the graph again.
+    pub scene_base: Option<crate::detail::Detail>,
     /// Every point group of the scene as last built, with its members'
     /// positions: what the dialog lists and what the markers are built
     /// from, so a switch flipped evaluates nothing.
@@ -2931,6 +2949,7 @@ impl State {
                 show_vertex_markers: self.show_vertex_markers,
                 show_vertex_normals: self.show_vertex_normals,
                 marked_groups: Self::join_marked_groups(&self.marked_groups),
+                visualizers: crate::visualizer::encode(&self.visualizers),
                 point_marker_size: self.point_marker_size,
                 point_marker_color: self.point_marker_color,
                 world_unit: self.world_unit.suffix().to_string(),
@@ -3011,6 +3030,7 @@ impl State {
         self.show_vertex_markers = v.show_vertex_markers;
         self.show_vertex_normals = v.show_vertex_normals;
         self.marked_groups = Self::marked_groups_of(&v.marked_groups);
+        self.visualizers = crate::visualizer::decode(&v.visualizers);
         self.point_marker_size = v.point_marker_size;
         self.point_marker_color = v.point_marker_color;
         if let Some(u) = cce_ui::units::Unit::parse(&v.world_unit) {
@@ -3046,6 +3066,8 @@ impl State {
         self.sync_grid_settings();
         self.rebuild_positions();
         self.apply_layout();
+        // The visualizers came with the block: shown on the scene in hand.
+        self.revisualize();
         self.viewport_dirty = true;
         self.save_settings();
     }
@@ -4341,6 +4363,7 @@ impl State {
                 self.reset_parameters();
             }
             "Group Markers" => self.open_group_markers_dialog(),
+            "Attribute Visualizers" => self.open_visualizers_dialog(),
             "Rename Node" => match self.selected_slots().first().copied() {
                 Some(slot) => self.open_rename_dialog(slot),
                 None => self.update_status_text("Select a node to rename."),
@@ -6127,6 +6150,14 @@ impl State {
         for page in [ViewportMenuPage::Style, ViewportMenuPage::Markers] {
             row(&mut options, &mut actions, page.label().into(), ViewportMenuAction::Submenu(page));
         }
+        // The visualizers' editor, which is the dialog — a list is not a
+        // submenu's shape.
+        row(
+            &mut options,
+            &mut actions,
+            label("attribute_visualizers", "Attribute Visualizers").to_string(),
+            ViewportMenuAction::Command("attribute_visualizers"),
+        );
 
         // 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
@@ -7666,6 +7697,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             show_vertex_markers: settings.viewport.show_vertex_markers,
             show_vertex_normals: settings.viewport.show_vertex_normals,
             marked_groups: Self::marked_groups_of(&settings.viewport.marked_groups),
+            visualizers: crate::visualizer::decode(&settings.viewport.visualizers),
+            vis_editing: None,
+            scene_attributes: Vec::new(),
+            scene_base: None,
             scene_groups: Vec::new(),
             marked_group_verts: Vec::new(),
             marked_groups_dirty: false,
diff --git a/src/command.rs b/src/command.rs
index b0eaaf1..90d347b 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -186,6 +186,7 @@ pub const COMMANDS: &[Command] = &[
     Command { id: "bypass_node", label: "Bypass Node", context: Context::Network, run: Run::Key(Action::ToggleBypass), default_chord: Some("b") },
     Command { id: "rename_node", label: "Rename Node", context: Context::Network, run: Run::Menu("Rename Node"), default_chord: None },
     Command { id: "group_markers", label: "Group Markers", context: Context::Viewport, run: Run::Menu("Group Markers"), default_chord: None },
+    Command { id: "attribute_visualizers", label: "Attribute Visualizers", context: Context::Viewport, run: Run::Menu("Attribute Visualizers"), default_chord: None },
     Command { id: "add_node", label: "Add Node", context: Context::Network, run: Run::Menu("Add Node"), default_chord: None },
     Command { id: "zoom_in", label: "Zoom In", context: Context::Network, run: Run::Menu("Zoom In"), default_chord: None },
     Command { id: "zoom_out", label: "Zoom Out", context: Context::Network, run: Run::Menu("Zoom Out"), default_chord: None },
diff --git a/src/dialog.rs b/src/dialog.rs
index a755c71..e470fa0 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -54,6 +54,14 @@ pub enum Mode {
     /// The palette TURNS INTO this list, the way it turns into the node
     /// list — one plate, one filter.
     Groups,
+    /// The `attribute_visualizers` command: the viewport's attribute
+    /// visualizers (`crate::visualizer`), a switch each, and Add
+    /// Visualizer. Picking a visualizer's row — off its switch — opens its
+    /// settings.
+    Visualizers,
+    /// One visualizer's settings (`State::vis_editing`) as rows: its
+    /// controls, Delete, and Back to the list, which Escape is too.
+    VisualizerEdit,
 }
 
 /// The control a row carries, drawn over its right end and worked in place —
@@ -294,6 +302,9 @@ pub struct Dialog {
     hover_ctl: Option<usize>,
     /// A row the pointer activated, drained by the app.
     activated: Option<String>,
+    /// Whether the press that set `activated` was on the row's control
+    /// (its switch) rather than on the rest of the row.
+    activated_control: bool,
     /// Whether the dialog is currently claiming its rect as an occluder — see
     /// [`Paint::popover`]. Lowered for the length of an event dispatch into
     /// the dialog, because the one claim serves two mechanisms that want
@@ -352,6 +363,7 @@ impl Dialog {
             hover_row: None,
             hover_ctl: None,
             activated: None,
+            activated_control: false,
             occluding: true,
             toggle_stamps: RefCell::new([off, on]),
             slider_stamp: RefCell::new(slider_stamp),
@@ -536,6 +548,18 @@ impl Dialog {
         self.activated.take()
     }
 
+    /// Whether the last activation was a press on the row's control —
+    /// read before [`Self::take_activated`] or after, it is the same press.
+    pub fn activated_on_control(&self) -> bool {
+        self.activated_control
+    }
+
+    /// Put the scroll back where it was after a re-rank, clamped to the
+    /// list the rows now make.
+    pub fn restore_scroll_px(&mut self, px: f32) {
+        self.set_scroll_px(px);
+    }
+
     /// Replace one row's control in place — the app re-reads a control from
     /// the live value after applying it, without touching the ranking, the
     /// selection or the scroll.
@@ -896,6 +920,8 @@ impl Paint for Dialog {
                     Mode::AddNode => "Add Node: type to filter nodes",
                     Mode::Rename => "Rename: type the node's name",
                     Mode::Groups => "Group Markers: type to filter groups",
+                    Mode::Visualizers => "Attribute Visualizers: type to filter",
+                    Mode::VisualizerEdit => "Visualizer: type to filter its settings",
                 },
                 q_w,
             );
@@ -932,6 +958,7 @@ impl Paint for Dialog {
                 Mode::AddNode => "No matching node",
                 Mode::Rename => "No node to rename",
                 Mode::Groups => "No point group in the scene",
+                Mode::Visualizers | Mode::VisualizerEdit => "No matching row",
             };
             ctx.text_with(empty, list.x + 8.0, ty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
             return;
@@ -1190,6 +1217,9 @@ impl Input for Dialog {
                         return true;
                     }
                     self.activated = self.rows.get(i).map(|r| r.id.clone());
+                    self.activated_control = r
+                        .and_then(|r| self.control_rect(r, &self.rows[i]))
+                        .is_some_and(|c| *x >= c.x && *x < c.x + c.width && *y >= c.y && *y < c.y + c.height);
                     return true;
                 }
                 // Inside the plate but on no control: consumed anyway, so the
@@ -1303,6 +1333,7 @@ impl Input for Dialog {
 
 use crate::app::State;
 use crate::command::Context;
+use crate::visualizer::{VIS_ADD_ROW_ID, VIS_FIELD_PREFIX, VIS_ROW_PREFIX};
 use crate::slots::DIALOG_IDX;
 
 /// The list's zoom row: not a registry command but a control — a slider
@@ -1550,6 +1581,35 @@ impl State {
         self.open_dialog_in(Mode::Groups);
     }
 
+    /// The Attribute Visualizers list: the `attribute_visualizers` command.
+    pub fn open_visualizers_dialog(&mut self) {
+        self.vis_editing = None;
+        self.open_dialog_in(Mode::Visualizers);
+    }
+
+    /// Visualizer `i`'s settings, in the same plate — the list turned into
+    /// the one visualizer, as the palette turns into Group Markers.
+    pub fn open_visualizer_editor(&mut self, i: usize) {
+        if i >= self.visualizers.len() {
+            return;
+        }
+        self.vis_editing = Some(i);
+        self.open_dialog_in(Mode::VisualizerEdit);
+    }
+
+    /// Rebuild the rows for a change that adds or drops some — Mode flipping
+    /// Ramp's rows for Vector's — keeping the selected row and the scroll,
+    /// as a control edited in place does.
+    fn refresh_dialog_rows_in_place(&mut self) {
+        let selected = self.slots.dialog.selected_id().map(str::to_string);
+        let scroll = self.slots.dialog.scroll_px;
+        self.refresh_dialog_rows();
+        if let Some(i) = selected.and_then(|id| self.slots.dialog.rows.iter().position(|r| r.id == id)) {
+            self.slots.dialog.selected = i;
+        }
+        self.slots.dialog.restore_scroll_px(scroll);
+    }
+
     /// What renaming the dialog's node to `typed` would do: the name it
     /// has and the one it would get, or why not. None when the node is
     /// gone.
@@ -1581,6 +1641,8 @@ impl State {
             Mode::AddNode => "Add Node: type to filter, Enter adds at the cursor, Escape closes.",
             Mode::Rename => "Rename: type the name, Enter renames, Escape closes.",
             Mode::Groups => "Group Markers: Enter or a click marks a group's points in the scene, Escape closes.",
+            Mode::Visualizers => "Attribute Visualizers: a switch turns one on or off, Enter or a click edits it, Escape closes.",
+            Mode::VisualizerEdit => "Visualizer: change a setting in place; Escape goes back to the list.",
         });
     }
 
@@ -1733,6 +1795,18 @@ impl State {
                 }
                 rows
             }
+            // The visualizers, then one visualizer's settings, ranked by
+            // label as every list is.
+            Mode::Visualizers | Mode::VisualizerEdit => {
+                let all = if self.slots.dialog.mode == Mode::Visualizers {
+                    self.visualizer_rows()
+                } else {
+                    self.vis_editing.map(|i| self.visualizer_edit_rows(i)).unwrap_or_default()
+                };
+                let labels: Vec<&str> = all.iter().map(|r| r.label.as_str()).collect();
+                let order = crate::command::fuzzy_rank(&query, &labels);
+                order.into_iter().map(|i| all[i].clone()).collect()
+            }
             // The scene's point groups, ranked by name, a switch each and the
             // member count in the chord column.
             Mode::Groups => {
@@ -1896,6 +1970,12 @@ impl State {
     /// moved, and re-ranking would throw the selection back to the top of a
     /// list the user is still working down.
     pub(crate) fn refresh_dialog_controls(&mut self) {
+        if matches!(self.slots.dialog.mode, Mode::Visualizers | Mode::VisualizerEdit) {
+            // Labels follow the settings ("mass — Ramp, Heat"), and the edit
+            // page's rows follow its Mode and Range, so these re-read whole.
+            self.refresh_dialog_rows_in_place();
+            return;
+        }
         if self.slots.dialog.mode == Mode::Groups {
             let ids: Vec<String> = self.slots.dialog.rows.iter().map(|r| r.id.clone()).collect();
             for id in ids {
@@ -2230,7 +2310,13 @@ impl State {
         }
         match &event.logical_key {
             Key::Named(NamedKey::Escape) => {
-                self.close_dialog();
+                // A visualizer's settings are a page of the list: Escape
+                // goes back to it, and from there closes.
+                if self.slots.dialog.mode == Mode::VisualizerEdit {
+                    self.open_visualizers_dialog();
+                } else {
+                    self.close_dialog();
+                }
                 return true;
             }
             Key::Named(NamedKey::Tab) => {
@@ -2322,6 +2408,10 @@ impl State {
                 if let Some(label) = id.strip_prefix(SETTING_ROW_PREFIX) {
                     let label = label.to_string();
                     self.apply_setting(&label, &options[next]);
+                } else if let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) {
+                    let field = field.to_string();
+                    self.set_visualizer_field(i, &field, &options[next], true);
+                    self.refresh_dialog_controls();
                 }
             }
             _ => {}
@@ -2340,6 +2430,19 @@ impl State {
     /// does. A spin row lands its whole number over the row's unit. A row the landing
     /// does not know falls through to `apply_setting`.
     pub(crate) fn land_dialog_slider(&mut self, id: &str, v: f32) {
+        if let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) {
+            // In place: the rows do not change under a drag, and a rebuild
+            // would drop the scroll it is being dragged at. The scene is
+            // re-presented, not re-evaluated; state.kdl is written on the
+            // release, or now for a wheel notch or an arrow.
+            let field = field.to_string();
+            let save = !self.slots.dialog.slider_dragging();
+            self.set_visualizer_field(i, &field, &v.to_string(), save);
+            if let Some(row) = self.visualizer_edit_rows(i).into_iter().find(|r| r.id == id) {
+                self.slots.dialog.set_control(id, row.control);
+            }
+            return;
+        }
         if id == ZOOM_ROW_ID {
             self.set_zoom_percent(v);
         } else if let Some(s) = setting_of_row(id) {
@@ -2384,10 +2487,52 @@ impl State {
     /// does nothing; the controls re-read, and the selection stays where it
     /// was — by Enter or by a click, since both arrive here.
     pub(crate) fn take_dialog_pick(&mut self, id: String) {
+        self.take_dialog_pick_at(id, false);
+    }
+
+    /// [`Self::take_dialog_pick`], told whether the press was on the row's
+    /// CONTROL: in the visualizer list the switch turns a visualizer on or
+    /// off, and the rest of the row opens it.
+    pub(crate) fn take_dialog_pick_at(&mut self, id: String, on_control: bool) {
         let mode = self.slots.dialog.mode;
         if mode == Mode::Commands && id == ZOOM_ROW_ID {
             return;
         }
+        if mode == Mode::Visualizers {
+            if id == VIS_ADD_ROW_ID {
+                let i = self.add_visualizer();
+                self.open_visualizer_editor(i);
+            } else if let Some(i) = id.strip_prefix(VIS_ROW_PREFIX).and_then(|i| i.parse::<usize>().ok()) {
+                if on_control {
+                    let on = !self.visualizers.get(i).is_some_and(|v| v.enabled);
+                    self.set_visualizer_enabled(i, on);
+                    self.refresh_dialog_controls();
+                } else {
+                    self.open_visualizer_editor(i);
+                }
+            }
+            return;
+        }
+        if mode == Mode::VisualizerEdit {
+            let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) else { return };
+            match field {
+                "back" => self.open_visualizers_dialog(),
+                "delete" => {
+                    self.delete_visualizer(i);
+                    self.open_visualizers_dialog();
+                }
+                _ => match self.slots.dialog.rows.iter().find(|r| r.id == id).and_then(|r| r.control.clone()) {
+                    Some(Control::Toggle(on)) => {
+                        let field = field.to_string();
+                        self.set_visualizer_field(i, &field, if on { "false" } else { "true" }, true);
+                        self.refresh_dialog_controls();
+                    }
+                    Some(Control::Choice { .. }) => self.nudge_dialog_selection(1),
+                    _ => {}
+                },
+            }
+            return;
+        }
         // A group's row is its switch: flipped in place, the list stays up.
         if mode == Mode::Groups {
             if let Some(name) = id.strip_prefix(GROUP_ROW_PREFIX) {
@@ -2466,7 +2611,7 @@ impl State {
                     Ok(said) | Err(said) => self.update_status_text(&said),
                 }
             }
-            Mode::Groups => {}
+            Mode::Groups | Mode::Visualizers | Mode::VisualizerEdit => {}
             Mode::AddNode => {
                 // A free cursor cell a wire runs through is a place in that
                 // chain: the new node is spliced into the wire, as a node
@@ -2502,7 +2647,8 @@ impl State {
     pub(crate) fn drain_dialog_clicks(&mut self) -> bool {
         let mut changed = false;
         if let Some(id) = self.slots.dialog.take_activated() {
-            self.take_dialog_pick(id);
+            let on_control = self.slots.dialog.activated_on_control();
+            self.take_dialog_pick_at(id, on_control);
             changed = true;
         }
         if let Some((id, v)) = self.slots.dialog.take_slider_change() {
diff --git a/src/main.rs b/src/main.rs
index 15f6966..b534142 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -23,6 +23,7 @@ pub mod collide;
 #[allow(unused_imports)]
 use app::{CustomEvent, McpAction, ModifiersState};
 pub mod plate_menu;
+pub mod visualizer;
 pub mod playbar;
 pub mod viewport_3d;
 pub mod api;
@@ -2208,7 +2209,7 @@ mod tests {
             vec![
                 vec![A::FrameAll, A::OneToOne],
                 vec![A::Command("toggle_grid"), A::Command("toggle_origin"), A::Command("toggle_camera_pivot"), A::CameraPivotSizeSlider],
-                vec![A::Submenu(P::Style), A::Submenu(P::Markers)],
+                vec![A::Submenu(P::Style), A::Submenu(P::Markers), A::Command("attribute_visualizers")],
             ]
         );
         assert_eq!(
@@ -7626,6 +7627,114 @@ mod tests {
     /// wear a marker in the scene, built from what the last rebuild kept,
     /// following the geometry through a rebuild and persisted with the
     /// display settings.
+    /// Attribute visualizers: added, edited, switched and deleted in the
+    /// dialog, applied to the displayed scene with no node in the graph,
+    /// and kept with the display settings.
+    #[test]
+    fn attribute_visualizers_are_edited_in_the_dialog_and_shown_on_the_scene() {
+        use crate::dialog::Mode;
+        use crate::visualizer::{VIS_ADD_ROW_ID, VIS_FIELD_PREFIX, VIS_ROW_PREFIX};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        state.rebuild_scene_geometry();
+        assert!(state.scene_attributes.iter().any(|a| a.name == "Norm"), "{:?}", state.scene_attributes);
+        let colours = |state: &State| state.rt_sphere_verts.iter().map(|v| v.color).collect::<Vec<_>>();
+        let plain = colours(&state);
+        let nodes_before = serde_json::to_string(&state.fs_root).unwrap();
+
+        // The list, empty but for Add Visualizer.
+        assert!(state.run_command("attribute_visualizers"));
+        assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
+        assert_eq!(state.slots.dialog.rows.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(), vec![VIS_ADD_ROW_ID]);
+
+        // Add one: the list turns into its settings.
+        state.take_dialog_pick(VIS_ADD_ROW_ID.to_string());
+        assert_eq!(state.visualizers.len(), 1);
+        assert_eq!(state.slots.dialog.mode, Mode::VisualizerEdit);
+        assert_eq!(state.vis_editing, Some(0));
+        let field = |f: &str| format!("{VIS_FIELD_PREFIX}{f}");
+        let has = |state: &State, f: &str| state.slots.dialog.rows.iter().any(|r| r.id == field(f));
+        assert!(has(&state, "ramp") && has(&state, "opacity") && !has(&state, "scale"), "Ramp's rows");
+
+        // On Norm, a Ramp recolours the scene, which gained no node.
+        state.set_visualizer_field(0, "attribute", "Norm", true);
+        assert_ne!(colours(&state), plain, "the ramp is on the scene");
+        assert_eq!(serde_json::to_string(&state.fs_root).unwrap(), nodes_before, "no node in the graph");
+
+        // Enter on Mode steps it to Vector: Vector's rows, and lines drawn.
+        let mode_row = state.slots.dialog.rows.iter().position(|r| r.id == field("mode")).unwrap();
+        state.slots.dialog.selected = mode_row;
+        let lines_before = state.overlay_normal_verts.len();
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+        assert!(state.visualizers[0].is_vector());
+        assert!(has(&state, "scale") && !has(&state, "ramp"), "Vector's rows");
+        assert_eq!(state.slots.dialog.selected_id(), Some(field("mode").as_str()), "the selection stays");
+        assert!(state.overlay_normal_verts.len() > lines_before, "the vectors are drawn");
+        assert_eq!(colours(&state), plain, "a Vector leaves the colours");
+
+        // Escape goes back to the list, which names it.
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::Escape)));
+        assert!(state.dialog_visible());
+        assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
+        let id = format!("{VIS_ROW_PREFIX}0");
+        let row = state.slots.dialog.rows.iter().find(|r| r.id == id).expect("its row").clone();
+        assert!(row.label.starts_with("Norm — Vector"), "{}", row.label);
+        assert_eq!(row.toggle(), Some(true));
+
+        // Kept with the display settings.
+        let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+        let kept = crate::visualizer::decode(&crate::app::DesignSettings::from_kdl_str(&kdl).viewport.visualizers);
+        assert_eq!(kept, state.visualizers);
+
+        // Its switch turns it off; the rest of the row opens it.
+        state.take_dialog_pick_at(id.clone(), true);
+        assert!(!state.visualizers[0].enabled);
+        assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
+        let lines_off = state.overlay_normal_verts.len();
+        assert!(lines_off < lines_before + 1, "off draws nothing");
+        state.take_dialog_pick_at(id, false);
+        assert_eq!(state.slots.dialog.mode, Mode::VisualizerEdit);
+
+        // Delete, and the list is empty again.
+        state.take_dialog_pick(field("delete"));
+        assert!(state.visualizers.is_empty());
+        assert_eq!(state.slots.dialog.mode, Mode::Visualizers);
+        assert_eq!(colours(&state), plain);
+        state.close_dialog();
+    }
+
+    /// A visualizer is the Visualize node's reading: the same settings give
+    /// the same colours as the node does, and the settings round-trip
+    /// through their one string.
+    #[test]
+    fn a_visualizer_reads_as_the_visualize_node_does() {
+        let mut state = State::new(false);
+        state.rebuild_scene_geometry();
+        let base = state.scene_base.clone().expect("a scene");
+        let mut v = crate::visualizer::Visualizer::new("Norm");
+        v.ramp = "Heat".into();
+        let mut by_vis = base.clone();
+        crate::visualizer::apply_all(&[v.clone()], &mut by_vis);
+        let mut by_node = base.clone();
+        let mut err = None;
+        crate::geometry::apply_visualize(&mut by_node, &v.as_node(), &mut err);
+        assert!(err.is_none());
+        assert_eq!(by_vis, by_node);
+        assert_ne!(by_vis, base);
+        let both = vec![v.clone(), crate::visualizer::Visualizer::new("Cd")];
+        assert_eq!(crate::visualizer::decode(&crate::visualizer::encode(&both)), both);
+        assert!(crate::visualizer::decode("not a record").is_empty());
+        // What the KDL writer cannot carry is escaped, and comes back.
+        let mut odd = crate::visualizer::Visualizer::new("a|b;c=\"d\\%");
+        odd.group = "g;1".into();
+        let text = crate::visualizer::encode(&[odd.clone()]);
+        assert!(!text.contains('"') && !text.contains('\\'), "{text}");
+        assert_eq!(crate::visualizer::decode(&text), vec![odd]);
+        assert_eq!(crate::visualizer::encode(&[]), "");
+    }
+
     #[test]
     fn the_group_markers_dialog_marks_a_groups_points() {
         use crate::dialog::{Mode, GROUP_ROW_PREFIX};
diff --git a/src/render.rs b/src/render.rs
index 2c097b6..e332b38 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1212,6 +1212,37 @@ impl State {
             self.update_status_text("Geometry updated successfully.");
         }
 
+        // What the visualizers are applied to, kept so an edit to one
+        // re-presents this scene rather than running the graph again.
+        self.scene_attributes = crate::visualizer::scene_attributes(&geom);
+        self.scene_base = Some(geom.clone());
+        self.present_scene(geom);
+
+        // The pull arrows measure the selected node against this new
+        // geometry version; a playing simnet reaches here every frame.
+        self.sync_selection_readouts();
+        self.sync_pull_arrows();
+
+        // Last, not first: the page's status line would otherwise be
+        // overwritten by the geometry pass's own, and a level showing a page
+        // has nothing to say about geometry.
+        self.rebuild_page();
+    }
+
+    /// Show the scene last evaluated again, under the visualizers as they
+    /// now stand — what an edit to one runs. Nothing before the first
+    /// evaluation.
+    pub(crate) fn revisualize(&mut self) {
+        if let Some(base) = self.scene_base.clone() {
+            self.present_scene(base);
+        }
+    }
+
+    /// Everything the viewport draws of an evaluated scene: the visualizers
+    /// applied to it, then its fill, its groups, its overlays and its edges.
+    fn present_scene(&mut self, mut geom: crate::detail::Detail) {
+        crate::visualizer::apply_all(&self.visualizers, &mut geom);
+
         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;
@@ -1291,8 +1322,8 @@ impl State {
         self.scene_edge_verts =
             if self.wireframe { scene_edge_verts(&geom) } else { Vec::new() };
 
-        // Visualize's vector markers ride the same LINE_LIST channel as the
-        // normal whiskers.
+        // Visualize's vector markers — a node's or a visualizer's — ride the
+        // same LINE_LIST channel as the normal whiskers.
         self.overlay_normal_verts.extend(crate::geometry::vis_marker_vertices(
             &geom,
             cce_ui::colors::to_linear_rgb,
@@ -1307,15 +1338,6 @@ impl State {
         if let Some(mvp) = self.last_scene_mvp {
             self.sync_point_number_alpha(mvp, self.last_scene_eye);
         }
-        // The pull arrows measure the selected node against this new
-        // geometry version; a playing simnet reaches here every frame.
-        self.sync_selection_readouts();
-        self.sync_pull_arrows();
-
-        // Last, not first: the page's status line would otherwise be
-        // overwritten by the geometry pass's own, and a level showing a page
-        // has nothing to say about geometry.
-        self.rebuild_page();
     }
 
     /// The path tracer's scene: the scene geometry as triangles, with one
diff --git a/src/visualizer.rs b/src/visualizer.rs
new file mode 100644
index 0000000..8fee32b
--- /dev/null
+++ b/src/visualizer.rs
@@ -0,0 +1,448 @@
+//! Attribute visualizers: Houdini's viewport visualizers. A visualizer shows
+//! a point attribute of whatever the viewport displays — coloured through a
+//! ramp, or drawn as a line from each point — without a node in the graph.
+//!
+//! It is the Visualize NODE's reading of an attribute, and runs through that
+//! node's own [`crate::geometry::apply_visualize`] over a node built from its
+//! settings ([`Visualizer::as_node`]), so the two cannot disagree about what
+//! a Ramp or a Vector draws. What differs is where it lives: a node is part
+//! of the scene and travels with its chain; a visualizer is a DISPLAY
+//! setting, as every display setting here is — a live field on `State`
+//! (`State::visualizers`), persisted to state.kdl and with the project's
+//! display block, applied to the merged scene `Detail` at the end of a scene
+//! rebuild (`State::present_scene`), and reached from the palette (the
+//! `attribute_visualizers` command). Several apply in order, the later over
+//! the earlier, as a chain of Visualize nodes composites.
+//!
+//! They are edited in the dialog: [`crate::dialog::Mode::Visualizers`] lists
+//! them with a switch each, and [`crate::dialog::Mode::VisualizerEdit`] is
+//! one visualizer's settings as rows.
+
+use crate::app::{FsNode, ParamDef, State};
+use crate::detail::Detail;
+use crate::dialog::{Control, Row};
+
+pub const MODES: [&str; 2] = ["Ramp", "Vector"];
+pub const RAMPS: [&str; 4] = ["Grayscale", "Heat", "Spectrum", "Viridis"];
+pub const RANGES: [&str; 2] = ["Auto", "Manual"];
+pub const BLENDS: [&str; 3] = ["Set", "Multiply", "Add"];
+
+fn ramp_mode() -> String {
+    "Ramp".into()
+}
+fn viridis() -> String {
+    "Viridis".into()
+}
+fn auto() -> String {
+    "Auto".into()
+}
+fn set() -> String {
+    "Set".into()
+}
+fn fifth() -> f32 {
+    0.2
+}
+
+/// One visualizer: the Visualize node's settings, under the same names and
+/// options, and whether it is on.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Visualizer {
+    pub enabled: bool,
+    /// The point attribute shown.
+    pub attribute: String,
+    /// `Ramp` colours points by the attribute; `Vector` draws a line along
+    /// it from each point.
+    pub mode: String,
+    pub ramp: String,
+    /// `Auto` spreads the ramp over the attribute's range in the scene;
+    /// `Manual` over From..To.
+    pub range: String,
+    pub from: f32,
+    pub to: f32,
+    pub blend: String,
+    pub opacity: f32,
+    /// A Vector line's length per unit of the attribute.
+    pub scale: f32,
+    /// A point group the visualizer is limited to; empty is every point.
+    pub group: String,
+}
+
+impl Visualizer {
+    /// A new visualizer on `attribute`, on, with the node's defaults.
+    pub fn new(attribute: &str) -> Visualizer {
+        Visualizer {
+            enabled: true,
+            attribute: attribute.to_string(),
+            mode: ramp_mode(),
+            ramp: viridis(),
+            range: auto(),
+            from: 0.0,
+            to: 1.0,
+            blend: set(),
+            opacity: 1.0,
+            scale: fifth(),
+            group: String::new(),
+        }
+    }
+
+    pub fn is_vector(&self) -> bool {
+        self.mode.eq_ignore_ascii_case("vector")
+    }
+
+    pub fn is_manual(&self) -> bool {
+        self.range.eq_ignore_ascii_case("manual")
+    }
+
+    /// What the list calls it: the attribute and how it is shown.
+    pub fn label(&self) -> String {
+        let attr = if self.attribute.is_empty() { "(no attribute)" } else { &self.attribute };
+        let how = if self.is_vector() { format!("Vector ×{:.2}", self.scale) } else { format!("Ramp, {}", self.ramp) };
+        let group = if self.group.is_empty() { String::new() } else { format!(" in {}", self.group) };
+        format!("{attr} — {how}{group}")
+    }
+
+    /// A Visualize node carrying these settings, for `apply_visualize`.
+    pub fn as_node(&self) -> FsNode {
+        let p = |name: &str, ty: &str, text: String| ParamDef::new(name.to_string(), ty.to_string(), text);
+        FsNode {
+            id: String::new(),
+            name: "visualizer".to_string(),
+            node_type: "visualize".to_string(),
+            children: Vec::new(),
+            params: vec![
+                p("Attribute", "attribute", self.attribute.clone()),
+                p("Mode", "choice", self.mode.clone()),
+                p("Ramp", "choice", self.ramp.clone()),
+                p("Range", "choice", self.range.clone()),
+                p("From", "float", self.from.to_string()),
+                p("To", "float", self.to.to_string()),
+                p("Blend", "choice", self.blend.clone()),
+                p("Opacity", "float", self.opacity.to_string()),
+                p("Scale", "float", self.scale.to_string()),
+                p("Group", "group", self.group.clone()),
+            ],
+            geometry_visible: true,
+            bypassed: false,
+            position: (0.0, 0.0),
+            inputs: 1,
+            outputs: 1,
+        }
+    }
+}
+
+/// Apply every visualizer that is on to `geom`, in order. One naming an
+/// attribute the scene does not have does nothing: it is a display setting,
+/// and the scene it was made for may come back.
+pub fn apply_all(visualizers: &[Visualizer], geom: &mut Detail) {
+    for v in visualizers.iter().filter(|v| v.enabled && !v.attribute.trim().is_empty()) {
+        let mut ignored = None;
+        crate::geometry::apply_visualize(geom, &v.as_node(), &mut ignored);
+    }
+}
+
+/// The visualizers as the settings hold them: one string, a visualizer per
+/// `;`, each `key=value` pairs joined by `|`, with `%`, `|`, `;`, `=`, `"`
+/// and `\` percent-escaped in the values. One string, as the marked groups
+/// are one, because the KDL writer cannot be trusted with a list of
+/// records; and not JSON, because it writes a string between quotes
+/// WITHOUT escaping the ones inside it — a JSON string came back as a line
+/// no parser reads, and a settings file that fails to parse is read as the
+/// DEFAULTS. A key the reader does not know is skipped and one it lacks
+/// takes the default, so the format can grow.
+pub fn encode(visualizers: &[Visualizer]) -> String {
+    visualizers
+        .iter()
+        .map(|v| {
+            [
+                ("enabled", v.enabled.to_string()),
+                ("attribute", v.attribute.clone()),
+                ("mode", v.mode.clone()),
+                ("ramp", v.ramp.clone()),
+                ("range", v.range.clone()),
+                ("from", v.from.to_string()),
+                ("to", v.to.to_string()),
+                ("blend", v.blend.clone()),
+                ("opacity", v.opacity.to_string()),
+                ("scale", v.scale.to_string()),
+                ("group", v.group.clone()),
+            ]
+            .iter()
+            .map(|(k, val)| format!("{k}={}", escape(val)))
+            .collect::<Vec<_>>()
+            .join("|")
+        })
+        .collect::<Vec<_>>()
+        .join(";")
+}
+
+/// The visualizers out of the settings' one string; a record that names no
+/// field it knows is dropped.
+pub fn decode(text: &str) -> Vec<Visualizer> {
+    text.split(';')
+        .filter(|r| !r.trim().is_empty())
+        .filter_map(|record| {
+            let mut v = Visualizer::new("");
+            let mut known = false;
+            for pair in record.split('|') {
+                let Some((k, raw)) = pair.split_once('=') else { continue };
+                let val = unescape(raw);
+                let num = |d: f32| val.parse::<f32>().unwrap_or(d);
+                known = true;
+                match k.trim() {
+                    "enabled" => v.enabled = val != "false",
+                    "attribute" => v.attribute = val,
+                    "mode" => v.mode = val,
+                    "ramp" => v.ramp = val,
+                    "range" => v.range = val,
+                    "from" => v.from = num(v.from),
+                    "to" => v.to = num(v.to),
+                    "blend" => v.blend = val,
+                    "opacity" => v.opacity = num(v.opacity),
+                    "scale" => v.scale = num(v.scale),
+                    "group" => v.group = val,
+                    _ => {}
+                }
+            }
+            known.then_some(v)
+        })
+        .collect()
+}
+
+const ESCAPED: [char; 6] = ['%', '|', ';', '=', '"', '\\'];
+
+fn escape(s: &str) -> String {
+    s.chars().map(|c| if ESCAPED.contains(&c) { format!("%{:02X}", c as u32) } else { c.to_string() }).collect()
+}
+
+fn unescape(s: &str) -> String {
+    let mut out = String::new();
+    let mut chars = s.chars();
+    while let Some(c) = chars.next() {
+        if c == '%' {
+            let hex: String = chars.by_ref().take(2).collect();
+            match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
+                Some(d) => out.push(d),
+                None => {
+                    out.push('%');
+                    out.push_str(&hex);
+                }
+            }
+        } else {
+            out.push(c);
+        }
+    }
+    out
+}
+
+/// A point attribute of the displayed scene, as the editor offers it: its
+/// name and the range of its first component, which is what a Ramp reads.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SceneAttribute {
+    pub name: String,
+    pub min: f32,
+    pub max: f32,
+}
+
+/// The scene's point attributes, by name, with their ranges — leaving out the
+/// vector markers a Visualize stages (`vis_*`), which are not the scene's.
+pub fn scene_attributes(geom: &Detail) -> Vec<SceneAttribute> {
+    let mut out: Vec<SceneAttribute> = geom
+        .points()
+        .names()
+        .into_iter()
+        .filter(|n| !n.starts_with(crate::detail::VIS_PREFIX))
+        .map(|name| {
+            let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
+            for p in 0..geom.num_points() {
+                if let Some(v) = geom.points().value(name, p) {
+                    let x = v.as_f32();
+                    if x.is_finite() {
+                        min = min.min(x);
+                        max = max.max(x);
+                    }
+                }
+            }
+            if !min.is_finite() {
+                (min, max) = (0.0, 1.0);
+            }
+            SceneAttribute { name: name.to_string(), min, max }
+        })
+        .collect();
+    out.sort_by(|a, b| a.name.cmp(&b.name));
+    out
+}
+
+/// A visualizer in [`crate::dialog::Mode::Visualizers`]: the prefix, then
+/// its index.
+pub const VIS_ROW_PREFIX: &str = "vis:";
+/// The list's Add Visualizer row.
+pub const VIS_ADD_ROW_ID: &str = "vis:add";
+/// A setting of the visualizer being edited, in
+/// [`crate::dialog::Mode::VisualizerEdit`]: the prefix, then the field.
+pub const VIS_FIELD_PREFIX: &str = "visfield:";
+
+/// The Attribute and Group choices' word for "nothing chosen".
+const NO_ATTRIBUTE: &str = "(none)";
+const ALL_POINTS: &str = "(all points)";
+
+fn choice(options: &[&str], current: &str) -> Control {
+    let options: Vec<String> = options.iter().map(|s| s.to_string()).collect();
+    let index = options.iter().position(|o| o.eq_ignore_ascii_case(current)).unwrap_or(0);
+    Control::Choice { options, index }
+}
+
+fn row(field: &str, label: &str, control: Option<Control>) -> Row {
+    Row { id: format!("{VIS_FIELD_PREFIX}{field}"), label: label.to_string(), chord: String::new(), control, truncate_head: false }
+}
+
+impl State {
+    /// The list's rows: each visualizer, a switch each, and Add Visualizer.
+    /// A visualizer whose attribute the scene does not have says so in the
+    /// chord column, since it draws nothing.
+    pub(crate) fn visualizer_rows(&self) -> Vec<Row> {
+        let mut rows: Vec<Row> = self
+            .visualizers
+            .iter()
+            .enumerate()
+            .map(|(i, v)| Row {
+                id: format!("{VIS_ROW_PREFIX}{i}"),
+                label: v.label(),
+                chord: if self.scene_attributes.iter().any(|a| a.name == v.attribute) { String::new() } else { "not in the scene".to_string() },
+                control: Some(Control::Toggle(v.enabled)),
+                truncate_head: false,
+            })
+            .collect();
+        rows.push(Row::plain(VIS_ADD_ROW_ID, "Add Visualizer", ""));
+        rows
+    }
+
+    /// The rows of visualizer `i`'s settings, those that apply to its mode:
+    /// Ramp's ramp, range and blend, Vector's scale — as the Visualize
+    /// node's `show_when` conditions have them.
+    pub(crate) fn visualizer_edit_rows(&self, i: usize) -> Vec<Row> {
+        let Some(v) = self.visualizers.get(i) else { return Vec::new() };
+        let mut rows = vec![row("enabled", "Enabled", Some(Control::Toggle(v.enabled)))];
+
+        let mut attrs: Vec<String> = self.scene_attributes.iter().map(|a| a.name.clone()).collect();
+        if !v.attribute.is_empty() && !attrs.contains(&v.attribute) {
+            attrs.push(v.attribute.clone());
+        }
+        if attrs.is_empty() {
+            attrs.push(NO_ATTRIBUTE.to_string());
+        }
+        let index = attrs.iter().position(|a| *a == v.attribute).unwrap_or(0);
+        rows.push(row("attribute", "Attribute", Some(Control::Choice { options: attrs, index })));
+        rows.push(row("mode", "Mode", Some(choice(&MODES, &v.mode))));
+        if v.is_vector() {
+            rows.push(row("scale", "Scale", Some(Control::Slider { value: v.scale, min: 0.0, max: 10.0f32.max(v.scale), dec: 2, step: 0.05, suffix: "" })));
+        } else {
+            rows.push(row("ramp", "Ramp", Some(choice(&RAMPS, &v.ramp))));
+            rows.push(row("range", "Range", Some(choice(&RANGES, &v.range))));
+            if v.is_manual() {
+                let (lo, hi) = self.visualizer_value_range(v);
+                let step = ((hi - lo) / 100.0).max(1e-4);
+                rows.push(row("from", "From", Some(Control::Slider { value: v.from, min: lo, max: hi, dec: 3, step, suffix: "" })));
+                rows.push(row("to", "To", Some(Control::Slider { value: v.to, min: lo, max: hi, dec: 3, step, suffix: "" })));
+            }
+            rows.push(row("blend", "Blend", Some(choice(&BLENDS, &v.blend))));
+            rows.push(row("opacity", "Opacity", Some(Control::Slider { value: v.opacity, min: 0.0, max: 1.0, dec: 2, step: 0.05, suffix: "" })));
+        }
+
+        let mut groups: Vec<String> = vec![ALL_POINTS.to_string()];
+        groups.extend(self.scene_groups.iter().map(|(g, _)| g.clone()));
+        if !v.group.is_empty() && !groups.contains(&v.group) {
+            groups.push(v.group.clone());
+        }
+        let index = if v.group.is_empty() { 0 } else { groups.iter().position(|g| *g == v.group).unwrap_or(0) };
+        rows.push(row("group", "Group", Some(Control::Choice { options: groups, index })));
+        rows.push(row("delete", "Delete Visualizer", None));
+        rows.push(row("back", "‹ Back to Visualizers", None));
+        rows
+    }
+
+    /// The span a Manual range's sliders cover: the attribute's range in the
+    /// scene with a quarter of it to spare each side, and the values in hand
+    /// whatever they are.
+    fn visualizer_value_range(&self, v: &Visualizer) -> (f32, f32) {
+        let (mut lo, mut hi) = match self.scene_attributes.iter().find(|a| a.name == v.attribute) {
+            Some(a) => {
+                let pad = ((a.max - a.min) * 0.25).max(if a.max > a.min { 0.0 } else { 1.0 });
+                (a.min - pad, a.max + pad)
+            }
+            None => (0.0, 1.0),
+        };
+        lo = lo.min(v.from).min(v.to);
+        hi = hi.max(v.from).max(v.to);
+        (lo, hi)
+    }
+
+    /// Add a visualizer, on, on the first attribute of the scene that is
+    /// not its colour or its position — what is worth looking at — and
+    /// return its index.
+    pub(crate) fn add_visualizer(&mut self) -> usize {
+        let pick = self
+            .scene_attributes
+            .iter()
+            .map(|a| a.name.as_str())
+            .find(|n| !matches!(*n, "P" | "Cd" | "N"))
+            .or_else(|| self.scene_attributes.first().map(|a| a.name.as_str()))
+            .unwrap_or("")
+            .to_string();
+        self.visualizers.push(Visualizer::new(&pick));
+        self.visualizers_changed(true);
+        self.visualizers.len() - 1
+    }
+
+    pub(crate) fn delete_visualizer(&mut self, i: usize) {
+        if i < self.visualizers.len() {
+            self.visualizers.remove(i);
+            self.visualizers_changed(true);
+        }
+    }
+
+    pub(crate) fn set_visualizer_enabled(&mut self, i: usize, on: bool) {
+        if let Some(v) = self.visualizers.get_mut(i) {
+            if v.enabled != on {
+                v.enabled = on;
+                self.visualizers_changed(true);
+            }
+        }
+    }
+
+    /// Write one of visualizer `i`'s settings from the text its row holds —
+    /// a choice's option, a toggle's `true`/`false`, a number.
+    pub(crate) fn set_visualizer_field(&mut self, i: usize, field: &str, value: &str, save: bool) {
+        let Some(v) = self.visualizers.get_mut(i) else { return };
+        let number = || value.parse::<f32>().ok();
+        match field {
+            "enabled" => v.enabled = value == "true",
+            "attribute" => v.attribute = if value == NO_ATTRIBUTE { String::new() } else { value.to_string() },
+            "mode" => v.mode = value.to_string(),
+            "ramp" => v.ramp = value.to_string(),
+            "range" => v.range = value.to_string(),
+            "blend" => v.blend = value.to_string(),
+            "group" => v.group = if value == ALL_POINTS { String::new() } else { value.to_string() },
+            "from" => v.from = number().unwrap_or(v.from),
+            "to" => v.to = number().unwrap_or(v.to),
+            "opacity" => v.opacity = number().unwrap_or(v.opacity).clamp(0.0, 1.0),
+            "scale" => v.scale = number().unwrap_or(v.scale).max(0.0),
+            _ => return,
+        }
+        // Switching to Manual starts From and To at the attribute's own
+        // range, which is what Auto was showing — not at 0..1.
+        if field == "range" && v.is_manual() && v.from == 0.0 && v.to == 1.0 {
+            if let Some(a) = self.scene_attributes.iter().find(|a| a.name == v.attribute) {
+                (v.from, v.to) = (a.min, a.max);
+            }
+        }
+        self.visualizers_changed(save);
+    }
+
+    /// Show the scene under the visualizers as they now stand, and keep them.
+    fn visualizers_changed(&mut self, save: bool) {
+        self.revisualize();
+        if save {
+            self.save_settings();
+        }
+    }
+}