CDT++ 1.0.0
Causal Dynamical Triangulations in C++
Loading...
Searching...
No Matches
cdt-viewer.cpp
Go to the documentation of this file.
1/*******************************************************************************
2Causal Dynamical Triangulations in C++ using CGAL
3Copyright © 2022 Adam Getchell
4******************************************************************************/
5
9
10#include <CGAL/draw_triangulation_3.h>
11#include <CGAL/Graphics_scene.h>
12#include <CGAL/Graphics_scene_options.h>
13#include <CGAL/IO/Color.h>
14#include <CGAL/Qt/Basic_viewer.h>
15#include <CGAL/Qt/camera.h>
16#include <CGAL/Qt/init_ogl_context.h>
17#include <CGAL/Qt/qglviewer.h>
18#include <CGAL/version.h>
19#include <fmt/ostream.h>
20#include <fmt/printf.h>
21
22#include <algorithm>
23#include <array>
24#include <bit>
25#include <boost/program_options.hpp>
26#include <cmath>
27#include <concepts>
28#include <cstddef>
29#include <cstdint>
30#include <cstdio>
31#include <cstdlib>
32#include <filesystem>
33#include <functional>
34#include <limits>
35#include <numbers>
36#include <QApplication>
37#include <QColor>
38#include <QCryptographicHash>
39#include <QFile>
40#include <QImage>
41#include <QImageReader>
42#include <QJsonArray>
43#include <QJsonDocument>
44#include <QJsonObject>
45#include <QJsonParseError>
46#include <QString>
47#include <QtGlobal>
48#include <QTimer>
49#include <set>
50#include <stdexcept>
51#include <string>
52#include <string_view>
53#include <system_error>
54#include <utility>
55#include <vector>
56
57#include "Settings.hpp"
59#include "Utilities.hpp"
60#include "Version.hpp"
61
62namespace po = boost::program_options;
63
64namespace
65{
66 using Delaunay = cdt::detail::TriangulationTraits<3>::Delaunay;
67 using Triangulation = Delaunay::Tr_Base;
68 using Scene_options =
69 CGAL::Graphics_scene_options<Triangulation, Triangulation::Vertex_handle,
70 Triangulation::Finite_edges_iterator,
71 Triangulation::Finite_facets_iterator>;
72
73 constexpr std::string_view USAGE =
74 R"(Causal Dynamical Triangulations in C++ using CGAL.
75
76Copyright (c) 2022 Adam Getchell
77
78Load a versioned triangulation fixture and render it with the CGAL Qt viewer.
79Without --output, the viewer remains interactive. With --output it saves the
80manifest-declared frame and exits without waiting for user input.
81
82Usage:
83 cdt-viewer --manifest MANIFEST [--fixture FIXTURE] [--output IMAGE]
84
85Options)";
86
87 struct Topology_counts
88 {
89 std::size_t vertices{};
90 std::size_t edges{};
91 std::size_t faces{};
92 std::size_t simplices{};
93 cdt::Int_precision minimum_timeslice{};
94 cdt::Int_precision maximum_timeslice{};
95 };
96
97 struct Camera_config
98 {
99 std::string projection;
100 std::array<double, 3> position{};
101 std::array<double, 3> target{};
102 std::array<double, 3> up{};
103 double vertical_field_of_view_radians{};
104 };
105
106 struct Render_config
107 {
108 int width{};
109 int height{};
110 bool transparent_background{};
111 double oversampling{};
112 bool expand_frustum{};
113 QColor background;
114 bool draw_vertices{};
115 bool draw_edges{};
116 bool draw_faces{};
117 std::string vertex_scope;
118 std::string edge_scope;
119 float point_size{};
120 float line_width{};
121 int edge_color_difference_threshold{};
122 int point_color_match_tolerance{};
123 bool flat_shading{};
124 QColor point_color;
125 QColor edge_color;
126 std::vector<CGAL::IO::Color> face_palette;
127 Camera_config camera;
128 std::size_t minimum_foreground_pixels{};
129 };
130
131 [[nodiscard]] auto draws_scene_edges(Render_config const& render) noexcept
132 -> bool
133 { return render.draw_edges && render.edge_scope == "all"; }
134
135 [[nodiscard]] auto draws_screen_space_edges(
136 Render_config const& render) noexcept -> bool
137 {
138 return render.draw_edges &&
139 render.edge_scope == "screen_space_face_boundaries";
140 }
141
142 struct Viewer_manifest
143 {
144 std::filesystem::path fixture;
145 std::string fixture_sha256;
146 Topology_counts expected;
147 std::string cgal_version;
148 std::string qt_version;
149 Render_config render;
150 };
151
152 class Artifact_viewer final : public CGAL::Qt::Basic_viewer
153 {
154 public:
155 using CGAL::Qt::Basic_viewer::Basic_viewer;
156
157 void after_initialization(std::function<void()> callback)
158 { callback_ = std::move(callback); }
159
160 protected:
161 void init() override
162 {
163 CGAL::Qt::Basic_viewer::init();
164 glDisable(GL_DITHER);
165 glDisable(GL_LINE_SMOOTH);
166 glDisable(GL_MULTISAMPLE);
167 if (callback_ && !callback_scheduled_)
168 {
169 callback_scheduled_ = true;
170 QTimer::singleShot(0, this, [this]() { callback_(); });
171 }
172 }
173
174 private:
175 std::function<void()> callback_;
176 bool callback_scheduled_{};
177 };
178
179 [[nodiscard]] auto require_object(QJsonObject const& parent,
180 QString const& key) -> QJsonObject
181 {
182 auto const value = parent.value(key);
183 if (!value.isObject())
184 {
185 throw std::invalid_argument(fmt::format(
186 "Render manifest field '{}' must be an object.", key.toStdString()));
187 }
188 return value.toObject();
189 }
190
191 [[nodiscard]] auto require_array(QJsonObject const& parent,
192 QString const& key) -> QJsonArray
193 {
194 auto const value = parent.value(key);
195 if (!value.isArray())
196 {
197 throw std::invalid_argument(fmt::format(
198 "Render manifest field '{}' must be an array.", key.toStdString()));
199 }
200 return value.toArray();
201 }
202
203 [[nodiscard]] auto require_string(QJsonObject const& parent,
204 QString const& key) -> std::string
205 {
206 auto const value = parent.value(key);
207 if (!value.isString() || value.toString().isEmpty())
208 {
209 throw std::invalid_argument(
210 fmt::format("Render manifest field '{}' must be a nonempty string.",
211 key.toStdString()));
212 }
213 return value.toString().toStdString();
214 }
215
216 [[nodiscard]] auto require_bool(QJsonObject const& parent, QString const& key)
217 -> bool
218 {
219 auto const value = parent.value(key);
220 if (!value.isBool())
221 {
222 throw std::invalid_argument(fmt::format(
223 "Render manifest field '{}' must be Boolean.", key.toStdString()));
224 }
225 return value.toBool();
226 }
227
228 [[nodiscard]] auto require_number(QJsonObject const& parent,
229 QString const& key) -> double
230 {
231 auto const value = parent.value(key);
232 if (!value.isDouble() || !std::isfinite(value.toDouble()))
233 {
234 throw std::invalid_argument(
235 fmt::format("Render manifest field '{}' must be a finite number.",
236 key.toStdString()));
237 }
238 return value.toDouble();
239 }
240
241 constexpr long double MAX_EXACT_JSON_INTEGER{9'007'199'254'740'991.0L};
242
243 template <std::integral Integer>
244 [[nodiscard]] auto require_integer(QJsonObject const& parent,
245 QString const& key) -> Integer
246 {
247 auto const number = require_number(parent, key);
248 auto const widened = static_cast<long double>(number);
249 // require_number() receives Qt's binary64 representation, so integers
250 // beyond 2^53 - 1 may already have rounded before this checked conversion.
251 if (std::trunc(number) != number ||
252 widened <
253 static_cast<long double>(std::numeric_limits<Integer>::min()) ||
254 widened >
255 static_cast<long double>(std::numeric_limits<Integer>::max()) ||
256 widened < -MAX_EXACT_JSON_INTEGER || widened > MAX_EXACT_JSON_INTEGER)
257 {
258 throw std::invalid_argument(
259 fmt::format("Render manifest field '{}' must fit the requested "
260 "integer type.",
261 key.toStdString()));
262 }
263 return static_cast<Integer>(number);
264 }
265
266 [[nodiscard]] auto require_vector(QJsonObject const& parent,
267 QString const& key) -> std::array<double, 3>
268 {
269 auto const values = require_array(parent, key);
270 if (values.size() != 3)
271 {
272 throw std::invalid_argument(
273 fmt::format("Render manifest field '{}' must have three entries.",
274 key.toStdString()));
275 }
276 std::array<double, 3> result{};
277 for (qsizetype index = 0; index < values.size(); ++index)
278 {
279 auto const value = values.at(index);
280 if (!value.isDouble() || !std::isfinite(value.toDouble()))
281 {
282 throw std::invalid_argument(
283 fmt::format("Render manifest field '{}' contains a non-number.",
284 key.toStdString()));
285 }
286 result.at(static_cast<std::size_t>(index)) = value.toDouble();
287 }
288 return result;
289 }
290
291 [[nodiscard]] auto require_color(QJsonObject const& parent,
292 QString const& key) -> QColor
293 {
294 auto const values = require_array(parent, key);
295 if (values.size() != 4)
296 {
297 throw std::invalid_argument(fmt::format(
298 "Render manifest field '{}' must be RGBA.", key.toStdString()));
299 }
300 std::array<int, 4> channels{};
301 for (qsizetype index = 0; index < values.size(); ++index)
302 {
303 auto const value = values.at(index);
304 if (!value.isDouble() ||
305 std::trunc(value.toDouble()) != value.toDouble() ||
306 value.toDouble() < 0.0 || value.toDouble() > 255.0)
307 {
308 throw std::invalid_argument(
309 fmt::format("Render manifest color '{}' must use integer channels "
310 "from 0 through 255.",
311 key.toStdString()));
312 }
313 channels.at(static_cast<std::size_t>(index)) =
314 static_cast<int>(value.toDouble());
315 }
316 return {channels[0], channels[1], channels[2], channels[3]};
317 }
318
319 [[nodiscard]] auto to_cgal_color(QColor const& color) -> CGAL::IO::Color
320 {
321 return {static_cast<unsigned char>(color.red()),
322 static_cast<unsigned char>(color.green()),
323 static_cast<unsigned char>(color.blue()),
324 static_cast<unsigned char>(color.alpha())};
325 }
326
327 [[nodiscard]] auto parse_manifest(std::filesystem::path const& path)
328 -> Viewer_manifest
329 {
330 QFile file(QString::fromStdString(path.string()));
331 if (!file.open(QIODevice::ReadOnly))
332 {
333 throw std::filesystem::filesystem_error(
334 "Could not open render manifest", path,
335 std::make_error_code(std::errc::no_such_file_or_directory));
336 }
337
338 QJsonParseError error;
339 auto const document = QJsonDocument::fromJson(file.readAll(), &error);
340 if (error.error != QJsonParseError::NoError || !document.isObject())
341 {
342 throw std::invalid_argument(
343 fmt::format("Could not parse render manifest {}: {}", path.string(),
344 error.errorString().toStdString()));
345 }
346 auto const root = document.object();
347 if (require_integer<int>(root, "schema_version") != 1)
348 {
349 throw std::invalid_argument("Unsupported render manifest schema.");
350 }
351
352 auto const fixture = require_object(root, "fixture");
353 auto const topology = require_object(fixture, "expected_topology");
354 auto const renderer = require_object(root, "renderer");
355 auto const render = require_object(root, "render");
356 auto const geometry = require_object(render, "geometry");
357 auto const style = require_object(render, "style");
358 auto const camera = require_object(render, "camera");
359 auto const checks = require_object(root, "comparison");
360
361 auto fixture_path = path.parent_path() / require_string(fixture, "path");
362 fixture_path = std::filesystem::weakly_canonical(fixture_path);
363
364 std::vector<CGAL::IO::Color> palette;
365 for (auto const entry : require_array(style, "face_palette"))
366 {
367 if (!entry.isArray())
368 {
369 throw std::invalid_argument(
370 "Every face palette entry must be an RGB array.");
371 }
372 auto const channels = entry.toArray();
373 if (channels.size() != 3)
374 {
375 throw std::invalid_argument(
376 "Every face palette entry must have three channels.");
377 }
378 std::array<unsigned char, 3> color{};
379 for (qsizetype index = 0; index < channels.size(); ++index)
380 {
381 auto const channel = channels.at(index);
382 if (!channel.isDouble() ||
383 std::trunc(channel.toDouble()) != channel.toDouble() ||
384 channel.toDouble() < 0.0 || channel.toDouble() > 255.0)
385 {
386 throw std::invalid_argument(
387 "Face palette channels must be integers from 0 through 255.");
388 }
389 color.at(static_cast<std::size_t>(index)) =
390 static_cast<unsigned char>(channel.toDouble());
391 }
392 palette.emplace_back(color[0], color[1], color[2]);
393 }
394 if (palette.empty())
395 {
396 throw std::invalid_argument("The face palette must not be empty.");
397 }
398
399 auto const width = require_integer<int>(render, "width");
400 auto const height = require_integer<int>(render, "height");
401 auto const point_size = require_number(style, "point_size");
402 auto const line_width = require_number(style, "line_width");
403 auto const oversampling = require_number(render, "oversampling");
404 constexpr double MAX_OVERSAMPLING{8.0};
405 if (width <= 0 || height <= 0 || point_size <= 0.0 || line_width <= 0.0 ||
406 point_size > static_cast<double>(std::numeric_limits<float>::max()) ||
407 line_width > static_cast<double>(std::numeric_limits<float>::max()))
408 {
409 throw std::invalid_argument(
410 "Render dimensions and point/line sizes must be positive and "
411 "representable.");
412 }
413 if (oversampling < 1.0 || oversampling > MAX_OVERSAMPLING)
414 {
415 throw std::invalid_argument(
416 "Render oversampling must be between 1 and 8.");
417 }
418
419 constexpr auto MAX_SNAPSHOT_PIXELS = std::size_t{268'435'456};
420 auto const sampled_width =
421 std::ceil(static_cast<long double>(width) * oversampling);
422 auto const sampled_height =
423 std::ceil(static_cast<long double>(height) * oversampling);
424 if (sampled_width >
425 static_cast<long double>(std::numeric_limits<int>::max()) ||
426 sampled_height >
427 static_cast<long double>(std::numeric_limits<int>::max()) ||
428 sampled_width * sampled_height >
429 static_cast<long double>(MAX_SNAPSHOT_PIXELS))
430 {
431 throw std::invalid_argument(
432 "Render dimensions and oversampling request an impractical "
433 "framebuffer.");
434 }
435
436 auto const minimum_foreground_pixels =
437 require_integer<std::size_t>(checks, "minimum_foreground_pixels");
438 auto const width_size = static_cast<std::size_t>(width);
439 auto const height_size = static_cast<std::size_t>(height);
440 if (width_size > std::numeric_limits<std::size_t>::max() / height_size ||
441 minimum_foreground_pixels > width_size * height_size)
442 {
443 throw std::invalid_argument(
444 "Minimum foreground pixels cannot exceed render width times "
445 "height.");
446 }
447 if (require_string(render, "output_format") != "png")
448 {
449 throw std::invalid_argument("The v1 viewer output format must be PNG.");
450 }
451
452 Viewer_manifest result{
453 .fixture = std::move(fixture_path),
454 .fixture_sha256 = require_string(fixture, "sha256"),
455 .expected = {.vertices =
456 require_integer<std::size_t>(topology, "vertices"),
457 .edges = require_integer<std::size_t>(topology, "edges"),
458 .faces = require_integer<std::size_t>(topology, "faces"),
459 .simplices =
460 require_integer<std::size_t>(topology, "simplices"),
461 .minimum_timeslice = require_integer<cdt::Int_precision>(
462 topology, "minimum_timeslice"),
463 .maximum_timeslice = require_integer<cdt::Int_precision>(
464 topology, "maximum_timeslice")},
465 .cgal_version = require_string(renderer, "cgal_version"),
466 .qt_version = require_string(renderer, "qt_version"),
467 .render = {
468 .width = width,
469 .height = height,
470 .transparent_background =
471 require_bool(render, "transparent_background"),
472 .oversampling = oversampling,
473 .expand_frustum = require_bool(render, "expand_frustum"),
474 .background = require_color(style, "background_rgba"),
475 .draw_vertices = require_bool(geometry, "vertices"),
476 .draw_edges = require_bool(geometry, "edges"),
477 .draw_faces = require_bool(geometry, "faces"),
478 .vertex_scope = require_string(geometry, "vertex_scope"),
479 .edge_scope = require_string(geometry, "edge_scope"),
480 .point_size = static_cast<float>(point_size),
481 .line_width = static_cast<float>(line_width),
482 .edge_color_difference_threshold =
483 require_integer<int>(style, "edge_color_difference_threshold"),
484 .point_color_match_tolerance =
485 require_integer<int>(style, "point_color_match_tolerance"),
486 .flat_shading = require_bool(style, "flat_shading"),
487 .point_color = require_color(style, "point_rgba"),
488 .edge_color = require_color(style, "edge_rgba"),
489 .face_palette = std::move(palette),
490 .camera = {.projection = require_string(camera, "projection"),
491 .position = require_vector(camera, "position"),
492 .target = require_vector(camera, "target"),
493 .up = require_vector(camera, "up"),
494 .vertical_field_of_view_radians = require_number(
495 camera, "vertical_field_of_view_radians")},
496 .minimum_foreground_pixels = minimum_foreground_pixels}
497 };
498
499 if (result.render.camera.projection != "perspective" &&
500 result.render.camera.projection != "orthographic")
501 {
502 throw std::invalid_argument(
503 "Camera projection must be 'perspective' or 'orthographic'.");
504 }
505 if (result.render.edge_scope != "all" &&
506 result.render.edge_scope != "screen_space_face_boundaries")
507 {
508 throw std::invalid_argument(
509 "Geometry edge scope must be 'all' or "
510 "'screen_space_face_boundaries'.");
511 }
512 if (result.render.vertex_scope != "all" &&
513 result.render.vertex_scope != "convex_hull")
514 {
515 throw std::invalid_argument(
516 "Geometry vertex scope must be 'all' or 'convex_hull'.");
517 }
518 if (result.render.edge_color_difference_threshold < 0 ||
519 result.render.edge_color_difference_threshold > 765)
520 {
521 throw std::invalid_argument(
522 "Edge color-difference threshold must be from 0 through 765.");
523 }
524 if (result.render.point_color_match_tolerance < 0 ||
525 result.render.point_color_match_tolerance > 765)
526 {
527 throw std::invalid_argument(
528 "Point color-match tolerance must be from 0 through 765.");
529 }
530 if (result.render.camera.vertical_field_of_view_radians <= 0.0 ||
531 result.render.camera.vertical_field_of_view_radians >= std::numbers::pi)
532 {
533 throw std::invalid_argument(
534 "Camera field of view must be between zero and pi radians.");
535 }
536 return result;
537 }
538
539 [[nodiscard]] auto sha256(std::filesystem::path const& path) -> std::string
540 {
541 QFile file(QString::fromStdString(path.string()));
542 if (!file.open(QIODevice::ReadOnly))
543 {
544 throw std::filesystem::filesystem_error(
545 "Could not open fixture for hashing", path,
546 std::make_error_code(std::errc::no_such_file_or_directory));
547 }
548 QCryptographicHash digest(QCryptographicHash::Sha256);
549 if (!digest.addData(&file))
550 {
551 throw std::runtime_error("Could not hash the viewer fixture.");
552 }
553 return digest.result().toHex().toStdString();
554 }
555
556 [[nodiscard]] auto topology_counts(Delaunay const& triangulation)
557 -> Topology_counts
558 {
559 if (!triangulation.is_valid() || triangulation.dimension() != 3)
560 {
561 throw std::invalid_argument(
562 "The viewer fixture is not a valid three-dimensional "
563 "triangulation.");
564 }
565 if (triangulation.number_of_vertices() == 0)
566 {
567 throw std::invalid_argument("The viewer fixture is empty.");
568 }
569
570 auto minimum = std::numeric_limits<cdt::Int_precision>::max();
571 auto maximum = std::numeric_limits<cdt::Int_precision>::min();
572 for (auto const vertex : triangulation.finite_vertex_handles())
573 {
574 minimum = std::min(minimum, vertex->info());
575 maximum = std::max(maximum, vertex->info());
576 }
577 return {.vertices = triangulation.number_of_vertices(),
578 .edges = triangulation.number_of_finite_edges(),
579 .faces = triangulation.number_of_finite_facets(),
580 .simplices = triangulation.number_of_finite_cells(),
581 .minimum_timeslice = minimum,
582 .maximum_timeslice = maximum};
583 }
584
585 void validate_fixture(Delaunay const& triangulation,
586 Viewer_manifest const& manifest)
587 {
588 auto const actual = topology_counts(triangulation);
589 if (actual.vertices != manifest.expected.vertices ||
590 actual.edges != manifest.expected.edges ||
591 actual.faces != manifest.expected.faces ||
592 actual.simplices != manifest.expected.simplices ||
593 actual.minimum_timeslice != manifest.expected.minimum_timeslice ||
594 actual.maximum_timeslice != manifest.expected.maximum_timeslice)
595 {
596 throw std::invalid_argument(fmt::format(
597 "Viewer fixture topology does not match the manifest: expected "
598 "V/E/F/T={}/{}/{}/{} and timeslices {}..{}; got "
599 "V/E/F/T={}/{}/{}/{} and timeslices {}..{}.",
600 manifest.expected.vertices, manifest.expected.edges,
601 manifest.expected.faces, manifest.expected.simplices,
602 manifest.expected.minimum_timeslice,
603 manifest.expected.maximum_timeslice, actual.vertices, actual.edges,
604 actual.faces, actual.simplices, actual.minimum_timeslice,
605 actual.maximum_timeslice));
606 }
607 }
608
609 constexpr auto FNV_OFFSET = std::uint64_t{14695981039346656037ULL};
610 constexpr auto FNV_PRIME = std::uint64_t{1099511628211ULL};
611
612 [[nodiscard]] constexpr auto mix_hash(std::uint64_t hash) noexcept
613 -> std::uint64_t
614 {
615 hash ^= hash >> 30U;
616 hash *= 0xbf58476d1ce4e5b9ULL;
617 hash ^= hash >> 27U;
618 hash *= 0x94d049bb133111ebULL;
619 return hash ^ (hash >> 31U);
620 }
621
622 void append_hash(std::uint64_t& hash, std::uint64_t value) noexcept
623 {
624 for (unsigned int byte = 0; byte < 8; ++byte)
625 {
626 hash ^= (value >> (byte * 8U)) & 0xffU;
627 hash *= FNV_PRIME;
628 }
629 }
630
631 [[nodiscard]] auto vertex_key(Triangulation::Vertex_handle const vertex)
632 -> std::array<std::uint64_t, 4>
633 {
634 auto const point = vertex->point();
635 return {std::bit_cast<std::uint64_t>(CGAL::to_double(point.x())),
636 std::bit_cast<std::uint64_t>(CGAL::to_double(point.y())),
637 std::bit_cast<std::uint64_t>(CGAL::to_double(point.z())),
638 static_cast<std::uint64_t>(vertex->info())};
639 }
640
641 [[nodiscard]] auto facet_vertices(
642 Triangulation::Finite_facets_iterator const facet)
643 -> std::array<Triangulation::Vertex_handle, 3>
644 {
645 auto const cell = facet->first;
646 auto const index = facet->second;
647 std::array<Triangulation::Vertex_handle, 3> vertices{
648 cell->vertex((index + 1) % 4), cell->vertex((index + 2) % 4),
649 cell->vertex((index + 3) % 4)};
650 std::ranges::sort(vertices, {}, vertex_key);
651 return vertices;
652 }
653
654 [[nodiscard]] auto facet_hash(
655 std::array<Triangulation::Vertex_handle, 3> const& vertices)
656 -> std::uint64_t
657 {
658 auto hash = FNV_OFFSET;
659 for (auto const vertex : vertices)
660 {
661 for (auto const value : vertex_key(vertex)) { append_hash(hash, value); }
662 }
663 return mix_hash(hash);
664 }
665
666 [[nodiscard]] auto convex_hull_vertices(Triangulation const& triangulation)
667 -> std::set<std::array<std::uint64_t, 4>>
668 {
669 std::set<std::array<std::uint64_t, 4>> result;
670 for (auto facet = triangulation.finite_facets_begin();
671 facet != triangulation.finite_facets_end(); ++facet)
672 {
673 auto const cell = facet->first;
674 auto const opposite = facet->second;
675 if (!triangulation.is_infinite(cell) &&
676 !triangulation.is_infinite(cell->neighbor(opposite)))
677 {
678 continue;
679 }
680 for (auto const vertex : facet_vertices(facet))
681 {
682 result.emplace(vertex_key(vertex));
683 }
684 }
685 return result;
686 }
687
688 void orient_outward(
689 std::array<Triangulation::Vertex_handle, 3>& vertices) noexcept
690 {
691 // The archival spherical fixture is star-shaped about the origin, so its
692 // centroid's radial direction identifies the outward-facing orientation.
693 auto const point = [](Triangulation::Vertex_handle const vertex) {
694 auto const& value = vertex->point();
695 return std::array{CGAL::to_double(value.x()), CGAL::to_double(value.y()),
696 CGAL::to_double(value.z())};
697 };
698 auto const a = point(vertices[0]);
699 auto const b = point(vertices[1]);
700 auto const c = point(vertices[2]);
701 auto const ab = std::array{b[0] - a[0], b[1] - a[1], b[2] - a[2]};
702 auto const ac = std::array{c[0] - a[0], c[1] - a[1], c[2] - a[2]};
703 auto const normal =
704 std::array{ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2],
705 ab[0] * ac[1] - ab[1] * ac[0]};
706 auto const centroid =
707 std::array{a[0] + b[0] + c[0], a[1] + b[1] + c[1], a[2] + b[2] + c[2]};
708 auto const radial_alignment = normal[0] * centroid[0] +
709 normal[1] * centroid[1] +
710 normal[2] * centroid[2];
711 if (radial_alignment < 0.0) { std::swap(vertices[1], vertices[2]); }
712 }
713
714 [[nodiscard]] auto make_scene(Delaunay const& delaunay,
715 Render_config const& render)
716 -> CGAL::Graphics_scene
717 {
718 auto const& triangulation = static_cast<Triangulation const&>(delaunay);
719 auto const hull_vertices = render.vertex_scope == "convex_hull"
720 ? convex_hull_vertices(triangulation)
721 : std::set<std::array<std::uint64_t, 4>>{};
722 Scene_options options;
723 options.ignore_all_vertices(!render.draw_vertices);
724 options.ignore_all_edges(!draws_scene_edges(render));
725 options.ignore_all_faces(true);
726 options.colored_vertex = [](Triangulation const&,
727 Triangulation::Vertex_handle) { return true; };
728 options.draw_vertex = [&hull_vertices, &render](
729 Triangulation const&,
730 Triangulation::Vertex_handle vertex) {
731 return render.vertex_scope == "all" ||
732 hull_vertices.contains(vertex_key(vertex));
733 };
734 options.vertex_color = [&render](Triangulation const&,
735 Triangulation::Vertex_handle) {
736 return to_cgal_color(render.point_color);
737 };
738 options.colored_edge = [](Triangulation const&,
739 Triangulation::Finite_edges_iterator) {
740 return true;
741 };
742 options.edge_color = [&render](Triangulation const&,
743 Triangulation::Finite_edges_iterator) {
744 return to_cgal_color(render.edge_color);
745 };
746 CGAL::Graphics_scene scene;
747 CGAL::add_to_graphics_scene(triangulation, scene, options);
748 if (render.draw_faces)
749 {
750 for (auto facet = triangulation.finite_facets_begin();
751 facet != triangulation.finite_facets_end(); ++facet)
752 {
753 auto vertices = facet_vertices(facet);
754 auto const index = static_cast<std::size_t>(facet_hash(vertices) %
755 render.face_palette.size());
756 orient_outward(vertices);
757 scene.face_begin(render.face_palette.at(index));
758 for (auto const vertex : vertices)
759 {
760 scene.add_point_in_face(vertex->point());
761 }
762 scene.face_end();
763 }
764 }
765 return scene;
766 }
767
768 [[nodiscard]] auto vec(std::array<double, 3> const& values)
769 -> CGAL::qglviewer::Vec
770 { return {values[0], values[1], values[2]}; }
771
772 void configure_viewer(CGAL::Qt::Basic_viewer& viewer,
773 Render_config const& render)
774 {
775 viewer.resize(render.width, render.height);
776 viewer.draw_vertices(render.draw_vertices);
777 viewer.draw_edges(draws_scene_edges(render));
778 viewer.draw_faces(render.draw_faces);
779 viewer.size_vertices(render.point_size);
780 viewer.size_edges(render.line_width);
781 viewer.flat_shading(render.flat_shading);
782 viewer.setBackgroundColor(render.background);
783
784 auto* const camera = viewer.camera();
785 camera->setType(render.camera.projection == "perspective"
786 ? CGAL::qglviewer::Camera::PERSPECTIVE
787 : CGAL::qglviewer::Camera::ORTHOGRAPHIC);
788 camera->setFieldOfView(render.camera.vertical_field_of_view_radians);
789 camera->setPosition(vec(render.camera.position));
790 camera->setUpVector(vec(render.camera.up));
791 camera->lookAt(vec(render.camera.target));
792 viewer.redraw();
793 }
794
795 [[nodiscard]] auto color_distance(QRgb first, QRgb second) noexcept -> int
796 {
797 return std::abs(qRed(first) - qRed(second)) +
798 std::abs(qGreen(first) - qGreen(second)) +
799 std::abs(qBlue(first) - qBlue(second));
800 }
801
802 constexpr int RGBA_CHANNELS{4};
803
804 [[nodiscard]] auto rgba_pixel(uchar const* row, int x) noexcept -> QRgb
805 {
806 auto const offset = x * RGBA_CHANNELS;
807 return qRgba(row[offset], row[offset + 1], row[offset + 2],
808 row[offset + 3]);
809 }
810
811 void set_rgba_pixel(uchar* row, int x, QRgb color) noexcept
812 {
813 auto const offset = x * RGBA_CHANNELS;
814 row[offset] = static_cast<uchar>(qRed(color));
815 row[offset + 1] = static_cast<uchar>(qGreen(color));
816 row[offset + 2] = static_cast<uchar>(qBlue(color));
817 row[offset + 3] = static_cast<uchar>(qAlpha(color));
818 }
819
820 void outline_face_boundaries(std::filesystem::path const& path,
821 Render_config const& render)
822 {
823 if (!draws_screen_space_edges(render)) { return; }
824
825 QImage source(QString::fromStdString(path.string()));
826 if (source.isNull())
827 {
828 throw std::runtime_error(
829 "Could not reopen the rendered image for edge outlining.");
830 }
831 source = source.convertToFormat(QImage::Format_RGBA8888);
832 auto outlined = source;
833 auto const edge_color = render.edge_color.rgba();
834
835 for (int y = 0; y < source.height(); ++y)
836 {
837 auto const* source_row = source.constScanLine(y);
838 auto const* lower_source_row =
839 y + 1 < source.height() ? source.constScanLine(y + 1) : nullptr;
840 auto* outlined_row = outlined.scanLine(y);
841 auto* lower_outlined_row =
842 y + 1 < outlined.height() ? outlined.scanLine(y + 1) : nullptr;
843 for (int x = 0; x < source.width(); ++x)
844 {
845 auto const color = rgba_pixel(source_row, x);
846 if (qAlpha(color) == 0) { continue; }
847 auto const boundary_with = [&](QRgb neighbor) {
848 return qAlpha(neighbor) != 0 &&
849 color_distance(color, neighbor) >
850 render.edge_color_difference_threshold;
851 };
852 auto const right_boundary =
853 x + 1 < source.width() &&
854 boundary_with(rgba_pixel(source_row, x + 1));
855 auto const lower_boundary =
856 lower_source_row != nullptr &&
857 boundary_with(rgba_pixel(lower_source_row, x));
858 if (right_boundary || lower_boundary)
859 {
860 set_rgba_pixel(outlined_row, x, edge_color);
861 if (render.line_width > 1.0F)
862 {
863 if (right_boundary)
864 {
865 set_rgba_pixel(outlined_row, x + 1, edge_color);
866 }
867 if (lower_boundary && lower_outlined_row != nullptr)
868 {
869 set_rgba_pixel(lower_outlined_row, x, edge_color);
870 }
871 }
872 }
873 }
874 }
875
876 auto const point_color = render.point_color.rgba();
877 auto const is_point = [&render, point_color](QRgb color) {
878 return qAlpha(color) != 0 && color_distance(color, point_color) <=
879 render.point_color_match_tolerance;
880 };
881 for (int y = 0; y < source.height(); ++y)
882 {
883 auto const* source_row = source.constScanLine(y);
884 auto const* upper_source_row =
885 y > 0 ? source.constScanLine(y - 1) : nullptr;
886 auto const* lower_source_row =
887 y + 1 < source.height() ? source.constScanLine(y + 1) : nullptr;
888 auto* outlined_row = outlined.scanLine(y);
889 for (int x = 0; x < source.width(); ++x)
890 {
891 if (!is_point(rgba_pixel(source_row, x))) { continue; }
892 auto const has_point_interior =
893 x > 0 && x + 1 < source.width() && upper_source_row != nullptr &&
894 lower_source_row != nullptr &&
895 is_point(rgba_pixel(source_row, x - 1)) &&
896 is_point(rgba_pixel(source_row, x + 1)) &&
897 is_point(rgba_pixel(upper_source_row, x)) &&
898 is_point(rgba_pixel(lower_source_row, x));
899 if (!has_point_interior)
900 {
901 set_rgba_pixel(outlined_row, x, edge_color);
902 }
903 }
904 }
905
906 if (!outlined.save(QString::fromStdString(path.string()), "PNG"))
907 {
908 throw std::runtime_error("Could not save the outlined render.");
909 }
910 }
911
912 [[nodiscard]] auto foreground_pixels(QImage image,
913 Render_config const& render)
914 -> std::size_t
915 {
916 image = image.convertToFormat(QImage::Format_RGBA8888);
917 auto const background = render.background.rgba();
918 std::size_t count{};
919 for (int y = 0; y < image.height(); ++y)
920 {
921 auto const* row = image.constScanLine(y);
922 for (int x = 0; x < image.width(); ++x)
923 {
924 auto const color = rgba_pixel(row, x);
925 if (render.transparent_background ? qAlpha(color) != 0
926 : color != background)
927 {
928 ++count;
929 }
930 }
931 }
932 return count;
933 }
934
935 void validate_image(std::filesystem::path const& path,
936 Render_config const& render)
937 {
938 QImageReader reader(QString::fromStdString(path.string()));
939 if (!reader.canRead())
940 {
941 throw std::runtime_error(fmt::format(
942 "Renderer did not produce a readable image at {}.", path.string()));
943 }
944 auto const dimensions = reader.size();
945 if (dimensions.width() != render.width ||
946 dimensions.height() != render.height)
947 {
948 throw std::runtime_error(
949 fmt::format("Rendered image has dimensions {}x{}, expected {}x{}.",
950 dimensions.width(), dimensions.height(), render.width,
951 render.height));
952 }
953 auto image = reader.read();
954 if (image.isNull())
955 {
956 throw std::runtime_error("Renderer produced an unreadable image.");
957 }
958 auto const foreground = foreground_pixels(std::move(image), render);
959 if (foreground < render.minimum_foreground_pixels)
960 {
961 throw std::runtime_error(fmt::format(
962 "Rendered image contains {} foreground pixels; expected at least "
963 "{}.",
964 foreground, render.minimum_foreground_pixels));
965 }
966 }
967
968 [[nodiscard]] auto run_viewer(CGAL::Graphics_scene const& scene,
969 Render_config const& render,
970 std::filesystem::path const& output) -> int
971 {
972 CGAL::Qt::init_ogl_context(4, 3);
973 int qt_argc = 1;
974 char application_name[] = "cdt-viewer";
975 char* qt_argv[] = {application_name, nullptr};
976 QApplication application(qt_argc, qt_argv);
977 std::string render_error;
978 QTimer headless_watchdog;
979 Artifact_viewer viewer(nullptr, scene, "CDT++ archival viewer",
980 render.draw_vertices, draws_scene_edges(render),
981 render.draw_faces);
982
983 if (!output.empty())
984 {
985 viewer.setAttribute(::Qt::WA_DontShowOnScreen, true);
986 viewer.after_initialization(
987 [&application, &viewer, &render, &output, &render_error]() {
988 try
989 {
990 configure_viewer(viewer, render);
991 if (output.has_parent_path())
992 {
993 std::filesystem::create_directories(output.parent_path());
994 }
995 viewer.saveSnapshot(QString::fromStdString(output.string()),
996 render.width, render.height,
997 render.expand_frustum, render.oversampling,
998 render.transparent_background
999 ? CGAL::qglviewer::TRANSPARENT_BACKGROUND
1000 : CGAL::qglviewer::CURRENT_BACKGROUND);
1001 outline_face_boundaries(output, render);
1002 validate_image(output, render);
1003 application.exit(EXIT_SUCCESS);
1004 }
1005 catch (std::exception const& error)
1006 {
1007 render_error = error.what();
1008 application.exit(EXIT_FAILURE);
1009 }
1010 catch (...)
1011 {
1012 render_error = "Unknown failure while rendering the artifact.";
1013 application.exit(EXIT_FAILURE);
1014 }
1015 });
1016 constexpr int HEADLESS_RENDER_TIMEOUT_MS{60'000};
1017 headless_watchdog.setSingleShot(true);
1018 QObject::connect(&headless_watchdog, &QTimer::timeout, &application,
1019 [&application, &render_error]() {
1020 render_error =
1021 "Headless renderer timed out before completion.";
1022 application.exit(EXIT_FAILURE);
1023 });
1024 headless_watchdog.start(HEADLESS_RENDER_TIMEOUT_MS);
1025 }
1026 else
1027 {
1028 viewer.after_initialization(
1029 [&viewer, &render]() { configure_viewer(viewer, render); });
1030 }
1031
1032 viewer.show();
1033 auto const status = application.exec();
1034 headless_watchdog.stop();
1035 if (!render_error.empty()) { throw std::runtime_error(render_error); }
1036 return status;
1037 }
1038} // namespace
1039
1040auto main(int const argc, char* const argv[]) -> int
1041try
1042{
1043 std::string manifest_path;
1044 std::string fixture_override;
1045 std::string output_path;
1046
1047 po::options_description description(std::string{USAGE});
1048 description.add_options()("help,h", "Show this message")(
1049 "version,v", "Show program version")(
1050 "manifest,m", po::value<std::string>(&manifest_path)->required(),
1051 "Versioned JSON render manifest")(
1052 "fixture,f", po::value<std::string>(&fixture_override),
1053 "Override the manifest fixture path (digest and topology still apply)")(
1054 "output,o", po::value<std::string>(&output_path),
1055 "Render noninteractively to this image and exit");
1056
1057 po::variables_map arguments;
1058 po::store(po::parse_command_line(argc, argv, description), arguments);
1059 if (arguments.count("help") != 0U)
1060 {
1061 fmt::print("{}\n", fmt::streamed(description));
1062 return EXIT_SUCCESS;
1063 }
1064 if (arguments.count("version") != 0U)
1065 {
1066 fmt::print("cdt-viewer version {} (CGAL {}, Qt {})\n", cdt::VERSION,
1067 CGAL_VERSION_STR, qVersion());
1068 return EXIT_SUCCESS;
1069 }
1070 po::notify(arguments);
1071
1072 auto manifest = parse_manifest(manifest_path);
1073 if (!fixture_override.empty())
1074 {
1075 manifest.fixture = std::filesystem::weakly_canonical(fixture_override);
1076 }
1077 if (sha256(manifest.fixture) != manifest.fixture_sha256)
1078 {
1079 throw std::invalid_argument(
1080 "Viewer fixture SHA-256 does not match the render manifest.");
1081 }
1082 if (manifest.cgal_version != CGAL_VERSION_STR ||
1083 manifest.qt_version != qVersion())
1084 {
1085 throw std::runtime_error(fmt::format(
1086 "Renderer version mismatch: manifest requires CGAL {} and Qt {}; "
1087 "this binary uses CGAL {} and Qt {}.",
1088 manifest.cgal_version, manifest.qt_version, CGAL_VERSION_STR,
1089 qVersion()));
1090 }
1091
1092 auto const triangulation =
1093 cdt::utilities::read_file<Delaunay>(manifest.fixture);
1094 validate_fixture(triangulation, manifest);
1095 auto const scene = make_scene(triangulation, manifest.render);
1096
1097 fmt::print("Validated viewer fixture {} with V/E/F/T={}/{}/{}/{}.\n",
1098 manifest.fixture.string(), manifest.expected.vertices,
1099 manifest.expected.edges, manifest.expected.faces,
1100 manifest.expected.simplices);
1101 auto const output = output_path.empty() ? std::filesystem::path{}
1102 : std::filesystem::path{output_path};
1103 auto const status = run_viewer(scene, manifest.render, output);
1104 if (!output.empty())
1105 {
1106 fmt::print("Rendered {}x{} artifact to {}.\n", manifest.render.width,
1107 manifest.render.height, output.string());
1108 }
1109 return status;
1110}
1111catch (std::exception const& error)
1112{
1113 fmt::print(stderr, "cdt-viewer: {}\n", error.what());
1114 return EXIT_FAILURE;
1115}
1116catch (...)
1117{
1118 fmt::print(stderr, "cdt-viewer: unknown failure\n");
1119 return EXIT_FAILURE;
1120}
Delaunay_t< 3 > Delaunay
Three-dimensional Delaunay triangulation used by move implementations.
Global integer and precision settings.
Traits class for particular uses of CGAL.
Utility functions.
auto read_file(std::filesystem::path const &filename) -> TriangulationType
Read triangulation from file.
std::int32_t Int_precision
Definition Settings.hpp:30