mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-18 14:32:36 +00:00
checkpoint() snapshotted `features` and undo() restored `features`, but
`variables` is a separate member of CadDocument. Every caller of the documented
checkpoint -> mutate -> recompute -> undo-on-failure pattern therefore failed to
roll a variable back: the bad value stayed in the document and every later
recompute failed, which is exactly the corruption the pattern exists to prevent.
Feature `expr` bindings were unaffected only because expr lives inside
CadFeature and rode along in the features snapshot — which is why the feature
side appeared to work.
This was a kernel gap, not a GUI one: McpControl::action_set_variable has the
same sequence and was equally broken.
The undo/redo stacks now hold a {features, variables} Snapshot. Nothing here is
serialized, so no recipe version change and no golden-fixture regeneration.
Two tests, both verified to FAIL against a faithful reproduction of the bug
(undo() leaving `variables` untouched) at test_caddocument.cpp:4420 and :4441:
one covers restoring a variable's previous value, the other covers removing a
variable that did not exist before the checkpoint. Worth recording that the
first mutation attempt was NOT faithful — it dropped the restore but kept
std::move(variables) into the redo stack, which empties the map as a side effect
and made the second test pass for the wrong reason. A mutation has to reproduce
the original defect, not merely break the code.
Second defect, same area: undo() calls recompute(), which succeeds and clears
doc.error, so the reason an edit was rejected was destroyed before anything
could display it. Six sites — four in DesignPanel, two in McpControl — now carry
the message across the rollback. on_remove_variable additionally asserted
"referenced by a feature expression" as fact; it now offers that as the likely
cause and appends the real error, since that diagnosis is wrong for any other
failure.
Suite 141 cases / 1972 assertions (was 139/1960). GUI compiles clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6403 lines
249 KiB
C++
6403 lines
249 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
|
|
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 = 0;
|
|
doc.features[ax].axis_plane_b = 1;
|
|
|
|
auto axes = doc.resolve_datum_axes();
|
|
REQUIRE(axes.size() == 1);
|
|
REQUIRE_FALSE(axes[0].error.empty());
|
|
}
|
|
|
|
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 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);
|
|
} |