Skip to main content

brep_render/engine_state/
cage_edit.rs

1//! The CONTROL CAGE editor of a NURBS Solid (`NS`): the engine side.
2//!
3//! The kernel's [`ControlCage`] is the one model: the points the editor lists,
4//! draws and drags are read through `nurbs_solid::resolve_cage`, which is also
5//! what the kernel builds the body from, so a handle is never somewhere the
6//! surface is not controlled from. Every edit writes the whole cage back to
7//! `persistentData.cage` (materialising the primitive's cage on the first
8//! edit) and re-runs the history.
9//!
10//! SELECTION lives in the transform gizmo's arm ([`CageArm`]): the gizmo is
11//! armed exactly while at least one point is selected, at the selection's
12//! centroid. A drag re-derives every selected point from a snapshot taken at
13//! the grab — translate adds the pose's motion, rotate turns the snapshot
14//! about the grab centroid — so a long drag never accumulates error, and the
15//! frames of one drag coalesce into one undo step.
16//!
17//! LOOPS are inserted by knot insertion ([`ControlCage::insert_ring_between`],
18//! [`ControlCage::insert_column_between`]) beside the selected point, which
19//! refines the cage without moving the body; the new loop is selected
20//! afterwards so it can be dragged at once.
21
22use super::transform_gizmo::{rotate_euler_xyz_f64, TransformPose};
23use super::*;
24use brep_kernel::feature_pipeline::nurbs_solid;
25use brep_kernel::{CagePoint, ControlCage, Vec3};
26use serde_json::Value;
27
28/// The overlay group the cage draws into.
29const OVERLAY_GROUP: &str = "cage-edit";
30/// Cage line colour (0..1 rgb) and the dots: plain and selected.
31const CAGE_LINE: [f32; 3] = [0.30, 0.48, 0.80];
32const DOT_PLAIN: [f32; 3] = [0.70, 0.84, 1.0];
33const DOT_SELECTED: [f32; 3] = [0.435, 0.886, 0.435];
34const DOT_SIZE_PX: f32 = 9.0;
35/// A viewport click within this many CSS pixels of a dot picks it.
36pub const CAGE_PICK_RADIUS_PX: f64 = 10.0;
37
38/// The gizmo's cage subject: which points, and the grab snapshot of a drag.
39#[derive(Clone, Debug, Default)]
40pub struct CageArm {
41    /// Selected linear point indices, ascending, no repeats.
42    pub(super) selected: Vec<usize>,
43    /// The selected points at grab and the grab centroid, while a drag is in
44    /// flight. Every drag frame re-derives the points from these.
45    pub(super) grab: Option<(Vec<[f64; 3]>, [f64; 3])>,
46    /// The pose last written during the drag — what the widget tracks.
47    pub(super) live: Option<TransformPose>,
48}
49
50/// How a pick or a list action changes the selection.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub enum CageSelect {
53    /// The given points become the selection.
54    Replace,
55    /// Each given point joins the selection, or leaves it when already in.
56    Toggle,
57    /// The given points join the selection.
58    Add,
59}
60
61/// The cage as the editor's panel describes it.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct CageSummary {
64    pub segments: usize,
65    pub rings: usize,
66    pub points: usize,
67    /// Whether the document carries an edited cage (the primitive params are
68    /// then only what it started from; Reset drops it).
69    pub persisted: bool,
70}
71
72fn array3(v: Vec3) -> [f64; 3] {
73    [v.x, v.y, v.z]
74}
75
76fn vec3(a: [f64; 3]) -> Vec3 {
77    Vec3::new(a[0], a[1], a[2])
78}
79
80fn centroid(points: &[[f64; 3]]) -> [f64; 3] {
81    if points.is_empty() {
82        return [0.0; 3];
83    }
84    let mut sum = [0.0; 3];
85    for p in points {
86        for k in 0..3 {
87            sum[k] += p[k];
88        }
89    }
90    let n = points.len() as f64;
91    [sum[0] / n, sum[1] / n, sum[2] / n]
92}
93
94impl EngineState {
95    // --- reads -----------------------------------------------------------------
96
97    /// Whether `feature_id` names a NURBS Solid.
98    pub fn is_cage_feature(&self, feature_id: &str) -> bool {
99        self.history
100            .index_of(feature_id)
101            .and_then(|index| self.history.feature_type(index))
102            .is_some_and(|ty| ty.eq_ignore_ascii_case("NS"))
103    }
104
105    /// The cage the kernel builds `feature_id` from (persisted or primitive),
106    /// or the reason it cannot be read.
107    pub fn cage_of(&self, feature_id: &str) -> Result<ControlCage, String> {
108        let index = self
109            .history
110            .index_of(feature_id)
111            .filter(|_| self.is_cage_feature(feature_id))
112            .ok_or_else(|| format!("'{feature_id}' is not a NURBS solid"))?;
113        let params = self.history.feature_params(index).unwrap_or(Value::Null);
114        let persistent = self.history.feature_persistent_data(index).unwrap_or(Value::Null);
115        let env = brep_kernel::Env::build(&self.history.expressions(), &self.history.configurator())
116            .map_err(|error| format!("expressions: {error}"))?;
117        nurbs_solid::resolve_cage(&env, &params, &persistent)
118    }
119
120    /// Every control point of the cage in linear order (empty for a
121    /// non-cage, or a cage the document cannot build).
122    pub fn cage_points(&self, feature_id: &str) -> Vec<[f64; 3]> {
123        self.cage_of(feature_id)
124            .map(|cage| cage.points().into_iter().map(array3).collect())
125            .unwrap_or_default()
126    }
127
128    /// The cage's shape for the panel, `None` for a non-cage.
129    pub fn cage_summary(&self, feature_id: &str) -> Option<CageSummary> {
130        let cage = self.cage_of(feature_id).ok()?;
131        let index = self.history.index_of(feature_id)?;
132        let persistent = self.history.feature_persistent_data(index).unwrap_or(Value::Null);
133        Some(CageSummary {
134            segments: cage.segments(),
135            rings: cage.ring_count(),
136            points: cage.point_count(),
137            persisted: nurbs_solid::has_persisted_cage(&persistent),
138        })
139    }
140
141    /// The NURBS solid whose cage editor is open, if any (the history panel
142    /// feeds it with the overlay).
143    pub fn cage_edit_feature(&self) -> Option<&str> {
144        self.cage_edit_feature.as_deref()
145    }
146
147    /// The selected point indices of the armed cage (empty unless the gizmo
148    /// is armed on a cage).
149    pub fn cage_selected(&self) -> Vec<usize> {
150        self.transform_gizmo
151            .cage
152            .as_ref()
153            .map(|cage| cage.selected.clone())
154            .unwrap_or_default()
155    }
156
157    /// Whether the gizmo is armed on the cage of `feature_id`.
158    pub fn cage_armed_for(&self, feature_id: &str) -> bool {
159        self.transform_gizmo.cage.is_some()
160            && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
161    }
162
163    // --- selection and the gizmo ---------------------------------------------------
164
165    /// Change the selection of `feature_id`'s cage and arm the gizmo at the
166    /// selection's centroid (disarmed when nothing is left selected).
167    /// Indices outside the cage are ignored.
168    pub fn cage_select(&mut self, feature_id: &str, indices: &[usize], mode: CageSelect) {
169        let count = self.cage_points(feature_id).len();
170        if count == 0 {
171            return;
172        }
173        let mut selected = if self.cage_armed_for(feature_id) {
174            self.cage_selected()
175        } else {
176            Vec::new()
177        };
178        match mode {
179            CageSelect::Replace => {
180                selected = indices.iter().copied().filter(|i| *i < count).collect();
181            }
182            CageSelect::Add => {
183                selected.extend(indices.iter().copied().filter(|i| *i < count));
184            }
185            CageSelect::Toggle => {
186                for &index in indices.iter().filter(|i| **i < count) {
187                    if let Some(at) = selected.iter().position(|s| *s == index) {
188                        selected.remove(at);
189                    } else {
190                        selected.push(index);
191                    }
192                }
193            }
194        }
195        selected.sort_unstable();
196        selected.dedup();
197        if selected.is_empty() {
198            if self.cage_armed_for(feature_id) {
199                self.disarm_transform();
200            }
201            return;
202        }
203        self.component_move_reset();
204        self.transform_gizmo.feature_id = Some(feature_id.to_string());
205        self.transform_gizmo.mode = GizmoMode::Transform;
206        self.transform_gizmo.drag = None;
207        self.transform_gizmo.anchor = None;
208        self.transform_gizmo.port = None;
209        self.transform_gizmo.cage = Some(CageArm {
210            selected,
211            grab: None,
212            live: None,
213        });
214        self.clear_feature_dimension_overlay();
215        self.sync_transform_gizmo();
216    }
217
218    /// Select every point of the cage.
219    pub fn cage_select_all(&mut self, feature_id: &str) {
220        let all: Vec<usize> = (0..self.cage_points(feature_id).len()).collect();
221        self.cage_select(feature_id, &all, CageSelect::Replace);
222    }
223
224    /// Clear the selection (and so disarm the gizmo).
225    pub fn cage_clear_selection(&mut self, feature_id: &str) {
226        self.cage_select(feature_id, &[], CageSelect::Replace);
227    }
228
229    /// Grow the selection to the whole ring of every selected ring point (a
230    /// selected pole stays as it is).
231    pub fn cage_select_rings_of_selection(&mut self, feature_id: &str) {
232        let Ok(cage) = self.cage_of(feature_id) else {
233            return;
234        };
235        let mut grown = Vec::new();
236        for index in self.cage_selected() {
237            if let Some(CagePoint::Ring { ring, .. }) = cage.locate(index) {
238                grown.extend((0..cage.segments()).map(|c| cage.index_of(ring, c)));
239            }
240        }
241        self.cage_select(feature_id, &grown, CageSelect::Add);
242    }
243
244    /// Grow the selection to the whole column (pole to pole, poles excluded)
245    /// of every selected ring point.
246    pub fn cage_select_columns_of_selection(&mut self, feature_id: &str) {
247        let Ok(cage) = self.cage_of(feature_id) else {
248            return;
249        };
250        let mut grown = Vec::new();
251        for index in self.cage_selected() {
252            if let Some(CagePoint::Ring { column, .. }) = cage.locate(index) {
253                grown.extend((0..cage.ring_count()).map(|r| cage.index_of(r, column)));
254            }
255        }
256        self.cage_select(feature_id, &grown, CageSelect::Add);
257    }
258
259    /// A viewport click while a cage editor is open: when one of the cage's
260    /// dots sits within [`CAGE_PICK_RADIUS_PX`] of CSS-pixel `(x, y)`, select
261    /// it — REPLACING the selection, or TOGGLING it in with `toggle` (a
262    /// modifier held) — and report its index. Dots are picked by projection,
263    /// so a point behind the body is as pickable as one in front, which is
264    /// what a cage wants. `None` leaves the click to the ordinary selection.
265    pub fn cage_point_pick_at(&mut self, x: f64, y: f64, toggle: bool) -> Option<usize> {
266        let feature_id = self.cage_edit_feature.clone()?;
267        let points = self.cage_points(&feature_id);
268        let mut best: Option<(usize, f64)> = None;
269        for (index, point) in points.iter().enumerate() {
270            if !self.camera.projectable(*point) {
271                continue;
272            }
273            let (sx, sy, _) = self.camera.project(*point);
274            let d = ((sx - x).powi(2) + (sy - y).powi(2)).sqrt();
275            if d <= CAGE_PICK_RADIUS_PX && best.is_none_or(|(_, bd)| d < bd) {
276                best = Some((index, d));
277            }
278        }
279        let (index, _) = best?;
280        let mode = if toggle { CageSelect::Toggle } else { CageSelect::Replace };
281        self.cage_select(&feature_id, &[index], mode);
282        Some(index)
283    }
284
285    /// Every cage dot projected to VIEWPORT-LOCAL px, `(index, (x, y))`, for
286    /// the host's hit blob. Empty unless a cage editor is open.
287    pub fn cage_point_screen_points(&self) -> Vec<(usize, (f64, f64))> {
288        let Some(feature_id) = self.cage_edit_feature.as_deref() else {
289            return Vec::new();
290        };
291        self.cage_points(feature_id)
292            .into_iter()
293            .enumerate()
294            .filter(|(_, point)| self.camera.projectable(*point))
295            .map(|(index, point)| {
296                let (sx, sy, _) = self.camera.project(point);
297                (index, (sx, sy))
298            })
299            .collect()
300    }
301
302    /// The armed cage's pose: the live drag pose while a drag is in flight,
303    /// else the selection's centroid with the world frame. `None` when the
304    /// selection is gone (the gizmo disarms).
305    pub(super) fn cage_pose(&self) -> Option<TransformPose> {
306        let arm = self.transform_gizmo.cage.as_ref()?;
307        if self.transform_gizmo.drag.is_some() {
308            if let Some(live) = arm.live {
309                return Some(live);
310            }
311        }
312        let feature_id = self.transform_gizmo.feature_id.as_deref()?;
313        let points = self.cage_points(feature_id);
314        let selected: Vec<[f64; 3]> = arm
315            .selected
316            .iter()
317            .filter_map(|i| points.get(*i).copied())
318            .collect();
319        if selected.is_empty() {
320            return None;
321        }
322        Some(TransformPose {
323            position: centroid(&selected),
324            rotation_deg: [0.0; 3],
325            scale: [1.0; 3],
326        })
327    }
328
329    /// Whether the armed cage's gizmo should draw its rotation rings: a lone
330    /// point has nothing to turn about.
331    pub(super) fn cage_shows_rings(&self) -> Option<bool> {
332        self.transform_gizmo
333            .cage
334            .as_ref()
335            .map(|arm| arm.selected.len() >= 2)
336    }
337
338    /// Snapshot the selection at the grab (called when a gizmo drag starts).
339    pub(super) fn cage_grab_begin(&mut self) {
340        let Some(feature_id) = self.transform_gizmo.feature_id.clone() else {
341            return;
342        };
343        if self.transform_gizmo.cage.is_none() {
344            return;
345        }
346        let points = self.cage_points(&feature_id);
347        if let Some(arm) = self.transform_gizmo.cage.as_mut() {
348            let snapshot: Vec<[f64; 3]> = arm
349                .selected
350                .iter()
351                .filter_map(|i| points.get(*i).copied())
352                .collect();
353            let center = centroid(&snapshot);
354            arm.grab = Some((snapshot, center));
355            arm.live = None;
356        }
357    }
358
359    /// Forget the grab snapshot (called when the drag ends).
360    pub(super) fn cage_grab_end(&mut self) {
361        if let Some(arm) = self.transform_gizmo.cage.as_mut() {
362            arm.grab = None;
363            arm.live = None;
364        }
365    }
366
367    /// The gizmo's write-back for a cage: `pose` is the grab pose moved by the
368    /// drag so far (position = grab centroid + translation; rotation = the
369    /// drag's turn, from identity). Every selected point is re-derived from
370    /// the grab snapshot: turned about the grab centroid, then moved.
371    pub(super) fn write_cage_pose(&mut self, pose: &TransformPose) {
372        let Some(feature_id) = self.transform_gizmo.feature_id.clone() else {
373            return;
374        };
375        let Some(arm) = self.transform_gizmo.cage.as_ref() else {
376            return;
377        };
378        let Some((snapshot, center)) = arm.grab.clone() else {
379            return;
380        };
381        let selected = arm.selected.clone();
382        let dragging = self.transform_dragging();
383        let euler = [
384            pose.rotation_deg[0].to_radians(),
385            pose.rotation_deg[1].to_radians(),
386            pose.rotation_deg[2].to_radians(),
387        ];
388        let shift = [
389            pose.position[0] - center[0],
390            pose.position[1] - center[1],
391            pose.position[2] - center[2],
392        ];
393        let Ok(mut cage) = self.cage_of(&feature_id) else {
394            return;
395        };
396        for (index, start) in selected.iter().zip(snapshot.iter()) {
397            let offset = [start[0] - center[0], start[1] - center[1], start[2] - center[2]];
398            let turned = rotate_euler_xyz_f64(offset, euler);
399            let moved: [f64; 3] = std::array::from_fn(|axis| {
400                let after = center[axis] + turned[axis] + shift[axis];
401                if dragging {
402                    crate::formatting::snap_drag_moved(start[axis], after)
403                } else {
404                    after
405                }
406            });
407            let moved = Vec3::new(moved[0], moved[1], moved[2]);
408            let _ = cage.set_point(*index, moved);
409        }
410        if let Some(arm) = self.transform_gizmo.cage.as_mut() {
411            arm.live = Some(*pose);
412        }
413        let key = format!("cage-drag:{feature_id}");
414        self.write_cage(&feature_id, &cage, Some(&key));
415    }
416
417    // --- edits -------------------------------------------------------------------------
418
419    /// Persist `cage` as the feature's edited cage (checkpointed; `coalesce`
420    /// folds a run of same-key writes into one undo step) and re-run.
421    fn write_cage(&mut self, feature_id: &str, cage: &ControlCage, coalesce: Option<&str>) {
422        let Some(index) = self.history.index_of(feature_id) else {
423            return;
424        };
425        self.history.set_feature_persistent_field_coalesced(
426            index,
427            nurbs_solid::PERSISTENT_KEY,
428            cage.to_json(),
429            coalesce,
430        );
431        self.rerun_history();
432    }
433
434    /// Move one point to `position` (a typed edit; coalesced per point).
435    pub fn cage_set_point_position(
436        &mut self,
437        feature_id: &str,
438        index: usize,
439        position: [f64; 3],
440    ) -> Result<(), String> {
441        let mut cage = self.cage_of(feature_id)?;
442        cage.set_point(index, vec3(position))?;
443        let key = format!("cage-point:{feature_id}:{index}");
444        self.write_cage(feature_id, &cage, Some(&key));
445        self.sync_transform_gizmo();
446        Ok(())
447    }
448
449    /// Insert a ring beside the selected point: `above` puts it between that
450    /// point's ring and the next towards the top pole, else towards the
451    /// bottom. With several points selected the first decides. The body is
452    /// unchanged; the new ring is selected. Returns its ring index.
453    pub fn cage_insert_ring(&mut self, feature_id: &str, above: bool) -> Result<usize, String> {
454        let mut cage = self.cage_of(feature_id)?;
455        let anchor = self
456            .cage_selected()
457            .first()
458            .copied()
459            .ok_or("select a point of the cage first: the ring goes beside it")?;
460        // Rows count the poles: the bottom pole is row 0, ring r is row r + 1.
461        let row = match cage.locate(anchor) {
462            Some(CagePoint::Pole(0)) => 0,
463            Some(CagePoint::Pole(_)) => cage.ring_count(),
464            Some(CagePoint::Ring { ring, .. }) => {
465                if above {
466                    ring + 1
467                } else {
468                    ring
469                }
470            }
471            None => return Err(format!("no cage point {anchor}")),
472        };
473        let new_ring = cage.insert_ring_between(row)?;
474        self.write_cage(feature_id, &cage, None);
475        let loop_points: Vec<usize> = (0..cage.segments()).map(|c| cage.index_of(new_ring, c)).collect();
476        self.cage_select(feature_id, &loop_points, CageSelect::Replace);
477        Ok(new_ring)
478    }
479
480    /// Insert a column (pole to pole) beside the selected point: `next` puts
481    /// it between that point's column and the next around the ring, else the
482    /// previous. A selected pole offers no column, so a ring point must be
483    /// selected. The body is unchanged; the new column is selected. Returns
484    /// its column index.
485    pub fn cage_insert_column(&mut self, feature_id: &str, next: bool) -> Result<usize, String> {
486        let mut cage = self.cage_of(feature_id)?;
487        let selected = self.cage_selected();
488        let column = selected
489            .iter()
490            .find_map(|index| match cage.locate(*index) {
491                Some(CagePoint::Ring { column, .. }) => Some(column),
492                _ => None,
493            })
494            .ok_or("select a ring point of the cage first: the column goes beside it")?;
495        let n = cage.segments();
496        let after = if next { column } else { (column + n - 1) % n };
497        let new_column = cage.insert_column_between(after)?;
498        self.write_cage(feature_id, &cage, None);
499        let loop_points: Vec<usize> = (0..cage.ring_count()).map(|r| cage.index_of(r, new_column)).collect();
500        self.cage_select(feature_id, &loop_points, CageSelect::Replace);
501        Ok(new_column)
502    }
503
504    /// Drop the edited cage: the body returns to the primitive the params
505    /// describe. Clears the selection.
506    pub fn cage_reset(&mut self, feature_id: &str) -> Result<(), String> {
507        let index = self
508            .history
509            .index_of(feature_id)
510            .filter(|_| self.is_cage_feature(feature_id))
511            .ok_or_else(|| format!("'{feature_id}' is not a NURBS solid"))?;
512        if self.cage_armed_for(feature_id) {
513            self.disarm_transform();
514        }
515        self.history
516            .set_feature_persistent_field(index, nurbs_solid::PERSISTENT_KEY, Value::Null);
517        self.rerun_history();
518        Ok(())
519    }
520
521    // --- the overlay ------------------------------------------------------------------
522
523    /// Feed the cage overlay for `feature_id` — its rings, its columns and a
524    /// dot per point, the selected ones highlighted — or clear it with
525    /// `None`. Cheap to call every frame: an unchanged feed is skipped.
526    pub fn refresh_cage_edit_overlay(&mut self, feature_id: Option<&str>) {
527        if self.cage_edit_feature.as_deref() != feature_id {
528            self.cage_edit_feature = feature_id.map(str::to_string);
529        }
530        let selected = feature_id
531            .filter(|id| self.cage_armed_for(id))
532            .map(|_| self.cage_selected())
533            .unwrap_or_default();
534        let key = feature_id.map(|id| (id.to_string(), selected.clone(), self.applied_generation));
535        if self.cage_overlay_key == key {
536            return;
537        }
538        self.cage_overlay_key = key;
539        let Some(feature_id) = feature_id else {
540            let _ = self
541                .widgets
542                .set_overlay_json(&serde_json::json!({ "groups": [{ "name": OVERLAY_GROUP }] }).to_string());
543            self.dirty = true;
544            return;
545        };
546        let Ok(cage) = self.cage_of(feature_id) else {
547            let _ = self
548                .widgets
549                .set_overlay_json(&serde_json::json!({ "groups": [{ "name": OVERLAY_GROUP }] }).to_string());
550            self.dirty = true;
551            return;
552        };
553        let mut line_positions: Vec<f64> = Vec::new();
554        let mut line_colors: Vec<f32> = Vec::new();
555        let mut push_line = |a: Vec3, b: Vec3| {
556            line_positions.extend_from_slice(&array3(a));
557            line_positions.extend_from_slice(&array3(b));
558            line_colors.extend_from_slice(&CAGE_LINE);
559            line_colors.extend_from_slice(&CAGE_LINE);
560        };
561        let n = cage.segments();
562        for ring in &cage.rings {
563            for c in 0..n {
564                push_line(ring[c], ring[(c + 1) % n]);
565            }
566        }
567        for c in 0..n {
568            let mut previous = cage.poles[0];
569            for ring in &cage.rings {
570                push_line(previous, ring[c]);
571                previous = ring[c];
572            }
573            push_line(previous, cage.poles[1]);
574        }
575        let mut dot_positions: Vec<f64> = Vec::new();
576        let mut dot_colors: Vec<f32> = Vec::new();
577        for (index, point) in cage.points().iter().enumerate() {
578            dot_positions.extend_from_slice(&array3(*point));
579            let color = if selected.binary_search(&index).is_ok() {
580                DOT_SELECTED
581            } else {
582                DOT_PLAIN
583            };
584            dot_colors.extend_from_slice(&color);
585        }
586        let group = serde_json::json!({
587            "name": OVERLAY_GROUP,
588            "renderOrder": 6,
589            "lines": { "positions": line_positions, "colors": line_colors },
590            "points": { "positions": dot_positions, "colors": dot_colors, "size": DOT_SIZE_PX },
591        });
592        let _ = self
593            .widgets
594            .set_overlay_json(&serde_json::json!({ "groups": [group] }).to_string());
595        self.dirty = true;
596    }
597}
598