feat(v2.0.0): Sprint 10 — B-Rep完善 + STEP导入/导出 + 布尔运算 + 验证
CI / Build & Test (push) Failing after 40s
CI / Release Build (push) Failing after 1m31s

S10-A: B-Rep modeling 补齐
- fillet: 恒定半径滚动球倒圆(NURBS 曲面构建 + 相邻面裁剪)
- chamfer: 等距倒角(平面偏移 + 倒角面构建)
- shell: 完整抽壳(顶点偏移 + 内外面 + 开口侧壁)

S10-B: STEP AP203/AP214 导入
- ISO 10303-21 解析器(1,424 行)
- 支持 14 种几何实体 → NurbsCurve/NurbsSurface 转换
- 完整拓扑链:VERTEX_POINT → EDGE_CURVE → EDGE_LOOP → ADVANCED_FACE → CLOSED_SHELL → MANIFOLD_SOLID_BREP
- 装配支持:NEXT_ASSEMBLY_USAGE_OCCURRENCE + CONTEXT_DEPENDENT_SHAPE_REPRESENTATION

S10-C.1: STEP AP214 导出
- export_step / export_step_file
- 曲线/曲面类型自动检测简化输出

S10-C.2: B-Rep 级布尔运算
- brep_union / brep_intersection / brep_difference
- 面-面求交 + 内/外分类 + 面缝合

S10-C.3: B-Rep 工程验证
- ValidationResult: 水密性/悬边/自相交/方向一致性/欧拉示性数

测试: 5 个新测试文件,47 项测试(modeling/STEP导入/导出/布尔/验证)

