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
+25 -13
View File
@@ -1,13 +1,25 @@
# Git LFS — enable after Gitea LFS_START_SERVER=true on the NAS
*.blend filter=lfs diff=lfs merge=lfs -text
*.blend1 filter=lfs diff=lfs merge=lfs -text
*.exr filter=lfs diff=lfs merge=lfs -text
*.hdr filter=lfs diff=lfs merge=lfs -text
*.png filter=lfs diff=lfs merge=lfs -text
*.jpg filter=lfs diff=lfs merge=lfs -text
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.tif filter=lfs diff=lfs merge=lfs -text
*.tiff filter=lfs diff=lfs merge=lfs -text
*.npz filter=lfs diff=lfs merge=lfs -text
*.glb filter=lfs diff=lfs merge=lfs -text
*.gltf filter=lfs diff=lfs merge=lfs -text
# Binary types are marked non-text so git never applies EOL conversion and never
# attempts a textual diff or merge on them.
#
# Git LFS is intentionally NOT used. This repository has no remote configured,
# and every tracked binary used to be a 131-byte LFS pointer whose real bytes
# lived only in .git/lfs/objects — so a checkout or clone without LFS produced
# unusable .blend and .png files. Real bytes are committed directly instead.
#
# To bring LFS back (for example once Gitea LFS_START_SERVER=true on the NAS),
# give each extension a filter again and re-store the files:
# *.blend filter=lfs diff=lfs merge=lfs -text
# git add --renormalize .
*.blend -text
*.blend1 -text
*.exr -text
*.hdr -text
*.png -text
*.pdf -text
*.jpg -text
*.jpeg -text
*.tif -text
*.tiff -text
*.npz -text
*.glb -text
*.gltf -text
+3 -3
View File
@@ -6,9 +6,9 @@ __pycache__/
*.egg-info/
dist/
build/export/
# Generated textures are tracked (Git LFS via *.png) so they can be synced to
# hardware production. Only the per-run job file is ignored: it records absolute
# machine paths and is rewritten by every run.
# Generated textures are tracked as real files (no Git LFS) so they can be synced
# to hardware production. Only the per-run job file is ignored: it records
# absolute machine paths and is rewritten by every run.
build/textures/**/_texture_job.json
.pytest_cache/
+56 -20
View File
@@ -24,7 +24,7 @@ sensors/finger/
├── current.yaml # active revision pointer
└── r01/
├── design.yaml # units, frames, object names
├── source/geometry.blend # LFS: named collections + objects
├── source/geometry.blend # named collections + objects
├── profiles/
│ ├── compute.yaml # → SDK bundle layout (Studio runtime)
│ └── studio.yaml # inputs for orisys-app compile_scene
@@ -34,9 +34,10 @@ sensors/finger/
## Prerequisites
- Python 3.10+
- [Git LFS](https://git-lfs.com/) on every client (`git lfs install`)
- Blender version pinned in `blender.lock.json` (install separately)
No Git LFS is required — binaries are committed as ordinary files.
### Finding Blender on Windows
The CLI looks for Blender in this order:
@@ -113,8 +114,10 @@ build/export/finger/r01/
## Texture patches (headless)
`orisys-design texture` generates one colored patch texture per UV map on the
sensing surface. UV unwrapping is **still an in-app Blender step**: this command
`orisys-design texture` generates a PNG, editable SVG, and native vector PDF for
each UV map on the sensing surface. The SVG/PDF are built directly from the UV
mesh and camera-space patch polygons — they do **not** embed or trace the PNG.
UV unwrapping is **still an in-app Blender step**: this command
consumes the UV maps already stored in `source_blend` and never creates or
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
@@ -130,8 +133,12 @@ Output is revision-scoped, so nothing has to be copied by hand:
```text
build/textures/finger/r01/
├── _texture_job.json # Blender input, regenerated by texture (gitignored)
├── patch_texture_produce.png # from the `produce` UV map (tracked, Git LFS)
└── patch_texture_compute.png # from the `compute` UV map (tracked, Git LFS)
├── patch_texture_produce.png # raster preview/use (tracked)
├── patch_texture_produce.svg # editable grouped vector artwork
├── patch_texture_produce.pdf # vector production/print handoff (tracked)
├── patch_texture_compute.png
├── patch_texture_compute.svg
└── patch_texture_compute.pdf
```
`produce` is the hardware-production layout and `compute` the compute/runtime
@@ -140,15 +147,31 @@ 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
(`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.
Generated artifacts are **committed** so a revision's textures can be synced to
hardware production. They are stored as real files, not Git LFS pointers, so a
plain `git clone` yields usable images and `.blend` files; `.gitattributes` only
marks binaries as non-text to protect them from line-ending conversion. Only
`_texture_job.json` is ignored, because it records absolute machine paths and is
rewritten every run.
SVG imports into CorelDRAW as named groups (`background`, `mesh`, `patches`, and
`reference-cross`) containing individual editable polygons. PDF contains the
same filled vector paths on a correctly sized page, with no embedded bitmap.
Seam-wrapped duplicate polygons are clipped to the page, so no stray objects sit
outside the artwork.
Defaults come from the `texture_patch` block in `design.yaml` — mesh, camera, the
`uv_outputs` map (UV map → PNG name), texture size, patch grid, colour
separation, edge sampling, reference cross, and an optional seed. CLI flags
override the shared values for every output:
`uv_outputs` map (UV map → artifact stem), raster dimensions, vector page size in
millimetres, patch grid, colour separation, edge sampling, reference cross, and
an optional seed. The current production page is configurable as:
```yaml
vector:
width_mm: 164
height_mm: 160
```
CLI flags override the shared rendering values for every output:
```powershell
orisys-design texture sensors/finger/r01 `
@@ -162,11 +185,14 @@ 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
```
A single manual target is also possible (this cannot be combined with `--job`):
A single manual target is also possible (this cannot be combined with `--job`).
`--output` names the PNG; same-stem SVG/PDF files are generated alongside it.
The vector page defaults to 164×160 mm and can be overridden manually:
```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
--uv-map produce --output D:\tmp\produce.png --width 1024 --height 999 `
--vector-width-mm 164 --vector-height-mm 160
```
Only the colored "patches" mode is reachable headlessly; the checker and dot
@@ -212,9 +238,17 @@ Example lock file: `examples/design-lock.json`.
- **Objects**: `origin_sensor`, `sensing_surface`, `cam_sensor`.
- **Matrix names** must state direction; exported pose file remains `world_to_cam.npy`
for SDK compatibility (sensor frame → camera).
- **Git LFS**: `*.blend`, large textures, exported `*.npz` in `build/export/`.
- **Binary files**: `.blend`, textures and exported `*.npz` are committed as
ordinary files (no Git LFS). `.gitattributes` marks them `-text` only.
## Git LFS on Gitea (NAS)
## Git LFS on Gitea (NAS) — not currently used
**Status: this repository does not use Git LFS.** `.gitattributes` only marks
binaries as non-text, and real `.blend` / `.png` / `.pdf` bytes are committed
directly. This is deliberate: LFS pointers are unusable without a reachable LFS
server, so a plain clone or `git checkout` would hand you 131-byte files instead
of images. The notes below are kept as a migration guide for when the NAS server
is ready.
**Short answer: not difficult.** Gitea has built-in LFS; NAS hosting is common. Plan for
extra disk on the NAS volume that holds LFS objects, not just the git bare repos.
@@ -262,8 +296,10 @@ cd orisys-sensor-design
git lfs pull
```
This repo’s `.gitattributes` already tracks `*.blend` and large binaries. After
`git lfs track`, always commit `.gitattributes`.
This repo’s `.gitattributes` does **not** track anything through LFS today. To turn
LFS on, add the extensions back with a filter (the file contains a ready-made
example line), run `git add --renormalize .` so real bytes become pointers, and
commit `.gitattributes` together with the renormalized files.
### Typical pitfalls
@@ -281,6 +317,6 @@ behind your NAS reverse proxy is the path of least resistance.
| Phase | Goal |
|-------|------|
| **A** (now) | Repo + LFS + r01 + headless compute export |
| **A** (now) | Repo + r01 + headless compute export |
| **B** | Lock file in SDK/App + CI reproduces export hashes |
| **C** (later) | Markers, camera metrics, simulation datasets |
+542 -95
View File
@@ -6,10 +6,12 @@ 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 -- \
--job <abs/build/textures/finger/r01/_texture_job.json>
Manual single-target mode (cannot be combined with --job):
Manual single-target mode (cannot be combined with --job) derives same-stem
SVG/PDF paths from the PNG path:
--uv-map produce --output FILE.png --mesh sensing_surface --cam cam_sensor \
--width 3280 --height 3200 --nx 80 --ny 45 --color-r 0.05 --seed 7 --no-cross
--width 3280 --height 3200 --vector-width-mm 164 --vector-height-mm 160 \
--nx 80 --ny 45 --color-r 0.05 --seed 7 --no-cross
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
@@ -34,39 +36,40 @@ from mathutils import Vector
# ----------------------------------------------------------------------
# Common Utilities
# ----------------------------------------------------------------------
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
"""
Draw a cross (plus sign) in pixel space.
cx_uv, cy_uv: centre of cross in UV coordinates (0‑1), origin at bottom‑left.
length_px: total length of each arm (pixels).
thickness_px: line thickness (odd number gives symmetric result).
"""
# Convert UV centre to pixel coordinates (y from bottom)
def cross_rectangles(width, height, cx_uv, cy_uv, length_px, thickness_px):
"""Return horizontal/vertical cross arms as bottom-left pixel rectangles."""
cx = int(round(cx_uv * (width - 1)))
cy = int(round(cy_uv * (height - 1)))
half_len = length_px // 2
half_thick = thickness_px // 2
# Horizontal arm
x0 = max(0, cx - half_len)
x1 = min(width - 1, cx + half_len)
y0 = max(0, cy - half_thick)
y1 = min(height - 1, cy + half_thick)
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
idx = (y * width + x) * 4
pixels[idx:idx+4] = color
horizontal = (
max(0, cx - half_len),
max(0, cy - half_thick),
min(width - 1, cx + half_len),
min(height - 1, cy + half_thick),
)
vertical = (
max(0, cx - half_thick),
max(0, cy - half_len),
min(width - 1, cx + half_thick),
min(height - 1, cy + half_len),
)
return [
(x0, y0, x1 - x0 + 1, y1 - y0 + 1)
for x0, y0, x1, y1 in (horizontal, vertical)
]
# Vertical arm
x0 = max(0, cx - half_thick)
x1 = min(width - 1, cx + half_thick)
y0 = max(0, cy - half_len)
y1 = min(height - 1, cy + half_len)
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
idx = (y * width + x) * 4
pixels[idx:idx+4] = color
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
"""Draw a cross (plus sign), using the same rectangles as vector output."""
for x0, y0, rect_width, rect_height in cross_rectangles(
width, height, cx_uv, cy_uv, length_px, thickness_px
):
for y in range(y0, y0 + rect_height):
for x in range(x0, x0 + rect_width):
idx = (y * width + x) * 4
pixels[idx:idx+4] = color
def fill_background(pixels, width, height, color):
"""Fill the entire image with a solid RGBA color (0-1 floats)."""
@@ -353,6 +356,25 @@ def sample_patch_uv_polygon(
return convex_hull_uv(sampled_uvs)
def mesh_uv_pixel_polygons(obj, tex_width, tex_height, uv_layer=None):
"""Return every mesh face as seam-aware pixel-space polygons."""
me = obj.data
layer = uv_layer if uv_layer is not None else me.uv_layers.active
if not layer:
return []
uv_data = layer.data
pixel_polygons = []
for poly in me.polygons:
uv_poly = []
for loop_idx in poly.loop_indices:
uv = uv_data[loop_idx].uv
uv_poly.append((uv.x, uv.y))
pixel_polygons.extend(
uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height)
)
return pixel_polygons
def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1), uv_layer=None):
"""
Fill all triangles of one UV map with 'color' (default white).
@@ -361,23 +383,14 @@ def rasterize_mesh_uvs(obj, pixels, tex_width, tex_height, color=(1,1,1,1), uv_l
``uv_layer`` selects the mesh UV map; ``None`` keeps the active-layer
behaviour used by the checker/dot helpers.
"""
me = obj.data
layer = uv_layer if uv_layer is not None else me.uv_layers.active
if not layer:
pixel_polygons = mesh_uv_pixel_polygons(
obj, tex_width, tex_height, uv_layer=uv_layer
)
if not pixel_polygons:
print("Warning: no UV layer selected – mesh rasterization skipped.")
return
uv_data = layer.data
for poly in me.polygons:
# Get loop indices for this polygon
loops = poly.loop_indices
# Build UV polygon
uv_poly = []
for loop_idx in loops:
uv = uv_data[loop_idx].uv
uv_poly.append((uv.x, uv.y))
# Convert UV (0‑1) to pixel coordinates
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, tex_width, tex_height):
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
for pixel_poly in pixel_polygons:
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
def rasterize_mesh_mask(obj, tex_width, tex_height):
"""Rasterize the active UV map to a boolean mesh mask."""
@@ -515,6 +528,315 @@ def checker_patch_color(i, j):
return np.array([1.0, 1.0, 1.0], dtype=np.float32)
return np.array([0.0, 0.0, 0.0], dtype=np.float32)
# ----------------------------------------------------------------------
# Native vector writers (SVG + PDF; no raster image embedding)
# ----------------------------------------------------------------------
def _format_number(value):
"""Compact deterministic decimal formatting for SVG/PDF coordinates."""
text = f"{float(value):.4f}".rstrip("0").rstrip(".")
return "0" if text in ("", "-0") else text
def _clamp_channel(value):
return max(0.0, min(1.0, float(value)))
def _svg_fill_attributes(color):
r, g, b, a = (_clamp_channel(channel) for channel in color)
fill = f"#{round(r * 255):02x}{round(g * 255):02x}{round(b * 255):02x}"
opacity = "" if a >= 0.999999 else f' fill-opacity="{_format_number(a)}"'
return f'fill="{fill}"{opacity} stroke="none"'
def _svg_polygon_element(element_id, polygon, color, indent=" "):
points = " ".join(
f"{_format_number(x)},{_format_number(y)}" for x, y in polygon
)
return (
f'{indent}<polygon id="{element_id}" points="{points}" '
f'{_svg_fill_attributes(color)}/>'
)
def _xml_escape(value):
return (
str(value)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _warn_if_page_aspect_differs(
texture_width, texture_height, width_mm, height_mm, tolerance=1e-6
):
"""Warn when the vector page would non-uniformly scale the artwork.
SVG/PDF use the full page box, so a page whose aspect ratio differs from the
raster's stretches patches along one axis. The default metadata matches
(164x160 mm for 3280x3200 px), so this only fires on deliberate mismatch.
"""
raster_aspect = float(texture_width) / float(texture_height)
page_aspect = float(width_mm) / float(height_mm)
if abs(raster_aspect - page_aspect) <= tolerance * max(raster_aspect, page_aspect):
return
print(
f"WARNING: vector page aspect {page_aspect:.6f} "
f"({width_mm:g}x{height_mm:g} mm) differs from the raster aspect "
f"{raster_aspect:.6f} ({texture_width}x{texture_height} px); geometry is "
f"scaled non-uniformly ({width_mm / texture_width:g} mm/px horizontally "
f"vs {height_mm / texture_height:g} mm/px vertically).",
file=sys.stderr,
)
def _clip_polygon_half_plane(points, axis, boundary, keep_greater):
"""Clip a polygon against one half-plane (Sutherland-Hodgman step)."""
if not points:
return []
def inside(point):
return point[axis] >= boundary if keep_greater else point[axis] <= boundary
result = []
previous = points[-1]
previous_inside = inside(previous)
for current in points:
current_inside = inside(current)
if current_inside != previous_inside:
denominator = current[axis] - previous[axis]
if denominator != 0.0:
t = (boundary - previous[axis]) / denominator
result.append(
(
previous[0] + t * (current[0] - previous[0]),
previous[1] + t * (current[1] - previous[1]),
)
)
if current_inside:
result.append(current)
previous, previous_inside = current, current_inside
return result
def clip_polygon_to_page(polygon, width, height):
"""Clip a pixel-space polygon to the page rectangle; [] when fully outside.
Seam-crossing UV faces and patches are duplicated at ±1 UV period, so their
pixel polygons can extend past the texture. The raster clamps each scanline,
but vector output would otherwise carry stray objects outside the artwork.
"""
points = [(float(x), float(y)) for x, y in polygon]
for axis, boundary, keep_greater in (
(0, 0.0, True),
(0, float(width), False),
(1, 0.0, True),
(1, float(height), False),
):
points = _clip_polygon_half_plane(points, axis, boundary, keep_greater)
if len(points) < 3:
return []
return points
def _clipped_polygons(polygons, width, height):
clipped = []
for polygon in polygons:
page_polygon = clip_polygon_to_page(polygon, width, height)
if page_polygon:
clipped.append(page_polygon)
return clipped
def _clipped_shapes(shapes, width, height):
clipped = []
for polygon, color in shapes:
page_polygon = clip_polygon_to_page(polygon, width, height)
if page_polygon:
clipped.append((page_polygon, color))
return clipped
def write_svg_vector(
output_path, texture_width, texture_height, width_mm, height_mm,
mesh_polygons, patch_shapes, cross_rects, cross_color, uv_map
):
"""Write editable grouped SVG geometry; no raster data is embedded."""
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
(
f'<svg xmlns="http://www.w3.org/2000/svg" '
f'width="{_format_number(width_mm)}mm" '
f'height="{_format_number(height_mm)}mm" '
f'viewBox="0 0 {texture_width} {texture_height}" '
f'preserveAspectRatio="none">'
),
f" <title>Orisys patch texture — {_xml_escape(uv_map)} UV map</title>",
(
" <desc>Native vector geometry grouped as background, mesh, "
"camera-space patches, and reference cross.</desc>"
),
(
f' <g id="artwork" transform="translate(0 {texture_height}) scale(1 -1)">'
),
' <g id="background">',
(
f' <rect id="background-fill" x="0" y="0" '
f'width="{texture_width}" height="{texture_height}" '
f'fill="#000000" stroke="none"/>'
),
" </g>",
' <g id="mesh">',
]
for index, polygon in enumerate(mesh_polygons, start=1):
lines.append(
_svg_polygon_element(
f"mesh-face-{index:04d}", polygon, (1.0, 1.0, 1.0, 1.0)
)
)
lines.extend([" </g>", ' <g id="patches">'])
for index, (polygon, color) in enumerate(patch_shapes, start=1):
lines.append(
_svg_polygon_element(f"patch-{index:04d}", polygon, color)
)
lines.extend([" </g>", ' <g id="reference-cross">'])
cross_attributes = _svg_fill_attributes(cross_color)
for index, (x, y, rect_width, rect_height) in enumerate(cross_rects, start=1):
lines.append(
f' <rect id="cross-arm-{index}" x="{_format_number(x)}" '
f'y="{_format_number(y)}" width="{_format_number(rect_width)}" '
f'height="{_format_number(rect_height)}" {cross_attributes}/>'
)
lines.extend([" </g>", " </g>", "</svg>", ""])
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("\n".join(lines), encoding="utf-8")
print(f"SVG vector texture saved to {path}")
def _pdf_set_fill(color):
r, g, b = (_clamp_channel(channel) for channel in color[:3])
return f"{_format_number(r)} {_format_number(g)} {_format_number(b)} rg"
def _pdf_polygon_commands(polygon, scale_x, scale_y):
if len(polygon) < 3:
return []
first_x, first_y = polygon[0]
commands = [
f"{_format_number(first_x * scale_x)} {_format_number(first_y * scale_y)} m"
]
commands.extend(
f"{_format_number(x * scale_x)} {_format_number(y * scale_y)} l"
for x, y in polygon[1:]
)
commands.extend(["h", "f"])
return commands
def _build_pdf(objects):
"""Assemble a minimal PDF 1.4 document from already-numbered objects."""
document = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
offsets = [0]
for object_number, payload in enumerate(objects, start=1):
offsets.append(len(document))
document.extend(f"{object_number} 0 obj\n".encode("ascii"))
document.extend(payload)
if not payload.endswith(b"\n"):
document.extend(b"\n")
document.extend(b"endobj\n")
xref_offset = len(document)
document.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
document.extend(b"0000000000 65535 f \n")
for offset in offsets[1:]:
document.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
document.extend(
(
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
f"startxref\n{xref_offset}\n%%EOF\n"
).encode("ascii")
)
return bytes(document)
def write_pdf_vector(
output_path, texture_width, texture_height, width_mm, height_mm,
mesh_polygons, patch_shapes, cross_rects, cross_color
):
"""Write native vector PDF paths at the configured physical page size."""
page_width_pt = float(width_mm) * 72.0 / 25.4
page_height_pt = float(height_mm) * 72.0 / 25.4
scale_x = page_width_pt / float(texture_width)
scale_y = page_height_pt / float(texture_height)
content = ["q", "% background", "0 0 0 rg"]
content.append(
f"0 0 {_format_number(page_width_pt)} {_format_number(page_height_pt)} re f"
)
content.extend(["% mesh", "1 1 1 rg"])
for polygon in mesh_polygons:
content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y))
content.append("% camera-space patches")
for polygon, color in patch_shapes:
content.append(_pdf_set_fill(color))
content.extend(_pdf_polygon_commands(polygon, scale_x, scale_y))
cross_alpha = _clamp_channel(cross_color[3])
has_cross_alpha = bool(cross_rects) and cross_alpha < 0.999999
content.extend(["% reference cross", "q"])
if has_cross_alpha:
content.append("/CrossAlpha gs")
content.append(_pdf_set_fill(cross_color))
for x, y, rect_width, rect_height in cross_rects:
content.append(
f"{_format_number(x * scale_x)} {_format_number(y * scale_y)} "
f"{_format_number(rect_width * scale_x)} "
f"{_format_number(rect_height * scale_y)} re f"
)
content.extend(["Q", "Q", ""])
content_bytes = "\n".join(content).encode("ascii")
if has_cross_alpha:
resources = b"<< /ExtGState << /CrossAlpha 5 0 R >> >>"
else:
resources = b"<< >>"
objects = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
(
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 "
+ _format_number(page_width_pt).encode("ascii")
+ b" "
+ _format_number(page_height_pt).encode("ascii")
+ b"] /Resources "
+ resources
+ b" /Contents 4 0 R >>"
),
(
f"<< /Length {len(content_bytes)} >>\nstream\n".encode("ascii")
+ content_bytes
+ b"endstream"
),
]
if has_cross_alpha:
objects.append(
(
f"<< /Type /ExtGState /ca {_format_number(cross_alpha)} "
f"/CA {_format_number(cross_alpha)} >>"
).encode("ascii")
)
path = Path(output_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(_build_pdf(objects))
print(f"PDF vector texture saved to {path}")
# ----------------------------------------------------------------------
# Mode 1: Colored Patches
# ----------------------------------------------------------------------
@@ -522,12 +844,18 @@ def generate_patch_texture(
obj, cam, scene, depsgraph,
nx=40, ny=30, r=0.05,
texture_width=2048, texture_height=2048,
output_path=None, uv_centre=None, cross_length=40,
output_path=None, svg_path=None, pdf_path=None,
vector_width_mm=164.0, vector_height_mm=160.0,
uv_centre=None, cross_length=40,
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
uv_layer=None, image_name="PatchTexture"
):
if not output_path:
raise ValueError("output_path is required (absolute PNG path)")
if svg_path or pdf_path:
_warn_if_page_aspect_differs(
texture_width, texture_height, vector_width_mm, vector_height_mm
)
cam_data = cam.data
render = scene.render
res_x = render.resolution_x
@@ -583,12 +911,19 @@ def generate_patch_texture(
# Fill entire image with black (no mesh areas)
fill_background(pixels, texture_width, texture_height, (0,0,0,1))
# Rasterize all mesh UVs in white (visible + non‑visible parts)
rasterize_mesh_uvs(
obj, pixels, texture_width, texture_height, color=(1,1,1,1), uv_layer=uv_layer
# Rasterize all mesh UVs in white (visible + non-visible parts). Retain
# the exact same polygons for native SVG/PDF output.
mesh_polygons = mesh_uv_pixel_polygons(
obj, texture_width, texture_height, uv_layer=uv_layer
)
for pixel_poly in mesh_polygons:
fill_polygon(
None, pixels, texture_width, texture_height,
pixel_poly, (1.0, 1.0, 1.0, 1.0)
)
# Now draw the visible patches on top
# Draw the visible patches and retain each editable vector shape.
patch_shapes = []
idx = 0
for j in range(ny):
for i in range(nx):
@@ -597,10 +932,16 @@ def generate_patch_texture(
color = patch_colors[j,i]
rgba = (color[0], color[1], color[2], 1.0)
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
patch_shapes.append((pixel_poly, rgba))
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
# (after drawing all patches)
cross_shapes = []
if uv_centre is not None:
cross_shapes = cross_rectangles(
texture_width, texture_height,
uv_centre[0], uv_centre[1],
cross_length, cross_thickness,
)
draw_cross(pixels, texture_width, texture_height,
uv_centre[0], uv_centre[1],
cross_length, cross_thickness, cross_color)
@@ -612,7 +953,27 @@ def generate_patch_texture(
image.filepath_raw = str(out_path)
image.file_format = 'PNG'
image.save()
print(f"Patch texture saved to {out_path}")
print(f"PNG raster texture saved to {out_path}")
uv_map_name = uv_layer.name if uv_layer is not None else "active"
if svg_path or pdf_path:
# Clip seam-wrapped duplicates to the page so nothing sits outside the
# artwork once the geometry is opened in an editor.
page_mesh_polygons = _clipped_polygons(mesh_polygons, texture_width, texture_height)
page_patch_shapes = _clipped_shapes(patch_shapes, texture_width, texture_height)
if svg_path:
write_svg_vector(
svg_path, texture_width, texture_height,
vector_width_mm, vector_height_mm,
page_mesh_polygons, page_patch_shapes, cross_shapes, cross_color,
uv_map_name,
)
if pdf_path:
write_pdf_vector(
pdf_path, texture_width, texture_height,
vector_width_mm, vector_height_mm,
page_mesh_polygons, page_patch_shapes, cross_shapes, cross_color,
)
return image
# ----------------------------------------------------------------------
@@ -874,7 +1235,7 @@ def parse_args(argv):
parser = argparse.ArgumentParser(prog="random_patch_dd02c.py")
parser.add_argument(
"--job", type=Path, default=None,
help="JSON texture job written by orisys-design (output + objects + settings)",
help="JSON texture job written by orisys-design (artifacts + objects + settings)",
)
parser.add_argument(
"--output", type=Path, default=None,
@@ -888,6 +1249,14 @@ def parse_args(argv):
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("--height", type=int, default=None, help="Texture height (overrides job)")
parser.add_argument(
"--vector-width-mm", dest="vector_width_mm", type=float, default=None,
help="SVG/PDF physical width in millimetres (overrides job)",
)
parser.add_argument(
"--vector-height-mm", dest="vector_height_mm", type=float, default=None,
help="SVG/PDF physical height in millimetres (overrides job)",
)
parser.add_argument("--nx", type=int, default=None, help="Patch columns (overrides job)")
parser.add_argument("--ny", type=int, default=None, help="Patch rows (overrides job)")
parser.add_argument("--color-r", dest="color_r", type=float, default=None, help="Colour separation")
@@ -924,6 +1293,12 @@ def _as_int(value, label, minimum=1):
return int(value)
def _as_positive_number(value, label):
if not isinstance(value, (int, float)) or isinstance(value, bool) or value <= 0:
raise ConfigError(f"{label}: must be a positive number (got {value!r})")
return float(value)
def _as_color(value, label):
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
raise ConfigError(f"{label}: must be a list of 3 or 4 numbers in [0, 1] (got {value!r})")
@@ -938,27 +1313,35 @@ def _as_color(value, label):
return tuple(rgba)
def _as_output_path(raw, label="output"):
_ARTIFACT_FORMATS = ("png", "svg", "pdf")
def _as_output_path(raw, format_name, label):
if raw is None or raw == "":
raise ConfigError(f"{label}: a PNG path is required")
raise ConfigError(f"{label}: a {format_name.upper()} 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})")
expected_suffix = f".{format_name}"
if path.suffix.lower() != expected_suffix:
raise ConfigError(
f"{label} must be a {expected_suffix} file (got {raw!r})"
)
return path
def _resolve_targets(args, job):
"""Return the ordered ``[(uv_map, output_path)]`` targets from the job or flags."""
"""Return ordered ``[(uv_map, {png, svg, pdf})]`` output targets."""
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}")
raise ConfigError(
"job 'outputs' must be a non-empty list of {uv_map, artifacts}"
)
targets = []
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
@@ -966,30 +1349,62 @@ def _resolve_targets(args, job):
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})"
f"job outputs[{index}]: 'uv_map' must be a non-empty string "
f"(got {uv_map!r})"
)
targets.append(
(uv_map, _as_output_path(entry.get("output"), f"job outputs[{index}].output"))
)
raw_artifacts = entry.get("artifacts")
if not isinstance(raw_artifacts, dict):
raise ConfigError(f"job outputs[{index}].artifacts must be a mapping")
unknown = sorted(set(raw_artifacts) - set(_ARTIFACT_FORMATS))
missing = [name for name in _ARTIFACT_FORMATS if name not in raw_artifacts]
if unknown:
raise ConfigError(
f"job outputs[{index}].artifacts has unknown format(s): "
f"{', '.join(unknown)}"
)
if missing:
raise ConfigError(
f"job outputs[{index}].artifacts is missing: {', '.join(missing)}"
)
artifacts = {
format_name: _as_output_path(
raw_artifacts[format_name], format_name,
f"job outputs[{index}].artifacts.{format_name}",
)
for format_name in _ARTIFACT_FORMATS
}
targets.append((uv_map, artifacts))
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))]
png_path = _as_output_path(args.output, "png", "output")
targets = [
(
args.uv_map,
{
format_name: png_path.with_suffix(f".{format_name}")
for format_name in _ARTIFACT_FORMATS
},
)
]
seen_names = set()
seen_paths = {}
for uv_map, path in targets:
for uv_map, artifacts 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
for format_name, path in artifacts.items():
key = str(path).lower()
if key in seen_paths:
previous_uv, previous_format = seen_paths[key]
raise ConfigError(
f"duplicate output path for {previous_uv!r}/{previous_format} and "
f"{uv_map!r}/{format_name}: {path}"
)
seen_paths[key] = (uv_map, format_name)
return targets
@@ -1011,6 +1426,9 @@ def _resolve_config(args):
job_cross = settings.get("cross") or {}
if not isinstance(job_cross, dict):
raise ConfigError("job 'settings.cross' must be a mapping")
job_vector = settings.get("vector") or {}
if not isinstance(job_vector, dict):
raise ConfigError("job 'settings.vector' must be a mapping")
def pick(flag, key, default):
if flag is not None:
@@ -1024,11 +1442,25 @@ def _resolve_config(args):
value = job_cross.get(key, default)
return default if value is None else value
def pick_vector(flag, key, default):
if flag is not None:
return flag
value = job_vector.get(key, default)
return default if value is None else value
mesh = args.mesh or objects.get("mesh") or _DEFAULT_MESH
camera = args.cam or objects.get("camera") or _DEFAULT_CAMERA
width = _as_int(pick(args.width, "width", 2048), "width")
height = _as_int(pick(args.height, "height", 2048), "height")
vector_width_mm = _as_positive_number(
pick_vector(args.vector_width_mm, "width_mm", 164.0),
"vector.width_mm",
)
vector_height_mm = _as_positive_number(
pick_vector(args.vector_height_mm, "height_mm", 160.0),
"vector.height_mm",
)
nx = _as_int(pick(args.nx, "nx", 80), "nx")
ny = _as_int(pick(args.ny, "ny", 45), "ny")
edge_sample_grid = _as_int(
@@ -1062,6 +1494,8 @@ def _resolve_config(args):
"camera": str(camera),
"width": width,
"height": height,
"vector_width_mm": vector_width_mm,
"vector_height_mm": vector_height_mm,
"nx": nx,
"ny": ny,
"color_r": float(color_r),
@@ -1149,23 +1583,24 @@ def main():
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"]
(uv_map, artifacts, _require_uv_layer(obj, uv_map))
for uv_map, artifacts in config["outputs"]
]
except SceneError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
for uv_map, output, _layer in targets:
try:
output.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(
f"ERROR: cannot create output directory for UV map {uv_map!r} "
f"({output.parent}): {exc}",
file=sys.stderr,
)
return 2
for uv_map, artifacts, _layer in targets:
for format_name, output in artifacts.items():
try:
output.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(
f"ERROR: cannot create {format_name.upper()} output directory for "
f"UV map {uv_map!r} ({output.parent}): {exc}",
file=sys.stderr,
)
return 2
scene = bpy.context.scene
depsgraph = bpy.context.evaluated_depsgraph_get()
@@ -1178,16 +1613,20 @@ def main():
rng_state = np.random.get_state()
start_time = time.time()
for uv_map, output, uv_layer in targets:
for uv_map, artifacts, 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}")
print(f"--- UV map {uv_map!r}")
try:
generate_patch_texture(
obj, cam, scene, depsgraph,
nx=config["nx"], ny=config["ny"], r=config["color_r"],
texture_width=config["width"], texture_height=config["height"],
output_path=str(output),
output_path=str(artifacts["png"]),
svg_path=str(artifacts["svg"]),
pdf_path=str(artifacts["pdf"]),
vector_width_mm=config["vector_width_mm"],
vector_height_mm=config["vector_height_mm"],
uv_centre=uv_centre,
cross_length=config["cross_length"],
cross_thickness=config["cross_thickness"],
@@ -1203,17 +1642,25 @@ def main():
)
return 1
if not output.is_file() or output.stat().st_size == 0:
print(
f"ERROR: texture was not written for UV map {uv_map!r}: {output}",
file=sys.stderr,
)
return 1
for format_name, output in artifacts.items():
if not output.is_file() or output.stat().st_size == 0:
print(
f"ERROR: {format_name.upper()} texture was not written for "
f"UV map {uv_map!r}: {output}",
file=sys.stderr,
)
return 1
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']}")
artifact_count = sum(len(artifacts) for _, artifacts, _ in targets)
print(f"Texture complete: {artifact_count} file(s)")
for uv_map, artifacts, _layer in targets:
print(f" [{uv_map}]")
for format_name, output in artifacts.items():
print(f" {format_name.upper()}: {output} ({output.stat().st_size} bytes)")
print(
f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']} "
f"vector {config['vector_width_mm']:g}x{config['vector_height_mm']:g}mm"
)
print(f"Completed in {time.time() - start_time:.2f} seconds.")
return 0
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 612 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 683 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 676 KiB

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 677 KiB

