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 {
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
2189// Green's theorem for wire-trimmed quadrics.
2190//
2191// The integrand does not depend on the second parameter for a cylinder, and depends on the first
2192// only through sin/cos/identity for all four quadrics, so an antiderivative in u exists in closed
2193// form and the area integral collapses to a contour integral around the trim wire.
2194//
2195// The sharpest possible check: a wire that traces exactly the parametric rectangle must give the
2196// same number as the rectangle's own closed form, which is analytically exact. Anything the
2197// contour form got wrong -- a sign, an orientation, a missed seam, a wrong antiderivative --
2198// shows up here immediately, and the old quadrature could only ever have agreed to ~1e-2.
2199BOOST_AUTO_TEST_CASE(WireTrimCapacityMatchesTheClosedForm)
2200{
2201 std::string error;
2202 // an off-origin centre and a tilted frame, so every term of every antiderivative is exercised
2203 // (C.U, C.V and C.W all non-zero) rather than cancelling
2204 const surf::Vec3 centre{0.7, -1.3, 0.45};
2205 const surf::Vec3 axis = surf::normalized({0.3, 0.4, 1.});
2206 const surf::Vec3 reference{1., 0.2, 0.};
2207 constexpr double kRelative = 1.e-12;
2208
2209 const auto compare = [&](const char* what, const surf::BoundedSurface& rectangle,
2210 const surf::BoundedSurface& wired) {
2211 BOOST_TEST_CONTEXT(what)
2212 {
2213 BOOST_CHECK(!wired.capacityIsExact()); // still reported inexact: see the note below
2214 const double exact = rectangle.capacityContribution();
2215 const double contour = wired.capacityContribution();
2216 BOOST_CHECK_GT(std::abs(exact), 1.e-6); // a zero contribution would prove nothing
2217 checkClose(contour, exact, kRelative * std::abs(exact));
2218 }
2219 };
2220
2221 {
2224 const double phiLow = 0.3, phiHigh = 2.4, hLow = -0.8, hHigh = 1.9;
2225 BOOST_REQUIRE(rectangle.initialize(centre, axis, reference, 1.7, hLow, hHigh, phiLow, phiHigh - phiLow, false,
2226 error));
2227 BOOST_REQUIRE(wired.initialize(centre, axis, reference, 1.7, hLow, hHigh, phiLow, phiHigh - phiLow, false,
2228 paramRectWireCurves(phiLow, phiHigh, hLow, hHigh), {}, error));
2229 compare("cylinder", rectangle, wired);
2230 }
2231 {
2234 const double phiLow = -0.4, phiHigh = 1.9, hLow = 0.2, hHigh = 2.1;
2235 BOOST_REQUIRE(rectangle.initialize(centre, axis, reference, 1.1, 2.3, hLow, hHigh, phiLow, phiHigh - phiLow,
2236 false, error));
2237 BOOST_REQUIRE(wired.initialize(centre, axis, reference, 1.1, 2.3, hLow, hHigh, phiLow, phiHigh - phiLow, false,
2238 paramRectWireCurves(phiLow, phiHigh, hLow, hHigh), {}, error));
2239 compare("cone", rectangle, wired);
2240 }
2241 {
2244 const double phiLow = 0.2, phiHigh = 2.7, thetaLow = 0.4, thetaHigh = 2.3;
2245 BOOST_REQUIRE(rectangle.initialize(centre, axis, reference, 2.2, thetaLow, thetaHigh, phiLow, phiHigh - phiLow,
2246 false, error));
2247 BOOST_REQUIRE(wired.initialize(centre, axis, reference, 2.2, thetaLow, thetaHigh, phiLow, phiHigh - phiLow, false,
2248 paramRectWireCurves(phiLow, phiHigh, thetaLow, thetaHigh), {}, error));
2249 compare("sphere", rectangle, wired);
2250 }
2251 {
2252 surf::TorusBoundedSurface rectangle;
2254 const double ringLow = 0.1, ringHigh = 2.2, tubeLow = -0.3, tubeHigh = 1.8;
2255 BOOST_REQUIRE(rectangle.initialize(centre, axis, reference, 4., 1.4, ringLow, ringHigh - ringLow, tubeLow,
2256 tubeHigh - tubeLow, false, error));
2257 BOOST_REQUIRE(wired.initialize(centre, axis, reference, 4., 1.4, ringLow, ringHigh - ringLow, tubeLow,
2258 tubeHigh - tubeLow, false,
2259 paramRectWireCurves(ringLow, ringHigh, tubeLow, tubeHigh), {}, error));
2260 compare("torus", rectangle, wired);
2261 }
2262
2263 // A trim the rectangle cannot express, checked against the integrator it replaces. The midpoint
2264 // rule is a genuinely independent computation -- it needs nothing of the integrand but its value
2265 // -- so agreement is evidence, but only to its own O(1/N) accuracy, which is the whole reason
2266 // this change exists. Refining it must walk *towards* the contour answer; that direction is the
2267 // real assertion here, not either number.
2268 {
2270 const double radius = 1.7;
2271 const std::vector<surf::Curve2D> trim{surf::Curve2D::makeCircle({1.0, 0.2}, 0.6)};
2272 BOOST_REQUIRE(disk.initialize(centre, axis, reference, radius, -2., 2., 0., surf::kTwoPi, false, trim, {},
2273 error));
2274 const double contour = disk.capacityContribution();
2275
2276 // the same trim, rebuilt here so the grid rule can be run over it directly
2277 surf::CurveWire outerWire;
2278 std::vector<surf::CurveWire> innerWires;
2280 BOOST_REQUIRE(surf::buildCurveTrim(trim, {}, outerWire, innerWires, lower, upper, error,
2282
2283 const auto gridRelativeError = [&](int samples) {
2284 const double grid = surf::integrateOverCurveTrim(
2285 outerWire, innerWires,
2286 [&disk, radius](double phi, double height) {
2287 const surf::Vec3 point = disk.pointAt(phi, height);
2288 return surf::dot(point, disk.normalAt(point)) * radius / 3.;
2289 },
2290 samples);
2291 return std::abs(grid - contour) / std::abs(contour);
2292 };
2293 const double at128 = gridRelativeError(128);
2294 const double at512 = gridRelativeError(512);
2295 const double at2048 = gridRelativeError(2048);
2296
2297 // The grid rule confirms the contour value to its own accuracy -- an independent computation
2298 // agreeing to 3e-5 is what says the antiderivative route is not just self-consistent.
2299 BOOST_CHECK_LT(at512, 1.e-4);
2300 BOOST_CHECK_LT(at2048, 1.e-4);
2301 // But it cannot do better, and that is the point. At the shipped 128 it is off by 2e-3 --
2302 // three orders outside the gate's 1e-6 band -- and refining it sixteen-fold does not fix that,
2303 // because the error is not monotone: the staircase re-phases and 2048 is *worse* than 512
2304 // (2.9e-5 against 2.4e-5 here; on ExcavatorArm/BucketLink2 the sequence 128..2048 runs 16.004,
2305 // 17.710, 16.927, 17.244, 17.032 around a true 17.079). So no N could have been the fix.
2306 BOOST_CHECK_GT(at128, 1.e-3);
2307 BOOST_CHECK_GT(at2048, at512);
2308 }
2309}
2310
2311BOOST_AUTO_TEST_CASE(WireTrimmedQuadricKernels)
2312{
2313 using surf::Curve2D;
2314 using surf::Vec3;
2315 std::string error;
2316
2317 const auto onCylinder = [](double phi, double height) {
2318 return Vec3{2. * std::cos(phi), 2. * std::sin(phi), height};
2319 };
2320
2321 // (1) cylinder wall with a rectangular window (hole) in (phi, h): phi in [2.0, 2.5], h in [-1, 1]
2323 const std::vector<Curve2D> outer{Curve2D::makeLine({0., -3.}, {surf::kTwoPi, -3.}),
2324 Curve2D::makeLine({surf::kTwoPi, -3.}, {surf::kTwoPi, 3.}),
2325 Curve2D::makeLine({surf::kTwoPi, 3.}, {0., 3.}),
2326 Curve2D::makeLine({0., 3.}, {0., -3.})};
2327 const std::vector<Curve2D> hole{Curve2D::makeLine({2.0, -1.}, {2.5, -1.}), Curve2D::makeLine({2.5, -1.}, {2.5, 1.}),
2328 Curve2D::makeLine({2.5, 1.}, {2.0, 1.}), Curve2D::makeLine({2.0, 1.}, {2.0, -1.})};
2329 BOOST_REQUIRE(windowed.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -3., 3., 0., surf::kTwoPi, false,
2330 outer, {hole}, error));
2331 BOOST_CHECK(windowed.containsPointOnSurface(onCylinder(0.5, 0.))); // material
2332 BOOST_CHECK(windowed.containsPointOnSurface(onCylinder(2.25, 2.5))); // material above the window
2333 BOOST_CHECK(!windowed.containsPointOnSurface(onCylinder(2.25, 0.))); // inside the window
2334 BOOST_CHECK(windowed.containsPointOnSurface(onCylinder(2.25, 1.))); // on the window edge (boundary)
2335
2336 // a radial ray into the window is filtered out; a radial ray into material registers one hit
2337 std::vector<surf::RayHit> hits;
2338 windowed.appendIntersections({0., 0., 0.}, {std::cos(2.25), std::sin(2.25), 0.}, 0., 1.e30, hits);
2339 BOOST_CHECK(hits.empty());
2340 hits.clear();
2341 windowed.appendIntersections({0., 0., 0.}, {std::cos(0.5), std::sin(0.5), 0.}, 0., 1.e30, hits);
2342 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
2343 checkClose(hits.front().distance, 2.);
2344
2345 // (2) arc trim: a parametric circle (disk in (phi, h)) centred at (pi, 0), radius 0.5
2347 const std::vector<Curve2D> arcOuter{Curve2D::makeCircle({surf::kPi, 0.}, 0.5)};
2348 BOOST_REQUIRE(arcTrim.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
2349 arcOuter, {}, error));
2350 BOOST_CHECK(arcTrim.containsPointOnSurface(onCylinder(surf::kPi, 0.))); // centre of the disk
2351 BOOST_CHECK(!arcTrim.containsPointOnSurface(onCylinder(surf::kPi, 0.6))); // outside in h
2352 BOOST_CHECK(!arcTrim.containsPointOnSurface(onCylinder(surf::kPi + 0.6, 0.))); // outside in phi
2353 BOOST_CHECK_GT(std::abs(arcTrim.capacityContribution()), 0.);
2354
2355 // (3) sphere section reproduced as a (phi, theta) rectangle wire must match the scalar section
2356 const auto onSphere = [](double theta, double phi) {
2357 return Vec3{2. * std::sin(theta) * std::cos(phi), 2. * std::sin(theta) * std::sin(phi), 2. * std::cos(theta)};
2358 };
2360 BOOST_REQUIRE(sphereWire.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., surf::kHalfPi / 2., surf::kHalfPi,
2361 0., surf::kHalfPi, false,
2362 paramRectWireCurves(0., surf::kHalfPi, surf::kHalfPi / 2., surf::kHalfPi), {},
2363 error));
2364 surf::SphericalBoundedSurface sphereScalar;
2365 BOOST_REQUIRE(sphereScalar.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., surf::kHalfPi / 2.,
2366 surf::kHalfPi, 0., surf::kHalfPi, false, error));
2367 BOOST_CHECK(sphereWire.containsPointOnSurface(onSphere(surf::kPi / 3., surf::kPi / 4.))); // inside the section
2368 BOOST_CHECK(!sphereWire.containsPointOnSurface(onSphere(surf::kPi / 6., surf::kPi / 4.))); // theta too small
2369 BOOST_CHECK(!sphereWire.containsPointOnSurface(onSphere(surf::kPi / 3., 3. * surf::kPi / 4.))); // phi outside
2370 // same story on the sphere: 1e-3 was the grid rule's allowance, not the geometry's
2371 checkClose(sphereWire.capacityContribution(), sphereScalar.capacityContribution(), 1.e-9);
2372
2373 // (4) a trim spanning more than a full turn in phi is rejected
2375 const std::vector<Curve2D> wideOuter{Curve2D::makeLine({0., -1.}, {7., -1.}), Curve2D::makeLine({7., -1.}, {7., 1.}),
2376 Curve2D::makeLine({7., 1.}, {0., 1.}), Curve2D::makeLine({0., 1.}, {0., -1.})};
2377 BOOST_CHECK(!tooWide.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
2378 wideOuter, {}, error));
2379}
2380
2381namespace
2382{
2383// Estimate the first fundamental form at (u, v) by central differences of a surface's own
2384// parametrisation. Checking parametricMetric against this is a proof that the closed form
2385// describes the map the rest of the kernel actually evaluates -- restating the formula in the
2386// test would only prove it was copied twice.
2387template <typename PointAt>
2388void checkMetricAgainstFiniteDifference(const surf::BoundedSurface& surface, const PointAt& pointAt,
2389 double uCoord, double vCoord, double tolerance = 1.e-6)
2390{
2391 const double step = 1.e-5;
2392 const surf::Vec3 dU = (pointAt(uCoord + step, vCoord) - pointAt(uCoord - step, vCoord)) * (0.5 / step);
2393 const surf::Vec3 dV = (pointAt(uCoord, vCoord + step) - pointAt(uCoord, vCoord - step)) * (0.5 / step);
2394
2395 double gUU = 0.;
2396 double gUV = 0.;
2397 double gVV = 0.;
2398 surface.parametricMetric({uCoord, vCoord}, gUU, gUV, gVV);
2399 checkClose(gUU, dot(dU, dU), tolerance);
2400 checkClose(gUV, dot(dU, dV), tolerance);
2401 checkClose(gVV, dot(dV, dV), tolerance);
2402}
2403} // namespace
2404
2405// The first fundamental form of every surface family, against the surface's own parametrisation,
2406// plus the two degeneracies and the cross term the callers of it have to cope with. This is the
2407// conversion that makes a parametric tolerance mean a length (findings K3, K5, K12, S10).
2408BOOST_AUTO_TEST_CASE(ParametricMetricIsTheFirstFundamentalForm)
2409{
2410 using surf::Vec2;
2411 using surf::Vec3;
2412 std::string error;
2413
2414 // (1) plane with deliberately non-orthonormal axes: the only family with a cross term, and the
2415 // only one whose (u, v) are not already lengths.
2416 const Vec3 axisU{2., 0., 0.};
2417 const Vec3 axisV{1., 3., 0.}; // not unit, not orthogonal to axisU
2418 const std::vector<Vec2> unitSquare{{0., 0.}, {1., 0.}, {1., 1.}, {0., 1.}};
2420 BOOST_REQUIRE(plane.initialize({0.5, -1., 2.}, axisU, axisV, unitSquare, {}, error));
2421 checkMetricAgainstFiniteDifference(plane, [&](double u, double v) { return plane.toGlobal({u, v}); }, 0.3, 0.7);
2422 {
2423 double gUU = 0.;
2424 double gUV = 0.;
2425 double gVV = 0.;
2426 plane.parametricMetric({0., 0.}, gUU, gUV, gVV);
2427 checkClose(gUU, 4.);
2428 checkClose(gUV, 2.); // dot(axisU, axisV) -- zero for every other family
2429 checkClose(gVV, 10.);
2430 // and it really measures 3D length: (du, dv) = (1, 0) spans |axisU| = 2 cm
2431 checkClose(std::sqrt(plane.parametricLengthSqAt({0., 0.}, {1., 0.})), 2.);
2432 checkClose(std::sqrt(plane.parametricLengthSqAt({0., 0.}, {0., 1.})), std::sqrt(10.));
2433 }
2434
2435 // (2) curved planar: initialize() insists on an orthonormal frame, so (u, v) are centimetres.
2437 BOOST_REQUIRE(curvedPlane.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.},
2438 {surf::Curve2D::makeCircle({0., 0.}, 1.)}, {}, error));
2439 checkMetricAgainstFiniteDifference(
2440 curvedPlane, [&](double u, double v) { return curvedPlane.toGlobal({u, v}); }, 0.2, -0.4);
2441
2442 // (3) cylinder, (u, v) = (phi, h). The radius factor is the whole point: the same parametric
2443 // drift is a different distance on a small hole and on a large cylinder.
2444 for (const double radius : {0.01, 100.}) {
2446 BOOST_REQUIRE(cylinder.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -1., 1., 0., surf::kTwoPi,
2447 false, error));
2448 checkMetricAgainstFiniteDifference(cylinder, [&](double u, double v) { return cylinder.pointAt(u, v); }, 1.1, 0.3, 1.e-4 * radius * radius);
2449 // a 2e-5 rad join drift is 2e-7 cm on the small cylinder and 2e-3 cm on the large one
2450 checkClose(std::sqrt(cylinder.parametricLengthSqAt({1.1, 0.3}, {2.e-5, 0.})), 2.e-5 * radius, 1.e-12);
2451 }
2452
2453 // (4) sphere, (u, v) = (phi, theta) -- the trim domain's order, the transpose of pointAt's.
2455 BOOST_REQUIRE(sphere.initialize({1., 2., 3.}, {0., 0., 1.}, {1., 0., 0.}, 2.5, 0., surf::kPi, 0., surf::kTwoPi,
2456 false, error));
2457 checkMetricAgainstFiniteDifference(sphere, [&](double u, double v) { return sphere.pointAt(v, u); }, 0.9, 1.2);
2458 {
2459 double gUU = 0.;
2460 double gUV = 0.;
2461 double gVV = 0.;
2462 // at the pole the azimuth degenerates: a phi separation there spans no distance at all
2463 sphere.parametricMetric({0.9, 0.}, gUU, gUV, gVV);
2464 checkClose(gUU, 0.);
2465 checkClose(gVV, 2.5 * 2.5);
2466 checkClose(sphere.parametricLengthSqAt({0.9, 0.}, {1., 0.}), 0.);
2467 sphere.parametricMetric({0.9, surf::kPi}, gUU, gUV, gVV);
2468 checkClose(gUU, 0.);
2469 // and on the equator it is the full radius
2470 sphere.parametricMetric({0.9, surf::kHalfPi}, gUU, gUV, gVV);
2471 checkClose(gUU, 2.5 * 2.5);
2472 }
2473
2474 // (5) cone, (u, v) = (phi, h): the azimuthal scale shrinks to zero at the apex, and a step in h
2475 // walks along the slope rather than along the axis.
2477 BOOST_REQUIRE(cone.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 0., 4., 0., 2., 0., surf::kTwoPi, false,
2478 error));
2479 checkMetricAgainstFiniteDifference(cone, [&](double u, double v) { return cone.pointAt(u, v); }, 2.0, 1.3);
2480 {
2481 double gUU = 0.;
2482 double gUV = 0.;
2483 double gVV = 0.;
2484 cone.parametricMetric({2.0, 0.}, gUU, gUV, gVV); // the apex, where r(h) = 0
2485 checkClose(gUU, 0.);
2486 checkClose(gVV, 1. + 2. * 2.); // slope = (4 - 0) / (2 - 0)
2487 checkClose(cone.parametricLengthSqAt({2.0, 0.}, {1., 0.}), 0.);
2488 cone.parametricMetric({2.0, 2.}, gUU, gUV, gVV); // the wide end, r = 4
2489 checkClose(gUU, 16.);
2490 }
2491
2492 // (6) torus, (u, v) = (phiRing, phiTube): the ring scale runs from R - r to R + r around the
2493 // tube, so it is the one family whose gUU varies without any degeneracy.
2495 BOOST_REQUIRE(torus.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 5., 1.5, 0., surf::kTwoPi, 0.,
2496 surf::kTwoPi, false, error));
2497 checkMetricAgainstFiniteDifference(torus, [&](double u, double v) { return torus.pointAt(u, v); }, 0.7, 2.1);
2498 {
2499 double gUU = 0.;
2500 double gUV = 0.;
2501 double gVV = 0.;
2502 torus.parametricMetric({0.7, 0.}, gUU, gUV, gVV); // outside of the ring
2503 checkClose(gUU, 6.5 * 6.5);
2504 checkClose(gVV, 1.5 * 1.5);
2505 torus.parametricMetric({0.7, surf::kPi}, gUU, gUV, gVV); // inside of the ring
2506 checkClose(gUU, 3.5 * 3.5);
2507 }
2508}
2509
2510// The join tolerance is a length, so the same parametric drift is accepted on a small cylinder
2511// and refused on a large one. Today's rule cannot tell them apart, which is the whole of K3 -- and
2512// it is the synthetic form of the measured ST1829909_01 loader rejection (six joins under 3e-5 rad
2513// on cylinder trims, negligible in arc length, read as three times over a 1e-5 "tolerance").
2514BOOST_AUTO_TEST_CASE(WireJoinToleranceIsALength)
2515{
2516 using surf::Curve2D;
2517 std::string error;
2518
2519 // A rectangular (phi, h) trim whose last edge stops `drift` radians short of closing the loop.
2520 const auto trimWithPhiDrift = [](double drift) {
2521 return std::vector<Curve2D>{Curve2D::makeLine({0.2, -1.}, {1.2, -1.}), Curve2D::makeLine({1.2, -1.}, {1.2, 1.}),
2522 Curve2D::makeLine({1.2, 1.}, {0.2 + drift, 1.}),
2523 Curve2D::makeLine({0.2 + drift, 1.}, {0.2 + drift, -1.})};
2524 };
2525 const auto acceptsDrift = [&](double radius, double drift) {
2527 return cylinder.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -1., 1., 0., surf::kTwoPi, false,
2528 trimWithPhiDrift(drift), {}, error);
2529 };
2530
2531 // The same 2e-5 rad drift, on two radii: 2e-7 cm of arc on the small cylinder against 2e-3 cm
2532 // on the large one. One is a rounding error, the other is a real gap.
2533 BOOST_CHECK(acceptsDrift(0.01, 2.e-5)); // the old rule refused this: 2e-5 > 1e-5, radius unseen
2534 BOOST_CHECK(!acceptsDrift(100., 2.e-5));
2535 // and the discrimination really is the radius: give the large cylinder a drift small enough to
2536 // span the same 2e-7 cm and it is accepted; give the small one a gap of 2e-5 cm and it is not.
2537 BOOST_CHECK(acceptsDrift(100., 2.e-9));
2538 BOOST_CHECK(!acceptsDrift(0.01, 2.e-3));
2539 // On a cylinder of radius 1 the two rules coincide up to the change of constant -- which is the
2540 // only configuration the old one was ever right on, and then only by accident.
2541 BOOST_CHECK(acceptsDrift(1., 5.e-7));
2542 BOOST_CHECK(!acceptsDrift(1., 5.e-6)); // the old rule accepted this: 5e-6 < 1e-5
2543}
2544
2545// K12: the polygon and curve wire types are fed by the same extractor with the same per-endpoint
2546// precision, and now judge a join by the same rule. They used to differ by four orders of
2547// magnitude -- 1e-9 for polygons against 1e-5 for curves -- and in incompatible units.
2548BOOST_AUTO_TEST_CASE(PolygonAndCurveWiresShareOneJoinRule)
2549{
2550 using surf::SurfaceEdge;
2551 using surf::Vec2;
2552 using surf::WireRole;
2553 using surf::WireStatus;
2554
2555 // A square whose last edge ends `gap` short of the first edge's start, in a domain where
2556 // (u, v) are already centimetres (the default identity metric).
2557 const auto polygonAcceptsGap = [](double gap) {
2558 const std::vector<SurfaceEdge> edges{{{0., 0.}, {1., 0.}},
2559 {{1., 0.}, {1., 1.}},
2560 {{1., 1.}, {0., 1.}},
2561 {{0., 1.}, {gap, 0.}}};
2562 surf::SurfaceWire wire;
2563 WireStatus status = WireStatus::Valid;
2564 return wire.initializeFromEdges(edges, WireRole::Outer, status, {});
2565 };
2566 const auto curveAcceptsGap = [](double gap) {
2567 const std::vector<surf::Curve2D> curves{surf::Curve2D::makeLine({0., 0.}, {1., 0.}),
2568 surf::Curve2D::makeLine({1., 0.}, {1., 1.}),
2569 surf::Curve2D::makeLine({1., 1.}, {0., 1.}),
2570 surf::Curve2D::makeLine({0., 1.}, {gap, 0.})};
2571 surf::CurveWire wire;
2572 WireStatus status = WireStatus::Valid;
2573 return wire.initialize(curves, WireRole::Outer, status, {});
2574 };
2575
2576 // inside the 1e-6 cm tolerance. 1e-8 and 1e-7 are the discriminating cases: the polygon wire
2577 // used to refuse them at 1e-9 while the curve wire accepted them at 1e-5.
2578 for (const double gap : {0., 1.e-8, 1.e-7}) {
2579 BOOST_CHECK(polygonAcceptsGap(gap));
2580 BOOST_CHECK(curveAcceptsGap(gap));
2581 }
2582 // outside it. 1e-5 is the mirror case: the curve wire used to accept it and the polygon not.
2583 for (const double gap : {1.e-5, 1.e-4}) {
2584 BOOST_CHECK(!polygonAcceptsGap(gap));
2585 BOOST_CHECK(!curveAcceptsGap(gap));
2586 }
2587}
2588
2589namespace
2590{
2591// Helpers writing the surface sidecar binary format documented in
2592// Detectors/CADSupport/doc/reference/BVHSurfaceSolid.md. Kept independent of the loader implementation so the
2593// test is a true round-trip through the documented byte layout.
2594void appendU32(std::vector<char>& bytes, uint32_t value)
2595{
2596 const char* raw = reinterpret_cast<const char*>(&value);
2597 bytes.insert(bytes.end(), raw, raw + sizeof(value));
2598}
2599
2600void appendDoubles(std::vector<char>& bytes, std::initializer_list<double> values)
2601{
2602 for (const double value : values) {
2603 const char* raw = reinterpret_cast<const char*>(&value);
2604 bytes.insert(bytes.end(), raw, raw + sizeof(value));
2605 }
2606}
2607
2608// The fixed header. Version 1 is the three-uint32 form; version 2 appends the model tolerance in
2609// cm. The default stays at version 1 on purpose: every sidecar test below then doubles as a
2610// regression test that the reader still accepts the older format.
2611void appendSidecarHeader(std::vector<char>& bytes, uint32_t nSurfaces, uint32_t version = 1,
2612 double modelTolerance = 0., uint32_t nModelEdges = 0)
2613{
2614 bytes.insert(bytes.end(), {'O', '2', 'S', 'S'});
2615 appendU32(bytes, version);
2616 appendU32(bytes, nSurfaces);
2617 appendU32(bytes, 0); // reserved
2618 if (version >= 2) {
2619 appendDoubles(bytes, {modelTolerance});
2620 }
2621 if (version >= 3) {
2622 appendU32(bytes, nModelEdges); // size of the model's edge table
2623 }
2624}
2625
2626// plane record (type 1) with a single rectangular outer wire of four line-segment edges
2627void appendPlaneRecord(std::vector<char>& bytes, const FaceFrame& frame)
2628{
2629 appendU32(bytes, 1); // surfaceType plane
2630 appendU32(bytes, 0); // flags
2631 appendU32(bytes, 9); // nParams
2632 appendDoubles(bytes, {frame.origin[0], frame.origin[1], frame.origin[2], frame.axisU[0], frame.axisU[1],
2633 frame.axisU[2], frame.axisV[0], frame.axisV[1], frame.axisV[2]});
2634 appendU32(bytes, 1); // nWires
2635 appendU32(bytes, 0); // wireRole outer
2636 appendU32(bytes, 4); // nEdges
2637 const double extentU = frame.extentU;
2638 const double extentV = frame.extentV;
2639 const std::array<std::array<double, 4>, 4> edges{{{0., 0., extentU, 0.},
2640 {extentU, 0., extentU, extentV},
2641 {extentU, extentV, 0., extentV},
2642 {0., extentV, 0., 0.}}};
2643 for (const auto& edge : edges) {
2644 appendU32(bytes, 0); // curveType line
2645 appendU32(bytes, 4); // nCurveParams
2646 appendDoubles(bytes, {edge[0], edge[1], edge[2], edge[3]});
2647 }
2648}
2649
2650// plane record (type 1) for a disk/annulus cap: one full-circle outer arc wire, plus a
2651// clockwise inner arc wire when holeRadius > 0. Exercises the arc-wire reader path.
2652void appendDiskPlaneRecord(std::vector<char>& bytes, const Point3D& center, const Point3D& axisU,
2653 const Point3D& axisV, double radius, double holeRadius = 0.)
2654{
2655 appendU32(bytes, 1); // surfaceType plane
2656 appendU32(bytes, 0); // flags
2657 appendU32(bytes, 9); // nParams
2658 appendDoubles(bytes, {center[0], center[1], center[2], axisU[0], axisU[1], axisU[2], axisV[0], axisV[1], axisV[2]});
2659 const uint32_t nWires = holeRadius > 0. ? 2u : 1u;
2660 appendU32(bytes, nWires);
2661 appendU32(bytes, 0); // outer wire role
2662 appendU32(bytes, 1); // one edge
2663 appendU32(bytes, 1); // curveType arc
2664 appendU32(bytes, 5); // nCurveParams
2665 appendDoubles(bytes, {0., 0., radius, 0., 2. * surf::kPi}); // cu cv radius phiStart phiSweep (CCW full circle)
2666 if (holeRadius > 0.) {
2667 appendU32(bytes, 1); // inner wire role
2668 appendU32(bytes, 1);
2669 appendU32(bytes, 1); // arc
2670 appendU32(bytes, 5);
2671 appendDoubles(bytes, {0., 0., holeRadius, 0., -2. * surf::kPi}); // clockwise hole
2672 }
2673}
2674
2675std::filesystem::path writeSidecarFile(const std::string& name, const std::vector<char>& bytes)
2676{
2677 const auto path = std::filesystem::temp_directory_path() / name;
2678 std::ofstream out(path, std::ios::binary);
2679 out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
2680 BOOST_REQUIRE(out.good());
2681 return path;
2682}
2683} // namespace
2684
2685BOOST_AUTO_TEST_CASE(SurfaceSidecarRoundTrip)
2686{
2687 // planar box: six plane records with polygon wires, loaded and compared against TGeoBBox
2688 constexpr double halfX = 1.;
2689 constexpr double halfY = 2.;
2690 constexpr double halfZ = 3.;
2691
2692 std::vector<char> boxBytes;
2693 appendSidecarHeader(boxBytes, 6);
2694 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
2695 appendPlaneRecord(boxBytes, boxFaceFrame(faceIndex, halfX, halfY, halfZ));
2696 }
2697 const auto boxPath = writeSidecarFile("o2_sidecar_roundtrip_box.bin", boxBytes);
2698
2699 SurfaceSolid box("sidecarBox");
2700 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(boxPath.string(), box));
2701 std::filesystem::remove(boxPath);
2702 BOOST_CHECK_EQUAL(box.GetNsurfaces(), 6);
2703 box.CloseShape();
2704 BOOST_CHECK(box.IsClosed());
2705 BOOST_CHECK(box.IsOrientationConsistent());
2706
2707 TGeoBBox referenceBox("referenceBox", halfX, halfY, halfZ);
2708 compareContainsGrid(box, referenceBox, 4., 7);
2709 compareDistance(box, referenceBox, {5., 0.5, 0.5}, {-1., 0., 0.});
2710 compareDistance(box, referenceBox, {0., 0., 0.}, unitDirection(1., 1., 1.));
2711 checkClose(box.Capacity(), referenceBox.Capacity(), 1.e-9);
2712
2713 // quadric + arc-wire caps: closed cylinder (lateral wall + two disk caps) against TGeoTube
2714 constexpr double radius = 2.;
2715 constexpr double halfHeight = 3.;
2716
2717 std::vector<char> tubeBytes;
2718 appendSidecarHeader(tubeBytes, 3);
2719 appendU32(tubeBytes, 2); // surfaceType cylinder
2720 appendU32(tubeBytes, 0); // flags (outer wall)
2721 appendU32(tubeBytes, 14); // nParams
2722 appendDoubles(tubeBytes, {0., 0., 0., 0., 0., 1., 1., 0., 0., radius, -halfHeight, halfHeight, 0., 2. * surf::kPi});
2723 appendU32(tubeBytes, 0); // nWires
2724 // caps as arc-wire plane records: outward normal is axisU x axisV, so the bottom cap flips axisV
2725 appendDiskPlaneRecord(tubeBytes, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radius);
2726 appendDiskPlaneRecord(tubeBytes, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radius);
2727 const auto tubePath = writeSidecarFile("o2_sidecar_roundtrip_tube.bin", tubeBytes);
2728
2729 SurfaceSolid tube("sidecarTube");
2730 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(tubePath.string(), tube));
2731 std::filesystem::remove(tubePath);
2732 BOOST_CHECK_EQUAL(tube.GetNsurfaces(), 3);
2733 tube.CloseShape();
2734 BOOST_CHECK(tube.IsClosed());
2735 BOOST_CHECK(tube.IsOrientationConsistent());
2736
2737 TGeoTube referenceTube("referenceTube", 0., radius, halfHeight);
2738 compareContainsGrid(tube, referenceTube, 4., 7);
2739 compareDistance(tube, referenceTube, {5., 0.5, 1.}, {-1., 0., 0.});
2740 compareDistance(tube, referenceTube, {0., 0., 0.}, unitDirection(1., 1., 1.));
2741 checkClose(tube.Capacity(), referenceTube.Capacity(), 1.e-9);
2742
2743 // malformed input must be rejected without loading surfaces
2744 const auto badPath = writeSidecarFile("o2_sidecar_bad_magic.bin", {'X', 'X', 'X', 'X', 0, 0, 0, 0});
2745 SurfaceSolid bad("sidecarBad");
2746 BOOST_CHECK(!o2::cad::LoadSurfaceSolid(badPath.string(), bad));
2747 std::filesystem::remove(badPath);
2748 BOOST_CHECK_EQUAL(bad.GetNsurfaces(), 0);
2749 BOOST_CHECK(!o2::cad::LoadSurfaceSolid("/nonexistent/o2_sidecar_missing.bin", bad));
2750
2751 // truncated file: valid header announcing a surface that never follows
2752 std::vector<char> truncatedBytes;
2753 appendSidecarHeader(truncatedBytes, 1);
2754 const auto truncatedPath = writeSidecarFile("o2_sidecar_truncated.bin", truncatedBytes);
2755 SurfaceSolid truncated("sidecarTruncated");
2756 BOOST_CHECK(!o2::cad::LoadSurfaceSolid(truncatedPath.string(), truncated));
2757 std::filesystem::remove(truncatedPath);
2758}
2759
2760// Sidecar version 2 carries the source model's own tolerance, so the kernel stops guessing what
2761// epsilon two faces of an imported solid should agree to. Both versions must load: a v1 file is a
2762// v2 file that simply does not state one.
2763BOOST_AUTO_TEST_CASE(SidecarModelToleranceRoundTrip)
2764{
2765 constexpr double halfX = 1.;
2766 constexpr double halfY = 2.;
2767 constexpr double halfZ = 3.;
2768 const auto boxBytesWithHeader = [&](uint32_t version, double modelTolerance) {
2769 std::vector<char> bytes;
2770 appendSidecarHeader(bytes, 6, version, modelTolerance);
2771 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
2772 appendPlaneRecord(bytes, boxFaceFrame(faceIndex, halfX, halfY, halfZ));
2773 }
2774 return bytes;
2775 };
2776 const auto loadFrom = [](const char* name, const std::vector<char>& bytes, SurfaceSolid& solid) {
2777 const auto path = writeSidecarFile(name, bytes);
2778 const bool ok = o2::cad::LoadSurfaceSolid(path.string(), solid);
2779 std::filesystem::remove(path);
2780 return ok;
2781 };
2782
2783 // version 2: the written tolerance reaches the solid untouched, and survives to CloseShape
2784 SurfaceSolid v2("sidecarV2");
2785 BOOST_REQUIRE(loadFrom("o2_sidecar_v2.bin", boxBytesWithHeader(2, 3.5e-5), v2));
2786 BOOST_CHECK_EQUAL(v2.GetNsurfaces(), 6);
2787 checkClose(v2.GetModelTolerance(), 3.5e-5, 1.e-18);
2788 v2.CloseShape();
2789 checkClose(v2.GetModelTolerance(), 3.5e-5, 1.e-18);
2790
2791 // a v2 file may still state nothing, and "nothing" is zero rather than an invented number
2792 SurfaceSolid v2Silent("sidecarV2Silent");
2793 BOOST_REQUIRE(loadFrom("o2_sidecar_v2_silent.bin", boxBytesWithHeader(2, 0.), v2Silent));
2794 BOOST_CHECK_EQUAL(v2Silent.GetModelTolerance(), 0.);
2795
2796 // version 1: still loads, and gets the reader's documented fallback rather than zero
2797 SurfaceSolid v1("sidecarV1");
2798 BOOST_REQUIRE(loadFrom("o2_sidecar_v1.bin", boxBytesWithHeader(1, 0.), v1));
2799 BOOST_CHECK_EQUAL(v1.GetNsurfaces(), 6);
2800 checkClose(v1.GetModelTolerance(), 1.e-6, 1.e-18);
2801
2802 // a solid nobody told anything keeps zero: "not stated" is not the same as "the fallback"
2803 SurfaceSolid handBuilt("handBuilt");
2804 BOOST_CHECK_EQUAL(handBuilt.GetModelTolerance(), 0.);
2805 handBuilt.SetModelTolerance(1.e-4);
2806 checkClose(handBuilt.GetModelTolerance(), 1.e-4, 1.e-18);
2807 handBuilt.SetModelTolerance(-1.); // refused, and the previous value stands
2808 checkClose(handBuilt.GetModelTolerance(), 1.e-4, 1.e-18);
2809
2810 // version 3 is understood now (it is a version-2 file that also states its edge identities);
2811 // it is exercised in SidecarV3EdgeIdentityRoundTrip below.
2812
2813 // an unknown version is refused rather than reinterpreted
2814 SurfaceSolid v4("sidecarV4");
2815 BOOST_CHECK(!loadFrom("o2_sidecar_v4.bin", boxBytesWithHeader(4, 1.e-5), v4));
2816 BOOST_CHECK_EQUAL(v4.GetNsurfaces(), 0);
2817
2818 // and a v2 header that stops before its tolerance is a truncated file, not a v1 one
2819 std::vector<char> stump;
2820 stump.insert(stump.end(), {'O', '2', 'S', 'S'});
2821 appendU32(stump, 2);
2822 appendU32(stump, 6);
2823 appendU32(stump, 0);
2824 SurfaceSolid stumped("sidecarV2Stump");
2825 BOOST_CHECK(!loadFrom("o2_sidecar_v2_stump.bin", stump, stumped));
2826 BOOST_CHECK_EQUAL(stumped.GetNsurfaces(), 0);
2827}
2828
2829BOOST_AUTO_TEST_CASE(WireTrimmedSidecarRoundTrip)
2830{
2831 // a cylinder record carrying a (line) trim wire block in its (phi, h) domain must load through
2832 // the wire-taking Add* overload and navigate like the equivalent scalar cylinder
2833 constexpr double radius = 2.;
2834 constexpr double halfHeight = 3.;
2835
2836 std::vector<char> bytes;
2837 appendSidecarHeader(bytes, 3);
2838 appendU32(bytes, 2); // surfaceType cylinder
2839 appendU32(bytes, 0); // flags (outer wall)
2840 appendU32(bytes, 14); // nParams
2841 appendDoubles(bytes, {0., 0., 0., 0., 0., 1., 1., 0., 0., radius, -halfHeight, halfHeight, 0., 2. * surf::kPi});
2842 appendU32(bytes, 1); // nWires
2843 appendU32(bytes, 0); // outer wire role
2844 appendU32(bytes, 4); // nEdges
2845 const std::array<std::array<double, 4>, 4> edges{{{0., -halfHeight, 2. * surf::kPi, -halfHeight},
2846 {2. * surf::kPi, -halfHeight, 2. * surf::kPi, halfHeight},
2847 {2. * surf::kPi, halfHeight, 0., halfHeight},
2848 {0., halfHeight, 0., -halfHeight}}};
2849 for (const auto& edge : edges) {
2850 appendU32(bytes, 0); // curveType line
2851 appendU32(bytes, 4); // nCurveParams
2852 appendDoubles(bytes, {edge[0], edge[1], edge[2], edge[3]});
2853 }
2854 appendDiskPlaneRecord(bytes, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radius);
2855 appendDiskPlaneRecord(bytes, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radius);
2856 const auto path = writeSidecarFile("o2_sidecar_wiretrim_cylinder.bin", bytes);
2857
2858 SurfaceSolid solid("sidecarWireCylinder");
2859 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(path.string(), solid));
2860 std::filesystem::remove(path);
2861 BOOST_CHECK_EQUAL(solid.GetNsurfaces(), 3);
2862 solid.CloseShape();
2863 BOOST_CHECK(solid.IsClosed());
2864 BOOST_CHECK(solid.IsOrientationConsistent());
2865
2866 TGeoTube reference("wireTrimSidecarTube", 0., radius, halfHeight);
2867 compareContainsGrid(solid, reference, 4., 7);
2868 compareDistance(solid, reference, {5., 0.5, 1.}, {-1., 0., 0.});
2869 compareDistance(solid, reference, {0., 0., 0.}, unitDirection(1., 1., 1.));
2870 checkClose(solid.Capacity(), reference.Capacity(), 1.e-6);
2871}
2872
2873BOOST_AUTO_TEST_CASE(TorusSidecarRoundTrip)
2874{
2875 // a full-torus record (surfaceType 5, 15 params, empty wire block) must load through the
2876 // scalar AddToroidalSurface path and navigate like TGeoTorus
2877 constexpr double majorR = 3.;
2878 constexpr double minorR = 1.;
2879
2880 std::vector<char> bytes;
2881 appendSidecarHeader(bytes, 1);
2882 appendU32(bytes, 5); // surfaceType torus
2883 appendU32(bytes, 0); // flags (outer wall)
2884 appendU32(bytes, 15); // nParams
2885 appendDoubles(bytes, {0., 0., 0., 0., 0., 1., 1., 0., 0., majorR, minorR, 0., 2. * surf::kPi, 0., 2. * surf::kPi});
2886 appendU32(bytes, 0); // nWires (full torus: scalar path)
2887 const auto path = writeSidecarFile("o2_sidecar_roundtrip_torus.bin", bytes);
2888
2889 SurfaceSolid solid("sidecarTorus");
2890 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(path.string(), solid));
2891 std::filesystem::remove(path);
2892 BOOST_CHECK_EQUAL(solid.GetNsurfaces(), 1);
2893 solid.CloseShape();
2894 BOOST_CHECK(solid.IsClosed());
2895 BOOST_CHECK(solid.IsOrientationConsistent());
2896
2897 TGeoTorus reference("sidecarTorusRef", majorR, 0., minorR);
2898 compareContainsGrid(solid, reference, 4.5, 9);
2899 checkClose(solid.Capacity(), reference.Capacity(), 1.e-7);
2900}
2901
2902// Wire-join gaps are judged against the tolerance the sidecar itself declares (the
2903// version-2 model tolerance), with the extractor-precision constant as the floor -- not against
2904// the bare constant when the model states it cannot do better. This is the ST1829909_01
2905// rejection: surface 1006's bspline->line join gaps by 5.41e-6 cm on a model that declares
2906// 4.7e-4 cm, and the 1e-6 constant "is a fallback, not a measurement of the model". The band
2907// must hold in the loader *and* in the kernel's own wire construction, or the loader would
2908// accept a wire that Add*Surface rejects moments later.
2909BOOST_AUTO_TEST_CASE(StreamY_LoaderHonoursTheDeclaredModelTolerance)
2910{
2911 constexpr double radius = 2.;
2912 constexpr double halfHeight = 3.;
2913 // one seam offset purely in v (cm), so the 3D gap equals the offset on any cylinder:
2914 // over the 1e-6 cm extractor floor, under the model tolerance the loading case declares
2915 constexpr double joinGap = 5.e-6;
2916
2917 const auto cylinderBytes = [&](uint32_t version, double modelTolerance) {
2918 std::vector<char> bytes;
2919 appendSidecarHeader(bytes, 3, version, modelTolerance);
2920 appendU32(bytes, 2); // surfaceType cylinder
2921 appendU32(bytes, 0); // flags (outer wall)
2922 appendU32(bytes, 14); // nParams
2923 appendDoubles(bytes, {0., 0., 0., 0., 0., 1., 1., 0., 0., radius, -halfHeight, halfHeight, 0., 2. * surf::kPi});
2924 appendU32(bytes, 1); // nWires
2925 appendU32(bytes, 0); // outer wire role
2926 appendU32(bytes, 5); // nEdges
2927 // The bottom edge is split in two and the second half starts joinGap off the first half's
2928 // end -- a mid-wire join like surface 1006's bspline->line seam. Deliberately *not* at the
2929 // phi-wrap corner: the full-turn seam pair (u = 0 vs u = 2*pi) must stay exactly coincident
2930 // to cancel in the rim chaining, and the real rejection's face spans only half a turn.
2931 const std::array<std::array<double, 4>, 5> edges{
2932 {{0., -halfHeight, surf::kPi, -halfHeight},
2933 {surf::kPi, -halfHeight + joinGap, 2. * surf::kPi, -halfHeight}, // starts joinGap off edge 0's end
2934 {2. * surf::kPi, -halfHeight, 2. * surf::kPi, halfHeight},
2935 {2. * surf::kPi, halfHeight, 0., halfHeight},
2936 {0., halfHeight, 0., -halfHeight}}};
2937 for (const auto& edge : edges) {
2938 appendU32(bytes, 0); // curveType line
2939 appendU32(bytes, 4); // nCurveParams
2940 appendDoubles(bytes, {edge[0], edge[1], edge[2], edge[3]});
2941 }
2942 appendDiskPlaneRecord(bytes, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radius);
2943 appendDiskPlaneRecord(bytes, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radius);
2944 return bytes;
2945 };
2946 const auto loadFrom = [](const char* name, const std::vector<char>& bytes, SurfaceSolid& solid) {
2947 const auto path = writeSidecarFile(name, bytes);
2948 const bool ok = o2::cad::LoadSurfaceSolid(path.string(), solid);
2949 std::filesystem::remove(path);
2950 return ok;
2951 };
2952
2953 // a model declaring 1e-4 cm: the 5e-6 cm seam is within the model's own statement, so it loads,
2954 // closes, and navigates like the gap-free tube (the kernel canonicalizes each seam on accept)
2955 SurfaceSolid declared("sidecarJoinDeclared");
2956 BOOST_REQUIRE(loadFrom("o2_sidecar_join_declared.bin", cylinderBytes(2, 1.e-4), declared));
2957 BOOST_CHECK_EQUAL(declared.GetNsurfaces(), 3);
2958 declared.CloseShape();
2959 BOOST_CHECK(declared.IsClosed());
2960 BOOST_CHECK(declared.IsOrientationConsistent());
2961 TGeoTube reference("declaredToleranceTube", 0., radius, halfHeight);
2962 compareContainsGrid(declared, reference, 4., 7);
2963 compareDistance(declared, reference, {5., 0.5, 1.}, {-1., 0., 0.});
2964
2965 // a v1 file states nothing, so the extractor-precision floor stands and the same seam is open
2966 SurfaceSolid silent("sidecarJoinSilent");
2967 BOOST_CHECK(!loadFrom("o2_sidecar_join_silent.bin", cylinderBytes(1, 0.), silent));
2968 BOOST_CHECK_EQUAL(silent.GetNsurfaces(), 0);
2969
2970 // a declared tolerance below the gap does not save it: the model itself calls the seam open
2971 SurfaceSolid tight("sidecarJoinTight");
2972 BOOST_CHECK(!loadFrom("o2_sidecar_join_tight.bin", cylinderBytes(2, 2.e-6), tight));
2973 BOOST_CHECK_EQUAL(tight.GetNsurfaces(), 0);
2974}
2975
2976namespace
2977{
2978// Append a plane record whose rectangular outer wire has its bottom edge as a degree-3 B-spline
2979// with collinear poles — geometrically identical to the straight edge, so the box still closes,
2980// but it exercises the whole B-spline sidecar pipeline (curveType 2 reader -> kernel).
2981void appendBSplineEdgePlaneRecord(std::vector<char>& bytes, const FaceFrame& frame)
2982{
2983 appendU32(bytes, 1); // surfaceType plane
2984 appendU32(bytes, 0); // flags
2985 appendU32(bytes, 9); // nParams
2986 appendDoubles(bytes, {frame.origin[0], frame.origin[1], frame.origin[2], frame.axisU[0], frame.axisU[1],
2987 frame.axisU[2], frame.axisV[0], frame.axisV[1], frame.axisV[2]});
2988 appendU32(bytes, 1); // nWires
2989 appendU32(bytes, 0); // wireRole outer
2990 appendU32(bytes, 4); // nEdges
2991 const double extentU = frame.extentU;
2992 const double extentV = frame.extentV;
2993 // edge 0: collinear cubic B-spline from (0, 0) to (extentU, 0)
2994 appendU32(bytes, 2); // curveType bspline
2995 appendU32(bytes, 22); // nCurveParams = 2 + 2*4 + 4 + 8
2996 appendDoubles(bytes, {3., 4., // degree, nPoles
2997 0., 0., extentU / 3., 0., 2. * extentU / 3., 0., extentU, 0., // poles
2998 1., 1., 1., 1., // weights
2999 0., 0., 0., 0., 1., 1., 1., 1.}); // clamped knots
3000 const std::array<std::array<double, 4>, 3> lines{
3001 {{extentU, 0., extentU, extentV}, {extentU, extentV, 0., extentV}, {0., extentV, 0., 0.}}};
3002 for (const auto& edge : lines) {
3003 appendU32(bytes, 0); // curveType line
3004 appendU32(bytes, 4); // nCurveParams
3005 appendDoubles(bytes, {edge[0], edge[1], edge[2], edge[3]});
3006 }
3007}
3008
3009// A rational quadratic B-spline (NURBS) quarter circle from angle a0 to a0 + pi/2, in the (u, v)
3010// domain, centred at (cu, cv) with radius r. Four of these form an exact circle.
3015surf::Curve2D fullCircleBSpline(double cu, double cv, double r)
3016{
3017 const double w = std::sqrt(0.5);
3018 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}};
3019 return surf::Curve2D::makeBSpline(2, poles, {1., w, 1., w, 1., w, 1., w, 1.},
3020 {0., 0., 0., 1., 1., 2., 2., 3., 3., 4., 4., 4.});
3021}
3022
3023surf::Curve2D quarterCircleBSpline(double cu, double cv, double r, double a0)
3024{
3025 const double a1 = a0 + surf::kHalfPi;
3026 const double aMid = 0.5 * (a0 + a1);
3027 const std::vector<surf::Vec2> poles{{cu + r * std::cos(a0), cv + r * std::sin(a0)},
3028 {cu + r * std::sqrt(2.) * std::cos(aMid), cv + r * std::sqrt(2.) * std::sin(aMid)},
3029 {cu + r * std::cos(a1), cv + r * std::sin(a1)}};
3030 return surf::Curve2D::makeBSpline(2, poles, {1., std::sqrt(0.5), 1.}, {0., 0., 0., 1., 1., 1.});
3031}
3032} // namespace
3033
3034// K5: the on-boundary band has to be as wide as the representation it measures against, and
3035// winding and distance have to measure against the same polyline.
3036BOOST_AUTO_TEST_CASE(BoundaryBandMatchesTheRepresentation)
3037{
3038 using surf::Vec2;
3040 using surf::WireRole;
3041 using surf::WireStatus;
3042 std::string error;
3043
3044 // A loop of lines and arcs is held exactly and claims no width of its own...
3045 surf::CurveWire exactWire;
3046 WireStatus status = WireStatus::Valid;
3047 BOOST_REQUIRE(exactWire.initialize({surf::Curve2D::makeCircle({0., 0.}, 1.)}, WireRole::Outer, status));
3048 BOOST_CHECK_EQUAL(exactWire.representationTolerance(), 0.);
3049
3050 // ...while a B-spline loop is only as good as the polyline it is flattened to.
3051 surf::CurveWire splineWire;
3052 BOOST_REQUIRE(splineWire.initialize({fullCircleBSpline(0., 0., 1.)}, WireRole::Outer, status));
3053 checkClose(splineWire.representationTolerance(), surf::kBSplineFlatness, 1.e-18);
3054
3055 // The Boundary state must therefore be reachable for a B-spline trim. It was not: a 1e-9 band
3056 // around a 1e-5 polyline is noise, so a point this close to the curve used to come back Inside
3057 // or Outside by coin flip.
3058 const double justInsideTheBand = 0.5 * surf::kBSplineFlatness;
3059 BOOST_CHECK(splineWire.classify({1. - justInsideTheBand, 0.}) == WireClassification::Boundary);
3060 BOOST_CHECK(splineWire.classify({1. + justInsideTheBand, 0.}) == WireClassification::Boundary);
3061 // and well outside the band the answer is decided again, in both directions
3062 BOOST_CHECK(splineWire.classify({0.5, 0.}) == WireClassification::Inside);
3063 BOOST_CHECK(splineWire.classify({2.0, 0.}) == WireClassification::Outside);
3064 // the exact loop keeps its narrow band: the same offset is decidable there
3065 BOOST_CHECK(exactWire.classify({1. - justInsideTheBand, 0.}) == WireClassification::Inside);
3066
3067 // The band is a length, so on a surface that stretches the domain it narrows in parametric
3068 // terms. A 100 cm cylinder resolves 1e-9 cm at 1e-11 rad, not at 1e-9 rad.
3070 BOOST_REQUIRE(bigCylinder.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 100., -1., 1., 0., surf::kTwoPi,
3071 false, error));
3072 const auto bigMetric = surf::parametricMetricOf(bigCylinder);
3073 surf::CurveWire squareWire;
3074 BOOST_REQUIRE(squareWire.initialize({surf::Curve2D::makeLine({0., -1.}, {1., -1.}),
3075 surf::Curve2D::makeLine({1., -1.}, {1., 1.}),
3076 surf::Curve2D::makeLine({1., 1.}, {0., 1.}),
3077 surf::Curve2D::makeLine({0., 1.}, {0., -1.})},
3078 WireRole::Outer, status, bigMetric));
3079 // 1e-10 rad is 1e-8 cm on this cylinder -- outside a 1e-9 cm band, so the point is decidable
3080 BOOST_CHECK(squareWire.classify({0.5, 1. - 1.e-10}, bigMetric) == WireClassification::Inside);
3081 // 1e-12 rad is 1e-10 cm, inside it
3082 BOOST_CHECK(squareWire.classify({0.5, 1. - 1.e-12}, bigMetric) == WireClassification::Boundary);
3083
3084 // One polyline: a wire fixes one vertex value per seam, and both the winding polyline and the
3085 // point-to-curve distance are built from it. Give two quarter arcs endpoints that differ within
3086 // the join tolerance and the wire must still agree with itself about where its boundary is.
3087 const double seamDrift = 4.e-7;
3088 surf::Curve2D first = quarterCircleBSpline(0., 0., 1., 0.);
3089 surf::Curve2D second = quarterCircleBSpline(0., 0., 1., surf::kHalfPi);
3090 surf::Curve2D third = quarterCircleBSpline(0., 0., 1., surf::kPi);
3091 surf::Curve2D fourth = quarterCircleBSpline(0., 0., 1., 3. * surf::kHalfPi);
3092 second.poles.front() = {second.poles.front().uCoord + seamDrift, second.poles.front().vCoord};
3093 surf::CurveWire driftedWire;
3094 BOOST_REQUIRE(driftedWire.initialize({first, second, third, fourth}, WireRole::Outer, status));
3095 for (const auto& curve : driftedWire.curves) {
3096 BOOST_CHECK(curve.hasCanonicalEndpoints);
3097 }
3098 // every curve now begins exactly where its predecessor ends -- there is one boundary, not two
3099 for (size_t index = 0; index < driftedWire.curves.size(); ++index) {
3100 const Vec2 thisEnd = driftedWire.curves[index].loopEnd();
3101 const Vec2 nextStart = driftedWire.curves[(index + 1) % driftedWire.curves.size()].loopStart();
3102 BOOST_CHECK_EQUAL(thisEnd.uCoord, nextStart.uCoord);
3103 BOOST_CHECK_EQUAL(thisEnd.vCoord, nextStart.vCoord);
3104 }
3105 // and the polyline the winding walks is the one the distance measures against
3106 for (const auto& curve : driftedWire.curves) {
3107 const auto& polyline = curve.bsplineSamples();
3108 BOOST_REQUIRE(polyline.size() >= 2);
3109 BOOST_CHECK_EQUAL(polyline.front().uCoord, curve.loopStart().uCoord);
3110 BOOST_CHECK_EQUAL(polyline.front().vCoord, curve.loopStart().vCoord);
3111 BOOST_CHECK_EQUAL(polyline.back().uCoord, curve.loopEnd().uCoord);
3112 BOOST_CHECK_EQUAL(polyline.back().vCoord, curve.loopEnd().vCoord);
3113 }
3114 BOOST_CHECK(driftedWire.classify({0., 0.}) == WireClassification::Inside);
3115 BOOST_CHECK(driftedWire.classify({3., 0.}) == WireClassification::Outside);
3116}
3117
3118// The other half of the on-boundary band check.
3119//
3120// BoundaryBandMatchesTheRepresentation above pins the *width* of the band. This pins what happens
3121// to a ray that lands in it. Resolving Boundary as "inside the trim" is a tie-break, not a fact,
3122// and it is one-sided: the patch keeps a sliver of the band's width past its true trim curve. On a
3123// Boolean seam that sliver lies in the solid's interior, where a crossing must not be counted, so
3124// a ray through it gains a spurious crossing and Contains() flips.
3125//
3126// Measured on cyl_cross_cyl (two unit cylinders fused, whose seam is transcendental in either
3127// face's chart, so it has to be carried as a B-spline): every one of 1440 sampled positions along
3128// the true seam overhangs by 1.0e-5 to 1.9e-5 cm and *none* undercuts -- the floor being the band
3129// itself and the excess the polyline flattening. That is the single direction-dependent point the
3130// section 4.2 sweep found, and it is not the root-finding defect (K6) it was filed as.
3131//
3132// The kernel cannot remove the sliver -- the data does not say where the seam is to better than
3133// this -- so it labels it instead, and Contains() re-aims when a shot rests on one. Hence the
3134// contract here: the flag is set exactly when the answer came from the tie-break, and is *not*
3135// set for a hit the trim decides on its own, because a flag that fired everywhere would put every
3136// query on the voting path.
3137BOOST_AUTO_TEST_CASE(TrimBoundaryHitsAreFlaggedAsAmbiguous)
3138{
3139 using surf::Curve2D;
3140 std::string error;
3141
3142 // a cylinder of radius 2 carrying a circular B-spline window of radius 0.5 in (phi, h)
3143 const double centrePhi = surf::kPi;
3144 const double trimRadius = 0.5;
3145 surf::CylindricalBoundedSurface splineTrimmed;
3146 BOOST_REQUIRE(splineTrimmed.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi,
3147 false,
3148 {quarterCircleBSpline(centrePhi, 0., trimRadius, 0.),
3149 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kHalfPi),
3150 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kPi),
3151 quarterCircleBSpline(centrePhi, 0., trimRadius, 3. * surf::kHalfPi)},
3152 {}, error));
3153
3154 // the same window held exactly, as one arc: it claims no width, so nothing is ever ambiguous
3156 BOOST_REQUIRE(arcTrimmed.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
3157 {Curve2D::makeCircle({centrePhi, 0.}, trimRadius)}, {}, error));
3158
3159 // a radial ray that meets the wall at azimuth phi, h = 0
3160 const auto hitAt = [](const surf::CylindricalBoundedSurface& surface, double phi) {
3161 std::vector<surf::RayHit> hits;
3162 surface.appendIntersections({0., 0., 0.}, {std::cos(phi), std::sin(phi), 0.}, 0., 1.e30, hits);
3163 return hits;
3164 };
3165
3166 // The band on this surface is the representation's own tolerance: 1e-5 in (phi, h), since the
3167 // length floor kTolerance / maxScale is 1e-9 / 2 and loses.
3168 const double band = surf::kBSplineFlatness;
3169 const double justInside = 0.5 * band;
3170
3171 // 1. well inside the window the trim decides by itself -- accepted, and NOT flagged
3172 {
3173 const auto hits = hitAt(splineTrimmed, centrePhi);
3174 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
3175 BOOST_CHECK(!hits.front().onTrimBoundary);
3176 }
3177 // 2. inside the window but within the band of its edge -- accepted, and flagged
3178 {
3179 const auto hits = hitAt(splineTrimmed, centrePhi + trimRadius - justInside);
3180 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
3181 BOOST_CHECK(hits.front().onTrimBoundary);
3182 }
3183 // 3. OUTSIDE the window, still within the band -- accepted anyway, and flagged. This is the
3184 // sliver: the tie-break keeps material the trim curve does not enclose.
3185 {
3186 const auto hits = hitAt(splineTrimmed, centrePhi + trimRadius + justInside);
3187 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
3188 BOOST_CHECK(hits.front().onTrimBoundary);
3189 }
3190 // 4. beyond the band the patch really does end
3191 {
3192 BOOST_CHECK(hitAt(splineTrimmed, centrePhi + trimRadius + 100. * band).empty());
3193 }
3194 // 5. an exactly-held trim has no sliver to label: the same offsets are decided, not flagged
3195 {
3196 const auto inside = hitAt(arcTrimmed, centrePhi + trimRadius - justInside);
3197 BOOST_REQUIRE_EQUAL(inside.size(), 1u);
3198 BOOST_CHECK(!inside.front().onTrimBoundary);
3199 BOOST_CHECK(hitAt(arcTrimmed, centrePhi + trimRadius + justInside).empty());
3200 }
3201 // 6. and an untrimmed patch never sets it, which is what keeps the fast path fast
3202 {
3204 BOOST_REQUIRE(plain.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
3205 error));
3206 const auto hits = hitAt(plain, centrePhi);
3207 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
3208 BOOST_CHECK(!hits.front().onTrimBoundary);
3209 }
3210}
3211
3212BOOST_AUTO_TEST_CASE(BSplineSidecarRoundTrip)
3213{
3214 // a closed box whose first face carries a (collinear) B-spline boundary edge round-trips through
3215 // the sidecar reader and navigates identically to TGeoBBox
3216 constexpr double halfX = 1.;
3217 constexpr double halfY = 2.;
3218 constexpr double halfZ = 3.;
3219
3220 std::vector<char> bytes;
3221 appendSidecarHeader(bytes, 6);
3222 appendBSplineEdgePlaneRecord(bytes, boxFaceFrame(0, halfX, halfY, halfZ));
3223 for (int faceIndex = 1; faceIndex < 6; ++faceIndex) {
3224 appendPlaneRecord(bytes, boxFaceFrame(faceIndex, halfX, halfY, halfZ));
3225 }
3226 const auto path = writeSidecarFile("o2_sidecar_bspline_box.bin", bytes);
3227
3228 SurfaceSolid box("sidecarBSplineBox");
3229 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(path.string(), box));
3230 std::filesystem::remove(path);
3231 BOOST_CHECK_EQUAL(box.GetNsurfaces(), 6);
3232 box.CloseShape();
3233 BOOST_CHECK(box.IsClosed());
3234 BOOST_CHECK(box.IsOrientationConsistent());
3235
3236 TGeoBBox reference("bsplineBoxRef", halfX, halfY, halfZ);
3237 compareContainsGrid(box, reference, 4., 7);
3238 compareDistance(box, reference, {5., 0.5, 0.5}, {-1., 0., 0.});
3239 compareDistance(box, reference, {0., 0., 0.}, unitDirection(1., 1., 1.));
3240 checkClose(box.Capacity(), reference.Capacity(), 1.e-6);
3241}
3242
3243BOOST_AUTO_TEST_CASE(BSplineWindowInCylinderWall)
3244{
3245 using surf::Curve2D;
3246 using surf::Vec3;
3247 std::string error;
3248
3249 const auto onCylinder = [](double phi, double height) {
3250 return Vec3{2. * std::cos(phi), 2. * std::sin(phi), height};
3251 };
3252
3253 // an exact circular trim in (phi, h) built from four NURBS quarter arcs must classify identically
3254 // to the same circle expressed as one exact arc Curve2D — validating the B-spline trim path on a
3255 // quadric against the closed-form arc path.
3256 const double centrePhi = surf::kPi;
3257 const double trimRadius = 0.5;
3259 const std::vector<Curve2D> bsplineOuter{quarterCircleBSpline(centrePhi, 0., trimRadius, 0.),
3260 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kHalfPi),
3261 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kPi),
3262 quarterCircleBSpline(centrePhi, 0., trimRadius, 3. * surf::kHalfPi)};
3263 BOOST_REQUIRE(bsplineDisk.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
3264 bsplineOuter, {}, error));
3265 BOOST_CHECK(!bsplineDisk.capacityIsExact()); // B-spline (wire) trim -> numeric capacity
3266
3268 BOOST_REQUIRE(arcDisk.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
3269 {Curve2D::makeCircle({centrePhi, 0.}, trimRadius)}, {}, error));
3270
3271 // classification agrees across a grid of the (phi, h) neighbourhood of the trim (skip a thin band
3272 // around the boundary, where the exact-arc and sampled-B-spline classifications can legitimately
3273 // differ by the sampling tolerance)
3274 int compared = 0;
3275 for (int phiStep = -12; phiStep <= 12; ++phiStep) {
3276 const double phi = centrePhi + 0.09 * phiStep;
3277 for (int hStep = -12; hStep <= 12; ++hStep) {
3278 const double height = 0.09 * hStep;
3279 // radial distance in (phi, h) from the trim centre; skip the boundary band
3280 const double distToCentre = std::hypot(phi - centrePhi, height);
3281 if (std::abs(distToCentre - trimRadius) < 5.e-3) {
3282 continue;
3283 }
3284 const Vec3 point = onCylinder(phi, height);
3285 BOOST_CHECK_EQUAL(bsplineDisk.containsPointOnSurface(point), arcDisk.containsPointOnSurface(point));
3286 ++compared;
3287 }
3288 }
3289 BOOST_CHECK_GT(compared, 100);
3290
3291 // a radial ray into the B-spline window (its centre) registers exactly one wall hit; a ray well
3292 // outside the window (opposite side of the cylinder) misses the trimmed patch
3293 std::vector<surf::RayHit> hits;
3294 bsplineDisk.appendIntersections({0., 0., 0.}, {std::cos(centrePhi), std::sin(centrePhi), 0.}, 0., 1.e30, hits);
3295 BOOST_REQUIRE_EQUAL(hits.size(), 1u);
3296 checkClose(hits.front().distance, 2.);
3297 hits.clear();
3298 bsplineDisk.appendIntersections({0., 0., 0.}, {std::cos(0.), std::sin(0.), 0.}, 0., 1.e30, hits);
3299 BOOST_CHECK(hits.empty());
3300}
3301
3302// A B-spline wire used as an *inner hole*, and in particular one spelled as a single CLOSED
3303// B-spline edge. This is what a tube-tube intersection produces and what the converter emits
3304// constantly: where a boom tube is planted on a fat tube, the fat tube's wall keeps a
3305// full-rectangle outer wire and carries the intersection curve as one closed B-spline hole.
3306//
3307// It regresses a bug that silently deleted such a wire outright. bsplineSampleRecursive used to
3308// end the recursion when the chord p0->p1 was shorter than the flatness scale; a closed curve has
3309// p0 == p1 exactly, so a full circle flattened to two coincident points and every polyline-based
3310// query (winding, closest point, boundary band, display mesh) saw an empty curve. The wire still
3311// validated and still reported the correct enclosed area, because signedAreaContribution
3312// integrates the curve by Gauss-Legendre rather than from the polyline -- which is exactly why
3313// this survived: every check that could have caught it used the analytic path.
3314//
3315// Impact: on ExcavatorArm/BoomCylinderOuter_0_1_1_9 a point 0.026 cm inside such a hole was reported as
3316// lying on the face, and a whole face whose outer wire was one closed B-spline did not exist at
3317// all. `WireTrimmedQuadricKernels` covers a *line* hole and `BSplineWindowInCylinderWall` covers a
3318// B-spline outer wire built from four *open* quarter arcs, so neither could see it.
3319BOOST_AUTO_TEST_CASE(BSplineHoleInCylinderWall)
3320{
3321 using surf::Curve2D;
3322 using surf::Vec3;
3323 std::string error;
3324
3325 const auto onCylinder = [](double phi, double height) {
3326 return Vec3{2. * std::cos(phi), 2. * std::sin(phi), height};
3327 };
3328
3329 // full-sweep outer wire (what the converter writes for an untrimmed cylinder wall) ...
3330 const std::vector<Curve2D> outer{Curve2D::makeLine({0., -3.}, {surf::kTwoPi, -3.}),
3331 Curve2D::makeLine({surf::kTwoPi, -3.}, {surf::kTwoPi, 3.}),
3332 Curve2D::makeLine({surf::kTwoPi, 3.}, {0., 3.}),
3333 Curve2D::makeLine({0., 3.}, {0., -3.})};
3334 // ... with a circular hole punched in it, expressed once as four NURBS quarter arcs and once as
3335 // the equivalent exact arc. The arc form is the oracle: it is the already-trusted path.
3336 const double centrePhi = surf::kPi;
3337 const double trimRadius = 0.5;
3338 const std::vector<Curve2D> bsplineHole{quarterCircleBSpline(centrePhi, 0., trimRadius, 0.),
3339 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kHalfPi),
3340 quarterCircleBSpline(centrePhi, 0., trimRadius, surf::kPi),
3341 quarterCircleBSpline(centrePhi, 0., trimRadius, 3. * surf::kHalfPi)};
3342
3344 BOOST_REQUIRE(bsplineHoled.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -3., 3., 0., surf::kTwoPi, false,
3345 outer, {bsplineHole}, error));
3347 BOOST_REQUIRE(arcHoled.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -3., 3., 0., surf::kTwoPi, false,
3348 outer, {{Curve2D::makeCircle({centrePhi, 0.}, trimRadius)}}, error));
3349
3350 // the defining property of a hole: its interior is NOT part of the face
3351 BOOST_CHECK(!arcHoled.containsPointOnSurface(onCylinder(centrePhi, 0.))); // oracle
3352 BOOST_CHECK(!bsplineHoled.containsPointOnSurface(onCylinder(centrePhi, 0.))); // the case under test
3353 // and material well away from the hole still is
3354 BOOST_CHECK(bsplineHoled.containsPointOnSurface(onCylinder(0.5, 0.)));
3355 BOOST_CHECK(bsplineHoled.containsPointOnSurface(onCylinder(centrePhi, 2.5)));
3356
3357 // the two spellings of the same hole must classify identically away from the boundary band
3358 int compared = 0;
3359 for (int phiStep = -12; phiStep <= 12; ++phiStep) {
3360 const double phi = centrePhi + 0.09 * phiStep;
3361 for (int hStep = -12; hStep <= 12; ++hStep) {
3362 const double height = 0.09 * hStep;
3363 if (std::abs(std::hypot(phi - centrePhi, height) - trimRadius) < 5.e-3) {
3364 continue;
3365 }
3366 const Vec3 point = onCylinder(phi, height);
3367 BOOST_CHECK_EQUAL(bsplineHoled.containsPointOnSurface(point), arcHoled.containsPointOnSurface(point));
3368 ++compared;
3369 }
3370 }
3371 BOOST_CHECK_GT(compared, 100);
3372
3373 // a radial ray aimed through the hole must not register a wall hit; one aimed at material must
3374 std::vector<surf::RayHit> hits;
3375 bsplineHoled.appendIntersections({0., 0., 0.}, {std::cos(centrePhi), std::sin(centrePhi), 0.}, 0., 1.e30, hits);
3376 BOOST_CHECK(hits.empty());
3377 hits.clear();
3378 bsplineHoled.appendIntersections({0., 0., 0.}, {std::cos(0.5), std::sin(0.5), 0.}, 0., 1.e30, hits);
3379 BOOST_CHECK_EQUAL(hits.size(), 1u);
3380
3381 // The same hole as ONE closed B-spline edge rather than four arc segments. This is what the
3382 // converter actually emits for a tube-tube seam (`_quadric_trim_wire` writes one B-spline per
3383 // BREP edge, and the intersection curve is a single closed edge).
3384 surf::CylindricalBoundedSurface singleEdgeHoled;
3385 BOOST_REQUIRE(singleEdgeHoled.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -3., 3., 0., surf::kTwoPi,
3386 false, outer, {{fullCircleBSpline(centrePhi, 0., trimRadius)}}, error));
3387 BOOST_CHECK(!singleEdgeHoled.containsPointOnSurface(onCylinder(centrePhi, 0.)));
3388 BOOST_CHECK(singleEdgeHoled.containsPointOnSurface(onCylinder(0.5, 0.)));
3389 BOOST_CHECK(singleEdgeHoled.containsPointOnSurface(onCylinder(centrePhi, 2.5)));
3390 for (int phiStep = -12; phiStep <= 12; ++phiStep) {
3391 const double phi = centrePhi + 0.09 * phiStep;
3392 for (int hStep = -12; hStep <= 12; ++hStep) {
3393 const double height = 0.09 * hStep;
3394 if (std::abs(std::hypot(phi - centrePhi, height) - trimRadius) < 5.e-3) {
3395 continue;
3396 }
3397 const Vec3 point = onCylinder(phi, height);
3398 BOOST_CHECK_EQUAL(singleEdgeHoled.containsPointOnSurface(point), arcHoled.containsPointOnSurface(point));
3399 }
3400 }
3401}
3402
3403namespace
3404{
3405// The public-API mirror of quarterCircleBSpline, for building a NURBS trim through Add*Surface.
3406BoundaryCurve quarterCircleBoundaryCurve(double cu, double cv, double r, double a0)
3407{
3408 const double a1 = a0 + surf::kHalfPi;
3409 const double aMid = 0.5 * (a0 + a1);
3410 const std::vector<Point2D> poles{{cu + r * std::cos(a0), cv + r * std::sin(a0)},
3411 {cu + r * std::sqrt(2.) * std::cos(aMid), cv + r * std::sqrt(2.) * std::sin(aMid)},
3412 {cu + r * std::cos(a1), cv + r * std::sin(a1)}};
3413 return BoundaryCurve::makeBSpline(2, poles, {1., std::sqrt(0.5), 1.}, {0., 0., 0., 1., 1., 1.});
3414}
3415
3416// Assert that two solids are the *same* solid, not merely similar ones: identical closure
3417// diagnostics and reliability, identical bounding box and capacity, and bit-identical answers
3418// from all four navigation kernels over the standard probe grid and direction set. This is the
3419// acceptance criterion for persistence -- a solid that survives a write/read cycle must be
3420// indistinguishable through the public interface.
3421void checkSolidsIdentical(const SurfaceSolid& solid, const SurfaceSolid& other, double extent, int samples)
3422{
3423 BOOST_CHECK_EQUAL(other.GetNsurfaces(), solid.GetNsurfaces());
3424 BOOST_CHECK_EQUAL(other.IsDefined(), solid.IsDefined());
3425 BOOST_CHECK_EQUAL(other.HasBVH(), solid.HasBVH());
3426 BOOST_CHECK_EQUAL(other.IsClosed(), solid.IsClosed());
3427 BOOST_CHECK_EQUAL(other.IsOrientationConsistent(), solid.IsOrientationConsistent());
3428 BOOST_CHECK_EQUAL(static_cast<int>(other.GetNavigationReliability()),
3429 static_cast<int>(solid.GetNavigationReliability()));
3430 BOOST_CHECK_EQUAL(other.GetBoundaryEdgeCount(), solid.GetBoundaryEdgeCount());
3431 BOOST_CHECK_EQUAL(other.GetNonManifoldEdgeCount(), solid.GetNonManifoldEdgeCount());
3432 BOOST_CHECK_EQUAL(other.GetReversedEdgeCount(), solid.GetReversedEdgeCount());
3433
3434 BOOST_CHECK_EQUAL(other.GetDX(), solid.GetDX());
3435 BOOST_CHECK_EQUAL(other.GetDY(), solid.GetDY());
3436 BOOST_CHECK_EQUAL(other.GetDZ(), solid.GetDZ());
3437 for (int dimension = 0; dimension < 3; ++dimension) {
3438 BOOST_CHECK_EQUAL(other.GetOrigin()[dimension], solid.GetOrigin()[dimension]);
3439 }
3440 BOOST_CHECK_EQUAL(other.Capacity(), solid.Capacity());
3441
3442 for (const auto& point : probeGrid(extent, samples)) {
3443 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
3444 {
3445 BOOST_CHECK_EQUAL(other.Contains(point.data()), solid.Contains(point.data()));
3446 BOOST_CHECK_EQUAL(other.Safety(point.data(), solid.Contains(point.data())),
3447 solid.Safety(point.data(), solid.Contains(point.data())));
3448 for (const auto& direction : probeDirections()) {
3449 BOOST_CHECK_EQUAL(other.DistFromOutside(point.data(), direction.data(), 3),
3450 solid.DistFromOutside(point.data(), direction.data(), 3));
3451 BOOST_CHECK_EQUAL(other.DistFromInside(point.data(), direction.data(), 3),
3452 solid.DistFromInside(point.data(), direction.data(), 3));
3453 }
3454 }
3455 }
3456}
3457
3458// Write "solid" to a ROOT file and read it back as an independent object.
3459std::unique_ptr<SurfaceSolid> writeAndReadBack(const SurfaceSolid& solid)
3460{
3461 const auto path = std::filesystem::temp_directory_path() /
3462 (std::string("o2_bvhsurfacesolid_persist_") + solid.GetName() + ".root");
3463 {
3464 TFile file(path.string().c_str(), "RECREATE");
3465 BOOST_REQUIRE(!file.IsZombie());
3466 // WriteObject takes a non-const pointer; the call does not modify the solid.
3467 file.WriteObject(const_cast<SurfaceSolid*>(&solid), "solid");
3468 }
3469 std::unique_ptr<SurfaceSolid> restored;
3470 {
3471 TFile file(path.string().c_str(), "READ");
3472 BOOST_REQUIRE(!file.IsZombie());
3473 restored.reset(file.Get<SurfaceSolid>("solid"));
3474 }
3475 std::filesystem::remove(path);
3476 return restored;
3477}
3478} // namespace
3479
3480// ROOT persistence round trip. The kernel objects behind the solid (BoundedSurface, the BVH, the
3481// display mesh) are all *derived* state; what has to survive a write/read cycle is the sequence of
3482// Add*Surface calls the solid was built from, after which CloseShape() reconstructs the rest.
3483//
3484// It regresses a bug where nothing at all was streamed: fImpl was transient, so
3485// a read-back solid came back with zero surfaces, CloseShape(false) then zeroed the streamed
3486// bounding box, and an *empty* ClosureReport defaults to closed/consistent -- so the husk reported
3487// NavigationReliability::Reliable and answered "outside" everywhere with full confidence. Any
3488// TGeoManager::Export/Import of a geometry containing one of these solids silently replaced it by
3489// an authoritatively-reliable empty point.
3490BOOST_AUTO_TEST_CASE(PersistenceRoundTrip)
3491{
3492 // every surface family and both trim flavours (scalar range and wire trim, the latter with
3493 // line, arc and B-spline curves) must survive, so each record field is exercised
3494 const auto box = makeBoxSolid("persistBox", 1., 2., 3.);
3495 const auto tube = makeTubeSolid("persistTube", 1., 2., 3.); // inner wall + annular arc-wire caps
3496 const auto cone = makeConeSolid("persistCone", 2., 1., 3.);
3497 const auto sphere = makeSphereSolid("persistSphere", 2.);
3498 const auto torus = makeTorusSolid("persistTorus", 3., 1.);
3499 const auto capsule = makeCapsuleSolid("persistCapsule", 2., 3.);
3500
3501 for (const auto* solid : {box.get(), tube.get(), cone.get(), sphere.get(), torus.get(), capsule.get()}) {
3502 BOOST_TEST_CONTEXT("solid = " << solid->GetName())
3503 {
3504 const auto restored = writeAndReadBack(*solid);
3505 BOOST_REQUIRE(restored != nullptr);
3506 checkSolidsIdentical(*solid, *restored, 4.5, 5);
3507 }
3508 }
3509
3510 // a wire-trimmed cylinder whose window is a NURBS loop: the B-spline degree, poles, weights and
3511 // knots all have to make the round trip, and the trimmed overload has to be the one replayed
3512 SurfaceSolid trimmed("persistTrimmed");
3513 constexpr double radius = 2.;
3514 constexpr double halfHeight = 3.;
3515 const std::vector<BoundaryCurve> window{quarterCircleBoundaryCurve(surf::kPi, 0., 0.5, 0.),
3516 quarterCircleBoundaryCurve(surf::kPi, 0., 0.5, surf::kHalfPi),
3517 quarterCircleBoundaryCurve(surf::kPi, 0., 0.5, surf::kPi),
3518 quarterCircleBoundaryCurve(surf::kPi, 0., 0.5, 3. * surf::kHalfPi)};
3519 BOOST_REQUIRE(trimmed.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -halfHeight,
3520 halfHeight, 0., surf::kTwoPi, false, window));
3521 BOOST_REQUIRE(addDiskSurface(trimmed, {0., 0., halfHeight}, {1., 0., 0.}, {0., 1., 0.}, radius));
3522 BOOST_REQUIRE(addDiskSurface(trimmed, {0., 0., -halfHeight}, {1., 0., 0.}, {0., -1., 0.}, radius));
3523 trimmed.CloseShape(false);
3524 BOOST_CHECK_EQUAL(trimmed.GetNsurfaces(), 3);
3525
3526 const auto restoredTrimmed = writeAndReadBack(trimmed);
3527 BOOST_REQUIRE(restoredTrimmed != nullptr);
3528 checkSolidsIdentical(trimmed, *restoredTrimmed, 4.5, 5);
3529
3530 // the model's own tolerance is solid-level state, not derived from the records, so it has to be
3531 // streamed rather than recomputed on replay
3532 SurfaceSolid toleranced("persistTolerance");
3533 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
3534 BOOST_REQUIRE(addBoxFace(toleranced, faceIndex, 1., 2., 3.));
3535 }
3536 toleranced.SetModelTolerance(7.25e-5);
3537 toleranced.CloseShape(false);
3538 const auto restoredToleranced = writeAndReadBack(toleranced);
3539 BOOST_REQUIRE(restoredToleranced != nullptr);
3540 checkClose(restoredToleranced->GetModelTolerance(), 7.25e-5, 1.e-18);
3541
3542 // an unnavigable solid must come back unnavigable: the failure mode S1 describes is precisely a
3543 // defective solid that acquires a clean bill of health by losing its surfaces on the way
3544 SurfaceSolid openBox("persistOpenBox");
3545 for (int faceIndex = 0; faceIndex < 5; ++faceIndex) { // deliberately missing the sixth face
3546 BOOST_REQUIRE(addBoxFace(openBox, faceIndex, 1., 2., 3.));
3547 }
3548 openBox.CloseShape(false);
3549 BOOST_REQUIRE(!openBox.IsNavigable());
3550 BOOST_CHECK_EQUAL(static_cast<int>(openBox.GetNavigationReliability()),
3551 static_cast<int>(SurfaceSolid::NavigationReliability::OpenSurfaceSet));
3552
3553 const auto restoredOpenBox = writeAndReadBack(openBox);
3554 BOOST_REQUIRE(restoredOpenBox != nullptr);
3555 BOOST_CHECK(!restoredOpenBox->IsNavigable());
3556 checkSolidsIdentical(openBox, *restoredOpenBox, 4.5, 5);
3557}
3558
3559// A solid that reaches the reader with no surface records -- a file written by an older version,
3560// or a solid streamed before CloseShape() -- must report Undetermined rather than manufacture a
3561// clean ClosureReport out of an empty surface set. "I do not know" is the only honest answer, and
3562// the difference matters: NavigationReliability is the flag callers are told to check.
3563BOOST_AUTO_TEST_CASE(EmptySolidIsNotReliable)
3564{
3565 SurfaceSolid empty("emptySolid");
3566 BOOST_CHECK_EQUAL(static_cast<int>(empty.GetNavigationReliability()),
3567 static_cast<int>(SurfaceSolid::NavigationReliability::Undetermined));
3568 BOOST_CHECK(!empty.IsNavigable());
3569
3570 // CloseShape on an empty surface set must not define the shape, with or without checking
3571 empty.CloseShape(false);
3572 BOOST_CHECK(!empty.IsDefined());
3573 BOOST_CHECK_EQUAL(static_cast<int>(empty.GetNavigationReliability()),
3574 static_cast<int>(SurfaceSolid::NavigationReliability::Undetermined));
3575 BOOST_CHECK(!empty.IsNavigable());
3576 BOOST_CHECK(!empty.IsClosed());
3577}
3578
3579namespace
3580{
3581// A golden-angle spiral of unit directions: quasi-uniform on the sphere, so no two are
3582// near-parallel and none aligns with a coordinate axis or a 45-degree symmetry plane. Used to
3583// test the invariant that containment does not depend on where the parity ray is aimed.
3584std::vector<std::array<double, 3>> spiralDirections(int count)
3585{
3586 std::vector<std::array<double, 3>> directions;
3587 directions.reserve(count);
3588 for (int index = 0; index < count; ++index) {
3589 const double cosTheta = 1. - 2. * (index + 0.5) / count;
3590 const double sinTheta = std::sqrt(1. - cosTheta * cosTheta);
3591 const double phi = 2.399963229728653 * index;
3592 directions.push_back({sinTheta * std::cos(phi), sinTheta * std::sin(phi), cosTheta});
3593 }
3594 return directions;
3595}
3596} // namespace
3597
3598// Parity containment answers a topological question, so on a closed, consistently oriented
3599// 2-manifold it cannot depend on where the ray is aimed. That invariant is what licenses the
3600// single-shot fast path: Contains() casts one fixed direction and stops.
3601//
3602// It is also the sharpest available oracle for the surface set itself -- no reference shape is
3603// involved, only the solid disagreeing with itself. Measured over the Phase 0 corpus, every part
3604// the closure check calls Reliable has *zero* direction disagreements in 11k points, and every
3605// part with disagreements is one the closure check already rejects.
3606BOOST_AUTO_TEST_CASE(ContainsIsDirectionIndependentOnClosedSolids)
3607{
3608 const auto box = makeBoxSolid("dirBox", 1., 2., 3.);
3609 const auto tube = makeTubeSolid("dirTube", 1., 2., 3.);
3610 const auto cone = makeConeSolid("dirCone", 2., 1., 3.);
3611 const auto sphere = makeSphereSolid("dirSphere", 2.);
3612 const auto torus = makeTorusSolid("dirTorus", 3., 1.);
3613 const auto capsule = makeCapsuleSolid("dirCapsule", 2., 3.);
3614
3615 const auto directions = spiralDirections(13);
3616 for (const auto* solid : {box.get(), tube.get(), cone.get(), sphere.get(), torus.get(), capsule.get()}) {
3617 BOOST_TEST_CONTEXT("solid = " << solid->GetName())
3618 {
3619 BOOST_REQUIRE(solid->IsNavigable());
3620 for (const auto& point : probeGrid(4.5, 7)) {
3621 const bool reference = solid->Contains(point.data());
3622 for (const auto& direction : directions) {
3623 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ") direction = ("
3624 << direction[0] << ", " << direction[1] << ", " << direction[2] << ")")
3625 {
3626 BOOST_CHECK_EQUAL(solid->ContainsAlongDirection(point.data(), direction.data()), reference);
3627 }
3628 }
3629 }
3630 }
3631 }
3632}
3633
3634// Section 4.4's re-shoot, on the defect it exists for. A gap in the surface set costs the parity
3635// ray exactly the crossings that fall inside the gap, so a point is misclassified over the whole
3636// *shadow* of the gap along the shooting direction -- centimetres of wrong answers arbitrarily far
3637// from any surface. Aiming the ray somewhere else escapes that shadow, which is why a majority
3638// over several directions recovers the right answer: measured over the 55 points where the single
3639// fixed direction disagrees with the OpenCascade oracle on the Phase 0 corpus, not one point is
3640// wrong in every direction.
3641//
3642// The fixture makes the mechanism explicit rather than statistical: the +x face of a box is split
3643// into two rectangles with a thin strip left out, so a ray leaving along +x from inside sees no
3644// crossing at all and reports "outside".
3645BOOST_AUTO_TEST_CASE(ContainsReshootsThroughSurfaceGaps)
3646{
3647 constexpr double halfX = 1.;
3648 constexpr double halfY = 2.;
3649 constexpr double halfZ = 3.;
3650 constexpr double gap = 0.05; // half-width in z of the missing strip on the +x face
3651
3652 SurfaceSolid gapped("gappedBox");
3653 for (int faceIndex = 1; faceIndex < 6; ++faceIndex) { // every face but +x
3654 BOOST_REQUIRE(addBoxFace(gapped, faceIndex, halfX, halfY, halfZ));
3655 }
3656 // the +x face as two rectangles, leaving z in (-gap, +gap) uncovered. Frame of face 0:
3657 // origin (halfX, -halfY, -halfZ), axisU = +y, axisV = +z.
3658 BOOST_REQUIRE(gapped.AddPlanarSurface({halfX, -halfY, -halfZ}, {0., 1., 0.}, {0., 0., 1.},
3659 rectangleWire(2. * halfY, halfZ - gap)));
3660 BOOST_REQUIRE(gapped.AddPlanarSurface({halfX, -halfY, gap}, {0., 1., 0.}, {0., 0., 1.},
3661 rectangleWire(2. * halfY, halfZ - gap)));
3662 gapped.CloseShape(false);
3663
3664 // the gap is what makes the solid unnavigable, and only an unnavigable solid re-shoots
3665 BOOST_REQUIRE(!gapped.IsNavigable());
3666 BOOST_CHECK_EQUAL(static_cast<int>(gapped.GetNavigationReliability()),
3667 static_cast<int>(SurfaceSolid::NavigationReliability::OpenSurfaceSet));
3668
3669 // a point deep inside whose +x ray leaves straight through the gap
3670 const std::array<double, 3> insidePoint{0., 0.3, 0.};
3671 const std::array<double, 3> throughGap{1., 0., 0.};
3672 BOOST_CHECK(!gapped.ContainsAlongDirection(insidePoint.data(), throughGap.data())); // the defect itself
3673 BOOST_CHECK(gapped.Contains(insidePoint.data())); // the re-shoot recovers it
3674 BOOST_CHECK(gapped.Contains_Loop(insidePoint.data())); // ... on both paths
3675
3676 // the same point in the same box *without* the gap is inside from every direction, so the
3677 // fixture isolates the gap and not some accident of the point
3678 const auto intact = makeBoxSolid("intactBox", halfX, halfY, halfZ);
3679 BOOST_CHECK(intact->Contains(insidePoint.data()));
3680 BOOST_CHECK(intact->ContainsAlongDirection(insidePoint.data(), throughGap.data()));
3681
3682 // and the BVH and loop parities still agree everywhere on the defective solid: the re-shoot is
3683 // applied by one shared helper, so it can never make the two paths differ
3684 for (const auto& point : probeGrid(4.5, 7)) {
3685 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
3686 {
3687 BOOST_CHECK_EQUAL(gapped.Contains(point.data()), gapped.Contains_Loop(point.data()));
3688 }
3689 }
3690}
3691
3692namespace
3693{
3694// An L-shaped prism, the concave fixture. Its footprint is ([0,3]x[0,1]) union ([0,1]x[1,2])
3695// extruded over
3696// z in [0, height], so it has a *reflex* (concave) vertical edge at x = 1, y = 1 -- the one place
3697// where a ray can touch the boundary from inside and stay inside, which no convex fixture can
3698// reproduce. Built from eight planar faces with outward normals, so it is closed and consistently
3699// oriented.
3700std::unique_ptr<SurfaceSolid> makeLPrismSolid(const char* name, double height = 1.)
3701{
3702 // footprint, counter-clockwise; (1,1) is the reflex vertex
3703 const std::vector<Point2D> footprint{{0., 0.}, {3., 0.}, {3., 1.}, {1., 1.}, {1., 2.}, {0., 2.}};
3704
3705 auto solid = std::make_unique<SurfaceSolid>(name);
3706
3707 // bottom (outward normal -z) and top (+z); axisU x axisV fixes the normal
3708 std::vector<Point2D> bottomWire;
3709 bottomWire.reserve(footprint.size());
3710 for (const auto& vertex : footprint) {
3711 bottomWire.push_back({vertex[1], vertex[0]}); // (u, v) = (y, x) so that axisU x axisV = -z
3712 }
3713 BOOST_REQUIRE(solid->AddPlanarSurface({0., 0., 0.}, {0., 1., 0.}, {1., 0., 0.}, bottomWire));
3714 BOOST_REQUIRE(solid->AddPlanarSurface({0., 0., height}, {1., 0., 0.}, {0., 1., 0.}, footprint));
3715
3716 // one vertical wall per footprint edge; axisU along the edge and axisV = +z put the normal at
3717 // (dy, -dx, 0), which points out of a counter-clockwise footprint
3718 for (size_t index = 0; index < footprint.size(); ++index) {
3719 const auto& start = footprint[index];
3720 const auto& end = footprint[(index + 1) % footprint.size()];
3721 const double deltaU = end[0] - start[0];
3722 const double deltaV = end[1] - start[1];
3723 const double length = std::hypot(deltaU, deltaV);
3724 BOOST_REQUIRE(solid->AddPlanarSurface({start[0], start[1], 0.}, {deltaU / length, deltaV / length, 0.},
3725 {0., 0., 1.}, rectangleWire(length, height)));
3726 }
3727 solid->CloseShape();
3728 return solid;
3729}
3730} // namespace
3731
3732// S2: the bounding-box pre-check ran *before* the documented "no BVH yet, fall back to the plain
3733// loop" branch. Before CloseShape() the box is still all zeros, so the pre-check rejected every
3734// point outside a 1e-9 cube at the origin and the fallback was unreachable -- Contains() was
3735// effectively disabled on any solid that had not been closed yet.
3736BOOST_AUTO_TEST_CASE(ContainsWorksBeforeCloseShape)
3737{
3738 SurfaceSolid box("preCloseBox");
3739 addBoxSurfaces(box, 1., 2., 3.);
3740 BOOST_REQUIRE(!box.HasBVH()); // the premise: no acceleration structure and no bounding box yet
3741
3742 for (const auto& point : probeGrid(4.5, 5)) {
3743 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
3744 {
3745 const bool inside = std::abs(point[0]) < 1. && std::abs(point[1]) < 2. && std::abs(point[2]) < 3.;
3746 BOOST_CHECK_EQUAL(box.Contains(point.data()), inside);
3747 BOOST_CHECK_EQUAL(box.Contains(point.data()), box.Contains_Loop(point.data()));
3748 }
3749 }
3750}
3751
3752// S3: a point *on* a face is inside for Contains, but its t = 0 exit was below the minimum ray
3753// parameter and therefore invisible to DistFromInside, which then returned Big. A navigator that
3754// asks "how far to the wall" while standing on the wall and is told "never" tunnels straight
3755// through the geometry. ROOT's own primitives answer 0 here, and so must this.
3756BOOST_AUTO_TEST_CASE(BoundaryPointsAgreeBetweenContainsAndDistances)
3757{
3758 const auto box = makeBoxSolid("boundaryPolicyBox", 1., 2., 3.);
3759 const std::array<double, 3> onFace{1., 0.5, 0.5}; // exactly on the +x face
3760 const std::array<double, 3> outward{1., 0., 0.};
3761 const std::array<double, 3> inward{-1., 0., 0.};
3762
3763 BOOST_CHECK(box->Contains(onFace.data())); // documented policy: on a face counts as inside
3764
3765 TGeoBBox reference("boundaryPolicyReference", 1., 2., 3.);
3766 BOOST_CHECK_EQUAL(box->DistFromInside(onFace.data(), outward.data(), 3), 0.);
3767 BOOST_CHECK_EQUAL(box->DistFromOutside(onFace.data(), inward.data(), 3), 0.);
3768 checkClose(box->DistFromInside(onFace.data(), outward.data(), 3),
3769 reference.DistFromInside(onFace.data(), outward.data(), 3));
3770 checkClose(box->DistFromOutside(onFace.data(), inward.data(), 3),
3771 reference.DistFromOutside(onFace.data(), inward.data(), 3));
3772
3773 // going the other way the far wall is still the answer, so the fix is not "always return 0"
3774 checkClose(box->DistFromInside(onFace.data(), inward.data(), 3), 2.);
3775
3776 // the BVH and loop paths must agree on all of it
3777 for (const auto& direction : {outward, inward}) {
3778 checkDistanceAgainstLoop(*box, onFace, direction);
3779 }
3780}
3781
3782// S4 / S5: a ray that only *touches* the boundary has not crossed it. Contains() knows this --
3783// near-equal hits are clustered and a cluster carrying both an entering and an exiting hit
3784// contributes even parity -- but the distance queries classified every hit on its own, so they
3785// reported the touch as a crossing. The two then disagree about the same ray: DistFromOutside
3786// hands the navigator a step to the touch point, Contains says it is still outside once it gets
3787// there, and the navigator takes zero-length steps forever.
3788//
3789// Both flavours are covered. A convex edge graze (box) is the outside-facing case, and the L-prism
3790// reflex edge is the inside-facing one, which no convex solid can produce: there the ray leaves
3791// and re-enters the material at a single point and must be reported as never having left.
3792BOOST_AUTO_TEST_CASE(EdgeGrazesAreNotCrossings)
3793{
3794 const double invSqrt2 = 1. / std::sqrt(2.);
3795
3796 // --- convex: touch the box edge x = +1, y = +2 and stay outside on both sides of the touch
3797 const auto box = makeBoxSolid("grazeBox", 1., 2., 3.);
3798 const std::array<double, 3> grazeDirection{invSqrt2, -invSqrt2, 0.};
3799 const std::array<double, 3> grazeOrigin{1. - 5. * invSqrt2, 2. + 5. * invSqrt2, 0.};
3800 BOOST_REQUIRE(!box->Contains(grazeOrigin.data()));
3801
3802 // a point just past the touch is still outside, so nothing was entered ...
3803 const std::array<double, 3> pastTouch{1. + 1.e-3 * invSqrt2, 2. - 1.e-3 * invSqrt2, 0.};
3804 BOOST_REQUIRE(!box->Contains(pastTouch.data()));
3805 // ... and the distance query must say so too
3806 BOOST_CHECK_EQUAL(box->DistFromOutside(grazeOrigin.data(), grazeDirection.data(), 3), TGeoShape::Big());
3807 BOOST_CHECK_EQUAL(box->DistFromOutside_Loop(grazeOrigin.data(), grazeDirection.data()), TGeoShape::Big());
3808
3809 // --- concave: the L-prism's reflex edge at x = 1, y = 1
3810 const auto prism = makeLPrismSolid("grazePrism");
3811 BOOST_REQUIRE(prism->IsNavigable());
3812 checkClose(prism->Capacity(), 4.); // 3x1 plus 1x1, extruded over unit height
3813
3814 // a ray through the reflex edge along (1,-1): inside before the touch, inside after it, so the
3815 // touch is not an exit. The real exit is where it leaves the long arm at y = 0.
3816 const std::array<double, 3> reflexDirection{invSqrt2, -invSqrt2, 0.};
3817 const std::array<double, 3> reflexOrigin{1. - 0.5 * invSqrt2, 1. + 0.5 * invSqrt2, 0.5};
3818 BOOST_REQUIRE(prism->Contains(reflexOrigin.data()));
3819 const std::array<double, 3> pastReflex{1. + 1.e-3 * invSqrt2, 1. - 1.e-3 * invSqrt2, 0.5};
3820 BOOST_REQUIRE(prism->Contains(pastReflex.data())); // still inside: the touch was not an exit
3821
3822 const double touchDistance = 0.5;
3823 const double exitDistance = 0.5 + std::sqrt(2.); // on to (2, 0, 0.5), in the middle of the y = 0 wall
3824 const double reported = prism->DistFromInside(reflexOrigin.data(), reflexDirection.data(), 3);
3825 BOOST_CHECK_GT(reported, touchDistance + 1.e-6); // the touch is not the answer ...
3826 checkClose(reported, exitDistance); // ... the far wall is
3827 BOOST_CHECK_EQUAL(prism->DistFromInside_Loop(reflexOrigin.data(), reflexDirection.data()), reported);
3828}
3829
3830// The direction-taking DescribeContainsCrossings dumps the crossing list behind ContainsAlongDirection.
3831BOOST_AUTO_TEST_CASE(DescribeContainsCrossingsTakesAnExplicitDirection)
3832{
3833 const auto box = makeBoxSolid("describeBox", 1., 2., 3.);
3834 const SurfaceSolid::Point3D inside{0.2, 0.3, 0.4};
3835 const double invSqrt2 = 1. / std::sqrt(2.);
3836 // +x leaves through x = +1 at 0.8; the unnormalised -z direction leaves through z = -3 at 3.4
3837 const std::vector<std::pair<SurfaceSolid::Point3D, double>> cases{
3838 {{1., 0., 0.}, 0.8}, {{invSqrt2, -invSqrt2, 0.}, 0.8 * std::sqrt(2.)}, {{0., 0., -2.}, 3.4}};
3839 for (const auto& [direction, exitDistance] : cases) {
3840 BOOST_TEST_CONTEXT("direction = (" << direction[0] << ", " << direction[1] << ", " << direction[2] << ")")
3841 {
3842 std::vector<SurfaceSolid::ContainsCrossing> bvhCrossings;
3843 std::vector<SurfaceSolid::ContainsCrossing> loopCrossings;
3844 box->DescribeContainsCrossings(inside, direction, bvhCrossings, loopCrossings);
3845 // from inside a convex box every ray leaves through exactly one face
3846 BOOST_REQUIRE_EQUAL(bvhCrossings.size(), 1u);
3847 BOOST_REQUIRE_EQUAL(loopCrossings.size(), 1u);
3848 BOOST_CHECK_EQUAL(bvhCrossings[0].distance, loopCrossings[0].distance);
3849 checkClose(bvhCrossings[0].distance, exitDistance);
3850 BOOST_CHECK_GT(bvhCrossings[0].normalAlignment, 0.); // an exit
3851 BOOST_CHECK(!bvhCrossings[0].onTrimBoundary);
3852 BOOST_CHECK(box->ContainsAlongDirection(inside.data(), direction.data()));
3853 }
3854 }
3855}
3856
3857// The concave fixture earns its keep beyond the single grazing ray: the whole sweep battery is
3858// run on it, since every invariant the convex fixtures pin (BVH == loop, direction-independent
3859// parity, Contains consistent with the distance answers) is weaker on shapes with no reflex edge.
3861{
3862 const auto prism = makeLPrismSolid("sweepPrism");
3863 BOOST_REQUIRE(prism->IsNavigable());
3864
3865 sweepDistanceAgainstLoop(*prism, 3.5, 5);
3866
3867 const auto directions = spiralDirections(13);
3868 for (const auto& point : probeGrid(3.5, 7)) {
3869 const bool inside = prism->Contains(point.data());
3870 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
3871 {
3872 BOOST_CHECK_EQUAL(prism->Contains_Loop(point.data()), inside);
3873 for (const auto& direction : directions) {
3874 BOOST_CHECK_EQUAL(prism->ContainsAlongDirection(point.data(), direction.data()), inside);
3875 }
3876 // the closed-form answer for the extruded L footprint
3877 const bool expected = point[2] > 0. && point[2] < 1. &&
3878 ((point[0] > 0. && point[0] < 3. && point[1] > 0. && point[1] < 1.) ||
3879 (point[0] > 0. && point[0] < 1. && point[1] >= 1. && point[1] < 2.));
3880 BOOST_CHECK_EQUAL(inside, expected);
3881 }
3882 }
3883}
3884
3885// K1: the B-spline endpoint shortcut assumed a clamped knot vector. A clamped curve interpolates
3886// its first and last pole, so returning those is exact and free; an unclamped one -- which is what
3887// OCC writes for a periodic tube-tube intersection curve before SetNotPeriodic -- starts and ends
3888// strictly inside its control polygon, and the shortcut then returned points that are not on the
3889// curve at all. Downstream, the wire's edges no longer meet (so it reads as Open and the whole
3890// face is thrown away) or the off-curve endpoint corrupts the winding classification, since
3891// CurveWire::classify deliberately uses canonical shared endpoints.
3892BOOST_AUTO_TEST_CASE(UnclampedBSplineEndpointsAreOnTheCurve)
3893{
3894 using surf::Curve2D;
3895 using surf::Vec2;
3896
3897 // the same cubic control polygon read twice: once with a clamped knot vector, once with a
3898 // uniform (unclamped) one. Only the clamped curve may claim its poles as endpoints.
3899 const std::vector<Vec2> poles{{0., 0.}, {1., 2.}, {3., 2.}, {4., 0.}};
3900 const Curve2D clamped = Curve2D::makeBSpline(3, poles, {}, {0., 0., 0., 0., 1., 1., 1., 1.});
3901 const Curve2D uniform = Curve2D::makeBSpline(3, poles, {}, {0., 1., 2., 3., 4., 5., 6., 7.});
3902
3903 BOOST_REQUIRE(clamped.valid());
3904 BOOST_REQUIRE(uniform.valid());
3905
3906 // the endpoints must lie on their own curve, whatever the knot vector says
3907 for (const auto* curve : {&clamped, &uniform}) {
3908 const Vec2 start = curve->startPoint();
3909 const Vec2 end = curve->endPoint();
3910 const Vec2 evaluatedStart = curve->pointAt(0.);
3911 const Vec2 evaluatedEnd = curve->pointAt(1.);
3912 checkClose(start.uCoord, evaluatedStart.uCoord);
3913 checkClose(start.vCoord, evaluatedStart.vCoord);
3914 checkClose(end.uCoord, evaluatedEnd.uCoord);
3915 checkClose(end.vCoord, evaluatedEnd.vCoord);
3916 }
3917
3918 // and the two curves really are different, so the test is not vacuous: the clamped one
3919 // interpolates its outer poles, the uniform one does not come near them
3920 checkClose(clamped.startPoint().uCoord, 0.);
3921 checkClose(clamped.endPoint().uCoord, 4.);
3922 BOOST_CHECK_GT(std::hypot(uniform.startPoint().uCoord - poles.front().uCoord,
3923 uniform.startPoint().vCoord - poles.front().vCoord),
3924 0.1);
3925
3926 // a wire closed on the *curve* must validate, which is what the shortcut used to prevent: with
3927 // poles.front() as the reported start, the joining line would have missed it by that distance
3928 const Vec2 uniformStart = uniform.startPoint();
3929 const Vec2 uniformEnd = uniform.endPoint();
3930 surf::CurveWire wire;
3931 surf::WireStatus status = surf::WireStatus::Valid;
3932 BOOST_CHECK(wire.initialize({uniform, Curve2D::makeLine(uniformEnd, uniformStart)}, surf::WireRole::Outer, status));
3933}
3934
3935// K2: the full-turn rejection measured the *control-point hull*, not the curve. A closed trim
3936// curve that wraps nearly a full turn in phi has poles outside its own span (that is what makes
3937// the hull a conservative bound), so the check saw more than 2*pi and refused a perfectly legal
3938// through-hole host face -- and a refused face is a face missing from the parity solid, i.e. wrong
3939// containment throughout its shadow.
3940BOOST_AUTO_TEST_CASE(NearFullTurnTrimIsNotRejectedOnItsPoleHull)
3941{
3942 using surf::Curve2D;
3943 using surf::Vec2;
3944 std::string error;
3945
3946 // A trim wrapping 350 degrees of a cylinder, spelled as two quadratic B-spline spans whose
3947 // middle poles sit *outside* the span in phi -- which is exactly what makes the control-point
3948 // hull a conservative bound and not the curve's own extent. The curve stays inside 2*pi; its
3949 // pole hull does not.
3950 const double sweep = 350. * surf::kPi / 180.;
3951 const double overshoot = 0.4;
3952 const std::vector<Curve2D> outer{
3953 Curve2D::makeBSpline(2, {{0., -1.}, {-overshoot, 0.}, {0.5 * sweep, 1.}}, {}, {0., 0., 0., 1., 1., 1.}),
3954 Curve2D::makeBSpline(2, {{0.5 * sweep, 1.}, {sweep + overshoot, 0.}, {sweep, -1.}}, {}, {0., 0., 0., 1., 1., 1.}),
3955 Curve2D::makeLine({sweep, -1.}, {0., -1.})};
3956
3957 // the pole hull must genuinely exceed a full turn, otherwise the fixture proves nothing
3958 Vec2 hullLower{1.e300, 1.e300};
3959 Vec2 hullUpper{-1.e300, -1.e300};
3960 surf::CurveWire hullWire;
3961 surf::WireStatus hullStatus = surf::WireStatus::Valid;
3962 BOOST_REQUIRE(hullWire.initialize(outer, surf::WireRole::Outer, hullStatus));
3963 hullWire.parametricBounds(hullLower, hullUpper);
3964 BOOST_REQUIRE_GT(hullUpper.uCoord - hullLower.uCoord, surf::kTwoPi);
3965
3966 // ... while the curve itself does not
3967 Vec2 tightLower{1.e300, 1.e300};
3968 Vec2 tightUpper{-1.e300, -1.e300};
3969 hullWire.tightParametricBounds(tightLower, tightUpper);
3970 BOOST_CHECK_LT(tightUpper.uCoord - tightLower.uCoord, surf::kTwoPi);
3971
3972 // so the surface must be accepted
3974 BOOST_CHECK_MESSAGE(surface.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -2., 2., 0., surf::kTwoPi,
3975 false, outer, {}, error),
3976 "near-full-turn trim rejected: " << error);
3977
3978 // a trim that really does wrap more than a full turn is still refused
3980 const std::vector<Curve2D> overWrapped{Curve2D::makeLine({0., -1.}, {surf::kTwoPi + 0.5, -1.}),
3981 Curve2D::makeLine({surf::kTwoPi + 0.5, -1.}, {surf::kTwoPi + 0.5, 1.}),
3982 Curve2D::makeLine({surf::kTwoPi + 0.5, 1.}, {0., 1.}),
3983 Curve2D::makeLine({0., 1.}, {0., -1.})};
3984 BOOST_CHECK(!tooWide.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -2., 2., 0., surf::kTwoPi, false,
3985 overWrapped, {}, error));
3986}
3987
3988// K7 claimed a face that fails to build is logged and silently omitted from the parity solid.
3989// Reading the code does not support that on any production path: Add*Surface returns false, and
3990// the sidecar loader turns that into a whole-file rejection, which the converter's generated macro
3991// turns into an exception. What is true is the weaker statement that the *return value* is the
3992// only signal, so this pins both halves -- the rejection is reported, and nothing is added behind
3993// the caller's back. Recorded rather than "fixed", in the same spirit as the S6 correction.
3994BOOST_AUTO_TEST_CASE(RejectedFacesAreNeverSilentlyAdded)
3995{
3996 SurfaceSolid solid("rejectingSolid");
3997 BOOST_REQUIRE(addBoxFace(solid, 0, 1., 2., 3.));
3998 BOOST_REQUIRE_EQUAL(solid.GetNsurfaces(), 1);
3999
4000 // degenerate frame (axisU parallel to axisV), a wire with too few vertices, and a zero-radius
4001 // cylinder: each must be refused, and none may leave a surface behind
4002 BOOST_CHECK(!solid.AddPlanarSurface({0., 0., 0.}, {1., 0., 0.}, {1., 0., 0.}, rectangleWire(1., 1.)));
4003 BOOST_CHECK(!solid.AddPlanarSurface({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.}, {{0., 0.}, {1., 0.}}));
4004 BOOST_CHECK(!solid.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 0., -1., 1.));
4005 BOOST_CHECK_EQUAL(solid.GetNsurfaces(), 1);
4006 BOOST_CHECK_EQUAL(static_cast<int>(solid.GetSurfaceRecords().size()), 1);
4007
4008 // the loader's contract on the same failure: reject the file rather than return a partial solid
4009 std::vector<char> bytes;
4010 appendSidecarHeader(bytes, 1);
4011 appendU32(bytes, 2); // cylinder
4012 appendU32(bytes, 0); // flags
4013 appendU32(bytes, 14); // nParams
4014 appendDoubles(bytes, {0., 0., 0., 0., 0., 1., 1., 0., 0., 0. /* radius */, -1., 1., 0., 2. * surf::kPi});
4015 appendU32(bytes, 0); // nWires
4016 const auto path = writeSidecarFile("o2_sidecar_rejected_face.bin", bytes);
4017 SurfaceSolid loaded("loadedRejecting");
4018 BOOST_CHECK(!o2::cad::LoadSurfaceSolid(path.string(), loaded));
4019 std::filesystem::remove(path);
4020}
4021
4022// The rim-based closure measurement.
4023//
4024// The half-edge check asks whether two faces emitted the *same vertices* along a shared edge. On
4025// real CAD that question has the answer "no" for reasons that are not gaps: each face samples the
4026// shared curve independently, so the vertices genuinely are not the same points and no tolerance
4027// on vertex equality can help. The rim measurement compares the boundaries as curves instead, and
4028// reports the answer as a length in cm rather than as a chord count.
4029//
4030// Nothing derives a verdict from it yet -- IsNavigable() still reads the chord counters -- so
4031// these tests pin the measurement, not a change of behaviour.
4032BOOST_AUTO_TEST_CASE(RimClosureMeasuresTheGapInCentimetres)
4033{
4034 constexpr double halfX = 1.;
4035 constexpr double halfY = 1.5;
4036 constexpr double halfZ = 2.;
4037
4038 // a closed box: one rim per face, all matched, and no gap at all
4039 SurfaceSolid closedBox("rimClosedBox");
4040 addBoxSurfaces(closedBox, halfX, halfY, halfZ);
4041 closedBox.CloseShape(false);
4042 BOOST_REQUIRE(closedBox.IsNavigable());
4043 BOOST_CHECK_EQUAL(closedBox.GetRimCount(), 6);
4044 BOOST_CHECK_EQUAL(closedBox.GetMatchedRimCount(), 6);
4045 BOOST_CHECK_EQUAL(closedBox.GetBoundaryRimCount(), 0);
4046 BOOST_CHECK_SMALL(closedBox.GetMaxRimIsolation(), 1.e-12);
4047 BOOST_CHECK_SMALL(closedBox.GetUnmatchedRimLength(), 1.e-12);
4048 // a box has no curved rim, so its polylines are exact and the measurement has no noise floor
4049 BOOST_CHECK_SMALL(closedBox.GetRimChordResolution(), 1.e-12);
4050 // the summed perimeter of the six faces, which is what "how much boundary" is measured in
4051 BOOST_CHECK_CLOSE(closedBox.GetTotalRimLength(), 16. * (halfX + halfY + halfZ), 1.e-9);
4052
4053 // the same box with the +z face lifted by a known delta. A box's rims are straight, so their
4054 // sampling resolution is zero and the match band is the declared tolerance alone; the lifted
4055 // face's rim is then alone by exactly delta, and that is what the isolation reports.
4056 constexpr double delta = 1.e-3;
4057 SurfaceSolid shiftedBox("rimShiftedBox");
4058 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4059 const Point3D center = faceIndex == 4 ? Point3D{0., 0., delta} : Point3D{0., 0., 0.};
4060 BOOST_REQUIRE(addBoxFace(shiftedBox, faceIndex, halfX, halfY, halfZ, false, center));
4061 }
4062 shiftedBox.CloseShape(false);
4063 BOOST_CHECK_CLOSE(shiftedBox.GetMaxRimIsolation(), delta, 1.e-6);
4064 // and the shift is far above the sampling noise floor, so the number means what it says
4065 BOOST_CHECK(shiftedBox.GetMaxRimIsolation() > shiftedBox.GetRimChordResolution());
4066}
4067
4068// The structural failure the rim criterion exists to fix, pinned: two faces that sample one
4069// shared edge at different chord counts emit different vertices, so vertex matching calls a
4070// perfectly closed box open -- and open by *chords*, which is how a seven-loop solid came to
4071// report 1418 boundary edges. Rim matching compares the curves and gets it right, and it is the
4072// rim answer that IsClosed()/IsNavigable() now report.
4073BOOST_AUTO_TEST_CASE(RimClosureSurvivesUnequalChordCounts)
4074{
4075 constexpr double halfX = 1.;
4076 constexpr double halfY = 1.5;
4077 constexpr double halfZ = 2.;
4078
4079 SurfaceSolid resampled("rimResampledBox");
4080 for (int faceIndex = 0; faceIndex < 5; ++faceIndex) {
4081 BOOST_REQUIRE(addBoxFace(resampled, faceIndex, halfX, halfY, halfZ));
4082 }
4083 // the last face again, but with every edge split in two: the same rectangle, twice the vertices
4084 const FaceFrame frame = boxFaceFrame(5, halfX, halfY, halfZ);
4085 const double extentU = frame.extentU;
4086 const double extentV = frame.extentV;
4087 BOOST_REQUIRE(resampled.AddPlanarSurface(frame.origin, frame.axisU, frame.axisV,
4088 {{0., 0.},
4089 {0.5 * extentU, 0.},
4090 {extentU, 0.},
4091 {extentU, 0.5 * extentV},
4092 {extentU, extentV},
4093 {0.5 * extentU, extentV},
4094 {0., extentV},
4095 {0., 0.5 * extentV}}));
4096 resampled.CloseShape(false);
4097
4098 // the per-chord counters still see the disagreement -- they compare the vertices the two faces
4099 // emitted, and those really are different points. That is the defect, and it is why they no
4100 // longer decide anything.
4101 BOOST_CHECK(resampled.GetBoundaryEdgeCount() > 0);
4102
4103 // the verdict comes from the rims, which see one boundary curve per face, all matched, no gap
4104 BOOST_CHECK(resampled.IsClosed());
4105 BOOST_CHECK(resampled.IsNavigable());
4106 BOOST_CHECK_EQUAL(resampled.GetRimCount(), 6);
4107 BOOST_CHECK_EQUAL(resampled.GetMatchedRimCount(), 6);
4108 BOOST_CHECK_EQUAL(resampled.GetBoundaryRimCount(), 0);
4109 BOOST_CHECK_SMALL(resampled.GetMaxRimIsolation(), 1.e-12);
4110 BOOST_CHECK_SMALL(resampled.GetUnmatchedRimLength(), 1.e-12);
4111}
4112
4113// The per-rim records name which loop is open. A rim's state is on the same scale the solid reports, so
4114// the solid's verdict must be exactly the worst state present -- which is a self-check, not a
4115// restatement: the two are accumulated independently.
4116BOOST_AUTO_TEST_CASE(RimReportsNameTheOffendingLoop)
4117{
4118 constexpr double halfX = 1.;
4119 constexpr double halfY = 1.5;
4120 constexpr double halfZ = 2.;
4121 constexpr double delta = 1.e-3;
4122
4123 SurfaceSolid shiftedBox("rimReportBox");
4124 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4125 const Point3D center = faceIndex == 4 ? Point3D{0., 0., delta} : Point3D{0., 0., 0.};
4126 BOOST_REQUIRE(addBoxFace(shiftedBox, faceIndex, halfX, halfY, halfZ, false, center));
4127 }
4128 shiftedBox.CloseShape(false);
4129
4130 const auto& rims = shiftedBox.GetRimReports();
4131 BOOST_REQUIRE_EQUAL(static_cast<int>(rims.size()), shiftedBox.GetRimCount());
4132
4133 int boundaryRims = 0;
4134 auto worst = SurfaceSolid::NavigationReliability::Reliable;
4135 double openLength = 0.;
4136 for (const auto& rim : rims) {
4137 BOOST_CHECK(rim.surface >= 0 && rim.surface < shiftedBox.GetNsurfaces());
4138 BOOST_CHECK(rim.rimOnSurface >= 0);
4139 BOOST_CHECK(rim.closed); // a box face's boundary is one closed loop
4140 BOOST_CHECK(rim.length > 0.);
4141 openLength += rim.unmatchedLength;
4142 if (rim.state == SurfaceSolid::NavigationReliability::OpenSurfaceSet) {
4143 ++boundaryRims;
4144 BOOST_CHECK(rim.unmatchedChords > 0);
4145 BOOST_CHECK(rim.unmatchedLength > 0.);
4146 } else {
4147 BOOST_CHECK_EQUAL(rim.unmatchedChords, 0);
4148 }
4149 worst = std::max(worst, rim.state);
4150 }
4151 BOOST_CHECK_EQUAL(boundaryRims, shiftedBox.GetBoundaryRimCount());
4152 BOOST_CHECK(worst == shiftedBox.GetNavigationReliability());
4153 BOOST_CHECK_CLOSE(openLength, shiftedBox.GetUnmatchedRimLength(), 1.e-9);
4154
4155 // the lifted face's own rim is the one that is alone, and it is alone by the lift
4156 const auto lifted = std::find_if(rims.begin(), rims.end(), [](const auto& rim) { return rim.surface == 4; });
4157 BOOST_REQUIRE(lifted != rims.end());
4158 BOOST_CHECK(lifted->state == SurfaceSolid::NavigationReliability::OpenSurfaceSet);
4159 BOOST_CHECK_CLOSE(lifted->maxIsolation, delta, 1.e-6);
4160 BOOST_CHECK(lifted->maxIsolationFace >= 0 && lifted->maxIsolationFace != 4);
4161 // and the worst chord is named where it is: on the +z face, which sits at halfZ + delta
4162 BOOST_CHECK_CLOSE(lifted->maxIsolationPoint[2], halfZ + delta, 1.e-6);
4163}
4164
4165// Rims are counted per boundary loop and measured in centimetres, not counted per chord. A bare
4166// cylinder wall is the clearest case: two circular rims, sampled at kArcSamples chords each.
4167BOOST_AUTO_TEST_CASE(RimCountsAreLoopsAndLengthsNotChords)
4168{
4169 constexpr double radius = 1.;
4170 constexpr double halfHeight = 2.;
4171 SurfaceSolid openTube("rimOpenTube");
4172 BOOST_REQUIRE(
4173 openTube.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, -halfHeight, halfHeight));
4174 openTube.CloseShape(false);
4175
4176 // the chord counter reports every sample of both rims
4177 BOOST_CHECK(openTube.GetBoundaryEdgeCount() >= 2 * surf::kArcSamples);
4178 // the rim measurement reports two open loops, and how long they are
4179 BOOST_CHECK_EQUAL(openTube.GetRimCount(), 2);
4180 BOOST_CHECK_EQUAL(openTube.GetBoundaryRimCount(), 2);
4181 BOOST_CHECK_EQUAL(openTube.GetMatchedRimCount(), 0);
4182 // both rims are open, and the length is the sampled circumference (a chord polygon, so slightly
4183 // under 2*pi*r); it is the *length* that is reported, not the sample count
4184 BOOST_CHECK_CLOSE(openTube.GetUnmatchedRimLength(), openTube.GetTotalRimLength(), 1.e-9);
4185 BOOST_CHECK_CLOSE(openTube.GetTotalRimLength(), 2. * surf::kTwoPi * radius, 1.);
4186
4187 // the sagitta of a circle of this radius sampled at kArcSamples per turn, from the closed form:
4188 // r (1 - cos(pi/kArcSamples)). The estimator must recover it, because it is the floor below
4189 // which a rim gap is how the rims were sampled rather than how far apart the faces are.
4190 const double exactSagitta = radius * (1. - std::cos(surf::kPi / surf::kArcSamples));
4191 BOOST_CHECK_CLOSE(openTube.GetRimChordResolution(), exactSagitta, 1.);
4192
4193 // Two rims and no third: a full-turn patch emits its seam twice, once each way, and that pair
4194 // bounds nothing -- it cancels in the half-edge check for the same reason. Chained naively it
4195 // would become a two-point rim straddling the patch, reporting a gap the size of the patch.
4196}
4197
4198// The matching tolerance is the model's own declared one when the sidecar states it (version 2),
4199// and a documented constant when it does not. Before this the kernel had no way to know what
4200// epsilon two faces of an imported solid should agree to, and guessed.
4201BOOST_AUTO_TEST_CASE(RimMatchToleranceComesFromTheModel)
4202{
4203 SurfaceSolid unstated("rimToleranceUnstated");
4204 addBoxSurfaces(unstated, 1., 1., 1.);
4205 unstated.CloseShape(false);
4206 BOOST_CHECK_EQUAL(unstated.GetModelTolerance(), 0.);
4207 BOOST_CHECK_CLOSE(unstated.GetRimMatchTolerance(), surf::kRimMatchTolerance, 1.e-9);
4208
4209 SurfaceSolid stated("rimToleranceStated");
4210 addBoxSurfaces(stated, 1., 1., 1.);
4211 stated.SetModelTolerance(2.5e-7);
4212 stated.CloseShape(false);
4213 BOOST_CHECK_CLOSE(stated.GetRimMatchTolerance(), 2.5e-7, 1.e-9);
4214}
4215
4216// --- Closed-loop quadrature and unclamped B-spline endpoints ---
4217
4222BOOST_AUTO_TEST_CASE(ClosedCurveReportsItsTravelNotItsEndpointGap)
4223{
4224 // a full circle in the (u, v) chart, written as an arc: the endpoints coincide exactly
4225 const surf::Curve2D circle = surf::Curve2D::makeArc({0., 0.}, 2., 0., 2. * surf::kPi);
4226 BOOST_CHECK_SMALL(std::abs(circle.endPoint().uCoord - circle.startPoint().uCoord), 1.e-12);
4227 // u = 2 cos(angle) travels from +2 down to -2 and back: total variation 8, not 0
4228 BOOST_CHECK_CLOSE(circle.uVariation(0., 1.), 8., 1.e-9);
4229
4230 // and the same for a closed B-spline, whose poles bound the travel from above
4231 std::vector<surf::Vec2> poles{{0., 0.}, {1., 1.}, {2., 0.}, {1., -1.}, {0., 0.}};
4232 std::vector<double> knots{0., 0., 0., 0.25, 0.5, 0.75, 1., 1., 1.};
4233 const surf::Curve2D loop = surf::Curve2D::makeBSpline(2, poles, {}, knots);
4234 BOOST_CHECK_SMALL(std::abs(loop.endPoint().uCoord - loop.startPoint().uCoord), 1.e-9);
4235 BOOST_CHECK_GT(loop.uVariation(0., 1.), 0.5);
4236}
4237
4250BOOST_AUTO_TEST_CASE(HoleInWireTrimIntegratesToTheAnalyticCapacity)
4251{
4252 const double radius = 1.2;
4253 const double height = 1.5;
4254 const double holeRadius = 0.3;
4255 const double centreU = surf::kPi;
4256 const double centreV = 0.5 * height;
4257
4258 std::vector<o2::cad::O2BVHSurfaceSolid::PlanarBoundaryCurve> outer{
4263
4264 // the textbook exact NURBS circle: nine poles on the circumscribed square's corners and edge
4265 // midpoints, corner weights sqrt(2)/2, four double interior knots
4266 const double corner = std::sqrt(2.) / 2.;
4267 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.}}};
4268 std::vector<o2::cad::O2BVHSurfaceSolid::Point2D> poles;
4269 for (const auto& pole : unit) {
4270 poles.push_back({centreU + holeRadius * pole[0], centreV + holeRadius * pole[1]});
4271 }
4272 const std::vector<double> weights{1., corner, 1., corner, 1., corner, 1., corner, 1.};
4273 const std::vector<double> knots{0., 0., 0., 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1., 1., 1.};
4274 std::vector<std::vector<o2::cad::O2BVHSurfaceSolid::PlanarBoundaryCurve>> holes{
4276
4277 o2::cad::O2BVHSurfaceSolid withHole("withHole");
4278 BOOST_REQUIRE(withHole.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, radius, 0., height, 0.,
4279 2. * surf::kPi, false, outer, holes));
4280 std::vector<double> contributions;
4281 withHole.GetSurfaceCapacityContributions(contributions);
4282 BOOST_REQUIRE_EQUAL(contributions.size(), 1u);
4283
4284 // f = (r/3)(C.U cos phi + C.V sin phi + r) with the axis through the origin reduces to r^2/3, so
4285 // the contribution is r^2/3 times the trimmed chart area: 2 pi h minus the hole's pi rho^2.
4286 const double chartArea = 2. * surf::kPi * height - surf::kPi * holeRadius * holeRadius;
4287 BOOST_CHECK_CLOSE(contributions[0], radius * radius * chartArea / 3., 1.e-6);
4288}
4289
4293BOOST_AUTO_TEST_CASE(UnclampedBSplineEndpointsAreEvaluatedNotReadOffThePoles)
4294{
4295 // uniform (unclamped) knots: the curve starts well inside the pole polygon
4296 std::vector<surf::Vec2> poles{{0., 0.}, {1., 2.}, {2., 2.}, {3., 0.}};
4297 std::vector<double> knots{0., 1., 2., 3., 4., 5., 6., 7.};
4298 const surf::Curve2D unclamped = surf::Curve2D::makeBSpline(3, poles, {}, knots);
4299 const surf::Vec2 start = unclamped.startPoint();
4300 const surf::Vec2 end = unclamped.endPoint();
4301 // the whole point: neither endpoint is a pole
4302 BOOST_CHECK_GT(std::abs(start.uCoord - poles.front().uCoord) + std::abs(start.vCoord - poles.front().vCoord), 1.e-3);
4303 BOOST_CHECK_GT(std::abs(end.uCoord - poles.back().uCoord) + std::abs(end.vCoord - poles.back().vCoord), 1.e-3);
4304 // and they are on the curve
4305 BOOST_CHECK_SMALL(std::abs(unclamped.pointAt(0.).uCoord - start.uCoord), 1.e-12);
4306 BOOST_CHECK_SMALL(std::abs(unclamped.pointAt(1.).vCoord - end.vCoord), 1.e-12);
4307}
4308
4309// --------------------------------------------------------------------------------------------
4310// Sidecar v3 edge identity.
4311// --------------------------------------------------------------------------------------------
4312
4313namespace
4314{
4325std::vector<std::pair<std::vector<unsigned int>, std::vector<unsigned char>>>
4326 boxEdgeIdentity(double halfX, double halfY, double halfZ)
4327{
4328 using Key = std::array<long long, 6>;
4329 auto quantize = [](double value) { return static_cast<long long>(std::llround(value * 1.e9)); };
4330 std::map<Key, unsigned int> edgeIds;
4331 std::vector<std::pair<std::vector<unsigned int>, std::vector<unsigned char>>> perFace(6);
4332
4333 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4334 const FaceFrame frame = boxFaceFrame(faceIndex, halfX, halfY, halfZ);
4335 const auto corners = rectangleWire(frame.extentU, frame.extentV);
4336 for (size_t segment = 0; segment < corners.size(); ++segment) {
4337 const auto& startUV = corners[segment];
4338 const auto& endUV = corners[(segment + 1) % corners.size()];
4339 auto toGlobal = [&](const Point2D& uv) {
4340 return Point3D{frame.origin[0] + frame.axisU[0] * uv[0] + frame.axisV[0] * uv[1],
4341 frame.origin[1] + frame.axisU[1] * uv[0] + frame.axisV[1] * uv[1],
4342 frame.origin[2] + frame.axisU[2] * uv[0] + frame.axisV[2] * uv[1]};
4343 };
4344 const Point3D start = toGlobal(startUV);
4345 const Point3D end = toGlobal(endUV);
4346 const Key forwardKey{quantize(start[0]), quantize(start[1]), quantize(start[2]),
4347 quantize(end[0]), quantize(end[1]), quantize(end[2])};
4348 const Key backwardKey{forwardKey[3], forwardKey[4], forwardKey[5],
4349 forwardKey[0], forwardKey[1], forwardKey[2]};
4350 // the lexicographically smaller ordering names the edge; running the other way is "reversed"
4351 const bool reversed = backwardKey < forwardKey;
4352 const Key canonical = reversed ? backwardKey : forwardKey;
4353 const auto inserted = edgeIds.emplace(canonical, static_cast<unsigned int>(edgeIds.size()));
4354 perFace[faceIndex].first.push_back(inserted.first->second);
4355 perFace[faceIndex].second.push_back(
4356 static_cast<unsigned char>(SurfaceSolid::kEdgeAnchored | (reversed ? SurfaceSolid::kEdgeReversed : 0u)));
4357 }
4358 }
4359 BOOST_REQUIRE_EQUAL(edgeIds.size(), 12u); // a box has twelve edges; if it did not, nothing below means anything
4360 return perFace;
4361}
4362
4364std::unique_ptr<SurfaceSolid> makeIdentifiedBox(const char* name, double halfX, double halfY, double halfZ)
4365{
4366 auto solid = std::make_unique<SurfaceSolid>(name);
4367 addBoxSurfaces(*solid, halfX, halfY, halfZ);
4368 const auto identity = boxEdgeIdentity(halfX, halfY, halfZ);
4369 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4370 BOOST_REQUIRE(solid->SetSurfaceBoundaryEdges(faceIndex, identity[faceIndex].first, identity[faceIndex].second));
4371 }
4372 return solid;
4373}
4374} // namespace
4375
4379BOOST_AUTO_TEST_CASE(EdgeIdentityDecidesClosureByCounting)
4380{
4381 const auto box = makeIdentifiedBox("identityBox", 1., 2., 3.);
4382 box->CloseShape(false);
4383
4384 BOOST_CHECK(box->HasEdgeIdentity());
4385 BOOST_CHECK_EQUAL(box->GetSourceEdgeCount(), 12);
4386 BOOST_CHECK_EQUAL(box->GetSharedSourceEdgeCount(), 12);
4387 BOOST_CHECK_EQUAL(box->GetBoundarySourceEdgeCount(), 0);
4388 BOOST_CHECK_EQUAL(box->GetNonManifoldSourceEdgeCount(), 0);
4389 BOOST_CHECK_EQUAL(box->GetReversedSourceEdgeCount(), 0);
4390 BOOST_CHECK_EQUAL(box->GetDegenerateSourceEdgeCount(), 0);
4391 BOOST_CHECK(box->IsClosed());
4392 BOOST_CHECK(box->IsOrientationConsistent());
4393 BOOST_CHECK(box->IsNavigable());
4394
4395 // the deviation is a measurement, and on a box built from one set of corners it is exactly zero
4396 BOOST_CHECK_EQUAL(box->GetMeasuredSharedEdgeCount(), 12);
4397 BOOST_CHECK_EQUAL(box->GetUnmeasuredSharedEdgeCount(), 0);
4398 BOOST_CHECK_SMALL(box->GetMaxSharedEdgeDeviation(), 1.e-15);
4399}
4400
4404BOOST_AUTO_TEST_CASE(EdgeIdentityFindsTheMissingFace)
4405{
4406 SurfaceSolid openBox("identityOpenBox");
4407 for (int faceIndex = 0; faceIndex < 5; ++faceIndex) {
4408 BOOST_REQUIRE(addBoxFace(openBox, faceIndex, 1., 2., 3.));
4409 }
4410 const auto identity = boxEdgeIdentity(1., 2., 3.);
4411 for (int faceIndex = 0; faceIndex < 5; ++faceIndex) {
4412 BOOST_REQUIRE(openBox.SetSurfaceBoundaryEdges(faceIndex, identity[faceIndex].first, identity[faceIndex].second));
4413 }
4414 openBox.CloseShape(false);
4415
4416 BOOST_CHECK(openBox.HasEdgeIdentity());
4417 BOOST_CHECK_EQUAL(openBox.GetSourceEdgeCount(), 12);
4418 BOOST_CHECK_EQUAL(openBox.GetSharedSourceEdgeCount(), 8);
4419 BOOST_CHECK_EQUAL(openBox.GetBoundarySourceEdgeCount(), 4); // the missing face's own four edges
4420 BOOST_CHECK(!openBox.IsClosed());
4421 BOOST_CHECK(!openBox.IsNavigable());
4422 BOOST_CHECK_EQUAL(static_cast<int>(openBox.GetNavigationReliability()),
4423 static_cast<int>(SurfaceSolid::NavigationReliability::OpenSurfaceSet));
4424}
4425
4428BOOST_AUTO_TEST_CASE(EdgeIdentityFindsReversedAndDuplicatedFaces)
4429{
4430 {
4431 const auto box = makeIdentifiedBox("identityReversed", 1., 2., 3.);
4432 // flip one face's senses: its four edges now read "twice, same way" instead of "twice, opposite"
4433 auto identity = boxEdgeIdentity(1., 2., 3.);
4434 for (auto& flag : identity[0].second) {
4435 flag = static_cast<unsigned char>(flag ^ SurfaceSolid::kEdgeReversed);
4436 }
4437 BOOST_REQUIRE(box->SetSurfaceBoundaryEdges(0, identity[0].first, identity[0].second));
4438 box->CloseShape(false);
4439 BOOST_CHECK_EQUAL(box->GetReversedSourceEdgeCount(), 4);
4440 BOOST_CHECK(box->IsClosed()); // still every edge twice: closed, but inconsistently oriented
4441 BOOST_CHECK(!box->IsOrientationConsistent());
4442 BOOST_CHECK_EQUAL(static_cast<int>(box->GetNavigationReliability()),
4443 static_cast<int>(SurfaceSolid::NavigationReliability::ReversedFaces));
4444 }
4445 {
4446 // a seventh face claiming edges that already have two owners is non-manifold by count
4447 const auto box = makeIdentifiedBox("identityNonManifold", 1., 2., 3.);
4448 BOOST_REQUIRE(addBoxFace(*box, 0, 1., 2., 3.));
4449 const auto identity = boxEdgeIdentity(1., 2., 3.);
4450 BOOST_REQUIRE(box->SetSurfaceBoundaryEdges(6, identity[0].first, identity[0].second));
4451 box->CloseShape(false);
4452 BOOST_CHECK_EQUAL(box->GetNonManifoldSourceEdgeCount(), 4);
4453 BOOST_CHECK(!box->IsClosed());
4454 BOOST_CHECK_EQUAL(static_cast<int>(box->GetNavigationReliability()),
4455 static_cast<int>(SurfaceSolid::NavigationReliability::NonManifold));
4456 }
4457}
4458
4464BOOST_AUTO_TEST_CASE(SharedEdgeDeviationMeasuresHowFarApartTheFacesAre)
4465{
4466 constexpr double delta = 3.e-4;
4467 SurfaceSolid shifted("identityShifted");
4468 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4469 // face 4 is the +z cap; nudge it along +z, which pulls its whole rim off the four side faces
4470 const Point3D centre = faceIndex == 4 ? Point3D{0., 0., delta} : Point3D{0., 0., 0.};
4471 BOOST_REQUIRE(addBoxFace(shifted, faceIndex, 1., 2., 3., false, centre));
4472 }
4473 const auto identity = boxEdgeIdentity(1., 2., 3.);
4474 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4475 BOOST_REQUIRE(shifted.SetSurfaceBoundaryEdges(faceIndex, identity[faceIndex].first, identity[faceIndex].second));
4476 }
4477 shifted.CloseShape(false);
4478
4479 BOOST_CHECK(shifted.HasEdgeIdentity());
4480 BOOST_CHECK_EQUAL(shifted.GetSharedSourceEdgeCount(), 12);
4481 BOOST_CHECK(shifted.IsClosed()); // by identity: the same twelve edges, each used twice
4482 BOOST_CHECK_EQUAL(shifted.GetMeasuredSharedEdgeCount(), 12);
4483 checkClose(shifted.GetMaxSharedEdgeDeviation(), delta, 1.e-12);
4484}
4485
4492BOOST_AUTO_TEST_CASE(TrimCurveIdentitySurvivesWireReorientation)
4493{
4494 SurfaceSolid flipped("identityFlippedWinding");
4495 const auto identity = boxEdgeIdentity(1., 2., 3.);
4496 std::vector<std::vector<unsigned int>> ids(6);
4497 std::vector<std::vector<unsigned char>> flags(6);
4498 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4499 FaceFrame frame = boxFaceFrame(faceIndex, 1., 2., 3.);
4500 auto corners = rectangleWire(frame.extentU, frame.extentV);
4501 // reverse the vertex ring: segment j of the new ring is segment (n-2-j) of the old one, run
4502 // backwards, and initialize() will reverse it again to restore the winding it wants
4503 std::reverse(corners.begin(), corners.end());
4504 BOOST_REQUIRE(flipped.AddPlanarSurface(frame.origin, frame.axisU, frame.axisV, corners));
4505 const size_t n = corners.size();
4506 ids[faceIndex].resize(n);
4507 flags[faceIndex].resize(n);
4508 for (size_t j = 0; j < n; ++j) {
4509 const size_t source = (n - 2 - j + n) % n;
4510 ids[faceIndex][j] = identity[faceIndex].first[source];
4511 flags[faceIndex][j] = identity[faceIndex].second[source];
4512 }
4513 BOOST_REQUIRE(flipped.SetSurfaceBoundaryEdges(faceIndex, ids[faceIndex], flags[faceIndex]));
4514 }
4515 flipped.CloseShape(false);
4516
4517 BOOST_CHECK(flipped.HasEdgeIdentity());
4518 BOOST_CHECK_EQUAL(flipped.GetSharedSourceEdgeCount(), 12);
4519 BOOST_CHECK_EQUAL(flipped.GetMeasuredSharedEdgeCount(), 12);
4520 // the load-bearing assertion: had the reversal gone unrecorded, this would be a box edge long
4521 BOOST_CHECK_SMALL(flipped.GetMaxSharedEdgeDeviation(), 1.e-12);
4522}
4523
4527BOOST_AUTO_TEST_CASE(PartialEdgeIdentityFallsBackToTheRimMeasurement)
4528{
4529 SurfaceSolid partial("identityPartial");
4530 addBoxSurfaces(partial, 1., 2., 3.);
4531 const auto identity = boxEdgeIdentity(1., 2., 3.);
4532 for (int faceIndex = 0; faceIndex < 5; ++faceIndex) { // one face left silent
4533 BOOST_REQUIRE(partial.SetSurfaceBoundaryEdges(faceIndex, identity[faceIndex].first, identity[faceIndex].second));
4534 }
4535 partial.CloseShape(false);
4536 BOOST_CHECK(!partial.HasEdgeIdentity());
4537 BOOST_CHECK_EQUAL(partial.GetSourceEdgeCount(), 0);
4538 BOOST_CHECK(partial.IsNavigable()); // the geometric verdict, unchanged: the box really is closed
4539
4540 // and an out-of-range index or mismatched arrays are refused rather than half-applied
4541 BOOST_CHECK(!partial.SetSurfaceBoundaryEdges(6, identity[0].first, identity[0].second));
4542 BOOST_CHECK(!partial.SetSurfaceBoundaryEdges(0, {1u, 2u}, {0}));
4543}
4544
4547BOOST_AUTO_TEST_CASE(EdgeIdentitySurvivesPersistence)
4548{
4549 const auto box = makeIdentifiedBox("identityPersist", 1., 2., 3.);
4550 box->CloseShape(false);
4551 BOOST_REQUIRE(box->HasEdgeIdentity());
4552
4553 const auto restored = writeAndReadBack(*box);
4554 BOOST_REQUIRE(restored != nullptr);
4555 BOOST_CHECK(restored->HasEdgeIdentity());
4556 BOOST_CHECK_EQUAL(restored->GetSourceEdgeCount(), box->GetSourceEdgeCount());
4557 BOOST_CHECK_EQUAL(restored->GetSharedSourceEdgeCount(), box->GetSharedSourceEdgeCount());
4558 BOOST_CHECK_EQUAL(restored->GetMeasuredSharedEdgeCount(), box->GetMeasuredSharedEdgeCount());
4559 checkClose(restored->GetMaxSharedEdgeDeviation(), box->GetMaxSharedEdgeDeviation(), 1.e-18);
4560 checkSolidsIdentical(*box, *restored, 4.5, 5);
4561}
4562
4566BOOST_AUTO_TEST_CASE(SidecarV3EdgeIdentityRoundTrip)
4567{
4568 constexpr double halfX = 1.;
4569 constexpr double halfY = 2.;
4570 constexpr double halfZ = 3.;
4571 const auto identity = boxEdgeIdentity(halfX, halfY, halfZ);
4572
4573 const auto boxBytes = [&](uint32_t version, bool writeIdentity) {
4574 std::vector<char> bytes;
4575 appendSidecarHeader(bytes, 6, version, 1.e-7, writeIdentity ? 12u : 0u);
4576 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4577 appendPlaneRecord(bytes, boxFaceFrame(faceIndex, halfX, halfY, halfZ));
4578 if (version >= 3) {
4579 const auto& ids = identity[faceIndex].first;
4580 const auto& flags = identity[faceIndex].second;
4581 appendU32(bytes, writeIdentity ? static_cast<uint32_t>(ids.size()) : 0u);
4582 if (writeIdentity) {
4583 for (size_t e = 0; e < ids.size(); ++e) {
4584 appendU32(bytes, ids[e]);
4585 bytes.push_back(static_cast<char>(flags[e]));
4586 }
4587 }
4588 }
4589 }
4590 return bytes;
4591 };
4592
4593 SurfaceSolid v3("sidecarV3Identity");
4594 const auto v3Path = writeSidecarFile("o2_sidecar_v3_identity.bin", boxBytes(3, true));
4595 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(v3Path.string(), v3));
4596 std::filesystem::remove(v3Path);
4597 v3.CloseShape(false);
4598 BOOST_CHECK(v3.HasEdgeIdentity());
4599 BOOST_CHECK_EQUAL(v3.GetSourceEdgeCount(), 12);
4600 BOOST_CHECK_EQUAL(v3.GetSharedSourceEdgeCount(), 12);
4601 BOOST_CHECK_EQUAL(v3.GetMeasuredSharedEdgeCount(), 12);
4602 BOOST_CHECK_SMALL(v3.GetMaxSharedEdgeDeviation(), 1.e-15);
4603 BOOST_CHECK(v3.IsNavigable());
4604 TGeoBBox reference("identityBoxReference", halfX, halfY, halfZ);
4605 compareContainsGrid(v3, reference, 4., 7);
4606 checkClose(v3.Capacity(), reference.Capacity(), 1.e-9);
4607
4608 // a v3 file may legitimately state no identities per face, and then it is a v2 file in all but
4609 // the header: same load, same geometric verdict, and nothing pretends to know the topology
4610 SurfaceSolid v3Silent("sidecarV3Silent");
4611 const auto silentPath = writeSidecarFile("o2_sidecar_v3_silent.bin", boxBytes(3, false));
4612 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(silentPath.string(), v3Silent));
4613 std::filesystem::remove(silentPath);
4614 v3Silent.CloseShape(false);
4615 BOOST_CHECK(!v3Silent.HasEdgeIdentity());
4616 BOOST_CHECK(v3Silent.IsNavigable());
4617
4618 // and the same six faces written as version 2 -- the compatibility statement, in one assertion
4619 SurfaceSolid v2("sidecarV2StillLoads");
4620 const auto v2Path = writeSidecarFile("o2_sidecar_v2_still_loads.bin", boxBytes(2, false));
4621 BOOST_REQUIRE(o2::cad::LoadSurfaceSolid(v2Path.string(), v2));
4622 std::filesystem::remove(v2Path);
4623 v2.CloseShape(false);
4624 BOOST_CHECK(!v2.HasEdgeIdentity());
4625 BOOST_CHECK(v2.IsNavigable());
4626 checkClose(v2.GetModelTolerance(), 1.e-7, 1.e-18);
4627
4628 // a v3 header over a v2 body: the counts it reads are the next record's bytes, and a reader that
4629 // resize()s to them is killed rather than reporting anything. It must fail as a parse error.
4630 std::vector<char> mislabelled;
4631 appendSidecarHeader(mislabelled, 6, 2, 1.e-7);
4632 mislabelled[4] = 3; // rewrite the version word in place, leaving a version-2 body behind it
4633 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4634 appendPlaneRecord(mislabelled, boxFaceFrame(faceIndex, halfX, halfY, halfZ));
4635 }
4636 SurfaceSolid mislabelledSolid("sidecarV3Mislabelled");
4637 const auto badPath = writeSidecarFile("o2_sidecar_v3_mislabelled.bin", mislabelled);
4638 BOOST_CHECK(!o2::cad::LoadSurfaceSolid(badPath.string(), mislabelledSolid));
4639 std::filesystem::remove(badPath);
4640}
4641
4648BOOST_AUTO_TEST_CASE(EdgeIdentityVerdictIsIndependentOfChordSampling)
4649{
4650 const auto box = makeIdentifiedBox("identitySampling", 1., 2., 3.);
4651 box->CloseShape(false);
4652 const double deviation = box->GetMaxSharedEdgeDeviation();
4653 const bool navigable = box->IsNavigable();
4654
4655 // Resample one face's rim at a different chord count by splitting its wire into eight segments
4656 // instead of four. The rim polylines the geometric measurement compares now differ in phase and
4657 // in count -- the exact situation section 13 shows moving the old verdict -- while the edge
4658 // identity, and every curve it names, is untouched.
4659 SurfaceSolid resampled("identitySamplingResampled");
4660 const auto identity = boxEdgeIdentity(1., 2., 3.);
4661 for (int faceIndex = 0; faceIndex < 6; ++faceIndex) {
4662 const FaceFrame frame = boxFaceFrame(faceIndex, 1., 2., 3.);
4663 auto corners = rectangleWire(frame.extentU, frame.extentV);
4664 std::vector<Point2D> dense;
4665 std::vector<unsigned int> ids;
4666 std::vector<unsigned char> flags;
4667 for (size_t segment = 0; segment < corners.size(); ++segment) {
4668 const auto& a = corners[segment];
4669 const auto& b = corners[(segment + 1) % corners.size()];
4670 dense.push_back(a);
4671 dense.push_back({0.5 * (a[0] + b[0]), 0.5 * (a[1] + b[1])});
4672 // both halves of one box edge carry that edge's identity: an edge split for sampling is
4673 // still one edge, and the count has to see it as one
4674 for (int half = 0; half < 2; ++half) {
4675 ids.push_back(identity[faceIndex].first[segment]);
4676 flags.push_back(identity[faceIndex].second[segment]);
4677 }
4678 }
4679 BOOST_REQUIRE(resampled.AddPlanarSurface(frame.origin, frame.axisU, frame.axisV, dense));
4680 BOOST_REQUIRE(resampled.SetSurfaceBoundaryEdges(faceIndex, ids, flags));
4681 }
4682 resampled.CloseShape(false);
4683
4684 // every box edge now appears four times (twice per face, split in half), so it is *not* the
4685 // "exactly twice" case -- which is the honest answer for this deliberately abused fixture, and
4686 // the assertion worth making is that the count says so rather than that it says "closed"
4687 BOOST_CHECK(resampled.HasEdgeIdentity());
4688 BOOST_CHECK_EQUAL(resampled.GetSourceEdgeCount(), 12);
4689 BOOST_CHECK_EQUAL(resampled.GetNonManifoldSourceEdgeCount(), 12);
4690
4691 // and the undisturbed box is unmoved by anything sampling-related
4692 BOOST_CHECK_EQUAL(box->IsNavigable(), navigable);
4693 checkClose(box->GetMaxSharedEdgeDeviation(), deviation, 1.e-18);
4694}
4695
4696// --- Sidecar v3 edge identity ---
4697
4698// --- Position and scale independence ---
4699//
4700// The kernel's length tolerances are absolute, so every number recorded on this branch is a
4701// statement about centimetre-scale geometry near the origin until someone runs the ladder
4702// somewhere else. Doing that found one defect, and these tests are what keeps it named.
4703
4704namespace
4705{
4709std::array<double, 5> torusRayQuartic(double majorRadius, double minorRadius,
4710 const Point3D& origin, const Point3D& dir)
4711{
4712 const double dirDotDir = dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2];
4713 const double originDotDir = origin[0] * dir[0] + origin[1] * dir[1] + origin[2] * dir[2];
4714 const double originDotOrigin =
4715 origin[0] * origin[0] + origin[1] * origin[1] + origin[2] * origin[2];
4716 const double constantK = majorRadius * majorRadius - minorRadius * minorRadius;
4717 const double transverseE = dir[0] * dir[0] + dir[1] * dir[1];
4718 const double transverseF = origin[0] * dir[0] + origin[1] * dir[1];
4719 const double transverseG = origin[0] * origin[0] + origin[1] * origin[1];
4720 const double fourRSquared = 4. * majorRadius * majorRadius;
4721 return {dirDotDir * dirDotDir,
4722 4. * dirDotDir * originDotDir,
4723 4. * originDotDir * originDotDir + 2. * dirDotDir * (originDotOrigin + constantK) -
4724 fourRSquared * transverseE,
4725 4. * originDotDir * (originDotOrigin + constantK) - 2. * fourRSquared * transverseF,
4726 (originDotOrigin + constantK) * (originDotOrigin + constantK) -
4727 fourRSquared * transverseG};
4728}
4729
4732std::vector<double> torusRootsAtScale(double scale)
4733{
4734 const Point3D origin{2.094269422822338 * scale, 3.292530879918199 * scale,
4735 1.9347519602583996 * scale};
4736 const Point3D dir{-0.7547297076674779, -0.03154700875395883, -0.655276929704412};
4737 const auto c = torusRayQuartic(2.5 * scale, 0.8 * scale, origin, dir);
4738 const auto roots = surf::solveQuarticReal(c[0], c[1], c[2], c[3], c[4]);
4739 return {roots.begin(), roots.end()};
4740}
4741} // namespace
4742
4743BOOST_AUTO_TEST_CASE(StreamE_TorusQuarticIsScaleCovariantWhereItWorks)
4744{
4745 // Ferrari's method is exactly scale-covariant: scaling the geometry and the ray origin by k
4746 // scales every root by k and nothing else. This is the property the whole sweep rests on, so it
4747 // is asserted rather than assumed.
4748 const auto reference = torusRootsAtScale(1.);
4749 BOOST_REQUIRE_EQUAL(reference.size(), 2u);
4750 for (const double scale : {0.5, 2., 10.}) {
4751 const auto roots = torusRootsAtScale(scale);
4752 BOOST_REQUIRE_EQUAL(roots.size(), reference.size());
4753 for (size_t i = 0; i < roots.size(); ++i) {
4754 checkClose(roots[i], reference[i] * scale, 1.e-12);
4755 }
4756 }
4757}
4758
4759BOOST_AUTO_TEST_CASE(StreamE_TorusQuarticKeepsEveryRootBelowTheOldResolventGuard)
4760{
4761 // The resolvent guard must be dimensionless: it used to compare a cm^2 quantity against a
4762 // length tolerance and silently returned zero roots as the geometry shrank. These three scales
4763 // must all keep finding both roots of a ray that genuinely crosses the torus twice.
4764 BOOST_CHECK_EQUAL(torusRootsAtScale(0.15).size(), 2u); // was above the old guard
4765 BOOST_CHECK_EQUAL(torusRootsAtScale(0.12).size(), 2u); // was below it: every root was lost
4766 BOOST_CHECK_EQUAL(torusRootsAtScale(0.05).size(), 2u);
4767
4768 // and the roots are exactly the unit-scale ones scaled down, since Ferrari's method is exactly
4769 // scale-covariant -- the property StreamE_TorusQuarticIsScaleCovariantWhereItWorks asserts
4770 // above, now that "where it works" is everywhere.
4771 const auto reference = torusRootsAtScale(1.);
4772 BOOST_REQUIRE_EQUAL(reference.size(), 2u);
4773 for (const double factor : {0.15, 0.12, 0.05, 0.01}) {
4774 const auto roots = torusRootsAtScale(factor);
4775 BOOST_REQUIRE_EQUAL(roots.size(), reference.size());
4776 for (size_t i = 0; i < roots.size(); ++i) {
4777 BOOST_CHECK_LT(std::abs(roots[i] - reference[i] * factor), 1.e-12 * reference[i] * factor);
4778 }
4779 }
4780
4781 // And the roots really are there: the quartic changes sign across each of the two crossings the
4782 // unit-scale solve found, scaled down. Without this the counts above could be read as the
4783 // solver merely agreeing with itself.
4784 const double scale = 0.12;
4785 const Point3D origin{2.094269422822338 * scale, 3.292530879918199 * scale,
4786 1.9347519602583996 * scale};
4787 const Point3D dir{-0.7547297076674779, -0.03154700875395883, -0.655276929704412};
4788 const auto c = torusRayQuartic(2.5 * scale, 0.8 * scale, origin, dir);
4789 const auto evaluate = [&c](double t) {
4790 return (((c[0] * t + c[1]) * t + c[2]) * t + c[3]) * t + c[4];
4791 };
4792 for (const double root : reference) {
4793 const double t = root * scale;
4794 const double span = 0.02 * t;
4795 BOOST_CHECK_LT(evaluate(t - span) * evaluate(t + span), 0.);
4796 }
4797}
4798
4799// --- Gating any TGeoShape ---
4800//
4801// The oracle gate could score exactly one thing: an O2BVHSurfaceSolid loaded from a
4802// surfaces_<part>.bin sidecar. The four scored queries are TGeoShape virtuals, so the scoring
4803// loop was never actually specific to that class -- only the loading was. These cases pin the
4804// two halves of removing that restriction:
4805//
4806// 1. the `shape_<part>.root` sidecar convention itself (one TGeoShape-derived object under the
4807// key "shape"), through the same save/load pair the harness and the fixture generator use,
4808// so producer and consumer cannot drift;
4809// 2. that the oracle validators really are representation-agnostic -- with a *negative
4810// control*, because a validator that reports "0 disagreements" for every input would pass a
4811// positive-only test while being structurally incapable of failing.
4812
4813BOOST_AUTO_TEST_CASE(ShapeSidecarRoundTripsAnyTGeoShape)
4814{
4815 namespace harness = o2::cad::harness;
4816 const auto dir = std::filesystem::temp_directory_path();
4817
4818 // A TGeoBBox that is *not* centred on the origin. The offset is the point: it is the cheapest
4819 // way for a frame convention to be silently wrong, so it has to survive the round trip.
4820 double origin[3] = {1.0, 1.5, 2.0};
4821 TGeoBBox box("shape", 1.0, 1.5, 2.0, origin);
4822
4823 // A TGeoCompositeShape, which is what the CSG emitter will actually hand over: a 4 cm cube with
4824 // an r = 0.8 cm axial through-hole. Built from a TGeoBoolNode rather than from a string
4825 // expression, so no TGeoManager is needed on either side.
4826 auto* cube = new TGeoBBox("cube", 2.0, 2.0, 2.0);
4827 auto* drill = new TGeoTube("drill", 0.0, 0.8, 2.5);
4828 TGeoCompositeShape composite("shape", new TGeoSubtraction(cube, drill, nullptr, nullptr));
4829
4830 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}};
4831 const std::vector<Point3D> directions{{1., 0., 0.}, {0., 1., 0.}, {0., 0., 1.}, {-1., 0., 0.}, {0.6, 0.8, 0.}};
4832
4833 for (const TGeoShape* original : {static_cast<const TGeoShape*>(&box),
4834 static_cast<const TGeoShape*>(&composite)}) {
4835 const std::string path = (dir / (std::string("o2_shape_sidecar_") + original->ClassName() + ".root")).string();
4836 std::string error;
4837 BOOST_REQUIRE_MESSAGE(harness::saveShapeToRootFile(path, *original, &error), error);
4838
4839 std::unique_ptr<TGeoShape> loaded(harness::loadShapeFromRootFile(path, &error));
4840 BOOST_REQUIRE_MESSAGE(loaded != nullptr, error);
4841 BOOST_CHECK_EQUAL(std::string(loaded->ClassName()), std::string(original->ClassName()));
4842 // TGeoCompositeShape::Capacity() is Monte-Carlo sampled, so this is a loose check by
4843 // necessity -- which is precisely why the gate does not treat capacity as a column for
4844 // composites. 5% is far outside the ~1% MC spread and far inside any real error.
4845 BOOST_CHECK_CLOSE(loaded->Capacity(), original->Capacity(), 5.0);
4846
4847 // The queries the gate actually scores must be bit-identical across the round trip.
4848 for (const auto& p : probes) {
4849 BOOST_CHECK_EQUAL(loaded->Contains(p.data()), original->Contains(p.data()));
4850 BOOST_CHECK_EQUAL(loaded->Safety(p.data(), original->Contains(p.data())),
4851 original->Safety(p.data(), original->Contains(p.data())));
4852 for (const auto& d : directions) {
4853 BOOST_CHECK_EQUAL(loaded->DistFromOutside(p.data(), d.data(), 3),
4854 original->DistFromOutside(p.data(), d.data(), 3));
4855 BOOST_CHECK_EQUAL(loaded->DistFromInside(p.data(), d.data(), 3),
4856 original->DistFromInside(p.data(), d.data(), 3));
4857 }
4858 }
4859 std::filesystem::remove(path);
4860 }
4861}
4862
4863BOOST_AUTO_TEST_CASE(ShapeSidecarRefusesWhatIsNotAShape)
4864{
4865 namespace harness = o2::cad::harness;
4866 const auto dir = std::filesystem::temp_directory_path();
4867 std::string error;
4868
4869 BOOST_CHECK(harness::loadShapeFromRootFile((dir / "o2_shape_absent.root").string(), &error) == nullptr);
4870 BOOST_CHECK(!error.empty());
4871
4872 // A well-formed ROOT file whose "shape" key holds something else must be refused rather than
4873 // silently ignored: an emitter that writes the wrong object would otherwise look like an
4874 // emitter that wrote nothing, and the part would quietly lose its column.
4875 const std::string path = (dir / "o2_shape_not_a_shape.root").string();
4876 {
4877 TFile out(path.c_str(), "RECREATE");
4878 TNamed impostor("shape", "not a shape");
4879 out.WriteTObject(&impostor, "shape");
4880 out.Close();
4881 }
4882 error.clear();
4883 BOOST_CHECK(harness::loadShapeFromRootFile(path, &error) == nullptr);
4884 BOOST_CHECK(!error.empty());
4885 std::filesystem::remove(path);
4886}
4887
4888BOOST_AUTO_TEST_CASE(OracleValidatorsScoreAPlainRootShape)
4889{
4890 namespace harness = o2::cad::harness;
4891
4892 // A ROOT primitive with no connection to O2BVHSurfaceSolid at all, and a *wrong* copy of it:
4893 // same shape, radius 0.05 cm too large. Everything below is asserted twice, once for each, so
4894 // no "0 disagreements" here can come from a validator that is unable to report anything else.
4895 constexpr double kR = 1.5;
4896 constexpr double kZ = 2.0;
4897 constexpr double kError = 0.05;
4898 const TGeoTube truth("truth", 0., kR, kZ);
4899 const TGeoTube wrong("wrong", 0., kR + kError, kZ);
4900
4901 // The oracle columns, built analytically from the tube's own closed form rather than from
4902 // either shape's methods, so `contains` and `safety` are genuinely independent of what is being
4903 // scored. (The two distance columns below are taken from `truth`, which is independent of
4904 // `wrong` -- the case that has to be able to fail.)
4905 const auto trueContains = [](const Point3D& p) {
4906 return (std::hypot(p[0], p[1]) <= kR && std::fabs(p[2]) <= kZ) ? 1 : 0;
4907 };
4908 const auto trueBoundaryDistance = [](const Point3D& p) {
4909 const double r = std::hypot(p[0], p[1]);
4910 const double dr = kR - r;
4911 const double dz = kZ - std::fabs(p[2]);
4912 if (dr > 0. && dz > 0.) {
4913 return std::min(dr, dz);
4914 }
4915 return std::hypot(std::max(r - kR, 0.), std::max(std::fabs(p[2]) - kZ, 0.));
4916 };
4917
4918 std::vector<Point3D> points;
4919 std::vector<int> containsState;
4920 std::vector<double> boundaryDistance;
4921 for (int ix = -6; ix <= 6; ++ix) {
4922 for (int iy = -6; iy <= 6; ++iy) {
4923 for (int iz = -4; iz <= 4; ++iz) {
4924 const Point3D p{0.31 * ix, 0.29 * iy, 0.53 * iz};
4925 // Points nearer the wall than the wrong shape's error would be legitimately ambiguous
4926 // for it, so they are dropped: the negative control has to fail on geometry, not on the
4927 // band. Points in the annulus the two shapes disagree about are deliberately kept.
4928 if (std::fabs(trueBoundaryDistance(p)) < 1.e-3) {
4929 continue;
4930 }
4931 points.push_back(p);
4932 containsState.push_back(trueContains(p));
4933 boundaryDistance.push_back(trueBoundaryDistance(p));
4934 }
4935 }
4936 }
4937 BOOST_REQUIRE_GT(points.size(), 500u);
4938
4939 harness::ValidationOptions opt;
4940 opt.meshBand = 1.e-6; // a synthetic shape has no modelling tolerance to hide behind
4941 opt.distanceTolerance = 1.e-9;
4942
4943 auto containsTruth = harness::validateContainsAgainstOracle(&truth, points, containsState,
4944 boundaryDistance, opt);
4945 auto containsWrong = harness::validateContainsAgainstOracle(&wrong, points, containsState,
4946 boundaryDistance, opt);
4947 BOOST_CHECK_EQUAL(containsTruth.nMismatchUnexplained + containsTruth.nMismatchMissedSurface, 0u);
4948 BOOST_CHECK_GT(containsWrong.nMismatchUnexplained + containsWrong.nMismatchMissedSurface, 0u);
4949
4950 auto safetyTruth = harness::validateSafetyAgainstOracle(&truth, points, boundaryDistance, opt);
4951 auto safetyWrong = harness::validateSafetyAgainstOracle(&wrong, points, boundaryDistance, opt);
4952 BOOST_CHECK_EQUAL(safetyTruth.nMismatchUnexplained + safetyTruth.nMismatchMissedSurface, 0u);
4953 BOOST_CHECK_GT(safetyWrong.nMismatchUnexplained + safetyWrong.nMismatchMissedSurface, 0u);
4954
4955 // Rays from well outside, aimed at points spread through the tube, so the hit rate is not
4956 // degenerate; the oracle distance is the nearest positive crossing, exactly as occtOracle.py
4957 // defines it, and the origin classification decides which TGeo entry point is asked.
4958 std::vector<harness::Ray> rays;
4959 std::vector<double> rayDistance;
4960 std::vector<int> originState;
4961 for (int i = 0; i < 400; ++i) {
4962 const double phi = 0.0173 * i;
4963 const double z = -1.9 + 0.0095 * i;
4964 const Point3D target{0.9 * kR * std::cos(2.1 * phi), 0.9 * kR * std::sin(2.1 * phi), z};
4965 const Point3D origin{5.0 * std::cos(phi), 5.0 * std::sin(phi), 3.0 - 0.01 * i};
4966 Point3D dir{target[0] - origin[0], target[1] - origin[1], target[2] - origin[2]};
4967 const double norm = std::sqrt(dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]);
4968 for (auto& component : dir) {
4969 component /= norm;
4970 }
4971 rays.push_back(harness::Ray{origin, dir});
4972 rayDistance.push_back(truth.DistFromOutside(origin.data(), dir.data(), 3));
4973 originState.push_back(trueContains(origin));
4974 }
4975
4976 auto distTruth = harness::validateDistanceAgainstOracle(&truth, rays, rayDistance,
4977 /*wantInside=*/false, opt, originState);
4978 auto distWrong = harness::validateDistanceAgainstOracle(&wrong, rays, rayDistance,
4979 /*wantInside=*/false, opt, originState);
4980 BOOST_CHECK_EQUAL(distTruth.nMismatchUnexplained + distTruth.nMismatchMissedSurface, 0u);
4981 BOOST_CHECK_GT(distWrong.nMismatchUnexplained + distWrong.nMismatchMissedSurface, 0u);
4982}
4983// --- The CSG emitter's two ROOT-side load-bearing claims ---
4984//
4985// The emitter itself is Python (Detectors/CADSupport/tools/cadsupport), and its own self-tests live there. What
4986// belongs here are the two properties of *ROOT* that the emitted file silently depends on. If a
4987// future ROOT changes either, every CSG part written by this project becomes wrong geometry that
4988// still loads, and nothing else in the suite would notice.
4989
4990namespace
4991{
4992// Build the emitter's former `placed(primitive, M)` idiom: no TGeoShape in ROOT 6.36 can carry a
4993// rigid transform (TGeoBBox has fOrigin and nothing else does), and TGeoCompositeShape is the only
4994// shape that holds a TGeoMatrix at all -- through its TGeoBoolNode, which needs two operands. So a
4995// recognised tube that was not already on the z axis USED TO BE written as the union of the
4996// primitive with an identical copy of itself under the same matrix.
4997//
4998// It is now written as the bare primitive plus a placement instead. This helper stays, because
4999// the self-union is
5000// still exactly the same point set and is therefore the reference the new emission is measured
5001// against -- see PlacedPrimitiveAnswersExactlyLikeTheSelfUnionComposite.
5002TGeoCompositeShape* makePlacedTube(const char* name, double rmin, double rmax, double dz,
5003 TGeoMatrix* matrixA, TGeoMatrix* matrixB)
5004{
5005 auto* left = new TGeoTube(Form("%s_l", name), rmin, rmax, dz);
5006 auto* right = new TGeoTube(Form("%s_r", name), rmin, rmax, dz);
5007 auto* node = new TGeoUnion(left, right, matrixA, matrixB);
5008 return new TGeoCompositeShape(name, node);
5009}
5010} // namespace
5011
5012BOOST_AUTO_TEST_CASE(CsgSelfUnionCarriesARigidTransformExactly)
5013{
5014 // A tube on an axis that is neither a coordinate axis nor through the origin -- i.e. the
5015 // ExcavatorArm case. Every query on the composite must equal the same query on the bare primitive
5016 // asked in the primitive's own frame, exactly, not within a band.
5017 constexpr double kRmin = 0.4;
5018 constexpr double kRmax = 1.0;
5019 constexpr double kDz = 5.0;
5020 const TGeoTube reference("reference", kRmin, kRmax, kDz);
5021
5022 auto* rotation = new TGeoRotation("csgRot", 0., 0., 0.);
5023 rotation->RotateX(30.);
5024 rotation->RotateZ(17.);
5025 auto* matrixA = new TGeoCombiTrans(0.3, 5.916, 2.0, rotation);
5026 auto* matrixB = new TGeoCombiTrans(0.3, 5.916, 2.0, rotation);
5027 const TGeoCombiTrans placement(0.3, 5.916, 2.0, rotation);
5028 std::unique_ptr<TGeoCompositeShape> placed(
5029 makePlacedTube("csgPlaced", kRmin, kRmax, kDz, matrixA, matrixB));
5030
5031 std::size_t probes = 0;
5032 std::size_t inside = 0;
5033 std::size_t outside = 0;
5034 for (int ix = -8; ix <= 8; ++ix) {
5035 for (int iy = -8; iy <= 8; ++iy) {
5036 for (int iz = -8; iz <= 8; ++iz) {
5037 const Point3D master{0.3 + 0.37 * ix, 5.916 + 0.41 * iy, 2.0 + 0.43 * iz};
5038 Point3D local{};
5039 placement.MasterToLocal(master.data(), local.data());
5040 // A point on the wall is decided by floating-point luck on either side; skip a thin
5041 // shell so the check tests geometry rather than tie-breaking.
5042 const double r = std::hypot(local[0], local[1]);
5043 if (std::fabs(r - kRmin) < 1.e-9 || std::fabs(r - kRmax) < 1.e-9 ||
5044 std::fabs(std::fabs(local[2]) - kDz) < 1.e-9) {
5045 continue;
5046 }
5047 ++probes;
5048 const bool wanted = reference.Contains(local.data());
5049 BOOST_REQUIRE_EQUAL(placed->Contains(master.data()), wanted);
5050 BOOST_REQUIRE_CLOSE_FRACTION(placed->Safety(master.data(), wanted),
5051 reference.Safety(local.data(), wanted), 1.e-12);
5052 wanted ? ++inside : ++outside;
5053
5054 for (const auto& dir : {Point3D{1., 0., 0.}, Point3D{0., 1., 0.}, Point3D{0., 0., 1.},
5055 Point3D{0.5773502691896258, 0.5773502691896258, 0.5773502691896258}}) {
5056 Point3D localDir{};
5057 placement.MasterToLocalVect(dir.data(), localDir.data());
5058 if (wanted) {
5059 BOOST_REQUIRE_CLOSE_FRACTION(placed->DistFromInside(master.data(), dir.data(), 3),
5060 reference.DistFromInside(local.data(), localDir.data(), 3),
5061 1.e-12);
5062 } else {
5063 const double got = placed->DistFromOutside(master.data(), dir.data(), 3);
5064 const double want = reference.DistFromOutside(local.data(), localDir.data(), 3);
5065 if (want > 1.e20) {
5066 BOOST_REQUIRE_GT(got, 1.e20);
5067 } else {
5068 BOOST_REQUIRE_CLOSE_FRACTION(got, want, 1.e-12);
5069 }
5070 }
5071 }
5072 }
5073 }
5074 }
5075 // A check that cannot fail is not a check: both classes must actually be populated.
5076 BOOST_CHECK_GT(probes, 2000u);
5077 BOOST_CHECK_GT(inside, 100u);
5078 BOOST_CHECK_GT(outside, 100u);
5079
5080 // The negative half. The same comparison against a primitive 0.05 cm too wide must disagree,
5081 // otherwise the loop above proves nothing about the transform.
5082 // The probes are placed *in the shell the two disagree about* and then mapped out to the
5083 // master frame, rather than being taken from a lattice that might miss a 0.05 cm shell.
5084 const TGeoTube wrong("wrongReference", kRmin, kRmax + 0.05, kDz);
5085 std::size_t disagreements = 0;
5086 std::size_t shellProbes = 0;
5087 for (int iphi = 0; iphi < 24; ++iphi) {
5088 const double phi = 2. * M_PI * iphi / 24.;
5089 for (int iz = -3; iz <= 3; ++iz) {
5090 const double radius = kRmax + 0.025;
5091 const Point3D local{radius * std::cos(phi), radius * std::sin(phi), 1.3 * iz};
5092 Point3D master{};
5093 placement.LocalToMaster(local.data(), master.data());
5094 ++shellProbes;
5095 disagreements += (placed->Contains(master.data()) != wrong.Contains(local.data())) ? 1 : 0;
5096 }
5097 }
5098 BOOST_CHECK_EQUAL(disagreements, shellProbes);
5099}
5100
5101BOOST_AUTO_TEST_CASE(CsgTwoLeafUnionRoundTripsAndMatchesTheClosedForm)
5102{
5103 // The ExcavatorArm ram, in miniature and in closed form: an eye (a tube on x) plus a rod (a solid
5104 // cylinder on z), which is what `tier2-tube-union` emits. The union is checked against the
5105 // membership function of the two cylinders written out by hand, which depends on neither ROOT
5106 // shape, and then the whole composite is pushed through the shape sidecar and checked again --
5107 // so a streaming defect that dropped a bool node's matrix would be caught here rather than in
5108 // a gate run three steps later.
5109 constexpr double kEyeRmin = 0.7;
5110 constexpr double kEyeRmax = 1.2;
5111 constexpr double kEyeDz = 0.75;
5112 constexpr double kRodR = 0.6;
5113 constexpr double kRodDz = 3.5;
5114 constexpr double kRodCentre = 3.5; // rod spans z in [0, 7]
5115
5116 auto* eyeRotation = new TGeoRotation("csgEyeRot", 90., 90., 0.); // local z -> global x
5117 auto* eyeMatrix = new TGeoCombiTrans(0., 0., 0., eyeRotation);
5118 auto* rodMatrix = new TGeoTranslation(0., 0., kRodCentre);
5119 auto* eye = new TGeoTube("csgEye", kEyeRmin, kEyeRmax, kEyeDz);
5120 auto* rod = new TGeoTube("csgRod", 0., kRodR, kRodDz);
5121 auto* node = new TGeoUnion(eye, rod, eyeMatrix, rodMatrix);
5122 std::unique_ptr<TGeoCompositeShape> ram(new TGeoCompositeShape("csgRam", node));
5123
5124 const auto closedForm = [&](const Point3D& p) {
5125 const double rEye = std::hypot(p[1], p[2]);
5126 const bool inEye = rEye >= kEyeRmin && rEye <= kEyeRmax && std::fabs(p[0]) <= kEyeDz;
5127 const double rRod = std::hypot(p[0], p[1]);
5128 const bool inRod = rRod <= kRodR && p[2] >= 0. && p[2] <= 2. * kRodDz;
5129 return inEye || inRod;
5130 };
5131 const auto nearWall = [&](const Point3D& p) {
5132 const double rEye = std::hypot(p[1], p[2]);
5133 const double rRod = std::hypot(p[0], p[1]);
5134 return std::fabs(rEye - kEyeRmin) < 1.e-9 || std::fabs(rEye - kEyeRmax) < 1.e-9 ||
5135 std::fabs(std::fabs(p[0]) - kEyeDz) < 1.e-9 || std::fabs(rRod - kRodR) < 1.e-9 ||
5136 std::fabs(p[2]) < 1.e-9 || std::fabs(p[2] - 2. * kRodDz) < 1.e-9;
5137 };
5138
5139 const std::filesystem::path path =
5140 std::filesystem::temp_directory_path() / "o2_csg_ram_shape.root";
5141 namespace harness = o2::cad::harness;
5142 std::string error;
5143 BOOST_REQUIRE_MESSAGE(harness::saveShapeToRootFile(path.string(), *ram, &error), error);
5144 std::unique_ptr<TGeoShape> loaded(harness::loadShapeFromRootFile(path.string(), &error));
5145 BOOST_REQUIRE_MESSAGE(loaded != nullptr, error);
5146 BOOST_CHECK_EQUAL(std::string(loaded->ClassName()), std::string("TGeoCompositeShape"));
5147
5148 std::size_t inside = 0;
5149 std::size_t outside = 0;
5150 for (int ix = -6; ix <= 6; ++ix) {
5151 for (int iy = -6; iy <= 6; ++iy) {
5152 for (int iz = -4; iz <= 20; ++iz) {
5153 const Point3D p{0.23 * ix, 0.27 * iy, 0.41 * iz};
5154 if (nearWall(p)) {
5155 continue;
5156 }
5157 const bool wanted = closedForm(p);
5158 BOOST_REQUIRE_EQUAL(ram->Contains(p.data()), wanted);
5159 BOOST_REQUIRE_EQUAL(loaded->Contains(p.data()), wanted);
5160 wanted ? ++inside : ++outside;
5161 }
5162 }
5163 }
5164 BOOST_CHECK_GT(inside, 50u);
5165 BOOST_CHECK_GT(outside, 500u);
5166
5167 // Capacity is Monte-Carlo for a composite, so it is *reported* and never gated.
5168 // The assertion is stated as scatter rather than as accuracy, because scatter needs no exact
5169 // volume and is the sharper statement: repeated calls returning *different* answers prove the
5170 // method is sampled, and a spread four orders of magnitude above the gate's 1e-6 band proves
5171 // that no capacity criterion could ever be applied to a shape written this way. If a future
5172 // ROOT made TGeoCompositeShape::Capacity() analytic this test would fail, which is the right
5173 // outcome: the emitter's acceptance policy would then be worth revisiting.
5174 double minCapacity = ram->Capacity();
5175 double maxCapacity = minCapacity;
5176 for (int i = 0; i < 5; ++i) {
5177 const double sampled = ram->Capacity();
5178 minCapacity = std::min(minCapacity, sampled);
5179 maxCapacity = std::max(maxCapacity, sampled);
5180 }
5181 BOOST_CHECK_GT(minCapacity, 0.);
5182 const double spread = (maxCapacity - minCapacity) / (0.5 * (maxCapacity + minCapacity));
5183 BOOST_CHECK_GT(spread, 1.e-4);
5184
5185 std::filesystem::remove(path);
5186}
5187// --- The CSG emitter's ROOT-side claims ---
5188
5189// ============================================================================================
5190// X-ray / geantino transport -- ordered crossing lists
5191// ============================================================================================
5192//
5193// Everything above this block, and everything the oracle gate measures, is a SINGLE-SHOT query:
5194// from a point, how far to the surface. A transport loop is different in kind -- step, land on
5195// the boundary, step again from there -- and its failure modes (a zero-length step, a particle
5196// that enters and never leaves, a crossing found twice, a step that overshoots) cannot be
5197// expressed as a disagreement on DistFromOutside from an interior sample. These cases pin the
5198// properties the X-ray benchmark rests on.
5199//
5200// They include XRayTransport.h, which is the SAME header the benchmark binary steps with. That
5201// is deliberate: a test written against a second implementation of the same idea tests neither.
5202
5203#include "XRayTransport.h"
5204
5205using namespace o2::cad::xray;
5207
5211BOOST_AUTO_TEST_CASE(XRayCrossingListsMatchClosedFormOnPrimitives)
5212{
5213 StepConfig cfg;
5214 Robustness stats;
5215 const XRayPoint origin{-5., 0., 0.};
5216 const XRayPoint dir{1., 0., 0.};
5217
5218 TGeoBBox box("xrayBox", 1., 1.5, 2.);
5219 const auto boxCrossings = stepWithShapeApi(&box, origin, dir, 10., cfg, stats);
5220 BOOST_REQUIRE_EQUAL(boxCrossings.size(), 2u);
5221 BOOST_CHECK_SMALL(boxCrossings[0].t - 4., 1.e-12);
5222 BOOST_CHECK_SMALL(boxCrossings[1].t - 6., 1.e-12);
5223 BOOST_CHECK_EQUAL(boxCrossings[0].kind, +1);
5224 BOOST_CHECK_EQUAL(boxCrossings[1].kind, -1);
5225
5226 TGeoTube tube("xrayTube", 0.5, 1.0, 2.0);
5227 const auto tubeCrossings = stepWithShapeApi(&tube, origin, dir, 10., cfg, stats);
5228 BOOST_REQUIRE_EQUAL(tubeCrossings.size(), 4u);
5229 const double expected[4] = {4.0, 4.5, 5.5, 6.0};
5230 const int senses[4] = {+1, -1, +1, -1};
5231 for (int i = 0; i < 4; ++i) {
5232 BOOST_CHECK_SMALL(tubeCrossings[i].t - expected[i], 1.e-12);
5233 BOOST_CHECK_EQUAL(tubeCrossings[i].kind, senses[i]);
5234 }
5235 BOOST_CHECK_EQUAL(stats.zeroLengthSteps, 0);
5236 BOOST_CHECK_EQUAL(stats.nonAdvancingSteps, 0);
5237 BOOST_CHECK_EQUAL(stats.unstickPushes, 0);
5238}
5239
5247BOOST_AUTO_TEST_CASE(XRayCrossingListsAgreeBetweenBVHAndLoopOnAllFixtures)
5248{
5249 StepConfig cfg;
5250 std::array<std::pair<std::unique_ptr<SurfaceSolid>, double>, 7> fixtures{{
5251 {makeBoxSolid("xrayLoopBox", 1., 2., 3.), 4.},
5252 {makeTubeSolid("xrayLoopTube", 0., 2., 3.), 4.},
5253 {makeTubeSolid("xrayLoopHollowTube", 1., 2., 3.), 4.},
5254 {makeConeSolid("xrayLoopCone", 2., 1., 3.), 4.},
5255 {makeSphereSolid("xrayLoopSphere", 2.5), 3.5},
5256 {makeTorusSolid("xrayLoopTorus", 3., 1.), 4.5},
5257 {makeCapsuleSolid("xrayLoopCapsule", 1., 1.5), 3.},
5258 }};
5259 size_t comparedRays = 0;
5260 size_t comparedCrossings = 0;
5261 for (const auto& [solid, extent] : fixtures) {
5262 BOOST_TEST_CONTEXT("fixture = " << solid->GetName())
5263 {
5264 BOOST_REQUIRE(solid->HasBVH());
5265 const XRayPoint lo{-extent, -extent, -extent};
5266 const XRayPoint hi{extent, extent, extent};
5267 // A fan rather than the three axes: a parallel beam is direction-poor, and the point of this
5268 // case is to exercise many ray/surface configurations per fixture.
5269 const Raster raster = buildRaster(lo, hi, 9, buildFanBeams(11), 0.);
5270 for (const auto& ray : raster.rays) {
5271 Robustness bvhStats;
5272 Robustness loopStats;
5273 const auto viaBVH =
5274 stepWithShapeApi(solid.get(), ray.origin, ray.dir, ray.tMax, cfg, bvhStats);
5275 // The non-BVH twins, stepped through the identical loop: only the traversal differs.
5276 const auto viaLoop = stepCrossingsWithKernels(
5277 ray.origin, ray.dir, ray.tMax, cfg, loopStats,
5278 [&solid](const double* p) { return solid->Contains_Loop(p); },
5279 [&solid](const double* p, const double* d) { return solid->DistFromOutside_Loop(p, d); },
5280 [&solid](const double* p, const double* d) { return solid->DistFromInside_Loop(p, d); });
5281 BOOST_REQUIRE_EQUAL(viaBVH.size(), viaLoop.size());
5282 for (size_t i = 0; i < viaBVH.size(); ++i) {
5283 BOOST_CHECK_EQUAL(viaBVH[i].kind, viaLoop[i].kind);
5284 // Bit-identical is the contract: both minimise over the same hits from the same kernels.
5285 BOOST_CHECK_EQUAL(viaBVH[i].t, viaLoop[i].t);
5286 }
5287 comparedRays += 1;
5288 comparedCrossings += viaBVH.size();
5289 }
5290 }
5291 }
5292 BOOST_CHECK_GT(comparedRays, 2000u);
5293 BOOST_CHECK_GT(comparedCrossings, 2000u);
5294}
5295
5299BOOST_AUTO_TEST_CASE(XRayCrossingComparatorCatchesInjectedDefects)
5300{
5301 const std::vector<Crossing> truth{{4.0, +1}, {4.5, -1}, {5.5, +1}, {6.0, -1}};
5302 const double tolerance = 1.e-6;
5303
5304 ListComparison clean;
5305 compareLists(truth, truth, {}, {}, tolerance, clean);
5307 BOOST_CHECK_EQUAL(clean.matched, 4);
5308 BOOST_CHECK_EQUAL(clean.missing, 0);
5309 BOOST_CHECK_EQUAL(clean.extra, 0);
5310 BOOST_CHECK_EQUAL(clean.displaced, 0);
5311
5312 auto perturbed = truth;
5313 perturbed[2].t += 1.e-3;
5314 ListComparison displaced;
5315 compareLists(perturbed, truth, {}, {}, tolerance, displaced);
5316 BOOST_CHECK_EQUAL(displaced.raysIdentical, 0);
5317 BOOST_CHECK_EQUAL(displaced.displaced, 1);
5318 BOOST_CHECK_EQUAL(displaced.missing, 0); // a moved crossing is NOT a lost one
5319 BOOST_CHECK_EQUAL(displaced.extra, 0);
5320 BOOST_CHECK_SMALL(displaced.worstDeltaT - 1.e-3, 1.e-12);
5321
5322 auto dropped = truth;
5323 dropped.erase(dropped.begin() + 1);
5324 ListComparison lost;
5325 compareLists(dropped, truth, {}, {}, tolerance, lost);
5326 BOOST_CHECK_EQUAL(lost.missing, 1);
5327 BOOST_CHECK_EQUAL(lost.extra, 0);
5328
5329 auto doubled = truth;
5330 doubled.insert(doubled.begin() + 1, {4.2, -1});
5331 ListComparison spurious;
5332 compareLists(doubled, truth, {}, {}, tolerance, spurious);
5333 BOOST_CHECK_EQUAL(spurious.extra, 1);
5334 BOOST_CHECK_EQUAL(spurious.missing, 0);
5335
5336 auto flipped = truth;
5337 flipped[1].kind = +1;
5338 ListComparison sense;
5339 compareLists(flipped, truth, {}, {}, tolerance, sense);
5341
5342 // A crossing moved by LESS than the tolerance must not be reported at all, or every run would
5343 // drown in last-digit noise.
5344 auto nudged = truth;
5345 nudged[0].t += 1.e-9;
5346 ListComparison quiet;
5347 compareLists(nudged, truth, {}, {}, tolerance, quiet);
5349 BOOST_CHECK_EQUAL(quiet.displaced, 0);
5350}
5351
5355BOOST_AUTO_TEST_CASE(XRayParityAuditContradictsATruncatedList)
5356{
5357 StepConfig cfg;
5358 TGeoBBox box("xrayParityBox", 1., 1., 1.);
5359 const XRayPoint origin{-5., 0., 0.};
5360 const XRayPoint dir{1., 0., 0.};
5361
5362 Robustness good;
5363 auditCrossingList({{4.0, +1}, {6.0, -1}}, &box, origin, dir, 10., cfg, good);
5366 BOOST_CHECK_SMALL(good.insideLength - 2., 1.e-12);
5367
5368 Robustness truncated;
5369 auditCrossingList({{4.0, +1}}, &box, origin, dir, 10., cfg, truncated);
5370 BOOST_CHECK_GT(truncated.parityMismatchIntervals, 0);
5371 BOOST_CHECK_EQUAL(truncated.oddCrossingLists, 1);
5372
5373 Robustness invented;
5374 auditCrossingList({{1.0, +1}, {2.0, -1}, {4.0, +1}, {6.0, -1}}, &box, origin, dir, 10., cfg,
5375 invented);
5376 BOOST_CHECK_GT(invented.parityMismatchIntervals, 0);
5377}
5378
5383BOOST_AUTO_TEST_CASE(XRayChordIntegralIsExactForABoxAndConvergesForASphere)
5384{
5385 StepConfig cfg;
5386 TGeoBBox box("xrayVolBox", 1., 1.5, 2.);
5387 for (const int n : {5, 16, 41}) {
5388 const Raster raster = buildRaster({-1., -1.5, -2.}, {1., 1.5, 2.}, n, buildBeams("xyz", 0.), 0.);
5389 Robustness stats;
5390 std::vector<double> byBeam(raster.beams.size(), 0.);
5391 for (const auto& ray : raster.rays) {
5392 const double before = stats.insideLength;
5393 const auto crossings = stepWithShapeApi(&box, ray.origin, ray.dir, ray.tMax, cfg, stats);
5394 auditCrossingList(crossings, nullptr, ray.origin, ray.dir, ray.tMax, cfg, stats);
5395 byBeam[ray.beam] += stats.insideLength - before;
5396 }
5397 BOOST_CHECK_SMALL(chordVolume(raster, byBeam) - 24., 1.e-9);
5398 }
5399
5400 // A curved silhouette cannot be exact at finite N. The bound below is the MEASURED envelope
5401 // over N = 24..192 (2e-3), not a convergence rate -- the convergence is NOT monotone in N,
5402 // because the silhouette cells realign with the lattice at every density. That is the reason
5403 // this benchmark's volume is quoted with its raster density and never extrapolated.
5404 TGeoSphere sphere("xrayVolSphere", 0., 1.);
5405 const double exact = 4. / 3. * 3.14159265358979323846;
5406 for (const int n : {24, 96}) {
5407 const Raster raster = buildRaster({-1., -1., -1.}, {1., 1., 1.}, n, buildBeams("z", 0.), 0.);
5408 Robustness stats;
5409 for (const auto& ray : raster.rays) {
5410 const auto crossings = stepWithShapeApi(&sphere, ray.origin, ray.dir, ray.tMax, cfg, stats);
5411 auditCrossingList(crossings, nullptr, ray.origin, ray.dir, ray.tMax, cfg, stats);
5412 }
5413 const double volume = stats.insideLength * raster.cellArea[0];
5414 BOOST_CHECK_LT(std::fabs(volume - exact) / exact, 2.e-3);
5415 }
5416}
5417
5422BOOST_AUTO_TEST_CASE(XRayRasterRaysStartOutsideAndFansAreDirectionDiverse)
5423{
5424 TGeoBBox box("xrayRasterBox", 1., 1.5, 2.);
5425 const Raster raster = buildRaster({-1., -1.5, -2.}, {1., 1.5, 2.}, 8, buildBeams("xyz", 0.), 0.);
5426 BOOST_CHECK_EQUAL(raster.rays.size(), 3u * 8u * 8u);
5427 for (const auto& ray : raster.rays) {
5428 BOOST_REQUIRE(!box.Contains(ray.origin.data()));
5429 // and the far end must be outside too, so the window really does bracket the solid
5430 const double end[3] = {ray.origin[0] + ray.tMax * ray.dir[0],
5431 ray.origin[1] + ray.tMax * ray.dir[1],
5432 ray.origin[2] + ray.tMax * ray.dir[2]};
5433 BOOST_REQUIRE(!box.Contains(end));
5434 }
5435
5436 const auto axes = buildBeams("xyz", 0.);
5437 const auto fan = buildFanBeams(64);
5438 BOOST_CHECK_EQUAL(axes.size(), 3u);
5439 BOOST_CHECK_EQUAL(fan.size(), 64u);
5440 for (const auto& beams : {axes, fan}) {
5441 for (const auto& beam : beams) {
5442 BOOST_CHECK_SMALL(dot3(beam.dir, beam.dir) - 1., 1.e-12);
5443 BOOST_CHECK_SMALL(dot3(beam.u, beam.v), 1.e-12);
5444 BOOST_CHECK_SMALL(dot3(beam.u, beam.dir), 1.e-12);
5445 BOOST_CHECK_SMALL(dot3(beam.v, beam.dir), 1.e-12);
5446 }
5447 }
5448 // Direction diversity, stated as a number: the axis beams are mutually orthogonal and nothing
5449 // else, while no two fan beams are closer than a few degrees and they span the sphere.
5450 double worstFanAlignment = -1.;
5451 for (size_t i = 0; i < fan.size(); ++i) {
5452 for (size_t j = i + 1; j < fan.size(); ++j) {
5453 worstFanAlignment = std::max(worstFanAlignment, std::fabs(dot3(fan[i].dir, fan[j].dir)));
5454 }
5455 }
5456 BOOST_CHECK_LT(worstFanAlignment, 0.999);
5457}
5458// --- X-ray / geantino transport benchmark ---
5459
5460// --- Dimensionally consistent guards in the quartic root solver ---
5461//
5462// solveQuarticReal used to decide all three of its branches with kTolerance -- 1e-9 *cm*, a
5463// length -- applied to quantities that are not lengths:
5464//
5465// |termQ| <= kTolerance selects the biquadratic branch; termQ scales as L^3
5466// resolvent > kTolerance licenses Ferrari's second stage; resolvent scales as L^2
5467// |derivative| > kTolerance licenses a Newton polishing step; the derivative scales as L^3
5468//
5469// Two consequences:
5470//
5471// * the resolvent guard fails and the function returns the EMPTY root set, so a ray silently
5472// misses a torus it does cross;
5473// * the termQ guard misroutes an asymmetric quartic into the biquadratic branch -- which
5474// *assumes* termQ = 0 and forces the roots to be symmetric about -b/4 -- so it returns
5475// confidently wrong roots instead of the right ones. That is worse than a miss, because a
5476// miss at least leaves a visible gap.
5477//
5478// The trigger is the ratio of the ray's lever arm to the feature it hits, not the model's scale:
5479// the reproducer below is real, *unscaled* ALICE3 geometry, a ray 375 cm from a 0.1 cm tube.
5480//
5481// These cases pin the repair from both sides. It is not enough that the previously-failing case
5482// now works: the branches exist for real reasons, so the biquadratic branch must still be
5483// *selected* for a true biquadratic, and both branches must still *decline* a configuration that
5484// genuinely has no real roots. A guard that always passes would satisfy neither.
5485
5486namespace
5487{
5491double quarticBackwardError(const std::array<double, 5>& coefficients, double x)
5492{
5493 double value = 0., magnitude = 0., power = 1.;
5494 for (int i = 0; i < 5; ++i) {
5495 const double term = coefficients[4 - i] * power;
5496 value += term;
5497 magnitude += std::abs(term);
5498 power *= x;
5499 }
5500 return magnitude > 0. ? std::abs(value) / magnitude : std::abs(value);
5501}
5502
5504std::array<double, 5> quarticFromRoots(double r1, double r2, double r3, double r4)
5505{
5506 return {1., -(r1 + r2 + r3 + r4),
5507 r1 * r2 + r1 * r3 + r1 * r4 + r2 * r3 + r2 * r4 + r3 * r4,
5508 -(r1 * r2 * r3 + r1 * r2 * r4 + r1 * r3 * r4 + r2 * r3 * r4), r1 * r2 * r3 * r4};
5509}
5510
5511std::vector<double> sortedRoots(const std::array<double, 5>& c, surf::QuarticBranch* branch = nullptr)
5512{
5513 const auto found = surf::solveQuarticReal(c[0], c[1], c[2], c[3], c[4], branch);
5514 std::vector<double> roots(found.begin(), found.end());
5515 std::sort(roots.begin(), roots.end());
5516 return roots;
5517}
5518
5520void checkRootsAreRoots(const std::array<double, 5>& c, const std::vector<double>& roots)
5521{
5522 for (const double root : roots) {
5523 BOOST_CHECK_LT(quarticBackwardError(c, root), 1.e-12);
5524 }
5525}
5526} // namespace
5527
5528BOOST_AUTO_TEST_CASE(StreamM_QuarticFindsTheALICE3ProductionScaleRoots)
5529{
5530 // ALICE3 part ST2487462_01, face 47: a torus of R = 5.3 cm and
5531 // r = 0.1 cm, hit by a ray whose origin is 375 cm away. The crossing lies on the untrimmed
5532 // surface to 1.7e-14 cm and inside both parameter windows -- the patch is there and the trim
5533 // admits it -- and the solver returned nothing.
5534 //
5535 // It is a *biquadratic* (the ray is perpendicular to the torus axis, so the true termQ is 0),
5536 // but termQ is evaluated as d - b*c/2 + b^3/8 from terms of magnitude ~1e8 and cancels to
5537 // -5.96e-08 rather than to 0. That is above the absolute 1e-9 test, so the quartic was routed
5538 // into the resolvent branch, whose resolvent is 7.1e-15 and fails its own absolute test.
5539 const std::array<double, 5> c{1.0, -1501.7280000044018, 845808.25396968238, -211752288.545858,
5540 19882619385.616932};
5541
5542 // The reference roots are Newton's method run to convergence on the *exact* binary values of
5543 // those five coefficients in 60-digit decimal arithmetic, so they are the truth for this input
5544 // and not another double-precision solve.
5545 const double firstRoot = 375.3392295779947145;
5546 const double secondRoot = 375.5247704240909448;
5547
5548 // The tolerance is not arbitrary. p'(firstRoot) = -14.47, so one ulp of a0 (3.8e-06 at 1.99e10)
5549 // moves this root by 2.6e-07 cm: the input coefficients do not determine the roots better than
5550 // that. 1e-06 cm is a few times the conditioning limit and four orders below the 0.1 cm tube
5551 // whose crossing this is.
5552 surf::QuarticBranch branch = surf::QuarticBranch::NotAQuartic;
5553 const auto roots = sortedRoots(c, &branch);
5554 BOOST_REQUIRE_EQUAL(roots.size(), 2u);
5555 checkClose(roots[0], firstRoot, 1.e-6);
5556 checkClose(roots[1], secondRoot, 1.e-6);
5557 checkRootsAreRoots(c, roots);
5558
5559 // and it must get there by recognising the biquadratic, not by luck in the resolvent branch
5560 BOOST_CHECK(branch == surf::QuarticBranch::Biquadratic);
5561}
5562
5563BOOST_AUTO_TEST_CASE(StreamM_QuarticIsScaleInvariantOnAnAsymmetricQuartic)
5564{
5565 // A thoroughly well-conditioned quartic with four simple real roots {1, 2, 3, 7}, uniformly
5566 // scaled. Ferrari's method is exactly scale-covariant, so every one of these must return four
5567 // roots at k times the reference ones -- there is no numerical excuse anywhere in this sweep.
5568 //
5569 // Before the repair this collapsed at k = 1e-04, where |termQ| = 5.6e-10 falls under the
5570 // absolute 1e-09 test: the solver takes the biquadratic branch on a quartic that is not
5571 // biquadratic and returns *two* roots, 6.5093e-04 and -9.3257e-07, instead of four.
5572 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}) {
5573 const auto c = quarticFromRoots(1. * k, 2. * k, 3. * k, 7. * k);
5574 surf::QuarticBranch branch = surf::QuarticBranch::NotAQuartic;
5575 const auto roots = sortedRoots(c, &branch);
5576 BOOST_REQUIRE_EQUAL(roots.size(), 4u);
5577 const double expected[4] = {1. * k, 2. * k, 3. * k, 7. * k};
5578 for (int i = 0; i < 4; ++i) {
5579 BOOST_CHECK_LT(std::abs(roots[i] - expected[i]), 1.e-9 * std::abs(expected[i]));
5580 }
5581 checkRootsAreRoots(c, roots);
5582 // The other half of the positive control: an asymmetric quartic must NOT be routed into the
5583 // biquadratic branch at any scale. A termQ test that always passed would fail here.
5584 BOOST_CHECK(branch == surf::QuarticBranch::Resolvent);
5585 }
5586
5587 // The same statement made about accuracy rather than about the branch, on the family that
5588 // produced "two confidently wrong roots": at k = 1e-04 the shipped code returns four roots for
5589 // {-2, -1, 1, 2.1} * k that are wrong by 1.3 % (relative backward error 1.6e-02), because the
5590 // biquadratic branch forces them to be symmetric about -b/4 and they are not.
5591 for (const double k : {1., 1.e-2, 1.e-4, 1.e-6}) {
5592 const auto c = quarticFromRoots(-2. * k, -1. * k, 1. * k, 2.1 * k);
5593 const auto roots = sortedRoots(c);
5594 BOOST_REQUIRE_EQUAL(roots.size(), 4u);
5595 const double expected[4] = {-2. * k, -1. * k, 1. * k, 2.1 * k};
5596 for (int i = 0; i < 4; ++i) {
5597 BOOST_CHECK_LT(std::abs(roots[i] - expected[i]), 1.e-9 * std::abs(expected[i]));
5598 }
5599 checkRootsAreRoots(c, roots);
5600 }
5601}
5602
5603BOOST_AUTO_TEST_CASE(StreamM_QuarticStillSelectsTheBiquadraticBranch)
5604{
5605 // Positive control, first direction. The biquadratic branch is not a fallback: it is the
5606 // correct, better-conditioned answer whenever the depressed quartic really has no odd term, and
5607 // a repair that simply widened its guard into irrelevance would be caught by the previous case
5608 // while a repair that narrowed it away would be caught here.
5609 {
5610 // y^4 - 5 y^2 + 4, roots +-1, +-2; termQ is exactly zero
5611 const std::array<double, 5> c{1., 0., -5., 0., 4.};
5612 surf::QuarticBranch branch = surf::QuarticBranch::NotAQuartic;
5613 const auto roots = sortedRoots(c, &branch);
5614 BOOST_CHECK(branch == surf::QuarticBranch::Biquadratic);
5615 BOOST_REQUIRE_EQUAL(roots.size(), 4u);
5616 const double expected[4] = {-2., -1., 1., 2.};
5617 for (int i = 0; i < 4; ++i) {
5618 checkClose(roots[i], expected[i], 1.e-12);
5619 }
5620 }
5621 // The same quartic shifted along x and scaled, which is what a torus at a lever arm produces:
5622 // termQ is zero in exact arithmetic but is computed by cancelling terms of size |b|^3, so the
5623 // criterion has to be relative to those terms rather than to a fixed length.
5624 // The centres stop at 100. Beyond that the *depression* step -- p, q, r from b, c, d, e -- is
5625 // the limit, not the guards: it cancels numbers of size (centre)^k to leave numbers of size
5626 // (spread)^k, so a quartic whose roots agree to 4 significant figures has lost 8 digits before
5627 // any branch is chosen and Ferrari's discriminants become noise. Measured on this family, with
5628 // rounded coefficients, both before and after this change: relative root spread 2e-01 gives
5629 // 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
5630 // property of Ferrari's method and is not hidden behind a looser tolerance here.
5631 for (const double centre : {0., 1., 100.}) {
5632 for (const double k : {1., 1.e-3, 1.e3}) {
5633 const auto c = quarticFromRoots((centre - 2.) * k, (centre - 1.) * k, (centre + 1.) * k,
5634 (centre + 2.) * k);
5635 surf::QuarticBranch branch = surf::QuarticBranch::NotAQuartic;
5636 const auto roots = sortedRoots(c, &branch);
5637 BOOST_CHECK(branch == surf::QuarticBranch::Biquadratic);
5638 BOOST_REQUIRE_EQUAL(roots.size(), 4u);
5639 const double expected[4] = {(centre - 2.) * k, (centre - 1.) * k, (centre + 1.) * k,
5640 (centre + 2.) * k};
5641 for (int i = 0; i < 4; ++i) {
5642 BOOST_CHECK_LT(std::abs(roots[i] - expected[i]), 1.e-9 * std::max(1.e-30, std::abs(expected[i])));
5643 }
5644 checkRootsAreRoots(c, roots);
5645 }
5646 }
5647}
5648
5649BOOST_AUTO_TEST_CASE(StreamM_QuarticStillDeclinesDegenerateConfigurations)
5650{
5651 // Positive control, second direction. A guard that always passes is not a fix. Each of these
5652 // must still produce no roots, in both branches, at every scale -- "declines" has to survive
5653 // the repair as surely as "accepts" does.
5654 {
5655 // not a genuine quartic at all
5656 surf::QuarticBranch branch = surf::QuarticBranch::Biquadratic;
5657 const auto roots = surf::solveQuarticReal(0., 1., 2., 3., 4., &branch);
5658 BOOST_CHECK_EQUAL(roots.size(), 0u);
5659 BOOST_CHECK(branch == surf::QuarticBranch::NotAQuartic);
5660 }
5661 for (const double k : {1.e4, 1., 1.e-4, 1.e-8}) {
5662 // (x^2 + k^2)(x^2 + 4 k^2): no real roots, termQ = 0 -> the biquadratic branch must decline
5663 const double k2 = k * k;
5664 const std::array<double, 5> biquadratic{1., 0., 5. * k2, 0., 4. * k2 * k2};
5665 surf::QuarticBranch branch = surf::QuarticBranch::NotAQuartic;
5666 BOOST_CHECK_EQUAL(sortedRoots(biquadratic, &branch).size(), 0u);
5667 BOOST_CHECK(branch == surf::QuarticBranch::Biquadratic);
5668
5669 // (x^2 + k^2)((x + k)^2 + 4 k^2): no real roots, termQ != 0 -> the resolvent branch must
5670 // decline, by finding both of Ferrari's quadratics complex rather than by refusing to run
5671 const std::array<double, 5> asymmetric{1., 2. * k, 6. * k2, 2. * k2 * k, 5. * k2 * k2};
5672 BOOST_CHECK_EQUAL(sortedRoots(asymmetric, &branch).size(), 0u);
5673 BOOST_CHECK(branch == surf::QuarticBranch::Resolvent);
5674 }
5675}
5676
5677BOOST_AUTO_TEST_CASE(StreamM_QuarticHasNoCliffAsAQuarticApproachesBiquadratic)
5678{
5679 // The defect is a *cliff*: an absolute threshold crossed by a quantity that carries units, so
5680 // the answer changes discontinuously with the size of the geometry. The repair has to be
5681 // continuous instead -- as termQ is driven to zero the two branches must agree, because they
5682 // are the two sides of one limit.
5683 //
5684 // {-2, -1, 1, 2 + delta} scaled by k: at delta = 0 the quartic is exactly biquadratic, and
5685 // delta walks it away from that continuously. Every point must give four correct roots.
5686 for (const double k : {1., 1.e-2, 1.e-4, 1.e-6}) {
5687 for (const double delta : {1.e-1, 1.e-3, 1.e-6, 1.e-9, 1.e-12, 0.}) {
5688 const auto c = quarticFromRoots(-2. * k, -1. * k, 1. * k, (2. + delta) * k);
5689 const auto roots = sortedRoots(c);
5690 BOOST_REQUIRE_EQUAL(roots.size(), 4u);
5691 const double expected[4] = {-2. * k, -1. * k, 1. * k, (2. + delta) * k};
5692 for (int i = 0; i < 4; ++i) {
5693 BOOST_CHECK_LT(std::abs(roots[i] - expected[i]), 1.e-8 * std::abs(expected[i]));
5694 }
5695 checkRootsAreRoots(c, roots);
5696 }
5697 }
5698}
5699// --- Tier 0: canonical recognition of NURBS-encoded quadrics
5700//
5701// The recognition work itself is entirely converter-side and its own controls live in
5702// `O2_CADtoTGeo.py --self-test` (18 checks: a NurbsConvert-ed quadric of each kind must be
5703// recovered, a genuine free-form patch must not, and every accepted face's MEASURED gap must be
5704// inside the acceptance tolerance). Nothing of that can be asserted from C++.
5705//
5706// What *can* be asserted here, and matters more than it looks, is the kernel-side contract the
5707// converter measures against. `_recognized_inner_wall()` decides a recognized quadric's
5708// `inner_wall` flag by comparing the face's own outward normal with "away from the axis", because
5709// on a NURBS-encoded quadric `TopoDS` orientation says nothing (on ALICE3: nine
5710// ALICE3 faces with an exactly antiparallel outward normal, 404 lost crossings, and every closure
5711// and edge-identity check blind to it because they are all sign-blind). That measurement is only
5712// correct if the kernel's own convention is the one it assumes. This work multiplies the number
5713// of faces going through that path, so the convention is pinned rather than assumed: if it were
5714// ever inverted, every recognized face would silently flip and no existing test would notice.
5715BOOST_AUTO_TEST_CASE(StreamK_InnerWallIsExactlyTheSignOfTheOutwardNormal)
5716{
5717 const Point3D centre{0.3, -0.7, 1.1};
5718 const Point3D axis{0., 0., 1.};
5719 const Point3D refU{1., 0., 0.};
5720 constexpr double radius = 2.5;
5721 constexpr double phi = 0.9;
5722
5723 // A point on each surface, and the direction "away from the axis / centre" there.
5724 const double cx = centre[0] + radius * std::cos(phi);
5725 const double cy = centre[1] + radius * std::sin(phi);
5726 const Double_t onCylinder[3] = {cx, cy, centre[2] + 0.4};
5727 const Double_t awayFromAxis[3] = {std::cos(phi), std::sin(phi), 0.};
5728
5729 for (const bool innerWall : {false, true}) {
5730 SurfaceSolid solid(innerWall ? "streamK_innerCyl" : "streamK_outerCyl");
5731 BOOST_REQUIRE(solid.AddCylindricalSurface(centre, axis, refU, radius, -1., 1., 0., surf::kTwoPi, innerWall));
5732 Double_t n[3] = {0., 0., 0.};
5733 solid.ComputeNormal(onCylinder, nullptr, n);
5734 const double alignment = n[0] * awayFromAxis[0] + n[1] * awayFromAxis[1] + n[2] * awayFromAxis[2];
5735 // Exactly +1 or exactly -1: this is a sign, not a tolerance.
5736 BOOST_CHECK_CLOSE(alignment, innerWall ? -1. : 1., 1.e-9);
5737 }
5738
5739 // The same convention on the cone and on the sphere. All three go through the converter's one
5740 // `_recognized_inner_wall` measurement, and ALICE3 exercises only the cylinder branch today
5741 // (recognized planes and spheres are untested there), so the
5742 // other two are pinned here rather than left to the first model that uses them.
5743 for (const bool innerWall : {false, true}) {
5744 SurfaceSolid solid(innerWall ? "streamK_innerCone" : "streamK_outerCone");
5745 // r(h) = 1 + h over h in [0, 2]: half-angle 45 degrees, apex at h = -1.
5746 BOOST_REQUIRE(solid.AddConicalSurface(centre, axis, refU, 1., 3., 0., 2., 0., surf::kTwoPi, innerWall));
5747 const double h = 1.0;
5748 const double r = 2.0;
5749 const Double_t onCone[3] = {centre[0] + r * std::cos(phi), centre[1] + r * std::sin(phi), centre[2] + h};
5750 Double_t n[3] = {0., 0., 0.};
5751 solid.ComputeNormal(onCone, nullptr, n);
5752 // The cone's outward normal tilts out of the radial direction by the half angle, so the
5753 // radial component is what carries the sign -- which is exactly the reasoning
5754 // `_recognized_inner_wall` relies on, and the reason it can use the radial direction alone.
5755 const double radial = n[0] * std::cos(phi) + n[1] * std::sin(phi);
5756 BOOST_CHECK_GT(innerWall ? -radial : radial, 0.5);
5757 }
5758
5759 for (const bool innerWall : {false, true}) {
5760 SurfaceSolid solid(innerWall ? "streamK_innerSph" : "streamK_outerSph");
5761 BOOST_REQUIRE(solid.AddSphericalSurface(centre, axis, refU, radius, 0., surf::kPi, 0., surf::kTwoPi, innerWall));
5762 const double theta = 1.1;
5763 const Double_t onSphere[3] = {centre[0] + radius * std::sin(theta) * std::cos(phi),
5764 centre[1] + radius * std::sin(theta) * std::sin(phi),
5765 centre[2] + radius * std::cos(theta)};
5766 const double outward[3] = {std::sin(theta) * std::cos(phi), std::sin(theta) * std::sin(phi), std::cos(theta)};
5767 Double_t n[3] = {0., 0., 0.};
5768 solid.ComputeNormal(onSphere, nullptr, n);
5769 const double alignment = n[0] * outward[0] + n[1] * outward[1] + n[2] * outward[2];
5770 BOOST_CHECK_CLOSE(alignment, innerWall ? -1. : 1., 1.e-9);
5771 }
5772}
5773// --- Placed primitives ---
5774//
5775// A recognised primitive whose frame is not the identity used to be emitted as a degenerate
5776// TGeoCompositeShape -- the primitive unioned with an identical copy of itself under the same
5777// matrix -- because no TGeoShape in ROOT 6.36 carries a rigid transform. That is still true of
5778// ROOT; what changed is where the transform lives. The shape is now written in its OWN canonical
5779// frame and the transform travels beside it, as a TGeoHMatrix under the key "placement" in
5780// shape_<part>.root. These cases pin the three things that can go wrong with that:
5781//
5782// 1. the artefact: the placement must survive the round trip, and its ABSENCE must keep meaning
5783// the identity, so that every file written before this convention still loads and still
5784// scores exactly as it did;
5785// 2. the equivalence: the bare primitive queried in its own frame must answer *exactly* like
5786// the composite it replaces, with a negative control that moves the count;
5787// 3. the composition order in geom.C -- `partPlacement * shapePlacement`. That one is silent
5788// when wrong: the geometry still builds and the shape is still the right shape, it is simply
5789// somewhere else. It is checked by navigating, with a transposed rotation and a reversed
5790// product as the controls.
5791
5792namespace
5793{
5796TGeoCombiTrans* makeStreamNPlacement()
5797{
5798 auto* rotation = new TGeoRotation("streamNRot", 0., 0., 0.);
5799 rotation->RotateX(30.);
5800 rotation->RotateZ(17.);
5801 rotation->RotateY(-41.);
5802 return new TGeoCombiTrans(0.3, 5.916, 2.0, rotation);
5803}
5804} // namespace
5805
5806BOOST_AUTO_TEST_CASE(ShapeSidecarRoundTripsAPlacement)
5807{
5808 namespace harness = o2::cad::harness;
5809 const auto dir = std::filesystem::temp_directory_path();
5810 const std::string path = (dir / "o2_shape_placed.root").string();
5811
5812 const TGeoTube tube("shape", 0.4, 1.0, 5.0);
5813 std::unique_ptr<TGeoCombiTrans> placement(makeStreamNPlacement());
5814
5815 std::string error;
5816 BOOST_REQUIRE_MESSAGE(harness::saveShapeToRootFile(path, tube, placement.get(), &error), error);
5817
5818 std::unique_ptr<TGeoShape> loaded(harness::loadShapeFromRootFile(path, &error));
5819 BOOST_REQUIRE_MESSAGE(loaded != nullptr, error);
5820 BOOST_CHECK_EQUAL(std::string(loaded->ClassName()), std::string("TGeoTube"));
5821
5822 std::unique_ptr<TGeoHMatrix> back(harness::loadShapePlacementFromRootFile(path));
5823 BOOST_REQUIRE(back != nullptr);
5824 for (int i = 0; i < 9; ++i) {
5825 BOOST_CHECK_EQUAL(back->GetRotationMatrix()[i], placement->GetRotationMatrix()[i]);
5826 }
5827 for (int i = 0; i < 3; ++i) {
5828 BOOST_CHECK_EQUAL(back->GetTranslation()[i], placement->GetTranslation()[i]);
5829 }
5830 // The point of storing it: a point of the part frame reaches the same place through the file as
5831 // through the original matrix.
5832 const Point3D master{0.9, 6.2, 3.1};
5833 Point3D viaFile{};
5834 Point3D viaOriginal{};
5835 back->MasterToLocal(master.data(), viaFile.data());
5836 placement->MasterToLocal(master.data(), viaOriginal.data());
5837 for (int i = 0; i < 3; ++i) {
5838 BOOST_CHECK_EQUAL(viaFile[i], viaOriginal[i]);
5839 }
5840 std::filesystem::remove(path);
5841}
5842
5843BOOST_AUTO_TEST_CASE(AbsentPlacementMeansIdentity)
5844{
5845 namespace harness = o2::cad::harness;
5846 const auto dir = std::filesystem::temp_directory_path();
5847 const TGeoTube tube("shape", 0.4, 1.0, 5.0);
5848 std::string error;
5849
5850 // 1. The historical two-argument overload -- the one every existing shape_*.root was written
5851 // with -- must record no placement at all.
5852 const std::string legacy = (dir / "o2_shape_legacy.root").string();
5853 BOOST_REQUIRE_MESSAGE(harness::saveShapeToRootFile(legacy, tube, &error), error);
5854 BOOST_CHECK(harness::loadShapePlacementFromRootFile(legacy) == nullptr);
5855
5856 // 2. An identity placement is deliberately NOT written, so that "no key" stays the one and only
5857 // spelling of the identity.
5858 const std::string identity = (dir / "o2_shape_identity.root").string();
5859 TGeoHMatrix unit("unit");
5860 BOOST_REQUIRE_MESSAGE(harness::saveShapeToRootFile(identity, tube, &unit, &error), error);
5861 BOOST_CHECK(harness::loadShapePlacementFromRootFile(identity) == nullptr);
5862
5863 // 3. A file that is not there is the same answer, and must not throw or complain: a part with
5864 // no shape sidecar at all is the overwhelmingly common case.
5865 BOOST_CHECK(harness::loadShapePlacementFromRootFile((dir / "o2_shape_nothing.root").string()) ==
5866 nullptr);
5867
5868 std::filesystem::remove(legacy);
5869 std::filesystem::remove(identity);
5870}
5871
5872BOOST_AUTO_TEST_CASE(PlacedPrimitiveAnswersExactlyLikeTheSelfUnionComposite)
5873{
5874 // The equivalence the change rests on: the bare primitive queried in its own frame answers like
5875 // the composite it replaces, on all four scored queries, exactly.
5876 constexpr double kRmin = 0.4;
5877 constexpr double kRmax = 1.0;
5878 constexpr double kDz = 5.0;
5879
5880 std::unique_ptr<TGeoCombiTrans> placement(makeStreamNPlacement());
5881 const TGeoTube placedPrimitive("streamNTube", kRmin, kRmax, kDz);
5882 // The old emission, built here so the two are compared rather than one being trusted.
5883 std::unique_ptr<TGeoCompositeShape> composite(
5884 makePlacedTube("streamNComposite", kRmin, kRmax, kDz, new TGeoCombiTrans(*placement),
5885 new TGeoCombiTrans(*placement)));
5886
5887 std::size_t probes = 0;
5888 std::size_t inside = 0;
5889 std::size_t disagreements = 0;
5890 // The negative control travels with the check: the same loop against a 5% fatter tube must
5891 // disagree, or the loop is not measuring anything.
5892 const TGeoTube wrong("streamNWrong", kRmin, kRmax * 1.05, kDz);
5893 std::size_t controlDisagreements = 0;
5894
5895 for (int ix = -8; ix <= 8; ++ix) {
5896 for (int iy = -8; iy <= 8; ++iy) {
5897 for (int iz = -8; iz <= 8; ++iz) {
5898 const Point3D master{0.3 + 0.37 * ix, 5.916 + 0.41 * iy, 2.0 + 0.43 * iz};
5899 Point3D local{};
5900 placement->MasterToLocal(master.data(), local.data());
5901 const double r = std::hypot(local[0], local[1]);
5902 if (std::fabs(r - kRmin) < 1.e-9 || std::fabs(r - kRmax) < 1.e-9 ||
5903 std::fabs(r - kRmax * 1.05) < 1.e-9 || std::fabs(std::fabs(local[2]) - kDz) < 1.e-9) {
5904 continue;
5905 }
5906 ++probes;
5907 const bool wanted = composite->Contains(master.data());
5908 if (placedPrimitive.Contains(local.data()) != wanted) {
5909 ++disagreements;
5910 }
5911 if (wrong.Contains(local.data()) != wanted) {
5912 ++controlDisagreements;
5913 }
5914 if (wanted) {
5915 ++inside;
5916 }
5917 BOOST_REQUIRE_CLOSE_FRACTION(placedPrimitive.Safety(local.data(), wanted),
5918 composite->Safety(master.data(), wanted), 1.e-12);
5919 for (const auto& dir : {Point3D{1., 0., 0.}, Point3D{0., 1., 0.}, Point3D{0., 0., 1.},
5920 Point3D{0.5773502691896258, 0.5773502691896258,
5921 0.5773502691896258}}) {
5922 Point3D localDir{};
5923 placement->MasterToLocalVect(dir.data(), localDir.data());
5924 if (wanted) {
5925 BOOST_REQUIRE_CLOSE_FRACTION(placedPrimitive.DistFromInside(local.data(),
5926 localDir.data(), 3),
5927 composite->DistFromInside(master.data(), dir.data(), 3),
5928 1.e-12);
5929 } else {
5930 const double got = placedPrimitive.DistFromOutside(local.data(), localDir.data(), 3);
5931 const double want = composite->DistFromOutside(master.data(), dir.data(), 3);
5932 if (want > 1.e20) {
5933 BOOST_REQUIRE_GT(got, 1.e20);
5934 } else {
5935 BOOST_REQUIRE_CLOSE_FRACTION(got, want, 1.e-12);
5936 }
5937 }
5938 }
5939 }
5940 }
5941 }
5942 BOOST_CHECK_EQUAL(disagreements, 0u);
5943 BOOST_CHECK_GT(controlDisagreements, 0u);
5944 BOOST_CHECK_GT(inside, 100u);
5945 BOOST_CHECK_GT(probes, 3000u);
5946}
5947
5948BOOST_AUTO_TEST_CASE(PlacedPrimitiveRecoversTheAnalyticCapacity)
5949{
5950 // What the degenerate composite cost, stated as a measurement rather than as a claim.
5951 constexpr double kRmin = 0.4;
5952 constexpr double kRmax = 1.0;
5953 constexpr double kDz = 5.0;
5954 const double analytic = TMath::Pi() * (kRmax * kRmax - kRmin * kRmin) * 2. * kDz;
5955
5956 const TGeoTube tube("streamNCapTube", kRmin, kRmax, kDz);
5957 BOOST_CHECK_CLOSE_FRACTION(tube.Capacity(), analytic, 1.e-14);
5958 // Deterministic: asked twice, the same bits.
5959 BOOST_CHECK_EQUAL(tube.Capacity(), tube.Capacity());
5960
5961 std::unique_ptr<TGeoCombiTrans> placement(makeStreamNPlacement());
5962 std::unique_ptr<TGeoCompositeShape> composite(
5963 makePlacedTube("streamNCapComposite", kRmin, kRmax, kDz, new TGeoCombiTrans(*placement),
5964 new TGeoCombiTrans(*placement)));
5965 // ... whereas TGeoCompositeShape::Capacity() throws 10000 Monte-Carlo points into the bounding
5966 // box, so two calls on the same object return different numbers. That is the reason the gate
5967 // marks a composite `capacityComparable=false`, and the reason a placed primitive that is no
5968 // longer a composite gets its capacity column back.
5969 const double first = composite->Capacity();
5970 const double second = composite->Capacity();
5971 BOOST_CHECK_NE(first, second);
5972 BOOST_CHECK_GT(std::fabs(first - analytic) / analytic, 1.e-6);
5973}
5974
5975BOOST_AUTO_TEST_CASE(NodeMatrixIsPartPlacementTimesShapePlacement)
5976{
5977 // The composition geom.C emits, decided by NAVIGATION rather than by reading the code.
5978 //
5979 // The reference is built without ever forming the product: a point of the assembly frame is
5980 // carried into the part frame by the part placement, then into the shape's frame by the shape
5981 // placement, and the tube membership is evaluated there. If `partPlacement * shapePlacement` is
5982 // the right node matrix, ROOT's navigator must reach the same verdict for every point.
5983 constexpr double kRmin = 0.4;
5984 constexpr double kRmax = 1.0;
5985 constexpr double kDz = 5.0;
5986
5987 std::unique_ptr<TGeoCombiTrans> shapePlacementOwned(makeStreamNPlacement());
5988 const TGeoHMatrix shapePlacement(*shapePlacementOwned);
5989 auto* partRotation = new TGeoRotation("streamNPartRot", 37., 24., 61.);
5990 const TGeoCombiTrans partPlacement(-2.0, 7.0, 1.5, partRotation);
5991
5992 const auto reference = [&](const Point3D& master, bool& onWall) {
5993 Point3D partFrame{};
5994 Point3D shapeFrame{};
5995 partPlacement.MasterToLocal(master.data(), partFrame.data());
5996 shapePlacement.MasterToLocal(partFrame.data(), shapeFrame.data());
5997 const double r = std::hypot(shapeFrame[0], shapeFrame[1]);
5998 onWall = std::fabs(r - kRmin) < 1.e-9 || std::fabs(r - kRmax) < 1.e-9 ||
5999 std::fabs(std::fabs(shapeFrame[2]) - kDz) < 1.e-9;
6000 return r >= kRmin && r <= kRmax && std::fabs(shapeFrame[2]) <= kDz;
6001 };
6002
6003 // Every candidate node matrix, including the three ways of getting it wrong. `partOnly` is the
6004 // bug this test is really for: forgetting to compose at all.
6005 TGeoHMatrix correct(partPlacement);
6006 correct.Multiply(&shapePlacement);
6007 TGeoHMatrix reversed(shapePlacement);
6008 reversed.Multiply(&partPlacement);
6009 TGeoHMatrix transposedRotation(shapePlacement);
6010 {
6011 double rt[9];
6012 const double* r = shapePlacement.GetRotationMatrix();
6013 for (int i = 0; i < 3; ++i) {
6014 for (int j = 0; j < 3; ++j) {
6015 rt[3 * i + j] = r[3 * j + i];
6016 }
6017 }
6018 transposedRotation.SetRotation(rt);
6019 transposedRotation.SetBit(TGeoMatrix::kGeoRotation);
6020 }
6021 TGeoHMatrix withTransposed(partPlacement);
6022 withTransposed.Multiply(&transposedRotation);
6023 const TGeoHMatrix partOnly(partPlacement);
6024
6025 const std::vector<std::pair<std::string, const TGeoHMatrix*>> candidates{
6026 {"part*shape", &correct},
6027 {"shape*part", &reversed},
6028 {"part*shape^T", &withTransposed},
6029 {"part only", &partOnly}};
6030
6031 // The lattice is centred where the solid actually is -- the translation of the CORRECT node
6032 // matrix -- and spans more than the tube's own extent. Guessing the centre by adding the two
6033 // translations put every probe outside the solid, and the controls then reported zero
6034 // disagreements while being structurally incapable of reporting anything else.
6035 const double* centre = correct.GetTranslation();
6036
6037 std::vector<size_t> disagreements(candidates.size(), 0);
6038 size_t probes = 0;
6039 size_t insideProbes = 0;
6040
6041 for (size_t c = 0; c < candidates.size(); ++c) {
6042 // One manager per candidate, and everything inside it allocated with new: a TGeoShape and a
6043 // TGeoVolume register themselves with gGeoManager, which frees them.
6044 auto* manager = new TGeoManager(("streamN_" + std::to_string(c)).c_str(), "composition order");
6045 auto* material = new TGeoMaterial("Vacuum", 0., 0., 0.);
6046 auto* medium = new TGeoMedium("Vacuum", 1, material);
6047 auto* world = new TGeoVolume("TOP", new TGeoBBox("streamNWorld", 30., 30., 30.), medium);
6048 auto* part = new TGeoVolume("PART", new TGeoTube("streamNNodeTube", kRmin, kRmax, kDz), medium);
6049 world->AddNode(part, 1, new TGeoHMatrix(*candidates[c].second));
6050 manager->SetTopVolume(world);
6051 manager->CloseGeometry();
6052
6053 size_t localProbes = 0;
6054 size_t localInside = 0;
6055 for (int ix = -14; ix <= 14; ++ix) {
6056 for (int iy = -14; iy <= 14; ++iy) {
6057 for (int iz = -14; iz <= 14; ++iz) {
6058 const Point3D master{centre[0] + 0.45 * ix, centre[1] + 0.47 * iy,
6059 centre[2] + 0.43 * iz};
6060 bool onWall = false;
6061 const bool wanted = reference(master, onWall);
6062 if (onWall) {
6063 continue;
6064 }
6065 ++localProbes;
6066 if (wanted) {
6067 ++localInside;
6068 }
6069 TGeoNode* node = manager->FindNode(master[0], master[1], master[2]);
6070 const bool got = node != nullptr && std::string(node->GetVolume()->GetName()) == "PART";
6071 if (got != wanted) {
6072 ++disagreements[c];
6073 }
6074 }
6075 }
6076 }
6077 probes = localProbes;
6078 insideProbes = localInside;
6079 delete manager;
6080 gGeoManager = nullptr;
6081 }
6082
6083 // The sampling has to be capable of failing: enough points, and enough of them inside.
6084 BOOST_CHECK_GT(probes, 5000u);
6085 BOOST_CHECK_GT(insideProbes, 200u);
6086 BOOST_CHECK_EQUAL(disagreements[0], 0u); // partPlacement * shapePlacement
6087 BOOST_CHECK_GT(disagreements[1], 0u); // reversed product
6088 BOOST_CHECK_GT(disagreements[2], 0u); // transposed shape rotation
6089 BOOST_CHECK_GT(disagreements[3], 0u); // shape placement dropped
6090}
6091
6092// ============================================================================================
6093// The representation cost/memory benchmark's own instruments
6094// ============================================================================================
6095//
6096// These pin the MEASURING apparatus, not the geometry. A per-call cost table is only worth
6097// reading if the harness that produced it can be shown to move its number when the thing it
6098// measures moves. Each case below is that demonstration for one column of the benchmark.
6099//
6100// They include RepresentationBench.h -- the SAME header the benchmark binary times with.
6101
6102#include "RepresentationBench.h"
6103
6104using namespace o2::cad::bench;
6105
6107BOOST_AUTO_TEST_CASE(RepBenchTimingHarnessRunsTheRequestedPasses)
6108{
6109 TGeoBBox fast("repBenchFast", 1., 1., 1.);
6110 const o2::cad::harness::Point3D lo{-1., -1., -1.};
6111 const o2::cad::harness::Point3D hi{1., 1., 1.};
6112 const QuerySamples samples = buildQuerySamples(&fast, "control", lo, hi, 1500, 1500);
6113
6114 // The sample set has to be capable of exercising both branches, or three of the four kernels
6115 // are being timed on an empty vector.
6116 BOOST_CHECK_GT(samples.insidePoints, 100);
6117 BOOST_CHECK_LT(samples.insidePoints, static_cast<long long>(samples.points.size()) - 100);
6118 BOOST_CHECK_EQUAL(samples.outsideRays.size(), 1500u);
6119 BOOST_CHECK_EQUAL(samples.insideRays.size(), 1500u);
6120
6121 // And the loop must not have been optimised away: a non-zero checksum, a positive time, and
6122 // the requested number of passes actually run.
6123 const TimingStat stat = timeContainsPass(&fast, samples, 1, 5);
6124 BOOST_CHECK_NE(stat.checksum, 0u);
6125 BOOST_CHECK_GT(stat.medianNsPerCall, 0.);
6126 BOOST_CHECK_EQUAL(stat.passes, 5);
6127 BOOST_CHECK_LE(stat.minNsPerCall, stat.medianNsPerCall);
6128 BOOST_CHECK_LE(stat.medianNsPerCall, stat.maxNsPerCall);
6129}
6130
6135BOOST_AUTO_TEST_CASE(RepBenchSampleSetIsReproducibleAndActuallyHits)
6136{
6137 TGeoTube tube("repBenchTube", 0.3, 1., 2.);
6138 const o2::cad::harness::Point3D lo{-1., -1., -2.};
6139 const o2::cad::harness::Point3D hi{1., 1., 2.};
6140 const QuerySamples a = buildQuerySamples(&tube, "surface", lo, hi, 2000, 2000);
6141 const QuerySamples b = buildQuerySamples(&tube, "surface", lo, hi, 2000, 2000);
6142
6143 // Same seed, same bbox, same reference -> bit-identical. Without this the cost table's
6144 // "same sample set" claim is not checkable from outside.
6145 BOOST_REQUIRE_EQUAL(a.points.size(), b.points.size());
6146 for (size_t i = 0; i < a.points.size(); ++i) {
6147 BOOST_CHECK_EQUAL(a.points[i][0], b.points[i][0]);
6148 BOOST_CHECK_EQUAL(a.pointIsInside[i], b.pointIsInside[i]);
6149 BOOST_CHECK_EQUAL(a.pointIsInside[i] != 0, tube.Contains(a.points[i].data()));
6150 }
6151 BOOST_CHECK_GT(timeDistOutPass(&tube, a, 1, 3).hitFraction, 0.5);
6152 BOOST_CHECK_EQUAL(timeDistInPass(&tube, a, 1, 3).hitFraction, 1.);
6153}
6154
6159#ifdef __linux__
6160BOOST_AUTO_TEST_CASE(RepBenchMemoryProbeSeesAnAllocationAndItsRelease)
6161{
6162 const MemorySnapshot before = readMemory();
6163 constexpr size_t kBytes = 64u << 20;
6164 auto block = std::make_unique<char[]>(kBytes);
6165 for (size_t i = 0; i < kBytes; i += 4096) {
6166 block[i] = static_cast<char>(i);
6167 }
6168 const MemorySnapshot delta = readMemory() - before;
6169 BOOST_CHECK_GT(delta.residentBytes, 32LL << 20);
6170 BOOST_CHECK_GT(delta.heapInUseBytes, 32LL << 20);
6171 block.reset();
6172 BOOST_CHECK_LT((readMemory() - before).heapInUseBytes, 8LL << 20);
6173}
6174#endif
6175
6179BOOST_AUTO_TEST_CASE(RepBenchBooleanLadderHasTheStructureItClaims)
6180{
6181 auto* manager = new TGeoManager("repBenchLadder", "ladder");
6182 for (const int k : {2, 4, 8, 16, 32}) {
6183 const BooleanTreeStats chain =
6184 booleanTreeStats(buildBooleanLadder(k, LadderShape::Chain, "tC" + std::to_string(k)));
6185 const BooleanTreeStats balanced =
6186 booleanTreeStats(buildBooleanLadder(k, LadderShape::Balanced, "tB" + std::to_string(k)));
6187 BOOST_CHECK_EQUAL(chain.leaves, k);
6188 BOOST_CHECK_EQUAL(balanced.leaves, k);
6189 BOOST_CHECK_EQUAL(chain.nodes, k - 1);
6190 BOOST_CHECK_EQUAL(balanced.nodes, k - 1);
6191 BOOST_CHECK_EQUAL(chain.depth, k);
6192 BOOST_CHECK_EQUAL(balanced.depth, 1 + static_cast<int>(std::lround(std::log2(k))));
6193 }
6194 // A single leaf is not a composite at all: the ladder must hand back the primitive rather than
6195 // a one-sided union, or the K=1 baseline row would be priced with boolean machinery.
6196 TGeoShape* single = buildBooleanLadder(1, LadderShape::Balanced, "tOne");
6197 BOOST_CHECK(dynamic_cast<TGeoCompositeShape*>(single) == nullptr);
6198 BOOST_CHECK_EQUAL(booleanTreeStats(single).leaves, 1);
6199 delete manager;
6200 gGeoManager = nullptr;
6201}
6202
6203// --- Representation cost/memory benchmark ---
6204
6205// --- BVH-accelerated Safety and ComputeNormal ---
6206//
6207// Safety() and ComputeNormal() answer the same question -- which trimmed patch is nearest to this
6208// point -- and both used to answer it with a bare loop over every patch, which on ALICE3's
6209// 965-patch solid cost 812 us per call. They now walk the
6210// BVH that was already there. The oracle is the loop they replaced, kept as Safety_Loop() /
6211// ComputeNormal_Loop(), and the contract against it is *exact equality*, not agreement to a
6212// tolerance: both minimise the same distanceSqToPatch over the same patches under the same
6213// tie-break, so any difference at all is a traversal or pruning bug.
6214
6215namespace
6216{
6217// The benchmark's deterministic LCG, seeded explicitly, so a failure is reproducible from the seed alone.
6218struct SampleStream {
6219 explicit SampleStream(std::uint64_t seed) { lcg.state = seed | 1u; }
6221 double uniform() { return lcg.next(); }
6222 double symmetric(double extent) { return (2. * uniform() - 1.) * extent; }
6223};
6224
6232std::vector<std::array<double, 3>> nearestPatchSample(const SurfaceSolid& solid, double extent, int count)
6233{
6234 SampleStream stream(0x5EAFE7Full);
6235 std::vector<std::array<double, 3>> points;
6236 points.reserve(static_cast<size_t>(count));
6237 for (int index = 0; index < count; ++index) {
6238 std::array<double, 3> point{stream.symmetric(extent), stream.symmetric(extent), stream.symmetric(extent)};
6239 switch (index % 5) {
6240 case 0: // wherever it landed: inside or outside, generic
6241 break;
6242 case 1: { // walked onto the surface along its own normal, i.e. distance ~ 0
6243 std::array<double, 3> normal{0., 0., 0.};
6244 const double safety = solid.Safety_Loop(point.data(), solid.Contains(point.data()));
6245 solid.ComputeNormal_Loop(point.data(), nullptr, normal.data());
6246 const double sign = solid.Contains(point.data()) ? 1. : -1.;
6247 for (int dimension = 0; dimension < 3; ++dimension) {
6248 point[dimension] += sign * safety * normal[dimension];
6249 }
6250 break;
6251 }
6252 case 2: { // a hair off the surface, at the scale where several patches compete
6253 std::array<double, 3> normal{0., 0., 0.};
6254 const double safety = solid.Safety_Loop(point.data(), solid.Contains(point.data()));
6255 solid.ComputeNormal_Loop(point.data(), nullptr, normal.data());
6256 const double sign = solid.Contains(point.data()) ? 1. : -1.;
6257 const double offset = safety - sign * 1.e-9 * std::max(1., extent);
6258 for (int dimension = 0; dimension < 3; ++dimension) {
6259 point[dimension] += sign * offset * normal[dimension];
6260 }
6261 break;
6262 }
6263 case 3: // well outside the bounding box
6264 for (auto& coordinate : point) {
6265 coordinate *= 40.;
6266 }
6267 break;
6268 case 4: // very far away, where the node bound is large and its rounding is worst
6269 for (auto& coordinate : point) {
6270 coordinate *= 1.e7;
6271 }
6272 break;
6273 }
6274 points.push_back(point);
6275 }
6276 // and the exact centre, where every face of a box is equidistant: the tie the tie-break decides
6277 points.push_back({0., 0., 0.});
6278 return points;
6279}
6280
6283int countNearestPatchDisagreements(const SurfaceSolid& solid, const std::array<double, 3>& point,
6284 double* worstSafetyGap = nullptr)
6285{
6286 int disagreements = 0;
6287 for (const bool inside : {true, false}) {
6288 const double accelerated = solid.Safety(point.data(), inside);
6289 const double reference = solid.Safety_Loop(point.data(), inside);
6290 if (accelerated != reference) {
6291 ++disagreements;
6292 }
6293 if (worstSafetyGap != nullptr) {
6294 *worstSafetyGap = std::max(*worstSafetyGap, accelerated - reference);
6295 }
6296 }
6297 // with and without a direction, since the direction flips the sign of the chosen patch's normal
6298 // and a wrong patch can hide behind that flip
6299 const std::array<double, 3> direction = unitDirection(0.37, -0.82, 0.44);
6300 for (const double* dir : {static_cast<const double*>(nullptr), direction.data()}) {
6301 std::array<double, 3> accelerated{0., 0., 0.};
6302 std::array<double, 3> reference{0., 0., 0.};
6303 solid.ComputeNormal(point.data(), dir, accelerated.data());
6304 solid.ComputeNormal_Loop(point.data(), dir, reference.data());
6305 for (int dimension = 0; dimension < 3; ++dimension) {
6306 if (accelerated[dimension] != reference[dimension]) {
6307 ++disagreements;
6308 }
6309 }
6310 }
6311 return disagreements;
6312}
6313
6314// A solid with many patches whose boxes overlap heavily: eight boxes on a line is the easy case
6315// for pruning, a shell of small boxes around a sphere is not.
6316std::unique_ptr<SurfaceSolid> makeManyPatchSolid(const char* name, int ringCount)
6317{
6318 auto solid = std::make_unique<SurfaceSolid>(name);
6319 for (int ring = 0; ring < ringCount; ++ring) {
6320 const double angle = surf::kTwoPi * ring / ringCount;
6321 addBoxSurfaces(*solid, 0.4, 0.4, 0.4, {3. * std::cos(angle), 3. * std::sin(angle), 0.});
6322 }
6323 solid->CloseShape();
6324 return solid;
6325}
6326
6327// A quarter of a circle of radius r about (cu, cv) in a parametric domain, as a rational quadratic
6328// B-spline -- the public-API twin of the kernel-level quarterCircleBSpline above.
6329BoundaryCurve quarterCircleBoundary(double cu, double cv, double r, double a0)
6330{
6331 const double a1 = a0 + surf::kHalfPi;
6332 const double aMid = 0.5 * (a0 + a1);
6333 std::vector<Point2D> poles{{cu + r * std::cos(a0), cv + r * std::sin(a0)},
6334 {cu + r * std::sqrt(2.) * std::cos(aMid), cv + r * std::sqrt(2.) * std::sin(aMid)},
6335 {cu + r * std::cos(a1), cv + r * std::sin(a1)}};
6336 return BoundaryCurve::makeBSpline(2, std::move(poles), {1., std::sqrt(0.5), 1.}, {0., 0., 0., 1., 1., 1.});
6337}
6338
6339// Four cylindrical windows cut by B-spline wires in (phi, h), plus two disks. Not a closed solid --
6340// it is not meant to be navigated -- but it is the *trim* family measured at 2-6 us per
6341// candidate patch, whose distanceSqToPatch walks a flattened polyline. That is where pruning has
6342// the most to save and where a wrong bound would cost the most, so the cross-check has to cover it.
6343std::unique_ptr<SurfaceSolid> makeWireTrimmedSolid(const char* name)
6344{
6345 auto solid = std::make_unique<SurfaceSolid>(name);
6346 for (int window = 0; window < 4; ++window) {
6347 const double centrePhi = surf::kHalfPi * window + 0.3;
6348 BOOST_REQUIRE(solid->AddCylindricalSurface(
6349 {0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0., surf::kTwoPi, false,
6350 {quarterCircleBoundary(centrePhi, 0., 0.5, 0.), quarterCircleBoundary(centrePhi, 0., 0.5, surf::kHalfPi),
6351 quarterCircleBoundary(centrePhi, 0., 0.5, surf::kPi),
6352 quarterCircleBoundary(centrePhi, 0., 0.5, 3. * surf::kHalfPi)}));
6353 }
6354 BOOST_REQUIRE(addDiskSurface(*solid, {0., 0., 1.}, {1., 0., 0.}, {0., 1., 0.}, 2.));
6355 BOOST_REQUIRE(addDiskSurface(*solid, {0., 0., -1.}, {1., 0., 0.}, {0., -1., 0.}, 2.));
6356 solid->CloseShape();
6357 return solid;
6358}
6359
6360struct NearestPatchFixture {
6361 std::unique_ptr<SurfaceSolid> solid;
6362 double extent;
6363};
6364
6365std::vector<NearestPatchFixture> nearestPatchFixtures()
6366{
6367 std::vector<NearestPatchFixture> fixtures;
6368 fixtures.push_back({makeBoxSolid("safetyBox", 1., 2., 3.), 4.});
6369 fixtures.push_back({makeTubeSolid("safetyTube", 0., 2., 3.), 4.});
6370 fixtures.push_back({makeTubeSolid("safetyHollowTube", 1., 2., 3.), 4.});
6371 fixtures.push_back({makeConeSolid("safetyCone", 2., 1., 3.), 4.});
6372 fixtures.push_back({makeSphereSolid("safetySphere", 2.5), 3.5});
6373 fixtures.push_back({makeTorusSolid("safetyTorus", 3., 1.), 4.5});
6374 fixtures.push_back({makeCapsuleSolid("safetyCapsule", 1., 1.5), 3.});
6375 fixtures.push_back({makeManyPatchSolid("safetyRing", 12), 4.5});
6376 fixtures.push_back({makeWireTrimmedSolid("safetyWireTrim"), 3.});
6377 return fixtures;
6378}
6379} // namespace
6380
6396BOOST_AUTO_TEST_CASE(StreamS_PatchDistanceIsNeverBelowTheDistanceToItsOwnBoundingBox)
6397{
6398 using surf::Vec2;
6399 using surf::Vec3;
6400 std::string error;
6401
6403 const std::vector<Vec2> rectangle{{0., 0.}, {2., 0.}, {2., 3.}, {0., 3.}};
6404 BOOST_REQUIRE(polygon.initialize({0., 0., 0.}, {1., 0., 0.}, {0., 1., 0.}, rectangle, {}, error));
6406 BOOST_REQUIRE(disk.initialize({0., 0., 0.5}, {1., 0., 0.}, {0., 1., 0.},
6407 {surf::Curve2D::makeCircle({0., 0.}, 1.5)}, {}, error));
6408 // a B-spline trim wire on a second planar face: the trim family found to dominate the
6409 // per-patch cost, and the one whose distanceSqToPatch walks a flattened polyline rather than a
6410 // closed form -- so the lower-bound claim has to hold for an approximated boundary too
6412 BOOST_REQUIRE(splineFace.initialize({0.2, -0.3, 1.1}, {1., 0., 0.}, {0., 1., 0.},
6413 {quarterCircleBSpline(0., 0., 1.2, 0.),
6414 quarterCircleBSpline(0., 0., 1.2, surf::kHalfPi),
6415 quarterCircleBSpline(0., 0., 1.2, surf::kPi),
6416 quarterCircleBSpline(0., 0., 1.2, 3. * surf::kHalfPi)},
6417 {}, error));
6419 BOOST_REQUIRE(cylinder.initialize({0.1, -0.2, 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1., 0.3,
6420 1.7 * surf::kPi, false, error));
6422 BOOST_REQUIRE(cone.initialize({0., 0., 0.}, {0., 1., 0.}, {1., 0., 0.}, 2., 0.5, -1., 1., 0., 1.1 * surf::kPi,
6423 false, error));
6425 BOOST_REQUIRE(sphere.initialize({0.3, 0.4, -0.5}, {0., 0., 1.}, {1., 0., 0.}, 1.7, 0.2, 2.4, 0.,
6426 1.3 * surf::kPi, false, error));
6428 BOOST_REQUIRE(torus.initialize({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 3., 0.8, 0., 1.4 * surf::kPi, 0.,
6429 1.9 * surf::kPi, false, error));
6430
6431 const std::vector<const surf::BoundedSurface*> surfaces{&polygon, &disk, &splineFace,
6432 &cylinder, &cone, &sphere,
6433 &torus};
6434
6435 SampleStream stream(0xB0B0Dull);
6436 size_t checked = 0;
6437 for (const auto* surface : surfaces) {
6438 Vec3 lower{TGeoShape::Big(), TGeoShape::Big(), TGeoShape::Big()};
6439 Vec3 upper{-TGeoShape::Big(), -TGeoShape::Big(), -TGeoShape::Big()};
6440 surface->conservativeBounds(lower, upper);
6441 const double boxLower[3] = {lower.xCoord, lower.yCoord, lower.zCoord};
6442 const double boxUpper[3] = {upper.xCoord, upper.yCoord, upper.zCoord};
6443 // the box the BVH actually stores is this one inflated outward, which only lowers the bound
6444 for (int sample = 0; sample < 4000; ++sample) {
6445 const double scale = (sample % 4 == 3) ? 1.e6 : ((sample % 4 == 2) ? 20. : 5.);
6446 const Vec3 point{stream.symmetric(scale), stream.symmetric(scale), stream.symmetric(scale)};
6447 const double coordinates[3] = {point.xCoord, point.yCoord, point.zCoord};
6448 double boxDistanceSq = 0.;
6449 for (int dimension = 0; dimension < 3; ++dimension) {
6450 if (coordinates[dimension] < boxLower[dimension]) {
6451 const double gap = boxLower[dimension] - coordinates[dimension];
6452 boxDistanceSq += gap * gap;
6453 } else if (coordinates[dimension] > boxUpper[dimension]) {
6454 const double gap = coordinates[dimension] - boxUpper[dimension];
6455 boxDistanceSq += gap * gap;
6456 }
6457 }
6458 const double patchDistanceSq = surface->distanceSqToPatch(point);
6459 // the direction of this inequality is the whole safety argument; a tolerance would hide the
6460 // failure it exists to catch, so it is asserted with the same relative guard the traversal
6461 // itself applies (1e-12) and nothing more
6462 BOOST_REQUIRE_LE(boxDistanceSq * (1. - 1.e-12), patchDistanceSq);
6463 ++checked;
6464 }
6465 }
6466 BOOST_CHECK_EQUAL(checked, 7u * 4000u);
6467}
6468
6470BOOST_AUTO_TEST_CASE(StreamS_SafetyAndNormalAreIdenticalToTheAllSurfacesLoop)
6471{
6472 size_t comparedPoints = 0;
6473 double worstSafetyGap = -std::numeric_limits<double>::infinity();
6474 for (const auto& fixture : nearestPatchFixtures()) {
6475 BOOST_TEST_CONTEXT("fixture = " << fixture.solid->GetName())
6476 {
6477 BOOST_REQUIRE(fixture.solid->HasBVH());
6478 for (const auto& point : nearestPatchSample(*fixture.solid, fixture.extent, 2000)) {
6479 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
6480 {
6481 BOOST_REQUIRE_EQUAL(countNearestPatchDisagreements(*fixture.solid, point, &worstSafetyGap), 0);
6482 }
6483 ++comparedPoints;
6484 }
6485 }
6486 }
6487 BOOST_CHECK_EQUAL(comparedPoints, 9u * 2001u);
6488 // exact equality, so the gap is not merely non-positive but identically zero
6489 BOOST_CHECK_EQUAL(worstSafetyGap, 0.);
6490}
6491
6495BOOST_AUTO_TEST_CASE(StreamS_SafetyFallsBackBeforeCloseShapeAndOnAnEmptySolid)
6496{
6497 SurfaceSolid open("safetyBeforeClose");
6498 addBoxSurfaces(open, 1., 2., 3.);
6499 BOOST_REQUIRE(!open.HasBVH()); // CloseShape not called: no acceleration structure yet
6500 const std::array<double, 3> probe{0.3, -1.1, 2.2};
6501 BOOST_CHECK_EQUAL(open.Safety(probe.data(), kTRUE), open.Safety_Loop(probe.data(), kTRUE));
6502 std::array<double, 3> viaBVH{0., 0., 0.};
6503 std::array<double, 3> viaLoop{0., 0., 0.};
6504 open.ComputeNormal(probe.data(), nullptr, viaBVH.data());
6505 open.ComputeNormal_Loop(probe.data(), nullptr, viaLoop.data());
6506 BOOST_CHECK_EQUAL(viaBVH[0], viaLoop[0]);
6507 BOOST_CHECK_EQUAL(viaBVH[1], viaLoop[1]);
6508 BOOST_CHECK_EQUAL(viaBVH[2], viaLoop[2]);
6509
6510 SurfaceSolid empty("safetyEmpty");
6511 empty.CloseShape();
6512 BOOST_CHECK_EQUAL(empty.Safety(probe.data(), kTRUE), TGeoShape::Big());
6513 BOOST_CHECK_EQUAL(empty.Safety_Loop(probe.data(), kTRUE), TGeoShape::Big());
6514 empty.ComputeNormal(probe.data(), nullptr, viaBVH.data());
6515 empty.ComputeNormal_Loop(probe.data(), nullptr, viaLoop.data());
6516 BOOST_CHECK_EQUAL(viaBVH[0], viaLoop[0]);
6517}
6518
6528BOOST_AUTO_TEST_CASE(StreamS_ComputeNormalKeepsTheLowestIndexTieBreak)
6529{
6530 const auto box = makeBoxSolid("tieBreakBox", 2., 2., 2.);
6531 const std::array<double, 3> centre{0., 0., 0.};
6532 std::array<double, 3> viaBVH{0., 0., 0.};
6533 std::array<double, 3> viaLoop{0., 0., 0.};
6534 box->ComputeNormal(centre.data(), nullptr, viaBVH.data());
6535 box->ComputeNormal_Loop(centre.data(), nullptr, viaLoop.data());
6536 // all six faces are exactly 2 away, and the loop's strict `<` keeps the first of them
6537 BOOST_CHECK_EQUAL(box->Safety(centre.data(), kTRUE), box->Safety_Loop(centre.data(), kTRUE));
6538 BOOST_CHECK_EQUAL(viaBVH[0], viaLoop[0]);
6539 BOOST_CHECK_EQUAL(viaBVH[1], viaLoop[1]);
6540 BOOST_CHECK_EQUAL(viaBVH[2], viaLoop[2]);
6541 // ... and it is genuinely a tie, i.e. the case has something to protect
6542 BOOST_CHECK_EQUAL(std::abs(viaLoop[0]) + std::abs(viaLoop[1]) + std::abs(viaLoop[2]), 1.);
6543
6544 // the same on the axis of a hollow tube, where the inner wall and both caps compete
6545 const auto tube = makeTubeSolid("tieBreakTube", 1., 2., 1.);
6546 tube->ComputeNormal(centre.data(), nullptr, viaBVH.data());
6547 tube->ComputeNormal_Loop(centre.data(), nullptr, viaLoop.data());
6548 BOOST_CHECK_EQUAL(viaBVH[0], viaLoop[0]);
6549 BOOST_CHECK_EQUAL(viaBVH[1], viaLoop[1]);
6550 BOOST_CHECK_EQUAL(viaBVH[2], viaLoop[2]);
6551}
6552
6563BOOST_AUTO_TEST_CASE(StreamS_BreakingThePruningBoundIsCaught)
6564{
6565 BOOST_REQUIRE(!SurfaceSolid::GetSafetyBoundUnsoundForTest()); // sound by default
6566
6567 const auto fixtures = nearestPatchFixtures();
6568 size_t caughtOnFixtures = 0;
6569 size_t prunableFixtures = 0;
6570 size_t sabotagedDisagreements = 0;
6571 size_t safetyTooLarge = 0;
6572 size_t safetyTooSmall = 0;
6573 for (const auto& fixture : fixtures) {
6574 // With the sub-patch BVH every fixture here has something to prune: a multi-surface solid has
6575 // one leaf per cover box across its surfaces, and even a single full sphere or torus owns a
6576 // whole grid of cover-box leaves. (Before sub-patching, single-patch solids were a single
6577 // unprunable leaf and had to be excluded here; that blind spot is gone by construction.)
6578 ++prunableFixtures;
6579 const auto points = nearestPatchSample(*fixture.solid, fixture.extent, 400);
6580 size_t disagreementsHere = 0;
6581 SurfaceSolid::SetSafetyBoundUnsoundForTest(true);
6582 for (const auto& point : points) {
6583 const double sabotaged = fixture.solid->Safety(point.data(), kTRUE);
6584 SurfaceSolid::SetSafetyBoundUnsoundForTest(false);
6585 const double reference = fixture.solid->Safety_Loop(point.data(), kTRUE);
6586 SurfaceSolid::SetSafetyBoundUnsoundForTest(true);
6587 if (sabotaged != reference) {
6588 ++disagreementsHere;
6589 (sabotaged > reference) ? ++safetyTooLarge : ++safetyTooSmall;
6590 }
6591 disagreementsHere += static_cast<size_t>(countNearestPatchDisagreements(*fixture.solid, point));
6592 }
6593 SurfaceSolid::SetSafetyBoundUnsoundForTest(false);
6594 sabotagedDisagreements += disagreementsHere;
6595 if (disagreementsHere > 0) {
6596 ++caughtOnFixtures;
6597 BOOST_TEST_MESSAGE("sabotaged bound caught on " << fixture.solid->GetName() << ": " << disagreementsHere
6598 << " disagreements over " << points.size() << " points");
6599 } else {
6600 BOOST_TEST_MESSAGE("sabotaged bound NOT caught on " << fixture.solid->GetName() << " ("
6601 << fixture.solid->GetNsurfaces() << " patches)");
6602 }
6603 }
6604
6605 // every fixture is sensitive to the sabotage, not just one lucky one
6606 BOOST_CHECK_EQUAL(caughtOnFixtures, prunableFixtures);
6607 BOOST_CHECK_EQUAL(prunableFixtures, fixtures.size());
6608 BOOST_CHECK_GT(sabotagedDisagreements, 100u);
6609 // and it fails the dangerous way: too much safety, never too little
6610 BOOST_CHECK_GT(safetyTooLarge, 0u);
6611 BOOST_CHECK_EQUAL(safetyTooSmall, 0u);
6612
6613 // with the sabotage off again the same sample is clean, so the disagreements above are the
6614 // sabotage and not the fixtures
6615 for (const auto& fixture : fixtures) {
6616 for (const auto& point : nearestPatchSample(*fixture.solid, fixture.extent, 400)) {
6617 BOOST_REQUIRE_EQUAL(countNearestPatchDisagreements(*fixture.solid, point), 0);
6618 }
6619 }
6620 BOOST_CHECK(!SurfaceSolid::GetSafetyBoundUnsoundForTest());
6621}
6622
6627BOOST_AUTO_TEST_CASE(StreamS_SafetyVisitsFarFewerPatchesThanTheLoop)
6628{
6629 const auto ring = makeManyPatchSolid("candidateRing", 24); // 24 boxes, 144 patches
6630 BOOST_REQUIRE_EQUAL(ring->GetNsurfaces(), 144);
6631 const auto points = nearestPatchSample(*ring, 5., 500);
6632
6633 SurfaceSolid::ResetSafetyCandidateCounter();
6634 for (const auto& point : points) {
6635 ring->Safety(point.data(), kTRUE);
6636 }
6637 const long long acceleratedCandidates = SurfaceSolid::GetSafetyCandidateCount();
6638 const double perCall = static_cast<double>(acceleratedCandidates) / points.size();
6639
6640 BOOST_TEST_MESSAGE("Safety candidates per call: " << perCall << " of " << ring->GetNsurfaces() << " patches");
6641 BOOST_CHECK_GT(acceleratedCandidates, 0);
6642 BOOST_CHECK_LT(perCall, 0.4 * ring->GetNsurfaces());
6643
6644 // the counter is not touched by the loop twin, which visits everything by construction
6645 SurfaceSolid::ResetSafetyCandidateCounter();
6646 for (const auto& point : points) {
6647 ring->Safety_Loop(point.data(), kTRUE);
6648 std::array<double, 3> normal{0., 0., 0.};
6649 ring->ComputeNormal_Loop(point.data(), nullptr, normal.data());
6650 }
6651 BOOST_CHECK_EQUAL(SurfaceSolid::GetSafetyCandidateCount(), 0);
6652
6653 // ComputeNormal prunes too, only slightly less: it may not drop a node whose bound ties the
6654 // current best, because such a node can hold an equally near patch of lower index
6655 SurfaceSolid::ResetSafetyCandidateCounter();
6656 for (const auto& point : points) {
6657 std::array<double, 3> normal{0., 0., 0.};
6658 ring->ComputeNormal(point.data(), nullptr, normal.data());
6659 }
6660 const double normalPerCall = static_cast<double>(SurfaceSolid::GetSafetyCandidateCount()) / points.size();
6661 BOOST_TEST_MESSAGE("ComputeNormal candidates per call: " << normalPerCall);
6662 BOOST_CHECK_LT(normalPerCall, 0.4 * ring->GetNsurfaces());
6663 BOOST_CHECK_GE(normalPerCall, perCall);
6664}
6665
6675
6676namespace
6677{
6678using CoverBox = surf::BoundedSurface::CoverBox;
6679
6680// Squared distance from a point to an axis-aligned box, zero inside; double throughout, so the
6681// test's bound carries no float rounding of its own.
6682double coverBoxDistanceSq(const CoverBox& box, const surf::Vec3& point)
6683{
6684 double distanceSq = 0.;
6685 const double coordinates[3] = {point.xCoord, point.yCoord, point.zCoord};
6686 const double lower[3] = {box.first.xCoord, box.first.yCoord, box.first.zCoord};
6687 const double upper[3] = {box.second.xCoord, box.second.yCoord, box.second.zCoord};
6688 for (int dimension = 0; dimension < 3; ++dimension) {
6689 const double gap = std::max({lower[dimension] - coordinates[dimension],
6690 coordinates[dimension] - upper[dimension], 0.});
6691 distanceSq += gap * gap;
6692 }
6693 return distanceSq;
6694}
6695
6696double minCoverBoxDistanceSq(const std::vector<CoverBox>& boxes, const surf::Vec3& point)
6697{
6698 double best = std::numeric_limits<double>::infinity();
6699 for (const auto& box : boxes) {
6700 best = std::min(best, coverBoxDistanceSq(box, point));
6701 }
6702 return best;
6703}
6704
6705bool anyCoverBoxContains(const std::vector<CoverBox>& boxes, const surf::Vec3& point, double slack)
6706{
6707 for (const auto& box : boxes) {
6708 if (point.xCoord >= box.first.xCoord - slack && point.xCoord <= box.second.xCoord + slack &&
6709 point.yCoord >= box.first.yCoord - slack && point.yCoord <= box.second.yCoord + slack &&
6710 point.zCoord >= box.first.zCoord - slack && point.zCoord <= box.second.zCoord + slack) {
6711 return true;
6712 }
6713 }
6714 return false;
6715}
6716
6717// The two properties every surface's cover boxes owe the traversal, checked against the surface's
6718// own kernels: (lower bound) the nearest cover box is never farther than distanceSqToPatch, which
6719// is what makes pruning on a box distance sound for Safety; (coverage) every point of the trimmed
6720// patch lies in some box, which is what makes a ray traversal that skips the other boxes complete.
6721void checkCoverBoxProperties(const surf::BoundedSurface& surface, const std::vector<surf::Vec3>& patchPoints,
6722 const char* label)
6723{
6724 std::vector<CoverBox> boxes;
6725 surface.appendCoverBoxes(boxes);
6726 BOOST_TEST_CONTEXT("surface = " << label)
6727 {
6728 BOOST_REQUIRE(!boxes.empty());
6729 for (const auto& point : patchPoints) {
6730 BOOST_TEST_CONTEXT("patch point = (" << point.xCoord << ", " << point.yCoord << ", " << point.zCoord << ")")
6731 {
6732 BOOST_CHECK(anyCoverBoxContains(boxes, point, 1.e-9));
6733 }
6734 }
6735 SampleStream stream(0xC0FEB0C5ull);
6736 // near the patch, a few radii out, and far away, so the bound is exercised where the box and
6737 // the patch nearly coincide and where the whole surface is a speck
6738 constexpr double kProbeScales[3] = {1.5, 8., 300.};
6739 for (int index = 0; index < 400; ++index) {
6740 const double scale = kProbeScales[index % 3];
6741 const surf::Vec3 point{stream.symmetric(scale), stream.symmetric(scale), stream.symmetric(scale)};
6742 const double patchDistanceSq = surface.distanceSqToPatch(point);
6743 const double boxDistanceSq = minCoverBoxDistanceSq(boxes, point);
6744 BOOST_TEST_CONTEXT("point = (" << point.xCoord << ", " << point.yCoord << ", " << point.zCoord << ")")
6745 {
6746 BOOST_CHECK_LE(boxDistanceSq, patchDistanceSq * (1. + 1.e-9) + 1.e-18);
6747 }
6748 }
6749 }
6750}
6751
6752// A curved family must actually sub-patch: one conservative box would satisfy both properties
6753// above and tighten nothing, which is the state this whole stream exists to leave behind.
6754void checkEmitsSeveralCoverBoxes(const surf::BoundedSurface& surface, const char* label)
6755{
6756 std::vector<CoverBox> boxes;
6757 surface.appendCoverBoxes(boxes);
6758 BOOST_TEST_CONTEXT("surface = " << label)
6759 {
6760 BOOST_CHECK_GT(boxes.size(), 1u);
6761 }
6762}
6763} // namespace
6764
6769BOOST_AUTO_TEST_CASE(StreamX_CoverBoxesAreATightLowerBoundEnvelopePerFamily)
6770{
6771 using surf::Vec3;
6772 std::string error;
6773
6774 const Vec3 skewCenter{0.4, -0.2, 0.1};
6775 const Vec3 skewAxis{0.2, 0.3, 1.};
6776 const Vec3 referenceU{1., 0., 0.};
6777
6778 {
6780 BOOST_REQUIRE(cylinder.initialize(skewCenter, skewAxis, referenceU, 1.7, -0.8, 1.2, 0.4, 1.9, false, error));
6781 std::vector<Vec3> patchPoints;
6782 for (int stepPhi = 0; stepPhi <= 12; ++stepPhi) {
6783 for (int stepH = 0; stepH <= 4; ++stepH) {
6784 patchPoints.push_back(cylinder.pointAt(0.4 + 1.9 * stepPhi / 12., -0.8 + 2. * stepH / 4.));
6785 }
6786 }
6787 checkCoverBoxProperties(cylinder, patchPoints, "partial cylinder, skew axis");
6788 checkEmitsSeveralCoverBoxes(cylinder, "partial cylinder, skew axis");
6789 }
6790 {
6792 BOOST_REQUIRE(cylinder.initialize({0., 0., 0.}, {0., 0., 1.}, referenceU, 2., -1., 1., 0., surf::kTwoPi,
6793 false, error));
6794 std::vector<Vec3> patchPoints;
6795 for (int stepPhi = 0; stepPhi <= 24; ++stepPhi) {
6796 patchPoints.push_back(cylinder.pointAt(surf::kTwoPi * stepPhi / 24., -1. + 2. * (stepPhi % 5) / 4.));
6797 }
6798 checkCoverBoxProperties(cylinder, patchPoints, "full cylinder");
6799 checkEmitsSeveralCoverBoxes(cylinder, "full cylinder");
6800 }
6801 {
6803 BOOST_REQUIRE(cylinder.initialize({0., 0., 0.}, {0., 0., 1.}, referenceU, 2., -1., 1., 0., surf::kTwoPi, false,
6804 paramRectWireCurves(0.3, 2.1, -0.5, 0.7), {}, error));
6805 std::vector<Vec3> patchPoints;
6806 for (int stepPhi = 0; stepPhi <= 10; ++stepPhi) {
6807 for (int stepH = 0; stepH <= 4; ++stepH) {
6808 const double phi = 0.3 + 1.8 * stepPhi / 10.;
6809 const double height = -0.5 + 1.2 * stepH / 4.;
6810 if (cylinder.pointInTrim(phi, height)) {
6811 patchPoints.push_back(cylinder.pointAt(phi, height));
6812 }
6813 }
6814 }
6815 BOOST_REQUIRE(!patchPoints.empty());
6816 checkCoverBoxProperties(cylinder, patchPoints, "wire-trimmed cylinder");
6817 }
6818 {
6820 BOOST_REQUIRE(sphere.initialize(skewCenter, skewAxis, referenceU, 2.5, 0., surf::kPi, 0., surf::kTwoPi,
6821 false, error));
6822 std::vector<Vec3> patchPoints;
6823 for (int stepTheta = 0; stepTheta <= 8; ++stepTheta) {
6824 for (int stepPhi = 0; stepPhi < 16; ++stepPhi) {
6825 patchPoints.push_back(sphere.pointAt(surf::kPi * stepTheta / 8., surf::kTwoPi * stepPhi / 16.));
6826 }
6827 }
6828 checkCoverBoxProperties(sphere, patchPoints, "full sphere, skew frame");
6829 checkEmitsSeveralCoverBoxes(sphere, "full sphere, skew frame");
6830 }
6831 {
6832 // a polar cap: distanceSqToPatch realises on the *whole* sphere (radial projection), so the
6833 // cover boxes must still cover the full ball surface, not merely the cap
6835 BOOST_REQUIRE(cap.initialize({0., 0., 0.}, {0., 0., 1.}, referenceU, 2., 0., 0.6, 0.2, 1.1, false, error));
6836 std::vector<Vec3> patchPoints;
6837 for (int stepTheta = 0; stepTheta <= 4; ++stepTheta) {
6838 for (int stepPhi = 0; stepPhi <= 6; ++stepPhi) {
6839 patchPoints.push_back(cap.pointAt(0.6 * stepTheta / 4., 0.2 + 1.1 * stepPhi / 6.));
6840 }
6841 }
6842 checkCoverBoxProperties(cap, patchPoints, "spherical cap");
6843 }
6844 {
6846 BOOST_REQUIRE(cone.initialize(skewCenter, skewAxis, referenceU, 2., 0.5, -0.9, 1.1, 0.7, 2.3, false, error));
6847 std::vector<Vec3> patchPoints;
6848 for (int stepPhi = 0; stepPhi <= 10; ++stepPhi) {
6849 for (int stepH = 0; stepH <= 4; ++stepH) {
6850 patchPoints.push_back(cone.pointAt(0.7 + 2.3 * stepPhi / 10., -0.9 + 2. * stepH / 4.));
6851 }
6852 }
6853 checkCoverBoxProperties(cone, patchPoints, "partial cone, skew axis");
6854 checkEmitsSeveralCoverBoxes(cone, "partial cone, skew axis");
6855 }
6856 {
6858 BOOST_REQUIRE(torus.initialize(skewCenter, skewAxis, referenceU, 2.4, 0.7, 0.3, 2.1, -0.4, 1.7, false, error));
6859 std::vector<Vec3> patchPoints;
6860 for (int stepRing = 0; stepRing <= 10; ++stepRing) {
6861 for (int stepTube = 0; stepTube <= 6; ++stepTube) {
6862 patchPoints.push_back(torus.pointAt(0.3 + 2.1 * stepRing / 10., -0.4 + 1.7 * stepTube / 6.));
6863 }
6864 }
6865 checkCoverBoxProperties(torus, patchPoints, "partial torus, skew axis");
6866 checkEmitsSeveralCoverBoxes(torus, "partial torus, skew axis");
6867 }
6868 {
6870 BOOST_REQUIRE(torus.initialize({0., 0., 0.}, {0., 0., 1.}, referenceU, 3., 1., 0., surf::kTwoPi, 0.,
6871 surf::kTwoPi, false, error));
6872 std::vector<Vec3> patchPoints;
6873 for (int stepRing = 0; stepRing < 16; ++stepRing) {
6874 for (int stepTube = 0; stepTube < 8; ++stepTube) {
6875 patchPoints.push_back(torus.pointAt(surf::kTwoPi * stepRing / 16., surf::kTwoPi * stepTube / 8.));
6876 }
6877 }
6878 checkCoverBoxProperties(torus, patchPoints, "full torus");
6879 }
6880 {
6882 const std::vector<surf::Vec2> rectangle{{0., 0.}, {2., 0.}, {2., 3.}, {0., 3.}};
6883 BOOST_REQUIRE(polygon.initialize({0.2, -0.4, 0.5}, {1., 0.2, 0.}, {-0.1, 1., 0.3}, rectangle, {}, error));
6884 std::vector<Vec3> patchPoints;
6885 patchPoints.push_back(polygon.toGlobal({0.01, 0.01}));
6886 patchPoints.push_back(polygon.toGlobal({1.9, 2.9}));
6887 checkCoverBoxProperties(polygon, patchPoints, "planar polygon");
6888 }
6889}
6890
6895BOOST_AUTO_TEST_CASE(StreamX_RaysThroughEmptyBoxRegionsReachNoPatch)
6896{
6897 // corner of the ball box, well outside the sphere: rho = |(2.2, 2.2)| = 3.11 > 2.5
6898 const auto sphere = makeSphereSolid("subBoxSphere", 2.5);
6899 BOOST_CHECK_EQUAL(sphere->CountBVHRayCandidates({2.2, 2.2, -5.}, {0., 0., 1.}), 0);
6900 BOOST_CHECK_GE(sphere->CountBVHRayCandidates({0., 0., -5.}, {0., 0., 1.}), 1);
6901
6902 // along the axis of a solid tube: the barrel patch cannot be hit, only the two caps can
6903 const auto tube = makeTubeSolid("subBoxTube", 0., 2., 1.);
6904 BOOST_CHECK_EQUAL(tube->CountBVHRayCandidates({0., 0., -5.}, {0., 0., 1.}), 2);
6905
6906 // corner of the torus box, outside the outer equator: rho = |(3.4, 3.4)| = 4.8 > R + r = 4
6907 const auto torus = makeTorusSolid("subBoxTorus", 3., 1.);
6908 BOOST_CHECK_EQUAL(torus->CountBVHRayCandidates({3.4, 3.4, -5.}, {0., 0., 1.}), 0);
6909 BOOST_CHECK_GE(torus->CountBVHRayCandidates({3., 0., -5.}, {0., 0., 1.}), 1);
6910
6911 // behind the back of a quarter cylinder: the full rim circles' box is crossed, the sweep band
6912 // is nowhere near. Not closed (a bare patch), which the BVH does not require.
6913 SurfaceSolid quarter("subBoxQuarterCylinder");
6914 BOOST_REQUIRE(quarter.AddCylindricalSurface({0., 0., 0.}, {0., 0., 1.}, {1., 0., 0.}, 2., -1., 1.,
6915 -surf::kPi / 4., surf::kHalfPi));
6916 quarter.CloseShape(false);
6917 BOOST_REQUIRE(quarter.HasBVH());
6918 BOOST_CHECK_EQUAL(quarter.CountBVHRayCandidates({-1.9, -5., 0.}, {0., 1., 0.}), 0);
6919 BOOST_CHECK_GE(quarter.CountBVHRayCandidates({5., 0., 0.}, {-1., 0., 0.}), 1);
6920}
6921
6927BOOST_AUTO_TEST_CASE(StreamX_CurvedFixturesStayIdenticalToTheLoop)
6928{
6929 struct Fixture {
6930 std::unique_ptr<SurfaceSolid> solid;
6931 double extent;
6932 };
6933 std::vector<Fixture> fixtures;
6934 fixtures.push_back({makeSphereSolid("subBoxSweepSphere", 2.5), 3.5});
6935 fixtures.push_back({makeTorusSolid("subBoxSweepTorus", 3., 1.), 4.5});
6936 fixtures.push_back({makeCapsuleSolid("subBoxSweepCapsule", 1., 1.5), 3.});
6937 fixtures.push_back({makeConeSolid("subBoxSweepCone", 2., 1., 3.), 4.});
6938 fixtures.push_back({makeWireTrimmedSolid("subBoxSweepWireTrim"), 3.});
6939
6940 for (const auto& fixture : fixtures) {
6941 BOOST_TEST_CONTEXT("fixture = " << fixture.solid->GetName())
6942 {
6943 sweepDistanceAgainstLoop(*fixture.solid, fixture.extent, 4);
6944 for (const auto& point : probeGrid(fixture.extent, 4)) {
6945 BOOST_TEST_CONTEXT("point = (" << point[0] << ", " << point[1] << ", " << point[2] << ")")
6946 {
6947 BOOST_CHECK_EQUAL(fixture.solid->Contains(point.data()), fixture.solid->Contains_Loop(point.data()));
6948 std::vector<SurfaceSolid::ContainsCrossing> bvhCrossings;
6949 std::vector<SurfaceSolid::ContainsCrossing> loopCrossings;
6950 fixture.solid->DescribeContainsCrossings({point[0], point[1], point[2]}, bvhCrossings, loopCrossings);
6951 BOOST_REQUIRE_EQUAL(bvhCrossings.size(), loopCrossings.size());
6952 for (size_t index = 0; index < bvhCrossings.size(); ++index) {
6953 BOOST_CHECK_EQUAL(bvhCrossings[index].distance, loopCrossings[index].distance);
6954 }
6955 }
6956 }
6957 }
6958 }
6959}
6960
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
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...
The X-ray transport benchmark's algorithms: stepping, auditing and comparing ordered crossing lists.
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 parametricMetric(const Vec2 &uv, double &gUU, double &gUV, double &gVV) const =0
The first fundamental form at uv, turning parametric displacements into 3D lengths; it varies over th...
virtual void appendCoverBoxes(std::vector< CoverBox > &boxes) const
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).
void conservativeBounds(Vec3 &lower, Vec3 &upper) const override
Accumulate a conservative axis-aligned bounding box of the trimmed patch.
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
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
GLenum target
Definition glcorearb.h:1641
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
GLuint start
Definition glcorearb.h:469
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...
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
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)
int32_t const char * file
int const float const TrackSeed< NLayers > & seed
const bool const bool const int FollowDirection BestTrial TrackITSInternal< NLayers > & best
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