feat(model): add exact solid modelling tools

This commit is contained in:
Hakan Seven committed 2026-08-23 22:53:31 +03:00
1 parent fb2ea1e831
commit 06cd0ec6a9
51 files changed
+1487 -574

No files matched your search

+4 -74
View File
@@ -1,11 +1,9 @@
use acadrust::entities::{EmbeddedEntity, Solid3D};
use acadrust::entities::Solid3D;
use acadrust::objects::{
SolidHistoryBox, SolidHistoryBrep, SolidHistoryCylinder, SolidHistoryLoft,
SolidHistoryBox, SolidHistoryBrep, SolidHistoryCylinder,
SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid,
SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus,
SolidHistorySphere, SolidHistoryTorus,
};
use acadrust::types::{Vector2, Vector3};
use acadrust::EntityType;
use cadkernel::brep::Body;
use crate::command::EntityTransform;
@@ -474,21 +472,6 @@ fn base(transform: [f64; 16]) -> SolidHistoryNodeBase {
base
}
fn embedded(entity: &EntityType) -> Option<EmbeddedEntity> {
Some(match entity {
EntityType::Point(value) => EmbeddedEntity::Point(value.clone()),
EntityType::Line(value) => EmbeddedEntity::Line(value.clone()),
EntityType::Arc(value) => EmbeddedEntity::Arc(value.clone()),
EntityType::Circle(value) => EmbeddedEntity::Circle(value.clone()),
EntityType::Ellipse(value) => EmbeddedEntity::Ellipse(value.clone()),
EntityType::Spline(value) => EmbeddedEntity::Spline(value.clone()),
EntityType::LwPolyline(value) => EmbeddedEntity::LwPolyline(value.clone()),
EntityType::Ray(value) => EmbeddedEntity::Ray(value.clone()),
EntityType::XLine(value) => EmbeddedEntity::XLine(value.clone()),
_ => return None,
})
}
pub fn box_op(
transform: [f64; 16],
length: f64,
@@ -593,7 +576,7 @@ pub fn pyramid_op(
}
pub fn brep_op(body: &Body) -> SolidHistoryOperation {
let acis_data = crate::scene::convert::acis_export::planar_solid_to_sat(body)
let acis_data = crate::scene::convert::acis_export::solid_to_sat(body)
.map(|document| {
let mut solid = Solid3D::new();
solid.set_sat_document(&document);
@@ -607,56 +590,3 @@ pub fn brep_op(body: &Body) -> SolidHistoryOperation {
..SolidHistoryBrep::default()
})
}
pub fn extrusion_op(profile: &EntityType, height: f64) -> SolidHistoryOperation {
SolidHistoryOperation::Extrusion(SolidHistorySweep {
base: base(glam::DMat4::IDENTITY.to_cols_array()),
operation_major: 1,
direction: Vector3::new(0.0, 0.0, height),
sweep_entity: embedded(profile),
scale_factor: 1.0,
sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(),
path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(),
..SolidHistorySweep::default()
})
}
pub fn sweep_op(profile: &EntityType, path: &EntityType) -> SolidHistoryOperation {
SolidHistoryOperation::Sweep(SolidHistorySweep {
base: base(glam::DMat4::IDENTITY.to_cols_array()),
operation_major: 1,
sweep_entity: embedded(profile),
path_entity: embedded(path),
scale_factor: 1.0,
sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(),
path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(),
..SolidHistorySweep::default()
})
}
pub fn loft_op(profiles: &[EntityType]) -> SolidHistoryOperation {
SolidHistoryOperation::Loft(SolidHistoryLoft {
base: base(glam::DMat4::IDENTITY.to_cols_array()),
operation_major: 1,
cross_sections: profiles.iter().filter_map(embedded).collect(),
..SolidHistoryLoft::default()
})
}
pub fn revolve_op(
profile: &EntityType,
axis_start: [f64; 3],
axis_end: [f64; 3],
angle: f64,
) -> SolidHistoryOperation {
let direction = glam::DVec3::from_array(axis_end) - glam::DVec3::from_array(axis_start);
SolidHistoryOperation::Revolve(SolidHistoryRevolve {
base: base(glam::DMat4::IDENTITY.to_cols_array()),
operation_major: 1,
axis_point: Vector3::new(axis_start[0], axis_start[1], axis_start[2]),
direction: Vector2::new(direction.x, direction.y),
revolve_angle: angle,
sweep_entity: embedded(profile),
..SolidHistoryRevolve::default()
})
}
+63 -22
View File
@@ -1,19 +1,6 @@
// 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.
// Kernel B-rep construction and display tessellation.
use cadkernel::brep::{self, Body};
use cadkernel::brep::{self, Body, Curve3, EdgeKey, FaceKey, Surface};
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
@@ -223,6 +210,65 @@ pub fn edge_wires(body: &Body) -> Vec<acadrust::entities::Wire> {
.collect()
}
/// B-rep edge nearest a world-space surface pick.
pub fn nearest_edge(body: &Body, pick: [f64; 3]) -> Option<EdgeKey> {
let pick = cadkernel::space::Vec3::from(pick);
body.edge_keys()
.filter_map(|key| {
let edge = body.edges.get(key)?;
let curve = body.curves.get(edge.curve)?;
let nearest = if matches!(curve, Curve3::Line(_)) {
let start = cadkernel::space::Vec3::from(curve.point_at(edge.start_parameter));
let end = cadkernel::space::Vec3::from(curve.point_at(edge.end_parameter));
let span = end - start;
let length2 = span.dot(span);
let along = if length2 > 0.0 {
(pick - start).dot(span) / length2
} else {
0.0
}
.clamp(0.0, 1.0);
pick.distance(start + span * along)
} else {
(0..=64)
.map(|step| {
let t = edge.start_parameter
+ (edge.end_parameter - edge.start_parameter) * step as f64 / 64.0;
pick.distance(cadkernel::space::Vec3::from(curve.point_at(t)))
})
.fold(f64::INFINITY, f64::min)
};
Some((key, nearest))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(key, _)| key)
}
/// Planar face nearest a world-space surface pick.
pub fn nearest_planar_face(body: &Body, pick: [f64; 3]) -> Option<FaceKey> {
let face = body
.face_keys()
.filter_map(|key| {
let face = body.faces.get(key)?;
let surface = body.surfaces.get(face.surface)?;
Some((key, surface.distance_to(pick).abs()))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(key, _)| key)?;
let node = body.faces.get(face)?;
matches!(body.surfaces.get(node.surface)?, Surface::Plane(_)).then_some(face)
}
/// Outward normal of a planar face.
pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> {
let face = body.faces.get(face)?;
let Surface::Plane(plane) = body.surfaces.get(face.surface)? else {
return None;
};
let normal = cadkernel::space::Vec3::from(plane.normal()?);
Some(if face.forward { normal } else { -normal }.to_array())
}
// ── Boolean operations ──────────────────────────────────────────────────────
/// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so
@@ -246,17 +292,12 @@ pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option<Body> {
Bool::Subtract => brep::Operation::Difference,
Bool::Intersect => brep::Operation::Intersection,
};
brep::combine(a.clone(), b.clone(), how, TOL).ok()
let tolerance = brep::operation_tolerance(&[a, b]);
brep::combine(a.clone(), b.clone(), how, tolerance).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> {
mesh_from_tessellation(tessellation(body), color)
}
fn mesh_from_tessellation(
tessellation: brep::mesh::BodyMesh,
color: [f32; 4],
+221 -89
View File
@@ -1,20 +1,8 @@
// 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.
// Exact profile sweeps stored as kernel B-reps and ACIS.
use cadkernel::brep::{self, Body};
use cadkernel::geom2d::Curve;
use cadkernel::space::{PlanarCurve, Plane};
use cadkernel::geom2d::{offset_polyline, Arc, Curve, EllipseArc, Line, Polyline};
use cadkernel::space::{PlanarCurve, Plane, Vec3};
use acadrust::EntityType;
use crate::entities::curve::entity_curve;
@@ -40,13 +28,17 @@ pub fn profile_of(entity: &EntityType) -> Option<Profile> {
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),
Curve::Arc(arc) => split_arc(*arc),
Curve::Ellipse(arc) => split_ellipse(*arc),
Curve::Nurbs(curve) => (0..4)
.map(|part| {
curve
.trimmed(part as f64 / 4.0, (part + 1) as f64 / 4.0)
.map(Curve::Nurbs)
})
.collect::<Option<Vec<_>>>()?,
_ => return None,
};
(pieces.len() >= 3).then_some(Profile {
@@ -57,7 +49,6 @@ pub fn profile_of(entity: &EntityType) -> Option<Profile> {
/// A circle as four arcs.
fn quarters(centre: [f64; 2], radius: f64) -> Vec<Curve> {
use cadkernel::geom2d::Arc;
use std::f64::consts::FRAC_PI_2;
(0..4)
.map(|quarter| {
@@ -72,6 +63,34 @@ fn quarters(centre: [f64; 2], radius: f64) -> Vec<Curve> {
.collect()
}
fn split_arc(arc: Arc) -> Vec<Curve> {
let start = arc.start_angle;
let step = arc.sweep() / 4.0;
(0..4)
.map(|part| {
Curve::Arc(Arc {
start_angle: start + step * part as f64,
end_angle: start + step * (part + 1) as f64,
..arc
})
})
.collect()
}
fn split_ellipse(arc: EllipseArc) -> Vec<Curve> {
let start = arc.start_parameter;
let step = arc.sweep() / 4.0;
(0..4)
.map(|part| {
Curve::Ellipse(EllipseArc {
ellipse: arc.ellipse,
start_parameter: start + step * part as f64,
end_parameter: start + step * (part + 1) 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
@@ -86,6 +105,13 @@ pub fn extruded(entity: &EntityType, height: f64) -> Option<Body> {
)
}
/// Signed distance of a drag along a profile's normal.
pub fn projected_drag(entity: &EntityType, from: glam::DVec3, to: glam::DVec3) -> Option<f64> {
let normal = entity_curve(entity)?.plane.normal()?;
let distance = (to - from).dot(glam::DVec3::from_array(normal));
(distance.is_finite() && distance.abs() > 1e-6).then_some(distance)
}
/// REVOLVE: turn the profile about the axis from `from` to `to` by `angle`
/// radians.
///
@@ -108,85 +134,191 @@ pub fn revolved(
)
}
use crate::scene::model::mesh_model::{MeshLodSet, MeshModel};
/// SWEEP through the kernel's tolerance-driven mesh API.
pub fn swept(profile: &EntityType, path: &EntityType, color: [f32; 4]) -> Option<MeshLodSet> {
let max_angle = cadkernel::tessellation::DEFAULT_ANGLE;
let surface = brep::mesh::sweep_surface(
&entity_curve(profile)?,
&entity_curve(path)?,
max_angle,
)?;
mesh_set(surface, color, 1e-9)
/// SWEEP as an exact B-rep along straight and circular path pieces.
pub fn swept(profile: &EntityType, path: &EntityType) -> Option<Body> {
let profile = profile_of(profile)?;
let path = entity_curve(path)?;
let pieces = match &path.curve {
Curve::Line(line) => vec![Curve::Line(*line)],
Curve::Arc(arc) => vec![Curve::Arc(*arc)],
Curve::Polyline(_) => path.curve.segments(),
_ => return None,
};
brep::sweep_along(profile.plane, &profile.pieces, path.plane, &pieces)
}
/// LOFT through the kernel's tolerance-driven mesh API.
pub fn lofted(profiles: &[EntityType], color: [f32; 4]) -> Option<MeshLodSet> {
let curves: Vec<PlanarCurve> = profiles.iter().filter_map(entity_curve).collect();
let max_angle = cadkernel::tessellation::DEFAULT_ANGLE;
mesh_set(brep::mesh::loft_surface(&curves, max_angle)?, color, 1e-9)
/// LOFT as an exact B-rep through compatible closed sections.
pub fn lofted(profiles: &[EntityType]) -> Option<Body> {
if let Some(body) = circular_loft(profiles) {
return Some(body);
}
let sections = profiles
.iter()
.map(|entity| {
let profile = profile_of(entity)?;
Some((profile.plane, profile.pieces))
})
.collect::<Option<Vec<_>>>()?;
brep::loft(&sections)
}
fn mesh_set(
surface: brep::mesh::SurfaceMesh,
color: [f32; 4],
precision: f64,
) -> Option<MeshLodSet> {
if surface.mesh.is_empty() {
/// Builds an exact wall solid around a polyline centreline.
pub fn polysolid(entity: &EntityType, width: f64, height: f64) -> Option<Body> {
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height.abs() <= 1e-12 {
return None;
}
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);
let planar = entity_curve(entity)?;
let Curve::Polyline(source) = planar.curve else {
return None;
};
let (left_pick, right_pick) = offset_picks(&source, width)?;
let mut left = offset_polyline(&source, width * 0.5, left_pick);
let mut right = offset_polyline(&source, width * 0.5, right_pick);
if left.len() != 1 || right.len() != 1 {
return None;
}
let silhouette = surface.silhouette_source(precision);
let mut set = MeshLodSet::from_single(MeshModel {
name: String::new(),
verts,
verts_low,
normals: surface
.mesh
.normals
.iter()
.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(),
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);
}
}
let left = left.remove(0);
let right = right.remove(0);
let normal = Vec3::from(planar.plane.normal()?);
let direction = (normal * height).to_array();
if source.closed {
if !left.closed || !right.closed {
return None;
}
let mut profiles = [left, right]
.into_iter()
.map(|polyline| {
let area = Curve::Polyline(polyline.clone()).enclosed_area().abs();
(area, Curve::Polyline(polyline).segments())
})
.collect::<Vec<_>>();
profiles.sort_by(|first, second| second.0.total_cmp(&first.0));
if profiles[0].0 <= profiles[1].0 || profiles[1].0 <= 1e-12 {
return None;
}
return brep::extrude_region(
planar.plane,
&[profiles.remove(0).1, profiles.remove(0).1],
direction,
);
}
set.curved_gens
.push(super::mesh_model::CurvedGen { source: silhouette });
Some(set)
if left.closed || right.closed {
return None;
}
let left_start = left.vertices.first()?.position;
let left_end = left.vertices.last()?.position;
let right_start = right.vertices.first()?.position;
let right_end = right.vertices.last()?.position;
let mut outline = Curve::Polyline(left).segments();
outline.push(Curve::Line(Line {
start: left_end,
end: right_end,
}));
let mut back = Curve::Polyline(right).segments();
back.reverse();
outline.extend(back);
outline.push(Curve::Line(Line {
start: right_start,
end: left_start,
}));
brep::extrude(planar.plane, &outline, direction)
}
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,
]);
fn offset_picks(polyline: &Polyline, width: f64) -> Option<([f64; 2], [f64; 2])> {
let start = polyline.vertices.first()?.position;
let next = polyline.vertices.get(1)?.position;
let along = if let Some(arc) = polyline.segment_arc(0) {
let near = arc.sample(1e-6);
[near[0] - start[0], near[1] - start[1]]
} else {
[next[0] - start[0], next[1] - start[1]]
};
let length = along[0].hypot(along[1]);
if length <= 1e-12 {
return None;
}
let side = [-along[1] / length * width, along[0] / length * width];
Some((
[start[0] + side[0], start[1] + side[1]],
[start[0] - side[0], start[1] - side[1]],
))
}
fn circular_loft(profiles: &[EntityType]) -> Option<Body> {
if profiles.len() < 2 {
return None;
}
let sections = profiles
.iter()
.map(|entity| {
let planar = entity_curve(entity)?;
let Curve::Circle(circle) = planar.curve else {
return None;
};
Some((
Vec3::from(planar.plane.point_at(circle.centre)),
circle.radius,
Vec3::from(planar.plane.normal()?),
Vec3::from(planar.plane.x_axis),
))
})
.collect::<Option<Vec<_>>>()?;
let first = sections.first()?;
let direction = (sections.last()?.0 - first.0).normalize()?;
let scale = sections
.iter()
.map(|(centre, radius, _, _)| centre.length().max(*radius))
.fold(1.0_f64, f64::max);
let tolerance = scale * 1e-9;
let mut heights = Vec::with_capacity(sections.len());
for (centre, radius, normal, _) in &sections {
if !radius.is_finite()
|| *radius <= tolerance
|| normal.dot(direction).abs() < 1.0 - 1e-9
{
return None;
}
let offset = *centre - first.0;
if (offset - direction * offset.dot(direction)).length() > tolerance {
return None;
}
heights.push(offset.dot(direction));
}
if heights
.windows(2)
.any(|pair| pair[1] <= pair[0] + tolerance)
{
return None;
}
let radial_seed = first.3 - direction * first.3.dot(direction);
let radial = radial_seed.normalize()?;
let profile_plane = Plane::from_axes(first.0.to_array(), radial.to_array(), direction.to_array());
let mut points = Vec::with_capacity(sections.len() + 2);
points.push([0.0, 0.0]);
points.extend(
sections
.iter()
.zip(&heights)
.map(|((_, radius, _, _), height)| [*radius, *height]),
);
points.push([0.0, *heights.last()?]);
let profile = (0..points.len())
.map(|index| {
Curve::Line(Line {
start: points[index],
end: points[(index + 1) % points.len()],
})
})
.collect::<Vec<_>>();
brep::revolve(
profile_plane,
&profile,
first.0.to_array(),
direction.to_array(),
std::f64::consts::TAU,
)
}
#[cfg(test)]