BREP_kernel 0.12.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
//! SWP — Path Sweep. Rewired to resolve its profile + path FROM THE SCENE by name
//! (the headless cutover), mirroring `extrude.rs` / `revolve.rs`.
//!
//! # What this feature is (audit finding)
//!
//! The DEDICATED swept-surface op — NOT the `Sweep` (SW) feature, which only
//! extrudes along a chord and ignores twist. This feature sweeps a closed
//! planar profile along a path via `sweep_profile_along_chain`, which folds the
//! twisted and anchored single-curve builders behind one call and sweeps a
//! MULTI-segment path as one continuous tube. This port honors twist exactly
//! the same way.
//!
//! # SWP or SW — which sweep a path belongs to
//!
//! The two features now accept the SAME path selection (a whole sketch, or
//! connected edges chained head-to-tail), and they divide on the path's SHAPE
//! rather than on how it was picked:
//!   - **SWP (here)** drives the profile along the path on a
//!     rotation-minimizing frame, so the profile turns to follow a curve. A
//!     TANGENT-CONTINUOUS run is skinned between sampled stations as one tube.
//!   - **SW** translates the profile along each segment's chord and unions the
//!     portions, so the profile never rotates — and it refuses a CURVED segment.
//! A CORNER is no longer the line between them. An OPEN path of STRAIGHT
//! segments meeting at corners is MITRED here: the section is swept along each
//! segment and both sweeps are trimmed to the joint's bisector plane, where they
//! share one face loop — inner side folded, outer side extended, no bulkhead
//! between them, a picture-frame corner. SW's `pathAlign` mode mitres the same
//! path the same way (the construction is the shared builder's); the two still
//! differ in WHERE they put the section, which is this feature's whole point:
//! SWP transplants it onto the path, SW's `pathAlign` carries it from where it
//! was drawn.
//! A CLOSED cornered path is the whole FRAME: its closing joint is mitred like
//! every other, and the result has no caps at all (see below — a frame and a ring
//! are the same capless shape reached by the two lanes).
//! What is still refused, by name: a corner at a CURVED segment (a curve has no
//! single direction for a bisector plane to bisect, and no lane mixes mitring
//! with skinning), a closed cornered loop that CROSSES ITSELF, a SPATIAL frame
//! whose counter-twist has no room (below), a bend too TIGHT for
//! the section across it — on a frame, the two mitres of one side OVERLAPPING —
//! and a COLLAPSED bend at or past the 168.522° fold angle.
//!
//! # A CLOSED path is a RING — or, cornered, a FRAME
//!
//! A path whose last segment ends where its first begins — classified as such
//! when it is resolved (`common::resolve_sweep_path`) — sweeps as one CAPLESS
//! ring: the stations go round the loop with the first and last the same station,
//! and the sections skin through the CLOSED loft, so the result is one face per
//! profile edge, no caps, `V − E + F = 0` and genus 1. A CORNERED closed path is
//! the same capless topology reached by the MITRE lane instead — `n` walls per
//! profile edge for `n` segments, one shared loop per joint, still no caps and
//! still genus 1 — which is why the cap names are dropped on `path.closed`
//! rather than on the lane. The volume is Pappus — the
//! section's area times the distance its centroid travels, which under
//! `Transplant` IS the path length, since the centroid rides the path.
//!
//! A PLANAR loop's frame turns only about the plane normal, so it returns to
//! itself and the section meets itself at the seam. A SPATIAL loop's frame
//! returns ROLLED by the path's holonomy — the solid angle its tangent
//! indicatrix encloses, modulo 2π — and the builder closes it with the smallest
//! counter-twist: laid down linearly in arc length on a ring, shared over the
//! sides by length on a frame (each share rolled over the middle half of its
//! side, so the mitres stay exact). `sweep_closure` reports the holonomy and the
//! twist applied. A frame is refused, by name, where that roll has no room.
//!
//! A requested `twistAngle` on a closed path rides on that counter-twist, over the
//! whole lap, so after one lap the section comes back turned by exactly the
//! requested angle. It builds only when that turn carries the section onto itself:
//! a whole number of turns, or a step of the section's own rotational symmetry
//! about the path (90° for a centred square), read curve for curve as drawn.
//! Anything else is refused by name by the builder
//! ([`crate::SWEEP_TWIST_CLOSURE_REFUSAL`]), quoting the nearest twists that close.
//! Each loop is swept in a call of its own, so a HOLE loop is asked the same
//! question about the same path axis: an off-centre hole closes only on whole
//! turns, and a hole sharing the outer loop's symmetry closes on its steps. Under
//! a fractional turn each wall lands on the next profile edge's curve after the lap
//! and is still one face, named for the edge it leaves from.
//!
//! # Headless input contract (post-cutover)
//!
//! Both inputs resolve from the live scene, by name (no marshaled curves):
//! - `profile` `reference_selection` names a SKETCH whose profile the SKETCH
//!   feature already extracted (`SceneMap::resolve_profile`). Each region's
//!   OUTER loop `.curves` are a swept ring; its `.edge_names` stamp the sidewall
//!   names. Hole loops sweep along the SAME path (same twist, anchored to the
//!   FIRST region outer's placement) into cutters subtracted via
//!   `common::subtract_region_holes`; further regions sweep anchored too and the
//!   region solids union into ONE result. A profile that resolves to a resident
//!   solid FACE becomes a `SketchProfile` via `face_profile::face_profile` —
//!   the extrude.rs / revolve.rs / sweep.rs two-step, and the reason the
//!   `profile` field's filter is `["SKETCH","FACE"]`. A face profile has no
//!   sketch behind it, so nothing is consumed and the source solid stays
//!   resident.
//! - `path` `reference_selection` (`multiple`) resolves via
//!   `common::resolve_path_chain` — SW's resolver — to an ordered, head-to-tail
//!   run of named world curves: a whole SKETCH contributes its published chain,
//!   individual sketch segments and resident solid EDGES resolve one curve each
//!   and chain by endpoint coincidence, and an edge running the "wrong" way is
//!   reversed. The FIRST pick seeds the direction. The whole run sweeps as ONE
//!   tube under a SINGLE rotation-minimizing frame, so the frame carries across
//!   a joint rather than restarting per segment; a one-segment run delegates to
//!   the single-curve builders and is byte-identical to what it always emitted.
//!
//! # Name fidelity (contract rule 2)
//!
//! Side faces in INPUT-curve order, then bottom cap = START, top cap = END (the
//! loft order `sweep_profile_along_path` produces):
//!   - side `i`  → `${edgeName}_SWP`  (missing name → the `"EDGE"` fallback)
//!   - START cap → `${featureID}_START`
//!   - END cap   → `${featureID}_END`
//! There is NO `${id}:` feature tag here (unlike SW) — the
//! names are bare. Solid name `featureID || "PathSweep"`.
//!
//! A RING has no caps, so its faces are the side walls and NOTHING else — the cap
//! names are not merely unused, they are not registered, because a container
//! standing for a face that does not exist is a name a later feature can select
//! and nothing can resolve.
//!
//! The optional `boolean` param folds via `common::finalize_solid`; the referenced
//! sketch is consumed (`common::consume_sketch`).

