Skip to main content

brep_kernel/edit/direct_edit/
face_move.rs

1use super::*;
2
3// ---------------------------------------------------------------------------
4// §6.12 sibling operation: move a face group.
5// ---------------------------------------------------------------------------
6
7/// How the moved group uses an edge: not at all, on both sides (carried
8/// rigidly), or on exactly one side (the seam to re-intersect).
9enum EdgeMoveClass {
10    Fixed,
11    Interior,
12    Boundary { moved_face: u64, fixed_face: u64 },
13}
14
15enum EdgeMoveAction {
16    /// Translate the curve rigidly; parameters and pcurves stay exact.
17    Translate,
18    /// Replace the curve by the straight line between re-solved endpoints.
19    Rebuild { start: Vec3, end: Vec3 },
20}
21
22enum FaceMoveAction {
23    /// Shift the whole control net; pcurves stay exact for any carrier type.
24    TranslateSurface,
25    /// Rebuild the (planar) carrier around the new boundary and recompute
26    /// every pcurve — the direct-edit equivalent of "extend the neighbour".
27    Retrim(Plane),
28}
29
30/// `plane_of_surface` with per-face memoisation, because a face is consulted
31/// once per boundary edge, once per touched vertex, and once at re-trim time.
32fn cached_plane(
33    cache: &mut HashMap<u64, Plane>,
34    solid: &BrepSolid,
35    face_lookup: &HashMap<u64, (usize, usize)>,
36    face_id: u64,
37    tolerance: f64,
38) -> Result<Plane, String> {
39    if let Some(plane) = cache.get(&face_id) {
40        return Ok(*plane);
41    }
42    let (shell_index, face_index) = *face_lookup
43        .get(&face_id)
44        .ok_or_else(|| format!("move_faces: missing face {face_id}"))?;
45    let plane = plane_of_surface(
46        &solid.shells[shell_index].faces[face_index].surface,
47        tolerance,
48        "move_faces",
49    )?;
50    cache.insert(face_id, plane);
51    Ok(plane)
52}
53
54/// Intersect three-or-more planes in one point: take the best-conditioned
55/// pair for the line, then the plane most transverse to that line for the
56/// point. The caller checks the residual against EVERY plane afterwards, so
57/// this only has to find *a* candidate, not prove consistency.
58fn solve_corner(planes: &[Plane]) -> Option<Vec3> {
59    let mut best_pair: Option<(usize, usize, f64)> = None;
60    for first in 0..planes.len() {
61        for second in first + 1..planes.len() {
62            let spread = planes[first].normal.cross(planes[second].normal).length();
63            if best_pair.map(|(_, _, best)| spread > best).unwrap_or(true) {
64                best_pair = Some((first, second, spread));
65            }
66        }
67    }
68    let (first, second, spread) = best_pair?;
69    if spread <= PARALLEL_EPS {
70        return None;
71    }
72    let line = intersect_planes(&planes[first], &planes[second])?;
73    let mut best_third: Option<(usize, f64)> = None;
74    for third in 0..planes.len() {
75        if third == first || third == second {
76            continue;
77        }
78        let transversality = line.dir.dot(planes[third].normal).abs();
79        if best_third
80            .map(|(_, best)| transversality > best)
81            .unwrap_or(true)
82        {
83            best_third = Some((third, transversality));
84        }
85    }
86    let (third, transversality) = best_third?;
87    if transversality <= PARALLEL_EPS {
88        return None;
89    }
90    intersect_line_plane(&line, &planes[third])
91}
92
93/// Rebuild a straight edge between two re-solved endpoints. Refuses the
94/// degenerate and inverted cases — a zero or reversed chord means the
95/// translation drove a moved face onto or past the neighbour this edge
96/// belongs to (e.g. pushing a box face through its opposite face).
97fn plan_straight_rebuild(
98    edge: &EdgeRecord,
99    start_old: Vec3,
100    end_old: Vec3,
101    start_new: Vec3,
102    end_new: Vec3,
103    tolerance: f64,
104) -> Result<EdgeMoveAction, String> {
105    if edge.degenerate {
106        return Err(format!(
107            "move_faces: degenerate edge {} would need re-stretching (deferred)",
108            edge.id
109        ));
110    }
111    if edge.curve.degree != 1 || edge.curve.control_points.len() != 2 {
112        return Err(format!(
113            "move_faces: edge {} must be re-stretched but is not a straight line \
114             (curved re-intersection edges are deferred in this slice)",
115            edge.id
116        ));
117    }
118    let new_chord = end_new.sub(start_new);
119    if new_chord.length() <= tolerance {
120        return Err(format!(
121            "move_faces: the translation collapses edge {} to zero length (a moved \
122             face lands exactly on its neighbour) — refusing",
123            edge.id
124        ));
125    }
126    if end_old.sub(start_old).dot(new_chord) <= 0.0 {
127        return Err(format!(
128            "move_faces: the translation inverts edge {} (a moved face passes beyond \
129             its neighbour) — refusing",
130            edge.id
131        ));
132    }
133    Ok(EdgeMoveAction::Rebuild {
134        start: start_new,
135        end: end_new,
136    })
137}
138
139/// Golovanov §6.12 direct editing — translate a group of faces rigidly and
140/// heal the adjacency with the faces that stay behind.
141///
142/// The moved carriers translate exactly (every control point shifts by the
143/// translation, which is exact for ANY surface type), and each boundary edge
144/// between a moved face and a fixed face is recomputed as the intersection of
145/// the translated moved carrier with the fixed carrier:
146///
147/// - When the translation is parallel to every fixed plane a boundary vertex
148///   touches, the whole neighbourhood translates rigidly — exact for any
149///   moved carrier and any edge curve type. This is the extrude-like case:
150///   pushing a face along its own normal slides the side walls in-plane.
151/// - Otherwise the new corner is re-solved as the common point of ALL carrier
152///   planes meeting at the vertex (moved ones translated), and every affected
153///   straight edge is rebuilt between the re-solved corners — the same
154///   relocate-onto-recovered-corners move `delete_face_and_heal` performs on
155///   its side edges.
156///
157/// v1 scope (honest refusals, never a bad solid): the moved faces may be any
158/// surface type, but every FIXED face that must be re-intersected — the fixed
159/// side of each boundary edge, and any face whose boundary edges must be
160/// rebuilt — must be PLANAR, and every rebuilt edge must be a straight line.
161/// A translation that collapses an adjacent edge to zero length or reverses
162/// its direction (moving a box face onto or past its opposite face) is
163/// refused, as is a group that tears away from its neighbours. The input is
164/// never mutated; the result is returned only when `validate()` is clean.
165pub fn move_faces(
166    solid: &BrepSolid,
167    face_ids: &[u64],
168    translation: Vec3,
169) -> Result<BrepSolid, String> {
170    if !(translation.x.is_finite() && translation.y.is_finite() && translation.z.is_finite()) {
171        return Err("move_faces: translation must be finite".into());
172    }
173    if face_ids.is_empty() {
174        return Err("move_faces: no faces selected".into());
175    }
176    let moved: HashSet<u64> = face_ids.iter().copied().collect();
177    // face id -> (shell, face) built once. move_faces never mutates `solid`,
178    // so this replaces the O(faces) `find_face` scans in the validation loop
179    // below and in `cached_plane` (called up to once per unique fixed face).
180    // `or_insert` keeps the first match, mirroring `find_face`.
181    let mut face_lookup: HashMap<u64, (usize, usize)> = HashMap::default();
182    for (shell_index, shell) in solid.shells.iter().enumerate() {
183        for (face_index, face) in shell.faces.iter().enumerate() {
184            face_lookup
185                .entry(face.id)
186                .or_insert((shell_index, face_index));
187        }
188    }
189    for &face_id in face_ids {
190        if !face_lookup.contains_key(&face_id) {
191            return Err(format!("move_faces: no face with id {face_id}"));
192        }
193    }
194
195    let scale = solid_scale(solid);
196    let tolerance = (scale * 1e-7).max(1e-9);
197    let plane_tolerance = (scale * 1e-6).max(1e-7);
198    // "Parallel to a fixed plane" means the translation's normal component
199    // could not move any point off that plane at model precision.
200    let parallel_tolerance = (translation.length() * 1e-9).max(1e-12);
201    // "Rigid" endpoints moved by exactly the translation (they are assigned
202    // `point + translation` verbatim, so this only absorbs rounding noise).
203    let rigid_tolerance = (scale * 1e-9).max(1e-12);
204
205    // --- Classify every edge by how the group uses it ----------------------
206    let mut faces_of_edge: HashMap<u64, Vec<u64>> = HashMap::default();
207    for shell in &solid.shells {
208        for face in &shell.faces {
209            for loop_record in &face.loops {
210                for coedge in &loop_record.coedges {
211                    faces_of_edge
212                        .entry(coedge.edge_id)
213                        .or_default()
214                        .push(face.id);
215                }
216            }
217        }
218    }
219    let mut classes: HashMap<u64, EdgeMoveClass> = HashMap::default();
220    let mut planes: HashMap<u64, Plane> = HashMap::default();
221    for edge in &solid.edges {
222        let uses = faces_of_edge
223            .get(&edge.id)
224            .map(Vec::as_slice)
225            .unwrap_or(&[]);
226        let expected = if edge.degenerate { 1 } else { 2 };
227        if uses.len() != expected {
228            return Err(format!(
229                "move_faces: edge {} is used {} times (non-manifold input)",
230                edge.id,
231                uses.len()
232            ));
233        }
234        let moved_uses = uses
235            .iter()
236            .filter(|face_id| moved.contains(*face_id))
237            .count();
238        let class = if moved_uses == 0 {
239            EdgeMoveClass::Fixed
240        } else if moved_uses == uses.len() {
241            EdgeMoveClass::Interior
242        } else {
243            let moved_face = *uses
244                .iter()
245                .find(|face_id| moved.contains(*face_id))
246                .unwrap();
247            let fixed_face = *uses
248                .iter()
249                .find(|face_id| !moved.contains(*face_id))
250                .unwrap();
251            // v1 scope gate: the face left behind across every boundary edge
252            // is the carrier we re-intersect against, so it must be planar.
253            cached_plane(
254                &mut planes,
255                solid,
256                &face_lookup,
257                fixed_face,
258                plane_tolerance,
259            )?;
260            EdgeMoveClass::Boundary {
261                moved_face,
262                fixed_face,
263            }
264        };
265        classes.insert(edge.id, class);
266    }
267
268    // --- Relocate every vertex the group touches ---------------------------
269    let mut vertex_faces: HashMap<u64, HashSet<u64>> = HashMap::default();
270    for edge in &solid.edges {
271        if let Some(uses) = faces_of_edge.get(&edge.id) {
272            for vertex_id in [edge.start_vertex_id, edge.end_vertex_id] {
273                vertex_faces
274                    .entry(vertex_id)
275                    .or_default()
276                    .extend(uses.iter().copied());
277            }
278        }
279    }
280    let mut new_vertex: HashMap<u64, Vec3> = HashMap::default();
281    for vertex in &solid.vertices {
282        let Some(adjacent) = vertex_faces.get(&vertex.id) else {
283            continue;
284        };
285        if !adjacent.iter().any(|face_id| moved.contains(face_id)) {
286            continue;
287        }
288        let fixed_at: Vec<u64> = adjacent
289            .iter()
290            .copied()
291            .filter(|face_id| !moved.contains(face_id))
292            .collect();
293        if fixed_at.is_empty() {
294            // Interior vertex: carried rigidly with the group.
295            new_vertex.insert(vertex.id, vertex.point.add(translation));
296            continue;
297        }
298        let mut fixed_planes = Vec::with_capacity(fixed_at.len());
299        for &face_id in &fixed_at {
300            fixed_planes.push(cached_plane(
301                &mut planes,
302                solid,
303                &face_lookup,
304                face_id,
305                plane_tolerance,
306            )?);
307        }
308        if fixed_planes
309            .iter()
310            .all(|plane| translation.dot(plane.normal).abs() <= parallel_tolerance)
311        {
312            // The translation is parallel to every fixed plane here, so the
313            // rigidly carried corner stays exactly on all of them — and it
314            // sits on every translated moved carrier by construction. Exact
315            // for any moved surface type.
316            new_vertex.insert(vertex.id, vertex.point.add(translation));
317            continue;
318        }
319        // Genuine re-intersection: every carrier meeting at the corner must
320        // be planar to solve the new corner in closed form.
321        let mut corner_planes = fixed_planes;
322        for face_id in adjacent
323            .iter()
324            .copied()
325            .filter(|face_id| moved.contains(face_id))
326        {
327            let mut plane =
328                cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)?;
329            plane.origin = plane.origin.add(translation);
330            corner_planes.push(plane);
331        }
332        let corner = solve_corner(&corner_planes).ok_or_else(|| {
333            format!(
334                "move_faces: cannot re-intersect the carriers meeting at vertex {} \
335                 (parallel or under-constrained planes)",
336                vertex.id
337            )
338        })?;
339        // The corner must genuinely sit on EVERY carrier; otherwise the group
340        // tears away from its fixed neighbours and no manifold heal exists.
341        for plane in &corner_planes {
342            if corner.sub(plane.origin).dot(plane.normal).abs() > tolerance {
343                return Err(format!(
344                    "move_faces: the moved group tears away from its neighbours at \
345                     vertex {} — refusing rather than emitting an invalid solid",
346                    vertex.id
347                ));
348            }
349        }
350        new_vertex.insert(vertex.id, corner);
351    }
352
353    // --- Plan every edge update -------------------------------------------
354    let vertex_position: HashMap<u64, Vec3> = solid
355        .vertices
356        .iter()
357        .map(|vertex| (vertex.id, vertex.point))
358        .collect();
359    let mut actions: HashMap<u64, EdgeMoveAction> = HashMap::default();
360    for edge in &solid.edges {
361        let position = |vertex_id: u64| -> Result<Vec3, String> {
362            vertex_position
363                .get(&vertex_id)
364                .copied()
365                .ok_or_else(|| format!("move_faces: missing vertex {vertex_id}"))
366        };
367        let start_old = position(edge.start_vertex_id)?;
368        let end_old = position(edge.end_vertex_id)?;
369        let start_new = new_vertex
370            .get(&edge.start_vertex_id)
371            .copied()
372            .unwrap_or(start_old);
373        let end_new = new_vertex
374            .get(&edge.end_vertex_id)
375            .copied()
376            .unwrap_or(end_old);
377        let rigid = start_new.sub(start_old.add(translation)).length() <= rigid_tolerance
378            && end_new.sub(end_old.add(translation)).length() <= rigid_tolerance;
379        match &classes[&edge.id] {
380            EdgeMoveClass::Fixed => {
381                if start_new.sub(start_old).length() == 0.0 && end_new.sub(end_old).length() == 0.0
382                {
383                    continue; // no endpoint relocated — the edge is untouched
384                }
385                // A fixed side edge follows its re-solved endpoint, exactly as
386                // delete_face_and_heal relocates side edges onto recovered
387                // corners. Its faces must be planar because they get re-trimmed.
388                for &face_id in &faces_of_edge[&edge.id] {
389                    cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)?;
390                }
391                actions.insert(
392                    edge.id,
393                    plan_straight_rebuild(edge, start_old, end_old, start_new, end_new, tolerance)?,
394                );
395            }
396            EdgeMoveClass::Interior => {
397                if rigid {
398                    actions.insert(edge.id, EdgeMoveAction::Translate);
399                } else {
400                    // A tangential translation left the carriers in place, so
401                    // an interior edge must stretch between re-solved corners
402                    // instead of riding along (both faces are planar-checked).
403                    for &face_id in &faces_of_edge[&edge.id] {
404                        cached_plane(&mut planes, solid, &face_lookup, face_id, plane_tolerance)?;
405                    }
406                    actions.insert(
407                        edge.id,
408                        plan_straight_rebuild(
409                            edge, start_old, end_old, start_new, end_new, tolerance,
410                        )?,
411                    );
412                }
413            }
414            EdgeMoveClass::Boundary {
415                moved_face,
416                fixed_face,
417            } => {
418                let fixed_plane = planes[fixed_face];
419                if rigid && translation.dot(fixed_plane.normal).abs() <= parallel_tolerance {
420                    // The whole edge slides inside the fixed plane while
421                    // staying on the translated moved carrier — exact for any
422                    // curve type, no re-intersection needed.
423                    actions.insert(edge.id, EdgeMoveAction::Translate);
424                } else {
425                    // Real re-intersection: line = translated moved plane ∩
426                    // fixed plane, delimited by the re-solved corners. The
427                    // moved side must be planar for the chord to stay on it.
428                    cached_plane(
429                        &mut planes,
430                        solid,
431                        &face_lookup,
432                        *moved_face,
433                        plane_tolerance,
434                    )?;
435                    actions.insert(
436                        edge.id,
437                        plan_straight_rebuild(
438                            edge, start_old, end_old, start_new, end_new, tolerance,
439                        )?,
440                    );
441                }
442            }
443        }
444    }
445
446    // --- Plan face updates -------------------------------------------------
447    let rebuilt: HashSet<u64> = actions
448        .iter()
449        .filter(|(_, action)| matches!(action, EdgeMoveAction::Rebuild { .. }))
450        .map(|(edge_id, _)| *edge_id)
451        .collect();
452    let dirty: HashSet<u64> = actions.keys().copied().collect();
453    let mut face_actions: Vec<(usize, usize, FaceMoveAction)> = Vec::new();
454    for (shell_index, shell) in solid.shells.iter().enumerate() {
455        for (face_index, face) in shell.faces.iter().enumerate() {
456            let edge_ids = || {
457                face.loops
458                    .iter()
459                    .flat_map(|loop_record| &loop_record.coedges)
460                    .map(|coedge| coedge.edge_id)
461            };
462            if moved.contains(&face.id) {
463                if edge_ids().any(|edge_id| rebuilt.contains(&edge_id)) {
464                    // A boundary edge stretched, so the patch must be re-trimmed
465                    // around it; only planar carriers extend for free in v1.
466                    let mut plane =
467                        cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)?;
468                    plane.origin = plane.origin.add(translation);
469                    face_actions.push((shell_index, face_index, FaceMoveAction::Retrim(plane)));
470                } else {
471                    // Every edge of the face rode along rigidly: shifting the
472                    // control net keeps surface, curves, and pcurves in exact
473                    // agreement for ANY carrier type.
474                    face_actions.push((shell_index, face_index, FaceMoveAction::TranslateSurface));
475                }
476            } else if edge_ids().any(|edge_id| dirty.contains(&edge_id)) {
477                let plane =
478                    cached_plane(&mut planes, solid, &face_lookup, face.id, plane_tolerance)?;
479                face_actions.push((shell_index, face_index, FaceMoveAction::Retrim(plane)));
480            }
481        }
482    }
483
484    // --- Apply to a fresh clone (the input is never touched) ---------------
485    let translate = AffineTransform::new([
486        1.0,
487        0.0,
488        0.0,
489        translation.x,
490        0.0,
491        1.0,
492        0.0,
493        translation.y,
494        0.0,
495        0.0,
496        1.0,
497        translation.z,
498        0.0,
499        0.0,
500        0.0,
501        1.0,
502    ])?;
503    let mut result = solid.clone();
504    for edge in &mut result.edges {
505        match actions.get(&edge.id) {
506            Some(EdgeMoveAction::Translate) => {
507                edge.curve = transform_curve(&edge.curve, translate)?;
508            }
509            Some(EdgeMoveAction::Rebuild { start, end }) => {
510                edge.curve = make_line(*start, *end)?;
511                edge.t0 = 0.0;
512                edge.t1 = 1.0;
513            }
514            None => {}
515        }
516    }
517    for vertex in &mut result.vertices {
518        if let Some(point) = new_vertex.get(&vertex.id) {
519            vertex.point = *point;
520        }
521    }
522    let final_edges: HashMap<u64, EdgeRecord> = result
523        .edges
524        .iter()
525        .map(|edge| (edge.id, edge.clone()))
526        .collect();
527    for (shell_index, face_index, action) in face_actions {
528        let face = &mut result.shells[shell_index].faces[face_index];
529        match action {
530            FaceMoveAction::TranslateSurface => {
531                face.surface = transform_surface(&face.surface, translate)?;
532            }
533            FaceMoveAction::Retrim(plane) => {
534                retrim_planar_face(face, &plane, &final_edges, scale, "move_faces")?;
535            }
536        }
537    }
538
539    // Topology (and therefore genus) is untouched — only geometry moved — so
540    // validate() re-checks Euler, loop closure, and pcurve agreement.
541    let issues = result.validate();
542    if !issues.is_empty() {
543        return Err(format!(
544            "move_faces: moved solid failed validation: {issues:?}"
545        ));
546    }
547    // Belt and braces on top of the per-edge inversion guard: a global
548    // inversion flips the signed volume even if every edge kept its direction.
549    if let (Ok(before), Ok(after)) = (solid_signed_volume(solid), solid_signed_volume(&result)) {
550        if before * after <= 0.0 {
551            return Err(
552                "move_faces: the translation inverts the solid (signed volume changed sign) \
553                 — refusing"
554                    .into(),
555            );
556        }
557    }
558    Ok(result)
559}