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