use crate::feature_pipeline::features::common;
use crate::feature_pipeline::{FeatureContext, FeatureRefusal, FeatureResult, SketchProfile};

pub fn execute(ctx: &FeatureContext) -> FeatureResult {
    match build(ctx) {
        Ok(result) => result,
        Err(error) => ctx.fail(error),
    }
}

fn build(ctx: &FeatureContext) -> Result<FeatureResult, FeatureRefusal> {
    // --- Profile: resolve the SKETCH profile from the scene by name.
    // Normalize a `{sketch}:FACE` display-sheet pick to the sketch profile base.
    let profile_name = common::normalize_profile_alias(
        common::first_reference_name(ctx.param("profile"))
            .ok_or("path sweep: missing `profile` reference selection")?,
    );
    // Sketch profile first; otherwise a resident FACE becomes a profile via
    // `face_profile` (the extrude.rs / revolve.rs / sweep.rs two-step, same
    // `SketchProfile` shape and the same downstream path). `from_face` skips the
    // consumed-sketch bookkeeping — a face profile has no sketch behind it.
    let mut face_profile_storage: Option<SketchProfile> = None;
    let (profile, from_face): (&SketchProfile, bool) =
        match ctx.scene.resolve_profile(&profile_name) {
            Some(profile) => (profile, false),
            None => match ctx.scene.resolve_face(&profile_name) {
                Some(face) => {
                    let built = super::face_profile::face_profile(face)
                        .map_err(|error| format!("path sweep: {error}"))?;
                    (&*face_profile_storage.insert(built), true)
                }
                None => {
                    return Err(format!(
                        "path sweep: profile '{profile_name}' not found (no sketch profile or resident face)"
                    ).into());
                }
            },
        };
    let first_outer = profile
        .regions
        .first()
        .and_then(|region| region.first())
        .ok_or("path sweep: profile has no outer loop")?;

    // --- Path: a whole sketch chain and/or individually picked edges, ordered
    //     head-to-tail into ONE connected run — the same resolution SW uses, so
    //     the two sweeps accept the same selection. The whole run is swept as
    //     one continuous tube under a single rotation-minimizing frame; the
    //     builder refuses a run with a CORNER in it, because skinning between
    //     stations would round the corner off rather than mitre it (SW is the
    //     feature for a cornered path). A missing selection is a hard error.
    let path_names = common::reference_names(ctx.param("path"));
    if path_names.is_empty() {
        return Err("path sweep: requires a path edge selection".into());
    }
    // The resolved chain AND its classification — closure, the plane it lies in if
    // it lies in one, and every joint's continuity — computed once here; the
    // builder's corner refusal and its closed-ring lane both read it.
    let path = common::resolve_sweep_path(ctx, &path_names)
        .map_err(|error| format!("path sweep: {error}"))?;

    let feature_id = if ctx.id.is_empty() {
        "PathSweep"
    } else {
        ctx.id.as_str()
    };

    // Twist: degrees → radians (a non-finite `twistAngle` → 0). The gate for the
    // twisted builder is plain truthiness on the RADIANS, i.e. `!= 0.0` exactly
    // (NOT an epsilon).
    let twist_degrees = common::number_or_default(ctx, "twistAngle", 0.0);
    let twist_radians = twist_degrees * std::f64::consts::PI / 180.0;

    // The builder re-centers a swept loop's own centroid onto the path; every
    // loop OTHER than the first region's outer (hole loops, and the outers of
    // further regions) must instead keep its in-plane offset relative to that
    // first outer — they sweep ANCHORED to its placement anchor.
    let anchor = crate::profile_anchor(&first_outer.curves)
        .map_err(|error| format!("path sweep: {error}"))?;

    // Per-loop cap names keyed off each region's STABLE loop identity, with the
    // un-keyed `{feature_id}_START/_END` as the containers standing for all of
    // them (see `common::CapNames`).
    let caps = common::CapNames::new(feature_id, &profile.regions);

    // One swept solid per REGION, unioned below.
    let mut region_solids = Vec::with_capacity(profile.regions.len());
    // The station lane's measured bounds, one per swept loop (`sweep.stations`).
    let mut approximations: Vec<crate::Approximation> = Vec::new();
    // Per region: the section is WIDER than its own bend and the kernel built the
    // swept ENVELOPE (a revolve) instead of skinning a tube. Read off the same
    // recognition the builder selects the lane on, so names and geometry agree.
    let mut envelope_regions = vec![false; profile.regions.len()];
    for (region_index, region) in profile.regions.iter().enumerate() {
        let outer = region.first().ok_or("path sweep: profile has no outer loop")?;
        if outer.curves.len() < 2 {
            return Err(format!(
                "path sweep: requires a closed profile with at least two boundary curves, got {}",
                outer.curves.len()
            ).into());
        }
        // Region 0 places on its OWN centroid; every later region rides region
        // 0's anchor so its in-plane offset survives (the same rule hole loops
        // follow below). `sweep_profile_along_chain` folds the twisted and
        // anchored variants into one call and delegates a ONE-segment chain
        // straight back to the single-curve builders, so an ordinary
        // single-edge path sweep emits exactly the geometry it always has.
        let envelope = matches!(
            crate::recognize_swept_envelope(
                &outer.curves,
                &path,
                crate::SectionPlacement::Transplant,
            ),
            Ok(Ok(_))
        );
        envelope_regions[region_index] = envelope;
        if envelope && region.len() > 1 {
            return Err(format!(
                "path sweep: region {} bends TIGHTER than its own section, so its swept solid is \
                 the envelope trimmed at its own self-intersection — a revolve, not a skinned tube \
                 — and a HOLE loop through one is not built: the hole's own envelope would have to \
                 be trimmed against the outer one, which nothing solves. Sweep the outer loop \
                 alone, or ease the bend",
                region_index + 1
            ).into());
        }
        // The station lane's MEASURED BOUND rides into the result's
        // `approximations` (`sweep.stations`); the mitre and envelope lanes are
        // exact and report none.
        let swept = crate::sweep_profile_along_chain_reported(
            &outer.curves,
            &path,
            twist_radians,
            Some(feature_id),
            (region_index != 0).then_some(anchor),
            crate::SectionPlacement::Transplant,
            "A corner between two STRAIGHT segments is MITRED — both sweeps trimmed to the joint's bisector plane, sharing one loop there — but a corner at a CURVED segment is not built by either sweep feature, because a curve has no single direction for a bisector plane to bisect. Split the path at that corner and sweep each run separately, or round the corner so the joint is tangent-continuous.",
            None,
        )
        .map_err(|error| format!("path sweep: {error}"))?;
        let mut solid = swept.solid;
        if let Some(report) = swept.report {
            approximations.push(report.approximation(if region_index == 0 {
                feature_id.to_string()
            } else {
                format!("{feature_id} region {}", region_index + 1)
            }));
        }

        // Face names: `[${edgeName}_SWP..., ${id}_START, ${id}_END]` (INPUT-curve
        // order). A MITRED path — a cornered polyline, built as one exact piece
        // per segment joined on each joint's bisector plane — has a wall per
        // (path segment × profile edge) instead, in that order, so each one is
        // keyed by its segment as `${edgeName}:${segment}_SWP` with the un-keyed
        // `${edgeName}_SWP` registered as the CONTAINER standing for that profile
        // edge's wall along every segment. Without the key several faces would
        // share one name and a downstream reference would resolve to whichever
        // the boolean happened to order first.
        let side_count = outer.curves.len();
        let mitred = path.cornered_polyline();
        let wall_base = |index: usize| -> String {
            outer
                .edge_names
                .get(index)
                .cloned()
                .flatten()
                .filter(|name| !name.trim().is_empty())
                .unwrap_or_else(|| "EDGE".to_string())
        };
        let mut face_names: Vec<String> = Vec::with_capacity(side_count + 2);
        let caps_here = if path.closed { 0 } else { 2 };
        let envelope_walls = solid
            .shells
            .first()
            .map_or(0, |shell| shell.faces.len())
            .saturating_sub(caps_here);
        if envelope {
            // The envelope's walls are REVOLVED arcs of the section circle, not
            // the section's own edges. Crossing the axis leaves ONE wall — the
            // image of the whole boundary — which takes edge 0's name, and the
            // other edges' containers list it (registered below). A section only
            // TANGENT to the axis leaves one wall per profile arc, one name each.
            let walls = if envelope_walls == 1 { 1 } else { side_count };
            // Only the ONE-wall envelope folds the other edges' names into it.
            envelope_regions[region_index] = walls == 1;
            for index in 0..walls {
                face_names.push(format!("{}_SWP", wall_base(index)));
            }
        } else if mitred {
            for segment in 0..path.len() {
                for index in 0..side_count {
                    face_names.push(format!("{}:{}_SWP", wall_base(index), path.name(segment)));
                }
            }
        } else {
            for index in 0..side_count {
                face_names.push(format!("{}_SWP", wall_base(index)));
            }
        }
        // A CLOSED path sweeps a capless RING: the closed loft emits one face per
        // profile edge, in the same INPUT-curve order, and no caps — so there is
        // no cap face to name and no cap NAME to publish.
        if !path.closed {
            let (start, end) = &caps.per_loop[region_index];
            face_names.push(start.clone());
            face_names.push(end.clone());
        }

        let faces = &mut solid
            .shells
            .get_mut(0)
            .ok_or("path sweep: builder produced no shell")?
            .faces;
        if faces.len() != face_names.len() {
            return Err(format!(
                "path sweep: builder produced {} faces, expected {} ({} {} + {})",
                faces.len(),
                face_names.len(),
                if mitred {
                    format!("{} x {side_count}", path.len())
                } else {
                    side_count.to_string()
                },
                if mitred { "mitred walls" } else { "sides" },
                if path.closed { "no caps (a closed path)" } else { "2 caps" }
            ).into());
        }
        for (face, name) in faces.iter_mut().zip(&face_names) {
            face.name = Some(name.clone());
        }

        // Region holes: sweep each hole loop along the SAME path with the SAME
        // twist into an anchored cutter and subtract. Cutter caps land coplanar
        // with the outer's caps; the exact boolean's coplanar merge handles them.
        if region.len() > 1 {
            solid = common::subtract_region_holes(
                solid,
                profile,
                region,
                "path sweep",
                feature_id,
                None,
                &mut |loop_index, _depth| {
                    let hole_curves = &region[loop_index].curves;
                    crate::sweep_profile_along_chain_reported(
                        hole_curves,
                        &path,
                        twist_radians,
                        None,
                        Some(anchor),
                        crate::SectionPlacement::Transplant,
                        "A corner between two STRAIGHT segments is MITRED — both sweeps trimmed to the joint's bisector plane, sharing one loop there — but a corner at a CURVED segment is not built by either sweep feature, because a curve has no single direction for a bisector plane to bisect. Split the path at that corner and sweep each run separately, or round the corner so the joint is tangent-continuous.",
                        None,
                    )
                    .map(|swept| {
                        if let Some(report) = swept.report {
                            approximations.push(report.approximation(format!("{feature_id} hole {loop_index}")));
                        }
                        swept.solid
                    })
                },
            )?;
        }
        region_solids.push(solid);
    }
    let solid = common::union_region_solids(region_solids)
        .map_err(|error| format!("path sweep: {error}"))?;

    // Role metadata: sidewall/cap convention, from the actual named faces. A RING
    // has no caps, so it offers none and registers no cap container.
    let (cap_starts, cap_ends) = if path.closed {
        (Vec::new(), Vec::new())
    } else {
        (caps.starts(), caps.ends())
    };
    common::stamp_sweep_roles_multi(&solid, "_SWP", &cap_starts, &cap_ends);

    let mut containers = if path.closed {
        Vec::new()
    } else {
        caps.containers()
    };
    // A MITRED path names a wall per segment, so the un-keyed `${edgeName}_SWP`
    // becomes the CONTAINER standing for all of them — the same relationship the
    // per-loop caps have with their un-keyed names, and the spelling a reference
    // stored before the path was given a corner already uses.
    // An ENVELOPE region's one revolved wall is named for profile edge 0; every
    // other edge's `_SWP` name is registered as a container standing for it, so
    // a reference to any edge's wall resolves to the face that is there.
    for (region_index, region) in profile.regions.iter().enumerate() {
        if !envelope_regions[region_index] {
            continue;
        }
        let Some(outer) = region.first() else {
            continue;
        };
        let base = |index: usize| {
            outer
                .edge_names
                .get(index)
                .cloned()
                .flatten()
                .filter(|name| !name.trim().is_empty())
                .unwrap_or_else(|| "EDGE".to_string())
        };
        let wall = format!("{}_SWP", base(0));
        for index in 1..outer.curves.len() {
            let name = format!("{}_SWP", base(index));
            if name != wall {
                containers.push((name, vec![wall.clone()]));
            }
        }
    }
    if path.cornered_polyline() {
        for region in &profile.regions {
            let Some(outer) = region.first() else {
                continue;
            };
            for index in 0..outer.curves.len() {
                let base = outer
                    .edge_names
                    .get(index)
                    .cloned()
                    .flatten()
                    .filter(|name| !name.trim().is_empty())
                    .unwrap_or_else(|| "EDGE".to_string());
                let members = (0..path.len())
                    .map(|segment| format!("{base}:{}_SWP", path.name(segment)))
                    .collect();
                containers.push((format!("{base}_SWP"), members));
            }
        }
    }
    let mut result = common::finalize_solid_grouped(ctx, solid, feature_id, &containers);
    if result.error.is_some() {
        return Ok(result);
    }
    result.approximations.extend(approximations);
    // A FACE profile has no sketch to consume — the source solid stays resident.
    if !from_face {
        common::consume_sketch(ctx, &profile_name, &mut result);
    }
    Ok(result)
}

