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