added svg patch output
@@ -1,13 +1,25 @@
|
|||||||
# Git LFS — enable after Gitea LFS_START_SERVER=true on the NAS
|
# Binary types are marked non-text so git never applies EOL conversion and never
|
||||||
*.blend filter=lfs diff=lfs merge=lfs -text
|
# attempts a textual diff or merge on them.
|
||||||
*.blend1 filter=lfs diff=lfs merge=lfs -text
|
#
|
||||||
*.exr filter=lfs diff=lfs merge=lfs -text
|
# Git LFS is intentionally NOT used. This repository has no remote configured,
|
||||||
*.hdr filter=lfs diff=lfs merge=lfs -text
|
# and every tracked binary used to be a 131-byte LFS pointer whose real bytes
|
||||||
*.png filter=lfs diff=lfs merge=lfs -text
|
# lived only in .git/lfs/objects — so a checkout or clone without LFS produced
|
||||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
# unusable .blend and .png files. Real bytes are committed directly instead.
|
||||||
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
#
|
||||||
*.tif filter=lfs diff=lfs merge=lfs -text
|
# To bring LFS back (for example once Gitea LFS_START_SERVER=true on the NAS),
|
||||||
*.tiff filter=lfs diff=lfs merge=lfs -text
|
# give each extension a filter again and re-store the files:
|
||||||
*.npz filter=lfs diff=lfs merge=lfs -text
|
# *.blend filter=lfs diff=lfs merge=lfs -text
|
||||||
*.glb filter=lfs diff=lfs merge=lfs -text
|
# git add --renormalize .
|
||||||
*.gltf filter=lfs diff=lfs merge=lfs -text
|
*.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
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ __pycache__/
|
|||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
dist/
|
||||||
build/export/
|
build/export/
|
||||||
# Generated textures are tracked (Git LFS via *.png) so they can be synced to
|
# Generated textures are tracked as real files (no Git LFS) so they can be synced
|
||||||
# hardware production. Only the per-run job file is ignored: it records absolute
|
# to hardware production. Only the per-run job file is ignored: it records
|
||||||
# machine paths and is rewritten by every run.
|
# absolute machine paths and is rewritten by every run.
|
||||||
build/textures/**/_texture_job.json
|
build/textures/**/_texture_job.json
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ sensors/finger/
|
|||||||
├── current.yaml # active revision pointer
|
├── current.yaml # active revision pointer
|
||||||
└── r01/
|
└── r01/
|
||||||
├── design.yaml # units, frames, object names
|
├── design.yaml # units, frames, object names
|
||||||
├── source/geometry.blend # LFS: named collections + objects
|
├── source/geometry.blend # named collections + objects
|
||||||
├── profiles/
|
├── profiles/
|
||||||
│ ├── compute.yaml # → SDK bundle layout (Studio runtime)
|
│ ├── compute.yaml # → SDK bundle layout (Studio runtime)
|
||||||
│ └── studio.yaml # inputs for orisys-app compile_scene
|
│ └── studio.yaml # inputs for orisys-app compile_scene
|
||||||
@@ -34,9 +34,10 @@ sensors/finger/
|
|||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- [Git LFS](https://git-lfs.com/) on every client (`git lfs install`)
|
|
||||||
- Blender version pinned in `blender.lock.json` (install separately)
|
- Blender version pinned in `blender.lock.json` (install separately)
|
||||||
|
|
||||||
|
No Git LFS is required — binaries are committed as ordinary files.
|
||||||
|
|
||||||
### Finding Blender on Windows
|
### Finding Blender on Windows
|
||||||
|
|
||||||
The CLI looks for Blender in this order:
|
The CLI looks for Blender in this order:
|
||||||
@@ -113,8 +114,10 @@ build/export/finger/r01/
|
|||||||
|
|
||||||
## Texture patches (headless)
|
## Texture patches (headless)
|
||||||
|
|
||||||
`orisys-design texture` generates one colored patch texture per UV map on the
|
`orisys-design texture` generates a PNG, editable SVG, and native vector PDF for
|
||||||
sensing surface. UV unwrapping is **still an in-app Blender step**: this command
|
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
|
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
|
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
|
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
|
```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_produce.png # from the `produce` UV map (tracked, Git LFS)
|
├── patch_texture_produce.png # raster preview/use (tracked)
|
||||||
└── patch_texture_compute.png # from the `compute` UV map (tracked, Git LFS)
|
├── 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
|
`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 —
|
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.
|
the images differ only in how the unwrap arranges the patches.
|
||||||
|
|
||||||
Generated PNGs are **committed** so a revision's textures can be synced to
|
Generated artifacts are **committed** so a revision's textures can be synced to
|
||||||
hardware production; `.gitattributes` already routes `*.png` through Git LFS
|
hardware production. They are stored as real files, not Git LFS pointers, so a
|
||||||
(`git lfs install` / `git lfs pull` as usual). Only `_texture_job.json` is
|
plain `git clone` yields usable images and `.blend` files; `.gitattributes` only
|
||||||
ignored, because it records absolute machine paths and is rewritten every run.
|
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
|
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
|
`uv_outputs` map (UV map → artifact stem), raster dimensions, vector page size in
|
||||||
separation, edge sampling, reference cross, and an optional seed. CLI flags
|
millimetres, patch grid, colour separation, edge sampling, reference cross, and
|
||||||
override the shared values for every output:
|
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
|
```powershell
|
||||||
orisys-design texture sensors/finger/r01 `
|
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
|
--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
|
```powershell
|
||||||
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 -- `
|
||||||
--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
|
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`.
|
- **Objects**: `origin_sensor`, `sensing_surface`, `cam_sensor`.
|
||||||
- **Matrix names** must state direction; exported pose file remains `world_to_cam.npy`
|
- **Matrix names** must state direction; exported pose file remains `world_to_cam.npy`
|
||||||
for SDK compatibility (sensor frame → camera).
|
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
|
**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.
|
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
|
git lfs pull
|
||||||
```
|
```
|
||||||
|
|
||||||
This repo’s `.gitattributes` already tracks `*.blend` and large binaries. After
|
This repo’s `.gitattributes` does **not** track anything through LFS today. To turn
|
||||||
`git lfs track`, always commit `.gitattributes`.
|
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
|
### Typical pitfalls
|
||||||
|
|
||||||
@@ -281,6 +317,6 @@ behind your NAS reverse proxy is the path of least resistance.
|
|||||||
|
|
||||||
| Phase | Goal |
|
| 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 |
|
| **B** | Lock file in SDK/App + CI reproduces export hashes |
|
||||||
| **C** (later) | Markers, camera metrics, simulation datasets |
|
| **C** (later) | Markers, camera metrics, simulation datasets |
|
||||||
|
|||||||
@@ -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 -- \
|
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 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 \
|
--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
|
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
|
||||||
@@ -34,37 +36,38 @@ from mathutils import Vector
|
|||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
# Common Utilities
|
# Common Utilities
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
|
def cross_rectangles(width, height, cx_uv, cy_uv, length_px, thickness_px):
|
||||||
"""
|
"""Return horizontal/vertical cross arms as bottom-left pixel rectangles."""
|
||||||
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)
|
|
||||||
cx = int(round(cx_uv * (width - 1)))
|
cx = int(round(cx_uv * (width - 1)))
|
||||||
cy = int(round(cy_uv * (height - 1)))
|
cy = int(round(cy_uv * (height - 1)))
|
||||||
|
|
||||||
half_len = length_px // 2
|
half_len = length_px // 2
|
||||||
half_thick = thickness_px // 2
|
half_thick = thickness_px // 2
|
||||||
|
|
||||||
# Horizontal arm
|
horizontal = (
|
||||||
x0 = max(0, cx - half_len)
|
max(0, cx - half_len),
|
||||||
x1 = min(width - 1, cx + half_len)
|
max(0, cy - half_thick),
|
||||||
y0 = max(0, cy - half_thick)
|
min(width - 1, cx + half_len),
|
||||||
y1 = min(height - 1, cy + half_thick)
|
min(height - 1, cy + half_thick),
|
||||||
for y in range(y0, y1 + 1):
|
)
|
||||||
for x in range(x0, x1 + 1):
|
vertical = (
|
||||||
idx = (y * width + x) * 4
|
max(0, cx - half_thick),
|
||||||
pixels[idx:idx+4] = color
|
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)
|
def draw_cross(pixels, width, height, cx_uv, cy_uv, length_px, thickness_px, color):
|
||||||
x1 = min(width - 1, cx + half_thick)
|
"""Draw a cross (plus sign), using the same rectangles as vector output."""
|
||||||
y0 = max(0, cy - half_len)
|
for x0, y0, rect_width, rect_height in cross_rectangles(
|
||||||
y1 = min(height - 1, cy + half_len)
|
width, height, cx_uv, cy_uv, length_px, thickness_px
|
||||||
for y in range(y0, y1 + 1):
|
):
|
||||||
for x in range(x0, x1 + 1):
|
for y in range(y0, y0 + rect_height):
|
||||||
|
for x in range(x0, x0 + rect_width):
|
||||||
idx = (y * width + x) * 4
|
idx = (y * width + x) * 4
|
||||||
pixels[idx:idx+4] = color
|
pixels[idx:idx+4] = color
|
||||||
|
|
||||||
@@ -353,6 +356,25 @@ def sample_patch_uv_polygon(
|
|||||||
|
|
||||||
return convex_hull_uv(sampled_uvs)
|
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):
|
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).
|
Fill all triangles of one UV map with 'color' (default white).
|
||||||
@@ -361,22 +383,13 @@ 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
|
``uv_layer`` selects the mesh UV map; ``None`` keeps the active-layer
|
||||||
behaviour used by the checker/dot helpers.
|
behaviour used by the checker/dot helpers.
|
||||||
"""
|
"""
|
||||||
me = obj.data
|
pixel_polygons = mesh_uv_pixel_polygons(
|
||||||
layer = uv_layer if uv_layer is not None else me.uv_layers.active
|
obj, tex_width, tex_height, uv_layer=uv_layer
|
||||||
if not layer:
|
)
|
||||||
|
if not pixel_polygons:
|
||||||
print("Warning: no UV layer selected – mesh rasterization skipped.")
|
print("Warning: no UV layer selected – mesh rasterization skipped.")
|
||||||
return
|
return
|
||||||
uv_data = layer.data
|
for pixel_poly in pixel_polygons:
|
||||||
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)
|
fill_polygon(None, pixels, tex_width, tex_height, pixel_poly, color)
|
||||||
|
|
||||||
def rasterize_mesh_mask(obj, tex_width, tex_height):
|
def rasterize_mesh_mask(obj, tex_width, tex_height):
|
||||||
@@ -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([1.0, 1.0, 1.0], dtype=np.float32)
|
||||||
return np.array([0.0, 0.0, 0.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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace('"', """)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
# Mode 1: Colored Patches
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -522,12 +844,18 @@ def generate_patch_texture(
|
|||||||
obj, cam, scene, depsgraph,
|
obj, cam, scene, depsgraph,
|
||||||
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, 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,
|
cross_thickness=3, cross_color=(1,0,0,1), edge_sample_grid=5,
|
||||||
uv_layer=None, image_name="PatchTexture"
|
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)")
|
||||||
|
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
|
cam_data = cam.data
|
||||||
render = scene.render
|
render = scene.render
|
||||||
res_x = render.resolution_x
|
res_x = render.resolution_x
|
||||||
@@ -583,12 +911,19 @@ def generate_patch_texture(
|
|||||||
# Fill entire image with black (no mesh areas)
|
# Fill entire image with black (no mesh areas)
|
||||||
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). Retain
|
||||||
rasterize_mesh_uvs(
|
# the exact same polygons for native SVG/PDF output.
|
||||||
obj, pixels, texture_width, texture_height, color=(1,1,1,1), uv_layer=uv_layer
|
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
|
idx = 0
|
||||||
for j in range(ny):
|
for j in range(ny):
|
||||||
for i in range(nx):
|
for i in range(nx):
|
||||||
@@ -597,10 +932,16 @@ def generate_patch_texture(
|
|||||||
color = patch_colors[j,i]
|
color = patch_colors[j,i]
|
||||||
rgba = (color[0], color[1], color[2], 1.0)
|
rgba = (color[0], color[1], color[2], 1.0)
|
||||||
for pixel_poly in uv_polygon_to_pixel_polygons(uv_poly, texture_width, texture_height):
|
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)
|
fill_polygon(image, pixels, texture_width, texture_height, pixel_poly, rgba)
|
||||||
|
|
||||||
# (after drawing all patches)
|
cross_shapes = []
|
||||||
if uv_centre is not None:
|
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,
|
draw_cross(pixels, texture_width, texture_height,
|
||||||
uv_centre[0], uv_centre[1],
|
uv_centre[0], uv_centre[1],
|
||||||
cross_length, cross_thickness, cross_color)
|
cross_length, cross_thickness, cross_color)
|
||||||
@@ -612,7 +953,27 @@ def generate_patch_texture(
|
|||||||
image.filepath_raw = str(out_path)
|
image.filepath_raw = str(out_path)
|
||||||
image.file_format = 'PNG'
|
image.file_format = 'PNG'
|
||||||
image.save()
|
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
|
return image
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
@@ -874,7 +1235,7 @@ def parse_args(argv):
|
|||||||
parser = argparse.ArgumentParser(prog="random_patch_dd02c.py")
|
parser = argparse.ArgumentParser(prog="random_patch_dd02c.py")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--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 (artifacts + objects + settings)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output", type=Path, default=None,
|
"--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("--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)")
|
||||||
parser.add_argument("--height", type=int, default=None, help="Texture height (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("--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("--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")
|
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)
|
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):
|
def _as_color(value, label):
|
||||||
if not isinstance(value, (list, tuple)) or len(value) not in (3, 4):
|
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})")
|
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)
|
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 == "":
|
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)):
|
if not isinstance(raw, (str, Path)):
|
||||||
raise ConfigError(f"{label}: must be a path (got {raw!r})")
|
raise ConfigError(f"{label}: must be a path (got {raw!r})")
|
||||||
path = Path(raw).expanduser()
|
path = Path(raw).expanduser()
|
||||||
if not path.is_absolute():
|
if not path.is_absolute():
|
||||||
raise ConfigError(f"{label} must be an absolute path (got {raw!r})")
|
raise ConfigError(f"{label} must be an absolute path (got {raw!r})")
|
||||||
if path.suffix.lower() != ".png":
|
expected_suffix = f".{format_name}"
|
||||||
raise ConfigError(f"{label} must be a .png file (got {raw!r})")
|
if path.suffix.lower() != expected_suffix:
|
||||||
|
raise ConfigError(
|
||||||
|
f"{label} must be a {expected_suffix} file (got {raw!r})"
|
||||||
|
)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _resolve_targets(args, job):
|
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.job is not None:
|
||||||
if args.output is not None or args.uv_map 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")
|
raise ConfigError("--output/--uv-map cannot be combined with --job")
|
||||||
entries = job.get("outputs")
|
entries = job.get("outputs")
|
||||||
if not isinstance(entries, list) or not entries:
|
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 = []
|
targets = []
|
||||||
for index, entry in enumerate(entries):
|
for index, entry in enumerate(entries):
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
@@ -966,30 +1349,62 @@ def _resolve_targets(args, job):
|
|||||||
uv_map = entry.get("uv_map")
|
uv_map = entry.get("uv_map")
|
||||||
if not isinstance(uv_map, str) or not uv_map.strip():
|
if not isinstance(uv_map, str) or not uv_map.strip():
|
||||||
raise ConfigError(
|
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(
|
raw_artifacts = entry.get("artifacts")
|
||||||
(uv_map, _as_output_path(entry.get("output"), f"job outputs[{index}].output"))
|
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:
|
else:
|
||||||
if args.uv_map is None or args.output is None:
|
if args.uv_map is None or args.output is None:
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
"Provide --job <path.json>, or both --uv-map <name> and --output <file.png>"
|
"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_names = set()
|
||||||
seen_paths = {}
|
seen_paths = {}
|
||||||
for uv_map, path in targets:
|
for uv_map, artifacts in targets:
|
||||||
if uv_map in seen_names:
|
if uv_map in seen_names:
|
||||||
raise ConfigError(f"duplicate UV map {uv_map!r} in outputs")
|
raise ConfigError(f"duplicate UV map {uv_map!r} in outputs")
|
||||||
seen_names.add(uv_map)
|
seen_names.add(uv_map)
|
||||||
|
for format_name, path in artifacts.items():
|
||||||
key = str(path).lower()
|
key = str(path).lower()
|
||||||
if key in seen_paths:
|
if key in seen_paths:
|
||||||
|
previous_uv, previous_format = seen_paths[key]
|
||||||
raise ConfigError(
|
raise ConfigError(
|
||||||
f"duplicate output path for {seen_paths[key]!r} and {uv_map!r}: {path}"
|
f"duplicate output path for {previous_uv!r}/{previous_format} and "
|
||||||
|
f"{uv_map!r}/{format_name}: {path}"
|
||||||
)
|
)
|
||||||
seen_paths[key] = uv_map
|
seen_paths[key] = (uv_map, format_name)
|
||||||
return targets
|
return targets
|
||||||
|
|
||||||
|
|
||||||
@@ -1011,6 +1426,9 @@ def _resolve_config(args):
|
|||||||
job_cross = settings.get("cross") or {}
|
job_cross = settings.get("cross") or {}
|
||||||
if not isinstance(job_cross, dict):
|
if not isinstance(job_cross, dict):
|
||||||
raise ConfigError("job 'settings.cross' must be a mapping")
|
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):
|
def pick(flag, key, default):
|
||||||
if flag is not None:
|
if flag is not None:
|
||||||
@@ -1024,11 +1442,25 @@ def _resolve_config(args):
|
|||||||
value = job_cross.get(key, default)
|
value = job_cross.get(key, default)
|
||||||
return default if value is None else value
|
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
|
mesh = args.mesh or objects.get("mesh") or _DEFAULT_MESH
|
||||||
camera = args.cam or objects.get("camera") or _DEFAULT_CAMERA
|
camera = args.cam or objects.get("camera") or _DEFAULT_CAMERA
|
||||||
|
|
||||||
width = _as_int(pick(args.width, "width", 2048), "width")
|
width = _as_int(pick(args.width, "width", 2048), "width")
|
||||||
height = _as_int(pick(args.height, "height", 2048), "height")
|
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")
|
nx = _as_int(pick(args.nx, "nx", 80), "nx")
|
||||||
ny = _as_int(pick(args.ny, "ny", 45), "ny")
|
ny = _as_int(pick(args.ny, "ny", 45), "ny")
|
||||||
edge_sample_grid = _as_int(
|
edge_sample_grid = _as_int(
|
||||||
@@ -1062,6 +1494,8 @@ def _resolve_config(args):
|
|||||||
"camera": str(camera),
|
"camera": str(camera),
|
||||||
"width": width,
|
"width": width,
|
||||||
"height": height,
|
"height": height,
|
||||||
|
"vector_width_mm": vector_width_mm,
|
||||||
|
"vector_height_mm": vector_height_mm,
|
||||||
"nx": nx,
|
"nx": nx,
|
||||||
"ny": ny,
|
"ny": ny,
|
||||||
"color_r": float(color_r),
|
"color_r": float(color_r),
|
||||||
@@ -1149,20 +1583,21 @@ def main():
|
|||||||
obj = _prepare_mesh_object(config["mesh"])
|
obj = _prepare_mesh_object(config["mesh"])
|
||||||
cam = _require_object(config["camera"], "CAMERA", "Camera")
|
cam = _require_object(config["camera"], "CAMERA", "Camera")
|
||||||
targets = [
|
targets = [
|
||||||
(uv_map, output, _require_uv_layer(obj, uv_map))
|
(uv_map, artifacts, _require_uv_layer(obj, uv_map))
|
||||||
for uv_map, output in config["outputs"]
|
for uv_map, artifacts in config["outputs"]
|
||||||
]
|
]
|
||||||
except SceneError as exc:
|
except SceneError as exc:
|
||||||
print(f"ERROR: {exc}", file=sys.stderr)
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
for uv_map, output, _layer in targets:
|
for uv_map, artifacts, _layer in targets:
|
||||||
|
for format_name, output in artifacts.items():
|
||||||
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(
|
print(
|
||||||
f"ERROR: cannot create output directory for UV map {uv_map!r} "
|
f"ERROR: cannot create {format_name.upper()} output directory for "
|
||||||
f"({output.parent}): {exc}",
|
f"UV map {uv_map!r} ({output.parent}): {exc}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
@@ -1178,16 +1613,20 @@ def main():
|
|||||||
rng_state = np.random.get_state()
|
rng_state = np.random.get_state()
|
||||||
|
|
||||||
start_time = time.time()
|
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)
|
np.random.set_state(rng_state)
|
||||||
uv_centre = _uv_centre(uv_layer) if config["cross_enabled"] else None
|
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:
|
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(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,
|
uv_centre=uv_centre,
|
||||||
cross_length=config["cross_length"],
|
cross_length=config["cross_length"],
|
||||||
cross_thickness=config["cross_thickness"],
|
cross_thickness=config["cross_thickness"],
|
||||||
@@ -1203,17 +1642,25 @@ def main():
|
|||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
for format_name, output in artifacts.items():
|
||||||
if not output.is_file() or output.stat().st_size == 0:
|
if not output.is_file() or output.stat().st_size == 0:
|
||||||
print(
|
print(
|
||||||
f"ERROR: texture was not written for UV map {uv_map!r}: {output}",
|
f"ERROR: {format_name.upper()} texture was not written for "
|
||||||
|
f"UV map {uv_map!r}: {output}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print(f"Texture complete: {len(targets)} file(s)")
|
artifact_count = sum(len(artifacts) for _, artifacts, _ in targets)
|
||||||
for uv_map, output, _layer in targets:
|
print(f"Texture complete: {artifact_count} file(s)")
|
||||||
print(f" [{uv_map}] {output} ({output.stat().st_size} bytes)")
|
for uv_map, artifacts, _layer in targets:
|
||||||
print(f" {config['width']}x{config['height']} grid {config['nx']}x{config['ny']}")
|
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.")
|
print(f"Completed in {time.time() - start_time:.2f} seconds.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 612 KiB |
|
After Width: | Height: | Size: 683 KiB |
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 676 KiB |
|
After Width: | Height: | Size: 677 KiB |
@@ -25,8 +25,8 @@ 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 maps 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
|
# One PNG/SVG/PDF set is written per UV map below. All maps and formats share
|
||||||
# and the same patch colours, so they differ only by UV layout.
|
# the settings and 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
|
||||||
@@ -40,7 +40,10 @@ texture_patch:
|
|||||||
ny: 45
|
ny: 45
|
||||||
color_r: 0.05
|
color_r: 0.05
|
||||||
edge_sample_grid: 5
|
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:
|
cross:
|
||||||
enabled: true
|
enabled: true
|
||||||
length_px: 40
|
length_px: 40
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 131 B After Width: | Height: | Size: 635 KiB |
@@ -257,25 +257,31 @@ def cmd_texture(revision_dir: Path, args: argparse.Namespace) -> int:
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
missing = [
|
missing = [
|
||||||
(target, path)
|
(target, format_name, path)
|
||||||
for target, path in targets
|
for target, artifacts in targets
|
||||||
|
for format_name, path in artifacts.items()
|
||||||
if not path.is_file() or path.stat().st_size == 0
|
if not path.is_file() or path.stat().st_size == 0
|
||||||
]
|
]
|
||||||
if missing:
|
if missing:
|
||||||
for target, path in missing:
|
for target, format_name, path in missing:
|
||||||
print(
|
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}",
|
f"{target.uv_map!r} is missing or empty: {path}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 3
|
return 3
|
||||||
|
|
||||||
print(f"OK: wrote {len(targets)} colored patch texture(s) to {output_dir}")
|
artifact_count = sum(len(artifacts) for _, artifacts in targets)
|
||||||
for target, path in targets:
|
print(f"OK: wrote {artifact_count} colored patch artifact(s) to {output_dir}")
|
||||||
print(f" [{target.uv_map}] {path}")
|
for target, artifacts in targets:
|
||||||
|
print(f" [{target.uv_map}]")
|
||||||
|
for format_name, path in artifacts.items():
|
||||||
|
print(f" {format_name.upper()}: {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} "
|
||||||
|
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
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ 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
|
A revision may carry several UV maps on the sensing surface (for example
|
||||||
``produce`` and ``compute``). ``texture_patch.uv_outputs`` names one output PNG
|
``produce`` and ``compute``). ``texture_patch.uv_outputs`` names one artifact
|
||||||
per UV map; a single run renders all of them with the same camera-space patch
|
stem per UV map; a single run writes matching PNG, editable SVG, and vector PDF
|
||||||
colours, so the files differ only in how the unwrap arranges the patches.
|
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 unwrapping is deliberately outside this module: the headless run consumes the
|
||||||
UV maps already authored in the Blender GUI.
|
UV maps already authored in the Blender GUI.
|
||||||
@@ -28,10 +29,11 @@ from orisys_sensor_design.manifest import (
|
|||||||
load_yaml,
|
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_JOB_FILENAME = "_texture_job.json"
|
||||||
TEXTURE_RELATIVE_ROOT = Path("build") / "textures"
|
TEXTURE_RELATIVE_ROOT = Path("build") / "textures"
|
||||||
TEXTURE_MODE = "patches"
|
TEXTURE_MODE = "patches"
|
||||||
|
TEXTURE_ARTIFACT_FORMATS = ("png", "svg", "pdf")
|
||||||
|
|
||||||
# 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.
|
||||||
@@ -51,6 +53,10 @@ DEFAULT_TEXTURE_PATCH: dict[str, Any] = {
|
|||||||
"color_r": 0.05,
|
"color_r": 0.05,
|
||||||
"edge_sample_grid": 5,
|
"edge_sample_grid": 5,
|
||||||
"seed": None,
|
"seed": None,
|
||||||
|
"vector": {
|
||||||
|
"width_mm": 164.0,
|
||||||
|
"height_mm": 160.0,
|
||||||
|
},
|
||||||
"cross": {
|
"cross": {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"length_px": 40,
|
"length_px": 40,
|
||||||
@@ -71,10 +77,12 @@ _TEXTURE_PATCH_KEYS = frozenset(
|
|||||||
"color_r",
|
"color_r",
|
||||||
"edge_sample_grid",
|
"edge_sample_grid",
|
||||||
"seed",
|
"seed",
|
||||||
|
"vector",
|
||||||
"cross",
|
"cross",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_CROSS_KEYS = frozenset({"enabled", "length_px", "thickness_px", "color"})
|
_CROSS_KEYS = frozenset({"enabled", "length_px", "thickness_px", "color"})
|
||||||
|
_VECTOR_KEYS = frozenset({"width_mm", "height_mm"})
|
||||||
|
|
||||||
_MAX_TEXTURE_EDGE = 32768
|
_MAX_TEXTURE_EDGE = 32768
|
||||||
|
|
||||||
@@ -88,19 +96,37 @@ class TextureConfigError(ValueError):
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TextureOutputTarget:
|
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
|
uv_map: str
|
||||||
filename: 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:
|
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]:
|
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 {
|
return {
|
||||||
"uv_map": self.uv_map,
|
"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
|
color_r: float
|
||||||
edge_sample_grid: int
|
edge_sample_grid: int
|
||||||
seed: int | None
|
seed: int | None
|
||||||
|
vector_width_mm: float
|
||||||
|
vector_height_mm: float
|
||||||
cross_enabled: bool
|
cross_enabled: bool
|
||||||
cross_length_px: int
|
cross_length_px: int
|
||||||
cross_thickness_px: int
|
cross_thickness_px: int
|
||||||
@@ -137,6 +165,10 @@ class TexturePatchSettings:
|
|||||||
"color_r": self.color_r,
|
"color_r": self.color_r,
|
||||||
"edge_sample_grid": self.edge_sample_grid,
|
"edge_sample_grid": self.edge_sample_grid,
|
||||||
"seed": self.seed,
|
"seed": self.seed,
|
||||||
|
"vector": {
|
||||||
|
"width_mm": self.vector_width_mm,
|
||||||
|
"height_mm": self.vector_height_mm,
|
||||||
|
},
|
||||||
"cross": {
|
"cross": {
|
||||||
"enabled": self.cross_enabled,
|
"enabled": self.cross_enabled,
|
||||||
"length_px": self.cross_length_px,
|
"length_px": self.cross_length_px,
|
||||||
@@ -202,19 +234,24 @@ def merge_texture_patch(
|
|||||||
for key, value in block.items():
|
for key, value in block.items():
|
||||||
if key not in _TEXTURE_PATCH_KEYS:
|
if key not in _TEXTURE_PATCH_KEYS:
|
||||||
continue
|
continue
|
||||||
if key == "cross":
|
nested_keys = {
|
||||||
|
"cross": _CROSS_KEYS,
|
||||||
|
"vector": _VECTOR_KEYS,
|
||||||
|
}
|
||||||
|
if key in nested_keys:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
errors.append(f"{where}.cross: must be a mapping")
|
errors.append(f"{where}.{key}: must be a mapping")
|
||||||
continue
|
continue
|
||||||
unknown_cross = sorted(set(value) - _CROSS_KEYS)
|
allowed_nested = nested_keys[key]
|
||||||
if unknown_cross:
|
unknown_nested = sorted(set(value) - allowed_nested)
|
||||||
|
if unknown_nested:
|
||||||
errors.append(
|
errors.append(
|
||||||
f"{where}.cross: unknown key(s) {', '.join(unknown_cross)} "
|
f"{where}.{key}: unknown key(s) {', '.join(unknown_nested)} "
|
||||||
f"(allowed: {', '.join(sorted(_CROSS_KEYS))})"
|
f"(allowed: {', '.join(sorted(allowed_nested))})"
|
||||||
)
|
)
|
||||||
for cross_key, cross_value in value.items():
|
for nested_key, nested_value in value.items():
|
||||||
if cross_key in _CROSS_KEYS:
|
if nested_key in allowed_nested:
|
||||||
merged["cross"][cross_key] = cross_value
|
merged[key][nested_key] = nested_value
|
||||||
continue
|
continue
|
||||||
merged[key] = value
|
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):
|
if seed is not None and not _is_int(seed):
|
||||||
errors.append(f"seed: must be an integer or null (got {seed!r})")
|
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")
|
cross = block.get("cross")
|
||||||
if not isinstance(cross, dict):
|
if not isinstance(cross, dict):
|
||||||
errors.append("cross: must be a mapping")
|
errors.append("cross: must be a mapping")
|
||||||
@@ -337,6 +383,7 @@ def resolve_texture_settings(
|
|||||||
raise TextureConfigError("\n".join(errors))
|
raise TextureConfigError("\n".join(errors))
|
||||||
|
|
||||||
cross = block["cross"]
|
cross = block["cross"]
|
||||||
|
vector = block["vector"]
|
||||||
color = [float(channel) for channel in cross["color"]]
|
color = [float(channel) for channel in cross["color"]]
|
||||||
if len(color) == 3:
|
if len(color) == 3:
|
||||||
color.append(1.0)
|
color.append(1.0)
|
||||||
@@ -357,6 +404,8 @@ def resolve_texture_settings(
|
|||||||
color_r=float(block["color_r"]),
|
color_r=float(block["color_r"]),
|
||||||
edge_sample_grid=int(block["edge_sample_grid"]),
|
edge_sample_grid=int(block["edge_sample_grid"]),
|
||||||
seed=None if block["seed"] is None else int(block["seed"]),
|
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_enabled=bool(cross["enabled"]),
|
||||||
cross_length_px=int(cross["length_px"]),
|
cross_length_px=int(cross["length_px"]),
|
||||||
cross_thickness_px=int(cross["thickness_px"]),
|
cross_thickness_px=int(cross["thickness_px"]),
|
||||||
@@ -406,10 +455,13 @@ def texture_job_path(output_dir: Path) -> Path:
|
|||||||
def texture_output_paths(
|
def texture_output_paths(
|
||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
settings: TexturePatchSettings,
|
settings: TexturePatchSettings,
|
||||||
) -> list[tuple[TextureOutputTarget, Path]]:
|
) -> list[tuple[TextureOutputTarget, dict[str, Path]]]:
|
||||||
"""Pair every configured target with its absolute output path, in order."""
|
"""Pair every target with its ordered PNG/SVG/PDF artifact paths."""
|
||||||
resolved_dir = Path(output_dir).resolve()
|
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(
|
def build_texture_job(
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ DEFAULT_OUTPUTS = [
|
|||||||
("produce", "patch_texture_produce.png"),
|
("produce", "patch_texture_produce.png"),
|
||||||
("compute", "patch_texture_compute.png"),
|
("compute", "patch_texture_compute.png"),
|
||||||
]
|
]
|
||||||
|
ARTIFACT_FORMATS = ("png", "svg", "pdf")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@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)
|
targets = texture_output_paths(tmp_path, settings)
|
||||||
|
|
||||||
assert [(target.uv_map, path.name) for target, path in targets] == DEFAULT_OUTPUTS
|
assert [target.uv_map for target, _ in targets] == ["produce", "compute"]
|
||||||
assert all(path.parent == tmp_path.resolve() for _, path in targets)
|
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:
|
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")
|
target = TextureOutputTarget(uv_map="produce", filename="a.png")
|
||||||
entry = target.to_job_entry(tmp_path)
|
entry = target.to_job_entry(tmp_path)
|
||||||
assert entry["uv_map"] == "produce"
|
assert entry["uv_map"] == "produce"
|
||||||
assert Path(entry["output"]).is_absolute()
|
assert list(entry["artifacts"]) == list(ARTIFACT_FORMATS)
|
||||||
assert Path(entry["output"]).name == "a.png"
|
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
|
# ------------------------------------------------------- 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.nx, settings.ny) == (80, 45)
|
||||||
assert settings.color_r == pytest.approx(0.05)
|
assert settings.color_r == pytest.approx(0.05)
|
||||||
assert settings.edge_sample_grid == 5
|
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_enabled is True
|
||||||
assert settings.cross_length_px == 40
|
assert settings.cross_length_px == 40
|
||||||
assert settings.cross_thickness_px == 3
|
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,
|
"color_r": 0.25,
|
||||||
"edge_sample_grid": 3,
|
"edge_sample_grid": 3,
|
||||||
"seed": 11,
|
"seed": 11,
|
||||||
|
"vector": {"width_mm": 82.0, "height_mm": 80.0},
|
||||||
"cross": {"enabled": False, "length_px": 8, "color": [0.0, 1.0, 0.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.color_r == pytest.approx(0.25)
|
||||||
assert settings.edge_sample_grid == 3
|
assert settings.edge_sample_grid == 3
|
||||||
assert settings.seed == 11
|
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_enabled is False
|
||||||
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.
|
||||||
@@ -249,6 +268,15 @@ def test_unknown_cross_key_is_rejected(tmp_path: Path) -> None:
|
|||||||
assert "thickness" in str(excinfo.value)
|
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:
|
def test_texture_patch_must_be_a_mapping(tmp_path: Path) -> None:
|
||||||
revision = _write_revision(tmp_path, "texture_patch: patches\n")
|
revision = _write_revision(tmp_path, "texture_patch: patches\n")
|
||||||
with pytest.raises(TextureConfigError) as excinfo:
|
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"),
|
({"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"),
|
||||||
|
({"vector": {"width_mm": 0}}, "vector.width_mm"),
|
||||||
|
({"vector": {"height_mm": "large"}}, "vector.height_mm"),
|
||||||
({"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"),
|
||||||
@@ -328,10 +358,14 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
|
|||||||
}
|
}
|
||||||
assert "output" not in job
|
assert "output" not in job
|
||||||
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
|
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
|
||||||
assert [Path(entry["output"]).name for entry in job["outputs"]] == [
|
for entry, (_, png_name) in zip(job["outputs"], DEFAULT_OUTPUTS):
|
||||||
filename for _, filename in DEFAULT_OUTPUTS
|
stem = Path(png_name).stem
|
||||||
]
|
assert list(entry["artifacts"]) == list(ARTIFACT_FORMATS)
|
||||||
assert all(Path(entry["output"]).is_absolute() for entry in job["outputs"])
|
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["objects"]) == {"mesh", "camera"}
|
||||||
assert set(job["settings"]) == {
|
assert set(job["settings"]) == {
|
||||||
"width",
|
"width",
|
||||||
@@ -341,8 +375,13 @@ def test_job_round_trips_with_absolute_paths(revision_dir: Path, tmp_path: Path)
|
|||||||
"color_r",
|
"color_r",
|
||||||
"edge_sample_grid",
|
"edge_sample_grid",
|
||||||
"seed",
|
"seed",
|
||||||
|
"vector",
|
||||||
"cross",
|
"cross",
|
||||||
}
|
}
|
||||||
|
assert job["settings"]["vector"] == {
|
||||||
|
"width_mm": 164.0,
|
||||||
|
"height_mm": 160.0,
|
||||||
|
}
|
||||||
assert set(job["settings"]["cross"]) == {
|
assert set(job["settings"]["cross"]) == {
|
||||||
"enabled",
|
"enabled",
|
||||||
"length_px",
|
"length_px",
|
||||||
@@ -374,20 +413,29 @@ def test_write_job_creates_output_directory(revision_dir: Path, tmp_path: Path)
|
|||||||
def _fake_blender(
|
def _fake_blender(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
exit_code: int,
|
exit_code: int,
|
||||||
write_pngs: bool = True,
|
write_artifacts: bool = True,
|
||||||
skip_uv: str | None = None,
|
skip_uv: str | None = None,
|
||||||
|
skip_format: str | None = None,
|
||||||
):
|
):
|
||||||
calls: list[tuple[str, Path, list[str]]] = []
|
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]):
|
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_pngs:
|
if write_artifacts:
|
||||||
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"))
|
||||||
for entry in job["outputs"]:
|
for entry in job["outputs"]:
|
||||||
|
for format_name, raw_path in entry["artifacts"].items():
|
||||||
if skip_uv is not None and entry["uv_map"] == skip_uv:
|
if skip_uv is not None and entry["uv_map"] == skip_uv:
|
||||||
continue
|
continue
|
||||||
Path(entry["output"]).write_bytes(b"\x89PNG\r\n\x1a\n")
|
if skip_format is not None and format_name == skip_format:
|
||||||
|
continue
|
||||||
|
Path(raw_path).write_bytes(payloads[format_name])
|
||||||
return exit_code
|
return exit_code
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "_run_blender_script", fake_run)
|
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
|
assert job_path == out_dir.resolve() / TEXTURE_JOB_FILENAME
|
||||||
|
|
||||||
for _, filename in DEFAULT_OUTPUTS:
|
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(
|
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_pngs=False)
|
_fake_blender(monkeypatch, 9, write_artifacts=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
|
||||||
|
|
||||||
@@ -424,7 +474,7 @@ def test_cmd_texture_propagates_blender_failure(
|
|||||||
def test_cmd_texture_detects_both_textures_missing(
|
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_pngs=False)
|
_fake_blender(monkeypatch, 0, write_artifacts=False)
|
||||||
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
|
||||||
|
|
||||||
@@ -438,6 +488,15 @@ def test_cmd_texture_detects_partial_pair(
|
|||||||
assert cli.cmd_texture(revision_dir, namespace) == 3
|
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(
|
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:
|
||||||
@@ -475,7 +534,9 @@ def test_main_texture_parses_flags_into_job(
|
|||||||
assert job["settings"]["seed"] == 7
|
assert job["settings"]["seed"] == 7
|
||||||
assert job["settings"]["cross"]["enabled"] is False
|
assert job["settings"]["cross"]["enabled"] is False
|
||||||
for _, filename in DEFAULT_OUTPUTS:
|
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(
|
def test_main_texture_rejects_filename_flag(
|
||||||
|
|||||||