axiolid-construct 0.2.1

Solid generation: profiles, lofts, sweeps, revolutions and half-space clipping
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
//! Extrude an arc-capable cross-section into an exact B-rep (ADR 0050).
//!
//! # Why this is a second extruder
//!
//! `extrude_polygon_rings` builds a planar wall per edge. An arc edge
//! sweeps a CYLINDRICAL wall instead, so the surface, the 3D curve and the
//! parameter-space curves all differ. Rather than thread a branch through
//! the polygon extruder, the arc case is built here and the polygon path
//! is left untouched.
//!
//! # Exactness
//!
//! An arc edge becomes a `Circle3` bound to the swept `Cylinder` surface:
//! no sampling, no tessellation. The bulge is converted to centre, radius
//! and sweep by closed form, verified against the defining circle before
//! any B-rep is built.

use axiolid_brep::{ExactBRep, ExactBRepBuilder, FaceName, SweptFace};
use axiolid_contracts::{GeomError, GeomResult};
use axiolid_core::{Frame2, Frame3, Interval, Point2, Point3, Scalar, Vec2, Vec3};
use axiolid_curve::{Circle2, Circle3, Curve2, Curve3, Line2, Line3};
use axiolid_overlay::ArcRing;
use axiolid_surface::{Cylinder, Plane, Surface};
use axiolid_topology::{
    Edge, EdgeId, EdgeUse, Face, FaceBound, FaceId, LoopId, Orientation, Vertex, VertexId,
};

use crate::extrude_exact::{
    add_line_edge, add_loop, add_single_bound_face, finish_closed, identity_frame3, reserve,
};

/// The circle an arc edge lies on.
#[derive(Debug, Clone, Copy)]
pub(crate) struct ArcGeometry {
    /// Circle centre in the cross-section plane.
    pub(crate) centre: Point2,
    /// Circle radius.
    pub(crate) radius: Scalar,
    /// Signed sweep, positive counter-clockwise.
    pub(crate) sweep: Scalar,
}

/// Recover the circle of an arc edge from its bulge.
///
/// The bulge is `tan(theta / 4)` for included angle `theta`, so
/// `theta = 4 atan(bulge)` carries both magnitude and direction. The
/// centre sits on the chord's left normal at `R cos(theta / 2)`, which
/// goes negative for a major arc and moves the centre to the other
/// side -- exactly the wanted behaviour, so no case split is needed.
pub(crate) fn arc_geometry(from: Point2, to: Point2, bulge: Scalar) -> GeomResult<ArcGeometry> {
    let chord = to - from;
    let length = chord.length();
    if length == 0.0 || !length.is_finite() || bulge == 0.0 {
        return Err(GeomError::Degenerate(
            "arc edge has no chord or no bulge".to_owned(),
        ));
    }
    let sweep = 4.0 * bulge.atan();
    let half = 0.5 * sweep;
    let sine = half.abs().sin();
    if sine == 0.0 {
        return Err(GeomError::Degenerate(
            "arc edge sweeps a full turn".to_owned(),
        ));
    }
    let radius = length / (2.0 * sine);
    let unit = chord / length;
    let normal = Vec2::new(-unit.y, unit.x);
    // The apothem must carry the sweep's SIGN. `cos` is even, so using it
    // unsigned puts +bulge and -bulge on the same centre: every arc would
    // bulge the same way regardless of its stated direction. A positive
    // sweep turns left, so its centre sits left of the chord.
    let centre = from + chord * 0.5 + normal * (radius * half.cos()) * sweep.signum();
    if !centre.is_finite() || !radius.is_finite() {
        return Err(GeomError::Degenerate(
            "arc edge produced a non-finite circle".to_owned(),
        ));
    }
    Ok(ArcGeometry {
        centre,
        radius,
        sweep,
    })
}

/// Per-edge topology of an extruded arc ring.
struct RingTopology {
    bottom_edges: Vec<EdgeId>,
    top_edges: Vec<EdgeId>,
    vertical_edges: Vec<EdgeId>,
    bottom_points: Vec<Point3>,
}

