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 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-08-10 00:32:34 +03:00
1 parent 8016b79ddf
commit 1f642bb40a
15 files changed
+905 -1627

No files matched your search

+27 -126
View File
@@ -1,131 +1,32 @@
//! Export a truck B-rep `Solid` to an exact ACIS `SatDocument`.
//! Export a B-rep [`Body`] to an exact ACIS `SatDocument`.
//!
//! OCS models 3-D solids with truck (`scene.solid_models`), but EXTRUDE /
//! REVOLVE / SWEEP / LOFT / boolean results carry no ACIS geometry, so other
//! CAD applications drop them on open. Converting the truck solid to ACIS lets
//! the DWG/DXF writer store real modeler geometry.
//! A solid built by the Model tab, by EXTRUDE / REVOLVE, or by a boolean
//! carries no ACIS geometry of its own, so other CAD applications drop it on
//! open. Writing the body out as real modeller geometry is what stops that.
//!
//! This module handles the **planar** case: a solid whose faces are all planes
//! bounded by straight edges (boxes, extruded polygons, planar boolean results).
//! It walks the topology into a vertex/face-ring mesh and hands it to acadrust's
//! [`build_planar_body`], which assembles the exact B-rep. Any curved face or
//! edge makes it return `None` — those solids are left for the NURBS path.
//! # Exact, not facetted
//!
//! The kernel keeps analytic surfaces — a cylinder is a `cylinder` record and
//! a sphere a `sphere` — so what lands in the document is the shape itself
//! rather than a triangulation of it. That is the whole reason the Model tab
//! builds bodies at all: a facetted export is a one-way door, and the next
//! application to open the file would have no way back to the circle.
//!
//! The writing is [`acis::append`], which lives with the codec because it has
//! to know both sides. What is left here is the small matter of putting one
//! body into a fresh document.
use std::collections::HashMap;
use acadrust::acis::append;
use acadrust::entities::acis::SatDocument;
use acadrust::kernel::brep::Body;
use acadrust::entities::acis::{primitives::build_planar_body, SatDocument};
use truck_modeling::{Curve, Solid, Surface};
/// Signed volume × 6 of the closed mesh (positive when every face ring is wound
/// counter-clockwise as seen from outside). Used to make the export independent
/// of truck's face-orientation convention.
fn signed_volume6(vertices: &[[f64; 3]], faces: &[Vec<usize>]) -> f64 {
let mut acc = 0.0;
for f in faces {
let a = vertices[f[0]];
// Fan-triangulate the (planar, convex-or-not) ring from its first vertex.
for i in 1..f.len() - 1 {
let b = vertices[f[i]];
let c = vertices[f[i + 1]];
acc += a[0] * (b[1] * c[2] - b[2] * c[1])
- a[1] * (b[0] * c[2] - b[2] * c[0])
+ a[2] * (b[0] * c[1] - b[1] * c[0]);
}
}
acc
}
/// Build an exact ACIS `SatDocument` from a truck solid whose faces are all
/// planar and edges all straight. Returns `None` for any curved face/edge (left
/// for the NURBS export), for faces with holes, or for a degenerate body.
pub fn planar_solid_to_sat(solid: &Solid) -> Option<SatDocument> {
let mut positions: Vec<[f64; 3]> = Vec::new();
let mut vert_index: HashMap<_, usize> = HashMap::new();
let mut faces: Vec<Vec<usize>> = Vec::new();
for shell in solid.boundaries() {
for face in shell.face_iter() {
// Planar faces only — a curved surface needs the NURBS path.
if !matches!(face.surface(), Surface::Plane(_)) {
return None;
}
// Single outer loop only (no holes) in the planar path.
let boundaries = face.boundaries();
if boundaries.len() != 1 {
return None;
}
let wire = &boundaries[0];
// Every bounding edge must be a straight line.
for edge in wire.edge_iter() {
if !matches!(edge.curve(), Curve::Line(_)) {
return None;
}
}
// Ordered vertex ring of the loop.
let mut ring: Vec<usize> = Vec::new();
for v in wire.vertex_iter() {
let key = v.id();
let p = v.point();
let idx = *vert_index.entry(key).or_insert_with(|| {
positions.push([p.x, p.y, p.z]);
positions.len() - 1
});
// Drop a closing vertex that repeats the ring's start.
if ring.last() != Some(&idx) {
ring.push(idx);
}
}
if ring.len() >= 2 && ring.first() == ring.last() {
ring.pop();
}
if ring.len() < 3 {
return None;
}
faces.push(ring);
}
}
if faces.len() < 4 || positions.len() < 4 {
return None;
}
// Make the winding outward-CCW regardless of truck's convention: a
// correctly-oriented closed shell has positive signed volume; if it came out
// negative every ring is inside-out, so reverse them all.
if signed_volume6(&positions, &faces) < 0.0 {
for f in faces.iter_mut() {
f.reverse();
}
}
build_planar_body(&positions, &faces)
}
#[cfg(test)]
mod tests {
use super::*;
use truck_modeling::builder;
use truck_modeling::{Point3, Vector3};
/// A truck box (dimension-raising sweep vertex→edge→face→solid) must export
/// to a valid 6-face / 12-edge / 8-vertex ACIS body.
#[test]
fn box_exports_to_valid_planar_sat() {
let p = builder::vertex(Point3::new(0.0, 0.0, 0.0));
let e = builder::tsweep(&p, Vector3::new(2.0, 0.0, 0.0));
let f = builder::tsweep(&e, Vector3::new(0.0, 2.0, 0.0));
let solid = builder::tsweep(&f, Vector3::new(0.0, 0.0, 2.0));
let sat = planar_solid_to_sat(&solid).expect("box should export to SAT");
assert_eq!(sat.faces().len(), 6, "box has 6 planar faces");
assert_eq!(sat.edges().len(), 12, "box has 12 straight edges");
assert_eq!(sat.vertices().len(), 8, "box has 8 vertices");
assert!(
sat.validate().is_empty(),
"exported box failed ACIS validation: {:?}",
sat.validate()
);
}
/// An exact ACIS document holding `body`, or `None` when the kernel has no
/// record form for something in it — a spline curve, at present.
///
/// Refusing is the point: a document missing a face is one that parses into a
/// different solid, and nothing downstream could tell.
pub fn planar_solid_to_sat(body: &Body) -> Option<SatDocument> {
let mut document = SatDocument::new();
append(body, &mut document).ok()?;
Some(document)
}