added svg patch output
This commit is contained in:
@@ -257,25 +257,31 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||
return code
|
||||
|
||||
missing = [
|
||||
(target, path)
|
||||
for target, path in targets
|
||||
(target, format_name, path)
|
||||
for target, artifacts in targets
|
||||
for format_name, path in artifacts.items()
|
||||
if not path.is_file() or path.stat().st_size == 0
|
||||
]
|
||||
if missing:
|
||||
for target, path in missing:
|
||||
for target, format_name, path in missing:
|
||||
print(
|
||||
f"ERROR: Blender finished but the texture for UV map "
|
||||
f"ERROR: Blender finished but the {format_name.upper()} artifact for UV map "
|
||||
f"{target.uv_map!r} is missing or empty: {path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
print(f"OK: wrote {len(targets)} colored patch texture(s) to {output_dir}")
|
||||
for target, path in targets:
|
||||
print(f" [{target.uv_map}] {path}")
|
||||
artifact_count = sum(len(artifacts) for _, artifacts in targets)
|
||||
print(f"OK: wrote {artifact_count} colored patch artifact(s) to {output_dir}")
|
||||
for target, artifacts in targets:
|
||||
print(f" [{target.uv_map}]")
|
||||
for format_name, path in artifacts.items():
|
||||
print(f" {format_name.upper()}: {path}")
|
||||
print(
|
||||
f" mode=patches {settings.width}x{settings.height} "
|
||||
f"grid={settings.nx}x{settings.ny} mesh={settings.mesh!r} camera={settings.camera!r}"
|
||||
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} camera={settings.camera!r}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ handed to Blender is absolute, so no manual copying between directories is
|
||||
required.
|
||||
|
||||
A revision may carry several UV maps on the sensing surface (for example
|
||||
``produce`` and ``compute``). ``texture_patch.uv_outputs`` names one output PNG
|
||||
per UV map; a single run renders all of them with the same camera-space patch
|
||||
colours, so the files differ only in how the unwrap arranges the patches.
|
||||
``produce`` and ``compute``). ``texture_patch.uv_outputs`` names one artifact
|
||||
stem per UV map; a single run writes matching PNG, editable SVG, and vector PDF
|
||||
files with the same camera-space patch colours. The files differ only in how
|
||||
the unwrap arranges the patches.
|
||||
|
||||
UV unwrapping is deliberately outside this module: the headless run consumes the
|
||||
UV maps already authored in the Blender GUI.
|
||||
@@ -28,10 +29,11 @@ from orisys_sensor_design.manifest import (
|
||||
load_yaml,
|
||||
)
|
||||
|
||||
TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v2"
|
||||
TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v3"
|
||||
TEXTURE_JOB_FILENAME = "_texture_job.json"
|
||||
TEXTURE_RELATIVE_ROOT = Path("build") / "textures"
|
||||
TEXTURE_MODE = "patches"
|
||||
TEXTURE_ARTIFACT_FORMATS = ("png", "svg", "pdf")
|
||||
|
||||
# Built-in fallbacks. sensors/<sensor>/<revision>/design.yaml is the source of
|
||||
# truth for a revision; these only apply when a key is absent there.
|
||||
@@ -51,6 +53,10 @@ DEFAULT_TEXTURE_PATCH: dict[str, Any] = {
|
||||
"color_r": 0.05,
|
||||
"edge_sample_grid": 5,
|
||||
"seed": None,
|
||||
"vector": {
|
||||
"width_mm": 164.0,
|
||||
"height_mm": 160.0,
|
||||
},
|
||||
"cross": {
|
||||
"enabled": True,
|
||||
"length_px": 40,
|
||||
@@ -71,10 +77,12 @@ _TEXTURE_PATCH_KEYS = frozenset(
|
||||
"color_r",
|
||||
"edge_sample_grid",
|
||||
"seed",
|
||||
"vector",
|
||||
"cross",
|
||||
}
|
||||
)
|
||||
_CROSS_KEYS = frozenset({"enabled", "length_px", "thickness_px", "color"})
|
||||
_VECTOR_KEYS = frozenset({"width_mm", "height_mm"})
|
||||
|
||||
_MAX_TEXTURE_EDGE = 32768
|
||||
|
||||
@@ -88,19 +96,37 @@ class TextureConfigError(ValueError):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextureOutputTarget:
|
||||
"""One generated PNG: the mesh UV map it reads and the file it writes."""
|
||||
"""One UV layout and the common stem of its PNG/SVG/PDF artifacts."""
|
||||
|
||||
uv_map: str
|
||||
filename: str
|
||||
|
||||
def artifact_filename(self, format_name: str) -> str:
|
||||
"""Return the configured PNG name with the requested artifact suffix."""
|
||||
if format_name not in TEXTURE_ARTIFACT_FORMATS:
|
||||
raise ValueError(f"Unsupported texture artifact format: {format_name!r}")
|
||||
return str(Path(self.filename).with_suffix(f".{format_name}"))
|
||||
|
||||
def output_path(self, output_dir: Path) -> Path:
|
||||
return Path(output_dir) / self.filename
|
||||
"""Backward-compatible convenience for the PNG artifact path."""
|
||||
return Path(output_dir) / self.artifact_filename("png")
|
||||
|
||||
def artifact_paths(self, output_dir: Path) -> dict[str, Path]:
|
||||
"""Return absolute artifact paths in stable PNG/SVG/PDF order."""
|
||||
resolved_dir = Path(output_dir).resolve()
|
||||
return {
|
||||
format_name: resolved_dir / self.artifact_filename(format_name)
|
||||
for format_name in TEXTURE_ARTIFACT_FORMATS
|
||||
}
|
||||
|
||||
def to_job_entry(self, output_dir: Path) -> dict[str, Any]:
|
||||
"""Return the job entry Blender consumes (absolute path, ordered)."""
|
||||
"""Return the v3 job entry Blender consumes (absolute paths, ordered)."""
|
||||
return {
|
||||
"uv_map": self.uv_map,
|
||||
"output": str(self.output_path(output_dir).resolve()),
|
||||
"artifacts": {
|
||||
format_name: str(path)
|
||||
for format_name, path in self.artifact_paths(output_dir).items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +148,8 @@ class TexturePatchSettings:
|
||||
color_r: float
|
||||
edge_sample_grid: int
|
||||
seed: int | None
|
||||
vector_width_mm: float
|
||||
vector_height_mm: float
|
||||
cross_enabled: bool
|
||||
cross_length_px: int
|
||||
cross_thickness_px: int
|
||||
@@ -137,6 +165,10 @@ class TexturePatchSettings:
|
||||
"color_r": self.color_r,
|
||||
"edge_sample_grid": self.edge_sample_grid,
|
||||
"seed": self.seed,
|
||||
"vector": {
|
||||
"width_mm": self.vector_width_mm,
|
||||
"height_mm": self.vector_height_mm,
|
||||
},
|
||||
"cross": {
|
||||
"enabled": self.cross_enabled,
|
||||
"length_px": self.cross_length_px,
|
||||
@@ -202,19 +234,24 @@ def merge_texture_patch(
|
||||
for key, value in block.items():
|
||||
if key not in _TEXTURE_PATCH_KEYS:
|
||||
continue
|
||||
if key == "cross":
|
||||
nested_keys = {
|
||||
"cross": _CROSS_KEYS,
|
||||
"vector": _VECTOR_KEYS,
|
||||
}
|
||||
if key in nested_keys:
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"{where}.cross: must be a mapping")
|
||||
errors.append(f"{where}.{key}: must be a mapping")
|
||||
continue
|
||||
unknown_cross = sorted(set(value) - _CROSS_KEYS)
|
||||
if unknown_cross:
|
||||
allowed_nested = nested_keys[key]
|
||||
unknown_nested = sorted(set(value) - allowed_nested)
|
||||
if unknown_nested:
|
||||
errors.append(
|
||||
f"{where}.cross: unknown key(s) {', '.join(unknown_cross)} "
|
||||
f"(allowed: {', '.join(sorted(_CROSS_KEYS))})"
|
||||
f"{where}.{key}: unknown key(s) {', '.join(unknown_nested)} "
|
||||
f"(allowed: {', '.join(sorted(allowed_nested))})"
|
||||
)
|
||||
for cross_key, cross_value in value.items():
|
||||
if cross_key in _CROSS_KEYS:
|
||||
merged["cross"][cross_key] = cross_value
|
||||
for nested_key, nested_value in value.items():
|
||||
if nested_key in allowed_nested:
|
||||
merged[key][nested_key] = nested_value
|
||||
continue
|
||||
merged[key] = value
|
||||
|
||||
@@ -304,6 +341,15 @@ def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> li
|
||||
if seed is not None and not _is_int(seed):
|
||||
errors.append(f"seed: must be an integer or null (got {seed!r})")
|
||||
|
||||
vector = block.get("vector")
|
||||
if not isinstance(vector, dict):
|
||||
errors.append("vector: must be a mapping")
|
||||
else:
|
||||
for key in ("width_mm", "height_mm"):
|
||||
value = vector.get(key)
|
||||
if not _is_number(value) or float(value) <= 0.0:
|
||||
errors.append(f"vector.{key}: must be a positive number (got {value!r})")
|
||||
|
||||
cross = block.get("cross")
|
||||
if not isinstance(cross, dict):
|
||||
errors.append("cross: must be a mapping")
|
||||
@@ -337,6 +383,7 @@ def resolve_texture_settings(
|
||||
raise TextureConfigError("\n".join(errors))
|
||||
|
||||
cross = block["cross"]
|
||||
vector = block["vector"]
|
||||
color = [float(channel) for channel in cross["color"]]
|
||||
if len(color) == 3:
|
||||
color.append(1.0)
|
||||
@@ -357,6 +404,8 @@ def resolve_texture_settings(
|
||||
color_r=float(block["color_r"]),
|
||||
edge_sample_grid=int(block["edge_sample_grid"]),
|
||||
seed=None if block["seed"] is None else int(block["seed"]),
|
||||
vector_width_mm=float(vector["width_mm"]),
|
||||
vector_height_mm=float(vector["height_mm"]),
|
||||
cross_enabled=bool(cross["enabled"]),
|
||||
cross_length_px=int(cross["length_px"]),
|
||||
cross_thickness_px=int(cross["thickness_px"]),
|
||||
@@ -406,10 +455,13 @@ def texture_job_path(output_dir: Path) -> Path:
|
||||
def texture_output_paths(
|
||||
output_dir: Path,
|
||||
settings: TexturePatchSettings,
|
||||
) -> list[tuple[TextureOutputTarget, Path]]:
|
||||
"""Pair every configured target with its absolute output path, in order."""
|
||||
) -> list[tuple[TextureOutputTarget, dict[str, Path]]]:
|
||||
"""Pair every target with its ordered PNG/SVG/PDF artifact paths."""
|
||||
resolved_dir = Path(output_dir).resolve()
|
||||
return [(target, target.output_path(resolved_dir)) for target in settings.outputs]
|
||||
return [
|
||||
(target, target.artifact_paths(resolved_dir))
|
||||
for target in settings.outputs
|
||||
]
|
||||
|
||||
|
||||
def build_texture_job(
|
||||
|
||||
Reference in New Issue
Block a user