/// Extrude one arc-capable ring along `offset`, naming each wall.
pub(crate) fn extrude_arc_ring(ring: &ArcRing, offset: Vec3) -> GeomResult<ExactBRep> {
    extrude_arc_rings(core::slice::from_ref(ring), offset)
}

/// Extrude an arc-capable section with holes along `offset`.
///
/// Ring 0 is the outer boundary; any further rings are through-holes. Each
/// ring contributes its own walls and its own cap LOOP, and the two cap faces
/// carry one bound per ring -- that is what makes a hole a hole rather than a
/// second disconnected outline.
///
/// Hole rings must be wound CLOCKWISE, matching the polygon path: a ring's
/// wall normals follow its winding, so a counter-clockwise hole would face
/// its walls outward and produce a solid that is inside-out along the
/// passage.
pub(crate) fn extrude_arc_rings(rings: &[ArcRing], offset: Vec3) -> GeomResult<ExactBRep> {
    if rings.is_empty() {
        return Err(GeomError::Degenerate(
            "arc extrusion needs at least one ring".to_owned(),
        ));
    }
    for ring in rings {
        if ring.vertices.len() < 2 {
            return Err(GeomError::Degenerate(
                "arc ring needs at least two vertices".to_owned(),
            ));
        }
    }
    let total: usize = rings.iter().map(|ring| ring.vertices.len()).sum();
    let ring_count = rings.len();

    let mut builder = ExactBRepBuilder::default();
    reserve(
        &mut builder,
        total * 2,
        total * 3,
        2 * ring_count + total,
        2 + total,
        total * 3,
        total * 8,
        2 + total,
    )?;

    let topologies = rings
        .iter()
        .map(|ring| add_arc_ring(&mut builder, ring, offset))
        .collect::<GeomResult<Vec<_>>>()?;

    let bottom_surface = builder.add_surface(Surface::Plane(Plane {
        frame: identity_frame3(Vec3::ZERO),
    }));
    let top_surface = builder.add_surface(Surface::Plane(Plane {
        frame: identity_frame3(offset),
    }));

    let mut bottom_bounds = Vec::with_capacity(ring_count);
    let mut top_bounds = Vec::with_capacity(ring_count);
    for (index, (ring, topology)) in rings.iter().zip(&topologies).enumerate() {
        let bottom_loop = arc_cap_loop(&mut builder, ring, topology, false);
        let top_loop = arc_cap_loop(&mut builder, ring, topology, true);
        bottom_bounds.push(FaceBound {
            loop_id: bottom_loop,
            orientation: Orientation::Forward,
            outer: index == 0,
        });
        top_bounds.push(FaceBound {
            loop_id: top_loop,
            orientation: Orientation::Forward,
            outer: index == 0,
        });
    }

    let bottom_face = builder.topology_mut().add_face(Face {
        surface: Some(bottom_surface),
        bounds: bottom_bounds,
        orientation: Orientation::Reversed,
    });
    let top_face = builder.topology_mut().add_face(Face {
        surface: Some(top_surface),
        bounds: top_bounds,
        orientation: Orientation::Forward,
    });
    builder.set_face_name(bottom_face, FaceName::swept(SweptFace::StartCap));
    builder.set_face_name(top_face, FaceName::swept(SweptFace::EndCap));

    let mut faces = vec![bottom_face, top_face];
    // Wall ordinals run across all rings so each side face keeps a distinct
    // name; restarting per ring would collide the outer and hole walls.
    let mut ordinal: u32 = 0;
    for (ring, topology) in rings.iter().zip(&topologies) {
        for index in 0..ring.vertices.len() {
            let bulge = ring.vertices[index].bulge;
            let face = if bulge == 0.0 {
                add_planar_wall(&mut builder, ring, topology, index, offset)?
            } else {
                add_cylindrical_wall(&mut builder, ring, topology, index, offset)?
            };
            builder.set_face_name(face, FaceName::swept(SweptFace::Side(ordinal)));
            ordinal = ordinal.checked_add(1).ok_or_else(|| {
                GeomError::Degenerate("profile edge count exceeds u32 capacity".to_owned())
            })?;
            faces.push(face);
        }
    }
    finish_closed(builder, faces)
}

