Skip to main content

brep_kernel/feature_pipeline/features/
common.rs

1//! Shared feature parameters, reference resolution, and solid operations.
2
3use rustc_hash::FxHashSet;
4
5use crate::feature_pipeline::{
6    AddedSolid, Axis, EdgeRef, FaceRef, FeatureContext, FeatureResult, Frame, SketchProfile,
7};
8use crate::{AffineTransform, BooleanOperation, BooleanOptions, BrepSolid, NurbsCurve, Vec3, transform_brep};
9
10/// Rotate a solid a half turn (180°) about the local X axis. This is a PROPER
11/// rotation (determinant +1), so `transform_brep` preserves face winding and every
12/// stamped face name. It realizes a NEGATIVE directional dimension: a primitive
13/// built along +Y with the value's MAGNITUDE is spun to occupy the -Y side, and a
14/// partial revolve/torus sweep is spun to the opposite side — exactly the geometry
15/// a signed value implies. `(x, y, z) -> (x, -y, -z)`.
16pub fn rotate_half_turn_about_x(solid: BrepSolid) -> Result<BrepSolid, String> {
17    let half_turn = AffineTransform::new([
18        1.0, 0.0, 0.0, 0.0,
19        0.0, -1.0, 0.0, 0.0,
20        0.0, 0.0, -1.0, 0.0,
21        0.0, 0.0, 0.0, 1.0,
22    ])?;
23    transform_brep(&solid, half_turn, false)
24}
25
26/// Collect `(face_id, name)` for every NAMED face, in shell/face order.
27pub fn collect_face_names(solid: &BrepSolid) -> Vec<(u64, String)> {
28    let mut names = Vec::new();
29    for shell in &solid.shells {
30        for face in &shell.faces {
31            if let Some(name) = &face.name {
32                names.push((face.id, name.clone()));
33            }
34        }
35    }
36    names
37}
38
39/// Collect `(edge_id, name)` for every NAMED edge.
40pub fn collect_edge_names(solid: &BrepSolid) -> Vec<(u64, String)> {
41    solid
42        .edges
43        .iter()
44        .filter_map(|edge| edge.name.as_ref().map(|name| (edge.id, name.clone())))
45        .collect()
46}
47
48/// Stamp one face's operation-role metadata (`faceRole` + `operationFaceType`,
49/// the established Sweep/Revolve convention the PMI dimension UI and tests read) into the
50/// kernel scene-metadata store, keyed by face NAME so it follows the face
51/// through booleans. Only the role keys are stamped — geometric metadata
52/// (`faceType`/`surfaceType`) stays inferred from the surface on the read side.
53pub fn stamp_face_role(face_name: &str, role: &str, op_type: &str) {
54    let mut record = serde_json::Map::new();
55    record.insert("faceRole".into(), serde_json::Value::String(role.into()));
56    record.insert(
57        "operationFaceType".into(),
58        serde_json::Value::String(op_type.into()),
59    );
60    crate::feature_pipeline::scene_metadata::merge_record(face_name, &record, true);
61}
62
63/// Stamp the whole sweep-family role convention on a BUILT solid's named faces:
64/// a name in `start_names`/`end_names` → `start_cap`/`STARTCAP` +
65/// `end_cap`/`ENDCAP`, any other name ending in `wall_suffix` →
66/// `sidewall`/`SIDEWALL`. Walking the actual faces (not the intended name list)
67/// means an absent cap (full revolve) or a skipped axis wall never leaves a
68/// phantom record in the store.
69///
70/// The lists are per-loop: each region of a multi-loop profile names its own
71/// caps (`{cap_base}:L{loopId}_START`), so the role pass takes the whole list
72/// rather than one name per end. A single-loop profile passes a one-element
73/// slice — there was a `stamp_sweep_roles` wrapper for that, and every caller
74/// went through this function instead, so it is gone.
75pub fn stamp_sweep_roles_multi(
76    solid: &BrepSolid,
77    wall_suffix: &str,
78    start_names: &[String],
79    end_names: &[String],
80) {
81    for face in solid.shells.iter().flat_map(|shell| shell.faces.iter()) {
82        let Some(name) = face.name.as_deref() else {
83            continue;
84        };
85        if start_names.iter().any(|start| start == name) {
86            stamp_face_role(name, "start_cap", "STARTCAP");
87        } else if end_names.iter().any(|end| end == name) {
88            stamp_face_role(name, "end_cap", "ENDCAP");
89        } else if name.ends_with(wall_suffix) {
90            stamp_face_role(name, "sidewall", "SIDEWALL");
91        }
92    }
93}
94
95/// The per-loop cap names of one profile-swept feature, plus the CONTAINER names
96/// they roll up into — the single place the per-loop cap convention is spelled,
97/// shared by extrude/sweep/revolve/path-sweep/loft so the five stay byte-identical.
98///
99/// `{cap_base}:{loop key}_START` / `_END` per region (see [`ProfileLoop::key`]),
100/// with `{cap_base}_START` / `_END` as the containers standing for all of them.
101/// A SINGLE-loop profile is the special case that matters most for compatibility:
102/// its one cap keys off its loop like any other, and its container has exactly one
103/// member, so every reference stored before per-loop naming resolves to exactly
104/// the face it always meant.
105pub struct CapNames {
106    /// Per region, in region order: `(start, end)`.
107    pub per_loop: Vec<(String, String)>,
108    pub start_container: String,
109    pub end_container: String,
110}
111
112impl CapNames {
113    /// Build the cap names for `regions` under `cap_base`.
114    ///
115    /// A region whose loop has no identity to key on (a FACE profile, a
116    /// hand-built profile) keeps the un-keyed `{cap_base}_START/_END` spelling it
117    /// has always had — such a profile is single-region, so there is nothing to
118    /// disambiguate and every saved reference to it stays exact.
119    pub fn new(cap_base: &str, regions: &[Vec<crate::feature_pipeline::ProfileLoop>]) -> Self {
120        let per_loop = regions
121            .iter()
122            .map(|region| {
123                match region.first().and_then(|outer| outer.key()) {
124                    Some(key) => (
125                        format!("{cap_base}:{key}_START"),
126                        format!("{cap_base}:{key}_END"),
127                    ),
128                    None => (format!("{cap_base}_START"), format!("{cap_base}_END")),
129                }
130            })
131            .collect();
132        Self {
133            per_loop,
134            start_container: format!("{cap_base}_START"),
135            end_container: format!("{cap_base}_END"),
136        }
137    }
138
139    /// This feature's `(container, members)` pairs for [`register_added_grouped`].
140    /// A container whose only member IS the container name (the un-keyed
141    /// single-region case) is dropped — the face is reachable exactly, so there is
142    /// no group to register.
143    pub fn containers(&self) -> Vec<(String, Vec<String>)> {
144        [
145            (self.start_container.clone(), self.starts()),
146            (self.end_container.clone(), self.ends()),
147        ]
148        .into_iter()
149        .filter(|(container, members)| members.as_slice() != [container.clone()])
150        .collect()
151    }
152
153    /// The INTERIOR cap names of a multi-segment sweep: one path SEGMENT's own
154    /// pair for `region_index`, spelled `{cap_base}:{loop key}:{segment}_START/_END`.
155    ///
156    /// A sweep along several path segments builds one portion per segment, and
157    /// every portion has two caps. The chain's own two ends keep the plain
158    /// per-loop spelling ([`Self::per_loop`]); every cap BETWEEN two portions is
159    /// a copy of the profile that the neighbouring portion's opposite cap cancels
160    /// in the union, so it normally does not reach the result at all. Naming it
161    /// after its segment anyway means that if a path ever leaves one standing
162    /// (a chain that doubles back), it arrives with a name tied to the segment
163    /// that built it instead of a positional `[n]` disambiguation.
164    pub fn interior(&self, region_index: usize, segment: &str) -> (String, String) {
165        let (start, end) = &self.per_loop[region_index];
166        let start_base = start.strip_suffix("_START").unwrap_or(start);
167        let end_base = end.strip_suffix("_END").unwrap_or(end);
168        (
169            format!("{start_base}:{segment}_START"),
170            format!("{end_base}:{segment}_END"),
171        )
172    }
173
174    /// All per-loop START names, for [`stamp_sweep_roles_multi`].
175    pub fn starts(&self) -> Vec<String> {
176        self.per_loop.iter().map(|(start, _)| start.clone()).collect()
177    }
178
179    /// All per-loop END names, for [`stamp_sweep_roles_multi`].
180    pub fn ends(&self) -> Vec<String> {
181        self.per_loop.iter().map(|(_, end)| end.clone()).collect()
182    }
183}
184
185pub(super) fn parse_boolean_operation(operation: &str) -> Result<BooleanOperation, String> {
186    match operation {
187        "UNION" => Ok(BooleanOperation::Union),
188        "SUBTRACT" => Ok(BooleanOperation::Subtract),
189        "INTERSECT" => Ok(BooleanOperation::Intersect),
190        other => Err(format!("unsupported boolean operation '{other}'")),
191    }
192}
193
194/// The optional `boolean` param, parsed from `inputParams.boolean`.
195struct BooleanParam {
196    operation: String,
197    targets: Vec<String>,
198    merge_coplanar_faces: bool,
199}
200
201fn read_boolean_param(ctx: &FeatureContext) -> BooleanParam {
202    let boolean = ctx.param("boolean");
203    let operation = boolean
204        .and_then(|value| value.get("operation"))
205        .and_then(|value| value.as_str())
206        .unwrap_or("NONE")
207        .to_uppercase();
208    let targets = boolean
209        .and_then(|value| value.get("targets"))
210        .and_then(|value| value.as_array())
211        .map(|array| {
212            array
213                .iter()
214                .filter_map(|entry| entry.as_str())
215                .map(|name| name.trim().to_string())
216                .filter(|name| !name.is_empty())
217                .collect()
218        })
219        .unwrap_or_default();
220    let merge_coplanar_faces = boolean
221        .and_then(|value| value.get("mergeCoplanarFaces"))
222        .and_then(|value| value.as_bool())
223        .unwrap_or(true);
224    BooleanParam {
225        operation,
226        targets,
227        merge_coplanar_faces,
228    }
229}
230
231/// Collect up to two distinct face names per edge, preserving first encounter order.
232fn collect_edge_face_names(
233    solid: &BrepSolid,
234) -> (std::collections::HashMap<u64, Vec<String>>, Vec<u64>) {
235    use std::collections::HashMap;
236    let mut edge_faces: HashMap<u64, Vec<String>> = HashMap::new();
237    let mut encounter_order: Vec<u64> = Vec::new();
238    for shell in &solid.shells {
239        for face in &shell.faces {
240            let face_name = face.name.clone().unwrap_or_default();
241            for loop_record in &face.loops {
242                for coedge in &loop_record.coedges {
243                    let entry = edge_faces.entry(coedge.edge_id).or_insert_with(|| {
244                        encounter_order.push(coedge.edge_id);
245                        Vec::new()
246                    });
247                    if entry.len() < 2 && !entry.contains(&face_name) {
248                        entry.push(face_name.clone());
249                    }
250                }
251            }
252        }
253    }
254    (edge_faces, encounter_order)
255}
256
257/// Name topology edges `{faceA}|{faceB}[n]`, sorting adjacent face names and
258/// numbering each pair in shell/face/loop encounter order. Existing names that
259/// contain `|` are regenerated; authored names are preserved.
260pub fn stamp_derived_edge_names(solid: &mut BrepSolid) {
261    use std::collections::HashMap;
262    let (edge_faces, encounter_order) = collect_edge_face_names(solid);
263    let solid_name_fallback = "Solid".to_string();
264    let mut base_counts: HashMap<String, usize> = HashMap::new();
265    for edge_id in encounter_order {
266        let Some(edge) = solid.edges.iter_mut().find(|edge| edge.id == edge_id) else {
267            continue;
268        };
269        if edge.degenerate {
270            continue;
271        }
272        let mut faces: Vec<String> = edge_faces
273            .get(&edge_id)
274            .map(|list| list.iter().filter(|n| !n.is_empty()).cloned().collect())
275            .unwrap_or_default();
276        faces.sort();
277        let base = if faces.len() >= 2 {
278            format!("{}|{}", faces[0], faces[1])
279        } else {
280            format!(
281                "{}|BOUNDARY",
282                faces.first().unwrap_or(&solid_name_fallback)
283            )
284        };
285        // Only topology edges advance the counter, keeping each pair's indices dense.
286        let is_topology_name = edge.name.as_deref().is_none_or(|n| n.contains('|'));
287        if is_topology_name {
288            let index = base_counts.entry(base.clone()).or_insert(0);
289            edge.name = Some(format!("{base}[{index}]"));
290            *index += 1;
291        }
292    }
293}
294
295/// Suffix copied face and authored-edge names with `::{suffix}`, then regenerate
296/// topology-edge names from the renamed faces. This keeps copies distinct from
297/// their sources in the scene's name lookup. Empty names are skipped.
298pub fn namespace_copy_names(solid: &mut BrepSolid, suffix: &str) {
299    for shell in &mut solid.shells {
300        for face in &mut shell.faces {
301            if let Some(name) = face.name.as_ref() {
302                let trimmed = name.trim();
303                if !trimmed.is_empty() {
304                    face.name = Some(format!("{trimmed}::{suffix}"));
305                }
306            }
307        }
308    }
309    for edge in &mut solid.edges {
310        if let Some(name) = edge.name.as_ref() {
311            let trimmed = name.trim();
312            // Topology edges (`|`) are re-derived below from the retagged faces;
313            // authored edges take the suffix directly.
314            if !trimmed.is_empty() && !trimmed.contains('|') {
315                edge.name = Some(format!("{trimmed}::{suffix}"));
316            }
317        }
318    }
319    stamp_derived_edge_names(solid);
320}
321
322/// Disambiguate duplicate face names with `[n]` in shell/face encounter order.
323/// Unique names stay unchanged. Run before [`stamp_derived_edge_names`] so edge
324/// names embed the final face names and scene lookups remain unambiguous.
325pub fn ensure_unique_face_names(solid: &mut BrepSolid) {
326    use std::collections::HashMap;
327    let mut counts: HashMap<String, usize> = HashMap::new();
328    for shell in &solid.shells {
329        for face in &shell.faces {
330            if let Some(name) = &face.name {
331                *counts.entry(name.clone()).or_insert(0) += 1;
332            }
333        }
334    }
335    let mut running: HashMap<String, usize> = HashMap::new();
336    for shell in &mut solid.shells {
337        for face in &mut shell.faces {
338            let Some(name) = face.name.clone() else { continue };
339            if counts.get(&name).copied().unwrap_or(0) > 1 {
340                let index = running.entry(name.clone()).or_insert(0);
341                face.name = Some(format!("{name}[{index}]"));
342                *index += 1;
343            }
344        }
345    }
346}
347
348/// Register a freshly built base solid as a single `AddedSolid` under `name`.
349/// Deduplicates FACE names, then stamps the derived `{faceA}|{faceB}[n]` names
350/// onto the edges (which embed the now-unique face names), so face AND edge
351/// reference_selections both resolve uniquely against the scene.
352pub fn register_added(base: BrepSolid, name: &str) -> AddedSolid {
353    register_added_grouped(base, name, &[])
354}
355
356/// [`register_added`] plus CONTAINER groups: `containers` is
357/// `(container_name, member_face_names)`, the un-keyed name a profile-swept
358/// feature publishes for all of its per-loop faces at once (`{cap_base}_START`
359/// standing for every `{cap_base}:L{id}_START`).
360///
361/// Members are resolved against the FINAL solid, so a face a boolean merged or
362/// consumed simply drops out of its group, and a container whose members all
363/// vanished registers nothing. Each container also gets the EDGE group implied by
364/// it — see [`collect_edge_group_aliases`].
365pub fn register_added_grouped(
366    mut base: BrepSolid,
367    name: &str,
368    containers: &[(String, Vec<String>)],
369) -> AddedSolid {
370    ensure_unique_face_names(&mut base);
371    stamp_derived_edge_names(&mut base);
372    let face_names = collect_face_names(&base);
373    let edge_names = collect_edge_names(&base);
374    let face_groups = collect_face_groups(&face_names, containers);
375    let mut edge_groups = collect_edge_group_aliases(&base, containers);
376    collect_derived_edge_bases(&edge_names, &mut edge_groups);
377    let handle = crate::register_solid_value(base);
378    AddedSolid {
379        handle,
380        name: name.to_string(),
381        face_names,
382        edge_names,
383        face_groups,
384        edge_groups,
385    }
386}
387
388/// Publish each derived edge name's BASE — `{faceA}|{faceB}` without the `[n]`
389/// — as a group standing for every edge that carries it.
390///
391/// [`stamp_derived_edge_names`] always appends the index, even where a face
392/// pair meets along a single edge, so the bare base is never an edge name and a
393/// reference stored as one resolves to nothing on its own. Bare bases are
394/// exactly what a model saved against an UNSTAMPED solid holds — the standalone
395/// Boolean feature published the imprint's raw `{faceA}|{faceB}` until it
396/// started stamping like every other feature — so they must keep naming the
397/// edges they always named.
398///
399/// The group carries every member, which is what [`SceneMap::resolve_edge`]
400/// (single) and [`SceneMap::resolve_edge_group`] (fan-out) already read
401/// correctly: a base one edge carries resolves everywhere, one several edges
402/// carry stays ambiguous for a single-edge consumer and fans out for a
403/// selection. A base an EXACT edge name already spells is left alone (both
404/// resolvers try the exact map first, so it could never be reached), and so is
405/// one a container alias already claims.
406fn collect_derived_edge_bases(
407    edge_names: &[(u64, String)],
408    groups: &mut Vec<(String, Vec<String>)>,
409) {
410    use std::collections::HashSet;
411    let taken: HashSet<&str> = groups.iter().map(|(name, _)| name.as_str()).collect();
412    let exact: HashSet<&str> = edge_names.iter().map(|(_, name)| name.as_str()).collect();
413    let mut bases: Vec<(String, Vec<String>)> = Vec::new();
414    for (_, name) in edge_names {
415        // `{base}[{index}]`: the index is the trailing bracketed run of digits.
416        let Some(base) = name.strip_suffix(']').and_then(|head| {
417            let at = head.rfind('[')?;
418            let (base, index) = (&head[..at], &head[at + 1..]);
419            (!index.is_empty() && index.bytes().all(|byte| byte.is_ascii_digit()))
420                .then_some(base)
421        }) else {
422            continue;
423        };
424        if base.is_empty() || !base.contains('|') {
425            continue;
426        }
427        if taken.contains(base) || exact.contains(base) {
428            continue;
429        }
430        match bases.iter_mut().find(|(existing, _)| existing == base) {
431            Some((_, members)) => members.push(name.clone()),
432            None => bases.push((base.to_string(), vec![name.clone()])),
433        }
434    }
435    groups.append(&mut bases);
436}
437
438/// Keep each container's member NAMES that actually survived onto the final
439/// solid. Containers with no surviving member are dropped.
440fn collect_face_groups(
441    face_names: &[(u64, String)],
442    containers: &[(String, Vec<String>)],
443) -> Vec<(String, Vec<String>)> {
444    let mut groups = Vec::new();
445    for (container, members) in containers {
446        let live: Vec<String> = members
447            .iter()
448            .filter(|member| face_names.iter().any(|(_, name)| name == *member))
449            .cloned()
450            .collect();
451        if !live.is_empty() {
452            groups.push((container.clone(), live));
453        }
454    }
455    groups
456}
457
458/// The CONTAINER-name spelling of each derived edge name.
459///
460/// Derived edge names embed face names (`{faceA}|{faceB}[n]`), so keying the caps
461/// renamed every cap-adjacent edge along with them — a saved fillet on the top
462/// edge of a box references a name no face pair spells any more. This recomputes
463/// each edge's derived name with every member face name replaced by its container
464/// name, and registers THAT as an alias for the edge.
465///
466/// It mirrors [`stamp_derived_edge_names`] step for step — same adjacency, same
467/// encounter order, same per-base counter, substitution applied BEFORE the pair is
468/// sorted (a substituted name can sort differently from the one it replaced). So
469/// when the mapping is 1:1 — every model with a single-loop profile, i.e. every
470/// model saved before per-loop naming — the alias is byte-identical to the name
471/// that solid's edges used to carry.
472fn collect_edge_group_aliases(
473    solid: &BrepSolid,
474    containers: &[(String, Vec<String>)],
475) -> Vec<(String, Vec<String>)> {
476    use std::collections::HashMap;
477    if containers.is_empty() {
478        return Vec::new();
479    }
480    // member face name -> its container name.
481    let mut container_of: HashMap<&str, &str> = HashMap::new();
482    for (container, members) in containers {
483        for member in members {
484            container_of.insert(member.as_str(), container.as_str());
485        }
486    }
487
488    let (edge_faces, encounter_order) = collect_edge_face_names(solid);
489
490    let solid_name_fallback = "Solid".to_string();
491    let mut base_counts: HashMap<String, usize> = HashMap::new();
492    let mut aliases: HashMap<String, Vec<String>> = HashMap::new();
493    let mut order: Vec<String> = Vec::new();
494    for edge_id in encounter_order {
495        let Some(edge) = solid.edges.iter().find(|edge| edge.id == edge_id) else {
496            continue;
497        };
498        if edge.degenerate {
499            continue;
500        }
501        // Only edges `stamp_derived_edge_names` actually named advance its
502        // counter, so only those advance this one.
503        if !edge.name.as_deref().is_none_or(|name| name.contains('|')) {
504            continue;
505        }
506        let mut faces: Vec<String> = edge_faces
507            .get(&edge_id)
508            .map(|list| list.iter().filter(|name| !name.is_empty()).cloned().collect())
509            .unwrap_or_default();
510        // Substitute BEFORE sorting — a container name need not sort where the
511        // member name it replaces did.
512        let substituted = faces.iter().any(|name| container_of.contains_key(name.as_str()));
513        for face in &mut faces {
514            if let Some(container) = container_of.get(face.as_str()) {
515                *face = (*container).to_string();
516            }
517        }
518        faces.sort();
519        let base = if faces.len() >= 2 {
520            format!("{}|{}", faces[0], faces[1])
521        } else {
522            format!("{}|BOUNDARY", faces.first().unwrap_or(&solid_name_fallback))
523        };
524        let index = base_counts.entry(base.clone()).or_insert(0);
525        let alias = format!("{base}[{index}]");
526        *index += 1;
527        // An edge touching no member face spells its own name — nothing to alias.
528        // An edge whose alias IS its own name needs none either.
529        let Some(live) = edge.name.clone() else { continue };
530        if !substituted || live == alias {
531            continue;
532        }
533        if aliases.entry(alias.clone()).or_default().is_empty() {
534            order.push(alias.clone());
535        }
536        aliases.get_mut(&alias).expect("just inserted").push(live);
537    }
538    order
539        .into_iter()
540        .map(|alias| {
541            let ids = aliases.remove(&alias).unwrap_or_default();
542            (alias, ids)
543        })
544        .filter(|(_, ids)| !ids.is_empty())
545        .collect()
546}
547
548/// Finalize several bodies using the optional boolean operation. Without an
549/// operation or resolved targets, register each body separately. Otherwise fold
550/// bodies into the targets in order and use the first target's name. Missing
551/// targets are reported in `unresolved` for the caller to repair.
552pub fn finalize_solids(ctx: &FeatureContext, bodies: Vec<(String, BrepSolid)>) -> FeatureResult {
553    let boolean = read_boolean_param(ctx);
554    let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
555
556    let separate = |mut result: FeatureResult, bodies: Vec<(String, BrepSolid)>| {
557        for (name, solid) in bodies {
558            result.added.push(register_added(solid, &name));
559        }
560        result
561    };
562
563    if boolean.operation == "NONE" || boolean.targets.is_empty() {
564        return separate(result, bodies);
565    }
566    let operation = match parse_boolean_operation(&boolean.operation) {
567        Ok(operation) => operation,
568        Err(error) => return ctx.fail(error),
569    };
570    let resolved = resolve_solid_names(ctx.scene, &boolean.targets, &mut result.unresolved);
571    if resolved.is_empty() {
572        return separate(result, bodies);
573    }
574    let options = BooleanOptions {
575        merge_coplanar_faces: boolean.merge_coplanar_faces,
576        ..BooleanOptions::default()
577    };
578    let mut bodies = bodies.into_iter();
579    let Some((_, first)) = bodies.next() else {
580        return result; // no bodies at all (shouldn't happen; callers gate)
581    };
582    // Fold the first body through every target (mirrors `finalize_solid`), then
583    // fold each remaining body into the running result (targets already merged in).
584    let mut current = first;
585    for (_, target_handle) in &resolved {
586        let operand = crate::register_solid_value(current);
587        let folded = crate::with_two_registered_solids(*target_handle, operand, |target, op_solid| {
588            crate::boolean_operation(target, op_solid, operation, &options)
589        });
590        crate::free_registered_solid(operand);
591        match folded {
592            Ok(solid) => current = solid,
593            Err(error) => return ctx.fail(format!("boolean {} failed: {error}", boolean.operation)),
594        }
595    }
596    for (_, body) in bodies {
597        match crate::boolean_operation(&current, &body, operation, &options) {
598            Ok(solid) => current = solid,
599            Err(error) => return ctx.fail(format!("boolean {} failed: {error}", boolean.operation)),
600        }
601    }
602    result.added.push(register_added(current, &resolved[0].0));
603    result.removed = resolved.into_iter().map(|(name, _)| name).collect();
604    result
605}
606
607pub fn finalize_solid(ctx: &FeatureContext, base: BrepSolid, base_name: &str) -> FeatureResult {
608    finalize_solid_grouped(ctx, base, base_name, &[])
609}
610
611/// [`finalize_solid`] carrying CONTAINER groups (the profile-swept features'
612/// per-loop cap roll-ups) onto whichever solid ends up registered — the base
613/// itself, or the boolean's result when one is folded. Members that the boolean
614/// consumed drop out of their group on their own (`register_added_grouped`
615/// resolves members against the FINAL solid).
616pub fn finalize_solid_grouped(
617    ctx: &FeatureContext,
618    base: BrepSolid,
619    base_name: &str,
620    containers: &[(String, Vec<String>)],
621) -> FeatureResult {
622    let boolean = read_boolean_param(ctx);
623    let mut result = FeatureResult::empty(ctx.id.clone(), ctx.feature_type.clone());
624
625    if boolean.operation == "NONE" || boolean.targets.is_empty() {
626        result.added.push(register_added_grouped(base, base_name, containers));
627        return result;
628    }
629
630    let operation = match parse_boolean_operation(&boolean.operation) {
631        Ok(operation) => operation,
632        Err(error) => return ctx.fail(error),
633    };
634
635    let resolved = resolve_solid_names(ctx.scene, &boolean.targets, &mut result.unresolved);
636
637    // Nothing resolved: emit the base un-booleaned; `unresolved` signals the caller to
638    // repair + re-dispatch (NOT a hard error — Stage 5 gate).
639    if resolved.is_empty() {
640        result.added.push(register_added_grouped(base, base_name, containers));
641        return result;
642    }
643
644    let options = BooleanOptions {
645        merge_coplanar_faces: boolean.merge_coplanar_faces,
646        ..BooleanOptions::default()
647    };
648    // Fold, keeping the running result as a LOCAL owned BrepSolid: register only
649    // the operand needed for the two-solid borrow, fold, free that operand — no
650    // leaked intermediates. The base primitive is never itself scene-resident.
651    let mut current = base;
652    for (_, target_handle) in &resolved {
653        let operand_handle = crate::register_solid_value(current);
654        let folded = crate::with_two_registered_solids(*target_handle, operand_handle, |target, operand| {
655            crate::boolean_operation(target, operand, operation, &options)
656        });
657        crate::free_registered_solid(operand_handle);
658        match folded {
659            Ok(solid) => current = solid,
660            Err(error) => return ctx.fail(format!("boolean {} failed: {error}", boolean.operation)),
661        }
662    }
663
664    let result_name = resolved[0].0.clone();
665    result
666        .added
667        .push(register_added_grouped(current, &result_name, containers));
668    result.removed = resolved.into_iter().map(|(name, _)| name).collect();
669    result
670}
671
672// ===========================================================================
673// Dressup edge/face selection — shared by fillet.rs (F) and chamfer.rs (CH)
674// ===========================================================================
675//
676// Fillet and chamfer share selection resolution; chamfer alone validates its
677// `direction`.
678// Each feature owns its numeric parameters, kernel call, and abort policy.
679
680/// One solid and sampled edge points for a fillet or chamfer operation.
681pub struct BlendTarget {
682    pub handle: u32,
683    pub name: String,
684    pub edge_points: Vec<Vec3>,
685    /// Canonical names parallel to `edge_points`, used to name generated faces.
686    /// Unnamed edges fall back to `E{edge_id}`.
687    pub edge_names: Vec<String>,
688}
689
690/// Resolved blend selection; callers decide how to handle cross-solid input.
691pub struct BlendSelection {
692    /// Present when at least one reference resolves, all to the same solid.
693    pub target: Option<BlendTarget>,
694    pub multi_solid: bool,
695    /// Missing edge/face names for the caller to repair and redispatch.
696    pub unresolved: Vec<String>,
697}
698
699/// One resolved ref: which topology entity of the owning solid it names.
700enum ResolvedRef {
701    Edge(u64),
702    Face(u64),
703}
704
705/// A `reference_selection` entry -> its name: a plain string, or an object with a
706/// `name` field. No token-splitting / `[N]`-index /
707/// snapshot-scoring — exact match only (contract rule 1 forbids the heuristic).
708pub(crate) fn reference_name(value: &serde_json::Value) -> Option<String> {
709    let raw = match value {
710        serde_json::Value::String(text) => Some(text.as_str()),
711        serde_json::Value::Object(map) => map.get("name").and_then(|value| value.as_str()),
712        _ => None,
713    }?;
714    let trimmed = raw.trim();
715    (!trimmed.is_empty()).then(|| trimmed.to_string())
716}
717
718/// The canonical scene name of an edge (`{faceA}|{faceB}[n]`, stamped by
719/// `stamp_derived_edge_names`), used to name the blend face grown from it. An
720/// unnamed / empty edge (e.g. a hand-built test solid never run through the
721/// canonical pass) falls back to a deterministic `E{edge_id}` so parallel blend
722/// faces still get DISTINCT, stable names.
723fn edge_name_or_id(solid: &BrepSolid, edge_id: u64) -> String {
724    solid
725        .edges
726        .iter()
727        .find(|edge| edge.id == edge_id)
728        .and_then(|edge| edge.name.clone())
729        .filter(|name| !name.is_empty())
730        .unwrap_or_else(|| format!("E{edge_id}"))
731}
732
733/// Midpoint of an edge's curve (`evaluate((t0 + t1) / 2)`) — the sample the kernel
734/// decodes back to the edge.
735fn sample_edge_midpoint(solid: &BrepSolid, edge_id: u64) -> Result<Vec3, String> {
736    let edge = solid
737        .edges
738        .iter()
739        .find(|edge| edge.id == edge_id)
740        .ok_or_else(|| format!("dressup: edge {edge_id} not found on target solid"))?;
741    let mid = (edge.t0 + edge.t1) * 0.5;
742    edge.curve
743        .evaluate(mid)
744        .map_err(|error| format!("dressup: edge {edge_id} midpoint sample failed: {error}"))
745}
746
747/// Boundary edge ids of a face (its loops' coedges), deduped, skipping DEGENERATE
748/// (pole) edges — "fillet a face" means "fillet all its real shared edges".
749fn face_boundary_edges(solid: &BrepSolid, face_id: u64) -> Result<Vec<u64>, String> {
750    let face = solid
751        .shells
752        .iter()
753        .flat_map(|shell| &shell.faces)
754        .find(|face| face.id == face_id)
755        .ok_or_else(|| format!("dressup: face {face_id} not found on target solid"))?;
756    let mut ids = Vec::new();
757    let mut seen = FxHashSet::default();
758    for loop_record in &face.loops {
759        for coedge in &loop_record.coedges {
760            let degenerate = solid
761                .edges
762                .iter()
763                .find(|edge| edge.id == coedge.edge_id)
764                .map(|edge| edge.degenerate)
765                .unwrap_or(false);
766            if degenerate {
767                continue;
768            }
769            if seen.insert(coedge.edge_id) {
770                ids.push(coedge.edge_id);
771            }
772        }
773    }
774    Ok(ids)
775}
776
777/// Resolve the `edges` `reference_selection` (EDGE and/or FACE names) against the
778/// live scene-map and sample one 3D point per selected edge. Exact-match only;
779/// misses accumulate into `unresolved`. Errs only on a genuine geometry/lookup
780/// failure while sampling the resolved solid.
781pub fn resolve_blend_selection(ctx: &FeatureContext) -> Result<BlendSelection, String> {
782    let names = reference_names(ctx.param("edges"));
783
784    let mut unresolved = Vec::new();
785    let mut resolved: Vec<(u32, ResolvedRef)> = Vec::new();
786    for name in names {
787        // Exact edge, exact face, then the CONTAINER groups — a name a model
788        // stored before per-loop cap naming stands for every face (or edge) the
789        // feature built from that profile, so a fillet on "the end cap" of a
790        // 3-loop extrude blends all three, and one on a single-loop extrude
791        // blends the one face it always meant.
792        let edges = ctx.scene.resolve_edge_group(&name);
793        let faces = ctx.scene.resolve_face_group(&name);
794        if edges.is_empty() && faces.is_empty() {
795            unresolved.push(name);
796            continue;
797        }
798        for edge in edges {
799            resolved.push((edge.handle, ResolvedRef::Edge(edge.edge_id)));
800        }
801        for face in faces {
802            resolved.push((face.handle, ResolvedRef::Face(face.face_id)));
803        }
804    }
805
806    // Distinct owning handles among the resolved refs.
807    let mut handles: Vec<u32> = resolved.iter().map(|(handle, _)| *handle).collect();
808    handles.sort_unstable();
809    handles.dedup();
810    if handles.len() > 1 {
811        return Ok(BlendSelection {
812            target: None,
813            multi_solid: true,
814            unresolved,
815        });
816    }
817    let Some(&handle) = handles.first() else {
818        // Nothing resolved: `unresolved` carries the misses (rule 1); no target.
819        return Ok(BlendSelection {
820            target: None,
821            multi_solid: false,
822            unresolved,
823        });
824    };
825
826    // Reverse-lookup the target's scene name — the result reuses it (loop
827    // removes-then-adds, freeing the consumed handle), matching the established dressup naming
828    // (`new Solid(kernel, { name: targetSolid.name })`).
829    let name = ctx
830        .scene
831        .solids
832        .iter()
833        .find(|(_, &registered)| registered == handle)
834        .map(|(name, _)| name.clone())
835        .ok_or_else(|| format!("dressup: target handle {handle} has no scene name"))?;
836
837    // Sample one point per selected edge; dedup edge ids GLOBALLY across all refs
838    // (dedup spans the whole selection, not per-face) in a single short borrow.
839    // The originating edge NAME is captured PARALLEL to each point so the grown
840    // blend face can be named after the edge it came from.
841    let (edge_points, edge_names) = crate::with_registered_solid_str(handle, |solid| {
842        let mut seen = FxHashSet::default();
843        let mut points = Vec::new();
844        let mut names = Vec::new();
845        for (_, entity) in &resolved {
846            match entity {
847                ResolvedRef::Edge(edge_id) => {
848                    if seen.insert(*edge_id) {
849                        points.push(sample_edge_midpoint(solid, *edge_id)?);
850                        names.push(edge_name_or_id(solid, *edge_id));
851                    }
852                }
853                ResolvedRef::Face(face_id) => {
854                    for edge_id in face_boundary_edges(solid, *face_id)? {
855                        if seen.insert(edge_id) {
856                            points.push(sample_edge_midpoint(solid, edge_id)?);
857                            names.push(edge_name_or_id(solid, edge_id));
858                        }
859                    }
860                }
861            }
862        }
863        Ok((points, names))
864    })?;
865
866    Ok(BlendSelection {
867        target: Some(BlendTarget {
868            handle,
869            name,
870            edge_points,
871            edge_names,
872        }),
873        multi_solid: false,
874        unresolved,
875    })
876}
877
878/// Shared wall and corner prefix, `{featureID || 'F'}:BLEND`.
879pub fn blend_face_base(id: &str) -> String {
880    format!("{}:BLEND", if id.is_empty() { "F" } else { id })
881}
882
883/// Per-edge face name; distinct wall names keep derived blend-edge names stable.
884pub fn blend_face_name(id: &str, edge_name: &str) -> String {
885    format!("{}:{}", blend_face_base(id), edge_name)
886}
887
888/// Reject chamfer directions that depended on the removed mesh blend pipeline.
889/// Fillet has no `direction` param: its blend side is always classified per edge.
890pub fn require_blend_direction(ctx: &FeatureContext, operation: &str) -> Result<(), String> {
891    let direction = ctx
892        .param("direction")
893        .and_then(|value| value.as_str())
894        .map(|text| text.trim().to_uppercase())
895        .filter(|text| !text.is_empty())
896        .unwrap_or_else(|| "AUTO".to_string());
897    if direction != "AUTO" && direction != "INSET" {
898        return Err(format!(
899            "{operation} direction '{direction}' belonged to the removed legacy mesh pipeline (only AUTO/INSET exist)"
900        ));
901    }
902    Ok(())
903}
904
905/// Read a finite parameter value, falling back for missing/null entries or
906/// evaluation errors. Used by features whose optional fields are lenient.
907pub(super) fn number_or_default(ctx: &FeatureContext, key: &str, default: f64) -> f64 {
908    match ctx.param(key) {
909        None | Some(serde_json::Value::Null) => default,
910        Some(_) => match ctx.number(key) {
911            Ok(value) if value.is_finite() => value,
912            _ => default,
913        },
914    }
915}
916
917/// An OPTIONAL numeric param: `None` when absent, else evaluated via
918/// [`FeatureContext::number`] (a hard error on a broken expression, like the
919/// primitive features). Used for the dressup gate/shape params (`inflate`,
920/// `nudgeFaceDistance`, `radiusEnd`, `distance2`, `angle`).
921pub fn optional_number(ctx: &FeatureContext, key: &str) -> Result<Option<f64>, String> {
922    if ctx.param(key).is_some() {
923        ctx.number(key).map(Some)
924    } else {
925        Ok(None)
926    }
927}
928
929// ===========================================================================
930// The ONE feature-transform convention (XFORM, ACOMP, gizmo write-back)
931// ===========================================================================
932
933/// Compose the row-major TRS matrix `[ RS | translate + pivot − RS·pivot ]`
934/// with `R` the intrinsic `XYZ` Euler rotation (= `Rx·Ry·Rz`) and `S =
935/// diag(scale)` — the SINGLE feature-transform compose. XFORM, the ACOMP
936/// instance pose, and any solver/gizmo pose write-back must all speak this
937/// convention; forking it would make the same stored angles place geometry
938/// differently per consumer.
939pub fn compose_trs_matrix(
940    translate: [f64; 3],
941    rotate_rad: [f64; 3],
942    scale: [f64; 3],
943    pivot: [f64; 3],
944) -> [f64; 16] {
945    let (a, b) = (rotate_rad[0].cos(), rotate_rad[0].sin());
946    let (c, d) = (rotate_rad[1].cos(), rotate_rad[1].sin());
947    let (e, f) = (rotate_rad[2].cos(), rotate_rad[2].sin());
948    let (ae, af, be, bf) = (a * e, a * f, b * e, b * f);
949    // Intrinsic 'XYZ' Euler rotation (row-major) = Rx·Ry·Rz.
950    let r = [
951        [c * e, -c * f, d],
952        [af + be * d, ae - bf * d, -b * c],
953        [bf - ae * d, be + af * d, a * c],
954    ];
955    // RS = R·diag(scale): scale each column (matches the standard TRS compose).
956    let mut rs = [[0.0f64; 3]; 3];
957    for i in 0..3 {
958        for j in 0..3 {
959            rs[i][j] = r[i][j] * scale[j];
960        }
961    }
962    let rs_pivot = [
963        rs[0][0] * pivot[0] + rs[0][1] * pivot[1] + rs[0][2] * pivot[2],
964        rs[1][0] * pivot[0] + rs[1][1] * pivot[1] + rs[1][2] * pivot[2],
965        rs[2][0] * pivot[0] + rs[2][1] * pivot[1] + rs[2][2] * pivot[2],
966    ];
967    let tx = translate[0] + pivot[0] - rs_pivot[0];
968    let ty = translate[1] + pivot[1] - rs_pivot[1];
969    let tz = translate[2] + pivot[2] - rs_pivot[2];
970    [
971        rs[0][0], rs[0][1], rs[0][2], tx, //
972        rs[1][0], rs[1][1], rs[1][2], ty, //
973        rs[2][0], rs[2][1], rs[2][2], tz, //
974        0.0, 0.0, 0.0, 1.0,
975    ]
976}
977
978/// Read a vec3 VALUE (array `[x,y,z]` or object `{x,y,z}`); each component may
979/// be a JSON number, an expression string (evaluated against the shared env),
980/// or null/missing (→ default). `None`/`Null` as the whole value is the
981/// default. Anything else is a loud error naming `key`. The shared reader for
982/// TOP-LEVEL vec3 params (via [`FeatureContext::param`]) and NESTED ones (the
983/// ACOMP `transform.translate` / `transform.rotateEulerDeg`).
984pub fn vec3_from_value(
985    env: &crate::feature_pipeline::Env,
986    value: Option<&serde_json::Value>,
987    key: &str,
988    default: [f64; 3],
989) -> Result<[f64; 3], String> {
990    let Some(value) = value.filter(|value| !value.is_null()) else {
991        return Ok(default);
992    };
993    let component = |slot: Option<&serde_json::Value>, fallback: f64| -> Result<f64, String> {
994        match slot {
995            None | Some(serde_json::Value::Null) => Ok(fallback),
996            Some(serde_json::Value::Number(number)) => number
997                .as_f64()
998                .ok_or_else(|| format!("param `{key}` has a non-finite component")),
999            Some(serde_json::Value::String(source)) => {
1000                let number = env
1001                    .eval(source)
1002                    .map_err(|error| format!("param `{key}`: {error}"))?;
1003                // An expression may evaluate to a NON-finite number (`1/0`) — that
1004                // must fail HERE, naming the slot, rather than travel on as a
1005                // point coordinate or reach `AffineTransform::new` as a nameless
1006                // "matrix must be finite".
1007                if !number.is_finite() {
1008                    return Err(format!(
1009                        "param `{key}`: `{source}` evaluated to {number}"
1010                    ));
1011                }
1012                Ok(number)
1013            }
1014            Some(other) => Err(format!(
1015                "param `{key}` component must be a number or expression, found {other}"
1016            )),
1017        }
1018    };
1019    if let Some(array) = value.as_array() {
1020        return Ok([
1021            component(array.first(), default[0])?,
1022            component(array.get(1), default[1])?,
1023            component(array.get(2), default[2])?,
1024        ]);
1025    }
1026    if value.is_object() {
1027        return Ok([
1028            component(value.get("x"), default[0])?,
1029            component(value.get("y"), default[1])?,
1030            component(value.get("z"), default[2])?,
1031        ]);
1032    }
1033    Err(format!("param `{key}` must be a vec3 array or object"))
1034}
1035
1036// ===========================================================================
1037// Reference-name + geometric-input resolution shared by profile-consumers
1038// ===========================================================================
1039
1040/// Resolve exact names in selection order, retaining duplicates and appending misses.
1041pub(super) fn resolve_solid_names(
1042    scene: &crate::feature_pipeline::SceneMap,
1043    names: &[String],
1044    unresolved: &mut Vec<String>,
1045) -> Vec<(String, u32)> {
1046    let mut resolved = Vec::new();
1047    for name in names {
1048        match scene.resolve_solid(name) {
1049            Some(handle) => resolved.push((name.clone(), handle)),
1050            None => unresolved.push(name.clone()),
1051        }
1052    }
1053    resolved
1054}
1055
1056/// Use an explicit sheet selection, or the sole sheet-metal body in the scene.
1057/// Missing or ambiguous implicit targets remain unselected.
1058pub(super) fn sheet_body_name(ctx: &FeatureContext) -> Option<String> {
1059    first_reference_name(ctx.param("sheet")).or_else(|| {
1060        let mut bodies = ctx.scene.solids.iter().filter_map(|(name, &handle)| {
1061            crate::feature_pipeline::sheet_metal::get_tree(handle).map(|_| name.clone())
1062        });
1063        match (bodies.next(), bodies.next()) {
1064            (Some(only), None) => Some(only),
1065            _ => None,
1066        }
1067    })
1068}
1069
1070/// Read a single selection without descending into nested arrays.
1071/// An empty first textual entry means no selection; later entries are ignored.
1072pub(super) fn single_reference_name(value: Option<&serde_json::Value>) -> Option<String> {
1073    let name = match value {
1074        Some(serde_json::Value::String(text)) => Some(text.clone()),
1075        Some(serde_json::Value::Array(array)) => array.iter().find_map(|entry| {
1076            entry
1077                .as_str()
1078                .or_else(|| entry.get("name").and_then(|name| name.as_str()))
1079                .map(str::to_string)
1080        }),
1081        Some(object @ serde_json::Value::Object(_)) => {
1082            object.get("name").and_then(|name| name.as_str()).map(str::to_string)
1083        }
1084        _ => None,
1085    }?;
1086    let trimmed = name.trim();
1087    (!trimmed.is_empty()).then(|| trimmed.to_string())
1088}
1089
1090/// The first reference name of a `reference_selection` param value (a plain
1091/// string, a `{name}` object, or an array of either — post-sanitization it is an
1092/// array). Exact match only; no `[N]` index / snapshot scoring.
1093pub fn first_reference_name(value: Option<&serde_json::Value>) -> Option<String> {
1094    fn one(value: &serde_json::Value) -> Option<String> {
1095        match value {
1096            serde_json::Value::Array(items) => items.iter().find_map(one),
1097            other => reference_name(other),
1098        }
1099    }
1100    value.and_then(one)
1101}
1102
1103/// Normalize a PROFILE reference name: a committed sketch is shown as a render-side
1104/// display SHEET whose planar face is PICKED as `{sketch}:FACE`, but the kernel run
1105/// never materializes that sheet — the pick actually names the sketch's PROFILE. So
1106/// strip a trailing `:FACE` to the sketch base, and every profile-consumer's resolve
1107/// + cap-naming + sketch-consume then uses the canonical id. Resident SOLID faces are
1108/// `{solid}|{face}[n]` and never end in `:FACE`, so real faces pass through untouched.
1109/// Apply ONLY to `profile` fields (never to `path`/`axis`).
1110pub fn normalize_profile_alias(name: String) -> String {
1111    match name.strip_suffix(":FACE") {
1112        Some(base) => base.to_string(),
1113        None => name,
1114    }
1115}
1116
1117/// Names from an array-valued reference selection, preserving order and duplicates.
1118/// Missing and non-array values are ignored; entries use [`reference_name`].
1119pub(crate) fn reference_name_array(value: Option<&serde_json::Value>) -> Vec<String> {
1120    value
1121        .and_then(serde_json::Value::as_array)
1122        .map(|items| items.iter().filter_map(reference_name).collect())
1123        .unwrap_or_default()
1124}
1125
1126/// ALL reference names of a `reference_selection` param, in order (a `multiple`
1127/// selection). Also accepts a single string or `{name}` object; empties are dropped.
1128pub fn reference_names(value: Option<&serde_json::Value>) -> Vec<String> {
1129    match value {
1130        Some(serde_json::Value::Array(items)) => items.iter().filter_map(reference_name).collect(),
1131        Some(other) => reference_name(other).into_iter().collect(),
1132        None => Vec::new(),
1133    }
1134}
1135
1136/// Reference names with duplicates removed, retaining first-occurrence order.
1137pub(super) fn unique_reference_names(value: Option<&serde_json::Value>) -> Vec<String> {
1138    let mut seen = std::collections::HashSet::new();
1139    reference_names(value)
1140        .into_iter()
1141        .filter(|name| seen.insert(name.clone()))
1142        .collect()
1143}
1144
1145/// `consumeProfileSketch` (default true) — the profile-consumers remove the sketch
1146/// after building, echoing its name into `removed` for the caller's display cleanup.
1147pub fn consume_profile_sketch(ctx: &FeatureContext) -> bool {
1148    !matches!(
1149        ctx.param("consumeProfileSketch"),
1150        Some(serde_json::Value::Bool(false))
1151    )
1152}
1153
1154/// Echo the consumed sketch's base name (`{ref}` with any `:PROFILE` stripped) into
1155/// `result.removed` when `consumeProfileSketch` is set. Sketches register no scene
1156/// solid, so this only signals caller-side display cleanup.
1157pub fn consume_sketch(ctx: &FeatureContext, reference_name: &str, result: &mut FeatureResult) {
1158    if !consume_profile_sketch(ctx) {
1159        return;
1160    }
1161    consume_sketch_always(reference_name, result);
1162}
1163
1164/// Sheet-metal contract: a sketch that fed a generated piece of geometry is
1165/// ALWAYS consumed (removed from the scene) — there is no keep-the-sketch
1166/// switch. Non-sheet-metal profile consumers keep the `consumeProfileSketch`
1167/// gate via `consume_sketch` above.
1168pub fn consume_sketch_always(reference_name: &str, result: &mut FeatureResult) {
1169    let base = reference_name
1170        .strip_suffix(":PROFILE")
1171        .unwrap_or(reference_name)
1172        .to_string();
1173    if !result.removed.contains(&base) {
1174        result.removed.push(base);
1175    }
1176}
1177
1178/// Map one profile loop's WORLD-space curves into the profile's own plane as
1179/// local `(u, v, 0)` curves: each control point `p` → `((p−origin)·x, (p−origin)·y, 0)`,
1180/// weights kept. A rigid in-plane change of frame, so rational curves (circles /
1181/// arcs) stay EXACT — the sheet-metal `Flat::holes` contract (hole loops in
1182/// flat-local coordinates at `z = 0`).
1183pub fn profile_loop_uv_curves(
1184    profile: &crate::feature_pipeline::SketchProfile,
1185    profile_loop: &crate::feature_pipeline::ProfileLoop,
1186) -> Result<Vec<NurbsCurve>, String> {
1187    let mut curves = Vec::with_capacity(profile_loop.curves.len());
1188    for curve in &profile_loop.curves {
1189        let mut control_points = Vec::with_capacity(curve.control_points.len());
1190        for cp in &curve.control_points {
1191            let delta = cp.point()?.sub(profile.origin);
1192            control_points.push(crate::Vec4::from_point(
1193                Vec3::new(delta.dot(profile.x_axis), delta.dot(profile.y_axis), 0.0),
1194                cp.w,
1195            ));
1196        }
1197        curves.push(NurbsCurve::new(
1198            curve.degree,
1199            curve.knots.clone(),
1200            control_points,
1201        )?);
1202    }
1203    Ok(curves)
1204}
1205
1206/// Resolve a sweep/rib PATH reference to its ordered world curve chain: a SKETCH
1207/// path (`scene.resolve_path`), a single resident solid EDGE (its curve TRIMMED
1208/// to the edge — [`edge_curve`]), or a sketch GEOMETRY name `{sketchId}:G{gid}`
1209/// → the owning sketch's published chain (the tube.rs convention —
1210/// per-geometry curves are not scene-indexed, and a chain holding more than the
1211/// named segment defers loudly downstream). A consumer wanting one path curve
1212/// uses the chain when it is length 1.
1213pub fn resolve_path(ctx: &FeatureContext, name: &str) -> Result<Vec<NurbsCurve>, String> {
1214    if let Some(chain) = ctx.scene.resolve_path(name) {
1215        return Ok(chain.clone());
1216    }
1217    if let Some(edge) = ctx.scene.resolve_edge(name) {
1218        return Ok(vec![edge_curve(edge)?]);
1219    }
1220    if let Some((owner, geometry)) = name.rsplit_once(':') {
1221        if geometry.len() > 1 && geometry.starts_with('G') {
1222            if let Some(chain) = ctx.scene.resolve_path(owner) {
1223                return Ok(chain.clone());
1224            }
1225        }
1226    }
1227    Err(format!(
1228        "path '{name}' not found (no sketch path or resident edge)"
1229    ))
1230}
1231
1232/// ONE segment of a resolved sweep path: the world curve, plus the SOURCE NAME
1233/// it came from (`{sketchId}:G{gid}` for a sketch geometry, the edge's own scene
1234/// name for a resident solid edge).
1235///
1236/// The name is the whole point: a feature that builds one PORTION per path
1237/// segment names that portion's faces after the segment, so adding, removing or
1238/// re-picking a path edge never renames a face built from a segment that did not
1239/// change. Naming by chain POSITION would renumber every downstream face the
1240/// moment a segment is inserted.
1241#[derive(Debug, Clone)]
1242pub struct PathSegment {
1243    pub name: String,
1244    pub curve: NurbsCurve,
1245    /// The segment's SCREW AXIS direction when its publisher knows it is a helix
1246    /// ([`SceneMap::resolve_path_segment_axes`]). Reversing the segment leaves it
1247    /// valid: a frame referenced to an axis does not depend on the axis' sign.
1248    pub axis: Option<Vec3>,
1249}
1250
1251/// Resolve a `path` `reference_selection` — one name or MANY — to a single
1252/// ordered, head-to-tail connected chain of NAMED segments.
1253///
1254/// Each reference contributes its curves through [`resolve_path`] (a whole
1255/// SKETCH's published chain, a single `{sketchId}:G{gid}` sketch segment, or a
1256/// resident solid EDGE), and each curve carries a name: the publisher's
1257/// per-segment names when it published them
1258/// ([`SceneMap::resolve_path_segment_names`]), else the reference name itself
1259/// (a one-curve reference) or `{reference}[i]` (an unnamed multi-curve chain).
1260/// A curve already contributed under the same name is dropped, so selecting a
1261/// sketch AND one of its own segments is not a duplicate.
1262///
1263/// The segments are then CHAINED by endpoint coincidence: the first reference
1264/// seeds the chain in its natural orientation, the chain grows from its tail and
1265/// then from its head (mirroring the sketch chainer), and a segment that has to
1266/// run backwards to join is reversed. So the picking ORDER does not matter —
1267/// the same set of edges always yields the same chain. Any segment that reaches
1268/// neither end is a disconnected or branching selection, and is reported BY NAME
1269/// rather than silently dropped.
1270/// Resolve a `path` `reference_selection` to the sweep's own PATH TYPE: the
1271/// ordered chain [`resolve_path_chain`] produces, CLASSIFIED once
1272/// ([`crate::SweepPath`]) — open or closed, the plane it lies in if it lies in
1273/// one, and the continuity of every joint including a closed path's closing one.
1274///
1275/// Every sweep refusal that used to answer one of those questions for itself now
1276/// reads this: the builder's corner gate, the closed-path lane, and `SW`'s own
1277/// `translate` fold-back rule, which could previously only report a closed path
1278/// as the consequence it happened to notice (a segment that must reverse).
1279/// Resolution, order and orientation are unchanged — this classifies the chain
1280/// the resolver produced, it does not re-resolve or re-order it.
1281pub fn resolve_sweep_path(
1282    ctx: &FeatureContext,
1283    names: &[String],
1284) -> Result<crate::SweepPath, String> {
1285    let segments = resolve_path_chain(ctx, names)?;
1286    let mut curves = Vec::with_capacity(segments.len());
1287    let mut segment_names = Vec::with_capacity(segments.len());
1288    let mut axes = Vec::with_capacity(segments.len());
1289    for segment in segments {
1290        curves.push(segment.curve);
1291        segment_names.push(segment.name);
1292        axes.push(segment.axis);
1293    }
1294    crate::SweepPath::new(curves, segment_names)?.with_screw_axes(axes)
1295}
1296
1297pub fn resolve_path_chain(
1298    ctx: &FeatureContext,
1299    names: &[String],
1300) -> Result<Vec<PathSegment>, String> {
1301    let mut segments: Vec<PathSegment> = Vec::new();
1302    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1303    for reference in names {
1304        let curves = resolve_path(ctx, reference)?;
1305        let published = ctx.scene.resolve_path_segment_names(reference);
1306        let published_axes = ctx.scene.resolve_path_segment_axes(reference);
1307        let single = curves.len() == 1;
1308        for (index, curve) in curves.into_iter().enumerate() {
1309            let name = published
1310                .and_then(|names| names.get(index).cloned().flatten())
1311                .filter(|name| !name.trim().is_empty())
1312                .unwrap_or_else(|| {
1313                    if single {
1314                        reference.clone()
1315                    } else {
1316                        format!("{reference}[{index}]")
1317                    }
1318                });
1319            let axis = published_axes
1320                .and_then(|axes| axes.get(index).copied().flatten())
1321                .map(|axis| axis.direction);
1322            if seen.insert(name.clone()) {
1323                segments.push(PathSegment { name, curve, axis });
1324            }
1325        }
1326    }
1327    if segments.is_empty() {
1328        return Err("path selection resolved to no curves".into());
1329    }
1330    chain_path_segments(segments)
1331}
1332
1333/// A path curve's world endpoints (`(start, end)` at its domain ends).
1334fn path_endpoints(curve: &NurbsCurve) -> Result<(Vec3, Vec3), String> {
1335    let [t0, t1] = curve.domain()?;
1336    Ok((curve.evaluate(t0)?, curve.evaluate(t1)?))
1337}
1338
1339/// Order `segments` head-to-tail (see [`resolve_path_chain`]). Segments that join
1340/// nothing are reported by name.
1341fn chain_path_segments(segments: Vec<PathSegment>) -> Result<Vec<PathSegment>, String> {
1342    if segments.len() < 2 {
1343        return Ok(segments);
1344    }
1345    let ends: Vec<(Vec3, Vec3)> = segments
1346        .iter()
1347        .map(|segment| path_endpoints(&segment.curve))
1348        .collect::<Result<_, _>>()?;
1349    // Scale-relative join tolerance, on the same footing as the sketch chainer's
1350    // absolute 1e-5: individually picked solid edges meet only to within the
1351    // tolerance their own builders left behind.
1352    let scale = ends
1353        .iter()
1354        .flat_map(|(a, b)| [a, b])
1355        .map(|point| point.sub(ends[0].0).length())
1356        .fold(1.0_f64, f64::max);
1357    let tolerance = 1e-5 * scale;
1358    let joins = |a: Vec3, b: Vec3| a.sub(b).length() <= tolerance;
1359
1360    let mut used = vec![false; segments.len()];
1361    used[0] = true;
1362    // `(index, reversed)` in head-to-tail order, seeded on the FIRST picked
1363    // segment in its natural orientation.
1364    let mut order: Vec<(usize, bool)> = vec![(0, false)];
1365    let mut head = ends[0].0;
1366    let mut tail = ends[0].1;
1367    loop {
1368        // Closed chain: the tail returned to the head — stop before overshooting
1369        // into a segment attached at the seam (the sketch chainer's rule).
1370        if order.len() > 1 && joins(tail, head) {
1371            break;
1372        }
1373        if let Some((index, reversed, far)) = attach_path_segment(&ends, &used, tail, tolerance) {
1374            used[index] = true;
1375            order.push((index, reversed));
1376            tail = far;
1377            continue;
1378        }
1379        if let Some((index, reversed, far)) = attach_path_segment(&ends, &used, head, tolerance) {
1380            used[index] = true;
1381            // Walking head-ward: `attach_path_segment` orients tail-ward, so flip.
1382            order.insert(0, (index, !reversed));
1383            head = far;
1384            continue;
1385        }
1386        break;
1387    }
1388    let stranded: Vec<&str> = segments
1389        .iter()
1390        .zip(&used)
1391        .filter(|(_, used)| !**used)
1392        .map(|(segment, _)| segment.name.as_str())
1393        .collect();
1394    if !stranded.is_empty() {
1395        return Err(format!(
1396            "the selected path edges do not form ONE connected chain — {} joins neither \
1397             end of the chain the other selections form (a sweep path must be a single \
1398             head-to-tail run; select connected edges, or one branch at a time)",
1399            stranded.join(", ")
1400        ));
1401    }
1402    let mut chained = Vec::with_capacity(segments.len());
1403    let mut taken: Vec<Option<PathSegment>> = segments.into_iter().map(Some).collect();
1404    for (index, reversed) in order {
1405        let mut segment = taken[index].take().expect("each segment placed once");
1406        if reversed {
1407            segment.curve = segment.curve.reversed()?;
1408        }
1409        chained.push(segment);
1410    }
1411    Ok(chained)
1412}
1413
1414/// The first unused segment with an endpoint at `cursor`, oriented to walk AWAY
1415/// from it: `(index, reversed, far_end)`. Index-order scan, deterministic.
1416fn attach_path_segment(
1417    ends: &[(Vec3, Vec3)],
1418    used: &[bool],
1419    cursor: Vec3,
1420    tolerance: f64,
1421) -> Option<(usize, bool, Vec3)> {
1422    for (index, (start, end)) in ends.iter().enumerate() {
1423        if used[index] {
1424            continue;
1425        }
1426        if start.sub(cursor).length() <= tolerance {
1427            return Some((index, false, *end));
1428        }
1429        if end.sub(cursor).length() <= tolerance {
1430            return Some((index, true, *start));
1431        }
1432    }
1433    None
1434}
1435
1436/// Translate every curve of a profile loop by `offset` — the per-portion
1437/// placement a multi-segment sweep needs (each path segment sweeps a COPY of the
1438/// profile, moved to that segment's start).
1439pub fn translate_curves(curves: &[NurbsCurve], offset: Vec3) -> Result<Vec<NurbsCurve>, String> {
1440    curves
1441        .iter()
1442        .map(|curve| translate_curve(curve, offset))
1443        .collect()
1444}
1445
1446/// The SKETCH PLANE governing a path/profile reference, if it has one: the
1447/// reference name itself (a SKETCH publishes its resolved frame under its own id)
1448/// or the owner prefix of a sketch-geometry name (`{sketchId}:G{gid}`,
1449/// `{sketchId}:PROFILE`, `{sketchId}:FACE`). `None` for a non-sketch reference —
1450/// a resident solid EDGE publishes no frame.
1451///
1452/// This is the plane a sketch was AUTHORED on, and it is the answer to every
1453/// "which way is out of this chain?" question a consumer would otherwise re-derive
1454/// from the chain's own bends: the re-derivation is sign-ambiguous (it follows the
1455/// order the segments happen to be drawn in), and for a chain with no bend at all —
1456/// a single straight segment — it does not exist. Consumed by the sheet-metal
1457/// contour flange, and by the rib.
1458pub fn sketch_plane_frame(ctx: &FeatureContext, names: &[String]) -> Option<Frame> {
1459    for name in names {
1460        if let Some(frame) = ctx.scene.resolve_frame(name) {
1461            return Some(frame);
1462        }
1463        if let Some((owner, _)) = name.rsplit_once(':') {
1464            if let Some(frame) = ctx.scene.resolve_frame(owner) {
1465                return Some(frame);
1466            }
1467        }
1468    }
1469    None
1470}
1471
1472/// The base name a sketch-consuming feature should REMOVE for a reference: the
1473/// owning SKETCH for a sketch-geometry pick (`{sketchId}:G{gid}` whose owner
1474/// published a frame), else the raw reference name (`consume_sketch_always` strips
1475/// `:PROFILE` itself; non-sketch names are harmless display no-ops).
1476///
1477/// A viewport pick on a sketch's drawn SEGMENT stores `{sketchId}:G{gid}`, so
1478/// without this the consume would echo a name no display knows and the consumed
1479/// sketch would linger in the scene. Consumed by the sheet-metal contour flange,
1480/// and by the rib.
1481pub fn sketch_base_name(ctx: &FeatureContext, name: &str) -> String {
1482    if let Some((owner, tail)) = name.rsplit_once(':') {
1483        if tail.len() > 1 && tail.starts_with('G') && ctx.scene.resolve_frame(owner).is_some() {
1484            return owner.to_string();
1485        }
1486    }
1487    name.to_string()
1488}
1489
1490/// A curve trimmed to the parameter range `[t0, t1]` (the `coalesce.rs` pattern):
1491/// skip a split whose parameter sits within [`crate::NurbsCurve::split`]'s absolute
1492/// knot tolerance of a domain end, or the guard inside `split` rejects it. The
1493/// result keeps the original parameterization, so its domain IS `[t0, t1]`.
1494pub fn trimmed_curve(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<NurbsCurve, String> {
1495    let [start, end] = curve.domain()?;
1496    let epsilon = (1e-9 * (end - start)).max(2e-9);
1497    let mut result = curve.clone();
1498    if t0 > start + epsilon && t0 < end - epsilon {
1499        result = result.split(t0)?.1;
1500    }
1501    let domain = result.domain()?;
1502    if t1 < domain[1] - epsilon && t1 > domain[0] + epsilon {
1503        result = result.split(t1)?.0;
1504    }
1505    Ok(result)
1506}
1507
1508/// The world curve of one resident solid EDGE, TRIMMED to that edge's own
1509/// `[t0, t1]`.
1510///
1511/// An `EdgeRecord` carries a curve plus the subrange of it the edge actually
1512/// spans, and the two disagree constantly: every boolean and every dressup that
1513/// shortens an edge rewrites `t0`/`t1` and leaves the whole underlying curve in
1514/// place (a box edge whose end is eaten by a fillet keeps its full 20mm line and
1515/// records `t=[0, 0.8]`). A consumer that clones `record.curve` and reads its
1516/// DOMAIN therefore builds along a curve that runs past the edge the user picked
1517/// — a tube or a sweep that overshoots into thin air. Everything that turns a
1518/// picked edge into a path goes through here so that cannot happen once per
1519/// feature.
1520pub fn edge_curve(edge: EdgeRef) -> Result<NurbsCurve, String> {
1521    crate::with_registered_solid_str(edge.handle, |solid| {
1522        let record = solid
1523            .edges
1524            .iter()
1525            .find(|candidate| candidate.id == edge.edge_id)
1526            .ok_or_else(|| format!("edge {} not found on solid", edge.edge_id))?;
1527        trimmed_curve(&record.curve, record.t0, record.t1)
1528    })
1529}
1530
1531/// Resolve a revolve/sweep axis from a resident solid EDGE: the axis passes through
1532/// the edge's start and runs along its chord (start → end). A straight edge is
1533/// exact; a curved edge yields its chord (best-effort, matching the established edge-axis use).
1534pub fn edge_axis(edge: EdgeRef) -> Result<Axis, String> {
1535    crate::with_registered_solid_str(edge.handle, |solid| {
1536        let record = solid
1537            .edges
1538            .iter()
1539            .find(|candidate| candidate.id == edge.edge_id)
1540            .ok_or_else(|| format!("edge {} not found on solid", edge.edge_id))?;
1541        let start = record.curve.evaluate(record.t0)?;
1542        let end = record.curve.evaluate(record.t1)?;
1543        let direction = end.sub(start).normalized()?;
1544        Ok(Axis {
1545            point: start,
1546            direction,
1547        })
1548    })
1549}
1550
1551// ===========================================================================
1552// Hole-loop subtraction (multi-loop regions) shared by the profile-consumers
1553// ===========================================================================
1554//
1555// A [`SketchProfile`] region carries `region[0]` = outer boundary,
1556// `region[1..]` = its containment-classified holes. Every profile-consumer
1557// (extrude, sweep, revolve, path sweep, loft) builds each region's OUTER solid
1558// with its own sweep, cuts the region's holes with the SAME sweep via
1559// [`subtract_region_holes`], then unions the region solids
1560// ([`union_region_solids`]). The nesting FOREST machinery below stays general
1561// (a hole group may carry children), though the sketch classifier promotes an
1562// island inside a hole to its own region, so sketch-produced regions are flat.
1563
1564/// Rigid-translate a curve by `offset` (homogeneous-safe: rational weights kept).
1565pub fn translate_curve(curve: &NurbsCurve, offset: Vec3) -> Result<NurbsCurve, String> {
1566    let mut control_points = Vec::with_capacity(curve.control_points.len());
1567    for cp in &curve.control_points {
1568        let cartesian = cp.point()?;
1569        control_points.push(crate::Vec4::from_point(cartesian.add(offset), cp.w));
1570    }
1571    NurbsCurve::new(curve.degree, curve.knots.clone(), control_points)
1572}
1573
1574/// One inner loop of a region with its DIRECTLY nested loops: `region[index]`
1575/// cuts material at even-odd `depth` 0; each child is one level deeper.
1576pub struct HoleGroup {
1577    /// Index into the region's loops (>= 1).
1578    pub index: usize,
1579    /// Nesting depth below the outer loop (0 = a direct hole).
1580    pub depth: usize,
1581    pub children: Vec<HoleGroup>,
1582}
1583
1584/// Classify `region[1..]` into a containment FOREST. Loops are sampled into 2D
1585/// polygons in the profile's own plane frame; loop `i` nests inside loop `j`
1586/// iff a representative point of `i` lies in `j`'s polygon (valid profiles
1587/// never intersect, so one point decides). Returns the depth-0 groups in loop
1588/// order.
1589pub fn hole_nesting(
1590    profile: &SketchProfile,
1591    region: &[crate::feature_pipeline::ProfileLoop],
1592) -> Result<Vec<HoleGroup>, String> {
1593    let count = region.len().saturating_sub(1);
1594    if count == 0 {
1595        return Ok(Vec::new());
1596    }
1597    // Sampled UV polygon per inner loop (8 samples per curve).
1598    let mut polygons: Vec<Vec<[f64; 2]>> = Vec::with_capacity(count);
1599    for hole in &region[1..] {
1600        let mut polygon = Vec::new();
1601        for curve in &hole.curves {
1602            let [t0, t1] = curve.domain()?;
1603            for step in 0..8 {
1604                let t = t0 + (t1 - t0) * (step as f64 / 8.0);
1605                let point = curve.evaluate(t)?;
1606                let delta = point.sub(profile.origin);
1607                polygon.push([delta.dot(profile.x_axis), delta.dot(profile.y_axis)]);
1608            }
1609        }
1610        polygons.push(polygon);
1611    }
1612    // Pairwise containment + per-loop depth (# of loops containing it).
1613    let mut contains = vec![vec![false; count]; count];
1614    for i in 0..count {
1615        let Some(representative) = polygons[i].first().copied() else {
1616            continue;
1617        };
1618        for j in 0..count {
1619            if i != j && point_in_polygon_uv(representative, &polygons[j]) {
1620                contains[i][j] = true;
1621            }
1622        }
1623    }
1624    let depths: Vec<usize> = (0..count)
1625        .map(|i| contains[i].iter().filter(|inside| **inside).count())
1626        .collect();
1627    Ok((0..count)
1628        .filter(|&i| depths[i] == 0)
1629        .map(|i| hole_group(i, &contains, &depths))
1630        .collect())
1631}
1632
1633/// Build the [`HoleGroup`] rooted at inner-loop `i`: its children are the loops
1634/// directly inside it (contained by it, exactly one level deeper).
1635fn hole_group(i: usize, contains: &[Vec<bool>], depths: &[usize]) -> HoleGroup {
1636    let children = (0..depths.len())
1637        .filter(|&k| contains[k][i] && depths[k] == depths[i] + 1)
1638        .map(|k| hole_group(k, contains, depths))
1639        .collect();
1640    HoleGroup {
1641        index: i + 1,
1642        depth: depths[i],
1643        children,
1644    }
1645}
1646
1647/// Standard even-odd ray-cast point-in-polygon in the profile's UV plane.
1648fn point_in_polygon_uv(point: [f64; 2], polygon: &[[f64; 2]]) -> bool {
1649    let count = polygon.len();
1650    if count < 3 {
1651        return false;
1652    }
1653    let mut inside = false;
1654    let mut j = count - 1;
1655    for i in 0..count {
1656        let pi = polygon[i];
1657        let pj = polygon[j];
1658        let intersects = (pi[1] > point[1]) != (pj[1] > point[1])
1659            && point[0] < (pj[0] - pi[0]) * (point[1] - pi[1]) / (pj[1] - pi[1]) + pi[0];
1660        if intersects {
1661            inside = !inside;
1662        }
1663        j = i;
1664    }
1665    inside
1666}
1667
1668/// Stamp `{id}:HOLE:{key}` on every still-unnamed face of a hole cutter — the
1669/// extrude hole-cutter convention every profile-consumer shares (the boolean
1670/// propagates these names onto the cut walls). `key` is the hole loop's STABLE
1671/// identity ([`ProfileLoop::key`]), not its position, so adding or removing one
1672/// hole never renames another's walls. `segment` keys the name further for a
1673/// feature that cuts the SAME hole once per path segment (see [`hole_face_name`]).
1674pub fn name_hole_cutter_faces(cutter: &mut BrepSolid, id: &str, key: &str, segment: Option<&str>) {
1675    let name = hole_face_name(id, key, segment);
1676    for face in cutter
1677        .shells
1678        .iter_mut()
1679        .flat_map(|shell| shell.faces.iter_mut())
1680    {
1681        if face.name.is_none() {
1682            face.name = Some(name.clone());
1683        }
1684    }
1685}
1686
1687/// A hole wall's face name: `{id}:HOLE:{key}`, and `{id}:HOLE:{key}:{segment}`
1688/// when the feature cuts the same hole once PER PATH SEGMENT (the multi-segment
1689/// sweep). The un-keyed spelling is then the CONTAINER standing for all of them,
1690/// exactly as it is for the per-loop caps.
1691pub fn hole_face_name(id: &str, key: &str, segment: Option<&str>) -> String {
1692    match segment {
1693        Some(segment) => format!("{id}:HOLE:{key}:{segment}"),
1694        None => format!("{id}:HOLE:{key}"),
1695    }
1696}
1697
1698/// Boolean-subtract `cutter` from `body` with `merge_coplanar_faces` — the
1699/// hole-cut boolean (register both, subtract, free both).
1700pub fn subtract_solid(body: BrepSolid, cutter: BrepSolid) -> Result<BrepSolid, String> {
1701    let options = BooleanOptions {
1702        merge_coplanar_faces: true,
1703        ..BooleanOptions::default()
1704    };
1705    let body_handle = crate::register_solid_value(body);
1706    let cutter_handle = crate::register_solid_value(cutter);
1707    let cut = crate::with_two_registered_solids(body_handle, cutter_handle, |body, tool| {
1708        crate::boolean_operation(body, tool, BooleanOperation::Subtract, &options)
1709    });
1710    crate::free_registered_solid(body_handle);
1711    crate::free_registered_solid(cutter_handle);
1712    // Lossy exit: the feature pipeline is still stringly (typed-refusal slice 3).
1713    cut.map_err(String::from)
1714}
1715
1716/// The straight-sweep hole cutter: the hole loop extruded into a prism that
1717/// clears BOTH caps (offset `margin` before the start plane, `2*margin`
1718/// over-long — the sheet-metal cutout pattern). `depth` scales the margin so a
1719/// nested island's prism strictly overhangs its parent hole's prism caps (no
1720/// coincident cutter-on-cutter cap faces); depth 0 is extrude's exact margin.
1721pub fn hole_prism(
1722    hole_curves: &[NurbsCurve],
1723    direction: Vec3,
1724    distance: f64,
1725    depth: usize,
1726) -> Result<BrepSolid, String> {
1727    let dir = direction.normalized()?;
1728    let span = distance.abs();
1729    let margin = span.max(1.0) * (depth as f64 + 1.0);
1730    let start_t = distance.min(0.0) - margin;
1731    let length = span + 2.0 * margin;
1732    let offset = dir.scale(start_t);
1733    let mut curves = Vec::with_capacity(hole_curves.len());
1734    for curve in hole_curves {
1735        curves.push(translate_curve(curve, offset)?);
1736    }
1737    crate::extrude_profile_brep(&curves, dir, length)
1738}
1739
1740/// Subtract one hole loop from `body` as a through prism — extrude's
1741/// `subtract_hole`, verbatim (build the over-long prism, stamp
1742/// `{id}:HOLE:{index}` on its faces, boolean-subtract with coplanar merge). A
1743/// degenerate (< 2 curve) hole loop is nothing to cut.
1744pub fn subtract_hole_prism(
1745    feature: &str,
1746    body: BrepSolid,
1747    hole_curves: &[NurbsCurve],
1748    direction: Vec3,
1749    distance: f64,
1750    id: &str,
1751    key: &str,
1752) -> Result<BrepSolid, String> {
1753    if hole_curves.len() < 2 {
1754        return Ok(body);
1755    }
1756    let mut cutter = hole_prism(hole_curves, direction, distance, 0)?;
1757    name_hole_cutter_faces(&mut cutter, id, key, None);
1758    subtract_solid(body, cutter)
1759        .map_err(|error| format!("{feature}: hole {key} cut failed: {error}"))
1760}
1761
1762/// Subtract every inner loop of `region` from `body`, nesting-aware: each
1763/// depth-0 hole becomes ONE cutter — its own swept solid minus its children's
1764/// sub-cutters (recursively). `build` sweeps `region[loop_index]` with the
1765/// FEATURE's own sweep (prism / revolve / path / loft); it receives the loop's
1766/// nesting depth for margin scaling. Cutter faces are stamped `{id}:HOLE:{key}`
1767/// with the hole loop's OWN stable identity ([`ProfileLoop::key`]), so the name
1768/// survives another hole being added or removed — the hole-wall sibling of the
1769/// per-loop cap naming (the old stamp counted holes positionally across regions,
1770/// so deleting one renumbered the rest). `segment`, when the feature cuts this
1771/// region once PER PATH SEGMENT (the multi-segment sweep), keys each cut's walls
1772/// by the segment that made them — otherwise adjacent portions' channel walls
1773/// would collide on one name and fall back to a positional `[n]`.
1774pub fn subtract_region_holes(
1775    mut body: BrepSolid,
1776    profile: &SketchProfile,
1777    region: &[crate::feature_pipeline::ProfileLoop],
1778    feature: &str,
1779    id: &str,
1780    segment: Option<&str>,
1781    build: &mut dyn FnMut(usize, usize) -> Result<BrepSolid, String>,
1782) -> Result<BrepSolid, String> {
1783    for group in hole_nesting(profile, region)? {
1784        let key = hole_key(&region[group.index], group.index);
1785        let Some(cutter) = hole_cutter(region, &group, id, segment, build)
1786            .map_err(|error| format!("{feature}: hole {key} cut failed: {error}"))?
1787        else {
1788            continue;
1789        };
1790        body = subtract_solid(body, cutter)
1791            .map_err(|error| format!("{feature}: hole {key} cut failed: {error}"))?;
1792    }
1793    Ok(body)
1794}
1795
1796/// A hole loop's naming key: its own stable loop identity, else its POSITION —
1797/// the pre-existing spelling, kept for a profile whose loops have no identity to
1798/// key on (a FACE profile, a hand-built profile), so their names never move.
1799pub fn hole_key(hole: &crate::feature_pipeline::ProfileLoop, index: usize) -> String {
1800    hole.key().unwrap_or_else(|| index.to_string())
1801}
1802
1803/// Build one hole group's CUTTER: the loop's swept solid minus each child's
1804/// cutter. `None` for a degenerate (< 2 curve) loop — nothing to cut.
1805fn hole_cutter(
1806    region: &[crate::feature_pipeline::ProfileLoop],
1807    group: &HoleGroup,
1808    id: &str,
1809    segment: Option<&str>,
1810    build: &mut dyn FnMut(usize, usize) -> Result<BrepSolid, String>,
1811) -> Result<Option<BrepSolid>, String> {
1812    if region[group.index].curves.len() < 2 {
1813        return Ok(None);
1814    }
1815    let mut cutter = build(group.index, group.depth)?;
1816    name_hole_cutter_faces(
1817        &mut cutter,
1818        id,
1819        &hole_key(&region[group.index], group.index),
1820        segment,
1821    );
1822    for child in &group.children {
1823        let Some(child_cutter) = hole_cutter(region, child, id, segment, build)? else {
1824            continue;
1825        };
1826        cutter = subtract_solid(cutter, child_cutter).map_err(|error| {
1827            format!("island {} carve from hole {} failed: {error}", child.index, group.index)
1828        })?;
1829    }
1830    Ok(Some(cutter))
1831}
1832
1833// ===========================================================================
1834// Face frame — a sketch/plane FRAME from a resident BREP face (planar-only)
1835// ===========================================================================
1836
1837/// A resolved "plane-like" reference — a solid FACE or a datum/construction PLANE
1838/// frame. THE shared resolution for a `["FACE","PLANE"]` reference: a datum plane
1839/// (from a `D`/`P` feature — both emit a scene [`Frame`]) and a planar face are
1840/// the same underlying object here, so every consumer (mirror, split, pattern,
1841/// plane) treats them identically instead of hand-rolling the two-table lookup.
1842/// That duplication was the source of the "picking a datum plane does nothing"
1843/// bug class — a new consumer just calls [`resolve_plane_reference`].
1844pub enum PlaneLikeRef {
1845    /// A solid face — its `(handle, face_id)`.
1846    Face(FaceRef),
1847    /// A datum / construction plane — its scene frame.
1848    Frame(Frame),
1849}
1850
1851impl PlaneLikeRef {
1852    /// The reference's plane as `(point, unit outward normal)`. A datum PLANE is
1853    /// its frame's `(origin, z-axis)`; a FACE is its midpoint tangent plane
1854    /// ([`face_point_normal`], valid for any face — a curved face yields the
1855    /// midpoint tangent, matching the pre-refactor mirror/split behaviour).
1856    pub fn point_normal(&self) -> Result<(Vec3, Vec3), String> {
1857        match self {
1858            PlaneLikeRef::Frame(frame) => Ok((frame.origin, frame.z_axis)),
1859            PlaneLikeRef::Face(face) => face_point_normal(*face),
1860        }
1861    }
1862
1863    /// The reference's full plane BASIS (origin + in-plane axes + normal). A datum
1864    /// PLANE is its frame directly; a FACE is [`face_frame`] (planar faces only).
1865    pub fn frame(&self) -> Result<Frame, String> {
1866        match self {
1867            PlaneLikeRef::Frame(frame) => Ok(*frame),
1868            PlaneLikeRef::Face(face) => face_frame(*face),
1869        }
1870    }
1871}
1872
1873/// Resolve a `["FACE","PLANE"]` reference name against the live scene-map: a solid
1874/// FACE first, else a datum / construction PLANE frame. The two name tables are
1875/// disjoint, so the order is irrelevant. `None` = unresolved (the caller records
1876/// it as an `unresolved` entry, contract rule 1 — never a no-op-with-no-signal).
1877pub fn resolve_plane_reference(ctx: &FeatureContext, name: &str) -> Option<PlaneLikeRef> {
1878    if let Some(face) = ctx.scene.resolve_face(name) {
1879        return Some(PlaneLikeRef::Face(face));
1880    }
1881    ctx.scene.resolve_frame(name).map(PlaneLikeRef::Frame)
1882}
1883
1884/// A face's plane as `(point on it, unit outward normal)`: the surface value +
1885/// normal at its parameter-domain midpoint, oriented by `same_sense`. Works on
1886/// ANY face (a curved face yields its midpoint tangent plane). The shared version
1887/// of the `plane_from_face` helper mirror/split/pattern each carried privately.
1888pub fn face_point_normal(face: FaceRef) -> Result<(Vec3, Vec3), String> {
1889    crate::with_registered_solid_str(face.handle, |solid| {
1890        for shell in &solid.shells {
1891            for record in &shell.faces {
1892                if record.id != face.face_id {
1893                    continue;
1894                }
1895                let [u0, u1] = record.surface.domain_u()?;
1896                let [v0, v1] = record.surface.domain_v()?;
1897                let (um, vm) = (0.5 * (u0 + u1), 0.5 * (v0 + v1));
1898                let point = record.surface.evaluate(um, vm)?;
1899                let mut normal = record.surface.normal(um, vm)?;
1900                if !record.same_sense {
1901                    normal = normal.scale(-1.0);
1902                }
1903                return Ok((point, normal));
1904            }
1905        }
1906        Err(format!("face {} not found on solid", face.face_id))
1907    })
1908}
1909
1910/// The world-space BOUNDARY SAMPLES of a face: five points along each
1911/// non-degenerate edge of each of its loops. This is the face EXTENT as this
1912/// kernel measures it — [`face_frame`]'s origin is the AABB centre of exactly
1913/// these points, and the assembly inference lane
1914/// ([`crate::feature_pipeline::assembly::infer`]) decides whether two faces
1915/// overlap by projecting them. Empty when every boundary edge is degenerate.
1916pub fn face_boundary_points(
1917    solid: &BrepSolid,
1918    record: &crate::FaceRecord,
1919) -> Result<Vec<Vec3>, String> {
1920    let mut points = Vec::new();
1921    for loop_record in &record.loops {
1922        for coedge in &loop_record.coedges {
1923            let Some(edge) = solid.edges.iter().find(|e| e.id == coedge.edge_id) else {
1924                continue;
1925            };
1926            if edge.degenerate {
1927                continue;
1928            }
1929            for step in 0..=4 {
1930                let t = edge.t0 + (edge.t1 - edge.t0) * (step as f64 / 4.0);
1931                points.push(edge.curve.evaluate(t)?);
1932            }
1933        }
1934    }
1935    Ok(points)
1936}
1937
1938/// The AABB of a point cloud (`None` when empty).
1939pub fn bounds_of(points: &[Vec3]) -> Option<(Vec3, Vec3)> {
1940    let mut iter = points.iter();
1941    let first = *iter.next()?;
1942    let (mut min, mut max) = (first, first);
1943    for point in iter {
1944        min = Vec3::new(min.x.min(point.x), min.y.min(point.y), min.z.min(point.z));
1945        max = Vec3::new(max.x.max(point.x), max.y.max(point.y), max.z.max(point.z));
1946    }
1947    Some((min, max))
1948}
1949
1950/// Compute a placement [`Frame`] from a resident face, for headless sketch/plane
1951/// resolution against a solid's face. Uses the face-basis convention:
1952/// the normal is the OUTWARD face normal (sense
1953/// respected); in-plane axes come from [`Frame::from_origin_normal`]; the origin is
1954/// the face boundary AABB center projected onto the plane. **Planar faces only** —
1955/// a curved face is not yet migrated (loud error, no-fallback).
1956pub fn face_frame(face: FaceRef) -> Result<Frame, String> {
1957    crate::with_registered_solid_str(face.handle, |solid| {
1958        let record = solid
1959            .shells
1960            .iter()
1961            .flat_map(|shell| &shell.faces)
1962            .find(|candidate| candidate.id == face.face_id)
1963            .ok_or_else(|| format!("face {} not found on solid", face.face_id))?;
1964
1965        let [u0, u1] = record.surface.domain_u()?;
1966        let [v0, v1] = record.surface.domain_v()?;
1967        let (um, vm) = ((u0 + u1) * 0.5, (v0 + v1) * 0.5);
1968
1969        // Planarity: the normal must be constant across the patch.
1970        let center_normal = record.surface.normal(um, vm)?;
1971        for (u, v) in [(u0, v0), (u1, v0), (u1, v1), (u0, v1)] {
1972            let normal = record.surface.normal(u, v)?;
1973            if normal.dot(center_normal).abs() < 1.0 - 1e-6 {
1974                return Err(
1975                    "sketch/plane on a non-planar face is not yet migrated to the Rust pipeline"
1976                        .into(),
1977                );
1978            }
1979        }
1980        // Outward face normal (respect the face sense, like `getAverageNormal`).
1981        let normal = if record.same_sense {
1982            center_normal
1983        } else {
1984            center_normal.scale(-1.0)
1985        };
1986
1987        // Boundary AABB center from the face loops' non-degenerate edges.
1988        let boundary = face_boundary_points(solid, record)?;
1989        let plane_point = record.surface.evaluate(um, vm)?;
1990        let center = match bounds_of(&boundary) {
1991            Some((min, max)) => min.add(max).scale(0.5),
1992            None => plane_point,
1993        };
1994        // Project the AABB center onto the plane so the origin lies exactly on it.
1995        let unit = normal.normalized()?;
1996        let signed = center.sub(plane_point).dot(unit);
1997        let origin = center.sub(unit.scale(signed));
1998        Frame::from_origin_normal(origin, normal)
1999    })
2000}
2001
2002// ===========================================================================
2003// Multi-region profiles — union the per-region solids into ONE result
2004// ===========================================================================
2005
2006/// UNION the per-region solids of a multi-region profile into ONE solid — the
2007/// established multi-region behavior (one solid per region, boolean-unioned). Folds with
2008/// `merge_coplanar_faces` (handle-registered operands, freed after each fold);
2009/// a single region passes through untouched, so single-region consumers are
2010/// byte-identical to the pre-region pipeline.
2011pub fn union_region_solids(solids: Vec<BrepSolid>) -> Result<BrepSolid, String> {
2012    union_solids_keeping(solids, &[])
2013}
2014
2015/// [`union_region_solids`] that PINS the named faces out of the coplanar merge:
2016/// a face whose name contains one of `keep_unmerged` is never coalesced with a
2017/// mergeable neighbour.
2018///
2019/// The multi-segment sweep needs it. Two portions built from CONSECUTIVE path
2020/// segments meet at a shared cap, and their sidewalls are frequently coplanar
2021/// (always so for two collinear segments, and for the walls parallel to the plane
2022/// a turn happens in). Merging those fuses two portions' walls into one face
2023/// carrying ONE of the two names — so which of the two source segments a
2024/// downstream fillet still resolves to would depend on the boolean's internal
2025/// face order, and would move whenever the path is edited. Pinning keeps one wall
2026/// face per (path segment × profile edge), each named after both — the same call
2027/// sheet metal makes to keep a flange attachable to a specific outline segment.
2028pub fn union_solids_keeping(
2029    solids: Vec<BrepSolid>,
2030    keep_unmerged: &[String],
2031) -> Result<BrepSolid, String> {
2032    let mut iter = solids.into_iter();
2033    let mut current = iter
2034        .next()
2035        .ok_or("profile produced no region solids to union")?;
2036    let options = BooleanOptions {
2037        merge_coplanar_faces: true,
2038        keep_unmerged_name_substrs: keep_unmerged.to_vec(),
2039        ..BooleanOptions::default()
2040    };
2041    for next in iter {
2042        let a = crate::register_solid_value(current);
2043        let b = crate::register_solid_value(next);
2044        let unioned = crate::with_two_registered_solids(a, b, |left, right| {
2045            crate::boolean_operation(left, right, BooleanOperation::Union, &options)
2046        });
2047        crate::free_registered_solid(a);
2048        crate::free_registered_solid(b);
2049        current = unioned.map_err(|error| format!("region union failed: {error}"))?;
2050    }
2051    Ok(current)
2052}
2053
2054
2055/// Transform controls shared by primitive solid schemas.
2056pub(super) fn primitive_transform_schema() -> serde_json::Value {
2057    serde_json::json!({
2058        "type": "transform",
2059        "default_value": {
2060            "position": [
2061                0,
2062                0,
2063                0
2064            ],
2065            "rotationEuler": [
2066                0,
2067                0,
2068                0
2069            ],
2070            "scale": [
2071                1,
2072                1,
2073                1
2074            ]
2075        },
2076        "referenceSelectionFilter": [
2077            "FACE",
2078            "EDGE",
2079            "VERTEX",
2080            "PLANE",
2081            "DATUM"
2082        ],
2083        "referenceLabel": "Start Reference",
2084        "referencePlaceholder": "Select point, edge, or face…",
2085        "hint": "Select a start reference, then position, rotate, and scale the solid relative to it."
2086    })
2087}
2088
2089/// Optional Boolean controls shared by solid-producing feature schemas.
2090pub(super) fn optional_boolean_schema() -> serde_json::Value {
2091    serde_json::json!({
2092        "type": "boolean_operation",
2093        "default_value": {
2094            "targets": [],
2095            "operation": "NONE",
2096            "mergeCoplanarFaces": true
2097        },
2098        "hint": "Optional boolean operation with selected solids"
2099    })
2100}
2101
2102