refactor(tess): move solid sampling to kernel
Remove local face, edge, spline, sweep, loft, and silhouette samplers. Meshes and overlays share the kernel tolerance and edge schedule.
This commit is contained in:
parent
60f5bca0d4
commit
dc998fe46b
15 changed files with 409 additions and 3937 deletions
44
Cargo.lock
generated
44
Cargo.lock
generated
|
|
@ -6,7 +6,7 @@ version = 4
|
|||
name = "OpenCADStudio"
|
||||
version = "0.9.4"
|
||||
dependencies = [
|
||||
"acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=1ffe44e)",
|
||||
"acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6059bae)",
|
||||
"ashpd",
|
||||
"bincode",
|
||||
"bytemuck",
|
||||
|
|
@ -70,11 +70,11 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
|||
[[package]]
|
||||
name = "acadifc"
|
||||
version = "0.5.0"
|
||||
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=1ffe44e#1ffe44ea986628a9915f3e4f0e0b1c433a131c3d"
|
||||
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6059bae#6059baed781c6399d8259aa85c19be803ba95205"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"acadrust 0.4.1 (git+https://github.com/HakanSeven12/cadcodec.git?rev=36e841f)",
|
||||
"base64",
|
||||
"cadkernel",
|
||||
"cadkernel 0.1.0 (git+https://github.com/HakanSeven12/cadkernel.git?rev=6f34deb)",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
|
@ -86,15 +86,38 @@ name = "acadifc"
|
|||
version = "0.5.0"
|
||||
source = "git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396#c65d396abc3defcf96b1d71a3f812c8ad993e77c"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"acadrust 0.4.1 (git+https://github.com/HakanSeven12/cadcodec.git?rev=d645c7f)",
|
||||
"base64",
|
||||
"cadkernel",
|
||||
"cadkernel 0.1.0 (git+https://github.com/HakanSeven12/cadkernel.git)",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.4.1"
|
||||
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=36e841f#36e841feeebe706b7f8dd604d273e34032fda764"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"anyhow",
|
||||
"bitflags 2.13.1",
|
||||
"byteorder",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memmap2",
|
||||
"nom 7.1.3",
|
||||
"once_cell",
|
||||
"rayon",
|
||||
"ryu",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.4.1"
|
||||
|
|
@ -904,11 +927,16 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|||
[[package]]
|
||||
name = "cadkernel"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git#860b9df7e5fc4a495334a6329560a4cde33659db"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6f34deb#6f34deb57eedf0dc5908bf225917f19c9707682d"
|
||||
dependencies = [
|
||||
"cavalier_contours",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadkernel"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/HakanSeven12/cadkernel.git#860b9df7e5fc4a495334a6329560a4cde33659db"
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.13.0"
|
||||
|
|
@ -3760,7 +3788,7 @@ dependencies = [
|
|||
name = "ocs_plugin_api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=c65d396)",
|
||||
"acadifc 0.5.0 (git+https://github.com/OpenAEC-Foundation/acadifc.git?rev=6059bae)",
|
||||
"bincode",
|
||||
"getrandom 0.2.17",
|
||||
"interprocess",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ env_logger = "0.11"
|
|||
# The CAD stack is reached through acadifc, which re-exports the codec and
|
||||
# the geometry kernel. Aliased to `acadrust` so existing `use acadrust::…`
|
||||
# paths keep resolving.
|
||||
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "1ffe44e", features = ["serde", "offset"] }
|
||||
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "6059bae", features = ["serde", "offset"] }
|
||||
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
||||
flate2 = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] }
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ license = "GPL-3.0-only"
|
|||
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
|
||||
# `HostApi` runtime surface. The default crate stays dependency-free so engine
|
||||
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
|
||||
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "c65d396", optional = true, features = ["serde"] }
|
||||
acadrust = { package = "acadifc", git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "6059bae", optional = true, features = ["serde"] }
|
||||
|
||||
# Runtime IPC and serialization (host feature only).
|
||||
interprocess = { version = "2", optional = true }
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
//
|
||||
// Geometry lives in ACIS data — we cannot edit it via the properties panel.
|
||||
// We expose the point_of_reference as a translate grip and show ACIS size
|
||||
// as read-only info. Grip translate also updates wire points so the wire
|
||||
// fallback stays in sync; the caller (scene/mod.rs apply_grip) translates
|
||||
// the MeshModel vertices to match.
|
||||
// as read-only info. Grip translate also updates stored wire points; the
|
||||
// caller translates the mesh vertices to match.
|
||||
|
||||
use acadrust::entities::{Body, Region, Solid3D, Surface};
|
||||
use acadrust::kernel::space::polygon;
|
||||
|
|
@ -745,11 +744,7 @@ impl PropertyEditable for Surface {
|
|||
|
||||
// ── Accessors for the Solid3D / Region / Body trio ─────────────────────────
|
||||
//
|
||||
// These three entity types share a common subset of fields (ACIS data
|
||||
// + point_of_reference + wires fallback). Code that needs to treat them
|
||||
// uniformly (mesh tess dispatch, fallback wires, grip translate) used
|
||||
// to repeat a three-arm `match entity` block at every callsite — the
|
||||
// helpers below collapse those to a single call.
|
||||
// These entity types share ACIS data and a point of reference.
|
||||
|
||||
use crate::scene::model::mesh_model::MeshLodSet;
|
||||
use crate::scene::convert::solid3d_tess;
|
||||
|
|
@ -766,77 +761,6 @@ pub fn point_of_reference(e: &EntityType) -> Option<&Vector3> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Pre-stored edge-wire fallback list (used when the SAT/SAB kernel
|
||||
/// can't produce a mesh — drawings authored by SOLVIEW / 3DPLOT carry
|
||||
/// these explicitly).
|
||||
pub fn fallback_wires(e: &EntityType) -> Option<&[acadrust::entities::Wire]> {
|
||||
match e {
|
||||
EntityType::Solid3D(s) => Some(&s.wires),
|
||||
EntityType::Region(r) => Some(&r.wires),
|
||||
EntityType::Body(b) => Some(&b.wires),
|
||||
EntityType::Surface(s) => Some(&s.wires),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wire_point(
|
||||
wire: &acadrust::entities::Wire,
|
||||
point: &acadrust::types::Vector3,
|
||||
) -> acadrust::types::Vector3 {
|
||||
if !wire.has_transform {
|
||||
return *point;
|
||||
}
|
||||
let x = point.x * wire.scale.x;
|
||||
let y = point.y * wire.scale.y;
|
||||
let z = point.z * wire.scale.z;
|
||||
acadrust::types::Vector3::new(
|
||||
wire.translation.x
|
||||
+ wire.x_axis.x * x
|
||||
+ wire.y_axis.x * y
|
||||
+ wire.z_axis.x * z,
|
||||
wire.translation.y
|
||||
+ wire.x_axis.y * x
|
||||
+ wire.y_axis.y * y
|
||||
+ wire.z_axis.y * z,
|
||||
wire.translation.z
|
||||
+ wire.x_axis.z * x
|
||||
+ wire.y_axis.z * y
|
||||
+ wire.z_axis.z * z,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether every ACIS face uses a surface family the mesh pipeline can decode.
|
||||
/// Unsupported or unresolved faces must keep their display-cache wires visible;
|
||||
/// otherwise a parseable but incomplete shell looks like a valid solid.
|
||||
pub fn acis_has_complete_surface_support(e: &EntityType) -> bool {
|
||||
let sat = match e {
|
||||
EntityType::Solid3D(s) => s.acis_data.parse(),
|
||||
EntityType::Region(r) => r.acis_data.parse(),
|
||||
EntityType::Body(b) => b.acis_data.parse(),
|
||||
EntityType::Surface(s) => s.acis_data.parse(),
|
||||
_ => None,
|
||||
};
|
||||
let Some(sat) = sat else {
|
||||
return false;
|
||||
};
|
||||
let faces = sat.faces();
|
||||
!faces.is_empty()
|
||||
&& faces.iter().all(|face| {
|
||||
sat.resolve(face.surface()).is_some_and(|surface| {
|
||||
matches!(
|
||||
surface.entity_type.as_str(),
|
||||
"plane-surface"
|
||||
| "cone-surface"
|
||||
| "sphere-surface"
|
||||
| "torus-surface"
|
||||
| "spline-surface"
|
||||
| "meshsurf-surface"
|
||||
| "bs3-surface"
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Build material-aware shaded geometry for every standard 3-D solid/surface
|
||||
/// and mesh family, returning `None` when decoded geometry is unusable.
|
||||
pub fn tessellate_volume(
|
||||
|
|
|
|||
|
|
@ -6,112 +6,143 @@
|
|||
//! the kernel for triangles, rather than to re-derive each surface's extent by
|
||||
//! sampling it.
|
||||
//!
|
||||
//! # Why it can still fall short
|
||||
//!
|
||||
//! A face on a surface the kernel does not model, a curve it has no form for,
|
||||
//! a pointer graph that does not hold together: [`lift`] reports each as a
|
||||
//! [`Loss`] rather than quietly dropping it. What comes back then is a body
|
||||
//! with faces missing, and the mesh it makes has holes — which is why the
|
||||
//! result is marked incomplete and the caller keeps its own sampler for those.
|
||||
//!
|
||||
//! Saying so is the point. A partial mesh that claimed to be whole would show
|
||||
//! a solid with a wall missing and nothing to suggest anything was wrong.
|
||||
//! Lift and tessellation failures are reported as an incomplete result.
|
||||
|
||||
use acadrust::acis::lift;
|
||||
use acadrust::entities::acis::SatDocument;
|
||||
use acadrust::kernel::brep;
|
||||
|
||||
use crate::scene::convert::solid3d_tess::{body_transform, finalize_mesh};
|
||||
use crate::scene::model::mesh_model::MeshLodSet;
|
||||
use crate::scene::model::mesh_model::{CurvedGen, MeshLodSet};
|
||||
|
||||
/// How far a triangle may sit from the surface it lies on, as a fraction of
|
||||
/// that surface's own radius.
|
||||
///
|
||||
/// A fraction rather than a length, because a length carries an assumption
|
||||
/// about the drawing's units: a centimetre of sag is nothing on a pipeline
|
||||
/// and is the whole of a bolt.
|
||||
///
|
||||
/// The *same* fraction the feature edges use, and deliberately so. Those edges
|
||||
/// are drawn over these faces, so sampling the two differently leaves the wire
|
||||
/// cutting across a facet instead of running along its corners — the rim of a
|
||||
/// cylinder standing proud of the wall it bounds. Sharing the constant is what
|
||||
/// keeps them from drifting apart when one is tuned.
|
||||
use crate::scene::convert::solid3d_tess::EDGE_CHORD_FRAC as CHORD_FRAC;
|
||||
/// Relative chord tolerance, resolved once per body.
|
||||
const CHORD_FRAC: f64 = 0.002;
|
||||
|
||||
/// What counts as the same point when the kernel reads a body over.
|
||||
///
|
||||
/// A micrometre, in a drawing measured in metres. Not slackness: an edge is
|
||||
/// shared by two faces, and in a real file it cannot sit exactly on both,
|
||||
/// because the two surfaces were fitted separately and written to finite
|
||||
/// precision. Asked for exactness the kernel decides the edge is not on its
|
||||
/// own plane, declines to project it, and the face is dropped — twenty-six
|
||||
/// walls of one building went missing at a nanometre that no drawing means.
|
||||
///
|
||||
/// Loosening further buys almost nothing: a hundredth of this recovers one
|
||||
/// more face in sixty thousand, and past that the tolerance would start
|
||||
/// accepting geometry that really is wrong.
|
||||
/// ACIS topology fit tolerance.
|
||||
const TOL: f64 = 1e-6;
|
||||
|
||||
/// Tessellate an ACIS document by lifting it into the kernel.
|
||||
///
|
||||
/// `None` when nothing in the document lifts at all. The result's `complete`
|
||||
/// flag says whether every face made it; a caller with a fallback sampler
|
||||
/// uses it to decide whether to run one.
|
||||
/// Tessellate an ACIS document through the kernel.
|
||||
pub fn tessellate_sat(
|
||||
document: &SatDocument,
|
||||
name: String,
|
||||
color: [f32; 4],
|
||||
facet_res: f64,
|
||||
isolines: usize,
|
||||
) -> Option<MeshLodSet> {
|
||||
let (bodies, loss) = lift(document);
|
||||
if bodies.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// `facet_res` is a resolution multiplier, not a length — the same one
|
||||
// `scale_lod` divides the fallback sampler's chord fraction by. Using it
|
||||
// as a sag made every solid as coarse as its own boundary: at the default
|
||||
// it asked for a whole world unit of departure, which on anything smaller
|
||||
// than that means no subdivision at all, and a pipe came out with as many
|
||||
// sides as its rim had points.
|
||||
//
|
||||
// It is not applied here at all. The feature edges these faces are drawn
|
||||
// under are built once at highest detail and never scaled, so scaling the
|
||||
// faces would pull the two apart again at any setting but one.
|
||||
let _ = facet_res;
|
||||
let frac = CHORD_FRAC;
|
||||
let mut placed_bodies = Vec::with_capacity(bodies.len());
|
||||
for body in bodies {
|
||||
let source = body.provenance.source()?;
|
||||
let transform = body_transform(document, source.index() as usize).ok()?;
|
||||
let placed = if let Some((matrix, translation, scale)) = transform {
|
||||
let placement = brep::Placement {
|
||||
x_axis: [scale * matrix[0], scale * matrix[1], scale * matrix[2]],
|
||||
y_axis: [scale * matrix[3], scale * matrix[4], scale * matrix[5]],
|
||||
z_axis: [scale * matrix[6], scale * matrix[7], scale * matrix[8]],
|
||||
origin: translation,
|
||||
};
|
||||
brep::transform(&body, &placement)?
|
||||
} else {
|
||||
body
|
||||
};
|
||||
placed_bodies.push(placed);
|
||||
}
|
||||
let bodies = placed_bodies;
|
||||
let resolution = if facet_res.is_finite() && facet_res > 0.0 {
|
||||
facet_res.clamp(0.01, 10.0)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let frac = CHORD_FRAC / resolution;
|
||||
|
||||
// Positions stay f64 until `finalize_mesh` splits them into the coarse
|
||||
// and fine pair, so a solid at survey coordinates keeps its millimetres.
|
||||
let mut positions: Vec<[f64; 3]> = Vec::new();
|
||||
let mut normals: Vec<[f32; 3]> = Vec::new();
|
||||
let mut indices: Vec<u32> = Vec::new();
|
||||
let mut edges: Vec<[f64; 3]> = Vec::new();
|
||||
let mut triangle_materials = Vec::new();
|
||||
let mut triangle_colors = Vec::new();
|
||||
let mut curved_gens = Vec::new();
|
||||
let face_materials: std::collections::HashMap<i32, acadrust::Handle> = document
|
||||
.records
|
||||
.iter()
|
||||
.filter(|record| record.entity_type == "material-adesk-attrib")
|
||||
.filter_map(|record| {
|
||||
let owner = record.token_pointer(2)?.0;
|
||||
let handle = record.token(3)?.as_integer()?;
|
||||
(owner >= 0 && handle > 0)
|
||||
.then(|| (owner, acadrust::Handle::new(handle as u64)))
|
||||
})
|
||||
.collect();
|
||||
let face_colors: std::collections::HashMap<i32, [f32; 4]> = document
|
||||
.records
|
||||
.iter()
|
||||
.filter(|record| record.entity_type == "color-adesk-attrib")
|
||||
.filter_map(|record| {
|
||||
let owner = record.token_pointer(2)?.0;
|
||||
let value = record.token(3)?.as_integer()?;
|
||||
let source = if (1..=255).contains(&value) {
|
||||
acadrust::Color::from_index(value as i16)
|
||||
} else if value > 257 {
|
||||
acadrust::Color::from_true_color_value(value as i32)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let mut rgba = crate::scene::convert::tess_util::aci_to_rgba(&source);
|
||||
rgba[3] = color[3];
|
||||
Some((owner, rgba))
|
||||
})
|
||||
.collect();
|
||||
// A face the kernel holds but cannot express in its surface's own
|
||||
// parameters leaves a hole, the same as one that never lifted — so both
|
||||
// are counted before calling the mesh whole.
|
||||
let mut undrawn = 0usize;
|
||||
let tolerance = brep::mesh::TessellationTolerance::relative(frac, TOL)
|
||||
.with_isolines(isolines);
|
||||
for body in &bodies {
|
||||
// What a flat face is sampled against: it never departs from its own
|
||||
// plane, so only its boundary arcs care, and the body's own size is
|
||||
// the nearest thing to a radius they have.
|
||||
let span = body_span(body);
|
||||
for face in body.face_keys() {
|
||||
let sag = frac * face_radius(body, face).unwrap_or(span);
|
||||
let Some(mesh) = brep::mesh::face(body, face, sag, TOL) else {
|
||||
undrawn += 1;
|
||||
continue;
|
||||
};
|
||||
let tessellation = brep::mesh::tessellate(body, tolerance);
|
||||
undrawn += tessellation.missing_faces.len();
|
||||
for face in &tessellation.triangle_faces {
|
||||
let record = body
|
||||
.faces
|
||||
.get(*face)
|
||||
.and_then(|face| face.provenance.source())
|
||||
.map(|source| source.index() as i32);
|
||||
triangle_materials.push(record.and_then(|record| face_materials.get(&record).copied()));
|
||||
triangle_colors.push(record.and_then(|record| face_colors.get(&record).copied()));
|
||||
}
|
||||
curved_gens.push(CurvedGen {
|
||||
source: tessellation.silhouette_source(),
|
||||
});
|
||||
let base = positions.len() as u32;
|
||||
positions.extend_from_slice(&mesh.positions);
|
||||
positions.extend_from_slice(&tessellation.mesh.positions);
|
||||
normals.extend(
|
||||
mesh.normals
|
||||
tessellation
|
||||
.mesh
|
||||
.normals
|
||||
.iter()
|
||||
.map(|n| [n[0] as f32, n[1] as f32, n[2] as f32]),
|
||||
);
|
||||
indices.extend(
|
||||
mesh.triangles
|
||||
.iter()
|
||||
.flat_map(|t| [base + t[0] as u32, base + t[1] as u32, base + t[2] as u32]),
|
||||
);
|
||||
indices.extend(tessellation.mesh.triangles.iter().flat_map(|triangle| {
|
||||
[
|
||||
base + triangle[0] as u32,
|
||||
base + triangle[1] as u32,
|
||||
base + triangle[2] as u32,
|
||||
]
|
||||
}));
|
||||
for edge in tessellation.edges {
|
||||
for segment in edge.positions.windows(2) {
|
||||
edges.extend_from_slice(segment);
|
||||
}
|
||||
}
|
||||
for isoline in tessellation.isolines {
|
||||
for segment in isoline.positions.windows(2) {
|
||||
edges.extend_from_slice(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
if indices.is_empty() {
|
||||
|
|
@ -127,81 +158,31 @@ pub fn tessellate_sat(
|
|||
positions,
|
||||
normals,
|
||||
indices,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
triangle_materials,
|
||||
triangle_colors,
|
||||
color,
|
||||
body_transform(document),
|
||||
None,
|
||||
));
|
||||
set.curved_gens = curved_gens;
|
||||
for point in edges {
|
||||
let high = [point[0] as f32, point[1] as f32, point[2] as f32];
|
||||
set.edge_verts.push(high);
|
||||
set.edge_verts_low.push([
|
||||
(point[0] - high[0] as f64) as f32,
|
||||
(point[1] - high[1] as f64) as f32,
|
||||
(point[2] - high[2] as f64) as f32,
|
||||
]);
|
||||
}
|
||||
set.complete = loss.is_empty() && undrawn == 0;
|
||||
Some(set)
|
||||
}
|
||||
|
||||
/// The radius of the surface a face lies on, where it has one.
|
||||
///
|
||||
/// A torus is measured by its tube rather than its ring: the tube is the
|
||||
/// tighter bend, and sampling to the ring would leave the section a hexagon.
|
||||
fn face_radius(body: &brep::Body, face: brep::FaceKey) -> Option<f64> {
|
||||
let surface = body.surfaces.get(body.faces.get(face)?.surface)?;
|
||||
match surface {
|
||||
brep::Surface::Plane(_) => None,
|
||||
brep::Surface::Cylinder(cylinder) => Some(cylinder.radius),
|
||||
brep::Surface::Cone(cone) => Some(cone.radius),
|
||||
brep::Surface::Sphere(sphere) => Some(sphere.radius),
|
||||
brep::Surface::Torus(torus) => Some(torus.minor_radius),
|
||||
}
|
||||
}
|
||||
|
||||
/// How big a body is, from the corners it is built on.
|
||||
fn body_span(body: &brep::Body) -> f64 {
|
||||
let mut low = [f64::INFINITY; 3];
|
||||
let mut high = [f64::NEG_INFINITY; 3];
|
||||
for (_, vertex) in body.vertices.iter() {
|
||||
for axis in 0..3 {
|
||||
low[axis] = low[axis].min(vertex.point[axis]);
|
||||
high[axis] = high[axis].max(vertex.point[axis]);
|
||||
}
|
||||
}
|
||||
if low[0] > high[0] {
|
||||
return 1.0;
|
||||
}
|
||||
(0..3)
|
||||
.map(|axis| high[axis] - low[axis])
|
||||
.fold(0.0_f64, f64::max)
|
||||
.max(1e-9)
|
||||
}
|
||||
|
||||
/// The edges of every body in an ACIS document, as polylines.
|
||||
///
|
||||
/// What draws a solid's wireframe and what a click hit-tests against. Taken
|
||||
/// from the kernel's own curves rather than from the mesh, so a rim is a
|
||||
/// circle sampled to tolerance instead of whatever the triangulation left
|
||||
/// along it.
|
||||
///
|
||||
/// Not called yet: the solid tessellator keeps its own feature-edge pass,
|
||||
/// which also carries isolines. Here because it is the kernel's answer to the
|
||||
/// same question, and the two should converge on it.
|
||||
#[allow(dead_code)]
|
||||
pub fn edge_polylines(document: &SatDocument, sag: f64) -> Vec<Vec<[f64; 3]>> {
|
||||
let (bodies, _) = lift(document);
|
||||
let sag = if sag > 0.0 { sag } else { CHORD_FRAC };
|
||||
let placement = body_transform(document);
|
||||
bodies
|
||||
.iter()
|
||||
.flat_map(|body| brep::edge_polylines(body, sag))
|
||||
.map(|polyline| {
|
||||
polyline
|
||||
.into_iter()
|
||||
.map(|point| placed(point, placement))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A body-local point moved to where the body sits.
|
||||
///
|
||||
/// ACIS treats points as row vectors — `p' = scale·(p·M) + T` — so the stored
|
||||
/// 3×3 is indexed transposed from a column-vector multiply. Getting that the
|
||||
/// wrong way round mirrors a placed solid rather than moving it.
|
||||
#[cfg(test)]
|
||||
fn placed(point: [f64; 3], xform: Option<([f64; 9], [f64; 3], f64)>) -> [f64; 3] {
|
||||
let Some((m, translation, scale)) = xform else {
|
||||
return point;
|
||||
|
|
@ -275,17 +256,4 @@ mod tests {
|
|||
assert!(sides(CHORD_FRAC) < 96.0, "{}", sides(CHORD_FRAC));
|
||||
}
|
||||
|
||||
/// And it is the edges' own density, so the wire drawn over a face lands
|
||||
/// on the facet corners rather than cutting across them.
|
||||
#[test]
|
||||
fn a_face_is_sampled_as_finely_as_the_edges_over_it() {
|
||||
let rim = crate::scene::convert::solid3d_tess::edge_arc_segs(
|
||||
5.0,
|
||||
std::f64::consts::TAU,
|
||||
) as f64;
|
||||
let wall = sides(CHORD_FRAC);
|
||||
assert!((rim - wall).abs() <= 1.0, "rim {rim} vs wall {wall}");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,4 +8,3 @@ pub(crate) mod tess;
|
|||
pub mod proxy_graphics;
|
||||
pub mod tess_util;
|
||||
pub mod solid3d_tess;
|
||||
pub mod spline_tess;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,454 +0,0 @@
|
|||
// 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 the kernel's
|
||||
// `BSplineSurface` (the same NURBS kernel the Model tab already builds on),
|
||||
// and sample its parametric grid into triangles.
|
||||
|
||||
use acadrust::entities::acis::types::Sense;
|
||||
use acadrust::entities::acis::{
|
||||
SatCoedge, SatDocument, SatFace, SatLoop, SatPCurve, SatRecord, SatSplineSurface, SatToken,
|
||||
};
|
||||
use rustc_hash::FxHashSet;
|
||||
use acadrust::kernel::space::NurbsSurface3;
|
||||
|
||||
use crate::scene::convert::solid3d_tess::LodConfig;
|
||||
|
||||
// A `spline-surface` block is either a non-rational `nubs`, whose control
|
||||
// points are plain xyz, or a rational `nurbs`, whose carry a weight. The
|
||||
// kernel's surface holds both — weights absent means polynomial — so there is
|
||||
// nothing here to tell apart.
|
||||
|
||||
/// 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<[f64; 3]>,
|
||||
normals: &mut Vec<[f32; 3]>,
|
||||
indices: &mut Vec<u32>,
|
||||
) -> bool {
|
||||
let Some(surf_rec) = sat.resolve(face.surface()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(surface) = build_spline_surface(sat, surf_rec) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Sample over the knot domain and clip cells against ACIS pcurves when the
|
||||
// face carries a parametric trim. This preserves holes and non-rectangular
|
||||
// spline faces instead of always filling the complete UV rectangle.
|
||||
let ((u0, u1), (v0, v1)) = surface.domain();
|
||||
if !(u1 > u0) || !(v1 > v0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A B-spline patch has no single analytic radius to drive a chord-tolerance
|
||||
// count, so sample at the LOD's nominal density (its unit-circle segment
|
||||
// count). Floor 8 so a curved patch stays smooth.
|
||||
let n = crate::scene::convert::solid3d_tess::nominal_segs(lod.chord_frac).max(8);
|
||||
let (su, sv) = (n, n);
|
||||
let trim_loops = collect_trim_loops(sat, face, n);
|
||||
let reversed = matches!(face.sense(), Sense::Reversed);
|
||||
let index_start = indices.len();
|
||||
|
||||
let base = verts.len() as u32;
|
||||
for j in 0..=sv {
|
||||
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.point_at_knot(u, v);
|
||||
// A pole, or a row of coincident control points, has no plane to
|
||||
// be perpendicular to. Up is as good an answer as any there and
|
||||
// better than an invented one, since the patch has no area at
|
||||
// that point to shade.
|
||||
let mut n = surface.normal_at_knot(u, v).unwrap_or([0.0, 0.0, 1.0]);
|
||||
if reversed {
|
||||
n = [-n[0], -n[1], -n[2]];
|
||||
}
|
||||
verts.push(p);
|
||||
normals.push([n[0] as f32, n[1] as f32, n[2] as f32]);
|
||||
}
|
||||
}
|
||||
|
||||
let row = (su + 1) as u32;
|
||||
for j in 0..sv as u32 {
|
||||
for i in 0..su as u32 {
|
||||
if let Some(loops) = trim_loops.as_ref() {
|
||||
let u = u0 + (u1 - u0) * ((i as f64 + 0.5) / su as f64);
|
||||
let v = v0 + (v1 - v0) * ((j as f64 + 0.5) / sv as f64);
|
||||
if !inside_trim((u, v), loops, (u0, u1, v0, v1)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let a = base + j * row + i;
|
||||
let b = a + 1;
|
||||
let c = a + row;
|
||||
let d = c + 1;
|
||||
if reversed {
|
||||
indices.extend_from_slice(&[a, d, b, a, c, d]);
|
||||
} else {
|
||||
indices.extend_from_slice(&[a, b, d, a, d, c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
indices.len() > index_start
|
||||
}
|
||||
|
||||
/// Collect complete face-loop pcurves in UV space. Missing pcurves disable
|
||||
/// clipping for that face; a partial trim would be worse than the old full
|
||||
/// patch fallback.
|
||||
fn collect_trim_loops(
|
||||
sat: &SatDocument,
|
||||
face: &SatFace,
|
||||
segments: usize,
|
||||
) -> Option<Vec<Vec<(f64, f64)>>> {
|
||||
let mut result = Vec::new();
|
||||
let mut loop_ptr = face.first_loop();
|
||||
let mut seen_loops = FxHashSet::default();
|
||||
while !loop_ptr.is_null() && seen_loops.insert(loop_ptr.0) {
|
||||
let sat_loop = SatLoop::from_record(sat.resolve(loop_ptr)?)?;
|
||||
let first = sat_loop.first_coedge();
|
||||
let mut coedge_ptr = first;
|
||||
let mut seen_coedges = FxHashSet::default();
|
||||
let mut polygon: Vec<(f64, f64)> = Vec::new();
|
||||
while !coedge_ptr.is_null() && seen_coedges.insert(coedge_ptr.0) {
|
||||
let coedge = SatCoedge::from_record(sat.resolve(coedge_ptr)?)?;
|
||||
let pcurve = SatPCurve::from_record(sat.resolve(coedge.pcurve())?)?;
|
||||
let mut points = pcurve.sample_in(sat, segments);
|
||||
if points.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
if matches!(coedge.sense(), Sense::Reversed) {
|
||||
points.reverse();
|
||||
}
|
||||
if let Some(&last) = polygon.last() {
|
||||
let first_gap =
|
||||
(last.0 - points[0].0).powi(2) + (last.1 - points[0].1).powi(2);
|
||||
let end = points[points.len() - 1];
|
||||
let last_gap = (last.0 - end.0).powi(2) + (last.1 - end.1).powi(2);
|
||||
if last_gap < first_gap {
|
||||
points.reverse();
|
||||
}
|
||||
}
|
||||
points.pop();
|
||||
polygon.extend(points);
|
||||
coedge_ptr = coedge.next();
|
||||
if coedge_ptr == first {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if polygon.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
result.push(polygon);
|
||||
loop_ptr = sat_loop.next_loop();
|
||||
}
|
||||
if result.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn inside_trim(
|
||||
point: (f64, f64),
|
||||
loops: &[Vec<(f64, f64)>],
|
||||
domain: (f64, f64, f64, f64),
|
||||
) -> bool {
|
||||
let domain_area = ((domain.1 - domain.0) * (domain.3 - domain.2)).abs();
|
||||
let area_epsilon = domain_area.max(1.0) * 1e-10;
|
||||
let periodic_boundary = loops.iter().any(|polygon| {
|
||||
if polygon_area(polygon).abs() > area_epsilon {
|
||||
return false;
|
||||
}
|
||||
let bounds = polygon_bounds(polygon);
|
||||
let u_span = (bounds[2] - bounds[0]).abs();
|
||||
let v_span = (bounds[3] - bounds[1]).abs();
|
||||
u_span >= (domain.1 - domain.0).abs() * 0.9
|
||||
|| v_span >= (domain.3 - domain.2).abs() * 0.9
|
||||
});
|
||||
if periodic_boundary {
|
||||
return loops.iter().all(|polygon| {
|
||||
polygon_area(polygon).abs() <= area_epsilon
|
||||
|| !point_in_polygon(point, polygon)
|
||||
});
|
||||
}
|
||||
|
||||
let Some((outer_index, _)) = loops
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, polygon)| (index, polygon_area(polygon).abs()))
|
||||
.max_by(|a, b| a.1.total_cmp(&b.1))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
point_in_polygon(point, &loops[outer_index])
|
||||
&& loops
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(index, polygon)| index == outer_index || !point_in_polygon(point, polygon))
|
||||
}
|
||||
|
||||
fn polygon_bounds(polygon: &[(f64, f64)]) -> [f64; 4] {
|
||||
polygon.iter().fold(
|
||||
[
|
||||
f64::INFINITY,
|
||||
f64::INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
f64::NEG_INFINITY,
|
||||
],
|
||||
|mut bounds, &(u, v)| {
|
||||
bounds[0] = bounds[0].min(u);
|
||||
bounds[1] = bounds[1].min(v);
|
||||
bounds[2] = bounds[2].max(u);
|
||||
bounds[3] = bounds[3].max(v);
|
||||
bounds
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn polygon_area(polygon: &[(f64, f64)]) -> f64 {
|
||||
polygon
|
||||
.iter()
|
||||
.zip(polygon.iter().cycle().skip(1))
|
||||
.take(polygon.len())
|
||||
.map(|(&(ax, ay), &(bx, by))| ax * by - bx * ay)
|
||||
.sum::<f64>()
|
||||
* 0.5
|
||||
}
|
||||
|
||||
fn point_in_polygon(point: (f64, f64), polygon: &[(f64, f64)]) -> bool {
|
||||
let (x, y) = point;
|
||||
let mut inside = false;
|
||||
let mut previous = polygon[polygon.len() - 1];
|
||||
for ¤t in polygon {
|
||||
let crosses = (current.1 > y) != (previous.1 > y)
|
||||
&& x
|
||||
< (previous.0 - current.0) * (y - current.1)
|
||||
/ (previous.1 - current.1)
|
||||
+ current.0;
|
||||
if crosses {
|
||||
inside = !inside;
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Parse the `nubs` control net + knot vectors out of a `spline-surface`
|
||||
/// record's token stream into a kernel surface.
|
||||
fn build_spline_surface(sat: &SatDocument, rec: &SatRecord) -> Option<NurbsSurface3> {
|
||||
if let Some(surface) = build_decoded_spline_surface(sat, rec) {
|
||||
return Some(surface);
|
||||
}
|
||||
|
||||
let mut toks = rec.tokens.as_slice();
|
||||
if let Some(reference) = primary_subtype_reference(toks) {
|
||||
toks = sat.subtype_tokens(reference)?;
|
||||
}
|
||||
// Locate the real B-spline block. `nullbs` placeholders (for absent
|
||||
// rail/path surfaces) precede it; the actual surface is `nubs` (plain xyz
|
||||
// control points) or `nurbs` (rational — each control point carries a
|
||||
// weight, so it is stored as xyzw).
|
||||
let start = toks
|
||||
.iter()
|
||||
.rposition(|t| matches!(t, SatToken::Ident(s) if s == "nubs" || s == "nurbs"))?;
|
||||
let rational = matches!(&toks[start], SatToken::Ident(s) if s == "nurbs");
|
||||
|
||||
let mut p = start + 1;
|
||||
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 raw_u_knots = read_knot_vec(toks, &mut p, n_uknot)?;
|
||||
let raw_v_knots = read_knot_vec(toks, &mut p, n_vknot)?;
|
||||
let stride = if rational { 4 } else { 3 };
|
||||
let available = toks[p..]
|
||||
.iter()
|
||||
.take_while(|token| token.as_float().is_some())
|
||||
.count();
|
||||
let base_u = raw_u_knots.len().checked_sub(deg_u + 1)?;
|
||||
let base_v = raw_v_knots.len().checked_sub(deg_v + 1)?;
|
||||
let mut best: Option<(usize, usize, bool, bool, usize)> = None;
|
||||
for clamp_u in [false, true] {
|
||||
for clamp_v in [false, true] {
|
||||
let n_ctrl_u = base_u + usize::from(clamp_u) * 2;
|
||||
let n_ctrl_v = base_v + usize::from(clamp_v) * 2;
|
||||
if n_ctrl_u <= deg_u || n_ctrl_v <= deg_v {
|
||||
continue;
|
||||
}
|
||||
let needed = n_ctrl_u.checked_mul(n_ctrl_v)?.checked_mul(stride)?;
|
||||
if needed > available {
|
||||
continue;
|
||||
}
|
||||
let remaining = available - needed;
|
||||
if best.as_ref().is_none_or(|candidate| remaining < candidate.4) {
|
||||
best = Some((n_ctrl_u, n_ctrl_v, clamp_u, clamp_v, remaining));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((n_ctrl_u, n_ctrl_v, clamp_u, clamp_v, _)) = best else {
|
||||
if std::env::var_os("OCS_TESS_DEBUG").is_some() {
|
||||
eprintln!(
|
||||
"acis_spline_parse[{}]: no control-net match degree={deg_u}x{deg_v} raw_knots={}x{} available={available}",
|
||||
rec.index,
|
||||
raw_u_knots.len(),
|
||||
raw_v_knots.len()
|
||||
);
|
||||
}
|
||||
return None;
|
||||
};
|
||||
let u_knots = with_clamped_ends(raw_u_knots, clamp_u)?;
|
||||
let v_knots = with_clamped_ends(raw_v_knots, clamp_v)?;
|
||||
|
||||
// Control points are stored row-major with u varying fastest (a full row
|
||||
// of u control points per v step). the kernel wants `ctrl[i_u][j_v]`.
|
||||
let total = n_ctrl_u * n_ctrl_v;
|
||||
let mut flat: Vec<[f64; 3]> = Vec::with_capacity(total);
|
||||
let mut flat_weights: Vec<f64> = 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)?;
|
||||
// A rational net stores the weight alongside each point, and the
|
||||
// point itself unweighted — the kernel carries the two separately and
|
||||
// does the homogeneous multiply where it belongs.
|
||||
flat_weights.push(if rational { read_float(toks, &mut p)? } else { 1.0 });
|
||||
flat.push([x, y, z]);
|
||||
}
|
||||
let mut net = vec![Vec::with_capacity(n_ctrl_v); n_ctrl_u];
|
||||
let mut weights = vec![Vec::with_capacity(n_ctrl_v); n_ctrl_u];
|
||||
for v in 0..n_ctrl_v {
|
||||
for u in 0..n_ctrl_u {
|
||||
net[u].push(flat[v * n_ctrl_u + u]);
|
||||
weights[u].push(flat_weights[v * n_ctrl_u + u]);
|
||||
}
|
||||
}
|
||||
let surface = NurbsSurface3::new(
|
||||
deg_u,
|
||||
deg_v,
|
||||
net,
|
||||
u_knots,
|
||||
v_knots,
|
||||
rational.then_some(weights),
|
||||
);
|
||||
if surface.is_none() && std::env::var_os("OCS_TESS_DEBUG").is_some() {
|
||||
eprintln!(
|
||||
"acis_spline_parse[{}]: surface rejected degree={deg_u}x{deg_v} control={n_ctrl_u}x{n_ctrl_v}",
|
||||
rec.index
|
||||
);
|
||||
}
|
||||
surface
|
||||
}
|
||||
|
||||
fn build_decoded_spline_surface(sat: &SatDocument, rec: &SatRecord) -> Option<NurbsSurface3> {
|
||||
let spline = SatSplineSurface::from_record(rec)?;
|
||||
let decoded = spline.bspline(sat)?;
|
||||
|
||||
// Row-major with u varying fastest, which is how ACIS writes it and the
|
||||
// other way round from the net the kernel reads.
|
||||
let mut net = vec![Vec::with_capacity(decoded.control_count_v); decoded.control_count_u];
|
||||
let mut weights = vec![Vec::with_capacity(decoded.control_count_v); decoded.control_count_u];
|
||||
for v in 0..decoded.control_count_v {
|
||||
for u in 0..decoded.control_count_u {
|
||||
let point = decoded.control_points[v * decoded.control_count_u + u];
|
||||
net[u].push([point[0], point[1], point[2]]);
|
||||
weights[u].push(point[3]);
|
||||
}
|
||||
}
|
||||
NurbsSurface3::new(
|
||||
decoded.degree_u,
|
||||
decoded.degree_v,
|
||||
net,
|
||||
decoded.u_knots,
|
||||
decoded.v_knots,
|
||||
decoded.rational.then_some(weights),
|
||||
)
|
||||
}
|
||||
|
||||
/// Read `count` `(knot value, multiplicity)` pairs into an expanded raw knot
|
||||
/// vector. Some ACIS families store degree-sized ends and others already carry
|
||||
/// the complete knot vector; the control-net size decides that after both axes
|
||||
/// have been read.
|
||||
fn read_knot_vec(
|
||||
toks: &[SatToken],
|
||||
p: &mut usize,
|
||||
count: usize,
|
||||
) -> Option<Vec<f64>> {
|
||||
let mut knots: Vec<f64> = Vec::new();
|
||||
for _ in 0..count {
|
||||
let value = read_float(toks, p)?;
|
||||
let mult = read_int(toks, p)? as usize;
|
||||
for _ in 0..mult {
|
||||
knots.push(value);
|
||||
}
|
||||
}
|
||||
if knots.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(knots)
|
||||
}
|
||||
|
||||
fn with_clamped_ends(mut knots: Vec<f64>, clamp: bool) -> Option<Vec<f64>> {
|
||||
if clamp {
|
||||
let first = *knots.first()?;
|
||||
let last = *knots.last()?;
|
||||
knots.insert(0, first);
|
||||
knots.push(last);
|
||||
}
|
||||
Some(knots)
|
||||
}
|
||||
|
||||
fn primary_subtype_reference(tokens: &[SatToken]) -> Option<usize> {
|
||||
let start = tokens
|
||||
.iter()
|
||||
.position(|token| token.as_ident() == Some("{"))?;
|
||||
if tokens.get(start + 1).and_then(SatToken::as_ident) != Some("ref")
|
||||
|| tokens.get(start + 3).and_then(SatToken::as_ident) != Some("}")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
tokens
|
||||
.get(start + 2)?
|
||||
.as_integer()
|
||||
.and_then(|index| usize::try_from(index).ok())
|
||||
}
|
||||
|
||||
fn read_int(toks: &[SatToken], p: &mut usize) -> Option<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
|
||||
}
|
||||
|
|
@ -1922,7 +1922,6 @@ fn fallback_geometry(entity: &EntityType) -> Geometry {
|
|||
| EntityType::Region(_)
|
||||
| EntityType::Body(_)
|
||||
| EntityType::Surface(_) => {
|
||||
let pts = solid_wire_fallback(entity);
|
||||
let mut snap = vec![];
|
||||
if let Some(p) = crate::entities::solid3d::point_of_reference(entity) {
|
||||
snap.push((
|
||||
|
|
@ -1930,7 +1929,7 @@ fn fallback_geometry(entity: &EntityType) -> Geometry {
|
|||
SnapHint::Insertion,
|
||||
));
|
||||
}
|
||||
(pts, snap, vec![], vec![])
|
||||
(vec![], snap, vec![], vec![])
|
||||
}
|
||||
_ => {
|
||||
let s = 0.5_f64;
|
||||
|
|
@ -1939,39 +1938,6 @@ fn fallback_geometry(entity: &EntityType) -> Geometry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Extract pre-computed edge-wire points from Solid3D / Region / Body entities.
|
||||
///
|
||||
/// Some drawings store explicit wire geometry 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) -> Vec<[f64; 3]> {
|
||||
let Some(wires) = crate::entities::solid3d::fallback_wires(entity) else {
|
||||
return vec![];
|
||||
};
|
||||
if wires.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
// Fully supported ACIS → mesh pipeline draws body. Parseable-but-partial
|
||||
// ACIS keeps source display wires so unsupported faces never disappear.
|
||||
if crate::entities::solid3d::acis_has_complete_surface_support(entity) {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut pts: Vec<[f64; 3]> = Vec::new();
|
||||
for wire in wires {
|
||||
if wire.points.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for v in &wire.points {
|
||||
let transformed = crate::entities::solid3d::wire_point(wire, v);
|
||||
pts.push([transformed.x, transformed.y, transformed.z]);
|
||||
}
|
||||
// NaN sentinel separates distinct wire segments.
|
||||
pts.push([f64::NAN, f64::NAN, f64::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]);
|
||||
|
|
|
|||
146
src/scene/mod.rs
146
src/scene/mod.rs
|
|
@ -1224,104 +1224,25 @@ fn transform_block_mesh_lod_set(
|
|||
use acadrust::types::Vector3;
|
||||
let mut out = set.clone();
|
||||
out.instance_transform = Some(*xform);
|
||||
let transform_direction = |direction: [f32; 3]| {
|
||||
let transformed = xform.apply_rotation(Vector3::new(
|
||||
direction[0] as f64,
|
||||
direction[1] as f64,
|
||||
direction[2] as f64,
|
||||
));
|
||||
let length = transformed.length();
|
||||
if length > 1e-12 {
|
||||
[
|
||||
(transformed.x / length) as f32,
|
||||
(transformed.y / length) as f32,
|
||||
(transformed.z / length) as f32,
|
||||
]
|
||||
} else {
|
||||
direction
|
||||
}
|
||||
};
|
||||
let scale_x = xform.apply_rotation(Vector3::UNIT_X).length();
|
||||
let scale_y = xform.apply_rotation(Vector3::UNIT_Y).length();
|
||||
let scale_z = xform.apply_rotation(Vector3::UNIT_Z).length();
|
||||
let uniform_scale = (scale_x + scale_y + scale_z) / 3.0;
|
||||
let is_uniform = (scale_x - uniform_scale).abs() <= uniform_scale.abs().max(1.0) * 1e-8
|
||||
&& (scale_y - uniform_scale).abs() <= uniform_scale.abs().max(1.0) * 1e-8
|
||||
&& (scale_z - uniform_scale).abs() <= uniform_scale.abs().max(1.0) * 1e-8;
|
||||
if is_uniform {
|
||||
let transform_split = |high: &mut [f32; 3], low: &mut [f32; 3]| {
|
||||
let transformed = xform.apply(Vector3::new(
|
||||
high[0] as f64 + low[0] as f64,
|
||||
high[1] as f64 + low[1] as f64,
|
||||
high[2] as f64 + low[2] as f64,
|
||||
));
|
||||
*high = [
|
||||
transformed.x as f32,
|
||||
transformed.y as f32,
|
||||
transformed.z as f32,
|
||||
let origin = xform.apply(Vector3::ZERO);
|
||||
let vectors = [
|
||||
xform.apply_rotation(Vector3::UNIT_X),
|
||||
xform.apply_rotation(Vector3::UNIT_Y),
|
||||
xform.apply_rotation(Vector3::UNIT_Z),
|
||||
];
|
||||
*low = [
|
||||
(transformed.x - high[0] as f64) as f32,
|
||||
(transformed.y - high[1] as f64) as f32,
|
||||
(transformed.z - high[2] as f64) as f32,
|
||||
];
|
||||
};
|
||||
for generator in &mut out.curved_gens {
|
||||
match generator {
|
||||
crate::scene::model::mesh_model::CurvedGen::Cone {
|
||||
base,
|
||||
base_low,
|
||||
axis,
|
||||
u_dir,
|
||||
v_dir,
|
||||
radius,
|
||||
h_max,
|
||||
..
|
||||
} => {
|
||||
transform_split(base, base_low);
|
||||
*axis = transform_direction(*axis);
|
||||
*u_dir = transform_direction(*u_dir);
|
||||
*v_dir = transform_direction(*v_dir);
|
||||
*radius *= uniform_scale as f32;
|
||||
*h_max *= uniform_scale as f32;
|
||||
}
|
||||
crate::scene::model::mesh_model::CurvedGen::Sphere {
|
||||
center,
|
||||
center_low,
|
||||
pole,
|
||||
u_dir,
|
||||
v_dir,
|
||||
radius,
|
||||
..
|
||||
} => {
|
||||
transform_split(center, center_low);
|
||||
*pole = transform_direction(*pole);
|
||||
*u_dir = transform_direction(*u_dir);
|
||||
*v_dir = transform_direction(*v_dir);
|
||||
*radius *= uniform_scale as f32;
|
||||
}
|
||||
crate::scene::model::mesh_model::CurvedGen::Torus {
|
||||
center,
|
||||
center_low,
|
||||
axis,
|
||||
u_dir,
|
||||
v_dir,
|
||||
major,
|
||||
minor,
|
||||
..
|
||||
} => {
|
||||
transform_split(center, center_low);
|
||||
*axis = transform_direction(*axis);
|
||||
*u_dir = transform_direction(*u_dir);
|
||||
*v_dir = transform_direction(*v_dir);
|
||||
*major *= uniform_scale as f32;
|
||||
*minor *= uniform_scale as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.curved_gens.retain_mut(|generator| {
|
||||
let transformed = acadrust::kernel::brep::mesh::transform_silhouette_affine(
|
||||
&generator.source,
|
||||
vectors.map(|vector| [vector.x, vector.y, vector.z]),
|
||||
[origin.x, origin.y, origin.z],
|
||||
);
|
||||
if let Some(source) = transformed {
|
||||
generator.source = source;
|
||||
true
|
||||
} else {
|
||||
out.curved_gens.clear();
|
||||
false
|
||||
}
|
||||
});
|
||||
let mut min_x = f32::INFINITY;
|
||||
let mut min_y = f32::INFINITY;
|
||||
let mut max_x = f32::NEG_INFINITY;
|
||||
|
|
@ -1395,41 +1316,6 @@ fn transform_block_mesh_lod_set(
|
|||
];
|
||||
}
|
||||
}
|
||||
for silhouette in &mut out.stored_silhouettes {
|
||||
silhouette.view_direction = transform_direction(silhouette.view_direction);
|
||||
silhouette.up_vector = transform_direction(silhouette.up_vector);
|
||||
let target = xform.apply(Vector3::new(
|
||||
silhouette.target[0] as f64,
|
||||
silhouette.target[1] as f64,
|
||||
silhouette.target[2] as f64,
|
||||
));
|
||||
silhouette.target = [target.x as f32, target.y as f32, target.z as f32];
|
||||
let count = silhouette.edge_verts.len();
|
||||
if silhouette.edge_verts_low.len() != count {
|
||||
silhouette.edge_verts_low = vec![[0.0; 3]; count];
|
||||
}
|
||||
for (high, low) in silhouette
|
||||
.edge_verts
|
||||
.iter_mut()
|
||||
.zip(silhouette.edge_verts_low.iter_mut())
|
||||
{
|
||||
let transformed = xform.apply(Vector3::new(
|
||||
high[0] as f64 + low[0] as f64,
|
||||
high[1] as f64 + low[1] as f64,
|
||||
high[2] as f64 + low[2] as f64,
|
||||
));
|
||||
*high = [
|
||||
transformed.x as f32,
|
||||
transformed.y as f32,
|
||||
transformed.z as f32,
|
||||
];
|
||||
*low = [
|
||||
(transformed.x - high[0] as f64) as f32,
|
||||
(transformed.y - high[1] as f64) as f32,
|
||||
(transformed.z - high[2] as f64) as f32,
|
||||
];
|
||||
}
|
||||
}
|
||||
if min_x.is_finite() {
|
||||
out.world_aabb = [min_x, min_y, max_x, max_y];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,76 +42,10 @@ pub struct MeshModel {
|
|||
///
|
||||
/// `lods` holds up to one MeshModel per LOD level (high → low). Empty
|
||||
/// slots fall back to the nearest available LOD at render time.
|
||||
/// A curved face's generator, kept so a view-dependent silhouette (DISPSILH)
|
||||
/// can be computed per frame — the silhouette is where the surface turns away
|
||||
/// from the eye, which no baked edge can capture. World-space, post body
|
||||
/// transform; base/centre points carry a double-single low half so they stay
|
||||
/// precise at UTM scale like the mesh verts. Each variant also carries the
|
||||
/// face's parametric extent so the silhouette is clipped to the actual face
|
||||
/// rather than drawn across the whole (possibly partial) surface.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum CurvedGen {
|
||||
/// Cone / cylinder: two edge-on lines up the surface.
|
||||
Cone {
|
||||
base: [f32; 3],
|
||||
base_low: [f32; 3],
|
||||
axis: [f32; 3],
|
||||
/// Radial frame: `u` is the θ=0 direction, `v = axis × u`.
|
||||
u_dir: [f32; 3],
|
||||
v_dir: [f32; 3],
|
||||
/// Radius at the base (`h = 0`).
|
||||
radius: f32,
|
||||
/// `tan(half-angle)`: radius at height `h` is `radius + h * tan_a`.
|
||||
tan_a: f32,
|
||||
/// Height span along the axis the face covers (base is `h = 0`).
|
||||
h_max: f32,
|
||||
theta_min: f32,
|
||||
theta_span: f32,
|
||||
full: bool,
|
||||
},
|
||||
/// Sphere: the great circle perpendicular to the view, clipped to the
|
||||
/// face's longitude/colatitude window.
|
||||
Sphere {
|
||||
center: [f32; 3],
|
||||
center_low: [f32; 3],
|
||||
pole: [f32; 3],
|
||||
u_dir: [f32; 3],
|
||||
v_dir: [f32; 3],
|
||||
radius: f32,
|
||||
theta_min: f32,
|
||||
theta_span: f32,
|
||||
full: bool,
|
||||
phi_min: f32,
|
||||
phi_max: f32,
|
||||
},
|
||||
/// Torus: view-dependent tube silhouette, clipped to both parametric
|
||||
/// windows the face covers.
|
||||
Torus {
|
||||
center: [f32; 3],
|
||||
center_low: [f32; 3],
|
||||
axis: [f32; 3],
|
||||
u_dir: [f32; 3],
|
||||
v_dir: [f32; 3],
|
||||
major: f32,
|
||||
minor: f32,
|
||||
phi_min: f32,
|
||||
phi_span: f32,
|
||||
full: bool,
|
||||
theta_min: f32,
|
||||
theta_span: f32,
|
||||
theta_full: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Kernel-owned source for a view-dependent silhouette.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StoredSilhouette {
|
||||
pub viewport_id: i64,
|
||||
pub view_direction: [f32; 3],
|
||||
pub up_vector: [f32; 3],
|
||||
pub target: [f32; 3],
|
||||
pub is_perspective: bool,
|
||||
pub edge_verts: Vec<[f32; 3]>,
|
||||
pub edge_verts_low: Vec<[f32; 3]>,
|
||||
pub struct CurvedGen {
|
||||
pub source: acadrust::kernel::brep::mesh::SilhouetteSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
|
|
@ -149,13 +83,8 @@ pub struct MeshLodSet {
|
|||
pub edge_verts: Vec<[f32; 3]>,
|
||||
/// Low residual paired with `edge_verts`.
|
||||
pub edge_verts_low: Vec<[f32; 3]>,
|
||||
/// Curved-face generators for per-frame silhouette (DISPSILH). Empty for a
|
||||
/// solid with no curved faces, or when silhouettes aren't wanted.
|
||||
/// Kernel sources for per-frame silhouettes.
|
||||
pub curved_gens: Vec<CurvedGen>,
|
||||
/// View-specific silhouette caches stored in COMMON_3DSOLID. They are used
|
||||
/// when the decoded surface family cannot provide a live analytic
|
||||
/// silhouette for the current view.
|
||||
pub stored_silhouettes: Vec<StoredSilhouette>,
|
||||
/// Geometry measurements calculated once from the highest available LOD.
|
||||
/// Properties can read these without re-parsing or re-tessellating ACIS on
|
||||
/// the UI thread.
|
||||
|
|
@ -291,7 +220,6 @@ impl MeshLodSet {
|
|||
edge_verts: Vec::new(),
|
||||
edge_verts_low: Vec::new(),
|
||||
curved_gens: Vec::new(),
|
||||
stored_silhouettes: Vec::new(),
|
||||
metrics,
|
||||
world_aabb,
|
||||
z_aabb,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ const SAG: f64 = 0.05;
|
|||
/// What counts as the same point when the kernel checks a body over.
|
||||
const TOL: f64 = 1e-9;
|
||||
|
||||
fn tessellation(body: &Body) -> brep::mesh::BodyMesh {
|
||||
brep::mesh::tessellate(body, brep::mesh::TessellationTolerance::new(SAG, TOL))
|
||||
}
|
||||
|
||||
/// Axis-aligned box from its center and full extents.
|
||||
pub fn box_solid(center: [f64; 3], length: f64, width: f64, height: f64) -> Option<Body> {
|
||||
brep::make::cuboid(
|
||||
|
|
@ -143,7 +147,7 @@ fn about_origin(x: [f64; 3], y: [f64; 3], z: [f64; 3], about: [f64; 3]) -> [f64;
|
|||
|
||||
/// The box a body occupies, from its mesh.
|
||||
pub fn extent(body: &Body) -> Option<([f64; 3], [f64; 3])> {
|
||||
let mesh = brep::mesh::body(body, SAG, TOL);
|
||||
let mesh = tessellation(body).mesh;
|
||||
if mesh.positions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -165,7 +169,7 @@ pub fn extent(body: &Body) -> Option<([f64; 3], [f64; 3])> {
|
|||
/// is a set of Line entities either way. Each triangle the plane crosses
|
||||
/// contributes the one segment where it does.
|
||||
pub fn section(body: &Body, axis: usize, value: f64) -> Vec<([f64; 3], [f64; 3])> {
|
||||
let mesh = brep::mesh::body(body, SAG, TOL);
|
||||
let mesh = tessellation(body).mesh;
|
||||
let mut out = Vec::new();
|
||||
for triangle in &mesh.triangles {
|
||||
let corners: Vec<[f64; 3]> = triangle.iter().map(|i| mesh.positions[*i]).collect();
|
||||
|
|
@ -201,16 +205,15 @@ pub fn section(body: &Body, axis: usize, value: f64) -> Vec<([f64; 3], [f64; 3])
|
|||
// ── Edge extraction (pick geometry + wireframe overlay) ─────────────────────
|
||||
|
||||
/// Tessellate the solid's B-rep edges into acadrust `Wire`s. Stored on the
|
||||
/// `Solid3D`/result entity so it is click-pickable (the renderer's wire
|
||||
/// fallback draws these as a wireframe over the shaded mesh, and hit-testing
|
||||
/// uses their points).
|
||||
/// `Solid3D`/result entity for picking.
|
||||
pub fn edge_wires(body: &Body) -> Vec<acadrust::entities::Wire> {
|
||||
use acadrust::types::Vector3;
|
||||
brep::edge_polylines(body, SAG)
|
||||
tessellation(body)
|
||||
.edges
|
||||
.into_iter()
|
||||
.map(|points| {
|
||||
.map(|edge| {
|
||||
acadrust::entities::Wire::from_points(
|
||||
points
|
||||
edge.positions
|
||||
.into_iter()
|
||||
.map(|p| Vector3::new(p[0], p[1], p[2]))
|
||||
.collect(),
|
||||
|
|
@ -250,7 +253,9 @@ pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option<Body> {
|
|||
/// Tessellate a `Body` into a single-LOD `MeshLodSet` (world-space, before
|
||||
/// world_offset is applied by the caller).
|
||||
pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option<MeshLodSet> {
|
||||
let mesh = brep::mesh::body(body, SAG, TOL);
|
||||
let tessellation = tessellation(body);
|
||||
let silhouette = tessellation.silhouette_source();
|
||||
let mesh = tessellation.mesh;
|
||||
if mesh.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -278,7 +283,7 @@ pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option<MeshLodSet> {
|
|||
.iter()
|
||||
.flat_map(|t| [t[0] as u32, t[1] as u32, t[2] as u32])
|
||||
.collect();
|
||||
Some(MeshLodSet::from_single(MeshModel {
|
||||
let mut set = MeshLodSet::from_single(MeshModel {
|
||||
name: String::new(),
|
||||
verts,
|
||||
verts_low,
|
||||
|
|
@ -288,7 +293,23 @@ pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option<MeshLodSet> {
|
|||
triangle_colors: Vec::new(),
|
||||
color,
|
||||
selected: false,
|
||||
}))
|
||||
});
|
||||
for edge in tessellation.edges {
|
||||
for segment in edge.positions.windows(2) {
|
||||
for point in segment {
|
||||
let high = [point[0] as f32, point[1] as f32, point[2] as f32];
|
||||
set.edge_verts.push(high);
|
||||
set.edge_verts_low.push([
|
||||
(point[0] - high[0] as f64) as f32,
|
||||
(point[1] - high[1] as f64) as f32,
|
||||
(point[2] - high[2] as f64) as f32,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
set.complete = tessellation.missing_faces.is_empty();
|
||||
set.curved_gens.push(super::mesh_model::CurvedGen { source: silhouette });
|
||||
Some(set)
|
||||
}
|
||||
|
||||
/// The middle of a body, for a caller needing a point to turn or scale about.
|
||||
|
|
@ -296,7 +317,7 @@ pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option<MeshLodSet> {
|
|||
/// Read off the mesh rather than `body_bounds`, which refuses a face that
|
||||
/// wraps a closed surface — a sphere is one such face and has no box at all.
|
||||
pub fn centre(body: &Body) -> Option<[f64; 3]> {
|
||||
let mesh = brep::mesh::body(body, SAG, TOL);
|
||||
let mesh = tessellation(body).mesh;
|
||||
if mesh.positions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -325,7 +346,7 @@ pub fn centre(body: &Body) -> Option<[f64; 3]> {
|
|||
#[cfg(test)]
|
||||
pub fn volume(body: &Body) -> f64 {
|
||||
use acadrust::kernel::space::Vec3;
|
||||
let mesh = brep::mesh::body(body, SAG, TOL);
|
||||
let mesh = tessellation(body).mesh;
|
||||
let Some(middle) = centre(body) else {
|
||||
return 0.0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
use acadrust::kernel::brep::{self, Body};
|
||||
use acadrust::kernel::geom2d::Curve;
|
||||
use acadrust::kernel::space::{PlanarCurve, Plane, Vec3};
|
||||
use acadrust::kernel::space::{PlanarCurve, Plane};
|
||||
use acadrust::EntityType;
|
||||
|
||||
use crate::entities::curve::entity_curve;
|
||||
|
|
@ -47,7 +47,7 @@ pub fn profile_of(entity: &EntityType) -> Option<Profile> {
|
|||
// both the fewest pieces a chain may have and the fewest that leave
|
||||
// each one unambiguous about which way round it goes.
|
||||
Curve::Circle(circle) => quarters(circle.centre, circle.radius),
|
||||
other => split_evenly(other, 4),
|
||||
_ => return None,
|
||||
};
|
||||
(pieces.len() >= 3).then_some(Profile {
|
||||
plane: planar.plane,
|
||||
|
|
@ -72,21 +72,6 @@ fn quarters(centre: [f64; 2], radius: f64) -> Vec<Curve> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Any other closed curve as `count` straight pieces between points on it.
|
||||
///
|
||||
/// The honest fallback: an ellipse or a spline has no analytic sweep, so the
|
||||
/// kernel would refuse the exact form anyway. Chords at least say plainly
|
||||
/// what they are.
|
||||
fn split_evenly(curve: &Curve, count: usize) -> Vec<Curve> {
|
||||
use acadrust::kernel::geom2d::Line;
|
||||
(0..count)
|
||||
.map(|step| Curve::Line(Line {
|
||||
start: curve.point_at(step as f64 / count as f64),
|
||||
end: curve.point_at((step + 1) as f64 / count as f64),
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// EXTRUDE: drag the profile `height` along its own plane's normal.
|
||||
///
|
||||
/// `None` for a profile that does not close, encloses nothing, or holds a
|
||||
|
|
@ -123,227 +108,86 @@ pub fn revolved(
|
|||
)
|
||||
}
|
||||
|
||||
// ── SWEEP and LOFT ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Neither keeps a B-rep — both have only ever produced a mesh — so both are
|
||||
// built from point lists rather than from topology. A profile becomes the
|
||||
// points its own curve tessellates to, which is the same source EXTRUDE and
|
||||
// REVOLVE read, so a circle stays round here too.
|
||||
|
||||
use crate::entities::curve::curve_points;
|
||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||
|
||||
/// The points a profile entity traces, and whether it closes.
|
||||
fn outline(entity: &EntityType) -> Option<(Vec<[f64; 3]>, bool)> {
|
||||
let planar = entity_curve(entity)?;
|
||||
let closed = planar.curve.is_closed();
|
||||
let mut points = curve_points(&planar);
|
||||
// A closed curve tessellates back to its own start. Carrying the repeat
|
||||
// would put a zero-width quad in every strip below.
|
||||
if closed && points.len() > 1 {
|
||||
let first = points[0];
|
||||
let last = points[points.len() - 1];
|
||||
if Vec3::from(first).distance(Vec3::from(last)) < 1e-9 {
|
||||
points.pop();
|
||||
}
|
||||
}
|
||||
(points.len() >= 2).then_some((points, closed))
|
||||
}
|
||||
|
||||
/// SWEEP: drag a profile along a path.
|
||||
///
|
||||
/// The path contributes its direction and length, not its shape — which is
|
||||
/// what SWEEP has always done here, and what makes it an extrusion along an
|
||||
/// arbitrary vector rather than along a curve.
|
||||
/// SWEEP through the kernel's tolerance-driven mesh API.
|
||||
pub fn swept(profile: &EntityType, path: &EntityType, color: [f32; 4]) -> Option<MeshLodSet> {
|
||||
let (points, closed) = outline(profile)?;
|
||||
let along = {
|
||||
let track = curve_points(&entity_curve(path)?);
|
||||
let (from, to) = (Vec3::from(*track.first()?), Vec3::from(*track.last()?));
|
||||
to - from
|
||||
};
|
||||
if along.length() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let moved: Vec<[f64; 3]> = points
|
||||
.iter()
|
||||
.map(|point| (Vec3::from(*point) + along).to_array())
|
||||
.collect();
|
||||
let mut mesh = Ribbon::default();
|
||||
mesh.band(&points, &moved, closed);
|
||||
if closed {
|
||||
// An open profile sweeps into a sheet with nothing to cap.
|
||||
mesh.cap(&points, true);
|
||||
mesh.cap(&moved, false);
|
||||
}
|
||||
mesh.finish(color)
|
||||
let tolerance = crate::scene::convert::curve_tol::current_curve_tol();
|
||||
let surface = brep::mesh::sweep_surface(
|
||||
&entity_curve(profile)?,
|
||||
&entity_curve(path)?,
|
||||
tolerance,
|
||||
)?;
|
||||
mesh_set(surface, color, tolerance)
|
||||
}
|
||||
|
||||
/// LOFT: rule a surface through a run of profiles.
|
||||
///
|
||||
/// Consecutive profiles are joined by a band each, and the two ends are
|
||||
/// capped when they close. Profiles with different point counts are resampled
|
||||
/// onto the finer of the two, so a circle lofted to a square does not twist.
|
||||
/// LOFT through the kernel's tolerance-driven mesh API.
|
||||
pub fn lofted(profiles: &[EntityType], color: [f32; 4]) -> Option<MeshLodSet> {
|
||||
let sections: Vec<(Vec<[f64; 3]>, bool)> = profiles.iter().filter_map(outline).collect();
|
||||
if sections.len() < 2 {
|
||||
let curves: Vec<PlanarCurve> = profiles.iter().filter_map(entity_curve).collect();
|
||||
let tolerance = crate::scene::convert::curve_tol::current_curve_tol();
|
||||
mesh_set(brep::mesh::loft_surface(&curves, tolerance)?, color, tolerance)
|
||||
}
|
||||
|
||||
fn mesh_set(
|
||||
surface: brep::mesh::SurfaceMesh,
|
||||
color: [f32; 4],
|
||||
tolerance: f64,
|
||||
) -> Option<MeshLodSet> {
|
||||
if surface.mesh.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut mesh = Ribbon::default();
|
||||
for pair in sections.windows(2) {
|
||||
let count = pair[0].0.len().max(pair[1].0.len());
|
||||
let closed = pair[0].1 && pair[1].1;
|
||||
let lower = resampled(&pair[0].0, count, pair[0].1);
|
||||
let upper = resampled(&pair[1].0, count, pair[1].1);
|
||||
mesh.band(&lower, &upper, closed);
|
||||
let mut verts = Vec::with_capacity(surface.mesh.positions.len());
|
||||
let mut verts_low = Vec::with_capacity(surface.mesh.positions.len());
|
||||
for point in &surface.mesh.positions {
|
||||
push_point(&mut verts, &mut verts_low, *point);
|
||||
}
|
||||
if sections.first()?.1 {
|
||||
mesh.cap(§ions.first()?.0, true);
|
||||
}
|
||||
if sections.last()?.1 {
|
||||
mesh.cap(§ions.last()?.0, false);
|
||||
}
|
||||
mesh.finish(color)
|
||||
}
|
||||
|
||||
/// A ring walked in `count` even steps along its own length.
|
||||
///
|
||||
/// Even by distance rather than by index: two profiles given at different
|
||||
/// densities line up where they are, so a band between them does not twist
|
||||
/// wherever one of them happened to be sampled more finely.
|
||||
fn resampled(points: &[[f64; 3]], count: usize, closed: bool) -> Vec<[f64; 3]> {
|
||||
let mut ring: Vec<Vec3> = points.iter().map(|point| Vec3::from(*point)).collect();
|
||||
if closed {
|
||||
ring.push(ring[0]);
|
||||
}
|
||||
let mut walked = vec![0.0];
|
||||
for pair in ring.windows(2) {
|
||||
walked.push(walked[walked.len() - 1] + pair[0].distance(pair[1]));
|
||||
}
|
||||
let total = *walked.last().unwrap_or(&0.0);
|
||||
if total <= 0.0 {
|
||||
return points.to_vec();
|
||||
}
|
||||
let steps = if closed { count } else { count.max(2) - 1 };
|
||||
(0..if closed { count } else { count.max(2) })
|
||||
.map(|step| {
|
||||
let want = total * step as f64 / steps as f64;
|
||||
let at = walked
|
||||
.iter()
|
||||
.rposition(|reached| *reached <= want)
|
||||
.unwrap_or(0)
|
||||
.min(ring.len() - 2);
|
||||
let span = walked[at + 1] - walked[at];
|
||||
let along = if span > 0.0 { (want - walked[at]) / span } else { 0.0 };
|
||||
ring[at].lerp(ring[at + 1], along).to_array()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Triangles being gathered from bands and caps.
|
||||
#[derive(Default)]
|
||||
struct Ribbon {
|
||||
positions: Vec<[f64; 3]>,
|
||||
normals: Vec<[f64; 3]>,
|
||||
triangles: Vec<[u32; 3]>,
|
||||
}
|
||||
|
||||
impl Ribbon {
|
||||
/// A strip of quads between two rings of the same length.
|
||||
fn band(&mut self, lower: &[[f64; 3]], upper: &[[f64; 3]], closed: bool) {
|
||||
let count = lower.len().min(upper.len());
|
||||
if count < 2 {
|
||||
return;
|
||||
}
|
||||
let spans = if closed { count } else { count - 1 };
|
||||
for step in 0..spans {
|
||||
let next = (step + 1) % count;
|
||||
self.quad(lower[step], lower[next], upper[next], upper[step]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A flat lid over a closed ring, fanned from its middle.
|
||||
///
|
||||
/// A fan rather than a proper triangulation: a lofted section can be
|
||||
/// concave and a fan would then cover ground outside it, but every
|
||||
/// profile these commands accept is a single closed curve, and the middle
|
||||
/// of one is inside it.
|
||||
fn cap(&mut self, ring: &[[f64; 3]], downward: bool) {
|
||||
if ring.len() < 3 {
|
||||
return;
|
||||
}
|
||||
let mut middle = Vec3::new(0.0, 0.0, 0.0);
|
||||
for point in ring {
|
||||
middle = middle + Vec3::from(*point);
|
||||
}
|
||||
let middle = (middle / ring.len() as f64).to_array();
|
||||
for step in 0..ring.len() {
|
||||
let next = (step + 1) % ring.len();
|
||||
if downward {
|
||||
self.triangle(middle, ring[next], ring[step]);
|
||||
} else {
|
||||
self.triangle(middle, ring[step], ring[next]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn quad(&mut self, a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]) {
|
||||
self.triangle(a, b, c);
|
||||
self.triangle(a, c, d);
|
||||
}
|
||||
|
||||
fn triangle(&mut self, a: [f64; 3], b: [f64; 3], c: [f64; 3]) {
|
||||
let Some(normal) = (Vec3::from(b) - Vec3::from(a))
|
||||
.cross(Vec3::from(c) - Vec3::from(a))
|
||||
.normalize()
|
||||
else {
|
||||
// Collapsed: no normal, and nothing to draw.
|
||||
return;
|
||||
};
|
||||
let base = self.positions.len() as u32;
|
||||
for corner in [a, b, c] {
|
||||
self.positions.push(corner);
|
||||
self.normals.push(normal.to_array());
|
||||
}
|
||||
self.triangles.push([base, base + 1, base + 2]);
|
||||
}
|
||||
|
||||
/// The gathered triangles as the renderer's mesh, or `None` for none.
|
||||
fn finish(self, color: [f32; 4]) -> Option<MeshLodSet> {
|
||||
if self.triangles.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The renderer holds each position as a coarse float plus a fine
|
||||
// correction, so a profile at survey coordinates keeps its last
|
||||
// millimetres instead of losing them to f32.
|
||||
let mut verts = Vec::with_capacity(self.positions.len());
|
||||
let mut verts_low = Vec::with_capacity(self.positions.len());
|
||||
for point in &self.positions {
|
||||
let high = [point[0] as f32, point[1] as f32, point[2] as f32];
|
||||
verts.push(high);
|
||||
verts_low.push([
|
||||
(point[0] - high[0] as f64) as f32,
|
||||
(point[1] - high[1] as f64) as f32,
|
||||
(point[2] - high[2] as f64) as f32,
|
||||
]);
|
||||
}
|
||||
Some(MeshLodSet::from_single(MeshModel {
|
||||
let silhouette = surface.silhouette_source(tolerance);
|
||||
let mut set = MeshLodSet::from_single(MeshModel {
|
||||
name: String::new(),
|
||||
verts,
|
||||
verts_low,
|
||||
normals: self
|
||||
normals: surface
|
||||
.mesh
|
||||
.normals
|
||||
.iter()
|
||||
.map(|n| [n[0] as f32, n[1] as f32, n[2] as f32])
|
||||
.map(|normal| [normal[0] as f32, normal[1] as f32, normal[2] as f32])
|
||||
.collect(),
|
||||
indices: surface
|
||||
.mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|index| *index as u32)
|
||||
.collect(),
|
||||
indices: self.triangles.iter().flatten().copied().collect(),
|
||||
triangle_material_handles: Vec::new(),
|
||||
triangle_colors: Vec::new(),
|
||||
color,
|
||||
selected: false,
|
||||
}))
|
||||
});
|
||||
{
|
||||
let (high, low) = (&mut set.edge_verts, &mut set.edge_verts_low);
|
||||
for edge in surface.edges {
|
||||
for segment in edge.windows(2) {
|
||||
for point in segment {
|
||||
push_point(high, low, *point);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
set.curved_gens
|
||||
.push(super::mesh_model::CurvedGen { source: silhouette });
|
||||
Some(set)
|
||||
}
|
||||
|
||||
fn push_point(high: &mut Vec<[f32; 3]>, low: &mut Vec<[f32; 3]>, point: [f64; 3]) {
|
||||
let coarse = [point[0] as f32, point[1] as f32, point[2] as f32];
|
||||
high.push(coarse);
|
||||
low.push([
|
||||
(point[0] - coarse[0] as f64) as f32,
|
||||
(point[1] - coarse[1] as f64) as f32,
|
||||
(point[2] - coarse[2] as f64) as f32,
|
||||
]);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
|
|
|||
|
|
@ -698,38 +698,12 @@ impl Scene {
|
|||
translate_split(high, low);
|
||||
}
|
||||
for generator in &mut set.curved_gens {
|
||||
match generator {
|
||||
crate::scene::model::mesh_model::CurvedGen::Cone {
|
||||
base,
|
||||
base_low,
|
||||
..
|
||||
} => translate_split(base, base_low),
|
||||
crate::scene::model::mesh_model::CurvedGen::Sphere {
|
||||
center,
|
||||
center_low,
|
||||
..
|
||||
}
|
||||
| crate::scene::model::mesh_model::CurvedGen::Torus {
|
||||
center,
|
||||
center_low,
|
||||
..
|
||||
} => translate_split(center, center_low),
|
||||
}
|
||||
}
|
||||
for silhouette in &mut set.stored_silhouettes {
|
||||
silhouette.target[0] += delta[0] as f32;
|
||||
silhouette.target[1] += delta[1] as f32;
|
||||
silhouette.target[2] += delta[2] as f32;
|
||||
if silhouette.edge_verts_low.len() != silhouette.edge_verts.len() {
|
||||
silhouette.edge_verts_low =
|
||||
vec![[0.0; 3]; silhouette.edge_verts.len()];
|
||||
}
|
||||
for (high, low) in silhouette
|
||||
.edge_verts
|
||||
.iter_mut()
|
||||
.zip(silhouette.edge_verts_low.iter_mut())
|
||||
{
|
||||
translate_split(high, low);
|
||||
let placement = acadrust::kernel::brep::Placement::at(delta);
|
||||
if let Some(source) = acadrust::kernel::brep::mesh::transform_silhouette(
|
||||
&generator.source,
|
||||
&placement,
|
||||
) {
|
||||
generator.source = source;
|
||||
}
|
||||
}
|
||||
set.metrics.centroid[0] += delta[0];
|
||||
|
|
|
|||
|
|
@ -168,9 +168,7 @@ pub struct Pipeline {
|
|||
/// it back into the base set (issue #316).
|
||||
text_preview_vbuf: Option<wgpu::Buffer>,
|
||||
text_preview_vcount: u32,
|
||||
/// Per-frame DISPSILH silhouette line list — rebuilt every prepare() from
|
||||
/// the mesh sets' curved-face generators and the current view direction, so
|
||||
/// the outline tracks the camera. Reuses the mesh vertex format / pipeline.
|
||||
/// Per-frame silhouette line list from the kernel mesh and current view.
|
||||
silhouette_vbuf: Option<wgpu::Buffer>,
|
||||
silhouette_vcount: u32,
|
||||
/// Last requested render size (the full viewport rect, in pixels). The
|
||||
|
|
@ -2290,35 +2288,17 @@ impl Pipeline {
|
|||
// The math lives outside the GPU method so it can be unit-tested; see the
|
||||
// module test below.
|
||||
|
||||
/// Rebuild the per-frame DISPSILH silhouette line list from the mesh sets'
|
||||
/// curved-face generators and the current eye. For each cone/cylinder face
|
||||
/// the silhouette runs at the two angles where the surface turns edge-on to
|
||||
/// the view — `θ = φ ± acos(-tanα·(view·axis) / |view⊥|)`, which reduces to
|
||||
/// `φ ± π/2` for a cylinder. Segments are uploaded in the mesh vertex format
|
||||
/// so they draw through the existing wireframe pipeline.
|
||||
/// Rebuild view-dependent silhouette lines through the kernel.
|
||||
pub fn upload_silhouettes(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
sets: &[crate::scene::model::mesh_model::MeshLodSet],
|
||||
view_dir: glam::Vec3,
|
||||
) {
|
||||
// Silhouettes follow the view *angle* only — a single parallel direction
|
||||
// for the whole scene, not the eye-to-surface vector — so the outline
|
||||
// stays put under pan and doesn't foreshorten. This is the orthographic
|
||||
// silhouette a CAD wireframe expects.
|
||||
let view = glam::DVec3::new(view_dir.x as f64, view_dir.y as f64, view_dir.z as f64)
|
||||
.normalize_or(glam::DVec3::NEG_Z);
|
||||
use crate::scene::model::mesh_model::CurvedGen;
|
||||
use crate::scene::pipeline::mesh_gpu::MeshVertex;
|
||||
let mut verts: Vec<MeshVertex> = Vec::new();
|
||||
let d3 = |a: [f32; 3]| glam::DVec3::new(a[0] as f64, a[1] as f64, a[2] as f64);
|
||||
let lo = |c: [f32; 3], l: [f32; 3]| {
|
||||
glam::DVec3::new(
|
||||
c[0] as f64 + l[0] as f64,
|
||||
c[1] as f64 + l[1] as f64,
|
||||
c[2] as f64 + l[2] as f64,
|
||||
)
|
||||
};
|
||||
for set in sets {
|
||||
let color = set.lods.first().map(|m| m.color).unwrap_or([0.0, 0.0, 0.0, 1.0]);
|
||||
let mk = |w: glam::DVec3| -> MeshVertex {
|
||||
|
|
@ -2346,141 +2326,12 @@ impl Pipeline {
|
|||
uv_normal: [0.0; 2],
|
||||
}
|
||||
};
|
||||
for g in &set.curved_gens {
|
||||
match g {
|
||||
CurvedGen::Cone {
|
||||
base, base_low, axis, u_dir, v_dir, radius, tan_a,
|
||||
h_max, theta_min, theta_span, full,
|
||||
} => {
|
||||
let base = lo(*base, *base_low);
|
||||
let (axis, u, v) = (d3(*axis), d3(*u_dir), d3(*v_dir));
|
||||
let Some((t0, t1)) =
|
||||
silhouette_thetas(view.dot(u), view.dot(v), view.dot(axis), *tan_a as f64)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let r0 = *radius as f64;
|
||||
let r1 = *radius as f64 + *h_max as f64 * *tan_a as f64;
|
||||
for theta in [t0, t1] {
|
||||
if !full {
|
||||
let off = (theta - *theta_min as f64).rem_euclid(std::f64::consts::TAU);
|
||||
if off > *theta_span as f64 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let (c, s) = (theta.cos(), theta.sin());
|
||||
let radial = u * c + v * s;
|
||||
verts.push(mk(base + radial * r0));
|
||||
verts.push(mk(base + radial * r1 + axis * *h_max as f64));
|
||||
}
|
||||
}
|
||||
CurvedGen::Sphere {
|
||||
center, center_low, pole, u_dir, v_dir, radius,
|
||||
theta_min, theta_span, full, phi_min, phi_max,
|
||||
} => {
|
||||
let c = lo(*center, *center_low);
|
||||
let (pole, u, v) = (d3(*pole), d3(*u_dir), d3(*v_dir));
|
||||
let r = *radius as f64;
|
||||
// Great circle in the plane perpendicular to the view.
|
||||
let mut e1 = view.cross(pole);
|
||||
if e1.length_squared() < 1e-12 {
|
||||
e1 = view.cross(u);
|
||||
}
|
||||
let e1 = e1.normalize();
|
||||
let e2 = view.cross(e1).normalize();
|
||||
const N: usize = 64;
|
||||
let mut prev: Option<glam::DVec3> = None;
|
||||
for i in 0..=N {
|
||||
let a = std::f64::consts::TAU * (i as f64 / N as f64);
|
||||
let dir = e1 * a.cos() + e2 * a.sin();
|
||||
// Keep only the arc that lies on the actual face.
|
||||
// `full` is a *longitude* wrap flag: a dish cap sits
|
||||
// on the pole and so covers every longitude while
|
||||
// still ending at its seam, so the colatitude test
|
||||
// always applies. A whole ball reports phi 0..π and
|
||||
// passes it regardless.
|
||||
let phi = dir.dot(pole).clamp(-1.0, 1.0).acos();
|
||||
let in_phi =
|
||||
phi >= *phi_min as f64 && phi <= *phi_max as f64;
|
||||
let in_theta = *full || {
|
||||
let th = dir.dot(v).atan2(dir.dot(u));
|
||||
let toff = (th - *theta_min as f64).rem_euclid(std::f64::consts::TAU);
|
||||
toff <= *theta_span as f64
|
||||
};
|
||||
let on_face = in_phi && in_theta;
|
||||
let p = if on_face { Some(c + dir * r) } else { None };
|
||||
if let (Some(a), Some(b)) = (prev, p) {
|
||||
verts.push(mk(a));
|
||||
verts.push(mk(b));
|
||||
}
|
||||
prev = p;
|
||||
}
|
||||
}
|
||||
CurvedGen::Torus {
|
||||
center, center_low, axis, u_dir, v_dir, major, minor,
|
||||
phi_min, phi_span, full, theta_min, theta_span, theta_full,
|
||||
} => {
|
||||
let ctr = lo(*center, *center_low);
|
||||
let (axis, u, v) = (d3(*axis), d3(*u_dir), d3(*v_dir));
|
||||
let (major, minor) = (*major as f64, *minor as f64);
|
||||
// True silhouette: at each revolution angle the tube is a
|
||||
// circle; the two points where its normal turns edge-on
|
||||
// trace two curves around the ring. Sample the revolution
|
||||
// and connect consecutive edge-on points.
|
||||
const N: usize = 72;
|
||||
let span = if *full { std::f64::consts::TAU } else { *phi_span as f64 };
|
||||
let mut prev: [Option<glam::DVec3>; 2] = [None, None];
|
||||
for i in 0..=N {
|
||||
let phi = *phi_min as f64 + span * (i as f64 / N as f64);
|
||||
let radial = u * phi.cos() + v * phi.sin();
|
||||
let ring = ctr + radial * major;
|
||||
let (rv, av) = (radial.dot(view), axis.dot(view));
|
||||
if rv.abs() < 1e-9 && av.abs() < 1e-9 {
|
||||
prev = [None, None];
|
||||
continue;
|
||||
}
|
||||
// tube normal(θ) = radial·cosθ + axis·sinθ; ⟂ view at
|
||||
// θ = atan2(-rv, av) and +π.
|
||||
let th = (-rv).atan2(av);
|
||||
let cur = [th, th + std::f64::consts::PI];
|
||||
for k in 0..2 {
|
||||
let t = cur[k];
|
||||
let theta_offset =
|
||||
(t - *theta_min as f64).rem_euclid(std::f64::consts::TAU);
|
||||
let on_face = *theta_full || theta_offset <= *theta_span as f64;
|
||||
let p = on_face.then(|| {
|
||||
ring + (radial * t.cos() + axis * t.sin()) * minor
|
||||
});
|
||||
if let (Some(pp), Some(p)) = (prev[k], p) {
|
||||
verts.push(mk(pp));
|
||||
verts.push(mk(p));
|
||||
}
|
||||
prev[k] = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if set.curved_gens.is_empty() || !set.complete {
|
||||
let best = set.stored_silhouettes.iter().max_by(|left, right| {
|
||||
let score = |silhouette: &crate::scene::model::mesh_model::StoredSilhouette| {
|
||||
let direction = d3(silhouette.view_direction)
|
||||
.normalize_or(glam::DVec3::NEG_Z);
|
||||
direction.dot(view).abs()
|
||||
};
|
||||
score(left)
|
||||
.partial_cmp(&score(right))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
if let Some(silhouette) = best {
|
||||
for (index, high) in silhouette.edge_verts.iter().copied().enumerate() {
|
||||
let low = silhouette
|
||||
.edge_verts_low
|
||||
.get(index)
|
||||
.copied()
|
||||
.unwrap_or([0.0; 3]);
|
||||
verts.push(mk(lo(high, low)));
|
||||
}
|
||||
for generator in &set.curved_gens {
|
||||
for point in acadrust::kernel::brep::mesh::silhouette(
|
||||
&generator.source,
|
||||
[view.x, view.y, view.z],
|
||||
) {
|
||||
verts.push(mk(glam::DVec3::from_array(point)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4678,26 +4529,3 @@ impl iced::widget::shader::Pipeline for MultiPipeline {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two silhouette angles of a cone/cylinder face for a view direction,
|
||||
/// expressed in the face's `(u, v, axis)` frame via the view's components on
|
||||
/// each: `du = view·u`, `dv = view·v`, `da = view·axis`. `tan_a` is the cone
|
||||
/// taper (0 for a cylinder).
|
||||
///
|
||||
/// The outward normal is edge-on to the view where `du·cosθ + dv·sinθ =
|
||||
/// -tanα·da`, i.e. `θ = φ ± acos(-tanα·da / |view⊥|)` with `φ = atan2(dv, du)`.
|
||||
/// `None` when the view runs down the axis (no outline) or the whole cone faces
|
||||
/// toward/away (`|arg| > 1`).
|
||||
fn silhouette_thetas(du: f64, dv: f64, da: f64, tan_a: f64) -> Option<(f64, f64)> {
|
||||
let r_perp = (du * du + dv * dv).sqrt();
|
||||
if r_perp < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let arg = -tan_a * da / r_perp;
|
||||
if arg.abs() > 1.0 {
|
||||
return None;
|
||||
}
|
||||
let phi = dv.atan2(du);
|
||||
let delta = arg.acos();
|
||||
Some((phi + delta, phi - delta))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue