Project
Loading...
Searching...
No Matches
testBVHSurfaceSolid.cxx
Go to the documentation of this file.
1// Copyright 2019-2026 CERN and copyright holders of ALICE O2.
2// See https://alice-o2.web.cern.ch/copyright for details of the copyright holders.
3// All rights not expressly granted are reserved.
4//
5// This software is distributed under the terms of the GNU General Public
6// License v3 (GPL Version 3), copied verbatim in the file "COPYING".
7//
8// In applying this license CERN does not waive the privileges and immunities
9// granted to it by virtue of its status as an Intergovernmental Organization
10// or submit itself to any jurisdiction.
13
14#define BOOST_TEST_MODULE Test O2BVHSurfaceSolid class
15#define BOOST_TEST_MAIN
16#define BOOST_TEST_DYN_LINK
17#include <boost/test/unit_test.hpp>
18
22
23#include "../src/BoundedSurface.h"
24
25#include "TFile.h"
26#include "TGeoBBox.h"
27#include "TGeoBoolNode.h"
28#include "TGeoCompositeShape.h"
29#include "TGeoCone.h"
30#include "TGeoManager.h"
31#include "TGeoMaterial.h"
32#include "TGeoMatrix.h"
33#include "TGeoMedium.h"
34#include "TGeoNode.h"
35#include "TGeoShape.h"
36#include "TGeoSphere.h"
37#include "TGeoTorus.h"
38#include "TGeoTube.h"
39#include "TGeoVolume.h"
40#include "TMath.h"
41#include "TNamed.h"
42
43#include <array>
44#include <cmath>
45#include <cstdint>
46#include <filesystem>
47#include <fstream>
48#include <initializer_list>
49#include <string>
50#include <memory>
51#include <utility>
52#include <vector>
53
54namespace
55{
56using SurfaceSolid = o2::cad::O2BVHSurfaceSolid;
57using Point2D = SurfaceSolid::Point2D;
58using Point3D = SurfaceSolid::Point3D;
59namespace surf = o2::cad::surface;
60
61std::vector<Point2D> rectangleWire(double extentU, double extentV)
62{
63 return {{0., 0.}, {extentU, 0.}, {extentU, extentV}, {0., extentV}};
64}
65
66using BoundaryCurve = SurfaceSolid::PlanarBoundaryCurve;
67
68// A full-circle boundary wire centred at (0,0) as a single +/-2pi arc (clockwise for holes).
69std::vector<BoundaryCurve> circleWire(double radius, bool clockwise = false)
70{
71 return {BoundaryCurve::makeArc({0., 0.}, radius, 0., clockwise ? -surf::kTwoPi : surf::kTwoPi)};
72}
73
74// A rectangular trim loop in a quadric's (u, v) parametric domain, as four line boundary curves
75// (u = phi, v = height or theta). Wound counter-clockwise; the kernel reorients as needed.
76std::vector<BoundaryCurve> paramRectWire(double uMin, double uMax, double vMin, double vMax)
77{
78 return {BoundaryCurve::makeLine({uMin, vMin}, {uMax, vMin}), BoundaryCurve::makeLine({uMax, vMin}, {uMax, vMax}),
79 BoundaryCurve::makeLine({uMax, vMax}, {uMin, vMax}), BoundaryCurve::makeLine({uMin, vMax}, {uMin, vMin})};
80}
81
82// Same rectangle as paramRectWire but as internal Curve2D segments, for direct kernel-level tests.
83std::vector<surf::Curve2D> paramRectWireCurves(double uMin, double uMax, double vMin, double vMax)
84{
85 return {surf::Curve2D::makeLine({uMin, vMin}, {uMax, vMin}), surf::Curve2D::makeLine({uMax, vMin}, {uMax, vMax}),
86 surf::Curve2D::makeLine({uMax, vMax}, {uMin, vMax}), surf::Curve2D::makeLine({uMin, vMax}, {uMin, vMin})};
87}
88
89// Add a planar disk (or annulus when holeRadius > 0) via the general curved-planar API,
90// replacing the retired AddPlanarDiskSurface convenience.
91bool addDiskSurface(SurfaceSolid& solid, const Point3D& center, const Point3D& axisU, const Point3D& axisV,
92 double radius, double holeRadius = 0.)
93{
94 std::vector<std::vector<BoundaryCurve>> inners;
95 if (holeRadius > 0.) {
96 inners.push_back(circleWire(holeRadius, true)); // clockwise hole: no reorientation needed
97 }
98 return solid.AddCurvedPlanarSurface(center, axisU, axisV, circleWire(radius), inners);
99}
100
101// Local frame (origin + parametric axes + rectangle extents) of a box face by index
102// (0:+x 1:-x 2:+y 3:-y 4:+z 5:-z), for a box centred at the origin.
103struct FaceFrame {
104 Point3D origin;
105 Point3D axisU;
106 Point3D axisV;
107 double extentU;
108 double extentV;
109};
110
111FaceFrame boxFaceFrame(int faceIndex, double halfX, double halfY, double halfZ)
112{
113 switch (faceIndex) {
114 case 0:
115 return {{halfX, -halfY, -halfZ}, {0., 1., 0.}, {0., 0., 1.}, 2. * halfY, 2. * halfZ};
116 case 1:
117 return {{-halfX, -halfY, -halfZ}, {0., 0., 1.}, {0., 1., 0.}, 2. * halfZ, 2. * halfY};
118 case 2:
119 return {{-halfX, halfY, -halfZ}, {0., 0., 1.}, {1., 0., 0.}, 2. * halfZ, 2. * halfX};
120 case 3:
121 return {{-halfX, -halfY, -halfZ}, {1., 0., 0.}, {0., 0., 1.}, 2. * halfX, 2. * halfZ};
122 case 4:
123 return {{-halfX, -halfY, halfZ}, {1., 0., 0.}, {0., 1., 0.}, 2. * halfX, 2. * halfY};
124 default:
125 return {{-halfX, -halfY, -halfZ}, {0., 1., 0.}, {1., 0., 0.}, 2. * halfY, 2. * halfX};
126 }
127}
128
129// Add a single box face by index of a box centred at "center". When "reversed" is set the
130// face's parametric axes are swapped, which flips the outward normal inward without changing
131// the covered rectangle - used to build an orientation-inconsistent fixture.
132bool addBoxFace(SurfaceSolid& solid, int faceIndex, double halfX, double halfY, double halfZ, bool reversed = false,
133 const Point3D& center = {0., 0., 0.})
134{
135 FaceFrame frame = boxFaceFrame(faceIndex, halfX, halfY, halfZ);
136 if (reversed) {
137 std::swap(frame.axisU, frame.axisV);
138 std::swap(frame.extentU, frame.extentV);
139 }
140 for (int dimension = 0; dimension < 3; ++dimension) {
141 frame.origin[dimension] += center[dimension];
142 }
143 return solid.AddPlanarSurface(frame.origin, frame.axisU, frame.axisV, rectangleWire(frame.extentU, frame.extentV));
144}
145
146void addBoxSurfaces(SurfaceSolid& solid, double halfX, double halfY, double halfZ,
147 const Point3D& center = {0., 0., 0.})
148{
149 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
150 BOOST_REQUIRE(addBoxFace(solid, faceIndex, halfX, halfY, halfZ, false, center));
151 }
152}
153
154surf::SurfaceWire makeWire(const std::vector<surf::Vec2>& vertices, surf::WireRole role, surf::WireStatus& status)
155{
157 wire.initialize(vertices, role, status);
158 return wire;
159}
160
161void checkClose(double value, double reference, double tolerance = 1.e-9)
162{
163 BOOST_CHECK_SMALL(value - reference, tolerance);
164}
165
166std::array<double, 3> unitDirection(double x, double y, double z)
167{
168 const double length = std::sqrt(x * x + y * y + z * z);
169 return {x / length, y / length, z / length};
170}
171
172// Compare Contains against a reference ROOT shape on a regular grid. The fractional offsets keep
173// grid points away from exact shape boundaries, where inside/outside conventions may differ.
174void compareContainsGrid(const SurfaceSolid& solid, const TGeoShape& reference, double extent, int samples)
175{
176 for (int stepX = 0; stepX < samples; ++stepX) {
177 for (int stepY = 0; stepY < samples; ++stepY) {
178 for (int stepZ = 0; stepZ < samples; ++stepZ) {
179 const double point[3] = {-extent + 2. * extent * (stepX + 0.517) / samples,
180 -extent + 2. * extent * (stepY + 0.263) / samples,
181 -extent + 2. * extent * (stepZ + 0.741) / samples};
182 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
183 {
184 BOOST_CHECK_EQUAL(solid.Contains(point), reference.Contains(point));
185 }
186 }
187 }
188 }
189}
190
191// Compare the direction-appropriate distance function against a reference ROOT shape.
192void compareDistance(const SurfaceSolid& solid, const TGeoShape& reference, const std::array<double, 3>& point,
193 const std::array<double, 3>& direction, double tolerance = 1.e-9)
194{
195 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ") direction = ("
196 << direction[0] << ", " << direction[1] << ", " << direction[2] << ")")
197 {
198 const bool inside = reference.Contains(point.data());
199 BOOST_CHECK_EQUAL(solid.Contains(point.data()), inside);
200 if (inside) {
201 checkClose(solid.DistFromInside(point.data(), direction.data(), 3),
202 reference.DistFromInside(point.data(), direction.data(), 3), tolerance);
203 } else {
204 checkClose(solid.DistFromOutside(point.data(), direction.data(), 3),
205 reference.DistFromOutside(point.data(), direction.data(), 3), tolerance);
206 }
207 }
208}
209
216
217std::unique_ptr<SurfaceSolid> makeBoxSolid(const char* name, double halfX, double halfY, double halfZ)
218{
219 auto solid = std::make_unique<SurfaceSolid>(name);
220 addBoxSurfaces(*solid, halfX, halfY, halfZ);
221 solid->CloseShape();
222 return solid;
223}
224
225// innerRadius > 0 gives a hollow tube (an inner wall plus annular caps).
226std::unique_ptr<SurfaceSolid> makeTubeSolid(const char* name, double innerRadius, double outerRadius,
227 double halfHeight)
228{
229 auto solid = std::make_unique<SurfaceSolid>(name);
230 BOOST_REQUIRE(solid->AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, outerRadius, -halfHeight,
231 halfHeight));
232 if (innerRadius > 0.) {
233 BOOST_REQUIRE(solid->AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, innerRadius, -halfHeight,
234 halfHeight, 0., surf::kTwoPi, true));
235 }
236 BOOST_REQUIRE(addDiskSurface(*solid, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, outerRadius, innerRadius));
237 BOOST_REQUIRE(addDiskSurface(*solid, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, outerRadius, innerRadius));
238 solid->CloseShape();
239 return solid;
240}
241
242std::unique_ptr<SurfaceSolid> makeConeSolid(const char* name, double radiusAtBottom, double radiusAtTop,
243 double halfHeight)
244{
245 auto solid = std::make_unique<SurfaceSolid>(name);
246 BOOST_REQUIRE(solid->AddConicalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radiusAtBottom, radiusAtTop,
247 -halfHeight, halfHeight));
248 BOOST_REQUIRE(addDiskSurface(*solid, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radiusAtTop));
249 BOOST_REQUIRE(addDiskSurface(*solid, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radiusAtBottom));
250 solid->CloseShape();
251 return solid;
252}
253
254std::unique_ptr<SurfaceSolid> makeSphereSolid(const char* name, double radius)
255{
256 auto solid = std::make_unique<SurfaceSolid>(name);
257 BOOST_REQUIRE(solid->AddSphericalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius));
258 solid->CloseShape();
259 return solid;
260}
261
262std::unique_ptr<SurfaceSolid> makeTorusSolid(const char* name, double majorRadius, double minorRadius)
263{
264 auto solid = std::make_unique<SurfaceSolid>(name);
265 BOOST_REQUIRE(solid->AddToroidalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, majorRadius, minorRadius));
266 solid->CloseShape();
267 return solid;
268}
269
270// Cylinder barrel closed by two hemispherical endcaps; a mixed-quadric solid with no ROOT
271// primitive equivalent, so the loop oracle is the only reference it has.
272std::unique_ptr<SurfaceSolid> makeCapsuleSolid(const char* name, double radius, double halfHeight)
273{
274 auto solid = std::make_unique<SurfaceSolid>(name);
275 BOOST_REQUIRE(solid->AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -halfHeight,
276 halfHeight));
277 BOOST_REQUIRE(solid->AddSphericalSurface({0., 0., halfHeight}, {0., 0., 1.}, {1., 0., 0.}, radius, 0.,
278 surf::kPi / 2.));
279 BOOST_REQUIRE(solid->AddSphericalSurface({0., 0., -halfHeight}, {0., 0., -1.}, {1., 0., 0.}, radius, 0.,
280 surf::kPi / 2.));
281 solid->CloseShape();
282 return solid;
283}
284
286
287// Directions probed by the navigation sweeps: the three axes both ways, face and body diagonals,
288// and a few skew directions that align with no symmetry of any fixture.
289const std::vector<std::array<double, 3>>& probeDirections()
290{
291 static const std::vector<std::array<double, 3>> directions{
292 {1., 0., 0.}, {-1., 0., 0.}, {0., 1., 0.}, {0., -1., 0.}, {0., 0., 1.}, {0., 0., -1.}, unitDirection(1., 1., 0.), unitDirection(1., 0., 1.), unitDirection(0., 1., 1.), unitDirection(1., 1., 1.), unitDirection(-1., 1., -1.), unitDirection(0.37, -0.82, 0.44), unitDirection(-0.91, 0.13, 0.39), unitDirection(0.21, 0.55, -0.81)};
293 return directions;
294}
295
296// A deterministic point grid over the cube of half-side "extent". The fractional offsets are the
297// same irrational-looking shifts the Contains sweeps use, which keeps samples off exact symmetry
298// planes and shape boundaries.
299std::vector<std::array<double, 3>> probeGrid(double extent, int samples)
300{
301 std::vector<std::array<double, 3>> points;
302 points.reserve(static_cast<size_t>(samples) * samples * samples);
303 for (int stepX = 0; stepX < samples; ++stepX) {
304 for (int stepY = 0; stepY < samples; ++stepY) {
305 for (int stepZ = 0; stepZ < samples; ++stepZ) {
306 points.push_back({-extent + 2. * extent * (stepX + 0.517) / samples,
307 -extent + 2. * extent * (stepY + 0.263) / samples,
308 -extent + 2. * extent * (stepZ + 0.741) / samples});
309 }
310 }
311 }
312 return points;
313}
314
323void checkDistanceAgainstLoop(const SurfaceSolid& solid, const std::array<double, 3>& point,
324 const std::array<double, 3>& direction, double stepmax = TGeoShape::Big())
325{
326 const double loopOutside = solid.DistFromOutside_Loop(point.data(), direction.data(), stepmax);
327 const double loopInside = solid.DistFromInside_Loop(point.data(), direction.data(), stepmax);
328 for (const bool pruning : {true, false}) {
329 SurfaceSolid::SetRayTMaxPruning(pruning);
330 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ") direction = ("
331 << direction[0] << ", " << direction[1] << ", " << direction[2]
332 << ") stepmax = " << stepmax << " pruning = " << pruning)
333 {
334 BOOST_CHECK_EQUAL(solid.DistFromOutside(point.data(), direction.data(), 3, stepmax), loopOutside);
335 BOOST_CHECK_EQUAL(solid.DistFromInside(point.data(), direction.data(), 3, stepmax), loopInside);
336 }
337 }
338 SurfaceSolid::SetRayTMaxPruning(true);
339}
340
341// Sweep every grid point against every probe direction, cross-checking BVH against the loop.
342void sweepDistanceAgainstLoop(const SurfaceSolid& solid, double extent, int samples)
343{
344 for (const auto& point : probeGrid(extent, samples)) {
345 for (const auto& direction : probeDirections()) {
346 checkDistanceAgainstLoop(solid, point, direction);
347 }
348 }
349}
350
351// Sweep both distance functions against a reference ROOT primitive, using each point in the role
352// (inside/outside) the reference itself assigns it. Points closer than "skin" to the reference
353// boundary are skipped: there the two shapes may legitimately disagree on which side the point is
354// on, and the resulting distances are then answers to different questions.
355void sweepDistanceAgainstReference(const SurfaceSolid& solid, const TGeoShape& reference, double extent, int samples,
356 double tolerance = 1.e-9, double skin = 1.e-6)
357{
358 for (const auto& point : probeGrid(extent, samples)) {
359 const bool inside = reference.Contains(point.data());
360 if (reference.Safety(point.data(), inside) < skin) {
361 continue;
362 }
363 for (const auto& direction : probeDirections()) {
364 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ") direction = ("
365 << direction[0] << ", " << direction[1] << ", " << direction[2] << ")")
366 {
367 if (inside) {
368 checkClose(solid.DistFromInside(point.data(), direction.data(), 3),
369 reference.DistFromInside(point.data(), direction.data(), 3), tolerance);
370 } else {
371 checkClose(solid.DistFromOutside(point.data(), direction.data(), 3),
372 reference.DistFromOutside(point.data(), direction.data(), 3), tolerance);
373 }
374 }
375 }
376 }
377}
378
379} // namespace
380
381BOOST_AUTO_TEST_CASE(PlanarBoxNavigationMatchesTGeoBBox)
382{
383 constexpr double halfX = 1.;
384 constexpr double halfY = 2.;
385 constexpr double halfZ = 3.;
386
387 SurfaceSolid solid("planarBox");
388 addBoxSurfaces(solid, halfX, halfY, halfZ);
389 solid.CloseShape();
390
391 TGeoBBox reference("referenceBox", halfX, halfY, halfZ);
392
393 BOOST_CHECK(solid.IsDefined());
394 BOOST_CHECK_EQUAL(solid.GetNsurfaces(), 6);
395
396 int meshVertices = 0;
397 int meshSegments = 0;
398 int meshPolygons = 0;
399 solid.GetMeshNumbers(meshVertices, meshSegments, meshPolygons);
400 BOOST_CHECK_EQUAL(meshVertices, 24);
401 BOOST_CHECK_EQUAL(meshSegments, 36);
402 BOOST_CHECK_EQUAL(meshPolygons, 12);
403
404 const std::array<std::array<double, 3>, 5> insidePoints{{{0., 0., 0.}, {0.9, 0., 0.}, {1., 0., 0.}, {1., 2., 3.}, {-1., -2., -3.}}};
405 for (const auto& point : insidePoints) {
406 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
407 {
408 BOOST_CHECK(solid.Contains(point.data()));
409 BOOST_CHECK(reference.Contains(point.data()));
410 }
411 }
412
413 const std::array<std::array<double, 3>, 4> outsidePoints{{{1.1, 0., 0.}, {0., 2.1, 0.}, {0., 0., -3.1}, {2., 3., 4.}}};
414 for (const auto& point : outsidePoints) {
415 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
416 {
417 BOOST_CHECK(!solid.Contains(point.data()));
418 BOOST_CHECK(!reference.Contains(point.data()));
419 }
420 }
421
422 const double fromLeft[3] = {-3., 0., 0.};
423 const double toRight[3] = {1., 0., 0.};
424 checkClose(solid.DistFromOutside(fromLeft, toRight, 3), reference.DistFromOutside(fromLeft, toRight, 3));
425
426 const double fromFront[3] = {0., -5., 0.};
427 const double toBack[3] = {0., 1., 0.};
428 checkClose(solid.DistFromOutside(fromFront, toBack, 3), reference.DistFromOutside(fromFront, toBack, 3));
429
430 const double fromCenter[3] = {0., 0., 0.};
431 const double alongX[3] = {1., 0., 0.};
432 const double alongZ[3] = {0., 0., -1.};
433 checkClose(solid.DistFromInside(fromCenter, alongX, 3), reference.DistFromInside(fromCenter, alongX, 3));
434 checkClose(solid.DistFromInside(fromCenter, alongZ, 3), reference.DistFromInside(fromCenter, alongZ, 3));
435
436 // safeties against analytic distances (TGeo safeties may be weaker underestimates)
437 const double outsideSafetyPoint[3] = {2.5, 0., 0.};
438 checkClose(solid.Safety(fromCenter, kTRUE), halfX);
439 checkClose(solid.Safety(outsideSafetyPoint, kFALSE), 2.5 - halfX);
440
441 const double normalPoint[3] = {halfX, 0., 0.};
442 double normal[3] = {0., 0., 0.};
443 solid.ComputeNormal(normalPoint, alongX, normal);
444 checkClose(normal[0], 1.);
445 checkClose(normal[1], 0.);
446 checkClose(normal[2], 0.);
447
448 checkClose(solid.Capacity(), 8. * halfX * halfY * halfZ);
449
450 BOOST_CHECK(solid.IsClosed());
451 BOOST_CHECK(solid.IsOrientationConsistent());
452}
453
454BOOST_AUTO_TEST_CASE(WireValidationAndOrientation)
455{
456 using surf::WireRole;
457 using surf::WireStatus;
458
459 // outer square given clockwise (negative area) must be re-oriented to CCW
460 WireStatus reversedStatus = WireStatus::Valid;
461 auto reversedOuter = makeWire({{0., 0.}, {0., 1.}, {1., 1.}, {1., 0.}}, WireRole::Outer, reversedStatus);
462 BOOST_CHECK(reversedStatus == WireStatus::Reversed);
463 BOOST_CHECK_GT(reversedOuter.signedArea(), 0.);
464
465 // outer square already CCW stays valid
466 WireStatus outerStatus = WireStatus::Valid;
467 auto outerWire = makeWire({{0., 0.}, {1., 0.}, {1., 1.}, {0., 1.}}, WireRole::Outer, outerStatus);
468 BOOST_CHECK(outerStatus == WireStatus::Valid);
469 BOOST_CHECK_GT(outerWire.signedArea(), 0.);
470
471 // inner (hole) wire must end up clockwise (negative area)
472 WireStatus innerStatus = WireStatus::Valid;
473 auto innerWire = makeWire({{0., 0.}, {1., 0.}, {1., 1.}, {0., 1.}}, WireRole::Inner, innerStatus);
474 BOOST_CHECK(innerStatus == WireStatus::Reversed);
475 BOOST_CHECK_LT(innerWire.signedArea(), 0.);
476
477 // degenerate / invalid inputs are rejected with a specific status
478 surf::SurfaceWire scratch;
479 WireStatus status = WireStatus::Valid;
480 BOOST_CHECK(!scratch.initialize({{0., 0.}, {1., 0.}}, WireRole::Outer, status));
481 BOOST_CHECK(status == WireStatus::TooFewVertices);
482
483 BOOST_CHECK(!scratch.initialize({{0., 0.}, {1., 0.}, {2., 0.}}, WireRole::Outer, status));
484 BOOST_CHECK(status == WireStatus::ZeroArea);
485
486 // self-touching (pinched) loop: a non-adjacent vertex repeats
487 BOOST_CHECK(!scratch.initialize({{0., 0.}, {1., 0.}, {0., 0.}, {1., 1.}}, WireRole::Outer, status));
488 BOOST_CHECK(status == WireStatus::DegenerateVertex);
489
490 // explicit edge list that does not close is flagged as open
491 const std::vector<surf::SurfaceEdge> openEdges{{{0., 0.}, {1., 0.}}, {{1., 0.}, {1., 1.}}, {{1., 1.}, {0.5, 0.5}}};
492 BOOST_CHECK(!scratch.initializeFromEdges(openEdges, WireRole::Outer, status));
493 BOOST_CHECK(status == WireStatus::Open);
494
495 // a closed edge list is accepted
496 const std::vector<surf::SurfaceEdge> closedEdges{
497 {{0., 0.}, {1., 0.}}, {{1., 0.}, {1., 1.}}, {{1., 1.}, {0., 1.}}, {{0., 1.}, {0., 0.}}};
498 BOOST_CHECK(scratch.initializeFromEdges(closedEdges, WireRole::Outer, status));
499
500 // point classification: inside, outside, and on-edge
501 BOOST_CHECK(outerWire.classify({0.5, 0.5}) == surf::WireClassification::Inside);
502 BOOST_CHECK(outerWire.classify({1.5, 0.5}) == surf::WireClassification::Outside);
503 BOOST_CHECK(outerWire.classify({0.5, 0.}) == surf::WireClassification::Boundary);
504}
505
506namespace o2::cad::surface
507{
510{
511 public:
512 DummyBoundedSurface(const Vec3& firstVertex, const Vec3& secondVertex, const Vec3& thirdVertex)
513 : mVertices{firstVertex, secondVertex, thirdVertex}
514 {
515 mNormal = normalized(cross(secondVertex - firstVertex, thirdVertex - firstVertex));
516 }
517
518 void conservativeBounds(Vec3& lower, Vec3& upper) const override
519 {
520 for (const auto& vertex : mVertices) {
521 lower.xCoord = std::min(lower.xCoord, vertex.xCoord);
522 lower.yCoord = std::min(lower.yCoord, vertex.yCoord);
523 lower.zCoord = std::min(lower.zCoord, vertex.zCoord);
524 upper.xCoord = std::max(upper.xCoord, vertex.xCoord);
525 upper.yCoord = std::max(upper.yCoord, vertex.yCoord);
526 upper.zCoord = std::max(upper.zCoord, vertex.zCoord);
527 }
528 }
529
530 bool containsPointOnSurface(const Vec3&) const override { return false; }
531
532 void appendIntersections(const Vec3&, const Vec3&, double, double, std::vector<RayHit>&) const override {}
533
534 double distanceSqToPatch(const Vec3& point) const override
535 {
536 double bestDistanceSq = std::numeric_limits<double>::infinity();
537 for (int vertexIndex = 0; vertexIndex < 3; ++vertexIndex) {
538 bestDistanceSq = std::min(bestDistanceSq, pointSegmentDistanceSq(point, mVertices[vertexIndex],
539 mVertices[(vertexIndex + 1) % 3]));
540 }
541 return bestDistanceSq;
542 }
543
544 Vec3 normalAt(const Vec3&) const override { return mNormal; }
545
547 void parametricMetric(const Vec2&, double& gUU, double& gUV, double& gVV) const override
548 {
549 gUU = 1.;
550 gUV = 0.;
551 gVV = 1.;
552 }
553
554 double capacityContribution() const override { return 0.; }
555
556 bool capacityIsExact() const override { return false; }
557
558 void appendDisplayMesh(std::vector<Vec3>& vertices, std::vector<std::array<int, 3>>& triangles) const override
559 {
560 const int firstVertexIndex = static_cast<int>(vertices.size());
561 for (const auto& vertex : mVertices) {
562 vertices.push_back(vertex);
563 }
564 triangles.push_back({firstVertexIndex, firstVertexIndex + 1, firstVertexIndex + 2});
565 }
566
567 void appendDirectedEdges(std::vector<std::pair<Vec3, Vec3>>& edges) const override
568 {
569 for (int vertexIndex = 0; vertexIndex < 3; ++vertexIndex) {
570 edges.emplace_back(mVertices[vertexIndex], mVertices[(vertexIndex + 1) % 3]);
571 }
572 }
573
574 private:
575 std::array<Vec3, 3> mVertices;
576 Vec3 mNormal;
577};
578} // namespace o2::cad::surface
579
580BOOST_AUTO_TEST_CASE(DummyBoundedSurfaceInterface)
581{
582 auto dummy = std::make_unique<surf::DummyBoundedSurface>(surf::Vec3{0., 0., 0.}, surf::Vec3{1., 0., 0.},
583 surf::Vec3{0., 1., 0.});
584
585 surf::Vec3 lower{surf::Vec3{1.e30, 1.e30, 1.e30}};
586 surf::Vec3 upper{surf::Vec3{-1.e30, -1.e30, -1.e30}};
587 dummy->conservativeBounds(lower, upper);
588 checkClose(lower.xCoord, 0.);
589 checkClose(upper.xCoord, 1.);
590 checkClose(upper.yCoord, 1.);
591
592 const surf::Vec3 normal = dummy->normalAt({0., 0., 0.});
593 checkClose(std::abs(normal.zCoord), 1.);
594 BOOST_CHECK(!dummy->capacityIsExact());
595
596 std::vector<surf::Vec3> vertices;
597 std::vector<std::array<int, 3>> triangles;
598 dummy->appendDisplayMesh(vertices, triangles);
599 BOOST_CHECK_EQUAL(vertices.size(), 3u);
600 BOOST_CHECK_EQUAL(triangles.size(), 1u);
601
602 // a single open triangle is not a closed manifold
603 std::vector<std::unique_ptr<surf::BoundedSurface>> surfaces;
604 surfaces.emplace_back(std::move(dummy));
606 BOOST_CHECK(!report.closed);
607 BOOST_CHECK_EQUAL(report.boundaryEdges, 3);
608}
609
610BOOST_AUTO_TEST_CASE(SolidClosureDetectsMissingAndReversedFaces)
611{
612 constexpr double halfX = 1.;
613 constexpr double halfY = 1.5;
614 constexpr double halfZ = 2.;
615
616 // missing face: only five of the six box faces are added
617 SurfaceSolid missing("missingFaceBox");
618 for (int faceIndex = 1; faceIndex < 6; ++faceIndex) {
619 BOOST_REQUIRE(addBoxFace(missing, faceIndex, halfX, halfY, halfZ));
620 }
621 missing.CloseShape(false);
622 BOOST_CHECK(!missing.IsClosed());
623
624 // reversed face: the +x face keeps its geometry but has an inward normal
625 SurfaceSolid reversed("reversedFaceBox");
626 BOOST_REQUIRE(addBoxFace(reversed, 0, halfX, halfY, halfZ, true));
627 for (int faceIndex = 1; faceIndex < 6; ++faceIndex) {
628 BOOST_REQUIRE(addBoxFace(reversed, faceIndex, halfX, halfY, halfZ));
629 }
630 reversed.CloseShape(false);
631 BOOST_CHECK(reversed.IsClosed());
632 BOOST_CHECK(!reversed.IsOrientationConsistent());
633}
634
635// The queryable navigation-reliability state.
636// A caller must be able to ask "can I trust this solid's navigation answers" and get a single
637// answer, rather than having to notice a printed warning; the state must also survive being
638// closed with check==false, since diagnostics and reporting are separate concerns.
639BOOST_AUTO_TEST_CASE(NavigationReliabilityIsQueryable)
640{
641 using Reliability = SurfaceSolid::NavigationReliability;
642 constexpr double halfX = 1.;
643 constexpr double halfY = 1.5;
644 constexpr double halfZ = 2.;
645
646 // before CloseShape there are no diagnostics at all
647 SurfaceSolid fresh("freshBox");
648 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
649 BOOST_REQUIRE(addBoxFace(fresh, faceIndex, halfX, halfY, halfZ));
650 }
651 BOOST_CHECK(fresh.GetNavigationReliability() == Reliability::Undetermined);
652 BOOST_CHECK(!fresh.IsNavigable());
653
654 fresh.CloseShape(false);
655 BOOST_CHECK(fresh.GetNavigationReliability() == Reliability::Reliable);
656 BOOST_CHECK(fresh.IsNavigable());
657 BOOST_CHECK_EQUAL(fresh.GetBoundaryEdgeCount(), 0);
658 BOOST_CHECK_EQUAL(fresh.GetNonManifoldEdgeCount(), 0);
659 BOOST_CHECK_EQUAL(fresh.GetReversedEdgeCount(), 0);
660
661 // a missing face leaves boundary edges: the gap case that motivates the whole state
662 SurfaceSolid missing("missingFaceBoxState");
663 for (int faceIndex = 1; faceIndex < 6; ++faceIndex) {
664 BOOST_REQUIRE(addBoxFace(missing, faceIndex, halfX, halfY, halfZ));
665 }
666 missing.CloseShape(false);
667 BOOST_CHECK(missing.GetNavigationReliability() == Reliability::OpenSurfaceSet);
668 BOOST_CHECK(!missing.IsNavigable());
669 BOOST_CHECK(missing.GetBoundaryEdgeCount() > 0);
670
671 // a reversed face is closed but inconsistently oriented
672 SurfaceSolid reversed("reversedFaceBoxState");
673 BOOST_REQUIRE(addBoxFace(reversed, 0, halfX, halfY, halfZ, true));
674 for (int faceIndex = 1; faceIndex < 6; ++faceIndex) {
675 BOOST_REQUIRE(addBoxFace(reversed, faceIndex, halfX, halfY, halfZ));
676 }
677 reversed.CloseShape(false);
678 BOOST_CHECK(reversed.GetNavigationReliability() == Reliability::ReversedFaces);
679 BOOST_CHECK(!reversed.IsNavigable());
680 BOOST_CHECK(reversed.GetReversedEdgeCount() > 0);
681
682 // duplicated faces: every edge is now shared by four faces. Non-manifold outranks the boundary
683 // and orientation cases because parity is not even order-independent on such input.
684 SurfaceSolid duplicated("duplicatedFaceBox");
685 for (int pass = 0; pass < 2; ++pass) {
686 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
687 BOOST_REQUIRE(addBoxFace(duplicated, faceIndex, halfX, halfY, halfZ));
688 }
689 }
690 duplicated.CloseShape(false);
691 BOOST_CHECK(duplicated.GetNavigationReliability() == Reliability::NonManifold);
692 BOOST_CHECK(!duplicated.IsNavigable());
693 BOOST_CHECK(duplicated.GetNonManifoldEdgeCount() > 0);
694
695 BOOST_CHECK_EQUAL(std::string(SurfaceSolid::GetNavigationReliabilityName(Reliability::Reliable)), "reliable");
696 BOOST_CHECK_EQUAL(std::string(SurfaceSolid::GetNavigationReliabilityName(Reliability::OpenSurfaceSet)),
697 "open-surface-set");
698 BOOST_CHECK_EQUAL(std::string(SurfaceSolid::GetNavigationReliabilityName(Reliability::NonManifold)), "non-manifold");
699}
700
701BOOST_AUTO_TEST_CASE(NumericalConventions)
702{
704 using surf::WireRole;
705 using surf::WireStatus;
706
707 // near-boundary point classification: a unit square wire, points offset from the bottom edge.
708 WireStatus status = WireStatus::Valid;
709 auto square = makeWire({{0., 0.}, {1., 0.}, {1., 1.}, {0., 1.}}, WireRole::Outer, status);
710 BOOST_REQUIRE(status == WireStatus::Valid);
711
712 // within tolerance of an edge -> Boundary, on both sides
713 BOOST_CHECK(square.classify({0.5, 0.5 * surf::kTolerance}) == WireClassification::Boundary);
714 BOOST_CHECK(square.classify({0.5, -0.5 * surf::kTolerance}) == WireClassification::Boundary);
715 // clearly beyond tolerance -> Inside / Outside
716 BOOST_CHECK(square.classify({0.5, 1.e3 * surf::kTolerance}) == WireClassification::Inside);
717 BOOST_CHECK(square.classify({0.5, -1.e3 * surf::kTolerance}) == WireClassification::Outside);
718
719 // near-tangent rays against a planar surface in the z = 0 plane.
721 std::string planeError;
722 BOOST_REQUIRE(plane.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.},
723 {{0., 0.}, {1., 0.}, {1., 1.}, {0., 1.}}, {}, planeError));
724
725 const surf::Vec3 origin{0.5, 0.5, 1.};
726 std::vector<surf::RayHit> hits;
727
728 // direction almost parallel to the plane (tiny z component) -> grazing miss
729 const surf::Vec3 grazing = surf::normalized({1., 0., 0.1 * surf::kTolerance});
730 plane.appendIntersections(origin, grazing, 0., 1.e30, hits);
731 BOOST_CHECK(hits.empty());
732
733 // steeper direction -> real intersection at the plane
734 const surf::Vec3 steep = surf::normalized({0., 0., -1.});
735 plane.appendIntersections(origin, steep, 0., 1.e30, hits);
736 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
737 checkClose(hits[0].distance, 1.);
738
739 // a hit rejected when it falls below the minimum ray parameter
740 hits.clear();
741 plane.appendIntersections(origin, steep, 2., 1.e30, hits);
742 BOOST_CHECK(hits.empty());
743
744 // duplicate-intersection clustering respects kIntersectionTolerance.
747}
748
750{
752 using surf::WireRole;
753 using surf::WireStatus;
754
755 // outer square wire: area, orientation, parametric AABB, and boundary sampling.
756 WireStatus status = WireStatus::Valid;
757 auto square = makeWire({{0., 0.}, {2., 0.}, {2., 3.}, {0., 3.}}, WireRole::Outer, status);
758 BOOST_REQUIRE(status == WireStatus::Valid);
759 checkClose(square.signedArea(), 6.);
760
761 surf::Vec2 lower{1.e30, 1.e30};
762 surf::Vec2 upper{-1.e30, -1.e30};
763 square.parametricBounds(lower, upper);
764 checkClose(lower.uCoord, 0.);
765 checkClose(lower.vCoord, 0.);
766 checkClose(upper.uCoord, 2.);
767 checkClose(upper.vCoord, 3.);
768
769 // the sampled boundary is the closed vertex ring (first vertex repeated at the end).
770 const auto samples = square.sampledBoundary();
771 BOOST_CHECK_EQUAL(samples.size(), square.vertices.size() + 1);
772 checkClose(samples.front().uCoord, samples.back().uCoord);
773 checkClose(samples.front().vCoord, samples.back().vCoord);
774
775 // edge distance / projection (closest point) on the bottom edge.
776 const surf::SurfaceEdge bottom{{0., 0.}, {2., 0.}};
777 double parameter = -1.;
778 const surf::Vec2 projected = bottom.closestPoint({1., 5.}, parameter);
779 checkClose(projected.uCoord, 1.);
780 checkClose(projected.vCoord, 0.);
781 checkClose(parameter, 0.5);
782 // projection is clamped to the segment endpoints.
783 bottom.closestPoint({-5., 1.}, parameter);
784 checkClose(parameter, 0.);
785 bottom.closestPoint({5., 1.}, parameter);
786 checkClose(parameter, 1.);
787 checkClose(std::sqrt(bottom.distanceSq({1., 4.})), 4.);
788
789 // reversed wire: same shape, opposite winding sign, identical parametric AABB.
790 WireStatus reversedStatus = WireStatus::Valid;
791 auto reversed = makeWire({{0., 0.}, {0., 3.}, {2., 3.}, {2., 0.}}, WireRole::Outer, reversedStatus);
792 BOOST_CHECK(reversedStatus == WireStatus::Reversed);
793 checkClose(reversed.signedArea(), 6.); // normalized back to CCW (positive)
794 surf::Vec2 reversedLower{1.e30, 1.e30};
795 surf::Vec2 reversedUpper{-1.e30, -1.e30};
796 reversed.parametricBounds(reversedLower, reversedUpper);
797 checkClose(reversedUpper.uCoord, 2.);
798 checkClose(reversedUpper.vCoord, 3.);
799
800 // open wire via an explicit non-closing edge list is rejected.
801 surf::SurfaceWire scratch;
802 const std::vector<surf::SurfaceEdge> openEdges{{{0., 0.}, {2., 0.}}, {{2., 0.}, {2., 3.}}, {{2., 3.}, {1., 1.}}};
803 BOOST_CHECK(!scratch.initializeFromEdges(openEdges, WireRole::Outer, status));
804 BOOST_CHECK(status == WireStatus::Open);
805
806 // point-on-edge classification.
807 BOOST_CHECK(square.classify({1., 0.}) == WireClassification::Boundary);
808 BOOST_CHECK(square.classify({1., 1.5}) == WireClassification::Inside);
809 BOOST_CHECK(square.classify({3., 1.5}) == WireClassification::Outside);
810
811 // square-with-hole: a planar surface with one inner (hole) wire.
813 std::string faceError;
814 const std::vector<surf::Vec2> outer{{0., 0.}, {4., 0.}, {4., 4.}, {0., 4.}};
815 const std::vector<std::vector<surf::Vec2>> holes{{{1., 1.}, {3., 1.}, {3., 3.}, {1., 3.}}};
816 BOOST_REQUIRE(holedFace.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.}, outer, holes, faceError));
817
818 bool boundary = false;
819 BOOST_CHECK(holedFace.containsLocal({0.5, 0.5}, &boundary)); // in material, outside the hole
820 BOOST_CHECK(!boundary);
821 BOOST_CHECK(!holedFace.containsLocal({2., 2.})); // inside the hole -> not on the patch
822 BOOST_CHECK(holedFace.containsLocal({2., 1.}, &boundary)); // on the hole boundary -> on the patch
823 BOOST_CHECK(boundary);
824
825 // the trimmed area accounts for the hole (16 - 4).
826 checkClose(holedFace.area(), 12.);
827}
828
829BOOST_AUTO_TEST_CASE(TrimmedCurveBoundaries)
830{
831 using surf::Curve2D;
832 using surf::CurveWire;
834 using surf::WireRole;
835 using surf::WireStatus;
836
837 // --- line curve: endpoint, tangent, bounds, projection -------------------------------------
838 const Curve2D line = Curve2D::makeLine({0., 0.}, {4., 0.});
839 checkClose(line.startPoint().uCoord, 0.);
840 checkClose(line.endPoint().uCoord, 4.);
841 const surf::Vec2 lineTangent = line.tangentAt(0.5);
842 checkClose(lineTangent.uCoord, 1.);
843 checkClose(lineTangent.vCoord, 0.);
844
845 double lineParameter = -1.;
846 const surf::Vec2 lineProjection = line.closestPoint({1., 5.}, lineParameter);
847 checkClose(lineProjection.uCoord, 1.);
848 checkClose(lineProjection.vCoord, 0.);
849 checkClose(lineParameter, 0.25);
850 checkClose(std::sqrt(line.distanceSq({1., 5.})), 5.);
851
852 // --- arc curve: endpoint, tangent, exact bounds, projection --------------------------------
853 // quarter circle of radius 2 centred at the origin, from angle 0 to pi/2.
854 const Curve2D quarter = Curve2D::makeArc({0., 0.}, 2., 0., surf::kHalfPi);
855 checkClose(quarter.startPoint().uCoord, 2.);
856 checkClose(quarter.startPoint().vCoord, 0.);
857 checkClose(quarter.endPoint().uCoord, 0.);
858 checkClose(quarter.endPoint().vCoord, 2.);
859 // tangent at the start of a CCW arc points in +v.
860 const surf::Vec2 arcTangent = quarter.tangentAt(0.);
861 checkClose(arcTangent.uCoord, 0.);
862 checkClose(arcTangent.vCoord, 1.);
863
864 // the quarter arc's exact bounding box is [0, 2] x [0, 2] (no cardinal extreme inside).
865 surf::Vec2 arcLower{1.e30, 1.e30};
866 surf::Vec2 arcUpper{-1.e30, -1.e30};
867 quarter.extendBounds(arcLower, arcUpper);
868 checkClose(arcLower.uCoord, 0.);
869 checkClose(arcLower.vCoord, 0.);
870 checkClose(arcUpper.uCoord, 2.);
871 checkClose(arcUpper.vCoord, 2.);
872
873 // projection of a far radial point lands on the circle (distance = |d - r|).
874 double arcParameter = -1.;
875 const surf::Vec2 arcProjection = quarter.closestPoint({5., 5.}, arcParameter);
876 checkClose(std::hypot(arcProjection.uCoord, arcProjection.vCoord), 2.);
877 checkClose(arcParameter, 0.5);
878
879 // a full circle's exact bounding box spans the whole diameter in both axes.
880 const Curve2D circle = Curve2D::makeCircle({1., -1.}, 3.);
881 surf::Vec2 circleLower{1.e30, 1.e30};
882 surf::Vec2 circleUpper{-1.e30, -1.e30};
883 circle.extendBounds(circleLower, circleUpper);
884 checkClose(circleLower.uCoord, -2.);
885 checkClose(circleUpper.uCoord, 4.);
886 checkClose(circleLower.vCoord, -4.);
887 checkClose(circleUpper.vCoord, 2.);
888
889 // --- disk: one full-circle outer wire ------------------------------------------------------
890 WireStatus status = WireStatus::Valid;
891 CurveWire disk;
892 BOOST_REQUIRE(disk.initialize({Curve2D::makeCircle({0., 0.}, 2.)}, WireRole::Outer, status));
893 BOOST_CHECK(status == WireStatus::Valid);
894 // exact area of the disk is pi * r^2.
895 checkClose(disk.signedArea(), surf::kPi * 4., 1.e-9);
896 BOOST_CHECK(disk.classify({0., 0.}) == WireClassification::Inside);
897 BOOST_CHECK(disk.classify({1.5, 0.}) == WireClassification::Inside);
898 BOOST_CHECK(disk.classify({3., 0.}) == WireClassification::Outside);
899 BOOST_CHECK(disk.classify({0., 3.}) == WireClassification::Outside);
900 BOOST_CHECK(disk.classify({2., 0.}) == WireClassification::Boundary);
901 BOOST_CHECK(disk.classify({0., -2.}) == WireClassification::Boundary);
902
903 // a clockwise circle used as an outer wire is re-oriented to counter-clockwise.
904 WireStatus reversedStatus = WireStatus::Valid;
905 CurveWire reversedDisk;
906 BOOST_REQUIRE(reversedDisk.initialize({Curve2D::makeCircle({0., 0.}, 2., true)}, WireRole::Outer, reversedStatus));
907 BOOST_CHECK(reversedStatus == WireStatus::Reversed);
908 checkClose(reversedDisk.signedArea(), surf::kPi * 4., 1.e-9);
909
910 // --- annulus: outer disk (CCW) minus an inner hole wire (CW) --------------------------------
911 WireStatus outerStatus = WireStatus::Valid;
912 WireStatus holeStatus = WireStatus::Valid;
913 CurveWire outerRing;
914 CurveWire innerRing;
915 BOOST_REQUIRE(outerRing.initialize({Curve2D::makeCircle({0., 0.}, 3.)}, WireRole::Outer, outerStatus));
916 BOOST_REQUIRE(innerRing.initialize({Curve2D::makeCircle({0., 0.}, 1.)}, WireRole::Inner, holeStatus));
917 BOOST_CHECK(holeStatus == WireStatus::Reversed); // CCW circle normalized to CW for a hole
918 BOOST_CHECK_LT(innerRing.signedArea(), 0.);
919 // net annulus area = pi * (R^2 - r^2).
920 checkClose(outerRing.signedArea() + innerRing.signedArea(), surf::kPi * (9. - 1.), 1.e-9);
921
922 // a point in the material (between radii) is inside the outer ring and outside the inner hole.
923 const surf::Vec2 materialPoint{2., 0.};
924 BOOST_CHECK(outerRing.classify(materialPoint) == WireClassification::Inside);
925 BOOST_CHECK(innerRing.classify(materialPoint) == WireClassification::Outside);
926 // a point inside the hole is inside both rings (so subtracted from the material).
927 const surf::Vec2 holePoint{0.2, 0.};
928 BOOST_CHECK(outerRing.classify(holePoint) == WireClassification::Inside);
929 BOOST_CHECK(innerRing.classify(holePoint) == WireClassification::Inside);
930
931 // --- mixed line + arc loop: a stadium / half-disk closed by a diameter ---------------------
932 // upper half-disk: diameter along v = 0 from (-2,0) to (2,0), closed by a CCW semicircle.
933 WireStatus halfStatus = WireStatus::Valid;
934 CurveWire halfDisk;
935 const std::vector<Curve2D> halfDiskCurves{Curve2D::makeLine({-2., 0.}, {2., 0.}),
936 Curve2D::makeArc({0., 0.}, 2., 0., surf::kPi)};
937 BOOST_REQUIRE(halfDisk.initialize(halfDiskCurves, WireRole::Outer, halfStatus));
938 BOOST_CHECK(halfStatus == WireStatus::Valid);
939 checkClose(halfDisk.signedArea(), 0.5 * surf::kPi * 4., 1.e-9); // half of pi*r^2
940 BOOST_CHECK(halfDisk.classify({0., 1.}) == WireClassification::Inside);
941 BOOST_CHECK(halfDisk.classify({0., -1.}) == WireClassification::Outside);
942 BOOST_CHECK(halfDisk.classify({0., 0.}) == WireClassification::Boundary);
943
944 // an open curve loop is rejected.
945 WireStatus openStatus = WireStatus::Valid;
946 CurveWire openWire;
947 const std::vector<Curve2D> openCurves{Curve2D::makeLine({0., 0.}, {2., 0.}),
948 Curve2D::makeLine({2., 0.}, {2., 2.})};
949 BOOST_CHECK(!openWire.initialize(openCurves, WireRole::Outer, openStatus));
950 BOOST_CHECK(openStatus == WireStatus::Open);
951}
952
953BOOST_AUTO_TEST_CASE(BSplineTrimCurveKernels)
954{
955 using surf::Curve2D;
956 using surf::CurveWire;
957 using surf::Vec2;
959 using surf::WireRole;
960 using surf::WireStatus;
961
962 // --- non-rational cubic B-spline: validity, clamped endpoints, convex-hull bounds ------------
963 const std::vector<Vec2> poles{{0., 0.}, {1., 2.}, {2., -1.}, {3., 1.}, {4., 0.}};
964 const std::vector<double> knots{0., 0., 0., 0., 0.5, 1., 1., 1., 1.};
965 const Curve2D spline = Curve2D::makeBSpline(3, poles, {}, knots);
966 BOOST_CHECK(spline.valid());
967 checkClose(spline.startPoint().uCoord, 0.);
968 checkClose(spline.startPoint().vCoord, 0.);
969 checkClose(spline.endPoint().uCoord, 4.);
970 checkClose(spline.endPoint().vCoord, 0.);
971 // extendBounds returns the (conservative) control-point convex-hull box
972 Vec2 lower{1.e30, 1.e30};
973 Vec2 upper{-1.e30, -1.e30};
974 spline.extendBounds(lower, upper);
975 checkClose(lower.uCoord, 0.);
976 checkClose(upper.uCoord, 4.);
977 checkClose(lower.vCoord, -1.);
978 checkClose(upper.vCoord, 2.);
979
980 // --- rational quadratic B-spline: an exact NURBS quarter circle -----------------------------
981 const std::vector<Vec2> circlePoles{{1., 0.}, {1., 1.}, {0., 1.}};
982 const std::vector<double> circleWeights{1., std::sqrt(0.5), 1.};
983 const std::vector<double> circleKnots{0., 0., 0., 1., 1., 1.};
984 const Curve2D quarter = Curve2D::makeBSpline(2, circlePoles, circleWeights, circleKnots);
985 BOOST_CHECK(quarter.valid());
986 for (int index = 0; index <= 8; ++index) {
987 const Vec2 point = quarter.pointAt(static_cast<double>(index) / 8);
988 checkClose(std::hypot(point.uCoord, point.vCoord), 1., 1.e-9);
989 }
990
991 // --- closed loop (B-spline top + three closing lines): area vs a fine-polygon reference ------
992 const std::vector<Curve2D> loop{spline, Curve2D::makeLine({4., 0.}, {4., -3.}),
993 Curve2D::makeLine({4., -3.}, {0., -3.}), Curve2D::makeLine({0., -3.}, {0., 0.})};
994 WireStatus status = WireStatus::Valid;
995 CurveWire wire;
996 BOOST_REQUIRE(wire.initialize(loop, WireRole::Outer, status));
997 double referenceArea = 0.;
998 const auto boundarySamples = wire.sampledBoundary();
999 for (size_t k = 0; k + 1 < boundarySamples.size(); ++k) {
1000 referenceArea += 0.5 * (boundarySamples[k].uCoord * boundarySamples[k + 1].vCoord -
1001 boundarySamples[k + 1].uCoord * boundarySamples[k].vCoord);
1002 }
1003 // wire.signedArea() is the exact Gauss-Legendre value; referenceArea is a chord-polyline
1004 // approximation of it, so compare at the sampling-accuracy level rather than machine precision
1005 checkClose(wire.signedArea(), std::abs(referenceArea), 1.e-4);
1006
1007 // classify inside / outside / boundary
1008 BOOST_CHECK(wire.classify({2., -1.5}) == WireClassification::Inside);
1009 BOOST_CHECK(wire.classify({2., -2.9}) == WireClassification::Inside);
1010 BOOST_CHECK(wire.classify({-1., -1.}) == WireClassification::Outside);
1011 BOOST_CHECK(wire.classify({2., 5.}) == WireClassification::Outside);
1012 BOOST_CHECK(wire.classify({0., 0.}) == WireClassification::Boundary); // on the B-spline start
1013 BOOST_CHECK(wire.classify({2., -3.}) == WireClassification::Boundary); // on the bottom line
1014
1015 // --- horizontal-tangent case: a scanline tangent to a smooth apex must not flip parity -------
1016 // downward arch: quadratic B-spline (0,0) -> apex (1,1) -> (2,0), closed by the baseline.
1017 const Curve2D arch = Curve2D::makeBSpline(2, {{0., 0.}, {1., 2.}, {2., 0.}}, {}, {0., 0., 0., 1., 1., 1.});
1018 checkClose(arch.pointAt(0.5).vCoord, 1.); // apex height
1019 WireStatus archStatus = WireStatus::Valid;
1020 CurveWire archRegion;
1021 BOOST_REQUIRE(archRegion.initialize({arch, Curve2D::makeLine({2., 0.}, {0., 0.})}, WireRole::Outer, archStatus));
1022 BOOST_CHECK(archRegion.classify({1., 0.5}) == WireClassification::Inside);
1023 BOOST_CHECK(archRegion.classify({1., 1.5}) == WireClassification::Outside);
1024 // scanline v = 1 is tangent to the apex to the right of these points: a robust kernel counts an
1025 // even number of crossings so both points classify Outside.
1026 BOOST_CHECK(archRegion.classify({-1., 1.}) == WireClassification::Outside);
1027 BOOST_CHECK(archRegion.classify({3., 1.}) == WireClassification::Outside);
1028
1029 // --- reversal keeps the same geometric image (poles/knots complemented) ----------------------
1030 Curve2D reversed = spline;
1031 reversed.reverseInPlace();
1032 checkClose(reversed.startPoint().uCoord, 4.);
1033 checkClose(reversed.endPoint().uCoord, 0.);
1034 checkClose(reversed.pointAt(0.25).uCoord, spline.pointAt(0.75).uCoord, 1.e-9);
1035 checkClose(reversed.pointAt(0.25).vCoord, spline.pointAt(0.75).vCoord, 1.e-9);
1036}
1037
1038// The adaptive sampler must not be fooled by a curve that meets its own chord where it is probed.
1039// Both halves of the criterion get their own case, because
1040// each defeats the other's reproducer on its own.
1041BOOST_AUTO_TEST_CASE(BSplineSamplingIsNotFooledBySymmetry)
1042{
1043 using surf::Curve2D;
1044 using surf::Vec2;
1045
1046 // --- symmetry about the parameter midpoint, within a single Bezier span ----------------------
1047 // A cubic Bezier is (P0 + 3 P1 + 3 P2 + P3) / 8 at t = 1/2, so this S-curve passes through
1048 // (1, 0) -- exactly on its own chord from (0, 0) to (2, 0) -- while bulging by about 0.3 either
1049 // side of it. A single midpoint probe therefore calls it flat at the very first step and
1050 // replaces the whole curve with a straight line. That is what happened to the tube-tube junction
1051 // curve of six ExcavatorArm parts, whose rim vanished entirely as a result.
1052 const Curve2D sCurve =
1053 Curve2D::makeBSpline(3, {{0., 0.}, {0.5, 1.}, {1.5, -1.}, {2., 0.}}, {}, {0., 0., 0., 0., 1., 1., 1., 1.});
1054 BOOST_REQUIRE(sCurve.valid());
1055 checkClose(sCurve.pointAt(0.5).uCoord, 1., 1.e-12);
1056 checkClose(sCurve.pointAt(0.5).vCoord, 0., 1.e-12); // the trap: the midpoint is on the chord
1057 double worstOffChord = 0.;
1058 for (int step = 0; step <= 64; ++step) {
1059 worstOffChord = std::max(worstOffChord, std::abs(sCurve.pointAt(static_cast<double>(step) / 64).vCoord));
1060 }
1061 BOOST_CHECK(worstOffChord > 0.2); // and the curve really does leave it, by a lot
1062
1063 std::vector<Vec2> samples;
1064 sCurve.bsplineSampleInto(samples);
1065 BOOST_CHECK(samples.size() > 2); // not flattened to its chord
1066 double worstSampleError = 0.;
1067 for (int step = 0; step <= 64; ++step) {
1068 const Vec2 onCurve = sCurve.pointAt(static_cast<double>(step) / 64);
1069 double nearest = 1.e30;
1070 for (size_t index = 0; index + 1 < samples.size(); ++index) {
1071 nearest = std::min(nearest, surf::pointSegmentDistanceSq(onCurve, samples[index], samples[index + 1]));
1072 }
1073 worstSampleError = std::max(worstSampleError, std::sqrt(nearest));
1074 }
1075 BOOST_CHECK(worstSampleError < 1.e-4); // and the polyline now follows it
1076
1077 // --- every knot span gets sampled, however flat the curve looks ------------------------------
1078 // A B-spline is one polynomial piece only *within* a span, so a flatness verdict that straddles
1079 // a knot is a verdict about a curve the test's own model does not describe. This one is exactly
1080 // straight, so no probe anywhere can distinguish it from its chord -- and it must still be
1081 // resolved span by span, because that is the only thing the curve's own structure guarantees.
1082 std::vector<Vec2> straightPoles;
1083 std::vector<double> uniformKnots{0., 0., 0., 0.};
1084 constexpr int spanCount = 8;
1085 for (int index = 0; index < spanCount + 3; ++index) {
1086 straightPoles.push_back({static_cast<double>(index), 0.});
1087 }
1088 for (int index = 1; index < spanCount; ++index) {
1089 uniformKnots.push_back(static_cast<double>(index) / spanCount);
1090 }
1091 uniformKnots.insert(uniformKnots.end(), {1., 1., 1., 1.});
1092 const Curve2D straight = Curve2D::makeBSpline(3, straightPoles, {}, uniformKnots);
1093 BOOST_REQUIRE(straight.valid());
1094 std::vector<Vec2> straightSamples;
1095 straight.bsplineSampleInto(straightSamples);
1096 BOOST_CHECK(static_cast<int>(straightSamples.size()) >= spanCount + 1);
1097}
1098
1099BOOST_AUTO_TEST_CASE(CurvedPlanarDiskKernels)
1100{
1101 using surf::Curve2D;
1102
1103 // annulus in the z = 0 plane: outer radius 2, hole radius 1
1105 std::string error;
1106 BOOST_REQUIRE(annulus.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.},
1107 {Curve2D::makeCircle({0., 0.}, 2.)},
1108 {{Curve2D::makeCircle({0., 0.}, 1., true)}}, error));
1109 BOOST_CHECK(!annulus.wasReoriented()); // outer CCW, hole CW: both already correctly oriented
1110
1111 // a skewed (non-orthonormal) frame is rejected
1113 BOOST_CHECK(!skewed.initialize({0., 0., 0.}, {1., 0., 0.}, {0.5, 1., 0.},
1114 {Curve2D::makeCircle({0., 0.}, 2.)}, {}, error));
1115
1116 // on-surface classification: material, hole, outside, off-plane
1117 BOOST_CHECK(annulus.containsPointOnSurface({1.5, 0., 0.}));
1118 BOOST_CHECK(!annulus.containsPointOnSurface({0.5, 0., 0.}));
1119 BOOST_CHECK(!annulus.containsPointOnSurface({3., 0., 0.}));
1120 BOOST_CHECK(!annulus.containsPointOnSurface({1.5, 0., 0.5}));
1121
1122 // ray intersections: one hit through the material, none through the hole
1123 std::vector<surf::RayHit> hits;
1124 annulus.appendIntersections({1.5, 0., 1.}, {0., 0., -1.}, 0., 1.e30, hits);
1125 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
1126 checkClose(hits.front().distance, 1.);
1127 checkClose(hits.front().normal.zCoord, 1.);
1128 hits.clear();
1129 annulus.appendIntersections({0.5, 0., 1.}, {0., 0., -1.}, 0., 1.e30, hits);
1130 BOOST_CHECK(hits.empty());
1131
1132 // exact patch distances: in-hole (to the hole rim), in-material off-plane, outside the rim,
1133 // and the combined in-plane plus out-of-plane case
1134 checkClose(annulus.distanceSqToPatch({0., 0., 0.}), 1.);
1135 checkClose(annulus.distanceSqToPatch({1.5, 0., 2.}), 4.);
1136 checkClose(annulus.distanceSqToPatch({4., 0., 0.}), 4.);
1137 checkClose(annulus.distanceSqToPatch({0.5, 0., 1.}), 1.25);
1138
1139 // divergence-theorem contribution of an offset disk: (origin . normal) * area / 3
1141 BOOST_REQUIRE(offsetDisk.initialize({0., 0., 2.}, {1., 0., 0.}, {0., 1., 0.},
1142 {Curve2D::makeCircle({0., 0.}, 1.5)}, {}, error));
1143 checkClose(offsetDisk.capacityContribution(), 2. * surf::kPi * 1.5 * 1.5 / 3., 1.e-9);
1144 BOOST_CHECK(offsetDisk.capacityIsExact());
1145}
1146
1147BOOST_AUTO_TEST_CASE(CylindricalSurfaceKernels)
1148{
1149 // full lateral cylinder, radius 2, height [-3, 3], axis z
1151 std::string error;
1152 BOOST_REQUIRE(cylinder.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -3., 3., 0., surf::kTwoPi,
1153 false, error));
1154
1155 // a transversal ray crosses the lateral surface twice: both hits must be reported
1156 std::vector<surf::RayHit> hits;
1157 cylinder.appendIntersections({-5., 0.5, 0.}, {1., 0., 0.}, 0., 1.e30, hits);
1158 BOOST_REQUIRE_EQUAL(hits.size(), 2u);
1159 const double chordHalf = std::sqrt(4. - 0.25);
1160 checkClose(hits[0].distance, 5. - chordHalf);
1161 checkClose(hits[1].distance, 5. + chordHalf);
1162 // entering hit: outward normal opposes the ray direction; exiting hit: aligned
1163 BOOST_CHECK_LT(hits[0].normal.xCoord, 0.);
1164 BOOST_CHECK_GT(hits[1].normal.xCoord, 0.);
1165
1166 // tangential graze reports no hits (keeps crossing parity even)
1167 hits.clear();
1168 cylinder.appendIntersections({-5., 2., 0.}, {1., 0., 0.}, 0., 1.e30, hits);
1169 BOOST_CHECK(hits.empty());
1170
1171 // axis-parallel ray never crosses the lateral surface
1172 hits.clear();
1173 cylinder.appendIntersections({0., 0., -5.}, {0., 0., 1.}, 0., 1.e30, hits);
1174 BOOST_CHECK(hits.empty());
1175
1176 // exact patch distances: radial, above the rim, and the diagonal rim case
1177 checkClose(cylinder.distanceSqToPatch({4., 0., 0.}), 4.);
1178 checkClose(cylinder.distanceSqToPatch({0., 0., 5.}), 8.);
1179 checkClose(cylinder.distanceSqToPatch({3., 0., 4.}), 2.);
1180
1181 // half cylinder (phi in [0, pi]): the phi trim filters hits and surface points
1183 BOOST_REQUIRE(halfCylinder.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -3., 3., 0., surf::kPi,
1184 false, error));
1185 BOOST_CHECK(halfCylinder.containsPointOnSurface({0., 2., 0.}));
1186 BOOST_CHECK(!halfCylinder.containsPointOnSurface({0., -2., 0.}));
1187 hits.clear();
1188 halfCylinder.appendIntersections({0., -5., 0.}, {0., 1., 0.}, 0., 1.e30, hits);
1189 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
1190 checkClose(hits.front().distance, 7.);
1191}
1192
1193BOOST_AUTO_TEST_CASE(ClosedCylinderMatchesTGeoTube)
1194{
1195 constexpr double radius = 2.;
1196 constexpr double halfHeight = 3.;
1197
1198 SurfaceSolid solid("closedCylinder");
1199 BOOST_REQUIRE(solid.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -halfHeight,
1200 halfHeight));
1201 // cap frames: outward normal is axisU x axisV, so the bottom cap flips axisV
1202 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radius));
1203 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radius));
1204 solid.CloseShape();
1205
1206 BOOST_CHECK(solid.IsClosed());
1207 BOOST_CHECK(solid.IsOrientationConsistent());
1208
1209 TGeoTube reference("referenceTube", 0., radius, halfHeight);
1210 compareContainsGrid(solid, reference, 4., 9);
1211
1212 compareDistance(solid, reference, {5., 0., 0.}, {-1., 0., 0.});
1213 compareDistance(solid, reference, {0., 0., 5.}, {0., 0., -1.});
1214 compareDistance(solid, reference, {5., 0.5, 1.}, {-1., 0., 0.});
1215 compareDistance(solid, reference, {-4., -1., -2.}, unitDirection(1., 0.3, 0.5));
1216 compareDistance(solid, reference, {0., 0., 0.}, {1., 0., 0.});
1217 compareDistance(solid, reference, {0., 0., 0.}, {0., 0., 1.});
1218 compareDistance(solid, reference, {0., 0., 0.}, unitDirection(1., 1., 1.));
1219 compareDistance(solid, reference, {1., 0.5, -2.}, unitDirection(0.3, -0.4, 0.5));
1220 compareDistance(solid, reference, {5., 2.5, 0.}, {-1., 0., 0.}); // grazing miss
1221
1222 // safeties against analytic distances (TGeo safeties may be weaker underestimates, so they
1223 // are not compared directly)
1224 const double center[3] = {0., 0., 0.};
1225 const double insidePoint[3] = {1., 0.5, 1.};
1226 const double radialOutside[3] = {4., 0., 0.};
1227 const double axialOutside[3] = {0., 0., 5.};
1228 const double cornerOutside[3] = {4., 0., 5.};
1229 checkClose(solid.Safety(center, kTRUE), radius);
1230 checkClose(solid.Safety(insidePoint, kTRUE), radius - std::sqrt(1.25));
1231 checkClose(solid.Safety(radialOutside, kFALSE), 2.);
1232 checkClose(solid.Safety(axialOutside, kFALSE), 2.);
1233 checkClose(solid.Safety(cornerOutside, kFALSE), std::sqrt(8.)); // exact corner distance
1234
1235 // normals on the lateral surface and the caps
1236 double normal[3] = {0., 0., 0.};
1237 const double sidePoint[3] = {radius, 0., 1.};
1238 const double alongX[3] = {1., 0., 0.};
1239 solid.ComputeNormal(sidePoint, alongX, normal);
1240 checkClose(normal[0], 1.);
1241 checkClose(normal[1], 0.);
1242 checkClose(normal[2], 0.);
1243 const double capPoint[3] = {0.5, 0.5, halfHeight};
1244 const double alongZ[3] = {0., 0., 1.};
1245 solid.ComputeNormal(capPoint, alongZ, normal);
1246 checkClose(normal[2], 1.);
1247
1248 checkClose(solid.Capacity(), reference.Capacity(), 1.e-9);
1249
1250 int meshVertices = 0;
1251 int meshSegments = 0;
1252 int meshPolygons = 0;
1253 solid.GetMeshNumbers(meshVertices, meshSegments, meshPolygons);
1254 BOOST_CHECK_GT(meshVertices, 0);
1255 BOOST_CHECK_GT(meshPolygons, 0);
1256}
1257
1258BOOST_AUTO_TEST_CASE(HollowCylinderMatchesTGeoTube)
1259{
1260 constexpr double innerRadius = 1.;
1261 constexpr double outerRadius = 2.;
1262 constexpr double halfHeight = 3.;
1263
1264 SurfaceSolid solid("hollowCylinder");
1265 BOOST_REQUIRE(solid.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, outerRadius, -halfHeight,
1266 halfHeight));
1267 BOOST_REQUIRE(solid.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, innerRadius, -halfHeight,
1268 halfHeight, 0., surf::kTwoPi, true));
1269 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, outerRadius,
1270 innerRadius));
1271 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, outerRadius,
1272 innerRadius));
1273 solid.CloseShape();
1274
1275 BOOST_CHECK(solid.IsClosed());
1276 BOOST_CHECK(solid.IsOrientationConsistent());
1277
1278 TGeoTube reference("referenceHollowTube", innerRadius, outerRadius, halfHeight);
1279 compareContainsGrid(solid, reference, 4., 9);
1280
1281 // from the hole the solid is entered through the inner wall
1282 compareDistance(solid, reference, {0., 0., 0.}, {1., 0., 0.});
1283 compareDistance(solid, reference, {0., 0., 2.}, unitDirection(0.4, 0.2, -1.));
1284 // inside the material both walls are exit candidates
1285 compareDistance(solid, reference, {1.5, 0., 0.}, {1., 0., 0.});
1286 compareDistance(solid, reference, {1.5, 0., 0.}, {-1., 0., 0.});
1287 compareDistance(solid, reference, {-1.2, 0.8, 1.}, unitDirection(-0.2, 0.9, 0.4));
1288 compareDistance(solid, reference, {5., 0., 0.}, {-1., 0., 0.});
1289
1290 // analytic safety in the middle of the material: 0.5 to either wall
1291 const double materialPoint[3] = {1.5, 0., 0.};
1292 checkClose(solid.Safety(materialPoint, kTRUE), 0.5);
1293
1294 checkClose(solid.Capacity(), reference.Capacity(), 1.e-9);
1295}
1296
1297BOOST_AUTO_TEST_CASE(SphereMatchesTGeoSphere)
1298{
1299 constexpr double radius = 2.5;
1300
1301 SurfaceSolid solid("fullSphere");
1302 BOOST_REQUIRE(solid.AddSphericalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius));
1303 solid.CloseShape();
1304
1305 // a full sphere is self-closing: no boundary edges at all
1306 BOOST_CHECK(solid.IsClosed());
1307 BOOST_CHECK(solid.IsOrientationConsistent());
1308
1309 TGeoSphere reference("referenceSphere", 0., radius);
1310 compareContainsGrid(solid, reference, 3.5, 9);
1311
1312 compareDistance(solid, reference, {5., 0., 0.}, {-1., 0., 0.});
1313 compareDistance(solid, reference, {0., 0., 0.}, {1., 0., 0.});
1314 compareDistance(solid, reference, {0., 0., 0.}, unitDirection(1., 1., 1.));
1315 compareDistance(solid, reference, {1., 1., 1.}, unitDirection(-0.3, 0.5, 0.8));
1316 compareDistance(solid, reference, {-4., 0.5, 0.5}, {1., 0., 0.});
1317 compareDistance(solid, reference, {-4., 2.6, 0.}, {1., 0., 0.}); // clean miss
1318
1319 // analytic safeties: |distance to center - radius|
1320 const double insidePoint[3] = {1., 0., 0.};
1321 const double outsidePoint[3] = {4., 0., 0.};
1322 checkClose(solid.Safety(insidePoint, kTRUE), radius - 1.);
1323 checkClose(solid.Safety(outsidePoint, kFALSE), 4. - radius);
1324
1325 double normal[3] = {0., 0., 0.};
1326 const double surfacePoint[3] = {radius, 0., 0.};
1327 const double alongX[3] = {1., 0., 0.};
1328 solid.ComputeNormal(surfacePoint, alongX, normal);
1329 checkClose(normal[0], 1.);
1330
1331 checkClose(solid.Capacity(), reference.Capacity(), 1.e-9);
1332
1333 int meshVertices = 0;
1334 int meshSegments = 0;
1335 int meshPolygons = 0;
1336 solid.GetMeshNumbers(meshVertices, meshSegments, meshPolygons);
1337 BOOST_CHECK_GT(meshVertices, 0);
1338 BOOST_CHECK_GT(meshPolygons, 0);
1339}
1340
1341BOOST_AUTO_TEST_CASE(SphericalSectionKernels)
1342{
1343 // upper hemisphere shell of radius 2 (theta in [0, pi/2], full phi)
1345 std::string error;
1346 BOOST_REQUIRE(hemisphere.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., 0., surf::kHalfPi, 0.,
1347 surf::kTwoPi, false, error));
1348
1349 // divergence contribution of a centred hemisphere shell: 2 pi R^3 / 3
1350 checkClose(hemisphere.capacityContribution(), 2. * surf::kPi * 8. / 3., 1.e-9);
1351
1352 // the polar-axis ray meets the sphere twice but only the upper hit is on the patch
1353 std::vector<surf::RayHit> hits;
1354 hemisphere.appendIntersections({0., 0., 5.}, {0., 0., -1.}, 0., 1.e30, hits);
1355 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
1356 checkClose(hits.front().distance, 3.);
1357 checkClose(hits.front().normal.zCoord, 1.);
1358
1359 // a transversal ray at z = 1 stays in the upper hemisphere: both hits reported
1360 hits.clear();
1361 hemisphere.appendIntersections({-5., 0., 1.}, {1., 0., 0.}, 0., 1.e30, hits);
1362 BOOST_CHECK_EQUAL(hits.size(), 2u);
1363
1364 // the mirrored ray at z = -1 misses the trimmed patch entirely
1365 hits.clear();
1366 hemisphere.appendIntersections({-5., 0., -1.}, {1., 0., 0.}, 0., 1.e30, hits);
1367 BOOST_CHECK(hits.empty());
1368
1369 // trim-aware surface point classification (equator lies on the trim boundary)
1370 BOOST_CHECK(hemisphere.containsPointOnSurface({0., 0., 2.}));
1371 BOOST_CHECK(hemisphere.containsPointOnSurface({2., 0., 0.}));
1372 BOOST_CHECK(!hemisphere.containsPointOnSurface({0., 0., -2.}));
1373
1374 // patch distance: exact radially above the pole, conservative lower bound below the equator
1375 checkClose(hemisphere.distanceSqToPatch({0., 0., 5.}), 9.);
1376 BOOST_CHECK_LE(hemisphere.distanceSqToPatch({0., 0., -4.}), 4. + 1.e-9);
1377}
1378
1379BOOST_AUTO_TEST_CASE(TruncatedConeMatchesTGeoCone)
1380{
1381 constexpr double halfHeight = 3.;
1382 constexpr double radiusAtBottom = 2.;
1383 constexpr double radiusAtTop = 1.;
1384
1385 SurfaceSolid solid("truncatedCone");
1386 BOOST_REQUIRE(solid.AddConicalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radiusAtBottom, radiusAtTop,
1387 -halfHeight, halfHeight));
1388 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radiusAtTop));
1389 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radiusAtBottom));
1390 solid.CloseShape();
1391
1392 BOOST_CHECK(solid.IsClosed());
1393 BOOST_CHECK(solid.IsOrientationConsistent());
1394
1395 TGeoCone reference("referenceCone", halfHeight, 0., radiusAtBottom, 0., radiusAtTop);
1396 compareContainsGrid(solid, reference, 3.5, 9);
1397
1398 compareDistance(solid, reference, {5., 0., 0.}, {-1., 0., 0.});
1399 compareDistance(solid, reference, {0., 0., 5.}, {0., 0., -1.});
1400 compareDistance(solid, reference, {0., 0., 0.}, {1., 0., 0.});
1401 compareDistance(solid, reference, {0., 0., 0.}, {0., 0., 1.});
1402 compareDistance(solid, reference, {0., 0., 0.}, {0., 0., -1.});
1403 compareDistance(solid, reference, {0.5, -0.3, 1.}, unitDirection(0.6, 0.4, 0.2));
1404 compareDistance(solid, reference, {-4., 0.2, -2.}, unitDirection(1., 0.05, 0.3));
1405
1406 // central safety: exact distance to the lateral generator segment (2,-3)-(1,3) in (rho, z);
1407 // TGeoCone's safety degenerates to 0 on the axis of an rmin = 0 cone, so no direct comparison
1408 const double center[3] = {0., 0., 0.};
1409 checkClose(solid.Safety(center, kTRUE), 9. / std::sqrt(37.));
1410
1411 // lateral-surface normal against the ROOT cone
1412 double normal[3] = {0., 0., 0.};
1413 double referenceNormal[3] = {0., 0., 0.};
1414 const double sidePoint[3] = {1.5, 0., 0.};
1415 const double alongX[3] = {1., 0., 0.};
1416 solid.ComputeNormal(sidePoint, alongX, normal);
1417 reference.ComputeNormal(sidePoint, alongX, referenceNormal);
1418 checkClose(normal[0], referenceNormal[0]);
1419 checkClose(normal[1], referenceNormal[1]);
1420 checkClose(normal[2], referenceNormal[2]);
1421
1422 checkClose(solid.Capacity(), reference.Capacity(), 1.e-9);
1423}
1424
1425BOOST_AUTO_TEST_CASE(ApexConeClosesWithSingleCap)
1426{
1427 // full cone: radius 3 at z = -1.5 shrinking to the apex at z = +1.5, closed by one cap
1428 constexpr double halfHeight = 1.5;
1429 constexpr double baseRadius = 3.;
1430
1431 SurfaceSolid solid("apexCone");
1432 BOOST_REQUIRE(solid.AddConicalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, baseRadius, 0., -halfHeight,
1433 halfHeight));
1434 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, baseRadius));
1435 solid.CloseShape();
1436
1437 // the apex rim degenerates to a point, so one cap suffices for a closed manifold
1438 BOOST_CHECK(solid.IsClosed());
1439 BOOST_CHECK(solid.IsOrientationConsistent());
1440
1441 // analytic containment: inside iff |z| < halfHeight and rho < r(z) = halfHeight - z
1442 const auto analyticInside = [&](double x, double y, double z) {
1443 return std::abs(z) < halfHeight && std::hypot(x, y) < halfHeight - z;
1444 };
1445 const std::array<std::array<double, 3>, 7> probePoints{{{0., 0., 0.},
1446 {1., 0., 0.},
1447 {1.4, 0., 0.5},
1448 {0., 0., 1.4},
1449 {0., 0., 1.6},
1450 {2., 2., -1.},
1451 {2., 0., -1.}}};
1452 for (const auto& probe : probePoints) {
1453 BOOST_TEST_CONTEXT("point = (" << probe[0] << ", " << probe[1] << ", " << probe[2] << ")")
1454 {
1455 BOOST_CHECK_EQUAL(solid.Contains(probe.data()), analyticInside(probe[0], probe[1], probe[2]));
1456 }
1457 }
1458
1459 // radial exit through the slanted surface
1460 const double insidePoint[3] = {0., 0., -1.};
1461 const double alongX[3] = {1., 0., 0.};
1462 checkClose(solid.DistFromInside(insidePoint, alongX, 3), 2.5);
1463
1464 // central safety is the exact distance to the slanted line rho + z = halfHeight
1465 const double center[3] = {0., 0., 0.};
1466 checkClose(solid.Safety(center, kTRUE), halfHeight / std::sqrt(2.), 1.e-9);
1467
1468 // exact capacity of a full cone: pi R^2 H / 3
1469 checkClose(solid.Capacity(), surf::kPi * baseRadius * baseRadius * 2. * halfHeight / 3., 1.e-9);
1470}
1471
1472BOOST_AUTO_TEST_CASE(ToroidalSurfaceKernels)
1473{
1474 // full torus, major radius 3, minor (tube) radius 1, axis z
1475 constexpr double majorR = 3.;
1476 constexpr double minorR = 1.;
1478 std::string error;
1479 BOOST_REQUIRE(torus.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, majorR, minorR, 0., surf::kTwoPi, 0.,
1480 surf::kTwoPi, false, error));
1481
1482 // a ray along +x through the centre crosses the donut four times: at rho = -(R+r), -(R-r),
1483 // (R-r), (R+r), i.e. distances 6, 8, 12, 14 from the origin at x = -10
1484 std::vector<surf::RayHit> hits;
1485 torus.appendIntersections({-10., 0., 0.}, {1., 0., 0.}, 0., 1.e30, hits);
1486 BOOST_REQUIRE_EQUAL(hits.size(), 4u);
1487 std::sort(hits.begin(), hits.end(),
1488 [](const surf::RayHit& a, const surf::RayHit& b) { return a.distance < b.distance; });
1489 checkClose(hits[0].distance, 6., 1.e-7);
1490 checkClose(hits[1].distance, 8., 1.e-7);
1491 checkClose(hits[2].distance, 12., 1.e-7);
1492 checkClose(hits[3].distance, 14., 1.e-7);
1493 // crossings alternate enter/exit/enter/exit along the ray
1494 BOOST_CHECK_LT(hits[0].normal.xCoord, 0.);
1495 BOOST_CHECK_GT(hits[1].normal.xCoord, 0.);
1496 BOOST_CHECK_LT(hits[2].normal.xCoord, 0.);
1497 BOOST_CHECK_GT(hits[3].normal.xCoord, 0.);
1498
1499 // a z-ray tangent to the outer equator (rho = R + r) touches at a single double root: no hit
1500 hits.clear();
1501 torus.appendIntersections({majorR + minorR, 0., -10.}, {0., 0., 1.}, 0., 1.e30, hits);
1502 BOOST_CHECK(hits.empty());
1503
1504 // a ray passing above the whole torus (z = 2 r) misses entirely
1505 hits.clear();
1506 torus.appendIntersections({-10., 0., 2. * minorR}, {1., 0., 0.}, 0., 1.e30, hits);
1507 BOOST_CHECK(hits.empty());
1508
1509 // outward normals: +x at the outer equator, -x (towards the axis) at the inner equator
1510 const surf::Vec3 outerNormal = torus.normalAt({majorR + minorR, 0., 0.});
1511 checkClose(outerNormal.xCoord, 1.);
1512 const surf::Vec3 innerNormal = torus.normalAt({majorR - minorR, 0., 0.});
1513 checkClose(innerNormal.xCoord, -1.);
1514 // top of the tube: normal points along +z
1515 const surf::Vec3 topNormal = torus.normalAt({majorR, 0., minorR});
1516 checkClose(topNormal.zCoord, 1.);
1517
1518 // exact meridian distances: radially outside the outer equator and inside the hole
1519 checkClose(torus.distanceSqToPatch({majorR + minorR + 2., 0., 0.}), 4.);
1520 checkClose(torus.distanceSqToPatch({0., 0., 0.}), (majorR - minorR) * (majorR - minorR));
1521
1522 // surface-point classification
1523 BOOST_CHECK(torus.containsPointOnSurface({majorR + minorR, 0., 0.}));
1524 BOOST_CHECK(torus.containsPointOnSurface({majorR, 0., minorR}));
1525 BOOST_CHECK(!torus.containsPointOnSurface({majorR, 0., 0.})); // tube spine (interior)
1526 BOOST_CHECK(!torus.containsPointOnSurface({majorR + 5., 0., 0.})); // off the surface
1527
1528 // exact divergence-theorem capacity of a full torus: 2 pi^2 R r^2
1529 checkClose(torus.capacityContribution(), 2. * surf::kPi * surf::kPi * majorR * minorR * minorR, 1.e-9);
1531
1532 // partial tube section (a quarter-tube fillet-like patch, phiTube in [0, pi/2], full ring):
1533 // the trim filters intersections and surface points
1534 surf::TorusBoundedSurface quarterTube;
1535 BOOST_REQUIRE(quarterTube.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, majorR, minorR, 0., surf::kTwoPi, 0.,
1536 surf::kHalfPi, false, error));
1537 BOOST_CHECK(quarterTube.containsPointOnSurface({majorR + minorR, 0., 0.})); // phiTube = 0 boundary
1538 BOOST_CHECK(quarterTube.containsPointOnSurface({majorR, 0., minorR})); // phiTube = pi/2 boundary
1539 BOOST_CHECK(!quarterTube.containsPointOnSurface({majorR, 0., -minorR})); // phiTube = -pi/2, off patch
1540 hits.clear();
1541 quarterTube.appendIntersections({majorR, 0., -10.}, {0., 0., 1.}, 0., 1.e30, hits);
1542 BOOST_REQUIRE_EQUAL(hits.size(), 1u); // only the top (+z) tube point is on the quarter patch
1543 checkClose(hits.front().distance, 10. + minorR, 1.e-7);
1544}
1545
1546BOOST_AUTO_TEST_CASE(FullTorusMatchesTGeoTorus)
1547{
1548 constexpr double majorR = 3.;
1549 constexpr double minorR = 1.;
1550
1551 SurfaceSolid solid("fullTorus");
1552 BOOST_REQUIRE(solid.AddToroidalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, majorR, minorR));
1553 solid.CloseShape();
1554
1555 // a full torus is self-closing: no boundary edges
1556 BOOST_CHECK(solid.IsClosed());
1557 BOOST_CHECK(solid.IsOrientationConsistent());
1558
1559 // TGeoTorus(R, Rmin, Rmax): a solid torus has Rmin = 0, Rmax = tube radius
1560 TGeoTorus reference("referenceTorus", majorR, 0., minorR);
1561 compareContainsGrid(solid, reference, 4.5, 11);
1562
1563 // analytic x-axis crossings (see the kernel test): from outside and from inside the material
1564 const double outsidePoint[3] = {-10., 0., 0.};
1565 const double alongX[3] = {1., 0., 0.};
1566 checkClose(solid.DistFromOutside(outsidePoint, alongX, 3), 6., 1.e-7);
1567 const double materialPoint[3] = {majorR + minorR - 0.25, 0., 0.}; // inside the tube on the +x side
1568 BOOST_CHECK(solid.Contains(materialPoint));
1569 checkClose(solid.DistFromInside(materialPoint, alongX, 3), 0.25, 1.e-7);
1570
1571 // a couple of oblique rays cross-checked against the ROOT torus
1572 compareDistance(solid, reference, {-10., 0.3, 0.2}, {1., 0., 0.}, 1.e-6);
1573 compareDistance(solid, reference, {0., 0., 5.}, {0., 0., -1.}, 1.e-6); // clean axial miss (through the hole)
1574
1575 // exact capacity: 2 pi^2 R r^2
1576 checkClose(solid.Capacity(), reference.Capacity(), 1.e-7);
1577 checkClose(solid.Capacity(), 2. * surf::kPi * surf::kPi * majorR * minorR * minorR, 1.e-9);
1578
1579 int meshVertices = 0;
1580 int meshSegments = 0;
1581 int meshPolygons = 0;
1582 solid.GetMeshNumbers(meshVertices, meshSegments, meshPolygons);
1583 BOOST_CHECK_GT(meshVertices, 0);
1584 BOOST_CHECK_GT(meshPolygons, 0);
1585}
1586
1587BOOST_AUTO_TEST_CASE(WireTrimmedTorusMatchesSection)
1588{
1589 // A partial toroidal patch (ring [0, pi/2], tube [0.2, 2.0] - a non-wrapping fillet-like arc)
1590 // built two ways must classify points identically: with the scalar parametric rectangle and
1591 // with an equivalent (phiRing, phiTube) line-wire trim. This exercises the wire-trim path
1592 // (numeric capacity, conservative Safety) and the periodic-in-both-angles unwrapping.
1593 constexpr double majorR = 4.;
1594 constexpr double minorR = 1.5;
1595 constexpr double tubeLow = 0.2;
1596 constexpr double tubeHigh = 2.0;
1597 std::string error;
1598
1599 surf::TorusBoundedSurface scalarSection;
1600 BOOST_REQUIRE(scalarSection.initialize({0.2, -0.1, 0.3}, {0., 0., 1.}, {1., 0., 0.}, majorR, minorR, 0.,
1601 surf::kHalfPi, tubeLow, tubeHigh - tubeLow, false, error));
1602
1603 surf::TorusBoundedSurface wireSection;
1604 const auto wire = paramRectWireCurves(0., surf::kHalfPi, tubeLow, tubeHigh);
1605 BOOST_REQUIRE(wireSection.initialize({0.2, -0.1, 0.3}, {0., 0., 1.}, {1., 0., 0.}, majorR, minorR, 0., surf::kHalfPi,
1606 tubeLow, tubeHigh - tubeLow, false, wire, {}, error));
1607 BOOST_CHECK(wireSection.hasWireTrim());
1608
1609 // classification agrees across a set of on-surface probes at several ring/tube angles
1610 for (double ring : {0.1, 0.7, 1.2, 1.7, 2.5}) {
1611 for (double tube : {0.3, 0.8, 1.5, 1.9, 2.6}) {
1612 const surf::Vec3 probe = scalarSection.pointAt(ring, tube);
1613 BOOST_TEST_CONTEXT("ring = " << ring << " tube = " << tube)
1614 {
1615 BOOST_CHECK_EQUAL(scalarSection.containsPointOnSurface(probe), wireSection.containsPointOnSurface(probe));
1616 }
1617 }
1618 }
1619
1620 // wire-trim capacity is numeric (flagged inexact) but must approximate the exact scalar value
1621 BOOST_CHECK(scalarSection.capacityIsExact());
1622 BOOST_CHECK(!wireSection.capacityIsExact());
1623 BOOST_CHECK_SMALL(wireSection.capacityContribution() - scalarSection.capacityContribution(),
1624 1.e-2 * std::abs(scalarSection.capacityContribution()));
1625}
1626
1627BOOST_AUTO_TEST_CASE(BVHConstructionAndTraversal)
1628{
1629 constexpr double halfX = 1.;
1630 constexpr double halfY = 2.;
1631 constexpr double halfZ = 3.;
1632
1633 SurfaceSolid solid("bvhBox");
1634 addBoxSurfaces(solid, halfX, halfY, halfZ);
1635 BOOST_CHECK(!solid.HasBVH()); // built only in CloseShape
1636 solid.CloseShape();
1637 BOOST_REQUIRE(solid.HasBVH());
1638
1639 // the BVH root box must enclose the exact solid bounds and stay conservative: not tighter
1640 // than the exact bounds, not looser than the documented expansion (plus float rounding)
1641 Point3D lower{};
1642 Point3D upper{};
1643 BOOST_REQUIRE(solid.GetBVHRootBounds(lower, upper));
1644 const Point3D exactLower{-halfX, -halfY, -halfZ};
1645 const Point3D exactUpper{halfX, halfY, halfZ};
1646 constexpr double boxSlack = 2. * surf::kBVHBoxTolerance;
1647 for (int dimension = 0; dimension < 3; ++dimension) {
1648 BOOST_TEST_CONTEXT("dimension = " << dimension)
1649 {
1650 BOOST_CHECK(lower[dimension] <= exactLower[dimension]);
1651 BOOST_CHECK(lower[dimension] >= exactLower[dimension] - boxSlack);
1652 BOOST_CHECK(upper[dimension] >= exactUpper[dimension]);
1653 BOOST_CHECK(upper[dimension] <= exactUpper[dimension] + boxSlack);
1654 }
1655 }
1656
1657 // a ray through the box must traverse (at least) the entry and exit face leaves ...
1658 BOOST_CHECK_GE(solid.CountBVHRayCandidates({-2., 0., 0.}, {1., 0., 0.}), 2);
1659 // ... while a ray pointing away from the solid reaches no leaf at all
1660 BOOST_CHECK_EQUAL(solid.CountBVHRayCandidates({0., 5., 0.}, {0., 1., 0.}), 0);
1661
1662 // two disjoint boxes: BVH pruning with well-separated primitive clusters. The union of two
1663 // closed manifolds is still a closed manifold, and parity containment handles it naturally.
1664 constexpr double half = 1.;
1665 constexpr double centerX = 3.;
1666 SurfaceSolid twoBoxes("twoBoxes");
1667 addBoxSurfaces(twoBoxes, half, half, half, {-centerX, 0., 0.});
1668 addBoxSurfaces(twoBoxes, half, half, half, {centerX, 0., 0.});
1669 twoBoxes.CloseShape();
1670 BOOST_REQUIRE(twoBoxes.HasBVH());
1671 BOOST_CHECK_EQUAL(twoBoxes.GetNsurfaces(), 12);
1672 BOOST_CHECK(twoBoxes.IsClosed());
1673 BOOST_CHECK(twoBoxes.IsOrientationConsistent());
1674
1675 const auto analyticInside = [&](const double* point) {
1676 return (std::abs(std::abs(point[0]) - centerX) < half) && std::abs(point[1]) < half && std::abs(point[2]) < half;
1677 };
1678 constexpr int samples = 9;
1679 constexpr double extent = 5.;
1680 for (int stepX = 0; stepX < samples; ++stepX) {
1681 for (int stepY = 0; stepY < samples; ++stepY) {
1682 for (int stepZ = 0; stepZ < samples; ++stepZ) {
1683 const double point[3] = {-extent + 2. * extent * (stepX + 0.517) / samples,
1684 -extent + 2. * extent * (stepY + 0.263) / samples,
1685 -extent + 2. * extent * (stepZ + 0.741) / samples};
1686 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
1687 {
1688 const bool bvhInside = twoBoxes.Contains(point);
1689 BOOST_CHECK_EQUAL(bvhInside, twoBoxes.Contains_Loop(point));
1690 BOOST_CHECK_EQUAL(bvhInside, analyticInside(point));
1691 }
1692 }
1693 }
1694 }
1695}
1696
1697BOOST_AUTO_TEST_CASE(ContainsBoundaryPointsAndCapsule)
1698{
1699 constexpr double halfX = 1.;
1700 constexpr double halfY = 2.;
1701 constexpr double halfZ = 3.;
1702
1703 SurfaceSolid box("boundaryBox");
1704 addBoxSurfaces(box, halfX, halfY, halfZ);
1705 box.CloseShape();
1706
1707 // boundary policy: points exactly on faces, edges and vertices count as inside,
1708 // in the BVH-accelerated path and in the trivial loop alike
1709 const std::array<std::array<double, 3>, 6> boundaryPoints{{
1710 {halfX, 0., 0.}, // face
1711 {0., -halfY, 0.}, // face
1712 {halfX, halfY, 0.}, // edge
1713 {-halfX, 0., halfZ}, // edge
1714 {halfX, halfY, halfZ}, // vertex
1715 {-halfX, -halfY, -halfZ} // vertex
1716 }};
1717 for (const auto& point : boundaryPoints) {
1718 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
1719 {
1720 BOOST_CHECK(box.Contains(point.data()));
1721 BOOST_CHECK(box.Contains_Loop(point.data()));
1722 }
1723 }
1724
1725 // capsule: cylinder barrel closed by two spherical endcaps - a mixed quadric fixture with no
1726 // ROOT primitive equivalent, cross-validated against the trivial loop and the analytic shape
1727 constexpr double radius = 1.;
1728 constexpr double halfHeight = 1.5;
1729 SurfaceSolid capsule("capsule");
1730 BOOST_REQUIRE(capsule.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -halfHeight,
1731 halfHeight));
1732 BOOST_REQUIRE(capsule.AddSphericalSurface({0., 0., halfHeight}, {0., 0., 1.}, {1., 0., 0.}, radius, 0.,
1733 surf::kPi / 2.));
1734 BOOST_REQUIRE(capsule.AddSphericalSurface({0., 0., -halfHeight}, {0., 0., -1.}, {1., 0., 0.}, radius, 0.,
1735 surf::kPi / 2.));
1736 capsule.CloseShape();
1737 BOOST_REQUIRE(capsule.HasBVH());
1738 BOOST_CHECK(capsule.IsClosed());
1739 BOOST_CHECK(capsule.IsOrientationConsistent());
1740
1741 const auto capsuleInside = [&](const double* point) {
1742 const double axialDistance = std::max(0., std::abs(point[2]) - halfHeight);
1743 return std::hypot(point[0], point[1], axialDistance) < radius;
1744 };
1745 constexpr int samples = 9;
1746 constexpr double extent = 3.;
1747 for (int stepX = 0; stepX < samples; ++stepX) {
1748 for (int stepY = 0; stepY < samples; ++stepY) {
1749 for (int stepZ = 0; stepZ < samples; ++stepZ) {
1750 const double point[3] = {-extent + 2. * extent * (stepX + 0.517) / samples,
1751 -extent + 2. * extent * (stepY + 0.263) / samples,
1752 -extent + 2. * extent * (stepZ + 0.741) / samples};
1753 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
1754 {
1755 const bool bvhInside = capsule.Contains(point);
1756 BOOST_CHECK_EQUAL(bvhInside, capsule.Contains_Loop(point));
1757 BOOST_CHECK_EQUAL(bvhInside, capsuleInside(point));
1758 }
1759 }
1760 }
1761 }
1762
1763 // a few characteristic capsule points, incl. points exactly on the barrel and cap surfaces
1764 const double onBarrel[3] = {radius, 0., 0.5};
1765 const double onCapApex[3] = {0., 0., halfHeight + radius};
1766 const double aboveApex[3] = {0., 0., halfHeight + radius + 0.01};
1767 const double onRim[3] = {radius, 0., halfHeight}; // shared cylinder/sphere rim
1768 BOOST_CHECK(capsule.Contains(onBarrel));
1769 BOOST_CHECK(capsule.Contains(onCapApex));
1770 BOOST_CHECK(!capsule.Contains(aboveApex));
1771 BOOST_CHECK(capsule.Contains(onRim));
1772
1773 // exact capacity: cylinder plus a full sphere from the two hemispheres
1774 checkClose(capsule.Capacity(),
1775 surf::kPi * radius * radius * 2. * halfHeight + 4. * surf::kPi * radius * radius * radius / 3., 1.e-9);
1776}
1777
1778BOOST_AUTO_TEST_CASE(DistanceBVHMatchesLoopOnAllFixtures)
1779{
1780 // The BVH distance queries against their all-surfaces oracle, over every fixture family and a
1781 // dense point x direction sweep. This is the correctness guard that does not depend on any
1782 // reference shape: it isolates traversal and pruning from the analytic kernels, which the
1783 // per-shape cases above already validate against ROOT.
1784 const std::array<std::pair<std::unique_ptr<SurfaceSolid>, double>, 7> fixtures{{
1785 {makeBoxSolid("loopBox", 1., 2., 3.), 4.},
1786 {makeTubeSolid("loopTube", 0., 2., 3.), 4.},
1787 {makeTubeSolid("loopHollowTube", 1., 2., 3.), 4.},
1788 {makeConeSolid("loopCone", 2., 1., 3.), 4.},
1789 {makeSphereSolid("loopSphere", 2.5), 3.5},
1790 {makeTorusSolid("loopTorus", 3., 1.), 4.5},
1791 {makeCapsuleSolid("loopCapsule", 1., 1.5), 3.},
1792 }};
1793
1794 for (const auto& [solid, extent] : fixtures) {
1795 BOOST_TEST_CONTEXT("fixture = " << solid->GetName())
1796 {
1797 BOOST_REQUIRE(solid->HasBVH());
1798 sweepDistanceAgainstLoop(*solid, extent, 5);
1799 }
1800 }
1801}
1802
1803BOOST_AUTO_TEST_CASE(DistanceSweepsMatchRootPrimitives)
1804{
1805 // Systematic point x direction sweeps against the ROOT primitives, for both the entering and
1806 // the exiting query. The per-shape cases above check a handful of hand-picked rays; this walks
1807 // a grid, so it also covers rays that miss, that graze, and that cross a hole.
1808 constexpr int samples = 5;
1809
1810 const auto box = makeBoxSolid("sweepBox", 1., 2., 3.);
1811 TGeoBBox boxReference("sweepBoxReference", 1., 2., 3.);
1812 sweepDistanceAgainstReference(*box, boxReference, 4., samples);
1813
1814 const auto tube = makeTubeSolid("sweepTube", 0., 2., 3.);
1815 TGeoTube tubeReference("sweepTubeReference", 0., 2., 3.);
1816 sweepDistanceAgainstReference(*tube, tubeReference, 4., samples);
1817
1818 const auto hollowTube = makeTubeSolid("sweepHollowTube", 1., 2., 3.);
1819 TGeoTube hollowTubeReference("sweepHollowTubeReference", 1., 2., 3.);
1820 sweepDistanceAgainstReference(*hollowTube, hollowTubeReference, 4., samples);
1821
1822 const auto cone = makeConeSolid("sweepCone", 2., 1., 3.);
1823 TGeoCone coneReference("sweepConeReference", 3., 0., 2., 0., 1.);
1824 sweepDistanceAgainstReference(*cone, coneReference, 4., samples);
1825
1826 const auto sphere = makeSphereSolid("sweepSphere", 2.5);
1827 TGeoSphere sphereReference("sweepSphereReference", 0., 2.5);
1828 sweepDistanceAgainstReference(*sphere, sphereReference, 3.5, samples);
1829
1830 // the torus kernel solves a quartic, so it carries more rounding than the quadric shapes
1831 const auto torus = makeTorusSolid("sweepTorus", 3., 1.);
1832 TGeoTorus torusReference("sweepTorusReference", 3., 0., 1.);
1833 sweepDistanceAgainstReference(*torus, torusReference, 4.5, samples, 1.e-6);
1834}
1835
1836BOOST_AUTO_TEST_CASE(DistanceHardCases)
1837{
1838 constexpr double halfX = 1.;
1839 constexpr double halfY = 2.;
1840 constexpr double halfZ = 3.;
1841 const auto box = makeBoxSolid("hardCaseBox", halfX, halfY, halfZ);
1842 TGeoBBox reference("hardCaseBoxReference", halfX, halfY, halfZ);
1843
1844 // --- rays through a shared edge and a shared vertex -----------------------------------------
1845 // Both are seen by more than one patch, so the same crossing is reported several times. Taking
1846 // the minimum over entering hits is insensitive to that, but the BVH and the loop must still
1847 // see the same set, which is what the loop cross-check asserts.
1848 const std::array<std::array<double, 3>, 4> throughFeature{{
1849 {-5., halfY, 0.}, // straight at the x = -1 / y = +2 edge
1850 {-5., halfY, halfZ}, // straight at the (-1, +2, +3) vertex
1851 {0., 0., 0.}, // from the centre out through the +y/+z edge
1852 {-5., -5., -5.}, // body diagonal through the (-1,-2,-3) vertex
1853 }};
1854 const std::array<std::array<double, 3>, 4> throughFeatureDirection{{
1855 {1., 0., 0.},
1856 {1., 0., 0.},
1857 unitDirection(0., 1., 1.5),
1858 unitDirection(1., 2., 3.),
1859 }};
1860 for (size_t index = 0; index < throughFeature.size(); ++index) {
1861 checkDistanceAgainstLoop(*box, throughFeature[index], throughFeatureDirection[index]);
1862 }
1863
1864 // --- grazing / tangent rays ------------------------------------------------------------------
1865 // A ray in the plane of a face never enters: every hit it can report is tangential, and a
1866 // tangential hit is not a crossing. Both queries must agree with the loop and find nothing.
1867 const std::array<std::array<double, 3>, 3> grazing{{
1868 {-5., halfY, 0.}, // in the plane of the y = +2 face
1869 {halfX, -5., 0.}, // in the plane of the x = +1 face
1870 {0., 0., halfZ}, // in the plane of the z = +3 face
1871 }};
1872 const std::array<std::array<double, 3>, 3> grazingDirection{{
1873 {1., 0., 0.},
1874 {0., 1., 0.},
1875 unitDirection(1., 1., 0.),
1876 }};
1877 for (size_t index = 0; index < grazing.size(); ++index) {
1878 checkDistanceAgainstLoop(*box, grazing[index], grazingDirection[index]);
1879 }
1880 // a cylinder tangent ray: the double root must not be reported as two crossings
1881 const auto tube = makeTubeSolid("hardCaseTube", 0., 2., 3.);
1882 checkDistanceAgainstLoop(*tube, {-5., 2., 0.}, {1., 0., 0.});
1883 checkDistanceAgainstLoop(*tube, {-5., 2. - 1.e-7, 0.}, {1., 0., 0.}); // just inside tangency
1884 checkDistanceAgainstLoop(*tube, {-5., 2. + 1.e-7, 0.}, {1., 0., 0.}); // just outside tangency
1885
1886 // --- rays starting exactly on a surface -------------------------------------------------------
1887 // The on-surface convention (a crossing at t = 0 is below kRayTolerance and is not reported) is
1888 // inherited from the analytic kernels; what matters here is that the BVH reproduces it exactly.
1889 const std::array<std::array<double, 3>, 4> onSurface{{
1890 {halfX, 0., 0.}, // on a face
1891 {-halfX, 0.5, -1.}, // on the opposite face
1892 {halfX, halfY, 0.}, // on an edge
1893 {halfX, halfY, halfZ}, // on a vertex
1894 }};
1895 for (const auto& point : onSurface) {
1896 for (const auto& direction : probeDirections()) {
1897 checkDistanceAgainstLoop(*box, point, direction);
1898 }
1899 }
1900 // just off the surface the answers must be the ordinary ones: entering after ~1e-6 from
1901 // outside, exiting after the full traversal from inside
1902 const std::array<double, 3> justOutside{halfX + 1.e-6, 0., 0.};
1903 const std::array<double, 3> justInside{halfX - 1.e-6, 0., 0.};
1904 const std::array<double, 3> inward{-1., 0., 0.};
1905 checkClose(box->DistFromOutside(justOutside.data(), inward.data(), 3), 1.e-6, 1.e-12);
1906 checkClose(box->DistFromInside(justInside.data(), inward.data(), 3), 2. * halfX - 1.e-6, 1.e-12);
1907 checkClose(box->DistFromOutside(justOutside.data(), inward.data(), 3),
1908 reference.DistFromOutside(justOutside.data(), inward.data(), 3), 1.e-12);
1909
1910 // --- stepmax ----------------------------------------------------------------------------------
1911 const std::array<double, 3> farOutside{-5., 0., 0.};
1912 const std::array<double, 3> alongX{1., 0., 0.};
1913 const double entryDistance = box->DistFromOutside(farOutside.data(), alongX.data(), 3);
1914 checkClose(entryDistance, 4.);
1915 // a hit beyond stepmax must not be reported ...
1916 BOOST_CHECK_EQUAL(box->DistFromOutside(farOutside.data(), alongX.data(), 3, entryDistance * 0.5),
1917 TGeoShape::Big());
1918 // ... including when it lies only just beyond, and the cheap bounding-box reject must agree
1919 BOOST_CHECK_EQUAL(box->DistFromOutside(farOutside.data(), alongX.data(), 3, entryDistance - 1.e-3),
1920 TGeoShape::Big());
1921 // ... while a stepmax past the hit changes nothing
1922 checkClose(box->DistFromOutside(farOutside.data(), alongX.data(), 3, entryDistance + 1.e-3), entryDistance);
1923 checkClose(box->DistFromOutside(farOutside.data(), alongX.data(), 3, 100.), entryDistance);
1924 // the same for the exiting query
1925 const std::array<double, 3> center{0., 0., 0.};
1926 const double exitDistance = box->DistFromInside(center.data(), alongX.data(), 3);
1927 checkClose(exitDistance, halfX);
1928 BOOST_CHECK_EQUAL(box->DistFromInside(center.data(), alongX.data(), 3, exitDistance * 0.5), TGeoShape::Big());
1929 checkClose(box->DistFromInside(center.data(), alongX.data(), 3, exitDistance * 2.), exitDistance);
1930 // and the loop must honour stepmax identically, at and around the hit
1931 for (const double stepmax : {entryDistance * 0.5, entryDistance - 1.e-9, entryDistance, entryDistance + 1.e-9,
1932 entryDistance * 2.}) {
1933 checkDistanceAgainstLoop(*box, farOutside, alongX, stepmax);
1934 checkDistanceAgainstLoop(*box, center, alongX, stepmax);
1935 }
1936
1937 // --- a ray that cannot reach the solid at all --------------------------------------------------
1938 const std::array<double, 3> wayOff{-1000., 0., 0.};
1939 BOOST_CHECK_EQUAL(box->DistFromOutside(wayOff.data(), alongX.data(), 3, 10.), TGeoShape::Big());
1940 checkClose(box->DistFromOutside(wayOff.data(), alongX.data(), 3), 999.);
1941}
1942
1943BOOST_AUTO_TEST_CASE(RayTMaxPruningIsOptimizationOnly)
1944{
1945 // A row of well-separated boxes: a ray along the row enters the first one, after which every
1946 // node behind it is beyond the tightened bound and must not be visited. Turning the tightening
1947 // off must cost candidates without changing a single answer.
1948 constexpr int boxCount = 8;
1949 constexpr double half = 0.5;
1950 constexpr double spacing = 4.;
1951
1952 SurfaceSolid row("prunedRow");
1953 for (int boxIndex = 0; boxIndex < boxCount; ++boxIndex) {
1954 addBoxSurfaces(row, half, half, half, {boxIndex * spacing, 0., 0.});
1955 }
1956 row.CloseShape();
1957 BOOST_REQUIRE(row.HasBVH());
1958 BOOST_CHECK(row.IsClosed());
1959 BOOST_CHECK_EQUAL(row.GetNsurfaces(), 6 * boxCount);
1960
1961 const std::array<double, 3> beforeRow{-5., 0., 0.};
1962 const std::array<double, 3> alongRow{1., 0., 0.};
1963
1964 BOOST_CHECK(SurfaceSolid::GetRayTMaxPruning()); // on by default
1965
1966 SurfaceSolid::ResetRayCandidateCounter();
1967 const double prunedDistance = row.DistFromOutside(beforeRow.data(), alongRow.data(), 3);
1968 const long long prunedCandidates = SurfaceSolid::GetRayCandidateCount();
1969
1970 SurfaceSolid::SetRayTMaxPruning(false);
1971 SurfaceSolid::ResetRayCandidateCounter();
1972 const double unprunedDistance = row.DistFromOutside(beforeRow.data(), alongRow.data(), 3);
1973 const long long unprunedCandidates = SurfaceSolid::GetRayCandidateCount();
1974 SurfaceSolid::SetRayTMaxPruning(true);
1975
1976 // same answer, and it is the entry face of the first box
1977 BOOST_CHECK_EQUAL(prunedDistance, unprunedDistance);
1978 checkClose(prunedDistance, 5. - half);
1979 // ... reached after strictly less work
1980 BOOST_CHECK_GT(prunedCandidates, 0);
1981 BOOST_CHECK_LT(prunedCandidates, unprunedCandidates);
1982
1983 // the answers stay identical over a full sweep, which is the property that lets the benchmark
1984 // treat the switch as a pure cost knob
1985 sweepDistanceAgainstLoop(row, 1.2 * boxCount * spacing / 2., 4);
1986
1987 // the counter is not touched by the _Loop variants, which visit everything by construction
1988 SurfaceSolid::ResetRayCandidateCounter();
1989 row.DistFromOutside_Loop(beforeRow.data(), alongRow.data());
1990 BOOST_CHECK_EQUAL(SurfaceSolid::GetRayCandidateCount(), 0);
1991}
1992
1993BOOST_AUTO_TEST_CASE(RayTMaxPruningKeepsNearTies)
1994{
1995 // Two entering candidates a controlled hair apart, one of them behind a very loose bounding
1996 // box: the geometry in which a mis-set tmax would do its damage.
1997 //
1998 // Why this shape of test. A node is culled when the ray *enters its box* beyond tmax, and a box
1999 // is always entered no later than the patch inside it is hit. A candidate nearer than the
2000 // current best therefore has a box entered earlier than the current best's hit, and survives
2001 // any bound at or above that hit -- which is why the implementation's bound (the best hit,
2002 // rounded up, plus the box inflation) can be argued safe rather than merely measured safe. The
2003 // narrow window that is left needs a loose box visited first and a tight one entered between
2004 // the loose patch's box and its hit, so that is what this fixture builds.
2005 //
2006 // Fixture: a sphere hit by a near-limb ray far behind where its bounding box starts, plus a
2007 // small flat patch just in front of that hit, swept over several decades of separation. It is
2008 // deliberately not a closed manifold (the patch clips into the sphere) and is closed with the
2009 // diagnostics off: it exists to place the two candidates, not to model a solid. That is
2010 // legitimate because the oracle is DistFromOutside_Loop, which minimises over the same hits.
2011 //
2012 // Scope, honestly: mutation-testing this suite showed that a bound scaled by 0.5 is caught
2013 // loudly by the sweeps above, while one scaled by 0.999 is caught by neither them nor this
2014 // case -- with so few primitives, both leaves are box-tested in the same inner-node visit,
2015 // before any leaf callback has run and tightened anything. So this pins the near-tie geometry
2016 // and the pruning-on == pruning-off == loop identity; the guarantee against a subtly tight
2017 // bound rests on the argument above, not on this test.
2018 constexpr double radius = 2.;
2019 constexpr double rayOffsetY = 1.9; // near the limb: box entered at x = -2, surface at x = -0.62
2020 const double sphereHitX = -std::sqrt(radius * radius - rayOffsetY * rayOffsetY);
2021 const std::array<double, 3> rayOrigin{-10., rayOffsetY, 0.};
2022 const std::array<double, 3> alongX{1., 0., 0.};
2023 const double sphereDistance = sphereHitX - rayOrigin[0];
2024
2025 // relative offsets spanning several decades below the sphere hit, so any tmax that is too
2026 // tight by anything in that range is caught by at least one of them regardless of how the
2027 // builder happens to lay out the tree
2028 for (const double relativeOffset : {1.e-5, 3.e-5, 1.e-4, 3.e-4, 1.e-3, 3.e-3, 1.e-2}) {
2029 const double patchX = sphereHitX - relativeOffset * sphereDistance;
2030 BOOST_TEST_CONTEXT("relativeOffset = " << relativeOffset << " patchX = " << patchX)
2031 {
2032 SurfaceSolid solid("nearTie");
2033 BOOST_REQUIRE(solid.AddSphericalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius));
2034 // axisU x axisV = z x y = -x: the patch faces the incoming ray, so crossing it enters
2035 BOOST_REQUIRE(solid.AddPlanarSurface({patchX, rayOffsetY - 0.1, -0.1}, {0., 0., 1.}, {0., 1., 0.},
2036 rectangleWire(0.2, 0.2)));
2037 solid.CloseShape(false);
2038 BOOST_REQUIRE(solid.HasBVH());
2039
2040 // the flat patch is the nearer entering crossing, by construction
2041 const double expected = patchX - rayOrigin[0];
2042 for (const bool pruning : {true, false}) {
2043 SurfaceSolid::SetRayTMaxPruning(pruning);
2044 BOOST_TEST_CONTEXT("pruning = " << pruning)
2045 {
2046 const double distance = solid.DistFromOutside(rayOrigin.data(), alongX.data(), 3);
2047 checkClose(distance, expected, 1.e-9);
2048 BOOST_CHECK_EQUAL(distance, solid.DistFromOutside_Loop(rayOrigin.data(), alongX.data()));
2049 }
2050 }
2051 SurfaceSolid::SetRayTMaxPruning(true);
2052 }
2053 }
2054}
2055
2056BOOST_AUTO_TEST_CASE(CurvedPlanarStadiumPrism)
2057{
2058 // A stadium (rectangle with two semicircular ends) extruded along z: the two end caps are
2059 // planar faces with mixed line+arc wires - the general curved-planar case a disk cannot
2060 // express. Straight sides are flat rectangles; the round ends are half-cylinders.
2061 constexpr double halfLen = 3.; // straight half-length along x
2062 constexpr double radius = 2.; // corner radius and half-width along y
2063 constexpr double halfHeight = 4.; // half-height along z
2064
2065 // Stadium cross-section boundary in the cap's local (u=x, v=y) frame, CCW: bottom line,
2066 // right semicircle, top line, left semicircle.
2067 const std::vector<BoundaryCurve> stadiumWire{
2068 BoundaryCurve::makeLine({-halfLen, -radius}, {halfLen, -radius}),
2069 BoundaryCurve::makeArc({halfLen, 0.}, radius, -surf::kHalfPi, surf::kHalfPi),
2070 BoundaryCurve::makeLine({halfLen, radius}, {-halfLen, radius}),
2071 BoundaryCurve::makeArc({-halfLen, 0.}, radius, surf::kHalfPi, 3. * surf::kHalfPi)};
2072
2073 SurfaceSolid solid("stadiumPrism");
2074 // caps (outward +z / -z: the bottom cap flips axisV)
2075 BOOST_REQUIRE(solid.AddCurvedPlanarSurface({0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, stadiumWire));
2076 BOOST_REQUIRE(solid.AddCurvedPlanarSurface({0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, stadiumWire));
2077 // flat side walls at y = +/- radius (outward +/- y)
2078 BOOST_REQUIRE(solid.AddPlanarSurface({-halfLen, radius, -halfHeight}, {0., 0., 1.}, {1., 0., 0.},
2079 rectangleWire(2. * halfHeight, 2. * halfLen)));
2080 BOOST_REQUIRE(solid.AddPlanarSurface({-halfLen, -radius, -halfHeight}, {1., 0., 0.}, {0., 0., 1.},
2081 rectangleWire(2. * halfLen, 2. * halfHeight)));
2082 // round ends as half-cylinders (outer walls)
2083 BOOST_REQUIRE(solid.AddCylindricalSurface({halfLen, 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -halfHeight,
2084 halfHeight, -surf::kHalfPi, surf::kPi));
2085 BOOST_REQUIRE(solid.AddCylindricalSurface({-halfLen, 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -halfHeight,
2086 halfHeight, surf::kHalfPi, surf::kPi));
2087 solid.CloseShape();
2088
2089 BOOST_CHECK(solid.IsClosed());
2090 BOOST_CHECK(solid.IsOrientationConsistent());
2091
2092 // exact capacity: (rectangle 2L x 2R + full circle pi R^2) x height 2H
2093 checkClose(solid.Capacity(), (4. * halfLen * radius + surf::kPi * radius * radius) * 2. * halfHeight, 1.e-6);
2094
2095 const auto stadiumInside = [&](const double* point) {
2096 if (std::abs(point[2]) > halfHeight) {
2097 return false;
2098 }
2099 const double ax = std::abs(point[0]);
2100 const double dx = ax > halfLen ? ax - halfLen : 0.;
2101 return dx * dx + point[1] * point[1] <= radius * radius;
2102 };
2103 // deterministic grid spanning well beyond the solid on every axis
2104 constexpr int samples = 21;
2105 const double extentX = 6., extentY = 3.5, extentZ = 5.;
2106 for (int stepX = 0; stepX < samples; ++stepX) {
2107 for (int stepY = 0; stepY < samples; ++stepY) {
2108 for (int stepZ = 0; stepZ < samples; ++stepZ) {
2109 const double point[3] = {-extentX + 2. * extentX * (stepX + 0.517) / samples,
2110 -extentY + 2. * extentY * (stepY + 0.263) / samples,
2111 -extentZ + 2. * extentZ * (stepZ + 0.741) / samples};
2112 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
2113 {
2114 BOOST_CHECK_EQUAL(solid.Contains(point), stadiumInside(point));
2115 }
2116 }
2117 }
2118 }
2119}
2120
2121BOOST_AUTO_TEST_CASE(WireTrimmedCylinderMatchesTube)
2122{
2123 constexpr double radius = 2.;
2124 constexpr double halfHeight = 3.;
2125
2126 // lateral wall via the wire-trim overload: the trim is the full parametric rectangle
2127 // phi in [0, 2pi] x h in [-hh, hh] expressed as four line edges, which must behave exactly like
2128 // the scalar rectangle path (equivalence check).
2129 SurfaceSolid solid("wireTrimmedCylinder");
2130 BOOST_REQUIRE(solid.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -halfHeight,
2131 halfHeight, 0., surf::kTwoPi, false,
2132 paramRectWire(0., surf::kTwoPi, -halfHeight, halfHeight)));
2133 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radius));
2134 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radius));
2135 solid.CloseShape();
2136
2137 BOOST_CHECK(solid.IsClosed());
2138 BOOST_CHECK(solid.IsOrientationConsistent());
2139
2140 TGeoTube reference("wireTrimTube", 0., radius, halfHeight);
2141 compareContainsGrid(solid, reference, 4., 9);
2142 compareDistance(solid, reference, {5., 0., 0.}, {-1., 0., 0.});
2143 compareDistance(solid, reference, {0., 0., 5.}, {0., 0., -1.});
2144 compareDistance(solid, reference, {-4., -1., -2.}, unitDirection(1., 0.3, 0.5));
2145 compareDistance(solid, reference, {0., 0., 0.}, unitDirection(1., 1., 1.));
2146 compareDistance(solid, reference, {5., 2.5, 0.}, {-1., 0., 0.}); // grazing miss
2147
2148 // capacity is numerically integrated for a wire trim; the wall integrand is constant here so it
2149 // stays accurate, but compare with a relaxed tolerance to reflect the quadrature
2150 checkClose(solid.Capacity(), reference.Capacity(), 1.e-6);
2151
2152 double normal[3] = {0., 0., 0.};
2153 const double sidePoint[3] = {radius, 0., 1.};
2154 const double alongX[3] = {1., 0., 0.};
2155 solid.ComputeNormal(sidePoint, alongX, normal);
2156 checkClose(normal[0], 1.);
2157 checkClose(normal[1], 0.);
2158 checkClose(normal[2], 0.);
2159}
2160
2161BOOST_AUTO_TEST_CASE(WireTrimmedConeMatchesCone)
2162{
2163 constexpr double halfHeight = 3.;
2164 constexpr double radiusAtBottom = 2.;
2165 constexpr double radiusAtTop = 1.;
2166
2167 SurfaceSolid solid("wireTrimmedCone");
2168 BOOST_REQUIRE(solid.AddConicalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radiusAtBottom, radiusAtTop,
2169 -halfHeight, halfHeight, 0., surf::kTwoPi, false,
2170 paramRectWire(0., surf::kTwoPi, -halfHeight, halfHeight)));
2171 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radiusAtTop));
2172 BOOST_REQUIRE(addDiskSurface(solid, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radiusAtBottom));
2173 solid.CloseShape();
2174
2175 BOOST_CHECK(solid.IsClosed());
2176 BOOST_CHECK(solid.IsOrientationConsistent());
2177
2178 TGeoCone reference("wireTrimCone", halfHeight, 0., radiusAtBottom, 0., radiusAtTop);
2179 compareContainsGrid(solid, reference, 3.5, 9);
2180 compareDistance(solid, reference, {5., 0., 0.}, {-1., 0., 0.});
2181 compareDistance(solid, reference, {0., 0., 0.}, {1., 0., 0.});
2182 compareDistance(solid, reference, {-4., 0.2, -2.}, unitDirection(1., 0.05, 0.3));
2183 // The wire-trimmed cone's capacity used to need a 1e-3 allowance for the grid quadrature; the
2184 // Green's-theorem contour form is exact on this rectangle-tracing wire, so it can be held to the
2185 // same tolerance as any untrimmed patch. A regression to the grid rule fails here.
2186 checkClose(solid.Capacity(), reference.Capacity(), 1.e-9);
2187}
2188
2189namespace
2190{
2191// Check that a patch's display mesh keeps its vertex count and stays within the chord sag of radius on the patch;
2192// boundarySag adds the sag of a trim side that the wire sampling leaves as one chord.
2193void checkDisplayMeshFollowsPatch(const surf::BoundedSurface& patch, double radius, size_t expectedVertices,
2194 double boundarySag = 0.)
2195{
2196 std::vector<surf::Vec3> vertices;
2197 std::vector<std::array<int, 3>> triangles;
2198 patch.appendDisplayMesh(vertices, triangles);
2199 BOOST_CHECK_EQUAL(vertices.size(), expectedVertices);
2200 BOOST_CHECK_EQUAL(triangles.size(), expectedVertices - 2);
2201 const double maxSag = 1.05 * (radius * (1. - std::cos(surf::kPi / surf::kArcSamples)) + boundarySag);
2202 double worstSag = 0.;
2203 for (const auto& triangle : triangles) {
2204 const surf::Vec3& first = vertices[triangle[0]];
2205 const surf::Vec3& second = vertices[triangle[1]];
2206 const surf::Vec3& third = vertices[triangle[2]];
2207 const double longestSq = std::max({normSq(second - first), normSq(third - second), normSq(first - third)});
2208 BOOST_CHECK_GT(norm(cross(second - first, third - first)), 1.e-6 * longestSq);
2209 for (const surf::Vec3& probe : {(first + second + third) * (1. / 3.), (first + second) * 0.5,
2210 (second + third) * 0.5, (third + first) * 0.5}) {
2211 worstSag = std::max(worstSag, std::sqrt(patch.distanceSqToPatch(probe)));
2212 }
2213 }
2214 BOOST_CHECK_LE(worstSag, maxSag);
2215}
2216} // namespace
2217
2218// The display triangles of a wire-trimmed quadric follow the patch instead of fanning across it (O2-7284).
2219BOOST_AUTO_TEST_CASE(WireTrimmedDisplayMeshFollowsPatch)
2220{
2221 std::string error;
2222 constexpr double phiMin = 1.6;
2223 constexpr double phiMax = 4.683;
2224
2225 // half-cylinder of the ALICE 3 tracker shell: 12 chords on each long side plus the two corners
2227 BOOST_REQUIRE(halfCylinder.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 10., -20., 20., phiMin,
2228 phiMax - phiMin, false, paramRectWireCurves(phiMin, phiMax, -20., 20.), {},
2229 error));
2230 checkDisplayMeshFollowsPatch(halfCylinder, 10., 26);
2231
2232 // quarter-torus: 6 chords on each ring side; each tube side is sampled as a single chord
2233 surf::TorusBoundedSurface quarterTorus;
2234 BOOST_REQUIRE(quarterTorus.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 10., 0.3, 0., surf::kHalfPi, 0.,
2235 surf::kPi / 3., false,
2236 paramRectWireCurves(0., surf::kHalfPi, 0., surf::kPi / 3.), {}, error));
2237 checkDisplayMeshFollowsPatch(quarterTorus, 10.3, 14, 0.3 * (1. - std::cos(surf::kPi / 6.)));
2238
2239 // half-cone
2241 BOOST_REQUIRE(halfCone.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 8., 12., -10., 10., phiMin,
2242 phiMax - phiMin, false, paramRectWireCurves(phiMin, phiMax, -10., 10.), {},
2243 error));
2244 checkDisplayMeshFollowsPatch(halfCone, 12., 26);
2245}
2246
2247namespace
2248{
2249// Whether the ray from \a origin along \a direction crosses the triangle (first, second, third).
2250bool rayHitsTriangle(const surf::Vec3& origin, const surf::Vec3& direction, const surf::Vec3& first,
2251 const surf::Vec3& second, const surf::Vec3& third)
2252{
2253 const surf::Vec3 edgeA = second - first;
2254 const surf::Vec3 edgeB = third - first;
2255 const surf::Vec3 normalB = cross(direction, edgeB);
2256 const double determinant = dot(edgeA, normalB);
2257 if (std::abs(determinant) < 1.e-12) {
2258 return false;
2259 }
2260 const surf::Vec3 offset = origin - first;
2261 const double firstWeight = dot(offset, normalB) / determinant;
2262 const double secondWeight = dot(direction, cross(offset, edgeA)) / determinant;
2263 const double distance = dot(edgeB, cross(offset, edgeA)) / determinant;
2264 return firstWeight >= 0. && secondWeight >= 0. && firstWeight + secondWeight <= 1. && distance > 0.;
2265}
2266
2267// Check that a patch's display mesh has n + 2h - 2 triangles, that a ray through a hole misses it and, for an
2268// \a expectedArea >= 0, that it covers that area.
2269void checkDisplayMeshLeavesHolesOpen(const surf::BoundedSurface& patch, int holeCount, double expectedArea,
2270 const surf::Vec3& rayOrigin, const surf::Vec3& rayDirection)
2271{
2272 std::vector<surf::Vec3> vertices;
2273 std::vector<std::array<int, 3>> triangles;
2274 patch.appendDisplayMesh(vertices, triangles);
2275 BOOST_CHECK_EQUAL(triangles.size(), vertices.size() + 2 * holeCount - 2);
2276 double area = 0.;
2277 int trianglesOverHole = 0;
2278 for (const auto& triangle : triangles) {
2279 const surf::Vec3& first = vertices[triangle[0]];
2280 const surf::Vec3& second = vertices[triangle[1]];
2281 const surf::Vec3& third = vertices[triangle[2]];
2282 area += 0.5 * norm(cross(second - first, third - first));
2283 trianglesOverHole += rayHitsTriangle(rayOrigin, rayDirection, first, second, third);
2284 }
2285 if (expectedArea >= 0.) {
2286 checkClose(area, expectedArea);
2287 }
2288 BOOST_CHECK_EQUAL(trianglesOverHole, 0);
2289}
2290} // namespace
2291
2292// The display triangles of a face leave its holes open instead of drawing them filled.
2293BOOST_AUTO_TEST_CASE(DisplayMeshLeavesHolesOpen)
2294{
2295 std::string error;
2296 const surf::Vec3 down{0., 0., -1.};
2297
2298 // a 4 x 4 square with a 2 x 2 square hole
2300 BOOST_REQUIRE(square.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.}, {{-2., -2.}, {2., -2.}, {2., 2.}, {-2., 2.}},
2301 {{{-1., -1.}, {-1., 1.}, {1., 1.}, {1., -1.}}}, error));
2302 checkDisplayMeshLeavesHolesOpen(square, 1, 12., {0.013, 0.007, 1.}, down);
2303
2304 // an annulus between radii 1 and 2, both circles sampled into the same number of chords
2306 BOOST_REQUIRE(annulus.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.}, {surf::Curve2D::makeCircle({0., 0.}, 2.)},
2307 {{surf::Curve2D::makeCircle({0., 0.}, 1., true)}}, error));
2308 const double polygonFactor = 0.5 * surf::kArcSamples * std::sin(surf::kTwoPi / surf::kArcSamples);
2309 checkDisplayMeshLeavesHolesOpen(annulus, 1, polygonFactor * (4. - 1.), {0.013, 0.007, 1.}, down);
2310
2311 // a half-cylinder of radius 10 with a window phi in [1, 2], height in [-5, 5]
2313 BOOST_REQUIRE(window.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 10., -20., 20., 0., surf::kPi, false,
2314 paramRectWireCurves(0., surf::kPi, -20., 20.),
2315 {paramRectWireCurves(1., 2., -5., 5.)}, error));
2316 checkDisplayMeshLeavesHolesOpen(window, 1, -1., {0., 0., 0.013}, {std::cos(1.5), std::sin(1.5), 0.});
2317}
2318
2319namespace
2320{
2321// The area covered by a patch's display triangles.
2322double displayArea(const surf::BoundedSurface& patch)
2323{
2324 std::vector<surf::Vec3> vertices;
2325 std::vector<std::array<int, 3>> triangles;
2326 patch.appendDisplayMesh(vertices, triangles);
2327 double area = 0.;
2328 for (const auto& triangle : triangles) {
2329 area += 0.5 * norm(cross(vertices[triangle[1]] - vertices[triangle[0]], vertices[triangle[2]] - vertices[triangle[0]]));
2330 }
2331 return area;
2332}
2333} // namespace
2334
2335// Holes that touch or cross a wire cannot be bridged; the face is then drawn filled, never overlapping or losing area.
2336BOOST_AUTO_TEST_CASE(DisplayMeshKeepsUnbridgeableHolesFilled)
2337{
2338 std::string error;
2339 const std::vector<surf::Vec2> outer{{-2., -2.}, {2., -2.}, {2., 2.}, {-2., 2.}};
2340
2342 BOOST_REQUIRE(plain.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.}, outer, {}, error));
2343
2344 // a triangular hole sharing the corner (2, 2) of the outer square
2346 BOOST_REQUIRE(touching.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.}, outer, {{{2., 2.}, {1., 0.}, {0., 1.}}}, error));
2347 checkClose(displayArea(touching), displayArea(plain));
2348
2349 // two square holes sharing the vertex (0, 0)
2351 BOOST_REQUIRE(sharing.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.}, outer,
2352 {{{-1., -1.}, {-1., 0.}, {0., 0.}, {0., -1.}}, {{0., 0.}, {0., 1.}, {1., 1.}, {1., 0.}}}, error));
2353 checkClose(displayArea(sharing), displayArea(plain));
2354
2355 // a full cylinder with a window straddling the phi seam
2357 BOOST_REQUIRE(fullCylinder.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 10., -20., 20., 0., surf::kTwoPi, false,
2358 paramRectWireCurves(0., surf::kTwoPi, -20., 20.), {}, error));
2360 BOOST_REQUIRE(seamWindow.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 10., -20., 20., 0., surf::kTwoPi, false,
2361 paramRectWireCurves(0., surf::kTwoPi, -20., 20.),
2362 {paramRectWireCurves(-0.5, 0.5, -5., 5.)}, error));
2363 checkClose(displayArea(seamWindow), displayArea(fullCylinder));
2364}
2365
2366// Green's theorem for wire-trimmed quadrics.
2367//
2368// The integrand does not depend on the second parameter for a cylinder, and depends on the first
2369// only through sin/cos/identity for all four quadrics, so an antiderivative in u exists in closed
2370// form and the area integral collapses to a contour integral around the trim wire.
2371//
2372// The sharpest possible check: a wire that traces exactly the parametric rectangle must give the
2373// same number as the rectangle's own closed form, which is analytically exact. Anything the
2374// contour form got wrong -- a sign, an orientation, a missed seam, a wrong antiderivative --
2375// shows up here immediately, and the old quadrature could only ever have agreed to ~1e-2.
2376BOOST_AUTO_TEST_CASE(WireTrimCapacityMatchesTheClosedForm)
2377{
2378 std::string error;
2379 // an off-origin centre and a tilted frame, so every term of every antiderivative is exercised
2380 // (C.U, C.V and C.W all non-zero) rather than cancelling
2381 const surf::Vec3 centre{0.7, -1.3, 0.45};
2382 const surf::Vec3 axis = surf::normalized({0.3, 0.4, 1.});
2383 const surf::Vec3 reference{1., 0.2, 0.};
2384 constexpr double kRelative = 1.e-12;
2385
2386 const auto compare = [&](const char* what, const surf::BoundedSurface& rectangle,
2387 const surf::BoundedSurface& wired) {
2388 BOOST_TEST_CONTEXT(what)
2389 {
2390 BOOST_CHECK(!wired.capacityIsExact()); // still reported inexact: see the note below
2391 const double exact = rectangle.capacityContribution();
2392 const double contour = wired.capacityContribution();
2393 BOOST_CHECK_GT(std::abs(exact), 1.e-6); // a zero contribution would prove nothing
2394 checkClose(contour, exact, kRelative * std::abs(exact));
2395 }
2396 };
2397
2398 {
2401 const double phiLow = 0.3, phiHigh = 2.4, hLow = -0.8, hHigh = 1.9;
2402 BOOST_REQUIRE(rectangle.initialize(centre, axis, reference, 1.7, hLow, hHigh, phiLow, phiHigh - phiLow, false,
2403 error));
2404 BOOST_REQUIRE(wired.initialize(centre, axis, reference, 1.7, hLow, hHigh, phiLow, phiHigh - phiLow, false,
2405 paramRectWireCurves(phiLow, phiHigh, hLow, hHigh), {}, error));
2406 compare("cylinder", rectangle, wired);
2407 }
2408 {
2411 const double phiLow = -0.4, phiHigh = 1.9, hLow = 0.2, hHigh = 2.1;
2412 BOOST_REQUIRE(rectangle.initialize(centre, axis, reference, 1.1, 2.3, hLow, hHigh, phiLow, phiHigh - phiLow,
2413 false, error));
2414 BOOST_REQUIRE(wired.initialize(centre, axis, reference, 1.1, 2.3, hLow, hHigh, phiLow, phiHigh - phiLow, false,
2415 paramRectWireCurves(phiLow, phiHigh, hLow, hHigh), {}, error));
2416 compare("cone", rectangle, wired);
2417 }
2418 {
2421 const double phiLow = 0.2, phiHigh = 2.7, thetaLow = 0.4, thetaHigh = 2.3;
2422 BOOST_REQUIRE(rectangle.initialize(centre, axis, reference, 2.2, thetaLow, thetaHigh, phiLow, phiHigh - phiLow,
2423 false, error));
2424 BOOST_REQUIRE(wired.initialize(centre, axis, reference, 2.2, thetaLow, thetaHigh, phiLow, phiHigh - phiLow, false,
2425 paramRectWireCurves(phiLow, phiHigh, thetaLow, thetaHigh), {}, error));
2426 compare("sphere", rectangle, wired);
2427 }
2428 {
2429 surf::TorusBoundedSurface rectangle;
2431 const double ringLow = 0.1, ringHigh = 2.2, tubeLow = -0.3, tubeHigh = 1.8;
2432 BOOST_REQUIRE(rectangle.initialize(centre, axis, reference, 4., 1.4, ringLow, ringHigh - ringLow, tubeLow,
2433 tubeHigh - tubeLow, false, error));
2434 BOOST_REQUIRE(wired.initialize(centre, axis, reference, 4., 1.4, ringLow, ringHigh - ringLow, tubeLow,
2435 tubeHigh - tubeLow, false,
2436 paramRectWireCurves(ringLow, ringHigh, tubeLow, tubeHigh), {}, error));
2437 compare("torus", rectangle, wired);
2438 }
2439
2440 // A trim the rectangle cannot express, checked against the integrator it replaces. The midpoint
2441 // rule is a genuinely independent computation -- it needs nothing of the integrand but its value
2442 // -- so agreement is evidence, but only to its own O(1/N) accuracy, which is the whole reason
2443 // this change exists. Refining it must walk *towards* the contour answer; that direction is the
2444 // real assertion here, not either number.
2445 {
2447 const double radius = 1.7;
2448 const std::vector<surf::Curve2D> trim{surf::Curve2D::makeCircle({1.0, 0.2}, 0.6)};
2449 BOOST_REQUIRE(disk.initialize(centre, axis, reference, radius, -2., 2., 0., surf::kTwoPi, false, trim, {},
2450 error));
2451 const double contour = disk.capacityContribution();
2452
2453 // the same trim, rebuilt here so the grid rule can be run over it directly
2454 surf::CurveWire outerWire;
2455 std::vector<surf::CurveWire> innerWires;
2457 BOOST_REQUIRE(surf::buildCurveTrim(trim, {}, outerWire, innerWires, lower, upper, error,
2459
2460 const auto gridRelativeError = [&](int samples) {
2461 const double grid = surf::integrateOverCurveTrim(
2462 outerWire, innerWires,
2463 [&disk, radius](double phi, double height) {
2464 const surf::Vec3 point = disk.pointAt(phi, height);
2465 return surf::dot(point, disk.normalAt(point)) * radius / 3.;
2466 },
2467 samples);
2468 return std::abs(grid - contour) / std::abs(contour);
2469 };
2470 const double at128 = gridRelativeError(128);
2471 const double at512 = gridRelativeError(512);
2472 const double at2048 = gridRelativeError(2048);
2473
2474 // The grid rule confirms the contour value to its own accuracy -- an independent computation
2475 // agreeing to 3e-5 is what says the antiderivative route is not just self-consistent.
2476 BOOST_CHECK_LT(at512, 1.e-4);
2477 BOOST_CHECK_LT(at2048, 1.e-4);
2478 // But it cannot do better, and that is the point. At the shipped 128 it is off by 2e-3 --
2479 // three orders outside the gate's 1e-6 band -- and refining it sixteen-fold does not fix that,
2480 // because the error is not monotone: the staircase re-phases and 2048 is *worse* than 512
2481 // (2.9e-5 against 2.4e-5 here; on ExcavatorArm/BucketLink2 the sequence 128..2048 runs 16.004,
2482 // 17.710, 16.927, 17.244, 17.032 around a true 17.079). So no N could have been the fix.
2483 BOOST_CHECK_GT(at128, 1.e-3);
2484 BOOST_CHECK_GT(at2048, at512);
2485 }
2486}
2487
2488BOOST_AUTO_TEST_CASE(WireTrimmedQuadricKernels)
2489{
2490 using surf::Curve2D;
2491 using surf::Vec3;
2492 std::string error;
2493
2494 const auto onCylinder = [](double phi, double height) {
2495 return Vec3{2. * std::cos(phi), 2. * std::sin(phi), height};
2496 };
2497
2498 // (1) cylinder wall with a rectangular window (hole) in (phi, h): phi in [2.0, 2.5], h in [-1, 1]
2500 const std::vector<Curve2D> outer{Curve2D::makeLine({0., -3.}, {surf::kTwoPi, -3.}),
2501 Curve2D::makeLine({surf::kTwoPi, -3.}, {surf::kTwoPi, 3.}),
2502 Curve2D::makeLine({surf::kTwoPi, 3.}, {0., 3.}),
2503 Curve2D::makeLine({0., 3.}, {0., -3.})};
2504 const std::vector<Curve2D> hole{Curve2D::makeLine({2.0, -1.}, {2.5, -1.}), Curve2D::makeLine({2.5, -1.}, {2.5, 1.}),
2505 Curve2D::makeLine({2.5, 1.}, {2.0, 1.}), Curve2D::makeLine({2.0, 1.}, {2.0, -1.})};
2506 BOOST_REQUIRE(windowed.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -3., 3., 0., surf::kTwoPi, false,
2507 outer, {hole}, error));
2508 BOOST_CHECK(windowed.containsPointOnSurface(onCylinder(0.5, 0.))); // material
2509 BOOST_CHECK(windowed.containsPointOnSurface(onCylinder(2.25, 2.5))); // material above the window
2510 BOOST_CHECK(!windowed.containsPointOnSurface(onCylinder(2.25, 0.))); // inside the window
2511 BOOST_CHECK(windowed.containsPointOnSurface(onCylinder(2.25, 1.))); // on the window edge (boundary)
2512
2513 // a radial ray into the window is filtered out; a radial ray into material registers one hit
2514 std::vector<surf::RayHit> hits;
2515 windowed.appendIntersections({0., 0., 0.}, {std::cos(2.25), std::sin(2.25), 0.}, 0., 1.e30, hits);
2516 BOOST_CHECK(hits.empty());
2517 hits.clear();
2518 windowed.appendIntersections({0., 0., 0.}, {std::cos(0.5), std::sin(0.5), 0.}, 0., 1.e30, hits);
2519 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
2520 checkClose(hits.front().distance, 2.);
2521
2522 // (2) arc trim: a parametric circle (disk in (phi, h)) centred at (pi, 0), radius 0.5
2524 const std::vector<Curve2D> arcOuter{Curve2D::makeCircle({surf::kPi, 0.}, 0.5)};
2525 BOOST_REQUIRE(arcTrim.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
2526 arcOuter, {}, error));
2527 BOOST_CHECK(arcTrim.containsPointOnSurface(onCylinder(surf::kPi, 0.))); // centre of the disk
2528 BOOST_CHECK(!arcTrim.containsPointOnSurface(onCylinder(surf::kPi, 0.6))); // outside in h
2529 BOOST_CHECK(!arcTrim.containsPointOnSurface(onCylinder(surf::kPi + 0.6, 0.))); // outside in phi
2530 BOOST_CHECK_GT(std::abs(arcTrim.capacityContribution()), 0.);
2531
2532 // (3) sphere section reproduced as a (phi, theta) rectangle wire must match the scalar section
2533 const auto onSphere = [](double theta, double phi) {
2534 return Vec3{2. * std::sin(theta) * std::cos(phi), 2. * std::sin(theta) * std::sin(phi), 2. * std::cos(theta)};
2535 };
2537 BOOST_REQUIRE(sphereWire.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., surf::kHalfPi / 2., surf::kHalfPi,
2538 0., surf::kHalfPi, false,
2539 paramRectWireCurves(0., surf::kHalfPi, surf::kHalfPi / 2., surf::kHalfPi), {},
2540 error));
2541 surf::SphericalBoundedSurface sphereScalar;
2542 BOOST_REQUIRE(sphereScalar.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., surf::kHalfPi / 2.,
2543 surf::kHalfPi, 0., surf::kHalfPi, false, error));
2544 BOOST_CHECK(sphereWire.containsPointOnSurface(onSphere(surf::kPi / 3., surf::kPi / 4.))); // inside the section
2545 BOOST_CHECK(!sphereWire.containsPointOnSurface(onSphere(surf::kPi / 6., surf::kPi / 4.))); // theta too small
2546 BOOST_CHECK(!sphereWire.containsPointOnSurface(onSphere(surf::kPi / 3., 3. * surf::kPi / 4.))); // phi outside
2547 // same story on the sphere: 1e-3 was the grid rule's allowance, not the geometry's
2548 checkClose(sphereWire.capacityContribution(), sphereScalar.capacityContribution(), 1.e-9);
2549
2550 // (4) a trim spanning more than a full turn in phi is rejected
2552 const std::vector<Curve2D> wideOuter{Curve2D::makeLine({0., -1.}, {7., -1.}), Curve2D::makeLine({7., -1.}, {7., 1.}),
2553 Curve2D::makeLine({7., 1.}, {0., 1.}), Curve2D::makeLine({0., 1.}, {0., -1.})};
2554 BOOST_CHECK(!tooWide.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
2555 wideOuter, {}, error));
2556}
2557
2558namespace
2559{
2560// Estimate the first fundamental form at (u, v) by central differences of a surface's own
2561// parametrisation. Checking parametricMetric against this is a proof that the closed form
2562// describes the map the rest of the kernel actually evaluates -- restating the formula in the
2563// test would only prove it was copied twice.
2564template <typename PointAt>
2565void checkMetricAgainstFiniteDifference(const surf::BoundedSurface& surface, const PointAt& pointAt,
2566 double uCoord, double vCoord, double tolerance = 1.e-6)
2567{
2568 const double step = 1.e-5;
2569 const surf::Vec3 dU = (pointAt(uCoord + step, vCoord) - pointAt(uCoord - step, vCoord)) * (0.5 / step);
2570 const surf::Vec3 dV = (pointAt(uCoord, vCoord + step) - pointAt(uCoord, vCoord - step)) * (0.5 / step);
2571
2572 double gUU = 0.;
2573 double gUV = 0.;
2574 double gVV = 0.;
2575 surface.parametricMetric({uCoord, vCoord}, gUU, gUV, gVV);
2576 checkClose(gUU, dot(dU, dU), tolerance);
2577 checkClose(gUV, dot(dU, dV), tolerance);
2578 checkClose(gVV, dot(dV, dV), tolerance);
2579}
2580} // namespace
2581
2582// The first fundamental form of every surface family, against the surface's own parametrisation,
2583// plus the two degeneracies and the cross term the callers of it have to cope with. This is the
2584// conversion that makes a parametric tolerance mean a length (findings K3, K5, K12, S10).
2585BOOST_AUTO_TEST_CASE(ParametricMetricIsTheFirstFundamentalForm)
2586{
2587 using surf::Vec2;
2588 using surf::Vec3;
2589 std::string error;
2590
2591 // (1) plane with deliberately non-orthonormal axes: the only family with a cross term, and the
2592 // only one whose (u, v) are not already lengths.
2593 const Vec3 axisU{2., 0., 0.};
2594 const Vec3 axisV{1., 3., 0.}; // not unit, not orthogonal to axisU
2595 const std::vector<Vec2> unitSquare{{0., 0.}, {1., 0.}, {1., 1.}, {0., 1.}};
2597 BOOST_REQUIRE(plane.initialize({0.5, -1., 2.}, axisU, axisV, unitSquare, {}, error));
2598 checkMetricAgainstFiniteDifference(plane, [&](double u, double v) { return plane.toGlobal({u, v}); }, 0.3, 0.7);
2599 {
2600 double gUU = 0.;
2601 double gUV = 0.;
2602 double gVV = 0.;
2603 plane.parametricMetric({0., 0.}, gUU, gUV, gVV);
2604 checkClose(gUU, 4.);
2605 checkClose(gUV, 2.); // dot(axisU, axisV) -- zero for every other family
2606 checkClose(gVV, 10.);
2607 // and it really measures 3D length: (du, dv) = (1, 0) spans |axisU| = 2 cm
2608 checkClose(std::sqrt(plane.parametricLengthSqAt({0., 0.}, {1., 0.})), 2.);
2609 checkClose(std::sqrt(plane.parametricLengthSqAt({0., 0.}, {0., 1.})), std::sqrt(10.));
2610 }
2611
2612 // (2) curved planar: initialize() insists on an orthonormal frame, so (u, v) are centimetres.
2614 BOOST_REQUIRE(curvedPlane.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.},
2615 {surf::Curve2D::makeCircle({0., 0.}, 1.)}, {}, error));
2616 checkMetricAgainstFiniteDifference(
2617 curvedPlane, [&](double u, double v) { return curvedPlane.toGlobal({u, v}); }, 0.2, -0.4);
2618
2619 // (3) cylinder, (u, v) = (phi, h). The radius factor is the whole point: the same parametric
2620 // drift is a different distance on a small hole and on a large cylinder.
2621 for (const double radius : {0.01, 100.}) {
2623 BOOST_REQUIRE(cylinder.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -1., 1., 0., surf::kTwoPi,
2624 false, error));
2625 checkMetricAgainstFiniteDifference(cylinder, [&](double u, double v) { return cylinder.pointAt(u, v); }, 1.1, 0.3, 1.e-4 * radius * radius);
2626 // a 2e-5 rad join drift is 2e-7 cm on the small cylinder and 2e-3 cm on the large one
2627 checkClose(std::sqrt(cylinder.parametricLengthSqAt({1.1, 0.3}, {2.e-5, 0.})), 2.e-5 * radius, 1.e-12);
2628 }
2629
2630 // (4) sphere, (u, v) = (phi, theta) -- the trim domain's order, the transpose of pointAt's.
2632 BOOST_REQUIRE(sphere.initialize({1., 2., 3.}, {0., 0., 1.}, {1., 0., 0.}, 2.5, 0., surf::kPi, 0., surf::kTwoPi,
2633 false, error));
2634 checkMetricAgainstFiniteDifference(sphere, [&](double u, double v) { return sphere.pointAt(v, u); }, 0.9, 1.2);
2635 {
2636 double gUU = 0.;
2637 double gUV = 0.;
2638 double gVV = 0.;
2639 // at the pole the azimuth degenerates: a phi separation there spans no distance at all
2640 sphere.parametricMetric({0.9, 0.}, gUU, gUV, gVV);
2641 checkClose(gUU, 0.);
2642 checkClose(gVV, 2.5 * 2.5);
2643 checkClose(sphere.parametricLengthSqAt({0.9, 0.}, {1., 0.}), 0.);
2644 sphere.parametricMetric({0.9, surf::kPi}, gUU, gUV, gVV);
2645 checkClose(gUU, 0.);
2646 // and on the equator it is the full radius
2647 sphere.parametricMetric({0.9, surf::kHalfPi}, gUU, gUV, gVV);
2648 checkClose(gUU, 2.5 * 2.5);
2649 }
2650
2651 // (5) cone, (u, v) = (phi, h): the azimuthal scale shrinks to zero at the apex, and a step in h
2652 // walks along the slope rather than along the axis.
2654 BOOST_REQUIRE(cone.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 0., 4., 0., 2., 0., surf::kTwoPi, false,
2655 error));
2656 checkMetricAgainstFiniteDifference(cone, [&](double u, double v) { return cone.pointAt(u, v); }, 2.0, 1.3);
2657 {
2658 double gUU = 0.;
2659 double gUV = 0.;
2660 double gVV = 0.;
2661 cone.parametricMetric({2.0, 0.}, gUU, gUV, gVV); // the apex, where r(h) = 0
2662 checkClose(gUU, 0.);
2663 checkClose(gVV, 1. + 2. * 2.); // slope = (4 - 0) / (2 - 0)
2664 checkClose(cone.parametricLengthSqAt({2.0, 0.}, {1., 0.}), 0.);
2665 cone.parametricMetric({2.0, 2.}, gUU, gUV, gVV); // the wide end, r = 4
2666 checkClose(gUU, 16.);
2667 }
2668
2669 // (6) torus, (u, v) = (phiRing, phiTube): the ring scale runs from R - r to R + r around the
2670 // tube, so it is the one family whose gUU varies without any degeneracy.
2672 BOOST_REQUIRE(torus.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 5., 1.5, 0., surf::kTwoPi, 0.,
2673 surf::kTwoPi, false, error));
2674 checkMetricAgainstFiniteDifference(torus, [&](double u, double v) { return torus.pointAt(u, v); }, 0.7, 2.1);
2675 {
2676 double gUU = 0.;
2677 double gUV = 0.;
2678 double gVV = 0.;
2679 torus.parametricMetric({0.7, 0.}, gUU, gUV, gVV); // outside of the ring
2680 checkClose(gUU, 6.5 * 6.5);
2681 checkClose(gVV, 1.5 * 1.5);
2682 torus.parametricMetric({0.7, surf::kPi}, gUU, gUV, gVV); // inside of the ring
2683 checkClose(gUU, 3.5 * 3.5);
2684 }
2685}
2686
2687// The join tolerance is a length, so the same parametric drift is accepted on a small cylinder
2688// and refused on a large one. Today's rule cannot tell them apart, which is the whole of K3 -- and
2689// it is the synthetic form of the measured ST1829909_01 loader rejection (six joins under 3e-5 rad
2690// on cylinder trims, negligible in arc length, read as three times over a 1e-5 "tolerance").
2691BOOST_AUTO_TEST_CASE(WireJoinToleranceIsALength)
2692{
2693 using surf::Curve2D;
2694 std::string error;
2695
2696 // A rectangular (phi, h) trim whose last edge stops `drift` radians short of closing the loop.
2697 const auto trimWithPhiDrift = [](double drift) {
2698 return std::vector<Curve2D>{Curve2D::makeLine({0.2, -1.}, {1.2, -1.}), Curve2D::makeLine({1.2, -1.}, {1.2, 1.}),
2699 Curve2D::makeLine({1.2, 1.}, {0.2 + drift, 1.}),
2700 Curve2D::makeLine({0.2 + drift, 1.}, {0.2 + drift, -1.})};
2701 };
2702 const auto acceptsDrift = [&](double radius, double drift) {
2704 return cylinder.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -1., 1., 0., surf::kTwoPi, false,
2705 trimWithPhiDrift(drift), {}, error);
2706 };
2707
2708 // The same 2e-5 rad drift, on two radii: 2e-7 cm of arc on the small cylinder against 2e-3 cm
2709 // on the large one. One is a rounding error, the other is a real gap.
2710 BOOST_CHECK(acceptsDrift(0.01, 2.e-5)); // the old rule refused this: 2e-5 > 1e-5, radius unseen
2711 BOOST_CHECK(!acceptsDrift(100., 2.e-5));
2712 // and the discrimination really is the radius: give the large cylinder a drift small enough to
2713 // span the same 2e-7 cm and it is accepted; give the small one a gap of 2e-5 cm and it is not.
2714 BOOST_CHECK(acceptsDrift(100., 2.e-9));
2715 BOOST_CHECK(!acceptsDrift(0.01, 2.e-3));
2716 // On a cylinder of radius 1 the two rules coincide up to the change of constant -- which is the
2717 // only configuration the old one was ever right on, and then only by accident.
2718 BOOST_CHECK(acceptsDrift(1., 5.e-7));
2719 BOOST_CHECK(!acceptsDrift(1., 5.e-6)); // the old rule accepted this: 5e-6 < 1e-5
2720}
2721
2722// K12: the polygon and curve wire types are fed by the same extractor with the same per-endpoint
2723// precision, and now judge a join by the same rule. They used to differ by four orders of
2724// magnitude -- 1e-9 for polygons against 1e-5 for curves -- and in incompatible units.
2725BOOST_AUTO_TEST_CASE(PolygonAndCurveWiresShareOneJoinRule)
2726{
2727 using surf::SurfaceEdge;
2728 using surf::Vec2;
2729 using surf::WireRole;
2730 using surf::WireStatus;
2731
2732 // A square whose last edge ends `gap` short of the first edge's start, in a domain where
2733 // (u, v) are already centimetres (the default identity metric).
2734 const auto polygonAcceptsGap = [](double gap) {
2735 const std::vector<SurfaceEdge> edges{{{0., 0.}, {1., 0.}},
2736 {{1., 0.}, {1., 1.}},
2737 {{1., 1.}, {0., 1.}},
2738 {{0., 1.}, {gap, 0.}}};
2739 surf::SurfaceWire wire;
2740 WireStatus status = WireStatus::Valid;
2741 return wire.initializeFromEdges(edges, WireRole::Outer, status, {});
2742 };
2743 const auto curveAcceptsGap = [](double gap) {
2744 const std::vector<surf::Curve2D> curves{surf::Curve2D::makeLine({0., 0.}, {1., 0.}),
2745 surf::Curve2D::makeLine({1., 0.}, {1., 1.}),
2746 surf::Curve2D::makeLine({1., 1.}, {0., 1.}),
2747 surf::Curve2D::makeLine({0., 1.}, {gap, 0.})};
2748 surf::CurveWire wire;
2749 WireStatus status = WireStatus::Valid;
2750 return wire.initialize(curves, WireRole::Outer, status, {});
2751 };
2752
2753 // inside the 1e-6 cm tolerance. 1e-8 and 1e-7 are the discriminating cases: the polygon wire
2754 // used to refuse them at 1e-9 while the curve wire accepted them at 1e-5.
2755 for (const double gap : {0., 1.e-8, 1.e-7}) {
2756 BOOST_CHECK(polygonAcceptsGap(gap));
2757 BOOST_CHECK(curveAcceptsGap(gap));
2758 }
2759 // outside it. 1e-5 is the mirror case: the curve wire used to accept it and the polygon not.
2760 for (const double gap : {1.e-5, 1.e-4}) {
2761 BOOST_CHECK(!polygonAcceptsGap(gap));
2762 BOOST_CHECK(!curveAcceptsGap(gap));
2763 }
2764}
2765
2766namespace
2767{
2768// Helpers writing the surface sidecar binary format documented in
2769// Detectors/CADSupport/doc/reference/BVHSurfaceSolid.md. Kept independent of the loader implementation so the
2770// test is a true round-trip through the documented byte layout.
2771void appendU32(std::vector<char>& bytes, uint32_t value)
2772{
2773 const char* raw = reinterpret_cast<const char*>(&value);
2774 bytes.insert(bytes.end(), raw, raw + sizeof(value));
2775}
2776
2777void appendDoubles(std::vector<char>& bytes, std::initializer_list<double> values)
2778{
2779 for (const double value : values) {
2780 const char* raw = reinterpret_cast<const char*>(&value);
2781 bytes.insert(bytes.end(), raw, raw + sizeof(value));
2782 }
2783}
2784
2785// The fixed header. Version 1 is the three-uint32 form; version 2 appends the model tolerance in
2786// cm. The default stays at version 1 on purpose: every sidecar test below then doubles as a
2787// regression test that the reader still accepts the older format.
2788void appendSidecarHeader(std::vector<char>& bytes, uint32_t nSurfaces, uint32_t version = 1,
2789 double modelTolerance = 0., uint32_t nModelEdges = 0)
2790{
2791 bytes.insert(bytes.end(), {'O', '2', 'S', 'S'});
2792 appendU32(bytes, version);
2793 appendU32(bytes, nSurfaces);
2794 appendU32(bytes, 0); // reserved
2795 if (version >= 2) {
2796 appendDoubles(bytes, {modelTolerance});
2797 }
2798 if (version >= 3) {
2799 appendU32(bytes, nModelEdges); // size of the model's edge table
2800 }
2801}
2802
2803// plane record (type 1) with a single rectangular outer wire of four line-segment edges
2804void appendPlaneRecord(std::vector<char>& bytes, const FaceFrame& frame)
2805{
2806 appendU32(bytes, 1); // surfaceType plane
2807 appendU32(bytes, 0); // flags
2808 appendU32(bytes, 9); // nParams
2809 appendDoubles(bytes, {frame.origin[0], frame.origin[1], frame.origin[2], frame.axisU[0], frame.axisU[1],
2810 frame.axisU[2], frame.axisV[0], frame.axisV[1], frame.axisV[2]});
2811 appendU32(bytes, 1); // nWires
2812 appendU32(bytes, 0); // wireRole outer
2813 appendU32(bytes, 4); // nEdges
2814 const double extentU = frame.extentU;
2815 const double extentV = frame.extentV;
2816 const std::array<std::array<double, 4>, 4> edges{{{0., 0., extentU, 0.},
2817 {extentU, 0., extentU, extentV},
2818 {extentU, extentV, 0., extentV},
2819 {0., extentV, 0., 0.}}};
2820 for (const auto& edge : edges) {
2821 appendU32(bytes, 0); // curveType line
2822 appendU32(bytes, 4); // nCurveParams
2823 appendDoubles(bytes, {edge[0], edge[1], edge[2], edge[3]});
2824 }
2825}
2826
2827// plane record (type 1) for a disk/annulus cap: one full-circle outer arc wire, plus a
2828// clockwise inner arc wire when holeRadius > 0. Exercises the arc-wire reader path.
2829void appendDiskPlaneRecord(std::vector<char>& bytes, const Point3D& center, const Point3D& axisU,
2830 const Point3D& axisV, double radius, double holeRadius = 0.)
2831{
2832 appendU32(bytes, 1); // surfaceType plane
2833 appendU32(bytes, 0); // flags
2834 appendU32(bytes, 9); // nParams
2835 appendDoubles(bytes, {center[0], center[1], center[2], axisU[0], axisU[1], axisU[2], axisV[0], axisV[1], axisV[2]});
2836 const uint32_t nWires = holeRadius > 0. ? 2u : 1u;
2837 appendU32(bytes, nWires);
2838 appendU32(bytes, 0); // outer wire role
2839 appendU32(bytes, 1); // one edge
2840 appendU32(bytes, 1); // curveType arc
2841 appendU32(bytes, 5); // nCurveParams
2842 appendDoubles(bytes, {0., 0., radius, 0., 2. * surf::kPi}); // cu cv radius phiStart phiSweep (CCW full circle)
2843 if (holeRadius > 0.) {
2844 appendU32(bytes, 1); // inner wire role
2845 appendU32(bytes, 1);
2846 appendU32(bytes, 1); // arc
2847 appendU32(bytes, 5);
2848 appendDoubles(bytes, {0., 0., holeRadius, 0., -2. * surf::kPi}); // clockwise hole
2849 }
2850}
2851
2852std::filesystem::path writeSidecarFile(const std::string& name, const std::vector<char>& bytes)
2853{
2854 const auto path = std::filesystem::temp_directory_path() / name;
2855 std::ofstream out(path, std::ios::binary);
2856 out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
2857 BOOST_REQUIRE(out.good());
2858 return path;
2859}
2860} // namespace
2861
2862BOOST_AUTO_TEST_CASE(SurfaceSidecarRoundTrip)
2863{
2864 // planar box: six plane records with polygon wires, loaded and compared against TGeoBBox
2865 constexpr double halfX = 1.;
2866 constexpr double halfY = 2.;
2867 constexpr double halfZ = 3.;
2868
2869 std::vector<char> boxBytes;
2870 appendSidecarHeader(boxBytes, 6);
2871 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
2872 appendPlaneRecord(boxBytes, boxFaceFrame(faceIndex, halfX, halfY, halfZ));
2873 }
2874 const auto boxPath = writeSidecarFile("o2_sidecar_roundtrip_box.bin", boxBytes);
2875
2876 SurfaceSolid box("sidecarBox");
2877 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(boxPath.string(), box));
2878 std::filesystem::remove(boxPath);
2879 BOOST_CHECK_EQUAL(box.GetNsurfaces(), 6);
2880 box.CloseShape();
2881 BOOST_CHECK(box.IsClosed());
2882 BOOST_CHECK(box.IsOrientationConsistent());
2883
2884 TGeoBBox referenceBox("referenceBox", halfX, halfY, halfZ);
2885 compareContainsGrid(box, referenceBox, 4., 7);
2886 compareDistance(box, referenceBox, {5., 0.5, 0.5}, {-1., 0., 0.});
2887 compareDistance(box, referenceBox, {0., 0., 0.}, unitDirection(1., 1., 1.));
2888 checkClose(box.Capacity(), referenceBox.Capacity(), 1.e-9);
2889
2890 // quadric + arc-wire caps: closed cylinder (lateral wall + two disk caps) against TGeoTube
2891 constexpr double radius = 2.;
2892 constexpr double halfHeight = 3.;
2893
2894 std::vector<char> tubeBytes;
2895 appendSidecarHeader(tubeBytes, 3);
2896 appendU32(tubeBytes, 2); // surfaceType cylinder
2897 appendU32(tubeBytes, 0); // flags (outer wall)
2898 appendU32(tubeBytes, 14); // nParams
2899 appendDoubles(tubeBytes, {0., 0., 0., 0., 0., 1., 1., 0., 0., radius, -halfHeight, halfHeight, 0., 2. * surf::kPi});
2900 appendU32(tubeBytes, 0); // nWires
2901 // caps as arc-wire plane records: outward normal is axisU x axisV, so the bottom cap flips axisV
2902 appendDiskPlaneRecord(tubeBytes, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radius);
2903 appendDiskPlaneRecord(tubeBytes, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radius);
2904 const auto tubePath = writeSidecarFile("o2_sidecar_roundtrip_tube.bin", tubeBytes);
2905
2906 SurfaceSolid tube("sidecarTube");
2907 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(tubePath.string(), tube));
2908 std::filesystem::remove(tubePath);
2909 BOOST_CHECK_EQUAL(tube.GetNsurfaces(), 3);
2910 tube.CloseShape();
2911 BOOST_CHECK(tube.IsClosed());
2912 BOOST_CHECK(tube.IsOrientationConsistent());
2913
2914 TGeoTube referenceTube("referenceTube", 0., radius, halfHeight);
2915 compareContainsGrid(tube, referenceTube, 4., 7);
2916 compareDistance(tube, referenceTube, {5., 0.5, 1.}, {-1., 0., 0.});
2917 compareDistance(tube, referenceTube, {0., 0., 0.}, unitDirection(1., 1., 1.));
2918 checkClose(tube.Capacity(), referenceTube.Capacity(), 1.e-9);
2919
2920 // malformed input must be rejected without loading surfaces
2921 const auto badPath = writeSidecarFile("o2_sidecar_bad_magic.bin", {'X', 'X', 'X', 'X', 0, 0, 0, 0});
2922 SurfaceSolid bad("sidecarBad");
2923 BOOST_CHECK(!o2::cad::LoadSurfaceSolid(badPath.string(), bad));
2924 std::filesystem::remove(badPath);
2925 BOOST_CHECK_EQUAL(bad.GetNsurfaces(), 0);
2926 BOOST_CHECK(!o2::cad::LoadSurfaceSolid("/nonexistent/o2_sidecar_missing.bin", bad));
2927
2928 // truncated file: valid header announcing a surface that never follows
2929 std::vector<char> truncatedBytes;
2930 appendSidecarHeader(truncatedBytes, 1);
2931 const auto truncatedPath = writeSidecarFile("o2_sidecar_truncated.bin", truncatedBytes);
2932 SurfaceSolid truncated("sidecarTruncated");
2933 BOOST_CHECK(!o2::cad::LoadSurfaceSolid(truncatedPath.string(), truncated));
2934 std::filesystem::remove(truncatedPath);
2935}
2936
2937// Sidecar version 2 carries the source model's own tolerance, so the kernel stops guessing what
2938// epsilon two faces of an imported solid should agree to. Both versions must load: a v1 file is a
2939// v2 file that simply does not state one.
2940BOOST_AUTO_TEST_CASE(SidecarModelToleranceRoundTrip)
2941{
2942 constexpr double halfX = 1.;
2943 constexpr double halfY = 2.;
2944 constexpr double halfZ = 3.;
2945 const auto boxBytesWithHeader = [&](uint32_t version, double modelTolerance) {
2946 std::vector<char> bytes;
2947 appendSidecarHeader(bytes, 6, version, modelTolerance);
2948 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
2949 appendPlaneRecord(bytes, boxFaceFrame(faceIndex, halfX, halfY, halfZ));
2950 }
2951 return bytes;
2952 };
2953 const auto loadFrom = [](const char* name, const std::vector<char>& bytes, SurfaceSolid& solid) {
2954 const auto path = writeSidecarFile(name, bytes);
2955 const bool ok = o2::cad::LoadSurfaceSolid(path.string(), solid);
2956 std::filesystem::remove(path);
2957 return ok;
2958 };
2959
2960 // version 2: the written tolerance reaches the solid untouched, and survives to CloseShape
2961 SurfaceSolid v2("sidecarV2");
2962 BOOST_REQUIRE(loadFrom("o2_sidecar_v2.bin", boxBytesWithHeader(2, 3.5e-5), v2));
2963 BOOST_CHECK_EQUAL(v2.GetNsurfaces(), 6);
2964 checkClose(v2.GetModelTolerance(), 3.5e-5, 1.e-18);
2965 v2.CloseShape();
2966 checkClose(v2.GetModelTolerance(), 3.5e-5, 1.e-18);
2967
2968 // a v2 file may still state nothing, and "nothing" is zero rather than an invented number
2969 SurfaceSolid v2Silent("sidecarV2Silent");
2970 BOOST_REQUIRE(loadFrom("o2_sidecar_v2_silent.bin", boxBytesWithHeader(2, 0.), v2Silent));
2971 BOOST_CHECK_EQUAL(v2Silent.GetModelTolerance(), 0.);
2972
2973 // version 1: still loads, and gets the reader's documented fallback rather than zero
2974 SurfaceSolid v1("sidecarV1");
2975 BOOST_REQUIRE(loadFrom("o2_sidecar_v1.bin", boxBytesWithHeader(1, 0.), v1));
2976 BOOST_CHECK_EQUAL(v1.GetNsurfaces(), 6);
2977 checkClose(v1.GetModelTolerance(), 1.e-6, 1.e-18);
2978
2979 // a solid nobody told anything keeps zero: "not stated" is not the same as "the fallback"
2980 SurfaceSolid handBuilt("handBuilt");
2981 BOOST_CHECK_EQUAL(handBuilt.GetModelTolerance(), 0.);
2982 handBuilt.SetModelTolerance(1.e-4);
2983 checkClose(handBuilt.GetModelTolerance(), 1.e-4, 1.e-18);
2984 handBuilt.SetModelTolerance(-1.); // refused, and the previous value stands
2985 checkClose(handBuilt.GetModelTolerance(), 1.e-4, 1.e-18);
2986
2987 // version 3 is understood now (it is a version-2 file that also states its edge identities);
2988 // it is exercised in SidecarV3EdgeIdentityRoundTrip below.
2989
2990 // an unknown version is refused rather than reinterpreted
2991 SurfaceSolid v4("sidecarV4");
2992 BOOST_CHECK(!loadFrom("o2_sidecar_v4.bin", boxBytesWithHeader(4, 1.e-5), v4));
2993 BOOST_CHECK_EQUAL(v4.GetNsurfaces(), 0);
2994
2995 // and a v2 header that stops before its tolerance is a truncated file, not a v1 one
2996 std::vector<char> stump;
2997 stump.insert(stump.end(), {'O', '2', 'S', 'S'});
2998 appendU32(stump, 2);
2999 appendU32(stump, 6);
3000 appendU32(stump, 0);
3001 SurfaceSolid stumped("sidecarV2Stump");
3002 BOOST_CHECK(!loadFrom("o2_sidecar_v2_stump.bin", stump, stumped));
3003 BOOST_CHECK_EQUAL(stumped.GetNsurfaces(), 0);
3004}
3005
3006BOOST_AUTO_TEST_CASE(WireTrimmedSidecarRoundTrip)
3007{
3008 // a cylinder record carrying a (line) trim wire block in its (phi, h) domain must load through
3009 // the wire-taking Add* overload and navigate like the equivalent scalar cylinder
3010 constexpr double radius = 2.;
3011 constexpr double halfHeight = 3.;
3012
3013 std::vector<char> bytes;
3014 appendSidecarHeader(bytes, 3);
3015 appendU32(bytes, 2); // surfaceType cylinder
3016 appendU32(bytes, 0); // flags (outer wall)
3017 appendU32(bytes, 14); // nParams
3018 appendDoubles(bytes, {0., 0., 0., 0., 0., 1., 1., 0., 0., radius, -halfHeight, halfHeight, 0., 2. * surf::kPi});
3019 appendU32(bytes, 1); // nWires
3020 appendU32(bytes, 0); // outer wire role
3021 appendU32(bytes, 4); // nEdges
3022 const std::array<std::array<double, 4>, 4> edges{{{0., -halfHeight, 2. * surf::kPi, -halfHeight},
3023 {2. * surf::kPi, -halfHeight, 2. * surf::kPi, halfHeight},
3024 {2. * surf::kPi, halfHeight, 0., halfHeight},
3025 {0., halfHeight, 0., -halfHeight}}};
3026 for (const auto& edge : edges) {
3027 appendU32(bytes, 0); // curveType line
3028 appendU32(bytes, 4); // nCurveParams
3029 appendDoubles(bytes, {edge[0], edge[1], edge[2], edge[3]});
3030 }
3031 appendDiskPlaneRecord(bytes, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radius);
3032 appendDiskPlaneRecord(bytes, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radius);
3033 const auto path = writeSidecarFile("o2_sidecar_wiretrim_cylinder.bin", bytes);
3034
3035 SurfaceSolid solid("sidecarWireCylinder");
3036 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(path.string(), solid));
3037 std::filesystem::remove(path);
3038 BOOST_CHECK_EQUAL(solid.GetNsurfaces(), 3);
3039 solid.CloseShape();
3040 BOOST_CHECK(solid.IsClosed());
3041 BOOST_CHECK(solid.IsOrientationConsistent());
3042
3043 TGeoTube reference("wireTrimSidecarTube", 0., radius, halfHeight);
3044 compareContainsGrid(solid, reference, 4., 7);
3045 compareDistance(solid, reference, {5., 0.5, 1.}, {-1., 0., 0.});
3046 compareDistance(solid, reference, {0., 0., 0.}, unitDirection(1., 1., 1.));
3047 checkClose(solid.Capacity(), reference.Capacity(), 1.e-6);
3048}
3049
3050BOOST_AUTO_TEST_CASE(TorusSidecarRoundTrip)
3051{
3052 // a full-torus record (surfaceType 5, 15 params, empty wire block) must load through the
3053 // scalar AddToroidalSurface path and navigate like TGeoTorus
3054 constexpr double majorR = 3.;
3055 constexpr double minorR = 1.;
3056
3057 std::vector<char> bytes;
3058 appendSidecarHeader(bytes, 1);
3059 appendU32(bytes, 5); // surfaceType torus
3060 appendU32(bytes, 0); // flags (outer wall)
3061 appendU32(bytes, 15); // nParams
3062 appendDoubles(bytes, {0., 0., 0., 0., 0., 1., 1., 0., 0., majorR, minorR, 0., 2. * surf::kPi, 0., 2. * surf::kPi});
3063 appendU32(bytes, 0); // nWires (full torus: scalar path)
3064 const auto path = writeSidecarFile("o2_sidecar_roundtrip_torus.bin", bytes);
3065
3066 SurfaceSolid solid("sidecarTorus");
3067 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(path.string(), solid));
3068 std::filesystem::remove(path);
3069 BOOST_CHECK_EQUAL(solid.GetNsurfaces(), 1);
3070 solid.CloseShape();
3071 BOOST_CHECK(solid.IsClosed());
3072 BOOST_CHECK(solid.IsOrientationConsistent());
3073
3074 TGeoTorus reference("sidecarTorusRef", majorR, 0., minorR);
3075 compareContainsGrid(solid, reference, 4.5, 9);
3076 checkClose(solid.Capacity(), reference.Capacity(), 1.e-7);
3077}
3078
3079// Wire-join gaps are judged against the tolerance the sidecar itself declares (the
3080// version-2 model tolerance), with the extractor-precision constant as the floor -- not against
3081// the bare constant when the model states it cannot do better. This is the ST1829909_01
3082// rejection: surface 1006's bspline->line join gaps by 5.41e-6 cm on a model that declares
3083// 4.7e-4 cm, and the 1e-6 constant "is a fallback, not a measurement of the model". The band
3084// must hold in the loader *and* in the kernel's own wire construction, or the loader would
3085// accept a wire that Add*Surface rejects moments later.
3086BOOST_AUTO_TEST_CASE(StreamY_LoaderHonoursTheDeclaredModelTolerance)
3087{
3088 constexpr double radius = 2.;
3089 constexpr double halfHeight = 3.;
3090 // one seam offset purely in v (cm), so the 3D gap equals the offset on any cylinder:
3091 // over the 1e-6 cm extractor floor, under the model tolerance the loading case declares
3092 constexpr double joinGap = 5.e-6;
3093
3094 const auto cylinderBytes = [&](uint32_t version, double modelTolerance) {
3095 std::vector<char> bytes;
3096 appendSidecarHeader(bytes, 3, version, modelTolerance);
3097 appendU32(bytes, 2); // surfaceType cylinder
3098 appendU32(bytes, 0); // flags (outer wall)
3099 appendU32(bytes, 14); // nParams
3100 appendDoubles(bytes, {0., 0., 0., 0., 0., 1., 1., 0., 0., radius, -halfHeight, halfHeight, 0., 2. * surf::kPi});
3101 appendU32(bytes, 1); // nWires
3102 appendU32(bytes, 0); // outer wire role
3103 appendU32(bytes, 5); // nEdges
3104 // The bottom edge is split in two and the second half starts joinGap off the first half's
3105 // end -- a mid-wire join like surface 1006's bspline->line seam. Deliberately *not* at the
3106 // phi-wrap corner: the full-turn seam pair (u = 0 vs u = 2*pi) must stay exactly coincident
3107 // to cancel in the rim chaining, and the real rejection's face spans only half a turn.
3108 const std::array<std::array<double, 4>, 5> edges{
3109 {{0., -halfHeight, surf::kPi, -halfHeight},
3110 {surf::kPi, -halfHeight + joinGap, 2. * surf::kPi, -halfHeight}, // starts joinGap off edge 0's end
3111 {2. * surf::kPi, -halfHeight, 2. * surf::kPi, halfHeight},
3112 {2. * surf::kPi, halfHeight, 0., halfHeight},
3113 {0., halfHeight, 0., -halfHeight}}};
3114 for (const auto& edge : edges) {
3115 appendU32(bytes, 0); // curveType line
3116 appendU32(bytes, 4); // nCurveParams
3117 appendDoubles(bytes, {edge[0], edge[1], edge[2], edge[3]});
3118 }
3119 appendDiskPlaneRecord(bytes, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radius);
3120 appendDiskPlaneRecord(bytes, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radius);
3121 return bytes;
3122 };
3123 const auto loadFrom = [](const char* name, const std::vector<char>& bytes, SurfaceSolid& solid) {
3124 const auto path = writeSidecarFile(name, bytes);
3125 const bool ok = o2::cad::LoadSurfaceSolid(path.string(), solid);
3126 std::filesystem::remove(path);
3127 return ok;
3128 };
3129
3130 // a model declaring 1e-4 cm: the 5e-6 cm seam is within the model's own statement, so it loads,
3131 // closes, and navigates like the gap-free tube (the kernel canonicalizes each seam on accept)
3132 SurfaceSolid declared("sidecarJoinDeclared");
3133 BOOST_REQUIRE(loadFrom("o2_sidecar_join_declared.bin", cylinderBytes(2, 1.e-4), declared));
3134 BOOST_CHECK_EQUAL(declared.GetNsurfaces(), 3);
3135 declared.CloseShape();
3136 BOOST_CHECK(declared.IsClosed());
3137 BOOST_CHECK(declared.IsOrientationConsistent());
3138 TGeoTube reference("declaredToleranceTube", 0., radius, halfHeight);
3139 compareContainsGrid(declared, reference, 4., 7);
3140 compareDistance(declared, reference, {5., 0.5, 1.}, {-1., 0., 0.});
3141
3142 // a v1 file states nothing, so the extractor-precision floor stands and the same seam is open
3143 SurfaceSolid silent("sidecarJoinSilent");
3144 BOOST_CHECK(!loadFrom("o2_sidecar_join_silent.bin", cylinderBytes(1, 0.), silent));
3145 BOOST_CHECK_EQUAL(silent.GetNsurfaces(), 0);
3146
3147 // a declared tolerance below the gap does not save it: the model itself calls the seam open
3148 SurfaceSolid tight("sidecarJoinTight");
3149 BOOST_CHECK(!loadFrom("o2_sidecar_join_tight.bin", cylinderBytes(2, 2.e-6), tight));
3150 BOOST_CHECK_EQUAL(tight.GetNsurfaces(), 0);
3151}
3152
3153namespace
3154{
3155// Append a plane record whose rectangular outer wire has its bottom edge as a degree-3 B-spline
3156// with collinear poles — geometrically identical to the straight edge, so the box still closes,
3157// but it exercises the whole B-spline sidecar pipeline (curveType 2 reader -> kernel).
3158void appendBSplineEdgePlaneRecord(std::vector<char>& bytes, const FaceFrame& frame)
3159{
3160 appendU32(bytes, 1); // surfaceType plane
3161 appendU32(bytes, 0); // flags
3162 appendU32(bytes, 9); // nParams
3163 appendDoubles(bytes, {frame.origin[0], frame.origin[1], frame.origin[2], frame.axisU[0], frame.axisU[1],
3164 frame.axisU[2], frame.axisV[0], frame.axisV[1], frame.axisV[2]});
3165 appendU32(bytes, 1); // nWires
3166 appendU32(bytes, 0); // wireRole outer
3167 appendU32(bytes, 4); // nEdges
3168 const double extentU = frame.extentU;
3169 const double extentV = frame.extentV;
3170 // edge 0: collinear cubic B-spline from (0, 0) to (extentU, 0)
3171 appendU32(bytes, 2); // curveType bspline
3172 appendU32(bytes, 22); // nCurveParams = 2 + 2*4 + 4 + 8
3173 appendDoubles(bytes, {3., 4., // degree, nPoles
3174 0., 0., extentU / 3., 0., 2. * extentU / 3., 0., extentU, 0., // poles
3175 1., 1., 1., 1., // weights
3176 0., 0., 0., 0., 1., 1., 1., 1.}); // clamped knots
3177 const std::array<std::array<double, 4>, 3> lines{
3178 {{extentU, 0., extentU, extentV}, {extentU, extentV, 0., extentV}, {0., extentV, 0., 0.}}};
3179 for (const auto& edge : lines) {
3180 appendU32(bytes, 0); // curveType line
3181 appendU32(bytes, 4); // nCurveParams
3182 appendDoubles(bytes, {edge[0], edge[1], edge[2], edge[3]});
3183 }
3184}
3185
3186// A rational quadratic B-spline (NURBS) quarter circle from angle a0 to a0 + pi/2, in the (u, v)
3187// domain, centred at (cu, cv) with radius r. Four of these form an exact circle.
3192surf::Curve2D fullCircleBSpline(double cu, double cv, double r)
3193{
3194 const double w = std::sqrt(0.5);
3195 const std::vector<surf::Vec2> poles{{cu + r, cv}, {cu + r, cv + r}, {cu, cv + r}, {cu - r, cv + r}, {cu - r, cv}, {cu - r, cv - r}, {cu, cv - r}, {cu + r, cv - r}, {cu + r, cv}};
3196 return surf::Curve2D::makeBSpline(2, poles, {1., w, 1., w, 1., w, 1., w, 1.},
3197 {0., 0., 0., 1., 1., 2., 2., 3., 3., 4., 4., 4.});
3198}
3199
3200surf::Curve2D quarterCircleBSpline(double cu, double cv, double r, double a0)
3201{
3202 const double a1 = a0 + surf::kHalfPi;
3203 const double aMid = 0.5 * (a0 + a1);
3204 const std::vector<surf::Vec2> poles{{cu + r * std::cos(a0), cv + r * std::sin(a0)},
3205 {cu + r * std::sqrt(2.) * std::cos(aMid), cv + r * std::sqrt(2.) * std::sin(aMid)},
3206 {cu + r * std::cos(a1), cv + r * std::sin(a1)}};
3207 return surf::Curve2D::makeBSpline(2, poles, {1., std::sqrt(0.5), 1.}, {0., 0., 0., 1., 1., 1.});
3208}
3209} // namespace
3210
3211// K5: the on-boundary band has to be as wide as the representation it measures against, and
3212// winding and distance have to measure against the same polyline.
3213BOOST_AUTO_TEST_CASE(BoundaryBandMatchesTheRepresentation)
3214{
3215 using surf::Vec2;
3217 using surf::WireRole;
3218 using surf::WireStatus;
3219 std::string error;
3220
3221 // A loop of lines and arcs is held exactly and claims no width of its own...
3222 surf::CurveWire exactWire;
3223 WireStatus status = WireStatus::Valid;
3224 BOOST_REQUIRE(exactWire.initialize({surf::Curve2D::makeCircle({0., 0.}, 1.)}, WireRole::Outer, status));
3225 BOOST_CHECK_EQUAL(exactWire.representationTolerance(), 0.);
3226
3227 // ...while a B-spline loop is only as good as the polyline it is flattened to.
3228 surf::CurveWire splineWire;
3229 BOOST_REQUIRE(splineWire.initialize({fullCircleBSpline(0., 0., 1.)}, WireRole::Outer, status));
3230 checkClose(splineWire.representationTolerance(), surf::kBSplineFlatness, 1.e-18);
3231
3232 // The Boundary state must therefore be reachable for a B-spline trim. It was not: a 1e-9 band
3233 // around a 1e-5 polyline is noise, so a point this close to the curve used to come back Inside
3234 // or Outside by coin flip.
3235 const double justInsideTheBand = 0.5 * surf::kBSplineFlatness;
3236 BOOST_CHECK(splineWire.classify({1. - justInsideTheBand, 0.}) == WireClassification::Boundary);
3237 BOOST_CHECK(splineWire.classify({1. + justInsideTheBand, 0.}) == WireClassification::Boundary);
3238 // and well outside the band the answer is decided again, in both directions
3239 BOOST_CHECK(splineWire.classify({0.5, 0.}) == WireClassification::Inside);
3240 BOOST_CHECK(splineWire.classify({2.0, 0.}) == WireClassification::Outside);
3241 // the exact loop keeps its narrow band: the same offset is decidable there
3242 BOOST_CHECK(exactWire.classify({1. - justInsideTheBand, 0.}) == WireClassification::Inside);
3243
3244 // The band is a length, so on a surface that stretches the domain it narrows in parametric
3245 // terms. A 100 cm cylinder resolves 1e-9 cm at 1e-11 rad, not at 1e-9 rad.
3247 BOOST_REQUIRE(bigCylinder.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 100., -1., 1., 0., surf::kTwoPi,
3248 false, error));
3249 const auto bigMetric = surf::parametricMetricOf(bigCylinder);
3250 surf::CurveWire squareWire;
3251 BOOST_REQUIRE(squareWire.initialize({surf::Curve2D::makeLine({0., -1.}, {1., -1.}),
3252 surf::Curve2D::makeLine({1., -1.}, {1., 1.}),
3253 surf::Curve2D::makeLine({1., 1.}, {0., 1.}),
3254 surf::Curve2D::makeLine({0., 1.}, {0., -1.})},
3255 WireRole::Outer, status, bigMetric));
3256 // 1e-10 rad is 1e-8 cm on this cylinder -- outside a 1e-9 cm band, so the point is decidable
3257 BOOST_CHECK(squareWire.classify({0.5, 1. - 1.e-10}, bigMetric) == WireClassification::Inside);
3258 // 1e-12 rad is 1e-10 cm, inside it
3259 BOOST_CHECK(squareWire.classify({0.5, 1. - 1.e-12}, bigMetric) == WireClassification::Boundary);
3260
3261 // One polyline: a wire fixes one vertex value per seam, and both the winding polyline and the
3262 // point-to-curve distance are built from it. Give two quarter arcs endpoints that differ within
3263 // the join tolerance and the wire must still agree with itself about where its boundary is.
3264 const double seamDrift = 4.e-7;
3265 surf::Curve2D first = quarterCircleBSpline(0., 0., 1., 0.);
3266 surf::Curve2D second = quarterCircleBSpline(0., 0., 1., surf::kHalfPi);
3267 surf::Curve2D third = quarterCircleBSpline(0., 0., 1., surf::kPi);
3268 surf::Curve2D fourth = quarterCircleBSpline(0., 0., 1., 3. * surf::kHalfPi);
3269 second.poles.front() = {second.poles.front().uCoord + seamDrift, second.poles.front().vCoord};
3270 surf::CurveWire driftedWire;
3271 BOOST_REQUIRE(driftedWire.initialize({first, second, third, fourth}, WireRole::Outer, status));
3272 for (const auto& curve : driftedWire.curves) {
3273 BOOST_CHECK(curve.hasCanonicalEndpoints);
3274 }
3275 // every curve now begins exactly where its predecessor ends -- there is one boundary, not two
3276 for (size_t index = 0; index < driftedWire.curves.size(); ++index) {
3277 const Vec2 thisEnd = driftedWire.curves[index].loopEnd();
3278 const Vec2 nextStart = driftedWire.curves[(index + 1) % driftedWire.curves.size()].loopStart();
3279 BOOST_CHECK_EQUAL(thisEnd.uCoord, nextStart.uCoord);
3280 BOOST_CHECK_EQUAL(thisEnd.vCoord, nextStart.vCoord);
3281 }
3282 // and the polyline the winding walks is the one the distance measures against
3283 for (const auto& curve : driftedWire.curves) {
3284 const auto& polyline = curve.bsplineSamples();
3285 BOOST_REQUIRE(polyline.size() >= 2);
3286 BOOST_CHECK_EQUAL(polyline.front().uCoord, curve.loopStart().uCoord);
3287 BOOST_CHECK_EQUAL(polyline.front().vCoord, curve.loopStart().vCoord);
3288 BOOST_CHECK_EQUAL(polyline.back().uCoord, curve.loopEnd().uCoord);
3289 BOOST_CHECK_EQUAL(polyline.back().vCoord, curve.loopEnd().vCoord);
3290 }
3291 BOOST_CHECK(driftedWire.classify({0., 0.}) == WireClassification::Inside);
3292 BOOST_CHECK(driftedWire.classify({3., 0.}) == WireClassification::Outside);
3293}
3294
3295// The other half of the on-boundary band check.
3296//
3297// BoundaryBandMatchesTheRepresentation above pins the *width* of the band. This pins what happens
3298// to a ray that lands in it. Resolving Boundary as "inside the trim" is a tie-break, not a fact,
3299// and it is one-sided: the patch keeps a sliver of the band's width past its true trim curve. On a
3300// Boolean seam that sliver lies in the solid's interior, where a crossing must not be counted, so
3301// a ray through it gains a spurious crossing and Contains() flips.
3302//
3303// Measured on cyl_cross_cyl (two unit cylinders fused, whose seam is transcendental in either
3304// face's chart, so it has to be carried as a B-spline): every one of 1440 sampled positions along
3305// the true seam overhangs by 1.0e-5 to 1.9e-5 cm and *none* undercuts -- the floor being the band
3306// itself and the excess the polyline flattening. That is the single direction-dependent point the
3307// section 4.2 sweep found, and it is not the root-finding defect (K6) it was filed as.
3308//
3309// The kernel cannot remove the sliver -- the data does not say where the seam is to better than
3310// this -- so it labels it instead, and Contains() re-aims when a shot rests on one. Hence the
3311// contract here: the flag is set exactly when the answer came from the tie-break, and is *not*
3312// set for a hit the trim decides on its own, because a flag that fired everywhere would put every
3313// query on the voting path.
3314BOOST_AUTO_TEST_CASE(TrimBoundaryHitsAreFlaggedAsAmbiguous)
3315{
3316 using surf::Curve2D;
3317 std::string error;
3318
3319 // a cylinder of radius 2 carrying a circular B-spline window of radius 0.5 in (phi, h)
3320 const double centrePhi = surf::kPi;
3321 const double trimRadius = 0.5;
3322 surf::CylindricalBoundedSurface splineTrimmed;
3323 BOOST_REQUIRE(splineTrimmed.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi,
3324 false,
3325 {quarterCircleBSpline(centrePhi, 0., trimRadius, 0.),
3326 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kHalfPi),
3327 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kPi),
3328 quarterCircleBSpline(centrePhi, 0., trimRadius, 3. * surf::kHalfPi)},
3329 {}, error));
3330
3331 // the same window held exactly, as one arc: it claims no width, so nothing is ever ambiguous
3333 BOOST_REQUIRE(arcTrimmed.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
3334 {Curve2D::makeCircle({centrePhi, 0.}, trimRadius)}, {}, error));
3335
3336 // a radial ray that meets the wall at azimuth phi, h = 0
3337 const auto hitAt = [](const surf::CylindricalBoundedSurface& surface, double phi) {
3338 std::vector<surf::RayHit> hits;
3339 surface.appendIntersections({0., 0., 0.}, {std::cos(phi), std::sin(phi), 0.}, 0., 1.e30, hits);
3340 return hits;
3341 };
3342
3343 // The band on this surface is the representation's own tolerance: 1e-5 in (phi, h), since the
3344 // length floor kTolerance / maxScale is 1e-9 / 2 and loses.
3345 const double band = surf::kBSplineFlatness;
3346 const double justInside = 0.5 * band;
3347
3348 // 1. well inside the window the trim decides by itself -- accepted, and NOT flagged
3349 {
3350 const auto hits = hitAt(splineTrimmed, centrePhi);
3351 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
3352 BOOST_CHECK(!hits.front().onTrimBoundary);
3353 }
3354 // 2. inside the window but within the band of its edge -- accepted, and flagged
3355 {
3356 const auto hits = hitAt(splineTrimmed, centrePhi + trimRadius - justInside);
3357 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
3358 BOOST_CHECK(hits.front().onTrimBoundary);
3359 }
3360 // 3. OUTSIDE the window, still within the band -- accepted anyway, and flagged. This is the
3361 // sliver: the tie-break keeps material the trim curve does not enclose.
3362 {
3363 const auto hits = hitAt(splineTrimmed, centrePhi + trimRadius + justInside);
3364 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
3365 BOOST_CHECK(hits.front().onTrimBoundary);
3366 }
3367 // 4. beyond the band the patch really does end
3368 {
3369 BOOST_CHECK(hitAt(splineTrimmed, centrePhi + trimRadius + 100. * band).empty());
3370 }
3371 // 5. an exactly-held trim has no sliver to label: the same offsets are decided, not flagged
3372 {
3373 const auto inside = hitAt(arcTrimmed, centrePhi + trimRadius - justInside);
3374 BOOST_REQUIRE_EQUAL(inside.size(), 1u);
3375 BOOST_CHECK(!inside.front().onTrimBoundary);
3376 BOOST_CHECK(hitAt(arcTrimmed, centrePhi + trimRadius + justInside).empty());
3377 }
3378 // 6. and an untrimmed patch never sets it, which is what keeps the fast path fast
3379 {
3381 BOOST_REQUIRE(plain.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
3382 error));
3383 const auto hits = hitAt(plain, centrePhi);
3384 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
3385 BOOST_CHECK(!hits.front().onTrimBoundary);
3386 }
3387}
3388
3389BOOST_AUTO_TEST_CASE(BSplineSidecarRoundTrip)
3390{
3391 // a closed box whose first face carries a (collinear) B-spline boundary edge round-trips through
3392 // the sidecar reader and navigates identically to TGeoBBox
3393 constexpr double halfX = 1.;
3394 constexpr double halfY = 2.;
3395 constexpr double halfZ = 3.;
3396
3397 std::vector<char> bytes;
3398 appendSidecarHeader(bytes, 6);
3399 appendBSplineEdgePlaneRecord(bytes, boxFaceFrame(0, halfX, halfY, halfZ));
3400 for (int faceIndex = 1; faceIndex < 6; ++faceIndex) {
3401 appendPlaneRecord(bytes, boxFaceFrame(faceIndex, halfX, halfY, halfZ));
3402 }
3403 const auto path = writeSidecarFile("o2_sidecar_bspline_box.bin", bytes);
3404
3405 SurfaceSolid box("sidecarBSplineBox");
3406 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(path.string(), box));
3407 std::filesystem::remove(path);
3408 BOOST_CHECK_EQUAL(box.GetNsurfaces(), 6);
3409 box.CloseShape();
3410 BOOST_CHECK(box.IsClosed());
3411 BOOST_CHECK(box.IsOrientationConsistent());
3412
3413 TGeoBBox reference("bsplineBoxRef", halfX, halfY, halfZ);
3414 compareContainsGrid(box, reference, 4., 7);
3415 compareDistance(box, reference, {5., 0.5, 0.5}, {-1., 0., 0.});
3416 compareDistance(box, reference, {0., 0., 0.}, unitDirection(1., 1., 1.));
3417 checkClose(box.Capacity(), reference.Capacity(), 1.e-6);
3418}
3419
3420BOOST_AUTO_TEST_CASE(BSplineWindowInCylinderWall)
3421{
3422 using surf::Curve2D;
3423 using surf::Vec3;
3424 std::string error;
3425
3426 const auto onCylinder = [](double phi, double height) {
3427 return Vec3{2. * std::cos(phi), 2. * std::sin(phi), height};
3428 };
3429
3430 // an exact circular trim in (phi, h) built from four NURBS quarter arcs must classify identically
3431 // to the same circle expressed as one exact arc Curve2D — validating the B-spline trim path on a
3432 // quadric against the closed-form arc path.
3433 const double centrePhi = surf::kPi;
3434 const double trimRadius = 0.5;
3436 const std::vector<Curve2D> bsplineOuter{quarterCircleBSpline(centrePhi, 0., trimRadius, 0.),
3437 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kHalfPi),
3438 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kPi),
3439 quarterCircleBSpline(centrePhi, 0., trimRadius, 3. * surf::kHalfPi)};
3440 BOOST_REQUIRE(bsplineDisk.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
3441 bsplineOuter, {}, error));
3442 BOOST_CHECK(!bsplineDisk.capacityIsExact()); // B-spline (wire) trim -> numeric capacity
3443
3445 BOOST_REQUIRE(arcDisk.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
3446 {Curve2D::makeCircle({centrePhi, 0.}, trimRadius)}, {}, error));
3447
3448 // classification agrees across a grid of the (phi, h) neighbourhood of the trim (skip a thin band
3449 // around the boundary, where the exact-arc and sampled-B-spline classifications can legitimately
3450 // differ by the sampling tolerance)
3451 int compared = 0;
3452 for (int phiStep = -12; phiStep <= 12; ++phiStep) {
3453 const double phi = centrePhi + 0.09 * phiStep;
3454 for (int hStep = -12; hStep <= 12; ++hStep) {
3455 const double height = 0.09 * hStep;
3456 // radial distance in (phi, h) from the trim centre; skip the boundary band
3457 const double distToCentre = std::hypot(phi - centrePhi, height);
3458 if (std::abs(distToCentre - trimRadius) < 5.e-3) {
3459 continue;
3460 }
3461 const Vec3 point = onCylinder(phi, height);
3462 BOOST_CHECK_EQUAL(bsplineDisk.containsPointOnSurface(point), arcDisk.containsPointOnSurface(point));
3463 ++compared;
3464 }
3465 }
3466 BOOST_CHECK_GT(compared, 100);
3467
3468 // a radial ray into the B-spline window (its centre) registers exactly one wall hit; a ray well
3469 // outside the window (opposite side of the cylinder) misses the trimmed patch
3470 std::vector<surf::RayHit> hits;
3471 bsplineDisk.appendIntersections({0., 0., 0.}, {std::cos(centrePhi), std::sin(centrePhi), 0.}, 0., 1.e30, hits);
3472 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
3473 checkClose(hits.front().distance, 2.);
3474 hits.clear();
3475 bsplineDisk.appendIntersections({0., 0., 0.}, {std::cos(0.), std::sin(0.), 0.}, 0., 1.e30, hits);
3476 BOOST_CHECK(hits.empty());
3477}
3478
3479// A B-spline wire used as an *inner hole*, and in particular one spelled as a single CLOSED
3480// B-spline edge. This is what a tube-tube intersection produces and what the converter emits
3481// constantly: where a boom tube is planted on a fat tube, the fat tube's wall keeps a
3482// full-rectangle outer wire and carries the intersection curve as one closed B-spline hole.
3483//
3484// It regresses a bug that silently deleted such a wire outright. bsplineSampleRecursive used to
3485// end the recursion when the chord p0->p1 was shorter than the flatness scale; a closed curve has
3486// p0 == p1 exactly, so a full circle flattened to two coincident points and every polyline-based
3487// query (winding, closest point, boundary band, display mesh) saw an empty curve. The wire still
3488// validated and still reported the correct enclosed area, because signedAreaContribution
3489// integrates the curve by Gauss-Legendre rather than from the polyline -- which is exactly why
3490// this survived: every check that could have caught it used the analytic path.
3491//
3492// Impact: on ExcavatorArm/BoomCylinderOuter_0_1_1_9 a point 0.026 cm inside such a hole was reported as
3493// lying on the face, and a whole face whose outer wire was one closed B-spline did not exist at
3494// all. `WireTrimmedQuadricKernels` covers a *line* hole and `BSplineWindowInCylinderWall` covers a
3495// B-spline outer wire built from four *open* quarter arcs, so neither could see it.
3496BOOST_AUTO_TEST_CASE(BSplineHoleInCylinderWall)
3497{
3498 using surf::Curve2D;
3499 using surf::Vec3;
3500 std::string error;
3501
3502 const auto onCylinder = [](double phi, double height) {
3503 return Vec3{2. * std::cos(phi), 2. * std::sin(phi), height};
3504 };
3505
3506 // full-sweep outer wire (what the converter writes for an untrimmed cylinder wall) ...
3507 const std::vector<Curve2D> outer{Curve2D::makeLine({0., -3.}, {surf::kTwoPi, -3.}),
3508 Curve2D::makeLine({surf::kTwoPi, -3.}, {surf::kTwoPi, 3.}),
3509 Curve2D::makeLine({surf::kTwoPi, 3.}, {0., 3.}),
3510 Curve2D::makeLine({0., 3.}, {0., -3.})};
3511 // ... with a circular hole punched in it, expressed once as four NURBS quarter arcs and once as
3512 // the equivalent exact arc. The arc form is the oracle: it is the already-trusted path.
3513 const double centrePhi = surf::kPi;
3514 const double trimRadius = 0.5;
3515 const std::vector<Curve2D> bsplineHole{quarterCircleBSpline(centrePhi, 0., trimRadius, 0.),
3516 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kHalfPi),
3517 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kPi),
3518 quarterCircleBSpline(centrePhi, 0., trimRadius, 3. * surf::kHalfPi)};
3519
3521 BOOST_REQUIRE(bsplineHoled.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -3., 3., 0., surf::kTwoPi, false,
3522 outer, {bsplineHole}, error));
3524 BOOST_REQUIRE(arcHoled.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -3., 3., 0., surf::kTwoPi, false,
3525 outer, {{Curve2D::makeCircle({centrePhi, 0.}, trimRadius)}}, error));
3526
3527 // the defining property of a hole: its interior is NOT part of the face
3528 BOOST_CHECK(!arcHoled.containsPointOnSurface(onCylinder(centrePhi, 0.))); // oracle
3529 BOOST_CHECK(!bsplineHoled.containsPointOnSurface(onCylinder(centrePhi, 0.))); // the case under test
3530 // and material well away from the hole still is
3531 BOOST_CHECK(bsplineHoled.containsPointOnSurface(onCylinder(0.5, 0.)));
3532 BOOST_CHECK(bsplineHoled.containsPointOnSurface(onCylinder(centrePhi, 2.5)));
3533
3534 // the two spellings of the same hole must classify identically away from the boundary band
3535 int compared = 0;
3536 for (int phiStep = -12; phiStep <= 12; ++phiStep) {
3537 const double phi = centrePhi + 0.09 * phiStep;
3538 for (int hStep = -12; hStep <= 12; ++hStep) {
3539 const double height = 0.09 * hStep;
3540 if (std::abs(std::hypot(phi - centrePhi, height) - trimRadius) < 5.e-3) {
3541 continue;
3542 }
3543 const Vec3 point = onCylinder(phi, height);
3544 BOOST_CHECK_EQUAL(bsplineHoled.containsPointOnSurface(point), arcHoled.containsPointOnSurface(point));
3545 ++compared;
3546 }
3547 }
3548 BOOST_CHECK_GT(compared, 100);
3549
3550 // a radial ray aimed through the hole must not register a wall hit; one aimed at material must
3551 std::vector<surf::RayHit> hits;
3552 bsplineHoled.appendIntersections({0., 0., 0.}, {std::cos(centrePhi), std::sin(centrePhi), 0.}, 0., 1.e30, hits);
3553 BOOST_CHECK(hits.empty());
3554 hits.clear();
3555 bsplineHoled.appendIntersections({0., 0., 0.}, {std::cos(0.5), std::sin(0.5), 0.}, 0., 1.e30, hits);
3556 BOOST_CHECK_EQUAL(hits.size(), 1u);
3557
3558 // The same hole as ONE closed B-spline edge rather than four arc segments. This is what the
3559 // converter actually emits for a tube-tube seam (`_quadric_trim_wire` writes one B-spline per
3560 // BREP edge, and the intersection curve is a single closed edge).
3561 surf::CylindricalBoundedSurface singleEdgeHoled;
3562 BOOST_REQUIRE(singleEdgeHoled.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -3., 3., 0., surf::kTwoPi,
3563 false, outer, {{fullCircleBSpline(centrePhi, 0., trimRadius)}}, error));
3564 BOOST_CHECK(!singleEdgeHoled.containsPointOnSurface(onCylinder(centrePhi, 0.)));
3565 BOOST_CHECK(singleEdgeHoled.containsPointOnSurface(onCylinder(0.5, 0.)));
3566 BOOST_CHECK(singleEdgeHoled.containsPointOnSurface(onCylinder(centrePhi, 2.5)));
3567 for (int phiStep = -12; phiStep <= 12; ++phiStep) {
3568 const double phi = centrePhi + 0.09 * phiStep;
3569 for (int hStep = -12; hStep <= 12; ++hStep) {
3570 const double height = 0.09 * hStep;
3571 if (std::abs(std::hypot(phi - centrePhi, height) - trimRadius) < 5.e-3) {
3572 continue;
3573 }
3574 const Vec3 point = onCylinder(phi, height);
3575 BOOST_CHECK_EQUAL(singleEdgeHoled.containsPointOnSurface(point), arcHoled.containsPointOnSurface(point));
3576 }
3577 }
3578}
3579
3580namespace
3581{
3582// The public-API mirror of quarterCircleBSpline, for building a NURBS trim through Add*Surface.
3583BoundaryCurve quarterCircleBoundaryCurve(double cu, double cv, double r, double a0)
3584{
3585 const double a1 = a0 + surf::kHalfPi;
3586 const double aMid = 0.5 * (a0 + a1);
3587 const std::vector<Point2D> poles{{cu + r * std::cos(a0), cv + r * std::sin(a0)},
3588 {cu + r * std::sqrt(2.) * std::cos(aMid), cv + r * std::sqrt(2.) * std::sin(aMid)},
3589 {cu + r * std::cos(a1), cv + r * std::sin(a1)}};
3590 return BoundaryCurve::makeBSpline(2, poles, {1., std::sqrt(0.5), 1.}, {0., 0., 0., 1., 1., 1.});
3591}
3592
3593// Assert that two solids are the *same* solid, not merely similar ones: identical closure
3594// diagnostics and reliability, identical bounding box and capacity, and bit-identical answers
3595// from all four navigation kernels over the standard probe grid and direction set. This is the
3596// acceptance criterion for persistence -- a solid that survives a write/read cycle must be
3597// indistinguishable through the public interface.
3598void checkSolidsIdentical(const SurfaceSolid& solid, const SurfaceSolid& other, double extent, int samples)
3599{
3600 BOOST_CHECK_EQUAL(other.GetNsurfaces(), solid.GetNsurfaces());
3601 BOOST_CHECK_EQUAL(other.IsDefined(), solid.IsDefined());
3602 BOOST_CHECK_EQUAL(other.HasBVH(), solid.HasBVH());
3603 BOOST_CHECK_EQUAL(other.IsClosed(), solid.IsClosed());
3604 BOOST_CHECK_EQUAL(other.IsOrientationConsistent(), solid.IsOrientationConsistent());
3605 BOOST_CHECK_EQUAL(static_cast<int>(other.GetNavigationReliability()),
3606 static_cast<int>(solid.GetNavigationReliability()));
3607 BOOST_CHECK_EQUAL(other.GetBoundaryEdgeCount(), solid.GetBoundaryEdgeCount());
3608 BOOST_CHECK_EQUAL(other.GetNonManifoldEdgeCount(), solid.GetNonManifoldEdgeCount());
3609 BOOST_CHECK_EQUAL(other.GetReversedEdgeCount(), solid.GetReversedEdgeCount());
3610
3611 BOOST_CHECK_EQUAL(other.GetDX(), solid.GetDX());
3612 BOOST_CHECK_EQUAL(other.GetDY(), solid.GetDY());
3613 BOOST_CHECK_EQUAL(other.GetDZ(), solid.GetDZ());
3614 for (int dimension = 0; dimension < 3; ++dimension) {
3615 BOOST_CHECK_EQUAL(other.GetOrigin()[dimension], solid.GetOrigin()[dimension]);
3616 }
3617 BOOST_CHECK_EQUAL(other.Capacity(), solid.Capacity());
3618
3619 for (const auto& point : probeGrid(extent, samples)) {
3620 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
3621 {
3622 BOOST_CHECK_EQUAL(other.Contains(point.data()), solid.Contains(point.data()));
3623 BOOST_CHECK_EQUAL(other.Safety(point.data(), solid.Contains(point.data())),
3624 solid.Safety(point.data(), solid.Contains(point.data())));
3625 for (const auto& direction : probeDirections()) {
3626 BOOST_CHECK_EQUAL(other.DistFromOutside(point.data(), direction.data(), 3),
3627 solid.DistFromOutside(point.data(), direction.data(), 3));
3628 BOOST_CHECK_EQUAL(other.DistFromInside(point.data(), direction.data(), 3),
3629 solid.DistFromInside(point.data(), direction.data(), 3));
3630 }
3631 }
3632 }
3633}
3634
3635// Write "solid" to a ROOT file and read it back as an independent object.
3636std::unique_ptr<SurfaceSolid> writeAndReadBack(const SurfaceSolid& solid)
3637{
3638 const auto path = std::filesystem::temp_directory_path() /
3639 (std::string("o2_bvhsurfacesolid_persist_") + solid.GetName() + ".root");
3640 {
3641 TFile file(path.string().c_str(), "RECREATE");
3642 BOOST_REQUIRE(!file.IsZombie());
3643 // WriteObject takes a non-const pointer; the call does not modify the solid.
3644 file.WriteObject(const_cast<SurfaceSolid*>(&solid), "solid");
3645 }
3646 std::unique_ptr<SurfaceSolid> restored;
3647 {
3648 TFile file(path.string().c_str(), "READ");
3649 BOOST_REQUIRE(!file.IsZombie());
3650 restored.reset(file.Get<SurfaceSolid>("solid"));
3651 }
3652 std::filesystem::remove(path);
3653 return restored;
3654}
3655} // namespace
3656
3657// ROOT persistence round trip. The kernel objects behind the solid (BoundedSurface, the BVH, the
3658// display mesh) are all *derived* state; what has to survive a write/read cycle is the sequence of
3659// Add*Surface calls the solid was built from, after which CloseShape() reconstructs the rest.
3660//
3661// It regresses a bug where nothing at all was streamed: fImpl was transient, so
3662// a read-back solid came back with zero surfaces, CloseShape(false) then zeroed the streamed
3663// bounding box, and an *empty* ClosureReport defaults to closed/consistent -- so the husk reported
3664// NavigationReliability::Reliable and answered "outside" everywhere with full confidence. Any
3665// TGeoManager::Export/Import of a geometry containing one of these solids silently replaced it by
3666// an authoritatively-reliable empty point.
3667BOOST_AUTO_TEST_CASE(PersistenceRoundTrip)
3668{
3669 // every surface family and both trim flavours (scalar range and wire trim, the latter with
3670 // line, arc and B-spline curves) must survive, so each record field is exercised
3671 const auto box = makeBoxSolid("persistBox", 1., 2., 3.);
3672 const auto tube = makeTubeSolid("persistTube", 1., 2., 3.); // inner wall + annular arc-wire caps
3673 const auto cone = makeConeSolid("persistCone", 2., 1., 3.);
3674 const auto sphere = makeSphereSolid("persistSphere", 2.);
3675 const auto torus = makeTorusSolid("persistTorus", 3., 1.);
3676 const auto capsule = makeCapsuleSolid("persistCapsule", 2., 3.);
3677
3678 for (const auto* solid : {box.get(), tube.get(), cone.get(), sphere.get(), torus.get(), capsule.get()}) {
3679 BOOST_TEST_CONTEXT("solid = " << solid->GetName())
3680 {
3681 const auto restored = writeAndReadBack(*solid);
3682 BOOST_REQUIRE(restored != nullptr);
3683 checkSolidsIdentical(*solid, *restored, 4.5, 5);
3684 }
3685 }
3686
3687 // a wire-trimmed cylinder whose window is a NURBS loop: the B-spline degree, poles, weights and
3688 // knots all have to make the round trip, and the trimmed overload has to be the one replayed
3689 SurfaceSolid trimmed("persistTrimmed");
3690 constexpr double radius = 2.;
3691 constexpr double halfHeight = 3.;
3692 const std::vector<BoundaryCurve> window{quarterCircleBoundaryCurve(surf::kPi, 0., 0.5, 0.),
3693 quarterCircleBoundaryCurve(surf::kPi, 0., 0.5, surf::kHalfPi),
3694 quarterCircleBoundaryCurve(surf::kPi, 0., 0.5, surf::kPi),
3695 quarterCircleBoundaryCurve(surf::kPi, 0., 0.5, 3. * surf::kHalfPi)};
3696 BOOST_REQUIRE(trimmed.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -halfHeight,
3697 halfHeight, 0., surf::kTwoPi, false, window));
3698 BOOST_REQUIRE(addDiskSurface(trimmed, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radius));
3699 BOOST_REQUIRE(addDiskSurface(trimmed, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radius));
3700 trimmed.CloseShape(false);
3701 BOOST_CHECK_EQUAL(trimmed.GetNsurfaces(), 3);
3702
3703 const auto restoredTrimmed = writeAndReadBack(trimmed);
3704 BOOST_REQUIRE(restoredTrimmed != nullptr);
3705 checkSolidsIdentical(trimmed, *restoredTrimmed, 4.5, 5);
3706
3707 // the model's own tolerance is solid-level state, not derived from the records, so it has to be
3708 // streamed rather than recomputed on replay
3709 SurfaceSolid toleranced("persistTolerance");
3710 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
3711 BOOST_REQUIRE(addBoxFace(toleranced, faceIndex, 1., 2., 3.));
3712 }
3713 toleranced.SetModelTolerance(7.25e-5);
3714 toleranced.CloseShape(false);
3715 const auto restoredToleranced = writeAndReadBack(toleranced);
3716 BOOST_REQUIRE(restoredToleranced != nullptr);
3717 checkClose(restoredToleranced->GetModelTolerance(), 7.25e-5, 1.e-18);
3718
3719 // an unnavigable solid must come back unnavigable: the failure mode S1 describes is precisely a
3720 // defective solid that acquires a clean bill of health by losing its surfaces on the way
3721 SurfaceSolid openBox("persistOpenBox");
3722 for (int faceIndex = 0; faceIndex < 5; ++faceIndex) { // deliberately missing the sixth face
3723 BOOST_REQUIRE(addBoxFace(openBox, faceIndex, 1., 2., 3.));
3724 }
3725 openBox.CloseShape(false);
3726 BOOST_REQUIRE(!openBox.IsNavigable());
3727 BOOST_CHECK_EQUAL(static_cast<int>(openBox.GetNavigationReliability()),
3728 static_cast<int>(SurfaceSolid::NavigationReliability::OpenSurfaceSet));
3729
3730 const auto restoredOpenBox = writeAndReadBack(openBox);
3731 BOOST_REQUIRE(restoredOpenBox != nullptr);
3732 BOOST_CHECK(!restoredOpenBox->IsNavigable());
3733 checkSolidsIdentical(openBox, *restoredOpenBox, 4.5, 5);
3734}
3735
3736// A solid that reaches the reader with no surface records -- a file written by an older version,
3737// or a solid streamed before CloseShape() -- must report Undetermined rather than manufacture a
3738// clean ClosureReport out of an empty surface set. "I do not know" is the only honest answer, and
3739// the difference matters: NavigationReliability is the flag callers are told to check.
3740BOOST_AUTO_TEST_CASE(EmptySolidIsNotReliable)
3741{
3742 SurfaceSolid empty("emptySolid");
3743 BOOST_CHECK_EQUAL(static_cast<int>(empty.GetNavigationReliability()),
3744 static_cast<int>(SurfaceSolid::NavigationReliability::Undetermined));
3745 BOOST_CHECK(!empty.IsNavigable());
3746
3747 // CloseShape on an empty surface set must not define the shape, with or without checking
3748 empty.CloseShape(false);
3749 BOOST_CHECK(!empty.IsDefined());
3750 BOOST_CHECK_EQUAL(static_cast<int>(empty.GetNavigationReliability()),
3751 static_cast<int>(SurfaceSolid::NavigationReliability::Undetermined));
3752 BOOST_CHECK(!empty.IsNavigable());
3753 BOOST_CHECK(!empty.IsClosed());
3754}
3755
3756namespace
3757{
3758// A golden-angle spiral of unit directions: quasi-uniform on the sphere, so no two are
3759// near-parallel and none aligns with a coordinate axis or a 45-degree symmetry plane. Used to
3760// test the invariant that containment does not depend on where the parity ray is aimed.
3761std::vector<std::array<double, 3>> spiralDirections(int count)
3762{
3763 std::vector<std::array<double, 3>> directions;
3764 directions.reserve(count);
3765 for (int index = 0; index < count; ++index) {
3766 const double cosTheta = 1. - 2. * (index + 0.5) / count;
3767 const double sinTheta = std::sqrt(1. - cosTheta * cosTheta);
3768 const double phi = 2.399963229728653 * index;
3769 directions.push_back({sinTheta * std::cos(phi), sinTheta * std::sin(phi), cosTheta});
3770 }
3771 return directions;
3772}
3773} // namespace
3774
3775// Parity containment answers a topological question, so on a closed, consistently oriented
3776// 2-manifold it cannot depend on where the ray is aimed. That invariant is what licenses the
3777// single-shot fast path: Contains() casts one fixed direction and stops.
3778//
3779// It is also the sharpest available oracle for the surface set itself -- no reference shape is
3780// involved, only the solid disagreeing with itself. Measured over the Phase 0 corpus, every part
3781// the closure check calls Reliable has *zero* direction disagreements in 11k points, and every
3782// part with disagreements is one the closure check already rejects.
3783BOOST_AUTO_TEST_CASE(ContainsIsDirectionIndependentOnClosedSolids)
3784{
3785 const auto box = makeBoxSolid("dirBox", 1., 2., 3.);
3786 const auto tube = makeTubeSolid("dirTube", 1., 2., 3.);
3787 const auto cone = makeConeSolid("dirCone", 2., 1., 3.);
3788 const auto sphere = makeSphereSolid("dirSphere", 2.);
3789 const auto torus = makeTorusSolid("dirTorus", 3., 1.);
3790 const auto capsule = makeCapsuleSolid("dirCapsule", 2., 3.);
3791
3792 const auto directions = spiralDirections(13);
3793 for (const auto* solid : {box.get(), tube.get(), cone.get(), sphere.get(), torus.get(), capsule.get()}) {
3794 BOOST_TEST_CONTEXT("solid = " << solid->GetName())
3795 {
3796 BOOST_REQUIRE(solid->IsNavigable());
3797 for (const auto& point : probeGrid(4.5, 7)) {
3798 const bool reference = solid->Contains(point.data());
3799 for (const auto& direction : directions) {
3800 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ") direction = ("
3801 << direction[0] << ", " << direction[1] << ", " << direction[2] << ")")
3802 {
3803 BOOST_CHECK_EQUAL(solid->ContainsAlongDirection(point.data(), direction.data()), reference);
3804 }
3805 }
3806 }
3807 }
3808 }
3809}
3810
3811// Section 4.4's re-shoot, on the defect it exists for. A gap in the surface set costs the parity
3812// ray exactly the crossings that fall inside the gap, so a point is misclassified over the whole
3813// *shadow* of the gap along the shooting direction -- centimetres of wrong answers arbitrarily far
3814// from any surface. Aiming the ray somewhere else escapes that shadow, which is why a majority
3815// over several directions recovers the right answer: measured over the 55 points where the single
3816// fixed direction disagrees with the OpenCascade oracle on the Phase 0 corpus, not one point is
3817// wrong in every direction.
3818//
3819// The fixture makes the mechanism explicit rather than statistical: the +x face of a box is split
3820// into two rectangles with a thin strip left out, so a ray leaving along +x from inside sees no
3821// crossing at all and reports "outside".
3822BOOST_AUTO_TEST_CASE(ContainsReshootsThroughSurfaceGaps)
3823{
3824 constexpr double halfX = 1.;
3825 constexpr double halfY = 2.;
3826 constexpr double halfZ = 3.;
3827 constexpr double gap = 0.05; // half-width in z of the missing strip on the +x face
3828
3829 SurfaceSolid gapped("gappedBox");
3830 for (int faceIndex = 1; faceIndex < 6; ++faceIndex) { // every face but +x
3831 BOOST_REQUIRE(addBoxFace(gapped, faceIndex, halfX, halfY, halfZ));
3832 }
3833 // the +x face as two rectangles, leaving z in (-gap, +gap) uncovered. Frame of face 0:
3834 // origin (halfX, -halfY, -halfZ), axisU = +y, axisV = +z.
3835 BOOST_REQUIRE(gapped.AddPlanarSurface({halfX, -halfY, -halfZ}, {0., 1., 0.}, {0., 0., 1.},
3836 rectangleWire(2. * halfY, halfZ - gap)));
3837 BOOST_REQUIRE(gapped.AddPlanarSurface({halfX, -halfY, gap}, {0., 1., 0.}, {0., 0., 1.},
3838 rectangleWire(2. * halfY, halfZ - gap)));
3839 gapped.CloseShape(false);
3840
3841 // the gap is what makes the solid unnavigable, and only an unnavigable solid re-shoots
3842 BOOST_REQUIRE(!gapped.IsNavigable());
3843 BOOST_CHECK_EQUAL(static_cast<int>(gapped.GetNavigationReliability()),
3844 static_cast<int>(SurfaceSolid::NavigationReliability::OpenSurfaceSet));
3845
3846 // a point deep inside whose +x ray leaves straight through the gap
3847 const std::array<double, 3> insidePoint{0., 0.3, 0.};
3848 const std::array<double, 3> throughGap{1., 0., 0.};
3849 BOOST_CHECK(!gapped.ContainsAlongDirection(insidePoint.data(), throughGap.data())); // the defect itself
3850 BOOST_CHECK(gapped.Contains(insidePoint.data())); // the re-shoot recovers it
3851 BOOST_CHECK(gapped.Contains_Loop(insidePoint.data())); // ... on both paths
3852
3853 // the same point in the same box *without* the gap is inside from every direction, so the
3854 // fixture isolates the gap and not some accident of the point
3855 const auto intact = makeBoxSolid("intactBox", halfX, halfY, halfZ);
3856 BOOST_CHECK(intact->Contains(insidePoint.data()));
3857 BOOST_CHECK(intact->ContainsAlongDirection(insidePoint.data(), throughGap.data()));
3858
3859 // and the BVH and loop parities still agree everywhere on the defective solid: the re-shoot is
3860 // applied by one shared helper, so it can never make the two paths differ
3861 for (const auto& point : probeGrid(4.5, 7)) {
3862 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
3863 {
3864 BOOST_CHECK_EQUAL(gapped.Contains(point.data()), gapped.Contains_Loop(point.data()));
3865 }
3866 }
3867}
3868
3869namespace
3870{
3871// An L-shaped prism, the concave fixture. Its footprint is ([0,3]x[0,1]) union ([0,1]x[1,2])
3872// extruded over
3873// z in [0, height], so it has a *reflex* (concave) vertical edge at x = 1, y = 1 -- the one place
3874// where a ray can touch the boundary from inside and stay inside, which no convex fixture can
3875// reproduce. Built from eight planar faces with outward normals, so it is closed and consistently
3876// oriented.
3877std::unique_ptr<SurfaceSolid> makeLPrismSolid(const char* name, double height = 1.)
3878{
3879 // footprint, counter-clockwise; (1,1) is the reflex vertex
3880 const std::vector<Point2D> footprint{{0., 0.}, {3., 0.}, {3., 1.}, {1., 1.}, {1., 2.}, {0., 2.}};
3881
3882 auto solid = std::make_unique<SurfaceSolid>(name);
3883
3884 // bottom (outward normal -z) and top (+z); axisU x axisV fixes the normal
3885 std::vector<Point2D> bottomWire;
3886 bottomWire.reserve(footprint.size());
3887 for (const auto& vertex : footprint) {
3888 bottomWire.push_back({vertex[1], vertex[0]}); // (u, v) = (y, x) so that axisU x axisV = -z
3889 }
3890 BOOST_REQUIRE(solid->AddPlanarSurface({0., 0., 0.}, {0., 1., 0.}, {1., 0., 0.}, bottomWire));
3891 BOOST_REQUIRE(solid->AddPlanarSurface({0., 0., height}, {1., 0., 0.}, {0., 1., 0.}, footprint));
3892
3893 // one vertical wall per footprint edge; axisU along the edge and axisV = +z put the normal at
3894 // (dy, -dx, 0), which points out of a counter-clockwise footprint
3895 for (size_t index = 0; index < footprint.size(); ++index) {
3896 const auto& start = footprint[index];
3897 const auto& end = footprint[(index + 1) % footprint.size()];
3898 const double deltaU = end[0] - start[0];
3899 const double deltaV = end[1] - start[1];
3900 const double length = std::hypot(deltaU, deltaV);
3901 BOOST_REQUIRE(solid->AddPlanarSurface({start[0], start[1], 0.}, {deltaU / length, deltaV / length, 0.},
3902 {0., 0., 1.}, rectangleWire(length, height)));
3903 }
3904 solid->CloseShape();
3905 return solid;
3906}
3907} // namespace
3908
3909// S2: the bounding-box pre-check ran *before* the documented "no BVH yet, fall back to the plain
3910// loop" branch. Before CloseShape() the box is still all zeros, so the pre-check rejected every
3911// point outside a 1e-9 cube at the origin and the fallback was unreachable -- Contains() was
3912// effectively disabled on any solid that had not been closed yet.
3913BOOST_AUTO_TEST_CASE(ContainsWorksBeforeCloseShape)
3914{
3915 SurfaceSolid box("preCloseBox");
3916 addBoxSurfaces(box, 1., 2., 3.);
3917 BOOST_REQUIRE(!box.HasBVH()); // the premise: no acceleration structure and no bounding box yet
3918
3919 for (const auto& point : probeGrid(4.5, 5)) {
3920 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
3921 {
3922 const bool inside = std::abs(point[0]) < 1. && std::abs(point[1]) < 2. && std::abs(point[2]) < 3.;
3923 BOOST_CHECK_EQUAL(box.Contains(point.data()), inside);
3924 BOOST_CHECK_EQUAL(box.Contains(point.data()), box.Contains_Loop(point.data()));
3925 }
3926 }
3927}
3928
3929// S3: a point *on* a face is inside for Contains, but its t = 0 exit was below the minimum ray
3930// parameter and therefore invisible to DistFromInside, which then returned Big. A navigator that
3931// asks "how far to the wall" while standing on the wall and is told "never" tunnels straight
3932// through the geometry. ROOT's own primitives answer 0 here, and so must this.
3933BOOST_AUTO_TEST_CASE(BoundaryPointsAgreeBetweenContainsAndDistances)
3934{
3935 const auto box = makeBoxSolid("boundaryPolicyBox", 1., 2., 3.);
3936 const std::array<double, 3> onFace{1., 0.5, 0.5}; // exactly on the +x face
3937 const std::array<double, 3> outward{1., 0., 0.};
3938 const std::array<double, 3> inward{-1., 0., 0.};
3939
3940 BOOST_CHECK(box->Contains(onFace.data())); // documented policy: on a face counts as inside
3941
3942 TGeoBBox reference("boundaryPolicyReference", 1., 2., 3.);
3943 BOOST_CHECK_EQUAL(box->DistFromInside(onFace.data(), outward.data(), 3), 0.);
3944 BOOST_CHECK_EQUAL(box->DistFromOutside(onFace.data(), inward.data(), 3), 0.);
3945 checkClose(box->DistFromInside(onFace.data(), outward.data(), 3),
3946 reference.DistFromInside(onFace.data(), outward.data(), 3));
3947 checkClose(box->DistFromOutside(onFace.data(), inward.data(), 3),
3948 reference.DistFromOutside(onFace.data(), inward.data(), 3));
3949
3950 // going the other way the far wall is still the answer, so the fix is not "always return 0"
3951 checkClose(box->DistFromInside(onFace.data(), inward.data(), 3), 2.);
3952
3953 // the BVH and loop paths must agree on all of it
3954 for (const auto& direction : {outward, inward}) {
3955 checkDistanceAgainstLoop(*box, onFace, direction);
3956 }
3957}
3958
3959// S4 / S5: a ray that only *touches* the boundary has not crossed it. Contains() knows this --
3960// near-equal hits are clustered and a cluster carrying both an entering and an exiting hit
3961// contributes even parity -- but the distance queries classified every hit on its own, so they
3962// reported the touch as a crossing. The two then disagree about the same ray: DistFromOutside
3963// hands the navigator a step to the touch point, Contains says it is still outside once it gets
3964// there, and the navigator takes zero-length steps forever.
3965//
3966// Both flavours are covered. A convex edge graze (box) is the outside-facing case, and the L-prism
3967// reflex edge is the inside-facing one, which no convex solid can produce: there the ray leaves
3968// and re-enters the material at a single point and must be reported as never having left.
3969BOOST_AUTO_TEST_CASE(EdgeGrazesAreNotCrossings)
3970{
3971 const double invSqrt2 = 1. / std::sqrt(2.);
3972
3973 // --- convex: touch the box edge x = +1, y = +2 and stay outside on both sides of the touch
3974 const auto box = makeBoxSolid("grazeBox", 1., 2., 3.);
3975 const std::array<double, 3> grazeDirection{invSqrt2, -invSqrt2, 0.};
3976 const std::array<double, 3> grazeOrigin{1. - 5. * invSqrt2, 2. + 5. * invSqrt2, 0.};
3977 BOOST_REQUIRE(!box->Contains(grazeOrigin.data()));
3978
3979 // a point just past the touch is still outside, so nothing was entered ...
3980 const std::array<double, 3> pastTouch{1. + 1.e-3 * invSqrt2, 2. - 1.e-3 * invSqrt2, 0.};
3981 BOOST_REQUIRE(!box->Contains(pastTouch.data()));
3982 // ... and the distance query must say so too
3983 BOOST_CHECK_EQUAL(box->DistFromOutside(grazeOrigin.data(), grazeDirection.data(), 3), TGeoShape::Big());
3984 BOOST_CHECK_EQUAL(box->DistFromOutside_Loop(grazeOrigin.data(), grazeDirection.data()), TGeoShape::Big());
3985
3986 // --- concave: the L-prism's reflex edge at x = 1, y = 1
3987 const auto prism = makeLPrismSolid("grazePrism");
3988 BOOST_REQUIRE(prism->IsNavigable());
3989 checkClose(prism->Capacity(), 4.); // 3x1 plus 1x1, extruded over unit height
3990
3991 // a ray through the reflex edge along (1,-1): inside before the touch, inside after it, so the
3992 // touch is not an exit. The real exit is where it leaves the long arm at y = 0.
3993 const std::array<double, 3> reflexDirection{invSqrt2, -invSqrt2, 0.};
3994 const std::array<double, 3> reflexOrigin{1. - 0.5 * invSqrt2, 1. + 0.5 * invSqrt2, 0.5};
3995 BOOST_REQUIRE(prism->Contains(reflexOrigin.data()));
3996 const std::array<double, 3> pastReflex{1. + 1.e-3 * invSqrt2, 1. - 1.e-3 * invSqrt2, 0.5};
3997 BOOST_REQUIRE(prism->Contains(pastReflex.data())); // still inside: the touch was not an exit
3998
3999 const double touchDistance = 0.5;
4000 const double exitDistance = 0.5 + std::sqrt(2.); // on to (2, 0, 0.5), in the middle of the y = 0 wall
4001 const double reported = prism->DistFromInside(reflexOrigin.data(), reflexDirection.data(), 3);
4002 BOOST_CHECK_GT(reported, touchDistance + 1.e-6); // the touch is not the answer ...
4003 checkClose(reported, exitDistance); // ... the far wall is
4004 BOOST_CHECK_EQUAL(prism->DistFromInside_Loop(reflexOrigin.data(), reflexDirection.data()), reported);
4005}
4006
4007// The direction-taking DescribeContainsCrossings dumps the crossing list behind ContainsAlongDirection.
4008BOOST_AUTO_TEST_CASE(DescribeContainsCrossingsTakesAnExplicitDirection)
4009{
4010 const auto box = makeBoxSolid("describeBox", 1., 2., 3.);
4011 const SurfaceSolid::Point3D inside{0.2, 0.3, 0.4};
4012 const double invSqrt2 = 1. / std::sqrt(2.);
4013 // +x leaves through x = +1 at 0.8; the unnormalised -z direction leaves through z = -3 at 3.4
4014 const std::vector<std::pair<SurfaceSolid::Point3D, double>> cases{
4015 {{1., 0., 0.}, 0.8}, {{invSqrt2, -invSqrt2, 0.}, 0.8 * std::sqrt(2.)}, {{0., 0., -2.}, 3.4}};
4016 for (const auto& [direction, exitDistance] : cases) {
4017 BOOST_TEST_CONTEXT("direction = (" << direction[0] << ", " << direction[1] << ", " << direction[2] << ")")
4018 {
4019 std::vector<SurfaceSolid::ContainsCrossing> bvhCrossings;
4020 std::vector<SurfaceSolid::ContainsCrossing> loopCrossings;
4021 box->DescribeContainsCrossings(inside, direction, bvhCrossings, loopCrossings);
4022 // from inside a convex box every ray leaves through exactly one face
4023 BOOST_REQUIRE_EQUAL(bvhCrossings.size(), 1u);
4024 BOOST_REQUIRE_EQUAL(loopCrossings.size(), 1u);
4025 BOOST_CHECK_EQUAL(bvhCrossings[0].distance, loopCrossings[0].distance);
4026 checkClose(bvhCrossings[0].distance, exitDistance);
4027 BOOST_CHECK_GT(bvhCrossings[0].normalAlignment, 0.); // an exit
4028 BOOST_CHECK(!bvhCrossings[0].onTrimBoundary);
4029 BOOST_CHECK(box->ContainsAlongDirection(inside.data(), direction.data()));
4030 }
4031 }
4032}
4033
4034// The concave fixture earns its keep beyond the single grazing ray: the whole sweep battery is
4035// run on it, since every invariant the convex fixtures pin (BVH == loop, direction-independent
4036// parity, Contains consistent with the distance answers) is weaker on shapes with no reflex edge.
4038{
4039 const auto prism = makeLPrismSolid("sweepPrism");
4040 BOOST_REQUIRE(prism->IsNavigable());
4041
4042 sweepDistanceAgainstLoop(*prism, 3.5, 5);
4043
4044 const auto directions = spiralDirections(13);
4045 for (const auto& point : probeGrid(3.5, 7)) {
4046 const bool inside = prism->Contains(point.data());
4047 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
4048 {
4049 BOOST_CHECK_EQUAL(prism->Contains_Loop(point.data()), inside);
4050 for (const auto& direction : directions) {
4051 BOOST_CHECK_EQUAL(prism->ContainsAlongDirection(point.data(), direction.data()), inside);
4052 }
4053 // the closed-form answer for the extruded L footprint
4054 const bool expected = point[2] > 0. && point[2] < 1. &&
4055 ((point[0] > 0. && point[0] < 3. && point[1] > 0. && point[1] < 1.) ||
4056 (point[0] > 0. && point[0] < 1. && point[1] >= 1. && point[1] < 2.));
4057 BOOST_CHECK_EQUAL(inside, expected);
4058 }
4059 }
4060}
4061
4062// K1: the B-spline endpoint shortcut assumed a clamped knot vector. A clamped curve interpolates
4063// its first and last pole, so returning those is exact and free; an unclamped one -- which is what
4064// OCC writes for a periodic tube-tube intersection curve before SetNotPeriodic -- starts and ends
4065// strictly inside its control polygon, and the shortcut then returned points that are not on the
4066// curve at all. Downstream, the wire's edges no longer meet (so it reads as Open and the whole
4067// face is thrown away) or the off-curve endpoint corrupts the winding classification, since
4068// CurveWire::classify deliberately uses canonical shared endpoints.
4069BOOST_AUTO_TEST_CASE(UnclampedBSplineEndpointsAreOnTheCurve)
4070{
4071 using surf::Curve2D;
4072 using surf::Vec2;
4073
4074 // the same cubic control polygon read twice: once with a clamped knot vector, once with a
4075 // uniform (unclamped) one. Only the clamped curve may claim its poles as endpoints.
4076 const std::vector<Vec2> poles{{0., 0.}, {1., 2.}, {3., 2.}, {4., 0.}};
4077 const Curve2D clamped = Curve2D::makeBSpline(3, poles, {}, {0., 0., 0., 0., 1., 1., 1., 1.});
4078 const Curve2D uniform = Curve2D::makeBSpline(3, poles, {}, {0., 1., 2., 3., 4., 5., 6., 7.});
4079
4080 BOOST_REQUIRE(clamped.valid());
4081 BOOST_REQUIRE(uniform.valid());
4082
4083 // the endpoints must lie on their own curve, whatever the knot vector says
4084 for (const auto* curve : {&clamped, &uniform}) {
4085 const Vec2 start = curve->startPoint();
4086 const Vec2 end = curve->endPoint();
4087 const Vec2 evaluatedStart = curve->pointAt(0.);
4088 const Vec2 evaluatedEnd = curve->pointAt(1.);
4089 checkClose(start.uCoord, evaluatedStart.uCoord);
4090 checkClose(start.vCoord, evaluatedStart.vCoord);
4091 checkClose(end.uCoord, evaluatedEnd.uCoord);
4092 checkClose(end.vCoord, evaluatedEnd.vCoord);
4093 }
4094
4095 // and the two curves really are different, so the test is not vacuous: the clamped one
4096 // interpolates its outer poles, the uniform one does not come near them
4097 checkClose(clamped.startPoint().uCoord, 0.);
4098 checkClose(clamped.endPoint().uCoord, 4.);
4099 BOOST_CHECK_GT(std::hypot(uniform.startPoint().uCoord - poles.front().uCoord,
4100 uniform.startPoint().vCoord - poles.front().vCoord),
4101 0.1);
4102
4103 // a wire closed on the *curve* must validate, which is what the shortcut used to prevent: with
4104 // poles.front() as the reported start, the joining line would have missed it by that distance
4105 const Vec2 uniformStart = uniform.startPoint();
4106 const Vec2 uniformEnd = uniform.endPoint();
4107 surf::CurveWire wire;
4108 surf::WireStatus status = surf::WireStatus::Valid;
4109 BOOST_CHECK(wire.initialize({uniform, Curve2D::makeLine(uniformEnd, uniformStart)}, surf::WireRole::Outer, status));
4110}
4111
4112// K2: the full-turn rejection measured the *control-point hull*, not the curve. A closed trim
4113// curve that wraps nearly a full turn in phi has poles outside its own span (that is what makes
4114// the hull a conservative bound), so the check saw more than 2*pi and refused a perfectly legal
4115// through-hole host face -- and a refused face is a face missing from the parity solid, i.e. wrong
4116// containment throughout its shadow.
4117BOOST_AUTO_TEST_CASE(NearFullTurnTrimIsNotRejectedOnItsPoleHull)
4118{
4119 using surf::Curve2D;
4120 using surf::Vec2;
4121 std::string error;
4122
4123 // A trim wrapping 350 degrees of a cylinder, spelled as two quadratic B-spline spans whose
4124 // middle poles sit *outside* the span in phi -- which is exactly what makes the control-point
4125 // hull a conservative bound and not the curve's own extent. The curve stays inside 2*pi; its
4126 // pole hull does not.
4127 const double sweep = 350. * surf::kPi / 180.;
4128 const double overshoot = 0.4;
4129 const std::vector<Curve2D> outer{
4130 Curve2D::makeBSpline(2, {{0., -1.}, {-overshoot, 0.}, {0.5 * sweep, 1.}}, {}, {0., 0., 0., 1., 1., 1.}),
4131 Curve2D::makeBSpline(2, {{0.5 * sweep, 1.}, {sweep + overshoot, 0.}, {sweep, -1.}}, {}, {0., 0., 0., 1., 1., 1.}),
4132 Curve2D::makeLine({sweep, -1.}, {0., -1.})};
4133
4134 // the pole hull must genuinely exceed a full turn, otherwise the fixture proves nothing
4135 Vec2 hullLower{1.e300, 1.e300};
4136 Vec2 hullUpper{-1.e300, -1.e300};
4137 surf::CurveWire hullWire;
4138 surf::WireStatus hullStatus = surf::WireStatus::Valid;
4139 BOOST_REQUIRE(hullWire.initialize(outer, surf::WireRole::Outer, hullStatus));
4140 hullWire.parametricBounds(hullLower, hullUpper);
4141 BOOST_REQUIRE_GT(hullUpper.uCoord - hullLower.uCoord, surf::kTwoPi);
4142
4143 // ... while the curve itself does not
4144 Vec2 tightLower{1.e300, 1.e300};
4145 Vec2 tightUpper{-1.e300, -1.e300};
4146 hullWire.tightParametricBounds(tightLower, tightUpper);
4147 BOOST_CHECK_LT(tightUpper.uCoord - tightLower.uCoord, surf::kTwoPi);
4148
4149 // so the surface must be accepted
4151 BOOST_CHECK_MESSAGE(surface.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -2., 2., 0., surf::kTwoPi,
4152 false, outer, {}, error),
4153 "near-full-turn trim rejected: " << error);
4154
4155 // a trim that really does wrap more than a full turn is still refused
4157 const std::vector<Curve2D> overWrapped{Curve2D::makeLine({0., -1.}, {surf::kTwoPi + 0.5, -1.}),
4158 Curve2D::makeLine({surf::kTwoPi + 0.5, -1.}, {surf::kTwoPi + 0.5, 1.}),
4159 Curve2D::makeLine({surf::kTwoPi + 0.5, 1.}, {0., 1.}),
4160 Curve2D::makeLine({0., 1.}, {0., -1.})};
4161 BOOST_CHECK(!tooWide.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -2., 2., 0., surf::kTwoPi, false,
4162 overWrapped, {}, error));
4163}
4164
4165// K7 claimed a face that fails to build is logged and silently omitted from the parity solid.
4166// Reading the code does not support that on any production path: Add*Surface returns false, and
4167// the sidecar loader turns that into a whole-file rejection, which the converter's generated macro
4168// turns into an exception. What is true is the weaker statement that the *return value* is the
4169// only signal, so this pins both halves -- the rejection is reported, and nothing is added behind
4170// the caller's back. Recorded rather than "fixed", in the same spirit as the S6 correction.
4171BOOST_AUTO_TEST_CASE(RejectedFacesAreNeverSilentlyAdded)
4172{
4173 SurfaceSolid solid("rejectingSolid");
4174 BOOST_REQUIRE(addBoxFace(solid, 0, 1., 2., 3.));
4175 BOOST_REQUIRE_EQUAL(solid.GetNsurfaces(), 1);
4176
4177 // degenerate frame (axisU parallel to axisV), a wire with too few vertices, and a zero-radius
4178 // cylinder: each must be refused, and none may leave a surface behind
4179 BOOST_CHECK(!solid.AddPlanarSurface({0., 0., 0.}, {1., 0., 0.}, {1., 0., 0.}, rectangleWire(1., 1.)));
4180 BOOST_CHECK(!solid.AddPlanarSurface({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.}, {{0., 0.}, {1., 0.}}));
4181 BOOST_CHECK(!solid.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 0., -1., 1.));
4182 BOOST_CHECK_EQUAL(solid.GetNsurfaces(), 1);
4183 BOOST_CHECK_EQUAL(static_cast<int>(solid.GetSurfaceRecords().size()), 1);
4184
4185 // the loader's contract on the same failure: reject the file rather than return a partial solid
4186 std::vector<char> bytes;
4187 appendSidecarHeader(bytes, 1);
4188 appendU32(bytes, 2); // cylinder
4189 appendU32(bytes, 0); // flags
4190 appendU32(bytes, 14); // nParams
4191 appendDoubles(bytes, {0., 0., 0., 0., 0., 1., 1., 0., 0., 0. /* radius */, -1., 1., 0., 2. * surf::kPi});
4192 appendU32(bytes, 0); // nWires
4193 const auto path = writeSidecarFile("o2_sidecar_rejected_face.bin", bytes);
4194 SurfaceSolid loaded("loadedRejecting");
4195 BOOST_CHECK(!o2::cad::LoadSurfaceSolid(path.string(), loaded));
4196 std::filesystem::remove(path);
4197}
4198
4199// The rim-based closure measurement.
4200//
4201// The half-edge check asks whether two faces emitted the *same vertices* along a shared edge. On
4202// real CAD that question has the answer "no" for reasons that are not gaps: each face samples the
4203// shared curve independently, so the vertices genuinely are not the same points and no tolerance
4204// on vertex equality can help. The rim measurement compares the boundaries as curves instead, and
4205// reports the answer as a length in cm rather than as a chord count.
4206//
4207// Nothing derives a verdict from it yet -- IsNavigable() still reads the chord counters -- so
4208// these tests pin the measurement, not a change of behaviour.
4209BOOST_AUTO_TEST_CASE(RimClosureMeasuresTheGapInCentimetres)
4210{
4211 constexpr double halfX = 1.;
4212 constexpr double halfY = 1.5;
4213 constexpr double halfZ = 2.;
4214
4215 // a closed box: one rim per face, all matched, and no gap at all
4216 SurfaceSolid closedBox("rimClosedBox");
4217 addBoxSurfaces(closedBox, halfX, halfY, halfZ);
4218 closedBox.CloseShape(false);
4219 BOOST_REQUIRE(closedBox.IsNavigable());
4220 BOOST_CHECK_EQUAL(closedBox.GetRimCount(), 6);
4221 BOOST_CHECK_EQUAL(closedBox.GetMatchedRimCount(), 6);
4222 BOOST_CHECK_EQUAL(closedBox.GetBoundaryRimCount(), 0);
4223 BOOST_CHECK_SMALL(closedBox.GetMaxRimIsolation(), 1.e-12);
4224 BOOST_CHECK_SMALL(closedBox.GetUnmatchedRimLength(), 1.e-12);
4225 // a box has no curved rim, so its polylines are exact and the measurement has no noise floor
4226 BOOST_CHECK_SMALL(closedBox.GetRimChordResolution(), 1.e-12);
4227 // the summed perimeter of the six faces, which is what "how much boundary" is measured in
4228 BOOST_CHECK_CLOSE(closedBox.GetTotalRimLength(), 16. * (halfX + halfY + halfZ), 1.e-9);
4229
4230 // the same box with the +z face lifted by a known delta. A box's rims are straight, so their
4231 // sampling resolution is zero and the match band is the declared tolerance alone; the lifted
4232 // face's rim is then alone by exactly delta, and that is what the isolation reports.
4233 constexpr double delta = 1.e-3;
4234 SurfaceSolid shiftedBox("rimShiftedBox");
4235 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4236 const Point3D center = faceIndex == 4 ? Point3D{0., 0., delta} : Point3D{0., 0., 0.};
4237 BOOST_REQUIRE(addBoxFace(shiftedBox, faceIndex, halfX, halfY, halfZ, false, center));
4238 }
4239 shiftedBox.CloseShape(false);
4240 BOOST_CHECK_CLOSE(shiftedBox.GetMaxRimIsolation(), delta, 1.e-6);
4241 // and the shift is far above the sampling noise floor, so the number means what it says
4242 BOOST_CHECK(shiftedBox.GetMaxRimIsolation() > shiftedBox.GetRimChordResolution());
4243}
4244
4245// The structural failure the rim criterion exists to fix, pinned: two faces that sample one
4246// shared edge at different chord counts emit different vertices, so vertex matching calls a
4247// perfectly closed box open -- and open by *chords*, which is how a seven-loop solid came to
4248// report 1418 boundary edges. Rim matching compares the curves and gets it right, and it is the
4249// rim answer that IsClosed()/IsNavigable() now report.
4250BOOST_AUTO_TEST_CASE(RimClosureSurvivesUnequalChordCounts)
4251{
4252 constexpr double halfX = 1.;
4253 constexpr double halfY = 1.5;
4254 constexpr double halfZ = 2.;
4255
4256 SurfaceSolid resampled("rimResampledBox");
4257 for (int faceIndex = 0; faceIndex < 5; ++faceIndex) {
4258 BOOST_REQUIRE(addBoxFace(resampled, faceIndex, halfX, halfY, halfZ));
4259 }
4260 // the last face again, but with every edge split in two: the same rectangle, twice the vertices
4261 const FaceFrame frame = boxFaceFrame(5, halfX, halfY, halfZ);
4262 const double extentU = frame.extentU;
4263 const double extentV = frame.extentV;
4264 BOOST_REQUIRE(resampled.AddPlanarSurface(frame.origin, frame.axisU, frame.axisV,
4265 {{0., 0.},
4266 {0.5 * extentU, 0.},
4267 {extentU, 0.},
4268 {extentU, 0.5 * extentV},
4269 {extentU, extentV},
4270 {0.5 * extentU, extentV},
4271 {0., extentV},
4272 {0., 0.5 * extentV}}));
4273 resampled.CloseShape(false);
4274
4275 // the per-chord counters still see the disagreement -- they compare the vertices the two faces
4276 // emitted, and those really are different points. That is the defect, and it is why they no
4277 // longer decide anything.
4278 BOOST_CHECK(resampled.GetBoundaryEdgeCount() > 0);
4279
4280 // the verdict comes from the rims, which see one boundary curve per face, all matched, no gap
4281 BOOST_CHECK(resampled.IsClosed());
4282 BOOST_CHECK(resampled.IsNavigable());
4283 BOOST_CHECK_EQUAL(resampled.GetRimCount(), 6);
4284 BOOST_CHECK_EQUAL(resampled.GetMatchedRimCount(), 6);
4285 BOOST_CHECK_EQUAL(resampled.GetBoundaryRimCount(), 0);
4286 BOOST_CHECK_SMALL(resampled.GetMaxRimIsolation(), 1.e-12);
4287 BOOST_CHECK_SMALL(resampled.GetUnmatchedRimLength(), 1.e-12);
4288}
4289
4290// The per-rim records name which loop is open. A rim's state is on the same scale the solid reports, so
4291// the solid's verdict must be exactly the worst state present -- which is a self-check, not a
4292// restatement: the two are accumulated independently.
4293BOOST_AUTO_TEST_CASE(RimReportsNameTheOffendingLoop)
4294{
4295 constexpr double halfX = 1.;
4296 constexpr double halfY = 1.5;
4297 constexpr double halfZ = 2.;
4298 constexpr double delta = 1.e-3;
4299
4300 SurfaceSolid shiftedBox("rimReportBox");
4301 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4302 const Point3D center = faceIndex == 4 ? Point3D{0., 0., delta} : Point3D{0., 0., 0.};
4303 BOOST_REQUIRE(addBoxFace(shiftedBox, faceIndex, halfX, halfY, halfZ, false, center));
4304 }
4305 shiftedBox.CloseShape(false);
4306
4307 const auto& rims = shiftedBox.GetRimReports();
4308 BOOST_REQUIRE_EQUAL(static_cast<int>(rims.size()), shiftedBox.GetRimCount());
4309
4310 int boundaryRims = 0;
4311 auto worst = SurfaceSolid::NavigationReliability::Reliable;
4312 double openLength = 0.;
4313 for (const auto& rim : rims) {
4314 BOOST_CHECK(rim.surface >= 0 && rim.surface < shiftedBox.GetNsurfaces());
4315 BOOST_CHECK(rim.rimOnSurface >= 0);
4316 BOOST_CHECK(rim.closed); // a box face's boundary is one closed loop
4317 BOOST_CHECK(rim.length > 0.);
4318 openLength += rim.unmatchedLength;
4319 if (rim.state == SurfaceSolid::NavigationReliability::OpenSurfaceSet) {
4320 ++boundaryRims;
4321 BOOST_CHECK(rim.unmatchedChords > 0);
4322 BOOST_CHECK(rim.unmatchedLength > 0.);
4323 } else {
4324 BOOST_CHECK_EQUAL(rim.unmatchedChords, 0);
4325 }
4326 worst = std::max(worst, rim.state);
4327 }
4328 BOOST_CHECK_EQUAL(boundaryRims, shiftedBox.GetBoundaryRimCount());
4329 BOOST_CHECK(worst == shiftedBox.GetNavigationReliability());
4330 BOOST_CHECK_CLOSE(openLength, shiftedBox.GetUnmatchedRimLength(), 1.e-9);
4331
4332 // the lifted face's own rim is the one that is alone, and it is alone by the lift
4333 const auto lifted = std::find_if(rims.begin(), rims.end(), [](const auto& rim) { return rim.surface == 4; });
4334 BOOST_REQUIRE(lifted != rims.end());
4335 BOOST_CHECK(lifted->state == SurfaceSolid::NavigationReliability::OpenSurfaceSet);
4336 BOOST_CHECK_CLOSE(lifted->maxIsolation, delta, 1.e-6);
4337 BOOST_CHECK(lifted->maxIsolationFace >= 0 && lifted->maxIsolationFace != 4);
4338 // and the worst chord is named where it is: on the +z face, which sits at halfZ + delta
4339 BOOST_CHECK_CLOSE(lifted->maxIsolationPoint[2], halfZ + delta, 1.e-6);
4340}
4341
4342// Rims are counted per boundary loop and measured in centimetres, not counted per chord. A bare
4343// cylinder wall is the clearest case: two circular rims, sampled at kArcSamples chords each.
4344BOOST_AUTO_TEST_CASE(RimCountsAreLoopsAndLengthsNotChords)
4345{
4346 constexpr double radius = 1.;
4347 constexpr double halfHeight = 2.;
4348 SurfaceSolid openTube("rimOpenTube");
4349 BOOST_REQUIRE(
4350 openTube.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -halfHeight, halfHeight));
4351 openTube.CloseShape(false);
4352
4353 // the chord counter reports every sample of both rims
4354 BOOST_CHECK(openTube.GetBoundaryEdgeCount() >= 2 * surf::kArcSamples);
4355 // the rim measurement reports two open loops, and how long they are
4356 BOOST_CHECK_EQUAL(openTube.GetRimCount(), 2);
4357 BOOST_CHECK_EQUAL(openTube.GetBoundaryRimCount(), 2);
4358 BOOST_CHECK_EQUAL(openTube.GetMatchedRimCount(), 0);
4359 // both rims are open, and the length is the sampled circumference (a chord polygon, so slightly
4360 // under 2*pi*r); it is the *length* that is reported, not the sample count
4361 BOOST_CHECK_CLOSE(openTube.GetUnmatchedRimLength(), openTube.GetTotalRimLength(), 1.e-9);
4362 BOOST_CHECK_CLOSE(openTube.GetTotalRimLength(), 2. * surf::kTwoPi * radius, 1.);
4363
4364 // the sagitta of a circle of this radius sampled at kArcSamples per turn, from the closed form:
4365 // r (1 - cos(pi/kArcSamples)). The estimator must recover it, because it is the floor below
4366 // which a rim gap is how the rims were sampled rather than how far apart the faces are.
4367 const double exactSagitta = radius * (1. - std::cos(surf::kPi / surf::kArcSamples));
4368 BOOST_CHECK_CLOSE(openTube.GetRimChordResolution(), exactSagitta, 1.);
4369
4370 // Two rims and no third: a full-turn patch emits its seam twice, once each way, and that pair
4371 // bounds nothing -- it cancels in the half-edge check for the same reason. Chained naively it
4372 // would become a two-point rim straddling the patch, reporting a gap the size of the patch.
4373}
4374
4375// The matching tolerance is the model's own declared one when the sidecar states it (version 2),
4376// and a documented constant when it does not. Before this the kernel had no way to know what
4377// epsilon two faces of an imported solid should agree to, and guessed.
4378BOOST_AUTO_TEST_CASE(RimMatchToleranceComesFromTheModel)
4379{
4380 SurfaceSolid unstated("rimToleranceUnstated");
4381 addBoxSurfaces(unstated, 1., 1., 1.);
4382 unstated.CloseShape(false);
4383 BOOST_CHECK_EQUAL(unstated.GetModelTolerance(), 0.);
4384 BOOST_CHECK_CLOSE(unstated.GetRimMatchTolerance(), surf::kRimMatchTolerance, 1.e-9);
4385
4386 SurfaceSolid stated("rimToleranceStated");
4387 addBoxSurfaces(stated, 1., 1., 1.);
4388 stated.SetModelTolerance(2.5e-7);
4389 stated.CloseShape(false);
4390 BOOST_CHECK_CLOSE(stated.GetRimMatchTolerance(), 2.5e-7, 1.e-9);
4391}
4392
4393// --- Closed-loop quadrature and unclamped B-spline endpoints ---
4394
4399BOOST_AUTO_TEST_CASE(ClosedCurveReportsItsTravelNotItsEndpointGap)
4400{
4401 // a full circle in the (u, v) chart, written as an arc: the endpoints coincide exactly
4402 const surf::Curve2D circle = surf::Curve2D::makeArc({0., 0.}, 2., 0., 2. * surf::kPi);
4403 BOOST_CHECK_SMALL(std::abs(circle.endPoint().uCoord - circle.startPoint().uCoord), 1.e-12);
4404 // u = 2 cos(angle) travels from +2 down to -2 and back: total variation 8, not 0
4405 BOOST_CHECK_CLOSE(circle.uVariation(0., 1.), 8., 1.e-9);
4406
4407 // and the same for a closed B-spline, whose poles bound the travel from above
4408 std::vector<surf::Vec2> poles{{0., 0.}, {1., 1.}, {2., 0.}, {1., -1.}, {0., 0.}};
4409 std::vector<double> knots{0., 0., 0., 0.25, 0.5, 0.75, 1., 1., 1.};
4410 const surf::Curve2D loop = surf::Curve2D::makeBSpline(2, poles, {}, knots);
4411 BOOST_CHECK_SMALL(std::abs(loop.endPoint().uCoord - loop.startPoint().uCoord), 1.e-9);
4412 BOOST_CHECK_GT(loop.uVariation(0., 1.), 0.5);
4413}
4414
4427BOOST_AUTO_TEST_CASE(HoleInWireTrimIntegratesToTheAnalyticCapacity)
4428{
4429 const double radius = 1.2;
4430 const double height = 1.5;
4431 const double holeRadius = 0.3;
4432 const double centreU = surf::kPi;
4433 const double centreV = 0.5 * height;
4434
4435 std::vector<o2::cad::O2BVHSurfaceSolid::PlanarBoundaryCurve> outer{
4440
4441 // the textbook exact NURBS circle: nine poles on the circumscribed square's corners and edge
4442 // midpoints, corner weights sqrt(2)/2, four double interior knots
4443 const double corner = std::sqrt(2.) / 2.;
4444 const std::array<std::array<double, 2>, 9> unit{{{1., 0.}, {1., 1.}, {0., 1.}, {-1., 1.}, {-1., 0.}, {-1., -1.}, {0., -1.}, {1., -1.}, {1., 0.}}};
4445 std::vector<o2::cad::O2BVHSurfaceSolid::Point2D> poles;
4446 for (const auto& pole : unit) {
4447 poles.push_back({centreU + holeRadius * pole[0], centreV + holeRadius * pole[1]});
4448 }
4449 const std::vector<double> weights{1., corner, 1., corner, 1., corner, 1., corner, 1.};
4450 const std::vector<double> knots{0., 0., 0., 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1., 1., 1.};
4451 std::vector<std::vector<o2::cad::O2BVHSurfaceSolid::PlanarBoundaryCurve>> holes{
4453
4454 o2::cad::O2BVHSurfaceSolid withHole("withHole");
4455 BOOST_REQUIRE(withHole.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, 0., height, 0.,
4456 2. * surf::kPi, false, outer, holes));
4457 std::vector<double> contributions;
4458 withHole.GetSurfaceCapacityContributions(contributions);
4459 BOOST_REQUIRE_EQUAL(contributions.size(), 1u);
4460
4461 // f = (r/3)(C.U cos phi + C.V sin phi + r) with the axis through the origin reduces to r^2/3, so
4462 // the contribution is r^2/3 times the trimmed chart area: 2 pi h minus the hole's pi rho^2.
4463 const double chartArea = 2. * surf::kPi * height - surf::kPi * holeRadius * holeRadius;
4464 BOOST_CHECK_CLOSE(contributions[0], radius * radius * chartArea / 3., 1.e-6);
4465}
4466
4470BOOST_AUTO_TEST_CASE(UnclampedBSplineEndpointsAreEvaluatedNotReadOffThePoles)
4471{
4472 // uniform (unclamped) knots: the curve starts well inside the pole polygon
4473 std::vector<surf::Vec2> poles{{0., 0.}, {1., 2.}, {2., 2.}, {3., 0.}};
4474 std::vector<double> knots{0., 1., 2., 3., 4., 5., 6., 7.};
4475 const surf::Curve2D unclamped = surf::Curve2D::makeBSpline(3, poles, {}, knots);
4476 const surf::Vec2 start = unclamped.startPoint();
4477 const surf::Vec2 end = unclamped.endPoint();
4478 // the whole point: neither endpoint is a pole
4479 BOOST_CHECK_GT(std::abs(start.uCoord - poles.front().uCoord) + std::abs(start.vCoord - poles.front().vCoord), 1.e-3);
4480 BOOST_CHECK_GT(std::abs(end.uCoord - poles.back().uCoord) + std::abs(end.vCoord - poles.back().vCoord), 1.e-3);
4481 // and they are on the curve
4482 BOOST_CHECK_SMALL(std::abs(unclamped.pointAt(0.).uCoord - start.uCoord), 1.e-12);
4483 BOOST_CHECK_SMALL(std::abs(unclamped.pointAt(1.).vCoord - end.vCoord), 1.e-12);
4484}
4485
4486// --------------------------------------------------------------------------------------------
4487// Sidecar v3 edge identity.
4488// --------------------------------------------------------------------------------------------
4489
4490namespace
4491{
4502std::vector<std::pair<std::vector<unsigned int>, std::vector<unsigned char>>>
4503 boxEdgeIdentity(double halfX, double halfY, double halfZ)
4504{
4505 using Key = std::array<long long, 6>;
4506 auto quantize = [](double value) { return static_cast<long long>(std::llround(value * 1.e9)); };
4507 std::map<Key, unsigned int> edgeIds;
4508 std::vector<std::pair<std::vector<unsigned int>, std::vector<unsigned char>>> perFace(6);
4509
4510 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4511 const FaceFrame frame = boxFaceFrame(faceIndex, halfX, halfY, halfZ);
4512 const auto corners = rectangleWire(frame.extentU, frame.extentV);
4513 for (size_t segment = 0; segment < corners.size(); ++segment) {
4514 const auto& startUV = corners[segment];
4515 const auto& endUV = corners[(segment + 1) % corners.size()];
4516 auto toGlobal = [&](const Point2D& uv) {
4517 return Point3D{frame.origin[0] + frame.axisU[0] * uv[0] + frame.axisV[0] * uv[1],
4518 frame.origin[1] + frame.axisU[1] * uv[0] + frame.axisV[1] * uv[1],
4519 frame.origin[2] + frame.axisU[2] * uv[0] + frame.axisV[2] * uv[1]};
4520 };
4521 const Point3D start = toGlobal(startUV);
4522 const Point3D end = toGlobal(endUV);
4523 const Key forwardKey{quantize(start[0]), quantize(start[1]), quantize(start[2]),
4524 quantize(end[0]), quantize(end[1]), quantize(end[2])};
4525 const Key backwardKey{forwardKey[3], forwardKey[4], forwardKey[5],
4526 forwardKey[0], forwardKey[1], forwardKey[2]};
4527 // the lexicographically smaller ordering names the edge; running the other way is "reversed"
4528 const bool reversed = backwardKey < forwardKey;
4529 const Key canonical = reversed ? backwardKey : forwardKey;
4530 const auto inserted = edgeIds.emplace(canonical, static_cast<unsigned int>(edgeIds.size()));
4531 perFace[faceIndex].first.push_back(inserted.first->second);
4532 perFace[faceIndex].second.push_back(
4533 static_cast<unsigned char>(SurfaceSolid::kEdgeAnchored | (reversed ? SurfaceSolid::kEdgeReversed : 0u)));
4534 }
4535 }
4536 BOOST_REQUIRE_EQUAL(edgeIds.size(), 12u); // a box has twelve edges; if it did not, nothing below means anything
4537 return perFace;
4538}
4539
4541std::unique_ptr<SurfaceSolid> makeIdentifiedBox(const char* name, double halfX, double halfY, double halfZ)
4542{
4543 auto solid = std::make_unique<SurfaceSolid>(name);
4544 addBoxSurfaces(*solid, halfX, halfY, halfZ);
4545 const auto identity = boxEdgeIdentity(halfX, halfY, halfZ);
4546 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4547 BOOST_REQUIRE(solid->SetSurfaceBoundaryEdges(faceIndex, identity[faceIndex].first, identity[faceIndex].second));
4548 }
4549 return solid;
4550}
4551} // namespace
4552
4556BOOST_AUTO_TEST_CASE(EdgeIdentityDecidesClosureByCounting)
4557{
4558 const auto box = makeIdentifiedBox("identityBox", 1., 2., 3.);
4559 box->CloseShape(false);
4560
4561 BOOST_CHECK(box->HasEdgeIdentity());
4562 BOOST_CHECK_EQUAL(box->GetSourceEdgeCount(), 12);
4563 BOOST_CHECK_EQUAL(box->GetSharedSourceEdgeCount(), 12);
4564 BOOST_CHECK_EQUAL(box->GetBoundarySourceEdgeCount(), 0);
4565 BOOST_CHECK_EQUAL(box->GetNonManifoldSourceEdgeCount(), 0);
4566 BOOST_CHECK_EQUAL(box->GetReversedSourceEdgeCount(), 0);
4567 BOOST_CHECK_EQUAL(box->GetDegenerateSourceEdgeCount(), 0);
4568 BOOST_CHECK(box->IsClosed());
4569 BOOST_CHECK(box->IsOrientationConsistent());
4570 BOOST_CHECK(box->IsNavigable());
4571
4572 // the deviation is a measurement, and on a box built from one set of corners it is exactly zero
4573 BOOST_CHECK_EQUAL(box->GetMeasuredSharedEdgeCount(), 12);
4574 BOOST_CHECK_EQUAL(box->GetUnmeasuredSharedEdgeCount(), 0);
4575 BOOST_CHECK_SMALL(box->GetMaxSharedEdgeDeviation(), 1.e-15);
4576}
4577
4581BOOST_AUTO_TEST_CASE(EdgeIdentityFindsTheMissingFace)
4582{
4583 SurfaceSolid openBox("identityOpenBox");
4584 for (int faceIndex = 0; faceIndex < 5; ++faceIndex) {
4585 BOOST_REQUIRE(addBoxFace(openBox, faceIndex, 1., 2., 3.));
4586 }
4587 const auto identity = boxEdgeIdentity(1., 2., 3.);
4588 for (int faceIndex = 0; faceIndex < 5; ++faceIndex) {
4589 BOOST_REQUIRE(openBox.SetSurfaceBoundaryEdges(faceIndex, identity[faceIndex].first, identity[faceIndex].second));
4590 }
4591 openBox.CloseShape(false);
4592
4593 BOOST_CHECK(openBox.HasEdgeIdentity());
4594 BOOST_CHECK_EQUAL(openBox.GetSourceEdgeCount(), 12);
4595 BOOST_CHECK_EQUAL(openBox.GetSharedSourceEdgeCount(), 8);
4596 BOOST_CHECK_EQUAL(openBox.GetBoundarySourceEdgeCount(), 4); // the missing face's own four edges
4597 BOOST_CHECK(!openBox.IsClosed());
4598 BOOST_CHECK(!openBox.IsNavigable());
4599 BOOST_CHECK_EQUAL(static_cast<int>(openBox.GetNavigationReliability()),
4600 static_cast<int>(SurfaceSolid::NavigationReliability::OpenSurfaceSet));
4601}
4602
4605BOOST_AUTO_TEST_CASE(EdgeIdentityFindsReversedAndDuplicatedFaces)
4606{
4607 {
4608 const auto box = makeIdentifiedBox("identityReversed", 1., 2., 3.);
4609 // flip one face's senses: its four edges now read "twice, same way" instead of "twice, opposite"
4610 auto identity = boxEdgeIdentity(1., 2., 3.);
4611 for (auto& flag : identity[0].second) {
4612 flag = static_cast<unsigned char>(flag ^ SurfaceSolid::kEdgeReversed);
4613 }
4614 BOOST_REQUIRE(box->SetSurfaceBoundaryEdges(0, identity[0].first, identity[0].second));
4615 box->CloseShape(false);
4616 BOOST_CHECK_EQUAL(box->GetReversedSourceEdgeCount(), 4);
4617 BOOST_CHECK(box->IsClosed()); // still every edge twice: closed, but inconsistently oriented
4618 BOOST_CHECK(!box->IsOrientationConsistent());
4619 BOOST_CHECK_EQUAL(static_cast<int>(box->GetNavigationReliability()),
4620 static_cast<int>(SurfaceSolid::NavigationReliability::ReversedFaces));
4621 }
4622 {
4623 // a seventh face claiming edges that already have two owners is non-manifold by count
4624 const auto box = makeIdentifiedBox("identityNonManifold", 1., 2., 3.);
4625 BOOST_REQUIRE(addBoxFace(*box, 0, 1., 2., 3.));
4626 const auto identity = boxEdgeIdentity(1., 2., 3.);
4627 BOOST_REQUIRE(box->SetSurfaceBoundaryEdges(6, identity[0].first, identity[0].second));
4628 box->CloseShape(false);
4629 BOOST_CHECK_EQUAL(box->GetNonManifoldSourceEdgeCount(), 4);
4630 BOOST_CHECK(!box->IsClosed());
4631 BOOST_CHECK_EQUAL(static_cast<int>(box->GetNavigationReliability()),
4632 static_cast<int>(SurfaceSolid::NavigationReliability::NonManifold));
4633 }
4634}
4635
4641BOOST_AUTO_TEST_CASE(SharedEdgeDeviationMeasuresHowFarApartTheFacesAre)
4642{
4643 constexpr double delta = 3.e-4;
4644 SurfaceSolid shifted("identityShifted");
4645 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4646 // face 4 is the +z cap; nudge it along +z, which pulls its whole rim off the four side faces
4647 const Point3D centre = faceIndex == 4 ? Point3D{0., 0., delta} : Point3D{0., 0., 0.};
4648 BOOST_REQUIRE(addBoxFace(shifted, faceIndex, 1., 2., 3., false, centre));
4649 }
4650 const auto identity = boxEdgeIdentity(1., 2., 3.);
4651 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4652 BOOST_REQUIRE(shifted.SetSurfaceBoundaryEdges(faceIndex, identity[faceIndex].first, identity[faceIndex].second));
4653 }
4654 shifted.CloseShape(false);
4655
4656 BOOST_CHECK(shifted.HasEdgeIdentity());
4657 BOOST_CHECK_EQUAL(shifted.GetSharedSourceEdgeCount(), 12);
4658 BOOST_CHECK(shifted.IsClosed()); // by identity: the same twelve edges, each used twice
4659 BOOST_CHECK_EQUAL(shifted.GetMeasuredSharedEdgeCount(), 12);
4660 checkClose(shifted.GetMaxSharedEdgeDeviation(), delta, 1.e-12);
4661}
4662
4669BOOST_AUTO_TEST_CASE(TrimCurveIdentitySurvivesWireReorientation)
4670{
4671 SurfaceSolid flipped("identityFlippedWinding");
4672 const auto identity = boxEdgeIdentity(1., 2., 3.);
4673 std::vector<std::vector<unsigned int>> ids(6);
4674 std::vector<std::vector<unsigned char>> flags(6);
4675 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4676 FaceFrame frame = boxFaceFrame(faceIndex, 1., 2., 3.);
4677 auto corners = rectangleWire(frame.extentU, frame.extentV);
4678 // reverse the vertex ring: segment j of the new ring is segment (n-2-j) of the old one, run
4679 // backwards, and initialize() will reverse it again to restore the winding it wants
4680 std::reverse(corners.begin(), corners.end());
4681 BOOST_REQUIRE(flipped.AddPlanarSurface(frame.origin, frame.axisU, frame.axisV, corners));
4682 const size_t n = corners.size();
4683 ids[faceIndex].resize(n);
4684 flags[faceIndex].resize(n);
4685 for (size_t j = 0; j < n; ++j) {
4686 const size_t source = (n - 2 - j + n) % n;
4687 ids[faceIndex][j] = identity[faceIndex].first[source];
4688 flags[faceIndex][j] = identity[faceIndex].second[source];
4689 }
4690 BOOST_REQUIRE(flipped.SetSurfaceBoundaryEdges(faceIndex, ids[faceIndex], flags[faceIndex]));
4691 }
4692 flipped.CloseShape(false);
4693
4694 BOOST_CHECK(flipped.HasEdgeIdentity());
4695 BOOST_CHECK_EQUAL(flipped.GetSharedSourceEdgeCount(), 12);
4696 BOOST_CHECK_EQUAL(flipped.GetMeasuredSharedEdgeCount(), 12);
4697 // the load-bearing assertion: had the reversal gone unrecorded, this would be a box edge long
4698 BOOST_CHECK_SMALL(flipped.GetMaxSharedEdgeDeviation(), 1.e-12);
4699}
4700
4704BOOST_AUTO_TEST_CASE(PartialEdgeIdentityFallsBackToTheRimMeasurement)
4705{
4706 SurfaceSolid partial("identityPartial");
4707 addBoxSurfaces(partial, 1., 2., 3.);
4708 const auto identity = boxEdgeIdentity(1., 2., 3.);
4709 for (int faceIndex = 0; faceIndex < 5; ++faceIndex) { // one face left silent
4710 BOOST_REQUIRE(partial.SetSurfaceBoundaryEdges(faceIndex, identity[faceIndex].first, identity[faceIndex].second));
4711 }
4712 partial.CloseShape(false);
4713 BOOST_CHECK(!partial.HasEdgeIdentity());
4714 BOOST_CHECK_EQUAL(partial.GetSourceEdgeCount(), 0);
4715 BOOST_CHECK(partial.IsNavigable()); // the geometric verdict, unchanged: the box really is closed
4716
4717 // and an out-of-range index or mismatched arrays are refused rather than half-applied
4718 BOOST_CHECK(!partial.SetSurfaceBoundaryEdges(6, identity[0].first, identity[0].second));
4719 BOOST_CHECK(!partial.SetSurfaceBoundaryEdges(0, {1u, 2u}, {0}));
4720}
4721
4724BOOST_AUTO_TEST_CASE(EdgeIdentitySurvivesPersistence)
4725{
4726 const auto box = makeIdentifiedBox("identityPersist", 1., 2., 3.);
4727 box->CloseShape(false);
4728 BOOST_REQUIRE(box->HasEdgeIdentity());
4729
4730 const auto restored = writeAndReadBack(*box);
4731 BOOST_REQUIRE(restored != nullptr);
4732 BOOST_CHECK(restored->HasEdgeIdentity());
4733 BOOST_CHECK_EQUAL(restored->GetSourceEdgeCount(), box->GetSourceEdgeCount());
4734 BOOST_CHECK_EQUAL(restored->GetSharedSourceEdgeCount(), box->GetSharedSourceEdgeCount());
4735 BOOST_CHECK_EQUAL(restored->GetMeasuredSharedEdgeCount(), box->GetMeasuredSharedEdgeCount());
4736 checkClose(restored->GetMaxSharedEdgeDeviation(), box->GetMaxSharedEdgeDeviation(), 1.e-18);
4737 checkSolidsIdentical(*box, *restored, 4.5, 5);
4738}
4739
4743BOOST_AUTO_TEST_CASE(SidecarV3EdgeIdentityRoundTrip)
4744{
4745 constexpr double halfX = 1.;
4746 constexpr double halfY = 2.;
4747 constexpr double halfZ = 3.;
4748 const auto identity = boxEdgeIdentity(halfX, halfY, halfZ);
4749
4750 const auto boxBytes = [&](uint32_t version, bool writeIdentity) {
4751 std::vector<char> bytes;
4752 appendSidecarHeader(bytes, 6, version, 1.e-7, writeIdentity ? 12u : 0u);
4753 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4754 appendPlaneRecord(bytes, boxFaceFrame(faceIndex, halfX, halfY, halfZ));
4755 if (version >= 3) {
4756 const auto& ids = identity[faceIndex].first;
4757 const auto& flags = identity[faceIndex].second;
4758 appendU32(bytes, writeIdentity ? static_cast<uint32_t>(ids.size()) : 0u);
4759 if (writeIdentity) {
4760 for (size_t e = 0; e < ids.size(); ++e) {
4761 appendU32(bytes, ids[e]);
4762 bytes.push_back(static_cast<char>(flags[e]));
4763 }
4764 }
4765 }
4766 }
4767 return bytes;
4768 };
4769
4770 SurfaceSolid v3("sidecarV3Identity");
4771 const auto v3Path = writeSidecarFile("o2_sidecar_v3_identity.bin", boxBytes(3, true));
4772 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(v3Path.string(), v3));
4773 std::filesystem::remove(v3Path);
4774 v3.CloseShape(false);
4775 BOOST_CHECK(v3.HasEdgeIdentity());
4776 BOOST_CHECK_EQUAL(v3.GetSourceEdgeCount(), 12);
4777 BOOST_CHECK_EQUAL(v3.GetSharedSourceEdgeCount(), 12);
4778 BOOST_CHECK_EQUAL(v3.GetMeasuredSharedEdgeCount(), 12);
4779 BOOST_CHECK_SMALL(v3.GetMaxSharedEdgeDeviation(), 1.e-15);
4780 BOOST_CHECK(v3.IsNavigable());
4781 TGeoBBox reference("identityBoxReference", halfX, halfY, halfZ);
4782 compareContainsGrid(v3, reference, 4., 7);
4783 checkClose(v3.Capacity(), reference.Capacity(), 1.e-9);
4784
4785 // a v3 file may legitimately state no identities per face, and then it is a v2 file in all but
4786 // the header: same load, same geometric verdict, and nothing pretends to know the topology
4787 SurfaceSolid v3Silent("sidecarV3Silent");
4788 const auto silentPath = writeSidecarFile("o2_sidecar_v3_silent.bin", boxBytes(3, false));
4789 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(silentPath.string(), v3Silent));
4790 std::filesystem::remove(silentPath);
4791 v3Silent.CloseShape(false);
4792 BOOST_CHECK(!v3Silent.HasEdgeIdentity());
4793 BOOST_CHECK(v3Silent.IsNavigable());
4794
4795 // and the same six faces written as version 2 -- the compatibility statement, in one assertion
4796 SurfaceSolid v2("sidecarV2StillLoads");
4797 const auto v2Path = writeSidecarFile("o2_sidecar_v2_still_loads.bin", boxBytes(2, false));
4798 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(v2Path.string(), v2));
4799 std::filesystem::remove(v2Path);
4800 v2.CloseShape(false);
4801 BOOST_CHECK(!v2.HasEdgeIdentity());
4802 BOOST_CHECK(v2.IsNavigable());
4803 checkClose(v2.GetModelTolerance(), 1.e-7, 1.e-18);
4804
4805 // a v3 header over a v2 body: the counts it reads are the next record's bytes, and a reader that
4806 // resize()s to them is killed rather than reporting anything. It must fail as a parse error.
4807 std::vector<char> mislabelled;
4808 appendSidecarHeader(mislabelled, 6, 2, 1.e-7);
4809 mislabelled[4] = 3; // rewrite the version word in place, leaving a version-2 body behind it
4810 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4811 appendPlaneRecord(mislabelled, boxFaceFrame(faceIndex, halfX, halfY, halfZ));
4812 }
4813 SurfaceSolid mislabelledSolid("sidecarV3Mislabelled");
4814 const auto badPath = writeSidecarFile("o2_sidecar_v3_mislabelled.bin", mislabelled);
4815 BOOST_CHECK(!o2::cad::LoadSurfaceSolid(badPath.string(), mislabelledSolid));
4816 std::filesystem::remove(badPath);
4817}
4818
4825BOOST_AUTO_TEST_CASE(EdgeIdentityVerdictIsIndependentOfChordSampling)
4826{
4827 const auto box = makeIdentifiedBox("identitySampling", 1., 2., 3.);
4828 box->CloseShape(false);
4829 const double deviation = box->GetMaxSharedEdgeDeviation();
4830 const bool navigable = box->IsNavigable();
4831
4832 // Resample one face's rim at a different chord count by splitting its wire into eight segments
4833 // instead of four. The rim polylines the geometric measurement compares now differ in phase and
4834 // in count -- the exact situation section 13 shows moving the old verdict -- while the edge
4835 // identity, and every curve it names, is untouched.
4836 SurfaceSolid resampled("identitySamplingResampled");
4837 const auto identity = boxEdgeIdentity(1., 2., 3.);
4838 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4839 const FaceFrame frame = boxFaceFrame(faceIndex, 1., 2., 3.);
4840 auto corners = rectangleWire(frame.extentU, frame.extentV);
4841 std::vector<Point2D> dense;
4842 std::vector<unsigned int> ids;
4843 std::vector<unsigned char> flags;
4844 for (size_t segment = 0; segment < corners.size(); ++segment) {
4845 const auto& a = corners[segment];
4846 const auto& b = corners[(segment + 1) % corners.size()];
4847 dense.push_back(a);
4848 dense.push_back({0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])});
4849 // both halves of one box edge carry that edge's identity: an edge split for sampling is
4850 // still one edge, and the count has to see it as one
4851 for (int half = 0; half < 2; ++half) {
4852 ids.push_back(identity[faceIndex].first[segment]);
4853 flags.push_back(identity[faceIndex].second[segment]);
4854 }
4855 }
4856 BOOST_REQUIRE(resampled.AddPlanarSurface(frame.origin, frame.axisU, frame.axisV, dense));
4857 BOOST_REQUIRE(resampled.SetSurfaceBoundaryEdges(faceIndex, ids, flags));
4858 }
4859 resampled.CloseShape(false);
4860
4861 // every box edge now appears four times (twice per face, split in half), so it is *not* the
4862 // "exactly twice" case -- which is the honest answer for this deliberately abused fixture, and
4863 // the assertion worth making is that the count says so rather than that it says "closed"
4864 BOOST_CHECK(resampled.HasEdgeIdentity());
4865 BOOST_CHECK_EQUAL(resampled.GetSourceEdgeCount(), 12);
4866 BOOST_CHECK_EQUAL(resampled.GetNonManifoldSourceEdgeCount(), 12);
4867
4868 // and the undisturbed box is unmoved by anything sampling-related
4869 BOOST_CHECK_EQUAL(box->IsNavigable(), navigable);
4870 checkClose(box->GetMaxSharedEdgeDeviation(), deviation, 1.e-18);
4871}
4872
4873// --- Sidecar v3 edge identity ---
4874
4875// --- Position and scale independence ---
4876//
4877// The kernel's length tolerances are absolute, so every number recorded on this branch is a
4878// statement about centimetre-scale geometry near the origin until someone runs the ladder
4879// somewhere else. Doing that found one defect, and these tests are what keeps it named.
4880
4881namespace
4882{
4886std::array<double, 5> torusRayQuartic(double majorRadius, double minorRadius,
4887 const Point3D& origin, const Point3D& dir)
4888{
4889 const double dirDotDir = dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2];
4890 const double originDotDir = origin[0] * dir[0] + origin[1] * dir[1] + origin[2] * dir[2];
4891 const double originDotOrigin =
4892 origin[0] * origin[0] + origin[1] * origin[1] + origin[2] * origin[2];
4893 const double constantK = majorRadius * majorRadius - minorRadius * minorRadius;
4894 const double transverseE = dir[0] * dir[0] + dir[1] * dir[1];
4895 const double transverseF = origin[0] * dir[0] + origin[1] * dir[1];
4896 const double transverseG = origin[0] * origin[0] + origin[1] * origin[1];
4897 const double fourRSquared = 4. * majorRadius * majorRadius;
4898 return {dirDotDir * dirDotDir,
4899 4. * dirDotDir * originDotDir,
4900 4. * originDotDir * originDotDir + 2. * dirDotDir * (originDotOrigin + constantK) -
4901 fourRSquared * transverseE,
4902 4. * originDotDir * (originDotOrigin + constantK) - 2. * fourRSquared * transverseF,
4903 (originDotOrigin + constantK) * (originDotOrigin + constantK) -
4904 fourRSquared * transverseG};
4905}
4906
4909std::vector<double> torusRootsAtScale(double scale)
4910{
4911 const Point3D origin{2.094269422822338 * scale, 3.292530879918199 * scale,
4912 1.9347519602583996 * scale};
4913 const Point3D dir{-0.7547297076674779, -0.03154700875395883, -0.655276929704412};
4914 const auto c = torusRayQuartic(2.5 * scale, 0.8 * scale, origin, dir);
4915 const auto roots = surf::solveQuarticReal(c[0], c[1], c[2], c[3], c[4]);
4916 return {roots.begin(), roots.end()};
4917}
4918} // namespace
4919
4920BOOST_AUTO_TEST_CASE(StreamE_TorusQuarticIsScaleCovariantWhereItWorks)
4921{
4922 // Ferrari's method is exactly scale-covariant: scaling the geometry and the ray origin by k
4923 // scales every root by k and nothing else. This is the property the whole sweep rests on, so it
4924 // is asserted rather than assumed.
4925 const auto reference = torusRootsAtScale(1.);
4926 BOOST_REQUIRE_EQUAL(reference.size(), 2u);
4927 for (const double scale : {0.5, 2., 10.}) {
4928 const auto roots = torusRootsAtScale(scale);
4929 BOOST_REQUIRE_EQUAL(roots.size(), reference.size());
4930 for (size_t i = 0; i < roots.size(); ++i) {
4931 checkClose(roots[i], reference[i] * scale, 1.e-12);
4932 }
4933 }
4934}
4935
4936BOOST_AUTO_TEST_CASE(StreamE_TorusQuarticKeepsEveryRootBelowTheOldResolventGuard)
4937{
4938 // The resolvent guard must be dimensionless: it used to compare a cm^2 quantity against a
4939 // length tolerance and silently returned zero roots as the geometry shrank. These three scales
4940 // must all keep finding both roots of a ray that genuinely crosses the torus twice.
4941 BOOST_CHECK_EQUAL(torusRootsAtScale(0.15).size(), 2u); // was above the old guard
4942 BOOST_CHECK_EQUAL(torusRootsAtScale(0.12).size(), 2u); // was below it: every root was lost
4943 BOOST_CHECK_EQUAL(torusRootsAtScale(0.05).size(), 2u);
4944
4945 // and the roots are exactly the unit-scale ones scaled down, since Ferrari's method is exactly
4946 // scale-covariant -- the property StreamE_TorusQuarticIsScaleCovariantWhereItWorks asserts
4947 // above, now that "where it works" is everywhere.
4948 const auto reference = torusRootsAtScale(1.);
4949 BOOST_REQUIRE_EQUAL(reference.size(), 2u);
4950 for (const double factor : {0.15, 0.12, 0.05, 0.01}) {
4951 const auto roots = torusRootsAtScale(factor);
4952 BOOST_REQUIRE_EQUAL(roots.size(), reference.size());
4953 for (size_t i = 0; i < roots.size(); ++i) {
4954 BOOST_CHECK_LT(std::abs(roots[i] - reference[i] * factor), 1.e-12 * reference[i] * factor);
4955 }
4956 }
4957
4958 // And the roots really are there: the quartic changes sign across each of the two crossings the
4959 // unit-scale solve found, scaled down. Without this the counts above could be read as the
4960 // solver merely agreeing with itself.
4961 const double scale = 0.12;
4962 const Point3D origin{2.094269422822338 * scale, 3.292530879918199 * scale,
4963 1.9347519602583996 * scale};
4964 const Point3D dir{-0.7547297076674779, -0.03154700875395883, -0.655276929704412};
4965 const auto c = torusRayQuartic(2.5 * scale, 0.8 * scale, origin, dir);
4966 const auto evaluate = [&c](double t) {
4967 return (((c[0] * t + c[1]) * t + c[2]) * t + c[3]) * t + c[4];
4968 };
4969 for (const double root : reference) {
4970 const double t = root * scale;
4971 const double span = 0.02 * t;
4972 BOOST_CHECK_LT(evaluate(t - span) * evaluate(t + span), 0.);
4973 }
4974}
4975
4976// --- Gating any TGeoShape ---
4977//
4978// The oracle gate could score exactly one thing: an O2BVHSurfaceSolid loaded from a
4979// surfaces_<part>.bin sidecar. The four scored queries are TGeoShape virtuals, so the scoring
4980// loop was never actually specific to that class -- only the loading was. These cases pin the
4981// two halves of removing that restriction:
4982//
4983// 1. the `shape_<part>.root` sidecar convention itself (one TGeoShape-derived object under the
4984// key "shape"), through the same save/load pair the harness and the fixture generator use,
4985// so producer and consumer cannot drift;
4986// 2. that the oracle validators really are representation-agnostic -- with a *negative
4987// control*, because a validator that reports "0 disagreements" for every input would pass a
4988// positive-only test while being structurally incapable of failing.
4989
4990BOOST_AUTO_TEST_CASE(ShapeSidecarRoundTripsAnyTGeoShape)
4991{
4992 namespace harness = o2::cad::harness;
4993 const auto dir = std::filesystem::temp_directory_path();
4994
4995 // A TGeoBBox that is *not* centred on the origin. The offset is the point: it is the cheapest
4996 // way for a frame convention to be silently wrong, so it has to survive the round trip.
4997 double origin[3] = {1.0, 1.5, 2.0};
4998 TGeoBBox box("shape", 1.0, 1.5, 2.0, origin);
4999
5000 // A TGeoCompositeShape, which is what the CSG emitter will actually hand over: a 4 cm cube with
5001 // an r = 0.8 cm axial through-hole. Built from a TGeoBoolNode rather than from a string
5002 // expression, so no TGeoManager is needed on either side.
5003 auto* cube = new TGeoBBox("cube", 2.0, 2.0, 2.0);
5004 auto* drill = new TGeoTube("drill", 0.0, 0.8, 2.5);
5005 TGeoCompositeShape composite("shape", new TGeoSubtraction(cube, drill, nullptr, nullptr));
5006
5007 const std::vector<Point3D> probes{{0.5, 0.5, 0.5}, {1.0, 1.5, 2.0}, {3.0, 1.5, 2.0}, {0.0, 0.0, 0.0}, {1.9, 0.0, 0.0}, {0.0, 0.0, 1.9}, {-1.5, -1.5, 1.0}, {0.79, 0.0, 0.0}, {0.81, 0.0, 0.0}};
5008 const std::vector<Point3D> directions{{1., 0., 0.}, {0., 1., 0.}, {0., 0., 1.}, {-1., 0., 0.}, {0.6, 0.8, 0.}};
5009
5010 for (const TGeoShape* original : {static_cast<const TGeoShape*>(&box),
5011 static_cast<const TGeoShape*>(&composite)}) {
5012 const std::string path = (dir / (std::string("o2_shape_sidecar_") + original->ClassName() + ".root")).string();
5013 std::string error;
5014 BOOST_REQUIRE_MESSAGE(harness::saveShapeToRootFile(path, *original, &error), error);
5015
5016 std::unique_ptr<TGeoShape> loaded(harness::loadShapeFromRootFile(path, &error));
5017 BOOST_REQUIRE_MESSAGE(loaded != nullptr, error);
5018 BOOST_CHECK_EQUAL(std::string(loaded->ClassName()), std::string(original->ClassName()));
5019 // TGeoCompositeShape::Capacity() is Monte-Carlo sampled, so this is a loose check by
5020 // necessity -- which is precisely why the gate does not treat capacity as a column for
5021 // composites. 5% is far outside the ~1% MC spread and far inside any real error.
5022 BOOST_CHECK_CLOSE(loaded->Capacity(), original->Capacity(), 5.0);
5023
5024 // The queries the gate actually scores must be bit-identical across the round trip.
5025 for (const auto& p : probes) {
5026 BOOST_CHECK_EQUAL(loaded->Contains(p.data()), original->Contains(p.data()));
5027 BOOST_CHECK_EQUAL(loaded->Safety(p.data(), original->Contains(p.data())),
5028 original->Safety(p.data(), original->Contains(p.data())));
5029 for (const auto& d : directions) {
5030 BOOST_CHECK_EQUAL(loaded->DistFromOutside(p.data(), d.data(), 3),
5031 original->DistFromOutside(p.data(), d.data(), 3));
5032 BOOST_CHECK_EQUAL(loaded->DistFromInside(p.data(), d.data(), 3),
5033 original->DistFromInside(p.data(), d.data(), 3));
5034 }
5035 }
5036 std::filesystem::remove(path);
5037 }
5038}
5039
5040BOOST_AUTO_TEST_CASE(ShapeSidecarRefusesWhatIsNotAShape)
5041{
5042 namespace harness = o2::cad::harness;
5043 const auto dir = std::filesystem::temp_directory_path();
5044 std::string error;
5045
5046 BOOST_CHECK(harness::loadShapeFromRootFile((dir / "o2_shape_absent.root").string(), &error) == nullptr);
5047 BOOST_CHECK(!error.empty());
5048
5049 // A well-formed ROOT file whose "shape" key holds something else must be refused rather than
5050 // silently ignored: an emitter that writes the wrong object would otherwise look like an
5051 // emitter that wrote nothing, and the part would quietly lose its column.
5052 const std::string path = (dir / "o2_shape_not_a_shape.root").string();
5053 {
5054 TFile out(path.c_str(), "RECREATE");
5055 TNamed impostor("shape", "not a shape");
5056 out.WriteTObject(&impostor, "shape");
5057 out.Close();
5058 }
5059 error.clear();
5060 BOOST_CHECK(harness::loadShapeFromRootFile(path, &error) == nullptr);
5061 BOOST_CHECK(!error.empty());
5062 std::filesystem::remove(path);
5063}
5064
5065BOOST_AUTO_TEST_CASE(OracleValidatorsScoreAPlainRootShape)
5066{
5067 namespace harness = o2::cad::harness;
5068
5069 // A ROOT primitive with no connection to O2BVHSurfaceSolid at all, and a *wrong* copy of it:
5070 // same shape, radius 0.05 cm too large. Everything below is asserted twice, once for each, so
5071 // no "0 disagreements" here can come from a validator that is unable to report anything else.
5072 constexpr double kR = 1.5;
5073 constexpr double kZ = 2.0;
5074 constexpr double kError = 0.05;
5075 const TGeoTube truth("truth", 0., kR, kZ);
5076 const TGeoTube wrong("wrong", 0., kR + kError, kZ);
5077
5078 // The oracle columns, built analytically from the tube's own closed form rather than from
5079 // either shape's methods, so `contains` and `safety` are genuinely independent of what is being
5080 // scored. (The two distance columns below are taken from `truth`, which is independent of
5081 // `wrong` -- the case that has to be able to fail.)
5082 const auto trueContains = [](const Point3D& p) {
5083 return (std::hypot(p[0], p[1]) <= kR && std::fabs(p[2]) <= kZ) ? 1 : 0;
5084 };
5085 const auto trueBoundaryDistance = [](const Point3D& p) {
5086 const double r = std::hypot(p[0], p[1]);
5087 const double dr = kR - r;
5088 const double dz = kZ - std::fabs(p[2]);
5089 if (dr > 0. && dz > 0.) {
5090 return std::min(dr, dz);
5091 }
5092 return std::hypot(std::max(r - kR, 0.), std::max(std::fabs(p[2]) - kZ, 0.));
5093 };
5094
5095 std::vector<Point3D> points;
5096 std::vector<int> containsState;
5097 std::vector<double> boundaryDistance;
5098 for (int ix = -6; ix <= 6; ++ix) {
5099 for (int iy = -6; iy <= 6; ++iy) {
5100 for (int iz = -4; iz <= 4; ++iz) {
5101 const Point3D p{0.31 * ix, 0.29 * iy, 0.53 * iz};
5102 // Points nearer the wall than the wrong shape's error would be legitimately ambiguous
5103 // for it, so they are dropped: the negative control has to fail on geometry, not on the
5104 // band. Points in the annulus the two shapes disagree about are deliberately kept.
5105 if (std::fabs(trueBoundaryDistance(p)) < 1.e-3) {
5106 continue;
5107 }
5108 points.push_back(p);
5109 containsState.push_back(trueContains(p));
5110 boundaryDistance.push_back(trueBoundaryDistance(p));
5111 }
5112 }
5113 }
5114 BOOST_REQUIRE_GT(points.size(), 500u);
5115
5116 harness::ValidationOptions opt;
5117 opt.meshBand = 1.e-6; // a synthetic shape has no modelling tolerance to hide behind
5118 opt.distanceTolerance = 1.e-9;
5119
5120 auto containsTruth = harness::validateContainsAgainstOracle(&truth, points, containsState,
5121 boundaryDistance, opt);
5122 auto containsWrong = harness::validateContainsAgainstOracle(&wrong, points, containsState,
5123 boundaryDistance, opt);
5124 BOOST_CHECK_EQUAL(containsTruth.nMismatchUnexplained + containsTruth.nMismatchMissedSurface, 0u);
5125 BOOST_CHECK_GT(containsWrong.nMismatchUnexplained + containsWrong.nMismatchMissedSurface, 0u);
5126
5127 auto safetyTruth = harness::validateSafetyAgainstOracle(&truth, points, boundaryDistance, opt);
5128 auto safetyWrong = harness::validateSafetyAgainstOracle(&wrong, points, boundaryDistance, opt);
5129 BOOST_CHECK_EQUAL(safetyTruth.nMismatchUnexplained + safetyTruth.nMismatchMissedSurface, 0u);
5130 BOOST_CHECK_GT(safetyWrong.nMismatchUnexplained + safetyWrong.nMismatchMissedSurface, 0u);
5131
5132 // Rays from well outside, aimed at points spread through the tube, so the hit rate is not
5133 // degenerate; the oracle distance is the nearest positive crossing, exactly as occtOracle.py
5134 // defines it, and the origin classification decides which TGeo entry point is asked.
5135 std::vector<harness::Ray> rays;
5136 std::vector<double> rayDistance;
5137 std::vector<int> originState;
5138 for (int i = 0; i < 400; ++i) {
5139 const double phi = 0.0173 * i;
5140 const double z = -1.9 + 0.0095 * i;
5141 const Point3D target{0.9 * kR * std::cos(2.1 * phi), 0.9 * kR * std::sin(2.1 * phi), z};
5142 const Point3D origin{5.0 * std::cos(phi), 5.0 * std::sin(phi), 3.0 - 0.01 * i};
5143 Point3D dir{target[0] - origin[0], target[1] - origin[1], target[2] - origin[2]};
5144 const double norm = std::sqrt(dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]);
5145 for (auto& component : dir) {
5146 component /= norm;
5147 }
5148 rays.push_back(harness::Ray{origin, dir});
5149 rayDistance.push_back(truth.DistFromOutside(origin.data(), dir.data(), 3));
5150 originState.push_back(trueContains(origin));
5151 }
5152
5153 auto distTruth = harness::validateDistanceAgainstOracle(&truth, rays, rayDistance,
5154 /*wantInside=*/false, opt, originState);
5155 auto distWrong = harness::validateDistanceAgainstOracle(&wrong, rays, rayDistance,
5156 /*wantInside=*/false, opt, originState);
5157 BOOST_CHECK_EQUAL(distTruth.nMismatchUnexplained + distTruth.nMismatchMissedSurface, 0u);
5158 BOOST_CHECK_GT(distWrong.nMismatchUnexplained + distWrong.nMismatchMissedSurface, 0u);
5159}
5160// --- The CSG emitter's two ROOT-side load-bearing claims ---
5161//
5162// The emitter itself is Python (Detectors/CADSupport/tools/cadsupport), and its own self-tests live there. What
5163// belongs here are the two properties of *ROOT* that the emitted file silently depends on. If a
5164// future ROOT changes either, every CSG part written by this project becomes wrong geometry that
5165// still loads, and nothing else in the suite would notice.
5166
5167namespace
5168{
5169// Build the emitter's former `placed(primitive, M)` idiom: no TGeoShape in ROOT 6.36 can carry a
5170// rigid transform (TGeoBBox has fOrigin and nothing else does), and TGeoCompositeShape is the only
5171// shape that holds a TGeoMatrix at all -- through its TGeoBoolNode, which needs two operands. So a
5172// recognised tube that was not already on the z axis USED TO BE written as the union of the
5173// primitive with an identical copy of itself under the same matrix.
5174//
5175// It is now written as the bare primitive plus a placement instead. This helper stays, because
5176// the self-union is
5177// still exactly the same point set and is therefore the reference the new emission is measured
5178// against -- see PlacedPrimitiveAnswersExactlyLikeTheSelfUnionComposite.
5179TGeoCompositeShape* makePlacedTube(const char* name, double rmin, double rmax, double dz,
5180 TGeoMatrix* matrixA, TGeoMatrix* matrixB)
5181{
5182 auto* left = new TGeoTube(Form("%s_l", name), rmin, rmax, dz);
5183 auto* right = new TGeoTube(Form("%s_r", name), rmin, rmax, dz);
5184 auto* node = new TGeoUnion(left, right, matrixA, matrixB);
5185 return new TGeoCompositeShape(name, node);
5186}
5187} // namespace
5188
5189BOOST_AUTO_TEST_CASE(CsgSelfUnionCarriesARigidTransformExactly)
5190{
5191 // A tube on an axis that is neither a coordinate axis nor through the origin -- i.e. the
5192 // ExcavatorArm case. Every query on the composite must equal the same query on the bare primitive
5193 // asked in the primitive's own frame, exactly, not within a band.
5194 constexpr double kRmin = 0.4;
5195 constexpr double kRmax = 1.0;
5196 constexpr double kDz = 5.0;
5197 const TGeoTube reference("reference", kRmin, kRmax, kDz);
5198
5199 auto* rotation = new TGeoRotation("csgRot", 0., 0., 0.);
5200 rotation->RotateX(30.);
5201 rotation->RotateZ(17.);
5202 auto* matrixA = new TGeoCombiTrans(0.3, 5.916, 2.0, rotation);
5203 auto* matrixB = new TGeoCombiTrans(0.3, 5.916, 2.0, rotation);
5204 const TGeoCombiTrans placement(0.3, 5.916, 2.0, rotation);
5205 std::unique_ptr<TGeoCompositeShape> placed(
5206 makePlacedTube("csgPlaced", kRmin, kRmax, kDz, matrixA, matrixB));
5207
5208 std::size_t probes = 0;
5209 std::size_t inside = 0;
5210 std::size_t outside = 0;
5211 for (int ix = -8; ix <= 8; ++ix) {
5212 for (int iy = -8; iy <= 8; ++iy) {
5213 for (int iz = -8; iz <= 8; ++iz) {
5214 const Point3D master{0.3 + 0.37 * ix, 5.916 + 0.41 * iy, 2.0 + 0.43 * iz};
5215 Point3D local{};
5216 placement.MasterToLocal(master.data(), local.data());
5217 // A point on the wall is decided by floating-point luck on either side; skip a thin
5218 // shell so the check tests geometry rather than tie-breaking.
5219 const double r = std::hypot(local[0], local[1]);
5220 if (std::fabs(r - kRmin) < 1.e-9 || std::fabs(r - kRmax) < 1.e-9 ||
5221 std::fabs(std::fabs(local[2]) - kDz) < 1.e-9) {
5222 continue;
5223 }
5224 ++probes;
5225 const bool wanted = reference.Contains(local.data());
5226 BOOST_REQUIRE_EQUAL(placed->Contains(master.data()), wanted);
5227 BOOST_REQUIRE_CLOSE_FRACTION(placed->Safety(master.data(), wanted),
5228 reference.Safety(local.data(), wanted), 1.e-12);
5229 wanted ? ++inside : ++outside;
5230
5231 for (const auto& dir : {Point3D{1., 0., 0.}, Point3D{0., 1., 0.}, Point3D{0., 0., 1.},
5232 Point3D{0.5773502691896258, 0.5773502691896258, 0.5773502691896258}}) {
5233 Point3D localDir{};
5234 placement.MasterToLocalVect(dir.data(), localDir.data());
5235 if (wanted) {
5236 BOOST_REQUIRE_CLOSE_FRACTION(placed->DistFromInside(master.data(), dir.data(), 3),
5237 reference.DistFromInside(local.data(), localDir.data(), 3),
5238 1.e-12);
5239 } else {
5240 const double got = placed->DistFromOutside(master.data(), dir.data(), 3);
5241 const double want = reference.DistFromOutside(local.data(), localDir.data(), 3);
5242 if (want > 1.e20) {
5243 BOOST_REQUIRE_GT(got, 1.e20);
5244 } else {
5245 BOOST_REQUIRE_CLOSE_FRACTION(got, want, 1.e-12);
5246 }
5247 }
5248 }
5249 }
5250 }
5251 }
5252 // A check that cannot fail is not a check: both classes must actually be populated.
5253 BOOST_CHECK_GT(probes, 2000u);
5254 BOOST_CHECK_GT(inside, 100u);
5255 BOOST_CHECK_GT(outside, 100u);
5256
5257 // The negative half. The same comparison against a primitive 0.05 cm too wide must disagree,
5258 // otherwise the loop above proves nothing about the transform.
5259 // The probes are placed *in the shell the two disagree about* and then mapped out to the
5260 // master frame, rather than being taken from a lattice that might miss a 0.05 cm shell.
5261 const TGeoTube wrong("wrongReference", kRmin, kRmax + 0.05, kDz);
5262 std::size_t disagreements = 0;
5263 std::size_t shellProbes = 0;
5264 for (int iphi = 0; iphi < 24; ++iphi) {
5265 const double phi = 2. * M_PI * iphi / 24.;
5266 for (int iz = -3; iz <= 3; ++iz) {
5267 const double radius = kRmax + 0.025;
5268 const Point3D local{radius * std::cos(phi), radius * std::sin(phi), 1.3 * iz};
5269 Point3D master{};
5270 placement.LocalToMaster(local.data(), master.data());
5271 ++shellProbes;
5272 disagreements += (placed->Contains(master.data()) != wrong.Contains(local.data())) ? 1 : 0;
5273 }
5274 }
5275 BOOST_CHECK_EQUAL(disagreements, shellProbes);
5276}
5277
5278BOOST_AUTO_TEST_CASE(CsgTwoLeafUnionRoundTripsAndMatchesTheClosedForm)
5279{
5280 // The ExcavatorArm ram, in miniature and in closed form: an eye (a tube on x) plus a rod (a solid
5281 // cylinder on z), which is what `tier2-tube-union` emits. The union is checked against the
5282 // membership function of the two cylinders written out by hand, which depends on neither ROOT
5283 // shape, and then the whole composite is pushed through the shape sidecar and checked again --
5284 // so a streaming defect that dropped a bool node's matrix would be caught here rather than in
5285 // a gate run three steps later.
5286 constexpr double kEyeRmin = 0.7;
5287 constexpr double kEyeRmax = 1.2;
5288 constexpr double kEyeDz = 0.75;
5289 constexpr double kRodR = 0.6;
5290 constexpr double kRodDz = 3.5;
5291 constexpr double kRodCentre = 3.5; // rod spans z in [0, 7]
5292
5293 auto* eyeRotation = new TGeoRotation("csgEyeRot", 90., 90., 0.); // local z -> global x
5294 auto* eyeMatrix = new TGeoCombiTrans(0., 0., 0., eyeRotation);
5295 auto* rodMatrix = new TGeoTranslation(0., 0., kRodCentre);
5296 auto* eye = new TGeoTube("csgEye", kEyeRmin, kEyeRmax, kEyeDz);
5297 auto* rod = new TGeoTube("csgRod", 0., kRodR, kRodDz);
5298 auto* node = new TGeoUnion(eye, rod, eyeMatrix, rodMatrix);
5299 std::unique_ptr<TGeoCompositeShape> ram(new TGeoCompositeShape("csgRam", node));
5300
5301 const auto closedForm = [&](const Point3D& p) {
5302 const double rEye = std::hypot(p[1], p[2]);
5303 const bool inEye = rEye >= kEyeRmin && rEye <= kEyeRmax && std::fabs(p[0]) <= kEyeDz;
5304 const double rRod = std::hypot(p[0], p[1]);
5305 const bool inRod = rRod <= kRodR && p[2] >= 0. && p[2] <= 2. * kRodDz;
5306 return inEye || inRod;
5307 };
5308 const auto nearWall = [&](const Point3D& p) {
5309 const double rEye = std::hypot(p[1], p[2]);
5310 const double rRod = std::hypot(p[0], p[1]);
5311 return std::fabs(rEye - kEyeRmin) < 1.e-9 || std::fabs(rEye - kEyeRmax) < 1.e-9 ||
5312 std::fabs(std::fabs(p[0]) - kEyeDz) < 1.e-9 || std::fabs(rRod - kRodR) < 1.e-9 ||
5313 std::fabs(p[2]) < 1.e-9 || std::fabs(p[2] - 2. * kRodDz) < 1.e-9;
5314 };
5315
5316 const std::filesystem::path path =
5317 std::filesystem::temp_directory_path() / "o2_csg_ram_shape.root";
5318 namespace harness = o2::cad::harness;
5319 std::string error;
5320 BOOST_REQUIRE_MESSAGE(harness::saveShapeToRootFile(path.string(), *ram, &error), error);
5321 std::unique_ptr<TGeoShape> loaded(harness::loadShapeFromRootFile(path.string(), &error));
5322 BOOST_REQUIRE_MESSAGE(loaded != nullptr, error);
5323 BOOST_CHECK_EQUAL(std::string(loaded->ClassName()), std::string("TGeoCompositeShape"));
5324
5325 std::size_t inside = 0;
5326 std::size_t outside = 0;
5327 for (int ix = -6; ix <= 6; ++ix) {
5328 for (int iy = -6; iy <= 6; ++iy) {
5329 for (int iz = -4; iz <= 20; ++iz) {
5330 const Point3D p{0.23 * ix, 0.27 * iy, 0.41 * iz};
5331 if (nearWall(p)) {
5332 continue;
5333 }
5334 const bool wanted = closedForm(p);
5335 BOOST_REQUIRE_EQUAL(ram->Contains(p.data()), wanted);
5336 BOOST_REQUIRE_EQUAL(loaded->Contains(p.data()), wanted);
5337 wanted ? ++inside : ++outside;
5338 }
5339 }
5340 }
5341 BOOST_CHECK_GT(inside, 50u);
5342 BOOST_CHECK_GT(outside, 500u);
5343
5344 // Capacity is Monte-Carlo for a composite, so it is *reported* and never gated.
5345 // The assertion is stated as scatter rather than as accuracy, because scatter needs no exact
5346 // volume and is the sharper statement: repeated calls returning *different* answers prove the
5347 // method is sampled, and a spread four orders of magnitude above the gate's 1e-6 band proves
5348 // that no capacity criterion could ever be applied to a shape written this way. If a future
5349 // ROOT made TGeoCompositeShape::Capacity() analytic this test would fail, which is the right
5350 // outcome: the emitter's acceptance policy would then be worth revisiting.
5351 double minCapacity = ram->Capacity();
5352 double maxCapacity = minCapacity;
5353 for (int i = 0; i < 5; ++i) {
5354 const double sampled = ram->Capacity();
5355 minCapacity = std::min(minCapacity, sampled);
5356 maxCapacity = std::max(maxCapacity, sampled);
5357 }
5358 BOOST_CHECK_GT(minCapacity, 0.);
5359 const double spread = (maxCapacity - minCapacity) / (0.5 * (maxCapacity + minCapacity));
5360 BOOST_CHECK_GT(spread, 1.e-4);
5361
5362 std::filesystem::remove(path);
5363}
5364// --- The CSG emitter's ROOT-side claims ---
5365
5366// ============================================================================================
5367// X-ray / geantino transport -- ordered crossing lists
5368// ============================================================================================
5369//
5370// Everything above this block, and everything the oracle gate measures, is a SINGLE-SHOT query:
5371// from a point, how far to the surface. A transport loop is different in kind -- step, land on
5372// the boundary, step again from there -- and its failure modes (a zero-length step, a particle
5373// that enters and never leaves, a crossing found twice, a step that overshoots) cannot be
5374// expressed as a disagreement on DistFromOutside from an interior sample. These cases pin the
5375// properties the X-ray benchmark rests on.
5376//
5377// They include XRayTransport.h, which is the SAME header the benchmark binary steps with. That
5378// is deliberate: a test written against a second implementation of the same idea tests neither.
5379
5380#include "XRayTransport.h"
5381
5382using namespace o2::cad::xray;
5384
5388BOOST_AUTO_TEST_CASE(XRayCrossingListsMatchClosedFormOnPrimitives)
5389{
5390 StepConfig cfg;
5391 Robustness stats;
5392 const XRayPoint origin{-5., 0., 0.};
5393 const XRayPoint dir{1., 0., 0.};
5394
5395 TGeoBBox box("xrayBox", 1., 1.5, 2.);
5396 const auto boxCrossings = stepWithShapeApi(&box, origin, dir, 10., cfg, stats);
5397 BOOST_REQUIRE_EQUAL(boxCrossings.size(), 2u);
5398 BOOST_CHECK_SMALL(boxCrossings[0].t - 4., 1.e-12);
5399 BOOST_CHECK_SMALL(boxCrossings[1].t - 6., 1.e-12);
5400 BOOST_CHECK_EQUAL(boxCrossings[0].kind, +1);
5401 BOOST_CHECK_EQUAL(boxCrossings[1].kind, -1);
5402
5403 TGeoTube tube("xrayTube", 0.5, 1.0, 2.0);
5404 const auto tubeCrossings = stepWithShapeApi(&tube, origin, dir, 10., cfg, stats);
5405 BOOST_REQUIRE_EQUAL(tubeCrossings.size(), 4u);
5406 const double expected[4] = {4.0, 4.5, 5.5, 6.0};
5407 const int senses[4] = {+1, -1, +1, -1};
5408 for (int i = 0; i < 4; ++i) {
5409 BOOST_CHECK_SMALL(tubeCrossings[i].t - expected[i], 1.e-12);
5410 BOOST_CHECK_EQUAL(tubeCrossings[i].kind, senses[i]);
5411 }
5412 BOOST_CHECK_EQUAL(stats.zeroLengthSteps, 0);
5413 BOOST_CHECK_EQUAL(stats.nonAdvancingSteps, 0);
5414 BOOST_CHECK_EQUAL(stats.unstickPushes, 0);
5415}
5416
5424BOOST_AUTO_TEST_CASE(XRayCrossingListsAgreeBetweenBVHAndLoopOnAllFixtures)
5425{
5426 StepConfig cfg;
5427 std::array<std::pair<std::unique_ptr<SurfaceSolid>, double>, 7> fixtures{{
5428 {makeBoxSolid("xrayLoopBox", 1., 2., 3.), 4.},
5429 {makeTubeSolid("xrayLoopTube", 0., 2., 3.), 4.},
5430 {makeTubeSolid("xrayLoopHollowTube", 1., 2., 3.), 4.},
5431 {makeConeSolid("xrayLoopCone", 2., 1., 3.), 4.},
5432 {makeSphereSolid("xrayLoopSphere", 2.5), 3.5},
5433 {makeTorusSolid("xrayLoopTorus", 3., 1.), 4.5},
5434 {makeCapsuleSolid("xrayLoopCapsule", 1., 1.5), 3.},
5435 }};
5436 size_t comparedRays = 0;
5437 size_t comparedCrossings = 0;
5438 for (const auto& [solid, extent] : fixtures) {
5439 BOOST_TEST_CONTEXT("fixture = " << solid->GetName())
5440 {
5441 BOOST_REQUIRE(solid->HasBVH());
5442 const XRayPoint lo{-extent, -extent, -extent};
5443 const XRayPoint hi{extent, extent, extent};
5444 // A fan rather than the three axes: a parallel beam is direction-poor, and the point of this
5445 // case is to exercise many ray/surface configurations per fixture.
5446 const Raster raster = buildRaster(lo, hi, 9, buildFanBeams(11), 0.);
5447 for (const auto& ray : raster.rays) {
5448 Robustness bvhStats;
5449 Robustness loopStats;
5450 const auto viaBVH =
5451 stepWithShapeApi(solid.get(), ray.origin, ray.dir, ray.tMax, cfg, bvhStats);
5452 // The non-BVH twins, stepped through the identical loop: only the traversal differs.
5453 const auto viaLoop = stepCrossingsWithKernels(
5454 ray.origin, ray.dir, ray.tMax, cfg, loopStats,
5455 [&solid](const double* p) { return solid->Contains_Loop(p); },
5456 [&solid](const double* p, const double* d) { return solid->DistFromOutside_Loop(p, d); },
5457 [&solid](const double* p, const double* d) { return solid->DistFromInside_Loop(p, d); });
5458 BOOST_REQUIRE_EQUAL(viaBVH.size(), viaLoop.size());
5459 for (size_t i = 0; i < viaBVH.size(); ++i) {
5460 BOOST_CHECK_EQUAL(viaBVH[i].kind, viaLoop[i].kind);
5461 // Bit-identical is the contract: both minimise over the same hits from the same kernels.
5462 BOOST_CHECK_EQUAL(viaBVH[i].t, viaLoop[i].t);
5463 }
5464 comparedRays += 1;
5465 comparedCrossings += viaBVH.size();
5466 }
5467 }
5468 }
5469 BOOST_CHECK_GT(comparedRays, 2000u);
5470 BOOST_CHECK_GT(comparedCrossings, 2000u);
5471}
5472
5476BOOST_AUTO_TEST_CASE(XRayCrossingComparatorCatchesInjectedDefects)
5477{
5478 const std::vector<Crossing> truth{{4.0, +1}, {4.5, -1}, {5.5, +1}, {6.0, -1}};
5479 const double tolerance = 1.e-6;
5480
5481 ListComparison clean;
5482 compareLists(truth, truth, {}, {}, tolerance, clean);
5484 BOOST_CHECK_EQUAL(clean.matched, 4);
5485 BOOST_CHECK_EQUAL(clean.missing, 0);
5486 BOOST_CHECK_EQUAL(clean.extra, 0);
5487 BOOST_CHECK_EQUAL(clean.displaced, 0);
5488
5489 auto perturbed = truth;
5490 perturbed[2].t += 1.e-3;
5491 ListComparison displaced;
5492 compareLists(perturbed, truth, {}, {}, tolerance, displaced);
5493 BOOST_CHECK_EQUAL(displaced.raysIdentical, 0);
5494 BOOST_CHECK_EQUAL(displaced.displaced, 1);
5495 BOOST_CHECK_EQUAL(displaced.missing, 0); // a moved crossing is NOT a lost one
5496 BOOST_CHECK_EQUAL(displaced.extra, 0);
5497 BOOST_CHECK_SMALL(displaced.worstDeltaT - 1.e-3, 1.e-12);
5498
5499 auto dropped = truth;
5500 dropped.erase(dropped.begin() + 1);
5501 ListComparison lost;
5502 compareLists(dropped, truth, {}, {}, tolerance, lost);
5503 BOOST_CHECK_EQUAL(lost.missing, 1);
5504 BOOST_CHECK_EQUAL(lost.extra, 0);
5505
5506 auto doubled = truth;
5507 doubled.insert(doubled.begin() + 1, {4.2, -1});
5508 ListComparison spurious;
5509 compareLists(doubled, truth, {}, {}, tolerance, spurious);
5510 BOOST_CHECK_EQUAL(spurious.extra, 1);
5511 BOOST_CHECK_EQUAL(spurious.missing, 0);
5512
5513 auto flipped = truth;
5514 flipped[1].kind = +1;
5515 ListComparison sense;
5516 compareLists(flipped, truth, {}, {}, tolerance, sense);
5518
5519 // A crossing moved by LESS than the tolerance must not be reported at all, or every run would
5520 // drown in last-digit noise.
5521 auto nudged = truth;
5522 nudged[0].t += 1.e-9;
5523 ListComparison quiet;
5524 compareLists(nudged, truth, {}, {}, tolerance, quiet);
5526 BOOST_CHECK_EQUAL(quiet.displaced, 0);
5527}
5528
5532BOOST_AUTO_TEST_CASE(XRayParityAuditContradictsATruncatedList)
5533{
5534 StepConfig cfg;
5535 TGeoBBox box("xrayParityBox", 1., 1., 1.);
5536 const XRayPoint origin{-5., 0., 0.};
5537 const XRayPoint dir{1., 0., 0.};
5538
5539 Robustness good;
5540 auditCrossingList({{4.0, +1}, {6.0, -1}}, &box, origin, dir, 10., cfg, good);
5543 BOOST_CHECK_SMALL(good.insideLength - 2., 1.e-12);
5544
5545 Robustness truncated;
5546 auditCrossingList({{4.0, +1}}, &box, origin, dir, 10., cfg, truncated);
5547 BOOST_CHECK_GT(truncated.parityMismatchIntervals, 0);
5548 BOOST_CHECK_EQUAL(truncated.oddCrossingLists, 1);
5549
5550 Robustness invented;
5551 auditCrossingList({{1.0, +1}, {2.0, -1}, {4.0, +1}, {6.0, -1}}, &box, origin, dir, 10., cfg,
5552 invented);
5553 BOOST_CHECK_GT(invented.parityMismatchIntervals, 0);
5554}
5555
5560BOOST_AUTO_TEST_CASE(XRayChordIntegralIsExactForABoxAndConvergesForASphere)
5561{
5562 StepConfig cfg;
5563 TGeoBBox box("xrayVolBox", 1., 1.5, 2.);
5564 for (const int n : {5, 16, 41}) {
5565 const Raster raster = buildRaster({-1., -1.5, -2.}, {1., 1.5, 2.}, n, buildBeams("xyz", 0.), 0.);
5566 Robustness stats;
5567 std::vector<double> byBeam(raster.beams.size(), 0.);
5568 for (const auto& ray : raster.rays) {
5569 const double before = stats.insideLength;
5570 const auto crossings = stepWithShapeApi(&box, ray.origin, ray.dir, ray.tMax, cfg, stats);
5571 auditCrossingList(crossings, nullptr, ray.origin, ray.dir, ray.tMax, cfg, stats);
5572 byBeam[ray.beam] += stats.insideLength - before;
5573 }
5574 BOOST_CHECK_SMALL(chordVolume(raster, byBeam) - 24., 1.e-9);
5575 }
5576
5577 // A curved silhouette cannot be exact at finite N. The bound below is the MEASURED envelope
5578 // over N = 24..192 (2e-3), not a convergence rate -- the convergence is NOT monotone in N,
5579 // because the silhouette cells realign with the lattice at every density. That is the reason
5580 // this benchmark's volume is quoted with its raster density and never extrapolated.
5581 TGeoSphere sphere("xrayVolSphere", 0., 1.);
5582 const double exact = 4. / 3. * 3.14159265358979323846;
5583 for (const int n : {24, 96}) {
5584 const Raster raster = buildRaster({-1., -1., -1.}, {1., 1., 1.}, n, buildBeams("z", 0.), 0.);
5585 Robustness stats;
5586 for (const auto& ray : raster.rays) {
5587 const auto crossings = stepWithShapeApi(&sphere, ray.origin, ray.dir, ray.tMax, cfg, stats);
5588 auditCrossingList(crossings, nullptr, ray.origin, ray.dir, ray.tMax, cfg, stats);
5589 }
5590 const double volume = stats.insideLength * raster.cellArea[0];
5591 BOOST_CHECK_LT(std::fabs(volume - exact) / exact, 2.e-3);
5592 }
5593}
5594
5599BOOST_AUTO_TEST_CASE(XRayRasterRaysStartOutsideAndFansAreDirectionDiverse)
5600{
5601 TGeoBBox box("xrayRasterBox", 1., 1.5, 2.);
5602 const Raster raster = buildRaster({-1., -1.5, -2.}, {1., 1.5, 2.}, 8, buildBeams("xyz", 0.), 0.);
5603 BOOST_CHECK_EQUAL(raster.rays.size(), 3u * 8u * 8u);
5604 for (const auto& ray : raster.rays) {
5605 BOOST_REQUIRE(!box.Contains(ray.origin.data()));
5606 // and the far end must be outside too, so the window really does bracket the solid
5607 const double end[3] = {ray.origin[0] + ray.tMax * ray.dir[0],
5608 ray.origin[1] + ray.tMax * ray.dir[1],
5609 ray.origin[2] + ray.tMax * ray.dir[2]};
5610 BOOST_REQUIRE(!box.Contains(end));
5611 }
5612
5613 const auto axes = buildBeams("xyz", 0.);
5614 const auto fan = buildFanBeams(64);
5615 BOOST_CHECK_EQUAL(axes.size(), 3u);
5616 BOOST_CHECK_EQUAL(fan.size(), 64u);
5617 for (const auto& beams : {axes, fan}) {
5618 for (const auto& beam : beams) {
5619 BOOST_CHECK_SMALL(dot3(beam.dir, beam.dir) - 1., 1.e-12);
5620 BOOST_CHECK_SMALL(dot3(beam.u, beam.v), 1.e-12);
5621 BOOST_CHECK_SMALL(dot3(beam.u, beam.dir), 1.e-12);
5622 BOOST_CHECK_SMALL(dot3(beam.v, beam.dir), 1.e-12);
5623 }
5624 }
5625 // Direction diversity, stated as a number: the axis beams are mutually orthogonal and nothing
5626 // else, while no two fan beams are closer than a few degrees and they span the sphere.
5627 double worstFanAlignment = -1.;
5628 for (size_t i = 0; i < fan.size(); ++i) {
5629 for (size_t j = i + 1; j < fan.size(); ++j) {
5630 worstFanAlignment = std::max(worstFanAlignment, std::fabs(dot3(fan[i].dir, fan[j].dir)));
5631 }
5632 }
5633 BOOST_CHECK_LT(worstFanAlignment, 0.999);
5634}
5635// --- X-ray / geantino transport benchmark ---
5636
5637// --- Dimensionally consistent guards in the quartic root solver ---
5638//
5639// solveQuarticReal used to decide all three of its branches with kTolerance -- 1e-9 *cm*, a
5640// length -- applied to quantities that are not lengths:
5641//
5642// |termQ| <= kTolerance selects the biquadratic branch; termQ scales as L^3
5643// resolvent > kTolerance licenses Ferrari's second stage; resolvent scales as L^2
5644// |derivative| > kTolerance licenses a Newton polishing step; the derivative scales as L^3
5645//
5646// Two consequences:
5647//
5648// * the resolvent guard fails and the function returns the EMPTY root set, so a ray silently
5649// misses a torus it does cross;
5650// * the termQ guard misroutes an asymmetric quartic into the biquadratic branch -- which
5651// *assumes* termQ = 0 and forces the roots to be symmetric about -b/4 -- so it returns
5652// confidently wrong roots instead of the right ones. That is worse than a miss, because a
5653// miss at least leaves a visible gap.
5654//
5655// The trigger is the ratio of the ray's lever arm to the feature it hits, not the model's scale:
5656// the reproducer below is real, *unscaled* ALICE3 geometry, a ray 375 cm from a 0.1 cm tube.
5657//
5658// These cases pin the repair from both sides. It is not enough that the previously-failing case
5659// now works: the branches exist for real reasons, so the biquadratic branch must still be
5660// *selected* for a true biquadratic, and both branches must still *decline* a configuration that
5661// genuinely has no real roots. A guard that always passes would satisfy neither.
5662
5663namespace
5664{
5668double quarticBackwardError(const std::array<double, 5>& coefficients, double x)
5669{
5670 double value = 0., magnitude = 0., power = 1.;
5671 for (int i = 0; i < 5; ++i) {
5672 const double term = coefficients[4 - i] * power;
5673 value += term;
5674 magnitude += std::abs(term);
5675 power *= x;
5676 }
5677 return magnitude > 0. ? std::abs(value) / magnitude : std::abs(value);
5678}
5679
5681std::array<double, 5> quarticFromRoots(double r1, double r2, double r3, double r4)
5682{
5683 return {1., -(r1 + r2 + r3 + r4),
5684 r1 * r2 + r1 * r3 + r1 * r4 + r2 * r3 + r2 * r4 + r3 * r4,
5685 -(r1 * r2 * r3 + r1 * r2 * r4 + r1 * r3 * r4 + r2 * r3 * r4), r1 * r2 * r3 * r4};
5686}
5687
5688std::vector<double> sortedRoots(const std::array<double, 5>& c, surf::QuarticBranch* branch = nullptr)
5689{
5690 const auto found = surf::solveQuarticReal(c[0], c[1], c[2], c[3], c[4], branch);
5691 std::vector<double> roots(found.begin(), found.end());
5692 std::sort(roots.begin(), roots.end());
5693 return roots;
5694}
5695
5697void checkRootsAreRoots(const std::array<double, 5>& c, const std::vector<double>& roots)
5698{
5699 for (const double root : roots) {
5700 BOOST_CHECK_LT(quarticBackwardError(c, root), 1.e-12);
5701 }
5702}
5703} // namespace
5704
5705BOOST_AUTO_TEST_CASE(StreamM_QuarticFindsTheALICE3ProductionScaleRoots)
5706{
5707 // ALICE3 part ST2487462_01, face 47: a torus of R = 5.3 cm and
5708 // r = 0.1 cm, hit by a ray whose origin is 375 cm away. The crossing lies on the untrimmed
5709 // surface to 1.7e-14 cm and inside both parameter windows -- the patch is there and the trim
5710 // admits it -- and the solver returned nothing.
5711 //
5712 // It is a *biquadratic* (the ray is perpendicular to the torus axis, so the true termQ is 0),
5713 // but termQ is evaluated as d - b*c/2 + b^3/8 from terms of magnitude ~1e8 and cancels to
5714 // -5.96e-08 rather than to 0. That is above the absolute 1e-9 test, so the quartic was routed
5715 // into the resolvent branch, whose resolvent is 7.1e-15 and fails its own absolute test.
5716 const std::array<double, 5> c{1.0, -1501.7280000044018, 845808.25396968238, -211752288.545858,
5717 19882619385.616932};
5718
5719 // The reference roots are Newton's method run to convergence on the *exact* binary values of
5720 // those five coefficients in 60-digit decimal arithmetic, so they are the truth for this input
5721 // and not another double-precision solve.
5722 const double firstRoot = 375.3392295779947145;
5723 const double secondRoot = 375.5247704240909448;
5724
5725 // The tolerance is not arbitrary. p'(firstRoot) = -14.47, so one ulp of a0 (3.8e-06 at 1.99e10)
5726 // moves this root by 2.6e-07 cm: the input coefficients do not determine the roots better than
5727 // that. 1e-06 cm is a few times the conditioning limit and four orders below the 0.1 cm tube
5728 // whose crossing this is.
5729 surf::QuarticBranch branch = surf::QuarticBranch::NotAQuartic;
5730 const auto roots = sortedRoots(c, &branch);
5731 BOOST_REQUIRE_EQUAL(roots.size(), 2u);
5732 checkClose(roots[0], firstRoot, 1.e-6);
5733 checkClose(roots[1], secondRoot, 1.e-6);
5734 checkRootsAreRoots(c, roots);
5735
5736 // and it must get there by recognising the biquadratic, not by luck in the resolvent branch
5737 BOOST_CHECK(branch == surf::QuarticBranch::Biquadratic);
5738}
5739
5740BOOST_AUTO_TEST_CASE(StreamM_QuarticIsScaleInvariantOnAnAsymmetricQuartic)
5741{
5742 // A thoroughly well-conditioned quartic with four simple real roots {1, 2, 3, 7}, uniformly
5743 // scaled. Ferrari's method is exactly scale-covariant, so every one of these must return four
5744 // roots at k times the reference ones -- there is no numerical excuse anywhere in this sweep.
5745 //
5746 // Before the repair this collapsed at k = 1e-04, where |termQ| = 5.6e-10 falls under the
5747 // absolute 1e-09 test: the solver takes the biquadratic branch on a quartic that is not
5748 // biquadratic and returns *two* roots, 6.5093e-04 and -9.3257e-07, instead of four.
5749 for (const double k : {1.e6, 1.e3, 1., 1.e-1, 1.e-2, 1.e-3, 1.e-4, 1.e-5, 1.e-6, 1.e-8}) {
5750 const auto c = quarticFromRoots(1. * k, 2. * k, 3. * k, 7. * k);
5751 surf::QuarticBranch branch = surf::QuarticBranch::NotAQuartic;
5752 const auto roots = sortedRoots(c, &branch);
5753 BOOST_REQUIRE_EQUAL(roots.size(), 4u);
5754 const double expected[4] = {1. * k, 2. * k, 3. * k, 7. * k};
5755 for (int i = 0; i < 4; ++i) {
5756 BOOST_CHECK_LT(std::abs(roots[i] - expected[i]), 1.e-9 * std::abs(expected[i]));
5757 }
5758 checkRootsAreRoots(c, roots);
5759 // The other half of the positive control: an asymmetric quartic must NOT be routed into the
5760 // biquadratic branch at any scale. A termQ test that always passed would fail here.
5761 BOOST_CHECK(branch == surf::QuarticBranch::Resolvent);
5762 }
5763
5764 // The same statement made about accuracy rather than about the branch, on the family that
5765 // produced "two confidently wrong roots": at k = 1e-04 the shipped code returns four roots for
5766 // {-2, -1, 1, 2.1} * k that are wrong by 1.3 % (relative backward error 1.6e-02), because the
5767 // biquadratic branch forces them to be symmetric about -b/4 and they are not.
5768 for (const double k : {1., 1.e-2, 1.e-4, 1.e-6}) {
5769 const auto c = quarticFromRoots(-2. * k, -1. * k, 1. * k, 2.1 * k);
5770 const auto roots = sortedRoots(c);
5771 BOOST_REQUIRE_EQUAL(roots.size(), 4u);
5772 const double expected[4] = {-2. * k, -1. * k, 1. * k, 2.1 * k};
5773 for (int i = 0; i < 4; ++i) {
5774 BOOST_CHECK_LT(std::abs(roots[i] - expected[i]), 1.e-9 * std::abs(expected[i]));
5775 }
5776 checkRootsAreRoots(c, roots);
5777 }
5778}
5779
5780BOOST_AUTO_TEST_CASE(StreamM_QuarticStillSelectsTheBiquadraticBranch)
5781{
5782 // Positive control, first direction. The biquadratic branch is not a fallback: it is the
5783 // correct, better-conditioned answer whenever the depressed quartic really has no odd term, and
5784 // a repair that simply widened its guard into irrelevance would be caught by the previous case
5785 // while a repair that narrowed it away would be caught here.
5786 {
5787 // y^4 - 5 y^2 + 4, roots +-1, +-2; termQ is exactly zero
5788 const std::array<double, 5> c{1., 0., -5., 0., 4.};
5789 surf::QuarticBranch branch = surf::QuarticBranch::NotAQuartic;
5790 const auto roots = sortedRoots(c, &branch);
5791 BOOST_CHECK(branch == surf::QuarticBranch::Biquadratic);
5792 BOOST_REQUIRE_EQUAL(roots.size(), 4u);
5793 const double expected[4] = {-2., -1., 1., 2.};
5794 for (int i = 0; i < 4; ++i) {
5795 checkClose(roots[i], expected[i], 1.e-12);
5796 }
5797 }
5798 // The same quartic shifted along x and scaled, which is what a torus at a lever arm produces:
5799 // termQ is zero in exact arithmetic but is computed by cancelling terms of size |b|^3, so the
5800 // criterion has to be relative to those terms rather than to a fixed length.
5801 // The centres stop at 100. Beyond that the *depression* step -- p, q, r from b, c, d, e -- is
5802 // the limit, not the guards: it cancels numbers of size (centre)^k to leave numbers of size
5803 // (spread)^k, so a quartic whose roots agree to 4 significant figures has lost 8 digits before
5804 // any branch is chosen and Ferrari's discriminants become noise. Measured on this family, with
5805 // rounded coefficients, both before and after this change: relative root spread 2e-01 gives
5806 // 3.9e-14, 2e-02 gives 6.2e-11, 2e-03 gives 9.3e-08, and 2e-04 returns no roots at all. It is a
5807 // property of Ferrari's method and is not hidden behind a looser tolerance here.
5808 for (const double centre : {0., 1., 100.}) {
5809 for (const double k : {1., 1.e-3, 1.e3}) {
5810 const auto c = quarticFromRoots((centre - 2.) * k, (centre - 1.) * k, (centre + 1.) * k,
5811 (centre + 2.) * k);
5812 surf::QuarticBranch branch = surf::QuarticBranch::NotAQuartic;
5813 const auto roots = sortedRoots(c, &branch);
5814 BOOST_CHECK(branch == surf::QuarticBranch::Biquadratic);
5815 BOOST_REQUIRE_EQUAL(roots.size(), 4u);
5816 const double expected[4] = {(centre - 2.) * k, (centre - 1.) * k, (centre + 1.) * k,
5817 (centre + 2.) * k};
5818 for (int i = 0; i < 4; ++i) {
5819 BOOST_CHECK_LT(std::abs(roots[i] - expected[i]), 1.e-9 * std::max(1.e-30, std::abs(expected[i])));
5820 }
5821 checkRootsAreRoots(c, roots);
5822 }
5823 }
5824}
5825
5826BOOST_AUTO_TEST_CASE(StreamM_QuarticStillDeclinesDegenerateConfigurations)
5827{
5828 // Positive control, second direction. A guard that always passes is not a fix. Each of these
5829 // must still produce no roots, in both branches, at every scale -- "declines" has to survive
5830 // the repair as surely as "accepts" does.
5831 {
5832 // not a genuine quartic at all
5833 surf::QuarticBranch branch = surf::QuarticBranch::Biquadratic;
5834 const auto roots = surf::solveQuarticReal(0., 1., 2., 3., 4., &branch);
5835 BOOST_CHECK_EQUAL(roots.size(), 0u);
5836 BOOST_CHECK(branch == surf::QuarticBranch::NotAQuartic);
5837 }
5838 for (const double k : {1.e4, 1., 1.e-4, 1.e-8}) {
5839 // (x^2 + k^2)(x^2 + 4 k^2): no real roots, termQ = 0 -> the biquadratic branch must decline
5840 const double k2 = k * k;
5841 const std::array<double, 5> biquadratic{1., 0., 5. * k2, 0., 4. * k2 * k2};
5842 surf::QuarticBranch branch = surf::QuarticBranch::NotAQuartic;
5843 BOOST_CHECK_EQUAL(sortedRoots(biquadratic, &branch).size(), 0u);
5844 BOOST_CHECK(branch == surf::QuarticBranch::Biquadratic);
5845
5846 // (x^2 + k^2)((x + k)^2 + 4 k^2): no real roots, termQ != 0 -> the resolvent branch must
5847 // decline, by finding both of Ferrari's quadratics complex rather than by refusing to run
5848 const std::array<double, 5> asymmetric{1., 2. * k, 6. * k2, 2. * k2 * k, 5. * k2 * k2};
5849 BOOST_CHECK_EQUAL(sortedRoots(asymmetric, &branch).size(), 0u);
5850 BOOST_CHECK(branch == surf::QuarticBranch::Resolvent);
5851 }
5852}
5853
5854BOOST_AUTO_TEST_CASE(StreamM_QuarticHasNoCliffAsAQuarticApproachesBiquadratic)
5855{
5856 // The defect is a *cliff*: an absolute threshold crossed by a quantity that carries units, so
5857 // the answer changes discontinuously with the size of the geometry. The repair has to be
5858 // continuous instead -- as termQ is driven to zero the two branches must agree, because they
5859 // are the two sides of one limit.
5860 //
5861 // {-2, -1, 1, 2 + delta} scaled by k: at delta = 0 the quartic is exactly biquadratic, and
5862 // delta walks it away from that continuously. Every point must give four correct roots.
5863 for (const double k : {1., 1.e-2, 1.e-4, 1.e-6}) {
5864 for (const double delta : {1.e-1, 1.e-3, 1.e-6, 1.e-9, 1.e-12, 0.}) {
5865 const auto c = quarticFromRoots(-2. * k, -1. * k, 1. * k, (2. + delta) * k);
5866 const auto roots = sortedRoots(c);
5867 BOOST_REQUIRE_EQUAL(roots.size(), 4u);
5868 const double expected[4] = {-2. * k, -1. * k, 1. * k, (2. + delta) * k};
5869 for (int i = 0; i < 4; ++i) {
5870 BOOST_CHECK_LT(std::abs(roots[i] - expected[i]), 1.e-8 * std::abs(expected[i]));
5871 }
5872 checkRootsAreRoots(c, roots);
5873 }
5874 }
5875}
5876// --- Tier 0: canonical recognition of NURBS-encoded quadrics
5877//
5878// The recognition work itself is entirely converter-side and its own controls live in
5879// `O2_CADtoTGeo.py --self-test` (18 checks: a NurbsConvert-ed quadric of each kind must be
5880// recovered, a genuine free-form patch must not, and every accepted face's MEASURED gap must be
5881// inside the acceptance tolerance). Nothing of that can be asserted from C++.
5882//
5883// What *can* be asserted here, and matters more than it looks, is the kernel-side contract the
5884// converter measures against. `_recognized_inner_wall()` decides a recognized quadric's
5885// `inner_wall` flag by comparing the face's own outward normal with "away from the axis", because
5886// on a NURBS-encoded quadric `TopoDS` orientation says nothing (on ALICE3: nine
5887// ALICE3 faces with an exactly antiparallel outward normal, 404 lost crossings, and every closure
5888// and edge-identity check blind to it because they are all sign-blind). That measurement is only
5889// correct if the kernel's own convention is the one it assumes. This work multiplies the number
5890// of faces going through that path, so the convention is pinned rather than assumed: if it were
5891// ever inverted, every recognized face would silently flip and no existing test would notice.
5892BOOST_AUTO_TEST_CASE(StreamK_InnerWallIsExactlyTheSignOfTheOutwardNormal)
5893{
5894 const Point3D centre{0.3, -0.7, 1.1};
5895 const Point3D axis{0., 0., 1.};
5896 const Point3D refU{1., 0., 0.};
5897 constexpr double radius = 2.5;
5898 constexpr double phi = 0.9;
5899
5900 // A point on each surface, and the direction "away from the axis / centre" there.
5901 const double cx = centre[0] + radius * std::cos(phi);
5902 const double cy = centre[1] + radius * std::sin(phi);
5903 const Double_t onCylinder[3] = {cx, cy, centre[2] + 0.4};
5904 const Double_t awayFromAxis[3] = {std::cos(phi), std::sin(phi), 0.};
5905
5906 for (const bool innerWall : {false, true}) {
5907 SurfaceSolid solid(innerWall ? "streamK_innerCyl" : "streamK_outerCyl");
5908 BOOST_REQUIRE(solid.AddCylindricalSurface(centre, axis, refU, radius, -1., 1., 0., surf::kTwoPi, innerWall));
5909 Double_t n[3] = {0., 0., 0.};
5910 solid.ComputeNormal(onCylinder, nullptr, n);
5911 const double alignment = n[0] * awayFromAxis[0] + n[1] * awayFromAxis[1] + n[2] * awayFromAxis[2];
5912 // Exactly +1 or exactly -1: this is a sign, not a tolerance.
5913 BOOST_CHECK_CLOSE(alignment, innerWall ? -1. : 1., 1.e-9);
5914 }
5915
5916 // The same convention on the cone and on the sphere. All three go through the converter's one
5917 // `_recognized_inner_wall` measurement, and ALICE3 exercises only the cylinder branch today
5918 // (recognized planes and spheres are untested there), so the
5919 // other two are pinned here rather than left to the first model that uses them.
5920 for (const bool innerWall : {false, true}) {
5921 SurfaceSolid solid(innerWall ? "streamK_innerCone" : "streamK_outerCone");
5922 // r(h) = 1 + h over h in [0, 2]: half-angle 45 degrees, apex at h = -1.
5923 BOOST_REQUIRE(solid.AddConicalSurface(centre, axis, refU, 1., 3., 0., 2., 0., surf::kTwoPi, innerWall));
5924 const double h = 1.0;
5925 const double r = 2.0;
5926 const Double_t onCone[3] = {centre[0] + r * std::cos(phi), centre[1] + r * std::sin(phi), centre[2] + h};
5927 Double_t n[3] = {0., 0., 0.};
5928 solid.ComputeNormal(onCone, nullptr, n);
5929 // The cone's outward normal tilts out of the radial direction by the half angle, so the
5930 // radial component is what carries the sign -- which is exactly the reasoning
5931 // `_recognized_inner_wall` relies on, and the reason it can use the radial direction alone.
5932 const double radial = n[0] * std::cos(phi) + n[1] * std::sin(phi);
5933 BOOST_CHECK_GT(innerWall ? -radial : radial, 0.5);
5934 }
5935
5936 for (const bool innerWall : {false, true}) {
5937 SurfaceSolid solid(innerWall ? "streamK_innerSph" : "streamK_outerSph");
5938 BOOST_REQUIRE(solid.AddSphericalSurface(centre, axis, refU, radius, 0., surf::kPi, 0., surf::kTwoPi, innerWall));
5939 const double theta = 1.1;
5940 const Double_t onSphere[3] = {centre[0] + radius * std::sin(theta) * std::cos(phi),
5941 centre[1] + radius * std::sin(theta) * std::sin(phi),
5942 centre[2] + radius * std::cos(theta)};
5943 const double outward[3] = {std::sin(theta) * std::cos(phi), std::sin(theta) * std::sin(phi), std::cos(theta)};
5944 Double_t n[3] = {0., 0., 0.};
5945 solid.ComputeNormal(onSphere, nullptr, n);
5946 const double alignment = n[0] * outward[0] + n[1] * outward[1] + n[2] * outward[2];
5947 BOOST_CHECK_CLOSE(alignment, innerWall ? -1. : 1., 1.e-9);
5948 }
5949}
5950// --- Placed primitives ---
5951//
5952// A recognised primitive whose frame is not the identity used to be emitted as a degenerate
5953// TGeoCompositeShape -- the primitive unioned with an identical copy of itself under the same
5954// matrix -- because no TGeoShape in ROOT 6.36 carries a rigid transform. That is still true of
5955// ROOT; what changed is where the transform lives. The shape is now written in its OWN canonical
5956// frame and the transform travels beside it, as a TGeoHMatrix under the key "placement" in
5957// shape_<part>.root. These cases pin the three things that can go wrong with that:
5958//
5959// 1. the artefact: the placement must survive the round trip, and its ABSENCE must keep meaning
5960// the identity, so that every file written before this convention still loads and still
5961// scores exactly as it did;
5962// 2. the equivalence: the bare primitive queried in its own frame must answer *exactly* like
5963// the composite it replaces, with a negative control that moves the count;
5964// 3. the composition order in geom.C -- `partPlacement * shapePlacement`. That one is silent
5965// when wrong: the geometry still builds and the shape is still the right shape, it is simply
5966// somewhere else. It is checked by navigating, with a transposed rotation and a reversed
5967// product as the controls.
5968
5969namespace
5970{
5973TGeoCombiTrans* makeStreamNPlacement()
5974{
5975 auto* rotation = new TGeoRotation("streamNRot", 0., 0., 0.);
5976 rotation->RotateX(30.);
5977 rotation->RotateZ(17.);
5978 rotation->RotateY(-41.);
5979 return new TGeoCombiTrans(0.3, 5.916, 2.0, rotation);
5980}
5981} // namespace
5982
5983BOOST_AUTO_TEST_CASE(ShapeSidecarRoundTripsAPlacement)
5984{
5985 namespace harness = o2::cad::harness;
5986 const auto dir = std::filesystem::temp_directory_path();
5987 const std::string path = (dir / "o2_shape_placed.root").string();
5988
5989 const TGeoTube tube("shape", 0.4, 1.0, 5.0);
5990 std::unique_ptr<TGeoCombiTrans> placement(makeStreamNPlacement());
5991
5992 std::string error;
5993 BOOST_REQUIRE_MESSAGE(harness::saveShapeToRootFile(path, tube, placement.get(), &error), error);
5994
5995 std::unique_ptr<TGeoShape> loaded(harness::loadShapeFromRootFile(path, &error));
5996 BOOST_REQUIRE_MESSAGE(loaded != nullptr, error);
5997 BOOST_CHECK_EQUAL(std::string(loaded->ClassName()), std::string("TGeoTube"));
5998
5999 std::unique_ptr<TGeoHMatrix> back(harness::loadShapePlacementFromRootFile(path));
6000 BOOST_REQUIRE(back != nullptr);
6001 for (int i = 0; i < 9; ++i) {
6002 BOOST_CHECK_EQUAL(back->GetRotationMatrix()[i], placement->GetRotationMatrix()[i]);
6003 }
6004 for (int i = 0; i < 3; ++i) {
6005 BOOST_CHECK_EQUAL(back->GetTranslation()[i], placement->GetTranslation()[i]);
6006 }
6007 // The point of storing it: a point of the part frame reaches the same place through the file as
6008 // through the original matrix.
6009 const Point3D master{0.9, 6.2, 3.1};
6010 Point3D viaFile{};
6011 Point3D viaOriginal{};
6012 back->MasterToLocal(master.data(), viaFile.data());
6013 placement->MasterToLocal(master.data(), viaOriginal.data());
6014 for (int i = 0; i < 3; ++i) {
6015 BOOST_CHECK_EQUAL(viaFile[i], viaOriginal[i]);
6016 }
6017 std::filesystem::remove(path);
6018}
6019
6020BOOST_AUTO_TEST_CASE(AbsentPlacementMeansIdentity)
6021{
6022 namespace harness = o2::cad::harness;
6023 const auto dir = std::filesystem::temp_directory_path();
6024 const TGeoTube tube("shape", 0.4, 1.0, 5.0);
6025 std::string error;
6026
6027 // 1. The historical two-argument overload -- the one every existing shape_*.root was written
6028 // with -- must record no placement at all.
6029 const std::string legacy = (dir / "o2_shape_legacy.root").string();
6030 BOOST_REQUIRE_MESSAGE(harness::saveShapeToRootFile(legacy, tube, &error), error);
6031 BOOST_CHECK(harness::loadShapePlacementFromRootFile(legacy) == nullptr);
6032
6033 // 2. An identity placement is deliberately NOT written, so that "no key" stays the one and only
6034 // spelling of the identity.
6035 const std::string identity = (dir / "o2_shape_identity.root").string();
6036 TGeoHMatrix unit("unit");
6037 BOOST_REQUIRE_MESSAGE(harness::saveShapeToRootFile(identity, tube, &unit, &error), error);
6038 BOOST_CHECK(harness::loadShapePlacementFromRootFile(identity) == nullptr);
6039
6040 // 3. A file that is not there is the same answer, and must not throw or complain: a part with
6041 // no shape sidecar at all is the overwhelmingly common case.
6042 BOOST_CHECK(harness::loadShapePlacementFromRootFile((dir / "o2_shape_nothing.root").string()) ==
6043 nullptr);
6044
6045 std::filesystem::remove(legacy);
6046 std::filesystem::remove(identity);
6047}
6048
6049BOOST_AUTO_TEST_CASE(PlacedPrimitiveAnswersExactlyLikeTheSelfUnionComposite)
6050{
6051 // The equivalence the change rests on: the bare primitive queried in its own frame answers like
6052 // the composite it replaces, on all four scored queries, exactly.
6053 constexpr double kRmin = 0.4;
6054 constexpr double kRmax = 1.0;
6055 constexpr double kDz = 5.0;
6056
6057 std::unique_ptr<TGeoCombiTrans> placement(makeStreamNPlacement());
6058 const TGeoTube placedPrimitive("streamNTube", kRmin, kRmax, kDz);
6059 // The old emission, built here so the two are compared rather than one being trusted.
6060 std::unique_ptr<TGeoCompositeShape> composite(
6061 makePlacedTube("streamNComposite", kRmin, kRmax, kDz, new TGeoCombiTrans(*placement),
6062 new TGeoCombiTrans(*placement)));
6063
6064 std::size_t probes = 0;
6065 std::size_t inside = 0;
6066 std::size_t disagreements = 0;
6067 // The negative control travels with the check: the same loop against a 5% fatter tube must
6068 // disagree, or the loop is not measuring anything.
6069 const TGeoTube wrong("streamNWrong", kRmin, kRmax * 1.05, kDz);
6070 std::size_t controlDisagreements = 0;
6071
6072 for (int ix = -8; ix <= 8; ++ix) {
6073 for (int iy = -8; iy <= 8; ++iy) {
6074 for (int iz = -8; iz <= 8; ++iz) {
6075 const Point3D master{0.3 + 0.37 * ix, 5.916 + 0.41 * iy, 2.0 + 0.43 * iz};
6076 Point3D local{};
6077 placement->MasterToLocal(master.data(), local.data());
6078 const double r = std::hypot(local[0], local[1]);
6079 if (std::fabs(r - kRmin) < 1.e-9 || std::fabs(r - kRmax) < 1.e-9 ||
6080 std::fabs(r - kRmax * 1.05) < 1.e-9 || std::fabs(std::fabs(local[2]) - kDz) < 1.e-9) {
6081 continue;
6082 }
6083 ++probes;
6084 const bool wanted = composite->Contains(master.data());
6085 if (placedPrimitive.Contains(local.data()) != wanted) {
6086 ++disagreements;
6087 }
6088 if (wrong.Contains(local.data()) != wanted) {
6089 ++controlDisagreements;
6090 }
6091 if (wanted) {
6092 ++inside;
6093 }
6094 BOOST_REQUIRE_CLOSE_FRACTION(placedPrimitive.Safety(local.data(), wanted),
6095 composite->Safety(master.data(), wanted), 1.e-12);
6096 for (const auto& dir : {Point3D{1., 0., 0.}, Point3D{0., 1., 0.}, Point3D{0., 0., 1.},
6097 Point3D{0.5773502691896258, 0.5773502691896258,
6098 0.5773502691896258}}) {
6099 Point3D localDir{};
6100 placement->MasterToLocalVect(dir.data(), localDir.data());
6101 if (wanted) {
6102 BOOST_REQUIRE_CLOSE_FRACTION(placedPrimitive.DistFromInside(local.data(),
6103 localDir.data(), 3),
6104 composite->DistFromInside(master.data(), dir.data(), 3),
6105 1.e-12);
6106 } else {
6107 const double got = placedPrimitive.DistFromOutside(local.data(), localDir.data(), 3);
6108 const double want = composite->DistFromOutside(master.data(), dir.data(), 3);
6109 if (want > 1.e20) {
6110 BOOST_REQUIRE_GT(got, 1.e20);
6111 } else {
6112 BOOST_REQUIRE_CLOSE_FRACTION(got, want, 1.e-12);
6113 }
6114 }
6115 }
6116 }
6117 }
6118 }
6119 BOOST_CHECK_EQUAL(disagreements, 0u);
6120 BOOST_CHECK_GT(controlDisagreements, 0u);
6121 BOOST_CHECK_GT(inside, 100u);
6122 BOOST_CHECK_GT(probes, 3000u);
6123}
6124
6125BOOST_AUTO_TEST_CASE(PlacedPrimitiveRecoversTheAnalyticCapacity)
6126{
6127 // What the degenerate composite cost, stated as a measurement rather than as a claim.
6128 constexpr double kRmin = 0.4;
6129 constexpr double kRmax = 1.0;
6130 constexpr double kDz = 5.0;
6131 const double analytic = TMath::Pi() * (kRmax * kRmax - kRmin * kRmin) * 2. * kDz;
6132
6133 const TGeoTube tube("streamNCapTube", kRmin, kRmax, kDz);
6134 BOOST_CHECK_CLOSE_FRACTION(tube.Capacity(), analytic, 1.e-14);
6135 // Deterministic: asked twice, the same bits.
6136 BOOST_CHECK_EQUAL(tube.Capacity(), tube.Capacity());
6137
6138 std::unique_ptr<TGeoCombiTrans> placement(makeStreamNPlacement());
6139 std::unique_ptr<TGeoCompositeShape> composite(
6140 makePlacedTube("streamNCapComposite", kRmin, kRmax, kDz, new TGeoCombiTrans(*placement),
6141 new TGeoCombiTrans(*placement)));
6142 // ... whereas TGeoCompositeShape::Capacity() throws 10000 Monte-Carlo points into the bounding
6143 // box, so two calls on the same object return different numbers. That is the reason the gate
6144 // marks a composite `capacityComparable=false`, and the reason a placed primitive that is no
6145 // longer a composite gets its capacity column back.
6146 const double first = composite->Capacity();
6147 const double second = composite->Capacity();
6148 BOOST_CHECK_NE(first, second);
6149 BOOST_CHECK_GT(std::fabs(first - analytic) / analytic, 1.e-6);
6150}
6151
6152BOOST_AUTO_TEST_CASE(NodeMatrixIsPartPlacementTimesShapePlacement)
6153{
6154 // The composition geom.C emits, decided by NAVIGATION rather than by reading the code.
6155 //
6156 // The reference is built without ever forming the product: a point of the assembly frame is
6157 // carried into the part frame by the part placement, then into the shape's frame by the shape
6158 // placement, and the tube membership is evaluated there. If `partPlacement * shapePlacement` is
6159 // the right node matrix, ROOT's navigator must reach the same verdict for every point.
6160 constexpr double kRmin = 0.4;
6161 constexpr double kRmax = 1.0;
6162 constexpr double kDz = 5.0;
6163
6164 std::unique_ptr<TGeoCombiTrans> shapePlacementOwned(makeStreamNPlacement());
6165 const TGeoHMatrix shapePlacement(*shapePlacementOwned);
6166 auto* partRotation = new TGeoRotation("streamNPartRot", 37., 24., 61.);
6167 const TGeoCombiTrans partPlacement(-2.0, 7.0, 1.5, partRotation);
6168
6169 const auto reference = [&](const Point3D& master, bool& onWall) {
6170 Point3D partFrame{};
6171 Point3D shapeFrame{};
6172 partPlacement.MasterToLocal(master.data(), partFrame.data());
6173 shapePlacement.MasterToLocal(partFrame.data(), shapeFrame.data());
6174 const double r = std::hypot(shapeFrame[0], shapeFrame[1]);
6175 onWall = std::fabs(r - kRmin) < 1.e-9 || std::fabs(r - kRmax) < 1.e-9 ||
6176 std::fabs(std::fabs(shapeFrame[2]) - kDz) < 1.e-9;
6177 return r >= kRmin && r <= kRmax && std::fabs(shapeFrame[2]) <= kDz;
6178 };
6179
6180 // Every candidate node matrix, including the three ways of getting it wrong. `partOnly` is the
6181 // bug this test is really for: forgetting to compose at all.
6182 TGeoHMatrix correct(partPlacement);
6183 correct.Multiply(&shapePlacement);
6184 TGeoHMatrix reversed(shapePlacement);
6185 reversed.Multiply(&partPlacement);
6186 TGeoHMatrix transposedRotation(shapePlacement);
6187 {
6188 double rt[9];
6189 const double* r = shapePlacement.GetRotationMatrix();
6190 for (int i = 0; i < 3; ++i) {
6191 for (int j = 0; j < 3; ++j) {
6192 rt[3 * i + j] = r[3 * j + i];
6193 }
6194 }
6195 transposedRotation.SetRotation(rt);
6196 transposedRotation.SetBit(TGeoMatrix::kGeoRotation);
6197 }
6198 TGeoHMatrix withTransposed(partPlacement);
6199 withTransposed.Multiply(&transposedRotation);
6200 const TGeoHMatrix partOnly(partPlacement);
6201
6202 const std::vector<std::pair<std::string, const TGeoHMatrix*>> candidates{
6203 {"part*shape", &correct},
6204 {"shape*part", &reversed},
6205 {"part*shape^T", &withTransposed},
6206 {"part only", &partOnly}};
6207
6208 // The lattice is centred where the solid actually is -- the translation of the CORRECT node
6209 // matrix -- and spans more than the tube's own extent. Guessing the centre by adding the two
6210 // translations put every probe outside the solid, and the controls then reported zero
6211 // disagreements while being structurally incapable of reporting anything else.
6212 const double* centre = correct.GetTranslation();
6213
6214 std::vector<size_t> disagreements(candidates.size(), 0);
6215 size_t probes = 0;
6216 size_t insideProbes = 0;
6217
6218 for (size_t c = 0; c < candidates.size(); ++c) {
6219 // One manager per candidate, and everything inside it allocated with new: a TGeoShape and a
6220 // TGeoVolume register themselves with gGeoManager, which frees them.
6221 auto* manager = new TGeoManager(("streamN_" + std::to_string(c)).c_str(), "composition order");
6222 auto* material = new TGeoMaterial("Vacuum", 0., 0., 0.);
6223 auto* medium = new TGeoMedium("Vacuum", 1, material);
6224 auto* world = new TGeoVolume("TOP", new TGeoBBox("streamNWorld", 30., 30., 30.), medium);
6225 auto* part = new TGeoVolume("PART", new TGeoTube("streamNNodeTube", kRmin, kRmax, kDz), medium);
6226 world->AddNode(part, 1, new TGeoHMatrix(*candidates[c].second));
6227 manager->SetTopVolume(world);
6228 manager->CloseGeometry();
6229
6230 size_t localProbes = 0;
6231 size_t localInside = 0;
6232 for (int ix = -14; ix <= 14; ++ix) {
6233 for (int iy = -14; iy <= 14; ++iy) {
6234 for (int iz = -14; iz <= 14; ++iz) {
6235 const Point3D master{centre[0] + 0.45 * ix, centre[1] + 0.47 * iy,
6236 centre[2] + 0.43 * iz};
6237 bool onWall = false;
6238 const bool wanted = reference(master, onWall);
6239 if (onWall) {
6240 continue;
6241 }
6242 ++localProbes;
6243 if (wanted) {
6244 ++localInside;
6245 }
6246 TGeoNode* node = manager->FindNode(master[0], master[1], master[2]);
6247 const bool got = node != nullptr && std::string(node->GetVolume()->GetName()) == "PART";
6248 if (got != wanted) {
6249 ++disagreements[c];
6250 }
6251 }
6252 }
6253 }
6254 probes = localProbes;
6255 insideProbes = localInside;
6256 delete manager;
6257 gGeoManager = nullptr;
6258 }
6259
6260 // The sampling has to be capable of failing: enough points, and enough of them inside.
6261 BOOST_CHECK_GT(probes, 5000u);
6262 BOOST_CHECK_GT(insideProbes, 200u);
6263 BOOST_CHECK_EQUAL(disagreements[0], 0u); // partPlacement * shapePlacement
6264 BOOST_CHECK_GT(disagreements[1], 0u); // reversed product
6265 BOOST_CHECK_GT(disagreements[2], 0u); // transposed shape rotation
6266 BOOST_CHECK_GT(disagreements[3], 0u); // shape placement dropped
6267}
6268
6269// ============================================================================================
6270// The representation cost/memory benchmark's own instruments
6271// ============================================================================================
6272//
6273// These pin the MEASURING apparatus, not the geometry. A per-call cost table is only worth
6274// reading if the harness that produced it can be shown to move its number when the thing it
6275// measures moves. Each case below is that demonstration for one column of the benchmark.
6276//
6277// They include RepresentationBench.h -- the SAME header the benchmark binary times with.
6278
6279#include "RepresentationBench.h"
6280
6281using namespace o2::cad::bench;
6282
6284BOOST_AUTO_TEST_CASE(RepBenchTimingHarnessRunsTheRequestedPasses)
6285{
6286 TGeoBBox fast("repBenchFast", 1., 1., 1.);
6287 const o2::cad::harness::Point3D lo{-1., -1., -1.};
6288 const o2::cad::harness::Point3D hi{1., 1., 1.};
6289 const QuerySamples samples = buildQuerySamples(&fast, "control", lo, hi, 1500, 1500);
6290
6291 // The sample set has to be capable of exercising both branches, or three of the four kernels
6292 // are being timed on an empty vector.
6293 BOOST_CHECK_GT(samples.insidePoints, 100);
6294 BOOST_CHECK_LT(samples.insidePoints, static_cast<long long>(samples.points.size()) - 100);
6295 BOOST_CHECK_EQUAL(samples.outsideRays.size(), 1500u);
6296 BOOST_CHECK_EQUAL(samples.insideRays.size(), 1500u);
6297
6298 // And the loop must not have been optimised away: a non-zero checksum, a positive time, and
6299 // the requested number of passes actually run.
6300 const TimingStat stat = timeContainsPass(&fast, samples, 1, 5);
6301 BOOST_CHECK_NE(stat.checksum, 0u);
6302 BOOST_CHECK_GT(stat.medianNsPerCall, 0.);
6303 BOOST_CHECK_EQUAL(stat.passes, 5);
6304 BOOST_CHECK_LE(stat.minNsPerCall, stat.medianNsPerCall);
6305 BOOST_CHECK_LE(stat.medianNsPerCall, stat.maxNsPerCall);
6306}
6307
6312BOOST_AUTO_TEST_CASE(RepBenchSampleSetIsReproducibleAndActuallyHits)
6313{
6314 TGeoTube tube("repBenchTube", 0.3, 1., 2.);
6315 const o2::cad::harness::Point3D lo{-1., -1., -2.};
6316 const o2::cad::harness::Point3D hi{1., 1., 2.};
6317 const QuerySamples a = buildQuerySamples(&tube, "surface", lo, hi, 2000, 2000);
6318 const QuerySamples b = buildQuerySamples(&tube, "surface", lo, hi, 2000, 2000);
6319
6320 // Same seed, same bbox, same reference -> bit-identical. Without this the cost table's
6321 // "same sample set" claim is not checkable from outside.
6322 BOOST_REQUIRE_EQUAL(a.points.size(), b.points.size());
6323 for (size_t i = 0; i < a.points.size(); ++i) {
6324 BOOST_CHECK_EQUAL(a.points[i][0], b.points[i][0]);
6325 BOOST_CHECK_EQUAL(a.pointIsInside[i], b.pointIsInside[i]);
6326 BOOST_CHECK_EQUAL(a.pointIsInside[i] != 0, tube.Contains(a.points[i].data()));
6327 }
6328 BOOST_CHECK_GT(timeDistOutPass(&tube, a, 1, 3).hitFraction, 0.5);
6329 BOOST_CHECK_EQUAL(timeDistInPass(&tube, a, 1, 3).hitFraction, 1.);
6330}
6331
6336#ifdef __linux__
6337BOOST_AUTO_TEST_CASE(RepBenchMemoryProbeSeesAnAllocationAndItsRelease)
6338{
6339 const MemorySnapshot before = readMemory();
6340 constexpr size_t kBytes = 64u << 20;
6341 auto block = std::make_unique<char[]>(kBytes);
6342 for (size_t i = 0; i < kBytes; i += 4096) {
6343 block[i] = static_cast<char>(i);
6344 }
6345 const MemorySnapshot delta = readMemory() - before;
6346 BOOST_CHECK_GT(delta.residentBytes, 32LL << 20);
6347 BOOST_CHECK_GT(delta.heapInUseBytes, 32LL << 20);
6348 block.reset();
6349 BOOST_CHECK_LT((readMemory() - before).heapInUseBytes, 8LL << 20);
6350}
6351#endif
6352
6356BOOST_AUTO_TEST_CASE(RepBenchBooleanLadderHasTheStructureItClaims)
6357{
6358 auto* manager = new TGeoManager("repBenchLadder", "ladder");
6359 for (const int k : {2, 4, 8, 16, 32}) {
6360 const BooleanTreeStats chain =
6361 booleanTreeStats(buildBooleanLadder(k, LadderShape::Chain, "tC" + std::to_string(k)));
6362 const BooleanTreeStats balanced =
6363 booleanTreeStats(buildBooleanLadder(k, LadderShape::Balanced, "tB" + std::to_string(k)));
6364 BOOST_CHECK_EQUAL(chain.leaves, k);
6365 BOOST_CHECK_EQUAL(balanced.leaves, k);
6366 BOOST_CHECK_EQUAL(chain.nodes, k - 1);
6367 BOOST_CHECK_EQUAL(balanced.nodes, k - 1);
6368 BOOST_CHECK_EQUAL(chain.depth, k);
6369 BOOST_CHECK_EQUAL(balanced.depth, 1 + static_cast<int>(std::lround(std::log2(k))));
6370 }
6371 // A single leaf is not a composite at all: the ladder must hand back the primitive rather than
6372 // a one-sided union, or the K=1 baseline row would be priced with boolean machinery.
6373 TGeoShape* single = buildBooleanLadder(1, LadderShape::Balanced, "tOne");
6374 BOOST_CHECK(dynamic_cast<TGeoCompositeShape*>(single) == nullptr);
6375 BOOST_CHECK_EQUAL(booleanTreeStats(single).leaves, 1);
6376 delete manager;
6377 gGeoManager = nullptr;
6378}
6379
6380// --- Representation cost/memory benchmark ---
6381
6382// --- BVH-accelerated Safety and ComputeNormal ---
6383//
6384// Safety() and ComputeNormal() answer the same question -- which trimmed patch is nearest to this
6385// point -- and both used to answer it with a bare loop over every patch, which on ALICE3's
6386// 965-patch solid cost 812 us per call. They now walk the
6387// BVH that was already there. The oracle is the loop they replaced, kept as Safety_Loop() /
6388// ComputeNormal_Loop(), and the contract against it is *exact equality*, not agreement to a
6389// tolerance: both minimise the same distanceSqToPatch over the same patches under the same
6390// tie-break, so any difference at all is a traversal or pruning bug.
6391
6392namespace
6393{
6394// The benchmark's deterministic LCG, seeded explicitly, so a failure is reproducible from the seed alone.
6395struct SampleStream {
6396 explicit SampleStream(std::uint64_t seed) { lcg.state = seed | 1u; }
6398 double uniform() { return lcg.next(); }
6399 double symmetric(double extent) { return (2. * uniform() - 1.) * extent; }
6400};
6401
6409std::vector<std::array<double, 3>> nearestPatchSample(const SurfaceSolid& solid, double extent, int count)
6410{
6411 SampleStream stream(0x5EAFE7Full);
6412 std::vector<std::array<double, 3>> points;
6413 points.reserve(static_cast<size_t>(count));
6414 for (int index = 0; index < count; ++index) {
6415 std::array<double, 3> point{stream.symmetric(extent), stream.symmetric(extent), stream.symmetric(extent)};
6416 switch (index % 5) {
6417 case 0: // wherever it landed: inside or outside, generic
6418 break;
6419 case 1: { // walked onto the surface along its own normal, i.e. distance ~ 0
6420 std::array<double, 3> normal{0., 0., 0.};
6421 const double safety = solid.Safety_Loop(point.data(), solid.Contains(point.data()));
6422 solid.ComputeNormal_Loop(point.data(), nullptr, normal.data());
6423 const double sign = solid.Contains(point.data()) ? 1. : -1.;
6424 for (int dimension = 0; dimension < 3; ++dimension) {
6425 point[dimension] += sign * safety * normal[dimension];
6426 }
6427 break;
6428 }
6429 case 2: { // a hair off the surface, at the scale where several patches compete
6430 std::array<double, 3> normal{0., 0., 0.};
6431 const double safety = solid.Safety_Loop(point.data(), solid.Contains(point.data()));
6432 solid.ComputeNormal_Loop(point.data(), nullptr, normal.data());
6433 const double sign = solid.Contains(point.data()) ? 1. : -1.;
6434 const double offset = safety - sign * 1.e-9 * std::max(1., extent);
6435 for (int dimension = 0; dimension < 3; ++dimension) {
6436 point[dimension] += sign * offset * normal[dimension];
6437 }
6438 break;
6439 }
6440 case 3: // well outside the bounding box
6441 for (auto& coordinate : point) {
6442 coordinate *= 40.;
6443 }
6444 break;
6445 case 4: // very far away, where the node bound is large and its rounding is worst
6446 for (auto& coordinate : point) {
6447 coordinate *= 1.e7;
6448 }
6449 break;
6450 }
6451 points.push_back(point);
6452 }
6453 // and the exact centre, where every face of a box is equidistant: the tie the tie-break decides
6454 points.push_back({0., 0., 0.});
6455 return points;
6456}
6457
6460int countNearestPatchDisagreements(const SurfaceSolid& solid, const std::array<double, 3>& point,
6461 double* worstSafetyGap = nullptr)
6462{
6463 int disagreements = 0;
6464 for (const bool inside : {true, false}) {
6465 const double accelerated = solid.Safety(point.data(), inside);
6466 const double reference = solid.Safety_Loop(point.data(), inside);
6467 if (accelerated != reference) {
6468 ++disagreements;
6469 }
6470 if (worstSafetyGap != nullptr) {
6471 *worstSafetyGap = std::max(*worstSafetyGap, accelerated - reference);
6472 }
6473 }
6474 // with and without a direction, since the direction flips the sign of the chosen patch's normal
6475 // and a wrong patch can hide behind that flip
6476 const std::array<double, 3> direction = unitDirection(0.37, -0.82, 0.44);
6477 for (const double* dir : {static_cast<const double*>(nullptr), direction.data()}) {
6478 std::array<double, 3> accelerated{0., 0., 0.};
6479 std::array<double, 3> reference{0., 0., 0.};
6480 solid.ComputeNormal(point.data(), dir, accelerated.data());
6481 solid.ComputeNormal_Loop(point.data(), dir, reference.data());
6482 for (int dimension = 0; dimension < 3; ++dimension) {
6483 if (accelerated[dimension] != reference[dimension]) {
6484 ++disagreements;
6485 }
6486 }
6487 }
6488 return disagreements;
6489}
6490
6491// A solid with many patches whose boxes overlap heavily: eight boxes on a line is the easy case
6492// for pruning, a shell of small boxes around a sphere is not.
6493std::unique_ptr<SurfaceSolid> makeManyPatchSolid(const char* name, int ringCount)
6494{
6495 auto solid = std::make_unique<SurfaceSolid>(name);
6496 for (int ring = 0; ring < ringCount; ++ring) {
6497 const double angle = surf::kTwoPi * ring / ringCount;
6498 addBoxSurfaces(*solid, 0.4, 0.4, 0.4, {3. * std::cos(angle), 3. * std::sin(angle), 0.});
6499 }
6500 solid->CloseShape();
6501 return solid;
6502}
6503
6504// A quarter of a circle of radius r about (cu, cv) in a parametric domain, as a rational quadratic
6505// B-spline -- the public-API twin of the kernel-level quarterCircleBSpline above.
6506BoundaryCurve quarterCircleBoundary(double cu, double cv, double r, double a0)
6507{
6508 const double a1 = a0 + surf::kHalfPi;
6509 const double aMid = 0.5 * (a0 + a1);
6510 std::vector<Point2D> poles{{cu + r * std::cos(a0), cv + r * std::sin(a0)},
6511 {cu + r * std::sqrt(2.) * std::cos(aMid), cv + r * std::sqrt(2.) * std::sin(aMid)},
6512 {cu + r * std::cos(a1), cv + r * std::sin(a1)}};
6513 return BoundaryCurve::makeBSpline(2, std::move(poles), {1., std::sqrt(0.5), 1.}, {0., 0., 0., 1., 1., 1.});
6514}
6515
6516// Four cylindrical windows cut by B-spline wires in (phi, h), plus two disks. Not a closed solid --
6517// it is not meant to be navigated -- but it is the *trim* family measured at 2-6 us per
6518// candidate patch, whose distanceSqToPatch walks a flattened polyline. That is where pruning has
6519// the most to save and where a wrong bound would cost the most, so the cross-check has to cover it.
6520std::unique_ptr<SurfaceSolid> makeWireTrimmedSolid(const char* name)
6521{
6522 auto solid = std::make_unique<SurfaceSolid>(name);
6523 for (int window = 0; window < 4; ++window) {
6524 const double centrePhi = surf::kHalfPi * window + 0.3;
6525 BOOST_REQUIRE(solid->AddCylindricalSurface(
6526 {0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
6527 {quarterCircleBoundary(centrePhi, 0., 0.5, 0.), quarterCircleBoundary(centrePhi, 0., 0.5, surf::kHalfPi),
6528 quarterCircleBoundary(centrePhi, 0., 0.5, surf::kPi),
6529 quarterCircleBoundary(centrePhi, 0., 0.5, 3. * surf::kHalfPi)}));
6530 }
6531 BOOST_REQUIRE(addDiskSurface(*solid, {0., 0., 1.}, {1., 0., 0.}, {0., 1., 0.}, 2.));
6532 BOOST_REQUIRE(addDiskSurface(*solid, {0., 0., -1.}, {1., 0., 0.}, {0., -1., 0.}, 2.));
6533 solid->CloseShape();
6534 return solid;
6535}
6536
6537struct NearestPatchFixture {
6538 std::unique_ptr<SurfaceSolid> solid;
6539 double extent;
6540};
6541
6542std::vector<NearestPatchFixture> nearestPatchFixtures()
6543{
6544 std::vector<NearestPatchFixture> fixtures;
6545 fixtures.push_back({makeBoxSolid("safetyBox", 1., 2., 3.), 4.});
6546 fixtures.push_back({makeTubeSolid("safetyTube", 0., 2., 3.), 4.});
6547 fixtures.push_back({makeTubeSolid("safetyHollowTube", 1., 2., 3.), 4.});
6548 fixtures.push_back({makeConeSolid("safetyCone", 2., 1., 3.), 4.});
6549 fixtures.push_back({makeSphereSolid("safetySphere", 2.5), 3.5});
6550 fixtures.push_back({makeTorusSolid("safetyTorus", 3., 1.), 4.5});
6551 fixtures.push_back({makeCapsuleSolid("safetyCapsule", 1., 1.5), 3.});
6552 fixtures.push_back({makeManyPatchSolid("safetyRing", 12), 4.5});
6553 fixtures.push_back({makeWireTrimmedSolid("safetyWireTrim"), 3.});
6554 return fixtures;
6555}
6556} // namespace
6557
6573BOOST_AUTO_TEST_CASE(StreamS_PatchDistanceIsNeverBelowTheDistanceToItsOwnBoundingBox)
6574{
6575 using surf::Vec2;
6576 using surf::Vec3;
6577 std::string error;
6578
6580 const std::vector<Vec2> rectangle{{0., 0.}, {2., 0.}, {2., 3.}, {0., 3.}};
6581 BOOST_REQUIRE(polygon.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.}, rectangle, {}, error));
6583 BOOST_REQUIRE(disk.initialize({0., 0., 0.5}, {1., 0., 0.}, {0., 1., 0.},
6584 {surf::Curve2D::makeCircle({0., 0.}, 1.5)}, {}, error));
6585 // a B-spline trim wire on a second planar face: the trim family found to dominate the
6586 // per-patch cost, and the one whose distanceSqToPatch walks a flattened polyline rather than a
6587 // closed form -- so the lower-bound claim has to hold for an approximated boundary too
6589 BOOST_REQUIRE(splineFace.initialize({0.2, -0.3, 1.1}, {1., 0., 0.}, {0., 1., 0.},
6590 {quarterCircleBSpline(0., 0., 1.2, 0.),
6591 quarterCircleBSpline(0., 0., 1.2, surf::kHalfPi),
6592 quarterCircleBSpline(0., 0., 1.2, surf::kPi),
6593 quarterCircleBSpline(0., 0., 1.2, 3. * surf::kHalfPi)},
6594 {}, error));
6596 BOOST_REQUIRE(cylinder.initialize({0.1, -0.2, 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0.3,
6597 1.7 * surf::kPi, false, error));
6599 BOOST_REQUIRE(cone.initialize({0., 0., 0.}, {0., 1., 0.}, {1., 0., 0.}, 2., 0.5, -1., 1., 0., 1.1 * surf::kPi,
6600 false, error));
6602 BOOST_REQUIRE(sphere.initialize({0.3, 0.4, -0.5}, {0., 0., 1.}, {1., 0., 0.}, 1.7, 0.2, 2.4, 0.,
6603 1.3 * surf::kPi, false, error));
6605 BOOST_REQUIRE(torus.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 3., 0.8, 0., 1.4 * surf::kPi, 0.,
6606 1.9 * surf::kPi, false, error));
6607
6608 const std::vector<const surf::BoundedSurface*> surfaces{&polygon, &disk, &splineFace,
6609 &cylinder, &cone, &sphere,
6610 &torus};
6611
6612 SampleStream stream(0xB0B0Dull);
6613 size_t checked = 0;
6614 for (const auto* surface : surfaces) {
6615 Vec3 lower{TGeoShape::Big(), TGeoShape::Big(), TGeoShape::Big()};
6616 Vec3 upper{-TGeoShape::Big(), -TGeoShape::Big(), -TGeoShape::Big()};
6617 surface->conservativeBounds(lower, upper);
6618 const double boxLower[3] = {lower.xCoord, lower.yCoord, lower.zCoord};
6619 const double boxUpper[3] = {upper.xCoord, upper.yCoord, upper.zCoord};
6620 // the box the BVH actually stores is this one inflated outward, which only lowers the bound
6621 for (int sample = 0; sample < 4000; ++sample) {
6622 const double scale = (sample % 4 == 3) ? 1.e6 : ((sample % 4 == 2) ? 20. : 5.);
6623 const Vec3 point{stream.symmetric(scale), stream.symmetric(scale), stream.symmetric(scale)};
6624 const double coordinates[3] = {point.xCoord, point.yCoord, point.zCoord};
6625 double boxDistanceSq = 0.;
6626 for (int dimension = 0; dimension < 3; ++dimension) {
6627 if (coordinates[dimension] < boxLower[dimension]) {
6628 const double gap = boxLower[dimension] - coordinates[dimension];
6629 boxDistanceSq += gap * gap;
6630 } else if (coordinates[dimension] > boxUpper[dimension]) {
6631 const double gap = coordinates[dimension] - boxUpper[dimension];
6632 boxDistanceSq += gap * gap;
6633 }
6634 }
6635 const double patchDistanceSq = surface->distanceSqToPatch(point);
6636 // the direction of this inequality is the whole safety argument; a tolerance would hide the
6637 // failure it exists to catch, so it is asserted with the same relative guard the traversal
6638 // itself applies (1e-12) and nothing more
6639 BOOST_REQUIRE_LE(boxDistanceSq * (1. - 1.e-12), patchDistanceSq);
6640 ++checked;
6641 }
6642 }
6643 BOOST_CHECK_EQUAL(checked, 7u * 4000u);
6644}
6645
6647BOOST_AUTO_TEST_CASE(StreamS_SafetyAndNormalAreIdenticalToTheAllSurfacesLoop)
6648{
6649 size_t comparedPoints = 0;
6650 double worstSafetyGap = -std::numeric_limits<double>::infinity();
6651 for (const auto& fixture : nearestPatchFixtures()) {
6652 BOOST_TEST_CONTEXT("fixture = " << fixture.solid->GetName())
6653 {
6654 BOOST_REQUIRE(fixture.solid->HasBVH());
6655 for (const auto& point : nearestPatchSample(*fixture.solid, fixture.extent, 2000)) {
6656 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
6657 {
6658 BOOST_REQUIRE_EQUAL(countNearestPatchDisagreements(*fixture.solid, point, &worstSafetyGap), 0);
6659 }
6660 ++comparedPoints;
6661 }
6662 }
6663 }
6664 BOOST_CHECK_EQUAL(comparedPoints, 9u * 2001u);
6665 // exact equality, so the gap is not merely non-positive but identically zero
6666 BOOST_CHECK_EQUAL(worstSafetyGap, 0.);
6667}
6668
6672BOOST_AUTO_TEST_CASE(StreamS_SafetyFallsBackBeforeCloseShapeAndOnAnEmptySolid)
6673{
6674 SurfaceSolid open("safetyBeforeClose");
6675 addBoxSurfaces(open, 1., 2., 3.);
6676 BOOST_REQUIRE(!open.HasBVH()); // CloseShape not called: no acceleration structure yet
6677 const std::array<double, 3> probe{0.3, -1.1, 2.2};
6678 BOOST_CHECK_EQUAL(open.Safety(probe.data(), kTRUE), open.Safety_Loop(probe.data(), kTRUE));
6679 std::array<double, 3> viaBVH{0., 0., 0.};
6680 std::array<double, 3> viaLoop{0., 0., 0.};
6681 open.ComputeNormal(probe.data(), nullptr, viaBVH.data());
6682 open.ComputeNormal_Loop(probe.data(), nullptr, viaLoop.data());
6683 BOOST_CHECK_EQUAL(viaBVH[0], viaLoop[0]);
6684 BOOST_CHECK_EQUAL(viaBVH[1], viaLoop[1]);
6685 BOOST_CHECK_EQUAL(viaBVH[2], viaLoop[2]);
6686
6687 SurfaceSolid empty("safetyEmpty");
6688 empty.CloseShape();
6689 BOOST_CHECK_EQUAL(empty.Safety(probe.data(), kTRUE), TGeoShape::Big());
6690 BOOST_CHECK_EQUAL(empty.Safety_Loop(probe.data(), kTRUE), TGeoShape::Big());
6691 empty.ComputeNormal(probe.data(), nullptr, viaBVH.data());
6692 empty.ComputeNormal_Loop(probe.data(), nullptr, viaLoop.data());
6693 BOOST_CHECK_EQUAL(viaBVH[0], viaLoop[0]);
6694}
6695
6705BOOST_AUTO_TEST_CASE(StreamS_ComputeNormalKeepsTheLowestIndexTieBreak)
6706{
6707 const auto box = makeBoxSolid("tieBreakBox", 2., 2., 2.);
6708 const std::array<double, 3> centre{0., 0., 0.};
6709 std::array<double, 3> viaBVH{0., 0., 0.};
6710 std::array<double, 3> viaLoop{0., 0., 0.};
6711 box->ComputeNormal(centre.data(), nullptr, viaBVH.data());
6712 box->ComputeNormal_Loop(centre.data(), nullptr, viaLoop.data());
6713 // all six faces are exactly 2 away, and the loop's strict `<` keeps the first of them
6714 BOOST_CHECK_EQUAL(box->Safety(centre.data(), kTRUE), box->Safety_Loop(centre.data(), kTRUE));
6715 BOOST_CHECK_EQUAL(viaBVH[0], viaLoop[0]);
6716 BOOST_CHECK_EQUAL(viaBVH[1], viaLoop[1]);
6717 BOOST_CHECK_EQUAL(viaBVH[2], viaLoop[2]);
6718 // ... and it is genuinely a tie, i.e. the case has something to protect
6719 BOOST_CHECK_EQUAL(std::abs(viaLoop[0]) + std::abs(viaLoop[1]) + std::abs(viaLoop[2]), 1.);
6720
6721 // the same on the axis of a hollow tube, where the inner wall and both caps compete
6722 const auto tube = makeTubeSolid("tieBreakTube", 1., 2., 1.);
6723 tube->ComputeNormal(centre.data(), nullptr, viaBVH.data());
6724 tube->ComputeNormal_Loop(centre.data(), nullptr, viaLoop.data());
6725 BOOST_CHECK_EQUAL(viaBVH[0], viaLoop[0]);
6726 BOOST_CHECK_EQUAL(viaBVH[1], viaLoop[1]);
6727 BOOST_CHECK_EQUAL(viaBVH[2], viaLoop[2]);
6728}
6729
6740BOOST_AUTO_TEST_CASE(StreamS_BreakingThePruningBoundIsCaught)
6741{
6742 BOOST_REQUIRE(!SurfaceSolid::GetSafetyBoundUnsoundForTest()); // sound by default
6743
6744 const auto fixtures = nearestPatchFixtures();
6745 size_t caughtOnFixtures = 0;
6746 size_t prunableFixtures = 0;
6747 size_t sabotagedDisagreements = 0;
6748 size_t safetyTooLarge = 0;
6749 size_t safetyTooSmall = 0;
6750 for (const auto& fixture : fixtures) {
6751 // With the sub-patch BVH every fixture here has something to prune: a multi-surface solid has
6752 // one leaf per cover box across its surfaces, and even a single full sphere or torus owns a
6753 // whole grid of cover-box leaves. (Before sub-patching, single-patch solids were a single
6754 // unprunable leaf and had to be excluded here; that blind spot is gone by construction.)
6755 ++prunableFixtures;
6756 const auto points = nearestPatchSample(*fixture.solid, fixture.extent, 400);
6757 size_t disagreementsHere = 0;
6758 SurfaceSolid::SetSafetyBoundUnsoundForTest(true);
6759 for (const auto& point : points) {
6760 const double sabotaged = fixture.solid->Safety(point.data(), kTRUE);
6761 SurfaceSolid::SetSafetyBoundUnsoundForTest(false);
6762 const double reference = fixture.solid->Safety_Loop(point.data(), kTRUE);
6763 SurfaceSolid::SetSafetyBoundUnsoundForTest(true);
6764 if (sabotaged != reference) {
6765 ++disagreementsHere;
6766 (sabotaged > reference) ? ++safetyTooLarge : ++safetyTooSmall;
6767 }
6768 disagreementsHere += static_cast<size_t>(countNearestPatchDisagreements(*fixture.solid, point));
6769 }
6770 SurfaceSolid::SetSafetyBoundUnsoundForTest(false);
6771 sabotagedDisagreements += disagreementsHere;
6772 if (disagreementsHere > 0) {
6773 ++caughtOnFixtures;
6774 BOOST_TEST_MESSAGE("sabotaged bound caught on " << fixture.solid->GetName() << ": " << disagreementsHere
6775 << " disagreements over " << points.size() << " points");
6776 } else {
6777 BOOST_TEST_MESSAGE("sabotaged bound NOT caught on " << fixture.solid->GetName() << " ("
6778 << fixture.solid->GetNsurfaces() << " patches)");
6779 }
6780 }
6781
6782 // every fixture is sensitive to the sabotage, not just one lucky one
6783 BOOST_CHECK_EQUAL(caughtOnFixtures, prunableFixtures);
6784 BOOST_CHECK_EQUAL(prunableFixtures, fixtures.size());
6785 BOOST_CHECK_GT(sabotagedDisagreements, 100u);
6786 // and it fails the dangerous way: too much safety, never too little
6787 BOOST_CHECK_GT(safetyTooLarge, 0u);
6788 BOOST_CHECK_EQUAL(safetyTooSmall, 0u);
6789
6790 // with the sabotage off again the same sample is clean, so the disagreements above are the
6791 // sabotage and not the fixtures
6792 for (const auto& fixture : fixtures) {
6793 for (const auto& point : nearestPatchSample(*fixture.solid, fixture.extent, 400)) {
6794 BOOST_REQUIRE_EQUAL(countNearestPatchDisagreements(*fixture.solid, point), 0);
6795 }
6796 }
6797 BOOST_CHECK(!SurfaceSolid::GetSafetyBoundUnsoundForTest());
6798}
6799
6804BOOST_AUTO_TEST_CASE(StreamS_SafetyVisitsFarFewerPatchesThanTheLoop)
6805{
6806 const auto ring = makeManyPatchSolid("candidateRing", 24); // 24 boxes, 144 patches
6807 BOOST_REQUIRE_EQUAL(ring->GetNsurfaces(), 144);
6808 const auto points = nearestPatchSample(*ring, 5., 500);
6809
6810 SurfaceSolid::ResetSafetyCandidateCounter();
6811 for (const auto& point : points) {
6812 ring->Safety(point.data(), kTRUE);
6813 }
6814 const long long acceleratedCandidates = SurfaceSolid::GetSafetyCandidateCount();
6815 const double perCall = static_cast<double>(acceleratedCandidates) / points.size();
6816
6817 BOOST_TEST_MESSAGE("Safety candidates per call: " << perCall << " of " << ring->GetNsurfaces() << " patches");
6818 BOOST_CHECK_GT(acceleratedCandidates, 0);
6819 BOOST_CHECK_LT(perCall, 0.4 * ring->GetNsurfaces());
6820
6821 // the counter is not touched by the loop twin, which visits everything by construction
6822 SurfaceSolid::ResetSafetyCandidateCounter();
6823 for (const auto& point : points) {
6824 ring->Safety_Loop(point.data(), kTRUE);
6825 std::array<double, 3> normal{0., 0., 0.};
6826 ring->ComputeNormal_Loop(point.data(), nullptr, normal.data());
6827 }
6828 BOOST_CHECK_EQUAL(SurfaceSolid::GetSafetyCandidateCount(), 0);
6829
6830 // ComputeNormal prunes too, only slightly less: it may not drop a node whose bound ties the
6831 // current best, because such a node can hold an equally near patch of lower index
6832 SurfaceSolid::ResetSafetyCandidateCounter();
6833 for (const auto& point : points) {
6834 std::array<double, 3> normal{0., 0., 0.};
6835 ring->ComputeNormal(point.data(), nullptr, normal.data());
6836 }
6837 const double normalPerCall = static_cast<double>(SurfaceSolid::GetSafetyCandidateCount()) / points.size();
6838 BOOST_TEST_MESSAGE("ComputeNormal candidates per call: " << normalPerCall);
6839 BOOST_CHECK_LT(normalPerCall, 0.4 * ring->GetNsurfaces());
6840 BOOST_CHECK_GE(normalPerCall, perCall);
6841}
6842
6852
6853namespace
6854{
6855using CoverBox = surf::BoundedSurface::CoverBox;
6856
6857// Squared distance from a point to an axis-aligned box, zero inside; double throughout, so the
6858// test's bound carries no float rounding of its own.
6859double coverBoxDistanceSq(const CoverBox& box, const surf::Vec3& point)
6860{
6861 double distanceSq = 0.;
6862 const double coordinates[3] = {point.xCoord, point.yCoord, point.zCoord};
6863 const double lower[3] = {box.first.xCoord, box.first.yCoord, box.first.zCoord};
6864 const double upper[3] = {box.second.xCoord, box.second.yCoord, box.second.zCoord};
6865 for (int dimension = 0; dimension < 3; ++dimension) {
6866 const double gap = std::max({lower[dimension] - coordinates[dimension],
6867 coordinates[dimension] - upper[dimension], 0.});
6868 distanceSq += gap * gap;
6869 }
6870 return distanceSq;
6871}
6872
6873double minCoverBoxDistanceSq(const std::vector<CoverBox>& boxes, const surf::Vec3& point)
6874{
6875 double best = std::numeric_limits<double>::infinity();
6876 for (const auto& box : boxes) {
6877 best = std::min(best, coverBoxDistanceSq(box, point));
6878 }
6879 return best;
6880}
6881
6882bool anyCoverBoxContains(const std::vector<CoverBox>& boxes, const surf::Vec3& point, double slack)
6883{
6884 for (const auto& box : boxes) {
6885 if (point.xCoord >= box.first.xCoord - slack && point.xCoord <= box.second.xCoord + slack &&
6886 point.yCoord >= box.first.yCoord - slack && point.yCoord <= box.second.yCoord + slack &&
6887 point.zCoord >= box.first.zCoord - slack && point.zCoord <= box.second.zCoord + slack) {
6888 return true;
6889 }
6890 }
6891 return false;
6892}
6893
6894// The two properties every surface's cover boxes owe the traversal, checked against the surface's
6895// own kernels: (lower bound) the nearest cover box is never farther than distanceSqToPatch, which
6896// is what makes pruning on a box distance sound for Safety; (coverage) every point of the trimmed
6897// patch lies in some box, which is what makes a ray traversal that skips the other boxes complete.
6898void checkCoverBoxProperties(const surf::BoundedSurface& surface, const std::vector<surf::Vec3>& patchPoints,
6899 const char* label)
6900{
6901 std::vector<CoverBox> boxes;
6902 surface.appendCoverBoxes(boxes);
6903 BOOST_TEST_CONTEXT("surface = " << label)
6904 {
6905 BOOST_REQUIRE(!boxes.empty());
6906 for (const auto& point : patchPoints) {
6907 BOOST_TEST_CONTEXT("patch point = (" << point.xCoord << ", " << point.yCoord << ", " << point.zCoord << ")")
6908 {
6909 BOOST_CHECK(anyCoverBoxContains(boxes, point, 1.e-9));
6910 }
6911 }
6912 SampleStream stream(0xC0FEB0C5ull);
6913 // near the patch, a few radii out, and far away, so the bound is exercised where the box and
6914 // the patch nearly coincide and where the whole surface is a speck
6915 constexpr double kProbeScales[3] = {1.5, 8., 300.};
6916 for (int index = 0; index < 400; ++index) {
6917 const double scale = kProbeScales[index % 3];
6918 const surf::Vec3 point{stream.symmetric(scale), stream.symmetric(scale), stream.symmetric(scale)};
6919 const double patchDistanceSq = surface.distanceSqToPatch(point);
6920 const double boxDistanceSq = minCoverBoxDistanceSq(boxes, point);
6921 BOOST_TEST_CONTEXT("point = (" << point.xCoord << ", " << point.yCoord << ", " << point.zCoord << ")")
6922 {
6923 BOOST_CHECK_LE(boxDistanceSq, patchDistanceSq * (1. + 1.e-9) + 1.e-18);
6924 }
6925 }
6926 }
6927}
6928
6929// A curved family must actually sub-patch: one conservative box would satisfy both properties
6930// above and tighten nothing, which is the state this whole stream exists to leave behind.
6931void checkEmitsSeveralCoverBoxes(const surf::BoundedSurface& surface, const char* label)
6932{
6933 std::vector<CoverBox> boxes;
6934 surface.appendCoverBoxes(boxes);
6935 BOOST_TEST_CONTEXT("surface = " << label)
6936 {
6937 BOOST_CHECK_GT(boxes.size(), 1u);
6938 }
6939}
6940} // namespace
6941
6946BOOST_AUTO_TEST_CASE(StreamX_CoverBoxesAreATightLowerBoundEnvelopePerFamily)
6947{
6948 using surf::Vec3;
6949 std::string error;
6950
6951 const Vec3 skewCenter{0.4, -0.2, 0.1};
6952 const Vec3 skewAxis{0.2, 0.3, 1.};
6953 const Vec3 referenceU{1., 0., 0.};
6954
6955 {
6957 BOOST_REQUIRE(cylinder.initialize(skewCenter, skewAxis, referenceU, 1.7, -0.8, 1.2, 0.4, 1.9, false, error));
6958 std::vector<Vec3> patchPoints;
6959 for (int stepPhi = 0; stepPhi <= 12; ++stepPhi) {
6960 for (int stepH = 0; stepH <= 4; ++stepH) {
6961 patchPoints.push_back(cylinder.pointAt(0.4 + 1.9 * stepPhi / 12., -0.8 + 2. * stepH / 4.));
6962 }
6963 }
6964 checkCoverBoxProperties(cylinder, patchPoints, "partial cylinder, skew axis");
6965 checkEmitsSeveralCoverBoxes(cylinder, "partial cylinder, skew axis");
6966 }
6967 {
6969 BOOST_REQUIRE(cylinder.initialize({0., 0., 0.}, {0., 0., 1.}, referenceU, 2., -1., 1., 0., surf::kTwoPi,
6970 false, error));
6971 std::vector<Vec3> patchPoints;
6972 for (int stepPhi = 0; stepPhi <= 24; ++stepPhi) {
6973 patchPoints.push_back(cylinder.pointAt(surf::kTwoPi * stepPhi / 24., -1. + 2. * (stepPhi % 5) / 4.));
6974 }
6975 checkCoverBoxProperties(cylinder, patchPoints, "full cylinder");
6976 checkEmitsSeveralCoverBoxes(cylinder, "full cylinder");
6977 }
6978 {
6980 BOOST_REQUIRE(cylinder.initialize({0., 0., 0.}, {0., 0., 1.}, referenceU, 2., -1., 1., 0., surf::kTwoPi, false,
6981 paramRectWireCurves(0.3, 2.1, -0.5, 0.7), {}, error));
6982 std::vector<Vec3> patchPoints;
6983 for (int stepPhi = 0; stepPhi <= 10; ++stepPhi) {
6984 for (int stepH = 0; stepH <= 4; ++stepH) {
6985 const double phi = 0.3 + 1.8 * stepPhi / 10.;
6986 const double height = -0.5 + 1.2 * stepH / 4.;
6987 if (cylinder.pointInTrim(phi, height)) {
6988 patchPoints.push_back(cylinder.pointAt(phi, height));
6989 }
6990 }
6991 }
6992 BOOST_REQUIRE(!patchPoints.empty());
6993 checkCoverBoxProperties(cylinder, patchPoints, "wire-trimmed cylinder");
6994 }
6995 {
6997 BOOST_REQUIRE(sphere.initialize(skewCenter, skewAxis, referenceU, 2.5, 0., surf::kPi, 0., surf::kTwoPi,
6998 false, error));
6999 std::vector<Vec3> patchPoints;
7000 for (int stepTheta = 0; stepTheta <= 8; ++stepTheta) {
7001 for (int stepPhi = 0; stepPhi < 16; ++stepPhi) {
7002 patchPoints.push_back(sphere.pointAt(surf::kPi * stepTheta / 8., surf::kTwoPi * stepPhi / 16.));
7003 }
7004 }
7005 checkCoverBoxProperties(sphere, patchPoints, "full sphere, skew frame");
7006 checkEmitsSeveralCoverBoxes(sphere, "full sphere, skew frame");
7007 }
7008 {
7009 // a polar cap: distanceSqToPatch realises on the *whole* sphere (radial projection), so the
7010 // cover boxes must still cover the full ball surface, not merely the cap
7012 BOOST_REQUIRE(cap.initialize({0., 0., 0.}, {0., 0., 1.}, referenceU, 2., 0., 0.6, 0.2, 1.1, false, error));
7013 std::vector<Vec3> patchPoints;
7014 for (int stepTheta = 0; stepTheta <= 4; ++stepTheta) {
7015 for (int stepPhi = 0; stepPhi <= 6; ++stepPhi) {
7016 patchPoints.push_back(cap.pointAt(0.6 * stepTheta / 4., 0.2 + 1.1 * stepPhi / 6.));
7017 }
7018 }
7019 checkCoverBoxProperties(cap, patchPoints, "spherical cap");
7020 }
7021 {
7023 BOOST_REQUIRE(cone.initialize(skewCenter, skewAxis, referenceU, 2., 0.5, -0.9, 1.1, 0.7, 2.3, false, error));
7024 std::vector<Vec3> patchPoints;
7025 for (int stepPhi = 0; stepPhi <= 10; ++stepPhi) {
7026 for (int stepH = 0; stepH <= 4; ++stepH) {
7027 patchPoints.push_back(cone.pointAt(0.7 + 2.3 * stepPhi / 10., -0.9 + 2. * stepH / 4.));
7028 }
7029 }
7030 checkCoverBoxProperties(cone, patchPoints, "partial cone, skew axis");
7031 checkEmitsSeveralCoverBoxes(cone, "partial cone, skew axis");
7032 }
7033 {
7035 BOOST_REQUIRE(torus.initialize(skewCenter, skewAxis, referenceU, 2.4, 0.7, 0.3, 2.1, -0.4, 1.7, false, error));
7036 std::vector<Vec3> patchPoints;
7037 for (int stepRing = 0; stepRing <= 10; ++stepRing) {
7038 for (int stepTube = 0; stepTube <= 6; ++stepTube) {
7039 patchPoints.push_back(torus.pointAt(0.3 + 2.1 * stepRing / 10., -0.4 + 1.7 * stepTube / 6.));
7040 }
7041 }
7042 checkCoverBoxProperties(torus, patchPoints, "partial torus, skew axis");
7043 checkEmitsSeveralCoverBoxes(torus, "partial torus, skew axis");
7044 }
7045 {
7047 BOOST_REQUIRE(torus.initialize({0., 0., 0.}, {0., 0., 1.}, referenceU, 3., 1., 0., surf::kTwoPi, 0.,
7048 surf::kTwoPi, false, error));
7049 std::vector<Vec3> patchPoints;
7050 for (int stepRing = 0; stepRing < 16; ++stepRing) {
7051 for (int stepTube = 0; stepTube < 8; ++stepTube) {
7052 patchPoints.push_back(torus.pointAt(surf::kTwoPi * stepRing / 16., surf::kTwoPi * stepTube / 8.));
7053 }
7054 }
7055 checkCoverBoxProperties(torus, patchPoints, "full torus");
7056 }
7057 {
7059 const std::vector<surf::Vec2> rectangle{{0., 0.}, {2., 0.}, {2., 3.}, {0., 3.}};
7060 BOOST_REQUIRE(polygon.initialize({0.2, -0.4, 0.5}, {1., 0.2, 0.}, {-0.1, 1., 0.3}, rectangle, {}, error));
7061 std::vector<Vec3> patchPoints;
7062 patchPoints.push_back(polygon.toGlobal({0.01, 0.01}));
7063 patchPoints.push_back(polygon.toGlobal({1.9, 2.9}));
7064 checkCoverBoxProperties(polygon, patchPoints, "planar polygon");
7065 }
7066}
7067
7072BOOST_AUTO_TEST_CASE(StreamX_RaysThroughEmptyBoxRegionsReachNoPatch)
7073{
7074 // corner of the ball box, well outside the sphere: rho = |(2.2, 2.2)| = 3.11 > 2.5
7075 const auto sphere = makeSphereSolid("subBoxSphere", 2.5);
7076 BOOST_CHECK_EQUAL(sphere->CountBVHRayCandidates({2.2, 2.2, -5.}, {0., 0., 1.}), 0);
7077 BOOST_CHECK_GE(sphere->CountBVHRayCandidates({0., 0., -5.}, {0., 0., 1.}), 1);
7078
7079 // along the axis of a solid tube: the barrel patch cannot be hit, only the two caps can
7080 const auto tube = makeTubeSolid("subBoxTube", 0., 2., 1.);
7081 BOOST_CHECK_EQUAL(tube->CountBVHRayCandidates({0., 0., -5.}, {0., 0., 1.}), 2);
7082
7083 // corner of the torus box, outside the outer equator: rho = |(3.4, 3.4)| = 4.8 > R + r = 4
7084 const auto torus = makeTorusSolid("subBoxTorus", 3., 1.);
7085 BOOST_CHECK_EQUAL(torus->CountBVHRayCandidates({3.4, 3.4, -5.}, {0., 0., 1.}), 0);
7086 BOOST_CHECK_GE(torus->CountBVHRayCandidates({3., 0., -5.}, {0., 0., 1.}), 1);
7087
7088 // behind the back of a quarter cylinder: the full rim circles' box is crossed, the sweep band
7089 // is nowhere near. Not closed (a bare patch), which the BVH does not require.
7090 SurfaceSolid quarter("subBoxQuarterCylinder");
7091 BOOST_REQUIRE(quarter.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1.,
7092 -surf::kPi / 4., surf::kHalfPi));
7093 quarter.CloseShape(false);
7094 BOOST_REQUIRE(quarter.HasBVH());
7095 BOOST_CHECK_EQUAL(quarter.CountBVHRayCandidates({-1.9, -5., 0.}, {0., 1., 0.}), 0);
7096 BOOST_CHECK_GE(quarter.CountBVHRayCandidates({5., 0., 0.}, {-1., 0., 0.}), 1);
7097}
7098
7104BOOST_AUTO_TEST_CASE(StreamX_CurvedFixturesStayIdenticalToTheLoop)
7105{
7106 struct Fixture {
7107 std::unique_ptr<SurfaceSolid> solid;
7108 double extent;
7109 };
7110 std::vector<Fixture> fixtures;
7111 fixtures.push_back({makeSphereSolid("subBoxSweepSphere", 2.5), 3.5});
7112 fixtures.push_back({makeTorusSolid("subBoxSweepTorus", 3., 1.), 4.5});
7113 fixtures.push_back({makeCapsuleSolid("subBoxSweepCapsule", 1., 1.5), 3.});
7114 fixtures.push_back({makeConeSolid("subBoxSweepCone", 2., 1., 3.), 4.});
7115 fixtures.push_back({makeWireTrimmedSolid("subBoxSweepWireTrim"), 3.});
7116
7117 for (const auto& fixture : fixtures) {
7118 BOOST_TEST_CONTEXT("fixture = " << fixture.solid->GetName())
7119 {
7120 sweepDistanceAgainstLoop(*fixture.solid, fixture.extent, 4);
7121 for (const auto& point : probeGrid(fixture.extent, 4)) {
7122 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
7123 {
7124 BOOST_CHECK_EQUAL(fixture.solid->Contains(point.data()), fixture.solid->Contains_Loop(point.data()));
7125 std::vector<SurfaceSolid::ContainsCrossing> bvhCrossings;
7126 std::vector<SurfaceSolid::ContainsCrossing> loopCrossings;
7127 fixture.solid->DescribeContainsCrossings({point[0], point[1], point[2]}, bvhCrossings, loopCrossings);
7128 BOOST_REQUIRE_EQUAL(bvhCrossings.size(), loopCrossings.size());
7129 for (size_t index = 0; index < bvhCrossings.size(); ++index) {
7130 BOOST_CHECK_EQUAL(bvhCrossings[index].distance, loopCrossings[index].distance);
7131 }
7132 }
7133 }
7134 }
7135 }
7136}
7137
header::DataOrigin origin
o2::monitoring::tags::Key Key
std::unique_ptr< expressions::Node > node
uint64_t vertex
Definition RawEventData.h:9
int32_t i
o2::raw::RawFileWriter * raw
GPUChain * chain
configStandalone Light OpenGL display display_light Apply smoothing to points Enable Z buffer Highlight links Highlight seeds Highlight sector tracks Highlight final tracks Show individual Show related Highlight grid Show only individual collision Color clusters belonging to track objects Set line Enable drawing TPC data Enable drawing TOF data Enable drawing magnetic field Set field line step count Invert colors configStandalone Heavy OpenGL display display_heavy Show tracks with ITS contribution Show tracks with TOF contribution Enable propagation of loopers Show clusters at nominal x of pad row for early transformed data Split CE tracks when they cross the central Mark clusters Mark adjacent clusters Hide rejected tracks Apply filter on tracks to be displayed Separate increase X h configStandalone Camera window renderer OpenGL display display_camera Orient the camera such that the y axis is always upwards Full Screen MultiSample Anti Downsampling Anti Enable Vertical Sync Target frame or if the backend cannot limit to the refresh Use OpenGL indirect draws to reduce number of draw calls Display h configStandalone Vulkan display display_vulkan Have uniform buffers in host accessible device memory h configStandalone Internal display display_internal Number of updates the Qt GUI may send ahead of the last one the display has before it waits for the display Opacity of the console background Number of console lines the history is shortened to this when it reaches twice the size Time in seconds an info message fades out after setInfoTime h OpenGL display display Font size Do not use Freetype for font Renderer for event Frontend to use for event ROOT macros used as track filter Console script file
configStandalone Light OpenGL display display_light Apply smoothing to points Enable Z buffer Highlight links Highlight seeds Highlight sector tracks Highlight final tracks Show individual Show related Highlight grid Show only individual collision Color clusters belonging to track objects Set line Enable drawing TPC data Enable drawing TOF data Enable drawing magnetic field Set field line step count Invert colors configStandalone Heavy OpenGL display display_heavy Show tracks with ITS contribution Show tracks with TOF contribution Enable propagation of loopers Show clusters at nominal x of pad row for early transformed data Split CE tracks when they cross the central Mark clusters Mark adjacent clusters Hide rejected tracks Apply filter on tracks to be displayed Separate increase X coordinate
Timeframe settings Average distance in cm of events merged into Randomize distance around average distance of merged events Simulate correct bunch interactions instead of placing only the average number of events A value[n] sets TFlen for[n] collisions in Number of bunches per trainsort Number of bunch trains Instantaneous interaction rate Do not simulate border Total number of collisions to be placed in the interior of all time Do not select random but walk over array of events in stride steps h Event generator settings h configStandalone Use CPU for GPU Force usage of the specified GPU device no CPU fallback Continue processing after an error First event to Number of events to Number of iterations to Number of iterations to Number of initial iterations excluded from const char e
Timeframe settings Average distance in cm of events merged into Randomize distance around average distance of merged events Simulate correct bunch interactions instead of placing only the average number of events A value[n] sets TFlen for[n] collisions in Number of bunches per trainsort Number of bunch trains Instantaneous interaction rate Do not simulate border Total number of collisions to be placed in the interior of all time Do not select random but walk over array of events in stride steps h Event generator settings h configStandalone Use CPU for GPU Force usage of the specified GPU device no CPU fallback Continue processing after an error First event to Number of events to Number of iterations to Number of iterations to Number of initial iterations excluded from const char Directory with events to Events directory is and not inside events d
configStandalone Reconstruction rec_tpc multiplier for the combined cluster track error during track following area in cm for the search of for z only used if decrease removal by factor
configStandalone Reconstruction rec_tpc multiplier for the combined cluster track error during track following area in cm for the search of for z only used if decrease removal by when above this local occupancy rowx Shift of TPC revert cluster flag edge bit distance to edge exceeds this parameter(fixed during CTF decoding)") AddOptionRTC(defaultZOffsetOverR
char error[CURL_ERROR_SIZE]
float center
double lower[3]
double upper[3]
Validation and timing harness for TGeoShape navigation, typed on plain TGeoShape*.
uint32_t role
std::vector< SidecarEdge > edges
uint32_t j
Definition RawData.h:0
uint32_t c
Definition RawData.h:2
uint32_t version
Definition RawData.h:8
Per-call cost, memory and the synthetic boolean ladder: the measuring parts of the representation com...
Acts::RotationMatrix3 rotation
const Acts::Surface * surface
std::vector< std::shared_ptr< Surface > > surfaces
double radius
The X-ray transport benchmark's algorithms: stepping, auditing and comparing ordered crossing lists.
size_t bytes
Class for time synchronization of RawReader instances.
bool AddCylindricalSurface(const Point3D &centerPoint, const Point3D &axis, const Point3D &referenceAxisU, double radius, double heightMin, double heightMax, double phiStart=0., double phiSweep=6.283185307179586, bool innerWall=false)
Add a cylindrical wall of radius around axis over a height range and a phi sweep; innerWall points th...
void GetSurfaceCapacityContributions(std::vector< double > &contributions) const
Each face's divergence-theorem contribution to Capacity(), in record order.
Abstract analytic surface patch: one support surface plus its trim, with the kernels the navigation n...
double parametricLengthSqAt(const Vec2 &uv, const Vec2 &delta) const
The 3D length squared spanned by a parametric displacement delta starting at uv.
virtual void appendDisplayMesh(std::vector< Vec3 > &vertices, std::vector< std::array< int, 3 > > &triangles) const =0
Append this patch's visualization triangulation (navigation must never depend on it).
virtual double distanceSqToPatch(const Vec3 &point) const =0
Squared distance from a 3D point to the trimmed patch (used for Safety).
A cone whose radius varies linearly with height, trimmed as the cylinder; one radius may be zero (an ...
Vec3 pointAt(double phi, double height) const
bool initialize(const Vec3 &centerPoint, const Vec3 &axis, const Vec3 &referenceAxisU, double radiusAtMin, double radiusAtMax, double heightMin, double heightMax, double phiStart, double phiSweep, bool innerWall, std::string &errorMessage)
void parametricMetric(const Vec2 &uv, double &gUU, double &gUV, double &gVV) const override
(u, v) = (phi[rad], h[cm]): the azimuthal scale is the local radius, and a step in h spans sqrt(1 + s...
A plane trimmed by curved (line/arc/B-spline) loops in an orthonormal frame: exact caps,...
bool initialize(const Vec3 &surfaceOrigin, const Vec3 &surfaceAxisU, const Vec3 &surfaceAxisV, const std::vector< Curve2D > &outerCurves, const std::vector< std::vector< Curve2D > > &innerCurves, std::string &errorMessage, double joinTolerance=kWireJoinTolerance)
A cylinder of given radius around an axis, trimmed to a (phi, h) rectangle or by curve wires; innerWa...
Vec3 pointAt(double phi, double height) const
Vec3 normalAt(const Vec3 &point) const override
Outward-oriented normal at (or nearest to) the given point.
bool capacityIsExact() const override
Whether capacityContribution() is analytically exact for this surface.
void appendIntersections(const Vec3 &rayOrigin, const Vec3 &rayDirection, double minDistance, double maxDistance, std::vector< RayHit > &hits) const override
Append every hit of the ray with the trimmed patch in [minDistance, maxDistance], with the outward no...
bool pointInTrim(double phi, double height, bool *boundary=nullptr) const
True if the (phi, h) point lies in the trim wire (phi unwrapped into the wire window).
bool initialize(const Vec3 &centerPoint, const Vec3 &axis, const Vec3 &referenceAxisU, double radius, double heightMin, double heightMax, double phiStart, double phiSweep, bool innerWall, std::string &errorMessage)
bool containsPointOnSurface(const Vec3 &point) const override
True if the 3D point lies on the trimmed patch within tolerance.
double distanceSqToPatch(const Vec3 &point) const override
Distance to the patch: exact for the parametric rectangle, a lower bound for a wire trim.
double capacityContribution() const override
Divergence-theorem contribution over the (phi, h) rectangle; a wire trim uses the contour form,...
A trivial bounded surface, a single 3D triangle, to exercise the BoundedSurface interface.
bool capacityIsExact() const override
Whether capacityContribution() is analytically exact for this surface.
DummyBoundedSurface(const Vec3 &firstVertex, const Vec3 &secondVertex, const Vec3 &thirdVertex)
void conservativeBounds(Vec3 &lower, Vec3 &upper) const override
Accumulate a conservative axis-aligned bounding box of the trimmed patch.
void appendDirectedEdges(std::vector< std::pair< Vec3, Vec3 > > &edges) const override
Append the 3D directed boundary edges of the patch, for solid-closure validation.
Vec3 normalAt(const Vec3 &) const override
Outward-oriented normal at (or nearest to) the given point.
double capacityContribution() const override
Signed divergence-theorem contribution to the enclosed volume.
bool containsPointOnSurface(const Vec3 &) const override
True if the 3D point lies on the trimmed patch within tolerance.
void appendDisplayMesh(std::vector< Vec3 > &vertices, std::vector< std::array< int, 3 > > &triangles) const override
Append this patch's visualization triangulation (navigation must never depend on it).
double distanceSqToPatch(const Vec3 &point) const override
Squared distance from a 3D point to the trimmed patch (used for Safety).
void parametricMetric(const Vec2 &, double &gUU, double &gUV, double &gVV) const override
A triangle carries no parametric domain, so the form is the identity.
void appendIntersections(const Vec3 &, const Vec3 &, double, double, std::vector< RayHit > &) const override
Append every hit of the ray with the trimmed patch in [minDistance, maxDistance], with the outward no...
bool initialize(const Vec3 &surfaceOrigin, const Vec3 &surfaceAxisU, const Vec3 &surfaceAxisV, const std::vector< Vec2 > &outerWireVertices, const std::vector< std::vector< Vec2 > > &innerWireVertices, std::string &errorMessage)
Vec3 toGlobal(const Vec2 &point) const
void parametricMetric(const Vec2 &, double &gUU, double &gUV, double &gVV) const override
Constant over the plane, with a cross term: the frame axes need be neither unit-length nor orthogonal...
bool containsLocal(const Vec2 &point, bool *boundary=nullptr) const
A sphere of given radius trimmed to a (theta, phi) rectangle or by curve wires; innerWall points the ...
bool initialize(const Vec3 &center, const Vec3 &polarAxis, const Vec3 &referenceAxisU, double radius, double thetaMin, double thetaMax, double phiStart, double phiSweep, bool innerWall, std::string &errorMessage)
Vec3 pointAt(double theta, double phi) const
void appendIntersections(const Vec3 &rayOrigin, const Vec3 &rayDirection, double minDistance, double maxDistance, std::vector< RayHit > &hits) const override
Append every hit of the ray with the trimmed patch in [minDistance, maxDistance], with the outward no...
double distanceSqToPatch(const Vec3 &point) const override
Distance to the patch: exact inside the trim, else the full-sphere distance, a lower bound.
void parametricMetric(const Vec2 &uv, double &gUU, double &gUV, double &gVV) const override
(u, v) = (phi[rad], theta[rad]); gUU vanishes at either pole.
bool containsPointOnSurface(const Vec3 &point) const override
True if the 3D point lies on the trimmed patch within tolerance.
double capacityContribution() const override
Divergence-theorem contribution over the (theta, phi) rectangle; a wire trim uses the contour form in...
bool containsPointOnSurface(const Vec3 &point) const override
True if the 3D point lies on the trimmed patch within tolerance.
Vec3 pointAt(double phiRing, double phiTube) const
bool initialize(const Vec3 &centerPoint, const Vec3 &axis, const Vec3 &referenceAxisU, double majorRadius, double minorRadius, double phiStart, double phiSweep, double tubeStart, double tubeSweep, bool innerWall, std::string &errorMessage)
void appendIntersections(const Vec3 &rayOrigin, const Vec3 &rayDirection, double minDistance, double maxDistance, std::vector< RayHit > &hits) const override
Append every hit of the ray with the trimmed patch in [minDistance, maxDistance], with the outward no...
bool capacityIsExact() const override
Whether capacityContribution() is analytically exact for this surface.
Vec3 normalAt(const Vec3 &point) const override
Outward-oriented normal at (or nearest to) the given point.
double distanceSqToPatch(const Vec3 &point) const override
Distance to the patch: exact for the full torus by the meridian distance, a lower bound for a trimmed...
double capacityContribution() const override
Divergence-theorem contribution over the (phiRing, phiTube) rectangle; a wire trim uses the contour f...
void parametricMetric(const Vec2 &uv, double &gUU, double &gUV, double &gVV) const override
(u, v) = (phiRing[rad], phiTube[rad]): the tube scale is r, the ring scale the distance from the axis...
GLdouble n
Definition glcorearb.h:1982
GLint GLenum GLint x
Definition glcorearb.h:403
GLenum GLsizeiptr const void GLsizei faceIndex
Definition glcorearb.h:5519
GLsizei const GLuint const GLfloat * weights
Definition glcorearb.h:5475
GLuint segment
Definition glcorearb.h:4945
GLint GLsizei count
Definition glcorearb.h:399
GLsizeiptr size
Definition glcorearb.h:659
GLuint GLuint end
Definition glcorearb.h:469
GLuint * ids
Definition glcorearb.h:647
const GLdouble * v
Definition glcorearb.h:832
GLuint index
Definition glcorearb.h:781
GLuint const GLchar * name
Definition glcorearb.h:781
GLsizei samples
Definition glcorearb.h:1309
GLdouble GLdouble right
Definition glcorearb.h:4077
GLint GLsizei GLsizei height
Definition glcorearb.h:270
GLint first
Definition glcorearb.h:399
GLboolean GLboolean GLboolean b
Definition glcorearb.h:1233
GLsizei GLsizei GLchar * source
Definition glcorearb.h:798
GLsizei GLsizei GLfloat distance
Definition glcorearb.h:5506
GLint y
Definition glcorearb.h:270
GLint reference
Definition glcorearb.h:5487
GLsizei const GLfloat * value
Definition glcorearb.h:819
GLint left
Definition glcorearb.h:1979
GLenum GLsizei GLsizei GLint * values
Definition glcorearb.h:1576
GLintptr offset
Definition glcorearb.h:660
GLuint GLsizei GLsizei * length
Definition glcorearb.h:790
GLuint GLsizei const GLchar * label
Definition glcorearb.h:2519
GLint GLint bottom
Definition glcorearb.h:1979
GLint GLenum GLboolean normalized
Definition glcorearb.h:867
GLfloat angle
Definition glcorearb.h:4071
GLbitfield flags
Definition glcorearb.h:1570
GLfloat GLfloat v1
Definition glcorearb.h:812
GLsizei const GLchar *const * path
Definition glcorearb.h:3591
GLboolean r
Definition glcorearb.h:1233
GLfloat GLfloat GLfloat GLfloat v3
Definition glcorearb.h:814
GLboolean GLboolean GLboolean GLboolean a
Definition glcorearb.h:1233
GLuint GLuint stream
Definition glcorearb.h:1806
GLubyte GLubyte GLubyte GLubyte w
Definition glcorearb.h:852
GLfloat GLfloat GLfloat v2
Definition glcorearb.h:813
GLsizei const GLint * box
Definition glcorearb.h:4697
GLdouble GLdouble GLdouble z
Definition glcorearb.h:843
void report(gsl::span< o2::InteractionTimeRecord > irs, int threshold, bool verbose)
QuerySamples buildQuerySamples(const TGeoShape *reference, const std::string &referenceName, const Point3D &bboxMin, const Point3D &bboxMax, int nPoints, int nRays, uint64_t seed=20260802ULL, double inflate=0.12)
TGeoShape * buildBooleanLadder(int leaves, LadderShape shape, const std::string &tag)
TimingStat timeContainsPass(const TGeoShape *shape, const QuerySamples &s, int warmup, int passes)
MemorySnapshot readMemory()
TimingStat timeDistInPass(const TGeoShape *shape, const QuerySamples &s, int warmup, int passes)
TimingStat timeDistOutPass(const TGeoShape *shape, const QuerySamples &s, int warmup, int passes)
BooleanTreeStats booleanTreeStats(const TGeoShape *shape)
std::array< double, 3 > Point3D
double distanceSq(const Vec2 &firstPoint, const Vec2 &secondPoint)
QuarticBranch
Which of solveQuarticReal's branches produced its roots, for the tests.
constexpr double kBSplineFlatness
Chord flatness of the adaptive B-spline sampler, in the curve's parametric units; a B-spline trim is ...
WireClassification
Classification of a parametric point against a closed wire.
double pointSegmentDistanceSq(const Vec2 &point, const Vec2 &segmentStart, const Vec2 &segmentEnd)
constexpr double kPi
double dot(const Vec3 &firstVector, const Vec3 &secondVector)
double integrateOverCurveTrim(const CurveWire &outerWire, const std::vector< CurveWire > &innerWires, const Integrand &integrand, int samplesPerAxis=128)
Midpoint-rule integral of integrand over the trimmed region; kept as the independent check of the con...
bool sameIntersection(double firstDistance, double secondDistance)
constexpr double kHalfPi
ParametricMetric parametricMetricOf(const Surface &surface)
bool buildCurveTrim(const std::vector< Curve2D > &outerTrim, const std::vector< std::vector< Curve2D > > &innerTrims, CurveWire &outerWire, std::vector< CurveWire > &innerWires, Vec2 &lower, Vec2 &upper, std::string &errorMessage, const ParametricMetric &metric={}, double joinTolerance=kWireJoinTolerance)
Build validated outer and inner trim wires and the outer loop's parametric bounds; rejects a trim wid...
constexpr double kBVHBoxTolerance
Widening of the BVH leaf boxes before the outward float rounding; it dominates every navigation lengt...
ClosureReport validateClosure(const std::vector< std::unique_ptr< BoundedSurface > > &surfaces, double modelTolerance=0.)
Validate closure and orientation of surfaces by half-edges, measure the rims, and count edge identiti...
double normSq(const Vec3 &vector)
QuarticRoots solveQuarticReal(double a4, double a3, double a2, double a1, double a0, QuarticBranch *takenBranch=nullptr)
Vec3 cross(const Vec3 &firstVector, const Vec3 &secondVector)
constexpr double kRimMatchTolerance
Rim-matching distance in cm when the model states no tolerance: the extractor precision,...
Vec3 normalized(const Vec3 &vector)
constexpr double kTolerance
generic length tolerance
constexpr double kTwoPi
constexpr int kArcSamples
Chords per full-circle arc for display and rims, shared by all surfaces so shared rims match; divisib...
constexpr double kIntersectionTolerance
clustering of near-equal intersections
double norm(const Vec3 &vector)
std::vector< Crossing > stepWithShapeApi(const TGeoShape *shape, const Point3D &origin, const Point3D &dir, double tMax, const StepConfig &cfg, Robustness &stats)
Mode (a): the same loop driven by the ordinary TGeoShape virtuals.
void compareLists(const std::vector< Crossing > &candidate, const std::vector< Crossing > &reference, const Point3D &origin, const Point3D &dir, double tolerance, ListComparison &out)
double chordVolume(const Raster &raster, const std::vector< double > &insideLengthPerBeam)
std::vector< Crossing > stepCrossingsWithKernels(const Point3D &origin, const Point3D &dir, double tMax, const StepConfig &cfg, Robustness &stats, ContainsFn contains, DistOutFn distFromOutside, DistInFn distFromInside)
void auditCrossingList(const std::vector< Crossing > &crossings, const TGeoShape *shape, const Point3D &origin, const Point3D &dir, double tMax, const StepConfig &cfg, Robustness &stats)
double dot3(const Point3D &a, const Point3D &b)
std::vector< Beam > buildFanBeams(int count)
Raster buildRaster(const Point3D &bboxMin, const Point3D &bboxMax, int n, const std::vector< Beam > &beams, double transverseMargin)
std::vector< Beam > buildBeams(const std::string &axesSpec, double tiltDegrees)
bool LoadSurfaceSolid(const std::string &file, O2BVHSurfaceSolid &solid)
int const float const TrackSeed< NLayers > & seed
const bool const bool const int FollowDirection BestTrial TrackITSInternal< NLayers > & best
const float const float const float const float std::array< float, 3 > std::array< float, 3 > & direction
ROOT::Math::PositionVector2D< ROOT::Math::Cartesian2D< T >, ROOT::Math::DefaultCoordinateSystemTag > Point2D
value_T step
Definition TrackUtils.h:42
std::string to_string(gsl::span< T, Size > span)
Definition common.h:52
void empty(int)
static PlanarBoundaryCurve makeLine(const Point2D &start, const Point2D &end)
static PlanarBoundaryCurve makeBSpline(int splineDegree, std::vector< Point2D > splinePoles, std::vector< double > splineWeights, std::vector< double > splineKnots)
long long nodes
TGeoCompositeShape / TGeoBoolNode pairs.
Whether a set of bounded surfaces forms a closed, consistently oriented 2-manifold,...
One trimmed boundary curve in a surface's (u, v) domain: a line segment, a circular arc or a clamped ...
std::vector< Vec2 > poles
double uVariation(double from, double to) const
An upper bound on how far u travels along the curve between from and to.
Vec2 pointAt(double parameter) const
Point at curve parameter parameter in [0, 1] (0 at the start, 1 at the end).
One closed, oriented boundary loop of Curve2D segments: outer loops wind counter-clockwise,...
double representationTolerance() const
The widest gap between the loop's representation and its boundary, in parametric units; 0 for lines a...
void tightParametricBounds(Vec2 &lower, Vec2 &upper) const
Add the loop's extent measured on the curves to a parametric bounding box; use it to reject a wire as...
void parametricBounds(Vec2 &lower, Vec2 &upper) const
Add the loop's conservative extent, a B-spline's pole hull included, to a parametric bounding box.
std::vector< Curve2D > curves
WireClassification classify(const Vec2 &point, double lengthFloor) const
Classify a point against the loop with band floor lengthFloor: Boundary within the band,...
bool initialize(const std::vector< Curve2D > &inputCurves, WireRole wireRole, WireStatus &status, const ParametricMetric &metric={}, double joinTolerance=kWireJoinTolerance)
Build and validate the wire from an ordered closed list of curves, joining within joinTolerance throu...
One ray/surface intersection: the ray parameter and the outward normal; a quadric patch can give seve...
One straight line segment of a polygon wire, in a surface's parametric (u, v) domain.
One closed, oriented polygon loop in a surface's parametric domain: outer loops wind counter-clockwis...
bool initializeFromEdges(const std::vector< SurfaceEdge > &edges, WireRole wireRole, WireStatus &status, const ParametricMetric &metric={}, double joinTolerance=kWireJoinTolerance)
Build and validate the wire from an ordered edge list, joining within joinTolerance through metric,...
bool initialize(const std::vector< Vec2 > &inputVertices, WireRole wireRole, WireStatus &status, const ParametricMetric &metric={})
Build and validate the wire from an implicitly closed vertex ring; metric turns separations into 3D l...
A 2D point/vector in a surface's parametric (u, v) domain.
A 3D point/vector in the solid's local frame.
long long displaced
same position in both lists, more than tolerance apart
long long raysIdentical
the whole ordered list matched, position and sense
double worstDeltaT
max |dt| over positionally matched crossings, cm
long long missing
in the reference, absent from the candidate
long long extra
in the candidate, absent from the reference
std::vector< RayDef > rays
std::vector< Beam > beams
std::vector< double > cellArea
double insideLength
summed inside-segment length, cm (the chord integral)
BOOST_AUTO_TEST_CASE(PlanarBoxNavigationMatchesTGeoBBox)
o2::cad::harness::Point3D XRayPoint
void compare(std::string_view s1, std::string_view s2)
std::map< std::string, ID > expected
VectorOfTObjectPtrs other
BOOST_CHECK(tree)
BOOST_CHECK_EQUAL(triggersD.size(), triggers.size())
std::vector< int > row