From 340a358c264d77a54b9c4fa6d8921b29086b7e23 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 18 Jul 2026 12:30:00 +0300 Subject: [PATCH] fix(solid3d): keep loaded-solid mesh verts f64 until the RTE split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loaded 3DSOLID faces rendered on a ~0.06 m grid and crawled against their own wireframe at UTM scale. Both ACIS tessellation paths cast each vertex f64→f32 in the samplers, and only split the double-single low half (verts_low) when the body carried a `transform` record. But many files — budweiser among them — store solid geometry in absolute coordinates with no body transform, so verts_low stayed empty: the GPU got position_low = 0 and reconstructed an already f32-quantized absolute position, while the feature edges (split unconditionally from f64) stayed exact. Both paths now accumulate vertices in f64 and hand them to one finalize step: finalize_mesh(name, verts_f64, normals, indices, color, xform) the only place a solid vertex becomes f32 — it computes the world coordinate in f64 (body placement applied, or identity), then splits into (verts, verts_low) unconditionally, exactly as attach_feature_edges already does for the wireframe. push_quad / tess_plane_face / tess_cone_face / tess_sphere_face / tess_torus_face / tess_spline_face and the truck append_group / bespoke_face / append_spline_faces all carry f64; apply_body_transform is gone. Verified on budweiser (all solids, body_transform = NONE): verts_low is now populated with the captured residual, max |low| ≈ 0.0624 m. The Model-tab (truck_tess::polygon_to_result) and edge paths were already correct and are unchanged. Co-Authored-By: Claude Opus 4.8 --- src/scene/convert/acis_to_truck.rs | 80 +++++++++---------- src/scene/convert/solid3d_tess.rs | 122 +++++++++++++++++------------ src/scene/convert/spline_tess.rs | 4 +- 3 files changed, 115 insertions(+), 91 deletions(-) diff --git a/src/scene/convert/acis_to_truck.rs b/src/scene/convert/acis_to_truck.rs index 9dc3a5e6c..303210b59 100644 --- a/src/scene/convert/acis_to_truck.rs +++ b/src/scene/convert/acis_to_truck.rs @@ -29,11 +29,11 @@ use acadrust::entities::acis::{ }; use crate::scene::convert::solid3d_tess::{ - apply_body_transform, body_transform, collect_face_loops, cone_axis_span, tess_cone_face, + body_transform, collect_face_loops, cone_axis_span, finalize_mesh, tess_cone_face, tess_plane_face, tess_sphere_face, tess_torus_face, LodConfig, BOUNDARY_CHORD_FRAC, TRUCK_CHORD_FRAC, }; -use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; +use crate::scene::model::mesh_model::MeshLodSet; /// Slightly over 2π so revolution builders close the loop. const FULL: f64 = std::f64::consts::TAU + 0.2; @@ -85,15 +85,12 @@ pub fn tessellate_sat_truck( color: [f32; 4], _facet_res: f64, ) -> Option { - let mut mesh = MeshModel { - name: name.clone(), - verts: Vec::new(), - verts_low: Vec::new(), - normals: Vec::new(), - indices: Vec::new(), - color, - selected: false, - }; + // Vertices accumulate in f64 world/local space; `finalize_mesh` splits them + // into the double-single (high, low) pair once, so a solid at UTM scale + // keeps full precision instead of quantizing to the f32 grid. + let mut verts: Vec<[f64; 3]> = Vec::new(); + let mut normals: Vec<[f32; 3]> = Vec::new(); + let mut indices: Vec = Vec::new(); for face in sat.faces().into_iter() { let Some(surf_rec) = sat.resolve(face.surface()) else { @@ -105,7 +102,7 @@ pub fn tessellate_sat_truck( let shell: Shell = faces.into(); let poly = shell.triangulation(tol).to_polygon(); if !poly.tri_faces().is_empty() { - append_group(&mut mesh, &poly, &outward); + append_group(&mut verts, &mut normals, &mut indices, &poly, &outward); appended = true; } } @@ -113,25 +110,22 @@ pub fn tessellate_sat_truck( // Truck's rsweep/triangulation degenerates to zero triangles on some // short-arc cones (a curved wall face whose profile passes through the // local origin), leaving a see-through gap. Fall back to the bespoke - // parametric sampler for just that face — it writes body-local verts - // into the same buffers, so the shared body transform below still - // applies uniformly. + // parametric sampler for just that face — it writes into the same + // buffers, so the shared finalize below still applies uniformly. if !appended { - bespoke_face(sat, &face, surf_rec, &mut mesh); + bespoke_face(sat, &face, surf_rec, &mut verts, &mut normals, &mut indices); } } // 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 mesh); + append_spline_faces(sat, &mut verts, &mut normals, &mut indices); - if mesh.indices.is_empty() { + if indices.is_empty() { return None; } - if let Some((m, tr, scale)) = body_transform(sat) { - apply_body_transform(&mut mesh, &m, &tr, scale); - } + let mesh = finalize_mesh(name, verts, normals, indices, color, body_transform(sat)); Some(MeshLodSet::from_lods(vec![mesh])) } @@ -142,9 +136,10 @@ fn bespoke_face( sat: &SatDocument, face: &SatFace, surf_rec: &acadrust::entities::acis::SatRecord, - mesh: &mut MeshModel, + v: &mut Vec<[f64; 3]>, + n: &mut Vec<[f32; 3]>, + i: &mut Vec, ) { - let (v, n, i) = (&mut mesh.verts, &mut mesh.normals, &mut mesh.indices); match surf_rec.entity_type.as_str() { "plane-surface" => { if let Some(p) = SatPlaneSurface::from_record(surf_rec) { @@ -413,8 +408,12 @@ fn cone_boundary_arc( /// Append meshes for every `spline-surface` face, reusing the truck /// BSplineSurface grid sampler in `spline_tess`. -fn append_spline_faces(sat: &SatDocument, mesh: &mut MeshModel) { - use crate::scene::convert::solid3d_tess::LodConfig; +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; @@ -422,21 +421,14 @@ fn append_spline_faces(sat: &SatDocument, mesh: &mut MeshModel) { if surf_rec.entity_type != "spline-surface" { continue; } - let mut verts: Vec<[f32; 3]> = Vec::new(); - let mut normals: Vec<[f32; 3]> = Vec::new(); - let mut indices: Vec = Vec::new(); crate::scene::convert::spline_tess::tess_spline_face( sat, &face, LodConfig::HIGH, - &mut verts, - &mut normals, - &mut indices, + verts, + normals, + indices, ); - let base = mesh.verts.len() as u32; - mesh.verts.extend_from_slice(&verts); - mesh.normals.extend_from_slice(&normals); - mesh.indices.extend(indices.iter().map(|i| i + base)); } } @@ -445,14 +437,20 @@ fn append_spline_faces(sat: &SatDocument, mesh: &mut MeshModel) { /// Append one face's triangulation to `mesh`, computing smooth per-vertex /// normals from the outward rule and flipping any triangle whose winding /// disagrees with that outward direction. -fn append_group(mesh: &mut MeshModel, poly: &PolygonMesh, outward: &Outward) { +fn append_group( + verts: &mut Vec<[f64; 3]>, + normals: &mut Vec<[f32; 3]>, + out_indices: &mut Vec, + poly: &PolygonMesh, + outward: &Outward, +) { let positions = poly.positions(); - let base = mesh.verts.len() as u32; + let base = verts.len() as u32; for p in positions { let pos = [p.x, p.y, p.z]; - mesh.verts.push([p.x as f32, p.y as f32, p.z as f32]); + verts.push(pos); let n = outward.at(pos); - mesh.normals.push([n[0] as f32, n[1] as f32, n[2] as f32]); + normals.push([n[0] as f32, n[1] as f32, n[2] as f32]); } for tri in poly.tri_faces() { let (i0, i1, i2) = (tri[0].pos, tri[1].pos, tri[2].pos); @@ -468,9 +466,9 @@ fn append_group(mesh: &mut MeshModel, poly: &PolygonMesh, outward: &Outward) { let out = outward.at(cen); let (j0, j1, j2) = (base + i0 as u32, base + i1 as u32, base + i2 as u32); if vdot(gn, out) < 0.0 { - mesh.indices.extend_from_slice(&[j0, j2, j1]); + out_indices.extend_from_slice(&[j0, j2, j1]); } else { - mesh.indices.extend_from_slice(&[j0, j1, j2]); + out_indices.extend_from_slice(&[j0, j1, j2]); } } } diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 2919ae175..cc4ac4241 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -113,13 +113,16 @@ pub(crate) fn nominal_segs(frac: f64) -> usize { // ── Public entry point ──────────────────────────────────────────────────────── /// Tessellate a SAT document into mesh buffers — shared by all ACIS entities. +/// Vertices accumulate in f64 and `finalize_mesh` splits them into the +/// double-single pair with the body placement (`xform`) applied. fn tessellate_sat( sat: &SatDocument, name: String, color: [f32; 4], lod: LodConfig, + xform: Option<([f64; 9], [f64; 3], f64)>, ) -> Option { - let mut verts: Vec<[f32; 3]> = Vec::new(); + let mut verts: Vec<[f64; 3]> = Vec::new(); let mut normals: Vec<[f32; 3]> = Vec::new(); let mut indices: Vec = Vec::new(); @@ -197,15 +200,7 @@ fn tessellate_sat( if indices.is_empty() { return None; } - Some(MeshModel { - name, - verts, - verts_low: Vec::new(), - normals, - indices, - color, - selected: false, - }) + Some(finalize_mesh(name, verts, normals, indices, color, xform)) } /// Tessellate an ACIS document, preferring the truck B-rep kernel and falling @@ -879,10 +874,7 @@ fn tessellate_sat_lods( let mut lods: Vec = Vec::with_capacity(3); for lod in configs { let scaled = scale_lod(lod, facet_res); - if let Some(mut m) = tessellate_sat(sat, name.clone(), color, scaled) { - if let Some((mat, tr, scale)) = xform { - apply_body_transform(&mut m, &mat, &tr, scale); - } + if let Some(m) = tessellate_sat(sat, name.clone(), color, scaled, xform) { lods.push(m); } } @@ -921,38 +913,69 @@ pub(crate) fn body_transform(sat: &SatDocument) -> Option<([f64; 9], [f64; 3], f /// Apply a body placement transform to a mesh. ACIS treats points as row /// vectors (`p' = scale·(p·M) + T`), so the 3×3 is indexed transposed relative /// to a column-vector multiply. Normals get the rotation only, renormalized. -pub(crate) fn apply_body_transform(mesh: &mut MeshModel, m: &[f64; 9], tr: &[f64; 3], scale: f64) { - // The body translation `tr` is where a solid gets its world placement, so at - // UTM scale this is exactly where the coordinate stops fitting in an f32. - // Compute each world vertex in f64 and split it into the double-single - // (high, low) pair the mesh shader reconstructs relative to the eye — the - // same treatment the feature edges already get. Without the low half the - // shaded faces sit on a ~0.06 m grid while their own edges stay exact, so the - // surface visibly crawls against its wireframe as the camera moves. - mesh.verts_low = Vec::with_capacity(mesh.verts.len()); - for i in 0..mesh.verts.len() { - let [vx, vy, vz] = mesh.verts[i]; - let (x, y, z) = (vx as f64, vy as f64, vz as f64); - let wx = scale * (x * m[0] + y * m[3] + z * m[6]) + tr[0]; - let wy = scale * (x * m[1] + y * m[4] + z * m[7]) + tr[1]; - let wz = scale * (x * m[2] + y * m[5] + z * m[8]) + tr[2]; +/// Build a `MeshModel` from f64 accumulation buffers. This is the ONLY place a +/// solid mesh vertex becomes f32: the world coordinate is computed in f64 (the +/// body placement applied, or identity when the solid stores absolute geometry) +/// and split into the double-single (high, low) pair the mesh shader +/// reconstructs relative to the eye — exactly the treatment the feature edges +/// get in `attach_feature_edges`. Casting to f32 any earlier quantizes a solid +/// placed at UTM scale to a ~0.06 m grid, so its shaded faces crawl against +/// their own (double-single) wireframe as the camera moves. The split runs +/// unconditionally: many solids store their geometry in absolute coordinates +/// with no body transform, and those need it just as much as placed ones. +pub(crate) fn finalize_mesh( + name: String, + verts: Vec<[f64; 3]>, + normals: Vec<[f32; 3]>, + indices: Vec, + color: [f32; 4], + xform: Option<([f64; 9], [f64; 3], f64)>, +) -> MeshModel { + let mut hi: Vec<[f32; 3]> = Vec::with_capacity(verts.len()); + let mut lo: Vec<[f32; 3]> = Vec::with_capacity(verts.len()); + for [x, y, z] in verts { + let (wx, wy, wz) = match &xform { + Some((m, tr, scale)) => ( + scale * (x * m[0] + y * m[3] + z * m[6]) + tr[0], + scale * (x * m[1] + y * m[4] + z * m[7]) + tr[1], + scale * (x * m[2] + y * m[5] + z * m[8]) + tr[2], + ), + None => (x, y, z), + }; let (hx, hy, hz) = (wx as f32, wy as f32, wz as f32); - mesh.verts[i] = [hx, hy, hz]; - mesh.verts_low.push([ + hi.push([hx, hy, hz]); + lo.push([ (wx - hx as f64) as f32, (wy - hy as f64) as f32, (wz - hz as f64) as f32, ]); } - for n in &mut mesh.normals { - let (x, y, z) = (n[0] as f64, n[1] as f64, n[2] as f64); - let nx = x * m[0] + y * m[3] + z * m[6]; - let ny = x * m[1] + y * m[4] + z * m[7]; - let nz = x * m[2] + y * m[5] + z * m[8]; - let len = (nx * nx + ny * ny + nz * nz).sqrt(); - if len > 1e-9 { - *n = [(nx / len) as f32, (ny / len) as f32, (nz / len) as f32]; - } + let normals = match &xform { + Some((m, _, _)) => normals + .iter() + .map(|n| { + let (x, y, z) = (n[0] as f64, n[1] as f64, n[2] as f64); + let nx = x * m[0] + y * m[3] + z * m[6]; + let ny = x * m[1] + y * m[4] + z * m[7]; + let nz = x * m[2] + y * m[5] + z * m[8]; + let len = (nx * nx + ny * ny + nz * nz).sqrt(); + if len > 1e-9 { + [(nx / len) as f32, (ny / len) as f32, (nz / len) as f32] + } else { + *n + } + }) + .collect(), + None => normals, + }; + MeshModel { + name, + verts: hi, + verts_low: lo, + normals, + indices, + color, + selected: false, } } @@ -1314,10 +1337,13 @@ pub(crate) fn resolve_point(sat: &SatDocument, v_ptr: SatPointer) -> Option<[f64 // ── Mesh builder helpers ────────────────────────────────────────────────────── -/// Append one quad (two triangles) to the mesh buffers. +/// Append one quad (two triangles) to the mesh buffers. Vertices stay in f64 +/// world/local space until `finalize_mesh` splits them into the double-single +/// (high, low) pair — casting here would quantize a solid placed at UTM scale to +/// the ~0.06 m f32 grid. #[inline] fn push_quad( - verts: &mut Vec<[f32; 3]>, + verts: &mut Vec<[f64; 3]>, normals: &mut Vec<[f32; 3]>, indices: &mut Vec, p: [[f64; 3]; 4], @@ -1326,7 +1352,7 @@ fn push_quad( let base = verts.len() as u32; let nf = [n[0] as f32, n[1] as f32, n[2] as f32]; for &pt in &p { - verts.push([pt[0] as f32, pt[1] as f32, pt[2] as f32]); + verts.push(pt); normals.push(nf); } // Two CCW triangles: (0,1,2) and (0,2,3) @@ -1340,7 +1366,7 @@ pub(crate) fn tess_plane_face( face: &SatFace, plane: &SatPlaneSurface, chord_frac: f64, - verts: &mut Vec<[f32; 3]>, + verts: &mut Vec<[f64; 3]>, normals: &mut Vec<[f32; 3]>, indices: &mut Vec, ) { @@ -1364,7 +1390,7 @@ pub(crate) fn tess_plane_face( let base = verts.len() as u32; for &pt in &poly { - verts.push([pt[0] as f32, pt[1] as f32, pt[2] as f32]); + verts.push(pt); normals.push(nf); } @@ -1383,7 +1409,7 @@ pub(crate) fn tess_cone_face( face: &SatFace, cone: &SatConeSurface, lod: LodConfig, - verts: &mut Vec<[f32; 3]>, + verts: &mut Vec<[f64; 3]>, normals: &mut Vec<[f32; 3]>, indices: &mut Vec, ) { @@ -1658,7 +1684,7 @@ pub(crate) fn tess_sphere_face( face: &SatFace, sphere: &SatSphereSurface, lod: LodConfig, - verts: &mut Vec<[f32; 3]>, + verts: &mut Vec<[f64; 3]>, normals: &mut Vec<[f32; 3]>, indices: &mut Vec, ) { @@ -1746,7 +1772,7 @@ pub(crate) fn tess_torus_face( face: &SatFace, torus: &SatTorusSurface, lod: LodConfig, - verts: &mut Vec<[f32; 3]>, + verts: &mut Vec<[f64; 3]>, normals: &mut Vec<[f32; 3]>, indices: &mut Vec, ) { diff --git a/src/scene/convert/spline_tess.rs b/src/scene/convert/spline_tess.rs index f39379bd3..938e99d09 100644 --- a/src/scene/convert/spline_tess.rs +++ b/src/scene/convert/spline_tess.rs @@ -56,7 +56,7 @@ pub fn tess_spline_face( sat: &SatDocument, face: &SatFace, lod: LodConfig, - verts: &mut Vec<[f32; 3]>, + verts: &mut Vec<[f64; 3]>, normals: &mut Vec<[f32; 3]>, indices: &mut Vec, ) { @@ -90,7 +90,7 @@ pub fn tess_spline_face( 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]); + verts.push([p.x, p.y, p.z]); normals.push([n.x as f32, n.y as f32, n.z as f32]); } }