diff --git a/Cargo.lock b/Cargo.lock index 5ab93f854..e1266847e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 121fbbc79..736afa12d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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). diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index ef4b8eee3..ef5d9cde8 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -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, diff --git a/src/scene/convert/acis_to_truck.rs b/src/scene/convert/acis_to_truck.rs index 303210b59..89dbbe35b 100644 --- a/src/scene/convert/acis_to_truck.rs +++ b/src/scene/convert/acis_to_truck.rs @@ -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 = 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, -) { - 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 diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 64b9c0f25..bb7a99b2a 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -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 { +) -> Option<(MeshModel, bool)> { let mut verts: Vec<[f64; 3]> = Vec::new(); let mut normals: Vec<[f32; 3]> = Vec::new(); let mut indices: Vec = 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 { - 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 = 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; } diff --git a/src/scene/convert/spline_tess.rs b/src/scene/convert/spline_tess.rs index 938e99d09..4d461206a 100644 --- a/src/scene/convert/spline_tess.rs +++ b/src/scene/convert/spline_tess.rs @@ -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, -) { +) -> 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; - 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>> { + 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::() + * 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, std::ops::Bound)) -> (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 { - let toks = &rec.tokens; +fn build_spline_surface(sat: &SatDocument, rec: &SatRecord) -> Option { + 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 { 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 { 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 = Vec::with_capacity(total); @@ -192,29 +401,35 @@ fn build_spline_surface(rec: &SatRecord) -> Option { 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> { let mut knots: Vec = 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, clamp: bool) -> Option> { + 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 { + 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 { while *p < toks.len() { let t = &toks[*p]; diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 59a72fc12..da19cb431 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -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() } } - diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index 73b95a0e8..da5a4ac99 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -97,6 +97,10 @@ pub enum CurvedGen { #[derive(Clone, Debug)] pub struct MeshLodSet { pub lods: Vec, + /// 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(),