graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the cameras and the parameter presets are commands
They were menus of two menubars that are not drawn, so nothing on screen
reached them. next_camera, previous_camera and default_camera step the
active camera, each camera node is a row of the palette, and
reset_parameters and custom_preset act on the node the params pane
shows, from the template default a subnet's override included.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 22 +++++++++--
src/app.rs | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/command.rs | 10 +++++
src/dialog.rs | 32 +++++++++++++++-
src/main.rs | 69 ++++++++++++++++++++++++++++++++--
src/window.rs | 100 +++++++------------------------------------------
6 files changed, 255 insertions(+), 94 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 21978cd..15544f4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -105,13 +105,29 @@ the schemas in sync — `test_mcp_tools_map_to_actions` enforces the mapping.
`HEADER_H` and `MENUBAR_H` are 0 — kept as pane identities and for their
checkmarks, so the MCP tool is the only thing that can click one. What
`process_window_event` still dispatches by index is what no registry command
-does: the viewport menubar's Camera menu, and the parameters menubar's Preset
-and Reset (`window::menu_is_dispatched`). Everything else they list is a
-command, reached through `run_command`; a click on it is an error saying so,
+was then: the viewport menubar's Camera menu, and the parameters menubar's
+Preset and Reset (`window::menu_is_dispatched`). Everything else they list is
+a command, reached through `run_command`; a click on it is an error saying so,
where it used to be accepted and, the item lists having drifted from the
matches, ran the wrong item (the header's Save opened a project).
`a_menubar_click_is_dispatched_or_refused` is the test.
+**The cameras and the presets are commands too** (the same day), so the
+palette reaches them and a chord can: `next_camera` / `previous_camera`
+step through `State::camera_names` (the Default Camera, then the level's
+camera nodes) and wrap, `default_camera` goes back to it, and each camera
+NODE is a row of the palette — `Camera: camera1`, id under
+`CAMERA_ROW_PREFIX`, ranked among the commands as the viewport's, with
+`active` in the chord column of the one in use. They are rows and not
+registry commands because they are nodes, as the recent projects are paths.
+`State::choose_camera` is the one entry. `reset_parameters` and
+`custom_preset` run `State::apply_param_preset` on the node the params pane
+shows, from `template_default` — so a child inside a subnet instance resets
+to the subnet template's override, where the menubar's arm looked the
+template up by node type and missed it. All five ship unbound. The three
+menubar menus run the same functions.
+`the_cameras_and_the_presets_are_commands` is the test.
+
(The former bespoke HTTP API on port 3000 was retired in favor of this;
app-internal threads like the cce-files choosers now return results via
`CustomEvent::RunAction` instead of POSTing to it.)
diff --git a/src/app.rs b/src/app.rs
index 188c628..b33080c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3058,6 +3058,107 @@ impl State {
self.active_camera = name;
}
+ /// The cameras this level offers, the Default Camera first: what the
+ /// camera commands step through and the palette's camera rows list.
+ pub fn camera_names(&self) -> Vec<String> {
+ let mut names = vec!["Default Camera".to_string()];
+ names.extend(
+ self.current_dir()
+ .children
+ .iter()
+ .filter(|c| c.node_type == "camera")
+ .map(|c| c.name.clone()),
+ );
+ names
+ }
+
+ /// Look through the named camera, if this level has it. The one entry
+ /// the camera commands, the palette's camera rows and the menubar's
+ /// Camera menu share.
+ pub fn choose_camera(&mut self, name: &str) -> bool {
+ if !self.camera_names().iter().any(|n| n == name) {
+ self.update_status_text(&format!("No camera named '{name}' here"));
+ return false;
+ }
+ self.set_active_camera(name);
+ // Re-checks the menubar's marks and the readouts keyed on the view.
+ self.sync_nodes();
+ self.update_status_text(&format!("Camera: {name}"));
+ true
+ }
+
+ /// Step to the next camera (or the previous), wrapping.
+ pub fn cycle_camera(&mut self, step: i32) -> bool {
+ let names = self.camera_names();
+ let at = names.iter().position(|n| *n == self.active_camera).unwrap_or(0) as i32;
+ let next = (at + step).rem_euclid(names.len() as i32) as usize;
+ self.choose_camera(&names[next])
+ }
+
+ /// Set every parameter of the node the params pane shows back to its
+ /// template's default — the text AND whether it is an expression, so a
+ /// default that is a reference is one again. `custom` is the menubar's
+ /// other preset: the defaults with their numbers half as large again.
+ pub fn apply_param_preset(&mut self, custom: bool) -> bool {
+ let Some(slot) = self.param_editor_selected() else {
+ self.update_status_text("No node selected");
+ return false;
+ };
+ let dir = self.param_editor_dir();
+ let Some(node) = dir.children.get(slot) else { return false };
+ // A subnet template's override for a child inside an instance wins,
+ // as it does for the row menu's Default.
+ let defaults: Vec<(String, String, bool)> = node
+ .params
+ .iter()
+ .filter_map(|p| {
+ self.template_default(dir, node, &p.name)
+ .map(|d| (p.name.clone(), d.text().to_string(), d.is_expr()))
+ })
+ .collect();
+ let name = node.name.clone();
+ if defaults.is_empty() {
+ self.update_status_text(&format!("{name} has no template to reset to"));
+ return false;
+ }
+ let half_again = |text: &str| -> String {
+ let one = |t: &str| -> Option<String> {
+ if let Ok(v) = t.parse::<i32>() {
+ Some(format!("{}", v * 2))
+ } else {
+ t.parse::<f32>().ok().map(|v| format!("{:.2}", v * 1.5))
+ }
+ };
+ if let Some(v) = one(text) {
+ v
+ } else if text.contains(':') {
+ text.split(':').map(|t| one(t).unwrap_or_else(|| t.to_string())).collect::<Vec<_>>().join(":")
+ } else {
+ text.to_string()
+ }
+ };
+ let node = &mut self.param_editor_dir_mut().children[slot];
+ for (pname, text, is_expr) in defaults {
+ if let Some(p) = node.params.iter_mut().find(|p| p.name == pname) {
+ if custom && !is_expr {
+ p.set_text(half_again(&text));
+ } else {
+ p.set_text(text);
+ }
+ p.set_expr(is_expr);
+ }
+ }
+ self.sync_nodes();
+ self.rebuild_scene_geometry();
+ self.sync_parameters_pane();
+ self.update_status_text(&if custom {
+ format!("{name}: custom preset")
+ } else {
+ format!("{name}: parameters reset")
+ });
+ true
+ }
+
pub fn cursor_in_viewport(&self) -> bool {
if self.network_overlay() {
// The complement of the overlay: everything in the body the
@@ -3810,6 +3911,21 @@ impl State {
"Set As Default" => {
self.set_current_as_default();
}
+ "Next Camera" => {
+ self.cycle_camera(1);
+ }
+ "Previous Camera" => {
+ self.cycle_camera(-1);
+ }
+ "Default Camera" => {
+ self.choose_camera("Default Camera");
+ }
+ "Reset Parameters" => {
+ self.apply_param_preset(false);
+ }
+ "Custom Preset" => {
+ self.apply_param_preset(true);
+ }
"New Project" | "New" => {
self.new_project();
self.update_status_text("New project");
diff --git a/src/command.rs b/src/command.rs
index 716c958..cfb8439 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -219,10 +219,20 @@ pub const COMMANDS: &[Command] = &[
Command { id: "toggle_vertex_normals", label: "Show Vertex Normals", context: Context::Viewport, run: Run::Key(Action::ToggleVertexNormals), default_chord: None },
Command { id: "toggle_wire_single_color", label: "Wireframe Single Color", context: Context::Viewport, run: Run::Key(Action::ToggleWireSingleColor), default_chord: None },
Command { id: "toggle_ray_traced_preview", label: "Ray Traced Preview", context: Context::Viewport, run: Run::Key(Action::ToggleRayTracedPreview), default_chord: None },
+ // The viewport menubar's Camera menu, as commands. The cameras
+ // themselves are rows of the palette (`CAMERA_ROW_PREFIX`), being nodes
+ // and not a fixed list.
+ Command { id: "next_camera", label: "Next Camera", context: Context::Viewport, run: Run::Menu("Next Camera"), default_chord: None },
+ Command { id: "previous_camera", label: "Previous Camera", context: Context::Viewport, run: Run::Menu("Previous Camera"), default_chord: None },
+ Command { id: "default_camera", label: "Default Camera", context: Context::Viewport, run: Run::Menu("Default Camera"), default_chord: None },
Command { id: "toggle_square_viewport", label: "Square Aspect", context: Context::Viewport, run: Run::Key(Action::ToggleSquareViewport), default_chord: Some("Ctrl+a") },
// --- Parameters ---
Command { id: "export", label: "Export", context: Context::Parameters, run: Run::Menu("Export"), default_chord: None },
+ // The parameters menubar's Preset and Reset menus. That bar is not
+ // drawn, so until these rows the two were reachable by MCP alone.
+ Command { id: "reset_parameters", label: "Reset Parameters", context: Context::Parameters, run: Run::Menu("Reset Parameters"), default_chord: None },
+ Command { id: "custom_preset", label: "Custom Preset", context: Context::Parameters, run: Run::Menu("Custom Preset"), default_chord: None },
// --- Playbar ---
Command { id: "play_pause", label: "Play / Pause", context: Context::Playbar, run: Run::Key(Action::PlayPause), default_chord: Some("Up") },
diff --git a/src/dialog.rs b/src/dialog.rs
index 9d55981..d00dbaf 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -1177,6 +1177,10 @@ pub const PATH_ROW_ID: &str = "project_path";
/// document's own path already is: rows under the path row, each opening its
/// project. Ranked against the path text like everything else.
pub const RECENT_ROW_PREFIX: &str = "recent:";
+/// A camera NODE of the current level, as a palette row: picking it looks
+/// through that camera. The Default Camera is a registry command instead
+/// (`default_camera`), there being always exactly one.
+pub const CAMERA_ROW_PREFIX: &str = "camera:";
/// How many recent projects the list offers. `recent_files` keeps ten; five
/// is what fits above the commands without the palette reading as a file
@@ -1392,12 +1396,18 @@ impl State {
let rows: Vec<Row> = match self.slots.dialog.mode {
Mode::Commands => {
let cmds = crate::command::COMMANDS;
+ // The level's camera nodes rank among the commands, as
+ // the viewport's: "Camera: camera1".
+ let cameras: Vec<String> = self.camera_names().into_iter().skip(1).collect();
+ let camera_labels: Vec<String> = cameras.iter().map(|n| format!("Camera: {n}")).collect();
let mut labels: Vec<&str> = cmds.iter().map(|c| c.label).collect();
labels.extend(SETTINGS.iter().map(|s| s.label));
+ labels.extend(camera_labels.iter().map(|l| l.as_str()));
let contexts: Vec<Context> = cmds
.iter()
.map(|c| c.context)
.chain(SETTINGS.iter().map(|_| Context::Always))
+ .chain(cameras.iter().map(|_| Context::Viewport))
.collect();
let ranked = crate::command::rank_with_focus(&query, &labels, &contexts, self.focused_context());
let mut rows: Vec<Row> = ranked
@@ -1416,8 +1426,20 @@ impl State {
control: self.command_toggle_state(c.id).map(Control::Toggle),
truncate_head: false,
}
- } else {
+ } else if i < cmds.len() + SETTINGS.len() {
self.setting_row(&SETTINGS[i - cmds.len()])
+ } else {
+ let at = i - cmds.len() - SETTINGS.len();
+ let active = cameras[at] == self.active_camera;
+ Row {
+ id: format!("{CAMERA_ROW_PREFIX}{}", cameras[at]),
+ label: camera_labels[at].clone(),
+ // The column a chord would use says which
+ // camera the viewport is looking through.
+ chord: if active { "active".to_string() } else { String::new() },
+ control: None,
+ truncate_head: false,
+ }
}
})
.collect();
@@ -2178,6 +2200,14 @@ impl State {
return;
}
}
+ if mode == Mode::Commands {
+ if let Some(name) = id.strip_prefix(CAMERA_ROW_PREFIX) {
+ let name = name.to_string();
+ self.close_dialog();
+ self.choose_camera(&name);
+ return;
+ }
+ }
if mode == Mode::Commands && self.command_toggle_state(&id).is_some() {
self.run_command(&id);
self.refresh_dialog_controls();
diff --git a/src/main.rs b/src/main.rs
index 4581726..f0b7a5b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -6653,6 +6653,66 @@ mod tests {
assert_eq!(state.fs_root.children.len(), nodes, "a refused click ran New Project");
}
+ /// The cameras and the parameter presets are commands. They were menus
+ /// of two menubars that are not drawn, so nothing on screen reached them.
+ #[test]
+ fn the_cameras_and_the_presets_are_commands() {
+ use crate::dialog::CAMERA_ROW_PREFIX;
+ let mut state = State::new(false);
+ assert!(state.camera_names().contains(&"camera1".to_string()), "the bundled project has camera1");
+
+ // Stepping wraps, and both copies of the name follow.
+ state.set_active_camera("Default Camera");
+ let count = state.camera_names().len();
+ for _ in 0..count {
+ assert!(state.run_command("next_camera"));
+ assert_eq!(state.viewport().active_camera, state.active_camera);
+ }
+ assert_eq!(state.active_camera, "Default Camera", "a full turn comes back");
+ assert!(state.run_command("previous_camera"));
+ assert_eq!(state.active_camera, *state.camera_names().last().unwrap());
+ assert!(state.run_command("default_camera"));
+ assert_eq!(state.active_camera, "Default Camera");
+
+ // A camera node is a row of the palette, found by its name.
+ state.open_dialog();
+ state.slots.dialog.query = "camera1".to_string();
+ state.refresh_dialog_rows();
+ let id = format!("{CAMERA_ROW_PREFIX}camera1");
+ let row = state.slots.dialog.rows.iter().position(|r| r.id == id).expect("no row for camera1");
+ assert_eq!(state.slots.dialog.rows[row].label, "Camera: camera1");
+ state.slots.dialog.selected = row;
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+ assert!(!state.dialog_visible());
+ assert_eq!(state.active_camera, "camera1");
+ assert_eq!(state.viewport().active_camera, "camera1");
+
+ // Reset puts a changed parameter back; the custom preset moves it.
+ let slot = state
+ .current_dir()
+ .children
+ .iter()
+ .position(|c| c.node_type == "sphere")
+ .expect("the bundled project has a sphere");
+ state.graph_mut().set_selected_node(Some(slot));
+ let default = {
+ let dir = state.current_dir();
+ state.template_default(dir, &dir.children[slot], "Radius").expect("no Radius default").text().to_string()
+ };
+ let radius = |state: &State| {
+ state.current_dir().children[slot].params.iter().find(|p| p.name == "Radius").unwrap().text().to_string()
+ };
+ state.current_dir_mut().children[slot].params.iter_mut().find(|p| p.name == "Radius").unwrap().set_text("3.25".to_string());
+ assert!(state.run_command("reset_parameters"));
+ assert_eq!(radius(&state), default);
+ assert!(state.run_command("custom_preset"));
+ assert_ne!(radius(&state), default, "the custom preset left Radius at its default");
+
+ // With nothing selected there is nothing to reset, and it says so.
+ state.graph_mut().set_selected_node(None);
+ state.run_command("reset_parameters");
+ }
+
/// New Project from the palette starts a project. The command named a
/// label no arm dispatched, so the row ran and nothing happened.
#[test]
@@ -12078,10 +12138,11 @@ mod tests {
assert!(state.run_command("toggle_dialog"));
assert!(state.dialog_visible());
- // Every command — beside the setting rows, and the network pane's
- // zoom slider row when that pane is focused (it is by default).
+ // Every command — beside the setting rows, the level's camera
+ // rows, and the network pane's zoom slider row when that pane is
+ // focused (it is by default).
assert_eq!(
- state.slots.dialog.rows.iter().filter(|r| !r.id.starts_with(crate::dialog::SETTING_ROW_PREFIX) && r.id != crate::dialog::ZOOM_ROW_ID).count(),
+ state.slots.dialog.rows.iter().filter(|r| !r.id.starts_with(crate::dialog::SETTING_ROW_PREFIX) && r.id != crate::dialog::ZOOM_ROW_ID && !r.id.starts_with(crate::dialog::CAMERA_ROW_PREFIX)).count(),
crate::command::COMMANDS.len(),
"an empty query lists everything"
);
@@ -12552,7 +12613,7 @@ mod tests {
}
assert_eq!(state.slots.dialog.query, "");
assert_eq!(
- state.slots.dialog.rows.iter().filter(|r| !r.id.starts_with(crate::dialog::SETTING_ROW_PREFIX) && r.id != crate::dialog::ZOOM_ROW_ID).count(),
+ state.slots.dialog.rows.iter().filter(|r| !r.id.starts_with(crate::dialog::SETTING_ROW_PREFIX) && r.id != crate::dialog::ZOOM_ROW_ID && !r.id.starts_with(crate::dialog::CAMERA_ROW_PREFIX)).count(),
crate::command::COMMANDS.len()
);
}
diff --git a/src/window.rs b/src/window.rs
index 7874477..9874e56 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -26,8 +26,9 @@ pub enum WindowEvent {
}
/// The menubar menus `process_window_event` still dispatches by index: the
-/// viewport's Camera menu and the parameters' Preset and Reset. The rest of
-/// what the menubars list is a registry command.
+/// viewport's Camera menu and the parameters' Preset and Reset, which run
+/// what the camera and preset commands run. The rest of what the menubars
+/// list is reached as a registry command.
pub(crate) fn menu_is_dispatched(widget_idx: usize, menu_idx: usize) -> bool {
match widget_idx {
RIGHT_MENUBAR_IDX => menu_idx == 0,
@@ -153,97 +154,24 @@ impl State {
// The menubars are not drawn (their bars have no height), so a
// click reaches these only through MCP's `menu_click`. What is
- // dispatched here is what no registry command does: choosing the
- // active camera, and the parameter presets. Everything else the
- // menubars list is a command, and is run as one.
+ // dispatched here is the two menus whose items are chosen by
+ // position: the cameras, and the parameter presets. Both run what
+ // their commands run. Everything else the menubars list is
+ // reached as a command.
if let Some((menu_idx, item_idx)) = state.menu_mut(RIGHT_MENUBAR_IDX).menu_click() {
if menu_idx == 0 {
- let camera_nodes: Vec<String> = state.current_dir().children.iter()
- .filter(|c| c.node_type == "camera")
- .map(|c| c.name.clone())
- .collect();
- let mut items = vec!["Default Camera".to_string()];
- items.extend(camera_nodes);
- if item_idx < items.len() {
- state.set_active_camera(items[item_idx].clone());
- let active_cam = state.active_camera.clone();
- for (i, item) in items.iter().enumerate() {
- state.menu_mut(RIGHT_MENUBAR_IDX).set_item_checked(0, i, item == &active_cam);
- }
- changed = true;
+ if let Some(name) = state.camera_names().get(item_idx).cloned() {
+ changed |= state.choose_camera(&name);
}
}
}
if let Some((menu_idx, item_idx)) = state.menu_mut(PARAM_MENUBAR_IDX).menu_click() {
- if menu_idx == 0 { // Preset
- if let Some(slot_idx) = state.graph().selected_node() {
- let node_type = state.current_dir().children[slot_idx].node_type.clone();
- let template_params = state.node_templates.iter()
- .find(|t| t.node.node_type == node_type)
- .map(|t| t.node.params.clone());
- if let Some(params_to_reset) = template_params {
- if item_idx == 0 { // Default
- for template_param in ¶ms_to_reset {
- if let Some(p) = state.current_dir_mut().children[slot_idx].params.iter_mut().find(|p| p.name == template_param.name) {
- // The template's text AND whether it is an
- // expression: a default that is a reference
- // is one again after a reset.
- p.set_text(template_param.text().to_string());
- p.set_expr(template_param.is_expr());
- }
- }
- } else if item_idx == 1 { // Custom
- for template_param in ¶ms_to_reset {
- if let Some(p) = state.current_dir_mut().children[slot_idx].params.iter_mut().find(|p| p.name == template_param.name) {
- if let Ok(v) = template_param.text().parse::<f32>() {
- p.set_text(format!("{:.2}", v * 1.5));
- } else if let Ok(v) = template_param.text().parse::<i32>() {
- p.set_text(format!("{}", v * 2));
- } else if template_param.text().contains(':') {
- let parts: Vec<&str> = template_param.text().split(':').collect();
- let custom_parts: Vec<String> = parts.iter().map(|p_str| {
- if let Ok(v) = p_str.parse::<f32>() {
- format!("{:.2}", v * 1.5)
- } else {
- p_str.to_string()
- }
- }).collect();
- p.set_text(custom_parts.join(":"));
- } else {
- p.set_text(template_param.text().to_string());
- }
- }
- }
- }
- state.sync_nodes();
- state.rebuild_scene_geometry();
- changed = true;
- }
- }
- } else if menu_idx == 1 { // Reset
- if item_idx == 0 { // All
- if let Some(slot_idx) = state.graph().selected_node() {
- let node_type = state.current_dir().children[slot_idx].node_type.clone();
- let template_params = state.node_templates.iter()
- .find(|t| t.node.node_type == node_type)
- .map(|t| t.node.params.clone());
- if let Some(params_to_reset) = template_params {
- for template_param in ¶ms_to_reset {
- if let Some(p) = state.current_dir_mut().children[slot_idx].params.iter_mut().find(|p| p.name == template_param.name) {
- // The template's text AND whether it is an
- // expression: a default that is a reference
- // is one again after a reset.
- p.set_text(template_param.text().to_string());
- p.set_expr(template_param.is_expr());
- }
- }
- state.sync_nodes();
- state.rebuild_scene_geometry();
- changed = true;
- }
- }
- }
+ // Preset: Default, Custom. Reset: All, which is Default.
+ match (menu_idx, item_idx) {
+ (0, 0) | (1, 0) => changed |= state.apply_param_preset(false),
+ (0, 1) => changed |= state.apply_param_preset(true),
+ _ => {}
}
}