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

src/image_tools.rs (13.8K)

  1 //! Working with 2D images from the viewport and the palette: the commands
  2 //! that turn the camera to an image, and the ones that make an image and put
  3 //! shapes and text on it.
  4 //!
  5 //! An image is a page (`src/page.rs`) — a `page` node and whatever draws on
  6 //! it — and the viewport shows the displayed one as a quad standing in the
  7 //! scene. Everything here is a registry command, so each is in the palette,
  8 //! bindable and scriptable over MCP's `run_command`.
  9 
 10 use crate::app::{FsNode, State};
 11 use crate::page::{is_page_node, PageShown};
 12 use glam::Vec3;
 13 
 14 /// What an Add … to Image command puts on the image.
 15 #[derive(Debug, Clone, Copy, PartialEq)]
 16 pub enum ImageLayer {
 17     Rectangle,
 18     Ellipse,
 19     Line,
 20     Polygon,
 21     Text,
 22 }
 23 
 24 impl ImageLayer {
 25     fn template(self) -> &'static str {
 26         match self {
 27             ImageLayer::Text => "Page Text",
 28             _ => "Page Shape",
 29         }
 30     }
 31 
 32     fn label(self) -> &'static str {
 33         match self {
 34             ImageLayer::Rectangle => "Rectangle",
 35             ImageLayer::Ellipse => "Ellipse",
 36             ImageLayer::Line => "Line",
 37             ImageLayer::Polygon => "Polygon",
 38             ImageLayer::Text => "Text",
 39         }
 40     }
 41 }
 42 
 43 /// The vertical field of view `Viewport3D::get_matrices` projects with,
 44 /// halved.
 45 const HALF_FOV: f32 = 0.45;
 46 
 47 /// How much of the pane a framed image leaves as margin, as a factor on the
 48 /// distance: the image spans ~93% of the axis that binds.
 49 const FRAME_MARGIN: f32 = 1.08;
 50 
 51 /// The distance at which an image `size` world units across fills a pane of
 52 /// `aspect`, seen head-on. The projection is a perspective, and a plane
 53 /// square to the view axis is scaled by it and not distorted, so one distance
 54 /// fits both axes and the nearer fit of the two is the one that binds.
 55 pub fn fit_distance(size: [f32; 2], aspect: f32) -> f32 {
 56     let t = HALF_FOV.tan();
 57     let by_height = size[1] / (2.0 * t);
 58     let by_width = size[0] / (2.0 * t * aspect.max(1e-4));
 59     by_height.max(by_width) * FRAME_MARGIN
 60 }
 61 
 62 /// The distance at which one pixel of an image `image_px` tall and `height`
 63 /// world units tall covers one pixel of a pane `pane_px` tall.
 64 pub fn pixel_distance(height: f32, image_px: u32, pane_px: u32) -> f32 {
 65     let world_per_px = height / image_px.max(1) as f32;
 66     world_per_px * pane_px.max(1) as f32 / (2.0 * HALF_FOV.tan())
 67 }
 68 
 69 fn set_param(node: &mut FsNode, name: &str, value: String) {
 70     if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
 71         p.set_text(value);
 72     }
 73 }
 74 
 75 /// A length for a parameter row: what the page's unit is read to. Pixels
 76 /// are whole, the rest two decimals.
 77 fn row_number(v: f32, unit: crate::page::PageUnit) -> String {
 78     if unit == crate::page::PageUnit::Pixels {
 79         format!("{}", v.round())
 80     } else {
 81         format!("{v:.2}")
 82     }
 83 }
 84 
 85 impl State {
 86     /// The pane's aspect as the stage pass has it.
 87     fn viewport_aspect(&self) -> f32 {
 88         if self.square_viewport {
 89             1.0
 90         } else {
 91             self.last_viewport_width.max(1) as f32 / self.last_viewport_height.max(1) as f32
 92         }
 93     }
 94 
 95     /// Put the active camera square to the image's plane, `dist` out from
 96     /// `center` along +Z — the side an image faces.
 97     ///
 98     /// The Default Camera has a fixed base ray and an orbit over it, so the
 99     /// orbit is set to what cancels the ray's own yaw and pitch and the zoom
100     /// to what makes the distance. A camera node is rewritten — Position,
101     /// Pivot and Rotation — as Frame All rewrites one; its Rotation takes up
102     /// whatever orbit the viewport widget is holding, which `get_matrices`
103     /// applies to every camera. Returns the distance reached, which is short
104     /// of the one asked for where the zoom's clamp refuses it.
105     fn face_image(&mut self, center: Vec3, dist: f32) -> f32 {
106         let camera_name = self.active_camera.clone();
107         let (orbit_x, orbit_y) = (self.viewport().rotation_x, self.viewport().rotation_y);
108         let mut reached = None;
109         if camera_name != "Default Camera" {
110             let dir = self.camera_level_mut();
111             if let Some(node) =
112                 dir.children.iter_mut().find(|c| c.node_type == "camera" && c.name == camera_name)
113             {
114                 let fmt3 = |v: Vec3| format!("{:.3}:{:.3}:{:.3}", v.x, v.y, v.z);
115                 set_param(node, "pivot", fmt3(center));
116                 set_param(node, "position", fmt3(center + Vec3::Z * dist));
117                 set_param(
118                     node,
119                     "rotation",
120                     format!("{:.3}:{:.3}:0.000", orbit_x.to_degrees(), orbit_y.to_degrees()),
121                 );
122                 reached = Some(dist);
123             }
124         }
125         let reached = match reached {
126             Some(d) => {
127                 let vp = self.viewport_mut();
128                 vp.zoom = 1.0;
129                 vp.pending_yaw = 0.0;
130                 vp.pending_pitch = 0.0;
131                 vp.reset_velocity();
132                 d
133             }
134             None => {
135                 let base = Vec3::new(2.5, 1.8, 2.5);
136                 let vp = self.viewport_mut();
137                 vp.pivot = center;
138                 vp.rotation_y = base.x.atan2(base.z);
139                 vp.rotation_x = (base.y / base.length()).asin();
140                 vp.zoom = (dist / base.length())
141                     .clamp(0.05, crate::viewport_3d::Viewport3D::MAX_ZOOM);
142                 vp.reset_velocity();
143                 vp.zoom * base.length()
144             }
145         };
146         self.viewport_dirty = true;
147         self.sync_nodes();
148         self.sync_parameters_pane();
149         reached
150     }
151 
152     /// The image the viewport shows, or a status line saying there is none.
153     fn image_to_frame(&mut self) -> Option<PageShown> {
154         let shown = self.page_shown.clone();
155         if shown.is_none() {
156             self.update_status_text("No image is shown: turn on an image node's display flag.");
157         }
158         shown
159     }
160 
161     /// `Frame Image`: turn the camera square to the image and fit it to the
162     /// pane.
163     pub fn frame_image(&mut self) -> bool {
164         let Some(shown) = self.image_to_frame() else { return false };
165         let size = shown.world_size(self.world_unit_mm());
166         let dist = fit_distance(size, self.viewport_aspect());
167         let reached = self.face_image(Vec3::from_array(shown.origin), dist);
168         if (reached - dist).abs() > dist * 1e-3 {
169             self.update_status_text("Framed the image as far as the zoom goes.");
170         } else {
171             self.update_status_text("Framed the image.");
172         }
173         true
174     }
175 
176     /// `View Image Pixels 1:1`: turn the camera square to the image at the
177     /// distance where one of its pixels covers one of the display's.
178     pub fn view_image_pixels(&mut self) -> bool {
179         let Some(shown) = self.image_to_frame() else { return false };
180         let size = shown.world_size(self.world_unit_mm());
181         let dist = pixel_distance(size[1], shown.pixels.1, self.last_viewport_height);
182         let reached = self.face_image(Vec3::from_array(shown.origin), dist);
183         if (reached - dist).abs() > dist * 1e-3 {
184             self.update_status_text("Image pixels 1:1 is past what the zoom reaches.");
185         } else {
186             self.update_status_text("Image pixels 1:1.");
187         }
188         true
189     }
190 
191     /// The scene's image in world space, for a bound that has to hold it.
192     pub(crate) fn image_world_corners(&self) -> Option<[[f32; 3]; 4]> {
193         let unit = self.world_unit_mm();
194         self.page_shown.as_ref().map(|s| s.world_corners(unit))
195     }
196 
197     /// Add a node from a template to the current level, at the nearest free
198     /// cell to `at`, and return its slot. The display flag is left as the
199     /// template has it cleared, for the caller to decide.
200     fn add_template_node(&mut self, template: &str, at: (f32, f32)) -> Option<usize> {
201         let idx = self.node_templates.iter().position(|t| {
202             t.label.eq_ignore_ascii_case(template) || t.node.name.eq_ignore_ascii_case(template)
203         })?;
204         let mut node = self.node_templates[idx].node.clone();
205         crate::app::regenerate_node_ids(&mut node);
206         node.position = self.find_empty_cell(at.0, at.1, None);
207         node.name = self.get_lowest_unused_name(&node.name);
208         node.geometry_visible = false;
209         self.current_dir_mut().children.push(node);
210         Some(self.current_dir().children.len() - 1)
211     }
212 
213     /// Show `slot`, select it, and bring everything that reads the tree up
214     /// to date — the tail every command here ends on.
215     fn show_and_select(&mut self, slot: usize) {
216         self.current_dir_mut().set_child_geometry_visible(slot, true);
217         if let Some(cell) = self.current_dir().children.get(slot).map(|n| n.position) {
218             self.grid_cursor_col = cell.0 as i32;
219             self.grid_cursor_row = cell.1 as i32;
220         }
221         self.deselected_cell = None;
222         self.sync_nodes();
223         self.graph_mut().set_selected_node(Some(slot));
224         self.rebuild_positions();
225         self.apply_layout();
226         self.update_panel_bounds();
227         self.rebuild_scene_geometry();
228         self.sync_parameters_pane();
229     }
230 
231     /// `New Image`: a page node at the grid cursor, shown and selected.
232     pub fn new_image(&mut self) -> Option<usize> {
233         let at = (self.grid_cursor_col as f32, self.grid_cursor_row as f32);
234         let slot = self.add_template_node("Page", at)?;
235         self.show_and_select(slot);
236         Some(slot)
237     }
238 
239     /// The node an Add … to Image command draws on: the selected node when
240     /// it belongs to an image, else the image the level shows.
241     fn image_target(&self) -> Option<usize> {
242         let dir = self.current_dir();
243         let is_image = |slot: &usize| {
244             dir.children.get(*slot).is_some_and(|n| is_page_node(&n.node_type))
245         };
246         self.selected_slots().into_iter().find(is_image).or_else(|| {
247             dir.children
248                 .iter()
249                 .enumerate()
250                 .filter(|(_, c)| is_page_node(&c.node_type) && c.geometry_visible)
251                 .map(|(i, _)| i)
252                 .next_back()
253         })
254     }
255 
256     /// `Add Rectangle to Image` and its family: a shape or text node wired
257     /// after the image's node, sized and placed from the image itself, shown
258     /// and selected so its rows are what the params pane holds next.
259     ///
260     /// With no image at the level one is made first. A target in the MIDDLE
261     /// of a chain has the new node inserted after it: what read the target
262     /// reads the new node.
263     pub fn add_to_image(&mut self, layer: ImageLayer) -> Option<usize> {
264         let target = match self.image_target() {
265             Some(slot) => slot,
266             None => self.new_image()?,
267         };
268         let (target_name, target_pos) = {
269             let n = &self.current_dir().children[target];
270             (n.name.clone(), n.position)
271         };
272         let Some(page) = crate::page::resolve_page(
273             &self.fs_root,
274             &self.current_dir().children[target],
275             &mut Vec::new(),
276         ) else {
277             self.update_status_text(&format!("{target_name} draws on no image: wire it to one."));
278             return None;
279         };
280 
281         let slot = self.add_template_node(layer.template(), (target_pos.0, target_pos.1 + 1.0))?;
282         let new_name = self.current_dir().children[slot].name.clone();
283         for (i, sibling) in self.current_dir_mut().children.iter_mut().enumerate() {
284             if i == slot || !is_page_node(&sibling.node_type) && sibling.node_type != "export" {
285                 continue;
286             }
287             for p in sibling.params.iter_mut() {
288                 if p.name == "input" && p.text().trim() == target_name {
289                     p.set_text(new_name.clone());
290                 }
291             }
292         }
293 
294         // In the page's unit, from the page's size: the middle of the image,
295         // a third of its width, and type a twentieth of its height tall.
296         let unit = page.unit;
297         let (w, h) = (page.in_unit(page.size[0]), page.in_unit(page.size[1]));
298         let num = |v: f32| row_number(v, unit);
299         let node = &mut self.current_dir_mut().children[slot];
300         set_param(node, "input", target_name);
301         set_param(node, "x", num(w * 0.5));
302         set_param(node, "y", num(h * 0.5));
303         match layer {
304             ImageLayer::Text => {
305                 set_param(node, "text", "Text".to_string());
306                 set_param(node, "size", num((h * 0.05).max(page.in_unit(1.0 / page.scale()))));
307                 set_param(node, "vertical", "Middle".to_string());
308             }
309             shape => {
310                 let side = w.min(h) / 3.0;
311                 set_param(node, "shape", shape.label().to_string());
312                 set_param(node, "width", num(if shape == ImageLayer::Line { w / 3.0 } else { side }));
313                 set_param(node, "height", num(side));
314                 // A hairline that survives the raster: a four-hundredth of
315                 // the short side, and never under a pixel and a half.
316                 let stroke = (page.size[0].min(page.size[1]) / 400.0).max(1.5 / page.scale());
317                 let stroke = page.in_unit(stroke);
318                 let stroke = if unit == crate::page::PageUnit::Pixels {
319                     format!("{}", stroke.round().max(1.0))
320                 } else {
321                     format!("{stroke:.3}")
322                 };
323                 set_param(node, "stroke_width", stroke);
324             }
325         }
326         self.show_and_select(slot);
327         // Straight into the node's viewer state: what was added is there to
328         // be placed, and its handles are how.
329         let node = &self.current_dir().children[slot];
330         if let Some(source) = crate::viewer_state::source_for(&node.node_type) {
331             self.viewer_tool = Some(crate::viewer_state::ViewerTool::new(node.id.clone(), source));
332         }
333         self.update_status_text(&format!("Added {} to {}.", layer.label().to_lowercase(), new_name));
334         Some(slot)
335     }
336 }