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
@@ -476,7 +476,7 @@ with tempfile.TemporaryDirectory(prefix='gr-camera-cli-', dir='/tmp/opencode') a
|
||||
'--observer-position', 0.3 * time, 0, 0,
|
||||
'--observer-velocity', 0.3, 0, 0,
|
||||
'--look-ra-deg', 0, '--look-dec-deg', 0,
|
||||
'--lens-map-output', path, '--output', tmp / 'alcubierre.png')
|
||||
'--lens-map-output', path, '--output', tmp / f'alcubierre.{ext}')
|
||||
assert struct.unpack_from('<d', path.read_bytes(),
|
||||
MAP_FRAME_HEADER_START + 8)[0] == time
|
||||
vertices, triangles = map_vertices(path)
|
||||
|
||||
+22
-4
@@ -1,5 +1,6 @@
|
||||
#include "frame.h"
|
||||
#include "lens_map.h"
|
||||
#include "mesh_overlay.h"
|
||||
#include "optics.h"
|
||||
|
||||
#include <math.h>
|
||||
@@ -1086,10 +1087,27 @@ int main(void) {
|
||||
if (!fast_ok)
|
||||
goto done;
|
||||
}
|
||||
frame_draw_mesh(&mesh, hdr, width, height, 0.5, 0.5);
|
||||
if (hdr[3 * (10 * width + 20)] != 0.25) {
|
||||
fputs("mesh diagnostic overlay regression failed\n", stderr);
|
||||
goto done;
|
||||
/* Diagnostic overlay draws the finalized mesh as an sRGB8 edge map: the
|
||||
* vertical coarse edge crossing (20, 10) must be painted, while an interior
|
||||
* pixel away from every edge must stay at the background value. */
|
||||
{
|
||||
MeshOverlayLines overlay_lines = {0};
|
||||
const MeshOverlaySettings overlay_settings = mesh_overlay_default_settings();
|
||||
unsigned char *overlay_rgb = calloc((size_t)width * height * 3, 1);
|
||||
const int overlay_ok =
|
||||
overlay_rgb != NULL &&
|
||||
mesh_overlay_prepare(&mesh, &overlay_lines) == 0 &&
|
||||
overlay_lines.count != 0 &&
|
||||
mesh_overlay_draw_rgb8(&overlay_lines, overlay_rgb, width, height,
|
||||
&overlay_settings) == 0 &&
|
||||
overlay_rgb[3 * (10 * width + 20)] != 0 &&
|
||||
overlay_rgb[3 * (12 * width + 5)] == 0;
|
||||
mesh_overlay_lines_destroy(&overlay_lines);
|
||||
free(overlay_rgb);
|
||||
if (!overlay_ok) {
|
||||
fputs("mesh diagnostic overlay regression failed\n", stderr);
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
/* A fixed absolute edge tolerance used to make tiny source triangles claim
|
||||
* sources far outside their field. */
|
||||
|
||||
@@ -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 <errno.h>
|
||||
#include <math.h>
|
||||
#include <omp.h>
|
||||
#include <setjmp.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.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 JOB_WIDTH 16
|
||||
#define JOB_HEIGHT 16
|
||||
@@ -35,6 +46,9 @@ typedef struct {
|
||||
size_t count;
|
||||
size_t order[MAX_JOBS];
|
||||
unsigned char pixels[MAX_JOBS][JOB_BYTES];
|
||||
int mesh_draw_flags[MAX_JOBS];
|
||||
size_t mesh_pixel_counts[MAX_JOBS];
|
||||
unsigned char layers[MAX_JOBS][JOB_WIDTH * JOB_HEIGHT * 4];
|
||||
} MockWriter;
|
||||
|
||||
static int mock_write(void *context, const MovieOutputJob *job,
|
||||
@@ -44,6 +58,12 @@ static int mock_write(void *context, const MovieOutputJob *job,
|
||||
if (mock->count < MAX_JOBS) {
|
||||
mock->order[mock->count] = job->frame_id;
|
||||
memcpy(mock->pixels[mock->count], job->clean_rgb8, JOB_BYTES);
|
||||
mock->mesh_draw_flags[mock->count] = job->draw_mesh;
|
||||
mock->mesh_pixel_counts[mock->count] = job->mesh_layer.rgba == NULL ? 0 :
|
||||
(size_t)job->mesh_layer.width * job->mesh_layer.height;
|
||||
if (job->mesh_layer.rgba != NULL)
|
||||
memcpy(mock->layers[mock->count], job->mesh_layer.rgba,
|
||||
sizeof mock->layers[mock->count]);
|
||||
}
|
||||
++mock->count;
|
||||
if (mock->delay_ms > 0) {
|
||||
@@ -76,6 +96,22 @@ static MovieOutputJob make_job(size_t frame_id) {
|
||||
return job;
|
||||
}
|
||||
|
||||
/* An independent premultiplied RGBA8 fixture with sparse nonzero pixels. */
|
||||
static int make_layer(MeshOverlayLayer *layer, size_t count) {
|
||||
*layer = (MeshOverlayLayer){0};
|
||||
layer->rgba = calloc(JOB_WIDTH * JOB_HEIGHT, 4);
|
||||
if (layer->rgba == NULL)
|
||||
return -1;
|
||||
layer->width = JOB_WIDTH;
|
||||
layer->height = JOB_HEIGHT;
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
for (int channel = 0; channel < 3; ++channel)
|
||||
layer->rgba[4 * i + channel] = (unsigned char)((i * 19 + channel * 3) % 129);
|
||||
layer->rgba[4 * i + 3] = 128;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Order and pixel fidelity for capacity 1 and 2. */
|
||||
static void test_order_and_pixels(size_t capacity) {
|
||||
MovieOutputQueue queue;
|
||||
@@ -146,7 +182,9 @@ static void test_backpressure(void) {
|
||||
}
|
||||
|
||||
/* Writer failure at frame N must propagate, unblock the producer, and join
|
||||
* cleanly without losing the ownership contract. */
|
||||
* cleanly. RGBA layers are attached to every job so both ownership paths
|
||||
* are exercised: the queue releases accepted jobs, while the caller releases
|
||||
* the job rejected after the failure is recorded. */
|
||||
static void test_writer_failure(void) {
|
||||
MovieOutputQueue queue;
|
||||
MockWriter mock;
|
||||
@@ -161,9 +199,16 @@ static void test_writer_failure(void) {
|
||||
int saw_failure = 0;
|
||||
for (size_t f = 0; f < 8; ++f) {
|
||||
MovieOutputJob job = make_job(f);
|
||||
if (movie_output_queue_submit(&queue, &job, NULL)) {
|
||||
/* The queue no longer owns these buffers. */
|
||||
job.draw_mesh = 1;
|
||||
if (make_layer(&job.mesh_layer, 3)) {
|
||||
free(job.clean_rgb8);
|
||||
check(0, "failure: layer allocation");
|
||||
break;
|
||||
}
|
||||
if (movie_output_queue_submit(&queue, &job, NULL)) {
|
||||
/* The queue no longer owns these resources. */
|
||||
free(job.clean_rgb8);
|
||||
mesh_overlay_layer_destroy(&job.mesh_layer);
|
||||
saw_failure = 1;
|
||||
break;
|
||||
}
|
||||
@@ -193,6 +238,76 @@ static void test_empty_paths(void) {
|
||||
movie_output_queue_destroy(&queue);
|
||||
}
|
||||
|
||||
/* The queued job owns an independent RGBA layer: rasterizing a heap mesh, then
|
||||
* releasing that source mesh before the writer runs, must not disturb the
|
||||
* pixels the writer observes. */
|
||||
static void test_lines_survive_source_release(void) {
|
||||
LensVertex *vertices = calloc(3, sizeof *vertices);
|
||||
LensTriangle *triangles = calloc(1, sizeof *triangles);
|
||||
if (vertices == NULL || triangles == NULL) {
|
||||
free(vertices);
|
||||
free(triangles);
|
||||
check(0, "source release: allocation");
|
||||
return;
|
||||
}
|
||||
vertices[0] = (LensVertex){.image_x = 1.5,
|
||||
.image_y = 1.5,
|
||||
.outcome = RAY_OUTCOME_ESCAPED,
|
||||
.traced = 1};
|
||||
vertices[1] = (LensVertex){.image_x = JOB_WIDTH - 2.5,
|
||||
.image_y = 2.5,
|
||||
.outcome = RAY_OUTCOME_DARK,
|
||||
.traced = 1};
|
||||
vertices[2] = (LensVertex){.image_x = 5.5,
|
||||
.image_y = JOB_HEIGHT - 2.5,
|
||||
.outcome = RAY_OUTCOME_INCOMPLETE,
|
||||
.traced = 0};
|
||||
triangles[0].vertex[0] = 0;
|
||||
triangles[0].vertex[1] = 1;
|
||||
triangles[0].vertex[2] = 2;
|
||||
FrameLensMesh mesh = {.vertices = vertices,
|
||||
.vertex_count = 3,
|
||||
.triangles = triangles,
|
||||
.triangle_count = 1};
|
||||
MeshOverlayLayer layer = {0};
|
||||
const MeshOverlaySettings overlay = mesh_overlay_default_settings();
|
||||
const int prepared = mesh_overlay_build_layer(&mesh, JOB_WIDTH, JOB_HEIGHT,
|
||||
&overlay, &layer) == 0;
|
||||
/* Release the source mesh (and its vertex/triangle arrays) before submit. */
|
||||
free(vertices);
|
||||
free(triangles);
|
||||
check(prepared, "source release: overlay extraction");
|
||||
|
||||
MovieOutputQueue queue;
|
||||
MockWriter mock;
|
||||
memset(&mock, 0, sizeof mock);
|
||||
mock.fail_at = -1;
|
||||
const PngWriteSettings settings = {-1};
|
||||
if (movie_output_queue_init(&queue, 1, &settings)) {
|
||||
check(0, "source release: queue init");
|
||||
mesh_overlay_layer_destroy(&layer);
|
||||
return;
|
||||
}
|
||||
movie_output_queue_set_writer(&queue, mock_write, &mock);
|
||||
MovieOutputJob job = make_job(0);
|
||||
job.draw_mesh = 1;
|
||||
job.mesh_layer = layer; /* ownership transferred to the job/queue */
|
||||
int submitted = job.clean_rgb8 != NULL &&
|
||||
movie_output_queue_submit(&queue, &job, NULL) == 0;
|
||||
if (!submitted) {
|
||||
free(job.clean_rgb8);
|
||||
mesh_overlay_layer_destroy(&job.mesh_layer);
|
||||
}
|
||||
check(submitted && movie_output_queue_finish(&queue) == 0,
|
||||
"source release: submit/finish");
|
||||
check(mock.count == 1 && mock.mesh_draw_flags[0] == 1 &&
|
||||
mock.mesh_pixel_counts[0] == JOB_WIDTH * JOB_HEIGHT &&
|
||||
memcmp(mock.layers[0], (unsigned char[JOB_WIDTH * JOB_HEIGHT * 4]){0},
|
||||
sizeof mock.layers[0]) != 0,
|
||||
"source release: writer saw the intact RGBA overlay");
|
||||
movie_output_queue_destroy(&queue);
|
||||
}
|
||||
|
||||
#ifdef ENABLE_PNG
|
||||
static int decode_png_rgb8(const char *path, unsigned char *out, int width,
|
||||
int height) {
|
||||
@@ -216,12 +331,12 @@ static int decode_png_rgb8(const char *path, unsigned char *out, int width,
|
||||
return ok ? 0 : -1;
|
||||
}
|
||||
|
||||
/* The default writer's clean and mesh files must decode to the submitted
|
||||
* payloads. */
|
||||
static void test_default_writer_success(void) {
|
||||
char directory[] = "/tmp/movie_output_XXXXXX";
|
||||
/* The default writer's clean file must decode unchanged and its mesh sibling
|
||||
* must equal the core overlay drawn in place on that same clean payload. */
|
||||
static void test_default_writer_mesh(void) {
|
||||
char directory[] = TEST_TMP_ROOT "/movie_output_XXXXXX";
|
||||
if (mkdtemp(directory) == NULL) {
|
||||
check(0, "default success: mkdtemp");
|
||||
check(0, "default mesh: mkdtemp");
|
||||
return;
|
||||
}
|
||||
char clean_path[PATH_MAX];
|
||||
@@ -231,7 +346,7 @@ static void test_default_writer_success(void) {
|
||||
const PngWriteSettings settings = {-1};
|
||||
MovieOutputQueue queue;
|
||||
if (movie_output_queue_init(&queue, 1, &settings)) {
|
||||
check(0, "default success: queue init");
|
||||
check(0, "default mesh: queue init");
|
||||
rmdir(directory);
|
||||
return;
|
||||
}
|
||||
@@ -239,29 +354,35 @@ static void test_default_writer_success(void) {
|
||||
job.draw_mesh = 1;
|
||||
snprintf(job.output_path, sizeof job.output_path, "%s", clean_path);
|
||||
snprintf(job.mesh_path, sizeof job.mesh_path, "%s", mesh_path);
|
||||
job.mesh_rgb8 = make_rgb8(99);
|
||||
int submitted = job.clean_rgb8 != NULL && job.mesh_rgb8 != NULL &&
|
||||
int layer_ok = make_layer(&job.mesh_layer, 5) == 0;
|
||||
unsigned char *clean_expected = make_rgb8(0);
|
||||
unsigned char mesh_expected[JOB_BYTES];
|
||||
int expected_ok = layer_ok && clean_expected != NULL;
|
||||
if (expected_ok) {
|
||||
memcpy(mesh_expected, clean_expected, JOB_BYTES);
|
||||
expected_ok = mesh_overlay_composite_rgb8(&job.mesh_layer, mesh_expected,
|
||||
JOB_WIDTH, JOB_HEIGHT) == 0;
|
||||
}
|
||||
int submitted = expected_ok && job.clean_rgb8 != NULL &&
|
||||
movie_output_queue_submit(&queue, &job, NULL) == 0;
|
||||
if (!submitted) {
|
||||
free(job.clean_rgb8);
|
||||
free(job.mesh_rgb8);
|
||||
mesh_overlay_layer_destroy(&job.mesh_layer);
|
||||
}
|
||||
check(submitted && movie_output_queue_finish(&queue) == 0,
|
||||
"default success: submit/finish");
|
||||
"default mesh: submit/finish");
|
||||
unsigned char clean_decoded[JOB_BYTES];
|
||||
unsigned char mesh_decoded[JOB_BYTES];
|
||||
unsigned char *clean_expected = make_rgb8(0);
|
||||
unsigned char *mesh_expected = make_rgb8(99);
|
||||
const int decoded_ok =
|
||||
clean_expected != NULL && mesh_expected != NULL &&
|
||||
decode_png_rgb8(clean_path, clean_decoded, JOB_WIDTH, JOB_HEIGHT) == 0 &&
|
||||
decode_png_rgb8(mesh_path, mesh_decoded, JOB_WIDTH, JOB_HEIGHT) == 0;
|
||||
check(decoded_ok && memcmp(clean_decoded, clean_expected, JOB_BYTES) == 0,
|
||||
"default success: clean pixels");
|
||||
check(decoded_ok && memcmp(mesh_decoded, mesh_expected, JOB_BYTES) == 0,
|
||||
"default success: mesh pixels");
|
||||
check(decoded_ok && clean_expected != NULL &&
|
||||
memcmp(clean_decoded, clean_expected, JOB_BYTES) == 0,
|
||||
"default mesh: clean image unchanged");
|
||||
check(decoded_ok && expected_ok &&
|
||||
memcmp(mesh_decoded, mesh_expected, JOB_BYTES) == 0,
|
||||
"default mesh: mesh matches core overlay result");
|
||||
free(clean_expected);
|
||||
free(mesh_expected);
|
||||
unlink(clean_path);
|
||||
unlink(mesh_path);
|
||||
rmdir(directory);
|
||||
@@ -269,38 +390,101 @@ static void test_default_writer_success(void) {
|
||||
}
|
||||
#endif
|
||||
|
||||
/* The default writer must fail under a real filesystem error. */
|
||||
static void test_default_writer_failure(void) {
|
||||
/* Failure while writing the clean image happens before any overlay work; the
|
||||
* queue must still release the job's resources. */
|
||||
static void test_default_writer_failure_before_overlay(void) {
|
||||
MovieOutputQueue queue;
|
||||
const PngWriteSettings settings = {-1};
|
||||
if (movie_output_queue_init(&queue, 1, &settings)) {
|
||||
check(0, "default failure: queue init");
|
||||
check(0, "failure before overlay: queue init");
|
||||
return;
|
||||
}
|
||||
MovieOutputJob job = make_job(0);
|
||||
job.draw_mesh = 1;
|
||||
if (make_layer(&job.mesh_layer, 4)) {
|
||||
free(job.clean_rgb8);
|
||||
check(0, "failure before overlay: layer allocation");
|
||||
movie_output_queue_destroy(&queue);
|
||||
return;
|
||||
}
|
||||
snprintf(job.output_path, sizeof job.output_path,
|
||||
"/nonexistent-directory-xyz/frame.png");
|
||||
"/nonexistent-directory-xyz/frame." TEST_IMAGE_EXT);
|
||||
snprintf(job.mesh_path, sizeof job.mesh_path,
|
||||
"/nonexistent-directory-xyz/frame_mesh." TEST_IMAGE_EXT);
|
||||
if (job.clean_rgb8 == NULL || movie_output_queue_submit(&queue, &job, NULL)) {
|
||||
free(job.clean_rgb8);
|
||||
check(0, "default failure: submit");
|
||||
mesh_overlay_layer_destroy(&job.mesh_layer);
|
||||
check(0, "failure before overlay: submit");
|
||||
movie_output_queue_destroy(&queue);
|
||||
return;
|
||||
}
|
||||
check(movie_output_queue_finish(&queue) != 0,
|
||||
"default failure: finish reports unwritable path");
|
||||
"failure before overlay: clean write error propagates");
|
||||
movie_output_queue_destroy(&queue);
|
||||
}
|
||||
|
||||
/* Failure while writing the mesh image happens after the clean file is written
|
||||
* and the overlay is drawn; that later error must also propagate. */
|
||||
static void test_default_writer_failure_after_overlay(void) {
|
||||
char directory[] = TEST_TMP_ROOT "/movie_output_XXXXXX";
|
||||
if (mkdtemp(directory) == NULL) {
|
||||
check(0, "failure after overlay: mkdtemp");
|
||||
return;
|
||||
}
|
||||
char clean_path[PATH_MAX];
|
||||
snprintf(clean_path, sizeof clean_path, "%s/frame_000000." TEST_IMAGE_EXT,
|
||||
directory);
|
||||
MovieOutputQueue queue;
|
||||
const PngWriteSettings settings = {-1};
|
||||
if (movie_output_queue_init(&queue, 1, &settings)) {
|
||||
check(0, "failure after overlay: queue init");
|
||||
rmdir(directory);
|
||||
return;
|
||||
}
|
||||
MovieOutputJob job = make_job(0);
|
||||
job.draw_mesh = 1;
|
||||
if (make_layer(&job.mesh_layer, 4)) {
|
||||
free(job.clean_rgb8);
|
||||
check(0, "failure after overlay: layer allocation");
|
||||
rmdir(directory);
|
||||
movie_output_queue_destroy(&queue);
|
||||
return;
|
||||
}
|
||||
snprintf(job.output_path, sizeof job.output_path, "%s", clean_path);
|
||||
snprintf(job.mesh_path, sizeof job.mesh_path,
|
||||
"/nonexistent-directory-xyz/frame_mesh." TEST_IMAGE_EXT);
|
||||
if (job.clean_rgb8 == NULL || movie_output_queue_submit(&queue, &job, NULL)) {
|
||||
free(job.clean_rgb8);
|
||||
mesh_overlay_layer_destroy(&job.mesh_layer);
|
||||
check(0, "failure after overlay: submit");
|
||||
rmdir(directory);
|
||||
movie_output_queue_destroy(&queue);
|
||||
return;
|
||||
}
|
||||
check(movie_output_queue_finish(&queue) != 0,
|
||||
"failure after overlay: mesh write error propagates");
|
||||
unlink(clean_path);
|
||||
rmdir(directory);
|
||||
movie_output_queue_destroy(&queue);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
/* Standalone/CI runs need not have an OpenCode-created scratch directory. */
|
||||
if (mkdir(TEST_TMP_ROOT, 0700) != 0 && errno != EEXIST) {
|
||||
perror("create movie-output test scratch directory");
|
||||
return 1;
|
||||
}
|
||||
test_order_and_pixels(1);
|
||||
test_order_and_pixels(2);
|
||||
test_backpressure();
|
||||
test_writer_failure();
|
||||
test_empty_paths();
|
||||
test_lines_survive_source_release();
|
||||
#ifdef ENABLE_PNG
|
||||
test_default_writer_success();
|
||||
test_default_writer_mesh();
|
||||
#endif
|
||||
test_default_writer_failure();
|
||||
test_default_writer_failure_before_overlay();
|
||||
test_default_writer_failure_after_overlay();
|
||||
if (failures != 0) {
|
||||
fprintf(stderr, "%d movie-output failure(s)\n", failures);
|
||||
return 1;
|
||||
|
||||
Reference in new issue
Block a user