BREP_kernel 0.2.0

A boundary representation (BREP) geometry kernel for building CAD applications.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
use super::*;

// ---------------------------------------------------------------------------
// §6.12 sibling operation: move a face group.
// ---------------------------------------------------------------------------

/// How the moved group uses an edge: not at all, on both sides (carried
/// rigidly), or on exactly one side (the seam to re-intersect).
enum EdgeMoveClass {
    Fixed,
    Interior,
    Boundary { moved_face: u64, fixed_face: u64 },
}

enum EdgeMoveAction {
    /// Translate the curve rigidly; parameters and pcurves stay exact.
    Translate,
    /// Replace the curve by the straight line between re-solved endpoints.
    Rebuild { start: Vec3, end: Vec3 },
}

enum FaceMoveAction {
    /// Shift the whole control net; pcurves stay exact for any carrier type.
    TranslateSurface,
    /// Rebuild the (planar) carrier around the new boundary and recompute
    /// every pcurve — the direct-edit equivalent of "extend the neighbour".
    Retrim(Plane),
}

/// `plane_of_surface` with per-face memoisation, because a face is consulted
/// once per boundary edge, once per touched vertex, and once at re-trim time.
fn cached_plane(
    cache: &mut HashMap<u64, Plane>,
    solid: &BrepSolid,
    face_lookup: &HashMap<u64, (usize, usize)>,
    face_id: u64,
    tolerance: f64,
) -> Result<Plane, String> {
    if let Some(plane) = cache.get(&face_id) {
        return Ok(*plane);
    }
    let (shell_index, face_index) = *face_lookup
        .get(&face_id)
        .ok_or_else(|| format!("move_faces: missing face {face_id}"))?;
    let plane = plane_of_surface(
        &solid.shells[shell_index].faces[face_index].surface,
        tolerance,
        "move_faces",
    )?;
    cache.insert(face_id, plane);
    Ok(plane)
}

/// Intersect three-or-more planes in one point: take the best-conditioned
/// pair for the line, then the plane most transverse to that line for the
/// point. The caller checks the residual against EVERY plane afterwards, so
/// this only has to find *a* candidate, not prove consistency.
fn solve_corner(planes: &[Plane]) -> Option<Vec3> {
    let mut best_pair: Option<(usize, usize, f64)> = None;
    for first in 0..planes.len() {
        for second in first + 1..planes.len() {
            let spread = planes[first].normal.cross(planes[second].normal).length();
            if best_pair.map(|(_, _, best)| spread > best).unwrap_or(true) {
                best_pair = Some((first, second, spread));
            }
        }
    }
    let (first, second, spread) = best_pair?;
    if spread <= PARALLEL_EPS {
        return None;
    }
    let line = intersect_planes(&planes[first], &planes[second])?;
    let mut best_third: Option<(usize, f64)> = None;
    for third in 0..planes.len() {
        if third == first || third == second {
            continue;
        }
        let transversality = line.dir.dot(planes[third].normal).abs();
        if best_third
            .map(|(_, best)| transversality > best)
            .unwrap_or(true)
        {
            best_third = Some((third, transversality));
        }
    }
    let (third, transversality) = best_third?;
    if transversality <= PARALLEL_EPS {
        return None;
    }
    intersect_line_plane(&line, &planes[third])
}