/// Build the vertices and the three edge families of an arc ring.
fn add_arc_ring(
    builder: &mut ExactBRepBuilder,
    ring: &ArcRing,
    offset: Vec3,
) -> GeomResult<RingTopology> {
    let count = ring.vertices.len();
    let bottom_points: Vec<Point3> = ring
        .vertices
        .iter()
        .map(|vertex| Point3::new(vertex.point.x, vertex.point.y, 0.0))
        .collect();
    let bottom_vertices: Vec<VertexId> = bottom_points
        .iter()
        .map(|position| {
            builder.topology_mut().add_vertex(Vertex {
                position: *position,
            })
        })
        .collect();
    let top_vertices: Vec<VertexId> = bottom_points
        .iter()
        .map(|position| {
            builder.topology_mut().add_vertex(Vertex {
                position: *position + offset,
            })
        })
        .collect();

    let mut bottom_edges = Vec::with_capacity(count);
    let mut top_edges = Vec::with_capacity(count);
    for index in 0..count {
        let next = (index + 1) % count;
        let bulge = ring.vertices[index].bulge;
        // Iterate by flag, not by height: a zero-height offset would make
        // `level == 0.0` true for BOTH levels and put every edge in the
        // bottom family. The caller refuses zero height, but a silent
        // mis-binding here would be far harder to see than a refusal.
        for (is_top, vertices) in [(false, &bottom_vertices), (true, &top_vertices)] {
            let level = if is_top { offset.z } else { 0.0 };
            let lift = Vec3::new(0.0, 0.0, level);
            let curve = if bulge == 0.0 {
                let origin = bottom_points[index] + lift;
                Curve3::Line(Line3 {
                    origin,
                    direction: bottom_points[next] - bottom_points[index],
                })
            } else {
                let arc =
                    arc_geometry(ring.vertices[index].point, ring.vertices[next].point, bulge)?;
                Curve3::Circle(circle_of(&arc, level, bottom_points[index] + lift)?)
            };
            let curve_id = builder.add_curve3(curve);
            let edge = builder.topology_mut().add_edge(Edge {
                start: vertices[index],
                end: vertices[next],
                curve: Some(curve_id),
            });
            let interval = if bulge == 0.0 {
                Interval::UNIT
            } else {
                let arc =
                    arc_geometry(ring.vertices[index].point, ring.vertices[next].point, bulge)?;
                Interval::new(0.0, arc.sweep)
            };
            builder.set_edge_interval(edge, interval);
            if is_top {
                top_edges.push(edge);
            } else {
                bottom_edges.push(edge);
            }
        }
    }

    let mut vertical_edges = Vec::with_capacity(count);
    for index in 0..count {
        vertical_edges.push(add_line_edge(
            builder,
            bottom_vertices[index],
            top_vertices[index],
            bottom_points[index],
            offset,
        ));
    }

    Ok(RingTopology {
        bottom_edges,
        top_edges,
        vertical_edges,
        bottom_points,
    })
}

/// The `Circle3` for an arc at height `level`, oriented so its frame
/// x-axis points at the arc start.
///
/// Anchoring x at the start vertex makes the surface parameter u the angle
/// measured from that vertex, so an edge interval of `0..sweep` is
/// literally the arc and needs no offset term.
fn circle_of(arc: &ArcGeometry, level: Scalar, start: Point3) -> GeomResult<Circle3> {
    let centre = Point3::new(arc.centre.x, arc.centre.y, level);
    let radial = start - centre;
    let length = radial.length();
    if length == 0.0 || !length.is_finite() {
        return Err(GeomError::Degenerate(
            "arc edge start lies on its own centre".to_owned(),
        ));
    }
    let x = radial / length;
    let z = Vec3::Z;
    let y = z.cross(x);
    Ok(Circle3 {
        frame: Frame3 {
            origin: centre,
            x,
            y,
            z,
        },
        radius: arc.radius,
    })
}

