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.
This commit is contained in:
wyj committed 2026-10-10 01:46:32 -04:00
1 parent 4053d5d0c8
commit 80f9dcb3a3
17 files changed
+1873 -182

No files matched your search

+16 -5
View File
@@ -36,7 +36,7 @@ TARGET_BASENAME := $(SPACETIME)_sky
OBJECT_DIR := $(BUILD_DIR)/obj/$(SPACETIME) OBJECT_DIR := $(BUILD_DIR)/obj/$(SPACETIME)
CORE_MINKOWSKI_SOURCES := $(COMMON_SOURCES) src/spacetime_minkowski.c 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)) ifneq ($(filter 0 1,$(PSF_EVENT_SINK)),$(PSF_EVENT_SINK))
$(error Unknown PSF_EVENT_SINK '$(PSF_EVENT_SINK)'; choose 0 or 1) $(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 MOVIE_OUTPUT_TEST_TARGET := $(TEST_OUT_DIR)/test_movie_output
SENSOR_BLOOM_TEST_TARGET := $(TEST_OUT_DIR)/test_sensor_bloom SENSOR_BLOOM_TEST_TARGET := $(TEST_OUT_DIR)/test_sensor_bloom
SENSOR_BLOOM_BENCH_TARGET := $(TEST_OUT_DIR)/benchmark_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) ifeq ($(PSF_BACKEND),hip)
TARGET := $(BUILD_DIR)/$(TARGET_BASENAME)_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) $(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 $@ $(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. # 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) $(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/optics.c $(CPU_FFTW_SOURCES) $(LDLIBS) -o $@ $(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) $(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 $@ $(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) $(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 $@ $(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. # The FFTW-vs-spatial test is meaningful only in the CPU PSF build.
ifneq ($(CPU_FFTW_SOURCES),) ifneq ($(CPU_FFTW_SOURCES),)
FAST_PSF_FFTW_TEST_DEP := $(FAST_PSF_FFTW_TEST_TARGET) FAST_PSF_FFTW_TEST_DEP := $(FAST_PSF_FFTW_TEST_TARGET)
@@ -293,7 +299,7 @@ FAST_PSF_FFTW_TEST_DEP :=
FAST_PSF_FFTW_TEST_RUN := FAST_PSF_FFTW_TEST_RUN :=
endif 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_minkowski
$(TEST_OUT_DIR)/test_observer_schwarzschild $(TEST_OUT_DIR)/test_observer_schwarzschild
$(TEST_TARGET) $(TEST_TARGET)
@@ -312,10 +318,12 @@ test: $(CAMERA_TEST_TARGETS) $(TEST_TARGET) $(ADAPTIVE_GEODESIC_TEST_TARGET) $(A
$(TONE_MAP_TEST_TARGET) $(TONE_MAP_TEST_TARGET)
$(MOVIE_OUTPUT_TEST_TARGET) $(MOVIE_OUTPUT_TEST_TARGET)
$(SENSOR_BLOOM_TEST_TARGET) $(SENSOR_BLOOM_TEST_TARGET)
$(MESH_OVERLAY_TEST_TARGET)
python3 tests/test_camera_cli.py $(BUILD_DIR) $(TEST_OUT_DIR) 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_adaptive_cli.py $(BUILD_DIR) $(TEST_OUT_DIR)
python3 tests/test_ray_diagnostics.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_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: $(TONE_MAP_TEST_TARGET)
$(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: $(SENSOR_BLOOM_TEST_TARGET)
$(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) sensor-bloom-bench: $(SENSOR_BLOOM_BENCH_TARGET)
fast-psf-fftw-bench: $(FAST_PSF_FFTW_BENCH_TARGET) fast-psf-fftw-bench: $(FAST_PSF_FFTW_BENCH_TARGET)
+4
View File
@@ -192,6 +192,10 @@ tone-mapped image; `--draw-mesh` additionally writes the final image-plane
triangles, so one command produces both triangles, so one command produces both
`output/imgs/schwarzschild_test_grid.png` (no mesh) and `output/imgs/schwarzschild_test_grid.png` (no mesh) and
`output/imgs/schwarzschild_test_grid_mesh.png` (mesh overlay). `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 ```sh
mkdir -p output/imgs mkdir -p output/imgs
+1
View File
@@ -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`(网格叠加)。 这个示例使用 `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 ```sh
mkdir -p output/imgs mkdir -p output/imgs
+11 -4
View File
@@ -1363,11 +1363,18 @@ immutable 的 cache,不再逐帧做 tile 扫描、prefetch OpenMP 区域或打
multi-frame map 与 observer movie 走同一 union 路径。 multi-frame map 与 observer movie 走同一 union 路径。
movie PNG 编码/写盘由一个单 producer、单 writer 的有界队列(默认容量 2)承担, movie PNG 编码/写盘由一个单 producer、单 writer 的有界队列(默认容量 2)承担,
与下一帧渲染重叠。producer 在 enqueue 前完成 sensor bloom、clean RGB8 与可选 与下一帧渲染重叠。producer 完成 sensor bloom、tone mapping 和 sRGB 转换,
mesh overlay RGB8 转换,job 只持有 8-bit buffer,HDR 在 submit 后即可释放。 生成 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 writer 的首个错误持久保存,使后续 submit 立即失败;`finish()` drain 已接受 job
后 join writer,所有退出路径都必须 join,绝不为求重叠而提前打印 `Rendered ... ok`。 后 join writer,所有退出路径都必须 join,绝不为求重叠而提前打印 `Rendered ... ok`。
诊断网格是最终 mesh 的只读可视化:一像素抗锯齿边按端点终态分别着色相邻半边,
在中点切换颜色。合成位于 tone mapping 与 sRGB transfer 之后,clean 图与 HDR
保持独立。调色与透明度配置见 [`usage.md`](usage.md)。
fast mode 的单星精度由 deposit 模式与 `N` 决定:`nearest` 的格点间距是 fast mode 的单星精度由 deposit 模式与 `N` 决定:`nearest` 的格点间距是
每轴 `1/N` 个输出像素,单帧瞬时舍入误差至多是 `1/(2N)`;在 每轴 `1/N` 个输出像素,单帧瞬时舍入误差至多是 `1/(2N)`;在
`--psf-fwhm-pixels` 不变时它与图像分辨率无关,只有增大 `N`(或使用保持 `--psf-fwhm-pixels` 不变时它与图像分辨率无关,只有增大 `N`(或使用保持
@@ -1987,8 +1994,8 @@ map 共用同一入口。每个 RGB 通道独立、各向同性地把超过有
\(E\) 使用 exposure 之后的 renderer-scale 线性 HDR;\(e\) 同时决定每轮保留传播的 \(E\) 使用 exposure 之后的 renderer-scale 线性 HDR;\(e\) 同时决定每轮保留传播的
比例和有效传播距离;模型允许信号损失,不守恒。原始 `--hdr-output` FITS 在模型 比例和有效传播距离;模型允许信号损失,不守恒。原始 `--hdr-output` FITS 在模型
运行前写出,因此始终是 bloom 前的 PSF HDR;tone-mapped PNG/PPM 与视频帧在模型 运行前写出,因此始终是 bloom 前的 PSF HDR;tone-mapped PNG/PPM 与视频帧在模型
之后写出。视觉式多尺度 bloom 不在当前范围内。mesh overlay 在模型之后绘制, 之后写出。视觉式多尺度 bloom 不在当前范围内。mesh 诊断层在 tone mapping 与
不参与溢出传播。 sRGB transfer 后合成。
--- ---
-94
View File
@@ -2543,100 +2543,6 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
return images; 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) { void frame_lens_mesh_destroy(FrameLensMesh *mesh) {
if (mesh == NULL) if (mesh == NULL)
return; return;
-2
View File
@@ -303,8 +303,6 @@ size_t frame_splat_catalog(const FrameLensMesh *mesh,
const FrameSplatProgress *progress, const FrameSplatProgress *progress,
FastPsfAccumulator *fast, FastPsfAccumulator *fast,
MovieFrameTiming *timing); 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); void frame_lens_mesh_destroy(FrameLensMesh *mesh);
#endif #endif
+98 -24
View File
@@ -1,6 +1,7 @@
#include "catalog.h" #include "catalog.h"
#include "frame.h" #include "frame.h"
#include "lens_map.h" #include "lens_map.h"
#include "mesh_overlay.h"
#include "movie.h" #include "movie.h"
#include "movie_output.h" #include "movie_output.h"
#include "observer_track.h" #include "observer_track.h"
@@ -89,6 +90,7 @@ typedef struct {
int retry_lookback_increment_specified, max_total_lookback_time_specified; int retry_lookback_increment_specified, max_total_lookback_time_specified;
ToneMapSettings tone_map; ToneMapSettings tone_map;
PngWriteSettings png; PngWriteSettings png;
MeshOverlaySettings mesh_overlay;
int sensor_bloom_enabled; int sensor_bloom_enabled;
int sensor_bloom_limit_specified; int sensor_bloom_limit_specified;
int sensor_bloom_transfer_specified; int sensor_bloom_transfer_specified;
@@ -223,6 +225,17 @@ static int parse_sensor_bloom_transfer(const char *text, double *value) {
: 0; : 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) { static int validate_tonemapped_output_path(const char *path) {
const size_t path_length = strlen(path); const size_t path_length = strlen(path);
#ifdef ENABLE_PNG #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, /* Canonical output order for every frame: clean HDR, clean tone-mapped image,
* then the mesh overlay. The overlay reuses the already-consumed HDR buffer, * then the mesh overlay. The overlay reuses the already-written RGB8 buffer,
* so no second full-size framebuffer is allocated and nothing is re-rendered. */ * so no second full-size framebuffer or tone-map conversion is needed. */
/* Optional sensor bloom applied to the post-exposure linear HDR before the /* Optional sensor bloom applied to the post-exposure linear HDR before the
* tone map. Shared by the synchronous writer and the async movie producer. */ * tone map. Shared by the synchronous writer and the async movie producer. */
static int apply_sensor_bloom(const Settings *s, double *hdr, int width, 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 = const int write_result =
write_rgb8_timed(s, paths->output_path, rgb8, width, height, timing); write_rgb8_timed(s, paths->output_path, rgb8, width, height, timing);
free(rgb8);
fprintf(write_result == 0 ? stdout : stderr, fprintf(write_result == 0 ? stdout : stderr,
"Rendered %zu images from %zu catalog stars to %s (%s%s)\n", "Rendered %zu images from %zu catalog stars to %s (%s%s)\n",
images, stars, paths->output_path, images, stars, paths->output_path,
write_result == 0 ? "ok" : "write failed", note); write_result == 0 ? "ok" : "write failed", note);
if (write_result) if (write_result) {
free(rgb8);
return -1; 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) { if (paths->draw_mesh) {
frame_draw_mesh(mesh, hdr, width, height, 0.5, 0.5); MeshOverlayLayer layer = {0};
unsigned char *mesh_rgb8 = NULL; int mesh_result = mesh_overlay_build_layer(mesh, width, height,
if (render_rgb8_image(s, hdr, width, height, &mesh_rgb8, timing) || &s->mesh_overlay, &layer);
write_rgb8_timed(s, paths->mesh_path, mesh_rgb8, width, height, if (mesh_result == 0)
timing)) { mesh_result = mesh_overlay_composite_rgb8(&layer, rgb8, width, height);
free(mesh_rgb8); 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", fprintf(stderr, "Failed to write mesh overlay image: %s\n",
paths->mesh_path); paths->mesh_path);
return -1; return -1;
} }
free(mesh_rgb8);
fprintf(stdout, "Wrote mesh overlay image: %s\n", paths->mesh_path); fprintf(stdout, "Wrote mesh overlay image: %s\n", paths->mesh_path);
} }
free(rgb8);
return 0;
}
/* Maps a --mesh-color-<category> 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; 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. */ * radius-30 camera would sit outside the active domain. */
s->observer_radius = 15.0; s->observer_radius = 15.0;
#endif #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; *write_path = NULL;
int tone_map_p_specified = 0; int tone_map_p_specified = 0;
enum MeshOverlayCategory mesh_category;
for (int i = 1; i < argc; ++i) { for (int i = 1; i < argc; ++i) {
if (!strcmp(argv[i], "--catalog") && i + 1 < argc) if (!strcmp(argv[i], "--catalog") && i + 1 < argc)
s->catalog_path = argv[++i]; 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)) { !parse_positive(argv[++i], &s->refinement.min_area_pixels2)) {
} else if (!strcmp(argv[i], "--draw-mesh")) { } else if (!strcmp(argv[i], "--draw-mesh")) {
s->draw_mesh = 1; 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")) { } else if (!strcmp(argv[i], "--allow-incomplete")) {
s->allow_incomplete = 1; s->allow_incomplete = 1;
} else if (!strcmp(argv[i], "--fov-deg") && i + 1 < argc && } 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-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-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-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); stdout);
#ifdef ENABLE_PNG #ifdef ENABLE_PNG
fputs(" --draw-mesh Also write the final lens-mesh overlay as <output-stem>_mesh.png\n", fputs(" --draw-mesh Also write the final lens-mesh overlay as <output-stem>_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 /* Producer half of the async movie output: finishes every HDR-side step
* (sensor bloom, tone map, optional mesh overlay, optional frame log) and * (sensor bloom, tone map, optional RGBA overlay, optional frame log) and
* fills a job that carries only 8-bit RGB buffers. HDR can then be freed * fills a job that carries one finished 8-bit RGB buffer plus an independent
* immediately after submit. */ * 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, static int prepare_movie_output_job(const Settings *s,
const FrameLensMesh *mesh, double *hdr, const FrameLensMesh *mesh, double *hdr,
int width, int height, int width, int height,
@@ -2420,13 +2492,15 @@ static int prepare_movie_output_job(const Settings *s,
return -1; return -1;
if (render_rgb8_image(s, hdr, width, height, &job->clean_rgb8, timing)) if (render_rgb8_image(s, hdr, width, height, &job->clean_rgb8, timing))
return -1; return -1;
if (paths->draw_mesh) { if (paths->draw_mesh &&
frame_draw_mesh(mesh, hdr, width, height, 0.5, 0.5); mesh_overlay_build_layer(mesh, width, height, &s->mesh_overlay,
if (render_rgb8_image(s, hdr, width, height, &job->mesh_rgb8, timing)) { &job->mesh_layer)) {
free(job->clean_rgb8); /* The producer still owns both allocations when this job is rejected, so
job->clean_rgb8 = NULL; * release them here; the caller only frees the HDR buffer. */
return -1; free(job->clean_rgb8);
} job->clean_rgb8 = NULL;
mesh_overlay_layer_destroy(&job->mesh_layer);
return -1;
} }
return 0; 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)) { if (movie_output_queue_submit(&output_queue, &job, &queue_wait)) {
/* The queue rejected the job; buffers still belong to this caller. */ /* The queue rejected the job; buffers still belong to this caller. */
free(job.clean_rgb8); free(job.clean_rgb8);
free(job.mesh_rgb8); mesh_overlay_layer_destroy(&job.mesh_layer);
goto done; goto done;
} }
frame_timing.writer_queue_wait_seconds = queue_wait; 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; double queue_wait = 0.0;
if (movie_output_queue_submit(&output_queue, &job, &queue_wait)) { if (movie_output_queue_submit(&output_queue, &job, &queue_wait)) {
free(job.clean_rgb8); free(job.clean_rgb8);
free(job.mesh_rgb8); mesh_overlay_layer_destroy(&job.mesh_layer);
result = -1; result = -1;
break; break;
} }
+506
View File
@@ -0,0 +1,506 @@
#include "mesh_overlay.h"
#include <limits.h>
#include <math.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
/* ------------------------------------------------------------------------- */
/* 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;
}
+103
View File
@@ -0,0 +1,103 @@
#ifndef MESH_OVERLAY_H
#define MESH_OVERLAY_H
#include "frame.h"
#include <stddef.h>
/* 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
+10 -6
View File
@@ -18,8 +18,12 @@ static int movie_output_default_write(void *context, const MovieOutputJob *job,
return -1; return -1;
fprintf(stdout, "Rendered %zu images from %zu catalog stars to %s (ok%s)\n", fprintf(stdout, "Rendered %zu images from %zu catalog stars to %s (ok%s)\n",
job->images, job->catalog_stars, job->output_path, job->note); job->images, job->catalog_stars, job->output_path, job->note);
if (job->draw_mesh && job->mesh_rgb8 != NULL) { /* The clean file is already written, so drawing in place on clean_rgb8 can
if (write_rgb8_image(job->mesh_path, job->mesh_rgb8, job->width, * 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)) job->height, settings))
return -1; return -1;
fprintf(stdout, "Wrote mesh overlay image: %s\n", job->mesh_path); 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) if (queue->count == 0 && queue->producer_done)
break; break;
const size_t slot = queue->head; 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 /* Ownership moved into the local copy; clear the slot so destroy() cannot
* free the same buffers a second time. */ * free the same buffers a second time. */
queue->jobs[slot].clean_rgb8 = NULL; 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->head = (queue->head + 1) % queue->capacity;
--queue->count; --queue->count;
pthread_cond_signal(&queue->not_full); pthread_cond_signal(&queue->not_full);
@@ -72,7 +76,7 @@ static void *movie_output_writer_main(void *opaque) {
} }
pthread_mutex_unlock(&queue->mutex); pthread_mutex_unlock(&queue->mutex);
free(job.clean_rgb8); free(job.clean_rgb8);
free(job.mesh_rgb8); mesh_overlay_layer_destroy(&job.mesh_layer);
pthread_mutex_lock(&queue->mutex); pthread_mutex_lock(&queue->mutex);
} }
pthread_mutex_unlock(&queue->mutex); pthread_mutex_unlock(&queue->mutex);
@@ -213,7 +217,7 @@ void movie_output_queue_destroy(MovieOutputQueue *queue) {
if (queue->jobs != NULL) if (queue->jobs != NULL)
for (size_t i = 0; i < queue->capacity; ++i) { for (size_t i = 0; i < queue->capacity; ++i) {
free(queue->jobs[i].clean_rgb8); 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_empty);
pthread_cond_destroy(&queue->not_full); pthread_cond_destroy(&queue->not_full);
+19 -10
View File
@@ -1,6 +1,7 @@
#ifndef MOVIE_OUTPUT_H #ifndef MOVIE_OUTPUT_H
#define MOVIE_OUTPUT_H #define MOVIE_OUTPUT_H
#include "mesh_overlay.h"
#include "optics.h" #include "optics.h"
#include <limits.h> #include <limits.h>
@@ -10,13 +11,16 @@
/* Bounded, single-producer/single-writer movie output queue. /* Bounded, single-producer/single-writer movie output queue.
* *
* The producer (the render loop) performs all HDR work and the tone map before * 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 * submitting, and rasterizes the independent premultiplied RGBA8 mesh overlay.
* double HDR framebuffer. The writer thread encodes/writes them in submit * A job carries finished RGB8 and optional immutable RGBA8, never a double HDR
* order while the producer renders the next frame. * 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 * Ownership contract for submit(): on success the queue owns clean_rgb8 and the
* mesh_rgb8 and frees them after writing; on failure they remain owned by the * mesh_layer buffer and releases them after writing; on failure they remain
* caller. * 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 * `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 * already-popped job, so the true in-memory bound is capacity + 1 jobs. With
@@ -29,10 +33,14 @@ typedef struct {
int draw_mesh; int draw_mesh;
unsigned char *clean_rgb8; unsigned char *clean_rgb8;
unsigned char *mesh_rgb8; /* NULL when draw_mesh is false */
int width; int width;
int height; 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 images;
size_t catalog_stars; size_t catalog_stars;
PsfSplatStats psf_stats; PsfSplatStats psf_stats;
@@ -47,9 +55,10 @@ typedef struct {
} MovieOutputJob; } MovieOutputJob;
/* Optional custom writer. Returns 0 on success; the default writer writes /* 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 * clean_rgb8 to output_path and, when draw_mesh is set, composites mesh_layer in
* mesh_path, then prints the "Rendered ... (<note>)" and PSF lines. The queue * place on clean_rgb8 before writing the augmented buffer to mesh_path. It
* owns and frees clean_rgb8/mesh_rgb8 after the writer returns. */ * then prints the "Rendered ... (<note>)" 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, typedef int (*MovieOutputWriteFn)(void *context, const MovieOutputJob *job,
const PngWriteSettings *settings); const PngWriteSettings *settings);
+1 -1
View File
@@ -476,7 +476,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-', dir='/tmp/opencode') a
'--observer-position', 0.3 * time, 0, 0, '--observer-position', 0.3 * time, 0, 0,
'--observer-velocity', 0.3, 0, 0, '--observer-velocity', 0.3, 0, 0,
'--look-ra-deg', 0, '--look-dec-deg', 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('<d', path.read_bytes(), assert struct.unpack_from('<d', path.read_bytes(),
MAP_FRAME_HEADER_START + 8)[0] == time MAP_FRAME_HEADER_START + 8)[0] == time
vertices, triangles = map_vertices(path) vertices, triangles = map_vertices(path)
+22 -4
View File
@@ -1,5 +1,6 @@
#include "frame.h" #include "frame.h"
#include "lens_map.h" #include "lens_map.h"
#include "mesh_overlay.h"
#include "optics.h" #include "optics.h"
#include <math.h> #include <math.h>
@@ -1086,10 +1087,27 @@ int main(void) {
if (!fast_ok) if (!fast_ok)
goto done; goto done;
} }
frame_draw_mesh(&mesh, hdr, width, height, 0.5, 0.5); /* Diagnostic overlay draws the finalized mesh as an sRGB8 edge map: the
if (hdr[3 * (10 * width + 20)] != 0.25) { * vertical coarse edge crossing (20, 10) must be painted, while an interior
fputs("mesh diagnostic overlay regression failed\n", stderr); * pixel away from every edge must stay at the background value. */
goto done; {
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 /* A fixed absolute edge tolerance used to make tiny source triangles claim
* sources far outside their field. */ * sources far outside their field. */
+740
View File
@@ -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 <float.h>
#include <limits.h>
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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;
}
+94
View File
@@ -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')
+213 -29
View File
@@ -2,12 +2,14 @@
#include "movie_output.h" #include "movie_output.h"
#include <errno.h>
#include <math.h> #include <math.h>
#include <omp.h> #include <omp.h>
#include <setjmp.h> #include <setjmp.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h>
#include <time.h> #include <time.h>
#include <unistd.h> #include <unistd.h>
@@ -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 MAX_JOBS 64
#define JOB_WIDTH 16 #define JOB_WIDTH 16
#define JOB_HEIGHT 16 #define JOB_HEIGHT 16
@@ -35,6 +46,9 @@ typedef struct {
size_t count; size_t count;
size_t order[MAX_JOBS]; size_t order[MAX_JOBS];
unsigned char pixels[MAX_JOBS][JOB_BYTES]; 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; } MockWriter;
static int mock_write(void *context, const MovieOutputJob *job, 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) { if (mock->count < MAX_JOBS) {
mock->order[mock->count] = job->frame_id; mock->order[mock->count] = job->frame_id;
memcpy(mock->pixels[mock->count], job->clean_rgb8, JOB_BYTES); 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; ++mock->count;
if (mock->delay_ms > 0) { if (mock->delay_ms > 0) {
@@ -76,6 +96,22 @@ static MovieOutputJob make_job(size_t frame_id) {
return job; 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. */ /* Order and pixel fidelity for capacity 1 and 2. */
static void test_order_and_pixels(size_t capacity) { static void test_order_and_pixels(size_t capacity) {
MovieOutputQueue queue; MovieOutputQueue queue;
@@ -146,7 +182,9 @@ static void test_backpressure(void) {
} }
/* Writer failure at frame N must propagate, unblock the producer, and join /* 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) { static void test_writer_failure(void) {
MovieOutputQueue queue; MovieOutputQueue queue;
MockWriter mock; MockWriter mock;
@@ -161,9 +199,16 @@ static void test_writer_failure(void) {
int saw_failure = 0; int saw_failure = 0;
for (size_t f = 0; f < 8; ++f) { for (size_t f = 0; f < 8; ++f) {
MovieOutputJob job = make_job(f); MovieOutputJob job = make_job(f);
if (movie_output_queue_submit(&queue, &job, NULL)) { job.draw_mesh = 1;
/* The queue no longer owns these buffers. */ if (make_layer(&job.mesh_layer, 3)) {
free(job.clean_rgb8); 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; saw_failure = 1;
break; break;
} }
@@ -193,6 +238,76 @@ static void test_empty_paths(void) {
movie_output_queue_destroy(&queue); 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 #ifdef ENABLE_PNG
static int decode_png_rgb8(const char *path, unsigned char *out, int width, static int decode_png_rgb8(const char *path, unsigned char *out, int width,
int height) { int height) {
@@ -216,12 +331,12 @@ static int decode_png_rgb8(const char *path, unsigned char *out, int width,
return ok ? 0 : -1; return ok ? 0 : -1;
} }
/* The default writer's clean and mesh files must decode to the submitted /* The default writer's clean file must decode unchanged and its mesh sibling
* payloads. */ * must equal the core overlay drawn in place on that same clean payload. */
static void test_default_writer_success(void) { static void test_default_writer_mesh(void) {
char directory[] = "/tmp/movie_output_XXXXXX"; char directory[] = TEST_TMP_ROOT "/movie_output_XXXXXX";
if (mkdtemp(directory) == NULL) { if (mkdtemp(directory) == NULL) {
check(0, "default success: mkdtemp"); check(0, "default mesh: mkdtemp");
return; return;
} }
char clean_path[PATH_MAX]; char clean_path[PATH_MAX];
@@ -231,7 +346,7 @@ static void test_default_writer_success(void) {
const PngWriteSettings settings = {-1}; const PngWriteSettings settings = {-1};
MovieOutputQueue queue; MovieOutputQueue queue;
if (movie_output_queue_init(&queue, 1, &settings)) { if (movie_output_queue_init(&queue, 1, &settings)) {
check(0, "default success: queue init"); check(0, "default mesh: queue init");
rmdir(directory); rmdir(directory);
return; return;
} }
@@ -239,29 +354,35 @@ static void test_default_writer_success(void) {
job.draw_mesh = 1; job.draw_mesh = 1;
snprintf(job.output_path, sizeof job.output_path, "%s", clean_path); snprintf(job.output_path, sizeof job.output_path, "%s", clean_path);
snprintf(job.mesh_path, sizeof job.mesh_path, "%s", mesh_path); snprintf(job.mesh_path, sizeof job.mesh_path, "%s", mesh_path);
job.mesh_rgb8 = make_rgb8(99); int layer_ok = make_layer(&job.mesh_layer, 5) == 0;
int submitted = job.clean_rgb8 != NULL && job.mesh_rgb8 != NULL && 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; movie_output_queue_submit(&queue, &job, NULL) == 0;
if (!submitted) { if (!submitted) {
free(job.clean_rgb8); free(job.clean_rgb8);
free(job.mesh_rgb8); mesh_overlay_layer_destroy(&job.mesh_layer);
} }
check(submitted && movie_output_queue_finish(&queue) == 0, check(submitted && movie_output_queue_finish(&queue) == 0,
"default success: submit/finish"); "default mesh: submit/finish");
unsigned char clean_decoded[JOB_BYTES]; unsigned char clean_decoded[JOB_BYTES];
unsigned char mesh_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 = 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(clean_path, clean_decoded, JOB_WIDTH, JOB_HEIGHT) == 0 &&
decode_png_rgb8(mesh_path, mesh_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, check(decoded_ok && clean_expected != NULL &&
"default success: clean pixels"); memcmp(clean_decoded, clean_expected, JOB_BYTES) == 0,
check(decoded_ok && memcmp(mesh_decoded, mesh_expected, JOB_BYTES) == 0, "default mesh: clean image unchanged");
"default success: mesh pixels"); check(decoded_ok && expected_ok &&
memcmp(mesh_decoded, mesh_expected, JOB_BYTES) == 0,
"default mesh: mesh matches core overlay result");
free(clean_expected); free(clean_expected);
free(mesh_expected);
unlink(clean_path); unlink(clean_path);
unlink(mesh_path); unlink(mesh_path);
rmdir(directory); rmdir(directory);
@@ -269,38 +390,101 @@ static void test_default_writer_success(void) {
} }
#endif #endif
/* The default writer must fail under a real filesystem error. */ /* Failure while writing the clean image happens before any overlay work; the
static void test_default_writer_failure(void) { * queue must still release the job's resources. */
static void test_default_writer_failure_before_overlay(void) {
MovieOutputQueue queue; MovieOutputQueue queue;
const PngWriteSettings settings = {-1}; const PngWriteSettings settings = {-1};
if (movie_output_queue_init(&queue, 1, &settings)) { if (movie_output_queue_init(&queue, 1, &settings)) {
check(0, "default failure: queue init"); check(0, "failure before overlay: queue init");
return; return;
} }
MovieOutputJob job = make_job(0); 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, 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)) { if (job.clean_rgb8 == NULL || movie_output_queue_submit(&queue, &job, NULL)) {
free(job.clean_rgb8); 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); movie_output_queue_destroy(&queue);
return; return;
} }
check(movie_output_queue_finish(&queue) != 0, 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); movie_output_queue_destroy(&queue);
} }
int main(void) { 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(1);
test_order_and_pixels(2); test_order_and_pixels(2);
test_backpressure(); test_backpressure();
test_writer_failure(); test_writer_failure();
test_empty_paths(); test_empty_paths();
test_lines_survive_source_release();
#ifdef ENABLE_PNG #ifdef ENABLE_PNG
test_default_writer_success(); test_default_writer_mesh();
#endif #endif
test_default_writer_failure(); test_default_writer_failure_before_overlay();
test_default_writer_failure_after_overlay();
if (failures != 0) { if (failures != 0) {
fprintf(stderr, "%d movie-output failure(s)\n", failures); fprintf(stderr, "%d movie-output failure(s)\n", failures);
return 1; return 1;
+35 -3
View File
@@ -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, 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, load+commit, and total tile time. Timing uses one clock read per bulk phase,
never inside the per-star or per-pixel hot loops. 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 Pass `--draw-mesh` to also write the final image-plane triangle mesh as a
`<output-stem>_mesh.png` sibling (`.ppm` in non-PNG builds). The main `<output-stem>_mesh.png` sibling (`.ppm` in non-PNG builds). The main
tone-mapped image and any `--hdr-output` FITS file remain mesh-free. The 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 overlay alpha-composites one-pixel-wide, coverage-antialiased triangle edges
linear-gray diagnostic lines at 0.5 opacity. The line rasterizer uses onto the final sRGB8 image **after** sensor bloom, tone mapping, and the sRGB
coverage-based antialiasing. 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 Normal progress and summaries go to stdout; warnings, errors, and Debug
diagnostics go to stderr. Successful runs exit `0` even if warnings are emitted. diagnostics go to stderr. Successful runs exit `0` even if warnings are emitted.