From 80f9dcb3a3e3262711a1aedcfffeb55d4e1c1b2a Mon Sep 17 00:00:00 2001 From: Yingjie Wang Date: Sat, 10 Oct 2026 01:46:32 -0400 Subject: [PATCH] Feat: Add post-tone-map mesh diagnostics with RGBA overlays Color antialiased half-edges by ray outcome with configurable Catppuccin colors and default opacity 0.5. Rasterize premultiplied RGBA8 overlays on the producer and composite in place after writing the clean image. Keep single-frame, movie, and replay output consistent. Add overlay, CLI, and queue ownership regressions and document the final output architecture. --- Makefile | 21 +- README.md | 4 + README.zh-CN.md | 1 + nr_spacetime_movie_renderer_design.md | 15 +- src/frame.c | 94 ---- src/frame.h | 2 - src/main.c | 122 ++++- src/mesh_overlay.c | 506 ++++++++++++++++++ src/mesh_overlay.h | 103 ++++ src/movie_output.c | 16 +- src/movie_output.h | 29 +- tests/test_camera_cli.py | 2 +- tests/test_frame.c | 26 +- tests/test_mesh_overlay.c | 740 ++++++++++++++++++++++++++ tests/test_mesh_overlay_cli.py | 94 ++++ tests/test_movie_output.c | 242 ++++++++- usage.md | 38 +- 17 files changed, 1873 insertions(+), 182 deletions(-) create mode 100644 src/mesh_overlay.c create mode 100644 src/mesh_overlay.h create mode 100644 tests/test_mesh_overlay.c create mode 100644 tests/test_mesh_overlay_cli.py diff --git a/Makefile b/Makefile index 9cb7970..33c32eb 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ TARGET_BASENAME := $(SPACETIME)_sky OBJECT_DIR := $(BUILD_DIR)/obj/$(SPACETIME) CORE_MINKOWSKI_SOURCES := $(COMMON_SOURCES) src/spacetime_minkowski.c -.PHONY: all backend clean run test tone-map-test sensor-bloom-test sensor-bloom-bench hip-psf-test hip-psf-bench fast-psf-fftw-bench minkowski schwarzschild alcubierre FORCE +.PHONY: all backend clean run test tone-map-test sensor-bloom-test sensor-bloom-bench mesh-overlay-test hip-psf-test hip-psf-bench fast-psf-fftw-bench minkowski schwarzschild alcubierre FORCE ifneq ($(filter 0 1,$(PSF_EVENT_SINK)),$(PSF_EVENT_SINK)) $(error Unknown PSF_EVENT_SINK '$(PSF_EVENT_SINK)'; choose 0 or 1) @@ -126,6 +126,7 @@ TONE_MAP_TEST_TARGET := $(TEST_OUT_DIR)/test_tone_map MOVIE_OUTPUT_TEST_TARGET := $(TEST_OUT_DIR)/test_movie_output SENSOR_BLOOM_TEST_TARGET := $(TEST_OUT_DIR)/test_sensor_bloom SENSOR_BLOOM_BENCH_TARGET := $(TEST_OUT_DIR)/benchmark_sensor_bloom +MESH_OVERLAY_TEST_TARGET := $(TEST_OUT_DIR)/test_mesh_overlay ifeq ($(PSF_BACKEND),hip) TARGET := $(BUILD_DIR)/$(TARGET_BASENAME)_hip @@ -267,10 +268,10 @@ $(FAST_PSF_FFTW_TEST_TARGET): tests/test_fast_psf_fftw.c $(CORE_MINKOWSKI_SOURCE $(TONE_MAP_TEST_TARGET): tests/test_tone_map.c src/optics.c src/optics.h $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR) $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_tone_map.c src/optics.c $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@ -# The movie-output queue links production optics + fast_psf_fftw only, so it +# The movie-output queue links production optics, mesh overlay and FFTW only, so it # needs neither a catalog nor ray tracing. -$(MOVIE_OUTPUT_TEST_TARGET): tests/test_movie_output.c src/movie_output.c src/movie_output.h src/optics.c src/optics.h $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR) - $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_movie_output.c src/movie_output.c src/optics.c $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@ +$(MOVIE_OUTPUT_TEST_TARGET): tests/test_movie_output.c src/movie_output.c src/movie_output.h src/mesh_overlay.c src/mesh_overlay.h src/optics.c src/optics.h $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR) + $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_movie_output.c src/movie_output.c src/mesh_overlay.c src/optics.c $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@ $(FAST_PSF_FFTW_BENCH_TARGET): tests/benchmark_fast_psf_fftw.c $(CORE_MINKOWSKI_SOURCES) $(CPU_FFTW_SOURCES) | $(TEST_OUT_DIR) $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc $^ $(LDLIBS) -o $@ @@ -284,6 +285,11 @@ $(SENSOR_BLOOM_TEST_TARGET): tests/test_sensor_bloom.c src/sensor_bloom.c src/se $(SENSOR_BLOOM_BENCH_TARGET): tests/benchmark_sensor_bloom.c src/sensor_bloom.c src/sensor_bloom.h | $(TEST_OUT_DIR) $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/benchmark_sensor_bloom.c src/sensor_bloom.c $(LDLIBS) -o $@ +# The mesh-overlay regression links only the standalone overlay module: it +# needs neither a catalog, ray tracing, FFTW, nor an output writer. +$(MESH_OVERLAY_TEST_TARGET): tests/test_mesh_overlay.c src/mesh_overlay.c src/mesh_overlay.h | $(TEST_OUT_DIR) + $(CC) $(CPPFLAGS) $(BUILD_CPPFLAGS) $(CFLAGS) $(BUILD_CFLAGS) $(OPENMP_FLAGS) -Isrc tests/test_mesh_overlay.c src/mesh_overlay.c $(LDLIBS) -o $@ + # The FFTW-vs-spatial test is meaningful only in the CPU PSF build. ifneq ($(CPU_FFTW_SOURCES),) FAST_PSF_FFTW_TEST_DEP := $(FAST_PSF_FFTW_TEST_TARGET) @@ -293,7 +299,7 @@ FAST_PSF_FFTW_TEST_DEP := FAST_PSF_FFTW_TEST_RUN := endif -test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(ADAPTIVE_GEODESIC_TEST_TARGET) $(ASYMPTOTIC_TEST_TARGET) $(ASYMPTOTIC_ENTRY_TEST_TARGET) $(ASYMPTOTIC_QUADRATIC_TEST_TARGET) $(ASYMPTOTIC_SCHWARZSCHILD_TEST_TARGET) $(TERMINATION_ORACLE_TEST_TARGET) $(FRAME_TEST_TARGET) $(SCHWARZSCHILD_TEST_TARGET) $(ALCUBIERRE_TEST_TARGET) $(OBSERVER_TRACK_TEST_TARGET) $(CATALOG_PREFETCH_TEST_TARGET) $(FAST_PSF_FFTW_TEST_DEP) $(TONE_MAP_TEST_TARGET) $(MOVIE_OUTPUT_TEST_TARGET) $(SENSOR_BLOOM_TEST_TARGET) +test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(ADAPTIVE_GEODESIC_TEST_TARGET) $(ASYMPTOTIC_TEST_TARGET) $(ASYMPTOTIC_ENTRY_TEST_TARGET) $(ASYMPTOTIC_QUADRATIC_TEST_TARGET) $(ASYMPTOTIC_SCHWARZSCHILD_TEST_TARGET) $(TERMINATION_ORACLE_TEST_TARGET) $(FRAME_TEST_TARGET) $(SCHWARZSCHILD_TEST_TARGET) $(ALCUBIERRE_TEST_TARGET) $(OBSERVER_TRACK_TEST_TARGET) $(CATALOG_PREFETCH_TEST_TARGET) $(FAST_PSF_FFTW_TEST_DEP) $(TONE_MAP_TEST_TARGET) $(MOVIE_OUTPUT_TEST_TARGET) $(SENSOR_BLOOM_TEST_TARGET) $(MESH_OVERLAY_TEST_TARGET) $(TEST_OUT_DIR)/test_observer_minkowski $(TEST_OUT_DIR)/test_observer_schwarzschild $(TEST_TARGET) @@ -312,10 +318,12 @@ test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(ADAPTIVE_GEODESIC_TEST_TARGET) $(A $(TONE_MAP_TEST_TARGET) $(MOVIE_OUTPUT_TEST_TARGET) $(SENSOR_BLOOM_TEST_TARGET) + $(MESH_OVERLAY_TEST_TARGET) python3 tests/test_camera_cli.py $(BUILD_DIR) $(TEST_OUT_DIR) python3 tests/test_adaptive_cli.py $(BUILD_DIR) $(TEST_OUT_DIR) python3 tests/test_ray_diagnostics.py $(BUILD_DIR) $(TEST_OUT_DIR) python3 tests/test_output_streams.py $(BUILD_DIR) $(TEST_OUT_DIR) + python3 tests/test_mesh_overlay_cli.py $(BUILD_DIR) tone-map-test: $(TONE_MAP_TEST_TARGET) $(TONE_MAP_TEST_TARGET) @@ -323,6 +331,9 @@ tone-map-test: $(TONE_MAP_TEST_TARGET) sensor-bloom-test: $(SENSOR_BLOOM_TEST_TARGET) $(SENSOR_BLOOM_TEST_TARGET) +mesh-overlay-test: $(MESH_OVERLAY_TEST_TARGET) + $(MESH_OVERLAY_TEST_TARGET) + sensor-bloom-bench: $(SENSOR_BLOOM_BENCH_TARGET) fast-psf-fftw-bench: $(FAST_PSF_FFTW_BENCH_TARGET) diff --git a/README.md b/README.md index ba75669..d76a25c 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,10 @@ tone-mapped image; `--draw-mesh` additionally writes the final image-plane triangles, so one command produces both `output/imgs/schwarzschild_test_grid.png` (no mesh) and `output/imgs/schwarzschild_test_grid_mesh.png` (mesh overlay). +The antialiased mesh is drawn after tone mapping: gray escape half-edges, +purple dark half-edges, yellow budget-unresolved half-edges, and red failure +half-edges, using Catppuccin Mocha defaults. Color and opacity settings are +documented in `usage.md`. ```sh mkdir -p output/imgs diff --git a/README.zh-CN.md b/README.zh-CN.md index caa1250..dfc7eba 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -119,6 +119,7 @@ mkdir -p output/imgs ### 示例:叠加网格的合成测试星表 这个示例使用 `assets/sky_grid_5deg.csv` 检查 Schwarzschild 时空中的引力透镜效果与自适应网格细分。主输出是不带网格的成品图;`--draw-mesh` 会额外写出最终的像平面三角网格,因此同一次命令会同时生成 `output/imgs/schwarzschild_test_grid.png`(无网格)和 `output/imgs/schwarzschild_test_grid_mesh.png`(网格叠加)。 +网格在 tone mapping 后以抗锯齿半边叠加。默认采用 Catppuccin Mocha:逃逸为灰色、暗终态为紫色、预算耗尽未决为黄色、真实失败为红色,未追踪为蓝色。颜色与透明度配置详见 `usage.md`。 ```sh mkdir -p output/imgs diff --git a/nr_spacetime_movie_renderer_design.md b/nr_spacetime_movie_renderer_design.md index f900ad3..cda0fc8 100644 --- a/nr_spacetime_movie_renderer_design.md +++ b/nr_spacetime_movie_renderer_design.md @@ -1363,11 +1363,18 @@ immutable 的 cache,不再逐帧做 tile 扫描、prefetch OpenMP 区域或打 multi-frame map 与 observer movie 走同一 union 路径。 movie PNG 编码/写盘由一个单 producer、单 writer 的有界队列(默认容量 2)承担, -与下一帧渲染重叠。producer 在 enqueue 前完成 sensor bloom、clean RGB8 与可选 -mesh overlay RGB8 转换,job 只持有 8-bit buffer,HDR 在 submit 后即可释放。 +与下一帧渲染重叠。producer 完成 sensor bloom、tone mapping 和 sRGB 转换, +生成 clean RGB8;可选诊断网格由去重边光栅化为预乘 alpha 的 sRGB RGBA8 层。 +HDR 与临时线段由 producer 释放;job 独立拥有这两个输出 buffer,成功 enqueue +后 ownership 转交队列。writer 先写 clean 图,再以 source-over 原地合成诊断层、写出 +mesh sibling,最后释放 job buffer。单帧、movie 与 lens-map replay 共用此合成规则。 writer 的首个错误持久保存,使后续 submit 立即失败;`finish()` drain 已接受 job 后 join writer,所有退出路径都必须 join,绝不为求重叠而提前打印 `Rendered ... ok`。 +诊断网格是最终 mesh 的只读可视化:一像素抗锯齿边按端点终态分别着色相邻半边, +在中点切换颜色。合成位于 tone mapping 与 sRGB transfer 之后,clean 图与 HDR +保持独立。调色与透明度配置见 [`usage.md`](usage.md)。 + fast mode 的单星精度由 deposit 模式与 `N` 决定:`nearest` 的格点间距是 每轴 `1/N` 个输出像素,单帧瞬时舍入误差至多是 `1/(2N)`;在 `--psf-fwhm-pixels` 不变时它与图像分辨率无关,只有增大 `N`(或使用保持 @@ -1987,8 +1994,8 @@ map 共用同一入口。每个 RGB 通道独立、各向同性地把超过有 \(E\) 使用 exposure 之后的 renderer-scale 线性 HDR;\(e\) 同时决定每轮保留传播的 比例和有效传播距离;模型允许信号损失,不守恒。原始 `--hdr-output` FITS 在模型 运行前写出,因此始终是 bloom 前的 PSF HDR;tone-mapped PNG/PPM 与视频帧在模型 -之后写出。视觉式多尺度 bloom 不在当前范围内。mesh overlay 在模型之后绘制, -不参与溢出传播。 +之后写出。视觉式多尺度 bloom 不在当前范围内。mesh 诊断层在 tone mapping 与 +sRGB transfer 后合成。 --- diff --git a/src/frame.c b/src/frame.c index 52ce7ff..f5bdebc 100644 --- a/src/frame.c +++ b/src/frame.c @@ -2543,100 +2543,6 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh, return images; } -static void blend_gray(double *hdr, int width, int height, int x, int y, - double gray, double alpha) { - if (x < 0 || x >= width || y < 0 || y >= height) - return; - double *pixel = &hdr[3 * (y * width + x)]; - for (int channel = 0; channel < 3; ++channel) - pixel[channel] = (1.0 - alpha) * pixel[channel] + alpha * gray; -} - -static double fractional_part(double value) { return value - floor(value); } - -static void plot_aa(double *hdr, int width, int height, int steep, int x, int y, - double coverage, double gray, double opacity) { - if (coverage > 0.0) - blend_gray(hdr, width, height, steep ? y : x, steep ? x : y, gray, - coverage * opacity); -} - -/* Xiaolin Wu line rasterization: a one-pixel line with coverage-based alpha. */ -static void draw_line(double *hdr, int width, int height, - const LensVertex *from, const LensVertex *to, double gray, - double opacity) { - double x0 = from->image_x, y0 = from->image_y; - double x1 = to->image_x, y1 = to->image_y; - const int steep = fabs(y1 - y0) > fabs(x1 - x0); - if (steep) { - double swap = x0; - x0 = y0; - y0 = swap; - swap = x1; - x1 = y1; - y1 = swap; - } - if (x0 > x1) { - double swap = x0; - x0 = x1; - x1 = swap; - swap = y0; - y0 = y1; - y1 = swap; - } - const double dx = x1 - x0; - if (dx == 0.0) { - plot_aa(hdr, width, height, steep, (int)lround(x0), (int)floor(y0), 1.0, - gray, opacity); - return; - } - const double gradient = (y1 - y0) / dx; - double x_end = round(x0); - double y_end = y0 + gradient * (x_end - x0); - double x_gap = 1.0 - fractional_part(x0 + 0.5); - int x_pixel_start = (int)x_end; - int y_pixel = (int)floor(y_end); - plot_aa(hdr, width, height, steep, x_pixel_start, y_pixel, - (1.0 - fractional_part(y_end)) * x_gap, gray, opacity); - plot_aa(hdr, width, height, steep, x_pixel_start, y_pixel + 1, - fractional_part(y_end) * x_gap, gray, opacity); - double inter_y = y_end + gradient; - x_end = round(x1); - y_end = y1 + gradient * (x_end - x1); - x_gap = fractional_part(x1 + 0.5); - const int x_pixel_end = (int)x_end; - y_pixel = (int)floor(y_end); - plot_aa(hdr, width, height, steep, x_pixel_end, y_pixel, - (1.0 - fractional_part(y_end)) * x_gap, gray, opacity); - plot_aa(hdr, width, height, steep, x_pixel_end, y_pixel + 1, - fractional_part(y_end) * x_gap, gray, opacity); - for (int x = x_pixel_start + 1; x < x_pixel_end; ++x) { - y_pixel = (int)floor(inter_y); - plot_aa(hdr, width, height, steep, x, y_pixel, - 1.0 - fractional_part(inter_y), gray, opacity); - plot_aa(hdr, width, height, steep, x, y_pixel + 1, fractional_part(inter_y), - gray, opacity); - inter_y += gradient; - } -} - -void frame_draw_mesh(const FrameLensMesh *mesh, double *hdr, int width, - int height, double gray, double opacity) { - if (mesh == NULL || hdr == NULL || width <= 0 || height <= 0 || gray < 0.0 || - opacity < 0.0 || opacity > 1.0) - return; - for (size_t i = 0; i < mesh->triangle_count; ++i) { - const LensTriangle *triangle = &mesh->triangles[i]; - for (int edge = 0; edge < 3; ++edge) { - const size_t from_id = triangle->vertex[edge]; - const size_t to_id = triangle->vertex[(edge + 1) % 3]; - if (from_id < to_id) - draw_line(hdr, width, height, &mesh->vertices[from_id], - &mesh->vertices[to_id], gray, opacity); - } - } -} - void frame_lens_mesh_destroy(FrameLensMesh *mesh) { if (mesh == NULL) return; diff --git a/src/frame.h b/src/frame.h index 0bbbb2f..f3b3272 100644 --- a/src/frame.h +++ b/src/frame.h @@ -303,8 +303,6 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh, const FrameSplatProgress *progress, FastPsfAccumulator *fast, MovieFrameTiming *timing); -void frame_draw_mesh(const FrameLensMesh *mesh, double *hdr, int width, - int height, double gray, double opacity); void frame_lens_mesh_destroy(FrameLensMesh *mesh); #endif diff --git a/src/main.c b/src/main.c index 5fa8199..d17cf7a 100644 --- a/src/main.c +++ b/src/main.c @@ -1,6 +1,7 @@ #include "catalog.h" #include "frame.h" #include "lens_map.h" +#include "mesh_overlay.h" #include "movie.h" #include "movie_output.h" #include "observer_track.h" @@ -89,6 +90,7 @@ typedef struct { int retry_lookback_increment_specified, max_total_lookback_time_specified; ToneMapSettings tone_map; PngWriteSettings png; + MeshOverlaySettings mesh_overlay; int sensor_bloom_enabled; int sensor_bloom_limit_specified; int sensor_bloom_transfer_specified; @@ -223,6 +225,17 @@ static int parse_sensor_bloom_transfer(const char *text, double *value) { : 0; } +/* Mesh overlay opacity: finite and in the closed interval [0, 1]. */ +static int parse_mesh_opacity(const char *text, double *value) { + char *end; + errno = 0; + *value = strtod(text, &end); + return errno || end == text || *end || !isfinite(*value) || *value < 0.0 || + *value > 1.0 + ? -1 + : 0; +} + static int validate_tonemapped_output_path(const char *path) { const size_t path_length = strlen(path); #ifdef ENABLE_PNG @@ -333,8 +346,8 @@ static int build_frame_output_paths(const Settings *s, const char *output_path, } /* Canonical output order for every frame: clean HDR, clean tone-mapped image, - * then the mesh overlay. The overlay reuses the already-consumed HDR buffer, - * so no second full-size framebuffer is allocated and nothing is re-rendered. */ + * then the mesh overlay. The overlay reuses the already-written RGB8 buffer, + * so no second full-size framebuffer or tone-map conversion is needed. */ /* Optional sensor bloom applied to the post-exposure linear HDR before the * tone map. Shared by the synchronous writer and the async movie producer. */ static int apply_sensor_bloom(const Settings *s, double *hdr, int width, @@ -389,27 +402,58 @@ static int write_frame_outputs(const Settings *s, const FrameLensMesh *mesh, } const int write_result = write_rgb8_timed(s, paths->output_path, rgb8, width, height, timing); - free(rgb8); fprintf(write_result == 0 ? stdout : stderr, "Rendered %zu images from %zu catalog stars to %s (%s%s)\n", images, stars, paths->output_path, write_result == 0 ? "ok" : "write failed", note); - if (write_result) + if (write_result) { + free(rgb8); return -1; + } + /* The clean image is already on disk, so the overlay is drawn in place on the + * same sRGB8 buffer; the HDR framebuffer is never touched by it. */ if (paths->draw_mesh) { - frame_draw_mesh(mesh, hdr, width, height, 0.5, 0.5); - unsigned char *mesh_rgb8 = NULL; - if (render_rgb8_image(s, hdr, width, height, &mesh_rgb8, timing) || - write_rgb8_timed(s, paths->mesh_path, mesh_rgb8, width, height, - timing)) { - free(mesh_rgb8); + MeshOverlayLayer layer = {0}; + int mesh_result = mesh_overlay_build_layer(mesh, width, height, + &s->mesh_overlay, &layer); + if (mesh_result == 0) + mesh_result = mesh_overlay_composite_rgb8(&layer, rgb8, width, height); + if (mesh_result == 0) + mesh_result = write_rgb8_timed(s, paths->mesh_path, rgb8, width, height, + timing); + mesh_overlay_layer_destroy(&layer); + if (mesh_result) { + free(rgb8); fprintf(stderr, "Failed to write mesh overlay image: %s\n", paths->mesh_path); return -1; } - free(mesh_rgb8); fprintf(stdout, "Wrote mesh overlay image: %s\n", paths->mesh_path); } + free(rgb8); + return 0; +} + +/* Maps a --mesh-color- option name to its palette index. Returns 1 + * when the name matches one of the five categories and 0 otherwise. */ +static int mesh_color_option(const char *name, + enum MeshOverlayCategory *category) { + static const struct { + const char *option; + enum MeshOverlayCategory category; + } options[] = { + {"--mesh-color-escape", MESH_OVERLAY_CATEGORY_ESCAPE}, + {"--mesh-color-dark", MESH_OVERLAY_CATEGORY_DARK}, + {"--mesh-color-unresolved", MESH_OVERLAY_CATEGORY_UNRESOLVED}, + {"--mesh-color-incomplete", MESH_OVERLAY_CATEGORY_INCOMPLETE}, + {"--mesh-color-untraced", MESH_OVERLAY_CATEGORY_UNTRACED}, + }; + for (size_t i = 0; i < sizeof options / sizeof options[0]; ++i) { + if (!strcmp(name, options[i].option)) { + *category = options[i].category; + return 1; + } + } return 0; } @@ -471,8 +515,12 @@ static int parse_args(int argc, char **argv, Settings *s, * radius-30 camera would sit outside the active domain. */ s->observer_radius = 15.0; #endif + /* The palette default is a function call, so it is assigned after the + * aggregate initializer instead of being embedded in it. */ + s->mesh_overlay = mesh_overlay_default_settings(); *write_path = NULL; int tone_map_p_specified = 0; + enum MeshOverlayCategory mesh_category; for (int i = 1; i < argc; ++i) { if (!strcmp(argv[i], "--catalog") && i + 1 < argc) s->catalog_path = argv[++i]; @@ -511,6 +559,23 @@ static int parse_args(int argc, char **argv, Settings *s, !parse_positive(argv[++i], &s->refinement.min_area_pixels2)) { } else if (!strcmp(argv[i], "--draw-mesh")) { s->draw_mesh = 1; + } else if (mesh_color_option(argv[i], &mesh_category)) { + if (i + 1 >= argc) { + fprintf(stderr, "%s requires a #RRGGBB hex color.\n", argv[i]); + return -1; + } + unsigned char rgb[3]; + if (mesh_overlay_parse_color(argv[++i], rgb)) { + fprintf(stderr, "%s expects a strict #RRGGBB hex color.\n", argv[i - 1]); + return -1; + } + memcpy(s->mesh_overlay.colors[mesh_category], rgb, sizeof rgb); + } else if (!strcmp(argv[i], "--mesh-opacity")) { + if (i + 1 >= argc || + parse_mesh_opacity(argv[++i], &s->mesh_overlay.opacity)) { + fputs("--mesh-opacity requires a finite number in [0,1].\n", stderr); + return -1; + } } else if (!strcmp(argv[i], "--allow-incomplete")) { s->allow_incomplete = 1; } else if (!strcmp(argv[i], "--fov-deg") && i + 1 < argc && @@ -818,7 +883,13 @@ static void print_help(const char *program) { " --refine-angle-rel R Relative angular interpolation error limit (default: 0.1)\n" " --refine-jacobian-min J Fold-refinement Jacobian threshold (default: 1e-3)\n" " --refine-min-edge-pixels P Stop refinement below this edge length (default: 0.5)\n" - " --refine-min-area-pixels2 A Stop refinement below this triangle area (default: 0.25)\n", + " --refine-min-area-pixels2 A Stop refinement below this triangle area (default: 0.25)\n" + " --mesh-color-escape #RRGGBB Escape half-edge color (default: #7F849C)\n" + " --mesh-color-dark #RRGGBB Dark half-edge color (default: #CBA6F7)\n" + " --mesh-color-unresolved #RRGGBB Unresolved half-edge color (default: #F9E2AF)\n" + " --mesh-color-incomplete #RRGGBB Incomplete half-edge color (default: #F38BA8)\n" + " --mesh-color-untraced #RRGGBB Untraced half-edge color (default: #89B4FA)\n" + " --mesh-opacity O Mesh overlay alpha in [0,1] (default: 0.5)\n", stdout); #ifdef ENABLE_PNG fputs(" --draw-mesh Also write the final lens-mesh overlay as _mesh.png\n", @@ -2387,9 +2458,10 @@ static void report_movie_timing_summary(const MovieTimingAccumulator *acc) { } /* Producer half of the async movie output: finishes every HDR-side step - * (sensor bloom, tone map, optional mesh overlay, optional frame log) and - * fills a job that carries only 8-bit RGB buffers. HDR can then be freed - * immediately after submit. */ + * (sensor bloom, tone map, optional RGBA overlay, optional frame log) and + * fills a job that carries one finished 8-bit RGB buffer plus an independent + * premultiplied RGBA8 overlay. HDR can then be freed immediately after + * submit and the writer never touches the live mesh. */ static int prepare_movie_output_job(const Settings *s, const FrameLensMesh *mesh, double *hdr, int width, int height, @@ -2420,13 +2492,15 @@ static int prepare_movie_output_job(const Settings *s, return -1; if (render_rgb8_image(s, hdr, width, height, &job->clean_rgb8, timing)) return -1; - if (paths->draw_mesh) { - frame_draw_mesh(mesh, hdr, width, height, 0.5, 0.5); - if (render_rgb8_image(s, hdr, width, height, &job->mesh_rgb8, timing)) { - free(job->clean_rgb8); - job->clean_rgb8 = NULL; - return -1; - } + if (paths->draw_mesh && + mesh_overlay_build_layer(mesh, width, height, &s->mesh_overlay, + &job->mesh_layer)) { + /* The producer still owns both allocations when this job is rejected, so + * release them here; the caller only frees the HDR buffer. */ + free(job->clean_rgb8); + job->clean_rgb8 = NULL; + mesh_overlay_layer_destroy(&job->mesh_layer); + return -1; } return 0; } @@ -2595,7 +2669,7 @@ static int render_movie(const Settings *s, StarCatalog *catalog, if (movie_output_queue_submit(&output_queue, &job, &queue_wait)) { /* The queue rejected the job; buffers still belong to this caller. */ free(job.clean_rgb8); - free(job.mesh_rgb8); + mesh_overlay_layer_destroy(&job.mesh_layer); goto done; } frame_timing.writer_queue_wait_seconds = queue_wait; @@ -2847,7 +2921,7 @@ static int render_lens_map(const Settings *s, StarCatalog *catalog) { double queue_wait = 0.0; if (movie_output_queue_submit(&output_queue, &job, &queue_wait)) { free(job.clean_rgb8); - free(job.mesh_rgb8); + mesh_overlay_layer_destroy(&job.mesh_layer); result = -1; break; } diff --git a/src/mesh_overlay.c b/src/mesh_overlay.c new file mode 100644 index 0000000..3375a03 --- /dev/null +++ b/src/mesh_overlay.c @@ -0,0 +1,506 @@ +#include "mesh_overlay.h" + +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------------- */ +/* Settings */ +/* ------------------------------------------------------------------------- */ + +MeshOverlaySettings mesh_overlay_default_settings(void) { + static const unsigned char defaults[MESH_OVERLAY_CATEGORY_COUNT][3] = { + {0x7F, 0x84, 0x9C}, /* ESCAPE Catppuccin Mocha overlay1 */ + {0xCB, 0xA6, 0xF7}, /* DARK mauve */ + {0xF9, 0xE2, 0xAF}, /* UNRESOLVED yellow */ + {0xF3, 0x8B, 0xA8}, /* INCOMPLETE red */ + {0x89, 0xB4, 0xFA}, /* UNTRACED blue */ + }; + MeshOverlaySettings settings; + memcpy(settings.colors, defaults, sizeof settings.colors); + settings.opacity = 0.5; + return settings; +} + +static int overlay_hex_nibble(char digit, unsigned char *value) { + if (digit >= '0' && digit <= '9') { + *value = (unsigned char)(digit - '0'); + return 0; + } + if (digit >= 'a' && digit <= 'f') { + *value = (unsigned char)(digit - 'a' + 10); + return 0; + } + if (digit >= 'A' && digit <= 'F') { + *value = (unsigned char)(digit - 'A' + 10); + return 0; + } + return -1; +} + +int mesh_overlay_parse_color(const char *text, unsigned char rgb[3]) { + if (text == NULL || rgb == NULL) + return -1; + if (strlen(text) != 7 || text[0] != '#') + return -1; + unsigned char parsed[3]; + for (int channel = 0; channel < 3; ++channel) { + unsigned char high, low; + if (overlay_hex_nibble(text[1 + 2 * channel], &high) || + overlay_hex_nibble(text[2 + 2 * channel], &low)) + return -1; + parsed[channel] = (unsigned char)((high << 4) | low); + } + rgb[0] = parsed[0]; + rgb[1] = parsed[1]; + rgb[2] = parsed[2]; + return 0; +} + +/* ------------------------------------------------------------------------- */ +/* Edge extraction */ +/* ------------------------------------------------------------------------- */ + +typedef struct { + size_t low; + size_t high; +} OverlayEdge; + +static int overlay_edge_compare(const void *lhs, const void *rhs) { + const OverlayEdge *a = lhs; + const OverlayEdge *b = rhs; + if (a->low != b->low) + return a->low < b->low ? -1 : 1; + if (a->high != b->high) + return a->high < b->high ? -1 : 1; + return 0; +} + +static unsigned char overlay_vertex_category(const LensVertex *vertex) { + if (!vertex->traced) + return (unsigned char)MESH_OVERLAY_CATEGORY_UNTRACED; + switch (vertex->outcome) { + case RAY_OUTCOME_ESCAPED: + return (unsigned char)MESH_OVERLAY_CATEGORY_ESCAPE; + case RAY_OUTCOME_DARK: + return (unsigned char)MESH_OVERLAY_CATEGORY_DARK; + case RAY_OUTCOME_UNRESOLVED: + return (unsigned char)MESH_OVERLAY_CATEGORY_UNRESOLVED; + case RAY_OUTCOME_INCOMPLETE: + default: + return (unsigned char)MESH_OVERLAY_CATEGORY_INCOMPLETE; + } +} + +int mesh_overlay_prepare(const FrameLensMesh *mesh, MeshOverlayLines *lines) { + if (lines == NULL) + return -1; + lines->lines = NULL; + lines->count = 0; + if (mesh == NULL) + return -1; + /* An overflowing triangle count is rejected before any pointer is + * dereferenced so a corrupt mesh cannot drive an out-of-bounds read. */ + if (mesh->triangle_count > SIZE_MAX / 3) + return -1; + if (mesh->triangle_count == 0) + return 0; + const size_t raw_count = mesh->triangle_count * 3; + if (raw_count > SIZE_MAX / sizeof(OverlayEdge)) + return -1; + if (mesh->triangles == NULL || mesh->vertices == NULL) + return -1; + + OverlayEdge *raw = malloc(raw_count * sizeof *raw); + if (raw == NULL) + return -1; + for (size_t triangle = 0; triangle < mesh->triangle_count; ++triangle) { + const LensTriangle *leaf = &mesh->triangles[triangle]; + for (int edge = 0; edge < 3; ++edge) { + const size_t from = leaf->vertex[edge]; + const size_t to = leaf->vertex[(edge + 1) % 3]; + if (from >= mesh->vertex_count || to >= mesh->vertex_count) { + free(raw); + return -1; + } + const LensVertex *a = &mesh->vertices[from]; + const LensVertex *b = &mesh->vertices[to]; + if (!isfinite(a->image_x) || !isfinite(a->image_y) || + !isfinite(b->image_x) || !isfinite(b->image_y)) { + free(raw); + return -1; + } + OverlayEdge *slot = &raw[3 * triangle + (size_t)edge]; + slot->low = from < to ? from : to; + slot->high = from < to ? to : from; + } + } + + qsort(raw, raw_count, sizeof *raw, overlay_edge_compare); + size_t unique = 0; + for (size_t i = 0; i < raw_count; ++i) { + if (unique == 0 || raw[unique - 1].low != raw[i].low || + raw[unique - 1].high != raw[i].high) + raw[unique++] = raw[i]; + } + if (unique > SIZE_MAX / sizeof(MeshOverlayLine)) { + free(raw); + return -1; + } + MeshOverlayLine *out = NULL; + if (unique != 0) { + out = malloc(unique * sizeof *out); + if (out == NULL) { + free(raw); + return -1; + } + } + for (size_t i = 0; i < unique; ++i) { + const LensVertex *a = &mesh->vertices[raw[i].low]; + const LensVertex *b = &mesh->vertices[raw[i].high]; + out[i].x0 = a->image_x; + out[i].y0 = a->image_y; + out[i].x1 = b->image_x; + out[i].y1 = b->image_y; + out[i].category0 = overlay_vertex_category(a); + out[i].category1 = overlay_vertex_category(b); + } + free(raw); + lines->lines = out; + lines->count = unique; + return 0; +} + +void mesh_overlay_lines_destroy(MeshOverlayLines *lines) { + if (lines == NULL) + return; + free(lines->lines); + lines->lines = NULL; + lines->count = 0; +} + +/* ------------------------------------------------------------------------- */ +/* Rasterization */ +/* ------------------------------------------------------------------------- */ + +static double overlay_fractional_part(double value) { return value - floor(value); } + +static void blend_overlay(unsigned char *pixels, int width, int height, int x, + int y, const unsigned char rgb[3], double alpha, + int rgba) { + if (alpha <= 0.0 || x < 0 || x >= width || y < 0 || y >= height) + return; + if (alpha > 1.0) + alpha = 1.0; + const int stride = rgba ? 4 : 3; + unsigned char *pixel = pixels + stride * ((size_t)y * (size_t)width + (size_t)x); + for (int channel = 0; channel < stride; ++channel) { + const double source = channel == 3 ? 255.0 : (double)rgb[channel]; + const double mixed = + (double)pixel[channel] * (1.0 - alpha) + source * alpha; + long value = lround(mixed); + if (value < 0) + value = 0; + if (value > 255) + value = 255; + pixel[channel] = (unsigned char)value; + } +} + +/* Liang-Barsky clip of the (major, minor) segment to the inclusive box. Keeps + * every subsequent cast and loop bounded even for huge finite coordinates. + * Returns 1 when a nonempty clipped segment remains, 0 when fully outside. The + * clipped outputs are guaranteed finite and inside the box before the caller + * casts them: a nonfinite interpolation result (cancellation) is skipped, and a + * finite roundoff overshoot is clamped back into the box. The segment direction + * keeps x0 <= x1 and the box clamp is monotone, so the order is preserved. */ +static int clip_overlay_segment(double *x0, double *y0, double *x1, double *y1, + double xmin, double xmax, double ymin, + double ymax) { + const double dx = *x1 - *x0; + const double dy = *y1 - *y0; + if (!isfinite(dx) || !isfinite(dy)) + return 0; + double t0 = 0.0, t1 = 1.0; + const double p[4] = {-dx, dx, -dy, dy}; + const double q[4] = {*x0 - xmin, xmax - *x0, *y0 - ymin, ymax - *y0}; + for (int i = 0; i < 4; ++i) { + if (p[i] == 0.0) { + if (q[i] < 0.0) + return 0; + } else { + const double r = q[i] / p[i]; + if (p[i] < 0.0) { + if (r > t1) + return 0; + if (r > t0) + t0 = r; + } else { + if (r < t0) + return 0; + if (r < t1) + t1 = r; + } + } + } + double nx0 = *x0 + t0 * dx; + double ny0 = *y0 + t0 * dy; + double nx1 = *x0 + t1 * dx; + double ny1 = *y0 + t1 * dy; + if (!isfinite(nx0) || !isfinite(ny0) || !isfinite(nx1) || !isfinite(ny1)) + return 0; + if (nx0 < xmin) + nx0 = xmin; + if (nx0 > xmax) + nx0 = xmax; + if (ny0 < ymin) + ny0 = ymin; + if (ny0 > ymax) + ny0 = ymax; + if (nx1 < xmin) + nx1 = xmin; + if (nx1 > xmax) + nx1 = xmax; + if (ny1 < ymin) + ny1 = ymin; + if (ny1 > ymax) + ny1 = ymax; + *x0 = nx0; + *y0 = ny0; + *x1 = nx1; + *y1 = ny1; + return 1; +} + +static void plot_overlay_aa(unsigned char *pixels, int width, int height, + int steep, int x, int y, double coverage, + const unsigned char rgb[3], double opacity, + int rgba) { + if (coverage > 0.0) + blend_overlay(pixels, width, height, steep ? y : x, steep ? x : y, rgb, + coverage * opacity, rgba); +} + +/* Xiaolin Wu line rasterization, one pixel wide, with a color that switches to + * the second endpoint category at the major-axis midpoint. A single pass + * colors the whole edge, so a midpoint pixel is never blended from both halves. */ +static void draw_overlay_line(unsigned char *pixels, int width, int height, + const MeshOverlayLine *line, + const MeshOverlaySettings *settings, int rgba) { + double x0 = line->x0, y0 = line->y0; + double x1 = line->x1, y1 = line->y1; + const unsigned char *first = settings->colors[line->category0]; + const unsigned char *second = settings->colors[line->category1]; + const int steep = fabs(y1 - y0) > fabs(x1 - x0); + if (steep) { + double swap = x0; + x0 = y0; + y0 = swap; + swap = x1; + x1 = y1; + y1 = swap; + } + if (x0 > x1) { + double swap = x0; + x0 = x1; + x1 = swap; + swap = y0; + y0 = y1; + y1 = swap; + const unsigned char *color_swap = first; + first = second; + second = color_swap; + } + /* The switch is fixed to the true midpoint of the unclipped edge. Halving + * each endpoint separately cannot overflow for finite same-sign endpoints. */ + const double midpoint = 0.5 * x0 + 0.5 * x1; + const int major_limit = steep ? height : width; + const int minor_limit = steep ? width : height; + double cx0 = x0, cy0 = y0, cx1 = x1, cy1 = y1; + if (!clip_overlay_segment(&cx0, &cy0, &cx1, &cy1, -1.0, (double)major_limit, + -1.0, (double)minor_limit)) + return; + x0 = cx0; + y0 = cy0; + x1 = cx1; + y1 = cy1; + const double dx = x1 - x0; + /* A zero-length edge has no coverage; do not turn it into a vertex dot. */ + if (!(dx > 0.0)) + return; + const double gradient = (y1 - y0) / dx; + /* After the steep/orientation normalization |gradient| <= 1, so it is finite + * for a finite nonzero dx; this guard keeps a pathological subnormal dx from + * ever reaching a float-to-int cast. */ + if (!isfinite(gradient)) + return; + const int first_column = (int)round(x0); + const int last_column = (int)round(x1); + if (first_column == last_column) { + /* Wu's two endpoint formulas overlap in the same column for a subpixel + * segment. Paint its length-weighted coverage once, rather than applying + * two alpha blends that make tiny edges brighter than full-length ones. */ + const double center_y = 0.5 * y0 + 0.5 * y1; + const int row = (int)floor(center_y); + const double fraction = overlay_fractional_part(center_y); + const unsigned char *color = first_column < midpoint ? first : second; + plot_overlay_aa(pixels, width, height, steep, first_column, row, + dx * (1.0 - fraction), color, settings->opacity, rgba); + plot_overlay_aa(pixels, width, height, steep, first_column, row + 1, + dx * fraction, color, settings->opacity, rgba); + return; + } + double x_end = (double)first_column; + double y_end = y0 + gradient * (x_end - x0); + if (!isfinite(y_end)) + return; + double x_gap = 1.0 - overlay_fractional_part(x0 + 0.5); + const int x_pixel_start = (int)x_end; + int y_pixel = (int)floor(y_end); + const unsigned char *start_color = (x_pixel_start < midpoint) ? first : second; + plot_overlay_aa(pixels, width, height, steep, x_pixel_start, y_pixel, + (1.0 - overlay_fractional_part(y_end)) * x_gap, start_color, + settings->opacity, rgba); + plot_overlay_aa(pixels, width, height, steep, x_pixel_start, y_pixel + 1, + overlay_fractional_part(y_end) * x_gap, start_color, + settings->opacity, rgba); + double inter_y = y_end + gradient; + x_end = (double)last_column; + y_end = y1 + gradient * (x_end - x1); + if (!isfinite(inter_y) || !isfinite(y_end)) + return; + x_gap = overlay_fractional_part(x1 + 0.5); + const int x_pixel_end = (int)x_end; + y_pixel = (int)floor(y_end); + const unsigned char *end_color = (x_pixel_end < midpoint) ? first : second; + plot_overlay_aa(pixels, width, height, steep, x_pixel_end, y_pixel, + (1.0 - overlay_fractional_part(y_end)) * x_gap, end_color, + settings->opacity, rgba); + plot_overlay_aa(pixels, width, height, steep, x_pixel_end, y_pixel + 1, + overlay_fractional_part(y_end) * x_gap, end_color, + settings->opacity, rgba); + for (int x = x_pixel_start + 1; x < x_pixel_end; ++x) { + y_pixel = (int)floor(inter_y); + const unsigned char *color = (x < midpoint) ? first : second; + plot_overlay_aa(pixels, width, height, steep, x, y_pixel, + 1.0 - overlay_fractional_part(inter_y), color, + settings->opacity, rgba); + plot_overlay_aa(pixels, width, height, steep, x, y_pixel + 1, + overlay_fractional_part(inter_y), color, + settings->opacity, rgba); + inter_y += gradient; + } +} + +static int draw_overlay(const MeshOverlayLines *lines, unsigned char *pixels, + int width, int height, + const MeshOverlaySettings *settings, int rgba) { + if (lines == NULL || pixels == NULL || settings == NULL) + return -1; + if (width <= 0 || height <= 0) + return -1; + /* Keep the raster's y+1 / x+1 and clipped-box endpoint casts strictly inside + * `int`, and keep the RGB8/RGBA8 byte count inside `size_t`. */ + if (width > INT_MAX - 2 || height > INT_MAX - 2) + return -1; + const size_t dim_width = (size_t)width; + const size_t dim_height = (size_t)height; + if (dim_width > SIZE_MAX / dim_height) + return -1; + const size_t pixel_count = dim_width * dim_height; + if (pixel_count > SIZE_MAX / (rgba ? 4 : 3)) + return -1; + if (!isfinite(settings->opacity) || settings->opacity < 0.0 || + settings->opacity > 1.0) + return -1; + /* A line batch larger than any allocatable MeshOverlayLine array cannot be + * real; reject it before dereferencing the array. */ + if (lines->count > SIZE_MAX / sizeof(MeshOverlayLine)) + return -1; + if (lines->count != 0 && lines->lines == NULL) + return -1; + /* Validate the whole batch before drawing so an invalid line cannot leave a + * partially painted image behind. Endpoint differences too large to + * represent (e.g. -DBL_MAX..+DBL_MAX) are rejected up front, before the + * midpoint or clipping math can produce a nonfinite value. */ + for (size_t i = 0; i < lines->count; ++i) { + const MeshOverlayLine *line = &lines->lines[i]; + if (line->category0 >= MESH_OVERLAY_CATEGORY_COUNT || + line->category1 >= MESH_OVERLAY_CATEGORY_COUNT) + return -1; + if (!isfinite(line->x0) || !isfinite(line->y0) || !isfinite(line->x1) || + !isfinite(line->y1)) + return -1; + if (!isfinite(line->x1 - line->x0) || !isfinite(line->y1 - line->y0)) + return -1; + } + for (size_t i = 0; i < lines->count; ++i) + draw_overlay_line(pixels, width, height, &lines->lines[i], settings, rgba); + return 0; +} + +int mesh_overlay_draw_rgb8(const MeshOverlayLines *lines, unsigned char *pixels, + int width, int height, + const MeshOverlaySettings *settings) { + return draw_overlay(lines, pixels, width, height, settings, 0); +} + +void mesh_overlay_layer_destroy(MeshOverlayLayer *layer) { + if (layer == NULL) + return; + free(layer->rgba); + *layer = (MeshOverlayLayer){0}; +} + +int mesh_overlay_build_layer(const FrameLensMesh *mesh, int width, int height, + const MeshOverlaySettings *settings, + MeshOverlayLayer *layer) { + if (layer == NULL) + return -1; + *layer = (MeshOverlayLayer){0}; + if (width <= 0 || height <= 0 || width > INT_MAX - 2 || + height > INT_MAX - 2 || (size_t)width > SIZE_MAX / (size_t)height || + (size_t)width * height > SIZE_MAX / 4 || settings == NULL || + !isfinite(settings->opacity) || settings->opacity < 0.0 || + settings->opacity > 1.0) + return -1; + MeshOverlayLines lines = {0}; + if (mesh_overlay_prepare(mesh, &lines)) + return -1; + unsigned char *pixels = calloc((size_t)width * height, 4); + const int result = pixels == NULL ? -1 : + draw_overlay(&lines, pixels, width, height, settings, 1); + mesh_overlay_lines_destroy(&lines); + if (result) { + free(pixels); + return -1; + } + layer->rgba = pixels; + layer->width = width; + layer->height = height; + return 0; +} + +int mesh_overlay_composite_rgb8(const MeshOverlayLayer *layer, + unsigned char *rgb8, int width, int height) { + if (layer == NULL || layer->rgba == NULL || rgb8 == NULL || width <= 0 || + height <= 0 || width != layer->width || height != layer->height || + (size_t)width > SIZE_MAX / (size_t)height || + (size_t)width * height > SIZE_MAX / 4) + return -1; + const size_t count = (size_t)width * height; + for (size_t pixel = 0; pixel < count; ++pixel) { + const unsigned char *source = &layer->rgba[4 * pixel]; + const unsigned int alpha = source[3]; + if (alpha == 0) + continue; + for (int channel = 0; channel < 3; ++channel) { + const unsigned int value = source[channel] + + (rgb8[3 * pixel + channel] * (255 - alpha) + 127) / 255; + rgb8[3 * pixel + channel] = value > 255 ? 255 : (unsigned char)value; + } + } + return 0; +} diff --git a/src/mesh_overlay.h b/src/mesh_overlay.h new file mode 100644 index 0000000..b27c728 --- /dev/null +++ b/src/mesh_overlay.h @@ -0,0 +1,103 @@ +#ifndef MESH_OVERLAY_H +#define MESH_OVERLAY_H + +#include "frame.h" + +#include + +/* Diagnostic overlay for a finalized lens mesh. It is a pure consumer of + * FrameLensMesh: it copies the coordinates it needs and never aliases the + * mutable mesh, so it can run alongside a writer without sharing state. + * + * Categories mirror the rendering terminal classes plus UNTRACED for a vertex + * that never received an endpoint (traced == 0). UNRESOLVED and INCOMPLETE are + * kept distinct so the overlay does not hide a retryable/completion shortfall + * behind the normal dark terminal. */ +enum MeshOverlayCategory { + MESH_OVERLAY_CATEGORY_ESCAPE = 0, + MESH_OVERLAY_CATEGORY_DARK, + MESH_OVERLAY_CATEGORY_UNRESOLVED, + MESH_OVERLAY_CATEGORY_INCOMPLETE, + MESH_OVERLAY_CATEGORY_UNTRACED, + MESH_OVERLAY_CATEGORY_COUNT +}; + +typedef struct { + /* Display-sRGB #RRGGBB color per category. Index by MeshOverlayCategory. */ + unsigned char colors[MESH_OVERLAY_CATEGORY_COUNT][3]; + /* Coverage multiplier in [0, 1] applied on top of the one-pixel AA weight. */ + double opacity; +} MeshOverlaySettings; + +/* One undirected mesh edge. (x0, y0) and (x1, y1) are the two endpoints in + * image-plane pixel coordinates; category0 belongs to endpoint 0 and + * category1 to endpoint 1. Each half of the edge carries the color of its + * adjacent vertex with an abrupt switch at the major-axis midpoint. */ +typedef struct { + double x0, y0, x1, y1; + unsigned char category0, category1; +} MeshOverlayLine; + +typedef struct { + MeshOverlayLine *lines; + size_t count; +} MeshOverlayLines; + +/* Owned display-sRGB overlay, byte order R,G,B,A with premultiplied RGB. + * Transparent pixels are all zero. No live mesh or palette is retained. */ +typedef struct { + unsigned char *rgba; + int width, height; +} MeshOverlayLayer; + +/* Allocate/rasterize a transparent layer from the unique mesh edges. The output + * must be empty; failures leave it empty. Temporary lines are freed before + * returning. The caller owns the layer until destroyed or submitted. */ +int mesh_overlay_build_layer(const FrameLensMesh *mesh, int width, int height, + const MeshOverlaySettings *settings, + MeshOverlayLayer *layer); +void mesh_overlay_layer_destroy(MeshOverlayLayer *layer); + +/* Composite a valid premultiplied layer onto RGB8 in place. Layer dimensions + * must match. Zero alpha leaves RGB bytes unchanged; the layer is read-only. */ +int mesh_overlay_composite_rgb8(const MeshOverlayLayer *layer, + unsigned char *rgb8, int width, int height); + +/* Catppuccin Mocha diagnostics palette with opacity 0.5: + * ESCAPE #7F849C, DARK #CBA6F7, UNRESOLVED #F9E2AF, INCOMPLETE #F38BA8, + * UNTRACED #89B4FA. */ +MeshOverlaySettings mesh_overlay_default_settings(void); + +/* Parse a strict `#RRGGBB` color into rgb[3]. Returns 0 on success and -1 for + * NULL arguments, a wrong length/prefix, or a non-hex digit. Both upper- and + * lower-case hex digits are accepted. */ +int mesh_overlay_parse_color(const char *text, unsigned char rgb[3]); + +/* Build the unique undirected edge set of the mesh's leaf triangles. Edges are + * canonicalized to (min vertex id, max vertex id), sorted and deduplicated so + * shared and boundary edges are emitted exactly once regardless of winding. + * Off-mesh probe witnesses have no triangle edge and are never emitted. + * Returns 0 on success (count may be 0) and -1 on invalid arguments, an + * out-of-range vertex index, a nonfinite coordinate, or allocation overflow. + * The output handle must be empty (zero-initialized or previously destroyed). + * On success the caller owns lines->lines and must release it with + * mesh_overlay_lines_destroy. */ +int mesh_overlay_prepare(const FrameLensMesh *mesh, MeshOverlayLines *lines); + +/* Release the edge array and reset the handle to empty. Safe on NULL. */ +void mesh_overlay_lines_destroy(MeshOverlayLines *lines); + +/* Rasterize the edges onto an interleaved RGB8 image (width*height*3 bytes), + * one pixel wide with Xiaolin Wu coverage AA, mixing directly in display sRGB + * with alpha = coverage * settings->opacity. Endpoint categories are chosen by + * position along the major axis relative to the midpoint; edges are clipped + * safely to the image so offscreen or huge coordinates cannot loop unbounded or + * overflow an integer conversion. Returns 0 on success and -1 on NULL + * arguments, a nonpositive size, an opacity outside [0, 1] or nonfinite, an + * out-of-range category, a nonfinite line coordinate/difference, or an + * overflowing image/batch size. Invalid batches are rejected before painting. */ +int mesh_overlay_draw_rgb8(const MeshOverlayLines *lines, unsigned char *pixels, + int width, int height, + const MeshOverlaySettings *settings); + +#endif diff --git a/src/movie_output.c b/src/movie_output.c index aa91121..86f90b4 100644 --- a/src/movie_output.c +++ b/src/movie_output.c @@ -18,8 +18,12 @@ static int movie_output_default_write(void *context, const MovieOutputJob *job, return -1; fprintf(stdout, "Rendered %zu images from %zu catalog stars to %s (ok%s)\n", job->images, job->catalog_stars, job->output_path, job->note); - if (job->draw_mesh && job->mesh_rgb8 != NULL) { - if (write_rgb8_image(job->mesh_path, job->mesh_rgb8, job->width, + /* The clean file is already written, so drawing in place on clean_rgb8 can + * never alter it; mesh_path receives the augmented buffer. */ + if (job->draw_mesh) { + if (mesh_overlay_composite_rgb8(&job->mesh_layer, job->clean_rgb8, job->width, + job->height) || + write_rgb8_image(job->mesh_path, job->clean_rgb8, job->width, job->height, settings)) return -1; fprintf(stdout, "Wrote mesh overlay image: %s\n", job->mesh_path); @@ -46,11 +50,11 @@ static void *movie_output_writer_main(void *opaque) { if (queue->count == 0 && queue->producer_done) break; const size_t slot = queue->head; - const MovieOutputJob job = queue->jobs[slot]; + MovieOutputJob job = queue->jobs[slot]; /* Ownership moved into the local copy; clear the slot so destroy() cannot * free the same buffers a second time. */ queue->jobs[slot].clean_rgb8 = NULL; - queue->jobs[slot].mesh_rgb8 = NULL; + queue->jobs[slot].mesh_layer = (MeshOverlayLayer){0}; queue->head = (queue->head + 1) % queue->capacity; --queue->count; pthread_cond_signal(&queue->not_full); @@ -72,7 +76,7 @@ static void *movie_output_writer_main(void *opaque) { } pthread_mutex_unlock(&queue->mutex); free(job.clean_rgb8); - free(job.mesh_rgb8); + mesh_overlay_layer_destroy(&job.mesh_layer); pthread_mutex_lock(&queue->mutex); } pthread_mutex_unlock(&queue->mutex); @@ -213,7 +217,7 @@ void movie_output_queue_destroy(MovieOutputQueue *queue) { if (queue->jobs != NULL) for (size_t i = 0; i < queue->capacity; ++i) { free(queue->jobs[i].clean_rgb8); - free(queue->jobs[i].mesh_rgb8); + mesh_overlay_layer_destroy(&queue->jobs[i].mesh_layer); } pthread_cond_destroy(&queue->not_empty); pthread_cond_destroy(&queue->not_full); diff --git a/src/movie_output.h b/src/movie_output.h index 0e1d890..585e54e 100644 --- a/src/movie_output.h +++ b/src/movie_output.h @@ -1,6 +1,7 @@ #ifndef MOVIE_OUTPUT_H #define MOVIE_OUTPUT_H +#include "mesh_overlay.h" #include "optics.h" #include @@ -10,13 +11,16 @@ /* Bounded, single-producer/single-writer movie output queue. * * The producer (the render loop) performs all HDR work and the tone map before - * submitting; a job therefore carries finished 8-bit RGB buffers, never a - * double HDR framebuffer. The writer thread encodes/writes them in submit - * order while the producer renders the next frame. + * submitting, and rasterizes the independent premultiplied RGBA8 mesh overlay. + * A job carries finished RGB8 and optional immutable RGBA8, never a double HDR + * framebuffer or a live mesh reference. The + * writer thread encodes/writes them in submit order while the producer renders + * the next frame. * - * Ownership contract for submit(): on success the queue owns clean_rgb8 and - * mesh_rgb8 and frees them after writing; on failure they remain owned by the - * caller. + * Ownership contract for submit(): on success the queue owns clean_rgb8 and the + * mesh_layer buffer and releases them after writing; on failure they remain + * owned by the caller, who must free(clean_rgb8) and call + * mesh_overlay_layer_destroy(&mesh_layer). * * `capacity` bounds the queued jobs only; the writer may additionally hold one * already-popped job, so the true in-memory bound is capacity + 1 jobs. With @@ -29,10 +33,14 @@ typedef struct { int draw_mesh; unsigned char *clean_rgb8; - unsigned char *mesh_rgb8; /* NULL when draw_mesh is false */ int width; int height; + /* Producer-rasterized, premultiplied display-sRGB RGBA8. The writer only + * composites it after writing the clean image; no mesh/palette is retained. + * Empty when draw_mesh is false. */ + MeshOverlayLayer mesh_layer; + size_t images; size_t catalog_stars; PsfSplatStats psf_stats; @@ -47,9 +55,10 @@ typedef struct { } MovieOutputJob; /* Optional custom writer. Returns 0 on success; the default writer writes - * clean_rgb8 to output_path and, when draw_mesh is set, mesh_rgb8 to - * mesh_path, then prints the "Rendered ... ()" and PSF lines. The queue - * owns and frees clean_rgb8/mesh_rgb8 after the writer returns. */ + * clean_rgb8 to output_path and, when draw_mesh is set, composites mesh_layer in + * place on clean_rgb8 before writing the augmented buffer to mesh_path. It + * then prints the "Rendered ... ()" and PSF lines. The queue owns and + * releases clean_rgb8 and mesh_layer after the writer returns. */ typedef int (*MovieOutputWriteFn)(void *context, const MovieOutputJob *job, const PngWriteSettings *settings); diff --git a/tests/test_camera_cli.py b/tests/test_camera_cli.py index 03f8295..71333c0 100644 --- a/tests/test_camera_cli.py +++ b/tests/test_camera_cli.py @@ -476,7 +476,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-', dir='/tmp/opencode') a '--observer-position', 0.3 * time, 0, 0, '--observer-velocity', 0.3, 0, 0, '--look-ra-deg', 0, '--look-dec-deg', 0, - '--lens-map-output', path, '--output', tmp / 'alcubierre.png') + '--lens-map-output', path, '--output', tmp / f'alcubierre.{ext}') assert struct.unpack_from(' @@ -1086,10 +1087,27 @@ int main(void) { if (!fast_ok) goto done; } - frame_draw_mesh(&mesh, hdr, width, height, 0.5, 0.5); - if (hdr[3 * (10 * width + 20)] != 0.25) { - fputs("mesh diagnostic overlay regression failed\n", stderr); - goto done; + /* Diagnostic overlay draws the finalized mesh as an sRGB8 edge map: the + * vertical coarse edge crossing (20, 10) must be painted, while an interior + * pixel away from every edge must stay at the background value. */ + { + MeshOverlayLines overlay_lines = {0}; + const MeshOverlaySettings overlay_settings = mesh_overlay_default_settings(); + unsigned char *overlay_rgb = calloc((size_t)width * height * 3, 1); + const int overlay_ok = + overlay_rgb != NULL && + mesh_overlay_prepare(&mesh, &overlay_lines) == 0 && + overlay_lines.count != 0 && + mesh_overlay_draw_rgb8(&overlay_lines, overlay_rgb, width, height, + &overlay_settings) == 0 && + overlay_rgb[3 * (10 * width + 20)] != 0 && + overlay_rgb[3 * (12 * width + 5)] == 0; + mesh_overlay_lines_destroy(&overlay_lines); + free(overlay_rgb); + if (!overlay_ok) { + fputs("mesh diagnostic overlay regression failed\n", stderr); + goto done; + } } /* A fixed absolute edge tolerance used to make tiny source triangles claim * sources far outside their field. */ diff --git a/tests/test_mesh_overlay.c b/tests/test_mesh_overlay.c new file mode 100644 index 0000000..27b2c6c --- /dev/null +++ b/tests/test_mesh_overlay.c @@ -0,0 +1,740 @@ +/* Standalone regression for the diagnostic mesh overlay (src/mesh_overlay.c). + * It links only the overlay module, so it needs neither a catalog, ray tracing, + * FFTW, nor an output writer. Every assertion targets observable behavior: + * terminal-category colors, half-edge switching, AA coverage, deduplication, + * deterministic ordering, clipping safety and clean failure on bad input. */ +#include "mesh_overlay.h" + +#include +#include +#include +#include +#include +#include +#include + +static int fail(const char *message) { + fprintf(stderr, "%s\n", message); + return -1; +} + +static LensVertex make_vertex(double x, double y, int traced, + RayOutcome outcome) { + LensVertex vertex; + memset(&vertex, 0, sizeof vertex); + vertex.image_x = x; + vertex.image_y = y; + vertex.traced = traced; + vertex.outcome = outcome; + return vertex; +} + +static int channel_at(const unsigned char *rgb, int width, int x, int y, + int channel) { + return rgb[3 * ((size_t)y * (size_t)width + (size_t)x) + (size_t)channel]; +} + +static int test_default_settings(void) { + const MeshOverlaySettings settings = mesh_overlay_default_settings(); + static const unsigned char expected[MESH_OVERLAY_CATEGORY_COUNT][3] = { + {0x7F, 0x84, 0x9C}, {0xCB, 0xA6, 0xF7}, {0xF9, 0xE2, 0xAF}, + {0xF3, 0x8B, 0xA8}, {0x89, 0xB4, 0xFA}}; + if (settings.opacity != 0.5) + return fail("default opacity is not 0.5"); + for (int category = 0; category < MESH_OVERLAY_CATEGORY_COUNT; ++category) + for (int channel = 0; channel < 3; ++channel) + if (settings.colors[category][channel] != expected[category][channel]) + return fail("default palette mismatch"); + return 0; +} + +static int test_parse_color(void) { + unsigned char rgb[3] = {1, 2, 3}; + if (mesh_overlay_parse_color("#7F849C", rgb) != 0 || rgb[0] != 0x7F || + rgb[1] != 0x84 || rgb[2] != 0x9C) + return fail("parse uppercase failed"); + if (mesh_overlay_parse_color("#7f849c", rgb) != 0 || rgb[0] != 0x7F || + rgb[1] != 0x84 || rgb[2] != 0x9C) + return fail("parse lowercase failed"); + if (mesh_overlay_parse_color("#000000", rgb) != 0 || rgb[0] || rgb[1] || + rgb[2]) + return fail("parse black failed"); + static const char *const bad[] = {"", "#", "7F849C", + "#7F849", "#7F849C0", "#GG849C", + "#7F84 9C", "#7F849c ", " #7F849C", + "#12345g", "#12345G0"}; + for (size_t i = 0; i < sizeof bad / sizeof *bad; ++i) + if (mesh_overlay_parse_color(bad[i], rgb) != -1) + return fail("accepted an invalid color string"); + if (mesh_overlay_parse_color(NULL, rgb) != -1) + return fail("accepted NULL text"); + if (mesh_overlay_parse_color("#7F849C", NULL) != -1) + return fail("accepted NULL output"); + return 0; +} + +/* Deduplication, canonical ordering and per-vertex category assignment. */ +static int test_prepare_dedup_categories(void) { + LensVertex vertices[4] = { + make_vertex(10, 10, 1, RAY_OUTCOME_ESCAPED), + make_vertex(30, 10, 1, RAY_OUTCOME_DARK), + make_vertex(10, 30, 1, RAY_OUTCOME_UNRESOLVED), + make_vertex(30, 30, 1, RAY_OUTCOME_INCOMPLETE)}; + LensTriangle triangles[2] = {{{0, 1, 2}, 0, 0, 0}, {{0, 2, 3}, 0, 0, 0}}; + FrameLensMesh mesh = {.vertices = vertices, + .vertex_count = 4, + .triangles = triangles, + .triangle_count = 2}; + MeshOverlayLines lines = {0}; + if (mesh_overlay_prepare(&mesh, &lines) != 0) + return fail("prepare failed on a valid quad"); + /* The shared diagonal (0,2) must appear exactly once. */ + if (lines.count != 5) { + mesh_overlay_lines_destroy(&lines); + return fail("unique edge count is not 5"); + } + static const struct { + unsigned char c0, c1; + } expected[5] = { + {MESH_OVERLAY_CATEGORY_ESCAPE, MESH_OVERLAY_CATEGORY_DARK}, + {MESH_OVERLAY_CATEGORY_ESCAPE, MESH_OVERLAY_CATEGORY_UNRESOLVED}, + {MESH_OVERLAY_CATEGORY_ESCAPE, MESH_OVERLAY_CATEGORY_INCOMPLETE}, + {MESH_OVERLAY_CATEGORY_DARK, MESH_OVERLAY_CATEGORY_UNRESOLVED}, + {MESH_OVERLAY_CATEGORY_UNRESOLVED, MESH_OVERLAY_CATEGORY_INCOMPLETE}}; + for (size_t i = 0; i < lines.count; ++i) + if (lines.lines[i].category0 != expected[i].c0 || + lines.lines[i].category1 != expected[i].c1) { + mesh_overlay_lines_destroy(&lines); + return fail("edge category or deterministic order mismatch"); + } + if (lines.lines[0].x0 != 10 || lines.lines[0].y0 != 10 || + lines.lines[0].x1 != 30 || lines.lines[0].y1 != 10) { + mesh_overlay_lines_destroy(&lines); + return fail("edge coordinates mismatch"); + } + /* Lines must copy coordinates, never alias the mutable mesh. */ + vertices[0].image_x = 999; + if (lines.lines[0].x0 != 10) { + mesh_overlay_lines_destroy(&lines); + return fail("overlay lines alias the live mesh"); + } + mesh_overlay_lines_destroy(&lines); + if (lines.lines != NULL || lines.count != 0) + return fail("destroy did not reset the handle"); + mesh_overlay_lines_destroy(NULL); + return 0; +} + +static int test_untraced_category(void) { + LensVertex vertices[3] = { + make_vertex(10, 10, 1, RAY_OUTCOME_ESCAPED), + /* traced == 0 must win over the stale outcome value. */ + make_vertex(30, 10, 0, RAY_OUTCOME_ESCAPED), + make_vertex(10, 30, 1, RAY_OUTCOME_INCOMPLETE)}; + LensTriangle triangle = {{0, 1, 2}, 0, 0, 0}; + FrameLensMesh mesh = {.vertices = vertices, + .vertex_count = 3, + .triangles = &triangle, + .triangle_count = 1}; + MeshOverlayLines lines = {0}; + if (mesh_overlay_prepare(&mesh, &lines) != 0) + return fail("prepare failed for untraced mesh"); + int saw_untraced = 0; + for (size_t i = 0; i < lines.count; ++i) + if (lines.lines[i].category0 == MESH_OVERLAY_CATEGORY_UNTRACED || + lines.lines[i].category1 == MESH_OVERLAY_CATEGORY_UNTRACED) + saw_untraced = 1; + mesh_overlay_lines_destroy(&lines); + return saw_untraced ? 0 : fail("untraced vertex category missing"); +} + +/* Boundary edges are emitted regardless of winding, and reversing the winding + * cannot change the deterministic output. */ +static int test_boundary_and_winding(void) { + LensVertex vertices[3] = { + make_vertex(10, 10, 1, RAY_OUTCOME_ESCAPED), + make_vertex(30, 10, 1, RAY_OUTCOME_DARK), + make_vertex(10, 30, 1, RAY_OUTCOME_UNRESOLVED)}; + LensTriangle forward[1] = {{{0, 1, 2}, 0, 0, 0}}; + LensTriangle reversed[1] = {{{2, 1, 0}, 0, 0, 0}}; + FrameLensMesh mesh_a = {.vertices = vertices, + .vertex_count = 3, + .triangles = forward, + .triangle_count = 1}; + FrameLensMesh mesh_b = {.vertices = vertices, + .vertex_count = 3, + .triangles = reversed, + .triangle_count = 1}; + MeshOverlayLines a = {0}, b = {0}; + if (mesh_overlay_prepare(&mesh_a, &a) != 0 || + mesh_overlay_prepare(&mesh_b, &b) != 0) { + mesh_overlay_lines_destroy(&a); + mesh_overlay_lines_destroy(&b); + return fail("prepare failed for single triangle"); + } + int ok = a.count == 3 && b.count == 3 && + memcmp(a.lines, b.lines, a.count * sizeof *a.lines) == 0; + mesh_overlay_lines_destroy(&a); + mesh_overlay_lines_destroy(&b); + return ok ? 0 : fail("boundary/winding determinism failed"); +} + +/* Off-mesh probe witnesses have no triangle edge and must never be emitted or + * drawn as a vertex dot. */ +static int test_isolated_witness_not_drawn(void) { + LensVertex vertices[4] = { + make_vertex(10, 50, 1, RAY_OUTCOME_ESCAPED), + make_vertex(30, 50, 1, RAY_OUTCOME_ESCAPED), + make_vertex(20, 30, 1, RAY_OUTCOME_ESCAPED), + make_vertex(90, 90, 1, RAY_OUTCOME_ESCAPED)}; + vertices[3].diagnostic_probe = 1; + vertices[3].probe_edge[0] = 0; + vertices[3].probe_edge[1] = 1; + LensTriangle triangle = {{0, 1, 2}, 0, 0, 0}; + FrameLensMesh mesh = {.vertices = vertices, + .vertex_count = 4, + .triangles = &triangle, + .triangle_count = 1}; + const int width = 100, height = 100; + MeshOverlayLines lines = {0}; + unsigned char *rgb = calloc((size_t)width * height * 3, 1); + const MeshOverlaySettings settings = mesh_overlay_default_settings(); + if (rgb == NULL || mesh_overlay_prepare(&mesh, &lines) != 0) { + free(rgb); + mesh_overlay_lines_destroy(&lines); + return fail("prepare failed for witness mesh"); + } + if (lines.count != 3) { + free(rgb); + mesh_overlay_lines_destroy(&lines); + return fail("isolated witness added an edge"); + } + if (mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) != 0) { + free(rgb); + mesh_overlay_lines_destroy(&lines); + return fail("draw failed for witness mesh"); + } + const int witness_painted = + channel_at(rgb, width, 90, 90, 0) != 0 || + channel_at(rgb, width, 90, 90, 1) != 0 || + channel_at(rgb, width, 90, 90, 2) != 0; + free(rgb); + mesh_overlay_lines_destroy(&lines); + return witness_painted ? fail("isolated witness vertex was drawn as a dot") + : 0; +} + +static int test_draw_category_colors(void) { + MeshOverlaySettings settings = mesh_overlay_default_settings(); + settings.opacity = 1.0; + const int width = 80, height = 40; + for (int category = 0; category < MESH_OVERLAY_CATEGORY_COUNT; ++category) { + unsigned char *rgb = calloc((size_t)width * height * 3, 1); + if (rgb == NULL) + return fail("allocation failed"); + MeshOverlayLine line = {.x0 = 10, + .y0 = 20, + .x1 = 70, + .y1 = 20, + .category0 = (unsigned char)category, + .category1 = (unsigned char)category}; + MeshOverlayLines lines = {.lines = &line, .count = 1}; + int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0; + for (int channel = 0; ok && channel < 3; ++channel) + if (channel_at(rgb, width, 40, 20, channel) != + settings.colors[category][channel]) + ok = 0; + free(rgb); + if (!ok) + return fail("category color mismatch"); + } + return 0; +} + +static int test_halves_and_switch(void) { + MeshOverlaySettings settings = mesh_overlay_default_settings(); + settings.opacity = 1.0; + const int width = 60, height = 40; + unsigned char *rgb = calloc((size_t)width * height * 3, 1); + if (rgb == NULL) + return fail("allocation failed"); + MeshOverlayLine line = {.x0 = 10, + .y0 = 20, + .x1 = 50, + .y1 = 20, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_DARK}; + MeshOverlayLines lines = {.lines = &line, .count = 1}; + int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0; + /* The switch sits at the major-axis midpoint x = 30. Interior pixels are + * fully covered, so each must equal exactly one endpoint color: the whole + * edge is rasterized once, never as two blends that would smear the switch. */ + for (int channel = 0; ok && channel < 3; ++channel) { + if (channel_at(rgb, width, 11, 20, channel) != + settings.colors[MESH_OVERLAY_CATEGORY_ESCAPE][channel] || + channel_at(rgb, width, 29, 20, channel) != + settings.colors[MESH_OVERLAY_CATEGORY_ESCAPE][channel] || + channel_at(rgb, width, 30, 20, channel) != + settings.colors[MESH_OVERLAY_CATEGORY_DARK][channel] || + channel_at(rgb, width, 49, 20, channel) != + settings.colors[MESH_OVERLAY_CATEGORY_DARK][channel]) + ok = 0; + } + free(rgb); + return ok ? 0 : fail("half-edge color switch failed"); +} + +static int test_antialiasing(void) { + MeshOverlaySettings settings = mesh_overlay_default_settings(); + settings.opacity = 1.0; + const int width = 60, height = 60; + unsigned char *rgb = calloc((size_t)width * height * 3, 1); + if (rgb == NULL) + return fail("allocation failed"); + MeshOverlayLine line = {.x0 = 10, + .y0 = 10, + .x1 = 50, + .y1 = 20, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_ESCAPE}; + MeshOverlayLines lines = {.lines = &line, .count = 1}; + int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0; + const int full = settings.colors[MESH_OVERLAY_CATEGORY_ESCAPE][0]; + int partial = 0; + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) { + const int value = channel_at(rgb, width, x, y, 0); + if (value > 0 && value < full) + ++partial; + } + free(rgb); + return ok && partial > 0 ? 0 : fail("no antialiased partial coverage"); +} + +static int test_subpixel_and_zero_length(void) { + MeshOverlaySettings settings = mesh_overlay_default_settings(); + settings.opacity = 1.0; + unsigned char pixels[8 * 8 * 3] = {0}; + MeshOverlayLine line = {.x0 = 2.1, .y0 = 3.0, .x1 = 2.4, .y1 = 3.0, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_ESCAPE}; + MeshOverlayLines lines = {.lines = &line, .count = 1}; + if (mesh_overlay_draw_rgb8(&lines, pixels, 8, 8, &settings)) + return fail("subpixel draw failed"); + for (int channel = 0; channel < 3; ++channel) + if (channel_at(pixels, 8, 2, 3, channel) != + lround((line.x1 - line.x0) * settings.colors[0][channel])) + return fail("subpixel edge applied overlapping endpoint blends"); + memset(pixels, 0, sizeof pixels); + line.x1 = line.x0; + if (mesh_overlay_draw_rgb8(&lines, pixels, 8, 8, &settings)) + return fail("zero-length draw failed"); + for (size_t i = 0; i < sizeof pixels; ++i) + if (pixels[i]) + return fail("zero-length edge became a vertex dot"); + return 0; +} + +static int test_high_white_background(void) { + const MeshOverlaySettings settings = mesh_overlay_default_settings(); + const int width = 80, height = 40; + unsigned char *rgb = malloc((size_t)width * height * 3); + if (rgb == NULL) + return fail("allocation failed"); + memset(rgb, 255, (size_t)width * height * 3); + MeshOverlayLine line = {.x0 = 10, + .y0 = 20, + .x1 = 60, + .y1 = 20, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_ESCAPE}; + MeshOverlayLines lines = {.lines = &line, .count = 1}; + int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0; + for (int channel = 0; ok && channel < 3; ++channel) { + const long expected = + lround(255.0 * (1.0 - settings.opacity) + + settings.colors[MESH_OVERLAY_CATEGORY_ESCAPE][channel] * + settings.opacity); + if (channel_at(rgb, width, 40, 20, channel) != expected) + ok = 0; + } + /* Still clearly visible against white. */ + if (ok && channel_at(rgb, width, 40, 20, 0) == 255) + ok = 0; + free(rgb); + return ok ? 0 : fail("overlay not visible on a high-white background"); +} + +static int test_opacity_extremes(void) { + const int width = 60, height = 40; + MeshOverlayLine line = {.x0 = 10, + .y0 = 20, + .x1 = 50, + .y1 = 20, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_ESCAPE}; + MeshOverlayLines lines = {.lines = &line, .count = 1}; + unsigned char *rgb = malloc((size_t)width * height * 3); + if (rgb == NULL) + return fail("allocation failed"); + MeshOverlaySettings settings = mesh_overlay_default_settings(); + + memset(rgb, 0x33, (size_t)width * height * 3); + settings.opacity = 0.0; + if (mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) != 0) { + free(rgb); + return fail("draw failed at opacity 0"); + } + for (int i = 0; i < width * height * 3; ++i) + if (rgb[i] != 0x33) { + free(rgb); + return fail("opacity 0 changed the image"); + } + + memset(rgb, 0x00, (size_t)width * height * 3); + settings.opacity = 1.0; + int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0; + for (int channel = 0; ok && channel < 3; ++channel) + if (channel_at(rgb, width, 30, 20, channel) != + settings.colors[MESH_OVERLAY_CATEGORY_ESCAPE][channel]) + ok = 0; + free(rgb); + return ok ? 0 : fail("opacity 1 did not apply the full color"); +} + +static int test_clipping_and_huge_coordinates(void) { + const MeshOverlaySettings settings = mesh_overlay_default_settings(); + const int width = 64, height = 64; + unsigned char *rgb = calloc((size_t)width * height * 3, 1); + if (rgb == NULL) + return fail("allocation failed"); + /* A horizontal line far beyond both image edges must still paint row 30 and + * terminate in bounded time. */ + MeshOverlayLine huge = {.x0 = -1e15, + .y0 = 30, + .x1 = 1e15, + .y1 = 30, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_DARK}; + MeshOverlayLines lines = {.lines = &huge, .count = 1}; + int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0; + int painted = 0; + for (int x = 0; x < width; ++x) + if (channel_at(rgb, width, x, 30, 0) != 0) + painted = 1; + if (!ok || !painted) { + free(rgb); + return fail("huge coordinate line was not clipped into view"); + } + /* A fully offscreen line leaves the buffer untouched. */ + memset(rgb, 0, (size_t)width * height * 3); + MeshOverlayLine offscreen = {.x0 = 1000, + .y0 = 1000, + .x1 = 2000, + .y1 = 1000, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_ESCAPE}; + lines.lines = &offscreen; + if (mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) != 0) { + free(rgb); + return fail("offscreen line returned an error"); + } + for (int i = 0; i < width * height * 3; ++i) + if (rgb[i] != 0) { + free(rgb); + return fail("offscreen line painted the image"); + } + /* A huge diagonal must not overflow the integer conversions. */ + MeshOverlayLine diagonal = {.x0 = -1e12, + .y0 = -1e12, + .x1 = 1e12, + .y1 = 1e12, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_DARK}; + lines.lines = &diagonal; + if (mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) != 0) { + free(rgb); + return fail("huge diagonal returned an error"); + } + free(rgb); + return 0; +} + +static int test_extreme_magnitudes(void) { + const MeshOverlaySettings settings = mesh_overlay_default_settings(); + const int width = 64, height = 64; + const size_t bytes = (size_t)width * height * 3; + unsigned char *rgb = malloc(bytes); + if (rgb == NULL) + return fail("allocation failed"); + + /* -DBL_MAX..+DBL_MAX overflows the endpoint difference to infinity: the whole + * batch must be rejected before painting and the buffer left untouched. */ + MeshOverlayLine bad = {.x0 = -DBL_MAX, + .y0 = 10, + .x1 = DBL_MAX, + .y1 = 10, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_ESCAPE}; + MeshOverlayLines lines = {.lines = &bad, .count = 1}; + memset(rgb, 0x5A, bytes); + int rejected = + mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == -1; + int unchanged = 1; + for (size_t i = 0; i < bytes; ++i) + if (rgb[i] != 0x5A) + unchanged = 0; + if (!rejected || !unchanged) { + free(rgb); + return fail("+-DBL_MAX x-delta not rejected unchanged"); + } + bad.x0 = 10; + bad.x1 = 10; + bad.y0 = -DBL_MAX; + bad.y1 = DBL_MAX; + memset(rgb, 0x5A, bytes); + rejected = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == -1; + unchanged = 1; + for (size_t i = 0; i < bytes; ++i) + if (rgb[i] != 0x5A) + unchanged = 0; + if (!rejected || !unchanged) { + free(rgb); + return fail("+-DBL_MAX y-delta not rejected unchanged"); + } + + /* Same-sign DBL_MAX endpoints have a finite difference and a finite (non + * overflowing) midpoint; the segment is entirely offscreen, so it is skipped + * safely without painting. */ + MeshOverlayLine same = {.x0 = DBL_MAX, + .y0 = 10, + .x1 = DBL_MAX, + .y1 = 30, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_DARK}; + lines.lines = &same; + memset(rgb, 0x5A, bytes); + if (mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) != 0) { + free(rgb); + return fail("same-sign DBL_MAX segment was not handled safely"); + } + for (size_t i = 0; i < bytes; ++i) + if (rgb[i] != 0x5A) { + free(rgb); + return fail("offscreen same-sign DBL_MAX segment painted the image"); + } + free(rgb); + return 0; +} + +static int test_clipped_midpoint_uses_original(void) { + MeshOverlaySettings settings = mesh_overlay_default_settings(); + settings.opacity = 1.0; + const int width = 64, height = 40; + unsigned char *rgb = calloc((size_t)width * height * 3, 1); + if (rgb == NULL) + return fail("allocation failed"); + /* The original midpoint is -40, so the entire visible span [0, 19] lies in + * the second half and every visible pixel must use category1. A midpoint + * recomputed from the clipped endpoints would wrongly color the left half. */ + MeshOverlayLine line = {.x0 = -100, + .y0 = 20, + .x1 = 20, + .y1 = 20, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_DARK}; + MeshOverlayLines lines = {.lines = &line, .count = 1}; + int ok = mesh_overlay_draw_rgb8(&lines, rgb, width, height, &settings) == 0; + static const int probes[] = {0, 1, 5, 10, 19}; + for (size_t p = 0; ok && p < sizeof probes / sizeof *probes; ++p) + for (int channel = 0; channel < 3; ++channel) + if (channel_at(rgb, width, probes[p], 20, channel) != + settings.colors[MESH_OVERLAY_CATEGORY_DARK][channel]) + ok = 0; + free(rgb); + return ok ? 0 + : fail("clipped edge did not use the original midpoint category"); +} + +static int test_invalid_arguments(void) { + MeshOverlaySettings settings = mesh_overlay_default_settings(); + unsigned char pixels[4 * 4 * 3] = {0}; + MeshOverlayLines lines = {0}; + + if (mesh_overlay_prepare(NULL, &lines) != -1) + return fail("prepare accepted NULL mesh"); + if (mesh_overlay_prepare(NULL, NULL) != -1) + return fail("prepare accepted NULL lines"); + FrameLensMesh empty = {0}; + if (mesh_overlay_prepare(&empty, &lines) != 0 || lines.count != 0 || + lines.lines != NULL) + return fail("empty mesh did not produce an empty edge set"); + + LensVertex vertices[3] = { + make_vertex(10, 10, 1, RAY_OUTCOME_ESCAPED), + make_vertex(30, 10, 1, RAY_OUTCOME_ESCAPED), + make_vertex(10, 30, 1, RAY_OUTCOME_ESCAPED)}; + LensTriangle triangle = {{0, 1, 2}, 0, 0, 0}; + FrameLensMesh mesh = {.vertices = vertices, + .vertex_count = 2, /* vertex 2 is out of range */ + .triangles = &triangle, + .triangle_count = 1}; + if (mesh_overlay_prepare(&mesh, &lines) != -1) + return fail("prepare accepted an out-of-range vertex"); + mesh.vertex_count = 3; + vertices[2].image_x = NAN; + if (mesh_overlay_prepare(&mesh, &lines) != -1) + return fail("prepare accepted a nonfinite coordinate"); + vertices[2].image_x = 10; + mesh.triangles = NULL; + if (mesh_overlay_prepare(&mesh, &lines) != -1) + return fail("prepare accepted NULL triangles"); + FrameLensMesh overflow = {0}; + overflow.triangle_count = SIZE_MAX; /* > SIZE_MAX / 3, rejected before use */ + if (mesh_overlay_prepare(&overflow, &lines) != -1) + return fail("prepare accepted an overflowing triangle count"); + + MeshOverlayLine line = {.x0 = 1, + .y0 = 1, + .x1 = 3, + .y1 = 1, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_ESCAPE}; + MeshOverlayLines one = {.lines = &line, .count = 1}; + if (mesh_overlay_draw_rgb8(NULL, pixels, 4, 4, &settings) != -1) + return fail("draw accepted NULL lines"); + if (mesh_overlay_draw_rgb8(&one, NULL, 4, 4, &settings) != -1) + return fail("draw accepted NULL pixels"); + if (mesh_overlay_draw_rgb8(&one, pixels, 0, 4, &settings) != -1) + return fail("draw accepted zero width"); + if (mesh_overlay_draw_rgb8(&one, pixels, 4, -1, &settings) != -1) + return fail("draw accepted negative height"); + if (mesh_overlay_draw_rgb8(&one, pixels, 4, 4, NULL) != -1) + return fail("draw accepted NULL settings"); + MeshOverlayLines null_lines = {.lines = NULL, .count = 1}; + if (mesh_overlay_draw_rgb8(&null_lines, pixels, 4, 4, &settings) != -1) + return fail("draw accepted a NULL line array with a nonzero count"); + /* Dimensions too near INT_MAX would overflow the raster's y+1/x+1 casts. */ + if (mesh_overlay_draw_rgb8(&one, pixels, INT_MAX, 1, &settings) != -1) + return fail("draw accepted a width near INT_MAX"); + if (mesh_overlay_draw_rgb8(&one, pixels, 1, INT_MAX, &settings) != -1) + return fail("draw accepted a height near INT_MAX"); + /* An unallocatable line count must be rejected before the array dereference. */ + MeshOverlayLines overflow_lines = {.lines = &line, .count = SIZE_MAX}; + if (mesh_overlay_draw_rgb8(&overflow_lines, pixels, 4, 4, &settings) != -1) + return fail("draw accepted an overflowing line count"); + + MeshOverlaySettings bad = settings; + bad.opacity = NAN; + if (mesh_overlay_draw_rgb8(&one, pixels, 4, 4, &bad) != -1) + return fail("draw accepted NaN opacity"); + bad.opacity = 1.5; + if (mesh_overlay_draw_rgb8(&one, pixels, 4, 4, &bad) != -1) + return fail("draw accepted opacity above 1"); + bad.opacity = -0.1; + if (mesh_overlay_draw_rgb8(&one, pixels, 4, 4, &bad) != -1) + return fail("draw accepted negative opacity"); + + MeshOverlayLine bad_category = {.x0 = 1, + .y0 = 1, + .x1 = 3, + .y1 = 1, + .category0 = 99, + .category1 = MESH_OVERLAY_CATEGORY_ESCAPE}; + MeshOverlayLines bad_lines = {.lines = &bad_category, .count = 1}; + if (mesh_overlay_draw_rgb8(&bad_lines, pixels, 4, 4, &settings) != -1) + return fail("draw accepted an out-of-range category"); + MeshOverlayLine bad_coord = {.x0 = NAN, + .y0 = 1, + .x1 = 3, + .y1 = 1, + .category0 = MESH_OVERLAY_CATEGORY_ESCAPE, + .category1 = MESH_OVERLAY_CATEGORY_ESCAPE}; + bad_lines.lines = &bad_coord; + if (mesh_overlay_draw_rgb8(&bad_lines, pixels, 4, 4, &settings) != -1) + return fail("draw accepted a nonfinite coordinate"); + + MeshOverlayLines none = {.lines = NULL, .count = 0}; + if (mesh_overlay_draw_rgb8(&none, pixels, 4, 4, &settings) != 0) + return fail("draw failed on an empty edge set"); + return 0; +} + +static int test_rgba_layer(void) { + LensVertex vertices[3] = { + make_vertex(2, 4, 1, RAY_OUTCOME_ESCAPED), + make_vertex(12, 4, 1, RAY_OUTCOME_ESCAPED), + make_vertex(2, 12, 1, RAY_OUTCOME_ESCAPED)}; + LensTriangle triangles[2] = {{{0, 1, 2}, 0, 0, 0}, + {{2, 1, 0}, 0, 0, 0}}; + FrameLensMesh mesh = {.vertices = vertices, .vertex_count = 3, + .triangles = triangles, .triangle_count = 1}; + MeshOverlaySettings settings = mesh_overlay_default_settings(); + settings.opacity = 0.5; + MeshOverlayLayer layer = {0}, duplicate = {0}; + if (mesh_overlay_build_layer(&mesh, 16, 16, &settings, &layer)) + return fail("RGBA layer build failed"); + int ok = layer.width == 16 && layer.height == 16; + const unsigned char *on_edge = &layer.rgba[4 * (4 * 16 + 5)]; + for (int c = 0; c < 3; ++c) + ok &= on_edge[c] == lround(settings.colors[0][c] * settings.opacity); + ok &= on_edge[3] == 128; + for (size_t p = 0; p < 16 * 16; ++p) + for (int c = 0; c < 3; ++c) + ok &= layer.rgba[4 * p + c] <= layer.rgba[4 * p + 3]; + mesh.triangle_count = 2; + ok &= mesh_overlay_build_layer(&mesh, 16, 16, &settings, &duplicate) == 0; + if (duplicate.rgba != NULL) + ok &= memcmp(layer.rgba, duplicate.rgba, 16 * 16 * 4) == 0; + unsigned char image[16 * 16 * 3]; + memset(image, 255, sizeof image); + ok &= mesh_overlay_composite_rgb8(&layer, image, 16, 16) == 0; + for (int c = 0; c < 3; ++c) + ok &= image[3 * (4 * 16 + 5) + c] == on_edge[c] + 127; + ok &= image[0] == 255; /* no overlay coverage, not a full-image gray tint */ + mesh_overlay_layer_destroy(&layer); + mesh_overlay_layer_destroy(&duplicate); + ok &= layer.rgba == NULL && layer.width == 0 && layer.height == 0; + settings.opacity = 0.0; + ok &= mesh_overlay_build_layer(&mesh, 16, 16, &settings, &layer) == 0; + if (layer.rgba != NULL) + for (size_t i = 0; i < 16 * 16 * 4; ++i) + ok &= layer.rgba[i] == 0; + unsigned char before[sizeof image]; + memcpy(before, image, sizeof image); + ok &= mesh_overlay_composite_rgb8(&layer, image, 16, 16) == 0; + ok &= memcmp(before, image, sizeof image) == 0; + ok &= mesh_overlay_composite_rgb8(&layer, image, 15, 16) == -1; + mesh_overlay_layer_destroy(&layer); + ok &= mesh_overlay_build_layer(&mesh, 0, 16, &settings, &layer) == -1; + ok &= mesh_overlay_build_layer(&mesh, INT_MAX, INT_MAX, &settings, &layer) == -1; + vertices[0].image_x = NAN; + ok &= mesh_overlay_build_layer(&mesh, 16, 16, &settings, &layer) == -1; + ok &= layer.rgba == NULL; + return ok ? 0 : fail("RGBA premultiplication/composition/ownership regression"); +} + +static int test_rgba_composition_extremes(void) { + unsigned char rgba[] = {0, 0, 0, 0, 20, 40, 60, 255, 10, 20, 30, 128}; + const MeshOverlayLayer layer = {.rgba = rgba, .width = 3, .height = 1}; + unsigned char rgb[] = {100, 110, 120, 100, 100, 100, 100, 100, 100}; + const unsigned char expected[] = {100, 110, 120, 20, 40, 60, 60, 70, 80}; + return mesh_overlay_composite_rgb8(&layer, rgb, 3, 1) == 0 && + memcmp(rgb, expected, sizeof rgb) == 0 + ? 0 : fail("RGBA transparent/opaque/partial-alpha composition"); +} + +int main(void) { + if (test_default_settings() || test_parse_color() || + test_prepare_dedup_categories() || test_untraced_category() || + test_boundary_and_winding() || test_isolated_witness_not_drawn() || + test_draw_category_colors() || test_halves_and_switch() || + test_antialiasing() || test_subpixel_and_zero_length() || + test_high_white_background() || + test_opacity_extremes() || test_clipping_and_huge_coordinates() || + test_extreme_magnitudes() || test_clipped_midpoint_uses_original() || + test_invalid_arguments() || test_rgba_layer() || + test_rgba_composition_extremes()) + return 1; + return 0; +} diff --git a/tests/test_mesh_overlay_cli.py b/tests/test_mesh_overlay_cli.py new file mode 100644 index 0000000..bbe78dd --- /dev/null +++ b/tests/test_mesh_overlay_cli.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Production CLI regression for post-tone-map mesh settings and replay.""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +BUILD = Path(sys.argv[1] if len(sys.argv) > 1 else 'build/Release').resolve() +BINARY = BUILD / 'minkowski_sky' +ENV = dict(os.environ, OMP_NUM_THREADS='4') +TMP = Path('/tmp/opencode') +TMP.mkdir(parents=True, exist_ok=True) +COLORS = ['escape', 'dark', 'unresolved', 'incomplete', 'untraced'] +COMMON = ['--catalog', 'assets/sky_grid_5deg.csv', '--width', '32', '--height', + '24', '--coarse-cell-pixels', '8', '--refine-max-level', '0', + '--exposure', '1e-3', '--psf-relative-tail', '1e-4'] + + +def run(*args, ok=True): + result = subprocess.run([str(BINARY), *map(str, args)], env=ENV, + capture_output=True, text=True) + assert (result.returncode == 0) == ok, (args, result.stdout, result.stderr) + return result + + +help_text = run('--help').stdout +ext = 'png' if '.png' in help_text else 'ppm' +for category in COLORS: + assert f'--mesh-color-{category}' in help_text + for value in ['red', '123456', '#12345', '#1234567', '#GG1122', '']: + result = run(f'--mesh-color-{category}', value, ok=False) + assert result.stderr and 'Rendered' not in result.stdout + run(f'--mesh-color-{category}', ok=False) +for value in ['nan', 'inf', '-inf', '-0.1', '1.1', 'junk']: + run('--mesh-opacity', value, ok=False) +run('--mesh-opacity', ok=False) + +custom = [] +for category, color in zip(COLORS, ['#123ABC', '#ABC123', '#AA5533', + '#1122EE', '#112233']): + custom += [f'--mesh-color-{category}', color] +custom += ['--mesh-opacity', '0.85'] + +with tempfile.TemporaryDirectory(prefix='mesh-cli-', dir=TMP) as directory: + tmp = Path(directory) + baseline = tmp / f'baseline.{ext}' + run(*COMMON, '--output', baseline) + configured = tmp / f'configured.{ext}' + run(*COMMON, *custom, '--output', configured) + assert baseline.read_bytes() == configured.read_bytes() + assert not (tmp / f'configured_mesh.{ext}').exists() + + for opacity in [0, 1]: + output = tmp / f'opacity{opacity}.{ext}' + run(*COMMON, *custom, '--mesh-opacity', opacity, '--draw-mesh', + '--output', output) + assert output.read_bytes() == baseline.read_bytes() + mesh = tmp / f'opacity{opacity}_mesh.{ext}' + assert (mesh.read_bytes() == baseline.read_bytes()) == (opacity == 0) + + single_map = tmp / 'single.grlens' + single = tmp / f'single.{ext}' + run(*COMMON, *custom, '--draw-mesh', '--lens-map-output', single_map, + '--output', single) + replay = tmp / f'replay.{ext}' + run('--catalog', 'assets/sky_grid_5deg.csv', '--exposure', '1e-3', + '--psf-relative-tail', '1e-4', *custom, '--draw-mesh', + '--lens-map-input', single_map, '--output', replay) + assert single.read_bytes() == replay.read_bytes() + assert (tmp / f'single_mesh.{ext}').read_bytes() == (tmp / f'replay_mesh.{ext}').read_bytes() + + track = tmp / 'track.csv' + run('--write-minkowski-accel-track', track, '--duration', 2, '--fps', 4, + '--proper-acceleration', 0.1) + movie_dir, replay_dir = tmp / 'movie', tmp / 'movie_replay' + movie_dir.mkdir() + replay_dir.mkdir() + movie_map = tmp / 'movie.grlens' + run(*COMMON, *custom, '--draw-mesh', '--observer-track', track, + '--duration', 1, '--fps', 1, '--frames-dir', movie_dir, + '--frames-prefix', 'frame', '--lens-map-output', movie_map) + run('--catalog', 'assets/sky_grid_5deg.csv', '--exposure', '1e-3', + '--psf-relative-tail', '1e-4', *custom, '--draw-mesh', + '--lens-map-input', movie_map, '--frames-dir', replay_dir, + '--frames-prefix', 'frame') + expected_names = {f'frame_{i:06d}{suffix}.{ext}' + for i in range(2) for suffix in ['', '_mesh']} + assert {p.name for p in movie_dir.iterdir()} == expected_names + assert {p.name for p in replay_dir.iterdir()} == expected_names + for name in expected_names: + assert (movie_dir / name).read_bytes() == (replay_dir / name).read_bytes(), name + +print('mesh-overlay CLI checks passed: colors/opacity, clean fidelity, single/movie replay') diff --git a/tests/test_movie_output.c b/tests/test_movie_output.c index 63a10d0..9392bab 100644 --- a/tests/test_movie_output.c +++ b/tests/test_movie_output.c @@ -2,12 +2,14 @@ #include "movie_output.h" +#include #include #include #include #include #include #include +#include #include #include @@ -24,6 +26,15 @@ static void check(int condition, const char *message) { } } +/* Scratch data lives in the pre-approved OpenCode scratch directory rather + * than directly under /tmp. */ +#define TEST_TMP_ROOT "/tmp/opencode" +#ifdef ENABLE_PNG +#define TEST_IMAGE_EXT "png" +#else +#define TEST_IMAGE_EXT "ppm" +#endif + #define MAX_JOBS 64 #define JOB_WIDTH 16 #define JOB_HEIGHT 16 @@ -35,6 +46,9 @@ typedef struct { size_t count; size_t order[MAX_JOBS]; unsigned char pixels[MAX_JOBS][JOB_BYTES]; + int mesh_draw_flags[MAX_JOBS]; + size_t mesh_pixel_counts[MAX_JOBS]; + unsigned char layers[MAX_JOBS][JOB_WIDTH * JOB_HEIGHT * 4]; } MockWriter; static int mock_write(void *context, const MovieOutputJob *job, @@ -44,6 +58,12 @@ static int mock_write(void *context, const MovieOutputJob *job, if (mock->count < MAX_JOBS) { mock->order[mock->count] = job->frame_id; memcpy(mock->pixels[mock->count], job->clean_rgb8, JOB_BYTES); + mock->mesh_draw_flags[mock->count] = job->draw_mesh; + mock->mesh_pixel_counts[mock->count] = job->mesh_layer.rgba == NULL ? 0 : + (size_t)job->mesh_layer.width * job->mesh_layer.height; + if (job->mesh_layer.rgba != NULL) + memcpy(mock->layers[mock->count], job->mesh_layer.rgba, + sizeof mock->layers[mock->count]); } ++mock->count; if (mock->delay_ms > 0) { @@ -76,6 +96,22 @@ static MovieOutputJob make_job(size_t frame_id) { return job; } +/* An independent premultiplied RGBA8 fixture with sparse nonzero pixels. */ +static int make_layer(MeshOverlayLayer *layer, size_t count) { + *layer = (MeshOverlayLayer){0}; + layer->rgba = calloc(JOB_WIDTH * JOB_HEIGHT, 4); + if (layer->rgba == NULL) + return -1; + layer->width = JOB_WIDTH; + layer->height = JOB_HEIGHT; + for (size_t i = 0; i < count; ++i) { + for (int channel = 0; channel < 3; ++channel) + layer->rgba[4 * i + channel] = (unsigned char)((i * 19 + channel * 3) % 129); + layer->rgba[4 * i + 3] = 128; + } + return 0; +} + /* Order and pixel fidelity for capacity 1 and 2. */ static void test_order_and_pixels(size_t capacity) { MovieOutputQueue queue; @@ -146,7 +182,9 @@ static void test_backpressure(void) { } /* Writer failure at frame N must propagate, unblock the producer, and join - * cleanly without losing the ownership contract. */ + * cleanly. RGBA layers are attached to every job so both ownership paths + * are exercised: the queue releases accepted jobs, while the caller releases + * the job rejected after the failure is recorded. */ static void test_writer_failure(void) { MovieOutputQueue queue; MockWriter mock; @@ -161,9 +199,16 @@ static void test_writer_failure(void) { int saw_failure = 0; for (size_t f = 0; f < 8; ++f) { MovieOutputJob job = make_job(f); - if (movie_output_queue_submit(&queue, &job, NULL)) { - /* The queue no longer owns these buffers. */ + job.draw_mesh = 1; + if (make_layer(&job.mesh_layer, 3)) { free(job.clean_rgb8); + check(0, "failure: layer allocation"); + break; + } + if (movie_output_queue_submit(&queue, &job, NULL)) { + /* The queue no longer owns these resources. */ + free(job.clean_rgb8); + mesh_overlay_layer_destroy(&job.mesh_layer); saw_failure = 1; break; } @@ -193,6 +238,76 @@ static void test_empty_paths(void) { movie_output_queue_destroy(&queue); } +/* The queued job owns an independent RGBA layer: rasterizing a heap mesh, then + * releasing that source mesh before the writer runs, must not disturb the + * pixels the writer observes. */ +static void test_lines_survive_source_release(void) { + LensVertex *vertices = calloc(3, sizeof *vertices); + LensTriangle *triangles = calloc(1, sizeof *triangles); + if (vertices == NULL || triangles == NULL) { + free(vertices); + free(triangles); + check(0, "source release: allocation"); + return; + } + vertices[0] = (LensVertex){.image_x = 1.5, + .image_y = 1.5, + .outcome = RAY_OUTCOME_ESCAPED, + .traced = 1}; + vertices[1] = (LensVertex){.image_x = JOB_WIDTH - 2.5, + .image_y = 2.5, + .outcome = RAY_OUTCOME_DARK, + .traced = 1}; + vertices[2] = (LensVertex){.image_x = 5.5, + .image_y = JOB_HEIGHT - 2.5, + .outcome = RAY_OUTCOME_INCOMPLETE, + .traced = 0}; + triangles[0].vertex[0] = 0; + triangles[0].vertex[1] = 1; + triangles[0].vertex[2] = 2; + FrameLensMesh mesh = {.vertices = vertices, + .vertex_count = 3, + .triangles = triangles, + .triangle_count = 1}; + MeshOverlayLayer layer = {0}; + const MeshOverlaySettings overlay = mesh_overlay_default_settings(); + const int prepared = mesh_overlay_build_layer(&mesh, JOB_WIDTH, JOB_HEIGHT, + &overlay, &layer) == 0; + /* Release the source mesh (and its vertex/triangle arrays) before submit. */ + free(vertices); + free(triangles); + check(prepared, "source release: overlay extraction"); + + MovieOutputQueue queue; + MockWriter mock; + memset(&mock, 0, sizeof mock); + mock.fail_at = -1; + const PngWriteSettings settings = {-1}; + if (movie_output_queue_init(&queue, 1, &settings)) { + check(0, "source release: queue init"); + mesh_overlay_layer_destroy(&layer); + return; + } + movie_output_queue_set_writer(&queue, mock_write, &mock); + MovieOutputJob job = make_job(0); + job.draw_mesh = 1; + job.mesh_layer = layer; /* ownership transferred to the job/queue */ + int submitted = job.clean_rgb8 != NULL && + movie_output_queue_submit(&queue, &job, NULL) == 0; + if (!submitted) { + free(job.clean_rgb8); + mesh_overlay_layer_destroy(&job.mesh_layer); + } + check(submitted && movie_output_queue_finish(&queue) == 0, + "source release: submit/finish"); + check(mock.count == 1 && mock.mesh_draw_flags[0] == 1 && + mock.mesh_pixel_counts[0] == JOB_WIDTH * JOB_HEIGHT && + memcmp(mock.layers[0], (unsigned char[JOB_WIDTH * JOB_HEIGHT * 4]){0}, + sizeof mock.layers[0]) != 0, + "source release: writer saw the intact RGBA overlay"); + movie_output_queue_destroy(&queue); +} + #ifdef ENABLE_PNG static int decode_png_rgb8(const char *path, unsigned char *out, int width, int height) { @@ -216,12 +331,12 @@ static int decode_png_rgb8(const char *path, unsigned char *out, int width, return ok ? 0 : -1; } -/* The default writer's clean and mesh files must decode to the submitted - * payloads. */ -static void test_default_writer_success(void) { - char directory[] = "/tmp/movie_output_XXXXXX"; +/* The default writer's clean file must decode unchanged and its mesh sibling + * must equal the core overlay drawn in place on that same clean payload. */ +static void test_default_writer_mesh(void) { + char directory[] = TEST_TMP_ROOT "/movie_output_XXXXXX"; if (mkdtemp(directory) == NULL) { - check(0, "default success: mkdtemp"); + check(0, "default mesh: mkdtemp"); return; } char clean_path[PATH_MAX]; @@ -231,7 +346,7 @@ static void test_default_writer_success(void) { const PngWriteSettings settings = {-1}; MovieOutputQueue queue; if (movie_output_queue_init(&queue, 1, &settings)) { - check(0, "default success: queue init"); + check(0, "default mesh: queue init"); rmdir(directory); return; } @@ -239,29 +354,35 @@ static void test_default_writer_success(void) { job.draw_mesh = 1; snprintf(job.output_path, sizeof job.output_path, "%s", clean_path); snprintf(job.mesh_path, sizeof job.mesh_path, "%s", mesh_path); - job.mesh_rgb8 = make_rgb8(99); - int submitted = job.clean_rgb8 != NULL && job.mesh_rgb8 != NULL && + int layer_ok = make_layer(&job.mesh_layer, 5) == 0; + unsigned char *clean_expected = make_rgb8(0); + unsigned char mesh_expected[JOB_BYTES]; + int expected_ok = layer_ok && clean_expected != NULL; + if (expected_ok) { + memcpy(mesh_expected, clean_expected, JOB_BYTES); + expected_ok = mesh_overlay_composite_rgb8(&job.mesh_layer, mesh_expected, + JOB_WIDTH, JOB_HEIGHT) == 0; + } + int submitted = expected_ok && job.clean_rgb8 != NULL && movie_output_queue_submit(&queue, &job, NULL) == 0; if (!submitted) { free(job.clean_rgb8); - free(job.mesh_rgb8); + mesh_overlay_layer_destroy(&job.mesh_layer); } check(submitted && movie_output_queue_finish(&queue) == 0, - "default success: submit/finish"); + "default mesh: submit/finish"); unsigned char clean_decoded[JOB_BYTES]; unsigned char mesh_decoded[JOB_BYTES]; - unsigned char *clean_expected = make_rgb8(0); - unsigned char *mesh_expected = make_rgb8(99); const int decoded_ok = - clean_expected != NULL && mesh_expected != NULL && decode_png_rgb8(clean_path, clean_decoded, JOB_WIDTH, JOB_HEIGHT) == 0 && decode_png_rgb8(mesh_path, mesh_decoded, JOB_WIDTH, JOB_HEIGHT) == 0; - check(decoded_ok && memcmp(clean_decoded, clean_expected, JOB_BYTES) == 0, - "default success: clean pixels"); - check(decoded_ok && memcmp(mesh_decoded, mesh_expected, JOB_BYTES) == 0, - "default success: mesh pixels"); + check(decoded_ok && clean_expected != NULL && + memcmp(clean_decoded, clean_expected, JOB_BYTES) == 0, + "default mesh: clean image unchanged"); + check(decoded_ok && expected_ok && + memcmp(mesh_decoded, mesh_expected, JOB_BYTES) == 0, + "default mesh: mesh matches core overlay result"); free(clean_expected); - free(mesh_expected); unlink(clean_path); unlink(mesh_path); rmdir(directory); @@ -269,38 +390,101 @@ static void test_default_writer_success(void) { } #endif -/* The default writer must fail under a real filesystem error. */ -static void test_default_writer_failure(void) { +/* Failure while writing the clean image happens before any overlay work; the + * queue must still release the job's resources. */ +static void test_default_writer_failure_before_overlay(void) { MovieOutputQueue queue; const PngWriteSettings settings = {-1}; if (movie_output_queue_init(&queue, 1, &settings)) { - check(0, "default failure: queue init"); + check(0, "failure before overlay: queue init"); return; } MovieOutputJob job = make_job(0); + job.draw_mesh = 1; + if (make_layer(&job.mesh_layer, 4)) { + free(job.clean_rgb8); + check(0, "failure before overlay: layer allocation"); + movie_output_queue_destroy(&queue); + return; + } snprintf(job.output_path, sizeof job.output_path, - "/nonexistent-directory-xyz/frame.png"); + "/nonexistent-directory-xyz/frame." TEST_IMAGE_EXT); + snprintf(job.mesh_path, sizeof job.mesh_path, + "/nonexistent-directory-xyz/frame_mesh." TEST_IMAGE_EXT); if (job.clean_rgb8 == NULL || movie_output_queue_submit(&queue, &job, NULL)) { free(job.clean_rgb8); - check(0, "default failure: submit"); + mesh_overlay_layer_destroy(&job.mesh_layer); + check(0, "failure before overlay: submit"); movie_output_queue_destroy(&queue); return; } check(movie_output_queue_finish(&queue) != 0, - "default failure: finish reports unwritable path"); + "failure before overlay: clean write error propagates"); + movie_output_queue_destroy(&queue); +} + +/* Failure while writing the mesh image happens after the clean file is written + * and the overlay is drawn; that later error must also propagate. */ +static void test_default_writer_failure_after_overlay(void) { + char directory[] = TEST_TMP_ROOT "/movie_output_XXXXXX"; + if (mkdtemp(directory) == NULL) { + check(0, "failure after overlay: mkdtemp"); + return; + } + char clean_path[PATH_MAX]; + snprintf(clean_path, sizeof clean_path, "%s/frame_000000." TEST_IMAGE_EXT, + directory); + MovieOutputQueue queue; + const PngWriteSettings settings = {-1}; + if (movie_output_queue_init(&queue, 1, &settings)) { + check(0, "failure after overlay: queue init"); + rmdir(directory); + return; + } + MovieOutputJob job = make_job(0); + job.draw_mesh = 1; + if (make_layer(&job.mesh_layer, 4)) { + free(job.clean_rgb8); + check(0, "failure after overlay: layer allocation"); + rmdir(directory); + movie_output_queue_destroy(&queue); + return; + } + snprintf(job.output_path, sizeof job.output_path, "%s", clean_path); + snprintf(job.mesh_path, sizeof job.mesh_path, + "/nonexistent-directory-xyz/frame_mesh." TEST_IMAGE_EXT); + if (job.clean_rgb8 == NULL || movie_output_queue_submit(&queue, &job, NULL)) { + free(job.clean_rgb8); + mesh_overlay_layer_destroy(&job.mesh_layer); + check(0, "failure after overlay: submit"); + rmdir(directory); + movie_output_queue_destroy(&queue); + return; + } + check(movie_output_queue_finish(&queue) != 0, + "failure after overlay: mesh write error propagates"); + unlink(clean_path); + rmdir(directory); movie_output_queue_destroy(&queue); } int main(void) { + /* Standalone/CI runs need not have an OpenCode-created scratch directory. */ + if (mkdir(TEST_TMP_ROOT, 0700) != 0 && errno != EEXIST) { + perror("create movie-output test scratch directory"); + return 1; + } test_order_and_pixels(1); test_order_and_pixels(2); test_backpressure(); test_writer_failure(); test_empty_paths(); + test_lines_survive_source_release(); #ifdef ENABLE_PNG - test_default_writer_success(); + test_default_writer_mesh(); #endif - test_default_writer_failure(); + test_default_writer_failure_before_overlay(); + test_default_writer_failure_after_overlay(); if (failures != 0) { fprintf(stderr, "%d movie-output failure(s)\n", failures); return 1; diff --git a/usage.md b/usage.md index fada54f..241943d 100644 --- a/usage.md +++ b/usage.md @@ -614,13 +614,45 @@ the sum of producer frame times only (it excludes tracing, prefetch, and the final queue drain). The all-sky `Movie catalog prefetch:` line reports mark, load+commit, and total tile time. Timing uses one clock read per bulk phase, never inside the per-star or per-pixel hot loops. +With `--draw-mesh`, tone mapping runs only once per frame. The async writer +writes the clean RGB8 image first, then composites the producer-rasterized +premultiplied RGBA8 layer in place and writes the mesh sibling. Mesh preparation +and rasterization are included in the producer's frame total; composition and +image output are included in the writer summary. Pass `--draw-mesh` to also write the final image-plane triangle mesh as a `_mesh.png` sibling (`.ppm` in non-PNG builds). The main tone-mapped image and any `--hdr-output` FITS file remain mesh-free. The -overlay alpha-composites image-plane triangle edges as one-pixel-wide 0.5 -linear-gray diagnostic lines at 0.5 opacity. The line rasterizer uses -coverage-based antialiasing. +overlay alpha-composites one-pixel-wide, coverage-antialiased triangle edges +onto the final sRGB8 image **after** sensor bloom, tone mapping, and the sRGB +transfer, preserving mesh contrast on saturated highlights. Each vertex colors +its incident half-edges; differently classified endpoints switch color at the +edge midpoint. Shared edges are drawn once. The premultiplied sRGB RGBA8 layer +uses source-over accumulation and composition, with the same rules for +single-frame, movie, and replay output. + +The default palette is Catppuccin Mocha, with opacity `0.5`: + +| Vertex category | Default color | CLI override | +| --- | --- | --- | +| `ESCAPED` | Overlay1 `#7F849C` (gray) | `--mesh-color-escape` | +| `DARK` | Mauve `#CBA6F7` (purple) | `--mesh-color-dark` | +| `UNRESOLVED` | Yellow `#F9E2AF` | `--mesh-color-unresolved` | +| `INCOMPLETE` | Red `#F38BA8` | `--mesh-color-incomplete` | +| Untraced | Blue `#89B4FA` | `--mesh-color-untraced` | + +Color arguments are strict sRGB `#RRGGBB` values; quote them in the shell. +`--mesh-opacity` accepts a finite number in `[0,1]`. These settings do not +implicitly enable `--draw-mesh`. For example: + +```sh +--draw-mesh --mesh-color-dark '#CBA6F7' --mesh-color-unresolved '#F9E2AF' --mesh-opacity 0.8 +``` + +`UNRESOLVED` denotes trustworthy trajectories with exhausted compute budgets +(not just accepted-step limits), while `INCOMPLETE` denotes actual history, +domain, metric, integration, I/O, or protocol failures. Different dark reasons +share one color. Coloring is a read-only visualization of the finalized mesh. Normal progress and summaries go to stdout; warnings, errors, and Debug diagnostics go to stderr. Successful runs exit `0` even if warnings are emitted.