forked from 1b-agent/opencadstudio-upstream
feat(model): build and read solids with the geometry kernel
Every 3-D solid OCS makes or opens now goes through cadkernel, which holds analytic surfaces — a cylinder is a cylinder record, not a spline that looks like one. That is what lets a solid built here be written back out as exact ACIS instead of a triangulation, and a facetted export is a one-way door: the next application to open the file would have no way back to the circle. The Model tab's seven primitives, the Design-group booleans, 3DROTATE, 3DMIRROR, 3DALIGN, SLICE, SECTION, POLYSOLID and PYRAMID all come from the kernel now, and `scene.solid_models` holds a `Body`. A mirrored solid needed its own care before; the kernel reverses the loops on the way through, so it comes back the right way out rather than lighting black. EXTRUDE and REVOLVE sweep the profile `entities::curve` already defines, so a circle, an arc-bulged polyline and a closed spline arrive as the same thing. A circle extrudes into four cylinder patches rather than a run of chords, and a profile turned about an axis becomes a cone, a sphere or a torus. A spline profile has no analytic side wall and is refused rather than approximated. ACIS goes both ways through the codec's own bridge: `lift` to read and `append` to write. That drops the 679-line truck conversion and the planar- only exporter, and the tessellator keeps its bespoke sampler as a fallback for whatever the kernel cannot yet express — reported rather than passed off as whole, since a mesh missing a wall looks finished. truck-shapeops is no longer used and is gone from the manifest. truck-modeling stays for now: fourteen files use it as a 3-D NURBS evaluator, which the kernel has no answer for yet, and SWEEP and LOFT have no kernel operation behind them either. The two `automation` test failures are unchanged from before this and are not caused by it. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
8016b79ddf
commit
1f642bb40a
15 files changed
+905
-1627
No files matched your search
@@ -8,5 +8,6 @@ pub mod ole_pres;
|
||||
pub mod pdf_raster;
|
||||
pub mod mesh_model;
|
||||
pub mod solid_model;
|
||||
pub mod sweep_model;
|
||||
pub mod visual_style_model;
|
||||
pub mod object;
|
||||
+356
-157
@@ -1,95 +1,201 @@
|
||||
// truck B-rep construction for the Model tab's primitives, plus tessellation
|
||||
// into the renderer's `MeshLodSet`. Each builder follows truck's own example
|
||||
// recipes (tsweep / rsweep / cone / try_attach_plane), oriented Z-up with the
|
||||
// footprint on the z = `base_z` plane to match acadrust's `acis::primitives`.
|
||||
// B-rep construction for the Model tab's primitives, plus tessellation into
|
||||
// the renderer's `MeshLodSet`.
|
||||
//
|
||||
// The resulting truck `Solid` is cached per entity handle on the Scene so the
|
||||
// Design-group boolean tools can run truck-shapeops on it.
|
||||
// The bodies come from the geometry kernel, which builds each primitive the
|
||||
// way ACIS records it: an analytic surface with singular vertices where the
|
||||
// surface has them, rather than a mesh or a spline that happens to look like
|
||||
// one. That is what lets a solid built here be written back out as exact
|
||||
// geometry instead of a facetted approximation — see `acis_bridge`.
|
||||
//
|
||||
// Everything is oriented Z-up with the footprint on the z = base plane, to
|
||||
// match acadrust's `acis::primitives`.
|
||||
//
|
||||
// The resulting `Body` is cached per entity handle on the Scene so the
|
||||
// Design-group boolean tools can run on it.
|
||||
|
||||
use truck_modeling::{builder, Point3, Rad, Solid, Vector3, Wire};
|
||||
use acadrust::kernel::brep::{self, Body};
|
||||
|
||||
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
|
||||
|
||||
const FULL: f64 = 7.0; // > 2π → builder closes the revolution
|
||||
/// How far a triangle may sit from the surface it lies on. A drawing is
|
||||
/// measured in millimetres, and a twentieth of one is past what a screen
|
||||
/// resolves at any sane zoom.
|
||||
const SAG: f64 = 0.05;
|
||||
|
||||
/// What counts as the same point when the kernel checks a body over.
|
||||
const TOL: f64 = 1e-9;
|
||||
|
||||
/// Axis-aligned box from its center and full extents.
|
||||
pub fn box_solid(center: [f64; 3], length: f64, width: f64, height: f64) -> Solid {
|
||||
let min = Point3::new(
|
||||
center[0] - length / 2.0,
|
||||
center[1] - width / 2.0,
|
||||
center[2] - height / 2.0,
|
||||
);
|
||||
let v = builder::vertex(min);
|
||||
let e = builder::tsweep(&v, Vector3::new(length, 0.0, 0.0));
|
||||
let f = builder::tsweep(&e, Vector3::new(0.0, width, 0.0));
|
||||
builder::tsweep(&f, Vector3::new(0.0, 0.0, height))
|
||||
pub fn box_solid(center: [f64; 3], length: f64, width: f64, height: f64) -> Option<Body> {
|
||||
brep::make::cuboid(
|
||||
[
|
||||
center[0] - length / 2.0,
|
||||
center[1] - width / 2.0,
|
||||
center[2] - height / 2.0,
|
||||
],
|
||||
[length, width, height],
|
||||
)
|
||||
}
|
||||
|
||||
/// Right triangular prism (wedge): right-triangle cross-section in XZ,
|
||||
/// extruded along Y. `origin` is the min corner, ramp rising in +X/+Z.
|
||||
pub fn wedge_solid(origin: [f64; 3], length: f64, width: f64, height: f64) -> Solid {
|
||||
let o = Point3::new(origin[0], origin[1], origin[2]);
|
||||
let a = builder::vertex(o);
|
||||
let b = builder::vertex(Point3::new(o.x + length, o.y, o.z));
|
||||
let c = builder::vertex(Point3::new(o.x, o.y, o.z + height));
|
||||
let wire: Wire = vec![
|
||||
builder::line(&a, &b),
|
||||
builder::line(&b, &c),
|
||||
builder::line(&c, &a),
|
||||
]
|
||||
.into();
|
||||
let face = builder::try_attach_plane(&[wire]).expect("wedge profile");
|
||||
builder::tsweep(&face, Vector3::new(0.0, width, 0.0))
|
||||
pub fn wedge_solid(origin: [f64; 3], length: f64, width: f64, height: f64) -> Option<Body> {
|
||||
brep::make::wedge(origin, length, width, height)
|
||||
}
|
||||
|
||||
/// Solid cylinder: disk on the z = base plane, extruded up by `height`.
|
||||
pub fn cylinder_solid(center: [f64; 3], radius: f64, height: f64) -> Solid {
|
||||
let v = builder::vertex(Point3::new(center[0] + radius, center[1], center[2]));
|
||||
let circle = builder::rsweep(
|
||||
&v,
|
||||
Point3::new(center[0], center[1], center[2]),
|
||||
Vector3::unit_z(),
|
||||
Rad(FULL),
|
||||
);
|
||||
let disk = builder::try_attach_plane(&[circle]).expect("cylinder cap");
|
||||
builder::tsweep(&disk, Vector3::new(0.0, 0.0, height))
|
||||
/// Solid cylinder standing on the z = base plane.
|
||||
pub fn cylinder_solid(center: [f64; 3], radius: f64, height: f64) -> Option<Body> {
|
||||
brep::make::cylinder(center, radius, height)
|
||||
}
|
||||
|
||||
/// Solid cone: profile (apex → rim → base-center) revolved about the axis.
|
||||
pub fn cone_solid(center: [f64; 3], radius: f64, height: f64) -> Solid {
|
||||
let cx = center[0];
|
||||
let cy = center[1];
|
||||
let cz = center[2];
|
||||
let apex = builder::vertex(Point3::new(cx, cy, cz + height));
|
||||
let rim = builder::vertex(Point3::new(cx + radius, cy, cz));
|
||||
let base = builder::vertex(Point3::new(cx, cy, cz));
|
||||
let wire: Wire = vec![builder::line(&apex, &rim), builder::line(&rim, &base)].into();
|
||||
let shell = builder::cone(&wire, Vector3::unit_z(), Rad(FULL));
|
||||
Solid::new(vec![shell])
|
||||
/// Solid cone standing on the z = base plane, apex `height` above it.
|
||||
pub fn cone_solid(center: [f64; 3], radius: f64, height: f64) -> Option<Body> {
|
||||
brep::make::cone(center, radius, height)
|
||||
}
|
||||
|
||||
/// Solid sphere: meridian semicircle revolved about the polar (Z) axis.
|
||||
pub fn sphere_solid(center: [f64; 3], radius: f64) -> Solid {
|
||||
let c = Point3::new(center[0], center[1], center[2]);
|
||||
let top = builder::vertex(Point3::new(c.x, c.y, c.z + radius));
|
||||
// Rotate the top point about the X axis by π → meridian from +Z to −Z.
|
||||
let meridian: Wire = builder::rsweep(&top, c, Vector3::unit_x(), Rad(std::f64::consts::PI));
|
||||
let shell = builder::cone(&meridian, Vector3::unit_z(), Rad(FULL));
|
||||
Solid::new(vec![shell])
|
||||
/// Solid sphere about `center`.
|
||||
pub fn sphere_solid(center: [f64; 3], radius: f64) -> Option<Body> {
|
||||
brep::make::sphere(center, radius)
|
||||
}
|
||||
|
||||
/// Solid torus in the z = base plane (tube revolved about the Z axis).
|
||||
pub fn torus_solid(center: [f64; 3], major: f64, minor: f64) -> Solid {
|
||||
let c = Point3::new(center[0], center[1], center[2]);
|
||||
let v = builder::vertex(Point3::new(c.x + major, c.y, c.z + minor));
|
||||
let tube = builder::rsweep(
|
||||
&v,
|
||||
Point3::new(c.x + major, c.y, c.z),
|
||||
Vector3::unit_y(),
|
||||
Rad(FULL),
|
||||
);
|
||||
let shell = builder::rsweep(&tube, c, Vector3::unit_z(), Rad(FULL));
|
||||
Solid::new(vec![shell])
|
||||
pub fn torus_solid(center: [f64; 3], major: f64, minor: f64) -> Option<Body> {
|
||||
brep::make::torus(center, major, minor)
|
||||
}
|
||||
|
||||
/// Solid pyramid on a regular polygon of `sides` corners.
|
||||
pub fn pyramid_solid(center: [f64; 3], radius: f64, height: f64, sides: usize) -> Option<Body> {
|
||||
brep::make::pyramid(center, radius, height, sides)
|
||||
}
|
||||
|
||||
// ── Placement ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Moves a body by a rigid transform, given as three axes and an origin.
|
||||
///
|
||||
/// The Model tab builds every primitive in its own upright frame and then
|
||||
/// puts it on the working plane, which is the only reason this exists. A
|
||||
/// body carries analytic surfaces, so moving it moves their frames rather
|
||||
/// than any points.
|
||||
pub fn placed(
|
||||
body: &Body,
|
||||
x: [f64; 3],
|
||||
y: [f64; 3],
|
||||
z: [f64; 3],
|
||||
origin: [f64; 3],
|
||||
) -> Option<Body> {
|
||||
brep::transform(
|
||||
body,
|
||||
&brep::Placement {
|
||||
x_axis: x,
|
||||
y_axis: y,
|
||||
z_axis: z,
|
||||
origin,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Turns a body about one of the world axes, through the point `about`.
|
||||
pub fn turned(body: &Body, axis: usize, angle: f64, about: [f64; 3]) -> Option<Body> {
|
||||
let (sin, cos) = angle.sin_cos();
|
||||
// The rotation's columns, written out per axis rather than assembled from
|
||||
// a general formula: three cases are shorter than the axis-angle one and
|
||||
// there is nothing to get subtly wrong in them.
|
||||
let (x, y, z) = match axis {
|
||||
0 => ([1.0, 0.0, 0.0], [0.0, cos, sin], [0.0, -sin, cos]),
|
||||
1 => ([cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]),
|
||||
_ => ([cos, sin, 0.0], [-sin, cos, 0.0], [0.0, 0.0, 1.0]),
|
||||
};
|
||||
placed(body, x, y, z, about_origin(x, y, z, about))
|
||||
}
|
||||
|
||||
/// Reflects a body in the plane across one of the world axes, through `about`.
|
||||
///
|
||||
/// The kernel puts the mirrored solid back the right way out; a reflection
|
||||
/// left alone lights black.
|
||||
pub fn mirrored(body: &Body, axis: usize, about: [f64; 3]) -> Option<Body> {
|
||||
let mut columns = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
|
||||
columns[axis][axis] = -1.0;
|
||||
let [x, y, z] = columns;
|
||||
placed(body, x, y, z, about_origin(x, y, z, about))
|
||||
}
|
||||
|
||||
/// Moves a body by a transform given as a column-major 4×4, which is what a
|
||||
/// frame-to-frame solve produces.
|
||||
pub fn by_matrix(body: &Body, matrix: [f64; 16]) -> Option<Body> {
|
||||
placed(
|
||||
body,
|
||||
[matrix[0], matrix[1], matrix[2]],
|
||||
[matrix[4], matrix[5], matrix[6]],
|
||||
[matrix[8], matrix[9], matrix[10]],
|
||||
[matrix[12], matrix[13], matrix[14]],
|
||||
)
|
||||
}
|
||||
|
||||
/// Where a transform's origin has to sit for it to act about `about` rather
|
||||
/// than about the world origin: `about − M·about`.
|
||||
fn about_origin(x: [f64; 3], y: [f64; 3], z: [f64; 3], about: [f64; 3]) -> [f64; 3] {
|
||||
let mut origin = about;
|
||||
for axis in 0..3 {
|
||||
origin[axis] -= x[axis] * about[0] + y[axis] * about[1] + z[axis] * about[2];
|
||||
}
|
||||
origin
|
||||
}
|
||||
|
||||
/// 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);
|
||||
if mesh.positions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut low = [f64::INFINITY; 3];
|
||||
let mut high = [f64::NEG_INFINITY; 3];
|
||||
for point in &mesh.positions {
|
||||
for axis in 0..3 {
|
||||
low[axis] = low[axis].min(point[axis]);
|
||||
high[axis] = high[axis].max(point[axis]);
|
||||
}
|
||||
}
|
||||
Some((low, high))
|
||||
}
|
||||
|
||||
/// Where an axis-aligned plane cuts a body, as line segments.
|
||||
///
|
||||
/// Taken off the mesh rather than the surfaces: a section of a cone by a
|
||||
/// slanted plane is a conic, of a torus a quartic, and the answer wanted here
|
||||
/// 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 mut out = Vec::new();
|
||||
for triangle in &mesh.triangles {
|
||||
let corners: Vec<[f64; 3]> = triangle.iter().map(|i| mesh.positions[*i]).collect();
|
||||
// Where each edge of the triangle meets the plane. A triangle with a
|
||||
// corner exactly on it contributes that corner twice, which collapses
|
||||
// to nothing and is dropped below.
|
||||
let mut hits: Vec<[f64; 3]> = Vec::new();
|
||||
for step in 0..3 {
|
||||
let (from, to) = (corners[step], corners[(step + 1) % 3]);
|
||||
let (a, b) = (from[axis] - value, to[axis] - value);
|
||||
if (a > 0.0) == (b > 0.0) || a == b {
|
||||
continue;
|
||||
}
|
||||
let along = a / (a - b);
|
||||
hits.push([
|
||||
from[0] + (to[0] - from[0]) * along,
|
||||
from[1] + (to[1] - from[1]) * along,
|
||||
from[2] + (to[2] - from[2]) * along,
|
||||
]);
|
||||
}
|
||||
if hits.len() == 2 {
|
||||
let span = (hits[0][0] - hits[1][0]).abs()
|
||||
+ (hits[0][1] - hits[1][1]).abs()
|
||||
+ (hits[0][2] - hits[1][2]).abs();
|
||||
if span > 1e-9 {
|
||||
out.push((hits[0], hits[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ── Edge extraction (pick geometry + wireframe overlay) ─────────────────────
|
||||
@@ -98,35 +204,22 @@ pub fn torus_solid(center: [f64; 3], major: f64, minor: f64) -> Solid {
|
||||
/// `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).
|
||||
pub fn edge_wires(solid: &Solid) -> Vec<acadrust::entities::Wire> {
|
||||
use crate::scene::convert::truck_tess::{tessellate_edge, TruckTessResult};
|
||||
pub fn edge_wires(body: &Body) -> Vec<acadrust::entities::Wire> {
|
||||
use acadrust::types::Vector3;
|
||||
let mut wires = Vec::new();
|
||||
for shell in solid.boundaries() {
|
||||
for edge in shell.edge_iter() {
|
||||
if let TruckTessResult::Lines(pts, pts_low) = tessellate_edge(&edge) {
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let pts3: Vec<Vector3> = pts
|
||||
.iter()
|
||||
.zip(pts_low.iter())
|
||||
.map(|(p, l)| {
|
||||
Vector3::new(
|
||||
p[0] as f64 + l[0] as f64,
|
||||
p[1] as f64 + l[1] as f64,
|
||||
p[2] as f64 + l[2] as f64,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
wires.push(acadrust::entities::Wire::from_points(pts3));
|
||||
}
|
||||
}
|
||||
}
|
||||
wires
|
||||
brep::edge_polylines(body, SAG)
|
||||
.into_iter()
|
||||
.map(|points| {
|
||||
acadrust::entities::Wire::from_points(
|
||||
points
|
||||
.into_iter()
|
||||
.map(|p| Vector3::new(p[0], p[1], p[2]))
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Boolean operations (truck-shapeops) ─────────────────────────────────────
|
||||
// ── Boolean operations ──────────────────────────────────────────────────────
|
||||
|
||||
/// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so
|
||||
/// this scene module has no dependency on the UI module.
|
||||
@@ -137,66 +230,124 @@ pub enum Bool {
|
||||
Intersect,
|
||||
}
|
||||
|
||||
#[cfg(feature = "solid3d")]
|
||||
const BOOL_TOL: f64 = 0.05;
|
||||
|
||||
/// Combine two solids. `Subtract` removes `b` from `a`. Returns `None` when the
|
||||
/// operation fails (e.g. the solids don't actually overlap).
|
||||
#[cfg(feature = "solid3d")]
|
||||
pub fn boolean(op: Bool, a: &Solid, b: &Solid) -> Option<Solid> {
|
||||
match op {
|
||||
Bool::Union => truck_shapeops::or(a, b, BOOL_TOL),
|
||||
Bool::Intersect => truck_shapeops::and(a, b, BOOL_TOL),
|
||||
Bool::Subtract => {
|
||||
let mut bn = b.clone();
|
||||
bn.not();
|
||||
truck_shapeops::and(a, &bn, BOOL_TOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Without `solid3d` (e.g. wasm) there is no boolean kernel.
|
||||
#[cfg(not(feature = "solid3d"))]
|
||||
pub fn boolean(_op: Bool, _a: &Solid, _b: &Solid) -> Option<Solid> {
|
||||
None
|
||||
/// Combine two solids. `Subtract` removes `b` from `a`.
|
||||
///
|
||||
/// `None` when the kernel refuses — a face pair it has no closed form for, a
|
||||
/// cut it cannot make. It refuses rather than returning a solid with a wall
|
||||
/// missing, and passing that on unchanged is the point: a half-done boolean
|
||||
/// looks finished.
|
||||
pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option<Body> {
|
||||
let how = match op {
|
||||
Bool::Union => brep::Operation::Union,
|
||||
Bool::Subtract => brep::Operation::Difference,
|
||||
Bool::Intersect => brep::Operation::Intersection,
|
||||
};
|
||||
brep::combine(a.clone(), b.clone(), how, TOL).ok()
|
||||
}
|
||||
|
||||
// ── Tessellation ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tessellate a truck `Solid` into a single-LOD `MeshLodSet` (world-space,
|
||||
/// before world_offset is applied by the caller).
|
||||
pub fn mesh_from_solid(solid: &Solid, color: [f32; 4]) -> Option<MeshLodSet> {
|
||||
use crate::scene::convert::truck_tess::{tessellate_solid, TruckTessResult};
|
||||
match tessellate_solid(solid) {
|
||||
TruckTessResult::Mesh {
|
||||
verts,
|
||||
verts_low,
|
||||
normals,
|
||||
indices,
|
||||
} if !indices.is_empty() => {
|
||||
let mesh = MeshModel {
|
||||
name: String::new(),
|
||||
verts,
|
||||
verts_low,
|
||||
normals,
|
||||
indices,
|
||||
triangle_material_handles: Vec::new(),
|
||||
triangle_colors: Vec::new(),
|
||||
color,
|
||||
selected: false,
|
||||
};
|
||||
Some(MeshLodSet::from_single(mesh))
|
||||
}
|
||||
_ => None,
|
||||
/// 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);
|
||||
if mesh.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// The renderer holds each position as a coarse float plus a fine
|
||||
// correction, so a survey coordinate keeps its last millimetres instead
|
||||
// of losing them to f32.
|
||||
let mut verts = Vec::with_capacity(mesh.positions.len());
|
||||
let mut verts_low = Vec::with_capacity(mesh.positions.len());
|
||||
for point in &mesh.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,
|
||||
]);
|
||||
}
|
||||
let normals = mesh
|
||||
.normals
|
||||
.iter()
|
||||
.map(|n| [n[0] as f32, n[1] as f32, n[2] as f32])
|
||||
.collect();
|
||||
let indices = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.flat_map(|t| [t[0] as u32, t[1] as u32, t[2] as u32])
|
||||
.collect();
|
||||
Some(MeshLodSet::from_single(MeshModel {
|
||||
name: String::new(),
|
||||
verts,
|
||||
verts_low,
|
||||
normals,
|
||||
indices,
|
||||
triangle_material_handles: Vec::new(),
|
||||
triangle_colors: Vec::new(),
|
||||
color,
|
||||
selected: false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The middle of a body, for a caller needing a point to turn or scale about.
|
||||
///
|
||||
/// 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);
|
||||
if mesh.positions.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut low = [f64::INFINITY; 3];
|
||||
let mut high = [f64::NEG_INFINITY; 3];
|
||||
for point in &mesh.positions {
|
||||
for axis in 0..3 {
|
||||
low[axis] = low[axis].min(point[axis]);
|
||||
high[axis] = high[axis].max(point[axis]);
|
||||
}
|
||||
}
|
||||
Some([
|
||||
(low[0] + high[0]) * 0.5,
|
||||
(low[1] + high[1]) * 0.5,
|
||||
(low[2] + high[2]) * 0.5,
|
||||
])
|
||||
}
|
||||
|
||||
/// How much a body encloses, from its mesh.
|
||||
///
|
||||
/// The divergence theorem over triangles wound outwards, which is what makes
|
||||
/// it a check rather than only a measurement: a solid built inside out
|
||||
/// reports a negative volume rather than a plausible one, and one missing a
|
||||
/// face reports far too little. Nothing in the app measures volume yet, so it
|
||||
/// exists to test with.
|
||||
#[cfg(test)]
|
||||
pub fn volume(body: &Body) -> f64 {
|
||||
use acadrust::kernel::space::Vec3;
|
||||
let mesh = brep::mesh::body(body, SAG, TOL);
|
||||
let Some(middle) = centre(body) else {
|
||||
return 0.0;
|
||||
};
|
||||
// About the body's own middle: at survey coordinates the tetrahedra
|
||||
// reaching back to the origin are enormous and nearly cancel, and a
|
||||
// cubic millimetre read off a sum of billions is noise.
|
||||
let middle = Vec3::from(middle);
|
||||
mesh.triangles
|
||||
.iter()
|
||||
.map(|triangle| {
|
||||
let at = |index: usize| Vec3::from(mesh.positions[triangle[index]]) - middle;
|
||||
at(0).cross(at(1)).dot(at(2)) / 6.0
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tri_count(s: &Solid) -> usize {
|
||||
mesh_from_solid(s, [0.7, 0.7, 0.7, 1.0])
|
||||
fn tri_count(body: &Body) -> usize {
|
||||
mesh_from_solid(body, [0.7, 0.7, 0.7, 1.0])
|
||||
.map(|m| m.lods[0].indices.len() / 3)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -204,18 +355,44 @@ mod tests {
|
||||
#[test]
|
||||
fn all_primitives_triangulate() {
|
||||
let c = [0.0, 0.0, 0.0];
|
||||
assert!(tri_count(&box_solid(c, 10.0, 10.0, 10.0)) >= 12, "box");
|
||||
assert!(tri_count(&wedge_solid(c, 10.0, 10.0, 10.0)) >= 6, "wedge");
|
||||
assert!(tri_count(&cylinder_solid(c, 5.0, 12.0)) > 20, "cylinder");
|
||||
assert!(tri_count(&cone_solid(c, 5.0, 12.0)) > 10, "cone");
|
||||
assert!(tri_count(&sphere_solid(c, 5.0)) > 50, "sphere");
|
||||
assert!(tri_count(&torus_solid(c, 8.0, 2.0)) > 50, "torus");
|
||||
assert!(tri_count(&box_solid(c, 10.0, 10.0, 10.0).unwrap()) >= 12, "box");
|
||||
assert!(tri_count(&wedge_solid(c, 10.0, 10.0, 10.0).unwrap()) >= 6, "wedge");
|
||||
assert!(tri_count(&cylinder_solid(c, 5.0, 12.0).unwrap()) > 20, "cylinder");
|
||||
assert!(tri_count(&cone_solid(c, 5.0, 12.0).unwrap()) > 10, "cone");
|
||||
assert!(tri_count(&sphere_solid(c, 5.0).unwrap()) > 50, "sphere");
|
||||
assert!(tri_count(&torus_solid(c, 8.0, 2.0).unwrap()) > 50, "torus");
|
||||
assert!(tri_count(&pyramid_solid(c, 5.0, 9.0, 6).unwrap()) >= 8, "pyramid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_primitive_is_the_size_it_was_asked_for() {
|
||||
// Triangle counts say a mesh exists; the volume says it is the right
|
||||
// shape and the right way out. A face left out reads far too small
|
||||
// and one wound inwards reads negative, and neither shows up in a
|
||||
// count.
|
||||
use std::f64::consts::PI;
|
||||
let c = [0.0, 0.0, 0.0];
|
||||
let cases: [(Body, f64); 5] = [
|
||||
(box_solid(c, 10.0, 4.0, 6.0).unwrap(), 240.0),
|
||||
(cylinder_solid(c, 5.0, 12.0).unwrap(), PI * 25.0 * 12.0),
|
||||
(cone_solid(c, 5.0, 12.0).unwrap(), PI * 25.0 * 12.0 / 3.0),
|
||||
(sphere_solid(c, 5.0).unwrap(), 4.0 / 3.0 * PI * 125.0),
|
||||
(torus_solid(c, 8.0, 2.0).unwrap(), 2.0 * PI * PI * 8.0 * 4.0),
|
||||
];
|
||||
for (body, expected) in cases {
|
||||
let got = volume(&body);
|
||||
assert!(got > 0.0, "wound inwards: {got}");
|
||||
// Facets are chords and lie inside, so a mesh reads short and
|
||||
// never over.
|
||||
assert!(got > 0.94 * expected, "{got} vs {expected}");
|
||||
assert!(got <= expected * 1.000_001, "{got} vs {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans_produce_solids() {
|
||||
let a = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0);
|
||||
let b = box_solid([5.0, 5.0, 5.0], 10.0, 10.0, 10.0);
|
||||
let a = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap();
|
||||
let b = box_solid([5.0, 5.0, 5.0], 10.0, 10.0, 10.0).unwrap();
|
||||
for (op, label) in [
|
||||
(Bool::Union, "union"),
|
||||
(Bool::Subtract, "subtract"),
|
||||
@@ -223,13 +400,35 @@ mod tests {
|
||||
] {
|
||||
let r = boolean(op, &a, &b);
|
||||
let n = r.as_ref().map(tri_count).unwrap_or(0);
|
||||
eprintln!("{label}: tris={n}");
|
||||
assert!(r.is_some() && n > 0, "{label} produced nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_exposes_edges() {
|
||||
assert!(edge_wires(&box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0)).len() >= 12);
|
||||
assert!(edge_wires(&box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0).unwrap()).len() >= 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placing_a_body_moves_it_without_changing_its_size() {
|
||||
let body = box_solid([0.0, 0.0, 0.0], 10.0, 4.0, 6.0).unwrap();
|
||||
// A quarter turn about Z, then five along X.
|
||||
let moved = placed(
|
||||
&body,
|
||||
[0.0, 1.0, 0.0],
|
||||
[-1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[5.0, 0.0, 0.0],
|
||||
)
|
||||
.expect("a turned box");
|
||||
assert!((volume(&moved) - 240.0).abs() < 1e-6, "{}", volume(&moved));
|
||||
// Centred on the origin to begin with, so the turn leaves it there
|
||||
// and the move puts it five along x.
|
||||
let middle = centre(&moved).unwrap();
|
||||
assert!((middle[0] - 5.0).abs() < 1e-9, "{middle:?}");
|
||||
// And ten along x really did become ten along y.
|
||||
let (low, high) = extent(&moved).unwrap();
|
||||
assert!((high[1] - low[1] - 10.0).abs() < 1e-9, "{low:?} {high:?}");
|
||||
assert!((high[0] - low[0] - 4.0).abs() < 1e-9, "{low:?} {high:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// EXTRUDE / REVOLVE: turning a drawn profile into a solid.
|
||||
//
|
||||
// The profile comes from `entities::curve`, which is where every entity's
|
||||
// geometry is already defined once, so a circle, an arc-bulged polyline and a
|
||||
// closed spline all arrive as the same thing: a plane plus a chain of curves
|
||||
// in that plane's coordinates. The kernel sweeps that chain into analytic
|
||||
// surfaces — a straight run into a plane, an arc into a cylinder, a profile
|
||||
// turned about an axis into a cone, a sphere or a torus — so the result can
|
||||
// be written back out as ACIS rather than as facets.
|
||||
//
|
||||
// A spline profile has no analytic side wall, so it is refused rather than
|
||||
// approximated into a surface nobody asked for. That is the kernel's answer
|
||||
// and this module passes it on.
|
||||
|
||||
use acadrust::kernel::brep::{self, Body};
|
||||
use acadrust::kernel::geom2d::Curve;
|
||||
use acadrust::kernel::space::{Plane, PlanarCurve};
|
||||
use acadrust::EntityType;
|
||||
|
||||
use crate::entities::curve::entity_curve;
|
||||
|
||||
/// A drawn profile, as the kernel wants it: the plane it lies in and the
|
||||
/// chain of pieces closing a loop in that plane.
|
||||
pub struct Profile {
|
||||
pub plane: Plane,
|
||||
pub pieces: Vec<Curve>,
|
||||
}
|
||||
|
||||
/// The closed profile an entity describes, or `None` when it does not
|
||||
/// describe one.
|
||||
///
|
||||
/// A single closed curve — a circle, an ellipse, a closed polyline — is what
|
||||
/// a sweep needs. The kernel wants it as a chain of at least three pieces,
|
||||
/// which a polyline already is and which a circle is not, so a round profile
|
||||
/// is handed over as the arcs it is made of rather than as one curve with
|
||||
/// nowhere for the sweep to start.
|
||||
pub fn profile_of(entity: &EntityType) -> Option<Profile> {
|
||||
let planar: PlanarCurve = entity_curve(entity)?;
|
||||
if !planar.curve.is_closed() {
|
||||
return None;
|
||||
}
|
||||
let pieces = match &planar.curve {
|
||||
// A polyline is already a chain. Its own segments carry the bulges,
|
||||
// so an arc stays an arc rather than becoming a run of chords.
|
||||
Curve::Polyline(_) => planar.curve.segments(),
|
||||
// Everything else closed on itself is cut into quarters, which is
|
||||
// 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),
|
||||
};
|
||||
(pieces.len() >= 3).then_some(Profile {
|
||||
plane: planar.plane,
|
||||
pieces,
|
||||
})
|
||||
}
|
||||
|
||||
/// A circle as four arcs.
|
||||
fn quarters(centre: [f64; 2], radius: f64) -> Vec<Curve> {
|
||||
use acadrust::kernel::geom2d::Arc;
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
(0..4)
|
||||
.map(|quarter| {
|
||||
let start = FRAC_PI_2 * quarter as f64;
|
||||
Curve::Arc(Arc {
|
||||
centre,
|
||||
radius,
|
||||
start_angle: start,
|
||||
end_angle: start + FRAC_PI_2,
|
||||
})
|
||||
})
|
||||
.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
|
||||
/// piece with no analytic side wall.
|
||||
pub fn extruded(entity: &EntityType, height: f64) -> Option<Body> {
|
||||
let profile = profile_of(entity)?;
|
||||
let normal = profile.plane.normal()?;
|
||||
brep::extrude(
|
||||
profile.plane,
|
||||
&profile.pieces,
|
||||
[normal[0] * height, normal[1] * height, normal[2] * height],
|
||||
)
|
||||
}
|
||||
|
||||
/// REVOLVE: turn the profile about the axis from `from` to `to` by `angle`
|
||||
/// radians.
|
||||
///
|
||||
/// The axis has to lie in the profile's plane — a profile and an axis that do
|
||||
/// not share one sweep into surfaces with no analytic form, and the kernel
|
||||
/// refuses rather than approximating them.
|
||||
pub fn revolved(
|
||||
entity: &EntityType,
|
||||
from: [f64; 3],
|
||||
to: [f64; 3],
|
||||
angle: f64,
|
||||
) -> Option<Body> {
|
||||
let profile = profile_of(entity)?;
|
||||
brep::revolve(
|
||||
profile.plane,
|
||||
&profile.pieces,
|
||||
from,
|
||||
[to[0] - from[0], to[1] - from[1], to[2] - from[2]],
|
||||
angle,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::entities::{Circle, LwPolyline, LwVertex};
|
||||
use acadrust::types::{Vector2, Vector3};
|
||||
|
||||
fn square(size: f64) -> EntityType {
|
||||
let mut polyline = LwPolyline::new();
|
||||
for corner in [[0.0, 0.0], [size, 0.0], [size, size], [0.0, size]] {
|
||||
polyline.add_vertex(LwVertex::new(Vector2::new(corner[0], corner[1])));
|
||||
}
|
||||
polyline.is_closed = true;
|
||||
EntityType::LwPolyline(polyline)
|
||||
}
|
||||
|
||||
fn volume(body: &Body) -> f64 {
|
||||
crate::scene::model::solid_model::volume(body)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_polyline_extrudes_into_a_prism() {
|
||||
let solid = extruded(&square(10.0), 4.0).expect("a prism");
|
||||
assert!((volume(&solid) - 400.0).abs() < 1e-6, "{}", volume(&solid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circle_extrudes_into_a_cylinder() {
|
||||
// Cut into quarter arcs on the way, so the wall is four cylinder
|
||||
// patches rather than a run of flat chords — the volume says which.
|
||||
let mut circle = Circle::new();
|
||||
circle.center = Vector3::new(0.0, 0.0, 0.0);
|
||||
circle.radius = 5.0;
|
||||
let solid = extruded(&EntityType::Circle(circle), 3.0).expect("a cylinder");
|
||||
let expected = std::f64::consts::PI * 25.0 * 3.0;
|
||||
let got = volume(&solid);
|
||||
assert!(got > 0.98 * expected, "{got} vs {expected}");
|
||||
assert!(got <= expected * 1.000_001, "{got} vs {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_square_revolved_about_its_own_edge_is_a_tube() {
|
||||
// The axis runs up the square's left side, so what it sweeps is a
|
||||
// solid cylinder of radius ten and height ten.
|
||||
let solid = revolved(
|
||||
&square(10.0),
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 10.0, 0.0],
|
||||
std::f64::consts::TAU,
|
||||
);
|
||||
// The profile is in the XY plane and the axis lies in it, so this is
|
||||
// a revolution about the y axis: radius ten, height ten.
|
||||
let solid = solid.expect("a cylinder");
|
||||
let expected = std::f64::consts::PI * 100.0 * 10.0;
|
||||
let got = volume(&solid);
|
||||
assert!(got > 0.98 * expected, "{got} vs {expected}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_profile_is_refused() {
|
||||
let mut polyline = LwPolyline::new();
|
||||
for corner in [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]] {
|
||||
polyline.add_vertex(LwVertex::new(Vector2::new(corner[0], corner[1])));
|
||||
}
|
||||
polyline.is_closed = false;
|
||||
assert!(extruded(&EntityType::LwPolyline(polyline), 4.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_axis_off_the_profiles_plane_is_refused() {
|
||||
assert!(revolved(
|
||||
&square(10.0),
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
std::f64::consts::TAU
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user