cad-editor/src/scene/model/solid_model.rs
2026-08-10 15:03:31 +03:00

462 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// B-rep construction for the Model tab's primitives, plus tessellation into
// the renderer's `MeshLodSet`.
//
// 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 cadkernel::brep::{self, Body};
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
/// 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(
cadkernel::tessellation::DEFAULT_ANGLE,
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(
[
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) -> Option<Body> {
brep::make::wedge(origin, length, width, 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 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 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) -> 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 = tessellation(body).mesh;
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 = 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();
// 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) ─────────────────────
/// Tessellate the solid's B-rep edges into acadrust `Wire`s. Stored on the
/// `Solid3D`/result entity for picking.
pub fn edge_wires(body: &Body) -> Vec<acadrust::entities::Wire> {
use acadrust::types::Vector3;
tessellation(body)
.edges
.into_iter()
.map(|edge| {
acadrust::entities::Wire::from_points(
edge.positions
.into_iter()
.map(|p| Vector3::new(p[0], p[1], p[2]))
.collect(),
)
})
.collect()
}
// ── 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.
#[derive(Clone, Copy)]
pub enum Bool {
Union,
Subtract,
Intersect,
}
/// 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 `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 tessellation = tessellation(body);
let silhouette = tessellation.silhouette_source();
let mesh = tessellation.mesh;
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();
let mut set = MeshLodSet::from_single(MeshModel {
name: String::new(),
verts,
verts_low,
normals,
indices,
triangle_material_handles: Vec::new(),
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.
///
/// 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 = tessellation(body).mesh;
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 cadkernel::space::Vec3;
let mesh = tessellation(body).mesh;
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(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)
}
#[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).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}");
// Close either way, rather than short and never over. A chord does
// lie inside the surface it spans, so a convex solid can only read
// short — but a torus is not convex, and across the inside of its
// tube the chords fall outside the material and add a little. What
// is being checked is that the mesh is the shape asked for, and a
// per cent covers both.
assert!(
(got - expected).abs() < 0.01 * expected,
"{got} vs {expected}"
);
}
}
#[test]
fn booleans_produce_solids() {
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"),
(Bool::Intersect, "intersect"),
] {
let r = boolean(op, &a, &b);
let n = r.as_ref().map(tri_count).unwrap_or(0);
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).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:?}");
}
}