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

src/visualizer.rs (21.2K)

  1 //! Attribute visualizers: Houdini's viewport visualizers. A visualizer shows
  2 //! a point attribute of whatever the viewport displays — coloured through a
  3 //! ramp, or drawn as a line from each point — without a node in the graph.
  4 //!
  5 //! It is the Visualize NODE's reading of an attribute, and runs through that
  6 //! node's own [`crate::geometry::apply_visualize`] over a node built from its
  7 //! settings ([`Visualizer::as_node`]), so the two cannot disagree about what
  8 //! a Ramp or a Vector draws. What differs is where it lives: a node is part
  9 //! of the scene and travels with its chain; a visualizer is a DISPLAY
 10 //! setting, as every display setting here is — a live field on `State`
 11 //! (`State::visualizers`), persisted to state.kdl and with the project's
 12 //! display block, applied to the merged scene `Detail` at the end of a scene
 13 //! rebuild (`State::present_scene`), and reached from the palette (the
 14 //! `attribute_visualizers` command). Several apply in order, the later over
 15 //! the earlier, as a chain of Visualize nodes composites.
 16 //!
 17 //! They are edited in the params HUD (`State::vis_hud`, since 2026-10-06;
 18 //! they were the dialog's Visualizers / VisualizerEdit modes): a Visualizer
 19 //! dropdown picks the one edited, Add and Delete beside it, its settings as
 20 //! ordinary rows under it, and Done back to the selected node. The rows are
 21 //! a pseudo-node's parameters (`visualizer_hud_params`), so the HUD's own
 22 //! row building, `show_when` and controls serve them unchanged.
 23 
 24 use crate::app::{FsNode, ParamDef, State};
 25 use crate::detail::Detail;
 26 
 27 pub const MODES: [&str; 2] = ["Ramp", "Vector"];
 28 pub const RAMPS: [&str; 4] = ["Grayscale", "Heat", "Spectrum", "Viridis"];
 29 pub const RANGES: [&str; 2] = ["Auto", "Manual"];
 30 pub const BLENDS: [&str; 3] = ["Set", "Multiply", "Add"];
 31 
 32 fn ramp_mode() -> String {
 33     "Ramp".into()
 34 }
 35 fn viridis() -> String {
 36     "Viridis".into()
 37 }
 38 fn auto() -> String {
 39     "Auto".into()
 40 }
 41 fn set() -> String {
 42     "Set".into()
 43 }
 44 fn fifth() -> f32 {
 45     0.2
 46 }
 47 
 48 /// One visualizer: the Visualize node's settings, under the same names and
 49 /// options, and whether it is on.
 50 #[derive(Clone, Debug, PartialEq)]
 51 pub struct Visualizer {
 52     pub enabled: bool,
 53     /// The point attribute shown.
 54     pub attribute: String,
 55     /// `Ramp` colours points by the attribute; `Vector` draws a line along
 56     /// it from each point.
 57     pub mode: String,
 58     pub ramp: String,
 59     /// `Auto` spreads the ramp over the attribute's range in the scene;
 60     /// `Manual` over Manual Range.
 61     pub range: String,
 62     /// The ramp's two ends under a Manual range: the node's float2, edited
 63     /// in the params HUD as one `float2` row.
 64     pub manual_range: [f32; 2],
 65     pub blend: String,
 66     pub opacity: f32,
 67     /// A Vector line's length per unit of the attribute.
 68     pub scale: f32,
 69     /// A point group the visualizer is limited to; empty is every point.
 70     pub group: String,
 71 }
 72 
 73 impl Visualizer {
 74     /// Whether this one does anything: on, and naming an attribute.
 75     pub fn applies(&self) -> bool {
 76         self.enabled && !self.attribute.trim().is_empty()
 77     }
 78 
 79     /// A new visualizer on `attribute`, on, with the node's defaults.
 80     pub fn new(attribute: &str) -> Visualizer {
 81         Visualizer {
 82             enabled: true,
 83             attribute: attribute.to_string(),
 84             mode: ramp_mode(),
 85             ramp: viridis(),
 86             range: auto(),
 87             manual_range: [0.0, 1.0],
 88             blend: set(),
 89             opacity: 1.0,
 90             scale: fifth(),
 91             group: String::new(),
 92         }
 93     }
 94 
 95     pub fn is_vector(&self) -> bool {
 96         self.mode.eq_ignore_ascii_case("vector")
 97     }
 98 
 99     pub fn is_manual(&self) -> bool {
100         self.range.eq_ignore_ascii_case("manual")
101     }
102 
103     /// What the list calls it: the attribute and how it is shown.
104     pub fn label(&self) -> String {
105         let attr = if self.attribute.is_empty() { "(no attribute)" } else { &self.attribute };
106         let how = if self.is_vector() { format!("Vector ×{:.2}", self.scale) } else { format!("Ramp, {}", self.ramp) };
107         let group = if self.group.is_empty() { String::new() } else { format!(" in {}", self.group) };
108         format!("{attr} — {how}{group}")
109     }
110 
111     /// A Visualize node carrying these settings, for `apply_visualize`.
112     pub fn as_node(&self) -> FsNode {
113         let p = |name: &str, ty: &str, text: String| ParamDef::new(name.to_string(), ty.to_string(), text);
114         FsNode {
115             id: String::new(),
116             name: "visualizer".to_string(),
117             node_type: "visualize".to_string(),
118             children: Vec::new(),
119             params: vec![
120                 p("attribute", "attribute", self.attribute.clone()),
121                 p("mode", "choice", self.mode.clone()),
122                 p("ramp", "choice", self.ramp.clone()),
123                 p("range", "choice", self.range.clone()),
124                 p("manual_range", "float2", format!("{}:{}", self.manual_range[0], self.manual_range[1])),
125                 p("blend", "choice", self.blend.clone()),
126                 p("opacity", "float", self.opacity.to_string()),
127                 p("scale", "float", self.scale.to_string()),
128                 p("group", "group", self.group.clone()),
129             ],
130             geometry_visible: true,
131             bypassed: false,
132             position: (0.0, 0.0),
133             inputs: 1,
134             outputs: 1,
135         }
136     }
137 }
138 
139 /// Apply every visualizer that is on to `geom`, in order. One naming an
140 /// attribute the scene does not have does nothing: it is a display setting,
141 /// and the scene it was made for may come back.
142 pub fn apply_all(visualizers: &[Visualizer], geom: &mut Detail) {
143     for v in visualizers.iter().filter(|v| v.applies()) {
144         let mut ignored = None;
145         crate::geometry::apply_visualize(geom, &v.as_node(), &mut ignored);
146     }
147 }
148 
149 /// Whether [`apply_all`] would change anything: some visualizer is on and
150 /// names an attribute.
151 pub fn any_applies(visualizers: &[Visualizer]) -> bool {
152     visualizers.iter().any(Visualizer::applies)
153 }
154 
155 /// The visualizers as the settings hold them: one string, a visualizer per
156 /// `;`, each `key=value` pairs joined by `|`, with `%`, `|`, `;`, `=`, `"`
157 /// and `\` percent-escaped in the values. One string, as the marked groups
158 /// are one, because the KDL writer cannot be trusted with a list of
159 /// records; and not JSON, because the writer then put a string between
160 /// quotes WITHOUT escaping the ones inside it — a JSON string came back as
161 /// a line no parser reads, and a settings file that fails to parse is read
162 /// as the DEFAULTS. (cce-ui escapes since 2026-10-01; this format stays,
163 /// being what the files hold.) A key the reader does not know is skipped
164 /// and one it lacks takes the default, so the format can grow.
165 pub fn encode(visualizers: &[Visualizer]) -> String {
166     visualizers
167         .iter()
168         .map(|v| {
169             [
170                 ("enabled", v.enabled.to_string()),
171                 ("attribute", v.attribute.clone()),
172                 ("mode", v.mode.clone()),
173                 ("ramp", v.ramp.clone()),
174                 ("range", v.range.clone()),
175                 ("manual_range", format!("{}:{}", v.manual_range[0], v.manual_range[1])),
176                 ("blend", v.blend.clone()),
177                 ("opacity", v.opacity.to_string()),
178                 ("scale", v.scale.to_string()),
179                 ("group", v.group.clone()),
180             ]
181             .iter()
182             .map(|(k, val)| format!("{k}={}", escape(val)))
183             .collect::<Vec<_>>()
184             .join("|")
185         })
186         .collect::<Vec<_>>()
187         .join(";")
188 }
189 
190 /// `lo:hi` as two numbers.
191 fn two(text: &str) -> Option<[f32; 2]> {
192     let (a, b) = text.split_once(':')?;
193     Some([a.trim().parse().ok()?, b.trim().parse().ok()?])
194 }
195 
196 /// The visualizers out of the settings' one string; a record that names no
197 /// field it knows is dropped.
198 pub fn decode(text: &str) -> Vec<Visualizer> {
199     text.split(';')
200         .filter(|r| !r.trim().is_empty())
201         .filter_map(|record| {
202             let mut v = Visualizer::new("");
203             let mut known = false;
204             for pair in record.split('|') {
205                 let Some((k, raw)) = pair.split_once('=') else { continue };
206                 let val = unescape(raw);
207                 let num = |d: f32| val.parse::<f32>().unwrap_or(d);
208                 known = true;
209                 match k.trim() {
210                     "enabled" => v.enabled = val != "false",
211                     "attribute" => v.attribute = val,
212                     "mode" => v.mode = val,
213                     "ramp" => v.ramp = val,
214                     "range" => v.range = val,
215                     "manual_range" => v.manual_range = two(&val).unwrap_or(v.manual_range),
216                     // Its two ends, as a settings file from before kept them.
217                     "from" => v.manual_range[0] = num(v.manual_range[0]),
218                     "to" => v.manual_range[1] = num(v.manual_range[1]),
219                     "blend" => v.blend = val,
220                     "opacity" => v.opacity = num(v.opacity),
221                     "scale" => v.scale = num(v.scale),
222                     "group" => v.group = val,
223                     _ => {}
224                 }
225             }
226             known.then_some(v)
227         })
228         .collect()
229 }
230 
231 const ESCAPED: [char; 6] = ['%', '|', ';', '=', '"', '\\'];
232 
233 fn escape(s: &str) -> String {
234     s.chars().map(|c| if ESCAPED.contains(&c) { format!("%{:02X}", c as u32) } else { c.to_string() }).collect()
235 }
236 
237 fn unescape(s: &str) -> String {
238     let mut out = String::new();
239     let mut chars = s.chars();
240     while let Some(c) = chars.next() {
241         if c == '%' {
242             let hex: String = chars.by_ref().take(2).collect();
243             match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
244                 Some(d) => out.push(d),
245                 None => {
246                     out.push('%');
247                     out.push_str(&hex);
248                 }
249             }
250         } else {
251             out.push(c);
252         }
253     }
254     out
255 }
256 
257 /// A point attribute of the displayed scene, as the editor offers it: its
258 /// name and the range of its first component, which is what a Ramp reads.
259 #[derive(Clone, Debug, PartialEq)]
260 pub struct SceneAttribute {
261     pub name: String,
262     pub min: f32,
263     pub max: f32,
264 }
265 
266 /// The scene's point attributes, by name, with their ranges — leaving out the
267 /// vector markers a Visualize stages (`vis_*`), which are not the scene's.
268 pub fn scene_attributes(geom: &Detail) -> Vec<SceneAttribute> {
269     let mut out: Vec<SceneAttribute> = geom
270         .points()
271         .names()
272         .into_iter()
273         .filter(|n| !n.starts_with(crate::detail::VIS_PREFIX))
274         .map(|name| {
275             let (mut min, mut max) = (f32::INFINITY, f32::NEG_INFINITY);
276             // The column found once, not by name at every point: this runs
277             // on every scene rebuild, every frame of a playing simulation.
278             let data = geom.points().get(name);
279             for p in 0..geom.num_points() {
280                 if let Some(v) = data.and_then(|d| d.get(p)) {
281                     let x = v.as_f32();
282                     if x.is_finite() {
283                         min = min.min(x);
284                         max = max.max(x);
285                     }
286                 }
287             }
288             if !min.is_finite() {
289                 (min, max) = (0.0, 1.0);
290             }
291             SceneAttribute { name: name.to_string(), min, max }
292         })
293         .collect();
294     out.sort_by(|a, b| a.name.cmp(&b.name));
295     out
296 }
297 
298 /// The Attribute and Group choices' word for "nothing chosen".
299 const NO_ATTRIBUTE: &str = "(none)";
300 const ALL_POINTS: &str = "(all points)";
301 
302 impl State {
303     /// Add a visualizer, on, on the first attribute of the scene that is
304     /// not its colour or its position — what is worth looking at — and
305     /// return its index.
306     pub(crate) fn add_visualizer(&mut self) -> usize {
307         let pick = self
308             .scene_attributes
309             .iter()
310             .map(|a| a.name.as_str())
311             .find(|n| !matches!(*n, "P" | "Cd" | "N"))
312             .or_else(|| self.scene_attributes.first().map(|a| a.name.as_str()))
313             .unwrap_or("")
314             .to_string();
315         self.visualizers.push(Visualizer::new(&pick));
316         self.visualizers_changed(true);
317         self.visualizers.len() - 1
318     }
319 
320     pub(crate) fn delete_visualizer(&mut self, i: usize) {
321         if i < self.visualizers.len() {
322             self.visualizers.remove(i);
323             self.visualizers_changed(true);
324         }
325     }
326 
327     /// Write one of visualizer `i`'s settings from the text its row holds —
328     /// a choice's option, a toggle's `true`/`false`, a number.
329     pub(crate) fn set_visualizer_field(&mut self, i: usize, field: &str, value: &str, save: bool) {
330         let Some(v) = self.visualizers.get_mut(i) else { return };
331         let number = || value.parse::<f32>().ok();
332         match field {
333             "enabled" => v.enabled = value == "true",
334             "attribute" => v.attribute = if value == NO_ATTRIBUTE { String::new() } else { value.to_string() },
335             "mode" => v.mode = value.to_string(),
336             "ramp" => v.ramp = value.to_string(),
337             "range" => v.range = value.to_string(),
338             "blend" => v.blend = value.to_string(),
339             "group" => v.group = if value == ALL_POINTS { String::new() } else { value.to_string() },
340             "manual_range" => v.manual_range = two(value).unwrap_or(v.manual_range),
341             "opacity" => v.opacity = number().unwrap_or(v.opacity).clamp(0.0, 1.0),
342             "scale" => v.scale = number().unwrap_or(v.scale).max(0.0),
343             _ => return,
344         }
345         // Switching to Manual starts the range at the attribute's own,
346         // which is what Auto was showing — not at 0..1.
347         if field == "range" && v.is_manual() && v.manual_range == [0.0, 1.0] {
348             if let Some(a) = self.scene_attributes.iter().find(|a| a.name == v.attribute) {
349                 v.manual_range = [a.min, a.max];
350             }
351         }
352         self.visualizers_changed(save);
353     }
354 
355     /// Show the scene under the visualizers as they now stand, and keep them.
356     fn visualizers_changed(&mut self, save: bool) {
357         self.revisualize();
358         if save {
359             self.save_settings();
360         }
361     }
362 }
363 
364 /// The HUD's visualizer view: its rows' parameter names.
365 const HUD_PICK: &str = "visualizer";
366 const HUD_ADD: &str = "add_visualizer";
367 const HUD_DELETE: &str = "delete_visualizer";
368 const HUD_DONE: &str = "done";
369 
370 impl State {
371     /// Show the attribute visualizers in the params HUD, editing the first
372     /// (or `i`), in place of the selected node's parameters.
373     pub(crate) fn open_visualizers_hud(&mut self) {
374         self.vis_hud = Some(self.vis_hud.unwrap_or(0).min(self.visualizers.len().saturating_sub(1)));
375         self.vis_hud_from = self.param_pane_target();
376         if !self.show_parameters {
377             self.execute_menu_action("Show Parameters Pane");
378         }
379         self.sync_parameters_pane();
380         self.update_status_text("Attribute Visualizers: in the parameters; Done goes back to the node.");
381     }
382 
383     /// Back to the selected node's parameters.
384     pub(crate) fn close_visualizers_hud(&mut self) {
385         if self.vis_hud.take().is_some() {
386             self.sync_parameters_pane();
387         }
388     }
389 
390     /// What the picker calls visualizer `i`.
391     fn visualizer_pick_label(&self, i: usize) -> String {
392         let v = &self.visualizers[i];
393         let attr = if v.attribute.is_empty() { NO_ATTRIBUTE } else { &v.attribute };
394         format!("#{} {attr}", i + 1)
395     }
396 
397     /// The HUD's visualizer view as a pseudo-node's parameters: the picker,
398     /// Add and Delete, the edited visualizer's settings under the Visualize
399     /// node's names and `show_when` conditions, and Done. With none yet,
400     /// Add and Done alone.
401     pub(crate) fn visualizer_hud_params(&self) -> Vec<ParamDef> {
402         let p = |name: &str, ty: String, text: String, label: &str| ParamDef::new(name.to_string(), ty, text).with_label(label);
403         let grouped = |mut d: ParamDef, g: &str| {
404             d.group = g.to_string();
405             d
406         };
407         let mut out = Vec::new();
408         let editing = self.vis_hud.filter(|&i| i < self.visualizers.len());
409         if let Some(i) = editing {
410             let labels: Vec<String> = (0..self.visualizers.len()).map(|k| self.visualizer_pick_label(k)).collect();
411             out.push(grouped(p(HUD_PICK, format!("choice:{}", labels.join(",")), labels[i].clone(), "Visualizer"), "pick"));
412         }
413         out.push(grouped(p(HUD_ADD, "button".into(), String::new(), "Add Visualizer"), "pick"));
414         if let Some(i) = editing {
415             out.push(grouped(p(HUD_DELETE, "button".into(), String::new(), "Delete Visualizer"), "pick"));
416             let v = &self.visualizers[i];
417             out.push(grouped(p("enabled", "toggle".into(), v.enabled.to_string(), "Enabled"), "settings"));
418             let mut attrs: Vec<String> = self.scene_attributes.iter().map(|a| a.name.clone()).collect();
419             if !v.attribute.is_empty() && !attrs.contains(&v.attribute) {
420                 attrs.push(v.attribute.clone());
421             }
422             if attrs.is_empty() {
423                 attrs.push(NO_ATTRIBUTE.to_string());
424             }
425             let attr = if v.attribute.is_empty() { attrs[0].clone() } else { v.attribute.clone() };
426             out.push(grouped(p("attribute", format!("choice:{}", attrs.join(",")), attr, "Attribute"), "settings"));
427             out.push(grouped(p("mode", format!("choice:{}", MODES.join(",")), v.mode.clone(), "Mode"), "settings"));
428             out.push(grouped(p("ramp", format!("choice:{}", RAMPS.join(",")), v.ramp.clone(), "Ramp").with_show_when("mode == Ramp"), "settings"));
429             out.push(grouped(p("range", format!("choice:{}", RANGES.join(",")), v.range.clone(), "Range").with_show_when("mode == Ramp"), "settings"));
430             out.push(grouped(
431                 p("manual_range", "float2".into(), format!("{}:{}", v.manual_range[0], v.manual_range[1]), "Manual Range").with_show_when("mode == Ramp && range == Manual"),
432                 "settings",
433             ));
434             out.push(grouped(p("blend", format!("choice:{}", BLENDS.join(",")), v.blend.clone(), "Blend").with_show_when("mode == Ramp"), "settings"));
435             out.push(grouped(p("opacity", "slider:0:1".into(), format!("{:.2}", v.opacity), "Opacity").with_show_when("mode == Ramp"), "settings"));
436             out.push(grouped(p("scale", format!("slider:0:{}", 10.0f32.max(v.scale)), format!("{:.2}", v.scale), "Scale").with_show_when("mode == Vector"), "settings"));
437             let mut groups: Vec<String> = vec![ALL_POINTS.to_string()];
438             groups.extend(self.scene_groups.iter().map(|(g, _)| g.clone()));
439             if !v.group.is_empty() && !groups.contains(&v.group) {
440                 groups.push(v.group.clone());
441             }
442             let group = if v.group.is_empty() { ALL_POINTS.to_string() } else { v.group.clone() };
443             out.push(grouped(p("group", format!("choice:{}", groups.join(",")), group, "Group"), "where"));
444         }
445         out.push(grouped(p(HUD_DONE, "button".into(), String::new(), "Done"), "done"));
446         out
447     }
448 
449     /// The HUD's rows written back into the visualizers: each row whose
450     /// value differs from what the view shows becomes the edit it names.
451     /// A change that adds or drops rows (another visualizer, Mode, Range,
452     /// Add, Delete) re-reads the view; a slider being dragged does not,
453     /// which would drop the slider held.
454     pub(crate) fn sync_visualizer_hud_back(&mut self) {
455         let shown = self.visualizer_hud_params();
456         let rows = self.param().node_params();
457         let mut reread = false;
458         let mut changed = false;
459         for (key, value, _) in rows {
460             let Some(def) = shown.iter().find(|d| d.shown_name() == key) else { continue };
461             if def.text() == value {
462                 continue;
463             }
464             let i = self.vis_hud.unwrap_or(0);
465             match def.name.as_str() {
466                 HUD_PICK => {
467                     if let Some(k) = (0..self.visualizers.len()).find(|&k| self.visualizer_pick_label(k) == value) {
468                         self.vis_hud = Some(k);
469                     }
470                     reread = true;
471                 }
472                 HUD_ADD if value == "clicked" => {
473                     let k = self.add_visualizer();
474                     self.vis_hud = Some(k);
475                     reread = true;
476                 }
477                 HUD_DELETE if value == "clicked" => {
478                     self.delete_visualizer(i);
479                     self.vis_hud = Some(i.min(self.visualizers.len().saturating_sub(1)));
480                     reread = true;
481                 }
482                 HUD_DONE if value == "clicked" => {
483                     self.close_visualizers_hud();
484                     return;
485                 }
486                 HUD_ADD | HUD_DELETE | HUD_DONE => {}
487                 field => {
488                     // Saved at the frame, not per motion of a drag.
489                     self.set_visualizer_field(i, field, &value, false);
490                     changed = true;
491                     if matches!(field, "mode" | "range" | "attribute") {
492                         reread = true;
493                     }
494                 }
495             }
496         }
497         if changed {
498             self.settings_save_pending = true;
499         }
500         if reread {
501             self.sync_parameters_pane();
502         }
503     }
504 }