- track per-face coverage and retain safe display fallbacks - tessellate shared NURBS, pcurve trims and periodic surfaces - pin the matching acadifc decoder revision
495 lines
17 KiB
Rust
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 ¤t 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
|
|
}
|