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 }