Skip to main content

brep_kernel/csg/
edge_split.rs

1use crate::entity_tolerance::EntityTolerances;
2use crate::imprint::ImprintResultRecord;
3use crate::topology::{BrepSolid, CoedgeRecord, EdgeRecord, VertexRecord};
4use crate::{KernelRefusal, KernelStage, OrRefuse};
5use crate::{NurbsCurve, Vec3};
6use rustc_hash::FxHashMap as HashMap;
7
8fn subcurve_by_fraction(
9    curve: &NurbsCurve,
10    start_fraction: f64,
11    end_fraction: f64,
12) -> Result<NurbsCurve, KernelRefusal> {
13    let [start, end] = curve.domain().or_refuse(KernelStage::Refine, "domain")?;
14    let first = start + (end - start) * start_fraction;
15    let second = start + (end - start) * end_fraction;
16    let epsilon = (1e-9 * (end - start)).max(2e-9);
17    let mut result = curve.clone();
18    if first > start + epsilon && first < end - epsilon {
19        result = result
20            .split(first)
21            .or_refuse(KernelStage::Refine, "split")?
22            .1;
23    }
24    let domain = result.domain().or_refuse(KernelStage::Refine, "domain")?;
25    if second < domain[1] - epsilon && second > domain[0] + epsilon {
26        result = result
27            .split(second)
28            .or_refuse(KernelStage::Refine, "split")?
29            .0;
30    } else if std::env::var("BREP_DEBUG_SUBRANGE").is_ok() && second < domain[1] - epsilon {
31        eprintln!("abnormal subrange skip in edge_split subcurve");
32    }
33    Ok(result)
34}
35
36/// Which vertex IS this split point? — the **section-endpoint correspondence
37/// decision**, and the one place `per-entity-tolerances.md`'s measured band is
38/// spent.
39///
40/// A split point is where a section curve crossed a boundary edge. The imprint
41/// has already computed that same crossing as a junction VERTEX, from the
42/// section side. On exact geometry the two agree to nanometres and the tight
43/// band below finds each other trivially. On a vendor import they cannot: when
44/// the edge's own 3D curve sits `d` off the surfaces it bounds, the crossing
45/// solved ON the edge and the crossing solved as the section's endpoint are
46/// necessarily up to about `d` apart, and welding them is not a convenience —
47/// it is the only way one junction ends up as one vertex.
48///
49/// `entity_band` is that edge's own measured residual (`EntityTolerances::edge`),
50/// already capped, and it is used only to WIDEN the search for a canonical
51/// imprint vertex, never to narrow it: the historical origin-coupled band stays
52/// the floor, so every case whose edges measure clean is bit-identical. It is a
53/// search question ("which candidates might be this point?"), not an acceptance
54/// gate — `validate` still judges the assembled solid on the global band.
55///
56/// `BREP_DEBUG_SPLIT_VERTEX=1` reports each decision and the distance to the
57/// nearest imprint junction, which is how the band was measured rather than
58/// guessed.
59fn claim_vertex(
60    vertices: &mut Vec<VertexRecord>,
61    imprint: &ImprintResultRecord,
62    point: Vec3,
63    entity_band: f64,
64    next_id: &mut u64,
65) -> u64 {
66    // The historical band. Origin-coupled, which this kernel treats as an
67    // anti-pattern everywhere else (`tolerance::merge_scale`'s doc records why),
68    // but preserved verbatim as the FLOOR so this change cannot narrow any
69    // existing weld: correcting it is a separate question from this one.
70    let floor = 1e-5 * (1.0 + point.length());
71    let tolerance = floor.max(if entity_band.is_finite() && entity_band > 0.0 {
72        entity_band
73    } else {
74        0.0
75    });
76    let debug = std::env::var("BREP_DEBUG_SPLIT_VERTEX").is_ok();
77    if let Some(vertex) = vertices
78        .iter()
79        .find(|vertex| vertex.point.sub(point).length() <= tolerance)
80    {
81        if debug {
82            eprintln!(
83                "split-vertex ({:.7},{:.7},{:.7}) band={tolerance:.3e} entity={entity_band:.3e}: reuse v{}",
84                point.x, point.y, point.z, vertex.id
85            );
86        }
87        return vertex.id;
88    }
89    let nearest = imprint
90        .vertices
91        .iter()
92        .map(|vertex| (vertex.point, vertex.point.sub(point).length()))
93        .min_by(|a, b| a.1.total_cmp(&b.1));
94    let canonical = nearest
95        .filter(|(_, distance)| *distance <= tolerance)
96        .map(|(candidate, _)| candidate)
97        .unwrap_or(point);
98    if debug {
99        eprintln!(
100            "split-vertex ({:.7},{:.7},{:.7}) band={tolerance:.3e} entity={entity_band:.3e}: \
101             nearest imprint junction {:.3e} -> {}",
102            point.x,
103            point.y,
104            point.z,
105            nearest.map(|(_, d)| d).unwrap_or(f64::INFINITY),
106            if canonical.sub(point).length() > 0.0 {
107                "SNAPPED"
108            } else {
109                "kept"
110            }
111        );
112    }
113    let id = *next_id;
114    *next_id += 1;
115    vertices.push(VertexRecord {
116        id,
117        point: canonical,
118    });
119    id
120}
121
122/// Apply the boundary-edge partition produced by imprint construction.
123///
124/// The original edge curve and parameter range semantics are preserved; only
125/// coedge p-curves are restricted to traversal-aligned subcurves.
126pub fn apply_edge_splits(
127    solid: &BrepSolid,
128    operand: u8,
129    imprint: &ImprintResultRecord,
130) -> Result<BrepSolid, KernelRefusal> {
131    apply_edge_splits_with_map(solid, operand, imprint).map(|(result, _)| result)
132}
133
134/// [`apply_edge_splits`] plus the identity ledger: original edge id → the
135/// minted sub-edge ids that replaced it. Consumers keyed on ORIGINAL edge
136/// ids (the fragment-selection barrier set) must remap through it, since the
137/// split solid's coedges reference the minted ids.
138pub fn apply_edge_splits_with_map(
139    solid: &BrepSolid,
140    operand: u8,
141    imprint: &ImprintResultRecord,
142) -> Result<(BrepSolid, HashMap<u64, Vec<u64>>), KernelRefusal> {
143    let requested = imprint
144        .edge_splits
145        .iter()
146        .filter(|split| split.operand == operand)
147        .map(|split| (split.edge_id, split.parameters.as_slice()))
148        .collect::<HashMap<_, _>>();
149    if requested.is_empty() {
150        return Ok((solid.clone(), HashMap::default()));
151    }
152
153    let mut result = solid.clone();
154    let mut next_vertex_id = result
155        .vertices
156        .iter()
157        .map(|vertex| vertex.id)
158        .max()
159        .unwrap_or(0)
160        + 1;
161    let mut next_edge_id = result.edges.iter().map(|edge| edge.id).max().unwrap_or(0) + 1;
162    let mut next_coedge_id = result
163        .shells
164        .iter()
165        .flat_map(|shell| &shell.faces)
166        .flat_map(|face| &face.loops)
167        .flat_map(|loop_record| &loop_record.coedges)
168        .map(|coedge| coedge.id)
169        .max()
170        .unwrap_or(0)
171        + 1;
172    let mut pieces: HashMap<u64, Vec<EdgeRecord>> = HashMap::default();
173    // Measured per-entity residuals for THIS operand, lazily and only for the
174    // edges actually split. Built on the pre-split solid — the entry snapshot —
175    // so nothing measured here can outlive the geometry it was taken from.
176    //
177    // Floor ZERO on purpose. This function has no tolerance policy of its own
178    // (its band has always been the origin-coupled literal in `claim_vertex`,
179    // which stays the floor there), and the view's floor also drives the
180    // sampler's refinement: the tighter it is the harder the sampler looks, and
181    // zero asks for its maximum. Only the edges actually split pay that cost.
182    let mut entity = EntityTolerances::with_floor(solid, 0.0);
183
184    for edge in &solid.edges {
185        let Some(parameters) = requested.get(&edge.id) else {
186            continue;
187        };
188        let parameter_tolerance = (edge.t1 - edge.t0).abs() * 1e-10;
189        let mut partition = parameters
190            .iter()
191            .copied()
192            .filter(|parameter| {
193                *parameter > edge.t0 + parameter_tolerance
194                    && *parameter < edge.t1 - parameter_tolerance
195            })
196            .collect::<Vec<_>>();
197        partition.sort_by(f64::total_cmp);
198        partition.dedup_by(|a, b| (*a - *b).abs() <= parameter_tolerance);
199        partition.insert(0, edge.t0);
200        partition.push(edge.t1);
201        if partition.len() <= 2 {
202            continue;
203        }
204        let entity_band = entity.edge(edge.id);
205        let mut vertex_ids = vec![edge.start_vertex_id];
206        for parameter in &partition[1..partition.len() - 1] {
207            let point = edge
208                .curve
209                .evaluate(*parameter)
210                .or_refuse(KernelStage::Refine, "evaluate")?;
211            vertex_ids.push(claim_vertex(
212                &mut result.vertices,
213                imprint,
214                point,
215                entity_band,
216                &mut next_vertex_id,
217            ));
218        }
219        vertex_ids.push(edge.end_vertex_id);
220        let mut edge_pieces = Vec::new();
221        for index in 0..partition.len() - 1 {
222            // First piece keeps the source name; later pieces get the
223            // deterministic `_1`, `_2` split suffixes the application uses.
224            let name = edge.name.as_ref().map(|name| {
225                if index == 0 {
226                    name.clone()
227                } else {
228                    format!("{name}_{index}")
229                }
230            });
231            edge_pieces.push(EdgeRecord {
232                id: next_edge_id,
233                curve: edge.curve.clone(),
234                t0: partition[index],
235                t1: partition[index + 1],
236                start_vertex_id: vertex_ids[index],
237                end_vertex_id: vertex_ids[index + 1],
238                degenerate: false,
239                name,
240            });
241            next_edge_id += 1;
242        }
243        pieces.insert(edge.id, edge_pieces);
244    }
245
246    result.edges.retain(|edge| !pieces.contains_key(&edge.id));
247    for edge_pieces in pieces.values() {
248        result.edges.extend(edge_pieces.iter().cloned());
249    }
250
251    // id -> source edge, built once. The per-coedge `solid.edges.iter().find`
252    // below was O(edges) inside the face/loop/coedge loops. `solid` is the
253    // immutable source, so the lookup returns the identical record.
254    let source_edges: HashMap<u64, &EdgeRecord> =
255        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
256
257    for face in result
258        .shells
259        .iter_mut()
260        .flat_map(|shell| shell.faces.iter_mut())
261    {
262        for loop_record in &mut face.loops {
263            let mut rebuilt = Vec::new();
264            for coedge in &loop_record.coedges {
265                let Some(edge_pieces) = pieces.get(&coedge.edge_id) else {
266                    rebuilt.push(coedge.clone());
267                    continue;
268                };
269                let original = *source_edges.get(&coedge.edge_id).ok_or_else(|| {
270                    KernelRefusal::internal(
271                        KernelStage::Refine,
272                        "edge_split",
273                        "apply_edge_splits: missing source edge",
274                    )
275                })?;
276                let span = original.t1 - original.t0;
277                let indices: Box<dyn Iterator<Item = usize>> = if coedge.forward {
278                    Box::new(0..edge_pieces.len())
279                } else {
280                    Box::new((0..edge_pieces.len()).rev())
281                };
282                for index in indices {
283                    let piece = &edge_pieces[index];
284                    let start_fraction = if coedge.forward {
285                        (piece.t0 - original.t0) / span
286                    } else {
287                        (original.t1 - piece.t1) / span
288                    };
289                    let end_fraction = if coedge.forward {
290                        (piece.t1 - original.t0) / span
291                    } else {
292                        (original.t1 - piece.t0) / span
293                    };
294                    rebuilt.push(CoedgeRecord {
295                        id: next_coedge_id,
296                        edge_id: piece.id,
297                        forward: coedge.forward,
298                        pcurve: subcurve_by_fraction(&coedge.pcurve, start_fraction, end_fraction)?,
299                    });
300                    next_coedge_id += 1;
301                }
302            }
303            loop_record.coedges = rebuilt;
304        }
305    }
306    let map = pieces
307        .iter()
308        .map(|(source_id, edge_pieces)| {
309            (
310                *source_id,
311                edge_pieces.iter().map(|piece| piece.id).collect(),
312            )
313        })
314        .collect();
315    Ok((result, map))
316}
317
318// BREP private tests: 29b68a32b7e7d6fe