Skip to main content

brep_render/sketch/
external_ref.rs

1//! External-reference edges (S6b-2) — link a picked 3D solid edge into the sketch
2//! as a construction reference.
3//!
4//! The pickEdges tool projects a scene edge's world-space polyline into the active
5//! sketch plane, classifies the projected shape (straight → `line`, a fitted
6//! circle → `circle`/`arc`, else a faithful `line`-chain fallback), and materializes
7//! it as external-reference geometry: `{fixed, construction, externalReference}`
8//! points, a `⏚` GROUND constraint per point (so the solver pins them), and the
9//! construction geometry referencing them. A per-session [`ExternalRef`] mapping
10//! (keyed by edge name) dedups a re-pick — the SAME edge UPDATES its points'
11//! coordinates instead of duplicating — and round-trips through
12//! `persistentData.externalRefs`.
13//!
14//! This mirrors `#ensureExternalRefForEdge`/`#projectWorldToUV`,
15//! extended to also emit the reference GEOMETRY (the previous version stored only the two
16//! endpoints) so the linked edge is visible and constrainable in the sketch.
17
18use std::collections::HashSet;
19
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22
23use super::doc::{id_key, SketchConstraint, SketchDoc, SketchGeometry, SketchPoint};
24use super::PlaneFrame;
25
26/// The classification of a projected edge polyline, in plane `(u, v)` coordinates.
27#[derive(Clone, Debug, PartialEq)]
28pub enum EdgeLink {
29    /// A straight edge — link as a `line` through the two endpoints.
30    Line { a: (f64, f64), b: (f64, f64) },
31    /// A closed circular edge — link as a `circle` (center + a rim point).
32    Circle { center: (f64, f64), rim: (f64, f64) },
33    /// A circular ARC — link as an `arc` (center, start, end; CCW start→end).
34    Arc {
35        center: (f64, f64),
36        start: (f64, f64),
37        end: (f64, f64),
38    },
39    /// A faithful fallback for anything else — a chain of `line` segments through the
40    /// projected polyline samples.
41    Polyline { pts: Vec<(f64, f64)> },
42}
43
44impl EdgeLink {
45    /// The solver geometry `type` this link materializes as (`polyline` fallback is a
46    /// chain of `line`s, so its representative type is `"line"`).
47    pub fn kind(&self) -> &'static str {
48        match self {
49            EdgeLink::Line { .. } => "line",
50            EdgeLink::Circle { .. } => "circle",
51            EdgeLink::Arc { .. } => "arc",
52            EdgeLink::Polyline { .. } => "polyline",
53        }
54    }
55
56    /// The ordered `(u, v)` coordinates of the points this link materializes (its
57    /// geometry references them in order).
58    pub fn point_uvs(&self) -> Vec<(f64, f64)> {
59        match self {
60            EdgeLink::Line { a, b } => vec![*a, *b],
61            EdgeLink::Circle { center, rim } => vec![*center, *rim],
62            EdgeLink::Arc { center, start, end } => vec![*center, *start, *end],
63            EdgeLink::Polyline { pts } => pts.clone(),
64        }
65    }
66}
67
68/// Project a world-space polyline into the plane's `(u, v)` frame (orthogonal
69/// projection; the off-plane component is dropped). Mirrors the previous
70/// world→UV projection applied per vertex.
71pub fn project_polyline(plane: &PlaneFrame, world: &[[f64; 3]]) -> Vec<(f64, f64)> {
72    world.iter().map(|&w| plane.to_uv(w)).collect()
73}
74
75/// Classify a projected polyline (in plane `(u, v)`) as a straight line, a circle /
76/// arc, or a polyline fallback. Tolerances are RELATIVE to the polyline's extent so
77/// the same thresholds work at any sketch scale:
78///
79/// - **STRAIGHT** when every interior sample lies within `1e-4·extent` of the chord
80///   between the endpoints (a 2-sample polyline is trivially straight).
81/// - **CIRCULAR** when a circle fit through 3 well-spaced samples has a max radial
82///   residual under `1e-3·extent` (and a non-degenerate radius). Coincident
83///   endpoints → a closed `Circle`; else an `Arc`.
84/// - else the **Polyline** fallback.
85pub fn classify_uv(uv: &[(f64, f64)]) -> EdgeLink {
86    let n = uv.len();
87    if n < 2 {
88        // Degenerate — surface it as a (possibly zero-length) polyline; callers guard
89        // against < 2 samples before linking.
90        return EdgeLink::Polyline { pts: uv.to_vec() };
91    }
92    let a = uv[0];
93    let b = uv[n - 1];
94    let extent = polyline_extent(uv).max(1e-9);
95    let straight_tol = 1e-4 * extent;
96    let closed = dist(a, b) <= straight_tol;
97
98    // Two samples (or all-interior-on-chord and open) → a straight line.
99    if !closed {
100        let max_dev = uv[1..n - 1]
101            .iter()
102            .map(|&p| point_segment_distance(p, a, b))
103            .fold(0.0_f64, f64::max);
104        if n == 2 || max_dev <= straight_tol {
105            return EdgeLink::Line { a, b };
106        }
107    }
108
109    // Circle fit through three well-spaced samples. Sampling at 0 / n/3 / 2n/3 (NOT
110    // the last index) keeps the three distinct even for a CLOSED loop, where the
111    // first and last samples coincide.
112    if n >= 3 {
113        if let Some((cx, cy, r)) = fit_circle(uv[0], uv[n / 3], uv[(2 * n) / 3]) {
114            let circle_tol = 1e-3 * extent;
115            let residual = uv
116                .iter()
117                .map(|&p| (dist(p, (cx, cy)) - r).abs())
118                .fold(0.0_f64, f64::max);
119            if r.is_finite() && r > straight_tol && residual <= circle_tol {
120                if closed {
121                    return EdgeLink::Circle {
122                        center: (cx, cy),
123                        rim: (cx + r, cy),
124                    };
125                }
126                return EdgeLink::Arc {
127                    center: (cx, cy),
128                    start: a,
129                    end: b,
130                };
131            }
132        }
133    }
134
135    EdgeLink::Polyline { pts: uv.to_vec() }
136}
137
138/// A per-session external-reference mapping: the linked scene edge (by name + owning
139/// solid) and the sketch entities materialized for it. Persisted to / loaded from
140/// `persistentData.externalRefs` so a linked edge round-trips a commit + re-enter.
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142pub struct ExternalRef {
143    /// Kernel edge name (the dedup key).
144    #[serde(rename = "edgeName")]
145    pub edge_name: String,
146    /// Owning solid's scene name (metadata; may be empty).
147    #[serde(rename = "solidName", default)]
148    pub solid_name: String,
149    /// The materialized point ids (order matches the link's geometry).
150    #[serde(rename = "pointIds", default)]
151    pub point_ids: Vec<Value>,
152    /// The materialized geometry ids (one for line/circle/arc; N-1 for a polyline
153    /// chain).
154    #[serde(rename = "geomIds", default)]
155    pub geom_ids: Vec<Value>,
156    /// The link classification (`"line"|"circle"|"arc"|"polyline"`).
157    #[serde(default)]
158    pub kind: String,
159}
160
161/// Link (or update) a picked scene edge into the sketch as an external reference.
162///
163/// Projects `world_poly` into `plane`, classifies it, and materializes external-ref
164/// points + `⏚` grounds + construction geometry, recording an [`ExternalRef`] in
165/// `refs`. Dedup: when `refs` already holds an entry for `edge_name` whose structure
166/// matches the new classification, the existing points are UPDATED in place (no
167/// duplication); when the structure differs, the old entities are removed and fresh
168/// ones created; otherwise a new ref is appended.
169///
170/// Returns whether the doc actually changed (a new/rebuilt ref, or moved coordinates)
171/// — `false` on a redundant re-link of an unchanged edge, so the caller can drop a
172/// dead undo step. Never solves; the caller re-solves.
173pub fn link_or_update(
174    doc: &mut SketchDoc,
175    refs: &mut Vec<ExternalRef>,
176    edge_name: &str,
177    solid_name: &str,
178    world_poly: &[[f64; 3]],
179    plane: &PlaneFrame,
180) -> bool {
181    if world_poly.len() < 2 {
182        return false;
183    }
184    let uv = project_polyline(plane, world_poly);
185    let link = classify_uv(&uv);
186    let new_uvs = link.point_uvs();
187
188    if let Some(pos) = refs.iter().position(|r| r.edge_name == edge_name) {
189        let structure_matches =
190            refs[pos].kind == link.kind() && refs[pos].point_ids.len() == new_uvs.len();
191        if structure_matches {
192            // Update the existing points' coordinates in place (keeps ids + geometry).
193            let mut moved = false;
194            let point_ids = refs[pos].point_ids.clone();
195            for (id, (u, v)) in point_ids.iter().zip(new_uvs.iter()) {
196                if let Some(p) = doc.point_mut(id) {
197                    if (p.x - u).abs() > 1e-12 || (p.y - v).abs() > 1e-12 {
198                        moved = true;
199                    }
200                    p.x = *u;
201                    p.y = *v;
202                    p.fixed = true;
203                    p.construction = true;
204                    p.external_reference = true;
205                }
206            }
207            if refs[pos].solid_name != solid_name {
208                refs[pos].solid_name = solid_name.to_string();
209            }
210            return moved;
211        }
212        // Structure changed (e.g. a straight edge became curved after a model edit) —
213        // drop the stale entities and rebuild the ref fresh.
214        remove_ref_entities(doc, &refs[pos].clone());
215        let (point_ids, geom_ids) = add_external_ref(doc, &link);
216        refs[pos] = ExternalRef {
217            edge_name: edge_name.to_string(),
218            solid_name: solid_name.to_string(),
219            point_ids,
220            geom_ids,
221            kind: link.kind().to_string(),
222        };
223        return true;
224    }
225
226    // A brand-new reference for this edge.
227    let (point_ids, geom_ids) = add_external_ref(doc, &link);
228    refs.push(ExternalRef {
229        edge_name: edge_name.to_string(),
230        solid_name: solid_name.to_string(),
231        point_ids,
232        geom_ids,
233        kind: link.kind().to_string(),
234    });
235    true
236}
237
238/// Materialize an [`EdgeLink`] into the doc: push external-ref points (`fixed`,
239/// `construction`, `externalReference`) with a `⏚` ground each, then the construction
240/// geometry referencing them. Returns `(point_ids, geom_ids)`.
241pub fn add_external_ref(doc: &mut SketchDoc, link: &EdgeLink) -> (Vec<Value>, Vec<Value>) {
242    let mut point_ids = Vec::new();
243    for (u, v) in link.point_uvs() {
244        let id = doc.next_point_id();
245        doc.points.push(SketchPoint {
246            id: id.clone(),
247            x: u,
248            y: v,
249            fixed: true,
250            construction: true,
251            external_reference: true,
252        });
253        push_ground(doc, &id);
254        point_ids.push(id);
255    }
256    let geom_ids = match link {
257        EdgeLink::Line { .. } => vec![push_construction_geometry(
258            doc,
259            "line",
260            vec![point_ids[0].clone(), point_ids[1].clone()],
261        )],
262        EdgeLink::Circle { .. } => vec![push_construction_geometry(
263            doc,
264            "circle",
265            vec![point_ids[0].clone(), point_ids[1].clone()],
266        )],
267        EdgeLink::Arc { .. } => vec![push_construction_geometry(
268            doc,
269            "arc",
270            vec![
271                point_ids[0].clone(),
272                point_ids[1].clone(),
273                point_ids[2].clone(),
274            ],
275        )],
276        EdgeLink::Polyline { .. } => point_ids
277            .windows(2)
278            .map(|w| push_construction_geometry(doc, "line", vec![w[0].clone(), w[1].clone()]))
279            .collect(),
280    };
281    (point_ids, geom_ids)
282}
283
284/// Remove every entity an [`ExternalRef`] materialized: its geometries, its points,
285/// and any constraint referencing one of its points (the `⏚` grounds).
286fn remove_ref_entities(doc: &mut SketchDoc, r: &ExternalRef) {
287    let pt_keys: HashSet<String> = r.point_ids.iter().map(id_key).collect();
288    let geo_keys: HashSet<String> = r.geom_ids.iter().map(id_key).collect();
289    doc.geometries.retain(|g| !geo_keys.contains(&id_key(&g.id)));
290    doc.points.retain(|p| !pt_keys.contains(&id_key(&p.id)));
291    doc.constraints
292        .retain(|c| !c.points().iter().any(|p| pt_keys.contains(&id_key(p))));
293}
294
295/// Drop every external-reference entry whose materialized entities no longer resolve
296/// in `doc` — i.e. the linked edge's points / geometry were deleted. A dropped entry
297/// frees its `edge_name` so the SAME edge can be RE-LINKED: without this, the stale
298/// entry (keyed by `edge_name`, holding now-dangling `point_ids`) makes
299/// [`link_or_update`]'s dedup take the "structure matches" branch, find every point
300/// missing (`doc.point_mut` → `None`), and return `false` — permanently blocking the
301/// re-link. Called both after a delete AND on sketch-enter (to heal docs already
302/// poisoned by that pre-fix delete). Returns whether any entry was pruned.
303pub fn prune_dead_refs(doc: &SketchDoc, refs: &mut Vec<ExternalRef>) -> bool {
304    let before = refs.len();
305    refs.retain(|r| {
306        r.point_ids.iter().all(|id| doc.point(id).is_some())
307            && r.geom_ids.iter().all(|id| doc.geometry(id).is_some())
308    });
309    refs.len() != before
310}
311
312/// Push a `⏚` GROUND constraint pinning point `pid` (mirrors the S4 toggle-ground
313/// factory).
314fn push_ground(doc: &mut SketchDoc, pid: &Value) {
315    let cid = doc.next_constraint_id();
316    let mut raw = Map::new();
317    raw.insert("id".to_string(), cid);
318    raw.insert("type".to_string(), Value::String("⏚".to_string()));
319    raw.insert("points".to_string(), Value::Array(vec![pid.clone()]));
320    doc.constraints.push(SketchConstraint { raw });
321}
322
323/// Push a construction geometry (`construction: true` — dashed, non-modeling) with a
324/// freshly minted id, returning that id.
325fn push_construction_geometry(doc: &mut SketchDoc, geom_type: &str, points: Vec<Value>) -> Value {
326    let id = doc.next_geometry_id();
327    let mut extra = Map::new();
328    extra.insert("construction".to_string(), Value::Bool(true));
329    doc.geometries.push(SketchGeometry {
330        id: id.clone(),
331        geom_type: geom_type.to_string(),
332        points,
333        extra,
334    });
335    id
336}
337
338// --- geometry helpers ---------------------------------------------------------
339
340/// Euclidean distance between two `(u, v)` points.
341fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
342    ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
343}
344
345/// The bounding-box diagonal of a `(u, v)` polyline — the relative-tolerance scale.
346fn polyline_extent(uv: &[(f64, f64)]) -> f64 {
347    let (mut minx, mut miny) = (f64::INFINITY, f64::INFINITY);
348    let (mut maxx, mut maxy) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
349    for &(x, y) in uv {
350        minx = minx.min(x);
351        miny = miny.min(y);
352        maxx = maxx.max(x);
353        maxy = maxy.max(y);
354    }
355    ((maxx - minx).powi(2) + (maxy - miny).powi(2)).sqrt()
356}
357
358/// Distance from point `p` to segment `a`–`b` (all in `(u, v)`).
359fn point_segment_distance(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
360    let (dx, dy) = (b.0 - a.0, b.1 - a.1);
361    let len2 = dx * dx + dy * dy;
362    let t = if len2 <= 1e-18 {
363        0.0
364    } else {
365        (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2).clamp(0.0, 1.0)
366    };
367    dist(p, (a.0 + t * dx, a.1 + t * dy))
368}
369
370/// Fit a circle through three points (circumcenter + radius), or `None` when the
371/// points are (near) collinear.
372fn fit_circle(p1: (f64, f64), p2: (f64, f64), p3: (f64, f64)) -> Option<(f64, f64, f64)> {
373    let (ax, ay) = p1;
374    let (bx, by) = p2;
375    let (cx, cy) = p3;
376    // 2·(signed area of the triangle) — zero when collinear.
377    let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
378    if d.abs() < 1e-12 {
379        return None;
380    }
381    let a2 = ax * ax + ay * ay;
382    let b2 = bx * bx + by * by;
383    let c2 = cx * cx + cy * cy;
384    let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
385    let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
386    let r = dist((ux, uy), p1);
387    if !ux.is_finite() || !uy.is_finite() || !r.is_finite() {
388        return None;
389    }
390    Some((ux, uy, r))
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use serde_json::json;
397
398    fn empty_doc() -> SketchDoc {
399        SketchDoc::default()
400    }
401
402    /// A straight polyline (2 samples) classifies as a line.
403    #[test]
404    fn classify_two_point_polyline_is_a_line() {
405        let link = classify_uv(&[(0.0, 0.0), (10.0, 5.0)]);
406        assert_eq!(link, EdgeLink::Line { a: (0.0, 0.0), b: (10.0, 5.0) });
407    }
408
409    /// A densely sampled straight polyline (interior on the chord) classifies as a
410    /// line, not a curve.
411    #[test]
412    fn classify_collinear_samples_is_a_line() {
413        let uv: Vec<(f64, f64)> = (0..=10).map(|i| (i as f64, 2.0 * i as f64)).collect();
414        assert_eq!(classify_uv(&uv).kind(), "line");
415    }
416
417    /// A closed circular polyline classifies as a circle with the fitted center +
418    /// radius.
419    #[test]
420    fn classify_closed_circle() {
421        let (cx, cy, r) = (3.0, -1.0, 5.0);
422        let n = 64;
423        let uv: Vec<(f64, f64)> = (0..=n)
424            .map(|i| {
425                let t = i as f64 / n as f64 * std::f64::consts::TAU;
426                (cx + r * t.cos(), cy + r * t.sin())
427            })
428            .collect();
429        match classify_uv(&uv) {
430            EdgeLink::Circle { center, rim } => {
431                assert!((center.0 - cx).abs() < 1e-6 && (center.1 - cy).abs() < 1e-6);
432                assert!((dist(center, rim) - r).abs() < 1e-6);
433            }
434            other => panic!("expected circle, got {other:?}"),
435        }
436    }
437
438    /// A quarter-circle (open) classifies as an arc through its endpoints.
439    #[test]
440    fn classify_open_arc() {
441        let (cx, cy, r) = (0.0, 0.0, 4.0);
442        let n = 16;
443        let uv: Vec<(f64, f64)> = (0..=n)
444            .map(|i| {
445                let t = i as f64 / n as f64 * (std::f64::consts::PI / 2.0);
446                (cx + r * t.cos(), cy + r * t.sin())
447            })
448            .collect();
449        match classify_uv(&uv) {
450            EdgeLink::Arc { center, start, end } => {
451                assert!((center.0 - cx).abs() < 1e-6 && (center.1 - cy).abs() < 1e-6);
452                assert!((start.0 - r).abs() < 1e-6 && start.1.abs() < 1e-6);
453                assert!(end.0.abs() < 1e-6 && (end.1 - r).abs() < 1e-6);
454            }
455            other => panic!("expected arc, got {other:?}"),
456        }
457    }
458
459    /// A wavy (non-circular, non-straight) polyline falls back to a line chain.
460    #[test]
461    fn classify_wavy_is_polyline_fallback() {
462        let uv: Vec<(f64, f64)> = (0..=8)
463            .map(|i| (i as f64, if i % 2 == 0 { 0.0 } else { 3.0 }))
464            .collect();
465        assert_eq!(classify_uv(&uv).kind(), "polyline");
466    }
467
468    /// Materializing a line link adds 2 external-ref points (each grounded) + a
469    /// construction line geometry.
470    #[test]
471    fn add_external_ref_line_marks_points_and_grounds() {
472        let mut doc = empty_doc();
473        let link = EdgeLink::Line { a: (1.0, 2.0), b: (7.0, 2.0) };
474        let (pids, gids) = add_external_ref(&mut doc, &link);
475        assert_eq!(pids.len(), 2);
476        assert_eq!(gids.len(), 1);
477        for id in &pids {
478            let p = doc.point(id).unwrap();
479            assert!(p.fixed && p.construction && p.external_reference, "point flags: {p:?}");
480            // Exactly one ⏚ ground referencing this point.
481            let grounds = doc
482                .constraints
483                .iter()
484                .filter(|c| c.ctype() == Some("⏚") && c.points().first().map(id_key) == Some(id_key(id)))
485                .count();
486            assert_eq!(grounds, 1, "point {id} should have one ground");
487        }
488        let g = doc.geometry(&gids[0]).unwrap();
489        assert_eq!(g.geom_type, "line");
490        assert!(g.construction(), "reference geometry must be construction");
491    }
492
493    /// Re-linking the SAME edge with the SAME projection does not duplicate; a
494    /// DIFFERENT projection updates the points in place; a DIFFERENT edge adds a ref.
495    #[test]
496    fn link_or_update_dedups_and_updates() {
497        let mut doc = empty_doc();
498        let mut refs: Vec<ExternalRef> = Vec::new();
499        let plane = PlaneFrame::xy();
500        let poly = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]];
501
502        // First link → a new ref (2 points + 1 geometry).
503        assert!(link_or_update(&mut doc, &mut refs, "edgeA", "Solid", &poly, &plane));
504        assert_eq!(refs.len(), 1);
505        assert_eq!(doc.points.len(), 2);
506        assert_eq!(doc.geometries.len(), 1);
507
508        // Re-link the SAME edge, SAME geometry → no change, no duplication.
509        assert!(!link_or_update(&mut doc, &mut refs, "edgeA", "Solid", &poly, &plane));
510        assert_eq!(refs.len(), 1);
511        assert_eq!(doc.points.len(), 2);
512        assert_eq!(doc.geometries.len(), 1);
513
514        // Re-link the SAME edge with a MOVED endpoint (a model change) → updates the
515        // existing points in place (still 2 points, 1 geometry), coords refreshed.
516        let moved = [[0.0, 0.0, 0.0], [10.0, 4.0, 0.0]];
517        assert!(link_or_update(&mut doc, &mut refs, "edgeA", "Solid", &moved, &plane));
518        assert_eq!(doc.points.len(), 2);
519        let p_end = doc.point(&refs[0].point_ids[1]).unwrap();
520        assert!((p_end.x - 10.0).abs() < 1e-9 && (p_end.y - 4.0).abs() < 1e-9);
521
522        // A DIFFERENT edge → a second ref.
523        let poly2 = [[0.0, 0.0, 0.0], [0.0, 8.0, 0.0]];
524        assert!(link_or_update(&mut doc, &mut refs, "edgeB", "Solid", &poly2, &plane));
525        assert_eq!(refs.len(), 2);
526        assert_eq!(doc.points.len(), 4);
527        assert_eq!(doc.geometries.len(), 2);
528    }
529
530    /// A structure change (line → circle for the same edge) rebuilds the ref rather
531    /// than leaving stale entities.
532    #[test]
533    fn link_or_update_rebuilds_on_structure_change() {
534        let mut doc = empty_doc();
535        let mut refs: Vec<ExternalRef> = Vec::new();
536        let plane = PlaneFrame::xy();
537
538        let line = [[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]];
539        link_or_update(&mut doc, &mut refs, "edgeA", "Solid", &line, &plane);
540        assert_eq!(refs[0].kind, "line");
541        assert_eq!(doc.points.len(), 2);
542
543        // Same edge now projects to a closed circle → the ref rebuilds as a circle.
544        let (cx, cy, r) = (0.0, 0.0, 5.0);
545        let n = 48;
546        let circle: Vec<[f64; 3]> = (0..=n)
547            .map(|i| {
548                let t = i as f64 / n as f64 * std::f64::consts::TAU;
549                [cx + r * t.cos(), cy + r * t.sin(), 0.0]
550            })
551            .collect();
552        link_or_update(&mut doc, &mut refs, "edgeA", "Solid", &circle, &plane);
553        assert_eq!(refs.len(), 1);
554        assert_eq!(refs[0].kind, "circle");
555        // Circle = center + rim = 2 points, 1 geometry; the old line's entities are gone.
556        assert_eq!(doc.points.len(), 2);
557        assert_eq!(doc.geometries.len(), 1);
558        assert_eq!(doc.geometry(&refs[0].geom_ids[0]).unwrap().geom_type, "circle");
559    }
560
561    /// Pruning drops a ref whose materialized entities were deleted (so the edge can
562    /// be re-linked) while KEEPING a ref whose entities still resolve.
563    #[test]
564    fn prune_dead_refs_drops_only_orphaned_entries() {
565        let mut doc = empty_doc();
566        let mut refs: Vec<ExternalRef> = Vec::new();
567        let plane = PlaneFrame::xy();
568
569        // Two independent linked edges.
570        link_or_update(&mut doc, &mut refs, "edgeA", "Solid", &[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]], &plane);
571        link_or_update(&mut doc, &mut refs, "edgeB", "Solid", &[[0.0, 0.0, 0.0], [0.0, 8.0, 0.0]], &plane);
572        assert_eq!(refs.len(), 2);
573
574        // Nothing deleted → prune is a no-op.
575        assert!(!prune_dead_refs(&doc, &mut refs));
576        assert_eq!(refs.len(), 2);
577
578        // Simulate deleting edgeA's materialized entities (what `sketch_delete_selection`
579        // does to the doc): remove its points + geometry.
580        remove_ref_entities(&mut doc, &refs[0].clone());
581        assert!(prune_dead_refs(&doc, &mut refs), "orphaned ref pruned");
582        assert_eq!(refs.len(), 1, "only edgeB's live ref survives");
583        assert_eq!(refs[0].edge_name, "edgeB");
584
585        // With edgeA's stale entry gone, re-linking the SAME edge succeeds (a fresh ref).
586        assert!(link_or_update(&mut doc, &mut refs, "edgeA", "Solid", &[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]], &plane));
587        assert_eq!(refs.len(), 2, "edgeA re-linked as a new ref");
588    }
589
590    /// `ExternalRef` round-trips through JSON (persistence shape).
591    #[test]
592    fn external_ref_json_round_trips() {
593        let r = ExternalRef {
594            edge_name: "e".into(),
595            solid_name: "s".into(),
596            point_ids: vec![json!(7), json!(8)],
597            geom_ids: vec![json!(20)],
598            kind: "line".into(),
599        };
600        let v = serde_json::to_value(&r).unwrap();
601        assert_eq!(v["edgeName"], "e");
602        assert_eq!(v["pointIds"], json!([7, 8]));
603        let back: ExternalRef = serde_json::from_value(v).unwrap();
604        assert_eq!(back, r);
605    }
606}