Skip to main content

brep_render/sketch/
trim.rs

1//! Trim sketch geometry between sampled-polyline intersections around the click.
2//!
3//! Samples carry curve parameters for choosing trim bounds. Lines split into
4//! segments, circles become arcs, arcs shorten, and Béziers keep surviving
5//! control-point ranges. Without bracketing intersections, the geometry is deleted.
6//!
7//! Cut points are pinned onto the crossing line or arc/circle, using coincidence
8//! near line endpoints. A surviving arc carries its radius through its endpoints;
9//! the trimmed circle's old radius point need not survive. Unreferenced points
10//! are removed after each trim by [`cleanup_orphan_points`].
11
12use std::cmp::Ordering;
13use std::collections::HashSet;
14use std::f64::consts::PI;
15
16use serde_json::{Map, Value};
17
18use super::doc::{id_key, SketchConstraint, SketchDoc, SketchGeometry, SketchPoint};
19
20/// Circle sampling resolution for trim (the previous 96-gon — finer
21/// than the S2 overlay's 64-gon so intersection params land accurately).
22const CIRCLE_SAMPLES: usize = 96;
23/// Bezier per-span sampling resolution (the previous 24/span).
24const BEZIER_SEG_SAMPLES: usize = 24;
25/// Snap radius when reusing an existing point for a cut vertex (`#findExistingPointId`).
26const SNAP_EPS: f64 = 1e-6;
27
28// ---------------------------------------------------------------------------
29// Sampling — geometry → (x, y, param) polyline (a port of `#sampleGeometry`).
30// ---------------------------------------------------------------------------
31
32/// One polyline sample: an in-plane `(x, y)` point plus its curve parameter (the
33/// value `select_trim_bounds` brackets the click against).
34#[derive(Clone, Copy, Debug)]
35struct Sample {
36    x: f64,
37    y: f64,
38    param: f64,
39}
40
41/// A sampled geometry: its polyline plus the metadata the bracket logic needs —
42/// `closed` (a full circle / full arc wraps), `max_param` (1 for line/circle/arc,
43/// `seg_count` for a bezier), and `seg_count` (bezier spans).
44struct Sampled {
45    closed: bool,
46    max_param: f64,
47    seg_count: usize,
48    samples: Vec<Sample>,
49}
50
51/// Sample one geometry into a `(x, y, param)` polyline, or `None` if a referenced
52/// point is missing or the geometry is degenerate (a faithful port of
53/// `#sampleGeometry`).
54fn sample_geometry(geo: &SketchGeometry, doc: &SketchDoc) -> Option<Sampled> {
55    let ids = &geo.points;
56    match geo.geom_type.as_str() {
57        "line" if ids.len() >= 2 => {
58            let p0 = doc.point(&ids[0])?;
59            let p1 = doc.point(&ids[1])?;
60            Some(Sampled {
61                closed: false,
62                max_param: 1.0,
63                seg_count: 1,
64                samples: vec![
65                    Sample { x: p0.x, y: p0.y, param: 0.0 },
66                    Sample { x: p1.x, y: p1.y, param: 1.0 },
67                ],
68            })
69        }
70        "circle" if ids.len() >= 2 => {
71            let pc = doc.point(&ids[0])?;
72            let pr = doc.point(&ids[1])?;
73            let r = (pr.x - pc.x).hypot(pr.y - pc.y);
74            if !r.is_finite() || r < 1e-9 {
75                return None;
76            }
77            let mut samples = Vec::with_capacity(CIRCLE_SAMPLES + 1);
78            for i in 0..=CIRCLE_SAMPLES {
79                let t = i as f64 / CIRCLE_SAMPLES as f64;
80                let a = t * 2.0 * PI;
81                samples.push(Sample {
82                    x: pc.x + r * a.cos(),
83                    y: pc.y + r * a.sin(),
84                    param: t,
85                });
86            }
87            Some(Sampled { closed: true, max_param: 1.0, seg_count: 1, samples })
88        }
89        "arc" if ids.len() >= 3 => {
90            let pc = doc.point(&ids[0])?;
91            let pa = doc.point(&ids[1])?;
92            let pb = doc.point(&ids[2])?;
93            let r = (pa.x - pc.x).hypot(pa.y - pc.y);
94            if !r.is_finite() || r < 1e-9 {
95                return None;
96            }
97            let a0 = (pa.y - pc.y).atan2(pa.x - pc.x);
98            let a1 = (pb.y - pc.y).atan2(pb.x - pc.x);
99            let mut d = (a1 - a0).rem_euclid(2.0 * PI);
100            let full = d < 1e-6;
101            if full {
102                d = 2.0 * PI;
103            }
104            let segs = ((CIRCLE_SAMPLES as f64 * d / (2.0 * PI)).ceil() as usize).max(8);
105            let mut samples = Vec::with_capacity(segs + 1);
106            for i in 0..=segs {
107                let t = i as f64 / segs as f64;
108                let a = a0 + d * t;
109                samples.push(Sample {
110                    x: pc.x + r * a.cos(),
111                    y: pc.y + r * a.sin(),
112                    param: t,
113                });
114            }
115            Some(Sampled { closed: full, max_param: 1.0, seg_count: 1, samples })
116        }
117        "bezier" if ids.len() >= 4 => {
118            let seg_count = (ids.len() - 1) / 3;
119            if seg_count < 1 {
120                return None;
121            }
122            let mut samples = Vec::new();
123            for seg in 0..seg_count {
124                let i0 = seg * 3;
125                let p0 = doc.point(&ids[i0])?;
126                let p1 = doc.point(&ids[i0 + 1])?;
127                let p2 = doc.point(&ids[i0 + 2])?;
128                let p3 = doc.point(&ids[i0 + 3])?;
129                for i in 0..=BEZIER_SEG_SAMPLES {
130                    if seg > 0 && i == 0 {
131                        continue; // shared knot already emitted
132                    }
133                    let t = i as f64 / BEZIER_SEG_SAMPLES as f64;
134                    let mt = 1.0 - t;
135                    let bx = mt * mt * mt * p0.x
136                        + 3.0 * mt * mt * t * p1.x
137                        + 3.0 * mt * t * t * p2.x
138                        + t * t * t * p3.x;
139                    let by = mt * mt * mt * p0.y
140                        + 3.0 * mt * mt * t * p1.y
141                        + 3.0 * mt * t * t * p2.y
142                        + t * t * t * p3.y;
143                    samples.push(Sample { x: bx, y: by, param: seg as f64 + t });
144                }
145            }
146            Some(Sampled {
147                closed: false,
148                max_param: seg_count as f64,
149                seg_count,
150                samples,
151            })
152        }
153        _ => None,
154    }
155}
156
157/// The curve param of the point on `samples` nearest to `(px, py)` (a port of
158/// `#closestParamOnSamples`/`#closestPointOnSamples`), or `None` for < 2 samples.
159fn closest_param_on_samples(px: f64, py: f64, samples: &[Sample]) -> Option<f64> {
160    closest_point_on_samples(px, py, samples).map(|(param, _)| param)
161}
162
163/// The `(param, dist)` of the closest point on `samples` to `(px, py)`.
164fn closest_point_on_samples(px: f64, py: f64, samples: &[Sample]) -> Option<(f64, f64)> {
165    if samples.len() < 2 {
166        return None;
167    }
168    let mut best_param = samples[0].param;
169    let mut best_dist = f64::INFINITY;
170    for w in samples.windows(2) {
171        let (a, b) = (w[0], w[1]);
172        let vx = b.x - a.x;
173        let vy = b.y - a.y;
174        let l2 = (vx * vx + vy * vy).max(1e-12);
175        let t = (((px - a.x) * vx + (py - a.y) * vy) / l2).clamp(0.0, 1.0);
176        let nx = a.x + vx * t;
177        let ny = a.y + vy * t;
178        let d = (px - nx).hypot(py - ny);
179        if d < best_dist {
180            best_dist = d;
181            best_param = a.param + (b.param - a.param) * t;
182        }
183    }
184    Some((best_param, best_dist))
185}
186
187/// The bounding-box-diagonal-derived tolerance for an endpoint touch (a port of
188/// `#sampleTol`): `clamp(diag * 1e-3, 1e-5, 1e-2)`.
189fn sample_tol(samples: &[Sample]) -> f64 {
190    if samples.is_empty() {
191        return 1e-3;
192    }
193    let (mut min_x, mut min_y, mut max_x, mut max_y) =
194        (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
195    for s in samples {
196        min_x = min_x.min(s.x);
197        min_y = min_y.min(s.y);
198        max_x = max_x.max(s.x);
199        max_y = max_y.max(s.y);
200    }
201    let diag = (max_x - min_x).hypot(max_y - min_y);
202    if !diag.is_finite() || diag < 1e-9 {
203        return 1e-3;
204    }
205    (diag * 1e-3).clamp(1e-5, 1e-2)
206}
207
208// ---------------------------------------------------------------------------
209// Segment-segment intersection + intersection collection.
210// ---------------------------------------------------------------------------
211
212/// A segment-segment crossing: the world point plus the clamped parameters on each
213/// segment (`ta` on the target segment, `tb` on the other).
214#[derive(Clone, Copy, Debug)]
215struct SegHit {
216    x: f64,
217    y: f64,
218    ta: f64,
219    tb: f64,
220}
221
222/// Intersect segments `a→b` and `c→d` (a port of `#segmentIntersection`). Two
223/// segments `(a, a+r)` and `(c, c+s)` cross where `t = ((c−a)×s)/(r×s)` and
224/// `u = ((c−a)×r)/(r×s)` both lie in `[0, 1]`; `None` when `r×s ≈ 0` (parallel /
225/// collinear) or the hit is off either segment.
226fn segment_intersection(a: [f64; 2], b: [f64; 2], c: [f64; 2], d: [f64; 2], eps: f64) -> Option<SegHit> {
227    let rdx = b[0] - a[0];
228    let rdy = b[1] - a[1];
229    let sdx = d[0] - c[0];
230    let sdy = d[1] - c[1];
231    let denom = rdx * sdy - rdy * sdx;
232    if denom.abs() < eps {
233        return None;
234    }
235    let t = ((c[0] - a[0]) * sdy - (c[1] - a[1]) * sdx) / denom;
236    let u = ((c[0] - a[0]) * rdy - (c[1] - a[1]) * rdx) / denom;
237    if t < -eps || t > 1.0 + eps || u < -eps || u > 1.0 + eps {
238        return None;
239    }
240    let tt = t.clamp(0.0, 1.0);
241    Some(SegHit {
242        x: a[0] + rdx * tt,
243        y: a[1] + rdy * tt,
244        ta: tt,
245        tb: u.clamp(0.0, 1.0),
246    })
247}
248
249/// One intersection of the target curve with another geometry: the target param,
250/// the world point, the other curve's param (for endpoint proximity), the other
251/// geometry's id (for the cut constraint), and whether it was an endpoint touch.
252#[derive(Clone, Debug)]
253struct Intersection {
254    param: f64,
255    x: f64,
256    y: f64,
257    other_param: Option<f64>,
258    other_geo_id: Value,
259    endpoint_snap: bool,
260}
261
262/// Collect crossings of `target` with `other` (a port of `#collectIntersections`):
263/// every target-segment × other-segment crossing, PLUS the other curve's endpoints
264/// (its first/last sample, when it is not closed) that touch the target within
265/// tolerance (`#collectEndpointIntersectionsOnTarget`).
266fn collect_intersections(
267    target: &Sampled,
268    other: &Sampled,
269    other_geo: &SketchGeometry,
270    out: &mut Vec<Intersection>,
271) {
272    let a = &target.samples;
273    let b = &other.samples;
274    if a.len() < 2 || b.len() < 2 {
275        return;
276    }
277    for i in 0..a.len() - 1 {
278        let (a0, a1) = (a[i], a[i + 1]);
279        for j in 0..b.len() - 1 {
280            let (b0, b1) = (b[j], b[j + 1]);
281            if let Some(hit) =
282                segment_intersection([a0.x, a0.y], [a1.x, a1.y], [b0.x, b0.y], [b1.x, b1.y], 1e-9)
283            {
284                out.push(Intersection {
285                    param: a0.param + (a1.param - a0.param) * hit.ta,
286                    x: hit.x,
287                    y: hit.y,
288                    other_param: Some(b0.param + (b1.param - b0.param) * hit.tb),
289                    other_geo_id: other_geo.id.clone(),
290                    endpoint_snap: false,
291                });
292            }
293        }
294    }
295    // Endpoint touches: a non-closed curve's first/last sample landing on the target
296    // (a T-junction the segment crossing above may miss). Circles/full arcs have no
297    // endpoints (they are closed), matching `#getGeometryEndpointInfos`.
298    if !other.closed {
299        let tol = sample_tol(&target.samples);
300        for end in [b.first(), b.last()].into_iter().flatten() {
301            if let Some((param, dist)) = closest_point_on_samples(end.x, end.y, &target.samples) {
302                if param.is_finite() && dist.is_finite() && dist <= tol {
303                    out.push(Intersection {
304                        param,
305                        x: end.x,
306                        y: end.y,
307                        other_param: Some(end.param),
308                        other_geo_id: other_geo.id.clone(),
309                        endpoint_snap: true,
310                    });
311                }
312            }
313        }
314    }
315}
316
317// ---------------------------------------------------------------------------
318// Bracket selection — the two intersections around the click (`#selectTrimBounds`).
319// ---------------------------------------------------------------------------
320
321/// The two intersections bracketing the click, plus the target's `max_param` (used
322/// by the circle-span check). `prev`/`next` are `None` when the click is before the
323/// first / after the last intersection on an open curve.
324struct Bounds {
325    prev: Option<Intersection>,
326    next: Option<Intersection>,
327    max_param: f64,
328}
329
330/// Choose the two intersections bracketing `click_param` (a port of
331/// `#selectTrimBounds`): normalize + sort + dedup the intersection params (a
332/// closed curve wraps mod `max_param`, an open one drops the endpoints), then for a
333/// closed curve take the nearest crossing ahead (`next`) and behind (`prev`) the
334/// click going CCW; for an open curve take the last crossing before and first after
335/// the click. `None` when nothing brackets the click.
336fn select_trim_bounds(intersections: &[Intersection], click_param: f64, target: &Sampled) -> Option<Bounds> {
337    let max_param = if target.max_param > 0.0 { target.max_param } else { 1.0 };
338    let param_eps = (1e-5f64).max(max_param * 1e-4);
339
340    let mut cleaned: Vec<Intersection> = Vec::new();
341    for inter in intersections {
342        if !inter.param.is_finite() {
343            continue;
344        }
345        let mut p = inter.param;
346        if target.closed {
347            p = p.rem_euclid(max_param);
348            if p < param_eps || p > max_param - param_eps {
349                p = 0.0;
350            }
351        } else if p <= param_eps || p >= max_param - param_eps {
352            continue;
353        }
354        let mut c = inter.clone();
355        c.param = p;
356        cleaned.push(c);
357    }
358    cleaned.sort_by(|a, b| a.param.partial_cmp(&b.param).unwrap_or(Ordering::Equal));
359
360    // Dedup near-equal params; a genuine endpoint touch supersedes a coincident
361    // segment crossing at the same spot.
362    let mut uniq: Vec<Intersection> = Vec::new();
363    for inter in cleaned {
364        match uniq.last() {
365            Some(prev) if (inter.param - prev.param).abs() <= param_eps => {
366                if !prev.endpoint_snap && inter.endpoint_snap {
367                    *uniq.last_mut().unwrap() = inter;
368                }
369            }
370            _ => uniq.push(inter),
371        }
372    }
373
374    if target.closed {
375        if uniq.len() < 2 {
376            return None;
377        }
378        let mut prev = None;
379        let mut next = None;
380        let mut best_next = f64::INFINITY;
381        let mut best_prev = f64::NEG_INFINITY;
382        for inter in &uniq {
383            let delta = (inter.param - click_param).rem_euclid(max_param);
384            if delta < param_eps {
385                continue;
386            }
387            if delta < best_next {
388                best_next = delta;
389                next = Some(inter.clone());
390            }
391            if delta > best_prev {
392                best_prev = delta;
393                prev = Some(inter.clone());
394            }
395        }
396        match (prev, next) {
397            (Some(prev), Some(next)) => Some(Bounds { prev: Some(prev), next: Some(next), max_param }),
398            _ => None,
399        }
400    } else {
401        if uniq.is_empty() {
402            return None;
403        }
404        let mut prev = None;
405        let mut next = None;
406        for inter in &uniq {
407            if inter.param < click_param - param_eps {
408                prev = Some(inter.clone());
409            } else if inter.param > click_param + param_eps {
410                next = Some(inter.clone());
411                break;
412            }
413        }
414        if prev.is_none() && next.is_none() {
415            return None;
416        }
417        Some(Bounds { prev, next, max_param })
418    }
419}
420
421// ---------------------------------------------------------------------------
422// Doc mutation helpers — point/geometry factory + orphan sweep.
423// ---------------------------------------------------------------------------
424
425/// Reuse an existing point within `SNAP_EPS` of `(x, y)`, else mint a fresh one
426/// (`#getOrCreatePointId`).
427fn get_or_create_point(doc: &mut SketchDoc, x: f64, y: f64) -> Value {
428    doc.snap_or_add_point(x, y, SNAP_EPS)
429}
430
431/// Always mint a fresh point at `(x, y)` (`#createPointAtUV`) — the bezier split
432/// anchors, which must not collapse onto a neighbor.
433fn create_point(doc: &mut SketchDoc, x: f64, y: f64) -> Value {
434    let id = doc.next_point_id();
435    doc.points.push(SketchPoint {
436        id: id.clone(),
437        x,
438        y,
439        fixed: false,
440        construction: false,
441        external_reference: false,
442    });
443    id
444}
445
446/// Append a geometry with a fresh id, inheriting `construction` from the trimmed
447/// source (`#addGeometry`). Returns the new id.
448fn add_geometry(doc: &mut SketchDoc, geom_type: &str, points: Vec<Value>, construction: bool) -> Value {
449    let id = doc.next_geometry_id();
450    let mut extra = Map::new();
451    extra.insert("construction".to_string(), Value::Bool(construction));
452    doc.geometries.push(SketchGeometry {
453        id: id.clone(),
454        geom_type: geom_type.to_string(),
455        points,
456        extra,
457    });
458    id
459}
460
461/// Remove the geometry with `id`; returns whether one was removed.
462fn remove_geometry(doc: &mut SketchDoc, id: &Value) -> bool {
463    let key = id_key(id);
464    let before = doc.geometries.len();
465    doc.geometries.retain(|g| id_key(&g.id) != key);
466    doc.geometries.len() != before
467}
468
469/// Drop points referenced by neither a surviving geometry nor a surviving
470/// constraint (so a trimmed-away stub leaves no dangling point/constraint). Points
471/// still named by a constraint (e.g. a crossing arc's rim pinned by `⇌`) are kept.
472fn cleanup_orphan_points(doc: &mut SketchDoc) {
473    let mut referenced: HashSet<String> = HashSet::new();
474    for g in &doc.geometries {
475        for pid in &g.points {
476            referenced.insert(id_key(pid));
477        }
478    }
479    for c in &doc.constraints {
480        for pid in c.points() {
481            referenced.insert(id_key(pid));
482        }
483    }
484    doc.points.retain(|p| referenced.contains(&id_key(&p.id)));
485}
486
487// ---------------------------------------------------------------------------
488// Cut constraints — pin each cut point onto the crossing geometry.
489// ---------------------------------------------------------------------------
490
491/// Whether a constraint already equals `(ctype, points)` under the previous
492/// order-aware matching — the trim-add dedup.
493fn constraint_matches(c: &SketchConstraint, ctype: &str, points: &[Value]) -> bool {
494    if c.ctype() != Some(ctype) {
495        return false;
496    }
497    let cp = c.points();
498    let k = |v: &Value| id_key(v);
499    match ctype {
500        "≡" => {
501            if points.len() < 2 || cp.len() < 2 {
502                return false;
503            }
504            let (a, b) = (k(&points[0]), k(&points[1]));
505            let (p0, p1) = (k(&cp[0]), k(&cp[1]));
506            (p0 == a && p1 == b) || (p0 == b && p1 == a)
507        }
508        "⏛" => {
509            if points.len() < 3 || cp.len() < 3 {
510                return false;
511            }
512            let (a, b, p) = (k(&points[0]), k(&points[1]), k(&points[2]));
513            let (p0, p1, p2) = (k(&cp[0]), k(&cp[1]), k(&cp[2]));
514            ((p0 == a && p1 == b) || (p0 == b && p1 == a)) && p2 == p
515        }
516        "⇌" => {
517            if points.len() < 4 || cp.len() < 4 {
518                return false;
519            }
520            let (a, b, c0, d) = (k(&points[0]), k(&points[1]), k(&points[2]), k(&points[3]));
521            let (p0, p1, p2, p3) = (k(&cp[0]), k(&cp[1]), k(&cp[2]), k(&cp[3]));
522            let same = |x0: &str, x1: &str, y0: &str, y1: &str| {
523                (x0 == y0 && x1 == y1) || (x0 == y1 && x1 == y0)
524            };
525            (same(&p0, &p1, &a, &b) && same(&p2, &p3, &c0, &d))
526                || (same(&p0, &p1, &c0, &d) && same(&p2, &p3, &a, &b))
527        }
528        _ => false,
529    }
530}
531
532/// Append a constraint of `(ctype, points)` unless an equal one exists (a port of
533/// `#addConstraintIfMissing`), carrying the base fields so it round-trips + the
534/// solver seeds any value. Returns whether one was added. Shared with
535/// [`super::infer`] (drop-time constraint inference).
536pub(crate) fn add_constraint_if_missing(doc: &mut SketchDoc, ctype: &str, points: Vec<Value>) -> bool {
537    if doc.constraints.iter().any(|c| constraint_matches(c, ctype, &points)) {
538        return false;
539    }
540    let id = doc.next_constraint_id();
541    let mut raw = Map::new();
542    raw.insert("id".to_string(), id);
543    raw.insert("type".to_string(), Value::String(ctype.to_string()));
544    raw.insert("points".to_string(), Value::Array(points));
545    raw.insert("labelX".to_string(), Value::from(0));
546    raw.insert("labelY".to_string(), Value::from(0));
547    raw.insert("displayStyle".to_string(), Value::String(String::new()));
548    raw.insert("value".to_string(), Value::Null);
549    raw.insert("valueNeedsSetup".to_string(), Value::Bool(true));
550    doc.constraints.push(SketchConstraint { raw });
551    true
552}
553
554/// Pin `point_id` onto a crossing LINE (`#ensurePointOnLineConstraint`): a
555/// coincident `≡` when the cut lands on the line's endpoint, else a point-on-line
556/// `⏛`.
557fn ensure_point_on_line(
558    doc: &mut SketchDoc,
559    line_geo: &SketchGeometry,
560    point_id: &Value,
561    inter: &Intersection,
562) -> bool {
563    let ids = &line_geo.points;
564    if ids.len() < 2 {
565        return false;
566    }
567    let a_id = ids[0].clone();
568    let b_id = ids[1].clone();
569    let (a, b, p) = match (doc.point(&a_id), doc.point(&b_id), doc.point(point_id)) {
570        (Some(a), Some(b), Some(p)) => ((a.x, a.y), (b.x, b.y), (p.x, p.y)),
571        _ => return false,
572    };
573    let len = {
574        let l = (b.0 - a.0).hypot(b.1 - a.1);
575        if l == 0.0 {
576            1.0
577        } else {
578            l
579        }
580    };
581    let eps_param = 1e-3;
582    let eps_dist = (len * 1e-3).clamp(1e-5, 1e-2);
583    let near_a = inter.other_param.map_or(false, |op| op <= eps_param)
584        || (p.0 - a.0).hypot(p.1 - a.1) <= eps_dist;
585    let near_b = inter.other_param.map_or(false, |op| op >= 1.0 - eps_param)
586        || (p.0 - b.0).hypot(p.1 - b.1) <= eps_dist;
587
588    if near_a && id_key(point_id) != id_key(&a_id) {
589        return add_constraint_if_missing(doc, "≡", vec![a_id, point_id.clone()]);
590    }
591    if near_b && id_key(point_id) != id_key(&b_id) {
592        return add_constraint_if_missing(doc, "≡", vec![b_id, point_id.clone()]);
593    }
594    if id_key(point_id) == id_key(&a_id) || id_key(point_id) == id_key(&b_id) {
595        return false;
596    }
597    add_constraint_if_missing(doc, "⏛", vec![a_id, b_id, point_id.clone()])
598}
599
600/// Pin `point_id` onto a crossing ARC/CIRCLE (`#ensurePointOnArcConstraint`): a
601/// point-on-arc `⇌ [center, rim, center, point]` (equal-distance to the rim).
602fn ensure_point_on_arc(doc: &mut SketchDoc, arc_geo: &SketchGeometry, point_id: &Value) -> bool {
603    let ids = &arc_geo.points;
604    if ids.len() < 2 {
605        return false;
606    }
607    let center = ids[0].clone();
608    let rim = ids[1].clone();
609    // Never pin one of the arc's OWN defining points onto it. Centre and rim are
610    // degenerate; the far endpoint (`ids[2]`) would restate the arc's implied
611    // equal-radius row verbatim, and the solver counts that implied row, so the
612    // duplicate would read as a redundant constraint the user never authored.
613    if ids.iter().any(|id| id_key(id) == id_key(point_id)) {
614        return false;
615    }
616    add_constraint_if_missing(doc, "⇌", vec![center.clone(), rim, center, point_id.clone()])
617}
618
619/// Pin a cut `point_id` onto the CROSSING geometry named by `inter` (a port of
620/// `#applyTrimIntersectionConstraint`): point-on-line for a line crosser,
621/// point-on-arc for an arc/circle crosser.
622fn apply_trim_intersection_constraint(doc: &mut SketchDoc, point_id: &Value, inter: &Intersection) {
623    let other = match doc.geometry(&inter.other_geo_id) {
624        Some(g) => g.clone(),
625        None => return,
626    };
627    match other.geom_type.as_str() {
628        "line" => {
629            ensure_point_on_line(doc, &other, point_id, inter);
630        }
631        "arc" | "circle" => {
632            ensure_point_on_arc(doc, &other, point_id);
633        }
634        _ => {}
635    }
636}
637
638// ---------------------------------------------------------------------------
639// Per-type split (`#trimLineGeometry` / `#trimCircleGeometry` / `#trimArcGeometry`
640// / `#trimBezierGeometry`).
641// ---------------------------------------------------------------------------
642
643/// Add a line `a→b` unless degenerate (same id / coincident). Returns whether added.
644fn add_line_if_valid(doc: &mut SketchDoc, a_id: &Value, b_id: &Value, construction: bool) -> bool {
645    if id_key(a_id) == id_key(b_id) {
646        return false;
647    }
648    let ok = match (doc.point(a_id), doc.point(b_id)) {
649        (Some(a), Some(b)) => (a.x - b.x).hypot(a.y - b.y) >= 1e-7,
650        _ => false,
651    };
652    if !ok {
653        return false;
654    }
655    add_geometry(doc, "line", vec![a_id.clone(), b_id.clone()], construction);
656    true
657}
658
659/// Add an arc `[center, start, end]` unless degenerate. Returns whether added.
660fn add_arc_if_valid(
661    doc: &mut SketchDoc,
662    center: &Value,
663    start: &Value,
664    end: &Value,
665    construction: bool,
666) -> bool {
667    if id_key(start) == id_key(end) {
668        return false;
669    }
670    let ok = match (doc.point(start), doc.point(end)) {
671        (Some(a), Some(b)) => (a.x - b.x).hypot(a.y - b.y) >= 1e-7,
672        _ => false,
673    };
674    if !ok {
675        return false;
676    }
677    add_geometry(doc, "arc", vec![center.clone(), start.clone(), end.clone()], construction);
678    true
679}
680
681/// Trim a LINE (`#trimLineGeometry`): keep the sub-segment(s) OUTSIDE the bracketed
682/// span — `[start, prev]` and/or `[next, end]` — minting the cut points and pinning
683/// each onto its crossing geometry. `false` (→ delete the whole line) when nothing
684/// survives.
685fn trim_line(doc: &mut SketchDoc, geo: &SketchGeometry, bounds: &Bounds) -> bool {
686    let ids = &geo.points;
687    if ids.len() < 2 {
688        return false;
689    }
690    let id0 = ids[0].clone();
691    let id1 = ids[1].clone();
692    let (p0, p1) = match (doc.point(&id0), doc.point(&id1)) {
693        (Some(a), Some(b)) => ((a.x, a.y), (b.x, b.y)),
694        _ => return false,
695    };
696    let eps = 1e-5;
697    let use_prev = bounds.prev.as_ref().map_or(false, |b| b.param > eps);
698    let use_next = bounds.next.as_ref().map_or(false, |b| b.param < 1.0 - eps);
699    if !use_prev && !use_next {
700        return false;
701    }
702    if use_prev && use_next {
703        let pp = bounds.prev.as_ref().unwrap().param;
704        let np = bounds.next.as_ref().unwrap().param;
705        if np - pp <= eps {
706            return false;
707        }
708    }
709    let point_at = |t: f64| (p0.0 + (p1.0 - p0.0) * t, p0.1 + (p1.1 - p0.1) * t);
710    let construction = geo.construction();
711
712    let prev_id = if use_prev {
713        let (x, y) = point_at(bounds.prev.as_ref().unwrap().param);
714        get_or_create_point(doc, x, y)
715    } else {
716        id0.clone()
717    };
718    let next_id = if use_next {
719        let (x, y) = point_at(bounds.next.as_ref().unwrap().param);
720        get_or_create_point(doc, x, y)
721    } else {
722        id1.clone()
723    };
724
725    let mut added = false;
726    if use_prev {
727        added |= add_line_if_valid(doc, &id0, &prev_id, construction);
728    }
729    if use_next {
730        added |= add_line_if_valid(doc, &next_id, &id1, construction);
731    }
732    if use_prev {
733        apply_trim_intersection_constraint(doc, &prev_id, bounds.prev.as_ref().unwrap());
734    }
735    if use_next {
736        apply_trim_intersection_constraint(doc, &next_id, bounds.next.as_ref().unwrap());
737    }
738    if added {
739        remove_geometry(doc, &geo.id);
740    }
741    added
742}
743
744/// Trim an ARC (`#trimArcGeometry`): keep the shorter arc(s) `[center, start, prev]`
745/// and/or `[center, next, end]` outside the bracketed span.
746fn trim_arc(doc: &mut SketchDoc, geo: &SketchGeometry, bounds: &Bounds) -> bool {
747    let ids = &geo.points;
748    if ids.len() < 3 {
749        return false;
750    }
751    let id_c = ids[0].clone();
752    let id_a = ids[1].clone();
753    let id_b = ids[2].clone();
754    let (pc, pa) = match (doc.point(&id_c), doc.point(&id_a)) {
755        (Some(c), Some(a)) => ((c.x, c.y), (a.x, a.y)),
756        _ => return false,
757    };
758    let r = (pa.0 - pc.0).hypot(pa.1 - pc.1);
759    if !r.is_finite() || r < 1e-9 {
760        return false;
761    }
762    let eps = 1e-5;
763    let use_prev = bounds.prev.as_ref().map_or(false, |b| b.param > eps);
764    let use_next = bounds.next.as_ref().map_or(false, |b| b.param < 1.0 - eps);
765    if !use_prev && !use_next {
766        return false;
767    }
768    if use_prev && use_next {
769        let pp = bounds.prev.as_ref().unwrap().param;
770        let np = bounds.next.as_ref().unwrap().param;
771        if np - pp <= eps {
772            return false;
773        }
774    }
775    let on_circle = |inter: &Intersection| {
776        let ang = (inter.y - pc.1).atan2(inter.x - pc.0);
777        (pc.0 + r * ang.cos(), pc.1 + r * ang.sin())
778    };
779    let prev_id = if use_prev {
780        let (x, y) = on_circle(bounds.prev.as_ref().unwrap());
781        get_or_create_point(doc, x, y)
782    } else {
783        id_a.clone()
784    };
785    let next_id = if use_next {
786        let (x, y) = on_circle(bounds.next.as_ref().unwrap());
787        get_or_create_point(doc, x, y)
788    } else {
789        id_b.clone()
790    };
791    let construction = geo.construction();
792
793    let mut added = false;
794    if use_prev {
795        added |= add_arc_if_valid(doc, &id_c, &id_a, &prev_id, construction);
796    }
797    if use_next {
798        added |= add_arc_if_valid(doc, &id_c, &next_id, &id_b, construction);
799    }
800    if use_prev {
801        apply_trim_intersection_constraint(doc, &prev_id, bounds.prev.as_ref().unwrap());
802    }
803    if use_next {
804        apply_trim_intersection_constraint(doc, &next_id, bounds.next.as_ref().unwrap());
805    }
806    if added {
807        remove_geometry(doc, &geo.id);
808    }
809    added
810}
811
812/// Trim a CIRCLE (`#trimCircleGeometry`): replace it with the arc `[center, next,
813/// prev]` — the surviving span that does NOT contain the click (CCW from the `next`
814/// intersection around to `prev`).
815fn trim_circle(doc: &mut SketchDoc, geo: &SketchGeometry, bounds: &Bounds) -> bool {
816    let ids = &geo.points;
817    if ids.len() < 2 {
818        return false;
819    }
820    let center = ids[0].clone();
821    let (pc, pr) = match (doc.point(&center), doc.point(&ids[1])) {
822        (Some(c), Some(r)) => ((c.x, c.y), (r.x, r.y)),
823        _ => return false,
824    };
825    let r = (pr.0 - pc.0).hypot(pr.1 - pc.1);
826    if !r.is_finite() || r < 1e-9 {
827        return false;
828    }
829    let (prev, next) = match (&bounds.prev, &bounds.next) {
830        (Some(prev), Some(next)) => (prev, next),
831        _ => return false,
832    };
833    let max_param = if bounds.max_param > 0.0 { bounds.max_param } else { 1.0 };
834    let delta = (next.param - prev.param).rem_euclid(max_param);
835    if delta < 1e-5 || delta > max_param - 1e-5 {
836        return false;
837    }
838    let on_circle = |inter: &Intersection| {
839        let ang = (inter.y - pc.1).atan2(inter.x - pc.0);
840        (pc.0 + r * ang.cos(), pc.1 + r * ang.sin())
841    };
842    let (px, py) = on_circle(prev);
843    let prev_id = get_or_create_point(doc, px, py);
844    let (nx, ny) = on_circle(next);
845    let next_id = get_or_create_point(doc, nx, ny);
846    if id_key(&prev_id) == id_key(&next_id) {
847        return false;
848    }
849    let construction = geo.construction();
850    add_geometry(doc, "arc", vec![center, next_id.clone(), prev_id.clone()], construction);
851    apply_trim_intersection_constraint(doc, &prev_id, prev);
852    apply_trim_intersection_constraint(doc, &next_id, next);
853    remove_geometry(doc, &geo.id);
854    true
855}
856
857/// De Casteljau split of the bezier span `seg_index` at `t` (a port of
858/// `#splitBezierAt`): shrink the span's control legs and splice three fresh anchors
859/// (`r0, s, r1`) into the geometry's point list. Returns the new mid-anchor's index.
860fn split_bezier_at(doc: &mut SketchDoc, geo_id: &Value, seg_index: usize, t: f64) -> Option<usize> {
861    let ids: Vec<Value> = doc.geometry(geo_id)?.points.clone();
862    let seg_count = ids.len().saturating_sub(1) / 3;
863    if seg_index >= seg_count {
864        return None;
865    }
866    let base = seg_index * 3;
867    let id0 = ids.get(base)?.clone();
868    let id1 = ids.get(base + 1)?.clone();
869    let id2 = ids.get(base + 2)?.clone();
870    let id3 = ids.get(base + 3)?.clone();
871    let (p0, p1, p2, p3) = {
872        let g0 = doc.point(&id0)?;
873        let g1 = doc.point(&id1)?;
874        let g2 = doc.point(&id2)?;
875        let g3 = doc.point(&id3)?;
876        ((g0.x, g0.y), (g1.x, g1.y), (g2.x, g2.y), (g3.x, g3.y))
877    };
878    let tt = t.clamp(0.0001, 0.9999);
879    let lerp = |a: (f64, f64), b: (f64, f64)| (a.0 + (b.0 - a.0) * tt, a.1 + (b.1 - a.1) * tt);
880    let q0 = lerp(p0, p1);
881    let q1 = lerp(p1, p2);
882    let q2 = lerp(p2, p3);
883    let r0 = lerp(q0, q1);
884    let r1 = lerp(q1, q2);
885    let s = lerp(r0, r1);
886
887    if let Some(pm) = doc.point_mut(&id1) {
888        pm.x = q0.0;
889        pm.y = q0.1;
890    }
891    if let Some(pm) = doc.point_mut(&id2) {
892        pm.x = q2.0;
893        pm.y = q2.1;
894    }
895    let r0_id = create_point(doc, r0.0, r0.1);
896    let s_id = create_point(doc, s.0, s.1);
897    let r1_id = create_point(doc, r1.0, r1.1);
898    let at = base + 2;
899    let g = doc.geometry_mut(geo_id)?;
900    g.points.splice(at..at, [r0_id, s_id, r1_id]);
901    Some(base + 3)
902}
903
904/// Trim a BEZIER (`#trimBezierGeometry`): split the span(s) at the bracketing
905/// intersections and re-emit the surviving control-point ranges as fresh bezier
906/// geometries. `false` (→ delete the whole bezier) when nothing survives.
907fn trim_bezier(doc: &mut SketchDoc, geo: &SketchGeometry, bounds: &Bounds, target: &Sampled) -> bool {
908    let geo_id = geo.id.clone();
909    let seg_count = if target.seg_count >= 1 {
910        target.seg_count
911    } else {
912        geo.points.len().saturating_sub(1) / 3
913    };
914    if seg_count < 1 {
915        return false;
916    }
917    let prev_int = bounds.prev.clone();
918    let next_int = bounds.next.clone();
919    if prev_int.is_none() && next_int.is_none() {
920        return false;
921    }
922    if let (Some(p), Some(n)) = (&prev_int, &next_int) {
923        if n.param - p.param <= 1e-5 {
924            return false;
925        }
926    }
927
928    /// A cut boundary: which end (`prev`=0/`next`=1), which span + local `t`, its
929    /// absolute param (for ordering), and the mid-anchor index once split.
930    struct Boundary {
931        kind: u8,
932        seg_index: usize,
933        t: f64,
934        pos: f64,
935        anchor_index: Option<usize>,
936    }
937    let mk = |kind: u8, param: f64| -> Boundary {
938        let seg_index = (param.floor() as isize).clamp(0, seg_count as isize - 1) as usize;
939        Boundary {
940            kind,
941            seg_index,
942            t: param - seg_index as f64,
943            pos: param,
944            anchor_index: None,
945        }
946    };
947    let mut boundaries: Vec<Boundary> = Vec::new();
948    if let Some(p) = &prev_int {
949        boundaries.push(mk(0, p.param));
950    }
951    if let Some(n) = &next_int {
952        boundaries.push(mk(1, n.param));
953    }
954    boundaries.sort_by(|a, b| a.pos.partial_cmp(&b.pos).unwrap_or(Ordering::Equal));
955
956    let mut splits_before = 0usize;
957    if boundaries.len() == 2 && boundaries[0].seg_index == boundaries[1].seg_index {
958        let first_t = boundaries[0].t;
959        let second_t = boundaries[1].t;
960        if second_t - first_t < 1e-5 {
961            return false;
962        }
963        let seg = boundaries[0].seg_index;
964        let res1 = match split_bezier_at(doc, &geo_id, seg + splits_before, first_t) {
965            Some(a) => a,
966            None => return false,
967        };
968        boundaries[0].anchor_index = Some(res1);
969        splits_before += 1;
970        let t2 = (second_t - first_t) / (1.0 - first_t);
971        let res2 = match split_bezier_at(doc, &geo_id, seg + splits_before, t2) {
972            Some(a) => a,
973            None => return false,
974        };
975        boundaries[1].anchor_index = Some(res2);
976    } else {
977        for b in boundaries.iter_mut() {
978            let res = match split_bezier_at(doc, &geo_id, b.seg_index + splits_before, b.t) {
979                Some(a) => a,
980                None => return false,
981            };
982            b.anchor_index = Some(res);
983            splits_before += 1;
984        }
985    }
986
987    let total_segs = match doc.geometry(&geo_id) {
988        Some(g) => g.points.len().saturating_sub(1) / 3,
989        None => return false,
990    };
991    let prev_seg = boundaries
992        .iter()
993        .find(|b| b.kind == 0)
994        .map(|b| b.anchor_index.unwrap_or(0) / 3)
995        .unwrap_or(0);
996    let next_seg = boundaries
997        .iter()
998        .find(|b| b.kind == 1)
999        .map(|b| b.anchor_index.unwrap_or(0) / 3)
1000        .unwrap_or(total_segs);
1001    if next_seg <= prev_seg {
1002        return false;
1003    }
1004    let mut keep_ranges: Vec<(usize, usize)> = Vec::new();
1005    if prev_seg > 0 {
1006        keep_ranges.push((0, prev_seg));
1007    }
1008    if next_seg < total_segs {
1009        keep_ranges.push((next_seg, total_segs));
1010    }
1011    let construction = geo.construction();
1012    let mut added = false;
1013    for (a, b) in keep_ranges {
1014        if b <= a {
1015            continue;
1016        }
1017        let start_idx = a * 3;
1018        let end_idx = b * 3;
1019        let plen = match doc.geometry(&geo_id) {
1020            Some(g) => g.points.len(),
1021            None => return false,
1022        };
1023        if end_idx >= plen {
1024            continue;
1025        }
1026        let pts: Vec<Value> = doc.geometry(&geo_id).unwrap().points[start_idx..=end_idx].to_vec();
1027        if pts.len() >= 4 {
1028            add_geometry(doc, "bezier", pts, construction);
1029            added = true;
1030        }
1031    }
1032    if added {
1033        remove_geometry(doc, &geo_id);
1034    }
1035    added
1036}
1037
1038// ---------------------------------------------------------------------------
1039// Public entry points.
1040// ---------------------------------------------------------------------------
1041
1042/// The id of the geometry whose sampled polyline passes nearest to `(u, v)` within
1043/// `radius`, or `None`. The geometry-only picker the trim tool uses (points are NOT
1044/// candidates — a trim click acts on a curve).
1045pub fn pick_geometry_id(doc: &SketchDoc, u: f64, v: f64, radius: f64) -> Option<Value> {
1046    let mut best: Option<(f64, Value)> = None;
1047    for g in &doc.geometries {
1048        let poly = super::tessellate::geometry_polyline_uv(g, doc);
1049        if poly.len() < 2 {
1050            continue;
1051        }
1052        let mut dmin = f64::INFINITY;
1053        for seg in poly.windows(2) {
1054            let d = point_seg_dist(u, v, seg[0], seg[1]);
1055            if d < dmin {
1056                dmin = d;
1057            }
1058        }
1059        if dmin <= radius && best.as_ref().map_or(true, |(bd, _)| dmin < *bd) {
1060            best = Some((dmin, g.id.clone()));
1061        }
1062    }
1063    best.map(|(_, id)| id)
1064}
1065
1066/// Trim (or delete) the geometry `geo_id` at the plane click `(u, v)` — the port of
1067/// `#trimGeometry`. Samples the target, collects its intersections with every other
1068/// geometry, brackets the click, and splits per type; with no bracketing
1069/// intersections the whole geometry is deleted. Returns whether the doc changed
1070/// (the caller then re-solves). Does NOT solve or touch selection/undo — the engine
1071/// wrapper owns that.
1072pub fn trim_geometry(doc: &mut SketchDoc, geo_id: &Value, u: f64, v: f64) -> bool {
1073    let geo = match doc.geometry(geo_id) {
1074        Some(g) => g.clone(),
1075        None => return false,
1076    };
1077    let target = match sample_geometry(&geo, doc) {
1078        Some(t) if t.samples.len() >= 2 => t,
1079        _ => return false,
1080    };
1081    let click_param = match closest_param_on_samples(u, v, &target.samples) {
1082        Some(p) if p.is_finite() => p,
1083        _ => return false,
1084    };
1085
1086    let mut intersections: Vec<Intersection> = Vec::new();
1087    for other in &doc.geometries {
1088        if id_key(&other.id) == id_key(geo_id) {
1089            continue;
1090        }
1091        if let Some(sample) = sample_geometry(other, doc) {
1092            collect_intersections(&target, &sample, other, &mut intersections);
1093        }
1094    }
1095
1096    let changed = match select_trim_bounds(&intersections, click_param, &target) {
1097        None => remove_geometry(doc, geo_id),
1098        Some(bounds) => {
1099            let trimmed = match geo.geom_type.as_str() {
1100                "line" => trim_line(doc, &geo, &bounds),
1101                "circle" => trim_circle(doc, &geo, &bounds),
1102                "arc" => {
1103                    if target.closed {
1104                        trim_circle(doc, &geo, &bounds)
1105                    } else {
1106                        trim_arc(doc, &geo, &bounds)
1107                    }
1108                }
1109                "bezier" => trim_bezier(doc, &geo, &bounds, &target),
1110                _ => false,
1111            };
1112            if trimmed {
1113                true
1114            } else {
1115                remove_geometry(doc, geo_id)
1116            }
1117        }
1118    };
1119    if changed {
1120        cleanup_orphan_points(doc);
1121    }
1122    changed
1123}
1124
1125/// Euclidean distance from `(px, py)` to segment `a`–`b` (plane uv).
1126fn point_seg_dist(px: f64, py: f64, a: [f64; 2], b: [f64; 2]) -> f64 {
1127    let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
1128    let len2 = dx * dx + dy * dy;
1129    let t = if len2 <= 1e-18 {
1130        0.0
1131    } else {
1132        (((px - a[0]) * dx + (py - a[1]) * dy) / len2).clamp(0.0, 1.0)
1133    };
1134    let (cx, cy) = (a[0] + t * dx, a[1] + t * dy);
1135    (px - cx).hypot(py - cy)
1136}
1137
1138// BREP private tests: 853262fc4ec81d14