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 euler = [
383            pose.rotation_deg[0].to_radians(),
384            pose.rotation_deg[1].to_radians(),
385            pose.rotation_deg[2].to_radians(),
386        ];
387        let shift = [
388            pose.position[0] - center[0],
389            pose.position[1] - center[1],
390            pose.position[2] - center[2],
391        ];
392        let Ok(mut cage) = self.cage_of(&feature_id) else {
393            return;
394        };
395        for (index, start) in selected.iter().zip(snapshot.iter()) {
396            let offset = [start[0] - center[0], start[1] - center[1], start[2] - center[2]];
397            let turned = rotate_euler_xyz_f64(offset, euler);
398            let moved = Vec3::new(
399                center[0] + turned[0] + shift[0],
400                center[1] + turned[1] + shift[1],
401                center[2] + turned[2] + shift[2],
402            );
403            let _ = cage.set_point(*index, moved);
404        }
405        if let Some(arm) = self.transform_gizmo.cage.as_mut() {
406            arm.live = Some(*pose);
407        }
408        let key = format!("cage-drag:{feature_id}");
409        self.write_cage(&feature_id, &cage, Some(&key));
410    }
411
412    // --- edits -------------------------------------------------------------------------
413
414    /// Persist `cage` as the feature's edited cage (checkpointed; `coalesce`
415    /// folds a run of same-key writes into one undo step) and re-run.
416    fn write_cage(&mut self, feature_id: &str, cage: &ControlCage, coalesce: Option<&str>) {
417        let Some(index) = self.history.index_of(feature_id) else {
418            return;
419        };
420        self.history.set_feature_persistent_field_coalesced(
421            index,
422            nurbs_solid::PERSISTENT_KEY,
423            cage.to_json(),
424            coalesce,
425        );
426        self.rerun_history();
427    }
428
429    /// Move one point to `position` (a typed edit; coalesced per point).
430    pub fn cage_set_point_position(
431        &mut self,
432        feature_id: &str,
433        index: usize,
434        position: [f64; 3],
435    ) -> Result<(), String> {
436        let mut cage = self.cage_of(feature_id)?;
437        cage.set_point(index, vec3(position))?;
438        let key = format!("cage-point:{feature_id}:{index}");
439        self.write_cage(feature_id, &cage, Some(&key));
440        self.sync_transform_gizmo();
441        Ok(())
442    }
443
444    /// Insert a ring beside the selected point: `above` puts it between that
445    /// point's ring and the next towards the top pole, else towards the
446    /// bottom. With several points selected the first decides. The body is
447    /// unchanged; the new ring is selected. Returns its ring index.
448    pub fn cage_insert_ring(&mut self, feature_id: &str, above: bool) -> Result<usize, String> {
449        let mut cage = self.cage_of(feature_id)?;
450        let anchor = self
451            .cage_selected()
452            .first()
453            .copied()
454            .ok_or("select a point of the cage first: the ring goes beside it")?;
455        // Rows count the poles: the bottom pole is row 0, ring r is row r + 1.
456        let row = match cage.locate(anchor) {
457            Some(CagePoint::Pole(0)) => 0,
458            Some(CagePoint::Pole(_)) => cage.ring_count(),
459            Some(CagePoint::Ring { ring, .. }) => {
460                if above {
461                    ring + 1
462                } else {
463                    ring
464                }
465            }
466            None => return Err(format!("no cage point {anchor}")),
467        };
468        let new_ring = cage.insert_ring_between(row)?;
469        self.write_cage(feature_id, &cage, None);
470        let loop_points: Vec<usize> = (0..cage.segments()).map(|c| cage.index_of(new_ring, c)).collect();
471        self.cage_select(feature_id, &loop_points, CageSelect::Replace);
472        Ok(new_ring)
473    }
474
475    /// Insert a column (pole to pole) beside the selected point: `next` puts
476    /// it between that point's column and the next around the ring, else the
477    /// previous. A selected pole offers no column, so a ring point must be
478    /// selected. The body is unchanged; the new column is selected. Returns
479    /// its column index.
480    pub fn cage_insert_column(&mut self, feature_id: &str, next: bool) -> Result<usize, String> {
481        let mut cage = self.cage_of(feature_id)?;
482        let selected = self.cage_selected();
483        let column = selected
484            .iter()
485            .find_map(|index| match cage.locate(*index) {
486                Some(CagePoint::Ring { column, .. }) => Some(column),
487                _ => None,
488            })
489            .ok_or("select a ring point of the cage first: the column goes beside it")?;
490        let n = cage.segments();
491        let after = if next { column } else { (column + n - 1) % n };
492        let new_column = cage.insert_column_between(after)?;
493        self.write_cage(feature_id, &cage, None);
494        let loop_points: Vec<usize> = (0..cage.ring_count()).map(|r| cage.index_of(r, new_column)).collect();
495        self.cage_select(feature_id, &loop_points, CageSelect::Replace);
496        Ok(new_column)
497    }
498
499    /// Drop the edited cage: the body returns to the primitive the params
500    /// describe. Clears the selection.
501    pub fn cage_reset(&mut self, feature_id: &str) -> Result<(), String> {
502        let index = self
503            .history
504            .index_of(feature_id)
505            .filter(|_| self.is_cage_feature(feature_id))
506            .ok_or_else(|| format!("'{feature_id}' is not a NURBS solid"))?;
507        if self.cage_armed_for(feature_id) {
508            self.disarm_transform();
509        }
510        self.history
511            .set_feature_persistent_field(index, nurbs_solid::PERSISTENT_KEY, Value::Null);
512        self.rerun_history();
513        Ok(())
514    }
515
516    // --- the overlay ------------------------------------------------------------------
517
518    /// Feed the cage overlay for `feature_id` — its rings, its columns and a
519    /// dot per point, the selected ones highlighted — or clear it with
520    /// `None`. Cheap to call every frame: an unchanged feed is skipped.
521    pub fn refresh_cage_edit_overlay(&mut self, feature_id: Option<&str>) {
522        if self.cage_edit_feature.as_deref() != feature_id {
523            self.cage_edit_feature = feature_id.map(str::to_string);
524        }
525        let selected = feature_id
526            .filter(|id| self.cage_armed_for(id))
527            .map(|_| self.cage_selected())
528            .unwrap_or_default();
529        let key = feature_id.map(|id| (id.to_string(), selected.clone(), self.applied_generation));
530        if self.cage_overlay_key == key {
531            return;
532        }
533        self.cage_overlay_key = key;
534        let Some(feature_id) = feature_id else {
535            let _ = self
536                .widgets
537                .set_overlay_json(&serde_json::json!({ "groups": [{ "name": OVERLAY_GROUP }] }).to_string());
538            self.dirty = true;
539            return;
540        };
541        let Ok(cage) = self.cage_of(feature_id) else {
542            let _ = self
543                .widgets
544                .set_overlay_json(&serde_json::json!({ "groups": [{ "name": OVERLAY_GROUP }] }).to_string());
545            self.dirty = true;
546            return;
547        };
548        let mut line_positions: Vec<f64> = Vec::new();
549        let mut line_colors: Vec<f32> = Vec::new();
550        let mut push_line = |a: Vec3, b: Vec3| {
551            line_positions.extend_from_slice(&array3(a));
552            line_positions.extend_from_slice(&array3(b));
553            line_colors.extend_from_slice(&CAGE_LINE);
554            line_colors.extend_from_slice(&CAGE_LINE);
555        };
556        let n = cage.segments();
557        for ring in &cage.rings {
558            for c in 0..n {
559                push_line(ring[c], ring[(c + 1) % n]);
560            }
561        }
562        for c in 0..n {
563            let mut previous = cage.poles[0];
564            for ring in &cage.rings {
565                push_line(previous, ring[c]);
566                previous = ring[c];
567            }
568            push_line(previous, cage.poles[1]);
569        }
570        let mut dot_positions: Vec<f64> = Vec::new();
571        let mut dot_colors: Vec<f32> = Vec::new();
572        for (index, point) in cage.points().iter().enumerate() {
573            dot_positions.extend_from_slice(&array3(*point));
574            let color = if selected.binary_search(&index).is_ok() {
575                DOT_SELECTED
576            } else {
577                DOT_PLAIN
578            };
579            dot_colors.extend_from_slice(&color);
580        }
581        let group = serde_json::json!({
582            "name": OVERLAY_GROUP,
583            "renderOrder": 6,
584            "lines": { "positions": line_positions, "colors": line_colors },
585            "points": { "positions": dot_positions, "colors": dot_colors, "size": DOT_SIZE_PX },
586        });
587        let _ = self
588            .widgets
589            .set_overlay_json(&serde_json::json!({ "groups": [group] }).to_string());
590        self.dirty = true;
591    }
592}
593