feat(v3.9): parallel OpenMP boolean + assembly instancing + update notifier
This commit is contained in:
@@ -10,6 +10,7 @@ option(BUILD_EXAMPLES "Build examples" ON)
|
|||||||
option(ENABLE_SANITIZERS "Enable ASan" OFF)
|
option(ENABLE_SANITIZERS "Enable ASan" OFF)
|
||||||
option(VDE_USE_GMP "GMP exact arithmetic" OFF)
|
option(VDE_USE_GMP "GMP exact arithmetic" OFF)
|
||||||
option(VDE_BUILD_PYTHON "Python bindings" OFF)
|
option(VDE_BUILD_PYTHON "Python bindings" OFF)
|
||||||
|
option(VDE_USE_OPENMP "Enable OpenMP parallelization" ON)
|
||||||
|
|
||||||
add_library(vde_compile_options INTERFACE)
|
add_library(vde_compile_options INTERFACE)
|
||||||
include(cmake/CompilerSettings.cmake)
|
include(cmake/CompilerSettings.cmake)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "vde/brep/assembly.h"
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace vde::brep {
|
||||||
|
|
||||||
|
/// Shared geometry instance (for repeated parts)
|
||||||
|
struct GeometryInstance {
|
||||||
|
std::string part_name;
|
||||||
|
BrepModel geometry; ///< shared geometry data
|
||||||
|
size_t use_count = 0; ///< number of references to this geometry
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Instance manager for large assemblies.
|
||||||
|
*
|
||||||
|
* Deduplicates repeated parts by reusing shared geometry.
|
||||||
|
* Reduces memory and improves cache coherence for assemblies
|
||||||
|
* with thousands of identical components (fasteners, links, panels).
|
||||||
|
*
|
||||||
|
* @ingroup brep
|
||||||
|
*/
|
||||||
|
class AssemblyInstancer {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Register a geometry for instancing.
|
||||||
|
* @param name Part name identifier
|
||||||
|
* @param geom Geometry to register
|
||||||
|
* @return instance ID, reuses existing if same name & geometry matches
|
||||||
|
*/
|
||||||
|
int register_instance(const std::string& name, const BrepModel& geom);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Create an occurrence of a registered instance.
|
||||||
|
* @param assy Target assembly
|
||||||
|
* @param parent Parent node (nullptr = root)
|
||||||
|
* @param instance_id Instance to place
|
||||||
|
* @param transform Local transform for this occurrence
|
||||||
|
*/
|
||||||
|
void place_instance(Assembly& assy, AssemblyNode* parent,
|
||||||
|
int instance_id, const Transform3D& transform);
|
||||||
|
|
||||||
|
/// Total unique geometries
|
||||||
|
[[nodiscard]] size_t unique_count() const { return instances_.size(); }
|
||||||
|
|
||||||
|
/// Total placed occurrences
|
||||||
|
[[nodiscard]] size_t total_placed() const { return total_placed_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<GeometryInstance> instances_;
|
||||||
|
size_t total_placed_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace vde::brep
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
#include "vde/brep/feature_tree.h"
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace vde::brep {
|
||||||
|
|
||||||
|
/// Callback type for model changes.
|
||||||
|
/// Receives the feature name and the new model after rebuild.
|
||||||
|
using UpdateCallback = std::function<void(const std::string& feature_name, const BrepModel& new_model)>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Notifies listeners when features change.
|
||||||
|
*
|
||||||
|
* Integrates with FeatureHistory: after each apply/undo/redo,
|
||||||
|
* call notify() to push the updated model to all registered callbacks.
|
||||||
|
*
|
||||||
|
* @ingroup brep
|
||||||
|
*/
|
||||||
|
class UpdateNotifier {
|
||||||
|
public:
|
||||||
|
/// Register a callback. Called on every notify().
|
||||||
|
void on_update(UpdateCallback cb) { callbacks_.push_back(std::move(cb)); }
|
||||||
|
|
||||||
|
/// Notify all registered listeners.
|
||||||
|
void notify(const std::string& feature, const BrepModel& model) {
|
||||||
|
for (auto& cb : callbacks_) cb(feature, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove all callbacks.
|
||||||
|
void clear() { callbacks_.clear(); }
|
||||||
|
|
||||||
|
/// Number of registered callbacks.
|
||||||
|
[[nodiscard]] size_t listener_count() const { return callbacks_.size(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<UpdateCallback> callbacks_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace vde::brep
|
||||||
@@ -158,6 +158,7 @@ add_library(vde_brep STATIC
|
|||||||
brep/assembly_constraints.cpp
|
brep/assembly_constraints.cpp
|
||||||
brep/measure.cpp
|
brep/measure.cpp
|
||||||
brep/trimmed_surface.cpp
|
brep/trimmed_surface.cpp
|
||||||
|
brep/assembly_instance.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(vde_brep
|
target_include_directories(vde_brep
|
||||||
PUBLIC ${CMAKE_SOURCE_DIR}/include
|
PUBLIC ${CMAKE_SOURCE_DIR}/include
|
||||||
@@ -219,3 +220,15 @@ if(VDE_USE_GMP)
|
|||||||
else()
|
else()
|
||||||
message(STATUS "GMP disabled (-DVDE_USE_GMP=ON to enable)")
|
message(STATUS "GMP disabled (-DVDE_USE_GMP=ON to enable)")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# ── OpenMP parallelization ──
|
||||||
|
if(VDE_USE_OPENMP)
|
||||||
|
find_package(OpenMP)
|
||||||
|
if(OpenMP_CXX_FOUND)
|
||||||
|
target_link_libraries(vde_brep PUBLIC OpenMP::OpenMP_CXX)
|
||||||
|
target_compile_definitions(vde_brep PUBLIC VDE_USE_OPENMP)
|
||||||
|
message(STATUS "OpenMP enabled")
|
||||||
|
else()
|
||||||
|
message(STATUS "OpenMP not found (parallelism disabled)")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#include "vde/brep/assembly_instance.h"
|
||||||
|
|
||||||
|
namespace vde::brep {
|
||||||
|
|
||||||
|
int AssemblyInstancer::register_instance(const std::string& name, const BrepModel& geom) {
|
||||||
|
// Check for existing match: same name + similar bounding box
|
||||||
|
for (size_t i = 0; i < instances_.size(); ++i) {
|
||||||
|
if (instances_[i].part_name == name) {
|
||||||
|
auto bb1 = instances_[i].geometry.bounds();
|
||||||
|
auto bb2 = geom.bounds();
|
||||||
|
if ((bb1.min() - bb2.min()).norm() < 1e-3) {
|
||||||
|
instances_[i].use_count++;
|
||||||
|
return static_cast<int>(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instances_.push_back({name, geom, 1});
|
||||||
|
return static_cast<int>(instances_.size() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AssemblyInstancer::place_instance(Assembly& assy, AssemblyNode* parent,
|
||||||
|
int instance_id, const Transform3D& transform) {
|
||||||
|
if (instance_id < 0 || instance_id >= static_cast<int>(instances_.size())) return;
|
||||||
|
auto& inst = instances_[instance_id];
|
||||||
|
if (parent) {
|
||||||
|
parent->add_part(inst.part_name, inst.geometry, transform);
|
||||||
|
} else {
|
||||||
|
assy.root.add_part(inst.part_name, inst.geometry, transform);
|
||||||
|
}
|
||||||
|
total_placed_++;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace vde::brep
|
||||||
@@ -11,6 +11,11 @@
|
|||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <limits>
|
#include <limits>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
#ifdef VDE_USE_OPENMP
|
||||||
|
#include <omp.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace vde::brep {
|
namespace vde::brep {
|
||||||
|
|
||||||
@@ -562,8 +567,12 @@ BrepModel brep_union(const BrepModel& a, const BrepModel& b) {
|
|||||||
// then classify fragments. Keep OUT and ON (from A).
|
// then classify fragments. Keep OUT and ON (from A).
|
||||||
// For each face of B: same against A, keep only OUT.
|
// For each face of B: same against A, keep only OUT.
|
||||||
std::vector<BrepModel> keep;
|
std::vector<BrepModel> keep;
|
||||||
|
std::mutex keep_mutex;
|
||||||
|
|
||||||
// A faces: SSI-split against B, classify, keep OUT/ON
|
// A faces: SSI-split against B, classify, keep OUT/ON
|
||||||
|
#ifdef VDE_USE_OPENMP
|
||||||
|
#pragma omp parallel for schedule(dynamic)
|
||||||
|
#endif
|
||||||
for (size_t fia = 0; fia < a.num_faces(); ++fia) {
|
for (size_t fia = 0; fia < a.num_faces(); ++fia) {
|
||||||
std::vector<BrepModel> fragments;
|
std::vector<BrepModel> fragments;
|
||||||
fragments.push_back(extract_face_fragment(a, static_cast<int>(fia)));
|
fragments.push_back(extract_face_fragment(a, static_cast<int>(fia)));
|
||||||
@@ -589,11 +598,17 @@ BrepModel brep_union(const BrepModel& a, const BrepModel& b) {
|
|||||||
// Classify each fragment against B
|
// Classify each fragment against B
|
||||||
for (auto& frag : fragments) {
|
for (auto& frag : fragments) {
|
||||||
auto cls = classify_face_fragment(frag, b);
|
auto cls = classify_face_fragment(frag, b);
|
||||||
if (cls == OUT || cls == ON) keep.push_back(std::move(frag));
|
if (cls == OUT || cls == ON) {
|
||||||
|
std::lock_guard<std::mutex> lock(keep_mutex);
|
||||||
|
keep.push_back(std::move(frag));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// B faces: SSI-split against A, classify, keep OUT only (ON from A already)
|
// B faces: SSI-split against A, classify, keep OUT only (ON from A already)
|
||||||
|
#ifdef VDE_USE_OPENMP
|
||||||
|
#pragma omp parallel for schedule(dynamic)
|
||||||
|
#endif
|
||||||
for (size_t fib = 0; fib < b.num_faces(); ++fib) {
|
for (size_t fib = 0; fib < b.num_faces(); ++fib) {
|
||||||
std::vector<BrepModel> fragments;
|
std::vector<BrepModel> fragments;
|
||||||
fragments.push_back(extract_face_fragment(b, static_cast<int>(fib)));
|
fragments.push_back(extract_face_fragment(b, static_cast<int>(fib)));
|
||||||
@@ -615,7 +630,10 @@ BrepModel brep_union(const BrepModel& a, const BrepModel& b) {
|
|||||||
|
|
||||||
for (auto& frag : fragments) {
|
for (auto& frag : fragments) {
|
||||||
auto cls = classify_face_fragment(frag, a);
|
auto cls = classify_face_fragment(frag, a);
|
||||||
if (cls == OUT) keep.push_back(std::move(frag));
|
if (cls == OUT) {
|
||||||
|
std::lock_guard<std::mutex> lock(keep_mutex);
|
||||||
|
keep.push_back(std::move(frag));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,8 +652,12 @@ BrepModel brep_intersection(const BrepModel& a, const BrepModel& b) {
|
|||||||
// For intersection: keep fragments IN the other body,
|
// For intersection: keep fragments IN the other body,
|
||||||
// and fragments ON the boundary (only from A to avoid duplicates).
|
// and fragments ON the boundary (only from A to avoid duplicates).
|
||||||
std::vector<BrepModel> keep;
|
std::vector<BrepModel> keep;
|
||||||
|
std::mutex keep_mutex;
|
||||||
|
|
||||||
// A faces: SSI-split against B, classify, keep IN/ON
|
// A faces: SSI-split against B, classify, keep IN/ON
|
||||||
|
#ifdef VDE_USE_OPENMP
|
||||||
|
#pragma omp parallel for schedule(dynamic)
|
||||||
|
#endif
|
||||||
for (size_t fia = 0; fia < a.num_faces(); ++fia) {
|
for (size_t fia = 0; fia < a.num_faces(); ++fia) {
|
||||||
std::vector<BrepModel> fragments;
|
std::vector<BrepModel> fragments;
|
||||||
fragments.push_back(extract_face_fragment(a, static_cast<int>(fia)));
|
fragments.push_back(extract_face_fragment(a, static_cast<int>(fia)));
|
||||||
@@ -657,11 +679,17 @@ BrepModel brep_intersection(const BrepModel& a, const BrepModel& b) {
|
|||||||
|
|
||||||
for (auto& frag : fragments) {
|
for (auto& frag : fragments) {
|
||||||
auto cls = classify_face_fragment(frag, b);
|
auto cls = classify_face_fragment(frag, b);
|
||||||
if (cls == IN || cls == ON) keep.push_back(std::move(frag));
|
if (cls == IN || cls == ON) {
|
||||||
|
std::lock_guard<std::mutex> lock(keep_mutex);
|
||||||
|
keep.push_back(std::move(frag));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// B faces: SSI-split against A, classify, keep IN only
|
// B faces: SSI-split against A, classify, keep IN only
|
||||||
|
#ifdef VDE_USE_OPENMP
|
||||||
|
#pragma omp parallel for schedule(dynamic)
|
||||||
|
#endif
|
||||||
for (size_t fib = 0; fib < b.num_faces(); ++fib) {
|
for (size_t fib = 0; fib < b.num_faces(); ++fib) {
|
||||||
std::vector<BrepModel> fragments;
|
std::vector<BrepModel> fragments;
|
||||||
fragments.push_back(extract_face_fragment(b, static_cast<int>(fib)));
|
fragments.push_back(extract_face_fragment(b, static_cast<int>(fib)));
|
||||||
@@ -683,7 +711,10 @@ BrepModel brep_intersection(const BrepModel& a, const BrepModel& b) {
|
|||||||
|
|
||||||
for (auto& frag : fragments) {
|
for (auto& frag : fragments) {
|
||||||
auto cls = classify_face_fragment(frag, a);
|
auto cls = classify_face_fragment(frag, a);
|
||||||
if (cls == IN) keep.push_back(std::move(frag));
|
if (cls == IN) {
|
||||||
|
std::lock_guard<std::mutex> lock(keep_mutex);
|
||||||
|
keep.push_back(std::move(frag));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,8 +735,12 @@ BrepModel brep_difference(const BrepModel& a, const BrepModel& b) {
|
|||||||
// - Keep A fragments that are OUT of B (discard IN and ON)
|
// - Keep A fragments that are OUT of B (discard IN and ON)
|
||||||
// - Keep B fragments that are IN A (forms the inner cut surface)
|
// - Keep B fragments that are IN A (forms the inner cut surface)
|
||||||
std::vector<BrepModel> keep;
|
std::vector<BrepModel> keep;
|
||||||
|
std::mutex keep_mutex;
|
||||||
|
|
||||||
// A faces: SSI-split against B, classify, keep OUT
|
// A faces: SSI-split against B, classify, keep OUT
|
||||||
|
#ifdef VDE_USE_OPENMP
|
||||||
|
#pragma omp parallel for schedule(dynamic)
|
||||||
|
#endif
|
||||||
for (size_t fia = 0; fia < a.num_faces(); ++fia) {
|
for (size_t fia = 0; fia < a.num_faces(); ++fia) {
|
||||||
std::vector<BrepModel> fragments;
|
std::vector<BrepModel> fragments;
|
||||||
fragments.push_back(extract_face_fragment(a, static_cast<int>(fia)));
|
fragments.push_back(extract_face_fragment(a, static_cast<int>(fia)));
|
||||||
@@ -727,11 +762,17 @@ BrepModel brep_difference(const BrepModel& a, const BrepModel& b) {
|
|||||||
|
|
||||||
for (auto& frag : fragments) {
|
for (auto& frag : fragments) {
|
||||||
auto cls = classify_face_fragment(frag, b);
|
auto cls = classify_face_fragment(frag, b);
|
||||||
if (cls == OUT) keep.push_back(std::move(frag));
|
if (cls == OUT) {
|
||||||
|
std::lock_guard<std::mutex> lock(keep_mutex);
|
||||||
|
keep.push_back(std::move(frag));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// B faces: SSI-split against A, classify, keep IN (forms inner cut surface)
|
// B faces: SSI-split against A, classify, keep IN (forms inner cut surface)
|
||||||
|
#ifdef VDE_USE_OPENMP
|
||||||
|
#pragma omp parallel for schedule(dynamic)
|
||||||
|
#endif
|
||||||
for (size_t fib = 0; fib < b.num_faces(); ++fib) {
|
for (size_t fib = 0; fib < b.num_faces(); ++fib) {
|
||||||
std::vector<BrepModel> fragments;
|
std::vector<BrepModel> fragments;
|
||||||
fragments.push_back(extract_face_fragment(b, static_cast<int>(fib)));
|
fragments.push_back(extract_face_fragment(b, static_cast<int>(fib)));
|
||||||
@@ -753,7 +794,10 @@ BrepModel brep_difference(const BrepModel& a, const BrepModel& b) {
|
|||||||
|
|
||||||
for (auto& frag : fragments) {
|
for (auto& frag : fragments) {
|
||||||
auto cls = classify_face_fragment(frag, a);
|
auto cls = classify_face_fragment(frag, a);
|
||||||
if (cls == IN) keep.push_back(std::move(frag));
|
if (cls == IN) {
|
||||||
|
std::lock_guard<std::mutex> lock(keep_mutex);
|
||||||
|
keep.push_back(std::move(frag));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,4 @@ add_vde_test(test_assembly_constraints)
|
|||||||
add_vde_test(test_trimmed_surface)
|
add_vde_test(test_trimmed_surface)
|
||||||
add_vde_test(test_brep_heal)
|
add_vde_test(test_brep_heal)
|
||||||
add_vde_test(test_brep_drawing)
|
add_vde_test(test_brep_drawing)
|
||||||
|
add_vde_test(test_assembly_instance)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
#include "vde/brep/assembly_instance.h"
|
||||||
|
#include "vde/brep/modeling.h"
|
||||||
|
|
||||||
|
using namespace vde::brep;
|
||||||
|
|
||||||
|
TEST(AssemblyInstancer, IdenticalBoxesReturnSameID) {
|
||||||
|
AssemblyInstancer instancer;
|
||||||
|
|
||||||
|
auto box1 = make_box(10, 5, 3);
|
||||||
|
auto box2 = make_box(10, 5, 3);
|
||||||
|
|
||||||
|
int id1 = instancer.register_instance("bolt", box1);
|
||||||
|
int id2 = instancer.register_instance("bolt", box2);
|
||||||
|
|
||||||
|
EXPECT_EQ(id1, id2);
|
||||||
|
EXPECT_EQ(instancer.unique_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(AssemblyInstancer, DifferentBoxesReturnDifferentIDs) {
|
||||||
|
AssemblyInstancer instancer;
|
||||||
|
|
||||||
|
auto box1 = make_box(10, 5, 3);
|
||||||
|
auto box2 = make_box(5, 5, 5);
|
||||||
|
|
||||||
|
int id1 = instancer.register_instance("bolt", box1);
|
||||||
|
int id2 = instancer.register_instance("bolt", box2);
|
||||||
|
|
||||||
|
EXPECT_NE(id1, id2);
|
||||||
|
EXPECT_EQ(instancer.unique_count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(AssemblyInstancer, DifferentNamesYieldDifferentIDs) {
|
||||||
|
AssemblyInstancer instancer;
|
||||||
|
|
||||||
|
auto box = make_box(10, 5, 3);
|
||||||
|
|
||||||
|
int id1 = instancer.register_instance("bolt", box);
|
||||||
|
int id2 = instancer.register_instance("screw", box);
|
||||||
|
|
||||||
|
EXPECT_NE(id1, id2);
|
||||||
|
EXPECT_EQ(instancer.unique_count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(AssemblyInstancer, PlaceInstancesCountsCorrectly) {
|
||||||
|
AssemblyInstancer instancer;
|
||||||
|
auto box = make_box(2, 2, 2);
|
||||||
|
int id = instancer.register_instance("cell", box);
|
||||||
|
|
||||||
|
Assembly assy("grid");
|
||||||
|
|
||||||
|
for (int i = 0; i < 100; ++i) {
|
||||||
|
auto xform = Transform3D::Translation(i % 10, i / 10, 0);
|
||||||
|
instancer.place_instance(assy, nullptr, id, xform);
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_EQ(instancer.total_placed(), 100);
|
||||||
|
EXPECT_EQ(instancer.unique_count(), 1);
|
||||||
|
EXPECT_EQ(assy.part_count(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(AssemblyInstancer, PlaceInstanceWithParent) {
|
||||||
|
AssemblyInstancer instancer;
|
||||||
|
auto box = make_box(1, 1, 1);
|
||||||
|
int id = instancer.register_instance("leaf", box);
|
||||||
|
|
||||||
|
Assembly assy("tree");
|
||||||
|
|
||||||
|
// Place a child under a subassembly
|
||||||
|
auto* sub = assy.root.add_subassembly("sub", Transform3D::Identity());
|
||||||
|
auto xform = Transform3D::Translation(5, 0, 0);
|
||||||
|
EXPECT_NO_THROW(instancer.place_instance(assy, sub, id, xform));
|
||||||
|
|
||||||
|
EXPECT_EQ(instancer.total_placed(), 1);
|
||||||
|
EXPECT_EQ(assy.root.children.size(), 1);
|
||||||
|
EXPECT_EQ(assy.root.children[0]->name, "sub");
|
||||||
|
EXPECT_EQ(assy.root.children[0]->children.size(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(AssemblyInstancer, InvalidInstanceIdNoOp) {
|
||||||
|
AssemblyInstancer instancer;
|
||||||
|
Assembly assy("empty");
|
||||||
|
|
||||||
|
EXPECT_NO_THROW(instancer.place_instance(assy, nullptr, -1, Transform3D::Identity()));
|
||||||
|
EXPECT_NO_THROW(instancer.place_instance(assy, nullptr, 999, Transform3D::Identity()));
|
||||||
|
EXPECT_EQ(instancer.total_placed(), 0);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user