forked from 1b-agent/opencadstudio-upstream
refactor(scene): group modules into role-based subfolders
Move the 30 flat modules under src/scene/ into six folders by role: convert (entity→geometry), text (fonts/shaping), model (render models), pick (hit-test/selection/grips/xclip), view (camera/transform/render), cache (block + property caches). pipeline/ is unchanged. Full path migration — all `scene::X` references become `scene::<group>::X`, no compatibility re-exports. render.rs items that were `pub(super)` (parent was scene) become `pub(in crate::scene)` to keep their original scene-wide visibility. No behavior change; build and tests are green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
cd048165a9
commit
7fe7e0230d
160 files changed
+732
-715
No files matched your search
@@ -0,0 +1,56 @@
|
||||
// acadrust -> truck topology conversion layer.
|
||||
|
||||
use acadrust::{CadDocument, EntityType};
|
||||
use glam::Vec3;
|
||||
use truck_modeling::{Edge, Solid, Vertex, Wire};
|
||||
|
||||
use crate::entities::traits::EntityTypeOps;
|
||||
use crate::scene::model::wire_model::{SnapHint, TangentGeom};
|
||||
|
||||
/// One group of glyph strokes with its world-space origin stored in f64.
|
||||
/// Strokes are in glyph-local space (origin = [0,0]) so that the large
|
||||
/// world offset can be subtracted with f64 precision in tessellate.rs.
|
||||
///
|
||||
/// `color`, when set, overrides the entity colour for just this group — used
|
||||
/// by MTEXT inline `\C` / `\c` per-run colour. Strokes sharing the same
|
||||
/// (color, None) override are merged into one WireModel downstream; runs with
|
||||
/// distinct colours emit their own WireModel.
|
||||
pub struct TextStroke {
|
||||
pub strokes: Vec<Vec<[f32; 2]>>,
|
||||
pub origin: [f64; 2],
|
||||
pub color: Option<[f32; 3]>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub enum TruckObject {
|
||||
Point(Vertex),
|
||||
Curve(Edge),
|
||||
Contour(Wire),
|
||||
Text(Vec<TextStroke>),
|
||||
/// Pre-computed NaN-separated 3-D point list (leader lines, arrowheads, etc.).
|
||||
/// Points are stored in WCS as **f64** so the large world_offset can be
|
||||
/// subtracted in full precision in tessellate.rs before the f32 cast.
|
||||
/// Casting WCS coordinates to f32 in the entity converters used to wreck
|
||||
/// rotated sub-glyph precision on drawings far from origin.
|
||||
Lines(Vec<[f64; 3]>),
|
||||
/// Like Lines but linetype pattern restarts at each NaN-separated segment (plinegen=false).
|
||||
SegmentedLines(Vec<[f64; 3]>),
|
||||
Volume(Solid),
|
||||
}
|
||||
|
||||
pub struct TruckEntity {
|
||||
pub object: TruckObject,
|
||||
pub snap_pts: Vec<(Vec3, SnapHint)>,
|
||||
pub tangent_geoms: Vec<TangentGeom>,
|
||||
/// Polyline vertex positions in WCS f64; converted to offset-relative f32
|
||||
/// at the wire-model boundary.
|
||||
pub key_vertices: Vec<[f64; 3]>,
|
||||
/// Pre-triangulated fill geometry: flat list of WCS f64 vertices, 3 per
|
||||
/// triangle. Non-empty for mesh-like entities (PolyfaceMesh, PolygonMesh)
|
||||
/// that need solid fill.
|
||||
pub fill_tris: Vec<[f64; 3]>,
|
||||
}
|
||||
|
||||
pub fn convert(entity: &EntityType, document: &CadDocument) -> Option<TruckEntity> {
|
||||
entity.to_truck_entity(document)
|
||||
}
|
||||
@@ -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::model::mesh_model::{MeshLodSet, MeshModel};
|
||||
use crate::scene::convert::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<MeshLodSet> {
|
||||
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<Face>, 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<Face> {
|
||||
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<Face>, 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<Face> {
|
||||
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<Face> {
|
||||
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::convert::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<u32> = Vec::new();
|
||||
crate::scene::convert::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]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod acad_to_truck;
|
||||
pub mod acis_to_truck;
|
||||
pub mod truck_tess;
|
||||
pub mod tessellate;
|
||||
pub mod tess_util;
|
||||
pub mod solid3d_tess;
|
||||
pub mod spline_tess;
|
||||
@@ -0,0 +1,982 @@
|
||||
// ACIS SAT → MeshModel tessellation for Solid3D (3DSOLID) entities.
|
||||
//
|
||||
// Strategy:
|
||||
// • plane-surface faces → collect coedge-loop polygon, fan-triangulate.
|
||||
// • cone-surface faces → sample a parametric grid (handles both cylinders
|
||||
// and true cones).
|
||||
// • 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.
|
||||
|
||||
use rustc_hash::FxHashSet as HashSet;
|
||||
use std::f64::consts::TAU;
|
||||
|
||||
use acadrust::entities::acis::types::Sense;
|
||||
use acadrust::entities::acis::{
|
||||
SabReader, SatCoedge, SatConeSurface, SatDocument, SatEdge, SatEllipseCurve, SatFace, SatLoop,
|
||||
SatPlaneSurface, SatPoint, SatPointer, SatSphereSurface, SatTorusSurface, SatVertex,
|
||||
};
|
||||
use acadrust::entities::{Body, Region, Solid3D};
|
||||
|
||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||
|
||||
/// Per-LOD sampling density. Higher values = finer mesh = more triangles.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct LodConfig {
|
||||
/// Arc segments per full circle for curved-surface sampling.
|
||||
pub circ_segs: usize,
|
||||
/// Longitudinal grid count for sphere / torus surfaces.
|
||||
pub grid_u: usize,
|
||||
/// Latitudinal grid count for sphere / torus surfaces.
|
||||
pub grid_v: usize,
|
||||
}
|
||||
|
||||
impl LodConfig {
|
||||
/// LOD 0 — full resolution. The pre-Phase-3.4 baseline.
|
||||
pub const HIGH: LodConfig = LodConfig {
|
||||
circ_segs: 48,
|
||||
grid_u: 32,
|
||||
grid_v: 16,
|
||||
};
|
||||
/// LOD 1 — half-resolution. Use between ~50–200 px projected diagonal.
|
||||
pub const MID: LodConfig = LodConfig {
|
||||
circ_segs: 24,
|
||||
grid_u: 16,
|
||||
grid_v: 8,
|
||||
};
|
||||
/// LOD 2 — quarter-resolution. Use below ~50 px.
|
||||
pub const LOW: LodConfig = LodConfig {
|
||||
circ_segs: 12,
|
||||
grid_u: 8,
|
||||
grid_v: 4,
|
||||
};
|
||||
/// Returns the three LOD configs in `[high, mid, low]` order — matches
|
||||
/// the `MeshLodSet::lods` slot ordering.
|
||||
pub const fn all() -> [LodConfig; 3] {
|
||||
[Self::HIGH, Self::MID, Self::LOW]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public entry point ────────────────────────────────────────────────────────
|
||||
|
||||
/// Tessellate a SAT document into mesh buffers — shared by all ACIS entities.
|
||||
fn tessellate_sat(
|
||||
sat: &SatDocument,
|
||||
name: String,
|
||||
color: [f32; 4],
|
||||
lod: LodConfig,
|
||||
) -> Option<MeshModel> {
|
||||
let mut verts: Vec<[f32; 3]> = Vec::new();
|
||||
let mut normals: Vec<[f32; 3]> = Vec::new();
|
||||
let mut indices: Vec<u32> = Vec::new();
|
||||
|
||||
for face in sat.faces() {
|
||||
let surf_ptr = face.surface();
|
||||
let Some(surf_rec) = sat.resolve(surf_ptr) else {
|
||||
continue;
|
||||
};
|
||||
match surf_rec.entity_type.as_str() {
|
||||
"plane-surface" => {
|
||||
if let Some(plane) = SatPlaneSurface::from_record(surf_rec) {
|
||||
tess_plane_face(
|
||||
sat,
|
||||
&face,
|
||||
&plane,
|
||||
lod.circ_segs,
|
||||
&mut verts,
|
||||
&mut normals,
|
||||
&mut indices,
|
||||
);
|
||||
}
|
||||
}
|
||||
"cone-surface" => {
|
||||
if let Some(cone) = SatConeSurface::from_record(surf_rec) {
|
||||
tess_cone_face(sat, &face, &cone, lod, &mut verts, &mut normals, &mut indices);
|
||||
}
|
||||
}
|
||||
"sphere-surface" => {
|
||||
if let Some(sphere) = SatSphereSurface::from_record(surf_rec) {
|
||||
tess_sphere_face(&sphere, lod, &mut verts, &mut normals, &mut indices);
|
||||
}
|
||||
}
|
||||
"torus-surface" => {
|
||||
if let Some(torus) = SatTorusSurface::from_record(surf_rec) {
|
||||
tess_torus_face(&torus, lod, &mut verts, &mut normals, &mut indices);
|
||||
}
|
||||
}
|
||||
"spline-surface" => {
|
||||
crate::scene::convert::spline_tess::tess_spline_face(
|
||||
sat,
|
||||
&face,
|
||||
lod,
|
||||
&mut verts,
|
||||
&mut normals,
|
||||
&mut indices,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(MeshModel {
|
||||
name,
|
||||
verts,
|
||||
normals,
|
||||
indices,
|
||||
color,
|
||||
selected: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<MeshLodSet> {
|
||||
if let Some(set) = crate::scene::convert::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(
|
||||
sat: &SatDocument,
|
||||
name: String,
|
||||
color: [f32; 4],
|
||||
facet_res: f64,
|
||||
) -> Option<MeshLodSet> {
|
||||
let configs = LodConfig::all();
|
||||
let xform = body_transform(sat);
|
||||
let mut lods: Vec<MeshModel> = 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);
|
||||
}
|
||||
lods.push(m);
|
||||
}
|
||||
}
|
||||
if lods.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let world_aabb = mesh_aabb(&lods[0]);
|
||||
Some(MeshLodSet { lods, world_aabb })
|
||||
}
|
||||
|
||||
/// Extract the body's placement transform from the SAT document: a row-major
|
||||
/// 3×3 affine, a translation, and the uniform scale. ACIS keeps a solid's
|
||||
/// geometry in body-local space and records the placement in a `transform`
|
||||
/// record (`<3×3> <tx ty tz> <scale> rotate reflect shear`). `None` when the
|
||||
/// document has no transform (treated as identity).
|
||||
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() {
|
||||
*slot = t.token_float(i)?;
|
||||
}
|
||||
let tr = [t.token_float(9)?, t.token_float(10)?, t.token_float(11)?];
|
||||
let scale = t.token_float(12).unwrap_or(1.0);
|
||||
Some((m, tr, scale))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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];
|
||||
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];
|
||||
*v = [wx as f32, wy as f32, wz 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale a LOD's segment counts by FACETRES (clamped to AutoCAD's
|
||||
/// documented [0.01, 10.0] range). 1.0 is the unchanged baseline.
|
||||
fn scale_lod(base: LodConfig, facet_res: f64) -> LodConfig {
|
||||
let m = (facet_res.clamp(0.01, 10.0) as f32).max(0.01);
|
||||
let scale = |v: usize| ((v as f32) * m).round().max(4.0) as usize;
|
||||
LodConfig {
|
||||
circ_segs: scale(base.circ_segs),
|
||||
grid_u: scale(base.grid_u),
|
||||
grid_v: scale(base.grid_v),
|
||||
}
|
||||
}
|
||||
|
||||
/// World-XY AABB of the mesh — used by the render-pipeline LOD selector
|
||||
/// to pick a level based on projected pixel diagonal.
|
||||
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;
|
||||
let mut max_y = f32::NEG_INFINITY;
|
||||
for &[x, y, _] in &mesh.verts {
|
||||
if !x.is_finite() || !y.is_finite() {
|
||||
continue;
|
||||
}
|
||||
if x < min_x { min_x = x; }
|
||||
if y < min_y { min_y = y; }
|
||||
if x > max_x { max_x = x; }
|
||||
if y > max_y { max_y = y; }
|
||||
}
|
||||
[min_x, min_y, max_x, max_y]
|
||||
}
|
||||
|
||||
fn parse_acis(
|
||||
sat_fn: impl FnOnce() -> Option<SatDocument>,
|
||||
is_binary: bool,
|
||||
sab_data: &[u8],
|
||||
) -> Option<SatDocument> {
|
||||
if let Some(doc) = sat_fn() {
|
||||
return Some(doc);
|
||||
}
|
||||
if is_binary && !sab_data.is_empty() {
|
||||
return SabReader::read(sab_data).ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Tessellate a `Region` entity (2D planar ACIS body) at all three LOD levels.
|
||||
pub fn tessellate_region(region: &Region, color: [f32; 4], facet_res: f64) -> Option<MeshLodSet> {
|
||||
let sat = parse_acis(
|
||||
|| region.parse_sat(),
|
||||
region.acis_data.is_binary,
|
||||
®ion.acis_data.sab_data,
|
||||
)?;
|
||||
let name = region.common.handle.value().to_string();
|
||||
tessellate_acis(&sat, name, color, facet_res)
|
||||
}
|
||||
|
||||
/// Tessellate a `Body` entity (3D ACIS body) at all three LOD levels.
|
||||
pub fn tessellate_body(body: &Body, color: [f32; 4], facet_res: f64) -> Option<MeshLodSet> {
|
||||
let sat = parse_acis(
|
||||
|| body.parse_sat(),
|
||||
body.acis_data.is_binary,
|
||||
&body.acis_data.sab_data,
|
||||
)?;
|
||||
let name = body.common.handle.value().to_string();
|
||||
tessellate_acis(&sat, name, color, facet_res)
|
||||
}
|
||||
|
||||
/// Tessellate a `Surface` entity (ACAD_SURFACE family) at all three LOD
|
||||
/// levels. Surfaces are ACIS-backed just like bodies, so the same SAT/SAB
|
||||
/// path applies — including the B-spline `spline-surface` faces that loft /
|
||||
/// sweep / revolve produce.
|
||||
pub fn tessellate_surface(
|
||||
surface: &acadrust::entities::Surface,
|
||||
color: [f32; 4],
|
||||
facet_res: f64,
|
||||
) -> Option<MeshLodSet> {
|
||||
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_acis(&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
|
||||
/// triangles (e.g. the solid uses only unsupported surface types).
|
||||
/// `facet_res` mirrors the header FACETRES variable (0.01–10.0).
|
||||
pub fn tessellate_solid3d(solid: &Solid3D, color: [f32; 4], facet_res: f64) -> Option<MeshLodSet> {
|
||||
let sat = parse_acis(
|
||||
|| solid.parse_sat(),
|
||||
solid.acis_data.is_binary,
|
||||
&solid.acis_data.sab_data,
|
||||
)?;
|
||||
let name = solid.common.handle.value().to_string();
|
||||
tessellate_acis(&sat, name, color, facet_res)
|
||||
}
|
||||
|
||||
// ── Topology helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk a face's outer coedge loop and collect ordered 3-D boundary points.
|
||||
///
|
||||
/// Straight edges contribute their start vertex; curved (ellipse / circle)
|
||||
/// edges are sampled into several points so circular boundaries — e.g. the
|
||||
/// cap of a cylinder or the rim of a cone — produce a real polygon instead of
|
||||
/// a single degenerate vertex. `circ_segs` is the sample count for a full
|
||||
/// circle (scaled down for shorter arcs).
|
||||
///
|
||||
/// Returns an empty `Vec` when the loop topology is broken or has fewer than
|
||||
/// three distinct points.
|
||||
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![];
|
||||
};
|
||||
let Some(sat_loop) = SatLoop::from_record(loop_rec) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let first_ptr = sat_loop.first_coedge();
|
||||
let mut cur = first_ptr;
|
||||
let mut pts: Vec<[f64; 3]> = Vec::new();
|
||||
let mut visited: HashSet<i32> = HashSet::default();
|
||||
|
||||
loop {
|
||||
if cur.is_null() {
|
||||
break;
|
||||
}
|
||||
if visited.contains(&cur.0) {
|
||||
break;
|
||||
}
|
||||
visited.insert(cur.0);
|
||||
|
||||
if let Some(ce_rec) = sat.resolve(cur) {
|
||||
if let Some(coedge) = SatCoedge::from_record(ce_rec) {
|
||||
append_coedge_points(sat, &coedge, circ_segs, &mut pts);
|
||||
|
||||
let next = coedge.next();
|
||||
if next == first_ptr {
|
||||
break;
|
||||
}
|
||||
cur = next;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
pts
|
||||
}
|
||||
|
||||
/// Append a coedge's boundary points to `pts`. Ellipse/circle curves are
|
||||
/// 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.
|
||||
pub(crate) fn append_coedge_points(
|
||||
sat: &SatDocument,
|
||||
coedge: &SatCoedge,
|
||||
circ_segs: usize,
|
||||
pts: &mut Vec<[f64; 3]>,
|
||||
) {
|
||||
let fwd = matches!(coedge.sense(), Sense::Forward);
|
||||
let Some(edge_rec) = sat.resolve(coedge.edge()) else {
|
||||
return;
|
||||
};
|
||||
let Some(edge) = SatEdge::from_record(edge_rec) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(curve_rec) = sat.resolve(edge.curve()) {
|
||||
if let Some(ellipse) = SatEllipseCurve::from_record(curve_rec) {
|
||||
let mut sampled = sample_ellipse_arc(
|
||||
&ellipse,
|
||||
edge.start_param(),
|
||||
edge.end_param(),
|
||||
circ_segs,
|
||||
);
|
||||
if !sampled.is_empty() {
|
||||
if !fwd {
|
||||
sampled.reverse();
|
||||
}
|
||||
pts.extend(sampled);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Straight / unsupported curve: keep the single start vertex.
|
||||
let v_ptr = if fwd {
|
||||
edge.start_vertex()
|
||||
} else {
|
||||
edge.end_vertex()
|
||||
};
|
||||
if let Some(pt) = resolve_point(sat, v_ptr) {
|
||||
pts.push(pt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample points along an ellipse/circle arc from `sp` to `ep` (radians).
|
||||
/// Returns points at the start of each segment (the end param is omitted so
|
||||
/// adjacent coedges don't double up the shared junction point). Segment count
|
||||
/// scales with the arc's angular span relative to a full circle.
|
||||
fn sample_ellipse_arc(
|
||||
ellipse: &SatEllipseCurve,
|
||||
sp: f64,
|
||||
ep: f64,
|
||||
circ_segs: usize,
|
||||
) -> Vec<[f64; 3]> {
|
||||
let span = ep - sp;
|
||||
if span.abs() < 1e-9 {
|
||||
return vec![];
|
||||
}
|
||||
let center = ellipse.center();
|
||||
let major = ellipse.major_axis();
|
||||
let major_len = (major.0 * major.0 + major.1 * major.1 + major.2 * major.2).sqrt();
|
||||
if major_len < 1e-12 {
|
||||
return vec![];
|
||||
}
|
||||
let major_u = [
|
||||
major.0 / major_len,
|
||||
major.1 / major_len,
|
||||
major.2 / major_len,
|
||||
];
|
||||
let normal = norm3([ellipse.normal().0, ellipse.normal().1, ellipse.normal().2]);
|
||||
let minor_u = cross3(normal, major_u);
|
||||
let minor_len = major_len * ellipse.ratio();
|
||||
|
||||
let segs = (circ_segs as f64 * (span.abs() / TAU)).round().max(2.0) as usize;
|
||||
let mut out = Vec::with_capacity(segs);
|
||||
for i in 0..segs {
|
||||
let t = sp + span * (i as f64 / segs as f64);
|
||||
let (c, s) = (t.cos(), t.sin());
|
||||
out.push([
|
||||
center.0 + major_u[0] * major_len * c + minor_u[0] * minor_len * s,
|
||||
center.1 + major_u[1] * major_len * c + minor_u[1] * minor_len * s,
|
||||
center.2 + major_u[2] * major_len * c + minor_u[2] * minor_len * s,
|
||||
]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve a vertex pointer all the way to its `[x, y, z]` coordinate.
|
||||
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())?;
|
||||
let point = SatPoint::from_record(pt_rec)?;
|
||||
let (x, y, z) = point.position();
|
||||
Some([x, y, z])
|
||||
}
|
||||
|
||||
// ── Mesh builder helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// Append one quad (two triangles) to the mesh buffers.
|
||||
#[inline]
|
||||
fn push_quad(
|
||||
verts: &mut Vec<[f32; 3]>,
|
||||
normals: &mut Vec<[f32; 3]>,
|
||||
indices: &mut Vec<u32>,
|
||||
p: [[f64; 3]; 4],
|
||||
n: [f64; 3],
|
||||
) {
|
||||
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]);
|
||||
normals.push(nf);
|
||||
}
|
||||
// Two CCW triangles: (0,1,2) and (0,2,3)
|
||||
indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
|
||||
}
|
||||
|
||||
// ── Planar face ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn tess_plane_face(
|
||||
sat: &SatDocument,
|
||||
face: &SatFace,
|
||||
plane: &SatPlaneSurface,
|
||||
circ_segs: usize,
|
||||
verts: &mut Vec<[f32; 3]>,
|
||||
normals: &mut Vec<[f32; 3]>,
|
||||
indices: &mut Vec<u32>,
|
||||
) {
|
||||
let mut poly = collect_face_polygon(sat, face, circ_segs);
|
||||
if poly.len() < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let (nx, ny, nz) = plane.normal();
|
||||
// Flip normal outward if the face sense is reversed.
|
||||
let (nx, ny, nz) = if matches!(face.sense(), Sense::Reversed) {
|
||||
(-nx, -ny, -nz)
|
||||
} else {
|
||||
(nx, ny, nz)
|
||||
};
|
||||
let nf = [nx as f32, ny as f32, nz as f32];
|
||||
|
||||
// Wind the polygon so its CCW area normal agrees with the outward face
|
||||
// normal. Curved boundaries are sampled in their curve's own orientation,
|
||||
// which may run either way relative to the face; reversing here keeps the
|
||||
// fan's winding-derived normal (flat shading) matching `nf` (Gouraud).
|
||||
if dot3(newell_normal(&poly), [nx, ny, nz]) < 0.0 {
|
||||
poly.reverse();
|
||||
}
|
||||
|
||||
let base = verts.len() as u32;
|
||||
for &pt in &poly {
|
||||
verts.push([pt[0] as f32, pt[1] as f32, pt[2] as f32]);
|
||||
normals.push(nf);
|
||||
}
|
||||
|
||||
// Fan triangulation from vertex 0.
|
||||
let n = poly.len() as u32;
|
||||
for i in 1..(n - 1) {
|
||||
indices.extend_from_slice(&[base, base + i, base + i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cone / cylinder face ──────────────────────────────────────────────────────
|
||||
|
||||
fn tess_cone_face(
|
||||
sat: &SatDocument,
|
||||
face: &SatFace,
|
||||
cone: &SatConeSurface,
|
||||
lod: LodConfig,
|
||||
verts: &mut Vec<[f32; 3]>,
|
||||
normals: &mut Vec<[f32; 3]>,
|
||||
indices: &mut Vec<u32>,
|
||||
) {
|
||||
// Determine the height range and angular span from the boundary polygon.
|
||||
let poly = collect_face_polygon(sat, face, lod.circ_segs);
|
||||
|
||||
let (cx, cy, cz) = cone.center();
|
||||
let (ax, ay, az) = cone.axis(); // axis direction (unit)
|
||||
let (ux, uy, uz) = cone.major_axis(); // u=0 direction
|
||||
let radius = cone.radius();
|
||||
let sin_a = cone.sin_half_angle();
|
||||
let cos_a = cone.cos_half_angle(); // ≈1 for cylinder, <1 for cone
|
||||
|
||||
// Build an orthonormal frame: axis_dir, u_dir, v_dir.
|
||||
let axis = norm3([ax, ay, az]);
|
||||
let u_dir = norm3([ux, uy, uz]);
|
||||
let v_dir = cross3(axis, u_dir);
|
||||
|
||||
// Determine height and angle range from boundary vertices.
|
||||
let (mut h_min, mut h_max, mut theta_min, mut theta_max, mut full_circle) =
|
||||
angular_range(cx, cy, cz, axis, u_dir, v_dir, &poly);
|
||||
|
||||
// A full cylinder/cone face is bounded by a single closed rim, so the
|
||||
// boundary alone can't span the height — the second extent (top rim or
|
||||
// apex) lives on a different face. When the boundary collapses to one
|
||||
// height, recover the span from the solid's coaxial circle rims plus the
|
||||
// analytic apex of a true cone, and sweep the full revolution.
|
||||
if (h_max - h_min).abs() < 1e-9 {
|
||||
if let Some((vmin, vmax)) = cone_axis_span(sat, cone, axis, [cx, cy, cz]) {
|
||||
h_min = vmin;
|
||||
h_max = vmax;
|
||||
theta_min = 0.0;
|
||||
theta_max = TAU;
|
||||
full_circle = true;
|
||||
}
|
||||
}
|
||||
|
||||
let segs_u = lod.circ_segs;
|
||||
let segs_v = (segs_u / 4).max(1); // height subdivisions
|
||||
|
||||
let theta_span = if full_circle {
|
||||
TAU
|
||||
} else {
|
||||
theta_max - theta_min
|
||||
};
|
||||
let h_span = h_max - h_min;
|
||||
|
||||
if h_span.abs() < 1e-10 || theta_span.abs() < 1e-10 {
|
||||
return;
|
||||
}
|
||||
|
||||
for j in 0..segs_v {
|
||||
let t0 = h_min + h_span * (j as f64 / segs_v as f64);
|
||||
let t1 = h_min + h_span * ((j + 1) as f64 / segs_v as f64);
|
||||
|
||||
for i in 0..segs_u {
|
||||
let a0 = theta_min + theta_span * (i as f64 / segs_u as f64);
|
||||
let a1 = theta_min + theta_span * ((i + 1) as f64 / segs_u as f64);
|
||||
|
||||
// Cone radius at height t: r(t) = radius + t * sin_a / cos_a
|
||||
let r0 = if cos_a.abs() > 1e-9 {
|
||||
radius + t0 * sin_a / cos_a
|
||||
} else {
|
||||
radius
|
||||
};
|
||||
let r1 = if cos_a.abs() > 1e-9 {
|
||||
radius + t1 * sin_a / cos_a
|
||||
} else {
|
||||
radius
|
||||
};
|
||||
|
||||
// Wind the quad so its face (CCW) normal points radially outward,
|
||||
// matching the supplied per-vertex normal `n` below. This keeps
|
||||
// flat-shaded mode (which derives the normal from winding) and
|
||||
// Gouraud mode (which uses `n`) consistent.
|
||||
let p = [
|
||||
cone_pt(cx, cy, cz, axis, u_dir, v_dir, r0, a0, t0),
|
||||
cone_pt(cx, cy, cz, axis, u_dir, v_dir, r0, a1, t0),
|
||||
cone_pt(cx, cy, cz, axis, u_dir, v_dir, r1, a1, t1),
|
||||
cone_pt(cx, cy, cz, axis, u_dir, v_dir, r1, a0, t1),
|
||||
];
|
||||
|
||||
// Outward normal: perpendicular to axis in the radial direction,
|
||||
// tilted by the cone half-angle.
|
||||
let mid_a = (a0 + a1) * 0.5;
|
||||
let rad_dir = [
|
||||
u_dir[0] * mid_a.cos() + v_dir[0] * mid_a.sin(),
|
||||
u_dir[1] * mid_a.cos() + v_dir[1] * mid_a.sin(),
|
||||
u_dir[2] * mid_a.cos() + v_dir[2] * mid_a.sin(),
|
||||
];
|
||||
let n = norm3([
|
||||
rad_dir[0] * cos_a - axis[0] * sin_a,
|
||||
rad_dir[1] * cos_a - axis[1] * sin_a,
|
||||
rad_dir[2] * cos_a - axis[2] * sin_a,
|
||||
]);
|
||||
|
||||
push_quad(verts, normals, indices, p, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a point on a cone/cylinder surface.
|
||||
#[inline]
|
||||
fn cone_pt(
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
cz: f64,
|
||||
axis: [f64; 3],
|
||||
u_dir: [f64; 3],
|
||||
v_dir: [f64; 3],
|
||||
r: f64,
|
||||
theta: f64,
|
||||
h: f64,
|
||||
) -> [f64; 3] {
|
||||
[
|
||||
cx + r * (u_dir[0] * theta.cos() + v_dir[0] * theta.sin()) + h * axis[0],
|
||||
cy + r * (u_dir[1] * theta.cos() + v_dir[1] * theta.sin()) + h * axis[1],
|
||||
cz + r * (u_dir[2] * theta.cos() + v_dir[2] * theta.sin()) + h * axis[2],
|
||||
]
|
||||
}
|
||||
|
||||
/// Determine the height range and angular range of a curved face's boundary.
|
||||
///
|
||||
/// Returns `(h_min, h_max, theta_min, theta_max, full_circle)`.
|
||||
/// `full_circle` is true when there are no boundary vertices (e.g. a sphere or
|
||||
/// a cylinder with no seam edge).
|
||||
fn angular_range(
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
cz: f64,
|
||||
axis: [f64; 3],
|
||||
u_dir: [f64; 3],
|
||||
v_dir: [f64; 3],
|
||||
poly: &[[f64; 3]],
|
||||
) -> (f64, f64, f64, f64, bool) {
|
||||
if poly.is_empty() {
|
||||
return (0.0, 0.0, 0.0, TAU, true);
|
||||
}
|
||||
|
||||
let mut h_min = f64::MAX;
|
||||
let mut h_max = f64::MIN;
|
||||
let mut angles: Vec<f64> = Vec::new();
|
||||
|
||||
for &pt in poly {
|
||||
let dx = pt[0] - cx;
|
||||
let dy = pt[1] - cy;
|
||||
let dz = pt[2] - cz;
|
||||
let h = dot3([dx, dy, dz], axis);
|
||||
h_min = h_min.min(h);
|
||||
h_max = h_max.max(h);
|
||||
let rv = dot3([dx, dy, dz], v_dir);
|
||||
// Project onto the plane perpendicular to the axis.
|
||||
let ru = dx * u_dir[0] + dy * u_dir[1] + dz * u_dir[2]
|
||||
- h * (axis[0] * u_dir[0] + axis[1] * u_dir[1] + axis[2] * u_dir[2]);
|
||||
angles.push(rv.atan2(ru));
|
||||
}
|
||||
|
||||
// Normalise angles to a contiguous range.
|
||||
angles.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let theta_min = *angles.first().unwrap();
|
||||
let theta_max = *angles.last().unwrap();
|
||||
|
||||
// If the angular span is almost 2π, treat as full circle.
|
||||
let full = (theta_max - theta_min) > TAU * 0.95;
|
||||
|
||||
(h_min, h_max, theta_min, theta_max, full)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// 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.
|
||||
pub(crate) fn cone_axis_span(
|
||||
sat: &SatDocument,
|
||||
cone: &SatConeSurface,
|
||||
axis: [f64; 3],
|
||||
center: [f64; 3],
|
||||
) -> Option<(f64, f64)> {
|
||||
let mut heights: Vec<f64> = Vec::new();
|
||||
for rec in &sat.records {
|
||||
if rec.entity_type != "ellipse-curve" {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = SatEllipseCurve::from_record(rec) else {
|
||||
continue;
|
||||
};
|
||||
let ec = e.center();
|
||||
let d = [ec.0 - center[0], ec.1 - center[1], ec.2 - center[2]];
|
||||
let h = dot3(d, axis);
|
||||
// Radial offset from the axis line: must be ~0 to be coaxial.
|
||||
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 n = e.normal();
|
||||
let n_dot = dot3(norm3([n.0, n.1, n.2]), axis).abs();
|
||||
if radial_len < 1e-6 && n_dot > 0.999 {
|
||||
heights.push(h);
|
||||
}
|
||||
}
|
||||
if heights.is_empty() {
|
||||
return None;
|
||||
}
|
||||
heights.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
heights.dedup_by(|a, b| (*a - *b).abs() < 1e-6);
|
||||
|
||||
let mut h_min = heights[0];
|
||||
let mut h_max = *heights.last().unwrap();
|
||||
|
||||
// True cone with a single rim: close the surface at its apex (r = 0).
|
||||
let sin_a = cone.sin_half_angle();
|
||||
let cos_a = cone.cos_half_angle();
|
||||
if sin_a.abs() > 1e-6 && heights.len() <= 1 {
|
||||
let apex = -cone.radius() * cos_a / sin_a;
|
||||
h_min = h_min.min(apex);
|
||||
h_max = h_max.max(apex);
|
||||
}
|
||||
|
||||
if (h_max - h_min).abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
Some((h_min, h_max))
|
||||
}
|
||||
|
||||
// ── Sphere face ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn tess_sphere_face(
|
||||
sphere: &SatSphereSurface,
|
||||
lod: LodConfig,
|
||||
verts: &mut Vec<[f32; 3]>,
|
||||
normals: &mut Vec<[f32; 3]>,
|
||||
indices: &mut Vec<u32>,
|
||||
) {
|
||||
let (cx, cy, cz) = sphere.center();
|
||||
let r = sphere.radius();
|
||||
let (px, py, pz) = sphere.pole(); // north-pole direction
|
||||
let pole = norm3([px, py, pz]);
|
||||
let (ux, uy, uz) = sphere.u_direction();
|
||||
let u_dir = norm3([ux, uy, uz]);
|
||||
let v_dir = cross3(pole, u_dir);
|
||||
|
||||
let nu = lod.grid_u.max(3);
|
||||
let nv = lod.grid_v.max(2);
|
||||
|
||||
for j in 0..nv {
|
||||
let phi0 = std::f64::consts::PI * (j as f64 / nv as f64); // 0..π
|
||||
let phi1 = std::f64::consts::PI * ((j + 1) as f64 / nv as f64);
|
||||
|
||||
for i in 0..nu {
|
||||
let theta0 = TAU * (i as f64 / nu as f64);
|
||||
let theta1 = TAU * ((i + 1) as f64 / nu as f64);
|
||||
|
||||
let n00 = sphere_dir(pole, u_dir, v_dir, theta0, phi0);
|
||||
let n10 = sphere_dir(pole, u_dir, v_dir, theta0, phi1);
|
||||
let n11 = sphere_dir(pole, u_dir, v_dir, theta1, phi1);
|
||||
let n01 = sphere_dir(pole, u_dir, v_dir, theta1, phi0);
|
||||
|
||||
let p = [
|
||||
[cx + r * n00[0], cy + r * n00[1], cz + r * n00[2]],
|
||||
[cx + r * n10[0], cy + r * n10[1], cz + r * n10[2]],
|
||||
[cx + r * n11[0], cy + r * n11[1], cz + r * n11[2]],
|
||||
[cx + r * n01[0], cy + r * n01[1], cz + r * n01[2]],
|
||||
];
|
||||
|
||||
// Average outward normal for the quad.
|
||||
let nav = norm3([
|
||||
n00[0] + n10[0] + n11[0] + n01[0],
|
||||
n00[1] + n10[1] + n11[1] + n01[1],
|
||||
n00[2] + n10[2] + n11[2] + n01[2],
|
||||
]);
|
||||
|
||||
push_quad(verts, normals, indices, p, nav);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn sphere_dir(pole: [f64; 3], u_dir: [f64; 3], v_dir: [f64; 3], theta: f64, phi: f64) -> [f64; 3] {
|
||||
let sin_phi = phi.sin();
|
||||
let cos_phi = phi.cos();
|
||||
let cos_theta = theta.cos();
|
||||
let sin_theta = theta.sin();
|
||||
// pole × cos_phi + (u*cos_theta + v*sin_theta) × sin_phi
|
||||
[
|
||||
pole[0] * cos_phi + (u_dir[0] * cos_theta + v_dir[0] * sin_theta) * sin_phi,
|
||||
pole[1] * cos_phi + (u_dir[1] * cos_theta + v_dir[1] * sin_theta) * sin_phi,
|
||||
pole[2] * cos_phi + (u_dir[2] * cos_theta + v_dir[2] * sin_theta) * sin_phi,
|
||||
]
|
||||
}
|
||||
|
||||
// ── Torus face ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn tess_torus_face(
|
||||
torus: &SatTorusSurface,
|
||||
lod: LodConfig,
|
||||
verts: &mut Vec<[f32; 3]>,
|
||||
normals: &mut Vec<[f32; 3]>,
|
||||
indices: &mut Vec<u32>,
|
||||
) {
|
||||
let (cx, cy, cz) = torus.center();
|
||||
let (nx, ny, nz) = torus.normal();
|
||||
let axis = norm3([nx, ny, nz]); // revolution axis
|
||||
let (ux, uy, uz) = torus.u_direction();
|
||||
let u_dir = norm3([ux, uy, uz]);
|
||||
let v_dir = cross3(axis, u_dir);
|
||||
let major_r = torus.major_radius();
|
||||
let minor_r = torus.minor_radius();
|
||||
|
||||
let nu = lod.grid_u.max(3); // around the tube
|
||||
let nv = lod.grid_v.max(3); // around the torus
|
||||
|
||||
for j in 0..nv {
|
||||
let phi0 = TAU * (j as f64 / nv as f64);
|
||||
let phi1 = TAU * ((j + 1) as f64 / nv as f64);
|
||||
|
||||
for i in 0..nu {
|
||||
let theta0 = TAU * (i as f64 / nu as f64);
|
||||
let theta1 = TAU * ((i + 1) as f64 / nu as f64);
|
||||
|
||||
let p = [
|
||||
torus_pt(
|
||||
cx, cy, cz, axis, u_dir, v_dir, major_r, minor_r, theta0, phi0,
|
||||
),
|
||||
torus_pt(
|
||||
cx, cy, cz, axis, u_dir, v_dir, major_r, minor_r, theta0, phi1,
|
||||
),
|
||||
torus_pt(
|
||||
cx, cy, cz, axis, u_dir, v_dir, major_r, minor_r, theta1, phi1,
|
||||
),
|
||||
torus_pt(
|
||||
cx, cy, cz, axis, u_dir, v_dir, major_r, minor_r, theta1, phi0,
|
||||
),
|
||||
];
|
||||
|
||||
// Outward tube normal.
|
||||
let mid_phi = (phi0 + phi1) * 0.5;
|
||||
let mid_theta = (theta0 + theta1) * 0.5;
|
||||
// Direction from tube center to surface point.
|
||||
let radial = [
|
||||
u_dir[0] * mid_phi.cos() + v_dir[0] * mid_phi.sin(),
|
||||
u_dir[1] * mid_phi.cos() + v_dir[1] * mid_phi.sin(),
|
||||
u_dir[2] * mid_phi.cos() + v_dir[2] * mid_phi.sin(),
|
||||
];
|
||||
let n = norm3([
|
||||
radial[0] * mid_theta.cos() + axis[0] * mid_theta.sin(),
|
||||
radial[1] * mid_theta.cos() + axis[1] * mid_theta.sin(),
|
||||
radial[2] * mid_theta.cos() + axis[2] * mid_theta.sin(),
|
||||
]);
|
||||
|
||||
push_quad(verts, normals, indices, p, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn torus_pt(
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
cz: f64,
|
||||
axis: [f64; 3],
|
||||
u_dir: [f64; 3],
|
||||
v_dir: [f64; 3],
|
||||
major_r: f64,
|
||||
minor_r: f64,
|
||||
theta: f64, // tube angle
|
||||
phi: f64, // revolution angle
|
||||
) -> [f64; 3] {
|
||||
// Ring center at angle phi.
|
||||
let ring = [
|
||||
cx + major_r * (u_dir[0] * phi.cos() + v_dir[0] * phi.sin()),
|
||||
cy + major_r * (u_dir[1] * phi.cos() + v_dir[1] * phi.sin()),
|
||||
cz + major_r * (u_dir[2] * phi.cos() + v_dir[2] * phi.sin()),
|
||||
];
|
||||
// Radial direction from torus axis to ring center.
|
||||
let radial = norm3([ring[0] - cx, ring[1] - cy, ring[2] - cz]);
|
||||
// Point on tube.
|
||||
[
|
||||
ring[0] + minor_r * (radial[0] * theta.cos() + axis[0] * theta.sin()),
|
||||
ring[1] + minor_r * (radial[1] * theta.cos() + axis[1] * theta.sin()),
|
||||
ring[2] + minor_r * (radial[2] * theta.cos() + axis[2] * theta.sin()),
|
||||
]
|
||||
}
|
||||
|
||||
// ── Math helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[inline]
|
||||
fn dot3(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
|
||||
/// Area-weighted polygon normal via Newell's method. Robust for non-planar or
|
||||
/// slightly noisy loops; its sign encodes the winding direction.
|
||||
fn newell_normal(poly: &[[f64; 3]]) -> [f64; 3] {
|
||||
let mut n = [0.0f64; 3];
|
||||
let len = poly.len();
|
||||
for i in 0..len {
|
||||
let a = poly[i];
|
||||
let b = poly[(i + 1) % len];
|
||||
n[0] += (a[1] - b[1]) * (a[2] + b[2]);
|
||||
n[1] += (a[2] - b[2]) * (a[0] + b[0]);
|
||||
n[2] += (a[0] - b[0]) * (a[1] + b[1]);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn cross3(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 norm3(v: [f64; 3]) -> [f64; 3] {
|
||||
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
|
||||
if len < 1e-12 {
|
||||
[0.0, 0.0, 1.0]
|
||||
} else {
|
||||
[v[0] / len, v[1] / len, v[2] / len]
|
||||
}
|
||||
}
|
||||
@@ -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::convert::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<u32>,
|
||||
) {
|
||||
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<f64>, std::ops::Bound<f64>)) -> (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<BSplineSurface<Point3>> {
|
||||
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<Point3> = 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<Vec<f64>> {
|
||||
let mut knots: Vec<f64> = 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<i64> {
|
||||
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<f64> {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Shared helpers used by per-entity tessellation impls in `crate::entities`
|
||||
// and the dispatcher in `crate::scene::convert::tessellate`.
|
||||
//
|
||||
// Anything cross-entity (colour resolution, arc sampling, chord tolerance)
|
||||
// lives here. Entity-specific helpers (dim format strings, mleader path
|
||||
// types, etc.) stay with their entity file.
|
||||
|
||||
use acadrust::types::Color as AcadColor;
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel};
|
||||
|
||||
/// Output of the fallback per-entity geometry path used by entities not
|
||||
/// covered by the truck topology pipeline (Viewport, Insert, Hatch
|
||||
/// outline, Ole2Frame). Tuple form preserved to avoid touching every
|
||||
/// callsite when the dispatcher wraps these into a WireModel.
|
||||
///
|
||||
/// Layout: `(points, snap_pts, tangent_geoms, key_vertices)`.
|
||||
pub type FallbackGeometry = (
|
||||
Vec<[f32; 3]>,
|
||||
Vec<(Vec3, SnapHint)>,
|
||||
Vec<TangentGeom>,
|
||||
Vec<[f32; 3]>,
|
||||
);
|
||||
|
||||
// ── Arc tessellation helpers ─────────────────────────────────────────────
|
||||
|
||||
/// Convert hatch-boundary arc `(start, end, ccw)` into a
|
||||
/// `(start, signed_span)` ready for the sampling loop. Matches the
|
||||
/// legacy `(TAU - sa, TAU - ea)` flip used here for years — direction
|
||||
/// semantics are preserved on real files. (Wrap-through-2π is a known
|
||||
/// edge case in that convention; do not "fix" it without a wider audit
|
||||
/// of how upstream writers emit CW boundary arcs.)
|
||||
///
|
||||
/// VERIFIED against real AutoCAD output (KSR-039 DWG, CW arc edges with
|
||||
/// line neighbours): the stored angles of a `ccw = false` arc edge are
|
||||
/// MIRRORED — the true point is `center + r·(cos(TAU-θ), sin(TAU-θ))`.
|
||||
/// Sampling the mirrored parameter directly (this function's flip) gives
|
||||
/// exact endpoint continuity with the adjacent edges (Δ = 0.000000),
|
||||
/// while interpreting them as true angles lands tens of units away. Any
|
||||
/// code that *produces* CW boundary arcs (mirror transforms, explode)
|
||||
/// must therefore store mirrored angles, not geometric ones.
|
||||
pub fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) {
|
||||
const TAU: f64 = std::f64::consts::TAU;
|
||||
let (sa, ea) = if ccw { (start, end) } else { (TAU - start, TAU - end) };
|
||||
(sa, ea - sa)
|
||||
}
|
||||
|
||||
/// Segment count for an arc, targeting `chord_tol_world` chord-height
|
||||
/// error in world units. Floor 8, cap 512.
|
||||
///
|
||||
/// Two production callers:
|
||||
/// - hatch fill polygon (built at load / on edit) passes a radius-
|
||||
/// relative tol via [`fill_chord_tol`] — ~0.1% radius, zoom-free so
|
||||
/// the polygon stays sharp at extreme zoom-in without re-tessellation.
|
||||
/// - hatch wire outline (re-tessellated every frame inside the render
|
||||
/// scope) passes a zoom-adaptive tol via [`wire_chord_tol`] — pulls
|
||||
/// from the per-frame override set by `Scene::wires_for_block` so
|
||||
/// far-out arcs collapse to a handful of segments.
|
||||
pub fn arc_segments(radius: f64, span_abs: f64, chord_tol_world: f64) -> u32 {
|
||||
if span_abs < 1e-9 || radius < 1e-9 {
|
||||
return 1;
|
||||
}
|
||||
let tol = chord_tol_world.max(1e-9).min(radius * 0.99);
|
||||
// θ where r * (1 - cos(θ/2)) = tol → θ = 2 * acos(1 - tol/r).
|
||||
let max_step = (2.0 * (1.0 - tol / radius).acos()).max(1e-6);
|
||||
((span_abs / max_step).ceil() as u32).clamp(8, 512)
|
||||
}
|
||||
|
||||
/// Chord tolerance for the load-time fill polygon: 0.1% of radius,
|
||||
/// floor 1 µm so degenerate radii still produce a workable count.
|
||||
pub fn fill_chord_tol(radius: f64) -> f64 {
|
||||
(radius * 0.001).max(1e-6)
|
||||
}
|
||||
|
||||
/// Chord tolerance for the per-frame wire outline: pulls the active
|
||||
/// `truck_tess::set_curve_tol_override` value (Scene sets it to
|
||||
/// `world_per_pixel × 0.5` so curves stay at ~half-pixel chord error at
|
||||
/// the current zoom). When no override is active (snap / hit-test
|
||||
/// passes, load-time builds), falls back to [`fill_chord_tol`] so we
|
||||
/// never under-sample.
|
||||
pub fn wire_chord_tol(radius: f64) -> f64 {
|
||||
match crate::scene::convert::truck_tess::active_curve_tol() {
|
||||
Some(t) => t.min(fill_chord_tol(radius)).max(1e-9),
|
||||
None => fill_chord_tol(radius),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Colour helper ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Convert an acadrust Color (ACI index or true-color) to a GPU RGBA value.
|
||||
pub fn aci_to_rgba(color: &AcadColor) -> [f32; 4] {
|
||||
if let Some((r, g, b)) = color.rgb() {
|
||||
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
|
||||
} else {
|
||||
WireModel::WHITE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
// Tessellation — convert acadrust EntityType to GPU-ready WireModel or MeshModel.
|
||||
//
|
||||
// Flow:
|
||||
// EntityType
|
||||
// ↓ acad_to_truck::convert()
|
||||
// TruckEntity { object: TruckObject, snap_pts, tangent_geoms, key_vertices }
|
||||
// ↓ truck_tess::tessellate_*()
|
||||
// TruckTessResult::Lines → WireModel
|
||||
// TruckTessResult::Point → WireModel (small cross)
|
||||
// TruckTessResult::Mesh → MeshModel
|
||||
// TruckObject::Text → one WireModel per glyph stroke (elevation from entity Z)
|
||||
//
|
||||
// Entities not handled by acad_to_truck (Viewport, Insert, Hatch, Ole2Frame)
|
||||
// are tessellated by the FallbackTess fallback_geometry() path.
|
||||
|
||||
use crate::entities::leader::LeaderTess;
|
||||
use acadrust::types::Color as AcadColor;
|
||||
use acadrust::{CadDocument, EntityType, Handle};
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::scene::convert::acad_to_truck::{convert, TruckObject};
|
||||
use crate::scene::convert::truck_tess::{
|
||||
tessellate_edge, tessellate_vertex, tessellate_wire, TruckTessResult,
|
||||
};
|
||||
use crate::scene::model::wire_model::{SnapHint, WireModel};
|
||||
|
||||
// ── Public entry points ────────────────────────────────────────────────────
|
||||
|
||||
/// Tessellate one entity into a WireModel.
|
||||
/// For Text/MText entities this produces one WireModel with all glyph strokes
|
||||
/// encoded as NaN-separated segments (wire_gpu skips NaN pairs).
|
||||
/// For Solid3D entities this returns an empty wire; mesh tessellation lives
|
||||
/// in `solid3d_tess` and is uploaded via the mesh pipeline instead.
|
||||
pub fn tessellate(
|
||||
document: &CadDocument,
|
||||
handle: Handle,
|
||||
entity: &EntityType,
|
||||
selected: bool,
|
||||
entity_color: [f32; 4],
|
||||
pattern_length: f32,
|
||||
pattern: [f32; 8],
|
||||
line_weight_px: f32,
|
||||
world_offset: [f64; 3],
|
||||
anno_scale: f32,
|
||||
) -> Vec<WireModel> {
|
||||
let color = if selected {
|
||||
WireModel::SELECTED
|
||||
} else {
|
||||
entity_color
|
||||
};
|
||||
let name = handle.value().to_string();
|
||||
|
||||
// Determine effective annotation scale for this entity.
|
||||
//
|
||||
// AutoCAD's R2007+ "annotative" system marks objects via extension-
|
||||
// dictionary records or "AcAnnoPO" / "AcAnnotativeData" xdata. Only
|
||||
// entities so marked should be auto-scaled by the viewport's
|
||||
// paper-scale; everything else is treated as manually pre-scaled
|
||||
// (old DXF/DWG convention with $DIMSCALE and oversized text).
|
||||
//
|
||||
// Default: NOT annotative (anno_scale = 1.0). Opt-in via explicit
|
||||
// xdata marker. Files that mark every entity annotative are rare; the
|
||||
// pre-R2007 manual-scale convention is far more common in field data.
|
||||
let anno_scale = {
|
||||
let xdata = &entity.common().extended_data;
|
||||
let is_annotative = xdata.get_record("AcAnnoPO").is_some()
|
||||
|| xdata.get_record("AcAnnotativeData").is_some();
|
||||
if is_annotative {
|
||||
anno_scale
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
};
|
||||
|
||||
// MultiLeader is handled by scene/mod.rs since it emits multiple WireModels
|
||||
// (leader, text, frame, fill) with distinct colors.
|
||||
if let EntityType::Leader(leader) = entity {
|
||||
return vec![leader.tessellate(
|
||||
document,
|
||||
handle,
|
||||
selected,
|
||||
entity_color,
|
||||
line_weight_px,
|
||||
world_offset,
|
||||
anno_scale,
|
||||
)];
|
||||
}
|
||||
|
||||
// ── Try the truck path first ───────────────────────────────────────────
|
||||
if let Some(te) = convert(entity, document) {
|
||||
match te.object {
|
||||
// ── Text / MText: pre-tessellated glyph strokes ───────────────
|
||||
//
|
||||
// Strokes are pre-grouped by world origin (one TextStroke per
|
||||
// line / per run / per fragment), each carrying an optional
|
||||
// colour override produced by MTEXT inline `\C` / `\c`. We bin
|
||||
// groups by override colour and emit one WireModel per bin so a
|
||||
// single MTEXT can hand back N colour-distinct wires when the
|
||||
// value mixes inline colours.
|
||||
TruckObject::Text(stroke_groups) => {
|
||||
let [ox, oy, oz] = world_offset;
|
||||
let elev = entity_z(entity) - oz as f32;
|
||||
|
||||
// anno_scale anchors at the first group's origin so multi-line
|
||||
// MText lines spread apart correctly as they grow.
|
||||
let ref_origin = stroke_groups
|
||||
.first()
|
||||
.map(|g| g.origin)
|
||||
.unwrap_or([0.0, 0.0]);
|
||||
let ref_lx = (ref_origin[0] - ox) as f32;
|
||||
let ref_ly = (ref_origin[1] - oy) as f32;
|
||||
|
||||
// Selection forces a single uniform colour — never split.
|
||||
let split_by_color = !selected;
|
||||
|
||||
// Bins: key = Some(rgb) or None (= inherit entity colour).
|
||||
// Preserve insertion order so wire-emit ordering tracks the
|
||||
// original glyph stream (useful for stable hit-test indexing).
|
||||
let mut bins: Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)> = Vec::new();
|
||||
let mut bin_first: Vec<bool> = Vec::new();
|
||||
let find_or_make =
|
||||
|key: Option<[f32; 3]>, bins: &mut Vec<(Option<[f32; 3]>, Vec<[f32; 3]>)>, firsts: &mut Vec<bool>| -> usize {
|
||||
if let Some(i) = bins.iter().position(|(k, _)| *k == key) {
|
||||
i
|
||||
} else {
|
||||
bins.push((key, Vec::new()));
|
||||
firsts.push(true);
|
||||
bins.len() - 1
|
||||
}
|
||||
};
|
||||
|
||||
for group in &stroke_groups {
|
||||
let lx = (group.origin[0] - ox) as f32;
|
||||
let ly = (group.origin[1] - oy) as f32;
|
||||
let slx = (lx - ref_lx) * anno_scale + ref_lx;
|
||||
let sly = (ly - ref_ly) * anno_scale + ref_ly;
|
||||
let bin_key = if split_by_color { group.color } else { None };
|
||||
let bi = find_or_make(bin_key, &mut bins, &mut bin_first);
|
||||
let pts = &mut bins[bi].1;
|
||||
for stroke in &group.strokes {
|
||||
if stroke.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
if !bin_first[bi] && !pts.is_empty() {
|
||||
pts.push([f32::NAN, f32::NAN, f32::NAN]);
|
||||
}
|
||||
bin_first[bi] = false;
|
||||
for &[x, y] in stroke {
|
||||
pts.push([x * anno_scale + slx, y * anno_scale + sly, elev]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
|
||||
let key_vertices: Vec<[f32; 3]> = te
|
||||
.key_vertices
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
|
||||
// Empty input (no glyphs) → emit a single empty wire so the
|
||||
// entity still has a hit-test target via snap_pts.
|
||||
if bins.is_empty() {
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points: Vec::new(),
|
||||
color,
|
||||
selected,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px,
|
||||
snap_pts,
|
||||
tangent_geoms: te.tangent_geoms,
|
||||
aci: 0,
|
||||
key_vertices,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
}];
|
||||
}
|
||||
|
||||
let bin_count = bins.len();
|
||||
let mut out: Vec<WireModel> = Vec::with_capacity(bin_count);
|
||||
for (idx, (override_rgb, pts)) in bins.into_iter().enumerate() {
|
||||
let wire_color = match override_rgb {
|
||||
Some([r, g, b]) => [r, g, b, color[3]],
|
||||
None => color,
|
||||
};
|
||||
// Snap points and key vertices belong to the entity as a
|
||||
// whole — attach them only to the first emitted wire so
|
||||
// pickers / hover don't double-count.
|
||||
let (snap, keys, tangents) = if idx == 0 {
|
||||
(
|
||||
snap_pts.clone(),
|
||||
key_vertices.clone(),
|
||||
te.tangent_geoms.clone(),
|
||||
)
|
||||
} else {
|
||||
(Vec::new(), Vec::new(), Vec::new())
|
||||
};
|
||||
out.push(WireModel {
|
||||
name: name.clone(),
|
||||
points: pts,
|
||||
color: wire_color,
|
||||
selected,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px,
|
||||
snap_pts: snap,
|
||||
tangent_geoms: tangents,
|
||||
aci: 0,
|
||||
key_vertices: keys,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Standard topology objects ─────────────────────────────────
|
||||
TruckObject::Point(v) => {
|
||||
let result = tessellate_vertex(&v, world_offset);
|
||||
match result {
|
||||
TruckTessResult::Point([x, y, z]) => {
|
||||
let s = 0.1_f32;
|
||||
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
|
||||
let [ox, oy, oz] = world_offset;
|
||||
let key_vertices: Vec<[f32; 3]> = te
|
||||
.key_vertices
|
||||
.into_iter()
|
||||
.map(|[kx, ky, kz]| {
|
||||
[(kx - ox) as f32, (ky - oy) as f32, (kz - oz) as f32]
|
||||
})
|
||||
.collect();
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points: vec![
|
||||
[x - s, y, z],
|
||||
[x + s, y, z],
|
||||
[x, y - s, z],
|
||||
[x, y + s, z],
|
||||
],
|
||||
color,
|
||||
selected,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts,
|
||||
tangent_geoms: te.tangent_geoms,
|
||||
aci: 0,
|
||||
key_vertices,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
}];
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
TruckObject::Curve(e) => {
|
||||
if let TruckTessResult::Lines(points) = tessellate_edge(&e, world_offset) {
|
||||
let [ox, oy, oz] = world_offset;
|
||||
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
|
||||
let key_vertices: Vec<[f32; 3]> = te
|
||||
.key_vertices
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points,
|
||||
color,
|
||||
selected,
|
||||
pattern_length,
|
||||
pattern,
|
||||
line_weight_px,
|
||||
snap_pts,
|
||||
tangent_geoms: te.tangent_geoms,
|
||||
aci: 0,
|
||||
key_vertices,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
TruckObject::Contour(w) => {
|
||||
if let TruckTessResult::Lines(points) = tessellate_wire(&w, world_offset) {
|
||||
let [ox, oy, oz] = world_offset;
|
||||
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
|
||||
let key_vertices: Vec<[f32; 3]> = te
|
||||
.key_vertices
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points,
|
||||
color,
|
||||
selected,
|
||||
pattern_length,
|
||||
pattern,
|
||||
line_weight_px,
|
||||
snap_pts,
|
||||
tangent_geoms: te.tangent_geoms,
|
||||
aci: 0,
|
||||
key_vertices,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
TruckObject::Lines(points) => {
|
||||
// Points are world-space f64 from entity converters (polyline,
|
||||
// leader, mesh, solid2d, etc.). Subtract world_offset in f64
|
||||
// before casting to f32 so drawings at large UTM-style
|
||||
// coordinates keep sub-unit precision in the wire model.
|
||||
let [ox, oy, oz] = world_offset;
|
||||
let local_pts: Vec<[f32; 3]> = points
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| {
|
||||
if x.is_nan() {
|
||||
[f32::NAN, f32::NAN, f32::NAN]
|
||||
} else {
|
||||
[(x - ox) as f32, (y - oy) as f32, (z - oz) as f32]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
|
||||
let key_vertices: Vec<[f32; 3]> = te
|
||||
.key_vertices
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
let fill_tris: Vec<[f32; 3]> = te
|
||||
.fill_tris
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points: local_pts,
|
||||
color,
|
||||
selected,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px,
|
||||
snap_pts,
|
||||
tangent_geoms: te.tangent_geoms,
|
||||
aci: 0,
|
||||
key_vertices,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris,
|
||||
}];
|
||||
}
|
||||
|
||||
TruckObject::SegmentedLines(points) => {
|
||||
let [ox, oy, oz] = world_offset;
|
||||
let local_pts: Vec<[f32; 3]> = points
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| {
|
||||
if x.is_nan() {
|
||||
[f32::NAN, f32::NAN, f32::NAN]
|
||||
} else {
|
||||
[(x - ox) as f32, (y - oy) as f32, (z - oz) as f32]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
|
||||
let key_vertices: Vec<[f32; 3]> = te
|
||||
.key_vertices
|
||||
.into_iter()
|
||||
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
|
||||
.collect();
|
||||
return vec![WireModel {
|
||||
name,
|
||||
points: local_pts,
|
||||
color,
|
||||
selected,
|
||||
pattern_length,
|
||||
pattern,
|
||||
line_weight_px,
|
||||
snap_pts,
|
||||
tangent_geoms: te.tangent_geoms,
|
||||
aci: 0,
|
||||
key_vertices,
|
||||
plinegen: false,
|
||||
vp_scissor: None,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
fill_tris: vec![],
|
||||
}];
|
||||
}
|
||||
|
||||
TruckObject::Volume(_) => {
|
||||
// Solid3D / Region / Body → mesh tessellation lives in
|
||||
// `solid3d_tess`. As a wire fallback, render the pre-computed
|
||||
// edge wires stored in the entity when present (e.g. from
|
||||
// SOLVIEW output or when the SAT kernel cannot parse the
|
||||
// ACIS data).
|
||||
let wire_pts = solid_wire_fallback(entity, world_offset);
|
||||
let mut wm = WireModel::solid(name, wire_pts, color, selected);
|
||||
// Add insertion snap at point_of_reference.
|
||||
let [ox, oy, oz] = world_offset;
|
||||
if let Some(p) = crate::entities::solid3d::point_of_reference(entity) {
|
||||
let sp = Vec3::new((p.x - ox) as f32, (p.y - oy) as f32, (p.z - oz) as f32);
|
||||
wm.snap_pts.push((sp, SnapHint::Insertion));
|
||||
}
|
||||
return vec![wm];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fallback for Viewport / Insert / Hatch / Ole2Frame ────────────────
|
||||
let (points, snap_pts, tangent_geoms, key_vertices) = fallback_geometry(entity, world_offset);
|
||||
vec![WireModel {
|
||||
name,
|
||||
points,
|
||||
color,
|
||||
selected,
|
||||
aci: 0,
|
||||
pattern_length,
|
||||
pattern,
|
||||
line_weight_px,
|
||||
snap_pts,
|
||||
tangent_geoms,
|
||||
key_vertices,
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum ArrowKind {
|
||||
None,
|
||||
Triangle { size: f32, filled: bool, size_mul: f32 },
|
||||
Tick { size: f32 },
|
||||
Open { size: f32, half_angle: f32 },
|
||||
Dot { size: f32, filled: bool },
|
||||
Origin { size: f32 },
|
||||
Box_ { size: f32, filled: bool },
|
||||
Datum { size: f32, filled: bool },
|
||||
}
|
||||
|
||||
pub(crate) fn arrow_from_block(
|
||||
doc: &CadDocument,
|
||||
handle: acadrust::types::Handle,
|
||||
dimasz: f32,
|
||||
) -> ArrowKind {
|
||||
let name = if handle.is_null() {
|
||||
None
|
||||
} else {
|
||||
doc.block_records
|
||||
.iter()
|
||||
.find(|b| b.handle == handle)
|
||||
.map(|b| b.name.as_str())
|
||||
};
|
||||
arrow_from_block_name(name, dimasz)
|
||||
}
|
||||
|
||||
fn arrow_from_block_name(name: Option<&str>, dimasz: f32) -> ArrowKind {
|
||||
// AutoCAD's standard arrow blocks are prefixed with "_" (e.g. "_OPEN").
|
||||
// Strip the prefix, upper-case, and switch on canonical names. Unknown
|
||||
// / missing names default to ClosedFilled.
|
||||
let n = name
|
||||
.map(|s| s.trim().trim_start_matches('_').to_ascii_uppercase())
|
||||
.unwrap_or_default();
|
||||
match n.as_str() {
|
||||
"" | "CLOSEDFILLED" => ArrowKind::Triangle {
|
||||
size: dimasz,
|
||||
filled: true,
|
||||
size_mul: 1.0,
|
||||
},
|
||||
"CLOSED" | "CLOSEDBLANK" => ArrowKind::Triangle {
|
||||
size: dimasz,
|
||||
filled: false,
|
||||
size_mul: 1.0,
|
||||
},
|
||||
"SMALL" => ArrowKind::Triangle {
|
||||
size: dimasz,
|
||||
filled: true,
|
||||
size_mul: 0.5,
|
||||
},
|
||||
"OPEN" => ArrowKind::Open {
|
||||
size: dimasz,
|
||||
half_angle: 9.5_f32.to_radians(),
|
||||
},
|
||||
"OPEN30" => ArrowKind::Open {
|
||||
size: dimasz,
|
||||
half_angle: 15.0_f32.to_radians(),
|
||||
},
|
||||
"OPEN90" => ArrowKind::Open {
|
||||
size: dimasz,
|
||||
half_angle: 45.0_f32.to_radians(),
|
||||
},
|
||||
"DOT" => ArrowKind::Dot {
|
||||
size: dimasz,
|
||||
filled: true,
|
||||
},
|
||||
"DOTSMALL" => ArrowKind::Dot {
|
||||
size: dimasz * 0.5,
|
||||
filled: true,
|
||||
},
|
||||
"DOTBLANK" => ArrowKind::Dot {
|
||||
size: dimasz,
|
||||
filled: false,
|
||||
},
|
||||
"DOTSMALLBLANK" => ArrowKind::Dot {
|
||||
size: dimasz * 0.5,
|
||||
filled: false,
|
||||
},
|
||||
"ORIGIN" | "ORIGIN2" | "ORIGININDICATOR" | "ORIGININDICATOR2" => {
|
||||
ArrowKind::Origin { size: dimasz }
|
||||
}
|
||||
"OBLIQUE" | "ARCHTICK" => ArrowKind::Tick { size: dimasz },
|
||||
"BOXFILLED" => ArrowKind::Box_ {
|
||||
size: dimasz,
|
||||
filled: true,
|
||||
},
|
||||
"BOXBLANK" | "BOX" => ArrowKind::Box_ {
|
||||
size: dimasz,
|
||||
filled: false,
|
||||
},
|
||||
"DATUMFILLED" | "DATUMTRIANGLEFILLED" => ArrowKind::Datum {
|
||||
size: dimasz,
|
||||
filled: true,
|
||||
},
|
||||
"DATUMBLANK" | "DATUMTRIANGLE" => ArrowKind::Datum {
|
||||
size: dimasz,
|
||||
filled: false,
|
||||
},
|
||||
"NONE" => ArrowKind::None,
|
||||
// INTEGRAL and other complex glyphs aren't reproduced here; fall through.
|
||||
_ => ArrowKind::Triangle {
|
||||
size: dimasz,
|
||||
filled: true,
|
||||
size_mul: 1.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct DimGeom {
|
||||
pub(crate) ext_lines: Vec<[f32; 3]>,
|
||||
pub(crate) dim_lines: Vec<[f32; 3]>,
|
||||
pub(crate) arrow_fill: Vec<[f32; 3]>,
|
||||
}
|
||||
|
||||
impl DimGeom {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
ext_lines: Vec::new(),
|
||||
dim_lines: Vec::new(),
|
||||
arrow_fill: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Convert an acadrust `Color` to RGBA, falling back to `inherited` for
|
||||
/// `ByLayer` / `ByBlock` (assumes those are already resolved upstream).
|
||||
pub(crate) fn color_or_inherit(c: &AcadColor, inherited: [f32; 4]) -> [f32; 4] {
|
||||
match c.rgb() {
|
||||
Some((r, g, b)) => [
|
||||
r as f32 / 255.0,
|
||||
g as f32 / 255.0,
|
||||
b as f32 / 255.0,
|
||||
inherited[3],
|
||||
],
|
||||
None => inherited,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Entity Z helper ───────────────────────────────────────────────────────
|
||||
|
||||
/// Extract the Z elevation from a text/mtext entity.
|
||||
pub(crate) fn entity_z(entity: &EntityType) -> f32 {
|
||||
match entity {
|
||||
EntityType::Text(t) => t.insertion_point.z as f32,
|
||||
EntityType::MText(t) => t.insertion_point.z as f32,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fallback geometry (Viewport, Insert, Hatch outline, Ole2Frame) ───────
|
||||
//
|
||||
// Per-entity blocks have moved to their respective `entities/*.rs` files
|
||||
// (Viewport, Insert, Hatch, Ole2Frame) via the `FallbackTess` trait. This
|
||||
// function stays as the dispatcher used by the main `tessellate()` path.
|
||||
|
||||
use crate::entities::traits::FallbackTess;
|
||||
use crate::scene::convert::tess_util::FallbackGeometry as Geometry;
|
||||
|
||||
fn fallback_geometry(entity: &EntityType, world_offset: [f64; 3]) -> Geometry {
|
||||
match entity {
|
||||
EntityType::Viewport(vp) => vp.fallback_geometry(world_offset),
|
||||
EntityType::Insert(ins) => ins.fallback_geometry(world_offset),
|
||||
EntityType::Hatch(h) => h.fallback_geometry(world_offset),
|
||||
EntityType::Ole2Frame(ole) => ole.fallback_geometry(world_offset),
|
||||
// Modeler solids render as meshes (solid3d_tess). Their wire path
|
||||
// contributes only the pre-computed edge wires (empty for binary SAB)
|
||||
// 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::Surface(_) => {
|
||||
let pts = solid_wire_fallback(entity, world_offset);
|
||||
let mut snap = vec![];
|
||||
if let Some(p) = crate::entities::solid3d::point_of_reference(entity) {
|
||||
let [ox, oy, oz] = world_offset;
|
||||
snap.push((
|
||||
Vec3::new((p.x - ox) as f32, (p.y - oy) as f32, (p.z - oz) as f32),
|
||||
SnapHint::Insertion,
|
||||
));
|
||||
}
|
||||
(pts, snap, vec![], vec![])
|
||||
}
|
||||
_ => {
|
||||
let s = 0.5_f32;
|
||||
(vec![[-s, 0.0, 0.0], [s, 0.0, 0.0]], vec![], vec![], vec![])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract pre-computed edge-wire points from Solid3D / Region / Body entities.
|
||||
///
|
||||
/// AutoCAD stores explicit wire geometry (from SOLVIEW / 3DPLOT) alongside the
|
||||
/// ACIS data. We use this as a visible fallback when the SAT tessellator
|
||||
/// produces no mesh (e.g. binary SAB data or unsupported geometry).
|
||||
fn solid_wire_fallback(entity: &EntityType, world_offset: [f64; 3]) -> Vec<[f32; 3]> {
|
||||
let [ox, oy, oz] = world_offset;
|
||||
let Some(wires) = crate::entities::solid3d::fallback_wires(entity) else {
|
||||
return vec![];
|
||||
};
|
||||
if wires.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut pts: Vec<[f32; 3]> = Vec::new();
|
||||
for wire in wires {
|
||||
if wire.points.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for v in &wire.points {
|
||||
pts.push([(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32]);
|
||||
}
|
||||
// NaN sentinel separates distinct wire segments.
|
||||
pts.push([f32::NAN, f32::NAN, f32::NAN]);
|
||||
}
|
||||
pts
|
||||
}
|
||||
|
||||
pub(crate) fn push_tri(out: &mut Vec<[f32; 3]>, a: Vec3, b: Vec3, c: Vec3) {
|
||||
out.push([a.x, a.y, a.z]);
|
||||
out.push([b.x, b.y, b.z]);
|
||||
out.push([c.x, c.y, c.z]);
|
||||
}
|
||||
|
||||
pub(crate) fn append_arrow(g: &mut DimGeom, tip: Vec3, dir: Vec3, arrow: &ArrowKind) {
|
||||
let dir = normalized_or(dir, Vec3::X);
|
||||
let perp = Vec3::new(-dir.y, dir.x, 0.0);
|
||||
match *arrow {
|
||||
ArrowKind::None => {}
|
||||
ArrowKind::Triangle {
|
||||
size,
|
||||
filled,
|
||||
size_mul,
|
||||
} => {
|
||||
let size = size * size_mul;
|
||||
let base = tip + dir * size;
|
||||
// ~1:6 length:half-width ratio (≈9.5° half-angle) matches
|
||||
// AutoCAD's standard ClosedFilled block.
|
||||
let half_w = size / 6.0;
|
||||
let left = base + perp * half_w;
|
||||
let right = base - perp * half_w;
|
||||
add_segment(&mut g.dim_lines, tip, left);
|
||||
add_segment(&mut g.dim_lines, left, right);
|
||||
add_segment(&mut g.dim_lines, right, tip);
|
||||
if filled {
|
||||
push_tri(&mut g.arrow_fill, tip, left, right);
|
||||
}
|
||||
}
|
||||
ArrowKind::Tick { size } => {
|
||||
// 45° oblique tick crossing the dim line at the tip; `size` is
|
||||
// the half-length (matches AutoCAD's DIMTSZ semantics).
|
||||
let off = (dir + perp).normalize_or_zero() * size;
|
||||
add_segment(&mut g.dim_lines, tip - off, tip + off);
|
||||
}
|
||||
ArrowKind::Open { size, half_angle } => {
|
||||
let base = tip + dir * size;
|
||||
let half_w = size * half_angle.tan();
|
||||
let left = base + perp * half_w;
|
||||
let right = base - perp * half_w;
|
||||
add_segment(&mut g.dim_lines, tip, left);
|
||||
add_segment(&mut g.dim_lines, tip, right);
|
||||
}
|
||||
ArrowKind::Dot { size, filled } => {
|
||||
let r = size * 0.5;
|
||||
const N: usize = 16;
|
||||
let mut ring: Vec<Vec3> = Vec::with_capacity(N + 1);
|
||||
for i in 0..=N {
|
||||
let a = i as f32 * std::f32::consts::TAU / N as f32;
|
||||
ring.push(tip + Vec3::new(a.cos() * r, a.sin() * r, 0.0));
|
||||
}
|
||||
add_polyline(&mut g.dim_lines, &ring);
|
||||
if filled {
|
||||
for i in 0..N {
|
||||
push_tri(&mut g.arrow_fill, tip, ring[i], ring[i + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
ArrowKind::Origin { size } => {
|
||||
// Small filled dot at the tip with a perpendicular tick crossing
|
||||
// the dim line — matches "_ORIGIN" / "_ORIGIN2" blocks.
|
||||
let r = size * 0.25;
|
||||
const N: usize = 12;
|
||||
let mut ring: Vec<Vec3> = Vec::with_capacity(N + 1);
|
||||
for i in 0..=N {
|
||||
let a = i as f32 * std::f32::consts::TAU / N as f32;
|
||||
ring.push(tip + Vec3::new(a.cos() * r, a.sin() * r, 0.0));
|
||||
}
|
||||
add_polyline(&mut g.dim_lines, &ring);
|
||||
for i in 0..N {
|
||||
push_tri(&mut g.arrow_fill, tip, ring[i], ring[i + 1]);
|
||||
}
|
||||
let half = size * 0.5;
|
||||
add_segment(&mut g.dim_lines, tip - perp * half, tip + perp * half);
|
||||
}
|
||||
ArrowKind::Box_ { size, filled } => {
|
||||
let half = size * 0.5;
|
||||
let p1 = tip - dir * half - perp * half;
|
||||
let p2 = tip + dir * half - perp * half;
|
||||
let p3 = tip + dir * half + perp * half;
|
||||
let p4 = tip - dir * half + perp * half;
|
||||
add_segment(&mut g.dim_lines, p1, p2);
|
||||
add_segment(&mut g.dim_lines, p2, p3);
|
||||
add_segment(&mut g.dim_lines, p3, p4);
|
||||
add_segment(&mut g.dim_lines, p4, p1);
|
||||
if filled {
|
||||
push_tri(&mut g.arrow_fill, p1, p2, p3);
|
||||
push_tri(&mut g.arrow_fill, p1, p3, p4);
|
||||
}
|
||||
}
|
||||
ArrowKind::Datum { size, filled } => {
|
||||
// Right-pointing triangle with the base perpendicular to the dim
|
||||
// line at the tip and the apex along +dir.
|
||||
let half = size * 0.5;
|
||||
let base_a = tip + perp * half;
|
||||
let base_b = tip - perp * half;
|
||||
let apex = tip + dir * size;
|
||||
add_segment(&mut g.dim_lines, base_a, apex);
|
||||
add_segment(&mut g.dim_lines, apex, base_b);
|
||||
add_segment(&mut g.dim_lines, base_b, base_a);
|
||||
if filled {
|
||||
push_tri(&mut g.arrow_fill, base_a, apex, base_b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn add_segment(points: &mut Vec<[f32; 3]>, a: Vec3, b: Vec3) {
|
||||
if !points.is_empty() {
|
||||
points.push([f32::NAN, f32::NAN, f32::NAN]);
|
||||
}
|
||||
points.push([a.x, a.y, a.z]);
|
||||
points.push([b.x, b.y, b.z]);
|
||||
}
|
||||
|
||||
pub(crate) fn add_polyline(points: &mut Vec<[f32; 3]>, polyline: &[Vec3]) {
|
||||
if polyline.len() < 2 {
|
||||
return;
|
||||
}
|
||||
if !points.is_empty() {
|
||||
points.push([f32::NAN, f32::NAN, f32::NAN]);
|
||||
}
|
||||
points.extend(polyline.iter().map(|p| [p.x, p.y, p.z]));
|
||||
}
|
||||
|
||||
pub(crate) fn offset_snap_pts(pts: Vec<(Vec3, SnapHint)>, off: [f64; 3]) -> Vec<(Vec3, SnapHint)> {
|
||||
let [ox, oy, oz] = off;
|
||||
pts.into_iter()
|
||||
.map(|(p, h)| {
|
||||
(
|
||||
Vec3::new(p.x - ox as f32, p.y - oy as f32, p.z - oz as f32),
|
||||
h,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns the text position of a dimension in DXF world-space (f64, no offset applied).
|
||||
/// Used when building a synthetic Text entity so tessellate() can apply world_offset itself.
|
||||
/// When the saved `text_middle_point` is zero (i.e. AutoCAD never wrote one),
|
||||
/// computes a fallback from the dim geometry and applies DIMTAD/DIMGAP.
|
||||
pub(crate) fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 {
|
||||
if v.length_squared() <= 1e-12 {
|
||||
fallback
|
||||
} else {
|
||||
v.normalize()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Unified tessellation for all truck topology levels.
|
||||
//
|
||||
// Vertex → TruckTessResult::Point
|
||||
// Edge → TruckTessResult::Lines (curve sampled with tolerance)
|
||||
// Wire → TruckTessResult::Lines (edges joined, no duplicate junctions)
|
||||
// Face / Shell / Solid → TruckTessResult::Mesh (triangle mesh via truck-meshalgo)
|
||||
//
|
||||
// The caller (acad_to_truck / tessellate) decides which function to call based on
|
||||
// the truck topology level of the object.
|
||||
|
||||
use truck_meshalgo::tessellation::{MeshableShape, MeshedShape};
|
||||
use truck_modeling::base::{BoundedCurve, ParameterDivision1D};
|
||||
use truck_modeling::{Edge, Shell, Solid, Vertex, Wire};
|
||||
use truck_polymesh::PolygonMesh;
|
||||
|
||||
|
||||
// Chord-height tolerance used for adaptive curve sampling (world units).
|
||||
const CURVE_TOL: f64 = 0.005;
|
||||
// Triangle mesh tolerance (world units).
|
||||
pub const MESH_TOL: f64 = 0.01;
|
||||
|
||||
// ── Zoom-adaptive curve tolerance override ────────────────────────────────
|
||||
//
|
||||
// The Scene sets this once per render frame to a value derived from
|
||||
// `world_per_pixel` (target ≈ 0.5 px chord height). All Edge tessellations
|
||||
// inside the frame — including those running on rayon worker threads — read
|
||||
// from the same atomic, so callers don't need to thread a tolerance
|
||||
// parameter through every entity-converter signature. Zero means "use
|
||||
// `CURVE_TOL`"; that is the default, and what BlockCache::build expects.
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static CURVE_TOL_BITS: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Set the per-frame curve tolerance. `None` (or any non-finite/<=0 value)
|
||||
/// reverts to the default `CURVE_TOL`.
|
||||
pub fn set_curve_tol_override(tol: Option<f64>) {
|
||||
let bits = match tol {
|
||||
Some(t) if t > 0.0 && t.is_finite() => t.to_bits(),
|
||||
_ => 0,
|
||||
};
|
||||
CURVE_TOL_BITS.store(bits, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Resolve the active curve tolerance. Clamped to the floor `CURVE_TOL` so
|
||||
/// extreme zoom-in never under-samples below the existing baseline quality.
|
||||
pub(crate) fn current_curve_tol() -> f64 {
|
||||
let bits = CURVE_TOL_BITS.load(Ordering::Relaxed);
|
||||
if bits == 0 {
|
||||
CURVE_TOL
|
||||
} else {
|
||||
f64::from_bits(bits).max(CURVE_TOL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `Some(tol)` only when a Scene-driven per-frame override is
|
||||
/// active (i.e. we're tessellating inside a render frame, not at load
|
||||
/// time or in a snap / hit-test pass). Hatch boundary outlines use this
|
||||
/// to apply zoom-adaptive sampling.
|
||||
pub(crate) fn active_curve_tol() -> Option<f64> {
|
||||
let bits = CURVE_TOL_BITS.load(Ordering::Relaxed);
|
||||
if bits == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(f64::from_bits(bits).max(CURVE_TOL))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public result type ────────────────────────────────────────────────────
|
||||
|
||||
/// Output of any truck topology tessellation.
|
||||
#[allow(dead_code)]
|
||||
pub enum TruckTessResult {
|
||||
/// A single world-space point (from Vertex).
|
||||
Point([f32; 3]),
|
||||
/// An ordered sequence of points forming a polyline (from Edge or Wire).
|
||||
Lines(Vec<[f32; 3]>),
|
||||
/// A triangle mesh (from Face, Shell, or Solid).
|
||||
Mesh {
|
||||
verts: Vec<[f32; 3]>,
|
||||
normals: Vec<[f32; 3]>,
|
||||
indices: Vec<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Shared offset helper ──────────────────────────────────────────────────
|
||||
|
||||
/// Convert a truck f64 point to local f32 by subtracting world_offset in f64
|
||||
/// before truncating. This preserves sub-unit decimal precision even when the
|
||||
/// raw world coordinates are in the millions (e.g. Turkish UTM).
|
||||
#[inline]
|
||||
fn to_local(x: f64, y: f64, z: f64, off: [f64; 3]) -> [f32; 3] {
|
||||
[
|
||||
(x - off[0]) as f32,
|
||||
(y - off[1]) as f32,
|
||||
(z - off[2]) as f32,
|
||||
]
|
||||
}
|
||||
|
||||
// ── Vertex ────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn tessellate_vertex(v: &Vertex, offset: [f64; 3]) -> TruckTessResult {
|
||||
let p = v.point();
|
||||
TruckTessResult::Point(to_local(p.x, p.y, p.z, offset))
|
||||
}
|
||||
|
||||
// ── Edge ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn tessellate_edge(e: &Edge, offset: [f64; 3]) -> TruckTessResult {
|
||||
// oriented_curve() respects the edge direction (inverts if needed).
|
||||
let curve = e.oriented_curve();
|
||||
let (t0, t1) = curve.range_tuple();
|
||||
// parameter_division samples adaptively: fewer points on straight segments,
|
||||
// more on tight curves, all within the active chord-height tolerance.
|
||||
// The Scene scales this with zoom so far-out arcs aren't oversampled.
|
||||
let (_, pts) = curve.parameter_division((t0, t1), current_curve_tol());
|
||||
let lines = pts
|
||||
.iter()
|
||||
.map(|p| to_local(p.x, p.y, p.z, offset))
|
||||
.collect();
|
||||
TruckTessResult::Lines(lines)
|
||||
}
|
||||
|
||||
// ── Wire ──────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn tessellate_wire(w: &Wire, offset: [f64; 3]) -> TruckTessResult {
|
||||
let mut pts: Vec<[f32; 3]> = Vec::new();
|
||||
for edge in w.edge_iter() {
|
||||
if let TruckTessResult::Lines(ep) = tessellate_edge(edge, offset) {
|
||||
if pts.is_empty() {
|
||||
pts = ep;
|
||||
} else {
|
||||
// Skip the first point of each continuation edge to avoid
|
||||
// duplicating the shared junction vertex.
|
||||
pts.extend_from_slice(&ep[1..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
TruckTessResult::Lines(pts)
|
||||
}
|
||||
|
||||
// ── Shell ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tessellate_shell(s: &Shell, offset: [f64; 3]) -> TruckTessResult {
|
||||
let meshed = s.triangulation(MESH_TOL);
|
||||
polygon_to_result(meshed.to_polygon(), offset)
|
||||
}
|
||||
|
||||
// ── Solid ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn tessellate_solid(s: &Solid, offset: [f64; 3]) -> TruckTessResult {
|
||||
let meshed = s.triangulation(MESH_TOL);
|
||||
polygon_to_result(meshed.to_polygon(), offset)
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn polygon_to_result(mesh: PolygonMesh, offset: [f64; 3]) -> TruckTessResult {
|
||||
let verts: Vec<[f32; 3]> = mesh
|
||||
.positions()
|
||||
.iter()
|
||||
.map(|p| to_local(p.x, p.y, p.z, offset))
|
||||
.collect();
|
||||
|
||||
// Per-vertex normals: if the mesh has normals, map each triangle vertex's
|
||||
// normal index back to the normals array. Fall back to empty if absent.
|
||||
let mesh_normals = mesh.normals();
|
||||
|
||||
let indices: Vec<u32> = mesh
|
||||
.tri_faces()
|
||||
.iter()
|
||||
.flat_map(|tri| [tri[0].pos as u32, tri[1].pos as u32, tri[2].pos as u32])
|
||||
.collect();
|
||||
|
||||
// Build a per-position normal by averaging normals of all faces that share it.
|
||||
// If mesh has no normals, leave the Vec empty.
|
||||
let normals: Vec<[f32; 3]> = if !mesh_normals.is_empty() {
|
||||
let n = verts.len();
|
||||
let mut acc = vec![[0.0_f32; 3]; n];
|
||||
let mut cnt = vec![0u32; n];
|
||||
for tri in mesh.tri_faces() {
|
||||
for sv in tri {
|
||||
let pos_idx = sv.pos;
|
||||
if let Some(nor_idx) = sv.nor {
|
||||
if let Some(nv) = mesh_normals.get(nor_idx) {
|
||||
acc[pos_idx][0] += nv.x as f32;
|
||||
acc[pos_idx][1] += nv.y as f32;
|
||||
acc[pos_idx][2] += nv.z as f32;
|
||||
cnt[pos_idx] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
acc.iter()
|
||||
.zip(cnt.iter())
|
||||
.map(|(s, &c)| {
|
||||
if c == 0 {
|
||||
[0.0, 1.0, 0.0]
|
||||
} else {
|
||||
let inv = 1.0 / c as f32;
|
||||
let nx = s[0] * inv;
|
||||
let ny = s[1] * inv;
|
||||
let nz = s[2] * inv;
|
||||
let len = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-9);
|
||||
[nx / len, ny / len, nz / len]
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
TruckTessResult::Mesh {
|
||||
verts,
|
||||
normals,
|
||||
indices,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user