"""Per-view page sizing derived from the projection (stdlib only). The problem ----------- A pair's texture page comes from ``texture_patch.vector``, one hand-entered size shared by every layout. But each production layout was authored by *Project from View (Bounds)* from its own camera, so ``[0,1]^2`` of that layout means a **different physical size** in each one. On `finger-xynova/r01` the layouts are 21.199 x 23.200 mm while the page says 18.5 x 16.4 mm — 23% wrong, and inverted from portrait to landscape. `_warn_if_page_aspect_differs` cannot catch this: it compares the raster aspect against the page aspect, and those two agree. The page is self-consistent and wrong about the geometry. The derivation -------------- For a layout that *is* a bounds-projection from a camera, ``u`` is an affine function of the camera's image-plane ``x`` and ``v`` of ``y``. So fitting each axis recovers the mapping exactly, and the page is its inverse: u = a * x + b page_width_mm = 1 / |a| v = c * y + d page_height_mm = 1 / |c| The fit's **residual is the proof** that the layout really is such a projection. Measured 1.2e-7 on the real `uv_1`/`uv_2` layouts, against 0.17-0.70 for `produce`/`compute`, which are unwraps and must keep a hand-set page. So the derivation verifies before it trusts, and refuses rather than silently falling back — a silently-kept page is precisely the defect being fixed. Two details that matter ----------------------- * The camera's **object scale must be divided out**. `finger-xynova`'s cameras carry scale 0.014988; measuring with ``matrix_world.inverted()`` reports a 1414 mm extent. The caller must pass image-plane coordinates measured through the scale-normalised view matrix, which is what `patch_projection.build_patch_geometry` already uses — so the page agrees with the geometry the generator actually clips. * The page is set from the **rounded raster**, ``page = raster_px / px_per_mm``, so page aspect and raster aspect are equal by construction and the existing uniform-scale guard cannot fire. The cost is at most ``0.5 / px_per_mm`` = 0.0025 mm at 200 px/mm, two orders of magnitude below the 0.1 mm alignment tolerance. """ from __future__ import annotations from dataclasses import dataclass from typing import Iterable, Sequence #: What both shipped revisions already use exactly: 3280/16.4, 3200/16.0, 3700/18.5 and #: 3280/16.4 are all 200.0, so only the proportion changes, not the density. DEFAULT_PX_PER_MM = 200.0 #: A true bounds-projection fits to ~1e-7; an unwrap lands at 0.17-0.70. The gap is five orders #: of magnitude, so the threshold is not delicate. DEFAULT_RESIDUAL_TOLERANCE = 1e-5 #: Bounds fitting puts the UV box exactly on [0,1]^2. DEFAULT_BBOX_TOLERANCE = 1e-5 MIN_PAGE_MM = 1e-3 class PageSizingError(ValueError): """The layout is not a verifiable bounds-projection, so no page can be derived.""" @dataclass(frozen=True) class DerivedPage: """A verified page/raster pair for one layout.""" page_width_mm: float page_height_mm: float raster_width_px: int raster_height_px: int px_per_mm: float slope_u: float slope_v: float residual_u: float residual_v: float uv_min: tuple[float, float] uv_max: tuple[float, float] @property def page_aspect(self) -> float: return self.page_width_mm / self.page_height_mm @property def raster_aspect(self) -> float: return self.raster_width_px / self.raster_height_px def describe(self) -> str: return ( f"page {self.page_width_mm:.3f} x {self.page_height_mm:.3f} mm " f"raster {self.raster_width_px} x {self.raster_height_px} px " f"({self.px_per_mm:g} px/mm)" ) def _fit(target: Sequence[float], source: Sequence[float]) -> tuple[float, float, float]: """Least-squares ``target = slope * source + intercept``; returns max residual too.""" count = len(source) if count < 2: raise PageSizingError(f"need at least 2 samples to fit an axis, got {count}") mean_source = sum(source) / count mean_target = sum(target) / count sxx = sum((value - mean_source) ** 2 for value in source) if sxx <= 0.0: raise PageSizingError("the mesh has no extent along one axis of this view") sxy = sum( (value - mean_source) * (other - mean_target) for value, other in zip(source, target) ) slope = sxy / sxx intercept = mean_target - slope * mean_source residual = max( abs(other - (slope * value + intercept)) for value, other in zip(source, target) ) return slope, intercept, residual def derive_page( uv_samples: Iterable[Sequence[float]], plane_samples: Iterable[Sequence[float]], px_per_mm: float = DEFAULT_PX_PER_MM, residual_tolerance: float = DEFAULT_RESIDUAL_TOLERANCE, bbox_tolerance: float = DEFAULT_BBOX_TOLERANCE, ) -> DerivedPage: """Derive and verify the page for one layout. ``uv_samples`` and ``plane_samples`` are parallel sequences of ``(u, v)`` and the corresponding camera image-plane ``(x, y)`` in millimetres, one entry per mesh loop. Raises `PageSizingError` — never warns and carries on — when the layout is not a bounds-projection from that camera. """ uvs = [(float(u), float(v)) for u, v in uv_samples] planes = [(float(x), float(y)) for x, y in plane_samples] if len(uvs) != len(planes): raise PageSizingError( f"uv_samples and plane_samples must be parallel: {len(uvs)} vs {len(planes)}" ) if not uvs: raise PageSizingError("no samples; the mesh has no loops") if not isinstance(px_per_mm, (int, float)) or px_per_mm <= 0: raise PageSizingError(f"px_per_mm must be > 0 (got {px_per_mm!r})") slope_u, _intercept_u, residual_u = _fit( [u for u, _ in uvs], [x for x, _ in planes] ) slope_v, _intercept_v, residual_v = _fit( [v for _, v in uvs], [y for _, y in planes] ) if abs(slope_u) <= 0.0 or abs(slope_v) <= 0.0: raise PageSizingError("a UV axis does not vary with the image plane; cannot size") if residual_u > residual_tolerance or residual_v > residual_tolerance: raise PageSizingError( "this layout is not a bounds-projection from the named camera, so no page can be " f"derived: u-vs-x max residual {residual_u:.3g}, v-vs-y max residual " f"{residual_v:.3g} (tolerance {residual_tolerance:g}). Unwrapped layouts such as " "'compute' land at 0.1-0.7 here. Set `page: fixed` for this pair to keep the " "page from texture_patch.vector." ) u_min = min(u for u, _ in uvs) u_max = max(u for u, _ in uvs) v_min = min(v for _, v in uvs) v_max = max(v for _, v in uvs) for name, low, high in (("u", u_min, u_max), ("v", v_min, v_max)): if abs(low) > bbox_tolerance or abs(high - 1.0) > bbox_tolerance: raise PageSizingError( f"the layout's UV bounding box is not [0,1]^2 in {name}: " f"[{low:.6f}, {high:.6f}] (tolerance {bbox_tolerance:g}), so it was not " "fitted to the view bounds and a page cannot be derived" ) # abs() because a mirrored axis still has a well-defined size; the sign only says the UV # runs opposite to the image plane, which no page needs to encode. page_width_mm = 1.0 / abs(slope_u) page_height_mm = 1.0 / abs(slope_v) if page_width_mm < MIN_PAGE_MM or page_height_mm < MIN_PAGE_MM: raise PageSizingError( f"degenerate page {page_width_mm:.6g} x {page_height_mm:.6g} mm; the mesh is " "edge-on to this camera" ) # Page from the rounded raster, so the two aspects are equal by construction. raster_width_px = max(1, int(round(page_width_mm * float(px_per_mm)))) raster_height_px = max(1, int(round(page_height_mm * float(px_per_mm)))) return DerivedPage( page_width_mm=raster_width_px / float(px_per_mm), page_height_mm=raster_height_px / float(px_per_mm), raster_width_px=raster_width_px, raster_height_px=raster_height_px, px_per_mm=float(px_per_mm), slope_u=slope_u, slope_v=slope_v, residual_u=residual_u, residual_v=residual_v, uv_min=(u_min, v_min), uv_max=(u_max, v_max), ) __all__ = [ "DEFAULT_BBOX_TOLERANCE", "DEFAULT_PX_PER_MM", "DEFAULT_RESIDUAL_TOLERANCE", "MIN_PAGE_MM", "DerivedPage", "PageSizingError", "derive_page", ]