From 9d823363f1e8ffd7200f6d4d3aaf097f9df81291 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 14 Jun 2026 20:11:44 +0300 Subject: [PATCH] feat(scene): render 3D surface entities (loft/sweep/extrude/etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACAD_SURFACE entities now load and render. Their NURBS faces are tessellated by building a truck BSplineSurface from the ACIS `spline-surface` control net and knot vectors, then sampling its parametric grid — reusing the same NURBS kernel the Model tab already builds on. Analytic faces (plane/cone/sphere/torus) continue through the existing tessellator. - New spline_tess.rs: parse the ACIS `nubs` block (degrees, knots with the clamped end-multiplicity fix, control grid) into a truck BSplineSurface and mesh it. - tessellate_surface + Surface wired through the mesh-build, fallback-wire, and entity-classification paths. - Bump acadrust to the commit that adds the Surface entity. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 4 +- src/app/helpers.rs | 1 + src/entities/names.rs | 1 + src/entities/solid3d.rs | 2 + src/entities/traits.rs | 1 + src/scene/mod.rs | 10 +- src/scene/solid3d_tess.rs | 28 ++++++ src/scene/spline_tess.rs | 190 ++++++++++++++++++++++++++++++++++++++ src/scene/tessellate.rs | 5 +- 9 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 src/scene/spline_tess.rs diff --git a/Cargo.lock b/Cargo.lock index 59ea96e4a..a03a43283 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,7 +56,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.3.4" -source = "git+https://github.com/HakanSeven12/acadrust?branch=main#b4ab9ffedfae44b5731301a94c849a3c8f695105" +source = "git+https://github.com/HakanSeven12/acadrust?branch=main#3e2afad2d0baf03be584c28111040e13870b9e70" dependencies = [ "ahash", "anyhow", @@ -3178,7 +3178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b577e2d69827c4740cba2b52efaad1c4cc7c73042860b199710b3575c68438d" dependencies = [ "bytecount", - "memchr 1.0.2", + "memchr 2.8.1", "nom 8.0.0", ] diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 5f8efd309..b914fe365 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -245,6 +245,7 @@ pub(super) fn entity_type_key(entity: &acadrust::EntityType) -> String { Solid3D(_) => "solid3d", Region(_) => "region", Body(_) => "body", + Surface(_) => "surface", Mesh(_) => "mesh", Ray(_) => "ray", XLine(_) => "xline", diff --git a/src/entities/names.rs b/src/entities/names.rs index 14b262dd1..0445a6b89 100644 --- a/src/entities/names.rs +++ b/src/entities/names.rs @@ -39,6 +39,7 @@ pub fn ui_name(e: &EntityType) -> &'static str { EntityType::Solid3D(_) => "3D Solid", EntityType::Region(_) => "Region", EntityType::Body(_) => "Body", + EntityType::Surface(_) => "Surface", EntityType::Mesh(_) => "Mesh", EntityType::Ray(_) => "Ray", EntityType::XLine(_) => "XLine", diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index 1ca2a305e..c875acab6 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -282,6 +282,7 @@ pub fn fallback_wires(e: &EntityType) -> Option<&[acadrust::entities::Wire]> { EntityType::Solid3D(s) => Some(&s.wires), EntityType::Region(r) => Some(&r.wires), EntityType::Body(b) => Some(&b.wires), + EntityType::Surface(s) => Some(&s.wires), _ => None, } } @@ -293,6 +294,7 @@ pub fn tessellate_volume(e: &EntityType, color: [f32; 4], facet_res: f64) -> Opt EntityType::Solid3D(s) => solid3d_tess::tessellate_solid3d(s, color, facet_res), EntityType::Region(r) => solid3d_tess::tessellate_region(r, color, facet_res), EntityType::Body(b) => solid3d_tess::tessellate_body(b, color, facet_res), + EntityType::Surface(s) => solid3d_tess::tessellate_surface(s, color, facet_res), _ => None, } } diff --git a/src/entities/traits.rs b/src/entities/traits.rs index 224b4c77e..c819cf4c2 100644 --- a/src/entities/traits.rs +++ b/src/entities/traits.rs @@ -129,6 +129,7 @@ pub fn entity_type_name(et: &EntityType) -> &'static str { EntityType::Solid3D(_) => "Solid3D", EntityType::Region(_) => "Region", EntityType::Body(_) => "Body", + EntityType::Surface(_) => "Surface", EntityType::Table(_) => "Table", EntityType::Tolerance(_) => "Tolerance", EntityType::PolyfaceMesh(_) => "PolyfaceMesh", diff --git a/src/scene/mod.rs b/src/scene/mod.rs index c40ee4095..5789be92d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -26,6 +26,7 @@ pub(super) struct EntityIndex { pub(crate) mod render; mod selection; pub mod solid3d_tess; +pub mod spline_tess; pub mod tess_util; pub mod tessellate; pub mod transform; @@ -185,7 +186,7 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches { match e { EntityType::Hatch(_) | EntityType::Solid(_) => hatch_handles.push(h), EntityType::RasterImage(_) => image_handles.push(h), - EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) => { + EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) => { mesh_handles.push(h) } _ => {} @@ -1839,7 +1840,7 @@ impl Scene { // draw-order biasing so 3D occlusion is never flattened. if matches!( e, - EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) + EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) ) { continue; } @@ -3448,7 +3449,7 @@ impl Scene { let facet_res = self.document.header.facet_resolution; let mesh_seed = if matches!( &entity, - EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) + EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) ) { let color = self.render_style(&entity).0; let woff = self.world_offset; @@ -4320,7 +4321,7 @@ impl Scene { .document .entities() .filter_map(|e| match e { - EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) => { + EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) | EntityType::Surface(_) => { let color = self.render_style(e).0; Some((e.common().handle, e.clone(), color)) } @@ -7002,6 +7003,7 @@ fn tessellate_entity( | EntityType::PolygonMesh(_) | EntityType::Body(_) | EntityType::Region(_) + | EntityType::Surface(_) ); if !is_text && w_px.max(h_px) / wpp < 5.0 { // Sub-pixel entity: emit a stub instead of diff --git a/src/scene/solid3d_tess.rs b/src/scene/solid3d_tess.rs index a6bc9420e..64ee3ffbb 100644 --- a/src/scene/solid3d_tess.rs +++ b/src/scene/solid3d_tess.rs @@ -106,6 +106,16 @@ fn tessellate_sat( tess_torus_face(&torus, lod, &mut verts, &mut normals, &mut indices); } } + "spline-surface" => { + crate::scene::spline_tess::tess_spline_face( + sat, + &face, + lod, + &mut verts, + &mut normals, + &mut indices, + ); + } _ => {} } } @@ -255,6 +265,24 @@ pub fn tessellate_body(body: &Body, color: [f32; 4], facet_res: f64) -> Option Option { + let sat = parse_acis( + || surface.parse_sat(), + surface.acis_data.is_binary, + &surface.acis_data.sab_data, + )?; + let name = surface.common.handle.value().to_string(); + tessellate_sat_lods(&sat, name, color, facet_res) +} + /// Tessellate a `Solid3D` entity at all three LOD levels. /// /// Returns `None` when the entity has no parseable SAT data or produces no diff --git a/src/scene/spline_tess.rs b/src/scene/spline_tess.rs new file mode 100644 index 000000000..02681e954 --- /dev/null +++ b/src/scene/spline_tess.rs @@ -0,0 +1,190 @@ +// 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::{SatDocument, SatFace, SatRecord, SatToken}; +use truck_modeling::{BSplineSurface, KnotVec, ParametricSurface, ParametricSurface3D, Point3}; + +use crate::scene::solid3d_tess::LodConfig; + +/// 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<[f32; 3]>, + normals: &mut Vec<[f32; 3]>, + indices: &mut Vec, +) { + let Some(surf_rec) = sat.resolve(face.surface()) else { + return; + }; + let Some(surface) = build_bspline_surface(surf_rec) else { + return; + }; + + // 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. + 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; + } + + let su = lod.grid_u.max(8); + let sv = lod.grid_v.max(8); + + 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 n = surface.normal(u, v); + verts.push([p.x as f32, p.y as f32, p.z as f32]); + 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 { + 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]); + } + } +} + +/// Extract the inclusive `[start, end]` bounds from a truck parameter range. +fn range_bounds(r: (std::ops::Bound, std::ops::Bound)) -> (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_bspline_surface(rec: &SatRecord) -> Option> { + let toks = &rec.tokens; + // Locate the real B-spline block. `nullbs` placeholders (for absent + // rail/path surfaces) precede it; the actual surface is `nubs`. + let start = toks.iter().position(|t| { + matches!(t, SatToken::Ident(s) if s == "nubs" || 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 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; + } + + // 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 mut flat: Vec = 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 uk = KnotVec::from(u_knots); + let vk = KnotVec::from(v_knots); + BSplineSurface::try_new((uk, vk), ctrl).ok() +} + +/// 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. +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 { + 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; + for _ in 0..mult { + knots.push(value); + } + } + if knots.len() < 2 { + return None; + } + Some(knots) +} + +fn read_int(toks: &[SatToken], p: &mut usize) -> Option { + 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 { + 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 +} diff --git a/src/scene/tessellate.rs b/src/scene/tessellate.rs index 1fb839f1c..9163473c9 100644 --- a/src/scene/tessellate.rs +++ b/src/scene/tessellate.rs @@ -617,7 +617,10 @@ fn fallback_geometry(entity: &EntityType, world_offset: [f64; 3]) -> Geometry { // plus an insertion snap — never the placeholder segment below, which // would otherwise draw a stray 1-unit line at the origin next to the // solid. - EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) => { + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Body(_) + | EntityType::Surface(_) => { let pts = solid_wire_fallback(entity, world_offset); let mut snap = vec![]; if let Some(p) = crate::entities::solid3d::point_of_reference(entity) {