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
This commit is contained in:
Hakan Seven 2026-07-26 01:35:16 +03:00
commit 34d0c377e2
8 changed files with 431 additions and 113 deletions

2
Cargo.lock generated
View file

@ -75,7 +75,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]] [[package]]
name = "acadrust" name = "acadrust"
version = "0.4.0" version = "0.4.0"
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=ff7a7a3#ff7a7a3209d11503ecfb3fe759ab86cd2be6c46a" source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=8cc4793#8cc479342635fe16694e17226670d800cb3a1dfe"
dependencies = [ dependencies = [
"ahash 0.8.12", "ahash 0.8.12",
"anyhow", "anyhow",

View file

@ -91,7 +91,7 @@ windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_Window
[patch.crates-io] [patch.crates-io]
# Track the verified DWG round-trip, I/O, and unified PERF fixes. # Track the verified DWG round-trip, I/O, and unified PERF fixes.
acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "ff7a7a3" } acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "8cc4793" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Native enables the plugin host runtime (out-of-process plugins). # Native enables the plugin host runtime (out-of-process plugins).

View file

@ -263,21 +263,34 @@ pub fn fallback_wires(e: &EntityType) -> Option<&[acadrust::entities::Wire]> {
} }
} }
/// Whether the entity's ACIS payload parses into a SAT document. When it /// Whether every ACIS face uses a surface family the mesh pipeline can decode.
/// does, the mesh pipeline (fill + feature edges + isolines, all body-placed) /// Unsupported or unresolved faces must keep their display-cache wires visible;
/// is the authoritative render and the embedded display-cache wires must NOT /// otherwise a parseable but incomplete shell looks like a valid solid.
/// be drawn: they are body-local (unplaced), and for R2013+ AcDs-backed pub fn acis_has_complete_surface_support(e: &EntityType) -> bool {
/// solids the inline wire section misparses into garbage points near the let sat = match e {
/// origin. The wires remain useful only as a last resort when the ACIS data EntityType::Solid3D(s) => s.acis_data.parse(),
/// itself is unreadable. EntityType::Region(r) => r.acis_data.parse(),
pub fn acis_parses(e: &EntityType) -> bool { EntityType::Body(b) => b.acis_data.parse(),
match e { EntityType::Surface(s) => s.acis_data.parse(),
EntityType::Solid3D(s) => s.acis_data.parse().is_some(), _ => None,
EntityType::Region(r) => r.acis_data.parse().is_some(), };
EntityType::Body(b) => b.acis_data.parse().is_some(), let Some(sat) = sat else {
EntityType::Surface(s) => s.acis_data.parse().is_some(), return false;
_ => false, };
} let faces = sat.faces();
!faces.is_empty()
&& faces.iter().all(|face| {
sat.resolve(face.surface()).is_some_and(|surface| {
matches!(
surface.entity_type.as_str(),
"plane-surface"
| "cone-surface"
| "sphere-surface"
| "torus-surface"
| "spline-surface"
)
})
})
} }
/// Run the appropriate `solid3d_tess::tessellate_*` for the entity, /// Run the appropriate `solid3d_tess::tessellate_*` for the entity,

View file

