diff --git a/Ex0.py b/Ex0.py index 33e3095..1daa814 100644 --- a/Ex0.py +++ b/Ex0.py @@ -28,7 +28,6 @@ from __future__ import annotations -import csv import math import sys from collections.abc import Iterable, Iterator, Sequence @@ -41,9 +40,9 @@ import numpy as np import nrrd - -class UsageError(Exception): - """A user-fixable input/usage problem reported as a one-line CLI error (FR-2).""" +# Import plane validation from the new module +from plane_validation import Plane, PlaneError, parse_planes, count_planes +from errors import UsageError def _require_file(label: str, path: Path) -> None: @@ -131,217 +130,12 @@ def load_volume(path: str | Path) -> np.ndarray: # --------------------------------------------------------------------------- -# Plane-list parsing + per-row validation (FR-7..FR-11) +# Frame rendering (FR-12, FR-13; geometry pinned in docs/algorithm.md) # --------------------------------------------------------------------------- -#: Plane validity tolerance (brief Q&A; SRS FR-10). -EPS = 0.001 - -#: Relative tolerance for the coplanarity predicate (rule 3, 4-point): the -#: scalar triple product of the leg directions and the apex separation is -#: compared against the product of the involved lengths, so the test is -#: scale-free. Genuinely non-coplanar point sets sit at O(0.01..1); the -#: corpus's rectangular planes sit at float-dust level (~1e-18). -COPLANAR_RTOL = 1e-9 - Point = tuple[float, float, float] -@dataclass(frozen=True) -class Plane: - """One validated CSV plane row (FR-8). - - ``points`` keeps the brief's column order: 3-point rows are - ``(a, b, c)`` (legs measured from ``a``); 4-point rows are - ``(a1, a2, b, c)`` (legs ``a1→b``/``a2→c``, bases ``a1→a2``/``b→c``). - Canonical voxel-index space per ADR 0002 - no world-space conversion. - """ - - row: int - points: tuple[Point, ...] - - @property - def is_trapezoid(self) -> bool: - return len(self.points) == 4 - - -@dataclass(frozen=True) -class PlaneError: - """One CSV row that failed validation (FR-10/FR-11). - - ``reason`` names the exact failing predicate with its numbers - the same - text the future error-frame banner (FR-15) and CLI log line (FR-16) - carry. ``banner()`` composes the full line, addressable by row index. - """ - - row: int - reason: str - - def banner(self) -> str: - return f"row {self.row}: {self.reason}" - - -def _fmt(value: float) -> str: - """Compact %g formatting for banner numbers (100 not 100.00000).""" - return f"{value:g}" - - -def _dist(p: Point, q: Point) -> float: - return math.dist(p, q) - - -def _cross(u: Point, v: Point) -> Point: - return ( - u[1] * v[2] - u[2] * v[1], - u[2] * v[0] - u[0] * v[2], - u[0] * v[1] - u[1] * v[0], - ) - - -def _sub(p: Point, q: Point) -> Point: - return (p[0] - q[0], p[1] - q[1], p[2] - q[2]) - - -def _dot(u: Point, v: Point) -> float: - return u[0] * v[0] + u[1] * v[1] + u[2] * v[2] - - -def _validate_triangle(a: Point, b: Point, c: Point) -> str | None: - """3-point validity + constructibility: the failing rule, or None. - - Rule 1 (FR-10): ``|dist(a,b) - dist(a,c)| < EPS`` - the isosceles legs - are measured from the first point. Constructibility (FR-11): no - zero-length leg; ``b == c`` makes every row's width 0 (the two legs - collapse onto one direction), a degenerate frame. - """ - dab, dac = _dist(a, b), _dist(a, c) - if dab == 0: - return "zero-length leg a→b (a == b)" - if dac == 0: - return "zero-length leg a→c (a == c)" - if abs(dab - dac) >= EPS: - return ( - f"|dist(a,b) − dist(a,c)| = {_fmt(abs(dab - dac))} ≥ EPS = {EPS}" - f" (dist(a,b) = {_fmt(dab)}, dist(a,c) = {_fmt(dac)})" - ) - if b == c: - return "degenerate: b == c (parallel legs give a zero-width frame)" - return None - - -def _validate_trapezoid(a1: Point, a2: Point, b: Point, c: Point) -> str | None: - """4-point validity + constructibility: the failing rule, or None. - - Rules in the brief's order (FR-10): legs-equal ``|dist(a1,b) - - dist(a2,c)| < EPS``; base ``dist(a1,a2) <= dist(b,c)`` (with the EPS - tolerance - the corpus's rectangles exceed it by ~4e-7 float dust); - coplanarity of the two legs. Constructibility (FR-11): no zero-length - leg; ``a1 == a2`` with same-direction legs maximizes the zero-width - collapse, while ``a1 == a2`` with diverging legs (including opposite-way - legs) is a triangle and renders fine. - Parallel legs with a nonzero top base are legal rectangles (the whole - head.csv corpus). - """ - d1, d2 = _dist(a1, b), _dist(a2, c) - if d1 == 0: - return "zero-length leg a1→b (a1 == b)" - if d2 == 0: - return "zero-length leg a2→c (a2 == c)" - if abs(d1 - d2) >= EPS: - return ( - f"|dist(a1,b) − dist(a2,c)| = {_fmt(abs(d1 - d2))} ≥ EPS = {EPS}" - f" (dist(a1,b) = {_fmt(d1)}, dist(a2,c) = {_fmt(d2)})" - ) - d12, dbc = _dist(a1, a2), _dist(b, c) - if d12 > dbc + EPS: - return ( - f"dist(a1,a2) = {_fmt(d12)} > dist(b,c) = {_fmt(dbc)}" - f" (rule: dist(a1,a2) ≤ dist(b,c))" - ) - leg1, leg2, sep = _sub(b, a1), _sub(c, a2), _sub(a2, a1) - triple = abs(_dot(sep, _cross(leg1, leg2))) - if triple > COPLANAR_RTOL * d1 * d2 * (d12 or 1.0): - return "legs a1→b and a2→c do not lie in the same plane (rule 3)" - if d12 == 0: - # a1 == a2: the frame is zero-width on every row exactly when the - # legs step the same way (a2->c == a1->b). Opposite-way legs - # (a2->c == -(a1->b)) are the 3-point triangle geometry — apex a1, - # base through b and c — and render. - if leg1 == leg2: - return "degenerate: a1 == a2 with same-direction legs (zero-width frame)" - return None - - -def _parse_row(row_idx: int, raw: list[str]) -> Plane | PlaneError: - """Parse and validate one CSV data row into its final record (FR-8..FR-11).""" - - def fail(reason: str) -> PlaneError: - return PlaneError(row=row_idx, reason=reason) - - count_cell = raw[0].strip() if raw else "" - try: - n = float(count_cell) - except ValueError: - return fail(f"point count is not a number: '{count_cell}'") - if n not in (3.0, 4.0): - return fail(f"point count must be 3 or 4, got '{count_cell}'") - n = int(n) - - if len(raw) < 1 + 3 * n: - return fail(f"expected {1 + 3 * n} columns for a {n}-point row, got {len(raw)}") - points: list[Point] = [] - for i in range(n): - cells = raw[1 + 3 * i : 4 + 3 * i] - point: list[float] = [] - for j, cell in enumerate(cells): - try: - point.append(float(cell)) - except ValueError: - return fail(f"coordinate is not a number: '{cell.strip()}' (point {i + 1})") - points.append((point[0], point[1], point[2])) - - if n == 3: - reason = _validate_triangle(*points) - else: - reason = _validate_trapezoid(*points) - if reason is not None: - return fail(reason) - return Plane(row=row_idx, points=tuple(points)) - - -def _iter_plane_rows(path: Path) -> Iterator[list[str]]: - """Yield the CSV's non-empty data rows (header excluded), FR-9-tolerant.""" - _require_file("plane list", path) - with _os_errors_as_usage("plane list", path): - with open(path, newline="") as file: - reader = csv.reader(file) - header = next(reader, None) - if header is None: - raise UsageError(f"cannot read plane list '{path}': empty file (no header)") - for row in reader: - if any(cell.strip() for cell in row): - yield row - - -def parse_planes(path: str | Path) -> list[Plane | PlaneError]: - """Parse the CSV plane list into one record per data row, in row order. - - Each non-empty row becomes either a validated :class:`Plane` or a - :class:`PlaneError` naming the exact failing rule (FR-10/FR-11) - no - row ever stops the run (NFR-4 groundwork; the error-frame ticket turns - these into banners). File-level problems (missing, directory, empty) - stay whole-run :class:`UsageError`s (FR-2). - - Tolerant of what the corpus provides (FR-9): a missing trailing - newline, integer- or float-coordinate cells, and blank separator rows. - """ - return [_parse_row(i, row) for i, row in enumerate(_iter_plane_rows(Path(path)), 1)] - - -# --------------------------------------------------------------------------- -# Frame rendering (FR-12, FR-13; geometry pinned in docs/algorithm.md) -# --------------------------------------------------------------------------- - @dataclass(frozen=True) class Frame: """One rendered plane projection, display-scaled to grayscale uint8. @@ -384,6 +178,10 @@ def _plane_corners(plane: Plane) -> tuple[Point, Point, Point, Point]: return (a, a, b, c) +def _dist(p: Point, q: Point) -> float: + return math.dist(p, q) + + def _render_plane(volume: np.ndarray, plane: Plane) -> Frame: """Render one validated plane as a display-scaled 2D uint8 frame (FR-12). @@ -576,7 +374,7 @@ def text_width(value: str) -> int: # --------------------------------------------------------------------------- -# Plane-list counting (FR-17 groundwork; superseded body: one record per row) +# Canvas assembly (FR-18/FR-14) # --------------------------------------------------------------------------- def iter_canvas_frames( @@ -597,19 +395,6 @@ def iter_canvas_frames( ) -def count_planes(path: str | Path) -> int: - """Count the CSV plane-list's data rows (the header row is excluded). - - Full parsing/validation of each row lives in :func:`parse_planes`; the - count is simply one record per non-empty row, so the video keeps one - frame per row, in row order (FR-17). Tolerant already of - what the corpus provides: integer- or float-coordinate cells and a - missing trailing newline (FR-9); rows that are entirely empty are not - planes. - """ - return len(parse_planes(path)) - - # --------------------------------------------------------------------------- # Video writer (FR-17..FR-21 container contract; encoder choice: docs/adr/0004) # --------------------------------------------------------------------------- @@ -736,4 +521,4 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file diff --git a/README.md b/README.md index b9e9916..e1f49ab 100644 --- a/README.md +++ b/README.md @@ -119,14 +119,14 @@ Measured end-to-end (load + render, min-of-repeats) on the reference machine: | Input | Frames | FPS | |---|---:|---:| -| `croissant.nrrd` × `croissant.csv` — 500³ uint8, the canonical 500³ case | 100 | **43.6** | -| `onion.nrrd` × `head.csv` | 280 | 382.0 | -| `MRHead_1.nrrd` × `head.csv` | 280 | 381.3 | +| `croissant.nrrd` × `croissant.csv` — 500³ uint8, the canonical 500³ case | 100 | **50.1** | +| `onion.nrrd` × `head.csv` | 280 | 433.5 | +| `MRHead_1.nrrd` × `head.csv` | 280 | 380.2 | The ≥ 30 FPS hard bar is met; the 100 FPS aspirational target is not met on this (2013 dual-core) machine. Every row of the full 30-case cross-product — with the exact one-line command that reproduced it — is in -[`benchmarks/results-2026-11-06.md`](benchmarks/results-2026-11-06.md). +[`benchmarks/results-2026-11-07.md`](benchmarks/results-2026-11-07.md). Reproduce with: ```sh @@ -188,7 +188,11 @@ volume has a matching CSV in [`assets/csv/`](assets/csv/), and ## Repository layout ``` -Ex0.py CLI + loader + validation + renderer + writer (single-file deliverable) +Ex0.py CLI orchestrator (imports from modules below) +errors.py Shared UsageError exception +geometry.py Scalar geometry helpers (dist, cross, sub, dot) +csv_reader.py FR-9 tolerant CSV row iterator +plane_validation.py Plane parsing + validation (parse_planes, count_planes) bench.py Benchmark harness (corpus cross-product, min-of-repeats) tests/ pytest suite (unit, golden, CLI/MP4, benchmark) assets/nrrd|csv|videos/ Sample volumes, plane lists and rendered outputs (Git LFS for large files) diff --git a/benchmarks/results-2026-11-07.md b/benchmarks/results-2026-11-07.md new file mode 100644 index 0000000..1821def --- /dev/null +++ b/benchmarks/results-2026-11-07.md @@ -0,0 +1,44 @@ +# Benchmark results + +Recorded: 2026-11-07 22:09 +0200 +Machine: Intel(R) Core(TM) i7-4650U CPU @ 1.70GHz (4 cores) +Software: Python 3.15.2, numpy 2.5.4, encode: cv2.VideoWriter mp4v @30 fps (ADR 0004), excluded from throughput + +Methodology (NFR-2): wall time via `time.perf_counter()` (monotonic, highest resolution); best = min-of-repeats (timeit methodology); the **load** scope is the NRRD read; the **render** scope is parse + render + canvas assembly; the mp4 write is *not* exercised: throughput runs do not write the mp4 — encode seconds appear only in the dedicated encode-probe row; throughput = rendered frames ÷ (load + render) wall time. + +| input | command | frames | load s | render s | encode s | FPS (load+render) | +|---|---|---|---|---|---|---| +| swiss_roll.nrrd x biscuit_cake.csv | `python bench.py assets/nrrd/swiss_roll.nrrd assets/csv/biscuit_cake.csv --repeats 3` | 120 | 0.180 | 0.343 | 0.000 | 229.3 | +| swiss_roll.nrrd x croissant.csv | `python bench.py assets/nrrd/swiss_roll.nrrd assets/csv/croissant.csv --repeats 3` | 100 | 0.184 | 0.705 | 0.000 | 112.5 | +| swiss_roll.nrrd x head.csv | `python bench.py assets/nrrd/swiss_roll.nrrd assets/csv/head.csv --repeats 3` | 280 | 0.179 | 0.727 | 0.000 | 309.1 | +| swiss_roll.nrrd x lecturer_example.csv | `python bench.py assets/nrrd/swiss_roll.nrrd assets/csv/lecturer_example.csv --repeats 3` | 2 | 0.187 | 0.015 | 0.000 | 9.9 | +| swiss_roll.nrrd x onion.csv | `python bench.py assets/nrrd/swiss_roll.nrrd assets/csv/onion.csv --repeats 3` | 120 | 0.207 | 0.344 | 0.000 | 217.7 | +| swiss_roll.nrrd x swiss_roll.csv | `python bench.py assets/nrrd/swiss_roll.nrrd assets/csv/swiss_roll.csv --repeats 3` | 120 | 0.189 | 0.349 | 0.000 | 223.2 | +| onion.nrrd x biscuit_cake.csv | `python bench.py assets/nrrd/onion.nrrd assets/csv/biscuit_cake.csv --repeats 3` | 120 | 0.052 | 0.310 | 0.000 | 331.6 | +| onion.nrrd x croissant.csv | `python bench.py assets/nrrd/onion.nrrd assets/csv/croissant.csv --repeats 3` | 100 | 0.051 | 0.693 | 0.000 | 134.5 | +| onion.nrrd x head.csv | `python bench.py assets/nrrd/onion.nrrd assets/csv/head.csv --repeats 3` | 280 | 0.050 | 0.596 | 0.000 | 433.5 | +| onion.nrrd x lecturer_example.csv | `python bench.py assets/nrrd/onion.nrrd assets/csv/lecturer_example.csv --repeats 3` | 2 | 0.050 | 0.010 | 0.000 | 33.3 | +| onion.nrrd x onion.csv | `python bench.py assets/nrrd/onion.nrrd assets/csv/onion.csv --repeats 3` | 120 | 0.049 | 0.301 | 0.000 | 342.7 | +| onion.nrrd x swiss_roll.csv | `python bench.py assets/nrrd/onion.nrrd assets/csv/swiss_roll.csv --repeats 3` | 120 | 0.049 | 0.295 | 0.000 | 348.8 | +| croissant.nrrd x biscuit_cake.csv | `python bench.py assets/nrrd/croissant.nrrd assets/csv/biscuit_cake.csv --repeats 3` | 120 | 0.848 | 0.337 | 0.000 | 101.2 | +| croissant.nrrd x croissant.csv | `python bench.py assets/nrrd/croissant.nrrd assets/csv/croissant.csv --repeats 3` | 100 | 0.878 | 1.116 | 0.000 | 50.1 | +| croissant.nrrd x head.csv | `python bench.py assets/nrrd/croissant.nrrd assets/csv/head.csv --repeats 3` | 280 | 0.894 | 0.625 | 0.000 | 184.3 | +| croissant.nrrd x lecturer_example.csv | `python bench.py assets/nrrd/croissant.nrrd assets/csv/lecturer_example.csv --repeats 3` | 2 | 0.905 | 0.018 | 0.000 | 2.2 | +| croissant.nrrd x onion.csv | `python bench.py assets/nrrd/croissant.nrrd assets/csv/onion.csv --repeats 3` | 120 | 0.969 | 0.338 | 0.000 | 91.8 | +| croissant.nrrd x swiss_roll.csv | `python bench.py assets/nrrd/croissant.nrrd assets/csv/swiss_roll.csv --repeats 3` | 120 | 0.871 | 0.344 | 0.000 | 98.8 | +| biscuit_cake.nrrd x biscuit_cake.csv | `python bench.py assets/nrrd/biscuit_cake.nrrd assets/csv/biscuit_cake.csv --repeats 3` | 120 | 0.091 | 0.339 | 0.000 | 278.6 | +| biscuit_cake.nrrd x croissant.csv | `python bench.py assets/nrrd/biscuit_cake.nrrd assets/csv/croissant.csv --repeats 3` | 100 | 0.093 | 0.675 | 0.000 | 130.3 | +| biscuit_cake.nrrd x head.csv | `python bench.py assets/nrrd/biscuit_cake.nrrd assets/csv/head.csv --repeats 3` | 280 | 0.117 | 0.662 | 0.000 | 359.5 | +| biscuit_cake.nrrd x lecturer_example.csv | `python bench.py assets/nrrd/biscuit_cake.nrrd assets/csv/lecturer_example.csv --repeats 3` | 2 | 0.107 | 0.011 | 0.000 | 17.0 | +| biscuit_cake.nrrd x onion.csv | `python bench.py assets/nrrd/biscuit_cake.nrrd assets/csv/onion.csv --repeats 3` | 120 | 0.108 | 0.330 | 0.000 | 274.3 | +| biscuit_cake.nrrd x swiss_roll.csv | `python bench.py assets/nrrd/biscuit_cake.nrrd assets/csv/swiss_roll.csv --repeats 3` | 120 | 0.099 | 0.332 | 0.000 | 278.7 | +| MRHead_1.nrrd x biscuit_cake.csv | `python bench.py assets/nrrd/MRHead_1.nrrd assets/csv/biscuit_cake.csv --repeats 3` | 120 | 0.081 | 0.327 | 0.000 | 294.2 | +| MRHead_1.nrrd x croissant.csv | `python bench.py assets/nrrd/MRHead_1.nrrd assets/csv/croissant.csv --repeats 3` | 100 | 0.080 | 0.682 | 0.000 | 131.2 | +| MRHead_1.nrrd x head.csv | `python bench.py assets/nrrd/MRHead_1.nrrd assets/csv/head.csv --repeats 3` | 280 | 0.087 | 0.650 | 0.000 | 380.2 | +| MRHead_1.nrrd x lecturer_example.csv | `python bench.py assets/nrrd/MRHead_1.nrrd assets/csv/lecturer_example.csv --repeats 3` | 2 | 0.081 | 0.010 | 0.000 | 22.1 | +| MRHead_1.nrrd x onion.csv | `python bench.py assets/nrrd/MRHead_1.nrrd assets/csv/onion.csv --repeats 3` | 120 | 0.080 | 0.327 | 0.000 | 294.5 | +| MRHead_1.nrrd x swiss_roll.csv | `python bench.py assets/nrrd/MRHead_1.nrrd assets/csv/swiss_roll.csv --repeats 3` | 120 | 0.081 | 0.325 | 0.000 | 295.9 | +| croissant.nrrd x croissant.csv (encode probe: throwaway mp4 written) | `python bench.py assets/nrrd/croissant.nrrd assets/csv/croissant.csv --repeats 1 --encode-probe` | 100 | 0.882 | 1.144 | 0.399 | 49.4 | + +NFR-1 hard bar (croissant.nrrd x croissant.csv, 500³ uint8): 50.1 FPS — met the 30 FPS hard bar (target of 100 FPS NOT met). +Repeatability (drift criterion < ~10%): 1.2% between two independent min-of-3 runs of the canonical pair. diff --git a/csv_reader.py b/csv_reader.py new file mode 100644 index 0000000..86eb94c --- /dev/null +++ b/csv_reader.py @@ -0,0 +1,53 @@ +"""CSV row iteration with FR-9 tolerances. + +Yields non-empty data rows (header excluded). Tolerant of: +- missing trailing newline +- CRLF line endings +- blank separator rows +- integer- or float-coordinate cells +""" + +from __future__ import annotations + +import csv +from collections.abc import Iterator +from pathlib import Path + +from errors import UsageError + + +def _require_file(label: str, path: Path) -> None: + """One-line usage error when a named input path is a directory.""" + if path.is_dir(): + raise UsageError(f"cannot read {label}: '{path}' is a directory, not a file") + + +def iter_csv_rows(path: str | Path) -> Iterator[list[str]]: + """Yield the CSV's non-empty data rows (header excluded), FR-9-tolerant. + + Args: + path: Path to the CSV file. + + Yields: + List of cell strings for each non-empty data row. + + Raises: + UsageError: If the file is missing, a directory, or empty (no header). + """ + path = Path(path) + _require_file("plane list", path) + try: + with open(path, newline="") as file: + reader = csv.reader(file) + header = next(reader, None) + if header is None: + raise UsageError(f"cannot read plane list '{path}': empty file (no header)") + for row in reader: + if any(cell.strip() for cell in row): + yield row + except FileNotFoundError as e: + raise UsageError(f"cannot read plane list '{path}': no such file") from e + except PermissionError as e: + raise UsageError(f"cannot read plane list '{path}': permission denied") from e + except OSError as e: + raise UsageError(f"cannot read plane list '{path}': {e.strerror or e}") from e \ No newline at end of file diff --git a/errors.py b/errors.py new file mode 100644 index 0000000..1911839 --- /dev/null +++ b/errors.py @@ -0,0 +1,7 @@ +"""Shared error types for plane-projection.""" + +from __future__ import annotations + + +class UsageError(Exception): + """A user-fixable input/usage problem reported as a one-line CLI error (FR-2).""" \ No newline at end of file diff --git a/geometry.py b/geometry.py new file mode 100644 index 0000000..e641ddb --- /dev/null +++ b/geometry.py @@ -0,0 +1,32 @@ +"""Scalar geometry helpers for plane validation. + +Used by plane_validation predicates. The renderer uses vectorized numpy +geometry instead (different abstraction level). +""" + +from __future__ import annotations + +import math +from typing import Tuple + +Point = Tuple[float, float, float] + + +def _dist(p: Point, q: Point) -> float: + return math.dist(p, q) + + +def _cross(u: Point, v: Point) -> Point: + return ( + u[1] * v[2] - u[2] * v[1], + u[2] * v[0] - u[0] * v[2], + u[0] * v[1] - u[1] * v[0], + ) + + +def _sub(p: Point, q: Point) -> Point: + return (p[0] - q[0], p[1] - q[1], p[2] - q[2]) + + +def _dot(u: Point, v: Point) -> float: + return u[0] * v[0] + u[1] * v[1] + u[2] * v[2] \ No newline at end of file diff --git a/plane_validation.py b/plane_validation.py new file mode 100644 index 0000000..c18f66e --- /dev/null +++ b/plane_validation.py @@ -0,0 +1,202 @@ +"""Plane-list parsing + per-row validation (FR-7..FR-11). + +Public interface: +- parse_planes(path) -> list[Plane | PlaneError] +- count_planes(path) -> int + +Dataclasses: +- Plane: one validated CSV plane row +- PlaneError: one CSV row that failed validation +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +from csv_reader import iter_csv_rows +from errors import UsageError +from geometry import _cross, _dist, _dot, _sub + +#: Plane validity tolerance (brief Q&A; SRS FR-10). +EPS = 0.001 + +#: Relative tolerance for the coplanarity predicate (rule 3, 4-point): +#: the scalar triple product of the leg directions and the apex separation +#: is compared against the product of the involved lengths, so the test is +#: scale-free. Genuinely non-coplanar point sets sit at O(0.01..1); the +#: corpus's rectangular planes sit at float-dust level (~1e-18). +COPLANAR_RTOL = 1e-9 + +Point = tuple[float, float, float] + + +@dataclass(frozen=True) +class Plane: + """One validated CSV plane row (FR-8). + + ``points`` keeps the brief's column order: 3-point rows are + ``(a, b, c)`` (legs measured from ``a``); 4-point rows are + ``(a1, a2, b, c)`` (legs ``a1→b``/``a2→c``, bases ``a1→a2``/``b→c``). + Canonical voxel-index space per ADR 0002 - no world-space conversion. + """ + + row: int + points: tuple[Point, ...] + + @property + def is_trapezoid(self) -> bool: + return len(self.points) == 4 + + +@dataclass(frozen=True) +class PlaneError: + """One CSV row that failed validation (FR-10/FR-11). + + ``reason`` names the exact failing predicate with its numbers - the same + text the future error-frame banner (FR-15) and CLI log line (FR-16) + carry. ``banner()`` composes the full line, addressable by row index. + """ + + row: int + reason: str + + def banner(self) -> str: + return f"row {self.row}: {self.reason}" + + +def _fmt(value: float) -> str: + """Compact %g formatting for banner numbers (100 not 100.00000).""" + return f"{value:g}" + + +def _validate_triangle(a: Point, b: Point, c: Point) -> str | None: + """3-point validity + constructibility: the failing rule, or None. + + Rule 1 (FR-10): ``|dist(a,b) - dist(a,c)| < EPS`` - the isosceles legs + are measured from the first point. Constructibility (FR-11): no + zero-length leg; ``b == c`` makes every row's width 0 (the two legs + collapse onto one direction), a degenerate frame. + """ + dab, dac = _dist(a, b), _dist(a, c) + if dab == 0: + return "zero-length leg a→b (a == b)" + if dac == 0: + return "zero-length leg a→c (a == c)" + if abs(dab - dac) >= EPS: + return ( + f"|dist(a,b) − dist(a,c)| = {_fmt(abs(dab - dac))} ≥ EPS = {EPS}" + f" (dist(a,b) = {_fmt(dab)}, dist(a,c) = {_fmt(dac)})" + ) + if b == c: + return "degenerate: b == c (parallel legs give a zero-width frame)" + return None + + +def _validate_trapezoid(a1: Point, a2: Point, b: Point, c: Point) -> str | None: + """4-point validity + constructibility: the failing rule, or None. + + Rules in the brief's order (FR-10): legs-equal ``|dist(a1,b) - + dist(a2,c)| < EPS``; base ``dist(a1,a2) <= dist(b,c)`` (with the EPS + tolerance - the corpus's rectangles exceed it by ~4e-7 float dust); + coplanarity of the two legs. Constructibility (FR-11): no zero-length + leg; ``a1 == a2`` with same-direction legs maximizes the zero-width + collapse, while ``a1 == a2`` with diverging legs (including opposite-way + legs) is a triangle and renders fine. + Parallel legs with a nonzero top base are legal rectangles (the whole + head.csv corpus). + """ + d1, d2 = _dist(a1, b), _dist(a2, c) + if d1 == 0: + return "zero-length leg a1→b (a1 == b)" + if d2 == 0: + return "zero-length leg a2→c (a2 == c)" + if abs(d1 - d2) >= EPS: + return ( + f"|dist(a1,b) − dist(a2,c)| = {_fmt(abs(d1 - d2))} ≥ EPS = {EPS}" + f" (dist(a1,b) = {_fmt(d1)}, dist(a2,c) = {_fmt(d2)})" + ) + d12, dbc = _dist(a1, a2), _dist(b, c) + if d12 > dbc + EPS: + return ( + f"dist(a1,a2) = {_fmt(d12)} > dist(b,c) = {_fmt(dbc)}" + f" (rule: dist(a1,a2) ≤ dist(b,c))" + ) + leg1, leg2, sep = _sub(b, a1), _sub(c, a2), _sub(a2, a1) + triple = abs(_dot(sep, _cross(leg1, leg2))) + if triple > COPLANAR_RTOL * d1 * d2 * (d12 or 1.0): + return "legs a1→b and a2→c do not lie in the same plane (rule 3)" + if d12 == 0: + # a1 == a2: the frame is zero-width on every row exactly when the + # legs step the same way (a2->c == a1->b). Opposite-way legs + # (a2->c == -(a1->b)) are the 3-point triangle geometry — apex a1, + # base through b and c — and render. + if leg1 == leg2: + return "degenerate: a1 == a2 with same-direction legs (zero-width frame)" + return None + + +def _parse_row(row_idx: int, raw: list[str]) -> Plane | PlaneError: + """Parse and validate one CSV data row into its final record (FR-8..FR-11).""" + + def fail(reason: str) -> PlaneError: + return PlaneError(row=row_idx, reason=reason) + + count_cell = raw[0].strip() if raw else "" + try: + n = float(count_cell) + except ValueError: + return fail(f"point count is not a number: '{count_cell}'") + if n not in (3.0, 4.0): + return fail(f"point count must be 3 or 4, got '{count_cell}'") + n = int(n) + + if len(raw) < 1 + 3 * n: + return fail(f"expected {1 + 3 * n} columns for a {n}-point row, got {len(raw)}") + points: list[Point] = [] + for i in range(n): + cells = raw[1 + 3 * i : 4 + 3 * i] + point: list[float] = [] + for j, cell in enumerate(cells): + try: + point.append(float(cell)) + except ValueError: + return fail(f"coordinate is not a number: '{cell.strip()}' (point {i + 1})") + points.append((point[0], point[1], point[2])) + + if n == 3: + reason = _validate_triangle(*points) + else: + reason = _validate_trapezoid(*points) + if reason is not None: + return fail(reason) + return Plane(row=row_idx, points=tuple(points)) + + +def parse_planes(path: str | Path) -> list[Plane | PlaneError]: + """Parse the CSV plane list into one record per data row, in row order. + + Each non-empty row becomes either a validated :class:`Plane` or a + :class:`PlaneError` naming the exact failing rule (FR-10/FR-11) - no + row ever stops the run (NFR-4 groundwork; the error-frame ticket turns + these into banners). File-level problems (missing, directory, empty) + stay whole-run :class:`UsageError`s (FR-2). + + Tolerant of what the corpus provides (FR-9): a missing trailing + newline, integer- or float-coordinate cells, and blank separator rows. + """ + return [_parse_row(i, row) for i, row in enumerate(iter_csv_rows(Path(path)), 1)] + + +def count_planes(path: str | Path) -> int: + """Count the CSV plane-list's data rows (the header row is excluded). + + Full parsing/validation of each row lives in :func:`parse_planes`; the + count is simply one record per non-empty row, so the video keeps one + frame per row, in row order (FR-17). Tolerant already of + what the corpus provides: integer- or float-coordinate cells and a + missing trailing newline (FR-9); rows that are entirely empty are not + planes. + """ + return len(parse_planes(path)) \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index ed54f71..974e92c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,6 +16,7 @@ import pytest import Ex0 +from plane_validation import Plane, PlaneError, parse_planes def read_frames(path: Path) -> list[np.ndarray]: @@ -151,8 +152,8 @@ def test_brief_command_on_lecturer_example(tmp_path, capsys): err_lines = captured.err.strip().splitlines() assert err_lines == [ record.banner() - for record in Ex0.parse_planes(ASSETS / "csv" / "lecturer_example.csv") - if isinstance(record, Ex0.PlaneError) + for record in parse_planes(ASSETS / "csv" / "lecturer_example.csv") + if isinstance(record, PlaneError) ] @@ -264,4 +265,4 @@ def test_brief_command_exit_codes_through_process(tmp_path): ) assert run.returncode == 2 assert run.stderr.count("\n") <= 1 # one line (possibly no trailing NL) - assert "Traceback" not in (run.stderr + run.stdout) + assert "Traceback" not in (run.stderr + run.stdout) \ No newline at end of file diff --git a/tests/test_plane_list.py b/tests/test_plane_list.py index a430952..50fd373 100644 --- a/tests/test_plane_list.py +++ b/tests/test_plane_list.py @@ -9,7 +9,8 @@ import pytest -import Ex0 +from plane_validation import count_planes +from errors import UsageError @pytest.mark.parametrize( @@ -27,7 +28,7 @@ def test_corpus_plane_lists_count_their_rows(name, rows): # FR-7..FR-9 across the whole provided corpus, including # lecturer_example.csv (missing trailing newline, mixed 3-/4-point rows) # and croissant.csv (float-coordinate rows). - assert Ex0.count_planes(f"assets/csv/{name}") == rows + assert count_planes(f"assets/csv/{name}") == rows def test_missing_trailing_newline_counts_identically(tmp_path): @@ -37,7 +38,7 @@ def test_missing_trailing_newline_counts_identically(tmp_path): body = "Number of Points (3 or 4),x1,y1,z1,,\n3,1,2,3,,\n3,4,5,6,," with_newline.write_text(body + "\n") without.write_text(body) - assert Ex0.count_planes(with_newline) == Ex0.count_planes(without) == 2 + assert count_planes(with_newline) == count_planes(without) == 2 def test_float_and_integer_cells_count_identically(tmp_path): @@ -49,7 +50,7 @@ def test_float_and_integer_cells_count_identically(tmp_path): header = "Number of Points (3 or 4),x1,y1,z1,," integer.write_text(f"{header}\n3,1,2,3,,\n3,4,5,6,,\n") floating.write_text(f"{header}\n3,1.5,2.5,3.0,,\n3,4.5,5.5,6.25,,\n") - assert Ex0.count_planes(integer) == Ex0.count_planes(floating) == 2 + assert count_planes(integer) == count_planes(floating) == 2 def test_empty_rows_are_not_planes(tmp_path): @@ -57,28 +58,28 @@ def test_empty_rows_are_not_planes(tmp_path): file = tmp_path / "blanks.csv" header = "Number of Points (3 or 4),x1,y1,z1,," file.write_text(f"{header}\n3,1,2,3,,\n\n3,4,5,6,,\n\n") - assert Ex0.count_planes(file) == 2 + assert count_planes(file) == 2 def test_crlf_line_endings_count_identically(tmp_path): header = "Number of Points (3 or 4),x1,y1,z1,," file = tmp_path / "crlf.csv" file.write_bytes(f"{header}\r\n3,1,2,3,,\r\n3,4,5,6,,\r\n".encode()) - assert Ex0.count_planes(file) == 2 + assert count_planes(file) == 2 def test_empty_file_is_rejected_naming_it(tmp_path): file = tmp_path / "empty.csv" file.write_text("") - with pytest.raises(Ex0.UsageError, match=str(file.name)): - Ex0.count_planes(file) + with pytest.raises(UsageError, match=str(file.name)): + count_planes(file) def test_directory_as_plane_list_is_rejected(tmp_path): - with pytest.raises(Ex0.UsageError, match="is a directory"): - Ex0.count_planes(tmp_path) + with pytest.raises(UsageError, match="is a directory"): + count_planes(tmp_path) def test_missing_plane_list_is_rejected(tmp_path): - with pytest.raises(Ex0.UsageError, match="no such file"): - Ex0.count_planes(tmp_path / "absent.csv") + with pytest.raises(UsageError, match="no such file"): + count_planes(tmp_path / "absent.csv") \ No newline at end of file diff --git a/tests/test_plane_validation.py b/tests/test_plane_validation.py index bda4c34..f52a919 100644 --- a/tests/test_plane_validation.py +++ b/tests/test_plane_validation.py @@ -14,7 +14,7 @@ import pytest -import Ex0 +from plane_validation import Plane, PlaneError, parse_planes, count_planes def write_csv(tmp_path, rows: str, header: str = "Number of Points (3 or 4),x1,y1,z1,x2,y2,z2,x3,y3,z3,x4,y4,z4"): @@ -31,7 +31,7 @@ def test_lecturer_example_row_verdicts(): # 3-point isosceles rule (|497.39 - 476.86| = 20.53); row 4 fails the # 4-point legs rule (|300 - 400| = 100 >= EPS) - the ticket's # hand-checkable "100" case. - records = Ex0.parse_planes("assets/csv/lecturer_example.csv") + records = parse_planes("assets/csv/lecturer_example.csv") assert [type(r).__name__ for r in records] == [ "Plane", "Plane", @@ -52,25 +52,25 @@ def test_corpus_plane_lists_fully_validate(name): # head.csv's 220 rectangles prove parallel legs are legal; the # onion-family files' ~4e-7 base-length float dust proves rule 2 needs # the EPS tolerance. Every row of these files must come out valid. - records = Ex0.parse_planes(f"assets/csv/{name}") - errors = [r for r in records if isinstance(r, Ex0.PlaneError)] + records = parse_planes(f"assets/csv/{name}") + errors = [r for r in records if isinstance(r, PlaneError)] assert errors == [], f"{name} unexpectedly flagged: {[e.banner() for e in errors[:3]]}" - assert all(isinstance(r, Ex0.Plane) for r in records) + assert all(isinstance(r, Plane) for r in records) # --- 3-point predicates (FR-10) --------------------------------------------- def test_triangle_within_eps_is_valid(tmp_path): # legs 5 and 5 + 0.0005 -> inside EPS = 0.001 - records = Ex0.parse_planes(write_csv(tmp_path, "3,0,0,0,5,0,0,0,5.0005,0,,\n")) - assert isinstance(records[0], Ex0.Plane) + records = parse_planes(write_csv(tmp_path, "3,0,0,0,5,0,0,0,5.0005,0,,\n")) + assert isinstance(records[0], Plane) def test_triangle_outside_eps_flags_the_isosceles_rule(tmp_path): # legs 5 and 6 -> |5 - 6| = 1 >= EPS - records = Ex0.parse_planes(write_csv(tmp_path, "3,0,0,0,5,0,0,0,6,0,,\n")) + records = parse_planes(write_csv(tmp_path, "3,0,0,0,5,0,0,0,6,0,,\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) assert record.row == 1 assert "dist(a,b)" in record.reason and "dist(a,c)" in record.reason assert "1" in record.reason and "0.001" in record.reason @@ -79,8 +79,8 @@ def test_triangle_outside_eps_flags_the_isosceles_rule(tmp_path): def test_triangle_legs_are_measured_from_the_first_point(tmp_path): # dist(a,b)=5, dist(a,c)=5, but dist(b,c)=8: valid regardless of the # base length - the rule only compares the two legs from a. - records = Ex0.parse_planes(write_csv(tmp_path, "3,0,0,0,5,0,0,0,-5,0,,\n")) - assert isinstance(records[0], Ex0.Plane) + records = parse_planes(write_csv(tmp_path, "3,0,0,0,5,0,0,0,-5,0,,\n")) + assert isinstance(records[0], Plane) # --- 4-point predicates (FR-10) ---------------------------------------------- @@ -88,15 +88,15 @@ def test_triangle_legs_are_measured_from_the_first_point(tmp_path): def test_trapezoid_valid(tmp_path): # a1=(0,0,0) a2=(2,0,0) b=(0,4,0) c=(5,2.6458,0): legs 4/4, bases # 2 <= 5.18, coplanar (z=0), legs not parallel. - records = Ex0.parse_planes(write_csv(tmp_path, "4,0,0,0,2,0,0,0,4,0,5,2.6457513,0\n")) - assert isinstance(records[0], Ex0.Plane) + records = parse_planes(write_csv(tmp_path, "4,0,0,0,2,0,0,0,4,0,5,2.6457513,0\n")) + assert isinstance(records[0], Plane) def test_trapezoid_legs_rule_names_the_exact_predicate(tmp_path): # legs a1->b = 3, a2->c = 4 -> |3 - 4| = 1 >= EPS - records = Ex0.parse_planes(write_csv(tmp_path, "4,0,0,0,2,0,0,0,3,0,2,4,0\n")) + records = parse_planes(write_csv(tmp_path, "4,0,0,0,2,0,0,0,3,0,2,4,0\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) assert "dist(a1,b)" in record.reason and "dist(a2,c)" in record.reason assert "0.001" in record.reason @@ -104,24 +104,24 @@ def test_trapezoid_legs_rule_names_the_exact_predicate(tmp_path): def test_trapezoid_base_rule_dist_a1a2_le_dist_bc(tmp_path): # legs 4/4 (b=(0,4,0), c=(1,0,0) from a2=(5,0,0)); top base a1a2 = 5 # > bottom base bc = 4.123 -> rule 2 fails. - records = Ex0.parse_planes(write_csv(tmp_path, "4,0,0,0,5,0,0,0,4,0,1,0,0\n")) + records = parse_planes(write_csv(tmp_path, "4,0,0,0,5,0,0,0,4,0,1,0,0\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) assert "dist(a1,a2)" in record.reason and "dist(b,c)" in record.reason def test_trapezoid_base_rule_tolerates_float_dust(tmp_path): # top base 5.0000004, bottom base 5 (onion-family dust) -> valid. - records = Ex0.parse_planes(write_csv(tmp_path, "4,0,0,0,0,0,5.0000004,0,4,0,0,4,5\n")) - assert isinstance(records[0], Ex0.Plane) + records = parse_planes(write_csv(tmp_path, "4,0,0,0,0,0,5.0000004,0,4,0,0,4,5\n")) + assert isinstance(records[0], Plane) def test_trapezoid_coplanarity_rule(tmp_path): # legs both 4 (b=(0,4,0), c=(2,3,√7) from a2=(2,0,0)), bases 2 <= 3.46, # but the legs' directions span different planes (triple product ≠ 0). - records = Ex0.parse_planes(write_csv(tmp_path, "4,0,0,0,2,0,0,0,4,0,2,3,2.6457513\n")) + records = parse_planes(write_csv(tmp_path, "4,0,0,0,2,0,0,0,4,0,2,3,2.6457513\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) assert "same plane" in record.reason or "coplanar" in record.reason @@ -129,40 +129,40 @@ def test_trapezoid_parallel_legs_are_legal(tmp_path): # Rectangle: legs a1->b and a2->c both (0,4,0). head.csv is full of # these; they must validate (FR-11's degeneracy is zero-width frames, # not parallel directions). - records = Ex0.parse_planes(write_csv(tmp_path, "4,0,0,0,0,0,5,0,4,0,0,4,5\n")) - assert isinstance(records[0], Ex0.Plane) + records = parse_planes(write_csv(tmp_path, "4,0,0,0,0,0,5,0,4,0,0,4,5\n")) + assert isinstance(records[0], Plane) # --- constructibility gates (FR-11) ------------------------------------------ def test_zero_length_leg_flags_constructibility(tmp_path): - records = Ex0.parse_planes(write_csv(tmp_path, "3,0,0,0,0,0,0,0,5,0,,\n")) + records = parse_planes(write_csv(tmp_path, "3,0,0,0,0,0,0,0,5,0,,\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) assert "zero-length" in record.reason def test_trapezoid_zero_length_leg_flags_constructibility(tmp_path): - records = Ex0.parse_planes(write_csv(tmp_path, "4,0,0,0,2,0,0,0,0,0,2,4,0\n")) + records = parse_planes(write_csv(tmp_path, "4,0,0,0,2,0,0,0,0,0,2,4,0\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) assert "zero-length" in record.reason def test_triangle_with_coincident_b_and_c_is_degenerate(tmp_path): # legs 5/5 (isosceles holds) but b == c -> zero-width frame. - records = Ex0.parse_planes(write_csv(tmp_path, "3,0,0,0,5,0,0,5,0,0,,\n")) + records = parse_planes(write_csv(tmp_path, "3,0,0,0,5,0,0,5,0,0,,\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) assert "degenerate" in record.reason or "zero-width" in record.reason def test_trapezoid_zero_top_base_with_same_direction_legs_is_degenerate(tmp_path): # legs 4/4 (isosceles holds), a1 == a2, and the legs step the same way # (a2->c == a1->b) -> every row's width is 0 -> zero-width frame. - records = Ex0.parse_planes(write_csv(tmp_path, "4,0,0,0,0,0,0,0,4,0,0,4,0\n")) + records = parse_planes(write_csv(tmp_path, "4,0,0,0,0,0,0,0,4,0,0,4,0\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) assert "degenerate" in record.reason or "zero-width" in record.reason @@ -170,48 +170,48 @@ def test_trapezoid_zero_top_base_with_opposite_legs_renders_as_a_triangle(tmp_pa # a1 == a2 but leg a2->c = -leg a1->b: opposite-way legs are the 3-point # triangle geometry (apex a1, base through b and c) - valid and rendered, # so FR-11 only rejects the same-direction collapse. - records = Ex0.parse_planes(write_csv(tmp_path, "4,0,0,0,0,0,0,0,4,0,0,-4,0\n")) - assert isinstance(records[0], Ex0.Plane) + records = parse_planes(write_csv(tmp_path, "4,0,0,0,0,0,0,0,4,0,0,-4,0\n")) + assert isinstance(records[0], Plane) def test_trapezoid_zero_top_base_with_non_parallel_legs_is_a_triangle(tmp_path): # a1 == a2 but the legs diverge: the pseudocode renders this as a # triangle (constant apex, widening rows) - valid. - records = Ex0.parse_planes(write_csv(tmp_path, "4,0,0,0,0,0,0,0,4,0,0,0,4\n")) - assert isinstance(records[0], Ex0.Plane) + records = parse_planes(write_csv(tmp_path, "4,0,0,0,0,0,0,0,4,0,0,0,4\n")) + assert isinstance(records[0], Plane) # --- row-level parsing (FR-7..FR-9) ------------------------------------------ def test_row_index_is_one_based_over_data_rows(tmp_path): rows = "3,0,0,0,5,0,0,0,5,,\n3,0,0,0,5,0,0,0,6,,\n3,0,0,0,1,0,0,0,5,,\n" - records = Ex0.parse_planes(write_csv(tmp_path, rows)) + records = parse_planes(write_csv(tmp_path, rows)) assert [r.row for r in records] == [1, 2, 3] - assert isinstance(records[2], Ex0.PlaneError) + assert isinstance(records[2], PlaneError) def test_wrong_point_count_is_an_error_record_not_a_crash(tmp_path): - records = Ex0.parse_planes(write_csv(tmp_path, "5,0,0,0,1,0,0,2,0,0,3,0,0,4,0,0\n")) + records = parse_planes(write_csv(tmp_path, "5,0,0,0,1,0,0,2,0,0,3,0,0,4,0,0\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) assert "3 or 4" in record.reason def test_non_numeric_coordinate_is_an_error_record(tmp_path): - records = Ex0.parse_planes(write_csv(tmp_path, "3,0,0,0,5,0,0,oops,5,,\n")) + records = parse_planes(write_csv(tmp_path, "3,0,0,0,5,0,0,oops,5,,\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) assert "number" in record.reason def test_short_row_is_an_error_record(tmp_path): - records = Ex0.parse_planes(write_csv(tmp_path, "3,0,0,0,5,0\n")) + records = parse_planes(write_csv(tmp_path, "3,0,0,0,5,0\n")) record = records[0] - assert isinstance(record, Ex0.PlaneError) + assert isinstance(record, PlaneError) def test_count_planes_comes_from_parse_planes(tmp_path): # One record per non-empty row, so the placeholder frame count (one per # row, error frames included) keeps matching the ticket-1 pinned counts. rows = "3,0,0,0,5,0,0,0,5,,\n\n3,0,0,0,5,0,0,0,6,,\n" - assert Ex0.count_planes(write_csv(tmp_path, rows)) == 2 + assert count_planes(write_csv(tmp_path, rows)) == 2 \ No newline at end of file diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 6d91ccb..54ae92f 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -15,13 +15,14 @@ import pytest import Ex0 +from plane_validation import Plane, PlaneError, parse_planes from render_cases import CASES, ramp_volume, short_ramp_volume GOLDEN_DIR = Path(__file__).parent / "golden" def frame_of(volume, points): - records = Ex0.render_frames(volume, [Ex0.Plane(row=1, points=tuple(points))]) + records = Ex0.render_frames(volume, [Plane(row=1, points=tuple(points))]) assert len(records) == 1 frame = records[0] assert isinstance(frame, Ex0.Frame) @@ -115,9 +116,9 @@ def test_corpus_rows_render_at_density_dimensions_with_content( # FR-12b on the real corpus: head/onion's 255-length edges give 255-pixel # frames; croissant's 559.02 leg gives 559 rows, 500 base gives 500. volume = Ex0.load_volume(f"assets/nrrd/{volume_name}.nrrd") - records = Ex0.parse_planes(f"assets/csv/{csv_name}") + records = parse_planes(f"assets/csv/{csv_name}") rendered = Ex0.render_frames(volume, records) - assert not any(isinstance(r, Ex0.PlaneError) for r in rendered) + assert not any(isinstance(r, PlaneError) for r in rendered) first = rendered[0] assert isinstance(first, Ex0.Frame) @@ -130,7 +131,7 @@ def test_head_frames_beyond_the_volume_are_black_regions_not_errors(): # FR-12d: head.csv's oblique rows extend past z=0, so part of the frame # samples outside M and renders black while the rest carries the slice. volume = Ex0.load_volume("assets/nrrd/MRHead_1.nrrd") - records = Ex0.parse_planes("assets/csv/head.csv") + records = parse_planes("assets/csv/head.csv") rendered = Ex0.render_frames(volume, records) frame = rendered[200] # row 201: z runs 146.46 down to -17.46 assert isinstance(frame, Ex0.Frame) @@ -140,9 +141,9 @@ def test_head_frames_beyond_the_volume_are_black_regions_not_errors(): def test_render_frames_passes_error_rows_through_in_order(): volume = np.zeros((4, 4, 1), dtype=np.uint8) - ok1 = Ex0.Plane(row=1, points=((1, 1, 0), (1, 3, 0), (3, 1, 0))) - bad = Ex0.PlaneError(row=2, reason="zero-length leg a→b (a == b)") - ok3 = Ex0.Plane(row=3, points=((0, 0, 0), (0, 2, 0), (2, 0, 0))) + ok1 = Plane(row=1, points=((1, 1, 0), (1, 3, 0), (3, 1, 0))) + bad = PlaneError(row=2, reason="zero-length leg a→b (a == b)") + ok3 = Plane(row=3, points=((0, 0, 0), (0, 2, 0), (2, 0, 0))) records = Ex0.render_frames(volume, [ok1, bad, ok3]) assert [r.row for r in records] == [1, 2, 3] assert isinstance(records[0], Ex0.Frame) @@ -155,7 +156,7 @@ def test_canvas_is_the_max_frame_size_made_even(): # renderable frames, rounded up to even for the mp4v writer. records = [ Ex0.Frame(row=1, image=np.zeros((3, 2), dtype=np.uint8)), - Ex0.PlaneError(row=2, reason="bad plane"), + PlaneError(row=2, reason="bad plane"), Ex0.Frame(row=3, image=np.zeros((2, 3), dtype=np.uint8)), ] assert Ex0.canvas_size(records) == (4, 4) @@ -164,14 +165,14 @@ def test_canvas_is_the_max_frame_size_made_even(): def test_canvas_falls_back_to_placeholder_when_no_row_renders(): # All-error CSV still yields a video (FR-20); there is no renderable # frame size to take a max over, so the placeholder canvas carries it. - records = [Ex0.PlaneError(row=1, reason="bad plane")] + records = [PlaneError(row=1, reason="bad plane")] assert Ex0.canvas_size(records) == Ex0.PLACEHOLDER_CANVAS assert Ex0.canvas_size([]) == Ex0.PLACEHOLDER_CANVAS def test_error_frame_has_red_banner_white_text_and_black_remainder(): canvas = Ex0.render_error_frame( - Ex0.PlaneError(row=7, reason="|dist(a1,b) − dist(a2,c)| = 100 ≥ EPS=0.001"), + PlaneError(row=7, reason="|dist(a1,b) − dist(a2,c)| = 100 ≥ EPS=0.001"), (256, 256), ) assert canvas.shape == (256, 256, 3) @@ -184,7 +185,7 @@ def test_error_frame_wrapping_and_determinism(): # FR-15: the banner carries the row index followed by the specific rule; # long reasons wrap to fill the fixed top corner, and the same record # always produces the identical frame (banner determinism, FR-16). - error = Ex0.PlaneError( + error = PlaneError( row=9, reason=( "|dist(a1,b) − dist(a2,c)| = 100 ≥ EPS = 0.001" @@ -206,14 +207,14 @@ def test_error_frame_truncation_keeps_text_inside_canvas(): # (white text, none below the banner band); the pathological single # overlong word cannot wrap, so it is drawn at the minimum scale and # blends into the banner rather than crashing or escaping the canvas. - error = Ex0.PlaneError(row=3, reason=" ".join(["ox"] * 60)) + error = PlaneError(row=3, reason=" ".join(["ox"] * 60)) canvas = Ex0.render_error_frame(error, (64, 32)) assert canvas.shape == (32, 64, 3) assert canvas[0, 0].tolist() == [0, 0, 255] assert ((canvas[:20] == (255, 255, 255)).all(axis=2)).any() assert not ((canvas[20:] == (255, 255, 255)).all(axis=2)).any() - giant = Ex0.render_error_frame(Ex0.PlaneError(row=3, reason="x" * 200), (64, 32)) + giant = Ex0.render_error_frame(PlaneError(row=3, reason="x" * 200), (64, 32)) assert giant.shape == (32, 64, 3) assert ((giant[..., 2] == 255) & (giant[..., 0] == 0)).any() # red banner intact @@ -294,4 +295,4 @@ def test_triangle_matches_hand_derived_pixels(): assert frame.image.tolist() == [ [0, 2, 2, 0], [0, 2, 2, 4], - ] + ] \ No newline at end of file