KiCad PCB EDA Suite
Loading...
Searching...
No Matches
test_triangulation_benchmark.cpp
Go to the documentation of this file.
1/*
2 * This program is part of KiCad, a free EDA CAD application.
3 * Copyright The KiCad Developers, see AUTHORS.txt for contributors.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
20
23#include <core/profile.h>
24
25#include "geom_test_utils.h"
26#include <nlohmann/json.hpp>
27
28#include <algorithm>
29#include <cmath>
30#include <cstdlib>
31#include <filesystem>
32#include <fstream>
33#include <map>
34#include <numeric>
35#include <sstream>
36#include <string>
37#include <vector>
38
39namespace fs = std::filesystem;
40
41namespace
42{
43
44struct ZONE_ENTRY
45{
46 std::string layer;
47 std::string net;
48 int outlineCount = 0;
49 int vertexCount = 0;
50 SHAPE_POLY_SET polySet;
51};
52
53
54struct BOARD_ENTRY
55{
56 std::string source;
57 std::vector<ZONE_ENTRY> zones;
58};
59
60
61struct ZONE_STATS
62{
63 std::string layer;
64 std::string net;
65 int outlineCount = 0;
66 int vertexCount = 0;
67 int triangleCount = 0;
68 int64_t timeUs = 0;
69 double originalArea = 0.0;
70 double triangulatedArea = 0.0;
71 double areaCoverage = 0.0;
72 double meanTriArea = 0.0;
73 double stddevTriArea = 0.0;
74 int spikeyTriangles = 0;
75 double spikeyRatio = 0.0;
76
77 // Regularity metrics. minAngle percentiles describe the tail of thin triangles; the
78 // worst 1% (p1) is the sliver signal, p50 the typical shape. Angles are degrees.
79 double minAnglePctl1 = 0.0;
80 double minAnglePctl5 = 0.0;
81 double minAnglePctl50 = 0.0;
82 int trisBelow5Deg = 0;
83 int trisBelow10Deg = 0;
84 int trisBelow15Deg = 0;
85 double meanRadiusRatio = 0.0;
86
87 // Per-triangle minimum interior angles, retained so the global run can aggregate
88 // corpus-wide percentiles rather than averaging per-zone summaries.
89 std::vector<double> minAngles;
90};
91
92
93struct BASELINE_ZONE
94{
95 std::string layer;
96 std::string net;
97 int triangleCount = 0;
98 double areaCoverage = 0.0;
99 double spikeyRatio = 0.0;
100 double stddevTriArea = 0.0;
101 int spikeyTriangles = 0;
102 double originalArea = 0.0;
103 double minAnglePctl1 = 0.0;
104 double minAnglePctl5 = 0.0;
105 int trisBelow10Deg = 0;
106 int64_t timeUs = 0;
107};
108
109
110struct BASELINE_BOARD
111{
112 std::vector<BASELINE_ZONE> zones;
113};
114
115
116struct BASELINE_DATA
117{
118 std::map<std::string, BASELINE_BOARD> boards;
119 int totalTriangles = 0;
120 int totalSpikeyTri = 0;
121 double spikeyRatio = 0.0;
122 int zoneCount = 0;
123 int boardCount = 0;
124 bool valid = false;
125};
126
127
128enum class CHANGE_TYPE
129{
130 BREAKING,
131 REGRESSION,
132 IMPROVEMENT,
134};
135
136
137struct ZONE_COMPARISON
138{
139 CHANGE_TYPE type = CHANGE_TYPE::UNCHANGED;
140 std::string source;
141 std::string layer;
142 std::string net;
143
144 int baseTriangles = 0;
145 int curTriangles = 0;
146 double baseSpikeyRatio = 0.0;
147 double curSpikeyRatio = 0.0;
148 double baseStddev = 0.0;
149 double curStddev = 0.0;
150 double baseCoverage = 0.0;
151 double curCoverage = 0.0;
152 double baseMinAngleP5 = 0.0;
153 double curMinAngleP5 = 0.0;
154 int baseBelow10Deg = 0;
155 int curBelow10Deg = 0;
156 int64_t baseTimeUs = 0;
157 int64_t curTimeUs = 0;
158
159 double spikeyDeltaPp() const { return ( curSpikeyRatio - baseSpikeyRatio ) * 100.0; }
160
161 // Positive means the current run's worst-tail triangles opened up (more regular).
162 double minAngleP5DeltaDeg() const { return curMinAngleP5 - baseMinAngleP5; }
163
164 double triangleDeltaPct() const
165 {
166 if( baseTriangles == 0 )
167 return curTriangles == 0 ? 0.0 : 100.0;
168
169 return ( curTriangles - baseTriangles ) / static_cast<double>( baseTriangles ) * 100.0;
170 }
171
172 double stddevDeltaPct() const
173 {
174 if( baseStddev == 0.0 )
175 return curStddev == 0.0 ? 0.0 : 100.0;
176
177 return ( curStddev - baseStddev ) / baseStddev * 100.0;
178 }
179
180 double timeDeltaPct() const
181 {
182 if( baseTimeUs == 0 )
183 return curTimeUs == 0 ? 0.0 : 100.0;
184
185 return static_cast<double>( curTimeUs - baseTimeUs ) / static_cast<double>( baseTimeUs )
186 * 100.0;
187 }
188};
189
190
191BASELINE_DATA LoadBaseline( const fs::path& aJsonPath )
192{
193 BASELINE_DATA baseline;
194
195 if( !fs::exists( aJsonPath ) )
196 return baseline;
197
198 std::ifstream file( aJsonPath );
199
200 if( !file.is_open() )
201 return baseline;
202
203 nlohmann::json j;
204
205 try
206 {
207 j = nlohmann::json::parse( file );
208 }
209 catch( const nlohmann::json::exception& )
210 {
211 return baseline;
212 }
213
214 if( j.contains( "metadata" ) )
215 {
216 baseline.boardCount = j["metadata"].value( "board_count", 0 );
217 baseline.zoneCount = j["metadata"].value( "zone_count", 0 );
218 }
219
220 if( j.contains( "global" ) )
221 {
222 baseline.totalTriangles = j["global"].value( "total_triangles", 0 );
223 baseline.totalSpikeyTri = j["global"].value( "total_spikey_triangles", 0 );
224 baseline.spikeyRatio = j["global"].value( "spikey_ratio", 0.0 );
225 }
226
227 if( j.contains( "boards" ) )
228 {
229 for( const auto& boardJson : j["boards"] )
230 {
231 std::string source = boardJson.value( "source", "" );
232 BASELINE_BOARD board;
233
234 if( boardJson.contains( "zones" ) )
235 {
236 for( const auto& zoneJson : boardJson["zones"] )
237 {
238 BASELINE_ZONE zone;
239 zone.layer = zoneJson.value( "layer", "" );
240 zone.net = zoneJson.value( "net", "" );
241 zone.triangleCount = zoneJson.value( "triangle_count", 0 );
242 zone.areaCoverage = zoneJson.value( "area_coverage", 0.0 );
243 zone.spikeyRatio = zoneJson.value( "spikey_ratio", 0.0 );
244 zone.stddevTriArea = zoneJson.value( "stddev_triangle_area_nm2", 0.0 );
245 zone.spikeyTriangles = zoneJson.value( "spikey_triangles", 0 );
246 zone.originalArea = zoneJson.value( "original_area_nm2", 0.0 );
247 zone.minAnglePctl1 = zoneJson.value( "min_angle_p1_deg", 0.0 );
248 zone.minAnglePctl5 = zoneJson.value( "min_angle_p5_deg", 0.0 );
249 zone.trisBelow10Deg = zoneJson.value( "tris_below_10deg", 0 );
250 zone.timeUs = zoneJson.value( "time_us", (int64_t) 0 );
251 board.zones.push_back( zone );
252 }
253 }
254
255 baseline.boards[source] = std::move( board );
256 }
257 }
258
259 baseline.valid = true;
260 return baseline;
261}
262
263
264bool ParsePolyFile( const fs::path& aPath, BOARD_ENTRY& aBoard )
265{
266 std::ifstream file( aPath );
267
268 if( !file.is_open() )
269 return false;
270
271 std::string content( ( std::istreambuf_iterator<char>( file ) ),
272 std::istreambuf_iterator<char>() );
273
274 size_t srcStart = content.find( "(source \"" );
275
276 if( srcStart != std::string::npos )
277 {
278 srcStart += 9;
279 size_t srcEnd = content.find( "\")", srcStart );
280
281 if( srcEnd != std::string::npos )
282 aBoard.source = content.substr( srcStart, srcEnd - srcStart );
283 }
284
285 size_t zonePos = 0;
286
287 while( ( zonePos = content.find( "(zone (layer \"", zonePos ) ) != std::string::npos )
288 {
289 ZONE_ENTRY entry;
290
291 size_t layerStart = zonePos + 14;
292 size_t layerEnd = content.find( "\")", layerStart );
293 entry.layer = content.substr( layerStart, layerEnd - layerStart );
294
295 size_t netStart = content.find( "(net \"", layerEnd );
296
297 if( netStart != std::string::npos )
298 {
299 netStart += 6;
300 size_t netEnd = content.find( "\")", netStart );
301 entry.net = content.substr( netStart, netEnd - netStart );
302 }
303
304 size_t ocStart = content.find( "(outline_count ", layerEnd );
305
306 if( ocStart != std::string::npos )
307 {
308 ocStart += 15;
309 entry.outlineCount = std::stoi( content.substr( ocStart ) );
310 }
311
312 size_t vcStart = content.find( "(vertex_count ", layerEnd );
313
314 if( vcStart != std::string::npos )
315 {
316 vcStart += 14;
317 entry.vertexCount = std::stoi( content.substr( vcStart ) );
318 }
319
320 size_t polysetStart = content.find( "polyset ", zonePos );
321
322 if( polysetStart != std::string::npos )
323 {
324 std::string remainder = content.substr( polysetStart );
325 std::stringstream ss( remainder );
326
327 if( entry.polySet.Parse( ss ) )
328 aBoard.zones.push_back( std::move( entry ) );
329 }
330
331 zonePos = layerEnd + 1;
332 }
333
334 return !aBoard.zones.empty();
335}
336
337
338// Number of cold re-triangulations timed per zone; the median rejects scheduler jitter.
339constexpr int TIMING_ITERATIONS = 5;
340
341
342// Radius ratio 2r/R normalized to [0,1]; 1.0 is equilateral, 0.0 is degenerate.
343double TriangleRadiusRatio( const VECTOR2I& a, const VECTOR2I& b, const VECTOR2I& c )
344{
345 double ab = a.Distance( b );
346 double bc = b.Distance( c );
347 double ca = c.Distance( a );
348
349 double s = ( ab + bc + ca ) / 2.0;
350
351 if( s <= 0.0 || ab <= 0.0 || bc <= 0.0 || ca <= 0.0 )
352 return 0.0;
353
354 double area = std::sqrt( std::max( 0.0, s * ( s - ab ) * ( s - bc ) * ( s - ca ) ) );
355
356 return 8.0 * area * area / ( s * ab * bc * ca );
357}
358
359
360// Linear-interpolated percentile over a pre-sorted ascending vector.
361double Percentile( const std::vector<double>& aSorted, double aPct )
362{
363 if( aSorted.empty() )
364 return 0.0;
365
366 if( aSorted.size() == 1 )
367 return aSorted.front();
368
369 double rank = aPct / 100.0 * static_cast<double>( aSorted.size() - 1 );
370 size_t lo = static_cast<size_t>( std::floor( rank ) );
371 size_t hi = static_cast<size_t>( std::ceil( rank ) );
372 double frac = rank - static_cast<double>( lo );
373
374 return aSorted[lo] + frac * ( aSorted[hi] - aSorted[lo] );
375}
376
377
378ZONE_STATS ComputeZoneStats( ZONE_ENTRY& aZone )
379{
380 ZONE_STATS stats;
381 stats.layer = aZone.layer;
382 stats.net = aZone.net;
383 stats.outlineCount = aZone.outlineCount;
384 stats.vertexCount = aZone.vertexCount;
385 stats.originalArea = aZone.polySet.Area();
386
387 // Cold-cache timing: aZone.polySet is not triangulated until after this loop, so each copy
388 // starts with an invalid cache and the timer captures a full re-triangulation, never a cache
389 // hit. Report the median across iterations to reject scheduler jitter.
390 std::vector<int64_t> timings;
391
392 for( int iter = 0; iter < TIMING_ITERATIONS; iter++ )
393 {
394 SHAPE_POLY_SET cold( aZone.polySet );
395
396 PROF_TIMER timer;
397 cold.CacheTriangulation();
398 timer.Stop();
399 timings.push_back( static_cast<int64_t>( timer.msecs() * 1000.0 ) );
400 }
401
402 std::sort( timings.begin(), timings.end() );
403 stats.timeUs = timings[timings.size() / 2];
404
405 // Populate the zone's own cache once for the geometry walk below.
406 aZone.polySet.CacheTriangulation();
407
408 std::vector<double> triAreas;
409
410 for( unsigned int i = 0; i < aZone.polySet.TriangulatedPolyCount(); i++ )
411 {
412 const auto* triPoly = aZone.polySet.TriangulatedPolygon( static_cast<int>( i ) );
413
414 for( const auto& tri : triPoly->Triangles() )
415 triAreas.push_back( tri.Area() );
416 }
417
418 stats.triangleCount = static_cast<int>( triAreas.size() );
419 stats.triangulatedArea = std::accumulate( triAreas.begin(), triAreas.end(), 0.0 );
420
421 if( stats.originalArea > 0.0 )
422 stats.areaCoverage = stats.triangulatedArea / stats.originalArea;
423
424 if( !triAreas.empty() )
425 {
426 stats.meanTriArea = stats.triangulatedArea / static_cast<double>( triAreas.size() );
427
428 double sumSqDiff = 0.0;
429
430 for( double a : triAreas )
431 {
432 double diff = a - stats.meanTriArea;
433 sumSqDiff += diff * diff;
434 }
435
436 stats.stddevTriArea = std::sqrt( sumSqDiff / static_cast<double>( triAreas.size() ) );
437 }
438
439 double radiusRatioSum = 0.0;
440
441 for( unsigned int i = 0; i < aZone.polySet.TriangulatedPolyCount(); i++ )
442 {
443 const auto* triPoly = aZone.polySet.TriangulatedPolygon( static_cast<int>( i ) );
444
445 for( const auto& tri : triPoly->Triangles() )
446 {
447 VECTOR2I pa = tri.GetPoint( 0 );
448 VECTOR2I pb = tri.GetPoint( 1 );
449 VECTOR2I pc = tri.GetPoint( 2 );
450
451 if( KIGEOM::IsSliverTriangle( pa, pb, pc ) )
452 stats.spikeyTriangles++;
453
454 double minAngle = GEOM_TEST::TriangleMinAngleDeg( pa, pb, pc );
455 stats.minAngles.push_back( minAngle );
456
457 if( minAngle < 5.0 )
458 stats.trisBelow5Deg++;
459
460 if( minAngle < 10.0 )
461 stats.trisBelow10Deg++;
462
463 if( minAngle < 15.0 )
464 stats.trisBelow15Deg++;
465
466 radiusRatioSum += TriangleRadiusRatio( pa, pb, pc );
467 }
468 }
469
470 if( stats.triangleCount > 0 )
471 {
472 stats.spikeyRatio = static_cast<double>( stats.spikeyTriangles ) / stats.triangleCount;
473 stats.meanRadiusRatio = radiusRatioSum / stats.triangleCount;
474
475 std::vector<double> sorted = stats.minAngles;
476 std::sort( sorted.begin(), sorted.end() );
477 stats.minAnglePctl1 = Percentile( sorted, 1.0 );
478 stats.minAnglePctl5 = Percentile( sorted, 5.0 );
479 stats.minAnglePctl50 = Percentile( sorted, 50.0 );
480 }
481
482 return stats;
483}
484
485
486nlohmann::json ZoneStatsToJson( const ZONE_STATS& aStats )
487{
488 nlohmann::json j;
489 j["layer"] = aStats.layer;
490 j["net"] = aStats.net;
491 j["outline_count"] = aStats.outlineCount;
492 j["vertex_count"] = aStats.vertexCount;
493 j["triangle_count"] = aStats.triangleCount;
494 j["time_us"] = aStats.timeUs;
495 j["original_area_nm2"] = aStats.originalArea;
496 j["triangulated_area_nm2"] = aStats.triangulatedArea;
497 j["area_coverage"] = aStats.areaCoverage;
498 j["mean_triangle_area_nm2"] = aStats.meanTriArea;
499 j["stddev_triangle_area_nm2"] = aStats.stddevTriArea;
500 j["spikey_triangles"] = aStats.spikeyTriangles;
501 j["spikey_ratio"] = aStats.spikeyRatio;
502 j["min_angle_p1_deg"] = aStats.minAnglePctl1;
503 j["min_angle_p5_deg"] = aStats.minAnglePctl5;
504 j["min_angle_p50_deg"] = aStats.minAnglePctl50;
505 j["tris_below_5deg"] = aStats.trisBelow5Deg;
506 j["tris_below_10deg"] = aStats.trisBelow10Deg;
507 j["tris_below_15deg"] = aStats.trisBelow15Deg;
508 j["mean_radius_ratio"] = aStats.meanRadiusRatio;
509 return j;
510}
511
512
513ZONE_COMPARISON CompareZone( const std::string& aSource, const ZONE_STATS& aCurrent,
514 const BASELINE_ZONE* aBaseline )
515{
516 ZONE_COMPARISON cmp;
517 cmp.source = aSource;
518 cmp.layer = aCurrent.layer;
519 cmp.net = aCurrent.net;
520 cmp.curTriangles = aCurrent.triangleCount;
521 cmp.curSpikeyRatio = aCurrent.spikeyRatio;
522 cmp.curStddev = aCurrent.stddevTriArea;
523 cmp.curCoverage = aCurrent.areaCoverage;
524 cmp.curMinAngleP5 = aCurrent.minAnglePctl5;
525 cmp.curBelow10Deg = aCurrent.trisBelow10Deg;
526 cmp.curTimeUs = aCurrent.timeUs;
527
528 if( !aBaseline )
529 {
530 cmp.type = CHANGE_TYPE::UNCHANGED;
531 return cmp;
532 }
533
534 cmp.baseTriangles = aBaseline->triangleCount;
535 cmp.baseSpikeyRatio = aBaseline->spikeyRatio;
536 cmp.baseStddev = aBaseline->stddevTriArea;
537 cmp.baseCoverage = aBaseline->areaCoverage;
538 cmp.baseMinAngleP5 = aBaseline->minAnglePctl5;
539 cmp.baseBelow10Deg = aBaseline->trisBelow10Deg;
540 cmp.baseTimeUs = aBaseline->timeUs;
541
542 bool coverageBroke = aCurrent.originalArea > 0.0
543 && ( aCurrent.areaCoverage < 0.99 || aCurrent.areaCoverage > 1.01 );
544 bool newFailure = aCurrent.triangleCount == 0 && aBaseline->triangleCount > 0
545 && aCurrent.originalArea > 0.0;
546
547 if( coverageBroke || newFailure )
548 {
549 cmp.type = CHANGE_TYPE::BREAKING;
550 return cmp;
551 }
552
553 // Lexicographic classification honoring the project priority speed > regularity > count.
554 // The first axis that moves beyond its noise threshold decides the verdict; lower-priority
555 // axes are only consulted when every higher-priority axis is unchanged. This prevents a
556 // regularity or count win from masking a speed regression.
557 struct AXIS
558 {
559 double delta; // signed change, current minus baseline
560 double threshold; // magnitude below which the axis counts as unchanged
561 bool lowerBetter;
562 };
563
564 // Regularity blends p5 min-angle (want higher) and sub-10 degree count (want lower) into a
565 // single signed score where positive is worse, matching the lowerBetter axes.
566 double regressScore = ( cmp.baseMinAngleP5 - cmp.curMinAngleP5 )
567 + ( cmp.curBelow10Deg - cmp.baseBelow10Deg )
568 / std::max( 1.0, static_cast<double>( cmp.baseTriangles ) )
569 * 100.0;
570
571 // Per-zone timing under ~100 us is dominated by scheduler jitter, so a percent delta there is
572 // meaningless; below the floor the speed axis is neutralized and the verdict falls through to
573 // regularity then count. Aggregate speed is judged corpus-wide (total time), not per zone.
574 constexpr int64_t SPEED_FLOOR_US = 100;
575 bool speedMeasurable = std::max( cmp.baseTimeUs, cmp.curTimeUs ) >= SPEED_FLOOR_US;
576
577 std::vector<AXIS> axes = {
578 { speedMeasurable ? cmp.timeDeltaPct() : 0.0, 15.0, true }, // speed (slower is worse)
579 { regressScore, 1.0, true }, // regularity (positive worse)
580 { cmp.triangleDeltaPct(), 5.0, true }, // count (more tris is worse)
581 };
582
583 cmp.type = CHANGE_TYPE::UNCHANGED;
584
585 for( const AXIS& axis : axes )
586 {
587 double signedWorse = axis.lowerBetter ? axis.delta : -axis.delta;
588
589 if( signedWorse > axis.threshold )
590 {
591 cmp.type = CHANGE_TYPE::REGRESSION;
592 break;
593 }
594
595 if( signedWorse < -axis.threshold )
596 {
597 cmp.type = CHANGE_TYPE::IMPROVEMENT;
598 break;
599 }
600 }
601
602 return cmp;
603}
604
605
606std::string FormatSign( double aValue, const std::string& aSuffix )
607{
608 std::ostringstream ss;
609 ss << std::fixed << std::setprecision( 1 );
610
611 if( aValue > 0.0 )
612 ss << "+";
613
614 ss << aValue << aSuffix;
615 return ss.str();
616}
617
618
619std::string FormatZoneDetail( const ZONE_COMPARISON& aCmp )
620{
621 std::ostringstream ss;
622 ss << " " << aCmp.source << " " << aCmp.layer << " \"" << aCmp.net << "\"" << "\n";
623 ss << std::fixed << std::setprecision( 1 );
624 ss << " time: " << FormatSign( aCmp.timeDeltaPct(), "%" );
625 ss << " minAngleP5: " << aCmp.baseMinAngleP5 << " -> " << aCmp.curMinAngleP5
626 << " deg (" << FormatSign( aCmp.minAngleP5DeltaDeg(), "deg" ) << ")";
627 ss << " <10deg: " << aCmp.baseBelow10Deg << " -> " << aCmp.curBelow10Deg;
628 ss << "\n spikey: " << ( aCmp.baseSpikeyRatio * 100.0 ) << "% -> "
629 << ( aCmp.curSpikeyRatio * 100.0 ) << "% (" << FormatSign( aCmp.spikeyDeltaPp(), "pp" )
630 << ")";
631 ss << " triangles: " << aCmp.baseTriangles << " -> " << aCmp.curTriangles
632 << " (" << FormatSign( aCmp.triangleDeltaPct(), "%" ) << ")";
633
634 if( aCmp.baseStddev > 0.0 || aCmp.curStddev > 0.0 )
635 {
636 ss << " stddev: " << FormatSign( aCmp.stddevDeltaPct(), "%" );
637 }
638
639 return ss.str();
640}
641
642
643void OutputComparisonReport( const BASELINE_DATA& aBaseline,
644 const std::vector<ZONE_COMPARISON>& aComparisons,
645 int aTotalTriangles, int aTotalSpikeyTri, int aTotalZones )
646{
647 std::vector<ZONE_COMPARISON> breaking;
648 std::vector<ZONE_COMPARISON> regressions;
649 std::vector<ZONE_COMPARISON> improvements;
650 int unchanged = 0;
651
652 for( const auto& cmp : aComparisons )
653 {
654 switch( cmp.type )
655 {
656 case CHANGE_TYPE::BREAKING: breaking.push_back( cmp ); break;
657 case CHANGE_TYPE::REGRESSION: regressions.push_back( cmp ); break;
658 case CHANGE_TYPE::IMPROVEMENT: improvements.push_back( cmp ); break;
659 case CHANGE_TYPE::UNCHANGED: unchanged++; break;
660 }
661 }
662
663 std::sort( improvements.begin(), improvements.end(),
664 []( const ZONE_COMPARISON& a, const ZONE_COMPARISON& b )
665 {
666 return a.spikeyDeltaPp() < b.spikeyDeltaPp();
667 } );
668
669 std::sort( regressions.begin(), regressions.end(),
670 []( const ZONE_COMPARISON& a, const ZONE_COMPARISON& b )
671 {
672 return a.spikeyDeltaPp() > b.spikeyDeltaPp();
673 } );
674
675 std::ostringstream report;
676 report << std::fixed << std::setprecision( 1 );
677
678 report << "\n=== Triangulation Comparison vs Baseline ===\n\n";
679
680 report << "Baseline: " << aBaseline.boardCount << " boards, "
681 << aBaseline.zoneCount << " zones\n";
682 report << "Current: " << aTotalZones << " zones\n\n";
683
684 double baseSpikey = aBaseline.spikeyRatio * 100.0;
685 double curSpikey = aTotalTriangles > 0
686 ? static_cast<double>( aTotalSpikeyTri ) / aTotalTriangles * 100.0
687 : 0.0;
688
689 report << "Global:\n";
690 report << " Triangles: " << aBaseline.totalTriangles << " -> " << aTotalTriangles
691 << " (" << FormatSign(
692 aTotalTriangles - aBaseline.totalTriangles == 0
693 ? 0.0
694 : ( aTotalTriangles - aBaseline.totalTriangles )
695 / static_cast<double>( aBaseline.totalTriangles )
696 * 100.0,
697 "%" )
698 << ")\n";
699 report << " Spikey: " << baseSpikey << "% -> " << curSpikey << "% ("
700 << FormatSign( curSpikey - baseSpikey, "pp" ) << ")\n";
701 report << " Spikey ct: " << aBaseline.totalSpikeyTri << " -> " << aTotalSpikeyTri
702 << "\n\n";
703
704 report << "BREAKING: " << breaking.size() << " zones\n";
705
706 for( const auto& cmp : breaking )
707 report << FormatZoneDetail( cmp ) << "\n";
708
709 if( breaking.empty() )
710 report << " (none)\n";
711
712 report << "\nREGRESSIONS: " << regressions.size() << " zones"
713 << " (lexicographic: speed >+5%, else regularity worse, else triangles >+5%)\n";
714
715 int shown = 0;
716
717 for( const auto& cmp : regressions )
718 {
719 if( shown >= 20 )
720 {
721 report << " ... and " << ( regressions.size() - 20 ) << " more\n";
722 break;
723 }
724
725 report << FormatZoneDetail( cmp ) << "\n";
726 shown++;
727 }
728
729 if( regressions.empty() )
730 report << " (none)\n";
731
732 report << "\nIMPROVEMENTS: " << improvements.size() << " zones\n";
733
734 shown = 0;
735
736 for( const auto& cmp : improvements )
737 {
738 if( shown >= 20 )
739 {
740 report << " ... and " << ( improvements.size() - 20 ) << " more\n";
741 break;
742 }
743
744 report << FormatZoneDetail( cmp ) << "\n";
745 shown++;
746 }
747
748 if( improvements.empty() )
749 report << " (none)\n";
750
751 report << "\nSummary: " << improvements.size() << " improved, "
752 << regressions.size() << " regressed, "
753 << breaking.size() << " breaking, "
754 << unchanged << " unchanged\n";
755
756 BOOST_TEST_MESSAGE( report.str() );
757
758 BOOST_CHECK_MESSAGE( breaking.empty(),
759 std::to_string( breaking.size() )
760 + " zone(s) have breaking triangulation changes" );
761}
762
763
764std::string GetTriangulationDataDir()
765{
766 return KI_TEST::GetTestDataRootDir() + "triangulation/";
767}
768
769}; // anonymous namespace
770
771
772BOOST_AUTO_TEST_SUITE( TriangulationBenchmark )
773
774
775BOOST_AUTO_TEST_CASE( BenchmarkAllExtractedPolygons )
776{
777 std::string dataDir = GetTriangulationDataDir();
778
779 if( !fs::exists( dataDir ) || fs::is_empty( dataDir ) )
780 {
781 BOOST_TEST_MESSAGE( "No triangulation data in " << dataDir << ", skipping benchmark" );
782 return;
783 }
784
785 fs::path jsonPath = fs::path( dataDir ) / "triangulation_status.json";
786 BASELINE_DATA baseline = LoadBaseline( jsonPath );
787
788 if( baseline.valid )
789 {
790 BOOST_TEST_MESSAGE( "Loaded baseline: " << baseline.boardCount << " boards, "
791 << baseline.zoneCount << " zones, "
792 << baseline.totalTriangles << " triangles" );
793 }
794 else
795 {
796 BOOST_TEST_MESSAGE( "No baseline found, running without comparison" );
797 }
798
799 std::vector<fs::path> polyFiles;
800
801 for( const auto& entry : fs::directory_iterator( dataDir ) )
802 {
803 if( entry.path().extension() == ".kicad_polys" )
804 polyFiles.push_back( entry.path() );
805 }
806
807 std::sort( polyFiles.begin(), polyFiles.end() );
808
809 BOOST_TEST_MESSAGE( "Found " << polyFiles.size() << " polygon files" );
810
811 // Stamp the report with the experimental variant under test (set by the harness / an env
812 // var when A/B testing an algorithm change against the recorded baseline).
813 if( const char* variant = std::getenv( "KICAD_TRI_VARIANT" ) )
814 BOOST_TEST_MESSAGE( "Variant: " << variant );
815
816 int totalTriangles = 0;
817 int totalSpikeyTri = 0;
818 int totalZones = 0;
819 int totalBelow10 = 0;
820 double totalTimeUs = 0.0;
821
822 // Corpus-wide min-angle percentiles are computed from every triangle rather than by
823 // averaging per-zone summaries, so one huge zone cannot dominate the tail statistics.
824 std::vector<double> globalMinAngles;
825
826 std::vector<ZONE_COMPARISON> comparisons;
827
828 for( const auto& polyFile : polyFiles )
829 {
830 BOARD_ENTRY board;
831
832 if( !ParsePolyFile( polyFile, board ) )
833 {
834 BOOST_TEST_MESSAGE( "Failed to parse: " << polyFile.filename() );
835 continue;
836 }
837
838 int boardTriangles = 0;
839 int boardSpikey = 0;
840 double boardTimeUs = 0.0;
841
842 const BASELINE_BOARD* baseBoard = nullptr;
843 auto it = baseline.boards.find( board.source );
844
845 if( it != baseline.boards.end() )
846 baseBoard = &it->second;
847
848 for( size_t zi = 0; zi < board.zones.size(); zi++ )
849 {
850 ZONE_STATS stats = ComputeZoneStats( board.zones[zi] );
851
853 stats.triangleCount > 0 || stats.originalArea == 0.0,
854 board.source + " " + stats.layer + " " + stats.net
855 + " produced 0 triangles with non-zero area" );
856
857 if( stats.originalArea > 0.0 )
858 {
860 stats.areaCoverage > 0.999 && stats.areaCoverage < 1.001,
861 board.source + " " + stats.layer + " " + stats.net
862 + " area coverage: " + std::to_string( stats.areaCoverage ) );
863 }
864
865 if( baseline.valid )
866 {
867 const BASELINE_ZONE* baseZone = nullptr;
868
869 if( baseBoard && zi < baseBoard->zones.size() )
870 baseZone = &baseBoard->zones[zi];
871
872 comparisons.push_back( CompareZone( board.source, stats, baseZone ) );
873 }
874
875 boardTriangles += stats.triangleCount;
876 boardSpikey += stats.spikeyTriangles;
877 boardTimeUs += static_cast<double>( stats.timeUs );
878 totalBelow10 += stats.trisBelow10Deg;
879 globalMinAngles.insert( globalMinAngles.end(), stats.minAngles.begin(),
880 stats.minAngles.end() );
881 totalZones++;
882 }
883
884 totalTriangles += boardTriangles;
885 totalSpikeyTri += boardSpikey;
886 totalTimeUs += boardTimeUs;
887 }
888
889 BOOST_TEST_MESSAGE( "Total triangles: " << totalTriangles
890 << " Spikey: " << totalSpikeyTri
891 << " (" << ( totalTriangles > 0
893 : 0.0 )
894 << "%)" );
895
896 std::sort( globalMinAngles.begin(), globalMinAngles.end() );
897 BOOST_TEST_MESSAGE( "Global min-angle deg p1: " << Percentile( globalMinAngles, 1.0 )
898 << " p5: " << Percentile( globalMinAngles, 5.0 )
899 << " p50: " << Percentile( globalMinAngles, 50.0 )
900 << " (<10deg: " << totalBelow10 << " = "
901 << ( totalTriangles > 0 ? 100.0 * totalBelow10 / totalTriangles : 0.0 )
902 << "%)" );
903 BOOST_TEST_MESSAGE( "Total time (median-per-zone sum): " << totalTimeUs / 1000.0 << " ms" );
904
905 if( baseline.valid )
906 OutputComparisonReport( baseline, comparisons, totalTriangles, totalSpikeyTri, totalZones );
907}
908
909
910BOOST_AUTO_TEST_CASE( UpdateTriangulationStatus, * boost::unit_test::disabled() )
911{
912 std::string dataDir = GetTriangulationDataDir();
913
914 if( !fs::exists( dataDir ) || fs::is_empty( dataDir ) )
915 {
916 BOOST_TEST_MESSAGE( "No triangulation data in " << dataDir << ", skipping" );
917 return;
918 }
919
920 std::vector<fs::path> polyFiles;
921
922 for( const auto& entry : fs::directory_iterator( dataDir ) )
923 {
924 if( entry.path().extension() == ".kicad_polys" )
925 polyFiles.push_back( entry.path() );
926 }
927
928 std::sort( polyFiles.begin(), polyFiles.end() );
929
932 int totalZones = 0;
933 double totalTimeUs = 0.0;
934 double totalArea = 0.0;
935
936 nlohmann::json boardsJson = nlohmann::json::array();
937
938 for( const auto& polyFile : polyFiles )
939 {
940 BOARD_ENTRY board;
941
942 if( !ParsePolyFile( polyFile, board ) )
943 continue;
944
945 nlohmann::json boardJson;
946 boardJson["source"] = board.source;
947 nlohmann::json zonesJson = nlohmann::json::array();
948
949 int boardTriangles = 0;
950 int boardSpikey = 0;
951 double boardTimeUs = 0.0;
952
953 for( ZONE_ENTRY& zone : board.zones )
954 {
955 ZONE_STATS stats = ComputeZoneStats( zone );
956 zonesJson.push_back( ZoneStatsToJson( stats ) );
957
958 boardTriangles += stats.triangleCount;
959 boardSpikey += stats.spikeyTriangles;
960 boardTimeUs += static_cast<double>( stats.timeUs );
961 totalArea += stats.triangulatedArea;
962 totalZones++;
963 }
964
965 boardJson["zones"] = zonesJson;
966
967 nlohmann::json boardTotals;
968 boardTotals["triangle_count"] = boardTriangles;
969 boardTotals["time_us"] = static_cast<int64_t>( boardTimeUs );
970 boardTotals["spikey_ratio"] = boardTriangles > 0
971 ? static_cast<double>( boardSpikey ) / boardTriangles
972 : 0.0;
973 boardJson["board_totals"] = boardTotals;
974 boardsJson.push_back( boardJson );
975
976 totalTriangles += boardTriangles;
977 totalSpikeyTri += boardSpikey;
978 totalTimeUs += boardTimeUs;
979 }
980
981 nlohmann::json globalJson;
982 globalJson["total_triangles"] = totalTriangles;
983 globalJson["total_time_us"] = static_cast<int64_t>( totalTimeUs );
984 globalJson["total_area_nm2"] = totalArea;
985 globalJson["total_spikey_triangles"] = totalSpikeyTri;
986 globalJson["spikey_ratio"] = totalTriangles > 0
987 ? static_cast<double>( totalSpikeyTri ) / totalTriangles
988 : 0.0;
989
990 nlohmann::json metadataJson;
991 metadataJson["board_count"] = static_cast<int>( polyFiles.size() );
992 metadataJson["zone_count"] = totalZones;
993
994 nlohmann::json jsonOutput;
995 jsonOutput["metadata"] = metadataJson;
996 jsonOutput["global"] = globalJson;
997 jsonOutput["boards"] = boardsJson;
998
999 fs::path jsonPath = fs::path( dataDir ) / "triangulation_status.json";
1000
1001 std::ofstream jsonFile( jsonPath );
1002 jsonFile << jsonOutput.dump( 2 ) << "\n";
1003 BOOST_CHECK( jsonFile.good() );
1004 jsonFile.close();
1005
1006 BOOST_TEST_MESSAGE( "Wrote triangulation status to " << jsonPath );
1007 BOOST_TEST_MESSAGE( "Boards: " << polyFiles.size() << " Zones: " << totalZones
1008 << " Triangles: " << totalTriangles );
1009}
1010
1011
A small class to help profiling.
Definition profile.h:46
void Stop()
Save the time when this function was called, and set the counter stane to stop.
Definition profile.h:86
double msecs(bool aSinceLast=false)
Definition profile.h:147
Represent a set of closed polygons.
double Area()
Return the area of this poly set.
bool Parse(std::stringstream &aStream) override
virtual void CacheTriangulation(bool aSimplify=false, const TASK_SUBMITTER &aSubmitter={})
Build a polygon triangulation, needed to draw a polygon on OpenGL and in some other calculations.
const TRIANGULATED_POLYGON * TriangulatedPolygon(int aIndex) const
unsigned int TriangulatedPolyCount() const
Return the number of triangulated polygons.
double Distance(const VECTOR2< extended_type > &aVector) const
Compute the distance between two vectors.
Definition vector2d.h:549
CHANGE_TYPE
Types of changes.
Definition commit.h:37
Exact orientation and in-circle predicates over integer coordinates.
double TriangleMinAngleDeg(const VECTOR2I &a, const VECTOR2I &b, const VECTOR2I &c)
The smallest interior angle of a triangle, in degrees; near zero for a sliver.
bool IsSliverTriangle(const VECTOR2I &a, const VECTOR2I &b, const VECTOR2I &c)
A triangle is a sliver when its longest edge exceeds ten times its shortest.
std::string GetTestDataRootDir()
BOOST_AUTO_TEST_CASE(HorizontalAlignment)
BOOST_AUTO_TEST_SUITE(CadstarPartParser)
BOOST_AUTO_TEST_SUITE_END()
std::ofstream jsonFile("pip_benchmark_results.json")
BOOST_CHECK_MESSAGE(totalMismatches==0, std::to_string(totalMismatches)+" board(s) with strategy disagreements")
std::vector< fs::path > polyFiles
BOOST_TEST_MESSAGE("\n=== Real-World Polygon PIP Benchmark ===\n"<< formatTable(table))
nlohmann::json metadataJson
nlohmann::json boardsJson
nlohmann::json globalJson
BOOST_AUTO_TEST_CASE(BenchmarkAllExtractedPolygons)
std::ofstream jsonFile(jsonPath)
nlohmann::json jsonOutput
int delta
VECTOR2< int32_t > VECTOR2I
Definition vector2d.h:683