/// Context-bar applicability ([`crate::feature_pipeline::context_offer`]):
/// a selected profile drives `profile`, edges `path`.
pub fn context_applicable(probe: &crate::feature_pipeline::SelectionProbe) -> bool {
    probe.has_profile() || probe.edges > 0
}

pub fn schema() -> serde_json::Value {
    serde_json::json!({
    "type": "SWP",
    "shortName": "SWP",
    "longName": "Path Sweep",
    "ribbonPath": "Home/Sweep/Path Sweep",
    "commandSize": "Compact",
    "displayBuilder": false,
    "inputParamsSchema": {
        "id": {
            "type": "string",
            "default_value": null,
            "hint": "unique identifier for the path sweep feature"
        },
        "profile": {
            "type": "reference_selection",
            "selectionFilter": [
                "SKETCH",
                "FACE"
            ],
            "multiple": false,
            "default_value": null,
            "hint": "Select a single closed planar profile (sketch or face) to sweep"
        },
        "consumeProfileSketch": {
            "type": "boolean",
            "default_value": true,
            "hint": "Remove the referenced sketch after creating the sweep. Turn off to keep it in the scene."
        },
        "path": {
            "type": "reference_selection",
            "selectionFilter": [
                "SKETCH",
                "EDGE"
            ],
            "multiple": true,
            "default_value": null,
            "hint": "Sweep path: pick a whole sketch, or one or more connected edges — they are chained head-to-tail, with the FIRST pick setting the direction the sweep runs. A smooth (tangent-continuous) run is swept as one tube; a run of STRAIGHT segments meeting at corners is MITRED on each joint's bisector plane. A CLOSED run sweeps with no end caps — a smooth one as a ring, a cornered one as the whole mitred FRAME. A closed loop that is not flat comes back from one lap rolled by the path's own holonomy, and the sweep closes it with the smallest counter-twist, spread evenly along the path. A corner at a curved segment, a closed loop that crosses itself, a bend too tight for the section, and a fold at or past 168.5 degrees are refused by name."
        },
        "twistAngle": {
            "type": "number",
            "default_value": 0,
            "label": "Twist (deg)",
            "hint": "Rotate the profile about the path tangent, linearly in arc length, by this total angle (§5.7). 0 = plain sweep. On a CLOSED path the twist must carry the profile onto itself after one lap: a whole number of turns, or a step of the profile's own symmetry about the path (every 90 degrees for a square centred on it). Other angles are refused, naming the nearest that close."
        },
        "boolean": common::optional_boolean_schema()
    }
})
}