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:
1 parent
4053d5d0c8
commit
80f9dcb3a3
17 files changed
+1873
-182
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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. */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user