@ -16,8 +16,8 @@
// Each face is meshed independently and its triangles are oriented outward // Each face is meshed independently and its triangles are oriented outward
// using an analytic per-surface normal — truck's own face orientation is not // using an analytic per-surface normal — truck's own face orientation is not
// consistent across independently built faces, so normals/winding are derived // consistent across independently built faces, so normals/winding are derived
// from geometry instead. Faces whose surface type isn't handled are skipped; // from geometry instead. Face coverage is recorded on `MeshLodSet`; partial
// the caller falls back to `solid3d_tess` when this returns `None`. // shells remain displayable but cannot masquerade as complete solid topology.
use truck_meshalgo::tessellation::{MeshableShape, MeshedShape}; use truck_meshalgo::tessellation::{MeshableShape, MeshedShape};
use truck_modeling::{builder, Face, InnerSpace, Point3, Rad, Shell, Vector3, Wire}; use truck_modeling::{builder, Face, InnerSpace, Point3, Rad, Shell, Vector3, Wire};
@ -91,11 +91,14 @@ pub fn tessellate_sat_truck(
let mut verts: Vec<[f64; 3]> = Vec::new(); let mut verts: Vec<[f64; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new(); let mut normals: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new(); let mut indices: Vec<u32> = Vec::new();
let mut complete = true;
for face in sat.faces().into_iter() { for face in sat.faces().into_iter() {
let Some(surf_rec) = sat.resolve(face.surface()) else { let Some(surf_rec) = sat.resolve(face.surface()) else {
complete = false;
continue; continue;
}; };
let before = indices.len();
let mut appended = false; let mut appended = false;
if let Some((faces, outward, tol)) = build_face_group(sat, &face, surf_rec) { if let Some((faces, outward, tol)) = build_face_group(sat, &face, surf_rec) {
if !faces.is_empty() { if !faces.is_empty() {
@ -114,19 +117,21 @@ pub fn tessellate_sat_truck(
// buffers, so the shared finalize below still applies uniformly. // buffers, so the shared finalize below still applies uniformly.
if !appended { if !appended {
bespoke_face(sat, &face, surf_rec, &mut verts, &mut normals, &mut indices); bespoke_face(sat, &face, surf_rec, &mut verts, &mut normals, &mut indices);
appended = indices.len() > before;
}
if !appended {
complete = false;
} }
} }
// Spline (NURBS) faces are meshed by direct grid sampling of the truck
// BSplineSurface — see spline_tess — and merged into the same buffers.
append_spline_faces(sat, &mut verts, &mut normals, &mut indices);
if indices.is_empty() { if indices.is_empty() {
return None; return None;
} }
let mesh = finalize_mesh(name, verts, normals, indices, color, body_transform(sat)); let mesh = finalize_mesh(name, verts, normals, indices, color, body_transform(sat));
Some(MeshLodSet::from_lods(vec![mesh])) let mut set = MeshLodSet::from_lods(vec![mesh]);
set.complete = complete;
Some(set)
} }
/// Fill one face with the bespoke parametric sampler (body-local verts into the /// Fill one face with the bespoke parametric sampler (body-local verts into the
@ -161,6 +166,16 @@ fn bespoke_face(
tess_torus_face(sat, face, &t, LodConfig::HIGH, v, n, i); tess_torus_face(sat, face, &t, LodConfig::HIGH, v, n, i);
} }
} }
"spline-surface" => {
crate::scene::convert::spline_tess::tess_spline_face(
sat,
face,
LodConfig::HIGH,
v,
n,
i,
);
}
_ => {} _ => {}
} }
} }
@ -404,34 +419,6 @@ fn cone_boundary_arc(
(start, span) (start, span)
} }
// ── Spline faces (NURBS) ─────────────────────────────────────────────────────
/// Append meshes for every `spline-surface` face, reusing the truck
/// BSplineSurface grid sampler in `spline_tess`.
fn append_spline_faces(
sat: &SatDocument,
verts: &mut Vec<[f64; 3]>,
normals: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>,
) {
for face in sat.faces() {
let Some(surf_rec) = sat.resolve(face.surface()) else {
continue;
};
if surf_rec.entity_type != "spline-surface" {
continue;
}
crate::scene::convert::spline_tess::tess_spline_face(
sat,
&face,
LodConfig::HIGH,
verts,
normals,
indices,
);
}
}
// ── Mesh append with analytic outward normals ──────────────────────────────── // ── Mesh append with analytic outward normals ────────────────────────────────
/// Append one face's triangulation to `mesh`, computing smooth per-vertex /// Append one face's triangulation to `mesh`, computing smooth per-vertex

View file

