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 }