修改: 7 files (+576/-47) | 新增: 13 files (4,141 行)
总计: +4,670 行
This commit is contained in:
茂之钳
2026-07-24 05:18:52 +00:00
parent c06d8f4d0a
commit 8a19e265cd
20 changed files with 4721 additions and 51 deletions
+537 -50
View File
@@ -1,6 +1,7 @@
#include "vde/brep/modeling.h"
#include <cmath>
#include <algorithm>
#include <cassert>
namespace vde::brep {
@@ -23,7 +24,6 @@ Point3D offset_vertex(const BrepModel& body, int vertex_id, double dist) {
for (int fi : efs) {
const auto& face = body.face(fi);
const auto& surf = body.surface(face.surface_id);
// Evaluate normal at approximate param
auto du = surf.derivative_u(0.5, 0.5);
auto dv = surf.derivative_v(0.5, 0.5);
Vector3D n = du.cross(dv);
@@ -37,6 +37,82 @@ Point3D offset_vertex(const BrepModel& body, int vertex_id, double dist) {
return p + avg_normal * dist;
}
// Compute a representative face normal from its first 3 vertices
Vector3D compute_face_normal(const BrepModel& body, int face_id) {
auto es = body.face_edges(face_id);
if (es.size() < 3) return Vector3D::UnitZ();
const auto& e0 = body.edge(es[0]);
const auto& e1 = body.edge(es[1]);
Point3D p0 = body.vertex(e0.v_start).point;
Point3D p1 = body.vertex(e0.v_end).point;
Point3D p2 = body.vertex(e1.v_end).point;
Vector3D n = (p1 - p0).cross(p2 - p0);
if (n.norm() > 1e-12) return n.normalized();
return Vector3D::UnitZ();
}
// Direction in face plane, perpendicular to an edge
Vector3D perpendicular_in_face(const Vector3D& edge_dir, const Vector3D& face_normal) {
Vector3D d = face_normal.cross(edge_dir);
double len = d.norm();
if (len > 1e-12) return d / len;
return Vector3D::UnitY();
}
// Create a fillet arc surface (ruled surface along fillet blend)
curves::NurbsSurface make_fillet_surface(
const std::vector<Point3D>& t1_pts, // tangent points on face 1
const std::vector<Point3D>& t2_pts, // tangent points on face 2
const std::vector<Point3D>& mid_pts) // arc midpoint (for curvature)
{
int n = static_cast<int>(t1_pts.size());
if (n < 2) n = 2;
// Build control grid: (n, 3) — n samples along edge, 3 across fillet
std::vector<std::vector<Point3D>> grid(n);
for (int i = 0; i < n; ++i) {
grid[i] = {t1_pts[i], mid_pts[i], t2_pts[i]};
}
std::vector<double> knots_u(n + 3, 0.0);
for (int i = 0; i < 3; ++i) knots_u[i] = 0.0;
for (int i = n; i < n + 3; ++i) knots_u[i] = 1.0;
for (int i = 3; i < n; ++i) knots_u[i] = static_cast<double>(i - 2) / (n - 2);
// degree 2 in v for arc, degree 2 in u for smoothness along edge
return curves::NurbsSurface(grid, knots_u, {0,0,0,1,1,1}, {}, 2, 2);
}
// Build vertices from a list of points into a model, return vertex IDs
std::vector<int> add_vertices(BrepModel& m, const std::vector<Point3D>& pts) {
std::vector<int> ids;
for (const auto& p : pts) ids.push_back(m.add_vertex(p));
return ids;
}
// Build quad faces from vertex grid (n_rows × n_cols vertices), return face IDs
// Grid is stored row-major: grid[r][c]
std::vector<int> build_quad_faces(BrepModel& m,
const std::vector<std::vector<int>>& grid)
{
std::vector<int> face_ids;
int rows = static_cast<int>(grid.size());
int cols = static_cast<int>(grid[0].size());
for (int r = 0; r + 1 < rows; ++r) {
for (int c = 0; c + 1 < cols; ++c) {
int v00 = grid[r][c], v10 = grid[r+1][c],
v11 = grid[r+1][c+1], v01 = grid[r][c+1];
int e1 = m.add_edge(v00, v10);
int e2 = m.add_edge(v10, v11);
int e3 = m.add_edge(v11, v01);
int e4 = m.add_edge(v01, v00);
Point3D p0 = m.vertex(v00).point, p1 = m.vertex(v10).point,
p2 = m.vertex(v11).point, p3 = m.vertex(v01).point;
int s = m.add_surface(make_plane_surface(p0, p1, p2, p3));
face_ids.push_back(m.add_face(s, {
m.add_loop({e1, e2, e3, e4}, true)}));
}
}
return face_ids;
}
} // namespace
// ── Box/Cylinder/Sphere (same as before, reused) ──
@@ -220,80 +296,491 @@ BrepModel loft(const std::vector<curves::NurbsCurve>& profiles) {
// ── Fillet ──
BrepModel fillet(const BrepModel& body, int edge_id, double radius) {
BrepModel result = body;
if (edge_id < 0 || edge_id >= static_cast<int>(body.num_edges())) return result;
const auto& edge = body.edge(edge_id);
// Discrete approximation: create a blend surface along the edge
Point3D p0 = body.vertex(edge.v_start).point;
Point3D p1 = body.vertex(edge.v_end).point;
Vector3D dir = (p1-p0).normalized();
// Validation
int num_edges = static_cast<int>(body.num_edges());
if (edge_id < 0 || edge_id >= num_edges) return body;
if (radius <= 0.0) return body;
// Find adjacent faces
auto efs = body.edge_faces(edge_id);
if (efs.size() < 2) return result;
if (efs.size() != 2) return body; // Only handle manifold edges with 2 adjacent faces
// Sample along edge and create blend arcs
int samples = 8;
std::vector<std::array<Point3D,3>> fillet_verts;
for (int i = 0; i <= samples; ++i) {
double t = static_cast<double>(i) / samples;
Point3D pt = p0 + dir * t * (p1-p0).norm();
// Cross-section circle (simplified: use quad)
Vector3D n0(0,0,0), n1(0,0,0);
for (int fi : efs) {
const auto& face = body.face(fi);
const auto& surf = body.surface(face.surface_id);
auto du = surf.derivative_u(0.5,0.5), dv = surf.derivative_v(0.5,0.5);
auto n = du.cross(dv); if (n.norm() > 1e-12) { if(n0.norm()<1e-12) n0=n.normalized(); else n1=n.normalized(); }
int face_a = efs[0], face_b = efs[1];
const auto& edge = body.edge(edge_id);
const auto& curve = *edge.curve;
// Compute face normals
Vector3D n_a = compute_face_normal(body, face_a);
Vector3D n_b = compute_face_normal(body, face_b);
// Check if faces are nearly coplanar (no meaningful fillet)
double cos_angle = n_a.dot(n_b);
if (std::abs(cos_angle) > 0.9999) return body;
// Bisector direction
Vector3D bisector = (n_a + n_b).normalized();
double cos_half = bisector.dot(n_a);
if (std::abs(cos_half) < 1e-8) return body;
// Sample the edge
int N = 8; // Number of samples along the edge
auto [t_min, t_max] = curve.domain();
std::vector<Point3D> edge_pts(N + 1);
std::vector<Point3D> t1_pts(N + 1); // Tangent points on face_a
std::vector<Point3D> t2_pts(N + 1); // Tangent points on face_b
std::vector<Point3D> mid_pts(N + 1); // Fillet arc midpoints
for (int i = 0; i <= N; ++i) {
double t = t_min + (t_max - t_min) * static_cast<double>(i) / N;
edge_pts[i] = curve.evaluate(t);
// Fillet ball center
double offset = radius / cos_half;
Point3D C = edge_pts[i] + offset * bisector;
// Tangent points
t1_pts[i] = C - radius * n_a;
t2_pts[i] = C - radius * n_b;
// Arc midpoint (for better surface approximation)
// Midpoint of the fillet arc: along the bisector direction from center
Vector3D to_mid = (t1_pts[i] + t2_pts[i]) * 0.5 - C;
double mid_len = to_mid.norm();
if (mid_len > 1e-10) {
mid_pts[i] = C + to_mid.normalized() * radius;
} else {
mid_pts[i] = (t1_pts[i] + t2_pts[i]) * 0.5;
}
if (n0.norm() < 1e-12 || n1.norm() < 1e-12) continue;
Vector3D bisect = (n0 + n1).normalized();
Point3D arc_pt = pt + bisect * radius;
fillet_verts.push_back({arc_pt, n0, n1});
}
// Add fillet faces to result (simplified: just offset vertices)
for (const auto& fv : fillet_verts) {
(void)fv; // Full implementation would add new faces
BrepModel result;
// ── 1. Copy non-affected faces verbatim ──
// For each face NOT adjacent to the filleted edge, copy all its geometry
for (size_t fi = 0; fi < body.num_faces(); ++fi) {
if (static_cast<int>(fi) == face_a || static_cast<int>(fi) == face_b)
continue;
// Copy this face into result
const auto& f_src = body.face(static_cast<int>(fi));
std::vector<int> new_loop_ids;
for (int li : f_src.loops) {
// Find the loop
const auto& lop = body.loop_by_id(li);
std::vector<int> new_es;
for (int ei : lop.edges) {
const auto& e_src = body.edge(ei);
Point3D p0 = body.vertex(e_src.v_start).point;
Point3D p1 = body.vertex(e_src.v_end).point;
int nv0 = result.add_vertex(p0);
int nv1 = result.add_vertex(p1);
new_es.push_back(result.add_edge(nv0, nv1));
}
new_loop_ids.push_back(result.add_loop(new_es, lop.is_outer));
}
// Copy surface
int new_surf = result.add_surface(body.surface(f_src.surface_id));
result.add_face(new_surf, new_loop_ids);
}
// ── 2. Rebuild adjacent faces with tangent offset ──
auto rebuild_face = [&](int face_id, const Vector3D& n,
const std::vector<Point3D>& tangent_pts) {
const auto& f_src = body.face(face_id);
const auto& curve_copy = body.surface(f_src.surface_id);
const auto& edge_src = body.edge(edge_id);
// Get the face's edges in order
auto f_edges = body.face_edges(face_id);
// Find the index of the filleted edge in this face's edge list
int fillet_edge_idx = -1;
for (size_t ei = 0; ei < f_edges.size(); ++ei) {
if (f_edges[ei] == edge_id) {
fillet_edge_idx = static_cast<int>(ei);
break;
}
}
if (fillet_edge_idx < 0) return; // Edge not found in this face — shouldn't happen
// Rebuild the face: replace the filleted edge with the tangent offset edge
std::vector<int> new_es;
// We need to know the direction of the tangent edge relative
// to the original edge orientation in this face's loop
for (const auto& lop : body.all_loops()) {
bool found = false;
for (int li : f_src.loops) {
if (lop.id == li) { found = true; break; }
}
if (!found) continue;
// Determine the orientation of the edge in this loop
for (size_t ei = 0; ei < lop.edges.size(); ++ei) {
int eidx = lop.edges[ei];
if (eidx != edge_id) continue;
// Found the edge — now we can determine its orientation
bool edge_reversed = (edge_src.v_start != body.edge(eidx).v_start);
// Collect all edges in loop order
std::vector<int> sorted_edges(lop.edges.size());
// Rotate so we start after the filleted edge
int start_offset = static_cast<int>((ei + 1) % lop.edges.size());
for (size_t j = 0; j < lop.edges.size(); ++j) {
sorted_edges[j] = lop.edges[(start_offset + static_cast<int>(j)) % lop.edges.size()];
}
// Add the filleted edge (replaced by tangent edge) at the beginning
sorted_edges.insert(sorted_edges.begin(), edge_id);
// Edge direction for the tangent edge
Point3D t0 = edge_reversed ? tangent_pts.back() : tangent_pts.front();
Point3D t1 = edge_reversed ? tangent_pts.front() : tangent_pts.back();
int nvt0 = result.add_vertex(t0);
int nvt1 = result.add_vertex(t1);
new_es.push_back(result.add_edge(nvt0, nvt1));
// Add the other edges (these are the edges NOT being filleted)
for (size_t j = 1; j < sorted_edges.size(); ++j) {
int old_ei = sorted_edges[j];
const auto& e_src = body.edge(old_ei);
Point3D p0 = body.vertex(e_src.v_start).point;
Point3D p1 = body.vertex(e_src.v_end).point;
// If either endpoint is on the filleted edge, offset it
bool v0_on_edge = (e_src.v_start == edge_src.v_start ||
e_src.v_start == edge_src.v_end);
bool v1_on_edge = (e_src.v_end == edge_src.v_start ||
e_src.v_end == edge_src.v_end);
// Determine which tangent point corresponds to which vertex
Point3D q0 = p0, q1 = p1;
if (v0_on_edge) {
bool is_start = (e_src.v_start == edge_src.v_start);
double dist0 = (is_start ? tangent_pts.front() : tangent_pts.back())
.dot(n) - p0.dot(n);
q0 = p0 + n * dist0;
// Actually, use the tangent point directly if the vertex
// exactly matches the edge endpoint
q0 = (is_start ? tangent_pts.front() : tangent_pts.back());
}
if (v1_on_edge) {
bool is_start = (e_src.v_end == edge_src.v_start);
q1 = (is_start ? tangent_pts.front() : tangent_pts.back());
}
int nv0 = result.add_vertex(q0);
int nv1 = result.add_vertex(q1);
new_es.push_back(result.add_edge(nv0, nv1));
}
break;
}
break;
}
// Build the rebuilt face
if (!new_es.empty()) {
int new_loop = result.add_loop(new_es, true);
int new_surf = result.add_surface(curve_copy);
result.add_face(new_surf, {new_loop});
}
};
rebuild_face(face_a, n_a, t1_pts);
rebuild_face(face_b, n_b, t2_pts);
// ── 3. Build fillet surface faces ──
for (int i = 0; i < N; ++i) {
// Create a quad face for the fillet strip between samples i and i+1
Point3D q00 = t1_pts[i], q01 = t2_pts[i];
Point3D q10 = t1_pts[i + 1], q11 = t2_pts[i + 1];
// Build small fillet surface patch (degree 2 × 2 for curvature)
std::vector<std::vector<Point3D>> grid = {
{q00, mid_pts[i], q01},
{q10, mid_pts[i + 1], q11}
};
curves::NurbsSurface fs(grid,
{0, 0, 1, 1},
{0, 0, 0, 1, 1, 1},
{}, 1, 2);
int v0 = result.add_vertex(q00), v1 = result.add_vertex(q10);
int v2 = result.add_vertex(q11), v3 = result.add_vertex(q01);
int e1 = result.add_edge(v0, v1);
int e2 = result.add_edge(v1, v2);
int e3 = result.add_edge(v2, v3);
int e4 = result.add_edge(v3, v0);
int surf_id = result.add_surface(fs);
int loop_id = result.add_loop({e1, e2, e3, e4}, true);
result.add_face(surf_id, {loop_id});
}
// ── 4. Build shell and body ──
std::vector<int> all_face_ids;
for (size_t fi = 0; fi < result.num_faces(); ++fi) {
all_face_ids.push_back(result.face(static_cast<int>(fi)).id);
}
if (!all_face_ids.empty()) {
int sh = result.add_shell(all_face_ids, true);
result.add_body({sh}, "fillet");
}
return result;
}
// ── Chamfer ──
BrepModel chamfer(const BrepModel& body, int edge_id, double dist) {
(void)body; (void)edge_id; (void)dist;
return body; // Simplified
}
// Validation
int num_edges = static_cast<int>(body.num_edges());
if (edge_id < 0 || edge_id >= num_edges) return body;
if (dist <= 0.0) return body;
BrepModel shell(const BrepModel& body, int open_face, double thickness) {
BrepModel result;
// Offset all vertices inward
std::vector<int> old_to_new(body.num_vertices());
for (size_t i = 0; i < body.num_vertices(); ++i) {
Point3D p = offset_vertex(body, static_cast<int>(i), -thickness);
old_to_new[i] = result.add_vertex(p);
auto efs = body.edge_faces(edge_id);
if (efs.size() != 2) return body;
int face_a = efs[0], face_b = efs[1];
const auto& edge = body.edge(edge_id);
const auto& curve = *edge.curve;
Vector3D n_a = compute_face_normal(body, face_a);
Vector3D n_b = compute_face_normal(body, face_b);
// Edge direction
auto [t_min, t_max] = curve.domain();
Point3D p_start = curve.evaluate(t_min);
Point3D p_end = curve.evaluate(t_max);
Vector3D edge_dir = (p_end - p_start);
if (edge_dir.norm() < 1e-12) return body;
edge_dir.normalize();
// Offset direction on each face: perpendicular to edge, in face plane
// The offset should go "away" from the edge into the face
Vector3D dir_a = perpendicular_in_face(edge_dir, n_a);
Vector3D dir_b = perpendicular_in_face(edge_dir, n_b);
// Ensure both offset directions point away from the sharp corner
// The fillet center should be between the two offset points
// Check: offset points should be on opposite sides of the edge
Point3D mid_a = p_start + dir_a * dist;
Point3D mid_b = p_start + dir_b * dist;
// If dir_a and dir_b point toward each other (concave), the chamfer doesn't make sense
Vector3D gap = mid_b - mid_a;
if (gap.norm() < dist * 0.1) {
// Try flipping one direction
dir_b = -dir_b;
mid_b = p_start + dir_b * dist;
}
// Copy faces with offset
std::vector<int> all_faces;
int N = 4; // Samples along edge (fewer needed for flat chamfer)
std::vector<Point3D> a_pts(N + 1), b_pts(N + 1);
for (int i = 0; i <= N; ++i) {
double t = t_min + (t_max - t_min) * static_cast<double>(i) / N;
Point3D pt = curve.evaluate(t);
a_pts[i] = pt + dir_a * dist;
b_pts[i] = pt + dir_b * dist;
}
BrepModel result;
// ── 1. Copy non-affected faces ──
for (size_t fi = 0; fi < body.num_faces(); ++fi) {
if (static_cast<int>(fi) == open_face) continue;
if (static_cast<int>(fi) == face_a || static_cast<int>(fi) == face_b)
continue;
const auto& f_src = body.face(static_cast<int>(fi));
std::vector<int> new_loop_ids;
for (int li : f_src.loops) {
const auto& lop = body.loop_by_id(li);
std::vector<int> new_es;
for (int ei : lop.edges) {
const auto& e_src = body.edge(ei);
Point3D p0 = body.vertex(e_src.v_start).point;
Point3D p1 = body.vertex(e_src.v_end).point;
new_es.push_back(result.add_edge(
result.add_vertex(p0), result.add_vertex(p1)));
}
new_loop_ids.push_back(result.add_loop(new_es, lop.is_outer));
}
result.add_face(result.add_surface(body.surface(f_src.surface_id)),
new_loop_ids);
}
// ── 2. Rebuild adjacent faces ──
auto rebuild_chamfer_face = [&](int face_id,
const Vector3D& dir,
const std::vector<Point3D>& offset_pts) {
const auto& f_src = body.face(face_id);
const auto& edge_src = body.edge(edge_id);
for (const auto& lop : body.all_loops()) {
bool found = false;
for (int li : f_src.loops) if (lop.id == li) { found = true; break; }
if (!found) continue;
for (size_t ei = 0; ei < lop.edges.size(); ++ei) {
if (lop.edges[ei] != edge_id) continue;
bool edge_reversed = (edge_src.v_start != body.edge(lop.edges[ei]).v_start);
Point3D t0 = edge_reversed ? offset_pts.back() : offset_pts.front();
Point3D t1 = edge_reversed ? offset_pts.front() : offset_pts.back();
std::vector<int> new_es;
new_es.push_back(result.add_edge(
result.add_vertex(t0), result.add_vertex(t1)));
// Other edges
for (size_t j = 1; j < lop.edges.size(); ++j) {
int oe_idx = static_cast<int>((ei + j) % lop.edges.size());
int old_ei = lop.edges[oe_idx];
const auto& e_src = body.edge(old_ei);
Point3D p0 = body.vertex(e_src.v_start).point;
Point3D p1 = body.vertex(e_src.v_end).point;
bool v0_on = (e_src.v_start == edge_src.v_start ||
e_src.v_start == edge_src.v_end);
bool v1_on = (e_src.v_end == edge_src.v_start ||
e_src.v_end == edge_src.v_end);
Point3D q0 = p0, q1 = p1;
if (v0_on) q0 = (e_src.v_start == edge_src.v_start) ?
offset_pts.front() : offset_pts.back();
if (v1_on) q1 = (e_src.v_end == edge_src.v_start) ?
offset_pts.front() : offset_pts.back();
new_es.push_back(result.add_edge(
result.add_vertex(q0), result.add_vertex(q1)));
}
if (!new_es.empty()) {
int new_loop = result.add_loop(new_es, true);
int new_surf = result.add_surface(
body.surface(f_src.surface_id));
result.add_face(new_surf, {new_loop});
}
break;
}
break;
}
};
rebuild_chamfer_face(face_a, dir_a, a_pts);
rebuild_chamfer_face(face_b, dir_b, b_pts);
// ── 3. Build chamfer faces (flat faces connecting offsets) ──
for (int i = 0; i < N; ++i) {
Point3D q00 = a_pts[i], q01 = b_pts[i];
Point3D q10 = a_pts[i + 1], q11 = b_pts[i + 1];
// Chamfer face: flat quad (make_plane_surface)
int v0 = result.add_vertex(q00), v1 = result.add_vertex(q10);
int v2 = result.add_vertex(q11), v3 = result.add_vertex(q01);
int e1 = result.add_edge(v0, v1);
int e2 = result.add_edge(v1, v2);
int e3 = result.add_edge(v2, v3);
int e4 = result.add_edge(v3, v0);
int surf_id = result.add_surface(make_plane_surface(q00, q10, q11, q01));
result.add_face(surf_id,
{result.add_loop({e1, e2, e3, e4}, true)});
}
// ── 4. Shell and body ──
std::vector<int> all_face_ids;
for (size_t fi = 0; fi < result.num_faces(); ++fi) {
all_face_ids.push_back(result.face(static_cast<int>(fi)).id);
}
if (!all_face_ids.empty()) {
int sh = result.add_shell(all_face_ids, true);
result.add_body({sh}, "chamfer");
}
return result;
}
// ── Shell ──
BrepModel shell(const BrepModel& body, int open_face, double thickness) {
BrepModel result;
// ── 1. Offset all vertices inward ──
std::vector<int> outer_vid(body.num_vertices()); // map old id → new outer id
std::vector<int> inner_vid(body.num_vertices()); // map old id → new inner id
for (size_t i = 0; i < body.num_vertices(); ++i) {
Point3D p_outer = body.vertex(static_cast<int>(i)).point;
Point3D p_inner = offset_vertex(body, static_cast<int>(i), -thickness);
outer_vid[i] = result.add_vertex(p_outer);
inner_vid[i] = result.add_vertex(p_inner);
}
std::vector<int> all_faces;
// ── 2. Create inner offset faces ──
for (size_t fi = 0; fi < body.num_faces(); ++fi) {
if (static_cast<int>(fi) == open_face) continue; // Skip open face
const auto& face = body.face(static_cast<int>(fi));
const auto& surf = body.surface(face.surface_id);
// Get face edges and create offset loop
auto es = body.face_edges(static_cast<int>(fi));
std::vector<int> new_edges;
// Create inner face (offset inward)
std::vector<int> inner_es;
for (int ei : es) {
const auto& e = body.edge(ei);
int vs = old_to_new[e.v_start], ve = old_to_new[e.v_end];
new_edges.push_back(result.add_edge(vs, ve));
int vs = inner_vid[e.v_start];
int ve = inner_vid[e.v_end];
inner_es.push_back(result.add_edge(vs, ve));
}
int inner_surf = result.add_surface(surf);
int inner_loop = result.add_loop(inner_es, face.loops.empty() ? true : true);
all_faces.push_back(result.add_face(inner_surf, {inner_loop}));
result.add_surface(surf); // Reuse surface (approximation)
int loop = result.add_loop(new_edges, face.loops.empty() ? true : true);
all_faces.push_back(result.add_face(static_cast<int>(result.num_faces()), {loop}));
// Create outer face (original position)
std::vector<int> outer_es;
for (int ei : es) {
const auto& e = body.edge(ei);
int vs = outer_vid[e.v_start];
int ve = outer_vid[e.v_end];
outer_es.push_back(result.add_edge(vs, ve));
}
int outer_surf = result.add_surface(surf);
int outer_loop = result.add_loop(outer_es,
face.loops.empty() ? true : true);
all_faces.push_back(result.add_face(outer_surf, {outer_loop}));
}
// ── 3. Create side walls connecting outer to inner ──
// For each edge of the open face, create a quad wall:
// outer_start → outer_end → inner_end → inner_start
if (open_face >= 0 && open_face < static_cast<int>(body.num_faces())) {
auto open_edges = body.face_edges(open_face);
for (int ei : open_edges) {
const auto& e = body.edge(ei);
int ovs = outer_vid[e.v_start];
int ove = outer_vid[e.v_end];
int ivs = inner_vid[e.v_start];
int ive = inner_vid[e.v_end];
Point3D p_os = result.vertex(ovs).point;
Point3D p_oe = result.vertex(ove).point;
Point3D p_is = result.vertex(ivs).point;
Point3D p_ie = result.vertex(ive).point;
// Side wall quad: outer_start → outer_end → inner_end → inner_start
int wall_e1 = result.add_edge(ovs, ove);
int wall_e2 = result.add_edge(ove, ive);
int wall_e3 = result.add_edge(ive, ivs);
int wall_e4 = result.add_edge(ivs, ovs);
int wall_surf = result.add_surface(
make_plane_surface(p_os, p_oe, p_ie, p_is));
int wall_loop = result.add_loop(
{wall_e1, wall_e2, wall_e3, wall_e4}, true);
all_faces.push_back(
result.add_face(wall_surf, {wall_loop}));
}
}
// ── 4. Build shell and body ──
if (!all_faces.empty()) {
int sh = result.add_shell(all_faces, true);
result.add_body({sh}, "shell");