@ -7,8 +7,9 @@
// • sphere-surface faces → sample a full UV grid. // • sphere-surface faces → sample a full UV grid.
// • torus-surface faces → sample a full UV grid. // • torus-surface faces → sample a full UV grid.
// //
// All other surface types are silently skipped; partial results are still // Coverage is tracked explicitly. Unsupported or malformed faces retain
// returned so the solid renders with at least its planar faces. // feature/display wires, and partial shells are marked non-complete so solid
// editing never mistakes them for closed topology.
use rustc_hash::FxHashSet as HashSet; use rustc_hash::FxHashSet as HashSet;
use std::f64::consts::TAU; use std::f64::consts::TAU;
@ -122,16 +123,19 @@ fn tessellate_sat(
color: [f32; 4], color: [f32; 4],
lod: LodConfig, lod: LodConfig,
xform: Option<([f64; 9], [f64; 3], f64)>, xform: Option<([f64; 9], [f64; 3], f64)>,
) -> Option<MeshModel> { ) -> Option<(MeshModel, bool)> {
let mut verts: Vec<[f64; 3]> = Vec::new(); let mut verts: Vec<[f64; 3]> = Vec::new();
let mut normals: Vec<[f32; 3]> = Vec::new(); let mut normals: Vec<[f32; 3]> = Vec::new();
let mut indices: Vec<u32> = Vec::new(); let mut indices: Vec<u32> = Vec::new();
let mut complete = true;
for face in sat.faces() { for face in sat.faces() {
let surf_ptr = face.surface(); let surf_ptr = face.surface();
let Some(surf_rec) = sat.resolve(surf_ptr) else { let Some(surf_rec) = sat.resolve(surf_ptr) else {
complete = false;
continue; continue;
}; };
let before = indices.len();
match surf_rec.entity_type.as_str() { match surf_rec.entity_type.as_str() {
"plane-surface" => { "plane-surface" => {
if let Some(plane) = SatPlaneSurface::from_record(surf_rec) { if let Some(plane) = SatPlaneSurface::from_record(surf_rec) {
@ -197,11 +201,17 @@ fn tessellate_sat(
} }
_ => {} _ => {}
} }
if indices.len() == before {
complete = false;
}
} }
if indices.is_empty() { if indices.is_empty() {
return None; return None;
} }
Some(finalize_mesh(name, verts, normals, indices, color, xform)) Some((
finalize_mesh(name, verts, normals, indices, color, xform),
complete,
))
} }
/// Tessellate an ACIS document, preferring the truck B-rep kernel and falling /// Tessellate an ACIS document, preferring the truck B-rep kernel and falling
@ -214,26 +224,51 @@ fn tessellate_acis(
facet_res: f64, facet_res: f64,
isolines: usize, isolines: usize,
) -> Option<MeshLodSet> { ) -> Option<MeshLodSet> {
let mut set = if let Some(set) = crate::scene::convert::acis_to_truck::tessellate_sat_truck( let truck = crate::scene::convert::acis_to_truck::tessellate_sat_truck(
sat, sat,
name.clone(), name.clone(),
color, color,
facet_res, facet_res,
) { );
let manual = if truck.as_ref().is_some_and(|set| set.complete) {
None
} else {
tessellate_sat_lods(sat, name.clone(), color, facet_res)
};
let mut set = match (truck, manual) {
(Some(set), _) if set.complete => set,
(_, Some(set)) if set.complete => set,
(Some(truck), Some(manual)) => {
let truck_tris = truck
.lods
.first()
.map(|mesh| mesh.indices.len())
.unwrap_or(0);
let manual_tris = manual
.lods
.first()
.map(|mesh| mesh.indices.len())
.unwrap_or(0);
if manual_tris > truck_tris {
manual
} else {
truck
}
}
(Some(set), None) | (None, Some(set)) => set,
(None, None) => return None,
};
if set.complete {
if std::env::var_os("OCS_TESS_DEBUG").is_some() { if std::env::var_os("OCS_TESS_DEBUG").is_some() {
let tris = set.lods.first().map(|m| m.indices.len() / 3).unwrap_or(0); let tris = set.lods.first().map(|m| m.indices.len() / 3).unwrap_or(0);
eprintln!("acis_tess[{name}]: truck ({tris} tris)"); eprintln!("acis_tess[{name}]: complete ({tris} tris)");
} }
set } else if std::env::var_os("OCS_TESS_DEBUG").is_some() {
} else { let tris = set.lods.first().map(|m| m.indices.len() / 3).unwrap_or(0);
// truck couldn't rebuild the shell — fall back to the bespoke sampler.
let fallback = tessellate_sat_lods(sat, name.clone(), color, facet_res);
eprintln!( eprintln!(
"acis_tess[{name}]: manual fallback ({})", "acis_tess[{name}]: partial ({tris} tris); feature/display wires retained"
if fallback.is_some() { "ok" } else { "empty" }
); );
fallback? }
};
// Attach the B-rep face-boundary edges plus ISOLINES on curved faces // Attach the B-rep face-boundary edges plus ISOLINES on curved faces
// (body-transformed, split into the double-single pair) so the solid's // (body-transformed, split into the double-single pair) so the solid's
// wireframe shows real edges and curved faces read from any angle. // wireframe shows real edges and curved faces read from any angle.
@ -878,16 +913,22 @@ fn tessellate_sat_lods(
let configs = LodConfig::all(); let configs = LodConfig::all();
let xform = body_transform(sat); let xform = body_transform(sat);
let mut lods: Vec<MeshModel> = Vec::with_capacity(3); let mut lods: Vec<MeshModel> = Vec::with_capacity(3);
let mut complete = true;
for lod in configs { for lod in configs {
let scaled = scale_lod(lod, facet_res); let scaled = scale_lod(lod, facet_res);
if let Some(m) = tessellate_sat(sat, name.clone(), color, scaled, xform) { if let Some((m, lod_complete)) = tessellate_sat(sat, name.clone(), color, scaled, xform) {
lods.push(m); lods.push(m);
complete &= lod_complete;
} else {
complete = false;
} }
} }
if lods.is_empty() { if lods.is_empty() {
return None; return None;
} }
Some(MeshLodSet::from_lods(lods)) let mut set = MeshLodSet::from_lods(lods);
set.complete = complete;
Some(set)
} }
/// Extract the body's placement transform from the SAT document: a row-major /// Extract the body's placement transform from the SAT document: a row-major
@ -1636,13 +1677,16 @@ fn angular_range(
} }
/// Recover a cone/cylinder face's height span (along its axis) from the solid's /// Recover a cone/cylinder face's height span (along its axis) from the solid's
/// circular rims when the face boundary collapses to a single height. /// circular rims or B-rep vertices when the face boundary collapses to a
/// single height.
/// ///
/// Scans every ellipse/circle curve in the document, keeps those coaxial with /// Scans every ellipse/circle curve in the document, keeps those coaxial with
/// this cone (centre on the axis line, normal parallel to the axis), and /// this cone (centre on the axis line, normal parallel to the axis), and
/// projects their centres onto the axis to get rim heights. For a true cone /// projects their centres onto the axis to get rim heights. Some imported
/// with a single rim, the tip is added analytically (the height where the /// solids represent circular rims as spline/intcurve edges, so point records
/// radius reaches zero). Returns `None` when no coaxial rim is found. /// lying on the analytic cone are the secondary source. For a true cone with a
/// single rim, the tip is added analytically. Returns `None` when no span is
/// recoverable.
pub(crate) fn cone_axis_span( pub(crate) fn cone_axis_span(
sat: &SatDocument, sat: &SatDocument,
cone: &SatConeSurface, cone: &SatConeSurface,
@ -1669,6 +1713,38 @@ pub(crate) fn cone_axis_span(
heights.push(h); heights.push(h);
} }
} }
if heights.len() < 2 {
let sin_a = cone.sin_half_angle();
let cos_a = cone.cos_half_angle();
let tangent = if cos_a.abs() > 1e-9 {
sin_a / cos_a
} else {
0.0
};
for rec in &sat.records {
let Some(point) = SatPoint::from_record(rec) else {
continue;
};
let position = point.position();
let d = [
position.0 - center[0],
position.1 - center[1],
position.2 - center[2],
];
let h = dot3(d, axis);
let radial = [
d[0] - h * axis[0],
d[1] - h * axis[1],
d[2] - h * axis[2],
];
let radial_len = dot3(radial, radial).sqrt();
let expected = (cone.radius() + h * tangent).abs();
let tolerance = expected.max(cone.radius().abs()).max(1.0) * 1e-5;
if (radial_len - expected).abs() <= tolerance {
heights.push(h);
}
}
}
if heights.is_empty() { if heights.is_empty() {
return None; return None;
} }

