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
8pub(crate) fn 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 env_read!("BREP_DEBUG_SUBRANGE").is_some() && second < domain[1] - epsilon {
31        eprintln!("abnormal subrange skip in edge_split subcurve");
32    }
33    Ok(result)
34}
35
36/// The physical weld floor [`claim_vertex`] widens by an edge's measured band.
37const SPLIT_VERTEX_FLOOR: f64 = 1e-7;
38
39/// Which vertex IS this split point?.
40///
41/// A split point is where a section curve crossed a boundary edge. The imprint
42/// has already computed that same crossing as a junction VERTEX, from the
43/// section side. On exact geometry the two agree to nanometres and the tight
44/// band below finds each other trivially. On a vendor import they cannot: when
45/// the edge's own 3D curve sits `d` off the surfaces it bounds, the crossing
46/// solved ON the edge and the crossing solved as the section's endpoint are
47/// necessarily up to about `d` apart, and welding them is not a convenience —
48/// it is the only way one junction ends up as one vertex.
49///
50/// `entity_band` is that edge's own measured residual (`EntityTolerances::edge`),
51/// already capped, and it widens the physical weld floor for this edge only.
52/// The floor must not grow with distance from the world origin: a rigid move
53/// cannot make two distinct junctions become one vertex. It is a search question
54/// ("which candidates might be this point?"), not an acceptance gate — `validate`
55/// still judges the assembled solid on the global band.
56///
57/// `BREP_DEBUG_SPLIT_VERTEX=1` reports each decision and the distance to the
58/// nearest imprint junction, which is how the band was measured rather than
59/// guessed.
60fn claim_vertex(
61    vertices: &mut Vec<VertexRecord>,
62    imprint: &ImprintResultRecord,
63    edge: &EdgeRecord,
64    point: Vec3,
65    entity_band: f64,
66    next_id: &mut u64,
67) -> u64 {
68    // Keep a true section/cap intersection distinct from a nearby authored
69    // seam vertex, regardless of placement. The former origin-coupled floor
70    // merged fixture 30's exact crossing 1.422e-5 from its seam, and translating
71    // the same cylinders by ten units made all four operations refuse.
72    let floor: f64 = SPLIT_VERTEX_FLOOR;
73    let tolerance = floor.max(if entity_band.is_finite() && entity_band > 0.0 {
74        entity_band
75    } else {
76        0.0
77    });
78    let debug = std::env::var("BREP_DEBUG_SPLIT_VERTEX").is_ok();
79    if let Some(vertex) = vertices
80        .iter()
81        .find(|vertex| vertex.point.sub(point).length() <= tolerance)
82    {
83        if debug {
84            eprintln!(
85                "split-vertex ({:.7},{:.7},{:.7}) band={tolerance:.3e} entity={entity_band:.3e}: reuse v{}",
86                point.x, point.y, point.z, vertex.id
87            );
88        }
89        return vertex.id;
90    }
91    let nearest = imprint
92        .vertices
93        .iter()
94        .map(|vertex| (vertex.point, vertex.point.sub(point).length()))
95        .min_by(|a, b| a.1.total_cmp(&b.1));
96    // A computed section endpoint can be displaced along an accurate edge
97    // by more than its carrier residual (e.g. a near-tangent root). Keep the
98    // repair search for that explicit junction, but require it to lie on this
99    // edge at identity precision. A nearby point on the opposite thin wall
100    // therefore cannot authorize moving this split across the wall.
101    let canonical = imprint.vertices.iter()
102        .map(|vertex| (vertex.point, vertex.point.sub(point).length()))
103        .filter(|(candidate, distance)| {
104            if *distance <= tolerance {
105                return true;
106            }
107            if *distance > tolerance.max(crate::tolerance::WELD_FLOOR) {
108                return false;
109            }
110            crate::project_point_to_curve(&edge.curve, *candidate).is_ok_and(|projection| {
111                projection.distance <= tolerance
112                    && projection.u >= edge.t0.min(edge.t1)
113                    && projection.u <= edge.t0.max(edge.t1)
114            })
115        })
116        .min_by(|a, b| a.1.total_cmp(&b.1))
117        .map(|(candidate, _)| candidate)
118        .unwrap_or(point);
119    if let Some(vertex) = vertices.iter()
120        .find(|vertex| vertex.point.sub(canonical).length() <= tolerance)
121    {
122        return vertex.id;
123    }
124    if debug {
125        eprintln!(
126            "split-vertex ({:.7},{:.7},{:.7}) band={tolerance:.3e} entity={entity_band:.3e}: \
127             nearest imprint junction {:.3e} -> {}",
128            point.x,
129            point.y,
130            point.z,
131            nearest.map(|(_, d)| d).unwrap_or(f64::INFINITY),
132            if canonical.sub(point).length() > 0.0 {
133                "SNAPPED"
134            } else {
135                "kept"
136            }
137        );
138    }
139    let id = *next_id;
140    *next_id += 1;
141    vertices.push(VertexRecord {
142        id,
143        point: canonical,
144    });
145    id
146}
147
148/// Apply the boundary-edge partition produced by imprint construction.
149///
150/// The original edge curve and parameter range semantics are preserved; only
151/// coedge p-curves are restricted to traversal-aligned subcurves.
152pub fn apply_edge_splits(
153    solid: &BrepSolid,
154    operand: u8,
155    imprint: &ImprintResultRecord,
156) -> Result<BrepSolid, KernelRefusal> {
157    apply_edge_splits_with_map(solid, operand, imprint).map(|(result, _)| result)
158}
159
160/// [`apply_edge_splits`] plus the identity ledger: original edge id → the
161/// minted sub-edge ids that replaced it. Consumers keyed on ORIGINAL edge
162/// ids (the fragment-selection barrier set) must remap through it, since the
163/// split solid's coedges reference the minted ids.
164pub fn apply_edge_splits_with_map(
165    solid: &BrepSolid,
166    operand: u8,
167    imprint: &ImprintResultRecord,
168) -> Result<(BrepSolid, HashMap<u64, Vec<u64>>), KernelRefusal> {
169    let requested = imprint
170        .edge_splits
171        .iter()
172        .filter(|split| split.operand == operand)
173        .map(|split| (split.edge_id, split.parameters.as_slice()))
174        .collect::<HashMap<_, _>>();
175    if requested.is_empty() {
176        return Ok((solid.clone(), HashMap::default()));
177    }
178
179    let mut result = solid.clone();
180    let mut next_vertex_id = result
181        .vertices
182        .iter()
183        .map(|vertex| vertex.id)
184        .max()
185        .unwrap_or(0)
186        + 1;
187    let mut next_edge_id = result.edges.iter().map(|edge| edge.id).max().unwrap_or(0) + 1;
188    let mut next_coedge_id = result
189        .shells
190        .iter()
191        .flat_map(|shell| &shell.faces)
192        .flat_map(|face| &face.loops)
193        .flat_map(|loop_record| &loop_record.coedges)
194        .map(|coedge| coedge.id)
195        .max()
196        .unwrap_or(0)
197        + 1;
198    let mut pieces: HashMap<u64, Vec<EdgeRecord>> = HashMap::default();
199    // Measured per-entity residuals for THIS operand, lazily and only for the
200    // edges actually split. Built on the pre-split solid — the entry snapshot —
201    // so nothing measured here can outlive the geometry it was taken from.
202    //
203    // Floor ZERO on purpose: measure the edge's actual residual separately
204    // from the weld floor in `claim_vertex`. The view's floor also drives the
205    // sampler's refinement: the tighter it is the harder the sampler looks,
206    // and zero asks for its maximum. Only edges actually split pay that cost.
207    let mut entity = EntityTolerances::with_floor(solid, 0.0);
208
209    for edge in &solid.edges {
210        let Some(parameters) = requested.get(&edge.id) else {
211            continue;
212        };
213        let parameter_tolerance = (edge.t1 - edge.t0).abs() * 1e-10;
214        let mut partition = parameters
215            .iter()
216            .copied()
217            .filter(|parameter| {
218                *parameter > edge.t0 + parameter_tolerance
219                    && *parameter < edge.t1 - parameter_tolerance
220            })
221            .collect::<Vec<_>>();
222        partition.sort_by(f64::total_cmp);
223        partition.dedup_by(|a, b| (*a - *b).abs() <= parameter_tolerance);
224        partition.insert(0, edge.t0);
225        partition.push(edge.t1);
226        if partition.len() <= 2 {
227            continue;
228        }
229        // `claim_vertex` reads the band only through `SPLIT_VERTEX_FLOOR.max(band)`:
230        // an edge whose affine bound is under half that floor (lane O row 25,
231        // `BREP_EDGE_BAND_AFFINE_BOUND=1`) gets the floor without sampling.
232        let entity_band = if entity.edge_bound_under(edge.id, 0.5 * SPLIT_VERTEX_FLOOR) {
233            0.0
234        } else {
235            entity.edge(edge.id)
236        };
237        let mut vertex_ids = vec![edge.start_vertex_id];
238        for parameter in &partition[1..partition.len() - 1] {
239            let point = edge
240                .curve
241                .evaluate(*parameter)
242                .or_refuse(KernelStage::Refine, "evaluate")?;
243            vertex_ids.push(claim_vertex(
244                &mut result.vertices,
245                imprint,
246                edge,
247                point,
248                entity_band,
249                &mut next_vertex_id,
250            ));
251        }
252        vertex_ids.push(edge.end_vertex_id);
253        let mut edge_pieces = Vec::new();
254        for index in 0..partition.len() - 1 {
255            // First piece keeps the source name; later pieces get the
256            // deterministic `_1`, `_2` split suffixes the application uses.
257            let name = edge.name.as_ref().map(|name| {
258                if index == 0 {
259                    name.clone()
260                } else {
261                    format!("{name}_{index}")
262                }
263            });
264            edge_pieces.push(EdgeRecord {
265                id: next_edge_id,
266                curve: edge.curve.clone(),
267                t0: partition[index],
268                t1: partition[index + 1],
269                start_vertex_id: vertex_ids[index],
270                end_vertex_id: vertex_ids[index + 1],
271                degenerate: false,
272                name,
273            });
274            next_edge_id += 1;
275        }
276        pieces.insert(edge.id, edge_pieces);
277    }
278
279    result.edges.retain(|edge| !pieces.contains_key(&edge.id));
280    for edge_pieces in pieces.values() {
281        result.edges.extend(edge_pieces.iter().cloned());
282    }
283
284    // id -> source edge, built once. The per-coedge `solid.edges.iter().find`
285    // below was O(edges) inside the face/loop/coedge loops. `solid` is the
286    // immutable source, so the lookup returns the identical record.
287    let source_edges: HashMap<u64, &EdgeRecord> =
288        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
289
290    for face in result
291        .shells
292        .iter_mut()
293        .flat_map(|shell| shell.faces.iter_mut())
294    {
295        for loop_record in &mut face.loops {
296            let mut rebuilt = Vec::new();
297            for coedge in &loop_record.coedges {
298                let Some(edge_pieces) = pieces.get(&coedge.edge_id) else {
299                    rebuilt.push(coedge.clone());
300                    continue;
301                };
302                let original = *source_edges.get(&coedge.edge_id).ok_or_else(|| {
303                    KernelRefusal::internal(
304                        KernelStage::Refine,
305                        "edge_split",
306                        "apply_edge_splits: missing source edge",
307                    )
308                })?;
309                let span = original.t1 - original.t0;
310                let indices: Box<dyn Iterator<Item = usize>> = if coedge.forward {
311                    Box::new(0..edge_pieces.len())
312                } else {
313                    Box::new((0..edge_pieces.len()).rev())
314                };
315                for index in indices {
316                    let piece = &edge_pieces[index];
317                    let start_fraction = if coedge.forward {
318                        (piece.t0 - original.t0) / span
319                    } else {
320                        (original.t1 - piece.t1) / span
321                    };
322                    let end_fraction = if coedge.forward {
323                        (piece.t1 - original.t0) / span
324                    } else {
325                        (original.t1 - piece.t0) / span
326                    };
327                    rebuilt.push(CoedgeRecord {
328                        id: next_coedge_id,
329                        edge_id: piece.id,
330                        forward: coedge.forward,
331                        pcurve: subcurve_by_fraction(&coedge.pcurve, start_fraction, end_fraction)?,
332                    });
333                    next_coedge_id += 1;
334                }
335            }
336            loop_record.coedges = rebuilt;
337        }
338    }
339    let map = pieces
340        .iter()
341        .map(|(source_id, edge_pieces)| {
342            (
343                *source_id,
344                edge_pieces.iter().map(|piece| piece.id).collect(),
345            )
346        })
347        .collect();
348    Ok((result, map))
349}
350