Files
OrcaSlicer/src/libslic3r/CadDocument.cpp
T
Tommaso BianchiandClaude Opus 4.8 0100c95ed1 CAD: Transform feature — move/rotate a body as a real B-rep operation
Until now move and rotate lived only in the GUI as m_body_xform, a display
transform. That made them a correctness hole, not a missing tool: a moved body
recomputed and booleaned at its ORIGINAL position, and the move was not in the
recipe at all, so it vanished on save/reload. Only export_step consulted the
transform, which is why the discrepancy stayed hidden.

CadFeatureType::Transform makes it a real feature: rotate angle_deg about
xf_axis through xf_pivot, then translate by xf_translate, applied to the target
body with BRepBuilderAPI_Transform. xf_copy=true keeps the source and appends
the transformed body instead of mutating in place, which covers Onshape's
Transform/copy in the same feature.

Rotation is composed before translation (trsf = tr * rot) so the pivot means
what a user expects — the point the body turns about, not a point that then
drifts with the translation. A rotation with a degenerate axis is refused
rather than silently skipped; a zero angle skips the rotation entirely so a
pure move needs no axis at all.

The decisive test is not the bbox arithmetic but "moved body participates in a
later boolean at its new position": two coincident boxes, one moved to partial
overlap, fused. The fused volume must be strictly greater than one box (the
move took effect in the kernel) and strictly less than both (they still
intersect). With a display-only transform the first assertion fails.

Serialization stays append-only; recipe version unchanged at 2. Golden fixture
regenerated with a GoldenTransform feature carrying distinctive literals so a
field reorder shows up as obviously wrong values. Also fills in the Helix arm
of feature_type_name(), missing since the helix commit.

Kernel suite 51 -> 57 cases, 985 -> 1054 assertions, green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 17:50:08 +02:00

2263 lines
96 KiB
C++