Binary file not shown.
+6 -3
View File
@@ -25,8 +25,8 @@ objects:
# Headless colored-patch texture generation (`orisys-design texture`).
# UV unwrapping is still done in the Blender GUI: the headless run consumes the
# 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.
# One PNG/SVG/PDF set is written per UV map below. All maps and formats share
# the settings and patch colours, so they differ only by UV layout.
# These are the revision defaults; CLI flags override every value.
texture_patch:
mesh: sensing_surface
@@ -40,7 +40,10 @@ texture_patch:
ny: 45
color_r: 0.05
edge_sample_grid: 5
seed: null # set an integer for reproducible colours
seed: 7 # fixed so PNG/SVG/PDF share one reproducible colour set
vector:
width_mm: 164 # editable SVG/PDF production page width
height_mm: 160 # editable SVG/PDF production page height
cross:
enabled: true
length_px: 40
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

After

Width:  |  Height:  |  Size: 635 KiB

+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(
+79 -18
View File
@@ -32,6 +32,7 @@ DEFAULT_OUTPUTS = [
("produce", "patch_texture_produce.png"),
("compute", "patch_texture_compute.png"),
]
ARTIFACT_FORMATS = ("png", "svg", "pdf")
@pytest.fixture()
@@ -126,8 +127,15 @@ def test_output_paths_pair_each_target(revision_dir: Path, tmp_path: Path) -> No
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)
assert [target.uv_map for target, _ in targets] == ["produce", "compute"]
for (target, artifacts), (_, png_name) in zip(targets, DEFAULT_OUTPUTS):
stem = Path(png_name).stem
assert list(artifacts) == list(ARTIFACT_FORMATS)
assert {name: path.name for name, path in artifacts.items()} == {
format_name: f"{stem}.{format_name}"
for format_name in ARTIFACT_FORMATS
}
assert all(path.parent == tmp_path.resolve() for path in artifacts.values())
def test_job_path_sits_beside_texture(tmp_path: Path) -> None:
@@ -138,8 +146,13 @@ def test_output_target_entry_is_absolute(revision_dir: Path, tmp_path: Path) ->
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"
assert list(entry["artifacts"]) == list(ARTIFACT_FORMATS)
assert all(Path(path).is_absolute() for path in entry["artifacts"].values())
assert {name: Path(path).name for name, path in entry["artifacts"].items()} == {
"png": "a.png",
"svg": "a.svg",
"pdf": "a.pdf",
}
# ------------------------------------------------------- settings merge
@@ -154,7 +167,10 @@ def test_defaults_come_from_design_yaml(revision_dir: Path) -> None:
assert (settings.nx, settings.ny) == (80, 45)
assert settings.color_r == pytest.approx(0.05)
assert settings.edge_sample_grid == 5
assert settings.seed is None
# Pinned in design.yaml so the committed PNG/SVG/PDF set is reproducible.
assert settings.seed == 7
assert settings.vector_width_mm == pytest.approx(164.0)
assert settings.vector_height_mm == pytest.approx(160.0)
assert settings.cross_enabled is True
assert settings.cross_length_px == 40
assert settings.cross_thickness_px == 3
@@ -199,6 +215,7 @@ def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
"color_r": 0.25,
"edge_sample_grid": 3,
"seed": 11,
"vector": {"width_mm": 82.0, "height_mm": 80.0},
"cross": {"enabled": False, "length_px": 8, "color": [0.0, 1.0, 0.0]},
},
)
@@ -209,6 +226,8 @@ def test_cli_overrides_beat_design_yaml(revision_dir: Path) -> None:
assert settings.color_r == pytest.approx(0.25)
assert settings.edge_sample_grid == 3
assert settings.seed == 11
assert settings.vector_width_mm == pytest.approx(82.0)
assert settings.vector_height_mm == pytest.approx(80.0)
assert settings.cross_enabled is False
assert settings.cross_length_px == 8
# A 3-channel cross colour is widened to RGBA.
@@ -249,6 +268,15 @@ def test_unknown_cross_key_is_rejected(tmp_path: Path) -> None:
assert "thickness" in str(excinfo.value)
def test_unknown_vector_key_is_rejected(tmp_path: Path) -> None:
revision = _write_revision(
tmp_path, "texture_patch:\n vector:\n dpi: 508\n"
)
with pytest.raises(TextureConfigError) as excinfo:
resolve_texture_settings(RevisionPaths.from_revision_dir(revision))
assert "dpi" in str(excinfo.value)
def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, "texture_patch: patches\n")
with pytest.raises(TextureConfigError) as excinfo:
@@ -266,6 +294,8 @@ def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None:
({"color_r": 2.0}, "color_r"),
({"edge_sample_grid": 1}, "edge_sample_grid"),
({"seed": "abc"}, "seed"),
({"vector": {"width_mm": 0}}, "vector.width_mm"),
({"vector": {"height_mm": "large"}}, "vector.height_mm"),
({"cross": {"enabled": "yes"}}, "cross.enabled"),
({"cross": {"length_px": 0}}, "cross.length_px"),
({"cross": {"color": [2.0, 0.0, 0.0, 1.0]}}, "cross.color"),
@@ -328,10 +358,14 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
}
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"])
for entry, (_, png_name) in zip(job["outputs"], DEFAULT_OUTPUTS):
stem = Path(png_name).stem
assert list(entry["artifacts"]) == list(ARTIFACT_FORMATS)
assert {name: Path(path).name for name, path in entry["artifacts"].items()} == {
format_name: f"{stem}.{format_name}"
for format_name in ARTIFACT_FORMATS
}
assert all(Path(path).is_absolute() for path in entry["artifacts"].values())
assert set(job["objects"]) == {"mesh", "camera"}
assert set(job["settings"]) == {
"width",
@@ -341,8 +375,13 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
"color_r",
"edge_sample_grid",
"seed",
"vector",
"cross",
}
assert job["settings"]["vector"] == {
"width_mm": 164.0,
"height_mm": 160.0,
}
assert set(job["settings"]["cross"]) == {
"enabled",
"length_px",
@@ -374,20 +413,29 @@ def test_write_job_creates_output_directory(revision_dir: Path, tmp_path: Path)
def _fake_blender(
monkeypatch: pytest.MonkeyPatch,
exit_code: int,
write_pngs: bool = True,
write_artifacts: bool = True,
skip_uv: str | None = None,
skip_format: str | None = None,
):
calls: list[tuple[str, Path, list[str]]] = []
payloads = {
"png": b"\x89PNG\r\n\x1a\n",
"svg": b"<svg/>",
"pdf": b"%PDF-1.4\n",
}
def fake_run(script: str, blend: Path, extra_args: list[str]):
calls.append((script, blend, list(extra_args)))
if write_pngs:
if write_artifacts:
job_path = Path(extra_args[extra_args.index("--job") + 1])
job = json.loads(job_path.read_text(encoding="utf-8"))
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")
for format_name, raw_path in entry["artifacts"].items():
if skip_uv is not None and entry["uv_map"] == skip_uv:
continue
if skip_format is not None and format_name == skip_format:
continue
Path(raw_path).write_bytes(payloads[format_name])
return exit_code
monkeypatch.setattr(cli, "_run_blender_script", fake_run)
@@ -410,13 +458,15 @@ def test_cmd_texture_forwards_job_and_verifies_both_artifacts(
assert job_path == out_dir.resolve() / TEXTURE_JOB_FILENAME
for _, filename in DEFAULT_OUTPUTS:
assert (out_dir / filename).is_file()
stem = Path(filename).stem
for format_name in ARTIFACT_FORMATS:
assert (out_dir / f"{stem}.{format_name}").is_file()
def test_cmd_texture_propagates_blender_failure(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
_fake_blender(monkeypatch, 9, write_pngs=False)
_fake_blender(monkeypatch, 9, write_artifacts=False)
namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 9
@@ -424,7 +474,7 @@ def test_cmd_texture_propagates_blender_failure(
def test_cmd_texture_detects_both_textures_missing(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
_fake_blender(monkeypatch, 0, write_pngs=False)
_fake_blender(monkeypatch, 0, write_artifacts=False)
namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 3
@@ -438,6 +488,15 @@ def test_cmd_texture_detects_partial_pair(
assert cli.cmd_texture(revision_dir, namespace) == 3
def test_cmd_texture_detects_one_missing_vector_format(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
"""Every UV target must have PNG, SVG, and PDF before success is reported."""
_fake_blender(monkeypatch, 0, skip_format="svg")
namespace = _texture_namespace(revision_dir, tmp_path / "out")
assert cli.cmd_texture(revision_dir, namespace) == 3
def test_main_texture_parses_flags_into_job(
monkeypatch: pytest.MonkeyPatch, revision_dir: Path, tmp_path: Path
) -> None:
@@ -475,7 +534,9 @@ def test_main_texture_parses_flags_into_job(
assert job["settings"]["seed"] == 7
assert job["settings"]["cross"]["enabled"] is False
for _, filename in DEFAULT_OUTPUTS:
assert (out_dir / filename).is_file()
stem = Path(filename).stem
for format_name in ARTIFACT_FORMATS:
assert (out_dir / f"{stem}.{format_name}").is_file()
def test_main_texture_rejects_filename_flag(