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