Skip to main content

brep_kernel/feature_pipeline/features/sketch/
loop_ids.rs

1//! Stable per-LOOP identity for a sketch's closed loops.
2//!
3//! # Why
4//!
5//! A profile-swept feature (extrude/sweep/revolve/loft/path-sweep) builds one
6//! prism per profile REGION and stamps names on the result. Sidewalls are safe:
7//! they are named from the source geometry's persistent `{sketchId}:G{gid}`. The
8//! CAPS were not — every region stamped the SAME `{cap_base}_START/_END`, so the
9//! post-union `ensure_unique_face_names` pass disambiguated them POSITIONALLY
10//! (`X[0]`, `X[1]`, …) in shell/face encounter order. Editing the sketch to add
11//! or remove a loop shifted those indices, and a fillet that referenced `X[1]`
12//! silently jumped to a different island on replay.
13//!
14//! The fix is to give every loop an identity of its own that the cap names embed.
15//! That identity must survive editing the loop itself, so it is PERSISTED: each
16//! sketch geometry carries a `loopId` in `persistentData.sketch`, written back by
17//! the editor on commit (`assign_sketch_loop_ids`).
18//!
19//! # The rule
20//!
21//! For each assembled closed loop:
22//! - if ANY member edge carries a stored `loopId`, the loop claims the LOWEST of
23//!   them ("lowest number wins") and that value is written back onto every other
24//!   member edge, so the loop keeps its identity as edges come and go;
25//! - otherwise the loop is new and SEEDS its id from the lowest geometry id
26//!   among its edges.
27//!
28//! Both halves are computed the same way whether or not the document has ever
29//! been committed, so a sketch that has never been through the editor derives
30//! exactly the id it would have been assigned — reading and writing never
31//! disagree, and no document renames faces merely by being opened.
32//!
33//! # Resilience
34//!
35//! - ADDING an edge to a loop: the new edge carries no id and inherits the
36//!   loop's — unchanged. (Minting also hands out ids above the current maximum,
37//!   so a seed can only move down, never up.)
38//! - REMOVING a non-anchor edge: the id lives on the survivors — unchanged.
39//! - REMOVING the anchor edge (the one the id was seeded from): still unchanged,
40//!   because the id was persisted onto the whole loop, not recomputed from the
41//!   anchor. This is the case a derive-only scheme cannot handle, and the reason
42//!   the id is stored at all.
43//! - Editing a DIFFERENT loop: never observed here — a loop's claim reads only
44//!   its own edges. That is the reported bug, fixed.
45//!
46//! # Collisions
47//!
48//! Two loops can claim the same id: a loop that SPLIT in two (both halves carry
49//! the same stored id), or — because the editor mints `max(existing gid) + 1` and
50//! therefore REUSES the ids of deleted geometry — a freshly drawn loop whose
51//! seed collides with an older loop's stored id. Ties break in this order:
52//! 1. a loop that INHERITED the id (stored on its edges) beats one that merely
53//!    seeded it from a gid, so a new loop can never steal a live loop's identity;
54//! 2. between two inheritors (the split case), the one holding the lower minimum
55//!    gid keeps it — deterministic, and independent of loop order.
56//! The loser re-seeds from its own lowest gid, or, if that is taken too, from one
57//! above the highest id in play. A split therefore keeps the original id on one
58//! half and mints a fresh one for the other; if the halves are later rejoined,
59//! the survivor's id wins again.
60
61use super::geometry::Loop;
62
63/// Resolve every loop's [`Loop::loop_id`], in place.
64///
65/// `loops` is the fully assembled set (chained loops plus whole circles and
66/// ellipses) BEFORE region classification, so ids are decided over the sketch as
67/// a whole and never depend on how the loops later group into regions.
68pub(super) fn resolve(loops: &mut [Loop]) {
69    let claims: Vec<Claim> = loops.iter().map(Claim::of).collect();
70    for (loop_data, id) in loops.iter_mut().zip(settle(&claims)) {
71        loop_data.loop_id = id;
72    }
73}
74
75/// One loop's claim: the id it wants, whether it INHERITED that id from stored
76/// geometry (as opposed to seeding it from a gid), and its lowest gid — the
77/// tie-break and the re-seed source.
78struct Claim {
79    id: Option<u64>,
80    inherited: bool,
81    min_gid: Option<u64>,
82}
83
84impl Claim {
85    fn of(loop_data: &Loop) -> Self {
86        let stored = loop_data
87            .entries
88            .iter()
89            .filter_map(|(segment, _)| segment.ids.loop_id)
90            .min();
91        let min_gid = loop_data
92            .entries
93            .iter()
94            .filter_map(|(segment, _)| segment.ids.gid)
95            .min();
96        Self {
97            id: stored.or(min_gid),
98            inherited: stored.is_some(),
99            min_gid,
100        }
101    }
102}
103
104/// Award each claim a UNIQUE id, applying the tie-break rules in the module doc.
105/// Returns one id per claim, in input order.
106fn settle(claims: &[Claim]) -> Vec<Option<u64>> {
107    // Award order: inheritors first (they cannot be displaced by a seeder), then
108    // by ascending minimum gid, then by input position — a total order, so the
109    // outcome never depends on the order the loops happened to be assembled in.
110    let mut order: Vec<usize> = (0..claims.len()).collect();
111    order.sort_by_key(|&index| {
112        let claim = &claims[index];
113        (!claim.inherited, claim.min_gid.unwrap_or(u64::MAX), index)
114    });
115
116    let mut taken: Vec<u64> = Vec::with_capacity(claims.len());
117    let mut resolved = vec![None; claims.len()];
118    for index in order {
119        let claim = &claims[index];
120        let Some(wanted) = claim.id else { continue };
121        let id = if taken.contains(&wanted) {
122            // Displaced: fall back to this loop's own lowest gid, else to one
123            // above everything in play (ids are just numbers — a re-seed is free
124            // to leave the gid space).
125            let own = claim.min_gid.filter(|gid| !taken.contains(gid));
126            own.unwrap_or_else(|| next_free(claims, &taken))
127        } else {
128            wanted
129        };
130        taken.push(id);
131        resolved[index] = Some(id);
132    }
133    resolved
134}
135
136/// One above the highest id anywhere in play (awarded, claimed, or a gid) — the
137/// last-resort id for a loop displaced off both its stored id and its own gid.
138fn next_free(claims: &[Claim], taken: &[u64]) -> u64 {
139    let highest = taken
140        .iter()
141        .copied()
142        .chain(claims.iter().flat_map(|claim| claim.id.into_iter()))
143        .chain(claims.iter().flat_map(|claim| claim.min_gid.into_iter()))
144        .max()
145        .unwrap_or(0);
146    highest.saturating_add(1)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::super::geometry::{SegKind, Segment, SegmentIds};
152    use super::*;
153
154    /// A loop of `(gid, stored loop id)` edges — geometry only matters to the
155    /// containment pass, so the segments here are placeholders.
156    fn loop_of(edges: &[(u64, Option<u64>)]) -> Loop {
157        Loop {
158            entries: edges
159                .iter()
160                .map(|&(gid, loop_id)| {
161                    (
162                        Segment {
163                            a: [0.0, 0.0],
164                            b: [1.0, 0.0],
165                            kind: SegKind::Line,
166                            name: Some(format!("S1:G{gid}")),
167                            ids: SegmentIds {
168                                gid: Some(gid),
169                                loop_id,
170                            },
171                        },
172                        false,
173                    )
174                })
175                .collect(),
176            polygon: Vec::new(),
177            loop_id: None,
178        }
179    }
180
181    fn ids(loops: &[Loop]) -> Vec<Option<u64>> {
182        loops.iter().map(|l| l.loop_id).collect()
183    }
184
185    /// A never-committed sketch seeds each loop from its own lowest gid.
186    #[test]
187    fn fresh_loops_seed_from_their_lowest_gid() {
188        let mut loops = vec![
189            loop_of(&[(4, None), (9, None), (12, None)]),
190            loop_of(&[(2, None), (7, None)]),
191        ];
192        resolve(&mut loops);
193        assert_eq!(ids(&loops), vec![Some(4), Some(2)]);
194    }
195
196    /// The reported bug: removing a WHOLE loop leaves the others' ids alone.
197    #[test]
198    fn deleting_another_loop_does_not_move_an_ids() {
199        let three = vec![
200            loop_of(&[(4, Some(4)), (5, Some(4))]),
201            loop_of(&[(9, Some(9)), (10, Some(9))]),
202            loop_of(&[(12, Some(12)), (13, Some(12))]),
203        ];
204        let mut before = three;
205        resolve(&mut before);
206        // The middle loop is deleted; the survivors keep their exact ids.
207        let mut after = vec![
208            loop_of(&[(4, Some(4)), (5, Some(4))]),
209            loop_of(&[(12, Some(12)), (13, Some(12))]),
210        ];
211        resolve(&mut after);
212        assert_eq!(ids(&before), vec![Some(4), Some(9), Some(12)]);
213        assert_eq!(ids(&after), vec![Some(4), Some(12)]);
214    }
215
216    /// Lowest stored id wins, and adding an edge (no stored id) changes nothing.
217    #[test]
218    fn lowest_stored_id_wins_and_new_edges_inherit() {
219        let mut loops = vec![loop_of(&[(9, Some(4)), (10, Some(7)), (11, None)])];
220        resolve(&mut loops);
221        assert_eq!(ids(&loops), vec![Some(4)]);
222    }
223
224    /// Deleting the anchor edge keeps the id: it lives on the survivors.
225    #[test]
226    fn deleting_the_anchor_edge_keeps_the_id() {
227        let mut loops = vec![loop_of(&[(9, Some(4)), (10, Some(4))])];
228        resolve(&mut loops);
229        assert_eq!(ids(&loops), vec![Some(4)]);
230    }
231
232    /// Gid reuse: a newly drawn loop whose seed collides with a live loop's
233    /// STORED id must not steal it — the inheritor keeps it, the seeder re-seeds.
234    #[test]
235    fn a_new_loop_cannot_steal_a_stored_id() {
236        let mut loops = vec![
237            // Older loop: its anchor (gid 4) was deleted, id 4 lives on gids 7/8.
238            loop_of(&[(7, Some(4)), (8, Some(4))]),
239            // Freshly drawn over the recycled ids 4..6 — seeds 4, which is taken.
240            loop_of(&[(4, None), (5, None), (6, None)]),
241        ];
242        resolve(&mut loops);
243        // The inheritor keeps 4; the new loop cannot fall back to its own lowest
244        // gid either (also 4, also taken), so it re-seeds above everything in
245        // play. What matters is that it did NOT take 4.
246        assert_eq!(ids(&loops), vec![Some(4), Some(8)]);
247    }
248
249    /// A split loop: one half keeps the id, the other re-seeds from its own gid.
250    #[test]
251    fn a_split_loop_keeps_one_id_and_mints_the_other() {
252        let mut loops = vec![
253            loop_of(&[(4, Some(4)), (5, Some(4))]),
254            loop_of(&[(6, Some(4)), (7, Some(4))]),
255        ];
256        resolve(&mut loops);
257        assert_eq!(ids(&loops), vec![Some(4), Some(6)]);
258    }
259
260    /// Awarding is independent of the order the loops arrive in.
261    #[test]
262    fn award_order_is_independent_of_loop_order() {
263        let mut forward = vec![
264            loop_of(&[(6, Some(4)), (7, Some(4))]),
265            loop_of(&[(4, Some(4)), (5, Some(4))]),
266        ];
267        resolve(&mut forward);
268        assert_eq!(ids(&forward), vec![Some(6), Some(4)]);
269    }
270}
271
272// ===========================================================================
273// Write-back — the editor side
274// ===========================================================================
275
276/// Assign every closed loop's id onto the geometries of a SOLVED sketch document,
277/// in place, and report whether anything changed.
278///
279/// This is the persistence half of the scheme: the kernel DERIVES ids the same
280/// way every run (so a document that has never been through here still names its
281/// faces correctly), and this writes the derived ids down so they survive the one
282/// edit deriving cannot: deleting the edge an id was seeded from. The editor calls
283/// it when a sketch is committed.
284///
285/// `doc` is the `persistentData.sketch` object. Geometry that is construction-only
286/// or not part of a closed loop is left untouched — only a loop has an identity.
287pub fn assign_sketch_loop_ids(doc: &mut serde_json::Value) -> bool {
288    let Some(loops) = closed_loops(doc) else {
289        return false;
290    };
291    let mut changed = false;
292    let Some(geometries) = doc
293        .get_mut("geometries")
294        .and_then(serde_json::Value::as_array_mut)
295    else {
296        return false;
297    };
298    for geometry in geometries.iter_mut() {
299        let Some(gid) = geometry.get("id").and_then(numeric) else {
300            continue;
301        };
302        let Some(loop_id) = loops.get(&gid) else {
303            continue;
304        };
305        let already = geometry.get("loopId").and_then(numeric);
306        if already == Some(*loop_id) {
307            continue;
308        }
309        if let Some(object) = geometry.as_object_mut() {
310            object.insert("loopId".into(), serde_json::json!(loop_id));
311            changed = true;
312        }
313    }
314    changed
315}
316
317/// `geometry id -> loop id` for every geometry that belongs to a closed loop,
318/// derived exactly as the sketch feature derives it: chain the segments, settle
319/// the claims, then hand each loop's awarded id to ALL of its members ("lowest
320/// number wins and updates the rest of the edges in that loop to match").
321fn closed_loops(doc: &serde_json::Value) -> Option<std::collections::HashMap<u64, u64>> {
322    let members = loop_members(doc)?;
323    let claims: Vec<Claim> = members
324        .iter()
325        .map(|edges| {
326            let stored = edges.iter().filter_map(|(_, stored)| *stored).min();
327            let min_gid = edges.iter().map(|(gid, _)| *gid).min();
328            Claim {
329                id: stored.or(min_gid),
330                inherited: stored.is_some(),
331                min_gid,
332            }
333        })
334        .collect();
335    let mut assignment = std::collections::HashMap::new();
336    for (edges, id) in members.iter().zip(settle(&claims)) {
337        let Some(id) = id else { continue };
338        for (gid, _) in edges {
339            assignment.insert(*gid, id);
340        }
341    }
342    Some(assignment)
343}
344
345/// Group the document's model geometry into closed loops, as
346/// `(geometry id, stored loop id)` per member.
347///
348/// Whole circles and ellipses are loops on their own. The rest chain end-to-end
349/// through shared POINT ids — the same adjacency the profile chainer walks, read
350/// off the document's point references rather than solved coordinates, which is
351/// exact and needs no tolerance. A chain is closed when it returns to its start.
352///
353/// At a BRANCHING junction (three or more segments meeting at one point — not a
354/// well-formed profile) this walk takes the first candidate, which may group the
355/// edges differently than the profile chainer does. That degrades safely rather
356/// than corrupting anything: the kernel resolves each loop's id from the ids
357/// stored on ITS OWN edges, so a mis-grouped stamp at worst makes two loops claim
358/// the same id, which [`settle`] then breaks apart deterministically.
359fn loop_members(doc: &serde_json::Value) -> Option<Vec<Vec<(u64, Option<u64>)>>> {
360    let geometries = doc.get("geometries")?.as_array()?;
361    let mut loops: Vec<Vec<(u64, Option<u64>)>> = Vec::new();
362    // (gid, stored, endpoint a, endpoint b) for the chainable segments.
363    let mut segments: Vec<(u64, Option<u64>, u64, u64)> = Vec::new();
364    for geometry in geometries {
365        if geometry.get("construction").and_then(serde_json::Value::as_bool) == Some(true) {
366            continue;
367        }
368        let Some(gid) = geometry.get("id").and_then(numeric) else {
369            continue;
370        };
371        let stored = geometry.get("loopId").and_then(numeric);
372        let geom_type = geometry
373            .get("type")
374            .and_then(serde_json::Value::as_str)
375            .unwrap_or("");
376        let points: Vec<u64> = geometry
377            .get("points")
378            .and_then(serde_json::Value::as_array)
379            .map(|ids| ids.iter().filter_map(numeric).collect())
380            .unwrap_or_default();
381        match geom_type {
382            // Self-contained loops.
383            "circle" | "ellipse" => loops.push(vec![(gid, stored)]),
384            // `[start, end]`.
385            "line" if points.len() == 2 => segments.push((gid, stored, points[0], points[1])),
386            // `[center, start, end]`.
387            "arc" if points.len() == 3 => segments.push((gid, stored, points[1], points[2])),
388            // `[p0, …, pn]` — the spline's endpoints are its first and last.
389            "bezier" if points.len() >= 2 => {
390                segments.push((gid, stored, points[0], points[points.len() - 1]))
391            }
392            _ => {}
393        }
394    }
395
396    // Walk each connected component; keep it only if it closes.
397    let mut visited = vec![false; segments.len()];
398    for start in 0..segments.len() {
399        if visited[start] {
400            continue;
401        }
402        let (_, _, first_point, _) = segments[start];
403        let mut chain = vec![start];
404        visited[start] = true;
405        let mut tail = segments[start].3;
406        loop {
407            if tail == first_point {
408                // Closed: a chain of one segment closes only if it is a loop on
409                // its own (a full-circle arc), which the endpoints already say.
410                loops.push(
411                    chain
412                        .iter()
413                        .map(|&index| (segments[index].0, segments[index].1))
414                        .collect(),
415                );
416                break;
417            }
418            let next = (0..segments.len()).find(|&index| {
419                !visited[index] && (segments[index].2 == tail || segments[index].3 == tail)
420            });
421            let Some(next) = next else { break };
422            visited[next] = true;
423            chain.push(next);
424            tail = if segments[next].2 == tail {
425                segments[next].3
426            } else {
427                segments[next].2
428            };
429        }
430    }
431    Some(loops)
432}
433
434/// A JSON id as a number (a numeric string counts) — the write-back's copy of the
435/// sketch pipeline's `numeric_id`, so both halves read ids identically.
436fn numeric(value: &serde_json::Value) -> Option<u64> {
437    match value {
438        serde_json::Value::Number(number) => number.as_u64(),
439        serde_json::Value::String(text) => text.parse().ok(),
440        _ => None,
441    }
442}
443
444#[cfg(test)]
445mod write_back_tests {
446    use super::*;
447    use serde_json::json;
448
449    fn line(id: u64, a: u64, b: u64) -> serde_json::Value {
450        json!({ "id": id, "type": "line", "points": [a, b] })
451    }
452
453    /// The loop id each geometry ended up with.
454    fn stamped(doc: &serde_json::Value) -> Vec<(u64, Option<u64>)> {
455        doc["geometries"]
456            .as_array()
457            .unwrap()
458            .iter()
459            .map(|geometry| {
460                (
461                    numeric(&geometry["id"]).unwrap(),
462                    geometry.get("loopId").and_then(numeric),
463                )
464            })
465            .collect()
466    }
467
468    /// A closed loop's id lands on EVERY one of its edges, not just the anchor —
469    /// that is what lets the loop keep its identity when the anchor is deleted.
470    #[test]
471    fn a_closed_loop_stamps_all_of_its_edges() {
472        let mut doc = json!({
473            "points": [],
474            "geometries": [line(4, 1, 2), line(5, 2, 3), line(6, 3, 1)],
475            "constraints": [],
476        });
477        assert!(assign_sketch_loop_ids(&mut doc));
478        assert_eq!(stamped(&doc), vec![(4, Some(4)), (5, Some(4)), (6, Some(4))]);
479        // Idempotent: a second commit changes nothing.
480        assert!(!assign_sketch_loop_ids(&mut doc));
481    }
482
483    /// Two disjoint loops get their own ids, and deleting the FIRST loop's anchor
484    /// edge leaves both loops' ids exactly where they were.
485    #[test]
486    fn deleting_an_anchor_edge_preserves_both_loop_ids() {
487        let mut doc = json!({
488            "points": [],
489            "geometries": [
490                line(4, 1, 2), line(5, 2, 3), line(6, 3, 1),
491                line(7, 10, 11), line(8, 11, 12), line(9, 12, 10),
492            ],
493            "constraints": [],
494        });
495        assign_sketch_loop_ids(&mut doc);
496        assert_eq!(
497            stamped(&doc),
498            vec![
499                (4, Some(4)), (5, Some(4)), (6, Some(4)),
500                (7, Some(7)), (8, Some(7)), (9, Some(7)),
501            ],
502        );
503
504        // Delete edge 4 (loop A's anchor) and re-close the loop with a new edge
505        // 12 — the editor's own "delete and redraw" shape.
506        let mut edited = json!({
507            "points": [],
508            "geometries": [
509                { "id": 5, "type": "line", "points": [2, 3], "loopId": 4 },
510                { "id": 6, "type": "line", "points": [3, 1], "loopId": 4 },
511                line(12, 1, 2),
512                { "id": 7, "type": "line", "points": [10, 11], "loopId": 7 },
513                { "id": 8, "type": "line", "points": [11, 12], "loopId": 7 },
514                { "id": 9, "type": "line", "points": [12, 10], "loopId": 7 },
515            ],
516            "constraints": [],
517        });
518        assign_sketch_loop_ids(&mut edited);
519        assert_eq!(
520            stamped(&edited),
521            vec![
522                (5, Some(4)), (6, Some(4)), (12, Some(4)),
523                (7, Some(7)), (8, Some(7)), (9, Some(7)),
524            ],
525            "both loops kept their ids; the new edge inherited loop A's",
526        );
527    }
528
529    /// A whole circle is a loop on its own; an OPEN chain is not a loop and is
530    /// left alone.
531    #[test]
532    fn circles_are_loops_and_open_chains_are_not() {
533        let mut doc = json!({
534            "points": [],
535            "geometries": [
536                { "id": 3, "type": "circle", "points": [1, 2] },
537                line(7, 10, 11),
538                line(8, 11, 12),
539            ],
540            "constraints": [],
541        });
542        assign_sketch_loop_ids(&mut doc);
543        assert_eq!(stamped(&doc), vec![(3, Some(3)), (7, None), (8, None)]);
544    }
545
546    /// Construction geometry constrains but never models a loop.
547    #[test]
548    fn construction_geometry_is_never_stamped() {
549        let mut doc = json!({
550            "points": [],
551            "geometries": [
552                line(4, 1, 2), line(5, 2, 3), line(6, 3, 1),
553                { "id": 2, "type": "line", "points": [1, 3], "construction": true },
554            ],
555            "constraints": [],
556        });
557        assign_sketch_loop_ids(&mut doc);
558        assert_eq!(
559            stamped(&doc),
560            vec![(4, Some(4)), (5, Some(4)), (6, Some(4)), (2, None)],
561        );
562    }
563}