Skip to main content

brep_render/sketch/
external_ref.rs

1//! Project solid edges into a sketch as fixed construction references.
2//!
3//! Projected polylines become lines, circles, arcs, or line-chain fallbacks.
4//! Ground constraints pin the generated points. Edge names identify persisted
5//! references, so picking an edge again updates its coordinates in place.
6
7use crate::geometry2d::{distance as dist, point_segment_distance};
8
9use std::collections::HashSet;
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13
14use super::doc::{id_key, SketchConstraint, SketchDoc, SketchGeometry, SketchPoint};
15use super::PlaneFrame;
16
17/// The classification of a projected edge polyline, in plane `(u, v)` coordinates.
18#[derive(Clone, Debug, PartialEq)]
19pub enum EdgeLink {
20    /// A straight edge — link as a `line` through the two endpoints.
21    Line { a: (f64, f64), b: (f64, f64) },
22    /// A closed circular edge — link as a `circle` (center + a rim point).
23    Circle { center: (f64, f64), rim: (f64, f64) },
24    /// A circular ARC — link as an `arc` (center, start, end; CCW start→end).
25    Arc {
26        center: (f64, f64),
27        start: (f64, f64),
28        end: (f64, f64),
29    },
30    /// A faithful fallback for anything else — a chain of `line` segments through the
31    /// projected polyline samples.
32    Polyline { pts: Vec<(f64, f64)> },
33}
34
35impl EdgeLink {
36    /// The classified shape type; `polyline` materializes as a chain of lines.
37    pub fn kind(&self) -> &'static str {
38        match self {
39            EdgeLink::Line { .. } => "line",
40            EdgeLink::Circle { .. } => "circle",
41            EdgeLink::Arc { .. } => "arc",
42            EdgeLink::Polyline { .. } => "polyline",
43        }
44    }
45
46    /// The ordered `(u, v)` coordinates of the points this link materializes (its
47    /// geometry references them in order).
48    pub fn point_uvs(&self) -> Vec<(f64, f64)> {
49        match self {
50            EdgeLink::Line { a, b } => vec![*a, *b],
51            EdgeLink::Circle { center, rim } => vec![*center, *rim],
52            EdgeLink::Arc { center, start, end } => vec![*center, *start, *end],
53            EdgeLink::Polyline { pts } => pts.clone(),
54        }
55    }
56}
57
58/// Project a world-space polyline into the plane's `(u, v)` frame (orthogonal
59/// projection; the off-plane component is dropped). Mirrors the previous
60/// world→UV projection applied per vertex.
61pub fn project_polyline(plane: &PlaneFrame, world: &[[f64; 3]]) -> Vec<(f64, f64)> {
62    world.iter().map(|&w| plane.to_uv(w)).collect()
63}
64
65/// Classify a projected polyline (in plane `(u, v)`) as a straight line, a circle /
66/// arc, or a polyline fallback. Tolerances are RELATIVE to the polyline's extent so
67/// the same thresholds work at any sketch scale:
68///
69/// - **STRAIGHT** when every interior sample lies within `1e-4·extent` of the chord
70///   between the endpoints (a 2-sample polyline is trivially straight).
71/// - **CIRCULAR** when a circle fit through 3 well-spaced samples has a max radial
72///   residual under `1e-3·extent` (and a non-degenerate radius). Coincident
73///   endpoints → a closed `Circle`; else an `Arc`.
74/// - else the **Polyline** fallback.
75pub fn classify_uv(uv: &[(f64, f64)]) -> EdgeLink {
76    let n = uv.len();
77    if n < 2 {
78        // Degenerate — surface it as a (possibly zero-length) polyline; callers guard
79        // against < 2 samples before linking.
80        return EdgeLink::Polyline { pts: uv.to_vec() };
81    }
82    let a = uv[0];
83    let b = uv[n - 1];
84    let extent = polyline_extent(uv).max(1e-9);
85    let straight_tol = 1e-4 * extent;
86    let closed = dist(a, b) <= straight_tol;
87
88    // Two samples (or all-interior-on-chord and open) → a straight line.
89    if !closed {
90        let max_dev = uv[1..n - 1]
91            .iter()
92            .map(|&p| point_segment_distance(p, a, b).0)
93            .fold(0.0_f64, f64::max);
94        if n == 2 || max_dev <= straight_tol {
95            return EdgeLink::Line { a, b };
96        }
97    }
98
99    // Circle fit through three well-spaced samples. Sampling at 0 / n/3 / 2n/3 (NOT
100    // the last index) keeps the three distinct even for a CLOSED loop, where the
101    // first and last samples coincide.
102    if n >= 3 {
103        if let Some((cx, cy, r)) = fit_circle(uv[0], uv[n / 3], uv[(2 * n) / 3]) {
104            let circle_tol = 1e-3 * extent;
105            let residual = uv
106                .iter()
107                .map(|&p| (dist(p, (cx, cy)) - r).abs())
108                .fold(0.0_f64, f64::max);
109            if r.is_finite() && r > straight_tol && residual <= circle_tol {
110                if closed {
111                    return EdgeLink::Circle {
112                        center: (cx, cy),
113                        rim: (cx + r, cy),
114                    };
115                }
116                return EdgeLink::Arc {
117                    center: (cx, cy),
118                    start: a,
119                    end: b,
120                };
121            }
122        }
123    }
124
125    EdgeLink::Polyline { pts: uv.to_vec() }
126}
127
128/// A per-session external-reference mapping: the linked scene edge (by name + owning
129/// solid) and the sketch entities materialized for it. Persisted to / loaded from
130/// `persistentData.externalRefs` so a linked edge round-trips a commit + re-enter.
131#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
132pub struct ExternalRef {
133    /// Kernel edge name (the dedup key).
134    #[serde(rename = "edgeName")]
135    pub edge_name: String,
136    /// Owning solid's scene name (metadata; may be empty).
137    #[serde(rename = "solidName", default)]
138    pub solid_name: String,
139    /// The materialized point ids (order matches the link's geometry).
140    #[serde(rename = "pointIds", default)]
141    pub point_ids: Vec<Value>,
142    /// The materialized geometry ids (one for line/circle/arc; N-1 for a polyline
143    /// chain).
144    #[serde(rename = "geomIds", default)]
145    pub geom_ids: Vec<Value>,
146    /// The link classification (`"line"|"circle"|"arc"|"polyline"`).
147    #[serde(default)]
148    pub kind: String,
149}
150
151/// Link (or update) a picked scene edge into the sketch as an external reference.
152///
153/// Projects `world_poly` into `plane`, classifies it, and materializes external-ref
154/// points + `⏚` grounds + construction geometry, recording an [`ExternalRef`] in
155/// `refs`. Dedup: when `refs` already holds an entry for `edge_name` whose structure
156/// matches the new classification, the existing points are UPDATED in place (no
157/// duplication); when the structure differs, the old entities are removed and fresh
158/// ones created; otherwise a new ref is appended.
159///
160/// Returns whether the doc actually changed (a new/rebuilt ref, or moved coordinates)
161/// — `false` on a redundant re-link of an unchanged edge, so the caller can drop a
162/// dead undo step. Never solves; the caller re-solves.
163pub fn link_or_update(
164    doc: &mut SketchDoc,
165    refs: &mut Vec<ExternalRef>,
166    edge_name: &str,
167    solid_name: &str,
168    world_poly: &[[f64; 3]],
169    plane: &PlaneFrame,
170) -> bool {
171    if world_poly.len() < 2 {
172        return false;
173    }
174    let uv = project_polyline(plane, world_poly);
175    let link = classify_uv(&uv);
176    let new_uvs = link.point_uvs();
177
178    if let Some(pos) = refs.iter().position(|r| r.edge_name == edge_name) {
179        let structure_matches =
180            refs[pos].kind == link.kind() && refs[pos].point_ids.len() == new_uvs.len();
181        if structure_matches {
182            // Update the existing points' coordinates in place (keeps ids + geometry).
183            let mut moved = false;
184            let point_ids = refs[pos].point_ids.clone();
185            for (id, (u, v)) in point_ids.iter().zip(new_uvs.iter()) {
186                if let Some(p) = doc.point_mut(id) {
187                    if (p.x - u).abs() > 1e-12 || (p.y - v).abs() > 1e-12 {
188                        moved = true;
189                    }
190                    p.x = *u;
191                    p.y = *v;
192                    p.fixed = true;
193                    p.construction = true;
194                    p.external_reference = true;
195                }
196            }
197            if refs[pos].solid_name != solid_name {
198                refs[pos].solid_name = solid_name.to_string();
199            }
200            return moved;
201        }
202        // Structure changed (e.g. a straight edge became curved after a model edit) —
203        // drop the stale entities and rebuild the ref fresh.
204        remove_ref_entities(doc, &refs[pos].clone());
205        let (point_ids, geom_ids) = add_external_ref(doc, &link);
206        refs[pos] = ExternalRef {
207            edge_name: edge_name.to_string(),
208            solid_name: solid_name.to_string(),
209            point_ids,
210            geom_ids,
211            kind: link.kind().to_string(),
212        };
213        return true;
214    }
215
216    // A brand-new reference for this edge.
217    let (point_ids, geom_ids) = add_external_ref(doc, &link);
218    refs.push(ExternalRef {
219        edge_name: edge_name.to_string(),
220        solid_name: solid_name.to_string(),
221        point_ids,
222        geom_ids,
223        kind: link.kind().to_string(),
224    });
225    true
226}
227
228/// Materialize an [`EdgeLink`] into the doc: push external-ref points (`fixed`,
229/// `construction`, `externalReference`) with a `⏚` ground each, then the construction
230/// geometry referencing them. Returns `(point_ids, geom_ids)`.
231pub fn add_external_ref(doc: &mut SketchDoc, link: &EdgeLink) -> (Vec<Value>, Vec<Value>) {
232    let mut point_ids = Vec::new();
233    for (u, v) in link.point_uvs() {
234        let id = doc.next_point_id();
235        doc.points.push(SketchPoint {
236            id: id.clone(),
237            x: u,
238            y: v,
239            fixed: true,
240            construction: true,
241            external_reference: true,
242        });
243        push_ground(doc, &id);
244        point_ids.push(id);
245    }
246    let geom_ids = match link {
247        EdgeLink::Line { .. } => vec![push_construction_geometry(
248            doc,
249            "line",
250            vec![point_ids[0].clone(), point_ids[1].clone()],
251        )],
252        EdgeLink::Circle { .. } => vec![push_construction_geometry(
253            doc,
254            "circle",
255            vec![point_ids[0].clone(), point_ids[1].clone()],
256        )],
257        EdgeLink::Arc { .. } => vec![push_construction_geometry(
258            doc,
259            "arc",
260            vec![
261                point_ids[0].clone(),
262                point_ids[1].clone(),
263                point_ids[2].clone(),
264            ],
265        )],
266        EdgeLink::Polyline { .. } => point_ids
267            .windows(2)
268            .map(|w| push_construction_geometry(doc, "line", vec![w[0].clone(), w[1].clone()]))
269            .collect(),
270    };
271    (point_ids, geom_ids)
272}
273
274/// Remove every entity an [`ExternalRef`] materialized: its geometries, its points,
275/// and any constraint referencing one of its points (the `⏚` grounds).
276fn remove_ref_entities(doc: &mut SketchDoc, r: &ExternalRef) {
277    let pt_keys: HashSet<String> = r.point_ids.iter().map(id_key).collect();
278    let geo_keys: HashSet<String> = r.geom_ids.iter().map(id_key).collect();
279    doc.geometries.retain(|g| !geo_keys.contains(&id_key(&g.id)));
280    doc.points.retain(|p| !pt_keys.contains(&id_key(&p.id)));
281    doc.constraints
282        .retain(|c| !c.points().iter().any(|p| pt_keys.contains(&id_key(p))));
283}
284
285/// Drop every external-reference entry whose materialized entities no longer resolve
286/// in `doc` — i.e. the linked edge's points / geometry were deleted. A dropped entry
287/// frees its `edge_name` so the SAME edge can be RE-LINKED: without this, the stale
288/// entry (keyed by `edge_name`, holding now-dangling `point_ids`) makes
289/// [`link_or_update`]'s dedup take the "structure matches" branch, find every point
290/// missing (`doc.point_mut` → `None`), and return `false` — permanently blocking the
291/// re-link. Called both after a delete AND on sketch-enter (to heal docs already
292/// poisoned by that pre-fix delete). Returns whether any entry was pruned.
293pub fn prune_dead_refs(doc: &SketchDoc, refs: &mut Vec<ExternalRef>) -> bool {
294    let before = refs.len();
295    refs.retain(|r| {
296        r.point_ids.iter().all(|id| doc.point(id).is_some())
297            && r.geom_ids.iter().all(|id| doc.geometry(id).is_some())
298    });
299    refs.len() != before
300}
301
302/// Push a `⏚` GROUND constraint pinning point `pid` (mirrors the S4 toggle-ground
303/// factory).
304fn push_ground(doc: &mut SketchDoc, pid: &Value) {
305    let cid = doc.next_constraint_id();
306    let mut raw = Map::new();
307    raw.insert("id".to_string(), cid);
308    raw.insert("type".to_string(), Value::String("⏚".to_string()));
309    raw.insert("points".to_string(), Value::Array(vec![pid.clone()]));
310    doc.constraints.push(SketchConstraint { raw });
311}
312
313/// Push a construction geometry (`construction: true` — dashed, non-modeling) with a
314/// freshly minted id, returning that id.
315fn push_construction_geometry(doc: &mut SketchDoc, geom_type: &str, points: Vec<Value>) -> Value {
316    let id = doc.next_geometry_id();
317    let mut extra = Map::new();
318    extra.insert("construction".to_string(), Value::Bool(true));
319    doc.geometries.push(SketchGeometry {
320        id: id.clone(),
321        geom_type: geom_type.to_string(),
322        points,
323        extra,
324    });
325    id
326}
327
328// --- geometry helpers ---------------------------------------------------------
329
330/// The bounding-box diagonal of a `(u, v)` polyline — the relative-tolerance scale.
331fn polyline_extent(uv: &[(f64, f64)]) -> f64 {
332    let (mut minx, mut miny) = (f64::INFINITY, f64::INFINITY);
333    let (mut maxx, mut maxy) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
334    for &(x, y) in uv {
335        minx = minx.min(x);
336        miny = miny.min(y);
337        maxx = maxx.max(x);
338        maxy = maxy.max(y);
339    }
340    ((maxx - minx).powi(2) + (maxy - miny).powi(2)).sqrt()
341}
342
343/// Fit a circle through three points (circumcenter + radius), or `None` when the
344/// points are (near) collinear.
345fn fit_circle(p1: (f64, f64), p2: (f64, f64), p3: (f64, f64)) -> Option<(f64, f64, f64)> {
346    let (ax, ay) = p1;
347    let (bx, by) = p2;
348    let (cx, cy) = p3;
349    // 2·(signed area of the triangle) — zero when collinear.
350    let d = 2.0 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
351    if d.abs() < 1e-12 {
352        return None;
353    }
354    let a2 = ax * ax + ay * ay;
355    let b2 = bx * bx + by * by;
356    let c2 = cx * cx + cy * cy;
357    let ux = (a2 * (by - cy) + b2 * (cy - ay) + c2 * (ay - by)) / d;
358    let uy = (a2 * (cx - bx) + b2 * (ax - cx) + c2 * (bx - ax)) / d;
359    let r = dist((ux, uy), p1);
360    if !ux.is_finite() || !uy.is_finite() || !r.is_finite() {
361        return None;
362    }
363    Some((ux, uy, r))
364}
365
366// BREP private tests: 0e3facafc62ff77f