Skip to main content

brepkit_operations/
transform.rs

1//! Affine transforms applied to topological shapes.
2
3use std::collections::HashSet;
4
5use brepkit_math::mat::Mat4;
6use brepkit_math::nurbs::curve::NurbsCurve;
7use brepkit_math::nurbs::surface::NurbsSurface;
8use brepkit_math::tolerance::Tolerance;
9use brepkit_math::vec::Vec3;
10use brepkit_topology::Topology;
11use brepkit_topology::edge::{EdgeCurve, EdgeId};
12use brepkit_topology::face::{FaceId, FaceSurface};
13use brepkit_topology::solid::SolidId;
14use brepkit_topology::vertex::VertexId;
15use brepkit_topology::wire::WireId;
16
17/// Apply an affine transform to a solid, modifying vertex positions and
18/// face surface geometry in place.
19///
20/// The transform matrix must be non-degenerate (non-zero determinant).
21/// All unique vertices reachable from the solid's shells are transformed,
22/// NURBS edge curves and face surfaces have their control points updated,
23/// and all planar face normals are updated using the inverse transpose.
24///
25/// # Errors
26///
27/// Returns an error if the matrix is degenerate or a referenced entity is missing.
28#[allow(clippy::too_many_lines)]
29pub fn transform_solid(
30    topo: &mut Topology,
31    solid: SolidId,
32    matrix: &Mat4,
33) -> Result<(), crate::OperationsError> {
34    let tol = Tolerance::new();
35    if tol.approx_eq(matrix.determinant(), 0.0) {
36        return Err(crate::OperationsError::InvalidInput {
37            reason: "transform matrix is degenerate (zero determinant)".into(),
38        });
39    }
40
41    // Collect all unique vertex IDs, edge IDs, and face IDs in a read phase.
42    let (vertex_ids, edge_ids, face_ids) = collect_solid_entities(topo, solid)?;
43
44    // Mutate phase 1: transform each vertex.
45    for vid in vertex_ids {
46        let vertex = topo.vertex_mut(vid)?;
47        let new_point = matrix.mul_point(vertex.point());
48        vertex.set_point(new_point);
49    }
50
51    // Mutate phase 2: transform edge curves (NURBS, Circle, Ellipse).
52    transform_edges(topo, &edge_ids, matrix)?;
53
54    // Mutate phase 3: transform face surface geometry.
55    // For plane normals, use the inverse transpose: n' = (M⁻¹)ᵀ · n
56    let normal_matrix = matrix.inverse()?.transpose();
57
58    for fid in face_ids {
59        let face = topo.face(fid)?;
60        match face.surface() {
61            FaceSurface::Plane { normal, .. } => {
62                let n = *normal;
63                // Transform the normal via the inverse transpose (treating it as
64                // a direction, so we use mul_point on a point at (nx, ny, nz)
65                // and subtract the translation component).
66                let transformed =
67                    normal_matrix.mul_point(brepkit_math::vec::Point3::new(n.x(), n.y(), n.z()));
68                // Extract direction only (ignore any translation component from
69                // the inverse transpose by subtracting the origin transform).
70                let origin = normal_matrix.mul_point(brepkit_math::vec::Point3::new(0.0, 0.0, 0.0));
71                let raw = Vec3::new(
72                    transformed.x() - origin.x(),
73                    transformed.y() - origin.y(),
74                    transformed.z() - origin.z(),
75                );
76                let new_normal = raw.normalize()?;
77
78                // Recompute d from a transformed vertex on this face. We use
79                // the first vertex of the outer wire.
80                let wire = topo.wire(face.outer_wire())?;
81                let first_oe = &wire.edges()[0];
82                let edge = topo.edge(first_oe.edge())?;
83                let ref_vid = if first_oe.is_forward() {
84                    edge.start()
85                } else {
86                    edge.end()
87                };
88                let ref_point = topo.vertex(ref_vid)?.point();
89                let new_d = new_normal.dot(Vec3::new(ref_point.x(), ref_point.y(), ref_point.z()));
90
91                let face_mut = topo.face_mut(fid)?;
92                face_mut.set_surface(FaceSurface::Plane {
93                    normal: new_normal,
94                    d: new_d,
95                });
96            }
97            FaceSurface::Nurbs(s) => {
98                let new_control_points: Vec<Vec<_>> = s
99                    .control_points()
100                    .iter()
101                    .map(|row| row.iter().map(|pt| matrix.mul_point(*pt)).collect())
102                    .collect();
103                let new_surface = NurbsSurface::new(
104                    s.degree_u(),
105                    s.degree_v(),
106                    s.knots_u().to_vec(),
107                    s.knots_v().to_vec(),
108                    new_control_points,
109                    s.weights().to_vec(),
110                );
111                topo.face_mut(fid)?
112                    .set_surface(FaceSurface::Nurbs(new_surface?));
113            }
114            FaceSurface::Cylinder(cyl) => {
115                let new_origin = matrix.mul_point(cyl.origin());
116                let new_axis = transform_direction(matrix, cyl.axis())?;
117                // Scale radius: measure how the matrix scales a direction perpendicular to axis
118                let new_radius = scaled_radius(matrix, cyl.axis(), cyl.radius());
119                let new_cyl = brepkit_math::surfaces::CylindricalSurface::new(
120                    new_origin, new_axis, new_radius,
121                )?;
122                topo.face_mut(fid)?
123                    .set_surface(FaceSurface::Cylinder(new_cyl));
124            }
125            FaceSurface::Cone(cone) => {
126                if is_uniform_scale(matrix) {
127                    let new_apex = matrix.mul_point(cone.apex());
128                    let new_axis = transform_direction(matrix, cone.axis())?;
129                    let new_cone = brepkit_math::surfaces::ConicalSurface::new(
130                        new_apex,
131                        new_axis,
132                        cone.half_angle(),
133                    )?;
134                    topo.face_mut(fid)?.set_surface(FaceSurface::Cone(new_cone));
135                } else {
136                    let v_range = analytic_face_v_range(topo, fid, |pt| cone.project_point(pt).1)?;
137                    let cone_clone = cone.clone();
138                    // Use heal's exact rational cone converter (geometry's
139                    // delegates to math's sampled approximation; heal's is
140                    // geometrically exact). v_range is the cone-generator
141                    // distance from apex.
142                    let nurbs = brepkit_heal::construct::convert_surface::cone_to_nurbs(
143                        &cone_clone,
144                        v_range,
145                    )
146                    .map_err(|e| crate::OperationsError::InvalidInput {
147                        reason: format!("cone_to_nurbs failed: {e}"),
148                    })?;
149                    let transformed = transform_nurbs_surface(&nurbs, matrix)?;
150                    topo.face_mut(fid)?
151                        .set_surface(FaceSurface::Nurbs(transformed));
152                }
153            }
154            FaceSurface::Sphere(sph) => {
155                if is_uniform_scale(matrix) {
156                    let new_center = matrix.mul_point(sph.center());
157                    // Extract uniform scale factor from column magnitudes
158                    let m = &matrix.0;
159                    let sx = (m[0][0] * m[0][0] + m[1][0] * m[1][0] + m[2][0] * m[2][0]).sqrt();
160                    let new_sph = brepkit_math::surfaces::SphericalSurface::new(
161                        new_center,
162                        sph.radius() * sx,
163                    )?;
164                    topo.face_mut(fid)?
165                        .set_surface(FaceSurface::Sphere(new_sph));
166                } else {
167                    // Non-uniform scale: sample the face's v-range of the
168                    // sphere and refit as NURBS.
169                    let (v_min, v_max) = sphere_face_v_range(topo, fid, sph)?;
170                    let sph_clone = sph.clone();
171                    let nurbs = sphere_to_transformed_nurbs(&sph_clone, matrix, v_min, v_max)?;
172                    topo.face_mut(fid)?.set_surface(FaceSurface::Nurbs(nurbs));
173                }
174            }
175            FaceSurface::Torus(tor) => {
176                if is_uniform_scale(matrix) {
177                    let new_center = matrix.mul_point(tor.center());
178                    let m = &matrix.0;
179                    let sx = (m[0][0] * m[0][0] + m[1][0] * m[1][0] + m[2][0] * m[2][0]).sqrt();
180                    let new_tor = brepkit_math::surfaces::ToroidalSurface::new(
181                        new_center,
182                        tor.major_radius() * sx,
183                        tor.minor_radius() * sx,
184                    )?;
185                    topo.face_mut(fid)?.set_surface(FaceSurface::Torus(new_tor));
186                } else {
187                    let tor_clone = tor.clone();
188                    // Use heal's exact rational torus converter (geometry's
189                    // delegates to math's sampled approximation; heal's is
190                    // geometrically exact 9×9 tensor product).
191                    let nurbs =
192                        brepkit_heal::construct::convert_surface::torus_to_nurbs(&tor_clone)
193                            .map_err(|e| crate::OperationsError::InvalidInput {
194                                reason: format!("torus_to_nurbs failed: {e}"),
195                            })?;
196                    let transformed = transform_nurbs_surface(&nurbs, matrix)?;
197                    topo.face_mut(fid)?
198                        .set_surface(FaceSurface::Nurbs(transformed));
199                }
200            }
201        }
202    }
203
204    Ok(())
205}
206
207/// Determine the v-range (latitude) of a sphere face from its boundary.
208///
209/// Projects boundary vertices onto the sphere to find their latitudes,
210/// then uses the sign of the average vertex Z offset from center to
211/// determine which hemisphere the face covers.
212fn sphere_face_v_range(
213    topo: &Topology,
214    face_id: FaceId,
215    sph: &brepkit_math::surfaces::SphericalSurface,
216) -> Result<(f64, f64), crate::OperationsError> {
217    use std::f64::consts::FRAC_PI_2;
218
219    let face = topo.face(face_id)?;
220    let wire = topo.wire(face.outer_wire())?;
221    let mut v_vals = Vec::new();
222
223    for oe in wire.edges() {
224        let edge = topo.edge(oe.edge())?;
225        let pt = topo.vertex(edge.start())?.point();
226        let (_u, v) = sph.project_point(pt);
227        v_vals.push(v);
228    }
229
230    if v_vals.is_empty() {
231        // Full sphere with no boundary → full range
232        return Ok((-FRAC_PI_2, FRAC_PI_2));
233    }
234
235    // All boundary vertices should be at roughly the same v (equator).
236    // Determine hemisphere by checking whether face is above or below boundary.
237    let boundary_v = v_vals.iter().copied().sum::<f64>() / v_vals.len() as f64;
238
239    // Check which side: sample a face interior point. A simpler heuristic:
240    // if any inner wire exists, check it. Otherwise, examine the face's
241    // Newell normal direction relative to the sphere center.
242    //
243    // For brepkit's make_sphere: south hemisphere has normals pointing
244    // away from center with v ∈ [-π/2, boundary_v], north hemisphere
245    // v ∈ [boundary_v, π/2].
246    //
247    // Use a heuristic: compute the average Z of boundary relative to center
248    // and compare with the face's position hints.
249    let center = sph.center();
250    let avg_boundary_z: f64 = {
251        let mut sum = 0.0;
252        for oe in wire.edges() {
253            let edge = topo.edge(oe.edge())?;
254            let pt = topo.vertex(edge.start())?.point();
255            sum += pt.z() - center.z();
256        }
257        sum / wire.edges().len() as f64
258    };
259
260    // If the boundary is near the equator (avg_z ≈ 0), we need another way.
261    // Try to detect hemisphere by checking if the face has a pole vertex
262    // (a degenerate edge with a pole at v = ±π/2).
263    // Simpler approach: this is called before the transform, and make_sphere
264    // creates two faces. Just check if boundary_v ≈ 0 and pick hemispheres.
265    if boundary_v.abs() < 0.1 {
266        // Near equator: use face ordering. Check if this face has vertices
267        // near the north pole (z > center.z) or south pole (z < center.z).
268        // If avg_boundary_z is near 0, look for a degenerate pole vertex.
269        let mut has_pole_north = false;
270        let mut has_pole_south = false;
271        for oe in wire.edges() {
272            let edge = topo.edge(oe.edge())?;
273            if edge.start() == edge.end() {
274                let pt = topo.vertex(edge.start())?.point();
275                let dz = pt.z() - center.z();
276                if dz > 0.0 {
277                    has_pole_north = true;
278                } else {
279                    has_pole_south = true;
280                }
281            }
282        }
283        if has_pole_north {
284            return Ok((boundary_v, FRAC_PI_2));
285        }
286        if has_pole_south {
287            return Ok((-FRAC_PI_2, boundary_v));
288        }
289        // Default: use the winding direction. If first edge goes "forward" in
290        // parameter space, it's the north hemisphere.
291        // Fallback: just check avg Z of all edge midpoints would require
292        // curve evaluation. Use a simpler heuristic based on face ordering.
293        // The first face in make_sphere is south, second is north.
294        // This is fragile, but works for this specific case.
295        if avg_boundary_z >= 0.0 {
296            return Ok((boundary_v, FRAC_PI_2));
297        }
298        return Ok((-FRAC_PI_2, boundary_v));
299    }
300
301    if boundary_v > 0.0 {
302        Ok((boundary_v, FRAC_PI_2))
303    } else {
304        Ok((-FRAC_PI_2, boundary_v))
305    }
306}
307
308/// Check whether a transform matrix has uniform scaling (all axis scale
309/// factors are approximately equal). Non-uniform scaling distorts spheres
310/// into ellipsoids, so analytic representations must be converted to NURBS.
311/// Compute the scaled radius of a circle perpendicular to `axis` after transform.
312fn scaled_radius(matrix: &Mat4, axis: Vec3, radius: f64) -> f64 {
313    // Pick a direction perpendicular to the axis
314    let perp = if axis.x().abs() < 0.9 {
315        Vec3::new(1.0, 0.0, 0.0)
316            .cross(axis)
317            .normalize()
318            .unwrap_or(Vec3::new(1.0, 0.0, 0.0))
319    } else {
320        Vec3::new(0.0, 1.0, 0.0)
321            .cross(axis)
322            .normalize()
323            .unwrap_or(Vec3::new(0.0, 1.0, 0.0))
324    };
325    // Transform the perpendicular direction and measure its length
326    let origin = brepkit_math::vec::Point3::new(0.0, 0.0, 0.0);
327    let end =
328        brepkit_math::vec::Point3::new(perp.x() * radius, perp.y() * radius, perp.z() * radius);
329    let t_origin = matrix.mul_point(origin);
330    let t_end = matrix.mul_point(end);
331    let diff = t_end - t_origin;
332    diff.length()
333}
334
335/// Transform a single face's surface geometry.
336///
337/// The `normal_matrix` should be `matrix.inverse()?.transpose()`.
338#[allow(clippy::too_many_lines)]
339fn transform_face_surface(
340    topo: &mut Topology,
341    fid: FaceId,
342    matrix: &Mat4,
343    normal_matrix: &Mat4,
344) -> Result<(), crate::OperationsError> {
345    let face = topo.face(fid)?;
346    match face.surface() {
347        FaceSurface::Plane { normal, .. } => {
348            let n = *normal;
349            let transformed =
350                normal_matrix.mul_point(brepkit_math::vec::Point3::new(n.x(), n.y(), n.z()));
351            let origin = normal_matrix.mul_point(brepkit_math::vec::Point3::new(0.0, 0.0, 0.0));
352            let raw = Vec3::new(
353                transformed.x() - origin.x(),
354                transformed.y() - origin.y(),
355                transformed.z() - origin.z(),
356            );
357            let new_normal = raw.normalize()?;
358            let wire = topo.wire(face.outer_wire())?;
359            let first_oe =
360                wire.edges()
361                    .first()
362                    .ok_or_else(|| crate::OperationsError::InvalidInput {
363                        reason: "face has empty outer wire".into(),
364                    })?;
365            let edge = topo.edge(first_oe.edge())?;
366            let ref_vid = if first_oe.is_forward() {
367                edge.start()
368            } else {
369                edge.end()
370            };
371            let ref_point = topo.vertex(ref_vid)?.point();
372            let new_d = new_normal.dot(Vec3::new(ref_point.x(), ref_point.y(), ref_point.z()));
373            topo.face_mut(fid)?.set_surface(FaceSurface::Plane {
374                normal: new_normal,
375                d: new_d,
376            });
377        }
378        FaceSurface::Nurbs(s) => {
379            let new_control_points: Vec<Vec<_>> = s
380                .control_points()
381                .iter()
382                .map(|row| row.iter().map(|pt| matrix.mul_point(*pt)).collect())
383                .collect();
384            let new_surface = NurbsSurface::new(
385                s.degree_u(),
386                s.degree_v(),
387                s.knots_u().to_vec(),
388                s.knots_v().to_vec(),
389                new_control_points,
390                s.weights().to_vec(),
391            );
392            topo.face_mut(fid)?
393                .set_surface(FaceSurface::Nurbs(new_surface?));
394        }
395        FaceSurface::Cylinder(cyl) => {
396            let new_origin = matrix.mul_point(cyl.origin());
397            let new_axis = transform_direction(matrix, cyl.axis())?;
398            let new_radius = scaled_radius(matrix, cyl.axis(), cyl.radius());
399            let new_cyl =
400                brepkit_math::surfaces::CylindricalSurface::new(new_origin, new_axis, new_radius)?;
401            topo.face_mut(fid)?
402                .set_surface(FaceSurface::Cylinder(new_cyl));
403        }
404        FaceSurface::Cone(cone) => {
405            if is_uniform_scale(matrix) {
406                let new_apex = matrix.mul_point(cone.apex());
407                let new_axis = transform_direction(matrix, cone.axis())?;
408                let new_cone = brepkit_math::surfaces::ConicalSurface::new(
409                    new_apex,
410                    new_axis,
411                    cone.half_angle(),
412                )?;
413                topo.face_mut(fid)?.set_surface(FaceSurface::Cone(new_cone));
414            } else {
415                let v_range = analytic_face_v_range(topo, fid, |pt| cone.project_point(pt).1)?;
416                let cone_clone = cone.clone();
417                let nurbs =
418                    brepkit_heal::construct::convert_surface::cone_to_nurbs(&cone_clone, v_range)
419                        .map_err(|e| crate::OperationsError::InvalidInput {
420                        reason: format!("cone_to_nurbs failed: {e}"),
421                    })?;
422                let transformed = transform_nurbs_surface(&nurbs, matrix)?;
423                topo.face_mut(fid)?
424                    .set_surface(FaceSurface::Nurbs(transformed));
425            }
426        }
427        FaceSurface::Sphere(sph) => {
428            if is_uniform_scale(matrix) {
429                let new_center = matrix.mul_point(sph.center());
430                let m = &matrix.0;
431                let sx = (m[0][0] * m[0][0] + m[1][0] * m[1][0] + m[2][0] * m[2][0]).sqrt();
432                let new_sph =
433                    brepkit_math::surfaces::SphericalSurface::new(new_center, sph.radius() * sx)?;
434                topo.face_mut(fid)?
435                    .set_surface(FaceSurface::Sphere(new_sph));
436            } else {
437                let (v_min, v_max) = sphere_face_v_range(topo, fid, sph)?;
438                let sph_clone = sph.clone();
439                let nurbs = sphere_to_transformed_nurbs(&sph_clone, matrix, v_min, v_max)?;
440                topo.face_mut(fid)?.set_surface(FaceSurface::Nurbs(nurbs));
441            }
442        }
443        FaceSurface::Torus(tor) => {
444            if is_uniform_scale(matrix) {
445                let new_center = matrix.mul_point(tor.center());
446                let m = &matrix.0;
447                let sx = (m[0][0] * m[0][0] + m[1][0] * m[1][0] + m[2][0] * m[2][0]).sqrt();
448                let new_tor = brepkit_math::surfaces::ToroidalSurface::new(
449                    new_center,
450                    tor.major_radius() * sx,
451                    tor.minor_radius() * sx,
452                )?;
453                topo.face_mut(fid)?.set_surface(FaceSurface::Torus(new_tor));
454            } else {
455                let tor_clone = tor.clone();
456                let nurbs = brepkit_heal::construct::convert_surface::torus_to_nurbs(&tor_clone)
457                    .map_err(|e| crate::OperationsError::InvalidInput {
458                        reason: format!("torus_to_nurbs failed: {e}"),
459                    })?;
460                let transformed = transform_nurbs_surface(&nurbs, matrix)?;
461                topo.face_mut(fid)?
462                    .set_surface(FaceSurface::Nurbs(transformed));
463            }
464        }
465    }
466    Ok(())
467}
468
469/// Compute the v-parameter range for an analytic surface face.
470///
471/// Projects boundary vertices using `project_v` and returns (v_min, v_max).
472fn analytic_face_v_range(
473    topo: &Topology,
474    face_id: FaceId,
475    project_v: impl Fn(brepkit_math::vec::Point3) -> f64,
476) -> Result<(f64, f64), crate::OperationsError> {
477    let face = topo.face(face_id)?;
478    let wire = topo.wire(face.outer_wire())?;
479    let mut v_min = f64::INFINITY;
480    let mut v_max = f64::NEG_INFINITY;
481    for oe in wire.edges() {
482        let edge = topo.edge(oe.edge())?;
483        let pt = topo.vertex(edge.start())?.point();
484        let v = project_v(pt);
485        v_min = v_min.min(v);
486        v_max = v_max.max(v);
487    }
488    if v_min >= v_max {
489        v_min = 0.0;
490        v_max = 1.0;
491    }
492    Ok((v_min, v_max))
493}
494
495/// Transform a NURBS surface's control points by a matrix.
496fn transform_nurbs_surface(
497    surface: &NurbsSurface,
498    matrix: &Mat4,
499) -> Result<NurbsSurface, crate::OperationsError> {
500    let new_cps: Vec<Vec<_>> = surface
501        .control_points()
502        .iter()
503        .map(|row| row.iter().map(|pt| matrix.mul_point(*pt)).collect())
504        .collect();
505    Ok(NurbsSurface::new(
506        surface.degree_u(),
507        surface.degree_v(),
508        surface.knots_u().to_vec(),
509        surface.knots_v().to_vec(),
510        new_cps,
511        surface.weights().to_vec(),
512    )?)
513}
514
515fn is_uniform_scale(matrix: &Mat4) -> bool {
516    let m = &matrix.0;
517    // Column vector magnitudes of the upper-left 3×3
518    let sx = (m[0][0] * m[0][0] + m[1][0] * m[1][0] + m[2][0] * m[2][0]).sqrt();
519    let sy = (m[0][1] * m[0][1] + m[1][1] * m[1][1] + m[2][1] * m[2][1]).sqrt();
520    let sz = (m[0][2] * m[0][2] + m[1][2] * m[1][2] + m[2][2] * m[2][2]).sqrt();
521    let avg = (sx + sy + sz) / 3.0;
522    let rel = 0.01; // 1% tolerance
523    (sx - avg).abs() < avg * rel && (sy - avg).abs() < avg * rel && (sz - avg).abs() < avg * rel
524}
525
526/// Sample a spherical surface over a given v-range, transform the points
527/// with a matrix, and refit as a NURBS surface. This preserves the correct
528/// geometry when a non-uniform scale is applied (sphere → ellipsoid).
529#[allow(clippy::cast_precision_loss)]
530fn sphere_to_transformed_nurbs(
531    sph: &brepkit_math::surfaces::SphericalSurface,
532    matrix: &Mat4,
533    v_min: f64,
534    v_max: f64,
535) -> Result<NurbsSurface, crate::OperationsError> {
536    use std::f64::consts::TAU;
537
538    let n_u = 33; // Longitude samples (0 to 2π)
539    let n_v = 17; // Latitude samples
540
541    let mut rows: Vec<Vec<brepkit_math::vec::Point3>> = Vec::with_capacity(n_v);
542    for iv in 0..n_v {
543        let v = v_min + (v_max - v_min) * (iv as f64) / ((n_v - 1) as f64);
544        let mut row = Vec::with_capacity(n_u);
545        for iu in 0..n_u {
546            let u = TAU * (iu as f64) / ((n_u - 1) as f64);
547            let pt = sph.evaluate(u, v);
548            row.push(matrix.mul_point(pt));
549        }
550        rows.push(row);
551    }
552
553    let nurbs = brepkit_math::nurbs::surface_fitting::interpolate_surface(&rows, 3, 3)?;
554    Ok(nurbs)
555}
556
557/// Transforms a direction vector by applying the matrix and subtracting the
558/// translation component, then normalizing.
559fn transform_direction(matrix: &Mat4, dir: Vec3) -> Result<Vec3, crate::OperationsError> {
560    let origin = matrix.mul_point(brepkit_math::vec::Point3::new(0.0, 0.0, 0.0));
561    let tip = matrix.mul_point(brepkit_math::vec::Point3::new(dir.x(), dir.y(), dir.z()));
562    let raw = Vec3::new(
563        tip.x() - origin.x(),
564        tip.y() - origin.y(),
565        tip.z() - origin.z(),
566    );
567    Ok(raw.normalize()?)
568}
569
570/// Transform a set of edge curves in place.
571///
572/// Line edges need no update — their geometry is defined by vertices.
573#[allow(clippy::too_many_lines)]
574fn transform_edges(
575    topo: &mut Topology,
576    edge_ids: &HashSet<EdgeId>,
577    matrix: &Mat4,
578) -> Result<(), crate::OperationsError> {
579    let origin = matrix.mul_point(brepkit_math::vec::Point3::new(0.0, 0.0, 0.0));
580    let transform_dir = |d: Vec3| -> Vec3 {
581        matrix.mul_point(brepkit_math::vec::Point3::new(d.x(), d.y(), d.z())) - origin
582    };
583    for &eid in edge_ids {
584        let edge = topo.edge(eid)?;
585        let new_curve = match edge.curve() {
586            EdgeCurve::Line => None,
587            EdgeCurve::NurbsCurve(c) => {
588                let new_control_points: Vec<_> = c
589                    .control_points()
590                    .iter()
591                    .map(|pt| matrix.mul_point(*pt))
592                    .collect();
593                Some(EdgeCurve::NurbsCurve(NurbsCurve::new(
594                    c.degree(),
595                    c.knots().to_vec(),
596                    new_control_points,
597                    c.weights().to_vec(),
598                )?))
599            }
600            EdgeCurve::Circle(c) => {
601                let new_center = matrix.mul_point(c.center());
602                let new_u = transform_dir(c.u_axis());
603                let new_v = transform_dir(c.v_axis());
604                let su = new_u.length();
605                let sv = new_v.length();
606                let new_normal = new_u.cross(new_v).normalize()?;
607                if (su - sv).abs() < 1e-12 * su.max(sv).max(1.0) {
608                    Some(EdgeCurve::Circle(
609                        brepkit_math::curves::Circle3D::with_axes(
610                            new_center,
611                            new_normal,
612                            c.radius() * su,
613                            new_u.normalize()?,
614                            new_v.normalize()?,
615                        )?,
616                    ))
617                } else {
618                    let (semi_major, semi_minor, u_dir, v_dir) = if su >= sv {
619                        (
620                            c.radius() * su,
621                            c.radius() * sv,
622                            new_u.normalize()?,
623                            new_v.normalize()?,
624                        )
625                    } else {
626                        (
627                            c.radius() * sv,
628                            c.radius() * su,
629                            new_v.normalize()?,
630                            new_u.normalize()?,
631                        )
632                    };
633                    Some(EdgeCurve::Ellipse(
634                        brepkit_math::curves::Ellipse3D::with_axes(
635                            new_center, new_normal, semi_major, semi_minor, u_dir, v_dir,
636                        )?,
637                    ))
638                }
639            }
640            EdgeCurve::Ellipse(e) => {
641                let new_center = matrix.mul_point(e.center());
642                let new_u = transform_dir(e.u_axis());
643                let new_v = transform_dir(e.v_axis());
644                let new_normal = new_u.cross(new_v).normalize()?;
645                Some(EdgeCurve::Ellipse(
646                    brepkit_math::curves::Ellipse3D::with_axes(
647                        new_center,
648                        new_normal,
649                        e.semi_major() * new_u.length(),
650                        e.semi_minor() * new_v.length(),
651                        new_u.normalize()?,
652                        new_v.normalize()?,
653                    )?,
654                ))
655            }
656        };
657        if let Some(curve) = new_curve {
658            topo.edge_mut(eid)?.set_curve(curve);
659        }
660    }
661    Ok(())
662}
663
664/// Apply an affine transform to a wire, modifying vertex positions and
665/// edge curve geometry in place.
666///
667/// # Errors
668///
669/// Returns an error if the matrix is degenerate or a referenced entity is missing.
670pub fn transform_wire(
671    topo: &mut Topology,
672    wire_id: WireId,
673    matrix: &Mat4,
674) -> Result<(), crate::OperationsError> {
675    let tol = Tolerance::new();
676    if tol.approx_eq(matrix.determinant(), 0.0) {
677        return Err(crate::OperationsError::InvalidInput {
678            reason: "transform matrix is degenerate (zero determinant)".into(),
679        });
680    }
681
682    let (vertex_ids, edge_ids) = collect_wire_entities(topo, wire_id)?;
683
684    // Transform vertices.
685    for vid in vertex_ids {
686        let vertex = topo.vertex_mut(vid)?;
687        let new_point = matrix.mul_point(vertex.point());
688        vertex.set_point(new_point);
689    }
690
691    // Transform edge curves.
692    transform_edges(topo, &edge_ids, matrix)?;
693
694    Ok(())
695}
696
697/// Apply an affine transform to a face, modifying vertex positions, edge
698/// curve geometry, and the face surface in place.
699///
700/// Transforms all vertices/edges in the face's outer and inner wires, then
701/// updates the face surface geometry (plane normal, NURBS CPs, etc.).
702///
703/// # Errors
704///
705/// Returns an error if the matrix is degenerate or a referenced entity is missing.
706#[allow(clippy::too_many_lines)]
707pub fn transform_face(
708    topo: &mut Topology,
709    face_id: FaceId,
710    matrix: &Mat4,
711) -> Result<(), crate::OperationsError> {
712    let tol = Tolerance::new();
713    if tol.approx_eq(matrix.determinant(), 0.0) {
714        return Err(crate::OperationsError::InvalidInput {
715            reason: "transform matrix is degenerate (zero determinant)".into(),
716        });
717    }
718
719    // Collect all vertices and edges from the face's wires.
720    let (vertex_ids, edge_ids) = collect_face_entities(topo, face_id)?;
721
722    // Transform vertices.
723    for vid in vertex_ids {
724        let vertex = topo.vertex_mut(vid)?;
725        let new_point = matrix.mul_point(vertex.point());
726        vertex.set_point(new_point);
727    }
728
729    // Transform edge curves.
730    transform_edges(topo, &edge_ids, matrix)?;
731
732    // Transform face surface.
733    let normal_matrix = matrix.inverse()?.transpose();
734    transform_face_surface(topo, face_id, matrix, &normal_matrix)?;
735
736    Ok(())
737}
738
739/// Traverses face → wires → edges → vertices and returns deduplicated sets.
740fn collect_face_entities(
741    topo: &Topology,
742    face_id: FaceId,
743) -> Result<(HashSet<VertexId>, HashSet<EdgeId>), crate::OperationsError> {
744    let mut vertex_ids = HashSet::new();
745    let mut edge_ids = HashSet::new();
746    let face = topo.face(face_id)?;
747    let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
748        .chain(face.inner_wires().iter().copied())
749        .collect();
750
751    for wid in wire_ids {
752        let wire = topo.wire(wid)?;
753        for oe in wire.edges() {
754            let eid = oe.edge();
755            edge_ids.insert(eid);
756            let edge = topo.edge(eid)?;
757            vertex_ids.insert(edge.start());
758            vertex_ids.insert(edge.end());
759        }
760    }
761
762    Ok((vertex_ids, edge_ids))
763}
764
765/// Traverses wire → edges → vertices and returns deduplicated sets.
766fn collect_wire_entities(
767    topo: &Topology,
768    wire_id: WireId,
769) -> Result<(HashSet<VertexId>, HashSet<EdgeId>), crate::OperationsError> {
770    let mut vertex_ids = HashSet::new();
771    let mut edge_ids = HashSet::new();
772    let wire = topo.wire(wire_id)?;
773    for oe in wire.edges() {
774        let eid = oe.edge();
775        edge_ids.insert(eid);
776        let edge = topo.edge(eid)?;
777        vertex_ids.insert(edge.start());
778        vertex_ids.insert(edge.end());
779    }
780    Ok((vertex_ids, edge_ids))
781}
782
783/// Traverses solid → shells → faces → wires → edges → vertices and
784/// returns deduplicated sets of vertex IDs, edge IDs, and face IDs.
785#[allow(clippy::type_complexity)]
786fn collect_solid_entities(
787    topo: &Topology,
788    solid: SolidId,
789) -> Result<(HashSet<VertexId>, HashSet<EdgeId>, HashSet<FaceId>), crate::OperationsError> {
790    let mut vertex_ids = HashSet::new();
791    let mut edge_ids = HashSet::new();
792    let mut face_ids = HashSet::new();
793    let solid_data = topo.solid(solid)?;
794    let shell_ids: Vec<_> = std::iter::once(solid_data.outer_shell())
795        .chain(solid_data.inner_shells().iter().copied())
796        .collect();
797
798    for shell_id in shell_ids {
799        let shell = topo.shell(shell_id)?;
800        let fids: Vec<_> = shell.faces().to_vec();
801
802        for face_id in fids {
803            face_ids.insert(face_id);
804            let face = topo.face(face_id)?;
805            let wire_ids: Vec<_> = std::iter::once(face.outer_wire())
806                .chain(face.inner_wires().iter().copied())
807                .collect();
808
809            for wire_id in wire_ids {
810                let wire = topo.wire(wire_id)?;
811                for oe in wire.edges() {
812                    let eid = oe.edge();
813                    edge_ids.insert(eid);
814                    let edge = topo.edge(eid)?;
815                    vertex_ids.insert(edge.start());
816                    vertex_ids.insert(edge.end());
817                }
818            }
819        }
820    }
821
822    Ok((vertex_ids, edge_ids, face_ids))
823}
824
825#[cfg(test)]
826mod tests;