/// Rebuild a straight edge between two re-solved endpoints. Refuses the
/// degenerate and inverted cases — a zero or reversed chord means the
/// translation drove a moved face onto or past the neighbour this edge
/// belongs to (e.g. pushing a box face through its opposite face).
fn plan_straight_rebuild(
    edge: &EdgeRecord,
    start_old: Vec3,
    end_old: Vec3,
    start_new: Vec3,
    end_new: Vec3,
    tolerance: f64,
) -> Result<EdgeMoveAction, String> {
    if edge.degenerate {
        return Err(format!(
            "move_faces: degenerate edge {} would need re-stretching (deferred)",
            edge.id
        ));
    }
    if edge.curve.degree != 1 || edge.curve.control_points.len() != 2 {
        return Err(format!(
            "move_faces: edge {} must be re-stretched but is not a straight line \
             (curved re-intersection edges are deferred in this slice)",
            edge.id
        ));
    }
    let new_chord = end_new.sub(start_new);
    if new_chord.length() <= tolerance {
        return Err(format!(
            "move_faces: the translation collapses edge {} to zero length (a moved \
             face lands exactly on its neighbour) — refusing",
            edge.id
        ));
    }
    if end_old.sub(start_old).dot(new_chord) <= 0.0 {
        return Err(format!(
            "move_faces: the translation inverts edge {} (a moved face passes beyond \
             its neighbour) — refusing",
            edge.id
        ));
    }
    Ok(EdgeMoveAction::Rebuild {
        start: start_new,
        end: end_new,
    })
}

