brep_kernel/csg/imprint.rs
1use crate::classification::{parameter_point_in_face, PolygonClass};
2use crate::curve::KNOT_IDENTITY_TOL;
3use crate::spatial::{Aabb, Bvh};
4use crate::tolerance::{
5 assembler_weld, merge_scale, solid_scale, COINCIDENCE_DISTANCE_FLOOR, WELD_FLOOR,
6};
7use crate::topology::{BrepSolid, EdgeRecord, FaceRecord};
8use crate::{
9 build_pcurve_on_surface, build_pcurve_on_surface_marched, classify_surface_pair_cached,
10 fit_polyline, intersect_curve_surface,
11 intersect_curves, intersect_surfaces, intersect_surfaces_supplemental, project_point_to_curve,
12 project_point_to_surface, project_point_to_surface_seeded, KnotVector, NurbsCurve, NurbsSurface,
13 SurfaceClassifyData,
14 SurfaceIntersectionOptions, SurfacePairClassification, SurfacePairRelation, Vec2, Vec3, Vec4,
15};
16use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
17use serde::{Deserialize, Serialize};
18use std::time::Duration;
19use web_time::Instant;
20
21/// Per-step wall-clock accumulators for `build_imprints`, printed to stderr
22/// when `BREP_PROFILE` is set. Native-only in practice (no env vars in wasm).
23#[derive(Default)]
24struct ImprintProfile {
25 enabled: bool,
26 pairs: u64,
27 marched_pairs: u64,
28 classify: Duration,
29 cosurface: Duration,
30 lies_on: Duration,
31 planar_iso: Duration,
32 analytic: Duration,
33 seeds: Duration,
34 march: Duration,
35 clip_and_fit: Duration,
36 process_curve: Duration,
37}
38
39impl ImprintProfile {
40 fn new() -> Self {
41 Self {
42 enabled: std::env::var("BREP_PROFILE").is_ok(),
43 ..Self::default()
44 }
45 }
46
47 fn lap(&self, started: &mut Option<Instant>) -> Duration {
48 if !self.enabled {
49 return Duration::ZERO;
50 }
51 let now = Instant::now();
52 let elapsed = started.map(|s| now - s).unwrap_or_default();
53 *started = Some(now);
54 elapsed
55 }
56
57 fn report(&self) {
58 if !self.enabled {
59 return;
60 }
61 let ms = |d: Duration| d.as_secs_f64() * 1_000.0;
62 eprintln!(
63 "imprint.profile pairs={} marched={} classify={:.2} cosurface={:.2} lies_on={:.2} planar_iso={:.2} analytic={:.2} seeds={:.2} march={:.2} clip_fit={:.2} process_curve={:.2}",
64 self.pairs,
65 self.marched_pairs,
66 ms(self.classify),
67 ms(self.cosurface),
68 ms(self.lies_on),
69 ms(self.planar_iso),
70 ms(self.analytic),
71 ms(self.seeds),
72 ms(self.march),
73 ms(self.clip_and_fit),
74 ms(self.process_curve),
75 );
76 }
77}
78
79#[derive(Clone, Debug, Deserialize)]
80pub struct ImprintOptions {
81 #[serde(default = "default_tolerance")]
82 pub tolerance: f64,
83 #[serde(default = "default_maximum_fit_points")]
84 pub maximum_fit_points: usize,
85 #[serde(default)]
86 pub local_fit: bool,
87 #[serde(default)]
88 pub fit_chunk_points: Option<usize>,
89 pub maximum_ssi_step: Option<f64>,
90}
91
92fn default_tolerance() -> f64 {
93 1e-7
94}
95
96fn default_maximum_fit_points() -> usize {
97 80
98}
99
100/// Angular band inside which two surface normals count as PARALLEL when a face
101/// pair is classified (`|n_a × n_b| <= PAIR_ANGULAR_TOLERANCE` ⇒ the carriers
102/// graze rather than cross there). Single source for the pair classifier's
103/// angular tolerance and for the near-tangency test that picks the march step.
104const PAIR_ANGULAR_TOLERANCE: f64 = 1e-4;
105
106/// Fraction of the part extent used as the FLOOR of the near-tangency march
107/// step when the two carriers actually touch (`minimum_separation == 0`), so
108/// the reachable curve length is a fixed fraction of the model rather than an
109/// absolute distance. See `march_maximum_step`.
110const NEAR_TANGENT_STEP_FRACTION: f64 = 1e-3;
111
112/// Span band for the B2 shared-section-edge decision, as a FRACTION of the part
113/// extent (so it tracks feature size, not an absolute distance). A SSI section
114/// is declared coincident with an existing boundary edge only when it lies
115/// within `SHARED_SECTION_BAND_FRACTION * extent` of that edge along its whole
116/// span in BOTH directions AND their endpoints already coincide within the weld
117/// radius. Sized to admit a genuine near-tangent GRAZE (the marched/analytic
118/// section drifts from the vendor edge by a graze-scale gap two-three orders
119/// above the fit residual — the B1-falsification measurement) while staying far
120/// below the feature scale at which a distinct same-endpoint edge diverges.
121/// Measured on fixture 09 (extent ~3.23, band ~3.23e-3): the two genuine graze
122/// gaps are 8.2e-4 and 1.67e-3 (ratios 2.5e-4 / 5.2e-4) — comfortably inside;
123/// fixtures 12/04 (the wrong-reuse tripwires) produce ZERO reuses and the
124/// prim×prim fuzz shows 0/2800 outcome changes, so the band is well separated
125/// from any distinct-edge scale.
126const SHARED_SECTION_BAND_FRACTION: f64 = 1e-3;
127
128/// Maximum march step for one face pair.
129///
130/// A NEAR-TANGENT pair marches finely: where the carriers graze, the corrector
131/// can hop between the two sheets of the contact, so the step stays inside the
132/// separation band. Two things that step must NOT be:
133///
134/// * **applied to a pair that only crosses.** A pair is classified `Singular`
135/// when any sample near the contact has a DEGENERATE normal (a pole of a
136/// revolution carrier) — that says nothing about tangency, and the pair can
137/// cross at 90°. Marching a transverse crossing at the tangency step crawls,
138/// and a curve longer than `maximum_steps × step` then dies as "trace
139/// exhausted" (2026-08-06 report: a draft-angle extrude wall against a
140/// revolve-of-revolve face — a 2.0-unit curve needing 4014 steps of 4000,
141/// 10 points once marched at its own scale). The step is therefore gated on
142/// the classification's own tangency evidence, not on the relation label.
143/// * **an absolute distance.** A fixed floor is a different fraction of every
144/// model, so the reachable curve length depended on part size. The floor is
145/// a fraction of the part extent instead; `solid_scale` is at least 1, so
146/// unit-scale parts keep the historical `1e-3` exactly.
147fn march_maximum_step(
148 classification: &SurfacePairClassification,
149 requested: Option<f64>,
150 scale: f64,
151) -> Option<f64> {
152 let grazes = matches!(
153 classification.relation,
154 SurfacePairRelation::NearTangent | SurfacePairRelation::Singular
155 ) && classification.minimum_normal_cross <= PAIR_ANGULAR_TOLERANCE;
156 if !grazes {
157 return requested;
158 }
159 Some(
160 requested.unwrap_or(
161 classification
162 .minimum_separation
163 .max(NEAR_TANGENT_STEP_FRACTION * scale),
164 ) * 0.5,
165 )
166}
167
168impl Default for ImprintOptions {
169 fn default() -> Self {
170 Self {
171 tolerance: default_tolerance(),
172 maximum_fit_points: default_maximum_fit_points(),
173 local_fit: false,
174 fit_chunk_points: None,
175 maximum_ssi_step: None,
176 }
177 }
178}
179
180#[derive(Clone, Debug, Deserialize, Serialize)]
181pub struct ImprintVertex {
182 pub id: u64,
183 pub point: Vec3,
184}
185
186#[derive(Clone, Debug, Deserialize, Serialize)]
187pub struct FacePcurve {
188 pub operand: u8,
189 pub face_id: u64,
190 pub pcurve: NurbsCurve,
191}
192
193#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, Hash, Serialize)]
194pub struct FaceKey {
195 pub operand: u8,
196 pub face_id: u64,
197}
198
199#[derive(Clone, Debug, Deserialize, Serialize)]
200pub struct ImprintPieceRecord {
201 pub id: u64,
202 pub curve: NurbsCurve,
203 pub t0: f64,
204 pub t1: f64,
205 pub start_vertex_id: u64,
206 pub end_vertex_id: u64,
207 pub pcurves: Vec<FacePcurve>,
208 pub support_faces: [FaceKey; 2],
209 /// OCCT common-block / `IsExistingPaveBlock` (B2): when this SSI section
210 /// curve was found to COINCIDE along its whole span with an EXISTING
211 /// boundary edge of one of its support faces, the section is not a new
212 /// 1-cell — it IS that boundary edge. Records `(operand, edge_id, aligned)`
213 /// of the existing edge to REUSE; `aligned` is whether the section's
214 /// start→end runs the same direction as the edge's start→end. The
215 /// assembler then resolves this piece to the shared boundary edge's
216 /// identity so both operands reference ONE edge (no duplicate section /
217 /// one-use edge). `None` = an ordinary freshly-minted section (the
218 /// historical behaviour; also what `BREP_SHARED_SECTION_EDGE=0` forces).
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub shared_edge: Option<(u8, u64, bool)>,
221}
222
223#[derive(Clone, Debug, Deserialize, Serialize)]
224pub struct EdgeSplitRecord {
225 pub operand: u8,
226 pub edge_id: u64,
227 pub parameters: Vec<f64>,
228}
229
230#[derive(Clone, Debug, Deserialize, Serialize)]
231pub struct FaceImprints {
232 pub operand: u8,
233 pub face_id: u64,
234 pub piece_ids: Vec<u64>,
235}
236
237#[derive(Clone, Debug, Deserialize, Serialize)]
238pub struct ImprintResultRecord {
239 pub vertices: Vec<ImprintVertex>,
240 pub pieces: Vec<ImprintPieceRecord>,
241 pub by_face: Vec<FaceImprints>,
242 pub edge_splits: Vec<EdgeSplitRecord>,
243 /// Operand edges geometrically OVERLAPPED by a section curve (the
244 /// cosurface/coincident locus: an inscribed sphere's contact circle
245 /// riding a cap ring). Fragment-selection fate must never flood across
246 /// them — the two sides of such an edge can lie on opposite sides of
247 /// the other operand (the cap disc inside, the wall outside).
248 #[serde(default)]
249 pub barrier_edges: Vec<(u8, u64)>,
250 /// Every ISOLATED TANGENT NODE the imprint admitted: a point where the two
251 /// carriers touch with parallel normals and the section crosses itself, so
252 /// the pair was marched rather than refused (see
253 /// `imprint/tangent_contact.rs`). The boolean attributes a later tearing to
254 /// these rather than reporting an anonymous degeneracy — the second-order
255 /// filter cannot decide a third-order singularity, so the assembly is the
256 /// authority on whether a node it admitted was really imprintable.
257 #[serde(default)]
258 pub tangent_nodes: Vec<Vec3>,
259 /// True when the imprint saw ANY surface-contact evidence — an accepted
260 /// pierce seed, a traced SSI branch (even one later clipped away), or a
261 /// minted piece. Pure material disjointness leaves none, while a
262 /// silently-lost section always leaves at least the upstream evidence, so
263 /// the boolean's legitimate-empty adjudication requires this to be false.
264 #[serde(default)]
265 pub section_evidence: bool,
266}
267
268#[path = "imprint/support.rs"]
269mod support;
270#[path = "imprint/builder.rs"]
271mod builder;
272#[path = "imprint/junctions.rs"]
273mod junctions;
274#[path = "imprint/self_touch.rs"]
275mod self_touch;
276#[path = "imprint/sections.rs"]
277mod sections;
278#[path = "imprint/tangent_contact.rs"]
279mod tangent_contact;
280#[path = "imprint/driver.rs"]
281mod driver;
282// BREP private tests: 926d1ffe6a42f122
283
284use builder::*;
285use junctions::*;
286use sections::*;
287use support::*;
288use tangent_contact::classify_tangent_contact;
289
290pub use driver::build_imprints;
291pub(crate) use self_touch::self_touch_edge_splits;
292
293// BREP private tests: 4efc8161f2a59e6e