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
+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