added svg patch output

This commit is contained in:
ykcheungab
2026-09-19 15:08:57 +08:00
parent b1815b15ea
commit a883288c13
18 changed files with 71615 additions and 180 deletions
+14 -8
View File
@@ -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
+72 -20
View File
@@ -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(