/// A cylindrical wall swept by an arc edge.
fn add_cylindrical_wall(
    builder: &mut ExactBRepBuilder,
    ring: &ArcRing,
    topology: &RingTopology,
    index: usize,
    offset: Vec3,
) -> GeomResult<FaceId> {
    let count = ring.vertices.len();
    let next = (index + 1) % count;
    let arc = arc_geometry(
        ring.vertices[index].point,
        ring.vertices[next].point,
        ring.vertices[index].bulge,
    )?;
    let circle = circle_of(&arc, 0.0, topology.bottom_points[index])?;
    let surface = builder.add_surface(Surface::Cylinder(Cylinder {
        frame: circle.frame,
        radius: arc.radius,
    }));
    let height = offset.z;
    let pcurves = [
        Curve2::Line(Line2 {
            origin: Vec2::ZERO,
            direction: Vec2::new(arc.sweep, 0.0),
        }),
        Curve2::Line(Line2 {
            origin: Vec2::new(arc.sweep, 0.0),
            direction: Vec2::new(0.0, height),
        }),
        Curve2::Line(Line2 {
            origin: Vec2::new(0.0, height),
            direction: Vec2::new(arc.sweep, 0.0),
        }),
        Curve2::Line(Line2 {
            origin: Vec2::ZERO,
            direction: Vec2::new(0.0, height),
        }),
    ];
    let edge_uses = [
        (topology.bottom_edges[index], Orientation::Forward),
        (topology.vertical_edges[next], Orientation::Forward),
        (topology.top_edges[index], Orientation::Reversed),
        (topology.vertical_edges[index], Orientation::Reversed),
    ];
    Ok(wall_face(builder, surface, edge_uses, pcurves))
}

/// A planar wall swept by a straight edge.
fn add_planar_wall(
    builder: &mut ExactBRepBuilder,
    ring: &ArcRing,
    topology: &RingTopology,
    index: usize,
    offset: Vec3,
) -> GeomResult<FaceId> {
    let count = ring.vertices.len();
    let next = (index + 1) % count;
    let start = topology.bottom_points[index];
    let along = topology.bottom_points[next] - start;
    let length = along.length();
    if length == 0.0 || !length.is_finite() {
        return Err(GeomError::Degenerate(
            "straight edge has zero length".to_owned(),
        ));
    }
    let x = along / length;
    let z = x.cross(offset).normalize();
    if !z.is_finite() {
        return Err(GeomError::Degenerate(
            "straight wall produced a degenerate plane".to_owned(),
        ));
    }
    let surface = builder.add_surface(Surface::Plane(Plane {
        frame: Frame3 {
            origin: start,
            x,
            y: z.cross(x),
            z,
        },
    }));
    let height = offset.z;
    let pcurves = [
        Curve2::Line(Line2 {
            origin: Vec2::ZERO,
            direction: Vec2::new(length, 0.0),
        }),
        Curve2::Line(Line2 {
            origin: Vec2::new(length, 0.0),
            direction: Vec2::new(0.0, height),
        }),
        Curve2::Line(Line2 {
            origin: Vec2::new(0.0, height),
            direction: Vec2::new(length, 0.0),
        }),
        Curve2::Line(Line2 {
            origin: Vec2::ZERO,
            direction: Vec2::new(0.0, height),
        }),
    ];
    let edge_uses = [
        (topology.bottom_edges[index], Orientation::Forward),
        (topology.vertical_edges[next], Orientation::Forward),
        (topology.top_edges[index], Orientation::Reversed),
        (topology.vertical_edges[index], Orientation::Reversed),
    ];
    Ok(wall_face(builder, surface, edge_uses, pcurves))
}

