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.
68pub(super) fn 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 WAYPOINT 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 == "WP" || ty == "WAYPOINT")
171    }
172
173    /// Whether `name` addresses a harness node at all: a WAYPOINT feature of
174    /// this document, a connection point it declares (`J1.VCC`), or one a
175    /// placed component carries (`ACOMP1:J1.VCC`), which the harness report
176    /// lists among its endpoints.
177    pub fn is_port_id(&self, name: &str) -> bool {
178        self.is_port_feature(name)
179            || self.history.declared_points()
180                .iter()
181                .any(|point| point.address() == name)
182            || self
183                .wire_harness_report
184                .as_ref()
185                .is_some_and(|report| report.endpoints.iter().any(|endpoint| endpoint.id == name))
186    }
187
188    /// Whether `feature_id` names a SPLINE feature.
189    pub fn is_spline_feature(&self, feature_id: &str) -> bool {
190        self.history
191            .index_of(feature_id)
192            .and_then(|index| self.history.feature_type(index))
193            .is_some_and(|ty| ty == "SP")
194    }
195
196    /// The spline's anchors, resolved (empty for a non-spline id).
197    pub fn spline_anchors(&self, feature_id: &str) -> Vec<SplineAnchorRow> {
198        let Some(index) = self.history.index_of(feature_id) else {
199            return Vec::new();
200        };
201        if !self.is_spline_feature(feature_id) {
202            return Vec::new();
203        }
204        let points = normalized_points(self.history.feature_persistent_data(index).as_ref());
205        points
206            .iter()
207            .enumerate()
208            .map(|(i, point)| {
209                let name = format!("{feature_id}:P{i}");
210                let axes = read_axes(point.get("rotation"));
211                let flip = point.get("flipDirection").and_then(Value::as_bool).unwrap_or(false);
212                let stored_direction = if flip {
213                    [-axes[0][0], -axes[0][1], -axes[0][2]]
214                } else {
215                    axes[0]
216                };
217                let position = self
218                    .sketch_points
219                    .iter()
220                    .find(|(n, _)| *n == name)
221                    .map(|(_, p)| [p.position.x, p.position.y, p.position.z])
222                    .unwrap_or_else(|| read_vec3(point.get("position"), [0.0; 3]));
223                let direction = self
224                    .sketch_axes
225                    .iter()
226                    .find(|(n, _)| *n == name)
227                    .map(|(_, a)| [a.direction.x, a.direction.y, a.direction.z])
228                    .unwrap_or_else(|| normalize3(stored_direction));
229                let attached = brep_kernel::SplineAttachment::parse(point.get("attachment"))
230                    .map(|a| (a.port_ref, a.side));
231                SplineAnchorRow {
232                    index: i,
233                    id: point.get("id").and_then(Value::as_str).unwrap_or("").to_string(),
234                    position,
235                    direction,
236                    forward: distance(point.get("forwardDistance")),
237                    backward: distance(point.get("backwardDistance")),
238                    flip,
239                    attached,
240                }
241            })
242            .collect()
243    }
244
245    /// The armed spline anchor `(feature id, index)`, if the gizmo is on one.
246    pub fn armed_spline_anchor(&self) -> Option<(String, usize)> {
247        let anchor = self.transform_gizmo.anchor?;
248        let id = self.transform_gizmo.feature_id.clone()?;
249        Some((id, anchor))
250    }
251
252    /// The spline whose anchor editor is open, if any (the history panel feeds
253    /// it with the cage overlay).
254    pub fn spline_edit_feature(&self) -> Option<&str> {
255        self.spline_edit_feature.as_deref()
256    }
257
258    /// A viewport click while a spline's editor is open: when one of THAT
259    /// spline's anchor dots (the sheet's vertex there) sits under CSS-pixel
260    /// `(x, y)`, select the anchor — the gizmo arms on a free one — and report
261    /// its index. The RAW pick is used, so the selection filter cannot hide
262    /// the anchors; `None` leaves the click to the ordinary selection.
263    pub fn spline_anchor_pick_at(&mut self, x: f64, y: f64) -> Option<usize> {
264        let feature_id = self.spline_edit_feature.clone()?;
265        let candidates = self.pick_candidates_at(x, y);
266        let hit = candidates
267            .iter()
268            .find(|c| matches!(c.kind, crate::pick::PickKind::Vertex) && c.solid == feature_id)?;
269        let rows = self.spline_anchors(&feature_id);
270        let index = rows
271            .iter()
272            .map(|row| {
273                let d = [0, 1, 2]
274                    .iter()
275                    .map(|&k| (row.position[k] - hit.position[k]).powi(2))
276                    .sum::<f64>()
277                    .sqrt();
278                (row.index, d, row.position)
279            })
280            .filter(|(_, d, p)| {
281                *d <= 1e-4 * (1.0 + p.iter().map(|v| v.abs()).fold(0.0, f64::max))
282            })
283            .min_by(|a, b| a.1.total_cmp(&b.1))?
284            .0;
285        self.arm_spline_anchor(&feature_id, index);
286        self.spline_anchor_picked = Some(index);
287        Some(index)
288    }
289
290    /// The anchor the last viewport click selected, once — the history panel
291    /// takes it to move its row selection.
292    pub fn take_spline_anchor_pick(&mut self) -> Option<usize> {
293        self.spline_anchor_picked.take()
294    }
295
296    // --- the write lane ----------------------------------------------------------
297
298    /// The normalized point list of `feature_id`, or `None` for a non-spline.
299    fn spline_points(&self, feature_id: &str) -> Option<Vec<Value>> {
300        let index = self.history.index_of(feature_id)?;
301        if !self.is_spline_feature(feature_id) {
302            return None;
303        }
304        Some(normalized_points(self.history.feature_persistent_data(index).as_ref()))
305    }
306
307    /// Persist `points` as the spline document (checkpointed; `coalesce`
308    /// folds a run of same-key writes into one undo step) and re-run.
309    fn write_spline_points(&mut self, feature_id: &str, points: Vec<Value>, coalesce: Option<&str>) {
310        let Some(index) = self.history.index_of(feature_id) else {
311            return;
312        };
313        self.history.set_feature_persistent_field_coalesced(
314            index,
315            "spline",
316            serde_json::json!({ "points": points }),
317            coalesce,
318        );
319        self.rerun_history();
320    }
321
322    /// Edit one anchor in place through `edit`, then persist + re-run.
323    fn edit_spline_anchor(
324        &mut self,
325        feature_id: &str,
326        anchor: usize,
327        coalesce: Option<&str>,
328        edit: impl FnOnce(&mut serde_json::Map<String, Value>),
329    ) -> Result<(), String> {
330        let mut points = self
331            .spline_points(feature_id)
332            .ok_or_else(|| format!("'{feature_id}' is not a spline"))?;
333        let point = points
334            .get_mut(anchor)
335            .and_then(Value::as_object_mut)
336            .ok_or_else(|| format!("spline '{feature_id}' has no anchor {anchor}"))?;
337        edit(point);
338        self.write_spline_points(feature_id, points, coalesce);
339        Ok(())
340    }
341
342    /// Set an anchor's stored position (ignored by the kernel while attached).
343    pub fn spline_set_anchor_position(
344        &mut self,
345        feature_id: &str,
346        anchor: usize,
347        position: [f64; 3],
348    ) -> Result<(), String> {
349        let key = format!("spline-anchor-position:{feature_id}:{anchor}");
350        self.edit_spline_anchor(feature_id, anchor, Some(&key), |point| {
351            point.insert("position".into(), serde_json::json!(position));
352        })
353    }
354
355    /// Set an anchor's forward / backward straight-run distances (either may
356    /// be left alone). Negative values clamp to zero.
357    pub fn spline_set_anchor_distances(
358        &mut self,
359        feature_id: &str,
360        anchor: usize,
361        forward: Option<f64>,
362        backward: Option<f64>,
363    ) -> Result<(), String> {
364        let key = format!("spline-anchor-distances:{feature_id}:{anchor}");
365        self.edit_spline_anchor(feature_id, anchor, Some(&key), |point| {
366            if let Some(forward) = forward {
367                point.insert("forwardDistance".into(), Value::from(forward.max(0.0)));
368            }
369            if let Some(backward) = backward {
370                point.insert("backwardDistance".into(), Value::from(backward.max(0.0)));
371            }
372        })
373    }
374
375    /// Flip an anchor's travel direction (no effect while attached — the
376    /// side decides).
377    pub fn spline_set_anchor_flip(&mut self, feature_id: &str, anchor: usize, flip: bool) -> Result<(), String> {
378        self.edit_spline_anchor(feature_id, anchor, None, |point| {
379            point.insert("flipDirection".into(), Value::Bool(flip));
380        })
381    }
382
383    /// Switch an attached anchor's port side.
384    pub fn spline_set_anchor_side(&mut self, feature_id: &str, anchor: usize, side: PortSide) -> Result<(), String> {
385        self.edit_spline_anchor(feature_id, anchor, None, |point| {
386            if let Some(attachment) = point.get_mut("attachment").and_then(Value::as_object_mut) {
387                attachment.insert("side".into(), Value::String(side.letter().to_string()));
388            }
389        })
390    }
391
392    /// Detach an anchor from its port, keeping the curve where it is: the
393    /// resolved position and direction the port gave it become its stored
394    /// placement.
395    pub fn spline_detach_anchor(&mut self, feature_id: &str, anchor: usize) -> Result<(), String> {
396        let row = self
397            .spline_anchors(feature_id)
398            .into_iter()
399            .nth(anchor)
400            .ok_or_else(|| format!("spline '{feature_id}' has no anchor {anchor}"))?;
401        self.edit_spline_anchor(feature_id, anchor, None, |point| {
402            point.insert("attachment".into(), Value::Null);
403            point.insert("position".into(), serde_json::json!(row.position));
404            point.insert("rotation".into(), axes_value(axes_from_direction(row.direction)));
405            point.insert("flipDirection".into(), Value::Bool(false));
406        })
407    }
408
409    /// Add an anchor after `after` (the last anchor when `None`), placed past
410    /// it along its direction by its forward run plus a gap, facing the same
411    /// way. Returns the new anchor's index.
412    pub fn spline_add_anchor(&mut self, feature_id: &str, after: Option<usize>) -> Result<usize, String> {
413        let rows = self.spline_anchors(feature_id);
414        if rows.is_empty() {
415            return Err(format!("'{feature_id}' is not a spline"));
416        }
417        let mut points = self.spline_points(feature_id).expect("a spline by the check above");
418        let at = after.unwrap_or(rows.len() - 1).min(rows.len() - 1);
419        let reference = &rows[at];
420        let step = reference.forward + NEW_ANCHOR_GAP;
421        let position = [
422            reference.position[0] + reference.direction[0] * step,
423            reference.position[1] + reference.direction[1] * step,
424            reference.position[2] + reference.direction[2] * step,
425        ];
426        let next_number = points.len();
427        let point = serde_json::json!({
428            "id": format!("p{next_number}"),
429            "position": position,
430            "rotation": axes_value(axes_from_direction(reference.direction)),
431            "forwardDistance": 1.0,
432            "backwardDistance": 1.0,
433            "flipDirection": false,
434            "attachment": Value::Null,
435        });
436        let index = at + 1;
437        points.insert(index, point);
438        self.write_spline_points(feature_id, points, None);
439        Ok(index)
440    }
441
442    /// Remove an anchor. A spline keeps at least two.
443    pub fn spline_remove_anchor(&mut self, feature_id: &str, anchor: usize) -> Result<(), String> {
444        let mut points = self
445            .spline_points(feature_id)
446            .ok_or_else(|| format!("'{feature_id}' is not a spline"))?;
447        if anchor >= points.len() {
448            return Err(format!("spline '{feature_id}' has no anchor {anchor}"));
449        }
450        if points.len() <= 2 {
451            return Err("a spline keeps at least two anchors".into());
452        }
453        points.remove(anchor);
454        if self.transform_gizmo.anchor.is_some()
455            && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
456        {
457            self.disarm_transform();
458        }
459        self.write_spline_points(feature_id, points, None);
460        Ok(())
461    }
462
463    /// Move an anchor one place up (towards the start) or down.
464    pub fn spline_move_anchor(&mut self, feature_id: &str, anchor: usize, up: bool) -> Result<(), String> {
465        let mut points = self
466            .spline_points(feature_id)
467            .ok_or_else(|| format!("'{feature_id}' is not a spline"))?;
468        let target = if up {
469            anchor.checked_sub(1)
470        } else {
471            (anchor + 1 < points.len()).then_some(anchor + 1)
472        };
473        let Some(target) = target else {
474            return Ok(()); // already at the end it was pushed towards
475        };
476        if anchor >= points.len() {
477            return Err(format!("spline '{feature_id}' has no anchor {anchor}"));
478        }
479        points.swap(anchor, target);
480        if self.transform_gizmo.anchor == Some(anchor)
481            && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
482        {
483            self.transform_gizmo.anchor = Some(target);
484        }
485        self.write_spline_points(feature_id, points, None);
486        Ok(())
487    }
488
489    // --- the gizmo -----------------------------------------------------------------
490
491    /// Arm the move/rotate gizmo on one anchor. Refused (false) for an
492    /// attached anchor — the port owns its pose — and for a bad index.
493    pub fn arm_spline_anchor(&mut self, feature_id: &str, anchor: usize) -> bool {
494        let rows = self.spline_anchors(feature_id);
495        let Some(row) = rows.get(anchor) else {
496            return false;
497        };
498        if row.attached.is_some() {
499            // The port owns this anchor's pose: nothing to arm — and a gizmo
500            // left on the PREVIOUSLY selected anchor would contradict the
501            // selection, so it goes.
502            if self.transform_gizmo.anchor.is_some() {
503                self.disarm_transform();
504            }
505            return false;
506        }
507        self.component_move_reset();
508        self.transform_gizmo.feature_id = Some(feature_id.to_string());
509        self.transform_gizmo.mode = GizmoMode::Transform;
510        self.transform_gizmo.drag = None;
511        self.transform_gizmo.anchor = Some(anchor);
512        self.clear_feature_dimension_overlay();
513        self.sync_transform_gizmo();
514        true
515    }
516
517    /// The armed anchor's pose: its stored position and the Euler of its
518    /// stored axis triad. `None` when the anchor is gone (the gizmo disarms).
519    pub(super) fn spline_anchor_pose(&self, feature_index: usize, anchor: usize) -> Option<([f64; 3], [f64; 3])> {
520        let points = normalized_points(self.history.feature_persistent_data(feature_index).as_ref());
521        let point = points.get(anchor)?;
522        let axes = read_axes(point.get("rotation"));
523        Some((
524            read_vec3(point.get("position"), [0.0; 3]),
525            euler_xyz_deg_from_axes(axes[0], axes[1], axes[2]),
526        ))
527    }
528
529    /// The gizmo's write-back for an anchor: position + the axis triad the
530    /// Euler rotates onto, coalesced per anchor so a drag is one undo step.
531    pub(super) fn write_spline_anchor_pose(
532        &mut self,
533        feature_id: &str,
534        anchor: usize,
535        position: [f64; 3],
536        rotation_deg: [f64; 3],
537    ) {
538        let key = format!("spline-anchor-pose:{feature_id}:{anchor}");
539        let _ = self.edit_spline_anchor(feature_id, anchor, Some(&key), |point| {
540            point.insert("position".into(), serde_json::json!(position));
541            point.insert("rotation".into(), axes_value(axes_from_euler_deg(rotation_deg)));
542        });
543    }
544
545    // --- attach (the reference picker's spline-anchor flavour) --------------------
546
547    /// Enter the reference picker to attach `anchor` to a port: the pick
548    /// admits a port's drawn sheet (its line / base vertex / the sheet), and
549    /// Finish writes the attachment.
550    pub fn begin_ref_select_for_spline_anchor(&mut self, feature_id: &str, anchor: usize) {
551        let restore_index = self.history.rollback();
552        let before = self
553            .history
554            .index_of(feature_id)
555            .map(|i| i.saturating_sub(1))
556            .unwrap_or(restore_index);
557        let filter: Vec<String> = ["SKETCH", "EDGE", "VERTEX"].iter().map(|s| s.to_string()).collect();
558        self.selection_filter = SelectionFilter::from_ref_filter(&filter);
559        let seed = self
560            .spline_anchors(feature_id)
561            .get(anchor)
562            .and_then(|row| row.attached.as_ref().map(|(port, _)| port.clone()))
563            .into_iter()
564            .collect();
565        self.ref_select = Some(RefSelectState {
566            feature_id: feature_id.to_string(),
567            path: Vec::new(),
568            label: format!("Anchor {anchor}: attach to port"),
569            filter,
570            multiple: false,
571            names: seed,
572            restore_index,
573            target: RefSelectTarget::SplineAnchor { index: anchor },
574        });
575        if before != restore_index {
576            self.history.set_rollback(before);
577            self.rerun_history();
578        }
579        self.sync_ref_select_emphasis();
580    }
581
582    /// Write `port` as `anchor`'s attachment (side `A` for a new port; an
583    /// existing side is kept when re-attaching to the same port) WITHOUT
584    /// re-running — the picker's shared end tail re-runs.
585    pub(super) fn attach_spline_anchor_no_rerun(&mut self, feature_id: &str, anchor: usize, port: &str) {
586        let Some(index) = self.history.index_of(feature_id) else {
587            return;
588        };
589        let Some(mut points) = self.spline_points(feature_id) else {
590            return;
591        };
592        let Some(point) = points.get_mut(anchor).and_then(Value::as_object_mut) else {
593            return;
594        };
595        let side = brep_kernel::SplineAttachment::parse(point.get("attachment"))
596            .filter(|current| current.port_ref == port)
597            .map(|current| current.side)
598            .unwrap_or(PortSide::A);
599        point.insert(
600            "attachment".into(),
601            serde_json::json!({ "portRef": port, "side": side.letter() }),
602        );
603        if self.transform_gizmo.anchor == Some(anchor)
604            && self.transform_gizmo.feature_id.as_deref() == Some(feature_id)
605        {
606            // The port owns the pose now; the gizmo has nothing to move.
607            self.transform_gizmo.anchor = None;
608            self.transform_gizmo.feature_id = None;
609            self.transform_gizmo.mode = GizmoMode::None;
610            let _ = self.widgets.set_transform_json("null");
611        }
612        self.history.set_feature_persistent_field(index, "spline", serde_json::json!({ "points": points }));
613    }
614
615    // --- the cage overlay ---------------------------------------------------------
616
617    /// Feed the anchor cage for `feature_id` (its forward / backward runs and
618    /// a dot per anchor, `selected` highlighted), or clear it with `None`.
619    /// Cheap to call every frame: an unchanged feed is skipped.
620    pub fn refresh_spline_edit_overlay(&mut self, feature_id: Option<&str>, selected: Option<usize>) {
621        if self.spline_edit_feature.as_deref() != feature_id {
622            self.spline_edit_feature = feature_id.map(str::to_string);
623            self.spline_anchor_picked = None;
624        }
625        let key = feature_id.map(|id| (id.to_string(), selected, self.applied_generation));
626        if self.spline_overlay_key == key {
627            return;
628        }
629        self.spline_overlay_key = key;
630        let Some(feature_id) = feature_id else {
631            let _ = self
632                .widgets
633                .set_overlay_json(&serde_json::json!({ "groups": [{ "name": OVERLAY_GROUP }] }).to_string());
634            self.dirty = true;
635            return;
636        };
637        let rows = self.spline_anchors(feature_id);
638        let mut line_positions: Vec<f64> = Vec::new();
639        let mut line_colors: Vec<f32> = Vec::new();
640        let mut dot_positions: Vec<f64> = Vec::new();
641        let mut dot_colors: Vec<f32> = Vec::new();
642        let mut push_line = |a: [f64; 3], b: [f64; 3], color: [f32; 3]| {
643            line_positions.extend_from_slice(&a);
644            line_positions.extend_from_slice(&b);
645            line_colors.extend_from_slice(&color);
646            line_colors.extend_from_slice(&color);
647        };
648        for row in &rows {
649            let along = |scale: f64| {
650                [
651                    row.position[0] + row.direction[0] * scale,
652                    row.position[1] + row.direction[1] * scale,
653                    row.position[2] + row.direction[2] * scale,
654                ]
655            };
656            if row.forward > 0.0 {
657                push_line(row.position, along(row.forward), CAGE_FORWARD);
658            }
659            if row.backward > 0.0 {
660                push_line(row.position, along(-row.backward), CAGE_BACKWARD);
661            }
662            dot_positions.extend_from_slice(&row.position);
663            let color = if Some(row.index) == selected {
664                DOT_SELECTED
665            } else if row.attached.is_some() {
666                DOT_ATTACHED
667            } else {
668                DOT_PLAIN
669            };
670            dot_colors.extend_from_slice(&color);
671        }
672        let group = serde_json::json!({
673            "name": OVERLAY_GROUP,
674            "renderOrder": 6,
675            "lines": { "positions": line_positions, "colors": line_colors },
676            "points": { "positions": dot_positions, "colors": dot_colors, "size": DOT_SIZE_PX },
677        });
678        let _ = self
679            .widgets
680            .set_overlay_json(&serde_json::json!({ "groups": [group] }).to_string());
681        self.dirty = true;
682    }
683}
684