adde produce + compute texture export
This commit is contained in:
@@ -113,10 +113,12 @@ build/export/finger/r01/
|
|||||||
|
|
||||||
## Texture patches (headless)
|
## Texture patches (headless)
|
||||||
|
|
||||||
`orisys-design texture` generates the colored patch texture headlessly. UV
|
`orisys-design texture` generates one colored patch texture per UV map on the
|
||||||
unwrapping is **still an in-app Blender step**: this command consumes the UV map
|
sensing surface. UV unwrapping is **still an in-app Blender step**: this command
|
||||||
already stored in `source_blend` and never creates or unwraps one. Unwrap the
|
consumes the UV maps already stored in `source_blend` and never creates or
|
||||||
mesh in the Blender GUI, save the `.blend`, then run the command.
|
unwraps one. Unwrap the mesh in the Blender GUI, save the `.blend`, then run the
|
||||||
|
command. A `.blend` saved in Edit mode is fine — the headless run enters Object
|
||||||
|
mode in memory and never writes the file.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
orisys-design texture sensors/finger/r01
|
orisys-design texture sensors/finger/r01
|
||||||
@@ -128,17 +130,25 @@ Output is revision-scoped, so nothing has to be copied by hand:
|
|||||||
```text
|
```text
|
||||||
build/textures/finger/r01/
|
build/textures/finger/r01/
|
||||||
├── _texture_job.json # Blender input, regenerated by texture (gitignored)
|
├── _texture_job.json # Blender input, regenerated by texture (gitignored)
|
||||||
└── patch_texture.png # colored patch texture (tracked, Git LFS)
|
├── patch_texture_produce.png # from the `produce` UV map (tracked, Git LFS)
|
||||||
|
└── patch_texture_compute.png # from the `compute` UV map (tracked, Git LFS)
|
||||||
```
|
```
|
||||||
|
|
||||||
Generated PNGs are **committed** so a revision's texture can be synced to
|
`produce` is the hardware-production layout and `compute` the compute/runtime
|
||||||
|
layout; they are the two UV maps authored on `sensing_surface`. Both outputs
|
||||||
|
share one set of settings and one seed, and both read the same camera-space
|
||||||
|
patch grid, so a given physical patch carries the same colour in either file —
|
||||||
|
the images differ only in how the unwrap arranges the patches.
|
||||||
|
|
||||||
|
Generated PNGs are **committed** so a revision's textures can be synced to
|
||||||
hardware production; `.gitattributes` already routes `*.png` through Git LFS
|
hardware production; `.gitattributes` already routes `*.png` through Git LFS
|
||||||
(`git lfs install` / `git lfs pull` as usual). Only `_texture_job.json` is
|
(`git lfs install` / `git lfs pull` as usual). Only `_texture_job.json` is
|
||||||
ignored, because it records absolute machine paths and is rewritten every run.
|
ignored, because it records absolute machine paths and is rewritten every run.
|
||||||
|
|
||||||
Defaults come from the `texture_patch` block in `design.yaml` — mesh, camera,
|
Defaults come from the `texture_patch` block in `design.yaml` — mesh, camera, the
|
||||||
texture size, patch grid, colour separation, edge sampling, reference cross,
|
`uv_outputs` map (UV map → PNG name), texture size, patch grid, colour
|
||||||
file name, and an optional seed. CLI flags override every value:
|
separation, edge sampling, reference cross, and an optional seed. CLI flags
|
||||||
|
override the shared values for every output:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
orisys-design texture sensors/finger/r01 `
|
orisys-design texture sensors/finger/r01 `
|
||||||
@@ -152,11 +162,18 @@ blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_pa
|
|||||||
--job D:\orisys_ws\orisys-sensor-design\build\textures\finger\r01\_texture_job.json
|
--job D:\orisys_ws\orisys-sensor-design\build\textures\finger\r01\_texture_job.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A single manual target is also possible (this cannot be combined with `--job`):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- `
|
||||||
|
--uv-map produce --output D:\tmp\produce.png --width 1024 --height 1024
|
||||||
|
```
|
||||||
|
|
||||||
Only the colored "patches" mode is reachable headlessly; the checker and dot
|
Only the colored "patches" mode is reachable headlessly; the checker and dot
|
||||||
helpers remain in `random_patch_dd02c.py` but are not exposed by the CLI. The
|
helpers remain in `random_patch_dd02c.py` but are not exposed by the CLI. The
|
||||||
default 3280×3200 grid matches the in-app script and is deliberately slow
|
default 3280×3200 grid matches the in-app script and is deliberately slow
|
||||||
(pure-Python rasterization) — use smaller `--width/--height/--nx/--ny` for quick
|
(pure-Python rasterization) — use smaller `--width/--height/--nx/--ny` for quick
|
||||||
check runs, and the full default grid for the texture you actually ship.
|
check runs, and the full default grid for the textures you actually ship.
|
||||||
|
|
||||||
Collection check:
|
Collection check:
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ and invokes this script. Blender only needs stdlib json — not PyYAML:
|
|||||||
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- \
|
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- \
|
||||||
--job <abs/build/textures/finger/r01/_texture_job.json>
|
--job <abs/build/textures/finger/r01/_texture_job.json>
|
||||||
|
|
||||||
Manual overrides (optional, win over job values):
|
Manual single-target mode (cannot be combined with --job):
|
||||||
|
|
||||||
--output FILE.png --mesh sensing_surface --cam cam_sensor --width 3280 --height 3200 \
|
--uv-map produce --output FILE.png --mesh sensing_surface --cam cam_sensor \
|
||||||
--nx 80 --ny 45 --color-r 0.05 --edge-sample-grid 5 --seed 7 --no-cross
|
--width 3280 --height 3200 --nx 80 --ny 45 --color-r 0.05 --seed 7 --no-cross
|
||||||
|
|
||||||
Scope: the colored "patches" mode only. checker/dots helpers remain available to
|
Scope: the colored "patches" mode only. checker/dots helpers remain available to
|
||||||
callers but are not reachable from the headless entry point. The mesh must
|
callers but are not reachable from the headless entry point. The mesh must
|
||||||
already have an active UV map: UV unwrapping is still an in-app Blender step and
|
already carry the requested UV maps: UV unwrapping is still an in-app Blender
|
||||||
is never performed here.
|
step and is never performed here. A scene saved in Edit mode is handled by
|
||||||
|
switching to Object mode in memory only — the .blend is never written.
|
||||||
|
|
||||||
- Mesh UVs are filled white so that non-visible parts become white.
|
- Mesh UVs are filled white so that non-visible parts become white.
|
||||||
- Texture background (no-mesh areas) stays black.
|
- Texture background (no-mesh areas) stays black.
|
||||||
@@ -220,9 +221,16 @@ def draw_ellipse(pixels, width, height, cx, cy, a, b, angle, color):
|
|||||||
idx = (y * width + x) * 4
|
idx = (y * width + x) * 4
|
||||||
pixels[idx:idx+4] = color
|
pixels[idx:idx+4] = color
|
||||||
|
|
||||||
def get_uv_from_face(obj, face_index, location):
|
def get_uv_from_face(obj, face_index, location, uv_layer=None):
|
||||||
"""World‑space location → UV coordinate on the mesh face."""
|
"""World‑space location → UV coordinate on the mesh face.
|
||||||
|
|
||||||
|
``uv_layer`` selects the mesh UV map explicitly. ``None`` keeps the previous
|
||||||
|
active-layer behaviour, which the non-headless checker/dot helpers rely on.
|
||||||
|
"""
|
||||||
me = obj.data
|
me = obj.data
|
||||||
|
layer = uv_layer if uv_layer is not None else me.uv_layers.active
|
||||||
|
if layer is None:
|
||||||
|
return None
|
||||||
local_loc = obj.matrix_world.inverted() @ location
|
local_loc = obj.matrix_world.inverted() @ location
|
||||||
poly = me.polygons[face_index]
|
poly = me.polygons[face_index]
|
||||||
vert_indices = poly.vertices
|
vert_indices = poly.vertices
|
||||||
@@ -232,10 +240,10 @@ def get_uv_from_face(obj, face_index, location):
|
|||||||
v0 = me.vertices[vert_indices[0]].co
|
v0 = me.vertices[vert_indices[0]].co
|
||||||
v1 = me.vertices[vert_indices[1]].co
|
v1 = me.vertices[vert_indices[1]].co
|
||||||
v2 = me.vertices[vert_indices[2]].co
|
v2 = me.vertices[vert_indices[2]].co
|
||||||
uv_layer = me.uv_layers.active.data
|
uv_data = layer.data
|
||||||
uv0 = uv_layer[loop_indices[0]].uv
|
uv0 = uv_data[loop_indices[0]].uv
|
||||||
uv1 = uv_layer[loop_indices[1]].uv
|
uv1 = uv_data[loop_indices[1]].uv
|
||||||
uv2 = uv_layer[loop_indices[2]].uv
|
uv2 = uv_data[loop_indices[2]].uv
|
||||||
def tri_area(a, b, c):
|
def tri_area(a, b, c):
|
||||||
return 0.5 * ((b - a).cross(c - a)).length
|
return 0.5 * ((b - a).cross(c - a)).length
|
||||||
total_area = tri_area(v0, v1, v2)
|
total_area = tri_area(v0, v1, v2)
|
||||||
@@ -261,14 +269,16 @@ def raycast_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, f
|
|||||||
return location, face_index
|
return location, face_index
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
def raycast_uv_from_camera(scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height):
|
def raycast_uv_from_camera(
|
||||||
|
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer=None
|
||||||
|
):
|
||||||
"""Camera raycast through normalized sensor coordinates and return UV."""
|
"""Camera raycast through normalized sensor coordinates and return UV."""
|
||||||
loc, face_idx = raycast_from_camera(
|
loc, face_idx = raycast_from_camera(
|
||||||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
|
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
|
||||||
)
|
)
|
||||||
if loc is None:
|
if loc is None:
|
||||||
return None
|
return None
|
||||||
return get_uv_from_face(obj, face_idx, loc)
|
return get_uv_from_face(obj, face_idx, loc, uv_layer)
|
||||||
|
|
||||||
def convex_hull_uv(points):
|
def convex_hull_uv(points):
|
||||||
"""Return UV points ordered around their 2D convex hull."""
|
"""Return UV points ordered around their 2D convex hull."""
|
||||||
@@ -301,7 +311,7 @@ def sample_patch_uv_polygon(
|
|||||||
scene, depsgraph, cam, obj,
|
scene, depsgraph, cam, obj,
|
||||||
u_start, u_end, v_start, v_end,
|
u_start, u_end, v_start, v_end,
|
||||||
frame_width, frame_height,
|
frame_width, frame_height,
|
||||||
edge_sample_grid=5
|
edge_sample_grid=5, uv_layer=None
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Build a UV polygon for one camera-space patch.
|
Build a UV polygon for one camera-space patch.
|
||||||
@@ -309,6 +319,8 @@ def sample_patch_uv_polygon(
|
|||||||
Full interior cells use the four corners. Edge cells that only partially hit
|
Full interior cells use the four corners. Edge cells that only partially hit
|
||||||
the mesh are sampled more densely and converted to a convex hull so they are
|
the mesh are sampled more densely and converted to a convex hull so they are
|
||||||
not dropped just because some corners miss the silhouette.
|
not dropped just because some corners miss the silhouette.
|
||||||
|
|
||||||
|
``uv_layer`` selects which mesh UV map the raycasts are resolved against.
|
||||||
"""
|
"""
|
||||||
corners_ndc = [
|
corners_ndc = [
|
||||||
(u_start, v_start),
|
(u_start, v_start),
|
||||||
@@ -320,7 +332,7 @@ def sample_patch_uv_polygon(
|
|||||||
corner_uvs = []
|
corner_uvs = []
|
||||||
for u_ndc, v_ndc in corners_ndc:
|
for u_ndc, v_ndc in corners_ndc:
|
||||||
uv = raycast_uv_from_camera(
|
uv = raycast_uv_from_camera(
|
||||||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
|
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer
|
||||||
)
|
)
|
||||||
if uv is not None:
|
if uv is not None:
|
||||||
corner_uvs.append(uv)
|
corner_uvs.append(uv)
|
||||||
@@ -334,24 +346,27 @@ def sample_patch_uv_polygon(
|
|||||||
for sx in range(edge_sample_grid):
|
for sx in range(edge_sample_grid):
|
||||||
u_ndc = u_start + (u_end - u_start) * sx / (edge_sample_grid - 1)
|
u_ndc = u_start + (u_end - u_start) * sx / (edge_sample_grid - 1)
|
||||||
uv = raycast_uv_from_camera(
|
uv = raycast_uv_from_camera(
|
||||||
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height
|
scene, depsgraph, cam, obj, u_ndc, v_ndc, frame_width, frame_height, uv_layer
|
||||||
)
|
)
|
||||||
if uv is not None:
|
if uv is not None:
|
||||||
sampled_uvs.append(uv)
|
sampled_uvs.append(uv)
|
||||||
|
|
||||||
return convex_hull_uv(sampled_uvs)
|
return convex_hull_uv(sampled_uvs)
|
||||||
|
|
||||||
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1)):
|
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1), uv_layer=None):
|
||||||
"""
|
"""
|
||||||
Fill all triangles of the active UV map with 'color' (default white).
|
Fill all triangles of one UV map with 'color' (default white).
|
||||||
This marks every part of the mesh, visible or not, as white.
|
This marks every part of the mesh, visible or not, as white.
|
||||||
|
|
||||||
|
``uv_layer`` selects the mesh UV map; ``None`` keeps the active-layer
|
||||||
|
behaviour used by the checker/dot helpers.
|
||||||
"""
|
"""
|
||||||
me = obj.data
|
me = obj.data
|
||||||
uv_layer = me.uv_layers.active
|
layer = uv_layer if uv_layer is not None else me.uv_layers.active
|
||||||
if not uv_layer:
|
if not layer:
|
||||||
print("Warning: No active UV layer – mesh rasterization skipped.")
|
print("Warning: no UV layer selected – mesh rasterization skipped.")
|
||||||
return
|
return
|
||||||
uv_data = uv_layer.data
|
uv_data = layer.data
|
||||||
for poly in me.polygons:
|
for poly in me.polygons:
|
||||||
# Get loop indices for this polygon
|
# Get loop indices for this polygon
|
||||||
loops = poly.loop_indices
|
loops = poly.loop_indices
|
||||||
@@ -508,7 +523,8 @@ def generate_patch_texture(
|
|||||||
nx=40, ny=30, r=0.05,
|
nx=40, ny=30, r=0.05,
|
||||||
texture_width=2048, texture_height=2048,
|
texture_width=2048, texture_height=2048,
|
||||||
output_path=None, uv_centre=None, cross_length=40,
|
output_path=None, uv_centre=None, cross_length=40,
|
||||||
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5
|
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
|
||||||
|
uv_layer=None, image_name="PatchTexture"
|
||||||
):
|
):
|
||||||
if not output_path:
|
if not output_path:
|
||||||
raise ValueError("output_path is required (absolute PNG path)")
|
raise ValueError("output_path is required (absolute PNG path)")
|
||||||
@@ -542,7 +558,7 @@ def generate_patch_texture(
|
|||||||
scene, depsgraph, cam, obj,
|
scene, depsgraph, cam, obj,
|
||||||
u_start, u_end, v_start, v_end,
|
u_start, u_end, v_start, v_end,
|
||||||
frame_width, frame_height,
|
frame_width, frame_height,
|
||||||
edge_sample_grid=edge_sample_grid
|
edge_sample_grid=edge_sample_grid, uv_layer=uv_layer
|
||||||
)
|
)
|
||||||
if uv_poly is None:
|
if uv_poly is None:
|
||||||
patch_uv_polygons.append(None)
|
patch_uv_polygons.append(None)
|
||||||
@@ -558,7 +574,7 @@ def generate_patch_texture(
|
|||||||
print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
|
print(f"Skipped {skipped_patches} patches with fewer than 3 mesh samples.")
|
||||||
|
|
||||||
# Create image – start with black background
|
# Create image – start with black background
|
||||||
img_name = "PatchTexture"
|
img_name = image_name
|
||||||
if img_name in bpy.data.images:
|
if img_name in bpy.data.images:
|
||||||
bpy.data.images.remove(bpy.data.images[img_name])
|
bpy.data.images.remove(bpy.data.images[img_name])
|
||||||
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
|
image = bpy.data.images.new(img_name, width=texture_width, height=texture_height, alpha=True)
|
||||||
@@ -568,7 +584,9 @@ def generate_patch_texture(
|
|||||||
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
|
||||||
|
|
||||||
# Rasterize all mesh UVs in white (visible + non‑visible parts)
|
# Rasterize all mesh UVs in white (visible + non‑visible parts)
|
||||||
rasterize_mesh_uvs(obj, pixels, texture_width, texture_height, color=(1,1,1,1))
|
rasterize_mesh_uvs(
|
||||||
|
obj, pixels, texture_width, texture_height, color=(1,1,1,1), uv_layer=uv_layer
|
||||||
|
)
|
||||||
|
|
||||||
# Now draw the visible patches on top
|
# Now draw the visible patches on top
|
||||||
idx = 0
|
idx = 0
|
||||||
@@ -858,7 +876,14 @@ def parse_args(argv):
|
|||||||
"--job", type=Path, default=None,
|
"--job", type=Path, default=None,
|
||||||
help="JSON texture job written by orisys-design (output + objects + settings)",
|
help="JSON texture job written by orisys-design (output + objects + settings)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--output", type=Path, default=None, help="Absolute output PNG (overrides job)")
|
parser.add_argument(
|
||||||
|
"--output", type=Path, default=None,
|
||||||
|
help="Absolute output PNG for a manual single-target run (needs --uv-map)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--uv-map", dest="uv_map", default=None,
|
||||||
|
help="Mesh UV map name for a manual single-target run (with --output)",
|
||||||
|
)
|
||||||
parser.add_argument("--mesh", default=None, help="Mesh object name (overrides job)")
|
parser.add_argument("--mesh", default=None, help="Mesh object name (overrides job)")
|
||||||
parser.add_argument("--cam", default=None, help="Camera object name (overrides job)")
|
parser.add_argument("--cam", default=None, help="Camera object name (overrides job)")
|
||||||
parser.add_argument("--width", type=int, default=None, help="Texture width (overrides job)")
|
parser.add_argument("--width", type=int, default=None, help="Texture width (overrides job)")
|
||||||
@@ -913,20 +938,68 @@ def _as_color(value, label):
|
|||||||
return tuple(rgba)
|
return tuple(rgba)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_output_path(raw, label="output"):
|
||||||
|
if raw is None or raw == "":
|
||||||
|
raise ConfigError(f"{label}: a PNG path is required")
|
||||||
|
if not isinstance(raw, (str, Path)):
|
||||||
|
raise ConfigError(f"{label}: must be a path (got {raw!r})")
|
||||||
|
path = Path(raw).expanduser()
|
||||||
|
if not path.is_absolute():
|
||||||
|
raise ConfigError(f"{label} must be an absolute path (got {raw!r})")
|
||||||
|
if path.suffix.lower() != ".png":
|
||||||
|
raise ConfigError(f"{label} must be a .png file (got {raw!r})")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_targets(args, job):
|
||||||
|
"""Return the ordered ``[(uv_map, output_path)]`` targets from the job or flags."""
|
||||||
|
if args.job is not None:
|
||||||
|
if args.output is not None or args.uv_map is not None:
|
||||||
|
raise ConfigError("--output/--uv-map cannot be combined with --job")
|
||||||
|
entries = job.get("outputs")
|
||||||
|
if not isinstance(entries, list) or not entries:
|
||||||
|
raise ConfigError("job 'outputs' must be a non-empty list of {uv_map, output}")
|
||||||
|
targets = []
|
||||||
|
for index, entry in enumerate(entries):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ConfigError(f"job outputs[{index}] must be a mapping")
|
||||||
|
uv_map = entry.get("uv_map")
|
||||||
|
if not isinstance(uv_map, str) or not uv_map.strip():
|
||||||
|
raise ConfigError(
|
||||||
|
f"job outputs[{index}]: 'uv_map' must be a non-empty string (got {uv_map!r})"
|
||||||
|
)
|
||||||
|
targets.append(
|
||||||
|
(uv_map, _as_output_path(entry.get("output"), f"job outputs[{index}].output"))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if args.uv_map is None or args.output is None:
|
||||||
|
raise ConfigError(
|
||||||
|
"Provide --job <path.json>, or both --uv-map <name> and --output <file.png>"
|
||||||
|
)
|
||||||
|
targets = [(args.uv_map, _as_output_path(args.output))]
|
||||||
|
|
||||||
|
seen_names = set()
|
||||||
|
seen_paths = {}
|
||||||
|
for uv_map, path in targets:
|
||||||
|
if uv_map in seen_names:
|
||||||
|
raise ConfigError(f"duplicate UV map {uv_map!r} in outputs")
|
||||||
|
seen_names.add(uv_map)
|
||||||
|
key = str(path).lower()
|
||||||
|
if key in seen_paths:
|
||||||
|
raise ConfigError(
|
||||||
|
f"duplicate output path for {seen_paths[key]!r} and {uv_map!r}: {path}"
|
||||||
|
)
|
||||||
|
seen_paths[key] = uv_map
|
||||||
|
return targets
|
||||||
|
|
||||||
|
|
||||||
def _resolve_config(args):
|
def _resolve_config(args):
|
||||||
"""Merge --job JSON with explicit flags. Explicit flags win over job values."""
|
"""Merge --job JSON with explicit flags. Explicit flags win over job values."""
|
||||||
job = {}
|
job = {}
|
||||||
if args.job is not None:
|
if args.job is not None:
|
||||||
job = _load_job(args.job.resolve())
|
job = _load_job(args.job.resolve())
|
||||||
|
|
||||||
output_raw = args.output or job.get("output")
|
targets = _resolve_targets(args, job)
|
||||||
if not output_raw:
|
|
||||||
raise ConfigError("Provide --job <path.json> and/or --output <file.png>")
|
|
||||||
output = Path(output_raw).expanduser()
|
|
||||||
if not output.is_absolute():
|
|
||||||
raise ConfigError(f"output must be an absolute path (got {str(output_raw)!r})")
|
|
||||||
if output.suffix.lower() != ".png":
|
|
||||||
raise ConfigError(f"output must be a .png file (got {str(output_raw)!r})")
|
|
||||||
|
|
||||||
objects = job.get("objects") or {}
|
objects = job.get("objects") or {}
|
||||||
if not isinstance(objects, dict):
|
if not isinstance(objects, dict):
|
||||||
@@ -984,7 +1057,7 @@ def _resolve_config(args):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"output": output,
|
"outputs": targets,
|
||||||
"mesh": str(mesh),
|
"mesh": str(mesh),
|
||||||
"camera": str(camera),
|
"camera": str(camera),
|
||||||
"width": width,
|
"width": width,
|
||||||
@@ -1010,21 +1083,55 @@ def _require_object(name, expected_type, label):
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def _uv_centre(obj):
|
def _prepare_mesh_object(name):
|
||||||
"""UV bounding-box centre; fails if the mesh was never unwrapped."""
|
"""Return the named mesh object in Object mode so its UV data is readable.
|
||||||
uv_layer = obj.data.uv_layers.active
|
|
||||||
if uv_layer is None or len(uv_layer.data) == 0:
|
source_blend may be saved with the object in Edit mode, where UV layer data
|
||||||
|
reads as empty. The switch is in memory only — this script never saves the
|
||||||
|
.blend file.
|
||||||
|
"""
|
||||||
|
obj = _require_object(name, "MESH", "Mesh")
|
||||||
|
if obj.mode != "OBJECT":
|
||||||
|
print(f"Switching {obj.name!r} from {obj.mode} to OBJECT (in memory only)")
|
||||||
|
try:
|
||||||
|
for other in bpy.context.view_layer.objects:
|
||||||
|
other.select_set(False)
|
||||||
|
bpy.context.view_layer.objects.active = obj
|
||||||
|
obj.select_set(True)
|
||||||
|
bpy.ops.object.mode_set(mode="OBJECT")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise SceneError(f"Could not leave Edit mode for {obj.name!r}: {exc}") from exc
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _require_uv_layer(obj, name):
|
||||||
|
"""Return the named UV layer, or fail listing the available maps."""
|
||||||
|
mesh = obj.data
|
||||||
|
for layer in mesh.uv_layers:
|
||||||
|
if layer.name == name:
|
||||||
|
if len(layer.data) == 0:
|
||||||
raise SceneError(
|
raise SceneError(
|
||||||
f"Mesh {obj.name!r} has no active UV map with loops. UV unwrapping is still an "
|
f"UV map {name!r} on mesh {obj.name!r} has no loop data. UV unwrapping "
|
||||||
"in-app Blender step: unwrap the mesh in the GUI, save the .blend, then re-run "
|
"is still an in-app Blender step: unwrap the mesh in the GUI, save the "
|
||||||
"orisys-design texture."
|
".blend, then re-run orisys-design texture."
|
||||||
)
|
)
|
||||||
|
return layer
|
||||||
|
available = ", ".join(sorted(layer.name for layer in mesh.uv_layers)) or "none"
|
||||||
|
raise SceneError(
|
||||||
|
f"Mesh {obj.name!r} has no UV map named {name!r} (available: {available}). UV "
|
||||||
|
"unwrapping is still an in-app Blender step: unwrap the mesh in the GUI, save the "
|
||||||
|
".blend, then re-run orisys-design texture."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _uv_centre(uv_layer):
|
||||||
|
"""UV bounding-box centre for one UV map."""
|
||||||
uv = np.array(
|
uv = np.array(
|
||||||
[uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))],
|
[uv_layer.data[i].uv.to_tuple() for i in range(len(uv_layer.data))],
|
||||||
dtype=np.float64,
|
dtype=np.float64,
|
||||||
)
|
)
|
||||||
centre = (uv.min(axis=0) + uv.max(axis=0)) / 2.0
|
centre = (uv.min(axis=0) + uv.max(axis=0)) / 2.0
|
||||||
print(f"UV centre: ({centre[0]:.4f}, {centre[1]:.4f})")
|
print(f"UV centre [{uv_layer.name}]: ({centre[0]:.4f}, {centre[1]:.4f})")
|
||||||
return centre
|
return centre
|
||||||
|
|
||||||
|
|
||||||
@@ -1036,50 +1143,76 @@ def main():
|
|||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
output = config["output"]
|
# Validate every target before rendering anything so a partial pair is never
|
||||||
|
# produced: the mesh mode, both UV maps and both output paths must be usable.
|
||||||
|
try:
|
||||||
|
obj = _prepare_mesh_object(config["mesh"])
|
||||||
|
cam = _require_object(config["camera"], "CAMERA", "Camera")
|
||||||
|
targets = [
|
||||||
|
(uv_map, output, _require_uv_layer(obj, uv_map))
|
||||||
|
for uv_map, output in config["outputs"]
|
||||||
|
]
|
||||||
|
except SceneError as exc:
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
for uv_map, output, _layer in targets:
|
||||||
try:
|
try:
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
print(f"ERROR: cannot create output directory {output.parent}: {exc}", file=sys.stderr)
|
print(
|
||||||
|
f"ERROR: cannot create output directory for UV map {uv_map!r} "
|
||||||
|
f"({output.parent}): {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
scene = bpy.context.scene
|
scene = bpy.context.scene
|
||||||
depsgraph = bpy.context.evaluated_depsgraph_get()
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
||||||
|
|
||||||
try:
|
|
||||||
obj = _require_object(config["mesh"], "MESH", "Mesh")
|
|
||||||
cam = _require_object(config["camera"], "CAMERA", "Camera")
|
|
||||||
uv_centre = _uv_centre(obj)
|
|
||||||
except SceneError as exc:
|
|
||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if config["seed"] is not None:
|
if config["seed"] is not None:
|
||||||
np.random.seed(config["seed"])
|
np.random.seed(config["seed"])
|
||||||
print(f"Using colour seed {config['seed']}")
|
print(f"Using colour seed {config['seed']}")
|
||||||
|
# One RNG snapshot is replayed for every target, so the same physical patch
|
||||||
|
# keeps the same colour in each UV layout (and reruns reproduce when seeded).
|
||||||
|
rng_state = np.random.get_state()
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
for uv_map, output, uv_layer in targets:
|
||||||
|
np.random.set_state(rng_state)
|
||||||
|
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
||||||
|
print(f"--- UV map {uv_map!r} -> {output}")
|
||||||
try:
|
try:
|
||||||
generate_patch_texture(
|
generate_patch_texture(
|
||||||
obj, cam, scene, depsgraph,
|
obj, cam, scene, depsgraph,
|
||||||
nx=config["nx"], ny=config["ny"], r=config["color_r"],
|
nx=config["nx"], ny=config["ny"], r=config["color_r"],
|
||||||
texture_width=config["width"], texture_height=config["height"],
|
texture_width=config["width"], texture_height=config["height"],
|
||||||
output_path=str(output),
|
output_path=str(output),
|
||||||
uv_centre=uv_centre if config["cross_enabled"] else None,
|
uv_centre=uv_centre,
|
||||||
cross_length=config["cross_length"],
|
cross_length=config["cross_length"],
|
||||||
cross_thickness=config["cross_thickness"],
|
cross_thickness=config["cross_thickness"],
|
||||||
cross_color=config["cross_color"],
|
cross_color=config["cross_color"],
|
||||||
edge_sample_grid=config["edge_sample_grid"],
|
edge_sample_grid=config["edge_sample_grid"],
|
||||||
|
uv_layer=uv_layer,
|
||||||
|
image_name=f"PatchTexture_{uv_map}",
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"ERROR: texture generation failed: {exc}", file=sys.stderr)
|
print(
|
||||||
|
f"ERROR: texture generation failed for UV map {uv_map!r}: {exc}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
if not output.is_file() or output.stat().st_size == 0:
|
if not output.is_file() or output.stat().st_size == 0:
|
||||||
print(f"ERROR: texture was not written: {output}", file=sys.stderr)
|
print(
|
||||||
|
f"ERROR: texture was not written for UV map {uv_map!r}: {output}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print(f"Texture complete: {output}")
|
print(f"Texture complete: {len(targets)} file(s)")
|
||||||
|
for uv_map, output, _layer in targets:
|
||||||
|
print(f" [{uv_map}] {output} ({output.stat().st_size} bytes)")
|
||||||
print(f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']}")
|
print(f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']}")
|
||||||
print(f"Completed in {time.time() - start_time:.2f} seconds.")
|
print(f"Completed in {time.time() - start_time:.2f} seconds.")
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0977139a3df265d2abbb5225ffe262da24b2cb4f8c9268cca93b92ae4a061f3c
|
||||||
|
size 628658
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bdd83c4138fd42308a262b9f2ea7d421e24cd0ef742262de1b02167698c5cc3b
|
||||||
|
size 693526
|
||||||
@@ -24,12 +24,16 @@ objects:
|
|||||||
|
|
||||||
# Headless colored-patch texture generation (`orisys-design texture`).
|
# Headless colored-patch texture generation (`orisys-design texture`).
|
||||||
# UV unwrapping is still done in the Blender GUI: the headless run consumes the
|
# UV unwrapping is still done in the Blender GUI: the headless run consumes the
|
||||||
# UV map already stored in source_blend and never creates or unwraps one.
|
# UV maps already stored in source_blend and never creates or unwraps one.
|
||||||
|
# One PNG is written per UV map below. All maps share the settings that follow
|
||||||
|
# and the same patch colours, so they differ only by UV layout.
|
||||||
# These are the revision defaults; CLI flags override every value.
|
# These are the revision defaults; CLI flags override every value.
|
||||||
texture_patch:
|
texture_patch:
|
||||||
mesh: sensing_surface
|
mesh: sensing_surface
|
||||||
camera: cam_sensor
|
camera: cam_sensor
|
||||||
filename: patch_texture.png
|
uv_outputs:
|
||||||
|
produce: patch_texture_produce.png # hardware production layout
|
||||||
|
compute: patch_texture_compute.png # compute/runtime layout
|
||||||
width: 3280 # 1640*2
|
width: 3280 # 1640*2
|
||||||
height: 3200 # 1600*2
|
height: 3200 # 1600*2
|
||||||
nx: 80
|
nx: 80
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e2a9aba857f0ea951da02424d36ae28a62c2cabafbaadaa15d06b6626ae46e39
|
oid sha256:2e21136b063a6694882db3d24ac331cc6d66683ade570b8266b6ad4baba6ba61
|
||||||
size 673299
|
size 688142
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from orisys_sensor_design.texture import (
|
|||||||
build_texture_job,
|
build_texture_job,
|
||||||
resolve_texture_output_dir,
|
resolve_texture_output_dir,
|
||||||
resolve_texture_settings,
|
resolve_texture_settings,
|
||||||
|
texture_output_paths,
|
||||||
texture_overrides_from_args,
|
texture_overrides_from_args,
|
||||||
write_texture_job,
|
write_texture_job,
|
||||||
)
|
)
|
||||||
@@ -224,7 +225,7 @@ def cmd_export(revision_dir: Path, profile: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
||||||
"""Generate the colored patch texture headlessly for one revision."""
|
"""Generate one colored patch texture per configured UV map, headlessly."""
|
||||||
root = repo_root(revision_dir)
|
root = repo_root(revision_dir)
|
||||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||||
errors = validate_revision_metadata(paths)
|
errors = validate_revision_metadata(paths)
|
||||||
@@ -242,8 +243,8 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
|||||||
print(f"ERROR: {message}", file=sys.stderr)
|
print(f"ERROR: {message}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
output_path = output_dir / settings.filename
|
targets = texture_output_paths(output_dir, settings)
|
||||||
job = build_texture_job(paths, settings, output_path)
|
job = build_texture_job(paths, settings, output_dir)
|
||||||
job_path = write_texture_job(job, output_dir)
|
job_path = write_texture_job(job, output_dir)
|
||||||
print(f"Wrote texture job: {job_path}")
|
print(f"Wrote texture job: {job_path}")
|
||||||
|
|
||||||
@@ -255,14 +256,23 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
|||||||
if code != 0:
|
if code != 0:
|
||||||
return code
|
return code
|
||||||
|
|
||||||
if not output_path.is_file() or output_path.stat().st_size == 0:
|
missing = [
|
||||||
|
(target, path)
|
||||||
|
for target, path in targets
|
||||||
|
if not path.is_file() or path.stat().st_size == 0
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
for target, path in missing:
|
||||||
print(
|
print(
|
||||||
f"ERROR: Blender finished but the texture is missing or empty: {output_path}",
|
f"ERROR: Blender finished but the texture for UV map "
|
||||||
|
f"{target.uv_map!r} is missing or empty: {path}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
print(f"OK: colored patch texture written to {output_path}")
|
print(f"OK: wrote {len(targets)} colored patch texture(s) to {output_dir}")
|
||||||
|
for target, path in targets:
|
||||||
|
print(f" [{target.uv_map}] {path}")
|
||||||
print(
|
print(
|
||||||
f" mode=patches {settings.width}x{settings.height} "
|
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} mesh={settings.mesh!r} camera={settings.camera!r}"
|
||||||
@@ -310,7 +320,7 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
|
|
||||||
texture_p = sub.add_parser(
|
texture_p = sub.add_parser(
|
||||||
"texture",
|
"texture",
|
||||||
help="Generate the colored patch texture headlessly (UV unwrap stays in-app)",
|
help="Generate one colored patch texture per UV map (UV unwrap stays in-app)",
|
||||||
)
|
)
|
||||||
texture_p.add_argument("revision_dir", type=Path)
|
texture_p.add_argument("revision_dir", type=Path)
|
||||||
texture_p.add_argument(
|
texture_p.add_argument(
|
||||||
@@ -319,11 +329,6 @@ def main(argv: list[str] | None = None) -> None:
|
|||||||
default=None,
|
default=None,
|
||||||
help="Output directory (default: build/textures/<sensor>/<revision>)",
|
help="Output directory (default: build/textures/<sensor>/<revision>)",
|
||||||
)
|
)
|
||||||
texture_p.add_argument(
|
|
||||||
"--filename",
|
|
||||||
default=None,
|
|
||||||
help="PNG file name (default: design.yaml texture_patch.filename)",
|
|
||||||
)
|
|
||||||
texture_p.add_argument("--mesh", default=None, help="Sensing surface mesh object name")
|
texture_p.add_argument("--mesh", default=None, help="Sensing surface mesh object name")
|
||||||
texture_p.add_argument("--cam", default=None, help="Sensor camera object name")
|
texture_p.add_argument("--cam", default=None, help="Sensor camera object name")
|
||||||
texture_p.add_argument("--width", type=int, default=None, help="Texture width in pixels")
|
texture_p.add_argument("--width", type=int, default=None, help="Texture width in pixels")
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ and invokes ``blender/scripts/random_patch_dd02c.py`` headlessly. Every path
|
|||||||
handed to Blender is absolute, so no manual copying between directories is
|
handed to Blender is absolute, so no manual copying between directories is
|
||||||
required.
|
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.
|
||||||
|
|
||||||
UV unwrapping is deliberately outside this module: the headless run consumes the
|
UV unwrapping is deliberately outside this module: the headless run consumes the
|
||||||
UV map already authored in the Blender GUI.
|
UV maps already authored in the Blender GUI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -23,15 +28,22 @@ from orisys_sensor_design.manifest import (
|
|||||||
load_yaml,
|
load_yaml,
|
||||||
)
|
)
|
||||||
|
|
||||||
TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v1"
|
TEXTURE_JOB_SCHEMA = "orisys-sensor-design/texture-job-v2"
|
||||||
TEXTURE_JOB_FILENAME = "_texture_job.json"
|
TEXTURE_JOB_FILENAME = "_texture_job.json"
|
||||||
TEXTURE_RELATIVE_ROOT = Path("build") / "textures"
|
TEXTURE_RELATIVE_ROOT = Path("build") / "textures"
|
||||||
TEXTURE_MODE = "patches"
|
TEXTURE_MODE = "patches"
|
||||||
|
|
||||||
# Built-in fallbacks. sensors/<sensor>/<revision>/design.yaml is the source of
|
# 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.
|
# truth for a revision; these only apply when a key is absent there.
|
||||||
|
#
|
||||||
|
# `uv_outputs` maps a UV map name on the mesh to the PNG written for it. Each run
|
||||||
|
# renders every entry with the same camera-space patch colours, so the maps
|
||||||
|
# differ only by how the unwrap arranges the patches.
|
||||||
DEFAULT_TEXTURE_PATCH: dict[str, Any] = {
|
DEFAULT_TEXTURE_PATCH: dict[str, Any] = {
|
||||||
"filename": "patch_texture.png",
|
"uv_outputs": {
|
||||||
|
"produce": "patch_texture_produce.png",
|
||||||
|
"compute": "patch_texture_compute.png",
|
||||||
|
},
|
||||||
"width": 2048,
|
"width": 2048,
|
||||||
"height": 2048,
|
"height": 2048,
|
||||||
"nx": 80,
|
"nx": 80,
|
||||||
@@ -51,7 +63,7 @@ _TEXTURE_PATCH_KEYS = frozenset(
|
|||||||
{
|
{
|
||||||
"mesh",
|
"mesh",
|
||||||
"camera",
|
"camera",
|
||||||
"filename",
|
"uv_outputs",
|
||||||
"width",
|
"width",
|
||||||
"height",
|
"height",
|
||||||
"nx",
|
"nx",
|
||||||
@@ -74,13 +86,35 @@ class TextureConfigError(ValueError):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TextureOutputTarget:
|
||||||
|
"""One generated PNG: the mesh UV map it reads and the file it writes."""
|
||||||
|
|
||||||
|
uv_map: str
|
||||||
|
filename: str
|
||||||
|
|
||||||
|
def output_path(self, output_dir: Path) -> Path:
|
||||||
|
return Path(output_dir) / self.filename
|
||||||
|
|
||||||
|
def to_job_entry(self, output_dir: Path) -> dict[str, Any]:
|
||||||
|
"""Return the job entry Blender consumes (absolute path, ordered)."""
|
||||||
|
return {
|
||||||
|
"uv_map": self.uv_map,
|
||||||
|
"output": str(self.output_path(output_dir).resolve()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TexturePatchSettings:
|
class TexturePatchSettings:
|
||||||
"""Resolved colored-patch settings for one headless run."""
|
"""Resolved colored-patch settings for one headless run.
|
||||||
|
|
||||||
|
All rendering settings are shared by every target in ``outputs``; the targets
|
||||||
|
differ only in which UV map they read and the file they write.
|
||||||
|
"""
|
||||||
|
|
||||||
mesh: str
|
mesh: str
|
||||||
camera: str
|
camera: str
|
||||||
filename: str
|
outputs: tuple[TextureOutputTarget, ...]
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
nx: int
|
nx: int
|
||||||
@@ -189,6 +223,22 @@ def merge_texture_patch(
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def _bare_png_name_problems(value: Any, label: str) -> list[str]:
|
||||||
|
"""Return messages for a value that must be a bare ``.png`` file name."""
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return [f"{label}: must be a non-empty file name"]
|
||||||
|
if (
|
||||||
|
"/" in value
|
||||||
|
or "\\" in value
|
||||||
|
or Path(value).name != value
|
||||||
|
or value in {".", ".."}
|
||||||
|
):
|
||||||
|
return [f"{label}: must be a bare file name without directories ({value!r})"]
|
||||||
|
if Path(value).suffix.lower() != ".png":
|
||||||
|
return [f"{label}: must end with .png ({value!r})"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> list[str]:
|
def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> list[str]:
|
||||||
"""Return one message per invalid value in a merged texture_patch mapping."""
|
"""Return one message per invalid value in a merged texture_patch mapping."""
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
@@ -205,18 +255,30 @@ def validate_texture_patch(block: dict[str, Any], objects: dict[str, str]) -> li
|
|||||||
elif not isinstance(camera, str) or not camera.strip():
|
elif not isinstance(camera, str) or not camera.strip():
|
||||||
errors.append("camera: must be a non-empty object name")
|
errors.append("camera: must be a non-empty object name")
|
||||||
|
|
||||||
filename = block.get("filename")
|
uv_outputs = block.get("uv_outputs")
|
||||||
if not isinstance(filename, str) or not filename.strip():
|
if not isinstance(uv_outputs, dict) or not uv_outputs:
|
||||||
errors.append("filename: must be a non-empty file name")
|
errors.append(
|
||||||
elif (
|
"uv_outputs: must be a non-empty mapping of UV map name to PNG file name"
|
||||||
"/" in filename
|
)
|
||||||
or "\\" in filename
|
else:
|
||||||
or Path(filename).name != filename
|
seen_outputs: dict[str, str] = {}
|
||||||
or filename in {".", ".."}
|
for uv_name, out_name in uv_outputs.items():
|
||||||
):
|
if not isinstance(uv_name, str) or not uv_name.strip():
|
||||||
errors.append(f"filename: must be a bare file name without directories ({filename!r})")
|
errors.append(
|
||||||
elif Path(filename).suffix.lower() != ".png":
|
f"uv_outputs: UV map names must be non-empty strings (got {uv_name!r})"
|
||||||
errors.append(f"filename: must end with .png ({filename!r})")
|
)
|
||||||
|
continue
|
||||||
|
problems = _bare_png_name_problems(out_name, f"uv_outputs.{uv_name}")
|
||||||
|
if problems:
|
||||||
|
errors.extend(problems)
|
||||||
|
continue
|
||||||
|
if out_name in seen_outputs:
|
||||||
|
errors.append(
|
||||||
|
f"uv_outputs: duplicate output file name {out_name!r} "
|
||||||
|
f"for {seen_outputs[out_name]!r} and {uv_name!r}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
seen_outputs[out_name] = uv_name
|
||||||
|
|
||||||
for key in ("width", "height"):
|
for key in ("width", "height"):
|
||||||
value = block.get(key)
|
value = block.get(key)
|
||||||
@@ -279,10 +341,15 @@ def resolve_texture_settings(
|
|||||||
if len(color) == 3:
|
if len(color) == 3:
|
||||||
color.append(1.0)
|
color.append(1.0)
|
||||||
|
|
||||||
|
outputs = tuple(
|
||||||
|
TextureOutputTarget(uv_map=str(uv_name), filename=str(out_name))
|
||||||
|
for uv_name, out_name in block["uv_outputs"].items()
|
||||||
|
)
|
||||||
|
|
||||||
return TexturePatchSettings(
|
return TexturePatchSettings(
|
||||||
mesh=str(block["mesh"]),
|
mesh=str(block["mesh"]),
|
||||||
camera=str(block["camera"]),
|
camera=str(block["camera"]),
|
||||||
filename=str(block["filename"]),
|
outputs=outputs,
|
||||||
width=int(block["width"]),
|
width=int(block["width"]),
|
||||||
height=int(block["height"]),
|
height=int(block["height"]),
|
||||||
nx=int(block["nx"]),
|
nx=int(block["nx"]),
|
||||||
@@ -301,8 +368,8 @@ def texture_revision_rel(root: Path, paths: RevisionPaths) -> Path:
|
|||||||
"""Return ``<sensor>/<revision>`` for the revision below the repo's sensors/.
|
"""Return ``<sensor>/<revision>`` for the revision below the repo's sensors/.
|
||||||
|
|
||||||
Directory names are used rather than design.yaml's ``sensor``/``revision``
|
Directory names are used rather than design.yaml's ``sensor``/``revision``
|
||||||
fields because those are not reliable across sensor folders (finger-xynova
|
fields so the output layout follows the on-disk revision tree regardless of
|
||||||
still declares ``sensor: finger``).
|
what those metadata fields say.
|
||||||
"""
|
"""
|
||||||
sensors_root = (root / "sensors").resolve()
|
sensors_root = (root / "sensors").resolve()
|
||||||
try:
|
try:
|
||||||
@@ -336,10 +403,19 @@ def texture_job_path(output_dir: Path) -> Path:
|
|||||||
return Path(output_dir) / TEXTURE_JOB_FILENAME
|
return Path(output_dir) / TEXTURE_JOB_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
def texture_output_paths(
|
||||||
|
output_dir: Path,
|
||||||
|
settings: TexturePatchSettings,
|
||||||
|
) -> list[tuple[TextureOutputTarget, Path]]:
|
||||||
|
"""Pair every configured target with its absolute output path, in order."""
|
||||||
|
resolved_dir = Path(output_dir).resolve()
|
||||||
|
return [(target, target.output_path(resolved_dir)) for target in settings.outputs]
|
||||||
|
|
||||||
|
|
||||||
def build_texture_job(
|
def build_texture_job(
|
||||||
paths: RevisionPaths,
|
paths: RevisionPaths,
|
||||||
settings: TexturePatchSettings,
|
settings: TexturePatchSettings,
|
||||||
output_path: Path,
|
output_dir: Path,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build the JSON job dict that random_patch_dd02c.py consumes (stdlib JSON)."""
|
"""Build the JSON job dict that random_patch_dd02c.py consumes (stdlib JSON)."""
|
||||||
return {
|
return {
|
||||||
@@ -347,7 +423,9 @@ def build_texture_job(
|
|||||||
"mode": TEXTURE_MODE,
|
"mode": TEXTURE_MODE,
|
||||||
"revision_dir": str(paths.root.resolve()),
|
"revision_dir": str(paths.root.resolve()),
|
||||||
"source_blend": str(paths.source_blend.resolve()),
|
"source_blend": str(paths.source_blend.resolve()),
|
||||||
"output": str(Path(output_path).resolve()),
|
"outputs": [
|
||||||
|
target.to_job_entry(Path(output_dir)) for target in settings.outputs
|
||||||
|
],
|
||||||
"objects": {
|
"objects": {
|
||||||
"mesh": settings.mesh,
|
"mesh": settings.mesh,
|
||||||
"camera": settings.camera,
|
"camera": settings.camera,
|
||||||
@@ -370,7 +448,6 @@ def texture_overrides_from_args(args: Any) -> dict[str, Any]:
|
|||||||
overrides: dict[str, Any] = {
|
overrides: dict[str, Any] = {
|
||||||
"mesh": getattr(args, "mesh", None),
|
"mesh": getattr(args, "mesh", None),
|
||||||
"camera": getattr(args, "cam", None),
|
"camera": getattr(args, "cam", None),
|
||||||
"filename": getattr(args, "filename", None),
|
|
||||||
"width": getattr(args, "width", None),
|
"width": getattr(args, "width", None),
|
||||||
"height": getattr(args, "height", None),
|
"height": getattr(args, "height", None),
|
||||||
"nx": getattr(args, "nx", None),
|
"nx": getattr(args, "nx", None),
|
||||||
|
|||||||
+130
-30
@@ -15,6 +15,7 @@ from orisys_sensor_design.texture import (
|
|||||||
TEXTURE_JOB_FILENAME,
|
TEXTURE_JOB_FILENAME,
|
||||||
TEXTURE_JOB_SCHEMA,
|
TEXTURE_JOB_SCHEMA,
|
||||||
TextureConfigError,
|
TextureConfigError,
|
||||||
|
TextureOutputTarget,
|
||||||
build_texture_job,
|
build_texture_job,
|
||||||
load_texture_patch_block,
|
load_texture_patch_block,
|
||||||
merge_texture_patch,
|
merge_texture_patch,
|
||||||
@@ -22,10 +23,16 @@ from orisys_sensor_design.texture import (
|
|||||||
resolve_texture_settings,
|
resolve_texture_settings,
|
||||||
texture_job_path,
|
texture_job_path,
|
||||||
texture_output_dir,
|
texture_output_dir,
|
||||||
|
texture_output_paths,
|
||||||
texture_revision_rel,
|
texture_revision_rel,
|
||||||
write_texture_job,
|
write_texture_job,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DEFAULT_OUTPUTS = [
|
||||||
|
("produce", "patch_texture_produce.png"),
|
||||||
|
("compute", "patch_texture_compute.png"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def revision_dir() -> Path:
|
def revision_dir() -> Path:
|
||||||
@@ -38,7 +45,6 @@ def _texture_namespace(
|
|||||||
namespace = argparse.Namespace(
|
namespace = argparse.Namespace(
|
||||||
revision_dir=revision_dir,
|
revision_dir=revision_dir,
|
||||||
output_dir=output_dir,
|
output_dir=output_dir,
|
||||||
filename=None,
|
|
||||||
mesh=None,
|
mesh=None,
|
||||||
cam=None,
|
cam=None,
|
||||||
width=None,
|
width=None,
|
||||||
@@ -108,16 +114,34 @@ def test_output_dir_explicit_override_wins(revision_dir: Path, tmp_path: Path) -
|
|||||||
def test_output_dir_uses_directory_names_not_design_sensor_field(
|
def test_output_dir_uses_directory_names_not_design_sensor_field(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""finger-xynova declares `sensor: finger`; the folder name must win."""
|
"""The on-disk sensor folder must win over design.yaml's sensor field."""
|
||||||
revision = _write_revision(tmp_path, sensor_dir="finger-xynova", revision_name="r01")
|
revision = _write_revision(tmp_path, sensor_dir="finger-xynova", revision_name="r01")
|
||||||
paths = RevisionPaths.from_revision_dir(revision)
|
paths = RevisionPaths.from_revision_dir(revision)
|
||||||
assert texture_revision_rel(tmp_path, paths) == Path("finger-xynova") / "r01"
|
assert texture_revision_rel(tmp_path, paths) == Path("finger-xynova") / "r01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_paths_pair_each_target(revision_dir: Path, tmp_path: Path) -> None:
|
||||||
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||||
|
settings = resolve_texture_settings(paths)
|
||||||
|
|
||||||
|
targets = texture_output_paths(tmp_path, settings)
|
||||||
|
|
||||||
|
assert [(target.uv_map, path.name) for target, path in targets] == DEFAULT_OUTPUTS
|
||||||
|
assert all(path.parent == tmp_path.resolve() for _, path in targets)
|
||||||
|
|
||||||
|
|
||||||
def test_job_path_sits_beside_texture(tmp_path: Path) -> None:
|
def test_job_path_sits_beside_texture(tmp_path: Path) -> None:
|
||||||
assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME
|
assert texture_job_path(tmp_path / "out") == tmp_path / "out" / TEXTURE_JOB_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_target_entry_is_absolute(revision_dir: Path, tmp_path: Path) -> None:
|
||||||
|
target = TextureOutputTarget(uv_map="produce", filename="a.png")
|
||||||
|
entry = target.to_job_entry(tmp_path)
|
||||||
|
assert entry["uv_map"] == "produce"
|
||||||
|
assert Path(entry["output"]).is_absolute()
|
||||||
|
assert Path(entry["output"]).name == "a.png"
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------- settings merge
|
# ------------------------------------------------------- settings merge
|
||||||
|
|
||||||
|
|
||||||
@@ -125,7 +149,7 @@ def test_defaults_come_from_design_yaml(revision_dir: Path) -> None:
|
|||||||
settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision_dir))
|
settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision_dir))
|
||||||
assert settings.mesh == "sensing_surface"
|
assert settings.mesh == "sensing_surface"
|
||||||
assert settings.camera == "cam_sensor"
|
assert settings.camera == "cam_sensor"
|
||||||
assert settings.filename == "patch_texture.png"
|
assert [(target.uv_map, target.filename) for target in settings.outputs] == DEFAULT_OUTPUTS
|
||||||
assert (settings.width, settings.height) == (3280, 3200)
|
assert (settings.width, settings.height) == (3280, 3200)
|
||||||
assert (settings.nx, settings.ny) == (80, 45)
|
assert (settings.nx, settings.ny) == (80, 45)
|
||||||
assert settings.color_r == pytest.approx(0.05)
|
assert settings.color_r == pytest.approx(0.05)
|
||||||
@@ -144,11 +168,23 @@ def test_builtin_defaults_when_design_block_absent(tmp_path: Path) -> None:
|
|||||||
settings = resolve_texture_settings(paths)
|
settings = resolve_texture_settings(paths)
|
||||||
assert settings.width == DEFAULT_TEXTURE_PATCH["width"]
|
assert settings.width == DEFAULT_TEXTURE_PATCH["width"]
|
||||||
assert settings.height == DEFAULT_TEXTURE_PATCH["height"]
|
assert settings.height == DEFAULT_TEXTURE_PATCH["height"]
|
||||||
|
assert [target.uv_map for target in settings.outputs] == ["produce", "compute"]
|
||||||
# Object names fall back to design.yaml `objects` defaults.
|
# Object names fall back to design.yaml `objects` defaults.
|
||||||
assert settings.mesh == "sensing_surface"
|
assert settings.mesh == "sensing_surface"
|
||||||
assert settings.camera == "cam_sensor"
|
assert settings.camera == "cam_sensor"
|
||||||
|
|
||||||
|
|
||||||
|
def test_design_yaml_uv_outputs_replace_builtin_defaults(tmp_path: Path) -> None:
|
||||||
|
revision = _write_revision(
|
||||||
|
tmp_path,
|
||||||
|
"texture_patch:\n uv_outputs:\n custom: my_custom_layout.png\n",
|
||||||
|
)
|
||||||
|
settings = resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
|
||||||
|
assert [(target.uv_map, target.filename) for target in settings.outputs] == [
|
||||||
|
("custom", "my_custom_layout.png")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
|
def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
|
||||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||||
settings = resolve_texture_settings(
|
settings = resolve_texture_settings(
|
||||||
@@ -177,6 +213,8 @@ def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
|
|||||||
assert settings.cross_length_px == 8
|
assert settings.cross_length_px == 8
|
||||||
# A 3-channel cross colour is widened to RGBA.
|
# A 3-channel cross colour is widened to RGBA.
|
||||||
assert settings.cross_color == (0.0, 1.0, 0.0, 1.0)
|
assert settings.cross_color == (0.0, 1.0, 0.0, 1.0)
|
||||||
|
# Shared rendering settings leave both targets intact.
|
||||||
|
assert [target.uv_map for target in settings.outputs] == ["produce", "compute"]
|
||||||
|
|
||||||
|
|
||||||
def test_unset_overrides_do_not_erase_configured_values(revision_dir: Path) -> None:
|
def test_unset_overrides_do_not_erase_configured_values(revision_dir: Path) -> None:
|
||||||
@@ -194,6 +232,14 @@ def test_unknown_design_key_is_rejected(tmp_path: Path) -> None:
|
|||||||
assert "widht" in str(excinfo.value)
|
assert "widht" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_filename_key_is_rejected(tmp_path: Path) -> None:
|
||||||
|
"""The singular filename key was replaced by uv_outputs."""
|
||||||
|
revision = _write_revision(tmp_path, "texture_patch:\n filename: patch_texture.png\n")
|
||||||
|
with pytest.raises(TextureConfigError) as excinfo:
|
||||||
|
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
|
||||||
|
assert "filename" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_cross_key_is_rejected(tmp_path: Path) -> None:
|
def test_unknown_cross_key_is_rejected(tmp_path: Path) -> None:
|
||||||
revision = _write_revision(
|
revision = _write_revision(
|
||||||
tmp_path, "texture_patch:\n cross:\n thickness: 3\n"
|
tmp_path, "texture_patch:\n cross:\n thickness: 3\n"
|
||||||
@@ -220,9 +266,6 @@ def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None:
|
|||||||
({"color_r": 2.0}, "color_r"),
|
({"color_r": 2.0}, "color_r"),
|
||||||
({"edge_sample_grid": 1}, "edge_sample_grid"),
|
({"edge_sample_grid": 1}, "edge_sample_grid"),
|
||||||
({"seed": "abc"}, "seed"),
|
({"seed": "abc"}, "seed"),
|
||||||
({"filename": "tex.jpg"}, "filename"),
|
|
||||||
({"filename": "nested/tex.png"}, "filename"),
|
|
||||||
({"filename": "..\\tex.png"}, "filename"),
|
|
||||||
({"cross": {"enabled": "yes"}}, "cross.enabled"),
|
({"cross": {"enabled": "yes"}}, "cross.enabled"),
|
||||||
({"cross": {"length_px": 0}}, "cross.length_px"),
|
({"cross": {"length_px": 0}}, "cross.length_px"),
|
||||||
({"cross": {"color": [2.0, 0.0, 0.0, 1.0]}}, "cross.color"),
|
({"cross": {"color": [2.0, 0.0, 0.0, 1.0]}}, "cross.color"),
|
||||||
@@ -238,6 +281,31 @@ def test_invalid_overrides_are_rejected(
|
|||||||
assert expected in str(excinfo.value)
|
assert expected in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("overrides", "expected"),
|
||||||
|
[
|
||||||
|
({"uv_outputs": {}}, "uv_outputs"),
|
||||||
|
({"uv_outputs": "produce.png"}, "uv_outputs"),
|
||||||
|
({"uv_outputs": {"": "produce.png"}}, "uv_outputs"),
|
||||||
|
({"uv_outputs": {"produce": ""}}, "uv_outputs.produce"),
|
||||||
|
({"uv_outputs": {"produce": "texture.jpg"}}, "uv_outputs.produce"),
|
||||||
|
({"uv_outputs": {"produce": "nested/a.png"}}, "uv_outputs.produce"),
|
||||||
|
({"uv_outputs": {"produce": "..\\a.png"}}, "uv_outputs.produce"),
|
||||||
|
(
|
||||||
|
{"uv_outputs": {"produce": "same.png", "compute": "same.png"}},
|
||||||
|
"duplicate output file name",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_uv_outputs_are_rejected(
|
||||||
|
revision_dir: Path, overrides: dict, expected: str
|
||||||
|
) -> None:
|
||||||
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||||
|
with pytest.raises(TextureConfigError) as excinfo:
|
||||||
|
resolve_texture_settings(paths, overrides)
|
||||||
|
assert expected in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- job I/O
|
# ------------------------------------------------------------- job I/O
|
||||||
|
|
||||||
|
|
||||||
@@ -245,9 +313,8 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
|
|||||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||||
settings = resolve_texture_settings(paths, {"width": 256, "height": 256, "seed": 3})
|
settings = resolve_texture_settings(paths, {"width": 256, "height": 256, "seed": 3})
|
||||||
output_dir = tmp_path / "textures"
|
output_dir = tmp_path / "textures"
|
||||||
output_path = output_dir / settings.filename
|
|
||||||
|
|
||||||
job = build_texture_job(paths, settings, output_path)
|
job = build_texture_job(paths, settings, output_dir)
|
||||||
assert job["schema"] == TEXTURE_JOB_SCHEMA
|
assert job["schema"] == TEXTURE_JOB_SCHEMA
|
||||||
assert job["mode"] == "patches"
|
assert job["mode"] == "patches"
|
||||||
assert set(job) == {
|
assert set(job) == {
|
||||||
@@ -255,10 +322,16 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
|
|||||||
"mode",
|
"mode",
|
||||||
"revision_dir",
|
"revision_dir",
|
||||||
"source_blend",
|
"source_blend",
|
||||||
"output",
|
"outputs",
|
||||||
"objects",
|
"objects",
|
||||||
"settings",
|
"settings",
|
||||||
}
|
}
|
||||||
|
assert "output" not in job
|
||||||
|
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
|
||||||
|
assert [Path(entry["output"]).name for entry in job["outputs"]] == [
|
||||||
|
filename for _, filename in DEFAULT_OUTPUTS
|
||||||
|
]
|
||||||
|
assert all(Path(entry["output"]).is_absolute() for entry in job["outputs"])
|
||||||
assert set(job["objects"]) == {"mesh", "camera"}
|
assert set(job["objects"]) == {"mesh", "camera"}
|
||||||
assert set(job["settings"]) == {
|
assert set(job["settings"]) == {
|
||||||
"width",
|
"width",
|
||||||
@@ -280,9 +353,8 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
|
|||||||
job_path = write_texture_job(job, output_dir)
|
job_path = write_texture_job(job, output_dir)
|
||||||
assert job_path == output_dir / TEXTURE_JOB_FILENAME
|
assert job_path == output_dir / TEXTURE_JOB_FILENAME
|
||||||
loaded = json.loads(job_path.read_text(encoding="utf-8"))
|
loaded = json.loads(job_path.read_text(encoding="utf-8"))
|
||||||
assert loaded["output"] == str(output_path.resolve())
|
assert loaded["outputs"] == job["outputs"]
|
||||||
assert loaded["source_blend"] == str(paths.source_blend.resolve())
|
assert loaded["source_blend"] == str(paths.source_blend.resolve())
|
||||||
assert Path(loaded["output"]).is_absolute()
|
|
||||||
assert loaded["objects"] == {"mesh": "sensing_surface", "camera": "cam_sensor"}
|
assert loaded["objects"] == {"mesh": "sensing_surface", "camera": "cam_sensor"}
|
||||||
assert loaded["settings"]["seed"] == 3
|
assert loaded["settings"]["seed"] == 3
|
||||||
assert loaded["settings"]["cross"]["color"] == [1.0, 0.0, 0.0, 1.0]
|
assert loaded["settings"]["cross"]["color"] == [1.0, 0.0, 0.0, 1.0]
|
||||||
@@ -292,56 +364,76 @@ def test_write_job_creates_output_directory(revision_dir: Path, tmp_path: Path)
|
|||||||
paths = RevisionPaths.from_revision_dir(revision_dir)
|
paths = RevisionPaths.from_revision_dir(revision_dir)
|
||||||
settings = resolve_texture_settings(paths)
|
settings = resolve_texture_settings(paths)
|
||||||
nested = tmp_path / "a" / "b"
|
nested = tmp_path / "a" / "b"
|
||||||
write_texture_job(build_texture_job(paths, settings, nested / settings.filename), nested)
|
write_texture_job(build_texture_job(paths, settings, nested), nested)
|
||||||
assert (nested / TEXTURE_JOB_FILENAME).is_file()
|
assert (nested / TEXTURE_JOB_FILENAME).is_file()
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------- CLI wiring
|
# ----------------------------------------------------------- CLI wiring
|
||||||
|
|
||||||
|
|
||||||
def _fake_blender(monkeypatch: pytest.MonkeyPatch, exit_code: int, write_png: bool):
|
def _fake_blender(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
exit_code: int,
|
||||||
|
write_pngs: bool = True,
|
||||||
|
skip_uv: str | None = None,
|
||||||
|
):
|
||||||
calls: list[tuple[str, Path, list[str]]] = []
|
calls: list[tuple[str, Path, list[str]]] = []
|
||||||
|
|
||||||
def fake_run(script: str, blend: Path, extra_args: list[str]):
|
def fake_run(script: str, blend: Path, extra_args: list[str]):
|
||||||
calls.append((script, blend, list(extra_args)))
|
calls.append((script, blend, list(extra_args)))
|
||||||
if write_png:
|
if write_pngs:
|
||||||
job_path = Path(extra_args[extra_args.index("--job") + 1])
|
job_path = Path(extra_args[extra_args.index("--job") + 1])
|
||||||
job = json.loads(job_path.read_text(encoding="utf-8"))
|
job = json.loads(job_path.read_text(encoding="utf-8"))
|
||||||
Path(job["output"]).write_bytes(b"\x89PNG\r\n\x1a\n")
|
for entry in job["outputs"]:
|
||||||
|
if skip_uv is not None and entry["uv_map"] == skip_uv:
|
||||||
|
continue
|
||||||
|
Path(entry["output"]).write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||||
return exit_code
|
return exit_code
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "_run_blender_script", fake_run)
|
monkeypatch.setattr(cli, "_run_blender_script", fake_run)
|
||||||
return calls
|
return calls
|
||||||
|
|
||||||
|
|
||||||
def test_cmd_texture_forwards_job_and_verifies_artifact(
|
def test_cmd_texture_forwards_job_and_verifies_both_artifacts(
|
||||||
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
calls = _fake_blender(monkeypatch, 0, write_png=True)
|
calls = _fake_blender(monkeypatch, 0)
|
||||||
out_dir = tmp_path / "out"
|
out_dir = tmp_path / "out"
|
||||||
assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0
|
assert cli.cmd_texture(revision_dir, _texture_namespace(revision_dir, out_dir)) == 0
|
||||||
|
|
||||||
|
assert len(calls) == 1, "one Blender launch must render every target"
|
||||||
script, blend, extra = calls[0]
|
script, blend, extra = calls[0]
|
||||||
assert script == "random_patch_dd02c.py"
|
assert script == "random_patch_dd02c.py"
|
||||||
assert blend == revision_dir.resolve() / "source" / "geometry.blend"
|
assert blend == revision_dir.resolve() / "source" / "geometry.blend"
|
||||||
assert extra[0] == "--job"
|
assert extra[0] == "--job"
|
||||||
job_path = Path(extra[1])
|
job_path = Path(extra[1])
|
||||||
assert job_path == out_dir.resolve() / TEXTURE_JOB_FILENAME
|
assert job_path == out_dir.resolve() / TEXTURE_JOB_FILENAME
|
||||||
assert (out_dir / "patch_texture.png").is_file()
|
|
||||||
|
for _, filename in DEFAULT_OUTPUTS:
|
||||||
|
assert (out_dir / filename).is_file()
|
||||||
|
|
||||||
|
|
||||||
def test_cmd_texture_propagates_blender_failure(
|
def test_cmd_texture_propagates_blender_failure(
|
||||||
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
_fake_blender(monkeypatch, 9, write_png=False)
|
_fake_blender(monkeypatch, 9, write_pngs=False)
|
||||||
namespace = _texture_namespace(revision_dir, tmp_path / "out")
|
namespace = _texture_namespace(revision_dir, tmp_path / "out")
|
||||||
assert cli.cmd_texture(revision_dir, namespace) == 9
|
assert cli.cmd_texture(revision_dir, namespace) == 9
|
||||||
|
|
||||||
|
|
||||||
def test_cmd_texture_detects_missing_texture(
|
def test_cmd_texture_detects_both_textures_missing(
|
||||||
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
_fake_blender(monkeypatch, 0, write_png=False)
|
_fake_blender(monkeypatch, 0, write_pngs=False)
|
||||||
|
namespace = _texture_namespace(revision_dir, tmp_path / "out")
|
||||||
|
assert cli.cmd_texture(revision_dir, namespace) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_cmd_texture_detects_partial_pair(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""One UV map written, the other missing, must not look like success."""
|
||||||
|
_fake_blender(monkeypatch, 0, skip_uv="compute")
|
||||||
namespace = _texture_namespace(revision_dir, tmp_path / "out")
|
namespace = _texture_namespace(revision_dir, tmp_path / "out")
|
||||||
assert cli.cmd_texture(revision_dir, namespace) == 3
|
assert cli.cmd_texture(revision_dir, namespace) == 3
|
||||||
|
|
||||||
@@ -349,7 +441,7 @@ def test_cmd_texture_detects_missing_texture(
|
|||||||
def test_main_texture_parses_flags_into_job(
|
def test_main_texture_parses_flags_into_job(
|
||||||
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
calls = _fake_blender(monkeypatch, 0, write_png=True)
|
calls = _fake_blender(monkeypatch, 0)
|
||||||
out_dir = tmp_path / "flagged"
|
out_dir = tmp_path / "flagged"
|
||||||
with pytest.raises(SystemExit) as excinfo:
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
cli.main(
|
cli.main(
|
||||||
@@ -358,8 +450,6 @@ def test_main_texture_parses_flags_into_job(
|
|||||||
str(revision_dir),
|
str(revision_dir),
|
||||||
"--output-dir",
|
"--output-dir",
|
||||||
str(out_dir),
|
str(out_dir),
|
||||||
"--filename",
|
|
||||||
"custom.png",
|
|
||||||
"--mesh",
|
"--mesh",
|
||||||
"sensing_surface",
|
"sensing_surface",
|
||||||
"--width",
|
"--width",
|
||||||
@@ -377,21 +467,31 @@ def test_main_texture_parses_flags_into_job(
|
|||||||
)
|
)
|
||||||
assert excinfo.value.code == 0
|
assert excinfo.value.code == 0
|
||||||
|
|
||||||
job = json.loads(
|
job = json.loads(Path(calls[0][2][1]).read_text(encoding="utf-8"))
|
||||||
Path(calls[0][2][1]).read_text(encoding="utf-8")
|
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
|
||||||
)
|
|
||||||
assert job["settings"]["width"] == 256
|
assert job["settings"]["width"] == 256
|
||||||
assert job["settings"]["nx"] == 4
|
assert job["settings"]["nx"] == 4
|
||||||
assert job["settings"]["ny"] == 3
|
assert job["settings"]["ny"] == 3
|
||||||
assert job["settings"]["seed"] == 7
|
assert job["settings"]["seed"] == 7
|
||||||
assert job["settings"]["cross"]["enabled"] is False
|
assert job["settings"]["cross"]["enabled"] is False
|
||||||
assert (out_dir / "custom.png").is_file()
|
for _, filename in DEFAULT_OUTPUTS:
|
||||||
|
assert (out_dir / filename).is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_texture_rejects_filename_flag(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path
|
||||||
|
) -> None:
|
||||||
|
"""The singular --filename flag is gone; per-UV names come from design.yaml."""
|
||||||
|
_fake_blender(monkeypatch, 0)
|
||||||
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
|
cli.main(["texture", str(revision_dir), "--filename", "custom.png"])
|
||||||
|
assert excinfo.value.code == 2
|
||||||
|
|
||||||
|
|
||||||
def test_main_texture_rejects_invalid_flag_with_exit_2(
|
def test_main_texture_rejects_invalid_flag_with_exit_2(
|
||||||
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
|
monkeypatch: pytest.MonkeyPatch, revision_dir: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
_fake_blender(monkeypatch, 0, write_png=True)
|
_fake_blender(monkeypatch, 0)
|
||||||
with pytest.raises(SystemExit) as excinfo:
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
cli.main(["texture", str(revision_dir), "--width", "0"])
|
cli.main(["texture", str(revision_dir), "--width", "0"])
|
||||||
assert excinfo.value.code == 2
|
assert excinfo.value.code == 2
|
||||||
|
|||||||
Reference in New Issue
Block a user