fix cli redundant images

finger-xynova first design completed
This commit is contained in:
ykcheungab
2026-09-28 16:18:17 +08:00
parent 88922f21ef
commit 267334d543
40 changed files with 618899 additions and 263563 deletions
+165 -26
View File
@@ -8,9 +8,10 @@ import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from orisys_sensor_design import __version__
from orisys_sensor_design import __version__, console
from orisys_sensor_design.manifest import (
RevisionPaths,
build_export_job,
@@ -149,6 +150,13 @@ def resolve_blender_executable(root: Path | None = None) -> Path | None:
return None
#: Reports the multi-view driver writes next to the textures. The host reads these back
#: rather than re-deriving them, so a printed decision cannot describe a run that did not
#: happen. Names are owned by `blender/scripts/random_patch_multiview.py`.
MULTIVIEW_GROUND_TRUTH_FILENAME = "_multiview_ground_truth.json"
MULTIVIEW_PRINT_VIEWS_FILENAME = "_print_views.json"
def _run_blender_script(script: str, blend: Path, extra_args: list[str]) -> int:
root = repo_root()
blender = resolve_blender_executable(root)
@@ -174,8 +182,66 @@ def _run_blender_script(script: str, blend: Path, extra_args: list[str]) -> int:
"--",
*extra_args,
]
print("Running:", " ".join(cmd))
return subprocess.call(cmd)
# The child cannot see our argparse namespace, and the job JSON must stay a pure record of
# what to build, so the level travels in the environment instead of in argv or the job.
env = dict(os.environ)
env["ORISYS_CONSOLE_LEVEL"] = console.verbosity_name()
# Rebuildable, pasteable: the old `Running: …` printed an unquoted argv that could not be
# pasted back into a shell and appeared on every run whether or not anything went wrong.
console.detail("command: " + " ".join(f'"{part}"' if " " in part else part for part in cmd))
# The child's output is captured rather than streamed so it can be filtered by level, which
# means nothing would appear for the length of the run. Announce it, or a 20 s command
# looks hung.
console.report(f" Blender: {script} running…")
started = time.monotonic()
try:
completed = subprocess.run(cmd, capture_output=True, text=True, env=env, check=False)
except OSError as exc:
print(f"ERROR: could not start Blender ({blender}): {exc}", file=sys.stderr)
return 2
elapsed = time.monotonic() - started
_forward_blender_output(script, completed.stdout, completed.stderr, completed.returncode, elapsed)
return completed.returncode
def _forward_blender_output(
script: str,
stdout: str,
stderr: str,
returncode: int,
elapsed: float,
) -> None:
"""Route a Blender run's output by level, and replay everything when it fails.
The child prefixes its lines (`::report::` / `::detail::`); anything unprefixed is
Blender's own chatter. On success the report lines are forwarded and the rest is held for
`--verbose`. On failure **both** streams are replayed verbatim at every level, prefixes
stripped, so a quiet failure is still fully diagnosable — a quiet run must never be a
silent one.
"""
if returncode != 0:
print(
f"ERROR: blender exited {returncode} after {elapsed:.2f}s "
f"running {script}; full output follows",
file=sys.stderr,
)
for raw in stdout.splitlines():
print(console.classify(raw)[1], file=sys.stderr)
for line in stderr.splitlines():
print(line, file=sys.stderr)
return
for raw in stdout.splitlines():
kind, text = console.classify(raw)
if kind == "report":
console.report(f" {text}")
elif kind == "detail":
console.detail(f" {text}")
else:
console.detail(raw)
for line in stderr.splitlines():
console.warn(line if line.startswith("WARN") else f"WARN: {line}")
console.report(f" Blender: {script} ok in {elapsed:.2f}s (full log with --verbose)")
def cmd_validate(revision_dir: Path, blender: bool) -> int:
@@ -402,11 +468,12 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
"""Generate one independent texture per production view.
"""Generate one owned texture per production view.
Step 1 of the multi-angle work: each pair writes its own PNG/SVG/PDF from its own
camera into its own UV layout. No ownership partition, no seam handling and no merge —
two pairs may cover the same surface region and that overlap is left unresolved.
Each pair writes its own PNG/SVG/PDF. Every loop triangle is assigned to exactly one pair
(`blender/scripts/texture_merge.py`) and each file emits only the fragments of the
triangles it owns, so two files cannot ink the same surface. A seam margin keeps the two
apart by more than the two-pass registration error.
"""
from orisys_sensor_design import texture_views
@@ -431,10 +498,8 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
targets = texture_views.multiview_output_paths(output_dir, pairs)
job = texture_views.build_multiview_job(paths, settings, pairs, output_dir)
job_path = texture_views.write_multiview_job(job, output_dir)
print(f"Wrote multi-view texture job: {job_path}")
print(f" {len(pairs)} view(s), step 1 (independent textures, no merge)")
for pair, _artifacts in targets:
print(f" [{pair.id}] camera={pair.camera!r} uv_map={pair.uv_map!r} seed={pair.seed}")
# The job path is a fact about an intermediate file, not a deliverable: verbose only.
console.detail(f"Wrote multi-view texture job: {job_path}")
code = _run_blender_script(
"random_patch_multiview.py",
@@ -460,21 +525,72 @@ def cmd_texture_views(revision_dir: Path, args: argparse.Namespace) -> int:
return 3
artifact_count = sum(len(artifacts) for _, artifacts in targets)
print(f"OK: wrote {artifact_count} multi-view artifact(s) to {output_dir}")
if console.get_level() == console.QUIET:
# A file list and nothing else, so stdout can be piped and emptiness is unambiguous.
console.artifact_paths(
path for _pair, artifacts in targets for path in artifacts.values()
)
return 0
# The decisions are read back from what the run itself wrote, not re-derived here, so the
# console cannot describe a partition that did not happen.
merge = job["merge"]
gap = job["seam"]["gap"]
margin = "off" if not gap["enabled"] else f"{gap['total_mm']:g}mm {gap['apply']}"
console.report(f"OK: {len(pairs)} view(s) -> {artifact_count} file(s) in {output_dir}")
console.report(
f" merge: mode={merge['mode']} limit={merge['incidence_limit_deg']:g}deg margin={margin}"
)
ground_truth = None
truth_path = output_dir / MULTIVIEW_GROUND_TRUTH_FILENAME
if truth_path.is_file():
try:
ground_truth = json.loads(truth_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
ground_truth = None
if ground_truth:
ownership = ground_truth.get("ownership") or {}
described = len(ownership.get("owner") or {})
total_triangles = len(ground_truth.get("triangle_vertices") or [])
hole = ownership.get("hole_mm2")
fraction = ownership.get("hole_fraction")
if total_triangles:
console.report(
f" partition: own={described:,}/{total_triangles:,} triangles"
+ (
f" hole={hole:.3f}mm^2 ({fraction:.4f})"
if isinstance(hole, (int, float)) and isinstance(fraction, (int, float))
else ""
)
)
print_views = None
views_path = output_dir / MULTIVIEW_PRINT_VIEWS_FILENAME
if views_path.is_file():
try:
print_views = json.loads(views_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
print_views = None
if print_views:
# `region=` names the per-view camera role; the region camera OWNS triangles, the
# pattern camera SUPPLIES fragments. Keeping the two nouns apart is what stops
# 43,402 and 32,593 reading as a contradiction.
by_id = {view.get("id"): view for view in print_views.get("views", [])}
for pair, _artifacts in targets:
view = by_id.get(pair.id) or {}
console.report(
f" [{pair.id}] region={pair.camera} -> {pair.uv_map}"
f" owns {view.get('owned_triangles', 0):,} triangles,"
f" emits {view.get('emitted_fragments', 0):,} fragments"
)
else:
for pair, _artifacts in targets:
console.report(f" [{pair.id}] region={pair.camera} -> {pair.uv_map}")
for pair, artifacts in targets:
print(f" [{pair.id}] camera={pair.camera!r} uv_map={pair.uv_map!r}")
for format_name, path in artifacts.items():
print(f" {format_name.upper()}: {path}")
print(
f" mode=patches-multiview {settings.width}x{settings.height} "
f"grid={settings.nx}x{settings.ny} "
f"vector={settings.vector_width_mm:g}x{settings.vector_height_mm:g}mm "
f"mesh={settings.mesh!r}"
)
print(
" NOTE: overlap between views is NOT resolved in this step; "
"merging needs surface-space ownership (see texture_views.py)"
)
for _format_name, path in artifacts.items():
console.report(f" [{pair.id}] file {path.name} {path.stat().st_size:,} B")
return 0
@@ -1829,8 +1945,31 @@ def _axes_arg(raw: str) -> list[str]:
def main(argv: list[str] | None = None) -> None:
# `--quiet` / `--verbose` are stripped here rather than added to all fourteen subparsers:
# one place to keep consistent, and they work in any position, including before the
# subcommand, which a per-subparser flag would not.
raw = list(sys.argv[1:] if argv is None else argv)
quiet = "--quiet" in raw
verbose = "--verbose" in raw
try:
console.set_level(console.level_from_flags(quiet=quiet, verbose=verbose))
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
raise SystemExit(2) from None
argv = [item for item in raw if item not in ("--quiet", "--verbose")]
parser = argparse.ArgumentParser(prog="orisys-design")
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
parser.add_argument(
"--quiet",
action="store_true",
help="Only the artifacts, one path per line (stripped before subcommand parsing)",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Also print internals, the rebuilt Blender command and its full log",
)
sub = parser.add_subparsers(dest="command", required=True)
validate_p = sub.add_parser("validate", help="Check revision metadata and optional Blender scene")
@@ -1919,7 +2058,7 @@ def main(argv: list[str] | None = None) -> None:
texture_views_p = sub.add_parser(
"texture-views",
help="Generate one independent texture per production view (no merge)",
help="Generate one owned texture per production view (partitioned, no double ink)",
)
texture_views_p.add_argument("revision_dir", type=Path)
texture_views_p.add_argument(
+130
View File
@@ -0,0 +1,130 @@
"""Console levels for the `orisys-design` CLI.
Three levels, one rule each:
* **default** — what was *decided* and what was *written*. Short enough to read at a glance,
complete enough to answer "did it work, what did it decide, where are the files".
* **--quiet** — stdout is exactly the artifact paths, one per line, nothing else. The output
is a file list, so it can be piped, and empty stdout unambiguously means nothing was written.
* **--verbose** — the default verbatim, plus everything the default elides. A superset, never
a different contract.
Why the Blender side has to be told
-----------------------------------
The Blender scripts are separate processes and cannot see the argparse namespace, so the level
is passed through the job's ``extra_args`` as ``--verbosity``. It is deliberately *not* a job
JSON key: the job is a content-addressed record of what to build, and a verbosity flag in it
would change the file between two runs that build the same thing.
Classification of a child process's output
------------------------------------------
The Blender side prefixes its lines so the parent can decide what to forward, because the
child's stdout is otherwise indistinguishable from Blender's own chatter::
::report:: <line> scene facts — forwarded at default and above
::detail:: <line> internals — forwarded at verbose only
<anything else> Blender itself — forwarded at verbose only
On failure the parent replays **both** streams verbatim at every level, prefixes stripped, so
a quiet failure is still fully diagnosable.
"""
from __future__ import annotations
import sys
QUIET = 0
DEFAULT = 1
VERBOSE = 2
#: Kept in step with the driver by `test_console_contract.py`; the two processes cannot share
#: a module because one runs under Blender and the host is stdlib-only.
REPORT_PREFIX = "::report::"
DETAIL_PREFIX = "::detail::"
_level = DEFAULT
def set_level(level: int) -> None:
global _level
_level = level
def get_level() -> int:
return _level
def verbosity_name(level: int | None = None) -> str:
"""The level as a word, for handing to a child process through the environment."""
value = _level if level is None else level
return {QUIET: "quiet", DEFAULT: "default", VERBOSE: "verbose"}.get(value, "default")
def level_from_name(name: str | None) -> int:
"""Parse `ORISYS_CONSOLE_LEVEL`. Unknown values fall back to default, never to quiet."""
return {"quiet": QUIET, "verbose": VERBOSE}.get((name or "").strip().lower(), DEFAULT)
def level_from_flags(quiet: bool = False, verbose: bool = False) -> int:
"""Map argparse flags to a level. `--quiet` wins, and the two are mutually exclusive."""
if quiet and verbose:
raise ValueError("--quiet and --verbose are mutually exclusive")
if quiet:
return QUIET
if verbose:
return VERBOSE
return DEFAULT
def report(message: str) -> None:
"""A decision or fact worth seeing by default."""
if _level >= DEFAULT:
print(message)
def detail(message: str) -> None:
"""An internal, elided unless `--verbose`."""
if _level >= VERBOSE:
print(message)
def warn(message: str) -> None:
"""Non-fatal, always on stderr, at every level."""
print(message, file=sys.stderr)
def artifact_paths(paths) -> None:
"""`--quiet` stdout: the artifact list and nothing else, in order."""
for path in paths:
print(path)
def classify(line: str) -> tuple[str, str]:
"""Split a child-process line into ``(kind, text)``.
``kind`` is ``"report"``, ``"detail"`` or ``"raw"`` (Blender's own output).
"""
if line.startswith(REPORT_PREFIX):
return "report", line[len(REPORT_PREFIX):].lstrip()
if line.startswith(DETAIL_PREFIX):
return "detail", line[len(DETAIL_PREFIX):].lstrip()
return "raw", line
__all__ = [
"DEFAULT",
"DETAIL_PREFIX",
"QUIET",
"REPORT_PREFIX",
"VERBOSE",
"artifact_paths",
"classify",
"detail",
"get_level",
"level_from_flags",
"level_from_name",
"report",
"set_level",
"verbosity_name",
"warn",
]
+325 -24
View File
@@ -1,6 +1,6 @@
"""Multi-view production textures: one independent texture per production UV layout.
"""Multi-view production textures: one owned texture per production UV layout.
Scope — **step 1 only**. Each pair generates its own PNG/SVG/PDF, with:
Each pair generates its own PNG/SVG/PDF, with:
* **no ownership partition** — two pairs may cover the same surface region, and the
resulting overlap is *not* resolved here;
@@ -63,8 +63,68 @@ MULTIVIEW_JOB_SCHEMA = "orisys-sensor-design/texture-job-multiview-v1"
MULTIVIEW_JOB_FILENAME = "_texture_views_job.json"
VIEWS_BLOCK_KEY = "texture_patch_views"
_BLOCK_KEYS = frozenset({"enabled", "pattern_camera", "pairs"})
_PAIR_KEYS = frozenset({"id", "camera", "uv_map", "filename", "seed"})
_BLOCK_KEYS = frozenset({"enabled", "pattern_camera", "pairs", "merge", "seam", "registration",
"print"})
_PAIR_KEYS = frozenset({"id", "camera", "uv_map", "filename", "seed", "pattern_camera"})
_MERGE_KEYS = frozenset({
"mode", "incidence_limit_deg", "min_footprint_mm2", "min_triangle_coverage",
"max_hole_fraction", "component_policy",
})
_SEAM_KEYS = frozenset({"boundary", "pins", "gap"})
_GAP_KEYS = frozenset({"enabled", "apply", "treatment", "alignment_tolerance_mm", "total_mm"})
#: How the margin is applied.
#:
#: ``post_process`` clip the already-generated UV polygons against the seam's offset
#: half-space. Exact, straight-edged, and the width is the requested
#: *physical* distance because the half-space is derived in the surface and
#: mapped through the triangle's own Jacobian. Default.
#: ``geometry_trim`` drop every fragment that comes within the margin. Cheap, but a fragment
#: is kept or dropped whole, so the edge snaps to fragment boundaries and
#: the band comes out sawtoothed and 0-to-one-fragment too wide. Kept for
#: comparison and for the record.
#:
#: ``no_print`` the band carries nothing. Implemented.
#: ``reduced_density`` a coarser/dithered pattern in the band so the two passes blend instead
#: of butting together. Accepted by the schema, refused at resolve time
#: until it exists, so it cannot silently do nothing.
GAP_APPLY_MODES = ("post_process", "geometry_trim")
GAP_DEFAULT_APPLY = "post_process"
GAP_TREATMENTS = ("no_print", "reduced_density")
GAP_DEFAULT_TREATMENT = "no_print"
GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM = 0.1
_PRINT_KEYS = frozenset({"base_fill"})
#: What is drawn under the colour patches, i.e. the white ground the marker sits on.
#:
#: Every artifact also carries a full-page black `<rect id="background-fill">`. That
#: background *is* a print element, so it must not be confused with "no print": the only way
#: to keep a region unprinted is to leave it at the background, and the only way to make it
#: print white is to draw the mesh layer there.
#:
#: ``owned`` white mesh layer over every triangle this view owns. The default: it is the
#: same white ground the single-view `compute` texture carries, **split by the
#: seam**, so the two files tile the part without either one painting white over
#: the other's territory.
#: ``inked`` white only under triangles that actually receive a patch. Narrower ground;
#: leaves owned-but-unpatterned surface at the background.
#: ``none`` no white layer at all. Only the coloured patches print. Use when no white
#: ground is wanted; note that the region then reads as the black background and
#: is indistinguishable from the split mask.
BASE_FILL_MODES = ("none", "inked", "owned")
PRINT_DEFAULTS = {"base_fill": "owned"}
#: Mirrors `blender/scripts/texture_merge.py`, which owns the real defaults. Duplicated
#: here (rather than imported) because the host is stdlib-only and must not import a
#: Blender-side module; a test asserts the two agree.
MERGE_DEFAULTS = {
"mode": "computed",
"incidence_limit_deg": 62.0,
"min_footprint_mm2": 2.0e-3,
"min_triangle_coverage": 0.60,
"max_hole_fraction": 0.02,
"component_policy": "best_coverage",
}
#: Default artifact stem per pair. Derived from the pair id so a pair never has to name its
#: own files unless it wants to; ``{id}`` is substituted.
@@ -77,14 +137,17 @@ class ViewPairConfigError(TextureConfigError):
@dataclass(frozen=True)
class ViewPair:
"""One production UV layout, and the pattern camera whose cells colour it.
"""One production view: its region camera, its UV layout, and the pattern source.
``camera`` is the **pattern source** (normally the sensing camera), not the view that
authored the layout. ``uv_map`` is the layout the pattern is written into.
``camera`` is the **region** camera — the production view that decides which surface this
pair may own (via visibility and incidence). ``pattern_camera`` is where the ink comes
from, normally the sensing camera, so every layout carries the same surface pattern.
``uv_map`` is the layout the pattern is written into.
"""
id: str
camera: str
pattern_camera: str
uv_map: str
target: TextureOutputTarget
seed: int | None
@@ -93,6 +156,7 @@ class ViewPair:
return {
"id": self.id,
"camera": self.camera,
"pattern_camera": self.pattern_camera,
"uv_map": self.uv_map,
"seed": self.seed,
"artifacts": {
@@ -150,6 +214,7 @@ def validate_views_block(
seen_ids: set[str] = set()
seen_files: dict[str, str] = {}
seen_camera_layout: dict[tuple[str, str], str] = {}
seen_region: dict[str, str] = {}
for index, pair in enumerate(pairs):
where = f"{VIEWS_BLOCK_KEY}.pairs[{index}]"
if not isinstance(pair, Mapping):
@@ -160,18 +225,16 @@ def validate_views_block(
errors.append(
f"{where}: unknown key(s) {extra}; allowed: {sorted(_PAIR_KEYS)}"
)
# `camera` is optional: the pattern source defaults to the sensing camera.
for key in ("id", "uv_map"):
# `camera` is the REGION camera: the production view that decides what this pair may
# own, so it is required. The pattern source is separate (`pattern_camera`).
for key in ("id", "camera", "uv_map"):
value = pair.get(key)
if not isinstance(value, str) or not value.strip():
errors.append(f"{where}.{key}: must be a non-empty string")
if "camera" in pair:
value = pair.get("camera")
errors.append(f"{where}.{key}: must be a non-empty object name/string")
if "pattern_camera" in pair:
value = pair.get("pattern_camera")
if not isinstance(value, str) or not value.strip():
errors.append(
f"{where}.camera: must be a non-empty object name "
"(omit it to use the pattern camera)"
)
errors.append(f"{where}.pattern_camera: must be a non-empty object name")
pair_id = pair.get("id")
if isinstance(pair_id, str) and pair_id.strip():
if pair_id in seen_ids:
@@ -192,9 +255,21 @@ def validate_views_block(
if seed is not None and (not isinstance(seed, int) or isinstance(seed, bool)):
errors.append(f"{where}.seed: must be an integer or omitted")
uv_map = pair.get("uv_map")
# Compare on the *effective* camera, so two pairs that both default to the sensing
# camera and write the same layout are still caught as a copy-paste.
effective_camera = pair.get("camera") or pattern_camera or "?"
# Two pairs sharing a REGION camera claim the same surface, and ownership would hand
# everything to whichever comes first in priority order while the other writes an
# empty texture. That is always a config mistake, not a subtlety.
region = pair.get("camera")
if isinstance(region, str) and region.strip():
if region in seen_region:
errors.append(
f"{where}.camera: region camera {region!r} is already used by pair "
f"{seen_region[region]!r}; both would claim the same surface region and "
"one of them would own nothing"
)
seen_region[region] = str(pair_id)
# Compare on the *effective* pattern camera, so two pairs that both default to the
# sensing camera and write the same layout are still caught as a copy-paste.
effective_camera = pair.get("pattern_camera") or pattern_camera or "?"
if isinstance(uv_map, str) and uv_map:
key = (str(effective_camera), uv_map)
if key in seen_camera_layout:
@@ -204,6 +279,149 @@ def validate_views_block(
"would be identical"
)
seen_camera_layout[key] = str(pair_id)
errors.extend(validate_merge_block(block.get("merge")))
errors.extend(validate_seam_block(block.get("seam")))
errors.extend(validate_print_block(block.get("print")))
return errors
def validate_print_block(print_block: Any) -> list[str]:
"""Validate the optional ``print`` sub-block (what is deposited where)."""
if print_block is None:
return []
where = f"{VIEWS_BLOCK_KEY}.print"
if not isinstance(print_block, Mapping):
return [f"{where}: must be a mapping"]
errors: list[str] = []
extra = sorted(set(print_block) - _PRINT_KEYS)
if extra:
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_PRINT_KEYS)}")
mode = print_block.get("base_fill", PRINT_DEFAULTS["base_fill"])
if mode not in BASE_FILL_MODES:
errors.append(f"{where}.base_fill: must be one of {list(BASE_FILL_MODES)} (got {mode!r})")
return errors
def validate_merge_block(merge: Any) -> list[str]:
"""Validate the optional ``merge`` sub-block (ownership policy)."""
if merge is None:
return []
errors: list[str] = []
where = f"{VIEWS_BLOCK_KEY}.merge"
if not isinstance(merge, Mapping):
return [f"{where}: must be a mapping"]
extra = sorted(set(merge) - _MERGE_KEYS)
if extra:
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_MERGE_KEYS)}")
mode = merge.get("mode", MERGE_DEFAULTS["mode"])
if mode not in ("computed", "authored"):
errors.append(f"{where}.mode: must be 'computed' or 'authored' (got {mode!r})")
component_policy = merge.get("component_policy", MERGE_DEFAULTS["component_policy"])
if component_policy not in ("best_coverage", "pinned"):
errors.append(
f"{where}.component_policy: must be 'best_coverage' or 'pinned' "
f"(got {component_policy!r})"
)
for key in ("incidence_limit_deg", "min_footprint_mm2", "min_triangle_coverage",
"max_hole_fraction"):
if key in merge and not isinstance(merge[key], (int, float)):
errors.append(f"{where}.{key}: must be a number")
for key in ("min_triangle_coverage", "max_hole_fraction"):
value = merge.get(key)
if isinstance(value, (int, float)) and not 0.0 <= float(value) <= 1.0:
errors.append(f"{where}.{key}: must be in [0, 1] (got {value!r})")
limit = merge.get("incidence_limit_deg")
if isinstance(limit, (int, float)) and not 0.0 <= float(limit) <= 90.0:
errors.append(f"{where}.incidence_limit_deg: must be in [0, 90] (got {limit!r})")
return errors
def validate_seam_block(seam: Any) -> list[str]:
"""Validate the optional ``seam`` sub-block (authored cut and its margin)."""
if seam is None:
return []
where = f"{VIEWS_BLOCK_KEY}.seam"
if not isinstance(seam, Mapping):
return [f"{where}: must be a mapping"]
errors: list[str] = []
extra = sorted(set(seam) - _SEAM_KEYS)
if extra:
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_SEAM_KEYS)}")
boundary = seam.get("boundary", "use_seam")
if boundary != "use_seam":
errors.append(f"{where}.boundary: only 'use_seam' is supported (got {boundary!r})")
errors.extend(validate_gap_block(seam.get("gap")))
pins = seam.get("pins") or []
if not isinstance(pins, list):
errors.append(f"{where}.pins: must be a list")
return errors
seen: set[int] = set()
for index, pin in enumerate(pins):
pat = f"{where}.pins[{index}]"
if not isinstance(pin, Mapping):
errors.append(f"{pat}: must be a mapping")
continue
extra = sorted(set(pin) - {"component", "view", "reason"})
if extra:
errors.append(f"{pat}: unknown key(s) {extra}; allowed: ['component', 'reason', 'view']")
component = pin.get("component")
if not isinstance(component, int) or isinstance(component, bool) or component < 0:
errors.append(f"{pat}.component: must be a non-negative integer")
elif component in seen:
errors.append(f"{pat}.component: duplicate pin for component {component}")
else:
seen.add(component)
view = pin.get("view")
if not isinstance(view, str) or not view.strip():
errors.append(f"{pat}.view: must be a non-empty view id")
reason = pin.get("reason")
if not isinstance(reason, str) or not reason.strip():
errors.append(f"{pat}.reason: must be a non-empty string (say why)")
return errors
def validate_gap_block(gap: Any) -> list[str]:
"""Validate the optional ``seam.gap`` sub-block (the margin along the seam).
``gap: false`` disables the margin explicitly; omitting the block enables it at the
default alignment tolerance.
"""
if gap is None:
return []
where = f"{VIEWS_BLOCK_KEY}.seam.gap"
if gap is False:
return []
if not isinstance(gap, Mapping):
return [f"{where}: must be a mapping, or false to disable"]
errors: list[str] = []
extra = sorted(set(gap) - _GAP_KEYS)
if extra:
errors.append(f"{where}: unknown key(s) {extra}; allowed: {sorted(_GAP_KEYS)}")
if "enabled" in gap and not isinstance(gap["enabled"], bool):
errors.append(f"{where}.enabled: must be true or false")
apply_mode = gap.get("apply", GAP_DEFAULT_APPLY)
if apply_mode not in GAP_APPLY_MODES:
errors.append(f"{where}.apply: must be one of {list(GAP_APPLY_MODES)} (got {apply_mode!r})")
treatment = gap.get("treatment", GAP_DEFAULT_TREATMENT)
if treatment not in GAP_TREATMENTS:
errors.append(
f"{where}.treatment: must be one of {list(GAP_TREATMENTS)} (got {treatment!r})"
)
tolerance = gap.get("alignment_tolerance_mm", GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM)
if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool):
errors.append(f"{where}.alignment_tolerance_mm: must be a number")
elif float(tolerance) <= 0.0:
errors.append(
f"{where}.alignment_tolerance_mm: must be > 0 (got {tolerance!r}); "
"use gap: false or omit the block for no margin"
)
total = gap.get("total_mm")
if total is not None:
if not isinstance(total, (int, float)) or isinstance(total, bool):
errors.append(f"{where}.total_mm: must be a number or null")
elif float(total) <= 0.0:
errors.append(f"{where}.total_mm: must be > 0 (got {total!r})")
return errors
@@ -232,8 +450,8 @@ def resolve_view_pairs(
raise ViewPairConfigError(f"{VIEWS_BLOCK_KEY}.enabled is false")
# The pattern source. The production cameras authored the UV layouts (Project from
# View); they are not pattern sources, so the default is the sensing camera that also
# defines `compute`.
# View) and define each pair's REGION for ownership; neither is the pattern source, so
# the pattern default is the sensing camera that also defines `compute`.
pattern_camera = str(block.get("pattern_camera") or settings.camera)
base_seed = settings.seed
pairs: list[ViewPair] = []
@@ -247,7 +465,8 @@ def resolve_view_pairs(
pairs.append(
ViewPair(
id=pair_id,
camera=str(raw.get("camera") or pattern_camera),
camera=str(raw["camera"]),
pattern_camera=str(raw.get("pattern_camera") or pattern_camera),
uv_map=str(raw["uv_map"]),
target=TextureOutputTarget(
uv_map=str(raw["uv_map"]),
@@ -259,16 +478,95 @@ def resolve_view_pairs(
return tuple(pairs)
def resolve_merge_policy(block: Mapping[str, Any] | None) -> dict[str, Any]:
"""Merge the optional ``merge`` sub-block over the calibrated defaults."""
policy = dict(MERGE_DEFAULTS)
if block:
merge = block.get("merge") or {}
if isinstance(merge, Mapping):
for key in _MERGE_KEYS:
if key in merge:
policy[key] = merge[key]
return policy
def resolve_seam_settings(block: Mapping[str, Any] | None) -> dict[str, Any]:
"""Resolve the optional ``seam`` sub-block into the job's seam settings.
The margin defaults to ON at the 0.1 mm alignment tolerance, because a band along the
seam is what absorbs two-pass registration error. ``seam.gap: false`` turns it off.
"""
seam = (block or {}).get("seam") or {}
pins = seam.get("pins") or []
raw_gap = seam.get("gap")
if raw_gap is False:
gap_settings = {
"enabled": False,
"requested": False,
"apply": GAP_DEFAULT_APPLY,
"treatment": GAP_DEFAULT_TREATMENT,
"alignment_tolerance_mm": 0.0,
"total_mm": 0.0,
"inset_mm": 0.0,
}
else:
gap = raw_gap if isinstance(raw_gap, Mapping) else {}
treatment = str(gap.get("treatment", GAP_DEFAULT_TREATMENT))
if treatment != "no_print":
raise ViewPairConfigError(
f"{VIEWS_BLOCK_KEY}.seam.gap.treatment: {treatment!r} is accepted by the "
"schema but is not implemented yet; only 'no_print' does anything today. "
"Refusing rather than silently treating the band as 'no_print'."
)
apply_mode = str(gap.get("apply", GAP_DEFAULT_APPLY))
tolerance = float(gap.get("alignment_tolerance_mm", GAP_DEFAULT_ALIGNMENT_TOLERANCE_MM))
total = gap.get("total_mm")
# Two passes each placed to within `tolerance` can close on each other by twice that,
# so the band that guarantees no overlap is 2 x tolerance wide, half per file.
total_mm = (2.0 * tolerance) if total is None else float(total)
requested = bool(gap.get("enabled", True))
gap_settings = {
"enabled": requested and total_mm > 0.0,
"requested": requested,
"apply": apply_mode,
"treatment": treatment,
"alignment_tolerance_mm": tolerance,
"total_mm": total_mm,
"inset_mm": total_mm / 2.0,
}
return {
"boundary": seam.get("boundary", "use_seam"),
"gap": gap_settings,
"pins": [
{"component": int(pin["component"]), "view": str(pin["view"]),
"reason": str(pin.get("reason", ""))}
for pin in pins
],
}
def resolve_print_settings(block: Mapping[str, Any] | None) -> dict[str, Any]:
"""Resolve the optional ``print`` sub-block into the job's print settings."""
settings = dict(PRINT_DEFAULTS)
sub = (block or {}).get("print") or {}
if isinstance(sub, Mapping) and "base_fill" in sub:
settings["base_fill"] = str(sub["base_fill"])
return settings
def build_multiview_job(
paths: RevisionPaths,
settings: TexturePatchSettings,
pairs: Sequence[ViewPair],
output_dir: Path,
block: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Build the JSON job ``random_patch_multiview.py`` consumes."""
if not pairs:
raise ViewPairConfigError("no view pairs to generate")
pattern_cameras = sorted({pair.camera for pair in pairs})
if block is None:
block = load_views_block(paths)
pattern_cameras = sorted({pair.pattern_camera for pair in pairs})
return {
"schema": MULTIVIEW_JOB_SCHEMA,
"mode": "patches-multiview",
@@ -276,8 +574,11 @@ def build_multiview_job(
"source_blend": str(paths.source_blend.resolve()),
"objects": {"mesh": settings.mesh},
# Recorded so a reader can see which camera defined the pattern, and that the
# production cameras merely chose the layouts.
# production cameras merely chose the layouts and own the regions.
"pattern_camera": pattern_cameras[0] if len(pattern_cameras) == 1 else pattern_cameras,
"merge": resolve_merge_policy(block),
"seam": resolve_seam_settings(block),
"print": resolve_print_settings(block),
"settings": settings.to_job_settings(),
"pairs": [pair.to_job_entry(Path(output_dir)) for pair in pairs],
}