mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 22:42:37 +00:00
Two defects found while driving the tools that Phase B wired but nobody had exercised yet. apply_project had no all-edges branch: with no face picked and no explicit edge list it threw "no edges or face selected". That is precisely the state the Project card opens in, and its label reads "(all edges)" — so the card's default could never be confirmed. It now projects every edge of the source body. Edges perpendicular to the target plane collapse to a point when projected, so segments whose endpoints coincide are dropped instead of being emitted as zero-length lines that would poison the sketch downstream. The second defect is why the first one was invisible. 29 of the 31 rollback sites in McpControl ran `if (!ok) doc.undo();`, and undo() recomputes the restored feature list — which succeeds and clears doc.error. Every failing command therefore reported `error: ""`. Yesterday's fix covered 2 sites and I treated the file as done; it was not. All 31 now capture the reason before the rollback and restore it after. Failures that read as `""` now read as "rib: bad entity" / "surface-revolve: revolve failed". Verified on the running GUI through the control socket: the Project call that previously returned ok:false now returns ok:true, and failures carry a reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6471 lines
252 KiB
C++
6471 lines
252 KiB
C++
#include <catch2/catch_all.hpp> // mainline OrcaSlicer ships Catch2 v3 (v2 was catch2/catch.hpp)
|
|
|
|
// Substring assertions, spelled so this file compiles UNCHANGED on both forks.
|
|
// Catch2 v2 (snaporca) spells it Matchers::Contains; v3 (orca_cad / mainline) spells it
|
|
// Matchers::ContainsSubstring and gives Contains an incompatible meaning — range-contains-
|
|
// ELEMENT — which fails to compile against a std::string rather than failing a test.
|
|
// Using find() sidesteps the rename entirely; INFO keeps the actual string in the report.
|
|
#define REQUIRE_CONTAINS(str, sub) \
|
|
do { const std::string _actual = (str); INFO("actual: " << _actual); \
|
|
REQUIRE(_actual.find(sub) != std::string::npos); } while (0)
|
|
#define CHECK_CONTAINS(str, sub) \
|
|
do { const std::string _actual = (str); INFO("actual: " << _actual); \
|
|
CHECK(_actual.find(sub) != std::string::npos); } while (0)
|
|
|
|
#include "libslic3r/CadDocument.hpp"
|
|
#include "libslic3r/GeometryEngine.hpp"
|
|
#include "libslic3r/SketchEngine.hpp"
|
|
#include "libslic3r/SketchImport.hpp"
|
|
#include "libslic3r/ThreadStandards.hpp"
|
|
#include "libslic3r/Utils.hpp"
|
|
|
|
#include <cmath>
|
|
#include <fstream>
|
|
#include <memory>
|
|
#include <set>
|
|
#include <Bnd_Box.hxx>
|
|
#include <BRepBndLib.hxx>
|
|
#include <BRepAdaptor_Curve.hxx>
|
|
#include <TopExp_Explorer.hxx>
|
|
#include <TopoDS.hxx>
|
|
#include <BRepGProp.hxx>
|
|
#include <GProp_GProps.hxx>
|
|
#include <BRepPrimAPI_MakeBox.hxx>
|
|
#include <BRepBuilderAPI_Transform.hxx>
|
|
#include <gp_Trsf.hxx>
|
|
#include <gp_Vec.hxx>
|
|
#include <cereal/archives/binary.hpp>
|
|
|
|
using namespace Slic3r;
|
|
using Catch::Approx; // Catch2 v3 scopes Approx under Catch:: (v2 had it unqualified)
|
|
|
|
TEST_CASE("CadDocument profile sketch -> extrude -> solid", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
|
|
SketchProfile sp;
|
|
sp.points.push_back(Vec2d(-10, -10));
|
|
sp.points.push_back(Vec2d( 10, -10));
|
|
sp.points.push_back(Vec2d( 10, 10));
|
|
sp.points.push_back(Vec2d(-10, 10));
|
|
sp.closed = true;
|
|
|
|
int sk_idx = doc.add_sketch_profile(sp, SketchPlane::XY(), "SquareProfile");
|
|
REQUIRE(sk_idx >= 0);
|
|
doc.add_extrude(sk_idx, 5.0, false, BooleanMode::New, "Extrude1");
|
|
|
|
bool ok = doc.recompute();
|
|
REQUIRE(ok);
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
}
|
|
|
|
TEST_CASE("CadDocument legacy Rectangle sketch still works", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
|
|
int sk_idx = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
20, 20, 10, "RectSketch");
|
|
REQUIRE(sk_idx >= 0);
|
|
doc.add_extrude(sk_idx, 5.0, false, BooleanMode::New, "Extrude1");
|
|
|
|
bool ok = doc.recompute();
|
|
REQUIRE(ok);
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
}
|
|
|
|
TEST_CASE("CadDocument preview with profile on self-contained candidate", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
|
|
CadFeature candidate;
|
|
candidate.type = CadFeatureType::Extrude;
|
|
candidate.plane = SketchPlane::XY();
|
|
candidate.distance = 4;
|
|
candidate.mode = BooleanMode::New;
|
|
// sketch_ref is -1 by default -> apply_feature uses candidate's own params
|
|
|
|
SketchProfile tri;
|
|
tri.points.push_back(Vec2d(0, 0));
|
|
tri.points.push_back(Vec2d(10, 0));
|
|
tri.points.push_back(Vec2d(5, 8.66));
|
|
tri.closed = true;
|
|
candidate.profile = tri;
|
|
|
|
TriangleMesh mesh;
|
|
std::string err;
|
|
bool ok = doc.preview(candidate, mesh, err);
|
|
REQUIRE(ok);
|
|
REQUIRE(mesh.facets_count() > 0);
|
|
}
|
|
|
|
TEST_CASE("CadDocument solve_sketch_feature snaps a rough quad to a rectangle", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
SketchProfile sp;
|
|
sp.points = { Vec2d(0,0), Vec2d(8,1), Vec2d(9,5), Vec2d(-1,4) };
|
|
sp.closed = true;
|
|
int sk = doc.add_sketch_profile(sp, SketchPlane::XY(), "S");
|
|
|
|
auto& cons = doc.features[sk].constraints;
|
|
cons.push_back({SketchConstraintType::Fix, 0,-1,-1,-1, 0});
|
|
cons.push_back({SketchConstraintType::LockX, 0,-1,-1,-1, 0});
|
|
cons.push_back({SketchConstraintType::LockY, 0,-1,-1,-1, 0});
|
|
cons.push_back({SketchConstraintType::Horizontal, 0, 1,-1,-1, 0});
|
|
cons.push_back({SketchConstraintType::Vertical, 1, 2,-1,-1, 0});
|
|
cons.push_back({SketchConstraintType::Horizontal, 2, 3,-1,-1, 0});
|
|
cons.push_back({SketchConstraintType::Vertical, 3, 0,-1,-1, 0});
|
|
cons.push_back({SketchConstraintType::Distance, 0, 1,-1,-1, 10});
|
|
cons.push_back({SketchConstraintType::Distance, 1, 2,-1,-1, 6});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& pts = doc.features[sk].profile.points;
|
|
REQUIRE_THAT(pts[1].x(), Catch::Matchers::WithinAbs(10.0, 1e-3));
|
|
REQUIRE_THAT(pts[1].y(), Catch::Matchers::WithinAbs(0.0, 1e-3));
|
|
REQUIRE_THAT(pts[2].x(), Catch::Matchers::WithinAbs(10.0, 1e-3));
|
|
REQUIRE_THAT(pts[2].y(), Catch::Matchers::WithinAbs(6.0, 1e-3));
|
|
REQUIRE_THAT(pts[3].x(), Catch::Matchers::WithinAbs(0.0, 1e-3));
|
|
REQUIRE_THAT(pts[3].y(), Catch::Matchers::WithinAbs(6.0, 1e-3));
|
|
|
|
// the solved profile still extrudes into a solid
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
}
|
|
|
|
TEST_CASE("sketch entities -> wire -> extrude", "[CadDocument]")
|
|
{
|
|
SECTION("square from 4 lines") {
|
|
CadDocument doc;
|
|
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.name = "square";
|
|
sk.plane = SketchPlane::XY();
|
|
sk.entities = {
|
|
{SketchEntity::Type::Line, Vec2d(-10,-10), Vec2d(10,-10)},
|
|
{SketchEntity::Type::Line, Vec2d(10,-10), Vec2d(10,10)},
|
|
{SketchEntity::Type::Line, Vec2d(10,10), Vec2d(-10,10)},
|
|
{SketchEntity::Type::Line, Vec2d(-10,10), Vec2d(-10,-10)},
|
|
};
|
|
doc.features.push_back(sk);
|
|
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.name = "extrude";
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 5;
|
|
ex.mode = BooleanMode::New;
|
|
doc.features.push_back(ex);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
|
|
auto bb = doc.display_mesh.bounding_box();
|
|
auto sz = bb.max - bb.min;
|
|
REQUIRE(std::abs(sz.x() - 20.0) < 0.5);
|
|
REQUIRE(std::abs(sz.y() - 20.0) < 0.5);
|
|
REQUIRE(std::abs(sz.z() - 5.0) < 0.5);
|
|
}
|
|
|
|
SECTION("single circle") {
|
|
CadDocument doc;
|
|
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.name = "circle";
|
|
sk.plane = SketchPlane::XY();
|
|
sk.entities = {
|
|
{SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 10.0},
|
|
};
|
|
doc.features.push_back(sk);
|
|
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.name = "extrude";
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 8;
|
|
ex.mode = BooleanMode::New;
|
|
doc.features.push_back(ex);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
|
|
auto bb = doc.display_mesh.bounding_box();
|
|
auto sz = bb.max - bb.min;
|
|
REQUIRE(std::abs(sz.x() - 20.0) < 0.5);
|
|
REQUIRE(std::abs(sz.y() - 20.0) < 0.5);
|
|
REQUIRE(std::abs(sz.z() - 8.0) < 0.5);
|
|
}
|
|
|
|
SECTION("construction line excluded") {
|
|
CadDocument doc;
|
|
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.name = "square_with_construction";
|
|
sk.plane = SketchPlane::XY();
|
|
|
|
SketchEntity cline;
|
|
cline.type = SketchEntity::Type::Line;
|
|
cline.p0 = Vec2d(-30, 0);
|
|
cline.p1 = Vec2d(30, 0);
|
|
cline.construction = true;
|
|
|
|
sk.entities = {
|
|
{SketchEntity::Type::Line, Vec2d(-10,-10), Vec2d(10,-10)},
|
|
{SketchEntity::Type::Line, Vec2d(10,-10), Vec2d(10,10)},
|
|
{SketchEntity::Type::Line, Vec2d(10,10), Vec2d(-10,10)},
|
|
{SketchEntity::Type::Line, Vec2d(-10,10), Vec2d(-10,-10)},
|
|
cline,
|
|
};
|
|
doc.features.push_back(sk);
|
|
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.name = "extrude";
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 5;
|
|
ex.mode = BooleanMode::New;
|
|
doc.features.push_back(ex);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
|
|
auto bb = doc.display_mesh.bounding_box();
|
|
auto sz = bb.max - bb.min;
|
|
REQUIRE(std::abs(sz.x() - 20.0) < 0.5);
|
|
REQUIRE(std::abs(sz.y() - 20.0) < 0.5);
|
|
REQUIRE(std::abs(sz.z() - 5.0) < 0.5);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("construction flag survives recipe round-trip", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
|
|
// Square edge + 1 construction line
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(-10, -10), Vec2d(10, -10)},
|
|
{SketchEntity::Type::Line, Vec2d(10, -10), Vec2d(10, 10)},
|
|
{SketchEntity::Type::Line, Vec2d(10, 10), Vec2d(-10, 10)},
|
|
{SketchEntity::Type::Line, Vec2d(-10, 10), Vec2d(-10, -10)},
|
|
};
|
|
SketchEntity cx;
|
|
cx.type = SketchEntity::Type::Line;
|
|
cx.p0 = Vec2d(-33.0, 7.0);
|
|
cx.p1 = Vec2d(33.0, 7.0);
|
|
cx.construction = true;
|
|
ents.push_back(cx); // ents[4]
|
|
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "SkCtor");
|
|
REQUIRE(sk == 0);
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Ex");
|
|
|
|
std::string blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument fresh;
|
|
REQUIRE(fresh.deserialize_recipe(blob));
|
|
|
|
// Locate SkCtor in fresh.features
|
|
const CadFeature* sf = nullptr;
|
|
for (const auto& f : fresh.features) {
|
|
if (f.name == "SkCtor") { sf = &f; break; }
|
|
}
|
|
REQUIRE(sf != nullptr);
|
|
REQUIRE(sf->entities.size() == 5);
|
|
REQUIRE(sf->entities[0].construction == false);
|
|
REQUIRE(sf->entities[4].construction == true);
|
|
REQUIRE_THAT(sf->entities[4].p0.x(), Catch::Matchers::WithinAbs(-33.0, 1e-9));
|
|
REQUIRE_THAT(sf->entities[4].p1.x(), Catch::Matchers::WithinAbs( 33.0, 1e-9));
|
|
}
|
|
|
|
// Mirrors the GUI interactive-sketch commit path (DesignPanel ->
|
|
// add_sketch_entities) for the Fase 4.1 entity drawing tools: a corner-rect and
|
|
// a center-rect produce 4 closed Line entities; a center-circle produces 1
|
|
// Circle entity. add_sketch_entities must store them and extrude into a solid.
|
|
TEST_CASE("add_sketch_entities commit path -> extrude", "[CadDocument]")
|
|
{
|
|
SECTION("corner-rect 4 lines -> 30x16x5") {
|
|
CadDocument doc;
|
|
// Corner A=(-15,-8), B=(15,8): the tool's push_closed_lines order.
|
|
const Vec2d A(-15, -8), B(15, 8);
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, A, Vec2d(B.x(), A.y())},
|
|
{SketchEntity::Type::Line, Vec2d(B.x(),A.y()), B},
|
|
{SketchEntity::Type::Line, B, Vec2d(A.x(), B.y())},
|
|
{SketchEntity::Type::Line, Vec2d(A.x(),B.y()), A},
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "Sketch1");
|
|
REQUIRE(sk == 0);
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude1");
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
auto sz = doc.display_mesh.bounding_box().size();
|
|
REQUIRE(std::abs(sz.x() - 30.0) < 0.5);
|
|
REQUIRE(std::abs(sz.y() - 16.0) < 0.5);
|
|
REQUIRE(std::abs(sz.z() - 5.0) < 0.5);
|
|
}
|
|
|
|
SECTION("center-circle 1 entity -> r=7 cylinder") {
|
|
CadDocument doc;
|
|
SketchEntity c;
|
|
c.type = SketchEntity::Type::Circle;
|
|
c.center = Vec2d(0, 0);
|
|
c.p0 = Vec2d(0, 0);
|
|
c.radius = 7.0;
|
|
int sk = doc.add_sketch_entities({c}, SketchPlane::XY(), "Sketch1");
|
|
doc.add_extrude(sk, 4.0, false, BooleanMode::New, "Extrude1");
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
auto sz = doc.display_mesh.bounding_box().size();
|
|
REQUIRE(std::abs(sz.x() - 14.0) < 0.5);
|
|
REQUIRE(std::abs(sz.y() - 14.0) < 0.5);
|
|
REQUIRE(std::abs(sz.z() - 4.0) < 0.5);
|
|
}
|
|
}
|
|
|
|
// A slot (stadium): 2 lines + 2 semicircular Arc entities forming one closed
|
|
// loop — the shape the Fase 4.1b Slot tool emits. Validates the kernel's Arc
|
|
// edge path (GC_MakeArcOfCircle via center/radius/start_angle/end_angle, with
|
|
// the mid reconstructed at (start+end)/2) inside a mixed Line/Arc wire.
|
|
TEST_CASE("slot (line+arc closed wire) -> extrude", "[CadDocument]")
|
|
{
|
|
const double PI = 3.14159265358979323846;
|
|
CadDocument doc;
|
|
|
|
// Centerline ends c0=(-10,0), c1=(10,0); half-width w=5 → stadium 30 x 10.
|
|
SketchEntity top; // top line A0(-10,5) -> A1(10,5)
|
|
top.type = SketchEntity::Type::Line; top.p0 = Vec2d(-10, 5); top.p1 = Vec2d(10, 5);
|
|
|
|
SketchEntity cap1; // right cap @c1=(10,0): A1(10,5) -> B1(10,-5) through (15,0)
|
|
cap1.type = SketchEntity::Type::Arc; cap1.center = Vec2d(10, 0); cap1.radius = 5;
|
|
cap1.p0 = Vec2d(10, 5); cap1.p1 = Vec2d(10, -5);
|
|
cap1.start_angle = PI / 2; cap1.end_angle = -PI / 2; // mid angle 0 -> (15,0)
|
|
|
|
SketchEntity bot; // bottom line B1(10,-5) -> B0(-10,-5)
|
|
bot.type = SketchEntity::Type::Line; bot.p0 = Vec2d(10, -5); bot.p1 = Vec2d(-10, -5);
|
|
|
|
SketchEntity cap0; // left cap @c0=(-10,0): B0(-10,-5) -> A0(-10,5) through (-15,0)
|
|
cap0.type = SketchEntity::Type::Arc; cap0.center = Vec2d(-10, 0); cap0.radius = 5;
|
|
cap0.p0 = Vec2d(-10, -5); cap0.p1 = Vec2d(-10, 5);
|
|
cap0.start_angle = -PI / 2; cap0.end_angle = -3 * PI / 2; // mid angle -PI -> (-15,0)
|
|
|
|
int sk = doc.add_sketch_entities({top, cap1, bot, cap0}, SketchPlane::XY(), "Slot");
|
|
doc.add_extrude(sk, 4.0, false, BooleanMode::New, "Extrude1");
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
auto sz = doc.display_mesh.bounding_box().size();
|
|
REQUIRE(std::abs(sz.x() - 30.0) < 0.5);
|
|
REQUIRE(std::abs(sz.y() - 10.0) < 0.5);
|
|
REQUIRE(std::abs(sz.z() - 4.0) < 0.5);
|
|
}
|
|
|
|
// Onshape-style constraints on coexisting entities (Fase 4.2). The solver maps
|
|
// each Line/Point entity endpoint to a solver variable, applies the entity
|
|
// constraints, and writes the solved coordinates back into the entities.
|
|
TEST_CASE("entity constraints: solve on SketchEntity endpoints", "[CadDocument]")
|
|
{
|
|
using R = SketchPointRole;
|
|
using T = SketchConstraintType;
|
|
|
|
auto dir = [](const SketchEntity& e) { return Vec2d(e.p1 - e.p0); };
|
|
|
|
SECTION("perpendicular rotates line1 normal to a pinned line0") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // line0 (pinned)
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(7,7)}, // line1 @45 deg
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0});
|
|
ec.push_back({T::Fix, 0, -1, R::P1, R::P0, 0.0});
|
|
ec.push_back({T::Perpendicular, 0, 1, R::P0, R::P0, 0.0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
// line0 stayed put.
|
|
REQUIRE(std::abs(e[0].p0.x() - 0.0) < 1e-6);
|
|
REQUIRE(std::abs(e[0].p1.x() - 10.0) < 1e-6);
|
|
// line1 is now perpendicular to line0: directions dot to ~0.
|
|
const double d = dir(e[0]).dot(dir(e[1]));
|
|
REQUIRE(std::abs(d) < 1e-6);
|
|
}
|
|
|
|
// Driving length: the Dimension tool records a Distance between a line's own
|
|
// P0/P1 (committed via add_sketch_entities' constraints arg). Solving drives
|
|
// the line to that exact length.
|
|
SECTION("driving length: Distance(P0,P1) sets a line's length") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // length 10
|
|
};
|
|
std::vector<SketchEntityConstraintDef> cons;
|
|
cons.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0}); // pin the start
|
|
cons.push_back({T::Distance, 0, 0, R::P0, R::P1, 25.0}); // length -> 25
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S", cons);
|
|
REQUIRE(doc.features[sk].entity_constraints.size() == 2); // constraints stored
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
REQUIRE(std::abs((e[0].p1 - e[0].p0).norm() - 25.0) < 1e-6);
|
|
}
|
|
|
|
SECTION("parallel flattens line1 onto a pinned horizontal line0") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // line0 (pinned)
|
|
{SketchEntity::Type::Line, Vec2d(0,5), Vec2d(7,9)}, // line1 tilted
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0});
|
|
ec.push_back({T::Fix, 0, -1, R::P1, R::P0, 0.0});
|
|
ec.push_back({T::Parallel, 0, 1, R::P0, R::P0, 0.0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
const Vec2d d0 = dir(e[0]), d1 = dir(e[1]);
|
|
const double cross = d0.x() * d1.y() - d0.y() * d1.x();
|
|
REQUIRE(std::abs(cross) < 1e-6);
|
|
}
|
|
|
|
SECTION("coincident merges a line endpoint onto another") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // line0
|
|
{SketchEntity::Type::Line, Vec2d(12,1), Vec2d(20,1)}, // line1
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Coincident, 0, 1, R::P1, R::P0, 0.0}); // line0.end == line1.start
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
const Vec2d gap = Vec2d(e[0].p1 - e[1].p0);
|
|
REQUIRE(gap.norm() < 1e-6);
|
|
}
|
|
|
|
SECTION("horizontal levels a tilted line's endpoints") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,2)}, // tilted line
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Horizontal, 0, 0, R::P0, R::P1, 0.0}); // p0.y == p1.y
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
REQUIRE(std::abs(e[0].p0.y() - e[0].p1.y()) < 1e-6);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("entity constraints: arc/circle registration + concentric", "[CadDocument]")
|
|
{
|
|
using R = SketchPointRole;
|
|
using T = SketchConstraintType;
|
|
|
|
SECTION("concentric centers coincide") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 5.0}, // circle0
|
|
{SketchEntity::Type::Circle, Vec2d(10,2), Vec2d(10,2), Vec2d(10,2), 3.0}, // circle1
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0, -1, R::Center, R::Center, 0.0});
|
|
ec.push_back({T::Concentric, 0, 1, R::Center, R::Center, 0.0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
REQUIRE((e[1].center - e[0].center).norm() < 1e-6);
|
|
REQUIRE(e[0].center.x() < 1e-6);
|
|
REQUIRE(e[0].center.y() < 1e-6);
|
|
}
|
|
|
|
SECTION("arc reflow keeps radius and angle consistent") {
|
|
CadDocument doc;
|
|
const double PI2 = M_PI / 2;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Arc, Vec2d(5,0), Vec2d(0,5), Vec2d(0,0), 5.0, 0.0, PI2},
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0, -1, R::Center, R::Center, 0.0});
|
|
ec.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
REQUIRE(std::abs((e[0].p0 - e[0].center).norm() - 5.0) < 1e-6);
|
|
REQUIRE(std::abs(e[0].start_angle - 0.0) < 1e-6);
|
|
REQUIRE(std::abs(e[0].end_angle - PI2) < 1e-3);
|
|
REQUIRE(e[0].end_angle > e[0].start_angle);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("entity constraints: radius/diameter dimensions", "[CadDocument]")
|
|
{
|
|
using R = SketchPointRole;
|
|
using T = SketchConstraintType;
|
|
|
|
SECTION("circle radius dimension") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 5.0},
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0, -1, R::Center, R::Center, 0.0, -1, R::P0});
|
|
ec.push_back({T::Radius, 0, -1, R::Center, R::P0, 8.0, -1, R::P0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
REQUIRE(std::abs(e[0].radius - 8.0) < 1e-9);
|
|
}
|
|
|
|
SECTION("circle diameter dimension") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 5.0},
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0, -1, R::Center, R::Center, 0.0, -1, R::P0});
|
|
ec.push_back({T::Diameter, 0, -1, R::Center, R::P0, 20.0, -1, R::P0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
REQUIRE(std::abs(e[0].radius - 10.0) < 1e-9);
|
|
}
|
|
|
|
SECTION("arc radius rescales endpoints") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Arc, Vec2d(5,0), Vec2d(0,5), Vec2d(0,0), 5.0, 0.0, M_PI/2},
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0, -1, R::Center, R::Center, 0.0, -1, R::P0});
|
|
ec.push_back({T::Radius, 0, -1, R::Center, R::P0, 10.0, -1, R::P0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
REQUIRE(std::abs(e[0].radius - 10.0) < 1e-9);
|
|
REQUIRE((e[0].p0 - Vec2d(10,0)).norm() < 1e-6);
|
|
REQUIRE((e[0].p1 - Vec2d(0,10)).norm() < 1e-6);
|
|
}
|
|
}
|
|
|
|
// PointOnLine (Fase: persistent positioning). A point-like entity is held on a
|
|
// line (value 0) or at perpendicular distance `value`. Drives e.g. a circle centre
|
|
// onto a construction axis and, being a real constraint, keeps it there on re-solve.
|
|
TEST_CASE("entity constraints: point-on-line positions a centre onto an axis", "[CadDocument]")
|
|
{
|
|
using R = SketchPointRole;
|
|
using T = SketchConstraintType;
|
|
|
|
SECTION("circle centre snaps onto a pinned axis (value 0) and persists") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // axis (X)
|
|
{SketchEntity::Type::Circle, Vec2d(5,7), Vec2d(5,7), Vec2d(5,7), 3.0}, // off-axis centre
|
|
};
|
|
std::vector<SketchEntityConstraintDef> cons;
|
|
cons.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0}); // pin axis endpoints
|
|
cons.push_back({T::Fix, 0, -1, R::P1, R::P1, 0.0});
|
|
cons.push_back({T::PointOnLine, 1, 0, R::Center, R::P0, 0.0}); // centre onto axis
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S", cons);
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
REQUIRE(std::abs(doc.features[sk].entities[1].center.y()) < 1e-6); // on the axis
|
|
// Re-solving keeps it on the axis (a driving constraint, not a one-shot move).
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
REQUIRE(std::abs(doc.features[sk].entities[1].center.y()) < 1e-6);
|
|
}
|
|
|
|
SECTION("non-zero perpendicular distance via the free solve_sketch_entities") {
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)},
|
|
{SketchEntity::Type::Point, Vec2d(4,9)}, // point above the axis
|
|
};
|
|
std::vector<SketchEntityConstraintDef> cons;
|
|
cons.push_back({T::Fix, 0, -1, R::P0, R::P0, 0.0});
|
|
cons.push_back({T::Fix, 0, -1, R::P1, R::P1, 0.0});
|
|
cons.push_back({T::PointOnLine, 1, 0, R::P0, R::P0, 2.0}); // hold at distance 2
|
|
REQUIRE(solve_sketch_entities(ents, cons));
|
|
REQUIRE(std::abs(std::abs(ents[1].p0.y()) - 2.0) < 1e-6); // 2 mm off the axis
|
|
}
|
|
}
|
|
|
|
// [known-broken]: the "tangent line to circle" SECTION below aborts inside the vendored
|
|
// solver (slvs/dsc.h FindById, "Cannot find handle"). SIGABRT is fatal to the whole Catch2
|
|
// process, so this one case takes the entire suite down with it and no later test runs.
|
|
// Tagged so the delegated dev loop (scripts/kernel-test.sh) can exclude it and still reach
|
|
// a green baseline; CI runs every test and keeps reporting it, so the bug stays visible.
|
|
TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument][known-broken]")
|
|
{
|
|
using R = SketchPointRole;
|
|
using T = SketchConstraintType;
|
|
|
|
auto dir = [](const SketchEntity& e) { return Vec2d(e.p1 - e.p0); };
|
|
|
|
SECTION("angle 90 between two lines") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // line0
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(5,5)}, // line1
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0,-1, R::P0,R::P0, 0.0, -1,R::P0});
|
|
ec.push_back({T::Fix, 0,-1, R::P1,R::P0, 0.0, -1,R::P0});
|
|
ec.push_back({T::Fix, 1,-1, R::P0,R::P0, 0.0, -1,R::P0});
|
|
ec.push_back({T::Angle,0, 1, R::P0,R::P0, M_PI/2,-1,R::P0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
const Vec2d d0 = dir(e[0]).normalized();
|
|
const Vec2d d1 = dir(e[1]).normalized();
|
|
REQUIRE(std::abs(d0.dot(d1)) < 1e-3);
|
|
}
|
|
|
|
SECTION("midpoint of a line") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)}, // line0
|
|
{SketchEntity::Type::Point, Vec2d(3,9)}, // point p
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0,-1, R::P0,R::P0, 0.0,-1,R::P0});
|
|
ec.push_back({T::Fix, 0,-1, R::P1,R::P0, 0.0,-1,R::P0});
|
|
ec.push_back({T::Midpoint,1, 0, R::P0,R::P0, 0.0,-1,R::P0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
REQUIRE((e[1].p0 - Vec2d(5,0)).norm() < 1e-3);
|
|
}
|
|
|
|
SECTION("tangent line to circle") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 5.0}, // circle0 r=5
|
|
{SketchEntity::Type::Line, Vec2d(-10,8), Vec2d(10,8)}, // line1 y=8
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0,-1, R::Center,R::Center, 0.0,-1,R::P0});
|
|
ec.push_back({T::LockX, 1,-1, R::P0, R::P0, -10.0,-1,R::P0});
|
|
ec.push_back({T::LockX, 1,-1, R::P1, R::P0, 10.0,-1,R::P0});
|
|
ec.push_back({T::Horizontal, 1, 1, R::P0, R::P1, 0.0,-1,R::P0});
|
|
ec.push_back({T::Tangent, 0, 1, R::Center,R::P0, 0.0,-1,R::P0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
REQUIRE(std::abs(std::abs(e[1].p0.y()) - 5.0) < 1e-3);
|
|
}
|
|
|
|
SECTION("symmetric across a line") {
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Point, Vec2d(2,3)}, // pointA
|
|
{SketchEntity::Type::Point, Vec2d(-1,1)}, // pointB
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(0,10)}// axis (Y axis)
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
auto& ec = doc.features[sk].entity_constraints;
|
|
ec.push_back({T::Fix, 0,-1, R::P0,R::P0, 0.0,-1,R::P0});
|
|
ec.push_back({T::Fix, 2,-1, R::P0,R::P0, 0.0,-1,R::P0});
|
|
ec.push_back({T::Fix, 2,-1, R::P1,R::P0, 0.0,-1,R::P0});
|
|
ec.push_back({T::Symmetric, 0, 1, R::P0,R::P0, 0.0, 2,R::P0});
|
|
|
|
REQUIRE(doc.solve_sketch_feature(sk));
|
|
const auto& e = doc.features[sk].entities;
|
|
REQUIRE((e[1].p0 - Vec2d(-2,3)).norm() < 1e-3);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]")
|
|
{
|
|
std::string font = resources_dir().empty()
|
|
? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf")
|
|
: resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf";
|
|
{
|
|
std::ifstream probe(font);
|
|
if (!probe.good()) {
|
|
SUCCEED("bundled font not reachable in this environment; covered live on :10");
|
|
return;
|
|
}
|
|
}
|
|
|
|
const std::string charset =
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
"abcdefghijklmnopqrstuvwxyz"
|
|
"0123456789"
|
|
"@#$%&*()[]{}<>?/+-=.,;:!"
|
|
"\xC3\xA0\xC3\xA8\xC3\xA9\xC3\xAC\xC3\xB2\xC3\xB9"; // à è é ì ò ù (UTF-8)
|
|
|
|
std::string failures;
|
|
for (char c : charset) {
|
|
ImportRegions regs = text_to_regions(std::string(1, c), 12.0, font);
|
|
if (regs.empty()) continue;
|
|
CadDocument doc;
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.plane = SketchPlane::XY();
|
|
sk.imported_regions = regs;
|
|
doc.features.push_back(sk);
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 3;
|
|
doc.features.push_back(ex);
|
|
if (!doc.recompute() || !doc.error.empty())
|
|
failures += c;
|
|
}
|
|
INFO("glyphs that failed to extrude: [" << failures << "]");
|
|
CHECK(failures.empty());
|
|
|
|
// A realistic multi-glyph word must extrude too.
|
|
{
|
|
ImportRegions regs = text_to_regions("Snapmaker", 12.0, font);
|
|
REQUIRE_FALSE(regs.empty());
|
|
CadDocument doc;
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.plane = SketchPlane::XY();
|
|
sk.imported_regions = regs;
|
|
doc.features.push_back(sk);
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 3;
|
|
doc.features.push_back(ex);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("imported regions: faces-with-holes extrude (Text/SVG carrier)", "[CadDocument]")
|
|
{
|
|
SECTION("square with a square hole -> tube volume") {
|
|
CadDocument doc;
|
|
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.name = "art";
|
|
sk.plane = SketchPlane::XY();
|
|
// One region: outer 20x20 (CCW) + inner 8x8 hole (CW).
|
|
sk.imported_regions = {{
|
|
{Vec2d(-10,-10), Vec2d(10,-10), Vec2d(10,10), Vec2d(-10,10)}, // outer
|
|
{Vec2d(-4,-4), Vec2d(-4,4), Vec2d(4,4), Vec2d(4,-4)}, // hole (reversed winding)
|
|
}};
|
|
doc.features.push_back(sk);
|
|
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.name = "extrude";
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 5;
|
|
ex.mode = BooleanMode::New;
|
|
doc.features.push_back(ex);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
|
|
// (20*20 - 8*8) * 5 = 1680 mm^3
|
|
REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(1680.0, 0.02));
|
|
|
|
auto sz = doc.display_mesh.bounding_box().size();
|
|
REQUIRE(std::abs(sz.x() - 20.0) < 0.5);
|
|
REQUIRE(std::abs(sz.y() - 20.0) < 0.5);
|
|
REQUIRE(std::abs(sz.z() - 5.0) < 0.5);
|
|
}
|
|
|
|
SECTION("inverted winding (CW outer, CCW hole) keeps body solid, counter empty") {
|
|
// Real glyph contours (P, e, o, 8) arrive with outer CW + hole CCW. The
|
|
// extrude must NORMALISE winding so the letter BODY is solid and the
|
|
// counter is the hole — not the inverse (the reported bug). 20x20 outer
|
|
// CW with an 8x8 hole CCW -> volume (400-64)*5 = 1680, NOT the inverse.
|
|
CadDocument doc;
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.plane = SketchPlane::XY();
|
|
sk.imported_regions = {{
|
|
{Vec2d(-10,-10), Vec2d(-10,10), Vec2d(10,10), Vec2d(10,-10)}, // outer CW
|
|
{Vec2d(-4,-4), Vec2d(4,-4), Vec2d(4,4), Vec2d(-4,4)}, // hole CCW
|
|
}};
|
|
doc.features.push_back(sk);
|
|
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 5;
|
|
doc.features.push_back(ex);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(1680.0, 0.02));
|
|
}
|
|
|
|
SECTION("degenerate / duplicate points are sanitised, not fatal") {
|
|
// FreeType/SVG flattening can emit repeated points; the extrude must
|
|
// survive them (previously to_occt_wire threw and failed the whole op).
|
|
CadDocument doc;
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.plane = SketchPlane::XY();
|
|
sk.imported_regions = {{
|
|
// 20x20 outer square with consecutive dups + an explicit closing dup
|
|
{Vec2d(-10,-10), Vec2d(-10,-10), Vec2d(10,-10), Vec2d(10,-10),
|
|
Vec2d(10,10), Vec2d(-10,10), Vec2d(-10,-10)},
|
|
}};
|
|
doc.features.push_back(sk);
|
|
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 5;
|
|
doc.features.push_back(ex);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(2000.0, 0.02));
|
|
}
|
|
|
|
SECTION("a degenerate region is skipped, valid ones still extrude") {
|
|
CadDocument doc;
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.plane = SketchPlane::XY();
|
|
sk.imported_regions = {
|
|
{ {Vec2d(0,0), Vec2d(0,0), Vec2d(0,0)} }, // collapses to nothing
|
|
{ {Vec2d(0,0), Vec2d(5,0), Vec2d(5,5), Vec2d(0,5)} }, // valid 5x5
|
|
};
|
|
doc.features.push_back(sk);
|
|
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 4;
|
|
doc.features.push_back(ex);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(100.0, 0.02));
|
|
}
|
|
|
|
SECTION("two disjoint regions form one shape") {
|
|
CadDocument doc;
|
|
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.plane = SketchPlane::XY();
|
|
sk.imported_regions = {
|
|
{{Vec2d(0,0), Vec2d(5,0), Vec2d(5,5), Vec2d(0,5)}},
|
|
{{Vec2d(10,0), Vec2d(15,0), Vec2d(15,5), Vec2d(10,5)}},
|
|
};
|
|
doc.features.push_back(sk);
|
|
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 3;
|
|
doc.features.push_back(ex);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
// 2 * (5*5*3) = 150 mm^3
|
|
REQUIRE_THAT(double(doc.display_mesh.volume()), Catch::Matchers::WithinRel(150.0, 0.02));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("tessellate tracks per-triangle face id", "[CadDocument]")
|
|
{
|
|
TopoDS_Shape box = BRepPrimAPI_MakeBox(10., 10., 10.).Shape();
|
|
std::vector<int> tf;
|
|
TriangleMesh m = SketchEngine::tessellate(box, tf);
|
|
|
|
REQUIRE(tf.size() == m.its.indices.size());
|
|
REQUIRE(!tf.empty());
|
|
|
|
std::set<int> distinct(tf.begin(), tf.end());
|
|
REQUIRE(distinct.size() == 6);
|
|
REQUIRE(*distinct.begin() == 0);
|
|
REQUIRE(*distinct.rbegin() == 5);
|
|
|
|
for (int fid : distinct) {
|
|
int count = 0;
|
|
for (int x : tf) if (x == fid) ++count;
|
|
REQUIRE(count >= 2);
|
|
}
|
|
|
|
REQUIRE(m.its.indices.size() >= 12);
|
|
}
|
|
|
|
TEST_CASE("extrude two-sided + through-all + intersect", "[CadDocument]")
|
|
{
|
|
using namespace Slic3r;
|
|
SketchPlane xy = SketchPlane::XY();
|
|
// a 10x10 square wire centred on origin
|
|
SketchProfile sp;
|
|
sp.points = { Vec2d(-5,-5), Vec2d(5,-5), Vec2d(5,5), Vec2d(-5,5) };
|
|
sp.closed = true;
|
|
TopoDS_Wire w = sp.to_occt_wire(xy);
|
|
|
|
SECTION("two-sided height = up+down") {
|
|
TopoDS_Shape s = SketchEngine::make_extrude_two_sided(w, xy, 10.0, 4.0);
|
|
REQUIRE_FALSE(s.IsNull());
|
|
Bnd_Box bb; BRepBndLib::Add(s, bb);
|
|
double xmin,ymin,zmin,xmax,ymax,zmax; bb.Get(xmin,ymin,zmin,xmax,ymax,zmax);
|
|
REQUIRE_THAT(zmax - zmin, Catch::Matchers::WithinAbs(14.0, 0.05)); // 10 up + 4 down
|
|
REQUIRE_THAT(zmax, Catch::Matchers::WithinAbs(10.0, 0.05));
|
|
REQUIRE_THAT(zmin, Catch::Matchers::WithinAbs(-4.0, 0.05));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("extrude taper + up-to-face distance", "[CadDocument]")
|
|
{
|
|
using namespace Slic3r;
|
|
SketchPlane xy = SketchPlane::XY();
|
|
SketchProfile sp; sp.points = { Vec2d(-5,-5),Vec2d(5,-5),Vec2d(5,5),Vec2d(-5,5) }; sp.closed = true;
|
|
TopoDS_Wire w = sp.to_occt_wire(xy);
|
|
|
|
SECTION("taper widens the top") {
|
|
TopoDS_Shape s = SketchEngine::make_extrude_taper(w, xy, 10.0, 15.0);
|
|
REQUIRE_FALSE(s.IsNull());
|
|
Bnd_Box bb; BRepBndLib::Add(s, bb);
|
|
double x0,y0,z0,x1,y1,z1; bb.Get(x0,y0,z0,x1,y1,z1);
|
|
REQUIRE_THAT(z1 - z0, Catch::Matchers::WithinAbs(10.0, 0.1));
|
|
REQUIRE((x1 - x0) > 12.0);
|
|
}
|
|
SECTION("extreme taper falls back to a straight prism") {
|
|
TopoDS_Shape s = SketchEngine::make_extrude_taper(w, xy, 10.0, 89.0);
|
|
REQUIRE_FALSE(s.IsNull());
|
|
Bnd_Box bb; BRepBndLib::Add(s, bb);
|
|
double x0,y0,z0,x1,y1,z1; bb.Get(x0,y0,z0,x1,y1,z1);
|
|
REQUIRE_THAT(x1 - x0, Catch::Matchers::WithinAbs(10.0, 0.1));
|
|
}
|
|
}
|
|
|
|
// [known-broken]: pre-existing failure, the cut groove volume does not meet the asserted
|
|
// threshold. Excluded from the delegated dev loop so a green run means "I broke nothing";
|
|
// CI still runs and reports it.
|
|
TEST_CASE("internal thread cuts a visible groove into the bore wall", "[CadDocument][known-broken]")
|
|
{
|
|
using namespace Slic3r;
|
|
SketchPlane xy = SketchPlane::XY();
|
|
|
|
// 40x40x20 box centred on the origin, extruded +Z.
|
|
auto make_box = [&](CadDocument& doc) {
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.plane = xy;
|
|
sk.imported_regions = {{
|
|
{Vec2d(-20,-20), Vec2d(20,-20), Vec2d(20,20), Vec2d(-20,20)},
|
|
}};
|
|
doc.features.push_back(sk);
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.sketch_ref = 0;
|
|
ex.distance = 20;
|
|
doc.features.push_back(ex);
|
|
};
|
|
|
|
// Reference: box with a plain Ø12 bore through it.
|
|
CadDocument hole_doc;
|
|
make_box(hole_doc);
|
|
hole_doc.add_hole(12.0, 20.0, true, 0.0, 0.0, xy, "Hole");
|
|
REQUIRE(hole_doc.recompute());
|
|
REQUIRE(hole_doc.error.empty());
|
|
const double v_hole = double(hole_doc.display_mesh.volume());
|
|
|
|
// Threaded: same box, internal thread of radius 6 (the bore radius).
|
|
CadDocument thr_doc;
|
|
make_box(thr_doc);
|
|
thr_doc.add_thread(6.0, 3.0, 20.0, 1.0, /*internal=*/true, 0.0, 0.0, xy, "Thread");
|
|
REQUIRE(thr_doc.recompute());
|
|
REQUIRE(thr_doc.error.empty());
|
|
const double v_thread = double(thr_doc.display_mesh.volume());
|
|
|
|
// The helical groove must carve material OUT of the wall, beyond the plain
|
|
// bore -> a visible internal thread. The old inward-pointing profile only
|
|
// swept already-empty bore space and removed essentially nothing, so it would
|
|
// give v_thread ~= v_hole; the fixed profile removes a meaningful volume.
|
|
REQUIRE(v_thread > 0.0);
|
|
REQUIRE(v_thread < v_hole);
|
|
REQUIRE((v_hole - v_thread) > 20.0);
|
|
}
|
|
|
|
TEST_CASE("revolve builds a solid of revolution about an in-plane axis", "[CadDocument]")
|
|
{
|
|
using namespace Slic3r;
|
|
SketchPlane xy = SketchPlane::XY();
|
|
|
|
// Rectangle profile (10 wide x 10 tall, area 100) offset to +v so it lies entirely
|
|
// on one side of the X axis; revolved 360deg about X -> a rectangular-section ring.
|
|
// Pappus: V = 2*pi*R*A = 2*pi*15*100 = ~9424.78 mm^3 (tessellation slightly under).
|
|
auto make_rev_doc = [&](double angle, int axis, double u0, double v0) {
|
|
auto doc = std::make_unique<CadDocument>();
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.plane = xy;
|
|
sk.profile.points = { Vec2d(u0 - 5, v0 - 5), Vec2d(u0 + 5, v0 - 5),
|
|
Vec2d(u0 + 5, v0 + 5), Vec2d(u0 - 5, v0 + 5) };
|
|
sk.profile.closed = true;
|
|
doc->features.push_back(sk);
|
|
doc->add_revolve(0, angle, axis, false, BooleanMode::New, "Rev");
|
|
return doc;
|
|
};
|
|
|
|
auto full = make_rev_doc(360.0, /*axis=X*/0, 0.0, 15.0);
|
|
REQUIRE(full->recompute());
|
|
REQUIRE(full->error.empty());
|
|
const double v_full = double(full->display_mesh.volume());
|
|
REQUIRE(v_full > 0.0);
|
|
REQUIRE(v_full == Approx(9424.78).epsilon(0.05));
|
|
|
|
// A 180deg sweep removes exactly half the material.
|
|
auto half = make_rev_doc(180.0, 0, 0.0, 15.0);
|
|
REQUIRE(half->recompute());
|
|
REQUIRE(half->error.empty());
|
|
const double v_half = double(half->display_mesh.volume());
|
|
REQUIRE(v_half > 0.0);
|
|
REQUIRE(v_full == Approx(2.0 * v_half).epsilon(0.05));
|
|
|
|
// Axis = plane Y: profile offset to +u (one side of the Y axis) gives the same ring.
|
|
auto ydoc = make_rev_doc(360.0, /*axis=Y*/1, 15.0, 0.0);
|
|
REQUIRE(ydoc->recompute());
|
|
REQUIRE(ydoc->error.empty());
|
|
REQUIRE(double(ydoc->display_mesh.volume()) == Approx(9424.78).epsilon(0.05));
|
|
}
|
|
|
|
TEST_CASE("sweep builds a solid by sweeping a profile along a path", "[CadDocument]")
|
|
{
|
|
using namespace Slic3r;
|
|
CadDocument doc;
|
|
|
|
// Profile: circle r=5 on the XY plane at the origin (area = 25*pi).
|
|
SketchEntity circ;
|
|
circ.type = SketchEntity::Type::Circle;
|
|
circ.center = Vec2d(0, 0);
|
|
circ.radius = 5.0;
|
|
const int prof = doc.add_sketch_entities({circ}, SketchPlane::XY(), "Profile");
|
|
|
|
// Path: a straight line on the XZ plane from 2D (0,0)->(0,100), i.e. world
|
|
// (0,0,0)->(0,0,100): the spine starts on the profile plane and runs +Z by 100.
|
|
SketchEntity line;
|
|
line.type = SketchEntity::Type::Line;
|
|
line.p0 = Vec2d(0, 0);
|
|
line.p1 = Vec2d(0, 100);
|
|
const int path = doc.add_sketch_entities({line}, SketchPlane::XZ(), "Path");
|
|
|
|
doc.add_sweep(prof, path, BooleanMode::New, "Sweep1");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Straight sweep of a circle == a cylinder: V = pi*r^2*h = pi*25*100 = ~7853.98.
|
|
const double v = double(doc.display_mesh.volume());
|
|
REQUIRE(v > 0.0);
|
|
REQUIRE_THAT(v, Catch::Matchers::WithinRel(M_PI * 25.0 * 100.0, 0.03));
|
|
|
|
// A valid path sketch is mandatory: a -1 path ref must error cleanly, not crash.
|
|
CadDocument bad;
|
|
const int p2 = bad.add_sketch_entities({circ}, SketchPlane::XY(), "Profile");
|
|
bad.add_sweep(p2, -1, BooleanMode::New, "BadSweep");
|
|
REQUIRE_FALSE(bad.recompute());
|
|
}
|
|
|
|
TEST_CASE("pattern replicates a body linearly and circularly", "[CadDocument]")
|
|
{
|
|
using namespace Slic3r;
|
|
|
|
// Linear: a 10x10x10 box (V=1000) repeated 3x at 20mm spacing along plane X.
|
|
// 20 > 10 so the copies are disjoint -> total V = 3*1000 = 3000.
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
10, 10, 5, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
|
|
doc.add_pattern(/*circular=*/false, /*count=*/3, /*spacing=*/20,
|
|
/*dir=*/0, /*angle=*/0, /*target=*/-1, "LinearPattern");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
const double v = double(doc.display_mesh.volume());
|
|
REQUIRE_THAT(v, Catch::Matchers::WithinRel(3000.0, 0.02));
|
|
}
|
|
|
|
// Circular: a 10x10x10 box centred at x=50 (radius 50 from the Z axis), 4 copies
|
|
// over 360deg about the plane normal through the origin -> a ring of 4 disjoint
|
|
// boxes -> V = 4*1000 = 4000.
|
|
{
|
|
CadDocument doc;
|
|
SketchProfile sp;
|
|
sp.points.push_back(Vec2d(45, -5));
|
|
sp.points.push_back(Vec2d(55, -5));
|
|
sp.points.push_back(Vec2d(55, 5));
|
|
sp.points.push_back(Vec2d(45, 5));
|
|
sp.closed = true;
|
|
int sk = doc.add_sketch_profile(sp, SketchPlane::XY(), "OffsetBox");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
|
|
doc.add_pattern(/*circular=*/true, /*count=*/4, /*spacing=*/0,
|
|
/*dir=*/0, /*angle=*/360, /*target=*/-1, "CircularPattern");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
const double v = double(doc.display_mesh.volume());
|
|
REQUIRE_THAT(v, Catch::Matchers::WithinRel(4000.0, 0.02));
|
|
}
|
|
|
|
// A pattern with no body must error cleanly, not crash.
|
|
{
|
|
CadDocument bad;
|
|
bad.add_pattern(false, 3, 20, 0, 0, -1, "NoBody");
|
|
REQUIRE_FALSE(bad.recompute());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("pattern-on-curve: copies land on a line and bbox spans the curve length", "[CadDocument][pattern]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
using Catch::Matchers::WithinRel;
|
|
using namespace Slic3r;
|
|
|
|
CadDocument doc;
|
|
|
|
// Seed body: 4x4x4 box at the origin via sketch+extrude.
|
|
int s0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 4, 4, 0, "Seed");
|
|
doc.add_extrude(s0, 4.0, false, BooleanMode::New, "E");
|
|
|
|
// Guide sketch: one Line entity from (0,0) to (30,0) on XY.
|
|
std::vector<SketchEntity> guide = {
|
|
{SketchEntity::Type::Line, Vec2d(0, 0), Vec2d(30, 0)},
|
|
};
|
|
int gs = doc.add_sketch_entities(guide, SketchPlane::XY(), "Guide");
|
|
|
|
doc.add_pattern_on_curve(4, gs, 0, 0, "OnCurve");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
|
|
auto bb = doc.display_mesh.bounding_box();
|
|
double x_extent = bb.max.x() - bb.min.x();
|
|
// 4 copies: at x=0, x=10, x=20, x=30. The seed is a 4x4 box centred at origin,
|
|
// so the overall X span is from -2 to 32 = 34 mm.
|
|
REQUIRE_THAT(x_extent, WithinAbs(34.0, 2.0));
|
|
}
|
|
|
|
TEST_CASE("pattern-on-curve: bad refs are safe", "[CadDocument][pattern]")
|
|
{
|
|
using namespace Slic3r;
|
|
|
|
CadDocument doc;
|
|
int s0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 4, 4, 0, "Seed");
|
|
doc.add_extrude(s0, 4.0, false, BooleanMode::New, "E");
|
|
|
|
// Bad sketch ref -> error.
|
|
doc.add_pattern_on_curve(3, 999, 0, 0, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
REQUIRE(doc.error.find("pattern") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("pattern-on-curve: round-trip through serialize/deserialize", "[CadDocument][pattern]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using namespace Slic3r;
|
|
|
|
CadDocument doc;
|
|
int s0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 4, 4, 0, "Seed");
|
|
doc.add_extrude(s0, 4.0, false, BooleanMode::New, "E");
|
|
|
|
std::vector<SketchEntity> guide = {
|
|
{SketchEntity::Type::Line, Vec2d(0, 0), Vec2d(30, 0)},
|
|
};
|
|
int gs = doc.add_sketch_entities(guide, SketchPlane::XY(), "Guide");
|
|
|
|
doc.add_pattern_on_curve(4, gs, 0, 0, "OnCurve");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
auto orig_bb = doc.display_mesh.bounding_box();
|
|
size_t orig_n = doc.bodies.size();
|
|
|
|
std::string blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument fresh;
|
|
REQUIRE(fresh.deserialize_recipe(blob));
|
|
REQUIRE(fresh.recompute());
|
|
REQUIRE(fresh.error.empty());
|
|
REQUIRE(fresh.bodies.size() == orig_n);
|
|
|
|
auto fresh_bb = fresh.display_mesh.bounding_box();
|
|
REQUIRE_THAT(orig_bb.min.x(), WithinRel(fresh_bb.min.x(), 1e-6));
|
|
REQUIRE_THAT(orig_bb.max.x(), WithinRel(fresh_bb.max.x(), 1e-6));
|
|
|
|
// Verify the deserialized field values.
|
|
bool found = false;
|
|
for (const auto& f : fresh.features) {
|
|
if (f.name == "OnCurve") {
|
|
REQUIRE(f.pattern_curve_sketch == gs);
|
|
REQUIRE(f.pattern_curve_entity == 0);
|
|
found = true;
|
|
break;
|
|
}
|
|
}
|
|
REQUIRE(found);
|
|
}
|
|
|
|
TEST_CASE("thread standards table carries correct ISO/UTS measures", "[CadDocument]")
|
|
{
|
|
using namespace Slic3r;
|
|
|
|
// Table is non-empty and every entry is self-consistent.
|
|
const auto& table = thread_standards();
|
|
REQUIRE(table.size() > 40);
|
|
for (const ThreadSpec& s : table) {
|
|
REQUIRE(s.major_diameter_mm > 0.0);
|
|
REQUIRE(s.pitch_mm > 0.0);
|
|
REQUIRE(s.minor_diameter_mm() < s.major_diameter_mm);
|
|
REQUIRE(s.minor_diameter_mm() > 0.0);
|
|
// 60deg V cut depth = 0.6134 * pitch.
|
|
REQUIRE(s.thread_depth_mm() == Approx(0.6134 * s.pitch_mm));
|
|
}
|
|
|
|
// ISO metric coarse: known nominal/pitch pairs.
|
|
const ThreadSpec* m6 = find_thread_standard("M6");
|
|
REQUIRE(m6 != nullptr);
|
|
REQUIRE(m6->major_diameter_mm == Approx(6.0));
|
|
REQUIRE(m6->pitch_mm == Approx(1.0));
|
|
REQUIRE(m6->series == ThreadSpec::Series::MetricCoarse);
|
|
REQUIRE_FALSE(m6->imperial());
|
|
REQUIRE(m6->thread_depth_mm() == Approx(0.6134));
|
|
// Tapped minor (tap-drill) diameter D - 1.0825*P = 6 - 1.0825 = 4.9175.
|
|
REQUIRE(m6->minor_diameter_mm() == Approx(4.9175));
|
|
|
|
const ThreadSpec* m3 = find_thread_standard("M3");
|
|
REQUIRE(m3 != nullptr);
|
|
REQUIRE(m3->pitch_mm == Approx(0.5));
|
|
|
|
// Imperial UNC: 1/4-20 -> 0.25in major, pitch = 25.4/20 = 1.27 mm.
|
|
const ThreadSpec* q = find_thread_standard("1/4-20 UNC");
|
|
REQUIRE(q != nullptr);
|
|
REQUIRE(q->major_diameter_mm == Approx(6.35));
|
|
REQUIRE(q->pitch_mm == Approx(1.27));
|
|
REQUIRE(q->series == ThreadSpec::Series::UNC);
|
|
REQUIRE(q->imperial());
|
|
|
|
// Imperial UNF fine variant has a finer pitch than its UNC sibling.
|
|
const ThreadSpec* qf = find_thread_standard("1/4-28 UNF");
|
|
REQUIRE(qf != nullptr);
|
|
REQUIRE(qf->major_diameter_mm == Approx(6.35));
|
|
REQUIRE(qf->pitch_mm == Approx(25.4 / 28.0));
|
|
REQUIRE(qf->pitch_mm < q->pitch_mm);
|
|
|
|
// Unknown designation -> nullptr.
|
|
REQUIRE(find_thread_standard("M7.3 bogus") == nullptr);
|
|
}
|
|
|
|
TEST_CASE("datum plane: offset + tilt resolution and sketching on it", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
// Parallel offset plane 30 mm above XY (normal +Z, origin at z=30).
|
|
CadDocument doc;
|
|
int p0 = doc.add_plane(0 /*XY*/, 30.0, 0.0, 0, "Plane1");
|
|
REQUIRE(p0 == 0);
|
|
|
|
auto planes = doc.resolve_datum_planes();
|
|
REQUIRE(planes.size() == 1);
|
|
REQUIRE(planes[0].first == "Plane1");
|
|
CHECK_THAT(planes[0].second.origin.z(), WithinAbs(30.0, 1e-9));
|
|
CHECK_THAT(planes[0].second.normal.z(), WithinAbs(1.0, 1e-9));
|
|
|
|
// A second datum plane tilted 90 deg about the base (XY) X axis: its normal
|
|
// rotates from +Z toward -Y (Rodrigues about +X: +Z -> -Y).
|
|
doc.add_plane(0 /*XY*/, 0.0, 90.0, 0 /*about X*/, "Plane2");
|
|
planes = doc.resolve_datum_planes();
|
|
REQUIRE(planes.size() == 2);
|
|
CHECK_THAT(planes[1].second.normal.y(), WithinAbs(-1.0, 1e-9));
|
|
CHECK_THAT(planes[1].second.normal.z(), WithinAbs(0.0, 1e-9));
|
|
|
|
// Sketch a 10x10 square ON Plane1 and extrude 4 mm: the solid must sit in z=[30,34].
|
|
SketchPlane sp = planes[0].second;
|
|
SketchProfile prof;
|
|
prof.points = {{-5,-5},{5,-5},{5,5},{-5,5}};
|
|
prof.closed = true;
|
|
int sk = doc.add_sketch_profile(prof, sp, "S");
|
|
doc.add_extrude(sk, 4.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
|
|
Bnd_Box bb;
|
|
BRepBndLib::Add(doc.body, bb);
|
|
double xmin,ymin,zmin,xmax,ymax,zmax;
|
|
bb.Get(xmin,ymin,zmin,xmax,ymax,zmax);
|
|
CHECK_THAT(zmin, WithinAbs(30.0, 1e-6));
|
|
CHECK_THAT(zmax, WithinAbs(34.0, 1e-6));
|
|
CHECK_THAT(double(doc.display_mesh.volume()), WithinRel(400.0, 0.02));
|
|
|
|
// A datum-plane-only document has no solid -> recompute is a benign failure.
|
|
CadDocument only_plane;
|
|
only_plane.add_plane(0, 10.0, 0.0, 0, "P");
|
|
REQUIRE_FALSE(only_plane.recompute());
|
|
}
|
|
|
|
TEST_CASE("loft builds a solid skinning two profiles on parallel planes", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Bottom 20x20 square on XY.
|
|
SketchProfile bot;
|
|
bot.points = {{-10,-10},{10,-10},{10,10},{-10,10}};
|
|
bot.closed = true;
|
|
int s0 = doc.add_sketch_profile(bot, SketchPlane::XY(), "Bottom");
|
|
|
|
// Top 10x10 square on a datum plane 20 mm above XY (exercises datum -> loft).
|
|
doc.add_plane(0 /*XY*/, 20.0, 0.0, 0, "Plane1");
|
|
SketchPlane top = doc.resolve_datum_planes()[0].second;
|
|
SketchProfile tp;
|
|
tp.points = {{-5,-5},{5,-5},{5,5},{-5,5}};
|
|
tp.closed = true;
|
|
int s1 = doc.add_sketch_profile(tp, top, "Top");
|
|
|
|
// Ruled (straight) sections -> exact planar end caps at z=0 and z=20.
|
|
doc.add_loft({s0, s1}, true /*ruled*/, BooleanMode::New, "Loft1");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// The loft spans z=[0,20]. ThruSections approximates each section as a BSpline
|
|
// curve, so the lateral surface bulges ~0.05 mm past the end planes (sub-visual,
|
|
// ~0.25%) — assert the span loosely; the volume below is the real correctness gate.
|
|
Bnd_Box bb;
|
|
BRepBndLib::Add(doc.body, bb);
|
|
double xmin,ymin,zmin,xmax,ymax,zmax;
|
|
bb.Get(xmin,ymin,zmin,xmax,ymax,zmax);
|
|
CHECK_THAT(zmin, WithinAbs(0.0, 0.1));
|
|
CHECK_THAT(zmax, WithinAbs(20.0, 0.1));
|
|
// Square frustum volume = h/3*(A1+A2+sqrt(A1*A2)) = 20/3*(400+100+200) = 4666.67.
|
|
CHECK_THAT(double(doc.display_mesh.volume()), WithinRel(4666.67, 0.03));
|
|
|
|
// A single profile is not enough -> benign recompute failure.
|
|
CadDocument one;
|
|
SketchProfile sp; sp.points = {{-5,-5},{5,-5},{5,5},{-5,5}}; sp.closed = true;
|
|
int only = one.add_sketch_profile(sp, SketchPlane::XY(), "Only");
|
|
one.add_loft({only}, false, BooleanMode::New, "L");
|
|
REQUIRE_FALSE(one.recompute());
|
|
}
|
|
|
|
TEST_CASE("draft tapers a solid face about the body base", "[CadDocument]")
|
|
{
|
|
using namespace Slic3r;
|
|
|
|
// 10x10x10 box from z=0..10 (V=1000). Drafting a vertical side face by +10deg about
|
|
// the bottom (neutral) plane tilts its top edge inward, removing material so V<1000.
|
|
// A box's 2 horizontal faces are parallel to the neutral plane and cannot be drafted
|
|
// (Add fails -> recompute returns false), so exactly the 4 vertical sides succeed.
|
|
int ok_faces = 0;
|
|
bool saw_taper = false;
|
|
for (int fid = 0; fid < 6; ++fid) {
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 5, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
|
|
doc.add_draft(10.0, fid, -1, "Draft1");
|
|
if (!doc.recompute()) continue; // top/bottom faces: parallel to base -> benign skip
|
|
++ok_faces;
|
|
const double v = double(doc.display_mesh.volume());
|
|
REQUIRE(v > 0.0);
|
|
if (v < 999.0) saw_taper = true;
|
|
}
|
|
REQUIRE(ok_faces == 4);
|
|
REQUIRE(saw_taper);
|
|
|
|
// Draft with no body must fail cleanly, not crash.
|
|
CadDocument bad;
|
|
bad.add_draft(5.0, 0, -1, "NoBody");
|
|
REQUIRE_FALSE(bad.recompute());
|
|
}
|
|
|
|
TEST_CASE("cut splits a body with a plane", "[cut]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
auto make_box = [](CadDocument& doc, double w, double h, double d) {
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.plane = SketchPlane::XY();
|
|
sk.imported_regions = {{
|
|
{Vec2d(-w / 2, -h / 2), Vec2d(w / 2, -h / 2),
|
|
Vec2d(w / 2, h / 2), Vec2d(-w / 2, h / 2)},
|
|
}};
|
|
doc.features.push_back(sk);
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.sketch_ref = 0;
|
|
ex.distance = d;
|
|
doc.features.push_back(ex);
|
|
};
|
|
|
|
SECTION("keep upper half only") {
|
|
CadDocument doc;
|
|
make_box(doc, 20.0, 20.0, 20.0);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
const double v_orig = double(doc.display_mesh.volume());
|
|
const int n_before = int(doc.bodies.size());
|
|
REQUIRE(v_orig > 0.0);
|
|
|
|
CadFeature cut;
|
|
cut.type = CadFeatureType::Cut;
|
|
cut.plane = SketchPlane::XY();
|
|
cut.cut_offset = 10.0; // mid-height of the 0..20 box
|
|
cut.cut_keep_upper = true;
|
|
cut.cut_keep_lower = false;
|
|
doc.features.push_back(cut);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(int(doc.bodies.size()) == n_before);
|
|
REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(v_orig * 0.5, 0.01));
|
|
}
|
|
|
|
SECTION("keep both halves splits into two bodies") {
|
|
CadDocument doc;
|
|
make_box(doc, 20.0, 20.0, 20.0);
|
|
REQUIRE(doc.recompute());
|
|
const double v_orig = double(doc.display_mesh.volume());
|
|
const int n_before = int(doc.bodies.size());
|
|
REQUIRE(v_orig > 0.0);
|
|
|
|
CadFeature cut;
|
|
cut.type = CadFeatureType::Cut;
|
|
cut.plane = SketchPlane::XY();
|
|
cut.cut_offset = 10.0;
|
|
cut.cut_keep_upper = true;
|
|
cut.cut_keep_lower = true;
|
|
doc.features.push_back(cut);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(int(doc.bodies.size()) == n_before + 1);
|
|
REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(v_orig, 0.01));
|
|
|
|
for (const auto& b : doc.bodies) {
|
|
double v = double(SketchEngine::tessellate(b.shape).volume());
|
|
REQUIRE_THAT(v, WithinRel(v_orig * 0.5, 0.01));
|
|
}
|
|
}
|
|
|
|
SECTION("keep lower half only") {
|
|
CadDocument doc;
|
|
make_box(doc, 20.0, 20.0, 20.0);
|
|
REQUIRE(doc.recompute());
|
|
const double v_orig = double(doc.display_mesh.volume());
|
|
const int n_before = int(doc.bodies.size());
|
|
|
|
CadFeature cut;
|
|
cut.type = CadFeatureType::Cut;
|
|
cut.plane = SketchPlane::XY();
|
|
cut.cut_offset = 10.0;
|
|
cut.cut_keep_upper = false;
|
|
cut.cut_keep_lower = true;
|
|
doc.features.push_back(cut);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(int(doc.bodies.size()) == n_before);
|
|
REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(v_orig * 0.5, 0.01));
|
|
}
|
|
|
|
SECTION("flip swaps which side is kept") {
|
|
CadDocument doc;
|
|
make_box(doc, 20.0, 20.0, 20.0);
|
|
REQUIRE(doc.recompute());
|
|
const double v_orig = double(doc.display_mesh.volume());
|
|
|
|
// cut with flip=true, keep_upper=true → the -normal side (original bottom half)
|
|
CadFeature cut;
|
|
cut.type = CadFeatureType::Cut;
|
|
cut.plane = SketchPlane::XY();
|
|
cut.cut_offset = 10.0;
|
|
cut.cut_flip = true;
|
|
cut.cut_keep_upper = true;
|
|
cut.cut_keep_lower = false;
|
|
doc.features.push_back(cut);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(v_orig * 0.5, 0.01));
|
|
}
|
|
|
|
SECTION("both keep flags false throws") {
|
|
CadDocument doc;
|
|
make_box(doc, 20.0, 20.0, 20.0);
|
|
REQUIRE(doc.recompute());
|
|
|
|
CadFeature cut;
|
|
cut.type = CadFeatureType::Cut;
|
|
cut.plane = SketchPlane::XY();
|
|
cut.cut_keep_upper = false;
|
|
cut.cut_keep_lower = false;
|
|
doc.features.push_back(cut);
|
|
|
|
REQUIRE_FALSE(doc.recompute());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("both-halves plane cut splits a body into two equal halves", "[CadDocument][cut]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
double v_orig = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
REQUIRE(v_orig > 0.0);
|
|
|
|
doc.add_cut(SketchPlane::XY(), 5.0, false, true, true, 0, "SplitBoth");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume());
|
|
REQUIRE_THAT(v0 + v1, WithinRel(v_orig, 1e-4));
|
|
REQUIRE_THAT(v0, WithinRel(20.0 * 20.0 * 5.0, 0.01));
|
|
REQUIRE_THAT(v1, WithinRel(20.0 * 20.0 * 5.0, 0.01));
|
|
}
|
|
|
|
TEST_CASE("split by a body face divides a box into two halves via top-face offset", "[CadDocument][cut]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
double v_orig = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
|
|
doc.add_split_by_face(0, -1, top_face, true, true, "SplitByFace");
|
|
doc.features.back().cut_offset = -5.0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume());
|
|
REQUIRE_THAT(v0 + v1, WithinRel(v_orig, 1e-4));
|
|
REQUIRE_THAT(v0, WithinRel(20.0 * 20.0 * 5.0, 0.02));
|
|
REQUIRE_THAT(v1, WithinRel(20.0 * 20.0 * 5.0, 0.02));
|
|
}
|
|
|
|
TEST_CASE("split by face keep upper only", "[CadDocument][cut]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
|
|
doc.add_split_by_face(0, -1, top_face, true, false, "SplitUpper");
|
|
doc.features.back().cut_offset = -5.0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
double v = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
REQUIRE_THAT(v, WithinRel(20.0 * 20.0 * 5.0, 0.02));
|
|
}
|
|
|
|
TEST_CASE("split by face round-trip serialization", "[CadDocument][cut]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
|
|
doc.add_split_by_face(0, 0, top_face, true, true, "SplitRT");
|
|
doc.features.back().cut_offset = -5.0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
int saved_face_body = doc.features.back().cut_face_body;
|
|
int saved_face = doc.features.back().cut_face;
|
|
bool saved_upper = doc.features.back().cut_keep_upper;
|
|
bool saved_lower = doc.features.back().cut_keep_lower;
|
|
size_t saved_nb = doc.bodies.size();
|
|
|
|
std::vector<std::pair<Vec3d, Vec3d>> bboxes;
|
|
for (const auto& b : doc.bodies) {
|
|
Bnd_Box bb; BRepBndLib::Add(b.shape, bb);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
bboxes.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)});
|
|
}
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
REQUIRE(doc2.bodies.size() == saved_nb);
|
|
REQUIRE(doc2.features.size() == doc.features.size());
|
|
|
|
const auto& f2 = doc2.features.back();
|
|
REQUIRE(f2.cut_face_body == saved_face_body);
|
|
REQUIRE(f2.cut_face == saved_face);
|
|
REQUIRE(f2.cut_keep_upper == saved_upper);
|
|
REQUIRE(f2.cut_keep_lower == saved_lower);
|
|
|
|
for (size_t i = 0; i < saved_nb; ++i) {
|
|
Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6));
|
|
REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6));
|
|
REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6));
|
|
REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6));
|
|
REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6));
|
|
REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("mirror reflects a body about a plane", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
auto make_box = [](CadDocument& doc, double w, double h, double d) {
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), w, h, 0, "Box");
|
|
doc.add_extrude(sk, d, false, BooleanMode::New, "E");
|
|
};
|
|
|
|
// --- New mode: 20x20x20 cube, mirror about XZ plane offset to x=30 ---
|
|
// The cube is in x=[-10,10]; the mirror is at x=30, so the mirrored cube
|
|
// is at x=[50,70]. Disjoint -> two bodies, equal volumes (8000 each).
|
|
SECTION("New mode: two disjoint bodies, equal volumes") {
|
|
CadDocument doc;
|
|
make_box(doc, 20.0, 20.0, 20.0);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
double v_orig = double(doc.display_mesh.volume());
|
|
REQUIRE_THAT(v_orig, WithinRel(8000.0, 0.01));
|
|
|
|
doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::New, "Mirror1");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume());
|
|
REQUIRE_THAT(v0, WithinRel(8000.0, 0.01));
|
|
REQUIRE_THAT(v1, WithinRel(8000.0, 0.01));
|
|
}
|
|
|
|
// --- New mode with keep_original=false: the source body is replaced ---
|
|
SECTION("New mode, keep_original=false: source body removed") {
|
|
CadDocument doc;
|
|
make_box(doc, 20.0, 20.0, 20.0);
|
|
REQUIRE(doc.recompute());
|
|
doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::New, "Mirror1");
|
|
doc.features.back().mirror_keep_original = false;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
double v = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
REQUIRE_THAT(v, WithinRel(8000.0, 0.01));
|
|
}
|
|
|
|
// --- Add mode: L-shape, entirely on one side of the mirror plane -> 2x volume ---
|
|
// Build an L-shape completely in x>0: base 20x20x5 at x=[0,20] + wall 10x20x10
|
|
// at x=[0,10] on top. Mirror about XZ at x=30 -> mirror at x=[40,60], disjoint.
|
|
SECTION("Add mode: asymmetric L-shape, disjoint halves -> 2x volume") {
|
|
CadDocument doc;
|
|
// Base: 20x20x5, shifted to x=10 so it's in x=[0,20]
|
|
CadFeature skb;
|
|
skb.type = CadFeatureType::Sketch;
|
|
skb.name = "Base";
|
|
skb.plane = SketchPlane::XY();
|
|
skb.imported_regions = {{ {Vec2d(0,-10), Vec2d(20,-10), Vec2d(20,10), Vec2d(0,10)} }};
|
|
doc.features.push_back(skb);
|
|
int skb_idx = int(doc.features.size()) - 1;
|
|
CadFeature exb;
|
|
exb.type = CadFeatureType::Extrude;
|
|
exb.name = "EBase";
|
|
exb.sketch_ref = skb_idx;
|
|
exb.distance = 5.0;
|
|
exb.mode = BooleanMode::New;
|
|
doc.features.push_back(exb);
|
|
|
|
// Wall: 10x20x10 on top of the base, x=[0,10]
|
|
CadFeature skw;
|
|
skw.type = CadFeatureType::Sketch;
|
|
skw.name = "Wall";
|
|
skw.plane = SketchPlane::XY();
|
|
skw.plane.origin = Vec3d(0, 0, 5);
|
|
skw.imported_regions = {{ {Vec2d(0,-10), Vec2d(10,-10), Vec2d(10,10), Vec2d(0,10)} }};
|
|
doc.features.push_back(skw);
|
|
int skw_idx = int(doc.features.size()) - 1;
|
|
CadFeature exw;
|
|
exw.type = CadFeatureType::Extrude;
|
|
exw.name = "EWall";
|
|
exw.sketch_ref = skw_idx;
|
|
exw.distance = 10.0;
|
|
exw.mode = BooleanMode::Add;
|
|
exw.target_body = 0;
|
|
doc.features.push_back(exw);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
const double v_l = double(doc.display_mesh.volume());
|
|
|
|
// Mirror about YZ at x=30 -> the mirror is entirely in x=[40,60],
|
|
// disjoint from the original in x=[0,20].
|
|
SketchPlane mp = SketchPlane::YZ();
|
|
mp.origin = Vec3d(30, 0, 0);
|
|
doc.add_mirror(mp, 0, BooleanMode::Add, "Mirror1");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
const double v_m = double(doc.display_mesh.volume());
|
|
REQUIRE_THAT(v_m, WithinRel(2.0 * v_l, 0.02));
|
|
}
|
|
|
|
// --- Add mode with intersecting plane -> fused volume < 2x ---
|
|
// A 20x20x20 cube centred on the origin (so x=[-10,10]), mirrored about
|
|
// XZ plane at x=0. The mirror maps the cube onto itself exactly (symmetry).
|
|
// Fusing a cube with itself at the plane of symmetry produces the same cube
|
|
// -> volume == original, strictly less than 2x.
|
|
SECTION("Add mode: intersecting plane -> volume < 2x original") {
|
|
CadDocument doc;
|
|
make_box(doc, 20.0, 20.0, 20.0);
|
|
REQUIRE(doc.recompute());
|
|
const double v_orig = double(doc.display_mesh.volume());
|
|
REQUIRE_THAT(v_orig, WithinRel(8000.0, 0.01));
|
|
|
|
doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::Add, "Mirror1");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
const double v_mir = double(doc.display_mesh.volume());
|
|
REQUIRE(v_mir < v_orig * 1.9);
|
|
}
|
|
|
|
// --- Invalid target body index ---
|
|
SECTION("invalid target body index -> error, no crash") {
|
|
CadDocument doc;
|
|
// No bodies in the document -> mirror must fail, not crash.
|
|
doc.features.push_back({CadFeatureType::Mirror, "BadMirror", true,
|
|
SketchShape::Rectangle, SketchPlane::XZ()});
|
|
doc.features.back().mode = BooleanMode::New;
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("mirror serialization round-trip", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box");
|
|
doc.add_extrude(sk, 20.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
|
|
doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::New, "Mirror1");
|
|
doc.features.back().mirror_keep_original = false;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
REQUIRE(doc2.recompute());
|
|
REQUIRE(doc2.error.empty());
|
|
|
|
REQUIRE(doc2.features.size() == doc.features.size());
|
|
const auto& f1 = doc.features.back();
|
|
const auto& f2 = doc2.features.back();
|
|
REQUIRE(f2.type == CadFeatureType::Mirror);
|
|
REQUIRE(f2.mode == BooleanMode::New);
|
|
REQUIRE(f2.mirror_keep_original == false);
|
|
REQUIRE(f2.name == "Mirror1");
|
|
|
|
REQUIRE(doc2.bodies.size() == doc.bodies.size());
|
|
}
|
|
|
|
TEST_CASE("mass properties: analytic cube", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
auto mp = doc.body_mass_properties(0);
|
|
REQUIRE(mp.valid);
|
|
// Cube 20x20x20 -> V = 8000 mm^3
|
|
REQUIRE_THAT(mp.volume, WithinRel(8000.0, 1e-6));
|
|
// Surface area = 6 * 20^2 = 2400 mm^2
|
|
REQUIRE_THAT(mp.surface_area, WithinRel(2400.0, 1e-6));
|
|
// COM at geometric centre: the box centred on origin is from z=0 to z=20,
|
|
// so centre at (0, 0, 10)
|
|
REQUIRE_THAT(mp.center_of_mass.x(), WithinAbs(0.0, 1e-6));
|
|
REQUIRE_THAT(mp.center_of_mass.y(), WithinAbs(0.0, 1e-6));
|
|
REQUIRE_THAT(mp.center_of_mass.z(), WithinAbs(10.0, 1e-6));
|
|
// Inertia sanity: symmetric cube -> diagonal terms equal, off-diagonal ~0
|
|
double Ixx = mp.inertia[0], Iyy = mp.inertia[4], Izz = mp.inertia[8];
|
|
REQUIRE_THAT(Ixx, WithinRel(8000.0 * (20*20 + 20*20) / 12.0, 1e-4)); // I = m/12*(b^2+h^2) about COM
|
|
REQUIRE_THAT(Iyy, WithinRel(Ixx, 1e-4));
|
|
REQUIRE_THAT(Izz, WithinRel(Ixx, 1e-4));
|
|
REQUIRE_THAT(mp.inertia[1], WithinAbs(0.0, 1e-6));
|
|
REQUIRE_THAT(mp.inertia[2], WithinAbs(0.0, 1e-6));
|
|
REQUIRE_THAT(mp.inertia[5], WithinAbs(0.0, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("mass properties: analytic cylinder", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
// Circle r=10 on XY, extrude height=5 -> cylinder
|
|
SketchEntity c;
|
|
c.type = SketchEntity::Type::Circle;
|
|
c.center = Vec2d(0, 0);
|
|
c.radius = 10.0;
|
|
int sk = doc.add_sketch_entities({c}, SketchPlane::XY(), "Circle");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
auto mp = doc.body_mass_properties(0);
|
|
REQUIRE(mp.valid);
|
|
double expected_vol = M_PI * 100.0 * 5.0; // pi * r^2 * h
|
|
REQUIRE_THAT(mp.volume, WithinRel(expected_vol, 1e-4));
|
|
// Surface: 2*pi*r^2 + 2*pi*r*h = 2*pi*100 + 2*pi*10*5 = 200*pi + 100*pi = 300*pi
|
|
double expected_area = 2 * M_PI * 100.0 + 2 * M_PI * 10.0 * 5.0;
|
|
REQUIRE_THAT(mp.surface_area, WithinRel(expected_area, 1e-4));
|
|
}
|
|
|
|
TEST_CASE("mass properties: hollow body", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
// Solid cube
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
auto mp_solid = doc.body_mass_properties(0);
|
|
REQUIRE(mp_solid.valid);
|
|
double solid_vol = mp_solid.volume;
|
|
|
|
// Hollow cube with a through hole
|
|
CadDocument doc2;
|
|
int sk2 = doc2.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch");
|
|
doc2.add_extrude(sk2, 20.0, false, BooleanMode::New, "Extrude");
|
|
doc2.add_hole(10.0, 20.0, true, 0.0, 0.0, SketchPlane::XY(), "Hole");
|
|
REQUIRE(doc2.recompute());
|
|
auto mp_hollow = doc2.body_mass_properties(0);
|
|
REQUIRE(mp_hollow.valid);
|
|
|
|
// Hollow volume < solid volume
|
|
REQUIRE(mp_hollow.volume < solid_vol);
|
|
// Hollow = solid - cylinder: V_cyl = pi*5^2*20 = 500*pi
|
|
double expected_hollow = solid_vol - M_PI * 25.0 * 20.0;
|
|
REQUIRE_THAT(mp_hollow.volume, WithinRel(expected_hollow, 0.01));
|
|
}
|
|
|
|
TEST_CASE("mass properties: invalid body index", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
|
|
// Out of range -> valid=false
|
|
auto mp = doc.body_mass_properties(99);
|
|
REQUIRE_FALSE(mp.valid);
|
|
REQUIRE(mp.volume == 0.0);
|
|
// Negative index
|
|
auto mp2 = doc.body_mass_properties(-1);
|
|
REQUIRE_FALSE(mp2.valid);
|
|
|
|
// Empty document (no recompute) -> all bodies empty, index 0 out of range
|
|
CadDocument empty;
|
|
auto mp3 = empty.body_mass_properties(0);
|
|
REQUIRE_FALSE(mp3.valid);
|
|
}
|
|
|
|
TEST_CASE("serialize_recipe roundtrip with two bodies", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
CadDocument doc;
|
|
|
|
// Body 1: rectangle sketch + extrude + fillet
|
|
int sk1 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
20, 20, 10, "Rect1");
|
|
REQUIRE(sk1 >= 0);
|
|
doc.add_extrude(sk1, 10.0, false, BooleanMode::New, "Extrude1");
|
|
doc.add_fillet(2.0, FaceGroup::All, "Fillet1");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Body 2: circle sketch + extrude (separate New body)
|
|
SketchEntity circ;
|
|
circ.type = SketchEntity::Type::Circle;
|
|
circ.center = Vec2d(0, 0);
|
|
circ.radius = 8.0;
|
|
int sk2 = doc.add_sketch_entities({circ}, SketchPlane::XZ(), "Circle2");
|
|
doc.add_extrude(sk2, 6.0, false, BooleanMode::New, "Extrude2");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
REQUIRE(doc.bodies.size() >= 2);
|
|
std::vector<double> orig_vols;
|
|
for (const auto& b : doc.bodies)
|
|
orig_vols.push_back(double(SketchEngine::tessellate(b.shape).volume()));
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
REQUIRE(doc2.error.empty());
|
|
REQUIRE(doc2.bodies.size() == doc.bodies.size());
|
|
|
|
for (size_t i = 0; i < doc.bodies.size(); ++i) {
|
|
double v2 = double(SketchEngine::tessellate(doc2.bodies[i].shape).volume());
|
|
REQUIRE_THAT(v2, WithinRel(orig_vols[i], 1e-6));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("deserialize_recipe rejects future version with error", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
std::ostringstream oss;
|
|
{
|
|
cereal::BinaryOutputArchive ar(oss);
|
|
uint32_t v = 999;
|
|
ar(v);
|
|
}
|
|
REQUIRE_FALSE(doc.deserialize_recipe(oss.str()));
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
CHECK_CONTAINS(doc.error, "newer version");
|
|
}
|
|
|
|
TEST_CASE("deserialize_recipe rejects older version with error", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
std::ostringstream oss;
|
|
{
|
|
cereal::BinaryOutputArchive ar(oss);
|
|
uint32_t v = 1;
|
|
ar(v);
|
|
}
|
|
REQUIRE_FALSE(doc.deserialize_recipe(oss.str()));
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
CHECK_CONTAINS(doc.error, "older version");
|
|
}
|
|
|
|
TEST_CASE("deserialize_recipe handles truncated blob without throwing", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
std::string garbage = "this is not a valid cereal blob";
|
|
REQUIRE_FALSE(doc.deserialize_recipe(garbage));
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
}
|
|
|
|
TEST_CASE("deserialize_recipe handles empty blob without throwing", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
REQUIRE_FALSE(doc.deserialize_recipe(""));
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
}
|
|
|
|
TEST_CASE("deserialize_recipe error is non-empty on every failure path", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
auto reset = [&]() { doc = CadDocument{}; };
|
|
|
|
// too new
|
|
{
|
|
std::ostringstream oss;
|
|
{ cereal::BinaryOutputArchive ar(oss); uint32_t v = 999; ar(v); }
|
|
reset();
|
|
REQUIRE_FALSE(doc.deserialize_recipe(oss.str()));
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
}
|
|
// too old
|
|
{
|
|
std::ostringstream oss;
|
|
{ cereal::BinaryOutputArchive ar(oss); uint32_t v = 1; ar(v); }
|
|
reset();
|
|
REQUIRE_FALSE(doc.deserialize_recipe(oss.str()));
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
}
|
|
// truncated / garbage
|
|
{
|
|
reset();
|
|
REQUIRE_FALSE(doc.deserialize_recipe("not a valid blob \x00\x01\x02"));
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
}
|
|
// empty
|
|
{
|
|
reset();
|
|
REQUIRE_FALSE(doc.deserialize_recipe(""));
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("re-edit: editing a mid-timeline feature rebuilds downstream", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
CadDocument doc;
|
|
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
20, 10, 0, "Sketch1");
|
|
REQUIRE(sk >= 0);
|
|
int ex = doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude1");
|
|
REQUIRE(ex >= 0);
|
|
doc.add_fillet(1.0, FaceGroup::All, "Fillet1");
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
Vec3d sz0 = doc.display_mesh.bounding_box().size();
|
|
REQUIRE(sz0.z() > 0.0);
|
|
|
|
// Edit the MID feature (extrude — NOT the last; Fillet is downstream).
|
|
doc.features[ex].distance = 12.0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
Vec3d sz1 = doc.display_mesh.bounding_box().size();
|
|
REQUIRE(sz1.z() > sz0.z() + 1.0);
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
// Edit the FIRST feature (sketch width) — must propagate the whole chain.
|
|
doc.features[sk].width = 30;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
Vec3d sz2 = doc.display_mesh.bounding_box().size();
|
|
REQUIRE(sz2.x() > sz1.x() + 1.0);
|
|
}
|
|
|
|
TEST_CASE("re-edit survives serialize -> deserialize", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
CadDocument doc;
|
|
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
20, 10, 0, "Sketch1");
|
|
REQUIRE(sk >= 0);
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude1");
|
|
doc.add_fillet(1.0, FaceGroup::All, "Fillet1");
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
REQUIRE(doc2.error.empty());
|
|
REQUIRE(doc2.recompute());
|
|
REQUIRE(doc2.error.empty());
|
|
Vec3d sz_pre = doc2.display_mesh.bounding_box().size();
|
|
REQUIRE(sz_pre.z() > 0.0);
|
|
|
|
// Mid-edit the deserialized document — the persisted recipe stays re-editable.
|
|
doc2.features[1].distance = 12.0;
|
|
REQUIRE(doc2.recompute());
|
|
REQUIRE(doc2.error.empty());
|
|
Vec3d sz_post = doc2.display_mesh.bounding_box().size();
|
|
REQUIRE(sz_post.z() > sz_pre.z() + 1.0);
|
|
}
|
|
|
|
TEST_CASE("re-edit: multi-type timeline replays all downstream features", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
CadDocument doc;
|
|
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
30, 30, 0, "Sketch1");
|
|
REQUIRE(sk >= 0);
|
|
int ex = doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude1");
|
|
REQUIRE(ex >= 0);
|
|
doc.add_hole(6.0, 4.0, false, 0.0, 0.0, SketchPlane::XY(), "Hole1");
|
|
doc.add_chamfer(1.0, FaceGroup::All, "Chamfer1");
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
size_t n_bodies = doc.bodies.size();
|
|
REQUIRE(n_bodies >= 1);
|
|
Vec3d sz0 = doc.display_mesh.bounding_box().size();
|
|
REQUIRE(sz0.z() > 0.0);
|
|
|
|
// Edit the MID extrude — downstream Hole and Chamfer must rebuild.
|
|
doc.features[ex].distance = 16.0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == n_bodies);
|
|
Vec3d sz1 = doc.display_mesh.bounding_box().size();
|
|
REQUIRE(sz1.z() > sz0.z() + 1.0);
|
|
}
|
|
|
|
TEST_CASE("datum plane construction methods", "[CadDocument][plane]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
// Build a doc with a box (sketch rect 40x30 + extrude 20) so bodies[0] has faces/edges.
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 30, 0, "BoxSketch");
|
|
REQUIRE(sk >= 0);
|
|
doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Extrude1");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
const int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
REQUIRE(n_faces == 6); // a box
|
|
const int n_edges = GeometryEngine::edge_count(doc.bodies[0].shape);
|
|
REQUIRE(n_edges == 12);
|
|
|
|
// Find top face (normal ~ +Z) and bottom face (normal ~ -Z) by scanning.
|
|
int top_idx = -1, bot_idx = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) top_idx = i;
|
|
if (n.z() < -0.9) bot_idx = i;
|
|
}
|
|
REQUIRE(top_idx >= 0);
|
|
REQUIRE(bot_idx >= 0);
|
|
|
|
// --- Offset from base XY by 10 ---
|
|
auto planes0 = doc.resolve_datum_planes();
|
|
size_t initial = planes0.size();
|
|
|
|
CadFeature f0;
|
|
f0.type = CadFeatureType::Plane;
|
|
f0.name = "Offset10";
|
|
f0.plane_base = 0; // XY
|
|
f0.plane_type = PlaneType::Offset;
|
|
f0.plane_offset = 10;
|
|
doc.features.push_back(f0);
|
|
|
|
auto planes = doc.resolve_datum_planes();
|
|
REQUIRE(planes.size() == initial + 1);
|
|
CHECK_THAT(planes.back().second.origin.z(), WithinAbs(10.0, 1e-6));
|
|
CHECK_THAT(planes.back().second.normal.z(), WithinAbs(1.0, 1e-6));
|
|
|
|
// --- Coincident to top face ---
|
|
CadFeature f1;
|
|
f1.type = CadFeatureType::Plane;
|
|
f1.name = "CoincidentTop";
|
|
f1.plane_type = PlaneType::Coincident;
|
|
f1.plane_face_body = 0;
|
|
f1.plane_face = top_idx;
|
|
doc.features.push_back(f1);
|
|
|
|
planes = doc.resolve_datum_planes();
|
|
REQUIRE(planes.size() == initial + 2);
|
|
CHECK_THAT(planes.back().second.origin.z(), WithinAbs(20.0, 1e-6)); // box height is 20
|
|
CHECK_THAT(planes.back().second.normal.z(), WithinAbs(1.0, 1e-6));
|
|
|
|
// --- Midplane between top and bottom faces ---
|
|
CadFeature f2;
|
|
f2.type = CadFeatureType::Plane;
|
|
f2.name = "Midplane";
|
|
f2.plane_type = PlaneType::Midplane;
|
|
f2.plane_face_body = 0;
|
|
f2.plane_face = top_idx;
|
|
f2.plane_face2_body = 0;
|
|
f2.plane_face2 = bot_idx;
|
|
doc.features.push_back(f2);
|
|
|
|
planes = doc.resolve_datum_planes();
|
|
REQUIRE(planes.size() == initial + 3);
|
|
CHECK_THAT(planes.back().second.origin.z(), WithinAbs(10.0, 1e-6)); // midway
|
|
CHECK_THAT(std::abs(planes.back().second.normal.z()), WithinAbs(1.0, 1e-6));
|
|
|
|
// --- Orthonormality check on all resolved planes ---
|
|
for (const auto& [name, sp] : planes) {
|
|
INFO("Plane: " << name);
|
|
CHECK_THAT(sp.normal.norm(), WithinAbs(1.0, 1e-6));
|
|
CHECK_THAT(sp.x_axis.norm(), WithinAbs(1.0, 1e-6));
|
|
CHECK_THAT(sp.y_axis.norm(), WithinAbs(1.0, 1e-6));
|
|
CHECK_THAT(std::abs(sp.x_axis.dot(sp.y_axis)), WithinAbs(0.0, 1e-6));
|
|
CHECK_THAT(std::abs(sp.x_axis.dot(sp.normal)), WithinAbs(0.0, 1e-6));
|
|
CHECK_THAT(std::abs(sp.y_axis.dot(sp.normal)), WithinAbs(0.0, 1e-6));
|
|
}
|
|
}
|
|
|
|
// Mirrors the snaporca [Deviation] case (snaporca carries it in test_geometry.cpp; here it lives
|
|
// alongside the CAD suite). GeometryEngine::surface_deviation = one-sided Hausdorff used by the
|
|
// MCP validate_against acceptance metric.
|
|
TEST_CASE("surface_deviation: identical solids ~0, shifted solid ~shift", "[Deviation]")
|
|
{
|
|
TopoDS_Shape ref = BRepPrimAPI_MakeBox(10.0, 10.0, 10.0).Shape();
|
|
auto d0 = GeometryEngine::surface_deviation(ref, ref, 0.5);
|
|
REQUIRE(d0.sample_count > 0);
|
|
REQUIRE_THAT(d0.max_mm, Catch::Matchers::WithinAbs(0.0, 1e-6));
|
|
|
|
// candidate shifted +2mm in X: far corner vertices sit 2mm outside the reference
|
|
gp_Trsf t; t.SetTranslation(gp_Vec(2.0, 0.0, 0.0));
|
|
TopoDS_Shape shifted = BRepBuilderAPI_Transform(ref, t, true).Shape();
|
|
auto d1 = GeometryEngine::surface_deviation(shifted, ref, 0.5);
|
|
REQUIRE_THAT(d1.max_mm, Catch::Matchers::WithinAbs(2.0, 0.05));
|
|
REQUIRE(d1.mean_mm > 0.0);
|
|
}
|
|
|
|
TEST_CASE("mesh_to_brep: watertight cube -> solid, coplanar merge gives 6 faces", "[design][mesh2brep]")
|
|
{
|
|
const indexed_triangle_set cube = its_make_cube(10.0, 20.0, 30.0);
|
|
REQUIRE(cube.indices.size() == 12);
|
|
|
|
SECTION("faceted: one B-rep face per triangle, exact volume") {
|
|
GeometryEngine::MeshBrepStats st;
|
|
const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(cube, 0.01, /*no merge*/0.0, st);
|
|
REQUIRE_FALSE(shape.IsNull());
|
|
CHECK(st.kept_tris == 12);
|
|
CHECK(st.faces_built == 12);
|
|
CHECK(st.faces_final == 12);
|
|
// Shared topology by construction: a cube has 8 vertices and 18 edges once the two
|
|
// triangles of each face share their diagonal. Every edge used exactly twice.
|
|
CHECK(st.unique_edges == 18);
|
|
CHECK(st.boundary_edges == 0);
|
|
CHECK(st.nonmanifold_edges == 0);
|
|
CHECK(st.watertight);
|
|
REQUIRE(st.is_solid);
|
|
REQUIRE_THAT(st.volume, Catch::Matchers::WithinRel(10.0 * 20.0 * 30.0, 1e-9));
|
|
}
|
|
|
|
SECTION("merge coplanar: 12 triangles collapse to the cube's 6 real faces") {
|
|
GeometryEngine::MeshBrepStats st;
|
|
const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(cube, 0.01, 5.0, st);
|
|
REQUIRE_FALSE(shape.IsNull());
|
|
REQUIRE(st.is_solid);
|
|
// This is the whole point of merging: the imported body must expose pickable CAD faces,
|
|
// not one face per triangle, or the fillet/extrude tools have nothing meaningful to grab.
|
|
REQUIRE(st.faces_final == 6);
|
|
REQUIRE_THAT(st.volume, Catch::Matchers::WithinRel(10.0 * 20.0 * 30.0, 1e-9));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("mesh_to_brep: an open mesh is reported as a shell, never a fake solid", "[design][mesh2brep]")
|
|
{
|
|
indexed_triangle_set open_cube = its_make_cube(10.0, 10.0, 10.0);
|
|
open_cube.indices.pop_back(); // punch a hole: drop one triangle
|
|
open_cube.indices.pop_back(); // (and its coplanar partner -> a whole face missing)
|
|
|
|
GeometryEngine::MeshBrepStats st;
|
|
const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(open_cube, 0.01, 5.0, st);
|
|
REQUIRE_FALSE(shape.IsNull());
|
|
CHECK(st.kept_tris == 10);
|
|
CHECK(st.boundary_edges > 0); // the hole's rim
|
|
CHECK_FALSE(st.watertight);
|
|
REQUIRE_FALSE(st.is_solid); // must NOT be dressed up as a solid
|
|
CHECK(st.volume == 0.0);
|
|
}
|
|
|
|
TEST_CASE("mesh_to_brep: degenerate triangles are rejected on a scale-independent test", "[design][mesh2brep]")
|
|
{
|
|
// A thin but perfectly legitimate CAD sliver. Every edge (1.0, ~0.5, ~0.5) is far above the
|
|
// 0.01 dedup tolerance, so no vertex collapses — but its area (5e-5) is BELOW tolerance^2
|
|
// (1e-4). A rule of "reject when area < tolerance^2" would therefore throw it away, which is
|
|
// precisely the bug that turned a watertight 62k-triangle input into a falsely-open shell.
|
|
// The scale-independent test (area < 1e-9 * longest_edge^2 = 1e-9) keeps it, as it must.
|
|
indexed_triangle_set sliver;
|
|
sliver.vertices = { {0.f, 0.f, 0.f}, {1.f, 0.f, 0.f}, {0.5f, 0.0001f, 0.f} };
|
|
sliver.indices = { {0, 1, 2} };
|
|
GeometryEngine::MeshBrepStats st;
|
|
GeometryEngine::mesh_to_brep(sliver, 0.01, 0.0, st);
|
|
CHECK(st.degenerate_sliver == 0);
|
|
CHECK(st.degenerate_collapsed == 0);
|
|
CHECK(st.kept_tris == 1);
|
|
|
|
// A truly collinear triangle has no area at any scale -> rejected as a sliver.
|
|
indexed_triangle_set collinear;
|
|
collinear.vertices = { {0.f, 0.f, 0.f}, {10.f, 0.f, 0.f}, {20.f, 0.f, 0.f} };
|
|
collinear.indices = { {0, 1, 2} };
|
|
GeometryEngine::MeshBrepStats st2;
|
|
CHECK_THROWS(GeometryEngine::mesh_to_brep(collinear, 0.01, 0.0, st2)); // nothing left to build
|
|
CHECK(st2.degenerate_sliver == 1);
|
|
|
|
// A triangle entirely inside one tolerance cell is sub-resolution noise -> collapsed.
|
|
indexed_triangle_set tiny;
|
|
tiny.vertices = { {0.f, 0.f, 0.f}, {0.001f, 0.f, 0.f}, {0.f, 0.001f, 0.f} };
|
|
tiny.indices = { {0, 1, 2} };
|
|
GeometryEngine::MeshBrepStats st3;
|
|
CHECK_THROWS(GeometryEngine::mesh_to_brep(tiny, 0.1, 0.0, st3));
|
|
CHECK(st3.degenerate_collapsed == 1);
|
|
}
|
|
|
|
TEST_CASE("datum axis: two points direction is unit and analytic", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int ax = doc.add_axis(AxisType::TwoPoints, "AxisThroughZ");
|
|
REQUIRE(ax == 0);
|
|
doc.features[ax].axis_p1 = Vec3d(0, 0, 0);
|
|
doc.features[ax].axis_p2 = Vec3d(0, 0, 10);
|
|
|
|
auto axes = doc.resolve_datum_axes();
|
|
REQUIRE(axes.size() == 1);
|
|
REQUIRE(axes[0].name == "AxisThroughZ");
|
|
REQUIRE(axes[0].error.empty());
|
|
CHECK_THAT(axes[0].direction.x(), WithinAbs(0.0, 1e-12));
|
|
CHECK_THAT(axes[0].direction.y(), WithinAbs(0.0, 1e-12));
|
|
CHECK_THAT(axes[0].direction.z(), WithinAbs(1.0, 1e-12));
|
|
CHECK_THAT(axes[0].direction.norm(), WithinAbs(1.0, 1e-9));
|
|
CHECK_THAT(axes[0].origin.x(), WithinAbs(0.0, 1e-9));
|
|
CHECK_THAT(axes[0].origin.y(), WithinAbs(0.0, 1e-9));
|
|
CHECK_THAT(axes[0].origin.z(), WithinAbs(0.0, 1e-9));
|
|
}
|
|
|
|
TEST_CASE("datum axis: degenerate two identical points fails cleanly", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
int ax = doc.add_axis(AxisType::TwoPoints, "Degenerate");
|
|
doc.features[ax].axis_p1 = Vec3d(5, 5, 5);
|
|
doc.features[ax].axis_p2 = Vec3d(5, 5, 5);
|
|
|
|
auto axes = doc.resolve_datum_axes();
|
|
REQUIRE(axes.size() == 1);
|
|
REQUIRE_FALSE(axes[0].error.empty());
|
|
}
|
|
|
|
TEST_CASE("datum axis: two parallel planes fail with error", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
// Two offset XY planes are parallel -> no intersection.
|
|
// 3 and 4, not 0 and 1: axis_plane_a/b use the same encoding as plane_base — 0/1/2 are the
|
|
// XY/XZ/YZ base planes and datums start at 3 — because the GUI fills these from
|
|
// populate_plane_choices() and stores the row verbatim.
|
|
doc.add_plane(0 /*XY*/, 10.0, 0.0, 0, "PlaneA");
|
|
doc.add_plane(0 /*XY*/, 30.0, 0.0, 0, "PlaneB");
|
|
|
|
int ax = doc.add_axis(AxisType::TwoPoints, "Parallel");
|
|
doc.features[ax].axis_type = AxisType::PlaneIntersection;
|
|
doc.features[ax].axis_plane_a = 3;
|
|
doc.features[ax].axis_plane_b = 4;
|
|
|
|
auto axes = doc.resolve_datum_axes();
|
|
REQUIRE(axes.size() == 1);
|
|
REQUIRE_FALSE(axes[0].error.empty());
|
|
}
|
|
|
|
// The case the GUI actually produces most often, and which could not work before: the two refs
|
|
// are rows of the plane picker, whose first three entries are the base planes. XY x XZ is the
|
|
// X axis. Previously ref 0 was read as "datum plane 0", so this silently resolved to the wrong
|
|
// plane or failed with "plane ref not found".
|
|
TEST_CASE("datum axis: intersection of two BASE planes gives the expected axis", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int ax = doc.add_axis(AxisType::PlaneIntersection, "XAxis");
|
|
doc.features[ax].axis_plane_a = 0; // XY
|
|
doc.features[ax].axis_plane_b = 1; // XZ
|
|
|
|
auto axes = doc.resolve_datum_axes();
|
|
REQUIRE(axes.size() == 1);
|
|
REQUIRE(axes[0].error.empty());
|
|
// XY normal is Z, XZ normal is Y; Z x Y is +/-X.
|
|
CHECK_THAT(std::abs(axes[0].direction.x()), WithinAbs(1.0, 1e-12));
|
|
CHECK_THAT(axes[0].direction.y(), WithinAbs(0.0, 1e-12));
|
|
CHECK_THAT(axes[0].direction.z(), WithinAbs(0.0, 1e-12));
|
|
}
|
|
|
|
// A datum plane crossed with a base plane — the mixed case, which pins the +3 offset.
|
|
TEST_CASE("datum axis: base plane crossed with a datum plane", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
doc.add_plane(1 /*XZ*/, 5.0, 0.0, 0, "OffsetXZ"); // datum 0 -> row 3
|
|
|
|
int ax = doc.add_axis(AxisType::PlaneIntersection, "MixedAxis");
|
|
doc.features[ax].axis_plane_a = 0; // XY base
|
|
doc.features[ax].axis_plane_b = 3; // the datum above
|
|
|
|
auto axes = doc.resolve_datum_axes();
|
|
REQUIRE(axes.size() == 1);
|
|
REQUIRE(axes[0].error.empty());
|
|
CHECK_THAT(std::abs(axes[0].direction.x()), WithinAbs(1.0, 1e-12));
|
|
}
|
|
|
|
TEST_CASE("datum axis: cylinder centreline from extruded circle", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Build a cylinder: circle r=5 at origin, extrude 20 mm along +Z -> cylinder z=[0,20]
|
|
int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle");
|
|
doc.add_extrude(sk, 20.0, false, BooleanMode::New, "Cyl");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
// Find the lateral cylindrical face
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int lateral_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(fc);
|
|
if (cyl.ok) { lateral_face = i; break; }
|
|
}
|
|
REQUIRE(lateral_face >= 0);
|
|
|
|
int ax = doc.add_axis(AxisType::TwoPoints, "CylAx");
|
|
doc.features[ax].axis_type = AxisType::CylinderCenterline;
|
|
doc.features[ax].axis_body = 0;
|
|
doc.features[ax].axis_face = lateral_face;
|
|
|
|
auto axes = doc.resolve_datum_axes();
|
|
REQUIRE(axes.size() == 1);
|
|
REQUIRE(axes[0].error.empty());
|
|
// OCCT may return the axis direction as +Z or -Z depending on face orientation;
|
|
// the centreline is always collinear with Z and passes through (x=0,y=0).
|
|
CHECK_THAT(std::abs(axes[0].direction.z()), WithinAbs(1.0, 1e-12));
|
|
CHECK_THAT(axes[0].direction.x(), WithinAbs(0.0, 1e-12));
|
|
CHECK_THAT(axes[0].direction.y(), WithinAbs(0.0, 1e-12));
|
|
CHECK_THAT(axes[0].origin.x(), WithinAbs(0.0, 1e-6));
|
|
CHECK_THAT(axes[0].origin.y(), WithinAbs(0.0, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal axes", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Build a body so we have a face to reference for FaceAndDirection.
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
Vec3d fn = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, i));
|
|
if (fn.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
|
|
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1");
|
|
REQUIRE(cs >= 0);
|
|
doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection;
|
|
doc.features[cs].coordsys_body = 0;
|
|
doc.features[cs].coordsys_face = top_face;
|
|
// Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z).
|
|
doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5);
|
|
|
|
auto css = doc.resolve_datum_coordsys();
|
|
REQUIRE(css.size() == 1);
|
|
REQUIRE(css[0].error.empty());
|
|
|
|
Vec3d X = css[0].x, Y = css[0].y;
|
|
// Orthonormality: each axis has unit length
|
|
CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9));
|
|
CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9));
|
|
// Pairwise dot products are ~0
|
|
CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9));
|
|
// Z = X x Y (derived), also unit and perpendicular
|
|
Vec3d Z = X.cross(Y);
|
|
CHECK_THAT(Z.norm(), WithinAbs(1.0, 1e-9));
|
|
CHECK_THAT(std::abs(X.dot(Z)), WithinAbs(0.0, 1e-9));
|
|
CHECK_THAT(std::abs(Y.dot(Z)), WithinAbs(0.0, 1e-9));
|
|
// Right-handedness: X x Y == Z
|
|
CHECK_THAT(Z.x(), WithinAbs((X.cross(Y)).x(), 1e-9));
|
|
CHECK_THAT(Z.y(), WithinAbs((X.cross(Y)).y(), 1e-9));
|
|
CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9));
|
|
}
|
|
|
|
TEST_CASE("datum coordinate system: point_world gives world axes", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(10, 20, 30), "CS_World");
|
|
REQUIRE(cs == 0);
|
|
|
|
auto css = doc.resolve_datum_coordsys();
|
|
REQUIRE(css.size() == 1);
|
|
REQUIRE(css[0].error.empty());
|
|
CHECK_THAT(css[0].origin.x(), WithinAbs(10.0, 1e-9));
|
|
CHECK_THAT(css[0].origin.y(), WithinAbs(20.0, 1e-9));
|
|
CHECK_THAT(css[0].origin.z(), WithinAbs(30.0, 1e-9));
|
|
CHECK_THAT(css[0].x.x(), WithinAbs(1.0, 1e-9));
|
|
CHECK_THAT(css[0].x.y(), WithinAbs(0.0, 1e-9));
|
|
CHECK_THAT(css[0].x.z(), WithinAbs(0.0, 1e-9));
|
|
CHECK_THAT(css[0].y.x(), WithinAbs(0.0, 1e-9));
|
|
CHECK_THAT(css[0].y.y(), WithinAbs(1.0, 1e-9));
|
|
CHECK_THAT(css[0].y.z(), WithinAbs(0.0, 1e-9));
|
|
}
|
|
|
|
|
|
TEST_CASE("helix curve: arc length, bounding box, left-handed, conical", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
// --- Cylindrical helix r=5, pitch=2, height=10 (5 turns) ---
|
|
// One turn arc length = sqrt((2*pi*r)^2 + pitch^2) = sqrt((10*pi)^2 + 4).
|
|
// Total = 5 * sqrt(986.96...) ≈ 5 * 31.4159 ≈ 157.08 mm.
|
|
SECTION("cylindrical helix arc length matches analytic") {
|
|
CadDocument doc;
|
|
doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 0.0, "H1");
|
|
REQUIRE(doc.features.size() == 1);
|
|
std::string err;
|
|
TopoDS_Wire w = doc.build_helix_wire(doc.features[0], err);
|
|
REQUIRE_FALSE(w.IsNull());
|
|
REQUIRE(err.empty());
|
|
|
|
GProp_GProps props;
|
|
BRepGProp::LinearProperties(w, props);
|
|
const double len = props.Mass();
|
|
const double one_turn = std::sqrt(std::pow(2.0 * M_PI * 5.0, 2) + std::pow(2.0, 2));
|
|
const double expected = 5.0 * one_turn;
|
|
REQUIRE_THAT(len, WithinRel(expected, 1e-3));
|
|
}
|
|
|
|
// --- Bounding box: X/Y extent = 2*radius, Z extent = height ---
|
|
SECTION("cylindrical helix bounding box") {
|
|
CadDocument doc;
|
|
doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 0.0, "H1");
|
|
std::string err;
|
|
TopoDS_Wire w = doc.build_helix_wire(doc.features[0], err);
|
|
REQUIRE_FALSE(w.IsNull());
|
|
|
|
Bnd_Box bb;
|
|
BRepBndLib::Add(w, bb);
|
|
double xmin, ymin, zmin, xmax, ymax, zmax;
|
|
bb.Get(xmin, ymin, zmin, xmax, ymax, zmax);
|
|
REQUIRE_THAT(xmax - xmin, WithinRel(10.0, 0.1));
|
|
REQUIRE_THAT(ymax - ymin, WithinRel(10.0, 0.1));
|
|
REQUIRE_THAT(zmax - zmin, WithinRel(10.0, 0.1));
|
|
}
|
|
|
|
// --- Left-handed helix: sample a point at parameter ~0.25 and compare ---
|
|
SECTION("left_handed flips the winding direction") {
|
|
CadDocument doc_rh, doc_lh;
|
|
doc_rh.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 0.0, "RH");
|
|
doc_lh.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, true, 0.0, "LH");
|
|
|
|
std::string err;
|
|
TopoDS_Wire w_rh = doc_rh.build_helix_wire(doc_rh.features[0], err);
|
|
TopoDS_Wire w_lh = doc_lh.build_helix_wire(doc_lh.features[0], err);
|
|
REQUIRE_FALSE(w_rh.IsNull());
|
|
REQUIRE_FALSE(w_lh.IsNull());
|
|
|
|
// Sample at height = height/4 along the helix:
|
|
// RH: angle = 2*pi*turns*0.25 = pi/2, direction is +2*pi*turns
|
|
// so at z = 2.5: u = pi/2 => x = r*cos(pi/2) = 0, y = r*sin(pi/2) = +5
|
|
// LH: angle goes negative, at z = 2.5: u = -pi/2 => x = 0, y = -5
|
|
double z_sample = 2.5; // height/4
|
|
// Approximate by scanning edges and picking the vertex nearest to target z
|
|
auto point_at_z = [&](const TopoDS_Wire& w, double target_z) -> gp_Pnt {
|
|
double best_dz = 1e9;
|
|
gp_Pnt best(0,0,0);
|
|
for (TopExp_Explorer ex(w, TopAbs_EDGE); ex.More(); ex.Next()) {
|
|
TopoDS_Edge e = TopoDS::Edge(ex.Current());
|
|
BRepAdaptor_Curve curve(e);
|
|
double u0 = curve.FirstParameter();
|
|
double u1 = curve.LastParameter();
|
|
for (int s = 0; s <= 100; ++s) {
|
|
double u = u0 + (u1 - u0) * s / 100.0;
|
|
gp_Pnt p = curve.Value(u);
|
|
if (std::abs(p.Z() - target_z) < best_dz) {
|
|
best_dz = std::abs(p.Z() - target_z);
|
|
best = p;
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
};
|
|
|
|
gp_Pnt prh = point_at_z(w_rh, z_sample);
|
|
gp_Pnt plh = point_at_z(w_lh, z_sample);
|
|
|
|
// At z=2.5 for RH: angle ~ pi/2 -> y > 0
|
|
REQUIRE(prh.Y() > 0.0);
|
|
// At z=2.5 for LH: angle ~ -pi/2 -> y < 0
|
|
REQUIRE(plh.Y() < 0.0);
|
|
// They must differ in sign of y (mirror winding), not just "differ"
|
|
REQUIRE(prh.Y() * plh.Y() < 0.0);
|
|
}
|
|
|
|
// --- Conical helix: top radius matches r + height*tan(taper) ---
|
|
SECTION("conical helix top radius") {
|
|
CadDocument doc;
|
|
doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 10.0, false, 10.0, "Cone");
|
|
std::string err;
|
|
TopoDS_Wire w = doc.build_helix_wire(doc.features[0], err);
|
|
REQUIRE_FALSE(w.IsNull());
|
|
REQUIRE(err.empty());
|
|
|
|
// Top radius = 5 + 10*tan(10) ≈ 5 + 1.7633 = 6.7633
|
|
const double expected_top = 5.0 + 10.0 * std::tan(10.0 * M_PI / 180.0);
|
|
|
|
// Sample at z = height: use same sampling approach
|
|
double best_dz = 1e9;
|
|
gp_Pnt best(0,0,0);
|
|
for (TopExp_Explorer ex(w, TopAbs_EDGE); ex.More(); ex.Next()) {
|
|
TopoDS_Edge e = TopoDS::Edge(ex.Current());
|
|
BRepAdaptor_Curve curve(e);
|
|
double u0 = curve.FirstParameter();
|
|
double u1 = curve.LastParameter();
|
|
for (int s = 0; s <= 200; ++s) {
|
|
double u = u0 + (u1 - u0) * s / 200.0;
|
|
gp_Pnt p = curve.Value(u);
|
|
if (std::abs(p.Z() - 10.0) < best_dz) {
|
|
best_dz = std::abs(p.Z() - 10.0);
|
|
best = p;
|
|
}
|
|
}
|
|
}
|
|
double top_r = std::sqrt(best.X() * best.X() + best.Y() * best.Y());
|
|
REQUIRE_THAT(top_r, WithinRel(expected_top, 1e-2));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("helix: invalid inputs fail cleanly", "[CadDocument]")
|
|
{
|
|
SECTION("radius <= 0") {
|
|
CadDocument doc;
|
|
doc.add_helix(SketchPlane::XY(), 0.0, 2.0, 10.0, false, 0.0, "H");
|
|
std::string err;
|
|
REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull());
|
|
REQUIRE_FALSE(err.empty());
|
|
}
|
|
SECTION("pitch <= 0") {
|
|
CadDocument doc;
|
|
doc.add_helix(SketchPlane::XY(), 5.0, 0.0, 10.0, false, 0.0, "H");
|
|
std::string err;
|
|
REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull());
|
|
REQUIRE_FALSE(err.empty());
|
|
}
|
|
SECTION("height < 0") {
|
|
CadDocument doc;
|
|
doc.add_helix(SketchPlane::XY(), 5.0, 2.0, -1.0, false, 0.0, "H");
|
|
std::string err;
|
|
REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull());
|
|
REQUIRE_FALSE(err.empty());
|
|
}
|
|
SECTION("height == 0 (flat spiral) rejected") {
|
|
CadDocument doc;
|
|
doc.add_helix(SketchPlane::XY(), 5.0, 2.0, 0.0, false, 0.0, "H");
|
|
std::string err;
|
|
REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull());
|
|
REQUIRE_FALSE(err.empty());
|
|
CHECK_CONTAINS(err, "flat spiral");
|
|
}
|
|
SECTION("absurd turn count") {
|
|
CadDocument doc;
|
|
doc.add_helix(SketchPlane::XY(), 5.0, 1e-4, 2.0, false, 0.0, "H");
|
|
std::string err;
|
|
REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull());
|
|
REQUIRE_FALSE(err.empty());
|
|
}
|
|
SECTION("taper drives radius negative") {
|
|
CadDocument doc;
|
|
doc.add_helix(SketchPlane::XY(), 1.0, 2.0, 10.0, false, -10.0, "H");
|
|
std::string err;
|
|
REQUIRE(doc.build_helix_wire(doc.features[0], err).IsNull());
|
|
REQUIRE_FALSE(err.empty());
|
|
CHECK_CONTAINS(err, "negative");
|
|
}
|
|
}
|
|
|
|
TEST_CASE("helix as sweep path: spring integration test", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
|
|
// Build a plane at the helix start (5,0,0) whose normal IS the start tangent direction.
|
|
// The helix tangent at u=0 is (0, R, P/(2*pi)) = (0, 5, 3/(2*pi)).
|
|
const double R = 5.0, P = 3.0;
|
|
Vec3d tan_dir(0, R, P / (2.0 * M_PI));
|
|
tan_dir.normalize();
|
|
Vec3d ref = (std::abs(tan_dir.z()) < 0.9) ? Vec3d(0, 0, 1) : Vec3d(1, 0, 0);
|
|
Vec3d x_axis = ref.cross(tan_dir);
|
|
if (x_axis.squaredNorm() < 1e-12) x_axis = Vec3d(1, 0, 0);
|
|
x_axis.normalize();
|
|
Vec3d y_axis = tan_dir.cross(x_axis).normalized();
|
|
SketchPlane profile_plane;
|
|
profile_plane.origin = Vec3d(R, 0, 0);
|
|
profile_plane.normal = tan_dir;
|
|
profile_plane.x_axis = x_axis;
|
|
profile_plane.y_axis = y_axis;
|
|
|
|
// Profile: small circle r=1.5 centered at 2D (0,0) = world (5,0,0) = helix start
|
|
SketchEntity prof;
|
|
prof.type = SketchEntity::Type::Circle;
|
|
prof.center = Vec2d(0, 0);
|
|
prof.radius = 1.5;
|
|
int prof_idx = doc.add_sketch_entities({prof}, profile_plane, "CircleProfile");
|
|
|
|
// Helix path: r=5, pitch=3, height=15 (5 turns) about Z axis from origin
|
|
int helix_idx = doc.add_helix(SketchPlane::XY(), R, P, 15.0, false, 0.0, "HelixPath");
|
|
|
|
int sweep_idx = doc.add_sweep(prof_idx, helix_idx, BooleanMode::New, "Spring");
|
|
REQUIRE(sweep_idx >= 0);
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
|
|
const double v = double(doc.display_mesh.volume());
|
|
REQUIRE(v > 0.0);
|
|
|
|
const double one_turn = std::sqrt(std::pow(2.0 * M_PI * R, 2) + std::pow(P, 2));
|
|
const double total_len = 5.0 * one_turn;
|
|
const double prof_area = M_PI * 1.5 * 1.5;
|
|
const double expected_v = prof_area * total_len;
|
|
REQUIRE_THAT(v, WithinRel(expected_v, 0.1));
|
|
}
|
|
|
|
TEST_CASE("helix serialization round-trip with distinctive values", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
doc.add_helix(SketchPlane::XZ(), 7.5, 3.25, 22.0, true, 5.0, "Helix_RT");
|
|
doc.features.back().helix_radius = 7.5;
|
|
doc.features.back().helix_pitch = 3.25;
|
|
doc.features.back().helix_height = 22.0;
|
|
doc.features.back().helix_left_handed = true;
|
|
doc.features.back().helix_taper_deg = 5.0;
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
// Deserialize into a feature list directly — recompute fails because a lone
|
|
// helix doesn't produce a solid, but the serialized field values must survive.
|
|
std::vector<CadFeature> features2;
|
|
{
|
|
std::istringstream iss(blob);
|
|
cereal::BinaryInputArchive ar(iss);
|
|
uint32_t v;
|
|
ar(v);
|
|
ar(features2);
|
|
}
|
|
REQUIRE(features2.size() == 1);
|
|
|
|
const auto& f = features2[0];
|
|
REQUIRE(f.type == CadFeatureType::Helix);
|
|
REQUIRE(f.name == "Helix_RT");
|
|
REQUIRE_THAT(f.helix_radius, WithinAbs(7.5, 1e-9));
|
|
REQUIRE_THAT(f.helix_pitch, WithinAbs(3.25, 1e-9));
|
|
REQUIRE_THAT(f.helix_height, WithinAbs(22.0, 1e-9));
|
|
REQUIRE(f.helix_left_handed == true);
|
|
REQUIRE_THAT(f.helix_taper_deg, WithinAbs(5.0, 1e-9));
|
|
}
|
|
|
|
TEST_CASE("helix with sweep path from a non-sketch/non-helix feature errors", "[CadDocument]")
|
|
{
|
|
// An Extrude feature used as sweep path must fail cleanly.
|
|
CadDocument doc;
|
|
|
|
// Profile: circle
|
|
SketchEntity prof;
|
|
prof.type = SketchEntity::Type::Circle;
|
|
prof.center = Vec2d(0, 0);
|
|
prof.radius = 2.0;
|
|
int prof_idx = doc.add_sketch_entities({prof}, SketchPlane::XY(), "Profile");
|
|
|
|
// Path: an Extrude feature (not a Sketch or Helix)
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.name = "NotAValidPath";
|
|
ex.sketch_ref = -1;
|
|
doc.features.push_back(ex);
|
|
int path_idx = int(doc.features.size()) - 1;
|
|
|
|
int sw = doc.add_sweep(prof_idx, path_idx, BooleanMode::New, "BadSweep");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
}
|
|
|
|
TEST_CASE("transform translate shifts body bbox by the given vector", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box");
|
|
REQUIRE(doc.recompute());
|
|
|
|
Bnd_Box bb0;
|
|
BRepBndLib::Add(doc.bodies[0].shape, bb0);
|
|
double xmin0, ymin0, zmin0, xmax0, ymax0, zmax0;
|
|
bb0.Get(xmin0, ymin0, zmin0, xmax0, ymax0, zmax0);
|
|
double vol0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
|
|
doc.checkpoint();
|
|
doc.add_transform(0, Vec3d(5, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0, false, "Move");
|
|
REQUIRE(doc.recompute());
|
|
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
double vol1 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
REQUIRE_THAT(vol1, WithinRel(vol0, 1e-6));
|
|
|
|
Bnd_Box bb1;
|
|
BRepBndLib::Add(doc.bodies[0].shape, bb1);
|
|
double xmin1, ymin1, zmin1, xmax1, ymax1, zmax1;
|
|
bb1.Get(xmin1, ymin1, zmin1, xmax1, ymax1, zmax1);
|
|
REQUIRE_THAT(xmin1, WithinAbs(xmin0 + 5, 1e-6));
|
|
REQUIRE_THAT(xmax1, WithinAbs(xmax0 + 5, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("transform rotate 90 about Z swaps XY bbox extents", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box");
|
|
REQUIRE(doc.recompute());
|
|
|
|
Bnd_Box bb0;
|
|
BRepBndLib::Add(doc.bodies[0].shape, bb0);
|
|
double xmin0, ymin0, zmin0, xmax0, ymax0, zmax0;
|
|
bb0.Get(xmin0, ymin0, zmin0, xmax0, ymax0, zmax0);
|
|
double vol0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
double dx0 = xmax0 - xmin0;
|
|
double dy0 = ymax0 - ymin0;
|
|
|
|
doc.checkpoint();
|
|
doc.add_transform(0, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 90, false, "Rot90");
|
|
REQUIRE(doc.recompute());
|
|
|
|
double vol1 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
REQUIRE_THAT(vol1, WithinRel(vol0, 1e-6));
|
|
|
|
Bnd_Box bb1;
|
|
BRepBndLib::Add(doc.bodies[0].shape, bb1);
|
|
double xmin1, ymin1, zmin1, xmax1, ymax1, zmax1;
|
|
bb1.Get(xmin1, ymin1, zmin1, xmax1, ymax1, zmax1);
|
|
double dx1 = xmax1 - xmin1;
|
|
double dy1 = ymax1 - ymin1;
|
|
REQUIRE_THAT(dx1, WithinAbs(dy0, 1e-6));
|
|
REQUIRE_THAT(dy1, WithinAbs(dx0, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("transform copy keeps original and appends transformed body", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box");
|
|
REQUIRE(doc.recompute());
|
|
|
|
Bnd_Box bb0;
|
|
BRepBndLib::Add(doc.bodies[0].shape, bb0);
|
|
double xmin0, ymin0, zmin0, xmax0, ymax0, zmax0;
|
|
bb0.Get(xmin0, ymin0, zmin0, xmax0, ymax0, zmax0);
|
|
|
|
doc.checkpoint();
|
|
doc.add_transform(0, Vec3d(5, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0, true, "Copy");
|
|
REQUIRE(doc.recompute());
|
|
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
Bnd_Box bb_orig;
|
|
BRepBndLib::Add(doc.bodies[0].shape, bb_orig);
|
|
double xo_min, yo_min, zo_min, xo_max, yo_max, zo_max;
|
|
bb_orig.Get(xo_min, yo_min, zo_min, xo_max, yo_max, zo_max);
|
|
REQUIRE_THAT(xo_min, WithinAbs(xmin0, 1e-6));
|
|
REQUIRE_THAT(xo_max, WithinAbs(xmax0, 1e-6));
|
|
|
|
Bnd_Box bb_copy;
|
|
BRepBndLib::Add(doc.bodies[1].shape, bb_copy);
|
|
double xc_min, yc_min, zc_min, xc_max, yc_max, zc_max;
|
|
bb_copy.Get(xc_min, yc_min, zc_min, xc_max, yc_max, zc_max);
|
|
REQUIRE_THAT(xc_min, WithinAbs(xmin0 + 5, 1e-6));
|
|
REQUIRE_THAT(xc_max, WithinAbs(xmax0 + 5, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("transform degenerate axis errors", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box");
|
|
REQUIRE(doc.recompute());
|
|
|
|
doc.checkpoint();
|
|
doc.add_transform(0, Vec3d(0, 0, 0), Vec3d(0, 0, 0), Vec3d(0, 0, 0), 45, false, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE(doc.error.find("axis") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("transform moved body participates in later boolean at its new position", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Two boxes that do NOT overlap
|
|
int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 5, "S0");
|
|
doc.add_extrude(sk0, 5.0, false, BooleanMode::New, "Box0");
|
|
|
|
int sk1 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 5, "S1");
|
|
doc.add_extrude(sk1, 5.0, false, BooleanMode::New, "Box1");
|
|
doc.features.back().target_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
|
|
double v0 = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
double v1 = double(SketchEngine::tessellate(doc.bodies[1].shape).volume());
|
|
double sum = v0 + v1;
|
|
|
|
// Move body 1 so it overlaps body 0
|
|
doc.checkpoint();
|
|
doc.add_transform(1, Vec3d(5, 5, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0, false, "Move1");
|
|
REQUIRE(doc.recompute());
|
|
|
|
doc.add_boolean(BooleanMode::Add, 0, 1, false, 0.0, -1, -1, "Fuse");
|
|
REQUIRE(doc.recompute());
|
|
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
double vf = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
// Partial overlap: strictly more than one box (the move DID take effect) and strictly
|
|
// less than both (they still intersect). Without a real B-rep transform the two boxes
|
|
// stay coincident and vf would equal v0 -- that is what `vf > v0` catches.
|
|
REQUIRE(vf > v0 * 1.05);
|
|
REQUIRE(vf < sum);
|
|
}
|
|
|
|
TEST_CASE("transform round-trip preserves all xf_* fields", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "S");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Box");
|
|
doc.add_transform(0, Vec3d(3, 4, 5), Vec3d(0, 1, 0), Vec3d(1, 2, 3), 37, true, "RoundTrip");
|
|
REQUIRE(doc.recompute());
|
|
int nb = int(doc.bodies.size());
|
|
REQUIRE(nb >= 1);
|
|
|
|
// Capture bboxes before serialization
|
|
std::vector<std::pair<Vec3d, Vec3d>> bboxes_before;
|
|
for (int i = 0; i < nb; ++i) {
|
|
Bnd_Box bb;
|
|
BRepBndLib::Add(doc.bodies[i].shape, bb);
|
|
double x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
bboxes_before.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)});
|
|
}
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
REQUIRE(doc2.bodies.size() == size_t(nb));
|
|
|
|
// Check the Transform feature fields
|
|
bool found = false;
|
|
for (const auto& f : doc2.features) {
|
|
if (f.type != CadFeatureType::Transform) continue;
|
|
REQUIRE_THAT(f.xf_translate.x(), WithinAbs(3, 1e-9));
|
|
REQUIRE_THAT(f.xf_translate.y(), WithinAbs(4, 1e-9));
|
|
REQUIRE_THAT(f.xf_translate.z(), WithinAbs(5, 1e-9));
|
|
REQUIRE_THAT(f.xf_axis.x(), WithinAbs(0, 1e-9));
|
|
REQUIRE_THAT(f.xf_axis.y(), WithinAbs(1, 1e-9));
|
|
REQUIRE_THAT(f.xf_axis.z(), WithinAbs(0, 1e-9));
|
|
REQUIRE_THAT(f.xf_pivot.x(), WithinAbs(1, 1e-9));
|
|
REQUIRE_THAT(f.xf_pivot.y(), WithinAbs(2, 1e-9));
|
|
REQUIRE_THAT(f.xf_pivot.z(), WithinAbs(3, 1e-9));
|
|
REQUIRE_THAT(f.xf_angle_deg, WithinAbs(37, 1e-9));
|
|
REQUIRE(f.xf_copy == true);
|
|
found = true;
|
|
break;
|
|
}
|
|
REQUIRE(found);
|
|
|
|
// Verify bboxes equal
|
|
for (int i = 0; i < nb; ++i) {
|
|
Bnd_Box bb;
|
|
BRepBndLib::Add(doc2.bodies[i].shape, bb);
|
|
double x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
REQUIRE_THAT(x0, WithinAbs(bboxes_before[i].first.x(), 1e-6));
|
|
REQUIRE_THAT(y0, WithinAbs(bboxes_before[i].first.y(), 1e-6));
|
|
REQUIRE_THAT(z0, WithinAbs(bboxes_before[i].first.z(), 1e-6));
|
|
REQUIRE_THAT(x1, WithinAbs(bboxes_before[i].second.x(), 1e-6));
|
|
REQUIRE_THAT(y1, WithinAbs(bboxes_before[i].second.y(), 1e-6));
|
|
REQUIRE_THAT(z1, WithinAbs(bboxes_before[i].second.z(), 1e-6));
|
|
}
|
|
}
|
|
|
|
// --- Thicken tests ---
|
|
|
|
TEST_CASE("thicken a planar face to a plate", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch");
|
|
REQUIRE(sk >= 0);
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude1");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
|
|
doc.add_thicken(0, top_face, 3.0, false, "Plate");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
double v = double(SketchEngine::tessellate(doc.bodies[1].shape).volume());
|
|
REQUIRE_THAT(v, WithinRel(20.0 * 20.0 * 3.0, 0.01));
|
|
}
|
|
|
|
TEST_CASE("thicken flip offsets against face normal", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude1");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
|
|
// non-flipped: plate grows above the box (z > 10)
|
|
CadDocument doc2;
|
|
int sk2 = doc2.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch2");
|
|
doc2.add_extrude(sk2, 10.0, false, BooleanMode::New, "Extrude2");
|
|
REQUIRE(doc2.recompute());
|
|
int nf2 = GeometryEngine::face_count(doc2.bodies[0].shape);
|
|
int tf2 = -1;
|
|
for (int i = 0; i < nf2; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc2.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { tf2 = i; break; }
|
|
}
|
|
REQUIRE(tf2 >= 0);
|
|
doc2.add_thicken(0, tf2, 3.0, false, "PlateFwd");
|
|
REQUIRE(doc2.recompute());
|
|
Bnd_Box bb_fwd; BRepBndLib::Add(doc2.bodies[1].shape, bb_fwd);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb_fwd.Get(x0, y0, z0, x1, y1, z1);
|
|
|
|
// flipped: plate grows below the face plane (z < 10)
|
|
CadDocument doc3;
|
|
int sk3 = doc3.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxSketch3");
|
|
doc3.add_extrude(sk3, 10.0, false, BooleanMode::New, "Extrude3");
|
|
REQUIRE(doc3.recompute());
|
|
int nf3 = GeometryEngine::face_count(doc3.bodies[0].shape);
|
|
int tf3 = -1;
|
|
for (int i = 0; i < nf3; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc3.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { tf3 = i; break; }
|
|
}
|
|
REQUIRE(tf3 >= 0);
|
|
doc3.add_thicken(0, tf3, 3.0, true, "PlateRev");
|
|
REQUIRE(doc3.recompute());
|
|
Bnd_Box bb_rev; BRepBndLib::Add(doc3.bodies[1].shape, bb_rev);
|
|
Standard_Real rx0, ry0, rz0, rx1, ry1, rz1;
|
|
bb_rev.Get(rx0, ry0, rz0, rx1, ry1, rz1);
|
|
|
|
// forward plate bbox z > 10 (source face at z=10, +3 offset = z in (10,13))
|
|
REQUIRE(z0 >= 9.9);
|
|
// reverse plate bbox z < 10 (source face at z=10, -3 offset = z in (7,10))
|
|
REQUIRE(rz1 <= 10.1);
|
|
}
|
|
|
|
TEST_CASE("thicken bad face index returns error", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
|
|
REQUIRE(doc.recompute());
|
|
|
|
doc.add_thicken(0, 9999, 3.0, false, "Bad");
|
|
bool ok = doc.recompute();
|
|
REQUIRE_FALSE(ok);
|
|
REQUIRE(doc.error.find("face") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("thicken zero thickness returns error", "[CadDocument]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
|
|
doc.add_thicken(0, top_face, 0.0, false, "Zero");
|
|
bool ok = doc.recompute();
|
|
REQUIRE_FALSE(ok);
|
|
REQUIRE(doc.error.find("thickness") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("thickened plate fuses with source", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
double v_box = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
|
|
doc.add_thicken(0, top_face, 3.0, false, "Plate");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
doc.add_boolean(BooleanMode::Add, 0, 1, false, 0.0, -1, -1, "Fuse");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
double v_fused = double(SketchEngine::tessellate(doc.bodies[0].shape).volume());
|
|
REQUIRE(v_fused > v_box);
|
|
}
|
|
|
|
TEST_CASE("thicken round-trip serialization", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
|
|
REQUIRE(doc.recompute());
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
doc.add_thicken(0, top_face, 3.0, false, "Plate");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
// Remember field values
|
|
int tf = doc.features.back().thicken_face;
|
|
double tt = doc.features.back().thicken_thickness;
|
|
bool tb = doc.features.back().thicken_flip;
|
|
size_t nb = doc.bodies.size();
|
|
|
|
std::vector<std::pair<Vec3d, Vec3d>> bboxes;
|
|
for (const auto& b : doc.bodies) {
|
|
Bnd_Box bb; BRepBndLib::Add(b.shape, bb);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
bboxes.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)});
|
|
}
|
|
|
|
std::string blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
REQUIRE(doc2.features.size() == doc.features.size());
|
|
REQUIRE(doc2.bodies.size() == nb);
|
|
|
|
const CadFeature& f2 = doc2.features.back();
|
|
REQUIRE(f2.thicken_face == tf);
|
|
REQUIRE_THAT(f2.thicken_thickness, WithinAbs(tt, 1e-9));
|
|
REQUIRE(f2.thicken_flip == tb);
|
|
|
|
for (size_t i = 0; i < nb; ++i) {
|
|
Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6));
|
|
REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6));
|
|
REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6));
|
|
REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6));
|
|
REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6));
|
|
REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6));
|
|
}
|
|
}
|
|
|
|
// --- Project feature tests ---
|
|
|
|
TEST_CASE("project a box top face to 4 lines, extrudable", "[CadDocument][project]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
|
|
int proj = doc.add_project_edges(0, {}, top_face, SketchPlane::XY(), "ProjTop");
|
|
REQUIRE(proj >= 0);
|
|
doc.add_extrude(proj, 5.0, false, BooleanMode::New, "FromProj");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
const auto& pf = doc.features[proj];
|
|
REQUIRE(pf.entities.size() == 4);
|
|
for (const auto& e : pf.entities)
|
|
REQUIRE(e.type == SketchEntity::Type::Line);
|
|
|
|
REQUIRE(doc.bodies.size() >= 2);
|
|
double v = double(SketchEngine::tessellate(doc.bodies.back().shape).volume());
|
|
REQUIRE_THAT(v, WithinRel(20.0 * 20.0 * 5.0, 1e-3));
|
|
}
|
|
|
|
TEST_CASE("project a cylinder top edge to 1 circle, extrudable", "[CadDocument][project]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
SketchEntity c;
|
|
c.type = SketchEntity::Type::Circle;
|
|
c.center = Vec2d(0, 0);
|
|
c.radius = 6.0;
|
|
int sk = doc.add_sketch_entities({c}, SketchPlane::XY(), "Circ");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
int n_edges = GeometryEngine::edge_count(doc.bodies[0].shape);
|
|
int top_edge = -1;
|
|
for (int i = 0; i < n_edges; ++i) {
|
|
TopoDS_Edge e = GeometryEngine::edge_by_index(doc.bodies[0].shape, i);
|
|
auto pts = GeometryEngine::sample_edge_world(e);
|
|
if (pts.empty()) continue;
|
|
Vec3d mid = Vec3d::Zero();
|
|
for (const auto& p : pts) mid += p;
|
|
mid /= double(pts.size());
|
|
if (mid.z() > 9.0) {
|
|
BRepAdaptor_Curve ac(e);
|
|
if (ac.GetType() == GeomAbs_Circle) { top_edge = i; break; }
|
|
}
|
|
}
|
|
REQUIRE(top_edge >= 0);
|
|
|
|
int proj = doc.add_project_edges(0, {top_edge}, -1, SketchPlane::XY(), "ProjCirc");
|
|
REQUIRE(proj >= 0);
|
|
doc.add_extrude(proj, 4.0, false, BooleanMode::New, "FromCirc");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
const auto& pf = doc.features[proj];
|
|
REQUIRE(pf.entities.size() == 1);
|
|
REQUIRE(pf.entities[0].type == SketchEntity::Type::Circle);
|
|
REQUIRE_THAT(pf.entities[0].radius, WithinRel(6.0, 1e-3));
|
|
|
|
REQUIRE(doc.bodies.size() >= 2);
|
|
double v = double(SketchEngine::tessellate(doc.bodies.back().shape).volume());
|
|
REQUIRE_THAT(v, WithinRel(M_PI * 36.0 * 4.0, 1e-2));
|
|
}
|
|
|
|
TEST_CASE("project with no face and no edge selection projects every edge", "[CadDocument][project]")
|
|
{
|
|
// This is the state the Project card opens in — its label reads "(all edges)". Before the
|
|
// all-edges branch existed it threw "no edges or face selected", so the card's default
|
|
// could never be confirmed.
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
|
|
REQUIRE(doc.recompute());
|
|
|
|
int proj = doc.add_project_edges(0, {}, -1, SketchPlane::XY(), "ProjAll");
|
|
REQUIRE(proj >= 0);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// A box has 12 edges; the 4 running along Z collapse to points on XY and are dropped,
|
|
// leaving the 4 bottom and 4 top edges.
|
|
const auto& pf = doc.features[proj];
|
|
REQUIRE(pf.entities.size() == 8);
|
|
for (const auto& e : pf.entities) {
|
|
REQUIRE(e.type == SketchEntity::Type::Line);
|
|
REQUIRE((e.p1 - e.p0).norm() > 1e-6);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("project bad face id returns error", "[CadDocument][project]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
|
|
REQUIRE(doc.recompute());
|
|
|
|
doc.add_project_edges(0, {}, 9999, SketchPlane::XY(), "Bad");
|
|
bool ok = doc.recompute();
|
|
REQUIRE_FALSE(ok);
|
|
bool has_project = doc.error.find("project") != std::string::npos
|
|
|| doc.error.find("face") != std::string::npos;
|
|
REQUIRE(has_project);
|
|
}
|
|
|
|
TEST_CASE("project round-trip serialization", "[CadDocument][project]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Ext");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
|
|
int n_faces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int top_face = -1;
|
|
for (int i = 0; i < n_faces; ++i) {
|
|
TopoDS_Face fc = GeometryEngine::face_by_index(doc.bodies[0].shape, i);
|
|
Vec3d n = GeometryEngine::face_normal_world(fc);
|
|
if (n.z() > 0.9) { top_face = i; break; }
|
|
}
|
|
REQUIRE(top_face >= 0);
|
|
|
|
doc.add_project_edges(0, {}, top_face, SketchPlane::XY(), "ProjTop");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int saved_src = doc.features.back().project_source_body;
|
|
int saved_face = doc.features.back().project_face;
|
|
auto saved_edges = doc.features.back().project_edges;
|
|
size_t saved_nb = doc.bodies.size();
|
|
|
|
std::vector<std::pair<Vec3d, Vec3d>> bboxes;
|
|
for (const auto& b : doc.bodies) {
|
|
Bnd_Box bb; BRepBndLib::Add(b.shape, bb);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
bboxes.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)});
|
|
}
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
REQUIRE(doc2.bodies.size() == saved_nb);
|
|
REQUIRE(doc2.features.size() == doc.features.size());
|
|
|
|
const auto& f2 = doc2.features.back();
|
|
REQUIRE(f2.project_source_body == saved_src);
|
|
REQUIRE(f2.project_face == saved_face);
|
|
REQUIRE(f2.project_edges == saved_edges);
|
|
|
|
for (size_t i = 0; i < saved_nb; ++i) {
|
|
Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6));
|
|
REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6));
|
|
REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6));
|
|
REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6));
|
|
REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6));
|
|
REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6));
|
|
}
|
|
}
|
|
|
|
// --- Golden recipe fixture (v1 format tripwire) ---
|
|
|
|
static CadDocument make_golden_doc_v1()
|
|
{
|
|
CadDocument doc;
|
|
|
|
// ---- Body 0: base box with distinctive taper ----
|
|
int sk0 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
30, 20, 15, "Sketch_Base");
|
|
doc.add_extrude(sk0, 15.0, false, BooleanMode::New, "Extrude_Base");
|
|
int ex0 = int(doc.features.size()) - 1;
|
|
doc.features[ex0].taper_deg = 8.5;
|
|
doc.features[ex0].extrude_end = ExtrudeEnd::Blind;
|
|
|
|
// Dress-up: fillet lateral faces, chamfer top face — distinct non-default sizes
|
|
doc.add_fillet(3.5, FaceGroup::Lateral, "Fillet_Lat35");
|
|
doc.add_chamfer(2.0, FaceGroup::Top, "Chamfer_Top2");
|
|
|
|
// Hole: offset position, non-through
|
|
doc.add_hole(7.5, 11.0, false, 4.0, 3.0, SketchPlane::XY(), "Hole_Off75");
|
|
|
|
// Draft: angle 7.25 deg on face 3
|
|
doc.add_draft(7.25, 3, 0, "Draft_F3");
|
|
|
|
// Shell: thickness 1.375 mm, open face 1
|
|
doc.add_shell(1.375, 1, 0, "Shell_T1375");
|
|
|
|
// Thread: internal, radius 4, pitch 2.5, height 15, depth 1.25
|
|
doc.add_thread(4.0, 2.5, 15.0, 1.25, true, 0.0, 0.0, SketchPlane::XY(), "Thread_Int");
|
|
|
|
// Cut: plane XY, offset 10, flip, keep upper only
|
|
doc.add_cut(SketchPlane::XY(), 10.0, true, true, false, 0, "Cut_Flip");
|
|
|
|
// Pattern: linear 5 copies, spacing 13.5 mm along plane X
|
|
doc.add_pattern(false, 5, 13.5, 0, 360.0, 0, "Pattern_Lin5");
|
|
|
|
// ---- Datum plane ----
|
|
doc.add_plane(0, 25.0, 0.0, 0, "Plane_Datum25");
|
|
|
|
// ---- Revolve: self-contained body (sketch + revolve) ----
|
|
{
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.name = "Sketch_Rev";
|
|
sk.plane = SketchPlane::XY();
|
|
sk.entities = {{SketchEntity::Type::Circle, Vec2d(12,0), Vec2d(12,0), Vec2d(12,0), 4.0}};
|
|
doc.features.push_back(sk);
|
|
}
|
|
int rev_sk = int(doc.features.size()) - 1;
|
|
doc.add_revolve(rev_sk, 217.0, 1, false, BooleanMode::New, "Revolve_Y217");
|
|
|
|
// ---- Sweep: self-contained body (profile + path sketches + sweep) ----
|
|
{
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.name = "Sketch_SwProf";
|
|
sk.plane = SketchPlane::XY();
|
|
sk.entities = {{SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 3.0}};
|
|
doc.features.push_back(sk);
|
|
}
|
|
int sw_prof = int(doc.features.size()) - 1;
|
|
{
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.name = "Sketch_SwPath";
|
|
sk.plane = SketchPlane::XZ();
|
|
sk.entities = {{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(0,35)}};
|
|
doc.features.push_back(sk);
|
|
}
|
|
int sw_path = int(doc.features.size()) - 1;
|
|
doc.add_sweep(sw_prof, sw_path, BooleanMode::New, "Sweep_Z35");
|
|
|
|
// ---- Loft: self-contained body (two profiles + loft) ----
|
|
{
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.name = "Sketch_LoftBot";
|
|
sk.plane = SketchPlane::XY();
|
|
sk.profile.points = {{-7,-7},{7,-7},{7,7},{-7,7}};
|
|
sk.profile.closed = true;
|
|
doc.features.push_back(sk);
|
|
}
|
|
int loft_bot = int(doc.features.size()) - 1;
|
|
{
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.name = "Sketch_LoftTop";
|
|
sk.plane.origin = Vec3d(0, 0, 25);
|
|
sk.plane.normal = Vec3d(0, 0, 1);
|
|
sk.plane.x_axis = Vec3d(1, 0, 0);
|
|
sk.plane.y_axis = Vec3d(0, 1, 0);
|
|
sk.profile.points = {{-9,-9},{9,-9},{9,9},{-9,9}};
|
|
sk.profile.closed = true;
|
|
doc.features.push_back(sk);
|
|
}
|
|
int loft_top = int(doc.features.size()) - 1;
|
|
doc.add_loft({loft_bot, loft_top}, true, BooleanMode::New, "Loft_Ruled");
|
|
|
|
// ---- Boolean: distinctive tolerance and face-mate params ----
|
|
doc.add_boolean(BooleanMode::Cut, 0, 1, false, 0.01, 2, 3, "Boolean_Cut");
|
|
|
|
// ---- Extrude variant: symmetric + two-sided end ----
|
|
{
|
|
CadFeature sk;
|
|
sk.type = CadFeatureType::Sketch;
|
|
sk.name = "Sketch_Ex2";
|
|
sk.plane = SketchPlane::XZ();
|
|
sk.entities = {{SketchEntity::Type::Circle, Vec2d(0,0), Vec2d(0,0), Vec2d(0,0), 6.0}};
|
|
doc.features.push_back(sk);
|
|
}
|
|
int sk_ex2 = int(doc.features.size()) - 1;
|
|
{
|
|
CadFeature ex;
|
|
ex.type = CadFeatureType::Extrude;
|
|
ex.name = "Extrude_Sym";
|
|
ex.sketch_ref = sk_ex2;
|
|
ex.distance = 25.0;
|
|
ex.symmetric = true;
|
|
ex.mode = BooleanMode::New;
|
|
ex.extrude_end = ExtrudeEnd::Symmetric;
|
|
ex.distance2 = 12.5;
|
|
doc.features.push_back(ex);
|
|
}
|
|
|
|
// ---- Mirror: XZ plane, New mode, keep_original=false (distinctive non-defaults) ----
|
|
doc.add_mirror(SketchPlane::XZ(), 0, BooleanMode::New, "Mirror_XZ");
|
|
doc.features.back().mirror_keep_original = false;
|
|
|
|
// ---- Datum Axis: two-points with distinctive non-default coordinates ----
|
|
{
|
|
int ax = doc.add_axis(AxisType::TwoPoints, "Axis_TP");
|
|
doc.features[ax].axis_p1 = Vec3d(10, 20, 30);
|
|
doc.features[ax].axis_p2 = Vec3d(13, 24, 34);
|
|
doc.features[ax].axis_body = 1;
|
|
doc.features[ax].axis_face = 3;
|
|
doc.features[ax].axis_edge = 2;
|
|
doc.features[ax].axis_plane_a = 4;
|
|
doc.features[ax].axis_plane_b = 5;
|
|
}
|
|
|
|
// ---- Datum CoordSys: PointWorld with distinctive origin ----
|
|
{
|
|
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(7, 8, 9), "CS_PtWorld");
|
|
doc.features[cs].coordsys_body = 2;
|
|
doc.features[cs].coordsys_face = 1;
|
|
doc.features[cs].coordsys_edge = 0;
|
|
doc.features[cs].coordsys_x_hint = Vec3d(0.5, 0.8, 0.3);
|
|
}
|
|
|
|
// ---- Helix: conical left-handed with distinctive non-default values ----
|
|
{
|
|
int hx = doc.add_helix(SketchPlane::XZ(), 11.5, 4.25, 18.0, true, 3.0, "Helix_CLH");
|
|
(void)hx;
|
|
}
|
|
|
|
// ---- Transform: rigid move/rotate with distinctive non-default values ----
|
|
doc.add_transform(0, Vec3d(3.5, 4.5, 5.5), Vec3d(0.0, 1.0, 0.0), Vec3d(1.5, 2.5, 3.5),
|
|
37.0, true, "GoldenTransform");
|
|
|
|
doc.add_thicken(0, 0, 1.75, true, "GoldenThicken");
|
|
|
|
doc.add_split_by_face(0, 0, 2, true, false, "GoldenSplit");
|
|
|
|
doc.add_project_edges(0, {}, 0, SketchPlane::XY(), "GoldenProject");
|
|
|
|
// Construction-flag on-disk lock: a real edge + a construction edge with distinctive
|
|
// literals. Regen-golden does not recompute, so this only exercises serialization.
|
|
{
|
|
std::vector<SketchEntity> ge;
|
|
SketchEntity real0; real0.type = SketchEntity::Type::Line;
|
|
real0.p0 = Vec2d(-12.0, -12.0); real0.p1 = Vec2d(12.0, -12.0);
|
|
SketchEntity ctor; ctor.type = SketchEntity::Type::Line;
|
|
ctor.p0 = Vec2d(-40.0, 9.0); ctor.p1 = Vec2d(40.0, 9.0);
|
|
ctor.construction = true;
|
|
ge.push_back(real0);
|
|
ge.push_back(ctor);
|
|
doc.add_sketch_entities(ge, SketchPlane::XY(), "Sketch_Ctor");
|
|
}
|
|
|
|
// ---- Mate connectors: two CoordSys features with distinctive non-default values ----
|
|
int cs_idx_a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(11, 12, 13), "CS_MateA");
|
|
doc.features[cs_idx_a].coordsys_body = 0;
|
|
doc.features[cs_idx_a].coordsys_x_hint = Vec3d(0.1, 0.2, 0.9);
|
|
int cs_idx_b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(14, 15, 16), "CS_MateB");
|
|
doc.features[cs_idx_b].coordsys_body = 1;
|
|
doc.features[cs_idx_b].coordsys_x_hint = Vec3d(0.6, 0.7, 0.3);
|
|
|
|
// ---- Mate: Fastened with all six fields carrying distinctive non-defaults ----
|
|
doc.add_mate(1, cs_idx_a, cs_idx_b, 7.25, 33.0, true, "GoldenMate");
|
|
|
|
return doc;
|
|
}
|
|
|
|
TEST_CASE("regenerate golden recipe fixture", "[.regen]")
|
|
{
|
|
CadDocument doc = make_golden_doc_v1();
|
|
// ponytail: serialize_recipe() only needs features, recompute is unnecessary
|
|
// for a fixture that exercises the serialization format.
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v3.bin";
|
|
std::ofstream ofs(path, std::ios::binary);
|
|
REQUIRE(ofs.is_open());
|
|
ofs.write(blob.data(), static_cast<std::streamsize>(blob.size()));
|
|
ofs.close();
|
|
SUCCEED("Fixture written to " << path);
|
|
}
|
|
|
|
TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
// Read the golden blob from disk
|
|
std::string path = std::string(TEST_DATA_DIR) + "/cad_recipe_v3.bin";
|
|
std::ifstream ifs(path, std::ios::binary);
|
|
REQUIRE(ifs.is_open());
|
|
std::string blob((std::istreambuf_iterator<char>(ifs)),
|
|
std::istreambuf_iterator<char>());
|
|
ifs.close();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
// --- Layer 1: deserialize features WITHOUT recomputing, assert field values ---
|
|
std::vector<CadFeature> features;
|
|
{
|
|
std::istringstream iss(blob);
|
|
cereal::BinaryInputArchive ar(iss);
|
|
uint32_t v;
|
|
ar(v);
|
|
REQUIRE(v <= CadDocument::SNAPORCA_CAD_RECIPE_VERSION);
|
|
ar(features);
|
|
}
|
|
|
|
CadDocument expected = make_golden_doc_v1();
|
|
// Scoped tightly: this advice is ONLY valid for a count mismatch. It must not be in
|
|
// scope for the field-value assertions below, where "regenerate the fixture" is the
|
|
// one thing you must never do -- regenerating after a reorder bakes the corrupted
|
|
// layout in as the new golden and permanently disarms this test.
|
|
{
|
|
INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?");
|
|
INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test.");
|
|
REQUIRE(features.size() == expected.features.size());
|
|
}
|
|
|
|
// A failure BELOW this point means the on-disk serialization format changed: some field
|
|
// in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be
|
|
// APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass --
|
|
// fix the field order instead. See scripts/kernel-test.sh and the [.regen] case.
|
|
|
|
// Field-by-field assertions against expected values.
|
|
// Every field set to a distinctive non-default literal must be checked here.
|
|
// A field reorder in save()/load() that swaps fields of differing types
|
|
// will produce a wrong value at this position and FAIL the test.
|
|
|
|
for (size_t i = 0; i < features.size(); ++i) {
|
|
const auto& f = features[i];
|
|
const auto& e = expected.features[i];
|
|
|
|
INFO("Feature index " << i << " type " << int(f.type));
|
|
|
|
REQUIRE(f.type == e.type);
|
|
REQUIRE(f.name == e.name);
|
|
REQUIRE(f.enabled == e.enabled);
|
|
|
|
// Sketch params (all Sketch types)
|
|
if (f.type == CadFeatureType::Sketch) {
|
|
REQUIRE(f.shape == e.shape);
|
|
if (e.name == "Sketch_Base") {
|
|
REQUIRE(f.width == 30);
|
|
REQUIRE(f.height == 20);
|
|
REQUIRE(f.radius == 15);
|
|
}
|
|
if (e.name == "Sketch_Rev" || e.name == "Sketch_SwProf" || e.name == "Sketch_Ex2" || e.name == "Sketch_SwPath") {
|
|
REQUIRE(f.entities.size() == e.entities.size());
|
|
if (!f.entities.empty()) {
|
|
REQUIRE(f.entities[0].type == e.entities[0].type);
|
|
REQUIRE_THAT(f.entities[0].p0.x(), WithinAbs(e.entities[0].p0.x(), 1e-9));
|
|
REQUIRE_THAT(f.entities[0].p0.y(), WithinAbs(e.entities[0].p0.y(), 1e-9));
|
|
}
|
|
}
|
|
if (e.name == "Sketch_LoftBot" || e.name == "Sketch_LoftTop") {
|
|
REQUIRE(f.profile.points.size() == e.profile.points.size());
|
|
REQUIRE(f.profile.closed == e.profile.closed);
|
|
}
|
|
if (e.name == "Sketch_LoftTop") {
|
|
REQUIRE_THAT(f.plane.origin.z(), WithinAbs(25.0, 1e-9));
|
|
}
|
|
if (e.name == "Sketch_Ctor") {
|
|
REQUIRE(f.entities.size() == 2);
|
|
REQUIRE(f.entities[0].construction == false);
|
|
REQUIRE(f.entities[1].construction == true);
|
|
REQUIRE_THAT(f.entities[1].p0.x(), WithinAbs(-40.0, 1e-9));
|
|
}
|
|
}
|
|
|
|
// Extrude params
|
|
if (f.type == CadFeatureType::Extrude) {
|
|
REQUIRE(f.mode == e.mode);
|
|
if (e.name == "Extrude_Base") {
|
|
REQUIRE(f.distance == 15.0);
|
|
REQUIRE(f.symmetric == false);
|
|
REQUIRE(f.extrude_end == ExtrudeEnd::Blind);
|
|
REQUIRE_THAT(f.taper_deg, WithinAbs(8.5, 1e-9));
|
|
}
|
|
if (e.name == "Extrude_Sym") {
|
|
REQUIRE(f.distance == 25.0);
|
|
REQUIRE(f.symmetric == true);
|
|
REQUIRE(f.extrude_end == ExtrudeEnd::Symmetric);
|
|
REQUIRE_THAT(f.distance2, WithinAbs(12.5, 1e-9));
|
|
}
|
|
}
|
|
|
|
// Fillet
|
|
if (f.type == CadFeatureType::Fillet && e.name == "Fillet_Lat35") {
|
|
REQUIRE_THAT(f.dressup_size, WithinAbs(3.5, 1e-9));
|
|
REQUIRE(f.face_group == FaceGroup::Lateral);
|
|
}
|
|
|
|
// Chamfer
|
|
if (f.type == CadFeatureType::Chamfer && e.name == "Chamfer_Top2") {
|
|
REQUIRE_THAT(f.dressup_size, WithinAbs(2.0, 1e-9));
|
|
REQUIRE(f.face_group == FaceGroup::Top);
|
|
}
|
|
|
|
// Hole
|
|
if (f.type == CadFeatureType::Hole && e.name == "Hole_Off75") {
|
|
REQUIRE_THAT(f.hole_diameter, WithinAbs(7.5, 1e-9));
|
|
REQUIRE_THAT(f.hole_depth, WithinAbs(11.0, 1e-9));
|
|
REQUIRE(f.hole_through == false);
|
|
REQUIRE_THAT(f.hole_x, WithinAbs(4.0, 1e-9));
|
|
REQUIRE_THAT(f.hole_y, WithinAbs(3.0, 1e-9));
|
|
}
|
|
|
|
// Draft
|
|
if (f.type == CadFeatureType::Draft && e.name == "Draft_F3") {
|
|
REQUIRE(f.draft_face == 3);
|
|
REQUIRE_THAT(f.draft_angle, WithinAbs(7.25, 1e-9));
|
|
}
|
|
|
|
// Shell
|
|
if (f.type == CadFeatureType::Shell && e.name == "Shell_T1375") {
|
|
REQUIRE_THAT(f.shell_thickness, WithinAbs(1.375, 1e-9));
|
|
REQUIRE(f.shell_face == 1);
|
|
}
|
|
|
|
// Thread
|
|
if (f.type == CadFeatureType::Thread && e.name == "Thread_Int") {
|
|
REQUIRE_THAT(f.thread_radius, WithinAbs(4.0, 1e-9));
|
|
REQUIRE_THAT(f.thread_pitch, WithinAbs(2.5, 1e-9));
|
|
REQUIRE_THAT(f.thread_height, WithinAbs(15.0, 1e-9));
|
|
REQUIRE_THAT(f.thread_depth, WithinAbs(1.25, 1e-9));
|
|
REQUIRE(f.thread_internal == true);
|
|
REQUIRE_THAT(f.thread_x, WithinAbs(0.0, 1e-9));
|
|
REQUIRE_THAT(f.thread_y, WithinAbs(0.0, 1e-9));
|
|
}
|
|
|
|
// Cut
|
|
if (f.type == CadFeatureType::Cut && e.name == "Cut_Flip") {
|
|
REQUIRE_THAT(f.cut_offset, WithinAbs(10.0, 1e-9));
|
|
REQUIRE(f.cut_flip == true);
|
|
REQUIRE(f.cut_keep_upper == true);
|
|
REQUIRE(f.cut_keep_lower == false);
|
|
}
|
|
|
|
// Pattern
|
|
if (f.type == CadFeatureType::Pattern && e.name == "Pattern_Lin5") {
|
|
REQUIRE(f.pattern_circular == false);
|
|
REQUIRE(f.pattern_count == 5);
|
|
REQUIRE_THAT(f.pattern_spacing, WithinAbs(13.5, 1e-9));
|
|
REQUIRE(f.pattern_dir == 0);
|
|
}
|
|
|
|
// Datum Plane
|
|
if (f.type == CadFeatureType::Plane && e.name == "Plane_Datum25") {
|
|
REQUIRE(f.plane_base == 0);
|
|
REQUIRE_THAT(f.plane_offset, WithinAbs(25.0, 1e-9));
|
|
REQUIRE_THAT(f.plane_angle_tilt, WithinAbs(0.0, 1e-9));
|
|
REQUIRE(f.plane_axis == 0);
|
|
}
|
|
|
|
// Revolve
|
|
if (f.type == CadFeatureType::Revolve && e.name == "Revolve_Y217") {
|
|
REQUIRE_THAT(f.revolve_angle, WithinAbs(217.0, 1e-9));
|
|
REQUIRE(f.revolve_axis == 1);
|
|
}
|
|
|
|
// Sweep
|
|
if (f.type == CadFeatureType::Sweep && e.name == "Sweep_Z35") {
|
|
REQUIRE(f.sweep_path_ref == e.sweep_path_ref);
|
|
REQUIRE(f.sweep_path_ref >= 0);
|
|
}
|
|
|
|
// Loft
|
|
if (f.type == CadFeatureType::Loft && e.name == "Loft_Ruled") {
|
|
REQUIRE(f.loft_ruled == true);
|
|
REQUIRE(f.loft_profile_refs.size() == 2);
|
|
}
|
|
|
|
// Boolean
|
|
if (f.type == CadFeatureType::Boolean && e.name == "Boolean_Cut") {
|
|
REQUIRE(f.mode == BooleanMode::Cut);
|
|
REQUIRE(f.bool_tool_body == 1);
|
|
REQUIRE(f.bool_keep_tool == false);
|
|
REQUIRE_THAT(f.bool_tolerance, WithinAbs(0.01, 1e-9));
|
|
REQUIRE(f.bool_target_face == 2);
|
|
REQUIRE(f.bool_tool_face == 3);
|
|
}
|
|
|
|
// Mirror
|
|
if (f.type == CadFeatureType::Mirror && e.name == "Mirror_XZ") {
|
|
REQUIRE(f.mode == BooleanMode::New);
|
|
REQUIRE(f.mirror_keep_original == false);
|
|
REQUIRE(f.target_body == 0);
|
|
}
|
|
|
|
// Datum Axis
|
|
if (f.type == CadFeatureType::Axis && e.name == "Axis_TP") {
|
|
REQUIRE(f.axis_type == AxisType::TwoPoints);
|
|
REQUIRE_THAT(f.axis_p1.x(), WithinAbs(10.0, 1e-9));
|
|
REQUIRE_THAT(f.axis_p1.y(), WithinAbs(20.0, 1e-9));
|
|
REQUIRE_THAT(f.axis_p1.z(), WithinAbs(30.0, 1e-9));
|
|
REQUIRE_THAT(f.axis_p2.x(), WithinAbs(13.0, 1e-9));
|
|
REQUIRE_THAT(f.axis_p2.y(), WithinAbs(24.0, 1e-9));
|
|
REQUIRE_THAT(f.axis_p2.z(), WithinAbs(34.0, 1e-9));
|
|
REQUIRE(f.axis_body == 1);
|
|
REQUIRE(f.axis_face == 3);
|
|
REQUIRE(f.axis_edge == 2);
|
|
REQUIRE(f.axis_plane_a == 4);
|
|
REQUIRE(f.axis_plane_b == 5);
|
|
}
|
|
|
|
// Datum CoordSys
|
|
if (f.type == CadFeatureType::CoordSys && e.name == "CS_PtWorld") {
|
|
REQUIRE(f.coordsys_type == CoordSysType::PointWorld);
|
|
REQUIRE_THAT(f.coordsys_point.x(), WithinAbs(7.0, 1e-9));
|
|
REQUIRE_THAT(f.coordsys_point.y(), WithinAbs(8.0, 1e-9));
|
|
REQUIRE_THAT(f.coordsys_point.z(), WithinAbs(9.0, 1e-9));
|
|
REQUIRE(f.coordsys_body == 2);
|
|
REQUIRE(f.coordsys_face == 1);
|
|
REQUIRE(f.coordsys_edge == 0);
|
|
REQUIRE_THAT(f.coordsys_x_hint.x(), WithinAbs(0.5, 1e-9));
|
|
REQUIRE_THAT(f.coordsys_x_hint.y(), WithinAbs(0.8, 1e-9));
|
|
REQUIRE_THAT(f.coordsys_x_hint.z(), WithinAbs(0.3, 1e-9));
|
|
}
|
|
|
|
// Helix
|
|
if (f.type == CadFeatureType::Helix && e.name == "Helix_CLH") {
|
|
REQUIRE_THAT(f.helix_radius, WithinAbs(11.5, 1e-9));
|
|
REQUIRE_THAT(f.helix_pitch, WithinAbs(4.25, 1e-9));
|
|
REQUIRE_THAT(f.helix_height, WithinAbs(18.0, 1e-9));
|
|
REQUIRE(f.helix_left_handed == true);
|
|
REQUIRE_THAT(f.helix_taper_deg, WithinAbs(3.0, 1e-9));
|
|
}
|
|
|
|
// Transform
|
|
if (f.type == CadFeatureType::Transform && e.name == "GoldenTransform") {
|
|
REQUIRE_THAT(f.xf_translate.x(), WithinAbs(3.5, 1e-9));
|
|
REQUIRE_THAT(f.xf_translate.y(), WithinAbs(4.5, 1e-9));
|
|
REQUIRE_THAT(f.xf_translate.z(), WithinAbs(5.5, 1e-9));
|
|
REQUIRE_THAT(f.xf_axis.x(), WithinAbs(0.0, 1e-9));
|
|
REQUIRE_THAT(f.xf_axis.y(), WithinAbs(1.0, 1e-9));
|
|
REQUIRE_THAT(f.xf_axis.z(), WithinAbs(0.0, 1e-9));
|
|
REQUIRE_THAT(f.xf_pivot.x(), WithinAbs(1.5, 1e-9));
|
|
REQUIRE_THAT(f.xf_pivot.y(), WithinAbs(2.5, 1e-9));
|
|
REQUIRE_THAT(f.xf_pivot.z(), WithinAbs(3.5, 1e-9));
|
|
REQUIRE_THAT(f.xf_angle_deg, WithinAbs(37.0, 1e-9));
|
|
REQUIRE(f.xf_copy == true);
|
|
}
|
|
|
|
// Thicken
|
|
if (f.type == CadFeatureType::Thicken && e.name == "GoldenThicken") {
|
|
REQUIRE(f.thicken_face == 0);
|
|
REQUIRE_THAT(f.thicken_thickness, WithinAbs(1.75, 1e-9));
|
|
REQUIRE(f.thicken_flip == true);
|
|
}
|
|
|
|
// Cut-by-face: GoldenSplit uses cut_face_body/cut_face instead of plane
|
|
if (f.type == CadFeatureType::Cut && e.name == "GoldenSplit") {
|
|
REQUIRE(f.cut_face_body == 0);
|
|
REQUIRE(f.cut_face == 2);
|
|
REQUIRE(f.cut_keep_upper == true);
|
|
REQUIRE(f.cut_keep_lower == false);
|
|
}
|
|
|
|
// Project
|
|
if (f.type == CadFeatureType::Project && e.name == "GoldenProject") {
|
|
REQUIRE(f.project_source_body == 0);
|
|
REQUIRE(f.project_face == 0);
|
|
REQUIRE(f.project_edges == e.project_edges);
|
|
}
|
|
|
|
// Mate
|
|
if (f.type == CadFeatureType::Mate && e.name == "GoldenMate") {
|
|
REQUIRE(f.mate_kind == 1);
|
|
REQUIRE(f.mate_cs_a == e.mate_cs_a);
|
|
REQUIRE(f.mate_cs_b == e.mate_cs_b);
|
|
REQUIRE_THAT(f.mate_offset, WithinAbs(7.25, 1e-9));
|
|
REQUIRE_THAT(f.mate_angle, WithinAbs(33.0, 1e-9));
|
|
REQUIRE(f.mate_flip == true);
|
|
}
|
|
}
|
|
|
|
// --- Layer 2: geometry check (optional — only if the document recomputes) ---
|
|
// Build expected document and try to recompute it.
|
|
// ponytail: the field-value layer above is the real tripwire;
|
|
// this layer is a bonus sanity check on the full recompute path.
|
|
CadDocument exp_doc = make_golden_doc_v1();
|
|
bool exp_ok = exp_doc.recompute();
|
|
if (!exp_ok) {
|
|
INFO("Expected document from make_golden_doc_v1() could not recompute "
|
|
"(complex feature tree). Field-value checks above are sufficient.");
|
|
}
|
|
|
|
CadDocument doc;
|
|
bool ser_ok = doc.deserialize_recipe(blob);
|
|
|
|
if (exp_ok && ser_ok) {
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == exp_doc.bodies.size());
|
|
for (size_t i = 0; i < doc.bodies.size(); ++i) {
|
|
double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume());
|
|
double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume());
|
|
REQUIRE_THAT(v, WithinRel(ev, 1e-6));
|
|
}
|
|
} else {
|
|
INFO("The golden recipe fixture was loaded and field-value checks passed.");
|
|
INFO("Recompute on the deserialized or expected document failed — this is");
|
|
INFO("expected for the extended golden fixture (many feature types coexist");
|
|
INFO("purely for serialization coverage). The field-value tripwire above is");
|
|
INFO("the primary format check.");
|
|
}
|
|
}
|
|
|
|
// --- Rib tests (M5b) ---
|
|
|
|
TEST_CASE("rib adds material to a box", "[CadDocument][rib]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
|
|
// Build a box: 40x40x10 extruded on XY -> z=[0,10]
|
|
int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
40, 40, 10, "Box");
|
|
doc.add_extrude(sk_box, 10.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
double Vbox = double(doc.display_mesh.volume());
|
|
REQUIRE(Vbox > 0.0);
|
|
|
|
// Sketch a single open Line across the box footprint, on the same XY plane.
|
|
// Line from (5,20) to (35,20), centred in Y but off-centre in X.
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(5, 20), Vec2d(35, 20)},
|
|
};
|
|
int sk_rib = doc.add_sketch_entities(ents, SketchPlane::XY(), "RibLine");
|
|
REQUIRE(sk_rib >= 0);
|
|
|
|
int fi = doc.add_rib(sk_rib, 0, 3.0, 12.0, 0, "Rib");
|
|
REQUIRE(fi >= 0);
|
|
REQUIRE(doc.features[fi].type == CadFeatureType::Rib);
|
|
|
|
bool ok = doc.recompute();
|
|
REQUIRE(ok);
|
|
REQUIRE(doc.error.empty());
|
|
// The rib fused extra material -> volume must be strictly larger.
|
|
double Vrib = double(doc.display_mesh.volume());
|
|
REQUIRE(Vrib > Vbox);
|
|
|
|
// A rib with a bad sketch ref must fail cleanly, not crash.
|
|
CadDocument bad;
|
|
bad.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
bad.add_extrude(0, 10.0, false, BooleanMode::New, "E");
|
|
REQUIRE(bad.recompute());
|
|
bad.add_rib(999, 0, 3.0, 10.0, 0, "BadRib");
|
|
REQUIRE_FALSE(bad.recompute());
|
|
}
|
|
|
|
TEST_CASE("rib non-line entity rejected safely", "[CadDocument][rib]")
|
|
{
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
|
|
// Sketch with a Circle entity (not a Line)
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Circle, Vec2d(0, 0), Vec2d(0, 0), Vec2d(0, 0), 5.0},
|
|
};
|
|
int sk2 = doc.add_sketch_entities(ents, SketchPlane::XY(), "CircleSketch");
|
|
doc.add_rib(sk2, 0, 2.0, 10.0, 0, "BadRib");
|
|
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_FALSE(doc.error.empty());
|
|
REQUIRE_CONTAINS(doc.error, "rib");
|
|
}
|
|
|
|
TEST_CASE("rib round-trip serialization", "[CadDocument][rib]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 10, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(5, 20), Vec2d(35, 20)},
|
|
};
|
|
int sk2 = doc.add_sketch_entities(ents, SketchPlane::XY(), "RibLine");
|
|
doc.add_rib(sk2, 0, 3.0, 12.0, 0, "Rib");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
size_t nb = doc.bodies.size();
|
|
double Vdoc = double(doc.display_mesh.volume());
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument fresh;
|
|
REQUIRE(fresh.deserialize_recipe(blob));
|
|
REQUIRE(fresh.bodies.size() == nb);
|
|
double Vfresh = double(fresh.display_mesh.volume());
|
|
REQUIRE_THAT(Vfresh, WithinAbs(Vdoc, 1e-6));
|
|
|
|
// Find the rib feature and check its fields survived.
|
|
const CadFeature* rf = nullptr;
|
|
for (const auto& f : fresh.features) {
|
|
if (f.type == CadFeatureType::Rib) { rf = &f; break; }
|
|
}
|
|
REQUIRE(rf != nullptr);
|
|
REQUIRE_THAT(rf->rib_thickness, WithinAbs(3.0, 1e-9));
|
|
REQUIRE_THAT(rf->rib_depth, WithinAbs(12.0, 1e-9));
|
|
}
|
|
|
|
// --- Bridge tests (M3c) ---
|
|
|
|
TEST_CASE("bridge two collinear lines", "[CadDocument][bridge]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)},
|
|
{SketchEntity::Type::Line, Vec2d(20,0), Vec2d(30,0)},
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
REQUIRE(sk == 0);
|
|
|
|
int bi = doc.add_bridge(0, 0, 1, 1, 0, "Bridge");
|
|
REQUIRE(bi == 2);
|
|
|
|
const auto& se = doc.features[0].entities;
|
|
REQUIRE(se.size() == 3);
|
|
REQUIRE(se[bi].type == SketchEntity::Type::BSpline);
|
|
REQUIRE(se[bi].ctrl.size() == 4);
|
|
REQUIRE_THAT(se[bi].ctrl.front().x(), WithinAbs(10.0, 1e-6));
|
|
REQUIRE_THAT(se[bi].ctrl.front().y(), WithinAbs(0.0, 1e-6));
|
|
REQUIRE_THAT(se[bi].ctrl.back().x(), WithinAbs(20.0, 1e-6));
|
|
REQUIRE_THAT(se[bi].ctrl.back().y(), WithinAbs(0.0, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("bridge closes a C profile and extrudes", "[CadDocument][bridge]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
// Right-side of a closed square: P0(10,-10), up to P1(10,10)
|
|
std::vector<SketchEntity> ents = {
|
|
// bottom edge: (-10,-10) to (10,-10)
|
|
{SketchEntity::Type::Line, Vec2d(-10,-10), Vec2d(10,-10)},
|
|
// left edge: (10,-10) to (10,10)
|
|
{SketchEntity::Type::Line, Vec2d(10,-10), Vec2d(10,10)},
|
|
// top edge: (10,10) to (-10,10)
|
|
{SketchEntity::Type::Line, Vec2d(10,10), Vec2d(-10,10)},
|
|
};
|
|
// Missing: left edge from (-10,10) to (-10,-10). Build it as a separate line
|
|
// entity so the bridge connects two existing lines.
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "C");
|
|
REQUIRE(sk == 0);
|
|
|
|
// Add the closing line as entity 3: (-10,10) to (-10,-10)
|
|
CadFeature& f = doc.features[sk];
|
|
SketchEntity closing;
|
|
closing.type = SketchEntity::Type::Line;
|
|
closing.p0 = Vec2d(-10, 10);
|
|
closing.p1 = Vec2d(-10, -10);
|
|
// The C is entities 0,1,2 (bottom cap, right side, top cap).
|
|
// Entity 0 end=1 is (10,-10); entity 2 start=0 is (10,10). That's a U.
|
|
// But we need a closed square from C shape.
|
|
// Re-think: a C shape open on the left side.
|
|
// Entities: 0 = bottom edge (-10,-10)->(10,-10) [end=1 at (10,-10)]
|
|
// 1 = right edge (10,-10)->(10,10) [start=0 at (10,-10), end=1 at (10,10)]
|
|
// 2 = top edge (10,10)->(-10,10) [start=0 at (10,10), end=1 at (-10,10)]
|
|
// The C is open: entity 2's end is at (-10,10) and entity 0's start is at (-10,-10).
|
|
// Bridge: entity 2 end=1 (-10,10) -> entity 0 start=0 (-10,-10).
|
|
f.entities.push_back(closing);
|
|
REQUIRE(f.entities.size() == 4);
|
|
|
|
// Now bridge from top end (entity 2 end=1 = (-10,10)) to bottom start (entity 0 end=0 = (-10,-10))
|
|
int bi = doc.add_bridge(sk, 2/*top edge*/, 1/*end*/, 0/*bottom edge*/, 0/*start*/, "Bridge");
|
|
REQUIRE(bi == 4);
|
|
REQUIRE(f.entities.size() == 5);
|
|
|
|
// Now the entities should form a closed loop -> extrude
|
|
int ex = doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(ex >= 0);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
REQUIRE_THAT(double(doc.display_mesh.volume()), WithinRel(20.0 * 20.0 * 5.0, 1e-2));
|
|
}
|
|
|
|
TEST_CASE("bridge bad indices throw", "[CadDocument][bridge]")
|
|
{
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)},
|
|
{SketchEntity::Type::Line, Vec2d(20,0), Vec2d(30,0)},
|
|
};
|
|
doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
|
|
// out-of-range entity a
|
|
REQUIRE_THROWS(doc.add_bridge(0, 99, 1, 1, 0, "Bad"));
|
|
// out-of-range entity b
|
|
REQUIRE_THROWS(doc.add_bridge(0, 0, 1, 99, 0, "Bad"));
|
|
// out-of-range sketch_ref
|
|
REQUIRE_THROWS(doc.add_bridge(99, 0, 1, 1, 0, "Bad"));
|
|
// non-sketch feature as sketch_ref
|
|
doc.add_extrude(0, 5.0, false, BooleanMode::New, "Ex");
|
|
REQUIRE_THROWS(doc.add_bridge(1, 0, 1, 1, 0, "Bad"));
|
|
}
|
|
|
|
TEST_CASE("bridge round-trip serialization", "[CadDocument][bridge]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
std::vector<SketchEntity> ents = {
|
|
{SketchEntity::Type::Line, Vec2d(0,0), Vec2d(10,0)},
|
|
{SketchEntity::Type::Line, Vec2d(20,0), Vec2d(30,0)},
|
|
};
|
|
int sk = doc.add_sketch_entities(ents, SketchPlane::XY(), "S");
|
|
REQUIRE(sk == 0);
|
|
int bi = doc.add_bridge(sk, 0, 1, 1, 0, "Bridge");
|
|
REQUIRE(bi == 2);
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
REQUIRE(doc2.features.size() == 2);
|
|
|
|
const auto& br = doc2.features[0].entities[2];
|
|
REQUIRE(br.type == SketchEntity::Type::BSpline);
|
|
REQUIRE(br.ctrl.size() == 4);
|
|
REQUIRE_THAT(br.ctrl.front().x(), WithinAbs(10.0, 1e-9));
|
|
REQUIRE_THAT(br.ctrl.front().y(), WithinAbs(0.0, 1e-9));
|
|
REQUIRE_THAT(br.ctrl.back().x(), WithinAbs(20.0, 1e-9));
|
|
REQUIRE_THAT(br.ctrl.back().y(), WithinAbs(0.0, 1e-9));
|
|
}
|
|
|
|
TEST_CASE("delete_face removes a fillet face and restores volume", "[CadDocument][deleteface]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
REQUIRE(sk >= 0);
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
double Vbox = double(doc.display_mesh.volume());
|
|
REQUIRE(Vbox > 0.0);
|
|
|
|
doc.add_fillet(2.0, FaceGroup::All, "Fillet");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
double Vf = double(doc.display_mesh.volume());
|
|
REQUIRE(Vf < Vbox);
|
|
|
|
int nfaces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
doc.checkpoint();
|
|
bool found = false;
|
|
for (int fi = 0; fi < nfaces && !found; ++fi) {
|
|
int idx = doc.add_delete_face(0, {fi}, "Unfillet");
|
|
(void)idx;
|
|
if (doc.recompute() && doc.error.empty()) {
|
|
double Vr = double(doc.display_mesh.volume());
|
|
if (Vr > Vf) {
|
|
found = true;
|
|
}
|
|
}
|
|
if (!found) doc.undo();
|
|
}
|
|
REQUIRE(found);
|
|
}
|
|
|
|
TEST_CASE("delete_face with bad face index fails safely", "[CadDocument][deleteface]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
REQUIRE(sk >= 0);
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_delete_face(0, {9999}, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "face");
|
|
}
|
|
|
|
TEST_CASE("delete_face round-trip serialization", "[CadDocument][deleteface]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box");
|
|
REQUIRE(sk >= 0);
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
|
|
doc.add_fillet(2.0, FaceGroup::All, "Fillet");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int nfaces = GeometryEngine::face_count(doc.bodies[0].shape);
|
|
int fillet_face = -1;
|
|
for (int fi = 0; fi < nfaces; ++fi) {
|
|
doc.checkpoint();
|
|
doc.add_delete_face(0, {fi}, "Unfillet");
|
|
bool ok = doc.recompute();
|
|
if (ok && doc.error.empty()) {
|
|
fillet_face = fi;
|
|
break;
|
|
}
|
|
doc.undo();
|
|
}
|
|
REQUIRE(fillet_face >= 0);
|
|
|
|
std::vector<std::pair<Vec3d, Vec3d>> bboxes;
|
|
for (const auto& b : doc.bodies) {
|
|
Bnd_Box bb; BRepBndLib::Add(b.shape, bb);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
bboxes.push_back({Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)});
|
|
}
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
REQUIRE(doc2.error.empty());
|
|
REQUIRE(doc2.recompute());
|
|
REQUIRE(doc2.error.empty());
|
|
REQUIRE(doc2.bodies.size() == doc.bodies.size());
|
|
|
|
for (size_t i = 0; i < bboxes.size(); ++i) {
|
|
Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6));
|
|
REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6));
|
|
REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6));
|
|
REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6));
|
|
REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6));
|
|
REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hole: counterbore removes more material than a simple bore", "[CadDocument][hole]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
auto make_box_hole = [](int style, double cbore_d, double cbore_depth,
|
|
double csink_d, double csink_angle, const std::string& desig) {
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 30, 0, "Box");
|
|
doc.add_extrude(sk, 15.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
double v_box = doc.body_mass_properties(0).volume;
|
|
if (style == 0)
|
|
doc.add_hole(6.0, 10.0, false, 0.0, 0.0, SketchPlane::XY(), "Hole");
|
|
else
|
|
doc.add_hole_styled(6.0, 10.0, false, 0.0, 0.0, SketchPlane::XY(), style,
|
|
cbore_d, cbore_depth, csink_d, csink_angle, desig, "Hole");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
double v = doc.body_mass_properties(0).volume;
|
|
REQUIRE(v < v_box);
|
|
return v;
|
|
};
|
|
|
|
double v_simple = make_box_hole(0, 0, 0, 0, 0, "");
|
|
double v_cbore = make_box_hole(1, 11.0, 6.0, 0, 0, "M6");
|
|
REQUIRE(v_cbore < v_simple);
|
|
}
|
|
|
|
TEST_CASE("hole: countersink removes more material than a simple bore", "[CadDocument][hole]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
auto make_box = []() {
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 30, 0, "Box");
|
|
doc.add_extrude(sk, 15.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
return doc;
|
|
};
|
|
|
|
CadDocument doc_simple = make_box();
|
|
doc_simple.add_hole(6.0, 10.0, false, 0.0, 0.0, SketchPlane::XY(), "Hole");
|
|
REQUIRE(doc_simple.recompute());
|
|
double v_simple = doc_simple.body_mass_properties(0).volume;
|
|
double v_box = 30.0 * 30.0 * 15.0;
|
|
|
|
CadDocument doc_csink = make_box();
|
|
doc_csink.add_hole_styled(6.0, 10.0, false, 0.0, 0.0, SketchPlane::XY(), 2,
|
|
0.0, 0.0, 12.0, 90.0, "M6", "Hole");
|
|
REQUIRE(doc_csink.recompute());
|
|
double v_csink = doc_csink.body_mass_properties(0).volume;
|
|
|
|
REQUIRE(v_simple < v_box);
|
|
REQUIRE(v_csink < v_simple);
|
|
}
|
|
|
|
TEST_CASE("hole: standards table lookup", "[CadDocument][hole]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 30, 0, "Box");
|
|
doc.add_extrude(sk, 15.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
|
|
int fi = doc.add_hole_standard("M6", 0, true, 10, 0, 0, SketchPlane::XY(), "H");
|
|
REQUIRE(fi >= 0);
|
|
REQUIRE_THAT(doc.features[fi].hole_diameter, WithinAbs(6.6, 1e-6));
|
|
REQUIRE(doc.features[fi].hole_standard == "M6");
|
|
|
|
REQUIRE_THROWS(doc.add_hole_standard("M999", 0, true, 10, 0, 0, SketchPlane::XY(), "H"));
|
|
try {
|
|
doc.add_hole_standard("M999", 0, true, 10, 0, 0, SketchPlane::XY(), "H");
|
|
} catch (const std::exception& ex) {
|
|
CHECK_CONTAINS(std::string(ex.what()), "standard");
|
|
}
|
|
}
|
|
|
|
TEST_CASE("hole: round-trip preserves styled counterbore hole", "[CadDocument][hole]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 30, 30, 0, "Box");
|
|
doc.add_extrude(sk, 15.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_hole_styled(6.0, 10.0, false, 0.0, 0.0, SketchPlane::XY(), 1,
|
|
11.0, 6.0, 0.0, 90.0, "M6", "Cbore");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
std::vector<std::pair<Vec3d, Vec3d>> bboxes;
|
|
for (const auto& b : doc.bodies) {
|
|
Bnd_Box bb; BRepBndLib::Add(b.shape, bb);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
bboxes.emplace_back(Vec3d(x0, y0, z0), Vec3d(x1, y1, z1));
|
|
}
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
REQUIRE(doc2.error.empty());
|
|
REQUIRE(doc2.bodies.size() == doc.bodies.size());
|
|
|
|
for (size_t i = 0; i < bboxes.size(); ++i) {
|
|
Bnd_Box bb; BRepBndLib::Add(doc2.bodies[i].shape, bb);
|
|
Standard_Real x0, y0, z0, x1, y1, z1;
|
|
bb.Get(x0, y0, z0, x1, y1, z1);
|
|
REQUIRE_THAT(double(x0), WithinAbs(bboxes[i].first.x(), 1e-6));
|
|
REQUIRE_THAT(double(y0), WithinAbs(bboxes[i].first.y(), 1e-6));
|
|
REQUIRE_THAT(double(z0), WithinAbs(bboxes[i].first.z(), 1e-6));
|
|
REQUIRE_THAT(double(x1), WithinAbs(bboxes[i].second.x(), 1e-6));
|
|
REQUIRE_THAT(double(y1), WithinAbs(bboxes[i].second.y(), 1e-6));
|
|
REQUIRE_THAT(double(z1), WithinAbs(bboxes[i].second.z(), 1e-6));
|
|
}
|
|
|
|
bool found = false;
|
|
for (const auto& f : doc2.features) {
|
|
if (f.type == CadFeatureType::Hole && f.name == "Cbore") {
|
|
REQUIRE(f.hole_style == 1);
|
|
REQUIRE_THAT(f.hole_cbore_diameter, WithinAbs(11.0, 1e-9));
|
|
found = true;
|
|
}
|
|
}
|
|
REQUIRE(found);
|
|
}
|
|
|
|
TEST_CASE("variables drive a box (parametric sketch + extrude)", "[CadDocument][variables]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.features.size() >= 2);
|
|
int ex = sk + 1;
|
|
|
|
doc.variables = {{"w", "10"}, {"h", "w*2"}};
|
|
doc.features[sk].expr = {{"width", "w"}, {"height", "w"}};
|
|
doc.features[ex].expr = {{"distance", "h"}};
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE_FALSE(doc.body.IsNull());
|
|
|
|
Bnd_Box bb; BRepBndLib::Add(doc.body, bb);
|
|
double xlo, ylo, zlo, xhi, yhi, zhi;
|
|
bb.Get(xlo, ylo, zlo, xhi, yhi, zhi);
|
|
REQUIRE_THAT(xhi - xlo, WithinAbs(10.0, 1.0));
|
|
REQUIRE_THAT(yhi - ylo, WithinAbs(10.0, 1.0));
|
|
REQUIRE_THAT(zhi - zlo, WithinAbs(20.0, 2.0));
|
|
}
|
|
|
|
TEST_CASE("function + pi in expression binding", "[CadDocument][variables]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(),
|
|
20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.features.size() >= 2);
|
|
|
|
doc.variables = {{"r", "sqrt(16)+abs(-2)"}};
|
|
doc.features[sk].expr = {{"radius", "r"}};
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE_THAT(doc.features[sk].radius, WithinAbs(6.0, 1e-6));
|
|
|
|
doc.variables = {{"r", "max(3, pi)"}};
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE_THAT(doc.features[sk].radius, WithinAbs(M_PI, 1e-6));
|
|
|
|
doc.variables = {{"r", "5"}, {"d", "r*2"}};
|
|
doc.features[sk].expr = {{"radius", "d"}};
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE_THAT(doc.features[sk].radius, WithinAbs(10.0, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("cycle detected fails recompute with error", "[CadDocument][variables]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
|
|
doc.features[sk].expr = {{"width", "a"}};
|
|
|
|
doc.variables = {{"a", "b"}, {"b", "a"}};
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "cycle");
|
|
}
|
|
|
|
TEST_CASE("unknown parameter fails recompute", "[CadDocument][variables]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
|
|
doc.features[sk].expr = {{"nope", "1"}};
|
|
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "unknown parameter");
|
|
}
|
|
|
|
TEST_CASE("unknown identifier fails recompute", "[CadDocument][variables]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
|
|
doc.features[sk].expr = {{"width", "x"}};
|
|
|
|
doc.variables = {{"x", "y+1"}};
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "unknown identifier");
|
|
}
|
|
|
|
// The checkpoint -> mutate -> recompute -> undo-on-failure pattern is what both the GUI and
|
|
// McpControl use to keep a bad edit out of the document. It only works if the snapshot covers
|
|
// `variables` as well as `features`: undo() used to restore features alone, so a bad variable
|
|
// survived the rollback and every later recompute failed — the document was left unusable.
|
|
TEST_CASE("undo rolls back a bad variable, not just features", "[CadDocument][variables]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
|
|
doc.features[sk].expr = {{"width", "w"}};
|
|
doc.variables = {{"w", "20"}};
|
|
REQUIRE(doc.recompute());
|
|
|
|
// Caller sets a variable to something unevaluable, exactly as action_set_variable does.
|
|
doc.checkpoint();
|
|
doc.variables["w"] = "nosuchvar + 1";
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE(doc.undo());
|
|
|
|
// The good value must be back...
|
|
REQUIRE(doc.variables.at("w") == "20");
|
|
// ...and, the part that actually bit, the document must still be usable.
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
}
|
|
|
|
// A variable ADDED under a checkpoint must disappear entirely on undo, not linger with a
|
|
// stale value: the pre-mutation snapshot simply did not contain the key.
|
|
TEST_CASE("undo removes a variable that did not exist before the checkpoint", "[CadDocument][variables]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.variables.empty());
|
|
|
|
doc.checkpoint();
|
|
doc.variables["bogus"] = "1/0 +"; // syntactically broken
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE(doc.undo());
|
|
|
|
REQUIRE(doc.variables.count("bogus") == 0);
|
|
REQUIRE(doc.recompute());
|
|
}
|
|
|
|
TEST_CASE("parametric recipe round-trips through serialize/deserialize", "[CadDocument][variables]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
|
|
20, 20, 10, "Sketch");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
|
|
REQUIRE(doc.features.size() >= 2);
|
|
int ex = sk + 1;
|
|
|
|
doc.variables = {{"w", "10"}, {"h", "w*2"}};
|
|
doc.features[sk].expr = {{"width", "w"}, {"height", "w"}};
|
|
doc.features[ex].expr = {{"distance", "h"}};
|
|
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
Bnd_Box bb_orig; BRepBndLib::Add(doc.body, bb_orig);
|
|
double ox0, oy0, oz0, ox1, oy1, oz1;
|
|
bb_orig.Get(ox0, oy0, oz0, ox1, oy1, oz1);
|
|
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument doc2;
|
|
REQUIRE(doc2.deserialize_recipe(blob));
|
|
|
|
REQUIRE(doc2.variables.size() == 2);
|
|
REQUIRE(doc2.variables["w"] == "10");
|
|
REQUIRE(doc2.variables["h"] == "w*2");
|
|
|
|
bool found_sk = false, found_ex = false;
|
|
for (const auto& f : doc2.features) {
|
|
if (f.name == "Sketch") {
|
|
REQUIRE(f.expr.size() == 2);
|
|
REQUIRE(f.expr.at("width") == "w");
|
|
REQUIRE(f.expr.at("height") == "w");
|
|
found_sk = true;
|
|
}
|
|
if (f.name == "Extrude") {
|
|
REQUIRE(f.expr.size() == 1);
|
|
REQUIRE(f.expr.at("distance") == "h");
|
|
found_ex = true;
|
|
}
|
|
}
|
|
REQUIRE(found_sk);
|
|
REQUIRE(found_ex);
|
|
|
|
REQUIRE(doc2.bodies.size() == doc.bodies.size());
|
|
|
|
Bnd_Box bb2; BRepBndLib::Add(doc2.body, bb2);
|
|
double nx0, ny0, nz0, nx1, ny1, nz1;
|
|
bb2.Get(nx0, ny0, nz0, nx1, ny1, nz1);
|
|
REQUIRE_THAT(nx0, WithinAbs(ox0, 1e-6));
|
|
REQUIRE_THAT(ny0, WithinAbs(oy0, 1e-6));
|
|
REQUIRE_THAT(nz0, WithinAbs(oz0, 1e-6));
|
|
REQUIRE_THAT(nx1, WithinAbs(ox1, 1e-6));
|
|
REQUIRE_THAT(ny1, WithinAbs(oy1, 1e-6));
|
|
REQUIRE_THAT(nz1, WithinAbs(oz1, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("surface-extrude makes an open shell", "[CadDocument][surface]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect");
|
|
REQUIRE(sk >= 0);
|
|
int fi = doc.add_surface_extrude(sk, 12.0, "Skin");
|
|
REQUIRE(fi == 1);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
|
|
// A rectangle skin has 4 side faces (no end caps).
|
|
int face_count = 0, solid_count = 0;
|
|
for (TopExp_Explorer fe(doc.bodies.back().shape, TopAbs_FACE); fe.More(); fe.Next()) ++face_count;
|
|
for (TopExp_Explorer se(doc.bodies.back().shape, TopAbs_SOLID); se.More(); se.Next()) ++solid_count;
|
|
REQUIRE(face_count >= 1);
|
|
REQUIRE(solid_count == 0);
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
}
|
|
|
|
TEST_CASE("surface-revolve makes an open shell", "[CadDocument][surface]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
// A small rectangle offset from the axis: u=10..15, v=0..5.
|
|
SketchProfile sp;
|
|
sp.points = {{10,0},{15,0},{15,5},{10,5}};
|
|
sp.closed = true;
|
|
const int sk = doc.add_sketch_profile(sp, SketchPlane::XY(), "Profile");
|
|
REQUIRE(sk >= 0);
|
|
int fi = doc.add_surface_revolve(sk, 360, 0, "Rev");
|
|
REQUIRE(fi == 1);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
|
|
int solid_count = 0;
|
|
for (TopExp_Explorer se(doc.bodies.back().shape, TopAbs_SOLID); se.More(); se.Next()) ++solid_count;
|
|
REQUIRE(solid_count == 0);
|
|
REQUIRE(doc.display_mesh.facets_count() > 0);
|
|
}
|
|
|
|
TEST_CASE("surface-extrude bad ref safe", "[CadDocument][surface]")
|
|
{
|
|
CadDocument doc;
|
|
int fi = doc.add_surface_extrude(999, 10, "Bad");
|
|
REQUIRE(fi == 0);
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "surface-extrude");
|
|
}
|
|
|
|
TEST_CASE("surface round-trip serialize/deserialize", "[CadDocument][surface]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect");
|
|
doc.add_surface_extrude(sk, 12.0, "Skin");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
|
|
size_t orig_nb = doc.bodies.size();
|
|
Bnd_Box orig_bb;
|
|
BRepBndLib::Add(doc.bodies.back().shape, orig_bb);
|
|
|
|
std::string blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument fresh;
|
|
REQUIRE(fresh.deserialize_recipe(blob));
|
|
REQUIRE(fresh.error.empty());
|
|
REQUIRE(fresh.bodies.size() == orig_nb);
|
|
REQUIRE(CadDocument::is_sheet_shape(fresh.bodies.back().shape));
|
|
|
|
Bnd_Box fresh_bb;
|
|
BRepBndLib::Add(fresh.bodies.back().shape, fresh_bb);
|
|
Standard_Real ox0, oy0, oz0, ox1, oy1, oz1;
|
|
orig_bb.Get(ox0, oy0, oz0, ox1, oy1, oz1);
|
|
Standard_Real fx0, fy0, fz0, fx1, fy1, fz1;
|
|
fresh_bb.Get(fx0, fy0, fz0, fx1, fy1, fz1);
|
|
REQUIRE_THAT(double(fx0), WithinAbs(double(ox0), 1e-6));
|
|
REQUIRE_THAT(double(fy0), WithinAbs(double(oy0), 1e-6));
|
|
REQUIRE_THAT(double(fz0), WithinAbs(double(oz0), 1e-6));
|
|
REQUIRE_THAT(double(fx1), WithinAbs(double(ox1), 1e-6));
|
|
REQUIRE_THAT(double(fy1), WithinAbs(double(oy1), 1e-6));
|
|
REQUIRE_THAT(double(fz1), WithinAbs(double(oz1), 1e-6));
|
|
}
|
|
|
|
TEST_CASE("thicken-surface makes a solid from a sheet", "[CadDocument][surface]")
|
|
{
|
|
using Catch::Matchers::WithinRel;
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect");
|
|
REQUIRE(sk >= 0);
|
|
int si = doc.add_surface_extrude(sk, 12.0, "Skin");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
|
|
Bnd_Box sheet_bb;
|
|
BRepBndLib::Add(doc.bodies.back().shape, sheet_bb);
|
|
|
|
int ti = doc.add_thicken_surface(0, 2.0, false, "Wall");
|
|
REQUIRE(ti == 2);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
REQUIRE(!CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
double vol = doc.body_mass_properties(1).volume;
|
|
REQUIRE(vol > 0);
|
|
|
|
Bnd_Box thick_bb;
|
|
BRepBndLib::Add(doc.bodies.back().shape, thick_bb);
|
|
Standard_Real sx0, sy0, sz0, sx1, sy1, sz1;
|
|
Standard_Real tx0, ty0, tz0, tx1, ty1, tz1;
|
|
sheet_bb.Get(sx0, sy0, sz0, sx1, sy1, sz1);
|
|
thick_bb.Get(tx0, ty0, tz0, tx1, ty1, tz1);
|
|
REQUIRE_THAT(double(tx0), WithinAbs(double(sx0), 2.1));
|
|
REQUIRE_THAT(double(tx1), WithinAbs(double(sx1), 2.1));
|
|
}
|
|
|
|
TEST_CASE("surface-offset creates another sheet shifted outward", "[CadDocument][surface]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect");
|
|
REQUIRE(sk >= 0);
|
|
int si = doc.add_surface_extrude(sk, 12.0, "Skin");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
Bnd_Box src_bb;
|
|
BRepBndLib::Add(doc.bodies.back().shape, src_bb);
|
|
|
|
int oi = doc.add_surface_offset(0, 1.0, "Off");
|
|
REQUIRE(oi == 2);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
|
|
Bnd_Box off_bb;
|
|
BRepBndLib::Add(doc.bodies.back().shape, off_bb);
|
|
Standard_Real sx0, sy0, sz0, sx1, sy1, sz1;
|
|
Standard_Real ox0, oy0, oz0, ox1, oy1, oz1;
|
|
src_bb.Get(sx0, sy0, sz0, sx1, sy1, sz1);
|
|
off_bb.Get(ox0, oy0, oz0, ox1, oy1, oz1);
|
|
REQUIRE(std::abs(double(ox0) - double(sx0)) > 1e-3);
|
|
REQUIRE(std::abs(double(ox1) - double(sx1)) > 1e-3);
|
|
REQUIRE(std::abs(double(oy0) - double(sy0)) > 1e-3);
|
|
REQUIRE(std::abs(double(oy1) - double(sy1)) > 1e-3);
|
|
}
|
|
|
|
TEST_CASE("thicken-surface on non-sheet fails", "[CadDocument][surface]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect");
|
|
REQUIRE(sk >= 0);
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Solid");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE_FALSE(CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
|
|
doc.add_thicken_surface(0, 2.0, false, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "sheet");
|
|
}
|
|
|
|
TEST_CASE("thicken-surface round-trip serialize/deserialize", "[CadDocument][surface]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
using Catch::Matchers::WithinRel;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect");
|
|
doc.add_surface_extrude(sk, 12.0, "Skin");
|
|
REQUIRE(doc.recompute());
|
|
doc.add_thicken_surface(0, 2.0, false, "Wall");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE_FALSE(CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
|
|
size_t orig_nb = doc.bodies.size();
|
|
Bnd_Box orig_bb;
|
|
BRepBndLib::Add(doc.bodies.back().shape, orig_bb);
|
|
|
|
std::string blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument fresh;
|
|
REQUIRE(fresh.deserialize_recipe(blob));
|
|
REQUIRE(fresh.error.empty());
|
|
REQUIRE(fresh.bodies.size() == orig_nb);
|
|
REQUIRE_FALSE(CadDocument::is_sheet_shape(fresh.bodies.back().shape));
|
|
|
|
Bnd_Box fresh_bb;
|
|
BRepBndLib::Add(fresh.bodies.back().shape, fresh_bb);
|
|
Standard_Real ox0, oy0, oz0, ox1, oy1, oz1;
|
|
Standard_Real fx0, fy0, fz0, fx1, fy1, fz1;
|
|
orig_bb.Get(ox0, oy0, oz0, ox1, oy1, oz1);
|
|
fresh_bb.Get(fx0, fy0, fz0, fx1, fy1, fz1);
|
|
REQUIRE_THAT(double(fx0), WithinAbs(double(ox0), 1e-6));
|
|
REQUIRE_THAT(double(fy0), WithinAbs(double(oy0), 1e-6));
|
|
REQUIRE_THAT(double(fz0), WithinAbs(double(oz0), 1e-6));
|
|
REQUIRE_THAT(double(fx1), WithinAbs(double(ox1), 1e-6));
|
|
REQUIRE_THAT(double(fy1), WithinAbs(double(oy1), 1e-6));
|
|
REQUIRE_THAT(double(fz1), WithinAbs(double(oz1), 1e-6));
|
|
}
|
|
|
|
TEST_CASE("surface-loft makes an open shell", "[CadDocument][surface]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
SketchProfile bot;
|
|
bot.points = {{-10,-10},{10,-10},{10,10},{-10,10}};
|
|
bot.closed = true;
|
|
int s0 = doc.add_sketch_profile(bot, SketchPlane::XY(), "Bottom");
|
|
|
|
doc.add_plane(0 /*XY*/, 20.0, 0.0, 0, "Plane1");
|
|
SketchPlane top = doc.resolve_datum_planes()[0].second;
|
|
SketchProfile tp;
|
|
tp.points = {{-5,-5},{5,-5},{5,5},{-5,5}};
|
|
tp.closed = true;
|
|
int s1 = doc.add_sketch_profile(tp, top, "Top");
|
|
|
|
int fi = doc.add_surface_loft({s0, s1}, false, "Skin");
|
|
REQUIRE(fi >= 0);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
|
|
// Contrast with a solid loft on the same profiles.
|
|
CadDocument doc2;
|
|
SketchProfile bot2;
|
|
bot2.points = {{-10,-10},{10,-10},{10,10},{-10,10}};
|
|
bot2.closed = true;
|
|
int a0 = doc2.add_sketch_profile(bot2, SketchPlane::XY(), "Bottom");
|
|
doc2.add_plane(0 /*XY*/, 20.0, 0.0, 0, "Plane1");
|
|
SketchPlane top2 = doc2.resolve_datum_planes()[0].second;
|
|
SketchProfile tp2;
|
|
tp2.points = {{-5,-5},{5,-5},{5,5},{-5,5}};
|
|
tp2.closed = true;
|
|
int a1 = doc2.add_sketch_profile(tp2, top2, "Top");
|
|
doc2.add_loft({a0, a1}, false, BooleanMode::New, "SolidLoft");
|
|
REQUIRE(doc2.recompute());
|
|
REQUIRE(doc2.error.empty());
|
|
REQUIRE_FALSE(CadDocument::is_sheet_shape(doc2.bodies.back().shape));
|
|
}
|
|
|
|
TEST_CASE("surface-fill makes a one-face sheet", "[CadDocument][surface]")
|
|
{
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Rect");
|
|
REQUIRE(sk >= 0);
|
|
int fi = doc.add_surface_fill(sk, "Patch");
|
|
REQUIRE(fi == 1);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 1);
|
|
REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
|
|
int face_count = 0;
|
|
for (TopExp_Explorer fe(doc.bodies.back().shape, TopAbs_FACE); fe.More(); fe.Next()) ++face_count;
|
|
REQUIRE(face_count >= 1);
|
|
}
|
|
|
|
TEST_CASE("surface-loft / surface-fill bad refs safe", "[CadDocument][surface]")
|
|
{
|
|
{
|
|
CadDocument doc;
|
|
doc.add_surface_loft({999}, false, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "surface-loft");
|
|
}
|
|
{
|
|
CadDocument doc;
|
|
doc.add_surface_fill(999, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "surface-fill");
|
|
}
|
|
}
|
|
|
|
TEST_CASE("surface-loft round-trip serialize/deserialize", "[CadDocument][surface]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
SketchProfile bot;
|
|
bot.points = {{-10,-10},{10,-10},{10,10},{-10,10}};
|
|
bot.closed = true;
|
|
int s0 = doc.add_sketch_profile(bot, SketchPlane::XY(), "Bottom");
|
|
doc.add_plane(0 /*XY*/, 20.0, 0.0, 0, "Plane1");
|
|
SketchPlane top = doc.resolve_datum_planes()[0].second;
|
|
SketchProfile tp;
|
|
tp.points = {{-5,-5},{5,-5},{5,5},{-5,5}};
|
|
tp.closed = true;
|
|
int s1 = doc.add_sketch_profile(tp, top, "Top");
|
|
doc.add_surface_loft({s0, s1}, false, "Skin");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(CadDocument::is_sheet_shape(doc.bodies.back().shape));
|
|
|
|
size_t orig_nb = doc.bodies.size();
|
|
Bnd_Box orig_bb;
|
|
BRepBndLib::Add(doc.bodies.back().shape, orig_bb);
|
|
|
|
std::string blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument fresh;
|
|
REQUIRE(fresh.deserialize_recipe(blob));
|
|
REQUIRE(fresh.error.empty());
|
|
REQUIRE(fresh.bodies.size() == orig_nb);
|
|
REQUIRE(CadDocument::is_sheet_shape(fresh.bodies.back().shape));
|
|
|
|
Bnd_Box fresh_bb;
|
|
BRepBndLib::Add(fresh.bodies.back().shape, fresh_bb);
|
|
Standard_Real ox0, oy0, oz0, ox1, oy1, oz1;
|
|
Standard_Real fx0, fy0, fz0, fx1, fy1, fz1;
|
|
orig_bb.Get(ox0, oy0, oz0, ox1, oy1, oz1);
|
|
fresh_bb.Get(fx0, fy0, fz0, fx1, fy1, fz1);
|
|
REQUIRE_THAT(double(fx0), WithinAbs(double(ox0), 1e-6));
|
|
REQUIRE_THAT(double(fy0), WithinAbs(double(oy0), 1e-6));
|
|
REQUIRE_THAT(double(fz0), WithinAbs(double(oz0), 1e-6));
|
|
REQUIRE_THAT(double(fx1), WithinAbs(double(ox1), 1e-6));
|
|
REQUIRE_THAT(double(fy1), WithinAbs(double(oy1), 1e-6));
|
|
REQUIRE_THAT(double(fz1), WithinAbs(double(oz1), 1e-6));
|
|
}
|
|
|
|
// --- Mate tests (M8a) ---
|
|
|
|
TEST_CASE("fastened mate with zero offset/angle", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_box = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
|
doc.add_extrude(sk_box, 5.0, false, BooleanMode::New, "BoxExt");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk_cyl = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
|
|
doc.add_extrude(sk_cyl, 10.0, false, BooleanMode::New, "CylExt");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int mi = doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate");
|
|
REQUIRE(mi >= 0);
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Z()), WithinAbs(5.0, 1e-4));
|
|
}
|
|
|
|
TEST_CASE("fastened mate with offset", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
|
|
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(0, cs_fixed, cs_moving, 7.0, 0.0, false, "Mate");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.Z()), WithinAbs(12.0, 1e-4));
|
|
}
|
|
|
|
TEST_CASE("fastened mate with angle", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
|
|
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_x_hint = Vec3d(1, 0, 0);
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 90.0, false, "Mate");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
|
}
|
|
|
|
TEST_CASE("fastened mate with flip", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
|
|
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "Mate");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
|
// Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid,
|
|
// but the mate executed cleanly and the body moved to the target connector.
|
|
}
|
|
|
|
TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
|
|
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(20, 0, 0), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(1, cs_fixed, cs_moving, 3.0, 0.0, false, "MatePlanar");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
// Connector B was at z=0 (bottom of 10mm cylinder). After planar mate with
|
|
// offset=3 and A at z=5, the z-distance from A to B becomes 3 => B.z = 8.
|
|
// The cylinder centroid (was at z=5) moves to z=13.
|
|
REQUIRE_THAT(double(com.Z()), WithinAbs(13.0, 1e-4));
|
|
}
|
|
|
|
TEST_CASE("mate round-trip serialization", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_a = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_A");
|
|
doc.features[cs_a].coordsys_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl");
|
|
doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_b = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(20, 0, 5), "CS_B");
|
|
doc.features[cs_b].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(1, cs_a, cs_b, 7.25, 33.0, true, "Mate");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
size_t nb = doc.bodies.size();
|
|
auto blob = doc.serialize_recipe();
|
|
REQUIRE_FALSE(blob.empty());
|
|
|
|
CadDocument fresh;
|
|
REQUIRE(fresh.deserialize_recipe(blob));
|
|
REQUIRE(fresh.bodies.size() == nb);
|
|
|
|
const CadFeature* mf = nullptr;
|
|
for (const auto& f : fresh.features) {
|
|
if (f.type == CadFeatureType::Mate) { mf = &f; break; }
|
|
}
|
|
REQUIRE(mf != nullptr);
|
|
REQUIRE(mf->mate_kind == 1);
|
|
REQUIRE_THAT(mf->mate_offset, WithinAbs(7.25, 1e-9));
|
|
REQUIRE_THAT(mf->mate_angle, WithinAbs(33.0, 1e-9));
|
|
REQUIRE(mf->mate_flip == true);
|
|
}
|
|
|
|
TEST_CASE("version 2 blob is rejected", "[CadDocument][mate]")
|
|
{
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
std::ostringstream oss;
|
|
{
|
|
cereal::BinaryOutputArchive ar(oss);
|
|
uint32_t fake_v = 2;
|
|
ar(fake_v);
|
|
ar(doc.features);
|
|
ar(doc.variables);
|
|
}
|
|
std::string blob = oss.str();
|
|
|
|
CadDocument fresh;
|
|
REQUIRE_FALSE(fresh.deserialize_recipe(blob));
|
|
REQUIRE_CONTAINS(fresh.error, "older version");
|
|
}
|
|
|
|
TEST_CASE("mate error: out of range connectors", "[CadDocument][mate]")
|
|
{
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS");
|
|
doc.features[cs].coordsys_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(0, 999, cs, 0, 0, false, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "mate_cs_a out of range");
|
|
doc.features.pop_back(); doc.error.clear();
|
|
|
|
doc.add_mate(0, cs, 999, 0, 0, false, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "mate_cs_b out of range");
|
|
doc.features.pop_back(); doc.error.clear();
|
|
|
|
doc.add_mate(0, sk, cs, 0, 0, false, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "not a valid CoordSys");
|
|
doc.features.pop_back(); doc.error.clear();
|
|
}
|
|
|
|
TEST_CASE("mate error: no associated body", "[CadDocument][mate]")
|
|
{
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(0, cs_fixed, cs_moving, 0, 0, false, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "no associated body");
|
|
}
|
|
|
|
TEST_CASE("mate error: disabled connector", "[CadDocument][mate]")
|
|
{
|
|
|
|
CadDocument doc;
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Box");
|
|
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "E");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.features[cs_moving].enabled = false;
|
|
|
|
doc.add_mate(0, cs_fixed, cs_moving, 0, 0, false, "Bad");
|
|
REQUIRE_FALSE(doc.recompute());
|
|
REQUIRE_CONTAINS(doc.error, "not a valid CoordSys");
|
|
}
|
|
|
|
TEST_CASE("ordering: fillet after mate resolves face ids", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Mate");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_fillet(1.0, FaceGroup::All, "FilletAfterMate");
|
|
doc.features.back().target_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
}
|
|
|
|
// --- M8a fix round: planar antiparallel + blind tests ---
|
|
|
|
static int find_face_by_normal(const CadDocument& doc, int body_idx, const Vec3d& dir, double tol = 0.99)
|
|
{
|
|
auto faces = GeometryEngine::faces_of(doc.bodies[body_idx].shape);
|
|
for (int fi = 0; fi < int(faces.size()); ++fi) {
|
|
if (GeometryEngine::face_normal_world(faces[fi]).dot(dir) > tol) return fi;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
// Global edge index of the first edge belonging to `face_idx`.
|
|
//
|
|
// A CoordSys built from a face alone takes its z from the face normal (which follows the
|
|
// body) but its x from coordsys_x_hint, a WORLD constant. Such a frame is only half
|
|
// body-following: the body's rotation about the face normal is invisible to it, so no mate
|
|
// can correct or preserve a spin the connector cannot see. Pinning coordsys_edge to an edge
|
|
// of the body makes the in-plane direction follow the body too, which is what any test
|
|
// distinguishing Slider (corrects spin) from Cylindrical (preserves it) requires.
|
|
static int find_edge_on_face(const CadDocument& doc, int body_idx, int face_idx)
|
|
{
|
|
TopoDS_Face f = GeometryEngine::face_by_index(doc.bodies[body_idx].shape, face_idx);
|
|
if (f.IsNull()) return -1;
|
|
auto face_edges = GeometryEngine::edges_of_face(f);
|
|
auto all_edges = GeometryEngine::edges_of(doc.bodies[body_idx].shape);
|
|
for (int ei = 0; ei < int(all_edges.size()); ++ei) {
|
|
for (const auto& fe : face_edges) {
|
|
if (all_edges[ei].IsSame(fe)) return ei;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
TEST_CASE("planar mate: antiparallel normals with asymmetric body", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Body A: box 20x20x5, centered at origin in XY
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Body B: box 20x10x5, also centered at origin (asymmetric in Y)
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
// Face on A with normal +X, face on B with normal -X
|
|
int faceA = find_face_by_normal(doc, 0, Vec3d(1, 0, 0));
|
|
REQUIRE(faceA >= 0);
|
|
int faceB = find_face_by_normal(doc, 1, Vec3d(-1, 0, 0));
|
|
REQUIRE(faceB >= 0);
|
|
|
|
// Verify z_A != z_B (they point opposite)
|
|
Vec3d nA_pre = GeometryEngine::face_normal_world(
|
|
GeometryEngine::face_by_index(doc.bodies[0].shape, faceA));
|
|
Vec3d nB_pre = GeometryEngine::face_normal_world(
|
|
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB));
|
|
REQUIRE(nA_pre.dot(nB_pre) < -0.9); // antiparallel
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
doc.features[cs_fixed].coordsys_face = faceA;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
doc.features[cs_moving].coordsys_face = faceB;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Planar mate: z_A=+X, z_B=-X antiparallel. offset=0.
|
|
// With fix: 180° flip, then translated so z-distance=0.
|
|
// Without fix: no rotation, only translation — body X-centroid moves differently.
|
|
doc.add_mate(1, cs_fixed, cs_moving, 0.0, 0.0, false, "PlanarAnti");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// With the fix: 180° rotation about faceB centroid, then translation.
|
|
// Body B's X-extents are mirrored. After rotation, centroid X = 2*o_B.x - pre_cx = -20,
|
|
// then translated by offset ≈ 20mm → centroid returns near 0.
|
|
// Without the fix: no rotation, body just translates ~+20mm → centroid X ≈ 20.
|
|
GProp_GProps post_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
|
|
double post_cx = post_props.CentreOfMass().X();
|
|
REQUIRE_THAT(std::abs(post_cx), WithinAbs(0.0, 1e-4));
|
|
}
|
|
|
|
TEST_CASE("planar mate: in-plane pose preserved", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 10.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
GProp_GProps pre_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
|
|
gp_Pnt pre_com = pre_props.CentreOfMass();
|
|
|
|
// Both connectors use PointWorld (z=(0,0,1)). B's connector offset in X/Y from A's.
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(10, 10, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(7, 4, 10), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(1, cs_fixed, cs_moving, 0.0, 0.0, false, "PlanarSameZ");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps post_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
|
|
gp_Pnt post_com = post_props.CentreOfMass();
|
|
|
|
// In-plane (X,Y) components unchanged, only Z moves.
|
|
REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4));
|
|
REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4));
|
|
REQUIRE_THAT(double(post_com.Z()), !WithinAbs(double(pre_com.Z()), 1e-4));
|
|
}
|
|
|
|
TEST_CASE("mate with FaceAndDirection connectors on non-Z faces", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Body A: box 20x20x10
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Face on A with normal +Y
|
|
int faceA = find_face_by_normal(doc, 0, Vec3d(0, 1, 0));
|
|
REQUIRE(faceA >= 0);
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
doc.features[cs_fixed].coordsys_face = faceA;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Body B: box placed at a different location
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Face on B with normal +Y
|
|
int faceB = find_face_by_normal(doc, 1, Vec3d(0, 1, 0));
|
|
REQUIRE(faceB >= 0);
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
doc.features[cs_moving].coordsys_face = faceB;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Fastened mate: B's +Y face lands on A's +Y face, offset=0.
|
|
// Both normals are +Y so z_A = z_B = (0,1,0) — genuine rotation from frame composition.
|
|
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateY");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// After fastened mate, the two mated faces should be coincident:
|
|
// same centroid position along the normal (Y), and same centroid in X and Z
|
|
// (within face dimensions since they're different sizes).
|
|
Vec3d ca = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[0].shape, faceA));
|
|
Vec3d cb = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[1].shape, faceB));
|
|
REQUIRE_THAT(cb.x(), WithinAbs(ca.x(), 1e-4));
|
|
REQUIRE_THAT(cb.y(), WithinAbs(ca.y(), 1e-4));
|
|
REQUIRE_THAT(cb.z(), WithinAbs(ca.z(), 1e-4));
|
|
}
|
|
|
|
TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Body A: box
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Body B: tapered extrude (asymmetric, centroid not at geometric centre)
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
|
|
int ex_b = doc.add_extrude(sk_b, 8.0, false, BooleanMode::New, "EB");
|
|
doc.features[ex_b].taper_deg = 8.0;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// PointWorld connectors at distinct positions.
|
|
// A's connector on the top face centre, B's connector at a corner.
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(10, 10, 10), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(2, 3, 0), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Fastened, NO flip
|
|
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
GProp_GProps nf_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props);
|
|
double nf_z = nf_props.CentreOfMass().Z();
|
|
|
|
doc.undo();
|
|
|
|
// Fastened, WITH flip
|
|
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
GProp_GProps f_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, f_props);
|
|
double f_z = f_props.CentreOfMass().Z();
|
|
|
|
// Flip changes the centroid Z for an asymmetric body
|
|
REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4));
|
|
}
|
|
|
|
// --- M8b: Revolute / Slider / Cylindrical mates ---
|
|
|
|
TEST_CASE("revolute mate: position corrected, rotation about axis preserved", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Body A: reference box
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Body B: asymmetric box 20x10x5
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
// Pre-rotate B 30deg about Z, translate off-axis to (15, 2, 7)
|
|
doc.add_transform(1, Vec3d(15, 2, 7), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Record rotation: body B has a face that was originally +X, now at 30deg
|
|
auto faces_pre = GeometryEngine::faces_of(doc.bodies[1].shape);
|
|
REQUIRE(faces_pre.size() == 6);
|
|
Vec3d pre_x_face_normal;
|
|
bool found = false;
|
|
for (const auto& f : faces_pre) {
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
// The face that was originally +X now points ~(cos30, sin30, 0)
|
|
if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) {
|
|
pre_x_face_normal = n; found = true; break;
|
|
}
|
|
}
|
|
REQUIRE(found);
|
|
|
|
// Fixed connector on A at (5,5,5), world axes
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
// Moving connector on B: PointWorld at its current (transformed) centroid
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 7), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(2, cs_fixed, cs_moving, 0.0, 0.0, false, "Revolute");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Position corrected: body centroid moves to the axis line.
|
|
// Connector B (15,2,7) → (5,5,5); body centroid (15,2,9.5) → (5,5,7.5).
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Z()), WithinAbs(7.5, 1e-4));
|
|
|
|
// Rotation about Z preserved: the ~(0.866, 0.5, 0) face normal still exists
|
|
auto faces_post = GeometryEngine::faces_of(doc.bodies[1].shape);
|
|
REQUIRE(faces_post.size() == 6);
|
|
bool rot_preserved = false;
|
|
for (const auto& f : faces_post) {
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) {
|
|
rot_preserved = true; break;
|
|
}
|
|
}
|
|
REQUIRE(rot_preserved);
|
|
// Fastened would have aligned face normals to world axes: no face with Y≈0.5 would exist.
|
|
}
|
|
|
|
TEST_CASE("revolute mate: no-op when already correct", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Move body B to exactly the correct pose: on axis at (5,5,5) with no rotation
|
|
doc.add_transform(1, Vec3d(5, 5, 5), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps pre_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
|
|
gp_Pnt pre_com = pre_props.CentreOfMass();
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(2, cs_fixed, cs_moving, 0.0, 0.0, false, "RevoluteNoOp");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps post_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
|
|
gp_Pnt post_com = post_props.CentreOfMass();
|
|
REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4));
|
|
REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4));
|
|
REQUIRE_THAT(double(post_com.Z()), WithinAbs(double(pre_com.Z()), 1e-4));
|
|
}
|
|
|
|
TEST_CASE("revolute mate: mate_angle applies additional rotation", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Body B: asymmetric box, pre-rotated 30deg about Z, off-axis
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_transform(1, Vec3d(15, 2, 7), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 7), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// mate_angle=45deg on top of preserved 30deg → face originally +X now at 75deg
|
|
doc.add_mate(2, cs_fixed, cs_moving, 0.0, 45.0, false, "RevoluteAngle");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Position on axis line (same as revolute preservation test)
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Z()), WithinAbs(7.5, 1e-4));
|
|
|
|
// Rotation = 30deg (preserved) + 45deg (mate_angle) = 75deg → normal ≈ (cos75, sin75, 0)
|
|
auto faces = GeometryEngine::faces_of(doc.bodies[1].shape);
|
|
bool found_75 = false;
|
|
for (const auto& f : faces) {
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
if (std::abs(n.x() - 0.2588) < 0.02 && std::abs(n.y() - 0.9659) < 0.02) {
|
|
found_75 = true; break;
|
|
}
|
|
}
|
|
REQUIRE(found_75);
|
|
}
|
|
|
|
TEST_CASE("slider mate: rotation corrected, axial position preserved", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Pre-rotate B 30deg about Z, translate off-axis to (15, 2, 13)
|
|
doc.add_transform(1, Vec3d(15, 2, 13), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps pre_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
|
|
double pre_z = pre_props.CentreOfMass().Z();
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 13), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(3, cs_fixed, cs_moving, 0.0, 0.0, false, "Slider");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Perpendicular position corrected to the axis line (X=5, Y=5)
|
|
GProp_GProps post_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
|
|
gp_Pnt post_com = post_props.CentreOfMass();
|
|
REQUIRE_THAT(double(post_com.X()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(post_com.Y()), WithinAbs(5.0, 1e-4));
|
|
|
|
// Axial (Z) position preserved from pre-mate pose
|
|
REQUIRE_THAT(double(post_com.Z()), WithinAbs(pre_z, 1e-4));
|
|
// Fastened would have placed the body at Z=5. pre_z=15.5 (centroid), clearly different.
|
|
}
|
|
|
|
TEST_CASE("slider mate: mate_offset shifts axial position", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_transform(1, Vec3d(12, 3, 9), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(12, 3, 9), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Slider with mate_offset=4: body centroid at (12,3,11.5), connector at (12,3,9).
|
|
// Perpendicular corrected: X=5,Y=5. Axial: preserved Z 11.5 + offset 4 = 15.5.
|
|
doc.add_mate(3, cs_fixed, cs_moving, 4.0, 0.0, false, "SliderOffset");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Z()), WithinAbs(15.5, 1e-3));
|
|
}
|
|
|
|
TEST_CASE("slider mate: no-op when already correct", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Body already on axis at (5,5,20), no rotation
|
|
doc.add_transform(1, Vec3d(5, 5, 20), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps pre_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
|
|
gp_Pnt pre_com = pre_props.CentreOfMass();
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 20), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(3, cs_fixed, cs_moving, 0.0, 0.0, false, "SliderNoOp");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps post_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
|
|
gp_Pnt post_com = post_props.CentreOfMass();
|
|
REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4));
|
|
REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4));
|
|
REQUIRE_THAT(double(post_com.Z()), WithinAbs(double(pre_com.Z()), 1e-4));
|
|
}
|
|
|
|
TEST_CASE("cylindrical mate: perpendicular corrected, rotation and axial preserved", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Pre-rotate B 30deg about Z, translate off-axis to (15, 2, 13)
|
|
doc.add_transform(1, Vec3d(15, 2, 13), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps pre_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
|
|
double pre_z = pre_props.CentreOfMass().Z();
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 13), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(4, cs_fixed, cs_moving, 0.0, 0.0, false, "Cylindrical");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Perpendicular position corrected to axis line
|
|
GProp_GProps post_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
|
|
gp_Pnt com = post_props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
|
|
|
// Axial Z position preserved from pre-mate
|
|
REQUIRE_THAT(double(com.Z()), WithinAbs(pre_z, 1e-4));
|
|
|
|
// Rotation about Z preserved: face originally +X is still at 30deg
|
|
auto faces = GeometryEngine::faces_of(doc.bodies[1].shape);
|
|
REQUIRE(faces.size() == 6);
|
|
bool rot_preserved = false;
|
|
for (const auto& f : faces) {
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) {
|
|
rot_preserved = true; break;
|
|
}
|
|
}
|
|
REQUIRE(rot_preserved);
|
|
// Fastened would have aligned face normals to world axes and fixed Z.
|
|
}
|
|
|
|
TEST_CASE("cylindrical mate: mate_offset and mate_angle applied on top", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_transform(1, Vec3d(15, 2, 9), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "PrePose");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(15, 2, 9), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(4, cs_fixed, cs_moving, 3.0, 45.0, false, "CylOffsetAngle");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Position: perpendicular corrected, axial = 11.5 (centroid preserved) + 3 (offset) = 14.5
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Z()), WithinAbs(14.5, 1e-3));
|
|
|
|
// Rotation = 30deg (preserved) + 45deg (angle) = 75deg
|
|
auto faces = GeometryEngine::faces_of(doc.bodies[1].shape);
|
|
bool found_75 = false;
|
|
for (const auto& f : faces) {
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
if (std::abs(n.x() - 0.2588) < 0.02 && std::abs(n.y() - 0.9659) < 0.02) {
|
|
found_75 = true; break;
|
|
}
|
|
}
|
|
REQUIRE(found_75);
|
|
}
|
|
|
|
TEST_CASE("cylindrical mate: no-op when already correct", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 5.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Body already on axis at (5,5,20), no rotation
|
|
doc.add_transform(1, Vec3d(5, 5, 20), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "PrePose");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps pre_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, pre_props);
|
|
gp_Pnt pre_com = pre_props.CentreOfMass();
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(5, 5, 20), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_mate(4, cs_fixed, cs_moving, 0.0, 0.0, false, "CylNoOp");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
GProp_GProps post_props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, post_props);
|
|
gp_Pnt post_com = post_props.CentreOfMass();
|
|
REQUIRE_THAT(double(post_com.X()), WithinAbs(double(pre_com.X()), 1e-4));
|
|
REQUIRE_THAT(double(post_com.Y()), WithinAbs(double(pre_com.Y()), 1e-4));
|
|
REQUIRE_THAT(double(post_com.Z()), WithinAbs(double(pre_com.Z()), 1e-4));
|
|
}
|
|
|
|
TEST_CASE("revolute mate with FaceAndDirection on non-Z faces", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Body A: box 20x20x10
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Face on A with normal +Y
|
|
int faceA = find_face_by_normal(doc, 0, Vec3d(0, 1, 0));
|
|
REQUIRE(faceA >= 0);
|
|
Vec3d nA = GeometryEngine::face_normal_world(
|
|
GeometryEngine::face_by_index(doc.bodies[0].shape, faceA));
|
|
|
|
// Body B: asymmetric box 20x10x5, also with +Y face
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int faceB = find_face_by_normal(doc, 1, Vec3d(0, 1, 0));
|
|
REQUIRE(faceB >= 0);
|
|
Vec3d nB_pre = GeometryEngine::face_normal_world(
|
|
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB));
|
|
REQUIRE_THAT(nB_pre.dot(nA), WithinAbs(1.0, 1e-4));
|
|
|
|
// Pre-rotate B about Y (the face normal) by 30deg to give it a non-trivial rotation about its connector z
|
|
doc.add_transform(1, Vec3d(0, 0, 0), Vec3d(0, 1, 0), Vec3d(0, 0, 0), 30.0, false, "PrePose");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// FaceAndDirection on A's +Y face
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
doc.features[cs_fixed].coordsys_face = faceA;
|
|
// FaceAndDirection on B's +Y face
|
|
int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0,0,0), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
doc.features[cs_moving].coordsys_face = faceB;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Record face normals on B before mate (to verify rotation preservation)
|
|
// +30deg rotation about Y maps +Z=(0,0,1) → (0.5, 0, 0.866), -Z → (-0.5, 0, -0.866)
|
|
auto faces_pre = GeometryEngine::faces_of(doc.bodies[1].shape);
|
|
Vec3d pre_rotated_normal;
|
|
{
|
|
bool fnd = false;
|
|
for (const auto& f : faces_pre) {
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
if (std::abs(n.z()) > 0.85 && std::abs(n.x()) > 0.45 && std::abs(n.y()) < 0.02) {
|
|
pre_rotated_normal = n; fnd = true; break;
|
|
}
|
|
}
|
|
REQUIRE(fnd);
|
|
}
|
|
|
|
doc.add_mate(2, cs_fixed, cs_moving, 0.0, 0.0, false, "RevoluteFaceDir");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Position: faces are coincident (same as fastened position since no offset and axes aligned)
|
|
Vec3d ca = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[0].shape, faceA));
|
|
Vec3d cb = GeometryEngine::face_centroid_world(GeometryEngine::face_by_index(doc.bodies[1].shape, faceB));
|
|
REQUIRE_THAT(cb.x(), WithinAbs(ca.x(), 1e-4));
|
|
REQUIRE_THAT(cb.y(), WithinAbs(ca.y(), 1e-4));
|
|
REQUIRE_THAT(cb.z(), WithinAbs(ca.z(), 1e-4));
|
|
|
|
// Rotation about the connector z (which is +Y) is preserved:
|
|
// the face with |z| ≈ 0.866, |x| ≈ 0.5, y ≈ 0 must still exist.
|
|
auto faces_post = GeometryEngine::faces_of(doc.bodies[1].shape);
|
|
bool rot_preserved = false;
|
|
for (const auto& f : faces_post) {
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
if (std::abs(n.z()) > 0.85 && std::abs(n.x()) > 0.45 && std::abs(n.y()) < 0.02) {
|
|
rot_preserved = true; break;
|
|
}
|
|
}
|
|
REQUIRE(rot_preserved);
|
|
// Fastened would force all axes to align: no face with Z≈0.87 would exist.
|
|
}
|
|
|
|
// --- M8b: rotation-coverage hole — FaceAndDirection captures body orientation ---
|
|
|
|
TEST_CASE("slider mate with FaceAndDirection corrects rotation", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Body A: box 20x20x10
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int faceA = find_face_by_normal(doc, 0, Vec3d(0, 0, 1));
|
|
REQUIRE(faceA >= 0);
|
|
|
|
// Body B: asymmetric box 20x10x5
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int faceB = find_face_by_normal(doc, 1, Vec3d(0, 0, 1));
|
|
REQUIRE(faceB >= 0);
|
|
|
|
// Pre-rotate B: first 20deg about X (tilts +Z face normal off-axis, so the
|
|
// connector z genuinely differs from A's), then 30deg about Z (adds rotation
|
|
// about the mate axis that Slider must correct and Cylindrical must preserve).
|
|
doc.add_transform(1, Vec3d(14, 3, 7), Vec3d(1, 0, 0), Vec3d(0, 0, 0), 20.0, false, "RotX");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_transform(1, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "RotZ");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Both connectors pin their in-plane direction to an edge of their own body, so each
|
|
// frame follows its body's spin. Without this the frames' x comes from the world hint
|
|
// and the 30deg spin is invisible to the mate — see find_edge_on_face.
|
|
int edgeA = find_edge_on_face(doc, 0, faceA);
|
|
int edgeB = find_edge_on_face(doc, 1, faceB);
|
|
REQUIRE(edgeA >= 0);
|
|
REQUIRE(edgeB >= 0);
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
doc.features[cs_fixed].coordsys_face = faceA;
|
|
doc.features[cs_fixed].coordsys_edge = edgeA;
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
doc.features[cs_moving].coordsys_face = faceB;
|
|
doc.features[cs_moving].coordsys_edge = edgeB;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Record the pre-mate axial position of the CONNECTOR, not of the centroid. Slider
|
|
// rotates the body about the connector origin to correct the 20deg tilt, and that
|
|
// rotation legitimately moves the centroid in Z (by -d*(1-cos20) for a centroid d
|
|
// below the mated face) while leaving the connector itself where it is. The DOF
|
|
// Slider preserves is the connector's position along the axis.
|
|
const double pre_conn_z = GeometryEngine::face_centroid_world(
|
|
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z();
|
|
|
|
doc.add_mate(3, cs_fixed, cs_moving, 0.0, 0.0, false, "SliderFaceDir");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Position: perpendicular corrected to axis line through A's +Z face centroid.
|
|
// The +Z face of a 20x20x10 box centred at origin has centroid at (0, 0, 10).
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.X()), WithinAbs(0.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Y()), WithinAbs(0.0, 1e-4));
|
|
// Axial position of the connector preserved — Slider does not own that DOF.
|
|
const double post_conn_z = GeometryEngine::face_centroid_world(
|
|
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z();
|
|
REQUIRE_THAT(post_conn_z, WithinAbs(pre_conn_z, 1e-4));
|
|
|
|
// Rotation: Slider owns this DOF — all face normals must be axis-aligned.
|
|
auto faces = GeometryEngine::faces_of(doc.bodies[1].shape);
|
|
REQUIRE(faces.size() == 6);
|
|
int axis_aligned = 0;
|
|
for (const auto& f : faces) {
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
double d = std::max({std::abs(n.x()), std::abs(n.y()), std::abs(n.z())});
|
|
if (d > 0.999) ++axis_aligned;
|
|
}
|
|
REQUIRE(axis_aligned == 6);
|
|
// Dropping rotation correction would leave 30deg Z rotation — at least 2
|
|
// faces would have normals not aligned to any world axis.
|
|
}
|
|
|
|
TEST_CASE("cylindrical mate with FaceAndDirection preserves rotation", "[CadDocument][mate]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// Body A: box 20x20x10
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int faceA = find_face_by_normal(doc, 0, Vec3d(0, 0, 1));
|
|
REQUIRE(faceA >= 0);
|
|
|
|
// Body B: asymmetric box 20x10x5
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 5.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
int faceB = find_face_by_normal(doc, 1, Vec3d(0, 0, 1));
|
|
REQUIRE(faceB >= 0);
|
|
|
|
// Same two-step pre-rotation as the slider test.
|
|
doc.add_transform(1, Vec3d(14, 3, 7), Vec3d(1, 0, 0), Vec3d(0, 0, 0), 20.0, false, "RotX");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
doc.add_transform(1, Vec3d(0, 0, 0), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 30.0, false, "RotZ");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Edge-pinned frames, as in the Slider case. These matter here for the opposite
|
|
// reason: with a world-derived in-plane direction, a Cylindrical that WRONGLY aligned
|
|
// the spin would still leave the body's 30deg face at 30deg, and the assertion below
|
|
// would pass for the wrong implementation.
|
|
int edgeA = find_edge_on_face(doc, 0, faceA);
|
|
int edgeB = find_edge_on_face(doc, 1, faceB);
|
|
REQUIRE(edgeA >= 0);
|
|
REQUIRE(edgeB >= 0);
|
|
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
doc.features[cs_fixed].coordsys_face = faceA;
|
|
doc.features[cs_fixed].coordsys_edge = edgeA;
|
|
|
|
int cs_moving = doc.add_coordsys(CoordSysType::FaceAndDirection, Vec3d(0, 0, 0), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
doc.features[cs_moving].coordsys_face = faceB;
|
|
doc.features[cs_moving].coordsys_edge = edgeB;
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// As in the Slider case, the preserved DOF is the CONNECTOR's position along the
|
|
// axis, not the centroid's: aligning the 20deg tilt rotates the body about the
|
|
// connector origin, which moves the centroid in Z by design.
|
|
const double pre_conn_z_c = GeometryEngine::face_centroid_world(
|
|
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z();
|
|
|
|
doc.add_mate(4, cs_fixed, cs_moving, 0.0, 0.0, false, "CylFaceDir");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
// Position: perpendicular corrected to axis line (X=0, Y=0 at +Z face centroid)
|
|
GProp_GProps props;
|
|
BRepGProp::VolumeProperties(doc.bodies[1].shape, props);
|
|
gp_Pnt com = props.CentreOfMass();
|
|
REQUIRE_THAT(double(com.X()), WithinAbs(0.0, 1e-4));
|
|
REQUIRE_THAT(double(com.Y()), WithinAbs(0.0, 1e-4));
|
|
// Axial position of the connector preserved — Cylindrical leaves that DOF free.
|
|
const double post_conn_z_c = GeometryEngine::face_centroid_world(
|
|
GeometryEngine::face_by_index(doc.bodies[1].shape, faceB)).z();
|
|
REQUIRE_THAT(post_conn_z_c, WithinAbs(pre_conn_z_c, 1e-4));
|
|
|
|
// Rotation about Z (30deg) is preserved: the 20deg X-rotation was undone
|
|
// by z-alignment, leaving the 30deg Z-rotation intact.
|
|
// The +X face normal should be at ~(cos30, sin30, 0).
|
|
auto faces = GeometryEngine::faces_of(doc.bodies[1].shape);
|
|
REQUIRE(faces.size() == 6);
|
|
bool rot_preserved = false;
|
|
for (const auto& f : faces) {
|
|
Vec3d n = GeometryEngine::face_normal_world(f);
|
|
if (std::abs(n.x() - 0.866) < 0.02 && std::abs(n.y() - 0.5) < 0.02) {
|
|
rot_preserved = true; break;
|
|
}
|
|
}
|
|
REQUIRE(rot_preserved);
|
|
// If cylindrical behaved like slider, the face would be at (1,0,0) instead.
|
|
}
|
|
|
|
// --- Interference detection (M8c) ---
|
|
|
|
TEST_CASE("interference: overlapping bodies reported with overlap volume", "[CadDocument][interference]")
|
|
{
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
CadDocument doc;
|
|
// A: 20x20x10 box centred on the origin in XY, z in [0,10]
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
|
// B: 20x20x10 box, same footprint
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 10.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
|
|
// Lift B by 6mm: the two overlap over 4mm of height => 20*20*4 = 1600 mm^3.
|
|
doc.add_transform(1, Vec3d(0, 0, 6), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "LiftB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
auto hits = doc.check_interference();
|
|
REQUIRE(hits.size() == 1);
|
|
REQUIRE(hits[0].body_a == 0);
|
|
REQUIRE(hits[0].body_b == 1);
|
|
REQUIRE_THAT(hits[0].volume, WithinAbs(1600.0, 1e-3));
|
|
}
|
|
|
|
TEST_CASE("interference: disjoint and merely touching bodies are not reported", "[CadDocument][interference]")
|
|
{
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 10.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
|
|
SECTION("clearly apart") {
|
|
doc.add_transform(1, Vec3d(0, 0, 50), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "MoveB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.check_interference().empty());
|
|
}
|
|
|
|
SECTION("face-to-face contact encloses no volume") {
|
|
// B sits exactly on top of A: they share a face but nothing overlaps.
|
|
doc.add_transform(1, Vec3d(0, 0, 10), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "StackB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.check_interference().empty());
|
|
}
|
|
}
|
|
|
|
TEST_CASE("interference: sheet bodies are skipped", "[CadDocument][interference]")
|
|
{
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
|
// A sheet passing straight through the solid: it has no volume, so no interference.
|
|
int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 0, "Sheet");
|
|
doc.add_surface_extrude(sk_s, 5.0, "SE");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 2);
|
|
REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape));
|
|
|
|
REQUIRE(doc.check_interference().empty());
|
|
}
|
|
|
|
TEST_CASE("interference: reports every overlapping pair", "[CadDocument][interference]")
|
|
{
|
|
CadDocument doc;
|
|
for (int i = 0; i < 3; ++i) {
|
|
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "Box");
|
|
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "E");
|
|
}
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.bodies.size() == 3);
|
|
|
|
// 0 and 1 stay coincident (overlapping); 2 is moved clear of both.
|
|
doc.add_transform(2, Vec3d(0, 0, 100), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "MoveC");
|
|
REQUIRE(doc.recompute());
|
|
|
|
auto hits = doc.check_interference();
|
|
REQUIRE(hits.size() == 1);
|
|
REQUIRE(hits[0].body_a == 0);
|
|
REQUIRE(hits[0].body_b == 1);
|
|
|
|
// min_volume gates the report: a threshold above the overlap silences it.
|
|
REQUIRE(doc.check_interference(1e9).empty());
|
|
}
|
|
|
|
TEST_CASE("interference: detects a clash created by a mate", "[CadDocument][interference]")
|
|
{
|
|
CadDocument doc;
|
|
int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA");
|
|
doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA");
|
|
int sk_b = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxB");
|
|
doc.add_extrude(sk_b, 10.0, false, BooleanMode::New, "EB");
|
|
REQUIRE(doc.recompute());
|
|
|
|
// Park B well clear, so the clash below is created by the mate and nothing else.
|
|
doc.add_transform(1, Vec3d(0, 0, 80), Vec3d(0, 0, 1), Vec3d(0, 0, 0), 0.0, false, "ParkB");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.check_interference().empty());
|
|
|
|
// Fastened mate onto a connector inside A's volume drives B into A.
|
|
int cs_fixed = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Fixed");
|
|
doc.features[cs_fixed].coordsys_body = 0;
|
|
int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 85), "CS_Moving");
|
|
doc.features[cs_moving].coordsys_body = 1;
|
|
REQUIRE(doc.recompute());
|
|
|
|
doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "Clash");
|
|
REQUIRE(doc.recompute());
|
|
REQUIRE(doc.error.empty());
|
|
|
|
auto hits = doc.check_interference();
|
|
REQUIRE(hits.size() == 1);
|
|
REQUIRE(hits[0].volume > 1.0);
|
|
} |