#include "CadDocument.hpp"
#include "SketchConstraints.hpp"
#include "SketchSolver.hpp"
#include "SketchImport.hpp" // transform_regions for imported art
#include <array>
#include <Standard_Failure.hxx>
#include <BRepBuilderAPI_MakeWire.hxx>
#include <BRepBuilderAPI_MakeEdge.hxx>
#include <BRepBuilderAPI_MakeFace.hxx>
#include <BRepBuilderAPI_MakePolygon.hxx>
#include <BRepAlgoAPI_Fuse.hxx>
#include <BRepAlgoAPI_Cut.hxx>
#include <BRepAlgoAPI_Common.hxx>
#include <BRepAlgoAPI_BooleanOperation.hxx>
#include <BRepOffsetAPI_MakePipe.hxx>
#include <BRepOffsetAPI_MakePipeShell.hxx>
#include <BRepOffsetAPI_MakeThickSolid.hxx>
#include <BRepOffsetAPI_DraftAngle.hxx>
#include <Bnd_Box.hxx>
#include <BRepBndLib.hxx>
#include <gp_Pln.hxx>
#include <TopTools_ListOfShape.hxx>
#include <BRepPrimAPI_MakeCylinder.hxx>
#include <BRepCheck_Analyzer.hxx>
#include <BRepLib.hxx>
#include <Geom_CylindricalSurface.hxx>
#include <Geom_ConicalSurface.hxx>
#include <Geom2d_TrimmedCurve.hxx>
#include <GCE2d_MakeSegment.hxx>
#include <TopoDS_Edge.hxx>
#include <TopoDS_Face.hxx>
#include <TopoDS_Compound.hxx> // multi-body: compound of bodies for display/compat
#include <BRep_Builder.hxx>
#include <TopAbs_Orientation.hxx> // outward-normal orientation for face-extrude
#include <gp_Circ.hxx>
#include <gp_Ax2.hxx>
#include <gp_Ax3.hxx>
#include <gp_Pnt2d.hxx>
#include <gp_Vec.hxx>
#include <gp_Trsf.hxx> // pattern: rigid copy transforms
#include <STEPControl_Writer.hxx> // STEP export (native B-rep)
#include <STEPControl_StepModelType.hxx>
#include <IFSelect_ReturnStatus.hxx>
#include <gp_Ax1.hxx> // pattern: rotation axis (circular)
#include <BRepBuilderAPI_Transform.hxx>
#include <BRepGProp.hxx>
#include <GProp_GProps.hxx>
#include <cmath>
#include <stdexcept>
#include <algorithm>
#include <sstream>
#include <cereal/archives/binary.hpp>
#include <BRepTools.hxx>
namespace Slic3r {
// ---- helical-thread construction helpers (file-local) ----------------------
// Helix spine on a cylinder (radius/pitch/height) about `axis`, as a wire.
static TopoDS_Wire make_helix_wire(const gp_Ax3& axis, double radius,
double pitch, double height)
{
Handle(Geom_CylindricalSurface) cyl = new Geom_CylindricalSurface(axis, radius);
double turns = (pitch > 1e-6) ? (height / pitch) : 1.0;
// In the surface (u,v) parametrization u is the angle, v the axial height.
gp_Pnt2d p0(0.0, 0.0);
gp_Pnt2d p1(2.0 * M_PI * turns, height);
Handle(Geom2d_TrimmedCurve) seg = GCE2d_MakeSegment(p0, p1);
TopoDS_Edge e = BRepBuilderAPI_MakeEdge(seg, cyl).Edge();
BRepLib::BuildCurves3d(e);
return BRepBuilderAPI_MakeWire(e).Wire();
}
// Helix spine from a CadFeature's helix params. Supports cylindrical (taper==0)
// and conical (taper!=0) surfaces; left_handed flips the winding direction.
// Returns null wire if validation fails (error is written to err).
static TopoDS_Wire make_helix_spine(const CadFeature& f, std::string& err)
{
err.clear();
const double R = f.helix_radius, P = f.helix_pitch, H = f.helix_height;
const double taper = f.helix_taper_deg * M_PI / 180.0;
if (R <= 0) { err = "helix radius must be > 0"; return TopoDS_Wire(); }
if (P <= 0) { err = "helix pitch must be > 0"; return TopoDS_Wire(); }
if (H < 0) { err = "helix height must be >= 0"; return TopoDS_Wire(); }
if (H == 0) { err = "helix height of 0 (flat spiral) is not supported"; return TopoDS_Wire(); }
const double turns = H / P;
if (turns > 10000) { err = "helix turn count exceeds limit (10000)"; return TopoDS_Wire(); }
if (std::abs(taper) > 1e-12) {
const double R_top = R + H * std::tan(taper);
if (R_top <= 0) {
err = "helix taper drives radius negative before reaching height";
return TopoDS_Wire();
}
}
gp_Dir zdir(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z());
gp_Dir xdir(f.plane.x_axis.x(), f.plane.x_axis.y(), f.plane.x_axis.z());
Vec3d ori = f.plane.origin;
gp_Pnt o(ori.x(), ori.y(), ori.z());
gp_Ax2 ax2(o, zdir, xdir);
gp_Ax3 ax3(o, zdir, xdir);
TopoDS_Edge e;
if (std::abs(taper) > 1e-12) {
Handle(Geom_ConicalSurface) cone = new Geom_ConicalSurface(ax3, taper, R);
double u1 = f.helix_left_handed ? -2.0 * M_PI * turns : 2.0 * M_PI * turns;
gp_Pnt2d p0(0.0, 0.0);
gp_Pnt2d p1(u1, H);
Handle(Geom2d_TrimmedCurve) seg = GCE2d_MakeSegment(p0, p1);
e = BRepBuilderAPI_MakeEdge(seg, cone).Edge();
} else {
Handle(Geom_CylindricalSurface) cyl = new Geom_CylindricalSurface(ax3, R);
double u1 = f.helix_left_handed ? -2.0 * M_PI * turns : 2.0 * M_PI * turns;
gp_Pnt2d p0(0.0, 0.0);
gp_Pnt2d p1(u1, H);
Handle(Geom2d_TrimmedCurve) seg = GCE2d_MakeSegment(p0, p1);
e = BRepBuilderAPI_MakeEdge(seg, cyl).Edge();
}
BRepLib::BuildCurves3d(e);
return BRepBuilderAPI_MakeWire(e).Wire();
}
// Triangular axial thread profile (a planar face) placed at the helix start
// (origin + radius*xdir). Spans +-pitch/2 axially; apex offset radially by depth.
// Both thread kinds sweep the SAME outward-biting V (base on the cylinder wall,
// apex `depth` into the surrounding material). Only the boolean differs:
// - external: the V is FUSED to the rod -> a raised helical ridge.
// - internal: the V is CUT from the wall -> a sunken helical groove. The cut MUST
// go outward into the wall to be visible; an inward V (the old behaviour) only
// sweeps already-empty bore space and removes nothing.
static TopoDS_Wire make_thread_profile(const gp_Pnt& origin, const gp_Dir& xdir,
const gp_Dir& zdir, double radius,
double pitch, double depth, bool internal)
{
(void)internal;
gp_Vec vx(xdir), vz(zdir);
// Root the V CLEARLY inside the wall (a real overlap, not a 0.05 mm tangency) so the boolean
// has clean intersections — near-coincident faces are what make OCCT's fuse/cut unstable.
const double over = std::min(std::max(depth, 0.25), radius * 0.4);
double inner = radius - over; // base, well inside the wall (solid overlap)
double crest = radius + depth; // apex, `depth` into the surrounding material
// Axial half-height must be < pitch/2 so ADJACENT helix turns don't collide — a full-pitch
// profile makes the swept solid self-intersect (invalid -> never renders, or crashes the
// boolean). 0.42*pitch leaves a clean gap between turns; the V still reads as a thread.
const double half = 0.42 * pitch;
gp_Pnt top (origin.XYZ() + (vx * inner).XYZ() + (vz * ( half)).XYZ());
gp_Pnt bot (origin.XYZ() + (vx * inner).XYZ() + (vz * (-half)).XYZ());
gp_Pnt apex(origin.XYZ() + (vx * crest).XYZ());
BRepBuilderAPI_MakePolygon poly(top, bot, apex, Standard_True);
return poly.Wire(); // closed triangle, swept by MakePipeShell with a fixed binormal
}
// ---------------------------------------------------------------------------
int CadDocument::add_sketch(SketchShape shape, const SketchPlane& plane,
double width, double height, double radius,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Sketch;
f.name = name;
f.shape = shape;
f.plane = plane;
f.width = width;
f.height = height;
f.radius = radius;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_sketch_profile(const SketchProfile& profile, const SketchPlane& plane,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Sketch;
f.name = name;
f.plane = plane;
f.profile = profile;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_sketch_entities(const std::vector<SketchEntity>& entities,
const SketchPlane& plane, const std::string& name,
const std::vector<SketchEntityConstraintDef>& constraints)
{
CadFeature f;
f.type = CadFeatureType::Sketch;
f.name = name;
f.plane = plane;
f.entities = entities;
f.entity_constraints = constraints; // driving dimensions, solved by solve_sketch_feature
features.push_back(f);
return int(features.size()) - 1;
}
// Solve Onshape-style constraints on a SketchEntity list (Fase 4.3). All entity
// types participate: Line (P0,P1), Arc (P0,P1,Center), Circle (Center), Point (P0).
// Solved coordinates are written back, with arc angles reflowed from the solved
// center+endpoints. Free function (declared in SketchEngine.hpp) so the in-session
// GUI sketch tool can live-solve the same way committed features do.
bool solve_sketch_entities(std::vector<SketchEntity>& entities,
const std::vector<SketchEntityConstraintDef>& constraints)
{
// Delegated to the vendored SolveSpace solver (SketchSolver / libslvs): full
// constraint set, real DoF + over-constrained detection.
return sketch_solve(entities, constraints).ok;
}
#if 0 // legacy hand-rolled Gauss-Newton solver — superseded by libslvs, kept for reference
static bool legacy_solve_sketch_entities(std::vector<SketchEntity>& entities,
const std::vector<SketchEntityConstraintDef>& constraints)
{
if (constraints.empty()) return true;
SketchConstraints sc;
// table[entity][role] -> solver point id, or -1 if that role is unregistered.
std::vector<std::array<int, 3>> table(entities.size(), {-1, -1, -1});
auto reg = [&](int ei, SketchPointRole role, const Vec2d& p) {
table[ei][int(role)] = sc.add_point(p.x(), p.y());
};
for (size_t i = 0; i < entities.size(); ++i) {
const SketchEntity& e = entities[i];
switch (e.type) {
case SketchEntity::Type::Line:
reg(int(i), SketchPointRole::P0, e.p0);
reg(int(i), SketchPointRole::P1, e.p1);
break;
case SketchEntity::Type::Arc:
reg(int(i), SketchPointRole::P0, e.p0);
reg(int(i), SketchPointRole::P1, e.p1);
reg(int(i), SketchPointRole::Center, e.center);
break;
case SketchEntity::Type::Circle:
reg(int(i), SketchPointRole::Center, e.center);
break;
case SketchEntity::Type::Point:
reg(int(i), SketchPointRole::P0, e.p0);
break;
}
}
auto pid = [&](int ei, SketchPointRole role) -> int {
if (ei < 0 || ei >= int(table.size())) return -1;
return table[ei][int(role)];
};
for (const SketchEntityConstraintDef& c : constraints) {
switch (c.type) {
// Point-form: refs A and B name individual entity points.
case SketchConstraintType::Fix: {
int a = pid(c.ea, c.ra);
if (a >= 0) sc.fix_point(a);
break;
}
case SketchConstraintType::Coincident: {
int a = pid(c.ea, c.ra), b = pid(c.eb, c.rb);
if (a >= 0 && b >= 0) sc.coincident(a, b);
break;
}
case SketchConstraintType::Horizontal: {
int a = pid(c.ea, c.ra), b = pid(c.eb, c.rb);
if (a >= 0 && b >= 0) sc.horizontal(a, b);
break;
}
case SketchConstraintType::Vertical: {
int a = pid(c.ea, c.ra), b = pid(c.eb, c.rb);
if (a >= 0 && b >= 0) sc.vertical(a, b);
break;
}
case SketchConstraintType::Distance: {
int a = pid(c.ea, c.ra), b = pid(c.eb, c.rb);
if (a >= 0 && b >= 0) sc.distance(a, b, c.value);
break;
}
case SketchConstraintType::LockX: {
int a = pid(c.ea, c.ra);
if (a >= 0) sc.lock_x(a, c.value);
break;
}
case SketchConstraintType::LockY: {
int a = pid(c.ea, c.ra);
if (a >= 0) sc.lock_y(a, c.value);
break;
}
// Segment-form: ea and eb name whole line segments (their P0->P1).
case SketchConstraintType::Parallel:
case SketchConstraintType::Perpendicular:
case SketchConstraintType::EqualLength: {
int a0 = pid(c.ea, SketchPointRole::P0), a1 = pid(c.ea, SketchPointRole::P1);
int b0 = pid(c.eb, SketchPointRole::P0), b1 = pid(c.eb, SketchPointRole::P1);
if (a0 < 0 || a1 < 0 || b0 < 0 || b1 < 0) break;
if (c.type == SketchConstraintType::Parallel) sc.parallel(a0, a1, b0, b1);
else if (c.type == SketchConstraintType::Perpendicular) sc.perpendicular(a0, a1, b0, b1);
else sc.equal_length(a0, a1, b0, b1);
break;
}
case SketchConstraintType::Concentric: {
int a = pid(c.ea, SketchPointRole::Center);
int b = pid(c.eb, SketchPointRole::Center);
if (a >= 0 && b >= 0) sc.coincident(a, b);
break;
}
case SketchConstraintType::Midpoint: {
int m = pid(c.ea, c.ra);
int a = pid(c.eb, SketchPointRole::P0);
int b = pid(c.eb, SketchPointRole::P1);
if (m >= 0 && a >= 0 && b >= 0) sc.midpoint(m, a, b);
break;
}
case SketchConstraintType::Symmetric: {
int a = pid(c.ea, c.ra);
int b = pid(c.eb, c.rb);
int x0 = pid(c.ec, SketchPointRole::P0);
int x1 = pid(c.ec, SketchPointRole::P1);
if (a >= 0 && b >= 0 && x0 >= 0 && x1 >= 0) sc.symmetric(a, b, x0, x1);
break;
}
case SketchConstraintType::Angle: {
int a0 = pid(c.ea, SketchPointRole::P0), a1 = pid(c.ea, SketchPointRole::P1);
int b0 = pid(c.eb, SketchPointRole::P0), b1 = pid(c.eb, SketchPointRole::P1);
if (a0 >= 0 && a1 >= 0 && b0 >= 0 && b1 >= 0) sc.angle(a0, a1, b0, b1, c.value);
break;
}
case SketchConstraintType::Radius:
case SketchConstraintType::Diameter:
// dimensions: applied in the post-solve pass below, not via the solver.
break;
case SketchConstraintType::PointOnLine: {
// Point `ea`/`ra` is held at signed perpendicular distance `value` from
// line `eb` (value 0 -> on the line). Drives e.g. a circle centre onto a
// construction axis and keeps it there through later edits.
int p = pid(c.ea, c.ra);
int l0 = pid(c.eb, SketchPointRole::P0), l1 = pid(c.eb, SketchPointRole::P1);
if (p >= 0 && l0 >= 0 && l1 >= 0) sc.point_line_distance(p, l0, l1, c.value);
break;
}
case SketchConstraintType::Tangent: {
auto in_range = [&](int e){ return e >= 0 && e < (int)entities.size(); };
if (!in_range(c.ea) || !in_range(c.eb)) break;
const SketchEntity& ea_e = entities[c.ea];
const SketchEntity& eb_e = entities[c.eb];
auto is_round = [](const SketchEntity& e){
return e.type == SketchEntity::Type::Circle || e.type == SketchEntity::Type::Arc; };
if (is_round(ea_e) && eb_e.type == SketchEntity::Type::Line) {
int cen = pid(c.ea, SketchPointRole::Center);
int l0 = pid(c.eb, SketchPointRole::P0), l1 = pid(c.eb, SketchPointRole::P1);
if (cen >= 0 && l0 >= 0 && l1 >= 0) sc.point_line_distance(cen, l0, l1, ea_e.radius);
} else if (is_round(eb_e) && ea_e.type == SketchEntity::Type::Line) {
int cen = pid(c.eb, SketchPointRole::Center);
int l0 = pid(c.ea, SketchPointRole::P0), l1 = pid(c.ea, SketchPointRole::P1);
if (cen >= 0 && l0 >= 0 && l1 >= 0) sc.point_line_distance(cen, l0, l1, eb_e.radius);
} else if (is_round(ea_e) && is_round(eb_e)) {
int c0 = pid(c.ea, SketchPointRole::Center), c1 = pid(c.eb, SketchPointRole::Center);
if (c0 >= 0 && c1 >= 0) sc.distance(c0, c1, ea_e.radius + eb_e.radius);
}
break;
}
}
}
const bool ok = sc.solve();
// Write solved coordinates back into the participating entities.
for (size_t i = 0; i < entities.size(); ++i) {
SketchEntity& e = entities[i];
int ip0 = table[i][int(SketchPointRole::P0)];
int ip1 = table[i][int(SketchPointRole::P1)];
int ic = table[i][int(SketchPointRole::Center)];
if (ip0 >= 0) e.p0 = sc.get_point(ip0);
if (ip1 >= 0) e.p1 = sc.get_point(ip1);
if (ic >= 0) e.center = sc.get_point(ic);
if (e.type == SketchEntity::Type::Arc && ic >= 0) {
// Reflow arc angles from solved center + endpoints, preserving the
// original sweep direction (CCW vs CW).
const double old_sweep = e.end_angle - e.start_angle; // signed, original
double ns = std::atan2(e.p0.y() - e.center.y(), e.p0.x() - e.center.x());
double ne = std::atan2(e.p1.y() - e.center.y(), e.p1.x() - e.center.x());
double sweep = ne - ns;
// Normalize `sweep` into (-2pi, 2pi) then match the sign of old_sweep so
// the arc keeps turning the same way it did before solving.
const double TWO_PI = 2.0 * M_PI;
while (sweep <= -TWO_PI) sweep += TWO_PI;
while (sweep >= TWO_PI) sweep -= TWO_PI;
if (old_sweep >= 0.0 && sweep < 0.0) sweep += TWO_PI;
if (old_sweep < 0.0 && sweep > 0.0) sweep -= TWO_PI;
e.start_angle = ns;
e.end_angle = ns + sweep;
e.radius = 0.5 * ((e.p0 - e.center).norm() + (e.p1 - e.center).norm());
}
if (e.type == SketchEntity::Type::Circle && ic >= 0) {
// p0 mirrors the center for circles; keep them consistent.
e.p0 = e.center;
}
}
// Apply radius/diameter dimensions directly (radius is not a solver variable).
for (const auto& c : constraints) {
if (c.type != SketchConstraintType::Radius &&
c.type != SketchConstraintType::Diameter) continue;
if (c.ea < 0 || c.ea >= (int)entities.size()) continue;
SketchEntity& e = entities[c.ea];
if (e.type != SketchEntity::Type::Circle && e.type != SketchEntity::Type::Arc) continue;
const double r = (c.type == SketchConstraintType::Diameter) ? 0.5 * c.value : c.value;
if (r <= 0.0) continue;
e.radius = r;
if (e.type == SketchEntity::Type::Arc) {
// Rescale endpoints to the new radius around the (solved) center, keeping
// each endpoint's direction so the reflowed start/end angles stay valid.
auto rescale = [&](Vec2d& p) {
Vec2d d = p - e.center;
const double n = d.norm();
if (n > 1e-12) p = e.center + (r / n) * d;
};
rescale(e.p0);
rescale(e.p1);
}
}
return ok;
}
#endif // legacy solver
bool CadDocument::solve_sketch_feature(int index)
{
if (index < 0 || index >= int(features.size())) return false;
CadFeature& f = features[index];
if (f.type != CadFeatureType::Sketch) return false;
// Onshape-style entity sketches solve against entity endpoints (Fase 4.2).
if (!f.entities.empty())
return solve_sketch_entities(f.entities, f.entity_constraints);
if (f.constraints.empty()) return true;
SketchConstraints sc;
for (const Vec2d& p : f.profile.points)
sc.add_point(p.x(), p.y());
for (const SketchConstraintDef& c : f.constraints) {
switch (c.type) {
case SketchConstraintType::Fix: sc.fix_point(c.a); break;
case SketchConstraintType::Coincident: sc.coincident(c.a, c.b); break;
case SketchConstraintType::Horizontal: sc.horizontal(c.a, c.b); break;
case SketchConstraintType::Vertical: sc.vertical(c.a, c.b); break;
case SketchConstraintType::Distance: sc.distance(c.a, c.b, c.value); break;
case SketchConstraintType::LockX: sc.lock_x(c.a, c.value); break;
case SketchConstraintType::LockY: sc.lock_y(c.a, c.value); break;
case SketchConstraintType::EqualLength: sc.equal_length(c.a, c.b, c.c, c.d); break;
case SketchConstraintType::Parallel: sc.parallel(c.a, c.b, c.c, c.d); break;
case SketchConstraintType::Perpendicular:sc.perpendicular(c.a, c.b, c.c, c.d); break;
}
}
const bool ok = sc.solve();
for (size_t i = 0; i < f.profile.points.size(); ++i)
f.profile.points[i] = sc.get_point(int(i));
return ok;
}
int CadDocument::add_extrude(int sketch_ref, double distance, bool symmetric,
BooleanMode mode, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Extrude;
f.name = name;
f.sketch_ref = sketch_ref;
f.distance = distance;
f.symmetric = symmetric;
f.mode = mode;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_extrude_entities(const std::vector<SketchEntity>& entities,
const SketchPlane& plane, double distance,
bool symmetric, BooleanMode mode, const std::string& name)
{
// Self-contained extrude of a single loop: the entity subset lives on the feature
// itself (sketch_ref = -1), so build_sketch_wire(f) uses f.entities directly. The
// source sketch stays a separate feature, so its other loops remain selectable.
CadFeature f;
f.type = CadFeatureType::Extrude;
f.name = name;
f.sketch_ref = -1;
f.entities = entities;
f.plane = plane;
f.distance = distance;
f.symmetric = symmetric;
f.mode = mode;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_extrude_face(int src_face, double distance, bool symmetric,
BooleanMode mode, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Extrude;
f.name = name;
f.sketch_ref = -1;
f.extrude_src_face = src_face;
f.distance = distance;
f.symmetric = symmetric;
f.mode = mode;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_fillet(double radius, FaceGroup faces, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Fillet;
f.name = name;
f.dressup_size = radius;
f.face_group = faces;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_fillet(double radius, int edge_id, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Fillet;
f.name = name;
f.dressup_size = radius;
f.dressup_edge = edge_id;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_chamfer(double distance, FaceGroup faces, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Chamfer;
f.name = name;
f.dressup_size = distance;
f.face_group = faces;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_chamfer(double distance, int edge_id, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Chamfer;
f.name = name;
f.dressup_size = distance;
f.dressup_edge = edge_id;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_hole(double diameter, double depth, bool through,
double x, double y, const SketchPlane& plane,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Hole;
f.name = name;
f.plane = plane;
f.hole_diameter = diameter;
f.hole_depth = depth;
f.hole_through = through;
f.hole_x = x;
f.hole_y = y;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_thread(double radius, double pitch, double height, double depth,
bool internal, double x, double y, const SketchPlane& plane,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Thread;
f.name = name;
f.plane = plane;
f.thread_radius = radius;
f.thread_pitch = pitch;
f.thread_height = height;
f.thread_depth = depth;
f.thread_internal = internal;
f.thread_x = x;
f.thread_y = y;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_revolve(int sketch_ref, double angle, int axis, bool flip,
BooleanMode mode, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Revolve;
f.name = name;
f.sketch_ref = sketch_ref;
f.revolve_angle = angle;
f.revolve_axis = axis;
f.flip = flip;
f.mode = mode;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_revolve_entities(const std::vector<SketchEntity>& entities,
const SketchPlane& plane, double angle, int axis,
bool flip, BooleanMode mode, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Revolve;
f.name = name;
f.sketch_ref = -1;
f.entities = entities;
f.plane = plane;
f.revolve_angle = angle;
f.revolve_axis = axis;
f.flip = flip;
f.mode = mode;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_sweep(int profile_sketch_ref, int path_sketch_ref, BooleanMode mode,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Sweep;
f.name = name;
f.sketch_ref = profile_sketch_ref;
f.sweep_path_ref = path_sketch_ref;
f.mode = mode;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_loft(const std::vector<int>& profile_refs, bool ruled, BooleanMode mode,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Loft;
f.name = name;
f.loft_profile_refs = profile_refs;
f.loft_ruled = ruled;
f.mode = mode;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_pattern(bool circular, int count, double spacing, int dir,
double angle_deg, int target_body, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Pattern;
f.name = name;
f.pattern_circular = circular;
f.pattern_count = count;
f.pattern_spacing = spacing;
f.pattern_dir = dir;
f.pattern_angle = angle_deg;
f.target_body = target_body;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_shell(double thickness, int face, int target_body, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Shell;
f.name = name;
f.shell_thickness = thickness;
f.shell_face = face;
f.target_body = target_body;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_draft(double angle, int face, int target_body, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Draft;
f.name = name;
f.draft_angle = angle;
f.draft_face = face;
f.target_body = target_body;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_boolean(BooleanMode op, int target_body, int tool_body, bool keep_tool,
double tolerance, int target_face, int tool_face,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Boolean;
f.name = name;
f.mode = op;
f.target_body = target_body;
f.bool_tool_body = tool_body;
f.bool_keep_tool = keep_tool;
f.bool_tolerance = tolerance;
f.bool_target_face = target_face;
f.bool_tool_face = tool_face;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_cut(const SketchPlane& plane, double offset, bool flip,
bool keep_upper, bool keep_lower, int target_body,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Cut;
f.name = name;
f.plane = plane;
f.cut_offset = offset;
f.cut_flip = flip;
f.cut_keep_upper = keep_upper;
f.cut_keep_lower = keep_lower;
f.target_body = target_body;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_mirror(const SketchPlane& plane, int target_body, BooleanMode mode,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Mirror;
f.name = name;
f.plane = plane;
f.target_body = target_body;
f.mode = mode;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_transform(int target_body, const Vec3d& translate, const Vec3d& axis,
const Vec3d& pivot, double angle_deg, bool copy,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Transform;
f.name = name;
f.target_body = target_body;
f.xf_translate = translate;
f.xf_axis = axis;
f.xf_pivot = pivot;
f.xf_angle_deg = angle_deg;
f.xf_copy = copy;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_plane(int base, double offset, double angle_tilt, int axis,
const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Plane;
f.name = name;
f.plane_base = base;
f.plane_offset = offset;
f.plane_angle_tilt = angle_tilt;
f.plane_axis = axis;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_axis(AxisType axis_type_, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Axis;
f.name = name;
f.axis_type = axis_type_;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_coordsys(CoordSysType type, const Vec3d& point, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::CoordSys;
f.name = name;
f.coordsys_type = type;
f.coordsys_point = point;
features.push_back(f);
return int(features.size()) - 1;
}
int CadDocument::add_helix(const SketchPlane& plane, double radius, double pitch, double height,
bool left_handed, double taper_deg, const std::string& name)
{
CadFeature f;
f.type = CadFeatureType::Helix;
f.name = name;
f.plane = plane;
f.helix_radius = radius;
f.helix_pitch = pitch;
f.helix_height = height;
f.helix_left_handed = left_handed;
f.helix_taper_deg = taper_deg;
features.push_back(f);
return int(features.size()) - 1;
}
TopoDS_Wire CadDocument::build_helix_wire(const CadFeature& f, std::string& err) const
{
return make_helix_spine(f, err);
}
// Derive a SketchPlane: shift `base` along its normal by `offset`, then tilt
// `angle_deg` about the base's X (axis 0) or Y (axis 1) axis (Rodrigues rotation).
static SketchPlane offset_angle_plane(const SketchPlane& base, double offset,
double angle_deg, int axis)
{
SketchPlane p;
p.origin = base.origin + base.normal * offset;
Vec3d n = base.normal, x = base.x_axis, y = base.y_axis;
if (std::abs(angle_deg) > 1e-9) {
const double a = angle_deg * M_PI / 180.0;
const Vec3d k = (axis == 1) ? base.y_axis : base.x_axis; // unit rotation axis
auto rot = [&](const Vec3d& v) -> Vec3d { // -> Vec3d forces eval (no dangling Eigen expr)
return v * std::cos(a) + k.cross(v) * std::sin(a)
+ k * (k.dot(v)) * (1.0 - std::cos(a));
};
n = rot(n);
if (axis == 1) x = rot(x); // tilt about Y rotates X + normal, Y fixed
else y = rot(y); // tilt about X rotates Y + normal, X fixed
}
p.normal = n.normalized();
p.x_axis = x.normalized();
p.y_axis = y.normalized();
return p;
}
// Build a full orthonormal frame from a normal + origin.
static SketchPlane frame_from(const Vec3d& origin, const Vec3d& normal)
{
Vec3d n = normal.normalized();
Vec3d ref = (std::abs(n.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
Vec3d x = ref.cross(n);
if (x.squaredNorm() < 1e-12) x = Vec3d(1, 0, 0);
x.normalize();
Vec3d y = n.cross(x).normalized();
SketchPlane p;
p.origin = origin;
p.normal = n;
p.x_axis = x;
p.y_axis = y;
return p;
}
std::vector<std::pair<std::string, SketchPlane>> CadDocument::resolve_datum_planes() const
{
std::vector<std::pair<std::string, SketchPlane>> out;
for (const CadFeature& f : features) {
if (f.type != CadFeatureType::Plane || !f.enabled) continue;
// Resolve base reference plane. The default XY/XZ/YZ planes pass through the modeling
// origin (bed centre); datum bases (>=3) are already in world coords from earlier passes.
SketchPlane base;
if (f.plane_base == 1) { base = SketchPlane::XZ(); base.origin += modeling_origin; }
else if (f.plane_base == 2) { base = SketchPlane::YZ(); base.origin += modeling_origin; }
else if (f.plane_base >= 3) {
const int di = f.plane_base - 3;
if (di < int(out.size())) base = out[di].second;
}
else { base = SketchPlane::XY(); base.origin += modeling_origin; }
// --- Resolve refs from bodies ---
auto resolve_face = [&](int body_idx, int face_idx) -> TopoDS_Face {
if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size()))
return TopoDS_Face();
return GeometryEngine::face_by_index(bodies[body_idx].shape, face_idx);
};
auto resolve_edge = [&](int body_idx, int edge_idx,
Vec3d& p0, Vec3d& dir) -> bool {
if (edge_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size()))
return false;
TopoDS_Edge e = GeometryEngine::edge_by_index(bodies[body_idx].shape, edge_idx);
if (e.IsNull()) return false;
auto pts = GeometryEngine::sample_edge_world(e);
if (pts.size() < 2) return false;
p0 = pts.front();
dir = (pts.back() - pts.front()).normalized();
return true;
};
// Face A
TopoDS_Face faceA = resolve_face(f.plane_face_body, f.plane_face);
SketchPlane faceA_plane;
bool has_faceA = false;
if (!faceA.IsNull()) {
faceA_plane = frame_from(
GeometryEngine::face_centroid_world(faceA),
GeometryEngine::face_normal_world(faceA));
has_faceA = true;
}
// Face B
TopoDS_Face faceB = resolve_face(f.plane_face2_body, f.plane_face2);
SketchPlane faceB_plane;
bool has_faceB = false;
if (!faceB.IsNull()) {
faceB_plane = frame_from(
GeometryEngine::face_centroid_world(faceB),
GeometryEngine::face_normal_world(faceB));
has_faceB = true;
}
// Edge A
Vec3d eA_p0, eA_dir;
bool has_edgeA = resolve_edge(f.plane_edge_body, f.plane_edge, eA_p0, eA_dir);
// Edge B
Vec3d eB_p0, eB_dir;
bool has_edgeB = resolve_edge(f.plane_edge2_body, f.plane_edge2, eB_p0, eB_dir);
// --- Dispatch on plane_type ---
auto fallback_offset = [&]() {
return offset_angle_plane(base, f.plane_offset, f.plane_angle_tilt, f.plane_axis);
};
SketchPlane result;
switch (f.plane_type) {
case PlaneType::Offset: {
// From a picked face: pure offset along its normal. From a base/datum plane:
// offset + the legacy tilt-about-axis (keeps the old Offset/Tilt controls live).
if (has_faceA)
result = frame_from(faceA_plane.origin + faceA_plane.normal * f.plane_offset,
faceA_plane.normal);
else
result = offset_angle_plane(base, f.plane_offset, f.plane_angle_tilt, f.plane_axis);
break;
}
case PlaneType::Coincident: {
result = has_faceA ? faceA_plane : base;
break;
}
case PlaneType::Angle: {
if (!has_edgeA) { result = fallback_offset(); break; }
const SketchPlane& ref = has_faceA ? faceA_plane : base;
Vec3d n0 = ref.normal - eA_dir * ref.normal.dot(eA_dir);
if (n0.squaredNorm() < 1e-12) {
Vec3d perp = (std::abs(eA_dir.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
n0 = perp - eA_dir * perp.dot(eA_dir);
}
n0.normalize();
const double a = f.plane_angle_tilt * M_PI / 180.0;
Vec3d n_rot = n0 * std::cos(a) + eA_dir.cross(n0) * std::sin(a)
+ eA_dir * (eA_dir.dot(n0)) * (1.0 - std::cos(a));
result = frame_from(eA_p0, n_rot);
break;
}
case PlaneType::Midplane: {
if (!has_faceA || !has_faceB) { result = fallback_offset(); break; }
Vec3d origin = 0.5 * (faceA_plane.origin + faceB_plane.origin);
Vec3d nB = (faceA_plane.normal.dot(faceB_plane.normal) >= 0)
? faceB_plane.normal : -faceB_plane.normal;
Vec3d normal = (faceA_plane.normal + nB).normalized();
result = frame_from(origin, normal);
break;
}
case PlaneType::Tangent: {
if (!has_faceA) { result = fallback_offset(); break; }
GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(faceA);
if (!cyl.ok) { result = fallback_offset(); break; }
Vec3d refdir = (std::abs(cyl.axis.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
refdir = refdir - cyl.axis * refdir.dot(cyl.axis);
refdir.normalize();
const double theta = f.plane_angle_tilt * M_PI / 180.0;
Vec3d r = refdir * std::cos(theta) + cyl.axis.cross(refdir) * std::sin(theta);
Vec3d touch = cyl.base + r * cyl.radius;
result = frame_from(touch, r);
break;
}
case PlaneType::TwoEdges: {
if (!has_edgeA) { result = fallback_offset(); break; }
if (!has_edgeB) { result = fallback_offset(); break; }
Vec3d cross = eA_dir.cross(eB_dir);
if (cross.squaredNorm() > 1e-12) {
result = frame_from(eA_p0, cross.normalized());
} else {
Vec3d v = eA_dir.cross(eB_p0 - eA_p0);
if (v.squaredNorm() > 1e-12) {
result = frame_from(eA_p0, v.normalized());
} else {
result = fallback_offset();
}
}
break;
}
}
out.emplace_back(f.name, result);
}
return out;
}
// Resolved datum axes in feature order. Origin + unit direction computed from
// construction params; axis_err is non-empty when construction fails (no crash).
std::vector<CadDocument::DatumAxis> CadDocument::resolve_datum_axes() const
{
std::vector<DatumAxis> out;
// For PlaneIntersection we need the already-resolved datum planes.
std::vector<std::pair<std::string, SketchPlane>> datum_planes = resolve_datum_planes();
auto resolve_face = [&](int body_idx, int face_idx) -> TopoDS_Face {
if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size()))
return TopoDS_Face();
return GeometryEngine::face_by_index(bodies[body_idx].shape, face_idx);
};
auto resolve_edge = [&](int body_idx, int edge_idx,
Vec3d& p0, Vec3d& dir) -> bool {
if (edge_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size()))
return false;
TopoDS_Edge e = GeometryEngine::edge_by_index(bodies[body_idx].shape, edge_idx);
if (e.IsNull()) return false;
auto pts = GeometryEngine::sample_edge_world(e);
if (pts.size() < 2) return false;
p0 = pts.front();
dir = (pts.back() - pts.front()).normalized();
return true;
};
for (const CadFeature& f : features) {
if (f.type != CadFeatureType::Axis || !f.enabled) continue;
DatumAxis da;
da.name = f.name;
switch (f.axis_type) {
case AxisType::TwoPoints: {
Vec3d dir = f.axis_p2 - f.axis_p1;
if (dir.squaredNorm() < 1e-18) { da.error = "two points are coincident"; break; }
da.origin = f.axis_p1;
da.direction = dir.normalized();
break;
}
case AxisType::FaceNormal: {
TopoDS_Face fc = resolve_face(f.axis_body, f.axis_face);
if (fc.IsNull()) { da.error = "face not found"; break; }
da.origin = GeometryEngine::face_centroid_world(fc);
da.direction = GeometryEngine::face_normal_world(fc);
break;
}
case AxisType::CylinderCenterline: {
TopoDS_Face fc = resolve_face(f.axis_body, f.axis_face);
if (fc.IsNull()) { da.error = "face not found"; break; }
GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(fc);
if (!cyl.ok) { da.error = "face is not a cylinder"; break; }
da.origin = cyl.base;
da.direction = cyl.axis;
break;
}
case AxisType::AlongEdge: {
Vec3d p0, dir;
if (!resolve_edge(f.axis_body, f.axis_edge, p0, dir)) {
da.error = "edge not found"; break;
}
da.origin = p0;
da.direction = dir;
break;
}
case AxisType::PlaneIntersection: {
auto find_plane = [&](int ref) -> const SketchPlane* {
if (ref >= 0 && ref < int(datum_planes.size()))
return &datum_planes[ref].second;
if (ref >= 3) { // base plane offset: 0=XY,1=XZ,2=YZ
da.error = "plane ref index out of range (datum planes not found)";
return nullptr;
}
return nullptr;
};
// For base planes we handle directly.
auto base_plane = [&](int ref, Vec3d& origin, Vec3d& normal) -> bool {
if (ref >= 0 && ref < int(datum_planes.size())) {
origin = datum_planes[ref].second.origin;
normal = datum_planes[ref].second.normal;
return true;
}
return false;
};
// Both refs reference datum plane indices in the resolved list.
// Supporting cross-base-plane where ref < 0 isn't in scope.
bool ok0 = base_plane(f.axis_plane_a, da.origin, da.direction); // direction reused as normal0
Vec3d origin1, normal1;
bool ok1 = base_plane(f.axis_plane_b, origin1, normal1);
if (!ok0 || !ok1) { da.error = "plane ref not found"; break; }
// Direction = cross product of the two plane normals.
Vec3d dir = da.direction.cross(normal1); // da.direction was normal0
if (dir.squaredNorm() < 1e-18) {
da.error = "planes are parallel (no intersection)"; break;
}
dir.normalize();
// Find a point on the intersection line: closest points between two planes.
// Project origin of plane A onto the intersection line.
Vec3d n0 = da.direction; // normal of plane A (stored temporarily)
Vec3d n1 = normal1;
Vec3d p0 = da.origin;
Vec3d p1 = origin1;
// Solve for point on line of intersection using vector formula.
double d0 = n0.dot(p0);
double d1 = n1.dot(p1);
double n0n1 = n0.dot(n1);
double det = 1.0 - n0n1 * n0n1;
if (std::abs(det) < 1e-18) { da.error = "planes are parallel (no intersection)"; break; }
double t0 = (d0 - d1 * n0n1) / det;
double t1 = (d1 - d0 * n0n1) / det;
da.origin = n0 * t0 + n1 * t1;
da.direction = dir;
break;
}
}
out.push_back(da);
}
return out;
}
std::vector<CadDocument::DatumCoordSys> CadDocument::resolve_datum_coordsys() const
{
std::vector<DatumCoordSys> out;
auto resolve_face = [&](int body_idx, int face_idx) -> TopoDS_Face {
if (face_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size()))
return TopoDS_Face();
return GeometryEngine::face_by_index(bodies[body_idx].shape, face_idx);
};
auto resolve_edge = [&](int body_idx, int edge_idx,
Vec3d& p0, Vec3d& dir) -> bool {
if (edge_idx < 0 || body_idx < 0 || body_idx >= int(bodies.size()))
return false;
TopoDS_Edge e = GeometryEngine::edge_by_index(bodies[body_idx].shape, edge_idx);
if (e.IsNull()) return false;
auto pts = GeometryEngine::sample_edge_world(e);
if (pts.size() < 2) return false;
p0 = pts.front();
dir = (pts.back() - pts.front()).normalized();
return true;
};
for (const CadFeature& f : features) {
if (f.type != CadFeatureType::CoordSys || !f.enabled) continue;
DatumCoordSys ds;
ds.name = f.name;
switch (f.coordsys_type) {
case CoordSysType::PointWorld: {
ds.origin = f.coordsys_point;
ds.x = Vec3d(1, 0, 0);
ds.y = Vec3d(0, 1, 0);
break;
}
case CoordSysType::FaceAndDirection: {
TopoDS_Face fc = resolve_face(f.coordsys_body, f.coordsys_face);
Vec3d p0, edge_dir;
bool have_edge = resolve_edge(f.coordsys_body, f.coordsys_edge, p0, edge_dir);
if (fc.IsNull()) { ds.error = "face not found"; break; }
ds.origin = GeometryEngine::face_centroid_world(fc);
Vec3d Z = GeometryEngine::face_normal_world(fc);
// Tentative X: edge direction if available, else the hint or a fallback.
Vec3d X_tent = have_edge ? edge_dir : f.coordsys_x_hint;
if (X_tent.squaredNorm() < 1e-18) { ds.error = "zero-length direction"; break; }
X_tent.normalize();
// Gram-Schmidt: ensure orthonormal, right-handed frame.
// Y = Z x X_tent, X = Y x Z (this makes X perpendicular to Z, not X_tent)
Vec3d Y = Z.cross(X_tent);
if (Y.squaredNorm() < 1e-12) {
// Edge/hint is parallel to Z -> X is degenerate; fall back to world X/Y orthonormalised.
Vec3d ref = (std::abs(Z.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
Y = Z.cross(ref);
if (Y.squaredNorm() < 1e-12) Y = Z.cross(Vec3d(0, 1, 0));
}
Y.normalize();
ds.x = Y.cross(Z).normalized();
ds.y = Y;
break;
}
}
out.push_back(ds);
}
return out;
}
void CadDocument::clear()
{
features.clear();
body = TopoDS_Shape();
bodies.clear(); // multibody result — must clear too (else solids linger)
display_mesh = TriangleMesh{};
display_body_meshes.clear();
display_tri_face.clear();
error.clear();
// A cleared document is a fresh start with no history.
m_undo.clear();
m_redo.clear();
}
void CadDocument::checkpoint()
{
m_undo.push_back(features); // snapshot the pre-mutation recipe
m_redo.clear(); // any new action invalidates the redo branch
if (m_undo.size() > k_undo_cap)
m_undo.erase(m_undo.begin());
}
bool CadDocument::undo()
{
if (m_undo.empty())
return false;
m_redo.push_back(std::move(features)); // current state becomes redoable
features = std::move(m_undo.back());
m_undo.pop_back();
recompute(); // benign-empty (only a sketch / empty doc) is a valid undo target
return true;
}
bool CadDocument::redo()
{
if (m_redo.empty())
return false;
m_undo.push_back(std::move(features));
features = std::move(m_redo.back());
m_redo.pop_back();
recompute();
return true;
}
// Re-run recompute(); if it fails for a GENUINE geometry error, restore `snapshot`
// and recompute that instead, so a rejected edit leaves the document exactly as it
// was. recompute() also returns false for the BENIGN case where the edit simply
// leaves no solid-producing feature (empty document, or only a sketch) — that is a
// valid result of a deletion, not a failure, so we accept it with an empty body.
static bool commit_or_rollback(CadDocument& doc, std::vector<CadFeature>& snapshot)
{
if (doc.recompute())
return true;
bool has_solid_feature = false;
for (const auto& f : doc.features)
if (f.enabled && f.type != CadFeatureType::Sketch) { has_solid_feature = true; break; }
if (!has_solid_feature) {
doc.bodies.clear();
doc.body = TopoDS_Shape();
doc.display_mesh = TriangleMesh{};
doc.display_body_meshes.clear();
doc.display_tri_face.clear();
doc.display_tri_body.clear();
doc.error.clear();
return true;
}
std::string fail_err = doc.error; // why the attempted edit failed
doc.features.swap(snapshot);
doc.recompute(); // restore the previous good body (clears error)
doc.error = fail_err.empty() ? std::string("feature is used by a later feature")
: fail_err;
return false;
}
bool CadDocument::remove_feature(int index)
{
if (index < 0 || index >= int(features.size()))
return false;
std::vector<CadFeature> snapshot = features;
// Deleting a Sketch cascades to every Extrude that consumes it (a dangling
// Extrude would have no wire). A lone Sketch, by contrast, is harmless.
std::vector<int> remove{index};
if (features[index].type == CadFeatureType::Sketch) {
for (int j = 0; j < int(features.size()); ++j)
if (features[j].type == CadFeatureType::Extrude && features[j].sketch_ref == index)
remove.push_back(j);
}
std::sort(remove.begin(), remove.end());
remove.erase(std::unique(remove.begin(), remove.end()), remove.end());
// Erase high-to-low so earlier indices stay valid.
for (auto it = remove.rbegin(); it != remove.rend(); ++it)
features.erase(features.begin() + *it);
// Remap surviving sketch_ref through the deletions: subtract the count of
// removed indices that sat before it; orphaned refs (target removed) -> -1.
for (auto& f : features) {
if (f.type != CadFeatureType::Extrude || f.sketch_ref < 0)
continue;
if (std::binary_search(remove.begin(), remove.end(), f.sketch_ref)) {
f.sketch_ref = -1;
} else {
int shift = 0;
for (int r : remove)
if (r < f.sketch_ref) ++shift;
f.sketch_ref -= shift;
}
}
return commit_or_rollback(*this, snapshot);
}
bool CadDocument::move_feature(int index, int delta)
{
if (index < 0 || index >= int(features.size()))
return false;
int target = index + delta;
if (target < 0 || target >= int(features.size()))
return true; // clamped at the ends — no-op, not a failure
std::vector<CadFeature> snapshot = features;
std::swap(features[index], features[target]);
// The two slots traded places: fix any sketch_ref that pointed at either.
for (auto& f : features) {
if (f.type != CadFeatureType::Extrude) continue;
if (f.sketch_ref == index) f.sketch_ref = target;
else if (f.sketch_ref == target) f.sketch_ref = index;
}
return commit_or_rollback(*this, snapshot);
}
bool CadDocument::replace_feature(int index, const CadFeature& edited)
{
if (index < 0 || index >= int(features.size()))
return false;
std::vector<CadFeature> snapshot = features;
// Preserve identity (name) and the structural link (sketch_ref) from the
// original; only the user-editable parameters come from `edited`.
CadFeature f = edited;
f.name = features[index].name;
f.type = features[index].type;
if (f.type == CadFeatureType::Extrude)
f.sketch_ref = features[index].sketch_ref;
features[index] = f;
return commit_or_rollback(*this, snapshot);
}
bool CadDocument::replace_sketch_extrude(int sketch_idx, int extrude_idx,
const CadFeature& edited)
{
if (sketch_idx < 0 || sketch_idx >= int(features.size())) return false;
if (extrude_idx < 0 || extrude_idx >= int(features.size())) return false;
std::vector<CadFeature> snapshot = features;
// A box in the tree is two linked features: the Sketch consumes the profile
// params (shape/plane/width/height/radius), the Extrude consumes the solid
// params (distance/symmetric/mode). `edited` carries all of them; split it
// back into the two slots, preserving each slot's name/type and the link.
CadFeature& sk = features[sketch_idx];
sk.shape = edited.shape;
sk.plane = edited.plane;
sk.width = edited.width;
sk.height = edited.height;
sk.radius = edited.radius;
CadFeature& ex = features[extrude_idx];
ex.distance = edited.distance;
ex.symmetric = edited.symmetric;
ex.mode = edited.mode;
return commit_or_rollback(*this, snapshot);
}
TopoDS_Wire CadDocument::build_sketch_wire(const CadFeature& sketch) const
{
if (!sketch.entities.empty()) {
TopoDS_Wire w = SketchEngine::entities_to_wire(sketch.entities, sketch.plane);
if (!w.IsNull()) return w;
// fall through to legacy paths if entities produced nothing
}
if (!sketch.profile.points.empty()) {
SketchProfile prof = sketch.profile;
prof.closed = true; // extrude needs a closed wire
TopoDS_Wire w = prof.to_occt_wire(sketch.plane);
if (w.IsNull()) throw std::runtime_error("sketch profile wire failed");
return w;
}
if (sketch.shape == SketchShape::Circle) {
gp_Pnt o(sketch.plane.origin.x(), sketch.plane.origin.y(), sketch.plane.origin.z());
gp_Dir n(sketch.plane.normal.x(), sketch.plane.normal.y(), sketch.plane.normal.z());
gp_Circ circ(gp_Ax2(o, n), sketch.radius);
TopoDS_Edge e = BRepBuilderAPI_MakeEdge(circ).Edge();
BRepBuilderAPI_MakeWire wm(e);
if (!wm.IsDone()) throw std::runtime_error("circle wire failed");
return wm.Wire();
}
// Rectangle centered on the plane origin
SketchProfile prof;
double hw = sketch.width * 0.5, hh = sketch.height * 0.5;
prof.points.push_back(Vec2d(-hw, -hh));
prof.points.push_back(Vec2d( hw, -hh));
prof.points.push_back(Vec2d( hw, hh));
prof.points.push_back(Vec2d(-hw, hh));
prof.closed = true;
return prof.to_occt_wire(sketch.plane);
}
void CadDocument::apply_feature(TopoDS_Shape& result, bool& have_body,
const TopoDS_Shape& context, const CadFeature& f) const
{
switch (f.type) {
case CadFeatureType::Sketch:
return; // sketches carry no solid; consumed by an extrude
case CadFeatureType::Helix:
return; // helical curve; consumed by Sweep as a path (like Sketch)
case CadFeatureType::Boolean:
return; // body-body boolean is handled in route_feature/apply_boolean, never here
case CadFeatureType::Import:
// Imported B-rep (STEP): rigid data carried on the feature, not built from
// parameters — adopt it as the new body (New-path: result starts empty).
result = f.imported_solid;
have_body = !result.IsNull();
return;
case CadFeatureType::Extrude: {
const bool sym = (f.extrude_end == ExtrudeEnd::Symmetric);
const double signed_d = f.flip ? -f.distance : f.distance;
TopoDS_Shape tool;
if (f.extrude_src_face >= 0) {
// The source face is read from `context` (the owner body), which for a New
// face-extrude is the source solid while `result` is the empty new body.
if (context.IsNull()) throw std::runtime_error("face-extrude needs a body");
TopoDS_Face srcf = GeometryEngine::face_by_index(context, f.extrude_src_face);
if (srcf.IsNull()) throw std::runtime_error("face-extrude: invalid face id");
SketchPlane fpl = SketchPlane::from_face(srcf);
// from_face takes the surface's geometric normal and IGNORES the topological
// face orientation, so for a REVERSED face (e.g. the top cap of an extruded
// prism) it points INTO the solid -> a default push would fuse to nothing.
// Orient it outward so push/pull grows away from the material (Onshape default);
// the Flip checkbox (signed_d) still lets the user drive it inward for a cut.
if (srcf.Orientation() == TopAbs_REVERSED) fpl.normal = -fpl.normal;
tool = SketchEngine::make_extrude_face(srcf, fpl, signed_d, sym);
} else {
// Use the referenced sketch when sketch_ref is a valid Sketch index,
// otherwise fall back to f's own inline sketch params (this makes a
// single self-contained candidate previewable).
const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size())
&& features[f.sketch_ref].type == CadFeatureType::Sketch)
? features[f.sketch_ref] : f;
// Imported rigid art (Text/SVG) extrudes via the faces-with-holes path
// (with its placement transform applied); otherwise build a single wire
// from entities/profile/shape.
tool = !sk.imported_regions.empty()
? SketchEngine::make_extrude_regions(
transform_regions(sk.imported_regions, sk.import_offset,
sk.import_scale_x, sk.import_scale_y),
sk.plane,
f.extrude_end == ExtrudeEnd::ThroughAll ? 1e5 : signed_d,
f.extrude_end == ExtrudeEnd::ThroughAll ? true : (sym || f.extrude_end == ExtrudeEnd::TwoSided))
: [&]() {
TopoDS_Wire wire = build_sketch_wire(sk);
TopoDS_Shape t;
switch (f.extrude_end) {
case ExtrudeEnd::Blind:
t = (std::abs(f.taper_deg) > 1e-6)
? SketchEngine::make_extrude_taper(wire, sk.plane, signed_d, f.taper_deg)
: SketchEngine::make_extrude(wire, sk.plane, signed_d, false);
break;
case ExtrudeEnd::Symmetric: t = SketchEngine::make_extrude(wire, sk.plane, f.distance, true); break;
case ExtrudeEnd::TwoSided: t = SketchEngine::make_extrude_two_sided(wire, sk.plane, f.distance, f.distance2); break;
case ExtrudeEnd::ThroughAll: t = SketchEngine::make_extrude(wire, sk.plane, 1.0e5, true); break;
case ExtrudeEnd::UpToFace: {
const TopoDS_Face tgt = GeometryEngine::face_by_index(context, f.up_to_face);
double L = signed_d;
if (!tgt.IsNull()) {
const Vec3d c = GeometryEngine::face_centroid_world(tgt);
L = (c - sk.plane.origin).dot(sk.plane.normal);
}
t = (std::abs(f.taper_deg) > 1e-6)
? SketchEngine::make_extrude_taper(wire, sk.plane, L, f.taper_deg)
: SketchEngine::make_extrude(wire, sk.plane, L, false);
break;
}
case ExtrudeEnd::UpToVertex: {
const double L = (f.up_to_point - sk.plane.origin).dot(sk.plane.normal);
t = SketchEngine::make_extrude(wire, sk.plane, L, false);
break;
}
default: t = SketchEngine::make_extrude(wire, sk.plane, signed_d, false); break;
}
return t;
}();
}
// New / first-of-a-body => result becomes the tool (route_feature sends New extrudes
// here with an empty result, so a face-extrude New builds a fresh body from the source
// face in `context` without touching it). Add/Cut/Intersect boolean into `result`.
if (!have_body || f.mode == BooleanMode::New) {
result = tool;
have_body = true;
} else if (f.mode == BooleanMode::Add) {
BRepAlgoAPI_Fuse fuse(result, tool);
if (!fuse.IsDone()) throw std::runtime_error("fuse failed");
result = fuse.Shape();
} else if (f.mode == BooleanMode::Cut) {
BRepAlgoAPI_Cut cut(result, tool);
if (!cut.IsDone()) throw std::runtime_error("cut failed");
result = cut.Shape();
} else if (f.mode == BooleanMode::Intersect) {
BRepAlgoAPI_Common common(result, tool);
if (!common.IsDone()) throw std::runtime_error("intersect failed");
result = common.Shape();
}
break;
}
case CadFeatureType::Revolve: {
// Resolve the profile sketch like Extrude: referenced Sketch when valid,
// else this feature's own inline entities/profile (self-contained candidate).
const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size())
&& features[f.sketch_ref].type == CadFeatureType::Sketch)
? features[f.sketch_ref] : f;
TopoDS_Wire wire = build_sketch_wire(sk);
const double ang = f.flip ? -f.revolve_angle : f.revolve_angle;
TopoDS_Shape tool = SketchEngine::make_revolve(wire, sk.plane, ang, f.revolve_axis);
if (!have_body || f.mode == BooleanMode::New) {
result = tool;
have_body = true;
} else if (f.mode == BooleanMode::Add) {
BRepAlgoAPI_Fuse fuse(result, tool);
if (!fuse.IsDone()) throw std::runtime_error("fuse failed");
result = fuse.Shape();
} else if (f.mode == BooleanMode::Cut) {
BRepAlgoAPI_Cut cut(result, tool);
if (!cut.IsDone()) throw std::runtime_error("cut failed");
result = cut.Shape();
} else if (f.mode == BooleanMode::Intersect) {
BRepAlgoAPI_Common common(result, tool);
if (!common.IsDone()) throw std::runtime_error("intersect failed");
result = common.Shape();
}
break;
}
case CadFeatureType::Sweep: {
const CadFeature& sk = (f.sketch_ref >= 0 && f.sketch_ref < int(features.size())
&& features[f.sketch_ref].type == CadFeatureType::Sketch)
? features[f.sketch_ref] : f;
if (f.sweep_path_ref < 0 || f.sweep_path_ref >= int(features.size()))
throw std::runtime_error("sweep needs a valid path reference");
const CadFeature& path_feat = features[f.sweep_path_ref];
TopoDS_Wire path;
if (path_feat.type == CadFeatureType::Helix) {
std::string helix_err;
path = make_helix_spine(path_feat, helix_err);
if (path.IsNull()) throw std::runtime_error("helix path: " + helix_err);
} else if (path_feat.type == CadFeatureType::Sketch) {
path = build_sketch_wire(path_feat);
} else {
throw std::runtime_error("sweep path must be a sketch or helix");
}
TopoDS_Wire profile = build_sketch_wire(sk);
TopoDS_Shape tool = SketchEngine::make_sweep(profile, path);
if (!have_body || f.mode == BooleanMode::New) {
result = tool;
have_body = true;
} else if (f.mode == BooleanMode::Add) {
BRepAlgoAPI_Fuse fuse(result, tool);
if (!fuse.IsDone()) throw std::runtime_error("fuse failed");
result = fuse.Shape();
} else if (f.mode == BooleanMode::Cut) {
BRepAlgoAPI_Cut cut(result, tool);
if (!cut.IsDone()) throw std::runtime_error("cut failed");
result = cut.Shape();
} else if (f.mode == BooleanMode::Intersect) {
BRepAlgoAPI_Common common(result, tool);
if (!common.IsDone()) throw std::runtime_error("intersect failed");
result = common.Shape();
}
break;
}
case CadFeatureType::Loft: {
// Loft through 2+ closed profile Sketches, in recipe order. Each profile builds
// a wire via build_sketch_wire (so it keeps its own plane); make_loft skins them.
std::vector<TopoDS_Wire> profiles;
for (int ref : f.loft_profile_refs) {
if (ref < 0 || ref >= int(features.size())
|| features[ref].type != CadFeatureType::Sketch)
continue;
profiles.push_back(build_sketch_wire(features[ref]));
}
if (profiles.size() < 2)
throw std::runtime_error("loft needs 2+ valid profile sketches");
TopoDS_Shape tool = SketchEngine::make_loft(profiles, f.loft_ruled);
if (!have_body || f.mode == BooleanMode::New) {
result = tool;
have_body = true;
} else if (f.mode == BooleanMode::Add) {
BRepAlgoAPI_Fuse fuse(result, tool);
if (!fuse.IsDone()) throw std::runtime_error("fuse failed");
result = fuse.Shape();
} else if (f.mode == BooleanMode::Cut) {
BRepAlgoAPI_Cut cut(result, tool);
if (!cut.IsDone()) throw std::runtime_error("cut failed");
result = cut.Shape();
} else if (f.mode == BooleanMode::Intersect) {
BRepAlgoAPI_Common common(result, tool);
if (!common.IsDone()) throw std::runtime_error("intersect failed");
result = common.Shape();
}
break;
}
case CadFeatureType::Pattern: {
// Replicate the target body. Each copy is a rigid gp_Trsf of the seed, all
// fused into one body. Linear: i*spacing along plane axis pattern_dir
// (0=X,1=Y). Circular: i*(angle/count) about the plane normal through the
// plane origin (so a seed offset from the origin orbits the axis).
if (!have_body) throw std::runtime_error("pattern needs a body");
const int n = std::max(1, f.pattern_count);
const TopoDS_Shape seed = result;
for (int i = 1; i < n; ++i) {
gp_Trsf trsf;
if (f.pattern_circular) {
Vec3d o = f.plane.to_world(Vec2d(0, 0));
gp_Ax1 ax(gp_Pnt(o.x(), o.y(), o.z()),
gp_Dir(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z()));
const double step = (f.pattern_angle * M_PI / 180.0) / double(n);
trsf.SetRotation(ax, step * i);
} else {
const Vec3d& d = (f.pattern_dir == 1) ? f.plane.y_axis : f.plane.x_axis;
trsf.SetTranslation(gp_Vec(d.x() * f.pattern_spacing * i,
d.y() * f.pattern_spacing * i,
d.z() * f.pattern_spacing * i));
}
TopoDS_Shape copy = BRepBuilderAPI_Transform(seed, trsf, true).Shape();
BRepAlgoAPI_Fuse fuse(result, copy);
if (!fuse.IsDone()) throw std::runtime_error("pattern fuse failed");
result = fuse.Shape();
}
break;
}
case CadFeatureType::Fillet:
if (!have_body) throw std::runtime_error("fillet needs a body");
if (f.dressup_edge >= 0)
result = GeometryEngine::apply_fillet(result, f.dressup_size, f.dressup_edge);
else
result = GeometryEngine::apply_fillet(result, f.dressup_size, f.face_group);
break;
case CadFeatureType::Chamfer:
if (!have_body) throw std::runtime_error("chamfer needs a body");
if (f.dressup_edge >= 0)
result = GeometryEngine::apply_chamfer(result, f.dressup_size, f.dressup_edge);
else
result = GeometryEngine::apply_chamfer(result, f.dressup_size, f.face_group);
break;
case CadFeatureType::Hole: {
if (!have_body) throw std::runtime_error("hole needs a body");
// Circle wire centered at the positioned point on the plane
Vec3d c = f.plane.to_world(Vec2d(f.hole_x, f.hole_y));
gp_Pnt o(c.x(), c.y(), c.z());
gp_Dir n(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z());
gp_Circ circ(gp_Ax2(o, n), f.hole_diameter * 0.5);
TopoDS_Edge e = BRepBuilderAPI_MakeEdge(circ).Edge();
BRepBuilderAPI_MakeWire wm(e);
if (!wm.IsDone()) throw std::runtime_error("hole wire failed");
// Through = symmetric huge cut (passes fully through any body);
// Blind = +normal extrude of hole_depth into the body.
TopoDS_Shape tool = f.hole_through
? SketchEngine::make_extrude(wm.Wire(), f.plane, 1.0e5, true, 0.0)
: SketchEngine::make_extrude(wm.Wire(), f.plane, f.hole_depth, false, 0.0);
BRepAlgoAPI_Cut cut(result, tool);
if (!cut.IsDone()) throw std::runtime_error("hole cut failed");
result = cut.Shape();
break;
}
case CadFeatureType::Thread: {
// Reject degenerate parameters that make OCCT's helical sweep / boolean unstable (a tiny
// pitch, depth >= half-pitch, an enormous turn count, depth eating the whole wall). Better
// a no-op than a crash. Leave the body unchanged when the spec can't be built safely.
{
const double R = f.thread_radius, P = f.thread_pitch, H = f.thread_height, D = f.thread_depth;
// ISO external thread depth is ~0.61*P, so allow up to 0.7*P (0.49 wrongly rejected
// every real thread -> nothing rendered). Still bound it well under a full pitch.
const bool ok = R > 0.5 && P > 0.1 && D > 1e-3 && D < 0.7 * P && D < 0.45 * R
&& H > 0.5 * P && (H / P) < 400.0;
if (!ok) break; // result/have_body untouched
}
// Axis at the positioned point on the plane; +normal = thread rise.
Vec3d c3 = f.plane.to_world(Vec2d(f.thread_x, f.thread_y));
gp_Pnt c(c3.x(), c3.y(), c3.z());
gp_Dir zdir(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z());
gp_Dir xdir(f.plane.x_axis.x(), f.plane.x_axis.y(), f.plane.x_axis.z());
gp_Ax3 ax3(c, zdir, xdir);
gp_Ax2 ax2(c, zdir, xdir);
// Build the swept helical ridge (guarded — never fatal).
TopoDS_Shape ridge;
bool have_ridge = false;
try {
TopoDS_Wire spine = make_helix_wire(ax3, f.thread_radius,
f.thread_pitch, f.thread_height);
TopoDS_Wire prof = make_thread_profile(c, xdir, zdir, f.thread_radius,
f.thread_pitch, f.thread_depth,
f.thread_internal);
// MakePipeShell with a FIXED BINORMAL = cylinder axis keeps the V-profile's orientation
// constant along the helix (axial edge always parallel to the axis, V always pointing
// radially out). The plain MakePipe used a Frenet frame that TWISTED the profile around
// the helix -> the wedge inclination varied and looked mirrored.
BRepOffsetAPI_MakePipeShell pipe(spine);
pipe.SetMode(zdir);
pipe.Add(prof);
pipe.Build();
if (pipe.IsDone() && pipe.MakeSolid()) {
ridge = pipe.Shape();
have_ridge = !ridge.IsNull();
}
} catch (const std::exception&) {
have_ridge = false; // fall back to the bare cylinder/bore below
} catch (const Standard_Failure&) {
have_ridge = false; // OCCT failure (not a std::exception) — must be caught here too
}
if (f.thread_internal) {
if (!have_body) throw std::runtime_error("internal thread needs a body");
// Tapped bore: ensure a clean cylindrical pocket, then carve the
// OUTWARD helical groove into its wall. When the thread is invoked on
// an existing hole the bore cut is coincident (a no-op that may report
// !IsDone) — tolerate it so the visible groove cut below still runs.
// Cut the pocket at the MINOR diameter (radius - depth), not the nominal radius.
// A nominal-radius bore that coincides with an existing hole's wall creates
// coincident faces that foul the following groove boolean (the groove then removes
// ~nothing -> invisible thread). The minor bore stays strictly inside any existing
// hole wall, leaving it clean for the groove; on solid stock it forms the tap-drill.
const double bore_r = std::max(0.5, f.thread_radius - f.thread_depth);
TopoDS_Shape bore = BRepPrimAPI_MakeCylinder(ax2, bore_r,
f.thread_height).Shape();
try {
BRepAlgoAPI_Cut cut_bore(result, bore);
if (cut_bore.IsDone() && !cut_bore.Shape().IsNull())
result = cut_bore.Shape();
} catch (const std::exception&) { /* keep existing bore */ }
if (have_ridge) {
BRepAlgoAPI_Cut cut_ridge(result, ridge);
if (cut_ridge.IsDone() && !cut_ridge.Shape().IsNull())
result = cut_ridge.Shape();
}
} else {
// External thread: FUSE the helical ridge ONTO the existing body (the picked cylinder),
// leaving the rest of the part intact. Replacing the body with a bare rod — the old
// behaviour — wiped whatever the user picked; that was the "mess". With no body yet
// (a thread from scratch on a dropdown plane), fall back to a standalone threaded rod.
if (have_body && !result.IsNull()) {
if (have_ridge) {
BRepAlgoAPI_Fuse fuse(result, ridge);
if (fuse.IsDone() && !fuse.Shape().IsNull()) result = fuse.Shape();
}
} else {
TopoDS_Shape rod = BRepPrimAPI_MakeCylinder(ax2, f.thread_radius,
f.thread_height).Shape();
if (have_ridge) {
BRepAlgoAPI_Fuse fuse(rod, ridge);
if (fuse.IsDone()) rod = fuse.Shape();
}
result = rod;
have_body = true;
}
}
break;
}
case CadFeatureType::Shell: {
if (!have_body) throw std::runtime_error("shell needs a body");
// Hollow the body to a wall thickness; the picked face (if any) is removed so the
// shell is open there. MakeThickSolidByJoin with a NEGATIVE offset shells inward.
TopTools_ListOfShape remove;
if (f.shell_face >= 0) {
TopoDS_Face fc = GeometryEngine::face_by_index(result, f.shell_face);
if (!fc.IsNull()) remove.Append(fc);
}
BRepOffsetAPI_MakeThickSolid mts;
mts.MakeThickSolidByJoin(result, remove, -std::abs(f.shell_thickness), 1.0e-3);
mts.Build();
if (!mts.IsDone()) throw std::runtime_error("shell failed");
result = mts.Shape();
if (result.IsNull()) throw std::runtime_error("shell produced no geometry");
break;
}
case CadFeatureType::Draft: {
if (!have_body) throw std::runtime_error("draft needs a body");
if (f.draft_face < 0) throw std::runtime_error("draft needs a picked face");
TopoDS_Face fc = GeometryEngine::face_by_index(result, f.draft_face);
if (fc.IsNull()) throw std::runtime_error("draft: face not found");
// Neutral plane = horizontal plane through the body's bbox bottom, pull direction +Z.
// The face pivots about the line where it meets the neutral plane and tilts by the angle.
// ponytail: neutral plane / pull direction fixed to world up; pick-based neutral plane
// deferred (same as the datum-plane pick types, snaporca-dgv).
Bnd_Box bb; BRepBndLib::Add(result, bb);
Standard_Real xmin, ymin, zmin, xmax, ymax, zmax;
bb.Get(xmin, ymin, zmin, xmax, ymax, zmax);
gp_Dir pull(0, 0, 1);
gp_Pln neutral(gp_Pnt(0, 0, zmin), pull);
BRepOffsetAPI_DraftAngle draft(result);
draft.Add(fc, pull, f.draft_angle * M_PI / 180.0, neutral);
if (!draft.AddDone())
throw std::runtime_error("draft: face cannot be drafted (is it parallel to the base?)");
draft.Build();
if (!draft.IsDone()) throw std::runtime_error("draft failed");
result = draft.Shape();
if (result.IsNull()) throw std::runtime_error("draft produced no geometry");
break;
}
}
}
// Compound of all body shapes (1 body => that body verbatim, so single-body display and
// global face/edge ids are byte-identical to the pre-multi-body behaviour).
static TopoDS_Shape compound_of(const std::vector<CadBody>& bodies)
{
if (bodies.size() == 1) return bodies[0].shape;
TopoDS_Compound comp;
BRep_Builder bld;
bld.MakeCompound(comp);
for (const CadBody& b : bodies)
if (!b.shape.IsNull()) bld.Add(comp, b.shape);
return comp;
}
// Tessellate every body separately and concatenate into one mesh, recording per-triangle
// (body index, face id WITHIN that body). Single-body => byte-identical to tessellate(body).
static TriangleMesh tessellate_bodies(const std::vector<CadBody>& bodies,
std::vector<int>& tri_face, std::vector<int>& tri_body,
std::vector<TriangleMesh>& body_meshes,
double lin, double ang)
{
tri_face.clear();
tri_body.clear();
body_meshes.clear();
indexed_triangle_set merged;
for (int bi = 0; bi < int(bodies.size()); ++bi) {
std::vector<int> tf;
TriangleMesh bm = SketchEngine::tessellate(bodies[bi].shape, tf, lin, ang);
const indexed_triangle_set& its = bm.its;
const int voff = int(merged.vertices.size());
for (const auto& v : its.vertices) merged.vertices.push_back(v);
for (const auto& t : its.indices)
merged.indices.emplace_back(t[0] + voff, t[1] + voff, t[2] + voff);
for (int fid : tf) { tri_face.push_back(fid); tri_body.push_back(bi); }
body_meshes.push_back(std::move(bm)); // per-body mesh kept for distinct GLVolume colors
}
return TriangleMesh(merged);
}
void CadDocument::apply_boolean(std::vector<CadBody>& bodies, const CadFeature& f) const
{
const int nb = int(bodies.size());
const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1;
const int tool = (f.bool_tool_body >= 0 && f.bool_tool_body < nb) ? f.bool_tool_body : -1;
if (tgt < 0 || tool < 0 || tgt == tool) return; // need two distinct bodies; otherwise no-op
const TopoDS_Shape A = bodies[tgt].shape; // target survives
const TopoDS_Shape B = bodies[tool].shape; // tool, consumed unless kept
if (A.IsNull() || B.IsNull()) return;
TopTools_ListOfShape args, tools;
args.Append(A);
tools.Append(B);
auto run = [&](BRepAlgoAPI_BooleanOperation& bop) -> TopoDS_Shape {
bop.SetArguments(args);
bop.SetTools(tools);
if (f.bool_tolerance > 0.0) bop.SetFuzzyValue(f.bool_tolerance); // OCCT fuzzy: merge near-coincident faces
bop.Build();
if (!bop.IsDone()) throw std::runtime_error("boolean operation failed");
return bop.Shape();
};
TopoDS_Shape result;
switch (f.mode) {
case BooleanMode::Add: { BRepAlgoAPI_Fuse op; result = run(op); break; } // union
case BooleanMode::Cut: { BRepAlgoAPI_Cut op; result = run(op); break; } // target - tool
case BooleanMode::Intersect: { BRepAlgoAPI_Common op; result = run(op); break; } // overlap
default: return; // BooleanMode::New is meaningless between two existing bodies
}
if (result.IsNull()) throw std::runtime_error("boolean produced an empty shape");
bodies[tgt].shape = result;
if (!f.bool_keep_tool) bodies.erase(bodies.begin() + tool); // consume the tool body
}
void CadDocument::apply_cut(std::vector<CadBody>& bodies, const CadFeature& f) const
{
const int nb = int(bodies.size());
if (nb == 0) throw std::runtime_error("cut: no target body");
const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1;
if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("cut: no target body");
if (!f.cut_keep_upper && !f.cut_keep_lower)
throw std::runtime_error("cut keeps nothing");
SketchPlane cp = f.plane;
cp.origin += cp.normal * f.cut_offset;
if (f.cut_flip) cp.normal = -cp.normal;
// Build a large square wire in the cut plane, centered at plane origin.
const double L = 1.0e5;
Vec3d x = cp.x_axis * L;
Vec3d y = cp.y_axis * L;
Vec3d o = cp.origin;
auto p = [&](double sx, double sy) {
Vec3d v = o + x * sx + y * sy;
return gp_Pnt(v.x(), v.y(), v.z());
};
BRepBuilderAPI_MakePolygon poly;
poly.Add(p( 1, 1));
poly.Add(p( 1, -1));
poly.Add(p(-1, -1));
poly.Add(p(-1, 1));
poly.Close();
if (!poly.IsDone()) throw std::runtime_error("cut: failed to build cut wire");
TopoDS_Wire wire = poly.Wire();
TopoDS_Shape upper_piece, lower_piece;
const TopoDS_Shape& target = bodies[tgt].shape;
if (f.cut_keep_upper) {
TopoDS_Shape upper_tool = SketchEngine::make_extrude(wire, cp, L, false, 0.0);
BRepAlgoAPI_Common common(target, upper_tool);
if (!common.IsDone()) throw std::runtime_error("cut operation failed");
upper_piece = common.Shape();
}
if (f.cut_keep_lower) {
SketchPlane lp = cp;
lp.normal = -lp.normal;
TopoDS_Shape lower_tool = SketchEngine::make_extrude(wire, lp, L, false, 0.0);
BRepAlgoAPI_Common common(target, lower_tool);
if (!common.IsDone()) throw std::runtime_error("cut operation failed");
lower_piece = common.Shape();
}
if (f.cut_keep_upper && f.cut_keep_lower) {
bodies[tgt].shape = upper_piece;
bodies.insert(bodies.begin() + tgt + 1,
CadBody{ lower_piece, bodies[tgt].name + " (2)" });
} else if (f.cut_keep_upper) {
bodies[tgt].shape = upper_piece;
} else {
bodies[tgt].shape = lower_piece;
}
}
void CadDocument::apply_mirror(std::vector<CadBody>& bodies, const CadFeature& f) const
{
const int nb = int(bodies.size());
if (nb == 0) throw std::runtime_error("mirror: no target body");
const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1;
if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("mirror: no target body");
const TopoDS_Shape& src = bodies[tgt].shape;
gp_Trsf trsf;
trsf.SetMirror(gp_Ax2(gp_Pnt(f.plane.origin.x(), f.plane.origin.y(), f.plane.origin.z()),
gp_Dir(f.plane.normal.x(), f.plane.normal.y(), f.plane.normal.z())));
BRepBuilderAPI_Transform xform(src, trsf, true /*copy*/);
if (!xform.IsDone()) throw std::runtime_error("mirror: transform failed");
TopoDS_Shape mirrored = xform.Shape();
// A mirror reverses orientation — verify the result has positive volume.
{
GProp_GProps props;
BRepGProp::VolumeProperties(mirrored, props);
if (props.Mass() <= 0.0) {
// Flip orientation to get a valid forward solid.
mirrored.Reverse();
BRepGProp::VolumeProperties(mirrored, props);
if (props.Mass() <= 0.0)
throw std::runtime_error("mirror: result has zero or negative volume");
}
}
switch (f.mode) {
case BooleanMode::Add: {
BRepAlgoAPI_Fuse fuse(src, mirrored);
if (!fuse.IsDone()) throw std::runtime_error("mirror fuse failed");
bodies[tgt].shape = fuse.Shape();
break;
}
case BooleanMode::New: {
if (!f.mirror_keep_original)
bodies.erase(bodies.begin() + tgt); // replace: the mirrored copy takes the source slot
bodies.push_back({mirrored, f.name.empty() ? std::string("Mirror") : f.name});
break;
}
default:
throw std::runtime_error("mirror: mode must be New or Add");
}
}
void CadDocument::apply_transform(std::vector<CadBody>& bodies, const CadFeature& f) const
{
const int nb = int(bodies.size());
if (nb == 0) throw std::runtime_error("transform: no target body");
const int tgt = (f.target_body >= 0 && f.target_body < nb) ? f.target_body : nb - 1;
if (tgt < 0 || bodies[tgt].shape.IsNull()) throw std::runtime_error("transform: no target body");
gp_Trsf rot;
if (std::abs(f.xf_angle_deg) > 1e-12) {
if (f.xf_axis.norm() < 1e-9)
throw std::runtime_error("transform: rotation axis is degenerate");
rot.SetRotation(gp_Ax1(gp_Pnt(f.xf_pivot.x(), f.xf_pivot.y(), f.xf_pivot.z()),
gp_Dir(f.xf_axis.x(), f.xf_axis.y(), f.xf_axis.z())),
f.xf_angle_deg * M_PI / 180.0);
}
gp_Trsf tr;
tr.SetTranslation(gp_Vec(f.xf_translate.x(), f.xf_translate.y(), f.xf_translate.z()));
const gp_Trsf trsf = tr * rot; // rotate first, then translate
BRepBuilderAPI_Transform xform(bodies[tgt].shape, trsf, true /*copy*/);
if (!xform.IsDone()) throw std::runtime_error("transform: failed");
TopoDS_Shape moved = xform.Shape();
if (f.xf_copy)
bodies.push_back({moved, f.name.empty() ? std::string("Transform") : f.name});
else
bodies[tgt].shape = moved;
}
void CadDocument::route_feature(std::vector<CadBody>& bodies, const CadFeature& f) const
{
if (f.type == CadFeatureType::Plane) return; // datum plane: not part of the body pipeline
if (f.type == CadFeatureType::Axis) return; // datum axis
if (f.type == CadFeatureType::CoordSys) return; // datum coordinate system
if (f.type == CadFeatureType::Helix) return; // helical curve; consumed by Sweep
if (f.type == CadFeatureType::Boolean) { apply_boolean(bodies, f); return; } // body-body op
if (f.type == CadFeatureType::Cut) { apply_cut(bodies, f); return; } // plane-split body
if (f.type == CadFeatureType::Mirror) { apply_mirror(bodies, f); return; } // mirror body about plane
if (f.type == CadFeatureType::Transform) { apply_transform(bodies, f); return; } // move/rotate body
// Resolve the target body: explicit target_body when valid, else the last body.
const int t = (f.target_body >= 0 && f.target_body < int(bodies.size()))
? f.target_body : int(bodies.size()) - 1;
const TopoDS_Shape context = (t >= 0) ? bodies[t].shape : TopoDS_Shape();
// A New extrude (or the very first solid feature) starts a fresh body; everything else
// mutates the target body in place.
const bool starts_new = bodies.empty()
|| f.type == CadFeatureType::Import // an imported solid is always its own base body
|| ((f.type == CadFeatureType::Extrude || f.type == CadFeatureType::Revolve
|| f.type == CadFeatureType::Sweep || f.type == CadFeatureType::Loft)
&& f.mode == BooleanMode::New);
if (starts_new) {
TopoDS_Shape result; // empty -> apply_feature fills it (New path)
bool have_body = false;
apply_feature(result, have_body, context, f);
if (have_body && !result.IsNull())
bodies.push_back({ result, f.name.empty() ? std::string("Body") : f.name });
} else {
if (t < 0) throw std::runtime_error("feature needs a body");
TopoDS_Shape result = bodies[t].shape; // shallow handle; apply_feature mutates it
bool have_body = true;
apply_feature(result, have_body, context, f);
bodies[t].shape = result;
}
}
bool CadDocument::recompute()
{
error.clear();
std::vector<CadBody> built;
try {
for (const CadFeature& f : features) {
if (!f.enabled) continue;
if (f.type == CadFeatureType::Sketch) continue; // consumed by an extrude
if (f.type == CadFeatureType::Helix) continue; // consumed by Sweep as a path
if (f.type == CadFeatureType::Plane) continue; // datum: no solid, derived on demand
if (f.type == CadFeatureType::Axis) continue; // datum axis
if (f.type == CadFeatureType::CoordSys) continue; // datum coordinate system
route_feature(built, f);
}
} catch (const Standard_Failure& e) {
// OCCT raises Standard_Failure (NOT a std::exception) — must be caught
// here or it escapes the event handler and terminates the app.
error = e.GetMessageString() ? e.GetMessageString() : "OCCT operation failed";
return false;
} catch (const std::exception& e) {
error = e.what();
return false;
} catch (...) {
error = "unknown geometry error";
return false;
}
if (built.empty()) { error = "no solid-producing features"; return false; }
// recompute() replaces the bodies vector wholesale, which would drop any per-body
// colour override (Color tool). Body indices are stable across a rebuild (bodies are
// appended in feature order), so carry the override forward by index — same indexing
// contract the GUI relies on for per-body visibility/Move.
for (size_t i = 0; i < built.size() && i < bodies.size(); ++i) {
if (bodies[i].has_color) {
built[i].has_color = true;
built[i].color = bodies[i].color;
}
}
bodies = std::move(built);
body = compound_of(bodies);
display_mesh = tessellate_bodies(bodies, display_tri_face, display_tri_body,
display_body_meshes,
linear_deflection, angular_deflection);
if (display_mesh.its.indices.empty()) {
error = "tessellation produced an empty mesh";
return false;
}
return true;
}
bool CadDocument::preview(const CadFeature& candidate, TriangleMesh& out_mesh,
std::vector<TriangleMesh>& out_body_meshes, std::string& err) const
{
err.clear();
out_body_meshes.clear();
std::vector<CadBody> tmp = bodies; // start from the current committed bodies
try {
route_feature(tmp, candidate); // candidate may append a new body or mutate one
} catch (const Standard_Failure& e) {
err = e.GetMessageString() ? e.GetMessageString() : "OCCT operation failed";
return false;
} catch (const std::exception& e) {
err = e.what();
return false;
} catch (...) {
err = "unknown geometry error";
return false;
}
if (tmp.empty()) {
err = "preview produced no geometry";
return false;
}
// Tessellate per body (same path as recompute) so the GUI can re-apply its display-only
// per-body Move transforms to the ghost; out_mesh is the merged whole.
std::vector<int> tf, tb;
out_mesh = tessellate_bodies(tmp, tf, tb, out_body_meshes, linear_deflection, angular_deflection);
if (out_mesh.its.indices.empty()) {
err = "preview produced an empty mesh";
return false;
}
return true;
}
bool CadDocument::preview(const CadFeature& candidate, TriangleMesh& out_mesh, std::string& err) const
{
std::vector<TriangleMesh> ignore;
return preview(candidate, out_mesh, ignore, err);
}
std::string brep_to_string(const TopoDS_Shape& s)
{
if (s.IsNull()) return {};
std::ostringstream oss;
BRepTools::Write(s, oss);
return oss.str();
}
TopoDS_Shape brep_from_string(const std::string& d)
{
if (d.empty()) return {};
std::istringstream iss(d);
TopoDS_Shape s;
BRep_Builder b;
BRepTools::Read(s, iss, b);
return s;
}
std::string CadDocument::serialize_recipe() const
{
std::ostringstream oss;
{
cereal::BinaryOutputArchive ar(oss);
uint32_t v = SNAPORCA_CAD_RECIPE_VERSION;
ar(v);
ar(features);
}
return oss.str();
}
bool CadDocument::deserialize_recipe(const std::string& blob)
{
error.clear();
try {
std::istringstream iss(blob);
cereal::BinaryInputArchive ar(iss);
uint32_t v;
ar(v);
if (v > SNAPORCA_CAD_RECIPE_VERSION) {
error = "saved with a newer version of SnapOrca CAD (format v"
+ std::to_string(v) + ", this build reads up to v"
+ std::to_string(SNAPORCA_CAD_RECIPE_VERSION) + ")";
return false;
}
if (v < SNAPORCA_CAD_RECIPE_VERSION) {
error = "saved with an older version of SnapOrca CAD (format v"
+ std::to_string(v) + "); this project cannot be opened by this build";
return false;
}
ar(features);
return recompute();
} catch (const Standard_Failure& e) {
const char* what = e.GetMessageString();
error = std::string("CAD data could not be read")
+ (what && *what ? ": " + std::string(what) : "");
return false;
} catch (const std::exception& e) {
error = std::string("CAD data could not be read: ") + e.what();
return false;
} catch (...) {
error = "CAD data could not be read";
return false;
}
}
bool CadDocument::export_step(const std::string& path,
const std::vector<Transform3d>& body_xforms,
std::string& err) const
{
err.clear();
if (bodies.empty()) { err = "nothing to export"; return false; }
try {
// Compound every body at its displayed (Move-gizmo) position so the STEP matches
// what Commit ships. Move transforms are rigid, so gp_Trsf::SetValues is valid.
BRep_Builder bld;
TopoDS_Compound comp;
bld.MakeCompound(comp);
for (size_t i = 0; i < bodies.size(); ++i) {
if (bodies[i].shape.IsNull()) continue;
TopoDS_Shape s = bodies[i].shape;
if (i < body_xforms.size() && !body_xforms[i].isApprox(Transform3d::Identity())) {
const Transform3d& m = body_xforms[i];
gp_Trsf t;
t.SetValues(m(0,0), m(0,1), m(0,2), m(0,3),
m(1,0), m(1,1), m(1,2), m(1,3),
m(2,0), m(2,1), m(2,2), m(2,3));
s = BRepBuilderAPI_Transform(s, t, true).Shape();
}
bld.Add(comp, s);
}
STEPControl_Writer writer;
if (writer.Transfer(comp, STEPControl_AsIs) != IFSelect_RetDone) {
err = "STEP transfer failed";
return false;
}
if (writer.Write(path.c_str()) != IFSelect_RetDone) {
err = "cannot write STEP file";
return false;
}
} catch (const Standard_Failure& e) {
err = e.GetMessageString() ? e.GetMessageString() : "OCCT failed to write STEP";
return false;
} catch (const std::exception& e) {
err = e.what();
return false;
}
return true;
}
GeometryEngine::MassProps CadDocument::body_mass_properties(int body_index) const
{
if (body_index < 0 || body_index >= int(bodies.size())) return {};
return GeometryEngine::mass_properties(bodies[body_index].shape);
}
} // namespace Slic3r