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:
parent
a16914a22a
commit
34d0c377e2
8 changed files with 431 additions and 113 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -75,7 +75,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
|||
[[package]]
|
||||
name = "acadrust"
|
||||
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 = [
|
||||
"ahash 0.8.12",
|
||||
"anyhow",
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_Window
|
|||
|
||||
[patch.crates-io]
|
||||
# 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]
|
||||
# Native enables the plugin host runtime (out-of-process plugins).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// does, the mesh pipeline (fill + feature edges + isolines, all body-placed)
|
||||
/// is the authoritative render and the embedded display-cache wires must NOT
|
||||
/// be drawn: they are body-local (unplaced), and for R2013+ AcDs-backed
|
||||
/// solids the inline wire section misparses into garbage points near the
|
||||
/// origin. The wires remain useful only as a last resort when the ACIS data
|
||||
/// itself is unreadable.
|
||||
pub fn acis_parses(e: &EntityType) -> bool {
|
||||
match e {
|
||||
EntityType::Solid3D(s) => s.acis_data.parse().is_some(),
|
||||
EntityType::Region(r) => r.acis_data.parse().is_some(),
|
||||
EntityType::Body(b) => b.acis_data.parse().is_some(),
|
||||
EntityType::Surface(s) => s.acis_data.parse().is_some(),
|
||||
_ => false,
|
||||
}
|
||||
/// Whether every ACIS face uses a surface family the mesh pipeline can decode.
|
||||
/// Unsupported or unresolved faces must keep their display-cache wires visible;
|
||||
/// otherwise a parseable but incomplete shell looks like a valid solid.
|
||||
pub fn acis_has_complete_surface_support(e: &EntityType) -> bool {
|
||||
let sat = match e {
|
||||
EntityType::Solid3D(s) => s.acis_data.parse(),
|
||||
EntityType::Region(r) => r.acis_data.parse(),
|
||||
EntityType::Body(b) => b.acis_data.parse(),
|
||||
EntityType::Surface(s) => s.acis_data.parse(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(sat) = sat else {
|
||||
return 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,
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@
|
|||
// Each face is meshed independently and its triangles are oriented outward
|
||||
// using an analytic per-surface normal — truck's own face orientation is not
|
||||
// consistent across independently built faces, so normals/winding are derived
|
||||
// from geometry instead. Faces whose surface type isn't handled are skipped;
|
||||
// the caller falls back to `solid3d_tess` when this returns `None`.
|
||||
// from geometry instead. Face coverage is recorded on `MeshLodSet`; partial
|
||||
// shells remain displayable but cannot masquerade as complete solid topology.
|
||||
|
||||
use truck_meshalgo::tessellation::{MeshableShape, MeshedShape};
|
||||
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 normals: Vec<[f32; 3]> = Vec::new();
|
||||
let mut indices: Vec<u32> = Vec::new();
|
||||
let mut complete = true;
|
||||
|
||||
for face in sat.faces().into_iter() {
|
||||
let Some(surf_rec) = sat.resolve(face.surface()) else {
|
||||
complete = false;
|
||||
continue;
|
||||
};
|
||||
let before = indices.len();
|
||||
let mut appended = false;
|
||||
if let Some((faces, outward, tol)) = build_face_group(sat, &face, surf_rec) {
|
||||
if !faces.is_empty() {
|
||||
|
|
@ -114,19 +117,21 @@ pub fn tessellate_sat_truck(
|
|||
// buffers, so the shared finalize below still applies uniformly.
|
||||
if !appended {
|
||||
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() {
|
||||
return None;
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -161,6 +166,16 @@ fn bespoke_face(
|
|||
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)
|
||||
}
|
||||
|
||||
// ── 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 ────────────────────────────────
|
||||
|
||||
/// Append one face's triangulation to `mesh`, computing smooth per-vertex
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@
|
|||
// • sphere-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
|
||||
// returned so the solid renders with at least its planar faces.
|
||||
// Coverage is tracked explicitly. Unsupported or malformed faces retain
|
||||
// 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 std::f64::consts::TAU;
|
||||
|
|
@ -122,16 +123,19 @@ fn tessellate_sat(
|
|||
color: [f32; 4],
|
||||
lod: LodConfig,
|
||||
xform: Option<([f64; 9], [f64; 3], f64)>,
|
||||
) -> Option<MeshModel> {
|
||||
) -> Option<(MeshModel, bool)> {
|
||||
let mut verts: Vec<[f64; 3]> = Vec::new();
|
||||
let mut normals: Vec<[f32; 3]> = Vec::new();
|
||||
let mut indices: Vec<u32> = Vec::new();
|
||||
let mut complete = true;
|
||||
|
||||
for face in sat.faces() {
|
||||
let surf_ptr = face.surface();
|
||||
let Some(surf_rec) = sat.resolve(surf_ptr) else {
|
||||
complete = false;
|
||||
continue;
|
||||
};
|
||||
let before = indices.len();
|
||||
match surf_rec.entity_type.as_str() {
|
||||
"plane-surface" => {
|
||||
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() {
|
||||
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
|
||||
|
|
@ -214,26 +224,51 @@ fn tessellate_acis(
|
|||
facet_res: f64,
|
||||
isolines: usize,
|
||||
) -> 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,
|
||||
name.clone(),
|
||||
color,
|
||||
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() {
|
||||
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 {
|
||||
// truck couldn't rebuild the shell — fall back to the bespoke sampler.
|
||||
let fallback = tessellate_sat_lods(sat, name.clone(), color, facet_res);
|
||||
} else if std::env::var_os("OCS_TESS_DEBUG").is_some() {
|
||||
let tris = set.lods.first().map(|m| m.indices.len() / 3).unwrap_or(0);
|
||||
eprintln!(
|
||||
"acis_tess[{name}]: manual fallback ({})",
|
||||
if fallback.is_some() { "ok" } else { "empty" }
|
||||
"acis_tess[{name}]: partial ({tris} tris); feature/display wires retained"
|
||||
);
|
||||
fallback?
|
||||
};
|
||||
}
|
||||
// Attach the B-rep face-boundary edges plus ISOLINES on curved faces
|
||||
// (body-transformed, split into the double-single pair) so the solid's
|
||||
// wireframe shows real edges and curved faces read from any angle.
|
||||
|
|
@ -878,16 +913,22 @@ fn tessellate_sat_lods(
|
|||
let configs = LodConfig::all();
|
||||
let xform = body_transform(sat);
|
||||
let mut lods: Vec<MeshModel> = Vec::with_capacity(3);
|
||||
let mut complete = true;
|
||||
for lod in configs {
|
||||
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);
|
||||
complete &= lod_complete;
|
||||
} else {
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
if lods.is_empty() {
|
||||
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
|
||||
|
|
@ -1636,13 +1677,16 @@ fn angular_range(
|
|||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// with a single rim, the tip is added analytically (the height where the
|
||||
/// radius reaches zero). Returns `None` when no coaxial rim is found.
|
||||
/// projects their centres onto the axis to get rim heights. Some imported
|
||||
/// solids represent circular rims as spline/intcurve edges, so point records
|
||||
/// 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(
|
||||
sat: &SatDocument,
|
||||
cone: &SatConeSurface,
|
||||
|
|
@ -1669,6 +1713,38 @@ pub(crate) fn cone_axis_span(
|
|||
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() {
|
||||
return None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@
|
|||
// `BSplineSurface` (the same NURBS kernel the Model tab already builds on),
|
||||
// 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::{
|
||||
BSplineSurface, KnotVec, NurbsSurface, ParametricSurface, ParametricSurface3D, Point3,
|
||||
|
|
@ -59,22 +63,22 @@ pub fn tess_spline_face(
|
|||
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;
|
||||
return false;
|
||||
};
|
||||
let Some(surface) = build_spline_surface(surf_rec) else {
|
||||
return;
|
||||
let Some(surface) = build_spline_surface(sat, surf_rec) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Sample over the knot domain. The B-spline patches stored by loft/sweep
|
||||
// are already trimmed to the face, so the full parametric rectangle is the
|
||||
// visible surface — no separate boundary trim needed.
|
||||
// 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;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 {
|
||||
|
|
@ -89,7 +96,10 @@ pub fn tess_spline_face(
|
|||
for i in 0..=su {
|
||||
let u = u0 + (u1 - u0) * (i as f64 / su as f64);
|
||||
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]);
|
||||
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;
|
||||
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.
|
||||
|
|
@ -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`
|
||||
/// record's token stream into a truck `BSplineSurface`.
|
||||
fn build_spline_surface(rec: &SatRecord) -> Option<SplineSurf> {
|
||||
let toks = &rec.tokens;
|
||||
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()
|
||||
.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 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_vknot = read_int(toks, &mut p)? as usize;
|
||||
|
||||
let u_knots = read_knot_vec(toks, &mut p, n_uknot, deg_u)?;
|
||||
let v_knots = read_knot_vec(toks, &mut p, n_vknot, deg_v)?;
|
||||
|
||||
let n_ctrl_u = u_knots.len().checked_sub(deg_u + 1)?;
|
||||
let n_ctrl_v = v_knots.len().checked_sub(deg_v + 1)?;
|
||||
if n_ctrl_u == 0 || n_ctrl_v == 0 {
|
||||
return None;
|
||||
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]`.
|
||||
|
|
@ -176,7 +374,18 @@ fn build_spline_surface(rec: &SatRecord) -> Option<SplineSurf> {
|
|||
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)))
|
||||
} else {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `count` `(knot value, multiplicity)` pairs into an expanded knot
|
||||
/// vector. ACIS stores the end knots with multiplicity = degree; a clamped
|
||||
/// B-spline needs degree + 1, so the first and last multiplicities are bumped
|
||||
/// by one.
|
||||
/// 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,
|
||||
degree: usize,
|
||||
) -> Option<Vec<f64>> {
|
||||
let mut knots: Vec<f64> = Vec::new();
|
||||
for i in 0..count {
|
||||
for _ in 0..count {
|
||||
let value = read_float(toks, p)?;
|
||||
let mut mult = read_int(toks, p)? as usize;
|
||||
if i == 0 || i == count - 1 {
|
||||
mult += 1;
|
||||
}
|
||||
let _ = degree;
|
||||
let mult = read_int(toks, p)? as usize;
|
||||
for _ in 0..mult {
|
||||
knots.push(value);
|
||||
}
|
||||
|
|
@ -225,6 +440,31 @@ fn read_knot_vec(
|
|||
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];
|
||||
|
|
|
|||
|
|
@ -1711,11 +1711,9 @@ fn solid_wire_fallback(entity: &EntityType) -> Vec<[f64; 3]> {
|
|||
if wires.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// Parseable ACIS → the mesh pipeline draws the body (placed); the embedded
|
||||
// display-cache wires are body-local and would render unplaced at the
|
||||
// origin (garbage for AcDs-backed solids). Only unreadable ACIS falls
|
||||
// back to them.
|
||||
if crate::entities::solid3d::acis_parses(entity) {
|
||||
// Fully supported ACIS → mesh pipeline draws body. Parseable-but-partial
|
||||
// ACIS keeps source display wires so unsupported faces never disappear.
|
||||
if crate::entities::solid3d::acis_has_complete_surface_support(entity) {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
|
|
@ -1870,4 +1868,3 @@ pub(crate) fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 {
|
|||
v.normalize()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -97,6 +97,10 @@ pub enum CurvedGen {
|
|||
#[derive(Clone, Debug)]
|
||||
pub struct MeshLodSet {
|
||||
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
|
||||
/// of the double-single. Populated for ACIS solids (the B-rep face-boundary
|
||||
/// 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);
|
||||
Self {
|
||||
lods,
|
||||
complete: true,
|
||||
edge_verts: Vec::new(),
|
||||
edge_verts_low: Vec::new(),
|
||||
curved_gens: Vec::new(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue