cad-editor/src/scene/convert/spline_tess.rs
Hakan Seven 34d0c377e2 fix(3d): complete ACIS solid rendering
- track per-face coverage and retain safe display fallbacks
- tessellate shared NURBS, pcurve trims and periodic surfaces
- pin the matching acadifc decoder revision
2026-07-26 01:35:16 +03:00

495 lines
17 KiB
Rust

// B-spline (NURBS) surface tessellation for ACIS `spline-surface` faces.
//
// Lofted / swept / revolved surfaces store their geometry as an ACIS
// `nubs` (non-uniform B-spline) block inside the `spline-surface` record.
// Rather than evaluate the basis functions by hand, we parse the control net
// and knot vectors out of the SAT tokens, hand them to truck's
// `BSplineSurface` (the same NURBS kernel the Model tab already builds on),
// and sample its parametric grid into triangles.
use acadrust::entities::acis::types::Sense;
use acadrust::entities::acis::{
SatCoedge, SatDocument, SatFace, SatLoop, SatPCurve, SatRecord, SatToken,
};
use rustc_hash::FxHashSet;
use truck_modeling::base::{Vector3, Vector4};
use truck_modeling::{
BSplineSurface, KnotVec, NurbsSurface, ParametricSurface, ParametricSurface3D, Point3,
};
use crate::scene::convert::solid3d_tess::LodConfig;
/// A `spline-surface` block is either a non-rational `nubs` (control points are
/// plain xyz) or a rational `nurbs` (control points carry a weight). They share
/// the parametric-surface interface, so tessellation treats them uniformly.
enum SplineSurf {
Bs(BSplineSurface<Point3>),
Nurbs(NurbsSurface<Vector4>),
}
impl SplineSurf {
fn parameter_range(
&self,
) -> (
(std::ops::Bound<f64>, std::ops::Bound<f64>),
(std::ops::Bound<f64>, std::ops::Bound<f64>),
) {
match self {
SplineSurf::Bs(s) => s.parameter_range(),
SplineSurf::Nurbs(s) => s.parameter_range(),
}
}
fn subs(&self, u: f64, v: f64) -> Point3 {
match self {
SplineSurf::Bs(s) => s.subs(u, v),
SplineSurf::Nurbs(s) => s.subs(u, v),
}
}
fn normal(&self, u: f64, v: f64) -> Vector3 {
match self {
SplineSurf::Bs(s) => s.normal(u, v),
SplineSurf::Nurbs(s) => s.normal(u, v),
}
}
}
/// Tessellate one `spline-surface` face by sampling its B-spline surface.
/// Appends triangles to the shared mesh buffers; a no-op when the surface
/// record can't be parsed into a B-spline.
pub fn tess_spline_face(
sat: &SatDocument,
face: &SatFace,
lod: LodConfig,
verts: &mut Vec<[f64; 3]>,
normals: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) -> bool {
let Some(surf_rec) = sat.resolve(face.surface()) else {
return false;
};
let Some(surface) = build_spline_surface(sat, surf_rec) else {
return false;
};
// Sample over the knot domain and clip cells against ACIS pcurves when the
// face carries a parametric trim. This preserves holes and non-rectangular
// spline faces instead of always filling the complete UV rectangle.
let (u_range, v_range) = surface.parameter_range();
let (u0, u1) = range_bounds(u_range);
let (v0, v1) = range_bounds(v_range);
if !(u1 > u0) || !(v1 > v0) {
return false;
}
// A B-spline patch has no single analytic radius to drive a chord-tolerance
// count, so sample at the LOD's nominal density (its unit-circle segment
// count). Floor 8 so a curved patch stays smooth.
let n = crate::scene::convert::solid3d_tess::nominal_segs(lod.chord_frac).max(8);
let (su, sv) = (n, n);
let trim_loops = collect_trim_loops(sat, face, n);
let reversed = matches!(face.sense(), Sense::Reversed);
let index_start = indices.len();
let base = verts.len() as u32;
for j in 0..=sv {
let v = v0 + (v1 - v0) * (j as f64 / sv as f64);
for i in 0..=su {
let u = u0 + (u1 - u0) * (i as f64 / su as f64);
let p = surface.subs(u, v);
let mut n = surface.normal(u, v);
if reversed {
n = -n;
}
verts.push([p.x, p.y, p.z]);
normals.push([n.x as f32, n.y as f32, n.z as f32]);
}
}
let row = (su + 1) as u32;
for j in 0..sv as u32 {
for i in 0..su as u32 {
if let Some(loops) = trim_loops.as_ref() {
let u = u0 + (u1 - u0) * ((i as f64 + 0.5) / su as f64);
let v = v0 + (v1 - v0) * ((j as f64 + 0.5) / sv as f64);
if !inside_trim((u, v), loops, (u0, u1, v0, v1)) {
continue;
}
}
let a = base + j * row + i;
let b = a + 1;
let c = a + row;
let d = c + 1;
if reversed {
indices.extend_from_slice(&[a, d, b, a, c, d]);
} else {
indices.extend_from_slice(&[a, b, d, a, d, c]);
}
}
}
indices.len() > index_start
}
/// Collect complete face-loop pcurves in UV space. Missing pcurves disable
/// clipping for that face; a partial trim would be worse than the old full
/// patch fallback.
fn collect_trim_loops(
sat: &SatDocument,
face: &SatFace,
segments: usize,
) -> Option<Vec<Vec<(f64, f64)>>> {
let mut result = Vec::new();
let mut loop_ptr = face.first_loop();
let mut seen_loops = FxHashSet::default();
while !loop_ptr.is_null() && seen_loops.insert(loop_ptr.0) {
let sat_loop = SatLoop::from_record(sat.resolve(loop_ptr)?)?;
let first = sat_loop.first_coedge();
let mut coedge_ptr = first;
let mut seen_coedges = FxHashSet::default();
let mut polygon: Vec<(f64, f64)> = Vec::new();
while !coedge_ptr.is_null() && seen_coedges.insert(coedge_ptr.0) {
let coedge = SatCoedge::from_record(sat.resolve(coedge_ptr)?)?;
let pcurve = SatPCurve::from_record(sat.resolve(coedge.pcurve())?)?;
let mut points = pcurve.sample_in(sat, segments);
if points.len() < 2 {
return None;
}
if matches!(coedge.sense(), Sense::Reversed) {
points.reverse();
}
if let Some(&last) = polygon.last() {
let first_gap =
(last.0 - points[0].0).powi(2) + (last.1 - points[0].1).powi(2);
let end = points[points.len() - 1];
let last_gap = (last.0 - end.0).powi(2) + (last.1 - end.1).powi(2);
if last_gap < first_gap {
points.reverse();
}
}
points.pop();
polygon.extend(points);
coedge_ptr = coedge.next();
if coedge_ptr == first {
break;
}
}
if polygon.len() < 3 {
return None;
}
result.push(polygon);
loop_ptr = sat_loop.next_loop();
}
if result.is_empty() {
None
} else {
Some(result)
}
}
fn inside_trim(
point: (f64, f64),
loops: &[Vec<(f64, f64)>],
domain: (f64, f64, f64, f64),
) -> bool {
let domain_area = ((domain.1 - domain.0) * (domain.3 - domain.2)).abs();
let area_epsilon = domain_area.max(1.0) * 1e-10;
let periodic_boundary = loops.iter().any(|polygon| {
if polygon_area(polygon).abs() > area_epsilon {
return false;
}
let bounds = polygon_bounds(polygon);
let u_span = (bounds[2] - bounds[0]).abs();
let v_span = (bounds[3] - bounds[1]).abs();
u_span >= (domain.1 - domain.0).abs() * 0.9
|| v_span >= (domain.3 - domain.2).abs() * 0.9
});
if periodic_boundary {
return loops.iter().all(|polygon| {
polygon_area(polygon).abs() <= area_epsilon
|| !point_in_polygon(point, polygon)
});
}
let Some((outer_index, _)) = loops
.iter()
.enumerate()
.map(|(index, polygon)| (index, polygon_area(polygon).abs()))
.max_by(|a, b| a.1.total_cmp(&b.1))
else {
return true;
};
point_in_polygon(point, &loops[outer_index])
&& loops
.iter()
.enumerate()
.all(|(index, polygon)| index == outer_index || !point_in_polygon(point, polygon))
}
fn polygon_bounds(polygon: &[(f64, f64)]) -> [f64; 4] {
polygon.iter().fold(
[
f64::INFINITY,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NEG_INFINITY,
],
|mut bounds, &(u, v)| {
bounds[0] = bounds[0].min(u);
bounds[1] = bounds[1].min(v);
bounds[2] = bounds[2].max(u);
bounds[3] = bounds[3].max(v);
bounds
},
)
}
fn polygon_area(polygon: &[(f64, f64)]) -> f64 {
polygon
.iter()
.zip(polygon.iter().cycle().skip(1))
.take(polygon.len())
.map(|(&(ax, ay), &(bx, by))| ax * by - bx * ay)
.sum::<f64>()
* 0.5
}
fn point_in_polygon(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
let (x, y) = point;
let mut inside = false;
let mut previous = polygon[polygon.len() - 1];
for &current in polygon {
let crosses = (current.1 > y) != (previous.1 > y)
&& x
< (previous.0 - current.0) * (y - current.1)
/ (previous.1 - current.1)
+ current.0;
if crosses {
inside = !inside;
}
previous = current;
}
inside
}
/// Extract the inclusive `[start, end]` bounds from a truck parameter range.
fn range_bounds(r: (std::ops::Bound<f64>, std::ops::Bound<f64>)) -> (f64, f64) {
use std::ops::Bound::*;
let lo = match r.0 {
Included(v) | Excluded(v) => v,
Unbounded => 0.0,
};
let hi = match r.1 {
Included(v) | Excluded(v) => v,
Unbounded => 1.0,
};
(lo, hi)
}
/// Parse the `nubs` control net + knot vectors out of a `spline-surface`
/// record's token stream into a truck `BSplineSurface`.
fn build_spline_surface(sat: &SatDocument, rec: &SatRecord) -> Option<SplineSurf> {
let mut toks = rec.tokens.as_slice();
if let Some(reference) = primary_subtype_reference(toks) {
toks = sat.subtype_tokens(reference)?;
}
// Locate the real B-spline block. `nullbs` placeholders (for absent
// rail/path surfaces) precede it; the actual surface is `nubs` (plain xyz
// control points) or `nurbs` (rational — each control point carries a
// weight, so it is stored as xyzw).
let start = toks
.iter()
.rposition(|t| matches!(t, SatToken::Ident(s) if s == "nubs" || s == "nurbs"))?;
let rational = matches!(&toks[start], SatToken::Ident(s) if s == "nurbs");
let mut p = start + 1;
let deg_u = read_int(toks, &mut p)? as usize;
let deg_v = read_int(toks, &mut p)? as usize;
// Four form flags (closure / singularity in u and v) — skip.
for _ in 0..4 {
read_int(toks, &mut p)?;
}
let n_uknot = read_int(toks, &mut p)? as usize;
let n_vknot = read_int(toks, &mut p)? as usize;
let raw_u_knots = read_knot_vec(toks, &mut p, n_uknot)?;
let raw_v_knots = read_knot_vec(toks, &mut p, n_vknot)?;
let stride = if rational { 4 } else { 3 };
let available = toks[p..]
.iter()
.take_while(|token| token.as_float().is_some())
.count();
let base_u = raw_u_knots.len().checked_sub(deg_u + 1)?;
let base_v = raw_v_knots.len().checked_sub(deg_v + 1)?;
let mut best: Option<(usize, usize, bool, bool, usize)> = None;
for clamp_u in [false, true] {
for clamp_v in [false, true] {
let n_ctrl_u = base_u + usize::from(clamp_u) * 2;
let n_ctrl_v = base_v + usize::from(clamp_v) * 2;
if n_ctrl_u <= deg_u || n_ctrl_v <= deg_v {
continue;
}
let needed = n_ctrl_u.checked_mul(n_ctrl_v)?.checked_mul(stride)?;
if needed > available {
continue;
}
let remaining = available - needed;
if best.as_ref().is_none_or(|candidate| remaining < candidate.4) {
best = Some((n_ctrl_u, n_ctrl_v, clamp_u, clamp_v, remaining));
}
}
}
let Some((n_ctrl_u, n_ctrl_v, clamp_u, clamp_v, _)) = best else {
if std::env::var_os("OCS_TESS_DEBUG").is_some() {
eprintln!(
"acis_spline_parse[{}]: no control-net match degree={deg_u}x{deg_v} raw_knots={}x{} available={available}",
rec.index,
raw_u_knots.len(),
raw_v_knots.len()
);
}
return None;
};
let u_knots = with_clamped_ends(raw_u_knots, clamp_u)?;
let v_knots = with_clamped_ends(raw_v_knots, clamp_v)?;
// Control points are stored row-major with u varying fastest (a full row
// of u control points per v step). truck wants `ctrl[i_u][j_v]`.
let total = n_ctrl_u * n_ctrl_v;
let uk = KnotVec::from(u_knots);
let vk = KnotVec::from(v_knots);
if rational {
// Rational: read x, y, z, w and store the homogeneous point (xw, yw,
// zw, w) that truck's `NurbsSurface` expects.
let mut flat: Vec<Vector4> = Vec::with_capacity(total);
for _ in 0..total {
let x = read_float(toks, &mut p)?;
let y = read_float(toks, &mut p)?;
let z = read_float(toks, &mut p)?;
let w = read_float(toks, &mut p)?;
flat.push(Vector4::new(x * w, y * w, z * w, w));
}
let mut ctrl = vec![Vec::with_capacity(n_ctrl_v); n_ctrl_u];
for v in 0..n_ctrl_v {
for u in 0..n_ctrl_u {
ctrl[u].push(flat[v * n_ctrl_u + u]);
}
}
let bs = match BSplineSurface::try_new((uk, vk), ctrl) {
Ok(surface) => surface,
Err(error) => {
if std::env::var_os("OCS_TESS_DEBUG").is_some() {
eprintln!(
"acis_spline_parse[{}]: rational surface rejected degree={deg_u}x{deg_v} control={n_ctrl_u}x{n_ctrl_v}: {error:?}",
rec.index
);
}
return None;
}
};
Some(SplineSurf::Nurbs(NurbsSurface::new(bs)))
} else {
let mut flat: Vec<Point3> = Vec::with_capacity(total);
for _ in 0..total {
let x = read_float(toks, &mut p)?;
let y = read_float(toks, &mut p)?;
let z = read_float(toks, &mut p)?;
flat.push(Point3::new(x, y, z));
}
let mut ctrl = vec![Vec::with_capacity(n_ctrl_v); n_ctrl_u];
for v in 0..n_ctrl_v {
for u in 0..n_ctrl_u {
ctrl[u].push(flat[v * n_ctrl_u + u]);
}
}
let bs = match BSplineSurface::try_new((uk, vk), ctrl) {
Ok(surface) => surface,
Err(error) => {
if std::env::var_os("OCS_TESS_DEBUG").is_some() {
eprintln!(
"acis_spline_parse[{}]: surface rejected degree={deg_u}x{deg_v} control={n_ctrl_u}x{n_ctrl_v}: {error:?}",
rec.index
);
}
return None;
}
};
Some(SplineSurf::Bs(bs))
}
}
/// Read `count` `(knot value, multiplicity)` pairs into an expanded raw knot
/// vector. Some ACIS families store degree-sized ends and others already carry
/// the complete knot vector; the control-net size decides that after both axes
/// have been read.
fn read_knot_vec(
toks: &[SatToken],
p: &mut usize,
count: usize,
) -> Option<Vec<f64>> {
let mut knots: Vec<f64> = Vec::new();
for _ in 0..count {
let value = read_float(toks, p)?;
let mult = read_int(toks, p)? as usize;
for _ in 0..mult {
knots.push(value);
}
}
if knots.len() < 2 {
return None;
}
Some(knots)
}
fn with_clamped_ends(mut knots: Vec<f64>, clamp: bool) -> Option<Vec<f64>> {
if clamp {
let first = *knots.first()?;
let last = *knots.last()?;
knots.insert(0, first);
knots.push(last);
}
Some(knots)
}
fn primary_subtype_reference(tokens: &[SatToken]) -> Option<usize> {
let start = tokens
.iter()
.position(|token| token.as_ident() == Some("{"))?;
if tokens.get(start + 1).and_then(SatToken::as_ident) != Some("ref")
|| tokens.get(start + 3).and_then(SatToken::as_ident) != Some("}")
{
return None;
}
tokens
.get(start + 2)?
.as_integer()
.and_then(|index| usize::try_from(index).ok())
}
fn read_int(toks: &[SatToken], p: &mut usize) -> Option<i64> {
while *p < toks.len() {
let t = &toks[*p];
*p += 1;
match t {
SatToken::Integer(v) => return Some(*v),
SatToken::Float(v) => return Some(*v as i64),
// Skip block delimiters / idents that may appear inline.
SatToken::Ident(_) | SatToken::Enum(_) => continue,
_ => return None,
}
}
None
}
fn read_float(toks: &[SatToken], p: &mut usize) -> Option<f64> {
while *p < toks.len() {
let t = &toks[*p];
*p += 1;
match t {
SatToken::Float(v) => return Some(*v),
SatToken::Integer(v) => return Some(*v as f64),
SatToken::Ident(_) | SatToken::Enum(_) => continue,
_ => return None,
}
}
None
}