Skip to main content

brep_render/engine_state/
spline_edit.rs

1//! Edit 3D spline anchors stored in `persistentData.spline.points`.
2//!
3//! List edits checkpoint the point list and rerun the history. Gizmo drags
4//! coalesce into one undo step; attached anchors take their pose from the port
5//! and cannot be armed. Reference selection attaches an anchor to a port, and
6//! the cage overlay shows anchor points and forward/backward straight runs.
7//!
8//! Anchor rotations store a row-flat axis triad `[x, y, z]`, with x as the
9//! travel direction. Gizmos use intrinsic XYZ Euler degrees; conversions here
10//! follow the kernel's rotation order and the gizmo's matrix conventions.
11
12use crate::json_support::vec3_or as read_vec3;
13use crate::geometry3d::cross3 as cross;
14
15use super::*;
16use super::transform_gizmo::{normalize3, rotate_euler_xyz_f64};
17use serde_json::Value;
18use brep_kernel::PortSide;
19
20/// The overlay group the cage draws into.
21const OVERLAY_GROUP: &str = "spline-edit";
22/// Cage line colours (0..1 rgb): forward run bright, backward run dim.
23const CAGE_FORWARD: [f32; 3] = [0.35, 0.55, 1.0];
24const CAGE_BACKWARD: [f32; 3] = [0.22, 0.34, 0.62];
25/// Anchor dot colours: plain, attached to a port, and the selected one.
26const DOT_PLAIN: [f32; 3] = [0.62, 0.79, 1.0];
27const DOT_ATTACHED: [f32; 3] = [1.0, 0.66, 0.42];
28const DOT_SELECTED: [f32; 3] = [0.435, 0.886, 0.435];
29const DOT_SIZE_PX: f32 = 9.0;
30/// A new anchor lands this far past its reference anchor along the direction.
31const NEW_ANCHOR_GAP: f64 = 2.0;
32
33/// One anchor as the editor lists it.
34#[derive(Debug, Clone, PartialEq, serde::Serialize)]
35pub struct SplineAnchorRow {
36    pub index: usize,
37    pub id: String,
38    /// The RESOLVED position (the last run's `{id}:P{index}` point; the
39    /// persisted one before the first run).
40    pub position: [f64; 3],
41    /// The RESOLVED unit travel direction (the `{id}:P{index}` axis).
42    pub direction: [f64; 3],
43    pub forward: f64,
44    pub backward: f64,
45    pub flip: bool,
46    /// `(port id, side)` when attached.
47    pub attached: Option<(String, PortSide)>,
48}
49
50/// Intrinsic-XYZ Euler (degrees) whose rotation carries +X/+Y/+Z onto the
51/// given orthonormal axes — the inverse of feeding each unit axis through
52/// `rotate_euler_xyz_f64`. Same matrix-element naming as the gizmo's
53/// quaternion extraction (`m<row><col>`, columns = the axes).
54pub(super) fn euler_xyz_deg_from_axes(x: [f64; 3], y: [f64; 3], z: [f64; 3]) -> [f64; 3] {
55    let (m11, m12, m13) = (x[0], y[0], z[0]);
56    let (m22, m23) = (y[1], z[1]);
57    let (m32, m33) = (y[2], z[2]);
58    let ey = m13.clamp(-1.0, 1.0).asin();
59    let (ex, ez) = if m13.abs() < 0.9999999 {
60        ((-m23).atan2(m33), (-m12).atan2(m11))
61    } else {
62        (m32.atan2(m22), 0.0)
63    };
64    [ex.to_degrees(), ey.to_degrees(), ez.to_degrees()]
65}
66
67/// The axis triad an intrinsic-XYZ Euler (degrees) rotates +X/+Y/+Z onto.
68fn axes_from_euler_deg(deg: [f64; 3]) -> [[f64; 3]; 3] {
69    let euler = [deg[0].to_radians(), deg[1].to_radians(), deg[2].to_radians()];
70    [
71        normalize3(rotate_euler_xyz_f64([1.0, 0.0, 0.0], euler)),
72        normalize3(rotate_euler_xyz_f64([0.0, 1.0, 0.0], euler)),
73        normalize3(rotate_euler_xyz_f64([0.0, 0.0, 1.0], euler)),
74    ]
75}
76
77/// An orthonormal triad with `x` as its first axis (the frame a detached or
78/// freshly added anchor is given so its stored rotation matches the direction
79/// it was resolved to). Uses the same up-hint the kernel's `Frame` uses.
80fn axes_from_direction(direction: [f64; 3]) -> [[f64; 3]; 3] {
81    let x = normalize3(direction);
82    let up = if x[2].abs() > 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] };
83    let y = normalize3(cross(up, x));
84    let z = normalize3(cross(x, y));
85    [x, y, z]
86}
87
88fn read_axes(value: Option<&Value>) -> [[f64; 3]; 3] {
89    let identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
90    let Some(array) = value.and_then(Value::as_array) else {
91        return identity;
92    };
93    if array.len() != 9 {
94        return identity;
95    }
96    let numbers: Vec<f64> = array.iter().filter_map(Value::as_f64).collect();
97    if numbers.len() != 9 {
98        return identity;
99    }
100    [
101        [numbers[0], numbers[1], numbers[2]],
102        [numbers[3], numbers[4], numbers[5]],
103        [numbers[6], numbers[7], numbers[8]],
104    ]
105}
106
107fn axes_value(axes: [[f64; 3]; 3]) -> Value {
108    Value::Array(axes.iter().flatten().map(|n| Value::from(*n)).collect())
109}
110
111fn distance(value: Option<&Value>) -> f64 {
112    value.and_then(Value::as_f64).map(|d| d.max(0.0)).unwrap_or(1.0)
113}
114
115/// A normalized anchor: every field present in the persisted shape.
116fn normalize_point(point: &Value, index: usize) -> Value {
117    let id = point
118        .get("id")
119        .and_then(Value::as_str)
120        .filter(|id| !id.is_empty())
121        .map(str::to_string)
122        .unwrap_or_else(|| format!("p{index}"));
123    let attachment = point
124        .get("attachment")
125        .filter(|a| brep_kernel::SplineAttachment::parse(Some(a)).is_some())
126        .cloned()
127        .unwrap_or(Value::Null);
128    serde_json::json!({
129        "id": id,
130        "position": read_vec3(point.get("position"), [0.0, 0.0, 0.0]),
131        "rotation": axes_value(read_axes(point.get("rotation"))),
132        "forwardDistance": distance(point.get("forwardDistance")),
133        "backwardDistance": distance(point.get("backwardDistance")),
134        "flipDirection": point.get("flipDirection").and_then(Value::as_bool).unwrap_or(false),
135        "attachment": attachment,
136    })
137}
138
139/// The persisted points of a spline, normalized — the kernel's default pair
140/// `(0,0,0) → (5,0,0)` when fewer than two are stored, so the editor always
141/// lists the anchors the curve is actually built from.
142fn normalized_points(persistent: Option<&Value>) -> Vec<Value> {
143    let stored: Vec<&Value> = persistent
144        .and_then(|p| p.get("spline"))
145        .and_then(|s| s.get("points"))
146        .and_then(Value::as_array)
147        .map(|points| points.iter().collect())
148        .unwrap_or_default();
149    if stored.len() < 2 {
150        return vec![
151            normalize_point(&serde_json::json!({ "id": "p0", "position": [0, 0, 0] }), 0),
152            normalize_point(&serde_json::json!({ "id": "p1", "position": [5, 0, 0] }), 1),
153        ];
154    }
155    stored
156        .iter()
157        .enumerate()
158        .map(|(index, point)| normalize_point(point, index))
159        .collect()
160}
161
162impl EngineState {
163    // --- reads -----------------------------------------------------------------
164
165    /// Whether `name` is a PORT feature's id.
166    pub fn is_port_feature(&self, name: &str) -> bool {
167        self.history
168            .index_of(name)
169            .and_then(|index| self.history.feature_type(index))
170            .is_some_and(|ty| ty == "PORT")
171    }
172
173    /// Whether `name` is a harness port at all: a PORT feature of this document,
174    /// or a port a placed component carries (`ACOMP1:PORT1`), which the harness
175    /// report lists among its endpoints.
176    pub fn is_port_id(&self, name: &str) -> bool {
177        self.is_port_feature(name)
178            || self
179                .wire_harness_report
180                .as_ref()
181                .is_some_and(|report| report.endpoints.iter().any(|endpoint| endpoint.id == name))
182    }
183
184    /// Whether `feature_id` names a SPLINE feature.
185    pub fn is_spline_feature(&self, feature_id: &str) -> bool {
186        self.history
187            .index_of(feature_id)
188            .and_then(|index| self.history.feature_type(index))
189            .is_some_and(|ty| ty == "SP")
190    }
191
192    /// The spline's anchors, resolved (empty for a non-spline id).
193    pub fn spline_anchors(&self, feature_id: &str) -> Vec<SplineAnchorRow> {
194        let Some(index) = self.history.index_of(feature_id) else {
195            return Vec::new();
196        };
197        if !self.is_spline_feature(feature_id) {
198            return Vec::new();
199        }
200        let points = normalized_points(self.history.feature_persistent_data(index).as_ref());
201        points
202            .iter()
203            .enumerate()
204            .map(|(i, point)| {
205                let name = format!("{feature_id}:P{i}");
206                let axes = read_axes(point.get("rotation"));
207                let flip = point.get("flipDirection").and_then(Value::as_bool).unwrap_or(false);
208                let stored_direction = if flip {
209                    [-axes[0][0], -axes[0][1], -axes[0][2]]
210                } else {
211                    axes[0]
212                };
213                let position = self
214                    .sketch_points
215                    .iter()
216                    .find(|(n, _)| *n == name)
217                    .map(|(_, p)| [p.position.x, p.position.y, p.position.z])
218                    .unwrap_or_else(|| read_vec3(point.get("position"), [0.0; 3]));
219                let direction = self
220                    .sketch_axes
221                    .iter()
222                    .find(|(n, _)| *n == name)
223                    .map(|(_, a)| [a.direction.x, a.direction.y, a.direction.z])
224                    .unwrap_or_else(|| normalize3(stored_direction));
225                let attached = brep_kernel::SplineAttachment::parse(point.get("attachment"))
226                    .map(|a| (a.port_ref, a.side));
227                SplineAnchorRow {
228                    index: i,
229                    id: point.get("id").and_then(Value::as_str).unwrap_or("").to_string(),
230                    position,
231                    direction,
232                    forward: distance(point.get("forwardDistance")),
233                    backward: distance(point.get("backwardDistance")),
234                    flip,
235                    attached,
236                }
237            })
238            .collect()
239    }
240
241    /// The armed spline anchor `(feature id, index)`, if the gizmo is on one.
242    pub fn armed_spline_anchor(&self) -> Option<(String, usize)> {
243        let anchor = self.transform_gizmo.anchor?;
244        let id = self.transform_gizmo.feature_id.clone()?;
245        Some((id, anchor))
246    }
247
248    /// The spline whose anchor editor is open, if any (the history panel feeds
249    /// it with the cage overlay).
250    pub fn spline_edit_feature(&self) -> Option<&str> {
251        self.spline_edit_feature.as_deref()
252    }
253
254    /// A viewport click while a spline's editor is open: when one of THAT
255    /// spline's anchor dots (the sheet's vertex there) sits under CSS-pixel
256    /// `(x, y)`, select the anchor — the gizmo arms on a free one — and report
257    /// its index. The RAW pick is used, so the selection filter cannot hide
258    /// the anchors; `None` leaves the click to the ordinary selection.
259    pub fn spline_anchor_pick_at(&mut self, x: f64, y: f64) -> Option<usize> {
260        let feature_id = self.spline_edit_feature.clone()?;
261        let candidates = self.pick_candidates_at(x, y);
262        let hit = candidates
263            .iter()
264            .find(|c| matches!(c.kind, crate::pick::PickKind::Vertex) && c.solid == feature_id)?;
265        let rows = self.spline_anchors(&feature_id);
266        let index = rows
267            .iter()
268            .map(|row| {
269                let d = [0, 1, 2]
270                    .iter()
271                    .map(|&k| (row.position[k] - hit.position[k]).powi(2))
272                    .sum::<f64>()
273                    .sqrt();
274                (row.index, d, row.position)
275            })
276            .filter(|(_, d, p)| {
277                *d <= 1e-4 * (1.0 + p.iter().map(|v| v.abs()).fold(0.0, f64::max))
278            })
279            .min_by(|a, b| a.1.total_cmp(&b.1))?
280            .0;
281        self.arm_spline_anchor(&feature_id, index);
282        self.spline_anchor_picked = Some(index);
283        Some(index)
284    }
285
286    /// The anchor the last viewport click selected, once — the history panel
287    /// takes it to move its row selection.
288    pub fn take_spline_anchor_pick(&mut self) -> Option<usize> {
289        self.spline_anchor_picked.take()
290    }
291
292    // --- the write lane ----------------------------------------------------------
293
294    /// The normalized point list of `feature_id`, or `None` for a non-spline.
295    fn spline_points(&self, feature_id: &str) -> Option<Vec<Value>> {
296        let index = self.history.index_of(feature_id)?;
297        if !self.is_spline_feature(feature_id) {
298            return None;
299        }
300        Some(normalized_points(self.history.feature_persistent_data(index).as_ref()))
301    }
302
303    /// Persist `points` as the spline document (checkpointed; `coalesce`
304    /// folds a run of same-key writes into one undo step) and re-run.
305    fn write_spline_points(&mut self, feature_id: &str, points: Vec<Value>, coalesce: Option<&str>) {
306        let Some(index) = self.history.index_of(feature_id) else {
307            return;
308        };
309        self.history.set_feature_persistent_field_coalesced(
310            index,
311            "spline",
312            serde_json::json!({ "points": points }),
313            coalesce,
314        );
315        self.rerun_history();
316    }
317
318    /// Edit one anchor in place through `edit`, then persist + re-run.
319    fn edit_spline_anchor(
320        &mut self,
321        feature_id: &str,
322        anchor: usize,
323        coalesce: Option<&str>,
324        edit: impl FnOnce(&mut serde_json::Map<String, Value>),
325    ) -> Result<(), String> {
326        let mut points = self
327            .spline_points(feature_id)
328            .ok_or_else(|| format!("'{feature_id}' is not a spline"))?;
329        let point = points
330            .get_mut(anchor)
331            .and_then(Value::as_object_mut)
332            .ok_or_else(|| format!("spline '{feature_id}' has no anchor {anchor}"))?;
333        edit(point);
334        self.write_spline_points(feature_id, points, coalesce);
335        Ok(())
336    }
337
338    /// Set an anchor's stored position (ignored by the kernel while attached).
339    pub fn spline_set_anchor_position(
340        &mut self,
341        feature_id: &str,
342        anchor: usize,
343        position: [f64; 3],
344    ) -> Result<(), String> {
345        let key = format!("spline-anchor-position:{feature_id}:{anchor}");
346        self.edit_spline_anchor(feature_id, anchor, Some(&key), |point| {
347            point.insert("position".into(), serde_json::json!(position));
348        })
349    }
350
351    /// Set an anchor's forward / backward straight-run distances (either may
352    /// be left alone). Negative values clamp to zero.
353    pub fn spline_set_anchor_distances(
354        &mut self,
355        feature_id: &str,
356        anchor: usize,
357        forward: Option<f64>,
358        backward: Option<f64>,
359    ) -> Result<(), String> {
360        let key = format!("spline-anchor-distances:{feature_id}:{anchor}");
361        self.edit_spline_anchor(feature_id, anchor, Some(&key), |point| {
362            if let Some(forward) = forward {
363                point.insert("forwardDistance".into(), Value::from(forward.max(0.0)));
364            }
365            if let Some(backward) = backward {
366                point.insert("backwardDistance".into(), Value::from(backward.max(0.0)));
367            }
368        })
369    }
370
371    /// Flip an anchor's travel direction (no effect while attached — the
372    /// side decides).
373    pub fn spline_set_anchor_flip(&mut self, feature_id: &str, anchor: usize, flip: bool) -> Result<(), String> {
374        self.edit_spline_anchor(feature_id, anchor, None, |point| {
375            point.insert("flipDirection".into(), Value::Bool(flip));
376        })
377    }
378
379    /// Switch an attached anchor's port side.
380    pub fn spline_set_anchor_side(&mut self, feature_id: &str, anchor: usize, side: PortSide) -> Result<(), String> {
381        self.edit_spline_anchor(feature_id, anchor, None, |point| {
382            if let Some(attachment) = point.get_mut("attachment").and_then(Value::as_object_mut) {
383                attachment.insert("side".into(), Value::String(side.letter().to_string()));
384            }
385        })
386    }
387
388    /// Detach an anchor from its port, keeping the curve where it is: the
389    /// resolved position and direction the port gave it become its stored
390    /// placement.
391    pub fn spline_detach_anchor(&mut self, feature_id: &str, anchor: usize) -> Result<(), String> {
392        let row = self
393            .spline_anchors(feature_id)
394            .into_iter()
395            .nth(anchor)
396            .ok_or_else(|| format!("spline '{feature_id}' has no anchor {anchor}"))?;
397        self.edit_spline_anchor(feature_id, anchor, None, |point| {
398            point.insert("attachment".into(), Value::Null);
399            point.insert("position".into(), serde_json::json!(row.position));
400            point.insert("rotation".into(), axes_value(axes_from_direction(row.direction)));
401            point.insert("flipDirection".into(), Value::Bool(false));
402        })
403    }
404
405    /// Add an anchor after `after` (the last anchor when `None`), placed past
406    /// it along its direction by its forward run plus a gap, facing the same
407    /// way. Returns the new anchor's index.
408    pub fn spline_add_anchor(&mut self, feature_id: &str, after: Option<usize>) -> Result<usize, String> {
409        let rows = self.spline_anchors(feature_id);
410        if rows.is_empty() {
411            return Err(format!("'{feature_id}' is not a spline"));
412        }
413        let mut points = self.spline_points(feature_id).expect("a spline by the check above");
414        let at = after.unwrap_or(rows.len() - 1).min(rows.len() - 1);
415        let reference = &rows[at];
416        let step = reference.forward + NEW_ANCHOR_GAP;
417        let position = [
418            reference.position[0] + reference.direction[0] * step,
419            reference.position[1] + reference.direction[1] * step,
420            reference.position[2] + reference.direction[2] * step,
421        ];
422        let next_number = points.len();
423        let point = serde_json::json!({
424            "id": format!("p{next_number}"),
425            "position": position,
426            "rotation": axes_value(axes_from_direction(reference.direction)),
427            "forwardDistance": 1.0,
428            "backwardDistance": 1.0,
429            "flipDirection": false,
430            "attachment": Value::Null,
431        });
432        let index = at + 1;
433        points.insert(index, point);
434        self.write_spline_points(feature_id, points, None);
435        Ok(index)
436    }
437
438    /// Remove an anchor. A spline keeps at least two.
439    pub fn spline_remove_anchor(&mut self, feature_id: &str, anchor: usize) -> Result<(), String> {
440        let mut points = self
441            .spline_points(feature_id)
442            .ok_or_else(|| format!("'{feature_id}' is not a spline"))?;
443        if anchor >= points.len() {
444            return Err(format!("spline '{feature_id}' has no anchor {anchor}"));
445        }
446        if points.len() <= 2 {
447            return Err("a spline keeps at least two anchors".into());
448        }
449        points.remove(anchor);
450        if self.transform_gizmo.anchor.is_some()
451            && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
452        {
453            self.disarm_transform();
454        }
455        self.write_spline_points(feature_id, points, None);
456        Ok(())
457    }
458
459    /// Move an anchor one place up (towards the start) or down.
460    pub fn spline_move_anchor(&mut self, feature_id: &str, anchor: usize, up: bool) -> Result<(), String> {
461        let mut points = self
462            .spline_points(feature_id)
463            .ok_or_else(|| format!("'{feature_id}' is not a spline"))?;
464        let target = if up {
465            anchor.checked_sub(1)
466        } else {
467            (anchor + 1 < points.len()).then_some(anchor + 1)
468        };
469        let Some(target) = target else {
470            return Ok(()); // already at the end it was pushed towards
471        };
472        if anchor >= points.len() {
473            return Err(format!("spline '{feature_id}' has no anchor {anchor}"));
474        }
475        points.swap(anchor, target);
476        if self.transform_gizmo.anchor == Some(anchor)
477            && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
478        {
479            self.transform_gizmo.anchor = Some(target);
480        }
481        self.write_spline_points(feature_id, points, None);
482        Ok(())
483    }
484
485    // --- the gizmo -----------------------------------------------------------------
486
487    /// Arm the move/rotate gizmo on one anchor. Refused (false) for an
488    /// attached anchor — the port owns its pose — and for a bad index.
489    pub fn arm_spline_anchor(&mut self, feature_id: &str, anchor: usize) -> bool {
490        let rows = self.spline_anchors(feature_id);
491        let Some(row) = rows.get(anchor) else {
492            return false;
493        };
494        if row.attached.is_some() {
495            // The port owns this anchor's pose: nothing to arm — and a gizmo
496            // left on the PREVIOUSLY selected anchor would contradict the
497            // selection, so it goes.
498            if self.transform_gizmo.anchor.is_some() {
499                self.disarm_transform();
500            }
501            return false;
502        }
503        self.component_move_reset();
504        self.transform_gizmo.feature_id = Some(feature_id.to_string());
505        self.transform_gizmo.mode = GizmoMode::Transform;
506        self.transform_gizmo.drag = None;
507        self.transform_gizmo.anchor = Some(anchor);
508        self.clear_feature_dimension_overlay();
509        self.sync_transform_gizmo();
510        true
511    }
512
513    /// The armed anchor's pose: its stored position and the Euler of its
514    /// stored axis triad. `None` when the anchor is gone (the gizmo disarms).
515    pub(super) fn spline_anchor_pose(&self, feature_index: usize, anchor: usize) -> Option<([f64; 3], [f64; 3])> {
516        let points = normalized_points(self.history.feature_persistent_data(feature_index).as_ref());
517        let point = points.get(anchor)?;
518        let axes = read_axes(point.get("rotation"));
519        Some((
520            read_vec3(point.get("position"), [0.0; 3]),
521            euler_xyz_deg_from_axes(axes[0], axes[1], axes[2]),
522        ))
523    }
524
525    /// The gizmo's write-back for an anchor: position + the axis triad the
526    /// Euler rotates onto, coalesced per anchor so a drag is one undo step.
527    pub(super) fn write_spline_anchor_pose(
528        &mut self,
529        feature_id: &str,
530        anchor: usize,
531        position: [f64; 3],
532        rotation_deg: [f64; 3],
533    ) {
534        let key = format!("spline-anchor-pose:{feature_id}:{anchor}");
535        let _ = self.edit_spline_anchor(feature_id, anchor, Some(&key), |point| {
536            point.insert("position".into(), serde_json::json!(position));
537            point.insert("rotation".into(), axes_value(axes_from_euler_deg(rotation_deg)));
538        });
539    }
540
541    // --- attach (the reference picker's spline-anchor flavour) --------------------
542
543    /// Enter the reference picker to attach `anchor` to a port: the pick
544    /// admits a port's drawn sheet (its line / base vertex / the sheet), and
545    /// Finish writes the attachment.
546    pub fn begin_ref_select_for_spline_anchor(&mut self, feature_id: &str, anchor: usize) {
547        let restore_index = self.history.rollback();
548        let before = self
549            .history
550            .index_of(feature_id)
551            .map(|i| i.saturating_sub(1))
552            .unwrap_or(restore_index);
553        let filter: Vec<String> = ["SKETCH", "EDGE", "VERTEX"].iter().map(|s| s.to_string()).collect();
554        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
555        let seed = self
556            .spline_anchors(feature_id)
557            .get(anchor)
558            .and_then(|row| row.attached.as_ref().map(|(port, _)| port.clone()))
559            .into_iter()
560            .collect();
561        self.ref_select = Some(RefSelectState {
562            feature_id: feature_id.to_string(),
563            path: Vec::new(),
564            label: format!("Anchor {anchor}: attach to port"),
565            filter,
566            multiple: false,
567            names: seed,
568            restore_index,
569            target: RefSelectTarget::SplineAnchor { index: anchor },
570        });
571        if before != restore_index {
572            self.history.set_rollback(before);
573            self.rerun_history();
574        }
575        self.sync_ref_select_emphasis();
576    }
577
578    /// Write `port` as `anchor`'s attachment (side `A` for a new port; an
579    /// existing side is kept when re-attaching to the same port) WITHOUT
580    /// re-running — the picker's shared end tail re-runs.
581    pub(super) fn attach_spline_anchor_no_rerun(&mut self, feature_id: &str, anchor: usize, port: &str) {
582        let Some(index) = self.history.index_of(feature_id) else {
583            return;
584        };
585        let Some(mut points) = self.spline_points(feature_id) else {
586            return;
587        };
588        let Some(point) = points.get_mut(anchor).and_then(Value::as_object_mut) else {
589            return;
590        };
591        let side = brep_kernel::SplineAttachment::parse(point.get("attachment"))
592            .filter(|current| current.port_ref == port)
593            .map(|current| current.side)
594            .unwrap_or(PortSide::A);
595        point.insert(
596            "attachment".into(),
597            serde_json::json!({ "portRef": port, "side": side.letter() }),
598        );
599        if self.transform_gizmo.anchor == Some(anchor)
600            && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
601        {
602            // The port owns the pose now; the gizmo has nothing to move.
603            self.transform_gizmo.anchor = None;
604            self.transform_gizmo.feature_id = None;
605            self.transform_gizmo.mode = GizmoMode::None;
606            let _ = self.widgets.set_transform_json("null");
607        }
608        self.history.set_feature_persistent_field(index, "spline", serde_json::json!({ "points": points }));
609    }
610
611    // --- the cage overlay ---------------------------------------------------------
612
613    /// Feed the anchor cage for `feature_id` (its forward / backward runs and
614    /// a dot per anchor, `selected` highlighted), or clear it with `None`.
615    /// Cheap to call every frame: an unchanged feed is skipped.
616    pub fn refresh_spline_edit_overlay(&mut self, feature_id: Option<&str>, selected: Option<usize>) {
617        if self.spline_edit_feature.as_deref() != feature_id {
618            self.spline_edit_feature = feature_id.map(str::to_string);
619            self.spline_anchor_picked = None;
620        }
621        let key = feature_id.map(|id| (id.to_string(), selected, self.applied_generation));
622        if self.spline_overlay_key == key {
623            return;
624        }
625        self.spline_overlay_key = key;
626        let Some(feature_id) = feature_id else {
627            let _ = self
628                .widgets
629                .set_overlay_json(&serde_json::json!({ "groups": [{ "name": OVERLAY_GROUP }] }).to_string());
630            self.dirty = true;
631            return;
632        };
633        let rows = self.spline_anchors(feature_id);
634        let mut line_positions: Vec<f64> = Vec::new();
635        let mut line_colors: Vec<f32> = Vec::new();
636        let mut dot_positions: Vec<f64> = Vec::new();
637        let mut dot_colors: Vec<f32> = Vec::new();
638        let mut push_line = |a: [f64; 3], b: [f64; 3], color: [f32; 3]| {
639            line_positions.extend_from_slice(&a);
640            line_positions.extend_from_slice(&b);
641            line_colors.extend_from_slice(&color);
642            line_colors.extend_from_slice(&color);
643        };
644        for row in &rows {
645            let along = |scale: f64| {
646                [
647                    row.position[0] + row.direction[0] * scale,
648                    row.position[1] + row.direction[1] * scale,
649                    row.position[2] + row.direction[2] * scale,
650                ]
651            };
652            if row.forward > 0.0 {
653                push_line(row.position, along(row.forward), CAGE_FORWARD);
654            }
655            if row.backward > 0.0 {
656                push_line(row.position, along(-row.backward), CAGE_BACKWARD);
657            }
658            dot_positions.extend_from_slice(&row.position);
659            let color = if Some(row.index) == selected {
660                DOT_SELECTED
661            } else if row.attached.is_some() {
662                DOT_ATTACHED
663            } else {
664                DOT_PLAIN
665            };
666            dot_colors.extend_from_slice(&color);
667        }
668        let group = serde_json::json!({
669            "name": OVERLAY_GROUP,
670            "renderOrder": 6,
671            "lines": { "positions": line_positions, "colors": line_colors },
672            "points": { "positions": dot_positions, "colors": dot_colors, "size": DOT_SIZE_PX },
673        });
674        let _ = self
675            .widgets
676            .set_overlay_json(&serde_json::json!({ "groups": [group] }).to_string());
677        self.dirty = true;
678    }
679}
680
681// BREP private tests: 0173aa49ace7a515