View file

@ -7,7 +7,11 @@
// `BSplineSurface` (the same NURBS kernel the Model tab already builds on), // `BSplineSurface` (the same NURBS kernel the Model tab already builds on),
// and sample its parametric grid into triangles. // and sample its parametric grid into triangles.
use acadrust::entities::acis::{SatDocument, SatFace, SatRecord, SatToken}; 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::base::{Vector3, Vector4};
use truck_modeling::{ use truck_modeling::{
BSplineSurface, KnotVec, NurbsSurface, ParametricSurface, ParametricSurface3D, Point3, BSplineSurface, KnotVec, NurbsSurface, ParametricSurface, ParametricSurface3D, Point3,
@ -59,22 +63,22 @@ pub fn tess_spline_face(
verts: &mut Vec<[f64; 3]>, verts: &mut Vec<[f64; 3]>,
normals: &mut Vec<[f32; 3]>, normals: &mut Vec<[f32; 3]>,
indices: &mut Vec<u32>, indices: &mut Vec<u32>,
) { ) -> bool {
let Some(surf_rec) = sat.resolve(face.surface()) else { let Some(surf_rec) = sat.resolve(face.surface()) else {
return; return false;
}; };
let Some(surface) = build_spline_surface(surf_rec) else { let Some(surface) = build_spline_surface(sat, surf_rec) else {
return; return false;
}; };
// Sample over the knot domain. The B-spline patches stored by loft/sweep // Sample over the knot domain and clip cells against ACIS pcurves when the
// are already trimmed to the face, so the full parametric rectangle is the // face carries a parametric trim. This preserves holes and non-rectangular
// visible surface — no separate boundary trim needed. // spline faces instead of always filling the complete UV rectangle.
let (u_range, v_range) = surface.parameter_range(); let (u_range, v_range) = surface.parameter_range();
let (u0, u1) = range_bounds(u_range); let (u0, u1) = range_bounds(u_range);
let (v0, v1) = range_bounds(v_range); let (v0, v1) = range_bounds(v_range);
if !(u1 > u0) || !(v1 > v0) { if !(u1 > u0) || !(v1 > v0) {
return; return false;
} }
// A B-spline patch has no single analytic radius to drive a chord-tolerance // A B-spline patch has no single analytic radius to drive a chord-tolerance
@ -82,6 +86,9 @@ pub fn tess_spline_face(
// count). Floor 8 so a curved patch stays smooth. // count). Floor 8 so a curved patch stays smooth.
let n = crate::scene::convert::solid3d_tess::nominal_segs(lod.chord_frac).max(8); let n = crate::scene::convert::solid3d_tess::nominal_segs(lod.chord_frac).max(8);
let (su, sv) = (n, n); 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; let base = verts.len() as u32;
for j in 0..=sv { for j in 0..=sv {
@ -89,7 +96,10 @@ pub fn tess_spline_face(
for i in 0..=su { for i in 0..=su {
let u = u0 + (u1 - u0) * (i as f64 / su as f64); let u = u0 + (u1 - u0) * (i as f64 / su as f64);
let p = surface.subs(u, v); let p = surface.subs(u, v);
let n = surface.normal(u, v); let mut n = surface.normal(u, v);
if reversed {
n = -n;
}
verts.push([p.x, p.y, p.z]); verts.push([p.x, p.y, p.z]);
normals.push([n.x as f32, n.y as f32, n.z as f32]); normals.push([n.x as f32, n.y as f32, n.z as f32]);
} }
@ -98,13 +108,166 @@ pub fn tess_spline_face(
let row = (su + 1) as u32; let row = (su + 1) as u32;
for j in 0..sv as u32 { for j in 0..sv as u32 {
for i in 0..su 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 a = base + j * row + i;
let b = a + 1; let b = a + 1;
let c = a + row; let c = a + row;
let d = c + 1; let d = c + 1;
indices.extend_from_slice(&[a, b, d, a, d, c]); 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. /// Extract the inclusive `[start, end]` bounds from a truck parameter range.
@ -123,15 +286,18 @@ fn range_bounds(r: (std::ops::Bound<f64>, std::ops::Bound<f64>)) -> (f64, f64) {
/// Parse the `nubs` control net + knot vectors out of a `spline-surface` /// Parse the `nubs` control net + knot vectors out of a `spline-surface`
/// record's token stream into a truck `BSplineSurface`. /// record's token stream into a truck `BSplineSurface`.
fn build_spline_surface(rec: &SatRecord) -> Option<SplineSurf> { fn build_spline_surface(sat: &SatDocument, rec: &SatRecord) -> Option<SplineSurf> {
let toks = &rec.tokens; 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 // Locate the real B-spline block. `nullbs` placeholders (for absent
// rail/path surfaces) precede it; the actual surface is `nubs` (plain xyz // rail/path surfaces) precede it; the actual surface is `nubs` (plain xyz
// control points) or `nurbs` (rational — each control point carries a // control points) or `nurbs` (rational — each control point carries a
// weight, so it is stored as xyzw). // weight, so it is stored as xyzw).
let start = toks let start = toks
.iter() .iter()
.position(|t| matches!(t, SatToken::Ident(s) if s == "nubs" || s == "nurbs"))?; .rposition(|t| matches!(t, SatToken::Ident(s) if s == "nubs" || s == "nurbs"))?;
let rational = matches!(&toks[start], SatToken::Ident(s) if s == "nurbs"); let rational = matches!(&toks[start], SatToken::Ident(s) if s == "nurbs");
let mut p = start + 1; let mut p = start + 1;
@ -144,14 +310,46 @@ fn build_spline_surface(rec: &SatRecord) -> Option<SplineSurf> {
let n_uknot = read_int(toks, &mut p)? as usize; let n_uknot = read_int(toks, &mut p)? as usize;
let n_vknot = read_int(toks, &mut p)? as usize; let n_vknot = read_int(toks, &mut p)? as usize;
let u_knots = read_knot_vec(toks, &mut p, n_uknot, deg_u)?; let raw_u_knots = read_knot_vec(toks, &mut p, n_uknot)?;
let v_knots = read_knot_vec(toks, &mut p, n_vknot, deg_v)?; let raw_v_knots = read_knot_vec(toks, &mut p, n_vknot)?;
let stride = if rational { 4 } else { 3 };
let n_ctrl_u = u_knots.len().checked_sub(deg_u + 1)?; let available = toks[p..]
let n_ctrl_v = v_knots.len().checked_sub(deg_v + 1)?; .iter()
if n_ctrl_u == 0 || n_ctrl_v == 0 { .take_while(|token| token.as_float().is_some())
return None; .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 // 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]`. // of u control points per v step). truck wants `ctrl[i_u][j_v]`.
@ -176,7 +374,18 @@ fn build_spline_surface(rec: &SatRecord) -> Option<SplineSurf> {
ctrl[u].push(flat[v * n_ctrl_u + u]); ctrl[u].push(flat[v * n_ctrl_u + u]);
} }
} }
let bs = BSplineSurface::try_new((uk, vk), ctrl).ok()?; 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))) Some(SplineSurf::Nurbs(NurbsSurface::new(bs)))
} else { } else {
let mut flat: Vec<Point3> = Vec::with_capacity(total); let mut flat: Vec<Point3> = Vec::with_capacity(total);
@ -192,29 +401,35 @@ fn build_spline_surface(rec: &SatRecord) -> Option<SplineSurf> {
ctrl[u].push(flat[v * n_ctrl_u + u]); ctrl[u].push(flat[v * n_ctrl_u + u]);
} }
} }
let bs = BSplineSurface::try_new((uk, vk), ctrl).ok()?; 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)) Some(SplineSurf::Bs(bs))
} }
} }
/// Read `count` `(knot value, multiplicity)` pairs into an expanded knot /// Read `count` `(knot value, multiplicity)` pairs into an expanded raw knot
/// vector. ACIS stores the end knots with multiplicity = degree; a clamped /// vector. Some ACIS families store degree-sized ends and others already carry
/// B-spline needs degree + 1, so the first and last multiplicities are bumped /// the complete knot vector; the control-net size decides that after both axes
/// by one. /// have been read.
fn read_knot_vec( fn read_knot_vec(
toks: &[SatToken], toks: &[SatToken],
p: &mut usize, p: &mut usize,
count: usize, count: usize,
degree: usize,
) -> Option<Vec<f64>> { ) -> Option<Vec<f64>> {
let mut knots: Vec<f64> = Vec::new(); let mut knots: Vec<f64> = Vec::new();
for i in 0..count { for _ in 0..count {
let value = read_float(toks, p)?; let value = read_float(toks, p)?;
let mut mult = read_int(toks, p)? as usize; let mult = read_int(toks, p)? as usize;
if i == 0 || i == count - 1 {
mult += 1;
}
let _ = degree;
for _ in 0..mult { for _ in 0..mult {
knots.push(value); knots.push(value);
} }
@ -225,6 +440,31 @@ fn read_knot_vec(
Some(knots) 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> { fn read_int(toks: &[SatToken], p: &mut usize) -> Option<i64> {
while *p < toks.len() { while *p < toks.len() {
let t = &toks[*p]; let t = &toks[*p];

View file

@ -1711,11 +1711,9 @@ fn solid_wire_fallback(entity: &EntityType) -> Vec<[f64; 3]> {
if wires.is_empty() { if wires.is_empty() {
return vec![]; return vec![];
} }
// Parseable ACIS → the mesh pipeline draws the body (placed); the embedded // Fully supported ACIS → mesh pipeline draws body. Parseable-but-partial
// display-cache wires are body-local and would render unplaced at the // ACIS keeps source display wires so unsupported faces never disappear.
// origin (garbage for AcDs-backed solids). Only unreadable ACIS falls if crate::entities::solid3d::acis_has_complete_surface_support(entity) {
// back to them.
if crate::entities::solid3d::acis_parses(entity) {
return vec![]; return vec![];
} }
@ -1870,4 +1868,3 @@ pub(crate) fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 {
v.normalize() v.normalize()
} }
} }

View file

@ -97,6 +97,10 @@ pub enum CurvedGen {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct MeshLodSet { pub struct MeshLodSet {
pub lods: Vec<MeshModel>, pub lods: Vec<MeshModel>,
/// True only when every source face produced triangles. False keeps
/// downstream solid-edit code from treating a display-only partial shell
/// as a closed, valid solid.
pub complete: bool,
/// Feature-edge line list (LOD-independent): pairs of endpoints, high half /// Feature-edge line list (LOD-independent): pairs of endpoints, high half
/// of the double-single. Populated for ACIS solids (the B-rep face-boundary /// of the double-single. Populated for ACIS solids (the B-rep face-boundary
/// edges) so their wireframe shows real edges rather than the triangulation. /// edges) so their wireframe shows real edges rather than the triangulation.
@ -146,6 +150,7 @@ impl MeshLodSet {
let (world_aabb, z_aabb) = compute_mesh_aabb(&lods); let (world_aabb, z_aabb) = compute_mesh_aabb(&lods);
Self { Self {
lods, lods,
complete: true,
edge_verts: Vec::new(), edge_verts: Vec::new(),
edge_verts_low: Vec::new(), edge_verts_low: Vec::new(),
curved_gens: Vec::new(), curved_gens: Vec::new(),