diff --git a/src/scene/acis_to_truck.rs b/src/scene/acis_to_truck.rs new file mode 100644 index 000000000..77ea20796 --- /dev/null +++ b/src/scene/acis_to_truck.rs @@ -0,0 +1,431 @@ +// ACIS B-rep → truck B-rep → mesh. +// +// Rebuilds every ACIS face as a truck topological `Face` and lets truck's +// meshing kernel triangulate it, routing loaded 3DSOLID / BODY / REGION / +// SURFACE geometry through the same NURBS/B-rep kernel the Model tab builds on +// instead of the bespoke per-surface sampler in `solid3d_tess`. +// +// plane-surface → planar face from the sampled boundary loop +// cone-surface → surface of revolution (cylinder / cone) via rsweep +// sphere-surface → meridian revolved about the pole +// torus-surface → tube circle revolved about the spine +// spline-surface → truck BSplineSurface, grid-sampled (see spline_tess) +// +// 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`. + +use truck_meshalgo::tessellation::{MeshableShape, MeshedShape}; +use truck_modeling::{builder, Face, InnerSpace, Point3, Rad, Shell, Vector3, Wire}; +use truck_polymesh::PolygonMesh; + +use acadrust::entities::acis::types::Sense; +use acadrust::entities::acis::{ + SatConeSurface, SatDocument, SatFace, SatPlaneSurface, SatSphereSurface, SatTorusSurface, +}; + +use crate::scene::mesh_model::{MeshLodSet, MeshModel}; +use crate::scene::solid3d_tess::{ + apply_body_transform, body_transform, collect_face_polygon, cone_axis_span, mesh_aabb, +}; + +/// Slightly over 2π so revolution builders close the loop. +const FULL: f64 = std::f64::consts::TAU + 0.2; +/// Boundary sampling density for planar faces with curved (circular) edges. +const BOUNDARY_SEGS: usize = 64; +/// Triangle mesh chord tolerance (world units). +const MESH_TOL: f64 = 0.01; + +/// Analytic outward-normal rule for a face, used to orient triangles and +/// supply smooth per-vertex normals (truck's face orientation is unreliable +/// for independently built faces). +enum Outward { + /// Constant normal (planar face). + Const([f64; 3]), + /// Cone/cylinder: radial away from the axis, tilted by the half-angle. + Cone { + center: [f64; 3], + axis: [f64; 3], + sin: f64, + cos: f64, + }, + /// Sphere: radial from the centre. + Sphere { center: [f64; 3] }, + /// Torus: from the nearest point on the spine circle. + Torus { + center: [f64; 3], + axis: [f64; 3], + major: f64, + }, +} + +impl Outward { + fn at(&self, p: [f64; 3]) -> [f64; 3] { + match self { + Outward::Const(n) => *n, + Outward::Cone { + center, + axis, + sin, + cos, + } => { + let d = vsub(p, *center); + let h = vdot(d, *axis); + let radial = vnorm(vsub(d, vscale(*axis, h))); + vnorm(vsub(vscale(radial, *cos), vscale(*axis, *sin))) + } + Outward::Sphere { center } => vnorm(vsub(p, *center)), + Outward::Torus { + center, + axis, + major, + } => { + let d = vsub(p, *center); + let h = vdot(d, *axis); + let radial = vsub(d, vscale(*axis, h)); + let rl = vlen(radial); + let spine = if rl > 1e-9 { + vadd(*center, vscale(radial, major / rl)) + } else { + *center + }; + vnorm(vsub(p, spine)) + } + } + } +} + +/// Tessellate an ACIS SAT document by rebuilding it as truck faces. +/// +/// Returns `None` when no face could be rebuilt (caller should fall back to +/// the bespoke sampler). +pub fn tessellate_sat_truck( + sat: &SatDocument, + name: String, + color: [f32; 4], + _facet_res: f64, +) -> Option { + let mut mesh = MeshModel { + name: name.clone(), + verts: Vec::new(), + normals: Vec::new(), + indices: Vec::new(), + color, + selected: false, + }; + + for face in sat.faces() { + let Some(surf_rec) = sat.resolve(face.surface()) else { + continue; + }; + let Some((faces, outward)) = build_face_group(sat, &face, surf_rec) else { + continue; + }; + if faces.is_empty() { + continue; + } + let shell: Shell = faces.into(); + let poly = shell.triangulation(MESH_TOL).to_polygon(); + append_group(&mut mesh, &poly, &outward); + } + + // 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); + + if mesh.indices.is_empty() { + return None; + } + + if let Some((m, tr, scale)) = body_transform(sat) { + apply_body_transform(&mut mesh, &m, &tr, scale); + } + let world_aabb = mesh_aabb(&mesh); + Some(MeshLodSet { + lods: vec![mesh], + world_aabb, + }) +} + +/// Build the truck face(s) + outward rule for one analytic ACIS face. +fn build_face_group( + sat: &SatDocument, + face: &SatFace, + surf_rec: &acadrust::entities::acis::SatRecord, +) -> Option<(Vec, Outward)> { + match surf_rec.entity_type.as_str() { + "plane-surface" => { + let plane = SatPlaneSurface::from_record(surf_rec)?; + let f = plane_face(sat, face)?; + let (nx, ny, nz) = plane.normal(); + let n = if matches!(face.sense(), Sense::Reversed) { + [-nx, -ny, -nz] + } else { + [nx, ny, nz] + }; + Some((vec![f], Outward::Const(vnorm(n)))) + } + "cone-surface" => { + let cone = SatConeSurface::from_record(surf_rec)?; + let (faces, out) = cone_faces(sat, &cone)?; + Some((faces, out)) + } + "sphere-surface" => { + let sphere = SatSphereSurface::from_record(surf_rec)?; + let (cx, cy, cz) = sphere.center(); + Some((sphere_faces(&sphere), Outward::Sphere { center: [cx, cy, cz] })) + } + "torus-surface" => { + let torus = SatTorusSurface::from_record(surf_rec)?; + let (cx, cy, cz) = torus.center(); + let (nx, ny, nz) = torus.normal(); + let out = Outward::Torus { + center: [cx, cy, cz], + axis: vnorm([nx, ny, nz]), + major: torus.major_radius(), + }; + Some((torus_faces(&torus), out)) + } + _ => None, + } +} + +// ── Planar face ──────────────────────────────────────────────────────────── + +/// Build a planar truck face from a face's sampled boundary loop. Curved +/// boundary edges (circles) are sampled into line segments, which keeps the +/// wire planar so `try_attach_plane` can fit the plane. +fn plane_face(sat: &SatDocument, face: &SatFace) -> Option { + let poly = collect_face_polygon(sat, face, BOUNDARY_SEGS); + if poly.len() < 3 { + return None; + } + let verts: Vec<_> = poly + .iter() + .map(|p| builder::vertex(Point3::new(p[0], p[1], p[2]))) + .collect(); + let n = verts.len(); + let edges: Vec<_> = (0..n) + .map(|i| builder::line(&verts[i], &verts[(i + 1) % n])) + .collect(); + let wire: Wire = edges.into(); + builder::try_attach_plane(&[wire]).ok() +} + +// ── Cone / cylinder face ───────────────────────────────────────────────────── + +/// Build the lateral surface of a cone/cylinder as revolution faces. The +/// height span comes from the solid's coaxial rims (plus a true cone's apex). +fn cone_faces(sat: &SatDocument, cone: &SatConeSurface) -> Option<(Vec, Outward)> { + let (cx, cy, cz) = cone.center(); + let (ax, ay, az) = cone.axis(); + let (ux, uy, uz) = cone.major_axis(); + let radius = cone.radius(); + let sin = cone.sin_half_angle(); + let cos = cone.cos_half_angle(); + + let axis = norm(Vector3::new(ax, ay, az)); + let udir = norm(Vector3::new(ux, uy, uz)); + let center = Point3::new(cx, cy, cz); + + let (hmin, hmax) = cone_axis_span(sat, cone, [axis.x, axis.y, axis.z], [cx, cy, cz])?; + let r_at = |h: f64| { + if cos.abs() > 1e-9 { + radius + h * sin / cos + } else { + radius + } + }; + + let p0 = center + udir * r_at(hmin) + axis * hmin; + let p1 = center + udir * r_at(hmax) + axis * hmax; + let profile = builder::line(&builder::vertex(p0), &builder::vertex(p1)); + let shell: Shell = builder::rsweep(&profile, center, axis, Rad(FULL)); + + let out = Outward::Cone { + center: [cx, cy, cz], + axis: [axis.x, axis.y, axis.z], + sin, + cos, + }; + Some((shell.face_iter().cloned().collect(), out)) +} + +// ── Sphere face ────────────────────────────────────────────────────────────── + +fn sphere_faces(sphere: &SatSphereSurface) -> Vec { + let (cx, cy, cz) = sphere.center(); + let r = sphere.radius(); + let (px, py, pz) = sphere.pole(); + let (ux, uy, uz) = sphere.u_direction(); + + let center = Point3::new(cx, cy, cz); + let pole = norm(Vector3::new(px, py, pz)); + let mut perp = Vector3::new(ux, uy, uz); + if perp.magnitude2() < 1e-12 || perp.dot(pole).abs() > 0.999 { + perp = perpendicular(pole); + } else { + perp = norm(perp - pole * perp.dot(pole)); + } + + let top = builder::vertex(center + pole * r); + let meridian: Wire = builder::rsweep(&top, center, perp, Rad(std::f64::consts::PI)); + let shell: Shell = builder::cone(&meridian, pole, Rad(FULL)); + shell.face_iter().cloned().collect() +} + +// ── Torus face ─────────────────────────────────────────────────────────────── + +fn torus_faces(torus: &SatTorusSurface) -> Vec { + let (cx, cy, cz) = torus.center(); + let (nx, ny, nz) = torus.normal(); + let (ux, uy, uz) = torus.u_direction(); + let major = torus.major_radius(); + let minor = torus.minor_radius(); + + let center = Point3::new(cx, cy, cz); + let axis = norm(Vector3::new(nx, ny, nz)); + let udir = norm(Vector3::new(ux, uy, uz)); + let binormal = norm(axis.cross(udir)); + + let ring_center = center + udir * major; + let tube_start = builder::vertex(ring_center + axis * minor); + let tube: Wire = builder::rsweep(&tube_start, ring_center, binormal, Rad(FULL)); + let shell: Shell = builder::rsweep(&tube, center, axis, Rad(FULL)); + shell.face_iter().cloned().collect() +} + +// ── 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, mesh: &mut MeshModel) { + use crate::scene::solid3d_tess::LodConfig; + for face in sat.faces() { + let Some(surf_rec) = sat.resolve(face.surface()) else { + continue; + }; + 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::spline_tess::tess_spline_face( + sat, + &face, + LodConfig::HIGH, + &mut verts, + &mut normals, + &mut 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)); + } +} + +// ── Mesh append with analytic outward normals ──────────────────────────────── + +/// 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) { + let positions = poly.positions(); + let base = mesh.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]); + let n = outward.at(pos); + mesh.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); + let a = pt(positions[i0]); + let b = pt(positions[i1]); + let c = pt(positions[i2]); + let gn = vcross(vsub(b, a), vsub(c, a)); + let cen = [ + (a[0] + b[0] + c[0]) / 3.0, + (a[1] + b[1] + c[1]) / 3.0, + (a[2] + b[2] + c[2]) / 3.0, + ]; + 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]); + } else { + mesh.indices.extend_from_slice(&[j0, j1, j2]); + } + } +} + +#[inline] +fn pt(p: Point3) -> [f64; 3] { + [p.x, p.y, p.z] +} + +// ── Vector helpers (cgmath Vector3) ────────────────────────────────────────── + +#[inline] +fn norm(v: Vector3) -> Vector3 { + let len = v.magnitude(); + if len < 1e-12 { + Vector3::unit_z() + } else { + v / len + } +} + +/// Any unit vector perpendicular to `v`. +fn perpendicular(v: Vector3) -> Vector3 { + let a = if v.x.abs() < 0.9 { + Vector3::unit_x() + } else { + Vector3::unit_y() + }; + norm(v.cross(a)) +} + +// ── Vector helpers ([f64; 3]) ──────────────────────────────────────────────── + +#[inline] +fn vsub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} +#[inline] +fn vadd(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} +#[inline] +fn vscale(a: [f64; 3], s: f64) -> [f64; 3] { + [a[0] * s, a[1] * s, a[2] * s] +} +#[inline] +fn vdot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} +#[inline] +fn vcross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} +#[inline] +fn vlen(a: [f64; 3]) -> f64 { + vdot(a, a).sqrt() +} +#[inline] +fn vnorm(a: [f64; 3]) -> [f64; 3] { + let l = vlen(a); + if l < 1e-12 { + [0.0, 0.0, 1.0] + } else { + [a[0] / l, a[1] / l, a[2] / l] + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 5789be92d..06bf6d1ad 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -25,6 +25,7 @@ pub(super) struct EntityIndex { } pub(crate) mod render; mod selection; +pub mod acis_to_truck; pub mod solid3d_tess; pub mod spline_tess; pub mod tess_util; diff --git a/src/scene/solid3d_tess.rs b/src/scene/solid3d_tess.rs index 64ee3ffbb..2acac3742 100644 --- a/src/scene/solid3d_tess.rs +++ b/src/scene/solid3d_tess.rs @@ -132,6 +132,31 @@ fn tessellate_sat( }) } +/// Tessellate an ACIS document, preferring the truck B-rep kernel and falling +/// back to the bespoke per-surface sampler when truck can't rebuild the shell +/// (e.g. an unhandled surface type). +fn tessellate_acis( + sat: &SatDocument, + name: String, + color: [f32; 4], + facet_res: f64, +) -> Option { + if let Some(set) = crate::scene::acis_to_truck::tessellate_sat_truck(sat, name.clone(), color, facet_res) { + 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)"); + } + return Some(set); + } + // truck couldn't rebuild the shell — fall back to the bespoke sampler. + let fallback = tessellate_sat_lods(sat, name.clone(), color, facet_res); + eprintln!( + "acis_tess[{name}]: manual fallback ({})", + if fallback.is_some() { "ok" } else { "empty" } + ); + fallback +} + /// Tessellate a SAT document at all three LODs and bundle them into a /// `MeshLodSet` ready for the render pipeline to pick a level per frame. fn tessellate_sat_lods( @@ -164,7 +189,7 @@ fn tessellate_sat_lods( /// geometry in body-local space and records the placement in a `transform` /// record (`<3×3> rotate reflect shear`). `None` when the /// document has no transform (treated as identity). -fn body_transform(sat: &SatDocument) -> Option<([f64; 9], [f64; 3], f64)> { +pub(crate) fn body_transform(sat: &SatDocument) -> Option<([f64; 9], [f64; 3], f64)> { let t = sat.records.iter().find(|r| r.entity_type == "transform")?; let mut m = [0.0f64; 9]; for (i, slot) in m.iter_mut().enumerate() { @@ -178,7 +203,7 @@ fn body_transform(sat: &SatDocument) -> Option<([f64; 9], [f64; 3], f64)> { /// 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. -fn apply_body_transform(mesh: &mut MeshModel, m: &[f64; 9], tr: &[f64; 3], scale: f64) { +pub(crate) fn apply_body_transform(mesh: &mut MeshModel, m: &[f64; 9], tr: &[f64; 3], scale: f64) { for v in &mut mesh.verts { let (x, y, z) = (v[0] as f64, v[1] as f64, v[2] as f64); let wx = scale * (x * m[0] + y * m[3] + z * m[6]) + tr[0]; @@ -212,7 +237,7 @@ fn scale_lod(base: LodConfig, facet_res: f64) -> LodConfig { /// World-XY AABB of the mesh — used by the render-pipeline LOD selector /// to pick a level based on projected pixel diagonal. -fn mesh_aabb(mesh: &MeshModel) -> [f32; 4] { +pub(crate) fn mesh_aabb(mesh: &MeshModel) -> [f32; 4] { let mut min_x = f32::INFINITY; let mut min_y = f32::INFINITY; let mut max_x = f32::NEG_INFINITY; @@ -251,7 +276,7 @@ pub fn tessellate_region(region: &Region, color: [f32; 4], facet_res: f64) -> Op ®ion.acis_data.sab_data, )?; let name = region.common.handle.value().to_string(); - tessellate_sat_lods(&sat, name, color, facet_res) + tessellate_acis(&sat, name, color, facet_res) } /// Tessellate a `Body` entity (3D ACIS body) at all three LOD levels. @@ -262,7 +287,7 @@ pub fn tessellate_body(body: &Body, color: [f32; 4], facet_res: f64) -> Option O &solid.acis_data.sab_data, )?; let name = solid.common.handle.value().to_string(); - tessellate_sat_lods(&sat, name, color, facet_res) + tessellate_acis(&sat, name, color, facet_res) } // ── Topology helpers ────────────────────────────────────────────────────────── @@ -310,7 +335,7 @@ pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4], facet_res: f64) -> O /// /// Returns an empty `Vec` when the loop topology is broken or has fewer than /// three distinct points. -fn collect_face_polygon(sat: &SatDocument, face: &SatFace, circ_segs: usize) -> Vec<[f64; 3]> { +pub(crate) fn collect_face_polygon(sat: &SatDocument, face: &SatFace, circ_segs: usize) -> Vec<[f64; 3]> { let loop_ptr = face.first_loop(); let Some(loop_rec) = sat.resolve(loop_ptr) else { return vec![]; @@ -355,7 +380,7 @@ fn collect_face_polygon(sat: &SatDocument, face: &SatFace, circ_segs: usize) -> /// sampled along their parametric arc (excluding the end param so the next /// coedge's start point provides the junction); all other curve types fall /// back to the single start vertex, respecting coedge sense. -fn append_coedge_points( +pub(crate) fn append_coedge_points( sat: &SatDocument, coedge: &SatCoedge, circ_segs: usize, @@ -442,7 +467,7 @@ fn sample_ellipse_arc( } /// Resolve a vertex pointer all the way to its `[x, y, z]` coordinate. -fn resolve_point(sat: &SatDocument, v_ptr: SatPointer) -> Option<[f64; 3]> { +pub(crate) fn resolve_point(sat: &SatDocument, v_ptr: SatPointer) -> Option<[f64; 3]> { let v_rec = sat.resolve(v_ptr)?; let vertex = SatVertex::from_record(v_rec)?; let pt_rec = sat.resolve(vertex.point())?; @@ -702,7 +727,7 @@ fn angular_range( /// 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. -fn cone_axis_span( +pub(crate) fn cone_axis_span( sat: &SatDocument, cone: &SatConeSurface, axis: [f64; 3], diff --git a/src/shaders/mesh.wgsl b/src/shaders/mesh.wgsl index 809ffc257..9b1faa486 100644 --- a/src/shaders/mesh.wgsl +++ b/src/shaders/mesh.wgsl @@ -49,8 +49,6 @@ fn vs_main(v: VertexIn) -> VertexOut { @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { - // Fixed directional light (world space, tilted toward the viewer). - let light_dir = normalize(vec3(0.4, 0.8, 0.6)); var n: vec3; if (u.flat_shade > 0.5) { // Per-triangle face normal: derivatives of the interpolated @@ -60,8 +58,19 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { } else { n = normalize(in.normal); } - // Half-Lambert: [0.5, 1.0] so the dark side isn't fully black. - let diff = clamp(dot(n, light_dir), 0.0, 1.0) * 0.5 + 0.5; - let rgb = in.color.rgb * diff; + + // Three-point-ish lighting (world space) plus ambient. Spread directions + // keep every face — and the back faces seen through an open surface — lit + // from at least one source, so the model never reads as a flat dark mass. + // `abs(dot)` makes each light two-sided (independent of normal direction). + let l0 = normalize(vec3( 0.5, 0.8, 0.6)); // key (upper front) + let l1 = normalize(vec3(-0.7, 0.3, 0.4)); // fill (left) + let l2 = normalize(vec3( 0.2, -0.6, -0.8)); // back/under + let ambient = 0.35; + let diff = ambient + + 0.45 * abs(dot(n, l0)) + + 0.30 * abs(dot(n, l1)) + + 0.25 * abs(dot(n, l2)); + let rgb = in.color.rgb * clamp(diff, 0.0, 1.0); return vec4(rgb, in.color.a); }