/// Assemble a four-sided wall face from its edges and pcurves.
fn wall_face(
    builder: &mut ExactBRepBuilder,
    surface: axiolid_brep::SurfaceId,
    edge_uses: [(EdgeId, Orientation); 4],
    pcurves: [Curve2; 4],
) -> FaceId {
    let mut uses = Vec::with_capacity(4);
    let mut intervals = Vec::with_capacity(4);
    for ((edge, orientation), pcurve) in edge_uses.into_iter().zip(pcurves) {
        let pcurve = builder.add_curve2(pcurve);
        uses.push(EdgeUse {
            edge,
            orientation,
            pcurve: Some(pcurve),
        });
        intervals.push(match orientation {
            Orientation::Forward => Interval::UNIT,
            Orientation::Reversed => Interval::new(1.0, 0.0),
        });
    }
    let loop_id = add_loop(builder, uses, intervals);
    add_single_bound_face(builder, surface, loop_id, Orientation::Forward)
}

/// The cap loop of an arc ring, in the cross-section plane.
///
/// Each pcurve is the cross-section curve itself: a `Line2` for a straight
/// edge, a circle for an arc. The cap surface is the z-plane, so the
/// parameter space IS the cross-section plane and no mapping is needed.
fn arc_cap_loop(
    builder: &mut ExactBRepBuilder,
    ring: &ArcRing,
    topology: &RingTopology,
    top: bool,
) -> LoopId {
    let count = ring.vertices.len();
    let edges = if top {
        &topology.top_edges
    } else {
        &topology.bottom_edges
    };
    let mut uses = Vec::with_capacity(count);
    let mut intervals = Vec::with_capacity(count);
    for (index, edge) in edges.iter().enumerate().take(count) {
        let next = (index + 1) % count;
        let from = ring.vertices[index].point;
        let to = ring.vertices[next].point;
        let bulge = ring.vertices[index].bulge;
        // The cap pcurve must be the cross-section curve itself. Using a
        // chord for an arc edge would make the cap boundary disagree with
        // the wall boundary along the same edge -- a silent inconsistency
        // that no area check would reveal.
        let (curve, interval) = if bulge == 0.0 {
            (
                Curve2::Line(Line2 {
                    origin: Vec2::new(from.x, from.y),
                    direction: Vec2::new(to.x - from.x, to.y - from.y),
                }),
                Interval::UNIT,
            )
        } else {
            match arc_geometry(from, to, bulge) {
                Ok(arc) => (
                    Curve2::Circle(Circle2 {
                        frame: circle2_frame(&arc, from),
                        radius: arc.radius,
                    }),
                    Interval::new(0.0, arc.sweep),
                ),
                // A malformed arc was already refused when its 3D edge was
                // built, so this branch is unreachable in practice; falling
                // back to the chord keeps the loop total rather than
                // panicking on an impossible state.
                Err(_) => (
                    Curve2::Line(Line2 {
                        origin: Vec2::new(from.x, from.y),
                        direction: Vec2::new(to.x - from.x, to.y - from.y),
                    }),
                    Interval::UNIT,
                ),
            }
        };
        let pcurve = builder.add_curve2(curve);
        uses.push(EdgeUse {
            edge: *edge,
            orientation: Orientation::Forward,
            pcurve: Some(pcurve),
        });
        intervals.push(interval);
    }
    add_loop(builder, uses, intervals)
}

/// A 2D frame whose x-axis points from the arc centre at its start.
fn circle2_frame(arc: &ArcGeometry, start: Point2) -> Frame2 {
    let radial = start - arc.centre;
    let length = radial.length();
    let x = if length == 0.0 {
        Vec2::X
    } else {
        radial / length
    };
    Frame2 {
        origin: Vec2::new(arc.centre.x, arc.centre.y),
        x,
        y: Vec2::new(-x.y, x.x),
    }
}