/// Golovanov §6.12 direct editing — translate a group of faces rigidly and
/// heal the adjacency with the faces that stay behind.
///
/// The moved carriers translate exactly (every control point shifts by the
/// translation, which is exact for ANY surface type), and each boundary edge
/// between a moved face and a fixed face is recomputed as the intersection of
/// the translated moved carrier with the fixed carrier:
///
/// - When the translation is parallel to every fixed plane a boundary vertex
///   touches, the whole neighbourhood translates rigidly — exact for any
///   moved carrier and any edge curve type. This is the extrude-like case:
///   pushing a face along its own normal slides the side walls in-plane.
/// - Otherwise the new corner is re-solved as the common point of ALL carrier
///   planes meeting at the vertex (moved ones translated), and every affected
///   straight edge is rebuilt between the re-solved corners — the same
///   relocate-onto-recovered-corners move `delete_face_and_heal` performs on
///   its side edges.
///
/// v1 scope (honest refusals, never a bad solid): the moved faces may be any
/// surface type, but every FIXED face that must be re-intersected — the fixed
/// side of each boundary edge, and any face whose boundary edges must be
/// rebuilt — must be PLANAR, and every rebuilt edge must be a straight line.
/// A translation that collapses an adjacent edge to zero length or reverses
/// its direction (moving a box face onto or past its opposite face) is
/// refused, as is a group that tears away from its neighbours. The input is
/// never mutated; the result is returned only when `validate()` is clean.
pub fn move_faces(
    solid: &BrepSolid,
    face_ids: &[u64],
    translation: Vec3,
) -> Result<BrepSolid, String> {
    if !(translation.x.is_finite() && translation.y.is_finite() && translation.z.is_finite()) {
        return Err("move_faces: translation must be finite".into());
    }
    if face_ids.is_empty() {
        return Err("move_faces: no faces selected".into());
    }
    let moved: HashSet<u64> = face_ids.iter().copied().collect();
    // face id -> (shell, face) built once. move_faces never mutates `solid`,
    // so this replaces the O(faces) `find_face` scans in the validation loop
    // below and in `cached_plane` (called up to once per unique fixed face).
    // `or_insert` keeps the first match, mirroring `find_face`.
    let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
    for (shell_index, shell) in solid.shells.iter().enumerate() {
        for (face_index, face) in shell.faces.iter().enumerate() {
            face_lookup
                .entry(face.id)
                .or_insert((shell_index, face_index));
        }
    }
    for &face_id in face_ids {
        if !face_lookup.contains_key(&face_id) {
            return Err(format!("move_faces: no face with id {face_id}"));
        }
    }

    let scale = solid_scale(solid);
    let tolerance = (scale * 1e-7).max(1e-9);
    let plane_tolerance = (scale * 1e-6).max(1e-7);
    // "Parallel to a fixed plane" means the translation's normal component
    // could not move any point off that plane at model precision.
    let parallel_tolerance = (translation.length() * 1e-9).max(1e-12);
    // "Rigid" endpoints moved by exactly the translation (they are assigned
    // `point + translation` verbatim, so this only absorbs rounding noise).
    let rigid_tolerance = (scale * 1e-9).max(1e-12);

    // --- Classify every edge by how the group uses it ----------------------
    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
    for shell in &solid.shells {
        for face in &shell.faces {
            for loop_record in &face.loops {
                for coedge in &loop_record.coedges {
                    faces_of_edge
                        .entry(coedge.edge_id)
                        .or_default()
                        .push(face.id);
                }
            }
        }
    }
    let mut classes: HashMap<u64, EdgeMoveClass> = HashMap::default();
    let mut planes: HashMap<u64, Plane> = HashMap::default();
    for edge in &solid.edges {
        let uses = faces_of_edge
            .get(&edge.id)
            .map(Vec::as_slice)
            .unwrap_or(&[]);
        let expected = if edge.degenerate { 1 } else { 2 };
        if uses.len() != expected {
            return Err(format!(
                "move_faces: edge {} is used {} times (non-manifold input)",
                edge.id,
                uses.len()
            ));
        }
        let moved_uses = uses
            .iter()
            .filter(|face_id| moved.contains(*face_id))
            .count();
        let class = if moved_uses == 0 {
            EdgeMoveClass::Fixed
        } else if moved_uses == uses.len() {
            EdgeMoveClass::Interior
        } else {
            let moved_face = *uses
                .iter()
                .find(|face_id| moved.contains(*face_id))
                .unwrap();
            let fixed_face = *uses
                .iter()
                .find(|face_id| !moved.contains(*face_id))
                .unwrap();
            // v1 scope gate: the face left behind across every boundary edge
            // is the carrier we re-intersect against, so it must be planar.
            cached_plane(
                &mut planes,
                solid,
                &face_lookup,
                fixed_face,
                plane_tolerance,
            )?;
            EdgeMoveClass::Boundary {
                moved_face,
                fixed_face,
            }
        };
        classes.insert(edge.id, class);
    }

    // --- Relocate every vertex the group touches ---------------------------
    let mut vertex_faces: HashMap<u64, HashSet<u64>> = HashMap::default();
    for edge in &solid.edges {
        if let Some(uses) = faces_of_edge.get(&edge.id) {
            for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
                vertex_faces
                    .entry(vertex_id)
                    .or_default()
                    .extend(uses.iter().copied());
            }
        }
    }
    let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
    for vertex in &solid.vertices {
        let Some(adjacent) = vertex_faces.get(&vertex.id) else {
            continue;
        };
        if !adjacent.iter().any(|face_id| moved.contains(face_id)) {
            continue;
        }
        let fixed_at: Vec<u64> = adjacent
            .iter()
            .copied()
            .filter(|face_id| !moved.contains(face_id))
            .collect();
        if fixed_at.is_empty() {
            // Interior vertex: carried rigidly with the group.
            new_vertex.insert(vertex.id, vertex.point.add(translation));
            continue;
        }
        let mut fixed_planes = Vec::with_capacity(fixed_at.len());
        for &face_id in &fixed_at {
            fixed_planes.push(cached_plane(
                &mut planes,
                solid,
                &face_lookup,
                face_id,
                plane_tolerance,
            )?);
        }
        if fixed_planes
            .iter()
            .all(|plane| translation.dot(plane.normal).abs() <= parallel_tolerance)
        {
            // The translation is parallel to every fixed plane here, so the
            // rigidly carried corner stays exactly on all of them — and it
            // sits on every translated moved carrier by construction. Exact
            // for any moved surface type.
            new_vertex.insert(vertex.id, vertex.point.add(translation));
            continue;
        }
        // Genuine re-intersection: every carrier meeting at the corner must
        // be planar to solve the new corner in closed form.
        let mut corner_planes = fixed_planes;
        for face_id in adjacent
            .iter()
            .copied()
            .filter(|face_id| moved.contains(face_id))
        {
            let mut plane =
                cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)?;
            plane.origin = plane.origin.add(translation);
            corner_planes.push(plane);
        }
        let corner = solve_corner(&corner_planes).ok_or_else(|| {
            format!(
                "move_faces: cannot re-intersect the carriers meeting at vertex {} \
                 (parallel or under-constrained planes)",
                vertex.id
            )
        })?;
        // The corner must genuinely sit on EVERY carrier; otherwise the group
        // tears away from its fixed neighbours and no manifold heal exists.
        for plane in &corner_planes {
            if corner.sub(plane.origin).dot(plane.normal).abs() > tolerance {
                return Err(format!(
                    "move_faces: the moved group tears away from its neighbours at \
                     vertex {} — refusing rather than emitting an invalid solid",
                    vertex.id
                ));
            }
        }
        new_vertex.insert(vertex.id, corner);
    }

    // --- Plan every edge update -------------------------------------------
    let vertex_position: HashMap<u64, Vec3> = solid
        .vertices
        .iter()
        .map(|vertex| (vertex.id, vertex.point))
        .collect();
    let mut actions: HashMap<u64, EdgeMoveAction> = HashMap::default();
    for edge in &solid.edges {
        let position = |vertex_id: u64| -> Result<Vec3, String> {
            vertex_position
                .get(&vertex_id)
                .copied()
                .ok_or_else(|| format!("move_faces: missing vertex {vertex_id}"))
        };
        let start_old = position(edge.start_vertex_id)?;
        let end_old = position(edge.end_vertex_id)?;
        let start_new = new_vertex
            .get(&edge.start_vertex_id)
            .copied()
            .unwrap_or(start_old);
        let end_new = new_vertex
            .get(&edge.end_vertex_id)
            .copied()
            .unwrap_or(end_old);
        let rigid = start_new.sub(start_old.add(translation)).length() <= rigid_tolerance
            && end_new.sub(end_old.add(translation)).length() <= rigid_tolerance;
        match &classes[&edge.id] {
            EdgeMoveClass::Fixed => {
                if start_new.sub(start_old).length() == 0.0 && end_new.sub(end_old).length() == 0.0
                {
                    continue; // no endpoint relocated — the edge is untouched
                }
                // A fixed side edge follows its re-solved endpoint, exactly as
                // delete_face_and_heal relocates side edges onto recovered
                // corners. Its faces must be planar because they get re-trimmed.
                for &face_id in &faces_of_edge[&edge.id] {
                    cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)?;
                }
                actions.insert(
                    edge.id,
                    plan_straight_rebuild(edge, start_old, end_old, start_new, end_new, tolerance)?,
                );
            }
            EdgeMoveClass::Interior => {
                if rigid {
                    actions.insert(edge.id, EdgeMoveAction::Translate);
                } else {
                    // A tangential translation left the carriers in place, so
                    // an interior edge must stretch between re-solved corners
                    // instead of riding along (both faces are planar-checked).
                    for &face_id in &faces_of_edge[&edge.id] {
                        cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)?;
                    }
                    actions.insert(
                        edge.id,
                        plan_straight_rebuild(
                            edge, start_old, end_old, start_new, end_new, tolerance,
                        )?,
                    );
                }
            }
            EdgeMoveClass::Boundary {
                moved_face,
                fixed_face,
            } => {
                let fixed_plane = planes[fixed_face];
                if rigid && translation.dot(fixed_plane.normal).abs() <= parallel_tolerance {
                    // The whole edge slides inside the fixed plane while
                    // staying on the translated moved carrier — exact for any
                    // curve type, no re-intersection needed.
                    actions.insert(edge.id, EdgeMoveAction::Translate);
                } else {
                    // Real re-intersection: line = translated moved plane ∩
                    // fixed plane, delimited by the re-solved corners. The
                    // moved side must be planar for the chord to stay on it.
                    cached_plane(
                        &mut planes,
                        solid,
                        &face_lookup,
                        *moved_face,
                        plane_tolerance,
                    )?;
                    actions.insert(
                        edge.id,
                        plan_straight_rebuild(
                            edge, start_old, end_old, start_new, end_new, tolerance,
                        )?,
                    );
                }
            }
        }
    }

    // --- Plan face updates -------------------------------------------------
    let rebuilt: HashSet<u64> = actions
        .iter()
        .filter(|(_, action)| matches!(action, EdgeMoveAction::Rebuild { .. }))
        .map(|(edge_id, _)| *edge_id)
        .collect();
    let dirty: HashSet<u64> = actions.keys().copied().collect();
    let mut face_actions: Vec<(usize, usize, FaceMoveAction)> = Vec::new();
    for (shell_index, shell) in solid.shells.iter().enumerate() {
        for (face_index, face) in shell.faces.iter().enumerate() {
            let edge_ids = || {
                face.loops
                    .iter()
                    .flat_map(|loop_record| &loop_record.coedges)
                    .map(|coedge| coedge.edge_id)
            };
            if moved.contains(&face.id) {
                if edge_ids().any(|edge_id| rebuilt.contains(&edge_id)) {
                    // A boundary edge stretched, so the patch must be re-trimmed
                    // around it; only planar carriers extend for free in v1.
                    let mut plane =
                        cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)?;
                    plane.origin = plane.origin.add(translation);
                    face_actions.push((shell_index, face_index, FaceMoveAction::Retrim(plane)));
                } else {
                    // Every edge of the face rode along rigidly: shifting the
                    // control net keeps surface, curves, and pcurves in exact
                    // agreement for ANY carrier type.
                    face_actions.push((shell_index, face_index, FaceMoveAction::TranslateSurface));
                }
            } else if edge_ids().any(|edge_id| dirty.contains(&edge_id)) {
                let plane =
                    cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)?;
                face_actions.push((shell_index, face_index, FaceMoveAction::Retrim(plane)));
            }
        }
    }

    // --- Apply to a fresh clone (the input is never touched) ---------------
    let translate = AffineTransform::new([
        1.0,
        0.0,
        0.0,
        translation.x,
        0.0,
        1.0,
        0.0,
        translation.y,
        0.0,
        0.0,
        1.0,
        translation.z,
        0.0,
        0.0,
        0.0,
        1.0,
    ])?;
    let mut result = solid.clone();
    for edge in &mut result.edges {
        match actions.get(&edge.id) {
            Some(EdgeMoveAction::Translate) => {
                edge.curve = transform_curve(&edge.curve, translate)?;
            }
            Some(EdgeMoveAction::Rebuild { start, end }) => {
                edge.curve = make_line(*start, *end)?;
                edge.t0 = 0.0;
                edge.t1 = 1.0;
            }
            None => {}
        }
    }
    for vertex in &mut result.vertices {
        if let Some(point) = new_vertex.get(&vertex.id) {
            vertex.point = *point;
        }
    }
    let final_edges: HashMap<u64, EdgeRecord> = result
        .edges
        .iter()
        .map(|edge| (edge.id, edge.clone()))
        .collect();
    for (shell_index, face_index, action) in face_actions {
        let face = &mut result.shells[shell_index].faces[face_index];
        match action {
            FaceMoveAction::TranslateSurface => {
                face.surface = transform_surface(&face.surface, translate)?;
            }
            FaceMoveAction::Retrim(plane) => {
                retrim_planar_face(face, &plane, &final_edges, scale, "move_faces")?;
            }
        }
    }

    // Topology (and therefore genus) is untouched — only geometry moved — so
    // validate() re-checks Euler, loop closure, and pcurve agreement.
    let issues = result.validate();
    if !issues.is_empty() {
        return Err(format!(
            "move_faces: moved solid failed validation: {issues:?}"
        ));
    }
    // Belt and braces on top of the per-edge inversion guard: a global
    // inversion flips the signed volume even if every edge kept its direction.
    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
        if before * after <= 0.0 {
            return Err(
                "move_faces: the translation inverts the solid (signed volume changed sign) \
                 — refusing"
                    .into(),
            );
        }
    }
    Ok(result)
}